diff --git a/apps/predbat/car_charger_control.py b/apps/predbat/car_charger_control.py new file mode 100644 index 000000000..d2dcea203 --- /dev/null +++ b/apps/predbat/car_charger_control.py @@ -0,0 +1,254 @@ +# ----------------------------------------------------------------------------- +# 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 utils import parse_car_plan_windows, in_car_plan_window + + +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. + + 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. + + 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): + """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. + """ + 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 = {} + + 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_count(self): + """How many cars have a plan to follow.""" + return self.num_cars + + 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: + await self.storage.save(module, key, {field: self.charger_control_enabled}) + except Exception as exc: + self.log("Warn: {}: could not save the {} control switch state: {}".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.""" + return in_car_plan_window(self.charger_control_windows.get(car_n, []), now) + + async def charger_control_tick(self, now): + """Run one cycle of charger control, releasing rather than just going quiet. + + 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. + 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 + 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) + 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_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.""" + 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/gateway.py b/apps/predbat/gateway.py index f20395b3f..2140203b2 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -16,7 +16,7 @@ import time import uuid import traceback -from utils import calc_percent_limit, export_mode_of, export_target_of, export_power_of +from utils import calc_percent_limit, export_mode_of, export_target_of, export_power_of, parse_car_plan_windows, in_car_plan_window from const import EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE, MINUTE_WATT import pytz as _pytz @@ -501,45 +501,16 @@ def _refresh_ev_windows(self): Called once per run() cycle so the minute-level check always has up-to-date window boundaries without polling HA every second. The ``planned`` attribute of - ``binary_sensor._car_charging_slot`` is a list of ``{start, end, ...}`` dicts - with datetime strings in ``"%m-%d %H:%M:%S"`` format (produced by output.py). - - Datetimes are localized using ``self.local_tz`` so comparisons respect the component - timezone and DST transitions. Year boundaries are handled: if a parsed start is more - than 23 hours in the past (i.e. the plan was built on Dec 31 and contains Jan 1 - windows), the year is bumped forward. Similarly, if end falls before start after - localization the end year is incremented to handle windows that straddle midnight - on New Year's Eve. + ``binary_sensor._car_charging_slot`` is parsed by parse_car_plan_windows(), + shared with the other charger components, which localises each window and rebuilds + its year around now for plans that straddle New Year. """ planned = self.get_state_wrapper(f"binary_sensor.{self.prefix}_car_charging_slot", attribute="planned") or [] - now = datetime.datetime.now(self.local_tz) - current_year = now.year - windows = [] - for w in planned: - try: - start_naive = datetime.datetime.strptime(w["start"], "%m-%d %H:%M:%S").replace(year=current_year) - end_naive = datetime.datetime.strptime(w["end"], "%m-%d %H:%M:%S").replace(year=current_year) - start_dt = self.local_tz.localize(start_naive) - end_dt = self.local_tz.localize(end_naive) - # If start is far in the past the plan crossed a year boundary (Dec 31 → Jan 1) - if start_dt < now - datetime.timedelta(hours=23): - start_dt = start_dt.replace(year=start_dt.year + 1) - end_dt = end_dt.replace(year=end_dt.year + 1) - elif end_dt < start_dt: - # end crossed into the new year but start did not (e.g. 23:30 → 00:30) - end_dt = end_dt.replace(year=end_dt.year + 1) - windows.append((start_dt, end_dt)) - except (KeyError, ValueError): - continue - self._ev_windows = windows + self._ev_windows = parse_car_plan_windows(planned, datetime.datetime.now(self.local_tz), self.local_tz) def _should_ev_charge_now(self): """Return True if the current local time falls inside any planned charge window.""" - now = datetime.datetime.now(self.local_tz) - for start_dt, end_dt in self._ev_windows: - if start_dt <= now < end_dt: - return True - return False + return in_car_plan_window(self._ev_windows, datetime.datetime.now(self.local_tz)) async def _apply_ev_charging_state(self): """Start or stop EVC charging when the window state transitions. diff --git a/apps/predbat/gecloud.py b/apps/predbat/gecloud.py index 77341d8fd..74d9a03e6 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 from predbat_metrics import record_api_call import asyncio import math @@ -212,7 +213,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 +326,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 +577,7 @@ def merge_non_null(fresh, previous): return merged -class GECloudDirect(ComponentBase): +class GECloudDirect(ComponentBase, CarChargerControl): """ GivEnergy Cloud Direct API interface """ @@ -585,14 +594,9 @@ def initialize(self, ge_cloud_direct, api_key, automatic, automatic_evc=False, e # 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 = {} + # 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") self.register_list = {} self.settings = {} self.status = {} @@ -727,9 +731,7 @@ 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 mapping = self.register_entity_map.get(entity_id, None) @@ -2092,43 +2094,15 @@ def evc_control_enable(self): 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. """ - self.evc_control_active = False + self.charger_control_active = False if not self.evc_control: 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") 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 +2114,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 +2124,34 @@ 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_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 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 release_evc_devices(self): - """Hand every held charger back by starting it again. + 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 +2239,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 +2333,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 +2403,22 @@ 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) + 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..bfd9a5432 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 MYENERGI_DIRECTOR_URL = "https://director.myenergi.net" MYENERGI_CLOUD_URL = "https://api.s18.myenergi.net" @@ -758,7 +758,7 @@ 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): @@ -783,13 +783,9 @@ def initialize(self, auth_method=None, hub_serial=None, api_key=None, key=None, 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") self.queued_events = [] self._auto_configured = False self.transport = None @@ -882,36 +878,6 @@ 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. - - 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. - """ - 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) - def enable_control(self): """Decide whether Predbat-led Zappi control should run, and say why when it will not. @@ -929,122 +895,64 @@ def enable_control(self): if not self.enable_controls: 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)) - - 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") + return [device for device in sorted(self.devices.values(), key=lambda item: item.serial) if device.kind == DEVICE_KIND_ZAPPI] - def control_read_only_now(self): - """Is Predbat in read only mode - the live attribute rather than just the config arg. + 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()] - 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. - """ - 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. + # 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_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 +999,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 +1086,7 @@ 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 not entity_id.endswith("_boost"): return False @@ -1204,14 +1110,14 @@ 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", ) diff --git a/apps/predbat/ohme.py b/apps/predbat/ohme.py index 782688d36..718d77b0b 100644 --- a/apps/predbat/ohme.py +++ b/apps/predbat/ohme.py @@ -27,6 +27,7 @@ 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 from predbat_metrics import record_api_call GOOGLE_API_KEY = "AIzaSyC8ZeZngm33tpOXLpbXeKfwtyZ1WrkbdBY" # cspell:disable-line @@ -95,8 +96,6 @@ "octopus_charge_limit": {"entity_id": "number.predbat_ohme_target_percent", "domain": "number", "access": "rw"}, } -# Format Predbat writes its planned car charging windows in - see PredBat.time_abs_str() -PLAN_TIME_FORMAT = "%m-%d %H:%M:%S" # 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 @@ -223,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): @@ -236,7 +235,8 @@ def initialize(self, email, password, ohme_automatic=False, ohme_automatic_octop # 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 + # No control switch: read only mode is what releases an Ohme charger + self.charger_control_setup("Ohme API", "charger") # 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 @@ -251,11 +251,6 @@ def initialize(self, email, password, ohme_automatic=False, ohme_automatic_octop 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.energy_today = 0.0 @@ -324,8 +319,15 @@ 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: + 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 @@ -334,7 +336,7 @@ def enable_control(self, octopus_intelligent): """ Decide whether Predbat-led charge control should run, and say why when it will not. """ - self.control_active = False + self.charger_control_active = False if not self.ohme_control: return if not self.ohme_automatic: @@ -343,21 +345,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. @@ -376,120 +366,49 @@ 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 - - now = self.now_utc_exact - windows = [] - for window in planned: - try: - start = self.local_tz.localize(datetime.datetime.strptime(window["start"], PLAN_TIME_FORMAT).replace(year=now.year)) - end = self.local_tz.localize(datetime.datetime.strptime(window["end"], PLAN_TIME_FORMAT).replace(year=now.year)) - except (KeyError, TypeError, ValueError): - continue - # The plan carries no year, so rebuild it around now for windows that cross New Year - if start < now - timedelta(hours=23): - start = start.replace(year=start.year + 1) - end = end.replace(year=end.year + 1) - elif end < start: - end = end.replace(year=end.year + 1) - windows.append((start, end)) - self.control_windows = windows - return True - - def should_charge_now(self): - """ - Is now inside one of Predbat's planned charging windows. - """ - now = self.now_utc_exact - return any(start <= now < end for start, end in self.control_windows) + def charger_control_chargers(self): + """The one Ohme charger on the account, which follows car 0.""" + return [(self.client.serial or "ohme", self.client)] - 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) - # 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) - 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. - - 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. - """ - 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: + 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 self.client.async_max_charge(True) + await 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 + 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) + # 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 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 def octopus_intelligent_wanted(self): """ @@ -583,10 +502,11 @@ async def update_slot_mode(self): self.enable_control(octopus_intelligent or self.octopus_other_device) # Predbat has stood down but is still holding the charger: hand it back. Checked on every - # poll rather than only on the change, as release_charger() clears control_charging last - + # 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: + self.log("Info: Ohme API: Octopus Intelligent now schedules the charge, releasing the charger") + await self.charger_control_release() if octopus_intelligent: slot_mode = SLOT_MODE_INTELLIGENT @@ -626,7 +546,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) 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..a822c1d80 --- /dev/null +++ b/apps/predbat/tests/test_car_charger_control.py @@ -0,0 +1,372 @@ +# ----------------------------------------------------------------------------- +# 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 + +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 = {} + 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): + """Config args, as ComponentBase.get_arg.""" + return self.args.get(name, default) + + def get_state_wrapper(self, entity_id, default=None, attribute=None): + """Only the car charging slot sensors are read.""" + return self.plans.get(entity_id, 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_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 + + +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_no_switch_without_storage_settings() + test_failed_release_is_retried() + test_one_refusing_charger_does_not_block_the_others() + test_inactive_does_nothing() + return False diff --git a/apps/predbat/tests/test_gateway.py b/apps/predbat/tests/test_gateway.py index b3a5f2ab4..499098aea 100644 --- a/apps/predbat/tests/test_gateway.py +++ b/apps/predbat/tests/test_gateway.py @@ -5009,25 +5009,51 @@ def test_should_charge_now_no_windows(self): assert gw._should_ev_charge_now() is False def test_refresh_ev_windows_year_boundary(self): - """Windows whose parsed start would be >23 h in the past get their year bumped.""" + """A window running across midnight on New Year's Eve is active on both sides of midnight.""" + import datetime as dt_mod + import types + from unittest.mock import patch + + import gateway as gateway_module + + gw = self._make_gateway() + planned = [{"start": "12-31 23:30:00", "end": "01-01 01:30:00", "kwh": 5.0, "average": 20.0, "cost": 1.0}] + gw.get_state_wrapper = lambda entity, attribute=None: planned if attribute == "planned" else "on" + + for wall_clock in (dt_mod.datetime(2026, 12, 31, 23, 45), dt_mod.datetime(2027, 1, 1, 0, 30)): + clock = gw.local_tz.localize(wall_clock) + + class FrozenDatetime(dt_mod.datetime): + """datetime with now() pinned to the test's clock.""" + + @classmethod + def now(cls, tz=None): + """The pinned clock, in the requested timezone.""" + return clock.astimezone(tz) if tz else clock + + with patch.object(gateway_module, "datetime", types.SimpleNamespace(datetime=FrozenDatetime, timedelta=dt_mod.timedelta)): + gw._refresh_ev_windows() + assert len(gw._ev_windows) == 1 + start_dt, end_dt = gw._ev_windows[0] + assert start_dt < clock < end_dt, "Expected {} inside {} to {}".format(clock, start_dt, end_dt) + assert gw._should_ev_charge_now() is True, "Expected to be charging at {}".format(clock) + + def test_refresh_ev_windows_leaves_a_finished_window_in_the_past(self): + """A window that ended yesterday is over - it must not be moved a year forward.""" import datetime as dt_mod gw = self._make_gateway() now = dt_mod.datetime.now(gw.local_tz) - # Simulate a Jan 1 window parsed with current_year when now is Dec 31 - # by injecting a planned entry whose start, parsed with the current year, is 30 h in the past stale = now - dt_mod.timedelta(hours=30) - future_end = stale + dt_mod.timedelta(hours=2) - # Format as MM-DD HH:MM:SS — these will be parsed with current year and end up in the past - planned = [{"start": stale.strftime("%m-%d %H:%M:%S"), "end": future_end.strftime("%m-%d %H:%M:%S"), "kwh": 5.0, "average": 20.0, "cost": 1.0}] + planned = [{"start": stale.strftime("%m-%d %H:%M:%S"), "end": (stale + dt_mod.timedelta(hours=2)).strftime("%m-%d %H:%M:%S"), "kwh": 5.0}] gw.get_state_wrapper = lambda entity, attribute=None: planned if attribute == "planned" else "on" gw._refresh_ev_windows() assert len(gw._ev_windows) == 1 start_dt, end_dt = gw._ev_windows[0] - # After year bump, start should be in the future (next year) - assert start_dt > now + assert end_dt < now, "A finished window must stay finished, got {} to {}".format(start_dt, end_dt) + assert gw._should_ev_charge_now() is False def test_apply_sends_start_on_transition(self): """_apply_ev_charging_state sends SetChargingProfile then RemoteStartTransaction when entering a window.""" diff --git a/apps/predbat/tests/test_ge_cloud.py b/apps/predbat/tests/test_ge_cloud.py index e91c88c96..db20d6db4 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 @@ -58,11 +58,7 @@ def __init__(self): 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 = {} + self.charger_control_setup("GECloud", "EV charger", "gecloud", "evc_control_state", "evc_control_enabled") self.entity_states = {} self.entity_attributes = {} self.pending_writes = {} @@ -131,7 +127,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 +3454,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 +3464,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 @@ -6328,13 +6326,13 @@ async def test(): ge = MockGECloudDirect() ge.automatic_evc = True 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, "Control should be off without ge_cloud_evc_control" # Test 2: and 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 +6342,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,11 +6371,11 @@ 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 7: nothing is commanded while no car is plugged in @@ -6386,7 +6384,7 @@ async def test(): 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 +6393,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 +6406,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 +6421,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..a50dbac10 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,7 +1176,7 @@ 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") @@ -1185,25 +1185,89 @@ def test_control_gating_refuses_with_a_reason(): 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 +1275,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,12 +1299,12 @@ 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") @@ -1252,13 +1316,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 +1340,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 +1365,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 +1382,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 +1424,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 +1442,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") @@ -2539,6 +2603,9 @@ def test_myenergi(my_predbat=None): test_control_gating_refuses_with_a_reason() 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_control_switch_is_not_published_when_control_cannot_run() diff --git a/apps/predbat/tests/test_ohme.py b/apps/predbat/tests/test_ohme.py index 6e974a48f..b79d23d83 100644 --- a/apps/predbat/tests/test_ohme.py +++ b/apps/predbat/tests/test_ohme.py @@ -305,6 +305,7 @@ 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"), ("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"), @@ -1635,16 +1636,13 @@ 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") self.octopus_intelligent = None self.octopus_other_device = 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.prefix = "predbat" self.local_tz = pytz.timezone("Europe/London") @@ -1706,7 +1704,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: @@ -1716,6 +1714,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} @@ -1728,14 +1731,14 @@ def _test_ohme_control_enable_rules(my_predbat=None): # Off by default api = MockOhmeAPI() 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 control off when ohme_control is not set" # 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 @@ -1743,7 +1746,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 @@ -1751,7 +1754,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 @@ -1766,20 +1769,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 @@ -1796,10 +1799,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 @@ -1811,14 +1820,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']}" @@ -1835,24 +1844,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") @@ -1867,14 +1876,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']}" @@ -1882,7 +1891,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") @@ -1897,25 +1906,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}" @@ -1935,7 +1944,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 @@ -1943,7 +1952,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}" @@ -1953,13 +1962,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 ****") @@ -1968,16 +2016,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 @@ -2256,7 +2304,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") @@ -2419,7 +2467,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 @@ -3188,7 +3236,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}" @@ -3327,7 +3375,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, @@ -3342,12 +3390,12 @@ async def mock_max_charge(state=True): api.client.async_resume_charge = mock_resume api.client.async_max_charge = mock_max_charge - api.control_charging = False + 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 api.charger_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 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}" @@ -3355,7 +3403,7 @@ async def mock_max_charge(state=True): # 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}" @@ -3422,7 +3470,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] @@ -3437,7 +3485,7 @@ async def mock_max_charge(state=True): 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.charger_control_state = {api.charger_control_chargers()[0][0]: False} # Predbat is holding the charger paused # 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") @@ -3446,15 +3494,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 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 0f3d5a499..2b062e3e6 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -278,6 +278,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 @@ -708,6 +709,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/apps/predbat/utils.py b/apps/predbat/utils.py index 135b5d254..b857a263c 100644 --- a/apps/predbat/utils.py +++ b/apps/predbat/utils.py @@ -1767,8 +1767,8 @@ def format_time_ago(last_updated): def parse_car_plan_windows(planned, now, local_tz): """Turn one car's published charging plan into a list of localised (start, end) pairs. - Shared by the components that drive a charger from the plan (myenergi, GivEnergy EVC) - so the awkward parts stay in one place: the plan carries no year, so each window is + Shared by every component that drives a charger from the plan (CarChargerControl and the + gateway's EV charger) so the awkward parts stay in one place: the plan carries no year, so each window is rebuilt around now - without that, a plan read either side of New Year lands eleven months out - and a malformed entry is skipped rather than costing the rest of the plan. diff --git a/tools/debug-journal.md b/tools/debug-journal.md index 0e0835d18..94a303ef0 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 `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). 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 (live, 2026-10-02) joins the two halves end to end:** an HA outage restarted Predbat with the control cache 35.9 minutes old, restore logged `Info: Sunsynk control cache is 35.9 minutes old (limit 15), forcing a rewrite` and dropped both halves — but nothing performs that 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 Predbat re-armed the hold every 5-minute cycle (reserve chasing SoC+1) and the inverter ran its 00:59 programme's idle-cap-5 slots, draining ~9 kWh (82% → 24%) into the EV until a charge-window plan change finally pressed the button at 03:02 — recovery then ran #5142's settle detection (`Warn: Sunsynk ... has not applied Predbat's settings after 4 settings polls`, fired at 03:59) and re-applied. Reading the "forcing a rewrite" line as if a rewrite follows is the trap: it is the notice that the cache was **dropped**. So a "holds lost for hours after an HA blip / battery drained while Predbat showed holds armed" report maps here even on a #5150+ version. 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. The release (`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 `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`) | 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. 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. | `octopus_*`, `saving_session*` |