Skip to content

Commit 6f096c2

Browse files
committed
Replace backoff with tenacity and handle always-off zones
1 parent 219a586 commit 6f096c2

4 files changed

Lines changed: 89 additions & 28 deletions

File tree

poetry.lock

Lines changed: 18 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ package-mode = false
55
python = ">=3.13.2,<4.0"
66
evohome-async = ">=1.0,<2"
77
aiohttp = "^3.11"
8-
backoff = "^2.2"
8+
tenacity = "^9.1"
99

1010
[tool.poetry.group.dev.dependencies]
1111
pytest = "^9.0"

src/evohome_helper/evohome.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import asyncio
2-
import backoff
32
import logging
43
import settings
54

65
from datetime import datetime, timedelta
76
from evohomeasync2 import EvohomeClientOld as EvohomeClient, ControlSystem, Location, Zone
87
from evohomeasync2.schemas import SystemMode, ZoneMode
98
from evohome_helper import weather
9+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
1010
from typing import Generator
1111

1212
logger = logging.getLogger(__name__)
1313
_evohome_client = None
14-
_retry = backoff.on_exception(wait_gen=backoff.expo, exception=Exception, max_tries=6)
14+
_retry = retry(
15+
retry=retry_if_exception_type(Exception),
16+
wait=wait_exponential(),
17+
stop=stop_after_attempt(6),
18+
reraise=True,
19+
)
1520

1621
_AWAY_MODE_MAP = {
1722
"auto": SystemMode.AUTO,
@@ -102,20 +107,24 @@ def _get_zone_switch_points(zone: Zone, now: datetime) -> list[tuple[datetime, f
102107
return result
103108

104109

105-
def _get_last_heating_switchpoint(zone: Zone, now: datetime) -> tuple[datetime, float]:
106-
last_heating_datetime = now - timedelta(weeks=52)
107-
last_heating_temperature = -99.0
110+
def _get_last_heating_switchpoint(zone: Zone, now: datetime) -> tuple[datetime, float] | None:
111+
last_heating_datetime = None
112+
last_heating_temperature = None
108113
for switchpoint_datetime, switchpoint_temperature in _get_zone_switch_points(zone, now):
109114
if not _is_considered_off(switchpoint_temperature):
110115
last_heating_datetime = switchpoint_datetime
111116
last_heating_temperature = switchpoint_temperature
117+
118+
if last_heating_datetime is None or last_heating_temperature is None:
119+
return None
120+
112121
return last_heating_datetime, last_heating_temperature
113122

114123

115-
def _get_active_setpoint(zone: Zone, now: datetime) -> float:
124+
def _get_active_setpoint(zone: Zone, now: datetime) -> float | None:
116125
switch_points = _get_zone_switch_points(zone, now)
117126
if not switch_points:
118-
return -99.0
127+
return None
119128
return switch_points[-1][1]
120129

121130

@@ -124,7 +133,12 @@ def is_in_schedule_grace_period(location: Location) -> bool:
124133

125134
zones = get_zones(location)
126135
for zone in zones:
127-
switch_point_start, switch_point_temperature = _get_last_heating_switchpoint(zone, now)
136+
switch_point = _get_last_heating_switchpoint(zone, now)
137+
if switch_point is None:
138+
logger.debug("no scheduled heating switch point found for %s", zone.name)
139+
continue
140+
141+
switch_point_start, switch_point_temperature = switch_point
128142
logger.debug(
129143
"last scheduled switch point for %s was at: %s (%s degrees celsius)",
130144
zone.name,
@@ -177,8 +191,8 @@ async def _is_normal_heating_needed(location: Location) -> bool:
177191
inside_temp_diff = settings.AUTO_ECO_INSIDE_TEMP_DIFF
178192
highest_set_point_temp = _get_highest_set_point_temp(location)
179193

180-
# all zones are off?
181-
if _is_considered_off(highest_set_point_temp):
194+
# no valid active setpoint, or all zones are off?
195+
if highest_set_point_temp is None or _is_considered_off(highest_set_point_temp):
182196
return True
183197

184198
# can we fetch a valid temperature?
@@ -198,12 +212,14 @@ async def _is_normal_heating_needed(location: Location) -> bool:
198212
return outside_current_temp + inside_temp_diff < highest_set_point_temp
199213

200214

201-
def _get_highest_set_point_temp(location: Location) -> float:
215+
def _get_highest_set_point_temp(location: Location) -> float | None:
202216
zones = list(get_zones(location))
203217
if not zones:
204-
return -99
218+
return None
205219
now = get_current_time(location)
206-
return max(_get_active_setpoint(zone, now) for zone in zones)
220+
active_setpoints = (_get_active_setpoint(zone, now) for zone in zones)
221+
valid_setpoints = filter(lambda setpoint: setpoint is not None, active_setpoints)
222+
return max(valid_setpoints, default=None)
207223

208224

209225
@_retry

tests/test_evohome.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,30 @@ def test_get_zone_switch_points_week_boundary_sunday_to_monday(evohome_factory):
148148
assert sunday_sps[0][0] == datetime(2024, 4, 7, 7, 0, 0) # last Sunday, not next
149149

150150

151+
def test_get_active_setpoint_returns_none_when_zone_has_no_schedule(evohome_factory):
152+
state = evohome_factory.complete_state(schedule=[])
153+
154+
now = datetime(2024, 4, 10, 8, 0, 0)
155+
assert evohome._get_active_setpoint(state.zone, now) is None
156+
157+
158+
def test_get_highest_set_point_temp_returns_none_when_no_valid_setpoints(evohome_factory):
159+
state = evohome_factory.complete_state(schedule=[])
160+
161+
with freeze_time("2024-04-10 08:00:00"):
162+
assert evohome._get_highest_set_point_temp(state.location) is None
163+
164+
165+
async def test_is_normal_heating_needed_when_no_valid_active_setpoint(monkeypatch, evohome_factory):
166+
state = evohome_factory.complete_state(schedule=[])
167+
168+
monkeypatch.setattr("settings.AUTO_ECO_ENABLED", True)
169+
monkeypatch.setattr("evohome_helper.evohome.weather.get_current_temperature", AsyncMock(return_value=25))
170+
171+
with freeze_time("2024-04-10 08:00:00"):
172+
assert await evohome._is_normal_heating_needed(state.location) is True
173+
174+
151175
def test_current_zone_switch_point_ignores_off_period(evohome_factory):
152176
"""When the current scheduled period is off, return the last heating switchpoint."""
153177
sp_heat = evohome_factory.switchpoint("11:55:00", 20)
@@ -162,6 +186,14 @@ def test_current_zone_switch_point_ignores_off_period(evohome_factory):
162186
assert sp_start == datetime(2024, 4, 7, 11, 55, 0)
163187

164188

189+
def test_get_last_heating_switchpoint_returns_none_when_zone_always_off(monkeypatch, evohome_factory):
190+
state = evohome_factory.complete_state(schedule=evohome_factory.uniform_schedule(15, "11:55:00"))
191+
monkeypatch.setattr("settings.EVOHOME_OFF_TEMP_THRESHOLD", 15)
192+
193+
now = datetime(2024, 4, 7, 12, 10, 0)
194+
assert evohome._get_last_heating_switchpoint(state.zone, now) is None
195+
196+
165197
def test_is_in_schedule_grace_period_triggered_after_off_within_grace(monkeypatch, evohome_factory):
166198
"""Grace period uses last heating start even if current period is off."""
167199
sp_heat = evohome_factory.switchpoint("11:55:00", 20)
@@ -176,6 +208,15 @@ def test_is_in_schedule_grace_period_triggered_after_off_within_grace(monkeypatc
176208
assert evohome.is_in_schedule_grace_period(state.location) is True
177209

178210

211+
def test_is_in_schedule_grace_period_false_when_zone_always_off(monkeypatch, evohome_factory):
212+
state = evohome_factory.complete_state(schedule=evohome_factory.uniform_schedule(15, "11:55:00"))
213+
monkeypatch.setattr("settings.PRESENCE_HEATING_SCHEDULE_GRACE_TIME", 900)
214+
monkeypatch.setattr("settings.EVOHOME_OFF_TEMP_THRESHOLD", 15)
215+
216+
with freeze_time("2024-04-07 12:00:00"):
217+
assert evohome.is_in_schedule_grace_period(state.location) is False
218+
219+
179220
def test_get_zones_filters_faulty_zones(evohome_factory):
180221
state = evohome_factory.complete_state(with_fault=False)
181222
faulty = evohome_factory.zone(name="bad", active_faults=["fault"])

0 commit comments

Comments
 (0)