257 files
This commit is contained in:
@@ -7,6 +7,7 @@ from typing import Optional, Any, Generic
|
||||
from datetime import datetime
|
||||
from collections.abc import Callable
|
||||
|
||||
from vtherm_api import ValveDiagnosticState
|
||||
from vtherm_api.log_collector import get_vtherm_logger
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
@@ -54,7 +55,7 @@ from .commons_type import ConfigData
|
||||
from .config_schema import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||
|
||||
from .vtherm_central_api import VersatileThermostatAPI
|
||||
from .underlyings import UnderlyingEntity, T
|
||||
from .underlyings import UnderlyingEntity, UnderlyingValve, T
|
||||
|
||||
from .ema import ExponentialMovingAverage
|
||||
|
||||
@@ -252,6 +253,13 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
"""Register a manager"""
|
||||
self._managers.append(manager)
|
||||
|
||||
def get_feature_manager(self, name: str):
|
||||
"""Return the newest manager registered under a stable name."""
|
||||
for manager in reversed(self._managers):
|
||||
if getattr(manager, "name", None) == name:
|
||||
return manager
|
||||
return None
|
||||
|
||||
def _load_external_feature_managers(self):
|
||||
"""Instantiate feature managers provided by external plugins.
|
||||
|
||||
@@ -1006,6 +1014,24 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
"""Return current operation."""
|
||||
return self._state_manager.current_state.hvac_mode
|
||||
|
||||
@property
|
||||
def requested_hvac_mode(self) -> VThermHvacMode | None:
|
||||
"""Return the mode requested by the user before feature overrides."""
|
||||
return self._state_manager.requested_state.hvac_mode
|
||||
|
||||
@property
|
||||
def valve_diagnostics(self) -> tuple[ValveDiagnosticState, ...]:
|
||||
"""Expose valve commanded and observed states without leaking underlyings."""
|
||||
return tuple(
|
||||
ValveDiagnosticState(
|
||||
entity_id=underlying.entity_id,
|
||||
should_be_active=underlying.should_device_be_active,
|
||||
is_active=underlying.is_device_active,
|
||||
)
|
||||
for underlying in self._underlyings
|
||||
if isinstance(underlying, UnderlyingValve)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_recalculate_scheduled(self) -> bool:
|
||||
"""Return true if a recalculation is scheduled."""
|
||||
@@ -1547,6 +1573,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
self._state_manager.requested_state.set_hvac_mode(hvac_mode)
|
||||
await self.update_states(force=False)
|
||||
|
||||
def get_underlying_hvac_mode(self, hvac_mode: VThermHvacMode) -> VThermHvacMode:
|
||||
"""Return the physical HVAC mode to apply to underlyings."""
|
||||
return hvac_mode
|
||||
|
||||
@overrides
|
||||
@check_lock
|
||||
async def async_set_preset_mode(self, preset_mode: str):
|
||||
@@ -1776,8 +1806,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
exc,
|
||||
)
|
||||
|
||||
# Check for heating/cooling failures (only for TPI VTherms)
|
||||
await self._heating_failure_detection_manager.refresh_state()
|
||||
# Preserve the legacy manager until existing entries have migrated, but
|
||||
# let an external manager with the same stable name take precedence.
|
||||
if "heating_failure_detection" not in self._external_manager_names:
|
||||
await self._heating_failure_detection_manager.refresh_state()
|
||||
|
||||
# Check and repair state discrepancies
|
||||
await self._repair_incorrect_state_manager.check_and_repair()
|
||||
@@ -2054,6 +2086,24 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
},
|
||||
}
|
||||
|
||||
if self.is_over_climate and self._ac_mode:
|
||||
self._attr_extra_state_attributes["preset_temperatures"].update(
|
||||
{
|
||||
"eco_cool_temp": self._presets.get(VThermPreset.ECO + PRESET_AC_SUFFIX, 0),
|
||||
"boost_cool_temp": self._presets.get(VThermPreset.BOOST + PRESET_AC_SUFFIX, 0),
|
||||
"comfort_cool_temp": self._presets.get(VThermPreset.COMFORT + PRESET_AC_SUFFIX, 0),
|
||||
"eco_cool_away_temp": self._presets_away.get(
|
||||
self.get_preset_away_name(VThermPreset.ECO + PRESET_AC_SUFFIX), 0
|
||||
),
|
||||
"boost_cool_away_temp": self._presets_away.get(
|
||||
self.get_preset_away_name(VThermPreset.BOOST + PRESET_AC_SUFFIX), 0
|
||||
),
|
||||
"comfort_cool_away_temp": self._presets_away.get(
|
||||
self.get_preset_away_name(VThermPreset.COMFORT + PRESET_AC_SUFFIX), 0
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
self._state_manager.add_custom_attributes(self._attr_extra_state_attributes)
|
||||
|
||||
for manager in self._managers:
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
"requirements": [
|
||||
"numpy",
|
||||
"scipy",
|
||||
"vtherm_api>=0.4.0"
|
||||
"vtherm_api>=0.5.0"
|
||||
],
|
||||
"ssdp": [],
|
||||
"version": "10.4.0",
|
||||
"version": "10.5.0",
|
||||
"zeroconf": []
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ class OpeningClosingDegreeCalculation:
|
||||
else:
|
||||
calculated_degree = 1 - max_cd
|
||||
|
||||
calculated_degree = max(calculated_degree, 1 - max_cd)
|
||||
|
||||
# set to base 100
|
||||
calculated_degree = round(calculated_degree * 100)
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -246,6 +246,15 @@ class TPIHandler:
|
||||
async def _get_tpi_data(self) -> dict[str, Any]:
|
||||
"""Calculate and return TPI cycle parameters."""
|
||||
t = self._thermostat
|
||||
get_feature_manager = getattr(t, "get_feature_manager", None)
|
||||
heating_failure_manager = (
|
||||
get_feature_manager("heating_failure_detection")
|
||||
if callable(get_feature_manager)
|
||||
else None
|
||||
)
|
||||
is_heating_failure = bool(
|
||||
getattr(heating_failure_manager, "is_detected", False)
|
||||
)
|
||||
|
||||
# Feed current temperatures to AutoTpiManager BEFORE getting params
|
||||
if self._auto_tpi_manager:
|
||||
@@ -256,7 +265,7 @@ class TPIHandler:
|
||||
hvac_mode=str(t.vtherm_hvac_mode),
|
||||
is_overpowering_detected=t.power_manager.is_overpowering_detected,
|
||||
is_central_boiler_off=self._is_central_boiler_off(),
|
||||
is_heating_failure=t.heating_failure_detection_manager.is_failure_detected,
|
||||
is_heating_failure=is_heating_failure,
|
||||
)
|
||||
|
||||
# Sync coefficients from AutoTpiManager before calculating
|
||||
@@ -304,6 +313,15 @@ class TPIHandler:
|
||||
"""TPI-specific control heating logic."""
|
||||
del timestamp
|
||||
t = self._thermostat
|
||||
get_feature_manager = getattr(t, "get_feature_manager", None)
|
||||
heating_failure_manager = (
|
||||
get_feature_manager("heating_failure_detection")
|
||||
if callable(get_feature_manager)
|
||||
else None
|
||||
)
|
||||
is_heating_failure = bool(
|
||||
getattr(heating_failure_manager, "is_detected", False)
|
||||
)
|
||||
|
||||
# Feed the Auto TPI manager
|
||||
if self._auto_tpi_manager:
|
||||
@@ -315,7 +333,7 @@ class TPIHandler:
|
||||
hvac_mode=str(t.vtherm_hvac_mode),
|
||||
is_overpowering_detected=t.power_manager.is_overpowering_detected,
|
||||
is_central_boiler_off=self._is_central_boiler_off(),
|
||||
is_heating_failure=t.heating_failure_detection_manager.is_failure_detected,
|
||||
is_heating_failure=is_heating_failure,
|
||||
)
|
||||
|
||||
# 2. Synchronize parameters if learning is active
|
||||
|
||||
@@ -21,7 +21,7 @@ from .cycle_scheduler import CycleScheduler
|
||||
|
||||
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||
from .commons import write_event_log
|
||||
from .vtherm_hvac_mode import VThermHvacMode, VThermHvacMode_OFF, VThermHvacMode_SLEEP
|
||||
from .vtherm_hvac_mode import VThermHvacMode, VThermHvacMode_COOL, VThermHvacMode_HEAT, VThermHvacMode_OFF, VThermHvacMode_SLEEP
|
||||
|
||||
# from .vtherm_central_api import VersatileThermostatAPI
|
||||
|
||||
@@ -407,6 +407,8 @@ class ThermostatOverClimateValve(ThermostatProp[UnderlyingClimate], ThermostatOv
|
||||
@property
|
||||
def is_device_active(self) -> bool:
|
||||
"""Returns true if one underlying is active"""
|
||||
if self.is_sleeping:
|
||||
return False
|
||||
if ThermostatOverClimate.is_device_active.fget(self) is not True:
|
||||
return False
|
||||
for under in self._underlyings_valve_regulation:
|
||||
@@ -431,6 +433,13 @@ class ThermostatOverClimateValve(ThermostatProp[UnderlyingClimate], ThermostatOv
|
||||
"""True if the thermostat is in sleep mode"""
|
||||
return self.vtherm_hvac_mode == VThermHvacMode_SLEEP
|
||||
|
||||
@overrides
|
||||
def get_underlying_hvac_mode(self, hvac_mode: VThermHvacMode) -> VThermHvacMode:
|
||||
"""Keep the TRV active while the VTherm is sleeping."""
|
||||
if hvac_mode == VThermHvacMode_SLEEP:
|
||||
return VThermHvacMode_COOL if self.ac_mode else VThermHvacMode_HEAT
|
||||
return hvac_mode
|
||||
|
||||
@overrides
|
||||
async def service_set_auto_regulation_mode(self, auto_regulation_mode: str):
|
||||
"""This should not be possible in valve regulation mode"""
|
||||
|
||||
@@ -70,7 +70,7 @@ class ThermostatOverValve(ThermostatProp[UnderlyingValve]): # pylint: disable=a
|
||||
def build_hvac_list(self) -> list[VThermHvacMode]:
|
||||
"""Build the hvac list depending on ac_mode"""
|
||||
if self._ac_mode:
|
||||
return [VThermHvacMode_COOL, VThermHvacMode_SLEEP, VThermHvacMode_OFF]
|
||||
return [VThermHvacMode_HEAT, VThermHvacMode_COOL, VThermHvacMode_SLEEP, VThermHvacMode_OFF]
|
||||
else:
|
||||
return [VThermHvacMode_HEAT, VThermHvacMode_SLEEP, VThermHvacMode_OFF]
|
||||
|
||||
@@ -224,6 +224,7 @@ class ThermostatOverValve(ThermostatProp[UnderlyingValve]): # pylint: disable=a
|
||||
),
|
||||
max_closing_degree=self._max_closing_degree,
|
||||
opening_threshold=self._opening_threshold_degree,
|
||||
has_valve_control=self._have_valve_control,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
"humidity_sensor_entity_id": "Luftfeuchtigkeitssensor"
|
||||
},
|
||||
"data_description": {
|
||||
"use_humidity_feature": "Deaktivieren Sie diese Option, um nur die Luftfeuchtigkeit der zugrunde liegenden Klimaanlage zu verwenden.",
|
||||
"use_humidity_feature": "Deaktivieren Sie diese Option, um nur die Luftfeuchtigkeit der zugeordneten Klimaanlage zu verwenden.",
|
||||
"humidity_sensor_entity_id": "Leer lassen, um den erkannten Sensor automatisch zu verwenden."
|
||||
}
|
||||
},
|
||||
@@ -736,7 +736,7 @@
|
||||
"humidity_sensor_entity_id": "Luftfeuchtigkeitssensor"
|
||||
},
|
||||
"data_description": {
|
||||
"use_humidity_feature": "Deaktivieren Sie diese Option, um nur die Luftfeuchtigkeit der zugrunde liegenden Klimaanlage zu verwenden.",
|
||||
"use_humidity_feature": "Deaktivieren Sie diese Option, um nur die Luftfeuchtigkeit der zugeordneten Klimaanlage zu verwenden.",
|
||||
"humidity_sensor_entity_id": "Leer lassen, um den erkannten Sensor automatisch zu verwenden."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -593,6 +593,7 @@ class UnderlyingClimate(UnderlyingEntity):
|
||||
|
||||
async def set_hvac_mode(self, hvac_mode: VThermHvacMode) -> bool:
|
||||
"""Set the HVACmode of the underlying climate. Returns true if something have change"""
|
||||
hvac_mode = self._thermostat.get_underlying_hvac_mode(hvac_mode)
|
||||
state = self._state_manager.get_state(self._entity_id)
|
||||
if state is None:
|
||||
return False
|
||||
@@ -609,6 +610,13 @@ class UnderlyingClimate(UnderlyingEntity):
|
||||
if hvac_mode in (VThermHvacMode_HEAT, VThermHvacMode_COOL) and not await self.check_overpowering():
|
||||
return False
|
||||
|
||||
# Always cancel a pending delayed temperature resend, whatever the new mode is.
|
||||
# Otherwise a HEAT -> OFF sequence within resend_delay_sec would send a set_temperature
|
||||
# to an underlying that is now off, which turns some devices (e.g. Sonoff TRVZB) back on.
|
||||
if self._cancel_set_temperature_later:
|
||||
self._cancel_set_temperature_later()
|
||||
self._cancel_set_temperature_later = None
|
||||
|
||||
await super().set_hvac_mode(hvac_mode)
|
||||
|
||||
data = {ATTR_ENTITY_ID: self._entity_id, "hvac_mode": to_legacy_ha_hvac_mode(hvac_mode)}
|
||||
@@ -630,8 +638,6 @@ class UnderlyingClimate(UnderlyingEntity):
|
||||
if temperature is not None:
|
||||
await self.set_temperature(temperature, None, None)
|
||||
|
||||
if self._cancel_set_temperature_later:
|
||||
self._cancel_set_temperature_later()
|
||||
self._cancel_set_temperature_later = async_call_later(self._hass, resend_delay_sec, callback_resend_temp)
|
||||
|
||||
return True
|
||||
@@ -1177,6 +1183,7 @@ class UnderlyingValve(UnderlyingEntity):
|
||||
max_opening_degree: int | None = None,
|
||||
max_closing_degree: int = 100,
|
||||
opening_threshold: int = 0,
|
||||
has_valve_control: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the underlying valve"""
|
||||
|
||||
@@ -1188,6 +1195,7 @@ class UnderlyingValve(UnderlyingEntity):
|
||||
)
|
||||
self._hvac_mode = None
|
||||
self._percent_open: int | None = None # self._thermostat.valve_open_percent
|
||||
self._raw_percent_open: float | None = None
|
||||
self._min_open: float | None = None
|
||||
self._max_open: float | None = None
|
||||
self._last_sent_temperature = None
|
||||
@@ -1196,6 +1204,7 @@ class UnderlyingValve(UnderlyingEntity):
|
||||
self._max_opening_degree = max_opening_degree
|
||||
self._max_closing_degree = max_closing_degree
|
||||
self._opening_threshold = opening_threshold
|
||||
self._has_valve_control = has_valve_control
|
||||
|
||||
def init_valve_state_min_max_open(self):
|
||||
"""Initialize the min and max open percent"""
|
||||
@@ -1224,6 +1233,11 @@ class UnderlyingValve(UnderlyingEntity):
|
||||
|
||||
self.init_valve_state_min_max_open()
|
||||
|
||||
raw_percent = self._thermostat.valve_open_percent
|
||||
if self._has_valve_control and isinstance(raw_percent, (int, float)):
|
||||
self._raw_percent_open = raw_percent
|
||||
self._percent_open = self._get_controlled_percent(raw_percent)
|
||||
|
||||
should_device_be_active = self.should_device_be_active
|
||||
is_device_active = self.is_device_active
|
||||
|
||||
@@ -1244,7 +1258,9 @@ class UnderlyingValve(UnderlyingEntity):
|
||||
self._last_sent_opening_value or 9999,
|
||||
self._entity_id,
|
||||
)
|
||||
await self.send_percent_open(fixed_value=self._min_open)
|
||||
self._percent_open = self._get_controlled_percent(0)
|
||||
self._raw_percent_open = 0
|
||||
await self.send_percent_open()
|
||||
|
||||
async def send_percent_open(self, fixed_value: int = None):
|
||||
"""Send the percent open to the underlying valve"""
|
||||
@@ -1259,6 +1275,7 @@ class UnderlyingValve(UnderlyingEntity):
|
||||
# Issue 341
|
||||
is_active = self.is_device_active
|
||||
self._percent_open = self._get_controlled_percent(0)
|
||||
self._raw_percent_open = 0
|
||||
if is_active:
|
||||
await self.send_percent_open()
|
||||
|
||||
@@ -1284,10 +1301,14 @@ class UnderlyingValve(UnderlyingEntity):
|
||||
@property
|
||||
def should_device_be_active(self) -> bool:
|
||||
"""If the toggleable device is currently active."""
|
||||
try:
|
||||
if not self._has_valve_control:
|
||||
return self._percent_open > (self._min_open or 0) if isinstance(self._percent_open, (int, float)) else False
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
return False
|
||||
|
||||
return (
|
||||
self._raw_percent_open > 0 and self._raw_percent_open >= self._opening_threshold
|
||||
if isinstance(self._raw_percent_open, (int, float))
|
||||
else False
|
||||
)
|
||||
|
||||
@property
|
||||
def is_device_active(self) -> bool | None:
|
||||
@@ -1295,7 +1316,7 @@ class UnderlyingValve(UnderlyingEntity):
|
||||
if (current_opening := self.current_valve_opening) is None:
|
||||
return None
|
||||
|
||||
return current_opening > (self._min_open or 0)
|
||||
return current_opening > max(100 - self._max_closing_degree, self._min_open or 0)
|
||||
|
||||
@overrides
|
||||
def clamp_sent_value(self, value) -> float:
|
||||
@@ -1322,7 +1343,9 @@ class UnderlyingValve(UnderlyingEntity):
|
||||
|
||||
async def set_valve_open_percent(self):
|
||||
"""Update the valve open percent"""
|
||||
caped_val = self._get_controlled_percent(self._thermostat.valve_open_percent)
|
||||
raw_percent = self._thermostat.valve_open_percent
|
||||
self._raw_percent_open = raw_percent
|
||||
caped_val = self._get_controlled_percent(raw_percent)
|
||||
if self._percent_open == caped_val:
|
||||
# No changes
|
||||
return
|
||||
@@ -1449,6 +1472,11 @@ class UnderlyingValveRegulation(UnderlyingValve):
|
||||
# Initialize valve state and min max opening
|
||||
self.init_valve_state_min_max_open()
|
||||
|
||||
if self._thermostat.is_sleeping:
|
||||
self._percent_open = 100
|
||||
await self.send_percent_open()
|
||||
return
|
||||
|
||||
hvac_mode = self._thermostat.vtherm_hvac_mode
|
||||
device_valve_opening = self.current_valve_opening # the real opening value
|
||||
|
||||
|
||||
Reference in New Issue
Block a user