Skip to content

Commit 47bba41

Browse files
fix(ohme,gateway): don't treat a still-active long window as a year rollover
Both parsers bumped a window's year forward whenever its parsed start was more than 23h in the past, to handle a plan built just before New Year carrying Jan 1 windows. But a genuinely active long/flat-rate window that started well over 23h ago (its end still ahead of now) hit the same heuristic and got shifted a year forward, dropping out of should_charge_now()/_should_ev_charge_now() and stopping the charger mid-window (#269, Copilot review on #5120). Only bump the year when the window has also already finished (end < now) - the signature of a genuine rollover artefact, not an active window. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent a9d32d8 commit 47bba41

4 files changed

Lines changed: 62 additions & 7 deletions

File tree

‎apps/predbat/gateway.py‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -434,9 +434,11 @@ def _refresh_ev_windows(self):
434434
435435
Datetimes are localized using ``self.local_tz`` so comparisons respect the component
436436
timezone and DST transitions. Year boundaries are handled: if a parsed start is more
437-
than 23 hours in the past (i.e. the plan was built on Dec 31 and contains Jan 1
438-
windows), the year is bumped forward. Similarly, if end falls before start after
439-
localization the end year is incremented to handle windows that straddle midnight
437+
than 23 hours in the past *and the window has already ended* (i.e. the plan was built on
438+
Dec 31 and contains Jan 1 windows), the year is bumped forward - a still-active long
439+
window is left alone even if its start is over 23 hours old (#269). Similarly, if end
440+
falls before start after localization the end year is incremented to handle windows that
441+
straddle midnight
440442
on New Year's Eve.
441443
"""
442444
planned = self.get_state_wrapper(f"binary_sensor.{self.prefix}_car_charging_slot", attribute="planned") or []
@@ -449,8 +451,12 @@ def _refresh_ev_windows(self):
449451
end_naive = datetime.datetime.strptime(w["end"], "%m-%d %H:%M:%S").replace(year=current_year)
450452
start_dt = self.local_tz.localize(start_naive)
451453
end_dt = self.local_tz.localize(end_naive)
452-
# If start is far in the past the plan crossed a year boundary (Dec 31 → Jan 1)
453-
if start_dt < now - datetime.timedelta(hours=23):
454+
# If start is far in the past the plan crossed a year boundary (Dec 31 → Jan 1).
455+
# Only treat it that way if the window has also finished - a still-active long
456+
# window (end still ahead of now) legitimately started that long ago and must not
457+
# be shifted a year forward, or _should_ev_charge_now() stops seeing it as current
458+
# and the charger is told to stop mid-window (#269).
459+
if start_dt < now - datetime.timedelta(hours=23) and end_dt < now:
454460
start_dt = start_dt.replace(year=start_dt.year + 1)
455461
end_dt = end_dt.replace(year=end_dt.year + 1)
456462
elif end_dt < start_dt:

‎apps/predbat/ohme.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,8 +339,12 @@ def refresh_car_windows(self):
339339
end = self.local_tz.localize(datetime.datetime.strptime(window["end"], PLAN_TIME_FORMAT).replace(year=now.year))
340340
except (KeyError, TypeError, ValueError):
341341
continue
342-
# The plan carries no year, so rebuild it around now for windows that cross New Year
343-
if start < now - timedelta(hours=23):
342+
# The plan carries no year, so rebuild it around now for windows that cross New Year.
343+
# A start more than 23 hours in the past is only a rollover artifact if the window has
344+
# also finished - a still-active long window (its end still ahead of now) legitimately
345+
# started that long ago and must not be shifted a year forward, or should_charge_now()
346+
# stops seeing it as current and the charger is told to stop mid-window (#269).
347+
if start < now - timedelta(hours=23) and end < now:
344348
start = start.replace(year=start.year + 1)
345349
end = end.replace(year=end.year + 1)
346350
elif end < start:

‎apps/predbat/tests/test_gateway.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4420,6 +4420,28 @@ def test_refresh_ev_windows_year_boundary(self):
44204420
# After year bump, start should be in the future (next year)
44214421
assert start_dt > now
44224422

4423+
def test_refresh_ev_windows_long_active_not_shifted(self):
4424+
"""A still-active window whose start is over 23h old is not mistaken for a year rollover (#269)."""
4425+
import datetime as dt_mod
4426+
4427+
gw = self._make_gateway()
4428+
now = dt_mod.datetime.now(gw.local_tz)
4429+
# A long/flat-rate window that started well over 23h ago but has not finished yet - its
4430+
# end is still ahead of now, so this is a genuinely active window, not a stale one left
4431+
# over from a plan built before a year boundary.
4432+
start = now - dt_mod.timedelta(hours=30)
4433+
end = now + dt_mod.timedelta(hours=2)
4434+
planned = [{"start": start.strftime("%m-%d %H:%M:%S"), "end": end.strftime("%m-%d %H:%M:%S"), "kwh": 5.0, "average": 20.0, "cost": 1.0}]
4435+
gw.get_state_wrapper = lambda entity, attribute=None: planned if attribute == "planned" else "on"
4436+
4437+
gw._refresh_ev_windows()
4438+
4439+
assert len(gw._ev_windows) == 1
4440+
start_dt, end_dt = gw._ev_windows[0]
4441+
# Must not be bumped a year forward, or the still-active window stops matching "now"
4442+
assert start_dt.year == now.year
4443+
assert gw._should_ev_charge_now() is True
4444+
44234445
def test_apply_sends_start_on_transition(self):
44244446
"""_apply_ev_charging_state sends SetChargingProfile then RemoteStartTransaction when entering a window."""
44254447
import asyncio

‎apps/predbat/tests/test_ohme.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ def test_ohme(my_predbat=None):
283283
("control_enable", _test_ohme_control_enable_rules, "ohme_control enable rules"),
284284
("control_windows", _test_ohme_control_window_parsing, "control window parsing"),
285285
("control_midnight", _test_ohme_control_window_year_rollover, "control windows across new year"),
286+
("control_long_active", _test_ohme_control_window_long_active_not_shifted, "long active window not mistaken for a rollover"),
286287
("control_startup", _test_ohme_control_waits_for_plan, "control waits for a published plan"),
287288
("control_edges", _test_ohme_control_edge_triggered, "control only acts on transitions"),
288289
("control_drift", _test_ohme_control_reapplies_on_drift, "control re-applies after app changes"),
@@ -1757,6 +1758,28 @@ def _test_ohme_control_window_year_rollover(my_predbat=None):
17571758
return 0
17581759

17591760

1761+
def _test_ohme_control_window_long_active_not_shifted(my_predbat=None):
1762+
"""Test a still-active window whose start is over 23 hours old is not mistaken for a New Year rollover (#269)"""
1763+
print("**** Running test_ohme_control_window_long_active_not_shifted ****")
1764+
1765+
tz = pytz.timezone("Europe/London")
1766+
# A long/flat-rate window starting just after midnight yesterday and still running: at 23:05
1767+
# the next day its start is nearly 47 hours old, well past the 23 hour rollover heuristic, but
1768+
# its end is still ahead of now, so it must be read as genuinely active rather than shifted a
1769+
# year forward and dropped out of should_charge_now().
1770+
now = tz.localize(datetime.datetime(2026, 6, 15, 23, 5, 0))
1771+
window = {"start": "06-14 00:10:00", "end": "06-16 02:00:00", "kwh": 40.0}
1772+
api = _ohme_control_api(windows=[window], now=now)
1773+
1774+
api.refresh_car_windows()
1775+
start, end = api.control_windows[0]
1776+
assert start.year == now.year, f"Expected the still-active window's start left in the current year, got {start}"
1777+
assert api.should_charge_now() is True, "Expected to still be charging inside a long active window over 23 hours after its start"
1778+
1779+
print("PASS: long active window left unshifted")
1780+
return 0
1781+
1782+
17601783
def _test_ohme_control_waits_for_plan(my_predbat=None):
17611784
"""Test control sends nothing until a plan has actually been published"""
17621785
print("**** Running test_ohme_control_waits_for_plan ****")

0 commit comments

Comments
 (0)