19 files
This commit is contained in:
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."""
|
||||
|
||||
Reference in new issue
Block a user