@@ -45,6 +45,11 @@ class FeatureHeatingFailureDetectionManager(BaseFeatureManager):
4545 This feature detects:
4646 1. Heating failure: high on_percent but temperature not increasing
4747 2. Cooling failure: on_percent at 0 but temperature still increasing
48+
49+ When a failure is detected on a thermostat with valve underlyings,
50+ root cause analysis is performed by comparing each underlying's
51+ should_device_be_active (commanded state) vs is_device_active (real state)
52+ to determine if the failure is caused by a stuck valve.
4853 """
4954
5055 unrecorded_attributes = frozenset (
@@ -325,12 +330,90 @@ def _reset_tracking(self):
325330 self ._high_power_start_time = None
326331 self ._zero_power_start_time = None
327332
333+ def _diagnose_root_cause (self , failure_type : str ) -> dict [str , Any ]:
334+ """Diagnose the root cause of a failure by checking valve underlyings.
335+
336+ For thermostats with valve underlyings (over_valve or over_climate_valve),
337+ compares the requested state (should_device_be_active) with the real state
338+ (is_device_active) to determine if a stuck valve is the cause.
339+
340+ Args:
341+ failure_type: "heating" or "cooling"
342+
343+ Returns:
344+ A dict with root_cause, root_cause_entity_id, and root_cause_details
345+ """
346+ result = {
347+ "root_cause" : "not_identified" ,
348+ "root_cause_entity_id" : None ,
349+ "root_cause_details" : [],
350+ }
351+
352+ if not hasattr (self ._vtherm , '_underlyings' ):
353+ return result
354+
355+ from .underlyings import UnderlyingValve
356+
357+ stuck_valves = []
358+ for under in self ._vtherm ._underlyings :
359+ if not isinstance (under , UnderlyingValve ):
360+ continue
361+
362+ should_active = under .should_device_be_active
363+ is_active = under .is_device_active
364+
365+ if should_active is None or is_active is None :
366+ continue
367+
368+ if should_active and not is_active :
369+ stuck_valves .append ({
370+ "entity_id" : under .entity_id ,
371+ "type" : "valve_stuck_closed" ,
372+ "requested" : "open" ,
373+ "actual" : "closed" ,
374+ })
375+ elif not should_active and is_active :
376+ stuck_valves .append ({
377+ "entity_id" : under .entity_id ,
378+ "type" : "valve_stuck_open" ,
379+ "requested" : "closed" ,
380+ "actual" : "open" ,
381+ })
382+
383+ if stuck_valves :
384+ if failure_type == "heating" :
385+ valve_stuck_type = "valve_stuck_closed"
386+ else :
387+ valve_stuck_type = "valve_stuck_open"
388+
389+ matching = [v for v in stuck_valves if v ["type" ] == valve_stuck_type ]
390+ if matching :
391+ result ["root_cause" ] = valve_stuck_type
392+ result ["root_cause_entity_id" ] = matching [0 ]["entity_id" ]
393+ result ["root_cause_details" ] = matching
394+ else :
395+ result ["root_cause" ] = stuck_valves [0 ]["type" ]
396+ result ["root_cause_entity_id" ] = stuck_valves [0 ]["entity_id" ]
397+ result ["root_cause_details" ] = stuck_valves
398+
399+ return result
400+
328401 def _send_heating_failure_event (self , event_type : str , on_percent : float , temp_diff : float , current_temp : float ):
329402 """Send a heating failure event"""
330403 is_enabled_by_template = self ._is_detection_enabled_by_template ()
404+
405+ root_cause_info = self ._diagnose_root_cause ("heating" ) if event_type == "heating_failure_start" else {
406+ "root_cause" : "not_identified" ,
407+ "root_cause_entity_id" : None ,
408+ "root_cause_details" : [],
409+ }
410+
331411 # Log the event
332412 if event_type == "heating_failure_start" :
333- write_event_log (_LOGGER , self ._vtherm , f"Heating failure detected: on_percent={ on_percent * 100 :.0f} %, temp_diff={ temp_diff :.2f} °" )
413+ write_event_log (
414+ _LOGGER , self ._vtherm ,
415+ f"Heating failure detected: on_percent={ on_percent * 100 :.0f} %, temp_diff={ temp_diff :.2f} °, root_cause={ root_cause_info ['root_cause' ]} "
416+ )
334417 else :
335418 write_event_log (
336419 _LOGGER , self ._vtherm , f"Heating failure ended: on_percent={ on_percent * 100 :.0f} %, temp_diff={ temp_diff :.2f} °, template_enabled={ is_enabled_by_template } "
@@ -348,15 +431,28 @@ def _send_heating_failure_event(self, event_type: str, on_percent: float, temp_d
348431 "threshold" : self ._heating_failure_threshold ,
349432 "detection_delay_min" : self ._heating_failure_detection_delay ,
350433 "is_enabled_by_template" : is_enabled_by_template ,
434+ "root_cause" : root_cause_info ["root_cause" ],
435+ "root_cause_entity_id" : root_cause_info ["root_cause_entity_id" ],
436+ "root_cause_details" : root_cause_info ["root_cause_details" ],
351437 },
352438 )
353439
354440 def _send_cooling_failure_event (self , event_type : str , on_percent : float , temp_diff : float , current_temp : float ):
355441 """Send a cooling failure event"""
356442 is_enabled_by_template = self ._is_detection_enabled_by_template ()
443+
444+ root_cause_info = self ._diagnose_root_cause ("cooling" ) if event_type == "cooling_failure_start" else {
445+ "root_cause" : "not_identified" ,
446+ "root_cause_entity_id" : None ,
447+ "root_cause_details" : [],
448+ }
449+
357450 # Log the event
358451 if event_type == "cooling_failure_start" :
359- write_event_log (_LOGGER , self ._vtherm , f"Cooling failure detected: on_percent={ on_percent * 100 :.0f} %, temp_diff=+{ temp_diff :.2f} °" )
452+ write_event_log (
453+ _LOGGER , self ._vtherm ,
454+ f"Cooling failure detected: on_percent={ on_percent * 100 :.0f} %, temp_diff=+{ temp_diff :.2f} °, root_cause={ root_cause_info ['root_cause' ]} "
455+ )
360456 else :
361457 write_event_log (
362458 _LOGGER , self ._vtherm , f"Cooling failure ended: on_percent={ on_percent * 100 :.0f} %, temp_diff={ temp_diff :.2f} °, template_enabled={ is_enabled_by_template } "
@@ -374,6 +470,9 @@ def _send_cooling_failure_event(self, event_type: str, on_percent: float, temp_d
374470 "threshold" : self ._cooling_failure_threshold ,
375471 "detection_delay_min" : self ._heating_failure_detection_delay ,
376472 "is_enabled_by_template" : is_enabled_by_template ,
473+ "root_cause" : root_cause_info ["root_cause" ],
474+ "root_cause_entity_id" : root_cause_info ["root_cause_entity_id" ],
475+ "root_cause_details" : root_cause_info ["root_cause_details" ],
377476 },
378477 )
379478
@@ -399,6 +498,12 @@ def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
399498 template_str = self ._failure_detection_enable_template .template
400499 template_enabled = self ._is_detection_enabled_by_template ()
401500
501+ root_cause_info = {}
502+ if self ._heating_failure_state == STATE_ON :
503+ root_cause_info = self ._diagnose_root_cause ("heating" )
504+ elif self ._cooling_failure_state == STATE_ON :
505+ root_cause_info = self ._diagnose_root_cause ("cooling" )
506+
402507 extra_state_attributes .update (
403508 {
404509 "heating_failure_detection_manager" : {
@@ -412,6 +517,8 @@ def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
412517 "is_detection_enabled_by_template" : template_enabled ,
413518 "heating_tracking" : heating_tracking_info ,
414519 "cooling_tracking" : cooling_tracking_info ,
520+ "root_cause" : root_cause_info .get ("root_cause" ),
521+ "root_cause_entity_id" : root_cause_info .get ("root_cause_entity_id" ),
415522 }
416523 }
417524 )
0 commit comments