Compare commits

..

5 Commits

Author SHA1 Message Date
Jean-Marc Collin
c512cb6f74 [#428] - Refacto start versatile_thermostat (#430)
Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
2024-03-26 21:06:25 +01:00
Jean-Marc Collin
9269240fe3 Release 2024-03-25 07:43:09 +00:00
Jean-Marc Collin
91ba2387b2 Persistence of boiler srv attribute 2024-03-25 07:25:42 +00:00
Jean-Marc Collin
162efb4709 [#425] - Boiler management entities are generated independently of this option selection (#426)
Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
2024-03-25 08:00:08 +01:00
Paulo Ferreira de Castro
6a97622226 Expose the keep_alive_sec attribute in HASS Developer Tools - States (#381)
* Typing: Make BaseThermostat generic on the UnderlyingEntity type

* Typing: Change the type of IntervalCaller._interval_sec from int to float

This makes the IntervalCaller class more reusable.

* Keep-alive: Expose UnderlyingSwitch.keep_alive_sec as a HASS Dev Tools attribute

Also improve a keep-alive log message.
2024-03-23 11:49:09 +01:00
17 changed files with 327 additions and 229 deletions

View File

@@ -3,9 +3,9 @@ default_config:
logger:
default: warning
logs:
# custom_components.versatile_thermostat: debug
# custom_components.versatile_thermostat.underlyings: debug
# custom_components.versatile_thermostat.climate: debug
custom_components.versatile_thermostat: info
custom_components.versatile_thermostat.underlyings: info
custom_components.versatile_thermostat.climate: info
# If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/)
debugpy:

View File

@@ -107,6 +107,8 @@ async def async_setup(
"VersatileThermostat - HA is started, initialize all links between VTherm entities"
)
await api.init_vtherm_links()
await api.notify_central_mode_change()
await api.reload_central_boiler_entities_list()
if hass.state == CoreState.running:
await _async_startup_internal()
@@ -156,8 +158,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await api.reload_central_boiler_entities_list()
await api.init_vtherm_links()
if hass.state == CoreState.running:
await api.reload_central_boiler_entities_list()
await api.init_vtherm_links()
return True

View File

@@ -7,7 +7,7 @@ import logging
from datetime import timedelta, datetime
from types import MappingProxyType
from typing import Any
from typing import Any, TypeVar, Generic
from homeassistant.util import dt as dt_util
from homeassistant.core import (
@@ -140,6 +140,7 @@ from .ema import ExponentialMovingAverage
_LOGGER = logging.getLogger(__name__)
ConfigData = MappingProxyType[str, Any]
T = TypeVar("T", bound=UnderlyingEntity)
def get_tz(hass: HomeAssistant):
@@ -148,7 +149,7 @@ def get_tz(hass: HomeAssistant):
return dt_util.get_time_zone(hass.config.time_zone)
class BaseThermostat(ClimateEntity, RestoreEntity):
class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"""Representation of a base class for all Versatile Thermostat device."""
_entity_component_unrecorded_attributes = (
@@ -278,7 +279,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._last_change_time = None
self._underlyings: list[UnderlyingEntity] = []
self._underlyings: list[T] = []
self._ema_temp = None
self._ema_algo = None
@@ -628,7 +629,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self.async_on_remove(self.remove_thermostat)
await self.async_startup()
# issue 428. Link to others entities will start at link
# await self.async_startup()
def remove_thermostat(self):
"""Called when the thermostat will be removed"""
@@ -636,155 +638,157 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
for under in self._underlyings:
under.remove_entity()
async def async_startup(self):
async def async_startup(self, central_configuration):
"""Triggered on startup, used to get old state and set internal states accordingly"""
_LOGGER.debug("%s - Calling async_startup", self)
@callback
async def _async_startup_internal(*_):
_LOGGER.debug("%s - Calling async_startup_internal", self)
need_write_state = False
_LOGGER.debug("%s - Calling async_startup_internal", self)
need_write_state = False
# Initialize all UnderlyingEntities
self.init_underlyings()
await self.get_my_previous_state()
temperature_state = self.hass.states.get(self._temp_sensor_entity_id)
if temperature_state and temperature_state.state not in (
await self.init_presets(central_configuration)
# Initialize all UnderlyingEntities
self.init_underlyings()
temperature_state = self.hass.states.get(self._temp_sensor_entity_id)
if temperature_state and temperature_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
_LOGGER.debug(
"%s - temperature sensor have been retrieved: %.1f",
self,
float(temperature_state.state),
)
await self._async_update_temp(temperature_state)
need_write_state = True
if self._ext_temp_sensor_entity_id:
ext_temperature_state = self.hass.states.get(
self._ext_temp_sensor_entity_id
)
if ext_temperature_state and ext_temperature_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
_LOGGER.debug(
"%s - temperature sensor have been retrieved: %.1f",
"%s - external temperature sensor have been retrieved: %.1f",
self,
float(temperature_state.state),
float(ext_temperature_state.state),
)
await self._async_update_temp(temperature_state)
need_write_state = True
if self._ext_temp_sensor_entity_id:
ext_temperature_state = self.hass.states.get(
self._ext_temp_sensor_entity_id
)
if ext_temperature_state and ext_temperature_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
_LOGGER.debug(
"%s - external temperature sensor have been retrieved: %.1f",
self,
float(ext_temperature_state.state),
)
await self._async_update_ext_temp(ext_temperature_state)
else:
_LOGGER.debug(
"%s - external temperature sensor have NOT been retrieved cause unknown or unavailable",
self,
)
await self._async_update_ext_temp(ext_temperature_state)
else:
_LOGGER.debug(
"%s - external temperature sensor have NOT been retrieved cause no external sensor",
"%s - external temperature sensor have NOT been retrieved cause unknown or unavailable",
self,
)
if self._pmax_on:
# try to acquire current power and power max
current_power_state = self.hass.states.get(self._power_sensor_entity_id)
if current_power_state and current_power_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
self._current_power = float(current_power_state.state)
_LOGGER.debug(
"%s - Current power have been retrieved: %.3f",
self,
self._current_power,
)
need_write_state = True
# Try to acquire power max
current_power_max_state = self.hass.states.get(
self._max_power_sensor_entity_id
)
if current_power_max_state and current_power_max_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
self._current_power_max = float(current_power_max_state.state)
_LOGGER.debug(
"%s - Current power max have been retrieved: %.3f",
self,
self._current_power_max,
)
need_write_state = True
# try to acquire window entity state
if self._window_sensor_entity_id:
window_state = self.hass.states.get(self._window_sensor_entity_id)
if window_state and window_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
self._window_state = window_state.state == STATE_ON
_LOGGER.debug(
"%s - Window state have been retrieved: %s",
self,
self._window_state,
)
need_write_state = True
# try to acquire motion entity state
if self._motion_sensor_entity_id:
motion_state = self.hass.states.get(self._motion_sensor_entity_id)
if motion_state and motion_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
self._motion_state = motion_state.state
_LOGGER.debug(
"%s - Motion state have been retrieved: %s",
self,
self._motion_state,
)
# recalculate the right target_temp in activity mode
await self._async_update_motion_temp()
need_write_state = True
if self._presence_on:
# try to acquire presence entity state
presence_state = self.hass.states.get(self._presence_sensor_entity_id)
if presence_state and presence_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
await self._async_update_presence(presence_state.state)
_LOGGER.debug(
"%s - Presence have been retrieved: %s",
self,
presence_state.state,
)
need_write_state = True
if need_write_state:
self.async_write_ha_state()
if self._prop_algorithm:
self._prop_algorithm.calculate(
self._target_temp,
self._cur_temp,
self._cur_ext_temp,
self._hvac_mode or HVACMode.OFF,
)
self.reset_last_change_time()
await self.get_my_previous_state()
if self.hass.state == CoreState.running:
await _async_startup_internal()
else:
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, _async_startup_internal
_LOGGER.debug(
"%s - external temperature sensor have NOT been retrieved cause no external sensor",
self,
)
if self._pmax_on:
# try to acquire current power and power max
current_power_state = self.hass.states.get(self._power_sensor_entity_id)
if current_power_state and current_power_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
self._current_power = float(current_power_state.state)
_LOGGER.debug(
"%s - Current power have been retrieved: %.3f",
self,
self._current_power,
)
need_write_state = True
# Try to acquire power max
current_power_max_state = self.hass.states.get(
self._max_power_sensor_entity_id
)
if current_power_max_state and current_power_max_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
self._current_power_max = float(current_power_max_state.state)
_LOGGER.debug(
"%s - Current power max have been retrieved: %.3f",
self,
self._current_power_max,
)
need_write_state = True
# try to acquire window entity state
if self._window_sensor_entity_id:
window_state = self.hass.states.get(self._window_sensor_entity_id)
if window_state and window_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
self._window_state = window_state.state == STATE_ON
_LOGGER.debug(
"%s - Window state have been retrieved: %s",
self,
self._window_state,
)
need_write_state = True
# try to acquire motion entity state
if self._motion_sensor_entity_id:
motion_state = self.hass.states.get(self._motion_sensor_entity_id)
if motion_state and motion_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
self._motion_state = motion_state.state
_LOGGER.debug(
"%s - Motion state have been retrieved: %s",
self,
self._motion_state,
)
# recalculate the right target_temp in activity mode
await self._async_update_motion_temp()
need_write_state = True
if self._presence_on:
# try to acquire presence entity state
presence_state = self.hass.states.get(self._presence_sensor_entity_id)
if presence_state and presence_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
await self._async_update_presence(presence_state.state)
_LOGGER.debug(
"%s - Presence have been retrieved: %s",
self,
presence_state.state,
)
need_write_state = True
if need_write_state:
self.async_write_ha_state()
if self._prop_algorithm:
self._prop_algorithm.calculate(
self._target_temp,
self._cur_temp,
self._cur_ext_temp,
self._hvac_mode or HVACMode.OFF,
)
self.hass.create_task(self._check_initial_state())
self.reset_last_change_time()
# if self.hass.state == CoreState.running:
# await _async_startup_internal()
# else:
# self.hass.bus.async_listen_once(
# EVENT_HOMEASSISTANT_START, _async_startup_internal
# )
def init_underlyings(self):
"""Initialize all underlyings. Should be overriden if necessary"""
@@ -824,19 +828,20 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
# Never restore a Power or Security preset
if old_preset_mode is not None and old_preset_mode not in HIDDEN_PRESETS:
# old_preset_mode in self._attr_preset_modes
self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE)
self._attr_preset_mode = old_preset_mode
self.save_preset_mode()
else:
self._attr_preset_mode = PRESET_NONE
if not self._hvac_mode and old_state.state in [
if old_state.state in [
HVACMode.OFF,
HVACMode.HEAT,
HVACMode.COOL,
]:
self._hvac_mode = old_state.state
else:
self._hvac_mode = HVACMode.OFF
if not self._hvac_mode:
self._hvac_mode = HVACMode.OFF
old_total_energy = old_state.attributes.get(ATTR_TOTAL_ENERGY)
if old_total_energy:
@@ -2084,6 +2089,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
new_central_mode,
)
first_init = self._last_central_mode == None
self._last_central_mode = new_central_mode
def save_all():
@@ -2092,7 +2099,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self.save_hvac_mode()
if new_central_mode == CENTRAL_MODE_AUTO:
if self.window_state is not STATE_ON:
if self.window_state is not STATE_ON and not first_init:
await self.restore_hvac_mode()
await self.restore_preset_mode()
@@ -2706,8 +2713,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
if self._attr_preset_mode:
await self._async_set_preset_mode_internal(self._attr_preset_mode, True)
self.hass.create_task(self._check_initial_state())
async def async_turn_off(self) -> None:
await self.async_set_hvac_mode(HVACMode.OFF)

View File

@@ -39,6 +39,7 @@ from .const import (
CONF_USE_WINDOW_FEATURE,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
CONF_USE_CENTRAL_BOILER_FEATURE,
CONF_CENTRAL_BOILER_ACTIVATION_SRV,
CONF_CENTRAL_BOILER_DEACTIVATION_SRV,
overrides,
@@ -63,10 +64,13 @@ async def async_setup_entry(
name = entry.data.get(CONF_NAME)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
entities = None
if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG:
entities = [
CentralBoilerBinarySensor(hass, unique_id, name, entry.data),
]
if entry.data.get(CONF_USE_CENTRAL_BOILER_FEATURE):
entities = [
CentralBoilerBinarySensor(hass, unique_id, name, entry.data),
]
else:
entities = [
SecurityBinarySensor(hass, unique_id, name, entry.data),
@@ -81,7 +85,8 @@ async def async_setup_entry(
if entry.data.get(CONF_USE_POWER_FEATURE):
entities.append(OverpoweringBinarySensor(hass, unique_id, name, entry.data))
async_add_entities(entities, True)
if entities:
async_add_entities(entities, True)
class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):

View File

@@ -242,6 +242,14 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
and infos.get(CONF_PRESENCE_SENSOR, None) is None
):
return False
if self._infos[CONF_USE_CENTRAL_BOILER_FEATURE] and (
not self._infos.get(CONF_CENTRAL_BOILER_ACTIVATION_SRV, False)
or len(self._infos.get(CONF_CENTRAL_BOILER_ACTIVATION_SRV)) <= 0
or not self._infos.get(CONF_CENTRAL_BOILER_DEACTIVATION_SRV, False)
or len(self._infos.get(CONF_CENTRAL_BOILER_DEACTIVATION_SRV)) <= 0
):
return False
else:
if (
infos.get(CONF_NAME) is None
@@ -857,6 +865,9 @@ class VersatileThermostatOptionsFlowHandler(
if not self._infos[CONF_USE_PRESENCE_FEATURE]:
self._infos[CONF_USE_PRESENCE_CENTRAL_CONFIG] = False
self._infos[CONF_PRESENCE_SENSOR] = None
if not self._infos[CONF_USE_CENTRAL_BOILER_FEATURE]:
self._infos[CONF_CENTRAL_BOILER_ACTIVATION_SRV] = None
self._infos[CONF_CENTRAL_BOILER_DEACTIVATION_SRV] = None
_LOGGER.info(
"Recreating entry %s due to configuration change. New config is now: %s",

View File

@@ -24,11 +24,16 @@ class IntervalCaller:
Convenience wrapper around Home Assistant's `async_track_time_interval` function.
"""
def __init__(self, hass: HomeAssistant, interval_sec: int) -> None:
def __init__(self, hass: HomeAssistant, interval_sec: float) -> None:
self._hass = hass
self._interval_sec = interval_sec
self._remove_handle: CALLBACK_TYPE | None = None
@property
def interval_sec(self) -> float:
"""Return the calling interval in seconds."""
return self._interval_sec
def cancel(self):
"""Cancel the regular calls to the action function."""
if self._remove_handle:
@@ -43,7 +48,11 @@ class IntervalCaller:
async def callback(_time: datetime):
try:
_LOGGER.debug("Calling keep-alive action")
_LOGGER.debug(
"Calling keep-alive action '%s' (%ss interval)",
action.__name__,
self._interval_sec,
)
await action()
except Exception as e: # pylint: disable=broad-exception-caught
_LOGGER.error(e)

View File

@@ -14,6 +14,6 @@
"quality_scale": "silver",
"requirements": [],
"ssdp": [],
"version": "6.0.2",
"version": "6.0.4",
"zeroconf": []
}

View File

@@ -51,6 +51,7 @@ from .const import (
CONF_USE_PRESETS_CENTRAL_CONFIG,
CONF_USE_PRESENCE_CENTRAL_CONFIG,
CONF_USE_PRESENCE_FEATURE,
CONF_USE_CENTRAL_BOILER_FEATURE,
overrides,
)
@@ -153,9 +154,10 @@ async def async_setup_entry(
# For central config only
else:
entities.append(
ActivateBoilerThresholdNumber(hass, unique_id, name, entry.data)
)
if entry.data.get(CONF_USE_CENTRAL_BOILER_FEATURE):
entities.append(
ActivateBoilerThresholdNumber(hass, unique_id, name, entry.data)
)
for preset in CONF_PRESETS_WITH_AC_VALUES:
_LOGGER.debug(
"%s - configuring Number central, AC, non AWAY for preset %s",
@@ -178,7 +180,8 @@ async def async_setup_entry(
)
)
async_add_entities(entities, True)
if len(entities) > 0:
async_add_entities(entities, True)
class ActivateBoilerThresholdNumber(
@@ -350,7 +353,7 @@ class CentralConfigTemperatureNumber(
# We have to reload all VTherm for which uses the central configuration
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass)
# Update the VTherms which have temperature in central config
self.hass.create_task(api.init_vtherm_links(only_use_central=True))
self.hass.create_task(api.init_vtherm_preset_with_central())
def __str__(self):
return f"VersatileThermostat-{self.name}"

View File

@@ -18,6 +18,9 @@ from custom_components.versatile_thermostat.base_thermostat import (
BaseThermostat,
ConfigData,
)
from custom_components.versatile_thermostat.vtherm_api import VersatileThermostatAPI
from .const import (
DOMAIN,
DEVICE_MANUFACTURER,
@@ -96,17 +99,20 @@ class CentralModeSelect(SelectEntity, RestoreEntity):
if old_state is not None:
self._attr_current_option = old_state.state
@callback
async def _async_startup_internal(*_):
_LOGGER.debug("%s - Calling async_startup_internal", self)
await self.notify_central_mode_change()
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass)
api.register_central_mode_select(self)
if self.hass.state == CoreState.running:
await _async_startup_internal()
else:
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, _async_startup_internal
)
# @callback
# async def _async_startup_internal(*_):
# _LOGGER.debug("%s - Calling async_startup_internal", self)
# await self.notify_central_mode_change()
#
# if self.hass.state == CoreState.running:
# await _async_startup_internal()
# else:
# self.hass.bus.async_listen_once(
# EVENT_HOMEASSISTANT_START, _async_startup_internal
# )
@overrides
async def async_select_option(self, option: str) -> None:
@@ -122,17 +128,9 @@ class CentralModeSelect(SelectEntity, RestoreEntity):
async def notify_central_mode_change(self, old_central_mode: str | None = None):
"""Notify all VTherm that the central_mode have change"""
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass)
# Update all VTherm states
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if isinstance(entity, BaseThermostat):
_LOGGER.debug(
"Changing the central_mode. We have find %s to update",
entity.name,
)
await entity.check_central_mode(
self._attr_current_option, old_central_mode
)
await api.notify_central_mode_change(old_central_mode)
def __str__(self) -> str:
return f"VersatileThermostat-{self.name}"

View File

@@ -79,7 +79,6 @@ async def async_setup_entry(
entities = [
NbActiveDeviceForBoilerSensor(hass, unique_id, name, entry.data)
]
async_add_entities(entities, True)
else:
entities = [
LastTemperatureSensor(hass, unique_id, name, entry.data),
@@ -108,6 +107,7 @@ async def async_setup_entry(
RegulatedTemperatureSensor(hass, unique_id, name, entry.data)
)
if entities:
async_add_entities(entities, True)

View File

@@ -58,7 +58,7 @@ from .underlyings import UnderlyingClimate
_LOGGER = logging.getLogger(__name__)
class ThermostatOverClimate(BaseThermostat):
class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
"""Representation of a base class for a Versatile Thermostat over a climate"""
_auto_regulation_mode: str | None = None

View File

@@ -27,7 +27,7 @@ from .prop_algorithm import PropAlgorithm
_LOGGER = logging.getLogger(__name__)
class ThermostatOverSwitch(BaseThermostat):
class ThermostatOverSwitch(BaseThermostat[UnderlyingSwitch]):
"""Representation of a base class for a Versatile Thermostat over a switch."""
_entity_component_unrecorded_attributes = (
@@ -136,11 +136,11 @@ class ThermostatOverSwitch(BaseThermostat):
"""Custom attributes"""
super().update_custom_attributes()
under0: UnderlyingSwitch = self._underlyings[0]
self._attr_extra_state_attributes["is_over_switch"] = self.is_over_switch
self._attr_extra_state_attributes["is_inversed"] = self.is_inversed
self._attr_extra_state_attributes["underlying_switch_0"] = self._underlyings[
0
].entity_id
self._attr_extra_state_attributes["keep_alive_sec"] = under0.keep_alive_sec
self._attr_extra_state_attributes["underlying_switch_0"] = under0.entity_id
self._attr_extra_state_attributes["underlying_switch_1"] = (
self._underlyings[1].entity_id if len(self._underlyings) > 1 else None
)

View File

@@ -31,7 +31,7 @@ from .underlyings import UnderlyingValve
_LOGGER = logging.getLogger(__name__)
class ThermostatOverValve(BaseThermostat): # pylint: disable=abstract-method
class ThermostatOverValve(BaseThermostat[UnderlyingValve]): # pylint: disable=abstract-method
"""Representation of a class for a Versatile Thermostat over a Valve"""
_entity_component_unrecorded_attributes = (

View File

@@ -188,7 +188,7 @@ class UnderlyingSwitch(UnderlyingEntity):
thermostat: Any,
switch_entity_id: str,
initial_delay_sec: int,
keep_alive_sec: int,
keep_alive_sec: float,
) -> None:
"""Initialize the underlying switch"""
@@ -217,6 +217,11 @@ class UnderlyingSwitch(UnderlyingEntity):
"""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()
@@ -481,8 +486,8 @@ class UnderlyingClimate(UnderlyingEntity):
self._underlying_climate,
)
else:
_LOGGER.error(
"%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational",
_LOGGER.info(
"%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational. Will try later.",
self,
self.entity_id,
)
@@ -775,15 +780,19 @@ class UnderlyingValve(UnderlyingEntity):
"""Send the percent open to the underlying valve"""
# This may fails if called after shutdown
try:
data = {ATTR_ENTITY_ID: self._entity_id, "value": self._percent_open}
data = {"value": self._percent_open}
target = {ATTR_ENTITY_ID: self._entity_id}
domain = self._entity_id.split(".")[0]
await self._hass.services.async_call(
domain,
SERVICE_SET_VALUE,
data,
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 turn_off(self):
"""Turn heater toggleable device off."""

View File

@@ -57,6 +57,7 @@ class VersatileThermostatAPI(dict):
self._threshold_number_entity = None
self._nb_active_number_entity = None
self._central_configuration = None
self._central_mode_select = None
# A dict that will store all Number entities which holds the temperature
self._number_temperatures = dict()
@@ -149,8 +150,8 @@ class VersatileThermostatAPI(dict):
return entity.state
return None
async def init_vtherm_links(self, only_use_central=False):
"""INitialize all VTherms entities links
async def init_vtherm_links(self):
"""Initialize all VTherms entities links
This method is called when HA is fully started (and all entities should be initialized)
Or when we need to reload all VTherm links (with Number temp entities, central boiler, ...)
"""
@@ -162,12 +163,34 @@ class VersatileThermostatAPI(dict):
)
if component:
for entity in component.entities:
if hasattr(entity, "init_presets"):
if (
only_use_central is False
or entity.use_central_config_temperature
):
await entity.init_presets(self.find_central_configuration())
# if hasattr(entity, "init_presets"):
# if (
# only_use_central is False
# or entity.use_central_config_temperature
# ):
# await entity.init_presets(self.find_central_configuration())
# A little hack to test if the climate is a VTherm. Cannot use isinstance due to circular dependency of BaseThermostat
if (
entity.device_info
and entity.device_info.get("model", None) == DOMAIN
):
await entity.async_startup(self.find_central_configuration())
async def init_vtherm_preset_with_central(self):
"""Init all VTherm presets when the VTherm uses central temperature"""
# Initialization of all preset for all VTherm
component: EntityComponent[ClimateEntity] = self._hass.data.get(
CLIMATE_DOMAIN, None
)
if component:
for entity in component.entities:
if (
entity.device_info
and entity.device_info.get("model", None) == DOMAIN
and entity.use_central_config_temperature
):
await entity.init_presets(self.find_central_configuration())
async def reload_central_boiler_binary_listener(self):
"""Reloads the BinarySensor entity which listen to the number of
@@ -180,6 +203,27 @@ class VersatileThermostatAPI(dict):
if self._nb_active_number_entity is not None:
await self._nb_active_number_entity.listen_vtherms_entities()
def register_central_mode_select(self, central_mode_select):
"""Register the select entity which holds the central_mode"""
self._central_mode_select = central_mode_select
async def notify_central_mode_change(self, old_central_mode: str | None = None):
"""Notify all VTherm that the central_mode have change"""
if self._central_mode_select is None:
return
# Update all VTherm states
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if entity.device_info and entity.device_info.get("model", None) == DOMAIN:
_LOGGER.debug(
"Changing the central_mode. We have find %s to update",
entity.name,
)
await entity.check_central_mode(
self._central_mode_select.state, old_central_mode
)
@property
def self_regulation_expert(self):
"""Get the self regulation params"""
@@ -229,6 +273,14 @@ class VersatileThermostatAPI(dict):
return None
return int(self._threshold_number_entity.native_value)
@property
def central_mode(self) -> str | None:
"""Get the current central mode or None"""
if self._central_mode_select:
return self._central_mode_select.state
else:
return None
@property
def hass(self):
"""Get the HomeAssistant object"""

View File

@@ -94,8 +94,8 @@ async def test_add_a_central_config(hass: HomeAssistant, skip_hass_states_is_sta
assert api.nb_active_device_for_boiler_entity is None
assert api.nb_active_device_for_boiler is None
assert api.nb_active_device_for_boiler_threshold_entity is not None
assert api.nb_active_device_for_boiler_threshold == 1 # the default value
assert api.nb_active_device_for_boiler_threshold_entity is None
assert api.nb_active_device_for_boiler_threshold is None
# @pytest.mark.parametrize("expected_lingering_tasks", [True])

View File

@@ -181,14 +181,18 @@ async def test_over_valve_full_start(
mock_service_call.assert_has_calls(
[
call.async_call(
"number",
"set_value",
{"entity_id": "number.mock_valve", "value": 90},
domain="number",
service="set_value",
service_data={"value": 90},
target={"entity_id": "number.mock_valve"},
# {"entity_id": "number.mock_valve", "value": 90},
),
call.async_call(
"number",
"set_value",
{"entity_id": "number.mock_valve", "value": 98},
domain="number",
service="set_value",
service_data={"value": 98},
target={"entity_id": "number.mock_valve"},
# {"entity_id": "number.mock_valve", "value": 98},
),
]
)
@@ -241,9 +245,10 @@ async def test_over_valve_full_start(
mock_service_call.assert_has_calls(
[
call.async_call(
"number",
"set_value",
{"entity_id": "number.mock_valve", "value": 10},
domain="number",
service="set_value",
service_data={"value": 10},
target={"entity_id": "number.mock_valve"},
)
]
)
@@ -254,20 +259,16 @@ async def test_over_valve_full_start(
mock_service_call.assert_has_calls(
[
call.async_call(
"number",
"set_value",
{
"entity_id": "number.mock_valve",
"value": 10,
}, # the min allowed value
domain="number",
service="set_value",
service_data={"value": 10},
target={"entity_id": "number.mock_valve"}, # the min allowed value
),
call.async_call(
"number",
"set_value",
{
"entity_id": "number.mock_valve",
"value": 50,
}, # the max allowed value
domain="number",
service="set_value",
service_data={"value": 50}, # the min allowed value
target={"entity_id": "number.mock_valve"},
),
]
)
@@ -466,9 +467,10 @@ async def test_over_valve_regulation(
mock_service_call.assert_has_calls(
[
call.async_call(
"number",
"set_value",
{"entity_id": "number.mock_valve", "value": 90},
domain="number",
service="set_value",
service_data={"value": 90},
target={"entity_id": "number.mock_valve"},
),
]
)
@@ -524,9 +526,10 @@ async def test_over_valve_regulation(
mock_service_call.assert_has_calls(
[
call.async_call(
"number",
"set_value",
{"entity_id": "number.mock_valve", "value": 96},
domain="number",
service="set_value",
service_data={"value": 96},
target={"entity_id": "number.mock_valve"},
),
]
)