From c4b816b55d9ad74c369fb066502c5b1cfdcecfc6 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 11:00:26 +0100 Subject: [PATCH 01/24] docs(wallbox): design spec and implementation plan for the Wallbox component Co-Authored-By: Claude Opus 5.5 (1M context) --- .../plans/2026-10-05-wallbox-component.md | 2662 +++++++++++++++++ .../2026-10-05-wallbox-component-design.md | 327 ++ 2 files changed, 2989 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-05-wallbox-component.md create mode 100644 docs/superpowers/specs/2026-10-05-wallbox-component-design.md diff --git a/docs/superpowers/plans/2026-10-05-wallbox-component.md b/docs/superpowers/plans/2026-10-05-wallbox-component.md new file mode 100644 index 000000000..254437f61 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-wallbox-component.md @@ -0,0 +1,2662 @@ +# Wallbox Component 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 `wallbox` cloud component to Predbat that monitors every Wallbox charger on an account, wires them into car charging, exposes manual controls, and can pause/resume each charger from Predbat's car plan. + +**Architecture:** One new file, `apps/predbat/wallbox.py`, in the shape of `myenergi.py`: a `WallboxTransport` that owns the tokens and every `aiohttp` call, a pure `normalise_charger()` that turns a status payload into a `WallboxCharger` dataclass, and a `WallboxAPI(ComponentBase)` that polls, publishes, handles control events, auto-configures and runs plan-led control. The component never touches `aiohttp`; the transport never touches Predbat. + +**Tech Stack:** Python 3, `aiohttp` (already a dependency, `>=3.12`), Predbat's `ComponentBase`, `MockBase`, `predbat_metrics.record_api_call`, `utils.parse_car_plan_windows`, the Storage component. + +**Spec:** `docs/superpowers/specs/2026-10-05-wallbox-component-design.md` — read it before starting. This plan argues from it. + +## Global Constraints + +- No new dependency. Do not import `wallbox`, `requests` or `aenum`. +- Auth host `https://user-api.wall-box.com/`, API host `https://api.wall-box.com/`. Sign in and refresh send the header `Partner: wallbox`. +- Config keys are exactly: `wallbox_username`, `wallbox_password`, `wallbox_automatic` (default `True`), `wallbox_control` (default `False`), `wallbox_poll_seconds` (default `120`, rounded to a multiple of 60, clamped to 60–1800). There is no `wallbox_enable_controls`. +- Entity names are `{prefix}_wallbox_{charger_id}_…`; the global switch is `switch.{prefix}_wallbox_control`. Every entity is published with `dashboard_item(..., app="wallbox")`. +- No state-of-charge sensor and no `car_charging_soc` wiring: a Type 2 connector cannot report it. +- Resume is sent only to a Paused charger (status 178, 182). Never to Scheduled or Waiting. Predbat never unlocks a charger. +- Component log lines start `Info: wallbox:`, `Warn: wallbox:` or `Error: wallbox:`. +- Every function and class needs a docstring (`interrogate` enforces 100%). Line length up to 250. British English in docs and comments. +- Variable names `lower_case_with_underscores`. +- Before editing an existing symbol (`COMPONENT_LIST`, `APPS_SCHEMA`, `TEST_REGISTRY`) run GitNexus `impact({target: "", direction: "upstream"})` and report the blast radius; before each commit run GitNexus `detect_changes()`. This is a repository rule in `CLAUDE.md`. +- Tests are slow. Always write test output to a file and read the file; never pipe a test run straight into `grep`. +- Work on a branch, not `main`: `git checkout -b feat/wallbox-component` before Task 1. + +## Review Focus + +Inputs the spec implies but does not spell out. Each has a test in the task named. + +1. A status payload with `null` or missing numbers (`charging_power: null`, no `config_data`) — the charger is still published with zeros, never an exception. → Task 3. +2. One charger's status call fails while another's succeeds — the healthy charger is still published, charger N is still car N, and control skips the stale one. → Task 5. +3. A charger with no matching car slot sensor (more chargers than planned cars, or Predbat has not planned yet) — it is left alone, not treated as "outside a window" and paused. → Task 8. +4. A password containing `:` or non-ASCII characters — the Basic header is still built correctly as UTF-8. → Task 1. +5. A number entity set to a non-numeric or out-of-range value — ignored or clamped, no API call with a bad value. → Task 6. + +## File Structure + +| File | Responsibility | +|---|---| +| `apps/predbat/wallbox.py` (create) | Transport, normaliser, component, CLI | +| `apps/predbat/tests/test_wallbox.py` (create) | All unit tests, one `test_wallbox()` entry point | +| `apps/predbat/unit_test.py` (modify) | Import and `TEST_REGISTRY` line | +| `apps/predbat/components.py` (modify) | `COMPONENT_LIST["wallbox"]` | +| `apps/predbat/config.py` (modify) | Five `APPS_SCHEMA` keys | +| `apps/predbat/config/apps.yaml` (modify) | Commented example block | +| `docs/components.md`, `docs/car-charging.md`, `docs/apps-yaml.md`, `docs/devices.md` (modify) | User documentation | +| `CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md` (modify) | Component count and name lists | + +How to run the tests (used by every task): + +```bash +cd coverage +./run_all --test wallbox > /tmp/wallbox_test.log 2>&1; tail -30 /tmp/wallbox_test.log +``` + +A pass ends with the `test_wallbox` summary and no traceback. A failure shows a traceback in the log file. + +--- + +### Task 1: Transport — authentication and reads + +**Files:** +- Create: `apps/predbat/wallbox.py` +- Create: `apps/predbat/tests/test_wallbox.py` +- Modify: `apps/predbat/unit_test.py` (import near line 278, registry near line 709, next to the `myenergi` lines) + +**Interfaces:** +- Produces: `WallboxError`, `WallboxAuthError`, `WallboxRateLimitError`, `WallboxPermissionError`, `WallboxApiError`; `basic_auth_header(username, password) -> str`; `WallboxTransport(log, username, password)` with `async authenticate()`, `async list_chargers() -> list`, `async get_status(charger_id) -> dict`, and the internal `async _api(method, path, body=None, write=False) -> dict` that Task 4 builds on. + +- [ ] **Step 1: Write the failing tests** + +Create `apps/predbat/tests/test_wallbox.py`: + +```python +# fmt: off +# pylint: disable=line-too-long +""" +Unit tests for the Wallbox EV charger integration +""" + +import asyncio +import datetime +import os +import sys +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import pytz + +# 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 wallbox import ( + WALLBOX_API_URL, + WALLBOX_AUTH_URL, + WallboxApiError, + WallboxAuthError, + WallboxRateLimitError, + WallboxTransport, + basic_auth_header, +) + + +def _response(json_data=None, status=200, json_error=None): + """Build a mock aiohttp response usable as an async context manager.""" + 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 _session(responses): + """Build a mock aiohttp session whose request() returns the next queued response. + + A queued exception instance is raised instead. Returns (session, calls), where calls + records the method, URL, headers and JSON body of every request in order. + """ + calls = [] + queue = list(responses) + + def _request(method, url, **kwargs): + """Record the request, then return or raise the next queued item.""" + calls.append({"method": method, "url": url, "headers": kwargs.get("headers") or {}, "json": kwargs.get("json")}) + item = queue.pop(0) if queue else _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 _auth_payload(token="jwt-1", refresh="refresh-1", life=3600, refresh_life=86400): + """Build a sign in response whose TTLs are absolute epoch milliseconds, as Wallbox sends them.""" + now = time.time() + return {"data": {"attributes": {"token": token, "refresh_token": refresh, "ttl": int((now + life) * 1000), "refresh_token_ttl": int((now + refresh_life) * 1000)}}} + + +MOCK_GROUPS = {"result": {"groups": [{"chargers": [{"id": 202}, {"id": 101}]}, {"chargers": []}]}} + + +def test_basic_auth_header(): + """The Basic header is UTF-8, so a colon or accented character in the password survives.""" + assert basic_auth_header("user@example.com", "secret") == "Basic dXNlckBleGFtcGxlLmNvbTpzZWNyZXQ=" + # base64 of "a:pä:ss" encoded as UTF-8 + assert basic_auth_header("a", "pä:ss") == "Basic YTpww6Q6c3M=" + print(" ✓ Basic auth header is built as UTF-8") + + +def test_transport_signs_in_then_lists_chargers(): + """The first call signs in with Basic auth and the Partner header, then uses the bearer token.""" + session, calls = _session([_response(_auth_payload()), _response(MOCK_GROUPS)]) + transport = WallboxTransport(print, "user@example.com", "secret") + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + chargers = run_async(transport.list_chargers()) + + assert chargers == [202, 101], chargers + assert calls[0]["url"] == WALLBOX_AUTH_URL + "users/signin" + assert calls[0]["headers"]["Authorization"] == basic_auth_header("user@example.com", "secret") + assert calls[0]["headers"]["Partner"] == "wallbox" + assert calls[1]["url"] == WALLBOX_API_URL + "v3/chargers/groups" + assert calls[1]["headers"]["Authorization"] == "Bearer jwt-1" + print(" ✓ Transport signs in then lists chargers") + + +def test_transport_reuses_a_valid_token(): + """A token with life left is reused: two reads make one sign in.""" + session, calls = _session([_response(_auth_payload()), _response({"status_id": 193}), _response({"status_id": 161})]) + transport = WallboxTransport(print, "user@example.com", "secret") + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + first = run_async(transport.get_status(101)) + second = run_async(transport.get_status(101)) + + assert first == {"status_id": 193} and second == {"status_id": 161} + assert [call["url"] for call in calls] == [WALLBOX_AUTH_URL + "users/signin", WALLBOX_API_URL + "chargers/status/101", WALLBOX_API_URL + "chargers/status/101"] + print(" ✓ A valid token is reused") + + +def test_transport_refreshes_an_expired_token(): + """An expired token with a live refresh token is refreshed with a bearer call, not a sign in.""" + session, calls = _session([_response(_auth_payload(token="jwt-2")), _response({"status_id": 193})]) + transport = WallboxTransport(print, "user@example.com", "secret") + transport.token = "jwt-1" + transport.token_expiry = time.time() - 10 + transport.refresh_token = "refresh-1" + transport.refresh_expiry = time.time() + 3600 + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + run_async(transport.get_status(101)) + + assert calls[0]["url"] == WALLBOX_AUTH_URL + "users/refresh-token" + assert calls[0]["headers"]["Authorization"] == "Bearer refresh-1" + assert calls[0]["headers"]["Partner"] == "wallbox" + assert calls[1]["headers"]["Authorization"] == "Bearer jwt-2" + print(" ✓ An expired token is refreshed") + + +def test_transport_signs_in_when_the_refresh_is_rejected(): + """A 401 on refresh means the refresh token was revoked: fall back to a full sign in.""" + session, calls = _session([_response({}, status=401), _response(_auth_payload(token="jwt-3")), _response({"status_id": 193})]) + transport = WallboxTransport(print, "user@example.com", "secret") + transport.token = "jwt-1" + transport.token_expiry = time.time() - 10 + transport.refresh_token = "refresh-1" + transport.refresh_expiry = time.time() + 3600 + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + run_async(transport.get_status(101)) + + assert [call["url"] for call in calls] == [WALLBOX_AUTH_URL + "users/refresh-token", WALLBOX_AUTH_URL + "users/signin", WALLBOX_API_URL + "chargers/status/101"] + assert calls[2]["headers"]["Authorization"] == "Bearer jwt-3" + print(" ✓ A rejected refresh falls back to sign in") + + +def test_transport_bad_credentials(): + """A 403 on sign in is an auth error, recorded with reason auth_error.""" + session, _ = _session([_response({}, status=403)]) + transport = WallboxTransport(print, "user@example.com", "wrong") + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call") as mock_record: + try: + run_async(transport.list_chargers()) + raise AssertionError("Expected WallboxAuthError") + except WallboxAuthError: + pass + assert mock_record.call_args.kwargs.get("reason") == "auth_error" + print(" ✓ Bad credentials raise WallboxAuthError") + + +def test_transport_retries_once_after_a_revoked_token(): + """A 401 on a data call drops the token, signs in again and retries the call once.""" + session, calls = _session([_response({}, status=401), _response(_auth_payload(token="jwt-9")), _response({"status_id": 193})]) + transport = WallboxTransport(print, "user@example.com", "secret") + transport.token = "jwt-1" + transport.token_expiry = time.time() + 3600 + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + status = run_async(transport.get_status(101)) + + assert status == {"status_id": 193} + assert calls[1]["url"] == WALLBOX_AUTH_URL + "users/signin" + assert calls[2]["headers"]["Authorization"] == "Bearer jwt-9" + print(" ✓ A revoked token is replaced and the call retried once") + + +def test_transport_rate_limit_and_failures(): + """429 is its own error; a 500, a timeout, a dropped connection and a non-JSON body are API errors.""" + cases = [ + (_response({}, status=429), WallboxRateLimitError, "rate_limit"), + (_response({}, status=500), WallboxApiError, "server_error"), + (_response({}, status=404), WallboxApiError, "client_error"), + (asyncio.TimeoutError(), WallboxApiError, "connection_error"), + (aiohttp.ClientError("boom"), WallboxApiError, "connection_error"), + (_response(json_error=ValueError("not json")), WallboxApiError, "decode_error"), + (_response(["not", "a", "dict"]), WallboxApiError, "decode_error"), + ] + for item, expected, reason in cases: + session, _ = _session([item]) + transport = WallboxTransport(print, "user@example.com", "secret") + transport.token = "jwt-1" + transport.token_expiry = time.time() + 3600 + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call") as mock_record: + try: + run_async(transport.get_status(101)) + raise AssertionError("Expected {}".format(expected.__name__)) + except expected: + pass + assert mock_record.call_args.kwargs.get("reason") == reason, (expected, mock_record.call_args) + print(" ✓ Rate limit and failures map to the right errors and metric reasons") + + +def test_wallbox(my_predbat=None): + """Run every Wallbox test.""" + print("=" * 70) + print("Wallbox tests") + print("=" * 70) + test_basic_auth_header() + test_transport_signs_in_then_lists_chargers() + test_transport_reuses_a_valid_token() + test_transport_refreshes_an_expired_token() + test_transport_signs_in_when_the_refresh_is_rejected() + test_transport_bad_credentials() + test_transport_retries_once_after_a_revoked_token() + test_transport_rate_limit_and_failures() + print("=" * 70) + return False +``` + +In `apps/predbat/unit_test.py`, add next to the `myenergi` import and registry lines: + +```python +from tests.test_wallbox import test_wallbox +``` + +```python + ("wallbox", test_wallbox, "Wallbox EV charger tests (transport, normalisation, publishing, auto-config, controls, plan-led charging)", False), +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run the test command from "How to run the tests". +Expected: a traceback ending `ModuleNotFoundError: No module named 'wallbox'`. + +- [ ] **Step 3: Write the implementation** + +Create `apps/predbat/wallbox.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 +# ----------------------------------------------------------------------------- +# Wallbox EV charger cloud API library. +# The API is the one the Home Assistant wallbox integration and the wallbox PyPI +# library use; it is reimplemented here over aiohttp. +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + + +"""Wallbox EV charger integration. + +Monitors every charger on a Wallbox account, wires them into Predbat's car charging +inputs, exposes pause/resume, lock, charging current and Eco-Smart controls, and can +pause and resume each charger from Predbat's own car charging plan. +""" + +import asyncio +import base64 +import time + +import aiohttp + +from predbat_metrics import record_api_call + +WALLBOX_AUTH_URL = "https://user-api.wall-box.com/" +WALLBOX_API_URL = "https://api.wall-box.com/" + +API_TIMEOUT = 30 +USER_AGENT = "Predbat" + +# A token is treated as expired this long before Wallbox says it is, so a call never +# leaves with a token that dies in flight +TOKEN_MARGIN_SECONDS = 120 + + +class WallboxError(Exception): + """Base class for every Wallbox API failure.""" + + +class WallboxAuthError(WallboxError): + """Wallbox rejected the credentials or the token.""" + + +class WallboxRateLimitError(WallboxError): + """Wallbox answered 429, too many requests.""" + + +class WallboxPermissionError(WallboxError): + """Wallbox refused a write because the account lacks admin rights over the charger.""" + + +class WallboxApiError(WallboxError): + """Any other failure: a bad status, a timeout, a dropped connection or an unreadable body.""" + + +def basic_auth_header(username, password): + """Build an HTTP Basic Authorization header value, encoded as UTF-8.""" + token = base64.b64encode("{}:{}".format(username, password).encode("utf-8")).decode("ascii") + return "Basic " + token + + +class WallboxTransport: + """Owns the Wallbox tokens and performs every HTTP call.""" + + def __init__(self, log, username, password): + """Store the credentials and start with no token.""" + self.log = log + self.username = username + self.password = password + self.token = None + self.refresh_token = None + # Absolute expiry times, in epoch seconds + self.token_expiry = 0 + self.refresh_expiry = 0 + + def _clear_tokens(self): + """Forget both tokens so the next call signs in from scratch.""" + self.token = None + self.refresh_token = None + self.token_expiry = 0 + self.refresh_expiry = 0 + + def _store_tokens(self, payload): + """Take the tokens and their expiry times from a sign in or refresh response.""" + try: + attributes = payload["data"]["attributes"] + self.token = attributes["token"] + self.refresh_token = attributes.get("refresh_token") + # Wallbox sends absolute expiry times in epoch milliseconds + self.token_expiry = float(attributes["ttl"]) / 1000.0 + self.refresh_expiry = float(attributes.get("refresh_token_ttl", 0)) / 1000.0 + except (KeyError, TypeError, ValueError) as exc: + self._clear_tokens() + raise WallboxApiError("unexpected sign in response from Wallbox") from exc + + async def _call(self, method, url, headers, body=None, write=False): + """Perform one HTTP request and return its decoded JSON object. + + Args: + method: The HTTP method. + url: The full URL. + headers: The request headers. + body: A JSON-serialisable body, or None. + write: True for a control call. A 403 is then a rights problem rather than + bad credentials, and an empty response body is accepted. + """ + try: + async with aiohttp.ClientSession() as session: + async with session.request(method, url, headers=headers, json=body, timeout=aiohttp.ClientTimeout(total=API_TIMEOUT)) as response: + status = response.status + if status == 429: + record_api_call("wallbox", success=False, reason="rate_limit") + raise WallboxRateLimitError("Wallbox rate limit reached calling {}".format(url)) + if status == 403 and write: + record_api_call("wallbox", success=False, reason="auth_error") + raise WallboxPermissionError("Wallbox refused {} - the account needs admin rights over the charger".format(url)) + if status in (401, 403): + record_api_call("wallbox", success=False, reason="auth_error") + raise WallboxAuthError("Wallbox rejected the credentials for {}".format(url)) + if status < 200 or status >= 300: + record_api_call("wallbox", success=False, reason="server_error" if status >= 500 else "client_error") + raise WallboxApiError("HTTP {} from {}".format(status, url)) + try: + payload = await response.json(content_type=None) + except (ValueError, aiohttp.ContentTypeError) as exc: + if write: + payload = {} + else: + record_api_call("wallbox", success=False, reason="decode_error") + raise WallboxApiError("unreadable response from {}".format(url)) from exc + if not isinstance(payload, dict): + if write: + payload = {} + else: + record_api_call("wallbox", success=False, reason="decode_error") + raise WallboxApiError("unexpected response from {}".format(url)) + record_api_call("wallbox", success=True) + return payload + except asyncio.TimeoutError as exc: + record_api_call("wallbox", success=False, reason="connection_error") + raise WallboxApiError("timed out calling {}".format(url)) from exc + except aiohttp.ClientError as exc: + record_api_call("wallbox", success=False, reason="connection_error") + raise WallboxApiError("request to {} failed: {}".format(url, exc)) from exc + + async def authenticate(self): + """Make sure there is a usable token: reuse it, refresh it, or sign in.""" + now = time.time() + if self.token and self.token_expiry - TOKEN_MARGIN_SECONDS > now: + return + if self.refresh_token and self.refresh_expiry - TOKEN_MARGIN_SECONDS > now: + headers = {"Authorization": "Bearer {}".format(self.refresh_token), "Partner": "wallbox", "Accept": "application/json", "User-Agent": USER_AGENT} + try: + self._store_tokens(await self._call("GET", WALLBOX_AUTH_URL + "users/refresh-token", headers)) + return + except WallboxAuthError: + self.log("Info: wallbox: the refresh token was rejected, signing in again") + self._clear_tokens() + headers = {"Authorization": basic_auth_header(self.username, self.password), "Partner": "wallbox", "Accept": "application/json", "User-Agent": USER_AGENT} + self._store_tokens(await self._call("GET", WALLBOX_AUTH_URL + "users/signin", headers)) + + def _headers(self): + """The headers every data call carries.""" + return { + "Authorization": "Bearer {}".format(self.token), + "Accept": "application/json", + "Content-Type": "application/json;charset=UTF-8", + "User-Agent": USER_AGENT, + } + + async def _api(self, method, path, body=None, write=False): + """Perform one authenticated data call, signing in again once if the token was revoked.""" + await self.authenticate() + try: + return await self._call(method, WALLBOX_API_URL + path, self._headers(), body=body, write=write) + except WallboxAuthError: + # The token was revoked before its stated expiry. Start again once. + self._clear_tokens() + await self.authenticate() + return await self._call(method, WALLBOX_API_URL + path, self._headers(), body=body, write=write) + + async def list_chargers(self): + """Return the id of every charger on the account, across all groups.""" + payload = await self._api("GET", "v3/chargers/groups") + charger_ids = [] + result = payload.get("result") + groups = result.get("groups") if isinstance(result, dict) else None + for group in groups or []: + for charger in (group or {}).get("chargers") or []: + if isinstance(charger, dict) and charger.get("id") is not None: + charger_ids.append(charger["id"]) + return charger_ids + + async def get_status(self, charger_id): + """Return the raw status payload of one charger.""" + return await self._api("GET", "chargers/status/{}".format(charger_id)) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run the test command. Expected: eight `✓` lines and no traceback. + +- [ ] **Step 5: Commit** + +```bash +git add apps/predbat/wallbox.py apps/predbat/tests/test_wallbox.py apps/predbat/unit_test.py +git commit -m "feat(wallbox): transport with sign in, token refresh and charger reads" +``` + +--- + +### Task 2: Command line mode and payload capture (needs the maintainer's charger) + +**Files:** +- Modify: `apps/predbat/wallbox.py` (append the CLI) +- Modify: `apps/predbat/tests/test_wallbox.py` (add the captured fixtures) + +**Interfaces:** +- Consumes: `WallboxTransport.list_chargers()`, `.get_status()`. +- Produces: `python3 wallbox.py --username … --password … [--raw]`; test constants `MOCK_STATUS_CHARGING`, `MOCK_STATUS_READY`, `MOCK_STATUS_PAUSED` used by later tasks through the `_status()` helper defined in Task 3. + +This task has a human step. The command line code is `# pragma: no cover`, as in `myenergi.py`, so it has no unit test; its test is the live run. + +- [ ] **Step 1: Add the command line mode** + +Add `import argparse` and `import json` to the imports of `wallbox.py`, and append: + +```python +async def run_wallbox_cli(args): # pragma: no cover + """Sign in, list the chargers and print each one's status against the live API.""" + transport = WallboxTransport(print, args.username, args.password) + charger_ids = await transport.list_chargers() + if not charger_ids: + print("No chargers found on this account") + return + print("Chargers: {}".format(charger_ids)) + for charger_id in charger_ids: + payload = await transport.get_status(charger_id) + if args.raw: + print(json.dumps(payload, indent=2, sort_keys=True, default=str)) + else: + print("{}: status_id={} power={}kW added_energy={}kWh".format(charger_id, payload.get("status_id"), payload.get("charging_power"), payload.get("added_energy"))) + + +def main(): # pragma: no cover + """Main function for command line execution.""" + parser = argparse.ArgumentParser(description="Test the Wallbox API") + parser.add_argument("--username", required=True, help="Wallbox account email address") + parser.add_argument("--password", required=True, help="Wallbox account password") + parser.add_argument("--raw", action="store_true", help="Print each charger's full status payload as JSON") + args = parser.parse_args() + asyncio.run(run_wallbox_cli(args)) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Capture real payloads (maintainer)** + +From `apps/predbat/`, run this three times: with the car charging, with the car unplugged, and with the car plugged in and charging paused from the Wallbox app. + +```bash +python3 wallbox.py --username --password --raw > /tmp/wallbox_charging.json +``` + +Expected: `Chargers: []` followed by a JSON object containing at least `status_id`, `charging_power`, `added_energy`, `max_available_power`, `name` and a `config_data` object with `max_charging_current`, `locked`, `serial_number`, `part_number` and `software`. + +Also confirm the sign in worked with `User-Agent: Predbat`. If Wallbox rejects it (the sign in fails but the same credentials work in the Wallbox app), change `USER_AGENT` to `"HomeAssistantWallboxPlugin/1.0.0"` and record that in `tools/debug-journal.md`. + +- [ ] **Step 3: Add the captures as fixtures** + +Add the three payloads to `test_wallbox.py` as `MOCK_STATUS_CHARGING`, `MOCK_STATUS_READY` and `MOCK_STATUS_PAUSED`, below `MOCK_GROUPS`. Before pasting, replace the charger id with `101`, `serial_number` with `"900001"`, `name` with `"Garage"`, and delete any field holding an address, a location, an email, a user id or a token. Keep every other field exactly as captured, including ones this plan never reads. + +- [ ] **Step 4: Check the capture against the spec** + +Compare the captured field names with spec section 2.3. If a field the spec reads is missing or named differently (for example `max_charging_current` is not under `config_data`), stop and report it: the normaliser in Task 3 and the spec both need correcting before going on. Record the paused capture's `status_id`; it must be 178 or 182, and if it is anything else, stop and report it, because spec section 2.4 is then wrong. + +- [ ] **Step 5: Commit** + +```bash +git add apps/predbat/wallbox.py apps/predbat/tests/test_wallbox.py +git commit -m "feat(wallbox): command line mode and captured status fixtures" +``` + +--- + +### Task 3: Normaliser and status table + +**Files:** +- Modify: `apps/predbat/wallbox.py` +- Modify: `apps/predbat/tests/test_wallbox.py` + +**Interfaces:** +- Produces: `WALLBOX_STATUS` (dict of status id to `(text, connected)`), `CHARGING_STATUS_IDS = (193, 194, 195)`, `PAUSED_STATUS_IDS = (178, 182)`, `ECO_SMART_OPTIONS = ["off", "eco_mode", "full_solar"]`, and + +```python +@dataclass +class WallboxCharger: + charger_id: str # str(id), used in entity names + name: str + serial: str + part_number: str + software_version: str + status_id: int + status: str # the Home Assistant wording + connected: bool + charging: bool + paused: bool + locked: bool + power_w: float + session_energy_kwh: float + max_charging_current: int + max_available_current: int + eco_smart: Optional[str] # one of ECO_SMART_OPTIONS, or None when unsupported + +def normalise_charger(charger_id, payload) -> WallboxCharger +``` + +- Test helper produced for later tasks: `_status(status_id=193, power=7.2, energy=4.5, locked=False, max_current=32, eco=None, name="Garage") -> dict`. + +- [ ] **Step 1: Write the failing tests** + +Add to the `from wallbox import (...)` list: `CHARGING_STATUS_IDS`, `PAUSED_STATUS_IDS`, `WALLBOX_STATUS`, `WallboxCharger`, `normalise_charger`. Add: + +```python +def _status(status_id=193, power=7.2, energy=4.5, locked=False, max_current=32, eco=None, name="Garage"): + """Build a status payload in the shape Wallbox returns, for the fields Predbat reads.""" + config = {"max_charging_current": max_current, "locked": locked, "serial_number": "900001", "part_number": "PLP1-0-2-4-9-002-E", "software": {"currentVersion": "5.5.10"}} + if eco is not None: + config["ecosmart"] = eco + return {"status_id": status_id, "charging_power": power, "added_energy": energy, "max_available_power": 32, "name": name, "config_data": config} + + +def test_normalise_charging(): + """A charging payload gives watts, session energy and the identity fields.""" + charger = normalise_charger(101, _status()) + assert charger.charger_id == "101" + assert charger.name == "Garage" and charger.serial == "900001" + assert charger.part_number == "PLP1-0-2-4-9-002-E" and charger.software_version == "5.5.10" + assert charger.status == "Charging" and charger.status_id == 193 + assert charger.connected is True and charger.charging is True and charger.paused is False + assert charger.power_w == 7200.0, charger.power_w + assert charger.session_energy_kwh == 4.5 + assert charger.max_charging_current == 32 and charger.max_available_current == 32 + assert charger.locked is False and charger.eco_smart is None + print(" ✓ A charging payload is normalised") + + +def test_normalise_status_table(): + """Every status code maps to the documented text, connected, charging and paused flags.""" + expected = { + 193: ("Charging", True, True, False), 194: ("Charging", True, True, False), 195: ("Charging", True, True, False), + 196: ("Discharging", True, False, False), + 178: ("Paused", True, False, True), 182: ("Paused", True, False, True), + 177: ("Scheduled", True, False, False), 179: ("Scheduled", True, False, False), + 164: ("Waiting", True, False, False), + 180: ("Waiting for car demand", True, False, False), 181: ("Waiting for car demand", True, False, False), + 183: ("Waiting in queue by Power Sharing", True, False, False), 184: ("Waiting in queue by Power Sharing", True, False, False), + 185: ("Waiting in queue by Power Boost", True, False, False), 186: ("Waiting in queue by Power Boost", True, False, False), + 187: ("Waiting MID failed", True, False, False), 188: ("Waiting MID safety margin exceeded", True, False, False), + 189: ("Waiting in queue by Eco-Smart", True, False, False), + 210: ("Locked, car connected", True, False, False), + 165: ("Locked", False, False, False), 209: ("Locked", False, False, False), + 161: ("Ready", False, False, False), 162: ("Ready", False, False, False), + 0: ("Disconnected", False, False, False), 163: ("Disconnected", False, False, False), + 166: ("Updating", False, False, False), + 14: ("Error", False, False, False), 15: ("Error", False, False, False), + } + assert set(expected) == set(WALLBOX_STATUS), set(expected) ^ set(WALLBOX_STATUS) + for status_id, (text, connected, charging, paused) in expected.items(): + charger = normalise_charger(101, _status(status_id=status_id)) + assert (charger.status, charger.connected, charger.charging, charger.paused) == (text, connected, charging, paused), status_id + unknown = normalise_charger(101, _status(status_id=999)) + assert (unknown.status, unknown.connected, unknown.charging, unknown.paused) == ("Unknown", False, False, False) + print(" ✓ Every status code maps to the right state") + + +def test_normalise_eco_smart(): + """Eco-Smart is None when unsupported, off when disabled, otherwise the mode.""" + assert normalise_charger(101, _status()).eco_smart is None + assert normalise_charger(101, _status(eco={"enabled": False, "mode": 0})).eco_smart == "off" + assert normalise_charger(101, _status(eco={"enabled": True, "mode": 0})).eco_smart == "eco_mode" + assert normalise_charger(101, _status(eco={"enabled": True, "mode": 1})).eco_smart == "full_solar" + assert normalise_charger(101, _status(eco={"enabled": True})).eco_smart is None + print(" ✓ Eco-Smart mode is normalised") + + +def test_normalise_handles_bad_values(): + """Null numbers, a missing config block and a non-dict payload give safe defaults, never an exception.""" + charger = normalise_charger(101, {"status_id": None, "charging_power": None, "added_energy": "junk", "config_data": None}) + assert charger.status == "Disconnected" and charger.connected is False + assert charger.power_w == 0.0 and charger.session_energy_kwh == 0.0 + assert charger.max_charging_current == 0 and charger.max_available_current == 0 + assert charger.name == "Wallbox 101" and charger.serial == "" and charger.locked is False + + empty = normalise_charger(101, "not a dict") + assert empty.status == "Disconnected" and empty.power_w == 0.0 + + truthy_lock = normalise_charger(101, _status(locked=1)) + assert truthy_lock.locked is True + print(" ✓ Bad values normalise to safe defaults") + + +def test_normalise_captured_payloads(): + """The payloads captured from a real charger normalise to the states they were captured in.""" + charging = normalise_charger(101, MOCK_STATUS_CHARGING) + assert charging.charging is True and charging.power_w > 0 + ready = normalise_charger(101, MOCK_STATUS_READY) + assert ready.connected is False and ready.power_w == 0.0 + paused = normalise_charger(101, MOCK_STATUS_PAUSED) + assert paused.paused is True and paused.connected is True + print(" ✓ Captured payloads normalise to their real states") +``` + +Add all five to `test_wallbox()`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: `ImportError: cannot import name 'CHARGING_STATUS_IDS' from 'wallbox'`. + +- [ ] **Step 3: Write the implementation** + +Add `from dataclasses import dataclass` and `from typing import Optional` to the imports. Add above `class WallboxError`: + +```python +# Status id to (text, car connected). The text is the Home Assistant wallbox integration's +# wording, so states match what existing users already see. A None status id means the +# charger reported nothing, which Wallbox treats as disconnected. +WALLBOX_STATUS = { + 0: ("Disconnected", False), + 14: ("Error", False), + 15: ("Error", False), + 161: ("Ready", False), + 162: ("Ready", False), + 163: ("Disconnected", False), + 164: ("Waiting", True), + 165: ("Locked", False), + 166: ("Updating", False), + 177: ("Scheduled", True), + 178: ("Paused", True), + 179: ("Scheduled", True), + 180: ("Waiting for car demand", True), + 181: ("Waiting for car demand", True), + 182: ("Paused", True), + 183: ("Waiting in queue by Power Sharing", True), + 184: ("Waiting in queue by Power Sharing", True), + 185: ("Waiting in queue by Power Boost", True), + 186: ("Waiting in queue by Power Boost", True), + 187: ("Waiting MID failed", True), + 188: ("Waiting MID safety margin exceeded", True), + 189: ("Waiting in queue by Eco-Smart", True), + 193: ("Charging", True), + 194: ("Charging", True), + 195: ("Charging", True), + 196: ("Discharging", True), + 209: ("Locked", False), + 210: ("Locked, car connected", True), +} +CHARGING_STATUS_IDS = (193, 194, 195) +PAUSED_STATUS_IDS = (178, 182) + +ECO_SMART_OFF = "off" +ECO_SMART_ECO = "eco_mode" +ECO_SMART_FULL_SOLAR = "full_solar" +ECO_SMART_OPTIONS = [ECO_SMART_OFF, ECO_SMART_ECO, ECO_SMART_FULL_SOLAR] + + +def _to_float(value, default=0.0): + """Convert a value to float, returning the default when it is missing or not a number.""" + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _to_int(value, default=0): + """Convert a value to int, returning the default when it is missing or not a number.""" + try: + return int(float(value)) + except (TypeError, ValueError): + return default + + +@dataclass +class WallboxCharger: + """One charger's state, normalised from a Wallbox status payload.""" + + charger_id: str + name: str + serial: str + part_number: str + software_version: str + status_id: int + status: str + connected: bool + charging: bool + paused: bool + locked: bool + power_w: float + session_energy_kwh: float + max_charging_current: int + max_available_current: int + eco_smart: Optional[str] + + +def normalise_charger(charger_id, payload): + """Turn one status payload into a WallboxCharger, with safe defaults for anything missing.""" + if not isinstance(payload, dict): + payload = {} + config = payload.get("config_data") + if not isinstance(config, dict): + config = {} + software = config.get("software") + if not isinstance(software, dict): + software = {} + + status_id = payload.get("status_id") + if status_id is None: + status_id = 0 + status_id = _to_int(status_id, -1) + status, connected = WALLBOX_STATUS.get(status_id, ("Unknown", False)) + + eco_smart = None + eco_block = config.get("ecosmart") + if isinstance(eco_block, dict) and eco_block.get("mode") is not None: + if not eco_block.get("enabled"): + eco_smart = ECO_SMART_OFF + elif _to_int(eco_block.get("mode"), 0) == 1: + eco_smart = ECO_SMART_FULL_SOLAR + else: + eco_smart = ECO_SMART_ECO + + return WallboxCharger( + charger_id=str(charger_id), + name=str(payload.get("name") or "Wallbox {}".format(charger_id)), + serial=str(config.get("serial_number") or ""), + part_number=str(config.get("part_number") or ""), + software_version=str(software.get("currentVersion") or ""), + status_id=status_id, + status=status, + connected=connected, + charging=status_id in CHARGING_STATUS_IDS, + paused=status_id in PAUSED_STATUS_IDS, + locked=bool(config.get("locked")), + power_w=round(_to_float(payload.get("charging_power")) * 1000.0, 1), + session_energy_kwh=_to_float(payload.get("added_energy")), + max_charging_current=_to_int(config.get("max_charging_current")), + max_available_current=_to_int(payload.get("max_available_power")), + eco_smart=eco_smart, + ) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Expected: five more `✓` lines, no traceback. If `test_normalise_captured_payloads` fails, the real payload differs from the spec: stop and report, do not bend the test. + +- [ ] **Step 5: Commit** + +```bash +git add apps/predbat/wallbox.py apps/predbat/tests/test_wallbox.py +git commit -m "feat(wallbox): normalise charger status payloads" +``` + +--- + +### Task 4: Transport — control calls + +**Files:** +- Modify: `apps/predbat/wallbox.py` (methods on `WallboxTransport`) +- Modify: `apps/predbat/tests/test_wallbox.py` + +**Interfaces:** +- Consumes: `WallboxTransport._api(method, path, body=None, write=False)`, `ECO_SMART_OPTIONS`. +- Produces, all returning the response dict: `async pause(charger_id)`, `async resume(charger_id)`, `async resume_schedule(charger_id)`, `async set_locked(charger_id, locked: bool)`, `async set_max_charging_current(charger_id, amps: int)`, `async set_eco_smart(charger_id, mode: str)`. + +- [ ] **Step 1: Write the failing tests** + +Add `WallboxPermissionError` to the import list. Add: + +```python +def _signed_in_transport(): + """A transport that already holds a valid token, so each test sees only the call it makes.""" + transport = WallboxTransport(print, "user@example.com", "secret") + transport.token = "jwt-1" + transport.token_expiry = time.time() + 3600 + return transport + + +def test_transport_control_requests(): + """Each control sends exactly the method, URL and body the Wallbox API expects.""" + eco_body = lambda enabled, mode: {"data": {"attributes": {"enabled": enabled, "mode": mode}, "type": "eco_smart"}} + cases = [ + (lambda t: t.pause(101), "POST", "v3/chargers/101/remote-action", {"action": 2}), + (lambda t: t.resume(101), "POST", "v3/chargers/101/remote-action", {"action": 1}), + (lambda t: t.resume_schedule(101), "POST", "v3/chargers/101/remote-action", {"action": 9}), + (lambda t: t.set_locked(101, True), "PUT", "v2/charger/101", {"locked": 1}), + (lambda t: t.set_locked(101, False), "PUT", "v2/charger/101", {"locked": 0}), + (lambda t: t.set_max_charging_current(101, 16), "PUT", "v2/charger/101", {"maxChargingCurrent": 16}), + (lambda t: t.set_eco_smart(101, "off"), "PUT", "v4/chargers/101/eco-smart", eco_body(0, 0)), + (lambda t: t.set_eco_smart(101, "eco_mode"), "PUT", "v4/chargers/101/eco-smart", eco_body(1, 0)), + (lambda t: t.set_eco_smart(101, "full_solar"), "PUT", "v4/chargers/101/eco-smart", eco_body(1, 1)), + ] + for action, method, path, body in cases: + session, calls = _session([_response({})]) + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + run_async(action(_signed_in_transport())) + assert len(calls) == 1, calls + assert (calls[0]["method"], calls[0]["url"], calls[0]["json"]) == (method, WALLBOX_API_URL + path, body), calls[0] + assert calls[0]["headers"]["Content-Type"] == "application/json;charset=UTF-8" + print(" ✓ Control calls send the right method, URL and body") + + +def test_transport_control_refused_for_rights(): + """A 403 on a write is a rights problem, not bad credentials, and is not retried behind a sign in.""" + session, calls = _session([_response({}, status=403)]) + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + try: + run_async(_signed_in_transport().set_locked(101, True)) + raise AssertionError("Expected WallboxPermissionError") + except WallboxPermissionError: + pass + assert len(calls) == 1, "A rights refusal must not trigger a sign in and retry" + print(" ✓ A 403 on a write raises WallboxPermissionError") + + +def test_transport_control_accepts_an_empty_body(): + """Eco-Smart answers with no JSON body; that is a success, not a decode error.""" + session, _ = _session([_response(json_error=ValueError("empty"))]) + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + assert run_async(_signed_in_transport().set_eco_smart(101, "off")) == {} + print(" ✓ A control call with an empty body succeeds") + + +def test_transport_rejects_an_unknown_eco_smart_mode(): + """An Eco-Smart mode that is not one of the three options never reaches the API.""" + session, calls = _session([]) + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + try: + run_async(_signed_in_transport().set_eco_smart(101, "turbo")) + raise AssertionError("Expected WallboxApiError") + except WallboxApiError: + pass + assert calls == [] + print(" ✓ An unknown Eco-Smart mode is rejected locally") +``` + +Add all four to `test_wallbox()`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: `AttributeError: 'WallboxTransport' object has no attribute 'pause'`. + +- [ ] **Step 3: Write the implementation** + +Add next to the other constants: + +```python +# remote-action codes, from the Wallbox portal +REMOTE_ACTION_RESUME = 1 +REMOTE_ACTION_PAUSE = 2 +REMOTE_ACTION_RESUME_SCHEDULE = 9 +``` + +Add to `WallboxTransport`: + +```python + async def _remote_action(self, charger_id, action): + """Send one remote-action code to a charger.""" + return await self._api("POST", "v3/chargers/{}/remote-action".format(charger_id), body={"action": action}, write=True) + + async def pause(self, charger_id): + """Pause the charging session. Only has an effect while the charger is charging.""" + return await self._remote_action(charger_id, REMOTE_ACTION_PAUSE) + + async def resume(self, charger_id): + """Resume a paused charging session. Has no effect on a Scheduled or Waiting charger.""" + return await self._remote_action(charger_id, REMOTE_ACTION_RESUME) + + async def resume_schedule(self, charger_id): + """Hand the charger back to its own schedule and Eco-Smart mode after a manual stop.""" + return await self._remote_action(charger_id, REMOTE_ACTION_RESUME_SCHEDULE) + + async def set_locked(self, charger_id, locked): + """Lock or unlock the charger.""" + return await self._api("PUT", "v2/charger/{}".format(charger_id), body={"locked": 1 if locked else 0}, write=True) + + async def set_max_charging_current(self, charger_id, amps): + """Set the maximum charging current in amps.""" + return await self._api("PUT", "v2/charger/{}".format(charger_id), body={"maxChargingCurrent": int(amps)}, write=True) + + async def set_eco_smart(self, charger_id, mode): + """Set the Eco-Smart solar charging mode to one of ECO_SMART_OPTIONS.""" + if mode not in ECO_SMART_OPTIONS: + raise WallboxApiError("unknown Eco-Smart mode '{}'".format(mode)) + attributes = {"enabled": 0 if mode == ECO_SMART_OFF else 1, "mode": 1 if mode == ECO_SMART_FULL_SOLAR else 0} + return await self._api("PUT", "v4/chargers/{}/eco-smart".format(charger_id), body={"data": {"attributes": attributes, "type": "eco_smart"}}, write=True) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Expected: four more `✓` lines. + +- [ ] **Step 5: Extend the command line mode and commit** + +Replace `run_wallbox_cli` and `main` with versions that take the control flags, so the same calls can be tried by hand: + +```python +async def run_wallbox_cli(args): # pragma: no cover + """Sign in, list the chargers, print each one's status and optionally send one control.""" + transport = WallboxTransport(print, args.username, args.password) + charger_ids = await transport.list_chargers() + if not charger_ids: + print("No chargers found on this account") + return + print("Chargers: {}".format(charger_ids)) + for charger_id in charger_ids: + payload = await transport.get_status(charger_id) + if args.raw: + print(json.dumps(payload, indent=2, sort_keys=True, default=str)) + else: + print(normalise_charger(charger_id, payload)) + + target = args.charger if args.charger is not None else charger_ids[0] + action = None + if args.pause: + action = ("pause", transport.pause(target)) + elif args.resume: + action = ("resume", transport.resume(target)) + elif args.resume_schedule: + action = ("resume schedule", transport.resume_schedule(target)) + elif args.lock: + action = ("lock", transport.set_locked(target, True)) + elif args.unlock: + action = ("unlock", transport.set_locked(target, False)) + elif args.max_current is not None: + action = ("set max current {}A".format(args.max_current), transport.set_max_charging_current(target, args.max_current)) + elif args.eco_smart: + action = ("set Eco-Smart {}".format(args.eco_smart), transport.set_eco_smart(target, args.eco_smart)) + if not action: + return + print("\nSending {} to {}...".format(action[0], target)) + await action[1] + print("Waiting {}s for the charger to report the change...".format(COMMAND_SETTLE_SECONDS)) + await asyncio.sleep(COMMAND_SETTLE_SECONDS) + print(normalise_charger(target, await transport.get_status(target))) + + +def main(): # pragma: no cover + """Main function for command line execution.""" + parser = argparse.ArgumentParser(description="Test the Wallbox API") + parser.add_argument("--username", required=True, help="Wallbox account email address") + parser.add_argument("--password", required=True, help="Wallbox account password") + parser.add_argument("--raw", action="store_true", help="Print each charger's full status payload as JSON") + parser.add_argument("--charger", default=None, help="Charger id to control; defaults to the first one") + group = parser.add_mutually_exclusive_group() + group.add_argument("--pause", action="store_true", help="Pause the charging session") + group.add_argument("--resume", action="store_true", help="Resume a paused charging session") + group.add_argument("--resume-schedule", action="store_true", help="Hand the charger back to its own schedule") + group.add_argument("--lock", action="store_true", help="Lock the charger") + group.add_argument("--unlock", action="store_true", help="Unlock the charger") + group.add_argument("--max-current", type=int, default=None, help="Set the maximum charging current in amps") + group.add_argument("--eco-smart", choices=ECO_SMART_OPTIONS, default=None, help="Set the Eco-Smart mode") + args = parser.parse_args() + asyncio.run(run_wallbox_cli(args)) +``` + +Add next to the constants: + +```python +# How long the command line mode waits before reading a charger back after a control +COMMAND_SETTLE_SECONDS = 8 +``` + +Run the test command once more (expected: still passing), then: + +```bash +git add apps/predbat/wallbox.py apps/predbat/tests/test_wallbox.py +git commit -m "feat(wallbox): transport control calls and command line actions" +``` + +--- + +### Task 5: Component — registration, polling, publishing and error handling + +**Files:** +- Modify: `apps/predbat/wallbox.py` (add `WallboxAPI`) +- Modify: `apps/predbat/components.py` (after the `"myenergi"` entry, about line 291) +- Modify: `apps/predbat/config.py` (after the `myenergi_*` keys, about line 2779) +- Modify: `apps/predbat/tests/test_wallbox.py` + +**Interfaces:** +- Consumes: `WallboxTransport`, `normalise_charger`, `WallboxCharger`, the error classes. +- Produces on `WallboxAPI(ComponentBase)`: + - `initialize(self, username, password, automatic=True, wallbox_control=False, poll_seconds=120)` + - attributes `transport`, `chargers` (dict of `charger_id` str to `WallboxCharger`), `charger_ids` (list as the API returned them, sorted), `stale_ids` (set of str), `queued_events` (list), `poll_seconds`, `skip_cycles`, `backoff_cycles`, `control_active` (bool, `False` until Task 8), `control_enabled` (bool, `True`) + - `entity_prefix(charger) -> str`, `ordered_chargers() -> list[WallboxCharger]` (sorted by `charger_id`, the order that makes charger N car N) + - `async run(seconds, first) -> bool`, `async poll(seconds, first) -> bool`, `async process_events() -> bool`, `async publish_data()` + - `warn_permission()` (logs the rights warning once) + - hooks that are no-ops in this task and filled in later: `async load_control_state()`, `enable_control()`, `automatic_config()`, `async control_tick(now)` +- Test helpers produced: `_StubTransport`, `_make_component(statuses=None, **overrides)`. + +- [ ] **Step 1: Write the failing tests** + +Add `WallboxAPI`, `MIN_POLL_SECONDS`, `MAX_POLL_SECONDS` to the import list. Add: + +```python +class _StubTransport: + """In-memory transport: serves statuses by charger id and records every call.""" + + def __init__(self, statuses=None): + """Hold the statuses to serve; errors maps a call tuple to an exception to raise once.""" + self.statuses = statuses if statuses is not None else {101: _status()} + self.calls = [] + self.errors = {} + + def _record(self, *call): + """Record a call, raising the queued error for it if there is one.""" + self.calls.append(call) + error = self.errors.pop(call, None) or self.errors.pop((call[0],), None) + if error: + raise error + return {} + + async def list_chargers(self): + """Return the ids of the statuses held.""" + self._record("list_chargers") + return list(self.statuses) + + async def get_status(self, charger_id): + """Return one charger's status payload.""" + self._record("get_status", charger_id) + return self.statuses[charger_id] + + async def pause(self, charger_id): + """Record a pause.""" + return self._record("pause", charger_id) + + async def resume(self, charger_id): + """Record a resume.""" + return self._record("resume", charger_id) + + async def resume_schedule(self, charger_id): + """Record a resume schedule.""" + return self._record("resume_schedule", charger_id) + + async def set_locked(self, charger_id, locked): + """Record a lock change.""" + return self._record("set_locked", charger_id, locked) + + async def set_max_charging_current(self, charger_id, amps): + """Record a current change.""" + return self._record("set_max_charging_current", charger_id, amps) + + async def set_eco_smart(self, charger_id, mode): + """Record an Eco-Smart change.""" + return self._record("set_eco_smart", charger_id, mode) + + def count(self, name): + """How many times a call of this name was made.""" + return len([call for call in self.calls if call[0] == name]) + + +def _make_component(statuses=None, **overrides): + """Build a WallboxAPI against MockBase with a stub transport and a captured log.""" + args = {"username": "user@example.com", "password": "secret", "automatic": True, "wallbox_control": False, "poll_seconds": 120} + args.update(overrides) + component = WallboxAPI(MockBase(), **args) + component.transport = _StubTransport(statuses) + component.log_messages = [] + component.log = component.log_messages.append + return component + + +def _logged(component, text): + """Was a message containing this text logged.""" + return any(text in message for message in component.log_messages) + + +def test_component_registration(): + """The component is registered with matching config keys and event filter.""" + import inspect + + from components import COMPONENT_LIST, load_component_class + from config import APPS_SCHEMA + + entry = COMPONENT_LIST["wallbox"] + assert load_component_class(entry) is WallboxAPI + assert entry["event_filter"] == "predbat_wallbox_" + assert entry["phase"] == 1 and entry["can_restart"] is True + assert entry["args"]["username"]["required"] is True and entry["args"]["username"]["secret"] is True + assert entry["args"]["password"]["required"] is True and entry["args"]["password"]["secret"] is True + assert {spec["config"] for spec in entry["args"].values()} == {"wallbox_username", "wallbox_password", "wallbox_automatic", "wallbox_control", "wallbox_poll_seconds"} + parameters = inspect.signature(WallboxAPI.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 is registered with matching config keys") + + +def test_component_poll_seconds_is_clamped(): + """The poll interval is a whole number of minutes between the limits.""" + assert _make_component(poll_seconds=120).poll_seconds == 120 + assert _make_component(poll_seconds=90).poll_seconds == 120 + assert _make_component(poll_seconds=5).poll_seconds == MIN_POLL_SECONDS + assert _make_component(poll_seconds=99999).poll_seconds == MAX_POLL_SECONDS + assert _make_component(poll_seconds="junk").poll_seconds == 120 + print(" ✓ Poll interval is rounded and clamped") + + +def test_component_missing_credentials(): + """Without both credentials there is no transport and run() fails.""" + component = WallboxAPI(MockBase(), username=None, password="secret") + assert component.transport is None + assert run_async(component.run(0, True)) is False + print(" ✓ Missing credentials leave the component failed") + + +def test_component_publishes_one_charger(): + """One charger without Eco-Smart publishes exactly the expected entities and values.""" + component = _make_component() + assert run_async(component.run(0, True)) is True + + entities = component.base.entities + prefix = "predbat_wallbox_101" + wallbox_entities = {name for name in entities if "_wallbox_" in name} + assert wallbox_entities == { + "sensor.{}_status".format(prefix), "sensor.{}_power".format(prefix), "sensor.{}_session_energy".format(prefix), + "binary_sensor.{}_connected".format(prefix), "binary_sensor.{}_charging".format(prefix), + "switch.{}_charging".format(prefix), "switch.{}_locked".format(prefix), "number.{}_max_charging_current".format(prefix), + }, wallbox_entities + assert entities["sensor.{}_status".format(prefix)]["state"] == "Charging" + status_attributes = entities["sensor.{}_status".format(prefix)]["attributes"] + assert status_attributes["status_id"] == 193 and status_attributes["serial_number"] == "900001" and status_attributes["name"] == "Garage" + assert entities["sensor.{}_power".format(prefix)]["state"] == 7200.0 + assert entities["sensor.{}_power".format(prefix)]["attributes"]["unit_of_measurement"] == "W" + assert entities["sensor.{}_session_energy".format(prefix)]["state"] == 4.5 + assert entities["binary_sensor.{}_connected".format(prefix)]["state"] == "on" + assert entities["binary_sensor.{}_charging".format(prefix)]["state"] == "on" + assert entities["switch.{}_charging".format(prefix)]["state"] == "on" + assert entities["switch.{}_locked".format(prefix)]["state"] == "off" + number = entities["number.{}_max_charging_current".format(prefix)] + assert number["state"] == 32 and number["attributes"]["min"] == 6 and number["attributes"]["max"] == 32 and number["attributes"]["step"] == 1 + assert component.last_success_timestamp is not None + print(" ✓ One charger publishes the expected entities") + + +def test_component_publishes_two_chargers_and_eco_smart(): + """Each charger gets its own entities, and the Eco-Smart select appears only where supported.""" + component = _make_component({202: _status(status_id=161, power=0, energy=0), 101: _status(eco={"enabled": True, "mode": 1})}) + assert run_async(component.run(0, True)) is True + + entities = component.base.entities + assert entities["sensor.predbat_wallbox_202_status"]["state"] == "Ready" + assert entities["binary_sensor.predbat_wallbox_202_connected"]["state"] == "off" + assert entities["switch.predbat_wallbox_202_charging"]["state"] == "off" + select = entities["select.predbat_wallbox_101_eco_smart"] + assert select["state"] == "full_solar" and select["attributes"]["options"] == ["off", "eco_mode", "full_solar"] + assert "select.predbat_wallbox_202_eco_smart" not in entities + assert [charger.charger_id for charger in component.ordered_chargers()] == ["101", "202"] + print(" ✓ Two chargers publish separately, Eco-Smart only where supported") + + +def test_component_poll_cadence(): + """Status is polled on the first run and every poll_seconds; the charger list every 30 minutes.""" + component = _make_component() + run_async(component.run(0, True)) + assert component.transport.count("get_status") == 1 and component.transport.count("list_chargers") == 1 + run_async(component.run(60, False)) + assert component.transport.count("get_status") == 1, "60s is between polls at poll_seconds=120" + run_async(component.run(120, False)) + assert component.transport.count("get_status") == 2 + assert component.transport.count("list_chargers") == 1, "The charger list is not refetched every poll" + run_async(component.run(1800, False)) + assert component.transport.count("list_chargers") == 2 + print(" ✓ Poll cadence follows poll_seconds and the 30 minute list refresh") + + +def test_component_no_chargers(): + """An account with no chargers warns on the first run and does not stamp a success.""" + component = _make_component({}) + assert run_async(component.run(0, True)) is True + assert _logged(component, "no chargers were found") + assert component.last_success_timestamp is None + assert not [name for name in component.base.entities if "_wallbox_" in name] + print(" ✓ An account with no chargers warns and publishes nothing") + + +def test_component_one_charger_failing_does_not_blank_the_other(): + """A status failure on one charger keeps the other published and keeps the charger order.""" + component = _make_component({101: _status(), 202: _status(status_id=161, power=0)}) + run_async(component.run(0, True)) + component.transport.errors[("get_status", 202)] = WallboxApiError("HTTP 500") + component.transport.statuses[101] = _status(status_id=178, power=0) + + assert run_async(component.run(120, False)) is True + assert component.base.entities["sensor.predbat_wallbox_101_status"]["state"] == "Paused" + assert component.stale_ids == {"202"} + assert [charger.charger_id for charger in component.ordered_chargers()] == ["101", "202"], "Charger N must stay car N" + assert _logged(component, "could not read charger 202") + + assert run_async(component.run(240, False)) is True + assert component.stale_ids == set() + print(" ✓ One failing charger does not blank the other") + + +def test_component_every_charger_failing_fails_the_cycle(): + """When no charger could be read the cycle fails and no success is stamped.""" + component = _make_component() + component.transport.errors[("get_status",)] = WallboxApiError("HTTP 500") + assert run_async(component.run(0, True)) is False + assert component.last_success_timestamp is None + assert _logged(component, "poll failed") + print(" ✓ A poll that reads no charger fails the cycle") + + +def test_component_auth_failure(): + """Bad credentials log an error naming the config keys and fail the cycle.""" + component = _make_component() + component.transport.errors[("list_chargers",)] = WallboxAuthError("rejected") + assert run_async(component.run(0, True)) is False + assert _logged(component, "Error: wallbox:") and _logged(component, "wallbox_username") + print(" ✓ Bad credentials are reported as an error") + + +def test_component_rate_limit_backoff(): + """A 429 skips polls on a doubling back-off capped at 15 minutes, then recovers.""" + component = _make_component() + run_async(component.run(0, True)) + + component.transport.errors[("get_status",)] = WallboxRateLimitError("429") + assert run_async(component.run(120, False)) is True, "A rate limit after start-up is not a failed cycle" + assert component.skip_cycles == 2 and _logged(component, "rate limited") + + polls = component.transport.count("get_status") + run_async(component.run(180, False)) + run_async(component.run(240, False)) + assert component.transport.count("get_status") == polls, "Both skipped cycles made no call" + + for expected in (4, 8, 15, 15): + component.skip_cycles = 0 + component.transport.errors[("get_status",)] = WallboxRateLimitError("429") + run_async(component.run(120, False)) + assert component.skip_cycles == expected, (expected, component.skip_cycles) + + component.skip_cycles = 0 + assert run_async(component.run(120, False)) is True + assert component.backoff_cycles == 0, "A good poll resets the back-off" + print(" ✓ Rate limit back-off doubles, caps at 15 minutes and resets") + + +def test_component_rate_limit_on_first_run_fails(): + """A 429 on the very first run fails it, so the component is not marked started with no data.""" + component = _make_component() + component.transport.errors[("list_chargers",)] = WallboxRateLimitError("429") + assert run_async(component.run(0, True)) is False + # The retry of the first run must poll, not be swallowed by the skip counter + assert run_async(component.run(0, True)) is True + assert "sensor.predbat_wallbox_101_status" in component.base.entities + print(" ✓ A rate limit on the first run fails it and the retry polls") +``` + +Add all twelve to `test_wallbox()`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: `ImportError: cannot import name 'WallboxAPI' from 'wallbox'`. + +- [ ] **Step 3: Register the component** + +Run `impact({target: "COMPONENT_LIST", direction: "upstream"})` and `impact({target: "APPS_SCHEMA", direction: "upstream"})` and report the result before editing; both are additive dict entries. + +In `apps/predbat/components.py`, after the closing `},` of the `"myenergi"` entry: + +```python + "wallbox": { + "class": "wallbox.WallboxAPI", + "name": "Wallbox Charger", + "event_filter": "predbat_wallbox_", + "args": { + "username": {"required": True, "secret": True, "config": "wallbox_username"}, + "password": {"required": True, "secret": True, "config": "wallbox_password"}, + "automatic": {"required": False, "config": "wallbox_automatic", "default": True}, + "wallbox_control": {"required": False, "config": "wallbox_control", "default": False}, + "poll_seconds": {"required": False, "config": "wallbox_poll_seconds", "default": 120}, + }, + "phase": 1, + "can_restart": True, + }, +``` + +In `apps/predbat/config.py`, after `"myenergi_zappi_control": {"type": "boolean"},`: + +```python + "wallbox_username": {"type": "string", "empty": False}, + "wallbox_password": {"type": "string", "empty": False}, + "wallbox_automatic": {"type": "boolean"}, + "wallbox_control": {"type": "boolean"}, + "wallbox_poll_seconds": {"type": "integer", "zero": False}, +``` + +- [ ] **Step 4: Write the component** + +In `wallbox.py` add `from component_base import ComponentBase` to the imports, add these constants, and add the class above `run_wallbox_cli`: + +```python +# components.py's is_alive() fails a component whose last successful update is more than +# 60 minutes old, so the poll interval is capped at half that +MIN_POLL_SECONDS = 60 +MAX_POLL_SECONDS = 30 * 60 +DEFAULT_POLL_SECONDS = 120 +CHARGER_LIST_SECONDS = 30 * 60 + +# Rate limit back-off, in 60 second run cycles. The cap keeps recovery well inside the +# 60 minute health window. +RATE_LIMIT_MIN_CYCLES = 2 +RATE_LIMIT_MAX_CYCLES = 15 + +MIN_CHARGING_CURRENT = 6 +# Used as the upper bound only when the charger does not report one +DEFAULT_MAX_CHARGING_CURRENT = 32 + +wallbox_attribute_table = { + "status": {"friendly_name": "Wallbox Status", "icon": "mdi:information-outline"}, + "power": {"friendly_name": "Wallbox Power", "icon": "mdi:lightning-bolt", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + "session_energy": {"friendly_name": "Wallbox Session Energy", "icon": "mdi:lightning-bolt", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total_increasing"}, + "connected": {"friendly_name": "Wallbox Car Connected", "icon": "mdi:ev-plug-type2"}, + "charging": {"friendly_name": "Wallbox Charging", "icon": "mdi:battery-charging"}, + "charging_switch": {"friendly_name": "Wallbox Charge", "icon": "mdi:ev-station"}, + "locked": {"friendly_name": "Wallbox Locked", "icon": "mdi:lock"}, + "max_charging_current": {"friendly_name": "Wallbox Maximum Charging Current", "icon": "mdi:current-ac", "unit_of_measurement": "A", "min": MIN_CHARGING_CURRENT, "step": 1}, + "eco_smart": {"friendly_name": "Wallbox Eco-Smart", "icon": "mdi:solar-power", "options": ECO_SMART_OPTIONS}, + "control": {"friendly_name": "Wallbox Charge Control", "icon": "mdi:ev-station"}, +} + + +class WallboxAPI(ComponentBase): + """Wallbox component: monitoring, controls and Predbat-led charging for Wallbox chargers.""" + + def initialize(self, username, password, automatic=True, wallbox_control=False, poll_seconds=120): + """Set up component state and the transport.""" + self.automatic = automatic + self.wallbox_control = bool(wallbox_control) + # ComponentBase.start() calls run() every 60 seconds, so the interval is a whole number of those + self.poll_seconds = min(MAX_POLL_SECONDS, max(MIN_POLL_SECONDS, int(round(_to_float(poll_seconds, DEFAULT_POLL_SECONDS) / 60.0)) * 60)) + + self.chargers = {} + self.charger_ids = [] + self.stale_ids = set() + self.queued_events = [] + self.skip_cycles = 0 + self.backoff_cycles = 0 + self.permission_warned = False + self._auto_configured = False + self.control_active = False + self.control_enabled = True + self.transport = None + + if not username or not password: + self.log("Error: wallbox: wallbox_username and wallbox_password must both be set") + return + self.transport = WallboxTransport(self.log, username, password) + + def entity_prefix(self, charger): + """Return the entity name prefix for a charger, e.g. predbat_wallbox_12345.""" + return "{}_wallbox_{}".format(self.prefix, charger.charger_id) + + def ordered_chargers(self): + """The chargers in id order. This order is what makes charger N the same thing as car N.""" + return [self.chargers[charger_id] for charger_id in sorted(self.chargers)] + + def warn_permission(self): + """Say once that the account cannot control the charger.""" + if not self.permission_warned: + self.log("Warn: wallbox: Wallbox refused a control - the account needs admin rights over the charger. Monitoring continues") + self.permission_warned = True + + async def load_control_state(self): + """Restore saved control state. Filled in with plan-led control.""" + + def enable_control(self): + """Decide whether plan-led control can run. Filled in with plan-led control.""" + + def automatic_config(self): + """Wire the charger entities into Predbat's car inputs. Filled in with automatic configuration.""" + + async def control_tick(self, now): + """Run one cycle of plan-led control. Filled in with plan-led control.""" + + async def run(self, seconds, first): + """Process queued control events, then poll and publish.""" + if not self.transport: + return False + if first: + await self.load_control_state() + self.enable_control() + self.skip_cycles = 0 + if self.skip_cycles > 0: + self.skip_cycles -= 1 + return True + try: + refresh = await self.process_events() + if first or refresh or (seconds % self.poll_seconds) == 0: + return await self.poll(seconds, first) + return True + except WallboxRateLimitError: + self.backoff_cycles = min(RATE_LIMIT_MAX_CYCLES, max(RATE_LIMIT_MIN_CYCLES, self.backoff_cycles * 2)) + self.skip_cycles = self.backoff_cycles + self.log("Warn: wallbox: rate limited by the Wallbox API, pausing polling for {} minutes".format(self.backoff_cycles)) + # After start-up one rate limit is not a failure, but a first run that got no + # data must not mark the component started + return not first + except WallboxAuthError as exc: + self.log("Error: wallbox: sign in failed, check wallbox_username and wallbox_password: {}".format(exc)) + return False + except WallboxError as exc: + self.log("Warn: wallbox: poll failed: {}".format(exc)) + return False + + async def process_events(self): + """Run every queued control. Returns True when at least one ran, so the caller polls afresh.""" + refresh = False + while self.queued_events: + handler, *event_args = self.queued_events[0] + try: + await handler(*event_args) + except WallboxRateLimitError: + # Left on the queue, so it is retried once the back-off has passed + raise + except WallboxPermissionError: + self.warn_permission() + except WallboxError as exc: + self.log("Warn: wallbox: control failed: {}".format(exc)) + self.queued_events.pop(0) + refresh = True + return refresh + + async def poll(self, seconds, first): + """Read every charger, publish, and run automatic configuration and control.""" + if first or not self.charger_ids or (seconds % CHARGER_LIST_SECONDS) == 0: + self.charger_ids = sorted(await self.transport.list_chargers(), key=str) + if not self.charger_ids: + if first: + self.log("Warn: wallbox: signed in but no chargers were found on the account") + return True + + chargers = {} + stale_ids = set() + for charger_id in self.charger_ids: + key = str(charger_id) + try: + chargers[key] = normalise_charger(charger_id, await self.transport.get_status(charger_id)) + except WallboxApiError as exc: + self.log("Warn: wallbox: could not read charger {}: {}".format(charger_id, exc)) + # Keep the last record so the charger keeps its place in the car order, + # marked stale so control does not act on old state + if key in self.chargers: + chargers[key] = self.chargers[key] + stale_ids.add(key) + if len(stale_ids) == len(chargers): + raise WallboxApiError("no charger could be read") + + self.chargers = chargers + self.stale_ids = stale_ids + await self.publish_data() + if self.automatic and not self._auto_configured: + self.automatic_config() + self._auto_configured = True + try: + await self.control_tick(self.now_utc_exact) + except WallboxPermissionError: + self.warn_permission() + except WallboxApiError as exc: + # Monitoring succeeded, so a refused control is a warning, not a failed cycle + self.log("Warn: wallbox: charge control failed: {}".format(exc)) + self.backoff_cycles = 0 + self.update_success_timestamp() + return True + + async def publish_data(self): + """Publish every known charger as Predbat entities.""" + if self.control_active: + # Published only while control could act on it, so the switch is never a lie + self.dashboard_item("switch.{}_wallbox_control".format(self.prefix), state="on" if self.control_enabled else "off", attributes=wallbox_attribute_table["control"], app="wallbox") + for charger in self.ordered_chargers(): + prefix = self.entity_prefix(charger) + status_attributes = dict(wallbox_attribute_table["status"]) + status_attributes.update({"status_id": charger.status_id, "name": charger.name, "serial_number": charger.serial, "part_number": charger.part_number, "software_version": charger.software_version}) + current_attributes = dict(wallbox_attribute_table["max_charging_current"]) + current_attributes["max"] = charger.max_available_current if charger.max_available_current >= MIN_CHARGING_CURRENT else DEFAULT_MAX_CHARGING_CURRENT + + self.dashboard_item("sensor.{}_status".format(prefix), state=charger.status, attributes=status_attributes, app="wallbox") + self.dashboard_item("sensor.{}_power".format(prefix), state=charger.power_w, attributes=wallbox_attribute_table["power"], app="wallbox") + self.dashboard_item("sensor.{}_session_energy".format(prefix), state=charger.session_energy_kwh, attributes=wallbox_attribute_table["session_energy"], app="wallbox") + self.dashboard_item("binary_sensor.{}_connected".format(prefix), state="on" if charger.connected else "off", attributes=wallbox_attribute_table["connected"], app="wallbox") + self.dashboard_item("binary_sensor.{}_charging".format(prefix), state="on" if charger.charging else "off", attributes=wallbox_attribute_table["charging"], app="wallbox") + self.dashboard_item("switch.{}_charging".format(prefix), state="on" if charger.charging else "off", attributes=wallbox_attribute_table["charging_switch"], app="wallbox") + self.dashboard_item("switch.{}_locked".format(prefix), state="on" if charger.locked else "off", attributes=wallbox_attribute_table["locked"], app="wallbox") + self.dashboard_item("number.{}_max_charging_current".format(prefix), state=charger.max_charging_current, attributes=current_attributes, app="wallbox") + if charger.eco_smart is not None: + self.dashboard_item("select.{}_eco_smart".format(prefix), state=charger.eco_smart, attributes=wallbox_attribute_table["eco_smart"], app="wallbox") +``` + +Note on `len(stale_ids) == len(chargers)`: a first poll where every status call fails leaves `chargers` empty and `stale_ids` empty, so `0 == 0` raises, which is the intended "nothing could be read" failure. + +- [ ] **Step 5: Run the tests to verify they pass** + +Expected: twelve more `✓` lines. Also run `./run_all --test components > /tmp/wallbox_components.log 2>&1; tail -20 /tmp/wallbox_components.log` (the quick-suite test that imports every registered component); expected: pass. + +- [ ] **Step 6: Commit** + +Run `detect_changes()` and confirm only `wallbox.py`, the test file, `COMPONENT_LIST` and `APPS_SCHEMA` are affected. + +```bash +git add apps/predbat/wallbox.py apps/predbat/components.py apps/predbat/config.py apps/predbat/tests/test_wallbox.py +git commit -m "feat(wallbox): component with polling, publishing and rate limit back-off" +``` + +--- + +### Task 6: Manual controls + +**Files:** +- Modify: `apps/predbat/wallbox.py` (`WallboxAPI`) +- Modify: `apps/predbat/tests/test_wallbox.py` + +**Interfaces:** +- Consumes: `queued_events`, `process_events()`, `entity_prefix()`, the transport control methods, `ECO_SMART_OPTIONS`, `MIN_CHARGING_CURRENT`, `DEFAULT_MAX_CHARGING_CURRENT`. +- Produces: `charger_for_entity(entity_id) -> WallboxCharger | None`; `async switch_event(entity_id, service)`, `async number_event(entity_id, value)`, `async select_event(entity_id, value)` (queue only); `async switch_event_handler(entity_id, service)`, `async number_event_handler(entity_id, value)`, `async select_event_handler(entity_id, value)`. Task 8 adds a `_wallbox_control` branch at the top of `switch_event_handler`. + +- [ ] **Step 1: Write the failing tests** + +```python +def _started_component(statuses=None, **overrides): + """A component that has completed its first poll, with the transport call log cleared.""" + component = _make_component(statuses, **overrides) + run_async(component.run(0, True)) + component.transport.calls = [] + return component + + +def test_controls_queue_rather_than_call(): + """An event only queues; the API call happens in the next run(), followed by a fresh poll.""" + component = _started_component() + run_async(component.switch_event("switch.predbat_wallbox_101_charging", "turn_off")) + assert component.transport.calls == [] and len(component.queued_events) == 1 + + run_async(component.run(60, False)) + assert component.transport.calls[0] == ("pause", 101) + assert ("get_status", 101) in component.transport.calls, "A control is followed by a re-poll even between polls" + assert component.queued_events == [] + print(" ✓ Controls are queued and followed by a re-poll") + + +def test_switch_controls(): + """The charging and lock switches map to resume/pause and lock/unlock.""" + cases = [ + ("switch.predbat_wallbox_101_charging", "turn_on", ("resume", 101)), + ("switch.predbat_wallbox_101_charging", "turn_off", ("pause", 101)), + ("switch.predbat_wallbox_101_locked", "turn_on", ("set_locked", 101, True)), + ("switch.predbat_wallbox_101_locked", "turn_off", ("set_locked", 101, False)), + ] + for entity_id, service, expected in cases: + component = _started_component() + run_async(component.switch_event_handler(entity_id, service)) + assert component.transport.calls == [expected], (entity_id, service, component.transport.calls) + + component = _started_component() + run_async(component.switch_event_handler("switch.predbat_wallbox_101_charging", "toggle")) + run_async(component.switch_event_handler("switch.predbat_wallbox_999_charging", "turn_on")) + run_async(component.switch_event_handler("switch.predbat_wallbox_101_something", "turn_on")) + assert component.transport.calls == [], "Unknown services, chargers and entities send nothing" + print(" ✓ Switch controls send the right calls") + + +def test_number_control_clamps_and_ignores_junk(): + """The charging current is clamped to 6..max available, and a non-number is ignored.""" + component = _started_component() + run_async(component.number_event_handler("number.predbat_wallbox_101_max_charging_current", 16)) + run_async(component.number_event_handler("number.predbat_wallbox_101_max_charging_current", "20.0")) + run_async(component.number_event_handler("number.predbat_wallbox_101_max_charging_current", 2)) + run_async(component.number_event_handler("number.predbat_wallbox_101_max_charging_current", 500)) + assert component.transport.calls == [("set_max_charging_current", 101, 16), ("set_max_charging_current", 101, 20), ("set_max_charging_current", 101, 6), ("set_max_charging_current", 101, 32)] + + component.transport.calls = [] + run_async(component.number_event_handler("number.predbat_wallbox_101_max_charging_current", "junk")) + run_async(component.number_event_handler("number.predbat_wallbox_101_max_charging_current", None)) + run_async(component.number_event_handler("number.predbat_wallbox_101_other", 16)) + assert component.transport.calls == [], "Junk values and other number entities send nothing" + print(" ✓ Charging current is clamped and junk is ignored") + + +def test_select_control(): + """Eco-Smart accepts only its three options, and only on a charger that supports it.""" + component = _started_component({101: _status(eco={"enabled": False, "mode": 0})}) + run_async(component.select_event_handler("select.predbat_wallbox_101_eco_smart", "full_solar")) + run_async(component.select_event_handler("select.predbat_wallbox_101_eco_smart", "turbo")) + assert component.transport.calls == [("set_eco_smart", 101, "full_solar")] + + unsupported = _started_component() + run_async(unsupported.select_event_handler("select.predbat_wallbox_101_eco_smart", "eco_mode")) + assert unsupported.transport.calls == [] + print(" ✓ Eco-Smart select sends only valid modes to chargers that support it") + + +def test_control_refused_for_rights_warns_once(): + """A rights refusal warns once across repeated attempts and does not fail the cycle.""" + component = _started_component() + for _ in range(2): + component.transport.errors[("set_locked", 101, True)] = WallboxPermissionError("403") + run_async(component.switch_event("switch.predbat_wallbox_101_locked", "turn_on")) + assert run_async(component.run(60, False)) is True + assert len([message for message in component.log_messages if "admin rights" in message]) == 1 + print(" ✓ A rights refusal warns once and monitoring continues") + + +def test_control_failure_is_logged_and_not_retried(): + """An API failure on a control is logged, dropped from the queue, and the cycle still succeeds.""" + component = _started_component() + component.transport.errors[("pause", 101)] = WallboxApiError("HTTP 500") + run_async(component.switch_event("switch.predbat_wallbox_101_charging", "turn_off")) + assert run_async(component.run(60, False)) is True + assert _logged(component, "control failed") and component.queued_events == [] + print(" ✓ A failed control is logged and dropped") + + +def test_control_survives_a_rate_limit(): + """A control that hits the rate limit stays queued and is sent after the back-off.""" + component = _started_component() + component.transport.errors[("pause", 101)] = WallboxRateLimitError("429") + run_async(component.switch_event("switch.predbat_wallbox_101_charging", "turn_off")) + run_async(component.run(60, False)) + assert len(component.queued_events) == 1 and component.skip_cycles == 2 + + component.skip_cycles = 0 + component.transport.calls = [] + run_async(component.run(120, False)) + assert component.transport.calls[0] == ("pause", 101) and component.queued_events == [] + print(" ✓ A rate limited control is retried after the back-off") + + +def test_charger_for_entity_requires_a_whole_id(): + """Charger 10 must not claim charger 101's entities.""" + component = _started_component({10: _status(), 101: _status()}) + assert component.charger_for_entity("switch.predbat_wallbox_101_charging").charger_id == "101" + assert component.charger_for_entity("switch.predbat_wallbox_10_charging").charger_id == "10" + assert component.charger_for_entity("switch.predbat_wallbox_control") is None + print(" ✓ Entities resolve to a whole charger id") +``` + +Add all eight to `test_wallbox()`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: `AttributeError: 'WallboxAPI' object has no attribute 'switch_event_handler'` (the base class `switch_event` is a no-op, so the first test fails on its queue assertion). + +- [ ] **Step 3: Write the implementation** + +Add to `WallboxAPI`: + +```python + def charger_for_entity(self, entity_id): + """Find the charger an entity belongs to, or None. + + The trailing underscore anchors the match to a whole id, so charger 10 cannot + claim charger 101's entities. + """ + for charger in self.chargers.values(): + if "{}_".format(self.entity_prefix(charger)) in entity_id: + return charger + return None + + def api_id(self, charger): + """The id in the form the API listed it, for a normalised charger.""" + for charger_id in self.charger_ids: + if str(charger_id) == charger.charger_id: + return charger_id + return charger.charger_id + + async def switch_event(self, entity_id, service): + """Queue a switch service call for the run loop.""" + 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.""" + self.queued_events.append((self.number_event_handler, entity_id, value)) + + async def select_event(self, entity_id, value): + """Queue a select change for the run loop.""" + self.queued_events.append((self.select_event_handler, entity_id, value)) + + async def switch_event_handler(self, entity_id, service): + """Pause/resume or lock/unlock in response to a charger switch.""" + if service not in ("turn_on", "turn_off"): + return + charger = self.charger_for_entity(entity_id) + if not charger: + return + turn_on = service == "turn_on" + if entity_id.endswith("_charging"): + self.log("Info: wallbox: {} charging on {}".format("resuming" if turn_on else "pausing", charger.name)) + if turn_on: + await self.transport.resume(self.api_id(charger)) + else: + await self.transport.pause(self.api_id(charger)) + elif entity_id.endswith("_locked"): + self.log("Info: wallbox: {} {}".format("locking" if turn_on else "unlocking", charger.name)) + await self.transport.set_locked(self.api_id(charger), turn_on) + + async def number_event_handler(self, entity_id, value): + """Set the maximum charging current, clamped to what the charger allows.""" + if not entity_id.endswith("_max_charging_current"): + return + charger = self.charger_for_entity(entity_id) + if not charger: + return + try: + amps = int(float(value)) + except (TypeError, ValueError): + self.log("Warn: wallbox: ignoring charging current '{}' for {}".format(value, charger.name)) + return + upper = charger.max_available_current if charger.max_available_current >= MIN_CHARGING_CURRENT else DEFAULT_MAX_CHARGING_CURRENT + amps = max(MIN_CHARGING_CURRENT, min(upper, amps)) + self.log("Info: wallbox: setting {} maximum charging current to {}A".format(charger.name, amps)) + await self.transport.set_max_charging_current(self.api_id(charger), amps) + + async def select_event_handler(self, entity_id, value): + """Set the Eco-Smart mode on a charger that supports it.""" + if not entity_id.endswith("_eco_smart") or value not in ECO_SMART_OPTIONS: + return + charger = self.charger_for_entity(entity_id) + if not charger or charger.eco_smart is None: + return + self.log("Info: wallbox: setting {} Eco-Smart to {}".format(charger.name, value)) + await self.transport.set_eco_smart(self.api_id(charger), value) +``` + +`api_id()` exists because the API lists ids as integers while entity names carry strings; calls go out with the id exactly as Wallbox listed it. The tests assert integer ids in the recorded calls, which pins this. + +- [ ] **Step 4: Run the tests to verify they pass** + +Expected: eight more `✓` lines. + +- [ ] **Step 5: Commit** + +```bash +git add apps/predbat/wallbox.py apps/predbat/tests/test_wallbox.py +git commit -m "feat(wallbox): pause, lock, charging current and Eco-Smart controls" +``` + +--- + +### Task 7: Automatic configuration + +**Files:** +- Modify: `apps/predbat/wallbox.py` (`WallboxAPI.automatic_config`) +- Modify: `apps/predbat/tests/test_wallbox.py` + +**Interfaces:** +- Consumes: `ordered_chargers()`, `entity_prefix()`, `ComponentBase.set_arg_auto(arg, value, overwrite=True)`, `set_arg(arg, value)`, `get_arg(arg, default)`. +- Produces: `automatic_config()` (sync), called once by `poll()` when `self.automatic` is on. + +- [ ] **Step 1: Write the failing tests** + +```python +def test_automatic_config_two_chargers(): + """Each argument is a per-car list in charger id order, and num_cars is raised to match.""" + component = _make_component({202: _status(), 101: _status()}) + run_async(component.run(0, True)) + args = component.base.args + assert args["car_charging_energy"] == ["sensor.predbat_wallbox_101_session_energy", "sensor.predbat_wallbox_202_session_energy"] + assert args["car_charging_planned"] == ["binary_sensor.predbat_wallbox_101_connected", "binary_sensor.predbat_wallbox_202_connected"] + assert args["car_charging_power"] == ["sensor.predbat_wallbox_101_power", "sensor.predbat_wallbox_202_power"] + assert args["car_charging_now"] == ["sensor.predbat_wallbox_101_power", "sensor.predbat_wallbox_202_power"] + assert args["num_cars"] == 2 + assert "car_charging_soc" not in args, "A Type 2 charger cannot report state of charge" + print(" ✓ Automatic configuration wires per-car lists in charger order") + + +def test_automatic_config_single_charger_is_still_a_list(): + """One charger still produces lists, which is what the per-car arguments expect.""" + component = _make_component() + run_async(component.run(0, True)) + assert component.base.args["car_charging_energy"] == ["sensor.predbat_wallbox_101_session_energy"] + assert component.base.args["num_cars"] == 1 + print(" ✓ A single charger is still wired as a list") + + +def test_automatic_config_keeps_user_values(): + """A user's car_charging_now is kept, and a larger num_cars is not reduced.""" + component = _make_component() + component.base.args["car_charging_now"] = ["binary_sensor.my_car_charging"] + component.base.args["num_cars"] = 3 + run_async(component.run(0, True)) + assert component.base.args["car_charging_now"] == ["binary_sensor.my_car_charging"] + assert component.base.args["num_cars"] == 3 + print(" ✓ User car_charging_now and a larger num_cars are kept") + + +def test_automatic_config_disabled(): + """With wallbox_automatic off nothing is wired.""" + component = _make_component(automatic=False) + run_async(component.run(0, True)) + assert "car_charging_energy" not in component.base.args and "num_cars" not in component.base.args + print(" ✓ Automatic configuration off wires nothing") + + +def test_automatic_config_runs_once(): + """A value the user changes after start-up is not put back by the next poll.""" + component = _make_component() + run_async(component.run(0, True)) + component.base.args["car_charging_energy"] = ["sensor.something_else"] + run_async(component.run(120, False)) + assert component.base.args["car_charging_energy"] == ["sensor.something_else"] + print(" ✓ Automatic configuration runs once") +``` + +Add all five to `test_wallbox()`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: `KeyError: 'car_charging_energy'`. + +- [ ] **Step 3: Write the implementation** + +Replace the placeholder `automatic_config` in `WallboxAPI`: + +```python + def automatic_config(self): + """Wire the charger entities into Predbat's car charging inputs. + + Each argument is a per-car list in charger id order, so charger N is car N. + car_charging_now takes the power sensors, which count as charging from + CAR_CHARGING_NOW_POWER_W; a value the user set in apps.yaml is kept. Session + energy resets with each session, which the shared incrementing-sensor handling + already copes with. car_charging_soc is not set: a Type 2 connector cannot + report the car's state of charge. + """ + chargers = self.ordered_chargers() + if not chargers: + return + energy_entities = ["sensor.{}_session_energy".format(self.entity_prefix(charger)) for charger in chargers] + planned_entities = ["binary_sensor.{}_connected".format(self.entity_prefix(charger)) for charger in chargers] + power_entities = ["sensor.{}_power".format(self.entity_prefix(charger)) for charger in chargers] + + self.log("Info: wallbox: setting car_charging_energy to {}".format(energy_entities)) + self.set_arg_auto("car_charging_energy", energy_entities) + self.log("Info: wallbox: setting car_charging_planned to {}".format(planned_entities)) + self.set_arg_auto("car_charging_planned", planned_entities) + self.log("Info: wallbox: setting car_charging_power and car_charging_now to {}".format(power_entities)) + self.set_arg_auto("car_charging_power", power_entities) + self.set_arg_auto("car_charging_now", power_entities, overwrite=False) + if _to_int(self.get_arg("num_cars", 0), 0) < len(chargers): + self.log("Info: wallbox: setting num_cars to {}".format(len(chargers))) + self.set_arg("num_cars", len(chargers)) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Expected: five more `✓` lines. + +- [ ] **Step 5: Commit** + +```bash +git add apps/predbat/wallbox.py apps/predbat/tests/test_wallbox.py +git commit -m "feat(wallbox): automatic car charging configuration" +``` + +--- + +### Task 8: Predbat-led charging + +**Files:** +- Modify: `apps/predbat/wallbox.py` (`WallboxAPI`) +- Modify: `apps/predbat/tests/test_wallbox.py` + +**Interfaces:** +- Consumes: `utils.parse_car_plan_windows(planned, now, local_tz)`, `utils.in_car_plan_window(windows, now)`, `ComponentBase.storage` (`async save(module, filename, data)`, `async load(module, filename)`, or `None`), `ordered_chargers()`, `stale_ids`, `api_id()`, `transport.pause/resume/resume_schedule`. +- Produces: `enable_control()`, `async load_control_state()`, `async save_control_state()`, `control_read_only_now() -> bool`, `refresh_car_windows(now) -> bool`, `async control_tick(now)`, `async control_charge(now)`, `async release_chargers()`; attributes `paused_by_predbat` (set of charger id str), `control_windows` (dict of car number to windows), `lock_warned` (set of charger id str); a `_wallbox_control` branch in `switch_event_handler`. + +- [ ] **Step 1: Write the failing tests** + +```python +CONTROL_TZ = pytz.timezone("Europe/London") +CONTROL_NOW = CONTROL_TZ.localize(datetime.datetime(2026, 10, 5, 23, 30)) + + +def _plan_window(start, end): + """Build one planned-window dict in the shape output.py publishes.""" + return {"start": start.strftime("%m-%d %H:%M:%S"), "end": end.strftime("%m-%d %H:%M:%S")} + + +# CONTROL_NOW is inside this window... +PLAN_INSIDE = [_plan_window(datetime.datetime(2026, 10, 5, 23, 0), datetime.datetime(2026, 10, 6, 1, 0))] +# ...and before this one +PLAN_OUTSIDE = [_plan_window(datetime.datetime(2026, 10, 6, 2, 0), datetime.datetime(2026, 10, 6, 4, 0))] + + +class _Storage: + """Minimal in-memory stand-in for the Storage component.""" + + def __init__(self): + """Start empty.""" + self.data = {} + + async def save(self, module, filename, data, **kwargs): + """Store a document.""" + self.data[(module, filename)] = data + + async def load(self, module, filename): + """Return a stored document, or None.""" + return self.data.get((module, filename)) + + +class _Components: + """Stand-in for the component registry, serving only the storage component.""" + + def __init__(self, storage): + """Hold the storage stand-in to serve.""" + self.storage = storage + + def get_component(self, name): + """Return the storage stand-in, and nothing else.""" + return self.storage if name == "storage" else None + + +def _control_component(plans, statuses=None, storage=None, **overrides): + """A component with control on, chargers loaded and car plans published. + + plans maps car number to a list of _plan_window() dicts. The chargers are loaded + directly rather than through run(), so each test calls control_tick() with a fixed clock. + """ + overrides.setdefault("wallbox_control", True) + component = _make_component(statuses, **overrides) + component.local_tz = CONTROL_TZ + component.base.local_tz = CONTROL_TZ + if storage is not None: + component.base.components = _Components(storage) + for car_n, windows in plans.items(): + postfix = "" if car_n == 0 else "_{}".format(car_n) + component.base.set_state_wrapper("binary_sensor.predbat_car_charging_slot" + postfix, "off", {"planned": windows}) + run_async(component.load_control_state()) + component.enable_control() + _load_chargers(component) + return component + + +def _load_chargers(component): + """Load the stub transport's current statuses into the component, as a poll would.""" + component.charger_ids = sorted(component.transport.statuses, key=str) + component.chargers = {str(charger_id): normalise_charger(charger_id, payload) for charger_id, payload in component.transport.statuses.items()} + component.stale_ids = set() + component.transport.calls = [] + + +def test_control_needs_automatic(): + """Control stays off, with a warning, unless automatic configuration is on.""" + assert _control_component({}).control_active is True + without_auto = _control_component({}, automatic=False) + assert without_auto.control_active is False and _logged(without_auto, "needs wallbox_automatic") + assert _control_component({}, wallbox_control=False).control_active is False + print(" ✓ Control needs wallbox_control and wallbox_automatic") + + +def test_control_resumes_inside_a_window(): + """A Paused charger inside its car's window is resumed.""" + component = _control_component({0: PLAN_INSIDE}, {101: _status(status_id=178, power=0)}) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("resume", 101)] + print(" ✓ A paused charger inside a window is resumed") + + +def test_control_pauses_outside_a_window(): + """A Charging charger outside its car's window is paused, and Predbat remembers it did so.""" + component = _control_component({0: PLAN_OUTSIDE}) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("pause", 101)] + assert component.paused_by_predbat == {"101"} + print(" ✓ A charging charger outside a window is paused") + + +def test_control_leaves_other_states_alone(): + """Scheduled, Waiting, Ready, Locked and already-correct chargers are sent nothing.""" + for plan, status_id in [(PLAN_INSIDE, 177), (PLAN_INSIDE, 179), (PLAN_INSIDE, 180), (PLAN_INSIDE, 164), (PLAN_INSIDE, 161), (PLAN_INSIDE, 193), (PLAN_INSIDE, 210), (PLAN_OUTSIDE, 178), (PLAN_OUTSIDE, 177), (PLAN_OUTSIDE, 161), (PLAN_OUTSIDE, 210)]: + component = _control_component({0: plan}, {101: _status(status_id=status_id, power=0)}) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [], (status_id, component.transport.calls) + print(" ✓ Scheduled, Waiting, Ready and Locked chargers are left alone") + + +def test_control_warns_once_about_a_locked_charger(): + """A locked charger with a car connected inside a window is warned about once, never unlocked.""" + component = _control_component({0: PLAN_INSIDE}, {101: _status(status_id=210, power=0, locked=True)}) + run_async(component.control_tick(CONTROL_NOW)) + run_async(component.control_tick(CONTROL_NOW)) + assert len([message for message in component.log_messages if "is locked" in message]) == 1 + assert component.transport.count("set_locked") == 0 + print(" ✓ A locked charger is warned about once and never unlocked") + + +def test_control_is_per_car(): + """Charger N follows car N's plan: the second charger reads the _1 slot sensor.""" + statuses = {101: _status(status_id=178, power=0), 202: _status(status_id=193)} + component = _control_component({0: PLAN_INSIDE, 1: PLAN_OUTSIDE}, statuses) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("resume", 101), ("pause", 202)] + print(" ✓ Each charger follows its own car's plan") + + +def test_control_skips_a_charger_with_no_plan(): + """A charger whose car has no slot sensor is left alone, not paused as if outside a window.""" + statuses = {101: _status(status_id=193), 202: _status(status_id=193)} + component = _control_component({0: PLAN_INSIDE}, statuses) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [], "Charger 202 has no car 1 plan, so it must not be paused" + + nothing_planned = _control_component({}) + run_async(nothing_planned.control_tick(CONTROL_NOW)) + assert nothing_planned.transport.calls == [], "Before Predbat has planned anything, no charger is touched" + print(" ✓ A charger with no car plan is left alone") + + +def test_control_skips_a_stale_charger(): + """A charger whose last status read failed is not controlled on old state.""" + component = _control_component({0: PLAN_OUTSIDE}) + component.stale_ids = {"101"} + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [] + print(" ✓ A stale charger is not controlled") + + +def test_control_forgets_an_unplugged_charger(): + """Once the car is unplugged, Predbat no longer counts the charger as one it paused.""" + component = _control_component({0: PLAN_OUTSIDE}) + run_async(component.control_tick(CONTROL_NOW)) + assert component.paused_by_predbat == {"101"} + component.transport.statuses[101] = _status(status_id=161, power=0) + _load_chargers(component) + run_async(component.control_tick(CONTROL_NOW)) + assert component.paused_by_predbat == set() and component.transport.calls == [] + print(" ✓ An unplugged charger is forgotten") + + +def test_control_releases_on_read_only_and_switch_off(): + """Read only mode and the control switch both resume what Predbat paused and hand back the schedule.""" + for release in ("read_only", "switch"): + component = _control_component({0: PLAN_OUTSIDE}) + run_async(component.control_tick(CONTROL_NOW)) + component.transport.statuses[101] = _status(status_id=178, power=0) + _load_chargers(component) + + if release == "read_only": + component.base.args["set_read_only"] = True + else: + run_async(component.switch_event_handler("switch.predbat_wallbox_control", "turn_off")) + assert component.control_enabled is False + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("resume", 101), ("resume_schedule", 101)], (release, component.transport.calls) + assert component.paused_by_predbat == set() + + component.transport.calls = [] + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [], "Release happens once, and control stays quiet while released" + print(" ✓ Read only mode and the control switch release the chargers") + + +def test_control_does_not_release_a_charger_it_did_not_pause(): + """A charger the user paused by hand is not resumed when Predbat releases.""" + component = _control_component({0: PLAN_OUTSIDE}, {101: _status(status_id=178, power=0)}) + component.base.args["set_read_only"] = True + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [] + print(" ✓ A charger Predbat did not pause is not resumed on release") + + +def test_control_state_survives_a_restart(): + """After a restart with control turned off, the charger Predbat paused is still released.""" + storage = _Storage() + component = _control_component({0: PLAN_OUTSIDE}, storage=storage) + run_async(component.control_tick(CONTROL_NOW)) + assert storage.data[("wallbox", "control_state")] == {"control_enabled": True, "paused": ["101"]} + + restarted = _control_component({0: PLAN_OUTSIDE}, {101: _status(status_id=178, power=0)}, storage=storage, wallbox_control=False) + assert restarted.control_active is False and restarted.paused_by_predbat == {"101"} + run_async(restarted.control_tick(CONTROL_NOW)) + assert restarted.transport.calls == [("resume", 101), ("resume_schedule", 101)] + assert storage.data[("wallbox", "control_state")]["paused"] == [] + print(" ✓ A restart with control off still releases the charger") + + +def test_control_switch_state_survives_a_restart(): + """The control switch stays off across a restart and is published in that state from the start.""" + storage = _Storage() + component = _control_component({0: PLAN_OUTSIDE}, storage=storage) + run_async(component.switch_event_handler("switch.predbat_wallbox_control", "turn_off")) + + restarted = _make_component(wallbox_control=True) + restarted.base.components = _Components(storage) + run_async(restarted.run(0, True)) + assert restarted.control_enabled is False + assert restarted.base.entities["switch.predbat_wallbox_control"]["state"] == "off" + print(" ✓ The control switch state survives a restart") + + +def test_control_switch_is_published_only_when_available(): + """The control switch appears only when control can actually run.""" + on = _make_component(wallbox_control=True) + run_async(on.run(0, True)) + assert on.base.entities["switch.predbat_wallbox_control"]["state"] == "on" + off = _make_component(wallbox_control=False) + run_async(off.run(0, True)) + assert "switch.predbat_wallbox_control" not in off.base.entities + print(" ✓ The control switch is published only when control is available") + + +def test_control_without_storage_still_works(): + """With no Storage component, control runs and nothing is raised.""" + component = _control_component({0: PLAN_OUTSIDE}) + assert component.storage is None + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("pause", 101)] + print(" ✓ Control works without the Storage component") + + +def test_control_failure_does_not_fail_the_cycle(): + """A refused pause during a poll is a warning; monitoring still succeeds and it is retried next poll.""" + component = _make_component(wallbox_control=True) + component.local_tz = CONTROL_TZ + component.base.local_tz = CONTROL_TZ + component.base.set_state_wrapper("binary_sensor.predbat_car_charging_slot", "off", {"planned": []}) + component.transport.errors[("pause", 101)] = WallboxApiError("HTTP 500") + assert run_async(component.run(0, True)) is True + assert _logged(component, "charge control failed") and component.paused_by_predbat == set() + run_async(component.run(120, False)) + assert component.paused_by_predbat == {"101"} + print(" ✓ A refused control is a warning and is retried") +``` + +Add all sixteen to `test_wallbox()`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: `AssertionError` in `test_control_needs_automatic` (the placeholder `enable_control` never sets `control_active`). + +- [ ] **Step 3: Write the implementation** + +Add `from utils import parse_car_plan_windows, in_car_plan_window` to the imports and these constants: + +```python +WALLBOX_STORAGE_MODULE = "wallbox" +WALLBOX_CONTROL_STATE = "control_state" +``` + +In `initialize`, next to `self.control_active = False`, add: + +```python + self.paused_by_predbat = set() + self.control_windows = {} + self.lock_warned = set() + self.control_state_loaded = False +``` + +Replace the three placeholders (`load_control_state`, `enable_control`, `control_tick`) and add the rest: + +```python + def enable_control(self): + """Decide whether Predbat-led charging can run, and say why when it cannot. + + Control needs automatic configuration because a charger is driven from its own + car's plan, and it is automatic configuration that makes charger N car N. + """ + self.control_active = False + if not self.wallbox_control: + return + if not self.automatic: + self.log("Warn: wallbox: wallbox_control needs wallbox_automatic to map each charger to a car, charge control is disabled") + return + self.control_active = True + self.log("Info: wallbox: Predbat-led charge control enabled") + + async def load_control_state(self): + """Restore the control switch and the list of chargers Predbat paused. + + The paused list is what lets a restart with control turned off still release a + charger an earlier session left paused. Fails soft with no Storage component. + """ + if self.control_state_loaded or self.storage is None: + return + try: + saved = await self.storage.load(WALLBOX_STORAGE_MODULE, WALLBOX_CONTROL_STATE) + except Exception as exc: + self.log("Warn: wallbox: could not read the saved charge control state: {}".format(exc)) + return + self.control_state_loaded = True + if not isinstance(saved, dict): + return + if "control_enabled" in saved: + self.control_enabled = bool(saved["control_enabled"]) + paused = saved.get("paused") + if isinstance(paused, list): + self.paused_by_predbat = {str(charger_id) for charger_id in paused} + + async def save_control_state(self): + """Persist the control switch and the list of chargers Predbat paused.""" + if self.storage is None: + return + try: + await self.storage.save(WALLBOX_STORAGE_MODULE, WALLBOX_CONTROL_STATE, {"control_enabled": self.control_enabled, "paused": sorted(self.paused_by_predbat)}) + except Exception as exc: + self.log("Warn: wallbox: could not save the charge control state: {}".format(exc)) + + def control_read_only_now(self): + """Is Predbat in read only mode - the live attribute first, then the config argument.""" + read_only = getattr(self.base, "set_read_only", None) + if read_only is None: + read_only = self.get_arg("set_read_only", False) + return bool(read_only) + + def refresh_car_windows(self, now): + """Read each car's planned charging windows into control_windows. + + A car with no slot sensor yet is simply absent, so its charger is left alone. + Returns True once at least one car's plan has been read. + """ + windows = {} + for car_n in range(len(self.chargers)): + postfix = "" if car_n == 0 else "_{}".format(car_n) + planned = self.get_state_wrapper("binary_sensor.{}_car_charging_slot{}".format(self.prefix, postfix), attribute="planned") + if planned is None: + continue + windows[car_n] = parse_car_plan_windows(planned, now, self.local_tz) + self.control_windows = windows + return bool(windows) + + async def control_tick(self, now): + """Run one cycle of charge control, releasing rather than just going quiet. + + Called on every poll whether or not control is available, because a charger a + previous session paused must be released even when control has since been turned off. + """ + reason = None + if not self.control_active: + reason = "charge control is not enabled" + elif self.control_read_only_now(): + reason = "Predbat is in read only mode" + elif not self.control_enabled: + reason = "the charge control switch is off" + if reason: + if self.paused_by_predbat: + self.log("Info: wallbox: releasing the chargers because {}".format(reason)) + await self.release_chargers() + return + await self.control_charge(now) + + async def release_chargers(self): + """Resume every charger Predbat paused and hand each back to its own schedule. + + A charger that is no longer paused - resumed by hand, or unplugged - is only + forgotten. The record is saved even if a call fails part way, so what was + released stays released and the rest is retried on the next poll. + """ + try: + for charger_id in sorted(self.paused_by_predbat): + charger = self.chargers.get(charger_id) + if charger and charger_id in self.stale_ids: + continue + if charger and charger.paused: + self.log("Info: wallbox: releasing {}".format(charger.name)) + await self.transport.resume(self.api_id(charger)) + await self.transport.resume_schedule(self.api_id(charger)) + self.paused_by_predbat.discard(charger_id) + finally: + await self.save_control_state() + + async def control_charge(self, now): + """Drive each charger from its car's plan: resume a paused one inside a window, pause a charging one outside. + + Nothing else is sent. A Scheduled charger is following its own schedule and a + Waiting one is waiting on the car, and resume does nothing for either. + """ + if not self.refresh_car_windows(now): + return + before = set(self.paused_by_predbat) + try: + for car_n, charger in enumerate(self.ordered_chargers()): + if car_n not in self.control_windows or charger.charger_id in self.stale_ids: + continue + wanted = in_car_plan_window(self.control_windows[car_n], now) + if not charger.connected: + self.paused_by_predbat.discard(charger.charger_id) + continue + if wanted and charger.locked: + if charger.charger_id not in self.lock_warned: + self.log("Warn: wallbox: {} is locked, so it will not charge in its planned window. Predbat does not unlock chargers".format(charger.name)) + self.lock_warned.add(charger.charger_id) + continue + self.lock_warned.discard(charger.charger_id) + if wanted and charger.paused: + self.log("Info: wallbox: resuming {} for car {}".format(charger.name, car_n)) + await self.transport.resume(self.api_id(charger)) + self.paused_by_predbat.discard(charger.charger_id) + elif not wanted and charger.charging: + self.log("Info: wallbox: pausing {} for car {}".format(charger.name, car_n)) + await self.transport.pause(self.api_id(charger)) + self.paused_by_predbat.add(charger.charger_id) + finally: + if self.paused_by_predbat != before: + await self.save_control_state() +``` + +In `switch_event_handler`, insert this between the `if service not in ("turn_on", "turn_off"): return` check and the `charger = self.charger_for_entity(entity_id)` line. It must come before the charger lookup, because the control switch belongs to no charger: + +```python + if entity_id.endswith("_wallbox_control"): + self.control_enabled = service == "turn_on" + self.log("Info: wallbox: charge control switched {}".format("on" if self.control_enabled else "off")) + await self.save_control_state() + return +``` + +Why `control_enabled` keeps a charger paused by the user out of release: `release_chargers` only walks `paused_by_predbat`, which is added to only where Predbat itself sent the pause. + +- [ ] **Step 4: Run the tests to verify they pass** + +Expected: sixteen more `✓` lines, and every earlier test still passing. + +- [ ] **Step 5: Commit** + +```bash +git add apps/predbat/wallbox.py apps/predbat/tests/test_wallbox.py +git commit -m "feat(wallbox): Predbat-led charging with persistent release" +``` + +--- + +### Task 9: Documentation + +**Files:** +- Modify: `docs/components.md`, `docs/car-charging.md`, `docs/apps-yaml.md`, `docs/devices.md`, `apps/predbat/config/apps.yaml`, `CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md` + +No code, so no test cycle. The check is `./run_pre_commit` (markdown lint and British English spelling). + +- [ ] **Step 1: `docs/components.md`** + +Add `- [Wallbox Charger (wallbox)](#wallbox-charger-wallbox)` to the table of contents after the myenergi line (about line 20). After the end of the `### myenergi (myenergi)` section, add: + +````markdown +### Wallbox Charger (wallbox) + +Talks directly to the Wallbox cloud, so it works with or without Home Assistant. It monitors every charger on your Wallbox account, can register them with Predbat as cars, and can pause and resume them from Predbat's car charging plan. + +#### What it publishes (wallbox) + +For each charger, with `` being the Wallbox charger id: + +| Entity | Description | +| ------ | ----------- | +| `sensor.predbat_wallbox__status` | Charger status, e.g. `Charging`, `Paused`, `Waiting for car demand`, `Ready` | +| `sensor.predbat_wallbox__power` | Charging power in W | +| `sensor.predbat_wallbox__session_energy` | Energy added in the current session in kWh; resets with each session | +| `binary_sensor.predbat_wallbox__connected` | On while a car is plugged in | +| `binary_sensor.predbat_wallbox__charging` | On while the car is charging | +| `switch.predbat_wallbox__charging` | Turn off to pause charging, on to resume a paused session | +| `switch.predbat_wallbox__locked` | Lock or unlock the charger | +| `number.predbat_wallbox__max_charging_current` | Maximum charging current in A | +| `select.predbat_wallbox__eco_smart` | Eco-Smart mode: `off`, `eco_mode` or `full_solar`. Only on chargers with Eco-Smart | + +Wallbox chargers cannot report the car's state of charge, so there is no battery sensor. + +The controls need a Wallbox account with admin rights over the charger. Without them monitoring still works and Predbat logs a warning the first time a control is refused. + +#### Automatic configuration (wallbox) + +With `wallbox_automatic` on (the default), Predbat sets **car_charging_energy**, **car_charging_planned**, **car_charging_power** and **car_charging_now** to the Wallbox entities, and raises **num_cars** to the number of chargers. Chargers are taken in order of their id, so the first charger is car 0. A **car_charging_now** you have set yourself is kept. + +Predbat does not set **car_charging_soc**. If you want Predbat to plan to a target charge level, set it from your car's own integration. + +#### Predbat-led charging (wallbox) + +Set `wallbox_control: True` to let Predbat pause and resume each charger from its own car charging plan. It needs `wallbox_automatic`. While it is active Predbat publishes `switch.predbat_wallbox_control`, which you can turn off to hand the chargers back at any time; read only mode does the same. + +- Inside a planned car charging slot a paused charger is resumed. +- Outside a slot a charging charger is paused. +- When control is released, any charger Predbat paused is resumed and returned to its own Wallbox schedule. + +Things to know: + +- Wallbox can only pause a charger once it is charging, so a car plugged in outside a slot draws power until Predbat's next poll (two minutes by default). +- Clear any schedule set in the Wallbox app. A charger that Wallbox is holding in `Scheduled` will not charge in a Predbat slot. +- Predbat never unlocks a locked charger. It logs a warning if a locked charger misses its slot. +- Some cars go to sleep when charging is paused and do not wake when it is resumed. If yours does, Predbat-led charging will not suit it. + +#### Configuration Options (wallbox) + +| Option | Type | Required | Default | Config Key | Description | +| ------ | ---- | -------- | ------- | ---------- | ----------- | +| `username` | String | Yes | - | `wallbox_username` | Your Wallbox account email address | +| `password` | String | Yes | - | `wallbox_password` | Your Wallbox account password | +| `automatic` | Boolean | No | `True` | `wallbox_automatic` | Register the chargers with Predbat as cars | +| `wallbox_control` | Boolean | No | `False` | `wallbox_control` | Let Predbat pause and resume the chargers from its own plan. Requires `wallbox_automatic`; released by read only mode | +| `poll_seconds` | Integer | No | `120` | `wallbox_poll_seconds` | How often to poll Wallbox, 60 to 1800, in steps of 60 | +```` + +Also add `wallbox_username` and `wallbox_password` to the list of secret ids near line 278, in the same style as `ohme_login`. + +- [ ] **Step 2: `docs/car-charging.md`** + +After the Ohme sections and before `## GivEnergy Gateway OCPP EV charger` (about line 423), add: + +````markdown +## Wallbox car charger direct integration + +Predbat can talk directly to your Wallbox charger by configuring your Wallbox account details in `apps.yaml`: + +```yaml + wallbox_username: !secret wallbox_username + wallbox_password: !secret wallbox_password + # Let Predbat pause and resume the charger from its own car charging plan + #wallbox_control: True +``` + +Predbat then registers each charger on the account as a car and sets **car_charging_energy**, **car_charging_planned**, **car_charging_power** and **car_charging_now** for you. A Wallbox charger cannot report the car's state of charge, so set **car_charging_soc** from your car's own integration if you want Predbat to plan to a target. + +See [Wallbox Charger](components.md#wallbox-charger-wallbox) for the entities it publishes and how Predbat-led charging behaves. + +If you would rather use the Home Assistant Wallbox integration, see [Wallbox Pulsar](devices.md#wallbox-pulsar). +```` + +In each of the four sentences that list the supported charger integrations (about lines 108, 211, 521 and 537), add `Wallbox` after `myenergi Zappi`. + +- [ ] **Step 3: `docs/apps-yaml.md`** + +After the `ohme_automatic_octopus_intelligent` bullet (about line 2112), add: + +```markdown +- **wallbox_username** - Wallbox EV charger account email address +- **wallbox_password** - Password for the above Wallbox account +- **wallbox_automatic** - Register the Wallbox chargers with Predbat as cars (default `True`) +- **wallbox_control** - Let Predbat pause and resume the Wallbox chargers from its own car charging plan (default `False`) +- **wallbox_poll_seconds** - How often to poll the Wallbox cloud in seconds (default `120`) +``` + +Add `wallbox_username` and `wallbox_password` to the sample secrets near lines 239–240, next to the Ohme ones and in the same form. + +- [ ] **Step 4: `docs/devices.md`** + +In the `## Wallbox Pulsar` section (about line 269), add as its first paragraph: + +```markdown +Predbat can talk to Wallbox directly, with no Home Assistant integration needed: see [Wallbox Charger](components.md#wallbox-charger-wallbox). The configuration below is for the Home Assistant Wallbox integration instead. +``` + +- [ ] **Step 5: `apps/predbat/config/apps.yaml`** + +After the Ohme block (ends about line 320), add: + +```yaml + # Wallbox EV charger cloud direct integration + #wallbox_username: !secret wallbox_username + #wallbox_password: !secret wallbox_password + # Register the Wallbox chargers with Predbat as cars (on by default) + #wallbox_automatic: True + # Let Predbat pause and resume the chargers according to its own car charging plan. + # Requires wallbox_automatic + #wallbox_control: True +``` + +- [ ] **Step 6: Component counts** + +In `CLAUDE.md` (line 113) and `AGENTS.md` (line 109), add `Wallbox` to the name list after `Ohme` and correct the count in "registry of 18 pluggable components". The 18 is already stale: count the entries with `grep -c '"class":' apps/predbat/components.py` after Task 5 and use that number. In `.github/copilot-instructions.md`, add `WallboxAPI` to the component class list (line 139) and `wallbox.py` to the file list (line 328). + +- [ ] **Step 7: Check and commit** + +```bash +./run_pre_commit > /tmp/wallbox_precommit.log 2>&1; tail -40 /tmp/wallbox_precommit.log +``` + +Expected: every hook `Passed`. If CSpell flags a real word (for example `ecosmart`), add it to `.cspell/custom-dictionary-workspace.txt`, run pre-commit again (it re-sorts that file), and stage the result. + +```bash +git add docs/components.md docs/car-charging.md docs/apps-yaml.md docs/devices.md apps/predbat/config/apps.yaml CLAUDE.md AGENTS.md .github/copilot-instructions.md .cspell/custom-dictionary-workspace.txt +git commit -m "docs(wallbox): document the Wallbox component" +``` + +--- + +### Task 10: Full verification and live check (needs the maintainer's charger) + +**Files:** +- Modify: `tools/debug-journal.md` (a Wallbox entry) + +- [ ] **Step 1: Run the quick suite** + +```bash +cd coverage +./run_all --quick > /tmp/wallbox_quick.log 2>&1; tail -40 /tmp/wallbox_quick.log +``` + +Expected: all tests pass. Tests that enumerate components or config keys (`components`, `validate_config`, `component_base`, `agent_tools`) are the likely ones to react to a new registry entry; if one fails, read its assertion in the log before changing anything. + +- [ ] **Step 2: Run pre-commit over everything** + +```bash +./run_pre_commit > /tmp/wallbox_precommit.log 2>&1; tail -40 /tmp/wallbox_precommit.log +``` + +Expected: every hook `Passed`, including `interrogate` at 100%. + +- [ ] **Step 3: Live check (maintainer)** + +With a car plugged in and charging, from `apps/predbat/`: + +```bash +python3 wallbox.py --username --password --pause +python3 wallbox.py --username --password --resume +python3 wallbox.py --username --password --resume-schedule +``` + +Expected after `--pause`: the read-back shows `status='Paused'`, `paused=True`. After `--resume`: `status='Charging'`. `--resume-schedule` returns without error. + +Then run Predbat with `wallbox_username`, `wallbox_password` and `wallbox_control: True` and confirm, in order: + +1. The `predbat_wallbox__*` entities appear with sensible values. +2. The log shows `car_charging_energy`, `car_charging_planned`, `car_charging_power` and `car_charging_now` being set. +3. With the car charging outside a planned car slot, Predbat pauses it within one poll. +4. Turning `switch.predbat_wallbox_control` off resumes the charger. +5. Over an hour of running there is no `rate limited` warning at the default 120 second poll. + +- [ ] **Step 4: Record what was learnt** + +Add a `Wallbox` entry to `tools/debug-journal.md` in that file's existing style, recording: which `User-Agent` the API accepted; the status ids actually seen for charging, paused and unplugged; whether a 120 second poll stayed clear of the rate limit; how long a pause took to show in the status; and anything in the live check that differed from this plan. + +- [ ] **Step 5: Commit** + +```bash +git add tools/debug-journal.md +git commit -m "docs(journal): Wallbox API findings from the live check" +``` diff --git a/docs/superpowers/specs/2026-10-05-wallbox-component-design.md b/docs/superpowers/specs/2026-10-05-wallbox-component-design.md new file mode 100644 index 000000000..08a72d0f4 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-wallbox-component-design.md @@ -0,0 +1,327 @@ +# Wallbox Component — Design + +Date: 2026-10-05 +Status: Awaiting review + +## 1. Purpose + +Add Wallbox EV charger support to Predbat as a pluggable cloud component, so a Wallbox +works without Home Assistant (Predbat.com and Docker) and without the regex wiring that +[docs/devices.md](../../devices.md) describes today. + +Scope: + +- Monitoring of every charger on one Wallbox account, published as Predbat entities. +- Automatic configuration wiring those entities into the `car_charging_*` arguments. +- Manual controls: pause/resume, lock, maximum charging current and Eco-Smart mode. +- Opt-in Predbat-led charging, where the car plan pauses and resumes the charger. +- A command line test mode that doubles as the tool for capturing real payloads. + +The reference for the API is the Home Assistant `wallbox` integration and the `wallbox` +PyPI library (v0.9.0) it wraps. That library is synchronous `requests`, so its calls are +reimplemented in `aiohttp` and no dependency is added. + +## 2. The Wallbox API + +Two hosts: `https://user-api.wall-box.com/` for authentication and +`https://api.wall-box.com/` for everything else. + +### 2.1 Authentication + +| Call | Request | Notes | +|---|---|---| +| Sign in | `GET user-api…/users/signin`, HTTP Basic (username, password), header `Partner: wallbox` | 403 means bad credentials | +| Refresh | `GET user-api…/users/refresh-token`, `Authorization: Bearer `, header `Partner: wallbox` | 401 means the refresh token was revoked: fall back to sign in | + +Both return `data.attributes` with `token`, `refresh_token`, `ttl` and +`refresh_token_ttl`. The two TTLs are absolute expiry times in epoch milliseconds. Every +other call sends `Authorization: Bearer `, `Accept: application/json` and +`Content-Type: application/json;charset=UTF-8`. + +### 2.2 Endpoints used + +| Purpose | Request | Body | +|---|---|---| +| List chargers | `GET v3/chargers/groups` | ids at `result.groups[].chargers[].id` | +| Charger status | `GET chargers/status/{id}` | see 2.3 | +| Pause / resume | `POST v3/chargers/{id}/remote-action` | `{"action": 2}` / `{"action": 1}` | +| Resume schedule | `POST v3/chargers/{id}/remote-action` | `{"action": 9}` | +| Lock / unlock | `PUT v2/charger/{id}` | `{"locked": 1}` / `{"locked": 0}` | +| Max charging current | `PUT v2/charger/{id}` | `{"maxChargingCurrent": }` | +| Eco-Smart | `PUT v4/chargers/{id}/eco-smart` | `{"data": {"attributes": {"enabled": 0\|1, "mode": 0\|1}, "type": "eco_smart"}}` | + +Any call can return 429 (too many requests). The writes can return 403 when the account +does not have admin rights over the charger. + +### 2.3 Status fields used + +| Field | Meaning | +|---|---| +| `status_id` | Numeric state, see 2.4 | +| `charging_power` | kW | +| `added_energy` | kWh delivered this session; resets for each session | +| `max_available_power` | Upper bound for the charging current, in amps despite the name | +| `name` | Charger name | +| `config_data.max_charging_current` | Amps | +| `config_data.locked` | Lock state | +| `config_data.serial_number`, `.part_number`, `.software.currentVersion` | Identity | +| `config_data.ecosmart.enabled`, `.mode` | Absent when the charger has no Eco-Smart; mode 0 is eco, 1 is full solar | + +The payload has a `state_of_charge` field, but a Type 2 connector cannot report the car's +state of charge, so it is not read. + +### 2.4 Status codes + +| Codes | State | Connected | Charging | +|---|---|---|---| +| 193, 194, 195 | Charging | yes | yes | +| 196 | Discharging | yes | no | +| 178, 182 | Paused | yes | no | +| 177, 179 | Scheduled | yes | no | +| 164, 180, 181, 183–189 | Waiting: the car is connected and the charger is waiting for it to draw energy | yes | no | +| 210 | Locked, car connected | yes | no | +| 165, 209 | Locked | no | no | +| 161, 162 | Ready | no | no | +| 0, 163 | Disconnected | no | no | +| 166 | Updating | no | no | +| 14, 15 | Error | no | no | +| anything else | Unknown | no | no | + +The published status text is the finer Home Assistant wording (for example "Waiting in +queue by Power Boost"), so entity states match what existing users already see. + +## 3. Architecture + +One new file, `apps/predbat/wallbox.py`, in the shape of `myenergi.py`: + +- `WallboxCharger` — a dataclass holding one charger's normalised state: id, name, + serial, status id, status text, connected, charging, paused, locked, power in watts, + session energy, max charging current, max available current and Eco-Smart mode + (`None` when unsupported). +- `normalise_charger(charger_id, payload)` — a pure function from a status payload to a + `WallboxCharger`. Missing or malformed fields give safe defaults, never an exception. +- `WallboxTransport` — owns the tokens and every HTTP call. A new `aiohttp.ClientSession` + per request with a 30 second total timeout, as myenergi does. Raises `WallboxAuthError` + (bad credentials), `WallboxRateLimitError` (429), `WallboxPermissionError` (403 on a + write) or `WallboxApiError`, all under `WallboxError`. Every call is recorded with + `record_api_call("wallbox", …)`. +- `WallboxAPI(ComponentBase)` — polling, publishing, event handling, automatic + configuration and plan-led control. It never touches `aiohttp`. + +The transport checks the token before each call: reuse it while it has more than +`poll_seconds` left, otherwise refresh, otherwise sign in. Tokens are held in memory +only, so a restart costs one sign in. + +## 4. Configuration + +### 4.1 `COMPONENT_LIST` entry (`components.py`) + +```python +"wallbox": { + "class": "wallbox.WallboxAPI", + "name": "Wallbox Charger", + "event_filter": "predbat_wallbox_", + "args": { + "username": {"required": True, "secret": True, "config": "wallbox_username"}, + "password": {"required": True, "secret": True, "config": "wallbox_password"}, + "automatic": {"required": False, "config": "wallbox_automatic", "default": True}, + "wallbox_control": {"required": False, "config": "wallbox_control", "default": False}, + "poll_seconds": {"required": False, "config": "wallbox_poll_seconds", "default": 120}, + }, + "phase": 1, + "can_restart": True, +}, +``` + +There is no switch to hide the manual controls: they are always published. + +### 4.2 `APPS_SCHEMA` additions (`config.py`) + +```python +"wallbox_username": {"type": "string", "empty": False}, +"wallbox_password": {"type": "string", "empty": False}, +"wallbox_automatic": {"type": "boolean"}, +"wallbox_control": {"type": "boolean"}, +"wallbox_poll_seconds": {"type": "integer", "zero": False}, +``` + +`wallbox_poll_seconds` is rounded to a multiple of 60 and clamped to 60–1800, as +myenergi's is, so the 60 minute component health window can never be tripped by config. + +## 5. Published entities + +Per charger, with `

` = `{prefix}_wallbox_{charger_id}`, all through `dashboard_item` +with `app="wallbox"`: + +| Entity | State | Notes | +|---|---|---| +| `sensor.

_status` | Status text | attributes: `status_id`, `name`, `serial_number`, `part_number`, `software_version` | +| `sensor.

_power` | Watts | `charging_power` × 1000 | +| `sensor.

_session_energy` | kWh | `added_energy` | +| `binary_sensor.

_connected` | on/off | from 2.4 | +| `binary_sensor.

_charging` | on/off | from 2.4 | +| `switch.

_charging` | on/off | on resumes, off pauses | +| `switch.

_locked` | on/off | | +| `number.

_max_charging_current` | Amps | min 6, max `max_available_power`, step 1 | +| `select.

_eco_smart` | `off` / `eco_mode` / `full_solar` | only when the charger reports Eco-Smart | + +Global: `switch.{prefix}_wallbox_control`, published only while plan-led control is +available (section 7). + +## 6. Automatic configuration + +Runs once, after the first successful poll, when `wallbox_automatic` is on. Chargers are +sorted by id, so charger N is car N. All through `set_arg_auto`: + +| Argument | Value | +|---|---| +| `car_charging_energy` | list of `sensor.

_session_energy` | +| `car_charging_planned` | list of `binary_sensor.

_connected` | +| `car_charging_power` | list of `sensor.

_power` | +| `car_charging_now` | the same power list, with `overwrite=False` | +| `num_cars` | raised to the number of chargers if lower, via `set_arg` | + +`car_charging_soc` is not set: the charger cannot know it. Users who want Predbat to plan +to a target must supply it from their car's own integration, and the docs say so. +`car_charging_now` relies on the existing rule that a power sensor counts as charging +from `CAR_CHARGING_NOW_POWER_W` (200 W). + +## 7. Controls + +### 7.1 Manual + +Event stubs queue `(handler, args)` and the queue is drained at the top of the next +`run()`, as both existing chargers do. Each successful write is followed by an immediate +re-poll of that charger so the entity reflects the result. + +| Entity | Action | +|---|---| +| `switch.

_charging` | on → resume, off → pause | +| `switch.

_locked` | lock / unlock | +| `number.

_max_charging_current` | set, clamped to 6..`max_available_power` | +| `select.

_eco_smart` | `off` disables; `eco_mode` and `full_solar` enable with mode 0 and 1 | + +A failed write logs a warning and does not fail the component. + +### 7.2 Predbat-led charging (`wallbox_control`) + +Available when `wallbox_control` and `wallbox_automatic` are both on. The user can then +turn it on and off at runtime with `switch.{prefix}_wallbox_control`. + +Each poll, for each charger N, read the `planned` attribute of +`binary_sensor.{prefix}_car_charging_slot` (suffix `_N` for N > 0) and parse it with the +shared `utils.parse_car_plan_windows` / `in_car_plan_window`: + +- Inside a window and the charger is Paused → resume. +- Outside a window and the charger is Charging → pause, and record that Predbat paused it. +- Any other state → no action. Resume applies only to a Paused charger: one in Scheduled + is following its own schedule and one in Waiting is waiting on the car, so neither is + sent anything. A locked charger is left alone with a one-off warning; Predbat never + unlocks it. + +At most one command is sent per charger per poll, and only on freshly polled state, so a +slow charger is not sent the same command repeatedly. + +**Release.** When Predbat is in read-only mode, or the control switch is off, or control +is not available at all, every charger Predbat paused is resumed and then sent "resume +schedule", and the record is cleared. + +**Persistence.** The control switch state and the list of charger ids Predbat paused are +saved through the Storage component (`storage.save("wallbox", "control_state", …)`) and +loaded on the first run. Release runs from the stored list even when control is +unavailable. This is the lesson from the myenergi entry in `tools/debug-journal.md`, +where control state latched at the first tick left a charger stopped after a restart with +control turned off. + +**Known limits.** Wallbox can only be paused once it is charging. A car plugged in outside +a planned window therefore draws power until the next poll, up to `wallbox_poll_seconds`. +A schedule set in the Wallbox app competes with Predbat: a charger held in Scheduled does +not charge inside a Predbat window. The docs tell users to clear the Wallbox schedule +when they turn `wallbox_control` on. + +## 8. Polling and error handling + +- `run()` is called every 60 seconds; a poll happens on the first run, after a control + event, and every `wallbox_poll_seconds`. +- The charger list is fetched on the first run and every 30 minutes. Each poll makes one + status call per charger. +- `WallboxAuthError` on sign in logs an error and returns False, so the base class's + start-up back-off applies. +- `WallboxRateLimitError` skips polls on a doubling back-off (2, 4, 8 minutes) capped at + 15 minutes, and returns True so one 429 does not count as a failure. The cap keeps + recovery inside the 60 minute health window. +- `WallboxPermissionError` on a write logs once that the account needs admin rights over + the charger. Monitoring carries on. +- Any other `WallboxError` on a poll logs a warning and returns False. +- `update_success_timestamp()` is called only after a poll that returned at least one + charger. + +## 9. Command line test interface + +`python3 wallbox.py --username … --password …`, in the shape of myenergi's `main()`: + +- Default: sign in, list chargers, print the normalised record of each. +- `--raw` prints the raw status payload as JSON, for building fixtures. +- `--charger ` selects a charger for the actions; default is the first. +- `--pause`, `--resume`, `--resume-schedule`, `--lock`, `--unlock`, + `--max-current `, `--eco-smart {off,eco_mode,full_solar}`. + +## 10. Testing + +`apps/predbat/tests/test_wallbox.py`, in the myenergi style: plain assert-style functions +called from one `test_wallbox(my_predbat=None)`, a real `WallboxAPI(MockBase(), …)`, the +transport tested under `patch("aiohttp.ClientSession")` and replaced by a stub for +component tests. Registered in `TEST_REGISTRY` in `unit_test.py` as `wallbox`. + +Fixtures are real payloads captured with `--raw` from the maintainer's charger, with the +serial number, charger id and name replaced. Capture is the first implementation task, so +the normaliser is written against real data. + +Coverage: + +- Normalisation: every row of the status table, a missing `ecosmart` block, a missing + `config_data`, kW to W. +- Transport: sign in, token reuse, refresh, refresh rejected with 401 then sign in, 403 + on sign in, 429, 403 on a write, timeout, and the exact method, URL and body of each + write. +- Publishing: the entity set for one and for two chargers; the Eco-Smart select absent + when unsupported. +- Events: each control's API call, clamping of the current, and the re-poll after a write. +- Automatic configuration: the argument lists, charger order, `car_charging_now` not + overwritten, nothing set when `wallbox_automatic` is off. +- Control: resume inside a window, pause outside, no action when disconnected, locked, Scheduled or Waiting, + one command per poll, second car reads the `_1` slot sensor, release on read-only and + on switch off, release after a restart from stored state, control state saved and + loaded. +- Polling: cadence, 429 back-off and recovery, `update_success_timestamp` only on success. + +The last task is a live check on real hardware: pause, resume and release. + +## 11. Documentation + +- `docs/components.md`: a `Wallbox Charger (wallbox)` section and table of contents entry. +- `docs/car-charging.md`: a "Wallbox direct integration" section including Predbat-led + charging and the missing state of charge, plus the lists of supported charger + integrations. +- `docs/apps-yaml.md`: the five keys and the two secrets. +- `docs/devices.md`: the existing "Wallbox Pulsar" section becomes the Home Assistant + route, with a pointer to the direct component as the alternative. +- `apps/predbat/config/apps.yaml`: a commented example block next to Ohme's. +- `CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`: the component count and + name lists. + +## 12. Out of scope + +- Energy cost, ICP maximum current, restart, firmware update and schedule editing. +- Quasar bidirectional discharge control; "Discharging" is reported as a status only. +- Octopus Intelligent wiring: the Wallbox API exposes no dispatch slots. +- Reporting to the discovery coordinator (myenergi does not either). +- The Predbat.com integration definition and credential UI, which live in another repo. + +## 13. Risks, to settle on hardware + +- Whether a 120 second poll stays clear of the rate limit. Home Assistant uses 90. +- Whether the API accepts a `User-Agent` of `Predbat`. The library sends + `HomeAssistantWallboxPlugin/1.0.0`. +- Some cars go to sleep when paused and will not resume. This is a property of the car, + and is documented as a caveat of Predbat-led charging. From ad09a4632a44f59b2b89bdbb18f9109d23205b14 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 11:00:26 +0100 Subject: [PATCH 02/24] feat(wallbox): transport with sign in, token refresh and charger reads Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 220 +++++++++++++++++++++++++++++ apps/predbat/unit_test.py | 2 + apps/predbat/wallbox.py | 202 ++++++++++++++++++++++++++ 3 files changed, 424 insertions(+) create mode 100644 apps/predbat/tests/test_wallbox.py create mode 100644 apps/predbat/wallbox.py diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py new file mode 100644 index 000000000..27db8725a --- /dev/null +++ b/apps/predbat/tests/test_wallbox.py @@ -0,0 +1,220 @@ +# fmt: off +# pylint: disable=line-too-long +""" +Unit tests for the Wallbox EV charger integration +""" + +import asyncio +import datetime +import os +import sys +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import pytz + +# 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 wallbox import ( + WALLBOX_API_URL, + WALLBOX_AUTH_URL, + WallboxApiError, + WallboxAuthError, + WallboxRateLimitError, + WallboxTransport, + basic_auth_header, +) + + +def _response(json_data=None, status=200, json_error=None): + """Build a mock aiohttp response usable as an async context manager.""" + 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 _session(responses): + """Build a mock aiohttp session whose request() returns the next queued response. + + A queued exception instance is raised instead. Returns (session, calls), where calls + records the method, URL, headers and JSON body of every request in order. + """ + calls = [] + queue = list(responses) + + def _request(method, url, **kwargs): + """Record the request, then return or raise the next queued item.""" + calls.append({"method": method, "url": url, "headers": kwargs.get("headers") or {}, "json": kwargs.get("json")}) + item = queue.pop(0) if queue else _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 _auth_payload(token="jwt-1", refresh="refresh-1", life=3600, refresh_life=86400): + """Build a sign in response whose TTLs are absolute epoch milliseconds, as Wallbox sends them.""" + now = time.time() + return {"data": {"attributes": {"token": token, "refresh_token": refresh, "ttl": int((now + life) * 1000), "refresh_token_ttl": int((now + refresh_life) * 1000)}}} + + +MOCK_GROUPS = {"result": {"groups": [{"chargers": [{"id": 202}, {"id": 101}]}, {"chargers": []}]}} + + +def test_basic_auth_header(): + """The Basic header is UTF-8, so a colon or accented character in the password survives.""" + assert basic_auth_header("user@example.com", "secret") == "Basic dXNlckBleGFtcGxlLmNvbTpzZWNyZXQ=" + # base64 of "a:pä:ss" encoded as UTF-8 + assert basic_auth_header("a", "pä:ss") == "Basic YTpww6Q6c3M=" + print(" ✓ Basic auth header is built as UTF-8") + + +def test_transport_signs_in_then_lists_chargers(): + """The first call signs in with Basic auth and the Partner header, then uses the bearer token.""" + session, calls = _session([_response(_auth_payload()), _response(MOCK_GROUPS)]) + transport = WallboxTransport(print, "user@example.com", "secret") + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + chargers = run_async(transport.list_chargers()) + + assert chargers == [202, 101], chargers + assert calls[0]["url"] == WALLBOX_AUTH_URL + "users/signin" + assert calls[0]["headers"]["Authorization"] == basic_auth_header("user@example.com", "secret") + assert calls[0]["headers"]["Partner"] == "wallbox" + assert calls[1]["url"] == WALLBOX_API_URL + "v3/chargers/groups" + assert calls[1]["headers"]["Authorization"] == "Bearer jwt-1" + print(" ✓ Transport signs in then lists chargers") + + +def test_transport_reuses_a_valid_token(): + """A token with life left is reused: two reads make one sign in.""" + session, calls = _session([_response(_auth_payload()), _response({"status_id": 193}), _response({"status_id": 161})]) + transport = WallboxTransport(print, "user@example.com", "secret") + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + first = run_async(transport.get_status(101)) + second = run_async(transport.get_status(101)) + + assert first == {"status_id": 193} and second == {"status_id": 161} + assert [call["url"] for call in calls] == [WALLBOX_AUTH_URL + "users/signin", WALLBOX_API_URL + "chargers/status/101", WALLBOX_API_URL + "chargers/status/101"] + print(" ✓ A valid token is reused") + + +def test_transport_refreshes_an_expired_token(): + """An expired token with a live refresh token is refreshed with a bearer call, not a sign in.""" + session, calls = _session([_response(_auth_payload(token="jwt-2")), _response({"status_id": 193})]) + transport = WallboxTransport(print, "user@example.com", "secret") + transport.token = "jwt-1" + transport.token_expiry = time.time() - 10 + transport.refresh_token = "refresh-1" + transport.refresh_expiry = time.time() + 3600 + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + run_async(transport.get_status(101)) + + assert calls[0]["url"] == WALLBOX_AUTH_URL + "users/refresh-token" + assert calls[0]["headers"]["Authorization"] == "Bearer refresh-1" + assert calls[0]["headers"]["Partner"] == "wallbox" + assert calls[1]["headers"]["Authorization"] == "Bearer jwt-2" + print(" ✓ An expired token is refreshed") + + +def test_transport_signs_in_when_the_refresh_is_rejected(): + """A 401 on refresh means the refresh token was revoked: fall back to a full sign in.""" + session, calls = _session([_response({}, status=401), _response(_auth_payload(token="jwt-3")), _response({"status_id": 193})]) + transport = WallboxTransport(print, "user@example.com", "secret") + transport.token = "jwt-1" + transport.token_expiry = time.time() - 10 + transport.refresh_token = "refresh-1" + transport.refresh_expiry = time.time() + 3600 + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + run_async(transport.get_status(101)) + + assert [call["url"] for call in calls] == [WALLBOX_AUTH_URL + "users/refresh-token", WALLBOX_AUTH_URL + "users/signin", WALLBOX_API_URL + "chargers/status/101"] + assert calls[2]["headers"]["Authorization"] == "Bearer jwt-3" + print(" ✓ A rejected refresh falls back to sign in") + + +def test_transport_bad_credentials(): + """A 403 on sign in is an auth error, recorded with reason auth_error.""" + session, _ = _session([_response({}, status=403)]) + transport = WallboxTransport(print, "user@example.com", "wrong") + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call") as mock_record: + try: + run_async(transport.list_chargers()) + raise AssertionError("Expected WallboxAuthError") + except WallboxAuthError: + pass + assert mock_record.call_args.kwargs.get("reason") == "auth_error" + print(" ✓ Bad credentials raise WallboxAuthError") + + +def test_transport_retries_once_after_a_revoked_token(): + """A 401 on a data call drops the token, signs in again and retries the call once.""" + session, calls = _session([_response({}, status=401), _response(_auth_payload(token="jwt-9")), _response({"status_id": 193})]) + transport = WallboxTransport(print, "user@example.com", "secret") + transport.token = "jwt-1" + transport.token_expiry = time.time() + 3600 + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + status = run_async(transport.get_status(101)) + + assert status == {"status_id": 193} + assert calls[1]["url"] == WALLBOX_AUTH_URL + "users/signin" + assert calls[2]["headers"]["Authorization"] == "Bearer jwt-9" + print(" ✓ A revoked token is replaced and the call retried once") + + +def test_transport_rate_limit_and_failures(): + """429 is its own error; a 500, a timeout, a dropped connection and a non-JSON body are API errors.""" + cases = [ + (_response({}, status=429), WallboxRateLimitError, "rate_limit"), + (_response({}, status=500), WallboxApiError, "server_error"), + (_response({}, status=404), WallboxApiError, "client_error"), + (asyncio.TimeoutError(), WallboxApiError, "connection_error"), + (aiohttp.ClientError("boom"), WallboxApiError, "connection_error"), + (_response(json_error=ValueError("not json")), WallboxApiError, "decode_error"), + (_response(["not", "a", "dict"]), WallboxApiError, "decode_error"), + ] + for item, expected, reason in cases: + session, _ = _session([item]) + transport = WallboxTransport(print, "user@example.com", "secret") + transport.token = "jwt-1" + transport.token_expiry = time.time() + 3600 + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call") as mock_record: + try: + run_async(transport.get_status(101)) + raise AssertionError("Expected {}".format(expected.__name__)) + except expected: + pass + assert mock_record.call_args.kwargs.get("reason") == reason, (expected, mock_record.call_args) + print(" ✓ Rate limit and failures map to the right errors and metric reasons") + + +def test_wallbox(my_predbat=None): + """Run every Wallbox test.""" + print("=" * 70) + print("Wallbox tests") + print("=" * 70) + test_basic_auth_header() + test_transport_signs_in_then_lists_chargers() + test_transport_reuses_a_valid_token() + test_transport_refreshes_an_expired_token() + test_transport_signs_in_when_the_refresh_is_rejected() + test_transport_bad_credentials() + test_transport_retries_once_after_a_revoked_token() + test_transport_rate_limit_and_failures() + print("=" * 70) + return False diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index f076cb42d..be2adf639 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -276,6 +276,7 @@ from tests.test_inverter_write_poll import run_inverter_write_poll_tests from tests.test_givtcp_rest import run_givtcp_rest_tests from tests.test_myenergi import test_myenergi +from tests.test_wallbox import test_wallbox from tests.test_component_base import test_component_base_all from tests.test_components import test_components_all from tests.test_coordinator import test_coordinator_all @@ -707,6 +708,7 @@ def main(): ("givtcp_rest", run_givtcp_rest_tests, "GivTCP REST client write/retry/transport tests", False), # myenergi Zappi and Eddi unit tests ("myenergi", test_myenergi, "myenergi Zappi and Eddi comprehensive tests (normalisation, transports, publishing, auto-config, controls)", False), + ("wallbox", test_wallbox, "Wallbox EV charger tests (transport, normalisation, publishing, auto-config, controls, plan-led charging)", False), # ComponentBase lifecycle tests ("component_base", test_component_base_all, "ComponentBase tests (all)", False), ("components", test_components_all, "Components registry tests (all)", False), diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py new file mode 100644 index 000000000..10685da5b --- /dev/null +++ b/apps/predbat/wallbox.py @@ -0,0 +1,202 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# Wallbox EV charger cloud API library. +# The API is the one the Home Assistant wallbox integration and the wallbox PyPI +# library use; it is reimplemented here over aiohttp. +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + + +"""Wallbox EV charger integration. + +Monitors every charger on a Wallbox account, wires them into Predbat's car charging +inputs, exposes pause/resume, lock, charging current and Eco-Smart controls, and can +pause and resume each charger from Predbat's own car charging plan. +""" + +import asyncio +import base64 +import time + +import aiohttp + +from predbat_metrics import record_api_call + +WALLBOX_AUTH_URL = "https://user-api.wall-box.com/" +WALLBOX_API_URL = "https://api.wall-box.com/" + +API_TIMEOUT = 30 +USER_AGENT = "Predbat" + +# A token is treated as expired this long before Wallbox says it is, so a call never +# leaves with a token that dies in flight +TOKEN_MARGIN_SECONDS = 120 + + +class WallboxError(Exception): + """Base class for every Wallbox API failure.""" + + +class WallboxAuthError(WallboxError): + """Wallbox rejected the credentials or the token.""" + + +class WallboxRateLimitError(WallboxError): + """Wallbox answered 429, too many requests.""" + + +class WallboxPermissionError(WallboxError): + """Wallbox refused a write because the account lacks admin rights over the charger.""" + + +class WallboxApiError(WallboxError): + """Any other failure: a bad status, a timeout, a dropped connection or an unreadable body.""" + + +def basic_auth_header(username, password): + """Build an HTTP Basic Authorization header value, encoded as UTF-8.""" + token = base64.b64encode("{}:{}".format(username, password).encode("utf-8")).decode("ascii") + return "Basic " + token + + +class WallboxTransport: + """Owns the Wallbox tokens and performs every HTTP call.""" + + def __init__(self, log, username, password): + """Store the credentials and start with no token.""" + self.log = log + self.username = username + self.password = password + self.token = None + self.refresh_token = None + # Absolute expiry times, in epoch seconds + self.token_expiry = 0 + self.refresh_expiry = 0 + + def _clear_tokens(self): + """Forget both tokens so the next call signs in from scratch.""" + self.token = None + self.refresh_token = None + self.token_expiry = 0 + self.refresh_expiry = 0 + + def _store_tokens(self, payload): + """Take the tokens and their expiry times from a sign in or refresh response.""" + try: + attributes = payload["data"]["attributes"] + self.token = attributes["token"] + self.refresh_token = attributes.get("refresh_token") + # Wallbox sends absolute expiry times in epoch milliseconds + self.token_expiry = float(attributes["ttl"]) / 1000.0 + self.refresh_expiry = float(attributes.get("refresh_token_ttl", 0)) / 1000.0 + except (KeyError, TypeError, ValueError) as exc: + self._clear_tokens() + raise WallboxApiError("unexpected sign in response from Wallbox") from exc + + async def _call(self, method, url, headers, body=None, write=False): + """Perform one HTTP request and return its decoded JSON object. + + Args: + method: The HTTP method. + url: The full URL. + headers: The request headers. + body: A JSON-serialisable body, or None. + write: True for a control call. A 403 is then a rights problem rather than + bad credentials, and an empty response body is accepted. + """ + try: + async with aiohttp.ClientSession() as session: + async with session.request(method, url, headers=headers, json=body, timeout=aiohttp.ClientTimeout(total=API_TIMEOUT)) as response: + status = response.status + if status == 429: + record_api_call("wallbox", success=False, reason="rate_limit") + raise WallboxRateLimitError("Wallbox rate limit reached calling {}".format(url)) + if status == 403 and write: + record_api_call("wallbox", success=False, reason="auth_error") + raise WallboxPermissionError("Wallbox refused {} - the account needs admin rights over the charger".format(url)) + if status in (401, 403): + record_api_call("wallbox", success=False, reason="auth_error") + raise WallboxAuthError("Wallbox rejected the credentials for {}".format(url)) + if status < 200 or status >= 300: + record_api_call("wallbox", success=False, reason="server_error" if status >= 500 else "client_error") + raise WallboxApiError("HTTP {} from {}".format(status, url)) + try: + payload = await response.json(content_type=None) + except (ValueError, aiohttp.ContentTypeError) as exc: + if write: + payload = {} + else: + record_api_call("wallbox", success=False, reason="decode_error") + raise WallboxApiError("unreadable response from {}".format(url)) from exc + if not isinstance(payload, dict): + if write: + payload = {} + else: + record_api_call("wallbox", success=False, reason="decode_error") + raise WallboxApiError("unexpected response from {}".format(url)) + record_api_call("wallbox", success=True) + return payload + except asyncio.TimeoutError as exc: + record_api_call("wallbox", success=False, reason="connection_error") + raise WallboxApiError("timed out calling {}".format(url)) from exc + except aiohttp.ClientError as exc: + record_api_call("wallbox", success=False, reason="connection_error") + raise WallboxApiError("request to {} failed: {}".format(url, exc)) from exc + + async def authenticate(self): + """Make sure there is a usable token: reuse it, refresh it, or sign in.""" + now = time.time() + if self.token and self.token_expiry - TOKEN_MARGIN_SECONDS > now: + return + if self.refresh_token and self.refresh_expiry - TOKEN_MARGIN_SECONDS > now: + headers = {"Authorization": "Bearer {}".format(self.refresh_token), "Partner": "wallbox", "Accept": "application/json", "User-Agent": USER_AGENT} + try: + self._store_tokens(await self._call("GET", WALLBOX_AUTH_URL + "users/refresh-token", headers)) + return + except WallboxAuthError: + self.log("Info: wallbox: the refresh token was rejected, signing in again") + self._clear_tokens() + headers = {"Authorization": basic_auth_header(self.username, self.password), "Partner": "wallbox", "Accept": "application/json", "User-Agent": USER_AGENT} + self._store_tokens(await self._call("GET", WALLBOX_AUTH_URL + "users/signin", headers)) + + def _headers(self): + """The headers every data call carries.""" + return { + "Authorization": "Bearer {}".format(self.token), + "Accept": "application/json", + "Content-Type": "application/json;charset=UTF-8", + "User-Agent": USER_AGENT, + } + + async def _api(self, method, path, body=None, write=False): + """Perform one authenticated data call, signing in again once if the token was revoked.""" + await self.authenticate() + try: + return await self._call(method, WALLBOX_API_URL + path, self._headers(), body=body, write=write) + except WallboxAuthError: + # The token was revoked before its stated expiry. Start again once. + self._clear_tokens() + await self.authenticate() + return await self._call(method, WALLBOX_API_URL + path, self._headers(), body=body, write=write) + + async def list_chargers(self): + """Return the id of every charger on the account, across all groups.""" + payload = await self._api("GET", "v3/chargers/groups") + charger_ids = [] + result = payload.get("result") + groups = result.get("groups") if isinstance(result, dict) else None + for group in groups or []: + for charger in (group or {}).get("chargers") or []: + if isinstance(charger, dict) and charger.get("id") is not None: + charger_ids.append(charger["id"]) + return charger_ids + + async def get_status(self, charger_id): + """Return the raw status payload of one charger.""" + return await self._api("GET", "chargers/status/{}".format(charger_id)) From 15763069bfb8c44b0335f339eaf6f627a2e90415 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 11:01:05 +0100 Subject: [PATCH 03/24] feat(wallbox): command line mode and stand-in status fixtures The fixtures follow the Home Assistant wallbox integration's test payload and are to be replaced by captures from a real charger. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 27 +++++++++++++++++++++++++ apps/predbat/wallbox.py | 32 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index 27db8725a..48d5bb566 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -76,6 +76,33 @@ def _auth_payload(token="jwt-1", refresh="refresh-1", life=3600, refresh_life=86 MOCK_GROUPS = {"result": {"groups": [{"chargers": [{"id": 202}, {"id": 101}]}, {"chargers": []}]}} +# Status payloads in the shape of the Home Assistant wallbox integration's own test +# fixture (tests/components/wallbox/const.py). These stand in for payloads captured from a +# real charger with `python3 wallbox.py --raw`, and should be replaced by such captures. +MOCK_STATUS_CHARGING = { + "charging_power": 7.2, + "status_id": 193, + "max_available_power": 25.0, + "charging_speed": 32, + "added_range": 150, + "added_energy": 44.697, + "name": "Garage", + "config_data": { + "max_charging_current": 24, + "energy_price": 0.4, + "locked": False, + "serial_number": "900001", + "part_number": "PLP1-0-2-4-9-002-E", + "software": {"currentVersion": "5.5.10"}, + "currency": {"code": "EUR/kWh"}, + "icp_max_current": 20, + "plan": {"features": ["POWER_BOOST"]}, + "ecosmart": {"enabled": False, "mode": 0}, + }, +} +MOCK_STATUS_READY = dict(MOCK_STATUS_CHARGING, charging_power=0, status_id=161, charging_speed=0, added_range=0, added_energy=0) +MOCK_STATUS_PAUSED = dict(MOCK_STATUS_CHARGING, charging_power=0, status_id=178, charging_speed=0) + def test_basic_auth_header(): """The Basic header is UTF-8, so a colon or accented character in the password survives.""" diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index 10685da5b..b2832f8cc 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -20,8 +20,10 @@ pause and resume each charger from Predbat's own car charging plan. """ +import argparse import asyncio import base64 +import json import time import aiohttp @@ -200,3 +202,33 @@ async def list_chargers(self): async def get_status(self, charger_id): """Return the raw status payload of one charger.""" return await self._api("GET", "chargers/status/{}".format(charger_id)) + + +async def run_wallbox_cli(args): # pragma: no cover + """Sign in, list the chargers and print each one's status against the live API.""" + transport = WallboxTransport(print, args.username, args.password) + charger_ids = await transport.list_chargers() + if not charger_ids: + print("No chargers found on this account") + return + print("Chargers: {}".format(charger_ids)) + for charger_id in charger_ids: + payload = await transport.get_status(charger_id) + if args.raw: + print(json.dumps(payload, indent=2, sort_keys=True, default=str)) + else: + print("{}: status_id={} power={}kW added_energy={}kWh".format(charger_id, payload.get("status_id"), payload.get("charging_power"), payload.get("added_energy"))) + + +def main(): # pragma: no cover + """Main function for command line execution.""" + parser = argparse.ArgumentParser(description="Test the Wallbox API") + parser.add_argument("--username", required=True, help="Wallbox account email address") + parser.add_argument("--password", required=True, help="Wallbox account password") + parser.add_argument("--raw", action="store_true", help="Print each charger's full status payload as JSON") + args = parser.parse_args() + asyncio.run(run_wallbox_cli(args)) + + +if __name__ == "__main__": + main() From 3d03db55aae69466bd18606d6ab27465205fafb5 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 11:01:22 +0100 Subject: [PATCH 04/24] feat(wallbox): normalise charger status payloads Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 99 ++++++++++++++++++++++ apps/predbat/wallbox.py | 129 +++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index 48d5bb566..ec4218ec8 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -21,13 +21,18 @@ from mock_base import MockBase from wallbox import ( + CHARGING_STATUS_IDS, + PAUSED_STATUS_IDS, WALLBOX_API_URL, WALLBOX_AUTH_URL, + WALLBOX_STATUS, WallboxApiError, WallboxAuthError, + WallboxCharger, WallboxRateLimitError, WallboxTransport, basic_auth_header, + normalise_charger, ) @@ -230,6 +235,95 @@ def test_transport_rate_limit_and_failures(): print(" ✓ Rate limit and failures map to the right errors and metric reasons") +def _status(status_id=193, power=7.2, energy=4.5, locked=False, max_current=32, eco=None, name="Garage"): + """Build a status payload in the shape Wallbox returns, for the fields Predbat reads.""" + config = {"max_charging_current": max_current, "locked": locked, "serial_number": "900001", "part_number": "PLP1-0-2-4-9-002-E", "software": {"currentVersion": "5.5.10"}} + if eco is not None: + config["ecosmart"] = eco + return {"status_id": status_id, "charging_power": power, "added_energy": energy, "max_available_power": 32, "name": name, "config_data": config} + + +def test_normalise_charging(): + """A charging payload gives watts, session energy and the identity fields.""" + charger = normalise_charger(101, _status()) + assert charger.charger_id == "101" + assert charger.name == "Garage" and charger.serial == "900001" + assert charger.part_number == "PLP1-0-2-4-9-002-E" and charger.software_version == "5.5.10" + assert charger.status == "Charging" and charger.status_id == 193 + assert charger.connected is True and charger.charging is True and charger.paused is False + assert charger.power_w == 7200.0, charger.power_w + assert charger.session_energy_kwh == 4.5 + assert charger.max_charging_current == 32 and charger.max_available_current == 32 + assert charger.locked is False and charger.eco_smart is None + print(" ✓ A charging payload is normalised") + + +def test_normalise_status_table(): + """Every status code maps to the documented text, connected, charging and paused flags.""" + expected = { + 193: ("Charging", True, True, False), 194: ("Charging", True, True, False), 195: ("Charging", True, True, False), + 196: ("Discharging", True, False, False), + 178: ("Paused", True, False, True), 182: ("Paused", True, False, True), + 177: ("Scheduled", True, False, False), 179: ("Scheduled", True, False, False), + 164: ("Waiting", True, False, False), + 180: ("Waiting for car demand", True, False, False), 181: ("Waiting for car demand", True, False, False), + 183: ("Waiting in queue by Power Sharing", True, False, False), 184: ("Waiting in queue by Power Sharing", True, False, False), + 185: ("Waiting in queue by Power Boost", True, False, False), 186: ("Waiting in queue by Power Boost", True, False, False), + 187: ("Waiting MID failed", True, False, False), 188: ("Waiting MID safety margin exceeded", True, False, False), + 189: ("Waiting in queue by Eco-Smart", True, False, False), + 210: ("Locked, car connected", True, False, False), + 165: ("Locked", False, False, False), 209: ("Locked", False, False, False), + 161: ("Ready", False, False, False), 162: ("Ready", False, False, False), + 0: ("Disconnected", False, False, False), 163: ("Disconnected", False, False, False), + 166: ("Updating", False, False, False), + 14: ("Error", False, False, False), 15: ("Error", False, False, False), + } + assert set(expected) == set(WALLBOX_STATUS), set(expected) ^ set(WALLBOX_STATUS) + for status_id, (text, connected, charging, paused) in expected.items(): + charger = normalise_charger(101, _status(status_id=status_id)) + assert (charger.status, charger.connected, charger.charging, charger.paused) == (text, connected, charging, paused), status_id + unknown = normalise_charger(101, _status(status_id=999)) + assert (unknown.status, unknown.connected, unknown.charging, unknown.paused) == ("Unknown", False, False, False) + print(" ✓ Every status code maps to the right state") + + +def test_normalise_eco_smart(): + """Eco-Smart is None when unsupported, off when disabled, otherwise the mode.""" + assert normalise_charger(101, _status()).eco_smart is None + assert normalise_charger(101, _status(eco={"enabled": False, "mode": 0})).eco_smart == "off" + assert normalise_charger(101, _status(eco={"enabled": True, "mode": 0})).eco_smart == "eco_mode" + assert normalise_charger(101, _status(eco={"enabled": True, "mode": 1})).eco_smart == "full_solar" + assert normalise_charger(101, _status(eco={"enabled": True})).eco_smart is None + print(" ✓ Eco-Smart mode is normalised") + + +def test_normalise_handles_bad_values(): + """Null numbers, a missing config block and a non-dict payload give safe defaults, never an exception.""" + charger = normalise_charger(101, {"status_id": None, "charging_power": None, "added_energy": "junk", "config_data": None}) + assert charger.status == "Disconnected" and charger.connected is False + assert charger.power_w == 0.0 and charger.session_energy_kwh == 0.0 + assert charger.max_charging_current == 0 and charger.max_available_current == 0 + assert charger.name == "Wallbox 101" and charger.serial == "" and charger.locked is False + + empty = normalise_charger(101, "not a dict") + assert empty.status == "Disconnected" and empty.power_w == 0.0 + + truthy_lock = normalise_charger(101, _status(locked=1)) + assert truthy_lock.locked is True + print(" ✓ Bad values normalise to safe defaults") + + +def test_normalise_captured_payloads(): + """The payloads captured from a real charger normalise to the states they were captured in.""" + charging = normalise_charger(101, MOCK_STATUS_CHARGING) + assert charging.charging is True and charging.power_w > 0 + ready = normalise_charger(101, MOCK_STATUS_READY) + assert ready.connected is False and ready.power_w == 0.0 + paused = normalise_charger(101, MOCK_STATUS_PAUSED) + assert paused.paused is True and paused.connected is True + print(" ✓ Captured payloads normalise to their real states") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -243,5 +337,10 @@ def test_wallbox(my_predbat=None): test_transport_bad_credentials() test_transport_retries_once_after_a_revoked_token() test_transport_rate_limit_and_failures() + test_normalise_charging() + test_normalise_status_table() + test_normalise_eco_smart() + test_normalise_handles_bad_values() + test_normalise_captured_payloads() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index b2832f8cc..f9d1f0c97 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -25,6 +25,8 @@ import base64 import json import time +from dataclasses import dataclass +from typing import Optional import aiohttp @@ -41,6 +43,133 @@ TOKEN_MARGIN_SECONDS = 120 +# Status id to (text, car connected). The text is the Home Assistant wallbox integration's +# wording, so states match what existing users already see. A None status id means the +# charger reported nothing, which Wallbox treats as disconnected. +WALLBOX_STATUS = { + 0: ("Disconnected", False), + 14: ("Error", False), + 15: ("Error", False), + 161: ("Ready", False), + 162: ("Ready", False), + 163: ("Disconnected", False), + 164: ("Waiting", True), + 165: ("Locked", False), + 166: ("Updating", False), + 177: ("Scheduled", True), + 178: ("Paused", True), + 179: ("Scheduled", True), + 180: ("Waiting for car demand", True), + 181: ("Waiting for car demand", True), + 182: ("Paused", True), + 183: ("Waiting in queue by Power Sharing", True), + 184: ("Waiting in queue by Power Sharing", True), + 185: ("Waiting in queue by Power Boost", True), + 186: ("Waiting in queue by Power Boost", True), + 187: ("Waiting MID failed", True), + 188: ("Waiting MID safety margin exceeded", True), + 189: ("Waiting in queue by Eco-Smart", True), + 193: ("Charging", True), + 194: ("Charging", True), + 195: ("Charging", True), + 196: ("Discharging", True), + 209: ("Locked", False), + 210: ("Locked, car connected", True), +} +CHARGING_STATUS_IDS = (193, 194, 195) +PAUSED_STATUS_IDS = (178, 182) + +ECO_SMART_OFF = "off" +ECO_SMART_ECO = "eco_mode" +ECO_SMART_FULL_SOLAR = "full_solar" +ECO_SMART_OPTIONS = [ECO_SMART_OFF, ECO_SMART_ECO, ECO_SMART_FULL_SOLAR] + + +def _to_float(value, default=0.0): + """Convert a value to float, returning the default when it is missing or not a number.""" + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _to_int(value, default=0): + """Convert a value to int, returning the default when it is missing or not a number.""" + try: + return int(float(value)) + except (TypeError, ValueError): + return default + + +@dataclass +class WallboxCharger: + """One charger's state, normalised from a Wallbox status payload.""" + + charger_id: str + name: str + serial: str + part_number: str + software_version: str + status_id: int + status: str + connected: bool + charging: bool + paused: bool + locked: bool + power_w: float + session_energy_kwh: float + max_charging_current: int + max_available_current: int + eco_smart: Optional[str] + + +def normalise_charger(charger_id, payload): + """Turn one status payload into a WallboxCharger, with safe defaults for anything missing.""" + if not isinstance(payload, dict): + payload = {} + config = payload.get("config_data") + if not isinstance(config, dict): + config = {} + software = config.get("software") + if not isinstance(software, dict): + software = {} + + status_id = payload.get("status_id") + if status_id is None: + status_id = 0 + status_id = _to_int(status_id, -1) + status, connected = WALLBOX_STATUS.get(status_id, ("Unknown", False)) + + eco_smart = None + eco_block = config.get("ecosmart") + if isinstance(eco_block, dict) and eco_block.get("mode") is not None: + if not eco_block.get("enabled"): + eco_smart = ECO_SMART_OFF + elif _to_int(eco_block.get("mode"), 0) == 1: + eco_smart = ECO_SMART_FULL_SOLAR + else: + eco_smart = ECO_SMART_ECO + + return WallboxCharger( + charger_id=str(charger_id), + name=str(payload.get("name") or "Wallbox {}".format(charger_id)), + serial=str(config.get("serial_number") or ""), + part_number=str(config.get("part_number") or ""), + software_version=str(software.get("currentVersion") or ""), + status_id=status_id, + status=status, + connected=connected, + charging=status_id in CHARGING_STATUS_IDS, + paused=status_id in PAUSED_STATUS_IDS, + locked=bool(config.get("locked")), + power_w=round(_to_float(payload.get("charging_power")) * 1000.0, 1), + session_energy_kwh=_to_float(payload.get("added_energy")), + max_charging_current=_to_int(config.get("max_charging_current")), + max_available_current=_to_int(payload.get("max_available_power")), + eco_smart=eco_smart, + ) + + class WallboxError(Exception): """Base class for every Wallbox API failure.""" From 486c3b55e6b75d2b3b725c8c007a7cdc80e8bce4 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 11:01:43 +0100 Subject: [PATCH 05/24] feat(wallbox): transport control calls and command line actions Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 71 ++++++++++++++++++++++++++++ apps/predbat/wallbox.py | 76 +++++++++++++++++++++++++++++- 2 files changed, 145 insertions(+), 2 deletions(-) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index ec4218ec8..dc91dccd2 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -29,6 +29,7 @@ WallboxApiError, WallboxAuthError, WallboxCharger, + WallboxPermissionError, WallboxRateLimitError, WallboxTransport, basic_auth_header, @@ -324,6 +325,72 @@ def test_normalise_captured_payloads(): print(" ✓ Captured payloads normalise to their real states") +def _signed_in_transport(): + """A transport that already holds a valid token, so each test sees only the call it makes.""" + transport = WallboxTransport(print, "user@example.com", "secret") + transport.token = "jwt-1" + transport.token_expiry = time.time() + 3600 + return transport + + +def test_transport_control_requests(): + """Each control sends exactly the method, URL and body the Wallbox API expects.""" + eco_body = lambda enabled, mode: {"data": {"attributes": {"enabled": enabled, "mode": mode}, "type": "eco_smart"}} + cases = [ + (lambda t: t.pause(101), "POST", "v3/chargers/101/remote-action", {"action": 2}), + (lambda t: t.resume(101), "POST", "v3/chargers/101/remote-action", {"action": 1}), + (lambda t: t.resume_schedule(101), "POST", "v3/chargers/101/remote-action", {"action": 9}), + (lambda t: t.set_locked(101, True), "PUT", "v2/charger/101", {"locked": 1}), + (lambda t: t.set_locked(101, False), "PUT", "v2/charger/101", {"locked": 0}), + (lambda t: t.set_max_charging_current(101, 16), "PUT", "v2/charger/101", {"maxChargingCurrent": 16}), + (lambda t: t.set_eco_smart(101, "off"), "PUT", "v4/chargers/101/eco-smart", eco_body(0, 0)), + (lambda t: t.set_eco_smart(101, "eco_mode"), "PUT", "v4/chargers/101/eco-smart", eco_body(1, 0)), + (lambda t: t.set_eco_smart(101, "full_solar"), "PUT", "v4/chargers/101/eco-smart", eco_body(1, 1)), + ] + for action, method, path, body in cases: + session, calls = _session([_response({})]) + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + run_async(action(_signed_in_transport())) + assert len(calls) == 1, calls + assert (calls[0]["method"], calls[0]["url"], calls[0]["json"]) == (method, WALLBOX_API_URL + path, body), calls[0] + assert calls[0]["headers"]["Content-Type"] == "application/json;charset=UTF-8" + print(" ✓ Control calls send the right method, URL and body") + + +def test_transport_control_refused_for_rights(): + """A 403 on a write is a rights problem, not bad credentials, and is not retried behind a sign in.""" + session, calls = _session([_response({}, status=403)]) + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + try: + run_async(_signed_in_transport().set_locked(101, True)) + raise AssertionError("Expected WallboxPermissionError") + except WallboxPermissionError: + pass + assert len(calls) == 1, "A rights refusal must not trigger a sign in and retry" + print(" ✓ A 403 on a write raises WallboxPermissionError") + + +def test_transport_control_accepts_an_empty_body(): + """Eco-Smart answers with no JSON body; that is a success, not a decode error.""" + session, _ = _session([_response(json_error=ValueError("empty"))]) + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + assert run_async(_signed_in_transport().set_eco_smart(101, "off")) == {} + print(" ✓ A control call with an empty body succeeds") + + +def test_transport_rejects_an_unknown_eco_smart_mode(): + """An Eco-Smart mode that is not one of the three options never reaches the API.""" + session, calls = _session([]) + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + try: + run_async(_signed_in_transport().set_eco_smart(101, "turbo")) + raise AssertionError("Expected WallboxApiError") + except WallboxApiError: + pass + assert calls == [] + print(" ✓ An unknown Eco-Smart mode is rejected locally") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -342,5 +409,9 @@ def test_wallbox(my_predbat=None): test_normalise_eco_smart() test_normalise_handles_bad_values() test_normalise_captured_payloads() + test_transport_control_requests() + test_transport_control_refused_for_rights() + test_transport_control_accepts_an_empty_body() + test_transport_rejects_an_unknown_eco_smart_mode() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index f9d1f0c97..1a019c70f 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -43,6 +43,14 @@ TOKEN_MARGIN_SECONDS = 120 +# remote-action codes, from the Wallbox portal +REMOTE_ACTION_RESUME = 1 +REMOTE_ACTION_PAUSE = 2 +REMOTE_ACTION_RESUME_SCHEDULE = 9 + +# How long the command line mode waits before reading a charger back after a control +COMMAND_SETTLE_SECONDS = 8 + # Status id to (text, car connected). The text is the Home Assistant wallbox integration's # wording, so states match what existing users already see. A None status id means the # charger reported nothing, which Wallbox treats as disconnected. @@ -332,9 +340,40 @@ async def get_status(self, charger_id): """Return the raw status payload of one charger.""" return await self._api("GET", "chargers/status/{}".format(charger_id)) + async def _remote_action(self, charger_id, action): + """Send one remote-action code to a charger.""" + return await self._api("POST", "v3/chargers/{}/remote-action".format(charger_id), body={"action": action}, write=True) + + async def pause(self, charger_id): + """Pause the charging session. Only has an effect while the charger is charging.""" + return await self._remote_action(charger_id, REMOTE_ACTION_PAUSE) + + async def resume(self, charger_id): + """Resume a paused charging session. Has no effect on a Scheduled or Waiting charger.""" + return await self._remote_action(charger_id, REMOTE_ACTION_RESUME) + + async def resume_schedule(self, charger_id): + """Hand the charger back to its own schedule and Eco-Smart mode after a manual stop.""" + return await self._remote_action(charger_id, REMOTE_ACTION_RESUME_SCHEDULE) + + async def set_locked(self, charger_id, locked): + """Lock or unlock the charger.""" + return await self._api("PUT", "v2/charger/{}".format(charger_id), body={"locked": 1 if locked else 0}, write=True) + + async def set_max_charging_current(self, charger_id, amps): + """Set the maximum charging current in amps.""" + return await self._api("PUT", "v2/charger/{}".format(charger_id), body={"maxChargingCurrent": int(amps)}, write=True) + + async def set_eco_smart(self, charger_id, mode): + """Set the Eco-Smart solar charging mode to one of ECO_SMART_OPTIONS.""" + if mode not in ECO_SMART_OPTIONS: + raise WallboxApiError("unknown Eco-Smart mode '{}'".format(mode)) + attributes = {"enabled": 0 if mode == ECO_SMART_OFF else 1, "mode": 1 if mode == ECO_SMART_FULL_SOLAR else 0} + return await self._api("PUT", "v4/chargers/{}/eco-smart".format(charger_id), body={"data": {"attributes": attributes, "type": "eco_smart"}}, write=True) + async def run_wallbox_cli(args): # pragma: no cover - """Sign in, list the chargers and print each one's status against the live API.""" + """Sign in, list the chargers, print each one's status and optionally send one control.""" transport = WallboxTransport(print, args.username, args.password) charger_ids = await transport.list_chargers() if not charger_ids: @@ -346,7 +385,31 @@ async def run_wallbox_cli(args): # pragma: no cover if args.raw: print(json.dumps(payload, indent=2, sort_keys=True, default=str)) else: - print("{}: status_id={} power={}kW added_energy={}kWh".format(charger_id, payload.get("status_id"), payload.get("charging_power"), payload.get("added_energy"))) + print(normalise_charger(charger_id, payload)) + + target = args.charger if args.charger is not None else charger_ids[0] + action = None + if args.pause: + action = ("pause", transport.pause(target)) + elif args.resume: + action = ("resume", transport.resume(target)) + elif args.resume_schedule: + action = ("resume schedule", transport.resume_schedule(target)) + elif args.lock: + action = ("lock", transport.set_locked(target, True)) + elif args.unlock: + action = ("unlock", transport.set_locked(target, False)) + elif args.max_current is not None: + action = ("set max current {}A".format(args.max_current), transport.set_max_charging_current(target, args.max_current)) + elif args.eco_smart: + action = ("set Eco-Smart {}".format(args.eco_smart), transport.set_eco_smart(target, args.eco_smart)) + if not action: + return + print("\nSending {} to {}...".format(action[0], target)) + await action[1] + print("Waiting {}s for the charger to report the change...".format(COMMAND_SETTLE_SECONDS)) + await asyncio.sleep(COMMAND_SETTLE_SECONDS) + print(normalise_charger(target, await transport.get_status(target))) def main(): # pragma: no cover @@ -355,6 +418,15 @@ def main(): # pragma: no cover parser.add_argument("--username", required=True, help="Wallbox account email address") parser.add_argument("--password", required=True, help="Wallbox account password") parser.add_argument("--raw", action="store_true", help="Print each charger's full status payload as JSON") + parser.add_argument("--charger", default=None, help="Charger id to control; defaults to the first one") + group = parser.add_mutually_exclusive_group() + group.add_argument("--pause", action="store_true", help="Pause the charging session") + group.add_argument("--resume", action="store_true", help="Resume a paused charging session") + group.add_argument("--resume-schedule", action="store_true", help="Hand the charger back to its own schedule") + group.add_argument("--lock", action="store_true", help="Lock the charger") + group.add_argument("--unlock", action="store_true", help="Unlock the charger") + group.add_argument("--max-current", type=int, default=None, help="Set the maximum charging current in amps") + group.add_argument("--eco-smart", choices=ECO_SMART_OPTIONS, default=None, help="Set the Eco-Smart mode") args = parser.parse_args() asyncio.run(run_wallbox_cli(args)) From 05485795d296b56f2a8d7321291eb1f61fd2e64c Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 11:02:11 +0100 Subject: [PATCH 06/24] feat(wallbox): component with polling, publishing and rate limit back-off Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/components.py | 14 ++ apps/predbat/config.py | 5 + apps/predbat/tests/test_wallbox.py | 270 +++++++++++++++++++++++++++++ apps/predbat/wallbox.py | 198 +++++++++++++++++++++ 4 files changed, 487 insertions(+) diff --git a/apps/predbat/components.py b/apps/predbat/components.py index ce458ff83..9e7835290 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -289,6 +289,20 @@ def load_component_class(component_info): "phase": 1, "can_restart": True, }, + "wallbox": { + "class": "wallbox.WallboxAPI", + "name": "Wallbox Charger", + "event_filter": "predbat_wallbox_", + "args": { + "username": {"required": True, "secret": True, "config": "wallbox_username"}, + "password": {"required": True, "secret": True, "config": "wallbox_password"}, + "automatic": {"required": False, "config": "wallbox_automatic", "default": True}, + "wallbox_control": {"required": False, "config": "wallbox_control", "default": False}, + "poll_seconds": {"required": False, "config": "wallbox_poll_seconds", "default": 120}, + }, + "phase": 1, + "can_restart": True, + }, "fox": { "class": "fox.FoxAPI", "name": "Fox API", diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 1a647bf8a..6aef46e77 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -2777,6 +2777,11 @@ "myenergi_enable_controls": {"type": "boolean"}, "myenergi_poll_seconds": {"type": "integer", "zero": False}, "myenergi_zappi_control": {"type": "boolean"}, + "wallbox_username": {"type": "string", "empty": False}, + "wallbox_password": {"type": "string", "empty": False}, + "wallbox_automatic": {"type": "boolean"}, + "wallbox_control": {"type": "boolean"}, + "wallbox_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/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index dc91dccd2..b173f55f1 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -22,10 +22,13 @@ from wallbox import ( CHARGING_STATUS_IDS, + MAX_POLL_SECONDS, + MIN_POLL_SECONDS, PAUSED_STATUS_IDS, WALLBOX_API_URL, WALLBOX_AUTH_URL, WALLBOX_STATUS, + WallboxAPI, WallboxApiError, WallboxAuthError, WallboxCharger, @@ -391,6 +394,261 @@ def test_transport_rejects_an_unknown_eco_smart_mode(): print(" ✓ An unknown Eco-Smart mode is rejected locally") +class _StubTransport: + """In-memory transport: serves statuses by charger id and records every call.""" + + def __init__(self, statuses=None): + """Hold the statuses to serve; errors maps a call tuple to an exception to raise once.""" + self.statuses = statuses if statuses is not None else {101: _status()} + self.calls = [] + self.errors = {} + + def _record(self, *call): + """Record a call, raising the queued error for it if there is one.""" + self.calls.append(call) + error = self.errors.pop(call, None) or self.errors.pop((call[0],), None) + if error: + raise error + return {} + + async def list_chargers(self): + """Return the ids of the statuses held.""" + self._record("list_chargers") + return list(self.statuses) + + async def get_status(self, charger_id): + """Return one charger's status payload.""" + self._record("get_status", charger_id) + return self.statuses[charger_id] + + async def pause(self, charger_id): + """Record a pause.""" + return self._record("pause", charger_id) + + async def resume(self, charger_id): + """Record a resume.""" + return self._record("resume", charger_id) + + async def resume_schedule(self, charger_id): + """Record a resume schedule.""" + return self._record("resume_schedule", charger_id) + + async def set_locked(self, charger_id, locked): + """Record a lock change.""" + return self._record("set_locked", charger_id, locked) + + async def set_max_charging_current(self, charger_id, amps): + """Record a current change.""" + return self._record("set_max_charging_current", charger_id, amps) + + async def set_eco_smart(self, charger_id, mode): + """Record an Eco-Smart change.""" + return self._record("set_eco_smart", charger_id, mode) + + def count(self, name): + """How many times a call of this name was made.""" + return len([call for call in self.calls if call[0] == name]) + + +def _make_component(statuses=None, **overrides): + """Build a WallboxAPI against MockBase with a stub transport and a captured log.""" + args = {"username": "user@example.com", "password": "secret", "automatic": True, "wallbox_control": False, "poll_seconds": 120} + args.update(overrides) + component = WallboxAPI(MockBase(), **args) + component.transport = _StubTransport(statuses) + component.log_messages = [] + component.log = component.log_messages.append + return component + + +def _logged(component, text): + """Was a message containing this text logged.""" + return any(text in message for message in component.log_messages) + + +def test_component_registration(): + """The component is registered with matching config keys and event filter.""" + import inspect + + from components import COMPONENT_LIST, load_component_class + from config import APPS_SCHEMA + + entry = COMPONENT_LIST["wallbox"] + assert load_component_class(entry) is WallboxAPI + assert entry["event_filter"] == "predbat_wallbox_" + assert entry["phase"] == 1 and entry["can_restart"] is True + assert entry["args"]["username"]["required"] is True and entry["args"]["username"]["secret"] is True + assert entry["args"]["password"]["required"] is True and entry["args"]["password"]["secret"] is True + assert {spec["config"] for spec in entry["args"].values()} == {"wallbox_username", "wallbox_password", "wallbox_automatic", "wallbox_control", "wallbox_poll_seconds"} + parameters = inspect.signature(WallboxAPI.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 is registered with matching config keys") + + +def test_component_poll_seconds_is_clamped(): + """The poll interval is a whole number of minutes between the limits.""" + assert _make_component(poll_seconds=120).poll_seconds == 120 + assert _make_component(poll_seconds=90).poll_seconds == 120 + assert _make_component(poll_seconds=5).poll_seconds == MIN_POLL_SECONDS + assert _make_component(poll_seconds=99999).poll_seconds == MAX_POLL_SECONDS + assert _make_component(poll_seconds="junk").poll_seconds == 120 + print(" ✓ Poll interval is rounded and clamped") + + +def test_component_missing_credentials(): + """Without both credentials there is no transport and run() fails.""" + component = WallboxAPI(MockBase(), username=None, password="secret") + assert component.transport is None + assert run_async(component.run(0, True)) is False + print(" ✓ Missing credentials leave the component failed") + + +def test_component_publishes_one_charger(): + """One charger without Eco-Smart publishes exactly the expected entities and values.""" + component = _make_component() + assert run_async(component.run(0, True)) is True + + entities = component.base.entities + prefix = "predbat_wallbox_101" + wallbox_entities = {name for name in entities if "_wallbox_" in name} + assert wallbox_entities == { + "sensor.{}_status".format(prefix), "sensor.{}_power".format(prefix), "sensor.{}_session_energy".format(prefix), + "binary_sensor.{}_connected".format(prefix), "binary_sensor.{}_charging".format(prefix), + "switch.{}_charging".format(prefix), "switch.{}_locked".format(prefix), "number.{}_max_charging_current".format(prefix), + }, wallbox_entities + assert entities["sensor.{}_status".format(prefix)]["state"] == "Charging" + status_attributes = entities["sensor.{}_status".format(prefix)]["attributes"] + assert status_attributes["status_id"] == 193 and status_attributes["serial_number"] == "900001" and status_attributes["name"] == "Garage" + assert entities["sensor.{}_power".format(prefix)]["state"] == 7200.0 + assert entities["sensor.{}_power".format(prefix)]["attributes"]["unit_of_measurement"] == "W" + assert entities["sensor.{}_session_energy".format(prefix)]["state"] == 4.5 + assert entities["binary_sensor.{}_connected".format(prefix)]["state"] == "on" + assert entities["binary_sensor.{}_charging".format(prefix)]["state"] == "on" + assert entities["switch.{}_charging".format(prefix)]["state"] == "on" + assert entities["switch.{}_locked".format(prefix)]["state"] == "off" + number = entities["number.{}_max_charging_current".format(prefix)] + assert number["state"] == 32 and number["attributes"]["min"] == 6 and number["attributes"]["max"] == 32 and number["attributes"]["step"] == 1 + assert component.last_success_timestamp is not None + print(" ✓ One charger publishes the expected entities") + + +def test_component_publishes_two_chargers_and_eco_smart(): + """Each charger gets its own entities, and the Eco-Smart select appears only where supported.""" + component = _make_component({202: _status(status_id=161, power=0, energy=0), 101: _status(eco={"enabled": True, "mode": 1})}) + assert run_async(component.run(0, True)) is True + + entities = component.base.entities + assert entities["sensor.predbat_wallbox_202_status"]["state"] == "Ready" + assert entities["binary_sensor.predbat_wallbox_202_connected"]["state"] == "off" + assert entities["switch.predbat_wallbox_202_charging"]["state"] == "off" + select = entities["select.predbat_wallbox_101_eco_smart"] + assert select["state"] == "full_solar" and select["attributes"]["options"] == ["off", "eco_mode", "full_solar"] + assert "select.predbat_wallbox_202_eco_smart" not in entities + assert [charger.charger_id for charger in component.ordered_chargers()] == ["101", "202"] + print(" ✓ Two chargers publish separately, Eco-Smart only where supported") + + +def test_component_poll_cadence(): + """Status is polled on the first run and every poll_seconds; the charger list every 30 minutes.""" + component = _make_component() + run_async(component.run(0, True)) + assert component.transport.count("get_status") == 1 and component.transport.count("list_chargers") == 1 + run_async(component.run(60, False)) + assert component.transport.count("get_status") == 1, "60s is between polls at poll_seconds=120" + run_async(component.run(120, False)) + assert component.transport.count("get_status") == 2 + assert component.transport.count("list_chargers") == 1, "The charger list is not refetched every poll" + run_async(component.run(1800, False)) + assert component.transport.count("list_chargers") == 2 + print(" ✓ Poll cadence follows poll_seconds and the 30 minute list refresh") + + +def test_component_no_chargers(): + """An account with no chargers warns on the first run and does not stamp a success.""" + component = _make_component({}) + assert run_async(component.run(0, True)) is True + assert _logged(component, "no chargers were found") + assert component.last_success_timestamp is None + assert not [name for name in component.base.entities if "_wallbox_" in name] + print(" ✓ An account with no chargers warns and publishes nothing") + + +def test_component_one_charger_failing_does_not_blank_the_other(): + """A status failure on one charger keeps the other published and keeps the charger order.""" + component = _make_component({101: _status(), 202: _status(status_id=161, power=0)}) + run_async(component.run(0, True)) + component.transport.errors[("get_status", 202)] = WallboxApiError("HTTP 500") + component.transport.statuses[101] = _status(status_id=178, power=0) + + assert run_async(component.run(120, False)) is True + assert component.base.entities["sensor.predbat_wallbox_101_status"]["state"] == "Paused" + assert component.stale_ids == {"202"} + assert [charger.charger_id for charger in component.ordered_chargers()] == ["101", "202"], "Charger N must stay car N" + assert _logged(component, "could not read charger 202") + + assert run_async(component.run(240, False)) is True + assert component.stale_ids == set() + print(" ✓ One failing charger does not blank the other") + + +def test_component_every_charger_failing_fails_the_cycle(): + """When no charger could be read the cycle fails and no success is stamped.""" + component = _make_component() + component.transport.errors[("get_status",)] = WallboxApiError("HTTP 500") + assert run_async(component.run(0, True)) is False + assert component.last_success_timestamp is None + assert _logged(component, "poll failed") + print(" ✓ A poll that reads no charger fails the cycle") + + +def test_component_auth_failure(): + """Bad credentials log an error naming the config keys and fail the cycle.""" + component = _make_component() + component.transport.errors[("list_chargers",)] = WallboxAuthError("rejected") + assert run_async(component.run(0, True)) is False + assert _logged(component, "Error: wallbox:") and _logged(component, "wallbox_username") + print(" ✓ Bad credentials are reported as an error") + + +def test_component_rate_limit_backoff(): + """A 429 skips polls on a doubling back-off capped at 15 minutes, then recovers.""" + component = _make_component() + run_async(component.run(0, True)) + + component.transport.errors[("get_status",)] = WallboxRateLimitError("429") + assert run_async(component.run(120, False)) is True, "A rate limit after start-up is not a failed cycle" + assert component.skip_cycles == 2 and _logged(component, "rate limited") + + polls = component.transport.count("get_status") + run_async(component.run(180, False)) + run_async(component.run(240, False)) + assert component.transport.count("get_status") == polls, "Both skipped cycles made no call" + + for expected in (4, 8, 15, 15): + component.skip_cycles = 0 + component.transport.errors[("get_status",)] = WallboxRateLimitError("429") + run_async(component.run(120, False)) + assert component.skip_cycles == expected, (expected, component.skip_cycles) + + component.skip_cycles = 0 + assert run_async(component.run(120, False)) is True + assert component.backoff_cycles == 0, "A good poll resets the back-off" + print(" ✓ Rate limit back-off doubles, caps at 15 minutes and resets") + + +def test_component_rate_limit_on_first_run_fails(): + """A 429 on the very first run fails it, so the component is not marked started with no data.""" + component = _make_component() + component.transport.errors[("list_chargers",)] = WallboxRateLimitError("429") + assert run_async(component.run(0, True)) is False + # The retry of the first run must poll, not be swallowed by the skip counter + assert run_async(component.run(0, True)) is True + assert "sensor.predbat_wallbox_101_status" in component.base.entities + print(" ✓ A rate limit on the first run fails it and the retry polls") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -413,5 +671,17 @@ def test_wallbox(my_predbat=None): test_transport_control_refused_for_rights() test_transport_control_accepts_an_empty_body() test_transport_rejects_an_unknown_eco_smart_mode() + test_component_registration() + test_component_poll_seconds_is_clamped() + test_component_missing_credentials() + test_component_publishes_one_charger() + test_component_publishes_two_chargers_and_eco_smart() + test_component_poll_cadence() + test_component_no_chargers() + test_component_one_charger_failing_does_not_blank_the_other() + test_component_every_charger_failing_fails_the_cycle() + test_component_auth_failure() + test_component_rate_limit_backoff() + test_component_rate_limit_on_first_run_fails() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index 1a019c70f..87ce16f62 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -30,6 +30,7 @@ import aiohttp +from component_base import ComponentBase from predbat_metrics import record_api_call WALLBOX_AUTH_URL = "https://user-api.wall-box.com/" @@ -51,6 +52,22 @@ # How long the command line mode waits before reading a charger back after a control COMMAND_SETTLE_SECONDS = 8 +# components.py's is_alive() fails a component whose last successful update is more than +# 60 minutes old, so the poll interval is capped at half that +MIN_POLL_SECONDS = 60 +MAX_POLL_SECONDS = 30 * 60 +DEFAULT_POLL_SECONDS = 120 +CHARGER_LIST_SECONDS = 30 * 60 + +# Rate limit back-off, in 60 second run cycles. The cap keeps recovery well inside the +# 60 minute health window. +RATE_LIMIT_MIN_CYCLES = 2 +RATE_LIMIT_MAX_CYCLES = 15 + +MIN_CHARGING_CURRENT = 6 +# Used as the upper bound only when the charger does not report one +DEFAULT_MAX_CHARGING_CURRENT = 32 + # Status id to (text, car connected). The text is the Home Assistant wallbox integration's # wording, so states match what existing users already see. A None status id means the # charger reported nothing, which Wallbox treats as disconnected. @@ -372,6 +389,187 @@ async def set_eco_smart(self, charger_id, mode): return await self._api("PUT", "v4/chargers/{}/eco-smart".format(charger_id), body={"data": {"attributes": attributes, "type": "eco_smart"}}, write=True) +wallbox_attribute_table = { + "status": {"friendly_name": "Wallbox Status", "icon": "mdi:information-outline"}, + "power": {"friendly_name": "Wallbox Power", "icon": "mdi:lightning-bolt", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + "session_energy": {"friendly_name": "Wallbox Session Energy", "icon": "mdi:lightning-bolt", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total_increasing"}, + "connected": {"friendly_name": "Wallbox Car Connected", "icon": "mdi:ev-plug-type2"}, + "charging": {"friendly_name": "Wallbox Charging", "icon": "mdi:battery-charging"}, + "charging_switch": {"friendly_name": "Wallbox Charge", "icon": "mdi:ev-station"}, + "locked": {"friendly_name": "Wallbox Locked", "icon": "mdi:lock"}, + "max_charging_current": {"friendly_name": "Wallbox Maximum Charging Current", "icon": "mdi:current-ac", "unit_of_measurement": "A", "min": MIN_CHARGING_CURRENT, "step": 1}, + "eco_smart": {"friendly_name": "Wallbox Eco-Smart", "icon": "mdi:solar-power", "options": ECO_SMART_OPTIONS}, + "control": {"friendly_name": "Wallbox Charge Control", "icon": "mdi:ev-station"}, +} + + +class WallboxAPI(ComponentBase): + """Wallbox component: monitoring, controls and Predbat-led charging for Wallbox chargers.""" + + def initialize(self, username, password, automatic=True, wallbox_control=False, poll_seconds=120): + """Set up component state and the transport.""" + self.automatic = automatic + self.wallbox_control = bool(wallbox_control) + # ComponentBase.start() calls run() every 60 seconds, so the interval is a whole number of those + self.poll_seconds = min(MAX_POLL_SECONDS, max(MIN_POLL_SECONDS, int(round(_to_float(poll_seconds, DEFAULT_POLL_SECONDS) / 60.0)) * 60)) + + self.chargers = {} + self.charger_ids = [] + self.stale_ids = set() + self.queued_events = [] + self.skip_cycles = 0 + self.backoff_cycles = 0 + self.permission_warned = False + self._auto_configured = False + self.control_active = False + self.control_enabled = True + self.transport = None + + if not username or not password: + self.log("Error: wallbox: wallbox_username and wallbox_password must both be set") + return + self.transport = WallboxTransport(self.log, username, password) + + def entity_prefix(self, charger): + """Return the entity name prefix for a charger, e.g. predbat_wallbox_12345.""" + return "{}_wallbox_{}".format(self.prefix, charger.charger_id) + + def ordered_chargers(self): + """The chargers in id order. This order is what makes charger N the same thing as car N.""" + return [self.chargers[charger_id] for charger_id in sorted(self.chargers)] + + def warn_permission(self): + """Say once that the account cannot control the charger.""" + if not self.permission_warned: + self.log("Warn: wallbox: Wallbox refused a control - the account needs admin rights over the charger. Monitoring continues") + self.permission_warned = True + + async def load_control_state(self): + """Restore saved control state. Filled in with plan-led control.""" + + def enable_control(self): + """Decide whether plan-led control can run. Filled in with plan-led control.""" + + def automatic_config(self): + """Wire the charger entities into Predbat's car inputs. Filled in with automatic configuration.""" + + async def control_tick(self, now): + """Run one cycle of plan-led control. Filled in with plan-led control.""" + + async def run(self, seconds, first): + """Process queued control events, then poll and publish.""" + if not self.transport: + return False + if first: + await self.load_control_state() + self.enable_control() + self.skip_cycles = 0 + if self.skip_cycles > 0: + self.skip_cycles -= 1 + return True + try: + refresh = await self.process_events() + if first or refresh or (seconds % self.poll_seconds) == 0: + return await self.poll(seconds, first) + return True + except WallboxRateLimitError: + self.backoff_cycles = min(RATE_LIMIT_MAX_CYCLES, max(RATE_LIMIT_MIN_CYCLES, self.backoff_cycles * 2)) + self.skip_cycles = self.backoff_cycles + self.log("Warn: wallbox: rate limited by the Wallbox API, pausing polling for {} minutes".format(self.backoff_cycles)) + # After start-up one rate limit is not a failure, but a first run that got no + # data must not mark the component started + return not first + except WallboxAuthError as exc: + self.log("Error: wallbox: sign in failed, check wallbox_username and wallbox_password: {}".format(exc)) + return False + except WallboxError as exc: + self.log("Warn: wallbox: poll failed: {}".format(exc)) + return False + + async def process_events(self): + """Run every queued control. Returns True when at least one ran, so the caller polls afresh.""" + refresh = False + while self.queued_events: + handler, *event_args = self.queued_events[0] + try: + await handler(*event_args) + except WallboxRateLimitError: + # Left on the queue, so it is retried once the back-off has passed + raise + except WallboxPermissionError: + self.warn_permission() + except WallboxError as exc: + self.log("Warn: wallbox: control failed: {}".format(exc)) + self.queued_events.pop(0) + refresh = True + return refresh + + async def poll(self, seconds, first): + """Read every charger, publish, and run automatic configuration and control.""" + if first or not self.charger_ids or (seconds % CHARGER_LIST_SECONDS) == 0: + self.charger_ids = sorted(await self.transport.list_chargers(), key=str) + if not self.charger_ids: + if first: + self.log("Warn: wallbox: signed in but no chargers were found on the account") + return True + + chargers = {} + stale_ids = set() + for charger_id in self.charger_ids: + key = str(charger_id) + try: + chargers[key] = normalise_charger(charger_id, await self.transport.get_status(charger_id)) + except WallboxApiError as exc: + self.log("Warn: wallbox: could not read charger {}: {}".format(charger_id, exc)) + # Keep the last record so the charger keeps its place in the car order, + # marked stale so control does not act on old state + if key in self.chargers: + chargers[key] = self.chargers[key] + stale_ids.add(key) + if len(stale_ids) == len(chargers): + raise WallboxApiError("no charger could be read") + + self.chargers = chargers + self.stale_ids = stale_ids + await self.publish_data() + if self.automatic and not self._auto_configured: + self.automatic_config() + self._auto_configured = True + try: + await self.control_tick(self.now_utc_exact) + except WallboxPermissionError: + self.warn_permission() + except WallboxApiError as exc: + # Monitoring succeeded, so a refused control is a warning, not a failed cycle + self.log("Warn: wallbox: charge control failed: {}".format(exc)) + self.backoff_cycles = 0 + self.update_success_timestamp() + return True + + async def publish_data(self): + """Publish every known charger as Predbat entities.""" + if self.control_active: + # Published only while control could act on it, so the switch is never a lie + self.dashboard_item("switch.{}_wallbox_control".format(self.prefix), state="on" if self.control_enabled else "off", attributes=wallbox_attribute_table["control"], app="wallbox") + for charger in self.ordered_chargers(): + prefix = self.entity_prefix(charger) + status_attributes = dict(wallbox_attribute_table["status"]) + status_attributes.update({"status_id": charger.status_id, "name": charger.name, "serial_number": charger.serial, "part_number": charger.part_number, "software_version": charger.software_version}) + current_attributes = dict(wallbox_attribute_table["max_charging_current"]) + current_attributes["max"] = charger.max_available_current if charger.max_available_current >= MIN_CHARGING_CURRENT else DEFAULT_MAX_CHARGING_CURRENT + + self.dashboard_item("sensor.{}_status".format(prefix), state=charger.status, attributes=status_attributes, app="wallbox") + self.dashboard_item("sensor.{}_power".format(prefix), state=charger.power_w, attributes=wallbox_attribute_table["power"], app="wallbox") + self.dashboard_item("sensor.{}_session_energy".format(prefix), state=charger.session_energy_kwh, attributes=wallbox_attribute_table["session_energy"], app="wallbox") + self.dashboard_item("binary_sensor.{}_connected".format(prefix), state="on" if charger.connected else "off", attributes=wallbox_attribute_table["connected"], app="wallbox") + self.dashboard_item("binary_sensor.{}_charging".format(prefix), state="on" if charger.charging else "off", attributes=wallbox_attribute_table["charging"], app="wallbox") + self.dashboard_item("switch.{}_charging".format(prefix), state="on" if charger.charging else "off", attributes=wallbox_attribute_table["charging_switch"], app="wallbox") + self.dashboard_item("switch.{}_locked".format(prefix), state="on" if charger.locked else "off", attributes=wallbox_attribute_table["locked"], app="wallbox") + self.dashboard_item("number.{}_max_charging_current".format(prefix), state=charger.max_charging_current, attributes=current_attributes, app="wallbox") + if charger.eco_smart is not None: + self.dashboard_item("select.{}_eco_smart".format(prefix), state=charger.eco_smart, attributes=wallbox_attribute_table["eco_smart"], app="wallbox") + + async def run_wallbox_cli(args): # pragma: no cover """Sign in, list the chargers, print each one's status and optionally send one control.""" transport = WallboxTransport(print, args.username, args.password) From f089b6a77c0faacbcc55acc68861cc8a1fc7ac3b Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 11:02:26 +0100 Subject: [PATCH 07/24] feat(wallbox): pause, lock, charging current and Eco-Smart controls Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 125 +++++++++++++++++++++++++++++ apps/predbat/wallbox.py | 76 ++++++++++++++++++ 2 files changed, 201 insertions(+) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index b173f55f1..45d31884d 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -649,6 +649,123 @@ def test_component_rate_limit_on_first_run_fails(): print(" ✓ A rate limit on the first run fails it and the retry polls") +def _started_component(statuses=None, **overrides): + """A component that has completed its first poll, with the transport call log cleared.""" + component = _make_component(statuses, **overrides) + run_async(component.run(0, True)) + component.transport.calls = [] + return component + + +def test_controls_queue_rather_than_call(): + """An event only queues; the API call happens in the next run(), followed by a fresh poll.""" + component = _started_component() + run_async(component.switch_event("switch.predbat_wallbox_101_charging", "turn_off")) + assert component.transport.calls == [] and len(component.queued_events) == 1 + + run_async(component.run(60, False)) + assert component.transport.calls[0] == ("pause", 101) + assert ("get_status", 101) in component.transport.calls, "A control is followed by a re-poll even between polls" + assert component.queued_events == [] + print(" ✓ Controls are queued and followed by a re-poll") + + +def test_switch_controls(): + """The charging and lock switches map to resume/pause and lock/unlock.""" + cases = [ + ("switch.predbat_wallbox_101_charging", "turn_on", ("resume", 101)), + ("switch.predbat_wallbox_101_charging", "turn_off", ("pause", 101)), + ("switch.predbat_wallbox_101_locked", "turn_on", ("set_locked", 101, True)), + ("switch.predbat_wallbox_101_locked", "turn_off", ("set_locked", 101, False)), + ] + for entity_id, service, expected in cases: + component = _started_component() + run_async(component.switch_event_handler(entity_id, service)) + assert component.transport.calls == [expected], (entity_id, service, component.transport.calls) + + component = _started_component() + run_async(component.switch_event_handler("switch.predbat_wallbox_101_charging", "toggle")) + run_async(component.switch_event_handler("switch.predbat_wallbox_999_charging", "turn_on")) + run_async(component.switch_event_handler("switch.predbat_wallbox_101_something", "turn_on")) + assert component.transport.calls == [], "Unknown services, chargers and entities send nothing" + print(" ✓ Switch controls send the right calls") + + +def test_number_control_clamps_and_ignores_junk(): + """The charging current is clamped to 6..max available, and a non-number is ignored.""" + component = _started_component() + run_async(component.number_event_handler("number.predbat_wallbox_101_max_charging_current", 16)) + run_async(component.number_event_handler("number.predbat_wallbox_101_max_charging_current", "20.0")) + run_async(component.number_event_handler("number.predbat_wallbox_101_max_charging_current", 2)) + run_async(component.number_event_handler("number.predbat_wallbox_101_max_charging_current", 500)) + assert component.transport.calls == [("set_max_charging_current", 101, 16), ("set_max_charging_current", 101, 20), ("set_max_charging_current", 101, 6), ("set_max_charging_current", 101, 32)] + + component.transport.calls = [] + run_async(component.number_event_handler("number.predbat_wallbox_101_max_charging_current", "junk")) + run_async(component.number_event_handler("number.predbat_wallbox_101_max_charging_current", None)) + run_async(component.number_event_handler("number.predbat_wallbox_101_other", 16)) + assert component.transport.calls == [], "Junk values and other number entities send nothing" + print(" ✓ Charging current is clamped and junk is ignored") + + +def test_select_control(): + """Eco-Smart accepts only its three options, and only on a charger that supports it.""" + component = _started_component({101: _status(eco={"enabled": False, "mode": 0})}) + run_async(component.select_event_handler("select.predbat_wallbox_101_eco_smart", "full_solar")) + run_async(component.select_event_handler("select.predbat_wallbox_101_eco_smart", "turbo")) + assert component.transport.calls == [("set_eco_smart", 101, "full_solar")] + + unsupported = _started_component() + run_async(unsupported.select_event_handler("select.predbat_wallbox_101_eco_smart", "eco_mode")) + assert unsupported.transport.calls == [] + print(" ✓ Eco-Smart select sends only valid modes to chargers that support it") + + +def test_control_refused_for_rights_warns_once(): + """A rights refusal warns once across repeated attempts and does not fail the cycle.""" + component = _started_component() + for _ in range(2): + component.transport.errors[("set_locked", 101, True)] = WallboxPermissionError("403") + run_async(component.switch_event("switch.predbat_wallbox_101_locked", "turn_on")) + assert run_async(component.run(60, False)) is True + assert len([message for message in component.log_messages if "admin rights" in message]) == 1 + print(" ✓ A rights refusal warns once and monitoring continues") + + +def test_control_failure_is_logged_and_not_retried(): + """An API failure on a control is logged, dropped from the queue, and the cycle still succeeds.""" + component = _started_component() + component.transport.errors[("pause", 101)] = WallboxApiError("HTTP 500") + run_async(component.switch_event("switch.predbat_wallbox_101_charging", "turn_off")) + assert run_async(component.run(60, False)) is True + assert _logged(component, "control failed") and component.queued_events == [] + print(" ✓ A failed control is logged and dropped") + + +def test_control_survives_a_rate_limit(): + """A control that hits the rate limit stays queued and is sent after the back-off.""" + component = _started_component() + component.transport.errors[("pause", 101)] = WallboxRateLimitError("429") + run_async(component.switch_event("switch.predbat_wallbox_101_charging", "turn_off")) + run_async(component.run(60, False)) + assert len(component.queued_events) == 1 and component.skip_cycles == 2 + + component.skip_cycles = 0 + component.transport.calls = [] + run_async(component.run(120, False)) + assert component.transport.calls[0] == ("pause", 101) and component.queued_events == [] + print(" ✓ A rate limited control is retried after the back-off") + + +def test_charger_for_entity_requires_a_whole_id(): + """Charger 10 must not claim charger 101's entities.""" + component = _started_component({10: _status(), 101: _status()}) + assert component.charger_for_entity("switch.predbat_wallbox_101_charging").charger_id == "101" + assert component.charger_for_entity("switch.predbat_wallbox_10_charging").charger_id == "10" + assert component.charger_for_entity("switch.predbat_wallbox_control") is None + print(" ✓ Entities resolve to a whole charger id") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -683,5 +800,13 @@ def test_wallbox(my_predbat=None): test_component_auth_failure() test_component_rate_limit_backoff() test_component_rate_limit_on_first_run_fails() + test_controls_queue_rather_than_call() + test_switch_controls() + test_number_control_clamps_and_ignores_junk() + test_select_control() + test_control_refused_for_rights_warns_once() + test_control_failure_is_logged_and_not_retried() + test_control_survives_a_rate_limit() + test_charger_for_entity_requires_a_whole_id() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index 87ce16f62..7864d3a6f 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -569,6 +569,82 @@ async def publish_data(self): if charger.eco_smart is not None: self.dashboard_item("select.{}_eco_smart".format(prefix), state=charger.eco_smart, attributes=wallbox_attribute_table["eco_smart"], app="wallbox") + def charger_for_entity(self, entity_id): + """Find the charger an entity belongs to, or None. + + The trailing underscore anchors the match to a whole id, so charger 10 cannot + claim charger 101's entities. + """ + for charger in self.chargers.values(): + if "{}_".format(self.entity_prefix(charger)) in entity_id: + return charger + return None + + def api_id(self, charger): + """The id in the form the API listed it, for a normalised charger.""" + for charger_id in self.charger_ids: + if str(charger_id) == charger.charger_id: + return charger_id + return charger.charger_id + + async def switch_event(self, entity_id, service): + """Queue a switch service call for the run loop.""" + 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.""" + self.queued_events.append((self.number_event_handler, entity_id, value)) + + async def select_event(self, entity_id, value): + """Queue a select change for the run loop.""" + self.queued_events.append((self.select_event_handler, entity_id, value)) + + async def switch_event_handler(self, entity_id, service): + """Pause/resume or lock/unlock in response to a charger switch.""" + if service not in ("turn_on", "turn_off"): + return + charger = self.charger_for_entity(entity_id) + if not charger: + return + turn_on = service == "turn_on" + if entity_id.endswith("_charging"): + self.log("Info: wallbox: {} charging on {}".format("resuming" if turn_on else "pausing", charger.name)) + if turn_on: + await self.transport.resume(self.api_id(charger)) + else: + await self.transport.pause(self.api_id(charger)) + elif entity_id.endswith("_locked"): + self.log("Info: wallbox: {} {}".format("locking" if turn_on else "unlocking", charger.name)) + await self.transport.set_locked(self.api_id(charger), turn_on) + + async def number_event_handler(self, entity_id, value): + """Set the maximum charging current, clamped to what the charger allows.""" + if not entity_id.endswith("_max_charging_current"): + return + charger = self.charger_for_entity(entity_id) + if not charger: + return + try: + amps = int(float(value)) + except (TypeError, ValueError): + self.log("Warn: wallbox: ignoring charging current '{}' for {}".format(value, charger.name)) + return + upper = charger.max_available_current if charger.max_available_current >= MIN_CHARGING_CURRENT else DEFAULT_MAX_CHARGING_CURRENT + amps = max(MIN_CHARGING_CURRENT, min(upper, amps)) + self.log("Info: wallbox: setting {} maximum charging current to {}A".format(charger.name, amps)) + await self.transport.set_max_charging_current(self.api_id(charger), amps) + + async def select_event_handler(self, entity_id, value): + """Set the Eco-Smart mode on a charger that supports it.""" + if not entity_id.endswith("_eco_smart") or value not in ECO_SMART_OPTIONS: + return + charger = self.charger_for_entity(entity_id) + if not charger or charger.eco_smart is None: + return + self.log("Info: wallbox: setting {} Eco-Smart to {}".format(charger.name, value)) + await self.transport.set_eco_smart(self.api_id(charger), value) + + async def run_wallbox_cli(args): # pragma: no cover """Sign in, list the chargers, print each one's status and optionally send one control.""" From 00a9a0f1a6414cf6a23513784d88abc4a64ea68b Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 11:02:50 +0100 Subject: [PATCH 08/24] feat(wallbox): automatic car charging configuration Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 60 ++++++++++++++++++++++++++++++ apps/predbat/wallbox.py | 27 +++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index 45d31884d..dc5d03ab7 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -766,6 +766,61 @@ def test_charger_for_entity_requires_a_whole_id(): print(" ✓ Entities resolve to a whole charger id") +def test_automatic_config_two_chargers(): + """Each argument is a per-car list in charger id order, and num_cars is raised to match.""" + component = _make_component({202: _status(), 101: _status()}) + run_async(component.run(0, True)) + args = component.base.args + assert args["car_charging_energy"] == ["sensor.predbat_wallbox_101_session_energy", "sensor.predbat_wallbox_202_session_energy"] + assert args["car_charging_planned"] == ["binary_sensor.predbat_wallbox_101_connected", "binary_sensor.predbat_wallbox_202_connected"] + assert args["car_charging_power"] == ["sensor.predbat_wallbox_101_power", "sensor.predbat_wallbox_202_power"] + assert args["car_charging_now"] == ["sensor.predbat_wallbox_101_power", "sensor.predbat_wallbox_202_power"] + assert args["num_cars"] == 2 + assert "car_charging_soc" not in args, "A Type 2 charger cannot report state of charge" + print(" ✓ Automatic configuration wires per-car lists in charger order") + + +def test_automatic_config_single_charger_is_still_a_list(): + """One charger still produces lists, which is what the per-car arguments expect.""" + component = _make_component() + run_async(component.run(0, True)) + assert component.base.args["car_charging_energy"] == ["sensor.predbat_wallbox_101_session_energy"] + assert component.base.args["num_cars"] == 1 + print(" ✓ A single charger is still wired as a list") + + +def test_automatic_config_keeps_user_values(): + """A user's car_charging_now is kept, and a larger num_cars is not reduced.""" + component = _make_component() + # set_arg_auto() decides what the user configured from the raw apps.yaml arguments + component.base.args_from_apps_yaml = {"car_charging_now": ["binary_sensor.my_car_charging"]} + component.base.apps_yaml_override_warned = set() + component.base.args["car_charging_now"] = ["binary_sensor.my_car_charging"] + component.base.args["num_cars"] = 3 + run_async(component.run(0, True)) + assert component.base.args["car_charging_now"] == ["binary_sensor.my_car_charging"] + assert component.base.args["num_cars"] == 3 + print(" ✓ User car_charging_now and a larger num_cars are kept") + + +def test_automatic_config_disabled(): + """With wallbox_automatic off nothing is wired.""" + component = _make_component(automatic=False) + run_async(component.run(0, True)) + assert "car_charging_energy" not in component.base.args and "num_cars" not in component.base.args + print(" ✓ Automatic configuration off wires nothing") + + +def test_automatic_config_runs_once(): + """A value the user changes after start-up is not put back by the next poll.""" + component = _make_component() + run_async(component.run(0, True)) + component.base.args["car_charging_energy"] = ["sensor.something_else"] + run_async(component.run(120, False)) + assert component.base.args["car_charging_energy"] == ["sensor.something_else"] + print(" ✓ Automatic configuration runs once") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -808,5 +863,10 @@ def test_wallbox(my_predbat=None): test_control_failure_is_logged_and_not_retried() test_control_survives_a_rate_limit() test_charger_for_entity_requires_a_whole_id() + test_automatic_config_two_chargers() + test_automatic_config_single_charger_is_still_a_list() + test_automatic_config_keeps_user_values() + test_automatic_config_disabled() + test_automatic_config_runs_once() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index 7864d3a6f..7bbcb51ea 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -451,7 +451,32 @@ def enable_control(self): """Decide whether plan-led control can run. Filled in with plan-led control.""" def automatic_config(self): - """Wire the charger entities into Predbat's car inputs. Filled in with automatic configuration.""" + """Wire the charger entities into Predbat's car charging inputs. + + Each argument is a per-car list in charger id order, so charger N is car N. + car_charging_now takes the power sensors, which count as charging from + CAR_CHARGING_NOW_POWER_W; a value the user set in apps.yaml is kept. Session + energy resets with each session, which the shared incrementing-sensor handling + already copes with. car_charging_soc is not set: a Type 2 connector cannot + report the car's state of charge. + """ + chargers = self.ordered_chargers() + if not chargers: + return + energy_entities = ["sensor.{}_session_energy".format(self.entity_prefix(charger)) for charger in chargers] + planned_entities = ["binary_sensor.{}_connected".format(self.entity_prefix(charger)) for charger in chargers] + power_entities = ["sensor.{}_power".format(self.entity_prefix(charger)) for charger in chargers] + + self.log("Info: wallbox: setting car_charging_energy to {}".format(energy_entities)) + self.set_arg_auto("car_charging_energy", energy_entities) + self.log("Info: wallbox: setting car_charging_planned to {}".format(planned_entities)) + self.set_arg_auto("car_charging_planned", planned_entities) + self.log("Info: wallbox: setting car_charging_power and car_charging_now to {}".format(power_entities)) + self.set_arg_auto("car_charging_power", power_entities) + self.set_arg_auto("car_charging_now", power_entities, overwrite=False) + if _to_int(self.get_arg("num_cars", 0), 0) < len(chargers): + self.log("Info: wallbox: setting num_cars to {}".format(len(chargers))) + self.set_arg("num_cars", len(chargers)) async def control_tick(self, now): """Run one cycle of plan-led control. Filled in with plan-led control.""" From f5fedfaa8a52967ea62cb45963741294ef4cd6f6 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 11:03:11 +0100 Subject: [PATCH 09/24] feat(wallbox): Predbat-led charging with persistent release Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 271 +++++++++++++++++++++++++++++ apps/predbat/wallbox.py | 167 +++++++++++++++++- 2 files changed, 429 insertions(+), 9 deletions(-) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index dc5d03ab7..ff7a4ce8b 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -821,6 +821,261 @@ def test_automatic_config_runs_once(): print(" ✓ Automatic configuration runs once") +CONTROL_TZ = pytz.timezone("Europe/London") +CONTROL_NOW = CONTROL_TZ.localize(datetime.datetime(2026, 10, 5, 23, 30)) + + +def _plan_window(start, end): + """Build one planned-window dict in the shape output.py publishes.""" + return {"start": start.strftime("%m-%d %H:%M:%S"), "end": end.strftime("%m-%d %H:%M:%S")} + + +# CONTROL_NOW is inside this window... +PLAN_INSIDE = [_plan_window(datetime.datetime(2026, 10, 5, 23, 0), datetime.datetime(2026, 10, 6, 1, 0))] +# ...and before this one +PLAN_OUTSIDE = [_plan_window(datetime.datetime(2026, 10, 6, 2, 0), datetime.datetime(2026, 10, 6, 4, 0))] + + +class _Storage: + """Minimal in-memory stand-in for the Storage component.""" + + def __init__(self): + """Start empty.""" + self.data = {} + + async def save(self, module, filename, data, **kwargs): + """Store a document.""" + self.data[(module, filename)] = data + + async def load(self, module, filename): + """Return a stored document, or None.""" + return self.data.get((module, filename)) + + +class _Components: + """Stand-in for the component registry, serving only the storage component.""" + + def __init__(self, storage): + """Hold the storage stand-in to serve.""" + self.storage = storage + + def get_component(self, name): + """Return the storage stand-in, and nothing else.""" + return self.storage if name == "storage" else None + + +def _control_component(plans, statuses=None, storage=None, **overrides): + """A component with control on, chargers loaded and car plans published. + + plans maps car number to a list of _plan_window() dicts. The chargers are loaded + directly rather than through run(), so each test calls control_tick() with a fixed clock. + """ + overrides.setdefault("wallbox_control", True) + component = _make_component(statuses, **overrides) + component.local_tz = CONTROL_TZ + component.base.local_tz = CONTROL_TZ + if storage is not None: + component.base.components = _Components(storage) + for car_n, windows in plans.items(): + postfix = "" if car_n == 0 else "_{}".format(car_n) + component.base.set_state_wrapper("binary_sensor.predbat_car_charging_slot" + postfix, "off", {"planned": windows}) + run_async(component.load_control_state()) + component.enable_control() + _load_chargers(component) + return component + + +def _load_chargers(component): + """Load the stub transport's current statuses into the component, as a poll would.""" + component.charger_ids = sorted(component.transport.statuses, key=str) + component.chargers = {str(charger_id): normalise_charger(charger_id, payload) for charger_id, payload in component.transport.statuses.items()} + component.stale_ids = set() + component.transport.calls = [] + + +def test_control_needs_automatic(): + """Control stays off, with a warning, unless automatic configuration is on.""" + assert _control_component({}).control_active is True + without_auto = _control_component({}, automatic=False) + assert without_auto.control_active is False and _logged(without_auto, "needs wallbox_automatic") + assert _control_component({}, wallbox_control=False).control_active is False + print(" ✓ Control needs wallbox_control and wallbox_automatic") + + +def test_control_resumes_inside_a_window(): + """A Paused charger inside its car's window is resumed.""" + component = _control_component({0: PLAN_INSIDE}, {101: _status(status_id=178, power=0)}) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("resume", 101)] + print(" ✓ A paused charger inside a window is resumed") + + +def test_control_pauses_outside_a_window(): + """A Charging charger outside its car's window is paused, and Predbat remembers it did so.""" + component = _control_component({0: PLAN_OUTSIDE}) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("pause", 101)] + assert component.paused_by_predbat == {"101"} + print(" ✓ A charging charger outside a window is paused") + + +def test_control_leaves_other_states_alone(): + """Scheduled, Waiting, Ready, Locked and already-correct chargers are sent nothing.""" + for plan, status_id in [(PLAN_INSIDE, 177), (PLAN_INSIDE, 179), (PLAN_INSIDE, 180), (PLAN_INSIDE, 164), (PLAN_INSIDE, 161), (PLAN_INSIDE, 193), (PLAN_INSIDE, 210), (PLAN_OUTSIDE, 178), (PLAN_OUTSIDE, 177), (PLAN_OUTSIDE, 161), (PLAN_OUTSIDE, 210)]: + component = _control_component({0: plan}, {101: _status(status_id=status_id, power=0)}) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [], (status_id, component.transport.calls) + print(" ✓ Scheduled, Waiting, Ready and Locked chargers are left alone") + + +def test_control_warns_once_about_a_locked_charger(): + """A locked charger with a car connected inside a window is warned about once, never unlocked.""" + component = _control_component({0: PLAN_INSIDE}, {101: _status(status_id=210, power=0, locked=True)}) + run_async(component.control_tick(CONTROL_NOW)) + run_async(component.control_tick(CONTROL_NOW)) + assert len([message for message in component.log_messages if "is locked" in message]) == 1 + assert component.transport.count("set_locked") == 0 + print(" ✓ A locked charger is warned about once and never unlocked") + + +def test_control_is_per_car(): + """Charger N follows car N's plan: the second charger reads the _1 slot sensor.""" + statuses = {101: _status(status_id=178, power=0), 202: _status(status_id=193)} + component = _control_component({0: PLAN_INSIDE, 1: PLAN_OUTSIDE}, statuses) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("resume", 101), ("pause", 202)] + print(" ✓ Each charger follows its own car's plan") + + +def test_control_skips_a_charger_with_no_plan(): + """A charger whose car has no slot sensor is left alone, not paused as if outside a window.""" + statuses = {101: _status(status_id=193), 202: _status(status_id=193)} + component = _control_component({0: PLAN_INSIDE}, statuses) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [], "Charger 202 has no car 1 plan, so it must not be paused" + + nothing_planned = _control_component({}) + run_async(nothing_planned.control_tick(CONTROL_NOW)) + assert nothing_planned.transport.calls == [], "Before Predbat has planned anything, no charger is touched" + print(" ✓ A charger with no car plan is left alone") + + +def test_control_skips_a_stale_charger(): + """A charger whose last status read failed is not controlled on old state.""" + component = _control_component({0: PLAN_OUTSIDE}) + component.stale_ids = {"101"} + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [] + print(" ✓ A stale charger is not controlled") + + +def test_control_forgets_an_unplugged_charger(): + """Once the car is unplugged, Predbat no longer counts the charger as one it paused.""" + component = _control_component({0: PLAN_OUTSIDE}) + run_async(component.control_tick(CONTROL_NOW)) + assert component.paused_by_predbat == {"101"} + component.transport.statuses[101] = _status(status_id=161, power=0) + _load_chargers(component) + run_async(component.control_tick(CONTROL_NOW)) + assert component.paused_by_predbat == set() and component.transport.calls == [] + print(" ✓ An unplugged charger is forgotten") + + +def test_control_releases_on_read_only_and_switch_off(): + """Read only mode and the control switch both resume what Predbat paused and hand back the schedule.""" + for release in ("read_only", "switch"): + component = _control_component({0: PLAN_OUTSIDE}) + run_async(component.control_tick(CONTROL_NOW)) + component.transport.statuses[101] = _status(status_id=178, power=0) + _load_chargers(component) + + if release == "read_only": + component.base.args["set_read_only"] = True + else: + run_async(component.switch_event_handler("switch.predbat_wallbox_control", "turn_off")) + assert component.control_enabled is False + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("resume", 101), ("resume_schedule", 101)], (release, component.transport.calls) + assert component.paused_by_predbat == set() + + component.transport.calls = [] + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [], "Release happens once, and control stays quiet while released" + print(" ✓ Read only mode and the control switch release the chargers") + + +def test_control_does_not_release_a_charger_it_did_not_pause(): + """A charger the user paused by hand is not resumed when Predbat releases.""" + component = _control_component({0: PLAN_OUTSIDE}, {101: _status(status_id=178, power=0)}) + component.base.args["set_read_only"] = True + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [] + print(" ✓ A charger Predbat did not pause is not resumed on release") + + +def test_control_state_survives_a_restart(): + """After a restart with control turned off, the charger Predbat paused is still released.""" + storage = _Storage() + component = _control_component({0: PLAN_OUTSIDE}, storage=storage) + run_async(component.control_tick(CONTROL_NOW)) + assert storage.data[("wallbox", "control_state")] == {"control_enabled": True, "paused": ["101"]} + + restarted = _control_component({0: PLAN_OUTSIDE}, {101: _status(status_id=178, power=0)}, storage=storage, wallbox_control=False) + assert restarted.control_active is False and restarted.paused_by_predbat == {"101"} + run_async(restarted.control_tick(CONTROL_NOW)) + assert restarted.transport.calls == [("resume", 101), ("resume_schedule", 101)] + assert storage.data[("wallbox", "control_state")]["paused"] == [] + print(" ✓ A restart with control off still releases the charger") + + +def test_control_switch_state_survives_a_restart(): + """The control switch stays off across a restart and is published in that state from the start.""" + storage = _Storage() + component = _control_component({0: PLAN_OUTSIDE}, storage=storage) + run_async(component.switch_event_handler("switch.predbat_wallbox_control", "turn_off")) + + restarted = _make_component(wallbox_control=True) + restarted.base.components = _Components(storage) + run_async(restarted.run(0, True)) + assert restarted.control_enabled is False + assert restarted.base.entities["switch.predbat_wallbox_control"]["state"] == "off" + print(" ✓ The control switch state survives a restart") + + +def test_control_switch_is_published_only_when_available(): + """The control switch appears only when control can actually run.""" + on = _make_component(wallbox_control=True) + run_async(on.run(0, True)) + assert on.base.entities["switch.predbat_wallbox_control"]["state"] == "on" + off = _make_component(wallbox_control=False) + run_async(off.run(0, True)) + assert "switch.predbat_wallbox_control" not in off.base.entities + print(" ✓ The control switch is published only when control is available") + + +def test_control_without_storage_still_works(): + """With no Storage component, control runs and nothing is raised.""" + component = _control_component({0: PLAN_OUTSIDE}) + assert component.storage is None + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("pause", 101)] + print(" ✓ Control works without the Storage component") + + +def test_control_failure_does_not_fail_the_cycle(): + """A refused pause during a poll is a warning; monitoring still succeeds and it is retried next poll.""" + component = _make_component(wallbox_control=True) + component.local_tz = CONTROL_TZ + component.base.local_tz = CONTROL_TZ + component.base.set_state_wrapper("binary_sensor.predbat_car_charging_slot", "off", {"planned": []}) + component.transport.errors[("pause", 101)] = WallboxApiError("HTTP 500") + assert run_async(component.run(0, True)) is True + assert _logged(component, "charge control failed") and component.paused_by_predbat == set() + run_async(component.run(120, False)) + assert component.paused_by_predbat == {"101"} + print(" ✓ A refused control is a warning and is retried") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -868,5 +1123,21 @@ def test_wallbox(my_predbat=None): test_automatic_config_keeps_user_values() test_automatic_config_disabled() test_automatic_config_runs_once() + test_control_needs_automatic() + test_control_resumes_inside_a_window() + test_control_pauses_outside_a_window() + test_control_leaves_other_states_alone() + test_control_warns_once_about_a_locked_charger() + test_control_is_per_car() + test_control_skips_a_charger_with_no_plan() + test_control_skips_a_stale_charger() + test_control_forgets_an_unplugged_charger() + test_control_releases_on_read_only_and_switch_off() + test_control_does_not_release_a_charger_it_did_not_pause() + test_control_state_survives_a_restart() + test_control_switch_state_survives_a_restart() + test_control_switch_is_published_only_when_available() + test_control_without_storage_still_works() + test_control_failure_does_not_fail_the_cycle() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index 7bbcb51ea..8c4d41831 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -32,6 +32,7 @@ from component_base import ComponentBase from predbat_metrics import record_api_call +from utils import parse_car_plan_windows, in_car_plan_window WALLBOX_AUTH_URL = "https://user-api.wall-box.com/" WALLBOX_API_URL = "https://api.wall-box.com/" @@ -68,6 +69,9 @@ # Used as the upper bound only when the charger does not report one DEFAULT_MAX_CHARGING_CURRENT = 32 +WALLBOX_STORAGE_MODULE = "wallbox" +WALLBOX_CONTROL_STATE = "control_state" + # Status id to (text, car connected). The text is the Home Assistant wallbox integration's # wording, so states match what existing users already see. A None status id means the # charger reported nothing, which Wallbox treats as disconnected. @@ -422,6 +426,10 @@ def initialize(self, username, password, automatic=True, wallbox_control=False, self.permission_warned = False self._auto_configured = False self.control_active = False + self.paused_by_predbat = set() + self.control_windows = {} + self.lock_warned = set() + self.control_state_loaded = False self.control_enabled = True self.transport = None @@ -444,12 +452,6 @@ def warn_permission(self): self.log("Warn: wallbox: Wallbox refused a control - the account needs admin rights over the charger. Monitoring continues") self.permission_warned = True - async def load_control_state(self): - """Restore saved control state. Filled in with plan-led control.""" - - def enable_control(self): - """Decide whether plan-led control can run. Filled in with plan-led control.""" - def automatic_config(self): """Wire the charger entities into Predbat's car charging inputs. @@ -478,9 +480,6 @@ def automatic_config(self): self.log("Info: wallbox: setting num_cars to {}".format(len(chargers))) self.set_arg("num_cars", len(chargers)) - async def control_tick(self, now): - """Run one cycle of plan-led control. Filled in with plan-led control.""" - async def run(self, seconds, first): """Process queued control events, then poll and publish.""" if not self.transport: @@ -628,6 +627,11 @@ async def switch_event_handler(self, entity_id, service): """Pause/resume or lock/unlock in response to a charger switch.""" if service not in ("turn_on", "turn_off"): return + if entity_id.endswith("_wallbox_control"): + self.control_enabled = service == "turn_on" + self.log("Info: wallbox: charge control switched {}".format("on" if self.control_enabled else "off")) + await self.save_control_state() + return charger = self.charger_for_entity(entity_id) if not charger: return @@ -670,6 +674,151 @@ async def select_event_handler(self, entity_id, value): await self.transport.set_eco_smart(self.api_id(charger), value) + def enable_control(self): + """Decide whether Predbat-led charging can run, and say why when it cannot. + + Control needs automatic configuration because a charger is driven from its own + car's plan, and it is automatic configuration that makes charger N car N. + """ + self.control_active = False + if not self.wallbox_control: + return + if not self.automatic: + self.log("Warn: wallbox: wallbox_control needs wallbox_automatic to map each charger to a car, charge control is disabled") + return + self.control_active = True + self.log("Info: wallbox: Predbat-led charge control enabled") + + async def load_control_state(self): + """Restore the control switch and the list of chargers Predbat paused. + + The paused list is what lets a restart with control turned off still release a + charger an earlier session left paused. Fails soft with no Storage component. + """ + if self.control_state_loaded or self.storage is None: + return + try: + saved = await self.storage.load(WALLBOX_STORAGE_MODULE, WALLBOX_CONTROL_STATE) + except Exception as exc: + self.log("Warn: wallbox: could not read the saved charge control state: {}".format(exc)) + return + self.control_state_loaded = True + if not isinstance(saved, dict): + return + if "control_enabled" in saved: + self.control_enabled = bool(saved["control_enabled"]) + paused = saved.get("paused") + if isinstance(paused, list): + self.paused_by_predbat = {str(charger_id) for charger_id in paused} + + async def save_control_state(self): + """Persist the control switch and the list of chargers Predbat paused.""" + if self.storage is None: + return + try: + await self.storage.save(WALLBOX_STORAGE_MODULE, WALLBOX_CONTROL_STATE, {"control_enabled": self.control_enabled, "paused": sorted(self.paused_by_predbat)}) + except Exception as exc: + self.log("Warn: wallbox: could not save the charge control state: {}".format(exc)) + + def control_read_only_now(self): + """Is Predbat in read only mode - the live attribute first, then the config argument.""" + read_only = getattr(self.base, "set_read_only", None) + if read_only is None: + read_only = self.get_arg("set_read_only", False) + return bool(read_only) + + def refresh_car_windows(self, now): + """Read each car's planned charging windows into control_windows. + + A car with no slot sensor yet is simply absent, so its charger is left alone. + Returns True once at least one car's plan has been read. + """ + windows = {} + for car_n in range(len(self.chargers)): + postfix = "" if car_n == 0 else "_{}".format(car_n) + planned = self.get_state_wrapper("binary_sensor.{}_car_charging_slot{}".format(self.prefix, postfix), attribute="planned") + if planned is None: + continue + windows[car_n] = parse_car_plan_windows(planned, now, self.local_tz) + self.control_windows = windows + return bool(windows) + + async def control_tick(self, now): + """Run one cycle of charge control, releasing rather than just going quiet. + + Called on every poll whether or not control is available, because a charger a + previous session paused must be released even when control has since been turned off. + """ + reason = None + if not self.control_active: + reason = "charge control is not enabled" + elif self.control_read_only_now(): + reason = "Predbat is in read only mode" + elif not self.control_enabled: + reason = "the charge control switch is off" + if reason: + if self.paused_by_predbat: + self.log("Info: wallbox: releasing the chargers because {}".format(reason)) + await self.release_chargers() + return + await self.control_charge(now) + + async def release_chargers(self): + """Resume every charger Predbat paused and hand each back to its own schedule. + + A charger that is no longer paused - resumed by hand, or unplugged - is only + forgotten. The record is saved even if a call fails part way, so what was + released stays released and the rest is retried on the next poll. + """ + try: + for charger_id in sorted(self.paused_by_predbat): + charger = self.chargers.get(charger_id) + if charger and charger_id in self.stale_ids: + continue + if charger and charger.paused: + self.log("Info: wallbox: releasing {}".format(charger.name)) + await self.transport.resume(self.api_id(charger)) + await self.transport.resume_schedule(self.api_id(charger)) + self.paused_by_predbat.discard(charger_id) + finally: + await self.save_control_state() + + async def control_charge(self, now): + """Drive each charger from its car's plan: resume a paused one inside a window, pause a charging one outside. + + Nothing else is sent. A Scheduled charger is following its own schedule and a + Waiting one is waiting on the car, and resume does nothing for either. + """ + if not self.refresh_car_windows(now): + return + before = set(self.paused_by_predbat) + try: + for car_n, charger in enumerate(self.ordered_chargers()): + if car_n not in self.control_windows or charger.charger_id in self.stale_ids: + continue + wanted = in_car_plan_window(self.control_windows[car_n], now) + if not charger.connected: + self.paused_by_predbat.discard(charger.charger_id) + continue + if wanted and charger.locked: + if charger.charger_id not in self.lock_warned: + self.log("Warn: wallbox: {} is locked, so it will not charge in its planned window. Predbat does not unlock chargers".format(charger.name)) + self.lock_warned.add(charger.charger_id) + continue + self.lock_warned.discard(charger.charger_id) + if wanted and charger.paused: + self.log("Info: wallbox: resuming {} for car {}".format(charger.name, car_n)) + await self.transport.resume(self.api_id(charger)) + self.paused_by_predbat.discard(charger.charger_id) + elif not wanted and charger.charging: + self.log("Info: wallbox: pausing {} for car {}".format(charger.name, car_n)) + await self.transport.pause(self.api_id(charger)) + self.paused_by_predbat.add(charger.charger_id) + finally: + if self.paused_by_predbat != before: + await self.save_control_state() + + async def run_wallbox_cli(args): # pragma: no cover """Sign in, list the chargers, print each one's status and optionally send one control.""" From 4bb0a79885585408bc931c99cd5a6b281d39b6fe Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 11:04:20 +0100 Subject: [PATCH 10/24] docs(wallbox): document the Wallbox component Also applies the formatter and spell checker fixes to wallbox.py and its tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- .cspell/custom-dictionary-workspace.txt | 1 + .github/copilot-instructions.md | 3 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- apps/predbat/config/apps.yaml | 9 ++++ apps/predbat/tests/test_wallbox.py | 7 +-- apps/predbat/wallbox.py | 2 - docs/apps-yaml.md | 7 +++ docs/car-charging.md | 23 ++++++++-- docs/components.md | 60 ++++++++++++++++++++++++- docs/devices.md | 2 + 11 files changed, 104 insertions(+), 14 deletions(-) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index f776394da..9dce572c6 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -170,6 +170,7 @@ dtype dwindow eauth echargingpile +ecosmart Eddi eddis efgh diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fd751f981..b4b5d1987 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -136,7 +136,7 @@ class MyComponent(ComponentBase): await asyncio.sleep(interval) ``` -**Key components**: `WebInterface`, `PredbatMCPServer`, `SolarAPI`, `AlertFeed`, `GECloudDirect`, `CarbonAPI`, `FoxAPI`, `DatabaseManager`, `OctopusAPI`, `OhmeAPI`, `HAHistory`, `HAInterface` +**Key components**: `WebInterface`, `PredbatMCPServer`, `SolarAPI`, `AlertFeed`, `GECloudDirect`, `CarbonAPI`, `FoxAPI`, `DatabaseManager`, `OctopusAPI`, `OhmeAPI`, `WallboxAPI`, `HAHistory`, `HAInterface` Components are managed via `Components` class (`apps/predbat/components.py`) and initialized via plugin system (`apps/predbat/plugin_system.py`). @@ -326,6 +326,7 @@ Web interface in `apps/predbat/web.py` runs on port 5052 (configurable via `web_ - `gecloud.py` - GivEnergy cloud API - `fox.py` - Fox ESS cloud API - `ohme.py` - Ohme EV charger integration +- `wallbox.py` - Wallbox EV charger integration - `carbon.py` - Carbon intensity API ## Common Gotchas diff --git a/AGENTS.md b/AGENTS.md index c4a9a1ec1..e8e035a3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ The main loop (`update_pred()`) runs every 5 minutes: fetch data → run optimiz ### Component/Plugin System -`components.py` defines a registry of 18 pluggable components (DB, HA, Web, MCP, GECloud, Octopus, Fox, Solax, Solis, Axle, Ohme, Kraken, etc.). Each component: +`components.py` defines a registry of 31 pluggable components (DB, HA, Web, MCP, GECloud, Octopus, Fox, Solax, Solis, Axle, Ohme, Wallbox, Kraken, etc.). Each component: - Inherits from `ComponentBase` - Has `api_start()` / `api_stop()` lifecycle methods diff --git a/CLAUDE.md b/CLAUDE.md index 394f72eed..bd6ca4a7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,7 +110,7 @@ The main loop (`update_pred()`) runs every 5 minutes: fetch data → run optimiz ### Component/Plugin System -`components.py` defines a registry of 18 pluggable components (DB, HA, Web, MCP, GECloud, Octopus, Fox, Solax, Solis, Axle, Ohme, Kraken, etc.). Each component: +`components.py` defines a registry of 31 pluggable components (DB, HA, Web, MCP, GECloud, Octopus, Fox, Solax, Solis, Axle, Ohme, Wallbox, Kraken, etc.). Each component: - Inherits from `ComponentBase` - Has `api_start()` / `api_stop()` lifecycle methods diff --git a/apps/predbat/config/apps.yaml b/apps/predbat/config/apps.yaml index ae8c8aa5b..333db4f7b 100644 --- a/apps/predbat/config/apps.yaml +++ b/apps/predbat/config/apps.yaml @@ -319,6 +319,15 @@ pred_bat: # it explicitly to True/False to override that decision #ohme_automatic_octopus_intelligent: True + # Wallbox EV charger cloud direct integration + #wallbox_username: !secret wallbox_username + #wallbox_password: !secret wallbox_password + # Register the Wallbox chargers with Predbat as cars (on by default) + #wallbox_automatic: True + # Let Predbat pause and resume the chargers according to its own car charging plan. + # Requires wallbox_automatic + #wallbox_control: True + # car_charging_energy defines a daily incrementing sensor which measures the charge added to your car and resets to zero at midnight # is used for car_charging_hold feature to filter out car charging from the previous load data # Automatically set to detect Wallbox and Zappi, if it doesn't match manually enter your sensor name diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index ff7a4ce8b..fd2c8ae03 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -21,17 +21,14 @@ from mock_base import MockBase from wallbox import ( - CHARGING_STATUS_IDS, MAX_POLL_SECONDS, MIN_POLL_SECONDS, - PAUSED_STATUS_IDS, WALLBOX_API_URL, WALLBOX_AUTH_URL, WALLBOX_STATUS, WallboxAPI, WallboxApiError, WallboxAuthError, - WallboxCharger, WallboxPermissionError, WallboxRateLimitError, WallboxTransport, @@ -115,9 +112,9 @@ def _auth_payload(token="jwt-1", refresh="refresh-1", life=3600, refresh_life=86 def test_basic_auth_header(): """The Basic header is UTF-8, so a colon or accented character in the password survives.""" - assert basic_auth_header("user@example.com", "secret") == "Basic dXNlckBleGFtcGxlLmNvbTpzZWNyZXQ=" + assert basic_auth_header("user@example.com", "secret") == "Basic dXNlckBleGFtcGxlLmNvbTpzZWNyZXQ=" # cspell:disable-line # base64 of "a:pä:ss" encoded as UTF-8 - assert basic_auth_header("a", "pä:ss") == "Basic YTpww6Q6c3M=" + assert basic_auth_header("a", "pä:ss") == "Basic YTpww6Q6c3M=" # cspell:disable-line print(" ✓ Basic auth header is built as UTF-8") diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index 8c4d41831..da7c6a8a0 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -673,7 +673,6 @@ async def select_event_handler(self, entity_id, value): self.log("Info: wallbox: setting {} Eco-Smart to {}".format(charger.name, value)) await self.transport.set_eco_smart(self.api_id(charger), value) - def enable_control(self): """Decide whether Predbat-led charging can run, and say why when it cannot. @@ -819,7 +818,6 @@ async def control_charge(self, now): await self.save_control_state() - async def run_wallbox_cli(args): # pragma: no cover """Sign in, list the chargers, print each one's status and optionally send one control.""" transport = WallboxTransport(print, args.username, args.password) diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index 8aa028f8f..c87acc0cf 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -238,6 +238,8 @@ pred_bat: teslemetry_key: !secret teslemetry_key # Teslemetry token (if using Teslemetry) ohme_login: !secret ohme_login # Ohme account e-mail (if using Ohme direct) ohme_password: !secret ohme_password # Ohme account password (if using Ohme direct) + wallbox_username: !secret wallbox_username # Wallbox account e-mail (if using Wallbox direct) + wallbox_password: !secret wallbox_password # Wallbox account password (if using Wallbox direct) ``` If a credential-like value (matching a key name containing `_key`, `password`, `secret` or `token`) is found written directly in `apps.yaml` instead of via `!secret`, Predbat logs a warning and lists the affected item(s) on the [web interface](web-interface.md) apps.yaml page. This is a warning rather than a validation error - the configuration still works, but moving the value into `secrets.yaml` keeps it out of `apps.yaml`, which is more likely to end up shared, backed up or attached to a bug report. @@ -2110,6 +2112,11 @@ whether you are within an Octopus Energy "smart charge" slot - **ohme_login** - Ohme EV charger account login - **ohme_password** - Password for above Ohme account - **ohme_automatic_octopus_intelligent** - Controls whether Predbat talks directly to the above Ohme account +- **wallbox_username** - Wallbox EV charger account email address +- **wallbox_password** - Password for the above Wallbox account +- **wallbox_automatic** - Register the Wallbox chargers with Predbat as cars (default `True`) +- **wallbox_control** - Let Predbat pause and resume the Wallbox chargers from its own car charging plan (default `False`) +- **wallbox_poll_seconds** - How often to poll the Wallbox cloud in seconds (default `120`) ## myenergi Integration diff --git a/docs/car-charging.md b/docs/car-charging.md index 8cabe4852..30e836de4 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -105,7 +105,7 @@ Like car_charging_energy it can be a list of sensors, one per line per car charg If you have multiple cars sharing one charger, then only include a single entry for the charger. If your car charger has no live power sensor, leave **car_charging_power** commented out in `apps.yaml`; the power flow diagram then shows the same four items it always has, and no **predbat.car_charging_power** sensor is published.
-If you use one of the supported charger integrations (Ohme, myenergi Zappi, GivEnergy EV charger, AlphaESS EV charger or the Predbat gateway) then this is configured automatically and you do not need an `apps.yaml` entry of your own. +If you use one of the supported charger integrations (Ohme, myenergi Zappi, Wallbox, GivEnergy EV charger, AlphaESS EV charger or the Predbat gateway) then this is configured automatically and you do not need an `apps.yaml` entry of your own. If you do not have a suitable car charging energy kWh sensor in Home Assistant then comment the **car_charging_energy** line out of `apps.yaml` and configure **input_number.predbat_car_charging_threshold** @@ -208,7 +208,7 @@ So it does not turn on **binary_sensor.predbat_car_charging_slot**, and an autom With Octopus Intelligent charging a charge outside a dispatch does not get the cheap rate either.
A car charging outside any charging slot is also modelled in the plan at **input_number.predbat_car_charging_rate** until the end of the current plan slot (**plan_interval_minutes**, 30 minutes by default), so no export is planned over it. With **switch.predbat_metric_dynamic_load_adjust** On its load is also taken out of the recent-load estimate, so it is not counted twice - see [Dynamic Load Adjust](customisation.md#scaling-and-weight-options).
Leave it commented out if you have no sensor that reports the car actually drawing power. -The Ohme (`ohme_automatic`), myenergi Zappi (`myenergi_automatic`) and Predbat gateway integrations set it for you, unless you have set it yourself in `apps.yaml`. +The Ohme (`ohme_automatic`), myenergi Zappi (`myenergi_automatic`), Wallbox (`wallbox_automatic`) and Predbat gateway integrations set it for you, unless you have set it yourself in `apps.yaml`. - **car_charging_now_response** - Set to the range of positive responses for car_charging_now to indicate that the car is charging. Useful if you have a sensor for your car charger that isn't binary. The sensor's state must match one of these exactly (ignoring case). If unset it defaults to `yes`, `on`, `enable`, `true` and `charging`, which covers on/off sensors and chargers whose status reads `Charging`, such as a myenergi Zappi's plug status. @@ -420,6 +420,23 @@ When **ohme_automatic** is set to `true`, Predbat points [car_charging_energy](a [car charging hold](#filtering-car-charging-energy-from-house-load) can subtract your car charging precisely rather than falling back to the `car_charging_threshold` heuristic. If you already have another charger's energy sensor configured - a Zappi or Wallbox, say - Predbat leaves your setting alone and logs that it has done so. +## Wallbox car charger direct integration + +Predbat can talk directly to your Wallbox charger by configuring your Wallbox account details in `apps.yaml`: + +```yaml + wallbox_username: !secret wallbox_username + wallbox_password: !secret wallbox_password + # Let Predbat pause and resume the charger from its own car charging plan + #wallbox_control: True +``` + +Predbat then registers each charger on the account as a car and sets **car_charging_energy**, **car_charging_planned**, **car_charging_power** and **car_charging_now** for you. A Wallbox charger cannot report the car's state of charge, so set **car_charging_soc** from your car's own integration if you want Predbat to plan to a target. + +See [Wallbox Charger](components.md#wallbox-charger-wallbox) for the entities it publishes and how Predbat-led charging behaves. + +If you would rather use the Home Assistant Wallbox integration, see [Wallbox Pulsar](devices.md#wallbox-pulsar). + ## GivEnergy Gateway OCPP EV charger When Predbat is connected to a GivEnergy Gateway that has an OCPP EV charger attached, the charger's live state is reported to Predbat over the gateway's MQTT telemetry and exposed as Home Assistant entities. @@ -518,7 +535,7 @@ It only applies to Octopus Intelligent charging (**switch.predbat_octopus_intell **Setting it up** Point **car_charging_now** in `apps.yaml` at a sensor that shows the car charging. -If you use `ohme_automatic`, `myenergi_automatic` or the Predbat gateway this is already done for you. +If you use `ohme_automatic`, `myenergi_automatic`, `wallbox_automatic` or the Predbat gateway this is already done for you. It can be an on/off sensor (matched against **car_charging_now_response**), or a charging power sensor in W or kW, where 200W or more counts as charging - useful for chargers such as Wallbox that have no "charging" sensor: ```yaml diff --git a/docs/components.md b/docs/components.md index 89d2ca3b6..a0713a60f 100644 --- a/docs/components.md +++ b/docs/components.md @@ -18,6 +18,7 @@ This document provides a comprehensive overview of all Predbat components, their - [Axle Energy VPP (axle)](#axle-energy-vpp-axle) - [Ohme Charger (ohme)](#ohme-charger-ohme) - [myenergi (myenergi)](#myenergi-myenergi) + - [Wallbox Charger (wallbox)](#wallbox-charger-wallbox) - [Fox ESS API (fox)](#fox-ess-api-fox) - [Tesla Powerwall Teslemetry API (teslemetry)](#tesla-powerwall-teslemetry-api-teslemetry) - [Enphase API (enphase)](#enphase-api-enphase) @@ -274,7 +275,7 @@ with `xxx`, so your API keys are not sent to your AI provider; pass `masked: fal deliberately want the raw values. A value counts as a credential if its name contains `_key`, `password`, `secret` or `token`, **or** if the component registry flags it explicitly - which covers the credentials a name alone cannot reveal, such as your Octopus account number, a Kraken -MPAN, a site or plant id, and login identifiers like `deye_username`, `kraken_email` and +MPAN, a site or plant id, and login identifiers like `deye_username`, `kraken_email`, `wallbox_username` and `myenergi_hub_serial` (the myenergi API's digest-auth username). Inverter serial numbers are deliberately *not* redacted: they identify hardware rather than authenticate it, and they are what makes an integration bug report diagnosable. `get_state` applies the same rule *and* the debug yaml's exclusion list, so it can never @@ -1047,6 +1048,63 @@ Note `--stop-charge` leaves the Zappi stopped, so remember to `--release` it aft --- +### Wallbox Charger (wallbox) + +Talks directly to the Wallbox cloud, so it works with or without Home Assistant. It monitors every charger on your Wallbox account, can register them with Predbat as cars, and can pause and resume them from Predbat's car charging plan. + +#### What it publishes (wallbox) + +For each charger, with `` being the Wallbox charger id: + +| Entity | Description | +| ------ | ----------- | +| `sensor.predbat_wallbox__status` | Charger status, e.g. `Charging`, `Paused`, `Waiting for car demand`, `Ready` | +| `sensor.predbat_wallbox__power` | Charging power in W | +| `sensor.predbat_wallbox__session_energy` | Energy added in the current session in kWh; resets with each session | +| `binary_sensor.predbat_wallbox__connected` | On while a car is plugged in | +| `binary_sensor.predbat_wallbox__charging` | On while the car is charging | +| `switch.predbat_wallbox__charging` | Turn off to pause charging, on to resume a paused session | +| `switch.predbat_wallbox__locked` | Lock or unlock the charger | +| `number.predbat_wallbox__max_charging_current` | Maximum charging current in A | +| `select.predbat_wallbox__eco_smart` | Eco-Smart mode: `off`, `eco_mode` or `full_solar`. Only on chargers with Eco-Smart | + +Wallbox chargers cannot report the car's state of charge, so there is no battery sensor. + +The controls need a Wallbox account with admin rights over the charger. Without them monitoring still works and Predbat logs a warning the first time a control is refused. + +#### Automatic configuration (wallbox) + +With `wallbox_automatic` on (the default), Predbat sets **car_charging_energy**, **car_charging_planned**, **car_charging_power** and **car_charging_now** to the Wallbox entities, and raises **num_cars** to the number of chargers. Chargers are taken in order of their id, so the first charger is car 0. A **car_charging_now** you have set yourself is kept. + +Predbat does not set **car_charging_soc**. If you want Predbat to plan to a target charge level, set it from your car's own integration. + +#### Predbat-led charging (wallbox) + +Set `wallbox_control: True` to let Predbat pause and resume each charger from its own car charging plan. It needs `wallbox_automatic`. While it is active Predbat publishes `switch.predbat_wallbox_control`, which you can turn off to hand the chargers back at any time; read only mode does the same. + +- Inside a planned car charging slot a paused charger is resumed. +- Outside a slot a charging charger is paused. +- When control is released, any charger Predbat paused is resumed and returned to its own Wallbox schedule. + +Things to know: + +- Wallbox can only pause a charger once it is charging, so a car plugged in outside a slot draws power until Predbat's next poll (two minutes by default). +- Clear any schedule set in the Wallbox app. A charger that Wallbox is holding in `Scheduled` will not charge in a Predbat slot. +- Predbat never unlocks a locked charger. It logs a warning if a locked charger misses its slot. +- Some cars go to sleep when charging is paused and do not wake when it is resumed. If yours does, Predbat-led charging will not suit it. + +#### Configuration Options (wallbox) + +| Option | Type | Required | Default | Config Key | Description | +| ------ | ---- | -------- | ------- | ---------- | ----------- | +| `username` | String | Yes | - | `wallbox_username` | Your Wallbox account email address | +| `password` | String | Yes | - | `wallbox_password` | Your Wallbox account password | +| `automatic` | Boolean | No | `True` | `wallbox_automatic` | Register the chargers with Predbat as cars | +| `wallbox_control` | Boolean | No | `False` | `wallbox_control` | Let Predbat pause and resume the chargers from its own plan. Requires `wallbox_automatic`; released by read only mode | +| `poll_seconds` | Integer | No | `120` | `wallbox_poll_seconds` | How often to poll Wallbox, 60 to 1800, in steps of 60 | + +--- + ### Fox ESS API (fox) **Can be restarted:** Yes diff --git a/docs/devices.md b/docs/devices.md index d9a6ca7d5..eb922954a 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -268,6 +268,8 @@ Example sensor name for BZ4X - `sensor.toyota_bz4x_battery_level` ## Wallbox Pulsar +Predbat can talk to Wallbox directly, with no Home Assistant integration needed: see [Wallbox Charger](components.md#wallbox-charger-wallbox). The configuration below is for the Home Assistant Wallbox integration instead. + Can be used both for the Car Charging Hold feature (to filter out previous car charging) and to determine if the car is plugged in From 30c1175c9c5415fdfc6fbb82674f1ba69a2cb6ca Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 11:11:32 +0100 Subject: [PATCH 11/24] fix(wallbox): stable charger-to-car order and a paused record that cannot be lost From the whole-branch review: - car order comes from the account's charger list, numeric and append-only, so a failed status read or a charger added later cannot shift which charger is which car - an unexpected error in a queued control no longer wedges the event queue - a pause is recorded before it is sent, and the record survives Updating, Error and Unknown states; only a definite unplug clears it - a control state read that fails at start-up is retried Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 125 +++++++++++++++++- apps/predbat/wallbox.py | 85 ++++++++---- docs/components.md | 3 +- .../2026-10-05-wallbox-component-design.md | 6 +- 4 files changed, 188 insertions(+), 31 deletions(-) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index fd2c8ae03..5926774f9 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -884,7 +884,8 @@ def _control_component(plans, statuses=None, storage=None, **overrides): def _load_chargers(component): """Load the stub transport's current statuses into the component, as a poll would.""" - component.charger_ids = sorted(component.transport.statuses, key=str) + component.charger_ids = list(component.transport.statuses) + component.update_car_order(component.charger_ids) component.chargers = {str(charger_id): normalise_charger(charger_id, payload) for charger_id, payload in component.transport.statuses.items()} component.stale_ids = set() component.transport.calls = [] @@ -1067,12 +1068,123 @@ def test_control_failure_does_not_fail_the_cycle(): component.base.set_state_wrapper("binary_sensor.predbat_car_charging_slot", "off", {"planned": []}) component.transport.errors[("pause", 101)] = WallboxApiError("HTTP 500") assert run_async(component.run(0, True)) is True - assert _logged(component, "charge control failed") and component.paused_by_predbat == set() + # The pause is recorded before it is sent, in case it was applied and only the reply was lost + assert _logged(component, "charge control failed") and component.paused_by_predbat == {"101"} + assert component.transport.count("pause") == 1 run_async(component.run(120, False)) - assert component.paused_by_predbat == {"101"} + assert component.transport.count("pause") == 2 and component.paused_by_predbat == {"101"} print(" ✓ A refused control is a warning and is retried") +def test_car_order_is_numeric_and_survives_a_first_poll_failure(): + """Charger N is car N by numeric id, even when a charger could not be read on the first poll.""" + component = _make_component({12345: _status(), 9999: _status()}) + component.transport.errors[("get_status", 9999)] = WallboxApiError("HTTP 500") + assert run_async(component.run(0, True)) is True + assert component.car_order == ["9999", "12345"], component.car_order + assert component.base.args["car_charging_energy"] == ["sensor.predbat_wallbox_9999_session_energy", "sensor.predbat_wallbox_12345_session_energy"] + assert component.base.args["num_cars"] == 2 + print(" ✓ Car order is numeric and survives a first poll failure") + + +def test_car_order_appends_a_charger_added_later(): + """A charger added to the account later becomes the next car; existing cars keep their number.""" + component = _make_component({9999: _status()}) + run_async(component.run(0, True)) + component.transport.statuses[123] = _status() + run_async(component.run(1800, False)) + assert component.car_order == ["9999", "123"], component.car_order + assert component.base.args["car_charging_energy"] == ["sensor.predbat_wallbox_9999_session_energy", "sensor.predbat_wallbox_123_session_energy"] + assert component.base.args["num_cars"] == 2 + print(" ✓ A charger added later becomes the next car") + + +def test_control_follows_the_car_order(): + """Control drives each charger from the car it was configured as, not from a re-sorted list.""" + component = _control_component({0: PLAN_OUTSIDE, 1: PLAN_INSIDE}, {12345: _status(), 9999: _status()}) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("pause", 9999)], component.transport.calls + print(" ✓ Control follows the configured car order") + + +def test_bad_event_does_not_wedge_the_queue(): + """An infinite number, or a handler that raises something unexpected, is dropped and polling goes on.""" + component = _started_component() + run_async(component.number_event("number.predbat_wallbox_101_max_charging_current", "inf")) + assert run_async(component.run(60, False)) is True + assert component.queued_events == [] and component.transport.count("set_max_charging_current") == 0 + + component.transport.errors[("pause", 101)] = RuntimeError("unexpected") + run_async(component.switch_event("switch.predbat_wallbox_101_charging", "turn_off")) + assert run_async(component.run(120, False)) is True + assert component.queued_events == [] and _logged(component, "control failed") + assert component.transport.count("get_status") >= 1, "Polling carried on after the bad event" + + infinite = normalise_charger(101, {"status_id": 193, "charging_power": float("inf"), "added_energy": float("nan")}) + assert infinite.power_w == 0.0 and infinite.session_energy_kwh == 0.0 + print(" ✓ A bad event is dropped and does not stop polling") + + +def test_control_records_a_pause_whose_response_was_lost(): + """A pause that fails after Wallbox applied it is still Predbat's to release.""" + component = _control_component({0: PLAN_OUTSIDE}) + component.transport.errors[("pause", 101)] = WallboxApiError("timed out") + try: + run_async(component.control_tick(CONTROL_NOW)) + raise AssertionError("Expected WallboxApiError") + except WallboxApiError: + pass + assert component.paused_by_predbat == {"101"} + + component.transport.statuses[101] = _status(status_id=178, power=0) + _load_chargers(component) + run_async(component.switch_event_handler("switch.predbat_wallbox_control", "turn_off")) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("resume", 101), ("resume_schedule", 101)], component.transport.calls + print(" ✓ A pause whose response was lost is still released") + + +def test_control_keeps_its_record_through_a_transient_status(): + """Updating, Error or Unknown between pause and release does not make Predbat forget the charger.""" + for transient in (166, 14, 999): + component = _control_component({0: PLAN_OUTSIDE}) + run_async(component.control_tick(CONTROL_NOW)) + component.transport.statuses[101] = _status(status_id=transient, power=0) + _load_chargers(component) + run_async(component.control_tick(CONTROL_NOW)) + assert component.paused_by_predbat == {"101"}, transient + + component.transport.statuses[101] = _status(status_id=178, power=0) + _load_chargers(component) + component.base.args["set_read_only"] = True + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("resume", 101), ("resume_schedule", 101)], (transient, component.transport.calls) + print(" ✓ A transient status does not lose the paused record") + + +def test_control_state_load_is_retried(): + """A storage read that fails on the first run is tried again, so the paused list is not lost.""" + storage = _Storage() + storage.data[("wallbox", "control_state")] = {"control_enabled": True, "paused": ["101"]} + real_load = storage.load + failures = [RuntimeError("storage not ready")] + + async def flaky_load(module, filename): + """Fail once, then behave.""" + if failures: + raise failures.pop() + return await real_load(module, filename) + + storage.load = flaky_load + component = _make_component({101: _status(status_id=178, power=0)}, wallbox_control=False) + component.base.components = _Components(storage) + run_async(component.run(0, True)) + assert component.paused_by_predbat == set() and component.transport.count("resume") == 0 + run_async(component.run(120, False)) + assert component.transport.count("resume") == 1, "The stored charger was released once the state could be read" + print(" ✓ A failed control state read is retried") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -1136,5 +1248,12 @@ def test_wallbox(my_predbat=None): test_control_switch_is_published_only_when_available() test_control_without_storage_still_works() test_control_failure_does_not_fail_the_cycle() + test_car_order_is_numeric_and_survives_a_first_poll_failure() + test_car_order_appends_a_charger_added_later() + test_control_follows_the_car_order() + test_bad_event_does_not_wedge_the_queue() + test_control_records_a_pause_whose_response_was_lost() + test_control_keeps_its_record_through_a_transient_status() + test_control_state_load_is_retried() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index da7c6a8a0..416280fe4 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -24,6 +24,7 @@ import asyncio import base64 import json +import math import time from dataclasses import dataclass from typing import Optional @@ -106,6 +107,8 @@ 210: ("Locked, car connected", True), } CHARGING_STATUS_IDS = (193, 194, 195) +# No car is plugged in: Disconnected, Ready, and Locked with no car +UNPLUGGED_STATUS_IDS = (0, 161, 162, 163, 165, 209) PAUSED_STATUS_IDS = (178, 182) ECO_SMART_OFF = "off" @@ -114,19 +117,27 @@ ECO_SMART_OPTIONS = [ECO_SMART_OFF, ECO_SMART_ECO, ECO_SMART_FULL_SOLAR] +def charger_sort_key(charger_id): + """Sort key putting numeric charger ids in numeric order, ahead of any that are not numbers.""" + text = str(charger_id) + return (0, int(text), "") if text.isdigit() else (1, 0, text) + + def _to_float(value, default=0.0): """Convert a value to float, returning the default when it is missing or not a number.""" try: - return float(value) - except (TypeError, ValueError): + result = float(value) + except (TypeError, ValueError, OverflowError): return default + # JSON may carry Infinity or NaN, which would otherwise be published as a reading + return result if math.isfinite(result) else default def _to_int(value, default=0): """Convert a value to int, returning the default when it is missing or not a number.""" try: return int(float(value)) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): return default @@ -424,7 +435,9 @@ def initialize(self, username, password, automatic=True, wallbox_control=False, self.skip_cycles = 0 self.backoff_cycles = 0 self.permission_warned = False - self._auto_configured = False + # Charger ids in car order: entry N is car N. Append-only, so a car never changes number while Predbat runs + self.car_order = [] + self._auto_configured_order = None self.control_active = False self.paused_by_predbat = set() self.control_windows = {} @@ -442,9 +455,21 @@ def entity_prefix(self, charger): """Return the entity name prefix for a charger, e.g. predbat_wallbox_12345.""" return "{}_wallbox_{}".format(self.prefix, charger.charger_id) + def update_car_order(self, listed_ids): + """Give every listed charger a car number, keeping the numbers already handed out. + + The order comes from the account's charger list rather than from the chargers that + happened to answer, so a status failure cannot shift it. New chargers are appended + in numeric id order; a charger added later becomes the next car and the existing + cars keep their numbers. + """ + for charger_id in sorted((str(listed_id) for listed_id in listed_ids), key=charger_sort_key): + if charger_id not in self.car_order: + self.car_order.append(charger_id) + def ordered_chargers(self): - """The chargers in id order. This order is what makes charger N the same thing as car N.""" - return [self.chargers[charger_id] for charger_id in sorted(self.chargers)] + """The chargers that have been read, in car order.""" + return [self.chargers[charger_id] for charger_id in self.car_order if charger_id in self.chargers] def warn_permission(self): """Say once that the account cannot control the charger.""" @@ -462,12 +487,12 @@ def automatic_config(self): already copes with. car_charging_soc is not set: a Type 2 connector cannot report the car's state of charge. """ - chargers = self.ordered_chargers() - if not chargers: + if not self.car_order: return - energy_entities = ["sensor.{}_session_energy".format(self.entity_prefix(charger)) for charger in chargers] - planned_entities = ["binary_sensor.{}_connected".format(self.entity_prefix(charger)) for charger in chargers] - power_entities = ["sensor.{}_power".format(self.entity_prefix(charger)) for charger in chargers] + prefixes = ["{}_wallbox_{}".format(self.prefix, charger_id) for charger_id in self.car_order] + energy_entities = ["sensor.{}_session_energy".format(prefix) for prefix in prefixes] + planned_entities = ["binary_sensor.{}_connected".format(prefix) for prefix in prefixes] + power_entities = ["sensor.{}_power".format(prefix) for prefix in prefixes] self.log("Info: wallbox: setting car_charging_energy to {}".format(energy_entities)) self.set_arg_auto("car_charging_energy", energy_entities) @@ -476,16 +501,17 @@ def automatic_config(self): self.log("Info: wallbox: setting car_charging_power and car_charging_now to {}".format(power_entities)) self.set_arg_auto("car_charging_power", power_entities) self.set_arg_auto("car_charging_now", power_entities, overwrite=False) - if _to_int(self.get_arg("num_cars", 0), 0) < len(chargers): - self.log("Info: wallbox: setting num_cars to {}".format(len(chargers))) - self.set_arg("num_cars", len(chargers)) + if _to_int(self.get_arg("num_cars", 0), 0) < len(self.car_order): + self.log("Info: wallbox: setting num_cars to {}".format(len(self.car_order))) + self.set_arg("num_cars", len(self.car_order)) async def run(self, seconds, first): """Process queued control events, then poll and publish.""" if not self.transport: return False + # Every cycle until it succeeds: a read that failed at start-up must not lose the paused list + await self.load_control_state() if first: - await self.load_control_state() self.enable_control() self.skip_cycles = 0 if self.skip_cycles > 0: @@ -522,7 +548,8 @@ async def process_events(self): raise except WallboxPermissionError: self.warn_permission() - except WallboxError as exc: + except Exception as exc: + # Anything else is dropped too: an event left on the queue would stop every later poll self.log("Warn: wallbox: control failed: {}".format(exc)) self.queued_events.pop(0) refresh = True @@ -531,7 +558,8 @@ async def process_events(self): async def poll(self, seconds, first): """Read every charger, publish, and run automatic configuration and control.""" if first or not self.charger_ids or (seconds % CHARGER_LIST_SECONDS) == 0: - self.charger_ids = sorted(await self.transport.list_chargers(), key=str) + self.charger_ids = sorted(await self.transport.list_chargers(), key=charger_sort_key) + self.update_car_order(self.charger_ids) if not self.charger_ids: if first: self.log("Warn: wallbox: signed in but no chargers were found on the account") @@ -556,9 +584,10 @@ async def poll(self, seconds, first): self.chargers = chargers self.stale_ids = stale_ids await self.publish_data() - if self.automatic and not self._auto_configured: + if self.automatic and self._auto_configured_order != self.car_order: + # Once per car order: at start-up, and again only when a charger is added self.automatic_config() - self._auto_configured = True + self._auto_configured_order = list(self.car_order) try: await self.control_tick(self.now_utc_exact) except WallboxPermissionError: @@ -655,7 +684,7 @@ async def number_event_handler(self, entity_id, value): return try: amps = int(float(value)) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): self.log("Warn: wallbox: ignoring charging current '{}' for {}".format(value, charger.name)) return upper = charger.max_available_current if charger.max_available_current >= MIN_CHARGING_CURRENT else DEFAULT_MAX_CHARGING_CURRENT @@ -733,7 +762,7 @@ def refresh_car_windows(self, now): Returns True once at least one car's plan has been read. """ windows = {} - for car_n in range(len(self.chargers)): + for car_n in range(len(self.car_order)): postfix = "" if car_n == 0 else "_{}".format(car_n) planned = self.get_state_wrapper("binary_sensor.{}_car_charging_slot{}".format(self.prefix, postfix), attribute="planned") if planned is None: @@ -792,13 +821,17 @@ async def control_charge(self, now): return before = set(self.paused_by_predbat) try: - for car_n, charger in enumerate(self.ordered_chargers()): - if car_n not in self.control_windows or charger.charger_id in self.stale_ids: + for car_n, charger_id in enumerate(self.car_order): + charger = self.chargers.get(charger_id) + if charger is None or car_n not in self.control_windows or charger_id in self.stale_ids: continue wanted = in_car_plan_window(self.control_windows[car_n], now) - if not charger.connected: + if charger.status_id in UNPLUGGED_STATUS_IDS: self.paused_by_predbat.discard(charger.charger_id) continue + if not charger.connected: + # Updating, Error or Unknown: say nothing, and keep the record until the charger reports again + continue if wanted and charger.locked: if charger.charger_id not in self.lock_warned: self.log("Warn: wallbox: {} is locked, so it will not charge in its planned window. Predbat does not unlock chargers".format(charger.name)) @@ -811,8 +844,10 @@ async def control_charge(self, now): self.paused_by_predbat.discard(charger.charger_id) elif not wanted and charger.charging: self.log("Info: wallbox: pausing {} for car {}".format(charger.name, car_n)) - await self.transport.pause(self.api_id(charger)) + # Recorded first: a pause whose reply is lost may still have been applied, + # and release ignores a recorded charger that turns out not to be paused self.paused_by_predbat.add(charger.charger_id) + await self.transport.pause(self.api_id(charger)) finally: if self.paused_by_predbat != before: await self.save_control_state() diff --git a/docs/components.md b/docs/components.md index a0713a60f..3aff444e4 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1074,7 +1074,7 @@ The controls need a Wallbox account with admin rights over the charger. Without #### Automatic configuration (wallbox) -With `wallbox_automatic` on (the default), Predbat sets **car_charging_energy**, **car_charging_planned**, **car_charging_power** and **car_charging_now** to the Wallbox entities, and raises **num_cars** to the number of chargers. Chargers are taken in order of their id, so the first charger is car 0. A **car_charging_now** you have set yourself is kept. +With `wallbox_automatic` on (the default), Predbat sets **car_charging_energy**, **car_charging_planned**, **car_charging_power** and **car_charging_now** to the Wallbox entities, and raises **num_cars** to the number of chargers. Chargers are taken in numeric order of their id, so the charger with the lowest id is car 0. A charger added to the account while Predbat is running becomes the next car. A **car_charging_now** you have set yourself is kept. Predbat does not set **car_charging_soc**. If you want Predbat to plan to a target charge level, set it from your car's own integration. @@ -1090,6 +1090,7 @@ Things to know: - Wallbox can only pause a charger once it is charging, so a car plugged in outside a slot draws power until Predbat's next poll (two minutes by default). - Clear any schedule set in the Wallbox app. A charger that Wallbox is holding in `Scheduled` will not charge in a Predbat slot. +- While Predbat-led charging is on, Predbat resumes a paused charger inside a planned slot even if you paused it yourself. Turn `switch.predbat_wallbox_control` off first if you want it to stay paused. - Predbat never unlocks a locked charger. It logs a warning if a locked charger misses its slot. - Some cars go to sleep when charging is paused and do not wake when it is resumed. If yours does, Predbat-led charging will not suit it. diff --git a/docs/superpowers/specs/2026-10-05-wallbox-component-design.md b/docs/superpowers/specs/2026-10-05-wallbox-component-design.md index 08a72d0f4..8158b3169 100644 --- a/docs/superpowers/specs/2026-10-05-wallbox-component-design.md +++ b/docs/superpowers/specs/2026-10-05-wallbox-component-design.md @@ -170,8 +170,10 @@ available (section 7). ## 6. Automatic configuration -Runs once, after the first successful poll, when `wallbox_automatic` is on. Chargers are -sorted by id, so charger N is car N. All through `set_arg_auto`: +Runs after the first successful poll when `wallbox_automatic` is on, and again only when a +charger is added to the account. Car order comes from the account's charger list in numeric +id order and is append-only while Predbat runs, so charger N is car N even when a status +read fails, and a charger added later becomes the next car. All through `set_arg_auto`: | Argument | Value | |---|---| From b7dd4c726667f04847195c942d18bf5e8584feb5 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 13:51:33 +0100 Subject: [PATCH 12/24] feat(wallbox): command line mode builds the component and runs one full cycle Matches the other components' harnesses: the run publishes the entities and shows the automatic configuration, then optionally sends one control. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/wallbox.py | 69 ++++++++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index 416280fe4..a9cbfa5b8 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -32,6 +32,7 @@ import aiohttp from component_base import ComponentBase +from mock_base import MockBase from predbat_metrics import record_api_call from utils import parse_car_plan_windows, in_car_plan_window @@ -853,22 +854,61 @@ async def control_charge(self, now): await self.save_control_state() +def print_charger_table(chargers): # pragma: no cover + """Print the charger summary table, for the poll and for a command read-back alike.""" + print("{:<12} {:<20} {:<34} {:>9} {:>12} {:>6} {:>7}".format("CHARGER", "NAME", "STATUS", "POWER W", "SESSION kWh", "MAX A", "LOCKED")) + for charger in chargers: + print("{:<12} {:<20} {:<34} {:>9.0f} {:>12.2f} {:>6} {:>7}".format(charger.charger_id, charger.name[:20], charger.status, charger.power_w, charger.session_energy_kwh, charger.max_charging_current, "yes" if charger.locked else "no")) + + async def run_wallbox_cli(args): # pragma: no cover - """Sign in, list the chargers, print each one's status and optionally send one control.""" - transport = WallboxTransport(print, args.username, args.password) - charger_ids = await transport.list_chargers() - if not charger_ids: + """Run one Wallbox component cycle, and optionally one control, against the live API.""" + mock_base = MockBase() + arg_dict = { + "username": args.username, + "password": args.password, + # On by default, as it is in apps.yaml, so a run shows the car_charging_energy, car_charging_planned, + # car_charging_power and car_charging_now wiring a real run would set up + "automatic": not args.no_automatic, + "wallbox_control": False, + } + component = WallboxAPI(mock_base, **arg_dict) + if not component.transport: + print("No usable credentials - pass --username and --password") + return + + # One whole component cycle rather than a bare status read: publishing and automatic + # configuration are part of run(), and the entities and arguments they produce are what + # this harness is used to check before wiring the component into apps.yaml + print("Running one poll cycle...") + if not await component.run(0, True): + print("The poll cycle failed - see the messages above") + return + chargers = component.ordered_chargers() + if not chargers: print("No chargers found on this account") return - print("Chargers: {}".format(charger_ids)) - for charger_id in charger_ids: - payload = await transport.get_status(charger_id) - if args.raw: - print(json.dumps(payload, indent=2, sort_keys=True, default=str)) - else: - print(normalise_charger(charger_id, payload)) - target = args.charger if args.charger is not None else charger_ids[0] + print("") + print("Car order: {}".format(component.car_order)) + print_charger_table(chargers) + + if args.raw: + for charger in chargers: + print("\nRaw status for charger {}:".format(charger.charger_id)) + print(json.dumps(await component.transport.get_status(component.api_id(charger)), indent=2, sort_keys=True, default=str)) + + if args.charger is not None: + charger = component.chargers.get(str(args.charger)) + if not charger: + print("\nCharger {} is not on this account".format(args.charger)) + return + else: + charger = chargers[0] + target = component.api_id(charger) + + # The same calls the component issues, so they can be tried by hand against a live charger + transport = component.transport action = None if args.pause: action = ("pause", transport.pause(target)) @@ -886,11 +926,11 @@ async def run_wallbox_cli(args): # pragma: no cover action = ("set Eco-Smart {}".format(args.eco_smart), transport.set_eco_smart(target, args.eco_smart)) if not action: return - print("\nSending {} to {}...".format(action[0], target)) + print("\nSending {} to {}...".format(action[0], charger.name)) await action[1] print("Waiting {}s for the charger to report the change...".format(COMMAND_SETTLE_SECONDS)) await asyncio.sleep(COMMAND_SETTLE_SECONDS) - print(normalise_charger(target, await transport.get_status(target))) + print_charger_table([normalise_charger(target, await transport.get_status(target))]) def main(): # pragma: no cover @@ -899,6 +939,7 @@ def main(): # pragma: no cover parser.add_argument("--username", required=True, help="Wallbox account email address") parser.add_argument("--password", required=True, help="Wallbox account password") parser.add_argument("--raw", action="store_true", help="Print each charger's full status payload as JSON") + parser.add_argument("--no-automatic", action="store_true", help="Skip the automatic configuration of car_charging_energy, car_charging_planned, car_charging_power and car_charging_now") parser.add_argument("--charger", default=None, help="Charger id to control; defaults to the first one") group = parser.add_mutually_exclusive_group() group.add_argument("--pause", action="store_true", help="Pause the charging session") From 32b7de3e661047e38fbc906dd5e6865497c78aa9 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 18:02:48 +0100 Subject: [PATCH 13/24] test(wallbox): add a real locked-charger status capture as a fixture Captured from a Pulsar Plus on firmware 6.7.43, with account identifiers replaced. The normaliser already read it correctly. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 124 +++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index 5926774f9..e9eeebac3 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -109,6 +109,115 @@ def _auth_payload(token="jwt-1", refresh="refresh-1", life=3600, refresh_life=86 MOCK_STATUS_READY = dict(MOCK_STATUS_CHARGING, charging_power=0, status_id=161, charging_speed=0, added_range=0, added_energy=0) MOCK_STATUS_PAUSED = dict(MOCK_STATUS_CHARGING, charging_power=0, status_id=178, charging_speed=0) +# A real status payload, captured on 5 Oct 2026 from a Pulsar Plus (firmware 6.7.43) that was +# locked with no car connected. Account identifiers and names are replaced; every field and its +# type is as Wallbox sent it. +# cspell:disable +MOCK_STATUS_LOCKED_CAPTURED = {'added_discharged_energy': 0, + 'added_energy': 0, + 'added_green_energy': 0, + 'added_grid_energy': 0, + 'added_range': 477, + 'car_id': 1, + 'car_plate': '', + 'charging_power': 0, + 'charging_speed': 0, + 'charging_time': 19717, + 'config_data': {'auto_lock': 0, + 'auto_lock_time': 60, + 'available': 1, + 'charger_has_image': 0, + 'charger_id': 101, + 'charger_load_type': 'Private', + 'connection_type': 1, + 'contract_charging_available': False, + 'country': {'code': 'GBR', 'id': 180, 'iso2': 'GB', 'name': 'REINO UNIDO', 'phone_code': '44'}, + 'currency': {'code': 'GBP', 'id': 48, 'name': 'United Kingdom Pound', 'symbol': '£'}, + 'dca_status': 0, + 'ecosmart': {'enabled': False, 'mode': 0, 'percentage': 100}, + 'energyCost': {'inheritedGroupId': None, 'value': 0.075}, + 'energy_price': 0.075, + 'gesture_status': 7, + 'grid_type': 0, + 'group_id': 1, + 'home_power_export_setpoint': None, + 'home_sharing': 0, + 'icp_max_current': 0, + 'language': 'EN', + 'live_refresh_time': 30, + 'locked': 1, + 'max_available_current': 32, + 'max_charging_current': 32, + 'max_charging_unit': 'amp', + 'max_charging_value': 32, + 'mid_enabled': 0, + 'mid_margin': 1, + 'mid_margin_unit': 1, + 'mid_serial_number': '', + 'mid_status': 1, + 'multiuser': 1, + 'name': 'Garage', + 'ocpp_ready': 'ocpp_1.6j', + 'operation_mode': 'ocpp', + 'owner_id': 1, + 'part_number': 'PLP1-0-2-2-E-002-C', + 'phase_switch': {'enabled': False}, + 'plan': {'features': ['DEFAULT_FEATURE', + 'POWER_BOOST', + 'MOBILE_CONNECTIVITY', + 'CHARGER_SUBGROUPS', + 'USER_SUBGROUPS', + 'DYNAMIC_POWER_SHARING', + 'PAYMENTS', + 'SET_UP_INCLUDED', + 'BULK_ACTIONS', + 'AUTOMATIC_REPORTING', + 'BILLING', + 'STATISTICS'], + 'plan_name': 'Business'}, + 'power_sharing_config': 256, + 'purchased_power': 0, + 'remote_action': 0, + 'rfid_type': None, + 'serial_number': '900001', + 'session_segment_length': 300, + 'sha256_charger_image': None, + 'show_default_user': 1, + 'show_email': 1, + 'show_lastname': 1, + 'show_name': 1, + 'show_profile': 1, + 'sim_iccid': None, + 'software': {'currentVersion': '6.7.43', 'fileName': 'plp1.tar', 'latestVersion': '6.7.43', 'updateAvailable': False}, + 'state': None, + 'sync_timestamp': 1791199799, + 'tariffs': [], + 'timezone': 'Europe/London', + 'uid': '00000000000000000000000000', + 'unlock_user_id': 1, + 'update_refresh_time': 300, + 'user_socket_locking': 0, + 'zipcode': None}, + 'cost': 0, + 'current_mode': 1, + 'depot_name': 'Home', + 'depot_price': 0.05, + 'finished': True, + 'grid': {'status': 0}, + 'last_sync': '2026-10-05 17:00:05', + 'max_available_power': 32, + 'mid_status': 1, + 'name': 'Garage', + 'ocpp_status': 4, + 'power_sharing_status': 0, + 'preventive_discharge': False, + 'pru': {'status': 0}, + 'state_of_charge': None, + 'status_id': 209, + 'user_id': 1, + 'user_name': 'default'} +# cspell:enable + def test_basic_auth_header(): """The Basic header is UTF-8, so a colon or accented character in the password survives.""" @@ -1185,6 +1294,20 @@ async def flaky_load(module, filename): print(" ✓ A failed control state read is retried") +def test_normalise_real_locked_capture(): + """A payload captured from a real locked, unplugged Pulsar Plus normalises to what the charger showed.""" + charger = normalise_charger(101, MOCK_STATUS_LOCKED_CAPTURED) + assert (charger.status_id, charger.status) == (209, "Locked") + assert charger.connected is False and charger.charging is False and charger.paused is False + assert charger.locked is True, "Wallbox sends locked as 1, not true" + assert charger.power_w == 0.0 and charger.session_energy_kwh == 0.0 + assert charger.max_charging_current == 32 and charger.max_available_current == 32 + assert charger.eco_smart == "off" + assert charger.name == "Garage" and charger.serial == "900001" + assert charger.part_number == "PLP1-0-2-2-E-002-C" and charger.software_version == "6.7.43" + print(" ✓ A real locked-charger capture is normalised") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -1255,5 +1378,6 @@ def test_wallbox(my_predbat=None): test_control_records_a_pause_whose_response_was_lost() test_control_keeps_its_record_through_a_transient_status() test_control_state_load_is_retried() + test_normalise_real_locked_capture() print("=" * 70) return False From 8a5d7e8f3d0be06487ceab98d92b4e8166b6872f Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 18:05:00 +0100 Subject: [PATCH 14/24] fix(wallbox): report HTTP 409 as the charger refusing an action in its current state Seen live: a resume sent to a locked charger with no car returns 409. It is now a WallboxStateError with a plain message, and the command line mode prints a failed call instead of a traceback. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 28 ++++++++++++++++++++++++++++ apps/predbat/wallbox.py | 15 ++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index e9eeebac3..65c3e9e9b 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -31,6 +31,7 @@ WallboxAuthError, WallboxPermissionError, WallboxRateLimitError, + WallboxStateError, WallboxTransport, basic_auth_header, normalise_charger, @@ -1308,6 +1309,31 @@ def test_normalise_real_locked_capture(): print(" ✓ A real locked-charger capture is normalised") +def test_transport_conflict_is_a_state_error(): + """A 409 means the charger cannot do that in its current state, e.g. resume with nothing paused.""" + session, calls = _session([_response({}, status=409)]) + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call") as mock_record: + try: + run_async(_signed_in_transport().resume(101)) + raise AssertionError("Expected WallboxStateError") + except WallboxStateError as exc: + assert "current state" in str(exc), str(exc) + assert isinstance(exc, WallboxApiError), "Callers that handle API errors must still catch it" + assert len(calls) == 1 and mock_record.call_args.kwargs.get("reason") == "client_error" + print(" ✓ A 409 is reported as the charger refusing the action in its current state") + + +def test_control_refused_for_state_is_logged_plainly(): + """A resume the charger refuses is logged with the reason, dropped, and polling carries on.""" + component = _started_component() + component.transport.errors[("resume", 101)] = WallboxStateError("Wallbox refused the action: the charger cannot do that in its current state") + run_async(component.switch_event("switch.predbat_wallbox_101_charging", "turn_on")) + assert run_async(component.run(60, False)) is True + assert _logged(component, "control failed") and _logged(component, "current state") + assert component.queued_events == [] + print(" ✓ A control refused for the charger's state is logged plainly") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -1379,5 +1405,7 @@ def test_wallbox(my_predbat=None): test_control_keeps_its_record_through_a_transient_status() test_control_state_load_is_retried() test_normalise_real_locked_capture() + test_transport_conflict_is_a_state_error() + test_control_refused_for_state_is_logged_plainly() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index a9cbfa5b8..ad96c5251 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -231,6 +231,10 @@ class WallboxApiError(WallboxError): """Any other failure: a bad status, a timeout, a dropped connection or an unreadable body.""" +class WallboxStateError(WallboxApiError): + """Wallbox answered 409: the charger cannot do what was asked in its current state.""" + + def basic_auth_header(username, password): """Build an HTTP Basic Authorization header value, encoded as UTF-8.""" token = base64.b64encode("{}:{}".format(username, password).encode("utf-8")).decode("ascii") @@ -295,6 +299,11 @@ async def _call(self, method, url, headers, body=None, write=False): if status in (401, 403): record_api_call("wallbox", success=False, reason="auth_error") raise WallboxAuthError("Wallbox rejected the credentials for {}".format(url)) + if status == 409: + # Seen live for a resume sent to a charger with nothing paused: the request + # was understood, the charger is just not in a state where it applies + record_api_call("wallbox", success=False, reason="client_error") + raise WallboxStateError("Wallbox refused the action: the charger cannot do that in its current state (HTTP 409 from {})".format(url)) if status < 200 or status >= 300: record_api_call("wallbox", success=False, reason="server_error" if status >= 500 else "client_error") raise WallboxApiError("HTTP {} from {}".format(status, url)) @@ -950,7 +959,11 @@ def main(): # pragma: no cover group.add_argument("--max-current", type=int, default=None, help="Set the maximum charging current in amps") group.add_argument("--eco-smart", choices=ECO_SMART_OPTIONS, default=None, help="Set the Eco-Smart mode") args = parser.parse_args() - asyncio.run(run_wallbox_cli(args)) + try: + asyncio.run(run_wallbox_cli(args)) + except WallboxError as exc: + # A refused control or a failed read is an answer, not a crash + print("\nWallbox call failed: {}".format(exc)) if __name__ == "__main__": From f0095aa38a5585c525ac4c76650da6e4f0a36b48 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 18:07:13 +0100 Subject: [PATCH 15/24] fix(wallbox): a refused control no longer claims the account lacks admin rights Seen live: a pause sent to a locked charger from an admin account returns 403. The error is now WallboxRefusedError and names both known causes. Refusals of controls the user sent are logged each time with the reason; the plan-led loop still logs once. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 54 +++++++++++++------ apps/predbat/wallbox.py | 32 ++++++----- docs/components.md | 2 +- .../2026-10-05-wallbox-component-design.md | 12 +++-- 4 files changed, 65 insertions(+), 35 deletions(-) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index 65c3e9e9b..dced7a6c1 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -29,7 +29,7 @@ WallboxAPI, WallboxApiError, WallboxAuthError, - WallboxPermissionError, + WallboxRefusedError, WallboxRateLimitError, WallboxStateError, WallboxTransport, @@ -467,17 +467,22 @@ def test_transport_control_requests(): print(" ✓ Control calls send the right method, URL and body") -def test_transport_control_refused_for_rights(): - """A 403 on a write is a rights problem, not bad credentials, and is not retried behind a sign in.""" +def test_transport_control_refused(): + """A 403 on a write is Wallbox refusing the control, not bad credentials, and is not retried behind a sign in. + + Seen live for a pause sent to a locked charger from an admin account, so the message must + not claim the cause is missing rights. + """ session, calls = _session([_response({}, status=403)]) with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): try: run_async(_signed_in_transport().set_locked(101, True)) - raise AssertionError("Expected WallboxPermissionError") - except WallboxPermissionError: - pass - assert len(calls) == 1, "A rights refusal must not trigger a sign in and retry" - print(" ✓ A 403 on a write raises WallboxPermissionError") + raise AssertionError("Expected WallboxRefusedError") + except WallboxRefusedError as exc: + assert "locked" in str(exc) and "HTTP 403" in str(exc), str(exc) + assert "needs admin rights" not in str(exc), "Missing rights is one possible cause, not the diagnosis" + assert len(calls) == 1, "A refusal must not trigger a sign in and retry" + print(" ✓ A 403 on a write raises WallboxRefusedError without guessing the cause") def test_transport_control_accepts_an_empty_body(): @@ -828,15 +833,31 @@ def test_select_control(): print(" ✓ Eco-Smart select sends only valid modes to chargers that support it") -def test_control_refused_for_rights_warns_once(): - """A rights refusal warns once across repeated attempts and does not fail the cycle.""" +def test_control_refused_is_logged_every_time_with_the_reason(): + """Each control the user sends that Wallbox refuses is logged with Wallbox's reason; the cycle still succeeds.""" component = _started_component() for _ in range(2): - component.transport.errors[("set_locked", 101, True)] = WallboxPermissionError("403") - run_async(component.switch_event("switch.predbat_wallbox_101_locked", "turn_on")) + component.transport.errors[("pause", 101)] = WallboxRefusedError("Wallbox refused the control (HTTP 403)") + run_async(component.switch_event("switch.predbat_wallbox_101_charging", "turn_off")) assert run_async(component.run(60, False)) is True - assert len([message for message in component.log_messages if "admin rights" in message]) == 1 - print(" ✓ A rights refusal warns once and monitoring continues") + refusals = [message for message in component.log_messages if "control refused" in message and "HTTP 403" in message] + assert len(refusals) == 2, component.log_messages + assert not _logged(component, "needs admin rights") + print(" ✓ A refused control is logged each time with the reason") + + +def test_plan_led_refusal_warns_once(): + """A refusal inside the plan-led loop repeats every poll, so it is logged once, not every two minutes.""" + component = _make_component(wallbox_control=True) + component.local_tz = CONTROL_TZ + component.base.local_tz = CONTROL_TZ + component.base.set_state_wrapper("binary_sensor.predbat_car_charging_slot", "off", {"planned": []}) + for seconds, first in ((0, True), (120, False), (240, False)): + component.transport.errors[("pause", 101)] = WallboxRefusedError("Wallbox refused the control (HTTP 403)") + assert run_async(component.run(seconds, first)) is True + refusals = [message for message in component.log_messages if "refused" in message and "HTTP 403" in message] + assert len(refusals) == 1, component.log_messages + print(" ✓ A refusal in the plan-led loop is logged once") def test_control_failure_is_logged_and_not_retried(): @@ -1353,7 +1374,7 @@ def test_wallbox(my_predbat=None): test_normalise_handles_bad_values() test_normalise_captured_payloads() test_transport_control_requests() - test_transport_control_refused_for_rights() + test_transport_control_refused() test_transport_control_accepts_an_empty_body() test_transport_rejects_an_unknown_eco_smart_mode() test_component_registration() @@ -1372,7 +1393,8 @@ def test_wallbox(my_predbat=None): test_switch_controls() test_number_control_clamps_and_ignores_junk() test_select_control() - test_control_refused_for_rights_warns_once() + test_control_refused_is_logged_every_time_with_the_reason() + test_plan_led_refusal_warns_once() test_control_failure_is_logged_and_not_retried() test_control_survives_a_rate_limit() test_charger_for_entity_requires_a_whole_id() diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index ad96c5251..e13b092b3 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -223,8 +223,13 @@ class WallboxRateLimitError(WallboxError): """Wallbox answered 429, too many requests.""" -class WallboxPermissionError(WallboxError): - """Wallbox refused a write because the account lacks admin rights over the charger.""" +class WallboxRefusedError(WallboxError): + """Wallbox answered 403 to a control. + + Seen live when a pause is sent to a locked charger. The Home Assistant integration + treats the same status as the account lacking admin rights over the charger, so + either may be the cause and the status alone does not say which. + """ class WallboxApiError(WallboxError): @@ -295,7 +300,7 @@ async def _call(self, method, url, headers, body=None, write=False): raise WallboxRateLimitError("Wallbox rate limit reached calling {}".format(url)) if status == 403 and write: record_api_call("wallbox", success=False, reason="auth_error") - raise WallboxPermissionError("Wallbox refused {} - the account needs admin rights over the charger".format(url)) + raise WallboxRefusedError("Wallbox refused the control (HTTP 403 from {}). A locked charger refuses controls; it can also mean the account lacks admin rights over the charger".format(url)) if status in (401, 403): record_api_call("wallbox", success=False, reason="auth_error") raise WallboxAuthError("Wallbox rejected the credentials for {}".format(url)) @@ -444,7 +449,7 @@ def initialize(self, username, password, automatic=True, wallbox_control=False, self.queued_events = [] self.skip_cycles = 0 self.backoff_cycles = 0 - self.permission_warned = False + self.refusal_warned = False # Charger ids in car order: entry N is car N. Append-only, so a car never changes number while Predbat runs self.car_order = [] self._auto_configured_order = None @@ -481,11 +486,11 @@ def ordered_chargers(self): """The chargers that have been read, in car order.""" return [self.chargers[charger_id] for charger_id in self.car_order if charger_id in self.chargers] - def warn_permission(self): - """Say once that the account cannot control the charger.""" - if not self.permission_warned: - self.log("Warn: wallbox: Wallbox refused a control - the account needs admin rights over the charger. Monitoring continues") - self.permission_warned = True + def warn_refused_once(self, exc): + """Log a refusal from the plan-led loop once: it would otherwise repeat on every poll.""" + if not self.refusal_warned: + self.log("Warn: wallbox: charge control refused: {}. Monitoring continues".format(exc)) + self.refusal_warned = True def automatic_config(self): """Wire the charger entities into Predbat's car charging inputs. @@ -556,8 +561,9 @@ async def process_events(self): except WallboxRateLimitError: # Left on the queue, so it is retried once the back-off has passed raise - except WallboxPermissionError: - self.warn_permission() + except WallboxRefusedError as exc: + # Something the user asked for, so every refusal is reported, with Wallbox's reason + self.log("Warn: wallbox: control refused: {}".format(exc)) except Exception as exc: # Anything else is dropped too: an event left on the queue would stop every later poll self.log("Warn: wallbox: control failed: {}".format(exc)) @@ -600,8 +606,8 @@ async def poll(self, seconds, first): self._auto_configured_order = list(self.car_order) try: await self.control_tick(self.now_utc_exact) - except WallboxPermissionError: - self.warn_permission() + except WallboxRefusedError as exc: + self.warn_refused_once(exc) except WallboxApiError as exc: # Monitoring succeeded, so a refused control is a warning, not a failed cycle self.log("Warn: wallbox: charge control failed: {}".format(exc)) diff --git a/docs/components.md b/docs/components.md index 3aff444e4..605921a97 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1070,7 +1070,7 @@ For each charger, with `` being the Wallbox charger id: Wallbox chargers cannot report the car's state of charge, so there is no battery sensor. -The controls need a Wallbox account with admin rights over the charger. Without them monitoring still works and Predbat logs a warning the first time a control is refused. +Wallbox refuses some controls depending on the charger's state: a locked charger refuses pause, and a charger with nothing paused refuses resume. It may also refuse controls from an account without admin rights over the charger. When a control is refused Predbat logs a warning with the reason Wallbox gave, and monitoring carries on. #### Automatic configuration (wallbox) diff --git a/docs/superpowers/specs/2026-10-05-wallbox-component-design.md b/docs/superpowers/specs/2026-10-05-wallbox-component-design.md index 8158b3169..ff5fe7201 100644 --- a/docs/superpowers/specs/2026-10-05-wallbox-component-design.md +++ b/docs/superpowers/specs/2026-10-05-wallbox-component-design.md @@ -50,8 +50,10 @@ other call sends `Authorization: Bearer `, `Accept: application/json` and | Max charging current | `PUT v2/charger/{id}` | `{"maxChargingCurrent": }` | | Eco-Smart | `PUT v4/chargers/{id}/eco-smart` | `{"data": {"attributes": {"enabled": 0\|1, "mode": 0\|1}, "type": "eco_smart"}}` | -Any call can return 429 (too many requests). The writes can return 403 when the account -does not have admin rights over the charger. +Any call can return 429 (too many requests). The writes can return 403: seen live for a pause sent +to a locked charger, and treated by the Home Assistant integration as missing admin rights. +They return 409 when the action does not apply in the charger's state, such as a resume +with nothing paused. ### 2.3 Status fields used @@ -102,7 +104,7 @@ One new file, `apps/predbat/wallbox.py`, in the shape of `myenergi.py`: `WallboxCharger`. Missing or malformed fields give safe defaults, never an exception. - `WallboxTransport` — owns the tokens and every HTTP call. A new `aiohttp.ClientSession` per request with a 30 second total timeout, as myenergi does. Raises `WallboxAuthError` - (bad credentials), `WallboxRateLimitError` (429), `WallboxPermissionError` (403 on a + (bad credentials), `WallboxRateLimitError` (429), `WallboxRefusedError` (403 on a write) or `WallboxApiError`, all under `WallboxError`. Every call is recorded with `record_api_call("wallbox", …)`. - `WallboxAPI(ComponentBase)` — polling, publishing, event handling, automatic @@ -252,8 +254,8 @@ when they turn `wallbox_control` on. - `WallboxRateLimitError` skips polls on a doubling back-off (2, 4, 8 minutes) capped at 15 minutes, and returns True so one 429 does not count as a failure. The cap keeps recovery inside the 60 minute health window. -- `WallboxPermissionError` on a write logs once that the account needs admin rights over - the charger. Monitoring carries on. +- `WallboxRefusedError` (403) on a control the user sent is logged each time with Wallbox's + reason. In the plan-led loop it is logged once. Monitoring carries on. - Any other `WallboxError` on a poll logs a warning and returns False. - `update_success_timestamp()` is called only after a poll that returned at least one charger. From 4494a19d3277ef72fcbd0d7eac537078868ea68a Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 18:08:45 +0100 Subject: [PATCH 16/24] feat(wallbox): do not send pause or resume to a locked charger Wallbox refuses both while the charger is locked (seen live: 403 for pause, 409 for resume). One gate now covers the manual switch, plan-led control, release and the command line. A charger Predbat paused stays on its release list until it is unlocked. Also adds a read-only --get option to the command line mode for exploring API paths. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 63 +++++++++++++++++++ apps/predbat/wallbox.py | 42 +++++++++++-- docs/components.md | 4 +- .../2026-10-05-wallbox-component-design.md | 7 ++- 4 files changed, 106 insertions(+), 10 deletions(-) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index dced7a6c1..2e5e46c67 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -1355,6 +1355,65 @@ def test_control_refused_for_state_is_logged_plainly(): print(" ✓ A control refused for the charger's state is logged plainly") +def test_switch_refuses_pause_and_resume_while_locked(): + """Wallbox refuses pause and resume on a locked charger, so they are not sent and the user is told why.""" + for service in ("turn_on", "turn_off"): + component = _started_component({101: _status(status_id=210, power=0, locked=1)}) + run_async(component.switch_event_handler("switch.predbat_wallbox_101_charging", service)) + assert component.transport.calls == [], (service, component.transport.calls) + assert _logged(component, "is locked") and _logged(component, "unlock") + + # The lock switch itself must still work, or there would be no way out + component = _started_component({101: _status(status_id=210, power=0, locked=1)}) + run_async(component.switch_event_handler("switch.predbat_wallbox_101_locked", "turn_off")) + assert component.transport.calls == [("set_locked", 101, False)] + print(" ✓ Pause and resume are not sent to a locked charger; unlock still is") + + +def test_control_leaves_a_locked_charger_alone_and_warns_once(): + """Plan-led control sends nothing to a locked charger, whichever way the plan points, and says so once.""" + for plan in (PLAN_INSIDE, PLAN_OUTSIDE): + for status_id in (193, 178, 210): + component = _control_component({0: plan}, {101: _status(status_id=status_id, locked=1)}) + run_async(component.control_tick(CONTROL_NOW)) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [], (status_id, component.transport.calls) + assert len([message for message in component.log_messages if "is locked" in message]) == 1, component.log_messages + print(" ✓ Plan-led control leaves a locked charger alone and warns once") + + +def test_release_waits_for_a_locked_charger(): + """A charger Predbat paused that is now locked cannot be resumed; the record is kept until it is unlocked.""" + component = _control_component({0: PLAN_OUTSIDE}) + run_async(component.control_tick(CONTROL_NOW)) + assert component.paused_by_predbat == {"101"} + + component.transport.statuses[101] = _status(status_id=178, power=0, locked=1) + _load_chargers(component) + component.base.args["set_read_only"] = True + run_async(component.control_tick(CONTROL_NOW)) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [] and component.paused_by_predbat == {"101"} + assert len([message for message in component.log_messages if "is locked" in message]) == 1 + + component.transport.statuses[101] = _status(status_id=178, power=0) + _load_chargers(component) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("resume", 101), ("resume_schedule", 101)] + assert component.paused_by_predbat == set() + print(" ✓ Release waits for a locked charger to be unlocked") + + +def test_transport_get_reads_any_path(): + """The read-only probe sends an authenticated GET to the path it is given.""" + session, calls = _session([_response({"ocpp": "enabled"})]) + with patch("aiohttp.ClientSession", return_value=session), patch("wallbox.record_api_call"): + reply = run_async(_signed_in_transport().get("v3/chargers/101/something")) + assert reply == {"ocpp": "enabled"} + assert (calls[0]["method"], calls[0]["url"], calls[0]["json"]) == ("GET", WALLBOX_API_URL + "v3/chargers/101/something", None) + print(" ✓ The read-only probe sends an authenticated GET") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -1429,5 +1488,9 @@ def test_wallbox(my_predbat=None): test_normalise_real_locked_capture() test_transport_conflict_is_a_state_error() test_control_refused_for_state_is_logged_plainly() + test_switch_refuses_pause_and_resume_while_locked() + test_control_leaves_a_locked_charger_alone_and_warns_once() + test_release_waits_for_a_locked_charger() + test_transport_get_reads_any_path() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index e13b092b3..0e7ae78c9 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -383,6 +383,10 @@ async def list_chargers(self): charger_ids.append(charger["id"]) return charger_ids + async def get(self, path): + """Read any API path and return the decoded reply. Read-only, for exploring the API from the command line.""" + return await self._api("GET", path) + async def get_status(self, charger_id): """Return the raw status payload of one charger.""" return await self._api("GET", "chargers/status/{}".format(charger_id)) @@ -638,6 +642,23 @@ async def publish_data(self): if charger.eco_smart is not None: self.dashboard_item("select.{}_eco_smart".format(prefix), state=charger.eco_smart, attributes=wallbox_attribute_table["eco_smart"], app="wallbox") + def held_by_lock(self, charger, once=False): + """Is the charger locked, which makes Wallbox refuse to pause or resume it. Says why when it is. + + Seen live: a locked charger answers a pause with 403 and a resume with 409, so + neither is sent. With once set the warning is given a single time per charger + until it is unlocked, for the plan-led loop that would otherwise repeat it on + every poll. + """ + if not charger.locked: + self.lock_warned.discard(charger.charger_id) + return False + if not once or charger.charger_id not in self.lock_warned: + self.log("Warn: wallbox: {} is locked, so Wallbox will refuse to pause or resume it. Unlock it with switch.{}_locked or in the Wallbox app; Predbat does not unlock chargers itself".format(charger.name, self.entity_prefix(charger))) + if once: + self.lock_warned.add(charger.charger_id) + return True + def charger_for_entity(self, entity_id): """Find the charger an entity belongs to, or None. @@ -682,6 +703,8 @@ async def switch_event_handler(self, entity_id, service): return turn_on = service == "turn_on" if entity_id.endswith("_charging"): + if self.held_by_lock(charger): + return self.log("Info: wallbox: {} charging on {}".format("resuming" if turn_on else "pausing", charger.name)) if turn_on: await self.transport.resume(self.api_id(charger)) @@ -819,6 +842,9 @@ async def release_chargers(self): charger = self.chargers.get(charger_id) if charger and charger_id in self.stale_ids: continue + if charger and charger.paused and self.held_by_lock(charger, once=True): + # Cannot be resumed until it is unlocked, so it stays on the list + continue if charger and charger.paused: self.log("Info: wallbox: releasing {}".format(charger.name)) await self.transport.resume(self.api_id(charger)) @@ -848,12 +874,8 @@ async def control_charge(self, now): if not charger.connected: # Updating, Error or Unknown: say nothing, and keep the record until the charger reports again continue - if wanted and charger.locked: - if charger.charger_id not in self.lock_warned: - self.log("Warn: wallbox: {} is locked, so it will not charge in its planned window. Predbat does not unlock chargers".format(charger.name)) - self.lock_warned.add(charger.charger_id) + if self.held_by_lock(charger, once=True): continue - self.lock_warned.discard(charger.charger_id) if wanted and charger.paused: self.log("Info: wallbox: resuming {} for car {}".format(charger.name, car_n)) await self.transport.resume(self.api_id(charger)) @@ -922,8 +944,15 @@ async def run_wallbox_cli(args): # pragma: no cover charger = chargers[0] target = component.api_id(charger) - # The same calls the component issues, so they can be tried by hand against a live charger transport = component.transport + if args.get: + print("\nGET {}:".format(args.get)) + print(json.dumps(await transport.get(args.get), indent=2, sort_keys=True, default=str)) + + # The same calls the component issues, so they can be tried by hand against a live charger + if (args.pause or args.resume or args.resume_schedule) and component.held_by_lock(charger): + print("\nNot sent: {} is locked. Run --unlock first".format(charger.name)) + return action = None if args.pause: action = ("pause", transport.pause(target)) @@ -954,6 +983,7 @@ def main(): # pragma: no cover parser.add_argument("--username", required=True, help="Wallbox account email address") parser.add_argument("--password", required=True, help="Wallbox account password") parser.add_argument("--raw", action="store_true", help="Print each charger's full status payload as JSON") + parser.add_argument("--get", default=None, help="Read-only: GET this API path, e.g. v3/chargers//ocpp-configuration, and print the reply. For exploring the API") parser.add_argument("--no-automatic", action="store_true", help="Skip the automatic configuration of car_charging_energy, car_charging_planned, car_charging_power and car_charging_now") parser.add_argument("--charger", default=None, help="Charger id to control; defaults to the first one") group = parser.add_mutually_exclusive_group() diff --git a/docs/components.md b/docs/components.md index 605921a97..8efe7ace1 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1070,7 +1070,7 @@ For each charger, with `` being the Wallbox charger id: Wallbox chargers cannot report the car's state of charge, so there is no battery sensor. -Wallbox refuses some controls depending on the charger's state: a locked charger refuses pause, and a charger with nothing paused refuses resume. It may also refuse controls from an account without admin rights over the charger. When a control is refused Predbat logs a warning with the reason Wallbox gave, and monitoring carries on. +A locked charger cannot be paused or resumed: Wallbox refuses both. Predbat therefore does not send them while the charger is locked, and logs a warning asking you to unlock it with `switch.predbat_wallbox__locked` or in the Wallbox app. Wallbox can also refuse a control for other reasons, such as a resume with nothing paused, or an account without admin rights over the charger. When that happens Predbat logs a warning with the reason Wallbox gave, and monitoring carries on. #### Automatic configuration (wallbox) @@ -1091,7 +1091,7 @@ Things to know: - Wallbox can only pause a charger once it is charging, so a car plugged in outside a slot draws power until Predbat's next poll (two minutes by default). - Clear any schedule set in the Wallbox app. A charger that Wallbox is holding in `Scheduled` will not charge in a Predbat slot. - While Predbat-led charging is on, Predbat resumes a paused charger inside a planned slot even if you paused it yourself. Turn `switch.predbat_wallbox_control` off first if you want it to stay paused. -- Predbat never unlocks a locked charger. It logs a warning if a locked charger misses its slot. +- Predbat never unlocks a locked charger, and cannot pause or resume one. While a charger is locked Predbat-led charging leaves it alone and logs a warning once. A charger Predbat had paused is resumed once you unlock it. - Some cars go to sleep when charging is paused and do not wake when it is resumed. If yours does, Predbat-led charging will not suit it. #### Configuration Options (wallbox) diff --git a/docs/superpowers/specs/2026-10-05-wallbox-component-design.md b/docs/superpowers/specs/2026-10-05-wallbox-component-design.md index ff5fe7201..552dbc09b 100644 --- a/docs/superpowers/specs/2026-10-05-wallbox-component-design.md +++ b/docs/superpowers/specs/2026-10-05-wallbox-component-design.md @@ -220,8 +220,11 @@ shared `utils.parse_car_plan_windows` / `in_car_plan_window`: - Outside a window and the charger is Charging → pause, and record that Predbat paused it. - Any other state → no action. Resume applies only to a Paused charger: one in Scheduled is following its own schedule and one in Waiting is waiting on the car, so neither is - sent anything. A locked charger is left alone with a one-off warning; Predbat never - unlocks it. + sent anything. +- A locked charger is sent neither pause nor resume, by plan-led control, by release or by + the manual switch: Wallbox refuses both while it is locked (seen live: 403 and 409). + Plan-led control warns once per charger; the manual switch warns each time. Predbat never + unlocks a charger. A charger Predbat paused stays on its list until it is unlocked. At most one command is sent per charger per poll, and only on freshly polled state, so a slow charger is not sent the same command repeatedly. From 3c53f8e41186b4150fb32c845be217649fb140b4 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 18:18:29 +0100 Subject: [PATCH 17/24] feat(wallbox): put the charger name in each entity's friendly name Entity ids keep the charger id; the friendly name now reads e.g. 'Wallbox Garage Power', so two chargers can be told apart. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 34 ++++++++++++++++++++++++++++++ apps/predbat/wallbox.py | 30 ++++++++++++++++++-------- docs/components.md | 2 ++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index 2e5e46c67..88b334d70 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -1414,6 +1414,38 @@ def test_transport_get_reads_any_path(): print(" ✓ The read-only probe sends an authenticated GET") +def test_friendly_names_carry_the_charger_name(): + """Every charger entity is named after its charger, so two chargers can be told apart.""" + component = _make_component({101: _status(name="Garage"), 202: _status(name="Drive", eco={"enabled": False, "mode": 0})}, wallbox_control=True) + run_async(component.run(0, True)) + entities = component.base.entities + + assert entities["sensor.predbat_wallbox_101_power"]["attributes"]["friendly_name"] == "Wallbox Garage Power" + assert entities["sensor.predbat_wallbox_202_power"]["attributes"]["friendly_name"] == "Wallbox Drive Power" + assert entities["sensor.predbat_wallbox_101_status"]["attributes"]["friendly_name"] == "Wallbox Garage Status" + assert entities["select.predbat_wallbox_202_eco_smart"]["attributes"]["friendly_name"] == "Wallbox Drive Eco-Smart" + for entity_id, entity in entities.items(): + if "_wallbox_101_" in entity_id: + assert entity["attributes"]["friendly_name"].startswith("Wallbox Garage "), entity_id + if "_wallbox_202_" in entity_id: + assert entity["attributes"]["friendly_name"].startswith("Wallbox Drive "), entity_id + # The one switch that belongs to no charger keeps its plain name + assert entities["switch.predbat_wallbox_control"]["attributes"]["friendly_name"] == "Wallbox Charge Control" + print(" ✓ Friendly names carry the charger name") + + +def test_friendly_names_without_a_usable_charger_name(): + """A charger with no name is labelled by its id, and a name that already says Wallbox is not doubled.""" + unnamed = _make_component({101: _status(name=None)}) + run_async(unnamed.run(0, True)) + assert unnamed.base.entities["sensor.predbat_wallbox_101_power"]["attributes"]["friendly_name"] == "Wallbox 101 Power" + + branded = _make_component({101: _status(name="My Wallbox")}) + run_async(branded.run(0, True)) + assert branded.base.entities["sensor.predbat_wallbox_101_power"]["attributes"]["friendly_name"] == "My Wallbox Power" + print(" ✓ Friendly names cope with a missing or already-branded charger name") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -1492,5 +1524,7 @@ def test_wallbox(my_predbat=None): test_control_leaves_a_locked_charger_alone_and_warns_once() test_release_waits_for_a_locked_charger() test_transport_get_reads_any_path() + test_friendly_names_carry_the_charger_name() + test_friendly_names_without_a_usable_charger_name() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index 0e7ae78c9..b8d6c6bef 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -619,6 +619,18 @@ async def poll(self, seconds, first): self.update_success_timestamp() return True + def charger_attributes(self, charger, key): + """A copy of one entity's attributes with the charger's name in the friendly name. + + Entity ids carry the charger id, which never changes; the friendly name carries the + name the user gave the charger, so two chargers can be told apart at a glance. A + name that already says Wallbox is used as it stands rather than doubled up. + """ + attributes = dict(wallbox_attribute_table[key]) + label = charger.name if "wallbox" in charger.name.lower() else "Wallbox {}".format(charger.name) + attributes["friendly_name"] = attributes["friendly_name"].replace("Wallbox", label, 1) + return attributes + async def publish_data(self): """Publish every known charger as Predbat entities.""" if self.control_active: @@ -626,21 +638,21 @@ async def publish_data(self): self.dashboard_item("switch.{}_wallbox_control".format(self.prefix), state="on" if self.control_enabled else "off", attributes=wallbox_attribute_table["control"], app="wallbox") for charger in self.ordered_chargers(): prefix = self.entity_prefix(charger) - status_attributes = dict(wallbox_attribute_table["status"]) + status_attributes = self.charger_attributes(charger, "status") status_attributes.update({"status_id": charger.status_id, "name": charger.name, "serial_number": charger.serial, "part_number": charger.part_number, "software_version": charger.software_version}) - current_attributes = dict(wallbox_attribute_table["max_charging_current"]) + current_attributes = self.charger_attributes(charger, "max_charging_current") current_attributes["max"] = charger.max_available_current if charger.max_available_current >= MIN_CHARGING_CURRENT else DEFAULT_MAX_CHARGING_CURRENT self.dashboard_item("sensor.{}_status".format(prefix), state=charger.status, attributes=status_attributes, app="wallbox") - self.dashboard_item("sensor.{}_power".format(prefix), state=charger.power_w, attributes=wallbox_attribute_table["power"], app="wallbox") - self.dashboard_item("sensor.{}_session_energy".format(prefix), state=charger.session_energy_kwh, attributes=wallbox_attribute_table["session_energy"], app="wallbox") - self.dashboard_item("binary_sensor.{}_connected".format(prefix), state="on" if charger.connected else "off", attributes=wallbox_attribute_table["connected"], app="wallbox") - self.dashboard_item("binary_sensor.{}_charging".format(prefix), state="on" if charger.charging else "off", attributes=wallbox_attribute_table["charging"], app="wallbox") - self.dashboard_item("switch.{}_charging".format(prefix), state="on" if charger.charging else "off", attributes=wallbox_attribute_table["charging_switch"], app="wallbox") - self.dashboard_item("switch.{}_locked".format(prefix), state="on" if charger.locked else "off", attributes=wallbox_attribute_table["locked"], app="wallbox") + self.dashboard_item("sensor.{}_power".format(prefix), state=charger.power_w, attributes=self.charger_attributes(charger, "power"), app="wallbox") + self.dashboard_item("sensor.{}_session_energy".format(prefix), state=charger.session_energy_kwh, attributes=self.charger_attributes(charger, "session_energy"), app="wallbox") + self.dashboard_item("binary_sensor.{}_connected".format(prefix), state="on" if charger.connected else "off", attributes=self.charger_attributes(charger, "connected"), app="wallbox") + self.dashboard_item("binary_sensor.{}_charging".format(prefix), state="on" if charger.charging else "off", attributes=self.charger_attributes(charger, "charging"), app="wallbox") + self.dashboard_item("switch.{}_charging".format(prefix), state="on" if charger.charging else "off", attributes=self.charger_attributes(charger, "charging_switch"), app="wallbox") + self.dashboard_item("switch.{}_locked".format(prefix), state="on" if charger.locked else "off", attributes=self.charger_attributes(charger, "locked"), app="wallbox") self.dashboard_item("number.{}_max_charging_current".format(prefix), state=charger.max_charging_current, attributes=current_attributes, app="wallbox") if charger.eco_smart is not None: - self.dashboard_item("select.{}_eco_smart".format(prefix), state=charger.eco_smart, attributes=wallbox_attribute_table["eco_smart"], app="wallbox") + self.dashboard_item("select.{}_eco_smart".format(prefix), state=charger.eco_smart, attributes=self.charger_attributes(charger, "eco_smart"), app="wallbox") def held_by_lock(self, charger, once=False): """Is the charger locked, which makes Wallbox refuse to pause or resume it. Says why when it is. diff --git a/docs/components.md b/docs/components.md index 8efe7ace1..02a9bb137 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1068,6 +1068,8 @@ For each charger, with `` being the Wallbox charger id: | `number.predbat_wallbox__max_charging_current` | Maximum charging current in A | | `select.predbat_wallbox__eco_smart` | Eco-Smart mode: `off`, `eco_mode` or `full_solar`. Only on chargers with Eco-Smart | +Each entity's friendly name includes the name you gave the charger in the Wallbox app, for example `Wallbox Garage Power`, so two chargers are easy to tell apart. The entity ids use the charger id, which does not change if you rename the charger. + Wallbox chargers cannot report the car's state of charge, so there is no battery sensor. A locked charger cannot be paused or resumed: Wallbox refuses both. Predbat therefore does not send them while the charger is locked, and logs a warning asking you to unlock it with `switch.predbat_wallbox__locked` or in the Wallbox app. Wallbox can also refuse a control for other reasons, such as a resume with nothing paused, or an account without admin rights over the charger. When that happens Predbat logs a warning with the reason Wallbox gave, and monitoring carries on. From 0bee169fca850e716e3682537b8e76f31f8dd71d Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 18:20:49 +0100 Subject: [PATCH 18/24] feat(wallbox): show the charger's operation mode on the status sensor A charger run by an OCPP backend reports operation_mode 'ocpp'. It is published as an attribute and shown in the command line table. The controls are unchanged and still fail on a charger the backend holds locked. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 13 +++++++++++++ apps/predbat/wallbox.py | 13 ++++++++++--- docs/components.md | 4 +++- .../specs/2026-10-05-wallbox-component-design.md | 1 + 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index 88b334d70..7a6b19140 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -1446,6 +1446,18 @@ def test_friendly_names_without_a_usable_charger_name(): print(" ✓ Friendly names cope with a missing or already-branded charger name") +def test_operation_mode_is_read_and_published(): + """The charger's operation mode (ocpp when an OCPP backend runs it) is shown on the status sensor.""" + assert normalise_charger(101, MOCK_STATUS_LOCKED_CAPTURED).operation_mode == "ocpp" + assert normalise_charger(101, _status()).operation_mode == "", "A payload without the field gives an empty mode, not an error" + assert normalise_charger(101, {"config_data": {"operation_mode": None}}).operation_mode == "" + + component = _make_component({101: MOCK_STATUS_LOCKED_CAPTURED}) + run_async(component.run(0, True)) + assert component.base.entities["sensor.predbat_wallbox_101_status"]["attributes"]["operation_mode"] == "ocpp" + print(" ✓ Operation mode is read and published on the status sensor") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -1526,5 +1538,6 @@ def test_wallbox(my_predbat=None): test_transport_get_reads_any_path() test_friendly_names_carry_the_charger_name() test_friendly_names_without_a_usable_charger_name() + test_operation_mode_is_read_and_published() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index b8d6c6bef..83c1d991e 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -162,6 +162,8 @@ class WallboxCharger: max_charging_current: int max_available_current: int eco_smart: Optional[str] + # How the charger is being run: "ocpp" when an OCPP backend is in charge of it. Empty when not reported + operation_mode: str = "" def normalise_charger(charger_id, payload): @@ -208,6 +210,7 @@ def normalise_charger(charger_id, payload): max_charging_current=_to_int(config.get("max_charging_current")), max_available_current=_to_int(payload.get("max_available_power")), eco_smart=eco_smart, + operation_mode=str(config.get("operation_mode") or ""), ) @@ -639,7 +642,7 @@ async def publish_data(self): for charger in self.ordered_chargers(): prefix = self.entity_prefix(charger) status_attributes = self.charger_attributes(charger, "status") - status_attributes.update({"status_id": charger.status_id, "name": charger.name, "serial_number": charger.serial, "part_number": charger.part_number, "software_version": charger.software_version}) + status_attributes.update({"status_id": charger.status_id, "name": charger.name, "serial_number": charger.serial, "part_number": charger.part_number, "software_version": charger.software_version, "operation_mode": charger.operation_mode}) current_attributes = self.charger_attributes(charger, "max_charging_current") current_attributes["max"] = charger.max_available_current if charger.max_available_current >= MIN_CHARGING_CURRENT else DEFAULT_MAX_CHARGING_CURRENT @@ -905,9 +908,13 @@ async def control_charge(self, now): def print_charger_table(chargers): # pragma: no cover """Print the charger summary table, for the poll and for a command read-back alike.""" - print("{:<12} {:<20} {:<34} {:>9} {:>12} {:>6} {:>7}".format("CHARGER", "NAME", "STATUS", "POWER W", "SESSION kWh", "MAX A", "LOCKED")) + print("{:<12} {:<20} {:<34} {:>9} {:>12} {:>6} {:>7} {:>8}".format("CHARGER", "NAME", "STATUS", "POWER W", "SESSION kWh", "MAX A", "LOCKED", "MODE")) for charger in chargers: - print("{:<12} {:<20} {:<34} {:>9.0f} {:>12.2f} {:>6} {:>7}".format(charger.charger_id, charger.name[:20], charger.status, charger.power_w, charger.session_energy_kwh, charger.max_charging_current, "yes" if charger.locked else "no")) + print( + "{:<12} {:<20} {:<34} {:>9.0f} {:>12.2f} {:>6} {:>7} {:>8}".format( + charger.charger_id, charger.name[:20], charger.status, charger.power_w, charger.session_energy_kwh, charger.max_charging_current, "yes" if charger.locked else "no", charger.operation_mode or "-" + ) + ) async def run_wallbox_cli(args): # pragma: no cover diff --git a/docs/components.md b/docs/components.md index 02a9bb137..e95178ab6 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1058,7 +1058,7 @@ For each charger, with `` being the Wallbox charger id: | Entity | Description | | ------ | ----------- | -| `sensor.predbat_wallbox__status` | Charger status, e.g. `Charging`, `Paused`, `Waiting for car demand`, `Ready` | +| `sensor.predbat_wallbox__status` | Charger status, e.g. `Charging`, `Paused`, `Waiting for car demand`, `Ready`. Its `operation_mode` attribute reads `ocpp` when an OCPP backend, such as Octopus for Intelligent Octopus Go, is running the charger | | `sensor.predbat_wallbox__power` | Charging power in W | | `sensor.predbat_wallbox__session_energy` | Energy added in the current session in kWh; resets with each session | | `binary_sensor.predbat_wallbox__connected` | On while a car is plugged in | @@ -1072,6 +1072,8 @@ Each entity's friendly name includes the name you gave the charger in the Wallbo Wallbox chargers cannot report the car's state of charge, so there is no battery sensor. +A charger run by an OCPP backend is held locked by that backend, and unlocking it from Predbat or the Wallbox app has no effect: the controls below will not work on it, and the backend decides when the car charges. + A locked charger cannot be paused or resumed: Wallbox refuses both. Predbat therefore does not send them while the charger is locked, and logs a warning asking you to unlock it with `switch.predbat_wallbox__locked` or in the Wallbox app. Wallbox can also refuse a control for other reasons, such as a resume with nothing paused, or an account without admin rights over the charger. When that happens Predbat logs a warning with the reason Wallbox gave, and monitoring carries on. #### Automatic configuration (wallbox) diff --git a/docs/superpowers/specs/2026-10-05-wallbox-component-design.md b/docs/superpowers/specs/2026-10-05-wallbox-component-design.md index 552dbc09b..e5b878d0f 100644 --- a/docs/superpowers/specs/2026-10-05-wallbox-component-design.md +++ b/docs/superpowers/specs/2026-10-05-wallbox-component-design.md @@ -67,6 +67,7 @@ with nothing paused. | `config_data.max_charging_current` | Amps | | `config_data.locked` | Lock state | | `config_data.serial_number`, `.part_number`, `.software.currentVersion` | Identity | +| `config_data.operation_mode` | `ocpp` when an OCPP backend runs the charger; published as an attribute of the status sensor. Seen live: such a charger stays Locked and an API unlock has no effect | | `config_data.ecosmart.enabled`, `.mode` | Absent when the charger has no Eco-Smart; mode 0 is eco, 1 is full solar | The payload has a `state_of_charge` field, but a Type 2 connector cannot report the car's From 9301e517ef5058be4f50ee3739a2bd57b5434708 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 18:23:34 +0100 Subject: [PATCH 19/24] feat(wallbox): no plan-led control for OCPP chargers; publish timezone and location A charger run by an OCPP backend is skipped by Predbat-led charging, with one warning, and its lock warning now says the backend holds the lock. The status sensor gains timezone, country and zipcode attributes. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 67 ++++++++++++++++++- apps/predbat/wallbox.py | 56 +++++++++++++++- docs/components.md | 3 +- .../2026-10-05-wallbox-component-design.md | 3 +- 4 files changed, 124 insertions(+), 5 deletions(-) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index 7a6b19140..93fee1931 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -346,11 +346,13 @@ def test_transport_rate_limit_and_failures(): print(" ✓ Rate limit and failures map to the right errors and metric reasons") -def _status(status_id=193, power=7.2, energy=4.5, locked=False, max_current=32, eco=None, name="Garage"): +def _status(status_id=193, power=7.2, energy=4.5, locked=False, max_current=32, eco=None, name="Garage", mode=None): """Build a status payload in the shape Wallbox returns, for the fields Predbat reads.""" config = {"max_charging_current": max_current, "locked": locked, "serial_number": "900001", "part_number": "PLP1-0-2-4-9-002-E", "software": {"currentVersion": "5.5.10"}} if eco is not None: config["ecosmart"] = eco + if mode is not None: + config["operation_mode"] = mode return {"status_id": status_id, "charging_power": power, "added_energy": energy, "max_available_power": 32, "name": name, "config_data": config} @@ -1458,6 +1460,65 @@ def test_operation_mode_is_read_and_published(): print(" ✓ Operation mode is read and published on the status sensor") +def test_control_is_disabled_for_an_ocpp_charger(): + """A charger run by an OCPP backend is never paused or resumed by the plan; other chargers still are.""" + statuses = {101: _status(status_id=193, mode="ocpp"), 202: _status(status_id=193, mode="wallbox")} + component = _control_component({0: PLAN_OUTSIDE, 1: PLAN_OUTSIDE}, statuses) + run_async(component.control_tick(CONTROL_NOW)) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.count("pause") == 2 and ("pause", 101) not in component.transport.calls, component.transport.calls + assert component.paused_by_predbat == {"202"} + assert len([message for message in component.log_messages if "OCPP" in message and "Garage" in message]) == 1, component.log_messages + + paused = _control_component({0: PLAN_INSIDE}, {101: _status(status_id=178, power=0, mode="ocpp")}) + run_async(paused.control_tick(CONTROL_NOW)) + assert paused.transport.calls == [], "Nor is an OCPP charger resumed inside a window" + print(" ✓ Plan-led control is disabled for a charger in OCPP mode") + + +def test_control_resumes_once_ocpp_is_turned_off(): + """Taking a charger out of OCPP mode puts it back under the plan, and the warning can be given again later.""" + component = _control_component({0: PLAN_OUTSIDE}, {101: _status(status_id=193, mode="ocpp")}) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [] + component.transport.statuses[101] = _status(status_id=193, mode="wallbox") + _load_chargers(component) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("pause", 101)] + print(" ✓ Plan-led control returns when OCPP mode is turned off") + + +def test_lock_warning_names_ocpp_when_it_holds_the_lock(): + """On an OCPP charger the user is told the backend holds the lock, not to unlock it, which does nothing.""" + component = _started_component({101: _status(status_id=209, power=0, locked=1, mode="ocpp")}) + run_async(component.switch_event_handler("switch.predbat_wallbox_101_charging", "turn_off")) + assert component.transport.calls == [] + assert _logged(component, "OCPP") and not _logged(component, "Unlock it with") + print(" ✓ The lock warning names OCPP when the backend holds the lock") + + +def test_location_and_timezone_are_read_and_published(): + """The charger's timezone, country and postcode are shown on the status sensor.""" + charger = normalise_charger(101, MOCK_STATUS_LOCKED_CAPTURED) + assert charger.timezone == "Europe/London" and charger.country == "GB" and charger.zipcode == "" + + full = _status() + full["config_data"].update({"timezone": "Europe/Madrid", "country": {"iso2": "ES", "code": "ESP"}, "zipcode": "08019"}) + charger = normalise_charger(101, full) + assert (charger.timezone, charger.country, charger.zipcode) == ("Europe/Madrid", "ES", "08019") + + bare = normalise_charger(101, {"config_data": {"country": "not a dict", "timezone": None}}) + assert (bare.timezone, bare.country, bare.zipcode) == ("", "", "") + three_letter = normalise_charger(101, {"config_data": {"country": {"code": "GBR"}}}) + assert three_letter.country == "GBR", "Falls back to the three letter code when there is no two letter one" + + component = _make_component({101: MOCK_STATUS_LOCKED_CAPTURED}) + run_async(component.run(0, True)) + attributes = component.base.entities["sensor.predbat_wallbox_101_status"]["attributes"] + assert attributes["timezone"] == "Europe/London" and attributes["country"] == "GB" and attributes["zipcode"] == "" + print(" ✓ Timezone, country and postcode are read and published") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -1539,5 +1600,9 @@ def test_wallbox(my_predbat=None): test_friendly_names_carry_the_charger_name() test_friendly_names_without_a_usable_charger_name() test_operation_mode_is_read_and_published() + test_control_is_disabled_for_an_ocpp_charger() + test_control_resumes_once_ocpp_is_turned_off() + test_lock_warning_names_ocpp_when_it_holds_the_lock() + test_location_and_timezone_are_read_and_published() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index 83c1d991e..f9f19144a 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -112,6 +112,9 @@ UNPLUGGED_STATUS_IDS = (0, 161, 162, 163, 165, 209) PAUSED_STATUS_IDS = (178, 182) +# config_data.operation_mode when an OCPP backend, not the Wallbox cloud, runs the charger +OPERATION_MODE_OCPP = "ocpp" + ECO_SMART_OFF = "off" ECO_SMART_ECO = "eco_mode" ECO_SMART_FULL_SOLAR = "full_solar" @@ -164,6 +167,15 @@ class WallboxCharger: eco_smart: Optional[str] # How the charger is being run: "ocpp" when an OCPP backend is in charge of it. Empty when not reported operation_mode: str = "" + # Where the charger is, as far as its status says: an IANA timezone, a country code and a postcode + timezone: str = "" + country: str = "" + zipcode: str = "" + + @property + def ocpp(self): + """Is an OCPP backend running this charger.""" + return self.operation_mode.lower() == OPERATION_MODE_OCPP def normalise_charger(charger_id, payload): @@ -193,6 +205,10 @@ def normalise_charger(charger_id, payload): else: eco_smart = ECO_SMART_ECO + country = config.get("country") + if not isinstance(country, dict): + country = {} + return WallboxCharger( charger_id=str(charger_id), name=str(payload.get("name") or "Wallbox {}".format(charger_id)), @@ -211,6 +227,9 @@ def normalise_charger(charger_id, payload): max_available_current=_to_int(payload.get("max_available_power")), eco_smart=eco_smart, operation_mode=str(config.get("operation_mode") or ""), + timezone=str(config.get("timezone") or ""), + country=str(country.get("iso2") or country.get("code") or ""), + zipcode=str(config.get("zipcode") or ""), ) @@ -464,6 +483,7 @@ def initialize(self, username, password, automatic=True, wallbox_control=False, self.paused_by_predbat = set() self.control_windows = {} self.lock_warned = set() + self.ocpp_warned = set() self.control_state_loaded = False self.control_enabled = True self.transport = None @@ -642,7 +662,19 @@ async def publish_data(self): for charger in self.ordered_chargers(): prefix = self.entity_prefix(charger) status_attributes = self.charger_attributes(charger, "status") - status_attributes.update({"status_id": charger.status_id, "name": charger.name, "serial_number": charger.serial, "part_number": charger.part_number, "software_version": charger.software_version, "operation_mode": charger.operation_mode}) + status_attributes.update( + { + "status_id": charger.status_id, + "name": charger.name, + "serial_number": charger.serial, + "part_number": charger.part_number, + "software_version": charger.software_version, + "operation_mode": charger.operation_mode, + "timezone": charger.timezone, + "country": charger.country, + "zipcode": charger.zipcode, + } + ) current_attributes = self.charger_attributes(charger, "max_charging_current") current_attributes["max"] = charger.max_available_current if charger.max_available_current >= MIN_CHARGING_CURRENT else DEFAULT_MAX_CHARGING_CURRENT @@ -669,11 +701,29 @@ def held_by_lock(self, charger, once=False): self.lock_warned.discard(charger.charger_id) return False if not once or charger.charger_id not in self.lock_warned: - self.log("Warn: wallbox: {} is locked, so Wallbox will refuse to pause or resume it. Unlock it with switch.{}_locked or in the Wallbox app; Predbat does not unlock chargers itself".format(charger.name, self.entity_prefix(charger))) + if charger.ocpp: + # Seen live: an unlock is accepted but has no effect while the backend holds the charger + self.log("Warn: wallbox: {} is locked by its OCPP backend, so Wallbox will refuse to pause or resume it, and unlocking it from Predbat or the Wallbox app has no effect".format(charger.name)) + else: + self.log("Warn: wallbox: {} is locked, so Wallbox will refuse to pause or resume it. Unlock it with switch.{}_locked or in the Wallbox app; Predbat does not unlock chargers itself".format(charger.name, self.entity_prefix(charger))) if once: self.lock_warned.add(charger.charger_id) return True + def held_by_ocpp(self, charger): + """Is an OCPP backend running the charger, which rules out Predbat-led charging. Says so once. + + The backend decides when the car charges, so Predbat's plan must not pause or resume + it. The warning is given once per charger and again only after it has left OCPP mode. + """ + if not charger.ocpp: + self.ocpp_warned.discard(charger.charger_id) + return False + if charger.charger_id not in self.ocpp_warned: + self.log("Warn: wallbox: {} is run by an OCPP backend, so Predbat-led charging is disabled for it. The backend decides when it charges".format(charger.name)) + self.ocpp_warned.add(charger.charger_id) + return True + def charger_for_entity(self, entity_id): """Find the charger an entity belongs to, or None. @@ -886,6 +936,8 @@ async def control_charge(self, now): if charger.status_id in UNPLUGGED_STATUS_IDS: self.paused_by_predbat.discard(charger.charger_id) continue + if self.held_by_ocpp(charger): + continue if not charger.connected: # Updating, Error or Unknown: say nothing, and keep the record until the charger reports again continue diff --git a/docs/components.md b/docs/components.md index e95178ab6..fe28ec8c9 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1058,7 +1058,7 @@ For each charger, with `` being the Wallbox charger id: | Entity | Description | | ------ | ----------- | -| `sensor.predbat_wallbox__status` | Charger status, e.g. `Charging`, `Paused`, `Waiting for car demand`, `Ready`. Its `operation_mode` attribute reads `ocpp` when an OCPP backend, such as Octopus for Intelligent Octopus Go, is running the charger | +| `sensor.predbat_wallbox__status` | Charger status, e.g. `Charging`, `Paused`, `Waiting for car demand`, `Ready`. Its `operation_mode` attribute reads `ocpp` when an OCPP backend, such as Octopus for Intelligent Octopus Go, is running the charger. The `timezone`, `country` and `zipcode` attributes give the charger's location as set in the Wallbox app | | `sensor.predbat_wallbox__power` | Charging power in W | | `sensor.predbat_wallbox__session_energy` | Energy added in the current session in kWh; resets with each session | | `binary_sensor.predbat_wallbox__connected` | On while a car is plugged in | @@ -1095,6 +1095,7 @@ Things to know: - Wallbox can only pause a charger once it is charging, so a car plugged in outside a slot draws power until Predbat's next poll (two minutes by default). - Clear any schedule set in the Wallbox app. A charger that Wallbox is holding in `Scheduled` will not charge in a Predbat slot. - While Predbat-led charging is on, Predbat resumes a paused charger inside a planned slot even if you paused it yourself. Turn `switch.predbat_wallbox_control` off first if you want it to stay paused. +- Predbat-led charging is disabled for a charger run by an OCPP backend (its status sensor's `operation_mode` reads `ocpp`). The backend decides when that charger charges, so Predbat leaves it alone and logs a warning once. Other chargers on the account are still controlled. - Predbat never unlocks a locked charger, and cannot pause or resume one. While a charger is locked Predbat-led charging leaves it alone and logs a warning once. A charger Predbat had paused is resumed once you unlock it. - Some cars go to sleep when charging is paused and do not wake when it is resumed. If yours does, Predbat-led charging will not suit it. diff --git a/docs/superpowers/specs/2026-10-05-wallbox-component-design.md b/docs/superpowers/specs/2026-10-05-wallbox-component-design.md index e5b878d0f..4dc9b8cac 100644 --- a/docs/superpowers/specs/2026-10-05-wallbox-component-design.md +++ b/docs/superpowers/specs/2026-10-05-wallbox-component-design.md @@ -67,7 +67,8 @@ with nothing paused. | `config_data.max_charging_current` | Amps | | `config_data.locked` | Lock state | | `config_data.serial_number`, `.part_number`, `.software.currentVersion` | Identity | -| `config_data.operation_mode` | `ocpp` when an OCPP backend runs the charger; published as an attribute of the status sensor. Seen live: such a charger stays Locked and an API unlock has no effect | +| `config_data.operation_mode` | `ocpp` when an OCPP backend runs the charger; published as an attribute of the status sensor. Seen live: such a charger stays Locked and an API unlock has no effect. Plan-led control skips a charger in this mode, with one warning | +| `config_data.timezone`, `.country.iso2`, `.zipcode` | Location, published as attributes of the status sensor | | `config_data.ecosmart.enabled`, `.mode` | Absent when the charger has no Eco-Smart; mode 0 is eco, 1 is full solar | The payload has a `state_of_charge` field, but a Type 2 connector cannot report the car's From def2afc7d0d56c612afcf7f660ac3d298709ce55 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 18:27:57 +0100 Subject: [PATCH 20/24] feat(wallbox): publish the latest firmware version and whether an update is available Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 22 ++++++++++++++++++++++ apps/predbat/wallbox.py | 7 +++++++ docs/components.md | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index 93fee1931..f3d97168d 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -1519,6 +1519,27 @@ def test_location_and_timezone_are_read_and_published(): print(" ✓ Timezone, country and postcode are read and published") +def test_software_versions_are_read_and_published(): + """The installed and latest firmware versions, and whether an update is waiting, are on the status sensor.""" + charger = normalise_charger(101, MOCK_STATUS_LOCKED_CAPTURED) + assert charger.software_version == "6.7.43" and charger.software_latest_version == "6.7.43" + assert charger.software_update_available is False + + behind = _status() + behind["config_data"]["software"] = {"currentVersion": "6.7.43", "latestVersion": "6.8.1", "updateAvailable": True} + charger = normalise_charger(101, behind) + assert (charger.software_version, charger.software_latest_version, charger.software_update_available) == ("6.7.43", "6.8.1", True) + + bare = normalise_charger(101, {"config_data": {"software": None}}) + assert (bare.software_version, bare.software_latest_version, bare.software_update_available) == ("", "", False) + + component = _make_component({101: behind}) + run_async(component.run(0, True)) + attributes = component.base.entities["sensor.predbat_wallbox_101_status"]["attributes"] + assert attributes["software_version"] == "6.7.43" and attributes["software_latest_version"] == "6.8.1" and attributes["software_update_available"] is True + print(" ✓ Firmware versions and update availability are read and published") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -1604,5 +1625,6 @@ def test_wallbox(my_predbat=None): test_control_resumes_once_ocpp_is_turned_off() test_lock_warning_names_ocpp_when_it_holds_the_lock() test_location_and_timezone_are_read_and_published() + test_software_versions_are_read_and_published() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index f9f19144a..617d125d8 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -171,6 +171,9 @@ class WallboxCharger: timezone: str = "" country: str = "" zipcode: str = "" + # The newest firmware Wallbox offers this charger, and whether it is newer than the one installed + software_latest_version: str = "" + software_update_available: bool = False @property def ocpp(self): @@ -230,6 +233,8 @@ def normalise_charger(charger_id, payload): timezone=str(config.get("timezone") or ""), country=str(country.get("iso2") or country.get("code") or ""), zipcode=str(config.get("zipcode") or ""), + software_latest_version=str(software.get("latestVersion") or ""), + software_update_available=bool(software.get("updateAvailable")), ) @@ -669,6 +674,8 @@ async def publish_data(self): "serial_number": charger.serial, "part_number": charger.part_number, "software_version": charger.software_version, + "software_latest_version": charger.software_latest_version, + "software_update_available": charger.software_update_available, "operation_mode": charger.operation_mode, "timezone": charger.timezone, "country": charger.country, diff --git a/docs/components.md b/docs/components.md index fe28ec8c9..c6852041a 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1058,7 +1058,7 @@ For each charger, with `` being the Wallbox charger id: | Entity | Description | | ------ | ----------- | -| `sensor.predbat_wallbox__status` | Charger status, e.g. `Charging`, `Paused`, `Waiting for car demand`, `Ready`. Its `operation_mode` attribute reads `ocpp` when an OCPP backend, such as Octopus for Intelligent Octopus Go, is running the charger. The `timezone`, `country` and `zipcode` attributes give the charger's location as set in the Wallbox app | +| `sensor.predbat_wallbox__status` | Charger status, e.g. `Charging`, `Paused`, `Waiting for car demand`, `Ready`. Its `operation_mode` attribute reads `ocpp` when an OCPP backend, such as Octopus for Intelligent Octopus Go, is running the charger. The `timezone`, `country` and `zipcode` attributes give the charger's location as set in the Wallbox app. `software_version`, `software_latest_version` and `software_update_available` show the installed firmware and whether Wallbox has a newer one | | `sensor.predbat_wallbox__power` | Charging power in W | | `sensor.predbat_wallbox__session_energy` | Energy added in the current session in kWh; resets with each session | | `binary_sensor.predbat_wallbox__connected` | On while a car is plugged in | From 96876eb3bce3dbaa51887969430133cbb91178fd Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 18:29:42 +0100 Subject: [PATCH 21/24] docs(wallbox): setup steps, command line usage and debug journal findings Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/components.md | 38 ++++++++++++++++++++++++++++++++++++++ tools/debug-journal.md | 1 + 2 files changed, 39 insertions(+) diff --git a/docs/components.md b/docs/components.md index c6852041a..3adfde8a5 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1052,6 +1052,44 @@ Note `--stop-charge` leaves the Zappi stopped, so remember to `--release` it aft Talks directly to the Wallbox cloud, so it works with or without Home Assistant. It monitors every charger on your Wallbox account, can register them with Predbat as cars, and can pause and resume them from Predbat's car charging plan. +#### Setting it up (wallbox) + +1. You need the email address and password of your Wallbox account, the ones you use in the Wallbox app or at . Predbat signs in with a password, so an account that only signs in through Google or Apple needs a password set first. +2. Put the two values in `secrets.yaml`: + + ```yaml + wallbox_username: "you@example.com" + wallbox_password: "your-wallbox-password" + ``` + +3. Add the component to `apps.yaml`: + + ```yaml + wallbox_username: !secret wallbox_username + wallbox_password: !secret wallbox_password + ``` + +4. Restart Predbat. Within a minute the entities below appear for every charger on the account, and the log shows the **car_charging_** settings Predbat has made for you. If sign in fails, the log says `wallbox: sign in failed` and the component shows as failed. +5. Decide who controls charging: + + - **Monitoring only** (the default). Predbat sees the charger and removes car charging from its house load, but something else decides when the car charges: the Wallbox app's schedule, your car, or your energy supplier. + - **Predbat-led charging.** Add `wallbox_control: True` and Predbat pauses and resumes the charger from its own car charging plan. See [Predbat-led charging](#predbat-led-charging-wallbox) below. + - **Octopus Intelligent Go, or any other OCPP backend.** Leave `wallbox_control` off. The backend controls the charger, and Predbat takes the car's charging slots from the Octopus integration as usual. + +If you previously pointed **car_charging_energy**, **car_charging_planned** or **car_charging_now** at the Home Assistant Wallbox integration's sensors, you can remove those lines: with `wallbox_automatic` on, Predbat uses its own Wallbox entities and logs a note when it replaces a setting of yours. + +#### Trying it from the command line (wallbox) + +You can check your credentials and see exactly what Predbat would publish without changing your configuration. From the `apps/predbat` directory: + +```bash +python3 wallbox.py --username you@example.com --password your-wallbox-password +``` + +This signs in, runs one cycle and prints every entity, the **car_charging_** settings it would make, and a summary line per charger. Add `--raw` to print each charger's full status as Wallbox sends it, which is the most useful thing to attach to a bug report; remove serial numbers and names first. + +To try a control by hand, add one of `--pause`, `--resume`, `--resume-schedule`, `--lock`, `--unlock`, `--max-current ` or `--eco-smart `, with `--charger ` to choose a charger other than the first. The charger's state is printed again a few seconds later so you can see whether it took effect. + #### What it publishes (wallbox) For each charger, with `` being the Wallbox charger id: diff --git a/tools/debug-journal.md b/tools/debug-journal.md index 507efb89e..b94f7bb8d 100644 --- a/tools/debug-journal.md +++ b/tools/debug-journal.md @@ -117,6 +117,7 @@ Grep for the named symbol rather than trusting a line number. | Grid sign / arrow direction | `grid_power_invert` is owned by some integrations and not others. With two systems configured, one integration setting it `True` bleeds into the other's entities and inverts the arrows. The fix is an explicit `False` in the automatic config of both. | `sunsynk_config`, `teslemetry` | | Enphase (`enphase.py`) | Unofficial Enlighten endpoints. Accounts with MFA cannot log in at all. Discharge-to-grid schedules are required for export control. Writes need a double-submit CSRF token or return 403. Using the Enphase app at the same time can trip session limits. | `enphase_api` | | myenergi (`myenergi.py`) | `myenergi_automatic_zappi` (PR #4997) gates the Zappi half of automatic config; with it off, Zappis stay monitor-only and `control_active` is never set. `release_zappis()` has exactly one caller — `control_tick()` — reached only under `if self.control_active:` in `run()`, and `control_active` is latched at the first tick: every `enable_control()` refusal (zappi_control → automatic → automatic_zappi → enable_controls) returns *before* setting it, and the gate re-runs only when a fresh instance starts. So after a component restart with any prerequisite off, a Zappi already held in 'Stopped' stays Stopped and the `switch.*_myenergi_zappi_control` entity is not even republished (it is gated on `control_active` too) — the symptom for a future report is "car won't charge after I turned off Predbat" with a Zappi. Note `control_tick()` itself *does* release on read-only mode and on the control switch being turned off; it is the config-gate half that strands. Since PR #5150 (merged 2026-09-19) sunsynk/deye instead persist `control_active` across a restart (and re-infer it from a pre-upgrade cache), so a restart cannot strand an already-armed inverter there — the myenergi strand is the opposite direction (the gate never latching in the first place) and remains. Two adjacent lifecycle facts: published entities are **never removed** — `dashboard_item()`/`set_state()` are upsert-only (`output.py`, `ha.py` POST `/api/states`), so when a gate stops `publish_data()` publishing a switch the old entity lingers frozen at its last state until HA restarts, and there is no entity-removal path anywhere in the codebase; and `set_arg`/`set_arg_auto` writes survive a component-only restart, because `Components.restart()` stops and re-creates only the instance and never resets the shared `base.args` from apps.yaml — auto-wired values (e.g. myenergi's `car_charging_*`) persist until a full process restart. Distinguish the two restarts when checking arg lifecycle. | `myenergi` | +| Wallbox (`wallbox.py`) | Cloud API reimplemented from the Home Assistant `wallbox` integration and the `wallbox` PyPI library, over aiohttp. Findings from the first live runs (5 Oct 2026, a Pulsar Plus on firmware 6.7.43): sign in works with `User-Agent: Predbat`. **A charger run by an OCPP backend** (here Octopus, for Intelligent Octopus Go) reports `config_data.operation_mode: "ocpp"`, sits in status 209 Locked, and an API unlock (`PUT v2/charger/` `{"locked": 0}`) is accepted but has no effect - so "the lock switch flips back" on such a charger is the backend, not a bug. On that locked charger a pause returned **HTTP 403** and a resume **HTTP 409**, from an account whose profile is super-admin: 403 on a control does *not* prove missing admin rights, whatever the HA integration assumes. `held_by_lock()` therefore sends neither while locked, and `held_by_ocpp()` keeps plan-led control off such a charger. `GET v3/chargers//ocpp-configuration` returns the backend address and the charge point credentials - **its reply contains a password, so never paste `--get` output unredacted**; no OCPP on/off write call is known. `locked` arrives as `1`/`0`, not a boolean, and `state_of_charge` is always null (a Type 2 connector cannot report it). Charger N is car N through `car_order`, which is numeric and append-only, so a status failure or a charger added later cannot shift it. Not yet verified on hardware when this was written: pause/resume on an unlocked, charging, non-OCPP charger; the real status ids for charging and paused; whether a 120s poll stays clear of HTTP 429. `python3 wallbox.py --raw` is the capture tool. | | Gateway MQTT (`gateway.py`) | Control writes are MQTT commands the hub acknowledges. **The ack design arrived with PR #5220 and was refined by PR #5231 (both merged 2026-09-26)** — both postdate every earlier gateway log, so keep a publish-per-call reading for pre-#5220 logs: `_subscribe_acks()` subscribes to `predbat/devices//ack/+`, tolerating a broker that refuses it (a single warn via `_ack_subscribe_warned`; without the subscription or before any ack, `_send_control()` publishes exactly as before); once acks are seen, an identical command (same entity, command, payload) is published once per `_COMMAND_ACK_WINDOW` (30s), re-sent once after the unanswered window, and if that re-send is also unanswered `_acks_seen` drops so writes fall back to publish-every-call until telemetry confirms; `_process_ack()` matches any tracked id of the current value, a refusal outranks an ok (a multi-unit dispatch acks once per unit), and replay errors are handled; ids come from a clock-seeded monotonic counter (`_next_command_id()`, `PBAT`), so they never restart at `PBAT1` after a restart, and the kept-id list (`_COMMAND_ACK_IDS_KEPT`) is pruned only after the new send is out, with an ack that arrives during a failing publish still counting. **PR #5172 is a separate, still-open implementation of the same feature** (`_subscribe_command_acks`, `uuid4().hex` ids, typed outcomes threaded through service dispatch/HTTP/inverter verifier) — its symbols are not on main until it rebases, so do not start a gateway-ack triage from its names. **Two gaps around serial-less and empty status, probed on main 2026-09-25 (GH#5227, enhancement — probed with temporary tests in `test_gateway.py`, reverted after):** (1) a sole slot publishing `serial=""` (shipped firmware 1.0.0 publishes every slot, including a GivEnergy slot whose serial discovery failed) is bound as the control target: `_needs_reconfigure()` treats `""` as a newly discovered inverter, the last-resort branch (`candidate_aios or list(all_inverters)`, `gateway.py`) binds it, and the result is empty-suffix entities (`select.predbat_gateway__charge_slot1_start`) and commands addressed to `""`, which the firmware rejects (`dongle_serial required`) — a real fleet incident on 2026-09-16 (~22 min of failed commands); an empty-serial guard in `_needs_reconfigure()`/`automatic_config()` would close it, and #5227's proposed `dongle_count` deferral does *not* cover this state (`dongle_count == len(inverters)` when the empty slot is the only one). (2) `if len(status.inverters) == 0: return` (`gateway.py`) fires **before** `_inject_entities()` (EV chargers, gateway-online) and before `_last_telemetry_time`/`update_success_timestamp()` — on a hub where all GivEnergy slots are withheld (post-predbat-gateway#335, e.g. a single-inverter hub), every status message is dropped: EV data freezes and the component fails the 60-minute staleness test (`components.py`). Symptom pointer: "gateway EV sensors froze / gateway component unhealthy while the hub is up" → this early return. Removals staying bound and reappearance triggering re-config are already the behaviour (probed). **Probe trap:** the topology branch in `automatic_config()` depends on the *whole* visible set — a probe that adds a Gateway alongside the `""` slot drops the `""` entry via the battery-presence filter (`aios` requires `battery.ByteSize() > 0`) so it never reaches the last-resort branch; construct the exact visible set the scenario implies, and note the filter silently hides data-less slots from the count in several branches (the same mechanism can make a Gateway+2-AIO fleet collapse to AIO-direct control). Existing precedent for topology probes: `TestGatewayUnitControlBinding` (`test_gateway.py`). | `gateway` | | Octopus (`octopus.py`, `fetch.py`) | Intelligent Go tariffs are detected via `is_intelligent_go_tariff()`, and IOG-prefixed tariffs must be skipped when updating intelligent devices. Saving-session auto-join rebinding regressed when `joined_events` was empty (GH#4573). `octopus_slots_signature()` deliberately omits the time-drifting fields of active dispatch slots so a replan is not forced every cycle. `car_charging_threshold` is a strict fallback gated on `not self.car_charging_energy` (`fetch.py:228`, and `load_ml_component.py:348-361`) — it never runs as a second filter alongside a real `car_charging_energy` sensor (GH#4717). Saving-session reporting credits the full saving rate to every minute of the session on both rate tables (`load_saving_slot()`, `octopus.py:2786`); the slot dict has no baseline field to subtract (GH#2090) — a complaint that a saving session's reported total looks inflated starts here, not in a rate-fetch bug. A tariff with no `standard_unit_rates` link (IOG-TOU, GO) goes down `async_get_day_night_rates()`, which infers the off-peak window from 7 days of measurement TOU labels by plurality vote. On IOG those labels include ad-hoc dispatch slots, so a bonus slot recurring on 4+ of 7 nights became a permanent nightly cheap window and Predbat charged into it at the day rate (PR #4854 skips the inference for IOG). A "cheap slot Octopus has never heard of" report where the dispatch feeds are *empty* is this, not phantom dispatches — check the log for `Using off-peak windows [...] from measurement TOU labels`. TOU labels are UTC instants, so windows derived from them must be re-anchored for a local wall-clock tariff or they drift an hour at DST. Free-session slots have their own family of gates, separate from the saving-session ones and each found the hard way. `octopus_free_session` events used to be dropped when `code` was null, which is exactly how Octopus publishes auto-joined Weekend Happy Hours - fixed, the gate is now `start and end` and the log falls back to the event id (GH#4835). The free/saving-session args name **event** entities (`attribute="events"`/`"joined_events"`, `octopus.py`), and the BottlecapDave integration exposes Octoplus sessions as calendar + event pairs of which only the event entity carries those state attributes - the calendars have none at all, and both sides ship **disabled by default** in the integration. "The documented sensor name does not exist" plus a proposal to point at the `calendar.` twin is almost always the disabled-default trap, not a rename (GH#5370, verified against the integration's source): enable the event entity; the calendar twin cannot back these args. Matching calendar/event names differ by the `_events` suffix, so a pattern built from one domain's names mistranslates, and the deprecated pre-v17 names are scheduled for removal in January 2027. `load_free_slot()` bounded itself with `start_minutes < self.forecast_minutes` while rate minutes are indexed from `midnight_utc`, so any event more than `forecast_minutes` past midnight was dropped with no log line at all - fixed in `3bacc6e8`, the gate and the end clamp now both use `forecast_minutes + minutes_now` like `load_saving_slot()` (GH#4931). In the same function the start/end range was only updated on a successful decode while the apply block ran unconditionally, so an undecodable slot re-applied its rate over the *previous* slot's minute range (PR #4935). The join side has its own: the `joined_events` guard still ends in `saving_rate > 0` (`octopus.py:3542`), so `octopoints_per_kwh: 0` - a genuine free hour - yields no slot of any kind; probe-verified (0 gives nothing, 500 gives a saving slot, null gives nothing), and `git blame` puts that guard in `1b8c9136`, the fix for null-rate sessions (GH#3079), so it is an oversight rather than a deliberate exclusion (GH#4851). On dispatches, `rate_add_io_slots()`'s `location` check used to apply to *completed* dispatches too, and `rate_import` is rebuilt every cycle, so a retroactive AT_HOME to AWAY relabel silently un-stamped the cheap rate and `today_cost()` re-priced the whole day at the day rate (GH#4946) - **fixed in PR #4957**, which added `dispatch_billed_off_peak()` (`octopus.py:2977`): a completed dispatch is billed off-peak regardless of location, a straddling one keeps the location test, and the midday budget cap still applies. Keep the mechanism in mind when reading a log from before that merge, where the whole day reprices at the day rate. A neighbouring cap bug had no entry here at all: the daily low-rate slot budget was keyed on the *loop* minute rather than the slot start, so a window straddling noon drew a fresh 12-slot budget at 12:00 and stamped the afternoon cheap (GH#4950, **fixed in PR #4951**). The midday boundary itself is deliberate (`337db867`); only the keying was wrong. Worth knowing because "a cheap slot at an hour Octopus never offered" has at least three distinct causes in this row alone. Compare has its own rate-fetch gap: `download_octopus_rates_func()` (`octopus.py:2725`) reads only `standard-unit-rates`, and the newer IOG-SMB-FIX products are `four_rate_ev` - that endpoint answers 200 with empty results and the rates live on `day-unit-rates`/`night-unit-rates`. The main OctopusAPI component already auto-detects that product shape; compare never got the same treatment (GH#4921, probed against the live API). Lastly, `{dno_region}` in a tariff URL is substituted by `resolve_arg`'s `.format(**self.args)` (`userinterface.py:106-116`), so a missing `-` before the placeholder glues the region letter onto the product code and yields a plausible-looking 404 - check the literal URL in apps.yaml before believing a tariff has been withdrawn. Two later additions to the free/saving-session picture, one of them a correction. **eventType is carried only by the legacy `savingSessions` query path — the flexibility feed drops it (GH#4548, re-verified on main 2026-09-29 by reading `async_get_flexibility_events()`):** that function extracts only `code`/`startAt`/`endAt` from `customerFlexibilityCampaignEvents` and maps every saving event into `joinedEvents` with **no eventType**, so on the Direct path with an MPAN the joined-event loop's free-slot branch (`event_type == "WEEKEND_HAPPY_HOUR"`) can never fire and a joined Happy Hour falls through to the rewarded-saving-slot branch whenever `octopus_saving_session_rate` is set above 0. The legacy query carries eventType (the `savingSessions` GraphQL request names it and its map stores it) and is reached on the Direct path only as the flexibility path's own fallback (no MPAN, or the flexibility API returned no saving events). eventType remains the only sound discriminator between a Power Down saving session and a free Power Up/Happy Hour — the field GH#4851's `saving_rate > 0` problem needed and did not have — but *which query path served the events* decides whether you have it: "free Power Up priced as a paid saving session" on the flexibility path is first a question of provenance, and waiting for the BottlecapDave API before building on the newer feed is deliberate, not an oversight. The Direct join mutation is likewise still the legacy `joinSavingSessionsEvent`. And a genuinely counter-intuitive ordering constraint, worth reading before touching that function: Weekend Happy Hours are now skipped from `available_events` (they cannot be joined through the API - Octopus allocates them or the user books on the website), but **the skip has to sit after the reward/code/type maps are populated**, because those maps are built from the same events list and a joined Happy Hour looks its own type up there. Skip too early and the joined event has no type, takes the injected default reward, and the planner prices a free hour as an **80p/kWh saving session** (`c0fb4e9c`; the test fails if the skip is moved above the maps). Two rate-provenance reports from mid-September. **GH#5012** (car unplugged, plan still charged the house battery in the phantom cheap window): `octopus_intelligent_ignore_unplugged` correctly removed the planned dispatches, but the cheap price came from the integration's own rate sensor, not Predbat's stamping — parse the debug yaml and compare, per suspect minute, `rate_import_base` vs `rate_import_no_io` vs `io_adjusted`: a cheap price present in base and no_io with `io_adjusted` empty is the integration's rate sensor; present only after no_io is Predbat's `rate_add_io_slots()` stamping (#4516/#4950 family); `io_adjusted` populated means the minutes were flagged as adjusted — but **since PR #5304 (v9.3.3) Predbat's own `rate_add_io_slots()` flags the minutes it lowers too** (it used to write no marker at all; it mirrors the integration's `is_intelligent_adjusted`, never the fixed off-peak, time over the daily cap, or a cancelled car), so a populated `io_adjusted` no longer discriminates the integration's stamp from Predbat's own; the base-vs-`no_io` comparison still does, and `rate_replicate()` still refuses to copy `io_adjusted`-flagged minutes into future days. This attributes "integration feeds the wrong price" vs "Predbat stamps the wrong price" in one step. Nothing reverts `io_adjusted`-flagged minutes when the car is unplugged + ignore_unplugged is on — that fix would be an enhancement, blocked in practice by the integration not flagging stale adjusted entries (`io_adjusted` was empty in this dump). **`io_adjusted` itself was for a while wiped by the export/gas fetch (GH#5286, fixed in PR #5290, merged 2026-09-28):** `fetch_octopus_rates()` replaced `self.io_adjusted` on every call, so the export and gas fetches that follow the import fetch reset it to `{}` whenever `metric_octopus_export`/`metric_octopus_gas` were set — regression from #2826 dropping the old "only when adjust_key is set" guard — and with no markers left, `dynamic_load_car_strip_feed_rates()` had nothing to strip, so a car cancelled out of a dispatch still left it priced cheap and the house battery planned into it. The fix makes `self.io_adjusted` replaced only when the fetch carries an `adjust_key`; keep the mechanism for pre-#5290 logs. A companion fix (same PR) stops a compared tariff inheriting the live tariff's dispatch markers — each compared tariff now starts with none, and one left on the live import rates gets a copy. A user who instead wants the house battery limited to minutes the car actually charges has no clean knob (GH#5065): `octopus_intelligent_charging` off does **not** stop the stamping — the switch gates only car planning and vehicle prefs, planned slots are collected into `self.octopus_slots` regardless (gated only by `octopus_intelligent_ignore_unplugged`, which covers unplugged, not plugged-in-but-deferred) and are consumed unconditionally by `rate_add_io_slots()`. `octopus_slot_max: 0` is not selective either — the cap is applied in `load_octopus_slots()` as well as `rate_add_io_slots()`, so it removes the car's charging slots too. The only complete workaround is unsetting `octopus_intelligent_slot`, which loses completed-slot tracking and Octopus car planning with it. **GH#5018** (tariff switched mid-day; tomorrow's export rates stayed on the old tariff and Nordpool estimates never appeared): Predbat re-reads Octopus rate events from HA every fetch cycle (`fetch_octopus_rates`), so stale export rates are the HA integration still serving the old contract — reload the integration. Two Predbat-side traps in the same report: `futurerate` only fills *missing* minutes (the `if minute not in rates` gate in fetch.py), so stale-but-present rates always win over Nordpool estimates; and `futurerate_adjust_auto` re-runs at every FutureRate init (one per fetch cycle) and persists via `set_arg`, so mid-switch it can persist `False` over the user's manual `futurerate_adjust_export: true` — log signature `FutureRate: No futurerate adjustment enabled, skipping futurerate analysis` repeating every cycle. The debug yaml does **not** contain the Octopus rate events (grep for `day_rates` comes up empty), so replays can't reproduce integration-served rate data; use the log's `Export rates: min/max/average` lines as the provenance check (identical min/max across days = static served data, and a fixed tariff has a fixed shape while a variable one doesn't). `self.mpan` is the **import** MPAN only — `async_find_tariffs()` sets it once from the first active import agreement's `meterPoint` and never overwrites it; an account with export has import and export as separate agreements with different MPANs, and the export one is retained nowhere (PR #4972 review). The saving-session/free-electricity GraphQL queries use `self.mpan` deliberately because those campaigns are import-side; any feature that needs to identify a *meter* cannot take `self.mpan` (PR #4972, merged 2026-09-19, adds `self.tariffs[direction]["mpan"]`). Symptom: two things that should describe different supply points coming out identical — that is this, not a deduplication bug. **GH#5144** (fixed in PR #5145): the REST tariff endpoints return overlapping `DIRECT_DEBIT`/`NON_DIRECT_DEBIT` rows for the same validity window and `minute_data()` writes each row over its range, so whichever row came last in the response won — and the order is not stable across periods, so the displayed rate could flip between variants from one period to the next ("always the higher rate" was an artefact of that ordering, not a property of the bug). `filter_payment_method()` (`utils.py`) now keeps one variant at the parse point on the component, day/night, annual and minute-data paths — preferring `DIRECT_DEBIT`, keeping rows with no `payment_method` (Agile) untouched, and leaving single-variant tariffs unchanged; null `payment_method` is the common case, so any future filter must keep nulls. **The daily cheap-slot cap is derived per account but enforced per car (GH#5215, semantics unresolved):** `get_octopus_slot_max()` (`octopus.py`) takes no `car_n` — it resolves the cap from the *account's* import tariff code via `has_six_hour_cap()` (matches `IOG-SMB` → 12) or a single apps.yaml integer — while both enforcement counters are locals (`slots_per_day` in `rate_add_io_slots()` and in `load_octopus_slots()`), and both callers run once per car, so each car draws a fresh budget: up to `octopus_slot_max × num_cars` cheap half-hours priced per day. Do **not** assume the docs' per-car sentence is simply wrong: the derivation is account-level but Octopus's own blog says "*Your car gets up to 6 hours of off-peak charging per day*" (quoted in GH#4830, the multi-car confusion thread), so the code is internally inconsistent and the intended semantics is genuinely open. Exposure is invisible outside IOG-SMB — uncapped tariffs default `octopus_slot_max` to 48 — so only capped multi-car installs can see the doubling; existing tests encode the per-car behaviour, and `octopus_slot_max` is read without `index=` so per-car configuration does not exist either way. **The IOG slot-confirmation strip removes the house's cheap rate too when a car's dispatch is cancelled (GH#5335, with the #5317 family - code chain on main 57ec7bf1):** with `octopus_intelligent_dynamic` on (default), `dynamic_load_car_check()` (`plan.py`) cancels every slot of a car that sits inside a started dispatch but is not seen charging after the confirmation grace (log: `car 0 is in a dispatch but not charging, cancelling its slots`), and `dynamic_load_car_strip_feed_rates()` (`octopus.py`) re-prices the house's view of those minutes back to `rate_max_base` when the cheapness came from the integration feed, **exempting the fixed IOG band** (`OCTOPUS_NIGHT_RATE_WINDOWS["iog"]` = 23:30–05:30) — so a house battery that had planned into the dispatch loses its cheap stamp as well, and PV10 re-prices the minutes at `rate_max` outright (prediction.py's `dispatch_gone` line, `minute > 30`). Version tell: the strip is v9.3.2+ code — a reporter's deliberate mid-log downgrade left the A/B in one `predbat.log`: v9.3.3 logged `Octopus Intelligent: removed the dispatch rate from 355 minutes of cars [0] which are not charging` while v9.3.1 kept the 6.57p dispatch rate (`Charging target 2%-100%`); workaround (semantics read, not live-tested): `octopus_intelligent_dynamic` off = no confirmation and no strip, ≈9.3.1 behaviour at the cost of #5229's low-load protection. Check the car side first, though: Predbat must *see* the car charging to confirm the slot, and a `plug_status` string (`eco`/`boost`) that never equals `car_charging_now_response: charging` leaves the car "not charging" through a real charge — check the sensor's own history before blaming the planner. **#5316 is the mid-dispatch variant, fixed in PR #5319 (merged 2026-10-03) — keep the pre-#5319 signature:** a car that stops part-way through a running dispatch half hour used to have the rest of that half hour re-priced at the day rate; the strip paths (`rate_add_io_slots()`, `dynamic_load_car_strip_feed_rates()`) now start from `dynamic_load_car_strip_from()` (`plan.py`) — the end of the half hour the car was last seen charging in, which is billed off-peak in full by Octopus — and the confirmation is never cleared, only outlived. Review-round refinements ride along: a sensor reading confirms a half hour only from 2 minutes into it, and the grace clock is not kept across a restart (saved state is judged on the skewed clock). | `octopus_*`, `saving_session*` | | Kraken / EDF (`kraken.py`) | EDF Kraken answers a day/night-structured tariff with **HTTP 400** on `standard-unit-rates/` (`{"detail": "This tariff has day and night rates, not standard."}`) while `day-unit-rates`/`night-unit-rates` return 200 and standing charges 200 — where Octopus four-rate products (GH#4921) answer the same-shaped URL **200 with empty results**. So the "empty results, then data empty" signature belongs to `octopus.py` and a hard 400 to `kraken.py`; do not carry the expectation across (GH#5166, probed against the live API). A nonexistent product still 404s, so 404 and 400 are both live "REST can't serve this tariff" signals on EDF — probe the exact URL from the reporter's log before assuming either. **Fixed in PR #5167** (`async_fetch_rates()`): the GraphQL `applicableRates` fallback now gates on `KRAKEN_REST_RATES_UNAVAILABLE_STATUSES` = (400, 404, 410), per direction (404 covers both a private product and one retired from the REST API; the authenticated retry stays 404/410 because auth cannot change a 400), and only counts a failure when the fallback comes back empty without counting one — `async_graphql_query()` counts its own failures, so a caller that also counts scores 2 per down cycle; snapshot the counter around the call. The standing-charge fallback stays deliberately 404/410-only (`kraken.py` — "400 is a rates-endpoint-only answer"). Two traps from the fix's review: `_fetch_rates_rest()` returns `(None, None)` on a network error, so `err is None` does **not** mean success — success is `(results, None)`; the merged code stashes the public attempt's status and restores it when the authenticated retry hits a network error (pre-fix symptom: a private-product tariff's rates vanish for one cycle with no fallback log line, while an identical 404 one cycle later recovers). The day/night endpoints' rows carry the same `payment_method` variant overlap as GH#5144 — suspected, no probe; check `minute_data`'s handling before consuming them directly. Also benign: `Warn: Kraken: Auth not available for find-tariffs` repeating on early restarts before the first `Tariff discovered` line is setup-phase, not a second bug. | `kraken` | From 42ed6e62107008735d14b875e784c28b521a2737 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 19:42:02 +0100 Subject: [PATCH 22/24] fix(wallbox): address review - release bookkeeping, per-charger isolation, CLI password prompt From the Copilot review of #5410: - release keeps a paused charger on its list until a fresh status settles it; a charger that could not be read, or reports Updating, Error, Unknown or locked with a car, stays pending - a refused or failed command on one charger no longer stops the control or release loop for the others; rate limit and sign in errors still reach run() - the paused list is stored before a pause is sent, a failed save is retried, and an empty read from Storage is retried a few times - a schedule restoration that fails after a successful resume is tracked and retried - the command line mode prompts for the password when --password is omitted Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 188 +++++++++++++++++++++++++-- apps/predbat/wallbox.py | 195 +++++++++++++++++++++-------- docs/components.md | 4 +- tools/debug-journal.md | 2 +- 4 files changed, 329 insertions(+), 60 deletions(-) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index f3d97168d..70275890f 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -1149,7 +1149,7 @@ def test_control_state_survives_a_restart(): storage = _Storage() component = _control_component({0: PLAN_OUTSIDE}, storage=storage) run_async(component.control_tick(CONTROL_NOW)) - assert storage.data[("wallbox", "control_state")] == {"control_enabled": True, "paused": ["101"]} + assert storage.data[("wallbox", "control_state")] == {"control_enabled": True, "paused": ["101"], "schedule": []} restarted = _control_component({0: PLAN_OUTSIDE}, {101: _status(status_id=178, power=0)}, storage=storage, wallbox_control=False) assert restarted.control_active is False and restarted.paused_by_predbat == {"101"} @@ -1262,12 +1262,8 @@ def test_control_records_a_pause_whose_response_was_lost(): """A pause that fails after Wallbox applied it is still Predbat's to release.""" component = _control_component({0: PLAN_OUTSIDE}) component.transport.errors[("pause", 101)] = WallboxApiError("timed out") - try: - run_async(component.control_tick(CONTROL_NOW)) - raise AssertionError("Expected WallboxApiError") - except WallboxApiError: - pass - assert component.paused_by_predbat == {"101"} + run_async(component.control_tick(CONTROL_NOW)) + assert component.paused_by_predbat == {"101"} and _logged(component, "charge control failed") component.transport.statuses[101] = _status(status_id=178, power=0) _load_chargers(component) @@ -1540,6 +1536,176 @@ def test_software_versions_are_read_and_published(): print(" ✓ Firmware versions and update availability are read and published") +def _restarted_with_control_off(storage, statuses): + """A fresh component, control not configured, sharing storage with an earlier session.""" + return _control_component({0: PLAN_OUTSIDE}, statuses, storage=storage, wallbox_control=False) + + +def _storage_with_paused(*charger_ids): + """Storage as an earlier session that paused these chargers would have left it.""" + storage = _Storage() + storage.data[("wallbox", "control_state")] = {"control_enabled": True, "paused": list(charger_ids), "schedule": []} + return storage + + +def test_release_keeps_a_charger_it_could_not_read(): + """After a restart, a paused charger whose status read failed stays on the list until it can be read.""" + storage = _storage_with_paused("101") + component = _restarted_with_control_off(storage, {202: _status(status_id=161, power=0)}) + component.update_car_order([101, 202]) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [] and component.paused_by_predbat == {"101"} + + component.transport.statuses[101] = _status(status_id=178, power=0) + _load_chargers(component) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("resume", 101), ("resume_schedule", 101)] + assert component.paused_by_predbat == set() + print(" ✓ Release keeps a charger it could not read") + + +def test_release_keeps_an_indeterminate_charger_and_forgets_a_settled_one(): + """Updating, Error, Unknown and locked-with-car stay pending on release; charging, waiting and unplugged are forgotten.""" + for status_id, locked in ((166, False), (14, False), (999, False), (210, 1)): + component = _restarted_with_control_off(_storage_with_paused("101"), {101: _status(status_id=status_id, power=0, locked=locked)}) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [] and component.paused_by_predbat == {"101"}, status_id + for status_id in (193, 177, 180, 161, 0): + component = _restarted_with_control_off(_storage_with_paused("101"), {101: _status(status_id=status_id, power=0)}) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [] and component.paused_by_predbat == set(), status_id + print(" ✓ Release keeps an indeterminate charger and forgets a settled one") + + +def test_one_chargers_failure_does_not_block_the_next(): + """A refused or failed command on the first charger still lets the second follow its plan and be released.""" + for error in (WallboxApiError("HTTP 500"), WallboxRefusedError("Wallbox refused the control (HTTP 403)")): + statuses = {101: _status(status_id=193), 202: _status(status_id=193)} + component = _control_component({0: PLAN_OUTSIDE, 1: PLAN_OUTSIDE}, statuses) + component.transport.errors[("pause", 101)] = error + run_async(component.control_tick(CONTROL_NOW)) + assert ("pause", 202) in component.transport.calls, component.transport.calls + + statuses = {101: _status(status_id=178, power=0), 202: _status(status_id=178, power=0)} + component = _restarted_with_control_off(_storage_with_paused("101", "202"), statuses) + component.transport.errors[("resume", 101)] = WallboxApiError("HTTP 500") + run_async(component.control_tick(CONTROL_NOW)) + assert ("resume", 202) in component.transport.calls and ("resume_schedule", 202) in component.transport.calls + assert component.paused_by_predbat == {"101"}, "The charger whose resume failed is still owed a release" + print(" ✓ One charger's failure does not block the next") + + +def test_account_wide_errors_still_stop_the_control_loop(): + """A rate limit is the whole account's problem, so it still reaches run() and starts the back-off.""" + component = _control_component({0: PLAN_OUTSIDE}) + component.transport.errors[("pause", 101)] = WallboxRateLimitError("429") + try: + run_async(component.control_tick(CONTROL_NOW)) + raise AssertionError("Expected WallboxRateLimitError") + except WallboxRateLimitError: + pass + print(" ✓ A rate limit still stops the control loop") + + +def test_pause_is_persisted_before_it_is_sent(): + """The charger id is in storage before the pause leaves, so a crash in between cannot strand it.""" + storage = _Storage() + component = _control_component({0: PLAN_OUTSIDE}, storage=storage) + seen = [] + real_pause = component.transport.pause + + async def recording_pause(charger_id): + """Note what storage held at the moment the pause was sent.""" + seen.append(list((storage.data.get(("wallbox", "control_state")) or {}).get("paused", []))) + return await real_pause(charger_id) + + component.transport.pause = recording_pause + run_async(component.control_tick(CONTROL_NOW)) + assert seen == [["101"]], seen + print(" ✓ A pause is persisted before it is sent") + + +def test_failed_control_state_save_is_retried(): + """A save that reports failure is not taken as done: it is tried again on the next cycle.""" + storage = _Storage() + results = [False, False] + real_save = storage.save + + async def flaky_save(module, filename, data, **kwargs): + """Report failure without storing, twice, then behave.""" + if results: + return results.pop() + await real_save(module, filename, data, **kwargs) + return True + + storage.save = flaky_save + component = _control_component({0: PLAN_OUTSIDE}, storage=storage) + run_async(component.control_tick(CONTROL_NOW)) + assert ("wallbox", "control_state") not in storage.data and _logged(component, "could not save") + component.transport.statuses[101] = _status(status_id=178, power=0) + _load_chargers(component) + run_async(component.control_tick(CONTROL_NOW)) + assert storage.data[("wallbox", "control_state")]["paused"] == ["101"] + print(" ✓ A failed control state save is retried") + + +def test_control_state_load_returning_nothing_is_retried_then_given_up(): + """Storage returns None for a failed read as well as a missing file, so a None is retried a few times.""" + storage = _storage_with_paused("101") + real_load = storage.load + answers = [None] + calls = [] + + async def flaky_load(module, filename): + """Return None once, as a failed read does, then the real data.""" + calls.append(filename) + if answers: + return answers.pop() + return await real_load(module, filename) + + storage.load = flaky_load + component = _make_component({101: _status(status_id=178, power=0)}, wallbox_control=False) + component.base.components = _Components(storage) + run_async(component.run(0, True)) + assert component.transport.count("resume") == 0 + run_async(component.run(120, False)) + assert component.transport.count("resume") == 1, "The saved charger was released once the read worked" + + empty = _Storage() + empty_calls = [] + + async def counting_load(module, filename): + """Always empty, counting the reads.""" + empty_calls.append(filename) + return None + + empty.load = counting_load + component = _make_component() + component.base.components = _Components(empty) + for cycle in range(12): + run_async(component.run(cycle * 60, cycle == 0)) + assert 1 < len(empty_calls) <= 5, "A file that is simply absent is not read forever: {}".format(len(empty_calls)) + print(" ✓ An empty control state read is retried a few times, then left") + + +def test_schedule_restoration_is_retried_after_a_failure(): + """If resume works but handing back the schedule fails, that step is retried even once the charger is charging.""" + storage = _storage_with_paused("101") + component = _restarted_with_control_off(storage, {101: _status(status_id=178, power=0)}) + component.transport.errors[("resume_schedule", 101)] = WallboxApiError("HTTP 500") + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("resume", 101), ("resume_schedule", 101)] + assert component.paused_by_predbat == set() and component.schedule_pending == {"101"} + assert storage.data[("wallbox", "control_state")]["schedule"] == ["101"], "The pending step survives a restart" + + component.transport.statuses[101] = _status(status_id=193) + _load_chargers(component) + run_async(component.control_tick(CONTROL_NOW)) + assert component.transport.calls == [("resume_schedule", 101)], component.transport.calls + assert component.schedule_pending == set() and storage.data[("wallbox", "control_state")]["schedule"] == [] + print(" ✓ Schedule restoration is retried after a failure") + + def test_wallbox(my_predbat=None): """Run every Wallbox test.""" print("=" * 70) @@ -1626,5 +1792,13 @@ def test_wallbox(my_predbat=None): test_lock_warning_names_ocpp_when_it_holds_the_lock() test_location_and_timezone_are_read_and_published() test_software_versions_are_read_and_published() + test_release_keeps_a_charger_it_could_not_read() + test_release_keeps_an_indeterminate_charger_and_forgets_a_settled_one() + test_one_chargers_failure_does_not_block_the_next() + test_account_wide_errors_still_stop_the_control_loop() + test_pause_is_persisted_before_it_is_sent() + test_failed_control_state_save_is_retried() + test_control_state_load_returning_nothing_is_retried_then_given_up() + test_schedule_restoration_is_retried_after_a_failure() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index 617d125d8..9d41b795e 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -23,6 +23,7 @@ import argparse import asyncio import base64 +import getpass import json import math import time @@ -73,6 +74,8 @@ WALLBOX_STORAGE_MODULE = "wallbox" WALLBOX_CONTROL_STATE = "control_state" +# How many cycles an empty answer from Storage is retried before the saved state is taken to be absent +CONTROL_STATE_LOAD_ATTEMPTS = 5 # Status id to (text, car connected). The text is the Home Assistant wallbox integration's # wording, so states match what existing users already see. A None status id means the @@ -490,6 +493,11 @@ def initialize(self, username, password, automatic=True, wallbox_control=False, self.lock_warned = set() self.ocpp_warned = set() self.control_state_loaded = False + self.control_state_load_attempts = 0 + self.control_state_dirty = False + # Chargers Predbat has resumed on release but not yet handed back to their own schedule + self.schedule_pending = set() + self.release_announced = False self.control_enabled = True self.transport = None @@ -829,35 +837,57 @@ def enable_control(self): self.log("Info: wallbox: Predbat-led charge control enabled") async def load_control_state(self): - """Restore the control switch and the list of chargers Predbat paused. - - The paused list is what lets a restart with control turned off still release a - charger an earlier session left paused. Fails soft with no Storage component. + """Restore the control switch and the chargers Predbat still owes a release. + + The saved lists are what let a restart with control turned off still release a + charger an earlier session left paused. Storage answers None for a failed read as + well as for a file that is not there, so a None is tried again on the next few + cycles rather than taken as "nothing saved"; after that the file is taken to be + absent. What is read is merged into what this session already knows, never + replacing it. Fails soft with no Storage component. """ - if self.control_state_loaded or self.storage is None: + if self.control_state_loaded or self.storage is None or self.control_state_load_attempts >= CONTROL_STATE_LOAD_ATTEMPTS: return + self.control_state_load_attempts += 1 try: saved = await self.storage.load(WALLBOX_STORAGE_MODULE, WALLBOX_CONTROL_STATE) except Exception as exc: self.log("Warn: wallbox: could not read the saved charge control state: {}".format(exc)) return - self.control_state_loaded = True if not isinstance(saved, dict): return + self.control_state_loaded = True if "control_enabled" in saved: self.control_enabled = bool(saved["control_enabled"]) - paused = saved.get("paused") - if isinstance(paused, list): - self.paused_by_predbat = {str(charger_id) for charger_id in paused} + for key, target in (("paused", self.paused_by_predbat), ("schedule", self.schedule_pending)): + values = saved.get(key) + if isinstance(values, list): + target.update(str(charger_id) for charger_id in values) async def save_control_state(self): - """Persist the control switch and the list of chargers Predbat paused.""" + """Persist the control switch and the chargers Predbat still owes a release. + + Returns True when the state is safely stored, or there is nowhere to store it. A + failed write leaves control_state_dirty set, so the next cycle tries again rather + than trusting a record that only exists in memory. + """ if self.storage is None: - return + self.control_state_dirty = False + return True + state = {"control_enabled": self.control_enabled, "paused": sorted(self.paused_by_predbat), "schedule": sorted(self.schedule_pending)} try: - await self.storage.save(WALLBOX_STORAGE_MODULE, WALLBOX_CONTROL_STATE, {"control_enabled": self.control_enabled, "paused": sorted(self.paused_by_predbat)}) + saved = await self.storage.save(WALLBOX_STORAGE_MODULE, WALLBOX_CONTROL_STATE, state) except Exception as exc: + saved = False self.log("Warn: wallbox: could not save the charge control state: {}".format(exc)) + else: + if saved is False: + self.log("Warn: wallbox: could not save the charge control state, will try again") + self.control_state_dirty = saved is False + if not self.control_state_dirty: + # What is on disk is now this session's state, so there is nothing older left to read + self.control_state_loaded = True + return not self.control_state_dirty def control_read_only_now(self): """Is Predbat in read only mode - the live attribute first, then the config argument.""" @@ -888,6 +918,8 @@ async def control_tick(self, now): Called on every poll whether or not control is available, because a charger a previous session paused must be released even when control has since been turned off. """ + if self.control_state_dirty: + await self.save_control_state() reason = None if not self.control_active: reason = "charge control is not enabled" @@ -896,34 +928,85 @@ async def control_tick(self, now): elif not self.control_enabled: reason = "the charge control switch is off" if reason: - if self.paused_by_predbat: - self.log("Info: wallbox: releasing the chargers because {}".format(reason)) + if self.paused_by_predbat or self.schedule_pending: + if not self.release_announced: + self.log("Info: wallbox: releasing the chargers because {}".format(reason)) + self.release_announced = True await self.release_chargers() return + self.release_announced = False + if self.schedule_pending: + # Predbat is in charge again, so there is no schedule to hand back + self.schedule_pending.clear() + self.control_state_dirty = True await self.control_charge(now) + async def per_charger(self, charger, action): + """Run one charger's control step so that its failure cannot stop the other chargers. + + A refusal or an API error belongs to this charger: it is logged and whatever was + owed stays owed. A rate limit or a sign in failure belongs to the whole account + and is left to reach run(). + """ + try: + await action + except WallboxRefusedError as exc: + self.warn_refused_once(exc) + except WallboxApiError as exc: + self.log("Warn: wallbox: charge control failed for {}: {}".format(charger.name, exc)) + async def release_chargers(self): """Resume every charger Predbat paused and hand each back to its own schedule. - A charger that is no longer paused - resumed by hand, or unplugged - is only - forgotten. The record is saved even if a call fails part way, so what was - released stays released and the rest is retried on the next poll. + A charger stays on the list until a fresh status either lets it be released or + shows there is nothing left to do. One that cannot be read, or reports Updating, + Error, Unknown or locked with a car, is neither: it is kept for the next poll. """ try: - for charger_id in sorted(self.paused_by_predbat): + for charger_id in sorted(self.paused_by_predbat | self.schedule_pending): charger = self.chargers.get(charger_id) - if charger and charger_id in self.stale_ids: + if charger is None or charger_id in self.stale_ids: continue - if charger and charger.paused and self.held_by_lock(charger, once=True): + await self.per_charger(charger, self.release_one(charger)) + finally: + if self.control_state_dirty: + await self.save_control_state() + + async def release_one(self, charger): + """Release one charger: resume it if it is still paused, then restore its schedule. + + The schedule step is tracked on its own, because a resume that worked followed by + a schedule call that failed leaves a charger that reports Charging and would + otherwise never be handed back. + """ + charger_id = charger.charger_id + api_id = self.api_id(charger) + if charger.status_id in UNPLUGGED_STATUS_IDS: + if charger_id in self.paused_by_predbat or charger_id in self.schedule_pending: + self.paused_by_predbat.discard(charger_id) + self.schedule_pending.discard(charger_id) + self.control_state_dirty = True + return + if charger_id in self.paused_by_predbat: + if charger.paused: + if self.held_by_lock(charger, once=True): # Cannot be resumed until it is unlocked, so it stays on the list - continue - if charger and charger.paused: - self.log("Info: wallbox: releasing {}".format(charger.name)) - await self.transport.resume(self.api_id(charger)) - await self.transport.resume_schedule(self.api_id(charger)) + return + self.log("Info: wallbox: releasing {}".format(charger.name)) + self.schedule_pending.add(charger_id) + self.control_state_dirty = True + await self.transport.resume(api_id) self.paused_by_predbat.discard(charger_id) - finally: - await self.save_control_state() + elif charger.connected and not charger.locked: + # Charging, Scheduled or Waiting: it is no longer paused, so nothing is owed + self.paused_by_predbat.discard(charger_id) + self.control_state_dirty = True + else: + return + if charger_id in self.schedule_pending: + await self.transport.resume_schedule(api_id) + self.schedule_pending.discard(charger_id) + self.control_state_dirty = True async def control_charge(self, now): """Drive each charger from its car's plan: resume a paused one inside a window, pause a charging one outside. @@ -933,37 +1016,47 @@ async def control_charge(self, now): """ if not self.refresh_car_windows(now): return - before = set(self.paused_by_predbat) try: for car_n, charger_id in enumerate(self.car_order): charger = self.chargers.get(charger_id) if charger is None or car_n not in self.control_windows or charger_id in self.stale_ids: continue wanted = in_car_plan_window(self.control_windows[car_n], now) - if charger.status_id in UNPLUGGED_STATUS_IDS: - self.paused_by_predbat.discard(charger.charger_id) - continue - if self.held_by_ocpp(charger): - continue - if not charger.connected: - # Updating, Error or Unknown: say nothing, and keep the record until the charger reports again - continue - if self.held_by_lock(charger, once=True): - continue - if wanted and charger.paused: - self.log("Info: wallbox: resuming {} for car {}".format(charger.name, car_n)) - await self.transport.resume(self.api_id(charger)) - self.paused_by_predbat.discard(charger.charger_id) - elif not wanted and charger.charging: - self.log("Info: wallbox: pausing {} for car {}".format(charger.name, car_n)) - # Recorded first: a pause whose reply is lost may still have been applied, - # and release ignores a recorded charger that turns out not to be paused - self.paused_by_predbat.add(charger.charger_id) - await self.transport.pause(self.api_id(charger)) + await self.per_charger(charger, self.control_one(car_n, charger, wanted)) finally: - if self.paused_by_predbat != before: + if self.control_state_dirty: await self.save_control_state() + async def control_one(self, car_n, charger, wanted): + """Apply the plan to one charger.""" + charger_id = charger.charger_id + if charger.status_id in UNPLUGGED_STATUS_IDS: + if charger_id in self.paused_by_predbat: + self.paused_by_predbat.discard(charger_id) + self.control_state_dirty = True + return + if self.held_by_ocpp(charger): + return + if not charger.connected: + # Updating, Error or Unknown: say nothing, and keep the record until the charger reports again + return + if self.held_by_lock(charger, once=True): + return + if wanted and charger.paused: + self.log("Info: wallbox: resuming {} for car {}".format(charger.name, car_n)) + await self.transport.resume(self.api_id(charger)) + if charger_id in self.paused_by_predbat: + self.paused_by_predbat.discard(charger_id) + self.control_state_dirty = True + elif not wanted and charger.charging: + self.log("Info: wallbox: pausing {} for car {}".format(charger.name, car_n)) + # Recorded and stored before it is sent: a pause whose reply is lost, or one + # followed by a crash, may still have been applied, and release ignores a + # recorded charger that turns out not to be paused + if charger_id not in self.paused_by_predbat: + self.paused_by_predbat.add(charger_id) + await self.save_control_state() + await self.transport.pause(self.api_id(charger)) def print_charger_table(chargers): # pragma: no cover """Print the charger summary table, for the poll and for a command read-back alike.""" @@ -1059,7 +1152,7 @@ def main(): # pragma: no cover """Main function for command line execution.""" parser = argparse.ArgumentParser(description="Test the Wallbox API") parser.add_argument("--username", required=True, help="Wallbox account email address") - parser.add_argument("--password", required=True, help="Wallbox account password") + parser.add_argument("--password", default=None, help="Wallbox account password. Leave it out to be asked for it, which keeps it out of your shell history and the process list") parser.add_argument("--raw", action="store_true", help="Print each charger's full status payload as JSON") parser.add_argument("--get", default=None, help="Read-only: GET this API path, e.g. v3/chargers//ocpp-configuration, and print the reply. For exploring the API") parser.add_argument("--no-automatic", action="store_true", help="Skip the automatic configuration of car_charging_energy, car_charging_planned, car_charging_power and car_charging_now") @@ -1073,6 +1166,8 @@ def main(): # pragma: no cover group.add_argument("--max-current", type=int, default=None, help="Set the maximum charging current in amps") group.add_argument("--eco-smart", choices=ECO_SMART_OPTIONS, default=None, help="Set the Eco-Smart mode") args = parser.parse_args() + if args.password is None: + args.password = getpass.getpass("Wallbox password: ") try: asyncio.run(run_wallbox_cli(args)) except WallboxError as exc: diff --git a/docs/components.md b/docs/components.md index 3adfde8a5..afccb4b81 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1083,10 +1083,10 @@ If you previously pointed **car_charging_energy**, **car_charging_planned** or * You can check your credentials and see exactly what Predbat would publish without changing your configuration. From the `apps/predbat` directory: ```bash -python3 wallbox.py --username you@example.com --password your-wallbox-password +python3 wallbox.py --username you@example.com ``` -This signs in, runs one cycle and prints every entity, the **car_charging_** settings it would make, and a summary line per charger. Add `--raw` to print each charger's full status as Wallbox sends it, which is the most useful thing to attach to a bug report; remove serial numbers and names first. +It asks for your Wallbox password, so the password never appears in your shell history. For a script you can pass `--password` instead. It then signs in, runs one cycle and prints every entity, the **car_charging_** settings it would make, and a summary line per charger. Add `--raw` to print each charger's full status as Wallbox sends it, which is the most useful thing to attach to a bug report; remove serial numbers and names first. To try a control by hand, add one of `--pause`, `--resume`, `--resume-schedule`, `--lock`, `--unlock`, `--max-current ` or `--eco-smart `, with `--charger ` to choose a charger other than the first. The charger's state is printed again a few seconds later so you can see whether it took effect. diff --git a/tools/debug-journal.md b/tools/debug-journal.md index b94f7bb8d..1556c9ea3 100644 --- a/tools/debug-journal.md +++ b/tools/debug-journal.md @@ -117,7 +117,7 @@ Grep for the named symbol rather than trusting a line number. | Grid sign / arrow direction | `grid_power_invert` is owned by some integrations and not others. With two systems configured, one integration setting it `True` bleeds into the other's entities and inverts the arrows. The fix is an explicit `False` in the automatic config of both. | `sunsynk_config`, `teslemetry` | | Enphase (`enphase.py`) | Unofficial Enlighten endpoints. Accounts with MFA cannot log in at all. Discharge-to-grid schedules are required for export control. Writes need a double-submit CSRF token or return 403. Using the Enphase app at the same time can trip session limits. | `enphase_api` | | myenergi (`myenergi.py`) | `myenergi_automatic_zappi` (PR #4997) gates the Zappi half of automatic config; with it off, Zappis stay monitor-only and `control_active` is never set. `release_zappis()` has exactly one caller — `control_tick()` — reached only under `if self.control_active:` in `run()`, and `control_active` is latched at the first tick: every `enable_control()` refusal (zappi_control → automatic → automatic_zappi → enable_controls) returns *before* setting it, and the gate re-runs only when a fresh instance starts. So after a component restart with any prerequisite off, a Zappi already held in 'Stopped' stays Stopped and the `switch.*_myenergi_zappi_control` entity is not even republished (it is gated on `control_active` too) — the symptom for a future report is "car won't charge after I turned off Predbat" with a Zappi. Note `control_tick()` itself *does* release on read-only mode and on the control switch being turned off; it is the config-gate half that strands. Since PR #5150 (merged 2026-09-19) sunsynk/deye instead persist `control_active` across a restart (and re-infer it from a pre-upgrade cache), so a restart cannot strand an already-armed inverter there — the myenergi strand is the opposite direction (the gate never latching in the first place) and remains. Two adjacent lifecycle facts: published entities are **never removed** — `dashboard_item()`/`set_state()` are upsert-only (`output.py`, `ha.py` POST `/api/states`), so when a gate stops `publish_data()` publishing a switch the old entity lingers frozen at its last state until HA restarts, and there is no entity-removal path anywhere in the codebase; and `set_arg`/`set_arg_auto` writes survive a component-only restart, because `Components.restart()` stops and re-creates only the instance and never resets the shared `base.args` from apps.yaml — auto-wired values (e.g. myenergi's `car_charging_*`) persist until a full process restart. Distinguish the two restarts when checking arg lifecycle. | `myenergi` | -| Wallbox (`wallbox.py`) | Cloud API reimplemented from the Home Assistant `wallbox` integration and the `wallbox` PyPI library, over aiohttp. Findings from the first live runs (5 Oct 2026, a Pulsar Plus on firmware 6.7.43): sign in works with `User-Agent: Predbat`. **A charger run by an OCPP backend** (here Octopus, for Intelligent Octopus Go) reports `config_data.operation_mode: "ocpp"`, sits in status 209 Locked, and an API unlock (`PUT v2/charger/` `{"locked": 0}`) is accepted but has no effect - so "the lock switch flips back" on such a charger is the backend, not a bug. On that locked charger a pause returned **HTTP 403** and a resume **HTTP 409**, from an account whose profile is super-admin: 403 on a control does *not* prove missing admin rights, whatever the HA integration assumes. `held_by_lock()` therefore sends neither while locked, and `held_by_ocpp()` keeps plan-led control off such a charger. `GET v3/chargers//ocpp-configuration` returns the backend address and the charge point credentials - **its reply contains a password, so never paste `--get` output unredacted**; no OCPP on/off write call is known. `locked` arrives as `1`/`0`, not a boolean, and `state_of_charge` is always null (a Type 2 connector cannot report it). Charger N is car N through `car_order`, which is numeric and append-only, so a status failure or a charger added later cannot shift it. Not yet verified on hardware when this was written: pause/resume on an unlocked, charging, non-OCPP charger; the real status ids for charging and paused; whether a 120s poll stays clear of HTTP 429. `python3 wallbox.py --raw` is the capture tool. | +| Wallbox (`wallbox.py`) | Cloud API reimplemented from the Home Assistant `wallbox` integration and the `wallbox` PyPI library, over aiohttp. Findings from the first live runs (5 Oct 2026, a Pulsar Plus on firmware 6.7.43): sign in works with `User-Agent: Predbat`. **A charger run by an OCPP backend** (here Octopus, for Intelligent Octopus Go) reports `config_data.operation_mode: "ocpp"`, sits in status 209 Locked, and an API unlock (`PUT v2/charger/` `{"locked": 0}`) is accepted but has no effect - so "the lock switch flips back" on such a charger is the backend, not a bug. On that locked charger a pause returned **HTTP 403** and a resume **HTTP 409**, from an account whose profile is super-admin: 403 on a control does *not* prove missing admin rights, whatever the HA integration assumes. `held_by_lock()` therefore sends neither while locked, and `held_by_ocpp()` keeps plan-led control off such a charger. `GET v3/chargers//ocpp-configuration` returns the backend address and the charge point credentials - **its reply contains a password, so never paste `--get` output unredacted**; no OCPP on/off write call is known. `locked` arrives as `1`/`0`, not a boolean, and `state_of_charge` is always null (a Type 2 connector cannot report it). Charger N is car N through `car_order`, which is numeric and append-only, so a status failure or a charger added later cannot shift it. Not yet verified on hardware when this was written: pause/resume on an unlocked, charging, non-OCPP charger; the real status ids for charging and paused; whether a 120s poll stays clear of HTTP 429. `python3 wallbox.py --username --raw` is the capture tool (it prompts for the password). Release bookkeeping: `paused_by_predbat` and `schedule_pending` are stored through Storage *before* a pause is sent, a record is dropped only on a fresh status that settles it (unplugged, or connected and no longer paused), and each charger's control step is isolated in `per_charger()` so one charger's failure cannot block another - if "a charger stayed paused after control was turned off" is reported, read `release_one()` first. | | Gateway MQTT (`gateway.py`) | Control writes are MQTT commands the hub acknowledges. **The ack design arrived with PR #5220 and was refined by PR #5231 (both merged 2026-09-26)** — both postdate every earlier gateway log, so keep a publish-per-call reading for pre-#5220 logs: `_subscribe_acks()` subscribes to `predbat/devices//ack/+`, tolerating a broker that refuses it (a single warn via `_ack_subscribe_warned`; without the subscription or before any ack, `_send_control()` publishes exactly as before); once acks are seen, an identical command (same entity, command, payload) is published once per `_COMMAND_ACK_WINDOW` (30s), re-sent once after the unanswered window, and if that re-send is also unanswered `_acks_seen` drops so writes fall back to publish-every-call until telemetry confirms; `_process_ack()` matches any tracked id of the current value, a refusal outranks an ok (a multi-unit dispatch acks once per unit), and replay errors are handled; ids come from a clock-seeded monotonic counter (`_next_command_id()`, `PBAT`), so they never restart at `PBAT1` after a restart, and the kept-id list (`_COMMAND_ACK_IDS_KEPT`) is pruned only after the new send is out, with an ack that arrives during a failing publish still counting. **PR #5172 is a separate, still-open implementation of the same feature** (`_subscribe_command_acks`, `uuid4().hex` ids, typed outcomes threaded through service dispatch/HTTP/inverter verifier) — its symbols are not on main until it rebases, so do not start a gateway-ack triage from its names. **Two gaps around serial-less and empty status, probed on main 2026-09-25 (GH#5227, enhancement — probed with temporary tests in `test_gateway.py`, reverted after):** (1) a sole slot publishing `serial=""` (shipped firmware 1.0.0 publishes every slot, including a GivEnergy slot whose serial discovery failed) is bound as the control target: `_needs_reconfigure()` treats `""` as a newly discovered inverter, the last-resort branch (`candidate_aios or list(all_inverters)`, `gateway.py`) binds it, and the result is empty-suffix entities (`select.predbat_gateway__charge_slot1_start`) and commands addressed to `""`, which the firmware rejects (`dongle_serial required`) — a real fleet incident on 2026-09-16 (~22 min of failed commands); an empty-serial guard in `_needs_reconfigure()`/`automatic_config()` would close it, and #5227's proposed `dongle_count` deferral does *not* cover this state (`dongle_count == len(inverters)` when the empty slot is the only one). (2) `if len(status.inverters) == 0: return` (`gateway.py`) fires **before** `_inject_entities()` (EV chargers, gateway-online) and before `_last_telemetry_time`/`update_success_timestamp()` — on a hub where all GivEnergy slots are withheld (post-predbat-gateway#335, e.g. a single-inverter hub), every status message is dropped: EV data freezes and the component fails the 60-minute staleness test (`components.py`). Symptom pointer: "gateway EV sensors froze / gateway component unhealthy while the hub is up" → this early return. Removals staying bound and reappearance triggering re-config are already the behaviour (probed). **Probe trap:** the topology branch in `automatic_config()` depends on the *whole* visible set — a probe that adds a Gateway alongside the `""` slot drops the `""` entry via the battery-presence filter (`aios` requires `battery.ByteSize() > 0`) so it never reaches the last-resort branch; construct the exact visible set the scenario implies, and note the filter silently hides data-less slots from the count in several branches (the same mechanism can make a Gateway+2-AIO fleet collapse to AIO-direct control). Existing precedent for topology probes: `TestGatewayUnitControlBinding` (`test_gateway.py`). | `gateway` | | Octopus (`octopus.py`, `fetch.py`) | Intelligent Go tariffs are detected via `is_intelligent_go_tariff()`, and IOG-prefixed tariffs must be skipped when updating intelligent devices. Saving-session auto-join rebinding regressed when `joined_events` was empty (GH#4573). `octopus_slots_signature()` deliberately omits the time-drifting fields of active dispatch slots so a replan is not forced every cycle. `car_charging_threshold` is a strict fallback gated on `not self.car_charging_energy` (`fetch.py:228`, and `load_ml_component.py:348-361`) — it never runs as a second filter alongside a real `car_charging_energy` sensor (GH#4717). Saving-session reporting credits the full saving rate to every minute of the session on both rate tables (`load_saving_slot()`, `octopus.py:2786`); the slot dict has no baseline field to subtract (GH#2090) — a complaint that a saving session's reported total looks inflated starts here, not in a rate-fetch bug. A tariff with no `standard_unit_rates` link (IOG-TOU, GO) goes down `async_get_day_night_rates()`, which infers the off-peak window from 7 days of measurement TOU labels by plurality vote. On IOG those labels include ad-hoc dispatch slots, so a bonus slot recurring on 4+ of 7 nights became a permanent nightly cheap window and Predbat charged into it at the day rate (PR #4854 skips the inference for IOG). A "cheap slot Octopus has never heard of" report where the dispatch feeds are *empty* is this, not phantom dispatches — check the log for `Using off-peak windows [...] from measurement TOU labels`. TOU labels are UTC instants, so windows derived from them must be re-anchored for a local wall-clock tariff or they drift an hour at DST. Free-session slots have their own family of gates, separate from the saving-session ones and each found the hard way. `octopus_free_session` events used to be dropped when `code` was null, which is exactly how Octopus publishes auto-joined Weekend Happy Hours - fixed, the gate is now `start and end` and the log falls back to the event id (GH#4835). The free/saving-session args name **event** entities (`attribute="events"`/`"joined_events"`, `octopus.py`), and the BottlecapDave integration exposes Octoplus sessions as calendar + event pairs of which only the event entity carries those state attributes - the calendars have none at all, and both sides ship **disabled by default** in the integration. "The documented sensor name does not exist" plus a proposal to point at the `calendar.` twin is almost always the disabled-default trap, not a rename (GH#5370, verified against the integration's source): enable the event entity; the calendar twin cannot back these args. Matching calendar/event names differ by the `_events` suffix, so a pattern built from one domain's names mistranslates, and the deprecated pre-v17 names are scheduled for removal in January 2027. `load_free_slot()` bounded itself with `start_minutes < self.forecast_minutes` while rate minutes are indexed from `midnight_utc`, so any event more than `forecast_minutes` past midnight was dropped with no log line at all - fixed in `3bacc6e8`, the gate and the end clamp now both use `forecast_minutes + minutes_now` like `load_saving_slot()` (GH#4931). In the same function the start/end range was only updated on a successful decode while the apply block ran unconditionally, so an undecodable slot re-applied its rate over the *previous* slot's minute range (PR #4935). The join side has its own: the `joined_events` guard still ends in `saving_rate > 0` (`octopus.py:3542`), so `octopoints_per_kwh: 0` - a genuine free hour - yields no slot of any kind; probe-verified (0 gives nothing, 500 gives a saving slot, null gives nothing), and `git blame` puts that guard in `1b8c9136`, the fix for null-rate sessions (GH#3079), so it is an oversight rather than a deliberate exclusion (GH#4851). On dispatches, `rate_add_io_slots()`'s `location` check used to apply to *completed* dispatches too, and `rate_import` is rebuilt every cycle, so a retroactive AT_HOME to AWAY relabel silently un-stamped the cheap rate and `today_cost()` re-priced the whole day at the day rate (GH#4946) - **fixed in PR #4957**, which added `dispatch_billed_off_peak()` (`octopus.py:2977`): a completed dispatch is billed off-peak regardless of location, a straddling one keeps the location test, and the midday budget cap still applies. Keep the mechanism in mind when reading a log from before that merge, where the whole day reprices at the day rate. A neighbouring cap bug had no entry here at all: the daily low-rate slot budget was keyed on the *loop* minute rather than the slot start, so a window straddling noon drew a fresh 12-slot budget at 12:00 and stamped the afternoon cheap (GH#4950, **fixed in PR #4951**). The midday boundary itself is deliberate (`337db867`); only the keying was wrong. Worth knowing because "a cheap slot at an hour Octopus never offered" has at least three distinct causes in this row alone. Compare has its own rate-fetch gap: `download_octopus_rates_func()` (`octopus.py:2725`) reads only `standard-unit-rates`, and the newer IOG-SMB-FIX products are `four_rate_ev` - that endpoint answers 200 with empty results and the rates live on `day-unit-rates`/`night-unit-rates`. The main OctopusAPI component already auto-detects that product shape; compare never got the same treatment (GH#4921, probed against the live API). Lastly, `{dno_region}` in a tariff URL is substituted by `resolve_arg`'s `.format(**self.args)` (`userinterface.py:106-116`), so a missing `-` before the placeholder glues the region letter onto the product code and yields a plausible-looking 404 - check the literal URL in apps.yaml before believing a tariff has been withdrawn. Two later additions to the free/saving-session picture, one of them a correction. **eventType is carried only by the legacy `savingSessions` query path — the flexibility feed drops it (GH#4548, re-verified on main 2026-09-29 by reading `async_get_flexibility_events()`):** that function extracts only `code`/`startAt`/`endAt` from `customerFlexibilityCampaignEvents` and maps every saving event into `joinedEvents` with **no eventType**, so on the Direct path with an MPAN the joined-event loop's free-slot branch (`event_type == "WEEKEND_HAPPY_HOUR"`) can never fire and a joined Happy Hour falls through to the rewarded-saving-slot branch whenever `octopus_saving_session_rate` is set above 0. The legacy query carries eventType (the `savingSessions` GraphQL request names it and its map stores it) and is reached on the Direct path only as the flexibility path's own fallback (no MPAN, or the flexibility API returned no saving events). eventType remains the only sound discriminator between a Power Down saving session and a free Power Up/Happy Hour — the field GH#4851's `saving_rate > 0` problem needed and did not have — but *which query path served the events* decides whether you have it: "free Power Up priced as a paid saving session" on the flexibility path is first a question of provenance, and waiting for the BottlecapDave API before building on the newer feed is deliberate, not an oversight. The Direct join mutation is likewise still the legacy `joinSavingSessionsEvent`. And a genuinely counter-intuitive ordering constraint, worth reading before touching that function: Weekend Happy Hours are now skipped from `available_events` (they cannot be joined through the API - Octopus allocates them or the user books on the website), but **the skip has to sit after the reward/code/type maps are populated**, because those maps are built from the same events list and a joined Happy Hour looks its own type up there. Skip too early and the joined event has no type, takes the injected default reward, and the planner prices a free hour as an **80p/kWh saving session** (`c0fb4e9c`; the test fails if the skip is moved above the maps). Two rate-provenance reports from mid-September. **GH#5012** (car unplugged, plan still charged the house battery in the phantom cheap window): `octopus_intelligent_ignore_unplugged` correctly removed the planned dispatches, but the cheap price came from the integration's own rate sensor, not Predbat's stamping — parse the debug yaml and compare, per suspect minute, `rate_import_base` vs `rate_import_no_io` vs `io_adjusted`: a cheap price present in base and no_io with `io_adjusted` empty is the integration's rate sensor; present only after no_io is Predbat's `rate_add_io_slots()` stamping (#4516/#4950 family); `io_adjusted` populated means the minutes were flagged as adjusted — but **since PR #5304 (v9.3.3) Predbat's own `rate_add_io_slots()` flags the minutes it lowers too** (it used to write no marker at all; it mirrors the integration's `is_intelligent_adjusted`, never the fixed off-peak, time over the daily cap, or a cancelled car), so a populated `io_adjusted` no longer discriminates the integration's stamp from Predbat's own; the base-vs-`no_io` comparison still does, and `rate_replicate()` still refuses to copy `io_adjusted`-flagged minutes into future days. This attributes "integration feeds the wrong price" vs "Predbat stamps the wrong price" in one step. Nothing reverts `io_adjusted`-flagged minutes when the car is unplugged + ignore_unplugged is on — that fix would be an enhancement, blocked in practice by the integration not flagging stale adjusted entries (`io_adjusted` was empty in this dump). **`io_adjusted` itself was for a while wiped by the export/gas fetch (GH#5286, fixed in PR #5290, merged 2026-09-28):** `fetch_octopus_rates()` replaced `self.io_adjusted` on every call, so the export and gas fetches that follow the import fetch reset it to `{}` whenever `metric_octopus_export`/`metric_octopus_gas` were set — regression from #2826 dropping the old "only when adjust_key is set" guard — and with no markers left, `dynamic_load_car_strip_feed_rates()` had nothing to strip, so a car cancelled out of a dispatch still left it priced cheap and the house battery planned into it. The fix makes `self.io_adjusted` replaced only when the fetch carries an `adjust_key`; keep the mechanism for pre-#5290 logs. A companion fix (same PR) stops a compared tariff inheriting the live tariff's dispatch markers — each compared tariff now starts with none, and one left on the live import rates gets a copy. A user who instead wants the house battery limited to minutes the car actually charges has no clean knob (GH#5065): `octopus_intelligent_charging` off does **not** stop the stamping — the switch gates only car planning and vehicle prefs, planned slots are collected into `self.octopus_slots` regardless (gated only by `octopus_intelligent_ignore_unplugged`, which covers unplugged, not plugged-in-but-deferred) and are consumed unconditionally by `rate_add_io_slots()`. `octopus_slot_max: 0` is not selective either — the cap is applied in `load_octopus_slots()` as well as `rate_add_io_slots()`, so it removes the car's charging slots too. The only complete workaround is unsetting `octopus_intelligent_slot`, which loses completed-slot tracking and Octopus car planning with it. **GH#5018** (tariff switched mid-day; tomorrow's export rates stayed on the old tariff and Nordpool estimates never appeared): Predbat re-reads Octopus rate events from HA every fetch cycle (`fetch_octopus_rates`), so stale export rates are the HA integration still serving the old contract — reload the integration. Two Predbat-side traps in the same report: `futurerate` only fills *missing* minutes (the `if minute not in rates` gate in fetch.py), so stale-but-present rates always win over Nordpool estimates; and `futurerate_adjust_auto` re-runs at every FutureRate init (one per fetch cycle) and persists via `set_arg`, so mid-switch it can persist `False` over the user's manual `futurerate_adjust_export: true` — log signature `FutureRate: No futurerate adjustment enabled, skipping futurerate analysis` repeating every cycle. The debug yaml does **not** contain the Octopus rate events (grep for `day_rates` comes up empty), so replays can't reproduce integration-served rate data; use the log's `Export rates: min/max/average` lines as the provenance check (identical min/max across days = static served data, and a fixed tariff has a fixed shape while a variable one doesn't). `self.mpan` is the **import** MPAN only — `async_find_tariffs()` sets it once from the first active import agreement's `meterPoint` and never overwrites it; an account with export has import and export as separate agreements with different MPANs, and the export one is retained nowhere (PR #4972 review). The saving-session/free-electricity GraphQL queries use `self.mpan` deliberately because those campaigns are import-side; any feature that needs to identify a *meter* cannot take `self.mpan` (PR #4972, merged 2026-09-19, adds `self.tariffs[direction]["mpan"]`). Symptom: two things that should describe different supply points coming out identical — that is this, not a deduplication bug. **GH#5144** (fixed in PR #5145): the REST tariff endpoints return overlapping `DIRECT_DEBIT`/`NON_DIRECT_DEBIT` rows for the same validity window and `minute_data()` writes each row over its range, so whichever row came last in the response won — and the order is not stable across periods, so the displayed rate could flip between variants from one period to the next ("always the higher rate" was an artefact of that ordering, not a property of the bug). `filter_payment_method()` (`utils.py`) now keeps one variant at the parse point on the component, day/night, annual and minute-data paths — preferring `DIRECT_DEBIT`, keeping rows with no `payment_method` (Agile) untouched, and leaving single-variant tariffs unchanged; null `payment_method` is the common case, so any future filter must keep nulls. **The daily cheap-slot cap is derived per account but enforced per car (GH#5215, semantics unresolved):** `get_octopus_slot_max()` (`octopus.py`) takes no `car_n` — it resolves the cap from the *account's* import tariff code via `has_six_hour_cap()` (matches `IOG-SMB` → 12) or a single apps.yaml integer — while both enforcement counters are locals (`slots_per_day` in `rate_add_io_slots()` and in `load_octopus_slots()`), and both callers run once per car, so each car draws a fresh budget: up to `octopus_slot_max × num_cars` cheap half-hours priced per day. Do **not** assume the docs' per-car sentence is simply wrong: the derivation is account-level but Octopus's own blog says "*Your car gets up to 6 hours of off-peak charging per day*" (quoted in GH#4830, the multi-car confusion thread), so the code is internally inconsistent and the intended semantics is genuinely open. Exposure is invisible outside IOG-SMB — uncapped tariffs default `octopus_slot_max` to 48 — so only capped multi-car installs can see the doubling; existing tests encode the per-car behaviour, and `octopus_slot_max` is read without `index=` so per-car configuration does not exist either way. **The IOG slot-confirmation strip removes the house's cheap rate too when a car's dispatch is cancelled (GH#5335, with the #5317 family - code chain on main 57ec7bf1):** with `octopus_intelligent_dynamic` on (default), `dynamic_load_car_check()` (`plan.py`) cancels every slot of a car that sits inside a started dispatch but is not seen charging after the confirmation grace (log: `car 0 is in a dispatch but not charging, cancelling its slots`), and `dynamic_load_car_strip_feed_rates()` (`octopus.py`) re-prices the house's view of those minutes back to `rate_max_base` when the cheapness came from the integration feed, **exempting the fixed IOG band** (`OCTOPUS_NIGHT_RATE_WINDOWS["iog"]` = 23:30–05:30) — so a house battery that had planned into the dispatch loses its cheap stamp as well, and PV10 re-prices the minutes at `rate_max` outright (prediction.py's `dispatch_gone` line, `minute > 30`). Version tell: the strip is v9.3.2+ code — a reporter's deliberate mid-log downgrade left the A/B in one `predbat.log`: v9.3.3 logged `Octopus Intelligent: removed the dispatch rate from 355 minutes of cars [0] which are not charging` while v9.3.1 kept the 6.57p dispatch rate (`Charging target 2%-100%`); workaround (semantics read, not live-tested): `octopus_intelligent_dynamic` off = no confirmation and no strip, ≈9.3.1 behaviour at the cost of #5229's low-load protection. Check the car side first, though: Predbat must *see* the car charging to confirm the slot, and a `plug_status` string (`eco`/`boost`) that never equals `car_charging_now_response: charging` leaves the car "not charging" through a real charge — check the sensor's own history before blaming the planner. **#5316 is the mid-dispatch variant, fixed in PR #5319 (merged 2026-10-03) — keep the pre-#5319 signature:** a car that stops part-way through a running dispatch half hour used to have the rest of that half hour re-priced at the day rate; the strip paths (`rate_add_io_slots()`, `dynamic_load_car_strip_feed_rates()`) now start from `dynamic_load_car_strip_from()` (`plan.py`) — the end of the half hour the car was last seen charging in, which is billed off-peak in full by Octopus — and the confirmation is never cleared, only outlived. Review-round refinements ride along: a sensor reading confirms a half hour only from 2 minutes into it, and the grace clock is not kept across a restart (saved state is judged on the skewed clock). | `octopus_*`, `saving_session*` | | Kraken / EDF (`kraken.py`) | EDF Kraken answers a day/night-structured tariff with **HTTP 400** on `standard-unit-rates/` (`{"detail": "This tariff has day and night rates, not standard."}`) while `day-unit-rates`/`night-unit-rates` return 200 and standing charges 200 — where Octopus four-rate products (GH#4921) answer the same-shaped URL **200 with empty results**. So the "empty results, then data empty" signature belongs to `octopus.py` and a hard 400 to `kraken.py`; do not carry the expectation across (GH#5166, probed against the live API). A nonexistent product still 404s, so 404 and 400 are both live "REST can't serve this tariff" signals on EDF — probe the exact URL from the reporter's log before assuming either. **Fixed in PR #5167** (`async_fetch_rates()`): the GraphQL `applicableRates` fallback now gates on `KRAKEN_REST_RATES_UNAVAILABLE_STATUSES` = (400, 404, 410), per direction (404 covers both a private product and one retired from the REST API; the authenticated retry stays 404/410 because auth cannot change a 400), and only counts a failure when the fallback comes back empty without counting one — `async_graphql_query()` counts its own failures, so a caller that also counts scores 2 per down cycle; snapshot the counter around the call. The standing-charge fallback stays deliberately 404/410-only (`kraken.py` — "400 is a rates-endpoint-only answer"). Two traps from the fix's review: `_fetch_rates_rest()` returns `(None, None)` on a network error, so `err is None` does **not** mean success — success is `(results, None)`; the merged code stashes the public attempt's status and restores it when the authenticated retry hits a network error (pre-fix symptom: a private-product tariff's rates vanish for one cycle with no fallback log line, while an identical 404 one cycle later recovers). The day/night endpoints' rows carry the same `payment_method` variant overlap as GH#5144 — suspected, no probe; check `minute_data`'s handling before consuming them directly. Also benign: `Warn: Kraken: Auth not available for find-tariffs` repeating on early restarts before the first `Tariff discovered` line is setup-phase, not a second bug. | `kraken` | From 15a3ad5da2bd6b8984a8355324b54d1cff724629 Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 19:42:28 +0100 Subject: [PATCH 23/24] style(wallbox): apply black formatting Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/wallbox.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index 9d41b795e..d2bf75df0 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -1058,6 +1058,7 @@ async def control_one(self, car_n, charger, wanted): await self.save_control_state() await self.transport.pause(self.api_id(charger)) + def print_charger_table(chargers): # pragma: no cover """Print the charger summary table, for the poll and for a command read-back alike.""" print("{:<12} {:<20} {:<34} {:>9} {:>12} {:>6} {:>7} {:>8}".format("CHARGER", "NAME", "STATUS", "POWER W", "SESSION kWh", "MAX A", "LOCKED", "MODE")) From 012f6732cf854741d63fcbf3c24d5705af03052f Mon Sep 17 00:00:00 2001 From: CI Date: Mon, 5 Oct 2026 19:46:47 +0100 Subject: [PATCH 24/24] refactor(wallbox): read the saved control state once when nothing is stored An empty answer from Storage is taken as nothing saved, as myenergi, GivEnergy EVC and Sunsynk do. A read that raises is still retried. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_wallbox.py | 40 ++++++++---------------------- apps/predbat/wallbox.py | 15 ++++------- 2 files changed, 15 insertions(+), 40 deletions(-) diff --git a/apps/predbat/tests/test_wallbox.py b/apps/predbat/tests/test_wallbox.py index 70275890f..752d5e31b 100644 --- a/apps/predbat/tests/test_wallbox.py +++ b/apps/predbat/tests/test_wallbox.py @@ -1649,43 +1649,23 @@ async def flaky_save(module, filename, data, **kwargs): print(" ✓ A failed control state save is retried") -def test_control_state_load_returning_nothing_is_retried_then_given_up(): - """Storage returns None for a failed read as well as a missing file, so a None is retried a few times.""" - storage = _storage_with_paused("101") - real_load = storage.load - answers = [None] +def test_control_state_with_nothing_saved_is_read_once(): + """An empty answer from Storage means nothing is saved: it is read once, as the other components do.""" + storage = _Storage() calls = [] - async def flaky_load(module, filename): - """Return None once, as a failed read does, then the real data.""" - calls.append(filename) - if answers: - return answers.pop() - return await real_load(module, filename) - - storage.load = flaky_load - component = _make_component({101: _status(status_id=178, power=0)}, wallbox_control=False) - component.base.components = _Components(storage) - run_async(component.run(0, True)) - assert component.transport.count("resume") == 0 - run_async(component.run(120, False)) - assert component.transport.count("resume") == 1, "The saved charger was released once the read worked" - - empty = _Storage() - empty_calls = [] - async def counting_load(module, filename): """Always empty, counting the reads.""" - empty_calls.append(filename) + calls.append(filename) return None - empty.load = counting_load + storage.load = counting_load component = _make_component() - component.base.components = _Components(empty) - for cycle in range(12): + component.base.components = _Components(storage) + for cycle in range(6): run_async(component.run(cycle * 60, cycle == 0)) - assert 1 < len(empty_calls) <= 5, "A file that is simply absent is not read forever: {}".format(len(empty_calls)) - print(" ✓ An empty control state read is retried a few times, then left") + assert len(calls) == 1, "Read once, not on every cycle: {}".format(len(calls)) + print(" ✓ With nothing saved, the control state is read once") def test_schedule_restoration_is_retried_after_a_failure(): @@ -1798,7 +1778,7 @@ def test_wallbox(my_predbat=None): test_account_wide_errors_still_stop_the_control_loop() test_pause_is_persisted_before_it_is_sent() test_failed_control_state_save_is_retried() - test_control_state_load_returning_nothing_is_retried_then_given_up() + test_control_state_with_nothing_saved_is_read_once() test_schedule_restoration_is_retried_after_a_failure() print("=" * 70) return False diff --git a/apps/predbat/wallbox.py b/apps/predbat/wallbox.py index d2bf75df0..592a77510 100644 --- a/apps/predbat/wallbox.py +++ b/apps/predbat/wallbox.py @@ -74,8 +74,6 @@ WALLBOX_STORAGE_MODULE = "wallbox" WALLBOX_CONTROL_STATE = "control_state" -# How many cycles an empty answer from Storage is retried before the saved state is taken to be absent -CONTROL_STATE_LOAD_ATTEMPTS = 5 # Status id to (text, car connected). The text is the Home Assistant wallbox integration's # wording, so states match what existing users already see. A None status id means the @@ -493,7 +491,6 @@ def initialize(self, username, password, automatic=True, wallbox_control=False, self.lock_warned = set() self.ocpp_warned = set() self.control_state_loaded = False - self.control_state_load_attempts = 0 self.control_state_dirty = False # Chargers Predbat has resumed on release but not yet handed back to their own schedule self.schedule_pending = set() @@ -840,23 +837,21 @@ async def load_control_state(self): """Restore the control switch and the chargers Predbat still owes a release. The saved lists are what let a restart with control turned off still release a - charger an earlier session left paused. Storage answers None for a failed read as - well as for a file that is not there, so a None is tried again on the next few - cycles rather than taken as "nothing saved"; after that the file is taken to be - absent. What is read is merged into what this session already knows, never + charger an earlier session left paused. A read that raises is tried again on the + next cycle, as Sunsynk's cache restore does; an empty answer is taken as nothing + saved. What is read is merged into what this session already knows, never replacing it. Fails soft with no Storage component. """ - if self.control_state_loaded or self.storage is None or self.control_state_load_attempts >= CONTROL_STATE_LOAD_ATTEMPTS: + if self.control_state_loaded or self.storage is None: return - self.control_state_load_attempts += 1 try: saved = await self.storage.load(WALLBOX_STORAGE_MODULE, WALLBOX_CONTROL_STATE) except Exception as exc: self.log("Warn: wallbox: could not read the saved charge control state: {}".format(exc)) return + self.control_state_loaded = True if not isinstance(saved, dict): return - self.control_state_loaded = True if "control_enabled" in saved: self.control_enabled = bool(saved["control_enabled"]) for key, target in (("paused", self.paused_by_predbat), ("schedule", self.schedule_pending)):