diff --git a/apps/predbat/car_charger_control.py b/apps/predbat/car_charger_control.py new file mode 100644 index 000000000..122e7cfef --- /dev/null +++ b/apps/predbat/car_charger_control.py @@ -0,0 +1,624 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +"""Predbat-led EV charger control, shared by the charger components. + +Each charger component used to carry its own copy of the same loop: read the car +charging plan, start the charger inside a planned window and stop it outside one, +put it back when something else changes it, and hand it back when Predbat is put in +read only mode or its control switch is turned off. CarChargerControl owns that loop +once; a component supplies only what is specific to its charger. +""" + +from datetime import datetime, timezone + +from utils import parse_car_plan_windows, in_car_plan_window + + +def parse_dispatch_time(value): + """Parse a dispatch start or end from an Octopus or Kraken dispatch sensor, or None. + + Octopus writes "2026-06-01T01:00:00+0000", Kraken ISO with a trailing Z. + """ + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + try: + parsed = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z") + except ValueError: + return None + # A time with no offset cannot be compared with the clock; both sources write UTC + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +# A guest switch left on turns itself off after this long, for chargers that cannot tell a car was unplugged +GUEST_CHARGING_MAX_HOURS = 12 + +# How long a charger is held as it is while the Octopus or Kraken component wires its Intelligent devices. +# Discovery normally finishes in the first cycle or two; past this, one that never gets there (a failed login, +# say) is treated as cannot tell - warned about once, and a charger Predbat holds is handed back +OCTOPUS_DISCOVERY_WAIT_MINUTES = 15 + +# The strings get_arg() reads as true for a boolean setting +CONTROL_TRUE_STRINGS = ("on", "true", "yes", "enabled", "enable", "connected") + + +def parse_control_setting(value): + """Normalise a charger's control setting from apps.yaml to None, True or False. + + The settings have no default, so that unset can mean "follow the automatic setup" - but + that also skips get_arg()'s boolean conversion, which only runs against a boolean + default. Without this a quoted "false", or a 0, would read as set and not False. + """ + if value is None or isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in CONTROL_TRUE_STRINGS + return bool(value) + + +class CarChargerControl: + """Mixin that drives a component's EV chargers from Predbat's per-car charging plan. + + The component calls charger_control_setup() from initialize(), decides for itself + whether control may run (setting charger_control_active), and calls + charger_control_tick() from its run loop. + + It supplies the charger-specific parts: + + - charger_control_chargers(): (key, handle) pairs in car order, so the Nth charger + follows car N. The key must be stable and hashable; the handle is passed back to + the methods below. + - charger_control_send(handle, charge, car_n): turn the charger on or off. + - charger_control_release_one(handle, charge): hand a charger Predbat last set to + charge back to its owner. + + and may override: + + - charger_control_connected(handle): False leaves the charger alone this cycle. + - charger_control_drifted(handle, charge): True when the charger is no longer in the + state Predbat last set, so it is set again. + - charger_control_car_count(): how many cars have a plan to follow. + - charger_control_car_plugged(handle): False once a car is unplugged, which ends guest + charging. Defaults to charger_control_connected(). + - charger_control_hand_to_octopus(handle, charge): let go of a charger Octopus is taking + over, without starting a charge. + + Chargers with no car to follow are left alone rather than stopped: a charger whose + car has no plan would otherwise read as "not planned" and be stopped mid-charge. One + Predbat was already holding when its car went away is released rather than stranded. + + The guest charging switch hands the chargers back so a car Predbat is not planning for + can charge. It turns itself off when a car plugged in after it was switched on is + unplugged again, on chargers that can tell, and otherwise after GUEST_CHARGING_MAX_HOURS. + + A car whose Octopus Intelligent dispatches are delivered by Octopus driving the charger + itself is left to Octopus - see charger_control_octopus_drives_charger(). Where Octopus + instead drives the car, the charger is left alone too, unless the user turns on + octopus_intelligent_charger_follows_car - Predbat then drives the charger from the slot + sensor that carries the Octopus dispatches, so the car cannot charge outside them on its + own timers. See charger_control_octopus_drives_car(). + + send and release_one must raise when the charger refuses, so nothing is recorded as + done and the next cycle tries again; the component catches it in its run loop. + """ + + def charger_control_setup(self, log_name, noun, storage_module=None, storage_key=None, storage_field=None, control=None, switch_prefix=None, control_setting=None): + """Initialise the shared control state. + + Args: + log_name: How the component names itself in log lines, e.g. "myenergi". + noun: What the chargers are called in log lines, e.g. "Zappi". + storage_module, storage_key, storage_field: Where the control switch is persisted. + Left as None the component has no switch and only read only mode releases. + control: The component's control setting from apps.yaml. None (unset) lets control + run wherever the component's automatic setup maps chargers to cars; False turns + it off; True also drives a charger whose Octopus arrangement cannot be told. + switch_prefix: The component's entity name, e.g. "gecloud", for its guest charging + switch. Left as None the component has no guest switch. + control_setting: The apps.yaml name of the control setting, e.g. "ge_cloud_evc_control", + named in the warning when Predbat cannot tell whether Octopus drives a charger. + """ + self.charger_control_config = control + self.charger_control_log_name = log_name + self.charger_control_noun = noun + self.charger_control_storage = (storage_module, storage_key, storage_field) if storage_module else None + self.charger_control_active = False + # The runtime switch, on unless the user turns it off. Restored from storage at startup. + self.charger_control_enabled = True + # Why control was last released, None while Predbat holds the chargers + self.charger_control_released = None + # car_n -> [(start, end)] from the last plan read + self.charger_control_windows = {} + # charger key -> True/False, the state Predbat last set. Only chargers Predbat has moved are here. + self.charger_control_state = {} + self.charger_control_setting = control_setting + # car_n -> why that car's charger is left alone ("octopus", "car", "discovering", "discovery_failed" or "unknown"), so + # each reason is logged once rather than every cycle, and again only when it changes + self.charger_control_octopus_cars = {} + # When the wait for Octopus or Kraken discovery began, None while not waiting + self.charger_control_discovery_since = None + self.charger_control_switch_prefix = switch_prefix + # Guest charging: deliberately not persisted, so a restart puts Predbat back in charge + self.charger_control_guest = False + # When the first control cycle saw guest charging on; the chargers seen empty since, and those + # a car has then been plugged in to - only an unplug from one of those ends guest charging + self.charger_control_guest_since = None + self.charger_control_guest_empty = set() + self.charger_control_guest_connected = set() + + def charger_control_connected(self, handle): + """Is a car on the cable - chargers that cannot tell are always treated as connected.""" + return True + + def charger_control_drifted(self, handle, charge): + """Has the charger moved away from the state Predbat last set - never, unless the charger can tell.""" + return False + + def charger_control_car_plugged(self, handle): + """Is a car plugged in, for ending guest charging - kept apart from charger_control_connected(). + + A charger can report its plug state without Predbat also skipping commands to it while + it is empty, which is what charger_control_connected() returning False would mean. + """ + return self.charger_control_connected(handle) + + def charger_control_car_count(self): + """How many cars have a plan to follow.""" + return self.num_cars + + def charger_control_octopus_drives_charger(self, car_n): + """Does Octopus Intelligent deliver this car's dispatches by driving its charger? + + True: Octopus switches the charger itself, so Predbat must leave it alone or the two + would fight - whatever octopus_intelligent_charging says, as that switch only changes + Predbat's own planning and Octopus goes on driving the charger regardless. False: there + is no Octopus Intelligent car here, or Octopus drives the car rather than the charger - + see charger_control_octopus_drives_car() for what Predbat does then. None: the car is on + Octopus Intelligent but which device Octopus drives cannot be told, or not yet. + + Read from the car's own wired dispatch sensor rather than from any one component: + the Octopus and Kraken components both publish the device's is_charger on it, and + reading it there cannot pair a car with some other car's device. + """ + slot = self.charger_control_dispatch_sensor(car_n) + if not slot: + # Not wired for this car - but the Octopus component may simply not have got there yet + return None if self.charger_control_octopus_discovering() else False + owner = getattr(self.base, "car_slot_owner", None) + if owner and owner != "octopus": + # Another charger component (Ohme) supplies the Intelligent slots from the charger itself + return True + is_charger = self.get_state_wrapper(slot, attribute="is_charger") + if is_charger is None: + # Not published yet, or a sensor that does not say (the Octopus Energy HA integration) + return None + if isinstance(is_charger, str) and is_charger.strip().lower() in ("unknown", "unavailable", "none", ""): + # A sensor that has lost its value does not say, which is not the same as "not the charger" + return None + return parse_control_setting(is_charger) + + def charger_control_octopus_drives_car(self, car_n): + """Does Octopus Intelligent deliver this car's dispatches by driving the car itself? + + The car is the Intelligent device (a BMW or Volkswagen linked to Octopus, say), so the car + starts and stops its own charge and the charger only supplies it. Predbat then leaves the + charger alone unless switch.predbat_octopus_intelligent_charger_follows_car is on, in which + case it drives the charger to the dispatches so the car cannot also charge on its own timers. + """ + slot = self.charger_control_dispatch_sensor(car_n) + if not slot: + return False + owner = getattr(self.base, "car_slot_owner", None) + if owner and owner != "octopus": + return False + return parse_control_setting(self.get_state_wrapper(slot, attribute="is_charger")) is False + + def charger_control_octopus_discovering(self): + """Is the Octopus or Kraken component still to wire its Intelligent devices into the car slots? + + Only a component running its automatic setup ever wires them, so one with + octopus_automatic off is never "still discovering" - waiting on it would leave the + charger alone for good. How long it has been is kept, see charger_control_discovery_overdue(). + """ + components = getattr(self.base, "components", None) + discovering = False + if components: + octopus = components.get_component("octopus") + # Kraken wires its SmartFlex devices in the same run that first succeeds - from its cache, + # or from a fresh discovery - so until then its slots may simply not be there yet + kraken = components.get_component("kraken") + discovering = (octopus is not None and octopus.automatic and octopus.intelligent_config_devices is None) or (kraken is not None and not kraken.api_started) + if not discovering: + self.charger_control_discovery_since = None + elif self.charger_control_discovery_since is None: + self.charger_control_discovery_since = datetime.now() + return discovering + + def charger_control_discovery_overdue(self): + """Has the wait for Octopus or Kraken discovery gone on past OCTOPUS_DISCOVERY_WAIT_MINUTES? + + Discovery normally finishes in a cycle or two. One that does not - a Kraken login that keeps + failing, say - still cannot say whether Octopus drives the charger, so it is then treated as + "cannot tell" rather than waited on for good. + """ + since = self.charger_control_discovery_since + return since is not None and (datetime.now() - since).total_seconds() >= OCTOPUS_DISCOVERY_WAIT_MINUTES * 60 + + def charger_control_log_left_alone(self, car_n, why): + """Say why a car's charger is being left alone. + + Waiting for Octopus discovery normally clears within a cycle or two, so it is Info. A + dispatch sensor that does not say which device Octopus drives never clears on its own, + and leaves the charger uncontrolled, so it is a warning naming the setting that fixes it. + """ + name = self.charger_control_log_name + noun = self.charger_control_noun + if why == "octopus": + self.log("Info: {}: Octopus Intelligent drives car {}'s {}, leaving it to Octopus".format(name, car_n, noun)) + elif why == "car": + self.log( + "Info: {}: Octopus Intelligent drives car {} itself, leaving its {} alone - in expert mode, turn on switch.{}_octopus_intelligent_charger_follows_car for Predbat to drive the {} to the dispatches".format( + name, car_n, noun, self.prefix, noun + ) + ) + elif why == "discovering": + self.log("Info: {}: waiting for the Octopus or Kraken component to find its devices before controlling car {}'s {}".format(name, car_n, noun)) + elif why == "discovery_failed": + setting = self.charger_control_setting or "the {} control setting".format(noun) + self.log( + "Warn: {}: the Octopus or Kraken component has not found its devices after {} minutes, so Predbat cannot tell whether Octopus Intelligent drives car {}'s {} and is leaving it alone. Check that component, or set {}: true in apps.yaml if the {} is not the Octopus device".format( + name, OCTOPUS_DISCOVERY_WAIT_MINUTES, car_n, noun, setting, noun + ) + ) + else: + setting = self.charger_control_setting or "the {} control setting".format(noun) + self.log( + "Warn: {}: cannot tell whether Octopus Intelligent drives car {}'s {} - its dispatch sensor does not say - so Predbat is leaving the {} alone. Set {}: true in apps.yaml if the {} is not the Octopus device".format( + name, car_n, noun, noun, setting, noun + ) + ) + + async def charger_control_hand_to_octopus(self, handle, charge): + """Let go of a charger Octopus is taking over, without starting a charge. + + A normal release undoes a stop so a car is never stranded, but here Octopus decides + when the charger runs - starting it would charge outside its dispatches. A charger + Predbat had left running is released as usual; one it had stopped is left for Octopus. + """ + if charge: + await self.charger_control_release_one(handle, charge) + + def charger_control_guest_entity(self): + """The guest charging switch's entity id, or None for a component without one.""" + if self.charger_control_switch_prefix is None: + return None + return "switch.{}_{}_guest_charging".format(self.prefix, self.charger_control_switch_prefix) + + def charger_control_publish_guest(self, app): + """Publish the guest charging switch - called only while control is active.""" + entity = self.charger_control_guest_entity() + if entity is None: + return + self.dashboard_item( + entity, + state="on" if self.charger_control_guest else "off", + attributes={"friendly_name": "{} Guest Charging".format(self.charger_control_noun), "icon": "mdi:account-arrow-right"}, + app=app, + ) + + async def charger_control_guest_event(self, entity_id, service): + """Handle the guest charging switch, returning True when entity_id was it.""" + if self.charger_control_switch_prefix is None or not entity_id.endswith("_{}_guest_charging".format(self.charger_control_switch_prefix)): + return False + self.charger_control_set_guest(not self.charger_control_guest if service == "toggle" else service == "turn_on") + return True + + def charger_control_set_guest(self, on, why=None): + """Turn guest charging on or off, starting its unplug and time limits afresh.""" + self.charger_control_guest = bool(on) + self.charger_control_guest_since = None + self.charger_control_guest_empty = set() + self.charger_control_guest_connected = set() + self.log("Info: {}: guest charging switched {}{}".format(self.charger_control_log_name, "on" if on else "off", " - {}".format(why) if why else "")) + + def charger_control_guest_over(self, now): + """Why guest charging should end now, or None while it should carry on. + + A charger only ends it by going empty, then plugged in, then empty again after it was + switched on. A car already on a charger at that point may be the owner's, unplugged to + make way for the guest, so its unplug must not end it - the cost is that a guest who + plugged in first is left to the time limit. Chargers that cannot tell always read as + plugged in, which also leaves them to the time limit. + """ + if self.charger_control_guest_since is None: + self.charger_control_guest_since = now + if (now - self.charger_control_guest_since).total_seconds() >= GUEST_CHARGING_MAX_HOURS * 3600: + return "on for {} hours".format(GUEST_CHARGING_MAX_HOURS) + for key, handle in self.charger_control_chargers()[: self.charger_control_car_count()]: + if not self.charger_control_car_plugged(handle): + if key in self.charger_control_guest_connected: + return "the guest's car was unplugged from {} {}".format(self.charger_control_noun, key) + self.charger_control_guest_empty.add(key) + elif key in self.charger_control_guest_empty: + self.charger_control_guest_connected.add(key) + return None + + def charger_control_read_only_now(self): + """Is Predbat in read only mode - the live attribute rather than just the config arg. + + Other components force read only by setting the attribute without touching the arg + (axle_control, for one), so read the attribute first and fall back to the arg for + the window before it is set. + """ + read_only = getattr(self.base, "set_read_only", None) + if read_only is None: + read_only = self.get_arg("set_read_only", False) + return bool(read_only) + + async def charger_control_set_enabled(self, enabled): + """Handle the control switch being turned on or off, and persist it.""" + self.charger_control_enabled = bool(enabled) + self.log("Info: {}: {} control switched {}".format(self.charger_control_log_name, self.charger_control_noun, "on" if self.charger_control_enabled else "off")) + await self.charger_control_save_enabled() + + async def charger_control_save_enabled(self): + """Persist the control switch so an off survives a restart. + + Without this a restart would silently take back a charger the user had deliberately + released, which they would only notice when the car charged at the wrong time. + Fails soft: no Storage component just means the switch is not sticky. + """ + if self.charger_control_storage is None or self.storage is None: + return + module, key, field = self.charger_control_storage + try: + # Storage reports serialisation and I/O failures by returning False rather than raising + if await self.storage.save(module, key, {field: self.charger_control_enabled}) is False: + raise OSError("storage refused the save") + except Exception as exc: + self.log("Warn: {}: could not save the {} control switch state, it may revert to the last saved state on restart: {}".format(self.charger_control_log_name, self.charger_control_noun, exc)) + + async def charger_control_load_enabled(self): + """Restore the control switch from storage, leaving it on when nothing is saved. + + Called before the first control cycle: the switch has to carry its restored state + from the start, or a restart with control switched off would take the charger back + for a cycle and then hand it over again. + """ + if self.charger_control_storage is None or self.storage is None: + return + module, key, field = self.charger_control_storage + try: + saved = await self.storage.load(module, key) + except Exception as exc: + self.log("Warn: {}: could not read the {} control switch state: {}".format(self.charger_control_log_name, self.charger_control_noun, exc)) + return + if isinstance(saved, dict) and field in saved: + self.charger_control_enabled = bool(saved[field]) + if not self.charger_control_enabled: + self.log("Info: {}: {} control is switched off from the last session".format(self.charger_control_log_name, self.charger_control_noun)) + + def charger_control_refresh_windows(self, now): + """Read Predbat's planned car charging windows for every car into charger_control_windows. + + The slot sensor's own on/off state only refreshes on Predbat's 5 minute cycle, so the + planned attribute is parsed and judged against the clock here instead - otherwise + every window boundary would be acted on up to 5 minutes late. + + Returns True once at least one car's plan has been read, False while no slot sensor + has ever been published - which is what stops a restart stopping a charge before + Predbat has decided anything. + """ + windows = {} + found = False + for car_n in range(self.charger_control_car_count()): + postfix = "" if car_n == 0 else "_{}".format(car_n) + planned = self.get_state_wrapper("binary_sensor.{}_car_charging_slot{}".format(self.prefix, postfix), attribute="planned") + if planned is None: + continue + found = True + windows[car_n] = parse_car_plan_windows(planned, now, self.local_tz) + self.charger_control_windows = windows + return found + + def charger_control_should_charge(self, car_n, now): + """Is now inside one of the planned charging windows for this car, or an Octopus dispatch.""" + return in_car_plan_window(self.charger_control_windows.get(car_n, []), now) or self.charger_control_dispatch_active(car_n, now) + + def charger_control_dispatch_active(self, car_n, now): + """Is an Octopus Intelligent dispatch running for this car right now? + + Only reached for a charger Predbat drives, so here Octopus drives the car. The plan + carries the dispatches too, but is only republished every 5 minutes and leaves out a + dispatch Octopus has not given any energy yet - following the dispatch sensor as well + stops the charger holding the car off for the first minutes of a new dispatch. With + octopus_intelligent_charging off Predbat follows only its own plan. + + The dispatch times on the sensor are judged against the clock rather than its on/off + state, which only refreshes every couple of minutes and would run the charger on past + the end of a dispatch. A sensor with no dispatch times falls back to its state. + """ + if not self.get_arg("octopus_intelligent_charging", True): + return False + slot = self.charger_control_dispatch_sensor(car_n) + if not slot: + return False + dispatches = [] + for attribute in ("planned_dispatches", "completed_dispatches"): + value = self.get_state_wrapper(slot, attribute=attribute) + if isinstance(value, list): + dispatches.extend(value) + if not dispatches: + return self.get_state_wrapper(slot) == "on" + for dispatch in dispatches: + if not isinstance(dispatch, dict): + continue + start = parse_dispatch_time(dispatch.get("start")) + end = parse_dispatch_time(dispatch.get("end")) + if start and end and start <= now < end: + return True + return False + + def charger_control_dispatch_sensor(self, car_n): + """The Octopus Intelligent dispatch sensor wired to this car, or None.""" + slots = self.get_arg("octopus_intelligent_slot", None, indirect=False) + if slots and not isinstance(slots, list): + slots = [slots] + if not slots or car_n >= len(slots): + return None + slot = slots[car_n] + # An apps.yaml default whose regex matched nothing (yet) is still its literal "re:" string - not an entity + if not slot or (isinstance(slot, str) and slot.startswith("re:")): + return None + return slot + + async def charger_control_tick(self, now): + """Run one cycle of charger control, releasing rather than just going quiet. + + Read only mode, the control switch and guest charging are all releases: Predbat may + have left a charger stopped, and walking away from that would strand the car unable + to charge - or leave the guest unable to. + A component stop deliberately does not release, as that is nearly always a restart + and releasing would glitch an in-progress charge. + + The caller passes now so every charger is judged against one instant. + """ + if not self.charger_control_active: + return + if self.charger_control_guest: + why = self.charger_control_guest_over(now) + if why: + self.charger_control_set_guest(False, why) + reason = None + if self.charger_control_read_only_now(): + reason = "Read only mode" + elif not self.charger_control_enabled: + reason = "The {} control switch".format(self.charger_control_noun) + elif self.charger_control_guest: + reason = "Guest charging" + if reason: + if self.charger_control_released is None: + self.log("Info: {}: releasing the {} because of: {}".format(self.charger_control_log_name, self.charger_control_noun, reason)) + await self.charger_control_release() + self.charger_control_released = reason + return + if self.charger_control_released is not None: + self.log("Info: {}: {} cleared, resuming {} control".format(self.charger_control_log_name, self.charger_control_released, self.charger_control_noun)) + self.charger_control_released = None + await self.charger_control_apply(now) + + async def charger_control_each(self, steps): + """Await each step in turn - one coroutine factory per charger - so one charger that + refuses a command does not stop the others being handled. The first failure is raised + once every step has had its turn, for the component's run loop to log and retry.""" + failure = None + for step in steps: + try: + await step() + except Exception as exc: + if failure is None: + failure = exc + if failure is not None: + raise failure + + async def charger_control_release(self): + """Hand back every charger Predbat has moved, forgetting each once it is released. + + A charger that refuses stays held, so the next cycle retries just that one. + """ + held = [(key, handle) for key, handle in self.charger_control_chargers() if key in self.charger_control_state] + await self.charger_control_each([lambda key=key, handle=handle: self.charger_control_release_held(key, handle) for key, handle in held]) + + async def charger_control_hand_over_held(self, key, handle): + """Hand one held charger to Octopus, then forget it - after, so a failed command is retried.""" + await self.charger_control_hand_to_octopus(handle, self.charger_control_state[key]) + del self.charger_control_state[key] + + async def charger_control_hand_over_all(self): + """Hand every held charger to Octopus, each on its own so one refusing does not strand the rest.""" + held = [(key, handle) for key, handle in self.charger_control_chargers() if key in self.charger_control_state] + await self.charger_control_each([lambda key=key, handle=handle: self.charger_control_hand_over_held(key, handle) for key, handle in held]) + + async def charger_control_release_held(self, key, handle): + """Release one charger Predbat holds, then forget it.""" + await self.charger_control_release_one(handle, self.charger_control_state[key]) + del self.charger_control_state[key] + + async def charger_control_apply(self, now): + """Drive every charger from its car's plan: on inside a planned window, off outside one. + + Predbat holds the charger for as long as it is in control, and puts it back if it has + drifted from what was last set - purely edge-triggered control diverges silently once + anything else touches the charger. A charger with no car connected is left alone. + """ + if not self.charger_control_refresh_windows(now): + return + chargers = self.charger_control_chargers() + car_count = self.charger_control_car_count() + steps = [lambda key=key, handle=handle: self.charger_control_car_gone(key, handle) for key, handle in chargers[car_count:]] + steps += [lambda car_n=car_n, key=key, handle=handle: self.charger_control_drive_one(car_n, key, handle, now) for car_n, (key, handle) in enumerate(chargers[:car_count])] + await self.charger_control_each(steps) + + async def charger_control_car_gone(self, key, handle): + """Release a charger Predbat holds whose car has gone (num_cars dropped). + + It would otherwise never be commanded again, left charging or stopped with nobody in control. + """ + if key in self.charger_control_state: + self.log("Info: {}: {} {} no longer has a car to follow, releasing it".format(self.charger_control_log_name, self.charger_control_noun, key)) + await self.charger_control_release_held(key, handle) + + async def charger_control_drive_one(self, car_n, key, handle, now): + """Drive one charger from car car_n's plan, or leave it to Octopus.""" + drives = self.charger_control_octopus_drives_charger(car_n) + if drives is not None: + # Known either way, so a later wait for discovery starts its clock afresh + self.charger_control_discovery_since = None + # Octopus drives the car itself: the charger is left alone too, unless the user has asked + # Predbat to drive it to the dispatches - or has turned Octopus Intelligent charging off in + # Predbat, which then plans the car itself and drives the charger to that plan + car_driven = drives is False and self.get_arg("octopus_intelligent_charging", True) and not self.get_arg("octopus_intelligent_charger_follows_car", False) and self.charger_control_octopus_drives_car(car_n) + # An explicit control: true is the user saying their charger is not the Octopus + # device, so it overrides "cannot tell" - but never a known charge point + if drives is True or car_driven or (drives is None and self.charger_control_config is not True): + if drives: + why = "octopus" + elif car_driven: + why = "car" + elif self.charger_control_octopus_discovering(): + why = "discovery_failed" if self.charger_control_discovery_overdue() else "discovering" + else: + why = "unknown" + if self.charger_control_octopus_cars.get(car_n) != why: + self.charger_control_log_left_alone(car_n, why) + self.charger_control_octopus_cars[car_n] = why + if key in self.charger_control_state: + if why == "octopus": + # Handed over before it is forgotten, so a failed command is retried next cycle + await self.charger_control_hand_over_held(key, handle) + elif why != "discovering": + # Nobody is known to be taking it over - with Octopus driving the car, nobody drives the + # charger - so a stop is undone rather than left in place + await self.charger_control_release_held(key, handle) + # While discovery is under way the charger stays as it is, and held, so it is settled + # either way once discovery has finished - releasing would start a charge Predbat had stopped + return + if car_n in self.charger_control_octopus_cars: + self.log("Info: {}: car {} is no longer left to Octopus, Predbat drives the {}".format(self.charger_control_log_name, car_n, self.charger_control_noun)) + del self.charger_control_octopus_cars[car_n] + if not self.charger_control_connected(handle): + return + charge = self.charger_control_should_charge(car_n, now) + last = self.charger_control_state.get(key, None) + drifted = self.charger_control_drifted(handle, charge) + if last == charge and not drifted: + return + if last == charge: + self.log("Info: {}: {} {} was changed away from what Predbat set, re-applying".format(self.charger_control_log_name, self.charger_control_noun, key)) + await self.charger_control_send(handle, charge, car_n) + self.charger_control_state[key] = charge diff --git a/apps/predbat/components.py b/apps/predbat/components.py index 7c4a6db9d..1ab703c10 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -164,9 +164,10 @@ def load_component_class(component_info): "default": False, "config": "ge_cloud_automatic_evc", }, + # No default: unset means "on when ge_cloud_automatic_evc maps the chargers to cars", + # distinct from an explicit False (never) or True (also when the Octopus side is unknown) "evc_control": { "required": False, - "default": False, "config": "ge_cloud_evc_control", }, }, @@ -275,7 +276,8 @@ def load_component_class(component_info): "automatic_eddi": {"required": False, "config": "myenergi_automatic_eddi", "default": True}, "enable_controls": {"required": False, "config": "myenergi_enable_controls", "default": True}, "poll_seconds": {"required": False, "config": "myenergi_poll_seconds", "default": 60}, - "zappi_control": {"required": False, "config": "myenergi_zappi_control", "default": False}, + # No default: unset means "on when the user set myenergi_automatic themselves" - see MyEnergiAPI.enable_control() + "zappi_control": {"required": False, "config": "myenergi_zappi_control"}, }, # Gate activation on having at least one auth path — api_key is the direct # transport's local hub credential, key is the cloud transport's access token. diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 6aef46e77..aaae033ef 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -883,6 +883,13 @@ "type": "switch", "default": True, }, + { + "name": "octopus_intelligent_charger_follows_car", + "friendly_name": "Drive the charger to the Intelligent dispatches when Octopus drives the car", + "type": "switch", + "default": False, + "enable": "expert_mode", + }, { "name": "octopus_saving_auto_join", "friendly_name": "Octopus Saving Session Auto Join", diff --git a/apps/predbat/gecloud.py b/apps/predbat/gecloud.py index 77341d8fd..c8101e275 100644 --- a/apps/predbat/gecloud.py +++ b/apps/predbat/gecloud.py @@ -16,7 +16,8 @@ import aiohttp import pytz from datetime import timedelta, datetime, timezone -from utils import str2time, dp1, dp2, dp4, parse_car_plan_windows, in_car_plan_window +from utils import str2time, dp1, dp2, dp4 +from car_charger_control import CarChargerControl, parse_control_setting from predbat_metrics import record_api_call import asyncio import math @@ -144,6 +145,10 @@ # which would look exactly like a working charger that Predbat quietly ignores. EVC_DISCONNECTED_STATUSES = {"available", "idle", "offline", "unavailable", "faulted", "reserved", "unknown"} +# The subset of those that positively mean nothing is plugged in, rather than that the charger +# cannot say - used to end guest charging, which a comms blip must not do +EVC_EMPTY_STATUSES = {"available", "idle"} + def evc_status_key(status): """Normalise a charger status into the form the status tables use. @@ -212,7 +217,7 @@ def evc_status_key(status): } # The two commands Predbat-led charge control drives a charger between. They are commands -# rather than modes, so there is nothing to restore on release - see release_evc_devices(). +# rather than modes, so there is nothing to restore on release - see charger_control_release_one(). EVC_COMMAND_START = "start-charge" EVC_COMMAND_STOP = "stop-charge" @@ -325,6 +330,14 @@ def classify_ge_failure(data, endpoint=None): return {"code": code, "message": ge_code_message(data, code), "reason": reason, "retry": retry} +class EVCCommandFailed(Exception): + """An EV charger command GivEnergy Cloud refused or never confirmed. + + async_send_evc_command() reports failure by returning None; charger control raises this + instead so the command is not recorded as done and is tried again next cycle. + """ + + class GECloudTerminalError(Exception): """Raised when the GE Cloud API reports a failure that no retry can clear. @@ -568,7 +581,7 @@ def merge_non_null(fresh, previous): return merged -class GECloudDirect(ComponentBase): +class GECloudDirect(ComponentBase, CarChargerControl): """ GivEnergy Cloud Direct API interface """ @@ -576,7 +589,7 @@ class GECloudDirect(ComponentBase): # GivEnergy's cloud applies a write some seconds after accepting it; the GEC and GEE rows wait 10 WRITE_AND_POLL_SLEEP = 10 - def initialize(self, ge_cloud_direct, api_key, automatic, automatic_evc=False, evc_control=False): + def initialize(self, ge_cloud_direct, api_key, automatic, automatic_evc=False, evc_control=None): """Initialise the GE Cloud Direct component""" self.api_key = api_key self.automatic = automatic @@ -584,15 +597,10 @@ def initialize(self, ge_cloud_direct, api_key, automatic, automatic_evc=False, e # chargers into the car planning registers a car and moves num_cars, so it has to # be something a user turns on rather than something an upgrade does to them. self.automatic_evc = automatic_evc - self.evc_control = evc_control - self.evc_control_active = False - # The runtime switch, on unless the user turns it off. Restored from storage at startup. - self.evc_control_enabled = True - self.evc_control_released = False - # What Predbat last asked each charger to do, so a poll that changes nothing sends - # nothing - every command goes through async_send_evc_command's retry loop. - self.evc_control_state = {} - self.evc_control_windows = {} + self.evc_control = parse_control_setting(evc_control) + # Remembering what each charger was last asked to do means a poll that changes nothing + # sends nothing - every command goes through async_send_evc_command's retry loop. + self.charger_control_setup("GECloud", "EV charger", EVC_STORAGE_MODULE, EVC_CONTROL_STATE, "evc_control_enabled", control=self.evc_control, switch_prefix="gecloud", control_setting="ge_cloud_evc_control") self.register_list = {} self.settings = {} self.status = {} @@ -727,9 +735,9 @@ async def switch_event(self, entity_id, service): Switch event """ if entity_id.endswith("_gecloud_evc_control"): - self.evc_control_enabled = service == "turn_on" - self.log("GECloud: EV charger control switched {}".format("on" if self.evc_control_enabled else "off")) - await self.save_evc_control_enabled() + await self.charger_control_set_enabled(service == "turn_on") + return + if await self.charger_control_guest_event(entity_id, service): return mapping = self.register_entity_map.get(entity_id, None) @@ -2091,44 +2099,21 @@ def evc_control_enable(self): Control needs the EVC automatic configuration because a charger is driven from its own car's plan, and it is that configuration which establishes which charger is which car - without it, charger 1 could be told to follow a car it is not attached to. + + ge_cloud_evc_control left unset turns control on with ge_cloud_automatic_evc, since + setting that is the user asking Predbat to plan for the car. False keeps it off. """ - self.evc_control_active = False - if not self.evc_control: + self.charger_control_active = False + if self.evc_control is False: return if not self.automatic_evc: - self.log("GECloud: Warn: ge_cloud_evc_control needs ge_cloud_automatic_evc to map each charger to a car, EV charger control is disabled") + # Unset control just follows the automatic setting, so only an explicit request is worth a warning + if self.evc_control: + self.log("GECloud: Warn: ge_cloud_evc_control needs ge_cloud_automatic_evc to map each charger to a car, EV charger control is disabled") return - self.evc_control_active = True + self.charger_control_active = True self.log("GECloud: Predbat-led EV charger control enabled") - async def save_evc_control_enabled(self): - """Persist the control switch so an off survives a restart. - - Without this a restart would silently take back a charger the user had deliberately - released, which they would only notice when the car charged at the wrong time. - Fails soft: no Storage component just means the switch is not sticky. - """ - if self.storage is None: - return - try: - await self.storage.save(EVC_STORAGE_MODULE, EVC_CONTROL_STATE, {"evc_control_enabled": self.evc_control_enabled}) - except Exception as exc: - self.log("GECloud: Warn: Could not save the EV charger control switch state: {}".format(exc)) - - async def load_evc_control_enabled(self): - """Restore the control switch from storage, leaving it on when nothing is saved.""" - if self.storage is None: - return - try: - saved = await self.storage.load(EVC_STORAGE_MODULE, EVC_CONTROL_STATE) - except Exception as exc: - self.log("GECloud: Warn: Could not read the EV charger control switch state: {}".format(exc)) - return - if isinstance(saved, dict) and "evc_control_enabled" in saved: - self.evc_control_enabled = bool(saved["evc_control_enabled"]) - if not self.evc_control_enabled: - self.log("GECloud: EV charger control is switched off from the last session") - def read_only_now(self): """Is Predbat in read only mode - the live attribute rather than just the config arg. @@ -2140,29 +2125,6 @@ def read_only_now(self): return self.get_state_wrapper("switch.{}_set_read_only".format(self.prefix), default="off") == "on" return bool(read_only) - def refresh_evc_car_windows(self, now): - """Read Predbat's planned car charging windows for every car into evc_control_windows. - - Returns True once at least one car's plan has been read, False while no slot sensor - has ever been published - which is what stops a restart stopping a charge before - Predbat has decided anything. - """ - windows = {} - found = False - for car_n in range(self.num_cars): - postfix = "" if car_n == 0 else "_{}".format(car_n) - planned = self.get_state_wrapper("binary_sensor.{}_car_charging_slot{}".format(self.prefix, postfix), attribute="planned") - if planned is None: - continue - found = True - windows[car_n] = parse_car_plan_windows(planned, now, self.local_tz) - self.evc_control_windows = windows - return found - - def evc_should_charge_now(self, car_n, now): - """Is now inside one of the planned charging windows for this car.""" - return in_car_plan_window(self.evc_control_windows.get(car_n, []), now) - def controlled_evc_devices(self): """The chargers to drive, in serial order, so charger N is auto-config's Nth car. @@ -2173,68 +2135,39 @@ def controlled_evc_devices(self): known = [uuid for uuid in self.evc_device_list if self.evc_device.get(uuid, {}).get("serial_number", None)] return sorted(known, key=lambda uuid: str(self.evc_device[uuid]["serial_number"])) - async def evc_control_tick(self, now): - """Run one cycle of EV charger control, releasing rather than just going quiet. + def charger_control_chargers(self): + """The chargers to drive, in car order - see controlled_evc_devices().""" + return [(uuid, uuid) for uuid in self.controlled_evc_devices()] - Read only mode and the control switch are both releases: Predbat may have left a - charger stopped, and walking away from that would strand the car unable to charge. - """ - if not self.evc_control_active: - return - reason = None - if self.read_only_now(): - reason = "Predbat is in read only mode" - elif not self.evc_control_enabled: - reason = "the EV charger control switch is off" - if reason: - if not self.evc_control_released: - self.log("GECloud: Releasing the EV chargers because {}".format(reason)) - await self.release_evc_devices() - self.evc_control_released = True - return - if self.evc_control_released: - self.log("GECloud: Resuming EV charger control") - self.evc_control_released = False - await self.evc_control_charge(now) + def charger_control_car_plugged(self, uuid): + """Is a car plugged in, for ending guest charging - only a status known to mean an empty + charger counts as unplugged, so an offline or unknown blip does not end it.""" + return evc_status_key(self.evc_device[uuid].get("status", None)) not in EVC_EMPTY_STATUSES + + def charger_control_connected(self, uuid): + """A charger with no car plugged in is left alone - commanding it would achieve nothing and every command costs a retry loop.""" + return self.evc_car_connected(self.evc_device[uuid].get("status", None)) - async def release_evc_devices(self): - """Hand every held charger back by starting it again. + async def charger_control_send(self, uuid, charge, car_n): + """Start the charger inside a planned window, stop it outside one.""" + command = EVC_COMMAND_START if charge else EVC_COMMAND_STOP + self.log("GECloud: Sending {} to EV charger {} for car {}".format(command, self.evc_device[uuid]["serial_number"], car_n)) + if await self.async_send_evc_command(uuid, command, {}) is None: + raise EVCCommandFailed("{} was not accepted by EV charger {}".format(command, self.evc_device[uuid]["serial_number"])) + + async def charger_control_release_one(self, uuid, charge): + """Hand a held charger back by starting it again. start-charge and stop-charge are commands rather than modes, so unlike a Zappi there is no previous mode to restore - releasing means undoing the only thing Predbat did, which is the stop. A charger Predbat had left running needs nothing. The charger's own mode still decides what happens next. """ - for uuid in self.controlled_evc_devices(): - if self.evc_control_state.get(uuid, None) != EVC_COMMAND_STOP: - continue - self.log("GECloud: Releasing EV charger {}".format(self.evc_device[uuid]["serial_number"])) - await self.async_send_evc_command(uuid, EVC_COMMAND_START, {}) - self.evc_control_state = {} - - async def evc_control_charge(self, now): - """Drive every controlled charger from its car's charge plan. - - Predbat holds the charger for as long as it is in control: charging inside a - planned window, stopped outside one. A charger with no car plugged in is left - alone - commanding it would achieve nothing and every command costs a retry loop. - """ - if not self.refresh_evc_car_windows(now): + if charge: return - # Only as far as there are cars to follow. async_automatic_config_evc() raises - # num_cars to the charger count, but that reaches the base object a cycle later, - # so there is a window where a charger has no plan of its own - and a charger with - # no plan would read as "not planned" and be stopped while its car was charging. - for car_n, uuid in enumerate(self.controlled_evc_devices()[: self.num_cars]): - device = self.evc_device[uuid] - if not self.evc_car_connected(device.get("status", None)): - continue - wanted = EVC_COMMAND_START if self.evc_should_charge_now(car_n, now) else EVC_COMMAND_STOP - if self.evc_control_state.get(uuid, None) == wanted: - continue - self.log("GECloud: Sending {} to EV charger {} for car {}".format(wanted, device["serial_number"], car_n)) - await self.async_send_evc_command(uuid, wanted, {}) - self.evc_control_state[uuid] = wanted + self.log("GECloud: Releasing EV charger {}".format(self.evc_device[uuid]["serial_number"])) + if await self.async_send_evc_command(uuid, EVC_COMMAND_START, {}) is None: + raise EVCCommandFailed("the release was not accepted by EV charger {}".format(self.evc_device[uuid]["serial_number"])) async def async_automatic_config_evc(self): """Wire the EV chargers into Predbat's car charging inputs. @@ -2322,7 +2255,7 @@ def apply_devices(self, devices, evc_devices): self.ems_slot_warned.clear() self.register_entity_map = {entity_id: entry for entity_id, entry in self.register_entity_map.items() if entry.get("device") in device_list} for uuid in set(self.evc_device_list) - set(evc_device_list): - for store in (self.evc_device, self.evc_data, self.evc_sessions, self.evc_control_state): + for store in (self.evc_device, self.evc_data, self.evc_sessions, self.charger_control_state): store.pop(uuid, None) self.devices_dict = devices @@ -2416,7 +2349,7 @@ async def run(self, seconds, first): # Before the first control cycle: the switch has to carry its restored state from # the start, or a restart with control switched off would take the charger back # for a cycle and then hand it over again - await self.load_evc_control_enabled() + await self.charger_control_load_enabled() self.evc_control_enable() if not self.device_list and not self.evc_device_list: @@ -2486,16 +2419,23 @@ async def run(self, seconds, first): await self.publish_evc_data(serial, self.evc_data[uuid]) await self.publish_evc_device(serial, self.evc_device[uuid]) - if self.evc_control_active: + if self.charger_control_active: # Published only when control could actually act on it - a switch reading # "on" for a feature that cannot run would be a lie self.dashboard_item( "switch.{}_gecloud_evc_control".format(self.prefix), - state="on" if self.evc_control_enabled else "off", + state="on" if self.charger_control_enabled else "off", attributes={"friendly_name": "EV Charger Control", "icon": "mdi:ev-station"}, app="gecloud", ) - await self.evc_control_tick(self.now_utc_exact) + self.charger_control_publish_guest("gecloud") + try: + await self.charger_control_tick(self.now_utc_exact) + except EVCCommandFailed as exc: + # Already logged as an error by async_send_evc_command; nothing is recorded as + # done, so the next cycle tries again - for a release, that is what stops a + # stopped car being stranded + self.log("GECloud: Warn: EV charger control failed, will retry: {}".format(exc)) if first or devices_changed or (seconds % (10 * 60) == 0): # Get All registers every now and again in case user changes them diff --git a/apps/predbat/myenergi.py b/apps/predbat/myenergi.py index 1f260b7b9..4027ac721 100644 --- a/apps/predbat/myenergi.py +++ b/apps/predbat/myenergi.py @@ -36,7 +36,7 @@ from mock_base import MockBase from oauth_mixin import OAuthMixin from predbat_metrics import record_api_call -from utils import parse_car_plan_windows, in_car_plan_window +from car_charger_control import CarChargerControl, parse_control_setting MYENERGI_DIRECTOR_URL = "https://director.myenergi.net" MYENERGI_CLOUD_URL = "https://api.s18.myenergi.net" @@ -758,10 +758,10 @@ async def cancel_boost(self, device): MAX_POLL_SECONDS = 30 * 60 -class MyEnergiAPI(ComponentBase, OAuthMixin): +class MyEnergiAPI(ComponentBase, OAuthMixin, CarChargerControl): """myenergi component providing Zappi and Eddi monitoring and boost control.""" - def initialize(self, auth_method=None, hub_serial=None, api_key=None, key=None, token_expires_at=None, token_hash=None, automatic=True, enable_controls=True, poll_seconds=60, zappi_control=False, automatic_zappi=True, automatic_eddi=True): + def initialize(self, auth_method=None, hub_serial=None, api_key=None, key=None, token_expires_at=None, token_hash=None, automatic=True, enable_controls=True, poll_seconds=60, zappi_control=None, automatic_zappi=True, automatic_eddi=True): """Select a transport from the configured credentials and set up component state.""" configured_auth_method = (auth_method or "direct").lower() self.hub_serial = hub_serial @@ -776,20 +776,17 @@ def initialize(self, auth_method=None, hub_serial=None, api_key=None, key=None, self.automatic_zappi = automatic_zappi self.automatic_eddi = automatic_eddi self.enable_controls = enable_controls - self.zappi_control = bool(zappi_control) + # Tri-state: None (unset) follows the automatic setup, see enable_control() + self.zappi_control = parse_control_setting(zappi_control) # ComponentBase.start() calls run() on a fixed 60 second cadence, so the poll # interval can only be a whole number of those intervals. self.poll_seconds = min(MAX_POLL_SECONDS, max(MIN_POLL_SECONDS, int(round(_to_float(poll_seconds, MIN_POLL_SECONDS) / 60.0)) * 60)) self.devices = {} self.boost_amounts = {} - self.control_windows = {} - self.control_modes = {} + # The mode each Zappi was in before Predbat first moved it, restored on release self.control_saved_modes = {} - self.control_active = False - # The runtime switch, on unless the user turns it off. Restored from storage at startup. - self.control_enabled = True - self.control_released = False + self.charger_control_setup("myenergi", "Zappi", MYENERGI_STORAGE_MODULE, MYENERGI_CONTROL_STATE, "control_enabled", control=self.zappi_control, switch_prefix="myenergi", control_setting="myenergi_zappi_control") self.queued_events = [] self._auto_configured = False self.transport = None @@ -882,169 +879,125 @@ def automatic_config(self): self.log("Info: myenergi: setting iboost_energy_today to {}".format(eddi_entity)) self.set_arg_auto("iboost_energy_today", eddi_entity) - def refresh_car_windows(self, now): - """Read Predbat's planned car charging windows for every car into control_windows. + def automatic_set_by_user(self): + """Did the user write myenergi's automatic setup into apps.yaml themselves. - Returns True once at least one car's plan has been read, False while no slot sensor - has ever been published - which is what stops the loop stopping a car on startup, - before Predbat has decided anything. - - The caller passes now so every car is judged against the same instant, and so the - parsing stays a pure function of the plan and the clock. + myenergi_automatic and myenergi_automatic_zappi both default on, so their values alone + cannot tell a user who asked for automatic setup from one who never mentioned it. """ - windows = {} - found = False - for car_n in range(self.num_cars): - postfix = "" if car_n == 0 else "_{}".format(car_n) - planned = self.get_state_wrapper("binary_sensor.{}_car_charging_slot{}".format(self.prefix, postfix), attribute="planned") - if planned is None: - continue - found = True - windows[car_n] = self._parse_plan_windows(planned, now) - self.control_windows = windows - return found - - def _parse_plan_windows(self, planned, now): - """Turn one car's published plan into a list of localised (start, end) pairs.""" - return parse_car_plan_windows(planned, now, self.local_tz) - - def should_charge_now(self, car_n, now): - """Is now inside one of the planned charging windows for this car.""" - return in_car_plan_window(self.control_windows.get(car_n, []), now) + raw_args = getattr(self.base, "args_from_apps_yaml", None) or {} + return any(raw_args.get(arg) is not None for arg in ("myenergi_automatic", "myenergi_automatic_zappi")) def enable_control(self): """Decide whether Predbat-led Zappi control should run, and say why when it will not. Control needs automatic configuration because a Zappi is driven from its own car's plan, and it is auto-config that establishes which Zappi is which car. + + myenergi_zappi_control left unset turns control on only when the user wrote + myenergi_automatic or myenergi_automatic_zappi into apps.yaml themselves. False keeps + it off. Only an explicit true is worth a warning when it cannot run. + + For review, to clear up later - the options considered for the unset case: + 1. On whenever automatic setup runs, the same rule as GE and Ohme. Rejected: automatic + defaults on for myenergi, so every Zappi user would be switched over on upgrade, + and a Zappi held off between windows can no longer divert solar in Eco/Eco+. + 2. On only when the user wrote the automatic setting themselves (this one). + 3. Zappi control stays opt-in. Rejected: it leaves Zappi out of the automatic behaviour + the other chargers now get. """ - if not self.zappi_control: + if self.zappi_control is False: + return + explicit = self.zappi_control is True + if not explicit and not self.automatic_set_by_user(): return if not self.automatic: - self.log("Warn: myenergi: myenergi_zappi_control needs myenergi_automatic to map each Zappi to a car, Zappi control is disabled") + if explicit: + self.log("Warn: myenergi: myenergi_zappi_control needs myenergi_automatic to map each Zappi to a car, Zappi control is disabled") return if not self.automatic_zappi: - self.log("Warn: myenergi: myenergi_zappi_control needs myenergi_automatic_zappi to map each Zappi to a car, Zappi control is disabled") + if explicit: + self.log("Warn: myenergi: myenergi_zappi_control needs myenergi_automatic_zappi to map each Zappi to a car, Zappi control is disabled") return if not self.enable_controls: - self.log("Warn: myenergi: myenergi_zappi_control is ignored while myenergi_enable_controls is off") + if explicit: + self.log("Warn: myenergi: myenergi_zappi_control is ignored while myenergi_enable_controls is off") return - self.control_active = True + self.charger_control_active = True self.log("Info: myenergi: Predbat-led Zappi charge control enabled") - async def save_control_enabled(self): - """Persist the control switch so an off survives a restart. + def controlled_zappis(self): + """The Zappis to drive, in serial order, so Zappi N is the same car as auto-config's Nth. - Without this a restart would silently hand Predbat back a Zappi the user had - deliberately released, which they would only notice when the car charged at the - wrong time. Fails soft: no Storage component just means the switch is not sticky. + automatic_config() wires car_charging_energy and car_charging_planned as per-car + lists in this same order, so the two cannot disagree about which Zappi is which car. """ - if self.storage is None: - return - try: - await self.storage.save(MYENERGI_STORAGE_MODULE, MYENERGI_CONTROL_STATE, {"control_enabled": self.control_enabled}) - except Exception as exc: - self.log("Warn: myenergi: could not save the Zappi control switch state: {}".format(exc)) + return [device for device in sorted(self.devices.values(), key=lambda item: item.serial) if device.kind == DEVICE_KIND_ZAPPI] - async def load_control_enabled(self): - """Restore the control switch from storage, leaving it on when nothing is saved.""" - if self.storage is None: - return - try: - saved = await self.storage.load(MYENERGI_STORAGE_MODULE, MYENERGI_CONTROL_STATE) - except Exception as exc: - self.log("Warn: myenergi: could not read the Zappi control switch state: {}".format(exc)) - return - if isinstance(saved, dict) and "control_enabled" in saved: - self.control_enabled = bool(saved["control_enabled"]) - if not self.control_enabled: - self.log("Info: myenergi: Zappi charge control is switched off from the last session") + def charger_control_chargers(self): + """The Zappis to drive, in car order - see controlled_zappis().""" + return [(device.device_id, device) for device in self.controlled_zappis()] - def control_read_only_now(self): - """Is Predbat in read only mode - the live attribute rather than just the config arg. + def charger_control_car_plugged(self, device): + """Is a car plugged in to this Zappi - an unknown or faulted plug state reads as plugged in.""" + return device.plug_status != ZAPPI_PLUG_STATES["A"] - axle_control forces read only by setting the attribute without touching the arg, so - read the attribute first and fall back to the arg for the window before it is set. - """ - read_only = getattr(self.base, "set_read_only", None) - if read_only is None: - read_only = self.get_arg("set_read_only", False) - return bool(read_only) + def charger_control_drifted(self, device, charge): + """Has the Zappi been put in a different mode, e.g. from the myenergi app.""" + return device.mode != (ZAPPI_MODE_CHARGING if charge else ZAPPI_MODE_STOPPED) - async def control_tick(self, now): - """Run one cycle of Zappi control, releasing rather than just going quiet. + async def charger_control_send(self, device, charge, car_n): + """Fast inside a planned window, Stopped outside one. - Read only mode and the control switch are both releases: Predbat may have left a - Zappi Stopped, and walking away from that would strand the car unable to charge. + Fast is the only mode that draws what the plan assumed, since the window was chosen + for its rate rather than for sunshine. """ - if not self.control_active: - return - reason = None - if self.control_read_only_now(): - reason = "Predbat is in read only mode" - elif not self.control_enabled: - reason = "the Zappi control switch is off" - if reason: - if not self.control_released: - self.log("Info: myenergi: releasing the Zappis because {}".format(reason)) - await self.release_zappis() - self.control_released = True - return - if self.control_released: - self.log("Info: myenergi: resuming Zappi charge control") - self.control_released = False - await self.control_charge(now) + wanted = ZAPPI_MODE_CHARGING if charge else ZAPPI_MODE_STOPPED + # Remember where the charger was before Predbat first moved it, so release can put it back + if device.device_id not in self.control_saved_modes: + self.control_saved_modes[device.device_id] = device.mode + self.log("Info: myenergi: setting {} to {} for car {}".format(device.name, wanted, car_n)) + await self.transport.set_mode(device, wanted) - async def release_zappis(self): - """Hand every held Zappi back, restoring the mode it had before Predbat took over. + async def charger_control_release_one(self, device, charge): + """Hand a held Zappi back, restoring the mode it had before Predbat took over. Falls back to Eco+ when nothing was saved - a restart, or a device that reported a mode neither API accepts back - so a released Zappi always lands somewhere useful rather than being left Stopped. """ - for device in self.controlled_zappis(): - if device.device_id not in self.control_modes: - continue - mode = self.control_saved_modes.get(device.device_id) - if mode not in ZAPPI_MODE_TO_CLOUD: - mode = ZAPPI_MODE_RELEASE - self.log("Info: myenergi: releasing {} back to {}".format(device.name, mode)) - await self.transport.set_mode(device, mode) - self.control_modes = {} - self.control_saved_modes = {} - - def controlled_zappis(self): - """The Zappis to drive, in serial order, so Zappi N is the same car as auto-config's Nth. - - automatic_config() wires car_charging_energy and car_charging_planned as per-car - lists in this same order, so the two cannot disagree about which Zappi is which car. + # Read, not popped: if the command fails the release is retried next cycle, and must + # still know where to put the Zappi back + mode = self.control_saved_modes.get(device.device_id) + if mode not in ZAPPI_MODE_TO_CLOUD: + mode = ZAPPI_MODE_RELEASE + self.log("Info: myenergi: releasing {} back to {}".format(device.name, mode)) + await self.transport.set_mode(device, mode) + # Only once it has gone through: a Zappi taken back later must snapshot its mode afresh + self.control_saved_modes.pop(device.device_id, None) + + async def charger_control_hand_to_octopus(self, device, charge): + """Hand a Zappi to Octopus in the mode it had before Predbat took over, but never Fast. + + Fast would start a grid charge outside Octopus's dispatches. Eco+ in its place only + diverts surplus solar, so the Zappi is not left Stopped either. """ - return [device for device in sorted(self.devices.values(), key=lambda item: item.serial) if device.kind == DEVICE_KIND_ZAPPI] - - async def control_charge(self, now): - """Drive every controlled Zappi from its car's charge plan. - - Predbat holds the charger for as long as it is in control: Fast inside a planned - window, Stopped outside one. Fast is the only mode that draws what the plan assumed, - since the window was chosen for its rate rather than for sunshine. - - The caller passes now so every Zappi is judged against one instant. + mode = self.control_saved_modes.get(device.device_id) + if mode not in ZAPPI_MODE_TO_CLOUD or mode == ZAPPI_MODE_CHARGING: + mode = ZAPPI_MODE_RELEASE + self.log("Info: myenergi: handing {} to Octopus in {}".format(device.name, mode)) + await self.transport.set_mode(device, mode) + self.control_saved_modes.pop(device.device_id, None) + + async def charger_control_release(self): + """Release every held Zappi, then forget every saved mode. + + Cleared only once the whole release has gone through, so a failed command keeps what + the retry needs. Cleared entirely rather than per Zappi, so a mode saved ahead of a + command that was refused cannot outlive the release and block a fresh snapshot later. """ - if not self.refresh_car_windows(now): - return - for car_n, device in enumerate(self.controlled_zappis()): - wanted = ZAPPI_MODE_CHARGING if self.should_charge_now(car_n, now) else ZAPPI_MODE_STOPPED - asked = self.control_modes.get(device.device_id) - if asked == wanted and device.mode == wanted: - continue - if asked == wanted: - self.log("Info: myenergi: {} was changed away from {}, re-applying".format(device.name, wanted)) - # Remember where the charger was before Predbat first moved it, so release can put it back - if device.device_id not in self.control_saved_modes: - self.control_saved_modes[device.device_id] = device.mode - self.log("Info: myenergi: setting {} to {} for car {}".format(device.name, wanted, car_n)) - await self.transport.set_mode(device, wanted) - self.control_modes[device.device_id] = wanted + await super().charger_control_release() + self.control_saved_modes = {} def boost_amount_for(self, device): """Return the currently selected boost amount for a device.""" @@ -1091,15 +1044,15 @@ async def run(self, seconds, first): # Before the first publish: the switch has to carry its restored state # from the start, or a restart with control switched off would show it # on for a cycle and then flip, looking like Predbat taking control back - await self.load_control_enabled() + await self.charger_control_load_enabled() self.enable_control() await self.publish_data() if self.automatic and not self._auto_configured: self.automatic_config() self._auto_configured = True - if self.control_active: + if self.charger_control_active: try: - await self.control_tick(self.now_utc_exact) + await self.charger_control_tick(self.now_utc_exact) except MyEnergiError as exc: # myenergi can refuse a mode for reasons Predbat cannot see - nothing # plugged in, a fault on the charger. Monitoring still succeeded, so @@ -1178,9 +1131,9 @@ async def switch_event_handler(self, entity_id, service): if not self.enable_controls: return False if entity_id.endswith("_myenergi_zappi_control"): - self.control_enabled = service == "turn_on" - self.log("Info: myenergi: Zappi charge control switched {}".format("on" if self.control_enabled else "off")) - await self.save_control_enabled() + await self.charger_control_set_enabled(service == "turn_on") + return True + if await self.charger_control_guest_event(entity_id, service): return True if not entity_id.endswith("_boost"): return False @@ -1204,17 +1157,18 @@ async def switch_event_handler(self, entity_id, service): async def publish_data(self): """Publish every known device as Predbat entities.""" - if self.control_active: + if self.charger_control_active: # Published only when control could actually act on it. Gating on the config # key alone would leave a switch reading "on" for a feature that cannot run - # monitor-only mode, or automatic configuration off - and making that switch # merely respond to a toggle would keep it live without making it honest. self.dashboard_item( "switch.{}_myenergi_zappi_control".format(self.prefix), - state="on" if self.control_enabled else "off", + state="on" if self.charger_control_enabled else "off", attributes=myenergi_attribute_table["zappi_control"], app="myenergi", ) + self.charger_control_publish_guest("myenergi") for device in self.devices.values(): prefix = self.entity_prefix(device) self.dashboard_item("sensor.{}_status".format(prefix), state=device.status, attributes=myenergi_attribute_table["status"], app="myenergi") diff --git a/apps/predbat/ohme.py b/apps/predbat/ohme.py index 2070cdb99..3d21f5181 100644 --- a/apps/predbat/ohme.py +++ b/apps/predbat/ohme.py @@ -27,8 +27,8 @@ from datetime import timedelta, timezone from const import TIME_FORMAT_HA, DISPATCH_SOURCE_CHARGER_SCHEDULE from component_base import ComponentBase +from car_charger_control import CarChargerControl, parse_control_setting from predbat_metrics import record_api_call -from utils import parse_car_plan_windows, in_car_plan_window GOOGLE_API_KEY = "AIzaSyC8ZeZngm33tpOXLpbXeKfwtyZ1WrkbdBY" # cspell:disable-line VERSION = "1.5.1" @@ -96,6 +96,7 @@ "octopus_charge_limit": {"entity_id": "number.predbat_ohme_target_percent", "domain": "number", "access": "rw"}, } + # Longest gap between power readings we will still integrate over. The charge session is polled # every 120 seconds, so a longer gap means Predbat stalled or was restarted and we have no evidence # of what the charger did meanwhile - under-counting is safe, inventing energy is not @@ -221,7 +222,7 @@ class ChargerPower: ct_amps: float -class OhmeAPI(ComponentBase): +class OhmeAPI(ComponentBase, CarChargerControl): """Ohme API component for EV charger integration.""" def initialize(self, email, password, ohme_automatic=False, ohme_automatic_octopus_intelligent=None, ohme_control=False): @@ -233,8 +234,9 @@ def initialize(self, email, password, ohme_automatic=False, ohme_automatic_octop self.ohme_automatic = ohme_automatic # Tri-state: True/False force the Intelligent wiring on or off, None auto-detects it self.ohme_automatic_octopus_intelligent = ohme_automatic_octopus_intelligent - self.ohme_control = ohme_control - self.control_active = False + self.ohme_control = parse_control_setting(ohme_control) + # No control switch: read only mode is what releases an Ohme charger + self.charger_control_setup("Ohme API", "charger", control=self.ohme_control, switch_prefix="ohme", control_setting="ohme_control") # 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 @@ -245,17 +247,15 @@ 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 + # Whether Predbat drives the charger while Octopus drives the car, as last decided - see update_slot_mode() + self.follows_car = False # 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 self.charger_slots_blocked = None - self.control_windows = [] - # The state we last pushed to the charger, None until we have acted or after releasing it - self.control_charging = None - # Last read-only state acted on, None until the control loop has run once - self.control_read_only = None # The charger's own target percent as it was before Predbat took control, restored on release self.control_saved_target = None + self.control_max_charge_started = False self.energy_today = 0.0 self.energy_today_date = None self.energy_last_time = None @@ -322,8 +322,16 @@ async def run(self, seconds, first): # "first or ..."), so the entities this reports against already exist by the time it runs. self.refresh_discovery() - if self.control_active and (seconds % CONTROL_INTERVAL_SECONDS) == 0: - await self.control_charge() + if self.charger_control_active and (seconds % CONTROL_INTERVAL_SECONDS) == 0: + self.charger_control_publish_guest("ohme") + try: + await self.charger_control_tick(self.now_utc_exact) + except ApiException as exc: + # Ohme can refuse a command for reasons Predbat cannot see - the car unplugged since + # it was paused, say. Monitoring still worked, so this is a warning rather than a failed + # cycle, and nothing is recorded as done, so the next cycle tries again - which for a + # release is what stops a paused car being stranded. + self.log("Warn: Ohme API: Charge control failed: {}".format(exc)) self.update_success_timestamp() return True @@ -331,8 +339,13 @@ async def run(self, seconds, first): def enable_control(self, octopus_intelligent): """ Decide whether Predbat-led charge control should run, and say why when it will not. + + Only an explicit ohme_control: true turns it on. Left unset, the Ohme keeps to its own + schedule and that schedule is Predbat's car plan (see charger_slots_wanted()): the user + may have set it up deliberately, and max charge would override the target set in the + Ohme app. """ - self.control_active = False + self.charger_control_active = False if not self.ohme_control: return if not self.ohme_automatic: @@ -341,21 +354,9 @@ def enable_control(self, octopus_intelligent): if octopus_intelligent: self.log("Warn: Ohme API: ohme_control is ignored on Octopus Intelligent - Octopus already schedules the charge") return - self.control_active = True + self.charger_control_active = True self.log("Info: Ohme API: Predbat-led charge control enabled") - def control_read_only_now(self): - """ - Is Predbat in read only mode - the effective state rather than just the switch. - - axle_control forces read only by setting the attribute without touching the config arg, so - read the attribute first and fall back to the arg for the window before it is first set. - """ - read_only = getattr(self.base, "set_read_only", None) - if read_only is None: - read_only = self.get_arg("set_read_only", False) - return bool(read_only) - def charger_mode(self): """ The charger's current mode, or None when there is no session to read it from. @@ -374,106 +375,75 @@ def charger_target(self): except (KeyError, TypeError): return None - def refresh_car_windows(self): - """ - Read Predbat's planned car charging windows into control_windows. - - The binary sensor's own on/off state only refreshes on Predbat's 5 minute cycle, so the - planned attribute is parsed and evaluated against the clock here instead - otherwise every - window boundary would be acted on up to 5 minutes late. - - Returns True once a plan has been read, False while the sensor has never been published - - which is what stops the loop pausing a car on startup before it knows anything. - """ - planned = self.get_state_wrapper("binary_sensor." + self.prefix + "_car_charging_slot", attribute="planned") - if planned is None: - return False + def charger_control_chargers(self): + """The one Ohme charger on the account, which follows car 0.""" + return [(self.client.serial or "ohme", self.client)] - # The plan carries no year - parse_car_plan_windows() (shared with myenergi and GivEnergy - # EVC) rebuilds it around now, whichever side of New Year it is read (#269) - self.control_windows = parse_car_plan_windows(planned, self.now_utc_exact, self.local_tz) - return True - - def should_charge_now(self): - """ - Is now inside one of Predbat's planned charging windows. - """ - return in_car_plan_window(self.control_windows, self.now_utc_exact) + def charger_control_car_plugged(self, client): + """Is a car plugged in to the Ohme - with no session read yet, as charger_mode(), it reads as plugged in.""" + try: + return client.status is not ChargerStatus.UNPLUGGED + except (KeyError, TypeError): + return True - def control_drifted(self, should_charge): - """ - Has the charger moved away from the state we last set, e.g. changed in the Ohme app. + def charger_control_car_count(self): + """An Ohme account has one charger, and it follows car 0 whatever num_cars says.""" + return 1 - Purely edge-triggered control diverges silently once anything else touches the charger, so - the mode we already poll is compared against what we asked for and re-applied if it moved. - """ - if self.control_charging is None: - return False + def charger_control_drifted(self, client, charge): + """Has the charger moved away from the state we last set, e.g. changed in the Ohme app.""" mode = self.charger_mode() if mode is None: # Nothing plugged in to correct return False - if should_charge: + if charge: return mode is not ChargerMode.MAX_CHARGE return mode is not ChargerMode.PAUSED - async def release_charger(self, reason="Read only mode"): - """ - Hand the charger back to Ohme's own smart schedule. - """ - if self.control_charging is None: - return - self.log("Info: Ohme API: {}, releasing the charger back to Ohme".format(reason)) - if not self.control_charging: - await self.client.async_resume_charge() - await self.client.async_max_charge(False) + async def charger_control_send(self, client, charge, car_n): + """Max charge inside a planned window, paused outside one.""" + if charge: + # Snapshot the user's target before max charge overrides it, so release can put it back + if self.control_saved_target is None: + self.control_saved_target = self.charger_target() + self.log("Info: Ohme API: Charge window active, setting max charge") + await client.async_max_charge(True) + self.control_max_charge_started = True + else: + self.log("Info: Ohme API: Outside the charge plan, pausing the charger") + await client.async_pause_charge() + + async def charger_control_release_one(self, client, charge): + """Hand the charger back to Ohme's own smart schedule.""" + self.log("Info: Ohme API: Releasing the charger back to Ohme") + if not charge: + await client.async_resume_charge() + await client.async_max_charge(False) + self.control_max_charge_started = False # Max charge overrides the charger's own target percent, so put back what the user had # before Predbat took over - otherwise Ohme's smart schedule is left charging to the wrong # level once we hand it back if self.control_saved_target is not None: - await self.client.async_set_target(target_percent=self.control_saved_target) + await client.async_set_target(target_percent=self.control_saved_target) self.log("Info: Ohme API: Restored the charger target to {}%".format(self.control_saved_target)) self.control_saved_target = None - self.control_charging = None - async def control_charge(self): - """ - Drive the charger from Predbat's car charging plan. + async def charger_control_hand_to_octopus(self, client, charge): + """Hand the charger to Octopus without starting a charge. - Predbat holds the charger for as long as it is in control: max charge inside a planned - window, paused outside one. Read only mode is the release - it hands the charger back to - Ohme rather than leaving a pause in place. A component stop deliberately does not release, - as that is nearly always a restart and releasing would glitch an in-progress charge. + Max charge is what forces a charge now, so it goes first; resuming a paused charger then + only returns it to Ohme's smart schedule, which Octopus drives. """ - if self.control_read_only_now(): - if self.control_read_only is not True: - self.control_read_only = True - await self.release_charger() - return - if self.control_read_only: - self.log("Info: Ohme API: Read only mode cleared, resuming charge control") - self.control_read_only = False - - if not self.refresh_car_windows(): - return - - should_charge = self.should_charge_now() - drifted = self.control_drifted(should_charge) - if should_charge == self.control_charging and not drifted: - return - - if drifted: - self.log("Info: Ohme API: Charger was changed away from what Predbat set, re-applying") - if should_charge: - # Snapshot the user's target before max charge overrides it, so release can put it back - if self.control_saved_target is None: - self.control_saved_target = self.charger_target() - self.log("Info: Ohme API: Charge window active, setting max charge") - await self.client.async_max_charge(True) - else: - self.log("Info: Ohme API: Outside the charge plan, pausing the charger") - await self.client.async_pause_charge() - self.control_charging = should_charge + self.log("Info: Ohme API: Octopus Intelligent drives the charger, handing it back") + # Only undo a max charge Predbat started - one the user set themselves is not ours to cancel + if charge or self.control_max_charge_started: + await client.async_max_charge(False) + self.control_max_charge_started = False + if self.control_saved_target is not None: + await client.async_set_target(target_percent=self.control_saved_target) + self.control_saved_target = None + if not charge: + await client.async_resume_charge() def octopus_intelligent_wanted(self): """ @@ -554,8 +524,17 @@ async def update_slot_mode(self): """ was_other_device = self.octopus_other_device octopus_intelligent = self.octopus_intelligent_wanted() - if octopus_intelligent != self.octopus_intelligent or self.octopus_other_device != was_other_device: - if octopus_intelligent: + # While Octopus drives the car, Predbat drives the charger only when asked to follow the car's + # dispatches - or when Intelligent charging is off in Predbat, which then plans the car itself + follows_car = bool(self.get_arg("octopus_intelligent_charger_follows_car", False)) or not self.get_arg("octopus_intelligent_charging", True) + octopus_changed = octopus_intelligent != self.octopus_intelligent or self.octopus_other_device != was_other_device + # The switch only matters while Octopus drives the car, so a change elsewhere is not a decision to remake + follows_changed = follows_car != self.follows_car and self.octopus_other_device + self.follows_car = follows_car + if octopus_changed or follows_changed: + if not octopus_changed: + self.log("Info: Ohme API: Octopus Intelligent drives the car, and Predbat {} the charger".format("now drives" if follows_car else "no longer drives")) + elif 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") @@ -563,14 +542,22 @@ async def update_slot_mode(self): 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 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) + # it does that through, so it follows the same change - unless Octopus drives the car and + # the user has asked for the charger to be driven to the car's dispatches + self.enable_control(octopus_intelligent or (self.octopus_other_device and not follows_car)) # 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 - + # poll rather than only on the change, as charger_control_release() forgets the charger last - # a hand-back that failed part way is tried again until the charger really is released - if not self.control_active and self.control_charging is not None: - await self.release_charger(reason="Octopus Intelligent now schedules the charge") + if not self.charger_control_active and self.charger_control_state: + if octopus_intelligent: + # Octopus drives this charger: hand it over without starting a charge outside a dispatch + self.log("Info: Ohme API: Octopus Intelligent now schedules the charge, handing the charger to Octopus") + await self.charger_control_hand_over_all() + else: + # Octopus drives the car: nobody drives the charger, so a stop is undone + self.log("Info: Ohme API: Predbat no longer drives the charger, releasing it") + await self.charger_control_release() if octopus_intelligent: slot_mode = SLOT_MODE_INTELLIGENT @@ -610,7 +597,7 @@ def charger_slots_wanted(self, octopus_intelligent): 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.octopus_other_device or self.control_active: + if not self.ohme_automatic or octopus_intelligent or self.octopus_other_device or self.charger_control_active: self.charger_slots_blocked = None return False existing = self.get_arg("octopus_intelligent_slot", default=None, indirect=False) @@ -1062,6 +1049,8 @@ async def switch_event_handler(self, entity_id, service): """ Switch event """ + if await self.charger_control_guest_event(entity_id, service): + return if entity_id.endswith("_max_charge"): if service == "turn_on": await self.client.async_max_charge(True) diff --git a/apps/predbat/tests/test_car_charger_control.py b/apps/predbat/tests/test_car_charger_control.py new file mode 100644 index 000000000..d8b21e44a --- /dev/null +++ b/apps/predbat/tests/test_car_charger_control.py @@ -0,0 +1,860 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +"""Tests for the shared Predbat-led EV charger control mixin.""" + +import datetime +import os +import sys + +import pytz + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from tests.test_infra import run_async +from car_charger_control import CarChargerControl, GUEST_CHARGING_MAX_HOURS, OCTOPUS_DISCOVERY_WAIT_MINUTES, parse_control_setting, parse_dispatch_time + +LONDON = pytz.timezone("Europe/London") + + +class FakeStorage: + """In-memory stand-in for the Storage component.""" + + def __init__(self, saved=None, fail=False): + """Start with an optional saved record, or fail every call.""" + self.saved = dict(saved or {}) + self.fail = fail + + async def save(self, module, key, value): + """Record a save.""" + if self.fail: + raise OSError("disk full") + self.saved[(module, key)] = value + + async def load(self, module, key): + """Return what was saved.""" + if self.fail: + raise OSError("disk gone") + return self.saved.get((module, key)) + + +class FakeBase: + """The parts of the base object the mixin reads.""" + + def __init__(self): + """Not read only unless a test says so.""" + self.set_read_only = False + self.num_cars = 1 + + +class FakeCharger: + """A charger that can tell whether a car is connected and what state it is in.""" + + def __init__(self, key, connected=True): + """A charger that is currently off.""" + self.key = key + self.connected = connected + self.charging = False + + +class FakeComponent(CarChargerControl): + """A minimal charger component using the mixin, recording every command.""" + + def __init__(self, chargers, storage=None, drift_aware=False): + """Wire up a component with the given chargers.""" + self.base = FakeBase() + self.prefix = "predbat" + self.local_tz = LONDON + self.storage = storage + self.chargers = chargers + self.drift_aware = drift_aware + self.args = {} + self.plans = {} + # entity id -> attributes of the Intelligent dispatch sensors + self.sensors = {} + self.logs = [] + self.commands = [] + # Chargers whose commands are refused + self.refusing = set() + self.charger_control_setup("Fake", "charger", "fake", "control_state", "enabled") + self.charger_control_active = True + + @property + def num_cars(self): + """Mirror ComponentBase.num_cars.""" + return self.base.num_cars + + def log(self, message): + """Record a log line.""" + self.logs.append(message) + + def get_arg(self, name, default=None, **kwargs): + """Config args, as ComponentBase.get_arg.""" + return self.args.get(name, default) + + def get_state_wrapper(self, entity_id, default=None, attribute=None): + """The car charging slot plans, or an attribute of a dispatch sensor.""" + if attribute == "planned": + return self.plans.get(entity_id, default) + return self.sensors.get(entity_id, {}).get(attribute or "state", default) + + def charger_control_chargers(self): + """Chargers in car order.""" + return [(charger.key, charger) for charger in self.chargers] + + def charger_control_connected(self, handle): + """Use the fake's plug state.""" + return handle.connected + + def charger_control_drifted(self, handle, charge): + """Only when the test asks for a drift-aware charger.""" + return self.drift_aware and handle.charging != charge + + async def charger_control_send(self, handle, charge, car_n): + """Record and apply the command, unless this charger refuses it.""" + if handle.key in self.refusing: + raise RuntimeError("charger {} refused".format(handle.key)) + self.commands.append((handle.key, "on" if charge else "off", car_n)) + handle.charging = charge + + async def charger_control_release_one(self, handle, charge): + """Record the release, unless this charger refuses it.""" + if handle.key in self.refusing: + raise RuntimeError("charger {} refused".format(handle.key)) + self.commands.append((handle.key, "release", charge)) + + +def _now(): + """A fixed instant: 1 Jun 2026 01:30 London time.""" + return LONDON.localize(datetime.datetime(2026, 6, 1, 1, 30)) + + +def _plan(component, car_n, windows): + """Publish a plan for one car as output.py would.""" + postfix = "" if car_n == 0 else "_{}".format(car_n) + component.plans["binary_sensor.predbat_car_charging_slot" + postfix] = [{"start": start, "end": end} for start, end in windows] + + +def test_no_plan_sends_nothing(): + """Before any plan is published nothing is commanded - a restart must not stop a charge.""" + component = FakeComponent([FakeCharger("a")]) + run_async(component.charger_control_tick(_now())) + assert component.commands == [], component.commands + + +def test_on_inside_window_off_outside(): + """Charges inside a planned window and stops outside one, sending only on a change.""" + component = FakeComponent([FakeCharger("a")]) + _plan(component, 0, [("06-01 01:00:00", "06-01 02:00:00")]) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "on", 0)], component.commands + run_async(component.charger_control_tick(_now())) + assert len(component.commands) == 1, "An unchanged state must not be re-sent" + run_async(component.charger_control_tick(_now() + datetime.timedelta(hours=1))) + assert component.commands[-1] == ("a", "off", 0), component.commands + + +def test_empty_plan_stops(): + """A published but empty plan means hold the charger off.""" + component = FakeComponent([FakeCharger("a")]) + _plan(component, 0, []) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], component.commands + + +def test_each_charger_follows_its_own_car(): + """Charger N follows car N's plan.""" + component = FakeComponent([FakeCharger("a"), FakeCharger("b")]) + component.base.num_cars = 2 + _plan(component, 0, []) + _plan(component, 1, [("06-01 01:00:00", "06-01 02:00:00")]) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0), ("b", "on", 1)], component.commands + + +def test_charger_without_a_car_is_left_alone(): + """A charger beyond num_cars has no plan of its own and must not be stopped.""" + component = FakeComponent([FakeCharger("a"), FakeCharger("b")]) + _plan(component, 0, []) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], component.commands + + +def test_charger_whose_car_goes_is_released(): + """A held charger that falls beyond num_cars is handed back, not left with nobody in control.""" + component = FakeComponent([FakeCharger("a"), FakeCharger("b")]) + component.base.num_cars = 2 + _plan(component, 0, []) + _plan(component, 1, [("06-01 01:00:00", "06-01 02:00:00")]) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0), ("b", "on", 1)], component.commands + + component.base.num_cars = 1 + run_async(component.charger_control_tick(_now())) + run_async(component.charger_control_tick(_now())) + assert component.commands[2:] == [("b", "release", True)], component.commands + assert component.charger_control_state == {"a": False}, component.charger_control_state + + +def test_disconnected_charger_is_left_alone(): + """No car on the cable - nothing to command.""" + component = FakeComponent([FakeCharger("a", connected=False)]) + _plan(component, 0, []) + run_async(component.charger_control_tick(_now())) + assert component.commands == [], component.commands + + +def test_drift_is_reapplied(): + """A charger changed behind Predbat's back is set again.""" + charger = FakeCharger("a") + component = FakeComponent([charger], drift_aware=True) + _plan(component, 0, [("06-01 01:00:00", "06-01 02:00:00")]) + run_async(component.charger_control_tick(_now())) + charger.charging = False + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "on", 0), ("a", "on", 0)], component.commands + assert any("re-applying" in line for line in component.logs), component.logs + + +def test_read_only_releases_once_and_resumes(): + """Read only hands back only chargers Predbat moved, once, then control resumes when it clears.""" + # b has no car to follow, so Predbat never moves it and must not release it either + component = FakeComponent([FakeCharger("a"), FakeCharger("b")]) + _plan(component, 0, []) + run_async(component.charger_control_tick(_now())) + component.base.set_read_only = True + run_async(component.charger_control_tick(_now())) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0), ("a", "release", False)], component.commands + assert component.charger_control_state == {}, "Released chargers are forgotten" + component.base.set_read_only = False + run_async(component.charger_control_tick(_now())) + assert component.commands[-1] == ("a", "off", 0), component.commands + assert any("Read only mode cleared" in line for line in component.logs), component.logs + + +def test_read_only_falls_back_to_the_arg(): + """Before the base attribute is set the config arg decides.""" + component = FakeComponent([FakeCharger("a")]) + component.base.set_read_only = None + component.args["set_read_only"] = True + assert component.charger_control_read_only_now() is True + component.args["set_read_only"] = False + assert component.charger_control_read_only_now() is False + + +def test_switch_off_releases_and_persists(): + """Turning the switch off releases, and is saved so a restart keeps it off.""" + storage = FakeStorage() + component = FakeComponent([FakeCharger("a")], storage=storage) + _plan(component, 0, [("06-01 01:00:00", "06-01 02:00:00")]) + run_async(component.charger_control_tick(_now())) + run_async(component.charger_control_set_enabled(False)) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "on", 0), ("a", "release", True)], component.commands + assert storage.saved[("fake", "control_state")] == {"enabled": False} + + restarted = FakeComponent([FakeCharger("a")], storage=storage) + run_async(restarted.charger_control_load_enabled()) + assert restarted.charger_control_enabled is False, "The off must survive a restart" + + +def test_storage_failures_fail_soft(): + """A broken store logs a warning and leaves the switch on.""" + component = FakeComponent([FakeCharger("a")], storage=FakeStorage(fail=True)) + run_async(component.charger_control_load_enabled()) + run_async(component.charger_control_save_enabled()) + assert component.charger_control_enabled is True + assert sum("Warn" in line for line in component.logs) == 2, component.logs + + +def test_storage_save_returning_false_is_a_failed_save(): + """Storage reports serialisation and I/O failures by returning False, which must be warned about; None is a pass.""" + component = FakeComponent([FakeCharger("a")], storage=FakeStorage()) + + async def refused(module, key, value): + """A save that Storage refuses without raising.""" + return False + + component.storage.save = refused + run_async(component.charger_control_save_enabled()) + assert sum("Warn" in line for line in component.logs) == 1, component.logs + + for ok in (None, True): + component = FakeComponent([FakeCharger("a")], storage=FakeStorage()) + + async def accepted(module, key, value, ok=ok): + """A save that goes through, as None from a test double or True from the real Storage.""" + return ok + + component.storage.save = accepted + run_async(component.charger_control_save_enabled()) + assert not any("Warn" in line for line in component.logs), component.logs + + +def test_no_switch_without_storage_settings(): + """A component that passes no storage location has no persisted switch.""" + component = FakeComponent([FakeCharger("a")], storage=FakeStorage()) + component.charger_control_setup("Fake", "charger") + run_async(component.charger_control_save_enabled()) + run_async(component.charger_control_load_enabled()) + assert component.storage.saved == {} + + +def test_failed_release_is_retried(): + """A release that raises propagates to the component and is tried again next cycle. + + A charger is only forgotten once its release has gone through, so the retry still knows + which chargers Predbat was holding - and only the one that refused is released again. + """ + component = FakeComponent([FakeCharger("a"), FakeCharger("b")]) + component.base.num_cars = 2 + _plan(component, 0, []) + _plan(component, 1, []) + run_async(component.charger_control_tick(_now())) + + real_release = component.charger_control_release_one + + async def fail_on_b(handle, charge): + """Release a, refuse b.""" + if handle.key == "b": + raise OSError("refused") + await real_release(handle, charge) + + component.charger_control_release_one = fail_on_b + component.base.set_read_only = True + try: + run_async(component.charger_control_tick(_now())) + raise AssertionError("The failed release should reach the component") + except OSError: + pass + assert component.charger_control_released is None, "A failed release is not recorded as done" + assert set(component.charger_control_state) == {"b"}, component.charger_control_state + + component.charger_control_release_one = real_release + run_async(component.charger_control_tick(_now())) + releases = [command for command in component.commands if command[1] == "release"] + assert releases == [("a", "release", False), ("b", "release", False)], releases + assert component.charger_control_state == {} and component.charger_control_released is not None + + +def test_one_refusing_charger_does_not_block_the_others(): + """A charger that refuses is retried on its own - the others are still driven and released.""" + component = FakeComponent([FakeCharger("a"), FakeCharger("b")]) + component.base.num_cars = 2 + _plan(component, 0, []) + _plan(component, 1, []) + component.refusing = {"a"} + try: + run_async(component.charger_control_tick(_now())) + raise AssertionError("The refusal should still be raised for the run loop to log") + except RuntimeError: + pass + assert component.commands == [("b", "off", 1)], component.commands + + component.refusing = set() + run_async(component.charger_control_tick(_now())) + component.refusing = {"a"} + component.base.set_read_only = True + try: + run_async(component.charger_control_tick(_now())) + except RuntimeError: + pass + assert ("b", "release", False) in component.commands, component.commands + assert list(component.charger_control_state) == ["a"], "Only the refusing charger is still held: {}".format(component.charger_control_state) + + +def test_inactive_does_nothing(): + """Control that was never enabled neither commands nor releases.""" + component = FakeComponent([FakeCharger("a")]) + component.charger_control_active = False + _plan(component, 0, []) + component.base.set_read_only = True + run_async(component.charger_control_tick(_now())) + component.base.set_read_only = False + run_async(component.charger_control_tick(_now())) + assert component.commands == [], component.commands + + +class FakeOctopus: + """The parts of the Octopus component the Octopus rule reads.""" + + def __init__(self, configured=True, automatic=True): + """configured False means discovery has not wired the car slots yet.""" + self.automatic = automatic + self.intelligent_config_devices = [] if configured else None + + +class FakeKraken: + """The part of the Kraken component the Octopus rule reads.""" + + def __init__(self, started): + """started False means its first run has not succeeded yet.""" + self.api_started = started + + +class FakeComponents: + """Component registry holding at most an Octopus and a Kraken component.""" + + def __init__(self, octopus, kraken=None): + """Wrap the given components, or None.""" + self.octopus = octopus + self.kraken = kraken + + def get_component(self, name): + """Only Octopus and Kraken are known.""" + return {"octopus": self.octopus, "kraken": self.kraken}.get(name) + + +DISPATCH = "binary_sensor.predbat_octopus_intelligent_dispatch" + + +def _octopus_component(octopus=None, is_charger=None, control=None, wired=True): + """A component for car 0's charger with the given Octopus arrangement. + + is_charger is what car 0's dispatch sensor says, None for a sensor that does not say. + """ + component = FakeComponent([FakeCharger("a")]) + component.charger_control_config = control + component.base.components = FakeComponents(octopus) + component.base.car_slot_owner = None + if wired: + component.args["octopus_intelligent_slot"] = [DISPATCH] + component.sensors[DISPATCH] = {} if is_charger is None else {"is_charger": is_charger} + _plan(component, 0, []) + return component + + +def test_parse_control_setting(): + """Control settings arrive unconverted, so quoted and numeric values must still read right.""" + for value, expected in ((None, None), (True, True), (False, False), ("false", False), ("False", False), ("off", False), ("true", True), ("on", True), (0, False), (1, True)): + assert parse_control_setting(value) is expected, (value, parse_control_setting(value)) + + +def test_octopus_rule_without_octopus_drives(): + """No Octopus Intelligent car at all - Predbat drives the charger.""" + component = _octopus_component(wired=False) + assert component.charger_control_octopus_drives_charger(0) is False + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], component.commands + + +def test_octopus_rule_car_integrated_drives(): + """Octopus drives the car, not the charger - Predbat drives the charger to match the dispatches, + but only with octopus_intelligent_charger_follows_car on.""" + component = _octopus_component(FakeOctopus(), is_charger=False) + component.args["octopus_intelligent_charger_follows_car"] = True + assert component.charger_control_octopus_drives_charger(0) is False + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], component.commands + + +def test_octopus_rule_car_integrated_left_alone_by_default(): + """Octopus drives the car - with octopus_intelligent_charger_follows_car off (the default) the charger + is left alone, and one Predbat held stopped is released as usual: nobody else drives the charger.""" + component = _octopus_component(FakeOctopus(), wired=False) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], component.commands + + component.args["octopus_intelligent_slot"] = [DISPATCH] + component.sensors[DISPATCH] = {"is_charger": False, "state": "on"} + run_async(component.charger_control_tick(_now())) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0), ("a", "release", False)], "Released once, then left alone: {}".format(component.commands) + assert component.charger_control_state == {} + assert sum("drives car 0 itself" in line and "octopus_intelligent_charger_follows_car" in line for line in component.logs) == 1, component.logs + + # With Octopus Intelligent charging off in Predbat, Predbat plans the car and drives the charger to that plan + component.args["octopus_intelligent_charging"] = False + run_async(component.charger_control_tick(_now())) + assert component.commands[-1] == ("a", "off", 0) and len(component.commands) == 3, component.commands + del component.args["octopus_intelligent_charging"] + component.charger_control_state = {} + + # Turned on: Predbat drives the charger to the running dispatch + component.args["octopus_intelligent_charger_follows_car"] = True + run_async(component.charger_control_tick(_now())) + assert component.commands[-1] == ("a", "on", 0), component.commands + assert any("no longer left to Octopus" in line for line in component.logs), component.logs + + +def test_octopus_rule_charge_point_hands_off_without_starting(): + """Octopus drives the charger itself - Predbat lets go of a charger it had stopped without + starting it, and says so once.""" + component = _octopus_component(wired=False) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], component.commands + + component.args["octopus_intelligent_slot"] = [DISPATCH] + component.sensors[DISPATCH] = {"is_charger": True} + run_async(component.charger_control_tick(_now())) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], "A stopped charger is left for Octopus, not started: {}".format(component.commands) + assert component.charger_control_state == {} + assert sum("leaving it to Octopus" in line for line in component.logs) == 1, component.logs + + # Octopus Intelligent turned off in Predbat only changes Predbat's planning - Octopus still + # drives the charger, so Predbat still leaves it alone rather than fight + component.args["octopus_intelligent_charging"] = False + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], component.commands + + # The charger stops being the Octopus device, and the user has Predbat follow the car - Predbat takes it back + component.args["octopus_intelligent_charger_follows_car"] = True + component.sensors[DISPATCH] = {"is_charger": False} + run_async(component.charger_control_tick(_now())) + assert component.commands[-1] == ("a", "off", 0) and len(component.commands) == 2, component.commands + assert any("no longer left to Octopus" in line for line in component.logs), component.logs + + +def test_octopus_rule_charge_point_releases_a_running_charger(): + """A charger Predbat had running is released as usual when Octopus takes it over.""" + component = _octopus_component(wired=False) + _plan(component, 0, [("06-01 01:00:00", "06-01 02:00:00")]) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "on", 0)], component.commands + + component.args["octopus_intelligent_slot"] = [DISPATCH] + component.sensors[DISPATCH] = {"is_charger": True} + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "on", 0), ("a", "release", True)], component.commands + + +def test_octopus_rule_car_integrated_follows_the_dispatch_sensor(): + """Octopus drives the car - the charger runs while a dispatch is on, even before the plan shows it.""" + component = _octopus_component(FakeOctopus(), is_charger=False) + component.args["octopus_intelligent_charger_follows_car"] = True + component.sensors[DISPATCH]["state"] = "on" + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "on", 0)], component.commands + + component.sensors[DISPATCH]["state"] = "off" + run_async(component.charger_control_tick(_now())) + assert component.commands[-1] == ("a", "off", 0), component.commands + + # With octopus_intelligent_charging off Predbat follows only its own plan + component.args["octopus_intelligent_charging"] = False + component.sensors[DISPATCH]["state"] = "on" + run_async(component.charger_control_tick(_now())) + assert component.commands[-1] == ("a", "off", 0) and len(component.commands) == 2, component.commands + + +def test_dispatch_times_judged_against_the_clock(): + """The dispatch times on the sensor decide, not its on/off state, which lags the end of a + dispatch by up to a refresh. Octopus and Kraken write the times differently.""" + component = _octopus_component(FakeOctopus(), is_charger=False) + now = _now() # 01:30 London, 00:30 UTC + component.sensors[DISPATCH]["state"] = "on" + # Octopus format - ended at 00:30 UTC, so the stale "on" must not keep the charger running + component.sensors[DISPATCH]["completed_dispatches"] = [{"start": "2026-06-01T00:00:00+0000", "end": "2026-06-01T00:30:00+0000"}] + assert component.charger_control_dispatch_active(0, now) is False + # Kraken format - running now + component.sensors[DISPATCH]["planned_dispatches"] = [{"start": "2026-06-01T00:15:00Z", "end": "2026-06-01T01:00:00Z"}] + assert component.charger_control_dispatch_active(0, now) is True + # A sensor with no dispatch times falls back to its state + component.sensors[DISPATCH] = {"is_charger": False, "state": "on"} + assert component.charger_control_dispatch_active(0, now) is True + assert parse_dispatch_time("not a time") is None and parse_dispatch_time(None) is None + # No offset: read as UTC rather than raising when compared with the clock + component.sensors[DISPATCH] = {"is_charger": False, "state": "off", "planned_dispatches": [{"start": "2026-06-01T00:15:00", "end": "2026-06-01T01:00:00"}]} + assert component.charger_control_dispatch_active(0, now) is True + + +def test_is_charger_that_has_lost_its_value_is_not_a_no(): + """A dispatch sensor reporting is_charger as unknown or unavailable is cannot tell, not 'not the charger'.""" + for lost in ("unknown", "unavailable", "none", ""): + component = _octopus_component(FakeOctopus(), is_charger=lost) + assert component.charger_control_octopus_drives_charger(0) is None, lost + + +def test_guest_switch_toggle(): + """A toggle flips guest charging rather than turning it off.""" + component = FakeComponent([FakeCharger("a")]) + component.charger_control_switch_prefix = "fake" + run_async(component.charger_control_guest_event("switch.predbat_fake_guest_charging", "toggle")) + assert component.charger_control_guest is True + run_async(component.charger_control_guest_event("switch.predbat_fake_guest_charging", "toggle")) + assert component.charger_control_guest is False + + +def test_octopus_rule_explicit_true_never_overrides_a_charge_point(): + """control: true does not make Predbat fight Octopus for a charger Octopus is known to drive.""" + component = _octopus_component(FakeOctopus(), is_charger=True, control=True) + run_async(component.charger_control_tick(_now())) + assert component.commands == [], component.commands + + +def test_octopus_rule_unknown_hands_off_unless_told(): + """A dispatch sensor that does not say (the Octopus Energy integration) - hands off unless control: true.""" + component = _octopus_component(None) + assert component.charger_control_octopus_drives_charger(0) is None + run_async(component.charger_control_tick(_now())) + assert component.commands == [], component.commands + warnings = [line for line in component.logs if line.startswith("Warn") and "cannot tell" in line] + assert len(warnings) == 1, component.logs + assert not any("leaving it to Octopus" in line for line in component.logs), component.logs + run_async(component.charger_control_tick(_now())) + assert sum(line.startswith("Warn") for line in component.logs) == 1, "Warned once, not every cycle: {}".format(component.logs) + + named = _octopus_component(None) + named.charger_control_setting = "ge_cloud_evc_control" + run_async(named.charger_control_tick(_now())) + assert any("Set ge_cloud_evc_control: true" in line for line in named.logs), named.logs + + told = _octopus_component(None, control=True) + run_async(told.charger_control_tick(_now())) + assert told.commands == [("a", "off", 0)], told.commands + + +def test_octopus_rule_waits_for_octopus_discovery(): + """The Octopus component has not wired its devices yet - nothing is commanded until it has.""" + component = _octopus_component(FakeOctopus(configured=False), wired=False) + assert component.charger_control_octopus_drives_charger(0) is None + run_async(component.charger_control_tick(_now())) + assert component.commands == [], component.commands + assert any("waiting for the Octopus or Kraken component" in line for line in component.logs), component.logs + assert not any(line.startswith("Warn") for line in component.logs), "Waiting for discovery is not worth a warning: {}".format(component.logs) + + # Discovery found no Intelligent devices - there is nothing for Octopus to drive + component.base.components = FakeComponents(FakeOctopus()) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], component.commands + + +def test_octopus_rule_waits_for_kraken(): + """Kraken wires its slots in its first successful run - until then nothing is commanded.""" + component = _octopus_component(None, wired=False) + component.base.components = FakeComponents(None, FakeKraken(started=False)) + assert component.charger_control_octopus_drives_charger(0) is None + run_async(component.charger_control_tick(_now())) + assert component.commands == [], component.commands + + component.base.components = FakeComponents(None, FakeKraken(started=True)) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], "Kraken started and wired nothing for this car: {}".format(component.commands) + + +def test_octopus_rule_discovery_wait_is_bounded(): + """A Kraken component whose first run never succeeds (a failed login, say) is not waited on for good: + once the wait is over Predbat cannot tell, warns, and hands back a charger it holds - never driving it, + as Kraken may be the one driving it.""" + component = _octopus_component(wired=False) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], component.commands + + # Kraken restarts: the charger Predbat stopped is left as it is, and still held + component.base.components = FakeComponents(None, FakeKraken(started=False)) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], "Nothing sent while discovery is under way: {}".format(component.commands) + assert component.charger_control_state == {"a": False}, component.charger_control_state + assert any("waiting for the Octopus or Kraken component" in line for line in component.logs), component.logs + assert not any(line.startswith("Warn") for line in component.logs), component.logs + + # It never comes back + component.charger_control_discovery_since = datetime.datetime.now() - datetime.timedelta(minutes=OCTOPUS_DISCOVERY_WAIT_MINUTES + 1) + assert component.charger_control_octopus_drives_charger(0) is None, "Still cannot tell, so Predbat does not take it over" + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0), ("a", "release", False)], "The stop is undone rather than left: {}".format(component.commands) + assert component.charger_control_state == {} + assert sum(line.startswith("Warn") and "has not found its devices" in line for line in component.logs) == 1, component.logs + run_async(component.charger_control_tick(_now())) + assert len(component.commands) == 2 and sum(line.startswith("Warn") for line in component.logs) == 1, "Left alone, and warned once: {}".format(component.logs) + + # Discovery that finishes resets the wait, so a later restart of the component waits afresh + component.base.components = FakeComponents(None, FakeKraken(started=True)) + component.charger_control_octopus_discovering() + assert component.charger_control_discovery_since is None + run_async(component.charger_control_tick(_now())) + assert component.commands[-1] == ("a", "off", 0), "Predbat drives it again once discovery has wired nothing for it: {}".format(component.commands) + + # A wait left over from before a charger's driver became known does not cut the next wait short + wired = _octopus_component(None, is_charger=False) + wired.charger_control_discovery_since = datetime.datetime.now() - datetime.timedelta(minutes=OCTOPUS_DISCOVERY_WAIT_MINUTES + 1) + run_async(wired.charger_control_tick(_now())) + assert wired.charger_control_discovery_since is None, wired.charger_control_discovery_since + + +def test_octopus_rule_cannot_tell_releases_a_held_charger(): + """A charger Predbat holds stopped is released, not just let go, when nobody is known to take it over.""" + component = _octopus_component(wired=False) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], component.commands + component.args["octopus_intelligent_slot"] = [DISPATCH] + component.sensors[DISPATCH] = {} + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0), ("a", "release", False)], "A stopped charger is started again, not stranded: {}".format(component.commands) + assert component.charger_control_state == {} + + +def test_octopus_rule_unmatched_regex_is_not_a_sensor(): + """The apps.yaml default's literal "re:" string, before its regex has matched, is not a dispatch sensor to ask.""" + component = _octopus_component(wired=False) + component.args["octopus_intelligent_slot"] = ["re:(binary_sensor.octopus_energy_([0-9a-z_]+|)_intelligent_dispatching)"] + assert component.charger_control_octopus_drives_charger(0) is False + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], component.commands + assert not any(line.startswith("Warn") for line in component.logs), component.logs + + +def test_octopus_rule_does_not_wait_without_octopus_automatic(): + """With octopus_automatic off the Octopus component never wires the slots, so there is nothing to wait for.""" + component = _octopus_component(FakeOctopus(configured=False, automatic=False), wired=False) + assert component.charger_control_octopus_drives_charger(0) is False + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], component.commands + + +def test_octopus_rule_other_slot_owner_hands_off(): + """Another component (Ohme) supplies the Intelligent slots from the charger itself.""" + component = _octopus_component(None) + component.base.car_slot_owner = "ohme" + assert component.charger_control_octopus_drives_charger(0) is True + + +def test_octopus_rule_per_car(): + """Each car is judged on its own wired dispatch sensor, whatever order the slots are listed in + and whichever component (Octopus or Kraken) published them.""" + component = FakeComponent([FakeCharger("a"), FakeCharger("b")]) + component.base.num_cars = 2 + component.base.components = FakeComponents(None) + component.base.car_slot_owner = None + component.args["octopus_intelligent_slot"] = ["binary_sensor.predbat_kraken_intelligent_dispatch_z", "binary_sensor.predbat_kraken_intelligent_dispatch_a"] + component.sensors = { + "binary_sensor.predbat_kraken_intelligent_dispatch_z": {"is_charger": True}, + "binary_sensor.predbat_kraken_intelligent_dispatch_a": {"is_charger": False}, + } + component.args["octopus_intelligent_charger_follows_car"] = True + _plan(component, 0, []) + _plan(component, 1, []) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("b", "off", 1)], component.commands + + +def _guest_component(): + """A component with a guest switch, holding its charger stopped outside any window.""" + component = FakeComponent([FakeCharger("a")]) + component.charger_control_switch_prefix = "fake" + _plan(component, 0, []) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0)], component.commands + return component + + +def test_guest_charging_releases_and_resumes(): + """Guest charging hands the charger back so the guest can charge, and Predbat takes it back after.""" + component = _guest_component() + assert run_async(component.charger_control_guest_event("switch.predbat_fake_guest_charging", "turn_on")) is True + assert run_async(component.charger_control_guest_event("switch.predbat_fake_other", "turn_on")) is False + run_async(component.charger_control_tick(_now())) + run_async(component.charger_control_tick(_now())) + assert component.commands == [("a", "off", 0), ("a", "release", False)], component.commands + + run_async(component.charger_control_guest_event("switch.predbat_fake_guest_charging", "turn_off")) + run_async(component.charger_control_tick(_now())) + assert component.commands[-1] == ("a", "off", 0), component.commands + + +def test_guest_charging_ends_when_the_car_is_unplugged(): + """A charger that can tell ends guest charging when a connected car is unplugged - but not + before the guest has plugged in.""" + component = _guest_component() + charger = component.chargers[0] + charger.connected = False + component.charger_control_set_guest(True) + run_async(component.charger_control_tick(_now())) + assert component.charger_control_guest is True, "Turned on before the guest arrived, so nothing has been unplugged yet" + + charger.connected = True + run_async(component.charger_control_tick(_now())) + charger.connected = False + run_async(component.charger_control_tick(_now())) + assert component.charger_control_guest is False + assert any("the guest's car was unplugged" in line for line in component.logs), component.logs + + +def test_guest_charging_survives_the_owner_unplugging(): + """The owner's car, already on the charger when guest charging went on, is unplugged to make + way for the guest - that must not end guest charging.""" + component = _guest_component() + charger = component.chargers[0] + component.charger_control_set_guest(True) + run_async(component.charger_control_tick(_now())) + charger.connected = False + run_async(component.charger_control_tick(_now())) + assert component.charger_control_guest is True, "The owner's unplug must not end guest charging" + + # The guest plugs in, charges, and leaves + charger.connected = True + run_async(component.charger_control_tick(_now())) + charger.connected = False + run_async(component.charger_control_tick(_now())) + assert component.charger_control_guest is False + + +def test_guest_charging_times_out(): + """A charger that cannot tell a car was unplugged ends guest charging after the time limit.""" + component = _guest_component() + component.charger_control_set_guest(True) + run_async(component.charger_control_tick(_now())) + run_async(component.charger_control_tick(_now() + datetime.timedelta(hours=GUEST_CHARGING_MAX_HOURS - 1))) + assert component.charger_control_guest is True + run_async(component.charger_control_tick(_now() + datetime.timedelta(hours=GUEST_CHARGING_MAX_HOURS))) + assert component.charger_control_guest is False + assert component.commands[-1] == ("a", "off", 0), "Predbat drives the charger again: {}".format(component.commands) + + +def test_no_guest_switch_without_a_prefix(): + """A component that did not ask for a guest switch has none.""" + component = FakeComponent([FakeCharger("a")]) + assert component.charger_control_guest_entity() is None + assert run_async(component.charger_control_guest_event("switch.predbat_fake_guest_charging", "turn_on")) is False + + +def run_car_charger_control_tests(my_predbat=None): + """Run the shared charger control tests. Returns True on failure.""" + print("**** Running car charger control tests ****") + test_no_plan_sends_nothing() + test_on_inside_window_off_outside() + test_empty_plan_stops() + test_each_charger_follows_its_own_car() + test_charger_without_a_car_is_left_alone() + test_charger_whose_car_goes_is_released() + test_disconnected_charger_is_left_alone() + test_drift_is_reapplied() + test_read_only_releases_once_and_resumes() + test_read_only_falls_back_to_the_arg() + test_switch_off_releases_and_persists() + test_storage_failures_fail_soft() + test_storage_save_returning_false_is_a_failed_save() + test_no_switch_without_storage_settings() + test_failed_release_is_retried() + test_one_refusing_charger_does_not_block_the_others() + test_inactive_does_nothing() + test_parse_control_setting() + test_octopus_rule_without_octopus_drives() + test_octopus_rule_car_integrated_drives() + test_octopus_rule_car_integrated_left_alone_by_default() + test_octopus_rule_charge_point_hands_off_without_starting() + test_octopus_rule_charge_point_releases_a_running_charger() + test_octopus_rule_car_integrated_follows_the_dispatch_sensor() + test_dispatch_times_judged_against_the_clock() + test_is_charger_that_has_lost_its_value_is_not_a_no() + test_guest_switch_toggle() + test_octopus_rule_explicit_true_never_overrides_a_charge_point() + test_octopus_rule_unknown_hands_off_unless_told() + test_octopus_rule_waits_for_octopus_discovery() + test_octopus_rule_does_not_wait_without_octopus_automatic() + test_octopus_rule_waits_for_kraken() + test_octopus_rule_discovery_wait_is_bounded() + test_octopus_rule_cannot_tell_releases_a_held_charger() + test_octopus_rule_unmatched_regex_is_not_a_sensor() + test_octopus_rule_other_slot_owner_hands_off() + test_octopus_rule_per_car() + test_guest_charging_releases_and_resumes() + test_guest_charging_ends_when_the_car_is_unplugged() + test_guest_charging_survives_the_owner_unplugging() + test_guest_charging_times_out() + test_no_guest_switch_without_a_prefix() + return False diff --git a/apps/predbat/tests/test_ge_cloud.py b/apps/predbat/tests/test_ge_cloud.py index e91c88c96..27cd9b5f9 100644 --- a/apps/predbat/tests/test_ge_cloud.py +++ b/apps/predbat/tests/test_ge_cloud.py @@ -11,7 +11,7 @@ from gecloud import GECloudDirect, GECloudData, regname_to_ha from gecloud import GE_API_ACCOUNT, GE_API_DEVICES, GE_API_EVC_SEND_COMMAND, GE_API_INVERTER_WRITE_SETTING, GE_API_SITE -from gecloud import GECloudTerminalError, SITE_MAX_AGE_MINUTES, parse_site_export_limit +from gecloud import EVCCommandFailed, GECloudTerminalError, SITE_MAX_AGE_MINUTES, parse_site_export_limit from gecloud import DEVICE_REFRESH_SECONDS, SETTINGS_SLOW_REFRESH_SECONDS, find_ems_slot_overrides, normalise_register_time from utils import dp4 import asyncio @@ -57,12 +57,9 @@ def __init__(self): self.evc_sessions = {} self.evc_status_unknown = set() self.automatic_evc = False - self.evc_control = False - self.evc_control_active = False - self.evc_control_enabled = True - self.evc_control_released = False - self.evc_control_state = {} - self.evc_control_windows = {} + # Unset, as components.py leaves it when apps.yaml does not mention it + self.evc_control = None + self.charger_control_setup("GECloud", "EV charger", "gecloud", "evc_control_state", "evc_control_enabled", switch_prefix="gecloud") self.entity_states = {} self.entity_attributes = {} self.pending_writes = {} @@ -131,7 +128,9 @@ def dashboard_item(self, entity_id, state, attributes, app=None): self.dashboard_items[entity_id] = {"state": state, "attributes": attributes} def get_arg(self, name, default=None, **kwargs): - """Mock get_arg""" + """Mock get_arg - set_read_only answers from the same flag as the read only switch, as it does in Predbat""" + if name == "set_read_only": + return self._read_only return self.config_args.get(name, default) def set_arg(self, name, value): @@ -3456,7 +3455,7 @@ async def test(): print("ERROR: EVC automatic config should re-run once, got {}".format(calls["automatic_evc"])) return 1 - ge_cloud.evc_control_state["evc-1"] = "start" + ge_cloud.charger_control_state["evc-1"] = True api["evc"] = [{"uuid": "evc-2", "alias": "Second"}] _reset_rediscovery_calls(calls) await ge_cloud.run(seconds=DEVICE_REFRESH_SECONDS * 2, first=False) @@ -3466,7 +3465,7 @@ async def test(): if "evc-1" in calls["evc_polled"]: print("ERROR: the removed charger should not be polled, got {}".format(calls["evc_polled"])) return 1 - for name in ("evc_device", "evc_data", "evc_sessions", "evc_control_state"): + for name in ("evc_device", "evc_data", "evc_sessions", "charger_control_state"): if "evc-1" in getattr(ge_cloud, name): print("ERROR: {} should no longer hold the removed charger".format(name)) return 1 @@ -6324,17 +6323,26 @@ async def test(): outside = tz.localize(datetime(2026, 8, 23, 6, 0)) plan = {EVC_PLAN_SENSOR: {"planned": [{"start": "08-22 23:00:00", "end": "08-23 05:00:00"}]}} - # Test 1: control stays off unless it is asked for + # Test 1: unset control follows ge_cloud_automatic_evc, and an explicit false keeps it off + ge = MockGECloudDirect() + ge.automatic_evc = True + ge.evc_control_enable() + assert ge.charger_control_active is True, "Unset ge_cloud_evc_control should turn on with ge_cloud_automatic_evc" ge = MockGECloudDirect() ge.automatic_evc = True + ge.evc_control = False ge.evc_control_enable() - assert ge.evc_control_active is False, "Control should be off without ge_cloud_evc_control" + assert ge.charger_control_active is False, "An explicit ge_cloud_evc_control: false keeps control off" + ge = MockGECloudDirect() + ge.evc_control_enable() + assert ge.charger_control_active is False, "Unset control stays off without ge_cloud_automatic_evc" + assert not any("Warn" in message for message in ge.log_messages), "Unset control is not a request, so its absence is not warned about" - # Test 2: and refuses to run without the auto-config that maps chargers to cars + # Test 2: an explicit request refuses to run without the auto-config that maps chargers to cars ge = MockGECloudDirect() ge.evc_control = True ge.evc_control_enable() - assert ge.evc_control_active is False, "Control needs ge_cloud_automatic_evc to know which charger is which car" + assert ge.charger_control_active is False, "Control needs ge_cloud_automatic_evc to know which charger is which car" assert any("ge_cloud_automatic_evc" in message for message in ge.log_messages), "The reason control is off should be logged" # Test 3: a planned window starts the charger, and is not re-sent every poll @@ -6344,27 +6352,27 @@ async def test(): ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "charging"}} ge.entity_attributes = plan - await ge.evc_control_charge(inside) + await ge.charger_control_apply(inside) assert commands == [("evc-001", "start-charge")], "A planned window should start the charge, got {}".format(commands) commands.clear() - await ge.evc_control_charge(inside) + await ge.charger_control_apply(inside) assert commands == [], "The same state should not be re-sent, got {}".format(commands) # Test 4: outside the window the charger is stopped commands.clear() - await ge.evc_control_charge(outside) + await ge.charger_control_apply(outside) assert commands == [("evc-001", "stop-charge")], "Outside a window the charge should stop, got {}".format(commands) # Test 5: read only mode hands the charger back, once commands.clear() ge._read_only = True - await ge.evc_control_tick(outside) + await ge.charger_control_tick(outside) assert commands == [("evc-001", "start-charge")], "Releasing should hand a stopped charger back, got {}".format(commands) - assert ge.evc_control_released is True, "The release should be remembered" + assert ge.charger_control_released is not None, "The release should be remembered" commands.clear() - await ge.evc_control_tick(outside) + await ge.charger_control_tick(outside) assert commands == [], "A release should happen once, not every cycle" # Test 6: turning the control switch off releases in the same way @@ -6373,20 +6381,48 @@ async def test(): ge.evc_device_list = ["evc-001"] ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "charging"}} ge.entity_attributes = plan - await ge.evc_control_charge(outside) + await ge.charger_control_apply(outside) commands.clear() await ge.switch_event("switch.predbat_gecloud_evc_control", "turn_off") - assert ge.evc_control_enabled is False, "The switch should turn control off" - await ge.evc_control_tick(outside) + assert ge.charger_control_enabled is False, "The switch should turn control off" + await ge.charger_control_tick(outside) assert commands == [("evc-001", "start-charge")], "Switching control off should release the charger, got {}".format(commands) + # Test 6b: the guest charging switch releases in the same way, and ends when the guest unplugs - + # but not when the owner's car, on the charger when it went on, is unplugged to make way + commands = [] + ge = _evc_control_component(commands) + ge.evc_device_list = ["evc-001"] + ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "charging"}} + ge.entity_attributes = plan + await ge.charger_control_apply(outside) + commands.clear() + await ge.switch_event("switch.predbat_gecloud_guest_charging", "turn_on") + assert ge.charger_control_guest is True, "The guest switch should turn guest charging on" + await ge.charger_control_tick(outside) + assert commands == [("evc-001", "start-charge")], "Guest charging should release the charger, got {}".format(commands) + ge.evc_device["evc-001"]["status"] = "idle" + await ge.charger_control_tick(outside) + assert ge.charger_control_guest is True, "The owner's car being unplugged should not end guest charging" + ge.evc_device["evc-001"]["status"] = "charging" + await ge.charger_control_tick(outside) + # A comms blip is not an unplug + ge.evc_device["evc-001"]["status"] = "Unavailable" + await ge.charger_control_tick(outside) + assert ge.charger_control_guest is True, "An unavailable status should not end guest charging" + ge.evc_device["evc-001"]["status"] = "charging" + await ge.charger_control_tick(outside) + ge.evc_device["evc-001"]["status"] = "idle" + await ge.charger_control_tick(outside) + assert ge.charger_control_guest is False, "Unplugging the guest's car should end guest charging" + # Test 7: nothing is commanded while no car is plugged in commands = [] ge = _evc_control_component(commands) ge.evc_device_list = ["evc-001"] ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "idle"}} ge.entity_attributes = plan - await ge.evc_control_charge(inside) + await ge.charger_control_apply(inside) assert commands == [], "An empty charger should not be commanded, got {}".format(commands) # Test 8: nothing is commanded before Predbat has published a plan, so a restart @@ -6395,7 +6431,7 @@ async def test(): ge = _evc_control_component(commands) ge.evc_device_list = ["evc-001"] ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "charging"}} - await ge.evc_control_charge(inside) + await ge.charger_control_apply(inside) assert commands == [], "With no plan published nothing should be commanded, got {}".format(commands) # Test 9: charger N is car N by serial order, matching the automatic configuration @@ -6408,7 +6444,7 @@ async def test(): } ge.entity_attributes = {EVC_PLAN_SENSOR: plan[EVC_PLAN_SENSOR], EVC_PLAN_SENSOR_CAR_1: {"planned": []}} - await ge.evc_control_charge(inside) + await ge.charger_control_apply(inside) assert sorted(commands) == sorted([("evc-second", "start-charge"), ("evc-first", "stop-charge")]), "The lower serial should be car 0, got {}".format(commands) # Test 10: a charger with no car index yet is left alone rather than stopped. @@ -6423,9 +6459,52 @@ async def test(): } ge.entity_attributes = {EVC_PLAN_SENSOR: plan[EVC_PLAN_SENSOR]} - await ge.evc_control_charge(inside) + await ge.charger_control_apply(inside) assert commands == [("evc-first", "start-charge")], "Only the charger with a car should be commanded, got {}".format(commands) + # Test 11: a command GE Cloud refuses is not recorded as sent, so it is tried again + commands = [] + ge = _evc_control_component(commands) + ge.evc_device_list = ["evc-001"] + ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "charging"}} + ge.entity_attributes = plan + refused = [] + + async def refuse(uuid, command, params): + """Refuse the command, as async_send_evc_command reports it: None.""" + refused.append((uuid, command)) + return None + + real_send = ge.async_send_evc_command + ge.async_send_evc_command = refuse + try: + await ge.charger_control_apply(outside) + assert False, "A refused command should raise" + except EVCCommandFailed: + pass + assert ge.charger_control_state == {}, "A refused stop must not be recorded, got {}".format(ge.charger_control_state) + + ge.async_send_evc_command = real_send + await ge.charger_control_apply(outside) + assert commands == [("evc-001", "stop-charge")], "The stop should be retried, got {}".format(commands) + + # Test 12: a refused release is not latched as done, so the stopped charger is not stranded + commands.clear() + ge._read_only = True + ge.async_send_evc_command = refuse + try: + await ge.charger_control_tick(outside) + assert False, "A refused release should raise" + except EVCCommandFailed: + pass + assert ge.charger_control_released is None, "A refused release must not be recorded as done" + assert ge.charger_control_state == {"evc-001": False}, "Predbat still holds the stopped charger" + + ge.async_send_evc_command = real_send + await ge.charger_control_tick(outside) + assert commands == [("evc-001", "start-charge")], "The release should be retried, got {}".format(commands) + assert ge.charger_control_released is not None + return 0 return run_async(test()) diff --git a/apps/predbat/tests/test_myenergi.py b/apps/predbat/tests/test_myenergi.py index cd85d7725..1b8654860 100644 --- a/apps/predbat/tests/test_myenergi.py +++ b/apps/predbat/tests/test_myenergi.py @@ -966,11 +966,11 @@ def test_control_window_parsing(): inside = _plan_window(datetime.datetime(2026, 8, 22, 23, 0), datetime.datetime(2026, 8, 23, 1, 0)) component = _control_component(plans={0: [inside]}) - assert component.refresh_car_windows(CONTROL_TZ.localize(datetime.datetime(2026, 8, 22, 23, 30))) is True - assert component.should_charge_now(0, CONTROL_TZ.localize(datetime.datetime(2026, 8, 22, 23, 30))) is True - assert component.should_charge_now(0, CONTROL_TZ.localize(datetime.datetime(2026, 8, 22, 22, 59))) is False + assert component.charger_control_refresh_windows(CONTROL_TZ.localize(datetime.datetime(2026, 8, 22, 23, 30))) is True + assert component.charger_control_should_charge(0, CONTROL_TZ.localize(datetime.datetime(2026, 8, 22, 23, 30))) is True + assert component.charger_control_should_charge(0, CONTROL_TZ.localize(datetime.datetime(2026, 8, 22, 22, 59))) is False # The window end is exclusive, so the boundary minute is already outside - assert component.should_charge_now(0, CONTROL_TZ.localize(datetime.datetime(2026, 8, 23, 1, 0))) is False + assert component.charger_control_should_charge(0, CONTROL_TZ.localize(datetime.datetime(2026, 8, 23, 1, 0))) is False print(" ✓ Planned car charging windows are parsed and matched against the clock") @@ -985,25 +985,25 @@ def test_control_windows_across_new_year(): component = _control_component(plans={0: [crossing]}) before_midnight = CONTROL_TZ.localize(datetime.datetime(2026, 12, 31, 23, 30)) - assert component.refresh_car_windows(before_midnight) is True - assert component.should_charge_now(0, before_midnight) is True, "The window is active before midnight" + assert component.charger_control_refresh_windows(before_midnight) is True + assert component.charger_control_should_charge(0, before_midnight) is True, "The window is active before midnight" after_midnight = CONTROL_TZ.localize(datetime.datetime(2027, 1, 1, 0, 30)) - assert component.refresh_car_windows(after_midnight) is True - assert component.should_charge_now(0, after_midnight) is True, "The same window is still active after midnight" + assert component.charger_control_refresh_windows(after_midnight) is True + assert component.charger_control_should_charge(0, after_midnight) is True, "The same window is still active after midnight" ended = CONTROL_TZ.localize(datetime.datetime(2027, 1, 1, 6, 0)) - assert component.refresh_car_windows(ended) is True - assert component.should_charge_now(0, ended) is False, "The window has ended by 06:00" + assert component.charger_control_refresh_windows(ended) is True + assert component.charger_control_should_charge(0, ended) is False, "The window has ended by 06:00" # A window genuinely far ahead must not be dragged back a year by the rebuild - the # plan reaches 48 hours, well beyond the 23 hour margin the first version allowed ahead = _plan_window(datetime.datetime(2026, 8, 23, 20, 0), datetime.datetime(2026, 8, 24, 2, 0)) component = _control_component(plans={0: [ahead]}) now = CONTROL_TZ.localize(datetime.datetime(2026, 8, 22, 10, 0)) - assert component.refresh_car_windows(now) is True - assert component.should_charge_now(0, now) is False, "A window 34 hours ahead has not started" - assert component.should_charge_now(0, CONTROL_TZ.localize(datetime.datetime(2026, 8, 23, 21, 0))) is True, "...and is active once it arrives" + assert component.charger_control_refresh_windows(now) is True + assert component.charger_control_should_charge(0, now) is False, "A window 34 hours ahead has not started" + assert component.charger_control_should_charge(0, CONTROL_TZ.localize(datetime.datetime(2026, 8, 23, 21, 0))) is True, "...and is active once it arrives" print(" ✓ Windows spanning New Year are matched from both sides of midnight") @@ -1014,13 +1014,13 @@ def test_control_windows_are_per_car(): component = _control_component(plans={0: [car0], 1: [car1]}) now = CONTROL_TZ.localize(datetime.datetime(2026, 8, 22, 23, 30)) - assert component.refresh_car_windows(now) is True - assert component.should_charge_now(0, now) is True - assert component.should_charge_now(1, now) is False, "Car 1's window has not started yet" + assert component.charger_control_refresh_windows(now) is True + assert component.charger_control_should_charge(0, now) is True + assert component.charger_control_should_charge(1, now) is False, "Car 1's window has not started yet" later = CONTROL_TZ.localize(datetime.datetime(2026, 8, 23, 4, 30)) - assert component.should_charge_now(0, later) is False - assert component.should_charge_now(1, later) is True + assert component.charger_control_should_charge(0, later) is False + assert component.charger_control_should_charge(1, later) is True print(" ✓ Each car's plan drives its own Zappi") @@ -1034,12 +1034,12 @@ def test_control_windows_tolerate_a_bad_entry_and_a_missing_plan(): now = CONTROL_TZ.localize(datetime.datetime(2026, 8, 22, 23, 30)) component = _control_component(plans={0: [{"start": "nonsense"}, good]}) - assert component.refresh_car_windows(now) is True - assert component.should_charge_now(0, now) is True, "The good window must survive a bad neighbour" + assert component.charger_control_refresh_windows(now) is True + assert component.charger_control_should_charge(0, now) is True, "The good window must survive a bad neighbour" never_published = _control_component() - assert never_published.refresh_car_windows(now) is False - assert never_published.should_charge_now(0, now) is False + assert never_published.charger_control_refresh_windows(now) is False + assert never_published.charger_control_should_charge(0, now) is False print(" ✓ A malformed window is skipped and a missing plan is not acted on") @@ -1050,8 +1050,8 @@ def test_control_windows_cross_the_year_boundary(): component = _control_component(plans={0: [window]}) now = CONTROL_TZ.localize(datetime.datetime(2026, 12, 31, 23, 45)) - assert component.refresh_car_windows(now) is True - assert component.should_charge_now(0, now) is True, "A window straddling New Year must still match" + assert component.charger_control_refresh_windows(now) is True + assert component.charger_control_should_charge(0, now) is True, "A window straddling New Year must still match" print(" ✓ Windows crossing the year boundary are rebuilt around now") @@ -1075,10 +1075,10 @@ def test_control_charge_sets_fast_inside_and_stopped_outside(): component.devices = {"Z12345678": _zappi(12345678)} component.transport.set_mode = AsyncMock(return_value=True) - run_async(component.control_charge(IN_WINDOW)) + run_async(component.charger_control_apply(IN_WINDOW)) assert component.transport.set_mode.await_args.args[1] == "Fast", component.transport.set_mode.await_args - run_async(component.control_charge(OUT_OF_WINDOW)) + run_async(component.charger_control_apply(OUT_OF_WINDOW)) assert component.transport.set_mode.await_args.args[1] == "Stopped", component.transport.set_mode.await_args print(" ✓ Fast inside a planned window, Stopped outside it") @@ -1093,7 +1093,7 @@ def test_control_charge_maps_each_zappi_to_its_own_car(): component.devices = {"Z12345678": _zappi(12345678), "Z22223333": _zappi(22223333)} component.transport.set_mode = AsyncMock(return_value=True) - run_async(component.control_charge(IN_WINDOW)) + run_async(component.charger_control_apply(IN_WINDOW)) by_serial = {call.args[0].serial: call.args[1] for call in component.transport.set_mode.await_args_list} assert by_serial == {"12345678": "Fast", "22223333": "Stopped"}, by_serial print(" ✓ Each Zappi follows its own car's plan") @@ -1109,16 +1109,16 @@ def test_control_charge_is_edge_triggered_but_corrects_drift(): component.devices = {"Z12345678": _zappi(12345678)} component.transport.set_mode = AsyncMock(return_value=True) - run_async(component.control_charge(IN_WINDOW)) + run_async(component.charger_control_apply(IN_WINDOW)) assert component.transport.set_mode.await_count == 1 # The poll now reports Fast, matching what was set, so nothing more is sent component.devices["Z12345678"] = _zappi(12345678, mode_index=1) - run_async(component.control_charge(IN_WINDOW)) + run_async(component.charger_control_apply(IN_WINDOW)) assert component.transport.set_mode.await_count == 1, "A settled charger must not be re-commanded" # Someone switches it to Eco+ in the myenergi app - Predbat puts it back component.devices["Z12345678"] = _zappi(12345678, mode_index=3) - run_async(component.control_charge(IN_WINDOW)) + run_async(component.charger_control_apply(IN_WINDOW)) assert component.transport.set_mode.await_count == 2, "Drift away from the set mode must be corrected" assert component.transport.set_mode.await_args.args[1] == "Fast" print(" ✓ Control is edge triggered but corrects drift") @@ -1130,7 +1130,7 @@ def test_control_charge_ignores_eddis(): component.devices = {"E87654321": normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI)} component.transport.set_mode = AsyncMock(return_value=True) - run_async(component.control_charge(IN_WINDOW)) + run_async(component.charger_control_apply(IN_WINDOW)) component.transport.set_mode.assert_not_awaited() print(" ✓ Eddis are never driven by car charge control") @@ -1145,7 +1145,7 @@ def test_control_charge_does_nothing_before_a_plan_exists(): component.devices = {"Z12345678": _zappi(12345678)} component.transport.set_mode = AsyncMock(return_value=True) - run_async(component.control_charge(IN_WINDOW)) + run_async(component.charger_control_apply(IN_WINDOW)) component.transport.set_mode.assert_not_awaited() print(" ✓ Nothing is commanded before Predbat has published a plan") @@ -1166,7 +1166,7 @@ def _controlling_component(plans=None, **overrides): def test_control_gating_refuses_with_a_reason(): """Control only runs when it is asked for and can work, and says why when it will not.""" - assert _controlling_component().control_active is True + assert _controlling_component().charger_control_active is True for overrides, expected in ( ({"zappi_control": False}, None), @@ -1176,34 +1176,153 @@ def test_control_gating_refuses_with_a_reason(): ({"enable_controls": False}, "myenergi_enable_controls"), ): component = _controlling_component(**overrides) - assert component.control_active is False, overrides + assert component.charger_control_active is False, overrides if expected: assert any(expected in message for message in component.log_messages), (overrides, component.log_messages) print(" ✓ Zappi control refuses to run without its prerequisites, and says which") +def _unset_control_component(apps_yaml, **overrides): + """Build a component with myenergi_zappi_control unset and the given apps.yaml entries.""" + component = _control_component(**overrides) + component.base.args_from_apps_yaml = apps_yaml + component.devices = {"Z12345678": _zappi(12345678)} + component.log_messages = [] + component.log = component.log_messages.append + component.enable_control() + return component + + +def test_unset_control_follows_automatic_written_by_the_user(): + """Unset myenergi_zappi_control turns on only when the user wrote the automatic setting. + + Both automatic settings default on, so without this check every Zappi user would be + switched to Predbat control on upgrade. + """ + assert _unset_control_component({}).charger_control_active is False, "Nothing written: Zappi control stays off" + assert _unset_control_component({"myenergi_automatic": True}).charger_control_active is True + assert _unset_control_component({"myenergi_automatic_zappi": True}).charger_control_active is True + # An explicit false still wins over automatic written by the user + assert _unset_control_component({"myenergi_automatic": True}, zappi_control=False).charger_control_active is False + + # Following the automatic setting is not a request, so a missing prerequisite is not warned about + for overrides in ({"automatic": False}, {"automatic_zappi": False}, {"enable_controls": False}): + component = _unset_control_component({"myenergi_automatic": True}, **overrides) + assert component.charger_control_active is False, overrides + assert not any("Warn" in message for message in component.log_messages), (overrides, component.log_messages) + print(" ✓ Unset Zappi control follows myenergi_automatic only when the user wrote it") + + +def test_control_setting_strings_are_read_as_booleans(): + """myenergi_zappi_control has no default, so a quoted value must still read as the boolean it names.""" + assert _make_component(zappi_control="false").zappi_control is False + assert _make_component(zappi_control="true").zappi_control is True + assert _make_component(zappi_control=0).zappi_control is False + assert _make_component().zappi_control is None + print(" ✓ Quoted and numeric myenergi_zappi_control values read as booleans") + + +def test_control_hand_to_octopus_never_fast(): + """Handing a Zappi to Octopus restores its saved mode, but never Fast - that would start a charge.""" + component = _controlling_component() + device = component.devices["Z12345678"] + component.control_saved_modes[device.device_id] = "Fast" + run_async(component.charger_control_hand_to_octopus(device, False)) + assert component.transport.set_mode.await_args.args[1] == "Eco+", component.transport.set_mode.await_args + assert device.device_id not in component.control_saved_modes + + component.control_saved_modes[device.device_id] = "Eco" + run_async(component.charger_control_hand_to_octopus(device, False)) + assert component.transport.set_mode.await_args.args[1] == "Eco", component.transport.set_mode.await_args + print(" ✓ A Zappi handed to Octopus goes back to its own mode, never Fast") + + def test_control_releases_to_the_saved_mode(): """Releasing puts the Zappi back where it was before Predbat first moved it.""" component = _controlling_component(plans={0: [NIGHT_WINDOW]}) - run_async(component.control_tick(IN_WINDOW)) + run_async(component.charger_control_tick(IN_WINDOW)) assert component.transport.set_mode.await_args.args[1] == "Fast" # The switch going off is a release, not just a pause in commanding - component.control_enabled = False - run_async(component.control_tick(IN_WINDOW)) + component.charger_control_enabled = False + run_async(component.charger_control_tick(IN_WINDOW)) assert component.transport.set_mode.await_args.args[1] == "Eco+", "The Zappi was in Eco+ before Predbat took over" - assert component.control_modes == {}, "A released Zappi is no longer held" + assert component.charger_control_state == {}, "A released Zappi is no longer held" print(" ✓ Releasing restores the mode the Zappi had before Predbat took over") +def test_control_release_forgets_the_saved_mode(): + """A Zappi released on its own (its car gone) forgets the mode it was saved in, so taking it + back later snapshots the mode the user has set since.""" + component = _controlling_component(plans={0: [NIGHT_WINDOW], 1: [NIGHT_WINDOW]}) + second = _zappi(22345678) + component.devices[second.device_id] = second + run_async(component.charger_control_tick(IN_WINDOW)) + assert component.control_saved_modes[second.device_id] == "Eco+" + + component.base.num_cars = 1 + run_async(component.charger_control_tick(IN_WINDOW)) + assert second.device_id not in component.control_saved_modes, component.control_saved_modes + + second.mode = "Eco" + component.base.num_cars = 2 + run_async(component.charger_control_tick(IN_WINDOW)) + assert component.control_saved_modes[second.device_id] == "Eco", component.control_saved_modes + print(" ✓ A released Zappi snapshots its mode afresh when taken back") + + +def test_control_release_retry_keeps_the_saved_mode(): + """A release that fails is retried next cycle, still restoring the mode the Zappi had before. + + The Zappi was in Eco before Predbat took it. The first release command fails, so the + retry must still put it back in Eco rather than falling back to Eco+. + """ + component = _controlling_component(plans={0: [NIGHT_WINDOW]}) + component.devices = {"Z12345678": _zappi(12345678, mode_index=2)} + run_async(component.charger_control_tick(IN_WINDOW)) + assert component.control_saved_modes == {"Z12345678": "Eco"}, component.control_saved_modes + + component.charger_control_enabled = False + component.transport.set_mode = AsyncMock(side_effect=MyEnergiApiError("timed out")) + try: + run_async(component.charger_control_tick(IN_WINDOW)) + raise AssertionError("The failed release should raise to the run loop") + except MyEnergiApiError: + pass + + component.transport.set_mode = AsyncMock(return_value=True) + run_async(component.charger_control_tick(IN_WINDOW)) + assert component.transport.set_mode.await_args.args[1] == "Eco", component.transport.set_mode.await_args + assert component.control_saved_modes == {}, "Saved modes are forgotten once the release has gone through" + print(" ✓ A failed release is retried with the saved mode intact") + + +def test_control_release_forgets_a_mode_saved_before_a_refused_command(): + """A mode saved ahead of a refused command does not outlive the release.""" + component = _controlling_component(plans={0: [NIGHT_WINDOW]}) + component.transport.set_mode = AsyncMock(side_effect=MyEnergiApiError("refused")) + try: + run_async(component.charger_control_tick(IN_WINDOW)) + except MyEnergiApiError: + pass + assert component.control_saved_modes == {"Z12345678": "Eco+"}, component.control_saved_modes + assert component.charger_control_state == {}, "A refused command is not recorded as set" + + component.charger_control_enabled = False + component.transport.set_mode = AsyncMock(return_value=True) + run_async(component.charger_control_tick(IN_WINDOW)) + assert component.control_saved_modes == {}, component.control_saved_modes + print(" ✓ Release forgets modes saved ahead of refused commands") + + def test_control_releases_to_eco_plus_when_nothing_was_saved(): """With no saved mode - a restart, or a mode that cannot be set - release falls back to Eco+.""" component = _controlling_component(plans={0: [NIGHT_WINDOW]}) - component.control_modes = {"Z12345678": "Fast"} + component.charger_control_state = {"Z12345678": True} component.control_saved_modes = {} - component.control_enabled = False + component.charger_control_enabled = False - run_async(component.control_tick(IN_WINDOW)) + run_async(component.charger_control_tick(IN_WINDOW)) assert component.transport.set_mode.await_args.args[1] == "Eco+", component.transport.set_mode.await_args print(" ✓ Release falls back to Eco+ when there is nothing saved") @@ -1211,19 +1330,19 @@ def test_control_releases_to_eco_plus_when_nothing_was_saved(): def test_control_stops_and_resumes_on_read_only(): """Read only mode releases the Zappis, and clearing it resumes control.""" component = _controlling_component(plans={0: [NIGHT_WINDOW]}) - run_async(component.control_tick(IN_WINDOW)) + run_async(component.charger_control_tick(IN_WINDOW)) assert component.transport.set_mode.await_count == 1 component.base.args["set_read_only"] = True - run_async(component.control_tick(IN_WINDOW)) + run_async(component.charger_control_tick(IN_WINDOW)) assert component.transport.set_mode.await_args.args[1] == "Eco+", "Read only must release, not just stop commanding" # Still read only - nothing more is sent, there is nothing left to release - run_async(component.control_tick(IN_WINDOW)) + run_async(component.charger_control_tick(IN_WINDOW)) assert component.transport.set_mode.await_count == 2 component.base.args["set_read_only"] = False - run_async(component.control_tick(IN_WINDOW)) + run_async(component.charger_control_tick(IN_WINDOW)) assert component.transport.set_mode.await_args.args[1] == "Fast", "Clearing read only must resume control" print(" ✓ Read only releases the Zappis and clearing it resumes control") @@ -1235,15 +1354,49 @@ def test_control_switch_is_published_and_toggles_control(): assert component.base.get_state_wrapper("switch.predbat_myenergi_zappi_control") == "on" run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_control", "turn_off")) - assert component.control_enabled is False + assert component.charger_control_enabled is False run_async(component.publish_data()) assert component.base.get_state_wrapper("switch.predbat_myenergi_zappi_control") == "off" run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_control", "turn_on")) - assert component.control_enabled is True + assert component.charger_control_enabled is True print(" ✓ The zappi control switch is published and toggles control") +def test_guest_switch_is_published_and_releases_the_zappi(): + """The guest charging switch is published beside the control switch, and hands the Zappi back.""" + component = _controlling_component(plans={0: [NIGHT_WINDOW]}) + run_async(component.publish_data()) + assert component.base.get_state_wrapper("switch.predbat_myenergi_guest_charging") == "off" + + run_async(component.charger_control_tick(OUT_OF_WINDOW)) + assert component.transport.set_mode.await_args.args[1] == "Stopped" + assert run_async(component.switch_event_handler("switch.predbat_myenergi_guest_charging", "turn_on")) is True + run_async(component.publish_data()) + assert component.base.get_state_wrapper("switch.predbat_myenergi_guest_charging") == "on" + run_async(component.charger_control_tick(OUT_OF_WINDOW)) + assert component.transport.set_mode.await_args.args[1] == "Eco+", "Guest charging hands the Zappi back to its own mode" + print(" ✓ The guest charging switch is published and hands the Zappi back") + + +def test_guest_charging_ends_when_the_zappi_is_unplugged(): + """The Zappi's plug status ends guest charging once the guest's car is unplugged.""" + component = _controlling_component(plans={0: [NIGHT_WINDOW]}) + device = component.devices["Z12345678"] + assert component.charger_control_car_plugged(device) is True, device.plug_status + device.plug_status = "EV Disconnected" + component.charger_control_set_guest(True) + run_async(component.charger_control_tick(OUT_OF_WINDOW)) + device.plug_status = "EV Connected" + run_async(component.charger_control_tick(OUT_OF_WINDOW)) + assert component.charger_control_guest is True + + device.plug_status = "EV Disconnected" + run_async(component.charger_control_tick(OUT_OF_WINDOW)) + assert component.charger_control_guest is False + print(" ✓ Unplugging from the Zappi ends guest charging") + + def test_control_switch_is_not_published_when_control_cannot_run(): """No switch appears when control could never act on it, rather than one that lies. @@ -1252,13 +1405,13 @@ def test_control_switch_is_not_published_when_control_cannot_run(): run. Toggling it would only make it responsive, not honest - so it is not published. """ component = _controlling_component(enable_controls=False) - assert component.control_active is False + assert component.charger_control_active is False run_async(component.publish_data()) assert component.base.get_state_wrapper("switch.predbat_myenergi_zappi_control") is None # It reappears, with its remembered state, once controls are allowed again allowed = _controlling_component() - assert allowed.control_active is True + assert allowed.charger_control_active is True run_async(allowed.publish_data()) assert allowed.base.get_state_wrapper("switch.predbat_myenergi_zappi_control") == "on" print(" ✓ No control switch appears when control could not act on it") @@ -1276,9 +1429,9 @@ def test_control_switch_publishes_its_restored_state_on_the_first_cycle(): async def _load_off(): """Stand in for storage returning a switched-off control state.""" - component.control_enabled = False + component.charger_control_enabled = False - component.load_control_enabled = _load_off + component.charger_control_load_enabled = _load_off run_async(component.run(0, True)) assert component.base.get_state_wrapper("switch.predbat_myenergi_zappi_control") == "off" @@ -1301,11 +1454,11 @@ def test_run_enables_control_and_drives_the_zappi(): zappi = _zappi(12345678) component.transport.fetch_devices = AsyncMock(return_value=[zappi]) component.transport.set_mode = AsyncMock(return_value=True) - component.load_control_enabled = AsyncMock() + component.charger_control_load_enabled = AsyncMock() assert run_async(component.run(0, True)) is True - assert component.control_active is True - component.load_control_enabled.assert_awaited_once() + assert component.charger_control_active is True + component.charger_control_load_enabled.assert_awaited_once() # A real poll drove the Zappi from the plan without waiting for a second cycle assert component.transport.set_mode.await_count == 1, component.transport.set_mode.await_args_list print(" ✓ The run loop enables control and drives the Zappi from the plan") @@ -1318,7 +1471,7 @@ def test_run_does_not_control_when_the_feature_is_off(): component.transport.set_mode = AsyncMock(return_value=True) assert run_async(component.run(0, True)) is True - assert component.control_active is False + assert component.charger_control_active is False component.transport.set_mode.assert_not_awaited() print(" ✓ The run loop leaves the Zappi alone when control is off") @@ -1360,9 +1513,9 @@ def get_component(self, name): restarted = _controlling_component(plans={0: [NIGHT_WINDOW]}) restarted.base.components = _Components(storage) - assert restarted.control_enabled is True, "A fresh component starts with control on" - run_async(restarted.load_control_enabled()) - assert restarted.control_enabled is False, "The saved off state must survive a restart" + assert restarted.charger_control_enabled is True, "A fresh component starts with control on" + run_async(restarted.charger_control_load_enabled()) + assert restarted.charger_control_enabled is False, "The saved off state must survive a restart" print(" ✓ The control switch state survives a restart") @@ -1378,12 +1531,12 @@ def test_control_failure_does_not_break_the_poll(): zappi = _zappi(12345678) component.transport.fetch_devices = AsyncMock(return_value=[zappi]) component.transport.set_mode = AsyncMock(side_effect=MyEnergiApiError("myenergi refused the mode")) - component.load_control_enabled = AsyncMock() + component.charger_control_load_enabled = AsyncMock() assert run_async(component.run(0, True)) is True, "A refused mode must not fail the cycle" assert component.last_success_timestamp is not None, "Monitoring still succeeded, so the poll counts" # Nothing was recorded as set, so the next cycle tries again rather than assuming it stuck - assert component.control_modes == {}, component.control_modes + assert component.charger_control_state == {}, component.charger_control_state print(" ✓ A refused mode command warns without failing the poll") @@ -2537,10 +2690,18 @@ def test_myenergi(my_predbat=None): test_control_charge_ignores_eddis() test_control_charge_does_nothing_before_a_plan_exists() test_control_gating_refuses_with_a_reason() + test_unset_control_follows_automatic_written_by_the_user() + test_control_setting_strings_are_read_as_booleans() + test_control_hand_to_octopus_never_fast() test_control_releases_to_the_saved_mode() test_control_releases_to_eco_plus_when_nothing_was_saved() + test_control_release_retry_keeps_the_saved_mode() + test_control_release_forgets_the_saved_mode() + test_control_release_forgets_a_mode_saved_before_a_refused_command() test_control_stops_and_resumes_on_read_only() test_control_switch_is_published_and_toggles_control() + test_guest_switch_is_published_and_releases_the_zappi() + test_guest_charging_ends_when_the_zappi_is_unplugged() test_control_switch_is_not_published_when_control_cannot_run() test_control_switch_publishes_its_restored_state_on_the_first_cycle() test_control_switch_is_not_published_without_the_feature() diff --git a/apps/predbat/tests/test_ohme.py b/apps/predbat/tests/test_ohme.py index 3bfe8161c..ae8cbef4b 100644 --- a/apps/predbat/tests/test_ohme.py +++ b/apps/predbat/tests/test_ohme.py @@ -286,6 +286,7 @@ def test_ohme(my_predbat=None): ("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"), + ("follows_car_switch", _test_ohme_follows_car_switch, "octopus_intelligent_charger_follows_car lets ohme_control follow the car"), ("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"), @@ -307,7 +308,10 @@ def test_ohme(my_predbat=None): ("control_drift", _test_ohme_control_reapplies_on_drift, "control re-applies after app changes"), ("control_read_only", _test_ohme_control_read_only_release, "read only releases the charger"), ("control_read_only_src", _test_ohme_control_read_only_effective, "read only uses the effective state"), + ("control_release_retry", _test_ohme_control_failed_release_retries, "a refused release is retried without failing the run"), ("control_target_restore", _test_ohme_control_restores_target, "release restores the charger target"), + ("control_hand_to_octopus", _test_ohme_control_hand_to_octopus, "handing to Octopus turns max charge off first"), + ("control_car_plugged", _test_ohme_control_car_plugged, "plug state for ending guest charging"), ("auto_config_keeps", _test_ohme_auto_config_keeps_existing_car_charging_energy, "auto config keeps a real charger sensor"), ("auto_config_keeps_now", _test_ohme_auto_config_keeps_user_car_charging_now, "auto config keeps the user's car_charging_now"), ("auto_config_power", _test_ohme_auto_config_wires_car_charging_power, "auto config wires car_charging_power"), @@ -1637,17 +1641,16 @@ def __init__(self): self.ohme_automatic = False self.ohme_automatic_octopus_intelligent = None self.ohme_control = False - self.control_active = False + self.charger_control_setup("Ohme API", "charger", switch_prefix="ohme") self.octopus_intelligent = None self.octopus_other_device = False + self.follows_car = False self.slot_mode = None self.slot_mode_applied = None self.charger_slots = False self.charger_slots_blocked = None - self.control_windows = [] - self.control_charging = None - self.control_read_only = None self.control_saved_target = None + self.control_max_charge_started = False self.prefix = "predbat" self.local_tz = pytz.timezone("Europe/London") self.states = {} @@ -1708,7 +1711,7 @@ def _ohme_control_api(windows=None, now=None, read_only=False): api = MockOhmeAPI() api.ohme_automatic = True api.ohme_control = True - api.control_active = True + api.charger_control_active = True api.base.set_read_only = read_only api._now_override = now or datetime.datetime(2026, 8, 22, 23, 0, 0, tzinfo=datetime.timezone.utc).astimezone(api.local_tz) if windows is not None: @@ -1718,6 +1721,11 @@ def _ohme_control_api(windows=None, now=None, read_only=False): return api +def _ohme_held(api): + """The state Predbat last set the charger to, None when it is not holding it""" + return next(iter(api.charger_control_state.values()), None) + + def _ohme_plan_window(start, end): """Build a planned window in the format Predbat publishes""" return {"start": start.strftime("%m-%d %H:%M:%S"), "end": end.strftime("%m-%d %H:%M:%S"), "kwh": 7.0} @@ -1727,17 +1735,29 @@ def _test_ohme_control_enable_rules(my_predbat=None): """Test when Predbat-led charge control is allowed to run""" print("**** Running test_ohme_control_enable_rules ****") - # Off by default + # Unset control stays off, even with ohme_automatic and the car's size and limit set: the Ohme + # keeps to its own schedule, which becomes the car plan - and saying so is not a warning + for automatic in (False, True): + api = MockOhmeAPI() + api.ohme_automatic = automatic + api.base.args_from_apps_yaml = {"car_charging_battery_size": 77, "car_charging_limit": 80} + api.enable_control(False) + assert api.charger_control_active is False, f"Expected unset control off (ohme_automatic {automatic})" + assert not any("Warn" in msg for msg in api.log_messages), f"Unset control is not a request, got {api.log_messages}" + + # An explicit false keeps it off api = MockOhmeAPI() + api.ohme_control = False + api.ohme_automatic = True api.enable_control(False) - assert api.control_active is False, "Expected control off when ohme_control is not set" + assert api.charger_control_active is False, "Expected ohme_control: false to keep control off" # Needs the car registered, or there is no plan to enforce api = MockOhmeAPI() api.ohme_control = True api.ohme_automatic = False api.enable_control(False) - assert api.control_active is False, "Expected control to need ohme_automatic" + assert api.charger_control_active is False, "Expected control to need ohme_automatic" assert any("needs ohme_automatic" in msg for msg in api.log_messages), f"Expected a warning, got {api.log_messages}" # Pointless alongside Intelligent - Octopus already schedules the charge @@ -1745,7 +1765,7 @@ def _test_ohme_control_enable_rules(my_predbat=None): api.ohme_control = True api.ohme_automatic = True api.enable_control(True) - assert api.control_active is False, "Expected control to stand down in Intelligent mode" + assert api.charger_control_active is False, "Expected control to stand down in Intelligent mode" assert any("Octopus already schedules" in msg for msg in api.log_messages), f"Expected a warning, got {api.log_messages}" # Enabled when both conditions hold @@ -1753,7 +1773,7 @@ def _test_ohme_control_enable_rules(my_predbat=None): api.ohme_control = True api.ohme_automatic = True api.enable_control(False) - assert api.control_active is True, "Expected control to enable" + assert api.charger_control_active is True, "Expected control to enable" print("PASS: control enable rules held") return 0 @@ -1768,20 +1788,20 @@ def _test_ohme_control_window_parsing(my_predbat=None): inside = _ohme_plan_window(datetime.datetime(2026, 8, 22, 23, 0), datetime.datetime(2026, 8, 23, 1, 0)) api = _ohme_control_api(windows=[inside], now=now) - assert api.refresh_car_windows() is True, "Expected the plan to be read" - assert len(api.control_windows) == 1, f"Expected one window, got {api.control_windows}" - assert api.should_charge_now() is True, "Expected 23:30 to fall inside a 23:00-01:00 window" + assert api.charger_control_refresh_windows(api.now_utc_exact) is True, "Expected the plan to be read" + assert len(api.charger_control_windows[0]) == 1, f"Expected one window, got {api.charger_control_windows}" + assert api.charger_control_should_charge(0, api.now_utc_exact) is True, "Expected 23:30 to fall inside a 23:00-01:00 window" # Just before the window starts, and exactly at the end, are both outside api._now_override = tz.localize(datetime.datetime(2026, 8, 22, 22, 59, 0)) - assert api.should_charge_now() is False, "Expected 22:59 to be outside the window" + assert api.charger_control_should_charge(0, api.now_utc_exact) is False, "Expected 22:59 to be outside the window" api._now_override = tz.localize(datetime.datetime(2026, 8, 23, 1, 0, 0)) - assert api.should_charge_now() is False, "Expected the window end to be exclusive" + assert api.charger_control_should_charge(0, api.now_utc_exact) is False, "Expected the window end to be exclusive" # A malformed entry is skipped rather than killing the whole plan api.states[("binary_sensor.predbat_car_charging_slot", "planned")] = [{"start": "nonsense"}, inside] - assert api.refresh_car_windows() is True, "Expected a malformed entry to be tolerated" - assert len(api.control_windows) == 1, f"Expected the good window to survive, got {api.control_windows}" + assert api.charger_control_refresh_windows(api.now_utc_exact) is True, "Expected a malformed entry to be tolerated" + assert len(api.charger_control_windows[0]) == 1, f"Expected the good window to survive, got {api.charger_control_windows}" print("PASS: control window parsing handled the plan") return 0 @@ -1798,10 +1818,16 @@ def _test_ohme_control_window_year_rollover(my_predbat=None): window = {"start": "12-31 23:30:00", "end": "01-01 01:30:00", "kwh": 7.0} api = _ohme_control_api(windows=[window], now=now) - api.refresh_car_windows() - start, end = api.control_windows[0] + api.charger_control_refresh_windows(api.now_utc_exact) + start, end = api.charger_control_windows[0][0] assert end > start, f"Expected the window end to follow its start, got {start} to {end}" - assert api.should_charge_now() is True, "Expected to be charging at 23:45 on new year's eve" + assert api.charger_control_should_charge(0, api.now_utc_exact) is True, "Expected to be charging at 23:45 on new year's eve" + + # The same window read just after midnight: its December start now parses into the new + # year and has to be pulled back, or the charge stops mid-window + api._now_override = tz.localize(datetime.datetime(2027, 1, 1, 0, 30, 0)) + api.charger_control_refresh_windows(api.now_utc_exact) + assert api.charger_control_should_charge(0, api.now_utc_exact) is True, "Expected to still be charging at 00:30 on new year's day" print("PASS: new year window handled") return 0 @@ -1815,16 +1841,16 @@ def _test_ohme_control_window_year_rollover_after_midnight(my_predbat=None): # Same window as the new year's eve test above, but now is read a little after midnight, once # the clock has already ticked into January. Naively anchoring both start and end to now.year # previously put the Dec 31 start a full year in the future (next Dec 31) rather than the - # actual previous one, so should_charge_now() stopped seeing the still-active window at all. + # actual previous one, so charger_control_should_charge() stopped seeing the still-active window at all. now = tz.localize(datetime.datetime(2027, 1, 1, 0, 15, 0)) window = {"start": "12-31 23:30:00", "end": "01-01 01:30:00", "kwh": 7.0} api = _ohme_control_api(windows=[window], now=now) - api.refresh_car_windows() - start, end = api.control_windows[0] + api.charger_control_refresh_windows(api.now_utc_exact) + start, end = api.charger_control_windows[0][0] assert start.year == 2026, f"Dec 31 start should anchor to the previous year, got {start}" assert end.year == 2027, f"Jan 1 end should anchor to the current year, got {end}" - assert api.should_charge_now() is True, "Expected to still be charging at 00:15 on new year's day" + assert api.charger_control_should_charge(0, api.now_utc_exact) is True, "Expected to still be charging at 00:15 on new year's day" print("PASS: new year window handled after midnight rollover") return 0 @@ -1838,15 +1864,15 @@ def _test_ohme_control_window_long_active_not_shifted(my_predbat=None): # A long/flat-rate window starting just after midnight yesterday and still running: at 23:05 # the next day its start is nearly 47 hours old, well past the 23 hour rollover heuristic, but # its end is still ahead of now, so it must be read as genuinely active rather than shifted a - # year forward and dropped out of should_charge_now(). + # year forward and dropped out of charger_control_should_charge(). now = tz.localize(datetime.datetime(2026, 6, 15, 23, 5, 0)) window = {"start": "06-14 00:10:00", "end": "06-16 02:00:00", "kwh": 40.0} api = _ohme_control_api(windows=[window], now=now) - api.refresh_car_windows() - start, end = api.control_windows[0] + api.charger_control_refresh_windows(api.now_utc_exact) + start, end = api.charger_control_windows[0][0] assert start.year == now.year, f"Expected the still-active window's start left in the current year, got {start}" - assert api.should_charge_now() is True, "Expected to still be charging inside a long active window over 23 hours after its start" + assert api.charger_control_should_charge(0, api.now_utc_exact) is True, "Expected to still be charging inside a long active window over 23 hours after its start" print("PASS: long active window left unshifted") return 0 @@ -1858,14 +1884,14 @@ def _test_ohme_control_waits_for_plan(my_predbat=None): # No plan sensor yet - pausing a car on no information would be the wrong default api = _ohme_control_api(windows=None) - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) assert len(api.client.request_log) == 0, f"Expected no commands before a plan exists, got {api.client.request_log}" - assert api.control_charging is None, "Expected no tracked state before a plan exists" + assert _ohme_held(api) is None, "Expected no tracked state before a plan exists" # An empty plan is a real answer, not a missing one - the charger is held paused api.states[("binary_sensor.predbat_car_charging_slot", "planned")] = [] - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) assert len(api.client.request_log) == 1, f"Expected a pause once the plan is known, got {api.client.request_log}" assert "stop" in api.client.request_log[0]["url"], f"Expected a pause command, got {api.client.request_log[0]['url']}" @@ -1882,24 +1908,24 @@ def _test_ohme_control_edge_triggered(my_predbat=None): api = _ohme_control_api(windows=[window], now=tz.localize(datetime.datetime(2026, 8, 22, 23, 30))) # Entering the window sets max charge once - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) assert len(api.client.request_log) == 1, f"Expected one command, got {api.client.request_log}" assert "enabled=true" in api.client.request_log[0]["url"], f"Expected max charge, got {api.client.request_log[0]['url']}" # Still inside it, and the charger already agrees - no repeat command api.client._charge_session = {"mode": "MAX_CHARGE"} - run_async(api.control_charge()) - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) + run_async(api.charger_control_tick(api.now_utc_exact)) assert len(api.client.request_log) == 1, f"Expected no repeat commands, got {api.client.request_log}" # Leaving the window pauses once api._now_override = tz.localize(datetime.datetime(2026, 8, 23, 1, 30)) - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) assert len(api.client.request_log) == 2, f"Expected a pause command, got {api.client.request_log}" assert "stop" in api.client.request_log[1]["url"], f"Expected a pause, got {api.client.request_log[1]['url']}" api.client._charge_session = {"mode": "STOPPED"} - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) assert len(api.client.request_log) == 2, f"Expected no repeat pause, got {api.client.request_log}" print("PASS: control was edge triggered") @@ -1914,14 +1940,14 @@ def _test_ohme_control_reapplies_on_drift(my_predbat=None): window = _ohme_plan_window(datetime.datetime(2026, 8, 22, 23, 0), datetime.datetime(2026, 8, 23, 1, 0)) api = _ohme_control_api(windows=[window], now=tz.localize(datetime.datetime(2026, 8, 22, 23, 30))) - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) api.client._charge_session = {"mode": "MAX_CHARGE"} - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) assert len(api.client.request_log) == 1, "Expected a settled state before drifting" # Someone pauses it in the Ohme app while Predbat still wants it charging api.client._charge_session = {"mode": "STOPPED"} - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) assert len(api.client.request_log) == 2, f"Expected the change to be corrected, got {api.client.request_log}" assert "enabled=true" in api.client.request_log[1]["url"], f"Expected max charge re-applied, got {api.client.request_log[1]['url']}" @@ -1929,7 +1955,7 @@ def _test_ohme_control_reapplies_on_drift(my_predbat=None): # An unplugged charger has nothing to correct api.client._charge_session = {"mode": "DISCONNECTED"} - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) assert len(api.client.request_log) == 2, f"Expected no command for an unplugged charger, got {api.client.request_log}" print("PASS: control re-applied after drift") @@ -1944,25 +1970,25 @@ def _test_ohme_control_read_only_release(my_predbat=None): window = _ohme_plan_window(datetime.datetime(2026, 8, 22, 23, 0), datetime.datetime(2026, 8, 23, 1, 0)) api = _ohme_control_api(windows=[window], now=tz.localize(datetime.datetime(2026, 8, 22, 23, 30))) - run_async(api.control_charge()) - assert api.control_charging is True, "Expected Predbat to be holding the charger" + run_async(api.charger_control_tick(api.now_utc_exact)) + assert _ohme_held(api) is True, "Expected Predbat to be holding the charger" # Read only - hand it back to Ohme's own schedule api.base.set_read_only = True - run_async(api.control_charge()) - assert any("releasing the charger back to Ohme" in msg for msg in api.log_messages), f"Expected a release log, got {api.log_messages}" + run_async(api.charger_control_tick(api.now_utc_exact)) + assert any("Releasing the charger back to Ohme" in msg for msg in api.log_messages), f"Expected a release log, got {api.log_messages}" assert "enabled=false" in api.client.request_log[-1]["url"], f"Expected max charge cleared, got {api.client.request_log[-1]['url']}" - assert api.control_charging is None, "Expected tracked state cleared after releasing" + assert _ohme_held(api) is None, "Expected tracked state cleared after releasing" # Staying in read only must not keep sending commands count = len(api.client.request_log) - run_async(api.control_charge()) - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) + run_async(api.charger_control_tick(api.now_utc_exact)) assert len(api.client.request_log) == count, f"Expected no further commands while read only, got {api.client.request_log}" # Clearing read only resumes control api.base.set_read_only = False - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) assert len(api.client.request_log) == count + 1, f"Expected control to resume, got {api.client.request_log}" assert "enabled=true" in api.client.request_log[-1]["url"], f"Expected max charge re-applied, got {api.client.request_log[-1]['url']}" assert any("Read only mode cleared" in msg for msg in api.log_messages), f"Expected a resume log, got {api.log_messages}" @@ -1971,6 +1997,60 @@ def _test_ohme_control_read_only_release(my_predbat=None): return 0 +def _test_ohme_control_hand_to_octopus(my_predbat=None): + """Test handing a paused charger to Octopus turns max charge off before resuming it""" + print("**** Running test_ohme_control_hand_to_octopus ****") + + api = _ohme_control_api() + api.control_saved_target = 70 + api.control_max_charge_started = True + run_async(api.charger_control_hand_to_octopus(api.client, False)) + urls = [request["url"] for request in api.client.request_log] + max_off = next(i for i, url in enumerate(urls) if "max-charge?enabled=false" in url) + resume = next(i for i, url in enumerate(urls) if url.endswith("/resume")) + assert max_off < resume, f"Max charge must be off before the charger is resumed, got {urls}" + assert api.control_saved_target is None, "Expected the saved target to be restored and cleared" + + # Max charge started with no readable target to save, then paused: the max charge is still Predbat's to undo + api = _ohme_control_api() + assert api.control_saved_target is None + api.control_max_charge_started = True + run_async(api.charger_control_hand_to_octopus(api.client, False)) + urls = [request["url"] for request in api.client.request_log] + max_off = next(i for i, url in enumerate(urls) if "max-charge?enabled=false" in url) + resume = next(i for i, url in enumerate(urls) if url.endswith("/resume")) + assert max_off < resume, f"Max charge must be off before the resume even with no saved target, got {urls}" + assert api.control_max_charge_started is False, "Expected the flag cleared once max charge is off" + + # A charger Predbat only paused, never on max charge, has no max charge of Predbat's to undo - + # one the user set in the Ohme app is left alone + api = _ohme_control_api() + assert api.control_saved_target is None + run_async(api.charger_control_hand_to_octopus(api.client, False)) + urls = [request["url"] for request in api.client.request_log] + assert not any("max-charge" in url for url in urls), f"Expected no max charge change for a charger Predbat never put on max charge, got {urls}" + assert any(url.endswith("/resume") for url in urls), f"Expected the paused charger resumed, got {urls}" + return 0 + + +def _test_ohme_control_car_plugged(my_predbat=None): + """Test the Ohme's plug state, used to end guest charging""" + print("**** Running test_ohme_control_car_plugged ****") + + api = _ohme_control_api() + api.client._charge_session = {} + assert api.charger_control_car_plugged(api.client) is True, "No session yet should read as plugged in" + api.client._charge_session = {"mode": "SMART_CHARGE", "power": {"watt": 0}} + assert api.charger_control_car_plugged(api.client) is True + api.client._charge_session = {"mode": "DISCONNECTED"} + assert api.charger_control_car_plugged(api.client) is False + + # The guest switch reaches the mixin through the Ohme's own switch handler + run_async(api.switch_event_handler("switch.predbat_ohme_guest_charging", "turn_on")) + assert api.charger_control_guest is True, "Expected the Ohme guest switch to turn guest charging on" + return 0 + + def _test_ohme_control_restores_target(my_predbat=None): """Test the user's charger target is put back when Predbat releases the charger""" print("**** Running test_ohme_control_restores_target ****") @@ -1982,7 +2062,7 @@ def _test_ohme_control_restores_target(my_predbat=None): api.client._charge_session = {"mode": "SMART_CHARGE", "power": {"watt": 0}, "appliedRule": {"targetPercent": 70, "targetTime": 25200}} api.client._last_rule = {"id": "RULE-70", "targetPercent": 70} - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) assert api.control_saved_target == 70, f"Expected the target to be snapshotted before max charge, got {api.control_saved_target}" # Snapshot must be taken before the max charge command, not after it @@ -1990,7 +2070,7 @@ def _test_ohme_control_restores_target(my_predbat=None): # Releasing puts the user's target back so Ohme's own schedule is left correct api.base.set_read_only = True - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) restore_requests = [request for request in api.client.request_log if request["method"] == "PATCH"] assert any(request["data"].get("targetPercent") == 70 for request in restore_requests), f"Expected the target to be restored, got {restore_requests}" @@ -2000,13 +2080,52 @@ def _test_ohme_control_restores_target(my_predbat=None): # Taking control again snapshots afresh rather than reusing the old value api.base.set_read_only = False api.client._charge_session = {"mode": "SMART_CHARGE", "power": {"watt": 0}, "appliedRule": {"targetPercent": 90, "targetTime": 25200}} - run_async(api.control_charge()) + run_async(api.charger_control_tick(api.now_utc_exact)) assert api.control_saved_target == 90, f"Expected a fresh snapshot, got {api.control_saved_target}" print("PASS: the charger target was restored on release") return 0 +def _test_ohme_control_failed_release_retries(my_predbat=None): + """Test a release Ohme refuses is retried next cycle without failing the component's run""" + print("**** Running test_ohme_control_failed_release_retries ****") + from ohme import ApiException + + tz = pytz.timezone("Europe/London") + # Outside the only window, so Predbat holds the charger paused + window = _ohme_plan_window(datetime.datetime(2026, 8, 22, 23, 0), datetime.datetime(2026, 8, 23, 1, 0)) + api = _ohme_control_api(windows=[window], now=tz.localize(datetime.datetime(2026, 8, 22, 21, 0))) + api.update_success_timestamp = lambda: None + run_async(api.run(seconds=60, first=False)) + assert _ohme_held(api) is False, "Expected Predbat to be holding the charger paused" + + # Read only, and Ohme refuses the resume - the car was unplugged since it was paused, say + api.base.set_read_only = True + resume_calls = [] + + async def refuse_resume(): + """Refuse the resume, as Ohme would when the car is gone.""" + resume_calls.append(True) + raise ApiException("refused") + + real_resume = api.client.async_resume_charge + api.client.async_resume_charge = refuse_resume + assert run_async(api.run(seconds=120, first=False)) is True, "A refused release must not fail the cycle" + assert any("Charge control failed" in msg for msg in api.log_messages), f"Expected a warning, got {api.log_messages}" + assert _ohme_held(api) is False, "Nothing was released, so Predbat still holds the charger" + + # The next cycle tries again, and once Ohme accepts it the charger is handed back + api.client.async_resume_charge = real_resume + assert run_async(api.run(seconds=180, first=False)) is True + assert len(resume_calls) == 1, f"Expected one refused attempt, got {len(resume_calls)}" + assert _ohme_held(api) is None, "Expected the charger to be released on the retry" + assert any("/resume" in request["url"] for request in api.client.request_log), f"Expected the resume to be sent, got {api.client.request_log}" + + print("PASS: a refused release was retried without failing the run") + return 0 + + def _test_ohme_control_read_only_effective(my_predbat=None): """Test read only follows the effective state, not just the config switch""" print("**** Running test_ohme_control_read_only_effective ****") @@ -2015,16 +2134,16 @@ def _test_ohme_control_read_only_effective(my_predbat=None): api = MockOhmeAPI() api.base.set_read_only = True api.args["set_read_only"] = False - assert api.control_read_only_now() is True, "Expected the attribute to win over the arg" + assert api.charger_control_read_only_now() is True, "Expected the attribute to win over the arg" # Before the attribute is first set, fall back to the configured value api = MockOhmeAPI() api.base.set_read_only = None api.args["set_read_only"] = True - assert api.control_read_only_now() is True, "Expected the arg to be used as a fallback" + assert api.charger_control_read_only_now() is True, "Expected the arg to be used as a fallback" api.args["set_read_only"] = False - assert api.control_read_only_now() is False, "Expected control to run when not read only" + assert api.charger_control_read_only_now() is False, "Expected control to run when not read only" print("PASS: read only used the effective state") return 0 @@ -2303,7 +2422,7 @@ def _test_ohme_run_iog_device_is_car(my_predbat=None): 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 api.charger_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") @@ -2352,6 +2471,75 @@ def _test_ohme_run_iog_device_changes(my_predbat=None): return 0 +def _test_ohme_follows_car_switch(my_predbat=None): + """Test octopus_intelligent_charger_follows_car lets ohme_control run while Octopus drives the car, and back""" + print("**** Running test_ohme_follows_car_switch ****") + + 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.octopus_other_device is True and api.charger_control_active is False, "Expected control off while Octopus drives the car" + + # Switched on at runtime: picked up on the next poll, without any change of device + api.args["octopus_intelligent_charger_follows_car"] = True + _ohme_run_poll(api, seconds=120) + assert api.charger_control_active is True, f"Expected control on to follow the car, got {api.log_messages}" + assert any("Predbat now drives the charger" in msg for msg in api.log_messages), api.log_messages + assert not any("Octopus Intelligent is" in msg for msg in api.log_messages), f"Expected no tariff change logged, got {api.log_messages}" + assert api.slot_mode is None, f"Expected the car slots left to the Octopus component, got {api.slot_mode}" + + # And off again: control stands down, and a charger Predbat holds is handed back + api.charger_control_state = {api.charger_control_chargers()[0][0]: False} + released = [] + + async def mock_resume(): + """Record the resume""" + released.append("resume") + + async def mock_max_charge(state=True): + """Record the max charge change""" + released.append(("max_charge", state)) + + api.client.async_resume_charge = mock_resume + api.client.async_max_charge = mock_max_charge + api.args["octopus_intelligent_charger_follows_car"] = False + _ohme_run_poll(api, seconds=240) + assert api.charger_control_active is False and not api.charger_control_state, f"Expected control off and the charger released, got {api.charger_control_state}" + assert released == ["resume", ("max_charge", False)], f"Expected a normal release (resume first) when Octopus drives the car, got {released}" + + # Octopus Intelligent charging off in Predbat: Predbat plans the car itself, so it drives the charger + 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.charger_control_active is False, "Expected control off while Octopus drives the car" + api.args["octopus_intelligent_charging"] = False + _ohme_run_poll(api, seconds=120) + assert api.charger_control_active is True, f"Expected control on with Intelligent charging off, got {api.log_messages}" + + # Away from Octopus Intelligent the switch changes nothing, and says nothing about Octopus + api = MockOhmeAPI() + api.ohme_automatic = True + api.ohme_control = True + _ohme_run_poll(api, first=True) + assert api.charger_control_active is True, "Expected control on away from Octopus Intelligent" + del api.log_messages[:] + api.args["octopus_intelligent_charger_follows_car"] = True + _ohme_run_poll(api, seconds=120) + assert api.charger_control_active is True and api.log_messages == [], f"Expected a quiet poll, got {api.log_messages}" + + # Without ohme_control: true the switch alone does not turn control on + api = _ohme_api_with_octopus(IOG_TARIFF, {"dev1": IOG_DEVICE_CAR}) + api.ohme_automatic = True + api.args["octopus_intelligent_charger_follows_car"] = True + _ohme_run_poll(api, first=True) + assert api.charger_control_active is False, "Expected the switch alone not to turn control on" + + print("PASS: the follows-car switch 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 ****") @@ -2466,7 +2654,7 @@ def _test_ohme_charger_slots_wanted_rules(my_predbat=None): api.ohme_automatic = True api.ohme_control = True api.enable_control(False) - assert api.control_active is True, "Expected control to enable" + assert api.charger_control_active is True, "Expected control to enable" assert api.charger_slots_wanted(False) is False, "Expected Predbat's own plan while it controls the charger" # Car slots already wired to something else are left alone, single entity or list of one @@ -3235,7 +3423,7 @@ def _test_ohme_run_first_with_charger_slots(my_predbat=None): api.ohme_automatic = True api.ohme_control = True published, wired = _ohme_run_first(api) - assert api.control_active is True, "Expected control to enable" + assert api.charger_control_active is True, "Expected control to enable" assert published == [False] and wired == [], f"Expected no slot wiring under ohme_control, got {published} {wired}" assert "octopus_intelligent_slot" not in api.args, f"Expected the car slots left unwired, got {api.args}" @@ -3374,7 +3562,7 @@ def _test_ohme_run_tariff_change_with_control(my_predbat=None): api.ohme_automatic = True api.ohme_control = True assert _ohme_run_poll(api, first=True) == [False], "Expected no charger schedule under ohme_control" - assert api.control_active is True and api.slot_mode is None, f"Expected control with no slot wiring, got {api.control_active} {api.slot_mode}" + assert api.charger_control_active is True and api.slot_mode is None, f"Expected control with no slot wiring, got {api.charger_control_active} {api.slot_mode}" assert "octopus_intelligent_slot" not in api.args, f"Expected the car slots left unwired, got {api.args}" # Onto Intelligent while Predbat is holding the charger paused: Octopus schedules the charge now, @@ -3387,22 +3575,29 @@ async def mock_resume(): async def mock_max_charge(state=True): released.append(("max_charge", state)) + async def mock_set_target(target_percent=None): + """Accept the target being put back""" + api.client.async_resume_charge = mock_resume api.client.async_max_charge = mock_max_charge - api.control_charging = False + api.client.async_set_target = mock_set_target + # Paused after an earlier max charge, so Predbat has a max charge and a user target to undo + api.control_saved_target = 70 + api.control_max_charge_started = True + api.charger_control_state = {api.charger_control_chargers()[0][0]: False} _ohme_set_tariff(api, "E-1R-INTELLI-VAR-22-10-14-A") assert _ohme_run_poll(api, seconds=120) == [False], "Expected plain dispatches on Intelligent" - assert api.control_active is False, "Expected control to stand down on Intelligent" - assert released == ["resume", ("max_charge", False)], f"Expected the charger released, got {released}" - assert api.control_charging is None, "Expected the control state cleared" + assert api.charger_control_active is False, "Expected control to stand down on Intelligent" + assert released == [("max_charge", False), "resume"], f"Expected max charge off before the resume, so no charge starts outside a dispatch, got {released}" + assert not api.charger_control_state, "Expected the control state cleared" assert api.slot_mode == "octopus_intelligent" and api.base.car_slot_owner == "ohme", f"Expected Intelligent mode, got {api.slot_mode} owner {api.base.car_slot_owner}" - assert any("Octopus Intelligent now schedules the charge" in msg for msg in api.log_messages), f"Expected the release to say why, got {api.log_messages}" + assert any("handing the charger to Octopus" in msg for msg in api.log_messages), f"Expected the release to say why, got {api.log_messages}" # Back off Intelligent: control resumes, and the Ohme slots must stop being the car plan or # Predbat's own plan - the one it is now enforcing - would never be built _ohme_set_tariff(api, "E-1R-COSY-22-12-08-A") assert _ohme_run_poll(api, seconds=240) == [False], "Expected no charger schedule under ohme_control" - assert api.control_active is True and api.slot_mode is None, f"Expected control back with no slot wiring, got {api.control_active} {api.slot_mode}" + assert api.charger_control_active is True and api.slot_mode is None, f"Expected control back with no slot wiring, got {api.charger_control_active} {api.slot_mode}" assert api.base.car_slot_owner is None, f"Expected the car slot claim released, got {api.base.car_slot_owner}" assert api.args.get("octopus_intelligent_slot") == [], f"Expected the car slot wiring cleared, got {api.args}" @@ -3469,7 +3664,7 @@ def _test_ohme_run_release_retried_after_failure(my_predbat=None): api.ohme_automatic = True api.ohme_control = True assert _ohme_run_poll(api, first=True) == [False], "Expected no charger schedule under ohme_control" - assert api.control_active is True, "Expected control to enable" + assert api.charger_control_active is True, "Expected control to enable" calls = [] fail = [True] @@ -3482,9 +3677,15 @@ async def mock_max_charge(state=True): if fail[0]: raise ApiException("max charge failed") + async def mock_set_target(target_percent=None): + """Accept the target being put back""" + api.client.async_resume_charge = mock_resume api.client.async_max_charge = mock_max_charge - api.control_charging = False # Predbat is holding the charger paused + api.client.async_set_target = mock_set_target + api.control_saved_target = 70 # Predbat is holding the charger paused, after an earlier max charge + api.control_max_charge_started = True + api.charger_control_state = {api.charger_control_chargers()[0][0]: False} # Onto Intelligent, and the hand-back fails half way: control has stood down, the charger is not released _ohme_set_tariff(api, "E-1R-INTELLI-VAR-22-10-14-A") @@ -3493,15 +3694,15 @@ async def mock_max_charge(state=True): assert False, "Expected the failed hand-back to raise" except ApiException: pass - assert api.control_active is False, "Expected control to have stood down" - assert api.control_charging is False, "Expected the charger still recorded as held" + assert api.charger_control_active is False, "Expected control to have stood down" + assert list(api.charger_control_state.values()) == [False], "Expected the charger still recorded as held" # The next poll sees no change of tariff, and must hand the charger back all the same fail[0] = False del calls[:] assert _ohme_run_poll(api, seconds=240) == [False], "Expected plain dispatches on Intelligent" - assert calls == ["resume", ("max_charge", False)], f"Expected the hand-back retried, got {calls}" - assert api.control_charging is None, "Expected the charger released" + assert calls == [("max_charge", False), "resume"], f"Expected the hand-back retried, got {calls}" + assert not api.charger_control_state, "Expected the charger released" assert api.slot_mode_applied == "octopus_intelligent" and api.base.car_slot_owner == "ohme", f"Expected the Intelligent wiring made, got {api.slot_mode_applied}" # Released once, not on every poll after diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 120aba6cf..a096d950f 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -281,6 +281,7 @@ from tests.test_givtcp_rest import run_givtcp_rest_tests from tests.test_myenergi import test_myenergi from tests.test_wallbox import test_wallbox +from tests.test_car_charger_control import run_car_charger_control_tests from tests.test_component_base import test_component_base_all from tests.test_components import test_components_all from tests.test_coordinator import test_coordinator_all @@ -714,6 +715,7 @@ def main(): ("memory_release", run_memory_release_tests, "glibc malloc_trim()/arena cap helper tests", False), ("inverter_write_poll", run_inverter_write_poll_tests, "Inverter write-and-poll timing tests", False), ("givtcp_rest", run_givtcp_rest_tests, "GivTCP REST client write/retry/transport tests", False), + ("car_charger_control", run_car_charger_control_tests, "Shared Predbat-led EV charger control tests", False), # myenergi Zappi and Eddi unit tests ("myenergi", test_myenergi, "myenergi Zappi and Eddi comprehensive tests (normalisation, transports, publishing, auto-config, controls)", False), ("wallbox", test_wallbox, "Wallbox EV charger tests (transport, normalisation, publishing, auto-config, controls, plan-led charging)", False), diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index c87acc0cf..c539784eb 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -643,10 +643,10 @@ This is a separate setting from **ge_cloud_automatic** because it registers a ca auto-configuration does not silently change your car setup. The charger's own entities are published either way. See [Components - GivEnergy Cloud Direct](components.md#ev-chargers-gecloud) for the entities this publishes. -- **ge_cloud_evc_control** - Optional, defaults to false. When set to `true`, Predbat starts and stops your GivEnergy EV charger from its +- **ge_cloud_evc_control** - Optional. Left unset, it turns on with **ge_cloud_automatic_evc**; set `false` to keep it off. When on, Predbat starts and stops your GivEnergy EV charger from its car charging plan, in the same way it can drive a myenergi Zappi or an Ohme charger. Charger N follows car N. Needs **ge_cloud_automatic_evc**, since it is that setting which maps each charger to a car. -A `switch.predbat_gecloud_evc_control` entity appears when this is set, on by default, so you can hand the charger back without editing +A `switch.predbat_gecloud_evc_control` entity appears when control is on, on by default, so you can hand the charger back without editing `apps.yaml`; releasing sends a start command if Predbat had stopped the charger, so a car is never left unable to charge. Read only mode releases the chargers in the same way. See [Components - Charger control](components.md#charger-control-gecloud) for the details. @@ -2150,7 +2150,7 @@ no hub, use the serial of the device acting as one, which is the Zappi or Eddi t - **myenergi_automatic_eddi** - Set to `false` to wire only the Zappi half of the automatic configuration, leaving your Eddi out of **iboost_energy_today** (default: `true`). This is what to use if your hot water diversion is handled elsewhere but you still want your Zappis wired as cars - **myenergi_enable_controls** - Set to `false` for monitor-only operation (default: `true`) - **myenergi_poll_seconds** - Poll interval in seconds, rounded to the nearest whole multiple of 60, minimum 60 and maximum 1800 (default: `60`) -- **myenergi_zappi_control** - Set to `true` to let Predbat drive your Zappi from its car charging plan: Fast inside a planned charging window, Stopped outside one (default: `false`). Needs **myenergi_automatic**, **myenergi_automatic_zappi** and **myenergi_enable_controls**, since it is automatic configuration that maps each Zappi to a car. A `switch.predbat_myenergi_zappi_control` entity appears when this is set, on by default, so you can hand the Zappi back without editing apps.yaml; releasing restores the mode the Zappi had before Predbat took over, or Eco+ when nothing was saved. Note the manual boost switch will refuse while control is on, as myenergi only accepts a boost in Eco or Eco+. +- **myenergi_zappi_control** - Lets Predbat drive your Zappi from its car charging plan: Fast inside a planned charging window, Stopped outside one. Left unset, it turns on only if you wrote **myenergi_automatic** or **myenergi_automatic_zappi** into `apps.yaml` yourself; set `false` to keep it off, for example to keep Eco/Eco+ solar diversion. Needs **myenergi_automatic**, **myenergi_automatic_zappi** and **myenergi_enable_controls**, since it is automatic configuration that maps each Zappi to a car. A `switch.predbat_myenergi_zappi_control` entity appears when control is on, on by default, so you can hand the Zappi back without editing apps.yaml; releasing restores the mode the Zappi had before Predbat took over, or Eco+ when nothing was saved. Note the manual boost switch will refuse while control is on, as myenergi only accepts a boost in Eco or Eco+. The component only starts when at least one of `myenergi_api_key`, `myenergi_key` or `myenergi_token_hash` is set. That test is a plain any-of and does not look at `myenergi_auth_method`, so a credential belonging to the transport you did not select still starts the component — it then logs which setting is missing rather than failing silently. diff --git a/docs/car-charging.md b/docs/car-charging.md index a7d88ef4c..1f862d0dd 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -373,7 +373,8 @@ Which device Octopus Intelligent controls matters, because that is the device Oc - **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. + power and energy readings are used. `ohme_control` is ignored here too, as Octopus is already scheduling the charge - unless the car is the Octopus device and you turn on + **switch.predbat_octopus_intelligent_charger_follows_car**, which lets `ohme_control` drive the Ohme to the car's dispatches (see [Octopus Intelligent and charger control](#octopus-intelligent-and-charger-control)). - **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. @@ -422,7 +423,7 @@ A few things to know about this mode: ### Predbat-led Ohme charging -**ohme_control** lets Predbat start and stop the charger itself, according to its own car charging plan: +**ohme_control** lets Predbat start and stop the charger itself, according to its own car charging plan. Unlike the GivEnergy and myenergi chargers, it is only on when you set it to `true` (see [Predbat starting and stopping your charger](#predbat-starting-and-stopping-your-charger)); otherwise the Ohme keeps to its own schedule, which becomes Predbat's car plan. Set `car_charging_battery_size` and `car_charging_limit` too, as Predbat sets the charger to max charge, which overrides the target in the Ohme app: ```yaml ohme_login: !secret ohme_login @@ -431,7 +432,7 @@ A few things to know about this mode: ohme_control: true ``` -It requires `ohme_automatic` (there is no plan to enforce until the car is registered) and is ignored when the Intelligent slots come from Ohme, as Octopus already schedules the charge in that case. +It requires `ohme_automatic` (there is no plan to enforce until the car is registered) and stands aside when the Intelligent slots come from Ohme, as Octopus drives the charger in that case. You must still set `car_charging_battery_size` and `car_charging_limit` yourself - Ohme cannot report either, and Predbat needs them to work out how much charge to add. Getting these right matters more than usual here: setting the charger to max charge overrides its own target percentage, so **the length of Predbat's planned window is the only thing limiting the @@ -442,10 +443,10 @@ Predbat sets the charger to max charge while a planned window is running, and pa `binary_sensor.predbat_car_charging_slot` state directly, so window boundaries are acted on promptly instead of waiting for Predbat's next full update. If you change the charger in the Ohme app while Predbat is in control, Predbat notices at its next poll and puts it back. -**While `ohme_control` is on, Predbat owns the charger.** Outside a planned window it holds the charger paused, including when nothing is planned at all. Switching Predbat to +**While Ohme control is on, Predbat owns the charger.** Outside a planned window it holds the charger paused, including when nothing is planned at all. Switching Predbat to [read only mode](customisation.md#predbat-mode) is what releases it - Predbat then hands the charger back to Ohme's own smart schedule, and picks it up again when you turn read only off. A component restart deliberately does *not* release the charger, so restarting Predbat will not interrupt a charge in progress. If Predbat stops unexpectedly while the charger is -paused, it stays paused until you turn read only on, disable `ohme_control`, or resume the charge in the Ohme app. +paused, it stays paused until you turn read only on, set `ohme_control: false`, or resume the charge in the Ohme app. ### Ohme charge energy @@ -514,6 +515,46 @@ When `gateway_evc_control` is enabled, Predbat checks once per minute whether th `car_charging_now` is wired to the charger's charging sensor, which is on while the car is drawing power (OCPP status `Charging`), so Predbat holds the house battery for the car while it charges. A car that stays connected once it is full or paused (`SuspendedEV`) does not hold the battery. It never adds a charging slot, so it cannot hold a session open against the window boundaries `gateway_evc_control` enforces. +## Predbat starting and stopping your charger + +Predbat can start and stop a GivEnergy EV charger, an Ohme charger or a myenergi Zappi itself, following its own car charging plan. Each charger follows its own car: charger N follows car N. + +Each has a control setting. Left out of `apps.yaml`, control turns on with the automatic setup that maps the charger to its car, except for the Ohme: + +| Charger | Setting | Left unset, control is on when | +|---------|---------|--------------------------------| +| GivEnergy EV charger | `ge_cloud_evc_control` | `ge_cloud_automatic_evc` is `true` | +| Ohme | `ohme_control` | never - set it to `true`; left unset, Ohme's own schedule is the car plan | +| myenergi Zappi | `myenergi_zappi_control` | you have written `myenergi_automatic` or `myenergi_automatic_zappi` into `apps.yaml` yourself | + +Set the control setting to `false` to keep Predbat's hands off the charger, or to `true` to ask for control explicitly - Predbat then also logs a warning if something it needs is missing. + +The Ohme stays opt-in because, without control, Predbat already follows it: the charger's own schedule is taken as the car charging plan. Predbat control sets it to max charge, which overrides the target in the Ohme app, so with `ohme_control: true` also set the car's battery size and charge limit - left at their defaults (100 kWh, 100%) the car would be charged to full. + +The Zappi rule is narrower because `myenergi_automatic` and `myenergi_automatic_zappi` both default on: following their values alone would put every Zappi under Predbat's control. A Zappi Predbat controls is Stopped outside a planned window, so it no longer diverts surplus solar to the car - set `myenergi_zappi_control: false` if you would rather keep solar diversion. + +**Upgrading:** if you already had `ge_cloud_automatic_evc` on, or wrote `myenergi_automatic` into `apps.yaml`, Predbat now starts and stops that charger unless you set its control setting to `false`. Ohme users see no change. + +The GivEnergy Gateway's `gateway_evc_control` (above) is unchanged and stays opt-in. + +### Octopus Intelligent and charger control + +On Octopus Intelligent (or E.ON/EDF SmartFlex through the Kraken component), what Predbat does depends on which device Octopus drives for that car. Predbat reads this from the car's own dispatch sensor in `octopus_intelligent_slot`: + +- **Octopus drives the charger** - your charger is the Intelligent device, or the Intelligent slots come from the Ohme. Predbat leaves the charger alone, and hands it back if it was holding it, so the two do not fight. Handing back never starts a charge: a charger Predbat had stopped is left for Octopus to start, an Ohme has max charge turned off, and a Zappi goes back to its own mode (Eco+ in place of Fast). +- **Octopus drives the car** - the car itself is the Intelligent device, so the car starts and stops its own charge. By default Predbat leaves the charger alone, and hands back a charger it was holding as usual, so one it had stopped is started again rather than left stopped. Turn on **switch.predbat_octopus_intelligent_charger_follows_car** (*expert mode*) to have Predbat drive the charger instead: on while a dispatch is running, off otherwise, so the car cannot charge outside the dispatches on its own timers. It follows both its plan and the dispatch times on the car's dispatch sensor, so a new dispatch starts the charger within a few minutes of Octopus announcing it rather than waiting for the next plan, and the charger stops when the dispatch ends. +- **Predbat cannot tell** - for example the dispatch sensor comes from the Octopus Energy integration, which does not say, or Predbat's Octopus component has not discovered your devices yet. Predbat leaves the charger alone unless you have set its control setting to `true`, which tells Predbat your charger is not the Octopus device. A charger Predbat was holding is handed back as usual, so a charger it had stopped is started again rather than left stopped with nobody in charge. When the dispatch sensor does not say, Predbat logs a warning naming the setting to change; while it is only waiting for its Octopus or Kraken component to find your devices, it just notes that it is waiting and leaves the charger as it is. If that component has still not found your devices after 15 minutes, Predbat warns and treats it as not being able to tell. + +**switch.predbat_octopus_intelligent_charging** only changes Predbat's own planning. With it off, a charger Octopus drives is still left to Octopus - Octopus goes on switching it either way - and a charger for a car Octopus drives follows Predbat's own plan, whether or not **switch.predbat_octopus_intelligent_charger_follows_car** is on. + +### Guest charging + +While Predbat drives a charger it holds it off outside the planned windows, so a visitor's car would not charge. Turn on the charger's guest charging switch - `switch.predbat_gecloud_guest_charging`, `switch.predbat_myenergi_guest_charging` or `switch.predbat_ohme_guest_charging` - and Predbat hands the charger back, as it does for read only mode. Choose how the guest charges (boost, solar only and so on) on the charger itself. + +Guest charging turns itself off when a car plugged in after you switched it on is unplugged, or after 12 hours. Unplugging a car that was already on the charger - your own, making way for the guest - does not end it, so if the guest plugged in before you switched it on, it runs until the 12 hours are up or you switch it off. It is not remembered across a restart, which puts Predbat back in charge. + +While it is on, the guest's charging is counted as your car's charging - Predbat has no way to tell the two apart. + ## Car Charging Planning There are two ways that Predbat can plan the slots for charging your car: diff --git a/docs/components.md b/docs/components.md index 09acdecb5..669dff9eb 100644 --- a/docs/components.md +++ b/docs/components.md @@ -513,7 +513,7 @@ Connects directly to the GivEnergy Cloud to control your GivEnergy inverter and | `api_key` | String | Yes | - | `ge_cloud_key` | Your GivEnergy Cloud API key | | `automatic` | Boolean | No | false | `ge_cloud_automatic` | Set to `true` to automatically configured Predbat to use GivEnergy Cloud direct (no additional apps.yaml changes required) | | `automatic_evc` | Boolean | No | false | `ge_cloud_automatic_evc` | Set to `true` to wire your GivEnergy EV chargers into `car_charging_energy`, `car_charging_planned` and `num_cars` — see [EV chargers](#ev-chargers-gecloud). Separate from `ge_cloud_automatic` because it registers a car | -| `evc_control` | Boolean | No | false | `ge_cloud_evc_control` | Set to `true` to let Predbat start and stop your EV charger from its car charging plan — see [Charger control](#charger-control-gecloud). Needs `ge_cloud_automatic_evc` | +| `evc_control` | Boolean | No | unset | `ge_cloud_evc_control` | Lets Predbat start and stop your EV charger from its car charging plan — see [Charger control](#charger-control-gecloud). Unset follows `ge_cloud_automatic_evc`; `false` keeps it off | | `load_today_ignore` | Boolean | No | false | `ge_cloud_load_today_ignore` | Set to `true` to ignore GE Cloud load_today data and use the `load_today` sensor from `apps.yaml` instead | | `automatic_shared_ct` | Boolean | No | false | `ge_cloud_automatic_shared_ct` | Set to `true` to force shared CT clamp mode — only the first inverter's grid and load readings are used, preventing double-counting on multi-inverter systems with a single shared CT | | `automatic_split_ct` | Boolean | No | false | `ge_cloud_automatic_split_ct` | Set to `true` to force split CT clamp mode — each inverter's readings are summed independently. Takes priority over `ge_cloud_automatic_shared_ct` if both are set | @@ -592,14 +592,15 @@ connected and logged once, so please report the value from the log so it can be #### Charger control (gecloud) -With `ge_cloud_evc_control` set to `true`, Predbat drives each charger from its own car's -plan: `start-charge` inside a planned charging window, `stop-charge` outside one. Charger N +With charger control on - `ge_cloud_evc_control` unset with `ge_cloud_automatic_evc` on, or set to +`true` - Predbat drives each charger from its own car's plan: `start-charge` inside a planned charging window, `stop-charge` outside one. Charger N follows car N, in the same serial order the automatic configuration uses, so the two cannot disagree about which charger is which car. `ge_cloud_automatic_evc` must also be on, since it is that configuration which establishes -the charger to car mapping. Predbat says so in the log and leaves control off rather than -guessing if you enable control without it. +the charger to car mapping. If you set `ge_cloud_evc_control: true` without it, Predbat says so +in the log and leaves control off rather than guessing. On Octopus Intelligent, see +[Octopus Intelligent and charger control](car-charging.md#octopus-intelligent-and-charger-control). - A command is only sent when the wanted state actually changes, so a charger already charging inside a window is left alone rather than commanded every minute @@ -899,7 +900,7 @@ Integrates with Ohme EV chargers to monitor charging sessions and coordinate cha | ------ | ---- | -------- | ------- | ---------- | ----------- | | `email` | String | Yes | - | `ohme_login` | Your Ohme account email address | | `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_automatic` | Boolean | No | `False` | `ohme_automatic` | Set to `true` to register the Ohme charger with Predbat as a car. Unless Predbat controls the charger (`ohme_control`), 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 (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 | @@ -953,7 +954,7 @@ Predbat supports both of myenergi's APIs: | `automatic_eddi` | Boolean | No | true | `myenergi_automatic_eddi` | Set to `false` to wire only the Zappi half of the automatic configuration, leaving your Eddi out of `iboost_energy_today`. Separate from `automatic` so either device kind can be excluded on its own | | `enable_controls` | Boolean | No | true | `myenergi_enable_controls` | Set to `false` for monitor-only operation | | `poll_seconds` | Integer | No | 60 | `myenergi_poll_seconds` | Poll interval in seconds, rounded to the nearest whole multiple of 60, minimum 60 and maximum 1800 (a longer gap would make Predbat's own health check report the component as failed) | -| `zappi_control` | Boolean | No | false | `myenergi_zappi_control` | Set to `true` to let Predbat drive your Zappi from its car charging plan — see [Zappi charge control](#zappi-charge-control-myenergi) | +| `zappi_control` | Boolean | No | unset | `myenergi_zappi_control` | Lets Predbat drive your Zappi from its car charging plan — see [Zappi charge control](#zappi-charge-control-myenergi). Unset turns on only if you wrote `myenergi_automatic` or `myenergi_automatic_zappi` yourself; `false` keeps it off | The component only starts when at least one of `myenergi_api_key`, `myenergi_key` or `myenergi_token_hash` is set. That test is a plain any-of and does not look at `myenergi_auth_method`, so a credential belonging @@ -1001,7 +1002,7 @@ Not implemented in this release: priority, minimum green level, phase setting, a #### Zappi charge control (myenergi) -With `myenergi_zappi_control: true` Predbat drives your Zappi from the car charging plan it has already worked out, instead of you scheduling the charge on the Zappi itself. +With Zappi control on, Predbat drives your Zappi from the car charging plan it has already worked out, instead of you scheduling the charge on the Zappi itself. Control is on with `myenergi_zappi_control: true`, or with it unset when you have written `myenergi_automatic` or `myenergi_automatic_zappi` into `apps.yaml` yourself. Both of those default on, so a Zappi user who never mentioned them is not switched over. Inside a planned charging window Predbat puts the Zappi in **Fast**, and outside one it puts it in **Stopped**. Fast is used because the window was chosen for its electricity rate rather than for sunshine — Eco or Eco+ would only charge from surplus, and the car would not get what the plan assumed. @@ -1011,7 +1012,7 @@ Predbat re-checks the Zappi every minute. If the mode is changed in the myenergi ##### The control switch -A `switch.predbat_myenergi_zappi_control` entity appears once `myenergi_zappi_control` is set. It starts **on**, and turning it off hands your Zappi back without editing `apps.yaml`. The setting is remembered across restarts, so a restart will not quietly take control back. +A `switch.predbat_myenergi_zappi_control` entity appears once Zappi control is on. It starts **on**, and turning it off hands your Zappi back without editing `apps.yaml`. The setting is remembered across restarts, so a restart will not quietly take control back. ##### When Predbat hands the Zappi back @@ -1019,11 +1020,11 @@ Predbat releases the Zappi when the control switch is turned off, or when Predba ##### Two things to expect -Charge control needs `myenergi_automatic` and `myenergi_automatic_zappi`, because it is that configuration which establishes which Zappi belongs to which car. It also needs `myenergi_enable_controls`. If any of them is off, Predbat logs which one and leaves the Zappi alone. +Charge control needs `myenergi_automatic` and `myenergi_automatic_zappi`, because it is that configuration which establishes which Zappi belongs to which car. It also needs `myenergi_enable_controls`. If any of them is off, Predbat leaves the Zappi alone, and logs which one when you set `myenergi_zappi_control: true`. While Predbat is in control the Zappi is in Fast or Stopped, and myenergi only accepts a boost in Eco or Eco+ — so the manual boost switch will refuse for as long as control is on. Turn the control switch off if you want to boost by hand. -Outside a planned window the Zappi is Stopped, which means it will not divert surplus solar to the car either. If you would rather keep solar diversion, leave `myenergi_zappi_control` off and let the Zappi run its own modes. +Outside a planned window the Zappi is Stopped, which means it will not divert surplus solar to the car either. If you would rather keep solar diversion, set `myenergi_zappi_control: false` and let the Zappi run its own modes. #### Known limitation (myenergi) diff --git a/tools/debug-journal.md b/tools/debug-journal.md index ace1e8fb3..338af7d9d 100644 --- a/tools/debug-journal.md +++ b/tools/debug-journal.md @@ -116,7 +116,7 @@ Grep for the named symbol rather than trusting a line number. | Sunsynk / DEYE (`sunsynk.py`, `deye.py`) | Freeze export is gated by the per-slot power register (`sellTime{n}Pac` on Sunsynk), confirmed on live hardware — setting the energy mode alone had no effect, and with slot power at zero the battery still charged. `read_only` was ignored by the reconcile loops until `_is_read_only()` gated `_reconcile_control()` (GH#4436). DEYE cloud telemetry keys are one hardcoded spelling per metric — `DEYE_TELEMETRY_KEYS["pv_power"] = "TotalSolarPower"` (`deye_const.py`), confirmed against a single live 3-phase hybrid — and a SUN-8K-SG05LP1-EU-AM2-P instead exposes `TotalDCInputPower` + `DCPowerPV1/2/3`, neither anywhere in the tree. Because `pv_power` is in `DEYE_TELEMETRY_REQUIRED`, the miss fires the `device/latest missing expected keys [...] - telemetry will read zero` warning every cycle and the Cloud PV sensor publishes 0.0 while a manual `pv_power` arg to a local sensor may still protect the planner. Reproduced by swapping the test fixture's single PV key (`tests/test_deye_api.py` `LIVE_DATA_LIST`). Whether the alternate key is unit-equivalent (vs summing `DCPowerPV1-3`) needs a live capture from an affected model — suspected, not verified. CONFIRMED live (Sunsynk 2211093089, 2026-09-19, via `sunsynk.py --tou-test`) that the settings object exposes the TOU register block a SECOND time under unrelated names, so several keys that read like independent settings are the same memory at a different scale: `volt1`-`volt4` are `sellTime3`-`sellTime6` as minutes-since-midnight / 50, `volt5`-`volt10` are `sellTime1Pac`-`sellTime6Pac` / 10, and `volt11`, `volt12`, `current1`-`current4` are `sellTime1Volt`-`sellTime6Volt` x 10 — a contiguous 16-value window, matched 16/16 in both the before and after reads of one write. Two consequences. It is independent proof a TOU write reached the real registers rather than a cloud-side echo, which is the cheapest hardware verification available. And it is a trap: anything that wrote `volt*`/`current*` would silently corrupt the charge programme. Predbat is safe today only because `SUNSYNK_SYSTEM_MODE_FIELDS` excludes them, so treat that exclusion as load-bearing rather than incidental. The alias does NOT extend to what the write payload must contain — `time{n}On` (capital O) is a derived echo of `time{n}on` and is correctly excluded via `SUNSYNK_DERIVED_SLOT_FIELDS`. **GH#5138**: the (pre-#5360 wording) `control cache is X minutes old (limit 15)` forced-rewrite check is **startup-only** - it lives in `restore_state()`, called only from `run()`'s `if first:` block - so a reporter reading `limit 15` as "rechecks every 15 minutes" is wrong; it never runs again after the first cycle. The issue's core premise (idle slots never clear `sellTime{n}En`) was disproved: every slot `_owned_payload` write carries an explicit sell flag, idle slots are written with `sell: 0`, and the TOU build places a sell-0 baseline segment at each window's end (probe-verified; a `sellTime5En: 1` read-back just after an export window can be *correct*). The real gap it exposed - a write the cloud API acknowledged but the dongle never collected was trusted forever, because the owned-field change gate suppresses identical re-sends and `note_settle` only warned - is **fixed in PR #5142 (merged 2026-09-19)**: sustained settle divergence now clears `applied_payload[sn]` so the next write cycle re-applies. Keep the mechanism for pre-#5142 logs, where a stuck export slot needed a restart to clear. No debug yaml was ever attached, so the reporter's exact overrun is unconfirmed; the `has not applied Predbat's settings after N settings polls` warning is the tell. Two component-side traps since. **PR #5150** (merged 2026-09-19): `control_active` is now persisted and restored alongside `applied_payload` under the same 15-minute bound (`SUNSYNK_RESTORE_MAX_CONTROL`/`DEYE_RESTORE_MAX_CONTROL`), so a restart **inside** the bound no longer strands an armed inverter in silence; on the upgrade path a cache carrying `applied_payload` with no `control_active` key infers the missing half from the payload keys — a safe lower bound, since `applied_payload[sn]` is written only by `apply_settings`/`apply_dynamic_control`, whose production callers arm `control_active` first (the CLI paths apply with `force=True` and never save). **PR #5360 (merged 2026-10-05, unreleased at the time of writing, fixes GH#5349 below) has since split the two halves into separate caches with separate bounds:** `applied_payload` keeps the 15-minute bound (`SUNSYNK_RESTORE_MAX_CONTROL`/`DEYE_RESTORE_MAX_CONTROL`) while `control_active` gets 8 hours (`SUNSYNK_RESTORE_MAX_CONTROL_ACTIVE`/`DEYE_RESTORE_MAX_CONTROL_ACTIVE`, in a cache of its own, with a migration path from the pre-#5360 combined cache), and the restore log line splits to match - `Info: Sunsynk control payload cache is X minutes old (limit 15), forcing a rewrite` drops only the payload cache; a new `Info: Sunsynk control ownership cache is X minutes old (limit 480), requiring a fresh write` is the one that drops the ownership half. And the in-code comment on the Sunsynk write-button handler claiming Predbat "presses this on every cycle" is wrong about frequency: both press sites in `inverter.py` are change-gated (target SoC presses only when `current_soc != soc`, the export window only when `schedule_changed`), and the one unconditional post-restart re-commit is `is_hm_format`-only, which excludes these types' `HH:MM:SS` — so after a restart with the plan already matching the inverter, nothing is pressed for hours. Read the press gates in `inverter.py` before using that comment to dismiss a "component stopped writing after a restart" report. **GH#5349 (2026-10-02, fixed in PR #5360, merged 2026-10-05 - keep the mechanism for pre-#5360 logs) joined the two halves end to end:** on a pre-#5360 build an HA outage restarted Predbat with the (single) control cache 35.9 minutes old, restore dropped both `applied_payload` and `control_active`, and **nothing performed the forced rewrite on its own** - `_reconcile_control()` gates on `control_active`, `number_event` reaches Predbat only as in-memory `update_local_schedule()` updates (no apply), and both press sites stayed change-gated - so zero settings POSTs fired from 00:59 to 03:02 (counted from the reporter's full-API-logging log) while the hold re-armed every cycle and the inverter's idle-cap-5 programme drained ~9 kWh into the EV until a plan change finally pressed the write-button at 03:02. #5360 closes it by keeping `control_active` for 8 hours (see above), so the reconcile path can own the re-apply within that window. Reading the "forcing a rewrite" line as if a rewrite follows is still the trap - it is the notice that the payload cache was **dropped**; a "holds lost for hours after an HA blip / battery drained while Predbat showed holds armed" report on a pre-#5360 (v9.3.5-) build maps here, and on post-#5360 builds the `has not applied Predbat's settings after N settings polls` settle path is the remaining backstop. Device-visible hold legs on these types (same log): the TOU slot cap is the only one — a hold is `cap = SoC+1` in the programme (cap 65 held the battery at 64% while the car drew from grid; cap 5 drained it toward the inverter floor), and the `discharge_rate 0` entity write is symbolic on this type, so a "wrote 0 to discharge_rate but the battery drained" report reads the cap, not the entity. Also normal for this type: Sunsynk TOU charge windows cannot cross midnight (`can_span_midnight: False`) — a 23:00–00:30 window is written 23:00–23:59 and re-rolled to 00:00–00:30 at midnight (log-confirmed; not a bug). **GH#5156** (padding truncating any TOU window crossing 04:00/08:00/12:00, `build_tou_slots()` — now shared by both components in `tou_schedule.py`) is **fixed**: padding slots now carry the state of the slot they follow and sit just after it, an inert interval that cannot end a window early (`_padding_segments()` documents the issue). The pre-fix tell was "Deye stops charging at 00:00/04:00/08:00/12:00/16:00/20:00 while the Plan page still shows charging", freshly generated each cycle — not a stale default slot. **GH#5193 (cloud component, load telemetry):** on Sunsynk M3.3.8.9 the `totalPower` field (`load_power`, `SUNSYNK_TELEMETRY`) read 0–100 W through a whole overnight cycling stretch while the battery was active — including mode-2 ("Limited to Home") stretches — and read plausibly the adjacent morning with the battery idle, so the collapse correlates with the battery being active, not cleanly with the work mode; what gates it is unresolved (suspected; the reporter's manual A/B blamed the mode flip, the log's mode-2 stretches say something else does too — a controlled live test on the affected firmware is the only settle). `dailyUsed` (`load_today`) kept counting at a plausible coarse rate through the same stretch, so a balance-derived load remains feasible — and `fill_load_from_power()` is the repair that gets nothing while `totalPower` is zero. Predbat's own export control writes the whole-system `sysWorkMode` to `selling_first` for every planned export window (`derive_control_state()`, applied in the settings payload), and the component auto-wires both load args through `set_arg_auto()`'s default `overwrite=True`, so a manual apps.yaml override does NOT win — only `sunsynk_automatic: False` opts out. The in-file docstring on main records the mode/battery-export reading plus the live counter-evidence (11.1 kWh exported in a day under "Limited to Home" with `solarSell` on; a separate live test showed battery export under mode 2 with the per-slot sell flag), so the docstring's "the mode governs battery export" claim is DEYE-inferred and not settled — a control-side alternative (stay in mode 2, drive `sellTime{n}En`) stays open. The component logs every API request/response in full in `predbat.log`, so a whole Sunsynk-cloud investigation can run from the log alone; when mining logged JSON widen the field width before trusting a truncated number (`0.4` prints as `0.` under a 14-char `substr`). **Automatic config maps a battery-side current into `battery_rate_max`, and `inverter_limit` does not bound the written rate (GH#5238, code-read on main 2026-09-25/29):** `battery_rate_max()` derives watts from `chargeCurrentLimit`/`maxChargeCurrentLimit` × nominal pack voltage (`SUNSYNK_CHARGE_CURRENT_FIELDS`, first-positive-wins — a battery/BMS-side limit; GH#5238's 280 A BMS derived 14.3 kW against an 8 kW inverter), `automatic_config()` maps it to `battery_rate_max` and `ratePower` to `inverter_limit` but **never sets `inverter_limit_charge`/`inverter_limit_discharge`**, and `inverter.py` defaults both of those to `battery_rate_max_raw` when unset — so `battery_rate_max_charge = min(raw, raw) = raw`, and that is the per-slot power the plan writes. `prediction.py` clamps only *predicted* flow to `inverter_limit`; nothing feeds `inverter_limit` into the written rate. `deye.py`'s `battery_rate_max()` docstring states the same wrong assumption outright ("inverter_limit constrains the AC side separately" while deriving from `maxChargeCurrent`), and its `automatic_config()` has the same two-arg shape — so treat any "automatic config shows/writes an impossible rate" report for a cloud component through this lens first, and a fix has to map `inverter_limit_charge`/`_discharge` (to `min(ratePower, derived battery rate)`); deriving the true inverter-side current setting needs a telemetry mapping Sunsynk does not currently expose. Workaround semantics: `inverter_limit_charge`/`inverter_limit_discharge` are safe apps.yaml overrides under `sunsynk_automatic` (the component never sets them), while `battery_rate_max` is not — `set_arg_auto(overwrite=True)` clobbers an apps.yaml value every cycle. | `sunsynk_control`, `deye_control` | | Grid sign / arrow direction | `grid_power_invert` is owned by some integrations and not others. With two systems configured, one integration setting it `True` bleeds into the other's entities and inverts the arrows. The fix is an explicit `False` in the automatic config of both. | `sunsynk_config`, `teslemetry` | | Enphase (`enphase.py`) | Unofficial Enlighten endpoints. Accounts with MFA cannot log in at all. Discharge-to-grid schedules are required for export control. Writes need a double-submit CSRF token or return 403. Using the Enphase app at the same time can trip session limits. | `enphase_api` | -| myenergi (`myenergi.py`) | `myenergi_automatic_zappi` (PR #4997) gates the Zappi half of automatic config; with it off, Zappis stay monitor-only and `control_active` is never set. `release_zappis()` has exactly one caller — `control_tick()` — reached only under `if self.control_active:` in `run()`, and `control_active` is latched at the first tick: every `enable_control()` refusal (zappi_control → automatic → automatic_zappi → enable_controls) returns *before* setting it, and the gate re-runs only when a fresh instance starts. So after a component restart with any prerequisite off, a Zappi already held in 'Stopped' stays Stopped and the `switch.*_myenergi_zappi_control` entity is not even republished (it is gated on `control_active` too) — the symptom for a future report is "car won't charge after I turned off Predbat" with a Zappi. Note `control_tick()` itself *does* release on read-only mode and on the control switch being turned off; it is the config-gate half that strands. Since PR #5150 (merged 2026-09-19) sunsynk/deye instead persist `control_active` across a restart (and re-infer it from a pre-upgrade cache), so a restart cannot strand an already-armed inverter there — the myenergi strand is the opposite direction (the gate never latching in the first place) and remains. Two adjacent lifecycle facts: published entities are **never removed** — `dashboard_item()`/`set_state()` are upsert-only (`output.py`, `ha.py` POST `/api/states`), so when a gate stops `publish_data()` publishing a switch the old entity lingers frozen at its last state until HA restarts, and there is no entity-removal path anywhere in the codebase; and `set_arg`/`set_arg_auto` writes survive a component-only restart, because `Components.restart()` stops and re-creates only the instance and never resets the shared `base.args` from apps.yaml — auto-wired values (e.g. myenergi's `car_charging_*`) persist until a full process restart. Distinguish the two restarts when checking arg lifecycle. | `myenergi` | +| myenergi (`myenergi.py`) | `myenergi_automatic_zappi` (PR #4997) gates the Zappi half of automatic config; with it off, Zappis stay monitor-only and `charger_control_active` is never set. `charger_control_release()` (shared with the other chargers via `CarChargerControl` in `car_charger_control.py`) has exactly one caller — `charger_control_tick()` — reached only under `if self.charger_control_active:` in `run()`, and `charger_control_active` is latched at the first tick: every `enable_control()` refusal (zappi_control → automatic → automatic_zappi → enable_controls) returns *before* setting it, and the gate re-runs only when a fresh instance starts. So after a component restart with any prerequisite off, a Zappi already held in 'Stopped' stays Stopped and the `switch.*_myenergi_zappi_control` entity is not even republished (it is gated on `charger_control_active` too) — the symptom for a future report is "car won't charge after I turned off Predbat" with a Zappi. Note `charger_control_tick()` itself *does* release on read-only mode and on the control switch being turned off; it is the config-gate half that strands. Since PR #5150 (merged 2026-09-19) sunsynk/deye instead persist `charger_control_active` across a restart (and re-infer it from a pre-upgrade cache), so a restart cannot strand an already-armed inverter there — the myenergi strand is the opposite direction (the gate never latching in the first place) and remains. Two adjacent lifecycle facts: published entities are **never removed** — `dashboard_item()`/`set_state()` are upsert-only (`output.py`, `ha.py` POST `/api/states`), so when a gate stops `publish_data()` publishing a switch the old entity lingers frozen at its last state until HA restarts, and there is no entity-removal path anywhere in the codebase; and `set_arg`/`set_arg_auto` writes survive a component-only restart, because `Components.restart()` stops and re-creates only the instance and never resets the shared `base.args` from apps.yaml — auto-wired values (e.g. myenergi's `car_charging_*`) persist until a full process restart. Distinguish the two restarts when checking arg lifecycle. | `myenergi` | | Wallbox (`wallbox.py`) | Component landed on main in PR #5410 (merged 2026-10-05, unreleased at the time of writing). Cloud API reimplemented from the Home Assistant `wallbox` integration and the `wallbox` PyPI library, over aiohttp. Findings from the first live runs (5 Oct 2026, a Pulsar Plus on firmware 6.7.43): sign in works with `User-Agent: Predbat`. **A charger run by an OCPP backend** (here Octopus, for Intelligent Octopus Go) reports `config_data.operation_mode: "ocpp"`, sits in status 209 Locked, and an API unlock (`PUT v2/charger/` `{"locked": 0}`) is accepted but has no effect - so "the lock switch flips back" on such a charger is the backend, not a bug. On that locked charger a pause returned **HTTP 403** and a resume **HTTP 409**, from an account whose profile is super-admin: 403 on a control does *not* prove missing admin rights, whatever the HA integration assumes. `held_by_lock()` therefore sends neither while locked, and `held_by_ocpp()` keeps plan-led control off such a charger. `GET v3/chargers//ocpp-configuration` returns the backend address and the charge point credentials - **its reply contains a password, so never paste `--get` output unredacted**; no OCPP on/off write call is known. `locked` arrives as `1`/`0`, not a boolean, and `state_of_charge` is always null (a Type 2 connector cannot report it). Charger N is car N through `car_order`, which is numeric and append-only, so a status failure or a charger added later cannot shift it. Not yet verified on hardware when this was written: pause/resume on an unlocked, charging, non-OCPP charger; the real status ids for charging and paused; whether a 120s poll stays clear of HTTP 429. `python3 wallbox.py --username --raw` is the capture tool (it prompts for the password). Release bookkeeping: `paused_by_predbat` and `schedule_pending` are stored through Storage *before* a pause is sent, a record is dropped only on a fresh status that settles it (unplugged, or connected and no longer paused), and each charger's control step is isolated in `per_charger()` so one charger's failure cannot block another - if "a charger stayed paused after control was turned off" is reported, read `release_one()` first. | | Gateway MQTT (`gateway.py`) | Control writes are MQTT commands the hub acknowledges. **The ack design arrived with PR #5220 and was refined by PR #5231 (both merged 2026-09-26)** — both postdate every earlier gateway log, so keep a publish-per-call reading for pre-#5220 logs: `_subscribe_acks()` subscribes to `predbat/devices//ack/+`, tolerating a broker that refuses it (a single warn via `_ack_subscribe_warned`; without the subscription or before any ack, `_send_control()` publishes exactly as before); once acks are seen, an identical command (same entity, command, payload) is published once per `_COMMAND_ACK_WINDOW` (30s), re-sent once after the unanswered window, and if that re-send is also unanswered `_acks_seen` drops so writes fall back to publish-every-call until telemetry confirms; `_process_ack()` matches any tracked id of the current value, a refusal outranks an ok (a multi-unit dispatch acks once per unit), and replay errors are handled; ids come from a clock-seeded monotonic counter (`_next_command_id()`, `PBAT`), so they never restart at `PBAT1` after a restart, and the kept-id list (`_COMMAND_ACK_IDS_KEPT`) is pruned only after the new send is out, with an ack that arrives during a failing publish still counting. **PR #5172 is a separate, still-open implementation of the same feature** (`_subscribe_command_acks`, `uuid4().hex` ids, typed outcomes threaded through service dispatch/HTTP/inverter verifier) — its symbols are not on main until it rebases, so do not start a gateway-ack triage from its names. **Two gaps around serial-less and empty status, probed on main 2026-09-25 (GH#5227, enhancement — probed with temporary tests in `test_gateway.py`, reverted after):** (1) a sole slot publishing `serial=""` (shipped firmware 1.0.0 publishes every slot, including a GivEnergy slot whose serial discovery failed) is bound as the control target: `_needs_reconfigure()` treats `""` as a newly discovered inverter, `_needs_reconfigure()` treats `""` as a newly discovered inverter and auto-config binds it — note the last-resort branch that used to fall back to `candidate_aios or list(all_inverters)` was removed in `eaef6e61` (2026-10-04), which now defers auto-config entirely with a warn when no battery-capable telemetry has arrived (`_auto_configured` stays False so `_needs_reconfigure()` retries); an empty-serial slot that does report battery telemetry is still bound, so the trap survives on that shape, and the result is empty-suffix entities (`select.predbat_gateway__charge_slot1_start`) and commands addressed to `""`, which the firmware rejects (`dongle_serial required`) — a real fleet incident on 2026-09-16 (~22 min of failed commands); an empty-serial guard in `_needs_reconfigure()`/`automatic_config()` would close it, and #5227's proposed `dongle_count` deferral does *not* cover this state (`dongle_count == len(inverters)` when the empty slot is the only one). (2) `if len(status.inverters) == 0: return` (`gateway.py`) fires **before** `_inject_entities()` (EV chargers, gateway-online) and before `_last_telemetry_time`/`update_success_timestamp()` — on a hub where all GivEnergy slots are withheld (post-predbat-gateway#335, e.g. a single-inverter hub), every status message is dropped: EV data freezes and the component fails the 60-minute staleness test (`components.py`). Symptom pointer: "gateway EV sensors froze / gateway component unhealthy while the hub is up" → this early return. Removals staying bound and reappearance triggering re-config are already the behaviour (probed). **Probe trap:** the topology branch in `automatic_config()` depends on the *whole* visible set — a probe that adds a Gateway alongside the `""` slot drops the `""` entry via the battery-presence filter (`aios` requires `battery.ByteSize() > 0`) so it never reaches the last-resort branch; construct the exact visible set the scenario implies, and note the filter silently hides data-less slots from the count in several branches (the same mechanism can make a Gateway+2-AIO fleet collapse to AIO-direct control). Existing precedent for topology probes: `TestGatewayUnitControlBinding` (`test_gateway.py`). | `gateway` | | Octopus (`octopus.py`, `fetch.py`) | Intelligent Go tariffs are detected via `is_intelligent_go_tariff()`, and IOG-prefixed tariffs must be skipped when updating intelligent devices. Saving-session auto-join rebinding regressed when `joined_events` was empty (GH#4573). `octopus_slots_signature()` deliberately omits the time-drifting fields of active dispatch slots so a replan is not forced every cycle. `car_charging_threshold` is a strict fallback gated on `not self.car_charging_energy` (`fetch.py:228`, and `load_ml_component.py:348-361`) — it never runs as a second filter alongside a real `car_charging_energy` sensor (GH#4717). Saving-session reporting credits the full saving rate to every minute of the session on both rate tables (`load_saving_slot()`, `octopus.py:2786`); the slot dict has no baseline field to subtract (GH#2090) — a complaint that a saving session's reported total looks inflated starts here, not in a rate-fetch bug. A tariff with no `standard_unit_rates` link (IOG-TOU, GO) goes down `async_get_day_night_rates()`, which infers the off-peak window from 7 days of measurement TOU labels by plurality vote. On IOG those labels include ad-hoc dispatch slots, so a bonus slot recurring on 4+ of 7 nights became a permanent nightly cheap window and Predbat charged into it at the day rate (PR #4854 skips the inference for IOG). A "cheap slot Octopus has never heard of" report where the dispatch feeds are *empty* is this, not phantom dispatches — check the log for `Using off-peak windows [...] from measurement TOU labels`. TOU labels are UTC instants, so windows derived from them must be re-anchored for a local wall-clock tariff or they drift an hour at DST. Free-session slots have their own family of gates, separate from the saving-session ones and each found the hard way. `octopus_free_session` events used to be dropped when `code` was null, which is exactly how Octopus publishes auto-joined Weekend Happy Hours - fixed, the gate is now `start and end` and the log falls back to the event id (GH#4835). The free/saving-session args name **event** entities (`attribute="events"`/`"joined_events"`, `octopus.py`), and the BottlecapDave integration exposes Octoplus sessions as calendar + event pairs of which only the event entity carries those state attributes - the calendars have none at all, and both sides ship **disabled by default** in the integration. "The documented sensor name does not exist" plus a proposal to point at the `calendar.` twin is almost always the disabled-default trap, not a rename (GH#5370, verified against the integration's source): enable the event entity; the calendar twin cannot back these args. Matching calendar/event names differ by the `_events` suffix, so a pattern built from one domain's names mistranslates, and the deprecated pre-v17 names are scheduled for removal in January 2027. `load_free_slot()` bounded itself with `start_minutes < self.forecast_minutes` while rate minutes are indexed from `midnight_utc`, so any event more than `forecast_minutes` past midnight was dropped with no log line at all - fixed in `3bacc6e8`, the gate and the end clamp now both use `forecast_minutes + minutes_now` like `load_saving_slot()` (GH#4931). In the same function the start/end range was only updated on a successful decode while the apply block ran unconditionally, so an undecodable slot re-applied its rate over the *previous* slot's minute range (PR #4935). The join side has its own: the `joined_events` guard still ends in `saving_rate > 0` (`octopus.py:3542`), so `octopoints_per_kwh: 0` - a genuine free hour - yields no slot of any kind; probe-verified (0 gives nothing, 500 gives a saving slot, null gives nothing), and `git blame` puts that guard in `1b8c9136`, the fix for null-rate sessions (GH#3079), so it is an oversight rather than a deliberate exclusion (GH#4851). On dispatches, `rate_add_io_slots()`'s `location` check used to apply to *completed* dispatches too, and `rate_import` is rebuilt every cycle, so a retroactive AT_HOME to AWAY relabel silently un-stamped the cheap rate and `today_cost()` re-priced the whole day at the day rate (GH#4946) - **fixed in PR #4957**, which added `dispatch_billed_off_peak()` (`octopus.py:2977`): a completed dispatch is billed off-peak regardless of location, a straddling one keeps the location test, and the midday budget cap still applies. Keep the mechanism in mind when reading a log from before that merge, where the whole day reprices at the day rate. A neighbouring cap bug had no entry here at all: the daily low-rate slot budget was keyed on the *loop* minute rather than the slot start, so a window straddling noon drew a fresh 12-slot budget at 12:00 and stamped the afternoon cheap (GH#4950, **fixed in PR #4951**). The midday boundary itself is deliberate (`337db867`); only the keying was wrong. Worth knowing because "a cheap slot at an hour Octopus never offered" has at least three distinct causes in this row alone. Compare has its own rate-fetch gap: `download_octopus_rates_func()` (`octopus.py:2725`) reads only `standard-unit-rates`, and the newer IOG-SMB-FIX products are `four_rate_ev` - that endpoint answers 200 with empty results and the rates live on `day-unit-rates`/`night-unit-rates`. The main OctopusAPI component already auto-detects that product shape; compare never got the same treatment (GH#4921, probed against the live API). Lastly, `{dno_region}` in a tariff URL is substituted by `resolve_arg`'s `.format(**self.args)` (`userinterface.py:106-116`), so a missing `-` before the placeholder glues the region letter onto the product code and yields a plausible-looking 404 - check the literal URL in apps.yaml before believing a tariff has been withdrawn. Two later additions to the free/saving-session picture, one of them a correction. **eventType is carried only by the legacy `savingSessions` query path — the flexibility feed drops it (GH#4548, re-verified on main 2026-09-29 by reading `async_get_flexibility_events()`):** that function extracts only `code`/`startAt`/`endAt` from `customerFlexibilityCampaignEvents` and maps every saving event into `joinedEvents` with **no eventType**, so on the Direct path with an MPAN the joined-event loop's free-slot branch (`event_type == "WEEKEND_HAPPY_HOUR"`) can never fire and a joined Happy Hour falls through to the rewarded-saving-slot branch whenever `octopus_saving_session_rate` is set above 0. The legacy query carries eventType (the `savingSessions` GraphQL request names it and its map stores it) and is reached on the Direct path only as the flexibility path's own fallback (no MPAN, or the flexibility API returned no saving events). eventType remains the only sound discriminator between a Power Down saving session and a free Power Up/Happy Hour — the field GH#4851's `saving_rate > 0` problem needed and did not have — but *which query path served the events* decides whether you have it: "free Power Up priced as a paid saving session" on the flexibility path is first a question of provenance, and waiting for the BottlecapDave API before building on the newer feed is deliberate, not an oversight. The Direct join mutation is likewise still the legacy `joinSavingSessionsEvent`. And a genuinely counter-intuitive ordering constraint, worth reading before touching that function: Weekend Happy Hours are now skipped from `available_events` (they cannot be joined through the API - Octopus allocates them or the user books on the website), but **the skip has to sit after the reward/code/type maps are populated**, because those maps are built from the same events list and a joined Happy Hour looks its own type up there. Skip too early and the joined event has no type, takes the injected default reward, and the planner prices a free hour as an **80p/kWh saving session** (`c0fb4e9c`; the test fails if the skip is moved above the maps). Two rate-provenance reports from mid-September. **GH#5012** (car unplugged, plan still charged the house battery in the phantom cheap window): `octopus_intelligent_ignore_unplugged` correctly removed the planned dispatches, but the cheap price came from the integration's own rate sensor, not Predbat's stamping — parse the debug yaml and compare, per suspect minute, `rate_import_base` vs `rate_import_no_io` vs `io_adjusted`: a cheap price present in base and no_io with `io_adjusted` empty is the integration's rate sensor; present only after no_io is Predbat's `rate_add_io_slots()` stamping (#4516/#4950 family); `io_adjusted` populated means the minutes were flagged as adjusted — but **since PR #5304 (v9.3.3) Predbat's own `rate_add_io_slots()` flags the minutes it lowers too** (it used to write no marker at all; it mirrors the integration's `is_intelligent_adjusted`, never the fixed off-peak, time over the daily cap, or a cancelled car), so a populated `io_adjusted` no longer discriminates the integration's stamp from Predbat's own; the base-vs-`no_io` comparison still does, and `rate_replicate()` still refuses to copy `io_adjusted`-flagged minutes into future days. Since PR #5396 (merged 2026-10-05, unreleased at the time of writing) a minute is not marked `io_adjusted` at all when the dispatch price is within `IO_RATE_TOLERANCE` (0.01p) of what it replaced - the unrounded tariff's 5.2314p off-peak receiving a 5.23p dispatch is dp2 rounding, not a dispatch (GH#5392). This attributes "integration feeds the wrong price" vs "Predbat stamps the wrong price" in one step. Nothing reverts `io_adjusted`-flagged minutes when the car is unplugged + ignore_unplugged is on — that fix would be an enhancement, blocked in practice by the integration not flagging stale adjusted entries (`io_adjusted` was empty in this dump). **`io_adjusted` itself was for a while wiped by the export/gas fetch (GH#5286, fixed in PR #5290, merged 2026-09-28):** `fetch_octopus_rates()` replaced `self.io_adjusted` on every call, so the export and gas fetches that follow the import fetch reset it to `{}` whenever `metric_octopus_export`/`metric_octopus_gas` were set — regression from #2826 dropping the old "only when adjust_key is set" guard — and with no markers left, `dynamic_load_car_strip_feed_rates()` had nothing to strip, so a car cancelled out of a dispatch still left it priced cheap and the house battery planned into it. The fix makes `self.io_adjusted` replaced only when the fetch carries an `adjust_key`; keep the mechanism for pre-#5290 logs. A companion fix (same PR) stops a compared tariff inheriting the live tariff's dispatch markers — each compared tariff now starts with none, and one left on the live import rates gets a copy. A user who instead wants the house battery limited to minutes the car actually charges has no clean knob (GH#5065): `octopus_intelligent_charging` off does **not** stop the stamping — the switch gates only car planning and vehicle prefs, planned slots are collected into `self.octopus_slots` regardless (gated only by `octopus_intelligent_ignore_unplugged`, which covers unplugged, not plugged-in-but-deferred) and are consumed unconditionally by `rate_add_io_slots()`. `octopus_slot_max: 0` is not selective either — the cap is applied in `load_octopus_slots()` as well as `rate_add_io_slots()`, so it removes the car's charging slots too. The only complete workaround is unsetting `octopus_intelligent_slot`, which loses completed-slot tracking and Octopus car planning with it. **GH#5018** (tariff switched mid-day; tomorrow's export rates stayed on the old tariff and Nordpool estimates never appeared): Predbat re-reads Octopus rate events from HA every fetch cycle (`fetch_octopus_rates`), so stale export rates are the HA integration still serving the old contract — reload the integration. Two Predbat-side traps in the same report: `futurerate` only fills *missing* minutes (the `if minute not in rates` gate in fetch.py), so stale-but-present rates always win over Nordpool estimates; and `futurerate_adjust_auto` re-runs at every FutureRate init (one per fetch cycle) and persists via `set_arg`, so mid-switch it can persist `False` over the user's manual `futurerate_adjust_export: true` — log signature `FutureRate: No futurerate adjustment enabled, skipping futurerate analysis` repeating every cycle. The debug yaml does **not** contain the Octopus rate events (grep for `day_rates` comes up empty), so replays can't reproduce integration-served rate data; use the log's `Export rates: min/max/average` lines as the provenance check (identical min/max across days = static served data, and a fixed tariff has a fixed shape while a variable one doesn't). `self.mpan` is the **import** MPAN only — `async_find_tariffs()` sets it once from the first active import agreement's `meterPoint` and never overwrites it; an account with export has import and export as separate agreements with different MPANs, and the export one is retained nowhere (PR #4972 review). The saving-session/free-electricity GraphQL queries use `self.mpan` deliberately because those campaigns are import-side; any feature that needs to identify a *meter* cannot take `self.mpan` (PR #4972, merged 2026-09-19, adds `self.tariffs[direction]["mpan"]`). Symptom: two things that should describe different supply points coming out identical — that is this, not a deduplication bug. **GH#5144** (fixed in PR #5145): the REST tariff endpoints return overlapping `DIRECT_DEBIT`/`NON_DIRECT_DEBIT` rows for the same validity window and `minute_data()` writes each row over its range, so whichever row came last in the response won — and the order is not stable across periods, so the displayed rate could flip between variants from one period to the next ("always the higher rate" was an artefact of that ordering, not a property of the bug). `filter_payment_method()` (`utils.py`) now keeps one variant at the parse point on the component, day/night, annual and minute-data paths — preferring `DIRECT_DEBIT`, keeping rows with no `payment_method` (Agile) untouched, and leaving single-variant tariffs unchanged; null `payment_method` is the common case, so any future filter must keep nulls. **The daily cheap-slot cap is derived per account but enforced per car (GH#5215, semantics unresolved):** `get_octopus_slot_max()` (`octopus.py`) takes no `car_n` — it resolves the cap from the *account's* import tariff code via `has_six_hour_cap()` (matches `IOG-SMB` → 12) or a single apps.yaml integer — while both enforcement counters are locals (`slots_per_day` in `rate_add_io_slots()` and in `load_octopus_slots()`), and both callers run once per car, so each car draws a fresh budget: up to `octopus_slot_max × num_cars` cheap half-hours priced per day. Do **not** assume the docs' per-car sentence is simply wrong: the derivation is account-level but Octopus's own blog says "*Your car gets up to 6 hours of off-peak charging per day*" (quoted in GH#4830, the multi-car confusion thread), so the code is internally inconsistent and the intended semantics is genuinely open. Exposure is invisible outside IOG-SMB — uncapped tariffs default `octopus_slot_max` to 48 — so only capped multi-car installs can see the doubling; existing tests encode the per-car behaviour, and `octopus_slot_max` is read without `index=` so per-car configuration does not exist either way. **The IOG slot-confirmation strip removes the house's cheap rate too when a car's dispatch is cancelled (GH#5335, with the #5317 family - code chain on main 57ec7bf1):** with `octopus_intelligent_dynamic` on (default), `dynamic_load_car_check()` (`plan.py`) cancels every slot of a car that sits inside a started dispatch but is not seen charging after the confirmation grace (log: `car 0 is in a dispatch but not charging, cancelling its slots`), and `dynamic_load_car_strip_feed_rates()` (`octopus.py`) re-prices the house's view of those minutes back to `rate_max_base` when the cheapness came from the integration feed, **exempting the fixed IOG band** (`OCTOPUS_NIGHT_RATE_WINDOWS["iog"]` = 23:30–05:30) — so a house battery that had planned into the dispatch loses its cheap stamp as well, and PV10 re-prices the minutes at `rate_max` outright (prediction.py's `dispatch_gone` line, `minute > 30`). Version tell: the strip is v9.3.2+ code — a reporter's deliberate mid-log downgrade left the A/B in one `predbat.log`: v9.3.3 logged `Octopus Intelligent: removed the dispatch rate from 355 minutes of cars [0] which are not charging` while v9.3.1 kept the 6.57p dispatch rate (`Charging target 2%-100%`); workaround (semantics read, not live-tested): `octopus_intelligent_dynamic` off = no confirmation and no strip, ≈9.3.1 behaviour at the cost of #5229's low-load protection. Check the car side first, though: Predbat must *see* the car charging to confirm the slot, and a `plug_status` string (`eco`/`boost`) that never equals `car_charging_now_response: charging` leaves the car "not charging" through a real charge — check the sensor's own history before blaming the planner. **#5316 is the mid-dispatch variant, fixed in PR #5319 (merged 2026-10-03) — keep the pre-#5319 signature:** a car that stops part-way through a running dispatch half hour used to have the rest of that half hour re-priced at the day rate; the strip paths (`rate_add_io_slots()`, `dynamic_load_car_strip_feed_rates()`) now start from `dynamic_load_car_strip_from()` (`plan.py`) — the end of the half hour the car was last seen charging in, which is billed off-peak in full by Octopus — and the confirmation is never cleared, only outlived. Review-round refinements ride along: a sensor reading confirms a half hour only from 2 minutes into it, and the grace clock is not kept across a restart (saved state is judged on the skewed clock). The bounce symptom itself — the battery flipping Charge↔Demand while a car charges in bursts — is the supervision working as coded (GH#5390, log-verified): a non-"charging" reading counts after `DYNAMIC_LOAD_CAR_START_MINUTES` (3 min) inside the dispatch, the cheap dispatch rate is stripped from the car's still-future slots (house plan re-prices those minutes) and it resumes on the next "charging" reading — ground-truth with the hourly `Last hour: ... car X kWh` log lines against Octopus's own per-slot amounts before calling it a bug. **The completed-dispatch cheap stamp lives only as long as the integration's dispatch feed carries the session (GH#5413, reporter log + yaml, 2026-10-05):** `rate_add_io_slots()` stamps completed dispatches deliberately (`dispatch_billed_off_peak()` exempts `end_minutes <= minutes_now`, and the fetch merges `completed_dispatches` unconditionally), but the integration withdrawing the dispatches when a session closes removes the stamp retroactively (`io_adjusted` reads empty, `rate_import`/`rate_max` all back at the day rate hours later) and the plan-History re-render derives past minutes from the **current** rate tables (`history_to_future_rates(self.rate_import, ...)`, and `today_cost()` re-prices today-so-far), so the flip is the display of the un-stamped arrays - no persistence of applied rates exists (open GH#2785, PR #3340 parked); the narrower fix is persisting the stamped/completed minutes. A tell from the same log worth keeping: those midday dispatches were visible only from their own start minute, so a "cheap slot Octopus never offered" question on an *ad-hoc* dispatch is different from the overnight band, which is visible hours ahead. **Octopus Intelligent devices freeze in place once the account leaves an Intelligent tariff (GH#5412, probe-verified on main 2026-10-05, still live):** `async_update_intelligent_devices()` returns before polling when the import tariffCode is not Intelligent, so the live-set pruning never runs off-IOG and everything built while on the Intelligent tariff persists - `automatic_config()` wires from `self.intelligent_devices` tariff-blind, the storage cache round-trips the devices, `fetch_sensor_data_car_planning()` keeps taking the Intelligent branch and rebuilding `self.octopus_slots` from the frozen entities' dispatch attributes every cycle, and `rate_add_io_slots()` keeps stamping completed dispatches (with the ~96h look-back) - the Ohme half flips `octopus_intelligent_wanted()` false on the new tariff but `charger_slots_wanted()` refuses while `octopus_intelligent_slot` still points at the frozen Octopus entity, so the #5401 charger-schedule mode stays blocked until the wiring is cleared by hand. #5405's owner re-wire (`car_slots_released_to_us()`) treats the not-refreshed device list as a live constraint on purpose - it refuses to re-wire off an Intelligent tariff, which is the same freeze acknowledged, not removed. **A related clobber to read before any "my manual octopus_intelligent_slot keeps reverting" report:** with `octopus_automatic: true`, `set_arg()` overwrites unconditionally and `automatic_config()` re-wires whenever the device dict is non-empty, so a user's own apps.yaml entity is replaced on the first run after every restart; since #5405 only the *empty-set clearing* respects foreign wiring (it checks the wiring is still what Octopus itself last wrote) - "the user is not affected" holds only under `octopus_automatic: false`. **Weekend Happy Hour / Power Up booking is not implemented (GH#5404, enhancement, 2026-10-05):** Predbat reads only the booked/allocated side (the free-session `events` attribute and `joined_events` with `event_type` WEEKEND_HAPPY_HOUR → `octopus_free_slots`); `available_events` is consumed only by the Power Down auto-join loop (`join_octoplus_power_down_session_event` plus the deprecated saving-session fallback, #4593) and WHH entries never reach it - they are skipped at the source (cannot be joined through the API - Octopus allocates them or the user books on the website) and Power Up entries ride in `available_events` at 0 octopoints, skipped by the min-octopoints guard; no WHH join service exists anywhere. If a join half is ever built, the saving-session auto-join block is the template and Power Up needs a slot *choice* among 2-3 candidates, with an explicit rule for weekend slots released Thursday that partly sit beyond the 48h horizon; the `events` attribute carries both available (code set) and auto-joined (`code` null) entries and the free-session path zero-rates them all regardless of booking state. | `octopus_*`, `saving_session*` |