Compare commits

..

24 Commits

Author SHA1 Message Date
Jean-Marc Collin
81780bd316 With testu for config_flow ok 2024-11-24 16:23:14 +00:00
Jean-Marc Collin
ce4ea866cb All tests ok. Add a multi test for climate with valve regulation 2024-11-24 12:09:11 +00:00
Jean-Marc Collin
36cab0c91f Test multi ok 2024-11-24 09:32:19 +00:00
Jean-Marc Collin
6947056d55 First unit test ok 2024-11-23 23:08:31 +00:00
Jean-Marc Collin
7005cd7b26 Step 2: manual tests ok 2024-11-23 10:58:05 +00:00
Jean-Marc Collin
9abea3d198 Step 2 - renaming. All tests ok 2024-11-23 10:08:57 +00:00
Jean-Marc Collin
ffb976cfa1 Indus step1 2024-11-23 07:45:36 +00:00
Jean-Marc Collin
7b0c41e8ab Update custom_components/versatile_thermostat/translations/en.json
Co-authored-by: Alexander Dransfield <2844540+alexdrans@users.noreply.github.com>
2024-11-23 07:45:36 +00:00
Jean-Marc Collin
606e5ad440 Fix Valve testus. Improve sending the open percent to valve 2024-11-23 07:45:36 +00:00
Jean-Marc Collin
fd0c80585d Issue #655 - combine motion and presence 2024-11-23 07:45:35 +00:00
Jean-Marc Collin
3ea63a6819 Fix underlying target is not updated 2024-11-23 07:45:35 +00:00
Jean-Marc Collin
386fd780bc Fix hvac_action
Fix offset_calibration=room_temp - (local_temp - current_offset)
2024-11-23 07:45:35 +00:00
Jean-Marc Collin
fdcdf91f95 Calculate offset_calibration as room_temp - local_temp
Fix hvac_action calculation
2024-11-23 07:45:34 +00:00
Jean-Marc Collin
2fa6a0dd52 Add #602 - implement a max_on_percent setting 2024-11-23 07:45:34 +00:00
Jean-Marc Collin
8bae40101d Fix release name 2024-11-23 07:45:34 +00:00
Jean-Marc Collin
ddb27bb333 Release 2024-11-23 07:45:34 +00:00
Jean-Marc Collin
3f5c4f5cbe Fix Testus 2024-11-23 07:45:34 +00:00
Jean-Marc Collin
cb71821196 Work in simuated environment 2024-11-23 07:45:34 +00:00
Jean-Marc Collin
e4d42da140 With 1rst implementation of VTherm TRVZB and underlying 2024-11-23 07:45:33 +00:00
Jean-Marc Collin
14f7eb2bbe Next (not finished) 2024-11-23 07:45:33 +00:00
Jean-Marc Collin
5fa679c1f2 Fix configuration 2024-11-23 07:45:33 +00:00
Jean-Marc Collin
2d88243e79 With Sonoff configuration ok 2024-11-23 07:45:32 +00:00
Jean-Marc Collin
0a658b7a2a Add on_percent into Plotly graph 2024-11-20 10:38:32 +01:00
ms5
289ccc7bb7 Implementing max_on_percent setting (#632)
* implementing max_on_percent setting

* remove % sign from log message

* README updated: created new export-mode section, moved self-regulation expert settings to new section, added new section about on-time clamping
2024-11-17 18:28:24 +01:00
30 changed files with 1600 additions and 486 deletions

204
README.md
View File

@@ -389,82 +389,6 @@ These three parameters make it possible to modulate the regulation and avoid mul
Self-regulation consists of forcing the equipment to go further by forcing its set temperature regularly. Its consumption can therefore be increased, as well as its wear.
#### Self-regulation in Expert mode
In **Expert** mode you can finely adjust the auto-regulation parameters to achieve your objectives and optimize as best as possible. The algorithm calculates the difference between the setpoint and the actual temperature of the room. This discrepancy is called error.
The adjustable parameters are as follows:
1. `kp`: the factor applied to the raw error,
2. `ki`: the factor applied to the accumulation of errors,
3. `k_ext`: the factor applied to the difference between the interior temperature and the exterior temperature,
4. `offset_max`: the maximum correction (offset) that the regulation can apply,
5. `stabilization_threshold`: a stabilization threshold which, when reached by the error, resets the accumulation of errors to 0,
6. `accumulated_error_threshold`: the maximum for error accumulation.
For tuning, these observations must be taken into account:
1. `kp * error` will give the offset linked to the raw error. This offset is directly proportional to the error and will be 0 when the target is reached,
2. the accumulation of the error makes it possible to correct the stabilization of the curve while there remains an error. The error accumulates and the offset therefore gradually increases which should eventually stabilize at the target temperature. For this fundamental parameter to have an effect it must not be too small. An average value is 30
3. `ki * accumulated_error_threshold` will give the maximum offset linked to the accumulation of the error,
4. `k_ext` allows a correction to be applied immediately (without waiting for errors to accumulate) when the outside temperature is very different from the target temperature. If the stabilization is done too high when the temperature differences are significant, it is because this parameter is too high. It should be possible to cancel completely to let the first 2 offsets take place
The pre-programmed values are as follows:
Slow régulation :
kp: 0.2 # 20% of the current internal regulation offset are caused by the current difference of target temperature and room temperature
ki: 0.8 / 288.0 # 80% of the current internal regulation offset are caused by the average offset of the past 24 hours
k_ext: 1.0 / 25.0 # this will add 1°C to the offset when it's 25°C colder outdoor than indoor
offset_max: 2.0 # limit to a final offset of -2°C to +2°C
stabilization_threshold: 0.0 # this needs to be disabled as otherwise the long term accumulated error will always be reset when the temp briefly crosses from/to below/above the target
accumulated_error_threshold: 2.0 * 288 # this allows up to 2°C long term offset in both directions
Light régulation :
kp: 0.2
ki: 0.05
k_ext: 0.05
offset_max: 1.5
stabilization_threshold: 0.1
accumulated_error_threshold: 10
Medium régulation :
kp: 0.3
ki: 0.05
k_ext: 0.1
offset_max: 2
stabilization_threshold: 0.1
accumulated_error_threshold: 20
Strong régulation :
"""Strong parameters for regulation
A set of parameters which doesn't take into account the external temp
and concentrate to internal temp error + accumulated error.
This should work for cold external conditions which else generates
high external_offset"""
kp: 0.4
ki: 0.08
k_ext: 0.0
offset_max: 5
stabilization_threshold: 0.1
accumulated_error_threshold: 50
To use Expert mode you must declare the values you want to use for each of these parameters in your `configuration.yaml` in the following form:
```
versatile_thermostat:
auto_regulation_expert:
kp: 0.4
ki: 0.08
k_ext: 0.0
offset_max: 5
stabilization_threshold: 0.1
accumulated_error_threshold: 50
```
and of course, configure the VTherm's self-regulation mode in **Expert** mode. All VTherms in Expert mode will use these same settings.
For the changes to be taken into account, you must either **completely restart Home Assistant** or just the **Versatile Thermostat integration** (Dev tools / Yaml / reloading the configuration / Versatile Thermostat).
#### Internal temperature compensation
Sometimes, a devices internal temperature sensor (like in a TRV or AC) can give inaccurate readings, especially if its too close to a heat source. This can cause the device to stop heating too soon.
For example:
@@ -800,6 +724,113 @@ context:
> ![Tip](images/tips.png) _*Notes*_
> Controlling a central boiler using software or hardware such as home automation can pose risks to its proper functioning. Before using these functions, make sure that your boiler has safety functions and that they are working. Turning on a boiler if all the taps are closed can generate excess pressure, for example.
## Expert Mode Settings
Expert Mode settings refer to Settings made in the Home Assistant `configuration.yaml` file under the `versatile_thermostat` section. You might have to add this section by yourself to the `configuration.yaml` file.
These settings are meant to be used only in **specific niche cases and with careful considerations**.
The following sections describe the available export mode settings in detail with examples on how to configure them. Be aware that these settings require a **complete restart** of Home Assistant or a **reload of Versatile Thermostat integration** (Dev tools / Yaml / reloading the configuration / Versatile Thermostat) to take effect.
### Self-regulation in Expert mode
In **Expert** mode you can finely adjust the auto-regulation parameters to achieve your objectives and optimize as best as possible. The algorithm calculates the difference between the setpoint and the actual temperature of the room. This discrepancy is called error.
The adjustable parameters are as follows:
1. `kp`: the factor applied to the raw error,
2. `ki`: the factor applied to the accumulation of errors,
3. `k_ext`: the factor applied to the difference between the interior temperature and the exterior temperature,
4. `offset_max`: the maximum correction (offset) that the regulation can apply,
5. `stabilization_threshold`: a stabilization threshold which, when reached by the error, resets the accumulation of errors to 0,
6. `accumulated_error_threshold`: the maximum for error accumulation.
For tuning, these observations must be taken into account:
1. `kp * error` will give the offset linked to the raw error. This offset is directly proportional to the error and will be 0 when the target is reached,
2. the accumulation of the error makes it possible to correct the stabilization of the curve while there remains an error. The error accumulates and the offset therefore gradually increases which should eventually stabilize at the target temperature. For this fundamental parameter to have an effect it must not be too small. An average value is 30
3. `ki * accumulated_error_threshold` will give the maximum offset linked to the accumulation of the error,
4. `k_ext` allows a correction to be applied immediately (without waiting for errors to accumulate) when the outside temperature is very different from the target temperature. If the stabilization is done too high when the temperature differences are significant, it is because this parameter is too high. It should be possible to cancel completely to let the first 2 offsets take place
The pre-programmed values are as follows:
Slow régulation :
kp: 0.2 # 20% of the current internal regulation offset are caused by the current difference of target temperature and room temperature
ki: 0.8 / 288.0 # 80% of the current internal regulation offset are caused by the average offset of the past 24 hours
k_ext: 1.0 / 25.0 # this will add 1°C to the offset when it's 25°C colder outdoor than indoor
offset_max: 2.0 # limit to a final offset of -2°C to +2°C
stabilization_threshold: 0.0 # this needs to be disabled as otherwise the long term accumulated error will always be reset when the temp briefly crosses from/to below/above the target
accumulated_error_threshold: 2.0 * 288 # this allows up to 2°C long term offset in both directions
Light régulation :
kp: 0.2
ki: 0.05
k_ext: 0.05
offset_max: 1.5
stabilization_threshold: 0.1
accumulated_error_threshold: 10
Medium régulation :
kp: 0.3
ki: 0.05
k_ext: 0.1
offset_max: 2
stabilization_threshold: 0.1
accumulated_error_threshold: 20
Strong régulation :
"""Strong parameters for regulation
A set of parameters which doesn't take into account the external temp
and concentrate to internal temp error + accumulated error.
This should work for cold external conditions which else generates
high external_offset"""
kp: 0.4
ki: 0.08
k_ext: 0.0
offset_max: 5
stabilization_threshold: 0.1
accumulated_error_threshold: 50
To use Expert mode you must declare the values you want to use for each of these parameters in your `configuration.yaml` in the following form:
```
versatile_thermostat:
auto_regulation_expert:
kp: 0.4
ki: 0.08
k_ext: 0.0
offset_max: 5
stabilization_threshold: 0.1
accumulated_error_threshold: 50
```
and of course, configure the VTherm's self-regulation mode in **Expert** mode. All VTherms in Expert mode will use these same settings.
For the changes to be taken into account, you must either **completely restart Home Assistant** or just the **Versatile Thermostat integration** (Dev tools / Yaml / reloading the configuration / Versatile Thermostat).
### On Time Clamping (max_on_percent)
The calculated on time percent can be limited to a maximum percentage of the cycle duration. This setting has to be made in expert mode and will be used for all Versatile Thermostats.
```
versatile_thermostat:
max_on_percent: 0.8
```
The example above limits the maximum ON time to 80% (0.8) of the cycle length. If the cycle length is for example 600 seconds (10min), the maximum ON time will be limited to 480 seconds (8min). The remaining 120 seconds of the cycle will always remain in the OFF state.
There are three debug attributes of interest regarding this feature:
* `max_on_percent` # clamping setting as configured in expert mode
* `calculated_on_percent` # calculated on percent without clamping applied
* `on_percent` # used on percent with clamping applied
<details>
<summary>Parameter summary</summary>
@@ -1248,9 +1279,13 @@ Replace values in [[ ]] by yours.
yaxis: y1
name: Ema
- entity: '[[climate]]'
attribute: regulated_target_temperature
yaxis: y1
name: Regulated T°
attribute: on_percent
yaxis: y2
name: Power percent
fill: tozeroy
fillcolor: rgba(200, 10, 10, 0.3)
line:
color: rgba(200, 10, 10, 0.9)
- entity: '[[slope]]'
name: Slope
fill: tozeroy
@@ -1275,12 +1310,19 @@ Replace values in [[ ]] by yours.
yaxis:
visible: true
position: 0
yaxis2:
visible: true
position: 0
fixedrange: true
range:
- 0
- 1
yaxis9:
visible: true
fixedrange: false
range:
- -0.5
- 0.5
- -2
- 2
position: 1
xaxis:
rangeselector:

View File

@@ -54,6 +54,7 @@ from .const import (
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_CLIMATE,
CONF_THERMOSTAT_VALVE,
CONF_MAX_ON_PERCENT,
)
from .vtherm_api import VersatileThermostatAPI
@@ -86,6 +87,7 @@ CONFIG_SCHEMA = vol.Schema(
CONF_AUTO_REGULATION_EXPERT: vol.Schema(SELF_REGULATION_PARAM_SCHEMA),
CONF_SHORT_EMA_PARAMS: vol.Schema(EMA_PARAM_SCHEMA),
CONF_SAFETY_MODE: vol.Schema(SAFETY_MODE_PARAM_SCHEMA),
vol.Optional(CONF_MAX_ON_PERCENT): vol.Coerce(float),
}
),
},

View File

@@ -9,7 +9,6 @@ from datetime import timedelta, datetime
from types import MappingProxyType
from typing import Any, TypeVar, Generic
from homeassistant.util import dt as dt_util
from homeassistant.core import (
HomeAssistant,
callback,
@@ -80,13 +79,6 @@ _LOGGER = logging.getLogger(__name__)
ConfigData = MappingProxyType[str, Any]
T = TypeVar("T", bound=UnderlyingEntity)
def get_tz(hass: HomeAssistant):
"""Get the current timezone"""
return dt_util.get_time_zone(hass.config.time_zone)
class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"""Representation of a base class for all Versatile Thermostat device."""
@@ -137,7 +129,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"is_device_active",
"target_temperature_step",
"is_used_by_central_boiler",
"temperature_slope"
"temperature_slope",
"max_on_percent",
"have_valve_regulation",
}
)
)
@@ -459,8 +453,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
else DEFAULT_SECURITY_DEFAULT_ON_PERCENT
)
self._minimal_activation_delay = entry_infos.get(CONF_MINIMAL_ACTIVATION_DELAY)
self._last_temperature_measure = datetime.now(tz=self._current_tz)
self._last_ext_temperature_measure = datetime.now(tz=self._current_tz)
self._last_temperature_measure = self.now
self._last_ext_temperature_measure = self.now
self._security_state = False
# Initiate the ProportionalAlgorithm
@@ -503,6 +497,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
entry_infos.get(CONF_WINDOW_ACTION) or CONF_WINDOW_TURN_OFF
)
self._max_on_percent = api.max_on_percent
_LOGGER.debug(
"%s - Creation of a new VersatileThermostat entity: unique_id=%s",
self,
@@ -1339,7 +1335,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
self, old_preset_mode: str | None = None
): # pylint: disable=unused-argument
"""Reset to now the last change time"""
self._last_change_time = datetime.now(tz=self._current_tz)
self._last_change_time = self.now
_LOGGER.debug("%s - last_change_time is now %s", self, self._last_change_time)
def reset_last_temperature_time(self, old_preset_mode: str | None = None):
@@ -1349,7 +1345,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
and old_preset_mode not in HIDDEN_PRESETS
):
self._last_temperature_measure = self._last_ext_temperature_measure = (
datetime.now(tz=self._current_tz)
self.now
)
def find_preset_temp(self, preset_mode: str):
@@ -1382,7 +1378,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
)
if motion_preset in self._presets:
return self._presets[motion_preset]
if self._presence_on and self.presence_state in [STATE_OFF, None]:
return self._presets_away[motion_preset + PRESET_AWAY_SUFFIX]
else:
return self._presets[motion_preset]
else:
return None
else:
@@ -1452,16 +1451,16 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"""Extract the last_changed state from State or return now if not available"""
return (
state.last_changed.astimezone(self._current_tz)
if state.last_changed is not None
else datetime.now(tz=self._current_tz)
if isinstance(state.last_changed, datetime)
else self.now
)
def get_last_updated_date_or_now(self, state: State) -> datetime:
"""Extract the last_changed state from State or return now if not available"""
return (
state.last_updated.astimezone(self._current_tz)
if state.last_updated is not None
else datetime.now(tz=self._current_tz)
if isinstance(state.last_updated, datetime)
else self.now
)
@callback
@@ -1903,7 +1902,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
STATE_NOT_HOME,
):
return
if self._attr_preset_mode not in [PRESET_BOOST, PRESET_COMFORT, PRESET_ECO]:
if self._attr_preset_mode not in [
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
PRESET_ACTIVITY,
]:
return
new_temp = self.find_preset_temp(self.preset_mode)
@@ -1993,7 +1997,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
if in_cycle:
slope = self._window_auto_algo.check_age_last_measurement(
temperature=self._ema_temp,
datetime_now=datetime.now(get_tz(self._hass)),
datetime_now=self.now,
)
else:
slope = self._window_auto_algo.add_temp_measurement(
@@ -2281,10 +2285,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
@property
def now(self) -> datetime:
"""Get now. The local datetime or the overloaded _set_now date"""
return self._now if self._now is not None else datetime.now(self._current_tz)
return self._now if self._now is not None else NowClass.get_now(self._hass)
async def check_safety(self) -> bool:
"""Check if last temperature date is too long"""
now = self.now
delta_temp = (
now - self._last_temperature_measure.replace(tzinfo=self._current_tz)
@@ -2652,9 +2657,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"device_power": self._device_power,
ATTR_MEAN_POWER_CYCLE: self.mean_cycle_power,
ATTR_TOTAL_ENERGY: self.total_energy,
"last_update_datetime": datetime.now()
.astimezone(self._current_tz)
.isoformat(),
"last_update_datetime": self.now.isoformat(),
"timezone": str(self._current_tz),
"temperature_unit": self.temperature_unit,
"is_device_active": self.is_device_active,
@@ -2662,6 +2665,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"is_used_by_central_boiler": self.is_used_by_central_boiler,
"temperature_slope": round(self.last_temperature_slope or 0, 3),
"hvac_off_reason": self.hvac_off_reason,
"max_on_percent": self._max_on_percent,
"have_valve_regulation": self.have_valve_regulation,
}
_LOGGER.debug(
@@ -2680,6 +2685,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
)
return super().async_write_ha_state()
@property
def have_valve_regulation(self) -> bool:
"""True if the Thermostat is regulated by valve"""
return False
@callback
def async_registry_entry_updated(self):
"""update the entity if the config entry have been updated

View File

@@ -22,28 +22,12 @@ from homeassistant.const import (
STATE_NOT_HOME,
)
from .const import (
DOMAIN,
PLATFORMS,
CONF_PRESETS_WITH_AC,
SERVICE_SET_PRESENCE,
SERVICE_SET_PRESET_TEMPERATURE,
SERVICE_SET_SECURITY,
SERVICE_SET_WINDOW_BYPASS,
SERVICE_SET_AUTO_REGULATION_MODE,
SERVICE_SET_AUTO_FAN_MODE,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_CLIMATE,
CONF_THERMOSTAT_VALVE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
CONF_SONOFF_TRZB_MODE,
)
from .const import * # pylint: disable=wildcard-import,unused-wildcard-import
from .thermostat_switch import ThermostatOverSwitch
from .thermostat_climate import ThermostatOverClimate
from .thermostat_valve import ThermostatOverValve
from .thermostat_sonoff_trvzb import ThermostatOverSonoffTRVZB
from .thermostat_climate_valve import ThermostatOverClimateValve
_LOGGER = logging.getLogger(__name__)
@@ -62,7 +46,9 @@ async def async_setup_entry(
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
is_sonoff_trvzb = entry.data.get(CONF_SONOFF_TRZB_MODE)
have_valve_regulation = (
entry.data.get(CONF_AUTO_REGULATION_MODE) == CONF_AUTO_REGULATION_VALVE
)
if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG:
return
@@ -72,8 +58,8 @@ async def async_setup_entry(
if vt_type == CONF_THERMOSTAT_SWITCH:
entity = ThermostatOverSwitch(hass, unique_id, name, entry.data)
elif vt_type == CONF_THERMOSTAT_CLIMATE:
if is_sonoff_trvzb is True:
entity = ThermostatOverSonoffTRVZB(hass, unique_id, name, entry.data)
if have_valve_regulation is True:
entity = ThermostatOverClimateValve(hass, unique_id, name, entry.data)
else:
entity = ThermostatOverClimate(hass, unique_id, name, entry.data)
elif vt_type == CONF_THERMOSTAT_VALVE:

View File

@@ -3,38 +3,20 @@
# pylint: disable=line-too-long
import logging
from datetime import timedelta, datetime
from datetime import timedelta
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
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 .const import DOMAIN, DEVICE_MANUFACTURER, ServiceConfigurationError
_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:

View File

@@ -29,27 +29,6 @@ COMES_FROM = "comes_from"
_LOGGER = logging.getLogger(__name__)
# Not used but can be useful in other context
# def schema_defaults(schema, **defaults):
# """Create a new schema with default values filled in."""
# copy = schema.extend({})
# for field, field_type in copy.schema.items():
# if isinstance(field_type, vol.In):
# value = None
#
# if value in field_type.container:
# # field.default = vol.default_factory(value)
# field.description = {"suggested_value": value}
# continue
#
# if field.schema in defaults:
# # field.default = vol.default_factory(defaults[field])
# field.description = {"suggested_value": defaults[field]}
# return copy
#
def add_suggested_values_to_schema(
data_schema: vol.Schema, suggested_values: Mapping[str, Any]
) -> vol.Schema:
@@ -162,21 +141,37 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
if COMES_FROM in self._infos:
del self._infos[COMES_FROM]
def check_sonoff_trvzb_nb_entities(self, data: dict) -> bool:
"""Check the number of entities for Sonoff TRVZB"""
def is_valve_regulation_selected(self, infos) -> bool:
"""True of the valve regulation mode is selected"""
return infos.get(CONF_AUTO_REGULATION_MODE, None) == CONF_AUTO_REGULATION_VALVE
def check_valve_regulation_nb_entities(self, data: dict, step_id=None) -> bool:
"""Check the number of entities for Valve regulation"""
if step_id not in ["type", "valve_regulation", "check_complete"]:
return True
# underlyings_to_check = data if step_id == "type" else self._infos
underlyings_to_check = self._infos # data if step_id == "type" else self._infos
regulation_infos_to_check = (
data if step_id == "valve_regulation" else self._infos
)
ret = True
if (
self._infos.get(CONF_SONOFF_TRZB_MODE)
and data.get(CONF_OFFSET_CALIBRATION_LIST) is not None
):
nb_unders = len(self._infos.get(CONF_UNDERLYING_LIST))
nb_offset = len(data.get(CONF_OFFSET_CALIBRATION_LIST))
nb_opening = len(data.get(CONF_OPENING_DEGREE_LIST))
nb_closing = len(data.get(CONF_CLOSING_DEGREE_LIST))
if self.is_valve_regulation_selected(underlyings_to_check):
nb_unders = len(underlyings_to_check.get(CONF_UNDERLYING_LIST))
nb_offset = len(
regulation_infos_to_check.get(CONF_OFFSET_CALIBRATION_LIST, [])
)
nb_opening = len(
regulation_infos_to_check.get(CONF_OPENING_DEGREE_LIST, [])
)
nb_closing = len(
regulation_infos_to_check.get(CONF_CLOSING_DEGREE_LIST, [])
)
if (
nb_unders != nb_offset
or nb_unders != nb_opening
or nb_unders != nb_closing
nb_unders != nb_opening
or (nb_unders != nb_offset and nb_offset > 0)
or (nb_unders != nb_closing and nb_closing > 0)
):
ret = False
return ret
@@ -259,8 +254,8 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
# Check that the number of offet_calibration and opening_degree and closing_degree are equals
# to the number of underlying entities
if not self.check_sonoff_trvzb_nb_entities(data):
raise SonoffTRVZBNbEntitiesIncorrect()
if not self.check_valve_regulation_nb_entities(data, step_id):
raise ValveRegulationNbEntitiesIncorrect()
def check_config_complete(self, infos) -> bool:
"""True if the config is now complete (ie all mandatory attributes are set)"""
@@ -357,7 +352,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
):
return False
if not self.check_sonoff_trvzb_nb_entities(infos):
if not self.check_valve_regulation_nb_entities(infos, "check_complete"):
return False
return True
@@ -400,8 +395,8 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
errors[str(err)] = "service_configuration_format"
except ConfigurationNotCompleteError as err:
errors["base"] = "configuration_not_complete"
except SonoffTRVZBNbEntitiesIncorrect as err:
errors["base"] = "sonoff_trvzb_nb_entities_incorrect"
except ValveRegulationNbEntitiesIncorrect as err:
errors["base"] = "valve_regulation_nb_entities_incorrect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@@ -453,6 +448,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
if (
self._infos.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI
or is_central_config
or self.is_valve_regulation_selected(self._infos)
):
menu_options.append("tpi")
@@ -488,8 +484,8 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
]:
menu_options.append("auto_start_stop")
if self._infos.get(CONF_SONOFF_TRZB_MODE) is True:
menu_options.append("sonoff_trvzb")
if self.is_valve_regulation_selected(self._infos):
menu_options.append("valve_regulation")
menu_options.append("advanced")
@@ -563,7 +559,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
if (
self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CLIMATE
and user_input is not None
and not user_input.get(CONF_SONOFF_TRZB_MODE)
and not self.is_valve_regulation_selected(user_input)
):
# Remove TPI info
for key in [
@@ -621,19 +617,21 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
return await self.generic_step("auto_start_stop", schema, user_input, next_step)
async def async_step_sonoff_trvzb(
async def async_step_valve_regulation(
self, user_input: dict | None = None
) -> FlowResult:
"""Handle the Sonoff TRVZB configuration step"""
"""Handle the valve regulation configuration step"""
_LOGGER.debug(
"Into ConfigFlow.async_step_sonoff_trvzb user_input=%s", user_input
"Into ConfigFlow.async_step_valve_regulation user_input=%s", user_input
)
schema = STEP_SONOFF_TRVZB
schema = STEP_VALVE_REGULATION
self._infos[COMES_FROM] = None
next_step = self.async_step_menu
return await self.generic_step("sonoff_trvzb", schema, user_input, next_step)
return await self.generic_step(
"valve_regulation", schema, user_input, next_step
)
async def async_step_tpi(self, user_input: dict | None = None) -> FlowResult:
"""Handle the TPI flow steps"""

View File

@@ -141,7 +141,6 @@ STEP_THERMOSTAT_CLIMATE = vol.Schema( # pylint: disable=invalid-name
selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN, multiple=True),
),
vol.Optional(CONF_AC_MODE, default=False): cv.boolean,
vol.Optional(CONF_SONOFF_TRZB_MODE, default=False): cv.boolean,
vol.Optional(
CONF_AUTO_REGULATION_MODE, default=CONF_AUTO_REGULATION_NONE
): selector.SelectSelector(
@@ -198,19 +197,19 @@ STEP_AUTO_START_STOP = vol.Schema( # pylint: disable=invalid-name
}
)
STEP_SONOFF_TRVZB = vol.Schema( # pylint: disable=invalid-name
STEP_VALVE_REGULATION = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_OFFSET_CALIBRATION_LIST): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN], multiple=True
),
),
vol.Required(CONF_OPENING_DEGREE_LIST): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN], multiple=True
),
),
vol.Required(CONF_CLOSING_DEGREE_LIST): selector.EntitySelector(
vol.Optional(CONF_OFFSET_CALIBRATION_LIST): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN], multiple=True
),
),
vol.Optional(CONF_CLOSING_DEGREE_LIST): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN], multiple=True
),

View File

@@ -2,9 +2,12 @@
"""Constants for the Versatile Thermostat integration."""
import logging
import math
from typing import Literal
from datetime import datetime
from enum import Enum
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_NAME, Platform
from homeassistant.components.climate import (
@@ -16,6 +19,7 @@ from homeassistant.components.climate import (
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
from .prop_algorithm import (
PROPORTIONAL_FUNCTION_TPI,
@@ -94,12 +98,12 @@ CONF_USE_POWER_FEATURE = "use_power_feature"
CONF_USE_CENTRAL_BOILER_FEATURE = "use_central_boiler_feature"
CONF_USE_AUTO_START_STOP_FEATURE = "use_auto_start_stop_feature"
CONF_AC_MODE = "ac_mode"
CONF_SONOFF_TRZB_MODE = "sonoff_trvzb_mode"
CONF_WINDOW_AUTO_OPEN_THRESHOLD = "window_auto_open_threshold"
CONF_WINDOW_AUTO_CLOSE_THRESHOLD = "window_auto_close_threshold"
CONF_WINDOW_AUTO_MAX_DURATION = "window_auto_max_duration"
CONF_AUTO_REGULATION_MODE = "auto_regulation_mode"
CONF_AUTO_REGULATION_NONE = "auto_regulation_none"
CONF_AUTO_REGULATION_VALVE = "auto_regulation_valve"
CONF_AUTO_REGULATION_SLOW = "auto_regulation_slow"
CONF_AUTO_REGULATION_LIGHT = "auto_regulation_light"
CONF_AUTO_REGULATION_MEDIUM = "auto_regulation_medium"
@@ -137,6 +141,7 @@ CONF_VALVE_4 = "valve_entity4_id"
# Global params into configuration.yaml
CONF_SHORT_EMA_PARAMS = "short_ema_params"
CONF_SAFETY_MODE = "safety_mode"
CONF_MAX_ON_PERCENT = "max_on_percent"
CONF_USE_MAIN_CENTRAL_CONFIG = "use_main_central_config"
CONF_USE_TPI_CENTRAL_CONFIG = "use_tpi_central_config"
@@ -291,7 +296,6 @@ ALL_CONF = (
CONF_USE_POWER_FEATURE,
CONF_USE_CENTRAL_BOILER_FEATURE,
CONF_AC_MODE,
CONF_SONOFF_TRZB_MODE,
CONF_AUTO_REGULATION_MODE,
CONF_AUTO_REGULATION_DTEMP,
CONF_AUTO_REGULATION_PERIOD_MIN,
@@ -325,6 +329,7 @@ CONF_FUNCTIONS = [
CONF_AUTO_REGULATION_MODES = [
CONF_AUTO_REGULATION_NONE,
CONF_AUTO_REGULATION_VALVE,
CONF_AUTO_REGULATION_LIGHT,
CONF_AUTO_REGULATION_MEDIUM,
CONF_AUTO_REGULATION_STRONG,
@@ -463,9 +468,9 @@ class RegulationParamVeryStrong:
kp: float = 0.6
ki: float = 0.1
k_ext: float = 0.2
offset_max: float = 4
offset_max: float = 8
stabilization_threshold: float = 0.1
accumulated_error_threshold: float = 30
accumulated_error_threshold: float = 80
class EventType(Enum):
@@ -490,6 +495,38 @@ def send_vtherm_event(hass, event_type: EventType, entity, data: dict):
hass.bus.fire(event_type.value, data)
def get_safe_float(hass, entity_id: str):
"""Get a safe float state value for an entity.
Return None if entity is not available"""
if (
entity_id is None
or not (state := hass.states.get(entity_id))
or state.state == "unknown"
or state.state == "unavailable"
):
return None
float_val = float(state.state)
return None if math.isinf(float_val) or not math.isfinite(float_val) else float_val
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))
class UnknownEntity(HomeAssistantError):
"""Error to indicate there is an unknown entity_id given."""
@@ -510,8 +547,8 @@ class ConfigurationNotCompleteError(HomeAssistantError):
"""Error the configuration is not complete"""
class SonoffTRVZBNbEntitiesIncorrect(HomeAssistantError):
"""Error to indicate there is an error in the configuration of the Sonoff TRVZB.
class ValveRegulationNbEntitiesIncorrect(HomeAssistantError):
"""Error to indicate there is an error in the configuration of the TRV with valve regulation.
The number of specific entities is incorrect."""

View File

@@ -31,6 +31,7 @@ class PropAlgorithm:
cycle_min: int,
minimal_activation_delay: int,
vtherm_entity_id: str = None,
max_on_percent: float = None,
) -> None:
"""Initialisation of the Proportional Algorithm"""
_LOGGER.debug(
@@ -78,6 +79,7 @@ class PropAlgorithm:
self._off_time_sec = self._cycle_min * 60
self._security = False
self._default_on_percent = 0
self._max_on_percent = max_on_percent
def calculate(
self,
@@ -161,6 +163,15 @@ class PropAlgorithm:
)
self._on_percent = self._calculated_on_percent
if self._max_on_percent is not None and self._on_percent > self._max_on_percent:
_LOGGER.debug(
"%s - Heating period clamped to %s (instead of %s) due to max_on_percent setting.",
self._vtherm_entity_id,
self._max_on_percent,
self._on_percent,
)
self._on_percent = self._max_on_percent
self._on_time_sec = self._on_percent * self._cycle_min * 60
# Do not heat for less than xx sec

View File

@@ -49,7 +49,8 @@ from .const import (
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
CONF_USE_CENTRAL_BOILER_FEATURE,
CONF_SONOFF_TRZB_MODE,
CONF_AUTO_REGULATION_VALVE,
CONF_AUTO_REGULATION_MODE,
overrides,
)
@@ -71,7 +72,9 @@ async def async_setup_entry(
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
is_sonoff_trvzb = entry.data.get(CONF_SONOFF_TRZB_MODE)
have_valve_regulation = (
entry.data.get(CONF_AUTO_REGULATION_MODE) == CONF_AUTO_REGULATION_VALVE
)
entities = None
@@ -102,13 +105,13 @@ async def async_setup_entry(
if (
entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE
or is_sonoff_trvzb
or have_valve_regulation
):
entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data))
if (
entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE
and not is_sonoff_trvzb
and not have_valve_regulation
):
entities.append(
RegulatedTemperatureSensor(hass, unique_id, name, entry.data)

View File

@@ -28,7 +28,7 @@
"presence": "Presence detection",
"advanced": "Advanced parameters",
"auto_start_stop": "Auto start and stop",
"sonoff_trvzb": "Sonoff TRVZB configuration",
"valve_regulation": "Valve regulation configuration",
"finalize": "All done",
"configuration_not_complete": "Configuration not complete"
}
@@ -65,7 +65,7 @@
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
"use_presence_feature": "Use presence detection",
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page",
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after selecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page",
"use_auto_start_stop_feature": "Use the auto start and stop feature"
}
},
@@ -77,7 +77,6 @@
"heater_keep_alive": "Switch keep-alive interval in seconds",
"proportional_function": "Algorithm",
"ac_mode": "AC mode",
"sonoff_trvzb_mode": "SONOFF TRVZB mode",
"auto_regulation_mode": "Self-regulation",
"auto_regulation_dtemp": "Regulation threshold",
"auto_regulation_periode_min": "Regulation minimum period",
@@ -90,7 +89,6 @@
"heater_keep_alive": "Optional heater switch state refresh interval. Leave empty if not required.",
"proportional_function": "Algorithm to use (TPI is the only one for now)",
"ac_mode": "Use the Air Conditioning (AC) mode",
"sonoff_trvzb_mode": "The underlyings are SONOFF TRVZB. You have to configure some extra entities in the specific menu option 'Sonoff trvzb configuration'",
"auto_regulation_mode": "Auto adjustment of the target temperature",
"auto_regulation_dtemp": "The threshold in ° (or % for valve) under which the temperature change will not be sent",
"auto_regulation_periode_min": "Duration in minutes between two regulation update",
@@ -219,9 +217,9 @@
"central_boiler_deactivation_service": "Command to turn-off the central boiler formatted like entity_id/service_name[/attribut:valeur]"
}
},
"sonoff_trvzb": {
"title": "Sonoff TRVZB configuration",
"description": "Specific Sonoff TRVZB configuration",
"valve_regulation": {
"title": "Self-regulation with valve",
"description": "Configuration for self-regulation with direct control of the valve",
"data": {
"offset_calibration_entity_ids": "Offset calibration entities",
"opening_degree_entity_ids": "Opening degree entities",
@@ -229,9 +227,9 @@
"proportional_function": "Algorithm"
},
"data_description": {
"offset_calibration_entity_ids": "The list of the 'offset calibration' entities. There should be one per underlying climate entities",
"offset_calibration_entity_ids": "The list of the 'offset calibration' entities. Set it if your TRV have the entity for better regulation. There should be one per underlying climate entities",
"opening_degree_entity_ids": "The list of the 'opening degree' entities. There should be one per underlying climate entities",
"closing_degree_entity_ids": "The list of the 'closing degree' entities. There should be one per underlying climate entities",
"closing_degree_entity_ids": "The list of the 'closing degree' entities. Set it if your TRV have the entity for better regulation. There should be one per underlying climate entities",
"proportional_function": "Algorithm to use (TPI is the only one for now)"
}
}
@@ -274,7 +272,7 @@
"presence": "Presence detection",
"advanced": "Advanced parameters",
"auto_start_stop": "Auto start and stop",
"sonoff_trvzb": "Sonoff TRVZB configuration",
"valve_regulation": "Valve regulation configuration",
"finalize": "All done",
"configuration_not_complete": "Configuration not complete"
}
@@ -311,7 +309,7 @@
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
"use_presence_feature": "Use presence detection",
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page",
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after selecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page",
"use_auto_start_stop_feature": "Use the auto start and stop feature"
}
},
@@ -323,7 +321,6 @@
"heater_keep_alive": "Switch keep-alive interval in seconds",
"proportional_function": "Algorithm",
"ac_mode": "AC mode",
"sonoff_trvzb_mode": "SONOFF TRVZB mode",
"auto_regulation_mode": "Self-regulation",
"auto_regulation_dtemp": "Regulation threshold",
"auto_regulation_periode_min": "Regulation minimum period",
@@ -336,7 +333,6 @@
"heater_keep_alive": "Optional heater switch state refresh interval. Leave empty if not required.",
"proportional_function": "Algorithm to use (TPI is the only one for now)",
"ac_mode": "Use the Air Conditioning (AC) mode",
"sonoff_trvzb_mode": "The underlyings are SONOFF TRVZB. You have to configure some extra entities in the specific menu option 'Sonoff trvzb configuration'",
"auto_regulation_mode": "Auto adjustment of the target temperature",
"auto_regulation_dtemp": "The threshold in ° (or % for valve) under which the temperature change will not be sent",
"auto_regulation_periode_min": "Duration in minutes between two regulation update",
@@ -465,9 +461,9 @@
"central_boiler_deactivation_service": "Command to turn-off the central boiler formatted like entity_id/service_name[/attribut:valeur]"
}
},
"sonoff_trvzb": {
"title": "Sonoff TRVZB configuration - {name}",
"description": "Specific Sonoff TRVZB configuration",
"valve_regulation": {
"title": "Self-regulation with valve - {name}",
"description": "Configuration for self-regulation with direct control of the valve",
"data": {
"offset_calibration_entity_ids": "Offset calibration entities",
"opening_degree_entity_ids": "Opening degree entities",
@@ -475,9 +471,9 @@
"proportional_function": "Algorithm"
},
"data_description": {
"offset_calibration_entity_ids": "The list of the 'offset calibration' entities. There should be one per underlying climate entities",
"offset_calibration_entity_ids": "The list of the 'offset calibration' entities. Set it if your TRV have the entity for better regulation. There should be one per underlying climate entities",
"opening_degree_entity_ids": "The list of the 'opening degree' entities. There should be one per underlying climate entities",
"closing_degree_entity_ids": "The list of the 'closing degree' entities. There should be one per underlying climate entities",
"closing_degree_entity_ids": "The list of the 'closing degree' entities. Set it if your TRV have the entity for better regulation. There should be one per underlying climate entities",
"proportional_function": "Algorithm to use (TPI is the only one for now)"
}
}
@@ -488,7 +484,7 @@
"window_open_detection_method": "Only one window open detection method should be used. Use either window sensor or automatic detection through temperature threshold but not both",
"no_central_config": "You cannot check 'use central configuration' because no central configuration was found. You need to create a Versatile Thermostat of type 'Central Configuration' to use it.",
"service_configuration_format": "The format of the service configuration is wrong",
"sonoff_trvzb_nb_entities_incorrect": "The number of specific entities for Sonoff TRVZB should be equal to the number of underlyings"
"valve_regulation_nb_entities_incorrect": "The number of valve entities for valve regulation should be equal to the number of underlyings"
},
"abort": {
"already_configured": "Device is already configured"
@@ -510,7 +506,8 @@
"auto_regulation_medium": "Medium",
"auto_regulation_light": "Light",
"auto_regulation_expert": "Expert",
"auto_regulation_none": "No auto-regulation"
"auto_regulation_none": "No auto-regulation",
"auto_regulation_valve": "Direct control of valve"
}
},
"auto_fan_mode": {

View File

@@ -16,7 +16,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
)
from .commons import NowClass, round_to_nearest
from .commons import round_to_nearest
from .base_thermostat import BaseThermostat, ConfigData
from .pi_algorithm import PITemperatureRegulator
@@ -90,7 +90,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
# super.__init__ calls post_init at the end. So it must be called after regulation initialization
super().__init__(hass, unique_id, name, entry_infos)
self._regulated_target_temp = self.target_temperature
self._last_regulation_change = NowClass.get_now(hass)
self._last_regulation_change = None # NowClass.get_now(hass)
@overrides
def post_init(self, config_entry: ConfigData):
@@ -151,15 +151,13 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
"""True if the Thermostat is over_climate"""
return True
@property
def hvac_action(self) -> HVACAction | None:
"""Returns the current hvac_action by checking all hvac_action of the underlyings"""
def calculate_hvac_action(self, under_list: list) -> HVACAction | None:
"""Calculate an hvac action based on the hvac_action of the list in argument"""
# if one not IDLE or OFF -> return it
# else if one IDLE -> IDLE
# else OFF
one_idle = False
for under in self._underlyings:
for under in under_list:
if (action := under.hvac_action) not in [
HVACAction.IDLE,
HVACAction.OFF,
@@ -171,13 +169,19 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
return HVACAction.IDLE
return HVACAction.OFF
@property
def hvac_action(self) -> HVACAction | None:
"""Returns the current hvac_action by checking all hvac_action of the underlyings"""
return self.calculate_hvac_action(self._underlyings)
@overrides
async def _async_internal_set_temperature(self, temperature: float):
"""Set the target temperature and the target temperature of underlying climate if any"""
await super()._async_internal_set_temperature(temperature)
self._regulation_algo.set_target_temp(self.target_temperature)
await self._send_regulated_temperature(force=True)
# is done by control_heating method. No need to do it here
# await self._send_regulated_temperature(force=True)
async def _send_regulated_temperature(self, force=False):
"""Sends the regulated temperature to all underlying"""
@@ -202,16 +206,18 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
force,
)
now: datetime = NowClass.get_now(self._hass)
period = float((now - self._last_regulation_change).total_seconds()) / 60.0
if not force and period < self._auto_regulation_period_min:
_LOGGER.info(
"%s - period (%.1f) min is < %.0f min -> forget the regulation send",
self,
period,
self._auto_regulation_period_min,
if self._last_regulation_change is not None:
period = (
float((self.now - self._last_regulation_change).total_seconds()) / 60.0
)
return
if not force and period < self._auto_regulation_period_min:
_LOGGER.info(
"%s - period (%.1f) min is < %.0f min -> forget the regulation send",
self,
period,
self._auto_regulation_period_min,
)
return
if not self._regulated_target_temp:
self._regulated_target_temp = self.target_temperature
@@ -249,7 +255,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
new_regulated_temp,
)
self._last_regulation_change = now
self._last_regulation_change = self.now
for under in self._underlyings:
# issue 348 - use device temperature if configured as offset
offset_temp = 0
@@ -921,7 +927,10 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
# Stop here
return False
elif action == AUTO_START_STOP_ACTION_ON:
elif (
action == AUTO_START_STOP_ACTION_ON
and self.hvac_off_reason == HVAC_OFF_REASON_AUTO_START_STOP
):
_LOGGER.info(
"%s - Turning ON the Vtherm due to auto-start-stop conditions", self
)
@@ -1252,6 +1261,13 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_SLOW)
elif auto_regulation_mode == "Expert":
self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_EXPERT)
else:
_LOGGER.warning(
"%s - auto_regulation_mode %s is not supported",
self,
auto_regulation_mode,
)
return
await self._send_regulated_temperature()
self.update_custom_attributes()

View File

@@ -1,13 +1,13 @@
# pylint: disable=line-too-long, too-many-lines, abstract-method
""" A climate over Sonoff TRVZB classe """
""" A climate with a direct valve regulation class """
import logging
from datetime import datetime
from homeassistant.core import HomeAssistant
from homeassistant.components.climate import HVACMode
from homeassistant.components.climate import HVACMode, HVACAction
from .underlyings import UnderlyingSonoffTRVZB
from .underlyings import UnderlyingValveRegulation
# from .commons import NowClass, round_to_nearest
from .base_thermostat import ConfigData
@@ -21,14 +21,14 @@ from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
_LOGGER = logging.getLogger(__name__)
class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
"""This class represent a VTherm over a Sonoff TRVZB climate"""
class ThermostatOverClimateValve(ThermostatOverClimate):
"""This class represent a VTherm over a climate with a direct valve regulation"""
_entity_component_unrecorded_attributes = ThermostatOverClimate._entity_component_unrecorded_attributes.union( # pylint: disable=protected-access
frozenset(
{
"is_over_climate",
"is_over_sonoff_trvzb",
"have_valve_regulation",
"underlying_entities",
"on_time_sec",
"off_time_sec",
@@ -40,8 +40,8 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
}
)
)
_underlyings_sonoff_trvzb: list[UnderlyingSonoffTRVZB] = []
_valve_open_percent: int = 0
_underlyings_valve_regulation: list[UnderlyingValveRegulation] = []
_valve_open_percent: int | None = None
_last_calculation_timestamp: datetime | None = None
_auto_regulation_dpercent: float | None = None
_auto_regulation_period_min: int | None = None
@@ -49,8 +49,8 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
def __init__(
self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigData
):
"""Initialize the ThermostatOverSonoffTRVZB class"""
_LOGGER.debug("%s - creating a ThermostatOverSonoffTRVZB VTherm", name)
"""Initialize the ThermostatOverClimateValve class"""
_LOGGER.debug("%s - creating a ThermostatOverClimateValve VTherm", name)
super().__init__(hass, unique_id, name, entry_infos)
# self._valve_open_percent: int = 0
# self._last_calculation_timestamp: datetime | None = None
@@ -60,8 +60,8 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
@overrides
def post_init(self, config_entry: ConfigData):
"""Initialize the Thermostat and underlyings
Beware that the underlyings list contains the climate which represent the Sonoff TRVZB
but also the UnderlyingSonoff which reprensent the valve"""
Beware that the underlyings list contains the climate which represent the TRV
but also the UnderlyingValveRegulation which reprensent the valve"""
super().post_init(config_entry)
@@ -86,30 +86,35 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
self.name,
)
offset_list = config_entry.get(CONF_OFFSET_CALIBRATION_LIST)
opening_list = config_entry.get(CONF_OPENING_DEGREE_LIST)
closing_list = config_entry.get(CONF_CLOSING_DEGREE_LIST)
for idx, _ in enumerate(config_entry.get(CONF_UNDERLYING_LIST)):
offset = config_entry.get(CONF_OFFSET_CALIBRATION_LIST)[idx]
opening = config_entry.get(CONF_OPENING_DEGREE_LIST)[idx]
closing = config_entry.get(CONF_CLOSING_DEGREE_LIST)[idx]
under = UnderlyingSonoffTRVZB(
offset = offset_list[idx] if idx < len(offset_list) else None
# number of opening should equal number of underlying
opening = opening_list[idx]
closing = closing_list[idx] if idx < len(closing_list) else None
under = UnderlyingValveRegulation(
hass=self._hass,
thermostat=self,
offset_calibration_entity_id=offset,
opening_degree_entity_id=opening,
closing_degree_entity_id=closing,
climate_underlying=self._underlyings[idx],
)
self._underlyings_sonoff_trvzb.append(under)
self._underlyings_valve_regulation.append(under)
@overrides
def update_custom_attributes(self):
"""Custom attributes"""
super().update_custom_attributes()
self._attr_extra_state_attributes["is_over_sonoff_trvzb"] = (
self.is_over_sonoff_trvzb
self._attr_extra_state_attributes["have_valve_regulation"] = (
self.have_valve_regulation
)
self._attr_extra_state_attributes["underlying_sonoff_trvzb_entities"] = [
underlying.entity_id for underlying in self._underlyings_sonoff_trvzb
self._attr_extra_state_attributes["underlyings_valve_regulation"] = [
underlying.entity_id for underlying in self._underlyings_valve_regulation
]
self._attr_extra_state_attributes["on_percent"] = (
@@ -187,9 +192,14 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
if new_valve_percent < self._auto_regulation_dpercent:
new_valve_percent = 0
dpercent = new_valve_percent - self.valve_open_percent
dpercent = (
new_valve_percent - self._valve_open_percent
if self._valve_open_percent is not None
else 0
)
if (
new_valve_percent > 0
self._last_calculation_timestamp is not None
and new_valve_percent > 0
and -1 * self._auto_regulation_dpercent
<= dpercent
< self._auto_regulation_dpercent
@@ -202,22 +212,38 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
return
if self._valve_open_percent == new_valve_percent:
if (
self._last_calculation_timestamp is not None
and self._valve_open_percent == new_valve_percent
):
_LOGGER.debug("%s - no change in valve_open_percent.", self)
return
self._valve_open_percent = new_valve_percent
for under in self._underlyings_sonoff_trvzb:
under.set_valve_open_percent()
self._last_calculation_timestamp = now
self.update_custom_attributes()
super().recalculate()
async def _send_regulated_temperature(self, force=False):
"""Sends the regulated temperature to all underlying"""
if self.target_temperature is None:
return
for under in self._underlyings:
if self.target_temperature != under.last_sent_temperature:
await under.set_temperature(
self.target_temperature,
self._attr_max_temp,
self._attr_min_temp,
)
for under in self._underlyings_valve_regulation:
await under.set_valve_open_percent()
@property
def is_over_sonoff_trvzb(self) -> bool:
"""True if the Thermostat is over_sonoff_trvzb"""
def have_valve_regulation(self) -> bool:
"""True if the Thermostat is regulated by valve"""
return True
@property
@@ -236,7 +262,23 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
@property
def valve_open_percent(self) -> int:
"""Gives the percentage of valve needed"""
if self._hvac_mode == HVACMode.OFF:
if self._hvac_mode == HVACMode.OFF or self._valve_open_percent is None:
return 0
else:
return self._valve_open_percent
@property
def hvac_action(self) -> HVACAction | None:
"""Returns the current hvac_action by checking all hvac_action of the _underlyings_valve_regulation"""
return self.calculate_hvac_action(self._underlyings_valve_regulation)
@property
def is_device_active(self) -> bool:
"""A hack to overrides the state from underlyings"""
return self.valve_open_percent > 0
@overrides
async def service_set_auto_regulation_mode(self, auto_regulation_mode: str):
"""This should not be possible in valve regulation mode"""
return

View File

@@ -25,20 +25,23 @@ _LOGGER = logging.getLogger(__name__)
class ThermostatOverSwitch(BaseThermostat[UnderlyingSwitch]):
"""Representation of a base class for a Versatile Thermostat over a switch."""
_entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union( # pylint: disable=protected-access
frozenset(
{
"is_over_switch",
"is_inversed",
"underlying_entities",
"on_time_sec",
"off_time_sec",
"cycle_min",
"function",
"tpi_coef_int",
"tpi_coef_ext",
"power_percent",
}
_entity_component_unrecorded_attributes = (
BaseThermostat._entity_component_unrecorded_attributes.union(
frozenset(
{
"is_over_switch",
"is_inversed",
"underlying_entities",
"on_time_sec",
"off_time_sec",
"cycle_min",
"function",
"tpi_coef_int",
"tpi_coef_ext",
"power_percent",
"calculated_on_percent",
}
)
)
)
@@ -79,6 +82,7 @@ class ThermostatOverSwitch(BaseThermostat[UnderlyingSwitch]):
self._cycle_min,
self._minimal_activation_delay,
self.name,
max_on_percent=self._max_on_percent,
)
lst_switches = config_entry.get(CONF_UNDERLYING_LIST)
@@ -144,6 +148,9 @@ class ThermostatOverSwitch(BaseThermostat[UnderlyingSwitch]):
self._attr_extra_state_attributes["function"] = self._proportional_function
self._attr_extra_state_attributes["tpi_coef_int"] = self._tpi_coef_int
self._attr_extra_state_attributes["tpi_coef_ext"] = self._tpi_coef_ext
self._attr_extra_state_attributes[
"calculated_on_percent"
] = self._prop_algorithm.calculated_on_percent
self.async_write_ha_state()
_LOGGER.debug(

View File

@@ -43,6 +43,7 @@ class ThermostatOverValve(BaseThermostat[UnderlyingValve]): # pylint: disable=a
"auto_regulation_dpercent",
"auto_regulation_period_min",
"last_calculation_timestamp",
"calculated_on_percent",
}
)
)
@@ -96,6 +97,7 @@ class ThermostatOverValve(BaseThermostat[UnderlyingValve]): # pylint: disable=a
self._cycle_min,
self._minimal_activation_delay,
self.name,
max_on_percent=self._max_on_percent,
)
lst_valves = config_entry.get(CONF_UNDERLYING_LIST)
@@ -179,6 +181,9 @@ class ThermostatOverValve(BaseThermostat[UnderlyingValve]): # pylint: disable=a
if self._last_calculation_timestamp
else None
)
self._attr_extra_state_attributes[
"calculated_on_percent"
] = self._prop_algorithm.calculated_on_percent
self.async_write_ha_state()
_LOGGER.debug(
@@ -243,8 +248,9 @@ class ThermostatOverValve(BaseThermostat[UnderlyingValve]): # pylint: disable=a
self._valve_open_percent = new_valve_percent
for under in self._underlyings:
under.set_valve_open_percent()
# is one in start_cycle now
# for under in self._underlyings:
# under.set_valve_open_percent()
self._last_calculation_timestamp = now

View File

@@ -28,7 +28,7 @@
"presence": "Presence detection",
"advanced": "Advanced parameters",
"auto_start_stop": "Auto start and stop",
"sonoff_trvzb": "Sonoff TRVZB configuration",
"valve_regulation": "Valve regulation configuration",
"finalize": "All done",
"configuration_not_complete": "Configuration not complete"
}
@@ -65,7 +65,7 @@
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
"use_presence_feature": "Use presence detection",
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page",
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after selecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page",
"use_auto_start_stop_feature": "Use the auto start and stop feature"
}
},
@@ -77,7 +77,6 @@
"heater_keep_alive": "Switch keep-alive interval in seconds",
"proportional_function": "Algorithm",
"ac_mode": "AC mode",
"sonoff_trvzb_mode": "SONOFF TRVZB mode",
"auto_regulation_mode": "Self-regulation",
"auto_regulation_dtemp": "Regulation threshold",
"auto_regulation_periode_min": "Regulation minimum period",
@@ -90,7 +89,6 @@
"heater_keep_alive": "Optional heater switch state refresh interval. Leave empty if not required.",
"proportional_function": "Algorithm to use (TPI is the only one for now)",
"ac_mode": "Use the Air Conditioning (AC) mode",
"sonoff_trvzb_mode": "The underlyings are SONOFF TRVZB. You have to configure some extra entities in the specific menu option 'Sonoff trvzb configuration'",
"auto_regulation_mode": "Auto adjustment of the target temperature",
"auto_regulation_dtemp": "The threshold in ° (or % for valve) under which the temperature change will not be sent",
"auto_regulation_periode_min": "Duration in minutes between two regulation update",
@@ -219,9 +217,9 @@
"central_boiler_deactivation_service": "Command to turn-off the central boiler formatted like entity_id/service_name[/attribut:valeur]"
}
},
"sonoff_trvzb": {
"title": "Sonoff TRVZB configuration",
"description": "Specific Sonoff TRVZB configuration",
"valve_regulation": {
"title": "Self-regulation with valve",
"description": "Configuration for self-regulation with direct control of the valve",
"data": {
"offset_calibration_entity_ids": "Offset calibration entities",
"opening_degree_entity_ids": "Opening degree entities",
@@ -229,9 +227,9 @@
"proportional_function": "Algorithm"
},
"data_description": {
"offset_calibration_entity_ids": "The list of the 'offset calibration' entities. There should be one per underlying climate entities",
"offset_calibration_entity_ids": "The list of the 'offset calibration' entities. Set it if your TRV have the entity for better regulation. There should be one per underlying climate entities",
"opening_degree_entity_ids": "The list of the 'opening degree' entities. There should be one per underlying climate entities",
"closing_degree_entity_ids": "The list of the 'closing degree' entities. There should be one per underlying climate entities",
"closing_degree_entity_ids": "The list of the 'closing degree' entities. Set it if your TRV have the entity for better regulation. There should be one per underlying climate entities",
"proportional_function": "Algorithm to use (TPI is the only one for now)"
}
}
@@ -274,7 +272,7 @@
"presence": "Presence detection",
"advanced": "Advanced parameters",
"auto_start_stop": "Auto start and stop",
"sonoff_trvzb": "Sonoff TRVZB configuration",
"valve_regulation": "Valve regulation configuration",
"finalize": "All done",
"configuration_not_complete": "Configuration not complete"
}
@@ -311,7 +309,7 @@
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
"use_presence_feature": "Use presence detection",
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page",
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after selecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page",
"use_auto_start_stop_feature": "Use the auto start and stop feature"
}
},
@@ -323,7 +321,6 @@
"heater_keep_alive": "Switch keep-alive interval in seconds",
"proportional_function": "Algorithm",
"ac_mode": "AC mode",
"sonoff_trvzb_mode": "SONOFF TRVZB mode",
"auto_regulation_mode": "Self-regulation",
"auto_regulation_dtemp": "Regulation threshold",
"auto_regulation_periode_min": "Regulation minimum period",
@@ -336,7 +333,6 @@
"heater_keep_alive": "Optional heater switch state refresh interval. Leave empty if not required.",
"proportional_function": "Algorithm to use (TPI is the only one for now)",
"ac_mode": "Use the Air Conditioning (AC) mode",
"sonoff_trvzb_mode": "The underlyings are SONOFF TRVZB. You have to configure some extra entities in the specific menu option 'Sonoff trvzb configuration'",
"auto_regulation_mode": "Auto adjustment of the target temperature",
"auto_regulation_dtemp": "The threshold in ° (or % for valve) under which the temperature change will not be sent",
"auto_regulation_periode_min": "Duration in minutes between two regulation update",
@@ -454,7 +450,7 @@
}
},
"central_boiler": {
"title": "Control of the central boiler",
"title": "Control of the central boiler - {name}",
"description": "Enter the services to call to turn on/off the central boiler. Leave blank if no service call is to be made (in this case, you will have to manage the turning on/off of your central boiler yourself). The service called must be formatted as follows: `entity_id/service_name[/attribute:value]` (/attribute:value is optional)\nFor example:\n- to turn on a switch: `switch.controle_chaudiere/switch.turn_on`\n- to turn off a switch: `switch.controle_chaudiere/switch.turn_off`\n- to program the boiler to 25° and thus force its ignition: `climate.thermostat_chaudiere/climate.set_temperature/temperature:25`\n- to send 10° to the boiler and thus force its extinction: `climate.thermostat_chaudiere/climate.set_temperature/temperature:10`",
"data": {
"central_boiler_activation_service": "Command to turn-on",
@@ -465,9 +461,9 @@
"central_boiler_deactivation_service": "Command to turn-off the central boiler formatted like entity_id/service_name[/attribut:valeur]"
}
},
"sonoff_trvzb": {
"title": "Sonoff TRVZB configuration",
"description": "Specific Sonoff TRVZB configuration",
"valve_regulation": {
"title": "Self-regulation with valve - {name}",
"description": "Configuration for self-regulation with direct control of the valve",
"data": {
"offset_calibration_entity_ids": "Offset calibration entities",
"opening_degree_entity_ids": "Opening degree entities",
@@ -475,9 +471,9 @@
"proportional_function": "Algorithm"
},
"data_description": {
"offset_calibration_entity_ids": "The list of the 'offset calibration' entities. There should be one per underlying climate entities",
"offset_calibration_entity_ids": "The list of the 'offset calibration' entities. Set it if your TRV have the entity for better regulation. There should be one per underlying climate entities",
"opening_degree_entity_ids": "The list of the 'opening degree' entities. There should be one per underlying climate entities",
"closing_degree_entity_ids": "The list of the 'closing degree' entities. There should be one per underlying climate entities",
"closing_degree_entity_ids": "The list of the 'closing degree' entities. Set it if your TRV have the entity for better regulation. There should be one per underlying climate entities",
"proportional_function": "Algorithm to use (TPI is the only one for now)"
}
}
@@ -488,7 +484,7 @@
"window_open_detection_method": "Only one window open detection method should be used. Use either window sensor or automatic detection through temperature threshold but not both",
"no_central_config": "You cannot check 'use central configuration' because no central configuration was found. You need to create a Versatile Thermostat of type 'Central Configuration' to use it.",
"service_configuration_format": "The format of the service configuration is wrong",
"sonoff_trvzb_nb_entities_incorrect": "The number of specific entities for Sonoff TRVZB should be equal to the number of underlyings"
"valve_regulation_nb_entities_incorrect": "The number of valve entities for valve regulation should be equal to the number of underlyings"
},
"abort": {
"already_configured": "Device is already configured"
@@ -510,7 +506,8 @@
"auto_regulation_medium": "Medium",
"auto_regulation_light": "Light",
"auto_regulation_expert": "Expert",
"auto_regulation_none": "No auto-regulation"
"auto_regulation_none": "No auto-regulation",
"auto_regulation_valve": "Direct control of valve"
}
},
"auto_fan_mode": {

View File

@@ -28,7 +28,7 @@
"presence": "Détection de présence",
"advanced": "Paramètres avancés",
"auto_start_stop": "Allumage/extinction automatique",
"sonoff_trvzb": "Configuration spécifique à Sonoff TRVZB",
"valve_regulation": "Configuration de la regulation par vanne",
"finalize": "Finaliser la création",
"configuration_not_complete": "Configuration incomplète"
}
@@ -77,7 +77,6 @@
"heater_keep_alive": "keep-alive (sec)",
"proportional_function": "Algorithme",
"ac_mode": "AC mode ?",
"sonoff_trvzb_mode": "Mode Sonoff TRVZB",
"auto_regulation_mode": "Auto-régulation",
"auto_regulation_dtemp": "Seuil de régulation",
"auto_regulation_periode_min": "Période minimale de régulation",
@@ -90,9 +89,8 @@
"heater_keep_alive": "Intervalle de rafraichissement du switch en secondes. Laisser vide pour désactiver. À n'utiliser que pour les switchs qui le nécessite.",
"proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)",
"ac_mode": "Utilisation du mode Air Conditionné (AC)",
"sonoff_trvzb_mode": "Les équipements sont des Sonoff TRVZB. Vous devez configurer les entités dédiées dans le menu 'Configuration Sonoff TRVZB'",
"auto_regulation_mode": "Ajustement automatique de la température cible",
"auto_regulation_dtemp": "Le seuil en ° (ou % pour les valves) en-dessous duquel la régulation ne sera pas envoyée",
"auto_regulation_mode": "Utilisation de l'auto-régulation faite par VTherm",
"auto_regulation_dtemp": "Le seuil en ° (ou % pour les vannes) en-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",
"auto_regulation_use_device_temp": "Compenser la temperature interne du sous-jacent pour accélérer l'auto-régulation",
"inverse_switch_command": "Inverse la commande du switch pour une installation avec fil pilote et diode",
@@ -219,19 +217,19 @@
"central_boiler_deactivation_service": "Commande à éxecuter pour étiendre la chaudière centrale au format entity_id/service_name[/attribut:valeur]"
}
},
"sonoff_trvzb": {
"title": "Configuration Sonoff TRVZB",
"description": "Configuration spécifique des Sonoff TRVZB",
"valve_regulation": {
"title": "Auto-régulation par vanne - {name}",
"description": "Configuration de l'auto-régulation par controle direct de la vanne",
"data": {
"offset_calibration_entity_ids": "Entités de 'Offset calibration'",
"opening_degree_entity_ids": "Entités de 'Opening degree'",
"closing_degree_entity_ids": "Entités de 'Closing degree'",
"offset_calibration_entity_ids": "Entités de 'calibrage du décalage''",
"opening_degree_entity_ids": "Entités 'ouverture de vanne'",
"closing_degree_entity_ids": "Entités 'fermeture de la vanne'",
"proportional_function": "Algorithme"
},
"data_description": {
"offset_calibration_entity_ids": "La liste des entités 'offset calibration' entities. Il doit y en avoir une par entité climate sous-jacente",
"opening_degree_entity_ids": "La liste des entités 'opening degree' entities. Il doit y en avoir une par entité climate sous-jacente",
"closing_degree_entity_ids": "La liste des entités 'closing degree' entities. Il doit y en avoir une par entité climate sous-jacente",
"offset_calibration_entity_ids": "La liste des entités 'calibrage du décalage' (offset calibration). Configurez le si votre TRV possède cette fonction pour une meilleure régulation. Il doit y en avoir une par entité climate sous-jacente",
"opening_degree_entity_ids": "La liste des entités 'ouverture de vanne'. Il doit y en avoir une par entité climate sous-jacente",
"closing_degree_entity_ids": "La liste des entités 'fermeture de la vanne'. Configurez le si votre TRV possède cette fonction pour une meilleure régulation. Il doit y en avoir une par entité climate sous-jacente",
"proportional_function": "Algorithme à utiliser (seulement TPI est disponible)"
}
}
@@ -274,7 +272,7 @@
"presence": "Détection de présence",
"advanced": "Paramètres avancés",
"auto_start_stop": "Allumage/extinction automatique",
"sonoff_trvzb": "Configuration spécifique à Sonoff TRVZB",
"valve_regulation": "Configuration de la regulation par vanne",
"finalize": "Finaliser les modifications",
"configuration_not_complete": "Configuration incomplète"
}
@@ -323,7 +321,6 @@
"heater_keep_alive": "keep-alive (sec)",
"proportional_function": "Algorithme",
"ac_mode": "AC mode ?",
"sonoff_trvzb_mode": "Mode Sonoff TRVZB",
"auto_regulation_mode": "Auto-régulation",
"auto_regulation_dtemp": "Seuil de régulation",
"auto_regulation_periode_min": "Période minimale de régulation",
@@ -336,9 +333,8 @@
"heater_keep_alive": "Intervalle de rafraichissement du switch en secondes. Laisser vide pour désactiver. À n'utiliser que pour les switchs qui le nécessite.",
"proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)",
"ac_mode": "Utilisation du mode Air Conditionné (AC)",
"sonoff_trvzb_mode": "Les équipements sont des Sonoff TRVZB. Vous devez configurer les entités dédiées dans le menu 'Configuration Sonoff TRVZB'",
"auto_regulation_mode": "Ajustement automatique de la température cible",
"auto_regulation_dtemp": "Le seuil en ° (ou % pour les valves) en-dessous duquel la régulation ne sera pas envoyée",
"auto_regulation_mode": "Utilisation de l'auto-régulation faite par VTherm",
"auto_regulation_dtemp": "Le seuil en ° (ou % pour les vannes) en-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",
"auto_regulation_use_device_temp": "Compenser la temperature interne du sous-jacent pour accélérer l'auto-régulation",
"inverse_switch_command": "Inverse la commande du switch pour une installation avec fil pilote et diode",
@@ -459,19 +455,19 @@
"central_boiler_deactivation_service": "Commande à éxecuter pour étiendre la chaudière centrale au format entity_id/service_name[/attribut:valeur]"
}
},
"sonoff_trvzb": {
"title": "Configuration Sonoff TRVZB - {name}",
"description": "Configuration spécifique des Sonoff TRVZB",
"valve_regulation": {
"title": "Auto-régulation par vanne - {name}",
"description": "Configuration de l'auto-régulation par controle direct de la vanne",
"data": {
"offset_calibration_entity_ids": "Entités de 'Offset calibration'",
"opening_degree_entity_ids": "Entités de 'Opening degree'",
"closing_degree_entity_ids": "Entités de 'Closing degree'",
"offset_calibration_entity_ids": "Entités de 'calibrage du décalage''",
"opening_degree_entity_ids": "Entités 'ouverture de vanne'",
"closing_degree_entity_ids": "Entités 'fermeture de la vanne'",
"proportional_function": "Algorithme"
},
"data_description": {
"offset_calibration_entity_ids": "La liste des entités 'offset calibration' entities. Il doit y en avoir une par entité climate sous-jacente",
"opening_degree_entity_ids": "La liste des entités 'opening degree' entities. Il doit y en avoir une par entité climate sous-jacente",
"closing_degree_entity_ids": "La liste des entités 'closing degree' entities. Il doit y en avoir une par entité climate sous-jacente",
"offset_calibration_entity_ids": "La liste des entités 'calibrage du décalage' (offset calibration). Configurez le si votre TRV possède cette fonction pour une meilleure régulation. Il doit y en avoir une par entité climate sous-jacente",
"opening_degree_entity_ids": "La liste des entités 'ouverture de vanne'. Il doit y en avoir une par entité climate sous-jacente",
"closing_degree_entity_ids": "La liste des entités 'fermeture de la vanne'. Configurez le si votre TRV possède cette fonction pour une meilleure régulation. Il doit y en avoir une par entité climate sous-jacente",
"proportional_function": "Algorithme à utiliser (seulement TPI est disponible)"
}
}
@@ -482,7 +478,7 @@
"window_open_detection_method": "Une seule méthode de détection des ouvertures ouvertes doit être utilisée. Utilisez le détecteur d'ouverture ou les seuils de température mais pas les deux.",
"no_central_config": "Vous ne pouvez pas cocher 'Utiliser la configuration centrale' car aucune configuration centrale n'a été trouvée. Vous devez créer un Versatile Thermostat de type 'Central Configuration' pour pouvoir l'utiliser.",
"service_configuration_format": "Mauvais format de la configuration du service",
"sonoff_trvzb_nb_entities_incorrect": "Le nombre d'entités spécifiques au Sonoff TRVZB doit être égal au nombre d'entité sous-jacentes"
"valve_regulation_nb_entities_incorrect": "Le nombre d'entités pour la régulation par vanne doit être égal au nombre d'entité sous-jacentes"
},
"abort": {
"already_configured": "Le device est déjà configuré"
@@ -504,7 +500,8 @@
"auto_regulation_medium": "Moyenne",
"auto_regulation_light": "Légère",
"auto_regulation_expert": "Expert",
"auto_regulation_none": "Aucune"
"auto_regulation_none": "Aucune",
"auto_regulation_valve": "Contrôle direct de la vanne"
}
},
"auto_fan_mode": {

View File

@@ -32,7 +32,7 @@ from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_call_later
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import UnknownEntity, overrides
from .const import UnknownEntity, overrides, get_safe_float
from .keep_alive import IntervalCaller
_LOGGER = logging.getLogger(__name__)
@@ -53,8 +53,8 @@ class UnderlyingEntityType(StrEnum):
# a valve
VALVE = "valve"
# a Sonoff TRVZB
SONOFF_TRVZB = "sonoff_trvzb"
# a direct valve regulation
VALVE_REGULATION = "valve_regulation"
class UnderlyingEntity:
@@ -871,7 +871,11 @@ class UnderlyingValve(UnderlyingEntity):
_last_sent_temperature = None
def __init__(
self, hass: HomeAssistant, thermostat: Any, valve_entity_id: str
self,
hass: HomeAssistant,
thermostat: Any,
valve_entity_id: str,
entity_type: UnderlyingEntityType = UnderlyingEntityType.VALVE,
) -> None:
"""Initialize the underlying valve"""
@@ -884,7 +888,7 @@ class UnderlyingValve(UnderlyingEntity):
self._async_cancel_cycle = None
self._should_relaunch_control_heating = False
self._hvac_mode = None
self._percent_open = self._thermostat.valve_open_percent
self._percent_open = None # self._thermostat.valve_open_percent
self._valve_entity_id = valve_entity_id
async def _send_value_to_number(self, number_entity_id: str, value: int):
@@ -920,7 +924,7 @@ class UnderlyingValve(UnderlyingEntity):
async def turn_on(self):
"""Nothing to do for Valve because it cannot be turned on"""
self.set_valve_open_percent()
await self.set_valve_open_percent()
async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool:
"""Set the HVACmode. Returns true if something have change"""
@@ -958,11 +962,8 @@ class UnderlyingValve(UnderlyingEntity):
force=False,
):
"""We use this function to change the on_percent"""
if force:
# self._percent_open = self.cap_sent_value(self._percent_open)
# await self.send_percent_open()
# avoid to send 2 times the same value at startup
self.set_valve_open_percent()
# if force:
await self.set_valve_open_percent()
@overrides
def cap_sent_value(self, value) -> float:
@@ -995,7 +996,7 @@ class UnderlyingValve(UnderlyingEntity):
return new_value
def set_valve_open_percent(self):
async def set_valve_open_percent(self):
"""Update the valve open percent"""
caped_val = self.cap_sent_value(self._thermostat.valve_open_percent)
if self._percent_open == caped_val:
@@ -1009,15 +1010,16 @@ class UnderlyingValve(UnderlyingEntity):
"%s - Setting valve ouverture percent to %s", self, self._percent_open
)
# Send the change to the valve, in background
self._hass.create_task(self.send_percent_open())
# self._hass.create_task(self.send_percent_open())
await self.send_percent_open()
def remove_entity(self):
"""Remove the entity after stopping its cycle"""
self._cancel_cycle()
class UnderlyingSonoffTRVZB(UnderlyingValve):
"""A specific underlying class for Sonoff TRVZB TRV"""
class UnderlyingValveRegulation(UnderlyingValve):
"""A specific underlying class for Valve regulation"""
_offset_calibration_entity_id: str
_opening_degree_entity_id: str
@@ -1030,15 +1032,23 @@ class UnderlyingSonoffTRVZB(UnderlyingValve):
offset_calibration_entity_id: str,
opening_degree_entity_id: str,
closing_degree_entity_id: str,
climate_underlying: UnderlyingClimate,
) -> None:
"""Initialize the underlying Sonoff TRV"""
super().__init__(hass, thermostat, opening_degree_entity_id)
"""Initialize the underlying TRV with valve regulation"""
super().__init__(
hass,
thermostat,
opening_degree_entity_id,
entity_type=UnderlyingEntityType.VALVE_REGULATION,
)
self._offset_calibration_entity_id = offset_calibration_entity_id
self._opening_degree_entity_id = opening_degree_entity_id
self._closing_degree_entity_id = closing_degree_entity_id
self._climate_underlying = climate_underlying
self._is_min_max_initialized = False
self._max_opening_degree = None
self._min_offset_calibration = None
self._max_offset_calibration = None
async def send_percent_open(self):
"""Send the percent open to the underlying valve"""
@@ -1049,13 +1059,21 @@ class UnderlyingSonoffTRVZB(UnderlyingValve):
self._max_opening_degree = self._hass.states.get(
self._opening_degree_entity_id
).attributes.get("max")
self._min_offset_calibration = self._hass.states.get(
self._offset_calibration_entity_id
).attributes.get("min")
self._is_min_max_initialized = (
self._max_opening_degree is not None
and self._min_offset_calibration is not None
if self.have_offset_calibration_entity:
self._min_offset_calibration = self._hass.states.get(
self._offset_calibration_entity_id
).attributes.get("min")
self._max_offset_calibration = self._hass.states.get(
self._offset_calibration_entity_id
).attributes.get("max")
self._is_min_max_initialized = self._max_opening_degree is not None and (
not self.have_offset_calibration_entity
or (
self._min_offset_calibration is not None
and self._max_offset_calibration is not None
)
)
if not self._is_min_max_initialized:
@@ -1067,15 +1085,46 @@ class UnderlyingSonoffTRVZB(UnderlyingValve):
# Send opening_degree
await super().send_percent_open()
# Send closing_degree. TODO 100 hard-coded or take the max of the _closing_degree_entity_id ?
await self._send_value_to_number(
self._closing_degree_entity_id,
self._max_opening_degree - self._percent_open,
)
# Send closing_degree if set
closing_degree = None
if self.have_closing_degree_entity:
await self._send_value_to_number(
self._closing_degree_entity_id,
closing_degree := self._max_opening_degree - self._percent_open,
)
# send offset_calibration to the min value
await self._send_value_to_number(
self._offset_calibration_entity_id, self._min_offset_calibration
# send offset_calibration to the difference between target temp and local temp
offset = None
if self.have_offset_calibration_entity:
if (
(local_temp := self._climate_underlying.underlying_current_temperature)
is not None
and (room_temp := self._thermostat.current_temperature) is not None
and (
current_offset := get_safe_float(
self._hass, self._offset_calibration_entity_id
)
)
is not None
):
offset = min(
self._max_offset_calibration,
max(
self._min_offset_calibration,
room_temp - (local_temp - current_offset),
),
)
await self._send_value_to_number(
self._offset_calibration_entity_id, offset
)
_LOGGER.debug(
"%s - valve regulation - I have sent offset_calibration=%s opening_degree=%s closing_degree=%s",
self,
offset,
self._percent_open,
closing_degree,
)
@property
@@ -1093,9 +1142,40 @@ class UnderlyingSonoffTRVZB(UnderlyingValve):
"""The offset_calibration_entity_id"""
return self._closing_degree_entity_id
@property
def have_closing_degree_entity(self) -> bool:
"""Return True if the underlying have a closing_degree entity"""
return self._closing_degree_entity_id is not None
@property
def have_offset_calibration_entity(self) -> bool:
"""Return True if the underlying have a offset_calibration entity"""
return self._offset_calibration_entity_id is not None
@property
def hvac_modes(self) -> list[HVACMode]:
"""Get the hvac_modes"""
if not self.is_initialized:
return []
return [HVACMode.OFF, HVACMode.HEAT]
@overrides
async def start_cycle(
self,
hvac_mode: HVACMode,
_1,
_2,
_3,
force=False,
):
"""We use this function to change the on_percent"""
# if force:
await self.set_valve_open_percent()
@property
def is_device_active(self):
"""If the opening valve is open."""
try:
return get_safe_float(self._hass, self._opening_degree_entity_id) > 0
except Exception: # pylint: disable=broad-exception-caught
return False

View File

@@ -15,6 +15,7 @@ from .const import (
CONF_SAFETY_MODE,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
CONF_MAX_ON_PERCENT,
)
VTHERM_API_NAME = "vtherm_api"
@@ -60,6 +61,7 @@ class VersatileThermostatAPI(dict):
self._central_mode_select = None
# A dict that will store all Number entities which holds the temperature
self._number_temperatures = dict()
self._max_on_percent = None
def find_central_configuration(self):
"""Search for a central configuration"""
@@ -107,6 +109,12 @@ class VersatileThermostatAPI(dict):
if self._safety_mode:
_LOGGER.debug("We have found safet_mode params %s", self._safety_mode)
self._max_on_percent = config.get(CONF_MAX_ON_PERCENT)
if self._max_on_percent:
_LOGGER.debug(
"We have found max_on_percent setting %s", self._max_on_percent
)
def register_central_boiler(self, central_boiler_entity):
"""Register the central boiler entity. This is used by the CentralBoilerBinarySensor
class to register itself at creation"""
@@ -171,7 +179,8 @@ class VersatileThermostatAPI(dict):
# ):
# await entity.init_presets(self.find_central_configuration())
# A little hack to test if the climate is a VTherm. Cannot use isinstance due to circular dependency of BaseThermostat
# A little hack to test if the climate is a VTherm. Cannot use isinstance
# due to circular dependency of BaseThermostat
if (
entity.device_info
and entity.device_info.get("model", None) == DOMAIN
@@ -241,6 +250,11 @@ class VersatileThermostatAPI(dict):
"""Get the safety_mode params"""
return self._safety_mode
@property
def max_on_percent(self):
"""Get the max_open_percent params"""
return self._max_on_percent
@property
def central_boiler_entity(self):
"""Get the central boiler binary_sensor entity"""

View File

@@ -3,6 +3,7 @@
""" Some common resources """
import asyncio
import logging
from typing import Any, Dict, Callable
from unittest.mock import patch, MagicMock # pylint: disable=unused-import
import pytest # pylint: disable=unused-import
@@ -30,10 +31,6 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
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.underlyings import * # pylint: disable=wildcard-import, unused-wildcard-import
from custom_components.versatile_thermostat.commons import ( # pylint: disable=unused-import
get_tz,
NowClass,
)
from custom_components.versatile_thermostat.vtherm_api import VersatileThermostatAPI
@@ -1007,12 +1004,50 @@ async def set_climate_preset_temp(
)
# The temperatures to set
default_temperatures_ac_away = {
"frost": 7.0,
"eco": 17.0,
"comfort": 19.0,
"boost": 21.0,
"eco_ac": 27.0,
"comfort_ac": 25.0,
"boost_ac": 23.0,
"frost_away": 7.1,
"eco_away": 17.1,
"comfort_away": 17.2,
"boost_away": 17.3,
"eco_ac_away": 27.1,
"comfort_ac_away": 25.1,
"boost_ac_away": 23.1,
}
default_temperatures_away = {
"frost": 7.0,
"eco": 17.0,
"comfort": 19.0,
"boost": 21.0,
"frost_away": 7.1,
"eco_away": 17.1,
"comfort_away": 17.2,
"boost_away": 17.3,
}
default_temperatures = {
"frost": 7.0,
"eco": 17.0,
"comfort": 19.0,
"boost": 21.0,
}
async def set_all_climate_preset_temp(
hass, vtherm: BaseThermostat, temps: dict, number_entity_base_name: str
hass, vtherm: BaseThermostat, temps: dict | None, number_entity_base_name: str
):
"""Initialize all temp of preset for a VTherm entity"""
local_temps = temps if temps is not None else default_temperatures
# We initialize
for preset_name, value in temps.items():
for preset_name, value in local_temps.items():
await set_climate_preset_temp(vtherm, preset_name, value)
@@ -1028,3 +1063,31 @@ async def set_all_climate_preset_temp(
assert temp_entity
# Because set_value is not implemented in Number class (really don't understand why...)
assert temp_entity.state == value
#
# Side effects management
#
SideEffectDict = Dict[str, Any]
class SideEffects:
"""A class to manage sideEffects for mock"""
def __init__(self, side_effects: SideEffectDict, default_side_effect: Any):
"""Initialise the side effects"""
self._current_side_effects: SideEffectDict = side_effects
self._default_side_effect: Any = default_side_effect
def get_side_effects(self) -> Callable[[str], Any]:
"""returns the method which apply the side effects"""
def side_effect_method(arg) -> Any:
"""Search a side effect definition and return it"""
return self._current_side_effects.get(arg, self._default_side_effect)
return side_effect_method
def add_or_update_side_effect(self, key: str, new_value: Any):
"""Update the value of a side effect"""
self._current_side_effects[key] = new_value

View File

@@ -46,7 +46,7 @@ async def test_over_climate_regulation(
event_timestamp = now - timedelta(minutes=10)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
@@ -87,7 +87,7 @@ async def test_over_climate_regulation(
# 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",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
):
await entity.async_set_temperature(temperature=18)
@@ -108,7 +108,7 @@ async def test_over_climate_regulation(
# 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",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 23, event_timestamp)
@@ -144,7 +144,7 @@ async def test_over_climate_regulation_ac_mode(
event_timestamp = now - timedelta(minutes=10)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
@@ -183,7 +183,7 @@ async def test_over_climate_regulation_ac_mode(
# set manual target temp
event_timestamp = now - timedelta(minutes=7)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
):
await entity.async_set_temperature(temperature=25)
@@ -204,7 +204,7 @@ async def test_over_climate_regulation_ac_mode(
# 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",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 26, event_timestamp)
@@ -219,7 +219,7 @@ async def test_over_climate_regulation_ac_mode(
# 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",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 18, event_timestamp)
@@ -260,7 +260,7 @@ async def test_over_climate_regulation_limitations(
event_timestamp = now - timedelta(minutes=20)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
@@ -286,71 +286,61 @@ async def test_over_climate_regulation_limitations(
assert entity.is_over_climate is True
assert entity.is_regulated is True
entity._set_now(event_timestamp)
# Will initialize the _last_regulation_change
# 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
await entity.async_set_temperature(temperature=17)
# 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
# 1. 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)
entity._set_now(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
fake_underlying_climate.set_hvac_action(
HVACAction.HEATING
) # simulate under heating
assert entity.hvac_action == HVACAction.HEATING
# the regulated temperature will change because when we set temp manually it is forced
assert entity.regulated_target_temp == 19.5
# the regulated temperature will not change because when we set temp manually it is forced
assert entity.regulated_target_temp == 17 # 19.5
# set manual target temp (at now - 18) -> the regulation should be taken into account
# 2. 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
) # In strong we could go up to +3 degre. 0.7 without round_to_nearest
old_regulated_temp = entity.regulated_target_temp
entity._set_now(event_timestamp)
# change temperature so that dtemp < 0.5 and time is > period_min (+ 3min)
await entity.async_set_temperature(temperature=17)
assert entity.regulated_target_temp > entity.target_temperature
assert (
entity.regulated_target_temp == 18 + 0
) # In strong we could go up to +3 degre. 0.7 without round_to_nearest
old_regulated_temp = entity.regulated_target_temp
# 3. 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)
entity._set_now(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
# 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)
# 4. 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, 15, event_timestamp)
await send_ext_temperature_change_event(entity, 12, event_timestamp)
entity._set_now(event_timestamp)
await send_ext_temperature_change_event(entity, 12, event_timestamp)
await send_temperature_change_event(entity, 15, 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 + 1.5
) # 0.7 without round_to_nearest
# 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 + 1.5 # 0.7 without round_to_nearest
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@@ -383,7 +373,7 @@ async def test_over_climate_regulation_use_device_temp(
event_timestamp = now - timedelta(minutes=10)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
@@ -416,7 +406,7 @@ async def test_over_climate_regulation_use_device_temp(
fake_underlying_climate.set_current_temperature(15)
event_timestamp = now - timedelta(minutes=7)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
await entity.async_set_temperature(temperature=16)
@@ -462,7 +452,7 @@ async def test_over_climate_regulation_use_device_temp(
event_timestamp = now - timedelta(minutes=5)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
await send_temperature_change_event(entity, 15, event_timestamp)
@@ -497,7 +487,7 @@ async def test_over_climate_regulation_use_device_temp(
event_timestamp = now - timedelta(minutes=3)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
await send_temperature_change_event(entity, 25, event_timestamp)
@@ -545,7 +535,7 @@ async def test_over_climate_regulation_dtemp_null(
event_timestamp = now - timedelta(minutes=20)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
@@ -573,7 +563,7 @@ async def test_over_climate_regulation_dtemp_null(
# set manual target temp
event_timestamp = now - timedelta(minutes=17)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
):
await entity.async_set_temperature(temperature=20)
@@ -594,7 +584,7 @@ async def test_over_climate_regulation_dtemp_null(
# change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=15)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 19, event_timestamp)
@@ -607,7 +597,7 @@ async def test_over_climate_regulation_dtemp_null(
# change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=13)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 20, event_timestamp)
@@ -621,7 +611,7 @@ async def test_over_climate_regulation_dtemp_null(
# Test if a small temperature change is taken into account : change temperature so that dtemp < 0.5 and time is > period_min (+ 3min)
event_timestamp = now - timedelta(minutes=10)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
"custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 19.6, event_timestamp)

View File

@@ -1401,7 +1401,7 @@ async def test_auto_start_stop_fast_heat_window_mixed(
assert vtherm.hvac_off_reason == HVAC_OFF_REASON_WINDOW_DETECTION
assert vtherm._saved_hvac_mode == HVACMode.HEAT
assert mock_send_event.call_count == 2
assert mock_send_event.call_count == 1
assert vtherm.window_state == STATE_ON

View File

@@ -161,19 +161,6 @@ async def test_bug_272(
"homeassistant.core.ServiceRegistry.async_call"
) as mock_service_call:
entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname")
# entry.add_to_hass(hass)
# await hass.config_entries.async_setup(entry.entry_id)
# assert entry.state is ConfigEntryState.LOADED
#
# def find_my_entity(entity_id) -> ClimateEntity:
# """Find my new entity"""
# component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN]
# for entity in component.entities:
# if entity.entity_id == entity_id:
# return entity
#
# entity = find_my_entity("climate.theoverclimatemockname")
assert entity
assert entity.name == "TheOverClimateMockName"
@@ -215,16 +202,18 @@ async def test_bug_272(
)
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
event_timestamp: datetime = datetime.now(tz=tz)
entity._set_now(now)
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
# Set room temperature to something very cold
event_timestamp = now + timedelta(minutes=1)
await send_temperature_change_event(entity, 13, now)
await send_ext_temperature_change_event(entity, 9, now)
await send_temperature_change_event(entity, 13, event_timestamp)
await send_ext_temperature_change_event(entity, 9, event_timestamp)
event_timestamp = event_timestamp + timedelta(minutes=3)
entity._set_now(event_timestamp)
# Not in the accepted interval (15-19)
await entity.async_set_temperature(temperature=10)
@@ -248,12 +237,15 @@ async def test_bug_272(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
# Set room temperature to something very cold
event_timestamp = now + timedelta(minutes=1)
event_timestamp = event_timestamp + timedelta(minutes=1)
entity._set_now(event_timestamp)
await send_temperature_change_event(entity, 13, event_timestamp)
await send_ext_temperature_change_event(entity, 9, event_timestamp)
# In the accepted interval
event_timestamp = event_timestamp + timedelta(minutes=3)
entity._set_now(event_timestamp)
await entity.async_set_temperature(temperature=20.8)
assert mock_service_call.call_count == 1
mock_service_call.assert_has_calls(

View File

@@ -1,7 +1,6 @@
# pylint: disable=unused-argument, line-too-long
# pylint: disable=unused-argument, line-too-long, too-many-lines
""" Test the Versatile Thermostat config flow """
from homeassistant import data_entry_flow
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import SOURCE_USER, ConfigEntry
@@ -517,7 +516,7 @@ async def test_user_config_flow_over_climate(
CONF_USE_ADVANCED_CENTRAL_CONFIG: False,
CONF_USED_BY_CENTRAL_BOILER: False,
CONF_USE_CENTRAL_MODE: False,
CONF_SONOFF_TRZB_MODE: False,
CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_STRONG,
}
assert result["result"]
assert result["result"].domain == DOMAIN
@@ -1127,7 +1126,7 @@ async def test_user_config_flow_over_climate_auto_start_stop(
CONF_USED_BY_CENTRAL_BOILER: False,
CONF_USE_AUTO_START_STOP_FEATURE: True,
CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_MEDIUM,
CONF_SONOFF_TRZB_MODE: False,
CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_STRONG,
}
assert result["result"]
assert result["result"].domain == DOMAIN
@@ -1386,3 +1385,339 @@ async def test_user_config_flow_over_switch_bug_552_tpi(
assert result["result"].version == 2
assert result["result"].title == "TheOverSwitchMockName"
assert isinstance(result["result"], ConfigEntry)
# @pytest.mark.parametrize("expected_lingering_tasks", [True])
# @pytest.mark.parametrize("expected_lingering_timers", [True])
# @pytest.mark.skip
async def test_user_config_flow_over_climate_valve(
hass: HomeAssistant, skip_hass_states_get
): # pylint: disable=unused-argument
"""Test the config flow with all thermostat_over_climate with the valve regulation activated.
We don't use any features nor central config
but we will add multiple underlying climate and valve"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == SOURCE_USER
# 1. Type
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
},
)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "menu"
assert result["menu_options"] == [
"main",
"features",
"type",
"presets",
"advanced",
"configuration_not_complete",
]
assert result.get("errors") is None
# 2. Main
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "main"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "main"
assert result.get("errors") == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_NAME: "TheOverClimateMockName",
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_DEVICE_POWER: 1,
CONF_USE_MAIN_CENTRAL_CONFIG: False,
CONF_USE_CENTRAL_MODE: False,
# Keep default values which are False
},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "main"
assert result.get("errors") == {}
# 3. Main 2
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
CONF_STEP_TEMPERATURE: 0.1,
# Keep default values which are False
},
)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "menu"
assert result.get("errors") is None
# 4. Type
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "type"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "type"
assert result.get("errors") == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_UNDERLYING_LIST: ["climate.mock_climate1", "climate.mock_climate2"],
CONF_AC_MODE: False,
CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_VALVE,
CONF_AUTO_REGULATION_DTEMP: 0.5,
CONF_AUTO_REGULATION_PERIOD_MIN: 2,
CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_HIGH,
CONF_AUTO_REGULATION_USE_DEVICE_TEMP: False,
},
)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "menu"
assert result["menu_options"] == [
"main",
"features",
"type",
"tpi",
"presets",
"valve_regulation",
"advanced",
"configuration_not_complete",
# "finalize", # because we need Advanced default parameters
]
assert result.get("errors") is None
# 5. TPI
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "tpi"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "tpi"
assert result.get("errors") == {}
# 6. TPI 2
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_USE_TPI_CENTRAL_CONFIG: False}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "tpi"
assert result.get("errors") == {}
# 7. Menu
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TPI_CONFIG
)
# 8. Presets
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "presets"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "presets"
assert result.get("errors") == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_USE_PRESETS_CENTRAL_CONFIG: False}
)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "menu"
assert result.get("errors") is None
# 9. Features
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "features"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "features"
assert result.get("errors") == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_AUTO_START_STOP_FEATURE: False,
},
)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "menu"
assert result.get("errors") is None
assert result["menu_options"] == [
"main",
"features",
"type",
"tpi",
"presets",
"valve_regulation",
"advanced",
"configuration_not_complete",
# "finalize", finalize is not present waiting for advanced configuration
]
# 11. Valve_regulation
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "valve_regulation"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "valve_regulation"
assert result.get("errors") == {}
# 11.1 Only one but 2 expected
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_OFFSET_CALIBRATION_LIST: ["number.offset_calibration1"],
CONF_OPENING_DEGREE_LIST: ["number.opening_degree1"],
CONF_CLOSING_DEGREE_LIST: ["number.closing_degree1"],
},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "valve_regulation"
assert result.get("errors") == {"base": "valve_regulation_nb_entities_incorrect"}
# 11.2 Give two openings but only one offset_calibration
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_OFFSET_CALIBRATION_LIST: [
"number.offset_calibration1",
"number.offset_calibration2",
],
CONF_OPENING_DEGREE_LIST: [
"number.opening_degree1",
"number.opening_degree2",
],
CONF_CLOSING_DEGREE_LIST: ["number.closing_degree1"],
},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "valve_regulation"
assert result.get("errors") == {"base": "valve_regulation_nb_entities_incorrect"}
# 11.3 Give two openings and 2 calibration and 0 closing
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_OFFSET_CALIBRATION_LIST: [
"number.offset_calibration1",
"number.offset_calibration2",
],
CONF_OPENING_DEGREE_LIST: [
"number.opening_degree1",
"number.opening_degree2",
],
CONF_CLOSING_DEGREE_LIST: [],
},
)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "menu"
assert result.get("errors") is None
assert result["menu_options"] == [
"main",
"features",
"type",
"tpi",
"presets",
"valve_regulation",
"advanced",
"configuration_not_complete",
# "finalize", finalize is not present waiting for advanced configuration
]
# 10. Advanced
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "advanced"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "advanced"
assert result.get("errors") == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_USE_ADVANCED_CENTRAL_CONFIG: False},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "advanced"
assert result.get("errors") == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_MINIMAL_ACTIVATION_DELAY: 10,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.4,
CONF_SECURITY_DEFAULT_ON_PERCENT: 0.3,
},
)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "menu"
assert result.get("errors") is None
assert result["menu_options"] == [
"main",
"features",
"type",
"tpi",
"presets",
"valve_regulation",
"advanced",
"finalize", # Now it is complete
]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "finalize"}
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result.get("errors") is None
assert result[
"data"
] == MOCK_TH_OVER_CLIMATE_USER_CONFIG | MOCK_TH_OVER_CLIMATE_MAIN_CONFIG | MOCK_TH_OVER_CLIMATE_CENTRAL_MAIN_CONFIG | MOCK_TH_OVER_CLIMATE_TYPE_CONFIG | {
CONF_MINIMAL_ACTIVATION_DELAY: 10,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.4,
CONF_SECURITY_DEFAULT_ON_PERCENT: 0.3,
} | MOCK_DEFAULT_FEATURE_CONFIG | {
CONF_USE_MAIN_CENTRAL_CONFIG: False,
CONF_USE_PRESETS_CENTRAL_CONFIG: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_AUTO_START_STOP_FEATURE: False,
CONF_USE_CENTRAL_BOILER_FEATURE: False,
CONF_USE_TPI_CENTRAL_CONFIG: False,
CONF_USE_WINDOW_CENTRAL_CONFIG: False,
CONF_USE_MOTION_CENTRAL_CONFIG: False,
CONF_USE_POWER_CENTRAL_CONFIG: False,
CONF_USE_PRESENCE_CENTRAL_CONFIG: False,
CONF_USE_ADVANCED_CENTRAL_CONFIG: False,
CONF_USED_BY_CENTRAL_BOILER: False,
CONF_USE_CENTRAL_MODE: False,
CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_VALVE,
CONF_UNDERLYING_LIST: ["climate.mock_climate1", "climate.mock_climate2"],
CONF_OPENING_DEGREE_LIST: ["number.opening_degree1", "number.opening_degree2"],
CONF_CLOSING_DEGREE_LIST: [],
CONF_OFFSET_CALIBRATION_LIST: [
"number.offset_calibration1",
"number.offset_calibration2",
],
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.1,
}
assert result["result"]
assert result["result"].domain == DOMAIN
assert result["result"].version == 2
assert result["result"].title == "TheOverClimateMockName"
assert isinstance(result["result"], ConfigEntry)

View File

@@ -1,6 +1,6 @@
# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long, too-many-lines
""" Test the Window management """
""" Test the over_climate Vtherm """
from unittest.mock import patch, call
from datetime import datetime, timedelta
@@ -517,6 +517,9 @@ async def test_bug_508(
data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay
)
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
# Min_temp is 10 and max_temp is 31 and features contains TARGET_TEMPERATURE_RANGE
fake_underlying_climate = MagicMockClimateWithTemperatureRange()
@@ -545,6 +548,9 @@ async def test_bug_508(
# Set the hvac_mode to HEAT
await entity.async_set_hvac_mode(HVACMode.HEAT)
now = now + timedelta(minutes=3) # avoid temporal filter
entity._set_now(now)
# Not In the accepted interval -> should be converted into 10 (the min) and send with target_temp_high and target_temp_low
await entity.async_set_temperature(temperature=8.5)
@@ -568,6 +574,9 @@ async def test_bug_508(
with patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
# Not In the accepted interval -> should be converted into 10 (the min) and send with target_temp_high and target_temp_low
now = now + timedelta(minutes=3) # avoid temporal filter
entity._set_now(now)
await entity.async_set_temperature(temperature=32)
# MagicMock climate is already HEAT by default. So there is no SET_HAVC_MODE call
@@ -972,7 +981,7 @@ async def test_manual_hvac_off_should_take_the_lead_over_window(
assert vtherm.hvac_off_reason == HVAC_OFF_REASON_WINDOW_DETECTION
assert vtherm._saved_hvac_mode == HVACMode.HEAT
assert mock_send_event.call_count == 2
assert mock_send_event.call_count == 1
assert vtherm.window_state == STATE_ON

View File

@@ -0,0 +1,475 @@
# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long, too-many-lines
""" Test the over_climate with valve regulation """
from unittest.mock import patch, call
from datetime import datetime, timedelta
import logging
from homeassistant.core import HomeAssistant, State
from custom_components.versatile_thermostat.thermostat_climate_valve import (
ThermostatOverClimateValve,
)
from .commons import *
from .const import *
logging.getLogger().setLevel(logging.DEBUG)
# @pytest.mark.parametrize("expected_lingering_tasks", [True])
# @pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_over_climate_valve_mono(hass: HomeAssistant, skip_hass_states_get):
"""Test the normal full start of a thermostat in thermostat_over_climate type"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverClimateMockName",
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_DEVICE_POWER: 1,
CONF_USE_MAIN_CENTRAL_CONFIG: False,
CONF_USE_CENTRAL_MODE: False,
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
CONF_STEP_TEMPERATURE: 0.1,
CONF_UNDERLYING_LIST: ["climate.mock_climate"],
CONF_AC_MODE: False,
CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_VALVE,
CONF_AUTO_REGULATION_DTEMP: 0.5,
CONF_AUTO_REGULATION_PERIOD_MIN: 2,
CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_HIGH,
CONF_AUTO_REGULATION_USE_DEVICE_TEMP: False,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.1,
CONF_OPENING_DEGREE_LIST: ["number.mock_opening_degree"],
CONF_CLOSING_DEGREE_LIST: ["number.mock_closing_degree"],
CONF_OFFSET_CALIBRATION_LIST: ["number.mock_offset_calibration"],
}
| MOCK_DEFAULT_FEATURE_CONFIG
| MOCK_DEFAULT_CENTRAL_CONFIG
| MOCK_ADVANCED_CONFIG,
)
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {})
# mock_get_state will be called for each OPENING/CLOSING/OFFSET_CALIBRATION list
mock_get_state_side_effect = SideEffects(
{
"number.mock_opening_degree": State(
"number.mock_opening_degree", "0", {"min": 0, "max": 100}
),
"number.mock_closing_degree": State(
"number.mock_closing_degree", "0", {"min": 0, "max": 100}
),
"number.mock_offset_calibration": State(
"number.mock_offset_calibration", "0", {"min": -12, "max": 12}
),
},
State("unknown.entity_id", "unknown"),
)
# 1. initialize the VTherm
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
# fmt: off
with patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \
patch("custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", return_value=fake_underlying_climate) as mock_find_climate, \
patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call,\
patch("homeassistant.core.StateMachine.get", side_effect=mock_get_state_side_effect.get_side_effects()) as mock_get_state:
# fmt: on
vtherm: ThermostatOverClimateValve = await create_thermostat(hass, entry, "climate.theoverclimatemockname")
assert vtherm
vtherm._set_now(now)
assert isinstance(vtherm, ThermostatOverClimateValve)
assert vtherm.name == "TheOverClimateMockName"
assert vtherm.is_over_climate is True
assert vtherm.have_valve_regulation is True
assert vtherm.hvac_action is HVACAction.OFF
assert vtherm.hvac_mode is HVACMode.OFF
assert vtherm.target_temperature == vtherm.min_temp
assert vtherm.preset_modes == [
PRESET_NONE,
PRESET_FROST_PROTECTION,
PRESET_ECO,
PRESET_COMFORT,
PRESET_BOOST,
]
assert vtherm.preset_mode is PRESET_NONE
assert vtherm._security_state is False
assert vtherm._window_state is None
assert vtherm._motion_state is None
assert vtherm._presence_state is None
assert vtherm.is_device_active is False
assert vtherm.valve_open_percent == 0
# 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},
),
]
)
mock_find_climate.assert_called_once()
mock_find_climate.assert_has_calls([call.find_underlying_vtherm()])
# the underlying set temperature call but no call to valve yet because VTherm is off
assert mock_service_call.call_count == 3
mock_service_call.assert_has_calls(
[
call("climate","set_temperature",{
"entity_id": "climate.mock_climate",
"temperature": 15, # temp-min
},
),
call(domain='number', service='set_value', service_data={'value': 0}, target={'entity_id': 'number.mock_opening_degree'}),
call(domain='number', service='set_value', service_data={'value': 100}, target={'entity_id': 'number.mock_closing_degree'}),
# we have no current_temperature yet
# call(domain='number', service='set_value', service_data={'value': 12}, target={'entity_id': 'number.mock_offset_calibration'}),
]
)
assert mock_get_state.call_count > 5 # each temp sensor + each valve
# initialize the temps
await set_all_climate_preset_temp(hass, vtherm, None, "theoverclimatemockname")
await send_temperature_change_event(vtherm, 18, now, True)
await send_ext_temperature_change_event(vtherm, 18, now, True)
# 2. Starts heating slowly (18 vs 19)
now = now + timedelta(minutes=1)
vtherm._set_now(now)
await vtherm.async_set_hvac_mode(HVACMode.HEAT)
# fmt: off
with patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \
patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call,\
patch("homeassistant.core.StateMachine.get", side_effect=mock_get_state_side_effect.get_side_effects()) as mock_get_state:
# fmt: on
now = now + timedelta(minutes=2) # avoid temporal filter
vtherm._set_now(now)
await vtherm.async_set_preset_mode(PRESET_COMFORT)
await hass.async_block_till_done()
assert vtherm.hvac_mode is HVACMode.HEAT
assert vtherm.preset_mode is PRESET_COMFORT
assert vtherm.target_temperature == 19
assert vtherm.current_temperature == 18
assert vtherm.valve_open_percent == 40 # 0.3*1 + 0.1*1
assert mock_service_call.call_count == 4
mock_service_call.assert_has_calls(
[
call('climate', 'set_temperature', {'entity_id': 'climate.mock_climate', 'temperature': 19.0}),
call(domain='number', service='set_value', service_data={'value': 40}, target={'entity_id': 'number.mock_opening_degree'}),
call(domain='number', service='set_value', service_data={'value': 60}, target={'entity_id': 'number.mock_closing_degree'}),
# 3 = 18 (room) - 15 (current of underlying) + 0 (current offset)
call(domain='number', service='set_value', service_data={'value': 3.0}, target={'entity_id': 'number.mock_offset_calibration'})
]
)
# set the opening to 40%
mock_get_state_side_effect.add_or_update_side_effect(
"number.mock_opening_degree",
State(
"number.mock_opening_degree", "40", {"min": 0, "max": 100}
))
assert vtherm.hvac_action is HVACAction.HEATING
assert vtherm.is_device_active is True
# 2. Starts heating very slowly (18.9 vs 19)
now = now + timedelta(minutes=2)
vtherm._set_now(now)
# fmt: off
with patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \
patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call,\
patch("homeassistant.core.StateMachine.get", side_effect=mock_get_state_side_effect.get_side_effects()) as mock_get_state:
# fmt: on
# set the offset to 3
mock_get_state_side_effect.add_or_update_side_effect(
"number.mock_offset_calibration",
State(
"number.mock_offset_calibration", "3", {"min": -12, "max": 12}
))
await send_temperature_change_event(vtherm, 18.9, now, True)
await hass.async_block_till_done()
assert vtherm.hvac_mode is HVACMode.HEAT
assert vtherm.preset_mode is PRESET_COMFORT
assert vtherm.target_temperature == 19
assert vtherm.current_temperature == 18.9
assert vtherm.valve_open_percent == 13 # 0.3*0.1 + 0.1*1
assert mock_service_call.call_count == 3
mock_service_call.assert_has_calls(
[
call(domain='number', service='set_value', service_data={'value': 13}, target={'entity_id': 'number.mock_opening_degree'}),
call(domain='number', service='set_value', service_data={'value': 87}, target={'entity_id': 'number.mock_closing_degree'}),
# 6 = 18 (room) - 15 (current of underlying) + 3 (current offset)
call(domain='number', service='set_value', service_data={'value': 6.899999999999999}, target={'entity_id': 'number.mock_offset_calibration'})
]
)
# set the opening to 13%
mock_get_state_side_effect.add_or_update_side_effect(
"number.mock_opening_degree",
State(
"number.mock_opening_degree", "13", {"min": 0, "max": 100}
))
assert vtherm.hvac_action is HVACAction.HEATING
assert vtherm.is_device_active is True
# 3. Stop heating 21 > 19
now = now + timedelta(minutes=2)
vtherm._set_now(now)
# fmt: off
with patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \
patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call,\
patch("homeassistant.core.StateMachine.get", side_effect=mock_get_state_side_effect.get_side_effects()) as mock_get_state:
# fmt: on
# set the offset to 3
mock_get_state_side_effect.add_or_update_side_effect(
"number.mock_offset_calibration",
State(
"number.mock_offset_calibration", "3", {"min": -12, "max": 12}
))
await send_temperature_change_event(vtherm, 21, now, True)
await hass.async_block_till_done()
assert vtherm.hvac_mode is HVACMode.HEAT
assert vtherm.preset_mode is PRESET_COMFORT
assert vtherm.target_temperature == 19
assert vtherm.current_temperature == 21
assert vtherm.valve_open_percent == 0 # 0.3* (-2) + 0.1*1
assert mock_service_call.call_count == 3
mock_service_call.assert_has_calls(
[
call(domain='number', service='set_value', service_data={'value': 0}, target={'entity_id': 'number.mock_opening_degree'}),
call(domain='number', service='set_value', service_data={'value': 100}, target={'entity_id': 'number.mock_closing_degree'}),
# 6 = 18 (room) - 15 (current of underlying) + 3 (current offset)
call(domain='number', service='set_value', service_data={'value': 9.0}, target={'entity_id': 'number.mock_offset_calibration'})
]
)
# set the opening to 13%
mock_get_state_side_effect.add_or_update_side_effect(
"number.mock_opening_degree",
State(
"number.mock_opening_degree", "0", {"min": 0, "max": 100}
))
assert vtherm.hvac_action is HVACAction.OFF
assert vtherm.is_device_active is False
await hass.async_block_till_done()
async def test_over_climate_valve_multi_presence(
hass: HomeAssistant, skip_hass_states_get
):
"""Test the normal full start of a thermostat in thermostat_over_climate type"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverClimateMockName",
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_DEVICE_POWER: 1,
CONF_USE_MAIN_CENTRAL_CONFIG: False,
CONF_USE_CENTRAL_MODE: False,
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
CONF_STEP_TEMPERATURE: 0.1,
CONF_UNDERLYING_LIST: ["climate.mock_climate1", "climate.mock_climate2"],
CONF_AC_MODE: False,
CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_VALVE,
CONF_AUTO_REGULATION_DTEMP: 0.5,
CONF_AUTO_REGULATION_PERIOD_MIN: 2,
CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_HIGH,
CONF_AUTO_REGULATION_USE_DEVICE_TEMP: False,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.1,
CONF_OPENING_DEGREE_LIST: [
"number.mock_opening_degree1",
"number.mock_opening_degree2",
],
CONF_CLOSING_DEGREE_LIST: [
"number.mock_closing_degree1",
"number.mock_closing_degree2",
],
CONF_OFFSET_CALIBRATION_LIST: [
"number.mock_offset_calibration1",
"number.mock_offset_calibration2",
],
CONF_USE_PRESENCE_FEATURE: True,
CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor",
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
}
| MOCK_DEFAULT_CENTRAL_CONFIG
| MOCK_ADVANCED_CONFIG,
)
fake_underlying_climate1 = MockClimate(
hass, "mockUniqueId1", "MockClimateName1", {}
)
fake_underlying_climate2 = MockClimate(
hass, "mockUniqueId2", "MockClimateName2", {}
)
# mock_get_state will be called for each OPENING/CLOSING/OFFSET_CALIBRATION list
mock_get_state_side_effect = SideEffects(
{
# Valve 1 is open
"number.mock_opening_degree1": State(
"number.mock_opening_degree1", "10", {"min": 0, "max": 100}
),
"number.mock_closing_degree1": State(
"number.mock_closing_degree1", "90", {"min": 0, "max": 100}
),
"number.mock_offset_calibration1": State(
"number.mock_offset_calibration1", "0", {"min": -12, "max": 12}
),
# Valve 2 is closed
"number.mock_opening_degree2": State(
"number.mock_opening_degree2", "0", {"min": 0, "max": 100}
),
"number.mock_closing_degree2": State(
"number.mock_closing_degree2", "100", {"min": 0, "max": 100}
),
"number.mock_offset_calibration2": State(
"number.mock_offset_calibration2", "10", {"min": -12, "max": 12}
),
},
State("unknown.entity_id", "unknown"),
)
# 1. initialize the VTherm
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
# fmt: off
with patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \
patch("custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", side_effect=[fake_underlying_climate1, fake_underlying_climate2]) as mock_find_climate, \
patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call,\
patch("homeassistant.core.StateMachine.get", side_effect=mock_get_state_side_effect.get_side_effects()) as mock_get_state:
# fmt: on
vtherm: ThermostatOverClimateValve = await create_thermostat(hass, entry, "climate.theoverclimatemockname")
assert vtherm
assert isinstance(vtherm, ThermostatOverClimateValve)
assert vtherm.name == "TheOverClimateMockName"
assert vtherm.is_over_climate is True
assert vtherm.have_valve_regulation is True
vtherm._set_now(now)
# initialize the temps
await set_all_climate_preset_temp(hass, vtherm, default_temperatures_away, "theoverclimatemockname")
await send_temperature_change_event(vtherm, 18, now, True)
await send_ext_temperature_change_event(vtherm, 18, now, True)
await send_presence_change_event(vtherm, False, True, now)
await vtherm.async_set_preset_mode(PRESET_COMFORT)
await vtherm.async_set_hvac_mode(HVACMode.HEAT)
assert vtherm.target_temperature == 17.2
# 2: set presence on -> should activate the valve and change target
# fmt: off
with patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \
patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call,\
patch("homeassistant.core.StateMachine.get", side_effect=mock_get_state_side_effect.get_side_effects()) as mock_get_state:
# fmt: on
now = now + timedelta(minutes=3)
vtherm._set_now(now)
await send_presence_change_event(vtherm, True, False, now)
await hass.async_block_till_done()
assert vtherm.is_device_active is True
assert vtherm.valve_open_percent == 40
# the underlying set temperature call and the call to the valve
assert mock_service_call.call_count == 8
mock_service_call.assert_has_calls([
call('climate', 'set_temperature', {'entity_id': 'climate.mock_climate1', 'temperature': 19.0}),
call('climate', 'set_temperature', {'entity_id': 'climate.mock_climate2', 'temperature': 19.0}),
call(domain='number', service='set_value', service_data={'value': 40}, target={'entity_id': 'number.mock_opening_degree1'}),
call(domain='number', service='set_value', service_data={'value': 60}, target={'entity_id': 'number.mock_closing_degree1'}),
call(domain='number', service='set_value', service_data={'value': 3.0}, target={'entity_id': 'number.mock_offset_calibration1'}),
call(domain='number', service='set_value', service_data={'value': 40}, target={'entity_id': 'number.mock_opening_degree2'}),
call(domain='number', service='set_value', service_data={'value': 60}, target={'entity_id': 'number.mock_closing_degree2'}),
call(domain='number', service='set_value', service_data={'value': 12}, target={'entity_id': 'number.mock_offset_calibration2'})
]
)
# 3: set presence off -> should deactivate the valve and change target
# fmt: off
with patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \
patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call,\
patch("homeassistant.core.StateMachine.get", side_effect=mock_get_state_side_effect.get_side_effects()) as mock_get_state:
# fmt: on
now = now + timedelta(minutes=3)
vtherm._set_now(now)
await send_presence_change_event(vtherm, False, True, now)
await hass.async_block_till_done()
assert vtherm.is_device_active is False
assert vtherm.valve_open_percent == 0
# the underlying set temperature call and the call to the valve
assert mock_service_call.call_count == 8
mock_service_call.assert_has_calls([
call('climate', 'set_temperature', {'entity_id': 'climate.mock_climate1', 'temperature': 17.2}),
call('climate', 'set_temperature', {'entity_id': 'climate.mock_climate2', 'temperature': 17.2}),
call(domain='number', service='set_value', service_data={'value': 0}, target={'entity_id': 'number.mock_opening_degree1'}),
call(domain='number', service='set_value', service_data={'value': 100}, target={'entity_id': 'number.mock_closing_degree1'}),
call(domain='number', service='set_value', service_data={'value': 3.0}, target={'entity_id': 'number.mock_offset_calibration1'}),
call(domain='number', service='set_value', service_data={'value': 0}, target={'entity_id': 'number.mock_opening_degree2'}),
call(domain='number', service='set_value', service_data={'value': 100}, target={'entity_id': 'number.mock_closing_degree2'}),
call(domain='number', service='set_value', service_data={'value': 12}, target={'entity_id': 'number.mock_offset_calibration2'})
]
)

View File

@@ -58,6 +58,7 @@ async def test_over_switch_full_start(hass: HomeAssistant, skip_hass_states_is_s
assert entity._motion_state is None
assert entity._presence_state is None
assert entity._prop_algorithm is not None
assert entity.have_valve_regulation is False
# should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT
assert mock_send_event.call_count == 2
@@ -94,18 +95,6 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_
return_value=fake_underlying_climate,
) as mock_find_climate:
entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname")
# entry.add_to_hass(hass)
# await hass.config_entries.async_setup(entry.entry_id)
# assert entry.state is ConfigEntryState.LOADED
#
# def find_my_entity(entity_id) -> ClimateEntity:
# """Find my new entity"""
# component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN]
# for entity in component.entities:
# if entity.entity_id == entity_id:
# return entity
#
# entity = find_my_entity("climate.theoverclimatemockname")
assert entity
assert isinstance(entity, ThermostatOverClimate)
@@ -127,6 +116,7 @@ async def test_over_climate_full_start(hass: HomeAssistant, skip_hass_states_is_
assert entity._window_state is None
assert entity._motion_state is None
assert entity._presence_state is None
assert entity.have_valve_regulation is False
# should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT
assert mock_send_event.call_count == 2

View File

@@ -17,7 +17,7 @@ from custom_components.versatile_thermostat.base_thermostat import BaseThermosta
from custom_components.versatile_thermostat.thermostat_switch import (
ThermostatOverSwitch,
)
from custom_components.versatile_thermostat.commons import NowClass
from custom_components.versatile_thermostat.const import NowClass
from custom_components.versatile_thermostat.vtherm_api import VersatileThermostatAPI
from .commons import *

View File

@@ -125,6 +125,39 @@ async def test_tpi_calculation(
assert tpi_algo.on_time_sec == 0
assert tpi_algo.off_time_sec == 300
"""
Test the max_on_percent clamping calculations
"""
tpi_algo._max_on_percent = 0.8
# no clamping
tpi_algo.calculate(15, 14.7, 15, HVACMode.HEAT)
assert tpi_algo.on_percent == 0.09
assert tpi_algo.calculated_on_percent == 0.09
assert tpi_algo.on_time_sec == 0
assert tpi_algo.off_time_sec == 300
# no clamping (calculated_on_percent = 0.79)
tpi_algo.calculate(15, 12.5, 11, HVACMode.HEAT)
assert tpi_algo.on_percent == 0.79
assert tpi_algo.calculated_on_percent == 0.79
assert tpi_algo.on_time_sec == 237
assert tpi_algo.off_time_sec == 63
# clamping to 80% (calculated_on_percent = 1)
tpi_algo.calculate(15, 10, 7, HVACMode.HEAT)
assert tpi_algo.on_percent == 0.8 # should be clamped to 80%
assert tpi_algo.calculated_on_percent == 1 # calculated percentage should not be affected by clamping
assert tpi_algo.on_time_sec == 240 # capped at 80%
assert tpi_algo.off_time_sec == 60
# clamping to 80% (calculated_on_percent = 0.81)
tpi_algo.calculate(15, 12.5, 9, HVACMode.HEAT)
assert tpi_algo.on_percent == 0.80 # should be clamped to 80%
assert tpi_algo.calculated_on_percent == 0.81 # calculated percentage should not be affected by clamping
assert tpi_algo.on_time_sec == 240 # capped at 80%
assert tpi_algo.off_time_sec == 60
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])

View File

@@ -103,6 +103,7 @@ async def test_over_valve_full_start(
assert entity._motion_state is None # pylint: disable=protected-access
assert entity._presence_state is None # pylint: disable=protected-access
assert entity._prop_algorithm is not None # pylint: disable=protected-access
assert entity.have_valve_regulation is False
# should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT
# assert mock_send_event.call_count == 2