639 lines
28 KiB
Python
639 lines
28 KiB
Python
# pylint: disable=line-too-long, too-many-lines, abstract-method
|
|
""" A climate with a direct valve regulation class """
|
|
|
|
import logging
|
|
from vtherm_api.log_collector import get_vtherm_logger
|
|
import asyncio
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from homeassistant.core import Event, HomeAssistant, State
|
|
from homeassistant.components.climate import HVACAction, HVACMode
|
|
from homeassistant.helpers.event import EventStateChangedData, async_call_later
|
|
from homeassistant.exceptions import ServiceValidationError
|
|
|
|
from .underlyings import UnderlyingValveRegulation, UnderlyingClimate
|
|
|
|
from .base_thermostat import ConfigData
|
|
from .thermostat_climate import ThermostatOverClimate
|
|
from .thermostat_prop import ThermostatProp
|
|
from .cycle_scheduler import CycleScheduler
|
|
|
|
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
|
|
from .commons import write_event_log
|
|
from .vtherm_hvac_mode import VThermHvacMode, VThermHvacMode_OFF, VThermHvacMode_SLEEP
|
|
|
|
# from .vtherm_central_api import VersatileThermostatAPI
|
|
|
|
_LOGGER = get_vtherm_logger(__name__)
|
|
|
|
|
|
class ThermostatOverClimateValve(ThermostatProp[UnderlyingClimate], ThermostatOverClimate):
|
|
"""This class represent a VTherm over a climate with a direct valve regulation"""
|
|
|
|
_entity_component_unrecorded_attributes = ThermostatOverClimate._entity_component_unrecorded_attributes.union( # pylint: disable=protected-access
|
|
frozenset(
|
|
{
|
|
"is_over_climate",
|
|
"vtherm_over_climate",
|
|
"vtherm_over_climate_valve",
|
|
}
|
|
)
|
|
)
|
|
|
|
def __init__(
|
|
self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigData
|
|
):
|
|
"""Initialize the ThermostatOverClimateValve class"""
|
|
_LOGGER.debug("%s - creating a ThermostatOverClimateValve VTherm", name)
|
|
self._underlyings_valve_regulation: list[UnderlyingValveRegulation] = []
|
|
self._valve_open_percent: int | None = None
|
|
self._last_calculation_timestamp: datetime | None = None
|
|
self._auto_regulation_dpercent: float | None = None
|
|
self._auto_regulation_period_min: int | None = None
|
|
self._min_opening_degress: list[int] = []
|
|
self._max_closing_degree: int = 100
|
|
self._opening_threshold_degree: int = 0
|
|
self._recalibrate_lock: asyncio.Lock = asyncio.Lock()
|
|
self._climate_under_initialized: bool = False
|
|
self._valve_under_initialized: bool = False
|
|
|
|
super().__init__(hass, unique_id, name, entry_infos)
|
|
|
|
@overrides
|
|
def post_init(self, config_entry: ConfigData):
|
|
"""Initialize the Thermostat and underlyings
|
|
Beware that the underlyings list contains the climate which represent the TRV
|
|
but also the UnderlyingValveRegulation which reprensent the valve"""
|
|
|
|
super().post_init(config_entry)
|
|
|
|
self._auto_regulation_dpercent = (
|
|
config_entry.get(CONF_AUTO_REGULATION_DTEMP)
|
|
if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None
|
|
else 0.0
|
|
)
|
|
self._auto_regulation_period_min = (
|
|
config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN)
|
|
if config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None
|
|
else 0
|
|
)
|
|
|
|
opening_list = config_entry.get(CONF_OPENING_DEGREE_LIST)
|
|
closing_list = config_entry.get(CONF_CLOSING_DEGREE_LIST, [])
|
|
self._max_closing_degree = config_entry.get(CONF_MAX_CLOSING_DEGREE, 100)
|
|
self._opening_threshold_degree = config_entry.get(CONF_OPENING_THRESHOLD_DEGREE, 0)
|
|
regulation_threshold = config_entry.get(CONF_AUTO_REGULATION_DTEMP, 0)
|
|
|
|
self._min_opening_degrees = config_entry.get(CONF_MIN_OPENING_DEGREES, None)
|
|
min_opening_degrees_list = []
|
|
if self._min_opening_degrees:
|
|
min_opening_degrees_list = [
|
|
int(x.strip()) for x in self._min_opening_degrees.split(",")
|
|
]
|
|
|
|
self._max_opening_degrees = config_entry.get(CONF_MAX_OPENING_DEGREES, None)
|
|
max_opening_degrees_list = []
|
|
if self._max_opening_degrees:
|
|
max_opening_degrees_list = [
|
|
int(x.strip()) for x in self._max_opening_degrees.split(",")
|
|
]
|
|
|
|
for idx, _ in enumerate(config_entry.get(CONF_UNDERLYING_LIST)):
|
|
# number of opening should equal number of underlying
|
|
opening = opening_list[idx]
|
|
closing = closing_list[idx] if idx < len(closing_list) else None
|
|
self._opening_threshold_degree = max(self._opening_threshold_degree, regulation_threshold)
|
|
# TODO c'est pas possible ici
|
|
opening_entity = self._hass.states.get(opening)
|
|
|
|
under = UnderlyingValveRegulation(
|
|
hass=self._hass,
|
|
thermostat=self,
|
|
opening_degree_entity_id=opening,
|
|
closing_degree_entity_id=closing,
|
|
climate_underlying=self._underlyings[idx],
|
|
min_opening_degree=(min_opening_degrees_list[idx] if idx < len(min_opening_degrees_list) else 0),
|
|
# TODO pas bon
|
|
max_opening_degree=(max_opening_degrees_list[idx] if idx < len(max_opening_degrees_list) else opening_entity.attributes.get("max", 100) if opening_entity else 100),
|
|
max_closing_degree=self._max_closing_degree,
|
|
opening_threshold=self._opening_threshold_degree,
|
|
)
|
|
self._underlyings_valve_regulation.append(under)
|
|
|
|
self._bind_scheduler(CycleScheduler(
|
|
hass=self._hass,
|
|
thermostat=self,
|
|
underlyings=self._underlyings_valve_regulation,
|
|
cycle_duration_sec=self._cycle_min * 60,
|
|
min_activation_delay=self.minimal_activation_delay,
|
|
min_deactivation_delay=self.minimal_deactivation_delay,
|
|
))
|
|
|
|
async def init_underlyings_completed(self, under_entity_id: Optional[str] = None):
|
|
"""Called when an underlying is fully initialized
|
|
Caution: this method is called for the _underlyings of the ThermostatClimate but also for the underlyings_valve_regulation
|
|
We have to call the parent method only when the both underlyings are initialized"""
|
|
|
|
_LOGGER.debug("%s - init_underlyings_completed called for %s", self, under_entity_id)
|
|
if not self.is_ready:
|
|
return
|
|
|
|
_LOGGER.debug("%s - both climate and valve underlyings are initialized", self)
|
|
|
|
await super().init_underlyings_completed(under_entity_id)
|
|
|
|
async def async_startup(self, central_configuration):
|
|
"""Startup the Entity. Listen to the underlying state changes"""
|
|
await super().async_startup(central_configuration)
|
|
|
|
# Register the valve listener
|
|
for under in self._underlyings_valve_regulation:
|
|
_LOGGER.debug("%s - starting underlying valve regulation %s", self, under)
|
|
try:
|
|
under.startup()
|
|
_LOGGER.debug("%s - underlying valve regulation %s started successfully", self, under)
|
|
except Exception as ex: # pylint: disable=broad-except
|
|
_LOGGER.error("%s - Error starting underlying valve regulation %s: %s", self, under, ex)
|
|
|
|
@overrides
|
|
def restore_specific_previous_state(self, old_state: State):
|
|
"""Restore my specific attributes from previous state"""
|
|
super().restore_specific_previous_state(old_state)
|
|
|
|
if self.is_sleeping:
|
|
self.set_hvac_off_reason(HVAC_OFF_REASON_SLEEP_MODE)
|
|
|
|
@property
|
|
def is_initialized(self) -> bool:
|
|
"""Check if all underlyings and valve underlyings are initialized"""
|
|
if not super().is_initialized:
|
|
return False
|
|
|
|
for under in self._underlyings_valve_regulation:
|
|
if not under.is_initialized:
|
|
return False
|
|
return True
|
|
|
|
@overrides
|
|
def update_custom_attributes(self):
|
|
"""Custom attributes"""
|
|
super().update_custom_attributes()
|
|
|
|
valve_attributes = {}
|
|
for under in self._underlyings_valve_regulation:
|
|
valve_attributes.update(
|
|
{
|
|
under.entity_id: {
|
|
"hvac_action": under.hvac_action,
|
|
"percent_open": under.percent_open,
|
|
"last_sent_opening_value": under.last_sent_opening_value,
|
|
"min_opening_degree": under._min_opening_degree, # pylint: disable=protected-access
|
|
"max_opening_degree": under._max_opening_degree, # pylint: disable=protected-access
|
|
}
|
|
}
|
|
)
|
|
|
|
self._attr_extra_state_attributes["valve_open_percent"] = self.valve_open_percent
|
|
self._attr_extra_state_attributes["power_percent"] = self.power_percent
|
|
self._attr_extra_state_attributes["on_percent"] = self.safe_on_percent
|
|
self._attr_extra_state_attributes.update(
|
|
{
|
|
"vtherm_over_climate_valve": {
|
|
"have_valve_regulation": self.have_valve_regulation,
|
|
"valve_regulation": {
|
|
"underlyings_valve_regulation": [underlying.valve_entity_ids for underlying in self._underlyings_valve_regulation],
|
|
"on_percent": self.safe_on_percent,
|
|
"power_percent": self.power_percent,
|
|
"function": self._proportional_function,
|
|
"tpi_coef_int": self._tpi_coef_int,
|
|
"tpi_coef_ext": self._tpi_coef_ext,
|
|
"tpi_threshold_low": self._tpi_threshold_low,
|
|
"tpi_threshold_high": self._tpi_threshold_high,
|
|
"minimal_activation_delay": self._minimal_activation_delay,
|
|
"minimal_deactivation_delay": self._minimal_deactivation_delay,
|
|
"min_opening_degrees": self._min_opening_degrees,
|
|
"opening_threshold_degree": self._opening_threshold_degree,
|
|
"max_closing_degree": self._max_closing_degree,
|
|
"max_opening_degrees": self._max_opening_degrees,
|
|
"valve_open_percent": self.valve_open_percent,
|
|
"auto_regulation_dpercent": self._auto_regulation_dpercent,
|
|
"auto_regulation_period_min": self._auto_regulation_period_min,
|
|
"last_calculation_timestamp": (self._last_calculation_timestamp.astimezone(self._current_tz).isoformat() if self._last_calculation_timestamp else None),
|
|
},
|
|
"underlying_valves": valve_attributes,
|
|
}
|
|
}
|
|
)
|
|
|
|
# self.async_write_ha_state()
|
|
# _LOGGER.debug("%s - Calling update_custom_attributes: %s", self, self._attr_extra_state_attributes)
|
|
|
|
@overrides
|
|
def recalculate(self, force=False):
|
|
"""A utility function to force the calculation of a the algo and
|
|
update the custom attributes and write the state
|
|
"""
|
|
_LOGGER.debug("%s - recalculate the open percent", self)
|
|
|
|
self.stop_recalculate_later()
|
|
|
|
# For testing purpose. Should call _set_now() before
|
|
now = self.now
|
|
|
|
if self._last_calculation_timestamp is not None:
|
|
period = (now - self._last_calculation_timestamp).total_seconds() / 60
|
|
if not force and period < self._auto_regulation_period_min:
|
|
_LOGGER.info(
|
|
"%s - do not calculate TPI because regulation_period (%d) is not exceeded",
|
|
self,
|
|
period,
|
|
)
|
|
self.do_recalculate_later()
|
|
return
|
|
|
|
# Call parent TPI recalculate to perform the TPI algorithm calculation
|
|
super().recalculate(force)
|
|
|
|
if self.is_sleeping:
|
|
new_valve_percent = 100
|
|
else:
|
|
on_percent = self.safe_on_percent
|
|
if on_percent is None:
|
|
# Temperature not yet available; preserve the current valve position.
|
|
return
|
|
new_valve_percent = round(max(0, min(on_percent, 1)) * 100)
|
|
|
|
# Issue 533 - don't filter with dtemp if valve should be close. Else it will never close
|
|
if new_valve_percent < self._auto_regulation_dpercent:
|
|
new_valve_percent = 0
|
|
|
|
dpercent = new_valve_percent - self._valve_open_percent if self._valve_open_percent is not None else 0
|
|
if self._last_calculation_timestamp is not None and new_valve_percent > 0 and -1 * self._auto_regulation_dpercent <= dpercent < self._auto_regulation_dpercent:
|
|
_LOGGER.debug(
|
|
"%s - do not calculate TPI because regulation_dpercent (%.1f) is not exceeded",
|
|
self,
|
|
dpercent,
|
|
)
|
|
|
|
return
|
|
|
|
if (
|
|
self._last_calculation_timestamp is not None
|
|
and self._valve_open_percent == new_valve_percent
|
|
):
|
|
_LOGGER.debug("%s - no change in valve_open_percent.", self)
|
|
return
|
|
|
|
self._valve_open_percent = new_valve_percent
|
|
|
|
self._last_calculation_timestamp = now
|
|
|
|
def do_recalculate_later(self):
|
|
"""A utility function to set the valve open percent later on all underlyings"""
|
|
_LOGGER.debug("%s - do_recalculate_later call", self)
|
|
|
|
async def callback_recalculate(_):
|
|
"""Callback to set the valve percent"""
|
|
self.recalculate()
|
|
await self.async_control_heating(force=False)
|
|
self.update_custom_attributes()
|
|
self.async_write_ha_state()
|
|
|
|
self.stop_recalculate_later()
|
|
|
|
self._cancel_recalculate_later = async_call_later(self._hass, delay=20, action=callback_recalculate)
|
|
|
|
async def _send_regulated_temperature(self, force=False):
|
|
"""Sends the regulated temperature to all underlying"""
|
|
# if self.vtherm_hvac_mode == VThermHvacMode_OFF and not self._is_sleeping:
|
|
# _LOGGER.debug("%s - don't send regulated temperature cause VTherm is off ", self)
|
|
# return
|
|
|
|
if self.target_temperature is None:
|
|
_LOGGER.warning(
|
|
"%s - don't send regulated temperature cause VTherm target_temp (%s) is None. This should be a temporary warning message.",
|
|
self,
|
|
self.target_temperature,
|
|
)
|
|
return
|
|
|
|
if not force and not self.check_auto_regulation_period_min(self.now):
|
|
return
|
|
|
|
# Don't send temperature if hvac_mode is off
|
|
if self.vtherm_hvac_mode != VThermHvacMode_OFF:
|
|
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,
|
|
)
|
|
|
|
self._last_regulation_change = self.now
|
|
self.reset_last_change_time_from_vtherm()
|
|
|
|
_LOGGER.debug(
|
|
"%s - last_regulation_change is now: %s and last_change_from_vtherm is now: %s", self, self._last_regulation_change, self._last_change_time_from_vtherm
|
|
) # pylint: disable=protected-access
|
|
|
|
for under in self._underlyings_valve_regulation:
|
|
await under.set_valve_open_percent()
|
|
|
|
@overrides
|
|
def build_hvac_list(self) -> list[VThermHvacMode]:
|
|
"""Build the hvac list depending on ac_mode"""
|
|
if self._ac_mode:
|
|
return [VThermHvacMode_COOL, VThermHvacMode_SLEEP, VThermHvacMode_OFF]
|
|
else:
|
|
return [VThermHvacMode_HEAT, VThermHvacMode_SLEEP, VThermHvacMode_OFF]
|
|
|
|
@overrides
|
|
def incremente_energy(self):
|
|
"""increment the energy counter if device is active"""
|
|
if self._underlying_climate_start_hvac_action_date:
|
|
stop_power_date = self.now
|
|
delta = stop_power_date - self._underlying_climate_start_hvac_action_date
|
|
self._underlying_climate_delta_t = delta.total_seconds() / 3600.0
|
|
_LOGGER.debug("%s - underlying_climate_delta_t: %.4f hours", self, self._underlying_climate_delta_t)
|
|
# increment energy at the end of the cycle
|
|
super().incremente_energy()
|
|
self._underlying_climate_start_hvac_action_date = self.now
|
|
self._underlying_climate_mean_power_cycle = self.power_manager.mean_cycle_power
|
|
else:
|
|
_LOGGER.debug("%s - no underlying_climate_start_hvac_action_date to calculate energy", self)
|
|
|
|
@property
|
|
def have_valve_regulation(self) -> bool:
|
|
"""True if the Thermostat is regulated by valve"""
|
|
return True
|
|
|
|
@property
|
|
def valve_open_percent(self) -> int:
|
|
"""Gives the percentage of valve needed"""
|
|
if (self.vtherm_hvac_mode == VThermHvacMode_OFF and not self.is_sleeping) or self._valve_open_percent is None:
|
|
return 0
|
|
else:
|
|
return self._valve_open_percent
|
|
|
|
def calculate_hvac_action(self, under_list: list = None) -> HVACAction | None:
|
|
"""Returns the current hvac_action by checking all hvac_action of the _underlyings_valve_regulation"""
|
|
|
|
if self.is_sleeping:
|
|
self._attr_hvac_action = HVACAction.OFF
|
|
else:
|
|
super().calculate_hvac_action(self._underlyings_valve_regulation)
|
|
|
|
@property
|
|
def should_device_be_active(self) -> bool:
|
|
"""A hack to overrides the state from underlyings"""
|
|
if self.is_sleeping:
|
|
return False
|
|
|
|
for under in self._underlyings_valve_regulation:
|
|
if under.should_device_be_active:
|
|
return True
|
|
return False
|
|
|
|
@property
|
|
def device_actives(self) -> int:
|
|
"""Calculate the number of active devices"""
|
|
if self.is_sleeping:
|
|
return []
|
|
|
|
return [under.opening_degree_entity_id for under in self._underlyings_valve_regulation if under.is_device_active]
|
|
|
|
@property
|
|
def is_device_active(self) -> bool:
|
|
"""Returns true if one underlying is active"""
|
|
if ThermostatOverClimate.is_device_active.fget(self) is not True:
|
|
return False
|
|
for under in self._underlyings_valve_regulation:
|
|
if under.is_device_active:
|
|
return True
|
|
return False
|
|
|
|
@property
|
|
def activable_underlying_entities(self) -> list | None:
|
|
"""Returns the activable underlying entities for controling the central boiler"""
|
|
return self._underlyings_valve_regulation
|
|
|
|
@overrides
|
|
@property
|
|
def all_underlying_entities(self) -> list | None:
|
|
"""Returns all underlying entities for controling the central boiler"""
|
|
return self._underlyings + self._underlyings_valve_regulation
|
|
|
|
@overrides
|
|
@property
|
|
def is_sleeping(self) -> bool:
|
|
"""True if the thermostat is in sleep mode"""
|
|
return self.vtherm_hvac_mode == VThermHvacMode_SLEEP
|
|
|
|
@overrides
|
|
async def service_set_auto_regulation_mode(self, auto_regulation_mode: str):
|
|
"""This should not be possible in valve regulation mode"""
|
|
return
|
|
|
|
@overrides
|
|
async def service_set_hvac_mode_sleep(self):
|
|
"""Set the hvac_mode to SLEEP mode (valid only for over_climate with valve regulation):
|
|
service: versatile_thermostat.set_hvac_mode_sleep
|
|
target:
|
|
entity_id: climate.thermostat_1
|
|
"""
|
|
write_event_log(_LOGGER, self, "Calling SERVICE_SET_HVAC_MODE_SLEEP")
|
|
await self.async_set_hvac_mode(hvac_mode=VThermHvacMode_SLEEP)
|
|
|
|
@overrides
|
|
def choose_auto_fan_mode(self, auto_fan_mode: str):
|
|
"""Force no auto_fan for climate with valve regulation"""
|
|
self._current_auto_fan_mode = CONF_AUTO_FAN_NONE
|
|
self._auto_activated_fan_mode = self._auto_deactivated_fan_mode = None
|
|
|
|
@property
|
|
def vtherm_type(self) -> str | None:
|
|
"""Return the type of thermostat"""
|
|
return "over_climate_valve"
|
|
|
|
@overrides
|
|
async def async_set_hvac_mode(self, hvac_mode: VThermHvacMode, ignore_lock: bool = False):
|
|
"""Disable HVAC mode change during recalibration"""
|
|
if not self._recalibrate_lock.locked() or ignore_lock:
|
|
await super().async_set_hvac_mode(hvac_mode)
|
|
|
|
@overrides
|
|
async def underlying_changed( # pylint: disable=too-many-arguments
|
|
self,
|
|
under: UnderlyingClimate,
|
|
new_hvac_mode: VThermHvacMode | None,
|
|
new_hvac_action: HVACAction | None,
|
|
new_target_temp: float | None,
|
|
new_fan_mode: str | None,
|
|
new_state: State,
|
|
old_state: State,
|
|
):
|
|
"""Handle underlying climate changes only if not in recalibration"""
|
|
if not self._recalibrate_lock.locked():
|
|
return await super().underlying_changed(under, new_hvac_mode, new_hvac_action, new_target_temp, new_fan_mode, new_state, old_state)
|
|
_LOGGER.info("%s - ignore underlying climate change because recalibration is in progress", self)
|
|
|
|
@overrides
|
|
async def async_set_temperature(self, **kwargs):
|
|
"""Disable temperature change during recalibration"""
|
|
if not self._recalibrate_lock.locked():
|
|
return await super().async_set_temperature(**kwargs)
|
|
_LOGGER.info("%s - ignore temperature change because recalibration is in progress", self)
|
|
|
|
@overrides
|
|
async def async_set_preset_mode(self, preset_mode: str):
|
|
"""Disable preset mode change during recalibration"""
|
|
if not self._recalibrate_lock.locked():
|
|
return await super().async_set_preset_mode(preset_mode)
|
|
_LOGGER.info("%s - ignore preset mode change because recalibration is in progress", self)
|
|
|
|
async def service_recalibrate_valves(self, delay_seconds: int):
|
|
"""Start recalibration of valve opening/closing degrees for each underlying valve in background.
|
|
|
|
Steps:
|
|
1) memorize requested state
|
|
2) set VTherm hvac mode to OFF
|
|
3) for each valve: open to 100% (opening_degree=100, closing_degree=0), wait,
|
|
close to fully (opening_degree=0, closing_degree=100), wait
|
|
4) restore requested state
|
|
|
|
During this operation opening_threshold/min/max are ignored by sending
|
|
direct commands to the underlying number entities.
|
|
"""
|
|
if self.lock_manager.check_is_locked("service_recalibrate_valves"):
|
|
return {"message": "thermostat locked"}
|
|
|
|
write_event_log(_LOGGER, self, f"Calling SERVICE_RECALIBRATE_VALVES delay_seconds={delay_seconds}")
|
|
|
|
# Validate underlyings synchronously before launching background task
|
|
if not self._underlyings_valve_regulation:
|
|
raise ServiceValidationError(f"{self} - No valve regulation underlyings available")
|
|
|
|
# Build a short validation list and capture entity min/max per underlying
|
|
valves_config = []
|
|
for under in self._underlyings_valve_regulation:
|
|
opening = under.opening_degree_entity_id
|
|
closing = under.closing_degree_entity_id
|
|
if not opening:
|
|
raise ServiceValidationError(f"{self} - Underlying {under} must have opening degree entities configured")
|
|
|
|
opening_state = self._hass.states.get(opening)
|
|
# closing_state = self._hass.states.get(closing)
|
|
if opening_state is None:
|
|
raise ServiceValidationError(f"{self} - Opening entity {opening} not found for underlying {under}")
|
|
|
|
opening_min = opening_state.attributes.get("min", 0)
|
|
opening_max = opening_state.attributes.get("max", 100)
|
|
# closing_min = closing_state.attributes.get("min", 0)
|
|
# closing_max = closing_state.attributes.get("max", 100)
|
|
|
|
closing_min: int | None = None
|
|
closing_max: int | None = None
|
|
|
|
if closing:
|
|
closing_state = self._hass.states.get(closing)
|
|
|
|
if closing_state is None:
|
|
raise ServiceValidationError(f"{self} - Closing entity {closing} not found for underlying {under}")
|
|
|
|
closing_min = closing_state.attributes.get("min", 0)
|
|
closing_max = closing_state.attributes.get("max", 100)
|
|
|
|
valves_config.append(
|
|
{
|
|
"under": under,
|
|
"opening": opening,
|
|
"closing": closing,
|
|
"opening_min": opening_min,
|
|
"opening_max": opening_max,
|
|
"closing_min": closing_min if closing else None,
|
|
"closing_max": closing_max if closing else None,
|
|
}
|
|
)
|
|
|
|
# Memorize expected/requested state
|
|
expected_state = self.requested_state.to_dict() if self.requested_state is not None else None
|
|
|
|
# If a recalibration is already running, return immediately and do not schedule
|
|
if self._recalibrate_lock.locked():
|
|
_LOGGER.warning("Recalibration request refused: already running for %s", self.entity_id)
|
|
return {"message": "recalibrage en cours"}
|
|
|
|
# Define the background coroutine
|
|
async def _recalibrate_task():
|
|
async with self._recalibrate_lock:
|
|
try:
|
|
# Turn off vtherm
|
|
_LOGGER.info("%s - Recalibration - Stopping VTherm and turn on underlying climates and waiting for %s seconds", self, delay_seconds)
|
|
await self.async_set_hvac_mode(VThermHvacMode_OFF, ignore_lock=True)
|
|
await asyncio.sleep(5)
|
|
|
|
# Turn on the underlying climate
|
|
for under in self._underlyings:
|
|
await under.set_hvac_mode(HVACMode.HEAT)
|
|
|
|
await asyncio.sleep(delay_seconds)
|
|
|
|
_LOGGER.info("%s - Recalibration - Full opening of the valves and waiting for %s seconds", self, delay_seconds)
|
|
for cfg in valves_config:
|
|
under = cfg["under"]
|
|
opening = cfg["opening"]
|
|
closing = cfg["closing"]
|
|
|
|
open_val = cfg["opening_max"]
|
|
close_val = cfg["closing_min"]
|
|
|
|
_LOGGER.info("%s - Forcing opening=%s to %s and closing=%s to %s", self, opening, open_val, closing, close_val)
|
|
await under.send_value_to_number(opening, open_val)
|
|
if closing:
|
|
await under.send_value_to_number(closing, close_val)
|
|
|
|
await asyncio.sleep(delay_seconds)
|
|
|
|
_LOGGER.info("%s - Recalibration - Full closing of the valves and waiting for %s seconds", self, delay_seconds)
|
|
for cfg in valves_config:
|
|
under = cfg["under"]
|
|
opening = cfg["opening"]
|
|
closing = cfg["closing"]
|
|
|
|
open_val2 = cfg["opening_min"]
|
|
close_val2 = cfg["closing_max"]
|
|
|
|
_LOGGER.info("%s - Forcing opening=%s to %s and closing=%s to %s", self, opening, open_val2, closing, close_val2)
|
|
await under.send_value_to_number(opening, open_val2)
|
|
if closing:
|
|
await under.send_value_to_number(closing, close_val2)
|
|
await asyncio.sleep(delay_seconds)
|
|
|
|
except Exception as exc: # pylint: disable=broad-except
|
|
_LOGGER.error("%s - Error during recalibration: %s", self, exc)
|
|
|
|
# Restore requested state
|
|
_LOGGER.info("%s - Recalibration - Restoring requested state", self)
|
|
if expected_state:
|
|
try:
|
|
self.requested_state.set_state(
|
|
hvac_mode=expected_state.get("hvac_mode"),
|
|
target_temperature=expected_state.get("target_temperature"),
|
|
preset=expected_state.get("preset"),
|
|
)
|
|
self.requested_state.force_changed()
|
|
await self.update_states(force=True)
|
|
except Exception as ex: # pylint: disable=broad-except
|
|
_LOGGER.error("%s - Cannot restore requested state after recalibration: %s", self, ex)
|
|
|
|
# Launch background task and return immediately
|
|
try:
|
|
self.hass.async_create_task(_recalibrate_task())
|
|
except Exception: # pylint: disable=broad-except
|
|
# fallback
|
|
self._hass.create_task(_recalibrate_task())
|
|
|
|
return {"message": "calibrage en cours"}
|