Compare commits

..

2 Commits

Author SHA1 Message Date
Jean-Marc Collin
592b2ca574 Fix hvac_action
Fix offset_calibration=room_temp - (local_temp - current_offset)
2024-11-19 19:32:20 +00:00
Jean-Marc Collin
9102b09691 Calculate offset_calibration as room_temp - local_temp
Fix hvac_action calculation
2024-11-19 06:58:20 +00:00
6 changed files with 114 additions and 25 deletions

View File

@@ -504,7 +504,7 @@ 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 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",

View File

@@ -2,6 +2,7 @@
"""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 enum import Enum from enum import Enum
@@ -464,9 +465,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):
@@ -491,6 +492,20 @@ 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
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."""

View File

@@ -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,6 +169,11 @@ 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"""

View File

@@ -5,7 +5,7 @@ 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 UnderlyingSonoffTRVZB
@@ -41,7 +41,7 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
) )
) )
_underlyings_sonoff_trvzb: list[UnderlyingSonoffTRVZB] = [] _underlyings_sonoff_trvzb: list[UnderlyingSonoffTRVZB] = []
_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
@@ -96,6 +96,7 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
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_sonoff_trvzb.append(under)
@@ -187,9 +188,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,7 +208,10 @@ 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
@@ -215,6 +224,10 @@ class ThermostatOverSonoffTRVZB(ThermostatOverClimate):
self.update_custom_attributes() self.update_custom_attributes()
async def _send_regulated_temperature(self, force=False):
"""Sends the regulated temperature to all underlying"""
self.recalculate()
@property @property
def is_over_sonoff_trvzb(self) -> bool: def is_over_sonoff_trvzb(self) -> bool:
"""True if the Thermostat is over_sonoff_trvzb""" """True if the Thermostat is over_sonoff_trvzb"""
@@ -236,7 +249,13 @@ 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_sonoff_trvzb"""
return self.calculate_hvac_action(self._underlyings_sonoff_trvzb)

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__)
@@ -884,7 +884,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):
@@ -1030,15 +1030,18 @@ 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 Sonoff TRV"""
super().__init__(hass, thermostat, opening_degree_entity_id) super().__init__(hass, thermostat, opening_degree_entity_id)
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"""
@@ -1052,10 +1055,14 @@ class UnderlyingSonoffTRVZB(UnderlyingValve):
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 self._max_opening_degree is not None
and self._min_offset_calibration is not None and 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 +1074,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
await self._send_value_to_number( closing_degree = None
self._closing_degree_entity_id, if self._closing_degree_entity_id is not None:
self._max_opening_degree - self._percent_open, await self._send_value_to_number(
) self._closing_degree_entity_id,
closing_degree := self._max_opening_degree - self._percent_open,
)
# send offset_calibration to the min value # send offset_calibration to the difference between target temp and local temp
await self._send_value_to_number( offset = None
self._offset_calibration_entity_id, self._min_offset_calibration if self._offset_calibration_entity_id is not None:
if (
(local_temp := self._climate_underlying.underlying_current_temperature)
is not None
and (room_temp := self._thermostat.current_temperature) is not None
and (
current_offset := get_safe_float(
self._hass, self._offset_calibration_entity_id
)
)
is not None
):
offset = min(
self._max_offset_calibration,
max(
self._min_offset_calibration,
room_temp - (local_temp - current_offset),
),
)
await self._send_value_to_number(
self._offset_calibration_entity_id, offset
)
_LOGGER.debug(
"%s - SonoffTRVZB - I have sent offset_calibration=%s opening_degree=%s closing_degree=%s",
self,
offset,
self._percent_open,
closing_degree,
) )
@property @property
@@ -1099,3 +1137,11 @@ class UnderlyingSonoffTRVZB(UnderlyingValve):
if not self.is_initialized: if not self.is_initialized:
return [] return []
return [HVACMode.OFF, HVACMode.HEAT] return [HVACMode.OFF, HVACMode.HEAT]
@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

@@ -179,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
@@ -249,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"""