* ConfigFlow (translation to change) * +1 * With implemn in thermostat_switch (not finished) * GUI fixe * +1 * Select Working * Test input in config_flow ok * documentation * Add github copilot Add first test ok for UnderlyingSwitch * All tests ok * Fix warnings * Fix All tests ok * Translations * safety * Add quick-start documentation --------- Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
1268 lines
42 KiB
Python
1268 lines
42 KiB
Python
# pylint: disable=unused-argument, line-too-long, too-many-lines
|
|
|
|
""" Underlying entities classes """
|
|
import logging
|
|
import re
|
|
from typing import Any, Dict, Tuple
|
|
|
|
from enum import StrEnum
|
|
|
|
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_OFF, STATE_UNAVAILABLE
|
|
from homeassistant.core import State
|
|
|
|
from homeassistant.exceptions import ServiceNotFound
|
|
|
|
from homeassistant.core import HomeAssistant, CALLBACK_TYPE
|
|
from homeassistant.components.climate import (
|
|
ClimateEntity,
|
|
ClimateEntityFeature,
|
|
DOMAIN as CLIMATE_DOMAIN,
|
|
HVACMode,
|
|
HVACAction,
|
|
SERVICE_SET_HVAC_MODE,
|
|
SERVICE_SET_FAN_MODE,
|
|
SERVICE_SET_HUMIDITY,
|
|
SERVICE_SET_SWING_MODE,
|
|
SERVICE_TURN_OFF,
|
|
SERVICE_TURN_ON,
|
|
SERVICE_SET_TEMPERATURE,
|
|
)
|
|
|
|
from homeassistant.components.number import SERVICE_SET_VALUE
|
|
|
|
from homeassistant.helpers.entity_component import EntityComponent
|
|
from homeassistant.helpers.event import async_call_later
|
|
from homeassistant.util.unit_conversion import TemperatureConverter
|
|
|
|
from .const import UnknownEntity, overrides, get_safe_float
|
|
from .keep_alive import IntervalCaller
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# remove this
|
|
# _LOGGER.setLevel(logging.DEBUG)
|
|
|
|
|
|
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"
|
|
|
|
|
|
class UnderlyingEntity:
|
|
"""Represent a underlying device which could be a switch or a climate"""
|
|
|
|
_hass: HomeAssistant
|
|
# Cannot import VersatileThermostat due to circular reference
|
|
_thermostat: Any
|
|
_entity_id: str
|
|
_type: UnderlyingEntityType
|
|
_hvac_mode: HVACMode | None
|
|
|
|
def __init__(
|
|
self,
|
|
hass: HomeAssistant,
|
|
thermostat: Any,
|
|
entity_type: UnderlyingEntityType,
|
|
entity_id: str,
|
|
) -> None:
|
|
"""Initialize the underlying entity"""
|
|
self._hass = hass
|
|
self._thermostat = thermostat
|
|
self._type = entity_type
|
|
self._entity_id = entity_id
|
|
self._hvac_mode = None
|
|
|
|
def __str__(self):
|
|
return str(self._thermostat) + "-" + self._entity_id
|
|
|
|
@property
|
|
def entity_id(self):
|
|
"""The entiy id represented by this class"""
|
|
return self._entity_id
|
|
|
|
@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"""
|
|
return True
|
|
|
|
def startup(self):
|
|
"""Startup the Entity"""
|
|
return
|
|
|
|
async def set_hvac_mode(self, hvac_mode: HVACMode):
|
|
"""Set the HVACmode"""
|
|
self._hvac_mode = hvac_mode
|
|
return
|
|
|
|
@property
|
|
def hvac_mode(self) -> HVACMode | None:
|
|
"""Return the current hvac_mode"""
|
|
return self._hvac_mode
|
|
|
|
@property
|
|
def is_device_active(self) -> bool | None:
|
|
"""If the toggleable device is currently active."""
|
|
return None
|
|
|
|
@property
|
|
def hvac_action(self) -> HVACAction:
|
|
"""Calculate a hvac_action"""
|
|
return HVACAction.HEATING if self.is_device_active is True else HVACAction.OFF
|
|
|
|
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
|
|
|
|
@property
|
|
def is_inversed(self):
|
|
"""Tells if the switch command should be inversed"""
|
|
return False
|
|
|
|
def remove_entity(self):
|
|
"""Remove the underlying entity"""
|
|
return
|
|
|
|
async def check_initial_state(self, hvac_mode: HVACMode):
|
|
"""Prevent the underlying to be on but thermostat is off"""
|
|
if hvac_mode == HVACMode.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.set_hvac_mode(hvac_mode)
|
|
elif hvac_mode != HVACMode.OFF and not self.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)
|
|
|
|
# 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 start_cycle(
|
|
self,
|
|
hvac_mode: HVACMode,
|
|
on_time_sec: int,
|
|
off_time_sec: int,
|
|
on_percent: int,
|
|
force=False,
|
|
):
|
|
"""Starting cycle for switch"""
|
|
|
|
def _cancel_cycle(self):
|
|
"""Stops an eventual cycle"""
|
|
|
|
def cap_sent_value(self, value) -> float:
|
|
"""capping of the value send to the underlying eqt"""
|
|
return value
|
|
|
|
async def turn_off_and_cancel_cycle(self):
|
|
"""Turn off and cancel eventual running cycle"""
|
|
self._cancel_cycle()
|
|
await self.turn_off()
|
|
|
|
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)"""
|
|
if not await self._thermostat.power_manager.check_power_available():
|
|
_LOGGER.debug("%s - overpowering is detected", self)
|
|
await self._thermostat.power_manager.set_overpowering(True)
|
|
return False
|
|
return True
|
|
|
|
|
|
class UnderlyingSwitch(UnderlyingEntity):
|
|
"""Represent a underlying switch"""
|
|
|
|
def __init__(
|
|
self, hass: HomeAssistant, thermostat: Any, switch_entity_id: str, initial_delay_sec: int, 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._initial_delay_sec = initial_delay_sec
|
|
self._async_cancel_cycle = None
|
|
self._should_relaunch_control_heating = False
|
|
self._on_time_sec = 0
|
|
self._off_time_sec = 0
|
|
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}
|
|
|
|
@property
|
|
def initial_delay_sec(self):
|
|
"""The initial delay for this class"""
|
|
return self._initial_delay_sec
|
|
|
|
@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: HVACMode) -> bool:
|
|
"""Set the HVACmode. Returns true if something have change"""
|
|
|
|
if hvac_mode == HVACMode.OFF:
|
|
if self.is_device_active:
|
|
await self.turn_off()
|
|
self._cancel_cycle()
|
|
|
|
if self.hvac_mode != hvac_mode:
|
|
await super().set_hvac_mode(hvac_mode)
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
@property
|
|
def is_device_active(self):
|
|
"""If the toggleable device is currently active."""
|
|
# real_state = self._hass.states.is_state(self._entity_id, STATE_ON)
|
|
# return (self.is_inversed and not real_state) or (
|
|
# not self.is_inversed and real_state
|
|
# )
|
|
is_on = self._hass.states.is_state(self._entity_id, self._on_command.get("state"))
|
|
if self.is_inversed:
|
|
return not is_on
|
|
|
|
return is_on
|
|
|
|
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(
|
|
"Entity %s is not available (state: %s). Will keep trying "
|
|
"keep alive calls, but won't log this condition every time.",
|
|
self._entity_id,
|
|
state.state if state else "None",
|
|
)
|
|
else:
|
|
if timer.in_progress:
|
|
timer.reset()
|
|
_LOGGER.warning(
|
|
"Entity %s has recovered (state: %s).",
|
|
self._entity_id,
|
|
state.state,
|
|
)
|
|
await (self.turn_on() if self.is_device_active 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:
|
|
_LOGGER.debug("%s - Sending command %s with data=%s", self, command, data)
|
|
await self._hass.services.async_call(self._domain, command, data)
|
|
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():
|
|
return False
|
|
|
|
command = self._on_command.get("command")
|
|
data = self._on_command.get("data")
|
|
try:
|
|
try:
|
|
_LOGGER.debug("%s - Sending command %s with data=%s", self, command, data)
|
|
await self._hass.services.async_call(self._domain, command, data)
|
|
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
|
|
async def start_cycle(
|
|
self,
|
|
hvac_mode: HVACMode,
|
|
on_time_sec: int,
|
|
off_time_sec: int,
|
|
on_percent: int,
|
|
force=False,
|
|
):
|
|
"""Starting cycle for switch"""
|
|
_LOGGER.debug(
|
|
"%s - Starting new cycle hvac_mode=%s on_time_sec=%d off_time_sec=%d force=%s",
|
|
self,
|
|
hvac_mode,
|
|
on_time_sec,
|
|
off_time_sec,
|
|
force,
|
|
)
|
|
|
|
self._on_time_sec = on_time_sec
|
|
self._off_time_sec = off_time_sec
|
|
self._hvac_mode = hvac_mode
|
|
|
|
# Cancel eventual previous cycle if any
|
|
if self._async_cancel_cycle is not None:
|
|
if force:
|
|
_LOGGER.debug("%s - we force a new cycle", self)
|
|
self._cancel_cycle()
|
|
else:
|
|
_LOGGER.debug(
|
|
"%s - A previous cycle is alredy running and no force -> waits for its end",
|
|
self,
|
|
)
|
|
# self._should_relaunch_control_heating = True
|
|
_LOGGER.debug("%s - End of cycle (2)", self)
|
|
return
|
|
|
|
# If we should heat, starts the cycle with delay
|
|
if self._hvac_mode in [HVACMode.HEAT, HVACMode.COOL] and on_time_sec > 0:
|
|
# Starts the cycle after the initial delay
|
|
self._async_cancel_cycle = self.call_later(
|
|
self._hass, self._initial_delay_sec, self._turn_on_later
|
|
)
|
|
_LOGGER.debug("%s - _async_cancel_cycle=%s", self, self._async_cancel_cycle)
|
|
|
|
# if we not heat but device is active
|
|
elif self.is_device_active:
|
|
_LOGGER.info(
|
|
"%s - stop heating (2) for %d min %d sec",
|
|
self,
|
|
off_time_sec // 60,
|
|
off_time_sec % 60,
|
|
)
|
|
await self.turn_off()
|
|
else:
|
|
_LOGGER.debug("%s - nothing to do", self)
|
|
|
|
@overrides
|
|
def _cancel_cycle(self):
|
|
"""Cancel the cycle"""
|
|
if self._async_cancel_cycle:
|
|
self._async_cancel_cycle()
|
|
self._async_cancel_cycle = None
|
|
_LOGGER.debug("%s - Stopping cycle during calculation", self)
|
|
|
|
async def _turn_on_later(self, _):
|
|
"""Turn the heater on after a delay"""
|
|
_LOGGER.debug(
|
|
"%s - calling turn_on_later hvac_mode=%s, should_relaunch_later=%s off_time_sec=%d",
|
|
self,
|
|
self._hvac_mode,
|
|
self._should_relaunch_control_heating,
|
|
self._on_time_sec,
|
|
)
|
|
|
|
self._cancel_cycle()
|
|
|
|
if self._hvac_mode == HVACMode.OFF:
|
|
_LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF - 2)", self)
|
|
if self.is_device_active:
|
|
await self.turn_off()
|
|
return
|
|
|
|
# safety mode could have change the on_time percent
|
|
await self._thermostat.safety_manager.refresh_state()
|
|
time = self._on_time_sec
|
|
|
|
action_label = "start"
|
|
|
|
if time > 0:
|
|
_LOGGER.info(
|
|
"%s - %s heating for %d min %d sec",
|
|
self,
|
|
action_label,
|
|
time // 60,
|
|
time % 60,
|
|
)
|
|
if not await self.turn_on():
|
|
return
|
|
else:
|
|
_LOGGER.debug("%s - No action on heater cause duration is 0", self)
|
|
self._async_cancel_cycle = self.call_later(
|
|
self._hass,
|
|
time,
|
|
self._turn_off_later,
|
|
)
|
|
|
|
async def _turn_off_later(self, _):
|
|
"""Turn the heater off and call the next cycle after the delay"""
|
|
_LOGGER.debug(
|
|
"%s - calling turn_off_later hvac_mode=%s, should_relaunch_later=%s off_time_sec=%d",
|
|
self,
|
|
self._hvac_mode,
|
|
self._should_relaunch_control_heating,
|
|
self._off_time_sec,
|
|
)
|
|
self._cancel_cycle()
|
|
|
|
if self._hvac_mode == HVACMode.OFF:
|
|
_LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF - 2)", self)
|
|
if self.is_device_active:
|
|
await self.turn_off()
|
|
return
|
|
|
|
action_label = "stop"
|
|
time = self._off_time_sec
|
|
|
|
if time > 0:
|
|
_LOGGER.info(
|
|
"%s - %s heating for %d min %d sec",
|
|
self,
|
|
action_label,
|
|
time // 60,
|
|
time % 60,
|
|
)
|
|
await self.turn_off()
|
|
else:
|
|
_LOGGER.debug("%s - No action on heater cause duration is 0", self)
|
|
self._async_cancel_cycle = self.call_later(
|
|
self._hass,
|
|
time,
|
|
self._turn_on_later,
|
|
)
|
|
|
|
# increment energy at the end of the cycle
|
|
self._thermostat.incremente_energy()
|
|
|
|
@overrides
|
|
def remove_entity(self):
|
|
"""Remove the entity after stopping its cycle"""
|
|
self._cancel_cycle()
|
|
self._keep_alive.cancel()
|
|
|
|
|
|
class UnderlyingClimate(UnderlyingEntity):
|
|
"""Represent a underlying climate"""
|
|
|
|
_underlying_climate: ClimateEntity
|
|
|
|
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._underlying_climate = None
|
|
self._last_sent_temperature = None
|
|
|
|
def find_underlying_climate(self) -> ClimateEntity:
|
|
"""Find the underlying climate entity"""
|
|
component: EntityComponent[ClimateEntity] = self._hass.data[CLIMATE_DOMAIN]
|
|
for entity in component.entities:
|
|
if self.entity_id == entity.entity_id:
|
|
return entity
|
|
return None
|
|
|
|
def startup(self):
|
|
"""Startup the Entity"""
|
|
# Get the underlying climate
|
|
self._underlying_climate = self.find_underlying_climate()
|
|
if self._underlying_climate:
|
|
_LOGGER.info(
|
|
"%s - The underlying climate entity: %s have been succesfully found",
|
|
self,
|
|
self._underlying_climate,
|
|
)
|
|
else:
|
|
_LOGGER.info(
|
|
"%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational. Will try later.",
|
|
self,
|
|
self.entity_id,
|
|
)
|
|
# #56 keep the over_climate and try periodically to find the underlying climate
|
|
# self._is_over_climate = False
|
|
raise UnknownEntity(f"Underlying entity {self.entity_id} not found")
|
|
return
|
|
|
|
@property
|
|
def is_initialized(self) -> bool:
|
|
"""True if the underlying climate was found"""
|
|
return self._underlying_climate is not None
|
|
|
|
async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool:
|
|
"""Set the HVACmode of the underlying climate. Returns true if something have change"""
|
|
if not self.is_initialized:
|
|
return False
|
|
|
|
if self._underlying_climate.hvac_mode == hvac_mode:
|
|
_LOGGER.debug(
|
|
"%s - hvac_mode is already is requested state %s. Do not send any command",
|
|
self,
|
|
self._underlying_climate.hvac_mode,
|
|
)
|
|
return False
|
|
|
|
# When turning on a climate, check that power is available
|
|
if hvac_mode in (HVACMode.HEAT, HVACMode.COOL) and not await self.check_overpowering():
|
|
return False
|
|
|
|
data = {ATTR_ENTITY_ID: self._entity_id, "hvac_mode": hvac_mode}
|
|
await self._hass.services.async_call(
|
|
CLIMATE_DOMAIN,
|
|
SERVICE_SET_HVAC_MODE,
|
|
data,
|
|
)
|
|
|
|
return True
|
|
|
|
@property
|
|
def is_device_active(self):
|
|
"""If the toggleable device is currently active."""
|
|
if self.is_initialized:
|
|
return self.hvac_mode != HVACMode.OFF and self.hvac_action not in [
|
|
HVACAction.IDLE,
|
|
HVACAction.OFF,
|
|
None,
|
|
]
|
|
else:
|
|
return None
|
|
|
|
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,
|
|
}
|
|
|
|
await self._hass.services.async_call(
|
|
CLIMATE_DOMAIN,
|
|
SERVICE_SET_FAN_MODE,
|
|
data,
|
|
)
|
|
|
|
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_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.cap_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._underlying_climate.supported_features:
|
|
data.update(
|
|
{
|
|
"target_temp_high": target_temp,
|
|
"target_temp_low": target_temp,
|
|
}
|
|
)
|
|
|
|
if ClimateEntityFeature.TARGET_TEMPERATURE in self._underlying_climate.supported_features:
|
|
data["temperature"] = target_temp
|
|
|
|
await self._hass.services.async_call(
|
|
CLIMATE_DOMAIN,
|
|
SERVICE_SET_TEMPERATURE,
|
|
data,
|
|
)
|
|
|
|
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_climate.hvac_action
|
|
if hvac_action is None:
|
|
target = (
|
|
self.underlying_target_temperature
|
|
or self._thermostat.target_temperature
|
|
)
|
|
current = (
|
|
self.underlying_current_temperature
|
|
or 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 == HVACMode.COOL and dtemp < 0:
|
|
hvac_action = HVACAction.COOLING
|
|
elif hvac_mode in [HVACMode.HEAT, HVACMode.HEAT_COOL] and dtemp > 0:
|
|
hvac_action = HVACAction.HEATING
|
|
|
|
return hvac_action
|
|
|
|
@property
|
|
def hvac_mode(self) -> HVACMode | None:
|
|
"""Get the hvac mode of the underlying"""
|
|
if not self.is_initialized:
|
|
return None
|
|
return self._underlying_climate.hvac_mode
|
|
|
|
@property
|
|
def fan_mode(self) -> str | None:
|
|
"""Get the fan_mode of the underlying"""
|
|
if not self.is_initialized:
|
|
return None
|
|
return self._underlying_climate.fan_mode
|
|
|
|
@property
|
|
def swing_mode(self) -> str | None:
|
|
"""Get the swing_mode of the underlying"""
|
|
if not self.is_initialized:
|
|
return None
|
|
return self._underlying_climate.swing_mode
|
|
|
|
@property
|
|
def supported_features(self) -> ClimateEntityFeature:
|
|
"""Get the supported features of the climate"""
|
|
if not self.is_initialized:
|
|
return ClimateEntityFeature.TARGET_TEMPERATURE
|
|
return self._underlying_climate.supported_features
|
|
|
|
@property
|
|
def hvac_modes(self) -> list[HVACMode]:
|
|
"""Get the hvac_modes"""
|
|
if not self.is_initialized:
|
|
return []
|
|
return self._underlying_climate.hvac_modes
|
|
|
|
@property
|
|
def current_humidity(self) -> float | None:
|
|
"""Get the humidity"""
|
|
if not self.is_initialized:
|
|
return None
|
|
return self._underlying_climate.current_humidity
|
|
|
|
@property
|
|
def fan_modes(self) -> list[str]:
|
|
"""Get the fan_modes"""
|
|
if not self.is_initialized:
|
|
return []
|
|
return self._underlying_climate.fan_modes
|
|
|
|
@property
|
|
def swing_modes(self) -> list[str]:
|
|
"""Get the swing_modes"""
|
|
if not self.is_initialized:
|
|
return []
|
|
return self._underlying_climate.swing_modes
|
|
|
|
@property
|
|
def temperature_unit(self) -> str:
|
|
"""Get the temperature_unit"""
|
|
if not self.is_initialized:
|
|
return self._hass.config.units.temperature_unit
|
|
return self._underlying_climate.temperature_unit
|
|
|
|
@property
|
|
def target_temperature_step(self) -> float:
|
|
"""Get the target_temperature_step"""
|
|
if not self.is_initialized:
|
|
return 1
|
|
return self._underlying_climate.target_temperature_step
|
|
|
|
@property
|
|
def target_temperature_high(self) -> float:
|
|
"""Get the target_temperature_high"""
|
|
if not self.is_initialized:
|
|
return 30
|
|
return self._underlying_climate.target_temperature_high
|
|
|
|
@property
|
|
def target_temperature_low(self) -> float:
|
|
"""Get the target_temperature_low"""
|
|
if not self.is_initialized:
|
|
return 15
|
|
return self._underlying_climate.target_temperature_low
|
|
|
|
@property
|
|
def underlying_target_temperature(self) -> float:
|
|
"""Get the target_temperature"""
|
|
if not self.is_initialized:
|
|
return None
|
|
|
|
if not hasattr(self._underlying_climate, "target_temperature"):
|
|
return None
|
|
else:
|
|
return self._underlying_climate.target_temperature
|
|
|
|
# return self._hass.states.get(self._entity_id).attributes.get(
|
|
# "target_temperature"
|
|
# )
|
|
|
|
@property
|
|
def underlying_current_temperature(self) -> float | None:
|
|
"""Get the underlying current_temperature if it exists
|
|
and if initialized"""
|
|
if not self.is_initialized:
|
|
return None
|
|
|
|
if not hasattr(self._underlying_climate, "current_temperature"):
|
|
return None
|
|
else:
|
|
return self._underlying_climate.current_temperature
|
|
|
|
# return self._hass.states.get(self._entity_id).attributes.get("current_temperature")
|
|
|
|
@property
|
|
def is_aux_heat(self) -> bool:
|
|
"""Get the is_aux_heat"""
|
|
if not self.is_initialized:
|
|
return False
|
|
return self._underlying_climate.is_aux_heat
|
|
|
|
def turn_aux_heat_on(self) -> None:
|
|
"""Turn auxiliary heater on."""
|
|
if not self.is_initialized:
|
|
return None
|
|
return self._underlying_climate.turn_aux_heat_on()
|
|
|
|
def turn_aux_heat_off(self) -> None:
|
|
"""Turn auxiliary heater on."""
|
|
if not self.is_initialized:
|
|
return None
|
|
return self._underlying_climate.turn_aux_heat_off()
|
|
|
|
@overrides
|
|
def cap_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)"""
|
|
|
|
if not self.is_initialized:
|
|
return value
|
|
|
|
# Gets the min_temp and max_temp
|
|
if (
|
|
self._underlying_climate.min_temp is not None
|
|
and self._underlying_climate is not None
|
|
):
|
|
min_val = TemperatureConverter.convert(
|
|
self._underlying_climate.min_temp, self._underlying_climate.temperature_unit, self._hass.config.units.temperature_unit
|
|
)
|
|
max_val = TemperatureConverter.convert(
|
|
self._underlying_climate.max_temp, self._underlying_climate.temperature_unit, self._hass.config.units.temperature_unit
|
|
)
|
|
|
|
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
|
|
|
|
|
|
class UnderlyingValve(UnderlyingEntity):
|
|
"""Represent a underlying switch"""
|
|
|
|
_hvac_mode: HVACMode
|
|
# This is the percentage of opening int integer (from 0 to 100)
|
|
_percent_open: int
|
|
_last_sent_temperature = None
|
|
|
|
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._async_cancel_cycle = None
|
|
self._should_relaunch_control_heating = False
|
|
self._hvac_mode = None
|
|
self._percent_open = None # self._thermostat.valve_open_percent
|
|
self._valve_entity_id = valve_entity_id
|
|
|
|
async def _send_value_to_number(self, number_entity_id: str, value: int):
|
|
"""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
|
|
|
|
async def send_percent_open(self):
|
|
"""Send the percent open to the underlying valve"""
|
|
# This may fails if called after shutdown
|
|
return await self._send_value_to_number(self._entity_id, self._percent_open)
|
|
|
|
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.cap_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: HVACMode) -> bool:
|
|
"""Set the HVACmode. Returns true if something have change"""
|
|
|
|
if hvac_mode == HVACMode.OFF and self.is_device_active:
|
|
await self.turn_off()
|
|
|
|
if hvac_mode != HVACMode.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 is_device_active(self):
|
|
"""If the toggleable device is currently active."""
|
|
try:
|
|
return self._percent_open > 0
|
|
# To test if real device is open but this is causing some side effect
|
|
# because the activation can be deferred -
|
|
# or float(self._hass.states.get(self._entity_id).state) > 0
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
return False
|
|
|
|
@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()
|
|
|
|
@overrides
|
|
def cap_sent_value(self, value) -> float:
|
|
"""Try to adapt the open_percent value to the min / max found
|
|
in the underlying entity (if any)"""
|
|
|
|
# Gets the last number state
|
|
valve_state: State = self._hass.states.get(self._valve_entity_id)
|
|
if valve_state is None:
|
|
return value
|
|
|
|
if "min" in valve_state.attributes and "max" in valve_state.attributes:
|
|
min_val = valve_state.attributes["min"]
|
|
max_val = valve_state.attributes["max"]
|
|
|
|
new_value = round(max(min_val, min(value / 100 * max_val, max_val)))
|
|
else:
|
|
_LOGGER.debug("%s - no min and max attributes on underlying", self)
|
|
new_value = value
|
|
|
|
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,
|
|
min_val,
|
|
max_val,
|
|
)
|
|
|
|
return new_value
|
|
|
|
async def set_valve_open_percent(self):
|
|
"""Update the valve open percent"""
|
|
caped_val = self.cap_sent_value(self._thermostat.valve_open_percent)
|
|
if self._percent_open == caped_val:
|
|
# 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 after stopping its cycle"""
|
|
self._cancel_cycle()
|
|
|
|
|
|
class UnderlyingValveRegulation(UnderlyingValve):
|
|
"""A specific underlying class for Valve regulation"""
|
|
|
|
def __init__(
|
|
self,
|
|
hass: HomeAssistant,
|
|
thermostat: Any,
|
|
offset_calibration_entity_id: str,
|
|
opening_degree_entity_id: str,
|
|
closing_degree_entity_id: str,
|
|
climate_underlying: UnderlyingClimate,
|
|
min_opening_degree: int = 0,
|
|
) -> None:
|
|
"""Initialize the underlying TRV with valve regulation"""
|
|
super().__init__(
|
|
hass,
|
|
thermostat,
|
|
opening_degree_entity_id,
|
|
entity_type=UnderlyingEntityType.VALVE_REGULATION,
|
|
)
|
|
self._offset_calibration_entity_id: str = offset_calibration_entity_id
|
|
self._opening_degree_entity_id: str = opening_degree_entity_id
|
|
self._closing_degree_entity_id: str = closing_degree_entity_id
|
|
self._climate_underlying = climate_underlying
|
|
self._is_min_max_initialized: bool = False
|
|
self._max_opening_degree: float = None
|
|
self._min_offset_calibration: float = None
|
|
self._max_offset_calibration: float = None
|
|
self._min_opening_degree: int = min_opening_degree
|
|
|
|
async def send_percent_open(self):
|
|
"""Send the percent open to the underlying valve"""
|
|
if not self._is_min_max_initialized:
|
|
_LOGGER.debug(
|
|
"%s - initialize min offset_calibration and max open_degree", self
|
|
)
|
|
self._max_opening_degree = self._hass.states.get(
|
|
self._opening_degree_entity_id
|
|
).attributes.get("max")
|
|
|
|
if self.have_offset_calibration_entity:
|
|
self._min_offset_calibration = self._hass.states.get(
|
|
self._offset_calibration_entity_id
|
|
).attributes.get("min")
|
|
self._max_offset_calibration = self._hass.states.get(
|
|
self._offset_calibration_entity_id
|
|
).attributes.get("max")
|
|
|
|
self._is_min_max_initialized = self._max_opening_degree is not None and (
|
|
not self.have_offset_calibration_entity
|
|
or (
|
|
self._min_offset_calibration is not None
|
|
and self._max_offset_calibration is not None
|
|
)
|
|
)
|
|
|
|
if not self._is_min_max_initialized:
|
|
_LOGGER.warning(
|
|
"%s - impossible to initialize max_opening_degree or min_offset_calibration. Abort sending percent open to the valve. This could be a temporary message at startup."
|
|
)
|
|
return
|
|
|
|
# Caclulate percent_open
|
|
if self._percent_open >= 1:
|
|
self._percent_open = round(
|
|
self._min_opening_degree
|
|
+ (self._percent_open
|
|
* (100 - self._min_opening_degree) / 100)
|
|
)
|
|
else:
|
|
self._percent_open = 0
|
|
|
|
# Send opening_degree
|
|
await super().send_percent_open()
|
|
|
|
# Send closing_degree if set
|
|
closing_degree = None
|
|
if self.have_closing_degree_entity:
|
|
await self._send_value_to_number(
|
|
self._closing_degree_entity_id,
|
|
closing_degree := self._max_opening_degree - self._percent_open,
|
|
)
|
|
|
|
# send offset_calibration to the difference between target temp and local temp
|
|
offset = None
|
|
if self.have_offset_calibration_entity:
|
|
if (
|
|
(local_temp := self._climate_underlying.underlying_current_temperature)
|
|
is not None
|
|
and (room_temp := self._thermostat.current_temperature) is not None
|
|
and (
|
|
current_offset := get_safe_float(
|
|
self._hass, self._offset_calibration_entity_id
|
|
)
|
|
)
|
|
is not None
|
|
):
|
|
offset = min(
|
|
self._max_offset_calibration,
|
|
max(
|
|
self._min_offset_calibration,
|
|
room_temp - (local_temp - current_offset),
|
|
),
|
|
)
|
|
|
|
await self._send_value_to_number(
|
|
self._offset_calibration_entity_id, offset
|
|
)
|
|
|
|
_LOGGER.debug(
|
|
"%s - valve regulation - I have sent offset_calibration=%s opening_degree=%s closing_degree=%s",
|
|
self,
|
|
offset,
|
|
self._percent_open,
|
|
closing_degree,
|
|
)
|
|
|
|
@property
|
|
def offset_calibration_entity_id(self) -> str:
|
|
"""The offset_calibration_entity_id"""
|
|
return self._offset_calibration_entity_id
|
|
|
|
@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 have_closing_degree_entity(self) -> bool:
|
|
"""Return True if the underlying have a closing_degree entity"""
|
|
return self._closing_degree_entity_id is not None
|
|
|
|
@property
|
|
def have_offset_calibration_entity(self) -> bool:
|
|
"""Return True if the underlying have a offset_calibration entity"""
|
|
return self._offset_calibration_entity_id is not None
|
|
|
|
@property
|
|
def hvac_modes(self) -> list[HVACMode]:
|
|
"""Get the hvac_modes"""
|
|
if not self.is_initialized:
|
|
return []
|
|
return [HVACMode.OFF, HVACMode.HEAT]
|
|
|
|
@overrides
|
|
async def start_cycle(
|
|
self,
|
|
hvac_mode: HVACMode,
|
|
_1,
|
|
_2,
|
|
_3,
|
|
force=False,
|
|
):
|
|
"""We use this function to change the on_percent"""
|
|
# if force:
|
|
await self.set_valve_open_percent()
|
|
|
|
@property
|
|
def is_device_active(self):
|
|
"""If the opening valve is open."""
|
|
try:
|
|
return get_safe_float(self._hass, self._opening_degree_entity_id) > 0
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
return False
|
|
|
|
@property
|
|
def valve_entity_ids(self) -> [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,
|
|
self.offset_calibration_entity_id,
|
|
]:
|
|
if entity:
|
|
ret.append(entity)
|
|
return ret
|