diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index f2e9ac50b..27ddb74d7 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -633,6 +633,11 @@ def initialize(self, key, account_id, automatic): # run() compares this against the live set so a device appearing, disappearing or being # suspended re-wires the slots without waiting for a restart (issue #4648). self.intelligent_config_devices = None + # Who held the car slots when automatic_config() last ran, so run() re-wires them when that changes + self.intelligent_config_owner = None + # The slot entities automatic_config() last wired the car slots to, so it only ever clears wiring + # that is still its own - None until it has wired any + self.intelligent_config_slots = None self.tariff_fetched_at = None self.device_fetched_at = None self.sensor_updated_at = None @@ -766,6 +771,19 @@ async def run(self, seconds, first): self.log("OctopusAPI: Live intelligent devices changed from {} to {}, reconfiguring car slots".format(self.intelligent_config_devices, active_devices)) self.automatic_config(self.tariffs) self.refresh_discovery() + elif sensor_due and getattr(self.base, "car_slot_owner", None) != self.intelligent_config_owner: + slot_owner = getattr(self.base, "car_slot_owner", None) + if self.car_slots_released_to_us(slot_owner, active_devices): + # Another component has given the car slots up - the Ohme component does once it + # finds Octopus Intelligent is driving the car and not the charger (#5402). The + # device set has not moved, so nothing else would wire them + self.log("OctopusAPI: Car slots released by the {} component, reconfiguring car slots".format(self.intelligent_config_owner)) + self.automatic_config(self.tariffs) + self.refresh_discovery() + else: + # Taken by another component, or given up with nothing here to wire them to: note + # the owner and leave the wiring to whoever has it + self.intelligent_config_owner = slot_owner # Unconditional and outside the "if self.automatic:" block above (unlike the two calls # inside it, which exist only to refresh the report in the SAME cycle a wiring change @@ -1156,6 +1174,22 @@ async def async_join_saving_session_events(self, account_id, event_code): # Re-fetch the saving sessions if we have joined any self.saving_sessions = await self.async_get_saving_sessions(account_id) + def car_slots_released_to_us(self, slot_owner, active_devices): + """ + Has another component given up the car slots for this one to wire to its own dispatches. + + Only when there is something to wire them to: a live, non-suspended Intelligent device on a + tariff that is still Intelligent. A component also lets go when there are no dispatches to + be had from anyone - every device suspended, or the tariff no longer Intelligent, where + self.intelligent_devices is not refreshed and still holds the old device - and has then + wired the slots to something of its own, which must not be overwritten or cleared (#5405 review). + """ + if slot_owner and slot_owner != "octopus": + return False + if not active_devices: + return False + return self.is_intelligent_go_tariff(self.tariffs.get("import", {}).get("tariffCode")) + def get_active_intelligent_device_ids(self): """ Return the sorted list of live intelligent device IDs that are not suspended. @@ -1446,20 +1480,31 @@ def automatic_config(self, tariffs): slot_list.append(self.get_entity_name("binary_sensor", "intelligent_dispatch", 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)) - self.set_arg("octopus_intelligent_slot", slot_list) - self.set_arg("octopus_ready_time", ready_list) - self.set_arg("octopus_charge_limit", limit_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) - if num_cars < len(active_devices): - self.set_arg("num_cars", len(active_devices)) - if active_devices: - self.log("OctopusAPI: Car slots wired to intelligent devices {}".format(sorted(active_devices))) + # With no active device left the wiring is cleared - but only wiring that is still what + # was put there from here. The slots are not claimed while nothing owns them, so another + # component may have wired them to something of its own since: the Ohme component does, + # to the charger's own schedule, when the device it was following is suspended + current_slots = self.get_arg("octopus_intelligent_slot", default=None, indirect=False) + if not active_devices and (not self.intelligent_config_slots or current_slots != self.intelligent_config_slots): + self.log("OctopusAPI: No active intelligent devices, and the car slot wiring is not from here - leaving it alone") else: - self.log("OctopusAPI: No active intelligent devices remain, cleared the car slot wiring") + self.set_arg("octopus_intelligent_slot", slot_list) + 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) + if num_cars < len(active_devices): + self.set_arg("num_cars", len(active_devices)) + if active_devices: + self.log("OctopusAPI: Car slots wired to intelligent devices {}".format(sorted(active_devices))) + else: + self.log("OctopusAPI: No active intelligent devices remain, cleared the car slot wiring") # Record the device set this wiring was built for so run() can spot it changing later self.intelligent_config_devices = self.get_active_intelligent_device_ids() + # And who held the car slots, so run() can wire them once another component lets go of them + self.intelligent_config_owner = slot_owner def _current_standing_charge_p(self, direction): """ diff --git a/apps/predbat/ohme.py b/apps/predbat/ohme.py index 5164d6a05..782688d36 100644 --- a/apps/predbat/ohme.py +++ b/apps/predbat/ohme.py @@ -239,6 +239,9 @@ def initialize(self, email, password, ohme_automatic=False, ohme_automatic_octop self.control_active = False # Whether the car is on Octopus Intelligent as last decided, None until the first decision self.octopus_intelligent = None + # Octopus Intelligent is driving a device that is not this charger - the car itself, say - so + # Octopus schedules the charge and its own dispatches are the plan, see octopus_intelligent_wanted() + self.octopus_other_device = False # How the car slot args are wired to the session slots, None when they are not - see update_slot_mode() self.slot_mode = None # The mode the args were last wired for. Kept apart from slot_mode so that a wiring change @@ -338,7 +341,7 @@ def enable_control(self, octopus_intelligent): self.log("Warn: Ohme API: ohme_control needs ohme_automatic set to register the car, charge control is disabled") return if octopus_intelligent: - self.log("Warn: Ohme API: ohme_control is ignored while the Intelligent slots come from Ohme - Octopus already schedules the charge") + self.log("Warn: Ohme API: ohme_control is ignored on Octopus Intelligent - Octopus already schedules the charge") return self.control_active = True self.log("Info: Ohme API: Predbat-led charge control enabled") @@ -497,10 +500,19 @@ def octopus_intelligent_wanted(self): control. Left unset it is auto-detected, but only when ohme_automatic is on - otherwise enabling this would start rewiring the config of every existing Ohme user who has asked Predbat for nothing. + + Detection needs more than an Intelligent tariff: it is the device Octopus Intelligent drives + that says whose slots are the dispatches (#5402). Where that is the car itself - a BMW or a + Volkswagen linked to Octopus directly - Octopus schedules the charge through the car and the + Ohme is only the socket, so Ohme's session has nothing to do with what Octopus bills as + off-peak. The slots are then left to the Octopus component, and octopus_other_device is set + so nothing of Ohme's stands in for them - see charger_slots_wanted() and update_slot_mode(). """ if self.ohme_automatic_octopus_intelligent is not None: + self.octopus_other_device = False return bool(self.ohme_automatic_octopus_intelligent) if not self.ohme_automatic: + self.octopus_other_device = False return False # OctopusAPI has already detected this by the time we first run - it sits earlier in @@ -512,7 +524,38 @@ def octopus_intelligent_wanted(self): # fetches. This is asked again on every poll, so an answer already given is kept rather # than dropping an Intelligent car's cheap rate over a gap in the account data return bool(self.octopus_intelligent) - return bool(octopus.is_intelligent_go_tariff(tariff_code)) + if not octopus.is_intelligent_go_tariff(tariff_code): + self.octopus_other_device = False + return False + + # The devices Octopus Intelligent is live on. OctopusAPI keeps the last known set through a + # failed poll, and holds only LIVE devices, so this does not flap with the API. A suspended + # device is one the customer has turned smart charging off for: Octopus is not scheduling it, + # which is also why OctopusAPI.automatic_config() leaves it out of its own car slot wiring + devices = list((octopus.get_intelligent_devices() or {}).values()) + active = [device for device in devices if not device.get("suspended")] + self.octopus_other_device = False + if any(self.is_ohme_charger(device) for device in active): + return True + if active: + # Octopus is driving something else, and wires the car slots to its dispatches itself + self.octopus_other_device = True + return False + if devices: + # Every device is suspended, so Octopus is scheduling nothing and there are no dispatches + # to take from anyone. Whatever charging happens is on the charger's own schedule + return False + # An Intelligent tariff with no Intelligent device at all. Octopus has nothing to wire the car + # slots to, so Ohme's slots are the only record of the charge there is: keep taking them, as + # was done before the device was looked at + return True + + @staticmethod + def is_ohme_charger(device): + """ + Is this Octopus Intelligent device an Ohme charger, going by what Octopus says it is. + """ + return bool(device.get("is_charger")) and str(device.get("provider") or "").strip().lower() == "ohme" async def update_slot_mode(self): """ @@ -525,15 +568,19 @@ async def update_slot_mode(self): Only decides: run() wires the args to match with apply_slot_mode(), once the slots have been published the new way. """ + was_other_device = self.octopus_other_device octopus_intelligent = self.octopus_intelligent_wanted() - if octopus_intelligent != self.octopus_intelligent: + if octopus_intelligent != self.octopus_intelligent or self.octopus_other_device != was_other_device: if octopus_intelligent: self.log("Info: Ohme API: Octopus Intelligent is in use, taking the car slots from Ohme") + elif self.octopus_other_device: + self.log("Info: Ohme API: Octopus Intelligent is driving another device, not this charger - leaving the car slots to Octopus") elif self.octopus_intelligent is not None: self.log("Info: Ohme API: Octopus Intelligent is no longer in use, the Ohme slots no longer carry the Intelligent rate") self.octopus_intelligent = octopus_intelligent - # Predbat-led control is ruled out by Intelligent, so it follows the same change - self.enable_control(octopus_intelligent) + # Predbat-led control is ruled out wherever Octopus schedules the charge, whichever device + # it does that through, so it follows the same change + self.enable_control(octopus_intelligent or self.octopus_other_device) # Predbat has stood down but is still holding the charger: hand it back. Checked on every # poll rather than only on the change, as release_charger() clears control_charging last - @@ -576,9 +623,10 @@ def charger_slots_wanted(self, octopus_intelligent): used as the plan instead, as car load only - they earn no cheap rate on any other tariff. Left alone when the car slots are already wired to something else, whether in apps.yaml or - by the Octopus component for its own Intelligent devices. + by the Octopus component for its own Intelligent devices. Never used while Octopus + Intelligent drives another device: Octopus schedules that charge, not Ohme. """ - if not self.ohme_automatic or octopus_intelligent or self.control_active: + if not self.ohme_automatic or octopus_intelligent or self.octopus_other_device or self.control_active: self.charger_slots_blocked = None return False existing = self.get_arg("octopus_intelligent_slot", default=None, indirect=False) diff --git a/apps/predbat/tests/test_octopus_intelligent_devices.py b/apps/predbat/tests/test_octopus_intelligent_devices.py index d9367d3ef..885a533ca 100644 --- a/apps/predbat/tests/test_octopus_intelligent_devices.py +++ b/apps/predbat/tests/test_octopus_intelligent_devices.py @@ -27,6 +27,8 @@ def test_octopus_intelligent_devices_wrapper(my_predbat): failed += test_discovery_report_retried_via_unconditional_run_call_after_first_cycle_failure(my_predbat) failed += test_discovery_report_produced_when_automatic_is_false(my_predbat) failed += test_intelligent_dispatch_change_requests_replan(my_predbat) + failed += test_car_slots_rewired_when_owner_changes(my_predbat) + failed += test_car_slots_not_taken_without_a_device_to_wire(my_predbat) return failed @@ -1299,6 +1301,157 @@ async def _async_false(*args, **kwargs): api.save_octopus_cache = _async_none +def test_car_slots_rewired_when_owner_changes(my_predbat): + """ + Issue #5402: the Ohme component claims the car slots when it takes the Intelligent slots itself, + and gives the claim up again once it finds Octopus Intelligent is driving the car and not the + charger. The device set has not moved when that happens, so run() has to re-wire the slots on the + change of owner alone - otherwise they are left pointing at nothing until the next restart. + """ + print("\n**** Running Octopus car slot owner change test ****") + failed = 0 + original_args = dict(my_predbat.args) + original_owner = getattr(my_predbat, "car_slot_owner", None) + + api = _make_discovery_api(my_predbat, "owner-change") + device_id = "smart-flex-vehicle-7001" + api.intelligent_devices = {device_id: {"suspended": False, "is_charger": False, "provider": "BMW"}} + api.tariffs = {"import": {"tariffCode": "E-1R-INTELLI-VAR-22-10-14-A", "productCode": "INTELLI-VAR-22-10-14", "deviceID": "meter-1"}} + _publish_car_entities(my_predbat, api, device_id) + _stub_run_dependencies(api) + 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 + my_predbat.car_slot_owner = "ohme" + my_predbat.args["octopus_intelligent_slot"] = "binary_sensor.predbat_ohme_slot_active" + 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')}") + failed += 1 + + # Ohme lets go and clears its wiring. Nothing about the devices has changed... + my_predbat.car_slot_owner = None + my_predbat.args["octopus_intelligent_slot"] = [] + # ...and a cycle that does not refresh the sensors does not wire entities it may not have published + asyncio.run(api.run(seconds=60, first=False)) + if my_predbat.args.get("octopus_intelligent_slot") != []: + print(f"ERROR: expected no re-wire off a sensor refresh, got {my_predbat.args.get('octopus_intelligent_slot')}") + failed += 1 + # The next sensor refresh wires the car's own dispatch entity + api.sensor_updated_at = None + asyncio.run(api.run(seconds=120, first=False)) + if my_predbat.args.get("octopus_intelligent_slot") != [own_entity]: + print(f"ERROR: expected the car slots wired to {own_entity} once released, got {my_predbat.args.get('octopus_intelligent_slot')}") + failed += 1 + + # And with the owner settled it is not done again: the user's later change is left alone + my_predbat.args["octopus_intelligent_slot"] = ["binary_sensor.set_by_hand"] + api.sensor_updated_at = None + asyncio.run(api.run(seconds=240, first=False)) + if my_predbat.args.get("octopus_intelligent_slot") != ["binary_sensor.set_by_hand"]: + print(f"ERROR: expected no repeat re-wire with the owner unchanged, got {my_predbat.args.get('octopus_intelligent_slot')}") + failed += 1 + + my_predbat.args = original_args + my_predbat.car_slot_owner = original_owner + if failed == 0: + print("PASS: the car slots were re-wired when their owner changed") + return failed + + +def test_car_slots_not_taken_without_a_device_to_wire(my_predbat): + """ + PR #5405 review: the Ohme component also gives up its claim on the car slots when there are no + dispatches to be had from anyone - the Intelligent device suspended, or the tariff no longer + Intelligent - and then wires them to the charger's own schedule. The Octopus component must + not clear or overwrite that: it has nothing to wire them to. It still clears wiring of its own + when its last device goes (issue #4648). + """ + print("\n**** Running Octopus car slots left alone test ****") + failed = 0 + original_args = dict(my_predbat.args) + original_owner = getattr(my_predbat, "car_slot_owner", None) + ohme_entity = "binary_sensor.predbat_ohme_slot_active" + iog_tariff = {"import": {"tariffCode": "E-1R-INTELLI-VAR-22-10-14-A", "productCode": "INTELLI-VAR-22-10-14", "deviceID": "meter-1"}} + cosy_tariff = {"import": {"tariffCode": "E-1R-COSY-22-12-08-A", "productCode": "COSY-22-12-08", "deviceID": "meter-1"}} + device_id = "smart-flex-charger-8001" + + def make_api(account_id, tariffs, suspended): + """An OctopusAPI with one Ohme Intelligent device, started while the Ohme component holds the car slots""" + api = _make_discovery_api(my_predbat, account_id) + api.intelligent_devices = {device_id: {"suspended": suspended, "is_charger": True, "provider": "Ohme"}} + api.tariffs = tariffs + _publish_car_entities(my_predbat, api, device_id) + _stub_run_dependencies(api) + api.report_discovery = lambda report: None + my_predbat.car_slot_owner = "ohme" + my_predbat.args["octopus_intelligent_slot"] = ohme_entity + asyncio.run(api.run(seconds=0, first=True)) + 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""" + my_predbat.car_slot_owner = None + for cycle in range(cycles): + api.sensor_updated_at = None + asyncio.run(api.run(seconds=120 * (cycle + 1), first=False)) + + # The Ohme is suspended in the Octopus app: Ohme releases the slots and uses its own schedule. + # Two refreshes, as the owner and the device set are noticed one at a time + api = make_api("left-alone-suspended", iog_tariff, suspended=False) + api.intelligent_devices[device_id]["suspended"] = True + release_and_run(api) + if my_predbat.args.get("octopus_intelligent_slot") != ohme_entity: + print(f"ERROR: expected the Ohme schedule wiring kept with every device suspended, got {my_predbat.args.get('octopus_intelligent_slot')}") + failed += 1 + + # Off the Intelligent tariff: the device is still cached as live, as it is not refreshed off + # Intelligent, but its dispatch entities are stale and must not replace the Ohme schedule + api = make_api("left-alone-tariff", iog_tariff, suspended=False) + api.tariffs = cosy_tariff + release_and_run(api) + if my_predbat.args.get("octopus_intelligent_slot") != ohme_entity: + print(f"ERROR: expected the Ohme schedule wiring kept off an Intelligent tariff, got {my_predbat.args.get('octopus_intelligent_slot')}") + failed += 1 + + # Started with only a suspended device and someone else's wiring in place: not cleared either + api = make_api("left-alone-start", iog_tariff, suspended=True) + release_and_run(api, cycles=1) + my_predbat.args["octopus_intelligent_slot"] = ohme_entity + api.automatic_config(api.tariffs) + if my_predbat.args.get("octopus_intelligent_slot") != ohme_entity: + print(f"ERROR: expected wiring that is not Octopus's own left alone, got {my_predbat.args.get('octopus_intelligent_slot')}") + failed += 1 + + # Wiring Octopus made itself is still cleared when its last device is suspended (#4648) + my_predbat.car_slot_owner = None + my_predbat.args["octopus_intelligent_slot"] = [] + api = _make_discovery_api(my_predbat, "left-alone-own") + api.intelligent_devices = {device_id: {"suspended": False, "is_charger": True, "provider": "Ohme"}} + api.tariffs = iog_tariff + _publish_car_entities(my_predbat, api, device_id) + _stub_run_dependencies(api) + api.report_discovery = lambda report: None + asyncio.run(api.run(seconds=0, first=True)) + own_entity = api.get_entity_name("binary_sensor", "intelligent_dispatch", index=api.device_id_to_index_suffix(device_id)) + if my_predbat.args.get("octopus_intelligent_slot") != [own_entity]: + print(f"ERROR: expected Octopus to wire its own device, got {my_predbat.args.get('octopus_intelligent_slot')}") + failed += 1 + api.intelligent_devices[device_id]["suspended"] = True + api.sensor_updated_at = None + asyncio.run(api.run(seconds=120, first=False)) + if my_predbat.args.get("octopus_intelligent_slot") != []: + print(f"ERROR: expected Octopus's own wiring cleared once its device is suspended, got {my_predbat.args.get('octopus_intelligent_slot')}") + failed += 1 + + my_predbat.args = original_args + my_predbat.car_slot_owner = original_owner + if failed == 0: + print("PASS: the car slots were left alone with no device to wire them to") + return failed + + def test_discovery_report_retried_via_unconditional_run_call_after_first_cycle_failure(my_predbat): """ Task 7 review, Finding 2: a build_discovery() failure on the very first run() cycle is retried diff --git a/apps/predbat/tests/test_ohme.py b/apps/predbat/tests/test_ohme.py index 6e38bb12e..6e974a48f 100644 --- a/apps/predbat/tests/test_ohme.py +++ b/apps/predbat/tests/test_ohme.py @@ -283,6 +283,9 @@ 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_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"), ("charger_slots_rules", _test_ohme_charger_slots_wanted_rules, "when Ohme's own schedule is the car plan"), ("charger_slots_wiring", _test_ohme_charger_slots_wiring, "charger schedule wiring leaves the car slots unclaimed"), ("charger_slots_published", _test_ohme_charger_slots_published_source, "charger schedule slots carry their source"), @@ -1575,9 +1578,14 @@ def _test_ohme_client_async_update_schedule_no_rule(my_predbat=None): class MockOctopusComponent: """Stand-in for the OctopusAPI component, reporting a tariff code""" - def __init__(self, tariff_code=None): - """Initialize with the import tariff code to report""" + def __init__(self, tariff_code=None, devices=None): + """Initialize with the import tariff code to report, and the Intelligent devices keyed by device id""" self.tariffs = {"import": {"tariffCode": tariff_code}} if tariff_code else {} + self.intelligent_devices = devices or {} + + def get_intelligent_devices(self): + """Mirror of OctopusAPI.get_intelligent_devices""" + return self.intelligent_devices @staticmethod def is_intelligent_go_tariff(tariff_code): @@ -1629,6 +1637,7 @@ def __init__(self): self.ohme_control = False self.control_active = False self.octopus_intelligent = None + self.octopus_other_device = False self.slot_mode = None self.slot_mode_applied = None self.charger_slots = False @@ -2158,14 +2167,144 @@ def _test_ohme_energy_today_published(my_predbat=None): return 0 -def _ohme_api_with_octopus(tariff_code=None): - """Build a MockOhmeAPI with an Octopus component reporting the given tariff code""" +def _ohme_api_with_octopus(tariff_code=None, devices=None): + """Build a MockOhmeAPI with an Octopus component reporting the given tariff code and Intelligent devices""" api = MockOhmeAPI() if tariff_code is not None: - api.base.components.components["octopus"] = MockOctopusComponent(tariff_code) + api.base.components.components["octopus"] = MockOctopusComponent(tariff_code, devices) return api +IOG_TARIFF = "E-1R-INTELLI-VAR-22-10-14-A" +# Octopus Intelligent devices as OctopusAPI.async_get_intelligent_devices() records them +IOG_DEVICE_OHME = {"deviceType": "CHARGE_POINTS", "status": "LIVE", "provider": "Ohme", "model": "Home Pro", "is_charger": True, "suspended": False} +IOG_DEVICE_CAR = {"deviceType": "ELECTRIC_VEHICLES", "status": "LIVE", "provider": "BMW", "model": "i4", "is_charger": False, "suspended": False} +IOG_DEVICE_OTHER_CHARGER = {"deviceType": "CHARGE_POINTS", "status": "LIVE", "provider": "myenergi", "model": "Zappi", "is_charger": True, "suspended": False} +OCTOPUS_DISPATCH_ENTITY = "binary_sensor.predbat_octopus_test_intelligent_dispatch" + + +def _test_ohme_iog_device_decides(my_predbat=None): + """Test auto-detection goes by the device Octopus Intelligent drives, not the tariff alone (#5402)""" + print("**** Running test_ohme_iog_device_decides ****") + + def detect(devices, flag=None, tariff=IOG_TARIFF): + """Auto-detect against the given Octopus devices, returning (wanted, octopus_other_device)""" + api = _ohme_api_with_octopus(tariff, devices) + api.ohme_automatic = True + api.ohme_automatic_octopus_intelligent = flag + return api.octopus_intelligent_wanted(), api.octopus_other_device + + # Octopus drives the Ohme: its slots are the dispatches + assert detect({"dev1": IOG_DEVICE_OHME}) == (True, False), "Expected the Ohme slots when the Intelligent device is the Ohme" + assert detect({"dev1": dict(IOG_DEVICE_OHME, provider="OHME ")}) == (True, False), "Expected the provider match to ignore case and padding" + + # Octopus drives the car: the Ohme is only the socket, and the slots stay with Octopus + assert detect({"dev1": IOG_DEVICE_CAR}) == (False, True), "Expected the slots left to Octopus when the Intelligent device is the car" + # Likewise a charger of another make + assert detect({"dev1": IOG_DEVICE_OTHER_CHARGER}) == (False, True), "Expected the slots left to Octopus for another make of charger" + # Both registered: the Ohme is being driven, so its slots are still dispatches + assert detect({"dev1": IOG_DEVICE_CAR, "dev2": IOG_DEVICE_OHME}) == (True, False), "Expected the Ohme slots when the Ohme is among the live devices" + + # A suspended device is not being scheduled by Octopus. A suspended car beside a live Ohme changes nothing... + assert detect({"dev1": dict(IOG_DEVICE_CAR, suspended=True), "dev2": IOG_DEVICE_OHME}) == (True, False), "Expected a suspended car to be ignored" + # ...a suspended Ohme beside a live car leaves the car as the device + assert detect({"dev1": IOG_DEVICE_CAR, "dev2": dict(IOG_DEVICE_OHME, suspended=True)}) == (False, True), "Expected a suspended Ohme not to claim the slots" + # ...and with everything suspended there are no dispatches at all: not Intelligent slots, and not Octopus's to supply either + assert detect({"dev1": dict(IOG_DEVICE_OHME, suspended=True)}) == (False, False), "Expected no Intelligent slots while the Ohme is suspended" + assert detect({"dev1": dict(IOG_DEVICE_CAR, suspended=True)}) == (False, False), "Expected no Intelligent slots while the car is suspended" + + # An Intelligent tariff with no device at all: the deliberate fallback, Ohme keeps the slots as before + assert detect({}) == (True, False), "Expected the Ohme slots when Octopus has no Intelligent device to wire" + + # The explicit flag still wins either way, whatever the device + assert detect({"dev1": IOG_DEVICE_CAR}, flag=True) == (True, False), "Expected an explicit true to beat the device check" + assert detect({"dev1": IOG_DEVICE_OHME}, flag=False) == (False, False), "Expected an explicit false to beat the device check" + + # And off an Intelligent tariff the devices are not consulted + assert detect({"dev1": IOG_DEVICE_CAR}, tariff="E-1R-COSY-22-12-08-A") == (False, False), "Expected no Intelligent slots off an Intelligent tariff" + + print("PASS: the Intelligent device decided whose slots are used") + return 0 + + +def _test_ohme_run_iog_device_is_car(my_predbat=None): + """Test nothing of Ohme's is the car plan while Octopus Intelligent drives the car (#5402)""" + print("**** Running test_ohme_run_iog_device_is_car ****") + + # The usual order: the Octopus component runs first and has wired the car's own dispatch entity + api = _ohme_api_with_octopus(IOG_TARIFF, {"dev1": IOG_DEVICE_CAR}) + api.ohme_automatic = True + api.args["octopus_intelligent_slot"] = [OCTOPUS_DISPATCH_ENTITY] + assert _ohme_run_poll(api, first=True) == [False], "Expected the Ohme slots not labelled as the plan" + assert api.slot_mode is None and api.base.car_slot_owner is None, f"Expected no Ohme slot wiring and no claim, got {api.slot_mode} owner {api.base.car_slot_owner}" + assert api.args["octopus_intelligent_slot"] == [OCTOPUS_DISPATCH_ENTITY], f"Expected Octopus's wiring untouched, got {api.args}" + # The Ohme is still registered as the charger: it is where the power is measured + assert api.args.get("car_charging_planned") == ["binary_sensor.predbat_ohme_connected"], f"Expected the charger registered, got {api.args}" + assert api.args.get("car_charging_energy") == ENERGY_TODAY_ENTITY, f"Expected the charge energy wired, got {api.args}" + assert any("driving another device" in msg for msg in api.log_messages), f"Expected the reason logged, got {api.log_messages}" + assert _ohme_run_poll(api, seconds=120) == [False] and api.log_messages == [], f"Expected a quiet poll, got {api.log_messages}" + + # Even with the car slots not wired by anyone, Ohme's own schedule does not stand in: Octopus + # schedules this charge through the car, so Ohme's session says nothing about it + api = _ohme_api_with_octopus(IOG_TARIFF, {"dev1": IOG_DEVICE_CAR}) + api.ohme_automatic = True + assert _ohme_run_poll(api, first=True) == [False], "Expected the Ohme slots not labelled as the plan" + assert api.slot_mode is None and "octopus_intelligent_slot" not in api.args, f"Expected the car slots left for Octopus, got {api.slot_mode} {api.args}" + + # ohme_control stays off: Octopus is scheduling the charge, through the car + api = _ohme_api_with_octopus(IOG_TARIFF, {"dev1": IOG_DEVICE_CAR}) + api.ohme_automatic = True + api.ohme_control = True + _ohme_run_poll(api, first=True) + assert api.control_active is False, "Expected control to stand down while Octopus drives the car" + assert any("Octopus already schedules" in msg for msg in api.log_messages), f"Expected a warning, got {api.log_messages}" + + print("PASS: the car slots were left to Octopus") + return 0 + + +def _test_ohme_run_iog_device_changes(my_predbat=None): + """Test the Intelligent device being found, swapped or suspended under a running Predbat is followed""" + print("**** Running test_ohme_run_iog_device_changes ****") + + # Started before Octopus had any device: the fallback, Ohme claims the slots + api = _ohme_api_with_octopus(IOG_TARIFF, {}) + api.ohme_automatic = True + octopus = api.base.components.components["octopus"] + assert _ohme_run_poll(api, first=True) == [False], "Expected plain dispatches on Intelligent" + 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}" + + # The device turns out to be the car: the claim is given up and Ohme's wiring cleared, so the + # Octopus component can wire the car's dispatches (it re-wires when the owner changes) + octopus.intelligent_devices = {"dev1": IOG_DEVICE_CAR} + assert _ohme_run_poll(api, seconds=120) == [False], "Expected the Ohme slots not labelled as the plan" + assert api.slot_mode is None and api.base.car_slot_owner is None, f"Expected the claim released, got {api.slot_mode} owner {api.base.car_slot_owner}" + assert api.args.get("octopus_intelligent_slot") == [], f"Expected Ohme's wiring cleared, got {api.args}" + + # Octopus wires its own entity; a gap in its data then changes nothing + api.args["octopus_intelligent_slot"] = [OCTOPUS_DISPATCH_ENTITY] + saved_tariffs = octopus.tariffs + octopus.tariffs = {} + assert _ohme_run_poll(api, seconds=240) == [False], "Expected nothing to move over a gap" + assert api.slot_mode is None and api.octopus_other_device is True, f"Expected the car still known as the device, got {api.slot_mode} {api.octopus_other_device}" + 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 + 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}" + + # Smart charging suspended on it: no dispatches from anyone, so the Ohme schedule is the plan at the tariff rate + 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}" + + print("PASS: a change of Intelligent device was followed") + return 0 + + def _test_ohme_iog_flag_forces_on(my_predbat=None): """Test an explicit flag turns Intelligent on even with no Octopus component""" print("**** Running test_ohme_iog_flag_forces_on ****") diff --git a/docs/car-charging.md b/docs/car-charging.md index 31ade9513..86060f287 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -364,9 +364,21 @@ If you have already set `car_charging_now` in `apps.yaml` - to your car's own ch 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 and the Octopus component reports an Intelligent tariff, -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. +`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 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. +- **Smart charging suspended** on every device in the Octopus app - Octopus is not scheduling anything, so there are no dispatches. Predbat uses Ohme's own schedule at your normal + tariff rates, as described in [Which car charging plan Predbat shows](#which-car-charging-plan-predbat-shows) below. +- **No Intelligent device on the account at all** - Predbat takes the slots from Ohme, as Octopus has nothing of its own to offer. + +Predbat checks this every two minutes, so linking a different device to Octopus Intelligent is picked up without a restart. ```yaml ohme_login: !secret ohme_login @@ -386,7 +398,8 @@ 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, unless you have set `ohme_automatic_octopus_intelligent: false` - or any tariff 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 slots, 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 | diff --git a/docs/components.md b/docs/components.md index 329617475..56859d500 100644 --- a/docs/components.md +++ b/docs/components.md @@ -900,7 +900,7 @@ Integrates with Ohme EV chargers to monitor charging sessions and coordinate cha | `password` | String | Yes | - | `ohme_password` | Your Ohme account password | | `ohme_automatic` | Boolean | No | `False` | `ohme_automatic` | Set to `true` to register the Ohme charger with Predbat as a car. Unless `ohme_control` is on, the car charging plan is taken from Ohme's own schedule | | `ohme_control` | Boolean | No | `False` | `ohme_control` | Set to `true` to let Predbat start and stop the charger from its own plan. Requires `ohme_automatic`; released by read only mode | -| `ohme_automatic_octopus_intelligent` | Boolean | No | unset (auto-detect) | `ohme_automatic_octopus_intelligent` | Take the Intelligent car slots from Ohme. Omit the setting entirely to auto-detect it when `ohme_automatic` is on, or give it `true`/`false` to override. Do not write `auto` - any value other than `true`/`false` is read as true | +| `ohme_automatic_octopus_intelligent` | Boolean | No | unset (auto-detect) | `ohme_automatic_octopus_intelligent` | Take the Intelligent car slots from Ohme. Omit the setting entirely to auto-detect it when `ohme_automatic` is on (it is used when the Octopus Intelligent device is the Ohme charger, not when it is the car), or give it `true`/`false` to override. Do not write `auto` - any value other than `true`/`false` is read as true | ---