Skip to content

Commit 5e6bffc

Browse files
authored
feat: add stuck TRV valve detection and auto-recovery (#1827)
* feat: add root cause analysis for heating/cooling failure detection When a heating or cooling failure is detected on a thermostat with valve underlyings, diagnose whether the failure is caused by a stuck valve by comparing should_device_be_active (commanded state) vs is_device_active (real valve position). Adds root_cause, root_cause_entity_id, and root_cause_details fields to failure events and custom attributes. Possible values: valve_stuck_open, valve_stuck_closed, or not_identified. Includes 12 unit tests covering all root cause diagnosis scenarios. * fix: rename 'commanded' to 'requested' in root cause details Aligns terminology with the rest of the VTherm codebase as suggested by the maintainer in code review.
1 parent 7ab9f14 commit 5e6bffc

File tree

2 files changed

+502
-2
lines changed

2 files changed

+502
-2
lines changed

custom_components/versatile_thermostat/feature_heating_failure_detection_manager.py

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)