From 41fc39232e06af348cb8d018e9b94524d8a9c94d Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 24 Aug 2026 11:46:05 +0100 Subject: [PATCH 1/2] Add GivEnergy EV charger auto-configuration and charge control The EV charger poll fetched the charger's status on every cycle and threw it away, publishing only the meter measurands, and nothing wired a charger into Predbat's car planning at all. Publishes two entities from data already fetched: _evc_status, the status as GivEnergy reports it, and _evc_car_connected, a binary sensor derived from it. An unrecognised status counts as no car - the safe way round - and is logged once so it can be added rather than silently ignored. ge_cloud_automatic_evc (default off) wires the chargers into car_charging_energy, car_charging_planned and num_cars, in serial order so charger N is car N. It is a separate setting from ge_cloud_automatic because it registers a car and moves num_cars, which would change the plan for existing users who had only asked for their inverter to be configured. It runs whether or not a GivEnergy battery is present, so a charger paired with another manufacturer's battery is configured too. car_charging_planned points at the binary sensor rather than the status string because "on" matches the default car_charging_planned_response, so no response list needs extending. ge_cloud_evc_control (default off, needs ge_cloud_automatic_evc) starts and stops each charger from its car's plan. A command is only sent when the wanted state changes, a charger with no car plugged in is left alone, and nothing is sent until a plan has been published so a restart cannot stop a charge already running. switch.predbat_gecloud_evc_control hands the charger back without editing apps.yaml, and is persisted. Read only mode and switching it off both release: a charger Predbat had stopped is started again on the way out. Unlike a Zappi there is no previous mode to restore - start-charge and stop-charge are commands, not modes. The car plan window parsing myenergi already had is now shared, as parse_car_plan_windows() and in_car_plan_window() in utils.py, so the New Year rollover and malformed entry handling live in one place. myenergi's behaviour is unchanged and its existing control tests cover the move. Co-Authored-By: Claude Opus 5 (1M context) --- .cspell/custom-dictionary-workspace.txt | 2 + apps/predbat/components.py | 10 + apps/predbat/config.py | 2 + apps/predbat/gecloud.py | 302 +++++++++++++++++++++++- apps/predbat/myenergi.py | 25 +- apps/predbat/tests/test_ge_cloud.py | 269 ++++++++++++++++++++- apps/predbat/utils.py | 41 ++++ docs/apps-yaml.md | 17 ++ docs/components.md | 70 ++++++ 9 files changed, 712 insertions(+), 26 deletions(-) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 9d3beb0cf..8ff6415ae 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -554,6 +554,8 @@ substep sunspec sunsynk supabase +suspendedev +suspendedevse synkctl syscmd sysdn diff --git a/apps/predbat/components.py b/apps/predbat/components.py index fb749dcb0..9c6ce79c4 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -140,6 +140,16 @@ "default": False, "config": "ge_cloud_automatic", }, + "automatic_evc": { + "required": False, + "default": False, + "config": "ge_cloud_automatic_evc", + }, + "evc_control": { + "required": False, + "default": False, + "config": "ge_cloud_evc_control", + }, }, "phase": 1, }, diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 2cdad77e4..15a766fd9 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -2485,6 +2485,8 @@ "ge_cloud_direct": {"type": "boolean"}, "ge_cloud_automatic": {"type": "boolean"}, "ge_cloud_load_today_ignore": {"type": "boolean"}, + "ge_cloud_automatic_evc": {"type": "boolean"}, + "ge_cloud_evc_control": {"type": "boolean"}, "ge_cloud_automatic_shared_ct": {"type": "boolean"}, "ge_cloud_automatic_split_ct": {"type": "boolean"}, "ge_cloud_automatic_split_pv": {"type": "boolean"}, diff --git a/apps/predbat/gecloud.py b/apps/predbat/gecloud.py index a87319979..48de7a2fa 100644 --- a/apps/predbat/gecloud.py +++ b/apps/predbat/gecloud.py @@ -16,7 +16,7 @@ import aiohttp import pytz from datetime import timedelta, datetime, timezone -from utils import str2time, dp1, dp2, dp4 +from utils import str2time, dp1, dp2, dp4, parse_car_plan_windows, in_car_plan_window from predbat_metrics import record_api_call import asyncio import json @@ -111,6 +111,27 @@ EVC_METER_PV1 = 2 EVC_METER_PV2 = 3 +# The charger statuses that mean a car is physically plugged in. GivEnergy reports the +# OCPP vocabulary, where every stage of a session from Preparing to Finishing has a car +# on the end of the cable - only Available and the fault states do not. +EVC_CONNECTED_STATUSES = {"preparing", "charging", "suspendedev", "suspendedevse", "finishing", "connected", "plugged_in", "charge_complete"} + +# The statuses that mean no car. Listed rather than inferred from the set above so an +# unrecognised value can be reported instead of silently reading as "nothing plugged in", +# which would look exactly like a working charger that Predbat quietly ignores. +EVC_DISCONNECTED_STATUSES = {"available", "idle", "offline", "unavailable", "faulted", "reserved", "unknown"} + + +def evc_status_key(status): + """Normalise a charger status into the form the status tables use. + + The API has been seen returning both 'charging' and 'SuspendedEV', and a status is + only ever compared here, never displayed, so case and separator differences are + flattened rather than every spelling being listed in the tables. + """ + return str(status or "").strip().lower().replace(" ", "_").replace("-", "_") + + # Commands # ['start-charge', 'stop-charge', 'adjust-charge-power-limit', 'set-plug-and-go', 'set-session-energy-limit', 'set-schedule', 'unlock-connector', 'delete-charging-profile', 'change-mode', 'restart-charger', 'change-randomised-delay-duration', 'add-id-tags', 'delete-id-tags', 'rename-id-tag', 'installation-mode', 'setup-version', 'set-active-schedule', 'set-max-import-capacity', 'enable-front-panel-led', 'configure-inverter-control', 'perform-factory-reset', 'configuration-mode', 'enable-local-control'] # Command adjust-charge-power-limit {'min': 6, 'max': 32, 'value': 32, 'unit': 'A'} @@ -167,6 +188,15 @@ "set-plug-and-go": "enabled", } +# 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(). +EVC_COMMAND_START = "start-charge" +EVC_COMMAND_STOP = "stop-charge" + +# Where the EVC control switch is persisted, so an off survives a restart +EVC_STORAGE_MODULE = "gecloud" +EVC_CONTROL_STATE = "evc_control_state" + # Unsupported commands EVC_BLACKLIST_COMMANDS = ["installation-mode", "perform-factory-reset", "rename-id-tag", "delete-id-tags", "change-randomised-delay-duration"] @@ -262,10 +292,23 @@ class GECloudDirect(ComponentBase): GivEnergy Cloud Direct API interface """ - def initialize(self, ge_cloud_direct, api_key, automatic): + def initialize(self, ge_cloud_direct, api_key, automatic, automatic_evc=False, evc_control=False): """Initialise the GE Cloud Direct component""" self.api_key = api_key self.automatic = automatic + # Kept apart from automatic, which existing users already have on: wiring the + # chargers into the car planning registers a car and moves num_cars, so it has to + # be something a user turns on rather than something an upgrade does to them. + self.automatic_evc = automatic_evc + self.evc_control = evc_control + self.evc_control_active = False + # The runtime switch, on unless the user turns it off. Restored from storage at startup. + self.evc_control_enabled = True + self.evc_control_released = False + # What Predbat last asked each charger to do, so a poll that changes nothing sends + # nothing - every command goes through async_send_evc_command's retry loop. + self.evc_control_state = {} + self.evc_control_windows = {} self.register_list = {} self.settings = {} self.status = {} @@ -277,6 +320,7 @@ def initialize(self, ge_cloud_direct, api_key, automatic): self.evc_device = {} self.evc_data = {} self.evc_sessions = {} + self.evc_status_unknown = set() self.api_fatal = False self.api_auth_failed = False self.auth_denied_reported = False @@ -377,6 +421,12 @@ 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() + return + mapping = self.register_entity_map.get(entity_id, None) if mapping: device = mapping.get("device", None) @@ -604,6 +654,44 @@ async def publish_info(self, device, device_info): self.dashboard_item(entity_name + "_max_inverter_rate", max_inverter_rate, attributes=attribute_table.get("max_inverter_rate", {}), app="gecloud") self.dashboard_item(entity_name + "_last_updated", last_updated, attributes=attribute_table.get("time", {}), app="gecloud") + def evc_car_connected(self, status): + """Is a car plugged into the charger, judged from its status string. + + An unrecognised status counts as no car - the safe way round, since a false + 'connected' would have Predbat plan charging slots for a car that is not there. + It is reported once per distinct value rather than every poll, because the only + way a status missing from the tables gets added is somebody seeing the log line. + """ + key = evc_status_key(status) + if key in EVC_CONNECTED_STATUSES: + return True + if key not in EVC_DISCONNECTED_STATUSES and key not in self.evc_status_unknown: + self.evc_status_unknown.add(key) + self.log("GECloud: Warn: Unrecognised EV charger status '{}', treating it as no car connected - please report it so it can be added".format(status)) + return False + + async def publish_evc_device(self, serial, evc_device): + """Publish the charger's own status, and whether a car is connected. + + The status arrives on every device poll and used to be dropped - only the meter + measurands were published. It goes out raw for visibility, and again reduced to a + binary sensor for planning: that sensor answers 'on', which the default + car_charging_planned_response already matches, so automatic configuration works + without the user having to extend a response list written around other vendors' + status vocabulary. + """ + status = evc_device.get("status", None) + if not status: + return + entity_name = "{}_gecloud_{}".format(self.prefix, serial).lower() + self.dashboard_item("sensor." + entity_name + "_evc_status", state=status, attributes={"friendly_name": "EV Charger Status", "icon": "mdi:ev-station"}, app="gecloud") + self.dashboard_item( + "binary_sensor." + entity_name + "_evc_car_connected", + state="on" if self.evc_car_connected(status) else "off", + attributes={"friendly_name": "EV Charger Car Connected", "icon": "mdi:ev-plug-type2"}, + app="gecloud", + ) + async def publish_evc_data(self, serial, evc_data): """ Data passed in is a dictionary of measurands according to EVC_DATA_POINTS @@ -1198,6 +1286,196 @@ def build_entities(domain, candidates): self.log("GECloud: Automatic configuration complete") + def evc_control_enable(self): + """Decide whether Predbat-led charger control should run, and say why when it will not. + + Control needs the EVC automatic configuration because a charger is driven from its + own car's plan, and it is that configuration which establishes which charger is + which car - without it, charger 1 could be told to follow a car it is not attached to. + """ + self.evc_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.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 evc_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, + so read the attribute first and fall back to the switch for the window before it is set. + """ + read_only = getattr(self.base, "set_read_only", None) + if read_only is None: + 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. + + async_automatic_config_evc() wires car_charging_energy and car_charging_planned as + per-car lists in this same order, so the two cannot disagree about which charger + is which car. + """ + 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. + + 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.evc_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) + + async def release_evc_devices(self): + """Hand every 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): + return + for car_n, uuid in enumerate(self.controlled_evc_devices()): + 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 + + async def async_automatic_config_evc(self): + """Wire the EV chargers into Predbat's car charging inputs. + + Deliberately separate from async_automatic_config(), which returns early when no + battery inverter is found: a GivEnergy charger paired with somebody else's battery + is a normal setup, and folding this in there would leave it unconfigured. + + Chargers are taken in serial order so charger N is always the same car as entry N + of both lists, and so the mapping does not shuffle when the API returns the + devices in a different order. car_charging_energy lets car_charging_hold subtract + the charging precisely instead of falling back to the car_charging_threshold + heuristic; car_charging_planned tells Predbat when there is actually a car to plan + for. Both go through set_arg_auto so an apps.yaml entry that auto-discovery is + about to override is logged rather than silently discarded. + """ + energy_entities = [] + connected_entities = [] + for uuid in sorted(self.evc_device_list, key=lambda item: str(self.evc_device.get(item, {}).get("serial_number", "") or "")): + serial = self.evc_device.get(uuid, {}).get("serial_number", None) + if not serial: + # The serial is read from the device endpoint, so a charger that has not + # answered yet has no entity name to point at - skip it rather than wire + # up a name with a hole in it. + self.log("GECloud: Warn: EV charger {} has no serial number yet, skipping it in automatic configuration".format(uuid)) + continue + entity_name = "{}_gecloud_{}".format(self.prefix, serial).lower() + energy_entities.append("sensor." + entity_name + "_evc_energy_active_import_register") + connected_entities.append("binary_sensor." + entity_name + "_evc_car_connected") + + if not energy_entities: + return + + # Only ever raised, never lowered, as ohme and octopus do with the same setting - + # another component may already have registered cars of its own that are not this + # charger, and shrinking the count would drop them off the plan. + if self.get_arg("num_cars", 0) < len(energy_entities): + self.set_arg("num_cars", len(energy_entities)) + + self.log("GECloud: Setting car_charging_energy to {}".format(energy_entities)) + self.set_arg_auto("car_charging_energy", energy_entities) + self.log("GECloud: Setting car_charging_planned to {}".format(connected_entities)) + self.set_arg_auto("car_charging_planned", connected_entities) + async def run(self, seconds, first): """ Start the client @@ -1238,6 +1516,12 @@ async def run(self, seconds, first): # device_name = device.get("alias", None) self.evc_device_list.append(uuid) self.log("GECloud: Starting up, found devices {}, evc_devices {}".format(self.device_list, self.evc_device_list)) + + # 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() + self.evc_control_enable() for device in self.device_list: self.pending_writes[device] = [] @@ -1295,6 +1579,18 @@ async def run(self, seconds, first): self.evc_data[uuid] = await self.async_get_evc_device_data(uuid, self.evc_data.get(uuid, {})) self.evc_sessions[uuid] = await self.async_get_evc_sessions(uuid, self.evc_sessions.get(uuid, [])) 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: + # 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", + attributes={"friendly_name": "EV Charger Control", "icon": "mdi:ev-station"}, + app="gecloud", + ) + await self.evc_control_tick(self.now_utc_exact) if first or (seconds % (10 * 60) == 0): # Get All registers every now and again in case user changes them @@ -1316,6 +1612,8 @@ async def run(self, seconds, first): if first: if self.automatic: await self.async_automatic_config(self.devices_dict) + if self.automatic_evc: + await self.async_automatic_config_evc() now_utc = self.now_utc_exact options_due = self.default_options_stamp is None or (now_utc - self.default_options_stamp) >= timedelta(hours=24) diff --git a/apps/predbat/myenergi.py b/apps/predbat/myenergi.py index c72364b18..b30685ed2 100644 --- a/apps/predbat/myenergi.py +++ b/apps/predbat/myenergi.py @@ -25,7 +25,6 @@ import argparse import asyncio -import datetime import time from abc import ABC, abstractmethod from dataclasses import dataclass @@ -37,6 +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 MYENERGI_DIRECTOR_URL = "https://director.myenergi.net" MYENERGI_CLOUD_URL = "https://api.s18.myenergi.net" @@ -112,10 +112,6 @@ # Boosting a Zappi is only accepted while it is in one of the green-energy modes. ZAPPI_BOOSTABLE_MODES = ("Eco", "Eco+") -# How output.py formats the start/end of each planned car charging window. It carries no -# year, so a parsed window has to be rebuilt around the current time. -PLAN_TIME_FORMAT = "%m-%d %H:%M:%S" - # The two modes Predbat-led charge control drives a Zappi between, and the mode a # released Zappi falls back to when nothing was saved to restore. ZAPPI_MODE_CHARGING = "Fast" @@ -884,26 +880,11 @@ def refresh_car_windows(self, now): def _parse_plan_windows(self, planned, now): """Turn one car's published plan into a list of localised (start, end) pairs.""" - parsed = [] - 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): - # One malformed entry must not cost the rest of the plan - continue - # The plan carries no year, so rebuild it around now for windows crossing New Year - if start < now - datetime.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) - parsed.append((start, end)) - return parsed + 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 any(start <= now < end for start, end in self.control_windows.get(car_n, [])) + 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. diff --git a/apps/predbat/tests/test_ge_cloud.py b/apps/predbat/tests/test_ge_cloud.py index 93bedde9f..ae223c7ef 100644 --- a/apps/predbat/tests/test_ge_cloud.py +++ b/apps/predbat/tests/test_ge_cloud.py @@ -50,6 +50,16 @@ def __init__(self): self.evc_device = {} self.evc_data = {} self.evc_sessions = {} + self.evc_status_unknown = set() + self.automatic_evc = False + self.evc_control = False + self.evc_control_active = False + self.evc_control_enabled = True + self.evc_control_released = False + self.evc_control_state = {} + self.evc_control_windows = {} + self.entity_states = {} + self.entity_attributes = {} self.pending_writes = {} self.register_entity_map = {} self.polling_mode = False @@ -78,6 +88,7 @@ async def set_state_external(self, entity_id, state, attributes={}): class MockBase: def __init__(self): self.ha_interface = MockHAInterface() + self.num_cars = 0 self.base = MockBase() @@ -102,11 +113,13 @@ def set_arg(self, name, value): """Mock set_arg""" self.config_args[name] = value - def get_state_wrapper(self, entity_id, default=None): + def get_state_wrapper(self, entity_id, default=None, attribute=None, **kwargs): """Mock get_state_wrapper""" if "_set_read_only" in entity_id: return "on" if self._read_only else "off" - return default + if attribute is not None: + return self.entity_attributes.get(entity_id, {}).get(attribute, default) + return self.entity_states.get(entity_id, default) def update_success_timestamp(self): """Mock update_success_timestamp""" @@ -261,6 +274,9 @@ def test_ge_cloud(my_predbat=None): ("publish_registers", _test_publish_registers, "Publish registers"), ("publish_evc_data", _test_publish_evc_data, "Publish EVC data"), ("automatic_config", _test_async_automatic_config, "Automatic config"), + ("publish_evc_device", _test_publish_evc_device, "Publish EVC device status"), + ("automatic_config_evc", _test_async_automatic_config_evc, "Automatic config for EV chargers"), + ("evc_control", _test_evc_control, "EV charger control from the car plan"), ("hybrid_detection", _test_hybrid_detection, "Hybrid inverter detection"), ("enable_defaults", _test_enable_default_options, "Enable default options"), ("enable_defaults_skip_target", _test_enable_default_options_skips_discharge_target, "Enable defaults skips the discharge target register"), @@ -2655,6 +2671,9 @@ async def mock_get_evc_sessions(uuid, previous): async def mock_publish_evc_data(serial, data): call_order.append(f"publish_evc_data:{serial}") + async def mock_publish_evc_device(serial, device): + call_order.append(f"publish_evc_device:{serial}") + async def mock_get_inverter_settings(device, first, previous): call_order.append(f"async_get_inverter_settings:{device}") return {} @@ -2665,6 +2684,9 @@ async def mock_publish_registers(device, settings): async def mock_automatic_config(devices_dict): call_order.append("async_automatic_config") + async def mock_automatic_config_evc(): + call_order.append("async_automatic_config_evc") + async def mock_enable_default_options(device, settings): call_order.append(f"enable_default_options:{device}") @@ -2683,9 +2705,11 @@ async def mock_enable_default_options(device, settings): ge_cloud.async_get_evc_device_data = mock_get_evc_device_data ge_cloud.async_get_evc_sessions = mock_get_evc_sessions ge_cloud.publish_evc_data = mock_publish_evc_data + ge_cloud.publish_evc_device = mock_publish_evc_device ge_cloud.async_get_inverter_settings = mock_get_inverter_settings ge_cloud.publish_registers = mock_publish_registers ge_cloud.async_automatic_config = mock_automatic_config + ge_cloud.async_automatic_config_evc = mock_automatic_config_evc ge_cloud.enable_default_options = mock_enable_default_options # Test first run (first=True, seconds=0) @@ -2714,6 +2738,7 @@ async def mock_enable_default_options(device, settings): "async_get_evc_device_data:evc-001", "async_get_evc_sessions:evc-001", "publish_evc_data:evc-serial-001", + "publish_evc_device:evc-serial-001", # Settings (every 10 minutes, also on first) "async_get_inverter_settings:inv001", "publish_registers:inv001", @@ -2728,6 +2753,22 @@ async def mock_enable_default_options(device, settings): print("Got: {}".format(call_order)) return 1 + # The EVC wiring is off unless asked for - it registers a car and moves num_cars, + # so an existing ge_cloud_automatic user must not get it from an upgrade alone + if "async_automatic_config_evc" in call_order: + print("ERROR: EVC automatic config ran with ge_cloud_automatic_evc off") + return 1 + + # ... and runs on the first cycle once it is enabled + ge_cloud.automatic_evc = True + call_order = [] + result = await ge_cloud.run(seconds=0, first=True) + + if "async_automatic_config_evc" not in call_order: + print("ERROR: EVC automatic config did not run with ge_cloud_automatic_evc on, got {}".format(call_order)) + return 1 + ge_cloud.automatic_evc = False + # Test subsequent run at seconds=120 (not first, but divisible by 120) call_order = [] result = await ge_cloud.run(seconds=120, first=False) @@ -2748,6 +2789,7 @@ async def mock_enable_default_options(device, settings): "async_get_evc_device_data:evc-001", "async_get_evc_sessions:evc-001", "publish_evc_data:evc-serial-001", + "publish_evc_device:evc-serial-001", ] if call_order != expected_order_120: @@ -2771,6 +2813,7 @@ async def mock_enable_default_options(device, settings): "async_get_evc_device_data:evc-001", "async_get_evc_sessions:evc-001", "publish_evc_data:evc-serial-001", + "publish_evc_device:evc-serial-001", "async_get_inverter_settings:inv001", "publish_registers:inv001", ] @@ -4169,6 +4212,228 @@ async def test(): return run_async(test()) +def _test_publish_evc_device(my_predbat): + """Test publishing the EVC status and the derived car connected binary sensor""" + + async def test(): + ge = MockGECloudDirect() + + # Test 1: a charging session publishes the raw status and reads as connected + await ge.publish_evc_device("EVC123456", {"serial_number": "EVC123456", "status": "charging"}) + status_entity = "sensor.predbat_gecloud_evc123456_evc_status" + connected_entity = "binary_sensor.predbat_gecloud_evc123456_evc_car_connected" + assert ge.dashboard_items.get(status_entity, {}).get("state") == "charging", "EVC status should be published raw" + assert ge.dashboard_items.get(connected_entity, {}).get("state") == "on", "A charging EVC should read as a car connected" + + # Test 2: idle means nothing is plugged in + await ge.publish_evc_device("EVC123456", {"status": "idle"}) + assert ge.dashboard_items[connected_entity]["state"] == "off", "An idle EVC should read as no car connected" + + # Test 3: the OCPP session stages count as connected, whatever their casing + for status in ["Preparing", "SuspendedEV", "SuspendedEVSE", "Finishing", "Charging"]: + await ge.publish_evc_device("EVC123456", {"status": status}) + assert ge.dashboard_items[connected_entity]["state"] == "on", "{} should read as a car connected".format(status) + + # Test 4: the disconnected statuses, including a charger that has gone offline + for status in ["Available", "offline", "Unavailable", "Faulted", "Reserved"]: + await ge.publish_evc_device("EVC123456", {"status": status}) + assert ge.dashboard_items[connected_entity]["state"] == "off", "{} should read as no car connected".format(status) + + # Test 5: an unrecognised status is safe (no car) but says so once, not every poll + ge.log_messages = [] + await ge.publish_evc_device("EVC123456", {"status": "not_a_real_status"}) + assert ge.dashboard_items[connected_entity]["state"] == "off", "An unknown status should read as no car connected" + warnings = [message for message in ge.log_messages if "not_a_real_status" in message] + assert len(warnings) == 1, "An unknown EVC status should be reported once, got {}".format(len(warnings)) + await ge.publish_evc_device("EVC123456", {"status": "not_a_real_status"}) + warnings = [message for message in ge.log_messages if "not_a_real_status" in message] + assert len(warnings) == 1, "An unknown EVC status should not be reported again on the next poll" + + # Test 6: no status at all publishes nothing rather than inventing a state + ge.dashboard_items = {} + await ge.publish_evc_device("EVC999999", {"serial_number": "EVC999999"}) + assert not ge.dashboard_items, "A device with no status should publish nothing, got {}".format(ge.dashboard_items) + + return 0 + + return run_async(test()) + + +def _test_async_automatic_config_evc(my_predbat): + """Test automatic configuration of Predbat car charging inputs from GE Cloud EV chargers""" + + async def test(): + ge = MockGECloudDirect() + + # Test 1: two chargers wire both car keys, ordered by serial so charger N is car N + ge.config_args = {} + ge.evc_device_list = ["evc-002", "evc-001"] + ge.evc_device = { + "evc-001": {"serial_number": "EVC200", "status": "charging"}, + "evc-002": {"serial_number": "EVC100", "status": "idle"}, + } + + await ge.async_automatic_config_evc() + + assert ge.config_args.get("car_charging_energy") == [ + "sensor.predbat_gecloud_evc100_evc_energy_active_import_register", + "sensor.predbat_gecloud_evc200_evc_energy_active_import_register", + ], "car_charging_energy should list both chargers in serial order, got {}".format(ge.config_args.get("car_charging_energy")) + assert ge.config_args.get("car_charging_planned") == [ + "binary_sensor.predbat_gecloud_evc100_evc_car_connected", + "binary_sensor.predbat_gecloud_evc200_evc_car_connected", + ], "car_charging_planned should list both chargers in serial order, got {}".format(ge.config_args.get("car_charging_planned")) + assert ge.config_args.get("num_cars") == 2, "num_cars should be raised to the number of chargers" + + # Test 2: an existing larger num_cars is left alone - another component may own those cars + ge.config_args = {"num_cars": 3} + await ge.async_automatic_config_evc() + assert ge.config_args.get("num_cars") == 3, "num_cars should not be reduced to the charger count" + + # Test 3: no chargers configures nothing at all + ge.config_args = {} + ge.evc_device_list = [] + ge.evc_device = {} + await ge.async_automatic_config_evc() + assert ge.config_args.get("car_charging_energy") is None, "car_charging_energy should be left alone with no chargers" + assert ge.config_args.get("car_charging_planned") is None, "car_charging_planned should be left alone with no chargers" + assert ge.config_args.get("num_cars") is None, "num_cars should be left alone with no chargers" + + # Test 4: a charger whose serial has not been read yet is skipped rather than + # publishing an entity name with a hole in it + ge.config_args = {} + ge.evc_device_list = ["evc-001", "evc-003"] + ge.evc_device = {"evc-001": {"serial_number": "EVC100"}, "evc-003": {"serial_number": None}} + await ge.async_automatic_config_evc() + assert ge.config_args.get("car_charging_energy") == ["sensor.predbat_gecloud_evc100_evc_energy_active_import_register"], "A charger with no serial should be skipped" + assert ge.config_args.get("num_cars") == 1, "num_cars should count only the chargers actually wired" + + return 0 + + return run_async(test()) + + +EVC_PLAN_SENSOR = "binary_sensor.predbat_car_charging_slot" +EVC_PLAN_SENSOR_CAR_1 = "binary_sensor.predbat_car_charging_slot_1" + + +def _evc_control_component(commands, num_cars=1): + """Build a mock component with EVC control live and its commands recorded.""" + ge = MockGECloudDirect() + ge.evc_control = True + ge.automatic_evc = True + ge.evc_control_enable() + ge.base.num_cars = num_cars + + async def mock_send(uuid, command, params): + commands.append((uuid, command)) + return {"success": True} + + ge.async_send_evc_command = mock_send + return ge + + +def _test_evc_control(my_predbat): + """Test Predbat-led start/stop control of GivEnergy EV chargers from the car plan""" + + async def test(): + tz = pytz.timezone("Europe/London") + inside = tz.localize(datetime(2026, 8, 22, 23, 30)) + outside = tz.localize(datetime(2026, 8, 23, 6, 0)) + plan = {EVC_PLAN_SENSOR: {"planned": [{"start": "08-22 23:00:00", "end": "08-23 05:00:00"}]}} + + # Test 1: control stays off unless it is asked for + 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" + + # 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 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 + commands = [] + ge = _evc_control_component(commands) + ge.evc_device_list = ["evc-001"] + ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "charging"}} + ge.entity_attributes = plan + + await ge.evc_control_charge(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) + 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) + 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) + 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" + + commands.clear() + await ge.evc_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 + commands = [] + ge = _evc_control_component(commands) + ge.evc_device_list = ["evc-001"] + ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "charging"}} + ge.entity_attributes = plan + await ge.evc_control_charge(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 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 + commands = [] + ge = _evc_control_component(commands) + ge.evc_device_list = ["evc-001"] + ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "idle"}} + ge.entity_attributes = plan + await ge.evc_control_charge(inside) + 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 + # cannot stop a charge that is already running + commands = [] + 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) + 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 + commands = [] + ge = _evc_control_component(commands, num_cars=2) + ge.evc_device_list = ["evc-second", "evc-first"] + ge.evc_device = { + "evc-first": {"serial_number": "EVC200", "status": "charging"}, + "evc-second": {"serial_number": "EVC100", "status": "charging"}, + } + ge.entity_attributes = {EVC_PLAN_SENSOR: plan[EVC_PLAN_SENSOR], EVC_PLAN_SENSOR_CAR_1: {"planned": []}} + + await ge.evc_control_charge(inside) + assert sorted(commands) == sorted([("evc-second", "start-charge"), ("evc-first", "stop-charge")]), "The lower serial should be car 0, got {}".format(commands) + + return 0 + + return run_async(test()) + + def _test_hybrid_detection(my_predbat): """Test hybrid vs AC-coupled inverter detection in async_automatic_config""" diff --git a/apps/predbat/utils.py b/apps/predbat/utils.py index d526b0a6e..3c650aa82 100644 --- a/apps/predbat/utils.py +++ b/apps/predbat/utils.py @@ -785,6 +785,47 @@ def format_time_ago(last_updated): return "Unknown ({})".format(last_updated) +# The format Predbat publishes car charging plan windows in. No year, because a plan never +# reaches more than 48 hours ahead - parse_car_plan_windows() puts one back. +CAR_PLAN_TIME_FORMAT = "%m-%d %H:%M:%S" + + +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 + 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. + + Args: + planned: The 'planned' attribute of a car charging slot sensor, a list of dicts + with 'start' and 'end' keys. + now: The instant every window is judged against, localised. + local_tz: The timezone the plan's wall clock times are expressed in. + """ + parsed = [] + for window in planned or []: + try: + start = local_tz.localize(datetime.strptime(window["start"], CAR_PLAN_TIME_FORMAT).replace(year=now.year)) + end = local_tz.localize(datetime.strptime(window["end"], CAR_PLAN_TIME_FORMAT).replace(year=now.year)) + except (KeyError, TypeError, ValueError): + continue + # Rebuild the year 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) + parsed.append((start, end)) + return parsed + + +def in_car_plan_window(windows, now): + """Is now inside one of the (start, end) pairs returned by parse_car_plan_windows.""" + return any(start <= now < end for start, end in windows) + + def in_iboost_slot(minute, iboost_plan): """ Is the given minute inside a car slot diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index a7dcd808b..7664040f5 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -518,6 +518,23 @@ This setting takes priority over **ge_cloud_automatic_shared_ct** if both are se - **ge_cloud_automatic_split_pv** - Optional, defaults to false. When set to `true`, Predbat will also include any standalone PV-only inverters (e.g. a GivEnergy AC-coupled PV inverter with no battery attached) in **pv_today** and **pv_power**, in addition to the battery inverters. Use this if you have a separate PV-only inverter alongside your battery inverter(s) and want its solar generation included in Predbat's totals. Leave this off (the default) if your battery inverters already report all of your solar generation, to avoid duplicating or including unwanted readings. +- **ge_cloud_automatic_evc** - Optional, defaults to false. When set to `true`, any GivEnergy EV charger on your account is wired into +Predbat's car planning, so **car_charging_energy**, **car_charging_planned** and **num_cars** need no `apps.yaml` entries of your own. +Chargers are taken in serial order, so charger N is car N, and this happens whether or not you have a GivEnergy battery. +Everything else about your car - **car_charging_battery_size**, **car_charging_limit** and **car_charging_soc** - still comes from +`apps.yaml` as usual. +This is a separate setting from **ge_cloud_automatic** because it registers a car and changes **num_cars**, so turning on inverter +auto-configuration does not silently change your car setup. The charger's own entities are published either way. +See [Components - GivEnergy Cloud Direct](components.md#ev-chargers-gecloud) for the entities this publishes. + +- **ge_cloud_evc_control** - Optional, defaults to false. When set to `true`, Predbat starts and stops your GivEnergy EV charger from its +car charging plan, in the same way it can drive a myenergi Zappi or an Ohme charger. Charger N follows car N. Needs **ge_cloud_automatic_evc**, +since it is that setting which maps each charger to a car. +A `switch.predbat_gecloud_evc_control` entity appears when this is set, on by default, so you can hand the charger back without editing +`apps.yaml`; releasing sends a start command if Predbat had stopped the charger, so a car is never left unable to charge. +Read only mode releases the chargers in the same way. +See [Components - Charger control](components.md#charger-control-gecloud) for the details. + ### SolaX Cloud Direct Predbat supports direct communication with the SolaX Cloud API to control SolaX inverters and batteries without requiring local integrations. diff --git a/docs/components.md b/docs/components.md index 893c8bdc1..172ea4347 100644 --- a/docs/components.md +++ b/docs/components.md @@ -218,11 +218,81 @@ Connects directly to the GivEnergy Cloud to control your GivEnergy inverter and | `ge_cloud_direct` | Boolean | Yes | - | `ge_cloud_direct` | Set to `true` to enable GivEnergy Cloud control | | `api_key` | String | Yes | - | `ge_cloud_key` | Your GivEnergy Cloud API key | | `automatic` | Boolean | No | false | `ge_cloud_automatic` | Set to `true` to automatically configured Predbat to use GivEnergy Cloud direct (no additional apps.yaml changes required) | +| `automatic_evc` | Boolean | No | false | `ge_cloud_automatic_evc` | Set to `true` to wire your GivEnergy EV chargers into `car_charging_energy`, `car_charging_planned` and `num_cars` — see [EV chargers](#ev-chargers-gecloud). Separate from `ge_cloud_automatic` because it registers a car | +| `evc_control` | Boolean | No | false | `ge_cloud_evc_control` | Set to `true` to let Predbat start and stop your EV charger from its car charging plan — see [Charger control](#charger-control-gecloud). Needs `ge_cloud_automatic_evc` | | `load_today_ignore` | Boolean | No | false | `ge_cloud_load_today_ignore` | Set to `true` to ignore GE Cloud load_today data and use the `load_today` sensor from `apps.yaml` instead | | `automatic_shared_ct` | Boolean | No | false | `ge_cloud_automatic_shared_ct` | Set to `true` to force shared CT clamp mode — only the first inverter's grid and load readings are used, preventing double-counting on multi-inverter systems with a single shared CT | | `automatic_split_ct` | Boolean | No | false | `ge_cloud_automatic_split_ct` | Set to `true` to force split CT clamp mode — each inverter's readings are summed independently. Takes priority over `ge_cloud_automatic_shared_ct` if both are set | | `automatic_split_pv` | Boolean | No | false | `ge_cloud_automatic_split_pv` | Set to `true` to also include standalone PV-only inverters' solar readings in `pv_today`/`pv_power`, in addition to battery inverters | +#### EV chargers (gecloud) + +Every GivEnergy EV charger on the account is polled alongside the inverters and publishes +its meter readings as `sensor.predbat_gecloud__evc_*` entities, plus two entities +describing the charger itself: + +| Entity | Description | +| ------ | ----------- | +| `sensor.predbat_gecloud__evc_status` | The charger's status as GivEnergy reports it, e.g. `charging`, `idle`, `offline` | +| `binary_sensor.predbat_gecloud__evc_car_connected` | `on` while a car is plugged in, from the status above | + +Those two entities are published whatever your settings say — they are new entities and +change nothing that already exists. + +Setting `ge_cloud_automatic_evc` to `true` additionally wires the chargers into Predbat's +car planning, in serial order so charger N is car N: + +- **car_charging_energy** — each charger's `_evc_energy_active_import_register`, so + `car_charging_hold` subtracts the car charging from house load precisely instead of + falling back to the `car_charging_threshold` heuristic +- **car_charging_planned** — each charger's `_evc_car_connected`, so Predbat only plans + car charging when there is actually a car on the cable +- **num_cars** — raised to the number of chargers if it is currently lower, never reduced, + since another component may have registered cars of its own + +This is deliberately a separate setting from `ge_cloud_automatic` rather than part of it: +it registers a car and moves `num_cars`, which would change the plan for existing users +who had only ever asked for their inverter to be configured. It runs whether or not a +GivEnergy battery is present, so a GivEnergy charger alongside another manufacturer's +battery is configured too. Everything else about the car — +**car_charging_battery_size**, **car_charging_limit** and **car_charging_soc** — still +comes from `apps.yaml` as usual. + +`car_charging_planned` is wired to a binary sensor rather than to the status sensor on +purpose: it answers `on`, which the default **car_charging_planned_response** already +matches, so this works without you having to add GivEnergy's status words to that list. +If your charger reports a status Predbat does not recognise it is treated as no car +connected and logged once, so please report the value from the log so it can be added. + +#### Charger control (gecloud) + +With `ge_cloud_evc_control` set to `true`, Predbat drives each charger from its own car's +plan: `start-charge` inside a planned charging window, `stop-charge` outside one. Charger N +follows car N, in the same serial order the automatic configuration uses, so the two cannot +disagree about which charger is which car. + +`ge_cloud_automatic_evc` must also be on, since it is that configuration which establishes +the charger to car mapping. Predbat says so in the log and leaves control off rather than +guessing if you enable control without it. + +- A command is only sent when the wanted state actually changes, so a charger already + charging inside a window is left alone rather than commanded every minute +- A charger with no car plugged in is never commanded — Predbat waits for + `_evc_car_connected` to go `on` +- Nothing is commanded until Predbat has published a car plan, so a restart cannot stop a + charge that is already running + +A `switch.predbat_gecloud_evc_control` entity appears when control is enabled, on by +default, so you can hand the charger back without editing `apps.yaml`. Turning it off — or +putting Predbat into read only mode — **releases** rather than just going quiet: if Predbat +had stopped the charger it sends `start-charge` once on the way out, so a car is never left +stranded by a charger Predbat walked away from. The switch state is saved, so an off +survives a restart. + +Unlike a Zappi, there is no previous mode to restore on release: `start-charge` and +`stop-charge` are commands rather than modes, so your charger's own mode (Grid, Hybrid, +Solar) still decides what happens once Predbat lets go. + #### How to get your API key (gecloud) 1. Log in to your GivEnergy account at From afd1b5af3e47be13a0284400ecc03ca52d442fc8 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 24 Aug 2026 13:31:24 +0100 Subject: [PATCH 2/2] Fix two charger control edge cases found in review A car charging window spanning New Year was matched before midnight but not after it. Read at 00:30 on 1 January, a 12-31 start parses as December of the year just started - eleven months ahead - and the rebuild only handled windows that landed in the past, so the window read as not yet started and the car was stopped mid-charge. The rebuild is now symmetric. The margin that decides a year is wrong moves from 23 hours to 180 days. 23 hours is inside the 48 hour horizon a plan covers, so shifting on it would have dragged a window 30 hours ahead back into the previous year; 180 days cannot collide with a real window but always catches a year that was stamped on wrongly. evc_control_charge() mapped every charger to a car index and stopped any whose index had no plan. num_cars is raised by async_automatic_config_evc, but that reaches the base object a cycle later, so a second charger could be stopped while its car was charging. Control now runs only as far as there are cars, leaving a charger with no plan alone. Both cases are covered by tests that fail without the fixes. Co-Authored-By: Claude Opus 5 (1M context) --- apps/predbat/gecloud.py | 6 ++++- apps/predbat/tests/test_ge_cloud.py | 15 +++++++++++++ apps/predbat/tests/test_myenergi.py | 34 +++++++++++++++++++++++++++++ apps/predbat/utils.py | 22 ++++++++++++++++--- 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/apps/predbat/gecloud.py b/apps/predbat/gecloud.py index 48de7a2fa..d401346ed 100644 --- a/apps/predbat/gecloud.py +++ b/apps/predbat/gecloud.py @@ -1422,7 +1422,11 @@ async def evc_control_charge(self, now): """ if not self.refresh_evc_car_windows(now): return - for car_n, uuid in enumerate(self.controlled_evc_devices()): + # 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 diff --git a/apps/predbat/tests/test_ge_cloud.py b/apps/predbat/tests/test_ge_cloud.py index ae223c7ef..50d562a7c 100644 --- a/apps/predbat/tests/test_ge_cloud.py +++ b/apps/predbat/tests/test_ge_cloud.py @@ -4429,6 +4429,21 @@ async def test(): await ge.evc_control_charge(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. + # num_cars is raised by async_automatic_config_evc, but that lands on the base + # object a cycle later, so briefly there can be more chargers than cars. + commands = [] + ge = _evc_control_component(commands, num_cars=1) + ge.evc_device_list = ["evc-first", "evc-second"] + ge.evc_device = { + "evc-first": {"serial_number": "EVC100", "status": "charging"}, + "evc-second": {"serial_number": "EVC200", "status": "charging"}, + } + ge.entity_attributes = {EVC_PLAN_SENSOR: plan[EVC_PLAN_SENSOR]} + + await ge.evc_control_charge(inside) + assert commands == [("evc-first", "start-charge")], "Only the charger with a car should be commanded, got {}".format(commands) + return 0 return run_async(test()) diff --git a/apps/predbat/tests/test_myenergi.py b/apps/predbat/tests/test_myenergi.py index f052e5ef5..84b49adb2 100644 --- a/apps/predbat/tests/test_myenergi.py +++ b/apps/predbat/tests/test_myenergi.py @@ -974,6 +974,39 @@ def test_control_window_parsing(): print(" ✓ Planned car charging windows are parsed and matched against the clock") +def test_control_windows_across_new_year(): + """A window spanning New Year is still matched, read from either side of midnight. + + The plan carries no year, so a window read just after midnight on 1 January parses + its 31 December start as this year - eleven months in the future - unless the year is + rebuilt around the clock. Getting this wrong stops a car mid-charge once a year. + """ + crossing = _plan_window(datetime.datetime(2026, 12, 31, 23, 0), datetime.datetime(2027, 1, 1, 5, 0)) + 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" + + 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" + + 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" + + # 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" + print(" ✓ Windows spanning New Year are matched from both sides of midnight") + + def test_control_windows_are_per_car(): """Each car's own slot sensor drives its own Zappi, so car 1 does not follow car 0.""" car0 = _plan_window(datetime.datetime(2026, 8, 22, 23, 0), datetime.datetime(2026, 8, 23, 1, 0)) @@ -2350,6 +2383,7 @@ def test_myenergi(my_predbat=None): test_cloud_one_bad_device_does_not_lose_the_others() test_cloud_auth_error_still_aborts_the_poll() test_control_window_parsing() + test_control_windows_across_new_year() test_control_windows_are_per_car() test_control_windows_tolerate_a_bad_entry_and_a_missing_plan() test_control_windows_cross_the_year_boundary() diff --git a/apps/predbat/utils.py b/apps/predbat/utils.py index 3c650aa82..4f6b679bc 100644 --- a/apps/predbat/utils.py +++ b/apps/predbat/utils.py @@ -789,6 +789,12 @@ def format_time_ago(last_updated): # reaches more than 48 hours ahead - parse_car_plan_windows() puts one back. CAR_PLAN_TIME_FORMAT = "%m-%d %H:%M:%S" +# How far from now a parsed window has to land before the year stamped on it is treated as +# the wrong one. Comfortably beyond the 48 hours a plan covers, so a genuinely distant +# window is never dragged into a different year, and far short of the ~12 months a +# mis-stamped year produces. +CAR_PLAN_YEAR_MARGIN = timedelta(days=180) + def parse_car_plan_windows(planned, now, local_tz): """Turn one car's published charging plan into a list of localised (start, end) pairs. @@ -798,6 +804,12 @@ def parse_car_plan_windows(planned, now, local_tz): 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. + The rebuild is symmetric. A window read at 23:30 on 31 December whose end is stamped + 01-01 parses as January of the year just ending, and needs shifting forward; the same + window read at 00:30 on 1 January has its 12-31 start parsed as December of the year + just started, and needs shifting back. Only the second case ever hides an active + window, which is why it is the one that stops a car mid-charge if it is missed. + Args: planned: The 'planned' attribute of a car charging slot sensor, a list of dicts with 'start' and 'end' keys. @@ -811,11 +823,15 @@ def parse_car_plan_windows(planned, now, local_tz): end = local_tz.localize(datetime.strptime(window["end"], CAR_PLAN_TIME_FORMAT).replace(year=now.year)) except (KeyError, TypeError, ValueError): continue - # Rebuild the year around now for windows that cross New Year - if start < now - timedelta(hours=23): + # Shift both ends together so their spacing survives, then close a window whose + # end is in January while its start is still in December + if start > now + CAR_PLAN_YEAR_MARGIN: + start = start.replace(year=start.year - 1) + end = end.replace(year=end.year - 1) + elif start < now - CAR_PLAN_YEAR_MARGIN: start = start.replace(year=start.year + 1) end = end.replace(year=end.year + 1) - elif end < start: + if end < start: end = end.replace(year=end.year + 1) parsed.append((start, end)) return parsed