Compare commits

...

9 Commits

Author SHA1 Message Date
Jean-Marc Collin
fe85ead916 Add rount_to_nearest to follow the dtemp threshold 2023-10-31 09:49:28 +00:00
Jean-Marc Collin
7d5ced55d3 Add regulation limitations tests 2023-10-31 09:30:07 +00:00
Jean-Marc Collin
9d099e3169 Add regulation limitations 2023-10-31 09:07:38 +00:00
Jean-Marc Collin
49377de248 Change regulation parameters 2023-10-30 16:43:21 +00:00
Jean-Marc Collin
6886dd6fb5 Implementation of regulation 2023-10-30 14:51:24 +00:00
Jean-Marc Collin
dde622e632 Implements regulation (tests ko) 2023-10-30 09:20:19 +00:00
Jean-Marc Collin
076d9eae24 Fix translations 2023-10-30 09:20:18 +00:00
Jean-Marc Collin
3356489f9d Add translations 2023-10-30 09:20:18 +00:00
Jean-Marc Collin
b323d676dc Algo implementation and tests 2023-10-30 09:20:18 +00:00
20 changed files with 1247 additions and 244 deletions

View File

@@ -198,7 +198,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._saved_hvac_mode = None self._saved_hvac_mode = None
self._window_call_cancel = None self._window_call_cancel = None
self._motion_call_cancel = None self._motion_call_cancel = None
self._cur_ext_temp = None
self._cur_temp = None self._cur_temp = None
self._ac_mode = None self._ac_mode = None
self._last_ext_temperature_mesure = None self._last_ext_temperature_mesure = None
@@ -1220,7 +1219,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
await self.async_control_heating(force=True) await self.async_control_heating(force=True)
async def _async_internal_set_temperature(self, temperature): async def _async_internal_set_temperature(self, temperature):
"""Set the target temperature and the target temperature of underlying climate if any""" """Set the target temperature and the target temperature of underlying climate if any
For testing purpose you can pass an event_timestamp.
"""
self._target_temp = temperature self._target_temp = temperature
return return
@@ -2248,7 +2249,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
await self.async_control_heating() await self.async_control_heating()
self.update_custom_attributes() self.update_custom_attributes()
#PR - Adding Window ByPass
async def service_set_window_bypass_state(self, window_bypass): async def service_set_window_bypass_state(self, window_bypass):
"""Called by a service call: """Called by a service call:
service: versatile_thermostat.set_window_bypass service: versatile_thermostat.set_window_bypass

View File

@@ -24,12 +24,12 @@ from .const import (
SERVICE_SET_PRESENCE, SERVICE_SET_PRESENCE,
SERVICE_SET_PRESET_TEMPERATURE, SERVICE_SET_PRESET_TEMPERATURE,
SERVICE_SET_SECURITY, SERVICE_SET_SECURITY,
#PR - Adding Window ByPass
SERVICE_SET_WINDOW_BYPASS, SERVICE_SET_WINDOW_BYPASS,
SERVICE_SET_AUTO_REGULATION_MODE,
CONF_THERMOSTAT_TYPE, CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_CLIMATE, CONF_THERMOSTAT_CLIMATE,
CONF_THERMOSTAT_VALVE, CONF_THERMOSTAT_VALVE
) )
from .thermostat_switch import ThermostatOverSwitch from .thermostat_switch import ThermostatOverSwitch
@@ -66,8 +66,6 @@ async def async_setup_entry(
entity = ThermostatOverValve(hass, unique_id, name, entry.data) entity = ThermostatOverValve(hass, unique_id, name, entry.data)
async_add_entities([entity], True) async_add_entities([entity], True)
# No more needed
# VersatileThermostat.add_entity(entry.entry_id, entity)
# Add services # Add services
platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform()
@@ -101,7 +99,6 @@ async def async_setup_entry(
"service_set_security", "service_set_security",
) )
#PR - Adding Window ByPass
platform.async_register_entity_service( platform.async_register_entity_service(
SERVICE_SET_WINDOW_BYPASS, SERVICE_SET_WINDOW_BYPASS,
{ {
@@ -110,3 +107,11 @@ async def async_setup_entry(
}, },
"service_set_window_bypass_state", "service_set_window_bypass_state",
) )
platform.async_register_entity_service(
SERVICE_SET_AUTO_REGULATION_MODE,
{
vol.Required("auto_regulation_mode"): vol.In(["None", "Light", "Medium", "Strong"]),
},
"service_set_auto_regulation_mode",
)

View File

@@ -1,18 +1,50 @@
""" Some usefull commons class """ """ Some usefull commons class """
import logging import logging
from datetime import timedelta from datetime import timedelta, datetime
from homeassistant.core import HomeAssistant, callback, Event from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
from homeassistant.helpers.event import async_track_state_change_event, async_call_later from homeassistant.helpers.event import async_track_state_change_event, async_call_later
from homeassistant.util import dt as dt_util
from .base_thermostat import BaseThermostat from .base_thermostat import BaseThermostat
from .const import DOMAIN, DEVICE_MANUFACTURER from .const import DOMAIN, DEVICE_MANUFACTURER
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def get_tz(hass: HomeAssistant):
"""Get the current timezone"""
return dt_util.get_time_zone(hass.config.time_zone)
class NowClass:
""" For testing purpose only"""
@staticmethod
def get_now(hass: HomeAssistant) -> datetime:
""" A test function to get the now.
For testing purpose this method can be overriden to get a specific
timestamp.
"""
return datetime.now( get_tz(hass))
def round_to_nearest(n:float, x: float)->float:
""" Round a number to the nearest x (which should be decimal but not null)
Example:
nombre1 = 3.2
nombre2 = 4.7
x = 0.3
nombre_arrondi1 = round_to_nearest(nombre1, x)
nombre_arrondi2 = round_to_nearest(nombre2, x)
print(nombre_arrondi1) # Output: 3.3
print(nombre_arrondi2) # Output: 4.6
"""
assert x > 0
return round(n * (1/x)) / (1/x)
class VersatileThermostatBaseEntity(Entity): class VersatileThermostatBaseEntity(Entity):
"""A base class for all entities""" """A base class for all entities"""
@@ -98,7 +130,7 @@ class VersatileThermostatBaseEntity(Entity):
await try_find_climate(None) await try_find_climate(None)
@callback @callback
async def async_my_climate_changed(self, event: Event): async def async_my_climate_changed(self, event: Event): # pylint: disable=unused-argument
"""Called when my climate have change """Called when my climate have change
This method aims to be overriden to take the status change This method aims to be overriden to take the status change
""" """

View File

@@ -1,6 +1,4 @@
# pylint: disable=line-too-long # pylint: disable=line-too-long, too-many-lines, invalid-name
# pylint: disable=too-many-lines
# pylint: disable=invalid-name
"""Config flow for Versatile Thermostat integration.""" """Config flow for Versatile Thermostat integration."""
from __future__ import annotations from __future__ import annotations
@@ -101,6 +99,11 @@ from .const import (
CONF_VALVE_2, CONF_VALVE_2,
CONF_VALVE_3, CONF_VALVE_3,
CONF_VALVE_4, CONF_VALVE_4,
CONF_AUTO_REGULATION_MODES,
CONF_AUTO_REGULATION_MODE,
CONF_AUTO_REGULATION_NONE,
CONF_AUTO_REGULATION_DTEMP,
CONF_AUTO_REGULATION_PERIOD_MIN,
UnknownEntity, UnknownEntity,
WindowOpenDetectionMethod, WindowOpenDetectionMethod,
) )
@@ -256,6 +259,16 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN), selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN),
), ),
vol.Optional(CONF_AC_MODE, default=False): cv.boolean, vol.Optional(CONF_AC_MODE, default=False): cv.boolean,
vol.Optional(
CONF_AUTO_REGULATION_MODE, default=CONF_AUTO_REGULATION_NONE
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_AUTO_REGULATION_MODES, translation_key="auto_regulation_mode"
)
),
vol.Optional(CONF_AUTO_REGULATION_DTEMP, default=0.5): vol.Coerce(float),
vol.Optional(CONF_AUTO_REGULATION_PERIOD_MIN, default=5): cv.positive_int
} }
) )

View File

@@ -1,3 +1,4 @@
# pylint: disable=line-too-long
"""Constants for the Versatile Thermostat integration.""" """Constants for the Versatile Thermostat integration."""
from enum import Enum from enum import Enum
@@ -82,6 +83,13 @@ CONF_VALVE = "valve_entity_id"
CONF_VALVE_2 = "valve_entity2_id" CONF_VALVE_2 = "valve_entity2_id"
CONF_VALVE_3 = "valve_entity3_id" CONF_VALVE_3 = "valve_entity3_id"
CONF_VALVE_4 = "valve_entity4_id" CONF_VALVE_4 = "valve_entity4_id"
CONF_AUTO_REGULATION_MODE= "auto_regulation_mode"
CONF_AUTO_REGULATION_NONE= "auto_regulation_none"
CONF_AUTO_REGULATION_LIGHT= "auto_regulation_light"
CONF_AUTO_REGULATION_MEDIUM= "auto_regulation_medium"
CONF_AUTO_REGULATION_STRONG= "auto_regulation_strong"
CONF_AUTO_REGULATION_DTEMP="auto_regulation_dtemp"
CONF_AUTO_REGULATION_PERIOD_MIN="auto_regulation_periode_min"
CONF_PRESETS = { CONF_PRESETS = {
p: f"{p}_temp" p: f"{p}_temp"
@@ -183,7 +191,9 @@ ALL_CONF = (
CONF_VALVE_2, CONF_VALVE_2,
CONF_VALVE_3, CONF_VALVE_3,
CONF_VALVE_4, CONF_VALVE_4,
CONF_AUTO_REGULATION_MODE,
CONF_AUTO_REGULATION_DTEMP,
CONF_AUTO_REGULATION_PERIOD_MIN
] ]
+ CONF_PRESETS_VALUES + CONF_PRESETS_VALUES
+ CONF_PRESETS_AWAY_VALUES + CONF_PRESETS_AWAY_VALUES
@@ -195,6 +205,8 @@ CONF_FUNCTIONS = [
PROPORTIONAL_FUNCTION_TPI, PROPORTIONAL_FUNCTION_TPI,
] ]
CONF_AUTO_REGULATION_MODES = [CONF_AUTO_REGULATION_NONE, CONF_AUTO_REGULATION_LIGHT, CONF_AUTO_REGULATION_MEDIUM, CONF_AUTO_REGULATION_STRONG]
CONF_THERMOSTAT_TYPES = [CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE, CONF_THERMOSTAT_VALVE] CONF_THERMOSTAT_TYPES = [CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE, CONF_THERMOSTAT_VALVE]
SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE
@@ -202,8 +214,8 @@ SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE
SERVICE_SET_PRESENCE = "set_presence" SERVICE_SET_PRESENCE = "set_presence"
SERVICE_SET_PRESET_TEMPERATURE = "set_preset_temperature" SERVICE_SET_PRESET_TEMPERATURE = "set_preset_temperature"
SERVICE_SET_SECURITY = "set_security" SERVICE_SET_SECURITY = "set_security"
#PR - Adding Window ByPass
SERVICE_SET_WINDOW_BYPASS = "set_window_bypass" SERVICE_SET_WINDOW_BYPASS = "set_window_bypass"
SERVICE_SET_AUTO_REGULATION_MODE = "set_auto_regulation_mode"
DEFAULT_SECURITY_MIN_ON_PERCENT = 0.5 DEFAULT_SECURITY_MIN_ON_PERCENT = 0.5
DEFAULT_SECURITY_DEFAULT_ON_PERCENT = 0.1 DEFAULT_SECURITY_DEFAULT_ON_PERCENT = 0.1
@@ -211,6 +223,33 @@ DEFAULT_SECURITY_DEFAULT_ON_PERCENT = 0.1
ATTR_TOTAL_ENERGY = "total_energy" ATTR_TOTAL_ENERGY = "total_energy"
ATTR_MEAN_POWER_CYCLE = "mean_cycle_power" ATTR_MEAN_POWER_CYCLE = "mean_cycle_power"
class RegulationParamLight:
""" Light parameters for regulation"""
kp:float = 0.2
ki:float = 0.05
k_ext:float = 0.1
offset_max:float = 2
stabilization_threshold:float = 0.1
accumulated_error_threshold:float = 20
class RegulationParamMedium:
""" Medium parameters for regulation"""
kp:float = 0.4
ki:float = 0.08
k_ext:float = 0.1
offset_max:float = 3
stabilization_threshold:float = 0.1
accumulated_error_threshold:float = 25
class RegulationParamStrong:
""" Strong parameters for regulation"""
kp:float = 0.6
ki:float = 0.1
k_ext:float = 0.2
offset_max:float = 4
stabilization_threshold:float = 0.1
accumulated_error_threshold:float = 30
class EventType(Enum): class EventType(Enum):
"""The event type that can be sent""" """The event type that can be sent"""

View File

@@ -0,0 +1,70 @@
# pylint: disable=line-too-long
""" The PI algorithm implementation """
import logging
_LOGGER = logging.getLogger(__name__)
class PITemperatureRegulator:
""" A class implementing a PI Algorithm
PI algorithms calculate a target temperature by adding an offset which is calculating as follow:
- offset = kp * error + ki * accumulated_error
To use it you must:
- instanciate the class and gives the algorithm parameters: kp, ki, offset_max, stabilization_threshold, accumulated_error_threshold
- call calculate_regulated_temperature with the internal and external temperature
- call set_target_temp when the target temperature change.
"""
def __init__(self, target_temp: float, kp: float, ki: float, k_ext: float, offset_max: float, stabilization_threshold: float, accumulated_error_threshold: float):
self.target_temp:float = target_temp
self.kp:float = kp # proportionnel gain
self.ki:float = ki # integral gain
self.k_ext:float = k_ext # exterior gain
self.offset_max:float = offset_max
self.stabilization_threshold:float = stabilization_threshold
self.accumulated_error:float = 0
self.accumulated_error_threshold:float = accumulated_error_threshold
def set_target_temp(self, target_temp):
""" Set the new target_temp"""
self.target_temp = target_temp
self.accumulated_error = 0
def calculate_regulated_temperature(self, internal_temp: float, external_temp:float): # pylint: disable=unused-argument
""" Calculate a new target_temp given some temperature"""
if internal_temp is None or external_temp is None:
_LOGGER.warning("Internal_temp or external_temp are not set. Regulation will be suspended")
return self.target_temp
# Calculate the error factor (P)
error = self.target_temp - internal_temp
# Calculate the sum of error (I)
self.accumulated_error += error
# Capping of the error
self.accumulated_error = min(self.accumulated_error_threshold, max(-self.accumulated_error_threshold, self.accumulated_error))
# Calculate the offset (proportionnel + intégral)
offset = self.kp * error + self.ki * self.accumulated_error
# Calculate the exterior offset
offset_ext = self.k_ext * (self.target_temp - external_temp)
# Capping of offset_ext
total_offset = offset + offset_ext
total_offset = min(self.offset_max, max(-self.offset_max, total_offset))
# If temperature is near the target_temp, reset the accumulated_error
if abs(error) < self.stabilization_threshold:
_LOGGER.debug("Stabilisation")
self.accumulated_error = 0
result = round(self.target_temp + total_offset, 1)
_LOGGER.debug("PITemperatureRegulator - Error: %.2f accumulated_error: %.2f offset: %.2f offset_ext: %.2f target_tem: %.1f regulatedTemp: %.1f",
error, self.accumulated_error, offset, offset_ext, self.target_temp, result)
return result

View File

@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorDeviceClass, SensorDeviceClass,
SensorStateClass, SensorStateClass,
UnitOfTemperature
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -24,6 +25,7 @@ from .const import (
PROPORTIONAL_FUNCTION_TPI, PROPORTIONAL_FUNCTION_TPI,
CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_VALVE, CONF_THERMOSTAT_VALVE,
CONF_THERMOSTAT_CLIMATE,
CONF_THERMOSTAT_TYPE, CONF_THERMOSTAT_TYPE,
) )
@@ -63,6 +65,9 @@ async def async_setup_entry(
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE: if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE:
entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data)) entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE:
entities.append(RegulatedTemperatureSensor(hass, unique_id, name, entry.data))
async_add_entities(entities, True) async_add_entities(entities, True)
@@ -470,3 +475,53 @@ class TemperatureSlopeSensor(VersatileThermostatBaseEntity, SensorEntity):
def suggested_display_precision(self) -> int | None: def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display.""" """Return the suggested number of decimal digits for display."""
return 2 return 2
class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a Energy sensor which exposes the energy"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the regulated temperature sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Regulated temperature"
self._attr_unique_id = f"{self._device_name}_regulated_temperature"
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
if math.isnan(self.my_climate.regulated_target_temp) or math.isinf(
self.my_climate.regulated_target_temp
):
raise ValueError(f"Sensor has illegal state {self.my_climate.regulated_target_temp}")
old_state = self._attr_native_value
self._attr_native_value = round(
self.my_climate.regulated_target_temp, self.suggested_display_precision
)
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:thermometer-auto"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.TEMPERATURE
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.MEASUREMENT
@property
def native_unit_of_measurement(self) -> str | None:
if not self.my_climate:
return UnitOfTemperature.CELSIUS
return self.my_climate.temperature_unit
@property
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 1

View File

@@ -137,4 +137,25 @@ set_window_bypass:
advanced: false advanced: false
default: true default: true
selector: selector:
boolean: boolean:
set_auto_regulation_mode:
name: Set Auto Regulation mode
description: Change the mode of self-regulation (only for VTherm over climate)
target:
entity:
integration: versatile_thermostat
fields:
auto_regulation_mode:
name: Auto regulation mode
description: Possible values
required: true
advanced: false
default: true
selector:
select:
options:
- "None"
- "Light"
- "Medium"
- "Strong"

View File

@@ -38,7 +38,10 @@
"valve_entity_id": "1rst valve number", "valve_entity_id": "1rst valve number",
"valve_entity2_id": "2nd valve number", "valve_entity2_id": "2nd valve number",
"valve_entity3_id": "3rd valve number", "valve_entity3_id": "3rd valve number",
"valve_entity4_id": "4th valve number" "valve_entity4_id": "4th valve number",
"auto_regulation_mode": "Self-regulation",
"auto_regulation_dtemp": "Regulation threshold",
"auto_regulation_periode_min": "Regulation minimal period"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Mandatory heater entity id", "heater_entity_id": "Mandatory heater entity id",
@@ -54,7 +57,10 @@
"valve_entity_id": "1rst valve number entity id", "valve_entity_id": "1rst valve number entity id",
"valve_entity2_id": "2nd valve number entity id", "valve_entity2_id": "2nd valve number entity id",
"valve_entity3_id": "3rd valve number entity id", "valve_entity3_id": "3rd valve number entity id",
"valve_entity4_id": "4th valve number entity id" "valve_entity4_id": "4th valve number entity id",
"auto_regulation_mode": "Auto adjustment of the target temperature",
"auto_regulation_dtemp": "The threshold in ° under which the temperature change will not be send",
"auto_regulation_periode_min": "Duration in minutes between two regulation update"
} }
}, },
"tpi": { "tpi": {
@@ -199,7 +205,10 @@
"valve_entity_id": "1rst valve number", "valve_entity_id": "1rst valve number",
"valve_entity2_id": "2nd valve number", "valve_entity2_id": "2nd valve number",
"valve_entity3_id": "3rd valve number", "valve_entity3_id": "3rd valve number",
"valve_entity4_id": "4th valve number" "valve_entity4_id": "4th valve number",
"auto_regulation_mode": "Self-regulation",
"auto_regulation_dtemp": "Regulation threshold",
"auto_regulation_periode_min": "Regulation minimal period"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Mandatory heater entity id", "heater_entity_id": "Mandatory heater entity id",
@@ -215,7 +224,10 @@
"valve_entity_id": "1rst valve number entity id", "valve_entity_id": "1rst valve number entity id",
"valve_entity2_id": "2nd valve number entity id", "valve_entity2_id": "2nd valve number entity id",
"valve_entity3_id": "3rd valve number entity id", "valve_entity3_id": "3rd valve number entity id",
"valve_entity4_id": "4th valve number entity id" "valve_entity4_id": "4th valve number entity id",
"auto_regulation_mode": "Auto adjustment of the target temperature",
"auto_regulation_dtemp": "The threshold in ° under which the temperature change will not be send",
"auto_regulation_periode_min": "Duration in minutes between two regulation update"
} }
}, },
"tpi": { "tpi": {
@@ -329,6 +341,14 @@
"thermostat_over_climate": "Thermostat over a climate", "thermostat_over_climate": "Thermostat over a climate",
"thermostat_over_valve": "Thermostat over a valve" "thermostat_over_valve": "Thermostat over a valve"
} }
},
"auto_regulation_mode": {
"options": {
"auto_regulation_strong": "Strong",
"auto_regulation_medium": "Medium",
"auto_regulation_light": "Light",
"auto_regulation_none": "No auto-regulation"
}
} }
}, },
"entity": { "entity": {

View File

@@ -1,16 +1,34 @@
# pylint: disable=line-too-long # pylint: disable=line-too-long
""" A climate over switch classe """ """ A climate over switch classe """
import logging import logging
from datetime import timedelta from datetime import timedelta, datetime
from homeassistant.core import callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval
from homeassistant.components.climate import HVACAction, HVACMode from homeassistant.components.climate import HVACAction, HVACMode
from .commons import NowClass, round_to_nearest
from .base_thermostat import BaseThermostat from .base_thermostat import BaseThermostat
from .pi_algorithm import PITemperatureRegulator
from .const import CONF_CLIMATE, CONF_CLIMATE_2, CONF_CLIMATE_3, CONF_CLIMATE_4, overrides from .const import (
overrides,
CONF_CLIMATE,
CONF_CLIMATE_2,
CONF_CLIMATE_3,
CONF_CLIMATE_4,
CONF_AUTO_REGULATION_MODE,
CONF_AUTO_REGULATION_NONE,
CONF_AUTO_REGULATION_LIGHT,
CONF_AUTO_REGULATION_MEDIUM,
CONF_AUTO_REGULATION_STRONG,
CONF_AUTO_REGULATION_DTEMP,
CONF_AUTO_REGULATION_PERIOD_MIN,
RegulationParamLight,
RegulationParamMedium,
RegulationParamStrong
)
from .underlyings import UnderlyingClimate from .underlyings import UnderlyingClimate
@@ -18,17 +36,25 @@ _LOGGER = logging.getLogger(__name__)
class ThermostatOverClimate(BaseThermostat): class ThermostatOverClimate(BaseThermostat):
"""Representation of a base class for a Versatile Thermostat over a climate""" """Representation of a base class for a Versatile Thermostat over a climate"""
_auto_regulation_mode:str = None
_regulation_algo = None
_regulated_target_temp: float = None
_auto_regulation_dtemp: float = None
_auto_regulation_period_min: int = None
_last_regulation_change: datetime = None
_entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union(frozenset( _entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union(frozenset(
{ {
"is_over_climate", "start_hvac_action_date", "underlying_climate_0", "underlying_climate_1", "is_over_climate", "start_hvac_action_date", "underlying_climate_0", "underlying_climate_1",
"underlying_climate_2", "underlying_climate_3" "underlying_climate_2", "underlying_climate_3", "regulation_accumulated_error"
})) }))
# Useless for now def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
# def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: """Initialize the thermostat over switch."""
# """Initialize the thermostat over switch.""" # super.__init__ calls post_init at the end. So it must be called after regulation initialization
# super().__init__(hass, unique_id, name, entry_infos) super().__init__(hass, unique_id, name, entry_infos)
self._regulated_target_temp = self.target_temperature
self._last_regulation_change = NowClass.get_now(hass)
@property @property
def is_over_climate(self) -> bool: def is_over_climate(self) -> bool:
@@ -55,188 +81,42 @@ class ThermostatOverClimate(BaseThermostat):
return HVACAction.IDLE return HVACAction.IDLE
return HVACAction.OFF return HVACAction.OFF
@property
def hvac_modes(self):
"""List of available operation modes."""
if self.underlying_entity(0):
return self.underlying_entity(0).hvac_modes
else:
return super.hvac_modes
@property
def mean_cycle_power(self) -> float | None:
"""Returns the mean power consumption during the cycle"""
return None
@property
def fan_mode(self) -> str | None:
"""Return the fan setting.
Requires ClimateEntityFeature.FAN_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).fan_mode
return None
@property
def fan_modes(self) -> list[str] | None:
"""Return the list of available fan modes.
Requires ClimateEntityFeature.FAN_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).fan_modes
return []
@property
def swing_mode(self) -> str | None:
"""Return the swing setting.
Requires ClimateEntityFeature.SWING_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).swing_mode
return None
@property
def swing_modes(self) -> list[str] | None:
"""Return the list of available swing modes.
Requires ClimateEntityFeature.SWING_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).swing_modes
return None
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
if self.underlying_entity(0):
return self.underlying_entity(0).temperature_unit
return self._unit
@property
def supported_features(self):
"""Return the list of supported features."""
if self.underlying_entity(0):
return self.underlying_entity(0).supported_features | self._support_flags
return self._support_flags
@property
def target_temperature_step(self) -> float | None:
"""Return the supported step of target temperature."""
if self.underlying_entity(0):
return self.underlying_entity(0).target_temperature_step
return None
@property
def target_temperature_high(self) -> float | None:
"""Return the highbound target temperature we try to reach.
Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).target_temperature_high
return None
@property
def target_temperature_low(self) -> float | None:
"""Return the lowbound target temperature we try to reach.
Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).target_temperature_low
return None
@property
def is_aux_heat(self) -> bool | None:
"""Return true if aux heater.
Requires ClimateEntityFeature.AUX_HEAT.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).is_aux_heat
return None
@overrides
def turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
if self.underlying_entity(0):
return self.underlying_entity(0).turn_aux_heat_on()
raise NotImplementedError()
@overrides
async def async_turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
for under in self._underlyings:
await under.async_turn_aux_heat_on()
@overrides
def turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
for under in self._underlyings:
return under.turn_aux_heat_off()
@overrides
async def async_turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
for under in self._underlyings:
await under.async_turn_aux_heat_off()
@overrides
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:
return
for under in self._underlyings:
await under.set_fan_mode(fan_mode)
self._fan_mode = fan_mode
self.async_write_ha_state()
@overrides
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:
return
for under in self._underlyings:
await under.set_humidity(humidity)
self._humidity = humidity
self.async_write_ha_state()
@overrides
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:
return
for under in self._underlyings:
await under.set_swing_mode(swing_mode)
self._swing_mode = swing_mode
self.async_write_ha_state()
@overrides @overrides
async def _async_internal_set_temperature(self, temperature): async def _async_internal_set_temperature(self, temperature):
"""Set the target temperature and the target temperature of underlying climate if any""" """Set the target temperature and the target temperature of underlying climate if any"""
await super()._async_internal_set_temperature(temperature) await super()._async_internal_set_temperature(temperature)
self._regulation_algo.set_target_temp(self.target_temperature)
await self._send_regulated_temperature()
async def _send_regulated_temperature(self):
""" Sends the regulated temperature to all underlying """
if not self._regulated_target_temp:
self._regulated_target_temp = self.target_temperature
new_regulated_temp = round_to_nearest(
self._regulation_algo.calculate_regulated_temperature(self.current_temperature, self._cur_ext_temp),
self._auto_regulation_dtemp)
dtemp = new_regulated_temp - self._regulated_target_temp
if abs(dtemp) < self._auto_regulation_dtemp:
_LOGGER.debug("%s - dtemp (%.1f) is < %.1f -> forget the regulation send", self, dtemp, self._auto_regulation_dtemp)
return
now:datetime = NowClass.get_now(self._hass)
period = float((now - self._last_regulation_change).total_seconds()) / 60.
if period < self._auto_regulation_period_min:
_LOGGER.debug("%s - period (%.1f) is < %.0f -> forget the regulation send", self, period, self._auto_regulation_period_min)
return
self._regulated_target_temp = new_regulated_temp
_LOGGER.info("%s - Regulated temp have changed to %.1f. Resend it to underlyings", self, new_regulated_temp)
self._last_regulation_change = now
for under in self._underlyings: for under in self._underlyings:
await under.set_temperature( await under.set_temperature(
temperature, self._attr_max_temp, self._attr_min_temp self.regulated_target_temp, self._attr_max_temp, self._attr_min_temp
) )
@overrides @overrides
@@ -259,6 +139,48 @@ class ThermostatOverClimate(BaseThermostat):
) )
) )
self.choose_auto_regulation_mode(
entry_infos.get(CONF_AUTO_REGULATION_MODE) if entry_infos.get(CONF_AUTO_REGULATION_MODE) is not None else CONF_AUTO_REGULATION_NONE
)
self._auto_regulation_dtemp = entry_infos.get(CONF_AUTO_REGULATION_DTEMP) if entry_infos.get(CONF_AUTO_REGULATION_DTEMP) is not None else 0.5
self._auto_regulation_period_min = entry_infos.get(CONF_AUTO_REGULATION_PERIOD_MIN) if entry_infos.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None else 5
def choose_auto_regulation_mode(self, auto_regulation_mode):
""" Choose or change the regulation mode"""
self._auto_regulation_mode = auto_regulation_mode
if self._auto_regulation_mode == CONF_AUTO_REGULATION_LIGHT:
self._regulation_algo = PITemperatureRegulator(
self.target_temperature,
RegulationParamLight.kp,
RegulationParamLight.ki,
RegulationParamLight.k_ext,
RegulationParamLight.offset_max,
RegulationParamLight.stabilization_threshold,
RegulationParamLight.accumulated_error_threshold)
elif self._auto_regulation_mode == CONF_AUTO_REGULATION_MEDIUM:
self._regulation_algo = PITemperatureRegulator(
self.target_temperature,
RegulationParamMedium.kp,
RegulationParamMedium.ki,
RegulationParamMedium.k_ext,
RegulationParamMedium.offset_max,
RegulationParamMedium.stabilization_threshold,
RegulationParamMedium.accumulated_error_threshold)
elif self._auto_regulation_mode == CONF_AUTO_REGULATION_STRONG:
self._regulation_algo = PITemperatureRegulator(
self.target_temperature,
RegulationParamStrong.kp,
RegulationParamStrong.ki,
RegulationParamStrong.k_ext,
RegulationParamStrong.offset_max,
RegulationParamStrong.stabilization_threshold,
RegulationParamStrong.accumulated_error_threshold)
else:
# A default empty algo (which does nothing)
self._regulation_algo = PITemperatureRegulator(
self.target_temperature, 0, 0, 0, 0, 0.1, 0)
@overrides @overrides
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Run when entity about to be added.""" """Run when entity about to be added."""
@@ -304,6 +226,10 @@ class ThermostatOverClimate(BaseThermostat):
self._underlyings[3].entity_id if len(self._underlyings) > 3 else None self._underlyings[3].entity_id if len(self._underlyings) > 3 else None
) )
if self.is_regulated:
self._attr_extra_state_attributes["regulated_target_temp"] = self._regulated_target_temp
self._attr_extra_state_attributes["regulation_accumulated_error"] = self._regulation_algo.accumulated_error
self.async_write_ha_state() self.async_write_ha_state()
_LOGGER.debug( _LOGGER.debug(
"%s - Calling update_custom_attributes: %s", "%s - Calling update_custom_attributes: %s",
@@ -509,7 +435,8 @@ class ThermostatOverClimate(BaseThermostat):
new_state.attributes, new_state.attributes,
) )
if ( if (
self.is_over_climate # we do not change target temperature on regulated VTherm
not self.is_regulated
and new_state.attributes and new_state.attributes
and (new_target_temp := new_state.attributes.get("temperature")) and (new_target_temp := new_state.attributes.get("temperature"))
and new_target_temp != self.target_temperature and new_target_temp != self.target_temperature
@@ -523,3 +450,222 @@ class ThermostatOverClimate(BaseThermostat):
changes = True changes = True
await end_climate_changed(changes) await end_climate_changed(changes)
@overrides
async def async_control_heating(self, force=False, _=None):
"""The main function used to run the calculation at each cycle"""
ret = await super().async_control_heating(force, _)
await self._send_regulated_temperature()
return ret
@property
def auto_regulation_mode(self):
""" Get the regulation mode """
return self._auto_regulation_mode
@property
def regulated_target_temp(self):
""" Get the regulated target temperature """
return self._regulated_target_temp
@property
def is_regulated(self):
""" Check if the ThermostatOverClimate is regulated """
return self.auto_regulation_mode != CONF_AUTO_REGULATION_NONE
@property
def hvac_modes(self):
"""List of available operation modes."""
if self.underlying_entity(0):
return self.underlying_entity(0).hvac_modes
else:
return super.hvac_modes
@property
def mean_cycle_power(self) -> float | None:
"""Returns the mean power consumption during the cycle"""
return None
@property
def fan_mode(self) -> str | None:
"""Return the fan setting.
Requires ClimateEntityFeature.FAN_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).fan_mode
return None
@property
def fan_modes(self) -> list[str] | None:
"""Return the list of available fan modes.
Requires ClimateEntityFeature.FAN_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).fan_modes
return []
@property
def swing_mode(self) -> str | None:
"""Return the swing setting.
Requires ClimateEntityFeature.SWING_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).swing_mode
return None
@property
def swing_modes(self) -> list[str] | None:
"""Return the list of available swing modes.
Requires ClimateEntityFeature.SWING_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).swing_modes
return None
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
if self.underlying_entity(0):
return self.underlying_entity(0).temperature_unit
return self._unit
@property
def supported_features(self):
"""Return the list of supported features."""
if self.underlying_entity(0):
return self.underlying_entity(0).supported_features | self._support_flags
return self._support_flags
@property
def target_temperature_step(self) -> float | None:
"""Return the supported step of target temperature."""
if self.underlying_entity(0):
return self.underlying_entity(0).target_temperature_step
return None
@property
def target_temperature_high(self) -> float | None:
"""Return the highbound target temperature we try to reach.
Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).target_temperature_high
return None
@property
def target_temperature_low(self) -> float | None:
"""Return the lowbound target temperature we try to reach.
Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).target_temperature_low
return None
@property
def is_aux_heat(self) -> bool | None:
"""Return true if aux heater.
Requires ClimateEntityFeature.AUX_HEAT.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).is_aux_heat
return None
@overrides
def turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
if self.underlying_entity(0):
return self.underlying_entity(0).turn_aux_heat_on()
raise NotImplementedError()
@overrides
async def async_turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
for under in self._underlyings:
await under.async_turn_aux_heat_on()
@overrides
def turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
for under in self._underlyings:
return under.turn_aux_heat_off()
@overrides
async def async_turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
for under in self._underlyings:
await under.async_turn_aux_heat_off()
@overrides
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:
return
for under in self._underlyings:
await under.set_fan_mode(fan_mode)
self._fan_mode = fan_mode
self.async_write_ha_state()
@overrides
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:
return
for under in self._underlyings:
await under.set_humidity(humidity)
self._humidity = humidity
self.async_write_ha_state()
@overrides
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:
return
for under in self._underlyings:
await under.set_swing_mode(swing_mode)
self._swing_mode = swing_mode
self.async_write_ha_state()
async def service_set_auto_regulation_mode(self, auto_regulation_mode):
"""Called by a service call:
service: versatile_thermostat.set_auto_regulation_mode
data:
auto_regulation_mode: [None | Light | Medium | Strong]
target:
entity_id: climate.thermostat_1
"""
_LOGGER.info("%s - Calling service_set_auto_regulation_mode, auto_regulation_mode: %s", self, auto_regulation_mode)
if auto_regulation_mode == "None":
self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_NONE)
elif auto_regulation_mode == "Light":
self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_LIGHT)
elif auto_regulation_mode == "Medium":
self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_MEDIUM)
elif auto_regulation_mode == "Strong":
self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_STRONG)
await self._send_regulated_temperature()
self.update_custom_attributes()

View File

@@ -38,7 +38,10 @@
"valve_entity_id": "1rst valve number", "valve_entity_id": "1rst valve number",
"valve_entity2_id": "2nd valve number", "valve_entity2_id": "2nd valve number",
"valve_entity3_id": "3rd valve number", "valve_entity3_id": "3rd valve number",
"valve_entity4_id": "4th valve number" "valve_entity4_id": "4th valve number",
"auto_regulation_mode": "Self-regulation",
"auto_regulation_dtemp": "Regulation threshold",
"auto_regulation_periode_min": "Regulation minimal period"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Mandatory heater entity id", "heater_entity_id": "Mandatory heater entity id",
@@ -54,7 +57,10 @@
"valve_entity_id": "1rst valve number entity id", "valve_entity_id": "1rst valve number entity id",
"valve_entity2_id": "2nd valve number entity id", "valve_entity2_id": "2nd valve number entity id",
"valve_entity3_id": "3rd valve number entity id", "valve_entity3_id": "3rd valve number entity id",
"valve_entity4_id": "4th valve number entity id" "valve_entity4_id": "4th valve number entity id",
"auto_regulation_mode": "Auto adjustment of the target temperature",
"auto_regulation_dtemp": "The threshold in ° under which the temperature change will not be send",
"auto_regulation_periode_min": "Duration in minutes between two regulation update"
} }
}, },
"tpi": { "tpi": {
@@ -199,7 +205,10 @@
"valve_entity_id": "1rst valve number", "valve_entity_id": "1rst valve number",
"valve_entity2_id": "2nd valve number", "valve_entity2_id": "2nd valve number",
"valve_entity3_id": "3rd valve number", "valve_entity3_id": "3rd valve number",
"valve_entity4_id": "4th valve number" "valve_entity4_id": "4th valve number",
"auto_regulation_mode": "Self-regulation",
"auto_regulation_dtemp": "Regulation threshold",
"auto_regulation_periode_min": "Regulation minimal period"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Mandatory heater entity id", "heater_entity_id": "Mandatory heater entity id",
@@ -215,7 +224,10 @@
"valve_entity_id": "1rst valve number entity id", "valve_entity_id": "1rst valve number entity id",
"valve_entity2_id": "2nd valve number entity id", "valve_entity2_id": "2nd valve number entity id",
"valve_entity3_id": "3rd valve number entity id", "valve_entity3_id": "3rd valve number entity id",
"valve_entity4_id": "4th valve number entity id" "valve_entity4_id": "4th valve number entity id",
"auto_regulation_mode": "Auto adjustment of the target temperature",
"auto_regulation_dtemp": "The threshold in ° under which the temperature change will not be send",
"auto_regulation_periode_min": "Duration in minutes between two regulation update"
} }
}, },
"tpi": { "tpi": {
@@ -329,6 +341,14 @@
"thermostat_over_climate": "Thermostat over a climate", "thermostat_over_climate": "Thermostat over a climate",
"thermostat_over_valve": "Thermostat over a valve" "thermostat_over_valve": "Thermostat over a valve"
} }
},
"auto_regulation_mode": {
"options": {
"auto_regulation_strong": "Strong",
"auto_regulation_medium": "Medium",
"auto_regulation_light": "Light",
"auto_regulation_none": "No auto-regulation"
}
} }
}, },
"entity": { "entity": {

View File

@@ -38,7 +38,10 @@
"valve_entity_id": "1ère valve number", "valve_entity_id": "1ère valve number",
"valve_entity2_id": "2ème valve number", "valve_entity2_id": "2ème valve number",
"valve_entity3_id": "3ème valve number", "valve_entity3_id": "3ème valve number",
"valve_entity4_id": "4ème valve number" "valve_entity4_id": "4ème valve number",
"auto_regulation_mode": "Auto-régulation",
"auto_regulation_dtemp": "Seuil de régulation",
"auto_regulation_periode_min": "Période minimale de régulation"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Entity id du 1er radiateur obligatoire", "heater_entity_id": "Entity id du 1er radiateur obligatoire",
@@ -54,7 +57,10 @@
"valve_entity_id": "Entity id de la 1ère valve", "valve_entity_id": "Entity id de la 1ère valve",
"valve_entity2_id": "Entity id de la 2ème valve", "valve_entity2_id": "Entity id de la 2ème valve",
"valve_entity3_id": "Entity id de la 3ème valve", "valve_entity3_id": "Entity id de la 3ème valve",
"valve_entity4_id": "Entity id de la 4ème valve" "valve_entity4_id": "Entity id de la 4ème valve",
"auto_regulation_mode": "Ajustement automatique de la température cible",
"auto_regulation_dtemp": "Le seuil en ° au-dessous duquel la régulation ne sera pas envoyée",
"auto_regulation_periode_min": "La durée en minutes entre deux mise à jour faites par la régulation"
} }
}, },
"tpi": { "tpi": {
@@ -197,10 +203,13 @@
"climate_entity3_id": "3ème thermostat sous-jacent", "climate_entity3_id": "3ème thermostat sous-jacent",
"climate_entity4_id": "4ème thermostat sous-jacent", "climate_entity4_id": "4ème thermostat sous-jacent",
"ac_mode": "AC mode ?", "ac_mode": "AC mode ?",
"valve_entity_id": "1ère valve number", "valve_entity_id": "1ère valve",
"valve_entity2_id": "2ème valve number", "valve_entity2_id": "2ème valve",
"valve_entity3_id": "3ème valve number", "valve_entity3_id": "3ème valve",
"valve_entity4_id": "4ème valve number" "valve_entity4_id": "4ème valve",
"auto_regulation_mode": "Auto-regulation",
"auto_regulation_dtemp": "Seuil de régulation",
"auto_regulation_periode_min": "Période minimale de régulation"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Entity id du 1er radiateur obligatoire", "heater_entity_id": "Entity id du 1er radiateur obligatoire",
@@ -216,7 +225,10 @@
"valve_entity_id": "Entity id de la 1ère valve", "valve_entity_id": "Entity id de la 1ère valve",
"valve_entity2_id": "Entity id de la 2ème valve", "valve_entity2_id": "Entity id de la 2ème valve",
"valve_entity3_id": "Entity id de la 3ème valve", "valve_entity3_id": "Entity id de la 3ème valve",
"valve_entity4_id": "Entity id de la 4ème valve" "valve_entity4_id": "Entity id de la 4ème valve",
"auto_regulation_mode": "Ajustement automatique de la consigne",
"auto_regulation_dtemp": "Le seuil en ° au-dessous duquel la régulation ne sera pas envoyée",
"auto_regulation_periode_min": "La durée en minutes entre deux mise à jour faites par la régulation"
} }
}, },
"tpi": { "tpi": {
@@ -330,6 +342,14 @@
"thermostat_over_climate": "Thermostat sur un autre thermostat", "thermostat_over_climate": "Thermostat sur un autre thermostat",
"thermostat_over_valve": "Thermostat sur une valve" "thermostat_over_valve": "Thermostat sur une valve"
} }
},
"auto_regulation_mode": {
"options": {
"auto_regulation_strong": "Forte",
"auto_regulation_medium": "Moyenne",
"auto_regulation_light": "Légère",
"auto_regulation_none": "Aucune"
}
} }
}, },
"entity": { "entity": {

View File

@@ -38,7 +38,8 @@
"valve_entity_id": "Primo valvola numero", "valve_entity_id": "Primo valvola numero",
"valve_entity2_id": "Secondo valvola numero", "valve_entity2_id": "Secondo valvola numero",
"valve_entity3_id": "Terzo valvola numero", "valve_entity3_id": "Terzo valvola numero",
"valve_entity4_id": "Quarto valvola numero" "valve_entity4_id": "Quarto valvola numero",
"auto_regulation_mode": "Autoregolamentazione"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Entity id obbligatoria del primo riscaldatore", "heater_entity_id": "Entity id obbligatoria del primo riscaldatore",
@@ -54,7 +55,8 @@
"valve_entity_id": "Entity id del primo valvola numero", "valve_entity_id": "Entity id del primo valvola numero",
"valve_entity2_id": "Entity id del secondo valvola numero", "valve_entity2_id": "Entity id del secondo valvola numero",
"valve_entity3_id": "Entity id del terzo valvola numero", "valve_entity3_id": "Entity id del terzo valvola numero",
"valve_entity4_id": "Entity id del quarto valvola numero" "valve_entity4_id": "Entity id del quarto valvola numero",
"auto_regulation_mode": "Regolazione automatica della temperatura target"
} }
}, },
"tpi": { "tpi": {
@@ -192,7 +194,8 @@
"valve_entity_id": "Primo valvola numero", "valve_entity_id": "Primo valvola numero",
"valve_entity2_id": "Secondo valvola numero", "valve_entity2_id": "Secondo valvola numero",
"valve_entity3_id": "Terzo valvola numero", "valve_entity3_id": "Terzo valvola numero",
"valve_entity4_id": "Quarto valvola numero" "valve_entity4_id": "Quarto valvola numero",
"auto_regulation_mode": "Autoregolamentazione"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Entity id obbligatoria del primo riscaldatore", "heater_entity_id": "Entity id obbligatoria del primo riscaldatore",
@@ -208,7 +211,8 @@
"valve_entity_id": "Entity id del primo valvola numero", "valve_entity_id": "Entity id del primo valvola numero",
"valve_entity2_id": "Entity id del secondo valvola numero", "valve_entity2_id": "Entity id del secondo valvola numero",
"valve_entity3_id": "Entity id del terzo valvola numero", "valve_entity3_id": "Entity id del terzo valvola numero",
"valve_entity4_id": "Entity id del quarto valvola numero" "valve_entity4_id": "Entity id del quarto valvola numero",
"auto_regulation_mode": "Autoregolamentazione"
} }
}, },
"tpi": { "tpi": {
@@ -315,6 +319,14 @@
"thermostat_over_climate": "Termostato sopra un altro termostato", "thermostat_over_climate": "Termostato sopra un altro termostato",
"thermostat_over_valve": "Thermostato su una valvola" "thermostat_over_valve": "Thermostato su una valvola"
} }
},
"auto_regulation_mode": {
"options": {
"auto_regulation_strong": "Forte",
"auto_regulation_medium": "Media",
"auto_regulation_light": "Leggera",
"auto_regulation_none": "Nessuna autoregolamentazione"
}
} }
}, },
"entity": { "entity": {

View File

@@ -10,7 +10,6 @@ from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State
from homeassistant.const import UnitOfTemperature, STATE_ON, STATE_OFF, ATTR_TEMPERATURE from homeassistant.const import UnitOfTemperature, STATE_ON, STATE_OFF, ATTR_TEMPERATURE
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.util import dt as dt_util
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.components.climate import ( from homeassistant.components.climate import (
ClimateEntity, ClimateEntity,
@@ -25,6 +24,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.versatile_thermostat.base_thermostat import BaseThermostat from custom_components.versatile_thermostat.base_thermostat import BaseThermostat
from custom_components.versatile_thermostat.const import * # pylint: disable=wildcard-import, unused-wildcard-import from custom_components.versatile_thermostat.const import * # pylint: disable=wildcard-import, unused-wildcard-import
from custom_components.versatile_thermostat.underlyings import * # pylint: disable=wildcard-import, unused-wildcard-import from custom_components.versatile_thermostat.underlyings import * # pylint: disable=wildcard-import, unused-wildcard-import
from custom_components.versatile_thermostat.commons import get_tz, NowClass # pylint: disable=unused-import
from .const import ( # pylint: disable=unused-import from .const import ( # pylint: disable=unused-import
MOCK_TH_OVER_SWITCH_USER_CONFIG, MOCK_TH_OVER_SWITCH_USER_CONFIG,
@@ -34,6 +34,8 @@ from .const import ( # pylint: disable=unused-import
MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG, MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG,
MOCK_TH_OVER_4SWITCH_TYPE_CONFIG, MOCK_TH_OVER_4SWITCH_TYPE_CONFIG,
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG, MOCK_TH_OVER_CLIMATE_TYPE_CONFIG,
MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG,
MOCK_TH_OVER_CLIMATE_TYPE_NOT_REGULATED_CONFIG,
MOCK_TH_OVER_SWITCH_TPI_CONFIG, MOCK_TH_OVER_SWITCH_TPI_CONFIG,
MOCK_PRESETS_CONFIG, MOCK_PRESETS_CONFIG,
MOCK_PRESETS_AC_CONFIG, MOCK_PRESETS_AC_CONFIG,
@@ -83,6 +85,20 @@ PARTIAL_CLIMATE_CONFIG = (
| MOCK_ADVANCED_CONFIG | MOCK_ADVANCED_CONFIG
) )
PARTIAL_CLIMATE_NOT_REGULATED_CONFIG = (
MOCK_TH_OVER_CLIMATE_USER_CONFIG
| MOCK_TH_OVER_CLIMATE_TYPE_NOT_REGULATED_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_ADVANCED_CONFIG
)
PARTIAL_CLIMATE_AC_CONFIG = (
MOCK_TH_OVER_CLIMATE_USER_CONFIG
| MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_ADVANCED_CONFIG
)
FULL_4SWITCH_CONFIG = ( FULL_4SWITCH_CONFIG = (
MOCK_TH_OVER_4SWITCH_USER_CONFIG MOCK_TH_OVER_4SWITCH_USER_CONFIG
| MOCK_TH_OVER_4SWITCH_TYPE_CONFIG | MOCK_TH_OVER_4SWITCH_TYPE_CONFIG
@@ -101,7 +117,7 @@ _LOGGER = logging.getLogger(__name__)
class MockClimate(ClimateEntity): class MockClimate(ClimateEntity):
"""A Mock Climate class used for Underlying climate mode""" """A Mock Climate class used for Underlying climate mode"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos, hvac_mode:HVACMode = HVACMode.OFF) -> None: # pylint: disable=unused-argument def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos, hvac_mode:HVACMode = HVACMode.OFF, hvac_action:HVACAction = HVACAction.OFF) -> None: # pylint: disable=unused-argument
"""Initialize the thermostat.""" """Initialize the thermostat."""
super().__init__() super().__init__()
@@ -118,17 +134,25 @@ class MockClimate(ClimateEntity):
self._attr_temperature_unit = UnitOfTemperature.CELSIUS self._attr_temperature_unit = UnitOfTemperature.CELSIUS
self._attr_target_temperature = 20 self._attr_target_temperature = 20
self._attr_current_temperature = 15 self._attr_current_temperature = 15
self._attr_hvac_action = hvac_action
def set_temperature(self, **kwargs): def set_temperature(self, **kwargs):
""" Set the target temperature""" """ Set the target temperature"""
temperature = kwargs.get(ATTR_TEMPERATURE) temperature = kwargs.get(ATTR_TEMPERATURE)
self._attr_target_temperature = temperature self._attr_target_temperature = temperature
self.async_write_ha_state()
async def async_set_hvac_mode(self, hvac_mode): async def async_set_hvac_mode(self, hvac_mode):
""" The hvac mode""" """ The hvac mode"""
self._attr_hvac_mode = hvac_mode self._attr_hvac_mode = hvac_mode
self.async_write_ha_state()
@property
def hvac_action(self):
""" The hvac action of the mock climate"""
return self._attr_hvac_action
def set_hvac_action(self, hvac_action: HVACAction):
""" Set the HVACaction """
self._attr_hvac_action = hvac_action
class MockUnavailableClimate(ClimateEntity): class MockUnavailableClimate(ClimateEntity):
"""A Mock Climate class used for Underlying climate mode""" """A Mock Climate class used for Underlying climate mode"""
@@ -454,13 +478,6 @@ async def send_presence_change_event(
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
return ret return ret
def get_tz(hass: HomeAssistant):
"""Get the current timezone"""
return dt_util.get_time_zone(hass.config.time_zone)
async def send_climate_change_event( async def send_climate_change_event(
entity: BaseThermostat, entity: BaseThermostat,
new_hvac_mode: HVACMode, new_hvac_mode: HVACMode,

View File

@@ -50,6 +50,11 @@ from custom_components.versatile_thermostat.const import (
CONF_PRESENCE_SENSOR, CONF_PRESENCE_SENSOR,
PRESET_AWAY_SUFFIX, PRESET_AWAY_SUFFIX,
CONF_CLIMATE, CONF_CLIMATE,
CONF_AUTO_REGULATION_MODE,
CONF_AUTO_REGULATION_MEDIUM,
CONF_AUTO_REGULATION_NONE,
CONF_AUTO_REGULATION_DTEMP,
CONF_AUTO_REGULATION_PERIOD_MIN
) )
MOCK_TH_OVER_SWITCH_USER_CONFIG = { MOCK_TH_OVER_SWITCH_USER_CONFIG = {
CONF_NAME: "TheOverSwitchMockName", CONF_NAME: "TheOverSwitchMockName",
@@ -89,14 +94,14 @@ MOCK_TH_OVER_CLIMATE_USER_CONFIG = {
CONF_CYCLE_MIN: 5, CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15, CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30, CONF_TEMP_MAX: 30,
CONF_DEVICE_POWER: 1, CONF_DEVICE_POWER: 1
# Keep default values which are False # Keep default values which are False
} }
MOCK_TH_OVER_SWITCH_TYPE_CONFIG = { MOCK_TH_OVER_SWITCH_TYPE_CONFIG = {
CONF_HEATER: "switch.mock_switch", CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_AC_MODE: False, CONF_AC_MODE: False
} }
MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG = { MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG = {
@@ -122,6 +127,23 @@ MOCK_TH_OVER_SWITCH_TPI_CONFIG = {
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG = { MOCK_TH_OVER_CLIMATE_TYPE_CONFIG = {
CONF_CLIMATE: "climate.mock_climate", CONF_CLIMATE: "climate.mock_climate",
CONF_AC_MODE: False, CONF_AC_MODE: False,
CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_MEDIUM,
CONF_AUTO_REGULATION_DTEMP: 0.5,
CONF_AUTO_REGULATION_PERIOD_MIN: 2
}
MOCK_TH_OVER_CLIMATE_TYPE_NOT_REGULATED_CONFIG = {
CONF_CLIMATE: "climate.mock_climate",
CONF_AC_MODE: False,
CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_NONE
}
MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG = {
CONF_CLIMATE: "climate.mock_climate",
CONF_AC_MODE: True,
CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_MEDIUM,
CONF_AUTO_REGULATION_DTEMP: 0.5,
CONF_AUTO_REGULATION_PERIOD_MIN: 1
} }
MOCK_PRESETS_CONFIG = { MOCK_PRESETS_CONFIG = {

View File

@@ -0,0 +1,334 @@
# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long
""" Test the normal start of a Thermostat """
from unittest.mock import patch #, call
from datetime import datetime, timedelta
from homeassistant.core import HomeAssistant
from homeassistant.components.climate import HVACAction, HVACMode
from homeassistant.config_entries import ConfigEntryState
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN
from pytest_homeassistant_custom_component.common import MockConfigEntry
# from custom_components.versatile_thermostat.base_thermostat import BaseThermostat
from custom_components.versatile_thermostat.thermostat_climate import ThermostatOverClimate
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_over_climate_regulation(hass: HomeAssistant, skip_hass_states_is_state, skip_send_event):
"""Test the regulation of an over climate thermostat"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
# This is include a medium regulation
data=PARTIAL_CLIMATE_CONFIG,
)
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {})
# Creates the regulated VTherm over climate
# change temperature so that the heating will start
event_timestamp = now - timedelta(minutes=10)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.LOADED
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:ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
assert entity
assert isinstance(entity, ThermostatOverClimate)
assert entity.name == "TheOverClimateMockName"
assert entity.is_over_climate is True
assert entity.is_regulated is True
assert entity.hvac_action is HVACAction.OFF
assert entity.hvac_mode is HVACMode.OFF
assert entity.target_temperature == entity.min_temp
assert entity.preset_modes == [
PRESET_NONE,
PRESET_ECO,
PRESET_COMFORT,
PRESET_BOOST,
]
assert entity.preset_mode is PRESET_NONE
# Activate the heating by changing HVACMode and temperature
# Select a hvacmode, presence and preset
await entity.async_set_hvac_mode(HVACMode.HEAT)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.hvac_action == HVACAction.OFF
assert entity.regulated_target_temp is entity.min_temp
await send_temperature_change_event(entity, 15, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
# set manual target temp (at now - 7) -> the regulation should occurs
event_timestamp = now - timedelta(minutes=7)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
):
await entity.async_set_temperature(temperature=18)
fake_underlying_climate.set_hvac_action(HVACAction.HEATING) # simulate under heating
assert entity.hvac_action == HVACAction.HEATING
assert entity.preset_mode == PRESET_NONE # Manual mode
# the regulated temperature should be greater
assert entity.regulated_target_temp > entity.target_temperature
# In medium we could go up to +3 degre
# normally the calcul gives 18 + 2.2 but we round the result to the nearest 0.5 which is 2.0
assert entity.regulated_target_temp == 18+2.0
assert entity.hvac_action == HVACAction.HEATING
# change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=5)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
):
await send_temperature_change_event(entity, 22, event_timestamp)
await send_ext_temperature_change_event(entity, 19, event_timestamp)
# the regulated temperature should be under
assert entity.regulated_target_temp < entity.target_temperature
assert entity.regulated_target_temp == 18-0.5 # normally 0.6 but round_to_nearest gives 0.5
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_over_climate_regulation_ac_mode(hass: HomeAssistant, skip_hass_states_is_state, skip_send_event):
"""Test the regulation of an over climate thermostat"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
# This is include a medium regulation
data=PARTIAL_CLIMATE_AC_CONFIG,
)
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {})
# Creates the regulated VTherm over climate
# change temperature so that the heating will start
event_timestamp = now - timedelta(minutes=10)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.LOADED
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:ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
assert entity
assert isinstance(entity, ThermostatOverClimate)
assert entity.name == "TheOverClimateMockName"
assert entity.is_over_climate is True
assert entity.is_regulated is True
assert entity.hvac_action is HVACAction.OFF
assert entity.hvac_mode is HVACMode.OFF
assert entity.target_temperature == entity.max_temp
assert entity.preset_modes == [
PRESET_NONE,
PRESET_ECO,
PRESET_COMFORT,
PRESET_BOOST,
]
assert entity.preset_mode is PRESET_NONE
# Activate the heating by changing HVACMode and temperature
# Select a hvacmode, presence and preset
await entity.async_set_hvac_mode(HVACMode.HEAT)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.hvac_action == HVACAction.OFF
# change temperature so that the heating will start
await send_temperature_change_event(entity, 30, event_timestamp)
await send_ext_temperature_change_event(entity, 35, event_timestamp)
# set manual target temp
event_timestamp = now - timedelta(minutes=7)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
):
await entity.async_set_temperature(temperature=25)
fake_underlying_climate.set_hvac_action(HVACAction.COOLING) # simulate under heating
assert entity.hvac_action == HVACAction.COOLING
assert entity.preset_mode == PRESET_NONE # Manual mode
# the regulated temperature should be lower
assert entity.regulated_target_temp < entity.target_temperature
assert entity.regulated_target_temp == 25-3 # In medium we could go up to -3 degre
assert entity.hvac_action == HVACAction.COOLING
# change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=5)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
):
await send_temperature_change_event(entity, 26, event_timestamp)
await send_ext_temperature_change_event(entity, 35, event_timestamp)
# the regulated temperature should be under
assert entity.regulated_target_temp < entity.target_temperature
assert entity.regulated_target_temp == 25-2.5 # +2.3 without round_to_nearest
# change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=3)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
):
await send_temperature_change_event(entity, 20, event_timestamp)
await send_ext_temperature_change_event(entity, 25, event_timestamp)
# the regulated temperature should be greater
assert entity.regulated_target_temp > entity.target_temperature
assert entity.regulated_target_temp == 25+0.5 # +0.4 without round_to_nearest
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_over_climate_regulation_limitations(hass: HomeAssistant, skip_hass_states_is_state, skip_send_event):
"""Test the limitations of the regulation of an over climate thermostat:
1. test the period_min parameter: do not send regulation event too frequently
2. test the dtemp parameter: do not send regulation event if offset temp is lower than dtemp
"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
# This is include a medium regulation, dtemp=0.5, period_min=2
data=PARTIAL_CLIMATE_CONFIG,
)
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {})
# Creates the regulated VTherm over climate at t-20
# change temperature so that the heating will start
event_timestamp = now - timedelta(minutes=20)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.LOADED
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:ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
assert entity
assert isinstance(entity, ThermostatOverClimate)
assert entity.name == "TheOverClimateMockName"
assert entity.is_over_climate is True
assert entity.is_regulated is True
# Activate the heating by changing HVACMode and temperature
# Select a hvacmode, presence and preset
await entity.async_set_hvac_mode(HVACMode.HEAT)
assert entity.hvac_mode is HVACMode.HEAT
# it is cold today
await send_temperature_change_event(entity, 15, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
# set manual target temp (at now - 19) -> the regulation should be ignored because too early
event_timestamp = now - timedelta(minutes=19)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
):
await entity.async_set_temperature(temperature=18)
fake_underlying_climate.set_hvac_action(HVACAction.HEATING) # simulate under heating
assert entity.hvac_action == HVACAction.HEATING
# the regulated temperature should be unchanged
assert entity.regulated_target_temp == 15
# set manual target temp (at now - 18) -> the regulation should be taken into account
event_timestamp = now - timedelta(minutes=18)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
):
await entity.async_set_temperature(temperature=17)
assert entity.regulated_target_temp > entity.target_temperature
assert entity.regulated_target_temp == 18+0.5 # In medium we could go up to +3 degre. 0.7 without round_to_nearest
old_regulated_temp = entity.regulated_target_temp
# change temperature so that dtemp < 0.5 and time is > period_min (+ 3min)
event_timestamp = now - timedelta(minutes=15)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
):
await send_temperature_change_event(entity, 16, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
# the regulated temperature should be under
assert entity.regulated_target_temp == old_regulated_temp
# change temperature so that dtemp > 0.5 and time is > period_min (+ 3min)
event_timestamp = now - timedelta(minutes=12)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
):
await send_temperature_change_event(entity, 18, event_timestamp)
await send_ext_temperature_change_event(entity, 12, event_timestamp)
# the regulated should have been done
assert entity.regulated_target_temp != old_regulated_temp
assert entity.regulated_target_temp > entity.target_temperature
assert entity.regulated_target_temp == 17 + 0.5 # 0.7 without round_to_nearest

View File

@@ -463,11 +463,11 @@ async def test_bug_101(
domain=DOMAIN, domain=DOMAIN,
title="TheOverClimateMockName", title="TheOverClimateMockName",
unique_id="uniqueId", unique_id="uniqueId",
data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay data=PARTIAL_CLIMATE_NOT_REGULATED_CONFIG, # 5 minutes security delay
) )
# Underlying is in HEAT mode but should be shutdown at startup # Underlying is in HEAT mode but should be shutdown at startup
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT) fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT, HVACAction.HEATING)
with patch( with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
@@ -495,7 +495,7 @@ async def test_bug_101(
assert entity.name == "TheOverClimateMockName" assert entity.name == "TheOverClimateMockName"
assert entity.is_over_climate is True assert entity.is_over_climate is True
assert entity.hvac_mode is HVACMode.OFF assert entity.hvac_mode is HVACMode.OFF
# because the underlying is heating. In real life the underlying should be shut-off # because in MockClimate HVACAction is HEATING if hvac_mode is not set
assert entity.hvac_action is HVACAction.HEATING assert entity.hvac_action is HVACAction.HEATING
# Underlying should have been shutdown # Underlying should have been shutdown
assert mock_underlying_set_hvac_mode.call_count == 1 assert mock_underlying_set_hvac_mode.call_count == 1
@@ -539,6 +539,7 @@ async def test_bug_101(
# 2. Change the target temp of underlying thermostat at 11 sec later -> the event will be taken # 2. Change the target temp of underlying thermostat at 11 sec later -> the event will be taken
# Wait 11 sec # Wait 11 sec
event_timestamp = now + timedelta(seconds=11) event_timestamp = now + timedelta(seconds=11)
assert entity.is_regulated is False
await send_climate_change_event_with_temperature(entity, HVACMode.HEAT, HVACMode.HEAT, HVACAction.OFF, HVACAction.OFF, event_timestamp, 12.75) await send_climate_change_event_with_temperature(entity, HVACMode.HEAT, HVACMode.HEAT, HVACAction.OFF, HVACAction.OFF, event_timestamp, 12.75)
assert entity.target_temperature == 12.75 assert entity.target_temperature == 12.75
assert entity.preset_mode is PRESET_NONE assert entity.preset_mode is PRESET_NONE

171
tests/test_pi.py Normal file
View File

@@ -0,0 +1,171 @@
# pylint: disable=line-too-long
""" Tests de PI algorithm used for auto-regulation """
from custom_components.versatile_thermostat.pi_algorithm import PITemperatureRegulator
def test_pi_algorithm_basics():
""" Test the PI algorithm """
the_algo = PITemperatureRegulator(target_temp=20, kp=0.2, ki=0.05, k_ext=0.1, offset_max=2, stabilization_threshold=0.1, accumulated_error_threshold=20)
assert the_algo
assert the_algo.calculate_regulated_temperature(20, 20) == 20
assert the_algo.calculate_regulated_temperature(20, 10) == 21
# to reset the accumulated erro
the_algo.set_target_temp(20)
# Test the accumulator threshold effect and offset_max
assert the_algo.calculate_regulated_temperature(10, 10) == 22 # +2
assert the_algo.calculate_regulated_temperature(10, 10) == 22
assert the_algo.calculate_regulated_temperature(10, 10) == 22
# Will keep infinitly 22.0
# to reset the accumulated erro
the_algo.set_target_temp(20)
assert the_algo.calculate_regulated_temperature(18, 10) == 21.5 # +1.5
assert the_algo.calculate_regulated_temperature(18.1, 10) == 21.6 # +1.6
assert the_algo.calculate_regulated_temperature(18.3, 10) == 21.6 # +1.6
assert the_algo.calculate_regulated_temperature(18.5, 10) == 21.7 # +1.7
assert the_algo.calculate_regulated_temperature(18.7, 10) == 21.7 # +1.7
assert the_algo.calculate_regulated_temperature(19, 10) == 21.7 # +1.7
assert the_algo.calculate_regulated_temperature(20, 10) == 21.5 # +1.5
assert the_algo.calculate_regulated_temperature(21, 10) == 20.8 # +0.8
assert the_algo.calculate_regulated_temperature(21, 10) == 20.7 # +0.7
assert the_algo.calculate_regulated_temperature(20, 10) == 20.9 # +0.7
# Test temperature external
assert the_algo.calculate_regulated_temperature(20, 12) == 20.8 # +0.8
assert the_algo.calculate_regulated_temperature(20, 15) == 20.5 # +0.5
assert the_algo.calculate_regulated_temperature(20, 18) == 20.2 # +0.2
assert the_algo.calculate_regulated_temperature(20, 20) == 20.0 # =
def test_pi_algorithm_light():
""" Test the PI algorithm """
the_algo = PITemperatureRegulator(target_temp=20, kp=0.2, ki=0.05, k_ext=0.1, offset_max=2, stabilization_threshold=0.1, accumulated_error_threshold=20)
assert the_algo
# to reset the accumulated erro
the_algo.set_target_temp(20)
assert the_algo.calculate_regulated_temperature(18, 10) == 21.5 # +1.5
assert the_algo.calculate_regulated_temperature(18.1, 10) == 21.6 # +1.6
assert the_algo.calculate_regulated_temperature(18.3, 10) == 21.6 # +1.6
assert the_algo.calculate_regulated_temperature(18.5, 10) == 21.7 # +1.7
assert the_algo.calculate_regulated_temperature(18.7, 10) == 21.7 # +1.7
assert the_algo.calculate_regulated_temperature(19, 10) == 21.7 # +1.7
assert the_algo.calculate_regulated_temperature(20, 10) == 21.5 # +1.5
assert the_algo.calculate_regulated_temperature(21, 10) == 20.8 # +0.8
assert the_algo.calculate_regulated_temperature(21, 10) == 20.7 # +0.7
assert the_algo.calculate_regulated_temperature(20, 10) == 20.9 # +0.7
# Test temperature external
assert the_algo.calculate_regulated_temperature(20, 12) == 20.8 # +0.8
assert the_algo.calculate_regulated_temperature(20, 15) == 20.5 # +0.5
assert the_algo.calculate_regulated_temperature(20, 18) == 20.2 # +0.2
assert the_algo.calculate_regulated_temperature(20, 20) == 20.0 # =
def test_pi_algorithm_medium():
""" Test the PI algorithm """
the_algo = PITemperatureRegulator(target_temp=20, kp=0.5, ki=0.1, k_ext=0.1, offset_max=3, stabilization_threshold=0.1, accumulated_error_threshold=30)
assert the_algo
# to reset the accumulated erro
the_algo.set_target_temp(20)
assert the_algo.calculate_regulated_temperature(18, 10) == 22.2
assert the_algo.calculate_regulated_temperature(18.1, 10) == 22.3
assert the_algo.calculate_regulated_temperature(18.3, 10) == 22.4
assert the_algo.calculate_regulated_temperature(18.5, 10) == 22.5
assert the_algo.calculate_regulated_temperature(18.7, 10) == 22.5
assert the_algo.calculate_regulated_temperature(19, 10) == 22.4
assert the_algo.calculate_regulated_temperature(20, 10) == 21.9
assert the_algo.calculate_regulated_temperature(21, 10) == 20.4
assert the_algo.calculate_regulated_temperature(21, 10) == 20.3
assert the_algo.calculate_regulated_temperature(20, 10) == 20.8
# Test temperature external
assert the_algo.calculate_regulated_temperature(20, 8) == 21.2
assert the_algo.calculate_regulated_temperature(20, 6) == 21.4
assert the_algo.calculate_regulated_temperature(20, 4) == 21.6
assert the_algo.calculate_regulated_temperature(20, 2) == 21.8
assert the_algo.calculate_regulated_temperature(20, 0) == 22.0
assert the_algo.calculate_regulated_temperature(20, -2) == 22.2
assert the_algo.calculate_regulated_temperature(20, -4) == 22.4
assert the_algo.calculate_regulated_temperature(20, -6) == 22.6
assert the_algo.calculate_regulated_temperature(20, -8) == 22.8
# to reset the accumulated erro
the_algo.set_target_temp(20)
# Test the error acculation effect
assert the_algo.calculate_regulated_temperature(19, 5) == 22.1
assert the_algo.calculate_regulated_temperature(19, 5) == 22.2
assert the_algo.calculate_regulated_temperature(19, 5) == 22.3
assert the_algo.calculate_regulated_temperature(19, 5) == 22.4
assert the_algo.calculate_regulated_temperature(19, 5) == 22.5
assert the_algo.calculate_regulated_temperature(19, 5) == 22.6
assert the_algo.calculate_regulated_temperature(19, 5) == 22.7
assert the_algo.calculate_regulated_temperature(19, 5) == 22.8
assert the_algo.calculate_regulated_temperature(19, 5) == 22.9
assert the_algo.calculate_regulated_temperature(19, 5) == 23
assert the_algo.calculate_regulated_temperature(19, 5) == 23
assert the_algo.calculate_regulated_temperature(19, 5) == 23
assert the_algo.calculate_regulated_temperature(19, 5) == 23
def test_pi_algorithm_strong():
""" Test the PI algorithm """
the_algo = PITemperatureRegulator(target_temp=20, kp=0.6, ki=0.2, k_ext=0.2, offset_max=4, stabilization_threshold=0.1, accumulated_error_threshold=40)
assert the_algo
# to reset the accumulated erro
the_algo.set_target_temp(20)
assert the_algo.calculate_regulated_temperature(18, 10) == 23.6
assert the_algo.calculate_regulated_temperature(18.1, 10) == 23.9
assert the_algo.calculate_regulated_temperature(18.3, 10) == 24.0
assert the_algo.calculate_regulated_temperature(18.5, 10) == 24
assert the_algo.calculate_regulated_temperature(18.7, 10) == 24
assert the_algo.calculate_regulated_temperature(19, 10) == 24
assert the_algo.calculate_regulated_temperature(20, 10) == 23.9
assert the_algo.calculate_regulated_temperature(21, 10) == 21.2
assert the_algo.calculate_regulated_temperature(21, 10) == 21
assert the_algo.calculate_regulated_temperature(21, 10) == 20.8
assert the_algo.calculate_regulated_temperature(21, 10) == 20.6
assert the_algo.calculate_regulated_temperature(21, 10) == 20.4
assert the_algo.calculate_regulated_temperature(21, 10) == 20.2
assert the_algo.calculate_regulated_temperature(21, 10) == 20
# Test temperature external
assert the_algo.calculate_regulated_temperature(20, 8) == 21.0
assert the_algo.calculate_regulated_temperature(20, 6) == 22.8
assert the_algo.calculate_regulated_temperature(20, 4) == 23.2
assert the_algo.calculate_regulated_temperature(20, 2) == 23.6
assert the_algo.calculate_regulated_temperature(20, 0) == 24
assert the_algo.calculate_regulated_temperature(20, -2) == 24
assert the_algo.calculate_regulated_temperature(20, -4) == 24
assert the_algo.calculate_regulated_temperature(20, -6) == 24
assert the_algo.calculate_regulated_temperature(20, -8) == 24
# to reset the accumulated erro
the_algo.set_target_temp(20)
# Test the error acculation effect
assert the_algo.calculate_regulated_temperature(19, 10) == 22.8
assert the_algo.calculate_regulated_temperature(19, 10) == 23
assert the_algo.calculate_regulated_temperature(19, 10) == 23.2
assert the_algo.calculate_regulated_temperature(19, 10) == 23.4
assert the_algo.calculate_regulated_temperature(19, 10) == 23.6
assert the_algo.calculate_regulated_temperature(19, 10) == 23.8
assert the_algo.calculate_regulated_temperature(19, 10) == 24
assert the_algo.calculate_regulated_temperature(19, 10) == 24
assert the_algo.calculate_regulated_temperature(19, 10) == 24
assert the_algo.calculate_regulated_temperature(19, 10) == 24
assert the_algo.calculate_regulated_temperature(19, 10) == 24

View File

@@ -1,11 +1,15 @@
# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long
""" Test the Security featrure """ """ Test the Security featrure """
from unittest.mock import patch, call from unittest.mock import patch, call
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from datetime import timedelta, datetime from datetime import timedelta, datetime
import logging import logging
from custom_components.versatile_thermostat.thermostat_climate import ThermostatOverClimate
from custom_components.versatile_thermostat.thermostat_switch import ThermostatOverSwitch
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
logging.getLogger().setLevel(logging.DEBUG) logging.getLogger().setLevel(logging.DEBUG)
@@ -55,7 +59,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
# 1. creates a thermostat and check that security is off # 1. creates a thermostat and check that security is off
now: datetime = datetime.now(tz=tz) now: datetime = datetime.now(tz=tz)
entity: VersatileThermostat = await create_thermostat( entity: ThermostatOverSwitch = await create_thermostat(
hass, entry, "climate.theoverswitchmockname" hass, entry, "climate.theoverswitchmockname"
) )
assert entity assert entity
@@ -211,7 +215,7 @@ async def test_security_over_climate(
data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay
) )
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT) fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT, HVACAction.HEATING)
with patch( with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
@@ -230,7 +234,7 @@ async def test_security_over_climate(
if entity.entity_id == entity_id: if entity.entity_id == entity_id:
return entity return entity
entity = find_my_entity("climate.theoverclimatemockname") entity: ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
assert entity assert entity
@@ -295,11 +299,11 @@ async def test_security_over_climate(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch( ) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on: ):
event_timestamp = now - timedelta(minutes=6) event_timestamp = now - timedelta(minutes=6)
await send_temperature_change_event(entity, 15, event_timestamp) await send_temperature_change_event(entity, 15, event_timestamp)
# Should stay False because a climate is never in security mode # Should stay False because a climate is never in security mode
assert entity.security_state is False assert entity.security_state is False
assert entity.preset_mode == 'none' assert entity.preset_mode == 'none'
assert entity._saved_preset_mode == 'none' assert entity._saved_preset_mode == 'none'

View File

@@ -1,5 +1,6 @@
""" Test the TPI algorithm """ """ Test the TPI algorithm """
from custom_components.versatile_thermostat.base_thermostat import BaseThermostat
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
@@ -37,7 +38,7 @@ async def test_tpi_calculation(
}, },
) )
entity: VersatileThermostat = await create_thermostat( entity: BaseThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname" hass, entry, "climate.theoverswitchmockname"
) )
assert entity assert entity