From fc9720b41a215bfdba210291698e4e8388045fac Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Thu, 1 Oct 2026 23:31:44 +0100 Subject: [PATCH 01/15] fix(octopus): ignore planned Intelligent dispatches while Smart Control is off (#5339) Octopus keeps returning the plan it made before Smart Control was switched off (isSuspended true, flexPlannedDispatches unchanged), and the Octopus Energy integration relays it unchanged, so Predbat kept costing the planned and bonus slots at the cheap rate. When the Smart Control switch reads off, skip the planned dispatches of that car and keep the completed ones. The switch is octopus_intelligent_smart_control (optional, one per car), or is derived from the integration's intelligent_dispatching sensor name. Only an explicit "off" counts, so a missing or unavailable switch behaves as before. Co-Authored-By: Claude Sonnet 5.5 --- apps/predbat/config.py | 1 + apps/predbat/fetch.py | 34 +++- apps/predbat/predbat.py | 1 + .../tests/test_octopus_smart_control.py | 183 ++++++++++++++++++ apps/predbat/unit_test.py | 2 + docs/apps-yaml.md | 1 + docs/car-charging.md | 6 + 7 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 apps/predbat/tests/test_octopus_smart_control.py diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 6aef46e77..a1c1c29ed 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -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"}, diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index bf089d69f..b5ec9c59e 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -42,6 +42,7 @@ from axle import fetch_axle_sessions, load_axle_slot, fetch_axle_active import copy +import re class Fetch: @@ -1445,6 +1446,32 @@ 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, slot_entity_id): + """ + 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), or for the Octopus Energy integration it is + derived from the dispatching sensor (binary_sensor.octopus_energy__intelligent_dispatching -> + switch.octopus_energy__intelligent_smart_charge). Only an explicit "off" counts - a missing or + unavailable switch leaves the planned slots trusted as before. + """ + 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 and isinstance(slot_entity_id, str): + match = re.fullmatch(r"binary_sensor\.(octopus_energy_.+)_intelligent_dispatching", slot_entity_id) + if match: + switch_id = "switch.{}_intelligent_smart_charge".format(match.group(1)) + if not switch_id: + return False + state = self.get_state_wrapper(entity_id=switch_id) + off = isinstance(state, str) and state.lower() == "off" + if off != self.octopus_smart_control_off_logged.get(car_n, False): + self.octopus_smart_control_off_logged[car_n] = off + self.log("Car {} Octopus Smart Control is now {} ({}), planned Octopus dispatches are {}".format(car_n, "Off" if off else "On", switch_id, "ignored" if off else "used")) + 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 @@ -1517,7 +1544,12 @@ 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]): + smart_control_off = self.octopus_smart_control_off(car_n, entity_id) + if planned and smart_control_off: + # 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 + pass + elif planned 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 diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 8af085388..a1fd7195a 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -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 = {} diff --git a/apps/predbat/tests/test_octopus_smart_control.py b/apps/predbat/tests/test_octopus_smart_control.py new file mode 100644 index 000000000..191f1ddcc --- /dev/null +++ b/apps/predbat/tests/test_octopus_smart_control.py @@ -0,0 +1,183 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2024 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + +import copy +from datetime import timedelta + +from tests.test_multi_car_iog import pin_test_clock, restore_test_clock + +SLOT_SENSOR = "binary_sensor.octopus_energy_abc123_intelligent_dispatching" +DERIVED_SWITCH = "switch.octopus_energy_abc123_intelligent_smart_charge" +CUSTOM_SENSOR = "binary_sensor.my_car_dispatching" +CUSTOM_SWITCH = "switch.my_car_smart_control" + +STATE_FIELDS = ( + "num_cars", + "car_charging_planned", + "car_charging_now", + "car_charging_plan_smart", + "car_charging_plan_max_price", + "car_charging_plan_time", + "car_charging_battery_size", + "car_charging_limit", + "car_charging_rate", + "car_charging_slots", + "car_charging_exclusive", + "car_charging_manual_soc", + "octopus_intelligent_charging", + "octopus_intelligent_ignore_unplugged", + "octopus_intelligent_consider_full", + "octopus_slots", + "octopus_smart_control_off_logged", +) +ARG_KEYS = ("car_charging_loss", "car_charging_soc", "car_charging_limit", "octopus_intelligent_slot", "octopus_intelligent_smart_control") + + +def _setup(my_predbat): + """ + One IOG car, plugged in, with a planned and a completed dispatch on the slot sensor. + """ + my_predbat.num_cars = 1 + my_predbat.car_charging_planned = [True] + my_predbat.car_charging_now = [False] + my_predbat.car_charging_plan_smart = [False] + my_predbat.car_charging_plan_max_price = [0] + my_predbat.car_charging_plan_time = ["07:00:00"] + my_predbat.car_charging_battery_size = [100.0] + my_predbat.car_charging_limit = [100.0] + my_predbat.car_charging_rate = [7.4] + my_predbat.car_charging_slots = [[]] + my_predbat.car_charging_exclusive = [False] + my_predbat.car_charging_manual_soc = [False] + my_predbat.octopus_intelligent_charging = True + my_predbat.octopus_intelligent_ignore_unplugged = False + my_predbat.octopus_intelligent_consider_full = False + my_predbat.octopus_smart_control_off_logged = {} + my_predbat.args["car_charging_loss"] = 0.0 + my_predbat.args["car_charging_soc"] = [50.0] + my_predbat.args["car_charging_limit"] = [100.0] + now = my_predbat.now_utc + fmt = "%Y-%m-%dT%H:%M:%S%z" + attributes = { + "completed_dispatches": [{"start": (now - timedelta(hours=3)).strftime(fmt), "end": (now - timedelta(hours=2)).strftime(fmt), "charge_in_kwh": 5.0, "source": "smart-charge", "location": "AT_HOME"}], + "planned_dispatches": [{"start": (now + timedelta(hours=1)).strftime(fmt), "end": (now + timedelta(hours=2)).strftime(fmt), "charge_in_kwh": 10.0, "source": "smart-charge", "location": "AT_HOME"}], + "vehicle_battery_size_in_kwh": 100.0, + "charge_point_power_in_kw": 7.4, + } + my_predbat.ha_interface.set_state(SLOT_SENSOR, "off", attributes=copy.deepcopy(attributes)) + my_predbat.ha_interface.set_state(CUSTOM_SENSOR, "off", attributes=copy.deepcopy(attributes)) + my_predbat.args["octopus_intelligent_slot"] = SLOT_SENSOR + my_predbat.args.pop("octopus_intelligent_smart_control", None) + + +def _slots(my_predbat): + """ + Run the fetch and return the (planned, completed) slot counts it produced for car 0, by the dispatches' own kWh. + """ + my_predbat.octopus_slots = [[]] + my_predbat.fetch_sensor_data_cars(save=False) + kwh = sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[0]) + return kwh + + +def _check(name, condition, detail=""): + """ + Print an error for a failed condition and return whether it failed. + """ + if not condition: + print("ERROR: {} {}".format(name, detail)) + return True + return False + + +def run_octopus_smart_control_tests(my_predbat): + """ + Octopus keeps returning its plan after Smart Control is switched off, so planned dispatches must be + ignored while the Smart Control switch is off, and completed ones kept (#5339). + """ + print("*** Running test: octopus_smart_control") + failed = False + saved_state = {field: copy.deepcopy(getattr(my_predbat, field, None)) for field in STATE_FIELDS} + saved_args = {key: copy.deepcopy(my_predbat.args[key]) for key in ARG_KEYS if key in my_predbat.args} + saved_clock = pin_test_clock(my_predbat) + items = my_predbat.ha_interface.dummy_items + try: + _setup(my_predbat) + + print("Test 1: no switch entity - planned and completed slots both used") + items.pop(DERIVED_SWITCH, None) + kwh = _slots(my_predbat) + failed |= _check("t1 both", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + + print("Test 2: derived switch on - planned and completed slots both used") + items[DERIVED_SWITCH] = "on" + kwh = _slots(my_predbat) + failed |= _check("t2 both", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + + print("Test 3: derived switch off - planned slots dropped, completed kept") + items[DERIVED_SWITCH] = "off" + kwh = _slots(my_predbat) + failed |= _check("t3 completed only", kwh == [5.0], "kwh {}".format(kwh)) + + print("Test 4: switch back on - planned slots return") + items[DERIVED_SWITCH] = "on" + kwh = _slots(my_predbat) + failed |= _check("t4 both again", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + + print("Test 5: unavailable or unknown switch is no evidence - planned slots kept") + for state in ("unavailable", "unknown", None): + items[DERIVED_SWITCH] = state + kwh = _slots(my_predbat) + failed |= _check("t5 {} kept".format(state), kwh == [5.0, 10.0], "kwh {}".format(kwh)) + + print("Test 6: a slot sensor that is not the Octopus Energy integration's has no derived switch") + items[DERIVED_SWITCH] = "off" + items[CUSTOM_SWITCH] = "off" + my_predbat.args["octopus_intelligent_slot"] = CUSTOM_SENSOR + kwh = _slots(my_predbat) + failed |= _check("t6 not derived", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + + print("Test 7: octopus_intelligent_smart_control names the switch explicitly (single value)") + my_predbat.args["octopus_intelligent_smart_control"] = CUSTOM_SWITCH + kwh = _slots(my_predbat) + failed |= _check("t7 explicit off", kwh == [5.0], "kwh {}".format(kwh)) + items[CUSTOM_SWITCH] = "on" + kwh = _slots(my_predbat) + failed |= _check("t7 explicit on", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + + print("Test 8: an explicit switch (list form) beats the derived one") + my_predbat.args["octopus_intelligent_slot"] = SLOT_SENSOR + my_predbat.args["octopus_intelligent_smart_control"] = [CUSTOM_SWITCH] + items[DERIVED_SWITCH] = "off" + items[CUSTOM_SWITCH] = "on" + kwh = _slots(my_predbat) + failed |= _check("t8 explicit wins", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + + print("Test 9: the slot signature changes when Smart Control goes off, so the plan is recomputed") + my_predbat.args.pop("octopus_intelligent_smart_control", None) + items[DERIVED_SWITCH] = "on" + _slots(my_predbat) + before = copy.deepcopy(my_predbat.octopus_slots) + items[DERIVED_SWITCH] = "off" + _slots(my_predbat) + failed |= _check("t9 signature", my_predbat.octopus_slots_signature(before) != my_predbat.octopus_slots_signature(my_predbat.octopus_slots), "") + finally: + for field, value in saved_state.items(): + setattr(my_predbat, field, value) + for key in ARG_KEYS: + if key in saved_args: + my_predbat.args[key] = saved_args[key] + else: + my_predbat.args.pop(key, None) + for entity in (SLOT_SENSOR, CUSTOM_SENSOR, DERIVED_SWITCH, CUSTOM_SWITCH): + items.pop(entity, None) + restore_test_clock(my_predbat, saved_clock) + print("*** octopus_smart_control test {}".format("FAILED" if failed else "PASSED")) + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 0f3d5a499..6013268e6 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -43,6 +43,7 @@ from tests.test_charge_hold import run_charge_hold_tests from tests.test_octopus_slots import run_load_octopus_slots_tests, run_octopus_slot_max_default_tests from tests.test_multi_car_iog import run_multi_car_iog_tests +from tests.test_octopus_smart_control import run_octopus_smart_control_tests from tests.test_fetch_config_options import test_fetch_config_options from tests.test_multi_inverter import run_inverter_multi_tests from tests.test_window2minutes import test_window2minutes @@ -571,6 +572,7 @@ def main(): ("octopus_slots", run_load_octopus_slots_tests, "Load Octopus slots tests", False), ("octopus_slot_max_default", run_octopus_slot_max_default_tests, "Octopus slot max auto-detection from IOG-SMB tariff code", False), ("multi_car_iog", run_multi_car_iog_tests, "Multi-car IOG tests", False), + ("octopus_smart_control", run_octopus_smart_control_tests, "Planned Octopus dispatches are ignored while Smart Control is off", False), ("rate_add_io_slots", run_rate_add_io_slots_tests, "Rate add IO slots tests", False), ("iog_car_need_gate", run_iog_car_need_gate_tests, "IOG car-need gate: no cheap rate for dispatch minutes no car needs", False), ("iog_charge_skew", run_iog_charge_skew_tests, "IOG earlier-charge skew characterisation tests", False), diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index c87acc0cf..37e592a6a 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -2098,6 +2098,7 @@ Details of configuring `apps.yaml` for EV charging are described in [Configure a - **car_charging_power** - Live power drawn by your EV charger, used for display only - **octopus_intelligent_slot** - Octopus Energy integration 'intelligent dispatching' sensor that indicates whether you are within an Octopus Energy "smart charge" slot +- **octopus_intelligent_smart_control** - Optional Octopus Energy integration 'intelligent smart charge' switch, planned Octopus slots are ignored while it is off (derived automatically from **octopus_intelligent_slot** when not set, see [car charging](car-charging.md#planned-car-charging)) - **octopus_ready_time** - Octopus Energy integration sensor for when the car charging will be completed by - **octopus_charge_limit** - Octopus Energy integration sensor for car charging limit - **octopus_slot_low_rate** - Whether Octopus Intelligent Slots reported will be at the lowest rate if at home diff --git a/docs/car-charging.md b/docs/car-charging.md index a7d88ef4c..a0596897a 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -141,6 +141,12 @@ The following `apps.yaml` configuration items are pre-defined with regular expre whether you are within an Octopus Energy "smart charge" slot, and provides the list of future planned charging activity. For **multiple IOG-enrolled vehicles**, set this to a list with one sensor per car (see [Multiple Electric Cars](#multiple-electric-cars)). +- **octopus_intelligent_smart_control** - Optional. Points to the Octopus Energy integration 'intelligent smart charge' switch (`switch.octopus_energy_{{DEVICE_ID}}_intelligent_smart_charge`), +which is on while Octopus Smart Control is on for the car. Octopus keeps reporting the charging plan it had already made after you switch Smart Control off, so while this switch is `off` +Predbat ignores the *planned* Octopus slots (including any bonus slots) as they will not happen; slots already delivered still count. +You do not normally need to set this: when **octopus_intelligent_slot** is the integration's `intelligent_dispatching` sensor, Predbat derives the switch from it. Only set it if you have renamed the switch. +For **multiple IOG-enrolled vehicles**, set this to a list with one switch per car. + - **octopus_ready_time** - Points to the Octopus Energy integration sensor that details when the car charging will be completed.
*Note:* the Octopus Integration now provides [Octopus Intelligent target time](https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/entities/intelligent/#target-time-time) in two formats, either a 'select' entity or a 'time' entity. Predbat uses the time entity (time.octopus_energy_{{DEVICE_ID}}_intelligent_target_time) which is disabled by default, so you will need to enable the time entity and disable the matching select entity. From 7555d0d27d628e715fbc6686453e00d5b57d15e5 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Thu, 1 Oct 2026 23:35:36 +0100 Subject: [PATCH 02/15] fix(octopus): address review of the Smart Control change (#5339) Derive the switch for the legacy sensor name without a device id, only log the Smart Control change on a live fetch (not compare.py re-runs), fold the skip into the existing planned-slot condition so the switch is read only when there are planned slots, and cover multi-car, short lists, case and logging in tests. Co-Authored-By: Claude Sonnet 5.5 --- apps/predbat/fetch.py | 15 ++-- .../tests/test_octopus_smart_control.py | 86 +++++++++++++++++-- 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index b5ec9c59e..5f9f40671 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -1446,7 +1446,7 @@ 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, slot_entity_id): + def octopus_smart_control_off(self, car_n, slot_entity_id, save=True): """ Whether Octopus Smart Control is explicitly switched off for car_n, so its planned dispatches will not happen. @@ -1460,14 +1460,14 @@ def octopus_smart_control_off(self, car_n, slot_entity_id): 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 and isinstance(slot_entity_id, str): - match = re.fullmatch(r"binary_sensor\.(octopus_energy_.+)_intelligent_dispatching", slot_entity_id) + match = re.fullmatch(r"binary_sensor\.(octopus_energy(?:_.+)?)_intelligent_dispatching", slot_entity_id) if match: switch_id = "switch.{}_intelligent_smart_charge".format(match.group(1)) if not switch_id: return False state = self.get_state_wrapper(entity_id=switch_id) off = isinstance(state, str) and state.lower() == "off" - if off != self.octopus_smart_control_off_logged.get(car_n, False): + if save and off != self.octopus_smart_control_off_logged.get(car_n, False): self.octopus_smart_control_off_logged[car_n] = off self.log("Car {} Octopus Smart Control is now {} ({}), planned Octopus dispatches are {}".format(car_n, "Off" if off else "On", switch_id, "ignored" if off else "used")) return off @@ -1544,12 +1544,9 @@ def fetch_sensor_data_cars(self, save=True): # Completed and planned slots - merge from all cars if completed: self.octopus_slots[car_n] += completed - smart_control_off = self.octopus_smart_control_off(car_n, entity_id) - if planned and smart_control_off: - # 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 - pass - elif 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 + if planned and not self.octopus_smart_control_off(car_n, entity_id, save=save) 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 diff --git a/apps/predbat/tests/test_octopus_smart_control.py b/apps/predbat/tests/test_octopus_smart_control.py index 191f1ddcc..678ba3583 100644 --- a/apps/predbat/tests/test_octopus_smart_control.py +++ b/apps/predbat/tests/test_octopus_smart_control.py @@ -15,6 +15,10 @@ SLOT_SENSOR = "binary_sensor.octopus_energy_abc123_intelligent_dispatching" DERIVED_SWITCH = "switch.octopus_energy_abc123_intelligent_smart_charge" +LEGACY_SENSOR = "binary_sensor.octopus_energy_intelligent_dispatching" +LEGACY_SWITCH = "switch.octopus_energy_intelligent_smart_charge" +CAR2_SENSOR = "binary_sensor.octopus_energy_def456_intelligent_dispatching" +CAR2_SWITCH = "switch.octopus_energy_def456_intelligent_smart_charge" CUSTOM_SENSOR = "binary_sensor.my_car_dispatching" CUSTOM_SWITCH = "switch.my_car_smart_control" @@ -73,18 +77,19 @@ def _setup(my_predbat): } my_predbat.ha_interface.set_state(SLOT_SENSOR, "off", attributes=copy.deepcopy(attributes)) my_predbat.ha_interface.set_state(CUSTOM_SENSOR, "off", attributes=copy.deepcopy(attributes)) + my_predbat.ha_interface.set_state(LEGACY_SENSOR, "off", attributes=copy.deepcopy(attributes)) + my_predbat.ha_interface.set_state(CAR2_SENSOR, "off", attributes=copy.deepcopy(attributes)) my_predbat.args["octopus_intelligent_slot"] = SLOT_SENSOR my_predbat.args.pop("octopus_intelligent_smart_control", None) -def _slots(my_predbat): +def _slots(my_predbat, save=False, car_n=0): """ - Run the fetch and return the (planned, completed) slot counts it produced for car 0, by the dispatches' own kWh. + Run the fetch and return the dispatches' own kWh it produced for car_n, sorted - 5.0 is the completed one, 10.0 the planned one. """ - my_predbat.octopus_slots = [[]] - my_predbat.fetch_sensor_data_cars(save=False) - kwh = sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[0]) - return kwh + my_predbat.octopus_slots = [[] for _ in range(my_predbat.num_cars)] + my_predbat.fetch_sensor_data_cars(save=save) + return sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[car_n]) def _check(name, condition, detail=""): @@ -168,6 +173,73 @@ def run_octopus_smart_control_tests(my_predbat): items[DERIVED_SWITCH] = "off" _slots(my_predbat) failed |= _check("t9 signature", my_predbat.octopus_slots_signature(before) != my_predbat.octopus_slots_signature(my_predbat.octopus_slots), "") + + print("Test 10: the legacy entity names without a device id derive their switch too") + my_predbat.args["octopus_intelligent_slot"] = LEGACY_SENSOR + items[LEGACY_SWITCH] = "off" + kwh = _slots(my_predbat) + failed |= _check("t10 legacy off", kwh == [5.0], "kwh {}".format(kwh)) + items[LEGACY_SWITCH] = "on" + kwh = _slots(my_predbat) + failed |= _check("t10 legacy on", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + + print("Test 11: the state is case-insensitive, and an odd state string is no evidence") + my_predbat.args["octopus_intelligent_slot"] = SLOT_SENSOR + items[DERIVED_SWITCH] = "OFF" + kwh = _slots(my_predbat) + failed |= _check("t11 OFF", kwh == [5.0], "kwh {}".format(kwh)) + items[DERIVED_SWITCH] = "true" + kwh = _slots(my_predbat) + failed |= _check("t11 true", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + + print("Test 12: the change is logged once on a live fetch, and not at all on a save=False re-run") + logs = [] + real_log = my_predbat.log + my_predbat.log = lambda message, *args, **kwargs: (logs.append(message), real_log(message, *args, **kwargs))[1] + try: + items[DERIVED_SWITCH] = "off" + my_predbat.octopus_smart_control_off_logged = {} + _slots(my_predbat, save=False) + failed |= _check("t12 no log on re-run", not [x for x in logs if "Smart Control is now" in x], "logs {}".format(logs)) + _slots(my_predbat, save=True) + _slots(my_predbat, save=True) + now_off = [x for x in logs if "Smart Control is now Off" in x] + failed |= _check("t12 logged once", len(now_off) == 1, "logs {}".format(logs)) + finally: + my_predbat.log = real_log + + print("Test 13: two cars - Smart Control off for one only") + my_predbat.num_cars = 2 + my_predbat.car_charging_planned = [True, True] + my_predbat.car_charging_now = [False, False] + my_predbat.car_charging_plan_smart = [False, False] + my_predbat.car_charging_plan_max_price = [0, 0] + my_predbat.car_charging_plan_time = ["07:00:00", "07:00:00"] + my_predbat.car_charging_battery_size = [100.0, 100.0] + my_predbat.car_charging_limit = [100.0, 100.0] + my_predbat.car_charging_rate = [7.4, 7.4] + my_predbat.car_charging_slots = [[], []] + my_predbat.car_charging_exclusive = [False, False] + my_predbat.car_charging_manual_soc = [False, False] + my_predbat.args["car_charging_soc"] = [50.0, 50.0] + my_predbat.args["car_charging_limit"] = [100.0, 100.0] + my_predbat.args["octopus_intelligent_slot"] = [SLOT_SENSOR, CAR2_SENSOR] + items[DERIVED_SWITCH] = "off" + items[CAR2_SWITCH] = "on" + failed |= _check("t13 car 0 off", _slots(my_predbat, car_n=0) == [5.0], "") + failed |= _check("t13 car 1 on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0, 10.0], "") + items[DERIVED_SWITCH] = "on" + items[CAR2_SWITCH] = "off" + _slots(my_predbat) + failed |= _check("t13 car 0 on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[0]) == [5.0, 10.0], "") + failed |= _check("t13 car 1 off", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0], "") + + print("Test 14: a switch list shorter than the cars falls back to the derived switch for the rest") + my_predbat.args["octopus_intelligent_smart_control"] = [CUSTOM_SWITCH] + items[CUSTOM_SWITCH] = "on" + _slots(my_predbat) + failed |= _check("t14 car 0 explicit on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[0]) == [5.0, 10.0], "") + failed |= _check("t14 car 1 derived off", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0], "") finally: for field, value in saved_state.items(): setattr(my_predbat, field, value) @@ -176,7 +248,7 @@ def run_octopus_smart_control_tests(my_predbat): my_predbat.args[key] = saved_args[key] else: my_predbat.args.pop(key, None) - for entity in (SLOT_SENSOR, CUSTOM_SENSOR, DERIVED_SWITCH, CUSTOM_SWITCH): + for entity in (SLOT_SENSOR, CUSTOM_SENSOR, LEGACY_SENSOR, CAR2_SENSOR, DERIVED_SWITCH, CUSTOM_SWITCH, LEGACY_SWITCH, CAR2_SWITCH): items.pop(entity, None) restore_test_clock(my_predbat, saved_clock) print("*** octopus_smart_control test {}".format("FAILED" if failed else "PASSED")) From 0e31b83e2aa7e469abd844d4d6507d3c2e5695ce Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Thu, 1 Oct 2026 23:39:28 +0100 Subject: [PATCH 03/15] fix(octopus): keep manual bump/boost dispatches while Smart Control is off (#5339) A bump or boost charge is something the user asked for, so Smart Control being off does not void it. Also read the switch on every fetch so a change is logged even when no dispatches are planned, and restore more fetch state in the test. Co-Authored-By: Claude Sonnet 5.5 --- apps/predbat/fetch.py | 10 ++++-- .../tests/test_octopus_smart_control.py | 32 ++++++++++++++++--- docs/car-charging.md | 2 +- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 5f9f40671..93f95bb02 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -44,6 +44,9 @@ import copy import re +# Planned dispatch sources the user asks for by hand, which Smart Control being off does not void +OCTOPUS_MANUAL_DISPATCH_SOURCES = ("bump-charge", "BOOST") + class Fetch: """Data fetching mixin for loading energy rates, consumption, and forecasts. @@ -1545,8 +1548,11 @@ def fetch_sensor_data_cars(self, save=True): if completed: self.octopus_slots[car_n] += completed # 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 - if planned and not self.octopus_smart_control_off(car_n, entity_id, save=save) and (not self.octopus_intelligent_ignore_unplugged or self.car_charging_planned[car_n] or self.car_charging_now[car_n]): + # 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 + if self.octopus_smart_control_off(car_n, entity_id, save=save): + planned = [slot for slot in (planned or []) if isinstance(slot, dict) and slot.get("source") in OCTOPUS_MANUAL_DISPATCH_SOURCES] + if planned 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 diff --git a/apps/predbat/tests/test_octopus_smart_control.py b/apps/predbat/tests/test_octopus_smart_control.py index 678ba3583..f3f10acd1 100644 --- a/apps/predbat/tests/test_octopus_smart_control.py +++ b/apps/predbat/tests/test_octopus_smart_control.py @@ -40,6 +40,11 @@ "octopus_intelligent_consider_full", "octopus_slots", "octopus_smart_control_off_logged", + "dispatch_timeline_pending", + "dispatch_timeline_last", + "car_charging_soc", + "car_charging_soc_next", + "car_charging_loss", ) ARG_KEYS = ("car_charging_loss", "car_charging_soc", "car_charging_limit", "octopus_intelligent_slot", "octopus_intelligent_smart_control") @@ -174,7 +179,24 @@ def run_octopus_smart_control_tests(my_predbat): _slots(my_predbat) failed |= _check("t9 signature", my_predbat.octopus_slots_signature(before) != my_predbat.octopus_slots_signature(my_predbat.octopus_slots), "") - print("Test 10: the legacy entity names without a device id derive their switch too") + print("Test 10: manual bump/boost dispatches are kept while Smart Control is off, scheduled ones are not") + my_predbat.num_cars = 1 + my_predbat.args["octopus_intelligent_slot"] = SLOT_SENSOR + my_predbat.args.pop("octopus_intelligent_smart_control", None) + attributes = copy.deepcopy(items[SLOT_SENSOR]) + boost_start = (my_predbat.now_utc + timedelta(hours=4)).strftime("%Y-%m-%dT%H:%M:%S%z") + boost_end = (my_predbat.now_utc + timedelta(hours=5)).strftime("%Y-%m-%dT%H:%M:%S%z") + attributes["planned_dispatches"].append({"start": boost_start, "end": boost_end, "charge_in_kwh": 3.0, "source": "bump-charge", "location": "AT_HOME"}) + my_predbat.ha_interface.set_state(SLOT_SENSOR, "off", attributes=attributes) + items[DERIVED_SWITCH] = "off" + kwh = _slots(my_predbat) + failed |= _check("t15 boost kept", kwh == [3.0, 5.0], "kwh {}".format(kwh)) + items[DERIVED_SWITCH] = "on" + kwh = _slots(my_predbat) + failed |= _check("t15 all on", kwh == [3.0, 5.0, 10.0], "kwh {}".format(kwh)) + _setup(my_predbat) + + print("Test 11: the legacy entity names without a device id derive their switch too") my_predbat.args["octopus_intelligent_slot"] = LEGACY_SENSOR items[LEGACY_SWITCH] = "off" kwh = _slots(my_predbat) @@ -183,7 +205,7 @@ def run_octopus_smart_control_tests(my_predbat): kwh = _slots(my_predbat) failed |= _check("t10 legacy on", kwh == [5.0, 10.0], "kwh {}".format(kwh)) - print("Test 11: the state is case-insensitive, and an odd state string is no evidence") + print("Test 12: the state is case-insensitive, and an odd state string is no evidence") my_predbat.args["octopus_intelligent_slot"] = SLOT_SENSOR items[DERIVED_SWITCH] = "OFF" kwh = _slots(my_predbat) @@ -192,7 +214,7 @@ def run_octopus_smart_control_tests(my_predbat): kwh = _slots(my_predbat) failed |= _check("t11 true", kwh == [5.0, 10.0], "kwh {}".format(kwh)) - print("Test 12: the change is logged once on a live fetch, and not at all on a save=False re-run") + print("Test 13: the change is logged once on a live fetch, and not at all on a save=False re-run") logs = [] real_log = my_predbat.log my_predbat.log = lambda message, *args, **kwargs: (logs.append(message), real_log(message, *args, **kwargs))[1] @@ -208,7 +230,7 @@ def run_octopus_smart_control_tests(my_predbat): finally: my_predbat.log = real_log - print("Test 13: two cars - Smart Control off for one only") + print("Test 14: two cars - Smart Control off for one only") my_predbat.num_cars = 2 my_predbat.car_charging_planned = [True, True] my_predbat.car_charging_now = [False, False] @@ -234,7 +256,7 @@ def run_octopus_smart_control_tests(my_predbat): failed |= _check("t13 car 0 on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[0]) == [5.0, 10.0], "") failed |= _check("t13 car 1 off", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0], "") - print("Test 14: a switch list shorter than the cars falls back to the derived switch for the rest") + print("Test 15: a switch list shorter than the cars falls back to the derived switch for the rest") my_predbat.args["octopus_intelligent_smart_control"] = [CUSTOM_SWITCH] items[CUSTOM_SWITCH] = "on" _slots(my_predbat) diff --git a/docs/car-charging.md b/docs/car-charging.md index a0596897a..d1a268ca9 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -143,7 +143,7 @@ For **multiple IOG-enrolled vehicles**, set this to a list with one sensor per c - **octopus_intelligent_smart_control** - Optional. Points to the Octopus Energy integration 'intelligent smart charge' switch (`switch.octopus_energy_{{DEVICE_ID}}_intelligent_smart_charge`), which is on while Octopus Smart Control is on for the car. Octopus keeps reporting the charging plan it had already made after you switch Smart Control off, so while this switch is `off` -Predbat ignores the *planned* Octopus slots (including any bonus slots) as they will not happen; slots already delivered still count. +Predbat ignores the *planned* Octopus slots (including any bonus slots) as they will not happen; slots already delivered, and bump/boost charges you asked for yourself, still count. You do not normally need to set this: when **octopus_intelligent_slot** is the integration's `intelligent_dispatching` sensor, Predbat derives the switch from it. Only set it if you have renamed the switch. For **multiple IOG-enrolled vehicles**, set this to a list with one switch per car. From 0d5fb904403578aee5cb6d6e71a2fbbaa2115c26 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Thu, 1 Oct 2026 23:43:18 +0100 Subject: [PATCH 04/15] refactor(octopus): share the manual dispatch sources, keep the raw plan in the timeline (#5339) Move the bump-charge/BOOST sources to const.py so rate costing and the Smart Control filter agree, read a source nested under meta, leave the diagnostic timeline showing what Octopus reported, and test BOOST. Co-Authored-By: Claude Sonnet 5.5 --- apps/predbat/const.py | 2 ++ apps/predbat/fetch.py | 11 ++++--- apps/predbat/octopus.py | 4 +-- .../tests/test_octopus_smart_control.py | 29 ++++++++++--------- 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/apps/predbat/const.py b/apps/predbat/const.py index cffb378f6..e5ff9d47a 100644 --- a/apps/predbat/const.py +++ b/apps/predbat/const.py @@ -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 diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 93f95bb02..b6546779a 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -32,6 +32,7 @@ LOAD_FORECAST_HISTORY_MAX_DAYS, PREDBAT_MAX_CARS, CAR_CHARGING_LIMIT_UNCAPPED, + OCTOPUS_MANUAL_DISPATCH_SOURCES, CAR_CHARGING_NOW_POWER_W, CLOUD_WINDOW_MINUTES, CLOUD_ARRAY_MARGIN, @@ -44,9 +45,6 @@ import copy import re -# Planned dispatch sources the user asks for by hand, which Smart Control being off does not void -OCTOPUS_MANUAL_DISPATCH_SOURCES = ("bump-charge", "BOOST") - class Fetch: """Data fetching mixin for loading energy rates, consumption, and forecasts. @@ -1550,12 +1548,13 @@ def fetch_sensor_data_cars(self, save=True): # 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 + planned_used = planned if self.octopus_smart_control_off(car_n, entity_id, save=save): - planned = [slot for slot in (planned or []) if isinstance(slot, dict) and slot.get("source") in OCTOPUS_MANUAL_DISPATCH_SOURCES] - if planned and (not self.octopus_intelligent_ignore_unplugged or self.car_charging_planned[car_n] or self.car_charging_now[car_n]): + 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] + 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") diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index bbb79a5a6..df8b70108 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -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 @@ -3312,7 +3312,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: diff --git a/apps/predbat/tests/test_octopus_smart_control.py b/apps/predbat/tests/test_octopus_smart_control.py index f3f10acd1..5f869c874 100644 --- a/apps/predbat/tests/test_octopus_smart_control.py +++ b/apps/predbat/tests/test_octopus_smart_control.py @@ -187,32 +187,33 @@ def run_octopus_smart_control_tests(my_predbat): boost_start = (my_predbat.now_utc + timedelta(hours=4)).strftime("%Y-%m-%dT%H:%M:%S%z") boost_end = (my_predbat.now_utc + timedelta(hours=5)).strftime("%Y-%m-%dT%H:%M:%S%z") attributes["planned_dispatches"].append({"start": boost_start, "end": boost_end, "charge_in_kwh": 3.0, "source": "bump-charge", "location": "AT_HOME"}) + attributes["planned_dispatches"].append({"start": boost_start, "end": boost_end, "charge_in_kwh": 2.0, "source": "BOOST", "location": "AT_HOME"}) my_predbat.ha_interface.set_state(SLOT_SENSOR, "off", attributes=attributes) items[DERIVED_SWITCH] = "off" kwh = _slots(my_predbat) - failed |= _check("t15 boost kept", kwh == [3.0, 5.0], "kwh {}".format(kwh)) + failed |= _check("t10 boost kept", kwh == [2.0, 3.0, 5.0], "kwh {}".format(kwh)) items[DERIVED_SWITCH] = "on" kwh = _slots(my_predbat) - failed |= _check("t15 all on", kwh == [3.0, 5.0, 10.0], "kwh {}".format(kwh)) + failed |= _check("t10 all on", kwh == [2.0, 3.0, 5.0, 10.0], "kwh {}".format(kwh)) _setup(my_predbat) print("Test 11: the legacy entity names without a device id derive their switch too") my_predbat.args["octopus_intelligent_slot"] = LEGACY_SENSOR items[LEGACY_SWITCH] = "off" kwh = _slots(my_predbat) - failed |= _check("t10 legacy off", kwh == [5.0], "kwh {}".format(kwh)) + failed |= _check("t11 legacy off", kwh == [5.0], "kwh {}".format(kwh)) items[LEGACY_SWITCH] = "on" kwh = _slots(my_predbat) - failed |= _check("t10 legacy on", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + failed |= _check("t11 legacy on", kwh == [5.0, 10.0], "kwh {}".format(kwh)) print("Test 12: the state is case-insensitive, and an odd state string is no evidence") my_predbat.args["octopus_intelligent_slot"] = SLOT_SENSOR items[DERIVED_SWITCH] = "OFF" kwh = _slots(my_predbat) - failed |= _check("t11 OFF", kwh == [5.0], "kwh {}".format(kwh)) + failed |= _check("t12 OFF", kwh == [5.0], "kwh {}".format(kwh)) items[DERIVED_SWITCH] = "true" kwh = _slots(my_predbat) - failed |= _check("t11 true", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + failed |= _check("t12 true", kwh == [5.0, 10.0], "kwh {}".format(kwh)) print("Test 13: the change is logged once on a live fetch, and not at all on a save=False re-run") logs = [] @@ -222,11 +223,11 @@ def run_octopus_smart_control_tests(my_predbat): items[DERIVED_SWITCH] = "off" my_predbat.octopus_smart_control_off_logged = {} _slots(my_predbat, save=False) - failed |= _check("t12 no log on re-run", not [x for x in logs if "Smart Control is now" in x], "logs {}".format(logs)) + failed |= _check("t13 no log on re-run", not [x for x in logs if "Smart Control is now" in x], "logs {}".format(logs)) _slots(my_predbat, save=True) _slots(my_predbat, save=True) now_off = [x for x in logs if "Smart Control is now Off" in x] - failed |= _check("t12 logged once", len(now_off) == 1, "logs {}".format(logs)) + failed |= _check("t13 logged once", len(now_off) == 1, "logs {}".format(logs)) finally: my_predbat.log = real_log @@ -248,20 +249,20 @@ def run_octopus_smart_control_tests(my_predbat): my_predbat.args["octopus_intelligent_slot"] = [SLOT_SENSOR, CAR2_SENSOR] items[DERIVED_SWITCH] = "off" items[CAR2_SWITCH] = "on" - failed |= _check("t13 car 0 off", _slots(my_predbat, car_n=0) == [5.0], "") - failed |= _check("t13 car 1 on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0, 10.0], "") + failed |= _check("t14 car 0 off", _slots(my_predbat, car_n=0) == [5.0], "") + failed |= _check("t14 car 1 on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0, 10.0], "") items[DERIVED_SWITCH] = "on" items[CAR2_SWITCH] = "off" _slots(my_predbat) - failed |= _check("t13 car 0 on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[0]) == [5.0, 10.0], "") - failed |= _check("t13 car 1 off", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0], "") + failed |= _check("t14 car 0 on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[0]) == [5.0, 10.0], "") + failed |= _check("t14 car 1 off", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0], "") print("Test 15: a switch list shorter than the cars falls back to the derived switch for the rest") my_predbat.args["octopus_intelligent_smart_control"] = [CUSTOM_SWITCH] items[CUSTOM_SWITCH] = "on" _slots(my_predbat) - failed |= _check("t14 car 0 explicit on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[0]) == [5.0, 10.0], "") - failed |= _check("t14 car 1 derived off", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0], "") + failed |= _check("t15 car 0 explicit on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[0]) == [5.0, 10.0], "") + failed |= _check("t15 car 1 derived off", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0], "") finally: for field, value in saved_state.items(): setattr(my_predbat, field, value) From 6a45acac92f1bcac604a394578dbd4ed717c2e46 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Fri, 2 Oct 2026 08:01:39 +0100 Subject: [PATCH 05/15] fix(octopus): take the Smart Control switch from apps.yaml, not a hard-coded regex (#5339 review) Drop the derivation of the switch from the dispatching sensor's name. The default now lives in the apps.yaml templates as a regex, like octopus_intelligent_slot, octopus_ready_time and octopus_charge_limit, so it can be overridden or removed. Co-Authored-By: Claude Sonnet 5.5 --- apps/predbat/config/apps.yaml | 1 + apps/predbat/fetch.py | 16 ++- .../tests/test_octopus_smart_control.py | 102 ++++++++---------- docs/apps-yaml.md | 2 +- docs/car-charging.md | 12 ++- templates/alphaess_cloud.yaml | 1 + templates/enphase_cloud.yaml | 1 + templates/ep_cube_cloud.yaml | 1 + templates/fox.yaml | 1 + templates/fox_cloud.yaml | 1 + templates/fronius.yaml | 1 + templates/ginlong_solis.yaml | 1 + templates/ginlong_solis_fb00.yaml | 1 + templates/givenergy_cloud.yaml | 1 + templates/givenergy_ems.yaml | 1 + templates/givenergy_givtcp.yaml | 1 + templates/hanchu_cloud.yaml | 1 + templates/luxpower.yaml | 1 + templates/sigenergy_cloud.yaml | 1 + templates/sigenergy_sigenstor.yaml | 1 + templates/sofar.yaml | 1 + templates/sofar_modbus.yaml | 1 + templates/solar_assistant_growatt_spa.yaml | 1 + templates/solar_assistant_growatt_sph.yaml | 1 + templates/solaredge.yaml | 1 + templates/solax_cloud.yaml | 1 + templates/solax_sx4.yaml | 1 + templates/solis_cloud.yaml | 1 + templates/sunsynk.yaml | 1 + templates/tesla_powerwall.yaml | 1 + templates/teslemetry.yaml | 1 + 31 files changed, 88 insertions(+), 71 deletions(-) diff --git a/apps/predbat/config/apps.yaml b/apps/predbat/config/apps.yaml index 333db4f7b..fd5a72637 100644 --- a/apps/predbat/config/apps.yaml +++ b/apps/predbat/config/apps.yaml @@ -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)' diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index b6546779a..3b585d20e 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -43,7 +43,6 @@ from axle import fetch_axle_sessions, load_axle_slot, fetch_axle_active import copy -import re class Fetch: @@ -1447,23 +1446,20 @@ 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, slot_entity_id, save=True): + 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), or for the Octopus Energy integration it is - derived from the dispatching sensor (binary_sensor.octopus_energy__intelligent_dispatching -> - switch.octopus_energy__intelligent_smart_charge). Only an explicit "off" counts - a missing or + 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 and isinstance(slot_entity_id, str): - match = re.fullmatch(r"binary_sensor\.(octopus_energy(?:_.+)?)_intelligent_dispatching", slot_entity_id) - if match: - switch_id = "switch.{}_intelligent_smart_charge".format(match.group(1)) if not switch_id: return False state = self.get_state_wrapper(entity_id=switch_id) @@ -1549,7 +1545,7 @@ def fetch_sensor_data_cars(self, save=True): # 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 planned_used = planned - if self.octopus_smart_control_off(car_n, entity_id, save=save): + 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] 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 diff --git a/apps/predbat/tests/test_octopus_smart_control.py b/apps/predbat/tests/test_octopus_smart_control.py index 5f869c874..385a55a5c 100644 --- a/apps/predbat/tests/test_octopus_smart_control.py +++ b/apps/predbat/tests/test_octopus_smart_control.py @@ -14,9 +14,7 @@ from tests.test_multi_car_iog import pin_test_clock, restore_test_clock SLOT_SENSOR = "binary_sensor.octopus_energy_abc123_intelligent_dispatching" -DERIVED_SWITCH = "switch.octopus_energy_abc123_intelligent_smart_charge" -LEGACY_SENSOR = "binary_sensor.octopus_energy_intelligent_dispatching" -LEGACY_SWITCH = "switch.octopus_energy_intelligent_smart_charge" +SWITCH = "switch.octopus_energy_abc123_intelligent_smart_charge" CAR2_SENSOR = "binary_sensor.octopus_energy_def456_intelligent_dispatching" CAR2_SWITCH = "switch.octopus_energy_def456_intelligent_smart_charge" CUSTOM_SENSOR = "binary_sensor.my_car_dispatching" @@ -82,10 +80,9 @@ def _setup(my_predbat): } my_predbat.ha_interface.set_state(SLOT_SENSOR, "off", attributes=copy.deepcopy(attributes)) my_predbat.ha_interface.set_state(CUSTOM_SENSOR, "off", attributes=copy.deepcopy(attributes)) - my_predbat.ha_interface.set_state(LEGACY_SENSOR, "off", attributes=copy.deepcopy(attributes)) my_predbat.ha_interface.set_state(CAR2_SENSOR, "off", attributes=copy.deepcopy(attributes)) my_predbat.args["octopus_intelligent_slot"] = SLOT_SENSOR - my_predbat.args.pop("octopus_intelligent_smart_control", None) + my_predbat.args["octopus_intelligent_smart_control"] = SWITCH def _slots(my_predbat, save=False, car_n=0): @@ -122,39 +119,39 @@ def run_octopus_smart_control_tests(my_predbat): _setup(my_predbat) print("Test 1: no switch entity - planned and completed slots both used") - items.pop(DERIVED_SWITCH, None) + items.pop(SWITCH, None) kwh = _slots(my_predbat) failed |= _check("t1 both", kwh == [5.0, 10.0], "kwh {}".format(kwh)) - print("Test 2: derived switch on - planned and completed slots both used") - items[DERIVED_SWITCH] = "on" + print("Test 2: switch on - planned and completed slots both used") + items[SWITCH] = "on" kwh = _slots(my_predbat) failed |= _check("t2 both", kwh == [5.0, 10.0], "kwh {}".format(kwh)) - print("Test 3: derived switch off - planned slots dropped, completed kept") - items[DERIVED_SWITCH] = "off" + print("Test 3: switch off - planned slots dropped, completed kept") + items[SWITCH] = "off" kwh = _slots(my_predbat) failed |= _check("t3 completed only", kwh == [5.0], "kwh {}".format(kwh)) print("Test 4: switch back on - planned slots return") - items[DERIVED_SWITCH] = "on" + items[SWITCH] = "on" kwh = _slots(my_predbat) failed |= _check("t4 both again", kwh == [5.0, 10.0], "kwh {}".format(kwh)) print("Test 5: unavailable or unknown switch is no evidence - planned slots kept") for state in ("unavailable", "unknown", None): - items[DERIVED_SWITCH] = state + items[SWITCH] = state kwh = _slots(my_predbat) failed |= _check("t5 {} kept".format(state), kwh == [5.0, 10.0], "kwh {}".format(kwh)) - print("Test 6: a slot sensor that is not the Octopus Energy integration's has no derived switch") - items[DERIVED_SWITCH] = "off" - items[CUSTOM_SWITCH] = "off" - my_predbat.args["octopus_intelligent_slot"] = CUSTOM_SENSOR + print("Test 6: with octopus_intelligent_smart_control not set nothing is guessed from the sensor name") + items[SWITCH] = "off" + my_predbat.args.pop("octopus_intelligent_smart_control", None) kwh = _slots(my_predbat) - failed |= _check("t6 not derived", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + failed |= _check("t6 not guessed", kwh == [5.0, 10.0], "kwh {}".format(kwh)) - print("Test 7: octopus_intelligent_smart_control names the switch explicitly (single value)") + print("Test 7: octopus_intelligent_smart_control as a single value") + items[CUSTOM_SWITCH] = "off" my_predbat.args["octopus_intelligent_smart_control"] = CUSTOM_SWITCH kwh = _slots(my_predbat) failed |= _check("t7 explicit off", kwh == [5.0], "kwh {}".format(kwh)) @@ -162,76 +159,66 @@ def run_octopus_smart_control_tests(my_predbat): kwh = _slots(my_predbat) failed |= _check("t7 explicit on", kwh == [5.0, 10.0], "kwh {}".format(kwh)) - print("Test 8: an explicit switch (list form) beats the derived one") - my_predbat.args["octopus_intelligent_slot"] = SLOT_SENSOR + print("Test 8: octopus_intelligent_smart_control as a list reads only its own switch") my_predbat.args["octopus_intelligent_smart_control"] = [CUSTOM_SWITCH] - items[DERIVED_SWITCH] = "off" + items[SWITCH] = "off" items[CUSTOM_SWITCH] = "on" kwh = _slots(my_predbat) - failed |= _check("t8 explicit wins", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + failed |= _check("t8 own switch read", kwh == [5.0, 10.0], "kwh {}".format(kwh)) print("Test 9: the slot signature changes when Smart Control goes off, so the plan is recomputed") - my_predbat.args.pop("octopus_intelligent_smart_control", None) - items[DERIVED_SWITCH] = "on" + my_predbat.args["octopus_intelligent_smart_control"] = SWITCH + items[SWITCH] = "on" _slots(my_predbat) before = copy.deepcopy(my_predbat.octopus_slots) - items[DERIVED_SWITCH] = "off" + items[SWITCH] = "off" _slots(my_predbat) failed |= _check("t9 signature", my_predbat.octopus_slots_signature(before) != my_predbat.octopus_slots_signature(my_predbat.octopus_slots), "") print("Test 10: manual bump/boost dispatches are kept while Smart Control is off, scheduled ones are not") my_predbat.num_cars = 1 my_predbat.args["octopus_intelligent_slot"] = SLOT_SENSOR - my_predbat.args.pop("octopus_intelligent_smart_control", None) + my_predbat.args["octopus_intelligent_smart_control"] = SWITCH attributes = copy.deepcopy(items[SLOT_SENSOR]) boost_start = (my_predbat.now_utc + timedelta(hours=4)).strftime("%Y-%m-%dT%H:%M:%S%z") boost_end = (my_predbat.now_utc + timedelta(hours=5)).strftime("%Y-%m-%dT%H:%M:%S%z") attributes["planned_dispatches"].append({"start": boost_start, "end": boost_end, "charge_in_kwh": 3.0, "source": "bump-charge", "location": "AT_HOME"}) attributes["planned_dispatches"].append({"start": boost_start, "end": boost_end, "charge_in_kwh": 2.0, "source": "BOOST", "location": "AT_HOME"}) my_predbat.ha_interface.set_state(SLOT_SENSOR, "off", attributes=attributes) - items[DERIVED_SWITCH] = "off" + items[SWITCH] = "off" kwh = _slots(my_predbat) failed |= _check("t10 boost kept", kwh == [2.0, 3.0, 5.0], "kwh {}".format(kwh)) - items[DERIVED_SWITCH] = "on" + items[SWITCH] = "on" kwh = _slots(my_predbat) failed |= _check("t10 all on", kwh == [2.0, 3.0, 5.0, 10.0], "kwh {}".format(kwh)) _setup(my_predbat) - print("Test 11: the legacy entity names without a device id derive their switch too") - my_predbat.args["octopus_intelligent_slot"] = LEGACY_SENSOR - items[LEGACY_SWITCH] = "off" - kwh = _slots(my_predbat) - failed |= _check("t11 legacy off", kwh == [5.0], "kwh {}".format(kwh)) - items[LEGACY_SWITCH] = "on" - kwh = _slots(my_predbat) - failed |= _check("t11 legacy on", kwh == [5.0, 10.0], "kwh {}".format(kwh)) - - print("Test 12: the state is case-insensitive, and an odd state string is no evidence") + print("Test 11: the state is case-insensitive, and an odd state string is no evidence") my_predbat.args["octopus_intelligent_slot"] = SLOT_SENSOR - items[DERIVED_SWITCH] = "OFF" + items[SWITCH] = "OFF" kwh = _slots(my_predbat) - failed |= _check("t12 OFF", kwh == [5.0], "kwh {}".format(kwh)) - items[DERIVED_SWITCH] = "true" + failed |= _check("t11 OFF", kwh == [5.0], "kwh {}".format(kwh)) + items[SWITCH] = "true" kwh = _slots(my_predbat) - failed |= _check("t12 true", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + failed |= _check("t11 true", kwh == [5.0, 10.0], "kwh {}".format(kwh)) - print("Test 13: the change is logged once on a live fetch, and not at all on a save=False re-run") + print("Test 12: the change is logged once on a live fetch, and not at all on a save=False re-run") logs = [] real_log = my_predbat.log my_predbat.log = lambda message, *args, **kwargs: (logs.append(message), real_log(message, *args, **kwargs))[1] try: - items[DERIVED_SWITCH] = "off" + items[SWITCH] = "off" my_predbat.octopus_smart_control_off_logged = {} _slots(my_predbat, save=False) - failed |= _check("t13 no log on re-run", not [x for x in logs if "Smart Control is now" in x], "logs {}".format(logs)) + failed |= _check("t12 no log on re-run", not [x for x in logs if "Smart Control is now" in x], "logs {}".format(logs)) _slots(my_predbat, save=True) _slots(my_predbat, save=True) now_off = [x for x in logs if "Smart Control is now Off" in x] - failed |= _check("t13 logged once", len(now_off) == 1, "logs {}".format(logs)) + failed |= _check("t12 logged once", len(now_off) == 1, "logs {}".format(logs)) finally: my_predbat.log = real_log - print("Test 14: two cars - Smart Control off for one only") + print("Test 13: two cars - Smart Control off for one only") my_predbat.num_cars = 2 my_predbat.car_charging_planned = [True, True] my_predbat.car_charging_now = [False, False] @@ -247,22 +234,23 @@ def run_octopus_smart_control_tests(my_predbat): my_predbat.args["car_charging_soc"] = [50.0, 50.0] my_predbat.args["car_charging_limit"] = [100.0, 100.0] my_predbat.args["octopus_intelligent_slot"] = [SLOT_SENSOR, CAR2_SENSOR] - items[DERIVED_SWITCH] = "off" + my_predbat.args["octopus_intelligent_smart_control"] = [SWITCH, CAR2_SWITCH] + items[SWITCH] = "off" items[CAR2_SWITCH] = "on" - failed |= _check("t14 car 0 off", _slots(my_predbat, car_n=0) == [5.0], "") - failed |= _check("t14 car 1 on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0, 10.0], "") - items[DERIVED_SWITCH] = "on" + failed |= _check("t13 car 0 off", _slots(my_predbat, car_n=0) == [5.0], "") + failed |= _check("t13 car 1 on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0, 10.0], "") + items[SWITCH] = "on" items[CAR2_SWITCH] = "off" _slots(my_predbat) - failed |= _check("t14 car 0 on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[0]) == [5.0, 10.0], "") - failed |= _check("t14 car 1 off", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0], "") + failed |= _check("t13 car 0 on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[0]) == [5.0, 10.0], "") + failed |= _check("t13 car 1 off", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0], "") - print("Test 15: a switch list shorter than the cars falls back to the derived switch for the rest") + print("Test 14: a switch list shorter than the cars leaves the extra cars' planned slots trusted") my_predbat.args["octopus_intelligent_smart_control"] = [CUSTOM_SWITCH] items[CUSTOM_SWITCH] = "on" _slots(my_predbat) - failed |= _check("t15 car 0 explicit on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[0]) == [5.0, 10.0], "") - failed |= _check("t15 car 1 derived off", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0], "") + failed |= _check("t14 car 0 switch on", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[0]) == [5.0, 10.0], "") + failed |= _check("t14 car 1 no switch", sorted(slot.get("charge_in_kwh") for slot in my_predbat.octopus_slots[1]) == [5.0, 10.0], "") finally: for field, value in saved_state.items(): setattr(my_predbat, field, value) @@ -271,7 +259,7 @@ def run_octopus_smart_control_tests(my_predbat): my_predbat.args[key] = saved_args[key] else: my_predbat.args.pop(key, None) - for entity in (SLOT_SENSOR, CUSTOM_SENSOR, LEGACY_SENSOR, CAR2_SENSOR, DERIVED_SWITCH, CUSTOM_SWITCH, LEGACY_SWITCH, CAR2_SWITCH): + for entity in (SLOT_SENSOR, CUSTOM_SENSOR, CAR2_SENSOR, SWITCH, CUSTOM_SWITCH, CAR2_SWITCH): items.pop(entity, None) restore_test_clock(my_predbat, saved_clock) print("*** octopus_smart_control test {}".format("FAILED" if failed else "PASSED")) diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index 37e592a6a..e2f943cc5 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -2098,7 +2098,7 @@ Details of configuring `apps.yaml` for EV charging are described in [Configure a - **car_charging_power** - Live power drawn by your EV charger, used for display only - **octopus_intelligent_slot** - Octopus Energy integration 'intelligent dispatching' sensor that indicates whether you are within an Octopus Energy "smart charge" slot -- **octopus_intelligent_smart_control** - Optional Octopus Energy integration 'intelligent smart charge' switch, planned Octopus slots are ignored while it is off (derived automatically from **octopus_intelligent_slot** when not set, see [car charging](car-charging.md#planned-car-charging)) +- **octopus_intelligent_smart_control** - Octopus Energy integration 'intelligent smart charge' switch, planned Octopus slots are ignored while it is off (see [car charging](car-charging.md#planned-car-charging)) - **octopus_ready_time** - Octopus Energy integration sensor for when the car charging will be completed by - **octopus_charge_limit** - Octopus Energy integration sensor for car charging limit - **octopus_slot_low_rate** - Whether Octopus Intelligent Slots reported will be at the lowest rate if at home diff --git a/docs/car-charging.md b/docs/car-charging.md index d1a268ca9..0594382e5 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -133,6 +133,7 @@ The following `apps.yaml` configuration items are pre-defined with regular expre ```yaml 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)' ``` @@ -141,10 +142,11 @@ The following `apps.yaml` configuration items are pre-defined with regular expre whether you are within an Octopus Energy "smart charge" slot, and provides the list of future planned charging activity. For **multiple IOG-enrolled vehicles**, set this to a list with one sensor per car (see [Multiple Electric Cars](#multiple-electric-cars)). -- **octopus_intelligent_smart_control** - Optional. Points to the Octopus Energy integration 'intelligent smart charge' switch (`switch.octopus_energy_{{DEVICE_ID}}_intelligent_smart_charge`), +- **octopus_intelligent_smart_control** - Points to the Octopus Energy integration 'intelligent smart charge' switch (`switch.octopus_energy_{{DEVICE_ID}}_intelligent_smart_charge`), which is on while Octopus Smart Control is on for the car. Octopus keeps reporting the charging plan it had already made after you switch Smart Control off, so while this switch is `off` Predbat ignores the *planned* Octopus slots (including any bonus slots) as they will not happen; slots already delivered, and bump/boost charges you asked for yourself, still count. -You do not normally need to set this: when **octopus_intelligent_slot** is the integration's `intelligent_dispatching` sensor, Predbat derives the switch from it. Only set it if you have renamed the switch. +It holds the entity ID of the switch, not a value, so Predbat follows the switch live, however it is turned off (the Octopus app, Home Assistant or an automation). +If you set up `apps.yaml` before this item existed, add it to pick up the change. If the switch cannot be found, planned slots are used as normal. For **multiple IOG-enrolled vehicles**, set this to a list with one switch per car. - **octopus_ready_time** - Points to the Octopus Energy integration sensor that details when the car charging will be completed.
@@ -273,7 +275,7 @@ If you have **two or more EVs enrolled in Octopus Intelligent Go**, Predbat can If you use the Octopus Direct function inside Predbat then you can set **octopus_automatic** to True to automatically configure IOG cars. -Otherwise if using Bottle Cap Dave's Octopus integration then set **octopus_intelligent_slot**, **octopus_ready_time** and **octopus_charge_limit** +Otherwise if using Bottle Cap Dave's Octopus integration then set **octopus_intelligent_slot**, **octopus_intelligent_smart_control**, **octopus_ready_time** and **octopus_charge_limit** to lists with one entry per car in `apps.yaml`: ```yaml @@ -287,6 +289,10 @@ to lists with one entry per car in `apps.yaml`: - 'binary_sensor.octopus_energy_{{DEVICE_ID_CAR1}}_intelligent_dispatching' - 'binary_sensor.octopus_energy_{{DEVICE_ID_CAR2}}_intelligent_dispatching' + octopus_intelligent_smart_control: + - 'switch.octopus_energy_{{DEVICE_ID_CAR1}}_intelligent_smart_charge' + - 'switch.octopus_energy_{{DEVICE_ID_CAR2}}_intelligent_smart_charge' + octopus_ready_time: - 'time.octopus_energy_{{DEVICE_ID_CAR1}}_intelligent_target_time' - 'time.octopus_energy_{{DEVICE_ID_CAR2}}_intelligent_target_time' diff --git a/templates/alphaess_cloud.yaml b/templates/alphaess_cloud.yaml index 16496c10d..46ad6dac7 100644 --- a/templates/alphaess_cloud.yaml +++ b/templates/alphaess_cloud.yaml @@ -195,6 +195,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/enphase_cloud.yaml b/templates/enphase_cloud.yaml index 06ac3866c..72ffdc5c2 100644 --- a/templates/enphase_cloud.yaml +++ b/templates/enphase_cloud.yaml @@ -176,6 +176,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/ep_cube_cloud.yaml b/templates/ep_cube_cloud.yaml index 8c352c440..22fdbbb13 100644 --- a/templates/ep_cube_cloud.yaml +++ b/templates/ep_cube_cloud.yaml @@ -230,6 +230,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/fox.yaml b/templates/fox.yaml index f1ce9746c..7f51d8336 100644 --- a/templates/fox.yaml +++ b/templates/fox.yaml @@ -268,6 +268,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/fox_cloud.yaml b/templates/fox_cloud.yaml index 3886b3317..4ad8ba982 100644 --- a/templates/fox_cloud.yaml +++ b/templates/fox_cloud.yaml @@ -167,6 +167,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/fronius.yaml b/templates/fronius.yaml index 719399b92..2e7f2ec61 100644 --- a/templates/fronius.yaml +++ b/templates/fronius.yaml @@ -316,6 +316,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/ginlong_solis.yaml b/templates/ginlong_solis.yaml index 9e84e3216..92e3d1efc 100644 --- a/templates/ginlong_solis.yaml +++ b/templates/ginlong_solis.yaml @@ -233,6 +233,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/ginlong_solis_fb00.yaml b/templates/ginlong_solis_fb00.yaml index 3f2ab5881..74b346c33 100644 --- a/templates/ginlong_solis_fb00.yaml +++ b/templates/ginlong_solis_fb00.yaml @@ -251,6 +251,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/givenergy_cloud.yaml b/templates/givenergy_cloud.yaml index cdbe4457e..906c70630 100644 --- a/templates/givenergy_cloud.yaml +++ b/templates/givenergy_cloud.yaml @@ -281,6 +281,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/givenergy_ems.yaml b/templates/givenergy_ems.yaml index 5fcc58e65..53f8eb2ba 100644 --- a/templates/givenergy_ems.yaml +++ b/templates/givenergy_ems.yaml @@ -196,6 +196,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/givenergy_givtcp.yaml b/templates/givenergy_givtcp.yaml index cb08421fd..902def086 100644 --- a/templates/givenergy_givtcp.yaml +++ b/templates/givenergy_givtcp.yaml @@ -342,6 +342,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/hanchu_cloud.yaml b/templates/hanchu_cloud.yaml index a5d292584..23dd7532c 100644 --- a/templates/hanchu_cloud.yaml +++ b/templates/hanchu_cloud.yaml @@ -194,6 +194,7 @@ pred_bat: # - True # ─── Octopus Energy ─────────────────────────────────────────────────────── 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)' #octopus_intelligent_slot: 'binary_sensor.ohme_slot_active' diff --git a/templates/luxpower.yaml b/templates/luxpower.yaml index 4819be165..3eecfcfd4 100644 --- a/templates/luxpower.yaml +++ b/templates/luxpower.yaml @@ -296,6 +296,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/sigenergy_cloud.yaml b/templates/sigenergy_cloud.yaml index aebb3ada3..aa645030e 100644 --- a/templates/sigenergy_cloud.yaml +++ b/templates/sigenergy_cloud.yaml @@ -196,6 +196,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/sigenergy_sigenstor.yaml b/templates/sigenergy_sigenstor.yaml index fede1e2fe..a68dd372c 100644 --- a/templates/sigenergy_sigenstor.yaml +++ b/templates/sigenergy_sigenstor.yaml @@ -222,6 +222,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/sofar.yaml b/templates/sofar.yaml index 20a765ec3..7cef55f0f 100644 --- a/templates/sofar.yaml +++ b/templates/sofar.yaml @@ -220,6 +220,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/sofar_modbus.yaml b/templates/sofar_modbus.yaml index d81b3ebb9..d55484d90 100644 --- a/templates/sofar_modbus.yaml +++ b/templates/sofar_modbus.yaml @@ -241,6 +241,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/solar_assistant_growatt_spa.yaml b/templates/solar_assistant_growatt_spa.yaml index 98fb3338b..85e6973fe 100644 --- a/templates/solar_assistant_growatt_spa.yaml +++ b/templates/solar_assistant_growatt_spa.yaml @@ -222,6 +222,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/solar_assistant_growatt_sph.yaml b/templates/solar_assistant_growatt_sph.yaml index 91847dd75..013279eca 100644 --- a/templates/solar_assistant_growatt_sph.yaml +++ b/templates/solar_assistant_growatt_sph.yaml @@ -219,6 +219,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/solaredge.yaml b/templates/solaredge.yaml index 3a90fde41..26c494c74 100644 --- a/templates/solaredge.yaml +++ b/templates/solaredge.yaml @@ -223,6 +223,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/solax_cloud.yaml b/templates/solax_cloud.yaml index 9398c6b16..461767edc 100644 --- a/templates/solax_cloud.yaml +++ b/templates/solax_cloud.yaml @@ -170,6 +170,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/solax_sx4.yaml b/templates/solax_sx4.yaml index eccbe1ad5..96478785d 100644 --- a/templates/solax_sx4.yaml +++ b/templates/solax_sx4.yaml @@ -279,6 +279,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/solis_cloud.yaml b/templates/solis_cloud.yaml index 66dfc78ab..43da33663 100644 --- a/templates/solis_cloud.yaml +++ b/templates/solis_cloud.yaml @@ -201,6 +201,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/sunsynk.yaml b/templates/sunsynk.yaml index 79bd2796e..52ecc647b 100644 --- a/templates/sunsynk.yaml +++ b/templates/sunsynk.yaml @@ -334,6 +334,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/tesla_powerwall.yaml b/templates/tesla_powerwall.yaml index d41cf8a11..8dbda1ae4 100644 --- a/templates/tesla_powerwall.yaml +++ b/templates/tesla_powerwall.yaml @@ -252,6 +252,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 the intelligent slot from the Octopus Energy integration 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)' diff --git a/templates/teslemetry.yaml b/templates/teslemetry.yaml index 19db62869..d981301a2 100644 --- a/templates/teslemetry.yaml +++ b/templates/teslemetry.yaml @@ -129,6 +129,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 the intelligent slot from the Octopus Energy integration 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)' From cbc0cd1d7e59b8c15a9687cd64ebb9ad2553f8b0 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Fri, 2 Oct 2026 08:25:24 +0100 Subject: [PATCH 06/15] chore(coverage): list octopus_intelligent_smart_control in the test apps.yaml (#5339) Commented out like its neighbours, so test behaviour is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- coverage/apps.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/coverage/apps.yaml b/coverage/apps.yaml index 3f118ac73..a18efe925 100755 --- a/coverage/apps.yaml +++ b/coverage/apps.yaml @@ -312,6 +312,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:((time|select).octopus_energy_([0-9a-z_]+|)_intelligent_target_time)' #octopus_charge_limit: 're:(number.octopus_energy_([0-9a-z_]+|)_intelligent_charge_target)' From 90477aa1da56c2b9bb8ac7c3fc8c084c86bd47d6 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Fri, 2 Oct 2026 09:05:13 +0100 Subject: [PATCH 07/15] feat(octopus): publish a writable Smart Control switch from the built-in component (#5339 review) The built-in Octopus component publishes switch._octopus__intelligent_smart_charge_ for every Intelligent device, suspended ones included, on while the device is not suspended. Turning it on or off shows the new state straight away and queues updateDeviceSmartControl UNSUSPEND / SUSPEND, which puts the switch back if Octopus rejects it. automatic_config() wires it into octopus_intelligent_smart_control for active devices only, so suspended devices stay unwired. Co-Authored-By: Claude Sonnet 5.5 --- apps/predbat/octopus.py | 74 +++++++++- .../tests/test_octopus_intelligent_devices.py | 1 + .../test_octopus_smart_control_switch.py | 135 ++++++++++++++++++ apps/predbat/unit_test.py | 2 + docs/car-charging.md | 3 +- 5 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 apps/predbat/tests/test_octopus_smart_control_switch.py diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index df8b70108..0166eff67 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -70,6 +70,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"), } @@ -350,6 +351,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}" @@ -688,7 +698,41 @@ 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: show the new state straight away + and queue the command that sends it to Octopus (rolled back if the command fails). + """ + suffix = self.get_entity_suffix(entity_id) + device_id = self.suffix_to_device_id(suffix) + if not device_id 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 + was_suspended = bool(device.get("suspended")) + if service == "turn_on": + enabled = True + elif service == "turn_off": + enabled = False + elif service == "toggle": + enabled = was_suspended + else: + return + device["suspended"] = not enabled + self.publish_smart_control_switch(device_id, device) + self.commands.append({"command": "set_intelligent_smart_control", "value": enabled, "device_id": device_id, "was_suspended": was_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", "icon": "mdi:ev-station"}, + app="octopus", + ) def is_alive(self): return self.api_started and self.account_data @@ -833,6 +877,9 @@ 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": + await self.async_set_intelligent_smart_control(command.get("device_id", None), command.get("value", True), command.get("was_suspended", False)) + 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) @@ -1149,6 +1196,27 @@ 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, was_suspended=False): + """ + Turn Octopus Smart Control on (UNSUSPEND) or off (SUSPEND) for an intelligent device. If Octopus rejects + it the switch goes back to how it was, rather than showing a state that never happened. + """ + 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)) + result = await self.async_graphql_query(intelligent_smart_control_mutation.format(device_id=device_id, action=action), "set-intelligent-smart-control", returns_data=False) + if result is None: + self.log("Warn: OctopusAPI: Failed to {} Smart Control for intelligent device {}, putting the switch back".format(action, device_id)) + device["suspended"] = was_suspended + self.publish_smart_control_switch(device_id, device) + return False + device["suspended"] = not enabled + 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 @@ -1469,6 +1537,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 @@ -1479,6 +1548,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 @@ -1490,6 +1560,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 @@ -2684,6 +2755,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) diff --git a/apps/predbat/tests/test_octopus_intelligent_devices.py b/apps/predbat/tests/test_octopus_intelligent_devices.py index 885a533ca..d5ef6c6b0 100644 --- a/apps/predbat/tests/test_octopus_intelligent_devices.py +++ b/apps/predbat/tests/test_octopus_intelligent_devices.py @@ -920,6 +920,7 @@ def test_build_discovery_cars_entity_map_matches_automatic_config(my_predbat): entities = report["cars"][0]["entities"] expected = { "octopus_intelligent_slot": api.get_entity_name("binary_sensor", "intelligent_dispatch", index=index_suffix), + "octopus_intelligent_smart_control": api.get_entity_name("switch", "intelligent_smart_charge", index=index_suffix), "octopus_ready_time": api.get_entity_name("select", "intelligent_target_time", index=index_suffix), "octopus_charge_limit": api.get_entity_name("number", "intelligent_target_soc", index=index_suffix), } diff --git a/apps/predbat/tests/test_octopus_smart_control_switch.py b/apps/predbat/tests/test_octopus_smart_control_switch.py new file mode 100644 index 000000000..fbaa42d62 --- /dev/null +++ b/apps/predbat/tests/test_octopus_smart_control_switch.py @@ -0,0 +1,135 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2024 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + +import asyncio +from unittest.mock import AsyncMock + +from octopus import OctopusAPI + +ACTIVE_ID = "smart-charge-1001" +SUSPENDED_ID = "smart-charge-2002" + + +def _check(name, condition, detail=""): + """ + Print an error for a failed condition and return whether it failed. + """ + if not condition: + print("ERROR: {} {}".format(name, detail)) + return True + return False + + +def _api(my_predbat): + """ + An OctopusAPI with an active and a suspended device, recording what it publishes and the args it sets. + """ + api = OctopusAPI(my_predbat, key="", account_id="acc-1", automatic=False) + api.intelligent_devices = { + ACTIVE_ID: {"suspended": False, "planned_dispatches": [], "completed_dispatches": []}, + SUSPENDED_ID: {"suspended": True, "planned_dispatches": [], "completed_dispatches": []}, + } + api.published = {} + api.dashboard_item = lambda entity, state, attributes=None, app=None: api.published.__setitem__(entity, state) + api.args_set = {} + api.set_arg = lambda name, value: api.args_set.__setitem__(name, value) + return api + + +def _switch(api, device_id): + """ + The Smart Control switch entity of a device. + """ + return api.get_entity_name("switch", "intelligent_smart_charge", index=api.device_id_to_index_suffix(device_id)) + + +async def _run(my_predbat): + """ + The Smart Control switch of the built-in Octopus component: state, both commands, rollback and wiring. + """ + failed = False + api = _api(my_predbat) + active_switch = _switch(api, ACTIVE_ID) + suspended_switch = _switch(api, SUSPENDED_ID) + + print("Test 1: the switch is published for every device, on while not suspended - suspended devices included") + await api.async_intelligent_update_sensor("acc-1") + failed |= _check("t1 active on", api.published.get(active_switch) == "on", "published {}".format(api.published)) + failed |= _check("t1 suspended off", api.published.get(suspended_switch) == "off", "published {}".format(api.published)) + + print("Test 2: turning the switch off shows off straight away and queues a SUSPEND command") + await api.switch_event(active_switch, "turn_off") + failed |= _check("t2 state", api.published.get(active_switch) == "off", "published {}".format(api.published)) + failed |= _check("t2 cached", api.intelligent_devices[ACTIVE_ID]["suspended"] is True, "") + failed |= _check("t2 queued", [c["command"] for c in api.commands] == ["set_intelligent_smart_control"] and api.commands[0]["value"] is False and api.commands[0]["device_id"] == ACTIVE_ID, "commands {}".format(api.commands)) + api.async_graphql_query = AsyncMock(return_value={"updateDeviceSmartControl": {"id": ACTIVE_ID}}) + failed |= _check("t2 processed", await api.process_commands("acc-1") is True, "") + mutation = api.async_graphql_query.call_args[0][0] + failed |= _check("t2 mutation", "updateDeviceSmartControl" in mutation and "action: SUSPEND" in mutation and ACTIVE_ID in mutation, "mutation {}".format(mutation)) + failed |= _check("t2 stays off", api.published.get(active_switch) == "off", "") + + print("Test 3: turning it back on sends UNSUSPEND, also for a device that is suspended") + api.commands = [] + await api.switch_event(suspended_switch, "turn_on") + failed |= _check("t3 state", api.published.get(suspended_switch) == "on", "published {}".format(api.published)) + api.async_graphql_query = AsyncMock(return_value={"updateDeviceSmartControl": {"id": SUSPENDED_ID}}) + await api.process_commands("acc-1") + mutation = api.async_graphql_query.call_args[0][0] + failed |= _check("t3 mutation", "action: UNSUSPEND" in mutation and SUSPENDED_ID in mutation, "mutation {}".format(mutation)) + failed |= _check("t3 cached", api.intelligent_devices[SUSPENDED_ID]["suspended"] is False, "") + + print("Test 4: toggle flips the state") + api.commands = [] + await api.switch_event(active_switch, "toggle") + failed |= _check("t4 toggled on", api.published.get(active_switch) == "on" and api.commands[0]["value"] is True, "commands {}".format(api.commands)) + api.commands = [] + + print("Test 5: a failed command puts the switch back to how it was") + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + await api.switch_event(active_switch, "turn_off") + failed |= _check("t5 shows off first", api.published.get(active_switch) == "off", "") + api.async_graphql_query = AsyncMock(return_value=None) + await api.process_commands("acc-1") + failed |= _check("t5 rolled back", api.published.get(active_switch) == "on" and api.intelligent_devices[ACTIVE_ID]["suspended"] is False, "published {}".format(api.published)) + + print("Test 6: events for other entities, other services and unknown devices are ignored") + api.commands = [] + await api.switch_event("switch.predbat_octopus_acc_1_something_else_1001", "turn_off") + await api.switch_event(active_switch, "bogus") + await api.switch_event(api.get_entity_name("switch", "intelligent_smart_charge", index="9999"), "turn_off") + failed |= _check("t6 nothing queued", api.commands == [], "commands {}".format(api.commands)) + + print("Test 7: automatic_config wires the switch for active devices only, alongside the dispatch sensor") + # automatic_config() reads back the slot wiring it wrote, so this uses the real args, restored afterwards + # because my_predbat is shared by every test in the run + original_args = dict(my_predbat.args) + del api.set_arg + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + api.intelligent_devices[SUSPENDED_ID]["suspended"] = True + api.automatic_config({}) + failed |= _check("t7 switch wired", my_predbat.args.get("octopus_intelligent_smart_control") == [active_switch], "args {}".format(my_predbat.args)) + failed |= _check("t7 slot wired alongside", len(my_predbat.args.get("octopus_intelligent_slot", [])) == 1, "args {}".format(my_predbat.args)) + api.intelligent_devices[ACTIVE_ID]["suspended"] = True + api.automatic_config({}) + failed |= _check("t7 none when all suspended", my_predbat.args.get("octopus_intelligent_smart_control") == [], "args {}".format(my_predbat.args)) + failed |= _check("t7 switches still published for suspended", api.published.get(active_switch) is not None, "") + my_predbat.args.clear() + my_predbat.args.update(original_args) + return failed + + +def test_octopus_smart_control_switch(my_predbat): + """ + Run the Smart Control switch tests of the built-in Octopus component. + """ + print("*** Running test: octopus_smart_control_switch") + failed = asyncio.run(_run(my_predbat)) + print("*** octopus_smart_control_switch test {}".format("FAILED" if failed else "PASSED")) + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 6013268e6..b849e539e 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -44,6 +44,7 @@ from tests.test_octopus_slots import run_load_octopus_slots_tests, run_octopus_slot_max_default_tests from tests.test_multi_car_iog import run_multi_car_iog_tests from tests.test_octopus_smart_control import run_octopus_smart_control_tests +from tests.test_octopus_smart_control_switch import test_octopus_smart_control_switch from tests.test_fetch_config_options import test_fetch_config_options from tests.test_multi_inverter import run_inverter_multi_tests from tests.test_window2minutes import test_window2minutes @@ -572,6 +573,7 @@ def main(): ("octopus_slots", run_load_octopus_slots_tests, "Load Octopus slots tests", False), ("octopus_slot_max_default", run_octopus_slot_max_default_tests, "Octopus slot max auto-detection from IOG-SMB tariff code", False), ("multi_car_iog", run_multi_car_iog_tests, "Multi-car IOG tests", False), + ("octopus_smart_control_switch", test_octopus_smart_control_switch, "Built-in Octopus component Smart Control switch: state, SUSPEND/UNSUSPEND commands, rollback, wiring", False), ("octopus_smart_control", run_octopus_smart_control_tests, "Planned Octopus dispatches are ignored while Smart Control is off", False), ("rate_add_io_slots", run_rate_add_io_slots_tests, "Rate add IO slots tests", False), ("iog_car_need_gate", run_iog_car_need_gate_tests, "IOG car-need gate: no cheap rate for dispatch minutes no car needs", False), diff --git a/docs/car-charging.md b/docs/car-charging.md index 0594382e5..46ca413d8 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -159,7 +159,8 @@ For **multiple IOG-enrolled vehicles**, set this to a list with one sensor per c If you are using Octopus-led charging with the [Octopus direct connection](energy-rates.md#octopus-energy-direct) method: -- Predbat gets its Octopus charging slot information direct from the Octopus API, so comment out or delete octopus_intelligent_slot, octopus_ready_time and octopus_charge_limit from `apps.yaml`. +- Predbat gets its Octopus charging slot information direct from the Octopus API, so comment out or delete octopus_intelligent_slot, octopus_intelligent_smart_control, octopus_ready_time and octopus_charge_limit from `apps.yaml`. +- Predbat publishes a `switch.predbat_octopus_{account}_intelligent_smart_charge_{n}` switch for each Intelligent device, which is on while Octopus Smart Control is on. Turning it off or on in Home Assistant sends SUSPEND / UNSUSPEND to Octopus, the same as the Smart Control toggle in the Octopus app, and the switch goes back to how it was if Octopus rejects the change. It is also shown for a device that is suspended, so that you can turn Smart Control back on. While it is off Predbat ignores the planned Octopus slots, and a suspended device is no longer wired into Predbat until Smart Control is turned back on. If you are using Predbat-led charging: From e8bd2f959004371953fe37725acb2774dee1b18a Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Fri, 2 Oct 2026 09:09:37 +0100 Subject: [PATCH 08/15] fix(octopus): harden the Smart Control switch after review (#5339) Keep a change we have sent on show for two minutes so a poll straight after it cannot flip the switch back, put the switch back on an exception or an empty reply too, match the device by its exact index suffix, and name the switch after the car. Co-Authored-By: Claude Sonnet 5.5 --- apps/predbat/octopus.py | 30 +++++++++++-- .../test_octopus_smart_control_switch.py | 44 +++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 0166eff67..3933ba9ec 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -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 = 120 # How long a Smart Control change we sent stays on show if Octopus has not yet reported 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. @@ -654,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 may not yet report self.mpan = None self.tou_windows = None self.tou_windows_day = None @@ -704,7 +706,7 @@ async def switch_event(self, entity_id, service): """ suffix = self.get_entity_suffix(entity_id) device_id = self.suffix_to_device_id(suffix) - if not device_id or entity_id != self.get_entity_name("switch", "intelligent_smart_charge", index=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: @@ -722,6 +724,20 @@ async def switch_event(self, entity_id, service): self.publish_smart_control_switch(device_id, device) self.commands.append({"command": "set_intelligent_smart_control", "value": enabled, "device_id": device_id, "was_suspended": was_suspended}) + def apply_smart_control_pending(self, device_id, device): + """ + Keep a Smart Control change we have just sent to Octopus on show until Octopus reports it (or + SMART_CONTROL_PENDING_SECONDS pass), so a poll made straight afterwards does not flip the switch back. + """ + pending = self.smart_control_pending.get(device_id) + if not pending: + return + suspended, expiry = pending + if device.get("suspended") == suspended or 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 @@ -730,7 +746,7 @@ def publish_smart_control_switch(self, device_id, device): 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", "icon": "mdi:ev-station"}, + 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", ) @@ -1079,6 +1095,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) @@ -1207,13 +1224,18 @@ async def async_set_intelligent_smart_control(self, device_id, enabled, was_susp return False action = "UNSUSPEND" if enabled else "SUSPEND" self.log("OctopusAPI: Setting Smart Control {} for intelligent device {}".format(action, device_id)) - result = await self.async_graphql_query(intelligent_smart_control_mutation.format(device_id=device_id, action=action), "set-intelligent-smart-control", returns_data=False) - if result is None: + 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 {}, putting the switch back".format(action, device_id)) device["suspended"] = was_suspended 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 diff --git a/apps/predbat/tests/test_octopus_smart_control_switch.py b/apps/predbat/tests/test_octopus_smart_control_switch.py index fbaa42d62..b4d58ccee 100644 --- a/apps/predbat/tests/test_octopus_smart_control_switch.py +++ b/apps/predbat/tests/test_octopus_smart_control_switch.py @@ -9,6 +9,7 @@ # pylint: disable=attribute-defined-outside-init import asyncio +from datetime import datetime, timedelta from unittest.mock import AsyncMock from octopus import OctopusAPI @@ -99,6 +100,49 @@ async def _run(my_predbat): await api.process_commands("acc-1") failed |= _check("t5 rolled back", api.published.get(active_switch) == "on" and api.intelligent_devices[ACTIVE_ID]["suspended"] is False, "published {}".format(api.published)) + print("Test 5b: an exception or an empty reply also puts the switch back") + for reply in (RuntimeError("boom"), {"updateDeviceSmartControl": None}): + api.commands = [] + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + await api.switch_event(active_switch, "turn_off") + api.async_graphql_query = AsyncMock(side_effect=reply) if isinstance(reply, Exception) else AsyncMock(return_value=reply) + await api.process_commands("acc-1") + failed |= _check("t5b rolled back {}".format(type(reply).__name__), api.published.get(active_switch) == "on" and api.intelligent_devices[ACTIVE_ID]["suspended"] is False, "published {}".format(api.published)) + + print("Test 5c: a poll straight after a successful change does not flip the switch back until Octopus reports it") + api.commands = [] + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + await api.switch_event(active_switch, "turn_off") + api.async_graphql_query = AsyncMock(return_value={"updateDeviceSmartControl": {"id": ACTIVE_ID}}) + await api.process_commands("acc-1") + polled = {"suspended": False} + api.apply_smart_control_pending(ACTIVE_ID, polled) + failed |= _check("t5c override held", polled["suspended"] is True, "polled {}".format(polled)) + polled = {"suspended": True} + api.apply_smart_control_pending(ACTIVE_ID, polled) + failed |= _check("t5c cleared once reported", ACTIVE_ID not in api.smart_control_pending and polled["suspended"] is True, "pending {}".format(api.smart_control_pending)) + api.smart_control_pending[ACTIVE_ID] = (True, datetime.now() - timedelta(seconds=1)) + polled = {"suspended": False} + api.apply_smart_control_pending(ACTIVE_ID, polled) + failed |= _check("t5c expires", polled["suspended"] is False and ACTIVE_ID not in api.smart_control_pending, "polled {}".format(polled)) + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + + print("Test 5d: devices whose ids end alike are told apart, and the name says which car") + other_id = "other-9" + api.device_id_to_index_suffix(ACTIVE_ID) + api.intelligent_devices[other_id] = {"suspended": False, "model": "iX3", "planned_dispatches": [], "completed_dispatches": []} + api.commands = [] + await api.switch_event(api.get_entity_name("switch", "intelligent_smart_charge", index=api.device_id_to_index_suffix(ACTIVE_ID)), "turn_off") + failed |= _check("t5d right device", [c["device_id"] for c in api.commands] == [ACTIVE_ID] and api.intelligent_devices[other_id]["suspended"] is False, "commands {}".format(api.commands)) + api.intelligent_devices.pop(other_id) + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + api.intelligent_devices[ACTIVE_ID]["model"] = "iX3" + captured = {} + api.dashboard_item = lambda entity, state, attributes=None, app=None: captured.__setitem__(entity, attributes) + api.publish_smart_control_switch(ACTIVE_ID, api.intelligent_devices[ACTIVE_ID]) + failed |= _check("t5d name", "iX3" in captured[active_switch]["friendly_name"], "captured {}".format(captured)) + api.dashboard_item = lambda entity, state, attributes=None, app=None: api.published.__setitem__(entity, state) + api.commands = [] + print("Test 6: events for other entities, other services and unknown devices are ignored") api.commands = [] await api.switch_event("switch.predbat_octopus_acc_1_something_else_1001", "turn_off") From c37a46709079fc3bcc88f930bd95b70759c9402b Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Mon, 5 Oct 2026 21:29:58 +0100 Subject: [PATCH 09/15] fix(octopus): log what the Smart Control switch reports when it stops being off, not "On" (#5341 review) An unknown or unavailable switch is no evidence either way, so the log no longer claims Smart Control came back on. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/fetch.py | 6 +++++- apps/predbat/tests/test_octopus_smart_control.py | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 3b585d20e..babbd421c 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -1466,7 +1466,11 @@ def octopus_smart_control_off(self, car_n, save=True): 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 - self.log("Car {} Octopus Smart Control is now {} ({}), planned Octopus dispatches are {}".format(car_n, "Off" if off else "On", switch_id, "ignored" if off else "used")) + 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): diff --git a/apps/predbat/tests/test_octopus_smart_control.py b/apps/predbat/tests/test_octopus_smart_control.py index 385a55a5c..591b59b79 100644 --- a/apps/predbat/tests/test_octopus_smart_control.py +++ b/apps/predbat/tests/test_octopus_smart_control.py @@ -215,6 +215,10 @@ def run_octopus_smart_control_tests(my_predbat): _slots(my_predbat, save=True) now_off = [x for x in logs if "Smart Control is now Off" in x] failed |= _check("t12 logged once", len(now_off) == 1, "logs {}".format(logs)) + items[SWITCH] = "unavailable" + _slots(my_predbat, save=True) + no_longer = [x for x in logs if "Smart Control is no longer Off" in x] + failed |= _check("t12 unavailable is not called On", len(no_longer) == 1 and "is unavailable" in no_longer[0] and not [x for x in logs if "Smart Control is now On" in x], "logs {}".format(logs)) finally: my_predbat.log = real_log From f620c4ec2fceef948c492ad10086588f9172bae9 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Mon, 5 Oct 2026 21:30:55 +0100 Subject: [PATCH 10/15] fix(octopus): roll a failed Smart Control change back to what Octopus confirmed (#5341 review) Each queued command used to carry the state shown just before it, which after an earlier optimistic change was never confirmed by Octopus. Queueing off then on, with both failing, left the switch off although nothing had changed. The state Octopus last confirmed is now kept per device until every change from the switch has been sent, a rollback goes back to that, and only the latest queued change per device is sent. A change made while another is being sent stays on show until it has been sent too. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/octopus.py | 44 +++++++++---- .../test_octopus_smart_control_switch.py | 62 +++++++++++++++++++ 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 3933ba9ec..ae9fa2028 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -656,6 +656,7 @@ def initialize(self, key, account_id, automatic): self.automatic = automatic self.commands = [] self.smart_control_pending = {} # device_id -> (suspended, expiry) for a Smart Control change Octopus may not yet report + self.smart_control_confirmed = {} # device_id -> suspended as Octopus last confirmed it, held while a change from the switch is unsent self.mpan = None self.tou_windows = None self.tou_windows_day = None @@ -703,6 +704,10 @@ async def switch_event(self, entity_id, service): """ Handle a turn on/off/toggle of an Intelligent Smart Control switch: show the new state straight away and queue the command that sends it to Octopus (rolled back if the command fails). + + Only the latest change per device is queued, and the state Octopus last confirmed is kept aside until + every change has been sent, so a failure puts the switch back to that rather than to a state that was + only ever shown here. """ suffix = self.get_entity_suffix(entity_id) device_id = self.suffix_to_device_id(suffix) @@ -711,18 +716,26 @@ async def switch_event(self, entity_id, service): device = self.intelligent_devices.get(device_id) if not device: return - was_suspended = bool(device.get("suspended")) if service == "turn_on": enabled = True elif service == "turn_off": enabled = False elif service == "toggle": - enabled = was_suspended + enabled = bool(device.get("suspended")) else: return + self.smart_control_confirmed.setdefault(device_id, bool(device.get("suspended"))) device["suspended"] = not enabled self.publish_smart_control_switch(device_id, device) - self.commands.append({"command": "set_intelligent_smart_control", "value": enabled, "device_id": device_id, "was_suspended": was_suspended}) + 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): """ @@ -894,7 +907,7 @@ async def process_commands(self, account_id): 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": - await self.async_set_intelligent_smart_control(command.get("device_id", None), command.get("value", True), command.get("was_suspended", False)) + 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) @@ -1213,10 +1226,13 @@ 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, was_suspended=False): + async def async_set_intelligent_smart_control(self, device_id, enabled): """ Turn Octopus Smart Control on (UNSUSPEND) or off (SUSPEND) for an intelligent device. If Octopus rejects - it the switch goes back to how it was, rather than showing a state that never happened. + it the switch goes back to the state Octopus last confirmed, rather than showing a state that never happened. + + A change made from the switch while this one was being sent is queued behind it, so the switch keeps showing + that newer change and the confirmed state stays aside until the newer one is sent too. """ device = self.intelligent_devices.get(device_id) if not device: @@ -1229,15 +1245,17 @@ async def async_set_intelligent_smart_control(self, device_id, enabled, was_susp 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: + succeeded = bool(result) and result.get("updateDeviceSmartControl") is not None + if succeeded: + self.smart_control_confirmed[device_id] = not enabled + self.smart_control_pending[device_id] = (not enabled, datetime.now() + timedelta(seconds=SMART_CONTROL_PENDING_SECONDS)) + else: self.log("Warn: OctopusAPI: Failed to {} Smart Control for intelligent device {}, putting the switch back".format(action, device_id)) - device["suspended"] = was_suspended - 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)) + if any(self.is_smart_control_command(command, device_id) for command in self.commands): + return succeeded + device["suspended"] = self.smart_control_confirmed.pop(device_id, not enabled if succeeded else device.get("suspended")) self.publish_smart_control_switch(device_id, device) - return True + return succeeded async def async_join_saving_session_events(self, account_id, event_code): """ diff --git a/apps/predbat/tests/test_octopus_smart_control_switch.py b/apps/predbat/tests/test_octopus_smart_control_switch.py index b4d58ccee..6432646b7 100644 --- a/apps/predbat/tests/test_octopus_smart_control_switch.py +++ b/apps/predbat/tests/test_octopus_smart_control_switch.py @@ -78,6 +78,7 @@ async def _run(my_predbat): print("Test 3: turning it back on sends UNSUSPEND, also for a device that is suspended") api.commands = [] + api.smart_control_confirmed = {} await api.switch_event(suspended_switch, "turn_on") failed |= _check("t3 state", api.published.get(suspended_switch) == "on", "published {}".format(api.published)) api.async_graphql_query = AsyncMock(return_value={"updateDeviceSmartControl": {"id": SUSPENDED_ID}}) @@ -88,9 +89,11 @@ async def _run(my_predbat): print("Test 4: toggle flips the state") api.commands = [] + api.smart_control_confirmed = {} await api.switch_event(active_switch, "toggle") failed |= _check("t4 toggled on", api.published.get(active_switch) == "on" and api.commands[0]["value"] is True, "commands {}".format(api.commands)) api.commands = [] + api.smart_control_confirmed = {} print("Test 5: a failed command puts the switch back to how it was") api.intelligent_devices[ACTIVE_ID]["suspended"] = False @@ -103,6 +106,7 @@ async def _run(my_predbat): print("Test 5b: an exception or an empty reply also puts the switch back") for reply in (RuntimeError("boom"), {"updateDeviceSmartControl": None}): api.commands = [] + api.smart_control_confirmed = {} api.intelligent_devices[ACTIVE_ID]["suspended"] = False await api.switch_event(active_switch, "turn_off") api.async_graphql_query = AsyncMock(side_effect=reply) if isinstance(reply, Exception) else AsyncMock(return_value=reply) @@ -111,6 +115,7 @@ async def _run(my_predbat): print("Test 5c: a poll straight after a successful change does not flip the switch back until Octopus reports it") api.commands = [] + api.smart_control_confirmed = {} api.intelligent_devices[ACTIVE_ID]["suspended"] = False await api.switch_event(active_switch, "turn_off") api.async_graphql_query = AsyncMock(return_value={"updateDeviceSmartControl": {"id": ACTIVE_ID}}) @@ -131,6 +136,7 @@ async def _run(my_predbat): other_id = "other-9" + api.device_id_to_index_suffix(ACTIVE_ID) api.intelligent_devices[other_id] = {"suspended": False, "model": "iX3", "planned_dispatches": [], "completed_dispatches": []} api.commands = [] + api.smart_control_confirmed = {} await api.switch_event(api.get_entity_name("switch", "intelligent_smart_charge", index=api.device_id_to_index_suffix(ACTIVE_ID)), "turn_off") failed |= _check("t5d right device", [c["device_id"] for c in api.commands] == [ACTIVE_ID] and api.intelligent_devices[other_id]["suspended"] is False, "commands {}".format(api.commands)) api.intelligent_devices.pop(other_id) @@ -142,9 +148,65 @@ async def _run(my_predbat): failed |= _check("t5d name", "iX3" in captured[active_switch]["friendly_name"], "captured {}".format(captured)) api.dashboard_item = lambda entity, state, attributes=None, app=None: api.published.__setitem__(entity, state) api.commands = [] + api.smart_control_confirmed = {} + + print("Test 5e: two queued changes become one, and when it fails the switch goes back to what Octopus confirmed") + api.commands = [] + api.smart_control_confirmed = {} + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + await api.switch_event(active_switch, "turn_off") + await api.switch_event(active_switch, "turn_on") + failed |= _check("t5e coalesced", [(c["device_id"], c["value"]) for c in api.commands] == [(ACTIVE_ID, True)], "commands {}".format(api.commands)) + api.async_graphql_query = AsyncMock(return_value=None) + await api.process_commands("acc-1") + failed |= _check("t5e confirmed state", api.published.get(active_switch) == "on" and api.intelligent_devices[ACTIVE_ID]["suspended"] is False, "published {}".format(api.published)) + failed |= _check("t5e baseline dropped", ACTIVE_ID not in api.smart_control_confirmed, "confirmed {}".format(api.smart_control_confirmed)) + + print("Test 5f: a change made while another is being sent stays on show, and both failing restores what Octopus confirmed") + api.commands = [] + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + await api.switch_event(active_switch, "turn_off") + + async def _fail_and_switch_back_on(query, *args, **kwargs): + """ + Fail the mutation, with the user turning the switch back on while it was being sent. + """ + if "action: SUSPEND" in query: + await api.switch_event(active_switch, "turn_on") + return None + + api.async_graphql_query = AsyncMock(side_effect=_fail_and_switch_back_on) + await api.process_commands("acc-1") + failed |= _check("t5f newer change shown", api.published.get(active_switch) == "on" and [c["value"] for c in api.commands] == [True], "published {} commands {}".format(api.published, api.commands)) + api.intelligent_devices[ACTIVE_ID]["suspended"] = True # a stale display state must not become the rollback target + await api.process_commands("acc-1") + failed |= _check("t5f back to confirmed", api.published.get(active_switch) == "on" and api.intelligent_devices[ACTIVE_ID]["suspended"] is False, "published {}".format(api.published)) + + print("Test 5g: the first change succeeding becomes the state a later failure goes back to") + api.commands = [] + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + await api.switch_event(active_switch, "turn_off") + + async def _succeed_then_fail(query, *args, **kwargs): + """ + SUSPEND succeeds, with the user turning the switch back on while it was being sent; UNSUSPEND fails. + """ + if "action: SUSPEND" in query: + await api.switch_event(active_switch, "turn_on") + return {"updateDeviceSmartControl": {"id": ACTIVE_ID}} + return None + + api.async_graphql_query = AsyncMock(side_effect=_succeed_then_fail) + await api.process_commands("acc-1") + failed |= _check("t5g newer change shown", api.published.get(active_switch) == "on", "published {}".format(api.published)) + await api.process_commands("acc-1") + failed |= _check("t5g back to the confirmed off", api.published.get(active_switch) == "off" and api.intelligent_devices[ACTIVE_ID]["suspended"] is True, "published {}".format(api.published)) + api.smart_control_pending = {} + api.intelligent_devices[ACTIVE_ID]["suspended"] = False print("Test 6: events for other entities, other services and unknown devices are ignored") api.commands = [] + api.smart_control_confirmed = {} await api.switch_event("switch.predbat_octopus_acc_1_something_else_1001", "turn_off") await api.switch_event(active_switch, "bogus") await api.switch_event(api.get_entity_name("switch", "intelligent_smart_charge", index="9999"), "turn_off") From 0d2adcbf64e47b1b82064903d2acd994c2ab3075 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Mon, 5 Oct 2026 21:34:34 +0100 Subject: [PATCH 11/15] fix(octopus): keep an unsent Smart Control change on show across a device poll (#5341 review) A switch change made while a poll was in flight was overwritten by the poll's older state before it had been sent, so the switch flipped back and a toggle then read the wrong state. The poll's state now becomes the one a failed send goes back to, and the unsent change stays on show. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/octopus.py | 19 ++++++++++++------- .../test_octopus_smart_control_switch.py | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index ae9fa2028..0ae1734da 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -741,15 +741,20 @@ def apply_smart_control_pending(self, device_id, device): """ Keep a Smart Control change we have just sent to Octopus on show until Octopus reports it (or SMART_CONTROL_PENDING_SECONDS pass), so a poll made straight afterwards does not flip the switch back. + + A change from the switch that is not yet sent stays on show too. What the poll reports becomes the + state a failed send goes back to. """ pending = self.smart_control_pending.get(device_id) - if not pending: - return - suspended, expiry = pending - if device.get("suspended") == suspended or datetime.now() >= expiry: - self.smart_control_pending.pop(device_id, None) - else: - device["suspended"] = suspended + if pending: + suspended, expiry = pending + if device.get("suspended") == suspended or datetime.now() >= expiry: + self.smart_control_pending.pop(device_id, None) + else: + device["suspended"] = suspended + if device_id in self.smart_control_confirmed and device_id in self.intelligent_devices: + self.smart_control_confirmed[device_id] = bool(device.get("suspended")) + device["suspended"] = self.intelligent_devices[device_id].get("suspended") def publish_smart_control_switch(self, device_id, device): """ diff --git a/apps/predbat/tests/test_octopus_smart_control_switch.py b/apps/predbat/tests/test_octopus_smart_control_switch.py index 6432646b7..79aa1b7fe 100644 --- a/apps/predbat/tests/test_octopus_smart_control_switch.py +++ b/apps/predbat/tests/test_octopus_smart_control_switch.py @@ -204,6 +204,25 @@ async def _succeed_then_fail(query, *args, **kwargs): api.smart_control_pending = {} api.intelligent_devices[ACTIVE_ID]["suspended"] = False + print("Test 5h: a poll before a change is sent keeps the change on show, and becomes the state a failure goes back to") + api.commands = [] + api.smart_control_confirmed = {} + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + await api.switch_event(active_switch, "turn_off") + polled = {"suspended": False} + api.apply_smart_control_pending(ACTIVE_ID, polled) + failed |= _check("t5h change kept", polled["suspended"] is True and api.smart_control_confirmed.get(ACTIVE_ID) is False, "polled {} confirmed {}".format(polled, api.smart_control_confirmed)) + api.intelligent_devices[ACTIVE_ID] = dict(api.intelligent_devices[ACTIVE_ID], **polled) + await api.switch_event(active_switch, "toggle") + failed |= _check("t5h toggle reads the shown state", [c["value"] for c in api.commands] == [True], "commands {}".format(api.commands)) + await api.switch_event(active_switch, "turn_off") + api.apply_smart_control_pending(ACTIVE_ID, {"suspended": True}) + failed |= _check("t5h poll updates confirmed", api.smart_control_confirmed.get(ACTIVE_ID) is True, "confirmed {}".format(api.smart_control_confirmed)) + api.async_graphql_query = AsyncMock(return_value=None) + await api.process_commands("acc-1") + failed |= _check("t5h fails back to the polled state", api.intelligent_devices[ACTIVE_ID]["suspended"] is True and ACTIVE_ID not in api.smart_control_confirmed, "device {}".format(api.intelligent_devices[ACTIVE_ID])) + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + print("Test 6: events for other entities, other services and unknown devices are ignored") api.commands = [] api.smart_control_confirmed = {} From b4b68dc6cc7ee059bd87133b58aa3b88679009fb Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Mon, 5 Oct 2026 21:38:19 +0100 Subject: [PATCH 12/15] fix(octopus): hold a Smart Control change past the second poll, and forget a gone device's baseline (#5341 review) The 2-minute hold expired before the second 2-minute device poll, so a slow Octopus update flipped the switch back on that poll and flipped it again a poll later. It is now 5 minutes. A change queued for a device that has since gone no longer leaves its confirmed state behind for a later rollback to use. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/octopus.py | 3 ++- .../test_octopus_smart_control_switch.py | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 0ae1734da..667e72022 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -59,7 +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 = 120 # How long a Smart Control change we sent stays on show if Octopus has not yet reported it +SMART_CONTROL_PENDING_SECONDS = 300 # How long a Smart Control change we sent stays on show if Octopus has not yet reported it - 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. @@ -1242,6 +1242,7 @@ async def async_set_intelligent_smart_control(self, device_id, enabled): 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)) + self.smart_control_confirmed.pop(device_id, None) return False action = "UNSUSPEND" if enabled else "SUSPEND" self.log("OctopusAPI: Setting Smart Control {} for intelligent device {}".format(action, device_id)) diff --git a/apps/predbat/tests/test_octopus_smart_control_switch.py b/apps/predbat/tests/test_octopus_smart_control_switch.py index 79aa1b7fe..fea79ce31 100644 --- a/apps/predbat/tests/test_octopus_smart_control_switch.py +++ b/apps/predbat/tests/test_octopus_smart_control_switch.py @@ -223,6 +223,26 @@ async def _succeed_then_fail(query, *args, **kwargs): failed |= _check("t5h fails back to the polled state", api.intelligent_devices[ACTIVE_ID]["suspended"] is True and ACTIVE_ID not in api.smart_control_confirmed, "device {}".format(api.intelligent_devices[ACTIVE_ID])) api.intelligent_devices[ACTIVE_ID]["suspended"] = False + print("Test 5i: a change for a device that has gone since does not leave its confirmed state behind") + api.commands = [] + api.smart_control_confirmed = {} + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + await api.switch_event(active_switch, "turn_off") + gone = api.intelligent_devices.pop(ACTIVE_ID) + await api.process_commands("acc-1") + failed |= _check("t5i baseline dropped", ACTIVE_ID not in api.smart_control_confirmed, "confirmed {}".format(api.smart_control_confirmed)) + api.intelligent_devices[ACTIVE_ID] = dict(gone, suspended=False) + + print("Test 5j: a successful change is held for longer than the poll straight after it") + api.commands = [] + await api.switch_event(active_switch, "turn_off") + api.async_graphql_query = AsyncMock(return_value={"updateDeviceSmartControl": {"id": ACTIVE_ID}}) + await api.process_commands("acc-1") + expiry = api.smart_control_pending[ACTIVE_ID][1] + failed |= _check("t5j held past the next 2-minute poll", expiry - datetime.now() > timedelta(minutes=4), "expiry {}".format(expiry)) + api.smart_control_pending = {} + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + print("Test 6: events for other entities, other services and unknown devices are ignored") api.commands = [] api.smart_control_confirmed = {} From a3546efce475f493a03633736356ebd34d81e4e0 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Mon, 5 Oct 2026 21:43:00 +0100 Subject: [PATCH 13/15] fix(octopus): keep a charger's own schedule while Smart Control is off (#5341 review) When the Ohme component wires the car slots to the charger's own schedule, the Smart Control switch can still read off. Those slots are the charge the charger will run, not Octopus's plan, so they are no longer dropped. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/fetch.py | 6 ++++-- apps/predbat/tests/test_octopus_smart_control.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index babbd421c..6a2d77d9b 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -33,6 +33,7 @@ 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, @@ -1547,10 +1548,11 @@ def fetch_sensor_data_cars(self, save=True): self.octopus_slots[car_n] += completed # 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 + # 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] + 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 diff --git a/apps/predbat/tests/test_octopus_smart_control.py b/apps/predbat/tests/test_octopus_smart_control.py index 591b59b79..e515ecba6 100644 --- a/apps/predbat/tests/test_octopus_smart_control.py +++ b/apps/predbat/tests/test_octopus_smart_control.py @@ -11,6 +11,8 @@ import copy from datetime import timedelta +from const import DISPATCH_SOURCE_CHARGER_SCHEDULE + from tests.test_multi_car_iog import pin_test_clock, restore_test_clock SLOT_SENSOR = "binary_sensor.octopus_energy_abc123_intelligent_dispatching" @@ -193,6 +195,15 @@ def run_octopus_smart_control_tests(my_predbat): failed |= _check("t10 all on", kwh == [2.0, 3.0, 5.0, 10.0], "kwh {}".format(kwh)) _setup(my_predbat) + print("Test 10b: a charger's own schedule (the Ohme component's wiring) is kept while Smart Control is off") + attributes = copy.deepcopy(items[SLOT_SENSOR]) + attributes["planned_dispatches"][0]["source"] = DISPATCH_SOURCE_CHARGER_SCHEDULE + my_predbat.ha_interface.set_state(SLOT_SENSOR, "off", attributes=attributes) + items[SWITCH] = "off" + kwh = _slots(my_predbat) + failed |= _check("t10b schedule kept", kwh == [5.0, 10.0], "kwh {}".format(kwh)) + _setup(my_predbat) + print("Test 11: the state is case-insensitive, and an odd state string is no evidence") my_predbat.args["octopus_intelligent_slot"] = SLOT_SENSOR items[SWITCH] = "OFF" From afb51ce6740a372476f6df036210a4a81d99c387 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Mon, 5 Oct 2026 21:45:47 +0100 Subject: [PATCH 14/15] refactor(octopus): show a Smart Control change once Octopus accepts it, not before (#5341 review) The switch used to show a change straight away and roll it back on failure. That needed the last confirmed state kept aside per device, and each review round found another way for it to go wrong: changes queued behind each other, a poll in between, a lost command, a poll reusing cached settings. The switch now changes once Octopus has accepted the change, as the target time and SoC already do, so it never shows a state Octopus does not have and there is nothing to roll back. Only the latest change per device is sent, two toggles before it is sent cancel out, and an accepted change is held against polls for its full 5 minutes, since a poll whose settings query failed reuses the state on show and so agreeing is no sign Octopus has caught up. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/octopus.py | 74 +++---- .../test_octopus_smart_control_switch.py | 182 ++++++------------ docs/car-charging.md | 2 +- 3 files changed, 88 insertions(+), 170 deletions(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 667e72022..e92ee5b46 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -59,7 +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 we sent stays on show if Octopus has not yet reported it - over two 2-minute device polls, not just the one straight after it +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. @@ -655,8 +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 may not yet report - self.smart_control_confirmed = {} # device_id -> suspended as Octopus last confirmed it, held while a change from the switch is unsent + 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 @@ -702,12 +701,9 @@ async def number_event(self, entity_id, value): async def switch_event(self, entity_id, service): """ - Handle a turn on/off/toggle of an Intelligent Smart Control switch: show the new state straight away - and queue the command that sends it to Octopus (rolled back if the command fails). - - Only the latest change per device is queued, and the state Octopus last confirmed is kept aside until - every change has been sent, so a failure puts the switch back to that rather than to a state that was - only ever shown here. + 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) @@ -716,17 +712,16 @@ async def switch_event(self, entity_id, service): 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": - enabled = bool(device.get("suspended")) + # 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.smart_control_confirmed.setdefault(device_id, bool(device.get("suspended"))) - device["suspended"] = not enabled - self.publish_smart_control_switch(device_id, device) 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}) @@ -739,22 +734,20 @@ def is_smart_control_command(command, device_id): def apply_smart_control_pending(self, device_id, device): """ - Keep a Smart Control change we have just sent to Octopus on show until Octopus reports it (or - SMART_CONTROL_PENDING_SECONDS pass), so a poll made straight afterwards does not flip the switch back. + 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. - A change from the switch that is not yet sent stays on show too. What the poll reports becomes the - state a failed send goes back to. + 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 pending: - suspended, expiry = pending - if device.get("suspended") == suspended or datetime.now() >= expiry: - self.smart_control_pending.pop(device_id, None) - else: - device["suspended"] = suspended - if device_id in self.smart_control_confirmed and device_id in self.intelligent_devices: - self.smart_control_confirmed[device_id] = bool(device.get("suspended")) - device["suspended"] = self.intelligent_devices[device_id].get("suspended") + 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): """ @@ -912,7 +905,9 @@ async def process_commands(self, account_id): 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": - await self.async_set_intelligent_smart_control(command.get("device_id", None), command.get("value", True)) + # 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) @@ -1233,16 +1228,12 @@ async def async_set_intelligent_target_schedule(self, account_id, device_id, tar async def async_set_intelligent_smart_control(self, device_id, enabled): """ - Turn Octopus Smart Control on (UNSUSPEND) or off (SUSPEND) for an intelligent device. If Octopus rejects - it the switch goes back to the state Octopus last confirmed, rather than showing a state that never happened. - - A change made from the switch while this one was being sent is queued behind it, so the switch keeps showing - that newer change and the confirmed state stays aside until the newer one is sent too. + 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)) - self.smart_control_confirmed.pop(device_id, None) return False action = "UNSUSPEND" if enabled else "SUSPEND" self.log("OctopusAPI: Setting Smart Control {} for intelligent device {}".format(action, device_id)) @@ -1251,17 +1242,14 @@ async def async_set_intelligent_smart_control(self, device_id, enabled): 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 - succeeded = bool(result) and result.get("updateDeviceSmartControl") is not None - if succeeded: - self.smart_control_confirmed[device_id] = not enabled - self.smart_control_pending[device_id] = (not enabled, datetime.now() + timedelta(seconds=SMART_CONTROL_PENDING_SECONDS)) - else: - self.log("Warn: OctopusAPI: Failed to {} Smart Control for intelligent device {}, putting the switch back".format(action, device_id)) - if any(self.is_smart_control_command(command, device_id) for command in self.commands): - return succeeded - device["suspended"] = self.smart_control_confirmed.pop(device_id, not enabled if succeeded else device.get("suspended")) + 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 succeeded + return True async def async_join_saving_session_events(self, account_id, event_code): """ diff --git a/apps/predbat/tests/test_octopus_smart_control_switch.py b/apps/predbat/tests/test_octopus_smart_control_switch.py index fea79ce31..f636d9301 100644 --- a/apps/predbat/tests/test_octopus_smart_control_switch.py +++ b/apps/predbat/tests/test_octopus_smart_control_switch.py @@ -65,183 +65,113 @@ async def _run(my_predbat): failed |= _check("t1 active on", api.published.get(active_switch) == "on", "published {}".format(api.published)) failed |= _check("t1 suspended off", api.published.get(suspended_switch) == "off", "published {}".format(api.published)) - print("Test 2: turning the switch off shows off straight away and queues a SUSPEND command") + print("Test 2: turning the switch off queues a SUSPEND command, and the switch shows off once Octopus accepts it") await api.switch_event(active_switch, "turn_off") - failed |= _check("t2 state", api.published.get(active_switch) == "off", "published {}".format(api.published)) - failed |= _check("t2 cached", api.intelligent_devices[ACTIVE_ID]["suspended"] is True, "") + failed |= _check("t2 not shown before it is sent", api.published.get(active_switch) == "on" and api.intelligent_devices[ACTIVE_ID]["suspended"] is False, "published {}".format(api.published)) failed |= _check("t2 queued", [c["command"] for c in api.commands] == ["set_intelligent_smart_control"] and api.commands[0]["value"] is False and api.commands[0]["device_id"] == ACTIVE_ID, "commands {}".format(api.commands)) api.async_graphql_query = AsyncMock(return_value={"updateDeviceSmartControl": {"id": ACTIVE_ID}}) failed |= _check("t2 processed", await api.process_commands("acc-1") is True, "") mutation = api.async_graphql_query.call_args[0][0] failed |= _check("t2 mutation", "updateDeviceSmartControl" in mutation and "action: SUSPEND" in mutation and ACTIVE_ID in mutation, "mutation {}".format(mutation)) - failed |= _check("t2 stays off", api.published.get(active_switch) == "off", "") + failed |= _check("t2 shown once accepted", api.published.get(active_switch) == "off" and api.intelligent_devices[ACTIVE_ID]["suspended"] is True, "published {}".format(api.published)) print("Test 3: turning it back on sends UNSUSPEND, also for a device that is suspended") api.commands = [] - api.smart_control_confirmed = {} await api.switch_event(suspended_switch, "turn_on") - failed |= _check("t3 state", api.published.get(suspended_switch) == "on", "published {}".format(api.published)) api.async_graphql_query = AsyncMock(return_value={"updateDeviceSmartControl": {"id": SUSPENDED_ID}}) await api.process_commands("acc-1") mutation = api.async_graphql_query.call_args[0][0] failed |= _check("t3 mutation", "action: UNSUSPEND" in mutation and SUSPENDED_ID in mutation, "mutation {}".format(mutation)) - failed |= _check("t3 cached", api.intelligent_devices[SUSPENDED_ID]["suspended"] is False, "") + failed |= _check("t3 shown", api.published.get(suspended_switch) == "on" and api.intelligent_devices[SUSPENDED_ID]["suspended"] is False, "published {}".format(api.published)) + api.smart_control_pending = {} - print("Test 4: toggle flips the state") + print("Test 4: toggle flips the state, and a second toggle before it is sent cancels the first") api.commands = [] - api.smart_control_confirmed = {} + api.intelligent_devices[ACTIVE_ID]["suspended"] = True + await api.switch_event(active_switch, "toggle") + failed |= _check("t4 toggled on", [c["value"] for c in api.commands] == [True], "commands {}".format(api.commands)) await api.switch_event(active_switch, "toggle") - failed |= _check("t4 toggled on", api.published.get(active_switch) == "on" and api.commands[0]["value"] is True, "commands {}".format(api.commands)) + failed |= _check("t4 toggled back", [c["value"] for c in api.commands] == [False], "commands {}".format(api.commands)) api.commands = [] - api.smart_control_confirmed = {} - - print("Test 5: a failed command puts the switch back to how it was") api.intelligent_devices[ACTIVE_ID]["suspended"] = False - await api.switch_event(active_switch, "turn_off") - failed |= _check("t5 shows off first", api.published.get(active_switch) == "off", "") - api.async_graphql_query = AsyncMock(return_value=None) - await api.process_commands("acc-1") - failed |= _check("t5 rolled back", api.published.get(active_switch) == "on" and api.intelligent_devices[ACTIVE_ID]["suspended"] is False, "published {}".format(api.published)) - print("Test 5b: an exception or an empty reply also puts the switch back") - for reply in (RuntimeError("boom"), {"updateDeviceSmartControl": None}): + print("Test 5: a rejected, failed or empty reply leaves the switch as it was") + for reply in (None, RuntimeError("boom"), {"updateDeviceSmartControl": None}): api.commands = [] - api.smart_control_confirmed = {} - api.intelligent_devices[ACTIVE_ID]["suspended"] = False + api.published = {} await api.switch_event(active_switch, "turn_off") api.async_graphql_query = AsyncMock(side_effect=reply) if isinstance(reply, Exception) else AsyncMock(return_value=reply) await api.process_commands("acc-1") - failed |= _check("t5b rolled back {}".format(type(reply).__name__), api.published.get(active_switch) == "on" and api.intelligent_devices[ACTIVE_ID]["suspended"] is False, "published {}".format(api.published)) + failed |= _check( + "t5 unchanged {}".format(type(reply).__name__), api.published.get(active_switch) == "on" and api.intelligent_devices[ACTIVE_ID]["suspended"] is False and ACTIVE_ID not in api.smart_control_pending, "published {}".format(api.published) + ) - print("Test 5c: a poll straight after a successful change does not flip the switch back until Octopus reports it") + print("Test 5b: two changes queued for a device become one") + api.commands = [] + await api.switch_event(active_switch, "turn_off") + await api.switch_event(active_switch, "turn_on") + failed |= _check("t5b coalesced", [(c["device_id"], c["value"]) for c in api.commands] == [(ACTIVE_ID, True)], "commands {}".format(api.commands)) + api.commands = [] + + print("Test 5c: a change made while a batch is being sent replaces the one for that device still in the batch") api.commands = [] - api.smart_control_confirmed = {} api.intelligent_devices[ACTIVE_ID]["suspended"] = False + await api.switch_event(suspended_switch, "turn_off") + await api.switch_event(active_switch, "turn_off") + + async def _switch_back_on_while_sending(query, *args, **kwargs): + """ + Accept the mutation, with the user turning the active switch back on while the first one was being sent. + """ + if SUSPENDED_ID in query: + await api.switch_event(active_switch, "turn_on") + return {"updateDeviceSmartControl": {"id": "x"}} + + api.async_graphql_query = AsyncMock(side_effect=_switch_back_on_while_sending) + await api.process_commands("acc-1") + sent = [call[0][0] for call in api.async_graphql_query.call_args_list] + failed |= _check("t5c stale change not sent", not [q for q in sent if ACTIVE_ID in q], "sent {}".format(sent)) + failed |= _check("t5c newer change queued", [(c["device_id"], c["value"]) for c in api.commands] == [(ACTIVE_ID, True)], "commands {}".format(api.commands)) + api.commands = [] + api.smart_control_pending = {} + api.intelligent_devices[SUSPENDED_ID]["suspended"] = True + + print("Test 5d: an accepted change is held against polls for its full time, even once a poll agrees") + api.commands = [] await api.switch_event(active_switch, "turn_off") api.async_graphql_query = AsyncMock(return_value={"updateDeviceSmartControl": {"id": ACTIVE_ID}}) await api.process_commands("acc-1") + expiry = api.smart_control_pending[ACTIVE_ID][1] + failed |= _check("t5d held past the next 2-minute poll", expiry - datetime.now() > timedelta(minutes=4), "expiry {}".format(expiry)) polled = {"suspended": False} api.apply_smart_control_pending(ACTIVE_ID, polled) - failed |= _check("t5c override held", polled["suspended"] is True, "polled {}".format(polled)) + failed |= _check("t5d override held", polled["suspended"] is True, "polled {}".format(polled)) polled = {"suspended": True} api.apply_smart_control_pending(ACTIVE_ID, polled) - failed |= _check("t5c cleared once reported", ACTIVE_ID not in api.smart_control_pending and polled["suspended"] is True, "pending {}".format(api.smart_control_pending)) + polled = {"suspended": False} + api.apply_smart_control_pending(ACTIVE_ID, polled) + failed |= _check("t5d still held after a poll agreed", polled["suspended"] is True and ACTIVE_ID in api.smart_control_pending, "polled {}".format(polled)) api.smart_control_pending[ACTIVE_ID] = (True, datetime.now() - timedelta(seconds=1)) polled = {"suspended": False} api.apply_smart_control_pending(ACTIVE_ID, polled) - failed |= _check("t5c expires", polled["suspended"] is False and ACTIVE_ID not in api.smart_control_pending, "polled {}".format(polled)) + failed |= _check("t5d expires", polled["suspended"] is False and ACTIVE_ID not in api.smart_control_pending, "polled {}".format(polled)) api.intelligent_devices[ACTIVE_ID]["suspended"] = False - print("Test 5d: devices whose ids end alike are told apart, and the name says which car") + print("Test 5e: devices whose ids end alike are told apart, and the name says which car") other_id = "other-9" + api.device_id_to_index_suffix(ACTIVE_ID) api.intelligent_devices[other_id] = {"suspended": False, "model": "iX3", "planned_dispatches": [], "completed_dispatches": []} api.commands = [] - api.smart_control_confirmed = {} await api.switch_event(api.get_entity_name("switch", "intelligent_smart_charge", index=api.device_id_to_index_suffix(ACTIVE_ID)), "turn_off") - failed |= _check("t5d right device", [c["device_id"] for c in api.commands] == [ACTIVE_ID] and api.intelligent_devices[other_id]["suspended"] is False, "commands {}".format(api.commands)) + failed |= _check("t5e right device", [c["device_id"] for c in api.commands] == [ACTIVE_ID] and api.intelligent_devices[other_id]["suspended"] is False, "commands {}".format(api.commands)) api.intelligent_devices.pop(other_id) api.intelligent_devices[ACTIVE_ID]["suspended"] = False api.intelligent_devices[ACTIVE_ID]["model"] = "iX3" captured = {} api.dashboard_item = lambda entity, state, attributes=None, app=None: captured.__setitem__(entity, attributes) api.publish_smart_control_switch(ACTIVE_ID, api.intelligent_devices[ACTIVE_ID]) - failed |= _check("t5d name", "iX3" in captured[active_switch]["friendly_name"], "captured {}".format(captured)) + failed |= _check("t5e name", "iX3" in captured[active_switch]["friendly_name"], "captured {}".format(captured)) api.dashboard_item = lambda entity, state, attributes=None, app=None: api.published.__setitem__(entity, state) api.commands = [] - api.smart_control_confirmed = {} - - print("Test 5e: two queued changes become one, and when it fails the switch goes back to what Octopus confirmed") - api.commands = [] - api.smart_control_confirmed = {} - api.intelligent_devices[ACTIVE_ID]["suspended"] = False - await api.switch_event(active_switch, "turn_off") - await api.switch_event(active_switch, "turn_on") - failed |= _check("t5e coalesced", [(c["device_id"], c["value"]) for c in api.commands] == [(ACTIVE_ID, True)], "commands {}".format(api.commands)) - api.async_graphql_query = AsyncMock(return_value=None) - await api.process_commands("acc-1") - failed |= _check("t5e confirmed state", api.published.get(active_switch) == "on" and api.intelligent_devices[ACTIVE_ID]["suspended"] is False, "published {}".format(api.published)) - failed |= _check("t5e baseline dropped", ACTIVE_ID not in api.smart_control_confirmed, "confirmed {}".format(api.smart_control_confirmed)) - - print("Test 5f: a change made while another is being sent stays on show, and both failing restores what Octopus confirmed") - api.commands = [] - api.intelligent_devices[ACTIVE_ID]["suspended"] = False - await api.switch_event(active_switch, "turn_off") - - async def _fail_and_switch_back_on(query, *args, **kwargs): - """ - Fail the mutation, with the user turning the switch back on while it was being sent. - """ - if "action: SUSPEND" in query: - await api.switch_event(active_switch, "turn_on") - return None - - api.async_graphql_query = AsyncMock(side_effect=_fail_and_switch_back_on) - await api.process_commands("acc-1") - failed |= _check("t5f newer change shown", api.published.get(active_switch) == "on" and [c["value"] for c in api.commands] == [True], "published {} commands {}".format(api.published, api.commands)) - api.intelligent_devices[ACTIVE_ID]["suspended"] = True # a stale display state must not become the rollback target - await api.process_commands("acc-1") - failed |= _check("t5f back to confirmed", api.published.get(active_switch) == "on" and api.intelligent_devices[ACTIVE_ID]["suspended"] is False, "published {}".format(api.published)) - - print("Test 5g: the first change succeeding becomes the state a later failure goes back to") - api.commands = [] - api.intelligent_devices[ACTIVE_ID]["suspended"] = False - await api.switch_event(active_switch, "turn_off") - - async def _succeed_then_fail(query, *args, **kwargs): - """ - SUSPEND succeeds, with the user turning the switch back on while it was being sent; UNSUSPEND fails. - """ - if "action: SUSPEND" in query: - await api.switch_event(active_switch, "turn_on") - return {"updateDeviceSmartControl": {"id": ACTIVE_ID}} - return None - - api.async_graphql_query = AsyncMock(side_effect=_succeed_then_fail) - await api.process_commands("acc-1") - failed |= _check("t5g newer change shown", api.published.get(active_switch) == "on", "published {}".format(api.published)) - await api.process_commands("acc-1") - failed |= _check("t5g back to the confirmed off", api.published.get(active_switch) == "off" and api.intelligent_devices[ACTIVE_ID]["suspended"] is True, "published {}".format(api.published)) - api.smart_control_pending = {} - api.intelligent_devices[ACTIVE_ID]["suspended"] = False - - print("Test 5h: a poll before a change is sent keeps the change on show, and becomes the state a failure goes back to") - api.commands = [] - api.smart_control_confirmed = {} - api.intelligent_devices[ACTIVE_ID]["suspended"] = False - await api.switch_event(active_switch, "turn_off") - polled = {"suspended": False} - api.apply_smart_control_pending(ACTIVE_ID, polled) - failed |= _check("t5h change kept", polled["suspended"] is True and api.smart_control_confirmed.get(ACTIVE_ID) is False, "polled {} confirmed {}".format(polled, api.smart_control_confirmed)) - api.intelligent_devices[ACTIVE_ID] = dict(api.intelligent_devices[ACTIVE_ID], **polled) - await api.switch_event(active_switch, "toggle") - failed |= _check("t5h toggle reads the shown state", [c["value"] for c in api.commands] == [True], "commands {}".format(api.commands)) - await api.switch_event(active_switch, "turn_off") - api.apply_smart_control_pending(ACTIVE_ID, {"suspended": True}) - failed |= _check("t5h poll updates confirmed", api.smart_control_confirmed.get(ACTIVE_ID) is True, "confirmed {}".format(api.smart_control_confirmed)) - api.async_graphql_query = AsyncMock(return_value=None) - await api.process_commands("acc-1") - failed |= _check("t5h fails back to the polled state", api.intelligent_devices[ACTIVE_ID]["suspended"] is True and ACTIVE_ID not in api.smart_control_confirmed, "device {}".format(api.intelligent_devices[ACTIVE_ID])) - api.intelligent_devices[ACTIVE_ID]["suspended"] = False - - print("Test 5i: a change for a device that has gone since does not leave its confirmed state behind") - api.commands = [] - api.smart_control_confirmed = {} - api.intelligent_devices[ACTIVE_ID]["suspended"] = False - await api.switch_event(active_switch, "turn_off") - gone = api.intelligent_devices.pop(ACTIVE_ID) - await api.process_commands("acc-1") - failed |= _check("t5i baseline dropped", ACTIVE_ID not in api.smart_control_confirmed, "confirmed {}".format(api.smart_control_confirmed)) - api.intelligent_devices[ACTIVE_ID] = dict(gone, suspended=False) - - print("Test 5j: a successful change is held for longer than the poll straight after it") - api.commands = [] - await api.switch_event(active_switch, "turn_off") - api.async_graphql_query = AsyncMock(return_value={"updateDeviceSmartControl": {"id": ACTIVE_ID}}) - await api.process_commands("acc-1") - expiry = api.smart_control_pending[ACTIVE_ID][1] - failed |= _check("t5j held past the next 2-minute poll", expiry - datetime.now() > timedelta(minutes=4), "expiry {}".format(expiry)) - api.smart_control_pending = {} - api.intelligent_devices[ACTIVE_ID]["suspended"] = False print("Test 6: events for other entities, other services and unknown devices are ignored") api.commands = [] diff --git a/docs/car-charging.md b/docs/car-charging.md index 46ca413d8..afdf7cbc8 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -160,7 +160,7 @@ For **multiple IOG-enrolled vehicles**, set this to a list with one sensor per c If you are using Octopus-led charging with the [Octopus direct connection](energy-rates.md#octopus-energy-direct) method: - Predbat gets its Octopus charging slot information direct from the Octopus API, so comment out or delete octopus_intelligent_slot, octopus_intelligent_smart_control, octopus_ready_time and octopus_charge_limit from `apps.yaml`. -- Predbat publishes a `switch.predbat_octopus_{account}_intelligent_smart_charge_{n}` switch for each Intelligent device, which is on while Octopus Smart Control is on. Turning it off or on in Home Assistant sends SUSPEND / UNSUSPEND to Octopus, the same as the Smart Control toggle in the Octopus app, and the switch goes back to how it was if Octopus rejects the change. It is also shown for a device that is suspended, so that you can turn Smart Control back on. While it is off Predbat ignores the planned Octopus slots, and a suspended device is no longer wired into Predbat until Smart Control is turned back on. +- Predbat publishes a `switch.predbat_octopus_{account}_intelligent_smart_charge_{n}` switch for each Intelligent device, which is on while Octopus Smart Control is on. Turning it off or on in Home Assistant sends SUSPEND / UNSUSPEND to Octopus on Predbat's next Octopus update, the same as the Smart Control toggle in the Octopus app. The switch shows the change once Octopus has accepted it, and stays as it was if Octopus rejects it. It is also shown for a device that is suspended, so that you can turn Smart Control back on. A suspended device is no longer wired into Predbat, so while Smart Control is off Predbat plans that car's charging as it would without Intelligent Octopus, until Smart Control is turned back on. If you are using Predbat-led charging: From fbf161001591a5066cd45e370a16feb89aaae668 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Mon, 5 Oct 2026 21:57:24 +0100 Subject: [PATCH 15/15] test(octopus): restore shared state on failure, and check the suspended switch is really published (#5341 review) Co-Authored-By: Claude Opus 5.5 --- .../tests/test_octopus_smart_control.py | 1 + .../test_octopus_smart_control_switch.py | 26 +++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/predbat/tests/test_octopus_smart_control.py b/apps/predbat/tests/test_octopus_smart_control.py index e515ecba6..f2faaaefd 100644 --- a/apps/predbat/tests/test_octopus_smart_control.py +++ b/apps/predbat/tests/test_octopus_smart_control.py @@ -45,6 +45,7 @@ "car_charging_soc", "car_charging_soc_next", "car_charging_loss", + "car_charging_limit_model", ) ARG_KEYS = ("car_charging_loss", "car_charging_soc", "car_charging_limit", "octopus_intelligent_slot", "octopus_intelligent_smart_control") diff --git a/apps/predbat/tests/test_octopus_smart_control_switch.py b/apps/predbat/tests/test_octopus_smart_control_switch.py index f636d9301..2d2bc7bc2 100644 --- a/apps/predbat/tests/test_octopus_smart_control_switch.py +++ b/apps/predbat/tests/test_octopus_smart_control_switch.py @@ -186,17 +186,21 @@ async def _switch_back_on_while_sending(query, *args, **kwargs): # because my_predbat is shared by every test in the run original_args = dict(my_predbat.args) del api.set_arg - api.intelligent_devices[ACTIVE_ID]["suspended"] = False - api.intelligent_devices[SUSPENDED_ID]["suspended"] = True - api.automatic_config({}) - failed |= _check("t7 switch wired", my_predbat.args.get("octopus_intelligent_smart_control") == [active_switch], "args {}".format(my_predbat.args)) - failed |= _check("t7 slot wired alongside", len(my_predbat.args.get("octopus_intelligent_slot", [])) == 1, "args {}".format(my_predbat.args)) - api.intelligent_devices[ACTIVE_ID]["suspended"] = True - api.automatic_config({}) - failed |= _check("t7 none when all suspended", my_predbat.args.get("octopus_intelligent_smart_control") == [], "args {}".format(my_predbat.args)) - failed |= _check("t7 switches still published for suspended", api.published.get(active_switch) is not None, "") - my_predbat.args.clear() - my_predbat.args.update(original_args) + try: + api.intelligent_devices[ACTIVE_ID]["suspended"] = False + api.intelligent_devices[SUSPENDED_ID]["suspended"] = True + api.automatic_config({}) + failed |= _check("t7 switch wired", my_predbat.args.get("octopus_intelligent_smart_control") == [active_switch], "args {}".format(my_predbat.args)) + failed |= _check("t7 slot wired alongside", len(my_predbat.args.get("octopus_intelligent_slot", [])) == 1, "args {}".format(my_predbat.args)) + api.intelligent_devices[ACTIVE_ID]["suspended"] = True + api.automatic_config({}) + failed |= _check("t7 none when all suspended", my_predbat.args.get("octopus_intelligent_smart_control") == [], "args {}".format(my_predbat.args)) + api.published = {} + await api.async_intelligent_update_sensor("acc-1") + failed |= _check("t7 switches still published for suspended", api.published.get(active_switch) == "off" and api.published.get(suspended_switch) == "off", "published {}".format(api.published)) + finally: + my_predbat.args.clear() + my_predbat.args.update(original_args) return failed