Files
Home-Assistant/custom_components/versatile_thermostat/underlyings.py
T
2026-06-16 10:33:21 -04:00

1566 lines
60 KiB
Python

# pylint: disable=unused-argument, line-too-long, too-many-lines, broad-exception-caught
"""Underlying entities classes"""
import re
from typing import Any, Dict, List, Optional, Tuple, TypeVar
from collections.abc import Callable
from datetime import datetime, timedelta
from enum import StrEnum
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature
from homeassistant.core import State
from homeassistant.exceptions import ServiceNotFound
from homeassistant.core import HomeAssistant, CALLBACK_TYPE, Context, ServiceResponse
from homeassistant.components.climate import (
ClimateEntityFeature,
DOMAIN as CLIMATE_DOMAIN,
HVACAction,
HVACMode,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_FAN_MODE,
SERVICE_SET_HUMIDITY,
SERVICE_SET_SWING_MODE,
SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_SET_TEMPERATURE,
)
from homeassistant.components.number import SERVICE_SET_VALUE
from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt as dt_util
from vtherm_api.log_collector import get_vtherm_logger
from .opening_degree_algorithm import OpeningClosingDegreeCalculation
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .vtherm_hvac_mode import VThermHvacMode, from_ha_hvac_mode, to_legacy_ha_hvac_mode
from .keep_alive import IntervalCaller
from .vtherm_central_api import VersatileThermostatAPI
from .underlying_state_manager import UnderlyingStateManager
resend_delay_sec = 2
_LOGGER = get_vtherm_logger(__name__)
class UnderlyingEntityType(StrEnum):
"""All underlying device type"""
# A switch
SWITCH = "switch"
# a climate
CLIMATE = "climate"
# a valve
VALVE = "valve"
# a direct valve regulation
VALVE_REGULATION = "valve_regulation"
# ----------------------------------------------------------------
# UnderlyingEntity
# ----------------------------------------------------------------
class UnderlyingEntity:
"""Represent a underlying device which could be a switch or a climate"""
def __init__(
self,
hass: HomeAssistant,
thermostat: Any,
entity_type: UnderlyingEntityType,
entity_id: str,
) -> None:
"""Initialize the underlying entity"""
self._hass: HomeAssistant = hass
self._thermostat: Any = thermostat
self._type: UnderlyingEntityType = entity_type
self._entity_id: str = entity_id
self._power_reservation_key = entity_id
self._hvac_mode: VThermHvacMode | None = None
self._on_cycle_start_callbacks: list[Callable] = []
self._last_command_sent_datetime: datetime = dt_util.utc_from_timestamp(0)
# Use UnderlyingStateManager to track underlying entity state
self._state_manager: UnderlyingStateManager = UnderlyingStateManager(self._hass, on_change=self._underlying_changed)
self._is_initialized: bool = False
self._api = VersatileThermostatAPI.get_vtherm_api(hass)
def register_cycle_callback(self, on_start: Callable):
"""Register a callback for cycle start"""
self._on_cycle_start_callbacks.append(on_start)
def __str__(self):
return str(self._thermostat) + "-" + self._entity_id
@property
def entity_id(self):
"""The entity id represented by this class"""
return self._entity_id
@property
def power_reservation_key(self) -> str:
"""Return the stable power reservation key for this underlying."""
return self._power_reservation_key
@property
def entity_type(self) -> UnderlyingEntityType:
"""The entity type represented by this class"""
return self._type
@property
def is_initialized(self) -> bool:
"""True if the underlying is initialized and have received a non None state"""
return self._is_initialized
def startup(self):
"""Startup the Entity. Listen to the underlying state changes"""
# starts listening and can provide the initial cached state.
self._state_manager.add_underlying_entities([self._entity_id])
async def _underlying_changed(self, entity_id: str, new_state: Optional[State], old_state: Optional[State] = None):
"""Handle underlying state change notified by UnderlyingStateManager.
`new_state` may be None when the entity is removed/unavailable.
Runs the initial state checks when all underlying entities are initialized.
"""
_LOGGER.debug("%s - --------> Underlying state change received: '%s'", self, new_state)
# If not yet initialized and we received a valid initial state, run initial checks
if not self.is_initialized:
# Check if we have a valid state for all underlying entities for the first time
if self._state_manager.is_all_states_initialized:
self._is_initialized = True
_LOGGER.debug("%s - All underlying states are now initialized", self)
await self.check_initial_state()
# if all underlying of the vtherm are initialized, notify the parent thermostat
if self._thermostat.is_initialized:
await self._thermostat.init_underlyings_completed(self._entity_id)
return
else:
_LOGGER.debug("%s - Underlying state still not yet initialized", self)
# Otherwise, the manager holds the latest state
# Update the hvac_action of the parent which could have changed
if self._thermostat.hvac_action != self._thermostat.calculate_hvac_action():
self._thermostat.update_custom_attributes()
self._thermostat.async_write_ha_state()
# update the sensor which count the number of active devices is vtherm is used by central boiler
if (
self._thermostat.is_used_by_central_boiler
and self._api.central_boiler_manager is not None
and self._api.central_boiler_manager.nb_device_active_for_boiler_entity is not None
):
await self._api.central_boiler_manager.nb_device_active_for_boiler_entity.calculate_nb_active_devices(None)
async def set_hvac_mode(self, hvac_mode: VThermHvacMode):
"""Set the HVACmode"""
self._hvac_mode = hvac_mode
return
async def set_temperature(self, temperature, max_temp, min_temp):
"""Set the target temperature"""
return
# This should be the correct way to handle turn_off and turn_on but this breaks the unit test
# will an not understandable error: TypeError: object MagicMock can't be used in 'await' expression
async def turn_off(self):
"""Turn off the underlying equipement.
Need to be overriden"""
return NotImplementedError
async def turn_on(self):
"""Turn off the underlying equipement.
Need to be overriden"""
return NotImplementedError
def remove_entity(self):
"""Remove the underlying entity"""
self._on_cycle_start_callbacks.clear()
# Stop the state manager listener for this entity
self._state_manager.stop()
async def check_initial_state(self):
"""Prevent the underlying to be on but thermostat is off"""
return NotImplementedError
# override to be able to mock the call
def call_later(
self, hass: HomeAssistant, delay_sec: int, called_method
) -> CALLBACK_TYPE:
"""Call the method after a delay"""
return async_call_later(hass, delay_sec, called_method)
async def hass_services_async_call(
self,
domain: str,
service: str,
service_data: dict[str, Any] | None = None,
blocking: bool = False,
context: Context | None = None,
target: dict[str, Any] | None = None,
return_response: bool = False,
) -> ServiceResponse:
"""Wrapper for HASS service calls"""
try:
response: ServiceResponse = await self._hass.services.async_call(domain, service, service_data, blocking, context, target, return_response)
self._last_command_sent_datetime = self._thermostat.now
return response
except Exception as err:
_LOGGER.error("%s - Error calling service %s.%s: %s. The underlying will not change its state.", self, domain, service, err)
def clamp_sent_value(self, value) -> float:
"""capping of the value send to the underlying eqt"""
return value
async def check_overpowering(self) -> bool:
"""Check that a underlying can be turned on, else
activate the overpowering state of the VTherm associated.
Returns True if the check is ok (no overpowering needed)"""
ret, _ = await self._thermostat.power_manager.check_power_available(
self.power_reservation_key
)
if not ret:
_LOGGER.debug("%s - overpowering is detected", self)
await self._thermostat.power_manager.set_overpowering(True)
return False
return True
async def send_value_to_number(self, number_entity_id: str, value: int):
"""Send a value to a number entity"""
try:
data = {"value": value}
target = {ATTR_ENTITY_ID: number_entity_id}
domain = number_entity_id.split(".")[0]
await self.hass_services_async_call(
domain=domain,
service=SERVICE_SET_VALUE,
service_data=data,
target=target,
)
except ServiceNotFound as err:
_LOGGER.error(err)
# This could happens in unit test if input_number domain is not yet loaded
# raise err
@property
def hvac_mode(self) -> VThermHvacMode | None:
"""Return the current hvac_mode"""
return self._hvac_mode
@property
def should_device_be_active(self) -> bool | None:
"""If the underlying device should currently be active.
Need to be overriden"""
return NotImplementedError
@property
def is_device_active(self) -> bool | None:
"""If the underlying device is currently active.
Need to be overriden"""
return NotImplementedError
@property
def hvac_action(self) -> HVACAction:
"""Calculate a hvac_action"""
raise NotImplementedError
@property
def is_inversed(self):
"""Tells if the switch command should be inversed"""
return False
@property
def state_manager(self) -> UnderlyingStateManager:
"""Return the underlying state manager"""
return self._state_manager
# For testing compatibility
@property
def _last_known_underlying_state(self) -> Optional[State]:
"""Return the last known underlying state"""
return self._state_manager.get_state(self._entity_id)
@property
def last_change(self) -> datetime | None:
"""Return the last_changed datetime of the underlying device if known."""
state = self._state_manager.get_state(self._entity_id)
return state.last_changed if state else None
async def check_and_repair(self) -> bool:
"""Check if the underlying device state matches the desired state and repair if needed.
Returns True if a repair was performed."""
should_be_active = self.should_device_be_active
is_active = self.is_device_active
_LOGGER.debug("%s - Checking if underlying state needs repair. should_be_on=%s, is_on=%s", self, should_be_active, is_active)
if should_be_active is None or is_active is None:
return False
if should_be_active == is_active:
return False
if should_be_active:
await self.turn_on()
else:
await self.turn_off()
return True
# ----------------------------------------------------------------
# UnderlyingSwitch
# ----------------------------------------------------------------
class UnderlyingSwitch(UnderlyingEntity):
"""Represent a underlying switch"""
def __init__(
self, hass: HomeAssistant, thermostat: Any, switch_entity_id: str, keep_alive_sec: float, vswitch_on: str = None, vswitch_off: str = None
) -> None:
"""Initialize the underlying switch"""
super().__init__(
hass=hass,
thermostat=thermostat,
entity_type=UnderlyingEntityType.SWITCH,
entity_id=switch_entity_id,
)
self._on_time_sec = 0
self._off_time_sec = 0
self._new_on_time_sec = 0
self._new_off_time_sec = 0
self._is_removed = False
self._keep_alive = IntervalCaller(hass, keep_alive_sec)
self._vswitch_on = vswitch_on.strip() if vswitch_on else None
self._vswitch_off = vswitch_off.strip() if vswitch_off else None
self._domain = self._entity_id.split(".")[0]
# build command
command, data, state_on = self.build_command(use_on=True)
self._on_command = {"command": command, "data": data, "state": state_on}
command, data, state_off = self.build_command(use_on=False)
self._off_command = {"command": command, "data": data, "state": state_off}
# the underlying is cycling with on_percent > 0
self._should_be_on = False
# true if the on part of the cycle is running. Off if the off part is running
self._is_on_part_running = False
@overrides
@property
def is_inversed(self) -> bool:
"""Tells if the switch command should be inversed"""
return self._thermostat.is_inversed
@property
def keep_alive_sec(self) -> float:
"""Return the switch keep-alive interval in seconds."""
return self._keep_alive.interval_sec
@overrides
def startup(self):
super().startup()
self._keep_alive.set_async_action(self._keep_alive_callback)
# @overrides this breaks some unit tests TypeError: object MagicMock can't be used in 'await' expression
async def set_hvac_mode(self, hvac_mode: VThermHvacMode) -> bool:
"""Set the HVACmode. Returns true if something have change"""
if hvac_mode == VThermHvacMode_OFF:
if self.is_device_active:
await self.turn_off()
if self.hvac_mode != hvac_mode:
await super().set_hvac_mode(hvac_mode)
self._calculate_should_be_on()
return True
else:
return False
def _calculate_should_be_on(self, should_be_on: bool | None = None) -> bool:
"""Calculate and update the _should_be_on flag"""
self._should_be_on = self._hvac_mode in [VThermHvacMode_HEAT, VThermHvacMode_COOL] and self._on_time_sec > 0
return self._should_be_on
@property
def should_device_be_active(self) -> bool:
"""If the toggleable device is currently active."""
return self._calculate_should_be_on()
@property
def is_device_active(self) -> bool | None:
"""If the toggleable device is currently active."""
if not self.is_initialized:
return None
state = self._state_manager.get_state(self._entity_id)
if state is None:
return None
is_on = state.state == self._on_command.get("state")
return is_on
async def check_initial_state(self):
"""Prevent the underlying to be on but thermostat is off"""
hvac_mode = self._thermostat.vtherm_hvac_mode
if hvac_mode == VThermHvacMode_OFF and self.is_device_active:
_LOGGER.info(
"%s - The hvac mode is OFF, but the underlying device is ON. Turning off device %s",
self,
self._entity_id,
)
await self.turn_off()
# elif hvac_mode != VThermHvacMode_OFF and not is_device_active:
# it is normal, the cycle could be started later or in off phase
async def _keep_alive_callback(self):
"""Keep alive: Turn on if already turned on, turn off if already turned off."""
timer = self._keep_alive.backoff_timer
state: State | None = self._hass.states.get(self._entity_id)
# Normal, expected state.state values are "on" and "off". An absent
# underlying MQTT switch was observed to produce either state == None
# or state.state == STATE_UNAVAILABLE ("unavailable").
if state is None or state.state == STATE_UNAVAILABLE:
if timer.is_ready():
_LOGGER.warning(
"%s - Entity %s is not available (state: %s). Will keep trying " "keep alive calls, but won't log this condition every time.",
self,
self._entity_id,
state.state if state else "None",
)
else:
if timer.in_progress:
timer.reset()
_LOGGER.warning(
"%s - Entity %s has recovered (state: %s).",
self,
self._entity_id,
state.state,
)
await (self.turn_on() if self._is_on_part_running else self.turn_off())
def build_command(self, use_on: bool) -> Tuple[str, Dict[str, str]]:
"""Build a command and returns a command and a dict as data"""
value = None
data = {ATTR_ENTITY_ID: self._entity_id}
take_on = (use_on and not self.is_inversed) or (not use_on and self.is_inversed)
vswitch = self._vswitch_on if take_on else self._vswitch_off
if vswitch:
pattern = r"^(?P<command>[^\s/]+)(?:/(?P<argument>[^\s:]+)(?::(?P<value>[^\s]+))?)?$"
match = re.match(pattern, vswitch)
if match:
# Extraire les groupes nommés
command = match.group("command")
argument = match.group("argument")
value = match.group("value")
if argument is not None and value is not None:
data.update({argument: value})
else:
raise ValueError(f"Invalid input format: {vswitch}. Must be conform to 'command[/argument[:value]]'")
else:
command = SERVICE_TURN_ON if take_on else SERVICE_TURN_OFF
if value is None:
value = STATE_ON if take_on else STATE_OFF
return command, data, value
async def turn_off(self):
"""Turn heater toggleable device off."""
self._keep_alive.cancel() # Cancel early to avoid a turn_on/turn_off race condition
_LOGGER.debug("%s - Stopping underlying entity %s", self, self._entity_id)
command = self._off_command.get("command")
data = self._off_command.get("data")
# This may fails if called after shutdown
try:
try:
self._thermostat.power_manager.sub_power_consumption_to_central_power_manager(
self.power_reservation_key
)
_LOGGER.debug("%s - Sending command %s with data=%s", self, command, data)
await self._hass.services.async_call(self._domain, command, data)
self._is_on_part_running = False
self._keep_alive.set_async_action(self._keep_alive_callback)
except Exception:
self._keep_alive.cancel()
raise
except ServiceNotFound as err:
_LOGGER.error(err)
async def turn_on(self):
"""Turn heater toggleable device on."""
self._keep_alive.cancel() # Cancel early to avoid a turn_on/turn_off race condition
_LOGGER.debug("%s - Starting underlying entity %s", self, self._entity_id)
if not await self.check_overpowering():
self._thermostat.requested_state.force_changed()
await self._thermostat.update_states(force=True)
return False
command = self._on_command.get("command")
data = self._on_command.get("data")
try:
try:
self._thermostat.power_manager.add_power_consumption_to_central_power_manager(
self.power_reservation_key
)
_LOGGER.debug("%s - Sending command %s with data=%s", self, command, data)
await self._hass.services.async_call(self._domain, command, data)
self._is_on_part_running = True
self._keep_alive.set_async_action(self._keep_alive_callback)
return True
except Exception:
self._keep_alive.cancel()
raise
except ServiceNotFound as err:
_LOGGER.error(err)
@overrides
def remove_entity(self):
"""Remove the entity"""
self._is_removed = True
self._keep_alive.cancel()
super().remove_entity()
def hvac_action(self) -> HVACAction:
"""Calculate a hvac_action based on the current state and should_be_on"""
if not self.is_initialized:
return HVACAction.OFF
return HVACAction.HEATING if self.should_device_be_active is True else HVACAction.OFF
async def check_and_repair(self) -> bool:
"""Check if the underlying device state matches the desired state and repair if needed.
Returns True if a repair was performed."""
should_be_on = self._is_on_part_running
is_on = self.is_device_active
_LOGGER.debug("%s - Checking if underlying switch state needs repair. should_be_on=%s, is_on=%s", self, should_be_on, is_on)
if should_be_on is None or is_on is None:
return False
if should_be_on == is_on:
return False
if should_be_on:
await self.turn_on()
return True
else:
await self.turn_off()
return True
# ----------------------------------------------------------------
# UnderlyingClimate
# ----------------------------------------------------------------
class UnderlyingClimate(UnderlyingEntity):
"""Represent a underlying climate"""
def __init__(
self,
hass: HomeAssistant,
thermostat: Any,
climate_entity_id: str,
) -> None:
"""Initialize the underlying climate"""
super().__init__(
hass=hass,
thermostat=thermostat,
entity_type=UnderlyingEntityType.CLIMATE,
entity_id=climate_entity_id,
)
self._last_sent_temperature: Optional[float] = None
self._cancel_set_fan_mode_later: Optional[Callable[[], None]] = None
self._cancel_set_temperature_later: Optional[Callable[[], None]] = None
self._min_sync_entity: float = None
self._max_sync_entity: float = None
self._step_sync_entity: float = None
async def set_hvac_mode(self, hvac_mode: VThermHvacMode) -> bool:
"""Set the HVACmode of the underlying climate. Returns true if something have change"""
state = self._state_manager.get_state(self._entity_id)
if state is None:
return False
if state.state == to_ha_hvac_mode(hvac_mode) and self._hvac_mode == hvac_mode:
_LOGGER.debug(
"%s - hvac_mode is already is requested state %s. Do not send any command",
self,
hvac_mode,
)
return False
# When turning on a climate, check that power is available
if hvac_mode in (VThermHvacMode_HEAT, VThermHvacMode_COOL) and not await self.check_overpowering():
return False
await super().set_hvac_mode(hvac_mode)
data = {ATTR_ENTITY_ID: self._entity_id, "hvac_mode": to_legacy_ha_hvac_mode(hvac_mode)}
await self.hass_services_async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
data,
)
# if restart the climate, then resend the target temperature 2 sec later for lazy SonoffTRVZB
if hvac_mode in (VThermHvacMode_HEAT, VThermHvacMode_COOL):
async def callback_resend_temp(_):
await self.set_temperature(self._thermostat.target_temperature, None, None)
if self._cancel_set_temperature_later:
self._cancel_set_temperature_later()
self._cancel_set_temperature_later = async_call_later(self._hass, resend_delay_sec, callback_resend_temp)
return True
@overrides
def remove_entity(self):
"""Remove the entity"""
if self._cancel_set_fan_mode_later:
self._cancel_set_fan_mode_later()
self._cancel_set_fan_mode_later = None
if self._cancel_set_temperature_later:
self._cancel_set_temperature_later()
self._cancel_set_temperature_later = None
super().remove_entity()
@property
def should_device_be_active(self):
"""If the toggleable device is currently active."""
return self.hvac_mode != VThermHvacMode_OFF and self.hvac_action not in [
HVACAction.IDLE,
HVACAction.OFF,
None,
]
@property
def is_device_active(self):
"""If the toggleable device is currently active."""
state = self._state_manager.get_state(self._entity_id)
if state is None:
return None
# Issue 1890 - unavailable/unknown entities should not be counted as active
if state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
return None
# Issue 1779 - if no hvac_action is available use a fake hvac_action based on temperature
hvac_action = state.attributes.get("hvac_action", None)
if not hvac_action:
hvac_action = self.hvac_action
# The device is active if hvac_mode is not OFF/IDLE and hvac_action is not OFF/IDLE. hvac_action could be None because it is not always implemented by all climate entities
return state.state != HVACMode.OFF and (hvac_action not in [HVACAction.IDLE, HVACAction.OFF])
async def check_initial_state(self):
"""Prevent the underlying to be on but thermostat is off"""
underlying_state = self._state_manager.get_state(self._entity_id)
underlying_hvac_mode = from_ha_hvac_mode(underlying_state.state) if underlying_state else None
self._hvac_mode = underlying_hvac_mode
is_device_active = underlying_state.state not in [HVACMode.OFF, STATE_UNAVAILABLE, STATE_UNKNOWN]
hvac_mode = self._thermostat.vtherm_hvac_mode
if hvac_mode == VThermHvacMode_OFF and is_device_active:
_LOGGER.info(
"%s - The hvac mode is OFF, but the underlying device is ON. Turning off device %s",
self,
self._entity_id,
)
await self.set_hvac_mode(hvac_mode)
elif hvac_mode != VThermHvacMode_OFF and not is_device_active:
_LOGGER.info(
"%s - The hvac mode is %s, but the underlying device is not ON. Turning on device %s if needed",
self,
hvac_mode,
self._entity_id,
)
await self.set_hvac_mode(hvac_mode)
async def _underlying_changed(self, entity_id: str, new_state: Optional[State], old_state: Optional[State] = None):
"""Handle underlying state change notified by UnderlyingStateManager.
`new_state` may be None when the entity is removed/unavailable.
Runs the initial state checks when all underlying entities are initialized.
"""
await super()._underlying_changed(entity_id, new_state, old_state)
if not self.is_initialized or new_state is None:
return
# Check if one attributes has changed that could impact the thermostat
new_hvac_mode = VThermHvacMode(new_state.state)
old_hvac_mode = VThermHvacMode(old_state.state) if old_state else None
new_hvac_action = new_state.attributes.get("hvac_action") if new_state.attributes else None
old_hvac_action = old_state.attributes.get("hvac_action") if old_state and old_state.attributes else None
new_fan_mode = new_state.attributes.get("fan_mode") if new_state.attributes else None
new_target_temp = new_state.attributes.get("temperature") if new_state.attributes else None
# Ignore new target temperature when out of range
if (
not new_target_temp is None
and not self._thermostat.min_temp is None
and not self._thermostat.max_temp is None
and not (self._thermostat.min_temp <= new_target_temp <= self._thermostat.max_temp)
):
_LOGGER.debug(
"%s - underlying sent a target temperature (%s) which is out of configured min/max range (%s / %s). The value will be ignored",
self,
new_target_temp,
self._thermostat.min_temp,
self._thermostat.max_temp,
)
new_target_temp = None
under_temp_diff = 0
if new_target_temp is not None:
last_sent_temperature = self.last_sent_temperature or 0
under_temp_diff = new_target_temp - last_sent_temperature
# check the dtemp is > step
step = self._thermostat.target_temperature_step or 1
if -step < under_temp_diff < step:
under_temp_diff = 0
new_target_temp = None
# Forget event when the event holds no real changes
if new_hvac_mode == self._thermostat.hvac_mode:
new_hvac_mode = None
if new_hvac_action == old_hvac_action:
new_hvac_action = None
if new_fan_mode == self._thermostat.fan_mode:
new_fan_mode = None
if under_temp_diff == 0:
new_target_temp = None
if new_hvac_mode is None and new_hvac_action is None and new_target_temp is None and new_fan_mode is None:
_LOGGER.debug(
"%s - a underlying state change event is received but no real change have been found. Forget the event",
self,
)
return
await self._thermostat.underlying_changed(self, new_hvac_mode, new_hvac_action, new_target_temp, new_fan_mode, new_state, old_state)
async def set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
if not self.is_initialized:
return
data = {
ATTR_ENTITY_ID: self._entity_id,
"fan_mode": fan_mode,
}
if self._cancel_set_fan_mode_later:
self._cancel_set_fan_mode_later()
self._cancel_set_fan_mode_later = None
delay: float = resend_delay_sec
now = self._thermostat.now
last_command_sent = self._last_command_sent_datetime
if now > last_command_sent + timedelta(seconds=delay):
await self.hass_services_async_call(
CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
data,
)
return
# Add a delay if last command was sent less than delay seconds ago
# Some AC units (e.g. Daikin) do not handle multiple consecutive commands well
_LOGGER.debug(
"%s - #1458 - Delaying command set_fan_mode for underlying %s by %.2fs",
self,
self._entity_id,
delay,
)
async def callback_set_fan_mode(_):
await self.set_fan_mode(fan_mode)
self._cancel_set_fan_mode_later = async_call_later(self._hass, delay, callback_set_fan_mode)
async def set_humidity(self, humidity: int):
"""Set new target humidity."""
_LOGGER.info("%s - Set humidity: %s", self, humidity)
if not self.is_initialized:
return
data = {
ATTR_ENTITY_ID: self._entity_id,
"humidity": humidity,
}
await self.hass_services_async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HUMIDITY,
data,
)
async def set_swing_mode(self, swing_mode):
"""Set new target swing operation."""
_LOGGER.info("%s - Set swing mode: %s", self, swing_mode)
if not self.is_initialized:
return
data = {
ATTR_ENTITY_ID: self._entity_id,
"swing_mode": swing_mode,
}
await self.hass_services_async_call(
CLIMATE_DOMAIN,
SERVICE_SET_SWING_MODE,
data,
)
async def set_swing_horizontal_mode(self, swing_horizontal_mode):
"""Set new target swing horizontal operation."""
_LOGGER.info("%s - Set swing horizontal mode: %s", self, swing_horizontal_mode)
if not self.is_initialized:
return
data = {
ATTR_ENTITY_ID: self._entity_id,
"swing_horizontal_mode": swing_horizontal_mode,
}
await self.hass_services_async_call(
CLIMATE_DOMAIN,
SERVICE_SET_SWING_HORIZONTAL_MODE,
data,
)
async def set_temperature(self, temperature, max_temp, min_temp):
"""Set the target temperature"""
if not self.is_initialized:
return
# Issue 508 we have to take care of service set_temperature or set_range
target_temp = self.clamp_sent_value(temperature)
data = {
ATTR_ENTITY_ID: self._entity_id,
}
_LOGGER.info("%s - Set setpoint temperature to: %s", self, target_temp)
# Issue 807 add TARGET_TEMPERATURE only if in the features
if ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in self.supported_features:
data.update(
{
"target_temp_high": target_temp,
"target_temp_low": target_temp,
}
)
if ClimateEntityFeature.TARGET_TEMPERATURE in self.supported_features:
data["temperature"] = target_temp
try:
await self.hass_services_async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
data,
)
except Exception as ex:
_LOGGER.error("%s - Error while sending set_temperature: %s", self, ex)
raise ex
self._last_sent_temperature = target_temp
_LOGGER.debug("%s - Last_sent_temperature is now: %s", self, self._last_sent_temperature)
@property
def last_sent_temperature(self) -> float | None:
"""Get the last send temperature. None if no temperature have been sent yet"""
return self._last_sent_temperature
@property
def hvac_action(self) -> HVACAction | None:
"""Get the hvac action of the underlying"""
if not self.is_initialized:
return None
hvac_action = self.underlying_hvac_action
if hvac_action is None:
# simulate hvac action if not provided by underlying climate
underlying_target = self.underlying_target_temperature
underlying_current = self.underlying_current_temperature
if underlying_target is not None and underlying_current is not None:
# Both values come from the underlying device — use them as a consistent pair
target = underlying_target
current = underlying_current
else:
# Fall back entirely to VTherm-managed temperatures (room sensor)
# This prevents mixing device-internal and room sensor temperatures
target = self._thermostat.target_temperature
current = self._thermostat.current_temperature
hvac_mode = self.hvac_mode
_LOGGER.debug(
"%s - hvac_action simulation target=%s, current=%s, hvac_mode=%s",
self,
target,
current,
hvac_mode,
)
hvac_action = HVACAction.IDLE
if target is not None and current is not None:
dtemp = target - current
if hvac_mode == VThermHvacMode_COOL and dtemp < 0:
hvac_action = HVACAction.COOLING
elif hvac_mode in [VThermHvacMode_HEAT, VThermHvacMode_HEAT_COOL] and dtemp > 0:
hvac_action = HVACAction.HEATING
return hvac_action
@property
def hvac_mode(self) -> VThermHvacMode | None:
"""Get the hvac mode of the underlying"""
if not self.is_initialized:
return None
state = self._state_manager.get_state(self._entity_id)
return state.state if state is not None else None
@property
def fan_mode(self) -> str | None:
"""Get the fan_mode of the underlying"""
if not self.is_initialized or self.supported_features & ClimateEntityFeature.FAN_MODE == 0:
return None
return self.get_underlying_attribute("fan_mode")
@property
def swing_mode(self) -> str | None:
"""Get the swing_mode of the underlying"""
if not self.is_initialized or self.supported_features & ClimateEntityFeature.SWING_MODE == 0:
return None
return self.get_underlying_attribute("swing_mode")
@property
def swing_horizontal_mode(self) -> str | None:
"""Get the swing_horizontal_mode of the underlying"""
return self.get_underlying_attribute("swing_horizontal_mode")
@property
def supported_features(self) -> ClimateEntityFeature:
"""Get the supported features of the climate"""
return self.get_underlying_attribute("supported_features") or ClimateEntityFeature(0)
@property
def hvac_modes(self) -> list[VThermHvacMode]:
"""Get the hvac_modes"""
if not self.is_initialized:
return []
return self.get_underlying_attribute("hvac_modes")
@property
def current_humidity(self) -> float | None:
"""Get the humidity"""
return self.get_underlying_attribute("current_humidity")
@property
def fan_modes(self) -> list[str]:
"""Get the fan_modes"""
if not self.is_initialized or self.supported_features & ClimateEntityFeature.FAN_MODE == 0:
return []
return self.get_underlying_attribute("fan_modes")
@property
def swing_modes(self) -> list[str]:
"""Get the swing_modes"""
if not self.is_initialized or self.supported_features & ClimateEntityFeature.SWING_MODE == 0:
return []
return self.get_underlying_attribute("swing_modes")
@property
def swing_horizontal_modes(self) -> list[str]:
"""Get the swing_horizontal_modes"""
if not self.is_initialized or self.supported_features & ClimateEntityFeature.SWING_HORIZONTAL_MODE == 0:
return []
return self.get_underlying_attribute("swing_horizontal_modes")
@property
def temperature_unit(self) -> str:
"""Get the temperature_unit"""
return self.get_underlying_attribute("temperature_unit") or UnitOfTemperature.CELSIUS
@property
def min_temp(self) -> str:
"""Get the min_temp"""
return self.get_underlying_attribute("min_temp")
@property
def max_temp(self) -> str:
"""Get the max_temp"""
return self.get_underlying_attribute("max_temp")
@property
def target_temperature_step(self) -> float:
"""Get the target_temperature_step"""
return self.get_underlying_attribute("target_temperature_step")
@property
def target_temperature_high(self) -> float:
"""Get the target_temperature_high"""
return self.get_underlying_attribute("target_temperature_high")
@property
def target_temperature_low(self) -> float:
"""Get the target_temperature_low"""
return self.get_underlying_attribute("target_temperature_low")
@property
def underlying_target_temperature(self) -> float:
"""Get the target_temperature"""
value = self.get_underlying_attribute("target_temperature")
if value is not None:
return value
return self.get_underlying_attribute("temperature")
@property
def underlying_current_temperature(self) -> float | None:
"""Get the underlying current_temperature if it exists
and if initialized"""
return self.get_underlying_attribute("current_temperature")
@property
def underlying_hvac_action(self) -> HVACAction | None:
"""Get the underlying hvac_action if it exists
and if initialized"""
return self.get_underlying_attribute("hvac_action")
@property
def is_aux_heat(self) -> bool:
"""Get the is_aux_heat"""
return self.get_underlying_attribute("is_aux_heat")
def get_underlying_attribute(self, attribute_name: str) -> Any:
"""Get an attribute from the underlying climate"""
if not self.is_initialized:
return None
state = self._state_manager.get_state(self._entity_id)
if state is None:
return None
return state.attributes.get(attribute_name, None)
@overrides
def clamp_sent_value(self, value) -> float:
"""Try to adapt the target temp value to the min_temp / max_temp found
in the underlying entity (if any)"""
min_val = None
max_val = None
if not self.is_initialized:
raise RuntimeError(f"{self} - cannot cap sent value because underlying is not initialized")
# return value
# Gets the min_temp and max_temp.
# Values from state attributes are already in HA's configured unit (same as value),
# because HA's climate component normalizes them via show_temp() before storing.
# No unit conversion is needed here.
if self.min_temp is not None:
min_val = self.min_temp
max_val = self.max_temp
new_value = max(min_val, min(value, max_val))
else:
_LOGGER.debug("%s - no min and max attributes on underlying", self)
new_value = value
if new_value != value:
_LOGGER.info(
"%s - Target temp have been updated due min, max of the underlying entity. new_value=%.0f value=%.0f min=%.0f max=%.0f",
self,
new_value,
value,
min_val,
max_val,
)
return new_value
def set_min_max_step_sync_entity(
self,
min_sync_entity: float,
max_sync_entity: float,
step_sync_entity: float,
):
"""Set the min, max and step for the offset calibration synchronization"""
self._min_sync_entity = min_sync_entity
self._max_sync_entity = max_sync_entity
self._step_sync_entity = step_sync_entity
async def check_and_repair(self) -> bool:
"""Check if the underlying device state matches the desired state and repair if needed.
Returns True if a repair was performed."""
hvac_mode = self._thermostat.vtherm_hvac_mode
under_hvac_mode = self.hvac_mode
_LOGGER.debug("%s - Checking if underlying climate state needs repair. hvac_mode=%s, under_hvac_mode=%s", self, hvac_mode, under_hvac_mode)
if hvac_mode is None or under_hvac_mode is None:
return False
if str(hvac_mode) == str(under_hvac_mode):
return False
await self.set_hvac_mode(hvac_mode)
return True
@property
def min_sync_entity(self) -> float:
"""Get the min sync entity"""
return self._min_sync_entity
@property
def max_sync_entity(self) -> float:
"""Get the max sync entity"""
return self._max_sync_entity
@property
def step_sync_entity(self) -> float:
"""Get the step sync entity"""
return self._step_sync_entity
# ----------------------------------------------------------------
# UnderlyingValve
# ----------------------------------------------------------------
class UnderlyingValve(UnderlyingEntity):
"""Represent a underlying switch"""
def __init__(
self,
hass: HomeAssistant,
thermostat: Any,
valve_entity_id: str,
entity_type: UnderlyingEntityType = UnderlyingEntityType.VALVE,
) -> None:
"""Initialize the underlying valve"""
super().__init__(
hass=hass,
thermostat=thermostat,
entity_type=UnderlyingEntityType.VALVE,
entity_id=valve_entity_id,
)
self._hvac_mode = None
self._percent_open: int | None = None # self._thermostat.valve_open_percent
self._min_open: float | None = None
self._max_open: float | None = None
self._last_sent_temperature = None
self._last_sent_opening_value: int | None = None
def init_valve_state_min_max_open(self):
"""Initialize the min and max open percent"""
if not self.is_initialized:
raise RuntimeError(f"{self} - cannot init min/max open because underlying is not initialized")
valve_state = self._state_manager.get_state(self.entity_id)
valve_open: float = get_safe_float_value(valve_state.state)
if valve_open is None:
# should not happen
raise ValueError(f"{self} - cannot check_initial_state because underlying entity {self._entity_id} value {valve_state.state} is not a valid float")
self._last_sent_opening_value = valve_open
if "min" in valve_state.attributes and "max" in valve_state.attributes:
self._min_open = valve_state.attributes["min"]
self._max_open = valve_state.attributes["max"]
else:
self._min_open = 0
self._max_open = 100
@overrides
async def check_initial_state(self):
"""Handle initial valve state change to get the min and max open percent"""
# Initialize percent_open to current state
self.init_valve_state_min_max_open()
should_device_be_active = self.should_device_be_active
is_device_active = self.is_device_active
if should_device_be_active and not is_device_active:
_LOGGER.info(
"%s - The valve should be active (percent_open=%.0f), but the underlying valve is closed (current_valve_opening=%.0f). Opening valve %s",
self,
self._percent_open or 9999,
self._last_sent_opening_value or 9999,
self._entity_id,
)
await self.send_percent_open()
elif not should_device_be_active and is_device_active:
_LOGGER.info(
"%s - The valve should not be active (percent_open=%.0f), but the underlying valve is open (current_valve_opening=%.0f). Closing valve %s",
self,
self._percent_open or 9999,
self._last_sent_opening_value or 9999,
self._entity_id,
)
await self.send_percent_open(fixed_value=self._min_open)
async def send_percent_open(self, fixed_value: int = None):
"""Send the percent open to the underlying valve"""
# This may fails if called after shutdown
value = self._percent_open if fixed_value is None else fixed_value
await self.send_value_to_number(self._entity_id, value)
self._last_sent_opening_value = value
async def turn_off(self):
"""Turn heater toggleable device off."""
_LOGGER.debug("%s - Stopping underlying valve entity %s", self, self._entity_id)
# Issue 341
is_active = self.is_device_active
self._percent_open = self.clamp_sent_value(0)
if is_active:
await self.send_percent_open()
async def turn_on(self):
"""Nothing to do for Valve because it cannot be turned on"""
await self.set_valve_open_percent()
async def set_hvac_mode(self, hvac_mode: VThermHvacMode) -> bool:
"""Set the HVACmode. Returns true if something have change"""
if hvac_mode == VThermHvacMode_OFF and self.is_device_active:
await self.turn_off()
if hvac_mode != VThermHvacMode_OFF and not self.is_device_active:
await self.turn_on()
if self._hvac_mode != hvac_mode:
self._hvac_mode = hvac_mode
return True
else:
return False
@property
def should_device_be_active(self) -> bool:
"""If the toggleable device is currently active."""
try:
return self._percent_open > (self._min_open or 0) if isinstance(self._percent_open, (int, float)) else False
except Exception: # pylint: disable=broad-exception-caught
return False
@property
def is_device_active(self) -> bool | None:
"""If the toggleable device is currently active."""
if (current_opening := self.current_valve_opening) is None:
return None
return current_opening > (self._min_open or 0)
@overrides
def clamp_sent_value(self, value) -> float:
"""Try to adapt the open_percent value to the min / max found
in the underlying entity (if any)"""
if not self.is_initialized:
raise RuntimeError(f"{self} - cannot clamp sent value because underlying is not initialized")
# return value
# Gets the last number state
new_value = round(max(self._min_open, min(value / 100 * self._max_open, self._max_open)))
if new_value != value:
_LOGGER.info(
"%s - Valve open percent have been updated due min, max of the underlying entity. new_value=%.0f value=%.0f min=%.0f max=%.0f",
self,
new_value,
value,
self._min_open,
self._max_open,
)
return new_value
async def set_valve_open_percent(self):
"""Update the valve open percent"""
caped_val = self.clamp_sent_value(self._thermostat.valve_open_percent)
if self._percent_open == caped_val:
# No changes
return
self._percent_open = caped_val
# Send the new command to valve via a service call
_LOGGER.info(
"%s - Setting valve ouverture percent to %s", self, self._percent_open
)
# Send the change to the valve, in background
# self._hass.create_task(self.send_percent_open())
await self.send_percent_open()
def remove_entity(self):
"""Remove the entity"""
super().remove_entity()
@property
def percent_open(self) -> int:
"""The current percent open"""
return self._percent_open
@property
def last_sent_opening_value(self) -> int | None:
"""Return the last sent value to the valve"""
return self._last_sent_opening_value
@overrides
async def check_and_repair(self) -> bool:
"""Check if the valve opening matches the last sent value and repair if needed.
Returns True if a repair was performed."""
last_sent = self._last_sent_opening_value
current = self.current_valve_opening
if last_sent is None or current is None:
return False
_LOGGER.debug("%s - Checking if underlying valve state needs repair. last_sent=%s, current=%s", self, last_sent, current)
if abs(current - last_sent) <= 0.5:
return False
await self.send_percent_open()
return True
@property
def current_valve_opening(self) -> float | None:
"""Get the current valve opening from the underlying entity"""
if (valve_state := self._state_manager.get_state(self.entity_id)) is None:
return None
valve_open: float = get_safe_float_value(valve_state.state)
return valve_open
# ----------------------------------------------------------------
# UnderlyingValveRegulation
# ----------------------------------------------------------------
class UnderlyingValveRegulation(UnderlyingValve):
"""A specific underlying class for Valve regulation"""
def __init__(
self,
hass: HomeAssistant,
thermostat: Any,
opening_degree_entity_id: str,
closing_degree_entity_id: str,
climate_underlying: UnderlyingClimate,
min_opening_degree: int = 0,
max_opening_degree: int = 100,
max_closing_degree: int = 100,
opening_threshold: int = 0,
) -> None:
"""Initialize the underlying TRV with valve regulation"""
super().__init__(
hass,
thermostat,
opening_degree_entity_id,
entity_type=UnderlyingEntityType.VALVE_REGULATION,
)
self._opening_degree_entity_id: str = opening_degree_entity_id
self._closing_degree_entity_id: str = closing_degree_entity_id
self._has_max_closing_degree: bool = closing_degree_entity_id is not None
self._climate_underlying = climate_underlying
self._max_opening_degree: float = max_opening_degree
self._min_opening_degree: int = min_opening_degree
self._max_closing_degree: int = max_closing_degree
self._opening_threshold: int = opening_threshold
if self._min_opening_degree >= self._max_opening_degree:
self._min_opening_degree = self._opening_threshold
_LOGGER.error(
"min_opening_degree must be less than the max value of the opening degree of the entity {self._opening_degree_entity_id}. Value has been defaulted to opening threshold ({self._opening_threshold})"
)
@overrides
async def check_initial_state(self):
"""Handle initial valve state change and hvac_mode"""
_LOGGER.debug("%s - Starting initial state check for valve regulation underlying", self)
# Initialize valve state and min max opening
self.init_valve_state_min_max_open()
hvac_mode = self._thermostat.vtherm_hvac_mode
device_valve_opening = self.current_valve_opening # the real opening value
# cuurent_hvac_mode_is_active = self._state_manager.get_state(self._climate_underlying.entity_id).state not in [HVACMode.OFF, STATE_UNAVAILABLE, STATE_UNKNOWN]
should_be_on = hvac_mode != VThermHvacMode_OFF and not self._thermostat.is_sleeping
# is_on = cuurent_hvac_mode_is_active or (device_valve_opening is not None and device_valve_opening > self._opening_threshold)
is_on = device_valve_opening is not None and device_valve_opening > self._opening_threshold
if should_be_on and not is_on:
_LOGGER.debug(
"%s - The valve should be active (hvac_mode=%s, sleeping=%s, percent_open=%.0f), but the underlying device is off or below threshold (current_valve_opening=%.0f). Opening valve %s",
self,
hvac_mode,
self._thermostat.is_sleeping,
self._percent_open or 9999,
device_valve_opening or 9999,
self._entity_id,
)
# await self._climate_underlying.set_hvac_mode(hvac_mode)
if self._thermostat.is_sleeping:
self._percent_open = 100
_LOGGER.debug("%s - is sleeping, setting percent_open to 100", self)
await self.send_percent_open()
else:
calculated_percent = self._thermostat.valve_open_percent
if calculated_percent is not None and calculated_percent > 0:
# TPI says heating is needed — respect the calculated value
self._percent_open = calculated_percent
_LOGGER.debug("%s - TPI says %.0f%% heating needed — opening valve", self, calculated_percent)
await self.send_percent_open()
else:
# TPI says 0% (no heating needed) — do not open the valve here.
# The normal startup flow will send the correct 0% command separately.
_LOGGER.debug(
"%s - Should be on (hvac_mode=%s) but TPI says 0%% or not yet calculated — leaving valve closed to avoid race condition",
self,
hvac_mode,
)
elif not should_be_on and is_on:
_LOGGER.debug(
"%s - The hvac mode is OFF and not sleeping, but the underlying device is not at off. Setting to %d%% device %s",
self,
self._opening_threshold,
self._entity_id,
)
self._percent_open = self._opening_threshold
_LOGGER.debug("%s - off and not sleeping, setting percent_open to %d", self, self._percent_open)
await self.send_percent_open()
# await self._climate_underlying.set_hvac_mode(hvac_mode)
def startup(self):
"""Startup the Entity. Listen to the underlying state changes"""
# Register the valve listener
# super().startup()
# starts listening and can provide the initial cached state.
# TODO peut être que écouter self._opening_degree_entity_id ne sert à rien ici puisque c'est super() qui le fait
entities = [self._opening_degree_entity_id]
if self._has_max_closing_degree:
entities.append(self._closing_degree_entity_id)
self._state_manager.add_underlying_entities(entities)
async def send_percent_open(self, fixed_value: int = None):
"""Send the percent open to the underlying valve"""
# Caclulate percent_open
value = self._percent_open if fixed_value is None else fixed_value
opening_degree, closing_degree = OpeningClosingDegreeCalculation.calculate_opening_closing_degree(
brut_valve_open_percent=value,
min_opening_degree=self._min_opening_degree,
max_closing_degree=self._max_closing_degree,
max_opening_degree=self._max_opening_degree,
opening_threshold=self._opening_threshold,
)
# Send opening_degree in UnderlyingValve
await super().send_percent_open(opening_degree)
if self.has_closing_degree_entity:
await self.send_value_to_number(self._closing_degree_entity_id, closing_degree)
_LOGGER.debug(
"%s - valve regulation - I have sent opening_degree=%s closing_degree=%s",
self,
opening_degree,
closing_degree,
)
@property
def opening_degree_entity_id(self) -> str:
"""The offset_calibration_entity_id"""
return self._opening_degree_entity_id
@property
def closing_degree_entity_id(self) -> str:
"""The offset_calibration_entity_id"""
return self._closing_degree_entity_id
@property
def min_opening_degree(self) -> int:
"""The minimum opening degree"""
return self._min_opening_degree
@property
def has_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 hvac_modes(self) -> list[VThermHvacMode]:
"""Get the hvac_modes"""
if not self.is_initialized:
return []
return [VThermHvacMode_HEAT, VThermHvacMode_SLEEP, VThermHvacMode_OFF]
@property
def is_device_active(self):
"""If the opening valve is open. The real opening value is used here. So we need to compare with 100 - max_closing which the max closing value if the valve is closed."""
val = self.current_valve_opening
return val > (100 - self._max_closing_degree) if isinstance(val, (int, float)) else False
@property
def should_device_be_active(self):
"""If the opening valve is open. The real opening value is used here. So we need to compare with 100 - max_closing which the max closing value if the valve is closed."""
# return value > (100 - self._max_closing_degree) # TODO why this ?
return self.hvac_mode not in [VThermHvacMode_OFF] and self._percent_open > self._opening_threshold if isinstance(self._percent_open, (int, float)) else False
@property
def hvac_action(self) -> HVACAction:
"""Calculate a hvac_action"""
if (value := self.last_sent_opening_value) is None:
return HVACAction.OFF
# Align the behavior with is_device_active
if self.is_device_active:
return HVACAction.HEATING
elif value > 0:
return HVACAction.IDLE
else:
return HVACAction.OFF
@property
def valve_entity_ids(self) -> List[str]:
"""get an arrary with all entityd id of the valve"""
ret = []
for entity in [
self.opening_degree_entity_id,
self.closing_degree_entity_id,
]:
if entity:
ret.append(entity)
return ret
@overrides
async def turn_off(self):
"""Turn valve off. In that context it means set the valve to the minimum opening degree."""
_LOGGER.debug("%s - Stopping underlying entity %s", self, self._entity_id)
self._percent_open = 0
await self.send_percent_open()
await self._climate_underlying.set_hvac_mode(VThermHvacMode_OFF)
T = TypeVar("T", bound=UnderlyingEntity)