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 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 #### 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. 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: For example:
@@ -800,6 +724,113 @@ context:
> ![Tip](images/tips.png) _*Notes*_ > ![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. > 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> <details>
<summary>Parameter summary</summary> <summary>Parameter summary</summary>
@@ -1248,9 +1279,13 @@ Replace values in [[ ]] by yours.
yaxis: y1 yaxis: y1
name: Ema name: Ema
- entity: '[[climate]]' - entity: '[[climate]]'
attribute: regulated_target_temperature attribute: on_percent
yaxis: y1 yaxis: y2
name: Regulated T° name: Power percent
fill: tozeroy
fillcolor: rgba(200, 10, 10, 0.3)
line:
color: rgba(200, 10, 10, 0.9)
- entity: '[[slope]]' - entity: '[[slope]]'
name: Slope name: Slope
fill: tozeroy fill: tozeroy
@@ -1275,12 +1310,19 @@ Replace values in [[ ]] by yours.
yaxis: yaxis:
visible: true visible: true
position: 0 position: 0
yaxis2:
visible: true
position: 0
fixedrange: true
range:
- 0
- 1
yaxis9: yaxis9:
visible: true visible: true
fixedrange: false fixedrange: false
range: range:
- -0.5 - -2
- 0.5 - 2
position: 1 position: 1
xaxis: xaxis:
rangeselector: rangeselector:

View File

@@ -54,6 +54,7 @@ from .const import (
CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_CLIMATE, CONF_THERMOSTAT_CLIMATE,
CONF_THERMOSTAT_VALVE, CONF_THERMOSTAT_VALVE,
CONF_MAX_ON_PERCENT,
) )
from .vtherm_api import VersatileThermostatAPI from .vtherm_api import VersatileThermostatAPI
@@ -86,6 +87,7 @@ CONFIG_SCHEMA = vol.Schema(
CONF_AUTO_REGULATION_EXPERT: vol.Schema(SELF_REGULATION_PARAM_SCHEMA), CONF_AUTO_REGULATION_EXPERT: vol.Schema(SELF_REGULATION_PARAM_SCHEMA),
CONF_SHORT_EMA_PARAMS: vol.Schema(EMA_PARAM_SCHEMA), CONF_SHORT_EMA_PARAMS: vol.Schema(EMA_PARAM_SCHEMA),
CONF_SAFETY_MODE: vol.Schema(SAFETY_MODE_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 types import MappingProxyType
from typing import Any, TypeVar, Generic from typing import Any, TypeVar, Generic
from homeassistant.util import dt as dt_util
from homeassistant.core import ( from homeassistant.core import (
HomeAssistant, HomeAssistant,
callback, callback,
@@ -80,13 +79,6 @@ _LOGGER = logging.getLogger(__name__)
ConfigData = MappingProxyType[str, Any] ConfigData = MappingProxyType[str, Any]
T = TypeVar("T", bound=UnderlyingEntity) 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]): class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"""Representation of a base class for all Versatile Thermostat device.""" """Representation of a base class for all Versatile Thermostat device."""
@@ -137,7 +129,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"is_device_active", "is_device_active",
"target_temperature_step", "target_temperature_step",
"is_used_by_central_boiler", "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 else DEFAULT_SECURITY_DEFAULT_ON_PERCENT
) )
self._minimal_activation_delay = entry_infos.get(CONF_MINIMAL_ACTIVATION_DELAY) self._minimal_activation_delay = entry_infos.get(CONF_MINIMAL_ACTIVATION_DELAY)
self._last_temperature_measure = datetime.now(tz=self._current_tz) self._last_temperature_measure = self.now
self._last_ext_temperature_measure = datetime.now(tz=self._current_tz) self._last_ext_temperature_measure = self.now
self._security_state = False self._security_state = False
# Initiate the ProportionalAlgorithm # Initiate the ProportionalAlgorithm
@@ -503,6 +497,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
entry_infos.get(CONF_WINDOW_ACTION) or CONF_WINDOW_TURN_OFF entry_infos.get(CONF_WINDOW_ACTION) or CONF_WINDOW_TURN_OFF
) )
self._max_on_percent = api.max_on_percent
_LOGGER.debug( _LOGGER.debug(
"%s - Creation of a new VersatileThermostat entity: unique_id=%s", "%s - Creation of a new VersatileThermostat entity: unique_id=%s",
self, self,
@@ -1339,7 +1335,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
self, old_preset_mode: str | None = None self, old_preset_mode: str | None = None
): # pylint: disable=unused-argument ): # pylint: disable=unused-argument
"""Reset to now the last change time""" """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) _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): 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 and old_preset_mode not in HIDDEN_PRESETS
): ):
self._last_temperature_measure = self._last_ext_temperature_measure = ( 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): def find_preset_temp(self, preset_mode: str):
@@ -1382,6 +1378,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
) )
if motion_preset in self._presets: if motion_preset in self._presets:
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] return self._presets[motion_preset]
else: else:
return None return None
@@ -1452,16 +1451,16 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"""Extract the last_changed state from State or return now if not available""" """Extract the last_changed state from State or return now if not available"""
return ( return (
state.last_changed.astimezone(self._current_tz) state.last_changed.astimezone(self._current_tz)
if state.last_changed is not None if isinstance(state.last_changed, datetime)
else datetime.now(tz=self._current_tz) else self.now
) )
def get_last_updated_date_or_now(self, state: State) -> datetime: def get_last_updated_date_or_now(self, state: State) -> datetime:
"""Extract the last_changed state from State or return now if not available""" """Extract the last_changed state from State or return now if not available"""
return ( return (
state.last_updated.astimezone(self._current_tz) state.last_updated.astimezone(self._current_tz)
if state.last_updated is not None if isinstance(state.last_updated, datetime)
else datetime.now(tz=self._current_tz) else self.now
) )
@callback @callback
@@ -1903,7 +1902,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
STATE_NOT_HOME, STATE_NOT_HOME,
): ):
return 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 return
new_temp = self.find_preset_temp(self.preset_mode) new_temp = self.find_preset_temp(self.preset_mode)
@@ -1993,7 +1997,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
if in_cycle: if in_cycle:
slope = self._window_auto_algo.check_age_last_measurement( slope = self._window_auto_algo.check_age_last_measurement(
temperature=self._ema_temp, temperature=self._ema_temp,
datetime_now=datetime.now(get_tz(self._hass)), datetime_now=self.now,
) )
else: else:
slope = self._window_auto_algo.add_temp_measurement( slope = self._window_auto_algo.add_temp_measurement(
@@ -2281,10 +2285,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
@property @property
def now(self) -> datetime: def now(self) -> datetime:
"""Get now. The local datetime or the overloaded _set_now date""" """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: async def check_safety(self) -> bool:
"""Check if last temperature date is too long""" """Check if last temperature date is too long"""
now = self.now now = self.now
delta_temp = ( delta_temp = (
now - self._last_temperature_measure.replace(tzinfo=self._current_tz) 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, "device_power": self._device_power,
ATTR_MEAN_POWER_CYCLE: self.mean_cycle_power, ATTR_MEAN_POWER_CYCLE: self.mean_cycle_power,
ATTR_TOTAL_ENERGY: self.total_energy, ATTR_TOTAL_ENERGY: self.total_energy,
"last_update_datetime": datetime.now() "last_update_datetime": self.now.isoformat(),
.astimezone(self._current_tz)
.isoformat(),
"timezone": str(self._current_tz), "timezone": str(self._current_tz),
"temperature_unit": self.temperature_unit, "temperature_unit": self.temperature_unit,
"is_device_active": self.is_device_active, "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, "is_used_by_central_boiler": self.is_used_by_central_boiler,
"temperature_slope": round(self.last_temperature_slope or 0, 3), "temperature_slope": round(self.last_temperature_slope or 0, 3),
"hvac_off_reason": self.hvac_off_reason, "hvac_off_reason": self.hvac_off_reason,
"max_on_percent": self._max_on_percent,
"have_valve_regulation": self.have_valve_regulation,
} }
_LOGGER.debug( _LOGGER.debug(
@@ -2680,6 +2685,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
) )
return super().async_write_ha_state() return super().async_write_ha_state()
@property
def have_valve_regulation(self) -> bool:
"""True if the Thermostat is regulated by valve"""
return False
@callback @callback
def async_registry_entry_updated(self): def async_registry_entry_updated(self):
"""update the entity if the config entry have been updated """update the entity if the config entry have been updated

View File

@@ -22,28 +22,12 @@ from homeassistant.const import (
STATE_NOT_HOME, STATE_NOT_HOME,
) )
from .const import ( from .const import * # pylint: disable=wildcard-import,unused-wildcard-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 .thermostat_switch import ThermostatOverSwitch from .thermostat_switch import ThermostatOverSwitch
from .thermostat_climate import ThermostatOverClimate from .thermostat_climate import ThermostatOverClimate
from .thermostat_valve import ThermostatOverValve from .thermostat_valve import ThermostatOverValve
from .thermostat_sonoff_trvzb import ThermostatOverSonoffTRVZB from .thermostat_climate_valve import ThermostatOverClimateValve
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -62,7 +46,9 @@ async def async_setup_entry(
unique_id = entry.entry_id unique_id = entry.entry_id
name = entry.data.get(CONF_NAME) name = entry.data.get(CONF_NAME)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE) 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: if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG:
return return
@@ -72,8 +58,8 @@ async def async_setup_entry(
if vt_type == CONF_THERMOSTAT_SWITCH: if vt_type == CONF_THERMOSTAT_SWITCH:
entity = ThermostatOverSwitch(hass, unique_id, name, entry.data) entity = ThermostatOverSwitch(hass, unique_id, name, entry.data)
elif vt_type == CONF_THERMOSTAT_CLIMATE: elif vt_type == CONF_THERMOSTAT_CLIMATE:
if is_sonoff_trvzb is True: if have_valve_regulation is True:
entity = ThermostatOverSonoffTRVZB(hass, unique_id, name, entry.data) entity = ThermostatOverClimateValve(hass, unique_id, name, entry.data)
else: else:
entity = ThermostatOverClimate(hass, unique_id, name, entry.data) entity = ThermostatOverClimate(hass, unique_id, name, entry.data)
elif vt_type == CONF_THERMOSTAT_VALVE: elif vt_type == CONF_THERMOSTAT_VALVE:

View File

@@ -3,38 +3,20 @@
# pylint: disable=line-too-long # pylint: disable=line-too-long
import logging import logging
from datetime import timedelta, datetime from datetime import timedelta
from homeassistant.core import HomeAssistant, callback, Event from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
from homeassistant.helpers.event import async_track_state_change_event, async_call_later from homeassistant.helpers.event import async_track_state_change_event, async_call_later
from homeassistant.util import dt as dt_util
from .base_thermostat import BaseThermostat from .base_thermostat import BaseThermostat
from .const import DOMAIN, DEVICE_MANUFACTURER, ServiceConfigurationError from .const import DOMAIN, DEVICE_MANUFACTURER, ServiceConfigurationError
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def get_tz(hass: HomeAssistant):
"""Get the current timezone"""
return dt_util.get_time_zone(hass.config.time_zone)
class NowClass:
"""For testing purpose only"""
@staticmethod
def get_now(hass: HomeAssistant) -> datetime:
"""A test function to get the now.
For testing purpose this method can be overriden to get a specific
timestamp.
"""
return datetime.now(get_tz(hass))
def round_to_nearest(n: float, x: float) -> float: def round_to_nearest(n: float, x: float) -> float:
"""Round a number to the nearest x (which should be decimal but not null) """Round a number to the nearest x (which should be decimal but not null)
Example: Example:

View File

@@ -29,27 +29,6 @@ COMES_FROM = "comes_from"
_LOGGER = logging.getLogger(__name__) _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( def add_suggested_values_to_schema(
data_schema: vol.Schema, suggested_values: Mapping[str, Any] data_schema: vol.Schema, suggested_values: Mapping[str, Any]
) -> vol.Schema: ) -> vol.Schema:
@@ -162,21 +141,37 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
if COMES_FROM in self._infos: if COMES_FROM in self._infos:
del self._infos[COMES_FROM] del self._infos[COMES_FROM]
def check_sonoff_trvzb_nb_entities(self, data: dict) -> bool: def is_valve_regulation_selected(self, infos) -> bool:
"""Check the number of entities for Sonoff TRVZB""" """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 ret = True
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 ( if (
self._infos.get(CONF_SONOFF_TRZB_MODE) nb_unders != nb_opening
and data.get(CONF_OFFSET_CALIBRATION_LIST) is not None or (nb_unders != nb_offset and nb_offset > 0)
): or (nb_unders != nb_closing and nb_closing > 0)
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 (
nb_unders != nb_offset
or nb_unders != nb_opening
or nb_unders != nb_closing
): ):
ret = False ret = False
return ret return ret
@@ -259,8 +254,8 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
# Check that the number of offet_calibration and opening_degree and closing_degree are equals # Check that the number of offet_calibration and opening_degree and closing_degree are equals
# to the number of underlying entities # to the number of underlying entities
if not self.check_sonoff_trvzb_nb_entities(data): if not self.check_valve_regulation_nb_entities(data, step_id):
raise SonoffTRVZBNbEntitiesIncorrect() raise ValveRegulationNbEntitiesIncorrect()
def check_config_complete(self, infos) -> bool: def check_config_complete(self, infos) -> bool:
"""True if the config is now complete (ie all mandatory attributes are set)""" """True if the config is now complete (ie all mandatory attributes are set)"""
@@ -357,7 +352,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
): ):
return False 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 False
return True return True
@@ -400,8 +395,8 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
errors[str(err)] = "service_configuration_format" errors[str(err)] = "service_configuration_format"
except ConfigurationNotCompleteError as err: except ConfigurationNotCompleteError as err:
errors["base"] = "configuration_not_complete" errors["base"] = "configuration_not_complete"
except SonoffTRVZBNbEntitiesIncorrect as err: except ValveRegulationNbEntitiesIncorrect as err:
errors["base"] = "sonoff_trvzb_nb_entities_incorrect" errors["base"] = "valve_regulation_nb_entities_incorrect"
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
@@ -453,6 +448,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
if ( if (
self._infos.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI self._infos.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI
or is_central_config or is_central_config
or self.is_valve_regulation_selected(self._infos)
): ):
menu_options.append("tpi") menu_options.append("tpi")
@@ -488,8 +484,8 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
]: ]:
menu_options.append("auto_start_stop") menu_options.append("auto_start_stop")
if self._infos.get(CONF_SONOFF_TRZB_MODE) is True: if self.is_valve_regulation_selected(self._infos):
menu_options.append("sonoff_trvzb") menu_options.append("valve_regulation")
menu_options.append("advanced") menu_options.append("advanced")
@@ -563,7 +559,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
if ( if (
self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CLIMATE self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CLIMATE
and user_input is not None 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 # Remove TPI info
for key in [ for key in [
@@ -621,19 +617,21 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
return await self.generic_step("auto_start_stop", schema, user_input, next_step) 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 self, user_input: dict | None = None
) -> FlowResult: ) -> FlowResult:
"""Handle the Sonoff TRVZB configuration step""" """Handle the valve regulation configuration step"""
_LOGGER.debug( _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 self._infos[COMES_FROM] = None
next_step = self.async_step_menu 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: async def async_step_tpi(self, user_input: dict | None = None) -> FlowResult:
"""Handle the TPI flow steps""" """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), selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN, multiple=True),
), ),
vol.Optional(CONF_AC_MODE, default=False): cv.boolean, vol.Optional(CONF_AC_MODE, default=False): cv.boolean,
vol.Optional(CONF_SONOFF_TRZB_MODE, default=False): cv.boolean,
vol.Optional( vol.Optional(
CONF_AUTO_REGULATION_MODE, default=CONF_AUTO_REGULATION_NONE CONF_AUTO_REGULATION_MODE, default=CONF_AUTO_REGULATION_NONE
): selector.SelectSelector( ): 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( vol.Required(CONF_OPENING_DEGREE_LIST): selector.EntitySelector(
selector.EntitySelectorConfig( selector.EntitySelectorConfig(
domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN], multiple=True 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( selector.EntitySelectorConfig(
domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN], multiple=True domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN], multiple=True
), ),

View File

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

View File

@@ -31,6 +31,7 @@ class PropAlgorithm:
cycle_min: int, cycle_min: int,
minimal_activation_delay: int, minimal_activation_delay: int,
vtherm_entity_id: str = None, vtherm_entity_id: str = None,
max_on_percent: float = None,
) -> None: ) -> None:
"""Initialisation of the Proportional Algorithm""" """Initialisation of the Proportional Algorithm"""
_LOGGER.debug( _LOGGER.debug(
@@ -78,6 +79,7 @@ class PropAlgorithm:
self._off_time_sec = self._cycle_min * 60 self._off_time_sec = self._cycle_min * 60
self._security = False self._security = False
self._default_on_percent = 0 self._default_on_percent = 0
self._max_on_percent = max_on_percent
def calculate( def calculate(
self, self,
@@ -161,6 +163,15 @@ class PropAlgorithm:
) )
self._on_percent = self._calculated_on_percent 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 self._on_time_sec = self._on_percent * self._cycle_min * 60
# Do not heat for less than xx sec # Do not heat for less than xx sec

View File

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

View File

@@ -28,7 +28,7 @@
"presence": "Presence detection", "presence": "Presence detection",
"advanced": "Advanced parameters", "advanced": "Advanced parameters",
"auto_start_stop": "Auto start and stop", "auto_start_stop": "Auto start and stop",
"sonoff_trvzb": "Sonoff TRVZB configuration", "valve_regulation": "Valve regulation configuration",
"finalize": "All done", "finalize": "All done",
"configuration_not_complete": "Configuration not complete" "configuration_not_complete": "Configuration not complete"
} }
@@ -65,7 +65,7 @@
"use_motion_feature": "Use motion detection", "use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management", "use_power_feature": "Use power management",
"use_presence_feature": "Use presence detection", "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" "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", "heater_keep_alive": "Switch keep-alive interval in seconds",
"proportional_function": "Algorithm", "proportional_function": "Algorithm",
"ac_mode": "AC mode", "ac_mode": "AC mode",
"sonoff_trvzb_mode": "SONOFF TRVZB mode",
"auto_regulation_mode": "Self-regulation", "auto_regulation_mode": "Self-regulation",
"auto_regulation_dtemp": "Regulation threshold", "auto_regulation_dtemp": "Regulation threshold",
"auto_regulation_periode_min": "Regulation minimum period", "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.", "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)", "proportional_function": "Algorithm to use (TPI is the only one for now)",
"ac_mode": "Use the Air Conditioning (AC) mode", "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_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_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", "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]" "central_boiler_deactivation_service": "Command to turn-off the central boiler formatted like entity_id/service_name[/attribut:valeur]"
} }
}, },
"sonoff_trvzb": { "valve_regulation": {
"title": "Sonoff TRVZB configuration", "title": "Self-regulation with valve",
"description": "Specific Sonoff TRVZB configuration", "description": "Configuration for self-regulation with direct control of the valve",
"data": { "data": {
"offset_calibration_entity_ids": "Offset calibration entities", "offset_calibration_entity_ids": "Offset calibration entities",
"opening_degree_entity_ids": "Opening degree entities", "opening_degree_entity_ids": "Opening degree entities",
@@ -229,9 +227,9 @@
"proportional_function": "Algorithm" "proportional_function": "Algorithm"
}, },
"data_description": { "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", "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)" "proportional_function": "Algorithm to use (TPI is the only one for now)"
} }
} }
@@ -274,7 +272,7 @@
"presence": "Presence detection", "presence": "Presence detection",
"advanced": "Advanced parameters", "advanced": "Advanced parameters",
"auto_start_stop": "Auto start and stop", "auto_start_stop": "Auto start and stop",
"sonoff_trvzb": "Sonoff TRVZB configuration", "valve_regulation": "Valve regulation configuration",
"finalize": "All done", "finalize": "All done",
"configuration_not_complete": "Configuration not complete" "configuration_not_complete": "Configuration not complete"
} }
@@ -311,7 +309,7 @@
"use_motion_feature": "Use motion detection", "use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management", "use_power_feature": "Use power management",
"use_presence_feature": "Use presence detection", "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" "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", "heater_keep_alive": "Switch keep-alive interval in seconds",
"proportional_function": "Algorithm", "proportional_function": "Algorithm",
"ac_mode": "AC mode", "ac_mode": "AC mode",
"sonoff_trvzb_mode": "SONOFF TRVZB mode",
"auto_regulation_mode": "Self-regulation", "auto_regulation_mode": "Self-regulation",
"auto_regulation_dtemp": "Regulation threshold", "auto_regulation_dtemp": "Regulation threshold",
"auto_regulation_periode_min": "Regulation minimum period", "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.", "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)", "proportional_function": "Algorithm to use (TPI is the only one for now)",
"ac_mode": "Use the Air Conditioning (AC) mode", "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_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_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", "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]" "central_boiler_deactivation_service": "Command to turn-off the central boiler formatted like entity_id/service_name[/attribut:valeur]"
} }
}, },
"sonoff_trvzb": { "valve_regulation": {
"title": "Sonoff TRVZB configuration - {name}", "title": "Self-regulation with valve - {name}",
"description": "Specific Sonoff TRVZB configuration", "description": "Configuration for self-regulation with direct control of the valve",
"data": { "data": {
"offset_calibration_entity_ids": "Offset calibration entities", "offset_calibration_entity_ids": "Offset calibration entities",
"opening_degree_entity_ids": "Opening degree entities", "opening_degree_entity_ids": "Opening degree entities",
@@ -475,9 +471,9 @@
"proportional_function": "Algorithm" "proportional_function": "Algorithm"
}, },
"data_description": { "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", "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)" "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", "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.", "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", "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": { "abort": {
"already_configured": "Device is already configured" "already_configured": "Device is already configured"
@@ -510,7 +506,8 @@
"auto_regulation_medium": "Medium", "auto_regulation_medium": "Medium",
"auto_regulation_light": "Light", "auto_regulation_light": "Light",
"auto_regulation_expert": "Expert", "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": { "auto_fan_mode": {

View File

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

View File

@@ -1,13 +1,13 @@
# pylint: disable=line-too-long, too-many-lines, abstract-method # 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 import logging
from datetime import datetime from datetime import datetime
from homeassistant.core import HomeAssistant 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 .commons import NowClass, round_to_nearest
from .base_thermostat import ConfigData from .base_thermostat import ConfigData
@@ -21,14 +21,14 @@ from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class ThermostatOverSonoffTRVZB(ThermostatOverClimate): class ThermostatOverClimateValve(ThermostatOverClimate):
"""This class represent a VTherm over a Sonoff TRVZB climate""" """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 _entity_component_unrecorded_attributes = ThermostatOverClimate._entity_component_unrecorded_attributes.union( # pylint: disable=protected-access
frozenset( frozenset(
{ {
"is_over_climate", "is_over_climate",
"is_over_sonoff_trvzb", "have_valve_regulation",
"underlying_entities", "underlying_entities",
"on_time_sec", "on_time_sec",
"off_time_sec", "off_time_sec",
@@ -40,8 +40,8 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
} }
) )
) )
_underlyings_sonoff_trvzb: list[UnderlyingSonoffTRVZB] = [] _underlyings_valve_regulation: list[UnderlyingValveRegulation] = []
_valve_open_percent: int = 0 _valve_open_percent: int | None = None
_last_calculation_timestamp: datetime | None = None _last_calculation_timestamp: datetime | None = None
_auto_regulation_dpercent: float | None = None _auto_regulation_dpercent: float | None = None
_auto_regulation_period_min: int | None = None _auto_regulation_period_min: int | None = None
@@ -49,8 +49,8 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
def __init__( def __init__(
self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigData self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigData
): ):
"""Initialize the ThermostatOverSonoffTRVZB class""" """Initialize the ThermostatOverClimateValve class"""
_LOGGER.debug("%s - creating a ThermostatOverSonoffTRVZB VTherm", name) _LOGGER.debug("%s - creating a ThermostatOverClimateValve VTherm", name)
super().__init__(hass, unique_id, name, entry_infos) super().__init__(hass, unique_id, name, entry_infos)
# self._valve_open_percent: int = 0 # self._valve_open_percent: int = 0
# self._last_calculation_timestamp: datetime | None = None # self._last_calculation_timestamp: datetime | None = None
@@ -60,8 +60,8 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
@overrides @overrides
def post_init(self, config_entry: ConfigData): def post_init(self, config_entry: ConfigData):
"""Initialize the Thermostat and underlyings """Initialize the Thermostat and underlyings
Beware that the underlyings list contains the climate which represent the Sonoff TRVZB Beware that the underlyings list contains the climate which represent the TRV
but also the UnderlyingSonoff which reprensent the valve""" but also the UnderlyingValveRegulation which reprensent the valve"""
super().post_init(config_entry) super().post_init(config_entry)
@@ -86,30 +86,35 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
self.name, 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)): for idx, _ in enumerate(config_entry.get(CONF_UNDERLYING_LIST)):
offset = config_entry.get(CONF_OFFSET_CALIBRATION_LIST)[idx] offset = offset_list[idx] if idx < len(offset_list) else None
opening = config_entry.get(CONF_OPENING_DEGREE_LIST)[idx] # number of opening should equal number of underlying
closing = config_entry.get(CONF_CLOSING_DEGREE_LIST)[idx] opening = opening_list[idx]
under = UnderlyingSonoffTRVZB( closing = closing_list[idx] if idx < len(closing_list) else None
under = UnderlyingValveRegulation(
hass=self._hass, hass=self._hass,
thermostat=self, thermostat=self,
offset_calibration_entity_id=offset, offset_calibration_entity_id=offset,
opening_degree_entity_id=opening, opening_degree_entity_id=opening,
closing_degree_entity_id=closing, closing_degree_entity_id=closing,
climate_underlying=self._underlyings[idx],
) )
self._underlyings_sonoff_trvzb.append(under) self._underlyings_valve_regulation.append(under)
@overrides @overrides
def update_custom_attributes(self): def update_custom_attributes(self):
"""Custom attributes""" """Custom attributes"""
super().update_custom_attributes() super().update_custom_attributes()
self._attr_extra_state_attributes["is_over_sonoff_trvzb"] = ( self._attr_extra_state_attributes["have_valve_regulation"] = (
self.is_over_sonoff_trvzb self.have_valve_regulation
) )
self._attr_extra_state_attributes["underlying_sonoff_trvzb_entities"] = [ self._attr_extra_state_attributes["underlyings_valve_regulation"] = [
underlying.entity_id for underlying in self._underlyings_sonoff_trvzb underlying.entity_id for underlying in self._underlyings_valve_regulation
] ]
self._attr_extra_state_attributes["on_percent"] = ( self._attr_extra_state_attributes["on_percent"] = (
@@ -187,9 +192,14 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
if new_valve_percent < self._auto_regulation_dpercent: if new_valve_percent < self._auto_regulation_dpercent:
new_valve_percent = 0 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 ( if (
new_valve_percent > 0 self._last_calculation_timestamp is not None
and new_valve_percent > 0
and -1 * self._auto_regulation_dpercent and -1 * self._auto_regulation_dpercent
<= dpercent <= dpercent
< self._auto_regulation_dpercent < self._auto_regulation_dpercent
@@ -202,22 +212,38 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
return 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) _LOGGER.debug("%s - no change in valve_open_percent.", self)
return return
self._valve_open_percent = new_valve_percent 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._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 @property
def is_over_sonoff_trvzb(self) -> bool: def have_valve_regulation(self) -> bool:
"""True if the Thermostat is over_sonoff_trvzb""" """True if the Thermostat is regulated by valve"""
return True return True
@property @property
@@ -236,7 +262,23 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
@property @property
def valve_open_percent(self) -> int: def valve_open_percent(self) -> int:
"""Gives the percentage of valve needed""" """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 return 0
else: else:
return self._valve_open_percent 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,7 +25,8 @@ _LOGGER = logging.getLogger(__name__)
class ThermostatOverSwitch(BaseThermostat[UnderlyingSwitch]): class ThermostatOverSwitch(BaseThermostat[UnderlyingSwitch]):
"""Representation of a base class for a Versatile Thermostat over a switch.""" """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 _entity_component_unrecorded_attributes = (
BaseThermostat._entity_component_unrecorded_attributes.union(
frozenset( frozenset(
{ {
"is_over_switch", "is_over_switch",
@@ -38,9 +39,11 @@ class ThermostatOverSwitch(BaseThermostat[UnderlyingSwitch]):
"tpi_coef_int", "tpi_coef_int",
"tpi_coef_ext", "tpi_coef_ext",
"power_percent", "power_percent",
"calculated_on_percent",
} }
) )
) )
)
# useless for now # useless for now
# def __init__(self, hass: HomeAssistant, unique_id, name, config_entry) -> None: # def __init__(self, hass: HomeAssistant, unique_id, name, config_entry) -> None:
@@ -79,6 +82,7 @@ class ThermostatOverSwitch(BaseThermostat[UnderlyingSwitch]):
self._cycle_min, self._cycle_min,
self._minimal_activation_delay, self._minimal_activation_delay,
self.name, self.name,
max_on_percent=self._max_on_percent,
) )
lst_switches = config_entry.get(CONF_UNDERLYING_LIST) 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["function"] = self._proportional_function
self._attr_extra_state_attributes["tpi_coef_int"] = self._tpi_coef_int 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["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() self.async_write_ha_state()
_LOGGER.debug( _LOGGER.debug(

View File

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

View File

@@ -28,7 +28,7 @@
"presence": "Presence detection", "presence": "Presence detection",
"advanced": "Advanced parameters", "advanced": "Advanced parameters",
"auto_start_stop": "Auto start and stop", "auto_start_stop": "Auto start and stop",
"sonoff_trvzb": "Sonoff TRVZB configuration", "valve_regulation": "Valve regulation configuration",
"finalize": "All done", "finalize": "All done",
"configuration_not_complete": "Configuration not complete" "configuration_not_complete": "Configuration not complete"
} }
@@ -65,7 +65,7 @@
"use_motion_feature": "Use motion detection", "use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management", "use_power_feature": "Use power management",
"use_presence_feature": "Use presence detection", "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" "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", "heater_keep_alive": "Switch keep-alive interval in seconds",
"proportional_function": "Algorithm", "proportional_function": "Algorithm",
"ac_mode": "AC mode", "ac_mode": "AC mode",
"sonoff_trvzb_mode": "SONOFF TRVZB mode",
"auto_regulation_mode": "Self-regulation", "auto_regulation_mode": "Self-regulation",
"auto_regulation_dtemp": "Regulation threshold", "auto_regulation_dtemp": "Regulation threshold",
"auto_regulation_periode_min": "Regulation minimum period", "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.", "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)", "proportional_function": "Algorithm to use (TPI is the only one for now)",
"ac_mode": "Use the Air Conditioning (AC) mode", "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_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_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", "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]" "central_boiler_deactivation_service": "Command to turn-off the central boiler formatted like entity_id/service_name[/attribut:valeur]"
} }
}, },
"sonoff_trvzb": { "valve_regulation": {
"title": "Sonoff TRVZB configuration", "title": "Self-regulation with valve",
"description": "Specific Sonoff TRVZB configuration", "description": "Configuration for self-regulation with direct control of the valve",
"data": { "data": {
"offset_calibration_entity_ids": "Offset calibration entities", "offset_calibration_entity_ids": "Offset calibration entities",
"opening_degree_entity_ids": "Opening degree entities", "opening_degree_entity_ids": "Opening degree entities",
@@ -229,9 +227,9 @@
"proportional_function": "Algorithm" "proportional_function": "Algorithm"
}, },
"data_description": { "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", "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)" "proportional_function": "Algorithm to use (TPI is the only one for now)"
} }
} }
@@ -274,7 +272,7 @@
"presence": "Presence detection", "presence": "Presence detection",
"advanced": "Advanced parameters", "advanced": "Advanced parameters",
"auto_start_stop": "Auto start and stop", "auto_start_stop": "Auto start and stop",
"sonoff_trvzb": "Sonoff TRVZB configuration", "valve_regulation": "Valve regulation configuration",
"finalize": "All done", "finalize": "All done",
"configuration_not_complete": "Configuration not complete" "configuration_not_complete": "Configuration not complete"
} }
@@ -311,7 +309,7 @@
"use_motion_feature": "Use motion detection", "use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management", "use_power_feature": "Use power management",
"use_presence_feature": "Use presence detection", "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" "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", "heater_keep_alive": "Switch keep-alive interval in seconds",
"proportional_function": "Algorithm", "proportional_function": "Algorithm",
"ac_mode": "AC mode", "ac_mode": "AC mode",
"sonoff_trvzb_mode": "SONOFF TRVZB mode",
"auto_regulation_mode": "Self-regulation", "auto_regulation_mode": "Self-regulation",
"auto_regulation_dtemp": "Regulation threshold", "auto_regulation_dtemp": "Regulation threshold",
"auto_regulation_periode_min": "Regulation minimum period", "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.", "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)", "proportional_function": "Algorithm to use (TPI is the only one for now)",
"ac_mode": "Use the Air Conditioning (AC) mode", "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_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_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", "auto_regulation_periode_min": "Duration in minutes between two regulation update",
@@ -454,7 +450,7 @@
} }
}, },
"central_boiler": { "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`", "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": { "data": {
"central_boiler_activation_service": "Command to turn-on", "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]" "central_boiler_deactivation_service": "Command to turn-off the central boiler formatted like entity_id/service_name[/attribut:valeur]"
} }
}, },
"sonoff_trvzb": { "valve_regulation": {
"title": "Sonoff TRVZB configuration", "title": "Self-regulation with valve - {name}",
"description": "Specific Sonoff TRVZB configuration", "description": "Configuration for self-regulation with direct control of the valve",
"data": { "data": {
"offset_calibration_entity_ids": "Offset calibration entities", "offset_calibration_entity_ids": "Offset calibration entities",
"opening_degree_entity_ids": "Opening degree entities", "opening_degree_entity_ids": "Opening degree entities",
@@ -475,9 +471,9 @@
"proportional_function": "Algorithm" "proportional_function": "Algorithm"
}, },
"data_description": { "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", "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)" "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", "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.", "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", "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": { "abort": {
"already_configured": "Device is already configured" "already_configured": "Device is already configured"
@@ -510,7 +506,8 @@
"auto_regulation_medium": "Medium", "auto_regulation_medium": "Medium",
"auto_regulation_light": "Light", "auto_regulation_light": "Light",
"auto_regulation_expert": "Expert", "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": { "auto_fan_mode": {

View File

@@ -28,7 +28,7 @@
"presence": "Détection de présence", "presence": "Détection de présence",
"advanced": "Paramètres avancés", "advanced": "Paramètres avancés",
"auto_start_stop": "Allumage/extinction automatique", "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", "finalize": "Finaliser la création",
"configuration_not_complete": "Configuration incomplète" "configuration_not_complete": "Configuration incomplète"
} }
@@ -77,7 +77,6 @@
"heater_keep_alive": "keep-alive (sec)", "heater_keep_alive": "keep-alive (sec)",
"proportional_function": "Algorithme", "proportional_function": "Algorithme",
"ac_mode": "AC mode ?", "ac_mode": "AC mode ?",
"sonoff_trvzb_mode": "Mode Sonoff TRVZB",
"auto_regulation_mode": "Auto-régulation", "auto_regulation_mode": "Auto-régulation",
"auto_regulation_dtemp": "Seuil de régulation", "auto_regulation_dtemp": "Seuil de régulation",
"auto_regulation_periode_min": "Période minimale 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.", "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)", "proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)",
"ac_mode": "Utilisation du mode Air Conditionné (AC)", "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": "Utilisation de l'auto-régulation faite par VTherm",
"auto_regulation_mode": "Ajustement automatique de la température cible", "auto_regulation_dtemp": "Le seuil en ° (ou % pour les vannes) en-dessous duquel la régulation ne sera pas envoyée",
"auto_regulation_dtemp": "Le seuil en ° (ou % pour les valves) 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_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", "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", "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]" "central_boiler_deactivation_service": "Commande à éxecuter pour étiendre la chaudière centrale au format entity_id/service_name[/attribut:valeur]"
} }
}, },
"sonoff_trvzb": { "valve_regulation": {
"title": "Configuration Sonoff TRVZB", "title": "Auto-régulation par vanne - {name}",
"description": "Configuration spécifique des Sonoff TRVZB", "description": "Configuration de l'auto-régulation par controle direct de la vanne",
"data": { "data": {
"offset_calibration_entity_ids": "Entités de 'Offset calibration'", "offset_calibration_entity_ids": "Entités de 'calibrage du décalage''",
"opening_degree_entity_ids": "Entités de 'Opening degree'", "opening_degree_entity_ids": "Entités 'ouverture de vanne'",
"closing_degree_entity_ids": "Entités de 'Closing degree'", "closing_degree_entity_ids": "Entités 'fermeture de la vanne'",
"proportional_function": "Algorithme" "proportional_function": "Algorithme"
}, },
"data_description": { "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", "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 'opening degree' entities. 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 'closing degree' entities. 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)" "proportional_function": "Algorithme à utiliser (seulement TPI est disponible)"
} }
} }
@@ -274,7 +272,7 @@
"presence": "Détection de présence", "presence": "Détection de présence",
"advanced": "Paramètres avancés", "advanced": "Paramètres avancés",
"auto_start_stop": "Allumage/extinction automatique", "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", "finalize": "Finaliser les modifications",
"configuration_not_complete": "Configuration incomplète" "configuration_not_complete": "Configuration incomplète"
} }
@@ -323,7 +321,6 @@
"heater_keep_alive": "keep-alive (sec)", "heater_keep_alive": "keep-alive (sec)",
"proportional_function": "Algorithme", "proportional_function": "Algorithme",
"ac_mode": "AC mode ?", "ac_mode": "AC mode ?",
"sonoff_trvzb_mode": "Mode Sonoff TRVZB",
"auto_regulation_mode": "Auto-régulation", "auto_regulation_mode": "Auto-régulation",
"auto_regulation_dtemp": "Seuil de régulation", "auto_regulation_dtemp": "Seuil de régulation",
"auto_regulation_periode_min": "Période minimale 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.", "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)", "proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)",
"ac_mode": "Utilisation du mode Air Conditionné (AC)", "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": "Utilisation de l'auto-régulation faite par VTherm",
"auto_regulation_mode": "Ajustement automatique de la température cible", "auto_regulation_dtemp": "Le seuil en ° (ou % pour les vannes) en-dessous duquel la régulation ne sera pas envoyée",
"auto_regulation_dtemp": "Le seuil en ° (ou % pour les valves) 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_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", "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", "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]" "central_boiler_deactivation_service": "Commande à éxecuter pour étiendre la chaudière centrale au format entity_id/service_name[/attribut:valeur]"
} }
}, },
"sonoff_trvzb": { "valve_regulation": {
"title": "Configuration Sonoff TRVZB - {name}", "title": "Auto-régulation par vanne - {name}",
"description": "Configuration spécifique des Sonoff TRVZB", "description": "Configuration de l'auto-régulation par controle direct de la vanne",
"data": { "data": {
"offset_calibration_entity_ids": "Entités de 'Offset calibration'", "offset_calibration_entity_ids": "Entités de 'calibrage du décalage''",
"opening_degree_entity_ids": "Entités de 'Opening degree'", "opening_degree_entity_ids": "Entités 'ouverture de vanne'",
"closing_degree_entity_ids": "Entités de 'Closing degree'", "closing_degree_entity_ids": "Entités 'fermeture de la vanne'",
"proportional_function": "Algorithme" "proportional_function": "Algorithme"
}, },
"data_description": { "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", "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 'opening degree' entities. 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 'closing degree' entities. 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)" "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.", "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.", "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", "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": { "abort": {
"already_configured": "Le device est déjà configuré" "already_configured": "Le device est déjà configuré"
@@ -504,7 +500,8 @@
"auto_regulation_medium": "Moyenne", "auto_regulation_medium": "Moyenne",
"auto_regulation_light": "Légère", "auto_regulation_light": "Légère",
"auto_regulation_expert": "Expert", "auto_regulation_expert": "Expert",
"auto_regulation_none": "Aucune" "auto_regulation_none": "Aucune",
"auto_regulation_valve": "Contrôle direct de la vanne"
} }
}, },
"auto_fan_mode": { "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.helpers.event import async_call_later
from homeassistant.util.unit_conversion import TemperatureConverter 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 from .keep_alive import IntervalCaller
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -53,8 +53,8 @@ class UnderlyingEntityType(StrEnum):
# a valve # a valve
VALVE = "valve" VALVE = "valve"
# a Sonoff TRVZB # a direct valve regulation
SONOFF_TRVZB = "sonoff_trvzb" VALVE_REGULATION = "valve_regulation"
class UnderlyingEntity: class UnderlyingEntity:
@@ -871,7 +871,11 @@ class UnderlyingValve(UnderlyingEntity):
_last_sent_temperature = None _last_sent_temperature = None
def __init__( 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: ) -> None:
"""Initialize the underlying valve""" """Initialize the underlying valve"""
@@ -884,7 +888,7 @@ class UnderlyingValve(UnderlyingEntity):
self._async_cancel_cycle = None self._async_cancel_cycle = None
self._should_relaunch_control_heating = False self._should_relaunch_control_heating = False
self._hvac_mode = None 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 self._valve_entity_id = valve_entity_id
async def _send_value_to_number(self, number_entity_id: str, value: int): 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): async def turn_on(self):
"""Nothing to do for Valve because it cannot be turned on""" """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: async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool:
"""Set the HVACmode. Returns true if something have change""" """Set the HVACmode. Returns true if something have change"""
@@ -958,11 +962,8 @@ class UnderlyingValve(UnderlyingEntity):
force=False, force=False,
): ):
"""We use this function to change the on_percent""" """We use this function to change the on_percent"""
if force: # if force:
# self._percent_open = self.cap_sent_value(self._percent_open) await self.set_valve_open_percent()
# await self.send_percent_open()
# avoid to send 2 times the same value at startup
self.set_valve_open_percent()
@overrides @overrides
def cap_sent_value(self, value) -> float: def cap_sent_value(self, value) -> float:
@@ -995,7 +996,7 @@ class UnderlyingValve(UnderlyingEntity):
return new_value return new_value
def set_valve_open_percent(self): async def set_valve_open_percent(self):
"""Update the valve open percent""" """Update the valve open percent"""
caped_val = self.cap_sent_value(self._thermostat.valve_open_percent) caped_val = self.cap_sent_value(self._thermostat.valve_open_percent)
if self._percent_open == caped_val: if self._percent_open == caped_val:
@@ -1009,15 +1010,16 @@ class UnderlyingValve(UnderlyingEntity):
"%s - Setting valve ouverture percent to %s", self, self._percent_open "%s - Setting valve ouverture percent to %s", self, self._percent_open
) )
# Send the change to the valve, in background # 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): def remove_entity(self):
"""Remove the entity after stopping its cycle""" """Remove the entity after stopping its cycle"""
self._cancel_cycle() self._cancel_cycle()
class UnderlyingSonoffTRVZB(UnderlyingValve): class UnderlyingValveRegulation(UnderlyingValve):
"""A specific underlying class for Sonoff TRVZB TRV""" """A specific underlying class for Valve regulation"""
_offset_calibration_entity_id: str _offset_calibration_entity_id: str
_opening_degree_entity_id: str _opening_degree_entity_id: str
@@ -1030,15 +1032,23 @@ class UnderlyingSonoffTRVZB(UnderlyingValve):
offset_calibration_entity_id: str, offset_calibration_entity_id: str,
opening_degree_entity_id: str, opening_degree_entity_id: str,
closing_degree_entity_id: str, closing_degree_entity_id: str,
climate_underlying: UnderlyingClimate,
) -> None: ) -> None:
"""Initialize the underlying Sonoff TRV""" """Initialize the underlying TRV with valve regulation"""
super().__init__(hass, thermostat, opening_degree_entity_id) super().__init__(
hass,
thermostat,
opening_degree_entity_id,
entity_type=UnderlyingEntityType.VALVE_REGULATION,
)
self._offset_calibration_entity_id = offset_calibration_entity_id self._offset_calibration_entity_id = offset_calibration_entity_id
self._opening_degree_entity_id = opening_degree_entity_id self._opening_degree_entity_id = opening_degree_entity_id
self._closing_degree_entity_id = closing_degree_entity_id self._closing_degree_entity_id = closing_degree_entity_id
self._climate_underlying = climate_underlying
self._is_min_max_initialized = False self._is_min_max_initialized = False
self._max_opening_degree = None self._max_opening_degree = None
self._min_offset_calibration = None self._min_offset_calibration = None
self._max_offset_calibration = None
async def send_percent_open(self): async def send_percent_open(self):
"""Send the percent open to the underlying valve""" """Send the percent open to the underlying valve"""
@@ -1049,13 +1059,21 @@ class UnderlyingSonoffTRVZB(UnderlyingValve):
self._max_opening_degree = self._hass.states.get( self._max_opening_degree = self._hass.states.get(
self._opening_degree_entity_id self._opening_degree_entity_id
).attributes.get("max") ).attributes.get("max")
if self.have_offset_calibration_entity:
self._min_offset_calibration = self._hass.states.get( self._min_offset_calibration = self._hass.states.get(
self._offset_calibration_entity_id self._offset_calibration_entity_id
).attributes.get("min") ).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._is_min_max_initialized = self._max_opening_degree is not None and (
self._max_opening_degree is not None not self.have_offset_calibration_entity
and self._min_offset_calibration is not None or (
self._min_offset_calibration is not None
and self._max_offset_calibration is not None
)
) )
if not self._is_min_max_initialized: if not self._is_min_max_initialized:
@@ -1067,15 +1085,46 @@ class UnderlyingSonoffTRVZB(UnderlyingValve):
# Send opening_degree # Send opening_degree
await super().send_percent_open() await super().send_percent_open()
# Send closing_degree. TODO 100 hard-coded or take the max of the _closing_degree_entity_id ? # Send closing_degree if set
closing_degree = None
if self.have_closing_degree_entity:
await self._send_value_to_number( await self._send_value_to_number(
self._closing_degree_entity_id, self._closing_degree_entity_id,
self._max_opening_degree - self._percent_open, closing_degree := self._max_opening_degree - self._percent_open,
)
# 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),
),
) )
# send offset_calibration to the min value
await self._send_value_to_number( await self._send_value_to_number(
self._offset_calibration_entity_id, self._min_offset_calibration 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 @property
@@ -1093,9 +1142,40 @@ class UnderlyingSonoffTRVZB(UnderlyingValve):
"""The offset_calibration_entity_id""" """The offset_calibration_entity_id"""
return self._closing_degree_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 @property
def hvac_modes(self) -> list[HVACMode]: def hvac_modes(self) -> list[HVACMode]:
"""Get the hvac_modes""" """Get the hvac_modes"""
if not self.is_initialized: if not self.is_initialized:
return [] return []
return [HVACMode.OFF, HVACMode.HEAT] 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_SAFETY_MODE,
CONF_THERMOSTAT_TYPE, CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG, CONF_THERMOSTAT_CENTRAL_CONFIG,
CONF_MAX_ON_PERCENT,
) )
VTHERM_API_NAME = "vtherm_api" VTHERM_API_NAME = "vtherm_api"
@@ -60,6 +61,7 @@ class VersatileThermostatAPI(dict):
self._central_mode_select = None self._central_mode_select = None
# A dict that will store all Number entities which holds the temperature # A dict that will store all Number entities which holds the temperature
self._number_temperatures = dict() self._number_temperatures = dict()
self._max_on_percent = None
def find_central_configuration(self): def find_central_configuration(self):
"""Search for a central configuration""" """Search for a central configuration"""
@@ -107,6 +109,12 @@ class VersatileThermostatAPI(dict):
if self._safety_mode: if self._safety_mode:
_LOGGER.debug("We have found safet_mode params %s", 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): def register_central_boiler(self, central_boiler_entity):
"""Register the central boiler entity. This is used by the CentralBoilerBinarySensor """Register the central boiler entity. This is used by the CentralBoilerBinarySensor
class to register itself at creation""" class to register itself at creation"""
@@ -171,7 +179,8 @@ class VersatileThermostatAPI(dict):
# ): # ):
# await entity.init_presets(self.find_central_configuration()) # 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 ( if (
entity.device_info entity.device_info
and entity.device_info.get("model", None) == DOMAIN and entity.device_info.get("model", None) == DOMAIN
@@ -241,6 +250,11 @@ class VersatileThermostatAPI(dict):
"""Get the safety_mode params""" """Get the safety_mode params"""
return self._safety_mode return self._safety_mode
@property
def max_on_percent(self):
"""Get the max_open_percent params"""
return self._max_on_percent
@property @property
def central_boiler_entity(self): def central_boiler_entity(self):
"""Get the central boiler binary_sensor entity""" """Get the central boiler binary_sensor entity"""

View File

@@ -3,6 +3,7 @@
""" Some common resources """ """ Some common resources """
import asyncio import asyncio
import logging import logging
from typing import Any, Dict, Callable
from unittest.mock import patch, MagicMock # pylint: disable=unused-import from unittest.mock import patch, MagicMock # pylint: disable=unused-import
import pytest # 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.base_thermostat import BaseThermostat
from custom_components.versatile_thermostat.const import * # pylint: disable=wildcard-import, unused-wildcard-import from custom_components.versatile_thermostat.const import * # pylint: disable=wildcard-import, unused-wildcard-import
from custom_components.versatile_thermostat.underlyings import * # pylint: disable=wildcard-import, unused-wildcard-import from custom_components.versatile_thermostat.underlyings import * # pylint: disable=wildcard-import, unused-wildcard-import
from custom_components.versatile_thermostat.commons import ( # pylint: disable=unused-import
get_tz,
NowClass,
)
from custom_components.versatile_thermostat.vtherm_api import VersatileThermostatAPI 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( 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""" """Initialize all temp of preset for a VTherm entity"""
local_temps = temps if temps is not None else default_temperatures
# We initialize # 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) await set_climate_preset_temp(vtherm, preset_name, value)
@@ -1028,3 +1063,31 @@ async def set_all_climate_preset_temp(
assert temp_entity assert temp_entity
# Because set_value is not implemented in Number class (really don't understand why...) # Because set_value is not implemented in Number class (really don't understand why...)
assert temp_entity.state == value 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) event_timestamp = now - timedelta(minutes=10)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
), patch( ), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", "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 # set manual target temp (at now - 7) -> the regulation should occurs
event_timestamp = now - timedelta(minutes=7) event_timestamp = now - timedelta(minutes=7)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
): ):
await entity.async_set_temperature(temperature=18) 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 # change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=5) event_timestamp = now - timedelta(minutes=5)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
): ):
await send_temperature_change_event(entity, 23, 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) event_timestamp = now - timedelta(minutes=10)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
), patch( ), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", "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 # set manual target temp
event_timestamp = now - timedelta(minutes=7) event_timestamp = now - timedelta(minutes=7)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
): ):
await entity.async_set_temperature(temperature=25) 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 # change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=5) event_timestamp = now - timedelta(minutes=5)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
): ):
await send_temperature_change_event(entity, 26, 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 # change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=3) event_timestamp = now - timedelta(minutes=3)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
): ):
await send_temperature_change_event(entity, 18, 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) event_timestamp = now - timedelta(minutes=20)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
), patch( ), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
@@ -286,21 +286,21 @@ async def test_over_climate_regulation_limitations(
assert entity.is_over_climate is True assert entity.is_over_climate is True
assert entity.is_regulated 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 # Activate the heating by changing HVACMode and temperature
# Select a hvacmode, presence and preset # Select a hvacmode, presence and preset
await entity.async_set_hvac_mode(HVACMode.HEAT) await entity.async_set_hvac_mode(HVACMode.HEAT)
assert entity.hvac_mode is HVACMode.HEAT assert entity.hvac_mode is HVACMode.HEAT
await entity.async_set_temperature(temperature=17)
# it is cold today # it is cold today
await send_temperature_change_event(entity, 15, event_timestamp) await send_temperature_change_event(entity, 15, event_timestamp)
await send_ext_temperature_change_event(entity, 10, 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) event_timestamp = now - timedelta(minutes=19)
with patch( entity._set_now(event_timestamp)
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await entity.async_set_temperature(temperature=18) await entity.async_set_temperature(temperature=18)
fake_underlying_climate.set_hvac_action( fake_underlying_climate.set_hvac_action(
@@ -308,15 +308,13 @@ async def test_over_climate_regulation_limitations(
) # simulate under heating ) # simulate under heating
assert entity.hvac_action == HVACAction.HEATING assert entity.hvac_action == HVACAction.HEATING
# the regulated temperature will change because when we set temp manually it is forced # the regulated temperature will not change because when we set temp manually it is forced
assert entity.regulated_target_temp == 19.5 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) event_timestamp = now - timedelta(minutes=18)
with patch( entity._set_now(event_timestamp)
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await entity.async_set_temperature(temperature=17) await entity.async_set_temperature(temperature=17)
assert entity.regulated_target_temp > entity.target_temperature assert entity.regulated_target_temp > entity.target_temperature
assert ( assert (
@@ -324,33 +322,25 @@ async def test_over_climate_regulation_limitations(
) # In strong we could go up to +3 degre. 0.7 without round_to_nearest ) # In strong we could go up to +3 degre. 0.7 without round_to_nearest
old_regulated_temp = entity.regulated_target_temp old_regulated_temp = entity.regulated_target_temp
# change temperature so that dtemp < 0.5 and time is > period_min (+ 3min) # 3. change temperature so that dtemp < 0.5 and time is > period_min (+ 3min)
event_timestamp = now - timedelta(minutes=15) event_timestamp = now - timedelta(minutes=15)
with patch( entity._set_now(event_timestamp)
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 16, event_timestamp) await send_temperature_change_event(entity, 16, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp) await send_ext_temperature_change_event(entity, 10, event_timestamp)
# the regulated temperature should be under # the regulated temperature should be under
assert entity.regulated_target_temp <= old_regulated_temp 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) event_timestamp = now - timedelta(minutes=12)
with patch( entity._set_now(event_timestamp)
"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) 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 # the regulated should have been done
assert entity.regulated_target_temp != old_regulated_temp assert entity.regulated_target_temp != old_regulated_temp
assert entity.regulated_target_temp >= entity.target_temperature assert entity.regulated_target_temp >= entity.target_temperature
assert ( assert entity.regulated_target_temp == 17 + 1.5 # 0.7 without round_to_nearest
entity.regulated_target_temp == 17 + 1.5
) # 0.7 without round_to_nearest
@pytest.mark.parametrize("expected_lingering_tasks", [True]) @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) event_timestamp = now - timedelta(minutes=10)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
), patch( ), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", "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) fake_underlying_climate.set_current_temperature(15)
event_timestamp = now - timedelta(minutes=7) event_timestamp = now - timedelta(minutes=7)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
await entity.async_set_temperature(temperature=16) 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) event_timestamp = now - timedelta(minutes=5)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
await send_temperature_change_event(entity, 15, event_timestamp) 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) event_timestamp = now - timedelta(minutes=3)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
await send_temperature_change_event(entity, 25, event_timestamp) 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) event_timestamp = now - timedelta(minutes=20)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
), patch( ), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", "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 # set manual target temp
event_timestamp = now - timedelta(minutes=17) event_timestamp = now - timedelta(minutes=17)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
): ):
await entity.async_set_temperature(temperature=20) 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 # change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=15) event_timestamp = now - timedelta(minutes=15)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
): ):
await send_temperature_change_event(entity, 19, 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 # change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=13) event_timestamp = now - timedelta(minutes=13)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
): ):
await send_temperature_change_event(entity, 20, 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) # 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) event_timestamp = now - timedelta(minutes=10)
with patch( with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.const.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
): ):
await send_temperature_change_event(entity, 19.6, 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.hvac_off_reason == HVAC_OFF_REASON_WINDOW_DETECTION
assert vtherm._saved_hvac_mode == HVACMode.HEAT 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 assert vtherm.window_state == STATE_ON

View File

@@ -161,19 +161,6 @@ async def test_bug_272(
"homeassistant.core.ServiceRegistry.async_call" "homeassistant.core.ServiceRegistry.async_call"
) as mock_service_call: ) as mock_service_call:
entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname") 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
assert entity.name == "TheOverClimateMockName" assert entity.name == "TheOverClimateMockName"
@@ -215,16 +202,18 @@ async def test_bug_272(
) )
tz = get_tz(hass) # pylint: disable=invalid-name 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( with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
# Set room temperature to something very cold # 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) event_timestamp = event_timestamp + timedelta(minutes=3)
await send_ext_temperature_change_event(entity, 9, event_timestamp) entity._set_now(event_timestamp)
# Not in the accepted interval (15-19) # Not in the accepted interval (15-19)
await entity.async_set_temperature(temperature=10) 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" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
# Set room temperature to something very cold # 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_temperature_change_event(entity, 13, event_timestamp)
await send_ext_temperature_change_event(entity, 9, event_timestamp) await send_ext_temperature_change_event(entity, 9, event_timestamp)
# In the accepted interval # In the accepted interval
event_timestamp = event_timestamp + timedelta(minutes=3)
entity._set_now(event_timestamp)
await entity.async_set_temperature(temperature=20.8) await entity.async_set_temperature(temperature=20.8)
assert mock_service_call.call_count == 1 assert mock_service_call.call_count == 1
mock_service_call.assert_has_calls( 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 """ """ Test the Versatile Thermostat config flow """
from homeassistant import data_entry_flow
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.config_entries import SOURCE_USER, ConfigEntry 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_USE_ADVANCED_CENTRAL_CONFIG: False,
CONF_USED_BY_CENTRAL_BOILER: False, CONF_USED_BY_CENTRAL_BOILER: False,
CONF_USE_CENTRAL_MODE: 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"]
assert result["result"].domain == DOMAIN 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_USED_BY_CENTRAL_BOILER: False,
CONF_USE_AUTO_START_STOP_FEATURE: True, CONF_USE_AUTO_START_STOP_FEATURE: True,
CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_MEDIUM, 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"]
assert result["result"].domain == DOMAIN 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"].version == 2
assert result["result"].title == "TheOverSwitchMockName" assert result["result"].title == "TheOverSwitchMockName"
assert isinstance(result["result"], ConfigEntry) 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 # 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 unittest.mock import patch, call
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -517,6 +517,9 @@ async def test_bug_508(
data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay 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 # Min_temp is 10 and max_temp is 31 and features contains TARGET_TEMPERATURE_RANGE
fake_underlying_climate = MagicMockClimateWithTemperatureRange() fake_underlying_climate = MagicMockClimateWithTemperatureRange()
@@ -545,6 +548,9 @@ async def test_bug_508(
# Set the hvac_mode to HEAT # Set the hvac_mode to HEAT
await entity.async_set_hvac_mode(HVACMode.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 # 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) 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: 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 # 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) await entity.async_set_temperature(temperature=32)
# MagicMock climate is already HEAT by default. So there is no SET_HAVC_MODE call # 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.hvac_off_reason == HVAC_OFF_REASON_WINDOW_DETECTION
assert vtherm._saved_hvac_mode == HVACMode.HEAT 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 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._motion_state is None
assert entity._presence_state is None assert entity._presence_state is None
assert entity._prop_algorithm is not 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 # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT
assert mock_send_event.call_count == 2 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, return_value=fake_underlying_climate,
) as mock_find_climate: ) as mock_find_climate:
entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname") 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
assert isinstance(entity, ThermostatOverClimate) 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._window_state is None
assert entity._motion_state is None assert entity._motion_state is None
assert entity._presence_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 # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT
assert mock_send_event.call_count == 2 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 ( from custom_components.versatile_thermostat.thermostat_switch import (
ThermostatOverSwitch, 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 custom_components.versatile_thermostat.vtherm_api import VersatileThermostatAPI
from .commons import * from .commons import *

View File

@@ -125,6 +125,39 @@ async def test_tpi_calculation(
assert tpi_algo.on_time_sec == 0 assert tpi_algo.on_time_sec == 0
assert tpi_algo.off_time_sec == 300 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_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [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._motion_state is None # pylint: disable=protected-access
assert entity._presence_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._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 # should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT
# assert mock_send_event.call_count == 2 # assert mock_send_event.call_count == 2