Skip to content

Commit 2f8995c

Browse files
committed
First implementation
1 parent 7ef649d commit 2f8995c

File tree

6 files changed

+213
-0
lines changed

6 files changed

+213
-0
lines changed

custom_components/versatile_thermostat/base_thermostat.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
from .feature_lock_manager import FeatureLockManager
7373
from .feature_timed_preset_manager import FeatureTimedPresetManager
7474
from .feature_heating_failure_detection_manager import FeatureHeatingFailureDetectionManager
75+
from .feature_repair_incorrect_state_manager import FeatureRepairIncorrectStateManager
7576
from .state_manager import StateManager
7677
from .vtherm_state import VThermState
7778
from .vtherm_preset import VThermPreset, HIDDEN_PRESETS, PRESET_AC_SUFFIX
@@ -214,6 +215,7 @@ def __init__(
214215
self._lock_manager: FeatureLockManager = FeatureLockManager(self, hass)
215216
self._timed_preset_manager: FeatureTimedPresetManager = FeatureTimedPresetManager(self, hass)
216217
self._heating_failure_detection_manager: FeatureHeatingFailureDetectionManager = FeatureHeatingFailureDetectionManager(self, hass)
218+
self._repair_incorrect_state_manager: FeatureRepairIncorrectStateManager = FeatureRepairIncorrectStateManager(self, hass)
217219

218220
self.register_manager(self._presence_manager)
219221
self.register_manager(self._power_manager)
@@ -223,6 +225,7 @@ def __init__(
223225
self.register_manager(self._lock_manager)
224226
self.register_manager(self._timed_preset_manager)
225227
self.register_manager(self._heating_failure_detection_manager)
228+
self.register_manager(self._repair_incorrect_state_manager)
226229

227230
self._cancel_recalculate_later: Callable[[], None] | None = None
228231

@@ -995,6 +998,11 @@ def heating_failure_detection_manager(self) -> FeatureHeatingFailureDetectionMan
995998
"""Get the heating failure detection manager"""
996999
return self._heating_failure_detection_manager
9971000

1001+
@property
1002+
def repair_incorrect_state_manager(self) -> FeatureRepairIncorrectStateManager:
1003+
"""Get the repair incorrect state manager"""
1004+
return self._repair_incorrect_state_manager
1005+
9981006
@property
9991007
def current_state(self) -> VThermState | None:
10001008
"""Get the current state"""
@@ -1578,6 +1586,9 @@ async def async_control_heating(self, timestamp=None, force=False) -> bool:
15781586
# Check for heating/cooling failures (only for TPI VTherms)
15791587
await self._heating_failure_detection_manager.refresh_state()
15801588

1589+
# Check and repair state discrepancies
1590+
await self._repair_incorrect_state_manager.check_and_repair()
1591+
15811592
self.calculate_hvac_action()
15821593
self.update_custom_attributes()
15831594
self.async_write_ha_state()

custom_components/versatile_thermostat/config_schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@
447447
CONF_SAFETY_DEFAULT_ON_PERCENT,
448448
default=DEFAULT_SAFETY_DEFAULT_ON_PERCENT,
449449
): vol.Coerce(float),
450+
vol.Optional(CONF_REPAIR_INCORRECT_STATE, default=False): cv.boolean,
450451
}
451452
)
452453

custom_components/versatile_thermostat/const.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@
146146
CONF_LOCK_AUTOMATIONS = "lock_automations"
147147
CONF_AUTO_RELOCK_SEC = "auto_relock_sec"
148148

149+
CONF_REPAIR_INCORRECT_STATE = "repair_incorrect_state"
150+
149151
CONF_VSWITCH_ON_CMD_LIST = "vswitch_on_command"
150152
CONF_VSWITCH_OFF_CMD_LIST = "vswitch_off_command"
151153

@@ -402,6 +404,7 @@
402404
CONF_HEATING_FAILURE_DETECTION_DELAY,
403405
CONF_TEMPERATURE_CHANGE_TOLERANCE,
404406
CONF_FAILURE_DETECTION_ENABLE_TEMPLATE,
407+
CONF_REPAIR_INCORRECT_STATE,
405408
]
406409
+ CONF_PRESETS_VALUES
407410
+ CONF_PRESETS_AWAY_VALUES
@@ -475,6 +478,11 @@
475478
DEFAULT_SAFETY_MIN_ON_PERCENT = 0.5
476479
DEFAULT_SAFETY_DEFAULT_ON_PERCENT = 0.1
477480

481+
# Repair incorrect state defaults
482+
DEFAULT_REPAIR_INCORRECT_STATE = False
483+
REPAIR_MAX_ATTEMPTS = 5
484+
REPAIR_MIN_DELAY_AFTER_INIT_SEC = 30
485+
478486
# Heating failure detection defaults
479487
DEFAULT_HEATING_FAILURE_THRESHOLD = 0.9 # 90%
480488
DEFAULT_COOLING_FAILURE_THRESHOLD = 0.0 # 0%
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# pylint: disable=line-too-long
2+
3+
"""Implements the Repair Incorrect State feature as a Feature Manager"""
4+
5+
import logging
6+
from .log_collector import get_vtherm_logger
7+
from typing import Any
8+
from datetime import datetime, timezone
9+
10+
from homeassistant.core import HomeAssistant
11+
12+
from .const import (
13+
CONF_REPAIR_INCORRECT_STATE,
14+
DEFAULT_REPAIR_INCORRECT_STATE,
15+
REPAIR_MAX_ATTEMPTS,
16+
REPAIR_MIN_DELAY_AFTER_INIT_SEC,
17+
overrides,
18+
)
19+
from .commons_type import ConfigData
20+
from .base_manager import BaseFeatureManager
21+
22+
_LOGGER = get_vtherm_logger(__name__)
23+
24+
25+
class FeatureRepairIncorrectStateManager(BaseFeatureManager):
26+
"""Detects and repairs discrepancies between VTherm's desired state
27+
and the actual state of underlying entities.
28+
29+
On each control heating cycle, if the feature is enabled, it compares
30+
the desired state (should_device_be_active) with the actual state
31+
(is_device_active) for each underlying entity. If they differ, it
32+
re-emits the desired command. The number of consecutive repairs is
33+
capped at REPAIR_MAX_ATTEMPTS to prevent infinite loops.
34+
35+
The feature only activates at least REPAIR_MIN_DELAY_AFTER_INIT_SEC
36+
seconds after VTherm has become fully operational (is_ready).
37+
"""
38+
39+
unrecorded_attributes = frozenset(
40+
{
41+
"is_repair_incorrect_state_configured",
42+
}
43+
)
44+
45+
def __init__(self, vtherm: Any, hass: HomeAssistant):
46+
"""Init of a FeatureManager"""
47+
super().__init__(vtherm, hass)
48+
49+
self._is_configured: bool = False
50+
self._ready_start_time: datetime | None = None
51+
self._consecutive_repair_count: int = 0
52+
53+
@overrides
54+
def post_init(self, entry_infos: ConfigData):
55+
"""Reinit of the manager"""
56+
self._is_configured = entry_infos.get(
57+
CONF_REPAIR_INCORRECT_STATE, DEFAULT_REPAIR_INCORRECT_STATE
58+
)
59+
self._ready_start_time = None
60+
self._consecutive_repair_count = 0
61+
62+
@overrides
63+
async def start_listening(self):
64+
"""Start listening - no external entity to monitor for this feature"""
65+
66+
@overrides
67+
def stop_listening(self):
68+
"""Stop listening - nothing to clean up for this feature"""
69+
70+
@property
71+
@overrides
72+
def is_configured(self) -> bool:
73+
"""True if the feature is enabled"""
74+
return self._is_configured
75+
76+
async def check_and_repair(self) -> bool:
77+
"""Check all underlyings for state discrepancies and repair if needed.
78+
79+
Called on each control heating cycle.
80+
Returns True if at least one repair was performed, False otherwise.
81+
"""
82+
if not self._is_configured:
83+
return False
84+
85+
if not self._vtherm.is_ready:
86+
# Reset so the delay restarts if VTherm loses readiness
87+
self._ready_start_time = None
88+
return False
89+
90+
now = datetime.now(timezone.utc)
91+
92+
# Record the first time VTherm becomes ready
93+
if self._ready_start_time is None:
94+
self._ready_start_time = now
95+
_LOGGER.debug(
96+
"%s - RepairIncorrectStateManager: VTherm just became ready, "
97+
"waiting %ds before activating",
98+
self._vtherm.name,
99+
REPAIR_MIN_DELAY_AFTER_INIT_SEC,
100+
)
101+
return False
102+
103+
# Wait for the minimum delay after init
104+
elapsed = (now - self._ready_start_time).total_seconds()
105+
if elapsed < REPAIR_MIN_DELAY_AFTER_INIT_SEC:
106+
return False
107+
108+
# Stop if the maximum consecutive repair count is reached
109+
if self._consecutive_repair_count >= REPAIR_MAX_ATTEMPTS:
110+
_LOGGER.warning(
111+
"%s - RepairIncorrectStateManager: maximum repair attempts (%d) "
112+
"reached. Stopped attempting repairs to avoid infinite loop.",
113+
self._vtherm.name,
114+
REPAIR_MAX_ATTEMPTS,
115+
)
116+
return False
117+
118+
repaired = False
119+
for underlying in self._vtherm.underlyings:
120+
repaired_this = await underlying.check_and_repair()
121+
if repaired_this:
122+
_LOGGER.info(
123+
"%s - RepairIncorrectStateManager: underlying %s was repaired. "
124+
"Consecutive repairs so far: %d",
125+
self._vtherm.name,
126+
underlying.entity_id,
127+
self._consecutive_repair_count + 1,
128+
)
129+
repaired = True
130+
131+
if repaired:
132+
self._consecutive_repair_count += 1
133+
else:
134+
self._consecutive_repair_count = 0
135+
136+
return repaired
137+
138+
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
139+
"""Add custom attributes for diagnostics"""
140+
extra_state_attributes.update(
141+
{
142+
"is_repair_incorrect_state_configured": self._is_configured,
143+
}
144+
)
145+
146+
if self._is_configured:
147+
extra_state_attributes.update(
148+
{
149+
"repair_incorrect_state_manager": {
150+
"consecutive_repair_count": self._consecutive_repair_count,
151+
"max_attempts": REPAIR_MAX_ATTEMPTS,
152+
"min_delay_after_init_sec": REPAIR_MIN_DELAY_AFTER_INIT_SEC,
153+
}
154+
}
155+
)

custom_components/versatile_thermostat/underlyings.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,24 @@ def last_change(self) -> datetime | None:
284284
state = self._state_manager.get_state(self._entity_id)
285285
return state.last_changed if state else None
286286

287+
async def check_and_repair(self) -> bool:
288+
"""Check if the underlying device state matches the desired state and repair if needed.
289+
Returns True if a repair was performed."""
290+
should_be_active = self.should_device_be_active
291+
is_active = self.is_device_active
292+
293+
if should_be_active is None or is_active is None:
294+
return False
295+
296+
if should_be_active == is_active:
297+
return False
298+
299+
if should_be_active:
300+
await self.turn_on()
301+
else:
302+
await self.turn_off()
303+
return True
304+
287305

288306
# ----------------------------------------------------------------
289307
# UnderlyingSwitch
@@ -1239,6 +1257,22 @@ def last_sent_opening_value(self) -> int | None:
12391257
"""Return the last sent value to the valve"""
12401258
return self._last_sent_opening_value
12411259

1260+
@overrides
1261+
async def check_and_repair(self) -> bool:
1262+
"""Check if the valve opening matches the last sent value and repair if needed.
1263+
Returns True if a repair was performed."""
1264+
last_sent = self._last_sent_opening_value
1265+
current = self.current_valve_opening
1266+
1267+
if last_sent is None or current is None:
1268+
return False
1269+
1270+
if abs(current - last_sent) <= 0.5:
1271+
return False
1272+
1273+
await self.send_percent_open()
1274+
return True
1275+
12421276
@property
12431277
def current_valve_opening(self) -> float | None:
12441278
"""Get the current valve opening from the underlying entity"""

tests/test_config_flow.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,7 @@ async def test_user_config_flow_over_climate(
506506
CONF_SAFETY_DELAY_MIN: 5,
507507
CONF_SAFETY_MIN_ON_PERCENT: 0.4,
508508
CONF_SAFETY_DEFAULT_ON_PERCENT: 0.3,
509+
CONF_REPAIR_INCORRECT_STATE: False,
509510
} | MOCK_DEFAULT_FEATURE_CONFIG | {
510511
CONF_USE_MAIN_CENTRAL_CONFIG: False,
511512
CONF_USE_PRESETS_CENTRAL_CONFIG: False,
@@ -756,6 +757,7 @@ async def test_user_config_flow_over_climate_auto_start_stop(
756757
CONF_SAFETY_DELAY_MIN: 5,
757758
CONF_SAFETY_MIN_ON_PERCENT: 0.4,
758759
CONF_SAFETY_DEFAULT_ON_PERCENT: 0.3,
760+
CONF_REPAIR_INCORRECT_STATE: False,
759761
} | MOCK_DEFAULT_FEATURE_CONFIG | {
760762
CONF_USE_MAIN_CENTRAL_CONFIG: False,
761763
CONF_USE_TPI_CENTRAL_CONFIG: False,
@@ -1020,6 +1022,7 @@ async def test_user_config_flow_over_switch_bug_552_tpi(
10201022
CONF_SAFETY_DELAY_MIN: 5,
10211023
CONF_SAFETY_MIN_ON_PERCENT: 0.4,
10221024
CONF_SAFETY_DEFAULT_ON_PERCENT: 0.3,
1025+
CONF_REPAIR_INCORRECT_STATE: False,
10231026
CONF_USE_MAIN_CENTRAL_CONFIG: False,
10241027
CONF_USE_TPI_CENTRAL_CONFIG: False,
10251028
CONF_USE_PRESETS_CENTRAL_CONFIG: False,
@@ -1489,6 +1492,7 @@ async def test_user_config_flow_over_climate_valve(
14891492
CONF_SAFETY_DELAY_MIN: 5,
14901493
CONF_SAFETY_MIN_ON_PERCENT: 0.4,
14911494
CONF_SAFETY_DEFAULT_ON_PERCENT: 0.3,
1495+
CONF_REPAIR_INCORRECT_STATE: False,
14921496
} | MOCK_DEFAULT_FEATURE_CONFIG | {
14931497
CONF_USE_MAIN_CENTRAL_CONFIG: False,
14941498
CONF_USE_PRESETS_CENTRAL_CONFIG: False,

0 commit comments

Comments
 (0)