# pylint: disable=line-too-long """ Implements the Heating Failure Detection as a Feature Manager""" import logging from vtherm_api.log_collector import get_vtherm_logger from typing import Any from datetime import datetime, timedelta from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.template import Template from .const import ( CONF_USE_HEATING_FAILURE_DETECTION_FEATURE, CONF_HEATING_FAILURE_THRESHOLD, CONF_COOLING_FAILURE_THRESHOLD, CONF_HEATING_FAILURE_DETECTION_DELAY, CONF_TEMPERATURE_CHANGE_TOLERANCE, CONF_FAILURE_DETECTION_ENABLE_TEMPLATE, DEFAULT_HEATING_FAILURE_THRESHOLD, DEFAULT_COOLING_FAILURE_THRESHOLD, DEFAULT_HEATING_FAILURE_DETECTION_DELAY, DEFAULT_TEMPERATURE_CHANGE_TOLERANCE, EventType, overrides, ) from .commons import write_event_log from .commons_type import ConfigData from .base_manager import BaseFeatureManager from .vtherm_hvac_mode import VThermHvacMode_OFF, VThermHvacMode_HEAT _LOGGER = get_vtherm_logger(__name__) class FeatureHeatingFailureDetectionManager(BaseFeatureManager): """The implementation of the Heating Failure Detection feature This feature detects: 1. Heating failure: high on_percent but temperature not increasing 2. Cooling failure: on_percent at 0 but temperature still increasing When a failure is detected on a thermostat with valve underlyings, root cause analysis is performed by comparing each underlying's should_device_be_active (commanded state) vs is_device_active (real state) to determine if the failure is caused by a stuck valve. """ 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", } ) def __init__(self, vtherm: Any, hass: HomeAssistant): """Init of a featureManager""" super().__init__(vtherm, hass) self._is_configured: bool = False self._heating_failure_threshold: float = DEFAULT_HEATING_FAILURE_THRESHOLD self._cooling_failure_threshold: float = DEFAULT_COOLING_FAILURE_THRESHOLD self._heating_failure_detection_delay: int = DEFAULT_HEATING_FAILURE_DETECTION_DELAY self._temperature_change_tolerance: float = DEFAULT_TEMPERATURE_CHANGE_TOLERANCE self._failure_detection_enable_template: Template | None = None # State tracking self._heating_failure_state: str = STATE_UNAVAILABLE self._cooling_failure_state: str = STATE_UNAVAILABLE # Temperature tracking for failure detection self._last_check_time: datetime | None = None self._last_check_temperature: float | None = None self._high_power_start_time: datetime | None = None self._zero_power_start_time: datetime | None = None @overrides def post_init(self, entry_infos: ConfigData): """Reinit of the manager""" use_feature = entry_infos.get(CONF_USE_HEATING_FAILURE_DETECTION_FEATURE, False) if not use_feature: self._is_configured = False self._heating_failure_state = STATE_UNAVAILABLE self._cooling_failure_state = STATE_UNAVAILABLE return self._heating_failure_threshold = entry_infos.get( CONF_HEATING_FAILURE_THRESHOLD, DEFAULT_HEATING_FAILURE_THRESHOLD ) self._cooling_failure_threshold = entry_infos.get( CONF_COOLING_FAILURE_THRESHOLD, DEFAULT_COOLING_FAILURE_THRESHOLD ) self._heating_failure_detection_delay = entry_infos.get( CONF_HEATING_FAILURE_DETECTION_DELAY, DEFAULT_HEATING_FAILURE_DETECTION_DELAY ) self._temperature_change_tolerance = entry_infos.get(CONF_TEMPERATURE_CHANGE_TOLERANCE, DEFAULT_TEMPERATURE_CHANGE_TOLERANCE) # Initialize the enable template if provided template_str = entry_infos.get(CONF_FAILURE_DETECTION_ENABLE_TEMPLATE) if template_str: self._failure_detection_enable_template = Template(template_str, self._hass) else: self._failure_detection_enable_template = None self._is_configured = True self._heating_failure_state = STATE_UNKNOWN self._cooling_failure_state = STATE_UNKNOWN @overrides async def start_listening(self): """Start listening the underlying entity""" # No external entity to listen to for this feature @overrides def stop_listening(self): """Stop listening and remove the eventual timer still running""" def _is_detection_enabled_by_template(self) -> bool: """Evaluate the enable template to determine if detection should be active. Returns True if detection is enabled (template returns True or no template configured). Returns False if template returns False. If template evaluation fails, returns True (detection enabled) as a safety measure. """ if self._failure_detection_enable_template is None: # No template configured, detection is always enabled return True try: result = self._failure_detection_enable_template.async_render() # Handle various truthy/falsy values if isinstance(result, bool): return result if isinstance(result, str): return result.lower() in ("true", "1", "yes", "on") return bool(result) except Exception as err: # pylint: disable=broad-except _LOGGER.warning( "%s - Error evaluating failure_detection_enable_template: %s. Detection remains enabled.", self, err ) # On error, enable detection as a safety measure return True @overrides async def refresh_state(self) -> bool: """Check for heating/cooling failures Return True if a failure is detected""" if not self._is_configured: _LOGGER.debug("%s - heating failure detection is disabled (or not configured)", self) return False # Only check for VTherms with proportional algorithm if not self._vtherm.has_prop: _LOGGER.debug("%s - heating failure detection skipped (no proportional algorithm)", self) return False if self._vtherm.requested_state.hvac_mode == VThermHvacMode_OFF: self._reset_tracking() self._heating_failure_state = STATE_OFF self._cooling_failure_state = STATE_OFF _LOGGER.debug("%s - heating failure detection is OFF because requested_state is OFF", self) return False # Check if detection is enabled by template if not self._is_detection_enabled_by_template(): _LOGGER.debug("%s - heating failure detection is disabled by template", self) # Reset tracking when template disables detection self._reset_tracking() # Keep states at OFF when disabled by template (not UNAVAILABLE since feature is configured) if self._heating_failure_state == STATE_ON: self._heating_failure_state = STATE_OFF self._send_heating_failure_event("heating_failure_end", 0, 0, self._vtherm.current_temperature or 0) if self._cooling_failure_state == STATE_ON: self._cooling_failure_state = STATE_OFF self._send_cooling_failure_event("cooling_failure_end", 0, 0, self._vtherm.current_temperature or 0) return False now = self._vtherm.now # Get current values current_temp = self._vtherm.current_temperature on_percent = self._vtherm.on_percent if current_temp is None or on_percent is None: _LOGGER.debug("%s - heating failure detection skipped (no temp or on_percent)", self) return False # Determine the mode (heating or cooling) is_heating_mode = self._vtherm.vtherm_hvac_mode == VThermHvacMode_HEAT # Initialize tracking if needed if self._last_check_time is None: self._last_check_time = now self._last_check_temperature = current_temp return False detection_delay_td = timedelta(minutes=self._heating_failure_detection_delay) # Check for heating and cooling failures if is_heating_mode: self._check_heating_failure(now, current_temp, on_percent, detection_delay_td) self._check_cooling_failure(now, current_temp, on_percent, detection_delay_td) # Initialize states if still unknown if self._heating_failure_state == STATE_UNKNOWN: self._heating_failure_state = STATE_OFF if self._cooling_failure_state == STATE_UNKNOWN: self._cooling_failure_state = STATE_OFF return self.is_failure_detected def _check_heating_failure(self, now: datetime, current_temp: float, on_percent: float, detection_delay_td: timedelta): """Check for HEATING failure: High on_percent (>= threshold) but temperature not increasing""" old_heating_failure = self._heating_failure_state == STATE_ON if on_percent >= self._heating_failure_threshold: if self._high_power_start_time is None: self._high_power_start_time = now self._last_check_temperature = current_temp _LOGGER.debug( "%s - Starting high power tracking (on_percent=%.2f >= threshold=%.2f)", self, on_percent, self._heating_failure_threshold ) else: elapsed = now - self._high_power_start_time if elapsed >= detection_delay_td: # Check if temperature has increased enough (above tolerance) if self._last_check_temperature is not None: temp_diff = current_temp - self._last_check_temperature if temp_diff < self._temperature_change_tolerance: # Temperature not increasing enough - heating failure detected if not old_heating_failure: _LOGGER.warning( "%s - Heating failure detected: on_percent=%.2f%%, temp_diff=%.2f° (tolerance=%.2f°) over %d minutes", self, on_percent * 100, temp_diff, self._temperature_change_tolerance, self._heating_failure_detection_delay, ) self._heating_failure_state = STATE_ON self._send_heating_failure_event("heating_failure_start", on_percent, temp_diff, current_temp) else: # Temperature is increasing enough, reset if we were in failure if old_heating_failure: _LOGGER.info("%s - Heating failure ended: temperature is now increasing", self) self._heating_failure_state = STATE_OFF self._send_heating_failure_event("heating_failure_end", on_percent, temp_diff, current_temp) self._high_power_start_time = now self._last_check_temperature = current_temp else: # Not in high power, reset tracking if self._high_power_start_time is not None: self._high_power_start_time = None if old_heating_failure: self._heating_failure_state = STATE_OFF self._send_heating_failure_event("heating_failure_end", on_percent, 0, current_temp) def _check_cooling_failure(self, now: datetime, current_temp: float, on_percent: float, detection_delay_td: timedelta): """Check for COOLING failure: on_percent at 0 (or <= cooling threshold) but temperature still increasing""" old_cooling_failure = self._cooling_failure_state == STATE_ON if on_percent <= self._cooling_failure_threshold: if self._zero_power_start_time is None: self._zero_power_start_time = now self._last_check_temperature = current_temp _LOGGER.debug( "%s - Starting zero power tracking (on_percent=%.2f <= threshold=%.2f)", self, on_percent, self._cooling_failure_threshold ) else: elapsed = now - self._zero_power_start_time if elapsed >= detection_delay_td: # Check if temperature is increasing above tolerance if self._last_check_temperature is not None: temp_diff = current_temp - self._last_check_temperature if temp_diff > self._temperature_change_tolerance: # Temperature still increasing despite no heating - cooling failure if not old_cooling_failure: _LOGGER.warning( "%s - Cooling failure detected: on_percent=%.2f%%, temp_diff=+%.2f° (tolerance=%.2f°) over %d minutes", self, on_percent * 100, temp_diff, self._temperature_change_tolerance, self._heating_failure_detection_delay, ) self._cooling_failure_state = STATE_ON self._send_cooling_failure_event("cooling_failure_start", on_percent, temp_diff, current_temp) else: # Temperature is stable or decreasing, reset if we were in failure if old_cooling_failure: _LOGGER.info("%s - Cooling failure ended: temperature is now stable or decreasing", self) self._cooling_failure_state = STATE_OFF self._send_cooling_failure_event("cooling_failure_end", on_percent, temp_diff, current_temp) self._zero_power_start_time = now self._last_check_temperature = current_temp else: # Not at zero power, reset tracking if self._zero_power_start_time is not None: self._zero_power_start_time = None if old_cooling_failure: self._cooling_failure_state = STATE_OFF self._send_cooling_failure_event("cooling_failure_end", on_percent, 0, current_temp) def _reset_tracking(self): """Reset all tracking variables""" self._last_check_time = None self._last_check_temperature = None self._high_power_start_time = None self._zero_power_start_time = None def _diagnose_root_cause(self, failure_type: str) -> dict[str, Any]: """Diagnose the root cause of a failure by checking valve underlyings. For thermostats with valve underlyings (over_valve or over_climate_valve), compares the requested state (should_device_be_active) with the real state (is_device_active) to determine if a stuck valve is the cause. Args: failure_type: "heating" or "cooling" Returns: A dict with root_cause, root_cause_entity_id, and root_cause_details """ result = { "root_cause": "not_identified", "root_cause_entity_id": None, "root_cause_details": [], } if not hasattr(self._vtherm, '_underlyings'): return result from .underlyings import UnderlyingValve stuck_valves = [] for under in self._vtherm._underlyings: if not isinstance(under, UnderlyingValve): continue should_active = under.should_device_be_active is_active = under.is_device_active if should_active is None or is_active is None: continue if should_active and not is_active: stuck_valves.append({ "entity_id": under.entity_id, "type": "valve_stuck_closed", "requested": "open", "actual": "closed", }) elif not should_active and is_active: stuck_valves.append({ "entity_id": under.entity_id, "type": "valve_stuck_open", "requested": "closed", "actual": "open", }) if stuck_valves: if failure_type == "heating": valve_stuck_type = "valve_stuck_closed" else: valve_stuck_type = "valve_stuck_open" matching = [v for v in stuck_valves if v["type"] == valve_stuck_type] if matching: result["root_cause"] = valve_stuck_type result["root_cause_entity_id"] = matching[0]["entity_id"] result["root_cause_details"] = matching else: result["root_cause"] = stuck_valves[0]["type"] result["root_cause_entity_id"] = stuck_valves[0]["entity_id"] result["root_cause_details"] = stuck_valves return result def _send_heating_failure_event(self, event_type: str, on_percent: float, temp_diff: float, current_temp: float): """Send a heating failure event""" is_enabled_by_template = self._is_detection_enabled_by_template() root_cause_info = self._diagnose_root_cause("heating") if event_type == "heating_failure_start" else { "root_cause": "not_identified", "root_cause_entity_id": None, "root_cause_details": [], } # Log the event if event_type == "heating_failure_start": write_event_log( _LOGGER, self._vtherm, f"Heating failure detected: on_percent={on_percent*100:.0f}%, temp_diff={temp_diff:.2f}°, root_cause={root_cause_info['root_cause']}" ) else: write_event_log( _LOGGER, self._vtherm, f"Heating failure ended: on_percent={on_percent*100:.0f}%, temp_diff={temp_diff:.2f}°, template_enabled={is_enabled_by_template}" ) self._vtherm.send_event( EventType.HEATING_FAILURE_EVENT, { "type": event_type, "failure_type": "heating", "on_percent": on_percent, "temperature_difference": temp_diff, "current_temp": current_temp, "target_temp": self._vtherm.target_temperature, "threshold": self._heating_failure_threshold, "detection_delay_min": self._heating_failure_detection_delay, "is_enabled_by_template": is_enabled_by_template, "root_cause": root_cause_info["root_cause"], "root_cause_entity_id": root_cause_info["root_cause_entity_id"], "root_cause_details": root_cause_info["root_cause_details"], }, ) def _send_cooling_failure_event(self, event_type: str, on_percent: float, temp_diff: float, current_temp: float): """Send a cooling failure event""" is_enabled_by_template = self._is_detection_enabled_by_template() root_cause_info = self._diagnose_root_cause("cooling") if event_type == "cooling_failure_start" else { "root_cause": "not_identified", "root_cause_entity_id": None, "root_cause_details": [], } # Log the event if event_type == "cooling_failure_start": write_event_log( _LOGGER, self._vtherm, f"Cooling failure detected: on_percent={on_percent*100:.0f}%, temp_diff=+{temp_diff:.2f}°, root_cause={root_cause_info['root_cause']}" ) else: write_event_log( _LOGGER, self._vtherm, f"Cooling failure ended: on_percent={on_percent*100:.0f}%, temp_diff={temp_diff:.2f}°, template_enabled={is_enabled_by_template}" ) self._vtherm.send_event( EventType.HEATING_FAILURE_EVENT, { "type": event_type, "failure_type": "cooling", "on_percent": on_percent, "temperature_difference": temp_diff, "current_temp": current_temp, "target_temp": self._vtherm.target_temperature, "threshold": self._cooling_failure_threshold, "detection_delay_min": self._heating_failure_detection_delay, "is_enabled_by_template": is_enabled_by_template, "root_cause": root_cause_info["root_cause"], "root_cause_entity_id": root_cause_info["root_cause_entity_id"], "root_cause_details": root_cause_info["root_cause_details"], }, ) def add_custom_attributes(self, extra_state_attributes: dict[str, Any]): """Add some custom attributes""" extra_state_attributes.update( { "is_heating_failure_detection_configured": self._is_configured, } ) if self._is_configured: # Calculate remaining time for heating failure detection heating_tracking_info = self._get_tracking_info(self._high_power_start_time, "heating") # Calculate remaining time for cooling failure detection cooling_tracking_info = self._get_tracking_info(self._zero_power_start_time, "cooling") # Get template status template_str = None template_enabled = True if self._failure_detection_enable_template is not None: template_str = self._failure_detection_enable_template.template template_enabled = self._is_detection_enabled_by_template() root_cause_info = {} if self._heating_failure_state == STATE_ON: root_cause_info = self._diagnose_root_cause("heating") elif self._cooling_failure_state == STATE_ON: root_cause_info = self._diagnose_root_cause("cooling") extra_state_attributes.update( { "heating_failure_detection_manager": { "heating_failure_state": self._heating_failure_state, "cooling_failure_state": self._cooling_failure_state, "heating_failure_threshold": self._heating_failure_threshold, "cooling_failure_threshold": self._cooling_failure_threshold, "detection_delay_min": self._heating_failure_detection_delay, "temperature_change_tolerance": self._temperature_change_tolerance, "failure_detection_enable_template": template_str, "is_detection_enabled_by_template": template_enabled, "heating_tracking": heating_tracking_info, "cooling_tracking": cooling_tracking_info, "root_cause": root_cause_info.get("root_cause"), "root_cause_entity_id": root_cause_info.get("root_cause_entity_id"), } } ) def _get_tracking_info(self, start_time: datetime | None, tracking_type: str) -> dict[str, Any]: """Get tracking information for heating or cooling failure detection Args: start_time: The start time of the tracking (high_power or zero_power) tracking_type: "heating" or "cooling" for logging purposes Returns: A dictionary with tracking status information """ if start_time is None: return { "is_tracking": False, "initial_temperature": None, "current_temperature": None, "remaining_time_min": None, "elapsed_time_min": None, } now = self._vtherm.now elapsed = now - start_time elapsed_minutes = elapsed.total_seconds() / 60 remaining_minutes = max(0, self._heating_failure_detection_delay - elapsed_minutes) return { "is_tracking": True, "initial_temperature": self._last_check_temperature, "current_temperature": self._vtherm.current_temperature, "remaining_time_min": round(remaining_minutes, 1), "elapsed_time_min": round(elapsed_minutes, 1), } @overrides @property def is_configured(self) -> bool: """Return True if the heating failure detection feature is configured""" return self._is_configured @property def is_failure_detected(self) -> bool: """Returns True if any failure is currently detected""" return self._heating_failure_state == STATE_ON or self._cooling_failure_state == STATE_ON @property def is_detected(self) -> bool: """Return the overall state of the feature manager based on failure states""" return self.is_failure_detected @property def is_heating_failure_detected(self) -> bool: """Returns True if a heating failure is detected""" return self._heating_failure_state == STATE_ON @property def is_cooling_failure_detected(self) -> bool: """Returns True if a cooling failure is detected""" return self._cooling_failure_state == STATE_ON @property def heating_failure_state(self) -> str: """Returns the heating failure state: STATE_ON, STATE_OFF, STATE_UNKNOWN, STATE_UNAVAILABLE""" return self._heating_failure_state @property def cooling_failure_state(self) -> str: """Returns the cooling failure state: STATE_ON, STATE_OFF, STATE_UNKNOWN, STATE_UNAVAILABLE""" return self._cooling_failure_state @property def heating_failure_threshold(self) -> float: """Returns the heating failure threshold""" return self._heating_failure_threshold @property def cooling_failure_threshold(self) -> float: """Returns the cooling failure threshold""" return self._cooling_failure_threshold @property def detection_delay_min(self) -> int: """Returns the detection delay in minutes""" return self._heating_failure_detection_delay @property def temperature_change_tolerance(self) -> float: """Returns the temperature change tolerance in degrees""" return self._temperature_change_tolerance def __str__(self): return f"HeatingFailureDetectionManager-{self.name}"