This commit is contained in:
Home Assistant Version Control committed 2026-08-20 15:12:43 +00:00
1 parent 33669f8f1e
commit 6644b9e4a8
19 files changed
+285 -110

No files matched your search

@@ -205,6 +205,17 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
# Instantiate all features manager
self._managers: list[BaseFeatureManager] = []
# Names of feature managers provided by external plugins that have
# already been instantiated for this thermostat. Used to avoid
# registering the same external manager twice (post_init + startup retry).
self._external_manager_names: set[str] = set()
# Instances of feature managers provided by external plugins. They are
# also present in ``self._managers`` (for lifecycle) but are tracked
# separately so they can be refreshed on every control cycle (internal
# managers have their own dedicated per-cycle mechanisms).
self._external_managers: list[BaseFeatureManager] = []
self._presence_manager: FeaturePresenceManager = FeaturePresenceManager(
self, hass
)
@@ -237,6 +248,42 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"""Register a manager"""
self._managers.append(manager)
def _load_external_feature_managers(self):
"""Instantiate feature managers provided by external plugins.
Query the VTherm API registry and create one manager instance per
eligible thermostat (the factory decides eligibility through its
``supports`` method). Managers whose plugin registers after this
thermostat has been built are picked up later during ``async_startup``,
mirroring the external proportional algorithm retry behavior.
"""
api = VersatileThermostatAPI.get_vtherm_api(self.hass)
if api is None or not hasattr(api, "get_feature_manager_factories"):
return
for factory in api.get_feature_manager_factories():
name = factory.name
if name in self._external_manager_names:
continue
try:
if not factory.supports(self):
continue
manager = factory.create(self)
manager.post_init(self._entry_infos)
except Exception as exc: # pylint: disable=broad-except
_LOGGER.error(
"%s - Error while creating external feature manager '%s': %s",
self,
name,
exc,
)
continue
self.register_manager(manager)
self._external_manager_names.add(name)
self._external_managers.append(manager)
_LOGGER.info("%s - Registered external feature manager '%s'", self, name)
def clean_central_config_doublon(
self, config_entry: ConfigData, central_config: ConfigEntry | None
) -> dict[str, Any]:
@@ -308,6 +355,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
for manager in self._managers:
manager.post_init(entry_infos)
# Instantiate feature managers provided by external plugins that are
# already registered at this point. Late-registered plugins are handled
# by a retry in async_startup.
self._load_external_feature_managers()
self._use_central_config_temperature = entry_infos.get(
CONF_USE_PRESETS_CENTRAL_CONFIG
) or (
@@ -487,6 +539,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
_LOGGER.debug("%s - Calling async_startup_internal", self)
# need_write_state = False
# Retry loading external feature managers: a plugin may have registered
# its factory after this thermostat was built (load order between the
# core and the plugin is not guaranteed). Newly created managers are
# then started in the loop below.
self._load_external_feature_managers()
# start listening for all managers
for manager in self._managers:
await manager.start_listening()
@@ -898,6 +956,30 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"""Return the temperature we try to reach."""
return self._state_manager.current_state.target_temperature
@property
def regulated_target_temperature(self) -> float | None:
"""Return the regulated target temperature used to drive the underlying.
The base implementation returns the plain target temperature. Over
climate thermostats override this to expose their regulated value.
"""
return self.target_temperature
@property
def underlying_fan_modes(self) -> list[str] | None:
"""Return the fan modes exposed by the underlying climate(s).
The base implementation returns None because most thermostats do not
expose an underlying climate with fan control.
"""
return None
async def async_set_underlying_fan_mode(self, fan_mode: str) -> None:
"""Send a fan mode to the underlying climate(s).
The base implementation is a no-op for thermostats without fan control.
"""
@property
def supported_features(self) -> ClimateEntityFeature:
"""Return the list of supported features."""
@@ -1609,6 +1691,25 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
# Call specific control heating
await self._control_heating_specific(timestamp, force)
# Refresh external feature managers (provided by plugins) on every cycle.
# This is done after the specific control heating so that a manager can
# react to the regulated target temperature (e.g. drive the underlying
# fan mode), and before the publication block below so that any custom
# attributes updated here are written to HA within the same cycle.
# Only external managers are refreshed here: internal managers have their
# own dedicated per-cycle mechanisms. A per-manager guard prevents a
# faulty plugin from breaking the control loop.
for manager in self._external_managers:
try:
await manager.refresh_state()
except Exception as exc: # pylint: disable=broad-except
_LOGGER.error(
"%s - Error while refreshing external feature manager '%s': %s",
self,
manager.name,
exc,
)
# Check for heating/cooling failures (only for TPI VTherms)
await self._heating_failure_detection_manager.refresh_state()
@@ -1884,7 +1985,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
self._state_manager.add_custom_attributes(self._attr_extra_state_attributes)
for manager in self._managers:
manager.add_custom_attributes(self._attr_extra_state_attributes)
# add_custom_attributes is optional for external feature managers
# (it is not part of the InterfaceFeatureManager contract), so call
# it defensively.
publish_attributes = getattr(manager, "add_custom_attributes", None)
if callable(publish_attributes):
publish_attributes(self._attr_extra_state_attributes)
def send_event(self, event_type: EventType, data: dict):
"""Send an event"""
@@ -125,7 +125,7 @@ async def async_setup_entry(
["None", "Low", "Medium", "High", "Turbo"]
),
},
"service_set_auto_fan_mode",
"service_set_auto_fan_mode_deprecated",
)
platform.async_register_entity_service(
@@ -19,9 +19,9 @@
"requirements": [
"numpy",
"scipy",
"vtherm_api>=0.3.0"
"vtherm_api>=0.4.0"
],
"ssdp": [],
"version": "10.1.0",
"version": "10.2.0",
"zeroconf": []
}
@@ -113,7 +113,7 @@ set_auto_regulation_mode:
set_auto_fan_mode:
name: Set Auto Fan mode
description: Change the mode of auto-fan (only for VTherm over climate)
description: Deprecated when using the vtherm_auto_fan_extended plugin. Without the plugin, legacy core behavior is kept.
target:
entity:
integration: versatile_thermostat
@@ -8,6 +8,7 @@ from datetime import timedelta, datetime
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval, EventStateChangedData, async_call_later
from homeassistant.components.climate import (
HVACAction,
@@ -27,6 +28,9 @@ from .vtherm_hvac_mode import VThermHvacMode
_LOGGER = get_vtherm_logger(__name__)
AUTO_FAN_PLUGIN_DOMAIN = "vtherm_auto_fan_extended"
AUTO_FAN_PLUGIN_TARGET_VTHERM_KEY = "target_vtherm_unique_id"
HVAC_ACTION_ON = [ # pylint: disable=invalid-name
HVACAction.COOLING,
HVACAction.DRYING,
@@ -465,8 +469,8 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
return None
def determine_fan_mode_contains_speed(fan_modes: list[str]) -> bool:
"""Determine if the fan_modes contains speed modes by searching for the keywords "low"/"1"/"one"/"speed_1"."""
for val in ["low", "1", "one", "speed_1"]:
"""Determine if the fan_modes contains speed modes by searching for the keywords "low"/"1"/"one"/"speed_1"/"on_low"."""
for val in ["low", "1", "one", "speed_1", "on_low"]:
if find_fan_mode(fan_modes, val):
return True
return False
@@ -482,6 +486,8 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
index = speed_modes.index("one")
elif "speed_1" in speed_modes:
index = speed_modes.index("speed_1")
elif "on_low" in speed_modes:
index = speed_modes.index("on_low")
if index > -1 and index >= len(speed_modes) / 2:
speed_modes.reverse()
@@ -1008,6 +1014,20 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
"""Get the regulated target temperature"""
return self._regulated_target_temp
@property
def regulated_target_temperature(self) -> float | None:
"""Return the regulated target temperature used to drive the underlying."""
return self._regulated_target_temp
@property
def underlying_fan_modes(self) -> list[str] | None:
"""Return the fan modes exposed by the underlying climate(s)."""
return self.fan_modes
async def async_set_underlying_fan_mode(self, fan_mode: str) -> None:
"""Send a fan mode to the underlying climate(s)."""
await self.async_set_fan_mode(fan_mode)
@property
def is_regulated(self) -> bool:
"""Check if the ThermostatOverClimate is regulated"""
@@ -1306,6 +1326,49 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
self.update_custom_attributes()
self.async_write_ha_state()
async def service_set_auto_fan_mode_deprecated(self, auto_fan_mode: str):
"""Deprecated service handler for auto fan mode.
Auto fan is now managed by the vtherm_auto_fan_extended plugin.
"""
config_entries = getattr(self._hass, "config_entries", None)
plugin_is_configured = False
if config_entries is not None:
try:
plugin_entries = config_entries.async_entries(AUTO_FAN_PLUGIN_DOMAIN)
except Exception: # pylint: disable=broad-except
plugin_entries = []
# If at least one entry targets this VTherm, plugin takes ownership.
plugin_is_configured = any(
entry.data.get(AUTO_FAN_PLUGIN_TARGET_VTHERM_KEY) == self.unique_id
for entry in plugin_entries
)
# Backward compatibility: old entries without target still mean plugin ownership.
if not plugin_is_configured and plugin_entries:
plugin_is_configured = any(
AUTO_FAN_PLUGIN_TARGET_VTHERM_KEY not in entry.data
for entry in plugin_entries
)
# If plugin is not configured for this VTherm, keep legacy core behavior.
if not plugin_is_configured:
await self.service_set_auto_fan_mode(auto_fan_mode)
return
_LOGGER.warning(
"%s - set_auto_fan_mode is deprecated in core and managed by the "
"vtherm_auto_fan_extended plugin (requested: %s)",
self,
auto_fan_mode,
)
raise HomeAssistantError(
"The set_auto_fan_mode service is now managed by the "
"vtherm_auto_fan_extended plugin and is no longer available in "
"Versatile Thermostat core."
)
@overrides
async def async_turn_off(self) -> None:
"""Turn off the climate entity."""