diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 6aef46e77..54faf021e 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_planned_slot": {"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..7e35a622b 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -1445,6 +1445,21 @@ def fetch_sensor_data_car_planning(self): self.log("Car {} charging is exclusive, will not plan other cars".format(car_n)) break + def car_planned_slot_entity(self, planned_entity_id_list, car_n, slot_entity_id): + """ + The sensor a car's planned dispatches are read from, where that is not its slot sensor. + + octopus_intelligent_slot supplies both the completed and the planned dispatches unless + octopus_intelligent_planned_slot names another sensor for the car. None when it does not, or + when it names the slot sensor itself - reading that twice would only repeat its dispatches. + """ + if car_n >= len(planned_entity_id_list): + return None + planned_entity_id = planned_entity_id_list[car_n] + if not planned_entity_id or not isinstance(planned_entity_id, str) or planned_entity_id.startswith("re:") or planned_entity_id == slot_entity_id: + return None + return planned_entity_id + 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 @@ -1478,6 +1493,14 @@ def fetch_sensor_data_cars(self, save=True): else: entity_id_list = [] + # A second sensor per car for the planned dispatches, where the one above only has a reliable + # record of the completed ones - see car_planned_slot_entity() + planned_entity_id_config = self.get_arg("octopus_intelligent_planned_slot", default=None, indirect=False) + if planned_entity_id_config and not isinstance(planned_entity_id_config, list): + planned_entity_id_list = [planned_entity_id_config] + else: + planned_entity_id_list = planned_entity_id_config or [] + # Cars whose charging plan came from Octopus Intelligent dispatch slots this cycle - used # below to decide which cars get a model-facing charge limit override (#4967) iog_slot_cars = [] @@ -1514,6 +1537,21 @@ def fetch_sensor_data_cars(self, save=True): self.log("Warn: Unable to get data from {} for car {} - octopus_intelligent_slot may not be set correctly in apps.yaml".format(entity_id, car_n)) self.record_status(message="Error: octopus_intelligent_slot not set correctly in apps.yaml for car {}".format(car_n), had_errors=True) + planned_entity_id = self.car_planned_slot_entity(planned_entity_id_list, car_n, entity_id) + if planned_entity_id: + # The slot sensor above keeps the record of what was dispatched, and so billed + # off-peak (#5413); the plan of what is still to come is taken from here instead. + # What this sensor has already seen finish goes in behind the slot sensor's own + # completed list: it covers a slot from when it ends until the slot sensor lists + # it, and load_octopus_slots() drops whatever of it the slot sensor has by then + try: + planned = self.get_state_wrapper(entity_id=planned_entity_id, attribute="planned_dispatches") or self.get_state_wrapper(entity_id=planned_entity_id, attribute="plannedDispatches") + planned_completed = self.get_state_wrapper(entity_id=planned_entity_id, attribute="completed_dispatches") or self.get_state_wrapper(entity_id=planned_entity_id, attribute="completedDispatches") + completed = (completed or []) + (planned_completed or []) + except (ValueError, TypeError): + self.log("Warn: Unable to get data from {} for car {} - octopus_intelligent_planned_slot may not be set correctly in apps.yaml".format(planned_entity_id, car_n)) + self.record_status(message="Error: octopus_intelligent_planned_slot not set correctly in apps.yaml for car {}".format(car_n), had_errors=True) + # Completed and planned slots - merge from all cars if completed: self.octopus_slots[car_n] += completed diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index bbb79a5a6..a68edce00 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -1459,10 +1459,12 @@ def automatic_config(self, tariffs): # Another component may have claimed the car slots - the Ohme component does when it is # set to take the Intelligent slots from the charger instead. This method re-runs whenever # the tariff or device set moves, so without this check it would quietly take them back. + # The claim is on the ready time and charge limit only. The dispatch sensor is still wired + # here: it is Octopus's record of what was dispatched, which the other component reads its + # completed slots from while supplying the planned ones itself (#5413). slot_owner = getattr(self.base, "car_slot_owner", None) - if slot_owner and slot_owner != "octopus": - self.log("OctopusAPI: Car slots are wired by the {} component, leaving them alone".format(slot_owner)) - elif devices or self.intelligent_config_devices: + other_owner = bool(slot_owner and slot_owner != "octopus") + if devices or self.intelligent_config_devices: # Suspended devices (e.g. an old/decommissioned charger still linked to the Octopus # account) aren't actively charging, so exclude them from the entity lists and from # the num_cars count below - otherwise a stale suspended device can silently push @@ -1490,8 +1492,11 @@ 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_ready_time", ready_list) - self.set_arg("octopus_charge_limit", limit_list) + if other_owner: + self.log("OctopusAPI: Car ready time and charge limit are wired by the {} component, leaving them alone".format(slot_owner)) + else: + self.set_arg("octopus_ready_time", ready_list) + self.set_arg("octopus_charge_limit", limit_list) self.intelligent_config_slots = slot_list # Increase number of cars if we have more active devices than the current limit to ensure all devices can be configured num_cars = self.get_arg("num_cars", 0) diff --git a/apps/predbat/ohme.py b/apps/predbat/ohme.py index 782688d36..2582e8c08 100644 --- a/apps/predbat/ohme.py +++ b/apps/predbat/ohme.py @@ -247,6 +247,10 @@ def initialize(self, email, password, ohme_automatic=False, ohme_automatic_octop # The mode the args were last wired for. Kept apart from slot_mode so that a wiring change # lost to a failed poll is still owed, and made on the next one self.slot_mode_applied = None + # On Octopus Intelligent, the sensor Octopus's own record of the dispatches is read from, None + # when there is not one and Ohme's slots are all there is - see octopus_dispatch_sensor() + self.slot_source = None + self.slot_source_applied = None # Ohme's own schedule is Predbat's car charging plan, as load only - see charger_slots_wanted() self.charger_slots = False # The reason charger_slots_wanted() last stood down, so it is only logged when it changes @@ -313,9 +317,10 @@ async def run(self, seconds, first): # After the publish, so the slots already read the new way when the args move. Compared # against what was last wired rather than done on the change alone: if the session fetch or # the publish above fails, this is skipped, and has to still be owed on the next poll - if poll and self.client.serial and self.slot_mode != self.slot_mode_applied: + if poll and self.client.serial and (self.slot_mode != self.slot_mode_applied or self.slot_source != self.slot_source_applied): await self.apply_slot_mode() self.slot_mode_applied = self.slot_mode + self.slot_source_applied = self.slot_source # Unconditional and outside the "if first and self.client.serial:" block above, so a # transient failure on that one-shot cycle is retried rather than lost - see @@ -596,6 +601,38 @@ async def update_slot_mode(self): slot_mode = None self.charger_slots = slot_mode == SLOT_MODE_CHARGER self.slot_mode = slot_mode + # Asked on every poll, like the mode: the Octopus component wires its dispatch sensor when it + # finds a device and clears it when the last one goes, and the Ohme wiring has to follow + slot_source = self.octopus_dispatch_sensor() if slot_mode == SLOT_MODE_INTELLIGENT else None + if slot_mode == SLOT_MODE_INTELLIGENT and (slot_source != self.slot_source or self.slot_mode_applied != SLOT_MODE_INTELLIGENT): + if slot_source: + self.log("Info: Ohme API: Taking the planned car slots from Ohme and the completed dispatches from {}".format(slot_source)) + else: + self.log("Info: Ohme API: No Octopus dispatch sensor is set in octopus_intelligent_slot, so the completed car slots come from Ohme too - they are only kept until the car is unplugged") + self.slot_source = slot_source + + def octopus_dispatch_sensor(self): + """ + The sensor holding Octopus's own record of the car's dispatches, or None when there is not one. + + Ohme knows the charge that is planned sooner and better than Octopus reports it, but only for + the session in progress: unplug the car and every slot it charged in is gone, and with them + the off-peak rate those minutes were billed at (#5413). Octopus keeps its completed dispatches, + so where octopus_intelligent_slot already points at a sensor of Octopus's - wired by the + Octopus component, or set in apps.yaml to the Octopus Energy integration's - that sensor is + left as the record of what has been dispatched and Ohme supplies the plan beside it. + + An Ohme sensor is not such a record, whether ours or the Ohme integration's. Nor is an + unmatched regex from the apps.yaml default, still its literal "re:" string at this point. + """ + existing = self.get_arg("octopus_intelligent_slot", default=None, indirect=False) + if isinstance(existing, list): + existing = existing[0] if existing else None + if not existing or not isinstance(existing, str) or existing.startswith("re:"): + return None + if existing == SLOT_ACTIVE_ENTITY or existing.split(".")[-1].startswith("ohme_"): + return None + return existing async def apply_slot_mode(self): """ @@ -604,9 +641,10 @@ async def apply_slot_mode(self): if self.slot_mode == SLOT_MODE_INTELLIGENT: await self.automatic_config_octopus_intelligent() return - # Only the Intelligent wiring holds the car slots against the Octopus component + # Only the Intelligent wiring holds the ready time and charge limit against the Octopus component if self.base.car_slot_owner == "ohme": self.base.car_slot_owner = None + self.clear_planned_slots() if self.slot_mode == SLOT_MODE_CHARGER: await self.automatic_config_charger_slots() else: @@ -634,7 +672,7 @@ def charger_slots_wanted(self, octopus_intelligent): existing = existing[0] # An unmatched regex from the apps.yaml default is still its literal "re:" string at this # point, as it is for car_charging_energy in automatic_config() - that is not a real entity - if existing and existing != SLOT_ACTIVE_ENTITY and not (isinstance(existing, str) and existing.startswith("re:")): + if existing and existing != SLOT_ACTIVE_ENTITY and not (isinstance(existing, str) and existing.startswith("re:")) and not self.dispatch_sensor_spent(existing): # Asked on every poll, so only said when it changes if existing != self.charger_slots_blocked: self.log("Info: Ohme API: Leaving octopus_intelligent_slot set to {} rather than taking the car charging plan from Ohme".format(existing)) @@ -687,13 +725,31 @@ async def automatic_config_octopus_intelligent(self): """ Automatically set the predbat entities to take the Intelligent car slots from Ohme. - Claims the car slot args so OctopusAPI.automatic_config() stops re-wiring them to its own - dispatch entities - it re-runs whenever the tariff or intelligent device set moves, which - would otherwise silently undo this part way through a run. + Claims the car slot args so OctopusAPI.automatic_config() stops re-wiring the ready time and + charge limit to its own entities - it re-runs whenever the tariff or intelligent device set + moves, which would otherwise silently undo this part way through a run. The claim does not + cover octopus_intelligent_slot, which the Octopus component goes on wiring to its dispatch + sensor: where there is one it stays as the record of the completed dispatches, and Ohme + supplies only the planned ones - see octopus_dispatch_sensor(). """ self.log("Info: Ohme API: Setting Predbat to use Ohme") self.base.car_slot_owner = "ohme" - self.wire_car_slots() + if self.slot_source: + self.wire_planned_slots() + else: + self.wire_car_slots() + + def dispatch_sensor_spent(self, entity_id): + """ + Is this the Octopus dispatch sensor Ohme's planned slots were last read beside, now with nothing more to give. + + On Octopus Intelligent that sensor is left in octopus_intelligent_slot as the record of the + completed dispatches. Once Intelligent no longer schedules anything - the tariff has gone, or + every device is suspended - it is not a plan of anyone's, and left there it would keep the + car on dispatches that never come. Not so while Octopus drives another device, whose + dispatches that sensor is about to carry. + """ + return bool(entity_id) and entity_id == self.slot_source_applied and not self.octopus_intelligent and not self.octopus_other_device async def automatic_config_charger_slots(self): """ @@ -729,13 +785,51 @@ def clear_car_slots(self): self.set_arg("octopus_ready_time", []) self.set_arg("octopus_charge_limit", []) + def clear_planned_slots(self): + """ + Take the planned slot arg, and the ready time and charge limit wired with it, back off the Ohme entities. + + For leaving the wiring wire_planned_slots() made, where octopus_intelligent_slot was never + Ohme's and so clear_car_slots() finds nothing to do. Anything since pointed elsewhere is left. + """ + existing = self.get_arg("octopus_intelligent_planned_slot", default=None, indirect=False) + if isinstance(existing, list) and len(existing) == 1: + existing = existing[0] + if existing != SLOT_ACTIVE_ENTITY: + return + self.set_arg("octopus_intelligent_planned_slot", []) + slot = self.get_arg("octopus_intelligent_slot", default=None, indirect=False) + if isinstance(slot, list) and len(slot) == 1: + slot = slot[0] + if self.dispatch_sensor_spent(slot): + self.set_arg("octopus_intelligent_slot", []) + for arg in ("octopus_ready_time", "octopus_charge_limit"): + current = self.get_arg(arg, default=None, indirect=False) + if isinstance(current, list) and len(current) == 1: + current = current[0] + if current == CAR_DISCOVERY_ENTITY_SPEC[arg]["entity_id"]: + self.set_arg(arg, []) + def wire_car_slots(self): """ Point the car slot args at the Ohme entities. """ self.set_arg("octopus_intelligent_slot", SLOT_ACTIVE_ENTITY) - self.set_arg("octopus_ready_time", "select.predbat_ohme_target_time") - self.set_arg("octopus_charge_limit", "number.predbat_ohme_target_percent") + # The slot sensor now carries the planned slots as well, nothing is read beside it + self.set_arg("octopus_intelligent_planned_slot", []) + self.set_arg("octopus_ready_time", CAR_DISCOVERY_ENTITY_SPEC["octopus_ready_time"]["entity_id"]) + self.set_arg("octopus_charge_limit", CAR_DISCOVERY_ENTITY_SPEC["octopus_charge_limit"]["entity_id"]) + + def wire_planned_slots(self): + """ + Point the planned slot arg at the Ohme slots, leaving octopus_intelligent_slot on Octopus's sensor. + + The ready time and charge limit are Ohme's either way: they are set on the charger, which is + what Octopus is scheduling. + """ + self.set_arg("octopus_intelligent_planned_slot", [SLOT_ACTIVE_ENTITY]) + self.set_arg("octopus_ready_time", CAR_DISCOVERY_ENTITY_SPEC["octopus_ready_time"]["entity_id"]) + self.set_arg("octopus_charge_limit", CAR_DISCOVERY_ENTITY_SPEC["octopus_charge_limit"]["entity_id"]) def _discovery_vehicle(self): """ diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 8af085388..a0f6bfed5 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -527,10 +527,12 @@ def reset(self): self.car_charging_threshold = 99 self.car_charging_energy = {} self.car_charging_energy_warned = False - # Which component's automatic_config() owns octopus_intelligent_slot/ready_time/charge_limit. - # Both OctopusAPI and OhmeAPI can wire the car slots, and Octopus re-runs its automatic_config + # Which component's automatic_config() owns octopus_ready_time/octopus_charge_limit. + # Both OctopusAPI and OhmeAPI can wire them, and Octopus re-runs its automatic_config # whenever the tariff or intelligent device set moves - without a claim it silently takes the - # args back off Ohme part way through a run. None means nobody has claimed them. + # args back off Ohme part way through a run. None means nobody has claimed them. The claim + # does not cover octopus_intelligent_slot: Octopus goes on wiring its dispatch sensor there + # as the record of completed dispatches, and Ohme adds octopus_intelligent_planned_slot (#5413). self.car_slot_owner = None self.octopus_intelligent_charging = False self.octopus_intelligent_ignore_unplugged = False diff --git a/apps/predbat/tests/test_multi_car_iog.py b/apps/predbat/tests/test_multi_car_iog.py index cbe2eb05e..0f38d1acc 100644 --- a/apps/predbat/tests/test_multi_car_iog.py +++ b/apps/predbat/tests/test_multi_car_iog.py @@ -527,6 +527,107 @@ def run_multi_car_iog_adhoc_dispatch_test(testname, my_predbat): return failed +def run_planned_slot_sensor_test(testname, my_predbat): + """ + Issue #5413: a charger such as Ohme knows the charge that is planned, but only for the session + in progress - unplug the car and its record of the slots already charged in is gone, and the + off-peak rate those minutes were billed at goes with it. octopus_intelligent_planned_slot names + a second sensor to take the planned dispatches from, leaving octopus_intelligent_slot as the + lasting record of the completed ones. This drives the real fetch_sensor_data_cars(). + """ + failed = False + print("**** Running Test: multi_car_iog {} ****".format(testname)) + + 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 = True + my_predbat.octopus_intelligent_consider_full = False + + saved_clock = pin_test_clock(my_predbat) + saved_planned_arg = my_predbat.args.get("octopus_intelligent_planned_slot") + + slot_entity = "binary_sensor.octopus_energy_intelligent_dispatching_5413" + planned_entity = "binary_sensor.predbat_ohme_slot_active_5413" + my_predbat.args["car_charging_loss"] = 0.0 + my_predbat.args["car_charging_soc"] = [50.0] + my_predbat.args["car_charging_limit"] = [100.0] + my_predbat.args["octopus_intelligent_slot"] = [slot_entity] + my_predbat.args["octopus_intelligent_planned_slot"] = [planned_entity] + + def stamp(minutes): + """A dispatch time this many minutes from now""" + return (my_predbat.now_utc + timedelta(minutes=minutes)).strftime("%Y-%m-%dT%H:%M:%S%z") + + octopus_completed = {"start": stamp(-180), "end": stamp(-150), "charge_in_kwh": -3.5, "source": "smart-charge", "location": "AT_HOME"} + octopus_planned = {"start": stamp(300), "end": stamp(330), "charge_in_kwh": -3.5, "source": "smart-charge", "location": "AT_HOME"} + ohme_completed = {"start": stamp(-60), "end": stamp(-30), "energy": -3.7, "location": "AT_HOME"} + ohme_planned = {"start": stamp(60), "end": stamp(120), "energy": -7.4, "location": "AT_HOME"} + + def fetch(ohme_attributes, plugged): + """Run the real fetch against the two sensors, returning the car's raw slots""" + my_predbat.ha_interface.set_state(slot_entity, "off", attributes={"completed_dispatches": [octopus_completed], "planned_dispatches": [octopus_planned]}) + my_predbat.ha_interface.set_state(planned_entity, "off", attributes=ohme_attributes) + my_predbat.car_charging_planned = [plugged] + my_predbat.octopus_slots = [[]] + my_predbat.fetch_sensor_data_cars(save=False) + return my_predbat.octopus_slots[0] + + # Plugged in: the plan is the charger's, and what it has seen finish stands in until the slot sensor lists it + slots = fetch({"completed_dispatches": [ohme_completed], "planned_dispatches": [ohme_planned]}, plugged=True) + if slots != [octopus_completed, ohme_completed, ohme_planned]: + print("ERROR: expected the slot sensor's completed dispatches then the planned sensor's slots, got {}".format(slots)) + failed = True + if octopus_planned in slots: + print("ERROR: the slot sensor's own planned dispatches should give way to the planned sensor's, got {}".format(slots)) + failed = True + + # Unplugged: the charger's session is over and it reports no slots at all. The completed + # dispatch is still on the slot sensor, so the minutes it covered keep their off-peak rate + slots = fetch({}, plugged=False) + if slots != [octopus_completed]: + print("ERROR: expected the completed dispatch kept once the car is unplugged, got {}".format(slots)) + failed = True + + # The same sensor named twice is read once + my_predbat.args["octopus_intelligent_planned_slot"] = [slot_entity] + slots = fetch({}, plugged=True) + if slots != [octopus_completed, octopus_planned]: + print("ERROR: expected the slot sensor read once when it is also named as the planned sensor, got {}".format(slots)) + failed = True + + # An unmatched regex, or no entry for the car, leaves the slot sensor supplying both + for value in (["re:(binary_sensor.no_such_sensor)"], [], None): + my_predbat.args["octopus_intelligent_planned_slot"] = value + slots = fetch({"planned_dispatches": [ohme_planned]}, plugged=True) + if slots != [octopus_completed, octopus_planned]: + print("ERROR: expected the slot sensor's own dispatches with octopus_intelligent_planned_slot {}, got {}".format(value, slots)) + failed = True + + if saved_planned_arg is None: + my_predbat.args.pop("octopus_intelligent_planned_slot", None) + else: + my_predbat.args["octopus_intelligent_planned_slot"] = saved_planned_arg + restore_test_clock(my_predbat, saved_clock) + + if failed: + print("Test: {} FAILED".format(testname)) + else: + print("Test: {} PASSED".format(testname)) + + return failed + + def run_charger_schedule_plan_test(testname, my_predbat): """ Issue #5399: off Octopus Intelligent, and with Predbat not controlling the charger, the Ohme @@ -985,4 +1086,5 @@ def run_multi_car_iog_tests(my_predbat): failed |= run_iog_consider_full_predict_test("multi_car_iog_consider_full_predict_4967", my_predbat) failed |= run_update_car_manual_soc_cap_test("multi_car_iog_manual_soc_cap_4967", my_predbat) failed |= run_charger_schedule_plan_test("charger_schedule_is_the_car_plan_5399", my_predbat) + failed |= run_planned_slot_sensor_test("planned_slot_sensor_5413", my_predbat) return failed diff --git a/apps/predbat/tests/test_octopus_intelligent_devices.py b/apps/predbat/tests/test_octopus_intelligent_devices.py index 885a533ca..eb6da100c 100644 --- a/apps/predbat/tests/test_octopus_intelligent_devices.py +++ b/apps/predbat/tests/test_octopus_intelligent_devices.py @@ -1322,12 +1322,18 @@ def test_car_slots_rewired_when_owner_changes(my_predbat): api.report_discovery = lambda report: None own_entity = api.get_entity_name("binary_sensor", "intelligent_dispatch", index=api.device_id_to_index_suffix(device_id)) - # Ohme holds the car slots on the first cycle, so Octopus leaves them alone + # Ohme holds the car slots on the first cycle. The claim covers the ready time and charge limit: + # the dispatch sensor is still wired, as the record of the completed dispatches (#5413) my_predbat.car_slot_owner = "ohme" my_predbat.args["octopus_intelligent_slot"] = "binary_sensor.predbat_ohme_slot_active" + my_predbat.args["octopus_ready_time"] = "select.predbat_ohme_target_time" + my_predbat.args["octopus_charge_limit"] = "number.predbat_ohme_target_percent" asyncio.run(api.run(seconds=0, first=True)) - if my_predbat.args.get("octopus_intelligent_slot") != "binary_sensor.predbat_ohme_slot_active": - print(f"ERROR: expected the Ohme wiring left alone while claimed, got {my_predbat.args.get('octopus_intelligent_slot')}") + if my_predbat.args.get("octopus_intelligent_slot") != [own_entity]: + print(f"ERROR: expected the dispatch sensor wired while claimed, got {my_predbat.args.get('octopus_intelligent_slot')}") + failed += 1 + if my_predbat.args.get("octopus_ready_time") != "select.predbat_ohme_target_time" or my_predbat.args.get("octopus_charge_limit") != "number.predbat_ohme_target_percent": + print(f"ERROR: expected the Ohme ready time and charge limit left alone while claimed, got {my_predbat.args.get('octopus_ready_time')} {my_predbat.args.get('octopus_charge_limit')}") failed += 1 # Ohme lets go and clears its wiring. Nothing about the devices has changed... @@ -1391,8 +1397,9 @@ def make_api(account_id, tariffs, suspended): return api def release_and_run(api, cycles=2): - """The Ohme component lets go, keeping the slots on its own entity, then sensor refreshes follow""" + """The Ohme component lets go, taking the slots for its own schedule, then sensor refreshes follow""" my_predbat.car_slot_owner = None + my_predbat.args["octopus_intelligent_slot"] = ohme_entity for cycle in range(cycles): api.sensor_updated_at = None asyncio.run(api.run(seconds=120 * (cycle + 1), first=False)) diff --git a/apps/predbat/tests/test_octopus_misc.py b/apps/predbat/tests/test_octopus_misc.py index c2d3f097a..00354e3f3 100644 --- a/apps/predbat/tests/test_octopus_misc.py +++ b/apps/predbat/tests/test_octopus_misc.py @@ -2127,15 +2127,16 @@ def test_octopus_automatic_config_clears_removed_devices(my_predbat): def test_octopus_automatic_config_respects_slot_claim(my_predbat): """ - Test that automatic_config leaves the car slot args alone when another component owns them. + Test that automatic_config leaves the car ready time and charge limit alone when another component owns them. - The Ohme component can be configured to take the Intelligent slots from the charger instead, - and claims the args when it does. automatic_config() re-runs whenever the tariff or the live - device set moves, so without honouring the claim it silently takes them back part way through - a run and the wiring flip-flops between the two components. + The Ohme component takes the planned Intelligent slots, the ready time and the charge limit from + the charger, and claims the args when it does. automatic_config() re-runs whenever the tariff or + the live device set moves, so without honouring the claim it silently takes them back part way + through a run and the wiring flip-flops between the two components. The dispatch sensor is not + part of the claim: it stays wired as the record of the completed dispatches (#5413). Tests: - - Test 1: A claim by another component leaves the slot args untouched + - Test 1: A claim by another component leaves the ready time and charge limit untouched, and still wires the dispatch sensor - Test 2: The rest of automatic_config still runs while claimed - Test 3: No claim, or Octopus's own claim, wires the slots as normal """ @@ -2145,15 +2146,21 @@ def test_octopus_automatic_config_respects_slot_claim(my_predbat): original_args = dict(my_predbat.args) original_owner = getattr(my_predbat, "car_slot_owner", None) - print("\n*** Test 1: A claim by another component leaves the slot args untouched ***") + print("\n*** Test 1: A claim by another component leaves the ready time and charge limit untouched ***") my_predbat.args["octopus_intelligent_slot"] = "binary_sensor.predbat_ohme_slot_active" + my_predbat.args["octopus_ready_time"] = "select.predbat_ohme_target_time" + my_predbat.args["octopus_charge_limit"] = "number.predbat_ohme_target_percent" my_predbat.car_slot_owner = "ohme" api = OctopusAPI(my_predbat, key="test-api-key", account_id="test-account", automatic=False) api.intelligent_devices = {"device-aaa1": {"suspended": False}} api.automatic_config(["import"]) - if my_predbat.args.get("octopus_intelligent_slot") != "binary_sensor.predbat_ohme_slot_active": - print(f"ERROR: Expected the Ohme wiring to survive, got {my_predbat.args.get('octopus_intelligent_slot')}") + if my_predbat.args.get("octopus_ready_time") != "select.predbat_ohme_target_time" or my_predbat.args.get("octopus_charge_limit") != "number.predbat_ohme_target_percent": + print(f"ERROR: Expected the Ohme ready time and charge limit to survive, got {my_predbat.args.get('octopus_ready_time')} {my_predbat.args.get('octopus_charge_limit')}") + failed = True + own_slot = api.get_entity_name("binary_sensor", "intelligent_dispatch", index=api.device_id_to_index_suffix("device-aaa1")) + if my_predbat.args.get("octopus_intelligent_slot") != [own_slot]: + print(f"ERROR: Expected the dispatch sensor wired to {own_slot} while claimed, got {my_predbat.args.get('octopus_intelligent_slot')}") failed = True print("\n*** Test 2: The rest of automatic_config still runs while claimed ***") diff --git a/apps/predbat/tests/test_ohme.py b/apps/predbat/tests/test_ohme.py index 6e974a48f..3565ae4b1 100644 --- a/apps/predbat/tests/test_ohme.py +++ b/apps/predbat/tests/test_ohme.py @@ -283,6 +283,10 @@ def test_ohme(my_predbat=None): ("iog_autodetect", _test_ohme_iog_autodetected_from_octopus, "Intelligent auto-detected from Octopus"), ("iog_autodetect_gated", _test_ohme_iog_autodetect_needs_ohme_automatic, "auto-detect needs ohme_automatic"), ("iog_claims_slots", _test_ohme_iog_claims_car_slots, "Intelligent wiring claims the car slots"), + ("iog_dispatch_sensor", _test_ohme_octopus_dispatch_sensor_rules, "which octopus_intelligent_slot is Octopus's record of the dispatches"), + ("iog_planned_slots", _test_ohme_iog_planned_slots_beside_octopus, "Intelligent wiring takes only the planned slots beside an Octopus sensor"), + ("run_iog_dispatch_sensor", _test_ohme_run_iog_dispatch_sensor_followed, "OhmeAPI run follows the Octopus dispatch sensor coming and going"), + ("run_iog_leaves_planned", _test_ohme_run_leaving_intelligent_clears_planned, "OhmeAPI run clears the planned slot wiring on leaving Intelligent"), ("iog_device_decides", _test_ohme_iog_device_decides, "the Intelligent device decides whose slots are used"), ("run_iog_device_car", _test_ohme_run_iog_device_is_car, "OhmeAPI run leaves the slots to Octopus when the car is the device"), ("run_iog_device_changes", _test_ohme_run_iog_device_changes, "OhmeAPI run follows a change of Intelligent device"), @@ -1640,6 +1644,8 @@ def __init__(self): self.octopus_other_device = False self.slot_mode = None self.slot_mode_applied = None + self.slot_source = None + self.slot_source_applied = None self.charger_slots = False self.charger_slots_blocked = None self.control_windows = [] @@ -2290,16 +2296,22 @@ def _test_ohme_run_iog_device_changes(my_predbat=None): assert api.args["octopus_intelligent_slot"] == [OCTOPUS_DISPATCH_ENTITY], f"Expected Octopus's wiring untouched, got {api.args}" octopus.tariffs = saved_tariffs - # The customer links the Ohme to Octopus instead of the car: Ohme takes the slots back + # The customer links the Ohme to Octopus instead of the car: Ohme supplies the planned slots, and + # Octopus's sensor stays as the record of the completed dispatches (#5413) octopus.intelligent_devices = {"dev2": IOG_DEVICE_OHME} assert _ohme_run_poll(api, seconds=360) == [False], "Expected plain dispatches from the Ohme" assert api.slot_mode == "octopus_intelligent" and api.base.car_slot_owner == "ohme", f"Expected Ohme to claim the slots, got {api.slot_mode} owner {api.base.car_slot_owner}" - assert api.args["octopus_intelligent_slot"] == "binary_sensor.predbat_ohme_slot_active", f"Expected the Ohme wiring, got {api.args}" + assert api.args["octopus_intelligent_slot"] == [OCTOPUS_DISPATCH_ENTITY], f"Expected Octopus's sensor kept for the completed dispatches, got {api.args}" + assert api.args["octopus_intelligent_planned_slot"] == ["binary_sensor.predbat_ohme_slot_active"], f"Expected the planned slots from Ohme, got {api.args}" + assert api.args["octopus_ready_time"] == "select.predbat_ohme_target_time", f"Expected the Ohme ready time, got {api.args}" - # Smart charging suspended on it: no dispatches from anyone, so the Ohme schedule is the plan at the tariff rate + # Smart charging suspended on it: no dispatches from anyone, so the Ohme schedule is the plan at the + # tariff rate - taking the place of the Octopus sensor, which has nothing more to give octopus.intelligent_devices = {"dev2": dict(IOG_DEVICE_OHME, suspended=True)} assert _ohme_run_poll(api, seconds=480) == [True], "Expected the charger schedule while smart charging is suspended" assert api.slot_mode == "charger_schedule" and api.base.car_slot_owner is None, f"Expected charger schedule mode unclaimed, got {api.slot_mode} owner {api.base.car_slot_owner}" + assert api.args["octopus_intelligent_slot"] == "binary_sensor.predbat_ohme_slot_active", f"Expected the Ohme schedule wired as the plan, got {api.args}" + assert api.args["octopus_intelligent_planned_slot"] == [], f"Expected no separate planned sensor on the charger schedule, got {api.args}" print("PASS: a change of Intelligent device was followed") return 0 @@ -2395,6 +2407,163 @@ def _test_ohme_iog_claims_car_slots(my_predbat=None): return 0 +def _test_ohme_octopus_dispatch_sensor_rules(my_predbat=None): + """Test which octopus_intelligent_slot settings count as Octopus's own record of the dispatches (#5413)""" + print("**** Running test_ohme_octopus_dispatch_sensor_rules ****") + + bcd_entity = "binary_sensor.octopus_energy_a_12345678_intelligent_dispatching" + cases = [ + (None, None, "nothing set"), + ([], None, "an empty list"), + ("re:(binary_sensor.octopus_energy([0-9a-z_]+|)_intelligent_dispatching)", None, "an unmatched regex"), + ("binary_sensor.predbat_ohme_slot_active", None, "our own slot sensor"), + (["binary_sensor.predbat_ohme_slot_active"], None, "our own slot sensor in a list"), + ("binary_sensor.ohme_slot_active", None, "the Ohme integration's slot sensor"), + (bcd_entity, bcd_entity, "the Octopus Energy integration's sensor"), + ([OCTOPUS_DISPATCH_ENTITY], OCTOPUS_DISPATCH_ENTITY, "the Octopus component's sensor in a list"), + ([OCTOPUS_DISPATCH_ENTITY, "binary_sensor.second_car"], OCTOPUS_DISPATCH_ENTITY, "the first car of several"), + ] + for value, expected, name in cases: + api = MockOhmeAPI() + api.args["octopus_intelligent_slot"] = value + result = api.octopus_dispatch_sensor() + assert result == expected, f"With {name} expected {expected}, got {result}" + + print("PASS: only a sensor of Octopus's counted as the record of the dispatches") + return 0 + + +def _test_ohme_iog_planned_slots_beside_octopus(my_predbat=None): + """Test the Intelligent wiring leaves Octopus's dispatch sensor in place and takes only the planned slots (#5413)""" + print("**** Running test_ohme_iog_planned_slots_beside_octopus ****") + + api = MockOhmeAPI() + api.args["octopus_intelligent_slot"] = [OCTOPUS_DISPATCH_ENTITY] + api.args["octopus_ready_time"] = ["select.predbat_octopus_test_intelligent_target_time"] + api.args["octopus_charge_limit"] = ["number.predbat_octopus_test_intelligent_target_soc"] + api.slot_source = api.octopus_dispatch_sensor() + + run_async(api.automatic_config_octopus_intelligent()) + + assert api.base.car_slot_owner == "ohme", f"Expected ohme to claim the ready time and charge limit, got {api.base.car_slot_owner}" + assert api.args["octopus_intelligent_slot"] == [OCTOPUS_DISPATCH_ENTITY], f"Expected Octopus's sensor left as the completed record, got {api.args['octopus_intelligent_slot']}" + assert api.args["octopus_intelligent_planned_slot"] == ["binary_sensor.predbat_ohme_slot_active"], f"Expected the planned slots from Ohme, got {api.args.get('octopus_intelligent_planned_slot')}" + assert api.args["octopus_ready_time"] == "select.predbat_ohme_target_time", f"Expected the Ohme ready time, got {api.args['octopus_ready_time']}" + assert api.args["octopus_charge_limit"] == "number.predbat_ohme_target_percent", f"Expected the Ohme charge limit, got {api.args['octopus_charge_limit']}" + + # With no Octopus sensor to read beside, Ohme supplies everything and no planned sensor is left set + api.slot_source = None + run_async(api.automatic_config_octopus_intelligent()) + assert api.args["octopus_intelligent_slot"] == "binary_sensor.predbat_ohme_slot_active", f"Expected the Ohme slot sensor, got {api.args['octopus_intelligent_slot']}" + assert api.args["octopus_intelligent_planned_slot"] == [], f"Expected no separate planned sensor, got {api.args['octopus_intelligent_planned_slot']}" + + print("PASS: the planned slots were taken beside Octopus's dispatch sensor") + return 0 + + +def _test_ohme_run_iog_dispatch_sensor_followed(my_predbat=None): + """Test the wiring follows the Octopus component wiring and clearing its dispatch sensor under a running Predbat (#5413)""" + print("**** Running test_ohme_run_iog_dispatch_sensor_followed ****") + + ohme_entity = "binary_sensor.predbat_ohme_slot_active" + + # Octopus has already wired its sensor for the Ohme by the time the Ohme component first runs + api = _ohme_api_with_octopus(IOG_TARIFF, {"dev1": IOG_DEVICE_OHME}) + api.ohme_automatic = True + api.args["octopus_intelligent_slot"] = [OCTOPUS_DISPATCH_ENTITY] + assert _ohme_run_poll(api, first=True) == [False], "Expected plain dispatches on Intelligent" + assert api.slot_mode == "octopus_intelligent" and api.slot_source == OCTOPUS_DISPATCH_ENTITY, f"Expected the Octopus sensor found, got {api.slot_mode} {api.slot_source}" + assert api.args["octopus_intelligent_slot"] == [OCTOPUS_DISPATCH_ENTITY], f"Expected Octopus's sensor kept, got {api.args}" + assert api.args["octopus_intelligent_planned_slot"] == [ohme_entity], f"Expected the planned slots from Ohme, got {api.args}" + assert any("completed dispatches from {}".format(OCTOPUS_DISPATCH_ENTITY) in msg for msg in api.log_messages), f"Expected the split to be logged, got {api.log_messages}" + + # Nothing moving is not re-wired or logged again + api.args["octopus_ready_time"] = "select.changed_by_hand" + _ohme_run_poll(api, seconds=120) + assert api.args["octopus_ready_time"] == "select.changed_by_hand", f"Expected no re-wire with nothing changed, got {api.args}" + assert not any("completed dispatches from" in msg for msg in api.log_messages), f"Expected nothing logged with nothing changed, got {api.log_messages}" + + # Octopus clears its sensor: Ohme's slots are all there is, so they supply the completed ones too + api.args["octopus_intelligent_slot"] = [] + _ohme_run_poll(api, seconds=240) + assert api.slot_mode == "octopus_intelligent" and api.slot_source is None, f"Expected Intelligent mode with no Octopus sensor, got {api.slot_mode} {api.slot_source}" + assert api.args["octopus_intelligent_slot"] == ohme_entity, f"Expected the Ohme slot sensor, got {api.args}" + assert api.args["octopus_intelligent_planned_slot"] == [], f"Expected no separate planned sensor, got {api.args}" + assert any("only kept until the car is unplugged" in msg for msg in api.log_messages), f"Expected the fallback to be logged, got {api.log_messages}" + + # And wires it again: back to Octopus for the completed dispatches + api.args["octopus_intelligent_slot"] = [OCTOPUS_DISPATCH_ENTITY] + _ohme_run_poll(api, seconds=360) + assert api.slot_source == OCTOPUS_DISPATCH_ENTITY, f"Expected the Octopus sensor found again, got {api.slot_source}" + assert api.args["octopus_intelligent_slot"] == [OCTOPUS_DISPATCH_ENTITY], f"Expected Octopus's sensor kept, got {api.args}" + assert api.args["octopus_intelligent_planned_slot"] == [ohme_entity], f"Expected the planned slots from Ohme, got {api.args}" + + # ohme_control stays ignored throughout: Octopus schedules the charge whichever sensor is read + api = _ohme_api_with_octopus(IOG_TARIFF, {"dev1": IOG_DEVICE_OHME}) + api.ohme_automatic = True + api.ohme_control = True + api.args["octopus_intelligent_slot"] = [OCTOPUS_DISPATCH_ENTITY] + _ohme_run_poll(api, first=True) + assert api.control_active is False, "Expected Predbat-led control ignored on Intelligent with the completed dispatches from Octopus" + + print("PASS: the Octopus dispatch sensor coming and going was followed") + return 0 + + +def _test_ohme_run_leaving_intelligent_clears_planned(my_predbat=None): + """Test leaving Intelligent takes the planned slot wiring back off Ohme, and the spent Octopus sensor with it (#5413)""" + print("**** Running test_ohme_run_leaving_intelligent_clears_planned ****") + + ohme_entity = "binary_sensor.predbat_ohme_slot_active" + + def start(control=False): + """An Ohme on Intelligent, reading its completed dispatches from the Octopus sensor""" + api = _ohme_api_with_octopus(IOG_TARIFF, {"dev1": IOG_DEVICE_OHME}) + api.ohme_automatic = True + api.ohme_control = control + api.args["octopus_intelligent_slot"] = [OCTOPUS_DISPATCH_ENTITY] + _ohme_run_poll(api, first=True) + assert api.args["octopus_intelligent_planned_slot"] == [ohme_entity], f"Expected the planned slots from Ohme, got {api.args}" + return api + + # Off the Intelligent tariff: the Octopus sensor has no more dispatches, so Ohme's schedule is the plan + api = start() + _ohme_set_tariff(api, "E-1R-COSY-22-12-08-A") + assert _ohme_run_poll(api, seconds=120) == [True], "Expected the charger schedule once off Intelligent" + assert api.slot_mode == "charger_schedule" and api.base.car_slot_owner is None, f"Expected charger schedule mode unclaimed, got {api.slot_mode} owner {api.base.car_slot_owner}" + assert api.args["octopus_intelligent_slot"] == ohme_entity, f"Expected the Ohme schedule wired as the plan, got {api.args}" + assert api.args["octopus_intelligent_planned_slot"] == [], f"Expected the planned sensor cleared, got {api.args}" + + # With Predbat-led control taking over, nothing of Ohme's or Octopus's is left as the car plan + api = start(control=True) + _ohme_set_tariff(api, "E-1R-COSY-22-12-08-A") + _ohme_run_poll(api, seconds=120) + assert api.control_active is True and api.slot_mode is None, f"Expected Predbat-led control with no slot wiring, got {api.control_active} {api.slot_mode}" + for arg in ("octopus_intelligent_slot", "octopus_intelligent_planned_slot", "octopus_ready_time", "octopus_charge_limit"): + assert api.args[arg] == [], f"Expected {arg} cleared for Predbat-led control, got {api.args[arg]}" + + # The device turns out to be the car: Octopus's sensor is about to carry the car's dispatches and is left + api = start() + api.base.components.components["octopus"].intelligent_devices = {"dev2": IOG_DEVICE_CAR} + _ohme_run_poll(api, seconds=120) + assert api.slot_mode is None and api.octopus_other_device is True, f"Expected the slots left to Octopus, got {api.slot_mode} {api.octopus_other_device}" + assert api.args["octopus_intelligent_slot"] == [OCTOPUS_DISPATCH_ENTITY], f"Expected Octopus's sensor left, got {api.args}" + assert api.args["octopus_intelligent_planned_slot"] == [] and api.args["octopus_ready_time"] == [], f"Expected the Ohme planned wiring cleared, got {api.args}" + + # A ready time the user has since pointed elsewhere is not ours to clear + api = start() + api.args["octopus_ready_time"] = "select.set_by_hand" + _ohme_set_tariff(api, "E-1R-COSY-22-12-08-A") + api.ohme_automatic_octopus_intelligent = False + api.args["octopus_intelligent_slot"] = ["binary_sensor.another_charger"] + _ohme_run_poll(api, seconds=120) + assert api.args["octopus_ready_time"] == "select.set_by_hand", f"Expected the hand-set ready time left, got {api.args}" + assert api.args["octopus_intelligent_slot"] == ["binary_sensor.another_charger"], f"Expected another sensor's wiring left, got {api.args}" + + print("PASS: leaving Intelligent cleared the planned slot wiring") + return 0 + + def _test_ohme_charger_slots_wanted_rules(my_predbat=None): """Test when Ohme's own schedule is used as Predbat's car charging plan (#5399)""" print("**** Running test_ohme_charger_slots_wanted_rules ****") diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index c87acc0cf..a7fb041da 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -2098,6 +2098,8 @@ 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_planned_slot** - Optional second sensor to take the planned charging slots from, leaving **octopus_intelligent_slot** to supply the completed ones +(see [car charging documentation](car-charging.md#taking-planned-slots-from-another-sensor)) - **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..acf507444 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -141,6 +141,8 @@ 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_planned_slot** - Optional, see [Taking planned slots from another sensor](#taking-planned-slots-from-another-sensor). + - **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. @@ -153,6 +155,28 @@ If you are using Octopus-led charging with the [Octopus direct connection](energ - 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`. +#### Taking planned slots from another sensor + +**octopus_intelligent_planned_slot** is optional. It names a second sensor, per car, that Predbat takes the planned charging slots from, while **octopus_intelligent_slot** +goes on supplying the completed ones. + +It is for a charger that knows the charge still to come better than Octopus reports it, but keeps no lasting record of the slots already used. +The [Ohme direct integration](#ohme-car-charger-direct-integration) sets it for you; you would only set it by hand for another charger whose sensor has a `planned_dispatches` attribute: + +```yaml + octopus_intelligent_slot: 're:(binary_sensor.octopus_energy([0-9a-z_]+|)_intelligent_dispatching)' + octopus_intelligent_planned_slot: 'binary_sensor.my_charger_slots' +``` + +With it set: + +- Completed slots, which decide the off-peak rate shown for charging already done, come from **octopus_intelligent_slot**. +- Planned slots come from **octopus_intelligent_planned_slot**. Any planned slots on **octopus_intelligent_slot** are ignored for that car. +- Slots the planned sensor reports as already finished are also used, until **octopus_intelligent_slot** lists them as completed. + +It is only read for a car that also has **octopus_intelligent_slot** set, and not when `octopus_intelligent_slot_action_config` is in use. +For multiple cars, set it to a list with one sensor per car. + If you are using Predbat-led charging: The following entries are pre-configured in the `apps.yaml` template: @@ -363,14 +387,15 @@ plugged in and still wants charge), `car_charging_now` (from `sensor.predbat_ohm If you have already set `car_charging_now` in `apps.yaml` - to your car's own charging sensor, say - Predbat keeps yours. The car's battery size and target charge level are left to your existing `car_charging_battery_size` and `car_charging_limit` settings, as Ohme cannot report them. -**ohme_automatic_octopus_intelligent** takes the Octopus Intelligent car charging slots from Ohme rather than from Octopus Intelligent directly, by pointing `octopus_intelligent_slot`, -`octopus_ready_time` and `octopus_charge_limit` at the Ohme entities. Left unset it is auto-detected: if `ohme_automatic` is on, the Octopus component reports an Intelligent tariff and +**ohme_automatic_octopus_intelligent** takes the planned Octopus Intelligent car charging slots, the ready time and the charge limit from Ohme rather than from Octopus Intelligent +directly, by pointing `octopus_intelligent_planned_slot`, `octopus_ready_time` and `octopus_charge_limit` at the Ohme entities. Left unset it is auto-detected: if `ohme_automatic` is on, the Octopus component reports an Intelligent tariff and the device Octopus Intelligent controls is your Ohme charger, Predbat uses the Ohme slots. Set it explicitly to override that either way - `true` forces it on (needed if you have no Octopus component for Predbat to detect from), `false` forces it off so the slots come from Octopus directly. Which device Octopus Intelligent controls matters, because that is the device Octopus schedules the charge through: -- **Your Ohme charger** - Ohme's slots are the Octopus dispatches, so Predbat takes them from Ohme. +- **Your Ohme charger** - Ohme's slots are the Octopus dispatches, so Predbat takes the planned slots from Ohme. The slots that have already finished are taken from Octopus, + see [Completed slots on Octopus Intelligent](#completed-slots-on-octopus-intelligent) below. - **Your car** (a BMW, Mini or Volkswagen linked to Octopus directly, say) or another make of charger - Octopus schedules the charge through that device and the Ohme is just the socket. Predbat leaves the car slots, ready time and charge limit with the Octopus component, and does not use Ohme's schedule at all. The Ohme is still registered as the charger, so its power and energy readings are used. `ohme_control` is ignored here too, as Octopus is already scheduling the charge. @@ -387,8 +412,26 @@ Predbat checks this every two minutes, so linking a different device to Octopus ohme_automatic_octopus_intelligent: true ``` -If you run the Octopus component as well, only one of them can own the car slot wiring. Whichever source is in use, Predbat records the owner so the other component stops re-wiring -those settings - previously both could write them and the wiring would alternate as Octopus re-detected your tariff or devices. +If you run the Octopus component as well, only one of them can own the ready time and charge limit wiring. Whichever source is in use, Predbat records the owner so the other +component stops re-wiring those settings - previously both could write them and the wiring would alternate as Octopus re-detected your tariff or devices. + +### Completed slots on Octopus Intelligent + +Ohme only reports the slots of the charging session in progress. Once the car is unplugged that session is over, and Ohme no longer says which slots the car charged in. +If Predbat relied on Ohme alone, the off-peak rate for the slots already used would disappear from the plan history and from today's cost as soon as you unplugged. + +So where Octopus has its own record of the dispatches, Predbat keeps `octopus_intelligent_slot` on that sensor for the completed slots and takes only the planned ones from Ohme: + +| `octopus_intelligent_slot` is | Completed slots come from | Planned slots come from | +| ----------------------------- | ------------------------- | ----------------------- | +| Wired by the Octopus component to its dispatch sensor, or set in `apps.yaml` to the Octopus Energy integration's 'intelligent dispatching' sensor | That Octopus sensor | Ohme | +| Not set, a pattern that matches no sensor, or an Ohme sensor | Ohme, until the car is unplugged | Ohme | + +In the first case a slot that has just finished is still taken from Ohme until Octopus lists it as completed, so there is no gap in between. +In the second case there is no Octopus record to use, so the slots still lose their off-peak rate in the history once the car is unplugged. +To avoid that, run the Octopus component, or leave `octopus_intelligent_slot` on the Octopus Energy integration's sensor rather than replacing it with an Ohme one. + +Predbat re-checks this every two minutes and logs which source it is using. Setting only **ohme_automatic_octopus_intelligent** (with no `ohme_automatic`) still behaves as it did before: the Intelligent slots are wired, and nothing else is. @@ -398,7 +441,7 @@ With `ohme_automatic` on, the car charging plan in Predbat comes from whoever is | Setup | Who schedules the car | The car plan in Predbat | | ----- | --------------------- | ----------------------- | -| Octopus Intelligent tariff with the Ohme as the Intelligent device, unless you have set `ohme_automatic_octopus_intelligent: false` - or any setup with it set to `true` | Octopus, through Ohme | Ohme's slots, priced at the Intelligent off-peak rate | +| Octopus Intelligent tariff with the Ohme as the Intelligent device, unless you have set `ohme_automatic_octopus_intelligent: false` - or any setup with it set to `true` | Octopus, through Ohme | Ohme's planned slots and Octopus's completed ones, priced at the Intelligent off-peak rate | | Octopus Intelligent tariff with your car (or another charger) as the Intelligent device | Octopus, through that device | Octopus's own dispatches, from the Octopus component | | `ohme_control: true` | Predbat | Predbat's own plan, which it carries out on the charger | | Neither of the above | Ohme | Ohme's own schedule, priced at your normal tariff rates |