diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 6333b7d09..e134525ef 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -27,6 +27,7 @@ argname armhf armv ASHP +asn asyncio atfork atleast @@ -53,6 +54,7 @@ batteryb batusecap beforeunload bierner +boostable brickatius byok cadata @@ -235,6 +237,7 @@ hanchu hanchuess hanres HAOS +harvi hasattr hass hassapi @@ -272,7 +275,9 @@ isort itemsize itemtype ivtime +jdayhour jedlix +jstatus jsyaml kaiming keepalive @@ -291,6 +296,7 @@ kwhb labelcolor labelnames larr +libbi libc LIFEPO linebreak @@ -352,6 +358,7 @@ mpxn mqtt mtok mult +myaccount myenergi mypy nanmean @@ -441,6 +448,7 @@ pvwatts pwindow pyjwt pylint +pymyenergi pypi pyproject pytest @@ -454,6 +462,7 @@ readbacks recp redownload Referer +refetched refetches regionid regname @@ -589,6 +598,7 @@ typecode typecodes tzfile tzpath +unasserted unconfigured underivable undiscounted @@ -636,4 +646,5 @@ ylabel YOURSERIAL yuanzhi zappi +Zappis zigbee diff --git a/apps/predbat/components.py b/apps/predbat/components.py index 4add2a3c9..57f77eff8 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -22,6 +22,7 @@ from solcast import SolarAPI from gecloud import GECloudDirect, GECloudData from ohme import OhmeAPI +from myenergi import MyEnergiAPI from octopus import OctopusAPI from carbon import CarbonAPI from temperature import TemperatureAPI @@ -219,6 +220,33 @@ }, "phase": 1, }, + "myenergi": { + "class": MyEnergiAPI, + "name": "myenergi Zappi/Eddi", + "event_filter": "predbat_myenergi_", + "args": { + "auth_method": {"required": False, "config": "myenergi_auth_method", "default": "direct"}, + "hub_serial": {"required": False, "config": "myenergi_hub_serial"}, + "api_key": {"required": False, "config": "myenergi_api_key"}, + "key": {"required": False, "config": "myenergi_key"}, + "token_expires_at": {"required": False, "config": "myenergi_token_expires_at"}, + "token_hash": {"required": False, "config": "myenergi_token_hash"}, + "automatic": {"required": False, "config": "myenergi_automatic", "default": True}, + "enable_controls": {"required": False, "config": "myenergi_enable_controls", "default": True}, + "poll_seconds": {"required": False, "config": "myenergi_poll_seconds", "default": 60}, + }, + # Gate activation on having at least one auth path — api_key is the direct + # transport's local hub credential, key is the cloud transport's access token. + # Without this the component would start for every instance since all + # individual args are optional to allow either auth mode. + # api_key is the direct transport's credential; key (the OAuth access token) and + # token_hash (which the refresh chain exchanges for one) are the cloud transport's. + # token_hash has to be listed too: a refresh-only OAuth setup carries no key, and + # initialize() accepts that, so gating on key alone would never construct it. + "required_or": ["api_key", "key", "token_hash"], + "phase": 1, + "can_restart": True, + }, "fox": { "class": FoxAPI, "name": "Fox API", diff --git a/apps/predbat/config.py b/apps/predbat/config.py index f50495cbc..d7c915251 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -2581,6 +2581,15 @@ "solis_access_token": {"type": "string", "empty": False}, "solis_token_expires_at": {"type": "string", "empty": False}, "solis_token_hash": {"type": "string", "empty": False}, + "myenergi_auth_method": {"type": "string", "empty": False}, + "myenergi_hub_serial": {"type": "string", "empty": False}, + "myenergi_api_key": {"type": "string", "empty": False}, + "myenergi_key": {"type": "string", "empty": False}, + "myenergi_token_expires_at": {"type": "string", "empty": False}, + "myenergi_token_hash": {"type": "string", "empty": False}, + "myenergi_automatic": {"type": "boolean"}, + "myenergi_enable_controls": {"type": "boolean"}, + "myenergi_poll_seconds": {"type": "integer", "zero": False}, "fox_key": {"type": "string", "empty": False}, "fox_automatic": {"type": "boolean"}, "fox_automatic_ignore_pv": {"type": "boolean"}, diff --git a/apps/predbat/myenergi.py b/apps/predbat/myenergi.py new file mode 100644 index 000000000..c014e9015 --- /dev/null +++ b/apps/predbat/myenergi.py @@ -0,0 +1,1030 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# myenergi API library. +# Supports both the direct "director" API (digest auth, /cgi-* endpoints) that +# pymyenergi and the ha-myenergi integration use, and the official 3rd party API +# documented at https://api-docs.s18.myenergi.net/ +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + + +"""myenergi Zappi and Eddi integration. + +Provides monitoring of myenergi Zappi EV chargers and Eddi hot water diverters, +automatic wiring of their energy sensors into Predbat's car charging and iboost +inputs, and send/cancel boost controls. Two interchangeable transports cover the +two myenergi APIs: a direct digest-authenticated transport that any myenergi owner +can configure today, and a bearer-token transport for the official 3rd party API. +""" + +import argparse +import asyncio +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional + +import aiohttp + +from component_base import ComponentBase +from mock_base import MockBase +from oauth_mixin import OAuthMixin +from predbat_metrics import record_api_call + +MYENERGI_DIRECTOR_URL = "https://director.myenergi.net" +MYENERGI_CLOUD_URL = "https://api.s18.myenergi.net" + +API_TIMEOUT = 30 +USER_AGENT = "Wget/1.14 (linux-gnu)" + +DEVICE_KIND_ZAPPI = "zappi" +DEVICE_KIND_EDDI = "eddi" +SUPPORTED_KINDS = (DEVICE_KIND_ZAPPI, DEVICE_KIND_EDDI) + +# Device id prefixes. The direct API uses a single letter, the cloud API two letters. +DIRECT_PREFIX = {DEVICE_KIND_ZAPPI: "Z", DEVICE_KIND_EDDI: "E"} +CLOUD_PREFIX = {DEVICE_KIND_ZAPPI: "ZA", DEVICE_KIND_EDDI: "ED"} + +# The "Charging" label appears in three unrelated tables below (a Zappi's numeric +# status, its plug status, and the cloud status translation) plus the component's +# charging binary sensor, all of which must agree on the exact string. +STATUS_CHARGING = "Charging" + +# Index tables used by the direct API's numeric status fields. +ZAPPI_CHARGE_MODES = ["None", "Fast", "Eco", "Eco+", "Stopped"] +ZAPPI_STATES = ["Unknown", "Paused", "Unknown", STATUS_CHARGING, "Boosting", "Completed"] +EDDI_STATES = ["Unknown", "Paused", "Unknown", "Diverting", "Boosting", "Max temp reached", "Stopped"] + +ZAPPI_PLUG_STATES = { + "A": "EV Disconnected", + "B1": "EV Connected", + "B2": "Waiting for EV", + "C1": "EV ready to charge", + "C2": STATUS_CHARGING, + "D1": "EV ready to charge", + "D2": STATUS_CHARGING, + "F": "Fault", +} + +EDDI_BOOST_TARGETS = {"heater1": 1, "heater2": 2, "relay1": 11, "relay2": 12} +EDDI_DEFAULT_BOOST_TARGET = "heater1" + +# The cloud API reports modes and statuses as strings. These maps translate them into +# the same vocabulary the direct API's index tables produce, so both transports emit +# identical MyEnergiDevice values for equivalent device states. +CLOUD_MODE_TO_NAME = {"fast": "Fast", "eco": "Eco", "eco+": "Eco+", "stop": "Stopped"} + +CLOUD_STATUS_TO_NAME = { + "ev_not_connected": "Paused", + "waiting_for_surplus": "Paused", + "waiting_for_ev": "Paused", + "charge_delayed": "Paused", + "smart_charge_delay": "Paused", + "charge_complete": "Completed", + "charging": STATUS_CHARGING, + "boosting": "Boosting", + "stopped": "Stopped", + "diverting": "Diverting", + "hot": "Max temp reached", + "starting": "Paused", + "dsr": "Paused", +} + +# Zappi boost energy limits, from the 3rd party API schema. The direct API accepts the +# same range in practice, so both transports validate against these. +BOOST_ENERGY_MIN = 1 +BOOST_ENERGY_MAX = 99 +BOOST_MINUTES_MIN = 0 +BOOST_MINUTES_MAX = 240 + +# Boosting a Zappi is only accepted while it is in one of the green-energy modes. +ZAPPI_BOOSTABLE_MODES = ("Eco", "Eco+") + +# aiohttp only grew DigestAuthMiddleware and ClientSession(middlewares=...) in 3.12, and +# the direct transport cannot authenticate without them. requirements.txt pins the floor, +# but a hand-managed install can still be older, so say what to do rather than failing +# with a bare AttributeError from deep inside the first request. +AIOHTTP_DIGEST_REQUIRED = "the direct myenergi transport needs aiohttp 3.12 or newer for digest authentication (installed: {}) - upgrade aiohttp, or set myenergi_auth_method to oauth" + + +class MyEnergiError(Exception): + """Base class for every myenergi transport failure.""" + + +class MyEnergiAuthError(MyEnergiError): + """Raised when myenergi rejects the supplied credentials.""" + + +class MyEnergiApiError(MyEnergiError): + """Raised when a myenergi request fails for a non-authentication reason.""" + + +@dataclass +class MyEnergiDevice: + """One normalised myenergi device, identical in shape across both transports.""" + + device_id: str + kind: str + serial: str + name: str + online: bool + status: str + mode: str + plug_status: str + power_w: float + grid_power_w: float + generation_w: float + voltage: float + session_energy_kwh: float + boost_active: bool + boost_remaining_mins: int + temp_1: Optional[float] + temp_2: Optional[float] + + +def _to_float(value, default=0.0): + """Coerce a raw API value to float, returning default for None or junk.""" + if value is None: + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _index_lookup(table, index, default="Unknown"): + """Look a numeric status code up in one of the direct API's index tables.""" + try: + position = int(index) + except (TypeError, ValueError): + return default + if 0 <= position < len(table): + return table[position] + return default + + +def _boost_units(amount): + """Round a boost amount to the whole kWh or whole minutes both APIs expect. + + int() alone truncates toward zero, so a 9.8 kWh selection would be sent as 9. + """ + return max(0, int(round(_to_float(amount)))) + + +def digest_auth_available(): + """Return True when the installed aiohttp provides the digest auth middleware.""" + return hasattr(aiohttp, "DigestAuthMiddleware") + + +def _optional_temp(value): + """Return an Eddi probe temperature, or None when no probe is connected. + + myenergi reports 127 for an unconnected probe and a negative value when the + reading is unknown; publishing either as a temperature would be misleading. + """ + if value is None: + return None + try: + temperature = float(value) + except (TypeError, ValueError): + return None + if temperature >= 127 or temperature < 0: + return None + return temperature + + +def normalise_direct_device(raw, kind): + """Convert one direct API device record into a MyEnergiDevice. + + Args: + raw: A single device dict from a /cgi-jstatus-* response. + kind: Either DEVICE_KIND_ZAPPI or DEVICE_KIND_EDDI. + """ + serial = str(raw.get("sno", "") or "") + if kind == DEVICE_KIND_ZAPPI: + status = _index_lookup(ZAPPI_STATES, raw.get("sta")) + mode = _index_lookup(ZAPPI_CHARGE_MODES, raw.get("zmo")) + plug_status = ZAPPI_PLUG_STATES.get(str(raw.get("pst", "") or ""), "") + boost_active = status == "Boosting" + boost_remaining_mins = 0 + temp_1 = None + temp_2 = None + else: + status = _index_lookup(EDDI_STATES, raw.get("sta")) + mode = "Stopped" if status == "Stopped" else "Normal" + plug_status = "" + boost_active = int(_to_float(raw.get("bsm"))) == 1 + boost_remaining_mins = int(round(_to_float(raw.get("rbt")) / 60.0)) + temp_1 = _optional_temp(raw.get("tp1")) + temp_2 = _optional_temp(raw.get("tp2")) + + return MyEnergiDevice( + device_id=DIRECT_PREFIX[kind] + serial, + kind=kind, + serial=serial, + name="{}-{}".format(kind, serial), + online=True, + status=status, + mode=mode, + plug_status=plug_status, + power_w=_to_float(raw.get("div")), + grid_power_w=_to_float(raw.get("grd")), + generation_w=_to_float(raw.get("gen")), + voltage=_to_float(raw.get("vol")) / 10.0, + session_energy_kwh=_to_float(raw.get("che")), + boost_active=boost_active, + boost_remaining_mins=boost_remaining_mins, + temp_1=temp_1, + temp_2=temp_2, + ) + + +def normalise_cloud_device(raw, meta): + """Convert one cloud API status response into a MyEnergiDevice. + + Args: + raw: The body of GET /devices/{id}/status. + meta: The matching device entry from GET /devices, used for the id, alias + and online flag that the status response does not carry. + """ + kind = DEVICE_KIND_ZAPPI if str(raw.get("deviceClass", "")).upper() == "ZAPPI" else DEVICE_KIND_EDDI + serial = str(meta.get("serialNumber", "") or "") + device_id = str(meta.get("deviceId", "") or "") + if not serial and device_id: + serial = device_id[2:] + + status = CLOUD_STATUS_TO_NAME.get(str(raw.get("deviceStatus", "") or "").lower(), "Unknown") + if kind == DEVICE_KIND_ZAPPI: + mode = CLOUD_MODE_TO_NAME.get(str(raw.get("supplyMode", "") or "").lower(), "Unknown") + plug_status = ZAPPI_PLUG_STATES.get(str(raw.get("pilotState", "") or ""), "") + boost_active = bool(raw.get("boostCharge", False)) + else: + mode = "Stopped" if status == "Stopped" else "Normal" + plug_status = "" + boost_active = bool(raw.get("boostActive", False)) + + return MyEnergiDevice( + device_id=device_id or (CLOUD_PREFIX[kind] + serial), + kind=kind, + serial=serial, + name=meta.get("alias") or "{}-{}".format(kind, serial), + online=bool(meta.get("online", True)), + status=status, + mode=mode, + plug_status=plug_status, + # The cloud API reports power in kW, the direct API in W. Normalise to W. + power_w=_to_float(raw.get("actualPower")) * 1000.0, + grid_power_w=_to_float(raw.get("gridPower")) * 1000.0, + generation_w=_to_float(raw.get("genPower")) * 1000.0, + voltage=0.0, + session_energy_kwh=_to_float(raw.get("sessionEnergy")), + boost_active=boost_active, + boost_remaining_mins=0, + # The cloud API does not expose Eddi probe temperatures + temp_1=None, + temp_2=None, + ) + + +class MyEnergiTransport(ABC): + """Wire-format adapter for one of the two myenergi APIs. + + This is the only layer that knows how a myenergi request is shaped. Everything + above it works in terms of MyEnergiDevice, so adding or changing a transport + never touches publishing, auto-configuration or the controls. + """ + + def __init__(self, log): + """Store the logging function and initialise the one-shot warning set.""" + self.log = log + self._warned_stubs = set() + + @abstractmethod + async def connect(self): + """Establish and validate the connection. Returns True on success.""" + + @abstractmethod + async def fetch_devices(self): + """Return a list of MyEnergiDevice for every supported device found.""" + + @abstractmethod + async def send_boost(self, device, amount, target_time=None): + """Start a boost on a device. + + Args: + device: The MyEnergiDevice to boost. + amount: kWh for a Zappi, minutes for an Eddi. + target_time: Optional "HH:MM" completion time, Zappi smart boost only. + """ + + @abstractmethod + async def cancel_boost(self, device): + """Cancel an active boost on a device.""" + + def _not_implemented(self, what): + """Warn once that a control is not implemented in this release, and return False.""" + if what not in self._warned_stubs: + self._warned_stubs.add(what) + self.log("Warn: myenergi: {} is not implemented in this release".format(what)) + return False + + async def set_mode(self, device, mode): + """Set the device supply mode. Not implemented in this release.""" + return self._not_implemented("set_mode") + + async def set_priority(self, device, priority): + """Set the device diversion priority. Not implemented in this release.""" + return self._not_implemented("set_priority") + + async def set_min_green_level(self, device, level): + """Set the Zappi minimum green level. Not implemented in this release.""" + return self._not_implemented("set_min_green_level") + + async def set_phase_setting(self, device, phase): + """Set the Zappi phase setting. Not implemented in this release.""" + return self._not_implemented("set_phase_setting") + + async def get_schedule(self, device): + """Read the device charging schedule. Not implemented in this release.""" + return self._not_implemented("get_schedule") + + async def set_schedule(self, device, schedule): + """Write the device charging schedule. Not implemented in this release.""" + return self._not_implemented("set_schedule") + + +class MyEnergiDirectTransport(MyEnergiTransport): + """Transport for the direct myenergi API used by pymyenergi and ha-myenergi. + + Authenticates with HTTP digest, using the hub serial as the username and the + API key generated at myaccount.myenergi.com as the password. myenergi shards + accounts across servers, so the first request goes to director.myenergi.net, + whose X_MYENERGI-asn response header names the host to use from then on. + """ + + def __init__(self, log, hub_serial, api_key): + """Store credentials and start with an unresolved active server.""" + super().__init__(log) + self.hub_serial = str(hub_serial) + self.api_key = api_key + self.base_url = None + self.needs_asn_refresh = True + + def _new_session(self): + """Create an aiohttp session carrying the digest auth middleware.""" + if not digest_auth_available(): + raise MyEnergiApiError(AIOHTTP_DIGEST_REQUIRED.format(getattr(aiohttp, "__version__", "unknown"))) + digest = aiohttp.DigestAuthMiddleware(self.hub_serial, self.api_key) + return aiohttp.ClientSession(middlewares=(digest,), headers={"User-Agent": USER_AGENT}) + + def _update_asn(self, headers): + """Follow the active server named by the X_MYENERGI-asn response header.""" + asn = headers.get("X_MYENERGI-asn") + if not asn: + raise MyEnergiAuthError("no X_MYENERGI-asn header returned - check the hub serial and API key") + new_url = "https://" + asn + if new_url != self.base_url: + self.log("Info: myenergi: active server is {}".format(new_url)) + self.base_url = new_url + + async def _resolve_asn(self): + """Ask director.myenergi.net which server this account lives on. + + Wrapped in the same status/timeout handling as _request, since this + cold-start call targets a different host (the shared director, not the + account's own server) and is the request most likely to hit a network + failure. A non-200 response is reported as MyEnergiApiError rather than + diagnosed as bad credentials - an outage or error page from the director + has no reason to carry the ASN header, so the missing-header check only + applies once the request itself actually succeeded. + """ + path = "/cgi-jstatus-E" + try: + async with self._new_session() as session: + async with session.get(MYENERGI_DIRECTOR_URL + path, timeout=aiohttp.ClientTimeout(total=API_TIMEOUT)) as response: + if response.status == 401: + record_api_call("myenergi", success=False, reason="auth_error") + raise MyEnergiAuthError("myenergi rejected the credentials for {}".format(path)) + if response.status != 200: + self.needs_asn_refresh = True + reason = "server_error" if response.status >= 500 else "client_error" + record_api_call("myenergi", success=False, reason=reason) + raise MyEnergiApiError("HTTP {} from {}".format(response.status, path)) + self._update_asn(response.headers) + record_api_call("myenergi", success=True) + except asyncio.TimeoutError as exc: + self.needs_asn_refresh = True + record_api_call("myenergi", success=False, reason="connection_error") + raise MyEnergiApiError("timed out calling {}".format(path)) from exc + except aiohttp.ClientError as exc: + self.needs_asn_refresh = True + record_api_call("myenergi", success=False, reason="connection_error") + raise MyEnergiApiError("request to {} failed: {}".format(path, exc)) from exc + self.needs_asn_refresh = False + + async def _request(self, path): + """Perform one GET against the active server, resolving the ASN if needed.""" + if self.base_url is None or self.needs_asn_refresh: + await self._resolve_asn() + url = self.base_url + path + try: + async with self._new_session() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=API_TIMEOUT)) as response: + if response.status == 401: + record_api_call("myenergi", success=False, reason="auth_error") + raise MyEnergiAuthError("myenergi rejected the credentials for {}".format(path)) + if response.status != 200: + self.needs_asn_refresh = True + reason = "server_error" if response.status >= 500 else "client_error" + record_api_call("myenergi", success=False, reason=reason) + raise MyEnergiApiError("HTTP {} from {}".format(response.status, path)) + # The missing-header check runs only once the request itself succeeded, + # for the same reason _resolve_asn() orders it this way: an outage or an + # error page has no reason to carry the ASN header, and reporting that as + # bad credentials sends the user off to regenerate a perfectly good key. + self._update_asn(response.headers) + try: + payload = await response.json(content_type=None) + except (ValueError, TypeError) as exc: + record_api_call("myenergi", success=False, reason="decode_error") + raise MyEnergiApiError("could not decode the response from {}".format(path)) from exc + record_api_call("myenergi", success=True) + return payload + except asyncio.TimeoutError as exc: + self.needs_asn_refresh = True + record_api_call("myenergi", success=False, reason="connection_error") + raise MyEnergiApiError("timed out calling {}".format(path)) from exc + except aiohttp.ClientError as exc: + self.needs_asn_refresh = True + record_api_call("myenergi", success=False, reason="connection_error") + raise MyEnergiApiError("request to {} failed: {}".format(path, exc)) from exc + + async def connect(self): + """Resolve the active server, which also validates the credentials.""" + await self._resolve_asn() + return True + + async def fetch_devices(self): + """Fetch every device in one /cgi-jstatus-* call and normalise the supported ones. + + The response is a list of single-key dicts, one per device family, plus + housekeeping entries such as {"asn": ...} and {"fwv": ...} that are skipped. + """ + payload = await self._request("/cgi-jstatus-*") + devices = [] + if not isinstance(payload, list): + return devices + for group in payload: + if not isinstance(group, dict): + continue + for kind, records in group.items(): + if kind not in SUPPORTED_KINDS or not isinstance(records, list): + continue + for raw in records: + if isinstance(raw, dict): + devices.append(normalise_direct_device(raw, kind)) + return devices + + def _check_command_status(self, payload, path): + """Raise when a /cgi-* command response reports a failure status. + + The command endpoints answer HTTP 200 whether or not they acted, carrying the + real outcome in a {"status": N} body - a non-zero N means myenergi refused the + command (an Eddi already at maximum temperature, a Zappi that will not accept + the mode, an unknown device). Without this the caller would log a success and + the switch would quietly flip back on the next poll. Only a numeric non-zero + status counts as a failure: some endpoints answer with no body at all, and an + unparseable body is left to the caller rather than invented into an error. + """ + if not isinstance(payload, dict): + return + status = payload.get("status") + if status is None: + return + try: + code = int(status) + except (TypeError, ValueError): + return + if code != 0: + raise MyEnergiApiError("myenergi refused {} with status {}".format(path, code)) + + async def send_boost(self, device, amount, target_time=None): + """Start a boost, choosing the manual or smart command for a Zappi.""" + if device.kind == DEVICE_KIND_ZAPPI: + energy = _boost_units(amount) + if target_time: + # The command wants HHMM, so "7:30" has to become "0730" and not "730", + # which myenergi would read as 07:30 shifted by a digit. + when = str(target_time).replace(":", "").zfill(4) + path = "/cgi-zappi-mode-Z{}-0-11-{}-{}".format(device.serial, energy, when) + else: + path = "/cgi-zappi-mode-Z{}-0-10-{}-0000".format(device.serial, energy) + elif device.kind == DEVICE_KIND_EDDI: + target = EDDI_BOOST_TARGETS[EDDI_DEFAULT_BOOST_TARGET] + path = "/cgi-eddi-boost-E{}-10-{}-{}".format(device.serial, target, _boost_units(amount)) + else: + raise MyEnergiApiError("cannot boost unsupported device kind '{}'".format(device.kind)) + self._check_command_status(await self._request(path), path) + return True + + async def cancel_boost(self, device): + """Cancel an active boost.""" + if device.kind == DEVICE_KIND_ZAPPI: + path = "/cgi-zappi-mode-Z{}-0-2-0-0000".format(device.serial) + elif device.kind == DEVICE_KIND_EDDI: + target = EDDI_BOOST_TARGETS[EDDI_DEFAULT_BOOST_TARGET] + path = "/cgi-eddi-boost-E{}-1-{}-0".format(device.serial, target) + else: + raise MyEnergiApiError("cannot cancel a boost on unsupported device kind '{}'".format(device.kind)) + self._check_command_status(await self._request(path), path) + return True + + +# The cloud device list changes rarely, so it is cached between polls. +CLOUD_DEVICE_LIST_MAX_AGE = 30 * 60 + +# Model names in GET /devices that map onto the kinds this release supports. +CLOUD_MODEL_TO_KIND = {"zappi": DEVICE_KIND_ZAPPI, "eddi": DEVICE_KIND_EDDI} + + +class MyEnergiCloudTransport(MyEnergiTransport): + """Transport for the official myenergi 3rd party API. + + Authenticates with a bearer JWT obtained through the OAuth2 authorization code + flow. The token is read through a callable on every request so that a refresh + performed by OAuthMixin on the component takes effect immediately. + """ + + def __init__(self, log, access_token_getter): + """Store the token accessor and initialise the device list cache.""" + super().__init__(log) + self.access_token_getter = access_token_getter + self.device_meta = {} + # Wall-clock stamp of the last GET /devices. A counter was tried first and never + # advanced, so the cache never expired and a device added, removed or renamed in + # the myenergi app stayed invisible until Predbat restarted. + self.meta_fetched_at = 0.0 + + def _headers(self): + """Build the request headers, including the current bearer token.""" + return { + "Authorization": "Bearer {}".format(self.access_token_getter() or ""), + "Accept": "application/json", + "User-Agent": USER_AGENT, + } + + async def _request(self, method, path, body=None): + """Perform one cloud API request and return the decoded JSON body.""" + url = MYENERGI_CLOUD_URL + path + try: + async with aiohttp.ClientSession(headers=self._headers()) as session: + async with session.request(method, url, json=body, timeout=aiohttp.ClientTimeout(total=API_TIMEOUT)) as response: + if response.status == 401: + record_api_call("myenergi", success=False, reason="auth_error") + raise MyEnergiAuthError("myenergi rejected the access token for {}".format(path)) + if response.status not in (200, 201, 202, 204): + reason = "server_error" if response.status >= 500 else "client_error" + record_api_call("myenergi", success=False, reason=reason) + raise MyEnergiApiError("HTTP {} from {} {}".format(response.status, method, path)) + if response.status == 204: + record_api_call("myenergi", success=True) + return {} + try: + payload = await response.json(content_type=None) + except (ValueError, TypeError) as exc: + record_api_call("myenergi", success=False, reason="decode_error") + raise MyEnergiApiError("could not decode the response from {} {}".format(method, path)) from exc + record_api_call("myenergi", success=True) + return payload + except asyncio.TimeoutError as exc: + record_api_call("myenergi", success=False, reason="connection_error") + raise MyEnergiApiError("timed out calling {} {}".format(method, path)) from exc + except aiohttp.ClientError as exc: + record_api_call("myenergi", success=False, reason="connection_error") + raise MyEnergiApiError("request to {} {} failed: {}".format(method, path, exc)) from exc + + async def _refresh_device_list(self): + """Reload GET /devices, keeping only the Zappi and Eddi entries.""" + payload = await self._request("GET", "/devices") + if not isinstance(payload, dict): + raise MyEnergiApiError("unexpected device list response shape from GET /devices") + meta = {} + for site in payload.get("sites", []) or []: + for entry in site.get("devices", []) or []: + kind = CLOUD_MODEL_TO_KIND.get(str(entry.get("model", "")).lower()) + device_id = entry.get("deviceId") + if kind and device_id: + meta[device_id] = entry + self.device_meta = meta + self.meta_fetched_at = time.time() + + async def connect(self): + """Load the device list, which also validates the access token.""" + await self._refresh_device_list() + return True + + async def fetch_devices(self): + """Poll status for every cached Zappi and Eddi, refreshing the list when stale. + + One device failing is tolerated so the rest stay visible, but a poll that reads + none of the devices it knows about is a failed poll rather than an empty site, + and raises. Returning [] there would let run() keep the previous readings and + still stamp success, so a site whose every device was erroring would report + healthy for as long as it kept failing. + """ + if not self.device_meta or (time.time() - self.meta_fetched_at) >= CLOUD_DEVICE_LIST_MAX_AGE: + await self._refresh_device_list() + devices = [] + skipped = 0 + for device_id, meta in self.device_meta.items(): + # One device failing its status call must not cost the whole poll: the + # remaining devices are still readable, and dropping them all would blank + # every published entity over a single device being briefly unreachable. + try: + status = await self._request("GET", "/devices/{}/status".format(device_id)) + except MyEnergiApiError as exc: + self.log("Warn: myenergi: skipping {} this poll: {}".format(device_id, exc)) + skipped += 1 + continue + if not status: + self.log("Warn: myenergi: no status returned for {}".format(device_id)) + skipped += 1 + continue + devices.append(normalise_cloud_device(status, meta)) + # An account with no Zappi or Eddi at all leaves both counts at zero and is not + # an error - only having devices and reading none of them is. + if skipped and not devices: + raise MyEnergiApiError("no myenergi device could be read this poll, {} skipped".format(skipped)) + return devices + + async def send_boost(self, device, amount, target_time=None): + """Start a boost, selecting the request body shape by device class. + + Sending a Zappi body to an Eddi (or the reverse) is a documented 400, so an + unrecognised kind is refused here rather than defaulted into the Eddi shape - + matching MyEnergiDirectTransport.send_boost. + """ + if device.kind == DEVICE_KIND_ZAPPI: + body = {"mode": "normal", "parameters": {"energy": _boost_units(amount)}} + if target_time: + body = {"mode": "smart", "parameters": {"energy": _boost_units(amount), "targetTime": target_time}} + elif device.kind == DEVICE_KIND_EDDI: + body = {"durationMinutes": _boost_units(amount)} + else: + raise MyEnergiApiError("cannot boost unsupported device kind '{}'".format(device.kind)) + await self._request("POST", "/devices/{}/boost".format(device.device_id), body=body) + return True + + async def cancel_boost(self, device): + """Cancel an active boost.""" + if device.kind not in SUPPORTED_KINDS: + raise MyEnergiApiError("cannot cancel a boost on unsupported device kind '{}'".format(device.kind)) + await self._request("DELETE", "/devices/{}/boost".format(device.device_id)) + return True + + +# Attribute table for the published Home Assistant entities, in the style of ohme.py +myenergi_attribute_table = { + "status": {"friendly_name": "myenergi Status", "icon": "mdi:information-outline"}, + "mode": {"friendly_name": "myenergi Mode", "icon": "mdi:ev-station"}, + "plug_status": {"friendly_name": "myenergi Plug Status", "icon": "mdi:ev-plug-type2"}, + "power": {"friendly_name": "myenergi Power", "icon": "mdi:lightning-bolt", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + "session_energy": {"friendly_name": "myenergi Session Energy", "icon": "mdi:lightning-bolt", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total_increasing"}, + "charging": {"friendly_name": "myenergi Charging", "icon": "mdi:battery-charging"}, + "boost": {"friendly_name": "myenergi Boost", "icon": "mdi:rocket-launch"}, + "boost_energy": {"friendly_name": "myenergi Boost Energy", "icon": "mdi:rocket-launch", "unit_of_measurement": "kWh", "min": BOOST_ENERGY_MIN, "max": BOOST_ENERGY_MAX, "step": 1}, + "boost_minutes": {"friendly_name": "myenergi Boost Minutes", "icon": "mdi:rocket-launch", "unit_of_measurement": "minutes", "min": BOOST_MINUTES_MIN, "max": BOOST_MINUTES_MAX, "step": 5}, + "temp_1": {"friendly_name": "myenergi Temperature 1", "icon": "mdi:thermometer", "unit_of_measurement": "°C", "device_class": "temperature", "state_class": "measurement"}, + "temp_2": {"friendly_name": "myenergi Temperature 2", "icon": "mdi:thermometer", "unit_of_measurement": "°C", "device_class": "temperature", "state_class": "measurement"}, +} + +DEFAULT_ZAPPI_BOOST_KWH = 10 +DEFAULT_EDDI_BOOST_MINUTES = 60 + +# components.py's is_alive() marks a component failed once its last successful update is +# more than 60 minutes old, and the success timestamp is only stamped by a poll that ran. +# Capping the interval at half that window keeps a slow poll from being reported as a +# component error, while still being far slower than anyone realistically wants. +MIN_POLL_SECONDS = 60 +MAX_POLL_SECONDS = 30 * 60 + + +class MyEnergiAPI(ComponentBase, OAuthMixin): + """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): + """Select a transport from the configured credentials and set up component state.""" + configured_auth_method = (auth_method or "direct").lower() + self.hub_serial = hub_serial + self.api_key = api_key + self.automatic = automatic + self.enable_controls = enable_controls + # ComponentBase.start() calls run() on a fixed 60 second cadence, so the poll + # interval can only be a whole number of those intervals. + self.poll_seconds = min(MAX_POLL_SECONDS, max(MIN_POLL_SECONDS, int(round(_to_float(poll_seconds, MIN_POLL_SECONDS) / 60.0)) * 60)) + + self.devices = {} + self.boost_amounts = {} + self.queued_events = [] + self._auto_configured = False + self.transport = None + + if configured_auth_method == "oauth": + self._init_oauth("oauth", key, token_expires_at, "myenergi") + # _init_oauth() sets self.auth_method to its own "oauth"/"api_key" vocabulary, + # overwriting whatever was assigned above - keep the user-facing "direct"/"oauth" + # value (used for logging and for tests asserting the selection) in its own + # attribute so it never depends on a name oauth_mixin.py owns. + self.auth_method_config = configured_auth_method + self.token_hash = token_hash or "" + if not key and not token_hash: + self.log("Error: myenergi: auth_method is 'oauth' but neither myenergi_key nor myenergi_token_hash is set") + return + self.transport = MyEnergiCloudTransport(self.log, lambda: self.access_token) + else: + self._init_oauth("api_key", None, None, "myenergi") + self.auth_method_config = configured_auth_method + if not hub_serial or not api_key: + self.log("Error: myenergi: auth_method is 'direct' but myenergi_hub_serial and myenergi_api_key are not both set") + return + if not digest_auth_available(): + self.log("Error: myenergi: " + AIOHTTP_DIGEST_REQUIRED.format(getattr(aiohttp, "__version__", "unknown"))) + return + self.transport = MyEnergiDirectTransport(self.log, hub_serial, api_key) + + def entity_prefix(self, device): + """Return the entity name prefix for a device, e.g. predbat_myenergi_zappi_12345678.""" + return "{}_myenergi_{}_{}".format(self.prefix, device.kind, device.serial) + + def automatic_config(self): + """Wire the device sensors into Predbat's load and car planning inputs. + + Zappi charging energy is subtracted from house load as car charging, so it + goes to car_charging_energy - as a list, because minute_data_import_export + accepts one and sums the entities. The matching plug status sensors go to + car_charging_planned, which is indexed per car, so entry N is the Nth Zappi; + without it Predbat falls back to the car_charging_threshold heuristic, because + the regex the apps.yaml templates ship targets the third-party ha-myenergi + integration's entity names rather than the ones this component publishes. Eddi + diverted energy feeds iboost_energy_today. + + These sensors are session-scoped and reset to zero when a session ends. That is + handled: get_from_incrementing() clamps negative deltas to zero for the + per-minute subtraction, and the daily totals go through minute_data_load()'s + clean_incrementing_reverse(), which rebases the series on a reset. The residual + limitation is narrower - minute_data() smooths a fall of less than 1 kWh as a dip + in the data (utils.py:565) before clean_incrementing_reverse() ever looks for a + reset (utils.py:740), so a session ending below roughly 1 kWh is under-counted. + An intervening zero reading does not rescue it, because the dip is smoothed away + first. That loss is in the shared cumulative series, so it affects + car_charging_energy and iboost_today alike. Documented in docs/components.md. + """ + zappi_energy_entities = [] + zappi_plug_entities = [] + eddi_entity = None + for device in sorted(self.devices.values(), key=lambda item: item.serial): + prefix = self.entity_prefix(device) + if device.kind == DEVICE_KIND_ZAPPI: + zappi_energy_entities.append("sensor.{}_session_energy".format(prefix)) + zappi_plug_entities.append("sensor.{}_plug_status".format(prefix)) + elif device.kind == DEVICE_KIND_EDDI and eddi_entity is None: + eddi_entity = "sensor.{}_session_energy".format(prefix) + + if zappi_energy_entities: + self.log("Info: myenergi: setting car_charging_energy to {}".format(zappi_energy_entities)) + self.set_arg_auto("car_charging_energy", zappi_energy_entities) + self.log("Info: myenergi: setting car_charging_planned to {}".format(zappi_plug_entities)) + self.set_arg_auto("car_charging_planned", zappi_plug_entities) + if eddi_entity: + self.log("Info: myenergi: setting iboost_energy_today to {}".format(eddi_entity)) + self.set_arg_auto("iboost_energy_today", eddi_entity) + + def boost_amount_for(self, device): + """Return the currently selected boost amount for a device.""" + default = DEFAULT_ZAPPI_BOOST_KWH if device.kind == DEVICE_KIND_ZAPPI else DEFAULT_EDDI_BOOST_MINUTES + return self.boost_amounts.get(device.device_id, default) + + async def run(self, seconds, first): + """Process queued control events, then poll and publish.""" + if first: + self.log("Info: myenergi: starting with the {} transport".format(self.auth_method_config)) + if not self.transport: + return False + + if self.auth_method == "oauth": + if not await self.check_and_refresh_oauth_token(): + return False + + refresh = False + while self.queued_events: + handler, *event_args = self.queued_events.pop(0) + try: + await handler(*event_args) + except MyEnergiError as exc: + self.log("Warn: myenergi: control failed: {}".format(exc)) + refresh = True + + if first or refresh or (seconds % self.poll_seconds) == 0: + try: + devices = await self.transport.fetch_devices() + except MyEnergiAuthError as exc: + # The proactive refresh above only covers a token that has reached its + # stated expiry. A token revoked before then wedges the component until + # restart unless the 401 itself triggers a refresh, so retry the poll + # once behind one, as fox.py, deye.py and solis.py do. + devices = await self._retry_poll_after_refresh(exc) + if devices is None: + return False + except MyEnergiError as exc: + self.log("Warn: myenergi: poll failed: {}".format(exc)) + return False + if devices: + self.devices = {device.device_id: device for device in devices} + await self.publish_data() + if self.automatic and not self._auto_configured: + self.automatic_config() + self._auto_configured = True + elif first: + self.log("Warn: myenergi: connected but no Zappi or Eddi devices were found") + # Stamped only by a cycle that actually polled, so the health check reflects + # real API contact. poll_seconds is capped at MAX_POLL_SECONDS for exactly + # this reason - see the comment there. + self.update_success_timestamp() + return True + + async def _retry_poll_after_refresh(self, exc): + """Refresh the OAuth token after a 401 and poll once more. Returns devices, or None on failure.""" + if self.auth_method != "oauth" or not await self.handle_oauth_401(): + self.log("Warn: myenergi: poll failed: {}".format(exc)) + return None + try: + return await self.transport.fetch_devices() + except MyEnergiError as retry_exc: + self.log("Warn: myenergi: poll failed again after refreshing the token: {}".format(retry_exc)) + return None + + def device_for_entity(self, entity_id): + """Find the device an entity belongs to, or None when it is not known. + + The trailing underscore anchors the match to a whole prefix, so a serial that + is a prefix of another device's serial cannot claim the other one's entities. + """ + for device in self.devices.values(): + if "{}_".format(self.entity_prefix(device)) in entity_id: + return device + return None + + async def switch_event(self, entity_id, service): + """Queue a switch service call for the run loop.""" + if not self.enable_controls: + return + self.queued_events.append((self.switch_event_handler, entity_id, service)) + + async def number_event(self, entity_id, value): + """Queue a number change for the run loop.""" + if not self.enable_controls: + return + self.queued_events.append((self.number_event_handler, entity_id, value)) + + async def number_event_handler(self, entity_id, value): + """Record a new boost amount for the device the entity belongs to. + + Guarded on the entity suffix, symmetrically with switch_event_handler: without + it any future number.{prefix}_* entity would be read as a boost amount and + clamped into boost_amounts purely because it belongs to a known device. + """ + if not entity_id.endswith(("_boost_energy", "_boost_minutes")): + return + device = self.device_for_entity(entity_id) + if not device: + return + if device.kind == DEVICE_KIND_ZAPPI: + amount = int(_to_float(value, DEFAULT_ZAPPI_BOOST_KWH)) + amount = max(BOOST_ENERGY_MIN, min(BOOST_ENERGY_MAX, amount)) + else: + amount = int(_to_float(value, DEFAULT_EDDI_BOOST_MINUTES)) + amount = max(BOOST_MINUTES_MIN, min(BOOST_MINUTES_MAX, amount)) + self.boost_amounts[device.device_id] = amount + + async def switch_event_handler(self, entity_id, service): + """Send or cancel a boost in response to the boost switch. + + Returns whether the command was actually issued and accepted, so a rejection + surfaces as the run loop's "control failed" warning instead of being logged as + a success that the next poll silently contradicts. + """ + if not self.enable_controls: + return False + if not entity_id.endswith("_boost"): + return False + device = self.device_for_entity(entity_id) + if not device: + self.log("Warn: myenergi: no known device for {}".format(entity_id)) + return False + + if service == "turn_on": + # myenergi rejects a boost unless the Zappi is in one of the green modes + if device.kind == DEVICE_KIND_ZAPPI and device.mode not in ZAPPI_BOOSTABLE_MODES: + self.log("Warn: myenergi: cannot boost {} while it is in {} mode - boost needs Eco or Eco+".format(device.name, device.mode)) + return False + amount = self.boost_amount_for(device) + self.log("Info: myenergi: boosting {} by {}".format(device.name, amount)) + return await self.transport.send_boost(device, amount) + if service == "turn_off": + self.log("Info: myenergi: cancelling boost on {}".format(device.name)) + return await self.transport.cancel_boost(device) + return False + + async def publish_data(self): + """Publish every known device as Predbat entities.""" + for device in self.devices.values(): + prefix = self.entity_prefix(device) + self.dashboard_item("sensor.{}_status".format(prefix), state=device.status, attributes=myenergi_attribute_table["status"], app="myenergi") + self.dashboard_item("sensor.{}_power".format(prefix), state=device.power_w, attributes=myenergi_attribute_table["power"], app="myenergi") + self.dashboard_item("sensor.{}_session_energy".format(prefix), state=device.session_energy_kwh, attributes=myenergi_attribute_table["session_energy"], app="myenergi") + self.dashboard_item("switch.{}_boost".format(prefix), state="on" if device.boost_active else "off", attributes=myenergi_attribute_table["boost"], app="myenergi") + + if device.kind == DEVICE_KIND_ZAPPI: + self.dashboard_item("sensor.{}_mode".format(prefix), state=device.mode, attributes=myenergi_attribute_table["mode"], app="myenergi") + self.dashboard_item("sensor.{}_plug_status".format(prefix), state=device.plug_status, attributes=myenergi_attribute_table["plug_status"], app="myenergi") + self.dashboard_item("binary_sensor.{}_charging".format(prefix), state="on" if device.status == STATUS_CHARGING else "off", attributes=myenergi_attribute_table["charging"], app="myenergi") + self.dashboard_item("number.{}_boost_energy".format(prefix), state=self.boost_amount_for(device), attributes=myenergi_attribute_table["boost_energy"], app="myenergi") + else: + self.dashboard_item("number.{}_boost_minutes".format(prefix), state=self.boost_amount_for(device), attributes=myenergi_attribute_table["boost_minutes"], app="myenergi") + if device.temp_1 is not None: + self.dashboard_item("sensor.{}_temp_1".format(prefix), state=device.temp_1, attributes=myenergi_attribute_table["temp_1"], app="myenergi") + if device.temp_2 is not None: + self.dashboard_item("sensor.{}_temp_2".format(prefix), state=device.temp_2, attributes=myenergi_attribute_table["temp_2"], app="myenergi") + + +async def run_myenergi_cli(args): # pragma: no cover + """Run one myenergi poll, and optionally a boost command, against the live API.""" + mock_base = MockBase() + arg_dict = { + "auth_method": "oauth" if (args.token or args.token_hash) else "direct", + "hub_serial": args.hub_serial, + "api_key": args.api_key, + "key": args.token, + "token_hash": args.token_hash, + "automatic": False, + "enable_controls": True, + } + component = MyEnergiAPI(mock_base, **arg_dict) + if not component.transport: + print("No usable credentials - pass --hub-serial and --api-key, or --token/--token-hash") + return + + print("Connecting with the {} transport...".format(component.auth_method_config)) + await component.transport.connect() + devices = await component.transport.fetch_devices() + if not devices: + print("No Zappi or Eddi devices found") + return + + if args.raw: + for device in devices: + print(device) + else: + print("{:<12} {:<10} {:<16} {:<10} {:>10} {:>12}".format("DEVICE", "KIND", "STATUS", "MODE", "POWER W", "SESSION kWh")) + for device in devices: + print("{:<12} {:<10} {:<16} {:<10} {:>10.0f} {:>12.2f}".format(device.device_id, device.kind, device.status, device.mode, device.power_w, device.session_energy_kwh)) + + target_kind = args.boost or args.cancel_boost + if target_kind: + device = next((item for item in devices if item.kind == target_kind), None) + if not device: + print("No {} device found to control".format(target_kind)) + return + if args.boost: + print("Boosting {} by {}...".format(device.name, args.amount)) + await component.transport.send_boost(device, args.amount) + else: + print("Cancelling boost on {}...".format(device.name)) + await component.transport.cancel_boost(device) + print("Done") + + +def main(): # pragma: no cover + """Main function for command line execution.""" + parser = argparse.ArgumentParser(description="Test the myenergi API") + parser.add_argument("--hub-serial", action="store", default=None, help="myenergi hub serial number (direct transport)") + parser.add_argument("--api-key", action="store", default=None, help="myenergi API key from myaccount.myenergi.com (direct transport)") + parser.add_argument("--token", action="store", default=None, help="myenergi OAuth access token (cloud transport)") + parser.add_argument("--token-hash", action="store", default=None, help="myenergi OAuth token hash for refresh (cloud transport)") + parser.add_argument("--boost", choices=SUPPORTED_KINDS, default=None, help="Send a boost to the first matching device") + parser.add_argument("--cancel-boost", choices=SUPPORTED_KINDS, default=None, help="Cancel a boost on the first matching device") + parser.add_argument("--amount", type=int, default=DEFAULT_ZAPPI_BOOST_KWH, help="Boost amount: kWh for a Zappi, minutes for an Eddi") + parser.add_argument("--raw", action="store_true", help="Print the full normalised device records") + + args = parser.parse_args() + asyncio.run(run_myenergi_cli(args)) + + +if __name__ == "__main__": + main() diff --git a/apps/predbat/tests/test_myenergi.py b/apps/predbat/tests/test_myenergi.py new file mode 100644 index 000000000..c5c617da6 --- /dev/null +++ b/apps/predbat/tests/test_myenergi.py @@ -0,0 +1,1888 @@ +# fmt: off +# pylint: disable=line-too-long +""" +Unit tests for the myenergi Zappi and Eddi integration +""" + +import asyncio +import json +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp + +# Add parent directory to path for imports +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from tests.test_infra import run_async +from mock_base import MockBase + +from myenergi import ( + BOOST_ENERGY_MAX, + BOOST_ENERGY_MIN, + BOOST_MINUTES_MAX, + BOOST_MINUTES_MIN, + CLOUD_DEVICE_LIST_MAX_AGE, + DEFAULT_EDDI_BOOST_MINUTES, + DEVICE_KIND_EDDI, + DEVICE_KIND_ZAPPI, + MAX_POLL_SECONDS, + ZAPPI_PLUG_STATES, + MyEnergiAPI, + MyEnergiApiError, + MyEnergiAuthError, + MyEnergiCloudTransport, + MyEnergiDevice, + MyEnergiDirectTransport, + MyEnergiTransport, + normalise_cloud_device, + normalise_direct_device, +) + +# ============================================================================ +# Mock data constants +# ============================================================================ + +# One entry from the "zappi" group of a direct /cgi-jstatus-* response +MOCK_DIRECT_ZAPPI = { + "sno": 12345678, + "sta": 3, + "zmo": 2, + "pst": "C2", + "div": 7360, + "che": 4.25, + "grd": 120, + "gen": 3400, + "vol": 2405, + "frq": 50.02, +} + +# One entry from the "eddi" group of a direct /cgi-jstatus-* response +MOCK_DIRECT_EDDI = { + "sno": 87654321, + "sta": 3, + "div": 1500, + "che": 2.5, + "grd": -40, + "gen": 3400, + "vol": 2401, + "bsm": 0, + "rbt": 0, + "tp1": 54, + "tp2": 127, + "hno": 1, +} + +# GET /devices/{id}/status for the same Zappi, plus its GET /devices metadata +MOCK_CLOUD_ZAPPI_STATUS = { + "deviceClass": "ZAPPI", + "status": "active", + "state": "charging", + "deviceStatus": "charging", + "supplyMode": "eco", + "pilotState": "C2", + "boostCharge": False, + "actualPower": 7.36, + "gridPower": 0.12, + "genPower": 3.4, + "sessionEnergy": 4.25, + "energyDelivered": 0.12, +} + +MOCK_CLOUD_ZAPPI_META = { + "deviceId": "ZA12345678", + "model": "zappi", + "alias": "Driveway", + "serialNumber": 12345678, + "online": True, +} + +MOCK_CLOUD_EDDI_STATUS = { + "deviceClass": "EDDI", + "status": "active", + "state": "waiting_for_surplus", + "deviceStatus": "diverting", + "boostActive": False, + "actualPower": 1.5, + "gridPower": -0.04, + "genPower": 3.4, + "sessionEnergy": 2.5, +} + +MOCK_CLOUD_EDDI_META = { + "deviceId": "ED87654321", + "model": "eddi", + "alias": "Hot water", + "serialNumber": 87654321, + "online": True, +} + + +def test_normalise_direct_zappi(): + """Direct Zappi payloads normalise into the shared device model.""" + device = normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI) + assert device.device_id == "Z12345678" + assert device.kind == DEVICE_KIND_ZAPPI + assert device.serial == "12345678" + assert device.status == "Charging" + assert device.mode == "Eco" + assert device.plug_status == "Charging" + assert device.power_w == 7360 + assert device.grid_power_w == 120 + assert device.generation_w == 3400 + assert device.voltage == 240.5 + assert device.session_energy_kwh == 4.25 + assert device.boost_active is False + assert device.temp_1 is None + print(" ✓ Direct Zappi normalisation") + + +def test_normalise_direct_eddi(): + """Direct Eddi payloads normalise, including probe temperature handling.""" + device = normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI) + assert device.device_id == "E87654321" + assert device.kind == DEVICE_KIND_EDDI + assert device.status == "Diverting" + assert device.power_w == 1500 + assert device.session_energy_kwh == 2.5 + assert device.boost_active is False + assert device.plug_status == "" + assert device.temp_1 == 54 + # 127 is myenergi's "probe not connected" sentinel and must not be published + assert device.temp_2 is None + print(" ✓ Direct Eddi normalisation") + + +def test_normalise_direct_eddi_boosting(): + """An Eddi mid-boost reports boost_active and remaining minutes.""" + raw = dict(MOCK_DIRECT_EDDI, sta=4, bsm=1, rbt=1800) + device = normalise_direct_device(raw, DEVICE_KIND_EDDI) + assert device.status == "Boosting" + assert device.boost_active is True + assert device.boost_remaining_mins == 30 + print(" ✓ Direct Eddi boost state") + + +def test_normalise_direct_zappi_boosting(): + """A Zappi mid-boost reports status Boosting and boost_active True.""" + raw = dict(MOCK_DIRECT_ZAPPI, sta=4) + device = normalise_direct_device(raw, DEVICE_KIND_ZAPPI) + assert device.status == "Boosting" + assert device.boost_active is True + print(" ✓ Direct Zappi boost state") + + +def test_normalise_cloud_matches_direct(): + """Cloud and direct payloads for the same device produce equal values.""" + direct_zappi = normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI) + cloud_zappi = normalise_cloud_device(MOCK_CLOUD_ZAPPI_STATUS, MOCK_CLOUD_ZAPPI_META) + assert cloud_zappi.kind == direct_zappi.kind + assert cloud_zappi.serial == direct_zappi.serial + assert cloud_zappi.status == direct_zappi.status + assert cloud_zappi.mode == direct_zappi.mode + assert cloud_zappi.plug_status == direct_zappi.plug_status + # Cloud reports kW, direct reports W - both land in W + assert cloud_zappi.power_w == direct_zappi.power_w + assert cloud_zappi.generation_w == direct_zappi.generation_w + assert cloud_zappi.session_energy_kwh == direct_zappi.session_energy_kwh + # The cloud device id keeps its two letter prefix and the friendly alias is used + assert cloud_zappi.device_id == "ZA12345678" + assert cloud_zappi.name == "Driveway" + + direct_eddi = normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI) + cloud_eddi = normalise_cloud_device(MOCK_CLOUD_EDDI_STATUS, MOCK_CLOUD_EDDI_META) + assert cloud_eddi.kind == direct_eddi.kind + assert cloud_eddi.status == direct_eddi.status + assert cloud_eddi.power_w == direct_eddi.power_w + assert cloud_eddi.session_energy_kwh == direct_eddi.session_energy_kwh + print(" ✓ Cloud and direct normalisation agree") + + +def test_normalise_handles_bad_values(): + """Out of range indices and missing fields fall back rather than raising.""" + device = normalise_direct_device({"sno": 1, "sta": 99, "zmo": "x"}, DEVICE_KIND_ZAPPI) + assert device.status == "Unknown" + assert device.mode == "Unknown" + assert device.power_w == 0 + assert device.session_energy_kwh == 0 + + device = normalise_direct_device({}, DEVICE_KIND_EDDI) + assert device.serial == "" + assert device.temp_1 is None + + device = normalise_cloud_device({"deviceClass": "EDDI"}, {}) + assert device.kind == DEVICE_KIND_EDDI + assert device.power_w == 0 + print(" ✓ Malformed payloads degrade safely") + + +class _StubTransport(MyEnergiTransport): + """Minimal concrete transport used to exercise the abstract base's stubs.""" + + async def connect(self): + """Pretend to connect.""" + return True + + async def fetch_devices(self): + """Return no devices.""" + return [] + + async def send_boost(self, device, amount, target_time=None): + """Pretend to send a boost.""" + return True + + async def cancel_boost(self, device): + """Pretend to cancel a boost.""" + return True + + +def test_transport_stubs(): + """Every unimplemented control returns False and warns exactly once.""" + messages = [] + transport = _StubTransport(messages.append) + + assert run_async(transport.set_mode(None, "Eco")) is False + assert run_async(transport.set_priority(None, 1)) is False + assert run_async(transport.set_min_green_level(None, 50)) is False + assert run_async(transport.set_phase_setting(None, "1")) is False + assert run_async(transport.get_schedule(None)) is False + assert run_async(transport.set_schedule(None, [])) is False + + assert len(messages) == 6, "Each stub should warn once, got {}".format(messages) + assert all("not implemented" in message for message in messages) + + # A second call must not warn again + assert run_async(transport.set_mode(None, "Eco")) is False + assert len(messages) == 6, "Repeat calls must not warn again" + print(" ✓ Stubbed controls warn once and return False") + + +def _direct_response(json_data=None, asn="s18.myenergi.net", status=200, json_error=None): + """Build a mock aiohttp response carrying an X_MYENERGI-asn header. + + Args: + json_data: The value `.json()` resolves to. Ignored when `json_error` is set. + asn: The X_MYENERGI-asn header value, or falsy to omit the header. + status: The HTTP status code to report. + json_error: When set, `.json()` raises this instead of returning `json_data`, + simulating an undecodable body such as a captive portal page. + """ + response = MagicMock() + response.status = status + response.headers = {"X_MYENERGI-asn": asn} if asn else {} + if json_error is not None: + response.json = AsyncMock(side_effect=json_error) + else: + response.json = AsyncMock(return_value=json_data) + response.__aenter__ = AsyncMock(return_value=response) + response.__aexit__ = AsyncMock(return_value=False) + return response + + +def _direct_session(responses): + """Build a mock aiohttp session whose get() returns the next queued response. + + Each queued item is either a mock response (from `_direct_response`) or an + exception instance, which is raised instead - used to simulate a timeout or + connection failure. Returns (session, calls), where calls records every + requested URL in order. + """ + calls = [] + queue = list(responses) + + def _get(url, **kwargs): + """Record the requested URL, then return or raise the next queued item.""" + calls.append(url) + item = queue.pop(0) if queue else _direct_response({}) + if isinstance(item, BaseException): + raise item + return item + + session = MagicMock() + session.get = _get + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock(return_value=False) + return session, calls + + +MOCK_JSTATUS_ALL = [ + {"eddi": [MOCK_DIRECT_EDDI]}, + {"zappi": [MOCK_DIRECT_ZAPPI]}, + {"harvi": [{"sno": 11112222}]}, + {"asn": "s18.myenergi.net"}, + {"fwv": "3560S5.036"}, +] + + +def test_direct_fetch_devices(): + """The direct transport resolves the ASN then parses the jstatus device groups.""" + session, calls = _direct_session([_direct_response([]), _direct_response(MOCK_JSTATUS_ALL)]) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + + with patch("aiohttp.ClientSession", return_value=session): + devices = run_async(transport.fetch_devices()) + + assert calls[0].startswith("https://director.myenergi.net/cgi-jstatus-E"), calls + assert calls[1] == "https://s18.myenergi.net/cgi-jstatus-*", calls + assert transport.base_url == "https://s18.myenergi.net" + # harvi is not a supported kind and must be skipped + assert len(devices) == 2, [device.kind for device in devices] + kinds = sorted(device.kind for device in devices) + assert kinds == [DEVICE_KIND_EDDI, DEVICE_KIND_ZAPPI] + print(" ✓ Direct transport resolves ASN and parses devices") + + +def test_direct_missing_asn_is_auth_error(): + """A response without the ASN header means bad credentials.""" + session, _calls = _direct_session([_direct_response([], asn=None)]) + transport = MyEnergiDirectTransport(print, "12345678", "wrong-key") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiAuthError") + except MyEnergiAuthError: + pass + print(" ✓ Missing ASN header raises MyEnergiAuthError") + + +def test_direct_boost_urls(): + """Boost and cancel produce the exact documented URLs for both device kinds.""" + zappi = normalise_direct_device(dict(MOCK_DIRECT_ZAPPI, zmo=2), DEVICE_KIND_ZAPPI) + eddi = normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + # Pre-resolve the active server so the requests under test are the only ones made + transport.base_url = "https://s18.myenergi.net" + transport.needs_asn_refresh = False + + session, calls = _direct_session([_direct_response({"status": 0}) for _ in range(4)]) + with patch("aiohttp.ClientSession", return_value=session): + run_async(transport.send_boost(zappi, 10)) + run_async(transport.cancel_boost(zappi)) + run_async(transport.send_boost(eddi, 60)) + run_async(transport.cancel_boost(eddi)) + + assert calls[0] == "https://s18.myenergi.net/cgi-zappi-mode-Z12345678-0-10-10-0000", calls[0] + assert calls[1] == "https://s18.myenergi.net/cgi-zappi-mode-Z12345678-0-2-0-0000", calls[1] + assert calls[2] == "https://s18.myenergi.net/cgi-eddi-boost-E87654321-10-1-60", calls[2] + assert calls[3] == "https://s18.myenergi.net/cgi-eddi-boost-E87654321-1-1-0", calls[3] + print(" ✓ Direct transport boost URLs") + + +def test_direct_smart_boost_url(): + """A Zappi boost with a target time uses the smart boost command.""" + zappi = normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + transport.base_url = "https://s18.myenergi.net" + transport.needs_asn_refresh = False + + session, calls = _direct_session([_direct_response({"status": 0})]) + with patch("aiohttp.ClientSession", return_value=session): + run_async(transport.send_boost(zappi, 15, target_time="07:30")) + + assert calls[0] == "https://s18.myenergi.net/cgi-zappi-mode-Z12345678-0-11-15-0730", calls[0] + print(" ✓ Direct transport smart boost URL") + + +def test_direct_401_is_auth_error(): + """A 401 from the active server raises MyEnergiAuthError.""" + session, _calls = _direct_session([_direct_response([]), _direct_response({}, status=401)]) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiAuthError") + except MyEnergiAuthError: + pass + print(" ✓ 401 from the active server raises MyEnergiAuthError") + + +def test_direct_missing_header_on_200_is_auth_error(): + """A 200 from the active server that carries no ASN header still fails the header check. + + The header check is what proves the digest handshake actually succeeded, so it must + survive being moved below the status checks - it now applies to exactly the case it + was meant for, a request that the server answered normally. + """ + session, _calls = _direct_session([_direct_response([]), _direct_response(MOCK_JSTATUS_ALL, asn=None)]) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiAuthError") + except MyEnergiAuthError as exc: + assert "X_MYENERGI-asn" in str(exc), exc + print(" ✓ A 200 without the ASN header raises MyEnergiAuthError") + + +def test_direct_401_without_header_is_a_credential_error(): + """A 401 that also lacks the ASN header is reported by the status check, naming the credentials. + + Both checks would raise MyEnergiAuthError here, so the message is what distinguishes + them: the status check must win, because an error response has no reason to carry the + header and diagnosing it as a missing header hides the actual 401. + """ + session, _calls = _direct_session([_direct_response([]), _direct_response({}, asn=None, status=401)]) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiAuthError") + except MyEnergiAuthError as exc: + assert "rejected the credentials" in str(exc), exc + assert "X_MYENERGI-asn" not in str(exc), exc + print(" ✓ A 401 without the ASN header is reported as a credential failure") + + +def test_direct_503_without_header_is_api_error(): + """A provider outage on the active server is an API error, never a credential error. + + myenergi's own status page going down used to surface as "check the hub serial and + API key", sending a self-hosted user off to regenerate a perfectly good key. A 503 + carries no ASN header, so this only passes while the status checks run first. + """ + session, _calls = _direct_session([_direct_response([]), _direct_response({}, asn=None, status=503)]) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiAuthError as exc: + raise AssertionError("A 503 must not be reported as an auth error: {}".format(exc)) + except MyEnergiApiError as exc: + assert "503" in str(exc), exc + print(" ✓ A 503 without the ASN header raises MyEnergiApiError, not an auth error") + + +def test_direct_non_200_sets_needs_asn_refresh(): + """A non-401 non-200 response from the active server raises MyEnergiApiError and forces ASN re-resolution.""" + session, _calls = _direct_session([_direct_response([]), _direct_response({}, status=500)]) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError: + pass + assert transport.needs_asn_refresh is True + print(" ✓ Non-200 from the active server raises MyEnergiApiError and sets needs_asn_refresh") + + +def test_direct_timeout_sets_needs_asn_refresh(): + """A timeout on the active-server request raises MyEnergiApiError and forces ASN re-resolution.""" + session, _calls = _direct_session([_direct_response([]), asyncio.TimeoutError()]) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError: + pass + assert transport.needs_asn_refresh is True + print(" ✓ Timeout on the active-server request raises MyEnergiApiError and sets needs_asn_refresh") + + +def test_direct_asn_migration_follows_new_host(): + """A response naming a different active server updates base_url and routes the next call there.""" + session, calls = _direct_session( + [ + _direct_response([]), # director resolve -> s18 + _direct_response(MOCK_JSTATUS_ALL), # first jstatus-* call, still on s18 + _direct_response(MOCK_JSTATUS_ALL, asn="s21.myenergi.net"), # second call, server has migrated + # The third call has to keep naming s21 explicitly: the default would migrate + # base_url back to s18 behind the assertion below, which is the opposite of + # what this test claims to be checking. + _direct_response(MOCK_JSTATUS_ALL, asn="s21.myenergi.net"), # third call, now targets s21 + ] + ) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + + with patch("aiohttp.ClientSession", return_value=session): + run_async(transport.fetch_devices()) + assert transport.base_url == "https://s18.myenergi.net" + run_async(transport.fetch_devices()) + assert transport.base_url == "https://s21.myenergi.net" + run_async(transport.fetch_devices()) + + assert calls[1] == "https://s18.myenergi.net/cgi-jstatus-*", calls + assert calls[2] == "https://s18.myenergi.net/cgi-jstatus-*", calls + assert calls[3] == "https://s21.myenergi.net/cgi-jstatus-*", calls + print(" ✓ Active server migration is followed on the next request") + + +def test_direct_resolve_asn_non_200_is_api_error(): + """A non-200 response from the director during ASN resolution is a service outage, not bad credentials.""" + session, _calls = _direct_session([_direct_response({}, asn=None, status=503)]) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.connect()) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError: + pass + print(" ✓ Non-200 while resolving the ASN raises MyEnergiApiError, not an auth error") + + +def test_direct_resolve_asn_timeout_is_api_error(): + """A timeout resolving the ASN raises MyEnergiApiError.""" + session, _calls = _direct_session([asyncio.TimeoutError()]) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.connect()) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError: + pass + print(" ✓ Timeout while resolving the ASN raises MyEnergiApiError") + + +MOCK_CLOUD_DEVICES = { + "sites": [ + { + "siteId": "site-1", + "name": "Home", + "gridLimit": 15, + "devices": [ + MOCK_CLOUD_ZAPPI_META, + MOCK_CLOUD_EDDI_META, + {"deviceId": "HA11112222", "model": "harvi", "alias": "CT", "serialNumber": 11112222, "online": True}, + ], + } + ] +} + + +def _cloud_response(json_data=None, status=200, json_error=None): + """Build a mock aiohttp response for the cloud API. + + Args: + json_data: The value `.json()` resolves to. Ignored when `json_error` is set. + status: The HTTP status code to report. + json_error: When set, `.json()` raises this instead of returning `json_data`, + simulating an undecodable body such as an HTML error page. + """ + response = MagicMock() + response.status = status + if json_error is not None: + response.json = AsyncMock(side_effect=json_error) + else: + response.json = AsyncMock(return_value=json_data) + response.__aenter__ = AsyncMock(return_value=response) + response.__aexit__ = AsyncMock(return_value=False) + return response + + +def _cloud_session(responses): + """Patch aiohttp.ClientSession recording (method, url, json) for each request. + + Each queued item is either a mock response (from `_cloud_response`) or an + exception instance, which is raised instead - used to simulate a timeout or + connection failure, mirroring `_direct_session` above. + """ + calls = [] + queue = list(responses) + + def _request(method, url, **kwargs): + """Record the request, then return or raise the next queued item.""" + calls.append((method, url, kwargs.get("json"))) + item = queue.pop(0) if queue else _cloud_response({}) + if isinstance(item, BaseException): + raise item + return item + + session = MagicMock() + session.request = _request + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock(return_value=False) + return session, calls + + +def test_cloud_fetch_devices(): + """The cloud transport lists devices then polls status for supported ones only.""" + session, calls = _cloud_session( + [ + _cloud_response(MOCK_CLOUD_DEVICES), + _cloud_response(MOCK_CLOUD_ZAPPI_STATUS), + _cloud_response(MOCK_CLOUD_EDDI_STATUS), + ] + ) + transport = MyEnergiCloudTransport(print, lambda: "jwt-token") + + with patch("aiohttp.ClientSession", return_value=session): + devices = run_async(transport.fetch_devices()) + + assert calls[0] == ("GET", "https://api.s18.myenergi.net/devices", None), calls[0] + assert calls[1][1] == "https://api.s18.myenergi.net/devices/ZA12345678/status", calls[1] + assert calls[2][1] == "https://api.s18.myenergi.net/devices/ED87654321/status", calls[2] + # harvi is unsupported and must never be polled + assert len(calls) == 3, calls + assert len(devices) == 2 + assert devices[0].name == "Driveway" + print(" ✓ Cloud transport lists and polls supported devices") + + +def test_cloud_boost_bodies(): + """Boost bodies are shaped per device class, never mixing the two forms.""" + zappi = normalise_cloud_device(MOCK_CLOUD_ZAPPI_STATUS, MOCK_CLOUD_ZAPPI_META) + eddi = normalise_cloud_device(MOCK_CLOUD_EDDI_STATUS, MOCK_CLOUD_EDDI_META) + transport = MyEnergiCloudTransport(print, lambda: "jwt-token") + + session, calls = _cloud_session([_cloud_response({"commandId": "c1"}) for _ in range(4)]) + with patch("aiohttp.ClientSession", return_value=session): + run_async(transport.send_boost(zappi, 10)) + run_async(transport.send_boost(eddi, 60)) + run_async(transport.cancel_boost(zappi)) + run_async(transport.cancel_boost(eddi)) + + assert calls[0] == ("POST", "https://api.s18.myenergi.net/devices/ZA12345678/boost", {"mode": "normal", "parameters": {"energy": 10}}), calls[0] + assert calls[1] == ("POST", "https://api.s18.myenergi.net/devices/ED87654321/boost", {"durationMinutes": 60}), calls[1] + assert calls[2][0] == "DELETE" and calls[2][1].endswith("/devices/ZA12345678/boost"), calls[2] + assert calls[3][0] == "DELETE" and calls[3][1].endswith("/devices/ED87654321/boost"), calls[3] + + # A Zappi body must never carry durationMinutes, an Eddi body never mode/parameters + assert "durationMinutes" not in calls[0][2] + assert "mode" not in calls[1][2] and "parameters" not in calls[1][2] + print(" ✓ Cloud transport boost bodies") + + +def test_cloud_sets_bearer_header(): + """Requests carry the current bearer token from the supplied callable, re-read on every call. + + A single request would pass identically for an implementation that captured the + token once in __init__, which is exactly the bug the callable design exists to + avoid (OAuthMixin refreshing the token on the component must take effect on the + very next request). Changing the token mid-test and issuing a second request + proves the re-read, not just that a bearer header is sent at all. + """ + tokens = ["first-token"] + session, _calls = _cloud_session([_cloud_response(MOCK_CLOUD_DEVICES)]) + transport = MyEnergiCloudTransport(print, lambda: tokens[0]) + + captured = {} + + def _client_session(**kwargs): + """Capture the headers passed to aiohttp.ClientSession and return the mock session.""" + captured.update(kwargs.get("headers") or {}) + return session + + with patch("aiohttp.ClientSession", side_effect=_client_session): + run_async(transport._request("GET", "/devices")) + assert captured.get("Authorization") == "Bearer first-token", captured + + tokens[0] = "second-token" + run_async(transport._request("GET", "/devices")) + assert captured.get("Authorization") == "Bearer second-token", captured + + print(" ✓ Cloud transport re-reads the bearer token on every request") + + +def test_cloud_unauthorised_raises_auth_error(): + """An HTTP 401 from the cloud API surfaces as MyEnergiAuthError.""" + session, _calls = _cloud_session([_cloud_response({"message": "nope", "code": "UNAUTHORISED"}, status=401)]) + transport = MyEnergiCloudTransport(print, lambda: "stale-token") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiAuthError") + except MyEnergiAuthError: + pass + print(" ✓ Cloud transport 401 raises MyEnergiAuthError") + + +def test_cloud_non_200_is_api_error(): + """A non-401, non-2xx response from the cloud API raises MyEnergiApiError.""" + session, _calls = _cloud_session([_cloud_response({"message": "boom"}, status=500)]) + transport = MyEnergiCloudTransport(print, lambda: "jwt-token") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError: + pass + print(" ✓ Cloud transport non-200 raises MyEnergiApiError") + + +def test_cloud_timeout_is_api_error(): + """A timeout calling the cloud API raises MyEnergiApiError.""" + session, _calls = _cloud_session([asyncio.TimeoutError()]) + transport = MyEnergiCloudTransport(print, lambda: "jwt-token") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError: + pass + print(" ✓ Cloud transport timeout raises MyEnergiApiError") + + +def test_cloud_non_json_response_is_api_error(): + """A 200 whose body cannot be decoded as JSON raises MyEnergiApiError, not a raw ValueError. + + aiohttp's json() is called with content_type=None, which disables the + content-type guard - so a 200 carrying an HTML error page (CDN, proxy or + maintenance interstitial) reaches the JSON decoder and must not escape as a + bare json.JSONDecodeError (a ValueError subclass). + """ + session, _calls = _cloud_session([_cloud_response(json_error=json.JSONDecodeError("Expecting value", "", 0))]) + transport = MyEnergiCloudTransport(print, lambda: "jwt-token") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError: + pass + print(" ✓ Cloud transport non-JSON body raises MyEnergiApiError") + + +def test_cloud_non_dict_payload_is_api_error(): + """A 200 whose decoded body is not a dict raises MyEnergiApiError, not a raw AttributeError. + + GET /devices is documented to return {"sites": [...]}; a body that decodes to a + list or string instead must not reach payload.get() and crash with AttributeError. + """ + session, _calls = _cloud_session([_cloud_response(["not", "a", "dict"])]) + transport = MyEnergiCloudTransport(print, lambda: "jwt-token") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError: + pass + print(" ✓ Cloud transport non-dict device list raises MyEnergiApiError") + + +def test_cloud_record_api_call_reasons(): + """record_api_call receives the documented reason vocabulary for every cloud failure branch.""" + scenarios = [ + (_cloud_response({}, status=401), "auth_error"), + (_cloud_response({}, status=500), "server_error"), + (_cloud_response({}, status=403), "client_error"), + (asyncio.TimeoutError(), "connection_error"), + (aiohttp.ClientConnectionError(), "connection_error"), + (_cloud_response(json_error=ValueError("bad json")), "decode_error"), + ] + for queued, expected_reason in scenarios: + session, _calls = _cloud_session([queued]) + transport = MyEnergiCloudTransport(print, lambda: "jwt-token") + with patch("aiohttp.ClientSession", return_value=session), patch("myenergi.record_api_call") as mock_record: + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected a MyEnergiError for reason={}".format(expected_reason)) + except (MyEnergiAuthError, MyEnergiApiError): + pass + reasons = [call.kwargs.get("reason") for call in mock_record.call_args_list if call.kwargs.get("reason")] + assert reasons == [expected_reason], (expected_reason, reasons) + print(" ✓ Cloud transport records the documented reason for every failure branch") + + +def test_direct_client_error_reason_is_connection_error(): + """A generic aiohttp.ClientError from the active-server request records reason=connection_error. + + _resolve_asn and the cloud transport both use connection_error for this case; + _request must be consistent with them rather than labelling it client_error, + which is reserved for a non-401 4xx HTTP response, not a transport failure. + """ + session, _calls = _direct_session([_direct_response([]), aiohttp.ClientConnectionError()]) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + + with patch("aiohttp.ClientSession", return_value=session), patch("myenergi.record_api_call") as mock_record: + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError: + pass + + reasons = [call.kwargs.get("reason") for call in mock_record.call_args_list if call.kwargs.get("reason")] + assert "connection_error" in reasons, reasons + assert "client_error" not in reasons, reasons + print(" ✓ Direct transport ClientError records reason=connection_error") + + +def test_direct_non_json_response_is_api_error(): + """A 200 body that cannot be decoded as JSON raises MyEnergiApiError with reason=decode_error. + + Mirrors test_cloud_non_json_response_is_api_error: MyEnergiDirectTransport._request + also calls response.json(content_type=None), which disables aiohttp's content-type + guard, so a captive portal or misconfigured proxy in front of the resolved ASN host + can return a 200 whose body is not valid JSON. It must not escape as a raw + json.JSONDecodeError (a ValueError subclass) - only MyEnergiError subclasses may + leave a transport, since Task 5's component catches only that base class. + """ + session, _calls = _direct_session([_direct_response([]), _direct_response(json_error=json.JSONDecodeError("Expecting value", "", 0))]) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + + with patch("aiohttp.ClientSession", return_value=session), patch("myenergi.record_api_call") as mock_record: + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError: + pass + + reasons = [call.kwargs.get("reason") for call in mock_record.call_args_list if call.kwargs.get("reason")] + assert reasons == ["decode_error"], reasons + print(" ✓ Direct transport non-JSON body raises MyEnergiApiError with reason=decode_error") + + +def _make_component(**overrides): + """Build a MyEnergiAPI against MockBase with a stub transport already attached.""" + base = MockBase() + args = { + "auth_method": "direct", + "hub_serial": "12345678", + "api_key": "secret-key", + "key": None, + "token_expires_at": None, + "token_hash": None, + "automatic": True, + "enable_controls": True, + "poll_seconds": 60, + } + args.update(overrides) + return MyEnergiAPI(base, **args) + + +def test_component_selects_transport(): + """auth_method picks the transport, and missing credentials refuse to start.""" + component = _make_component() + assert isinstance(component.transport, MyEnergiDirectTransport) + # _init_oauth() overwrites self.auth_method with its own "oauth"/"api_key" vocabulary, + # so the user-facing configured value must survive under its own attribute name. + assert component.auth_method_config == "direct" + + component = _make_component(auth_method="oauth", hub_serial=None, api_key=None, key="jwt-token") + assert isinstance(component.transport, MyEnergiCloudTransport) + assert component.auth_method_config == "oauth" + + # No credentials at all - no transport, and the reason is logged + component = _make_component(hub_serial=None, api_key=None) + assert component.transport is None + + # oauth selected but neither the access token nor a stored token hash is set + component = _make_component(auth_method="oauth", hub_serial=None, api_key=None, key=None, token_hash=None) + assert component.transport is None + print(" ✓ Transport selection and credential validation") + + +def test_component_publishes_entities(): + """A poll publishes the documented entity set for each device.""" + component = _make_component() + # A second, boosting Zappi so the boost switch's "on" case is actually exercised - + # MOCK_DIRECT_ZAPPI alone only ever proves the switch can report "off". + boosting_zappi = normalise_direct_device(dict(MOCK_DIRECT_ZAPPI, sno=99999999, sta=4), DEVICE_KIND_ZAPPI) + component.devices = { + "Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI), + "E87654321": normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI), + "Z99999999": boosting_zappi, + } + run_async(component.publish_data()) + + entities = component.base.entities + assert "sensor.predbat_myenergi_zappi_12345678_status" in entities + assert "sensor.predbat_myenergi_zappi_12345678_mode" in entities + assert "sensor.predbat_myenergi_zappi_12345678_plug_status" in entities + assert "sensor.predbat_myenergi_zappi_12345678_power" in entities + assert "sensor.predbat_myenergi_zappi_12345678_session_energy" in entities + assert "binary_sensor.predbat_myenergi_zappi_12345678_charging" in entities + assert "switch.predbat_myenergi_zappi_12345678_boost" in entities + assert "number.predbat_myenergi_zappi_12345678_boost_energy" in entities + + assert "sensor.predbat_myenergi_eddi_87654321_status" in entities + assert "sensor.predbat_myenergi_eddi_87654321_power" in entities + assert "sensor.predbat_myenergi_eddi_87654321_session_energy" in entities + assert "sensor.predbat_myenergi_eddi_87654321_temp_1" in entities + assert "switch.predbat_myenergi_eddi_87654321_boost" in entities + assert "number.predbat_myenergi_eddi_87654321_boost_minutes" in entities + + # tp2 was the 127 sentinel, so no entity should exist for it + assert "sensor.predbat_myenergi_eddi_87654321_temp_2" not in entities + + # The boost switch reflects the device, not a locally held value + assert component.base.get_state_wrapper("switch.predbat_myenergi_zappi_12345678_boost") == "off" + assert component.base.get_state_wrapper("switch.predbat_myenergi_zappi_99999999_boost") == "on" + + # The charging binary sensor reflects device.status, not just whether it exists + assert component.base.get_state_wrapper("binary_sensor.predbat_myenergi_zappi_12345678_charging") == "on" + # The boosting device is not "Charging" (it is "Boosting"), so its charging sensor is off + assert component.base.get_state_wrapper("binary_sensor.predbat_myenergi_zappi_99999999_charging") == "off" + + # Units come from the attribute table + power = component.base.entities["sensor.predbat_myenergi_zappi_12345678_power"] + assert power["attributes"]["unit_of_measurement"] == "W" + assert power["attributes"]["device_class"] == "power" + energy = component.base.entities["sensor.predbat_myenergi_zappi_12345678_session_energy"] + assert energy["attributes"]["unit_of_measurement"] == "kWh" + print(" ✓ Component publishes the expected entities") + + +def test_component_retains_last_good_reading(): + """A failed poll leaves the previously published values alone.""" + component = _make_component() + component.transport.fetch_devices = AsyncMock(return_value=[normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)]) + assert run_async(component.run(0, True)) is True + good = component.base.get_state_wrapper("sensor.predbat_myenergi_zappi_12345678_session_energy") + assert good == 4.25 + + component.transport.fetch_devices = AsyncMock(side_effect=MyEnergiApiError("boom")) + assert run_async(component.run(60, False)) is False + still_good = component.base.get_state_wrapper("sensor.predbat_myenergi_zappi_12345678_session_energy") + assert still_good == 4.25, "A failed poll must not overwrite the last good reading" + print(" ✓ Failed polls retain the last good reading") + + +def test_component_empty_device_list_does_not_wipe_devices(): + """A successful poll returning no devices warns on the first run and never clears devices already known.""" + component = _make_component() + messages = [] + component.log = messages.append + + component.transport.fetch_devices = AsyncMock(return_value=[]) + assert run_async(component.run(0, True)) is True + assert component.devices == {} + assert any("no Zappi or Eddi devices were found" in message for message in messages), messages + + component.devices = {"Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)} + component.transport.fetch_devices = AsyncMock(return_value=[]) + assert run_async(component.run(60, False)) is True + assert "Z12345678" in component.devices, "An empty poll result must not wipe previously known devices" + print(" ✓ An empty poll result warns once and never wipes previously known devices") + + +def test_component_poll_seconds_rounding(): + """poll_seconds is clamped to a whole number of base loop intervals, within the health window. + + The ceiling matters because the success timestamp is only stamped by a cycle that + actually polled: components.py marks a component failed once its last success is over + 60 minutes old, so an unbounded poll interval would report a perfectly healthy + component as broken. + """ + assert _make_component(poll_seconds=1).poll_seconds == 60 + assert _make_component(poll_seconds=90).poll_seconds == 120 + assert _make_component(poll_seconds=300).poll_seconds == 300 + assert _make_component(poll_seconds=7200).poll_seconds == MAX_POLL_SECONDS + assert MAX_POLL_SECONDS < 60 * 60, "The poll interval must stay inside components.py's 60 minute health window" + print(" ✓ poll_seconds rounds to a multiple of 60 and stays inside the health window") + + +def test_component_oauth_refresh_failure_stops_the_poll(): + """A hard OAuth refresh failure (e.g. needs_reauth) stops run() before it ever calls the API with a dead token.""" + component = _make_component(auth_method="oauth", hub_serial=None, api_key=None, key="jwt-token") + component.check_and_refresh_oauth_token = AsyncMock(return_value=False) + component.transport.fetch_devices = AsyncMock(return_value=[]) + + assert run_async(component.run(0, True)) is False + component.transport.fetch_devices.assert_not_awaited() + print(" ✓ A failed OAuth refresh stops the poll before fetch_devices is ever called") + + +def test_component_registration(): + """The component is registered with matching config keys and event filter.""" + from components import COMPONENT_LIST + from config import APPS_SCHEMA + + entry = COMPONENT_LIST["myenergi"] + assert entry["class"] is MyEnergiAPI + assert entry["event_filter"] == "predbat_myenergi_" + assert entry["phase"] == 1 + assert entry["can_restart"] is True + # token_hash must be in the gate: a refresh-only OAuth setup has no key, and + # initialize() accepts that, so gating on key alone would never construct the component + assert entry["required_or"] == ["api_key", "key", "token_hash"] + + # Every declared arg must name a config key that exists in the schema, and every + # arg must be accepted by initialize() + import inspect + + parameters = inspect.signature(MyEnergiAPI.initialize).parameters + for arg_name, spec in entry["args"].items(): + assert arg_name in parameters, "initialize() has no parameter '{}'".format(arg_name) + assert spec["config"] in APPS_SCHEMA, "{} missing from APPS_SCHEMA".format(spec["config"]) + + # The reverse direction: every initialize() parameter must also be declared in + # args, or a new parameter silently never receives a value from apps.yaml. + expected = {name for name in parameters if name != "self"} + assert set(entry["args"]) == expected, "COMPONENT_LIST args and initialize() parameters have diverged" + print(" ✓ Component registration and schema keys") + + +def test_automatic_config(): + """Zappis wire into car_charging_energy and the first-by-serial Eddi into iboost_energy_today. + + Devices are inserted in deliberately reversed/shuffled order and a second Eddi is + added, so this only passes if automatic_config() actually sorts by serial rather + than relying on dict insertion order - which happened to match serial order in an + earlier version of this test and let a missing sort go undetected. + """ + component = _make_component() + second_zappi = dict(MOCK_DIRECT_ZAPPI, sno=22223333) + second_eddi = dict(MOCK_DIRECT_EDDI, sno=11112222) + component.devices = { + "E87654321": normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI), + "Z22223333": normalise_direct_device(second_zappi, DEVICE_KIND_ZAPPI), + "E11112222": normalise_direct_device(second_eddi, DEVICE_KIND_EDDI), + "Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI), + } + component.automatic_config() + + assert component.base.args["car_charging_energy"] == [ + "sensor.predbat_myenergi_zappi_12345678_session_energy", + "sensor.predbat_myenergi_zappi_22223333_session_energy", + ], component.base.args["car_charging_energy"] + # car_charging_planned is indexed per car, so the list has to stay in the same + # serial order as car_charging_energy or car N would be paired with another Zappi + assert component.base.args["car_charging_planned"] == [ + "sensor.predbat_myenergi_zappi_12345678_plug_status", + "sensor.predbat_myenergi_zappi_22223333_plug_status", + ], component.base.args["car_charging_planned"] + # 11112222 sorts before 87654321, so it must be the one picked as "the first Eddi" + assert component.base.args["iboost_energy_today"] == "sensor.predbat_myenergi_eddi_11112222_session_energy" + print(" ✓ Automatic configuration wires both energy inputs, deterministically by serial") + + +def test_automatic_config_single_zappi_is_still_a_list(): + """A single Zappi still produces a list, so adding a second changes nothing else.""" + component = _make_component() + component.devices = {"Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)} + component.automatic_config() + assert component.base.args["car_charging_energy"] == ["sensor.predbat_myenergi_zappi_12345678_session_energy"] + assert component.base.args["car_charging_planned"] == ["sensor.predbat_myenergi_zappi_12345678_plug_status"] + assert "iboost_energy_today" not in component.base.args + print(" ✓ Single Zappi auto-config") + + +def test_automatic_config_eddi_only(): + """A site with only an Eddi wires iboost_energy_today and leaves car_charging_energy untouched.""" + component = _make_component() + component.devices = {"E87654321": normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI)} + component.automatic_config() + assert "car_charging_energy" not in component.base.args + assert "car_charging_planned" not in component.base.args + assert component.base.args["iboost_energy_today"] == "sensor.predbat_myenergi_eddi_87654321_session_energy" + print(" ✓ Eddi-only site wires iboost_energy_today and skips car_charging_energy") + + +def test_automatic_config_uses_set_arg_auto(): + """An explicit apps.yaml value is reported via apps_yaml_override_warned, proving set_arg_auto (not set_arg) is used. + + MockBase has neither args_from_apps_yaml nor apps_yaml_override_warned, so + set_arg_auto() silently degrades to plain set_arg() unless the test supplies + them - meaning swapping set_arg_auto() for set_arg() in automatic_config() + would leave every other auto-config test green. This test pins the call to + set_arg_auto specifically. + """ + component = _make_component() + component.base.args_from_apps_yaml = {"car_charging_energy": ["sensor.explicit"]} + component.base.apps_yaml_override_warned = set() + component.devices = {"Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)} + component.automatic_config() + assert "car_charging_energy" in component.base.apps_yaml_override_warned, component.base.apps_yaml_override_warned + print(" ✓ Automatic configuration uses set_arg_auto, not set_arg") + + +def test_automatic_config_disabled(): + """With automatic off, nothing is wired even after a successful poll that reached the publish block.""" + component = _make_component(automatic=False) + component.transport.fetch_devices = AsyncMock(return_value=[normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)]) + run_async(component.run(0, True)) + # Proves the poll actually reached the block that would have called automatic_config(), + # rather than an early return making the "nothing wired" assertion trivially true. + assert component.devices, "poll must have reached the publish block" + assert component._auto_configured is False + assert "car_charging_energy" not in component.base.args + print(" ✓ Automatic configuration respects the off switch") + + +def test_automatic_config_runs_once(): + """Auto-config runs after the first poll and is not repeated.""" + component = _make_component() + component.transport.fetch_devices = AsyncMock(return_value=[normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)]) + run_async(component.run(0, True)) + assert component._auto_configured is True + # Proves the first run actually wired the value, not just set the flag + assert component.base.args["car_charging_energy"] == ["sensor.predbat_myenergi_zappi_12345678_session_energy"] + component.base.args["car_charging_energy"] = ["sensor.user_override"] + run_async(component.run(60, False)) + assert component.base.args["car_charging_energy"] == ["sensor.user_override"], "Auto-config must not run twice" + print(" ✓ Automatic configuration runs exactly once") + + +def test_controls_queue_rather_than_call(): + """Switch and number events queue for the run loop instead of calling inline.""" + component = _make_component() + component.devices = {"Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)} + component.transport.send_boost = AsyncMock(return_value=True) + + run_async(component.switch_event("switch.predbat_myenergi_zappi_12345678_boost", "turn_on")) + assert len(component.queued_events) == 1 + component.transport.send_boost.assert_not_called() + + component.transport.fetch_devices = AsyncMock(return_value=list(component.devices.values())) + run_async(component.run(60, False)) + component.transport.send_boost.assert_called_once() + assert component.queued_events == [] + print(" ✓ Control events queue for the run loop") + + +def test_boost_uses_number_entity_value(): + """The boost amount comes from the companion number entity.""" + component = _make_component() + device = normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI) + component.devices = {"Z12345678": device} + component.transport.send_boost = AsyncMock(return_value=True) + + # number_event only queues, so drain the queue the way run() would + run_async(component.number_event("number.predbat_myenergi_zappi_12345678_boost_energy", 25)) + handler, *event_args = component.queued_events.pop(0) + run_async(handler(*event_args)) + + run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_12345678_boost", "turn_on")) + + component.transport.send_boost.assert_called_once_with(device, 25) + print(" ✓ Boost uses the number entity value") + + +def test_boost_refused_in_fast_mode(): + """A Zappi outside Eco or Eco+ is not boosted, and no API call is made.""" + component = _make_component() + fast = normalise_direct_device(dict(MOCK_DIRECT_ZAPPI, zmo=1), DEVICE_KIND_ZAPPI) + assert fast.mode == "Fast" + component.devices = {"Z12345678": fast} + component.transport.send_boost = AsyncMock(return_value=True) + + run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_12345678_boost", "turn_on")) + component.transport.send_boost.assert_not_called() + print(" ✓ Boost refused outside Eco and Eco+") + + +def test_cancel_boost(): + """Turning the switch off cancels the boost.""" + component = _make_component() + device = normalise_direct_device(dict(MOCK_DIRECT_EDDI, bsm=1, sta=4), DEVICE_KIND_EDDI) + component.devices = {"E87654321": device} + component.transport.cancel_boost = AsyncMock(return_value=True) + + run_async(component.switch_event_handler("switch.predbat_myenergi_eddi_87654321_boost", "turn_off")) + component.transport.cancel_boost.assert_called_once_with(device) + print(" ✓ Cancel boost") + + +def test_controls_disabled(): + """With enable_controls off, events are ignored entirely.""" + component = _make_component(enable_controls=False) + component.devices = {"Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)} + component.transport.send_boost = AsyncMock(return_value=True) + + run_async(component.switch_event("switch.predbat_myenergi_zappi_12345678_boost", "turn_on")) + assert component.queued_events == [] + run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_12345678_boost", "turn_on")) + component.transport.send_boost.assert_not_called() + print(" ✓ Controls respect enable_controls") + + +def test_control_for_unknown_entity_is_ignored(): + """An event for a device that is not known does nothing and does not raise. + + A known device is loaded first so device_for_entity() actually runs its comparison: + with devices empty the loop body never executes and this passes for any lookup + implementation at all, including a broken one. + """ + component = _make_component() + component.devices = {"Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)} + component.transport.send_boost = AsyncMock(return_value=True) + assert run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_99999999_boost", "turn_on")) is False + component.transport.send_boost.assert_not_called() + print(" ✓ Unknown entity events are ignored") + + +def test_boost_eddi_skips_mode_check(): + """An Eddi boost is not subject to the Zappi-only Eco/Eco+ mode check and uses its own boost amount.""" + component = _make_component() + device = normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI) + component.devices = {"E87654321": device} + component.transport.send_boost = AsyncMock(return_value=True) + + run_async(component.switch_event_handler("switch.predbat_myenergi_eddi_87654321_boost", "turn_on")) + component.transport.send_boost.assert_called_once_with(device, DEFAULT_EDDI_BOOST_MINUTES) + print(" ✓ Eddi boost skips the Eco/Eco+ check and uses the default minutes") + + +def test_number_event_handler_clamps_amount(): + """The stored boost amount is clamped to the documented range for each device kind.""" + component = _make_component() + zappi = normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI) + eddi = normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI) + component.devices = {"Z12345678": zappi, "E87654321": eddi} + + run_async(component.number_event_handler("number.predbat_myenergi_zappi_12345678_boost_energy", 500)) + assert component.boost_amounts[zappi.device_id] == BOOST_ENERGY_MAX + + run_async(component.number_event_handler("number.predbat_myenergi_zappi_12345678_boost_energy", -5)) + assert component.boost_amounts[zappi.device_id] == BOOST_ENERGY_MIN + + run_async(component.number_event_handler("number.predbat_myenergi_eddi_87654321_boost_minutes", 999)) + assert component.boost_amounts[eddi.device_id] == BOOST_MINUTES_MAX + + run_async(component.number_event_handler("number.predbat_myenergi_eddi_87654321_boost_minutes", -10)) + assert component.boost_amounts[eddi.device_id] == BOOST_MINUTES_MIN + print(" ✓ number_event_handler clamps to the documented range for both device kinds") + + +def test_number_event_handler_unknown_entity_is_ignored(): + """A number event for an unknown device does nothing and does not raise. + + As with the switch case, a known device has to be present or the lookup loop never + runs and the assertion holds regardless of how device_for_entity() is written. + """ + component = _make_component() + component.devices = {"E87654321": normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI)} + run_async(component.number_event_handler("number.predbat_myenergi_zappi_99999999_boost_energy", 25)) + assert component.boost_amounts == {} + print(" ✓ Unknown entity number events are ignored") + + +def test_number_event_disabled(): + """With enable_controls off, number_event does not queue.""" + component = _make_component(enable_controls=False) + run_async(component.number_event("number.predbat_myenergi_zappi_12345678_boost_energy", 25)) + assert component.queued_events == [] + print(" ✓ number_event respects enable_controls") + + +def test_switch_event_handler_ignores_non_boost_and_unknown_service(): + """A non-boost switch entity, or an unrecognised service on the boost switch, makes no API call.""" + component = _make_component() + device = normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI) + component.devices = {"Z12345678": device} + component.transport.send_boost = AsyncMock(return_value=True) + component.transport.cancel_boost = AsyncMock(return_value=True) + + # Not a boost entity at all + run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_12345678_something_else", "turn_on")) + component.transport.send_boost.assert_not_called() + + # The boost switch, but a service that is neither turn_on nor turn_off + run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_12345678_boost", "toggle")) + component.transport.send_boost.assert_not_called() + component.transport.cancel_boost.assert_not_called() + print(" ✓ Non-boost entities and unrecognised services make no API call") + + +def test_direct_record_api_call_reasons(): + """record_api_call receives the documented reason vocabulary for every direct failure branch. + + The cloud transport has had this table since review; the direct transport - which is + the default, and therefore the one nearly every user runs - only had one-off tests for + two of its six branches, leaving auth_error, server_error and client_error unasserted. + The first queued response resolves the ASN successfully and records no reason, so the + reasons collected here belong solely to the active-server request under test. + """ + scenarios = [ + (_direct_response({}, status=401), "auth_error"), + (_direct_response({}, status=500), "server_error"), + (_direct_response({}, status=403), "client_error"), + (asyncio.TimeoutError(), "connection_error"), + (aiohttp.ClientConnectionError(), "connection_error"), + (_direct_response(json_error=ValueError("bad json")), "decode_error"), + ] + for queued, expected_reason in scenarios: + session, _calls = _direct_session([_direct_response([]), queued]) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + with patch("aiohttp.ClientSession", return_value=session), patch("myenergi.record_api_call") as mock_record: + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected a MyEnergiError for reason={}".format(expected_reason)) + except (MyEnergiAuthError, MyEnergiApiError): + pass + reasons = [call.kwargs.get("reason") for call in mock_record.call_args_list if call.kwargs.get("reason")] + assert reasons == [expected_reason], (expected_reason, reasons) + print(" ✓ Direct transport records the documented reason for every failure branch") + + +def test_direct_transport_requires_aiohttp_digest_support(): + """An aiohttp too old for digest auth is reported as an actionable message, not an AttributeError. + + requirements.txt floors aiohttp at 3.12 for DigestAuthMiddleware and + ClientSession(middlewares=...), but a hand-managed install can still be older, in + which case _new_session() used to die with a bare AttributeError escaping as a raw + traceback plus a startup stall, saying nothing about what to do. + """ + with patch("myenergi.digest_auth_available", return_value=False): + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + try: + run_async(transport.connect()) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError as exc: + assert "aiohttp 3.12" in str(exc), exc + + # The component refuses to build the transport at all, so the message is logged + # once at startup rather than once per poll + component = _make_component() + assert component.transport is None + print(" ✓ An aiohttp without digest support is reported with an actionable message") + + +def test_direct_boost_rejection_is_an_error(): + """A /cgi-* command answering 200 with a non-zero status is a refusal, not a success. + + The command endpoints always answer 200; the outcome is in the body. Without this the + component logged "boosting eddi-87654321 by 60" for a tank already at temperature, and + the switch quietly flipped back on the next poll with nothing to explain it. + """ + eddi = normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + transport.base_url = "https://s18.myenergi.net" + transport.needs_asn_refresh = False + + session, _calls = _direct_session([_direct_response({"status": -14})]) + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.send_boost(eddi, 60)) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError as exc: + assert "-14" in str(exc), exc + + session, _calls = _direct_session([_direct_response({"status": 1})]) + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.cancel_boost(eddi)) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError: + pass + print(" ✓ A non-zero command status raises rather than reporting success") + + +def test_direct_boost_without_a_status_body_is_success(): + """A command endpoint that answers with no status at all is treated as success. + + Not every /cgi-* endpoint returns a status field, so the check has to be conservative + or a working boost would be reported as a failure. + """ + zappi = normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + transport.base_url = "https://s18.myenergi.net" + transport.needs_asn_refresh = False + + for body in (None, {}, {"status": 0}, {"status": "0"}, ["unexpected"]): + session, _calls = _direct_session([_direct_response(body)]) + with patch("aiohttp.ClientSession", return_value=session): + assert run_async(transport.send_boost(zappi, 10)) is True, body + print(" ✓ A command response with no failure status is treated as success") + + +def test_direct_boost_amount_and_time_formatting(): + """Boost amounts round rather than truncate, and a target time is zero padded to HHMM.""" + zappi = normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI) + eddi = normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + transport.base_url = "https://s18.myenergi.net" + transport.needs_asn_refresh = False + + session, calls = _direct_session([_direct_response({"status": 0}) for _ in range(3)]) + with patch("aiohttp.ClientSession", return_value=session): + # 9.8 kWh must not be sent as 9 + run_async(transport.send_boost(zappi, 9.8)) + # "7:30" must not be sent as "730", which myenergi reads as a different time + run_async(transport.send_boost(zappi, 15, target_time="7:30")) + run_async(transport.send_boost(eddi, 44.6)) + + assert calls[0].endswith("/cgi-zappi-mode-Z12345678-0-10-10-0000"), calls[0] + assert calls[1].endswith("/cgi-zappi-mode-Z12345678-0-11-15-0730"), calls[1] + assert calls[2].endswith("/cgi-eddi-boost-E87654321-10-1-45"), calls[2] + print(" ✓ Boost amounts round and target times are zero padded") + + +def test_direct_boost_rejects_unsupported_kinds(): + """A device that is neither a Zappi nor an Eddi is refused rather than sent an Eddi command. + + The kind check used to be an else, so any future kind reaching send_boost would have + been issued a /cgi-eddi-boost against a device that is not an Eddi. + """ + harvi = _make_device(kind="harvi", serial="11112222") + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + transport.base_url = "https://s18.myenergi.net" + transport.needs_asn_refresh = False + + session, calls = _direct_session([_direct_response({"status": 0})]) + with patch("aiohttp.ClientSession", return_value=session): + for call in (transport.send_boost(harvi, 10), transport.cancel_boost(harvi)): + try: + run_async(call) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError as exc: + assert "harvi" in str(exc), exc + assert calls == [], "No request may be made for an unsupported device kind" + print(" ✓ Boost and cancel refuse unsupported device kinds") + + +def test_cloud_boost_rejects_unsupported_kinds(): + """The cloud transport refuses an unknown kind rather than defaulting it to the Eddi body. + + Sending an Eddi body (durationMinutes) to a non-Eddi is a documented 400, so the + unsupported case must be caught locally - matching the direct transport above. + """ + harvi = _make_device(kind="harvi", serial="11112222", device_id="HA11112222") + transport = MyEnergiCloudTransport(print, lambda: "jwt-token") + + session, calls = _cloud_session([_cloud_response({"commandId": "c1"})]) + with patch("aiohttp.ClientSession", return_value=session): + for call in (transport.send_boost(harvi, 10), transport.cancel_boost(harvi)): + try: + run_async(call) + raise AssertionError("Expected MyEnergiApiError") + except MyEnergiApiError as exc: + assert "harvi" in str(exc), exc + assert calls == [], "No request may be made for an unsupported device kind" + print(" ✓ Cloud boost and cancel refuse unsupported device kinds") + + +def test_cloud_device_list_cache_expires_on_the_clock(): + """The cached device list is refetched once it is older than CLOUD_DEVICE_LIST_MAX_AGE, and not before. + + The cache age was tracked with a counter that nothing ever incremented, so GET /devices + ran exactly once per process and a device added, removed or renamed in the myenergi app + stayed invisible until Predbat restarted. Time is patched rather than slept on. + """ + session, calls = _cloud_session( + [ + _cloud_response(MOCK_CLOUD_DEVICES), + _cloud_response(MOCK_CLOUD_ZAPPI_STATUS), + _cloud_response(MOCK_CLOUD_EDDI_STATUS), + _cloud_response(MOCK_CLOUD_ZAPPI_STATUS), + _cloud_response(MOCK_CLOUD_EDDI_STATUS), + _cloud_response(MOCK_CLOUD_DEVICES), + _cloud_response(MOCK_CLOUD_ZAPPI_STATUS), + _cloud_response(MOCK_CLOUD_EDDI_STATUS), + ] + ) + transport = MyEnergiCloudTransport(print, lambda: "jwt-token") + clock = [1000.0] + + with patch("aiohttp.ClientSession", return_value=session), patch("myenergi.time.time", side_effect=lambda: clock[0]): + run_async(transport.fetch_devices()) + clock[0] += CLOUD_DEVICE_LIST_MAX_AGE - 1 + run_async(transport.fetch_devices()) + list_calls = [call for call in calls if call[1].endswith("/devices")] + assert len(list_calls) == 1, "A fresh cache must not be refetched: {}".format(list_calls) + + clock[0] += 2 + run_async(transport.fetch_devices()) + + list_calls = [call for call in calls if call[1].endswith("/devices")] + assert len(list_calls) == 2, "A stale cache must be refetched: {}".format(list_calls) + print(" ✓ The cloud device list cache expires on the wall clock") + + +def test_cloud_one_bad_device_does_not_lose_the_others(): + """A status call failing for one device costs that device only, not the whole poll. + + Aborting the poll would blank every published entity over a single device being + briefly unreachable, and a device answering with an empty body would vanish silently. + """ + session, _calls = _cloud_session( + [ + _cloud_response(MOCK_CLOUD_DEVICES), + _cloud_response({"message": "boom"}, status=500), + _cloud_response(MOCK_CLOUD_EDDI_STATUS), + ] + ) + messages = [] + transport = MyEnergiCloudTransport(messages.append, lambda: "jwt-token") + + with patch("aiohttp.ClientSession", return_value=session): + devices = run_async(transport.fetch_devices()) + + assert [device.kind for device in devices] == [DEVICE_KIND_EDDI], devices + assert any("ZA12345678" in message for message in messages), messages + + # An empty status body is reported too, rather than dropping the device silently + session, _calls = _cloud_session([_cloud_response(MOCK_CLOUD_ZAPPI_STATUS), _cloud_response({})]) + messages = [] + transport.log = messages.append + with patch("aiohttp.ClientSession", return_value=session): + devices = run_async(transport.fetch_devices()) + assert len(devices) == 1, devices + assert any("no status returned" in message for message in messages), messages + print(" ✓ One failing device does not cost the whole cloud poll") + + +def test_cloud_every_device_failing_is_a_failed_poll(): + """Reading none of the known devices raises, rather than passing as an empty site. + + Returning [] would let run() keep the previous readings and still stamp success, so a + site whose every device was erroring would report healthy for as long as it kept + failing. An account with genuinely no Zappi or Eddi is still not an error. + """ + session, _calls = _cloud_session( + [ + _cloud_response(MOCK_CLOUD_DEVICES), + _cloud_response({"message": "boom"}, status=500), + _cloud_response({"message": "boom"}, status=500), + ] + ) + transport = MyEnergiCloudTransport(print, lambda: "jwt-token") + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiApiError when every device fails") + except MyEnergiApiError as exc: + assert "2 skipped" in str(exc), exc + + # A site with no supported devices at all reports no devices, not a failure + session, _calls = _cloud_session([_cloud_response({"sites": [{"siteId": "s1", "devices": []}]})]) + transport = MyEnergiCloudTransport(print, lambda: "jwt-token") + with patch("aiohttp.ClientSession", return_value=session): + assert run_async(transport.fetch_devices()) == [] + print(" ✓ A poll that reads no device at all is a failed poll, an empty site is not") + + +def test_failed_poll_does_not_stamp_success(): + """A failed poll leaves last_success_timestamp alone so repeated failures go unhealthy. + + components.py fails a component after 60 minutes without a success, which is the + mechanism that surfaces a persistently broken account to the user - stamping on a + cycle that read nothing would keep it looking healthy indefinitely. + """ + component = _make_component() + component.transport.fetch_devices = AsyncMock(return_value=[normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)]) + assert run_async(component.run(0, True)) is True + stamped = component.last_success_timestamp + assert stamped is not None + + component.transport.fetch_devices = AsyncMock(side_effect=MyEnergiApiError("no myenergi device could be read this poll, 2 skipped")) + assert run_async(component.run(60, False)) is False + assert component.last_success_timestamp == stamped, "A failed poll must not advance the success timestamp" + print(" ✓ A failed poll leaves the success timestamp alone") + + +def test_cloud_auth_error_still_aborts_the_poll(): + """A 401 on a device status still propagates, so the reactive token refresh can see it. + + The per-device tolerance above must catch MyEnergiApiError only: swallowing + MyEnergiAuthError would strand a revoked token, since run() would never be told. + """ + session, _calls = _cloud_session([_cloud_response(MOCK_CLOUD_DEVICES), _cloud_response({}, status=401)]) + transport = MyEnergiCloudTransport(print, lambda: "stale-token") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiAuthError") + except MyEnergiAuthError: + pass + print(" ✓ A 401 on a device status still aborts the cloud poll") + + +def _make_device(kind="zappi", serial="12345678", **overrides): + """Build a MyEnergiDevice directly, for kinds no transport would ever normalise.""" + fields = { + "device_id": "{}{}".format(kind[0].upper(), serial), + "kind": kind, + "serial": serial, + "name": "{}-{}".format(kind, serial), + "online": True, + "status": "Unknown", + "mode": "Normal", + "plug_status": "", + "power_w": 0.0, + "grid_power_w": 0.0, + "generation_w": 0.0, + "voltage": 0.0, + "session_energy_kwh": 0.0, + "boost_active": False, + "boost_remaining_mins": 0, + "temp_1": None, + "temp_2": None, + } + fields.update(overrides) + return MyEnergiDevice(**fields) + + +def test_automatic_config_ignores_unsupported_kinds(): + """A device that is neither a Zappi nor an Eddi is never wired into any Predbat input. + + The Eddi branch is an explicit kind test rather than a bare else, so a Harvi (or any + kind a future release adds) cannot end up published as the house's hot water sensor. + Reverting that guard leaves every other auto-config test green, so it is pinned here. + """ + component = _make_component() + component.devices = {"H11112222": _make_device(kind="harvi", serial="11112222")} + component.automatic_config() + assert "iboost_energy_today" not in component.base.args, component.base.args + assert "car_charging_energy" not in component.base.args, component.base.args + assert "car_charging_planned" not in component.base.args, component.base.args + print(" ✓ Unsupported device kinds are never auto-configured") + + +def test_device_for_entity_requires_a_whole_prefix_match(): + """The entity lookup anchors on a whole prefix, so one serial cannot claim another's entities. + + An unanchored substring match let a device with serial 1234 answer for every entity + belonging to serial 12345678, silently boosting the wrong charger. + """ + component = _make_component() + short = _make_device(serial="1234") + long_serial = _make_device(serial="12345678", device_id="Z12345678") + component.devices = {"Z1234": short, "Z12345678": long_serial} + + assert component.device_for_entity("switch.predbat_myenergi_zappi_12345678_boost") is long_serial + assert component.device_for_entity("switch.predbat_myenergi_zappi_1234_boost") is short + assert component.device_for_entity("switch.predbat_myenergi_zappi_9999_boost") is None + print(" ✓ Entity lookup anchors on the whole device prefix") + + +def test_number_event_handler_ignores_other_number_entities(): + """A number entity that is not a boost amount is left alone even though its device is known. + + number_event_handler branches on device.kind, so without a suffix guard any future + number.{prefix}_* entity would be clamped into boost_amounts as a boost amount. + """ + component = _make_component() + component.devices = {"Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)} + run_async(component.number_event_handler("number.predbat_myenergi_zappi_12345678_charge_limit", 25)) + assert component.boost_amounts == {}, component.boost_amounts + print(" ✓ Number events for non-boost entities are ignored") + + +def test_component_poll_seconds_gate_skips_cycles(): + """poll_seconds actually gates the poll, and a skipped cycle stamps no success timestamp. + + Deleting the modulo gate left the whole suite green, which made myenergi_poll_seconds + a setting with no observable effect. + """ + component = _make_component(poll_seconds=300) + devices = [normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)] + component.transport.fetch_devices = AsyncMock(return_value=devices) + + assert run_async(component.run(0, True)) is True + component.transport.fetch_devices.assert_awaited_once() + first_stamp = component.last_success_timestamp + assert first_stamp is not None + + component.transport.fetch_devices.reset_mock() + assert run_async(component.run(60, False)) is True + component.transport.fetch_devices.assert_not_awaited() + assert component.last_success_timestamp == first_stamp, "A skipped cycle must not stamp a poll it never made" + + assert run_async(component.run(300, False)) is True + component.transport.fetch_devices.assert_awaited_once() + assert component.last_success_timestamp != first_stamp + print(" ✓ poll_seconds gates the poll and only a real poll stamps success") + + +def test_component_reactive_oauth_refresh_retries_the_poll(): + """A 401 mid-poll triggers one reactive token refresh and a single retry. + + The proactive check only covers a token that has reached its stated expiry; a token + revoked before then wedged the component until Predbat restarted. + """ + component = _make_component(auth_method="oauth", hub_serial=None, api_key=None, key="jwt-token") + component.check_and_refresh_oauth_token = AsyncMock(return_value=True) + component.handle_oauth_401 = AsyncMock(return_value=True) + devices = [normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)] + component.transport.fetch_devices = AsyncMock(side_effect=[MyEnergiAuthError("401"), devices]) + + assert run_async(component.run(0, True)) is True + component.handle_oauth_401.assert_awaited_once() + assert component.transport.fetch_devices.await_count == 2 + assert "Z12345678" in component.devices + print(" ✓ A 401 mid-poll refreshes the token and retries once") + + +def test_component_reactive_oauth_refresh_gives_up_after_one_retry(): + """A refresh that fails, or a retry that fails again, ends the cycle instead of looping.""" + component = _make_component(auth_method="oauth", hub_serial=None, api_key=None, key="jwt-token") + component.check_and_refresh_oauth_token = AsyncMock(return_value=True) + component.handle_oauth_401 = AsyncMock(return_value=False) + component.transport.fetch_devices = AsyncMock(side_effect=MyEnergiAuthError("401")) + assert run_async(component.run(0, True)) is False + assert component.transport.fetch_devices.await_count == 1 + + component = _make_component(auth_method="oauth", hub_serial=None, api_key=None, key="jwt-token") + component.check_and_refresh_oauth_token = AsyncMock(return_value=True) + component.handle_oauth_401 = AsyncMock(return_value=True) + component.transport.fetch_devices = AsyncMock(side_effect=[MyEnergiAuthError("401"), MyEnergiAuthError("401 again")]) + assert run_async(component.run(0, True)) is False + assert component.transport.fetch_devices.await_count == 2 + print(" ✓ The reactive refresh retries exactly once and then gives up") + + +def test_component_direct_auth_error_never_refreshes(): + """A digest credential failure is not an OAuth problem and must not attempt a refresh.""" + component = _make_component() + component.handle_oauth_401 = AsyncMock(return_value=True) + component.transport.fetch_devices = AsyncMock(side_effect=MyEnergiAuthError("bad key")) + + assert run_async(component.run(0, True)) is False + component.handle_oauth_401.assert_not_awaited() + assert component.transport.fetch_devices.await_count == 1 + print(" ✓ A direct-transport auth failure never attempts an OAuth refresh") + + +def test_switch_event_handler_reports_the_transport_result(): + """The handler returns whether the boost was issued and accepted, rather than always claiming success.""" + component = _make_component() + zappi = normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI) + fast = normalise_direct_device(dict(MOCK_DIRECT_ZAPPI, sno=99999999, zmo=1), DEVICE_KIND_ZAPPI) + component.devices = {"Z12345678": zappi, "Z99999999": fast} + component.transport.send_boost = AsyncMock(return_value=True) + component.transport.cancel_boost = AsyncMock(return_value=True) + + assert run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_12345678_boost", "turn_on")) is True + assert run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_12345678_boost", "turn_off")) is True + # Refused before the call, because the Zappi is in Fast mode + assert run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_99999999_boost", "turn_on")) is False + assert run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_12345678_boost", "toggle")) is False + assert run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_12345678_something", "turn_on")) is False + print(" ✓ The boost switch handler reports what actually happened") + + +def test_queued_boost_rejection_is_logged_as_a_failure(): + """A boost myenergi refuses is logged as a control failure by the run loop, not as a success.""" + component = _make_component() + messages = [] + component.log = messages.append + device = normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI) + component.devices = {"E87654321": device} + component.transport.send_boost = AsyncMock(side_effect=MyEnergiApiError("myenergi refused /cgi-eddi-boost-E87654321-10-1-60 with status -14")) + component.transport.fetch_devices = AsyncMock(return_value=[device]) + + run_async(component.switch_event("switch.predbat_myenergi_eddi_87654321_boost", "turn_on")) + assert run_async(component.run(60, False)) is True + + assert any("control failed" in message and "status -14" in message for message in messages), messages + print(" ✓ A refused boost is logged as a control failure") + + +def test_templates_accept_the_connected_zappi_plug_states(): + """Every myenergi-aware apps.yaml template accepts the plug states that mean the car is connected. + + automatic_config() wires the Zappi plug status sensor into car_charging_planned, so a + published state missing from car_charging_planned_response reads as "not planned to + charge" - which is exactly how "EV ready to charge" (pilot states C1 and D1) silently + disabled charge planning for a plugged-in car waiting to start. + """ + repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + templates_dir = os.path.join(repo_root, "templates") + connected_states = sorted({value.lower() for key, value in ZAPPI_PLUG_STATES.items() if key != "A" and key != "F"}) + assert "ev ready to charge" in connected_states, connected_states + + checked = 0 + for name in sorted(os.listdir(templates_dir)): + if not name.endswith(".yaml"): + continue + with open(os.path.join(templates_dir, name), encoding="utf-8") as handle: + text = handle.read() + if "car_charging_planned_response" not in text or "'ev connected'" not in text: + continue + checked += 1 + for state in connected_states: + assert "'{}'".format(state) in text, "{} does not accept the '{}' plug state".format(name, state) + assert checked > 10, "Expected the templates to be found and checked, only saw {}".format(checked) + print(" ✓ {} templates accept every connected Zappi plug state".format(checked)) + + +def test_myenergi(my_predbat=None): + """ + ====================================================================== + MYENERGI TEST SUITE + ====================================================================== + Comprehensive test suite for the myenergi Zappi and Eddi integration including: + - Payload normalisation for both transports + """ + print("\n" + "=" * 70) + print("MYENERGI TEST SUITE") + print("=" * 70) + + test_normalise_direct_zappi() + test_normalise_direct_eddi() + test_normalise_direct_eddi_boosting() + test_normalise_direct_zappi_boosting() + test_normalise_cloud_matches_direct() + test_normalise_handles_bad_values() + test_transport_stubs() + test_direct_fetch_devices() + test_direct_missing_asn_is_auth_error() + test_direct_missing_header_on_200_is_auth_error() + test_direct_401_without_header_is_a_credential_error() + test_direct_503_without_header_is_api_error() + test_direct_boost_urls() + test_direct_smart_boost_url() + test_direct_401_is_auth_error() + test_direct_non_200_sets_needs_asn_refresh() + test_direct_timeout_sets_needs_asn_refresh() + test_direct_asn_migration_follows_new_host() + test_direct_resolve_asn_non_200_is_api_error() + test_direct_resolve_asn_timeout_is_api_error() + test_cloud_fetch_devices() + test_cloud_boost_bodies() + test_cloud_sets_bearer_header() + test_cloud_unauthorised_raises_auth_error() + test_cloud_non_200_is_api_error() + test_cloud_timeout_is_api_error() + test_cloud_non_json_response_is_api_error() + test_cloud_non_dict_payload_is_api_error() + test_cloud_record_api_call_reasons() + test_direct_client_error_reason_is_connection_error() + test_direct_non_json_response_is_api_error() + test_direct_record_api_call_reasons() + test_direct_transport_requires_aiohttp_digest_support() + test_direct_boost_rejection_is_an_error() + test_direct_boost_without_a_status_body_is_success() + test_direct_boost_amount_and_time_formatting() + test_direct_boost_rejects_unsupported_kinds() + test_cloud_boost_rejects_unsupported_kinds() + test_cloud_every_device_failing_is_a_failed_poll() + test_failed_poll_does_not_stamp_success() + test_cloud_device_list_cache_expires_on_the_clock() + test_cloud_one_bad_device_does_not_lose_the_others() + test_cloud_auth_error_still_aborts_the_poll() + test_component_selects_transport() + test_component_publishes_entities() + test_component_retains_last_good_reading() + test_component_empty_device_list_does_not_wipe_devices() + test_component_poll_seconds_rounding() + test_component_oauth_refresh_failure_stops_the_poll() + test_component_poll_seconds_gate_skips_cycles() + test_component_reactive_oauth_refresh_retries_the_poll() + test_component_reactive_oauth_refresh_gives_up_after_one_retry() + test_component_direct_auth_error_never_refreshes() + test_component_registration() + test_automatic_config() + test_automatic_config_single_zappi_is_still_a_list() + test_automatic_config_eddi_only() + test_automatic_config_uses_set_arg_auto() + test_automatic_config_disabled() + test_automatic_config_runs_once() + test_automatic_config_ignores_unsupported_kinds() + test_templates_accept_the_connected_zappi_plug_states() + test_controls_queue_rather_than_call() + test_boost_uses_number_entity_value() + test_boost_refused_in_fast_mode() + test_cancel_boost() + test_controls_disabled() + test_control_for_unknown_entity_is_ignored() + test_boost_eddi_skips_mode_check() + test_number_event_handler_clamps_amount() + test_number_event_handler_unknown_entity_is_ignored() + test_number_event_disabled() + test_switch_event_handler_ignores_non_boost_and_unknown_service() + test_switch_event_handler_reports_the_transport_result() + test_queued_boost_rejection_is_logged_as_a_failure() + test_device_for_entity_requires_a_whole_prefix_match() + test_number_event_handler_ignores_other_number_entities() + + print("=" * 70) + return False diff --git a/apps/predbat/tests/test_web_debug_history_routes.py b/apps/predbat/tests/test_web_debug_history_routes.py index 94b73d8f9..48f3e0504 100644 --- a/apps/predbat/tests/test_web_debug_history_routes.py +++ b/apps/predbat/tests/test_web_debug_history_routes.py @@ -33,6 +33,9 @@ def __init__(self, query=None): def _make_web(my_predbat, storage=None): """Build a minimal WebInterface bound to my_predbat, bypassing ComponentBase.__init__ (which would stand up the real aiohttp app) - same pattern as test_web_chart_currency.py. + + Note this stubs my_predbat.components, which is shared across the whole test run, so the + caller must restore it - see the finally block in test_web_debug_history_routes(). """ w = WebInterface.__new__(WebInterface) w.base = my_predbat @@ -52,6 +55,10 @@ def test_web_debug_history_routes(my_predbat): print("**** Testing debug-history web routes ****") tmpdir = tempfile.mkdtemp(prefix="predbat_test_debug_history_routes_") + # _make_web() stubs my_predbat.components, and my_predbat is shared with every later + # test in the run - leaving the stub in place breaks anything that calls a real method + # on it (e.g. is_running() -> components.is_all_alive()). + saved_components = getattr(my_predbat, "components", None) try: print("Test: no storage component available - list is empty, downloads 404 without raising") w_no_storage = _make_web(my_predbat, storage=None) @@ -140,6 +147,7 @@ def test_web_debug_history_routes(my_predbat): failed = True finally: + my_predbat.components = saved_components shutil.rmtree(tmpdir, ignore_errors=True) return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index be046a38a..f3ef9ceac 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -233,6 +233,7 @@ from tests.test_github import test_github from tests.test_download import test_download from tests.test_ohme import test_ohme +from tests.test_myenergi import test_myenergi from tests.test_component_base import test_component_base_all from tests.test_mock_base import test_mock_base_all from tests.test_solis import run_solis_tests @@ -582,6 +583,8 @@ def main(): ("github", test_github, "GitHub mixin tests (cache hit/miss/stale, HTTP errors, release parsing, auto-update)", False), # Ohme EV charger API unit tests ("ohme", test_ohme, "Ohme EV charger comprehensive tests (helper functions, client methods, API operations, event handlers)", False), + # myenergi Zappi and Eddi unit tests + ("myenergi", test_myenergi, "myenergi Zappi and Eddi comprehensive tests (normalisation, transports, publishing, auto-config, controls)", False), # ComponentBase lifecycle tests ("component_base", test_component_base_all, "ComponentBase tests (all)", False), # Shared MockBase tests diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index 8134e5fa4..29420c6ad 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -186,6 +186,7 @@ pred_bat: forecast_solar_api_key: !secret forecast_solar_api_key # Forecast.solar API key (if using Forecast.solar) ge_cloud_key: !secret ge_cloud_key # GivEnergy API key (if using GE Cloud) fox_key: !secret fox_key # Fox ESS API key and username (if using Fox Cloud) + myenergi_api_key: !secret myenergi_api_key # myenergi API key (if using the myenergi direct transport) deye_app_id: !secret deye_app_id # DeyeCloud developer app id (if using DEYE Cloud) deye_app_secret: !secret deye_app_secret # DeyeCloud developer app secret (if using DEYE Cloud) deye_username: !secret deye_username # DeyeCloud account e-mail/username (if using DEYE Cloud) @@ -1891,6 +1892,36 @@ whether you are within an Octopus Energy "smart charge" slot - **ohme_password** - Password for above Ohme account - **ohme_automatic_octopus_intelligent** - Controls whether Predbat talks directly to the above Ohme account +## myenergi Integration + +If you have a myenergi Zappi EV charger or Eddi hot water diverter, Predbat can monitor them and, with `myenergi_automatic` on (the default), +automatically set **car_charging_energy** and **car_charging_planned** from your Zappis and **iboost_energy_today** from your first Eddi, +so those three keys need no `apps.yaml` entries of your own. Everything else about your car setup — **car_charging_battery_size**, +**car_charging_limit**, **car_charging_soc** and **car_charging_planned_response** — still comes from `apps.yaml` as usual. + +The direct transport (the default) needs your hub serial number and an API key you generate yourself: + +```yaml + myenergi_hub_serial: '12345678' + myenergi_api_key: !secret myenergi_api_key +``` + +**Configuration options:** + +- **myenergi_auth_method** - `direct` (default, local digest API) or `oauth` (official cloud API) +- **myenergi_hub_serial** - Hub serial number, printed on the hub and shown in the myenergi app - required when `myenergi_auth_method` is `direct` +- **myenergi_api_key** - API key generated at [myaccount.myenergi.com](https://myaccount.myenergi.com) (Advanced → API Key) - required when `myenergi_auth_method` is `direct` +- **myenergi_key** - OAuth access token, cloud transport +- **myenergi_token_hash** - OAuth refresh token hash, used to refresh `myenergi_key` automatically - at least one of `myenergi_key` or `myenergi_token_hash` is required when `myenergi_auth_method` is `oauth` +- **myenergi_token_expires_at** - OAuth access token expiry, used to trigger a refresh +- **myenergi_automatic** - Set to `false` to stop Predbat wiring the device sensors into **car_charging_energy**, **car_charging_planned** and **iboost_energy_today** automatically (default: `true`) +- **myenergi_enable_controls** - Set to `false` for monitor-only operation (default: `true`) +- **myenergi_poll_seconds** - Poll interval in seconds, rounded to the nearest whole multiple of 60, minimum 60 and maximum 1800 (default: `60`) + +The component only starts when at least one of `myenergi_api_key`, `myenergi_key` or `myenergi_token_hash` is set. That test is a plain any-of and does not look at `myenergi_auth_method`, so a credential belonging to the transport you did not select still starts the component — it then logs which setting is missing rather than failing silently. + +See [Components - myenergi](components.md#myenergi-myenergi) for the full list of published entities, the boost controls, and a known limitation around very short charging or diversion sessions. + ## Watch List - automatically start Predbat execution By default Predbat will run automatically every 5 minute and to execute the plan, and re-evaluate the plan automatically every 10 minutes. diff --git a/docs/components.md b/docs/components.md index 6195ef4c8..9d84e9c1a 100644 --- a/docs/components.md +++ b/docs/components.md @@ -16,6 +16,7 @@ This document provides a comprehensive overview of all Predbat components, their - [Octopus Energy Direct (octopus)](#octopus-energy-direct-octopus) - [Axle Energy VPP (axle)](#axle-energy-vpp-axle) - [Ohme Charger (ohme)](#ohme-charger-ohme) + - [myenergi (myenergi)](#myenergi-myenergi) - [Fox ESS API (fox)](#fox-ess-api-fox) - [Tesla Powerwall Teslemetry API (teslemetry)](#tesla-powerwall-teslemetry-api-teslemetry) - [Enphase API (enphase)](#enphase-api-enphase) @@ -506,6 +507,112 @@ Integrates with Ohme EV chargers to monitor charging sessions and coordinate cha --- +### myenergi (myenergi) + +**Can be restarted:** Yes + +#### What it does (myenergi) + +Monitors myenergi Zappi EV chargers and Eddi hot water diverters, publishing their status, power and session energy as Predbat entities, and provides send-boost and cancel-boost controls. + +Predbat supports both of myenergi's APIs: + +- **Direct** (default) — HTTP digest authentication against `director.myenergi.net`, using your hub serial number and an API key you generate yourself. This is the same API the `ha-myenergi` Home Assistant integration uses, and any myenergi owner can set it up today. +- **Cloud OAuth** — the official 3rd party API at `api.s18.myenergi.net`. This needs credentials issued by myenergi through their partner registration process. + +#### When to enable (myenergi) + +- You have a Zappi or an Eddi and want Predbat to account for their energy use when planning +- You want Predbat to publish sensors for their status, power and session energy +- You want to trigger or cancel a boost from Home Assistant + +#### Important notes (myenergi) + +- With `myenergi_automatic` on (the default), Predbat sets three `apps.yaml` values for you: + - `car_charging_energy` — every Zappi's session energy, so charging is subtracted from your house load rather than being learnt as base load. Ensure `switch.predbat_car_charging_hold` is on (it is by default) for that subtraction to take effect + - `car_charging_planned` — every Zappi's plug status sensor, one entry per car, so Predbat knows when the car is plugged in and due to charge. The regex the `apps.yaml` templates ship for this key matches the third-party `ha-myenergi` integration's entity names, not the ones Predbat publishes, so without this Predbat would fall back to the `car_charging_threshold` heuristic + - `iboost_energy_today` — the first Eddi's session energy (first by serial number). This feeds the iboost model, and it is also subtracted from your historical house load whenever `switch.predbat_iboost_energy_subtract` is on (the default), which happens whether or not iboost itself is enabled +- Auto-configuration runs once, after the first poll that returns devices. A Zappi or Eddi added later is published as entities but is not wired into those keys until Predbat restarts +- If you set `car_charging_planned` yourself in `apps.yaml`, Predbat logs a note and auto-discovery still wins — remove your entry to silence it +- Predbat's shipped `car_charging_planned_response` list covers the plug states a Zappi reports when the car is connected, including `ev ready to charge`. If you maintain your own list, add that value or Predbat will treat a car that is plugged in and waiting as not planned to charge +- Boosting a Zappi is only accepted by myenergi while it is in Eco or Eco+ mode +- Set `myenergi_enable_controls` to `false` for monitor-only operation — the boost switches are still published but stop responding + +#### Configuration Options (myenergi) + +| Option | Type | Required | Default | Config Key | Description | +| ------ | ---- | -------- | ------- | ---------- | ----------- | +| `auth_method` | String | No | `direct` | `myenergi_auth_method` | `direct` (local digest API) or `oauth` (official cloud API) | +| `hub_serial` | String | No | - | `myenergi_hub_serial` | Hub serial number — required when `auth_method` is `direct` | +| `api_key` | String | No | - | `myenergi_api_key` | API key generated at myaccount.myenergi.com — required when `auth_method` is `direct` | +| `key` | String | No | - | `myenergi_key` | OAuth access token, cloud transport | +| `token_hash` | String | No | - | `myenergi_token_hash` | OAuth refresh token hash, used to refresh `key` automatically. At least one of `key` or `token_hash` is required when `auth_method` is `oauth` | +| `token_expires_at` | String | No | - | `myenergi_token_expires_at` | OAuth access token expiry, used to trigger a refresh | +| `automatic` | Boolean | No | true | `myenergi_automatic` | Set to `false` to stop Predbat wiring the device sensors into `car_charging_energy`, `car_charging_planned` and `iboost_energy_today` automatically | +| `enable_controls` | Boolean | No | true | `myenergi_enable_controls` | Set to `false` for monitor-only operation | +| `poll_seconds` | Integer | No | 60 | `myenergi_poll_seconds` | Poll interval in seconds, rounded to the nearest whole multiple of 60, minimum 60 and maximum 1800 (a longer gap would make Predbat's own health check report the component as failed) | + +The component only starts when at least one of `myenergi_api_key`, `myenergi_key` or `myenergi_token_hash` +is set. That test is a plain any-of and does not look at `myenergi_auth_method`, so a credential belonging +to the transport you did not select still starts the component — it then logs which setting is missing +rather than failing silently. + +Example for the direct transport: + +```yaml +myenergi_hub_serial: '12345678' +myenergi_api_key: !secret myenergi_api_key +``` + +#### How to get your API key (myenergi) + +1. Sign in at . +2. Open **Advanced** then **API Key**. +3. Generate a key for your hub and copy it. +4. Your hub serial number is printed on the hub and shown in the myenergi app. + +#### Published entities (myenergi) + +Per Zappi (`{sn}` is the device serial number): + +- `sensor.predbat_myenergi_zappi_{sn}_status`, `_mode`, `_plug_status`, `_power`, `_session_energy` +- `binary_sensor.predbat_myenergi_zappi_{sn}_charging` +- `switch.predbat_myenergi_zappi_{sn}_boost`, `number.predbat_myenergi_zappi_{sn}_boost_energy` + +Per Eddi: + +- `sensor.predbat_myenergi_eddi_{sn}_status`, `_power`, `_session_energy`, `_temp_1`, `_temp_2` +- `switch.predbat_myenergi_eddi_{sn}_boost`, `number.predbat_myenergi_eddi_{sn}_boost_minutes` + +The Eddi temperature sensors are only published when a probe is connected. + +#### Controls (myenergi) + +Turning a boost switch on sends a boost of the amount selected on the companion number entity — kWh for a Zappi, minutes for an Eddi. Turning it off cancels the boost. The switch state is read back from the device, so a boost started or stopped in the myenergi app is reflected here too. + +myenergi only accepts a Zappi boost while the charger is in Eco or Eco+ mode. Predbat checks that one condition before calling, and logs a warning instead. Every other reason a boost can be refused — an Eddi already at its maximum tank temperature, for instance — is only discovered from myenergi's reply, so Predbat issues the call and logs `myenergi: control failed` when it comes back refused. The switch reverts to the device's real state on the next poll either way. + +Not implemented in this release: mode selection, priority, minimum green level, phase setting, and charging schedules. These exist on the transport interface as reserved methods, ready for a later release, and nothing in Predbat calls them — there is no entity or service that can reach them, so there is nothing for you to try. If a future release wires one up before it is implemented, it warns once per control rather than failing silently. Super schedules, managed mode and Libbi batteries are out of scope entirely for this release; the component only supports Zappi and Eddi devices and does not expose any control surface for them. + +#### Known limitation (myenergi) + +The session energy sensors reset to zero when a charging or heating session ends. Predbat expects that: it treats these sensors as incrementing counters and rebases the series whenever it sees one reset, so both the per-minute load subtraction and the daily `iboost_today` total come out right across any number of sessions in a day. + +The one case it cannot see is a small session. A drop of less than 1 kWh is smoothed over as a dip in the data rather than treated as a reset, so a session that finishes below roughly 1 kWh — a short top-up, or a brief Eddi diversion — can be missed and its energy left out of the day's figures. A reading of zero between the sessions does not help, because the dip is smoothed away before the reset is looked for. That applies equally to `car_charging_energy` and to `iboost_today`. In practice it is a fraction of a kWh, and the planner mostly cares about the larger sessions, but the daily totals can read slightly low if your Zappi or Eddi does a lot of very short sessions. + +#### Testing your configuration (myenergi) + +You can test either transport independently of Predbat: + +```bash +cd /config/appdaemon/apps/predbat +python3 myenergi.py --hub-serial YOUR_HUB_SERIAL --api-key YOUR_API_KEY +``` + +Add `--boost zappi` or `--boost eddi` (with `--amount`) to send a test boost, or `--cancel-boost zappi`/`--cancel-boost eddi` to cancel one. Use `--token` in place of `--hub-serial`/`--api-key` to test the cloud OAuth transport instead. + +--- + ### Fox ESS API (fox) **Can be restarted:** Yes diff --git a/docs/superpowers/plans/2026-08-23-myenergi-integration.md b/docs/superpowers/plans/2026-08-23-myenergi-integration.md new file mode 100644 index 000000000..a603a8a5a --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-myenergi-integration.md @@ -0,0 +1,2399 @@ +# myenergi Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `myenergi` Predbat component that monitors Zappi and Eddi devices over either of myenergi's two APIs, auto-wires their energy sensors into `car_charging_energy` and `iboost_energy_today`, and exposes send-boost / cancel-boost controls. + +**Architecture:** One module `apps/predbat/myenergi.py` holding a `MyEnergiAPI(ComponentBase, OAuthMixin)` component over a `MyEnergiTransport` abstraction with two implementations — `MyEnergiDirectTransport` (HTTP digest against `director.myenergi.net`, `/cgi-*` endpoints, the default) and `MyEnergiCloudTransport` (bearer JWT against `api.s18.myenergi.net`, REST endpoints). Both normalise to a shared `MyEnergiDevice` dataclass, so publishing, auto-config, controls and tests are written once. + +**Tech Stack:** Python 3, `aiohttp` (including `aiohttp.DigestAuthMiddleware`), `dataclasses`, `abc`, existing Predbat infrastructure (`ComponentBase`, `OAuthMixin`, `MockBase`, `dashboard_item`, `set_arg_auto`), `unittest.mock` for tests. + +**Spec:** `docs/superpowers/specs/2026-08-23-myenergi-integration-design.md` + +## Global Constraints + +- **Line length:** 256 chars (Black), 250 chars (Flake8). +- **Docstrings:** 100% coverage required (`interrogate`) — every function, method and class needs one, including nested helpers and test functions. +- **Spelling:** British English (`en-gb`) via CSpell. New words go in `.cspell/custom-dictionary-workspace.txt`, which is auto-sorted on commit, so re-stage after running pre-commit. `Eddi`, `myenergi` and `zappi` are already present; `libbi`, `jstatus`, `jdayhour`, `harvi` and `asn` are not. +- **Variable naming:** `lower_case_with_underscores`. +- **aiohttp floor:** `aiohttp.DigestAuthMiddleware` and the `ClientSession(middlewares=...)` parameter require **aiohttp >= 3.12**. `requirements.txt` currently lists `aiohttp` unpinned and must be changed to `aiohttp>=3.12`. +- **Test invocation:** always redirect test output to a file and grep the file afterwards — never pipe straight to grep. Use the scratchpad directory for output files. +- **Shared fixture:** Predbat tests share one `my_predbat` fixture. Never mutate it in a way that leaks into later tests; construct components against `MockBase` wherever a full Predbat instance is not needed. +- **Every new code path needs a unit test** (repository rule in `CLAUDE.md`). +- **Pre-commit:** `./run_pre_commit` must pass before any commit. +- **Commit messages:** descriptive sentence style matching repository history (e.g. "Retain the last good GE Cloud reading when leaf values come back null"), ending with: + ``` + Co-Authored-By: Claude Opus 5 (1M context) + ``` +- **Branch:** all work happens on a feature branch off `main`, not on `main` itself. + +### Reference: myenergi wire formats + +Direct API (`director.myenergi.net`, digest auth, username = hub serial, password = API key): + +| Purpose | Path | +|---|---| +| ASN discovery | `GET /cgi-jstatus-E` against `https://director.myenergi.net` | +| All device status | `GET /cgi-jstatus-*` | +| Zappi manual boost | `GET /cgi-zappi-mode-Z{serial}-0-10-{kwh}-0000` | +| Zappi smart boost | `GET /cgi-zappi-mode-Z{serial}-0-11-{kwh}-{hhmm}` | +| Zappi cancel boost | `GET /cgi-zappi-mode-Z{serial}-0-2-0-0000` | +| Eddi boost | `GET /cgi-eddi-boost-E{serial}-10-{target}-{minutes}` | +| Eddi cancel boost | `GET /cgi-eddi-boost-E{serial}-1-{target}-0` | + +`/cgi-jstatus-*` returns a **list of single-key dicts**, e.g. +`[{"eddi": [{...}]}, {"zappi": [{...}]}, {"harvi": [...]}, {"asn": "s18.myenergi.net"}, {"fwv": "3560S5.036"}]`. + +Every response carries an `X_MYENERGI-asn` header naming the real host. Its absence means bad credentials. + +Cloud API (`https://api.s18.myenergi.net`, `Authorization: Bearer `): + +| Purpose | Path | +|---|---| +| Device list | `GET /devices` → `{"sites": [{"devices": [{"deviceId", "model", "alias", "serialNumber", "online", ...}]}]}` | +| Device status | `GET /devices/{id}/status` | +| Send boost | `POST /devices/{id}/boost` | +| Cancel boost | `DELETE /devices/{id}/boost` | + +Zappi boost body: `{"mode": "normal", "parameters": {"energy": N}}` (N is 1–99 kWh) or `{"mode": "smart", "parameters": {"energy": N, "targetTime": ""}}`. +Eddi boost body: `{"durationMinutes": M}` (M is 0–240). Cross-sending these fields is a 400. + +--- + +### Task 1: Module foundation — constants, device model and normalisers + +**Files:** +- Create: `apps/predbat/myenergi.py` +- Create: `apps/predbat/tests/test_myenergi.py` +- Modify: `apps/predbat/unit_test.py` (import near line 226, `TEST_REGISTRY` entry near line 540) +- Modify: `.cspell/custom-dictionary-workspace.txt` +- Modify: `requirements.txt` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `MyEnergiError`, `MyEnergiAuthError(MyEnergiError)`, `MyEnergiApiError(MyEnergiError)` — exception classes. + - `MyEnergiDevice` — frozen-by-convention dataclass, fields listed in step 3. + - `normalise_direct_device(raw: dict, kind: str) -> MyEnergiDevice` + - `normalise_cloud_device(raw: dict, meta: dict) -> MyEnergiDevice` + - Constants: `MYENERGI_DIRECTOR_URL`, `MYENERGI_CLOUD_URL`, `DEVICE_KIND_ZAPPI`, `DEVICE_KIND_EDDI`, `SUPPORTED_KINDS`, `DIRECT_PREFIX`, `CLOUD_PREFIX`, `ZAPPI_CHARGE_MODES`, `ZAPPI_STATES`, `EDDI_STATES`, `ZAPPI_PLUG_STATES`, `EDDI_BOOST_TARGETS`, `CLOUD_MODE_TO_NAME`, `CLOUD_STATUS_TO_NAME`, `API_TIMEOUT`, `USER_AGENT`. + - `test_myenergi(my_predbat=None) -> bool` — returns `False` on success, matching `test_axle`. + +- [ ] **Step 1: Create the module with its header, imports and constants** + +Create `apps/predbat/myenergi.py`: + +```python +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# myenergi API library. +# Supports both the direct "director" API (digest auth, /cgi-* endpoints) that +# pymyenergi and the ha-myenergi integration use, and the official 3rd party API +# documented at https://api-docs.s18.myenergi.net/ +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + + +"""myenergi Zappi and Eddi integration. + +Provides monitoring of myenergi Zappi EV chargers and Eddi hot water diverters, +automatic wiring of their energy sensors into Predbat's car charging and iboost +inputs, and send/cancel boost controls. Two interchangeable transports cover the +two myenergi APIs: a direct digest-authenticated transport that any myenergi owner +can configure today, and a bearer-token transport for the official 3rd party API. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional +import argparse +import asyncio + +import aiohttp + +from component_base import ComponentBase +from mock_base import MockBase +from oauth_mixin import OAuthMixin +from predbat_metrics import record_api_call + +MYENERGI_DIRECTOR_URL = "https://director.myenergi.net" +MYENERGI_CLOUD_URL = "https://api.s18.myenergi.net" + +API_TIMEOUT = 30 +USER_AGENT = "Wget/1.14 (linux-gnu)" + +DEVICE_KIND_ZAPPI = "zappi" +DEVICE_KIND_EDDI = "eddi" +SUPPORTED_KINDS = (DEVICE_KIND_ZAPPI, DEVICE_KIND_EDDI) + +# Device id prefixes. The direct API uses a single letter, the cloud API two letters. +DIRECT_PREFIX = {DEVICE_KIND_ZAPPI: "Z", DEVICE_KIND_EDDI: "E"} +CLOUD_PREFIX = {DEVICE_KIND_ZAPPI: "ZA", DEVICE_KIND_EDDI: "ED"} + +# Index tables used by the direct API's numeric status fields. +ZAPPI_CHARGE_MODES = ["None", "Fast", "Eco", "Eco+", "Stopped"] +ZAPPI_STATES = ["Unknown", "Paused", "Unknown", "Charging", "Boosting", "Completed"] +EDDI_STATES = ["Unknown", "Paused", "Unknown", "Diverting", "Boosting", "Max temp reached", "Stopped"] + +ZAPPI_PLUG_STATES = { + "A": "EV Disconnected", + "B1": "EV Connected", + "B2": "Waiting for EV", + "C1": "EV ready to charge", + "C2": "Charging", + "D1": "EV ready to charge", + "D2": "Charging", + "F": "Fault", +} + +EDDI_BOOST_TARGETS = {"heater1": 1, "heater2": 2, "relay1": 11, "relay2": 12} +EDDI_DEFAULT_BOOST_TARGET = "heater1" + +# The cloud API reports modes and statuses as strings. These maps translate them into +# the same vocabulary the direct API's index tables produce, so both transports emit +# identical MyEnergiDevice values for equivalent device states. +CLOUD_MODE_TO_NAME = {"fast": "Fast", "eco": "Eco", "eco+": "Eco+", "stop": "Stopped"} + +CLOUD_STATUS_TO_NAME = { + "ev_not_connected": "Paused", + "waiting_for_surplus": "Paused", + "waiting_for_ev": "Paused", + "charge_delayed": "Paused", + "smart_charge_delay": "Paused", + "charge_complete": "Completed", + "charging": "Charging", + "boosting": "Boosting", + "stopped": "Stopped", + "diverting": "Diverting", + "hot": "Max temp reached", + "starting": "Paused", + "dsr": "Paused", +} + +# Zappi boost energy limits, from the 3rd party API schema. The direct API accepts the +# same range in practice, so both transports validate against these. +BOOST_ENERGY_MIN = 1 +BOOST_ENERGY_MAX = 99 +BOOST_MINUTES_MIN = 0 +BOOST_MINUTES_MAX = 240 + +# Boosting a Zappi is only accepted while it is in one of the green-energy modes. +ZAPPI_BOOSTABLE_MODES = ("Eco", "Eco+") + + +class MyEnergiError(Exception): + """Base class for every myenergi transport failure.""" + + +class MyEnergiAuthError(MyEnergiError): + """Raised when myenergi rejects the supplied credentials.""" + + +class MyEnergiApiError(MyEnergiError): + """Raised when a myenergi request fails for a non-authentication reason.""" +``` + +- [ ] **Step 2: Write the failing normalisation tests** + +Create `apps/predbat/tests/test_myenergi.py`: + +```python +# fmt: off +# pylint: disable=line-too-long +""" +Unit tests for the myenergi Zappi and Eddi integration +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from myenergi import ( + DEVICE_KIND_EDDI, + DEVICE_KIND_ZAPPI, + normalise_cloud_device, + normalise_direct_device, +) + +# ============================================================================ +# Mock data constants +# ============================================================================ + +# One entry from the "zappi" group of a direct /cgi-jstatus-* response +MOCK_DIRECT_ZAPPI = { + "sno": 12345678, + "sta": 3, + "zmo": 2, + "pst": "C2", + "div": 7360, + "che": 4.25, + "grd": 120, + "gen": 3400, + "vol": 2405, + "frq": 50.02, +} + +# One entry from the "eddi" group of a direct /cgi-jstatus-* response +MOCK_DIRECT_EDDI = { + "sno": 87654321, + "sta": 3, + "div": 1500, + "che": 2.5, + "grd": -40, + "gen": 3400, + "vol": 2401, + "bsm": 0, + "rbt": 0, + "tp1": 54, + "tp2": 127, + "hno": 1, +} + +# GET /devices/{id}/status for the same Zappi, plus its GET /devices metadata +MOCK_CLOUD_ZAPPI_STATUS = { + "deviceClass": "ZAPPI", + "status": "active", + "state": "charging", + "deviceStatus": "charging", + "supplyMode": "eco", + "pilotState": "C2", + "boostCharge": False, + "actualPower": 7.36, + "gridPower": 0.12, + "genPower": 3.4, + "sessionEnergy": 4.25, + "energyDelivered": 0.12, +} + +MOCK_CLOUD_ZAPPI_META = { + "deviceId": "ZA12345678", + "model": "zappi", + "alias": "Driveway", + "serialNumber": 12345678, + "online": True, +} + +MOCK_CLOUD_EDDI_STATUS = { + "deviceClass": "EDDI", + "status": "active", + "state": "waiting_for_surplus", + "deviceStatus": "diverting", + "boostActive": False, + "actualPower": 1.5, + "gridPower": -0.04, + "genPower": 3.4, + "sessionEnergy": 2.5, +} + +MOCK_CLOUD_EDDI_META = { + "deviceId": "ED87654321", + "model": "eddi", + "alias": "Hot water", + "serialNumber": 87654321, + "online": True, +} + + +def test_normalise_direct_zappi(): + """Direct Zappi payloads normalise into the shared device model.""" + device = normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI) + assert device.device_id == "Z12345678" + assert device.kind == DEVICE_KIND_ZAPPI + assert device.serial == "12345678" + assert device.status == "Charging" + assert device.mode == "Eco" + assert device.plug_status == "Charging" + assert device.power_w == 7360 + assert device.grid_power_w == 120 + assert device.generation_w == 3400 + assert device.voltage == 240.5 + assert device.session_energy_kwh == 4.25 + assert device.boost_active is False + assert device.temp_1 is None + print(" ✓ Direct Zappi normalisation") + + +def test_normalise_direct_eddi(): + """Direct Eddi payloads normalise, including probe temperature handling.""" + device = normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI) + assert device.device_id == "E87654321" + assert device.kind == DEVICE_KIND_EDDI + assert device.status == "Diverting" + assert device.power_w == 1500 + assert device.session_energy_kwh == 2.5 + assert device.boost_active is False + assert device.plug_status == "" + assert device.temp_1 == 54 + # 127 is myenergi's "probe not connected" sentinel and must not be published + assert device.temp_2 is None + print(" ✓ Direct Eddi normalisation") + + +def test_normalise_direct_eddi_boosting(): + """An Eddi mid-boost reports boost_active and remaining minutes.""" + raw = dict(MOCK_DIRECT_EDDI, sta=4, bsm=1, rbt=1800) + device = normalise_direct_device(raw, DEVICE_KIND_EDDI) + assert device.status == "Boosting" + assert device.boost_active is True + assert device.boost_remaining_mins == 30 + print(" ✓ Direct Eddi boost state") + + +def test_normalise_cloud_matches_direct(): + """Cloud and direct payloads for the same device produce equal values.""" + direct_zappi = normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI) + cloud_zappi = normalise_cloud_device(MOCK_CLOUD_ZAPPI_STATUS, MOCK_CLOUD_ZAPPI_META) + assert cloud_zappi.kind == direct_zappi.kind + assert cloud_zappi.serial == direct_zappi.serial + assert cloud_zappi.status == direct_zappi.status + assert cloud_zappi.mode == direct_zappi.mode + assert cloud_zappi.plug_status == direct_zappi.plug_status + # Cloud reports kW, direct reports W - both land in W + assert cloud_zappi.power_w == direct_zappi.power_w + assert cloud_zappi.generation_w == direct_zappi.generation_w + assert cloud_zappi.session_energy_kwh == direct_zappi.session_energy_kwh + # The cloud device id keeps its two letter prefix and the friendly alias is used + assert cloud_zappi.device_id == "ZA12345678" + assert cloud_zappi.name == "Driveway" + + direct_eddi = normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI) + cloud_eddi = normalise_cloud_device(MOCK_CLOUD_EDDI_STATUS, MOCK_CLOUD_EDDI_META) + assert cloud_eddi.kind == direct_eddi.kind + assert cloud_eddi.status == direct_eddi.status + assert cloud_eddi.power_w == direct_eddi.power_w + assert cloud_eddi.session_energy_kwh == direct_eddi.session_energy_kwh + print(" ✓ Cloud and direct normalisation agree") + + +def test_normalise_handles_bad_values(): + """Out of range indices and missing fields fall back rather than raising.""" + device = normalise_direct_device({"sno": 1, "sta": 99, "zmo": "x"}, DEVICE_KIND_ZAPPI) + assert device.status == "Unknown" + assert device.mode == "Unknown" + assert device.power_w == 0 + assert device.session_energy_kwh == 0 + + device = normalise_direct_device({}, DEVICE_KIND_EDDI) + assert device.serial == "" + assert device.temp_1 is None + + device = normalise_cloud_device({"deviceClass": "EDDI"}, {}) + assert device.kind == DEVICE_KIND_EDDI + assert device.power_w == 0 + print(" ✓ Malformed payloads degrade safely") + + +def test_myenergi(my_predbat=None): + """ + ====================================================================== + MYENERGI TEST SUITE + ====================================================================== + Comprehensive test suite for the myenergi Zappi and Eddi integration including: + - Payload normalisation for both transports + """ + print("\n" + "=" * 70) + print("MYENERGI TEST SUITE") + print("=" * 70) + + test_normalise_direct_zappi() + test_normalise_direct_eddi() + test_normalise_direct_eddi_boosting() + test_normalise_cloud_matches_direct() + test_normalise_handles_bad_values() + + print("=" * 70) + return False +``` + +- [ ] **Step 3: Register the test suite** + +In `apps/predbat/unit_test.py`, add the import alongside the other component test imports (near the `from tests.test_ohme import test_ohme` line): + +```python +from tests.test_myenergi import test_myenergi +``` + +And add to `TEST_REGISTRY`, near the `("ohme", ...)` entry: + +```python + # myenergi Zappi and Eddi unit tests + ("myenergi", test_myenergi, "myenergi Zappi and Eddi comprehensive tests (normalisation, transports, publishing, auto-config, controls)", False), +``` + +- [ ] **Step 4: Run the test to verify it fails** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t1.log 2>&1; grep -iE "error|fail|ImportError|cannot import" /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t1.log | head -20 +``` + +Expected: FAIL with `ImportError: cannot import name 'normalise_direct_device' from 'myenergi'`. + +- [ ] **Step 5: Implement the device model and normalisers** + +Append to `apps/predbat/myenergi.py`: + +```python +@dataclass +class MyEnergiDevice: + """One normalised myenergi device, identical in shape across both transports.""" + + device_id: str + kind: str + serial: str + name: str + online: bool + status: str + mode: str + plug_status: str + power_w: float + grid_power_w: float + generation_w: float + voltage: float + session_energy_kwh: float + boost_active: bool + boost_remaining_mins: int + temp_1: Optional[float] + temp_2: Optional[float] + + +def _to_float(value, default=0.0): + """Coerce a raw API value to float, returning default for None or junk.""" + if value is None: + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _index_lookup(table, index, default="Unknown"): + """Look a numeric status code up in one of the direct API's index tables.""" + try: + position = int(index) + except (TypeError, ValueError): + return default + if 0 <= position < len(table): + return table[position] + return default + + +def _optional_temp(value): + """Return an Eddi probe temperature, or None when no probe is connected. + + myenergi reports 127 for an unconnected probe and a negative value when the + reading is unknown; publishing either as a temperature would be misleading. + """ + if value is None: + return None + try: + temperature = float(value) + except (TypeError, ValueError): + return None + if temperature >= 127 or temperature < 0: + return None + return temperature + + +def normalise_direct_device(raw, kind): + """Convert one direct API device record into a MyEnergiDevice. + + Args: + raw: A single device dict from a /cgi-jstatus-* response. + kind: Either DEVICE_KIND_ZAPPI or DEVICE_KIND_EDDI. + """ + serial = str(raw.get("sno", "") or "") + if kind == DEVICE_KIND_ZAPPI: + status = _index_lookup(ZAPPI_STATES, raw.get("sta")) + mode = _index_lookup(ZAPPI_CHARGE_MODES, raw.get("zmo")) + plug_status = ZAPPI_PLUG_STATES.get(str(raw.get("pst", "") or ""), "") + boost_active = status == "Boosting" + boost_remaining_mins = 0 + temp_1 = None + temp_2 = None + else: + status = _index_lookup(EDDI_STATES, raw.get("sta")) + mode = "Stopped" if status == "Stopped" else "Normal" + plug_status = "" + boost_active = int(_to_float(raw.get("bsm"))) == 1 + boost_remaining_mins = int(round(_to_float(raw.get("rbt")) / 60.0)) + temp_1 = _optional_temp(raw.get("tp1")) + temp_2 = _optional_temp(raw.get("tp2")) + + return MyEnergiDevice( + device_id=DIRECT_PREFIX[kind] + serial, + kind=kind, + serial=serial, + name="{}-{}".format(kind, serial), + online=True, + status=status, + mode=mode, + plug_status=plug_status, + power_w=_to_float(raw.get("div")), + grid_power_w=_to_float(raw.get("grd")), + generation_w=_to_float(raw.get("gen")), + voltage=_to_float(raw.get("vol")) / 10.0, + session_energy_kwh=_to_float(raw.get("che")), + boost_active=boost_active, + boost_remaining_mins=boost_remaining_mins, + temp_1=temp_1, + temp_2=temp_2, + ) + + +def normalise_cloud_device(raw, meta): + """Convert one cloud API status response into a MyEnergiDevice. + + Args: + raw: The body of GET /devices/{id}/status. + meta: The matching device entry from GET /devices, used for the id, alias + and online flag that the status response does not carry. + """ + kind = DEVICE_KIND_ZAPPI if str(raw.get("deviceClass", "")).upper() == "ZAPPI" else DEVICE_KIND_EDDI + serial = str(meta.get("serialNumber", "") or "") + device_id = str(meta.get("deviceId", "") or "") + if not serial and device_id: + serial = device_id[2:] + + status = CLOUD_STATUS_TO_NAME.get(str(raw.get("deviceStatus", "") or "").lower(), "Unknown") + if kind == DEVICE_KIND_ZAPPI: + mode = CLOUD_MODE_TO_NAME.get(str(raw.get("supplyMode", "") or "").lower(), "Unknown") + plug_status = ZAPPI_PLUG_STATES.get(str(raw.get("pilotState", "") or ""), "") + boost_active = bool(raw.get("boostCharge", False)) + else: + mode = "Stopped" if status == "Stopped" else "Normal" + plug_status = "" + boost_active = bool(raw.get("boostActive", False)) + + return MyEnergiDevice( + device_id=device_id or (CLOUD_PREFIX[kind] + serial), + kind=kind, + serial=serial, + name=meta.get("alias") or "{}-{}".format(kind, serial), + online=bool(meta.get("online", True)), + status=status, + mode=mode, + plug_status=plug_status, + # The cloud API reports power in kW, the direct API in W. Normalise to W. + power_w=_to_float(raw.get("actualPower")) * 1000.0, + grid_power_w=_to_float(raw.get("gridPower")) * 1000.0, + generation_w=_to_float(raw.get("genPower")) * 1000.0, + voltage=0.0, + session_energy_kwh=_to_float(raw.get("sessionEnergy")), + boost_active=boost_active, + boost_remaining_mins=0, + # The cloud API does not expose Eddi probe temperatures + temp_1=None, + temp_2=None, + ) +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t1.log 2>&1; tail -20 /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t1.log +``` + +Expected: PASS, all five normalisation checks printed. + +Note: every kW→W conversion in the mock payloads is exact in IEEE 754 (`7.36 * 1000.0 == 7360.0`, `3.4 * 1000.0 == 3400.0`, `1.5 * 1000.0 == 1500.0`, `0.12 * 1000.0 == 120.0`), so the equality assertions hold as written. If you add a mock value whose conversion is not exact, compare with `abs(a - b) < 0.001` instead of `==`. + +- [ ] **Step 7: Pin aiohttp and add the new dictionary words** + +In `requirements.txt`, change the `aiohttp` line to: + +``` +aiohttp>=3.12 +``` + +Add to `.cspell/custom-dictionary-workspace.txt` (the file is auto-sorted on commit, so append and re-stage): + +``` +asn +harvi +jdayhour +jstatus +libbi +``` + +- [ ] **Step 8: Run pre-commit and commit** + +```bash +cd /Users/treforsouthwell/batpred2 && ./run_pre_commit +git add apps/predbat/myenergi.py apps/predbat/tests/test_myenergi.py apps/predbat/unit_test.py .cspell/custom-dictionary-workspace.txt requirements.txt +git commit -m "Add myenergi device model and payload normalisation + +Introduces the myenergi module with the shared MyEnergiDevice dataclass and +normalisers for both the direct and cloud API payload shapes, so that the +transports added next can share every layer above the wire format. + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 2: Transport abstraction and stubbed controls + +**Files:** +- Modify: `apps/predbat/myenergi.py` +- Modify: `apps/predbat/tests/test_myenergi.py` + +**Interfaces:** +- Consumes: `MyEnergiDevice`, `MyEnergiError` from Task 1. +- Produces: `MyEnergiTransport` ABC with abstract `connect()`, `fetch_devices()`, `send_boost(device, amount, target_time=None)`, `cancel_boost(device)`; concrete stub methods `set_mode(device, mode)`, `set_priority(device, priority)`, `set_min_green_level(device, level)`, `set_phase_setting(device, phase)`, `get_schedule(device)`, `set_schedule(device, schedule)`, each returning `False`; helper `_not_implemented(what) -> bool`. + +- [ ] **Step 1: Write the failing stub tests** + +Append to `apps/predbat/tests/test_myenergi.py`, above `test_myenergi()`: + +```python +class _StubTransport(MyEnergiTransport): + """Minimal concrete transport used to exercise the abstract base's stubs.""" + + async def connect(self): + """Pretend to connect.""" + return True + + async def fetch_devices(self): + """Return no devices.""" + return [] + + async def send_boost(self, device, amount, target_time=None): + """Pretend to send a boost.""" + return True + + async def cancel_boost(self, device): + """Pretend to cancel a boost.""" + return True + + +def test_transport_stubs(): + """Every unimplemented control returns False and warns exactly once.""" + messages = [] + transport = _StubTransport(messages.append) + + assert run_async(transport.set_mode(None, "Eco")) is False + assert run_async(transport.set_priority(None, 1)) is False + assert run_async(transport.set_min_green_level(None, 50)) is False + assert run_async(transport.set_phase_setting(None, "1")) is False + assert run_async(transport.get_schedule(None)) is False + assert run_async(transport.set_schedule(None, [])) is False + + assert len(messages) == 6, "Each stub should warn once, got {}".format(messages) + assert all("not implemented" in message for message in messages) + + # A second call must not warn again + assert run_async(transport.set_mode(None, "Eco")) is False + assert len(messages) == 6, "Repeat calls must not warn again" + print(" ✓ Stubbed controls warn once and return False") +``` + +Extend the imports at the top of the test file: + +```python +from tests.test_infra import run_async + +from myenergi import ( + DEVICE_KIND_EDDI, + DEVICE_KIND_ZAPPI, + MyEnergiTransport, + normalise_cloud_device, + normalise_direct_device, +) +``` + +And call it from `test_myenergi()`: + +```python + test_transport_stubs() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t2.log 2>&1; grep -iE "error|fail|cannot import" /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t2.log | head +``` + +Expected: FAIL with `ImportError: cannot import name 'MyEnergiTransport'`. + +- [ ] **Step 3: Implement the transport base class** + +Append to `apps/predbat/myenergi.py`: + +```python +class MyEnergiTransport(ABC): + """Wire-format adapter for one of the two myenergi APIs. + + This is the only layer that knows how a myenergi request is shaped. Everything + above it works in terms of MyEnergiDevice, so adding or changing a transport + never touches publishing, auto-configuration or the controls. + """ + + def __init__(self, log): + """Store the logging function and initialise the one-shot warning set.""" + self.log = log + self._warned_stubs = set() + + @abstractmethod + async def connect(self): + """Establish and validate the connection. Returns True on success.""" + + @abstractmethod + async def fetch_devices(self): + """Return a list of MyEnergiDevice for every supported device found.""" + + @abstractmethod + async def send_boost(self, device, amount, target_time=None): + """Start a boost on a device. + + Args: + device: The MyEnergiDevice to boost. + amount: kWh for a Zappi, minutes for an Eddi. + target_time: Optional "HH:MM" completion time, Zappi smart boost only. + """ + + @abstractmethod + async def cancel_boost(self, device): + """Cancel an active boost on a device.""" + + def _not_implemented(self, what): + """Warn once that a control is not implemented in this release, and return False.""" + if what not in self._warned_stubs: + self._warned_stubs.add(what) + self.log("Warn: myenergi: {} is not implemented in this release".format(what)) + return False + + async def set_mode(self, device, mode): + """Set the device supply mode. Not implemented in this release.""" + return self._not_implemented("set_mode") + + async def set_priority(self, device, priority): + """Set the device diversion priority. Not implemented in this release.""" + return self._not_implemented("set_priority") + + async def set_min_green_level(self, device, level): + """Set the Zappi minimum green level. Not implemented in this release.""" + return self._not_implemented("set_min_green_level") + + async def set_phase_setting(self, device, phase): + """Set the Zappi phase setting. Not implemented in this release.""" + return self._not_implemented("set_phase_setting") + + async def get_schedule(self, device): + """Read the device charging schedule. Not implemented in this release.""" + return self._not_implemented("get_schedule") + + async def set_schedule(self, device, schedule): + """Write the device charging schedule. Not implemented in this release.""" + return self._not_implemented("set_schedule") +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t2.log 2>&1; tail -20 /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t2.log +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/treforsouthwell/batpred2 && ./run_pre_commit +git add apps/predbat/myenergi.py apps/predbat/tests/test_myenergi.py +git commit -m "Add the myenergi transport abstraction with stubbed controls + +Fixes the interface both transports implement, and lands the controls that are +out of scope for this release as single-warning stubs so the follow-up work is +purely additive. + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 3: Direct transport + +**Files:** +- Modify: `apps/predbat/myenergi.py` +- Modify: `apps/predbat/tests/test_myenergi.py` + +**Interfaces:** +- Consumes: `MyEnergiTransport`, `MyEnergiDevice`, `normalise_direct_device`, exceptions, constants from Tasks 1–2. +- Produces: `MyEnergiDirectTransport(log, hub_serial, api_key)` with `connect()`, `fetch_devices()`, `send_boost()`, `cancel_boost()`, and the internal `_request(path)` that performs ASN resolution. Exposes `self.base_url` and `self.needs_asn_refresh` for tests. + +**Behaviour to implement**, exactly matching what the myenergi service does: + +1. When `base_url` is unset or `needs_asn_refresh` is True, GET `https://director.myenergi.net/cgi-jstatus-E` with digest auth and read `X_MYENERGI-asn` from the response headers; `base_url` becomes `https://`. +2. Issue the real request against `base_url + path`. +3. Re-read `X_MYENERGI-asn` from every response and follow it if it changed. +4. A missing `X_MYENERGI-asn` header means bad credentials → `MyEnergiAuthError`. +5. HTTP 401 → `MyEnergiAuthError`. Any other non-200 → `MyEnergiApiError`, and set `needs_asn_refresh` so the next call re-resolves. +6. A timeout sets `needs_asn_refresh` and raises `MyEnergiApiError`. + +- [ ] **Step 1: Write the failing direct transport tests** + +Append to `apps/predbat/tests/test_myenergi.py`: + +```python +def _direct_response(json_data, asn="s18.myenergi.net", status=200): + """Build a mock aiohttp response carrying an X_MYENERGI-asn header.""" + response = MagicMock() + response.status = status + response.headers = {"X_MYENERGI-asn": asn} if asn else {} + response.json = AsyncMock(return_value=json_data) + response.__aenter__ = AsyncMock(return_value=response) + response.__aexit__ = AsyncMock(return_value=False) + return response + + +def _direct_session(responses): + """Build a mock aiohttp session whose get() returns the next queued response. + + Returns (session, calls), where calls records every requested URL in order. + """ + calls = [] + queue = list(responses) + + def _get(url, **kwargs): + calls.append(url) + return queue.pop(0) if queue else _direct_response({}) + + session = MagicMock() + session.get = _get + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock(return_value=False) + return session, calls + + +MOCK_JSTATUS_ALL = [ + {"eddi": [MOCK_DIRECT_EDDI]}, + {"zappi": [MOCK_DIRECT_ZAPPI]}, + {"harvi": [{"sno": 11112222}]}, + {"asn": "s18.myenergi.net"}, + {"fwv": "3560S5.036"}, +] + + +def test_direct_fetch_devices(): + """The direct transport resolves the ASN then parses the jstatus device groups.""" + session, calls = _direct_session([_direct_response([]), _direct_response(MOCK_JSTATUS_ALL)]) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + + with patch("aiohttp.ClientSession", return_value=session): + devices = run_async(transport.fetch_devices()) + + assert calls[0].startswith("https://director.myenergi.net/cgi-jstatus-E"), calls + assert calls[1] == "https://s18.myenergi.net/cgi-jstatus-*", calls + assert transport.base_url == "https://s18.myenergi.net" + # harvi is not a supported kind and must be skipped + assert len(devices) == 2, [device.kind for device in devices] + kinds = sorted(device.kind for device in devices) + assert kinds == [DEVICE_KIND_EDDI, DEVICE_KIND_ZAPPI] + print(" ✓ Direct transport resolves ASN and parses devices") + + +def test_direct_missing_asn_is_auth_error(): + """A response without the ASN header means bad credentials.""" + session, _calls = _direct_session([_direct_response([], asn=None)]) + transport = MyEnergiDirectTransport(print, "12345678", "wrong-key") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiAuthError") + except MyEnergiAuthError: + pass + print(" ✓ Missing ASN header raises MyEnergiAuthError") + + +def test_direct_boost_urls(): + """Boost and cancel produce the exact documented URLs for both device kinds.""" + zappi = normalise_direct_device(dict(MOCK_DIRECT_ZAPPI, zmo=2), DEVICE_KIND_ZAPPI) + eddi = normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + # Pre-resolve the active server so the requests under test are the only ones made + transport.base_url = "https://s18.myenergi.net" + transport.needs_asn_refresh = False + + session, calls = _direct_session([_direct_response({"status": 0}) for _ in range(4)]) + with patch("aiohttp.ClientSession", return_value=session): + run_async(transport.send_boost(zappi, 10)) + run_async(transport.cancel_boost(zappi)) + run_async(transport.send_boost(eddi, 60)) + run_async(transport.cancel_boost(eddi)) + + assert calls[0] == "https://s18.myenergi.net/cgi-zappi-mode-Z12345678-0-10-10-0000", calls[0] + assert calls[1] == "https://s18.myenergi.net/cgi-zappi-mode-Z12345678-0-2-0-0000", calls[1] + assert calls[2] == "https://s18.myenergi.net/cgi-eddi-boost-E87654321-10-1-60", calls[2] + assert calls[3] == "https://s18.myenergi.net/cgi-eddi-boost-E87654321-1-1-0", calls[3] + print(" ✓ Direct transport boost URLs") + + +def test_direct_smart_boost_url(): + """A Zappi boost with a target time uses the smart boost command.""" + zappi = normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI) + transport = MyEnergiDirectTransport(print, "12345678", "secret-key") + transport.base_url = "https://s18.myenergi.net" + transport.needs_asn_refresh = False + + session, calls = _direct_session([_direct_response({"status": 0})]) + with patch("aiohttp.ClientSession", return_value=session): + run_async(transport.send_boost(zappi, 15, target_time="07:30")) + + assert calls[0] == "https://s18.myenergi.net/cgi-zappi-mode-Z12345678-0-11-15-0730", calls[0] + print(" ✓ Direct transport smart boost URL") +``` + +Extend the test file imports: + +```python +from unittest.mock import AsyncMock, MagicMock, patch + +from myenergi import ( + DEVICE_KIND_EDDI, + DEVICE_KIND_ZAPPI, + MyEnergiAuthError, + MyEnergiDirectTransport, + MyEnergiTransport, + normalise_cloud_device, + normalise_direct_device, +) +``` + +And call them from `test_myenergi()`: + +```python + test_direct_fetch_devices() + test_direct_missing_asn_is_auth_error() + test_direct_boost_urls() + test_direct_smart_boost_url() +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t3.log 2>&1; grep -iE "error|fail|cannot import" /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t3.log | head +``` + +Expected: FAIL with `ImportError: cannot import name 'MyEnergiDirectTransport'`. + +- [ ] **Step 3: Implement the direct transport** + +Append to `apps/predbat/myenergi.py`: + +```python +class MyEnergiDirectTransport(MyEnergiTransport): + """Transport for the direct myenergi API used by pymyenergi and ha-myenergi. + + Authenticates with HTTP digest, using the hub serial as the username and the + API key generated at myaccount.myenergi.com as the password. myenergi shards + accounts across servers, so the first request goes to director.myenergi.net, + whose X_MYENERGI-asn response header names the host to use from then on. + """ + + def __init__(self, log, hub_serial, api_key): + """Store credentials and start with an unresolved active server.""" + super().__init__(log) + self.hub_serial = str(hub_serial) + self.api_key = api_key + self.base_url = None + self.needs_asn_refresh = True + + def _new_session(self): + """Create an aiohttp session carrying the digest auth middleware.""" + digest = aiohttp.DigestAuthMiddleware(self.hub_serial, self.api_key) + return aiohttp.ClientSession(middlewares=(digest,), headers={"User-Agent": USER_AGENT}) + + def _update_asn(self, headers): + """Follow the active server named by the X_MYENERGI-asn response header.""" + asn = headers.get("X_MYENERGI-asn") + if not asn: + raise MyEnergiAuthError("no X_MYENERGI-asn header returned - check the hub serial and API key") + new_url = "https://" + asn + if new_url != self.base_url: + self.log("Info: myenergi: active server is {}".format(new_url)) + self.base_url = new_url + + async def _resolve_asn(self): + """Ask director.myenergi.net which server this account lives on.""" + async with self._new_session() as session: + async with session.get(MYENERGI_DIRECTOR_URL + "/cgi-jstatus-E", timeout=aiohttp.ClientTimeout(total=API_TIMEOUT)) as response: + self._update_asn(response.headers) + self.needs_asn_refresh = False + + async def _request(self, path): + """Perform one GET against the active server, resolving the ASN if needed.""" + if self.base_url is None or self.needs_asn_refresh: + await self._resolve_asn() + url = self.base_url + path + try: + async with self._new_session() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=API_TIMEOUT)) as response: + self._update_asn(response.headers) + if response.status == 401: + record_api_call("myenergi", success=False, reason="unauthorised") + raise MyEnergiAuthError("myenergi rejected the credentials for {}".format(path)) + if response.status != 200: + self.needs_asn_refresh = True + record_api_call("myenergi", success=False, reason="http_{}".format(response.status)) + raise MyEnergiApiError("HTTP {} from {}".format(response.status, path)) + record_api_call("myenergi", success=True) + return await response.json(content_type=None) + except asyncio.TimeoutError as exc: + self.needs_asn_refresh = True + record_api_call("myenergi", success=False, reason="timeout") + raise MyEnergiApiError("timed out calling {}".format(path)) from exc + except aiohttp.ClientError as exc: + self.needs_asn_refresh = True + record_api_call("myenergi", success=False, reason="client_error") + raise MyEnergiApiError("request to {} failed: {}".format(path, exc)) from exc + + async def connect(self): + """Resolve the active server, which also validates the credentials.""" + await self._resolve_asn() + return True + + async def fetch_devices(self): + """Fetch every device in one /cgi-jstatus-* call and normalise the supported ones. + + The response is a list of single-key dicts, one per device family, plus + housekeeping entries such as {"asn": ...} and {"fwv": ...} that are skipped. + """ + payload = await self._request("/cgi-jstatus-*") + devices = [] + if not isinstance(payload, list): + return devices + for group in payload: + if not isinstance(group, dict): + continue + for kind, records in group.items(): + if kind not in SUPPORTED_KINDS or not isinstance(records, list): + continue + for raw in records: + if isinstance(raw, dict): + devices.append(normalise_direct_device(raw, kind)) + return devices + + async def send_boost(self, device, amount, target_time=None): + """Start a boost, choosing the manual or smart command for a Zappi.""" + if device.kind == DEVICE_KIND_ZAPPI: + energy = int(amount) + if target_time: + when = str(target_time).replace(":", "") + await self._request("/cgi-zappi-mode-Z{}-0-11-{}-{}".format(device.serial, energy, when)) + else: + await self._request("/cgi-zappi-mode-Z{}-0-10-{}-0000".format(device.serial, energy)) + else: + target = EDDI_BOOST_TARGETS[EDDI_DEFAULT_BOOST_TARGET] + await self._request("/cgi-eddi-boost-E{}-10-{}-{}".format(device.serial, target, int(amount))) + return True + + async def cancel_boost(self, device): + """Cancel an active boost.""" + if device.kind == DEVICE_KIND_ZAPPI: + await self._request("/cgi-zappi-mode-Z{}-0-2-0-0000".format(device.serial)) + else: + target = EDDI_BOOST_TARGETS[EDDI_DEFAULT_BOOST_TARGET] + await self._request("/cgi-eddi-boost-E{}-1-{}-0".format(device.serial, target)) + return True +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t3.log 2>&1; tail -25 /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t3.log +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/treforsouthwell/batpred2 && ./run_pre_commit +git add apps/predbat/myenergi.py apps/predbat/tests/test_myenergi.py +git commit -m "Add the direct myenergi transport with ASN resolution + +Digest-authenticated access to the director API, following the X_MYENERGI-asn +header to the account's active server and treating its absence as a credential +failure rather than a transport error. + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 4: Cloud transport + +**Files:** +- Modify: `apps/predbat/myenergi.py` +- Modify: `apps/predbat/tests/test_myenergi.py` + +**Interfaces:** +- Consumes: everything from Tasks 1–3. +- Produces: `MyEnergiCloudTransport(log, access_token_getter)` where `access_token_getter` is a zero-argument callable returning the current bearer token. Methods: `connect()`, `fetch_devices()`, `send_boost()`, `cancel_boost()`, plus `self.device_meta` (a dict keyed by `deviceId`) and `self.meta_age_seconds` for the 30 minute device-list cache. + +Taking the token through a callable rather than a stored string means `OAuthMixin` can refresh it on the component without the transport holding a stale copy. + +- [ ] **Step 1: Write the failing cloud transport tests** + +Append to `apps/predbat/tests/test_myenergi.py`: + +```python +MOCK_CLOUD_DEVICES = { + "sites": [ + { + "siteId": "site-1", + "name": "Home", + "gridLimit": 15, + "devices": [ + MOCK_CLOUD_ZAPPI_META, + MOCK_CLOUD_EDDI_META, + {"deviceId": "HA11112222", "model": "harvi", "alias": "CT", "serialNumber": 11112222, "online": True}, + ], + } + ] +} + + +def _cloud_response(json_data, status=200): + """Build a mock aiohttp response for the cloud API.""" + response = MagicMock() + response.status = status + response.json = AsyncMock(return_value=json_data) + response.__aenter__ = AsyncMock(return_value=response) + response.__aexit__ = AsyncMock(return_value=False) + return response + + +def _cloud_session(responses): + """Patch aiohttp.ClientSession recording (method, url, json) for each request.""" + calls = [] + queue = list(responses) + + def _request(method, url, **kwargs): + calls.append((method, url, kwargs.get("json"))) + return queue.pop(0) if queue else _cloud_response({}) + + session = MagicMock() + session.request = _request + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock(return_value=False) + return session, calls + + +def test_cloud_fetch_devices(): + """The cloud transport lists devices then polls status for supported ones only.""" + session, calls = _cloud_session( + [ + _cloud_response(MOCK_CLOUD_DEVICES), + _cloud_response(MOCK_CLOUD_ZAPPI_STATUS), + _cloud_response(MOCK_CLOUD_EDDI_STATUS), + ] + ) + transport = MyEnergiCloudTransport(print, lambda: "jwt-token") + + with patch("aiohttp.ClientSession", return_value=session): + devices = run_async(transport.fetch_devices()) + + assert calls[0] == ("GET", "https://api.s18.myenergi.net/devices", None), calls[0] + assert calls[1][1] == "https://api.s18.myenergi.net/devices/ZA12345678/status", calls[1] + assert calls[2][1] == "https://api.s18.myenergi.net/devices/ED87654321/status", calls[2] + # harvi is unsupported and must never be polled + assert len(calls) == 3, calls + assert len(devices) == 2 + assert devices[0].name == "Driveway" + print(" ✓ Cloud transport lists and polls supported devices") + + +def test_cloud_boost_bodies(): + """Boost bodies are shaped per device class, never mixing the two forms.""" + zappi = normalise_cloud_device(MOCK_CLOUD_ZAPPI_STATUS, MOCK_CLOUD_ZAPPI_META) + eddi = normalise_cloud_device(MOCK_CLOUD_EDDI_STATUS, MOCK_CLOUD_EDDI_META) + transport = MyEnergiCloudTransport(print, lambda: "jwt-token") + + session, calls = _cloud_session([_cloud_response({"commandId": "c1"}) for _ in range(4)]) + with patch("aiohttp.ClientSession", return_value=session): + run_async(transport.send_boost(zappi, 10)) + run_async(transport.send_boost(eddi, 60)) + run_async(transport.cancel_boost(zappi)) + run_async(transport.cancel_boost(eddi)) + + assert calls[0] == ("POST", "https://api.s18.myenergi.net/devices/ZA12345678/boost", {"mode": "normal", "parameters": {"energy": 10}}), calls[0] + assert calls[1] == ("POST", "https://api.s18.myenergi.net/devices/ED87654321/boost", {"durationMinutes": 60}), calls[1] + assert calls[2][0] == "DELETE" and calls[2][1].endswith("/devices/ZA12345678/boost"), calls[2] + assert calls[3][0] == "DELETE" and calls[3][1].endswith("/devices/ED87654321/boost"), calls[3] + + # A Zappi body must never carry durationMinutes, an Eddi body never mode/parameters + assert "durationMinutes" not in calls[0][2] + assert "mode" not in calls[1][2] and "parameters" not in calls[1][2] + print(" ✓ Cloud transport boost bodies") + + +def test_cloud_sets_bearer_header(): + """Requests carry the current bearer token from the supplied callable.""" + tokens = ["first-token"] + session, _calls = _cloud_session([_cloud_response(MOCK_CLOUD_DEVICES)]) + transport = MyEnergiCloudTransport(print, lambda: tokens[0]) + + captured = {} + + def _client_session(**kwargs): + captured.update(kwargs.get("headers") or {}) + return session + + with patch("aiohttp.ClientSession", side_effect=_client_session): + run_async(transport._request("GET", "/devices")) + + assert captured.get("Authorization") == "Bearer first-token", captured + print(" ✓ Cloud transport sends the bearer token") + + +def test_cloud_unauthorised_raises_auth_error(): + """An HTTP 401 from the cloud API surfaces as MyEnergiAuthError.""" + session, _calls = _cloud_session([_cloud_response({"message": "nope", "code": "UNAUTHORISED"}, status=401)]) + transport = MyEnergiCloudTransport(print, lambda: "stale-token") + + with patch("aiohttp.ClientSession", return_value=session): + try: + run_async(transport.fetch_devices()) + raise AssertionError("Expected MyEnergiAuthError") + except MyEnergiAuthError: + pass + print(" ✓ Cloud transport 401 raises MyEnergiAuthError") +``` + +Add `MyEnergiCloudTransport` to the test file's `myenergi` imports, and call the four new tests from `test_myenergi()`. + +- [ ] **Step 2: Run to verify it fails** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t4.log 2>&1; grep -iE "error|fail|cannot import" /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t4.log | head +``` + +Expected: FAIL with `ImportError: cannot import name 'MyEnergiCloudTransport'`. + +- [ ] **Step 3: Implement the cloud transport** + +Append to `apps/predbat/myenergi.py`: + +```python +# The cloud device list changes rarely, so it is cached between polls. +CLOUD_DEVICE_LIST_MAX_AGE = 30 * 60 + +# Model names in GET /devices that map onto the kinds this release supports. +CLOUD_MODEL_TO_KIND = {"zappi": DEVICE_KIND_ZAPPI, "eddi": DEVICE_KIND_EDDI} + + +class MyEnergiCloudTransport(MyEnergiTransport): + """Transport for the official myenergi 3rd party API. + + Authenticates with a bearer JWT obtained through the OAuth2 authorization code + flow. The token is read through a callable on every request so that a refresh + performed by OAuthMixin on the component takes effect immediately. + """ + + def __init__(self, log, access_token_getter): + """Store the token accessor and initialise the device list cache.""" + super().__init__(log) + self.access_token_getter = access_token_getter + self.device_meta = {} + self.meta_age_seconds = CLOUD_DEVICE_LIST_MAX_AGE + + def _headers(self): + """Build the request headers, including the current bearer token.""" + return { + "Authorization": "Bearer {}".format(self.access_token_getter() or ""), + "Accept": "application/json", + "User-Agent": USER_AGENT, + } + + async def _request(self, method, path, body=None): + """Perform one cloud API request and return the decoded JSON body.""" + url = MYENERGI_CLOUD_URL + path + try: + async with aiohttp.ClientSession(headers=self._headers()) as session: + async with session.request(method, url, json=body, timeout=aiohttp.ClientTimeout(total=API_TIMEOUT)) as response: + if response.status == 401: + record_api_call("myenergi", success=False, reason="unauthorised") + raise MyEnergiAuthError("myenergi rejected the access token for {}".format(path)) + if response.status not in (200, 201, 202, 204): + record_api_call("myenergi", success=False, reason="http_{}".format(response.status)) + raise MyEnergiApiError("HTTP {} from {} {}".format(response.status, method, path)) + record_api_call("myenergi", success=True) + if response.status == 204: + return {} + return await response.json(content_type=None) + except asyncio.TimeoutError as exc: + record_api_call("myenergi", success=False, reason="timeout") + raise MyEnergiApiError("timed out calling {} {}".format(method, path)) from exc + except aiohttp.ClientError as exc: + record_api_call("myenergi", success=False, reason="client_error") + raise MyEnergiApiError("request to {} {} failed: {}".format(method, path, exc)) from exc + + async def _refresh_device_list(self): + """Reload GET /devices, keeping only the Zappi and Eddi entries.""" + payload = await self._request("GET", "/devices") + meta = {} + for site in payload.get("sites", []) or []: + for entry in site.get("devices", []) or []: + kind = CLOUD_MODEL_TO_KIND.get(str(entry.get("model", "")).lower()) + device_id = entry.get("deviceId") + if kind and device_id: + meta[device_id] = entry + self.device_meta = meta + self.meta_age_seconds = 0 + + async def connect(self): + """Load the device list, which also validates the access token.""" + await self._refresh_device_list() + return True + + async def fetch_devices(self): + """Poll status for every cached Zappi and Eddi, refreshing the list when stale.""" + if not self.device_meta or self.meta_age_seconds >= CLOUD_DEVICE_LIST_MAX_AGE: + await self._refresh_device_list() + devices = [] + for device_id, meta in self.device_meta.items(): + status = await self._request("GET", "/devices/{}/status".format(device_id)) + if status: + devices.append(normalise_cloud_device(status, meta)) + return devices + + async def send_boost(self, device, amount, target_time=None): + """Start a boost, selecting the request body shape by device class.""" + if device.kind == DEVICE_KIND_ZAPPI: + body = {"mode": "normal", "parameters": {"energy": int(amount)}} + if target_time: + body = {"mode": "smart", "parameters": {"energy": int(amount), "targetTime": target_time}} + else: + body = {"durationMinutes": int(amount)} + await self._request("POST", "/devices/{}/boost".format(device.device_id), body=body) + return True + + async def cancel_boost(self, device): + """Cancel an active boost.""" + await self._request("DELETE", "/devices/{}/boost".format(device.device_id)) + return True +``` + +Note: `target_time` for the cloud transport is an ISO-8601 timestamp, not the `HH:MM` the direct transport takes. The component only issues untimed boosts in this release, so the difference is confined to the transports; Task 8's controls never pass `target_time`. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t4.log 2>&1; tail -25 /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t4.log +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/treforsouthwell/batpred2 && ./run_pre_commit +git add apps/predbat/myenergi.py apps/predbat/tests/test_myenergi.py +git commit -m "Add the cloud myenergi transport for the 3rd party API + +Bearer-token access to api.s18.myenergi.net with a cached device list and +per-device-class boost bodies, so a Zappi never receives durationMinutes and an +Eddi never receives mode or parameters. + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 5: Component core — initialisation, transport selection, run loop and publishing + +**Files:** +- Modify: `apps/predbat/myenergi.py` +- Modify: `apps/predbat/tests/test_myenergi.py` + +**Interfaces:** +- Consumes: everything from Tasks 1–4. +- Produces: `MyEnergiAPI(ComponentBase, OAuthMixin)` with `initialize(auth_method, hub_serial, api_key, key, token_expires_at, token_hash, automatic, enable_controls, poll_seconds)`, `run(seconds, first)`, `publish_data()`, `entity_prefix(device)`, and the attributes `self.transport`, `self.devices` (dict keyed by `device_id`), `self.boost_amounts` (dict keyed by `device_id`), `self.queued_events`. +- Also produces the module-level `myenergi_attribute_table` used by publishing. + +- [ ] **Step 1: Write the failing component tests** + +Append to `apps/predbat/tests/test_myenergi.py`: + +```python +def _make_component(**overrides): + """Build a MyEnergiAPI against MockBase with a stub transport already attached.""" + base = MockBase() + args = { + "auth_method": "direct", + "hub_serial": "12345678", + "api_key": "secret-key", + "key": None, + "token_expires_at": None, + "token_hash": None, + "automatic": True, + "enable_controls": True, + "poll_seconds": 60, + } + args.update(overrides) + return MyEnergiAPI(base, **args) + + +def test_component_selects_transport(): + """auth_method picks the transport, and missing credentials refuse to start.""" + component = _make_component() + assert isinstance(component.transport, MyEnergiDirectTransport) + + component = _make_component(auth_method="oauth", hub_serial=None, api_key=None, key="jwt-token") + assert isinstance(component.transport, MyEnergiCloudTransport) + + # No credentials at all - no transport, and the reason is logged + component = _make_component(hub_serial=None, api_key=None) + assert component.transport is None + print(" ✓ Transport selection and credential validation") + + +def test_component_publishes_entities(): + """A poll publishes the documented entity set for each device.""" + component = _make_component() + component.devices = { + "Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI), + "E87654321": normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI), + } + run_async(component.publish_data()) + + entities = component.base.entities + assert "sensor.predbat_myenergi_zappi_12345678_status" in entities + assert "sensor.predbat_myenergi_zappi_12345678_mode" in entities + assert "sensor.predbat_myenergi_zappi_12345678_plug_status" in entities + assert "sensor.predbat_myenergi_zappi_12345678_power" in entities + assert "sensor.predbat_myenergi_zappi_12345678_session_energy" in entities + assert "binary_sensor.predbat_myenergi_zappi_12345678_charging" in entities + assert "switch.predbat_myenergi_zappi_12345678_boost" in entities + assert "number.predbat_myenergi_zappi_12345678_boost_energy" in entities + + assert "sensor.predbat_myenergi_eddi_87654321_status" in entities + assert "sensor.predbat_myenergi_eddi_87654321_power" in entities + assert "sensor.predbat_myenergi_eddi_87654321_session_energy" in entities + assert "sensor.predbat_myenergi_eddi_87654321_temp_1" in entities + assert "switch.predbat_myenergi_eddi_87654321_boost" in entities + assert "number.predbat_myenergi_eddi_87654321_boost_minutes" in entities + + # tp2 was the 127 sentinel, so no entity should exist for it + assert "sensor.predbat_myenergi_eddi_87654321_temp_2" not in entities + + # The boost switch reflects the device, not a locally held value + assert component.base.get_state_wrapper("switch.predbat_myenergi_zappi_12345678_boost") == "off" + + # Units come from the attribute table + power = component.base.entities["sensor.predbat_myenergi_zappi_12345678_power"] + assert power["attributes"]["unit_of_measurement"] == "W" + assert power["attributes"]["device_class"] == "power" + energy = component.base.entities["sensor.predbat_myenergi_zappi_12345678_session_energy"] + assert energy["attributes"]["unit_of_measurement"] == "kWh" + print(" ✓ Component publishes the expected entities") + + +def test_component_retains_last_good_reading(): + """A failed poll leaves the previously published values alone.""" + component = _make_component() + component.transport.fetch_devices = AsyncMock(return_value=[normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)]) + assert run_async(component.run(0, True)) is True + good = component.base.get_state_wrapper("sensor.predbat_myenergi_zappi_12345678_session_energy") + assert good == 4.25 + + component.transport.fetch_devices = AsyncMock(side_effect=MyEnergiApiError("boom")) + assert run_async(component.run(60, False)) is False + still_good = component.base.get_state_wrapper("sensor.predbat_myenergi_zappi_12345678_session_energy") + assert still_good == 4.25, "A failed poll must not overwrite the last good reading" + print(" ✓ Failed polls retain the last good reading") + + +def test_component_poll_seconds_rounding(): + """poll_seconds is clamped to a whole number of base loop intervals.""" + assert _make_component(poll_seconds=1).poll_seconds == 60 + assert _make_component(poll_seconds=90).poll_seconds == 120 + assert _make_component(poll_seconds=300).poll_seconds == 300 + print(" ✓ poll_seconds rounds to a multiple of 60") +``` + +Extend the test imports with `MockBase`, `MyEnergiAPI`, `MyEnergiApiError`, `MyEnergiCloudTransport`, and register the four tests in `test_myenergi()`: + +```python +from mock_base import MockBase +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t5.log 2>&1; grep -iE "error|fail|cannot import" /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t5.log | head +``` + +Expected: FAIL with `ImportError: cannot import name 'MyEnergiAPI'`. + +- [ ] **Step 3: Implement the attribute table and component core** + +Append to `apps/predbat/myenergi.py`: + +```python +# Attribute table for the published Home Assistant entities, in the style of ohme.py +myenergi_attribute_table = { + "status": {"friendly_name": "myenergi Status", "icon": "mdi:information-outline"}, + "mode": {"friendly_name": "myenergi Mode", "icon": "mdi:ev-station"}, + "plug_status": {"friendly_name": "myenergi Plug Status", "icon": "mdi:ev-plug-type2"}, + "power": {"friendly_name": "myenergi Power", "icon": "mdi:lightning-bolt", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + "session_energy": {"friendly_name": "myenergi Session Energy", "icon": "mdi:lightning-bolt", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total_increasing"}, + "charging": {"friendly_name": "myenergi Charging", "icon": "mdi:battery-charging"}, + "boost": {"friendly_name": "myenergi Boost", "icon": "mdi:rocket-launch"}, + "boost_energy": {"friendly_name": "myenergi Boost Energy", "icon": "mdi:rocket-launch", "unit_of_measurement": "kWh", "min": BOOST_ENERGY_MIN, "max": BOOST_ENERGY_MAX, "step": 1}, + "boost_minutes": {"friendly_name": "myenergi Boost Minutes", "icon": "mdi:rocket-launch", "unit_of_measurement": "minutes", "min": BOOST_MINUTES_MIN, "max": BOOST_MINUTES_MAX, "step": 5}, + "temp_1": {"friendly_name": "myenergi Temperature 1", "icon": "mdi:thermometer", "unit_of_measurement": "°C", "device_class": "temperature", "state_class": "measurement"}, + "temp_2": {"friendly_name": "myenergi Temperature 2", "icon": "mdi:thermometer", "unit_of_measurement": "°C", "device_class": "temperature", "state_class": "measurement"}, +} + +DEFAULT_ZAPPI_BOOST_KWH = 10 +DEFAULT_EDDI_BOOST_MINUTES = 60 + + +class MyEnergiAPI(ComponentBase, OAuthMixin): + """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): + """Select a transport from the configured credentials and set up component state.""" + self.auth_method = (auth_method or "direct").lower() + self.hub_serial = hub_serial + self.api_key = api_key + self.automatic = automatic + self.enable_controls = enable_controls + # ComponentBase.start() calls run() on a fixed 60 second cadence, so the poll + # interval can only be a whole number of those intervals. + self.poll_seconds = max(60, int(round(_to_float(poll_seconds, 60) / 60.0)) * 60) + + self.devices = {} + self.boost_amounts = {} + self.queued_events = [] + self._auto_configured = False + self.transport = None + + if self.auth_method == "oauth": + self._init_oauth("oauth", key, token_expires_at, "myenergi") + self.token_hash = token_hash or "" + if not key and not token_hash: + self.log("Error: myenergi: auth_method is 'oauth' but neither myenergi_key nor myenergi_token_hash is set") + return + self.transport = MyEnergiCloudTransport(self.log, lambda: self.access_token) + else: + self._init_oauth("api_key", None, None, "myenergi") + if not hub_serial or not api_key: + self.log("Error: myenergi: auth_method is 'direct' but myenergi_hub_serial and myenergi_api_key are not both set") + return + self.transport = MyEnergiDirectTransport(self.log, hub_serial, api_key) + + def entity_prefix(self, device): + """Return the entity name prefix for a device, e.g. predbat_myenergi_zappi_12345678.""" + return "{}_myenergi_{}_{}".format(self.prefix, device.kind, device.serial) + + def boost_amount_for(self, device): + """Return the currently selected boost amount for a device.""" + default = DEFAULT_ZAPPI_BOOST_KWH if device.kind == DEVICE_KIND_ZAPPI else DEFAULT_EDDI_BOOST_MINUTES + return self.boost_amounts.get(device.device_id, default) + + async def run(self, seconds, first): + """Process queued control events, then poll and publish.""" + if first: + self.log("Info: myenergi: starting with the {} transport".format(self.auth_method)) + if not self.transport: + return False + + if self.auth_method == "oauth": + await self.check_and_refresh_oauth_token() + + refresh = False + while self.queued_events: + handler, *event_args = self.queued_events.pop(0) + try: + await handler(*event_args) + except MyEnergiError as exc: + self.log("Warn: myenergi: control failed: {}".format(exc)) + refresh = True + + if first or refresh or (seconds % self.poll_seconds) == 0: + try: + devices = await self.transport.fetch_devices() + except MyEnergiError as exc: + self.log("Warn: myenergi: poll failed: {}".format(exc)) + return False + if devices: + self.devices = {device.device_id: device for device in devices} + await self.publish_data() + elif first: + self.log("Warn: myenergi: connected but no Zappi or Eddi devices were found") + + self.update_success_timestamp() + return True + + async def publish_data(self): + """Publish every known device as Predbat entities.""" + for device in self.devices.values(): + prefix = self.entity_prefix(device) + self.dashboard_item("sensor.{}_status".format(prefix), state=device.status, attributes=myenergi_attribute_table["status"], app="myenergi") + self.dashboard_item("sensor.{}_power".format(prefix), state=device.power_w, attributes=myenergi_attribute_table["power"], app="myenergi") + self.dashboard_item("sensor.{}_session_energy".format(prefix), state=device.session_energy_kwh, attributes=myenergi_attribute_table["session_energy"], app="myenergi") + self.dashboard_item("switch.{}_boost".format(prefix), state="on" if device.boost_active else "off", attributes=myenergi_attribute_table["boost"], app="myenergi") + + if device.kind == DEVICE_KIND_ZAPPI: + self.dashboard_item("sensor.{}_mode".format(prefix), state=device.mode, attributes=myenergi_attribute_table["mode"], app="myenergi") + self.dashboard_item("sensor.{}_plug_status".format(prefix), state=device.plug_status, attributes=myenergi_attribute_table["plug_status"], app="myenergi") + self.dashboard_item("binary_sensor.{}_charging".format(prefix), state="on" if device.status == "Charging" else "off", attributes=myenergi_attribute_table["charging"], app="myenergi") + self.dashboard_item("number.{}_boost_energy".format(prefix), state=self.boost_amount_for(device), attributes=myenergi_attribute_table["boost_energy"], app="myenergi") + else: + self.dashboard_item("number.{}_boost_minutes".format(prefix), state=self.boost_amount_for(device), attributes=myenergi_attribute_table["boost_minutes"], app="myenergi") + if device.temp_1 is not None: + self.dashboard_item("sensor.{}_temp_1".format(prefix), state=device.temp_1, attributes=myenergi_attribute_table["temp_1"], app="myenergi") + if device.temp_2 is not None: + self.dashboard_item("sensor.{}_temp_2".format(prefix), state=device.temp_2, attributes=myenergi_attribute_table["temp_2"], app="myenergi") +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t5.log 2>&1; tail -30 /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t5.log +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/treforsouthwell/batpred2 && ./run_pre_commit +git add apps/predbat/myenergi.py apps/predbat/tests/test_myenergi.py +git commit -m "Add the myenergi component core with polling and entity publishing + +Selects a transport from the configured credentials, polls on the component base +cadence and publishes per-device entities. A failed poll returns False without +republishing, so the last good reading survives a transient outage. + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 6: Component registration and configuration schema + +**Files:** +- Modify: `apps/predbat/components.py` (import block near line 36, `COMPONENT_LIST` — add after the `"ohme"` entry) +- Modify: `apps/predbat/config.py` (`APPS_SCHEMA`, near the existing `fox_*` keys around line 2510) +- Modify: `apps/predbat/tests/test_myenergi.py` + +**Interfaces:** +- Consumes: `MyEnergiAPI` from Task 5. +- Produces: the `"myenergi"` key in `COMPONENT_LIST` with `event_filter` `"predbat_myenergi_"`; nine `myenergi_*` keys in `APPS_SCHEMA`. + +- [ ] **Step 1: Write the failing registration test** + +Append to `apps/predbat/tests/test_myenergi.py`: + +```python +def test_component_registration(): + """The component is registered with matching config keys and event filter.""" + from components import COMPONENT_LIST + from config import APPS_SCHEMA + + entry = COMPONENT_LIST["myenergi"] + assert entry["class"] is MyEnergiAPI + assert entry["event_filter"] == "predbat_myenergi_" + assert entry["phase"] == 1 + assert entry["can_restart"] is True + assert entry["required_or"] == ["api_key", "key"] + + # Every declared arg must name a config key that exists in the schema, and every + # arg must be accepted by initialize() + import inspect + + parameters = inspect.signature(MyEnergiAPI.initialize).parameters + for arg_name, spec in entry["args"].items(): + assert arg_name in parameters, "initialize() has no parameter '{}'".format(arg_name) + assert spec["config"] in APPS_SCHEMA, "{} missing from APPS_SCHEMA".format(spec["config"]) + print(" ✓ Component registration and schema keys") +``` + +Register it in `test_myenergi()`. + +- [ ] **Step 2: Run to verify it fails** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t6.log 2>&1; grep -iE "KeyError|error|fail" /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t6.log | head +``` + +Expected: FAIL with `KeyError: 'myenergi'`. + +- [ ] **Step 3: Register the component** + +In `apps/predbat/components.py`, add to the import block: + +```python +from myenergi import MyEnergiAPI +``` + +And add to `COMPONENT_LIST`, after the `"ohme"` entry: + +```python + "myenergi": { + "class": MyEnergiAPI, + "name": "myenergi", + "event_filter": "predbat_myenergi_", + "args": { + "auth_method": {"required": False, "config": "myenergi_auth_method", "default": "direct"}, + "hub_serial": {"required": False, "config": "myenergi_hub_serial"}, + "api_key": {"required": False, "config": "myenergi_api_key"}, + "key": {"required": False, "config": "myenergi_key"}, + "token_expires_at": {"required": False, "config": "myenergi_token_expires_at"}, + "token_hash": {"required": False, "config": "myenergi_token_hash"}, + "automatic": {"required": False, "config": "myenergi_automatic", "default": True}, + "enable_controls": {"required": False, "config": "myenergi_enable_controls", "default": True}, + "poll_seconds": {"required": False, "config": "myenergi_poll_seconds", "default": 60}, + }, + "required_or": ["api_key", "key"], + "phase": 1, + "can_restart": True, + }, +``` + +- [ ] **Step 4: Add the schema keys** + +In `apps/predbat/config.py`, add to `APPS_SCHEMA` near the `fox_*` keys: + +```python + "myenergi_auth_method": {"type": "string", "empty": False}, + "myenergi_hub_serial": {"type": "string", "empty": False}, + "myenergi_api_key": {"type": "string", "empty": False}, + "myenergi_key": {"type": "string", "empty": False}, + "myenergi_token_expires_at": {"type": "string", "empty": False}, + "myenergi_token_hash": {"type": "string", "empty": False}, + "myenergi_automatic": {"type": "boolean"}, + "myenergi_enable_controls": {"type": "boolean"}, + "myenergi_poll_seconds": {"type": "integer", "zero": False}, +``` + +- [ ] **Step 5: Run the myenergi tests and the config validation tests** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi --test validate_config --test plugin_startup > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t6.log 2>&1; tail -30 /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t6.log +``` + +Expected: PASS for all three. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/treforsouthwell/batpred2 && ./run_pre_commit +git add apps/predbat/components.py apps/predbat/config.py apps/predbat/tests/test_myenergi.py +git commit -m "Register the myenergi component and its apps.yaml schema + +Adds the COMPONENT_LIST entry with required_or on the two credential sets so the +component only starts when one transport is fully configured, plus the matching +APPS_SCHEMA keys. + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 7: Automatic configuration + +**Files:** +- Modify: `apps/predbat/myenergi.py` +- Modify: `apps/predbat/tests/test_myenergi.py` + +**Interfaces:** +- Consumes: `MyEnergiAPI`, `entity_prefix()` from Task 5. +- Produces: `MyEnergiAPI.automatic_config()`, called once from `run()` after the first successful poll. + +Behaviour: Zappi session energy entities go to `car_charging_energy` (a list when there is more than one Zappi, since `minute_data_import_export` accepts and sums a list); the first Eddi's session energy entity goes to `iboost_energy_today`. Both use `set_arg_auto()` so an explicit apps.yaml value is reported rather than silently replaced. + +- [ ] **Step 1: Write the failing auto-config tests** + +Append to `apps/predbat/tests/test_myenergi.py`: + +```python +def test_automatic_config(): + """Zappis wire into car_charging_energy and the Eddi into iboost_energy_today.""" + component = _make_component() + second_zappi = dict(MOCK_DIRECT_ZAPPI, sno=22223333) + component.devices = { + "Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI), + "Z22223333": normalise_direct_device(second_zappi, DEVICE_KIND_ZAPPI), + "E87654321": normalise_direct_device(MOCK_DIRECT_EDDI, DEVICE_KIND_EDDI), + } + component.automatic_config() + + assert component.base.args["car_charging_energy"] == [ + "sensor.predbat_myenergi_zappi_12345678_session_energy", + "sensor.predbat_myenergi_zappi_22223333_session_energy", + ], component.base.args["car_charging_energy"] + assert component.base.args["iboost_energy_today"] == "sensor.predbat_myenergi_eddi_87654321_session_energy" + print(" ✓ Automatic configuration wires both energy inputs") + + +def test_automatic_config_single_zappi_is_still_a_list(): + """A single Zappi still produces a list, so adding a second changes nothing else.""" + component = _make_component() + component.devices = {"Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)} + component.automatic_config() + assert component.base.args["car_charging_energy"] == ["sensor.predbat_myenergi_zappi_12345678_session_energy"] + assert "iboost_energy_today" not in component.base.args + print(" ✓ Single Zappi auto-config") + + +def test_automatic_config_disabled(): + """With automatic off, nothing is wired even after a successful poll.""" + component = _make_component(automatic=False) + component.transport.fetch_devices = AsyncMock(return_value=[normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)]) + run_async(component.run(0, True)) + assert "car_charging_energy" not in component.base.args + print(" ✓ Automatic configuration respects the off switch") + + +def test_automatic_config_runs_once(): + """Auto-config runs after the first poll and is not repeated.""" + component = _make_component() + component.transport.fetch_devices = AsyncMock(return_value=[normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)]) + run_async(component.run(0, True)) + assert component._auto_configured is True + component.base.args["car_charging_energy"] = ["sensor.user_override"] + run_async(component.run(60, False)) + assert component.base.args["car_charging_energy"] == ["sensor.user_override"], "Auto-config must not run twice" + print(" ✓ Automatic configuration runs exactly once") +``` + +Register the four tests in `test_myenergi()`. + +- [ ] **Step 2: Run to verify it fails** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t7.log 2>&1; grep -iE "AttributeError|error|fail" /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t7.log | head +``` + +Expected: FAIL with `AttributeError: 'MyEnergiAPI' object has no attribute 'automatic_config'`. + +- [ ] **Step 3: Implement automatic configuration** + +Add to `MyEnergiAPI` in `apps/predbat/myenergi.py`: + +```python + def automatic_config(self): + """Wire the device energy sensors into Predbat's load inputs. + + Zappi charging energy is subtracted from house load as car charging, so it + goes to car_charging_energy - as a list, because minute_data_import_export + accepts one and sums the entities. Eddi diverted energy feeds the iboost + model instead. + + Note that these sensors are session-scoped and reset to zero when a session + ends. get_from_incrementing() clamps negative deltas to zero so the per-minute + subtraction is unaffected, but the iboost_today total derived in fetch.py from + the midnight-to-now difference will under-report after a mid-day Eddi reset. + This is a known limitation, documented in docs/components.md. + """ + zappi_entities = [] + eddi_entity = None + for device in sorted(self.devices.values(), key=lambda item: item.serial): + entity = "sensor.{}_session_energy".format(self.entity_prefix(device)) + if device.kind == DEVICE_KIND_ZAPPI: + zappi_entities.append(entity) + elif eddi_entity is None: + eddi_entity = entity + + if zappi_entities: + self.log("Info: myenergi: setting car_charging_energy to {}".format(zappi_entities)) + self.set_arg_auto("car_charging_energy", zappi_entities) + if eddi_entity: + self.log("Info: myenergi: setting iboost_energy_today to {}".format(eddi_entity)) + self.set_arg_auto("iboost_energy_today", eddi_entity) +``` + +And call it from `run()`, immediately after the successful publish: + +```python + if devices: + self.devices = {device.device_id: device for device in devices} + await self.publish_data() + if self.automatic and not self._auto_configured: + self.automatic_config() + self._auto_configured = True + elif first: +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t7.log 2>&1; tail -30 /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t7.log +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/treforsouthwell/batpred2 && ./run_pre_commit +git add apps/predbat/myenergi.py apps/predbat/tests/test_myenergi.py +git commit -m "Wire myenergi devices into car_charging_energy and iboost_energy_today + +Zappi session energy sensors are set as a list on car_charging_energy so several +chargers sum, and the first Eddi feeds iboost_energy_today. Uses set_arg_auto so +an explicit apps.yaml value is reported rather than silently replaced. + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 8: Boost controls + +**Files:** +- Modify: `apps/predbat/myenergi.py` +- Modify: `apps/predbat/tests/test_myenergi.py` + +**Interfaces:** +- Consumes: `MyEnergiAPI`, transports' `send_boost`/`cancel_boost`. +- Produces: `MyEnergiAPI.switch_event(entity_id, service)`, `number_event(entity_id, value)`, `switch_event_handler(entity_id, service)`, `number_event_handler(entity_id, value)`, `device_for_entity(entity_id)`. + +Events are queued onto `self.queued_events` rather than actioned inline, so API calls never run on the event thread — the same pattern `ohme.py` uses. + +- [ ] **Step 1: Write the failing control tests** + +Append to `apps/predbat/tests/test_myenergi.py`: + +```python +def test_controls_queue_rather_than_call(): + """Switch and number events queue for the run loop instead of calling inline.""" + component = _make_component() + component.devices = {"Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)} + component.transport.send_boost = AsyncMock(return_value=True) + + run_async(component.switch_event("switch.predbat_myenergi_zappi_12345678_boost", "turn_on")) + assert len(component.queued_events) == 1 + component.transport.send_boost.assert_not_called() + + component.transport.fetch_devices = AsyncMock(return_value=list(component.devices.values())) + run_async(component.run(60, False)) + component.transport.send_boost.assert_called_once() + assert component.queued_events == [] + print(" ✓ Control events queue for the run loop") + + +def test_boost_uses_number_entity_value(): + """The boost amount comes from the companion number entity.""" + component = _make_component() + device = normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI) + component.devices = {"Z12345678": device} + component.transport.send_boost = AsyncMock(return_value=True) + + # number_event only queues, so drain the queue the way run() would + run_async(component.number_event("number.predbat_myenergi_zappi_12345678_boost_energy", 25)) + handler, *event_args = component.queued_events.pop(0) + run_async(handler(*event_args)) + + run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_12345678_boost", "turn_on")) + + component.transport.send_boost.assert_called_once_with(device, 25) + print(" ✓ Boost uses the number entity value") + + +def test_boost_refused_in_fast_mode(): + """A Zappi outside Eco or Eco+ is not boosted, and no API call is made.""" + component = _make_component() + fast = normalise_direct_device(dict(MOCK_DIRECT_ZAPPI, zmo=1), DEVICE_KIND_ZAPPI) + assert fast.mode == "Fast" + component.devices = {"Z12345678": fast} + component.transport.send_boost = AsyncMock(return_value=True) + + run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_12345678_boost", "turn_on")) + component.transport.send_boost.assert_not_called() + print(" ✓ Boost refused outside Eco and Eco+") + + +def test_cancel_boost(): + """Turning the switch off cancels the boost.""" + component = _make_component() + device = normalise_direct_device(dict(MOCK_DIRECT_EDDI, bsm=1, sta=4), DEVICE_KIND_EDDI) + component.devices = {"E87654321": device} + component.transport.cancel_boost = AsyncMock(return_value=True) + + run_async(component.switch_event_handler("switch.predbat_myenergi_eddi_87654321_boost", "turn_off")) + component.transport.cancel_boost.assert_called_once_with(device) + print(" ✓ Cancel boost") + + +def test_controls_disabled(): + """With enable_controls off, events are ignored entirely.""" + component = _make_component(enable_controls=False) + component.devices = {"Z12345678": normalise_direct_device(MOCK_DIRECT_ZAPPI, DEVICE_KIND_ZAPPI)} + component.transport.send_boost = AsyncMock(return_value=True) + + run_async(component.switch_event("switch.predbat_myenergi_zappi_12345678_boost", "turn_on")) + assert component.queued_events == [] + run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_12345678_boost", "turn_on")) + component.transport.send_boost.assert_not_called() + print(" ✓ Controls respect enable_controls") + + +def test_control_for_unknown_entity_is_ignored(): + """An event for a device that is not known does nothing and does not raise.""" + component = _make_component() + component.transport.send_boost = AsyncMock(return_value=True) + run_async(component.switch_event_handler("switch.predbat_myenergi_zappi_99999999_boost", "turn_on")) + component.transport.send_boost.assert_not_called() + print(" ✓ Unknown entity events are ignored") +``` + +Register the six tests in `test_myenergi()`. + +- [ ] **Step 2: Run to verify it fails** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t8.log 2>&1; grep -iE "AttributeError|error|fail" /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t8.log | head +``` + +Expected: FAIL — `switch_event_handler` does not exist (the base class's no-op `switch_event` swallows the call, so `queued_events` stays empty). + +- [ ] **Step 3: Implement the controls** + +Add to `MyEnergiAPI` in `apps/predbat/myenergi.py`: + +```python + def device_for_entity(self, entity_id): + """Find the device an entity belongs to, or None when it is not known.""" + for device in self.devices.values(): + if self.entity_prefix(device) in entity_id: + return device + return None + + async def switch_event(self, entity_id, service): + """Queue a switch service call for the run loop.""" + if not self.enable_controls: + return + self.queued_events.append((self.switch_event_handler, entity_id, service)) + + async def number_event(self, entity_id, value): + """Queue a number change for the run loop.""" + if not self.enable_controls: + return + self.queued_events.append((self.number_event_handler, entity_id, value)) + + async def number_event_handler(self, entity_id, value): + """Record a new boost amount for the device the entity belongs to.""" + device = self.device_for_entity(entity_id) + if not device: + return + if device.kind == DEVICE_KIND_ZAPPI: + amount = int(_to_float(value, DEFAULT_ZAPPI_BOOST_KWH)) + amount = max(BOOST_ENERGY_MIN, min(BOOST_ENERGY_MAX, amount)) + else: + amount = int(_to_float(value, DEFAULT_EDDI_BOOST_MINUTES)) + amount = max(BOOST_MINUTES_MIN, min(BOOST_MINUTES_MAX, amount)) + self.boost_amounts[device.device_id] = amount + + async def switch_event_handler(self, entity_id, service): + """Send or cancel a boost in response to the boost switch.""" + if not self.enable_controls: + return + if not entity_id.endswith("_boost"): + return + device = self.device_for_entity(entity_id) + if not device: + self.log("Warn: myenergi: no known device for {}".format(entity_id)) + return + + if service == "turn_on": + # myenergi rejects a boost unless the Zappi is in one of the green modes + if device.kind == DEVICE_KIND_ZAPPI and device.mode not in ZAPPI_BOOSTABLE_MODES: + self.log("Warn: myenergi: cannot boost {} while it is in {} mode - boost needs Eco or Eco+".format(device.name, device.mode)) + return + amount = self.boost_amount_for(device) + self.log("Info: myenergi: boosting {} by {}".format(device.name, amount)) + await self.transport.send_boost(device, amount) + elif service == "turn_off": + self.log("Info: myenergi: cancelling boost on {}".format(device.name)) + await self.transport.cancel_boost(device) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --test myenergi > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t8.log 2>&1; tail -35 /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t8.log +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/treforsouthwell/batpred2 && ./run_pre_commit +git add apps/predbat/myenergi.py apps/predbat/tests/test_myenergi.py +git commit -m "Add myenergi send and cancel boost controls + +Boost switches with a companion number entity for the amount, queued onto the run +loop so API calls never run on the event thread. A Zappi outside Eco or Eco+ is +refused locally rather than issuing a call myenergi would reject. + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +### Task 9: Command line test interface and documentation + +**Files:** +- Modify: `apps/predbat/myenergi.py` +- Modify: `docs/components.md` +- Modify: `docs/apps-yaml.md` + +**Interfaces:** +- Consumes: `MyEnergiAPI`, `MockBase`. +- Produces: module-level `test_myenergi_api(...)` coroutine and `main()`, guarded by `if __name__ == "__main__":`. Both are `# pragma: no cover`. + +- [ ] **Step 1: Implement the CLI harness** + +Append to `apps/predbat/myenergi.py`: + +```python +async def run_myenergi_cli(args): # pragma: no cover + """Run one myenergi poll, and optionally a boost command, against the live API.""" + mock_base = MockBase() + arg_dict = { + "auth_method": "oauth" if args.token else "direct", + "hub_serial": args.hub_serial, + "api_key": args.api_key, + "key": args.token, + "token_hash": args.token_hash, + "automatic": False, + "enable_controls": True, + } + component = MyEnergiAPI(mock_base, **arg_dict) + if not component.transport: + print("No usable credentials - pass --hub-serial and --api-key, or --token") + return + + print("Connecting with the {} transport...".format(component.auth_method)) + devices = await component.transport.fetch_devices() + if not devices: + print("No Zappi or Eddi devices found") + return + + if args.raw: + for device in devices: + print(device) + else: + print("{:<12} {:<10} {:<16} {:<10} {:>10} {:>12}".format("DEVICE", "KIND", "STATUS", "MODE", "POWER W", "SESSION kWh")) + for device in devices: + print("{:<12} {:<10} {:<16} {:<10} {:>10.0f} {:>12.2f}".format(device.device_id, device.kind, device.status, device.mode, device.power_w, device.session_energy_kwh)) + + target_kind = args.boost or args.cancel_boost + if target_kind: + device = next((item for item in devices if item.kind == target_kind), None) + if not device: + print("No {} device found to control".format(target_kind)) + return + if args.boost: + print("Boosting {} by {}...".format(device.name, args.amount)) + await component.transport.send_boost(device, args.amount) + else: + print("Cancelling boost on {}...".format(device.name)) + await component.transport.cancel_boost(device) + print("Done") + + +def main(): # pragma: no cover + """Main function for command line execution.""" + parser = argparse.ArgumentParser(description="Test the myenergi API") + parser.add_argument("--hub-serial", action="store", default=None, help="myenergi hub serial number (direct transport)") + parser.add_argument("--api-key", action="store", default=None, help="myenergi API key from myaccount.myenergi.com (direct transport)") + parser.add_argument("--token", action="store", default=None, help="myenergi OAuth access token (cloud transport)") + parser.add_argument("--token-hash", action="store", default=None, help="myenergi OAuth token hash for refresh (cloud transport)") + parser.add_argument("--boost", choices=SUPPORTED_KINDS, default=None, help="Send a boost to the first matching device") + parser.add_argument("--cancel-boost", choices=SUPPORTED_KINDS, default=None, help="Cancel a boost on the first matching device") + parser.add_argument("--amount", type=int, default=DEFAULT_ZAPPI_BOOST_KWH, help="Boost amount: kWh for a Zappi, minutes for an Eddi") + parser.add_argument("--raw", action="store_true", help="Print the full normalised device records") + + args = parser.parse_args() + asyncio.run(run_myenergi_cli(args)) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Verify the harness runs** + +```bash +cd /Users/treforsouthwell/batpred2/apps/predbat && ../../coverage/venv/bin/python myenergi.py --help > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t9.log 2>&1; cat /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t9.log +``` + +Expected: the argparse help text, listing every option above. No credentials are needed for `--help`. + +Then confirm the no-credentials path exits cleanly: + +```bash +cd /Users/treforsouthwell/batpred2/apps/predbat && ../../coverage/venv/bin/python myenergi.py >> /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t9.log 2>&1; tail -5 /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/t9.log +``` + +Expected: `No usable credentials - pass --hub-serial and --api-key, or --token`, exit 0. + +- [ ] **Step 3: Document the component** + +Add to `docs/components.md`, following the layout of the existing sections (`### ()`, then `#### What it does`, `#### When to enable`, `#### Configuration Options`, and any extra subsections, each heading suffixed with the component key): + +````markdown +### myenergi (myenergi) + +#### What it does (myenergi) + +Monitors myenergi Zappi EV chargers and Eddi hot water diverters, publishing their +status, power and session energy as Predbat entities, and provides send-boost and +cancel-boost controls. + +Predbat supports both of myenergi's APIs: + +- **Direct** (default) — HTTP digest authentication against `director.myenergi.net`, + using your hub serial number and an API key you generate yourself. This is the same + API the `ha-myenergi` Home Assistant integration uses, and any myenergi owner can + set it up today. +- **Cloud OAuth** — the official 3rd party API at `api.s18.myenergi.net`. This needs + credentials issued by myenergi through their partner registration process. + +#### When to enable (myenergi) + +Enable it if you own a Zappi or an Eddi and want Predbat to account for their energy +use when planning. With `myenergi_automatic` on (the default), Predbat wires the +sensors up for you: + +- Zappi session energy is set as `car_charging_energy`, so charging is subtracted + from your house load rather than being learnt as base load. Turn on + `car_charging_hold` for that subtraction to take effect. +- The first Eddi's session energy is set as `iboost_energy_today`, feeding the + iboost model. + +#### Configuration Options (myenergi) + +| Option | Default | Description | +|---|---|---| +| `myenergi_auth_method` | `direct` | `direct` or `oauth` | +| `myenergi_hub_serial` | — | Hub serial number, direct transport | +| `myenergi_api_key` | — | API key from myaccount.myenergi.com, direct transport | +| `myenergi_key` | — | OAuth access token, cloud transport | +| `myenergi_token_hash` | — | OAuth token hash used for refresh, cloud transport | +| `myenergi_token_expires_at` | — | OAuth access token expiry, cloud transport | +| `myenergi_automatic` | `True` | Wire the energy sensors into Predbat automatically | +| `myenergi_enable_controls` | `True` | Set to `False` for monitor-only operation | +| `myenergi_poll_seconds` | `60` | Poll interval, rounded up to a multiple of 60 | + +Example for the direct transport: + +```yaml +myenergi_hub_serial: '12345678' +myenergi_api_key: 'your-api-key' +``` + +#### How to get your API key (myenergi) + +1. Sign in at . +2. Open **Advanced** then **API Key**. +3. Generate a key for your hub and copy it. +4. Your hub serial number is printed on the hub and shown in the myenergi app. + +#### Published entities (myenergi) + +Per Zappi (`{sn}` is the device serial number): + +- `sensor.predbat_myenergi_zappi_{sn}_status`, `_mode`, `_plug_status`, `_power`, `_session_energy` +- `binary_sensor.predbat_myenergi_zappi_{sn}_charging` +- `switch.predbat_myenergi_zappi_{sn}_boost`, `number.predbat_myenergi_zappi_{sn}_boost_energy` + +Per Eddi: + +- `sensor.predbat_myenergi_eddi_{sn}_status`, `_power`, `_session_energy`, `_temp_1`, `_temp_2` +- `switch.predbat_myenergi_eddi_{sn}_boost`, `number.predbat_myenergi_eddi_{sn}_boost_minutes` + +Temperature sensors are only published when a probe is connected. + +#### Controls (myenergi) + +Turning a boost switch on sends a boost of the amount selected on the companion +number entity — kWh for a Zappi, minutes for an Eddi. Turning it off cancels the +boost. The switch state is read back from the device, so a boost started or stopped +in the myenergi app is reflected here too. + +myenergi only accepts a Zappi boost while the charger is in Eco or Eco+ mode. +Predbat checks this first and logs a warning rather than issuing a call that would +be rejected. + +Not implemented in this release: mode selection, priority, minimum green level, +phase setting, charging schedules, super schedules, managed mode, and Libbi +batteries. Attempting one of these logs a warning. + +#### Known limitation (myenergi) + +The session energy sensors reset to zero when a charging or heating session ends. +Predbat's per-minute load subtraction handles that correctly, so `car_charging_energy` +is unaffected. However, the `iboost_today` total is derived from the difference +between the midnight and current readings, so it will under-report if an Eddi session +resets part-way through the day. The planner's behaviour is unaffected; only the +reported daily iboost total is. +```` + +Add the section to the Table of Contents at the top of `docs/components.md`. + +- [ ] **Step 4: Document the apps.yaml keys** + +Add the nine `myenergi_*` keys to `docs/apps-yaml.md`, following the format used for the neighbouring `fox_*` and `ohme_*` keys in that file. + +- [ ] **Step 5: Run the full test suite** + +```bash +cd /Users/treforsouthwell/batpred2/coverage && ./run_all --quick > /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/full.log 2>&1; tail -30 /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/full.log; grep -icE "^FAILED|Traceback" /private/tmp/claude-501/-Users-treforsouthwell-batpred2/7ef137ca-438c-4434-b5ef-9af8b509c595/scratchpad/full.log +``` + +Expected: every test passes and the grep count is 0. Any pre-existing failure must be confirmed as pre-existing by checking out `main` and re-running before it is dismissed. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/treforsouthwell/batpred2 && ./run_pre_commit +git add apps/predbat/myenergi.py docs/components.md docs/apps-yaml.md +git commit -m "Add the myenergi command line harness and documentation + +Standalone CLI for exercising either transport against the live API, plus the +components and apps.yaml documentation, including the iboost_today limitation +that follows from the session-scoped energy sensors. + +Co-Authored-By: Claude Opus 5 (1M context) " +``` + +--- + +## Self-Review Notes + +Checked against the spec: + +- §2 both APIs → Tasks 3 and 4, with the exact endpoints in the Global Constraints reference table. +- §3 architecture and transport selection → Tasks 2, 3, 4 and 5. +- §3.1 normalised device model → Task 1. +- §4 configuration → Task 6. +- §5 published entities → Task 5. The `_boosting` binary sensors are absent, matching the spec's note that the boost switch already carries that state. +- §6 automatic configuration and §6.1 the known limitation → Task 7 (docstring) and Task 9 (documentation). +- §7 controls and §7.1 stubs → Tasks 8 and 2. +- §8 polling and error handling → Task 5 (`poll_seconds` rounding, last-good-reading retention, `record_api_call` in Tasks 3 and 4). +- §9 CLI → Task 9. +- §10 testing — all nine listed areas are covered: normalisation (1), direct transport (3), cloud transport (4), transport selection (5), publishing (5), auto-config (7), controls (8), stubs (2), error handling (5). +- §11 documentation → Tasks 1 (cspell) and 9 (components.md, apps-yaml.md). +- §12 out of scope — nothing in the plan implements Libbi, webhooks or schedules. + +Type consistency: `MyEnergiDevice` field names are used identically in Tasks 1, 3, 4, 5, 7 and 8. `send_boost(device, amount, target_time=None)` and `cancel_boost(device)` keep the same signature in the ABC (Task 2) and both implementations (Tasks 3, 4), and are called with two positional arguments from Task 8. `entity_prefix(device)` is defined in Task 5 and used in Tasks 7 and 8. + +One deliberate cross-transport asymmetry is flagged in Task 4: `target_time` is `HH:MM` for the direct transport and ISO-8601 for the cloud one. Nothing in this release passes it, so the divergence stays inside the transports; unifying it is a prerequisite for any future smart-boost control. diff --git a/docs/superpowers/specs/2026-08-23-myenergi-integration-design.md b/docs/superpowers/specs/2026-08-23-myenergi-integration-design.md new file mode 100644 index 000000000..25bb3e4fd --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-myenergi-integration-design.md @@ -0,0 +1,440 @@ +# myenergi Integration — Design + +Date: 2026-08-23 +Status: Approved for implementation planning + +## 1. Purpose + +Add myenergi Zappi (EV charger) and Eddi (hot water diverter) support to Predbat as a +pluggable component. Scope for this first implementation: + +- Monitoring of Zappi and Eddi devices, published as Predbat entities. +- Automatic configuration wiring the device energy sensors into `car_charging_energy` + (Zappi) and `iboost_energy_today` (Eddi). +- Send-boost and cancel-boost controls, exposed as switches with a companion number + entity for the boost amount. +- Documented stubs for every other control (mode, priority, minimum green level, + schedules, Libbi) so the shape of the interface is fixed before the work lands. +- A command line test interface, matching `fox.py`, `axle.py` and the other components. + +Libbi is explicitly out of scope. Webhooks are out of scope; this release polls. + +## 2. The two myenergi APIs + +myenergi exposes two unrelated APIs, and Predbat needs both. + +| | 3rd-party API | Direct ("director") API | +|---|---|---| +| Host | `api.s18.myenergi.net`, auth at `auth.s18.myenergi.net` | `director.myenergi.net`, redirecting to the user's active server | +| Auth | OAuth2 authorization_code, bearer JWT | HTTP Digest: username = hub serial, password = API key | +| Credentials | `client_id`/`client_secret` issued by manual myenergi partner registration | Self-served by the user at myaccount.myenergi.com | +| Protocol | REST/JSON | CGI-style GET endpoints returning JSON | +| Eddi | Documented as "support currently in development" | Fully supported | +| Token life | Access token 1 day, refresh token 1 year | n/a | +| Reference | | `pymyenergi`, as used by `cjne/ha-myenergi` | + +The direct API is the only one a self-hosted Home Assistant user can set up today. The +3rd-party API is the only one usable at scale by Predbat.com, and it is the officially +supported route. Supporting only one of them would strand one of the two audiences. + +### 2.1 Direct API endpoints used + +The hub redirects clients to a per-account server. `director.myenergi.net` returns an +`X_MYENERGI-asn` response header naming the real host (e.g. `s18.myenergi.net`); all +subsequent requests go there, and the value is re-read on every response so a server +migration is followed automatically. + +| Purpose | Endpoint | +|---|---| +| All device status | `GET /cgi-jstatus-*` | +| Single device status | `GET /cgi-jstatus-{P}{serial}` where `{P}` is `Z` or `E` | +| Day history (hourly) | `GET /cgi-jdayhour-{P}{serial}-{yyyy}-{m}-{d}-{hour}-{hours}` | +| Zappi manual boost | `GET /cgi-zappi-mode-Z{serial}-0-10-{kwh}-0000` | +| Zappi smart boost | `GET /cgi-zappi-mode-Z{serial}-0-11-{kwh}-{hhmm}` | +| Zappi cancel boost | `GET /cgi-zappi-mode-Z{serial}-0-2-0-0000` | +| Zappi set mode | `GET /cgi-zappi-mode-Z{serial}-{mode}-0-0-0000` (stub) | +| Eddi boost | `GET /cgi-eddi-boost-E{serial}-10-{target}-{minutes}` | +| Eddi cancel boost | `GET /cgi-eddi-boost-E{serial}-1-{target}-0` | +| Eddi set mode | `GET /cgi-eddi-mode-E{serial}-{0\|1}` (stub) | + +Boost targets are `heater1: 1`, `heater2: 2`, `relay1: 11`, `relay2: 12`. Only `heater1` +is used in this release. + +Zappi charge modes are indexed `["None", "Fast", "Eco", "Eco+", "Stopped"]`; Zappi states +are `["Unkn0", "Paused", "Unkn2", "Charging", "Boosting", "Completed"]`; Eddi states are +`["Unkn0", "Paused", "Unkn2", "Diverting", "Boosting", "Max temp reached", "Stopped"]`. + +Relevant raw JSON fields: `sno` serial, `sta` state index, `zmo` Zappi charge mode index, +`pst` plug state, `che` session energy in kWh, `div` diverted power in W, `grd` grid power, +`gen` generated power, `vol` voltage in decivolts, `frq` frequency, `rbt` Eddi remaining +boost seconds, `bsm` Eddi boosting flag, `tp1`/`tp2` Eddi temperatures, `hno` Eddi active +heater. + +### 2.2 3rd-party API endpoints used + +| Purpose | Endpoint | +|---|---| +| Token exchange / refresh | `POST https://auth.s18.myenergi.net/oauth2/token` | +| Device list | `GET /devices` | +| Device status | `GET /devices/{id}/status` | +| Send boost | `POST /devices/{id}/boost` | +| Cancel boost | `DELETE /devices/{id}/boost` | +| Set mode | `POST /devices/{id}/mode` (stub) | +| History | `GET /devices/{id}/history` (stub) | + +Device IDs are the device class prefix plus serial, e.g. `ZA12345678`, `ED12345678`. + +Zappi boost body is `{"mode": "normal", "parameters": {"energy": <1-99 kWh>}}`, or +`{"mode": "smart", "parameters": {"energy": N, "targetTime": ""}}`. Eddi boost +body is `{"durationMinutes": <0-240>}`. Sending Zappi fields to an Eddi (or the reverse) +is rejected with a 400, so the transport selects the body by device class. + +Status fields used: `deviceClass`, `status`, `state`, `deviceStatus`, `supplyMode`, +`pilotState`, `boostCharge` (Zappi) / `boostActive` (Eddi), `actualPower`, `gridPower`, +`genPower`, `sessionEnergy`, `energyDelivered`, `timestamp`. Power is in kW and energy in +kWh, both of which are scaled to Predbat's expected units on normalisation. + +## 3. Architecture + +A single new module `apps/predbat/myenergi.py`, registered as component `myenergi`, built +around a transport abstraction so that everything above the wire format is written once. + +``` +MyEnergiAPI(ComponentBase, OAuthMixin) # lifecycle, polling, publishing, auto-config, controls + └── transport: MyEnergiTransport # abstract + ├── MyEnergiDirectTransport # digest auth, ASN redirect, /cgi-* endpoints + └── MyEnergiCloudTransport # bearer JWT via OAuthMixin, REST endpoints +``` + +`MyEnergiTransport` is the only place that knows about wire formats. It exposes: + +```python +async def connect(self) -> bool +async def fetch_devices(self) -> list[MyEnergiDevice] +async def send_boost(self, device, amount, target_time=None) -> bool +async def cancel_boost(self, device) -> bool +async def set_mode(self, device, mode) -> bool # stub +async def set_priority(self, device, priority) -> bool # stub +async def set_min_green_level(self, device, level) # stub +async def get_schedule(self, device) # stub +async def set_schedule(self, device, schedule) # stub +``` + +Stub methods log a single "not implemented in this release" warning and return `False`. +They exist so the interface is settled and the follow-up work is additive. + +### 3.1 Normalised device model + +Both transports return the same dataclass, so the publishing and control layers never +branch on transport: + +```python +@dataclass +class MyEnergiDevice: + device_id: str # "Z12345678" direct, "ZA12345678" cloud + kind: str # "zappi" | "eddi" + serial: str + name: str + online: bool + status: str # normalised: charging / boosting / diverting / paused / ... + mode: str # zappi charge mode; eddi operating mode + plug_status: str # zappi only, "" for eddi + power_w: float # charging (zappi) or diverted (eddi) power + grid_power_w: float + generation_w: float + voltage: float + session_energy_kwh: float + boost_active: bool + boost_remaining_mins: int + temp_1: float | None # eddi only + temp_2: float | None # eddi only +``` + +Normalisation is two pure functions, `normalise_direct_device(raw, kind)` and +`normalise_cloud_device(raw, meta)`, testable without any network or component fixture. + +### 3.2 Transport selection + +`myenergi_auth_method` selects the transport: `direct` (default) or `oauth`. The +component validates at initialise time that the credentials for the chosen method are +present, and logs an actionable error naming the missing keys otherwise. + +The cloud transport reuses `oauth_mixin.py` exactly as `fox.py`, `deye.py` and `solis.py` +do: the access token arrives via `myenergi_key`, refresh is delegated to the +oauth-refresh edge function keyed by `myenergi_token_hash`, and Predbat never holds a +`client_secret`. `provider_name` is `"myenergi"`. Both refresh paths are wired - +`check_and_refresh_oauth_token()` proactively before each poll for a token that has +reached its stated expiry, and `handle_oauth_401()` reactively when a poll comes back +401, with the poll retried once behind it, for a token revoked before then. + +## 4. Configuration + +### 4.1 `COMPONENT_LIST` entry (`components.py`) + +```python +"myenergi": { + "class": MyEnergiAPI, + "name": "myenergi", + "event_filter": "predbat_myenergi_", + "args": { + "auth_method": {"required": False, "config": "myenergi_auth_method", "default": "direct"}, + "hub_serial": {"required": False, "config": "myenergi_hub_serial"}, + "api_key": {"required": False, "config": "myenergi_api_key"}, + "key": {"required": False, "config": "myenergi_key"}, + "token_expires_at": {"required": False, "config": "myenergi_token_expires_at"}, + "token_hash": {"required": False, "config": "myenergi_token_hash"}, + "automatic": {"required": False, "config": "myenergi_automatic", "default": True}, + "enable_controls": {"required": False, "config": "myenergi_enable_controls", "default": True}, + "poll_seconds": {"required": False, "config": "myenergi_poll_seconds", "default": 60}, + }, + "required_or": ["api_key", "key"], + "phase": 1, + "can_restart": True, +}, +``` + +`required_or` means the component only starts when the user has supplied credentials for +one transport or the other, matching how `axle` gates itself. + +### 4.2 `APPS_SCHEMA` additions (`config.py`) + +```python +"myenergi_auth_method": {"type": "string", "empty": False}, +"myenergi_hub_serial": {"type": "string", "empty": False}, +"myenergi_api_key": {"type": "string", "empty": False}, +"myenergi_key": {"type": "string", "empty": False}, +"myenergi_token_expires_at": {"type": "string", "empty": False}, +"myenergi_token_hash": {"type": "string", "empty": False}, +"myenergi_automatic": {"type": "boolean"}, +"myenergi_enable_controls": {"type": "boolean"}, +"myenergi_poll_seconds": {"type": "integer", "zero": False}, +``` + +### 4.3 apps.yaml examples + +Direct, the default for self-hosted users: + +```yaml +myenergi_hub_serial: '12345678' +myenergi_api_key: 'your-api-key-from-myaccount-myenergi-com' +``` + +Cloud OAuth: + +```yaml +myenergi_auth_method: 'oauth' +myenergi_key: '' +myenergi_token_hash: '' +myenergi_token_expires_at: '2026-09-01T00:00:00Z' +``` + +## 5. Published entities + +Entity names carry the serial so multi-device sites work without collisions, following +the `gecloud` per-device naming convention. + +Zappi, per device: + +| Entity | Notes | +|---|---| +| `sensor.predbat_myenergi_zappi_{sn}_status` | normalised status string | +| `sensor.predbat_myenergi_zappi_{sn}_mode` | Fast / Eco / Eco+ / Stopped | +| `sensor.predbat_myenergi_zappi_{sn}_plug_status` | EV connection state | +| `sensor.predbat_myenergi_zappi_{sn}_power` | W, `device_class: power` | +| `sensor.predbat_myenergi_zappi_{sn}_session_energy` | kWh, `device_class: energy` | +| `binary_sensor.predbat_myenergi_zappi_{sn}_charging` | | +| `switch.predbat_myenergi_zappi_{sn}_boost` | on = send boost, off = cancel boost | +| `number.predbat_myenergi_zappi_{sn}_boost_energy` | kWh, 1–99, default 10 | + +Eddi, per device: + +| Entity | Notes | +|---|---| +| `sensor.predbat_myenergi_eddi_{sn}_status` | | +| `sensor.predbat_myenergi_eddi_{sn}_power` | W | +| `sensor.predbat_myenergi_eddi_{sn}_session_energy` | kWh | +| `sensor.predbat_myenergi_eddi_{sn}_temp_1` / `_temp_2` | °C, omitted when unavailable | +| `switch.predbat_myenergi_eddi_{sn}_boost` | on = send boost, off = cancel boost | +| `number.predbat_myenergi_eddi_{sn}_boost_minutes` | minutes, 0–240, default 60 | + +There is deliberately no separate `_boosting` binary sensor: the boost switch's own +state is derived from the device's `boost_active`, so a second entity would only +duplicate it. + +All are published through `dashboard_item(..., app="myenergi")` with an attribute table +in the style of `ohme_attribute_table`. + +## 6. Automatic configuration + +Gated on `myenergi_automatic` (default true), run once after the first successful poll. + +- Zappi session energy sensors → `car_charging_energy`, as a list when more than one + Zappi is present. `minute_data_import_export` accepts a list and sums the entities. +- Zappi plug status sensors → `car_charging_planned`, as a list, which is indexed per + car so entry N is the Nth Zappi by serial. The regex the apps.yaml templates ship for + this key targets the third-party `ha-myenergi` integration's entity names, which do + not match the ones this component publishes, so without this the key fails to resolve + and Predbat silently falls back to the `car_charging_threshold` heuristic. The Zappi + pilot states `C1`/`D1` normalise to `EV ready to charge`, which the templates' + `car_charging_planned_response` lists did not carry and now do. +- Eddi session energy sensor → `iboost_energy_today`, first Eddi only. + +All use `set_arg_auto()` so that an explicit apps.yaml value is reported rather than +silently overwritten. + +Predbat reads these back from Home Assistant history as incrementing counters. Session +energy resets to zero at the end of each session, which `get_from_incrementing` handles +by clamping negative deltas to zero (`fetch.py:574`). + +### 6.1 Known limitation + +Session resets themselves are handled correctly. `iboost_energy_today` is read at +`fetch.py:782` as `abs(value[0] - value[minutes_now])`, but the series it reads has +already been through `minute_data_load(..., clean_increment=True)` → +`clean_incrementing_reverse()` (`utils.py:716-744`), which rebases the counter whenever +it detects a reset. A day of several Eddi sessions therefore totals correctly, and the +same holds for `car_charging_energy`. + +The residual limitation is narrower, and applies to both keys equally because the loss is +in the shared cumulative series. `minute_data()` only propagates a fall as a reset when it +is near midnight or at least 1.0 kWh (`utils.py:565`); anything smaller is interpolated +over as a dip in the data before `clean_incrementing_reverse()` (`utils.py:740`) ever sees +it. A session ending below roughly 1 kWh is therefore under-counted, and an intervening +zero reading does not rescue it — the dip is smoothed away first. Measured against +Predbat's own `minute_data`, two 0.6 kWh sessions +in a day total 0.600 kWh rather than 1.20 kWh, while two sessions of 2.0 and 1.5 kWh total +correctly. This is accepted for this release: it is a fraction of a kWh, and the planner +is driven by the larger sessions. The fix, if it is wanted later, is to derive the sensor +from the day-history endpoint (`/cgi-jdayhour-E{sn}-...` or `GET /devices/{id}/history`), +which both transports already reach. This is recorded in the documentation so the +behaviour is not mistaken for a bug. + +## 7. Controls + +The boost switch is a momentary-style control, following `ohme.py`'s `_approve_charge` +pattern: `turn_on` sends a boost, `turn_off` cancels it, and the published state is +re-derived from the device's own `boost_active` on the next poll rather than being held +locally. That way a boost started or stopped from the myenergi app is reflected correctly. + +Events arrive via `switch_event` / `number_event` and are queued onto `self.queued_events` +for the run loop rather than being actioned inside the event callback, exactly as +`ohme.py` does, so that API calls never run on the event thread. + +Boost amount comes from the companion number entity, so the switch itself carries no +parameters. Sending a Zappi boost while the charger is in Fast or Stopped mode is +rejected by the API; the component checks the mode first and logs a clear warning instead +of issuing a call it knows will fail. + +All controls are gated on `myenergi_enable_controls` (default true), so a user can run the +component in monitor-only mode. + +### 7.1 Stubbed controls + +Zappi mode select, priority, minimum green level, phase setting, lock settings, schedules +and super-schedules; Eddi mode, priority and heater priority; all Libbi support. Each is a +transport method that logs once and returns `False`, plus a line in the documentation +saying it is not yet implemented. + +## 8. Polling and error handling + +`ComponentBase.start()` calls `run(seconds, first)` on a fixed 60 second cadence once +started, so `myenergi_poll_seconds` (default 60) is rounded up to the nearest multiple of +60 and enforced inside `run()` by a `seconds % interval == 0` guard. It exists to let a +user back off polling on a multi-device site, not to poll faster than the base loop. + +The direct transport fetches all devices in a single `/cgi-jstatus-*` call. The cloud +transport caches `GET /devices` and refreshes it every 30 minutes, polling +`/devices/{id}/status` per device in between. + +- Digest auth failures and HTTP 401 are reported as configuration errors, not retried + tightly; `ComponentBase` already applies exponential startup backoff. +- A missing `X_MYENERGI-asn` header on the direct transport means bad credentials, and is + reported as such rather than as a transport failure. +- `update_success_timestamp()` is called on each successful poll so component health + monitoring works. +- API calls are wrapped with `record_api_call` from `predbat_metrics`, as the other + components do. +- The last good reading is retained when a poll returns nothing, so a transient failure + does not publish zeros into the energy sensors that feed `car_charging_energy`. + +## 9. Command line test interface + +`python3 myenergi.py` with `argparse`, following `fox.py`: + +``` +--hub-serial SERIAL --api-key KEY direct transport +--token KEY [--token-hash H] cloud transport +--boost {zappi,eddi} --amount N send a boost to the first matching device +--cancel-boost {zappi,eddi} cancel a boost +--raw dump the raw API response +``` + +Default behaviour with credentials only is to connect, run one poll, and print the +normalised device table. Uses `MockBase` from `mock_base.py`, is marked +`# pragma: no cover`, and never requires a running Predbat. + +## 10. Testing + +New file `apps/predbat/tests/test_myenergi.py`, exporting `test_myenergi()` and registered +in `TEST_REGISTRY` in `unit_test.py`. + +Coverage: + +1. **Normalisation** — direct and cloud raw payloads for Zappi and Eddi map to identical + `MyEnergiDevice` values, including unit conversion (kW→W, decivolts→volts) and the + `sta`/`zmo` index lookups. Out-of-range indices fall back safely rather than raising. +2. **Direct transport** — ASN redirect is followed and re-read; a missing `X_MYENERGI-asn` + header is treated as an auth failure; boost and cancel produce the exact expected URLs + for both device kinds. +3. **Cloud transport** — correct boost body per device class; Eddi never receives `mode` + or `parameters`, Zappi never receives `durationMinutes`; bearer header is set. +4. **Transport selection** — `auth_method` picks the right class; missing credentials + produce a clear error and no start. +5. **Publishing** — entity names, units and device classes for a two-Zappi one-Eddi site; + temperatures omitted when unavailable. +6. **Auto-config** — `car_charging_energy` becomes a list for multiple Zappis; + `iboost_energy_today` is set for the Eddi; nothing is set when `myenergi_automatic` is + false; `set_arg_auto` is used. +7. **Controls** — switch and number events queue rather than calling inline; boost uses + the number entity's value; a Zappi boost in Fast mode is refused with a warning and no + API call; controls do nothing when `myenergi_enable_controls` is false. +8. **Stubs** — every stubbed method returns `False` and logs, without raising. +9. **Error handling** — a failed poll retains the previous reading and does not publish + zeros; HTTP errors increment the error count without killing the component. + +Tests use `unittest.mock.AsyncMock` against the transport's HTTP layer, following +`test_ohme.py` and `test_fox_api.py`. No network access. + +Per the repository's shared-fixture constraint, the tests must not leak state into the +shared `my_predbat` fixture; the component is constructed against `MockBase` wherever a +full Predbat instance is not required. + +## 11. Documentation + +- `docs/components.md` — a `### myenergi (myenergi)` section matching the existing + layout: what it does, when to enable, configuration options, how to get an API key + from myaccount.myenergi.com, the published entities, the reserved controls, and the + small-session limitation from section 6.1. +- `docs/apps-yaml.md` — the new `myenergi_*` keys. +- `.cspell/custom-dictionary-workspace.txt` — `libbi`, `jstatus`, `jdayhour`, `harvi` + and `asn`. `Eddi`, `myenergi` and `zappi` are already present. + +## 12. Out of scope + +Libbi battery support; webhooks; charge schedules and super-schedules; managed mode; +cloud configuration endpoints; charge-session history import; Zappi mode control; Eddi +heater 2 and relay targets; myenergi as a Predbat inverter or battery source. + +## 13. Risks + +- **Partner registration.** The cloud transport cannot be tested end to end until + myenergi issues a `client_id`/`client_secret`. Mitigation: the transport is written + against the published OpenAPI schema and unit-tested against recorded payloads; the + direct transport is the default so the release is useful regardless. +- **Eddi on the 3rd-party API.** myenergi document Eddi support there as "in development", + so cloud-transport Eddi behaviour may change. Mitigation: normalisation is centralised, + and the direct transport covers Eddi fully today. +- **Undocumented direct API.** The `/cgi-*` endpoints are not officially supported and + could change. Mitigation: they are stable in practice and widely used by + `pymyenergi`/`ha-myenergi`; failures degrade to a logged error, never a crash. +- **Session-energy semantics.** Covered in section 6.1. diff --git a/requirements.txt b/requirements.txt index 814314f4f..3471d529d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ aiofiles -aiohttp +aiohttp>=3.12 aiomqtt coverage matplotlib diff --git a/templates/alphaess_cloud.yaml b/templates/alphaess_cloud.yaml index 466507764..f5932af2e 100644 --- a/templates/alphaess_cloud.yaml +++ b/templates/alphaess_cloud.yaml @@ -141,6 +141,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/enphase_cloud.yaml b/templates/enphase_cloud.yaml index 27915e389..eb52967c9 100644 --- a/templates/enphase_cloud.yaml +++ b/templates/enphase_cloud.yaml @@ -123,6 +123,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/ep_cube_cloud.yaml b/templates/ep_cube_cloud.yaml index 6c2706139..594429f2d 100644 --- a/templates/ep_cube_cloud.yaml +++ b/templates/ep_cube_cloud.yaml @@ -177,6 +177,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/fox_cloud.yaml b/templates/fox_cloud.yaml index f7d6b8beb..c74b8ecf0 100644 --- a/templates/fox_cloud.yaml +++ b/templates/fox_cloud.yaml @@ -114,6 +114,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/fronius.yaml b/templates/fronius.yaml index b5739a3d0..276e224cc 100644 --- a/templates/fronius.yaml +++ b/templates/fronius.yaml @@ -285,6 +285,7 @@ pred_bat: # - 'true' # - 'connected' # - 'ev connected' + # - 'ev ready to charge' # - 'charging' # - 'paused' # - 'waiting for car demand' diff --git a/templates/ge_cloud_octopus_standalone.yaml b/templates/ge_cloud_octopus_standalone.yaml index 6add7775e..303d75c45 100644 --- a/templates/ge_cloud_octopus_standalone.yaml +++ b/templates/ge_cloud_octopus_standalone.yaml @@ -135,6 +135,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/ginlong_solis.yaml b/templates/ginlong_solis.yaml index 097466139..cc68e66e2 100644 --- a/templates/ginlong_solis.yaml +++ b/templates/ginlong_solis.yaml @@ -218,6 +218,7 @@ pred_bat: # - 'true' # - 'connected' # - 'ev connected' + # - 'ev ready to charge' # - 'charging' # - 'paused' # - 'waiting for car demand' diff --git a/templates/givenergy_cloud.yaml b/templates/givenergy_cloud.yaml index 707570e5d..62ee5aadf 100644 --- a/templates/givenergy_cloud.yaml +++ b/templates/givenergy_cloud.yaml @@ -231,6 +231,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/givenergy_ems.yaml b/templates/givenergy_ems.yaml index d7c5ce668..a4366d42d 100644 --- a/templates/givenergy_ems.yaml +++ b/templates/givenergy_ems.yaml @@ -145,6 +145,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/givenergy_givtcp.yaml b/templates/givenergy_givtcp.yaml index 8b15f9163..d486725f9 100644 --- a/templates/givenergy_givtcp.yaml +++ b/templates/givenergy_givtcp.yaml @@ -274,6 +274,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/hanchu_cloud.yaml b/templates/hanchu_cloud.yaml index 176d162a0..c625fe3bb 100644 --- a/templates/hanchu_cloud.yaml +++ b/templates/hanchu_cloud.yaml @@ -158,6 +158,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/luxpower.yaml b/templates/luxpower.yaml index bb992bd36..bcb3eba4e 100644 --- a/templates/luxpower.yaml +++ b/templates/luxpower.yaml @@ -243,6 +243,7 @@ pred_bat: # - 'true' # - 'connected' # - 'ev connected' + # - 'ev ready to charge' # - 'charging' # - 'paused' # - 'waiting for car demand' diff --git a/templates/sigenergy_cloud.yaml b/templates/sigenergy_cloud.yaml index aefe0ac51..e9344f873 100644 --- a/templates/sigenergy_cloud.yaml +++ b/templates/sigenergy_cloud.yaml @@ -143,6 +143,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/sigenergy_sigenstor.yaml b/templates/sigenergy_sigenstor.yaml index bb9f614e8..d9639a2c5 100644 --- a/templates/sigenergy_sigenstor.yaml +++ b/templates/sigenergy_sigenstor.yaml @@ -188,6 +188,7 @@ pred_bat: # - 'true' # - 'connected' # - 'ev connected' + # - 'ev ready to charge' # - 'charging' # - 'paused' # - 'waiting for car demand' diff --git a/templates/sofar.yaml b/templates/sofar.yaml index b67244a9d..e4978d01f 100644 --- a/templates/sofar.yaml +++ b/templates/sofar.yaml @@ -172,6 +172,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/sofar_modbus.yaml b/templates/sofar_modbus.yaml index 9281bcc57..7cc60913d 100644 --- a/templates/sofar_modbus.yaml +++ b/templates/sofar_modbus.yaml @@ -189,6 +189,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/solar_assistant_growatt_spa.yaml b/templates/solar_assistant_growatt_spa.yaml index 21b605bd6..3bc9ee3c8 100644 --- a/templates/solar_assistant_growatt_spa.yaml +++ b/templates/solar_assistant_growatt_spa.yaml @@ -188,6 +188,7 @@ pred_bat: # - 'true' # - 'connected' # - 'ev connected' + # - 'ev ready to charge' # - 'charging' # - 'paused' # - 'waiting for car demand' diff --git a/templates/solar_assistant_growatt_sph.yaml b/templates/solar_assistant_growatt_sph.yaml index 4e7b535bd..9b598dd05 100644 --- a/templates/solar_assistant_growatt_sph.yaml +++ b/templates/solar_assistant_growatt_sph.yaml @@ -185,6 +185,7 @@ pred_bat: # - 'true' # - 'connected' # - 'ev connected' + # - 'ev ready to charge' # - 'charging' # - 'paused' # - 'waiting for car demand' diff --git a/templates/solaredge.yaml b/templates/solaredge.yaml index c635470fa..d10109cbc 100644 --- a/templates/solaredge.yaml +++ b/templates/solaredge.yaml @@ -189,6 +189,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/solax_cloud.yaml b/templates/solax_cloud.yaml index 1f5bf3360..cf608c331 100644 --- a/templates/solax_cloud.yaml +++ b/templates/solax_cloud.yaml @@ -117,6 +117,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/solax_sx4.yaml b/templates/solax_sx4.yaml index d2a2b455b..ded326400 100644 --- a/templates/solax_sx4.yaml +++ b/templates/solax_sx4.yaml @@ -245,6 +245,7 @@ pred_bat: # - 'true' # - 'connected' # - 'ev connected' + # - 'ev ready to charge' # - 'charging' # - 'paused' # - 'waiting for car demand' diff --git a/templates/solis_cloud.yaml b/templates/solis_cloud.yaml index 1d58feefb..3a81deed1 100644 --- a/templates/solis_cloud.yaml +++ b/templates/solis_cloud.yaml @@ -146,6 +146,7 @@ pred_bat: - 'true' - 'connected' - 'ev connected' + - 'ev ready to charge' - 'charging' - 'paused' - 'waiting for car demand' diff --git a/templates/sunsynk.yaml b/templates/sunsynk.yaml index 9e39f743e..cf2d5472a 100644 --- a/templates/sunsynk.yaml +++ b/templates/sunsynk.yaml @@ -281,6 +281,7 @@ pred_bat: # - 'true' # - 'connected' # - 'ev connected' + # - 'ev ready to charge' # - 'charging' # - 'paused' # - 'waiting for car demand' diff --git a/templates/tesla_powerwall.yaml b/templates/tesla_powerwall.yaml index ca79a4837..8a042ef86 100644 --- a/templates/tesla_powerwall.yaml +++ b/templates/tesla_powerwall.yaml @@ -218,6 +218,7 @@ pred_bat: # - 'true' # - 'connected' # - 'ev connected' + # - 'ev ready to charge' # - 'charging' # - 'paused' # - 'waiting for car demand' diff --git a/templates/teslemetry.yaml b/templates/teslemetry.yaml index bb202032c..1c85783b4 100644 --- a/templates/teslemetry.yaml +++ b/templates/teslemetry.yaml @@ -90,6 +90,7 @@ pred_bat: # - 'true' # - 'connected' # - 'ev connected' + # - 'ev ready to charge' # - 'charging' # - 'paused' # - 'waiting for car demand'