Skip to content

Commit 0ba4b93

Browse files
fix(charger): don't follow planned Octopus dispatches while Smart Control is off (#5339)
Predbat-led charger control (#5448) starts the charger whenever a dispatch on the car's dispatch sensor is running, reading the sensor's raw planned_dispatches, or its on/off state when it lists no times. While Octopus Smart Control is off the sensor goes on listing the plan Octopus made before, which Predbat's own plan already ignores - so with the charger driven (control: true, or charger_follows_car), the charger was switched on for dispatches that will not happen, and a car with its own timers charged at the normal rate. charger_control_dispatch_active() now filters the planned dispatches through the same smart_control_off_slots() the plan uses (bump/boost kept), and does not fall back on the sensor's state, while the car's Smart Control switch is off. The docs say so, and that a driven charger follows the sensor's dispatch times as well as the plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1d916cc commit 0ba4b93

3 files changed

Lines changed: 48 additions & 1 deletion

File tree

‎apps/predbat/car_charger_control.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,19 +467,27 @@ def charger_control_dispatch_active(self, car_n, now):
467467
The dispatch times on the sensor are judged against the clock rather than its on/off
468468
state, which only refreshes every couple of minutes and would run the charger on past
469469
the end of a dispatch. A sensor with no dispatch times falls back to its state.
470+
471+
While Octopus Smart Control is off the sensor goes on listing the plan Octopus made
472+
before, which nothing will act on (#5339), so only the planned dispatches Predbat still
473+
uses are followed - a bump charge the user asked for - and the sensor's state, which
474+
reflects that plan too, is not fallen back on.
470475
"""
471476
if not self.get_arg("octopus_intelligent_charging", True):
472477
return False
473478
slot = self.charger_control_dispatch_sensor(car_n)
474479
if not slot:
475480
return False
481+
smart_control_off = self.base.octopus_smart_control_off(car_n, save=False)
476482
dispatches = []
477483
for attribute in ("planned_dispatches", "completed_dispatches"):
478484
value = self.get_state_wrapper(slot, attribute=attribute)
479485
if isinstance(value, list):
486+
if smart_control_off and attribute == "planned_dispatches":
487+
value = self.base.smart_control_off_slots(value, False)
480488
dispatches.extend(value)
481489
if not dispatches:
482-
return self.get_state_wrapper(slot) == "on"
490+
return not smart_control_off and self.get_state_wrapper(slot) == "on"
483491
for dispatch in dispatches:
484492
if not isinstance(dispatch, dict):
485493
continue

‎apps/predbat/tests/test_car_charger_control.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from tests.test_infra import run_async
1717
from car_charger_control import CarChargerControl, GUEST_CHARGING_MAX_HOURS, OCTOPUS_DISCOVERY_WAIT_MINUTES, parse_control_setting, parse_dispatch_time
18+
from fetch import Fetch
1819

1920
LONDON = pytz.timezone("Europe/London")
2021

@@ -43,10 +44,19 @@ async def load(self, module, key):
4344
class FakeBase:
4445
"""The parts of the base object the mixin reads."""
4546

47+
# Predbat's own filter for the dispatches still used while Octopus Smart Control is off
48+
smart_control_off_slots = Fetch.smart_control_off_slots
49+
4650
def __init__(self):
4751
"""Not read only unless a test says so."""
4852
self.set_read_only = False
4953
self.num_cars = 1
54+
# car_n -> True while that car's Octopus Smart Control switch is off
55+
self.smart_control_off = {}
56+
57+
def octopus_smart_control_off(self, car_n, save=True):
58+
"""Whether the car's Smart Control switch is off, as Fetch.octopus_smart_control_off."""
59+
return self.smart_control_off.get(car_n, False)
5060

5161

5262
class FakeCharger:
@@ -577,6 +587,32 @@ def test_dispatch_times_judged_against_the_clock():
577587
assert component.charger_control_dispatch_active(0, now) is True
578588

579589

590+
def test_smart_control_off_ignores_planned_dispatches():
591+
"""While Octopus Smart Control is off the dispatch sensor keeps the plan Octopus made before, but
592+
nothing will act on it (#5339), so the charger does not follow it - only a manual bump charge."""
593+
component = _octopus_component(FakeOctopus(), is_charger=False)
594+
component.args["octopus_intelligent_charger_follows_car"] = True
595+
now = _now() # 01:30 London, 00:30 UTC
596+
smart = {"start": "2026-06-01T00:15:00+0000", "end": "2026-06-01T01:00:00+0000", "source": "smart-charge"}
597+
component.sensors[DISPATCH]["state"] = "on"
598+
component.sensors[DISPATCH]["planned_dispatches"] = [smart]
599+
assert component.charger_control_dispatch_active(0, now) is True
600+
component.base.smart_control_off[0] = True
601+
assert component.charger_control_dispatch_active(0, now) is False
602+
run_async(component.charger_control_tick(now))
603+
assert component.commands == [("a", "off", 0)], component.commands
604+
# The sensor's state reflects the ignored plan too, so it is not fallen back on
605+
component.sensors[DISPATCH]["planned_dispatches"] = []
606+
assert component.charger_control_dispatch_active(0, now) is False
607+
# A bump charge the user asked for still runs the charger
608+
component.sensors[DISPATCH]["planned_dispatches"] = [dict(smart, source="bump-charge")]
609+
assert component.charger_control_dispatch_active(0, now) is True
610+
# Another car's switch does not count
611+
component.base.smart_control_off = {1: True}
612+
component.sensors[DISPATCH]["planned_dispatches"] = [smart]
613+
assert component.charger_control_dispatch_active(0, now) is True
614+
615+
580616
def test_is_charger_that_has_lost_its_value_is_not_a_no():
581617
"""A dispatch sensor reporting is_charger as unknown or unavailable is cannot tell, not 'not the charger'."""
582618
for lost in ("unknown", "unavailable", "none", ""):
@@ -951,6 +987,7 @@ def run_car_charger_control_tests(my_predbat=None):
951987
test_octopus_rule_charge_point_releases_a_running_charger()
952988
test_octopus_rule_car_integrated_follows_the_dispatch_sensor()
953989
test_dispatch_times_judged_against_the_clock()
990+
test_smart_control_off_ignores_planned_dispatches()
954991
test_is_charger_that_has_lost_its_value_is_not_a_no()
955992
test_guest_switch_toggle()
956993
test_octopus_rule_explicit_true_never_overrides_a_charge_point()

‎docs/car-charging.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,8 @@ On Octopus Intelligent (or E.ON/EDF SmartFlex through the Kraken component), wha
609609
- **Octopus drives the car** - the car itself is the Intelligent device, so the car starts and stops its own charge. By default Predbat leaves the charger alone, and hands back a charger it was holding as usual, so one it had stopped is started again rather than left stopped. Turn on **switch.predbat_octopus_intelligent_charger_follows_car** (*expert mode*) to have Predbat drive the charger instead: on while a dispatch is running, off otherwise, so the car cannot charge outside the dispatches on its own timers. It follows both its plan and the dispatch times on the car's dispatch sensor, so a new dispatch starts the charger within a few minutes of Octopus announcing it rather than waiting for the next plan, and the charger stops when the dispatch ends.
610610
- **Predbat cannot tell** - for example the dispatch sensor comes from the Octopus Energy integration, which does not say, or Predbat's Octopus component has not discovered your devices yet. Setting the charger's control setting to `true` tells Predbat your charger is not the Octopus device, so Predbat drives the charger to its plan. If your charger is the Octopus device, leave its control setting off.
611611

612+
Wherever Predbat drives the charger, it also follows the dispatch times on the car's dispatch sensor, as above. While **octopus_intelligent_smart_control** is `off` it does not follow the planned ones, which Octopus will not carry out, apart from bump/boost charges you asked for yourself.
613+
612614
**switch.predbat_octopus_intelligent_charging** only changes Predbat's own planning. With it off, a charger Octopus drives is still left to Octopus - Octopus goes on switching it either way - and a charger for a car Octopus drives follows Predbat's own plan, whether or not **switch.predbat_octopus_intelligent_charger_follows_car** is on.
613615

614616
### Guest charging

0 commit comments

Comments
 (0)