Compare commits

..

6 Commits
6.0.2 ... 6.1.0

Author SHA1 Message Date
Jean-Marc Collin
a9b87b3aee Release 6.1.0 2024-03-26 20:10:29 +00:00
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: logger:
default: warning default: warning
logs: logs:
# custom_components.versatile_thermostat: debug custom_components.versatile_thermostat: info
# custom_components.versatile_thermostat.underlyings: debug custom_components.versatile_thermostat.underlyings: info
# custom_components.versatile_thermostat.climate: debug custom_components.versatile_thermostat.climate: info
# If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/)
debugpy: debugpy:

View File

@@ -107,6 +107,8 @@ async def async_setup(
"VersatileThermostat - HA is started, initialize all links between VTherm entities" "VersatileThermostat - HA is started, initialize all links between VTherm entities"
) )
await api.init_vtherm_links() await api.init_vtherm_links()
await api.notify_central_mode_change()
await api.reload_central_boiler_entities_list()
if hass.state == CoreState.running: if hass.state == CoreState.running:
await _async_startup_internal() 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 hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await api.reload_central_boiler_entities_list() if hass.state == CoreState.running:
await api.init_vtherm_links() await api.reload_central_boiler_entities_list()
await api.init_vtherm_links()
return True return True

View File

@@ -7,7 +7,7 @@ import logging
from datetime import timedelta, datetime from datetime import timedelta, datetime
from types import MappingProxyType from types import MappingProxyType
from typing import Any from typing import Any, TypeVar, Generic
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.core import ( from homeassistant.core import (
@@ -140,6 +140,7 @@ from .ema import ExponentialMovingAverage
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ConfigData = MappingProxyType[str, Any] ConfigData = MappingProxyType[str, Any]
T = TypeVar("T", bound=UnderlyingEntity)
def get_tz(hass: HomeAssistant): def get_tz(hass: HomeAssistant):
@@ -148,7 +149,7 @@ def get_tz(hass: HomeAssistant):
return dt_util.get_time_zone(hass.config.time_zone) 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.""" """Representation of a base class for all Versatile Thermostat device."""
_entity_component_unrecorded_attributes = ( _entity_component_unrecorded_attributes = (
@@ -278,7 +279,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._last_change_time = None self._last_change_time = None
self._underlyings: list[UnderlyingEntity] = [] self._underlyings: list[T] = []
self._ema_temp = None self._ema_temp = None
self._ema_algo = None self._ema_algo = None
@@ -628,7 +629,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self.async_on_remove(self.remove_thermostat) 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): def remove_thermostat(self):
"""Called when the thermostat will be removed""" """Called when the thermostat will be removed"""
@@ -636,155 +638,157 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
for under in self._underlyings: for under in self._underlyings:
under.remove_entity() 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""" """Triggered on startup, used to get old state and set internal states accordingly"""
_LOGGER.debug("%s - Calling async_startup", self) _LOGGER.debug("%s - Calling async_startup", self)
@callback _LOGGER.debug("%s - Calling async_startup_internal", self)
async def _async_startup_internal(*_): need_write_state = False
_LOGGER.debug("%s - Calling async_startup_internal", self)
need_write_state = False
# Initialize all UnderlyingEntities await self.get_my_previous_state()
self.init_underlyings()
temperature_state = self.hass.states.get(self._temp_sensor_entity_id) await self.init_presets(central_configuration)
if temperature_state and temperature_state.state not in (
# 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_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
): ):
_LOGGER.debug( _LOGGER.debug(
"%s - temperature sensor have been retrieved: %.1f", "%s - external temperature sensor have been retrieved: %.1f",
self, self,
float(temperature_state.state), float(ext_temperature_state.state),
) )
await self._async_update_temp(temperature_state) await self._async_update_ext_temp(ext_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,
)
else: else:
_LOGGER.debug( _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, 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: else:
self.hass.bus.async_listen_once( _LOGGER.debug(
EVENT_HOMEASSISTANT_START, _async_startup_internal "%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): def init_underlyings(self):
"""Initialize all underlyings. Should be overriden if necessary""" """Initialize all underlyings. Should be overriden if necessary"""
@@ -824,19 +828,20 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
# Never restore a Power or Security preset # Never restore a Power or Security preset
if old_preset_mode is not None and old_preset_mode not in HIDDEN_PRESETS: if old_preset_mode is not None and old_preset_mode not in HIDDEN_PRESETS:
# old_preset_mode in self._attr_preset_modes # 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() self.save_preset_mode()
else: else:
self._attr_preset_mode = PRESET_NONE self._attr_preset_mode = PRESET_NONE
if not self._hvac_mode and old_state.state in [ if old_state.state in [
HVACMode.OFF, HVACMode.OFF,
HVACMode.HEAT, HVACMode.HEAT,
HVACMode.COOL, HVACMode.COOL,
]: ]:
self._hvac_mode = old_state.state self._hvac_mode = old_state.state
else: 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) old_total_energy = old_state.attributes.get(ATTR_TOTAL_ENERGY)
if old_total_energy: if old_total_energy:
@@ -2084,6 +2089,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
new_central_mode, new_central_mode,
) )
first_init = self._last_central_mode == None
self._last_central_mode = new_central_mode self._last_central_mode = new_central_mode
def save_all(): def save_all():
@@ -2092,7 +2099,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self.save_hvac_mode() self.save_hvac_mode()
if new_central_mode == CENTRAL_MODE_AUTO: 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_hvac_mode()
await self.restore_preset_mode() await self.restore_preset_mode()
@@ -2706,8 +2713,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
if self._attr_preset_mode: if self._attr_preset_mode:
await self._async_set_preset_mode_internal(self._attr_preset_mode, True) 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: async def async_turn_off(self) -> None:
await self.async_set_hvac_mode(HVACMode.OFF) await self.async_set_hvac_mode(HVACMode.OFF)

View File

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

View File

@@ -242,6 +242,14 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
and infos.get(CONF_PRESENCE_SENSOR, None) is None and infos.get(CONF_PRESENCE_SENSOR, None) is None
): ):
return False 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: else:
if ( if (
infos.get(CONF_NAME) is None infos.get(CONF_NAME) is None
@@ -857,6 +865,9 @@ class VersatileThermostatOptionsFlowHandler(
if not self._infos[CONF_USE_PRESENCE_FEATURE]: if not self._infos[CONF_USE_PRESENCE_FEATURE]:
self._infos[CONF_USE_PRESENCE_CENTRAL_CONFIG] = False self._infos[CONF_USE_PRESENCE_CENTRAL_CONFIG] = False
self._infos[CONF_PRESENCE_SENSOR] = None 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( _LOGGER.info(
"Recreating entry %s due to configuration change. New config is now: %s", "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. 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._hass = hass
self._interval_sec = interval_sec self._interval_sec = interval_sec
self._remove_handle: CALLBACK_TYPE | None = None 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): def cancel(self):
"""Cancel the regular calls to the action function.""" """Cancel the regular calls to the action function."""
if self._remove_handle: if self._remove_handle:
@@ -43,7 +48,11 @@ class IntervalCaller:
async def callback(_time: datetime): async def callback(_time: datetime):
try: try:
_LOGGER.debug("Calling keep-alive action") _LOGGER.debug(
"Calling keep-alive action '%s' (%ss interval)",
action.__name__,
self._interval_sec,
)
await action() await action()
except Exception as e: # pylint: disable=broad-exception-caught except Exception as e: # pylint: disable=broad-exception-caught
_LOGGER.error(e) _LOGGER.error(e)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,6 +57,7 @@ class VersatileThermostatAPI(dict):
self._threshold_number_entity = None self._threshold_number_entity = None
self._nb_active_number_entity = None self._nb_active_number_entity = None
self._central_configuration = None self._central_configuration = None
self._central_mode_select = None
# A dict that will store all Number entities which holds the temperature # A dict that will store all Number entities which holds the temperature
self._number_temperatures = dict() self._number_temperatures = dict()
@@ -149,8 +150,8 @@ class VersatileThermostatAPI(dict):
return entity.state return entity.state
return None return None
async def init_vtherm_links(self, only_use_central=False): async def init_vtherm_links(self):
"""INitialize all VTherms entities links """Initialize all VTherms entities links
This method is called when HA is fully started (and all entities should be initialized) 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, ...) Or when we need to reload all VTherm links (with Number temp entities, central boiler, ...)
""" """
@@ -162,12 +163,34 @@ class VersatileThermostatAPI(dict):
) )
if component: if component:
for entity in component.entities: for entity in component.entities:
if hasattr(entity, "init_presets"): # if hasattr(entity, "init_presets"):
if ( # if (
only_use_central is False # only_use_central is False
or entity.use_central_config_temperature # or entity.use_central_config_temperature
): # ):
await entity.init_presets(self.find_central_configuration()) # await entity.init_presets(self.find_central_configuration())
# A little hack to test if the climate is a VTherm. Cannot use isinstance due to circular dependency of BaseThermostat
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): async def reload_central_boiler_binary_listener(self):
"""Reloads the BinarySensor entity which listen to the number of """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: if self._nb_active_number_entity is not None:
await self._nb_active_number_entity.listen_vtherms_entities() 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 @property
def self_regulation_expert(self): def self_regulation_expert(self):
"""Get the self regulation params""" """Get the self regulation params"""
@@ -229,6 +273,14 @@ class VersatileThermostatAPI(dict):
return None return None
return int(self._threshold_number_entity.native_value) 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 @property
def hass(self): def hass(self):
"""Get the HomeAssistant object""" """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_entity is None
assert api.nb_active_device_for_boiler 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_entity is None
assert api.nb_active_device_for_boiler_threshold == 1 # the default value assert api.nb_active_device_for_boiler_threshold is None
# @pytest.mark.parametrize("expected_lingering_tasks", [True]) # @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( mock_service_call.assert_has_calls(
[ [
call.async_call( call.async_call(
"number", domain="number",
"set_value", service="set_value",
{"entity_id": "number.mock_valve", "value": 90}, service_data={"value": 90},
target={"entity_id": "number.mock_valve"},
# {"entity_id": "number.mock_valve", "value": 90},
), ),
call.async_call( call.async_call(
"number", domain="number",
"set_value", service="set_value",
{"entity_id": "number.mock_valve", "value": 98}, 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( mock_service_call.assert_has_calls(
[ [
call.async_call( call.async_call(
"number", domain="number",
"set_value", service="set_value",
{"entity_id": "number.mock_valve", "value": 10}, 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( mock_service_call.assert_has_calls(
[ [
call.async_call( call.async_call(
"number", domain="number",
"set_value", service="set_value",
{ service_data={"value": 10},
"entity_id": "number.mock_valve", target={"entity_id": "number.mock_valve"}, # the min allowed value
"value": 10,
}, # the min allowed value
), ),
call.async_call( call.async_call(
"number", domain="number",
"set_value", service="set_value",
{ service_data={"value": 50}, # the min allowed value
"entity_id": "number.mock_valve", target={"entity_id": "number.mock_valve"},
"value": 50,
}, # the max allowed value
), ),
] ]
) )
@@ -466,9 +467,10 @@ async def test_over_valve_regulation(
mock_service_call.assert_has_calls( mock_service_call.assert_has_calls(
[ [
call.async_call( call.async_call(
"number", domain="number",
"set_value", service="set_value",
{"entity_id": "number.mock_valve", "value": 90}, 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( mock_service_call.assert_has_calls(
[ [
call.async_call( call.async_call(
"number", domain="number",
"set_value", service="set_value",
{"entity_id": "number.mock_valve", "value": 96}, service_data={"value": 96},
target={"entity_id": "number.mock_valve"},
), ),
] ]
) )