This commit is contained in:
Home Assistant Version Control
2026-08-12 19:11:00 +00:00
parent b654759146
commit 2218539a12
76 changed files with 443 additions and 100 deletions
@@ -17,6 +17,12 @@ _LOGGER = get_vtherm_logger(__name__)
class BaseFeatureManager:
"""A base class for all feature"""
# Attributes that should be excluded from the recorder history. Each manager
# exposes its custom attributes under a dedicated top-level section key; that
# section name is declared here so the recorder skips the whole section
# (the recorder can only filter top-level keys, not nested ones).
unrecorded_attributes = frozenset()
def __init__(self, vtherm: Any, hass: HomeAssistant, name: str = None):
"""Init of a featureManager"""
self._vtherm = vtherm
@@ -85,11 +85,16 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
_attr_swing_horizontal_mode = ""
_entity_component_unrecorded_attributes = (
ClimateEntity._entity_component_unrecorded_attributes.union(frozenset({"configuration", "preset_temperatures"}))
ClimateEntity._entity_component_unrecorded_attributes.union(frozenset({"configuration", "preset_temperatures", "specific_states"}))
.union(FeaturePresenceManager.unrecorded_attributes)
.union(FeaturePowerManager.unrecorded_attributes)
.union(FeatureMotionManager.unrecorded_attributes)
.union(FeatureWindowManager.unrecorded_attributes)
.union(FeatureSafetyManager.unrecorded_attributes)
.union(FeatureLockManager.unrecorded_attributes)
.union(FeatureTimedPresetManager.unrecorded_attributes)
.union(FeatureHeatingFailureDetectionManager.unrecorded_attributes)
.union(FeatureRepairIncorrectStateManager.unrecorded_attributes)
)
##
@@ -192,6 +197,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
self._use_central_config_temperature = False
self._hvac_off_reason: str | None = None
self._hvac_mode_reason: str | None = None
self._hvac_list: list[VThermHvacMode] = []
self._str_hvac_list: list[str] = []
self._temperature_reason: str | None = None
@@ -634,6 +640,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
# Try to get total_energy from specific_states (new format) or root level (old format)
specific_states = old_state.attributes.get("specific_states", {})
self._hvac_mode_reason = specific_states.get(HVAC_MODE_REASON_NAME, self._hvac_off_reason)
old_total_energy = specific_states.get(ATTR_TOTAL_ENERGY)
if old_total_energy is None:
# Fallback to root level for backward compatibility
@@ -1280,6 +1287,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
window detection or auto-start-stop"""
return self._hvac_off_reason
@property
def hvac_mode_reason(self) -> str | None:
"""Returns the reason why the current hvac_mode is forced.
Unlike hvac_off_reason, this is set whatever the forced hvac_mode is
(off, fan_only, dry, ...) so the UI can explain why the VTherm is not
in the requested mode."""
return self._hvac_mode_reason
@property
def temperature_reason(self) -> str | None:
"""Returns the reason of the target temperature
@@ -1680,6 +1695,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
# issue #1958 - when window_action=fan_only temporarily switches the mode to FAN_ONLY,
# the user's intent (requested_state) is still COOL, so we must use AC presets.
or (self.vtherm_hvac_mode == VThermHvacMode_FAN_ONLY and self.requested_state.hvac_mode == VThermHvacMode_COOL)
# when auto-start/stop temporarily switches the mode to DRY as its stop mode,
# the user's intent (requested_state) is still COOL, so we must use AC presets.
or (self.vtherm_hvac_mode == VThermHvacMode_DRY and self.requested_state.hvac_mode == VThermHvacMode_COOL)
# (self.is_over_switch and self._ac_mode)
# or self.vtherm_hvac_mode == VThermHvacMode_COOL
# or (self.vtherm_hvac_mode == VThermHvacMode_OFF and self.requested_state.hvac_mode == VThermHvacMode_COOL)
@@ -1733,6 +1751,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"""Set the reason of hvac_off"""
self._hvac_off_reason = hvac_off_reason
def set_hvac_mode_reason(self, hvac_mode_reason: str | None):
"""Set the reason why the current hvac_mode is forced"""
self._hvac_mode_reason = hvac_mode_reason
def set_temperature_reason(self, temperature_reason: str | None):
"""Set the reason of temperature"""
self._temperature_reason = temperature_reason
@@ -1823,6 +1845,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"ema_temp": self._ema_temp,
"temperature_slope": round(self.last_temperature_slope or 0, 3),
"hvac_off_reason": self.hvac_off_reason,
"hvac_mode_reason": self.hvac_mode_reason,
ATTR_TOTAL_ENERGY: self.total_energy,
"last_change_time_from_vtherm": (
self._last_change_time_from_vtherm.astimezone(self._current_tz).isoformat() if self._last_change_time_from_vtherm is not None else None
@@ -2134,7 +2157,13 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"This thermostat does not use TPI algorithm."
)
async def service_set_auto_tpi_mode(self, auto_tpi_mode: bool):
async def service_set_auto_tpi_mode(
self,
auto_tpi_mode: bool,
reinitialise: bool = True,
allow_kint_boost_on_stagnation: bool = False,
allow_kext_compensation_on_overshoot: bool = False,
):
"""Stub method for Auto TPI mode service on non-TPI thermostats.
This service is only available for switch/valve type thermostats that use TPI algorithm.
@@ -50,13 +50,14 @@ DOMAIN = "versatile_thermostat"
# The order is important.
PLATFORMS: list[Platform] = [
Platform.SELECT,
Platform.CLIMATE,
Platform.SENSOR,
# Number should be after CLIMATE
Platform.NUMBER,
Platform.BINARY_SENSOR,
Platform.SWITCH,
# Select should be after CLIMATE
Platform.SELECT,
]
CONF_UNDERLYING_LIST = "underlying_entity_ids"
@@ -244,7 +245,18 @@ TYPE_AUTO_START_STOP_LEVELS = Literal[ # pylint: disable=invalid-name
AUTO_START_STOP_LEVEL_NONE,
]
# The hvac_mode applied when the auto-start/stop feature detects a stop condition
AUTO_START_STOP_STOP_MODE_OFF = str(VThermHvacMode_OFF)
AUTO_START_STOP_STOP_MODE_FAN_ONLY = str(VThermHvacMode_FAN_ONLY)
AUTO_START_STOP_STOP_MODE_DRY = str(VThermHvacMode_DRY)
AUTO_START_STOP_STOP_MODES = [
AUTO_START_STOP_STOP_MODE_OFF,
AUTO_START_STOP_STOP_MODE_FAN_ONLY,
AUTO_START_STOP_STOP_MODE_DRY,
]
HVAC_OFF_REASON_NAME = "hvac_off_reason"
HVAC_MODE_REASON_NAME = "hvac_mode_reason"
HVAC_OFF_REASON_MANUAL = "hvac_off_manual"
HVAC_OFF_REASON_AUTO_START_STOP = "hvac_off_auto_start_stop"
HVAC_OFF_REASON_WINDOW_DETECTION = "hvac_off_window_detection"
@@ -255,6 +267,15 @@ HVAC_OFF_REASONS = Literal[ # pylint: disable=invalid-name
HVAC_OFF_REASON_MANUAL, HVAC_OFF_REASON_AUTO_START_STOP, HVAC_OFF_REASON_WINDOW_DETECTION, HVAC_OFF_REASON_SLEEP_MODE, HVAC_OFF_REASON_SAFETY
]
# The hvac_mode_reason set when the auto-start/stop feature applies a non-off stop mode
HVAC_MODE_REASON_AUTO_START_STOP_FAN_ONLY = "hvac_fan_only_auto_start_stop"
HVAC_MODE_REASON_AUTO_START_STOP_DRY = "hvac_dry_auto_start_stop"
# Maps the stop mode applied by the auto-start/stop feature to the related hvac_mode_reason
AUTO_START_STOP_HVAC_MODE_REASONS = {
AUTO_START_STOP_STOP_MODE_FAN_ONLY: HVAC_MODE_REASON_AUTO_START_STOP_FAN_ONLY,
AUTO_START_STOP_STOP_MODE_DRY: HVAC_MODE_REASON_AUTO_START_STOP_DRY,
}
DEFAULT_SHORT_EMA_PARAMS = {
"max_alpha": 0.5,
# In sec
@@ -502,7 +523,7 @@ ATTR_TOTAL_ENERGY = "total_energy"
ATTR_MEAN_POWER_CYCLE = "mean_cycle_power"
AUTO_FAN_DTEMP_THRESHOLD = 2
AUTO_FAN_DEACTIVATED_MODES = ["mute", "quiet", "low", "quiet", "1", "auto"]
AUTO_FAN_DEACTIVATED_MODES = ["mute", "quiet", "low", "quiet", "1", "one", "speed_1", "auto"]
CENTRAL_CONFIG_NAME = "Central configuration"
@@ -32,12 +32,8 @@ class FeatureAutoStartStopManager(BaseFeatureManager):
unrecorded_attributes = frozenset(
{
"auto_start_stop_level",
"auto_start_stop_dtmin",
"auto_start_stop_enable",
"auto_start_stop_accumulated_error",
"auto_start_stop_accumulated_error_threshold",
"auto_start_stop_last_switch_date",
"is_auto_start_stop_configured",
"auto_start_stop_manager",
}
)
@@ -50,6 +46,7 @@ class FeatureAutoStartStopManager(BaseFeatureManager):
self._is_configured: bool = False
self._is_auto_start_stop_enabled: bool = False
self._is_auto_stop_detected: bool = False
self._stop_mode: str = AUTO_START_STOP_STOP_MODE_OFF
@overrides
def post_init(self, entry_infos: ConfigData):
@@ -126,7 +123,7 @@ class FeatureAutoStartStopManager(BaseFeatureManager):
"type": "stop",
"name": self.name,
"cause": "Auto stop conditions reached",
"hvac_mode": str(VThermHvacMode_OFF),
"hvac_mode": str(self.stop_mode),
"saved_hvac_mode": str(self._vtherm.requested_state.hvac_mode),
"target_temperature": self._vtherm.target_temperature,
"current_temperature": self._vtherm.current_temperature,
@@ -207,6 +204,25 @@ class FeatureAutoStartStopManager(BaseFeatureManager):
self._vtherm.update_custom_attributes()
async def set_auto_start_stop_stop_mode(self, stop_mode: str):
"""Set the hvac_mode to apply when a stop is detected (off/fan_only/dry).
If a stop is currently active, the new mode is applied immediately
while the stop state is kept."""
if self._stop_mode == stop_mode:
return
write_event_log(_LOGGER, self._vtherm, f"Auto start/stop stop mode changed from {self._stop_mode} to {stop_mode}")
self._stop_mode = stop_mode
# If a stop is currently active, re-evaluate the state so the hvac_mode
# reflects the new choice immediately while keeping the stop state.
if self._is_auto_stop_detected:
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(force=True)
self._vtherm.update_custom_attributes()
@callback
@overrides
def restore_state(self, old_state) -> None:
@@ -272,6 +288,7 @@ class FeatureAutoStartStopManager(BaseFeatureManager):
"auto_start_stop_accumulated_error_threshold": self._auto_start_stop_algo.accumulated_error_threshold,
"auto_start_stop_last_switch_date": self._auto_start_stop_algo.last_switch_date,
"is_auto_stop_detected": self.is_auto_stop_detected,
"auto_start_stop_stop_mode": self._stop_mode,
}
}
)
@@ -302,6 +319,11 @@ class FeatureAutoStartStopManager(BaseFeatureManager):
"""Returns the auto_start_stop_enable"""
return self._is_auto_start_stop_enabled
@property
def stop_mode(self) -> VThermHvacMode:
"""Return the hvac_mode to apply when a stop is detected (off/fan_only/dry)"""
return VThermHvacMode(self._stop_mode)
@property
def is_auto_stopped(self) -> bool:
"""Returns the is vtherm is stopped and reason is AUTO_START_STOP"""
@@ -54,12 +54,8 @@ class FeatureHeatingFailureDetectionManager(BaseFeatureManager):
unrecorded_attributes = frozenset(
{
"heating_failure_threshold",
"cooling_failure_threshold",
"heating_failure_detection_delay",
"temperature_change_tolerance",
"is_heating_failure_detection_configured",
"failure_detection_enable_template",
"heating_failure_detection_manager",
}
)
@@ -21,6 +21,14 @@ _LOGGER = get_vtherm_logger(__name__)
class FeatureLockManager(BaseFeatureManager):
""" The implementation of the Lock Feature Manager for Versatile Thermostat """
unrecorded_attributes = frozenset(
{
"is_lock_configured",
"lock_manager",
}
)
def __init__(self, vtherm: Any, hass: HomeAssistant):
"""Initialize the FeatureLockManager."""
super().__init__(vtherm, hass)
@@ -42,12 +42,8 @@ class FeatureMotionManager(BaseFeatureManager):
unrecorded_attributes = frozenset(
{
"motion_sensor_entity_id",
"is_motion_configured",
"motion_delay_sec",
"motion_off_delay_sec",
"motion_preset",
"no_motion_preset",
"motion_manager",
}
)
@@ -31,13 +31,8 @@ class FeaturePowerManager(BaseFeatureManager):
unrecorded_attributes = frozenset(
{
"power_sensor_entity_id",
"max_power_sensor_entity_id",
"is_power_configured",
"device_power",
"power_temp",
"current_power",
"current_max_power",
"power_manager",
}
)
@@ -38,8 +38,8 @@ class FeaturePresenceManager(BaseFeatureManager):
unrecorded_attributes = frozenset(
{
"presence_sensor_entity_id",
"is_presence_configured",
"presence_manager",
}
)
@@ -38,6 +38,7 @@ class FeatureRepairIncorrectStateManager(BaseFeatureManager):
unrecorded_attributes = frozenset(
{
"is_repair_incorrect_state_configured",
"repair_incorrect_state_manager",
}
)
@@ -32,10 +32,8 @@ class FeatureSafetyManager(BaseFeatureManager):
unrecorded_attributes = frozenset(
{
"safety_delay_min",
"safety_min_on_percent",
"safety_default_on_percent",
"is_safety_configured",
"safety_manager",
}
)
@@ -43,15 +43,8 @@ class FeatureWindowManager(BaseFeatureManager):
unrecorded_attributes = frozenset(
{
"window_sensor_entity_id",
"is_window_configured",
"window_delay_sec",
"window_off_delay_sec",
"window_auto_configured",
"window_auto_open_threshold",
"window_auto_close_threshold",
"window_auto_max_duration",
"window_action",
"window_manager",
}
)
@@ -134,7 +127,8 @@ class FeatureWindowManager(BaseFeatureManager):
# Try to get last window bypass state
old_state = await self._vtherm.async_get_last_state()
self._is_window_bypass = old_state is not None and hasattr(old_state, "attributes") and old_state.attributes.get("is_window_bypass") is True
old_attributes = getattr(old_state, "attributes", None) or {}
self._is_window_bypass = (old_attributes.get("window_manager") or {}).get("is_window_bypass") is True
if self._is_configured:
self.stop_listening()
@@ -22,6 +22,6 @@
"vtherm_api>=0.3.0"
],
"ssdp": [],
"version": "10.0.2",
"version": "10.1.0",
"zeroconf": []
}
@@ -86,8 +86,11 @@ class PITemperatureRegulator:
# Calculate the sum of error (I)
# Discussion #384. Finally don't reset the accumulated error but smoothly reset it if the sign is inversed
# If the error have change its sign, reset smoothly the accumulated error
# The divisor is clamped so that a fractional cycle (time_delta < 0.5), which happens when
# the regulation is triggered twice in quick succession (e.g. repeated target changes),
# can never amplify the accumulated error instead of decaying it.
if self.overheat_protection and error * self.accumulated_error < 0:
self.accumulated_error = self.accumulated_error / (2.0 * time_delta)
self.accumulated_error = self.accumulated_error / (2.0 * max(time_delta, 0.5))
self.accumulated_error += error * time_delta
@@ -4,8 +4,9 @@
import logging
from vtherm_api.log_collector import get_vtherm_logger
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.const import EntityCategory
from homeassistant.components.select import SelectEntity
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
from homeassistant.config_entries import ConfigEntry
@@ -18,14 +19,22 @@ from custom_components.versatile_thermostat.base_thermostat import (
from custom_components.versatile_thermostat.vtherm_central_api import VersatileThermostatAPI
from .base_entity import VersatileThermostatBaseEntity
from .const import (
DOMAIN,
DEVICE_MANUFACTURER,
CONF_NAME,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
CONF_THERMOSTAT_CLIMATE,
CONF_USE_AUTO_START_STOP_FEATURE,
CENTRAL_MODE_AUTO,
CENTRAL_MODES,
AUTO_START_STOP_STOP_MODE_OFF,
AUTO_START_STOP_STOP_MODE_FAN_ONLY,
AUTO_START_STOP_STOP_MODE_DRY,
AUTO_START_STOP_STOP_MODES,
overrides,
)
from .commons import write_event_log
@@ -44,14 +53,16 @@ async def async_setup_entry(
_LOGGER.debug("%s - Calling async_setup_entry entry=%s, data=%s", name, entry.entry_id, entry.data)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
if vt_type != CONF_THERMOSTAT_CENTRAL_CONFIG:
return
entities = []
entities = [
CentralModeSelect(hass, unique_id, name, entry.data),
]
if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG:
entities.append(CentralModeSelect(hass, unique_id, name, entry.data))
elif vt_type == CONF_THERMOSTAT_CLIMATE:
if entry.data.get(CONF_USE_AUTO_START_STOP_FEATURE) is True:
entities.append(AutoStartStopStopModeSelect(hass, unique_id, name, entry.data))
async_add_entities(entities, True)
if entities:
async_add_entities(entities, True)
class CentralModeSelect(SelectEntity, RestoreEntity):
@@ -124,3 +135,111 @@ class CentralModeSelect(SelectEntity, RestoreEntity):
def __str__(self) -> str:
return f"VersatileThermostat-{self.name}"
class AutoStartStopStopModeSelect(
VersatileThermostatBaseEntity, SelectEntity, RestoreEntity
):
"""Representation of the hvac_mode applied when the auto-start/stop
feature detects a stop condition (off, fan_only or dry)."""
def __init__(
self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigData
):
"""Initialize the auto-start/stop stop mode select"""
super().__init__(hass, unique_id, name)
self._attr_name = "Auto start/stop stop mode"
self._attr_unique_id = f"{self._device_name}_auto_start_stop_stop_mode"
self._attr_translation_key = "auto_start_stop_stop_mode"
self._attr_entity_category = EntityCategory.CONFIG
self._attr_current_option = AUTO_START_STOP_STOP_MODE_OFF
@property
def icon(self) -> str | None:
"""The icon"""
return "mdi:hvac"
@property
def options(self) -> list[str]:
"""The available options, computed from the underlying supported hvac_modes"""
return self._build_options()
def _build_options(self) -> list[str]:
"""Build the available options from the underlying supported hvac_modes.
fan_only and dry are only proposed if the underlying supports them."""
options = [AUTO_START_STOP_STOP_MODE_OFF]
climate = self.my_climate
if climate is not None:
hvac_modes = climate.hvac_modes
if AUTO_START_STOP_STOP_MODE_FAN_ONLY in hvac_modes:
options.append(AUTO_START_STOP_STOP_MODE_FAN_ONLY)
if AUTO_START_STOP_STOP_MODE_DRY in hvac_modes:
options.append(AUTO_START_STOP_STOP_MODE_DRY)
return options
@callback
def my_climate_is_initialized(self):
"""Called when the associated climate is resolved -> refresh the options.
The restored option is only validated once the VTherm is fully
initialized (its underlying hvac_modes are available)."""
self._refresh_current_option()
self.hass.create_task(self.update_my_state_and_vtherm())
def _refresh_current_option(self):
"""Reset the current option to off only once the VTherm is fully
initialized and its underlying does not support the current option.
While the VTherm is not initialized, the restored option is kept as is
to avoid discarding it before the underlying hvac_modes are available."""
climate = self.my_climate
if climate is None or not climate.is_initialized:
return
if self._attr_current_option not in self.options:
self._attr_current_option = AUTO_START_STOP_STOP_MODE_OFF
@overrides
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate changes -> refresh the available options.
The underlying supported hvac_modes may become available only after
the VTherm has adopted them, so the options must be recomputed."""
if self.my_climate is None:
return
self._refresh_current_option()
self.async_write_ha_state()
@overrides
async def async_added_to_hass(self):
# Restore the persisted value before looking for the climate so that
# my_climate_is_initialized validates the options against it.
last_state = await self.async_get_last_state()
if last_state is not None and last_state.state in AUTO_START_STOP_STOP_MODES:
self._attr_current_option = last_state.state
await super().async_added_to_hass()
await self.update_my_state_and_vtherm()
async def update_my_state_and_vtherm(self):
"""Update the stop mode in my VTherm auto-start/stop manager"""
self.async_write_ha_state()
if (
self.my_climate is not None
and self.my_climate.auto_start_stop_manager is not None
):
await self.my_climate.auto_start_stop_manager.set_auto_start_stop_stop_mode(self._attr_current_option)
@overrides
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
if option == self._attr_current_option:
return
if option in self.options:
write_event_log(_LOGGER, self, f"Auto start/stop stop mode is being changed from {self._attr_current_option} to {option}")
self._attr_current_option = option
await self.update_my_state_and_vtherm()
@overrides
def select_option(self, option: str) -> None:
"""Change the selected option"""
self.hass.create_task(self.async_select_option(option))
@@ -13,6 +13,7 @@ from .const import (
HVAC_OFF_REASON_AUTO_START_STOP,
HVAC_OFF_REASON_SLEEP_MODE,
HVAC_OFF_REASON_CENTRAL_MODE,
AUTO_START_STOP_HVAC_MODE_REASONS,
CONF_WINDOW_ECO_TEMP,
CONF_WINDOW_FAN_ONLY,
CONF_WINDOW_FROST_TEMP,
@@ -112,13 +113,16 @@ class StateManager:
if vtherm.last_central_mode == CENTRAL_MODE_STOPPED:
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_CENTRAL_MODE)
vtherm.set_hvac_mode_reason(HVAC_OFF_REASON_CENTRAL_MODE)
elif vtherm.safety_manager.is_safety_detected and (vtherm.is_over_climate or vtherm.safety_manager.safety_default_on_percent <= 0.0):
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_SAFETY)
vtherm.set_hvac_mode_reason(HVAC_OFF_REASON_SAFETY)
# then check if window is open
elif vtherm.window_manager.is_window_detected and self._requested_state.hvac_mode != VThermHvacMode_OFF:
vtherm.set_hvac_mode_reason(HVAC_OFF_REASON_WINDOW_DETECTION)
if vtherm.window_manager.window_action == CONF_WINDOW_FAN_ONLY and VThermHvacMode_FAN_ONLY in vtherm.vtherm_hvac_modes:
self._current_state.set_hvac_mode(VThermHvacMode_FAN_ONLY)
elif vtherm.window_manager.window_action == CONF_WINDOW_TURN_OFF or (
@@ -128,10 +132,17 @@ class StateManager:
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_WINDOW_DETECTION)
elif vtherm.auto_start_stop_manager and vtherm.auto_start_stop_manager.is_auto_stop_detected and self._requested_state.hvac_mode != VThermHvacMode_OFF:
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_AUTO_START_STOP)
stop_mode = vtherm.auto_start_stop_manager.stop_mode
if stop_mode != VThermHvacMode_OFF and stop_mode in vtherm.vtherm_hvac_modes:
self._current_state.set_hvac_mode(stop_mode)
vtherm.set_hvac_mode_reason(AUTO_START_STOP_HVAC_MODE_REASONS.get(str(stop_mode), HVAC_OFF_REASON_AUTO_START_STOP))
else:
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_AUTO_START_STOP)
vtherm.set_hvac_mode_reason(HVAC_OFF_REASON_AUTO_START_STOP)
elif vtherm.last_central_mode == CENTRAL_MODE_COOL_ONLY and self._requested_state.hvac_mode != VThermHvacMode_OFF:
vtherm.set_hvac_mode_reason(HVAC_OFF_REASON_CENTRAL_MODE)
if VThermHvacMode_COOL in vtherm.vtherm_hvac_modes:
self._current_state.set_hvac_mode(VThermHvacMode_COOL)
else:
@@ -139,6 +150,7 @@ class StateManager:
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
elif vtherm.last_central_mode == CENTRAL_MODE_HEAT_ONLY and self._requested_state.hvac_mode != VThermHvacMode_OFF:
vtherm.set_hvac_mode_reason(HVAC_OFF_REASON_CENTRAL_MODE)
if VThermHvacMode_HEAT in vtherm.vtherm_hvac_modes:
self._current_state.set_hvac_mode(VThermHvacMode_HEAT)
else:
@@ -146,6 +158,7 @@ class StateManager:
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
elif vtherm.last_central_mode == CENTRAL_MODE_FROST_PROTECTION and self._requested_state.hvac_mode != VThermHvacMode_OFF:
vtherm.set_hvac_mode_reason(HVAC_OFF_REASON_CENTRAL_MODE)
preset_modes = vtherm.vtherm_preset_modes
if preset_modes is None or VThermPreset.FROST not in preset_modes or VThermHvacMode_HEAT not in vtherm.vtherm_hvac_modes:
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
@@ -157,7 +170,11 @@ class StateManager:
else:
if self._current_state.hvac_mode == VThermHvacMode_OFF and self._requested_state.hvac_mode == VThermHvacMode_OFF:
_LOGGER.info("%s - already in OFF. Change the reason to MANUAL", vtherm)
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL if not vtherm.is_sleeping else HVAC_OFF_REASON_SLEEP_MODE)
reason = HVAC_OFF_REASON_MANUAL if not vtherm.is_sleeping else HVAC_OFF_REASON_SLEEP_MODE
vtherm.set_hvac_off_reason(reason)
vtherm.set_hvac_mode_reason(reason)
else:
vtherm.set_hvac_mode_reason(None)
self._current_state.set_hvac_mode(self._requested_state.hvac_mode)
@@ -166,6 +183,7 @@ class StateManager:
vtherm.set_hvac_off_reason(None)
elif self._current_state.hvac_mode == VThermHvacMode_SLEEP:
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_SLEEP_MODE)
vtherm.set_hvac_mode_reason(HVAC_OFF_REASON_SLEEP_MODE)
return self._current_state.is_hvac_mode_changed
@@ -933,6 +933,16 @@
"boost_ac_away_temp": {
"name": "Boost ac away"
}
},
"select": {
"auto_start_stop_stop_mode": {
"name": "Auto start/stop stop mode",
"state": {
"off": "Off",
"fan_only": "Fan only",
"dry": "Dry"
}
}
}
},
"services": {
@@ -174,6 +174,29 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
)
return
if self.vtherm_hvac_mode not in (VThermHvacMode_HEAT, VThermHvacMode_COOL):
_LOGGER.debug(
"%s - auto-regulation is disabled cause VTherm hvac_mode is %s (not heat nor cool). Sending the raw target temperature",
self,
self.vtherm_hvac_mode,
)
# Outside of heat/cool the device is active but must receive the original
# (non regulated) target temperature. Force the regulated temperature to the
# target temperature and send it to all underlyings only when it differs from
# the last sent value, to avoid resending the same setpoint on each cycle.
self._regulated_target_temp = self.target_temperature
for under in self._underlyings:
if under.last_sent_temperature == self.target_temperature:
continue
await under.set_temperature(
self.target_temperature,
self._attr_max_temp,
self._attr_min_temp,
)
# Reset the timer of last regulation change to avoid time delta too high
self._last_regulation_change = self.now
return
_LOGGER.info(
"%s - Calling ThermostatClimate._send_regulated_temperature force=%s",
self,
@@ -416,8 +439,10 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
DOMAIN,
)
if not self._regulation_algo:
# A default empty algo (which does nothing)
if self._auto_regulation_mode == CONF_AUTO_REGULATION_NONE or not self._regulation_algo:
# A default empty algo (which does nothing). It must also replace any
# previously active algo when switching to None at runtime, else the
# old regulator keeps sending regulated setpoints to the underlyings.
self._regulation_algo = PITemperatureRegulator(self.target_temperature, 0, 0, 0, 0, 0, True)
def choose_auto_fan_mode(self, auto_fan_mode: str):
@@ -440,8 +465,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"."""
for val in ["low", "1"]:
"""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"]:
if find_fan_mode(fan_modes, val):
return True
return False
@@ -453,6 +478,10 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
index = speed_modes.index("low")
elif "1" in speed_modes:
index = speed_modes.index("1")
elif "one" in speed_modes:
index = speed_modes.index("one")
elif "speed_1" in speed_modes:
index = speed_modes.index("speed_1")
if index > -1 and index >= len(speed_modes) / 2:
speed_modes.reverse()
@@ -300,7 +300,8 @@
"heater_cooling_time": "Auskühlzeit (min)",
"auto_tpi_heating_rate": "Aufheizgeschwindigkeit ({unit}/h)",
"auto_tpi_aggressiveness": "Aggressivität",
"auto_tpi_enable_advanced_settings": "Erweiterte Einstellungen aktivieren"
"auto_tpi_enable_advanced_settings": "Erweiterte Einstellungen aktivieren",
"auto_tpi_continuous_kext": "Kontinuierliches Kext-lernen"
},
"data_description": {
"auto_tpi_learning_type": "Wähle 'Erkundung' für den ersten Start (gewichteter Durchschnitt, Gewicht 1) oder 'Feinabstimmung' für kontinuierliche Anpassungen (EWMA, Alpha 0,08).",
@@ -308,7 +309,8 @@
"heater_cooling_time": "Abkühlzeit nach dem Ausschalten (Minuten)\n\n| Typ | Aufheizzeit | Abkühlzeit |\n| :--- | :--- | :--- |\n| Elektroheizkörper | 5 Min. | 7 Min. |\n| Wasserheizkörper | 15 Min. | 20 Min. |\n| Fußbodenheizung | 30 Min. | 45 Min. |",
"auto_tpi_heating_rate": "Temperaturanstiegskapazität des Heizkörpers ({unit} pro Stunde). Kann über den Aktionsdienst 'Kapazität kalibrieren' geschätzt werden.",
"auto_tpi_aggressiveness": "Skalierungsfaktor für die gelernten Koeffizienten (50-100 %). Niedrigere Werte führen zu konservativeren Koeffizienten und verringern das Risiko einer Überschreitung des Sollwerts.",
"auto_tpi_enable_advanced_settings": "Aktivieren Sie diese Option, um die Feinabstimmung der Parameter (Alpha, Abklingen, Gewicht) vorzunehmen."
"auto_tpi_enable_advanced_settings": "Option anwählen, um die Feinabstimmung der Parameter (Alpha, Abklingen, Gewicht) vorzunehmen.",
"auto_tpi_continuous_kext": "Kontrollkästchen aktivieren, um das kontinuierliche Lernen des externen Koeffizienten (Kext) außerhalb von AutoTPI-Sitzungen zu ermöglichen."
}
},
"auto_tpi_avg_settings": {
@@ -326,11 +328,13 @@
"description": "Parameter für die EWMA-Methode.\n\nEmpfehlungen :\n| Situation | Alpha (ema_alpha) | Abklingrate (ema_decay_rate) |\n| :--- | :--- | :--- |\n| Lernstart | 0.15 | 0.08 |\n| Lernabschluß | 0.08 | 0.12 |\n| Kontinuierliches Lernen | 0.05 | 0.02 |",
"data": {
"auto_tpi_ema_alpha": "Alpha",
"auto_tpi_ema_decay_rate": "Rückgangsrate"
"auto_tpi_ema_decay_rate": "Rückgangsrate",
"auto_tpi_continuous_kext_alpha": "Continuous Kext Alpha"
},
"data_description": {
"auto_tpi_ema_alpha": "Glättungsfaktor (0-1). Höher = schnellere Anpassung",
"auto_tpi_ema_decay_rate": "Rate, mit der Alpha im Laufe der Zeit abnimmt (Stabilisierung)"
"auto_tpi_ema_decay_rate": "Rate, mit der Alpha im Laufe der Zeit abnimmt (Stabilisierung)",
"auto_tpi_continuous_kext_alpha": "Smoothing factor for continuous Kext learning (Alpha). Default 0.04 (approx 3-5 days adaptation)."
}
},
"sync_device_internal_temp": {
@@ -698,7 +702,8 @@
"heater_heating_time": "Aufheizzeit (min)",
"heater_cooling_time": "Abkühlzeit (min)",
"auto_tpi_aggressiveness": "Aggressivität",
"auto_tpi_enable_advanced_settings": "Erweiterte Einstellungen aktivieren"
"auto_tpi_enable_advanced_settings": "Erweiterte Einstellungen aktivieren",
"auto_tpi_continuous_kext": "Kontinuierliches Kext-lernen"
},
"data_description": {
"auto_tpi_learning_type": "Wähle 'Erkundung' für den ersten Start (gewichteter Durchschnitt, Gewicht 1) oder 'Feinabstimmung' für kontinuierliche Anpassungen (EWMA, Alpha 0,08).",
@@ -706,7 +711,8 @@
"heater_heating_time": "Zeit bis zum Erreichen der vollen Leistung (Minuten)",
"heater_cooling_time": "Abkühlzeit nach dem Ausschalten (Minuten)\n\n| Typ | Aufheizzeit | Abkühlzeit |\n| :--- | :--- | :--- |\n| Elektroheizkörper | 5 Min. | 7 Min. |\n| Wasserheizkörper | 15 Min. | 20 Min. |\n| Fußbodenheizung | 30 Min. | 45 Min. |",
"auto_tpi_aggressiveness": "Skalierungsfaktor für die gelernten Koeffizienten (50100 %). Niedrigere Werte führen zu konservativeren Koeffizienten und verringern das Risiko einer Überschreitung des Sollwerts.",
"auto_tpi_enable_advanced_settings": "Kontrollkästchen aktivieren, um die Parameter des ausgewählten Algorithmus zu ändern."
"auto_tpi_enable_advanced_settings": "Kontrollkästchen aktivieren, um die Parameter des ausgewählten Algorithmus zu ändern.",
"auto_tpi_continuous_kext": "Kontrollkästchen aktivieren, um das kontinuierliche Lernen des externen Koeffizienten (Kext) außerhalb von AutoTPI-Sitzungen zu ermöglichen."
}
},
"auto_tpi_avg_settings": {
@@ -724,11 +730,13 @@
"description": "Parameter für die EWMA-Methode.\n\nEmpfehlungen:\n| Situation | Alpha (ema_alpha) | Abklingrate (ema_decay_rate) |\n| :--- | :--- | :--- |\n| Anfängliches Lernen | 0,15 | 0,08 |\n| Endgültiges Lernen | 0,08 | 0,12 |\n| Kontinuierliches Lernen | 0,05 | 0,02 |",
"data": {
"auto_tpi_ema_alpha": "Alpha",
"auto_tpi_ema_decay_rate": "Rückgangsrate"
"auto_tpi_ema_decay_rate": "Rückgangsrate",
"auto_tpi_continuous_kext_alpha": "Kontinuierliches Kext Alpha"
},
"data_description": {
"auto_tpi_ema_alpha": "Glättungsfaktor (0-1). Höher = schnellere Anpassung",
"auto_tpi_ema_decay_rate": "Rate, mit der Alpha im Laufe der Zeit abnimmt (Stabilisierung)"
"auto_tpi_ema_decay_rate": "Rate, mit der Alpha im Laufe der Zeit abnimmt (Stabilisierung)",
"auto_tpi_continuous_kext_alpha": "Glättungsfaktor für kontinuierliches Kext-Lernen (Alpha). Standardwert 0,04 (Anpassungszeit von ca. 35 Tagen)."
}
},
"heating_failure_detection": {
@@ -776,7 +784,7 @@
"options": {
"thermostat_central_config": "Zentrale Konfiguration",
"thermostat_over_switch": "Thermostat an einem Schalter",
"thermostat_over_climate": "Thermostat an einem Thermostat",
"thermostat_over_climate": "Thermostat mittels Thermostat/Klimaanlage",
"thermostat_over_valve": "Thermostat an einem Ventil"
}
},
@@ -1013,6 +1021,24 @@
"description": "Der optionale Sperrcode"
}
}
},
"download_logs": {
"name": "Download logs",
"description": "Gefilterte Protokolle für eine VTherm-Entität erfassen und herunterladen.",
"fields": {
"log_level": {
"name": "Log level",
"description": "Mindestprotokollstufe für die Erfassung"
},
"period_start": {
"name": "Period start",
"description": "Erfassungszeitraum Start"
},
"period_end": {
"name": "Period end",
"description": "Erfassungszeitraum Ende"
}
}
}
},
"exceptions": {
@@ -933,6 +933,16 @@
"boost_ac_away_temp": {
"name": "Boost ac away"
}
},
"select": {
"auto_start_stop_stop_mode": {
"name": "Auto start/stop stop mode",
"state": {
"off": "Off",
"fan_only": "Fan only",
"dry": "Dry"
}
}
}
},
"services": {
@@ -935,6 +935,16 @@
"boost_ac_away_temp": {
"name": "Boost clim abs"
}
},
"select": {
"auto_start_stop_stop_mode": {
"name": "Mode d'arrêt auto start/stop",
"state": {
"off": "Arrêt",
"fan_only": "Ventilation seule",
"dry": "Déshumidification"
}
}
}
},
"services": {
@@ -860,8 +860,15 @@ class UnderlyingClimate(UnderlyingEntity):
_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.supported_features:
# Issue 807 add TARGET_TEMPERATURE only if in the features.
# Use a bitwise test (not the `in` operator): self.supported_features is read
# from the underlying entity's state attributes, where Home Assistant may store
# it as a plain int (e.g. for restored states). `EnumMember in <int>` raises
# `TypeError: argument of type 'int' is not a container or iterable`, whereas
# the bitwise `&` works for both a plain int and a ClimateEntityFeature IntFlag.
# This matches the bitwise pattern already used elsewhere in this class
# (fan_modes / swing_modes).
if self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
data.update(
{
"target_temp_high": target_temp,
@@ -869,7 +876,7 @@ class UnderlyingClimate(UnderlyingEntity):
}
)
if ClimateEntityFeature.TARGET_TEMPERATURE in self.supported_features:
if self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE:
data["temperature"] = target_temp
try: