Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
fc9720b
fix(octopus): ignore planned Intelligent dispatches while Smart Contr…
chalfontchubby Oct 1, 2026
7555d0d
fix(octopus): address review of the Smart Control change (#5339)
chalfontchubby Oct 1, 2026
0e31b83
fix(octopus): keep manual bump/boost dispatches while Smart Control i…
chalfontchubby Oct 1, 2026
0d5fb90
refactor(octopus): share the manual dispatch sources, keep the raw pl…
chalfontchubby Oct 1, 2026
6a45aca
fix(octopus): take the Smart Control switch from apps.yaml, not a har…
chalfontchubby Oct 2, 2026
cbc0cd1
chore(coverage): list octopus_intelligent_smart_control in the test a…
chalfontchubby Oct 2, 2026
90477aa
feat(octopus): publish a writable Smart Control switch from the built…
chalfontchubby Oct 2, 2026
e8bd2f9
fix(octopus): harden the Smart Control switch after review (#5339)
chalfontchubby Oct 2, 2026
c37a467
fix(octopus): log what the Smart Control switch reports when it stops…
chalfontchubby Oct 5, 2026
f620c4e
fix(octopus): roll a failed Smart Control change back to what Octopus…
chalfontchubby Oct 5, 2026
0d2adcb
fix(octopus): keep an unsent Smart Control change on show across a de…
chalfontchubby Oct 5, 2026
b4b68dc
fix(octopus): hold a Smart Control change past the second poll, and f…
chalfontchubby Oct 5, 2026
a3546ef
fix(octopus): keep a charger's own schedule while Smart Control is of…
chalfontchubby Oct 5, 2026
afb51ce
refactor(octopus): show a Smart Control change once Octopus accepts i…
chalfontchubby Oct 5, 2026
fbf1610
test(octopus): restore shared state on failure, and check the suspend…
chalfontchubby Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2839,6 +2839,7 @@
"enphase_automatic": {"type": "boolean"},
"enphase_automatic_ignore_pv": {"type": "boolean"},
"octopus_intelligent_slot": {"type": "sensor|sensor_list", "sensor_type": "boolean|action", "entries": "num_cars", "optional_entries": True},
"octopus_intelligent_smart_control": {"type": "sensor|sensor_list", "sensor_type": "boolean", "entries": "num_cars", "optional_entries": True},
"octopus_ready_time": {"type": "sensor|sensor_list", "sensor_type": "string", "entries": "num_cars", "optional_entries": True},
"octopus_charge_limit": {"type": "sensor|sensor_list", "sensor_type": "float", "entries": "num_cars", "optional_entries": True},
"octopus_slot_low_rate": {"type": "boolean"},
Expand Down
1 change: 1 addition & 0 deletions apps/predbat/config/apps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ pred_bat:
# When enabled it overrides the 'car_charging_planned' feature and predict the car charging based on the intelligent plan (unless octopus intelligent charging is False)
# This matches either the intelligent slot from the Octopus Plugin or from the Intelligent plugin
octopus_intelligent_slot: 're:(binary_sensor.octopus_energy([0-9a-z_]+|)_intelligent_dispatching)'
octopus_intelligent_smart_control: 're:(switch.octopus_energy([0-9a-z_]+|)_intelligent_smart_charge)'
octopus_ready_time: 're:((select|time).octopus_energy_([0-9a-z_]+|)_intelligent_target_time)'
octopus_charge_limit: 're:(number.octopus_energy([0-9a-z_]+|)_intelligent_charge_target)'

Expand Down
2 changes: 2 additions & 0 deletions apps/predbat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@
# A car_charging_now sensor that reports a charging power (in W, after unit conversion) rather than
# on/off counts as charging from this power: above a charger's standby draw, below the smallest charge
CAR_CHARGING_NOW_POWER_W = 200
# Planned Octopus dispatch sources the user asks for by hand - not scheduled by Smart Control, so switching it off does not void them
OCTOPUS_MANUAL_DISPATCH_SOURCES = ("bump-charge", "BOOST")
CAR_CHARGING_LIMIT_UNCAPPED = 9999.0 # Model-facing car charge limit (kWh) that makes predict()'s fill clamp inert - larger than any real car battery (#4967)
DEBUG_ENABLE_MAX_HOURS = 2 # Auto-disable switch.predbat_debug_enable after this long left on, to bound the raw per-cycle debug.yaml disk writes it triggers (and the C++ kernel bypass it forces) if left on by accident - the rotating debug-history buffer covers longer-term history at a coarser interval instead
# How far ahead a manual override may be placed. The two horizons differ on purpose: a manual
Expand Down
40 changes: 38 additions & 2 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
LOAD_FORECAST_HISTORY_MAX_DAYS,
PREDBAT_MAX_CARS,
CAR_CHARGING_LIMIT_UNCAPPED,
OCTOPUS_MANUAL_DISPATCH_SOURCES,
DISPATCH_SOURCE_CHARGER_SCHEDULE,
CAR_CHARGING_NOW_POWER_W,
CLOUD_WINDOW_MINUTES,
CLOUD_ARRAY_MARGIN,
Expand Down Expand Up @@ -1445,6 +1447,33 @@ def fetch_sensor_data_car_planning(self):
self.log("Car {} charging is exclusive, will not plan other cars".format(car_n))
break

def octopus_smart_control_off(self, car_n, save=True):
"""
Whether Octopus Smart Control is explicitly switched off for car_n, so its planned dispatches will not happen.

The switch is octopus_intelligent_smart_control (one per car), which the apps.yaml template points at the Octopus
Energy integration's intelligent smart charge switch. Only an explicit "off" counts - an unset, missing or
unavailable switch leaves the planned slots trusted as before.

The change is logged once, and only when save is True (not in compare.py's re-runs for other tariffs).
"""
switch_config = self.get_arg("octopus_intelligent_smart_control", None, indirect=False)
if switch_config and not isinstance(switch_config, list):
switch_config = [switch_config]
switch_id = switch_config[car_n] if switch_config and car_n < len(switch_config) else None
if not switch_id:
return False
state = self.get_state_wrapper(entity_id=switch_id)
off = isinstance(state, str) and state.lower() == "off"
if save and off != self.octopus_smart_control_off_logged.get(car_n, False):
self.octopus_smart_control_off_logged[car_n] = off
if off:
self.log("Car {} Octopus Smart Control is now Off ({}), planned Octopus dispatches are ignored".format(car_n, switch_id))
else:
# Not necessarily On: unknown or unavailable is no evidence either way, so say what the switch reports
self.log("Car {} Octopus Smart Control is no longer Off ({} is {}), planned Octopus dispatches are used".format(car_n, switch_id, state))
return off

def fetch_sensor_data_cars(self, save=True):
"""
Fetch car specific data such as Octopus intelligent slots and vehicle data if we can get it, and calculate current SoC and limits based on that
Expand Down Expand Up @@ -1517,10 +1546,17 @@ def fetch_sensor_data_cars(self, save=True):
# Completed and planned slots - merge from all cars
if completed:
self.octopus_slots[car_n] += completed
if planned and (not self.octopus_intelligent_ignore_unplugged or self.car_charging_planned[car_n] or self.car_charging_now[car_n]):
# Octopus keeps returning the plan it made before Smart Control was switched off, but nothing will act
# on it (#5339). Slots already delivered (completed) are real, so those are still counted above, as are
# the manual bump/boost charges the user asked for themselves. So is a charger's own schedule: the Ohme
# component wires that in when Octopus no longer drives the charger, and Smart Control has no say in it
planned_used = planned
if self.octopus_smart_control_off(car_n, save=save):
planned_used = [slot for slot in (planned or []) if isinstance(slot, dict) and (slot.get("source") or (slot.get("meta") or {}).get("source")) in OCTOPUS_MANUAL_DISPATCH_SOURCES + (DISPATCH_SOURCE_CHARGER_SCHEDULE,)]
if planned_used and (not self.octopus_intelligent_ignore_unplugged or self.car_charging_planned[car_n] or self.car_charging_now[car_n]):
# We only count planned slots if the car is plugged in or we are ignoring unplugged cars. A car
# charging now is plugged in, even before car_charging_planned catches up with an ad-hoc dispatch
self.octopus_slots[car_n] += planned
self.octopus_slots[car_n] += planned_used

# Extract vehicle data if we can get it
size = self.get_state_wrapper(entity_id=entity_id, attribute="vehicle_battery_size_in_kwh")
Expand Down
112 changes: 109 additions & 3 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import re
from datetime import datetime, timedelta, timezone
from predbat_metrics import record_api_call
from const import TIME_FORMAT, TIME_FORMAT_OCTOPUS, DISPATCH_SOURCE_CHARGER_SCHEDULE
from const import TIME_FORMAT, TIME_FORMAT_OCTOPUS, DISPATCH_SOURCE_CHARGER_SCHEDULE, OCTOPUS_MANUAL_DISPATCH_SOURCES
from utils import str2time, minutes_to_time, dp1, dp2, dp4, minute_data, round_out_to_period, filter_payment_method, is_edge_block_body, token_mint_backoff_seconds, TOKEN_MINT_BACKOFF_LOG_INTERVAL_SECONDS
from component_base import ComponentBase
from mock_base import MockBase as SharedMockBase
Expand Down Expand Up @@ -59,6 +59,7 @@
OCTOPUS_SLOT_MAX_DEFAULT = 48 # 24 hours with 30-minute slots
OCTOPUS_SLOT_MAX_CAPPED = 12 # 6 hours with 30-minute slots
IO_RATE_TOLERANCE = 0.01 # A dispatch lowers a rate by pence; anything closer is rate_min_base's dp2 rounding against the unrounded tariff feed (#5392)
SMART_CONTROL_PENDING_SECONDS = 300 # How long a Smart Control change Octopus accepted is held against its polls - over two 2-minute device polls, not just the one straight after it

# Per-device settings read from the Octopus intelligent settings query. Kept as a list so a poll
# whose settings query fails can carry the previous values forward rather than dropping the device.
Expand All @@ -70,6 +71,7 @@
# rather than the catalogue drifting out of step with what apps.yaml actually points at.
OCTOPUS_CAR_ENTITY_SPEC = {
"octopus_intelligent_slot": ("binary_sensor", "intelligent_dispatch", "r"),
"octopus_intelligent_smart_control": ("switch", "intelligent_smart_charge", "rw"),
"octopus_ready_time": ("select", "intelligent_target_time", "rw"),
"octopus_charge_limit": ("number", "intelligent_target_soc", "rw"),
}
Expand Down Expand Up @@ -350,6 +352,15 @@ def parse_date_time(dt_str):
}}
}}"""

intelligent_smart_control_mutation = """mutation {{
updateDeviceSmartControl(input: {{
deviceId: "{device_id}"
action: {action}
}}) {{
id
}}
}}"""

intelligent_settings_mutation_schedule = """{{
dayOfWeek: {day_of_week}
time: "{target_time}"
Expand Down Expand Up @@ -644,6 +655,7 @@ def initialize(self, key, account_id, automatic):
self.sensor_updated_at = None
self.automatic = automatic
self.commands = []
self.smart_control_pending = {} # device_id -> (suspended, expiry) for a Smart Control change Octopus accepted but may not yet report
self.mpan = None
self.tou_windows = None
self.tou_windows_day = None
Expand Down Expand Up @@ -688,7 +700,66 @@ async def number_event(self, entity_id, value):
self.commands.append({"command": "set_intelligent_target_percentage", "value": value, "device_id": device_id})

async def switch_event(self, entity_id, service):
pass
"""
Handle a turn on/off/toggle of an Intelligent Smart Control switch by queueing the command that sends it to
Octopus. The switch shows the change once Octopus has accepted it, as the target time and SoC do, so it
never shows a state Octopus does not have. Only the latest change per device is kept in the queue.
"""
suffix = self.get_entity_suffix(entity_id)
device_id = self.suffix_to_device_id(suffix)
if not device_id or self.device_id_to_index_suffix(device_id) != suffix or entity_id != self.get_entity_name("switch", "intelligent_smart_charge", index=suffix):
return
device = self.intelligent_devices.get(device_id)
if not device:
return
queued = [command for command in self.commands if self.is_smart_control_command(command, device_id)]
if service == "turn_on":
enabled = True
elif service == "turn_off":
enabled = False
elif service == "toggle":
# Toggle from the change already asked for, if there is one, so two toggles cancel out
enabled = not queued[-1]["value"] if queued else bool(device.get("suspended"))
else:
return
self.commands = [command for command in self.commands if not self.is_smart_control_command(command, device_id)]
self.commands.append({"command": "set_intelligent_smart_control", "value": enabled, "device_id": device_id})

@staticmethod
def is_smart_control_command(command, device_id):
"""
Whether a queued command sets Smart Control for device_id.
"""
return command.get("command") == "set_intelligent_smart_control" and command.get("device_id") == device_id

def apply_smart_control_pending(self, device_id, device):
"""
Hold a Smart Control change Octopus has just accepted against its polls for SMART_CONTROL_PENDING_SECONDS,
as Octopus can go on reporting the old state for a while, and the switch would otherwise flip back.

The hold runs its full time even once a poll agrees: a poll whose settings query failed reuses the state
already on show, so agreeing is no sign Octopus has caught up.
"""
pending = self.smart_control_pending.get(device_id)
if not pending:
return
suspended, expiry = pending
if datetime.now() >= expiry:
self.smart_control_pending.pop(device_id, None)
else:
device["suspended"] = suspended

def publish_smart_control_switch(self, device_id, device):
"""
Publish the Smart Control switch of an Intelligent device, on while the device is not suspended. It is
published for suspended devices too, so that Smart Control can be turned back on.
"""
self.dashboard_item(
self.get_entity_name("switch", "intelligent_smart_charge", index=self.device_id_to_index_suffix(device_id)),
"off" if device.get("suspended") else "on",
attributes={"friendly_name": "Octopus Intelligent Smart Control ({})".format(device.get("model") or self.device_id_to_index_suffix(device_id)), "icon": "mdi:ev-station"},
app="octopus",
)

def is_alive(self):
return self.api_started and self.account_data
Expand Down Expand Up @@ -833,6 +904,11 @@ async def process_commands(self, account_id):
device_id = command.get("device_id", None)
await self.async_set_intelligent_target_schedule(account_id, target_time=value, device_id=device_id)
done_command = True
elif command_name == "set_intelligent_smart_control":
# A newer change for the same device, made while this batch was being sent, replaces this one
if not any(self.is_smart_control_command(queued, command.get("device_id", None)) for queued in self.commands):
await self.async_set_intelligent_smart_control(command.get("device_id", None), command.get("value", True))
done_command = True
elif command_name == "join_saving_session_event":
event_code = command.get("event_code", None)
await self.async_join_saving_session_events(self.account_id, event_code)
Expand Down Expand Up @@ -1032,6 +1108,7 @@ async def async_update_intelligent_devices(self, account_id):
# Always call fetch_previous_dispatch when completed dispatches are available to merge historical data.
for device_id in intelligent_devices:
device = intelligent_devices[device_id]
self.apply_smart_control_pending(device_id, device)
if "completed_dispatches" in device:
self.intelligent_devices[device_id] = device
await self.fetch_previous_dispatch(device_id)
Expand Down Expand Up @@ -1149,6 +1226,31 @@ async def async_set_intelligent_target_schedule(self, account_id, device_id, tar
else:
self.log("Warn: OctopusAPI: Try to set target schedule, but no intelligent device ID {} found".format(device_id))

async def async_set_intelligent_smart_control(self, device_id, enabled):
"""
Turn Octopus Smart Control on (UNSUSPEND) or off (SUSPEND) for an intelligent device, and show it on the switch
once Octopus accepts it. If Octopus rejects it the switch is left as it was.
"""
device = self.intelligent_devices.get(device_id)
if not device:
self.log("Warn: OctopusAPI: Try to set Smart Control, but no intelligent device ID {} found".format(device_id))
return False
action = "UNSUSPEND" if enabled else "SUSPEND"
self.log("OctopusAPI: Setting Smart Control {} for intelligent device {}".format(action, device_id))
try:
result = await self.async_graphql_query(intelligent_smart_control_mutation.format(device_id=device_id, action=action), "set-intelligent-smart-control", returns_data=False)
except Exception as error: # pylint: disable=broad-exception-caught
self.log("Warn: OctopusAPI: Smart Control {} for intelligent device {} raised {}".format(action, device_id, error))
result = None
if not result or result.get("updateDeviceSmartControl") is None:
self.log("Warn: OctopusAPI: Failed to {} Smart Control for intelligent device {}, the switch stays as it was".format(action, device_id))
self.publish_smart_control_switch(device_id, device)
return False
device["suspended"] = not enabled
self.smart_control_pending[device_id] = (not enabled, datetime.now() + timedelta(seconds=SMART_CONTROL_PENDING_SECONDS))
self.publish_smart_control_switch(device_id, device)
return True

async def async_join_saving_session_events(self, account_id, event_code):
"""
Join the saving session events
Expand Down Expand Up @@ -1469,6 +1571,7 @@ def automatic_config(self, tariffs):
# num_cars past what Predbat actually supports (see fetch_config_options' clamp).
active_devices = {device_id: device for device_id, device in devices.items() if not device.get("suspended")}
slot_list = []
smart_control_list = []
ready_list = []
limit_list = []
# Sort so a given device always lands in the same car slot. The slot index is what
Expand All @@ -1479,6 +1582,7 @@ def automatic_config(self, tariffs):
for device_id in sorted(active_devices):
index_suffix = self.device_id_to_index_suffix(device_id)
slot_list.append(self.get_entity_name("binary_sensor", "intelligent_dispatch", index=index_suffix))
smart_control_list.append(self.get_entity_name("switch", "intelligent_smart_charge", index=index_suffix))
ready_list.append(self.get_entity_name("select", "intelligent_target_time", index=index_suffix))
limit_list.append(self.get_entity_name("number", "intelligent_target_soc", index=index_suffix))
# With no active device left the wiring is cleared - but only wiring that is still what
Expand All @@ -1490,6 +1594,7 @@ def automatic_config(self, tariffs):
self.log("OctopusAPI: No active intelligent devices, and the car slot wiring is not from here - leaving it alone")
else:
self.set_arg("octopus_intelligent_slot", slot_list)
self.set_arg("octopus_intelligent_smart_control", smart_control_list)
self.set_arg("octopus_ready_time", ready_list)
self.set_arg("octopus_charge_limit", limit_list)
self.intelligent_config_slots = slot_list
Expand Down Expand Up @@ -2684,6 +2789,7 @@ async def async_intelligent_update_sensor(self, account_id):
active_event = True
dispatch_attributes = {"friendly_name": "Octopus Intelligent Dispatches", "icon": "mdi:flash", **device}
self.dashboard_item(self.get_entity_name("binary_sensor", "intelligent_dispatch", index=device_index), "on" if active_event else "off", attributes=dispatch_attributes, app="octopus")
self.publish_smart_control_switch(device_id, device)
dispatch_slots.append(planned + completed)

weekday_target_time = device.get("weekday_target_time", None)
Expand Down Expand Up @@ -3312,7 +3418,7 @@ def dispatch_billed_off_peak(self, source, location, end_minutes):
:return: True if the dispatch is eligible for the off-peak rate
"""
# Ignore bump-charge slots as their cost won't change
if source == "bump-charge" or source == "BOOST":
if source in OCTOPUS_MANUAL_DISPATCH_SOURCES:
return False
# A charger's own schedule on a tariff with no dispatch rate: car load only, never a cheap slot
if source == DISPATCH_SOURCE_CHARGER_SCHEDULE:
Expand Down
1 change: 1 addition & 0 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,7 @@ def reset(self):
self.low_rates = []
self.high_export_rates = []
self.octopus_slots = [[] for _ in range(8)]
self.octopus_smart_control_off_logged = {}
self.cost_today_sofar = 0
self.carbon_today_sofar = 0
self.import_today = {}
Expand Down
Loading
Loading