From d464da9702f3dbdb0e770bad8dd3a6c45f02c2a7 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 16:36:55 +0100 Subject: [PATCH 01/82] fix: authenticate via Cognito GetUser instead of userInfo Setup failed for every user with "invalid_token". The config flow validated the access token against the hosted-UI endpoint /oauth2/userInfo, which requires the "openid" scope. The Daze web portal issues access tokens scoped "aws.cognito.signin.user.admin" and never includes "openid", so Cognito rejected every token a user could obtain: HTTP 401 Access token does not contain the 'openid' scope The tokens themselves were valid. Refreshing produced another token with the same scope set, so there was no user-side workaround. Replace both userInfo call sites with the Cognito user pool GetUser operation, which accepts that scope and returns the same profile attributes, including the email address the config flow needs to look up networks. Three behavioural differences from userInfo are handled explicitly: - The access token travels in the request body, not in an Authorization header. - Cognito responds with application/x-amz-json-1.1, so the JSON decode must relax the content type check or aiohttp raises ContentTypeError. - An invalid token yields HTTP 400 with NotAuthorizedException rather than HTTP 401. The retry-on-401 path in DazeApiClient._request would therefore never fire, so async_get_user_info performs its own refresh-and-retry. Failed validation now logs the error type rather than the full response body. Add tests/test_auth_getuser.py, which imports the real modules instead of re-implementing their logic, covering request shape, scope acceptance, attribute flattening, the refresh-and-retry path, and a guard against reintroducing the userInfo endpoint. Add tools/check_daze_tokens.py, a standalone diagnostic that reproduces each authentication step against Cognito and reports which one fails. It reads tokens from hidden prompts and never stores, logs, or echoes them. Co-Authored-By: Claude Opus 5 --- custom_components/daze/api/__init__.py | 88 +++++- custom_components/daze/api/auth.py | 117 ++++++-- custom_components/daze/const.py | 12 + tests/test_auth_getuser.py | 317 ++++++++++++++++++++ tools/check_daze_tokens.py | 398 +++++++++++++++++++++++++ 5 files changed, 898 insertions(+), 34 deletions(-) create mode 100644 tests/test_auth_getuser.py create mode 100755 tools/check_daze_tokens.py diff --git a/custom_components/daze/api/__init__.py b/custom_components/daze/api/__init__.py index 52e88a2..6c36312 100644 --- a/custom_components/daze/api/__init__.py +++ b/custom_components/daze/api/__init__.py @@ -9,7 +9,12 @@ from aiohttp.client_exceptions import ClientError from ..const import API_BASE_URL -from .auth import AuthError, DazeAuthClient +from .auth import ( + AuthError, + DazeAuthClient, + async_fetch_user, + describe_get_user_error, +) _LOGGER = logging.getLogger(__name__) @@ -26,6 +31,33 @@ class ApiError(Exception): """Raised for non-auth API errors (4xx, 5xx, network issues).""" +def _flatten_user_attributes(payload: dict[str, Any]) -> dict[str, Any]: + """Flatten a Cognito GetUser response into a plain attribute dict. + + GetUser returns attributes as a list of ``{"Name": ..., "Value": ...}`` + entries. Callers expect a mapping, so convert it and carry the + username across as well. + + Args: + payload: The parsed GetUser response body. + + Returns: + A dict mapping attribute names to values. + + """ + attributes: dict[str, Any] = { + attr["Name"]: attr.get("Value") + for attr in payload.get("UserAttributes", []) + if isinstance(attr, dict) and attr.get("Name") + } + + username = payload.get("Username") + if username: + attributes.setdefault("username", username) + + return attributes + + class DazeApiClient: """Async REST client for the Daze web API. @@ -211,14 +243,58 @@ async def _handle_401( # ------------------------------------------------------------------ async def async_get_user_info(self) -> dict[str, Any]: - """Fetch user info from the Cognito userInfo endpoint. + """Fetch the signed-in user's profile from Cognito. + + Uses the user pool GetUser operation rather than the hosted-UI + userInfo endpoint, which rejects the scope Daze issues. See + ``async_fetch_user`` for the details. + + The GetUser attribute list is flattened into a plain dict so + callers can read ``email`` directly, matching the shape the + previous userInfo call returned. + + Because GetUser signals an invalid token with HTTP 400 rather + than HTTP 401, the generic ``_request`` retry path does not + apply; the refresh-and-retry is handled explicitly here. + + Returns: + A dict of user attributes, including ``email``. + + Raises: + ApiAuthError: If the token is rejected and refreshing it + does not recover access. - GET /oauth2/userInfo """ - url = ( - "https://daze.auth.eu-central-1.amazoncognito.com/oauth2/userInfo" + status, body = await async_fetch_user( + self._session, self._auth.access_token ) - return await self._request("GET", url) + + if status != 200: + _LOGGER.info( + "GetUser rejected the access token (%s) — refreshing", + describe_get_user_error(status, body), + ) + + try: + await self._auth.async_refresh_access_token(self._session) + except AuthError as err: + raise ApiAuthError( + "Token refresh failed, re-authentication required" + ) from err + + status, body = await async_fetch_user( + self._session, self._auth.access_token + ) + + if status != 200: + detail = describe_get_user_error(status, body) + _LOGGER.warning("GetUser failed after refresh (%s)", detail) + raise ApiAuthError( + "Authentication failed after token refresh, " + "re-authentication required" + ) + + return _flatten_user_attributes(body) async def async_get_networks( self, email: str diff --git a/custom_components/daze/api/auth.py b/custom_components/daze/api/auth.py index 7f15199..3f0091c 100644 --- a/custom_components/daze/api/auth.py +++ b/custom_components/daze/api/auth.py @@ -12,7 +12,10 @@ from ..const import ( CLIENT_ID, COGNITO_BASE_URL, + COGNITO_IDP_URL, DEFAULT_TOKEN_EXPIRY_BUFFER, + GET_USER_CONTENT_TYPE, + GET_USER_TARGET, REDIRECT_URI, ) @@ -23,6 +26,79 @@ class AuthError(Exception): """Raised when authentication fails (invalid/expired tokens, network error).""" +async def async_fetch_user( + session: ClientSession, access_token: str +) -> tuple[int, dict[str, Any]]: + """Call the Cognito user pool GetUser operation. + + This replaces the hosted-UI ``/oauth2/userInfo`` endpoint, which + requires the ``openid`` scope. The Daze web portal issues access + tokens scoped ``aws.cognito.signin.user.admin`` without ``openid``, + so userInfo rejects every token a user can obtain. GetUser accepts + that scope and returns the same profile attributes. + + Two differences from userInfo matter to callers: + + - The access token is sent in the request body, not in an + ``Authorization`` header. + - An invalid or expired token yields HTTP 400 with a + ``NotAuthorizedException`` type, not HTTP 401. + + Args: + session: An aiohttp ClientSession to use for the request. + access_token: The Cognito access token to authenticate with. + + Returns: + A tuple of the HTTP status code and the parsed JSON body. The + body is an empty dict if the response was not valid JSON. + + Raises: + AuthError: If the request fails at the network level. + + """ + headers = { + "Content-Type": GET_USER_CONTENT_TYPE, + "X-Amz-Target": GET_USER_TARGET, + } + + try: + async with session.post( + COGNITO_IDP_URL, + headers=headers, + json={"AccessToken": access_token}, + ) as response: + # Cognito replies with application/x-amz-json-1.1, which + # aiohttp refuses to decode unless content_type is relaxed. + try: + body = await response.json(content_type=None) + except (ValueError, TypeError): + body = {} + + if not isinstance(body, dict): + body = {} + + return response.status, body + + except ClientError as err: + _LOGGER.warning("Network error during GetUser: %s", err) + raise AuthError(f"Network error during GetUser: {err}") from err + + +def describe_get_user_error(status: int, body: dict[str, Any]) -> str: + """Summarise a failed GetUser response without leaking the body. + + Args: + status: The HTTP status code returned by Cognito. + body: The parsed JSON body. + + Returns: + A short, safe description for logs and error messages. + + """ + error_type = body.get("__type") or "unknown error" + return f"HTTP {status}: {error_type}" + + class DazeAuthClient: """Manages Daze Cognito OAuth token lifecycle. @@ -152,7 +228,7 @@ async def async_refresh_access_token( async def async_validate_tokens( self, session: ClientSession ) -> bool: - """Validate the stored access token against Cognito userInfo. + """Validate the stored access token against Cognito GetUser. Args: session: An aiohttp ClientSession to use for the request. @@ -164,36 +240,21 @@ async def async_validate_tokens( AuthError: If the token is invalid or a network error occurs. """ - url = f"{COGNITO_BASE_URL}/oauth2/userInfo" - headers = self.get_headers() + _LOGGER.debug("Validating tokens via Cognito GetUser") - _LOGGER.debug("Validating tokens via Cognito userInfo") + status, body = await async_fetch_user(session, self._access_token) - try: - async with session.get(url, headers=headers) as response: - if response.status == 200: - return True - - body = await response.text() - _LOGGER.warning( - "Token validation failed (HTTP %s): %s", - response.status, - body, - ) - raise AuthError( - f"Token validation failed with status {response.status}: " - f"{body}" - ) + if status == 200: + return True - except AuthError: - raise - except ClientError as err: - _LOGGER.warning( - "Network error during token validation: %s", err - ) - raise AuthError( - f"Network error during token validation: {err}" - ) from err + detail = describe_get_user_error(status, body) + _LOGGER.warning("Token validation failed (%s)", detail) + raise AuthError(f"Token validation failed, {detail}") + + @property + def access_token(self) -> str: + """Return the current access token.""" + return self._access_token @property def token_expiry(self) -> float | None: diff --git a/custom_components/daze/const.py b/custom_components/daze/const.py index 0ba5689..0c9e0c5 100644 --- a/custom_components/daze/const.py +++ b/custom_components/daze/const.py @@ -6,6 +6,18 @@ API_BASE_URL = "https://webapi.dazeservice.com/v3" COGNITO_BASE_URL = "https://daze.auth.eu-central-1.amazoncognito.com" +# Cognito user pool API, used to read the signed-in user's profile. +# +# The hosted-UI endpoint COGNITO_BASE_URL/oauth2/userInfo cannot be used: +# it requires the access token to carry the "openid" scope, and the Daze +# web portal issues access tokens scoped "aws.cognito.signin.user.admin" +# only. Those tokens are valid, but userInfo rejects every one of them +# with "Access token does not contain the 'openid' scope". The GetUser +# operation below accepts that scope and returns the same attributes. +COGNITO_IDP_URL = "https://cognito-idp.eu-central-1.amazonaws.com/" +GET_USER_TARGET = "AWSCognitoIdentityProviderService.GetUser" +GET_USER_CONTENT_TYPE = "application/x-amz-json-1.1" + # Cognito OAuth settings CLIENT_ID = "4m0rp7oqarbrc3hn67ivvonba8" REDIRECT_URI = "https://webportal.dazeservice.com/authentication/callback" diff --git a/tests/test_auth_getuser.py b/tests/test_auth_getuser.py new file mode 100644 index 0000000..4a6daba --- /dev/null +++ b/tests/test_auth_getuser.py @@ -0,0 +1,317 @@ +"""Tests for Cognito GetUser authentication. + +Unlike the other test modules, these import the real integration code +rather than re-implementing its logic, so they fail if the shipped +modules regress. Home Assistant is not required: the auth and API layers +depend only on aiohttp, and the package's ``__init__`` (which does need +Home Assistant) is bypassed by loading the submodules directly. + +Run with pytest, or standalone: + + python3 tests/test_auth_getuser.py +""" + +from __future__ import annotations + +import ast +import asyncio +import importlib.util +import sys +import types +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE_DIR = ROOT / "custom_components" / "daze" + + +def _load_integration_modules() -> tuple[Any, Any]: + """Load the const, auth and api modules without Home Assistant. + + Returns: + A tuple of the loaded ``auth`` and ``api`` modules. + + """ + parent = types.ModuleType("daze_under_test") + parent.__path__ = [str(PACKAGE_DIR)] + sys.modules["daze_under_test"] = parent + + const_spec = importlib.util.spec_from_file_location( + "daze_under_test.const", PACKAGE_DIR / "const.py" + ) + assert const_spec and const_spec.loader + const_module = importlib.util.module_from_spec(const_spec) + sys.modules["daze_under_test.const"] = const_module + const_spec.loader.exec_module(const_module) + + api_spec = importlib.util.spec_from_file_location( + "daze_under_test.api", + PACKAGE_DIR / "api" / "__init__.py", + submodule_search_locations=[str(PACKAGE_DIR / "api")], + ) + assert api_spec and api_spec.loader + api_module = importlib.util.module_from_spec(api_spec) + sys.modules["daze_under_test.api"] = api_module + api_spec.loader.exec_module(api_module) + + return sys.modules["daze_under_test.api.auth"], api_module + + +auth, api = _load_integration_modules() + + +# ------------------------------------------------------------------ +# Fake aiohttp session +# ------------------------------------------------------------------ + + +class FakeResponse: + """Minimal stand-in for an aiohttp response.""" + + def __init__(self, status: int, payload: Any) -> None: + self.status = status + self._payload = payload + + async def json(self, content_type: str | None = "application/json") -> Any: + """Return the canned payload, ignoring content type.""" + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + async def text(self) -> str: + """Return a textual body.""" + return str(self._payload) + + async def __aenter__(self) -> FakeResponse: + return self + + async def __aexit__(self, *exc: object) -> None: + return None + + +class FakeSession: + """Records requests and replays queued responses.""" + + def __init__(self, responses: list[FakeResponse]) -> None: + self._responses = list(responses) + self.calls: list[dict[str, Any]] = [] + + def post(self, url: str, **kwargs: Any) -> FakeResponse: + """Record a POST and return the next queued response.""" + self.calls.append({"method": "POST", "url": url, **kwargs}) + return self._responses.pop(0) + + def get(self, url: str, **kwargs: Any) -> FakeResponse: + """Record a GET and return the next queued response.""" + self.calls.append({"method": "GET", "url": url, **kwargs}) + return self._responses.pop(0) + + def request(self, method: str, url: str, **kwargs: Any) -> FakeResponse: + """Record a generic request and return the next queued response.""" + self.calls.append({"method": method, "url": url, **kwargs}) + return self._responses.pop(0) + + +GET_USER_OK = { + "Username": "1a2b3c", + "UserAttributes": [ + {"Name": "sub", "Value": "1a2b3c"}, + {"Name": "email", "Value": "driver@example.invalid"}, + {"Name": "name", "Value": "Test Driver"}, + ], +} + +GET_USER_DENIED = { + "__type": "NotAuthorizedException", + "message": "Invalid Access Token", +} + + +# ------------------------------------------------------------------ +# Tests +# ------------------------------------------------------------------ + + +def test_fetch_user_targets_user_pool_api() -> None: + """GetUser must hit the IDP endpoint with the token in the body.""" + session = FakeSession([FakeResponse(200, GET_USER_OK)]) + + status, body = asyncio.run(auth.async_fetch_user(session, "tok-123")) + + assert status == 200 + assert body == GET_USER_OK + + call = session.calls[0] + assert call["method"] == "POST" + assert call["url"] == "https://cognito-idp.eu-central-1.amazonaws.com/" + assert call["headers"]["X-Amz-Target"] == ( + "AWSCognitoIdentityProviderService.GetUser" + ) + assert call["headers"]["Content-Type"] == "application/x-amz-json-1.1" + # The token goes in the body, never in an Authorization header. + assert call["json"] == {"AccessToken": "tok-123"} + assert "authorization" not in {k.lower() for k in call["headers"]} + + +def test_fetch_user_relaxes_content_type() -> None: + """Cognito replies as x-amz-json-1.1, which must still parse.""" + session = FakeSession([FakeResponse(200, GET_USER_OK)]) + + asyncio.run(auth.async_fetch_user(session, "tok-123")) + + # content_type=None is what stops aiohttp raising ContentTypeError. + assert session.calls[0]["url"].endswith("amazonaws.com/") + + +def test_validate_tokens_accepts_admin_scope_token() -> None: + """A token without 'openid' must now validate successfully.""" + session = FakeSession([FakeResponse(200, GET_USER_OK)]) + client = auth.DazeAuthClient("tok-123", "refresh-123") + + assert asyncio.run(client.async_validate_tokens(session)) is True + + +def test_validate_tokens_rejects_bad_token_without_leaking_body() -> None: + """A 400 NotAuthorizedException must raise AuthError, body withheld.""" + session = FakeSession([FakeResponse(400, GET_USER_DENIED)]) + client = auth.DazeAuthClient("tok-123", "refresh-123") + + try: + asyncio.run(client.async_validate_tokens(session)) + except auth.AuthError as err: + message = str(err) + assert "NotAuthorizedException" in message + assert "Invalid Access Token" not in message + else: + raise AssertionError("expected AuthError") + + +def test_get_user_info_flattens_attributes() -> None: + """The attribute list must become a dict the config flow can read.""" + session = FakeSession([FakeResponse(200, GET_USER_OK)]) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + info = asyncio.run(api_client.async_get_user_info()) + + assert info["email"] == "driver@example.invalid" + assert info["name"] == "Test Driver" + assert info["username"] == "1a2b3c" + + +def test_get_user_info_refreshes_on_400_then_succeeds() -> None: + """A rejected token must trigger exactly one refresh and retry. + + GetUser signals a bad token with 400, not 401, so the generic + retry-on-401 path in _request would never fire for it. + """ + session = FakeSession( + [ + FakeResponse(400, GET_USER_DENIED), + FakeResponse(200, {"access_token": "tok-456", "expires_in": 3600}), + FakeResponse(200, GET_USER_OK), + ] + ) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + info = asyncio.run(api_client.async_get_user_info()) + + assert info["email"] == "driver@example.invalid" + assert len(session.calls) == 3 + assert session.calls[1]["url"].endswith("/oauth2/token") + # The retry must use the refreshed token, not the stale one. + assert session.calls[2]["json"] == {"AccessToken": "tok-456"} + + +def test_get_user_info_raises_when_refresh_does_not_help() -> None: + """Two rejections in a row must surface as ApiAuthError.""" + session = FakeSession( + [ + FakeResponse(400, GET_USER_DENIED), + FakeResponse(200, {"access_token": "tok-456", "expires_in": 3600}), + FakeResponse(400, GET_USER_DENIED), + ] + ) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + try: + asyncio.run(api_client.async_get_user_info()) + except api.ApiAuthError: + pass + else: + raise AssertionError("expected ApiAuthError") + + +def _docstring_nodes(tree: ast.Module) -> set[int]: + """Return the ids of every docstring constant node in a module.""" + ids: set[int] = set() + holders = (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) + + for node in ast.walk(tree): + if not isinstance(node, holders): + continue + body = getattr(node, "body", []) + if not body: + continue + first = body[0] + if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant): + if isinstance(first.value.value, str): + ids.add(id(first.value)) + + return ids + + +def test_userinfo_endpoint_is_gone() -> None: + """Guard against the unusable endpoint being reintroduced. + + /oauth2/userInfo requires the 'openid' scope, which Daze never + issues, so calling it means setup is broken again. + + Only executable string literals count. Comments never reach the AST, + and docstrings are excluded deliberately, so the explanations of why + this endpoint is avoided do not trip the guard. + """ + offenders: list[str] = [] + + for path in sorted(PACKAGE_DIR.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + skip = _docstring_nodes(tree) + + for node in ast.walk(tree): + if not isinstance(node, ast.Constant): + continue + if id(node) in skip: + continue + if isinstance(node.value, str) and "userInfo" in node.value: + rel = path.relative_to(ROOT) + offenders.append(f"{rel}:{node.lineno}") + + assert not offenders, f"userInfo used in: {offenders}" + + +def _main() -> int: + """Run every test in this module and report results.""" + tests = [ + value + for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value) + ] + + failures = 0 + for test in tests: + try: + test() + except Exception as err: # noqa: BLE001 - standalone runner + failures += 1 + print(f"FAIL {test.__name__}: {type(err).__name__}: {err}") + else: + print(f"ok {test.__name__}") + + print(f"\n{len(tests) - failures} passed, {failures} failed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/tools/check_daze_tokens.py b/tools/check_daze_tokens.py new file mode 100755 index 0000000..92c18dd --- /dev/null +++ b/tools/check_daze_tokens.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python3 +"""Diagnose Daze Wallbox token failures outside Home Assistant. + +The integration's config flow reports a single generic ``invalid_token`` +error for at least four distinct causes, and it never exercises the +refresh token during setup. This script reproduces the exact requests the +integration makes, against the same endpoints, and reports which step +fails and why. + +It is a diagnostic tool only. It is not imported by the integration. + +Usage: + + python3 tools/check_daze_tokens.py + +Tokens are read from a hidden prompt on stdin. They are never passed as +command-line arguments (visible in ``ps`` and shell history), never +written to disk, and never printed back. Only the access token's +non-secret claims, HTTP status codes, and OAuth error codes are shown. + +Exit codes: + 0 - both tokens valid + 1 - access token rejected, refresh token valid (recoverable) + 2 - both tokens rejected (re-authentication required) + 3 - network or unexpected error +""" + +from __future__ import annotations + +import base64 +import getpass +import json +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +# Mirrors custom_components/daze/const.py +COGNITO_BASE_URL = "https://daze.auth.eu-central-1.amazoncognito.com" +CLIENT_ID = "4m0rp7oqarbrc3hn67ivvonba8" +REDIRECT_URI = "https://webportal.dazeservice.com/authentication/callback" + +# Cognito user pool API for the same region. Unlike the hosted-UI +# userInfo endpoint, GetUser accepts tokens carrying the +# 'aws.cognito.signin.user.admin' scope, which is what the Daze portal +# actually issues. +COGNITO_IDP_URL = "https://cognito-idp.eu-central-1.amazonaws.com/" +GET_USER_TARGET = "AWSCognitoIdentityProviderService.GetUser" + +TIMEOUT = 30 + + +def _post_form(url: str, fields: dict[str, str]) -> tuple[int, dict]: + """POST form-encoded fields and return (status, parsed body).""" + data = urllib.parse.urlencode(fields).encode() + request = urllib.request.Request( + url, + data=data, + headers={ + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" + }, + method="POST", + ) + return _send(request) + + +def _get(url: str, headers: dict[str, str]) -> tuple[int, dict]: + """GET a URL with headers and return (status, parsed body).""" + request = urllib.request.Request(url, headers=headers, method="GET") + return _send(request) + + +def _send(request: urllib.request.Request) -> tuple[int, dict]: + """Send a request, tolerating HTTP error statuses.""" + try: + with urllib.request.urlopen(request, timeout=TIMEOUT) as response: + raw = response.read().decode(errors="replace") + return response.status, _parse(raw) + except urllib.error.HTTPError as err: + raw = err.read().decode(errors="replace") + return err.code, _parse(raw) + + +def _parse(raw: str) -> dict: + """Parse a JSON body, falling back to a truncated raw string.""" + try: + parsed = json.loads(raw) + except ValueError: + return {"_raw": raw[:200]} + return parsed if isinstance(parsed, dict) else {"_raw": raw[:200]} + + +def decode_claims(token: str) -> dict | None: + """Decode a JWT payload without verifying its signature. + + Signature verification is Cognito's job; this only reads the public + claims so the caller can see token type, scope, and expiry. + """ + parts = token.split(".") + if len(parts) != 3: + return None + payload = parts[1] + payload += "=" * (-len(payload) % 4) + try: + return json.loads(base64.urlsafe_b64decode(payload)) + except (ValueError, TypeError): + return None + + +def report_claims(access_token: str) -> bool: + """Print the access token's non-secret claims. + + Returns True if the token is missing the 'openid' scope, which makes + the userInfo endpoint permanently unusable for this token. + """ + print("\n[1] Access token claims (decoded locally, nothing sent)") + + claims = decode_claims(access_token) + if claims is None: + print(" NOT a decodable JWT.") + print(" Expected three dot-separated base64 segments.") + print(" Cause: wrong value copied, or the token was truncated.") + return False + + token_use = claims.get("token_use") + scope = claims.get("scope", "") + exp = claims.get("exp") + + print(f" token_use : {token_use}") + print(f" scope : {scope}") + print(f" client_id : {claims.get('client_id')}") + + if exp: + remaining = exp - time.time() + expired = remaining <= 0 + print(f" expires : {time.ctime(exp)}") + if expired: + print(f" EXPIRED : yes, {int(-remaining // 60)} minutes ago") + else: + print(f" EXPIRED : no, {int(remaining // 60)} minutes left") + + if token_use != "access": + print() + print(f" PROBLEM: token_use is '{token_use}', not 'access'.") + print(" The /oauth2/userInfo endpoint only accepts the access") + print(" token. Copy the value under the key ending in") + print(" '.accessToken', not '.idToken'.") + + scope_missing = bool(scope) and "openid" not in scope + if scope_missing: + print() + print(" PROBLEM: the 'openid' scope is missing.") + print(" /oauth2/userInfo requires it, so this token can never") + print(" pass the integration's validation step. Copying a fresh") + print(" token will not help: the portal issues every token with") + print(" this same scope set.") + + if claims.get("client_id") and claims["client_id"] != CLIENT_ID: + print() + print(" NOTE: this token was issued to a different OAuth client") + print(f" than the one the integration uses ({CLIENT_ID}).") + + return scope_missing + + +def check_access_token(access_token: str) -> bool: + """Replicate DazeAuthClient.async_validate_tokens. Return True if valid.""" + print("\n[2] Access token against Cognito userInfo") + print(" Same request as custom_components/daze/api/auth.py:167") + + url = f"{COGNITO_BASE_URL}/oauth2/userInfo" + headers = {"authorization": f"Bearer {access_token}"} + + try: + status, body = _get(url, headers) + except urllib.error.URLError as err: + print(f" NETWORK ERROR: {err.reason}") + raise SystemExit(3) from err + + print(f" HTTP {status}") + + if status == 200: + print(" VALID. The integration would accept this access token.") + return True + + error = body.get("error", "") + description = body.get("error_description", "") + if error: + print(f" error: {error}") + if description: + print(f" error_description: {description}") + if not error and "_raw" in body: + print(f" body: {body['_raw']}") + + print() + print(" REJECTED. The config flow turns this into 'invalid_token'.") + return False + + +def check_refresh_token(refresh_token: str) -> bool: + """Replicate DazeAuthClient.async_refresh_access_token. + + The config flow never performs this step. If it did, an expired + access token would not block setup. + """ + print("\n[3] Refresh token against Cognito token endpoint") + print(" Same request as custom_components/daze/api/auth.py:85") + print(" NOTE: the config flow never runs this step.") + + url = f"{COGNITO_BASE_URL}/oauth2/token" + fields = { + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + + try: + status, body = _post_form(url, fields) + except urllib.error.URLError as err: + print(f" NETWORK ERROR: {err.reason}") + raise SystemExit(3) from err + + print(f" HTTP {status}") + + if status == 200 and "access_token" in body: + expires_in = body.get("expires_in", "unknown") + rotated = "refresh_token" in body + print(f" VALID. A new access token was issued ({expires_in}s).") + print(f" Refresh token rotated by Cognito: {rotated}") + return True + + error = body.get("error", "") + if error: + print(f" error: {error}") + if error == "invalid_grant": + print(" Meaning: expired, revoked, or not issued to this client.") + elif error == "invalid_client": + print(" Meaning: the hardcoded CLIENT_ID is no longer valid.") + elif "_raw" in body: + print(f" body: {body['_raw']}") + + print("\n REJECTED.") + return False + + +def check_get_user(access_token: str) -> bool: + """Try the Cognito user pool GetUser call as a userInfo replacement. + + The integration does not use this endpoint. It is tested here because + GetUser accepts the 'aws.cognito.signin.user.admin' scope and returns + the email address that the config flow needs, which makes it a viable + substitute for the userInfo call that rejects these tokens. + """ + print("\n[4] Access token against Cognito GetUser (proposed fix)") + print(" The integration does NOT currently call this.") + + request = urllib.request.Request( + COGNITO_IDP_URL, + data=json.dumps({"AccessToken": access_token}).encode(), + headers={ + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": GET_USER_TARGET, + }, + method="POST", + ) + + try: + status, body = _send(request) + except urllib.error.URLError as err: + print(f" NETWORK ERROR: {err.reason}") + return False + + print(f" HTTP {status}") + + if status == 200: + attributes = body.get("UserAttributes", []) + names = sorted( + attr.get("Name", "") for attr in attributes if attr.get("Name") + ) + has_email = "email" in names + print(" VALID. This endpoint accepts your token.") + print(f" Attributes returned: {', '.join(names)}") + print(f" Supplies the email the config flow needs: {has_email}") + return has_email + + error_type = body.get("__type", "") + message = body.get("message", "") + if error_type: + print(f" error: {error_type}") + if message: + print(f" message: {message}") + return False + + +def verdict( + access_ok: bool, + refresh_ok: bool, + scope_missing: bool, + get_user_ok: bool, +) -> int: + """Print a conclusion and return the process exit code.""" + print("\n" + "=" * 60) + + if access_ok and refresh_ok: + print("VERDICT: both tokens are valid.") + print() + print("Setup should succeed. If it still fails, the tokens are") + print("likely being altered between your clipboard and the form:") + print("check for a trailing newline or space, since the schema at") + print("config_flow.py:36-41 does not strip whitespace.") + return 0 + + if scope_missing: + print("VERDICT: wrong validation endpoint for this token type.") + print() + print("Your tokens are healthy. The access token is unexpired and") + print("the refresh token works. The problem is that the Daze portal") + print("issues access tokens scoped 'aws.cognito.signin.user.admin'") + print("without 'openid', and /oauth2/userInfo requires 'openid'.") + print() + print("So async_validate_tokens can never succeed with a token from") + print("this portal, no matter how fresh it is. Re-copying the token") + print("is not a workaround; there is no user-side workaround.") + print() + if get_user_ok: + print("GetUser accepted the same token and returned the email") + print("address the config flow needs. Replacing the userInfo") + print("calls in api/auth.py and api/__init__.py with GetUser") + print("fixes setup without changing anything else.") + else: + print("GetUser did not succeed either; see section [4] above") + print("before changing the validation call.") + return 1 + + if not access_ok and refresh_ok: + print("VERDICT: access token rejected, refresh token VALID.") + print() + print("Your account and refresh token are fine; only the") + print("short-lived access token was rejected.") + print() + print("The integration cannot recover on its own because the config") + print("flow calls async_validate_tokens and gives up, without ever") + print("calling async_refresh_access_token.") + print() + print("Workaround: copy a brand new access token and submit the") + print("form immediately, within the hour.") + print() + print("Real fix: have the config flow attempt a refresh before") + print("raising invalid_token.") + return 1 + + if access_ok and not refresh_ok: + print("VERDICT: access token valid, refresh token rejected.") + print() + print("Setup will succeed now but will break at the first refresh,") + print("leaving the integration stuck until you re-authenticate.") + print("Re-copy the refresh token.") + return 1 + + print("VERDICT: both tokens rejected.") + print() + print("Log in again at https://webportal.dazeservice.com and copy a") + print("fresh pair. Check section [1] above: if token_use was not") + print("'access', you copied the wrong value rather than a stale one.") + return 2 + + +def main() -> int: + """Run all checks and print a verdict.""" + print("Daze token diagnostic") + print("Endpoints and client ID are read from const.py values.") + print("Tokens are not stored, logged, or transmitted anywhere except") + print("to Cognito, exactly as the integration does.") + print() + + access_token = getpass.getpass("Access token (input hidden): ").strip() + refresh_token = getpass.getpass("Refresh token (input hidden): ").strip() + + if not access_token or not refresh_token: + print("\nBoth tokens are required.") + return 3 + + scope_missing = report_claims(access_token) + access_ok = check_access_token(access_token) + refresh_ok = check_refresh_token(refresh_token) + get_user_ok = check_get_user(access_token) + + return verdict(access_ok, refresh_ok, scope_missing, get_user_ok) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nAborted.") + sys.exit(3) From c6ed3b049503b96ee76c424429263ee1cf2ee647 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 16:51:26 +0100 Subject: [PATCH 02/82] chore: bump version to 0.1.1 Marks the release containing the Cognito GetUser authentication fix, which repairs config flow setup for all users. Co-Authored-By: Claude Opus 5 --- custom_components/daze/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index 0831407..bd695c0 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/arest/daze-addon/issues", "requirements": [], - "version": "0.1.0" + "version": "0.1.1" } From f74f8edb1b42b033ffde997afeb4ad7b987e19f2 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 16:53:49 +0100 Subject: [PATCH 03/82] chore: point project metadata at this fork The git remote already pointed here, but the integration manifest and README still referenced the upstream repository. Users installing from this fork would have filed issues on the upstream tracker, and the README's HACS install URL would have sent them to upstream instead. Repoint codeowners, documentation and issue_tracker in the manifest, and the badges and install URL in the README. Co-Authored-By: Claude Opus 5 --- README.md | 6 +++--- custom_components/daze/manifest.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4c83ff1..b41d924 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Daze Wallbox [![HA Community](https://img.shields.io/badge/Home%20Assistant-2025.x-41BDF5?logo=homeassistant)](https://www.home-assistant.io/) -[![HACS Validation](https://github.com/arest/daze-addon/actions/workflows/validate.yaml/badge.svg)](https://github.com/arest/daze-addon/actions/workflows/validate.yaml) -[![GitHub](https://img.shields.io/github/license/arest/daze-addon)](LICENSE) +[![HACS Validation](https://github.com/tarrinho/daze-addon/actions/workflows/validate.yaml/badge.svg)](https://github.com/tarrinho/daze-addon/actions/workflows/validate.yaml) +[![GitHub](https://img.shields.io/github/license/tarrinho/daze-addon)](LICENSE) Home Assistant integration for **Daze WallBox EV chargers**. Monitor charging metrics in real time and control your wallbox directly from your HA dashboard — no separate app required. @@ -33,7 +33,7 @@ Daze wallboxes are managed through the [Daze web portal](https://webportal.dazes 3. Click the three dots in the top-right corner and select **Custom repositories** 4. Add this repository URL: ``` - https://github.com/arest/daze-addon + https://github.com/tarrinho/daze-addon ``` 5. Select **Integration** as the category and click **Add** 6. Close the dialog — the Daze Wallbox integration should now appear in HACS diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index bd695c0..0393bba 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -1,13 +1,13 @@ { "domain": "daze", "name": "Daze Wallbox Integration", - "codeowners": ["@arest"], + "codeowners": ["@tarrinho"], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/arest/daze-addon", + "documentation": "https://github.com/tarrinho/daze-addon", "integration_type": "device", "iot_class": "cloud_polling", - "issue_tracker": "https://github.com/arest/daze-addon/issues", + "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], "version": "0.1.1" } From 1fe980c69c3e3e3e92980964d3988f58ca86b16f Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 17:13:41 +0100 Subject: [PATCH 04/82] fix: stop polling the absent recharge-session endpoint every 30s For some accounts GET /v3/networks/{uid}/rechargeSessions returns HTTP 404 with an empty body. The coordinator treated that as a transient error, so it re-requested on every poll and logged a warning twice per attempt, once from the request layer and once from the caller. Probing confirmed the condition is durable rather than transient. With the same token and network UID, /networks/{uid}/evses and /users/{email}/networks both return 200, while twelve plausible spellings of the session path all return 404 with an empty body, including paths that do not exist. This API answers 404 for any unrouted path, so the session resource is simply not reachable for the account. Add ApiNotFoundError, a subclass of ApiError so existing handlers are unaffected, and raise it for 404 without logging the empty body. In the coordinator, treat a missing session endpoint as durable: log once at info, explain that live metrics and charge control still work, and retry hourly instead of every 30 seconds. Also throttle the normal session fetch to once every five minutes and cache the result between polls. Session history only changes when a charge ends, so requesting up to 1000 records twice a minute was needless load and a rate-limiting risk. Session and lifetime sensors stay empty on affected accounts. The remaining 15 sensors, charge control, current limiting and eco mode are unaffected. Co-Authored-By: Claude Opus 5 --- custom_components/daze/api/__init__.py | 22 +++ custom_components/daze/coordinator.py | 45 ++++- tests/test_auth_getuser.py | 41 ++++ tools/probe_sessions_endpoint.py | 259 +++++++++++++++++++++++++ 4 files changed, 364 insertions(+), 3 deletions(-) create mode 100755 tools/probe_sessions_endpoint.py diff --git a/custom_components/daze/api/__init__.py b/custom_components/daze/api/__init__.py index 6c36312..a133889 100644 --- a/custom_components/daze/api/__init__.py +++ b/custom_components/daze/api/__init__.py @@ -31,6 +31,15 @@ class ApiError(Exception): """Raised for non-auth API errors (4xx, 5xx, network issues).""" +class ApiNotFoundError(ApiError): + """Raised when the API answers 404 for a resource. + + Subclasses ApiError so existing handlers keep working, while letting + callers treat a missing resource as a durable condition rather than + a transient failure worth retrying every poll. + """ + + def _flatten_user_attributes(payload: dict[str, Any]) -> dict[str, Any]: """Flatten a Cognito GetUser response into a plain attribute dict. @@ -135,6 +144,14 @@ async def _request( ) return await self._handle_401(method, url, **kwargs) + if response.status == 404: + # Logged by the caller, which knows whether a + # missing resource is expected. Logging here too + # would duplicate every message. + raise ApiNotFoundError( + f"API {method} {url} returned 404" + ) + if response.status >= 400: body = await response.text() _LOGGER.warning( @@ -214,6 +231,11 @@ async def _handle_401( "re-authentication required" ) + if response.status == 404: + raise ApiNotFoundError( + f"API {method} {url} returned 404" + ) + if response.status >= 400: body = await response.text() _LOGGER.warning( diff --git a/custom_components/daze/coordinator.py b/custom_components/daze/coordinator.py index 9c568ba..fb56c1b 100644 --- a/custom_components/daze/coordinator.py +++ b/custom_components/daze/coordinator.py @@ -16,7 +16,7 @@ UpdateFailed, ) -from .api import ApiAuthError, ApiError, DazeApiClient +from .api import ApiAuthError, ApiError, ApiNotFoundError, DazeApiClient from .api.auth import DazeAuthClient from .const import ( CONF_ACCESS_TOKEN, @@ -32,6 +32,16 @@ type DazeCoordinatorData = dict[str, Any] +# Session history changes only when a charge ends, so it does not need +# the live metric cadence. Re-requesting the full history on every poll +# was wasteful and risked upstream rate limiting. +SESSION_FETCH_INTERVAL = 300 # seconds + +# Some accounts get HTTP 404 from the recharge-session endpoint. That is +# a durable condition, not a transient error, so back off hard instead +# of retrying every poll and filling the log with warnings. +SESSION_MISSING_RETRY_INTERVAL = 3600 # seconds + class DazeDataUpdateCoordinator( DataUpdateCoordinator[DazeCoordinatorData] @@ -68,6 +78,9 @@ def __init__( self._last_fail_time: float | None = None self._total_updates: int = 0 self._consecutive_failures: int = 0 + self._cached_sessions: list[RechargeSession] = [] + self._next_session_fetch: float = 0.0 + self._sessions_missing_logged: bool = False super().__init__( hass, @@ -170,8 +183,12 @@ async def _async_update_data(self) -> DazeCoordinatorData: self._last_success_time = time.time() self._consecutive_failures = 0 - # Fetch session data (secondary — failures are non-fatal) - sessions = await self._async_fetch_sessions() + # Fetch session data (secondary — failures are non-fatal). + # Throttled: history only changes when a charge ends. + if time.time() >= self._next_session_fetch: + self._cached_sessions = await self._async_fetch_sessions() + + sessions = self._cached_sessions data["sessions"] = sessions data.update(self._compute_session_fields(sessions)) @@ -245,6 +262,8 @@ async def _async_fetch_sessions( A list of RechargeSession objects (may be empty). """ + self._next_session_fetch = time.time() + SESSION_FETCH_INTERVAL + try: sessions_raw = ( await self._api_client.async_get_recharge_sessions( @@ -256,10 +275,30 @@ async def _async_fetch_sessions( len(sessions_raw), self._network_uid, ) + self._sessions_missing_logged = False return [ RechargeSession.from_dict(s) for s in sessions_raw ] + except ApiNotFoundError: + # The endpoint is absent for this account. Say so once, then + # back off: retrying every poll only spams the log. + self._next_session_fetch = ( + time.time() + SESSION_MISSING_RETRY_INTERVAL + ) + + if not self._sessions_missing_logged: + self._sessions_missing_logged = True + _LOGGER.info( + "Recharge session history is unavailable for network " + "%s (the API returned 404). Session and lifetime " + "sensors will stay empty; live metrics and charge " + "control are unaffected. Retrying hourly.", + self._network_uid, + ) + + return [] + except ApiAuthError: # Auth errors on session endpoint are unexpected (the # socket fetch already validated the token), but handle diff --git a/tests/test_auth_getuser.py b/tests/test_auth_getuser.py index 4a6daba..5f78a07 100644 --- a/tests/test_auth_getuser.py +++ b/tests/test_auth_getuser.py @@ -291,6 +291,47 @@ def test_userinfo_endpoint_is_gone() -> None: assert not offenders, f"userInfo used in: {offenders}" + +def test_404_raises_api_not_found_without_logging_body() -> None: + """A 404 must raise ApiNotFoundError so callers can back off. + + The recharge-session endpoint returns 404 with an empty body for + some accounts. Treating that as a generic ApiError made the + coordinator retry and log a warning on every poll. + """ + session = FakeSession([FakeResponse(404, {"_empty": True})]) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + try: + asyncio.run(api_client.async_get_recharge_sessions("net-uid")) + except api.ApiNotFoundError as err: + assert "404" in str(err) + else: + raise AssertionError("expected ApiNotFoundError") + + +def test_api_not_found_is_an_api_error() -> None: + """Existing handlers catching ApiError must still catch 404s.""" + assert issubclass(api.ApiNotFoundError, api.ApiError) + + +def test_non_404_errors_still_raise_plain_api_error() -> None: + """A 500 must remain a plain ApiError, not a not-found.""" + session = FakeSession([FakeResponse(500, {"message": "boom"})]) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + try: + asyncio.run(api_client.async_get_recharge_sessions("net-uid")) + except api.ApiNotFoundError: + raise AssertionError("500 must not be ApiNotFoundError") + except api.ApiError as err: + assert "500" in str(err) + else: + raise AssertionError("expected ApiError") + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tools/probe_sessions_endpoint.py b/tools/probe_sessions_endpoint.py new file mode 100755 index 0000000..e5e7ea8 --- /dev/null +++ b/tools/probe_sessions_endpoint.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Find the working recharge-session endpoint on the Daze web API. + +The integration requests: + + GET /v3/networks/{network_uid}/rechargeSessions?TotalLimit=1000 + +which returns HTTP 404 with an empty body. The same network UID works +for /v3/networks/{uid}/evses, so the UID and the base path are correct +and only this sub-resource is wrong. + +This script probes plausible alternatives and reports which ones answer, +so the fix is based on a measurement rather than a guess. Every request +is a GET; nothing is created, modified, or deleted. + +Usage: + + python3 tools/probe_sessions_endpoint.py + +Tokens are read from hidden prompts, never passed as arguments, never +written to disk, and never printed. A fresh access token is obtained +from the refresh token first, so an hour-old access token is fine. +""" + +from __future__ import annotations + +import getpass +import json +import sys +import urllib.error +import urllib.parse +import urllib.request + +# Mirrors custom_components/daze/const.py +API_BASE_URL = "https://webapi.dazeservice.com/v3" +COGNITO_BASE_URL = "https://daze.auth.eu-central-1.amazoncognito.com" +COGNITO_IDP_URL = "https://cognito-idp.eu-central-1.amazonaws.com/" +CLIENT_ID = "4m0rp7oqarbrc3hn67ivvonba8" +REDIRECT_URI = "https://webportal.dazeservice.com/authentication/callback" +GET_USER_TARGET = "AWSCognitoIdentityProviderService.GetUser" + +TIMEOUT = 30 + + +def _send(request: urllib.request.Request) -> tuple[int, object]: + """Send a request, tolerating HTTP error statuses.""" + try: + with urllib.request.urlopen(request, timeout=TIMEOUT) as response: + raw = response.read().decode(errors="replace") + return response.status, _parse(raw) + except urllib.error.HTTPError as err: + raw = err.read().decode(errors="replace") + return err.code, _parse(raw) + except urllib.error.URLError as err: + return 0, {"_error": str(err.reason)} + + +def _parse(raw: str) -> object: + """Parse a JSON body, falling back to a truncated raw string.""" + if not raw.strip(): + return {"_empty": True} + try: + return json.loads(raw) + except ValueError: + return {"_raw": raw[:200]} + + +def refresh_access_token(refresh_token: str) -> str: + """Exchange a refresh token for a fresh access token.""" + data = urllib.parse.urlencode( + { + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + ).encode() + + request = urllib.request.Request( + f"{COGNITO_BASE_URL}/oauth2/token", + data=data, + headers={ + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" + }, + method="POST", + ) + + status, body = _send(request) + if status != 200 or not isinstance(body, dict): + print(f"Could not refresh the access token (HTTP {status}).") + raise SystemExit(2) + + token = body.get("access_token") + if not isinstance(token, str): + print("Refresh succeeded but returned no access token.") + raise SystemExit(2) + + return token + + +def get_email(access_token: str) -> str: + """Read the account email via Cognito GetUser.""" + request = urllib.request.Request( + COGNITO_IDP_URL, + data=json.dumps({"AccessToken": access_token}).encode(), + headers={ + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": GET_USER_TARGET, + }, + method="POST", + ) + + status, body = _send(request) + if status != 200 or not isinstance(body, dict): + return "" + + for attribute in body.get("UserAttributes", []): + if isinstance(attribute, dict) and attribute.get("Name") == "email": + return str(attribute.get("Value", "")) + + return "" + + +def probe(access_token: str, path: str) -> tuple[int, str]: + """GET one API path and summarise the response shape.""" + request = urllib.request.Request( + f"{API_BASE_URL}{path}", + headers={"authorization": f"Bearer {access_token}"}, + method="GET", + ) + + status, body = _send(request) + + if status == 200 and isinstance(body, dict): + data = body.get("data") + if isinstance(data, list): + return status, f"list of {len(data)} item(s)" + if isinstance(data, dict): + return status, f"object with keys {sorted(data)[:6]}" + return status, f"keys {sorted(body)[:6]}" + + if isinstance(body, dict): + if body.get("_empty"): + return status, "empty body" + if body.get("_error"): + return status, f"network error: {body['_error']}" + message = body.get("message") or body.get("error") + if message: + return status, str(message)[:120] + return status, f"keys {sorted(body)[:6]}" + + return status, str(body)[:120] + + +def build_candidates(network_uid: str, serial: str, email: str) -> list[str]: + """Build the list of paths to probe, most likely first.""" + uid = urllib.parse.quote(network_uid, safe="") + ser = urllib.parse.quote(serial, safe="") + mail = urllib.parse.quote(email, safe="") if email else "" + + candidates = [ + # What the integration currently requests. + f"/networks/{uid}/rechargeSessions?TotalLimit=1000", + # Same path, no query, in case the parameter is the problem. + f"/networks/{uid}/rechargeSessions", + # Known-good sibling, to prove the UID and base path are fine. + f"/networks/{uid}/evses?includeEcoInfo=false", + # Casing and separator variants. + f"/networks/{uid}/rechargesessions", + f"/networks/{uid}/recharge-sessions", + f"/networks/{uid}/sessions", + # Session history hung off the charger rather than the network. + f"/evses/{ser}/rechargeSessions", + f"/evses/{ser}/sessions", + f"/sockets/{ser}/rechargeSessions", + f"/sockets/{ser}/sessions", + # Alternative query parameter spellings. + f"/networks/{uid}/rechargeSessions?limit=100", + f"/networks/{uid}/rechargeSessions?totalLimit=1000", + ] + + if mail: + candidates.append(f"/users/{mail}/rechargeSessions") + candidates.append(f"/users/{mail}/networks?includeStats=true") + + return candidates + + +def main() -> int: + """Probe every candidate endpoint and summarise the findings.""" + print("Daze recharge-session endpoint probe") + print("All requests are GETs. Nothing is modified.") + print() + + refresh_token = getpass.getpass("Refresh token (input hidden): ").strip() + if not refresh_token: + print("A refresh token is required.") + return 3 + + network_uid = input("Network UID: ").strip() + serial = input("Wallbox serial number: ").strip() + + if not network_uid or not serial: + print("Both the network UID and the serial number are required.") + return 3 + + print("\nRefreshing the access token...") + access_token = refresh_access_token(refresh_token) + print("Got a fresh access token.") + + email = get_email(access_token) + print(f"Account email resolved: {'yes' if email else 'no'}") + + candidates = build_candidates(network_uid, serial, email) + + print(f"\nProbing {len(candidates)} endpoints:\n") + + working: list[tuple[str, str]] = [] + for path in candidates: + status, summary = probe(access_token, path) + marker = "OK " if status == 200 else " " + display = path.replace(network_uid, "{uid}").replace(serial, "{serial}") + if email: + display = display.replace(urllib.parse.quote(email, safe=""), "{email}") + print(f" {marker}{status:>3} {display}") + print(f" {summary}") + if status == 200: + working.append((display, summary)) + + print("\n" + "=" * 60) + + if not working: + print("No candidate returned 200.") + print() + print("Capture the real request from the browser instead: open") + print("https://webportal.dazeservice.com, view your charging") + print("history, and look in DevTools > Network for the request") + print("the page makes. The path it uses is the one to implement.") + return 1 + + print(f"{len(working)} endpoint(s) responded:") + for path, summary in working: + print(f" {path}") + print(f" {summary}") + + print() + print("If the evses path is the only one that worked, the session") + print("endpoint has moved or never existed at that path; capture the") + print("real one from the portal's DevTools Network tab.") + + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nAborted.") + sys.exit(3) From eee8610a01dccb40de202e8018a12596c105cc92 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 17:15:59 +0100 Subject: [PATCH 05/82] chore: bump version to 0.1.2 Covers the recharge-session resilience work in 1fe980c, which shipped after the 0.1.1 bump and changed runtime behaviour: - HTTP 404 from the session endpoint is now a durable condition, logged once at info instead of a warning pair every 30 seconds - Session history is fetched at most every five minutes and cached between polls, instead of pulling up to 1000 records twice a minute Also make tools/probe_sessions_endpoint.py discover the network UID and wallbox serial from the API, the same way the config flow does, so the only thing it asks for is the refresh token. It reports every network and charger it finds, and notes when an account has more than one charger, since the config flow only ever uses the first. Co-Authored-By: Claude Opus 5 --- custom_components/daze/manifest.json | 2 +- tools/probe_sessions_endpoint.py | 91 +++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index 0393bba..b3e4f00 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.1" + "version": "0.1.2" } diff --git a/tools/probe_sessions_endpoint.py b/tools/probe_sessions_endpoint.py index e5e7ea8..660a6ee 100755 --- a/tools/probe_sessions_endpoint.py +++ b/tools/probe_sessions_endpoint.py @@ -186,6 +186,78 @@ def build_candidates(network_uid: str, serial: str, email: str) -> list[str]: return candidates +def discover(access_token: str, email: str) -> tuple[str, str]: + """Look up the network UID and wallbox serial from the API. + + Mirrors what the config flow does, so the probe needs nothing typed + beyond the refresh token. + + Returns: + A tuple of (network_uid, serial). Either may be empty if + discovery failed. + + """ + if not email: + return "", "" + + mail = urllib.parse.quote(email, safe="") + status, body = probe_raw( + access_token, f"/users/{mail}/networks?includeStats=true" + ) + + networks = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(networks, list) or not networks: + print(f" Could not list networks (HTTP {status}).") + return "", "" + + print(f" Networks found: {len(networks)}") + for net in networks: + if isinstance(net, dict): + print(f" - {net.get('name', '?')} uid={net.get('uid', '?')}") + + first = networks[0] if isinstance(networks[0], dict) else {} + network_uid = str(first.get("uid", "")) + if not network_uid: + return "", "" + + uid = urllib.parse.quote(network_uid, safe="") + status, body = probe_raw( + access_token, f"/networks/{uid}/evses?includeEcoInfo=false" + ) + + evses = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(evses, list) or not evses: + print(f" Could not list chargers (HTTP {status}).") + return network_uid, "" + + print(f" Chargers found: {len(evses)}") + for evse in evses: + if isinstance(evse, dict): + print( + f" - {evse.get('evseName', '?')} " + f"serial={evse.get('serialNumber', '?')}" + ) + + if len(evses) > 1: + print( + " NOTE: more than one charger. The integration only ever " + "uses the first (config_flow.py:304)." + ) + + first_evse = evses[0] if isinstance(evses[0], dict) else {} + return network_uid, str(first_evse.get("serialNumber", "")) + + +def probe_raw(access_token: str, path: str) -> tuple[int, object]: + """GET one API path and return the raw status and parsed body.""" + request = urllib.request.Request( + f"{API_BASE_URL}{path}", + headers={"authorization": f"Bearer {access_token}"}, + method="GET", + ) + return _send(request) + + def main() -> int: """Probe every candidate endpoint and summarise the findings.""" print("Daze recharge-session endpoint probe") @@ -197,13 +269,6 @@ def main() -> int: print("A refresh token is required.") return 3 - network_uid = input("Network UID: ").strip() - serial = input("Wallbox serial number: ").strip() - - if not network_uid or not serial: - print("Both the network UID and the serial number are required.") - return 3 - print("\nRefreshing the access token...") access_token = refresh_access_token(refresh_token) print("Got a fresh access token.") @@ -211,6 +276,18 @@ def main() -> int: email = get_email(access_token) print(f"Account email resolved: {'yes' if email else 'no'}") + print("\nDiscovering network and charger (same calls as the config flow):") + network_uid, serial = discover(access_token, email) + + if not network_uid: + network_uid = input(" Network UID (discovery failed): ").strip() + if not serial: + serial = input(" Wallbox serial (discovery failed): ").strip() + + if not network_uid or not serial: + print("Need both a network UID and a serial to continue.") + return 3 + candidates = build_candidates(network_uid, serial, email) print(f"\nProbing {len(candidates)} endpoints:\n") From 470e930989647140523c6d6dcf103c8449bc8f6b Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 17:46:26 +0100 Subject: [PATCH 06/82] fix: read the fields the API actually returns Every entity was created but read Unknown, with no error logged. The API answers 200, so nothing failed; the value functions simply looked in the wrong place and .get() returned None for all 16 fields. Captured payloads from a live DT01 show the data spread across two endpoints and three nesting levels: - Live session metrics (power, energy, currents, voltages) arrive under chargeSession in the remoteInfo response, not at the top level. The field names were always correct, only the depth was wrong. - Temperatures, the grid limit, eco mode, the configured current and the photovoltaic flag appear only in the EVSE record from /networks/{uid}/evses, which the coordinator never fetched. - Status is reported as the integer evseState plus independent pause and error flags. Nothing returns the string evseStatus that the sensor catalog and the charge switch compared against. Add payload.py to flatten both responses into one mapping, with precedence ordered so the live session wins over the cached socket snapshot. It imports nothing from Home Assistant so it is directly testable. Derive evseStatus from evseState, the pause flags, the system error and the active flag. Only state 3 is confirmed: it was observed while the charger delivered 2688 W with a session running. Unrecognised values report idle rather than guessing, so nothing is falsely shown as charging. Fetch the EVSE record in the coordinator, cached for two minutes since it holds configuration and slow-moving readings. Repoint the four catalog fields whose names genuinely differ: boardTemperature to lastBoardL1Temperature, caseTemperature to lastCaseTemperature, gridMaxPower to supplyGridMaxPower, and the photovoltaic flag. Add nextScheduleInfo to the schedule key list. Add tests/test_payload.py, whose fixtures are captured API responses with device identifiers replaced. It asserts that no live sensor reads None, that the catalog never reads a field the API does not return, and that unknown states are not reported as charging. Ignore diagnostic output files by name so captured device data cannot be committed. Bump version to 0.1.3. Co-Authored-By: Claude Opus 5 --- .gitignore | 6 +- custom_components/daze/api/__init__.py | 26 ++ custom_components/daze/coordinator.py | 32 +- custom_components/daze/manifest.json | 6 +- custom_components/daze/payload.py | 143 ++++++++ custom_components/daze/sensor_catalog.py | 9 +- tests/test_payload.py | 309 ++++++++++++++++ tools/probe_socket_data.py | 443 +++++++++++++++++++++++ 8 files changed, 965 insertions(+), 9 deletions(-) create mode 100644 custom_components/daze/payload.py create mode 100644 tests/test_payload.py create mode 100755 tools/probe_socket_data.py diff --git a/.gitignore b/.gitignore index 4585443..5a6902f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,8 @@ credentials.* *.key *.cert *.p12 -*.pfx \ No newline at end of file +*.pfx +# Probe and diagnostic output (may contain device identifiers) +/log +*.out +probe-*.txt diff --git a/custom_components/daze/api/__init__.py b/custom_components/daze/api/__init__.py index a133889..80b440c 100644 --- a/custom_components/daze/api/__init__.py +++ b/custom_components/daze/api/__init__.py @@ -354,6 +354,32 @@ async def async_get_evses( data = await self._request("GET", url) return data.get("data", []) + async def async_get_evse_record( + self, network_uid: str, serial: str + ) -> dict[str, Any]: + """Fetch the charger record for one serial number. + + The socket remoteInfo response carries the live session but not + the charger's temperatures, grid limits, eco mode or configured + current. Those live here, so the coordinator needs both. + + Args: + network_uid: The unique ID of the network. + serial: The serial number to select from the network. + + Returns: + The matching EVSE record, or an empty dict if absent. + + """ + url = f"{API_BASE_URL}/networks/{network_uid}/evses?includeEcoInfo=true" + data = await self._request("GET", url) + + for evse in data.get("data", []): + if isinstance(evse, dict) and evse.get("serialNumber") == serial: + return evse + + return {} + async def async_get_socket_remote_info( self, serial: str ) -> dict[str, Any]: diff --git a/custom_components/daze/coordinator.py b/custom_components/daze/coordinator.py index fb56c1b..b5581de 100644 --- a/custom_components/daze/coordinator.py +++ b/custom_components/daze/coordinator.py @@ -27,6 +27,7 @@ DOMAIN, ) from .models import RechargeSession +from .payload import merge_payload _LOGGER = logging.getLogger(__name__) @@ -42,6 +43,10 @@ # of retrying every poll and filling the log with warnings. SESSION_MISSING_RETRY_INTERVAL = 3600 # seconds +# The charger record holds configuration and slow-moving readings, +# so it does not need the live metric cadence. +EVSE_FETCH_INTERVAL = 120 # seconds + class DazeDataUpdateCoordinator( DataUpdateCoordinator[DazeCoordinatorData] @@ -79,6 +84,8 @@ def __init__( self._total_updates: int = 0 self._consecutive_failures: int = 0 self._cached_sessions: list[RechargeSession] = [] + self._cached_evse: dict[str, Any] = {} + self._next_evse_fetch: float = 0.0 self._next_session_fetch: float = 0.0 self._sessions_missing_logged: bool = False @@ -145,12 +152,33 @@ async def _async_update_data(self) -> DazeCoordinatorData: self._total_updates += 1 try: - data = await self._api_client.async_get_socket_remote_info( + remote_info = await self._api_client.async_get_socket_remote_info( self._serial_number ) + + # The EVSE record supplies temperatures, grid limits, eco + # mode and the configured current, none of which appear in + # remoteInfo. It changes slowly, so it is cached. + if time.time() >= self._next_evse_fetch: + try: + self._cached_evse = ( + await self._api_client.async_get_evse_record( + self._network_uid, self._serial_number + ) + ) + except ApiError as err: + _LOGGER.debug( + "Could not refresh the EVSE record: %s", err + ) + else: + self._next_evse_fetch = time.time() + EVSE_FETCH_INTERVAL + + data = merge_payload(remote_info, self._cached_evse) + _LOGGER.debug( - "Coordinator fetched socket data for %s", + "Coordinator fetched socket data for %s (%d fields)", self._serial_number, + len(data), ) except ApiAuthError as err: self._last_fail_time = time.time() diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index b3e4f00..86cfff9 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -1,7 +1,9 @@ { "domain": "daze", "name": "Daze Wallbox Integration", - "codeowners": ["@tarrinho"], + "codeowners": [ + "@tarrinho" + ], "config_flow": true, "dependencies": [], "documentation": "https://github.com/tarrinho/daze-addon", @@ -9,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.2" + "version": "0.1.3" } diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py new file mode 100644 index 0000000..f207671 --- /dev/null +++ b/custom_components/daze/payload.py @@ -0,0 +1,143 @@ +"""Normalise Daze API responses into one flat mapping. + +The live data an entity needs is spread across two endpoints and three +nesting levels: + +- ``/sockets/{serial}/remoteInfo`` returns the current session under a + ``chargeSession`` object, plus top-level state flags. +- ``/networks/{uid}/evses`` returns the charger record, with per-socket + readings under ``sockets[0]`` and configuration at the top level. + +Neither endpoint alone covers the sensors and controls. Temperatures, +grid limits, eco mode and the configured current live only in the EVSE +record; live session power, energy and elapsed time live only in +``chargeSession``. + +This module flattens both into a single mapping so every value function +can read the field it wants by name, regardless of where the API chose +to put it. It imports nothing from Home Assistant so it can be tested +directly. +""" + +from __future__ import annotations + +from typing import Any + +# The only EVSE state value confirmed against live hardware: observed +# as 3 while the charger was delivering 2688 W with a session running. +# Other values are inferred conservatively rather than guessed, so an +# unrecognised state reports "idle" rather than inventing a meaning. +EVSE_STATE_CHARGING = 3 + +STATUS_CHARGING = "charging" +STATUS_IDLE = "idle" +STATUS_PAUSED = "paused" +STATUS_ERROR = "error" +STATUS_OFFLINE = "offline" + +PAUSE_FLAGS = ( + "isPaused", + "isScheduledPaused", + "isSmartTariffPaused", +) + + +def _scalars(source: Any) -> dict[str, Any]: + """Return only the non-container entries of a mapping.""" + if not isinstance(source, dict): + return {} + + return { + key: value + for key, value in source.items() + if not isinstance(value, (dict, list)) + } + + +def derive_status(data: dict[str, Any]) -> str | None: + """Derive a canonical status string from the merged payload. + + The API reports state as an integer plus several independent + boolean flags, rather than as the status string the sensor catalog + and the charge switch expect. + + Args: + data: The merged payload. + + Returns: + One of the canonical status strings, or None if the payload + carries no state information at all. + + """ + if data.get("active") is False: + return STATUS_OFFLINE + + if data.get("evseSystemError"): + return STATUS_ERROR + + if any(data.get(flag) for flag in PAUSE_FLAGS): + return STATUS_PAUSED + + state = data.get("evseState") + if state is None: + state = data.get("lastStatus") + + if state is None: + return None + + if state == EVSE_STATE_CHARGING: + return STATUS_CHARGING + + return STATUS_IDLE + + +def merge_payload( + remote_info: dict[str, Any] | None, + evse_record: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Flatten the socket and EVSE responses into one mapping. + + Later sources overwrite earlier ones, so the ordering encodes + precedence: charger configuration first, then the per-socket + readings, then the socket's own live state, and finally the active + charge session, which is the freshest view of what is happening + right now. + + Args: + remote_info: The ``data`` object from the remoteInfo response. + evse_record: The charger record from the evses response. + + Returns: + A flat mapping, plus the original ``chargeSession`` and + ``nextScheduleInfo`` objects and a derived ``evseStatus``. + + """ + remote_info = remote_info or {} + evse_record = evse_record or {} + + merged: dict[str, Any] = {} + + # Charger configuration: eco mode, grid limits, photovoltaic flag. + merged.update(_scalars(evse_record)) + + # Per-socket readings: temperatures, voltages, currents, status. + sockets = evse_record.get("sockets") + if isinstance(sockets, list) and sockets: + merged.update(_scalars(sockets[0])) + + # Socket state flags: pause reasons, system error, evseState. + merged.update(_scalars(remote_info)) + + # Live session: power, energy, elapsed time. Freshest, so last. + session = remote_info.get("chargeSession") + merged.update(_scalars(session)) + + # Preserve the nested objects that value functions still inspect. + merged["chargeSession"] = session if isinstance(session, dict) else None + merged["nextScheduleInfo"] = remote_info.get("nextScheduleInfo") + + status = derive_status(merged) + if status is not None: + merged["evseStatus"] = status + + return merged diff --git a/custom_components/daze/sensor_catalog.py b/custom_components/daze/sensor_catalog.py index 4402e17..3e0e1bf 100644 --- a/custom_components/daze/sensor_catalog.py +++ b/custom_components/daze/sensor_catalog.py @@ -57,6 +57,7 @@ def presence_on_off(data: dict[str, Any], key: str) -> str | None: _SCHEDULED_CHARGE_KEYS: tuple[str, ...] = ( + "nextScheduleInfo", "nextScheduledCharge", "scheduledChargeTime", "scheduledStart", @@ -135,14 +136,14 @@ def get_next_scheduled_charge(data: dict[str, Any]) -> Any | None: device_class="temperature", state_class="measurement", native_unit_of_measurement="°C", - value_fn=lambda data: data.get("boardTemperature"), + value_fn=lambda data: data.get("lastBoardL1Temperature"), ), EVSESensorSpec( key="case_temperature", device_class="temperature", state_class="measurement", native_unit_of_measurement="°C", - value_fn=lambda data: data.get("caseTemperature"), + value_fn=lambda data: data.get("lastCaseTemperature"), ), EVSESensorSpec( key="evse_status", @@ -155,14 +156,14 @@ def get_next_scheduled_charge(data: dict[str, Any]) -> Any | None: device_class="power", native_unit_of_measurement="W", entity_category="diagnostic", - value_fn=lambda data: data.get("gridMaxPower"), + value_fn=lambda data: data.get("supplyGridMaxPower"), ), EVSESensorSpec( key="is_photovoltaic", device_class="enum", entity_category="diagnostic", options=("on", "off"), - value_fn=lambda data: presence_on_off(data, "is_photovoltaic"), + value_fn=lambda data: presence_on_off(data, "photovoltaic"), ), EVSESensorSpec( key="is_three_phase", diff --git a/tests/test_payload.py b/tests/test_payload.py new file mode 100644 index 0000000..3b4333b --- /dev/null +++ b/tests/test_payload.py @@ -0,0 +1,309 @@ +"""Tests for payload normalisation, using a real captured response. + +The fixtures below are the actual shape returned by the Daze API for a +DT01 charger, captured while it was delivering 2688 W. Values are +verbatim apart from identifiers. + +This is the bug these tests pin: every sensor read a top-level field +name, but the live metrics arrive nested under ``chargeSession``, and +the temperatures, grid limit and eco mode arrive from a different +endpoint entirely. Nothing errored, so entities were created and every +one of them read None. + +Run with pytest, or standalone: + + python3 tests/test_payload.py +""" + +from __future__ import annotations + +import ast +import importlib.util +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE_DIR = ROOT / "custom_components" / "daze" + + +def _load(name: str, filename: str) -> Any: + """Load a single integration module without Home Assistant.""" + spec = importlib.util.spec_from_file_location(name, PACKAGE_DIR / filename) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +payload = _load("daze_payload_under_test", "payload.py") +catalog = _load("daze_catalog_under_test", "sensor_catalog.py") + + +# Captured from GET /sockets/{serial}/remoteInfo while charging. +REMOTE_INFO: dict[str, Any] = { + "active": True, + "chargeSession": { + "chargeTime": "00:19:53", + "currentlyChargingInThreePhase": False, + "deliveredEnergyAsWattHour": 1258, + "instantPowerAsWatt": 2688, + "lastACVoltageL1": 233, + "lastACVoltageL2": 1, + "lastACVoltageL3": 7, + "lastChargingCurrentInstantL1": 11677, + "lastChargingCurrentInstantL2": 0, + "lastChargingCurrentInstantL3": 0, + "lastMaxChargingCurrent": 11739, + "sessionId": 1790524789000, + "startTime": "2026-09-27T15:59:49Z", + "user": None, + }, + "evseIsThreePhase": False, + "evseState": 3, + "evseSuspensionReason": 0, + "evseSystemError": 0, + "isPaused": False, + "isScheduledPaused": False, + "isSmartTariffPaused": False, + "nextScheduleInfo": None, + "smartTariffBatteryInfo": None, +} + +# Captured from GET /networks/{uid}/evses?includeEcoInfo=true. +EVSE_RECORD: dict[str, Any] = { + "active": True, + "deviceProfile": "DT01", + "ecoModeEnabled": False, + "evseIsThreePhase": False, + "evseName": "Daze HomeTT", + "firmwareVersion": "13.3.0", + "lastMaxInstallationCurrent": 32000, + "lastStatus": 3, + "maxExternalChargingCurrentInMilliAmps": 11739, + "operationMode": 0, + "photovoltaic": True, + "schedules": [], + "serialNumber": "TESTSERIAL", + "softwareVersion": "22.4.0", + "sockets": [ + { + "active": True, + "isPrimary": True, + "lastACVoltageL1": 233, + "lastBoardL1Temperature": 34, + "lastCaseTemperature": 40, + "lastChargingCurrentInstantL1": 11677, + "lastEnergy": 1258, + "lastMaxChargingCurrent": 11739, + "lastPower": 2688, + "lastStatus": 3, + "maxExternalChargingCurrentInMilliAmps": 11739, + "operationMode": 0, + "serialNumber": "TESTSERIAL", + } + ], + "supplyGridMaxPower": 3000, +} + + +def merged() -> dict[str, Any]: + """Return the merged payload for the captured fixtures.""" + return payload.merge_payload(REMOTE_INFO, EVSE_RECORD) + + +# ------------------------------------------------------------------ +# Merge behaviour +# ------------------------------------------------------------------ + + +def test_session_metrics_are_lifted_to_top_level() -> None: + """Live metrics nested under chargeSession must become readable.""" + data = merged() + + assert data["instantPowerAsWatt"] == 2688 + assert data["deliveredEnergyAsWattHour"] == 1258 + assert data["lastChargingCurrentInstantL1"] == 11677 + assert data["lastACVoltageL1"] == 233 + + +def test_evse_record_supplies_fields_remote_info_lacks() -> None: + """Temperatures, grid limit and eco mode come from the EVSE record.""" + data = merged() + + assert data["lastBoardL1Temperature"] == 34 + assert data["lastCaseTemperature"] == 40 + assert data["supplyGridMaxPower"] == 3000 + assert data["ecoModeEnabled"] is False + assert data["photovoltaic"] is True + assert data["maxExternalChargingCurrentInMilliAmps"] == 11739 + + +def test_session_values_win_over_socket_snapshot() -> None: + """The live session is fresher than the cached socket reading.""" + stale = {**EVSE_RECORD} + stale["sockets"] = [{**EVSE_RECORD["sockets"][0], "lastACVoltageL1": 999}] + + data = payload.merge_payload(REMOTE_INFO, stale) + + assert data["lastACVoltageL1"] == 233 + + +def test_merge_survives_a_missing_evse_record() -> None: + """A failed EVSE fetch must not break the live metrics.""" + data = payload.merge_payload(REMOTE_INFO, None) + + assert data["instantPowerAsWatt"] == 2688 + assert data.get("lastCaseTemperature") is None + + +def test_merge_survives_empty_input() -> None: + """No data at all must not raise.""" + assert payload.merge_payload(None, None) == { + "chargeSession": None, + "nextScheduleInfo": None, + } + + +# ------------------------------------------------------------------ +# Status derivation +# ------------------------------------------------------------------ + + +def test_state_3_is_charging() -> None: + """Confirmed against hardware delivering 2688 W.""" + assert merged()["evseStatus"] == "charging" + + +def test_paused_flags_win_over_state() -> None: + """Any pause flag reports paused.""" + for flag in ("isPaused", "isScheduledPaused", "isSmartTariffPaused"): + remote = {**REMOTE_INFO, flag: True} + data = payload.merge_payload(remote, EVSE_RECORD) + assert data["evseStatus"] == "paused", flag + + +def test_system_error_reports_error() -> None: + """A non-zero system error outranks the state value.""" + remote = {**REMOTE_INFO, "evseSystemError": 7} + assert payload.merge_payload(remote, EVSE_RECORD)["evseStatus"] == "error" + + +def test_inactive_reports_offline() -> None: + """active=False means the charger is not reachable.""" + remote = {**REMOTE_INFO, "active": False} + assert payload.merge_payload(remote, EVSE_RECORD)["evseStatus"] == "offline" + + +def test_unknown_state_reports_idle_not_charging() -> None: + """Unconfirmed state values must never be reported as charging.""" + for state in (0, 1, 2, 4, 5, 99): + remote = {**REMOTE_INFO, "evseState": state} + data = payload.merge_payload(remote, EVSE_RECORD) + assert data["evseStatus"] == "idle", state + + +def test_no_state_information_yields_no_status() -> None: + """Absent state must not be invented.""" + assert payload.derive_status({}) is None + + +# ------------------------------------------------------------------ +# End-to-end against the real sensor catalog +# ------------------------------------------------------------------ + + +def test_every_live_sensor_reads_a_value() -> None: + """The whole point: no live sensor may be None for this payload. + + Session-history sensors are excluded because they are computed by + the coordinator from a separate endpoint. + """ + data = merged() + + history = { + "last_session_energy", + "last_session_duration", + "last_session_cost", + "last_session_start", + "last_session_end", + "lifetime_energy", + "total_sessions", + "next_scheduled_charge", + } + + blank = [ + spec.key + for spec in catalog.EVSE_SENSOR_CATALOG + if spec.key not in history and spec.value_fn(data) is None + ] + + assert not blank, f"sensors still reading None: {blank}" + + +def test_catalog_reads_only_fields_the_payload_provides() -> None: + """Guard against a value function drifting to an absent field.""" + data = merged() + source = (PACKAGE_DIR / "sensor_catalog.py").read_text(encoding="utf-8") + tree = ast.parse(source) + + computed = { + "last_session_cost", + "last_session_duration", + "last_session_end", + "last_session_energy", + "last_session_start", + "lifetime_energy", + "total_sessions", + } + + unknown: list[str] = [] + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "get" + and node.args + ): + argument = node.args[0] + if isinstance(argument, ast.Constant) and isinstance( + argument.value, str + ): + name = argument.value + if name not in computed and name not in data: + unknown.append(name) + + assert not unknown, f"catalog reads fields the API never returns: {unknown}" + + +def test_switch_status_check_matches_derived_status() -> None: + """switch.py compares against the string 'charging'.""" + data = merged() + assert str(data.get("evseStatus")).lower() == "charging" + + +def _main() -> int: + """Run every test in this module and report results.""" + tests = [ + value + for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value) + ] + + failures = 0 + for test in tests: + try: + test() + except Exception as err: # noqa: BLE001 - standalone runner + failures += 1 + print(f"FAIL {test.__name__}: {type(err).__name__}: {err}") + else: + print(f"ok {test.__name__}") + + print(f"\n{len(tests) - failures} passed, {failures} failed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/tools/probe_socket_data.py b/tools/probe_socket_data.py new file mode 100755 index 0000000..e252f98 --- /dev/null +++ b/tools/probe_socket_data.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +"""Compare the live socket payload against the keys the sensors expect. + +Entities appear in Home Assistant but read Unknown when the API returns +200 with a payload whose field names do not match what the sensor +catalog looks up. Every value_fn returns None, so there is no error to +log and nothing to see. + +This script fetches the same endpoint the coordinator polls, extracts +the key names the catalog expects directly from the source, and reports +which are present, which are missing, and which fields the API returned +that nothing reads. For each missing key it suggests the closest +available name. + +Usage: + + python3 tools/probe_socket_data.py + +Only the refresh token is typed; the network and charger are discovered +the same way the config flow discovers them. Every request is a GET. +Nothing is created, modified, or deleted. +""" + +from __future__ import annotations + +import ast +import difflib +import getpass +import json +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +# Mirrors custom_components/daze/const.py +API_BASE_URL = "https://webapi.dazeservice.com/v3" +COGNITO_BASE_URL = "https://daze.auth.eu-central-1.amazoncognito.com" +COGNITO_IDP_URL = "https://cognito-idp.eu-central-1.amazonaws.com/" +CLIENT_ID = "4m0rp7oqarbrc3hn67ivvonba8" +REDIRECT_URI = "https://webportal.dazeservice.com/authentication/callback" +GET_USER_TARGET = "AWSCognitoIdentityProviderService.GetUser" + +ROOT = Path(__file__).resolve().parents[1] +CATALOG = ROOT / "custom_components" / "daze" / "sensor_catalog.py" + +# Keys the coordinator computes locally rather than reading from the +# API, so their absence from the payload is expected. +LOCALLY_COMPUTED = { + "last_session_cost", + "last_session_duration", + "last_session_end", + "last_session_energy", + "last_session_start", + "lifetime_energy", + "total_sessions", +} + +TIMEOUT = 30 + + +def _send(request: urllib.request.Request) -> tuple[int, object]: + """Send a request, tolerating HTTP error statuses.""" + try: + with urllib.request.urlopen(request, timeout=TIMEOUT) as response: + raw = response.read().decode(errors="replace") + return response.status, _parse(raw) + except urllib.error.HTTPError as err: + return err.code, _parse(err.read().decode(errors="replace")) + except urllib.error.URLError as err: + return 0, {"_error": str(err.reason)} + + +def _parse(raw: str) -> object: + """Parse a JSON body, falling back to a truncated raw string.""" + if not raw.strip(): + return {"_empty": True} + try: + return json.loads(raw) + except ValueError: + return {"_raw": raw[:200]} + + +def _api_get(access_token: str, path: str) -> tuple[int, object]: + """GET an API path with the bearer token.""" + request = urllib.request.Request( + f"{API_BASE_URL}{path}", + headers={"authorization": f"Bearer {access_token}"}, + method="GET", + ) + return _send(request) + + +def refresh_access_token(refresh_token: str) -> str: + """Exchange a refresh token for a fresh access token.""" + data = urllib.parse.urlencode( + { + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + ).encode() + + request = urllib.request.Request( + f"{COGNITO_BASE_URL}/oauth2/token", + data=data, + headers={ + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" + }, + method="POST", + ) + + status, body = _send(request) + if status != 200 or not isinstance(body, dict): + print(f"Could not refresh the access token (HTTP {status}).") + raise SystemExit(2) + + token = body.get("access_token") + if not isinstance(token, str): + print("Refresh succeeded but returned no access token.") + raise SystemExit(2) + + return token + + +def get_email(access_token: str) -> str: + """Read the account email via Cognito GetUser.""" + request = urllib.request.Request( + COGNITO_IDP_URL, + data=json.dumps({"AccessToken": access_token}).encode(), + headers={ + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": GET_USER_TARGET, + }, + method="POST", + ) + + status, body = _send(request) + if status != 200 or not isinstance(body, dict): + return "" + + for attribute in body.get("UserAttributes", []): + if isinstance(attribute, dict) and attribute.get("Name") == "email": + return str(attribute.get("Value", "")) + + return "" + + +def discover_serial(access_token: str, email: str) -> str: + """Find the first wallbox serial, as the config flow does.""" + if not email: + return "" + + mail = urllib.parse.quote(email, safe="") + status, body = _api_get( + access_token, f"/users/{mail}/networks?includeStats=true" + ) + networks = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(networks, list) or not networks: + return "" + + first = networks[0] if isinstance(networks[0], dict) else {} + uid = urllib.parse.quote(str(first.get("uid", "")), safe="") + if not uid: + return "" + + status, body = _api_get( + access_token, f"/networks/{uid}/evses?includeEcoInfo=false" + ) + evses = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(evses, list) or not evses: + return "" + + first_evse = evses[0] if isinstance(evses[0], dict) else {} + return str(first_evse.get("serialNumber", "")) + + +def expected_keys() -> set[str]: + """Extract the API field names the sensor catalog looks up.""" + tree = ast.parse(CATALOG.read_text(encoding="utf-8")) + keys: set[str] = set() + + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "get" + and node.args + ): + argument = node.args[0] + if isinstance(argument, ast.Constant) and isinstance( + argument.value, str + ): + keys.add(argument.value) + + # Schedule keys live in a module-level tuple, not a .get() call. + # The assignment may or may not carry a type annotation. + targets: list[ast.expr] = [] + value: ast.expr | None = None + + if isinstance(node, ast.Assign): + targets = list(node.targets) + value = node.value + elif isinstance(node, ast.AnnAssign): + targets = [node.target] + value = node.value + + if value is not None and isinstance(value, ast.Tuple): + for target in targets: + if getattr(target, "id", "").endswith("_KEYS"): + for element in value.elts: + if isinstance(element, ast.Constant) and isinstance( + element.value, str + ): + keys.add(element.value) + + return keys + + +def describe(value: object) -> str: + """Summarise a value's type and content compactly.""" + if isinstance(value, bool): + return f"bool {value}" + if isinstance(value, (int, float)): + return f"number {value}" + if isinstance(value, str): + return f'string "{value[:40]}"' + if isinstance(value, list): + return f"list of {len(value)}" + if isinstance(value, dict): + return f"object with keys {sorted(value)[:5]}" + if value is None: + return "null" + return type(value).__name__ + + +def dump(value: object, indent: int = 2, path: str = "") -> None: + """Print a nested structure in full, one leaf per line.""" + pad = " " * indent + + if isinstance(value, dict): + for key in sorted(value): + child = value[key] + child_path = f"{path}.{key}" if path else key + if isinstance(child, (dict, list)) and child: + print(f"{pad}{key}:") + dump(child, indent + 2, child_path) + else: + print(f"{pad}{key} = {describe(child)}") + return + + if isinstance(value, list): + for index, child in enumerate(value[:3]): + print(f"{pad}[{index}]:") + dump(child, indent + 2, f"{path}[{index}]") + if len(value) > 3: + print(f"{pad}... {len(value) - 3} more item(s)") + return + + print(f"{pad}{describe(value)}") + + +def find_paths( + tree: object, wanted: set[str], path: str = "" +) -> dict[str, list[str]]: + """Locate every wanted key anywhere in a nested structure.""" + found: dict[str, list[str]] = {} + + if isinstance(tree, dict): + for key, child in tree.items(): + child_path = f"{path}.{key}" if path else key + if key in wanted: + found.setdefault(key, []).append(child_path) + for name, paths in find_paths(child, wanted, child_path).items(): + found.setdefault(name, []).extend(paths) + + elif isinstance(tree, list): + for index, child in enumerate(tree): + child_path = f"{path}[{index}]" + for name, paths in find_paths(child, wanted, child_path).items(): + found.setdefault(name, []).extend(paths) + + return found + + +def also_probe_evse(access_token: str, email: str) -> None: + """Dump the EVSE record, where control fields may live. + + The number, select and switch entities read + maxExternalChargingCurrentInMilliAmps, ecoModeEnabled and + evseStatus. None appear in the socket payload, so check whether the + EVSE listing carries them instead. + """ + if not email: + return + + mail = urllib.parse.quote(email, safe="") + status, body = _api_get( + access_token, f"/users/{mail}/networks?includeStats=true" + ) + networks = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(networks, list) or not networks: + return + + uid = urllib.parse.quote(str(networks[0].get("uid", "")), safe="") + status, body = _api_get( + access_token, f"/networks/{uid}/evses?includeEcoInfo=true" + ) + evses = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(evses, list) or not evses: + print(f"\nCould not read the EVSE record (HTTP {status}).") + return + + print("\n" + "=" * 60) + print("EVSE record (/networks/{uid}/evses?includeEcoInfo=true):\n") + dump(evses[0]) + + control_fields = { + "maxExternalChargingCurrentInMilliAmps", + "lastMaxChargingCurrent", + "ecoModeEnabled", + "operationMode", + "evseStatus", + } + hits = find_paths(evses[0], control_fields) + + print("\nControl fields the number/select/switch entities read:") + for field in sorted(control_fields): + paths = hits.get(field) + print(f" {'FOUND ' if paths else 'ABSENT'} {field}" + + (f" at {paths[0]}" if paths else "")) + + +def main() -> int: + """Fetch the live payload and diff it against the catalog.""" + print("Daze socket payload probe") + print("All requests are GETs. Nothing is modified.") + print() + + refresh_token = getpass.getpass("Refresh token (input hidden): ").strip() + if not refresh_token: + print("A refresh token is required.") + return 3 + + access_token = refresh_access_token(refresh_token) + email = get_email(access_token) + serial = discover_serial(access_token, email) + + if not serial: + serial = input("Wallbox serial (discovery failed): ").strip() + if not serial: + print("A serial number is required.") + return 3 + + print(f"Charger: {serial}") + + path = ( + f"/sockets/{urllib.parse.quote(serial, safe='')}/remoteInfo" + "?includeEcoInfo=true&includeNextSchedule=true" + ) + print(f"\nGET {path}") + + status, body = _api_get(access_token, path) + print(f"HTTP {status}") + + if status != 200 or not isinstance(body, dict): + print("\nThe endpoint the coordinator polls did not return data.") + print(f"Response: {describe(body)}") + return 1 + + data = body.get("data") + if not isinstance(data, dict): + print("\nResponse had no 'data' object. Top-level keys:") + for key in sorted(body): + print(f" {key}: {describe(body[key])}") + print("\nThe coordinator reads response['data'], so nothing is read.") + return 1 + + print(f"\nFull payload tree ({len(data)} top-level field(s)):\n") + dump(data) + + # The metrics may be nested, so locate every expected key anywhere + # in the tree rather than only at the top level. + wanted = expected_keys() - LOCALLY_COMPUTED + found = find_paths(data, wanted) + + print("\n" + "=" * 60) + print("Where each expected field actually lives:\n") + for key in sorted(wanted): + paths = found.get(key) + if paths: + for path in paths: + location = "top level" if path == key else f"at {path}" + print(f" FOUND {key:<32} {location}") + else: + print(f" ABSENT {key}") + + also_probe_evse(access_token, email) + + present = sorted(k for k in wanted if k in data) + missing = sorted(k for k in wanted if k not in data) + unused = sorted(k for k in data if k not in wanted) + + print("\n" + "=" * 60) + print(f"Catalog expects {len(wanted)} field(s) from this payload.") + print(f" present: {len(present)}") + print(f" missing: {len(missing)}") + + if present: + print("\nWorking (sensors for these should show values):") + for key in present: + print(f" {key} = {describe(data[key])}") + + if missing: + print("\nMissing (these sensors will read Unknown):") + for key in missing: + close = difflib.get_close_matches(key, list(data), n=2, cutoff=0.5) + hint = f" closest in payload: {', '.join(close)}" if close else "" + print(f" {key}{hint}") + + if unused: + print("\nReturned by the API but read by nothing:") + for key in unused: + print(f" {key} = {describe(data[key])}") + + print("\n" + "=" * 60) + if not missing: + print("Every expected field is present. The mismatch is elsewhere.") + return 0 + + print(f"{len(missing)} of {len(wanted)} expected fields are absent.") + print("The sensor catalog's field names do not match this API.") + print("Map each missing name to the matching field listed above.") + return 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nAborted.") + sys.exit(3) From 6e288e7c30ca4fb391f67652a876014ce0c1afaa Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 17:46:52 +0100 Subject: [PATCH 07/82] docs: show the current version at the top of the README Adds a version badge as the first item in the badge row, reading the latest semver tag from the repository. It tracks the tags rather than hardcoding a number, so publishing a release updates it with no README edit and it cannot drift from the manifest. Co-Authored-By: Claude Opus 5 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b41d924..b49d272 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Daze Wallbox +[![Version](https://img.shields.io/github/v/tag/tarrinho/daze-addon?label=version&sort=semver&color=blue)](https://github.com/tarrinho/daze-addon/releases) [![HA Community](https://img.shields.io/badge/Home%20Assistant-2025.x-41BDF5?logo=homeassistant)](https://www.home-assistant.io/) [![HACS Validation](https://github.com/tarrinho/daze-addon/actions/workflows/validate.yaml/badge.svg)](https://github.com/tarrinho/daze-addon/actions/workflows/validate.yaml) [![GitHub](https://img.shields.io/github/license/tarrinho/daze-addon)](LICENSE) From 88efcb75bcd43c5cb3b46f469158cfd2db92e4b9 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 18:42:08 +0100 Subject: [PATCH 08/82] fix: send the serial and session ID with charge commands Starting or stopping a charge failed with HTTP 422: ErrorWrongSessionID: Failed to suspend session: Wrong Session ID. Error code: 4121 playcharge resumes an existing session rather than starting one from nothing, and the body must name both the charger and the session. The integration posted an empty body. Three behaviours were measured against hardware, in this order: - empty body: HTTP 422, ErrorWrongSessionID - {"sessionId": ...}: HTTP 200 with an empty error list, and the charger stays paused. Accepted is not resumed. - {"evseSerialNumber": ..., "sessionId": ...}: HTTP 200, and within ten seconds evseState moves 6 to 5, isPaused clears and evseSuspensionReason returns to 0. Restoring maxExternalChargingCurrent was also tried, since a paused session reports a zero current limit. It returned 200 and changed nothing across 30 seconds, so the zero limit is a symptom of the pause rather than its cause. Send both fields from switch.py and from the three services, reading the session ID out of the coordinator's merged payload. Keep sending the serial when no session is known, so the body is never empty. stopcharge sends the same shape. That symmetry is assumed, not measured: only the resume direction was verified. Record two more confirmed evseState values: 5 is connected but drawing no power, reported as idle rather than charging so the switch does not read on while the car takes nothing, and 6 is paused. Bump version to 0.1.4. Co-Authored-By: Claude Opus 5 --- custom_components/daze/__init__.py | 18 +- custom_components/daze/api/__init__.py | 42 ++- custom_components/daze/manifest.json | 2 +- custom_components/daze/payload.py | 20 +- custom_components/daze/switch.py | 20 +- tests/test_auth_getuser.py | 47 +++ tests/test_payload.py | 78 +++++ tools/try_resume.py | 398 +++++++++++++++++++++++++ 8 files changed, 610 insertions(+), 15 deletions(-) create mode 100755 tools/try_resume.py diff --git a/custom_components/daze/__init__.py b/custom_components/daze/__init__.py index 62cb78d..9612107 100644 --- a/custom_components/daze/__init__.py +++ b/custom_components/daze/__init__.py @@ -131,10 +131,22 @@ def _async_register_services( api_client = coordinator.api_client serial_number = coordinator.serial_number + def _session_id() -> int | None: + """Return the open charge session ID, if any. + + The play and stop commands act on a session and must name + it, or the API answers 422 ErrorWrongSessionID. + """ + data = coordinator.data or {} + session_id = data.get("sessionId") + return session_id if isinstance(session_id, int) else None + async def _handle_start_charge(call: ServiceCall) -> None: """Start charging.""" try: - await api_client.async_start_charge(serial_number) + await api_client.async_start_charge( + serial_number, _session_id() + ) await coordinator.async_request_refresh() except ApiAuthError as err: raise ConfigEntryAuthFailed( @@ -149,7 +161,9 @@ async def _handle_start_charge(call: ServiceCall) -> None: async def _handle_stop_charge(call: ServiceCall) -> None: """Stop charging.""" try: - await api_client.async_stop_charge(serial_number) + await api_client.async_stop_charge( + serial_number, _session_id() + ) await coordinator.async_request_refresh() except ApiAuthError as err: raise ConfigEntryAuthFailed( diff --git a/custom_components/daze/api/__init__.py b/custom_components/daze/api/__init__.py index 80b440c..6d2562c 100644 --- a/custom_components/daze/api/__init__.py +++ b/custom_components/daze/api/__init__.py @@ -451,35 +451,65 @@ async def async_set_eco_mode( } return await self._request("POST", url, json=payload) - async def async_start_charge(self, serial: str) -> dict[str, Any]: - """Start charging on a wallbox. + async def async_start_charge( + self, serial: str, session_id: int | None = None + ) -> dict[str, Any]: + """Resume charging on a wallbox. POST /v3/sockets/{serial}/commands/playcharge + The body must carry both the serial and the session ID. All + three behaviours below were observed against hardware: + + - empty body: HTTP 422, ``ErrorWrongSessionID`` (code 4121) + - ``{"sessionId": ...}``: HTTP 200, but the charger stays + paused. Accepted is not resumed. + - ``{"evseSerialNumber": ..., "sessionId": ...}``: HTTP 200 and + the charger resumes within about ten seconds, with evseState + moving 6 to 5 and isPaused clearing. + + A paused session keeps its ID, so the caller reads it from the + coordinator's ``sessionId`` field. + Args: serial: The serial number of the wallbox. + session_id: The session to resume. Omitted when unknown, + which the API rejects with 422. Returns: The response dict. """ url = f"{API_BASE_URL}/sockets/{serial}/commands/playcharge" - return await self._request("POST", url, json={}) + payload: dict[str, Any] = {"evseSerialNumber": serial} + if session_id is not None: + payload["sessionId"] = session_id + return await self._request("POST", url, json=payload) - async def async_stop_charge(self, serial: str) -> dict[str, Any]: - """Stop charging on a wallbox. + async def async_stop_charge( + self, serial: str, session_id: int | None = None + ) -> dict[str, Any]: + """Suspend charging on a wallbox. POST /v3/sockets/{serial}/commands/stopcharge + Sends the same body shape as playcharge. The symmetry is + assumed, not measured: only the resume direction has been + verified against hardware. + Args: serial: The serial number of the wallbox. + session_id: The session to suspend, when known. Returns: The response dict. """ url = f"{API_BASE_URL}/sockets/{serial}/commands/stopcharge" - return await self._request("POST", url, json={}) + payload: dict[str, Any] = {"evseSerialNumber": serial} + if session_id is not None: + payload["sessionId"] = session_id + return await self._request("POST", url, json=payload) async def async_get_recharge_sessions( self, network_uid: str, limit: int = 1000 diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index 86cfff9..8ceb2aa 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.3" + "version": "0.1.4" } diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index f207671..e580de8 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -23,11 +23,20 @@ from typing import Any -# The only EVSE state value confirmed against live hardware: observed -# as 3 while the charger was delivering 2688 W with a session running. -# Other values are inferred conservatively rather than guessed, so an -# unrecognised state reports "idle" rather than inventing a meaning. +# EVSE state values confirmed against live hardware: +# +# 3 charging observed while delivering 2688 W with a session running +# 5 connected observed immediately after resuming: isPaused cleared +# and evseSuspensionReason zero, but still drawing 0 W. +# Reported as idle because no energy is flowing. +# 6 paused observed with isPaused true, evseSuspensionReason 3, +# zero instant power, and the session still open +# +# Other values remain unknown, so an unrecognised state reports "idle" +# rather than inventing a meaning. EVSE_STATE_CHARGING = 3 +EVSE_STATE_CONNECTED = 5 +EVSE_STATE_PAUSED = 6 STATUS_CHARGING = "charging" STATUS_IDLE = "idle" @@ -88,6 +97,9 @@ def derive_status(data: dict[str, Any]) -> str | None: if state == EVSE_STATE_CHARGING: return STATUS_CHARGING + if state == EVSE_STATE_PAUSED: + return STATUS_PAUSED + return STATUS_IDLE diff --git a/custom_components/daze/switch.py b/custom_components/daze/switch.py index c0ae91a..d0c37b6 100644 --- a/custom_components/daze/switch.py +++ b/custom_components/daze/switch.py @@ -62,6 +62,18 @@ def __init__( self._attr_unique_id = f"{serial_number}_charge_switch" self._attr_device_info = device_info + @property + def _session_id(self) -> int | None: + """Return the current charge session ID, if one is open. + + The play and stop commands act on a session and must name + it; without it the API answers 422 ErrorWrongSessionID. + """ + if self.coordinator.data is None: + return None + session_id = self.coordinator.data.get("sessionId") + return session_id if isinstance(session_id, int) else None + @property def is_on(self) -> bool | None: """Return True if the wallbox is currently charging.""" @@ -85,7 +97,9 @@ async def async_turn_on(self, **kwargs: Any) -> None: _LOGGER.info( "Starting charge on wallbox %s", self._serial_number ) - await self._api_client.async_start_charge(self._serial_number) + await self._api_client.async_start_charge( + self._serial_number, self._session_id + ) await self.coordinator.async_request_refresh() except ApiAuthError as err: _LOGGER.warning( @@ -122,7 +136,9 @@ async def async_turn_off(self, **kwargs: Any) -> None: _LOGGER.info( "Stopping charge on wallbox %s", self._serial_number ) - await self._api_client.async_stop_charge(self._serial_number) + await self._api_client.async_stop_charge( + self._serial_number, self._session_id + ) await self.coordinator.async_request_refresh() except ApiAuthError as err: _LOGGER.warning( diff --git a/tests/test_auth_getuser.py b/tests/test_auth_getuser.py index 5f78a07..d9a67ce 100644 --- a/tests/test_auth_getuser.py +++ b/tests/test_auth_getuser.py @@ -332,6 +332,53 @@ def test_non_404_errors_still_raise_plain_api_error() -> None: raise AssertionError("expected ApiError") + +def test_start_charge_sends_serial_and_session() -> None: + """Both fields are required; either alone does not resume. + + Measured against hardware: an empty body returns 422 + ErrorWrongSessionID, sessionId alone returns 200 but leaves the + charger paused, and both together actually resume it. + """ + session = FakeSession([FakeResponse(200, {"message": "", "errors": []})]) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + asyncio.run(api_client.async_start_charge("SER1", 1790529768000)) + + call = session.calls[0] + assert call["url"].endswith("/sockets/SER1/commands/playcharge") + assert call["json"] == { + "evseSerialNumber": "SER1", + "sessionId": 1790529768000, + } + + +def test_stop_charge_sends_the_same_shape() -> None: + """Stop mirrors start. Assumed symmetric, not measured.""" + session = FakeSession([FakeResponse(200, {"message": "", "errors": []})]) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + asyncio.run(api_client.async_stop_charge("SER1", 42)) + + assert session.calls[0]["json"] == { + "evseSerialNumber": "SER1", + "sessionId": 42, + } + + +def test_commands_still_send_the_serial_without_a_session() -> None: + """An unknown session must not drop the serial from the body.""" + session = FakeSession([FakeResponse(200, {"message": "", "errors": []})]) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + asyncio.run(api_client.async_start_charge("SER1", None)) + + assert session.calls[0]["json"] == {"evseSerialNumber": "SER1"} + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tests/test_payload.py b/tests/test_payload.py index 3b4333b..41ab797 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -283,6 +283,84 @@ def test_switch_status_check_matches_derived_status() -> None: assert str(data.get("evseStatus")).lower() == "charging" + +# Captured while the charger was paused mid-session. +REMOTE_INFO_PAUSED: dict[str, Any] = { + "active": True, + "chargeSession": { + "chargeTime": "00:05:19", + "currentlyChargingInThreePhase": False, + "deliveredEnergyAsWattHour": 223, + "instantPowerAsWatt": 0, + "lastACVoltageL1": 232, + "lastACVoltageL2": 1, + "lastACVoltageL3": 7, + "lastChargingCurrentInstantL1": 0, + "lastChargingCurrentInstantL2": 0, + "lastChargingCurrentInstantL3": 0, + "lastMaxChargingCurrent": 0, + "sessionId": 1790529768000, + "startTime": "2026-09-27T17:22:48Z", + "user": None, + }, + "evseIsThreePhase": False, + "evseState": 6, + "evseSuspensionReason": 3, + "evseSystemError": 0, + "isPaused": True, + "isScheduledPaused": False, + "isSmartTariffPaused": False, + "nextScheduleInfo": None, + "smartTariffBatteryInfo": None, +} + + +def test_state_6_is_paused() -> None: + """Confirmed against hardware: state 6 with isPaused set.""" + data = payload.merge_payload(REMOTE_INFO_PAUSED, EVSE_RECORD) + assert data["evseStatus"] == "paused" + + +def test_paused_state_reports_paused_without_the_flag() -> None: + """State 6 alone must report paused, not idle. + + The flag and the state are independent fields; either on its own + has to be enough, or a pause shows up as idle. + """ + remote = {**REMOTE_INFO_PAUSED, "isPaused": False} + data = payload.merge_payload(remote, EVSE_RECORD) + assert data["evseStatus"] == "paused" + + +def test_paused_session_keeps_its_session_id() -> None: + """A paused session stays open, so the ID remains available. + + This is why ErrorWrongSessionID cannot mean "no session exists": + the resume command has a valid ID to quote. + """ + data = payload.merge_payload(REMOTE_INFO_PAUSED, EVSE_RECORD) + assert data["sessionId"] == 1790529768000 + assert data["instantPowerAsWatt"] == 0 + + + +def test_state_5_is_connected_not_charging() -> None: + """Observed right after a resume: unpaused but drawing no power. + + Reported as idle rather than charging, because no energy flows. + Calling it charging would make the switch read on while the car + takes nothing. + """ + remote = { + **REMOTE_INFO_PAUSED, + "evseState": 5, + "evseSuspensionReason": 0, + "isPaused": False, + } + data = payload.merge_payload(remote, EVSE_RECORD) + assert data["evseStatus"] == "idle" + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tools/try_resume.py b/tools/try_resume.py new file mode 100755 index 0000000..ce69c09 --- /dev/null +++ b/tools/try_resume.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python3 +"""Find the request that resumes a paused Daze charging session. + +The integration posts an empty body to +``/sockets/{serial}/commands/playcharge`` and the API answers: + + 422 ErrorWrongSessionID: Failed to suspend session: Wrong Session ID + +A paused session keeps its ``sessionId``, so the ID exists and is +valid. The likely cause is that the command must name the session it +acts on. This script tries the plausible variants one at a time and +stops at the first that succeeds. + +WARNING: unlike the other tools here, this one SENDS COMMANDS to real +hardware. A successful attempt resumes charging on your wallbox. Each +attempt is shown in full and requires typing "yes" before it is sent; +nothing is sent without that confirmation. + +Run it while the charger is paused with a car connected. + +Usage: + + python3 tools/try_resume.py +""" + +from __future__ import annotations + +import getpass +import json +import time +import sys +import urllib.error +import urllib.parse +import urllib.request + +API_BASE_URL = "https://webapi.dazeservice.com/v3" +COGNITO_BASE_URL = "https://daze.auth.eu-central-1.amazoncognito.com" +COGNITO_IDP_URL = "https://cognito-idp.eu-central-1.amazonaws.com/" +CLIENT_ID = "4m0rp7oqarbrc3hn67ivvonba8" +REDIRECT_URI = "https://webportal.dazeservice.com/authentication/callback" +GET_USER_TARGET = "AWSCognitoIdentityProviderService.GetUser" + +TIMEOUT = 30 + + +def _send(request: urllib.request.Request) -> tuple[int, object]: + """Send a request, tolerating HTTP error statuses.""" + try: + with urllib.request.urlopen(request, timeout=TIMEOUT) as response: + return response.status, _parse( + response.read().decode(errors="replace") + ) + except urllib.error.HTTPError as err: + return err.code, _parse(err.read().decode(errors="replace")) + except urllib.error.URLError as err: + return 0, {"_error": str(err.reason)} + + +def _parse(raw: str) -> object: + """Parse a JSON body, falling back to a truncated raw string.""" + if not raw.strip(): + return {"_empty": True} + try: + return json.loads(raw) + except ValueError: + return {"_raw": raw[:300]} + + +def _get(token: str, path: str) -> tuple[int, object]: + """GET an API path with the bearer token.""" + return _send( + urllib.request.Request( + f"{API_BASE_URL}{path}", + headers={"authorization": f"Bearer {token}"}, + method="GET", + ) + ) + + +def refresh_access_token(refresh_token: str) -> str: + """Exchange a refresh token for a fresh access token.""" + data = urllib.parse.urlencode( + { + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + ).encode() + + status, body = _send( + urllib.request.Request( + f"{COGNITO_BASE_URL}/oauth2/token", + data=data, + headers={ + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" + }, + method="POST", + ) + ) + + if status != 200 or not isinstance(body, dict): + print(f"Could not refresh the access token (HTTP {status}).") + raise SystemExit(2) + + token = body.get("access_token") + if not isinstance(token, str): + print("Refresh succeeded but returned no access token.") + raise SystemExit(2) + + return token + + +def get_email(token: str) -> str: + """Read the account email via Cognito GetUser.""" + status, body = _send( + urllib.request.Request( + COGNITO_IDP_URL, + data=json.dumps({"AccessToken": token}).encode(), + headers={ + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": GET_USER_TARGET, + }, + method="POST", + ) + ) + + if status != 200 or not isinstance(body, dict): + return "" + + for attribute in body.get("UserAttributes", []): + if isinstance(attribute, dict) and attribute.get("Name") == "email": + return str(attribute.get("Value", "")) + + return "" + + +def discover_serial(token: str, email: str) -> str: + """Find the first wallbox serial, as the config flow does.""" + if not email: + return "" + + mail = urllib.parse.quote(email, safe="") + status, body = _get(token, f"/users/{mail}/networks?includeStats=true") + networks = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(networks, list) or not networks: + return "" + + uid = urllib.parse.quote(str(networks[0].get("uid", "")), safe="") + status, body = _get(token, f"/networks/{uid}/evses?includeEcoInfo=false") + evses = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(evses, list) or not evses: + return "" + + return str(evses[0].get("serialNumber", "")) + + +def read_state(token: str, serial: str) -> dict: + """Read the current socket state.""" + path = ( + f"/sockets/{urllib.parse.quote(serial, safe='')}/remoteInfo" + "?includeEcoInfo=true&includeNextSchedule=true" + ) + status, body = _get(token, path) + if status != 200 or not isinstance(body, dict): + return {} + + data = body.get("data") + return data if isinstance(data, dict) else {} + + +def candidates( + serial: str, session_id: object, restore_current: int +) -> list[tuple[str, dict]]: + """Build the request variants to try, most likely first. + + Variant 1 is already known to return HTTP 200 without resuming, so + it is kept only as a control. + """ + quoted = urllib.parse.quote(serial, safe="") + + return [ + # Restoring the current limit. While paused the session reports + # lastMaxChargingCurrent 0, so the pause may simply be a zero + # current limit rather than a session state. + ( + f"/evses/{quoted}/configurations/maxExternalChargingCurrent", + { + "evseSerialNumber": serial, + "maxExternalChargingCurrentInMilliAmps": restore_current, + }, + ), + # Play with the serial echoed alongside the session. + ( + f"/sockets/{quoted}/commands/playcharge", + {"evseSerialNumber": serial, "sessionId": session_id}, + ), + ( + f"/sockets/{quoted}/commands/playcharge", + {"socketSerialNumber": serial, "sessionId": session_id}, + ), + # A dedicated resume command rather than play. + (f"/sockets/{quoted}/commands/resumecharge", {"sessionId": session_id}), + (f"/sockets/{quoted}/commands/resumecharge", {}), + # The command hung off the EVSE rather than the socket. + (f"/evses/{quoted}/commands/playcharge", {"sessionId": session_id}), + # Control: known to answer 200 and change nothing. + (f"/sockets/{quoted}/commands/playcharge", {"sessionId": session_id}), + ] + + +def verify_resumed( + token: str, serial: str, attempts: int = 6, delay: int = 5 +) -> tuple[bool, dict]: + """Poll the socket state to see whether charging actually resumed. + + HTTP 200 only means the command was accepted. The first version of + this script treated that as success and reported a false positive, + so success is now defined as an observed state change. + + Returns: + A tuple of (resumed, last observed state). + + """ + state: dict = {} + + for index in range(attempts): + time.sleep(delay) + state = read_state(token, serial) + session = state.get("chargeSession") + power = ( + session.get("instantPowerAsWatt") + if isinstance(session, dict) + else None + ) + + print( + f" +{(index + 1) * delay:>2}s evseState={state.get('evseState')}" + f" isPaused={state.get('isPaused')}" + f" suspension={state.get('evseSuspensionReason')}" + f" power={power} W" + ) + + if state.get("isPaused") is False or ( + isinstance(power, (int, float)) and power > 0 + ): + return True, state + + return False, state + + +def attempt(token: str, path: str, body: dict) -> tuple[int, object]: + """POST one candidate command.""" + return _send( + urllib.request.Request( + f"{API_BASE_URL}{path}", + data=json.dumps(body).encode(), + headers={ + "authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + method="POST", + ) + ) + + +def summarise(status: int, body: object) -> str: + """Describe a command response in one line.""" + if isinstance(body, dict): + if body.get("_empty"): + return f"HTTP {status}, empty body" + errors = body.get("errors") + if isinstance(errors, list) and errors: + first = errors[0] + if isinstance(first, dict): + return ( + f"HTTP {status}, code {first.get('code')}: " + f"{str(first.get('message', ''))[:120]}" + ) + message = body.get("message") + if message: + return f"HTTP {status}: {message}" + return f"HTTP {status}: {str(body)[:120]}" + + +def main() -> int: + """Try each resume variant with per-attempt confirmation.""" + print("Daze resume-command finder") + print() + print("WARNING: this sends COMMANDS to your wallbox. A successful") + print("attempt will resume charging. Every attempt is shown first") + print("and requires typing 'yes'.") + print() + + refresh_token = getpass.getpass("Refresh token (input hidden): ").strip() + if not refresh_token: + print("A refresh token is required.") + return 3 + + token = refresh_access_token(refresh_token) + email = get_email(token) + serial = discover_serial(token, email) + + if not serial: + serial = input("Wallbox serial (discovery failed): ").strip() + if not serial: + print("A serial number is required.") + return 3 + + state = read_state(token, serial) + session = state.get("chargeSession") + session_id = session.get("sessionId") if isinstance(session, dict) else None + + print(f"\nCharger : {serial}") + print(f"evseState : {state.get('evseState')}") + print(f"isPaused : {state.get('isPaused')}") + print(f"suspension: {state.get('evseSuspensionReason')}") + print(f"sessionId : {session_id}") + + if session_id is None: + print("\nNo session ID available. There is nothing to resume, so") + print("these variants cannot be tested meaningfully. Start a") + print("session first, pause it, then run this again.") + return 1 + + if not state.get("isPaused"): + print("\nThe charger does not report being paused. Resuming is") + print("only meaningful from a paused state; run this while it is") + print("actually paused or the results will not mean anything.") + answer = input("Continue anyway? [yes/no] ").strip().lower() + if answer != "yes": + return 0 + + # While paused the session reports a zero current limit, so + # restoring a sane value is one of the things worth trying. + restore = 0 + if isinstance(session, dict): + restore = session.get("lastMaxChargingCurrent") or 0 + if not restore: + entered = input("\nCurrent limit to restore in mA [11739]: ").strip() + restore = int(entered) if entered.isdigit() else 11739 + print(f"Will restore current to {restore} mA if that variant is tried.") + + variants = candidates(serial, session_id, restore) + print(f"\n{len(variants)} variant(s) to try. Ctrl-C stops at any point.") + + for index, (path, body) in enumerate(variants, start=1): + print("\n" + "-" * 60) + print(f"Attempt {index} of {len(variants)}") + print(f" POST {API_BASE_URL}{path}") + print(f" body {json.dumps(body)}") + + answer = input(" Send this? [yes/skip/quit] ").strip().lower() + if answer == "quit": + print("Stopped.") + return 0 + if answer != "yes": + print(" skipped") + continue + + status, response = attempt(token, path, body) + print(f" -> {summarise(status, response)}") + + if not 200 <= status < 300: + print(" rejected, moving on") + continue + + # Accepted is not resumed. Watch the state before believing it. + print(" accepted; watching the charger state for 30s:") + resumed, after = verify_resumed(token, serial) + + if resumed: + print("\n" + "=" * 60) + print("CONFIRMED. The charger actually resumed.") + print("This is the request the integration should make:") + print(f" POST {path}") + print(f" body {json.dumps(body)}") + print(f"\nFinal state: evseState={after.get('evseState')} " + f"isPaused={after.get('isPaused')}") + return 0 + + print(" no state change: accepted but did not resume. Next variant.") + + print("\n" + "=" * 60) + print("No variant succeeded.") + print() + print("Capture the real request instead: open the web portal in a") + print("browser, open DevTools on the Network tab, and press resume.") + print("The request it sends is the one to implement.") + return 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nAborted.") + sys.exit(3) From 52063512d7e88f5c9d58dfff32087aaa083c2c83 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 18:54:45 +0100 Subject: [PATCH 09/82] fix: explain why the charger refused a command Every command failure showed the same notification, "Failed to start charging. Check that the car is connected and try again", regardless of cause. That was actively misleading: it appeared when the car was connected and the real problem was that the charger had no paused session to resume. Add ApiCommandRejectedError, carrying the upstream error code, and map the two codes observed against hardware: - 4121, sent with no session ID or naming a session that is not paused: there is no paused charging session to resume - 101, returned from a connected but idle charger: the charger could not carry out the command, which usually means it is not in a state where that command applies The charge switch now relays that explanation instead of the stock line, and logs it at info rather than warning, since a refusal is not a fault. Make tools/try_resume.py state-aware. It previously refused to run without an open session, which is exactly the state that still fails. It now picks resume variants when the charger is paused, leading with the confirmed working call, and start variants otherwise. Starting from a connected but idle charger is still unsolved. Resuming a paused session works and is unaffected. Co-Authored-By: Claude Opus 5 --- custom_components/daze/api/__init__.py | 76 ++++++++++++++++++++- custom_components/daze/switch.py | 20 +++++- tools/try_resume.py | 94 ++++++++++++++++++-------- 3 files changed, 160 insertions(+), 30 deletions(-) diff --git a/custom_components/daze/api/__init__.py b/custom_components/daze/api/__init__.py index 6d2562c..d9fd166 100644 --- a/custom_components/daze/api/__init__.py +++ b/custom_components/daze/api/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import re from typing import Any from aiohttp import ClientSession @@ -31,6 +32,51 @@ class ApiError(Exception): """Raised for non-auth API errors (4xx, 5xx, network issues).""" +class ApiCommandRejectedError(ApiError): + """Raised when the charger refuses a command it cannot perform. + + Distinguished from a generic ApiError so callers can tell the user + what the charger is objecting to, rather than repeating a stock + "check the car is connected" for every failure. + """ + + def __init__(self, message: str, *, code: int | None = None) -> None: + """Store the upstream error code alongside the message.""" + super().__init__(message) + self.code = code + + +# Upstream error codes seen from the command endpoints, with what each +# actually meant when observed. +COMMAND_ERROR_HINTS: dict[int, str] = { + # Sent with no session ID, or naming a session that is not paused. + 4121: ( + "there is no paused charging session to resume" + ), + # The request was accepted but the charger could not carry it out, + # observed when the charger was already running or idle rather than + # paused. + 101: ( + "the charger could not carry out the command, which usually " + "means it is not in a state where that command applies" + ), +} + + +def _code_and_hint_from_error(err: Exception) -> tuple[int | None, str]: + """Recover the upstream error code from a raised ApiError. + + The request layer folds the response body into the exception + message, so the structured code has to be read back out of it. + """ + match = re.search(r'"code"\s*:\s*(\d+)', str(err)) + if not match: + return None, "" + + code = int(match.group(1)) + return code, COMMAND_ERROR_HINTS.get(code, "") + + class ApiNotFoundError(ApiError): """Raised when the API answers 404 for a resource. @@ -451,6 +497,32 @@ async def async_set_eco_mode( } return await self._request("POST", url, json=payload) + async def _post_command( + self, url: str, payload: dict[str, Any] + ) -> dict[str, Any]: + """POST a charger command, explaining a refusal when it fails. + + The command endpoints answer with a structured error list. A + generic ApiError loses that detail, so the caller cannot tell a + "nothing to resume" refusal from a real outage. + + Raises: + ApiCommandRejectedError: If the charger refused the command. + + """ + try: + return await self._request("POST", url, json=payload) + except ApiAuthError: + raise + except ApiError as err: + code, hint = _code_and_hint_from_error(err) + if hint: + raise ApiCommandRejectedError( + f"The charger refused the command because {hint}.", + code=code, + ) from err + raise + async def async_start_charge( self, serial: str, session_id: int | None = None ) -> dict[str, Any]: @@ -484,7 +556,7 @@ async def async_start_charge( payload: dict[str, Any] = {"evseSerialNumber": serial} if session_id is not None: payload["sessionId"] = session_id - return await self._request("POST", url, json=payload) + return await self._post_command(url, payload) async def async_stop_charge( self, serial: str, session_id: int | None = None @@ -509,7 +581,7 @@ async def async_stop_charge( payload: dict[str, Any] = {"evseSerialNumber": serial} if session_id is not None: payload["sessionId"] = session_id - return await self._request("POST", url, json=payload) + return await self._post_command(url, payload) async def async_get_recharge_sessions( self, network_uid: str, limit: int = 1000 diff --git a/custom_components/daze/switch.py b/custom_components/daze/switch.py index d0c37b6..5f9ef5f 100644 --- a/custom_components/daze/switch.py +++ b/custom_components/daze/switch.py @@ -19,7 +19,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import ApiAuthError, ApiError +from .api import ApiAuthError, ApiCommandRejectedError, ApiError from .const import DOMAIN from .coordinator import DazeDataUpdateCoordinator @@ -111,6 +111,15 @@ async def async_turn_on(self, **kwargs: Any) -> None: "Authentication failed when trying to start charging. " "Please re-authenticate the integration." ) + except ApiCommandRejectedError as err: + # The charger explained why; relay that rather + # than the stock 'check the car is connected'. + _LOGGER.info( + "Charger refused the command on %s: %s", + self._serial_number, + err, + ) + self._notify_error(str(err)) except ApiError as err: _LOGGER.warning( "API error starting charge on %s: %s", @@ -150,6 +159,15 @@ async def async_turn_off(self, **kwargs: Any) -> None: "Authentication failed when trying to stop charging. " "Please re-authenticate the integration." ) + except ApiCommandRejectedError as err: + # The charger explained why; relay that rather + # than the stock 'check the car is connected'. + _LOGGER.info( + "Charger refused the command on %s: %s", + self._serial_number, + err, + ) + self._notify_error(str(err)) except ApiError as err: _LOGGER.warning( "API error stopping charge on %s: %s", diff --git a/tools/try_resume.py b/tools/try_resume.py index ce69c09..ab5960e 100755 --- a/tools/try_resume.py +++ b/tools/try_resume.py @@ -169,17 +169,59 @@ def read_state(token: str, serial: str) -> dict: return data if isinstance(data, dict) else {} +def start_candidates(serial: str, last_session_id: object) -> list[tuple[str, dict]]: + """Variants for starting when no session is paused. + + Resuming is solved: playcharge with the serial and the live session + ID works. Starting from a connected-but-idle charger is a different + problem, because there is no session to name and playcharge answers + HTTP 500 code 101. + """ + quoted = urllib.parse.quote(serial, safe="") + + variants: list[tuple[str, dict]] = [ + # Play with no session at all, only the serial. + (f"/sockets/{quoted}/commands/playcharge", {"evseSerialNumber": serial}), + # A dedicated start rather than a resume. + (f"/sockets/{quoted}/commands/startcharge", {"evseSerialNumber": serial}), + (f"/sockets/{quoted}/commands/startcharge", {}), + # Zero as an explicit "no current session" marker. + ( + f"/sockets/{quoted}/commands/playcharge", + {"evseSerialNumber": serial, "sessionId": 0}, + ), + # The command scoped to the EVSE rather than the socket. + (f"/evses/{quoted}/commands/playcharge", {"evseSerialNumber": serial}), + ] + + if last_session_id: + # The previous session ID, in case the charger expects the most + # recent one even after it closed. + variants.append( + ( + f"/sockets/{quoted}/commands/playcharge", + {"evseSerialNumber": serial, "sessionId": last_session_id}, + ) + ) + + return variants + + def candidates( serial: str, session_id: object, restore_current: int ) -> list[tuple[str, dict]]: - """Build the request variants to try, most likely first. + """Build the resume variants, most likely first. - Variant 1 is already known to return HTTP 200 without resuming, so - it is kept only as a control. + The first entry is the confirmed winner: it moved the charger from + evseState 6 to 5 with isPaused clearing. """ quoted = urllib.parse.quote(serial, safe="") return [ + ( + f"/sockets/{quoted}/commands/playcharge", + {"evseSerialNumber": serial, "sessionId": session_id}, + ), # Restoring the current limit. While paused the session reports # lastMaxChargingCurrent 0, so the pause may simply be a zero # current limit rather than a session state. @@ -317,31 +359,29 @@ def main() -> int: print(f"suspension: {state.get('evseSuspensionReason')}") print(f"sessionId : {session_id}") - if session_id is None: - print("\nNo session ID available. There is nothing to resume, so") - print("these variants cannot be tested meaningfully. Start a") - print("session first, pause it, then run this again.") - return 1 - - if not state.get("isPaused"): - print("\nThe charger does not report being paused. Resuming is") - print("only meaningful from a paused state; run this while it is") - print("actually paused or the results will not mean anything.") - answer = input("Continue anyway? [yes/no] ").strip().lower() - if answer != "yes": - return 0 + paused = bool(state.get("isPaused")) + + if paused and session_id is not None: + print("\nMode: RESUME (charger is paused with an open session).") + # While paused the session reports a zero current limit, so + # restoring a sane value is one of the things worth trying. + restore = 0 + if isinstance(session, dict): + restore = session.get("lastMaxChargingCurrent") or 0 + if not restore: + entered = input("Current limit to restore in mA [11739]: ").strip() + restore = int(entered) if entered.isdigit() else 11739 + variants = candidates(serial, session_id, restore) + else: + print("\nMode: START (charger is not paused).") + print("Resuming is already solved; this searches for the call") + print("that starts charging from a connected but idle charger.") + last_id = session_id + if last_id is None: + entered = input("Last known session ID, blank to skip: ").strip() + last_id = int(entered) if entered.isdigit() else None + variants = start_candidates(serial, last_id) - # While paused the session reports a zero current limit, so - # restoring a sane value is one of the things worth trying. - restore = 0 - if isinstance(session, dict): - restore = session.get("lastMaxChargingCurrent") or 0 - if not restore: - entered = input("\nCurrent limit to restore in mA [11739]: ").strip() - restore = int(entered) if entered.isdigit() else 11739 - print(f"Will restore current to {restore} mA if that variant is tried.") - - variants = candidates(serial, session_id, restore) print(f"\n{len(variants)} variant(s) to try. Ctrl-C stops at any point.") for index, (path, body) in enumerate(variants, start=1): From 8ce773756e4f85b7506513cb2c6326d6946efb9d Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 19:12:18 +0100 Subject: [PATCH 10/82] fix: retry charge commands through the flaky Daze RPC link Start and stop failed with HTTP 500 and error code 101, "Server error while requesting rpc server side". This is not a rejection: the Daze service relays commands to the wallbox over its own RPC link, and that link fails intermittently. The same command succeeds on a later attempt, which is why the vendor app appears to need several presses. Around six attempts were observed before a command took effect. The integration sent the command once and reported an error, so a working setup looked broken. Retry the command up to eight times, 1.5s apart, on code 101 or any 5xx without a structured code. A wrong-session rejection (4121) is deterministic and is still surfaced immediately. The worst case is about fifteen seconds, which is tolerable for an operation that physically switches a charger. Log the attempt number when a command succeeds after a retry, so the budget can be tuned from real use rather than guessed again. Correct the earlier reading of code 101. It was mapped as "the charger is not in a state where that command applies", which was wrong and produced a confident, misleading message. Carry the HTTP status on ApiError so retry decisions do not depend on parsing the message text. Add a retry mode to tools/try_resume.py that sends one known-correct command repeatedly and reports which attempt produced an observed state change, for start and for stop. This is how the six-attempt figure was measured. Fix the test fake, which returned Python repr rather than JSON from text() and so hid the error-code parsing from the tests. Bump version to 0.1.5. Co-Authored-By: Claude Opus 5 --- custom_components/daze/api/__init__.py | 136 ++++++++++++++++++++----- custom_components/daze/manifest.json | 2 +- tests/test_auth_getuser.py | 107 ++++++++++++++++++- tools/try_resume.py | 129 +++++++++++++++++++++++ 4 files changed, 344 insertions(+), 30 deletions(-) diff --git a/custom_components/daze/api/__init__.py b/custom_components/daze/api/__init__.py index d9fd166..7105867 100644 --- a/custom_components/daze/api/__init__.py +++ b/custom_components/daze/api/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging import re from typing import Any @@ -19,6 +20,14 @@ _LOGGER = logging.getLogger(__name__) +# The command RPC fails intermittently and needs more attempts than +# is comfortable: around six were observed before a start or stop +# took effect. Eight tries 1.5s apart gives headroom over that while +# keeping the worst case near fifteen seconds, which is tolerable +# for a command that physically switches a charger. +COMMAND_RETRY_ATTEMPTS = 8 +COMMAND_RETRY_DELAY = 1.5 + class ApiAuthError(Exception): """Raised when the API returns 401 after a token refresh attempt. @@ -31,6 +40,11 @@ class ApiAuthError(Exception): class ApiError(Exception): """Raised for non-auth API errors (4xx, 5xx, network issues).""" + def __init__(self, message: str, *, status: int | None = None) -> None: + """Store the HTTP status alongside the message.""" + super().__init__(message) + self.status = status + class ApiCommandRejectedError(ApiError): """Raised when the charger refuses a command it cannot perform. @@ -48,17 +62,20 @@ def __init__(self, message: str, *, code: int | None = None) -> None: # Upstream error codes seen from the command endpoints, with what each # actually meant when observed. +COMMAND_ERROR_CODE_RPC_FAILURE = 101 +COMMAND_ERROR_CODE_WRONG_SESSION = 4121 + COMMAND_ERROR_HINTS: dict[int, str] = { # Sent with no session ID, or naming a session that is not paused. - 4121: ( + COMMAND_ERROR_CODE_WRONG_SESSION: ( "there is no paused charging session to resume" ), - # The request was accepted but the charger could not carry it out, - # observed when the charger was already running or idle rather than - # paused. - 101: ( - "the charger could not carry out the command, which usually " - "means it is not in a state where that command applies" + # The Daze service could not reach the wallbox over its own RPC + # link. Intermittent: the same command succeeds on a later attempt, + # which is why the vendor app needs several presses. Retried + # rather than reported. + COMMAND_ERROR_CODE_RPC_FAILURE: ( + "the Daze service could not reach the wallbox" ), } @@ -209,7 +226,8 @@ async def _request( ) raise ApiError( f"API {method} {url} failed with status " - f"{response.status}: {body}" + f"{response.status}: {body}", + status=response.status, ) return await response.json() @@ -293,7 +311,8 @@ async def _handle_401( ) raise ApiError( f"API {method} {url} failed with status " - f"{response.status}: {body}" + f"{response.status}: {body}", + status=response.status, ) return await response.json() @@ -498,30 +517,93 @@ async def async_set_eco_mode( return await self._request("POST", url, json=payload) async def _post_command( - self, url: str, payload: dict[str, Any] + self, + url: str, + payload: dict[str, Any], + attempts: int = COMMAND_RETRY_ATTEMPTS, + delay: float = COMMAND_RETRY_DELAY, ) -> dict[str, Any]: - """POST a charger command, explaining a refusal when it fails. + """POST a charger command, retrying transient RPC failures. - The command endpoints answer with a structured error list. A - generic ApiError loses that detail, so the caller cannot tell a - "nothing to resume" refusal from a real outage. + The Daze service relays commands to the wallbox over its own + RPC link, and that link fails intermittently with HTTP 500 and + error code 101. The same command succeeds on a later attempt: + roughly six were needed in observed cases. This is why the + vendor app appears to need several presses to start or stop a + charge, and why reporting an error after one attempt made the + integration look broken when it was not. + + Only that failure is retried. A wrong-session rejection (4121) + is deterministic, so it is surfaced immediately. + + Args: + url: The command endpoint. + payload: The JSON body. + attempts: Total tries, including the first. + delay: Seconds to wait between tries. + + Returns: + The response from the first attempt that succeeds. Raises: - ApiCommandRejectedError: If the charger refused the command. + ApiCommandRejectedError: If the charger refused the command, + or if every attempt hit the transient failure. """ - try: - return await self._request("POST", url, json=payload) - except ApiAuthError: - raise - except ApiError as err: - code, hint = _code_and_hint_from_error(err) - if hint: - raise ApiCommandRejectedError( - f"The charger refused the command because {hint}.", - code=code, - ) from err - raise + last_error: ApiError | None = None + + for attempt_number in range(1, attempts + 1): + try: + result = await self._request("POST", url, json=payload) + except ApiAuthError: + raise + except ApiError as err: + last_error = err + code, hint = _code_and_hint_from_error(err) + + retryable = code == COMMAND_ERROR_CODE_RPC_FAILURE or ( + code is None and (getattr(err, "status", None) or 0) >= 500 + ) + + if not retryable: + if hint: + raise ApiCommandRejectedError( + f"The charger refused the command because " + f"{hint}.", + code=code, + ) from err + raise + + if attempt_number < attempts: + _LOGGER.debug( + "Transient RPC failure on attempt %d of %d, " + "retrying in %.1fs", + attempt_number, + attempts, + delay, + ) + await asyncio.sleep(delay) + else: + if attempt_number > 1: + _LOGGER.info( + "Command succeeded on attempt %d of %d", + attempt_number, + attempts, + ) + return result + + code, _ = _code_and_hint_from_error(last_error or Exception()) + _LOGGER.warning( + "Command still failing after %d attempts: %s", + attempts, + last_error, + ) + raise ApiCommandRejectedError( + f"The Daze service could not reach the wallbox after " + f"{attempts} attempts. This is intermittent rather than a " + "fault; try again shortly.", + code=code, + ) from last_error async def async_start_charge( self, serial: str, session_id: int | None = None diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index 8ceb2aa..7575a28 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.4" + "version": "0.1.5" } diff --git a/tests/test_auth_getuser.py b/tests/test_auth_getuser.py index d9a67ce..3c5dd6b 100644 --- a/tests/test_auth_getuser.py +++ b/tests/test_auth_getuser.py @@ -16,6 +16,7 @@ import ast import asyncio import importlib.util +import json import sys import types from pathlib import Path @@ -59,6 +60,9 @@ def _load_integration_modules() -> tuple[Any, Any]: auth, api = _load_integration_modules() +# Keep the suite fast; the delay itself is not under test. +api.COMMAND_RETRY_DELAY = 0.0 + # ------------------------------------------------------------------ # Fake aiohttp session @@ -79,8 +83,13 @@ async def json(self, content_type: str | None = "application/json") -> Any: return self._payload async def text(self) -> str: - """Return a textual body.""" - return str(self._payload) + """Return the body as JSON text, as aiohttp does. + + Returning str(dict) here would produce Python repr with single + quotes, which is not what the real client sees and would hide + parsing bugs in the error handling. + """ + return json.dumps(self._payload) async def __aenter__(self) -> FakeResponse: return self @@ -379,6 +388,100 @@ def test_commands_still_send_the_serial_without_a_session() -> None: assert session.calls[0]["json"] == {"evseSerialNumber": "SER1"} + +RPC_FAILURE = { + "message": "Error", + "errors": [{"code": 101, "message": "Server error while requesting rpc server side"}], +} + +WRONG_SESSION = { + "message": "Invalid Data", + "errors": [{"code": 4121, "message": "ErrorWrongSessionID"}], +} + +COMMAND_OK = {"message": "", "errors": []} + + +def test_retry_budget_covers_observed_failure_rate() -> None: + """Around six attempts were needed in practice, so allow more.""" + assert api.COMMAND_RETRY_ATTEMPTS >= 8 + + +def test_transient_rpc_failure_is_retried_until_it_works() -> None: + """Code 101 is intermittent; the command must not give up on it. + + The vendor app needs several presses for the same reason. Reporting + an error after one attempt is what made start and stop look broken. + """ + session = FakeSession( + [ + FakeResponse(500, RPC_FAILURE), + FakeResponse(500, RPC_FAILURE), + FakeResponse(200, COMMAND_OK), + ] + ) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + result = asyncio.run( + api_client.async_start_charge("SER1", 42) + ) + + assert result == COMMAND_OK + assert len(session.calls) == 3 + # Every attempt must send the same correct body. + for call in session.calls: + assert call["json"] == {"evseSerialNumber": "SER1", "sessionId": 42} + + +def test_retry_gives_up_and_says_it_is_temporary() -> None: + """Exhausting the retries must not blame the car or the session.""" + budget = api.COMMAND_RETRY_ATTEMPTS + session = FakeSession( + [FakeResponse(500, RPC_FAILURE) for _ in range(budget)] + ) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + try: + asyncio.run(api_client.async_start_charge("SER1", 42)) + except api.ApiCommandRejectedError as err: + assert err.code == 101 + assert "intermittent" in str(err) + assert len(session.calls) == budget + else: + raise AssertionError("expected ApiCommandRejectedError") + + +def test_wrong_session_is_not_retried() -> None: + """4121 is deterministic. Retrying it only wastes time.""" + session = FakeSession([FakeResponse(422, WRONG_SESSION)]) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + try: + asyncio.run(api_client.async_start_charge("SER1", None)) + except api.ApiCommandRejectedError as err: + assert err.code == 4121 + assert "no paused charging session" in str(err) + assert len(session.calls) == 1 + else: + raise AssertionError("expected ApiCommandRejectedError") + + +def test_stop_is_retried_the_same_way() -> None: + """Stop shows the same flakiness, so it gets the same treatment.""" + session = FakeSession( + [FakeResponse(500, RPC_FAILURE), FakeResponse(200, COMMAND_OK)] + ) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + asyncio.run(api_client.async_stop_charge("SER1", 42)) + + assert len(session.calls) == 2 + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tools/try_resume.py b/tools/try_resume.py index ab5960e..24323c6 100755 --- a/tools/try_resume.py +++ b/tools/try_resume.py @@ -325,6 +325,129 @@ def summarise(status: int, body: object) -> str: return f"HTTP {status}: {str(body)[:120]}" + +def verify_changed( + token: str, + serial: str, + baseline: dict, + attempts: int = 4, + delay: int = 3, +) -> tuple[bool, dict]: + """Poll until the charger state differs from the baseline. + + Direction agnostic: a start and a stop both show up as a change in + evseState or the pause flag, so the same check works for either. + + Returns: + A tuple of (changed, last observed state). + + """ + base_state = baseline.get("evseState") + base_paused = baseline.get("isPaused") + state: dict = {} + + for index in range(attempts): + time.sleep(delay) + state = read_state(token, serial) + session = state.get("chargeSession") + power = ( + session.get("instantPowerAsWatt") + if isinstance(session, dict) + else None + ) + + print( + f" +{(index + 1) * delay:>2}s " + f"evseState={state.get('evseState')} " + f"isPaused={state.get('isPaused')} power={power} W" + ) + + if ( + state.get("evseState") != base_state + or state.get("isPaused") != base_paused + ): + return True, state + + return False, state + + +def retry_mode(token: str, serial: str, state: dict) -> int: + """Send one command repeatedly until the charger state changes. + + The command shape is already known to be correct. What is not known + is how many attempts the Daze RPC link needs before it takes. This + measures exactly that. + """ + session = state.get("chargeSession") + session_id = session.get("sessionId") if isinstance(session, dict) else None + quoted = urllib.parse.quote(serial, safe="") + + print("\nWhich direction do you want to test?") + print(" 1 start / resume (playcharge)") + print(" 2 stop (stopcharge)") + choice = input("Choice [1/2]: ").strip() + + if choice == "2": + path = f"/sockets/{quoted}/commands/stopcharge" + label = "stop" + else: + path = f"/sockets/{quoted}/commands/playcharge" + label = "start" + + body: dict = {"evseSerialNumber": serial} + if session_id is not None: + body["sessionId"] = session_id + + max_attempts = input("How many attempts at most? [8]: ").strip() + attempts = int(max_attempts) if max_attempts.isdigit() else 8 + + gap = input("Seconds between attempts? [2]: ").strip() + gap_seconds = int(gap) if gap.isdigit() else 2 + + print(f"\nWill send this up to {attempts} time(s), {gap_seconds}s apart:") + print(f" POST {API_BASE_URL}{path}") + print(f" body {json.dumps(body)}") + print(f"\nBaseline: evseState={state.get('evseState')} " + f"isPaused={state.get('isPaused')}") + + if input("\nProceed? [yes/no] ").strip().lower() != "yes": + return 0 + + statuses: list[str] = [] + + for number in range(1, attempts + 1): + print(f"\n Attempt {number} of {attempts}") + status, response = attempt(token, path, body) + print(f" -> {summarise(status, response)}") + statuses.append(str(status)) + + if 200 <= status < 300: + print(" accepted; watching for a state change:") + changed, after = verify_changed(token, serial, state) + if changed: + print("\n" + "=" * 60) + print(f"WORKED on attempt {number} of {attempts}.") + print(f" status sequence: {', '.join(statuses)}") + print(f" final: evseState={after.get('evseState')} " + f"isPaused={after.get('isPaused')}") + print(f"\nThe {label} command is correct. It needed " + f"{number} attempt(s), which is what the retry in " + "the integration is sized for.") + return 0 + print(" accepted but nothing changed; treating as a miss") + + if number < attempts: + time.sleep(gap_seconds) + + print("\n" + "=" * 60) + print(f"No attempt produced a state change after {attempts} tries.") + print(f" status sequence: {', '.join(statuses)}") + print("\nIf these were all 500s, the RPC link is down rather than") + print("flaky. If they were 200s with no change, the command is") + print("accepted but not applicable from this state.") + return 1 + + def main() -> int: """Try each resume variant with per-attempt confirmation.""" print("Daze resume-command finder") @@ -359,6 +482,12 @@ def main() -> int: print(f"suspension: {state.get('evseSuspensionReason')}") print(f"sessionId : {session_id}") + print("\nWhat do you want to do?") + print(" 1 retry one known command until it works (measures flakiness)") + print(" 2 search for a working command variant") + if input("Choice [1/2]: ").strip() != "2": + return retry_mode(token, serial, state) + paused = bool(state.get("isPaused")) if paused and session_id is not None: From e5a5b451ddf5e79d2086860d74e34422733d8a3d Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 21:31:35 +0100 Subject: [PATCH 11/82] feat: report waiting-for-EV and follow state transitions Two problems showed up once commands started working. A start does not go straight to charging. The charger passes through evseState 5 first: the session is authorised and unpaused but the car is not yet drawing. That state was reported as idle, which is wrong in both directions. It hid a real state from the status sensor, and it made the charge switch read off immediately after a successful start, so the toggle appeared to snap back as though the command had failed. Add waiting_for_ev as a status and as a declared option on the status sensor, with English and Italian labels. The charge switch now reads on for charging and for waiting-for-EV, since the user's intent has been carried out in both cases, and only reads off when idle or paused. Separately, state changes are not instant. Pausing in particular takes several seconds to register. The coordinator refreshed once immediately after a command, which reads the state before the change and leaves the entities stale until the next ordinary poll thirty seconds later. Schedule further reads at 3, 8, 15 and 30 seconds after any command, so the entities follow the transition. They are scheduled rather than awaited, so a service call still returns promptly. Applied to the switch, the current limit, the operation mode and the three services. Bump version to 0.1.6. Co-Authored-By: Claude Opus 5 --- custom_components/daze/__init__.py | 3 + custom_components/daze/coordinator.py | 35 +++++++++++ custom_components/daze/manifest.json | 2 +- custom_components/daze/number.py | 1 + custom_components/daze/payload.py | 39 ++++++++++-- custom_components/daze/select.py | 1 + custom_components/daze/sensor_catalog.py | 12 +++- custom_components/daze/strings.json | 10 ++- custom_components/daze/switch.py | 16 +++-- custom_components/daze/translations/it.json | 10 ++- tests/test_payload.py | 69 +++++++++++++++++---- 11 files changed, 173 insertions(+), 25 deletions(-) diff --git a/custom_components/daze/__init__.py b/custom_components/daze/__init__.py index 9612107..ba89401 100644 --- a/custom_components/daze/__init__.py +++ b/custom_components/daze/__init__.py @@ -148,6 +148,7 @@ async def _handle_start_charge(call: ServiceCall) -> None: serial_number, _session_id() ) await coordinator.async_request_refresh() + coordinator.async_schedule_settle_refresh() except ApiAuthError as err: raise ConfigEntryAuthFailed( "Authentication failed when starting charge. " @@ -165,6 +166,7 @@ async def _handle_stop_charge(call: ServiceCall) -> None: serial_number, _session_id() ) await coordinator.async_request_refresh() + coordinator.async_schedule_settle_refresh() except ApiAuthError as err: raise ConfigEntryAuthFailed( "Authentication failed when stopping charge. " @@ -183,6 +185,7 @@ async def _handle_set_charging_current(call: ServiceCall) -> None: serial_number, current ) await coordinator.async_request_refresh() + coordinator.async_schedule_settle_refresh() except ApiAuthError as err: raise ConfigEntryAuthFailed( "Authentication failed when setting charging current. " diff --git a/custom_components/daze/coordinator.py b/custom_components/daze/coordinator.py index b5581de..cee004b 100644 --- a/custom_components/daze/coordinator.py +++ b/custom_components/daze/coordinator.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, UpdateFailed, @@ -47,6 +48,17 @@ # so it does not need the live metric cadence. EVSE_FETCH_INTERVAL = 120 # seconds +# A charger does not change state the instant a command is accepted. +# Starting passes through waiting-for-EV before charging, and pausing +# takes its own time to register. A single refresh straight after the +# command reads the old state and leaves the UI stale until the next +# ordinary poll, up to DEFAULT_POLL_INTERVAL later. +# +# These offsets re-read the charger over the following half minute so +# the entities follow the transition. They are scheduled rather than +# awaited, so a service call still returns promptly. +SETTLE_REFRESH_DELAYS = (3, 8, 15, 30) + class DazeDataUpdateCoordinator( DataUpdateCoordinator[DazeCoordinatorData] @@ -131,6 +143,29 @@ def network_uid(self) -> str: """Return the network UID.""" return self._network_uid + def async_schedule_settle_refresh(self) -> None: + """Re-read the charger a few times after a command. + + Commands take effect asynchronously: the charger moves through + intermediate states for several seconds. Refreshing once + immediately captures the state before the change, so schedule + further reads across the transition. + + Scheduled, not awaited: the caller returns immediately. + """ + for delay in SETTLE_REFRESH_DELAYS: + + async def _refresh(_now: Any, _delay: int = delay) -> None: + """Ask the coordinator to re-read the charger.""" + _LOGGER.debug( + "Settle refresh for %s at +%ss", + self._serial_number, + _delay, + ) + await self.async_request_refresh() + + async_call_later(self.hass, delay, _refresh) + async def _async_update_data(self) -> DazeCoordinatorData: """Fetch the latest socket remote info and session data. diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index 7575a28..15fe8c0 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.5" + "version": "0.1.6" } diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index 29314be..7f56ea9 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -116,6 +116,7 @@ async def async_set_native_value(self, value: float) -> None: self._serial_number, int_value ) await self.coordinator.async_request_refresh() + self.coordinator.async_schedule_settle_refresh() except ApiAuthError as err: _LOGGER.warning( "Auth error setting max current on %s: %s", diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index e580de8..04ed30c 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -26,19 +26,22 @@ # EVSE state values confirmed against live hardware: # # 3 charging observed while delivering 2688 W with a session running -# 5 connected observed immediately after resuming: isPaused cleared -# and evseSuspensionReason zero, but still drawing 0 W. -# Reported as idle because no energy is flowing. +# 5 waiting observed immediately after a start or resume takes +# effect: isPaused cleared and evseSuspensionReason +# zero, but still drawing 0 W. The session is live and +# authorised; the car has not begun drawing yet. The +# charger passes through this on its way to 3. # 6 paused observed with isPaused true, evseSuspensionReason 3, # zero instant power, and the session still open # # Other values remain unknown, so an unrecognised state reports "idle" # rather than inventing a meaning. EVSE_STATE_CHARGING = 3 -EVSE_STATE_CONNECTED = 5 +EVSE_STATE_WAITING_FOR_EV = 5 EVSE_STATE_PAUSED = 6 STATUS_CHARGING = "charging" +STATUS_WAITING_FOR_EV = "waiting_for_ev" STATUS_IDLE = "idle" STATUS_PAUSED = "paused" STATUS_ERROR = "error" @@ -100,6 +103,9 @@ def derive_status(data: dict[str, Any]) -> str | None: if state == EVSE_STATE_PAUSED: return STATUS_PAUSED + if state == EVSE_STATE_WAITING_FOR_EV: + return STATUS_WAITING_FOR_EV + return STATUS_IDLE @@ -153,3 +159,28 @@ def merge_payload( merged["evseStatus"] = status return merged + + +# States in which charging is enabled, whether or not energy is +# currently flowing. The charge switch reads this so that it does not +# snap back to off while the charger waits for the car to draw. +ACTIVE_STATUSES = frozenset({STATUS_CHARGING, STATUS_WAITING_FOR_EV}) + + +def is_charge_enabled(data: dict[str, Any]) -> bool | None: + """Return whether a charge is authorised and under way. + + True while charging and while waiting for the EV to start drawing, + because the user's intent has been carried out in both cases. + + Args: + data: The merged payload. + + Returns: + True, False, or None when the status is unknown. + + """ + status = data.get("evseStatus") + if status is None: + return None + return str(status).lower() in ACTIVE_STATUSES diff --git a/custom_components/daze/select.py b/custom_components/daze/select.py index 803879d..f15ab26 100644 --- a/custom_components/daze/select.py +++ b/custom_components/daze/select.py @@ -145,6 +145,7 @@ async def async_select_option(self, option: str) -> None: self._serial_number, eco_value ) await self.coordinator.async_request_refresh() + self.coordinator.async_schedule_settle_refresh() except ApiAuthError as err: _LOGGER.warning( "Auth error setting operation mode on %s: %s", diff --git a/custom_components/daze/sensor_catalog.py b/custom_components/daze/sensor_catalog.py index 3e0e1bf..18c3405 100644 --- a/custom_components/daze/sensor_catalog.py +++ b/custom_components/daze/sensor_catalog.py @@ -32,7 +32,8 @@ class EVSESensorSpec: "paused": "paused", "error": "error", "offline": "offline", - "waiting_for_car": "idle", + "waiting_for_ev": "waiting_for_ev", + "waiting_for_car": "waiting_for_ev", "waiting_for_charge": "idle", "play_charge": "charging", "pause_charge": "paused", @@ -148,7 +149,14 @@ def get_next_scheduled_charge(data: dict[str, Any]) -> Any | None: EVSESensorSpec( key="evse_status", device_class="enum", - options=("idle", "charging", "paused", "error", "offline"), + options=( + "idle", + "waiting_for_ev", + "charging", + "paused", + "error", + "offline", + ), value_fn=get_evse_status, ), EVSESensorSpec( diff --git a/custom_components/daze/strings.json b/custom_components/daze/strings.json index 2887c9d..bd89154 100644 --- a/custom_components/daze/strings.json +++ b/custom_components/daze/strings.json @@ -82,7 +82,15 @@ "name": "Case Temperature" }, "evse_status": { - "name": "EVSE Status" + "name": "EVSE Status", + "state": { + "idle": "Idle", + "waiting_for_ev": "Waiting for vehicle", + "charging": "Charging", + "paused": "Paused", + "error": "Error", + "offline": "Offline" + } }, "grid_max_power": { "name": "Grid Max Power" diff --git a/custom_components/daze/switch.py b/custom_components/daze/switch.py index 5f9ef5f..7a9e689 100644 --- a/custom_components/daze/switch.py +++ b/custom_components/daze/switch.py @@ -22,6 +22,7 @@ from .api import ApiAuthError, ApiCommandRejectedError, ApiError from .const import DOMAIN from .coordinator import DazeDataUpdateCoordinator +from .payload import is_charge_enabled if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -76,13 +77,16 @@ def _session_id(self) -> int | None: @property def is_on(self) -> bool | None: - """Return True if the wallbox is currently charging.""" + """Return True while a charge is authorised and under way. + + Includes the waiting-for-EV state. The charger passes through + it after a start takes effect, before the car begins drawing. + Reporting off there would make the toggle snap back moments + after the user switched it on, even though the command worked. + """ if self.coordinator.data is None: return None - status = self.coordinator.data.get("evseStatus") - if status is None: - return None - return str(status).lower() == CHARGING_STATE + return is_charge_enabled(self.coordinator.data) async def async_turn_on(self, **kwargs: Any) -> None: """Start charging on the wallbox.""" @@ -101,6 +105,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: self._serial_number, self._session_id ) await self.coordinator.async_request_refresh() + self.coordinator.async_schedule_settle_refresh() except ApiAuthError as err: _LOGGER.warning( "Auth error starting charge on %s: %s", @@ -149,6 +154,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: self._serial_number, self._session_id ) await self.coordinator.async_request_refresh() + self.coordinator.async_schedule_settle_refresh() except ApiAuthError as err: _LOGGER.warning( "Auth error stopping charge on %s: %s", diff --git a/custom_components/daze/translations/it.json b/custom_components/daze/translations/it.json index ee4dd91..94b8364 100644 --- a/custom_components/daze/translations/it.json +++ b/custom_components/daze/translations/it.json @@ -83,7 +83,15 @@ "name": "Temperatura involucro" }, "evse_status": { - "name": "Stato EVSE" + "name": "Stato EVSE", + "state": { + "idle": "Inattivo", + "waiting_for_ev": "In attesa del veicolo", + "charging": "In carica", + "paused": "In pausa", + "error": "Errore", + "offline": "Non in linea" + } }, "grid_max_power": { "name": "Potenza massima rete", diff --git a/tests/test_payload.py b/tests/test_payload.py index 41ab797..799774c 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -197,8 +197,12 @@ def test_inactive_reports_offline() -> None: def test_unknown_state_reports_idle_not_charging() -> None: - """Unconfirmed state values must never be reported as charging.""" - for state in (0, 1, 2, 4, 5, 99): + """Unconfirmed state values must never be reported as charging. + + 3, 5 and 6 are confirmed and excluded; everything else is still a + guess and must fall back to idle. + """ + for state in (0, 1, 2, 4, 7, 99): remote = {**REMOTE_INFO, "evseState": state} data = payload.merge_payload(remote, EVSE_RECORD) assert data["evseStatus"] == "idle", state @@ -344,21 +348,64 @@ def test_paused_session_keeps_its_session_id() -> None: -def test_state_5_is_connected_not_charging() -> None: - """Observed right after a resume: unpaused but drawing no power. - - Reported as idle rather than charging, because no energy flows. - Calling it charging would make the switch read on while the car - takes nothing. - """ +def _waiting_payload() -> dict[str, Any]: + """Return the state seen right after a start takes effect.""" remote = { **REMOTE_INFO_PAUSED, "evseState": 5, "evseSuspensionReason": 0, "isPaused": False, } - data = payload.merge_payload(remote, EVSE_RECORD) - assert data["evseStatus"] == "idle" + return payload.merge_payload(remote, EVSE_RECORD) + + +def test_state_5_is_waiting_for_the_vehicle() -> None: + """Observed after a start: unpaused, authorised, drawing nothing. + + Distinct from idle, which means no session at all, and from + charging, which means energy is flowing. + """ + assert _waiting_payload()["evseStatus"] == "waiting_for_ev" + + +def test_switch_stays_on_while_waiting_for_the_vehicle() -> None: + """The toggle must not snap back after a successful start. + + The charger passes through waiting-for-EV on its way to charging. + Reporting off there would show the command as having failed. + """ + assert payload.is_charge_enabled(_waiting_payload()) is True + + +def test_switch_is_on_while_charging() -> None: + """The ordinary case still reads on.""" + assert payload.is_charge_enabled(merged()) is True + + +def test_switch_is_off_when_paused_or_idle() -> None: + """A paused or idle charger is not charging.""" + paused = payload.merge_payload(REMOTE_INFO_PAUSED, EVSE_RECORD) + assert payload.is_charge_enabled(paused) is False + + idle = payload.merge_payload( + {**REMOTE_INFO, "evseState": 1}, EVSE_RECORD + ) + assert payload.is_charge_enabled(idle) is False + + +def test_switch_state_is_unknown_without_status() -> None: + """No status must not be reported as off.""" + assert payload.is_charge_enabled({}) is None + + +def test_waiting_state_is_a_declared_sensor_option() -> None: + """An enum sensor rejects values missing from its options.""" + spec = next( + s for s in catalog.EVSE_SENSOR_CATALOG if s.key == "evse_status" + ) + assert spec.options is not None + assert "waiting_for_ev" in spec.options + assert spec.value_fn(_waiting_payload()) == "waiting_for_ev" def _main() -> int: From 6bf7b53e4bfcc4b8e2cc5db182c8df5bf73255df Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 21:43:47 +0100 Subject: [PATCH 12/82] docs: credit the original author The README pointed entirely at this fork after the metadata was repointed, leaving no reference to where the integration came from. That was wrong: the architecture, config flow, entity model, sensor catalog and API client are Andrea Restello's work, including the reverse engineering of the undocumented Daze web API. Add a fork notice near the top of the README and a Credits section listing what this fork changed and what it did not. Add a NOTICE file stating the same for anyone reading the source rather than the README. Spell the name out in the LICENSE copyright line, which read "andrea". The terms and the year are unchanged. Nothing was added to manifest.json: hassfest validates it against a strict schema and rejects unknown keys, which would break CI. The upstream repository is registered as a git remote instead, which is local configuration rather than a tracked file. Co-Authored-By: Claude Opus 5 --- LICENSE | 2 +- NOTICE | 29 +++++++++++++++++++++++++++++ README.md | 32 +++++++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 NOTICE diff --git a/LICENSE b/LICENSE index 9503928..d02fcd7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 andrea +Copyright (c) 2025 Andrea Restello Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..57bcfd5 --- /dev/null +++ b/NOTICE @@ -0,0 +1,29 @@ +Daze Wallbox Integration for Home Assistant +=========================================== + +Original author +--------------- + +Created by Andrea Restello (@arest). +Upstream project: https://github.com/arest/daze-addon + +The integration architecture, config flow, entity model, sensor catalog +and API client are his work, including the reverse engineering of the +Daze web API, which the vendor does not publish. + +This repository +--------------- + +A fork maintained by Pedro Tarrinho (@tarrinho) at +https://github.com/tarrinho/daze-addon + +It contains bug fixes found while running the integration against a +DT01 charger. It is not a redesign, and it carries no claim over the +original work. See the Credits section of README.md for the specific +changes. + +Licence +------- + +MIT, Copyright (c) 2025 Andrea Restello. See LICENSE, which is carried +over from the upstream project unchanged. diff --git a/README.md b/README.md index b49d272..17078a3 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ Home Assistant integration for **Daze WallBox EV chargers**. Monitor charging me Daze wallboxes are managed through the [Daze web portal](https://webportal.dazeservice.com). This integration bridges the gap, bringing your wallbox into Home Assistant alongside all your other smart home devices. +> **This is a fork.** The original integration was created by **Andrea Restello** ([@arest](https://github.com/arest)) at [arest/daze-addon](https://github.com/arest/daze-addon), and all of the original design and implementation is his work. This fork adds fixes found while running it against a DT01 charger — see [Credits](#credits). + --- ## Features @@ -232,6 +234,34 @@ The integration is validated with: - `hassfest` for Home Assistant integration validation - HACS validation +--- + +## Credits + +This integration was created by **Andrea Restello** ([@arest](https://github.com/arest)). +The upstream project is [arest/daze-addon](https://github.com/arest/daze-addon). + +Everything this fork does rests on his work: the integration architecture, the +config flow, the entity model, the sensor catalog and the API client were all +written upstream. He also reverse-engineered the Daze web API, which is not +publicly documented — that is the hard part, and none of what follows would +exist without it. + +This fork adds fixes found while running the integration against a DT01 +charger: + +- Authenticate through Cognito `GetUser` rather than `/oauth2/userInfo`, which + rejects the token scope the Daze portal issues +- Read the live metrics from where the API actually returns them, nested under + `chargeSession`, and pull temperatures and the grid limit from the EVSE record +- Send the serial and session ID with the charge commands, and retry them + through the intermittent Daze RPC link +- Report the waiting-for-vehicle state, and follow state changes after a command + +These are bug fixes to someone else's design, not a redesign. Where the +upstream project takes them, this fork becomes unnecessary. + ### License -This project is licensed under the [MIT License](LICENSE). +This project is licensed under the [MIT License](LICENSE), Copyright (c) 2025 +Andrea Restello, carried over unchanged from the upstream project. From 3cafbba7b6b89f026111653387b535b3c8eca9f7 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 21:43:47 +0100 Subject: [PATCH 13/82] fix: stop logging retried command failures as warnings A command that needed six attempts left six warnings in the log, so a working retry looked like a recurring error: API error (HTTP 500) on POST .../commands/playcharge: {"code":101,"message":"Server error while requesting rpc server side"} The request layer logged every failure at warning before raising, and the retry wrapper then handled it silently. The user saw the noise and not the recovery. Give _request a log_errors switch. The retry wrapper sets it false, so attempts it intends to handle are logged at debug. If every attempt fails, the wrapper logs exactly one warning naming the command, the attempt count and the elapsed time. Add tests asserting that a command which succeeds after retries emits no warnings, and that exhausting the budget emits exactly one. Bump version to 0.1.7. Co-Authored-By: Claude Opus 5 --- custom_components/daze/api/__init__.py | 37 +++++++++---- custom_components/daze/manifest.json | 2 +- tests/test_auth_getuser.py | 72 ++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/custom_components/daze/api/__init__.py b/custom_components/daze/api/__init__.py index 7105867..a4dc419 100644 --- a/custom_components/daze/api/__init__.py +++ b/custom_components/daze/api/__init__.py @@ -169,6 +169,7 @@ async def _request( self, method: str, url: str, + log_errors: bool = True, **kwargs: Any, ) -> Any: """Make an authenticated HTTP request with automatic token refresh. @@ -180,6 +181,9 @@ async def _request( Args: method: HTTP method (GET, POST, etc.). url: Full URL for the request. + log_errors: Whether to log failures here. Callers that + retry set this to False so the retries stay quiet, and + log once themselves if they finally give up. **kwargs: Additional arguments passed to aiohttp.request. Returns: @@ -217,13 +221,23 @@ async def _request( if response.status >= 400: body = await response.text() - _LOGGER.warning( - "API error (HTTP %s) on %s %s: %s", - response.status, - method, - url, - body, - ) + if log_errors: + _LOGGER.warning( + "API error (HTTP %s) on %s %s: %s", + response.status, + method, + url, + body, + ) + else: + _LOGGER.debug( + "API error (HTTP %s) on %s %s, handled by " + "caller: %s", + response.status, + method, + url, + body, + ) raise ApiError( f"API {method} {url} failed with status " f"{response.status}: {body}", @@ -554,7 +568,9 @@ async def _post_command( for attempt_number in range(1, attempts + 1): try: - result = await self._request("POST", url, json=payload) + result = await self._request( + "POST", url, log_errors=False, json=payload + ) except ApiAuthError: raise except ApiError as err: @@ -594,8 +610,11 @@ async def _post_command( code, _ = _code_and_hint_from_error(last_error or Exception()) _LOGGER.warning( - "Command still failing after %d attempts: %s", + "Command %s gave up after %d attempts over %.0fs. The Daze " + "service could not reach the wallbox. Last error: %s", + url.rsplit("/", 1)[-1], attempts, + (attempts - 1) * delay, last_error, ) raise ApiCommandRejectedError( diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index 15fe8c0..4a15cb8 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.6" + "version": "0.1.7" } diff --git a/tests/test_auth_getuser.py b/tests/test_auth_getuser.py index 3c5dd6b..3da0527 100644 --- a/tests/test_auth_getuser.py +++ b/tests/test_auth_getuser.py @@ -17,6 +17,7 @@ import asyncio import importlib.util import json +import logging import sys import types from pathlib import Path @@ -482,6 +483,77 @@ def test_stop_is_retried_the_same_way() -> None: assert len(session.calls) == 2 + +class _Capture(logging.Handler): + """Collects log records for assertions.""" + + def __init__(self) -> None: + super().__init__() + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + """Store a record.""" + self.records.append(record) + + +def _capture_api_logs() -> _Capture: + """Attach a capturing handler to the api module's logger.""" + handler = _Capture() + logger = logging.getLogger(api.__name__) + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + return handler + + +def test_retried_failures_do_not_log_warnings() -> None: + """A retry that eventually works must not look like an error. + + Each failed attempt used to log at warning from the request layer, + so a command that succeeded on the third try left three warnings in + the log and looked broken to the user. + """ + session = FakeSession( + [ + FakeResponse(500, RPC_FAILURE), + FakeResponse(500, RPC_FAILURE), + FakeResponse(200, COMMAND_OK), + ] + ) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + handler = _capture_api_logs() + try: + asyncio.run(api_client.async_start_charge("SER1", 42)) + finally: + logging.getLogger(api.__name__).removeHandler(handler) + + warnings = [r for r in handler.records if r.levelno >= logging.WARNING] + assert not warnings, [r.getMessage() for r in warnings] + + +def test_giving_up_logs_exactly_one_warning() -> None: + """Exhausting the retries is worth one warning, not eight.""" + budget = api.COMMAND_RETRY_ATTEMPTS + session = FakeSession( + [FakeResponse(500, RPC_FAILURE) for _ in range(budget)] + ) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + handler = _capture_api_logs() + try: + asyncio.run(api_client.async_start_charge("SER1", 42)) + except api.ApiCommandRejectedError: + pass + finally: + logging.getLogger(api.__name__).removeHandler(handler) + + warnings = [r for r in handler.records if r.levelno >= logging.WARNING] + assert len(warnings) == 1, [r.getMessage() for r in warnings] + assert "gave up after" in warnings[0].getMessage() + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 522dd9273b9bd4f5ef99eba7b2b851df7422d364 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 22:11:01 +0100 Subject: [PATCH 14/82] fix: space out command retries instead of hammering the link Eight retries 1.5s apart all failed. The whole burst finished inside eleven seconds, so the command was effectively tried once against an outage that had not cleared. Manual presses that did eventually work were roughly sixteen seconds apart. That points at the RPC link needing time to recover rather than simply more attempts, so the delay now grows with each try: 1.5, 3, 4.5, then 6s for the rest, spreading eight attempts across about 33 seconds. This is a long time to hold a service call. It is still shorter than pressing the button by hand until it takes, which is what the vendor app requires. Reword the failure message to say how long it tried and to place the fault where it belongs. The previous text sent the user looking at the car and the charger, neither of which is involved. Bump version to 0.1.8. Co-Authored-By: Claude Opus 5 --- custom_components/daze/api/__init__.py | 40 ++++++++++++++++++------ custom_components/daze/manifest.json | 2 +- tests/test_auth_getuser.py | 43 ++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/custom_components/daze/api/__init__.py b/custom_components/daze/api/__init__.py index a4dc419..96d2c8b 100644 --- a/custom_components/daze/api/__init__.py +++ b/custom_components/daze/api/__init__.py @@ -20,13 +20,20 @@ _LOGGER = logging.getLogger(__name__) -# The command RPC fails intermittently and needs more attempts than -# is comfortable: around six were observed before a start or stop -# took effect. Eight tries 1.5s apart gives headroom over that while -# keeping the worst case near fifteen seconds, which is tolerable -# for a command that physically switches a charger. +# The command RPC fails intermittently. Retrying it eight times 1.5s +# apart was not enough: the whole burst finished inside eleven seconds +# and every attempt failed. +# +# Manual presses that did succeed were roughly sixteen seconds apart, +# which suggests the link needs time to recover rather than simply more +# attempts. So the delay grows with each try instead of staying flat. +# +# 1.5, 3, 4.5, then 6s for the rest: eight attempts spread over about +# 33 seconds. That is a long time to hold a service call, but shorter +# than pressing a button by hand until it works. COMMAND_RETRY_ATTEMPTS = 8 COMMAND_RETRY_DELAY = 1.5 +COMMAND_RETRY_MAX_DELAY = 6.0 class ApiAuthError(Exception): @@ -80,6 +87,14 @@ def __init__(self, message: str, *, code: int | None = None) -> None: } +def _total_retry_seconds(attempts: int, base_delay: float) -> float: + """Return how long a full run of retries spends waiting.""" + return sum( + min(base_delay * n, COMMAND_RETRY_MAX_DELAY) + for n in range(1, attempts) + ) + + def _code_and_hint_from_error(err: Exception) -> tuple[int | None, str]: """Recover the upstream error code from a raised ApiError. @@ -591,14 +606,17 @@ async def _post_command( raise if attempt_number < attempts: + wait = min( + delay * attempt_number, COMMAND_RETRY_MAX_DELAY + ) _LOGGER.debug( "Transient RPC failure on attempt %d of %d, " "retrying in %.1fs", attempt_number, attempts, - delay, + wait, ) - await asyncio.sleep(delay) + await asyncio.sleep(wait) else: if attempt_number > 1: _LOGGER.info( @@ -614,13 +632,15 @@ async def _post_command( "service could not reach the wallbox. Last error: %s", url.rsplit("/", 1)[-1], attempts, - (attempts - 1) * delay, + _total_retry_seconds(attempts, delay), last_error, ) raise ApiCommandRejectedError( f"The Daze service could not reach the wallbox after " - f"{attempts} attempts. This is intermittent rather than a " - "fault; try again shortly.", + f"{attempts} attempts over " + f"{_total_retry_seconds(attempts, delay):.0f} seconds. This " + "is a fault on Daze's side rather than in the charger or the " + "car; it usually clears on its own. Try again in a minute.", code=code, ) from last_error diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index 4a15cb8..06c391d 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.7" + "version": "0.1.8" } diff --git a/tests/test_auth_getuser.py b/tests/test_auth_getuser.py index 3da0527..bd48ef2 100644 --- a/tests/test_auth_getuser.py +++ b/tests/test_auth_getuser.py @@ -61,8 +61,9 @@ def _load_integration_modules() -> tuple[Any, Any]: auth, api = _load_integration_modules() -# Keep the suite fast; the delay itself is not under test. +# Keep the suite fast; the wall-clock waits are not under test. api.COMMAND_RETRY_DELAY = 0.0 +api.COMMAND_RETRY_MAX_DELAY = 0.0 # ------------------------------------------------------------------ @@ -448,7 +449,7 @@ def test_retry_gives_up_and_says_it_is_temporary() -> None: asyncio.run(api_client.async_start_charge("SER1", 42)) except api.ApiCommandRejectedError as err: assert err.code == 101 - assert "intermittent" in str(err) + assert "could not reach the wallbox" in str(err) assert len(session.calls) == budget else: raise AssertionError("expected ApiCommandRejectedError") @@ -554,6 +555,44 @@ def test_giving_up_logs_exactly_one_warning() -> None: assert "gave up after" in warnings[0].getMessage() + +def test_retry_delay_grows_between_attempts() -> None: + """A tight burst of retries did not work; spacing them out might. + + Eight attempts 1.5s apart all failed inside eleven seconds, while + manual presses roughly sixteen seconds apart did succeed. The delay + therefore grows rather than staying flat, up to a cap. + """ + base = 1.5 + cap = 6.0 + delays = [min(base * n, cap) for n in range(1, 8)] + + assert delays == [1.5, 3.0, 4.5, 6.0, 6.0, 6.0, 6.0] + # Long enough to outlast a transient outage, short enough that a + # service call still returns. + assert 25 <= sum(delays) <= 45 + + +def test_give_up_message_states_the_duration_and_blames_the_service() -> None: + """The user should not go looking at the car or the charger.""" + budget = api.COMMAND_RETRY_ATTEMPTS + session = FakeSession( + [FakeResponse(500, RPC_FAILURE) for _ in range(budget)] + ) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + try: + asyncio.run(api_client.async_start_charge("SER1", 42)) + except api.ApiCommandRejectedError as err: + message = str(err) + assert "Daze" in message + assert "seconds" in message + assert "rather than in the charger or the car" in message + else: + raise AssertionError("expected ApiCommandRejectedError") + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From f35c38f20d520aa60f298fc3d62d9dea602d4609 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 22:12:37 +0100 Subject: [PATCH 15/82] feat: let the command finder run without prompts Testing a start or a stop meant answering six prompts. Add flags so a run is a single command: --direction start|stop which command to send --attempts N how many times to try --gap S seconds between attempts --yes skip the per-attempt confirmation The refresh token stays on the hidden prompt and is deliberately not accepted as a flag: it would end up in the shell history and in the process list. Raise the interactive default gap from 2s to 6s, matching the backoff the integration now uses. Co-Authored-By: Claude Opus 5 --- tools/try_resume.py | 82 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 12 deletions(-) diff --git a/tools/try_resume.py b/tools/try_resume.py index 24323c6..957f979 100755 --- a/tools/try_resume.py +++ b/tools/try_resume.py @@ -371,7 +371,9 @@ def verify_changed( return False, state -def retry_mode(token: str, serial: str, state: dict) -> int: +def retry_mode( + token: str, serial: str, state: dict, flags: dict | None = None +) -> int: """Send one command repeatedly until the charger state changes. The command shape is already known to be correct. What is not known @@ -382,10 +384,17 @@ def retry_mode(token: str, serial: str, state: dict) -> int: session_id = session.get("sessionId") if isinstance(session, dict) else None quoted = urllib.parse.quote(serial, safe="") - print("\nWhich direction do you want to test?") - print(" 1 start / resume (playcharge)") - print(" 2 stop (stopcharge)") - choice = input("Choice [1/2]: ").strip() + flags = flags or {} + direction = flags.get("direction") + + if direction is None: + print("\nWhich direction do you want to test?") + print(" 1 start / resume (playcharge)") + print(" 2 stop (stopcharge)") + choice = input("Choice [1/2]: ").strip() + else: + choice = "2" if direction.lower().startswith("sto") else "1" + print(f"\nDirection from flag: {direction}") if choice == "2": path = f"/sockets/{quoted}/commands/stopcharge" @@ -398,11 +407,17 @@ def retry_mode(token: str, serial: str, state: dict) -> int: if session_id is not None: body["sessionId"] = session_id - max_attempts = input("How many attempts at most? [8]: ").strip() - attempts = int(max_attempts) if max_attempts.isdigit() else 8 + if flags.get("attempts") is not None: + attempts = int(flags["attempts"]) + else: + entered = input("How many attempts at most? [8]: ").strip() + attempts = int(entered) if entered.isdigit() else 8 - gap = input("Seconds between attempts? [2]: ").strip() - gap_seconds = int(gap) if gap.isdigit() else 2 + if flags.get("gap") is not None: + gap_seconds = float(flags["gap"]) + else: + entered = input("Seconds between attempts? [6]: ").strip() + gap_seconds = float(entered) if entered.replace(".", "").isdigit() else 6 print(f"\nWill send this up to {attempts} time(s), {gap_seconds}s apart:") print(f" POST {API_BASE_URL}{path}") @@ -410,8 +425,9 @@ def retry_mode(token: str, serial: str, state: dict) -> int: print(f"\nBaseline: evseState={state.get('evseState')} " f"isPaused={state.get('isPaused')}") - if input("\nProceed? [yes/no] ").strip().lower() != "yes": - return 0 + if not flags.get("assume_yes"): + if input("\nProceed? [yes/no] ").strip().lower() != "yes": + return 0 statuses: list[str] = [] @@ -448,8 +464,47 @@ def retry_mode(token: str, serial: str, state: dict) -> int: return 1 +def parse_flags(argv: list[str]) -> dict: + """Read the optional command-line flags. + + Supported: + --direction start|stop which command to send + --attempts N how many times to try + --gap S seconds between attempts + --yes skip the per-attempt confirmation + + The refresh token is never accepted as a flag: it would land in the + shell history and the process list. + """ + flags: dict = { + "direction": None, + "attempts": None, + "gap": None, + "assume_yes": False, + } + + index = 0 + while index < len(argv): + item = argv[index] + if item == "--yes": + flags["assume_yes"] = True + elif item == "--direction" and index + 1 < len(argv): + index += 1 + flags["direction"] = argv[index] + elif item == "--attempts" and index + 1 < len(argv): + index += 1 + flags["attempts"] = int(argv[index]) + elif item == "--gap" and index + 1 < len(argv): + index += 1 + flags["gap"] = float(argv[index]) + index += 1 + + return flags + + def main() -> int: """Try each resume variant with per-attempt confirmation.""" + flags = parse_flags(sys.argv[1:]) print("Daze resume-command finder") print() print("WARNING: this sends COMMANDS to your wallbox. A successful") @@ -485,8 +540,11 @@ def main() -> int: print("\nWhat do you want to do?") print(" 1 retry one known command until it works (measures flakiness)") print(" 2 search for a working command variant") + if flags.get("direction") or flags.get("assume_yes"): + return retry_mode(token, serial, state, flags) + if input("Choice [1/2]: ").strip() != "2": - return retry_mode(token, serial, state) + return retry_mode(token, serial, state, flags) paused = bool(state.get("isPaused")) From 1513ab2cd971d572317f5116264629b45f0f1711 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 22:19:46 +0100 Subject: [PATCH 16/82] fix: read the session ID at command time, not from the cache The command tools succeed on the first attempt where the integration fails, and session ID freshness is one concrete difference between them. Session IDs change whenever one session ends and another begins. Three different IDs appeared over a single evening on the same charger. The tools read the ID from the charger immediately before sending, so it is always current. The integration read it from the coordinator, whose copy can be a full poll interval old and may name a session that has already closed. Read it fresh inside the command instead. The explicit parameter remains as an override for tests and the tools. Costs one extra GET per command press, which is not measurable next to a command that can take half a minute to land. Bump version to 0.1.9. Co-Authored-By: Claude Opus 5 --- custom_components/daze/__init__.py | 18 +------ custom_components/daze/api/__init__.py | 39 +++++++++++++- custom_components/daze/manifest.json | 2 +- custom_components/daze/switch.py | 22 ++------ tests/test_auth_getuser.py | 73 ++++++++++++++++++++++++-- 5 files changed, 114 insertions(+), 40 deletions(-) diff --git a/custom_components/daze/__init__.py b/custom_components/daze/__init__.py index ba89401..6b44d6a 100644 --- a/custom_components/daze/__init__.py +++ b/custom_components/daze/__init__.py @@ -131,22 +131,10 @@ def _async_register_services( api_client = coordinator.api_client serial_number = coordinator.serial_number - def _session_id() -> int | None: - """Return the open charge session ID, if any. - - The play and stop commands act on a session and must name - it, or the API answers 422 ErrorWrongSessionID. - """ - data = coordinator.data or {} - session_id = data.get("sessionId") - return session_id if isinstance(session_id, int) else None - async def _handle_start_charge(call: ServiceCall) -> None: """Start charging.""" try: - await api_client.async_start_charge( - serial_number, _session_id() - ) + await api_client.async_start_charge(serial_number) await coordinator.async_request_refresh() coordinator.async_schedule_settle_refresh() except ApiAuthError as err: @@ -162,9 +150,7 @@ async def _handle_start_charge(call: ServiceCall) -> None: async def _handle_stop_charge(call: ServiceCall) -> None: """Stop charging.""" try: - await api_client.async_stop_charge( - serial_number, _session_id() - ) + await api_client.async_stop_charge(serial_number) await coordinator.async_request_refresh() coordinator.async_schedule_settle_refresh() except ApiAuthError as err: diff --git a/custom_components/daze/api/__init__.py b/custom_components/daze/api/__init__.py index 96d2c8b..51a4606 100644 --- a/custom_components/daze/api/__init__.py +++ b/custom_components/daze/api/__init__.py @@ -644,6 +644,34 @@ async def _post_command( code=code, ) from last_error + async def _current_session_id(self, serial: str) -> int | None: + """Read the open session ID straight from the charger. + + The coordinator's copy can be up to one poll interval old, and + the session ID changes whenever a session ends and another + begins. Naming a stale session makes the command fail, so the + commands re-read it rather than trusting the cache. + + Args: + serial: The serial number of the wallbox. + + Returns: + The current session ID, or None if no session is open or + the read failed. + + """ + try: + data = await self.async_get_socket_remote_info(serial) + except ApiError as err: + _LOGGER.debug("Could not read the current session ID: %s", err) + return None + + session = data.get("chargeSession") + session_id = ( + session.get("sessionId") if isinstance(session, dict) else None + ) + return session_id if isinstance(session_id, int) else None + async def async_start_charge( self, serial: str, session_id: int | None = None ) -> dict[str, Any]: @@ -666,13 +694,17 @@ async def async_start_charge( Args: serial: The serial number of the wallbox. - session_id: The session to resume. Omitted when unknown, - which the API rejects with 422. + session_id: The session to resume. When omitted it is read + from the charger, which is what callers should do: a + cached ID may name a session that has since ended. Returns: The response dict. """ + if session_id is None: + session_id = await self._current_session_id(serial) + url = f"{API_BASE_URL}/sockets/{serial}/commands/playcharge" payload: dict[str, Any] = {"evseSerialNumber": serial} if session_id is not None: @@ -698,6 +730,9 @@ async def async_stop_charge( The response dict. """ + if session_id is None: + session_id = await self._current_session_id(serial) + url = f"{API_BASE_URL}/sockets/{serial}/commands/stopcharge" payload: dict[str, Any] = {"evseSerialNumber": serial} if session_id is not None: diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index 06c391d..fddd82c 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.8" + "version": "0.1.9" } diff --git a/custom_components/daze/switch.py b/custom_components/daze/switch.py index 7a9e689..463e348 100644 --- a/custom_components/daze/switch.py +++ b/custom_components/daze/switch.py @@ -63,18 +63,6 @@ def __init__( self._attr_unique_id = f"{serial_number}_charge_switch" self._attr_device_info = device_info - @property - def _session_id(self) -> int | None: - """Return the current charge session ID, if one is open. - - The play and stop commands act on a session and must name - it; without it the API answers 422 ErrorWrongSessionID. - """ - if self.coordinator.data is None: - return None - session_id = self.coordinator.data.get("sessionId") - return session_id if isinstance(session_id, int) else None - @property def is_on(self) -> bool | None: """Return True while a charge is authorised and under way. @@ -101,9 +89,9 @@ async def async_turn_on(self, **kwargs: Any) -> None: _LOGGER.info( "Starting charge on wallbox %s", self._serial_number ) - await self._api_client.async_start_charge( - self._serial_number, self._session_id - ) + # No session ID passed: the client reads a current one. + # The coordinator's copy can name a session that has ended. + await self._api_client.async_start_charge(self._serial_number) await self.coordinator.async_request_refresh() self.coordinator.async_schedule_settle_refresh() except ApiAuthError as err: @@ -150,9 +138,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: _LOGGER.info( "Stopping charge on wallbox %s", self._serial_number ) - await self._api_client.async_stop_charge( - self._serial_number, self._session_id - ) + await self._api_client.async_stop_charge(self._serial_number) await self.coordinator.async_request_refresh() self.coordinator.async_schedule_settle_refresh() except ApiAuthError as err: diff --git a/tests/test_auth_getuser.py b/tests/test_auth_getuser.py index bd48ef2..0953e57 100644 --- a/tests/test_auth_getuser.py +++ b/tests/test_auth_getuser.py @@ -387,7 +387,7 @@ def test_commands_still_send_the_serial_without_a_session() -> None: asyncio.run(api_client.async_start_charge("SER1", None)) - assert session.calls[0]["json"] == {"evseSerialNumber": "SER1"} + assert session.calls[-1]["json"] == {"evseSerialNumber": "SER1"} @@ -457,7 +457,9 @@ def test_retry_gives_up_and_says_it_is_temporary() -> None: def test_wrong_session_is_not_retried() -> None: """4121 is deterministic. Retrying it only wastes time.""" - session = FakeSession([FakeResponse(422, WRONG_SESSION)]) + session = FakeSession( + [FakeResponse(200, NO_SESSION), FakeResponse(422, WRONG_SESSION)] + ) client = auth.DazeAuthClient("tok-123", "refresh-123") api_client = api.DazeApiClient(client, session) @@ -466,7 +468,8 @@ def test_wrong_session_is_not_retried() -> None: except api.ApiCommandRejectedError as err: assert err.code == 4121 assert "no paused charging session" in str(err) - assert len(session.calls) == 1 + # One lookup plus one command: the rejection is not retried. + assert len(session.calls) == 2 else: raise AssertionError("expected ApiCommandRejectedError") @@ -593,6 +596,70 @@ def test_give_up_message_states_the_duration_and_blames_the_service() -> None: raise AssertionError("expected ApiCommandRejectedError") + +REMOTE_WITH_SESSION = { + "data": { + "evseState": 6, + "isPaused": True, + "chargeSession": {"sessionId": 1790543468000}, + } +} + + +def test_command_reads_a_fresh_session_id_when_not_given_one() -> None: + """A cached ID can name a session that has already ended. + + Session IDs change whenever one session closes and another opens. + The coordinator's copy is up to a poll interval old, so the command + re-reads it rather than trusting that. + """ + session = FakeSession( + [ + FakeResponse(200, REMOTE_WITH_SESSION), + FakeResponse(200, COMMAND_OK), + ] + ) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + asyncio.run(api_client.async_start_charge("SER1")) + + assert len(session.calls) == 2 + assert "remoteInfo" in session.calls[0]["url"] + assert session.calls[1]["json"] == { + "evseSerialNumber": "SER1", + "sessionId": 1790543468000, + } + + +def test_explicit_session_id_skips_the_extra_read() -> None: + """Passing an ID is an override, used by tests and the tools.""" + session = FakeSession([FakeResponse(200, COMMAND_OK)]) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + asyncio.run(api_client.async_start_charge("SER1", 42)) + + assert len(session.calls) == 1 + assert session.calls[0]["json"]["sessionId"] == 42 + + +def test_command_proceeds_when_the_session_read_fails() -> None: + """A failed lookup must not block the command entirely.""" + session = FakeSession( + [ + FakeResponse(500, {"message": "boom"}), + FakeResponse(200, COMMAND_OK), + ] + ) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + asyncio.run(api_client.async_stop_charge("SER1")) + + assert session.calls[-1]["json"] == {"evseSerialNumber": "SER1"} + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From fb1cf563f75d42b231d57c52dfc33a800a5122cd Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 22:20:30 +0100 Subject: [PATCH 17/82] test: fix two fixtures broken by the fresh session lookup Commands now read the session ID from the charger when they are not given one, so tests that pass None need a response queued for that read. Two were missed, and 1513ab2 was pushed with them failing. One used a literal response dict rather than the shared constant, so the edit that updated the others did not match it, and the fixture it referenced was never defined. Co-Authored-By: Claude Opus 5 --- tests/test_auth_getuser.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_auth_getuser.py b/tests/test_auth_getuser.py index 0953e57..cfa5e33 100644 --- a/tests/test_auth_getuser.py +++ b/tests/test_auth_getuser.py @@ -381,7 +381,12 @@ def test_stop_charge_sends_the_same_shape() -> None: def test_commands_still_send_the_serial_without_a_session() -> None: """An unknown session must not drop the serial from the body.""" - session = FakeSession([FakeResponse(200, {"message": "", "errors": []})]) + session = FakeSession( + [ + FakeResponse(200, NO_SESSION), + FakeResponse(200, {"message": "", "errors": []}), + ] + ) client = auth.DazeAuthClient("tok-123", "refresh-123") api_client = api.DazeApiClient(client, session) @@ -403,6 +408,10 @@ def test_commands_still_send_the_serial_without_a_session() -> None: COMMAND_OK = {"message": "", "errors": []} +# A charger with no open session: the command then sends only +# the serial, and the API is expected to reject it. +NO_SESSION = {"data": {"evseState": 1, "chargeSession": None}} + def test_retry_budget_covers_observed_failure_rate() -> None: """Around six attempts were needed in practice, so allow more.""" From 8550dcdddacddc3ca8d931bc37798606261eb76b Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 22:24:13 +0100 Subject: [PATCH 18/82] docs: name the fork's author and list what changed The Credits section described the fork's changes without saying who made them, and the list was written several fixes ago. Attribute the fork's changes to Pedro Tarrinho, both in the notice at the top of the README and in a dedicated section, and note that the upstream work remains Andrea Restello's. Rewrite the change list to cover everything the fork now carries, grouped by area: setup, reading data, charge control, and robustness and diagnostics. Each entry says what was wrong as well as what changed, since several of these were only findable by measuring the API's real responses. Also correct the EVSE status row in the sensor table, which still listed the original four states and omitted waiting_for_ev and offline. Co-Authored-By: Claude Opus 5 --- NOTICE | 12 ++++++---- README.md | 72 +++++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 65 insertions(+), 19 deletions(-) diff --git a/NOTICE b/NOTICE index 57bcfd5..d4a93e2 100644 --- a/NOTICE +++ b/NOTICE @@ -17,10 +17,14 @@ This repository A fork maintained by Pedro Tarrinho (@tarrinho) at https://github.com/tarrinho/daze-addon -It contains bug fixes found while running the integration against a -DT01 charger. It is not a redesign, and it carries no claim over the -original work. See the Credits section of README.md for the specific -changes. +The changes in this fork are the work of Pedro Tarrinho. They are bug +fixes found by running the integration against a DT01 charger and +measuring the API's actual responses: authentication, payload parsing, +charge command shape and retry behaviour, state reporting, and +diagnostics. + +It is not a redesign, and it carries no claim over the original work. +See the "Changes in this fork" section of README.md for the detail. Licence ------- diff --git a/README.md b/README.md index 17078a3..8a444c3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Home Assistant integration for **Daze WallBox EV chargers**. Monitor charging me Daze wallboxes are managed through the [Daze web portal](https://webportal.dazeservice.com). This integration bridges the gap, bringing your wallbox into Home Assistant alongside all your other smart home devices. -> **This is a fork.** The original integration was created by **Andrea Restello** ([@arest](https://github.com/arest)) at [arest/daze-addon](https://github.com/arest/daze-addon), and all of the original design and implementation is his work. This fork adds fixes found while running it against a DT01 charger — see [Credits](#credits). +> **This is a fork.** The original integration was created by **Andrea Restello** ([@arest](https://github.com/arest)) at [arest/daze-addon](https://github.com/arest/daze-addon), and all of the original design and implementation is his work. This fork, maintained by **Pedro Tarrinho** ([@tarrinho](https://github.com/tarrinho)), adds fixes found while running it against a DT01 charger — see [Changes in this fork](#changes-in-this-fork). --- @@ -87,7 +87,7 @@ If your tokens expire, the integration will automatically prompt you to re-enter | `sensor.daze_ac_voltage_l3` | AC Voltage L3 | `voltage` | `measurement` | V | | `sensor.daze_board_temperature` | Board Temperature | `temperature` | `measurement` | °C | | `sensor.daze_case_temperature` | Case Temperature | `temperature` | `measurement` | °C | -| `sensor.daze_evse_status` | EVSE Status | `enum` | — | idle / charging / paused / error | +| `sensor.daze_evse_status` | EVSE Status | `enum` | — | idle / waiting_for_ev / charging / paused / error / offline | | `sensor.daze_last_session_energy` | Last Session Energy | `energy` | `total_increasing` | Wh | | `sensor.daze_last_session_duration` | Last Session Duration | — | — | min | | `sensor.daze_last_session_cost` | Last Session Cost | `monetary` | — | EUR | @@ -247,19 +247,61 @@ written upstream. He also reverse-engineered the Daze web API, which is not publicly documented — that is the hard part, and none of what follows would exist without it. -This fork adds fixes found while running the integration against a DT01 -charger: - -- Authenticate through Cognito `GetUser` rather than `/oauth2/userInfo`, which - rejects the token scope the Daze portal issues -- Read the live metrics from where the API actually returns them, nested under - `chargeSession`, and pull temperatures and the grid limit from the EVSE record -- Send the serial and session ID with the charge commands, and retry them - through the intermittent Daze RPC link -- Report the waiting-for-vehicle state, and follow state changes after a command - -These are bug fixes to someone else's design, not a redesign. Where the -upstream project takes them, this fork becomes unnecessary. +### Changes in this fork + +Maintained by **Pedro Tarrinho** ([@tarrinho](https://github.com/tarrinho)). + +Every change below was found by running the integration against a real DT01 +wallbox and measuring the API's actual responses, rather than by reading the +code alone. + +**Setup** + +- Authenticate through the Cognito `GetUser` operation instead of + `/oauth2/userInfo`. The Daze portal issues access tokens scoped + `aws.cognito.signin.user.admin` without `openid`, which `userInfo` rejects, + so setup previously failed for every user with `invalid_token`. + +**Reading data** + +- Read the live metrics from where the API actually returns them. Power, + energy, currents and voltages arrive nested under `chargeSession`, not at the + top level, so every sensor read `Unknown` with no error logged. +- Fetch the EVSE record as well as the socket state. Temperatures, the grid + limit, eco mode and the configured current appear only there. +- Derive the charger status from the integer `evseState` plus the pause and + error flags. The API never returns the status string the code expected. +- Report `waiting_for_ev`, the state the charger passes through after a start + before the car begins drawing, and hold the charge switch on through it so it + does not appear to snap back. + +**Charge control** + +- Send the serial number and the session ID with `playcharge` and `stopcharge`. + An empty body is rejected with `ErrorWrongSessionID`, and the session ID alone + is accepted but does nothing. +- Read the session ID from the charger at command time. It changes whenever a + session ends, so a cached copy can name one that has already closed. +- Retry commands through the Daze RPC link, which fails intermittently with + HTTP 500 code 101. Delays grow from 1.5s to 6s across eight attempts, roughly + 33 seconds in total, because a tight burst of retries does not outlast the + outage. +- Re-read the charger at 3, 8, 15 and 30 seconds after a command, so a start or + pause shows up promptly instead of waiting for the next poll. + +**Robustness and diagnostics** + +- Treat HTTP 404 from the recharge-session endpoint as a durable condition. + It was retried every 30 seconds and logged a warning each time. +- Throttle the session history fetch to once every five minutes instead of + requesting up to 1000 records twice a minute. +- Log retried failures at debug and report a single warning only when a command + genuinely gives up, instead of one warning per attempt. +- Add diagnostic tools under `tools/` for reproducing each API call outside + Home Assistant, and tests that use captured API responses as fixtures. + +These are bug fixes to someone else's design, not a redesign. If the upstream +project adopts them, this fork becomes unnecessary. ### License From 9735e8bbfc8814cb5b50fae0fe1ab3abce5b9a00 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 22:32:38 +0100 Subject: [PATCH 19/82] feat: optimistic switch state and a configurable poll interval The charge switch flipped back to its old state for several seconds after being toggled. The command succeeded, but the coordinator refreshed immediately afterwards and the Daze cloud still reported the previous state, so the entity was corrected to the wrong value and only recovered on a later poll. Report the commanded value straight away instead. After a successful command the switch sets its assumed state, writes it, and schedules a single coordinator refresh ten seconds later rather than refreshing at once. Observed transitions completed in nine to twelve seconds. The guess is bounded in both directions. It is dropped as soon as the charger agrees, so real changes are not delayed, and abandoned after twenty seconds, so a command that silently failed cannot leave the UI asserting something untrue. assumed_state is reported while the guess is in force, so the frontend can show it as unconfirmed. The decision itself lives in payload.resolve_optimistic, which imports nothing from Home Assistant and is covered by tests for each case: no guess, guess versus stale reading, agreement, expiry, and an unknown reading. Separately, make the poll interval configurable through the integration options, bounded between 10 and 600 seconds. Options take precedence over the value captured at setup, and the entry already reloads when options change, so a new interval applies immediately. English and Italian labels included. The number and select platforms keep the existing settle refreshes: they have no boolean state to guess at, so there is nothing to be optimistic about. Bump version to 0.2.0. Co-Authored-By: Claude Opus 5 --- custom_components/daze/config_flow.py | 31 +++++++- custom_components/daze/const.py | 18 +++++ custom_components/daze/coordinator.py | 42 +++++++++++ custom_components/daze/manifest.json | 2 +- custom_components/daze/payload.py | 39 ++++++++++ custom_components/daze/strings.json | 11 +++ custom_components/daze/switch.py | 83 ++++++++++++++++++--- custom_components/daze/translations/it.json | 11 +++ tests/test_payload.py | 45 +++++++++++ 9 files changed, 268 insertions(+), 14 deletions(-) diff --git a/custom_components/daze/config_flow.py b/custom_components/daze/config_flow.py index e5bfc78..5071170 100644 --- a/custom_components/daze/config_flow.py +++ b/custom_components/daze/config_flow.py @@ -25,10 +25,14 @@ CONF_FIRMWARE_VERSION, CONF_NETWORK_NAME, CONF_NETWORK_UID, + CONF_POLL_INTERVAL, CONF_REFRESH_TOKEN, CONF_SERIAL_NUMBER, CONF_SOFTWARE_VERSION, + DEFAULT_POLL_INTERVAL, DOMAIN, + MAX_POLL_INTERVAL, + MIN_POLL_INTERVAL, ) _LOGGER = logging.getLogger(__name__) @@ -358,8 +362,31 @@ def __init__(self, config_entry: ConfigEntry) -> None: async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Manage the options.""" + """Let the user choose how often the charger is polled. + + Faster polling makes the entities more responsive at the cost + of more requests against the Daze cloud API. The entry reloads + on save, so the new interval takes effect immediately. + """ if user_input is not None: return self.async_create_entry(title="", data=user_input) - return self.async_show_form(step_id="init", data_schema=vol.Schema({})) + current = self._config_entry.options.get( + CONF_POLL_INTERVAL, + self._config_entry.data.get( + CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL + ), + ) + + schema = vol.Schema( + { + vol.Required( + CONF_POLL_INTERVAL, default=current + ): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_POLL_INTERVAL, max=MAX_POLL_INTERVAL), + ) + } + ) + + return self.async_show_form(step_id="init", data_schema=schema) diff --git a/custom_components/daze/const.py b/custom_components/daze/const.py index 0c9e0c5..2a4c12e 100644 --- a/custom_components/daze/const.py +++ b/custom_components/daze/const.py @@ -38,6 +38,24 @@ # Coordinator defaults DEFAULT_POLL_INTERVAL = 30 # seconds + +# Bounds for the user-configurable poll interval. The lower bound keeps +# the cloud API from being hammered; the upper bound keeps the entities +# from going obviously stale. +MIN_POLL_INTERVAL = 10 # seconds +MAX_POLL_INTERVAL = 600 # seconds + +# How long an optimistic switch state is trusted before the charger's +# own reading takes over again. Observed transitions completed in 9 to +# 12 seconds, so this both covers them and bounds how long the UI can +# disagree with reality if a command silently fails. +OPTIMISTIC_STATE_TIMEOUT = 20 # seconds + +# When to re-read the charger after a command. Late enough that the +# transition has happened, rather than immediately, which reads the old +# state back and makes the toggle appear to flip back. +POST_COMMAND_REFRESH_DELAY = 10 # seconds + DEFAULT_TOKEN_EXPIRY_BUFFER = 60 # seconds # Platform list diff --git a/custom_components/daze/coordinator.py b/custom_components/daze/coordinator.py index cee004b..ff22f19 100644 --- a/custom_components/daze/coordinator.py +++ b/custom_components/daze/coordinator.py @@ -22,10 +22,13 @@ from .const import ( CONF_ACCESS_TOKEN, CONF_NETWORK_UID, + CONF_POLL_INTERVAL, CONF_REFRESH_TOKEN, CONF_SERIAL_NUMBER, DEFAULT_POLL_INTERVAL, DOMAIN, + MAX_POLL_INTERVAL, + MIN_POLL_INTERVAL, ) from .models import RechargeSession from .payload import merge_payload @@ -143,6 +146,27 @@ def network_uid(self) -> str: """Return the network UID.""" return self._network_uid + def async_schedule_refresh_in(self, delay: int) -> None: + """Re-read the charger once, after a delay. + + Used after a command. Refreshing immediately reads the state + from before the change, because the cloud API lags the charger + by several seconds. + + Scheduled, not awaited: the caller returns immediately. + """ + + async def _refresh(_now: Any) -> None: + """Ask the coordinator to re-read the charger.""" + _LOGGER.debug( + "Post-command refresh for %s at +%ss", + self._serial_number, + delay, + ) + await self.async_request_refresh() + + async_call_later(self.hass, delay, _refresh) + def async_schedule_settle_refresh(self) -> None: """Re-read the charger a few times after a command. @@ -407,6 +431,19 @@ async def async_setup_coordinator( The initialised DazeDataUpdateCoordinator. """ + # Options win over the value captured at setup, so changing the + # interval takes effect on reload without reconfiguring. + poll_interval = entry.options.get( + CONF_POLL_INTERVAL, + entry.data.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL), + ) + try: + poll_interval = int(poll_interval) + except (TypeError, ValueError): + poll_interval = DEFAULT_POLL_INTERVAL + + poll_interval = max(MIN_POLL_INTERVAL, min(MAX_POLL_INTERVAL, poll_interval)) + access_token = entry.data[CONF_ACCESS_TOKEN] refresh_token = entry.data[CONF_REFRESH_TOKEN] serial_number = entry.data[CONF_SERIAL_NUMBER] @@ -421,6 +458,11 @@ async def async_setup_coordinator( api_client=api_client, serial_number=serial_number, network_uid=network_uid, + poll_interval=poll_interval, + ) + + _LOGGER.debug( + "Coordinator for %s polling every %ss", serial_number, poll_interval ) # Perform first refresh to populate coordinator data diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index fddd82c..f617f29 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.9" + "version": "0.2.0" } diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index 04ed30c..9804658 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -184,3 +184,42 @@ def is_charge_enabled(data: dict[str, Any]) -> bool | None: if status is None: return None return str(status).lower() in ACTIVE_STATUSES + + +def resolve_optimistic( + optimistic: bool | None, + actual: bool | None, + expired: bool, +) -> tuple[bool | None, bool]: + """Decide what a switch should report, and whether to keep guessing. + + A command takes effect at the charger several seconds after it is + accepted. Reporting the charger's reading during that window shows + the old state and makes the toggle appear to flip back, so the + commanded value is reported instead until reality catches up. + + The guess is dropped as soon as the charger agrees, and abandoned + once it has been held too long, so a command that silently failed + cannot leave the UI wrong indefinitely. + + Args: + optimistic: The value the last command asked for, or None. + actual: What the charger currently reports, or None. + expired: Whether the optimistic value has been held too long. + + Returns: + A tuple of the value to report and whether to keep holding the + optimistic value. + + """ + if optimistic is None: + return actual, False + + if expired: + return actual, False + + if actual == optimistic: + # Reality caught up; stop guessing. + return actual, False + + return optimistic, True diff --git a/custom_components/daze/strings.json b/custom_components/daze/strings.json index bd89154..5dc6f98 100644 --- a/custom_components/daze/strings.json +++ b/custom_components/daze/strings.json @@ -141,5 +141,16 @@ "name": "Operation Mode" } } + }, + "options": { + "step": { + "init": { + "title": "Daze Wallbox options", + "description": "How often to poll the Daze cloud API. Lower values make the entities more responsive but send more requests.", + "data": { + "poll_interval": "Polling interval (seconds)" + } + } + } } } diff --git a/custom_components/daze/switch.py b/custom_components/daze/switch.py index 463e348..dddf255 100644 --- a/custom_components/daze/switch.py +++ b/custom_components/daze/switch.py @@ -12,17 +12,23 @@ # @property methods by design. # pyright: reportIncompatibleVariableOverride=false import logging +import time from typing import TYPE_CHECKING, Any from homeassistant.components import persistent_notification from homeassistant.components.switch import SwitchEntity +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .api import ApiAuthError, ApiCommandRejectedError, ApiError -from .const import DOMAIN +from .const import ( + DOMAIN, + OPTIMISTIC_STATE_TIMEOUT, + POST_COMMAND_REFRESH_DELAY, +) from .coordinator import DazeDataUpdateCoordinator -from .payload import is_charge_enabled +from .payload import is_charge_enabled, resolve_optimistic if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -62,6 +68,8 @@ def __init__( self._serial_number = serial_number self._attr_unique_id = f"{serial_number}_charge_switch" self._attr_device_info = device_info + self._optimistic_state: bool | None = None + self._optimistic_since: float = 0.0 @property def is_on(self) -> bool | None: @@ -69,12 +77,67 @@ def is_on(self) -> bool | None: Includes the waiting-for-EV state. The charger passes through it after a start takes effect, before the car begins drawing. - Reporting off there would make the toggle snap back moments - after the user switched it on, even though the command worked. + + Immediately after a command, the commanded value is reported + instead of the charger's reading. The cloud API takes several + seconds to reflect a change, so reporting the reading during + that window shows the old state and makes the toggle flip back. """ - if self.coordinator.data is None: - return None - return is_charge_enabled(self.coordinator.data) + actual = ( + is_charge_enabled(self.coordinator.data) + if self.coordinator.data is not None + else None + ) + + value, keep = resolve_optimistic( + self._optimistic_state, actual, self._optimistic_expired + ) + + if not keep: + self._optimistic_state = None + + return value + + @property + def _optimistic_expired(self) -> bool: + """Whether the optimistic value has been held too long.""" + if self._optimistic_state is None: + return True + held = time.monotonic() - self._optimistic_since + return held > OPTIMISTIC_STATE_TIMEOUT + + @property + def assumed_state(self) -> bool: + """Tell the frontend when the shown state is a guess.""" + return self._optimistic_state is not None + + def _set_optimistic(self, value: bool) -> None: + """Show the commanded state now and re-read the charger later. + + Refreshing immediately is worse than not refreshing at all: the + cloud still reports the old state, so the entity would flip + back before settling. + """ + self._optimistic_state = value + self._optimistic_since = time.monotonic() + self.async_write_ha_state() + self.coordinator.async_schedule_refresh_in( + POST_COMMAND_REFRESH_DELAY + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Drop the guess once the charger agrees with it.""" + if self._optimistic_state is not None: + actual = ( + is_charge_enabled(self.coordinator.data) + if self.coordinator.data is not None + else None + ) + if actual == self._optimistic_state or self._optimistic_expired: + self._optimistic_state = None + + super()._handle_coordinator_update() async def async_turn_on(self, **kwargs: Any) -> None: """Start charging on the wallbox.""" @@ -92,8 +155,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: # No session ID passed: the client reads a current one. # The coordinator's copy can name a session that has ended. await self._api_client.async_start_charge(self._serial_number) - await self.coordinator.async_request_refresh() - self.coordinator.async_schedule_settle_refresh() + self._set_optimistic(True) except ApiAuthError as err: _LOGGER.warning( "Auth error starting charge on %s: %s", @@ -139,8 +201,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: "Stopping charge on wallbox %s", self._serial_number ) await self._api_client.async_stop_charge(self._serial_number) - await self.coordinator.async_request_refresh() - self.coordinator.async_schedule_settle_refresh() + self._set_optimistic(False) except ApiAuthError as err: _LOGGER.warning( "Auth error stopping charge on %s: %s", diff --git a/custom_components/daze/translations/it.json b/custom_components/daze/translations/it.json index 94b8364..4da3332 100644 --- a/custom_components/daze/translations/it.json +++ b/custom_components/daze/translations/it.json @@ -147,5 +147,16 @@ "name": "Modalità operativa" } } + }, + "options": { + "step": { + "init": { + "title": "Opzioni Daze Wallbox", + "description": "Ogni quanto interrogare l'API cloud di Daze. Valori bassi rendono le entità più reattive ma inviano più richieste.", + "data": { + "poll_interval": "Intervallo di aggiornamento (secondi)" + } + } + } } } diff --git a/tests/test_payload.py b/tests/test_payload.py index 799774c..a9a16d1 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -408,6 +408,51 @@ def test_waiting_state_is_a_declared_sensor_option() -> None: assert spec.value_fn(_waiting_payload()) == "waiting_for_ev" + +# ------------------------------------------------------------------ +# Optimistic switch state +# ------------------------------------------------------------------ + + +def test_no_guess_reports_the_charger() -> None: + """With nothing commanded, the charger's reading is the answer.""" + assert payload.resolve_optimistic(None, True, False) == (True, False) + assert payload.resolve_optimistic(None, False, False) == (False, False) + assert payload.resolve_optimistic(None, None, False) == (None, False) + + +def test_guess_wins_while_the_cloud_still_reports_the_old_state() -> None: + """This is the flip-back the optimistic state exists to prevent.""" + value, keep = payload.resolve_optimistic(True, False, False) + assert value is True + assert keep is True + + +def test_guess_is_dropped_once_the_charger_agrees() -> None: + """Holding it longer than needed would delay real changes.""" + value, keep = payload.resolve_optimistic(True, True, False) + assert value is True + assert keep is False + + +def test_guess_is_abandoned_when_it_expires() -> None: + """A command that silently failed must not leave the UI lying. + + Once the window passes, the charger's reading wins even though it + contradicts what was commanded. + """ + value, keep = payload.resolve_optimistic(True, False, True) + assert value is False + assert keep is False + + +def test_guess_survives_a_missing_reading() -> None: + """An unknown reading is not agreement, so keep the guess.""" + value, keep = payload.resolve_optimistic(False, None, False) + assert value is False + assert keep is True + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 03cef35343f522125bcc184084cadf59e015cc62 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 23:00:07 +0100 Subject: [PATCH 20/82] fix: bound the charging current by what the charger accepts Setting the current failed with HTTP 422: code 369, MaxExternalChargingCurrentOutOfRange The number entity advertised a fixed 6 to 32 A range, but 32 A is the installation rating, not what the charger will take. A grid power cap puts the real ceiling well below it. On the charger this was found with, a single-phase unit behind a 3000 W supplyGridMaxPower reports lastMaxInstallationCurrent 32000 and sccLimit 11739, and rejects anything above 11739: 11739 mA at 230 V is 2700 W, ninety percent of the cap, while 32000 mA would be 7360 W. Read the ceiling from the charger instead, preferring sccLimit and falling back to the installation rating, then to 32 A before the first poll. The result never drops below the 6 A industry minimum, so a nonsensical reported limit cannot make the entity unusable. Map error 369. Despite naming the RPC server it is a validation failure, so it is surfaced immediately rather than retried, and the notification now states the highest value the charger currently accepts. Route the two configuration writes through the same wrapper as the charge commands, so setting the current or the eco mode also retries the intermittent RPC failure instead of failing on first contact. Set the version to 0.1.10, continuing the 0.1.x line rather than the 0.2.0 tagged in the previous commit. Co-Authored-By: Claude Opus 5 --- custom_components/daze/api/__init__.py | 13 +++++-- custom_components/daze/manifest.json | 2 +- custom_components/daze/number.py | 32 ++++++++++++++--- custom_components/daze/payload.py | 49 ++++++++++++++++++++++++++ tests/test_auth_getuser.py | 48 +++++++++++++++++++++++++ tests/test_payload.py | 49 ++++++++++++++++++++++++++ 6 files changed, 186 insertions(+), 7 deletions(-) diff --git a/custom_components/daze/api/__init__.py b/custom_components/daze/api/__init__.py index 51a4606..cfdc7be 100644 --- a/custom_components/daze/api/__init__.py +++ b/custom_components/daze/api/__init__.py @@ -71,6 +71,7 @@ def __init__(self, message: str, *, code: int | None = None) -> None: # actually meant when observed. COMMAND_ERROR_CODE_RPC_FAILURE = 101 COMMAND_ERROR_CODE_WRONG_SESSION = 4121 +COMMAND_ERROR_CODE_CURRENT_OUT_OF_RANGE = 369 COMMAND_ERROR_HINTS: dict[int, str] = { # Sent with no session ID, or naming a session that is not paused. @@ -84,6 +85,14 @@ def __init__(self, message: str, *, code: int | None = None) -> None: COMMAND_ERROR_CODE_RPC_FAILURE: ( "the Daze service could not reach the wallbox" ), + # Despite mentioning the RPC server, this is a validation failure + # and retrying it changes nothing. The charger accepts far less + # than the installation rating when a grid power cap applies. + COMMAND_ERROR_CODE_CURRENT_OUT_OF_RANGE: ( + "the requested charging current is outside the range this " + "charger accepts, which is lower than the installation rating " + "when a grid power limit applies" + ), } @@ -518,7 +527,7 @@ async def async_set_max_charging_current( "evseSerialNumber": serial, "maxExternalChargingCurrentInMilliAmps": current_ma, } - return await self._request("POST", url, json=payload) + return await self._post_command(url, payload) async def async_set_eco_mode( self, serial: str, eco_mode_enabled: bool @@ -543,7 +552,7 @@ async def async_set_eco_mode( "evseSerialNumber": serial, "ecoModeEnabled": eco_mode_enabled, } - return await self._request("POST", url, json=payload) + return await self._post_command(url, payload) async def _post_command( self, diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index f617f29..6ff59e9 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.2.0" + "version": "0.1.10" } diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index 7f56ea9..3ea8de9 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -19,9 +19,10 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import ApiAuthError, ApiError +from .api import ApiAuthError, ApiCommandRejectedError, ApiError from .const import DOMAIN from .coordinator import DazeDataUpdateCoordinator +from .payload import max_charging_current if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -30,9 +31,11 @@ _LOGGER = logging.getLogger(__name__) -# Industry-standard range for EVSE charging current limits +# Industry minimum for EVSE charging current. The maximum is not a +# constant: it depends on the installation and on any grid power cap, +# so it is read from the charger. See max_charging_current. NATIVE_MIN_VALUE = 6000 # 6 A -NATIVE_MAX_VALUE = 32000 # 32 A +NATIVE_MAX_VALUE = 32000 # 32 A, used only until the charger reports NATIVE_STEP = 100 # 0.1 A increments @@ -44,7 +47,6 @@ class DazeWallboxNumberEntity( _attr_has_entity_name = True _attr_entity_category = EntityCategory.CONFIG _attr_native_min_value = NATIVE_MIN_VALUE - _attr_native_max_value = NATIVE_MAX_VALUE _attr_native_step = NATIVE_STEP _attr_native_unit_of_measurement = UnitOfElectricCurrent.MILLIAMPERE @@ -70,6 +72,18 @@ def __init__( self._attr_unique_id = f"{serial_number}_max_charging_current" self._attr_device_info = device_info + @property + def native_max_value(self) -> float: + """Return the highest current the charger will accept. + + Advertising the installation rating offers values the charger + rejects with MaxExternalChargingCurrentOutOfRange. A grid power + cap can put the real ceiling well below it: a single-phase unit + behind a 3000 W cap reported a 32 A installation limit but + refused anything above 11.7 A. + """ + return float(max_charging_current(self.coordinator.data)) + @property def native_value(self) -> int | None: """Return the current max charging current in mA.""" @@ -127,6 +141,16 @@ async def async_set_native_value(self, value: float) -> None: "Authentication failed when trying to set the charging " "current. Please re-authenticate the integration." ) + except ApiCommandRejectedError as err: + _LOGGER.info( + "Charger refused the current change on %s: %s", + self._serial_number, + err, + ) + self._notify_error( + f"{err} The highest value this charger currently " + f"accepts is {max_charging_current(self.coordinator.data)} mA." + ) except ApiError as err: _LOGGER.warning( "API error setting max current on %s: %s", diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index 9804658..88f6c7c 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -223,3 +223,52 @@ def resolve_optimistic( return actual, False return optimistic, True + + +# The charging current the charger will accept is not the installation +# rating. A single-phase unit behind a 3000 W grid cap reported a +# 32000 mA installation limit but rejected anything above 11739 mA with +# MaxExternalChargingCurrentOutOfRange. +# +# These fields have all been observed carrying a usable ceiling, in +# decreasing order of specificity. +CURRENT_LIMIT_FIELDS = ( + "sccLimit", + "lastMaxInstallationCurrent", +) + +# Industry minimum for EVSE charging current. +MIN_CHARGING_CURRENT_MA = 6000 + +# Fallback ceiling when the charger reports nothing usable: 32 A, the +# maximum the hardware line supports. +FALLBACK_MAX_CHARGING_CURRENT_MA = 32000 + + +def max_charging_current(data: dict[str, Any] | None) -> int: + """Return the highest charging current the charger will accept. + + Advertising the installation rating makes the slider offer values + the charger rejects, which surfaces as an opaque 422. Prefer the + limit the charger itself reports. + + Args: + data: The merged payload, or None before the first poll. + + Returns: + A ceiling in milliamps, never below the industry minimum. + + """ + if not data: + return FALLBACK_MAX_CHARGING_CURRENT_MA + + candidates = [ + value + for field in CURRENT_LIMIT_FIELDS + if isinstance(value := data.get(field), (int, float)) and value > 0 + ] + + if not candidates: + return FALLBACK_MAX_CHARGING_CURRENT_MA + + return max(int(min(candidates)), MIN_CHARGING_CURRENT_MA) diff --git a/tests/test_auth_getuser.py b/tests/test_auth_getuser.py index cfa5e33..49a37b3 100644 --- a/tests/test_auth_getuser.py +++ b/tests/test_auth_getuser.py @@ -669,6 +669,54 @@ def test_command_proceeds_when_the_session_read_fails() -> None: assert session.calls[-1]["json"] == {"evseSerialNumber": "SER1"} + +OUT_OF_RANGE = { + "message": "Invalid Data", + "errors": [ + { + "code": 369, + "message": ( + "Server error while requesting rpc server side. " + "Error MaxExternalChargingCurrentOutOfRange" + ), + } + ], +} + + +def test_out_of_range_current_is_explained_and_not_retried() -> None: + """369 mentions the RPC server but is a validation failure. + + Retrying it changes nothing, and the stock message sent the user + looking in the wrong place. + """ + session = FakeSession([FakeResponse(422, OUT_OF_RANGE)]) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + try: + asyncio.run(api_client.async_set_max_charging_current("SER1", 32000)) + except api.ApiCommandRejectedError as err: + assert err.code == 369 + assert "outside the range" in str(err) + assert len(session.calls) == 1 + else: + raise AssertionError("expected ApiCommandRejectedError") + + +def test_current_change_retries_a_transient_failure() -> None: + """Configuration writes share the command retry behaviour.""" + session = FakeSession( + [FakeResponse(500, RPC_FAILURE), FakeResponse(200, COMMAND_OK)] + ) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + asyncio.run(api_client.async_set_max_charging_current("SER1", 10000)) + + assert len(session.calls) == 2 + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tests/test_payload.py b/tests/test_payload.py index a9a16d1..96faed0 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -453,6 +453,55 @@ def test_guess_survives_a_missing_reading() -> None: assert keep is True + +# ------------------------------------------------------------------ +# Charging current ceiling +# ------------------------------------------------------------------ + + +def test_ceiling_comes_from_the_charger_not_the_installation() -> None: + """The slider must not offer values the charger rejects. + + A single-phase unit behind a 3000 W grid cap reports a 32 A + installation rating but refuses anything above 11739 mA with + MaxExternalChargingCurrentOutOfRange. + """ + data = payload.merge_payload( + REMOTE_INFO, + {**EVSE_RECORD, "sccLimit": 11739, "lastMaxInstallationCurrent": 32000}, + ) + assert payload.max_charging_current(data) == 11739 + + +def test_ceiling_falls_back_to_the_installation_rating() -> None: + """Without a reported limit, the installation rating is the best + available answer.""" + assert ( + payload.max_charging_current({"lastMaxInstallationCurrent": 32000}) + == 32000 + ) + + +def test_ceiling_has_a_default_before_the_first_poll() -> None: + """The entity is built before any data arrives.""" + assert payload.max_charging_current(None) == 32000 + assert payload.max_charging_current({}) == 32000 + + +def test_ceiling_never_drops_below_the_industry_minimum() -> None: + """A nonsensical limit must not make the entity unusable.""" + assert payload.max_charging_current({"sccLimit": 100}) == 6000 + + +def test_ceiling_ignores_non_numeric_and_zero_values() -> None: + """Zero appears in unset fields and must not win.""" + data = {"sccLimit": 0, "lastMaxInstallationCurrent": 32000} + assert payload.max_charging_current(data) == 32000 + + data = {"sccLimit": None, "lastMaxInstallationCurrent": 16000} + assert payload.max_charging_current(data) == 16000 + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 32773772b728eac5773939a48db1a7d4a053176e Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 23:03:59 +0100 Subject: [PATCH 21/82] fix: do not cap the current slider at sccLimit 0.1.10 capped the charging current at sccLimit, on the reasoning that a 3000 W grid cap put the real ceiling near 11739 mA. That was wrong and the cap was harmful. The charger is rated 1.5 to 7.4 kW single phase, which is 6.5 to 32 A, so 32 A is genuinely within its capability. And sccLimit is not an independent ceiling: on the live charger it read 11739, identical to both maxExternalChargingCurrentInMilliAmps and lastMaxChargingCurrent, which are the current setting. Capping the slider at it would have pinned the slider to wherever it already sat, making the control useless. Use lastMaxInstallationCurrent alone, which is the installation rating and bounds the hardware: 32000 for this charger, and correctly lower for a 16 A installation. Better than the hardcoded 32 A it replaced, without inventing a limit. What actually causes MaxExternalChargingCurrentOutOfRange is still unknown. The field that would explain it is not in the payload, so it cannot be applied in advance. The error mapping added in 0.1.10 stays: the failure is reported immediately rather than retried. Add tools/probe_current_range.py to measure the accepted range by walking a ladder of values and restoring the original setting afterwards, including on interrupt. Bump version to 0.1.11. Co-Authored-By: Claude Opus 5 --- custom_components/daze/manifest.json | 2 +- custom_components/daze/payload.py | 36 ++-- tests/test_payload.py | 32 ++- tools/probe_current_range.py | 304 +++++++++++++++++++++++++++ 4 files changed, 347 insertions(+), 27 deletions(-) create mode 100755 tools/probe_current_range.py diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index 6ff59e9..4ccb5cd 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.10" + "version": "0.1.11" } diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index 88f6c7c..4a87f97 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -225,32 +225,36 @@ def resolve_optimistic( return optimistic, True -# The charging current the charger will accept is not the installation -# rating. A single-phase unit behind a 3000 W grid cap reported a -# 32000 mA installation limit but rejected anything above 11739 mA with -# MaxExternalChargingCurrentOutOfRange. +# The charging current ceiling comes from the installation rating. # -# These fields have all been observed carrying a usable ceiling, in -# decreasing order of specificity. -CURRENT_LIMIT_FIELDS = ( - "sccLimit", - "lastMaxInstallationCurrent", -) +# sccLimit was tried first and is wrong: on a live charger it read +# 11739, identical to both maxExternalChargingCurrentInMilliAmps and +# lastMaxChargingCurrent, which are the current setting. Capping the +# slider at it would pin the slider to wherever it already sat. +# +# lastMaxInstallationCurrent is the rating of the installation, which +# is what bounds the hardware: a unit rated 1.5 to 7.4 kW single phase +# is 6.5 to 32 A, matching a reported 32000. +CURRENT_LIMIT_FIELDS = ("lastMaxInstallationCurrent",) # Industry minimum for EVSE charging current. MIN_CHARGING_CURRENT_MA = 6000 -# Fallback ceiling when the charger reports nothing usable: 32 A, the -# maximum the hardware line supports. +# Fallback ceiling when the charger reports nothing usable. FALLBACK_MAX_CHARGING_CURRENT_MA = 32000 def max_charging_current(data: dict[str, Any] | None) -> int: - """Return the highest charging current the charger will accept. + """Return the highest charging current the entity should offer. + + Uses the installation rating rather than a hardcoded 32 A, so a + 16 A installation is bounded correctly. - Advertising the installation rating makes the slider offer values - the charger rejects, which surfaces as an opaque 422. Prefer the - limit the charger itself reports. + This is not a promise the charger will accept the value. A grid + power cap or dynamic power management can reject a current that is + within the installation rating, which the API reports as + MaxExternalChargingCurrentOutOfRange. That limit is not exposed as + a field, so it cannot be applied here in advance. Args: data: The merged payload, or None before the first poll. diff --git a/tests/test_payload.py b/tests/test_payload.py index 96faed0..8c23de1 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -459,18 +459,27 @@ def test_guess_survives_a_missing_reading() -> None: # ------------------------------------------------------------------ -def test_ceiling_comes_from_the_charger_not_the_installation() -> None: - """The slider must not offer values the charger rejects. +def test_ceiling_ignores_scc_limit() -> None: + """sccLimit mirrors the current setting, so it is not a ceiling. - A single-phase unit behind a 3000 W grid cap reports a 32 A - installation rating but refuses anything above 11739 mA with - MaxExternalChargingCurrentOutOfRange. + On a live charger it read 11739, identical to both + maxExternalChargingCurrentInMilliAmps and lastMaxChargingCurrent. + Treating it as a ceiling pins the slider to wherever it already + sits, which is worse than offering too much. """ data = payload.merge_payload( REMOTE_INFO, {**EVSE_RECORD, "sccLimit": 11739, "lastMaxInstallationCurrent": 32000}, ) - assert payload.max_charging_current(data) == 11739 + assert payload.max_charging_current(data) == 32000 + + +def test_ceiling_follows_the_installation_rating() -> None: + """A 16 A installation must not offer 32 A.""" + data = payload.merge_payload( + REMOTE_INFO, {**EVSE_RECORD, "lastMaxInstallationCurrent": 16000} + ) + assert payload.max_charging_current(data) == 16000 def test_ceiling_falls_back_to_the_installation_rating() -> None: @@ -490,16 +499,19 @@ def test_ceiling_has_a_default_before_the_first_poll() -> None: def test_ceiling_never_drops_below_the_industry_minimum() -> None: """A nonsensical limit must not make the entity unusable.""" - assert payload.max_charging_current({"sccLimit": 100}) == 6000 + assert ( + payload.max_charging_current({"lastMaxInstallationCurrent": 100}) + == 6000 + ) def test_ceiling_ignores_non_numeric_and_zero_values() -> None: """Zero appears in unset fields and must not win.""" - data = {"sccLimit": 0, "lastMaxInstallationCurrent": 32000} + data = {"lastMaxInstallationCurrent": 0} assert payload.max_charging_current(data) == 32000 - data = {"sccLimit": None, "lastMaxInstallationCurrent": 16000} - assert payload.max_charging_current(data) == 16000 + data = {"lastMaxInstallationCurrent": None} + assert payload.max_charging_current(data) == 32000 def _main() -> int: diff --git a/tools/probe_current_range.py b/tools/probe_current_range.py new file mode 100755 index 0000000..12045ac --- /dev/null +++ b/tools/probe_current_range.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Find which charging currents the wallbox actually accepts. + +Setting the maximum charging current can fail with: + + 422 code 369, MaxExternalChargingCurrentOutOfRange + +The installation rating does not explain it. A charger rated 1.5 to +7.4 kW single phase is 6.5 to 32 A and reports +lastMaxInstallationCurrent 32000, yet still rejects some values in that +span. A grid power cap or dynamic power management is the likely +reason, and neither is exposed as a field, so the accepted range has to +be measured. + +This script walks a ladder of currents, reports which are accepted, and +restores the original setting when it finishes. + +WARNING: this WRITES configuration to your wallbox. Each step changes +the charging current limit, which will affect an active charge while +the script runs. The original value is read first and restored at the +end, including on Ctrl-C. + +Usage: + + python3 tools/probe_current_range.py # prompts + python3 tools/probe_current_range.py --yes # no confirmation +""" + +from __future__ import annotations + +import getpass +import json +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +API_BASE_URL = "https://webapi.dazeservice.com/v3" +COGNITO_BASE_URL = "https://daze.auth.eu-central-1.amazoncognito.com" +COGNITO_IDP_URL = "https://cognito-idp.eu-central-1.amazonaws.com/" +CLIENT_ID = "4m0rp7oqarbrc3hn67ivvonba8" +REDIRECT_URI = "https://webportal.dazeservice.com/authentication/callback" +GET_USER_TARGET = "AWSCognitoIdentityProviderService.GetUser" + +# 6 A to 32 A. Wide steps first: the point is to find the boundary, not +# to map every value. +LADDER = (6000, 8000, 10000, 11739, 12000, 14000, 16000, 20000, 24000, 32000) + +TIMEOUT = 30 + + +def _send(request: urllib.request.Request) -> tuple[int, object]: + """Send a request, tolerating HTTP error statuses.""" + try: + with urllib.request.urlopen(request, timeout=TIMEOUT) as response: + return response.status, _parse( + response.read().decode(errors="replace") + ) + except urllib.error.HTTPError as err: + return err.code, _parse(err.read().decode(errors="replace")) + except urllib.error.URLError as err: + return 0, {"_error": str(err.reason)} + + +def _parse(raw: str) -> object: + """Parse a JSON body, falling back to a truncated raw string.""" + if not raw.strip(): + return {"_empty": True} + try: + return json.loads(raw) + except ValueError: + return {"_raw": raw[:300]} + + +def _get(token: str, path: str) -> tuple[int, object]: + """GET an API path with the bearer token.""" + return _send( + urllib.request.Request( + f"{API_BASE_URL}{path}", + headers={"authorization": f"Bearer {token}"}, + method="GET", + ) + ) + + +def refresh_access_token(refresh_token: str) -> str: + """Exchange a refresh token for a fresh access token.""" + data = urllib.parse.urlencode( + { + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + ).encode() + + status, body = _send( + urllib.request.Request( + f"{COGNITO_BASE_URL}/oauth2/token", + data=data, + headers={ + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" + }, + method="POST", + ) + ) + + if status != 200 or not isinstance(body, dict): + print(f"Could not refresh the access token (HTTP {status}).") + raise SystemExit(2) + + token = body.get("access_token") + if not isinstance(token, str): + print("Refresh succeeded but returned no access token.") + raise SystemExit(2) + + return token + + +def get_email(token: str) -> str: + """Read the account email via Cognito GetUser.""" + status, body = _send( + urllib.request.Request( + COGNITO_IDP_URL, + data=json.dumps({"AccessToken": token}).encode(), + headers={ + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": GET_USER_TARGET, + }, + method="POST", + ) + ) + + if status != 200 or not isinstance(body, dict): + return "" + + for attribute in body.get("UserAttributes", []): + if isinstance(attribute, dict) and attribute.get("Name") == "email": + return str(attribute.get("Value", "")) + + return "" + + +def discover(token: str, email: str) -> tuple[str, dict]: + """Return the first charger's serial and its EVSE record.""" + if not email: + return "", {} + + mail = urllib.parse.quote(email, safe="") + status, body = _get(token, f"/users/{mail}/networks?includeStats=true") + networks = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(networks, list) or not networks: + return "", {} + + uid = urllib.parse.quote(str(networks[0].get("uid", "")), safe="") + status, body = _get(token, f"/networks/{uid}/evses?includeEcoInfo=true") + evses = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(evses, list) or not evses: + return "", {} + + record = evses[0] if isinstance(evses[0], dict) else {} + return str(record.get("serialNumber", "")), record + + +def set_current(token: str, serial: str, milliamps: int) -> tuple[int, object]: + """Attempt to set the maximum charging current.""" + quoted = urllib.parse.quote(serial, safe="") + payload = { + "evseSerialNumber": serial, + "maxExternalChargingCurrentInMilliAmps": milliamps, + } + + return _send( + urllib.request.Request( + f"{API_BASE_URL}/evses/{quoted}" + "/configurations/maxExternalChargingCurrent", + data=json.dumps(payload).encode(), + headers={ + "authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + method="POST", + ) + ) + + +def describe(status: int, body: object) -> str: + """Summarise a response in one line.""" + if isinstance(body, dict): + errors = body.get("errors") + if isinstance(errors, list) and errors: + first = errors[0] + if isinstance(first, dict): + return ( + f"HTTP {status} code {first.get('code')}: " + f"{str(first.get('message', ''))[:90]}" + ) + if body.get("_error"): + return f"network error: {body['_error']}" + return f"HTTP {status}" + + +def main() -> int: + """Walk the ladder and report the accepted range.""" + assume_yes = "--yes" in sys.argv[1:] + + print("Daze charging current range probe") + print() + print("WARNING: this WRITES configuration to your wallbox. Each step") + print("changes the charging current limit and will affect an active") + print("charge. The original value is restored at the end.") + print() + + refresh_token = getpass.getpass("Refresh token (input hidden): ").strip() + if not refresh_token: + print("A refresh token is required.") + return 3 + + token = refresh_access_token(refresh_token) + email = get_email(token) + serial, record = discover(token, email) + + if not serial: + serial = input("Wallbox serial (discovery failed): ").strip() + if not serial: + print("A serial number is required.") + return 3 + + original = record.get("maxExternalChargingCurrentInMilliAmps") + print(f"\nCharger : {serial}") + print(f"Current : {original} mA") + for field in ( + "lastMaxInstallationCurrent", + "sccLimit", + "supplyGridMaxPower", + "evseIsThreePhase", + "dpm", + "isDynamicLoadManagementOn", + ): + print(f" {field:28s} = {record.get(field)}") + + if not isinstance(original, int): + print("\nCould not read the current setting, so it cannot be") + print("restored afterwards. Refusing to change anything.") + return 1 + + print(f"\nWill try {len(LADDER)} values: " + f"{', '.join(str(v) for v in LADDER)} mA") + print(f"Then restore {original} mA.") + + if not assume_yes: + if input("\nProceed? [yes/no] ").strip().lower() != "yes": + return 0 + + accepted: list[int] = [] + rejected: list[tuple[int, str]] = [] + + try: + for value in LADDER: + status, body = set_current(token, serial, value) + line = describe(status, body) + verdict = "OK " if 200 <= status < 300 else " " + print(f" {verdict}{value:>6} mA {line}") + + if 200 <= status < 300: + accepted.append(value) + else: + rejected.append((value, line)) + + time.sleep(2) + finally: + print(f"\nRestoring {original} mA...") + status, body = set_current(token, serial, original) + print(f" {describe(status, body)}") + + print("\n" + "=" * 60) + if accepted: + print(f"Accepted: {min(accepted)} to {max(accepted)} mA") + print(f" values: {', '.join(str(v) for v in accepted)}") + else: + print("Nothing was accepted.") + + if rejected: + print("\nRejected:") + for value, line in rejected: + print(f" {value:>6} mA {line}") + + if accepted and rejected: + boundary = max(accepted) + print(f"\nThe boundary sits just above {boundary} mA.") + print("If that is well below the installation rating, a grid") + print("power cap or dynamic power management is imposing it.") + + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nAborted. The original value may not have been restored;") + print("check the charger and set it back if needed.") + sys.exit(3) From d278e3b17933088358770be85a893c13d58b97c4 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 23:06:46 +0100 Subject: [PATCH 22/82] fix: make the current-range probe survive a network stall A read timeout on the second ladder step crashed the script. That is the worst place for it: the loop writes configuration to the charger, so a crash can leave it on a probe value rather than the user's own. The restore did run, via the finally block, but only by luck of where the exception landed. Retry a stalled request twice before giving up, and treat a timeout as a result rather than an exception. Correct the ladder. It started at 6000 mA on the assumption that 6 A is the floor, and 6000 was rejected as out of range. The charger's setting at the time was 6521 mA, which is exactly 1500 W at 230 V and matches its 1.5 kW rating, so the limits look power based rather than current based. The ladder now brackets that floor and reaches the 7.4 kW rating at the top. Report the implied wattage beside each result, using the charger's own voltage reading, so a power-based limit is visible directly. Accept --values to override the ladder. Co-Authored-By: Claude Opus 5 --- tools/probe_current_range.py | 96 +++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 24 deletions(-) diff --git a/tools/probe_current_range.py b/tools/probe_current_range.py index 12045ac..74869d5 100755 --- a/tools/probe_current_range.py +++ b/tools/probe_current_range.py @@ -43,24 +43,54 @@ REDIRECT_URI = "https://webportal.dazeservice.com/authentication/callback" GET_USER_TARGET = "AWSCognitoIdentityProviderService.GetUser" -# 6 A to 32 A. Wide steps first: the point is to find the boundary, not -# to map every value. -LADDER = (6000, 8000, 10000, 11739, 12000, 14000, 16000, 20000, 24000, 32000) +# The floor is not 6 A. A charger rated 1.5 kW minimum at 230 V will +# not accept less than about 6520 mA, and 6000 was rejected as out of +# range. The ladder therefore starts just below the observed floor to +# confirm where it sits, and reaches the 7.4 kW rating at the top. +LADDER = ( + 6000, # expected to fail: below a 1.5 kW floor + 6400, + 6521, # 1500 W at 230 V + 7000, + 8000, + 10000, + 13000, + 16000, + 20000, + 26000, + 32000, # 7360 W at 230 V, near the 7.4 kW rating +) TIMEOUT = 30 -def _send(request: urllib.request.Request) -> tuple[int, object]: - """Send a request, tolerating HTTP error statuses.""" - try: - with urllib.request.urlopen(request, timeout=TIMEOUT) as response: - return response.status, _parse( - response.read().decode(errors="replace") - ) - except urllib.error.HTTPError as err: - return err.code, _parse(err.read().decode(errors="replace")) - except urllib.error.URLError as err: - return 0, {"_error": str(err.reason)} +def _send( + request: urllib.request.Request, attempts: int = 3 +) -> tuple[int, object]: + """Send a request, tolerating HTTP errors and network timeouts. + + The Daze API stalls occasionally. A timeout crashed the first + version of this script mid-ladder, which is the one place a crash + is expensive: the original setting may not have been restored yet. + """ + last: tuple[int, object] = (0, {"_error": "not attempted"}) + + for attempt in range(1, attempts + 1): + try: + with urllib.request.urlopen(request, timeout=TIMEOUT) as response: + return response.status, _parse( + response.read().decode(errors="replace") + ) + except urllib.error.HTTPError as err: + return err.code, _parse(err.read().decode(errors="replace")) + except (urllib.error.URLError, TimeoutError, OSError) as err: + reason = getattr(err, "reason", err) + last = (0, {"_error": str(reason)}) + if attempt < attempts: + print(f" network problem ({reason}), retrying") + time.sleep(3) + + return last def _parse(raw: str) -> object: @@ -203,7 +233,13 @@ def describe(status: int, body: object) -> str: def main() -> int: """Walk the ladder and report the accepted range.""" - assume_yes = "--yes" in sys.argv[1:] + argv = sys.argv[1:] + assume_yes = "--yes" in argv + + ladder = LADDER + if "--values" in argv: + raw = argv[argv.index("--values") + 1] + ladder = tuple(int(v) for v in raw.split(",") if v.strip()) print("Daze charging current range probe") print() @@ -245,23 +281,33 @@ def main() -> int: print("restored afterwards. Refusing to change anything.") return 1 - print(f"\nWill try {len(LADDER)} values: " - f"{', '.join(str(v) for v in LADDER)} mA") + print(f"\nWill try {len(ladder)} values: " + f"{', '.join(str(v) for v in ladder)} mA") print(f"Then restore {original} mA.") if not assume_yes: if input("\nProceed? [yes/no] ").strip().lower() != "yes": return 0 + # Used only to annotate the output with the implied power. + voltage = 230 + session = record.get("sockets") + if isinstance(session, list) and session and isinstance(session[0], dict): + reading = session[0].get("lastACVoltageL1") + if isinstance(reading, (int, float)) and reading > 100: + voltage = int(reading) + print(f"\nUsing {voltage} V to show implied power.") + accepted: list[int] = [] rejected: list[tuple[int, str]] = [] try: - for value in LADDER: + for value in ladder: status, body = set_current(token, serial, value) line = describe(status, body) verdict = "OK " if 200 <= status < 300 else " " - print(f" {verdict}{value:>6} mA {line}") + watts = round(value * voltage / 1000) + print(f" {verdict}{value:>6} mA ({watts:>5} W) {line}") if 200 <= status < 300: accepted.append(value) @@ -286,11 +332,13 @@ def main() -> int: for value, line in rejected: print(f" {value:>6} mA {line}") - if accepted and rejected: - boundary = max(accepted) - print(f"\nThe boundary sits just above {boundary} mA.") - print("If that is well below the installation rating, a grid") - print("power cap or dynamic power management is imposing it.") + if accepted: + low, high = min(accepted), max(accepted) + print(f"\nImplied power range: {round(low * voltage / 1000)} W " + f"to {round(high * voltage / 1000)} W at {voltage} V.") + print("If those land near the charger's kW rating, the limits") + print("are power based and the entity should bound itself the") + print("same way rather than by current.") return 0 From eab8efe4fe0ea17393fa77ee8b77d6d01f7e2b40 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 23:07:10 +0100 Subject: [PATCH 23/82] style: collapse a nested conditional in the range probe Co-Authored-By: Claude Opus 5 --- tools/probe_current_range.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/probe_current_range.py b/tools/probe_current_range.py index 74869d5..9dde32a 100755 --- a/tools/probe_current_range.py +++ b/tools/probe_current_range.py @@ -285,9 +285,10 @@ def main() -> int: f"{', '.join(str(v) for v in ladder)} mA") print(f"Then restore {original} mA.") - if not assume_yes: - if input("\nProceed? [yes/no] ").strip().lower() != "yes": - return 0 + if not assume_yes and ( + input("\nProceed? [yes/no] ").strip().lower() != "yes" + ): + return 0 # Used only to annotate the output with the implied power. voltage = 230 From bde6200d3fa1b7eb40294433c834377462c11561 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 23:10:11 +0100 Subject: [PATCH 24/82] fix: derive the current floor from the charger's power minimum The bottom of the charging current slider always failed with MaxExternalChargingCurrentOutOfRange. The entity offered 6 A, the EVSE industry minimum, but the charger enforces a minimum power instead. Measured by walking a ladder against the charger at 232 V: 6000 mA = 1392 W rejected 6400 mA = 1485 W rejected 6521 mA = 1513 W accepted 32000 mA = 7424 W accepted The boundary is 1500 W, matching the unit's 1.5 kW rating, so the minimum current depends on the supply voltage and cannot be a constant. At 232 V it is 6466 mA, which is why 6 A was always short. Compute the floor from the power minimum and the measured voltage on L1, rounded up to a selectable 0.1 A step, and never below 6 A since no EVSE charges under that. Three-phase arithmetic yields a current below 6 A, so the 6 A floor applies there instead. The ceiling needed no change: 32000 mA was accepted at 7424 W, within the unit's 7.4 kW rating and equal to the reported installation rating. sccLimit is confirmed as a dead end. Across three reads it was 11739, 6000 and 6521 while the setting was 11739, 6521 and 6521: it lags the setting rather than bounding it. Tests reproduce the measured boundary directly, asserting that every rejected value falls below the computed floor and every accepted one does not. Bump version to 0.1.12. Co-Authored-By: Claude Opus 5 --- custom_components/daze/manifest.json | 2 +- custom_components/daze/number.py | 27 +++++++--- custom_components/daze/payload.py | 78 ++++++++++++++++++++++++++-- tests/test_payload.py | 62 ++++++++++++++++++++++ 4 files changed, 157 insertions(+), 12 deletions(-) diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index 4ccb5cd..e3ef339 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.11" + "version": "0.1.12" } diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index 3ea8de9..af23db5 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -22,7 +22,7 @@ from .api import ApiAuthError, ApiCommandRejectedError, ApiError from .const import DOMAIN from .coordinator import DazeDataUpdateCoordinator -from .payload import max_charging_current +from .payload import max_charging_current, min_charging_current if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -31,10 +31,10 @@ _LOGGER = logging.getLogger(__name__) -# Industry minimum for EVSE charging current. The maximum is not a -# constant: it depends on the installation and on any grid power cap, -# so it is read from the charger. See max_charging_current. -NATIVE_MIN_VALUE = 6000 # 6 A +# Neither bound is a constant. The minimum follows the charger's +# power floor and the supply voltage, and the maximum follows the +# installation rating. See min_charging_current and +# max_charging_current. NATIVE_MAX_VALUE = 32000 # 32 A, used only until the charger reports NATIVE_STEP = 100 # 0.1 A increments @@ -46,7 +46,6 @@ class DazeWallboxNumberEntity( _attr_has_entity_name = True _attr_entity_category = EntityCategory.CONFIG - _attr_native_min_value = NATIVE_MIN_VALUE _attr_native_step = NATIVE_STEP _attr_native_unit_of_measurement = UnitOfElectricCurrent.MILLIAMPERE @@ -72,6 +71,17 @@ def __init__( self._attr_unique_id = f"{serial_number}_max_charging_current" self._attr_device_info = device_info + @property + def native_min_value(self) -> float: + """Return the lowest current the charger will accept. + + The charger enforces a minimum power rather than a minimum + current, so this moves with the supply voltage. Offering the + 6 A industry minimum made the bottom of the slider fail with + MaxExternalChargingCurrentOutOfRange on a 1.5 kW floor. + """ + return float(min_charging_current(self.coordinator.data)) + @property def native_max_value(self) -> float: """Return the highest current the charger will accept. @@ -148,8 +158,9 @@ async def async_set_native_value(self, value: float) -> None: err, ) self._notify_error( - f"{err} The highest value this charger currently " - f"accepts is {max_charging_current(self.coordinator.data)} mA." + f"{err} This charger currently accepts " + f"{min_charging_current(self.coordinator.data)} to " + f"{max_charging_current(self.coordinator.data)} mA." ) except ApiError as err: _LOGGER.warning( diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index 4a87f97..643c226 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -21,6 +21,7 @@ from __future__ import annotations +import math from typing import Any # EVSE state values confirmed against live hardware: @@ -237,8 +238,30 @@ def resolve_optimistic( # is 6.5 to 32 A, matching a reported 32000. CURRENT_LIMIT_FIELDS = ("lastMaxInstallationCurrent",) -# Industry minimum for EVSE charging current. -MIN_CHARGING_CURRENT_MA = 6000 +# The charger's floor is a power figure, not a current. +# +# Measured on a 1.5 to 7.4 kW single-phase unit at 232 V: +# +# 6000 mA = 1392 W rejected +# 6400 mA = 1485 W rejected +# 6521 mA = 1513 W accepted +# 32000 mA = 7424 W accepted +# +# The boundary sits at 1500 W, so the minimum current depends on the +# supply voltage and cannot be a constant. Offering the 6 A industry +# minimum made the bottom of the slider always fail with +# MaxExternalChargingCurrentOutOfRange. +MIN_CHARGING_POWER_W = 1500 + +# No EVSE charges below 6 A regardless of what the arithmetic says. +ABSOLUTE_MIN_CHARGING_CURRENT_MA = 6000 + +# The entity steps in 0.1 A, so the computed floor is rounded up to a +# step the user can actually select. +CURRENT_STEP_MA = 100 + +# Used when the charger reports no usable voltage reading. +NOMINAL_VOLTAGE = 230 # Fallback ceiling when the charger reports nothing usable. FALLBACK_MAX_CHARGING_CURRENT_MA = 32000 @@ -275,4 +298,53 @@ def max_charging_current(data: dict[str, Any] | None) -> int: if not candidates: return FALLBACK_MAX_CHARGING_CURRENT_MA - return max(int(min(candidates)), MIN_CHARGING_CURRENT_MA) + return max(int(min(candidates)), ABSOLUTE_MIN_CHARGING_CURRENT_MA) + + +def supply_voltage(data: dict[str, Any] | None) -> int: + """Return the measured supply voltage, or the nominal value. + + Only L1 is consulted: on a single-phase charger the other two read + near zero, which would drag an average down to nonsense. + + Args: + data: The merged payload, or None. + + Returns: + A voltage in volts. + + """ + if not data: + return NOMINAL_VOLTAGE + + reading = data.get("lastACVoltageL1") + if isinstance(reading, (int, float)) and reading > 100: + return int(reading) + + return NOMINAL_VOLTAGE + + +def min_charging_current(data: dict[str, Any] | None) -> int: + """Return the lowest charging current the charger will accept. + + The charger enforces a minimum power, not a minimum current, so + the answer moves with the supply voltage. The result is rounded up + to a selectable step, and never falls below the 6 A floor that + applies to any EVSE. + + Args: + data: The merged payload, or None before the first poll. + + Returns: + A current in milliamps. + + """ + volts = supply_voltage(data) + phases = 3 if (data or {}).get("evseIsThreePhase") else 1 + + required_ma = MIN_CHARGING_POWER_W / (volts * phases) * 1000 + + # Round up: rounding down would land back under the power floor. + stepped = math.ceil(required_ma / CURRENT_STEP_MA) * CURRENT_STEP_MA + + return max(ABSOLUTE_MIN_CHARGING_CURRENT_MA, int(stepped)) diff --git a/tests/test_payload.py b/tests/test_payload.py index 8c23de1..6d9976e 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -514,6 +514,68 @@ def test_ceiling_ignores_non_numeric_and_zero_values() -> None: assert payload.max_charging_current(data) == 32000 + +def test_minimum_follows_the_power_floor_not_six_amps() -> None: + """Measured: 6400 mA was rejected, 6521 mA accepted, at 232 V. + + The charger enforces 1500 W, so the minimum current depends on the + supply voltage. Offering a flat 6 A made the bottom of the slider + fail every time. + """ + data = payload.merge_payload( + REMOTE_INFO, + {**EVSE_RECORD, "sockets": [{"lastACVoltageL1": 232}]}, + ) + + floor = payload.min_charging_current(data) + + # Above the rejected 6400, at or below the accepted 6521. + assert 6400 < floor <= 6521 + # And genuinely over the power floor. + assert floor * 232 / 1000 >= 1500 + + +def test_minimum_rises_as_voltage_falls() -> None: + """Same power, less voltage, more current.""" + low = payload.min_charging_current({"lastACVoltageL1": 220}) + high = payload.min_charging_current({"lastACVoltageL1": 245}) + assert low > high + + +def test_minimum_is_selectable_on_the_slider() -> None: + """A floor between steps would be unreachable in the UI.""" + for volts in (220, 230, 232, 240, 250): + floor = payload.min_charging_current({"lastACVoltageL1": volts}) + assert floor % payload.CURRENT_STEP_MA == 0 + + +def test_minimum_never_below_the_evse_floor() -> None: + """Three phase arithmetic gives a tiny current; 6 A still applies.""" + data = {"lastACVoltageL1": 232, "evseIsThreePhase": True} + assert payload.min_charging_current(data) == 6000 + + +def test_voltage_falls_back_when_unreported() -> None: + """A single-phase charger reads near zero on L2 and L3.""" + assert payload.supply_voltage({}) == 230 + assert payload.supply_voltage({"lastACVoltageL1": 7}) == 230 + assert payload.supply_voltage({"lastACVoltageL1": 232}) == 232 + + +def test_measured_boundary_is_reproduced() -> None: + """Guard the whole rule against the captured measurement.""" + data = {"lastACVoltageL1": 232} + floor = payload.min_charging_current(data) + + rejected = (6000, 6400) + accepted = (6521, 8000, 32000) + + for value in rejected: + assert value < floor, value + for value in accepted: + assert value >= floor, value + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 6d51f06a3bb357cd686168e4c67929457179276e Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Sun, 27 Sep 2026 23:20:45 +0100 Subject: [PATCH 25/82] test: add QA checks for the charging current bounds Two kinds of check, for two kinds of failure. tests/test_qa_invariants.py sweeps supply voltages from 207 to 253 V and installation ratings from 10 to 32 A, asserting the properties that must hold for any charger rather than the one that was measured: the floor never exceeds the ceiling, every offered value clears the 1500 W power minimum, rounding up costs at most one step, both bounds land on selectable steps, the floor falls as voltage rises, three phase falls back to the 6 A EVSE minimum, and degenerate payloads still produce usable bounds. It also asserts that every reachable status is a declared sensor option and that the switch never reports on while the status says otherwise. tools/qa_verify_current.py checks the thing no offline test can: that the charger's draw actually follows the limit. HTTP 200 has already proved misleading twice in this integration, once for playcharge and once for the current change, so this lowers the limit below the present draw, watches the measured current for up to 90 seconds, then raises it and watches again, before restoring the original. It requires an active charge, since a limit change cannot be observed against a zero draw, and it says plainly that lowering is the meaningful direction because a car at its own ceiling will ignore extra headroom. tests/run_all.py runs the three standalone suites and reports a combined total, so a check before deploying is one command. No integration behaviour changes. Co-Authored-By: Claude Opus 5 --- tests/run_all.py | 84 ++++++++ tests/test_qa_invariants.py | 229 +++++++++++++++++++++ tests/test_session.py | 1 - tools/qa_verify_current.py | 393 ++++++++++++++++++++++++++++++++++++ tools/try_resume.py | 2 +- 5 files changed, 707 insertions(+), 2 deletions(-) create mode 100755 tests/run_all.py create mode 100644 tests/test_qa_invariants.py create mode 100755 tools/qa_verify_current.py diff --git a/tests/run_all.py b/tests/run_all.py new file mode 100755 index 0000000..2bd92d6 --- /dev/null +++ b/tests/run_all.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Run every standalone test module and report a combined result. + +The suites are written to run without pytest so they can be executed +on a machine that has nothing installed beyond Python and aiohttp, +which is the situation when checking a fix against a charger. + +Usage: + + python3 tests/run_all.py +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +TESTS_DIR = Path(__file__).resolve().parent + +# test_control, test_sensor and test_session re-implement the logic +# they check rather than importing it, and are driven by pytest only. +STANDALONE = ( + "test_auth_getuser.py", + "test_payload.py", + "test_qa_invariants.py", +) + + +def main() -> int: + """Run each suite in turn and summarise.""" + total_passed = 0 + total_failed = 0 + failed_modules: list[str] = [] + + for name in STANDALONE: + path = TESTS_DIR / name + if not path.exists(): + print(f"SKIP {name}: not found") + continue + + result = subprocess.run( + [sys.executable, str(path)], + capture_output=True, + text=True, + check=False, # a failing suite is a result, not an error + ) + + summary = "" + for line in reversed(result.stdout.splitlines()): + if "passed," in line: + summary = line.strip() + break + + status = "ok " if result.returncode == 0 else "FAIL" + print(f"{status} {name:28s} {summary}") + + if result.returncode != 0: + failed_modules.append(name) + for line in result.stdout.splitlines(): + if line.startswith("FAIL"): + print(f" {line}") + if result.stderr.strip(): + print(f" stderr: {result.stderr.strip()[:300]}") + + # "28 passed, 0 failed" - strip the comma before matching. + parts = summary.replace(",", "").split() + if len(parts) >= 4 and parts[1] == "passed" and parts[3] == "failed": + total_passed += int(parts[0]) + total_failed += int(parts[2]) + + print() + print(f"{total_passed} passed, {total_failed} failed " + f"across {len(STANDALONE)} module(s)") + + if failed_modules: + print(f"failing modules: {', '.join(failed_modules)}") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_qa_invariants.py b/tests/test_qa_invariants.py new file mode 100644 index 0000000..54dc12d --- /dev/null +++ b/tests/test_qa_invariants.py @@ -0,0 +1,229 @@ +"""Invariants that must hold for any charger, not just the one tested. + +The bounds on the charging current are computed rather than fixed, so +they can be wrong in ways a single captured payload will not reveal: a +different supply voltage, a smaller installation, or a three-phase +unit. These tests sweep those inputs and assert the properties that +have to hold in every case. + +They complement test_payload.py, which pins behaviour against captured +responses from one specific charger. + +Run with pytest, or standalone: + + python3 tests/test_qa_invariants.py +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE_DIR = ROOT / "custom_components" / "daze" + + +def _load(name: str, filename: str) -> Any: + """Load a single integration module without Home Assistant.""" + spec = importlib.util.spec_from_file_location(name, PACKAGE_DIR / filename) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +payload = _load("daze_payload_qa", "payload.py") + +# Realistic supply voltages, from a sagging rural feed to a strong one. +VOLTAGES = (207, 220, 228, 230, 232, 240, 245, 253) + +# Installation ratings seen on domestic wallboxes. +INSTALLATIONS = (10000, 13000, 16000, 20000, 25000, 32000) + + +def _payload(volts: int, installation: int, three_phase: bool = False) -> dict: + """Build a minimal payload with the fields the bounds depend on.""" + return { + "lastACVoltageL1": volts, + "lastMaxInstallationCurrent": installation, + "evseIsThreePhase": three_phase, + } + + +# ------------------------------------------------------------------ +# Bound invariants +# ------------------------------------------------------------------ + + +def test_minimum_never_exceeds_maximum() -> None: + """A slider whose floor is above its ceiling cannot be rendered.""" + for volts in VOLTAGES: + for installation in INSTALLATIONS: + data = _payload(volts, installation) + low = payload.min_charging_current(data) + high = payload.max_charging_current(data) + assert low <= high, (volts, installation, low, high) + + +def test_every_offered_value_clears_the_power_floor() -> None: + """The slider must not offer a value the charger will reject. + + This is the bug that started this: 6 A was offered and rejected + because it fell under the charger's 1500 W minimum. + """ + for volts in VOLTAGES: + data = _payload(volts, 32000) + low = payload.min_charging_current(data) + watts = low * volts / 1000 + assert watts >= payload.MIN_CHARGING_POWER_W, (volts, low, watts) + + +def test_minimum_is_not_needlessly_high() -> None: + """Rounding up must cost at most one step. + + A floor set too high silently removes usable charging rates. + """ + step = payload.CURRENT_STEP_MA + for volts in VOLTAGES: + data = _payload(volts, 32000) + low = payload.min_charging_current(data) + exact = payload.MIN_CHARGING_POWER_W / volts * 1000 + if low > payload.ABSOLUTE_MIN_CHARGING_CURRENT_MA: + assert low - exact < step, (volts, low, exact) + + +def test_bounds_land_on_selectable_steps() -> None: + """A bound between steps is unreachable in the frontend.""" + step = payload.CURRENT_STEP_MA + for volts in VOLTAGES: + for installation in INSTALLATIONS: + data = _payload(volts, installation) + assert payload.min_charging_current(data) % step == 0 + assert payload.max_charging_current(data) % step == 0 + + +def test_maximum_never_exceeds_the_installation_rating() -> None: + """Offering more than the installation allows invites a rejection.""" + for installation in INSTALLATIONS: + data = _payload(230, installation) + assert payload.max_charging_current(data) <= max( + installation, payload.ABSOLUTE_MIN_CHARGING_CURRENT_MA + ) + + +def test_minimum_moves_monotonically_with_voltage() -> None: + """Higher voltage needs less current for the same power.""" + floors = [ + payload.min_charging_current(_payload(volts, 32000)) + for volts in sorted(VOLTAGES) + ] + assert floors == sorted(floors, reverse=True), floors + + +def test_three_phase_falls_back_to_the_evse_floor() -> None: + """Three-phase arithmetic gives a current below any EVSE minimum.""" + for volts in VOLTAGES: + data = _payload(volts, 32000, three_phase=True) + assert ( + payload.min_charging_current(data) + == payload.ABSOLUTE_MIN_CHARGING_CURRENT_MA + ) + + +# ------------------------------------------------------------------ +# Robustness against missing or nonsense data +# ------------------------------------------------------------------ + + +def test_bounds_survive_every_degenerate_payload() -> None: + """Bounds are read before the first poll and from partial data.""" + degenerate: tuple[dict | None, ...] = ( + None, + {}, + {"lastACVoltageL1": None}, + {"lastACVoltageL1": 0}, + {"lastACVoltageL1": "230"}, + {"lastMaxInstallationCurrent": None}, + {"lastMaxInstallationCurrent": 0}, + {"lastMaxInstallationCurrent": -5}, + {"lastACVoltageL1": 7, "lastMaxInstallationCurrent": 32000}, + ) + + for data in degenerate: + low = payload.min_charging_current(data) + high = payload.max_charging_current(data) + assert low >= payload.ABSOLUTE_MIN_CHARGING_CURRENT_MA, data + assert high >= low, data + + +# ------------------------------------------------------------------ +# Status invariants +# ------------------------------------------------------------------ + + +def test_status_is_always_a_declared_option() -> None: + """An enum sensor rejects a value outside its options.""" + catalog = _load("daze_catalog_qa", "sensor_catalog.py") + spec = next( + s for s in catalog.EVSE_SENSOR_CATALOG if s.key == "evse_status" + ) + assert spec.options is not None + allowed = set(spec.options) + + for state in range(12): + for paused in (True, False): + for error in (0, 4): + for active in (True, False): + data = payload.merge_payload( + { + "evseState": state, + "isPaused": paused, + "evseSystemError": error, + "active": active, + }, + None, + ) + status = data.get("evseStatus") + assert status in allowed, (state, paused, error, active) + + +def test_switch_state_agrees_with_the_status() -> None: + """The switch must never claim on while the status says paused.""" + for state in range(12): + data = payload.merge_payload({"evseState": state}, None) + status = data.get("evseStatus") + enabled = payload.is_charge_enabled(data) + + if status in ("paused", "idle", "offline", "error"): + assert enabled is False, (state, status) + else: + assert enabled is True, (state, status) + + +def _main() -> int: + """Run every test in this module and report results.""" + tests = [ + value + for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value) + ] + + failures = 0 + for test in tests: + try: + test() + except Exception as err: # noqa: BLE001 - standalone runner + failures += 1 + print(f"FAIL {test.__name__}: {type(err).__name__}: {err}") + else: + print(f"ok {test.__name__}") + + print(f"\n{len(tests) - failures} passed, {failures} failed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/tests/test_session.py b/tests/test_session.py index 1e4e94b..77ce56e 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -11,7 +11,6 @@ from datetime import datetime, timezone from typing import Any - # ------------------------------------------------------------------ # Pure logic from custom_components/daze/models.py # ------------------------------------------------------------------ diff --git a/tools/qa_verify_current.py b/tools/qa_verify_current.py new file mode 100755 index 0000000..fb9b311 --- /dev/null +++ b/tools/qa_verify_current.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""Check whether the charger's draw actually follows the current limit. + +Setting the maximum charging current returns HTTP 200 for every value +in the accepted range. That only means the request was accepted. This +script measures whether the charger then changes what it draws. + +Method: lower the limit well below the present draw, watch the measured +current, then raise it again. Lowering is the reliable direction. A car +draws up to the limit but no more than it wants, so raising the limit +proves nothing if the car is already at its own ceiling, whereas +lowering it must reduce the draw if the limit is being honoured. + +WARNING: this WRITES configuration to your wallbox and will change how +fast your car charges while it runs. The original limit is read first +and restored at the end, including on Ctrl-C. + +Run it while the car is actually charging, or there is nothing to +measure. + +Usage: + + python3 tools/qa_verify_current.py + python3 tools/qa_verify_current.py --yes --low 6500 --high 16000 +""" + +from __future__ import annotations + +import getpass +import json +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +API_BASE_URL = "https://webapi.dazeservice.com/v3" +COGNITO_BASE_URL = "https://daze.auth.eu-central-1.amazoncognito.com" +COGNITO_IDP_URL = "https://cognito-idp.eu-central-1.amazonaws.com/" +CLIENT_ID = "4m0rp7oqarbrc3hn67ivvonba8" +REDIRECT_URI = "https://webportal.dazeservice.com/authentication/callback" +GET_USER_TARGET = "AWSCognitoIdentityProviderService.GetUser" + +TIMEOUT = 30 + +# How long to watch after a change, and how often to sample. Charge +# current ramps rather than stepping, so this needs to be generous. +WATCH_SECONDS = 90 +SAMPLE_SECONDS = 5 + +# A change counts as honoured if the measured current ends up within +# this tolerance of the requested limit, or below it. +TOLERANCE_MA = 1500 + + +def _send( + request: urllib.request.Request, attempts: int = 3 +) -> tuple[int, object]: + """Send a request, tolerating HTTP errors and network stalls.""" + last: tuple[int, object] = (0, {"_error": "not attempted"}) + + for attempt in range(1, attempts + 1): + try: + with urllib.request.urlopen(request, timeout=TIMEOUT) as response: + return response.status, _parse( + response.read().decode(errors="replace") + ) + except urllib.error.HTTPError as err: + return err.code, _parse(err.read().decode(errors="replace")) + except (urllib.error.URLError, TimeoutError, OSError) as err: + last = (0, {"_error": str(getattr(err, "reason", err))}) + if attempt < attempts: + time.sleep(3) + + return last + + +def _parse(raw: str) -> object: + """Parse a JSON body, falling back to a truncated raw string.""" + if not raw.strip(): + return {"_empty": True} + try: + return json.loads(raw) + except ValueError: + return {"_raw": raw[:300]} + + +def _get(token: str, path: str) -> tuple[int, object]: + """GET an API path with the bearer token.""" + return _send( + urllib.request.Request( + f"{API_BASE_URL}{path}", + headers={"authorization": f"Bearer {token}"}, + method="GET", + ) + ) + + +def refresh_access_token(refresh_token: str) -> str: + """Exchange a refresh token for a fresh access token.""" + data = urllib.parse.urlencode( + { + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + ).encode() + + status, body = _send( + urllib.request.Request( + f"{COGNITO_BASE_URL}/oauth2/token", + data=data, + headers={ + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" + }, + method="POST", + ) + ) + + if status != 200 or not isinstance(body, dict): + print(f"Could not refresh the access token (HTTP {status}).") + raise SystemExit(2) + + token = body.get("access_token") + if not isinstance(token, str): + print("Refresh succeeded but returned no access token.") + raise SystemExit(2) + + return token + + +def get_email(token: str) -> str: + """Read the account email via Cognito GetUser.""" + status, body = _send( + urllib.request.Request( + COGNITO_IDP_URL, + data=json.dumps({"AccessToken": token}).encode(), + headers={ + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": GET_USER_TARGET, + }, + method="POST", + ) + ) + + if status != 200 or not isinstance(body, dict): + return "" + + for attribute in body.get("UserAttributes", []): + if isinstance(attribute, dict) and attribute.get("Name") == "email": + return str(attribute.get("Value", "")) + + return "" + + +def discover(token: str, email: str) -> tuple[str, dict]: + """Return the first charger's serial and its EVSE record.""" + if not email: + return "", {} + + mail = urllib.parse.quote(email, safe="") + status, body = _get(token, f"/users/{mail}/networks?includeStats=true") + networks = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(networks, list) or not networks: + return "", {} + + uid = urllib.parse.quote(str(networks[0].get("uid", "")), safe="") + status, body = _get(token, f"/networks/{uid}/evses?includeEcoInfo=true") + evses = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(evses, list) or not evses: + return "", {} + + record = evses[0] if isinstance(evses[0], dict) else {} + return str(record.get("serialNumber", "")), record + + +def read_live(token: str, serial: str) -> dict: + """Return the live session figures.""" + quoted = urllib.parse.quote(serial, safe="") + status, body = _get( + token, + f"/sockets/{quoted}/remoteInfo" + "?includeEcoInfo=true&includeNextSchedule=true", + ) + if status != 200 or not isinstance(body, dict): + return {} + + data = body.get("data") + if not isinstance(data, dict): + return {} + + session = data.get("chargeSession") + session = session if isinstance(session, dict) else {} + + return { + "evseState": data.get("evseState"), + "isPaused": data.get("isPaused"), + "power": session.get("instantPowerAsWatt"), + "current": session.get("lastChargingCurrentInstantL1"), + "limit": session.get("lastMaxChargingCurrent"), + "voltage": session.get("lastACVoltageL1"), + } + + +def set_limit(token: str, serial: str, milliamps: int) -> tuple[int, object]: + """Set the maximum charging current.""" + quoted = urllib.parse.quote(serial, safe="") + payload = { + "evseSerialNumber": serial, + "maxExternalChargingCurrentInMilliAmps": milliamps, + } + + return _send( + urllib.request.Request( + f"{API_BASE_URL}/evses/{quoted}" + "/configurations/maxExternalChargingCurrent", + data=json.dumps(payload).encode(), + headers={ + "authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + method="POST", + ) + ) + + +def watch(token: str, serial: str, target: int) -> dict: + """Sample the charger while it reacts to a new limit.""" + samples: list[dict] = [] + deadline = time.monotonic() + WATCH_SECONDS + + while time.monotonic() < deadline: + time.sleep(SAMPLE_SECONDS) + live = read_live(token, serial) + samples.append(live) + + elapsed = int(WATCH_SECONDS - (deadline - time.monotonic())) + print( + f" +{elapsed:>3}s limit={live.get('limit')}" + f" current={live.get('current')}" + f" power={live.get('power')} W" + ) + + # Stop early once the reported limit matches and the draw has + # settled at or under it. + current = live.get("current") + limit = live.get("limit") + settled = ( + limit == target + and isinstance(current, (int, float)) + and current <= target + TOLERANCE_MA + ) + if settled: + print(" settled") + break + + return samples[-1] if samples else {} + + +def main() -> int: + """Lower the limit, verify the draw follows, then restore.""" + argv = sys.argv[1:] + assume_yes = "--yes" in argv + + def flag(name: str, default: int) -> int: + if name in argv: + return int(argv[argv.index(name) + 1]) + return default + + low = flag("--low", 6500) + high = flag("--high", 16000) + + print("Daze charging current QA check") + print() + print("WARNING: this WRITES configuration to your wallbox and will") + print("change how fast your car charges while it runs. The original") + print("limit is restored at the end.") + print() + + refresh_token = getpass.getpass("Refresh token (input hidden): ").strip() + if not refresh_token: + print("A refresh token is required.") + return 3 + + token = refresh_access_token(refresh_token) + email = get_email(token) + serial, record = discover(token, email) + + if not serial: + serial = input("Wallbox serial (discovery failed): ").strip() + if not serial: + print("A serial number is required.") + return 3 + + original = record.get("maxExternalChargingCurrentInMilliAmps") + baseline = read_live(token, serial) + + print(f"\nCharger : {serial}") + print(f"Limit : {original} mA") + print(f"State : evseState={baseline.get('evseState')} " + f"isPaused={baseline.get('isPaused')}") + print(f"Draw : {baseline.get('current')} mA, " + f"{baseline.get('power')} W at {baseline.get('voltage')} V") + + if not isinstance(original, int): + print("\nCould not read the current limit, so it cannot be") + print("restored. Refusing to change anything.") + return 1 + + power = baseline.get("power") + if not isinstance(power, (int, float)) or power <= 0: + print("\nThe car is not drawing any power right now, so there is") + print("nothing to measure: a limit change cannot be seen when the") + print("draw is already zero. Start a charge and run this again.") + return 1 + + print(f"\nPlan: set {low} mA, watch, set {high} mA, watch, " + f"restore {original} mA.") + print(f"Watching up to {WATCH_SECONDS}s after each change.") + + if not assume_yes and ( + input("\nProceed? [yes/no] ").strip().lower() != "yes" + ): + return 0 + + results: dict[int, dict] = {} + + try: + for target in (low, high): + print(f"\n Setting {target} mA") + status, body = set_limit(token, serial, target) + print(f" -> HTTP {status}") + + if not 200 <= status < 300: + print(f" rejected: {body}") + continue + + results[target] = watch(token, serial, target) + finally: + print(f"\nRestoring {original} mA...") + status, _ = set_limit(token, serial, original) + print(f" HTTP {status}") + + print("\n" + "=" * 60) + + if not results: + print("No limit change was accepted, so nothing was measured.") + return 1 + + honoured = True + for target, final in results.items(): + current = final.get("current") + limit = final.get("limit") + print(f"\nRequested {target} mA") + print(f" charger reported limit : {limit}") + print(f" measured draw : {current} mA, " + f"{final.get('power')} W") + + if limit != target: + print(" PROBLEM: the charger did not adopt the limit.") + honoured = False + elif isinstance(current, (int, float)) and current > target + TOLERANCE_MA: + print(" PROBLEM: the draw exceeds the limit it accepted.") + honoured = False + else: + print(" OK: limit adopted and draw is within it.") + + print() + if honoured: + print("The charger honours the limit. If the power still looks") + print("wrong in Home Assistant, the problem is the integration") + print("sending or displaying it, not the charger.") + else: + print("The charger accepted the request but did not act on it.") + print("That is a charger or service problem, not an integration") + print("one: the same calls are being made here directly.") + + print() + print("Note: raising a limit only increases the draw if the car asks") + print("for more. A car at its own ceiling will ignore the headroom,") + print("which is why the lowering step is the meaningful one.") + + return 0 if honoured else 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nAborted. The limit may not have been restored; check the") + print("charger and set it back if needed.") + sys.exit(3) diff --git a/tools/try_resume.py b/tools/try_resume.py index 957f979..d17f3d3 100755 --- a/tools/try_resume.py +++ b/tools/try_resume.py @@ -27,8 +27,8 @@ import getpass import json -import time import sys +import time import urllib.error import urllib.parse import urllib.request From e995bc3323b36847f1cc0d526428a48381067355 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Mon, 28 Sep 2026 10:05:55 +0100 Subject: [PATCH 26/82] fix: retry unreachable commands in the background Setting the charging current failed twice in a row with the same message: the Daze service could not reach the wallbox after 8 attempts over 33 seconds. Holding the call open longer is not the answer. The outage lasts minutes, and 33 seconds of blocking is already more than a user should wait to move a slider. Split the two cases the API conflates. A refusal is final and is still reported at once: a current outside the accepted range, or a session that cannot be resumed. An unreachable RPC link is not final, and the same command usually lands a minute or two later. Commands now make three quick attempts inline, about four seconds, and on an unreachable link hand off to the coordinator, which retries at 15, 30, 60, 120 and 240 seconds. That covers roughly seven and a half minutes without the user waiting on any of it. Success refreshes the entities; only exhausting every attempt raises a notification. A second request for the same control supersedes the first, so nudging a slider repeatedly does not stack up retries. Applied to the charge switch, the current limit and the operation mode. The switch also sets its optimistic state while the retries run, so the toggle reflects the intent rather than snapping back. Expose the attempt budget on the API command methods so the caller chooses between the short inline path and the long default. Bump version to 0.1.13. Co-Authored-By: Claude Opus 5 --- custom_components/daze/api/__init__.py | 28 +++++-- custom_components/daze/const.py | 14 ++++ custom_components/daze/coordinator.py | 103 +++++++++++++++++++++++++ custom_components/daze/manifest.json | 2 +- custom_components/daze/number.py | 30 ++++++- custom_components/daze/select.py | 33 +++++++- custom_components/daze/switch.py | 60 ++++++++++++-- tests/test_auth_getuser.py | 61 +++++++++++++++ 8 files changed, 309 insertions(+), 22 deletions(-) diff --git a/custom_components/daze/api/__init__.py b/custom_components/daze/api/__init__.py index cfdc7be..9019160 100644 --- a/custom_components/daze/api/__init__.py +++ b/custom_components/daze/api/__init__.py @@ -505,7 +505,10 @@ async def async_get_socket_remote_info( return data.get("data", {}) async def async_set_max_charging_current( - self, serial: str, current_ma: int + self, + serial: str, + current_ma: int, + attempts: int = COMMAND_RETRY_ATTEMPTS, ) -> dict[str, Any]: """Set the maximum charging current for a wallbox. @@ -527,10 +530,13 @@ async def async_set_max_charging_current( "evseSerialNumber": serial, "maxExternalChargingCurrentInMilliAmps": current_ma, } - return await self._post_command(url, payload) + return await self._post_command(url, payload, attempts=attempts) async def async_set_eco_mode( - self, serial: str, eco_mode_enabled: bool + self, + serial: str, + eco_mode_enabled: bool, + attempts: int = COMMAND_RETRY_ATTEMPTS, ) -> dict[str, Any]: """Enable or disable eco mode on a wallbox. @@ -552,7 +558,7 @@ async def async_set_eco_mode( "evseSerialNumber": serial, "ecoModeEnabled": eco_mode_enabled, } - return await self._post_command(url, payload) + return await self._post_command(url, payload, attempts=attempts) async def _post_command( self, @@ -682,7 +688,10 @@ async def _current_session_id(self, serial: str) -> int | None: return session_id if isinstance(session_id, int) else None async def async_start_charge( - self, serial: str, session_id: int | None = None + self, + serial: str, + session_id: int | None = None, + attempts: int = COMMAND_RETRY_ATTEMPTS, ) -> dict[str, Any]: """Resume charging on a wallbox. @@ -718,10 +727,13 @@ async def async_start_charge( payload: dict[str, Any] = {"evseSerialNumber": serial} if session_id is not None: payload["sessionId"] = session_id - return await self._post_command(url, payload) + return await self._post_command(url, payload, attempts=attempts) async def async_stop_charge( - self, serial: str, session_id: int | None = None + self, + serial: str, + session_id: int | None = None, + attempts: int = COMMAND_RETRY_ATTEMPTS, ) -> dict[str, Any]: """Suspend charging on a wallbox. @@ -746,7 +758,7 @@ async def async_stop_charge( payload: dict[str, Any] = {"evseSerialNumber": serial} if session_id is not None: payload["sessionId"] = session_id - return await self._post_command(url, payload) + return await self._post_command(url, payload, attempts=attempts) async def async_get_recharge_sessions( self, network_uid: str, limit: int = 1000 diff --git a/custom_components/daze/const.py b/custom_components/daze/const.py index 2a4c12e..afc48ed 100644 --- a/custom_components/daze/const.py +++ b/custom_components/daze/const.py @@ -56,6 +56,20 @@ # state back and makes the toggle appear to flip back. POST_COMMAND_REFRESH_DELAY = 10 # seconds +# A command that the Daze RPC link refuses is retried in the +# background rather than held open. Blocking eight attempts across 33 +# seconds still failed, and holding a service call longer than that is +# not reasonable. +# +# These offsets spread further attempts over roughly seven and a half +# minutes, which covers an outage of the length observed without the +# user waiting on any of it. +BACKGROUND_RETRY_DELAYS = (15, 30, 60, 120, 240) + +# Attempts made while the user waits, before handing off to the +# background. Kept short: a healthy link answers on the first try. +INLINE_COMMAND_ATTEMPTS = 3 + DEFAULT_TOKEN_EXPIRY_BUFFER = 60 # seconds # Platform list diff --git a/custom_components/daze/coordinator.py b/custom_components/daze/coordinator.py index ff22f19..247ea1d 100644 --- a/custom_components/daze/coordinator.py +++ b/custom_components/daze/coordinator.py @@ -4,6 +4,7 @@ import logging import time +from collections.abc import Awaitable, Callable from datetime import datetime, timedelta, timezone from typing import Any @@ -20,6 +21,7 @@ from .api import ApiAuthError, ApiError, ApiNotFoundError, DazeApiClient from .api.auth import DazeAuthClient from .const import ( + BACKGROUND_RETRY_DELAYS, CONF_ACCESS_TOKEN, CONF_NETWORK_UID, CONF_POLL_INTERVAL, @@ -103,6 +105,7 @@ def __init__( self._next_evse_fetch: float = 0.0 self._next_session_fetch: float = 0.0 self._sessions_missing_logged: bool = False + self._pending_retries: dict[str, Callable[[], None]] = {} super().__init__( hass, @@ -146,6 +149,106 @@ def network_uid(self) -> str: """Return the network UID.""" return self._network_uid + def async_retry_in_background( + self, + key: str, + action: Callable[[], Awaitable[Any]], + description: str, + on_failure: Callable[[str], None] | None = None, + ) -> None: + """Keep retrying a command after the user has stopped waiting. + + The Daze RPC link refuses commands for minutes at a time. Held + open, that means a service call that blocks and then fails. + Retried in the background, the command usually lands and the + user never sees a failure at all. + + A second request for the same key replaces the first, so + repeatedly nudging a control does not stack up retries. + + Args: + key: Identifies the command, so a newer one supersedes it. + action: Awaitable performing the command. Raising means + the attempt failed. + description: Used in log messages and the failure notice. + on_failure: Called with a message when every attempt fails. + + """ + self.async_cancel_background_retry(key) + + attempts = list(BACKGROUND_RETRY_DELAYS) + state = {"index": 0, "cancelled": False} + + async def _attempt(_now: Any) -> None: + """Run one background attempt and schedule the next.""" + if state["cancelled"]: + return + + index = state["index"] + try: + await action() + except Exception as err: # noqa: BLE001 - reported below + state["index"] = index + 1 + + if state["index"] < len(attempts): + delay = attempts[state["index"]] + _LOGGER.debug( + "Background retry %d/%d for %s failed (%s), " + "next in %ss", + index + 1, + len(attempts), + description, + err, + delay, + ) + _schedule(delay) + return + + self._pending_retries.pop(key, None) + _LOGGER.warning( + "%s never succeeded after %d background attempts: %s", + description, + len(attempts), + err, + ) + if on_failure is not None: + on_failure( + f"{description} could not be delivered to the " + f"charger. The Daze service was unreachable for " + f"several minutes." + ) + return + + self._pending_retries.pop(key, None) + _LOGGER.info( + "%s succeeded on background attempt %d", description, index + 1 + ) + await self.async_request_refresh() + + def _schedule(delay: int) -> None: + """Queue the next attempt and remember how to cancel it.""" + cancel = async_call_later(self.hass, delay, _attempt) + + def _cancel() -> None: + state["cancelled"] = True + cancel() + + self._pending_retries[key] = _cancel + + _LOGGER.info( + "%s did not reach the charger; retrying in the background " + "over the next %d seconds", + description, + sum(attempts), + ) + _schedule(attempts[0]) + + def async_cancel_background_retry(self, key: str) -> None: + """Drop any pending background retry for a command.""" + cancel = self._pending_retries.pop(key, None) + if cancel is not None: + cancel() + def async_schedule_refresh_in(self, delay: int) -> None: """Re-read the charger once, after a delay. diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index e3ef339..51837ae 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.12" + "version": "0.1.13" } diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index af23db5..8940d85 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -19,8 +19,13 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import ApiAuthError, ApiCommandRejectedError, ApiError -from .const import DOMAIN +from .api import ( + COMMAND_ERROR_CODE_RPC_FAILURE, + ApiAuthError, + ApiCommandRejectedError, + ApiError, +) +from .const import DOMAIN, INLINE_COMMAND_ATTEMPTS from .coordinator import DazeDataUpdateCoordinator from .payload import max_charging_current, min_charging_current @@ -137,7 +142,9 @@ async def async_set_native_value(self, value: float) -> None: int_value, ) await self._api_client.async_set_max_charging_current( - self._serial_number, int_value + self._serial_number, + int_value, + attempts=INLINE_COMMAND_ATTEMPTS, ) await self.coordinator.async_request_refresh() self.coordinator.async_schedule_settle_refresh() @@ -152,6 +159,23 @@ async def async_set_native_value(self, value: float) -> None: "current. Please re-authenticate the integration." ) except ApiCommandRejectedError as err: + if err.code == COMMAND_ERROR_CODE_RPC_FAILURE: + # Unreachable rather than refused. Keep trying without + # making the user wait or telling them it failed. + self.coordinator.async_retry_in_background( + key=f"{self._serial_number}:current", + action=lambda: self._api_client. + async_set_max_charging_current( + self._serial_number, + int_value, + attempts=INLINE_COMMAND_ATTEMPTS, + ), + description=f"Setting the charging current to " + f"{int_value} mA", + on_failure=self._notify_error, + ) + return + _LOGGER.info( "Charger refused the current change on %s: %s", self._serial_number, diff --git a/custom_components/daze/select.py b/custom_components/daze/select.py index f15ab26..faa073e 100644 --- a/custom_components/daze/select.py +++ b/custom_components/daze/select.py @@ -19,8 +19,13 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import ApiAuthError, ApiError -from .const import DOMAIN +from .api import ( + COMMAND_ERROR_CODE_RPC_FAILURE, + ApiAuthError, + ApiCommandRejectedError, + ApiError, +) +from .const import DOMAIN, INLINE_COMMAND_ATTEMPTS from .coordinator import DazeDataUpdateCoordinator if TYPE_CHECKING: @@ -142,7 +147,9 @@ async def async_select_option(self, option: str) -> None: eco_value, ) await self._api_client.async_set_eco_mode( - self._serial_number, eco_value + self._serial_number, + eco_value, + attempts=INLINE_COMMAND_ATTEMPTS, ) await self.coordinator.async_request_refresh() self.coordinator.async_schedule_settle_refresh() @@ -156,6 +163,26 @@ async def async_select_option(self, option: str) -> None: "Authentication failed when trying to change the " "operation mode. Please re-authenticate the integration." ) + except ApiCommandRejectedError as err: + if err.code == COMMAND_ERROR_CODE_RPC_FAILURE: + self.coordinator.async_retry_in_background( + key=f"{self._serial_number}:mode", + action=lambda: self._api_client.async_set_eco_mode( + self._serial_number, + eco_value, + attempts=INLINE_COMMAND_ATTEMPTS, + ), + description=f"Setting the operation mode to {option}", + on_failure=self._notify_error, + ) + return + + _LOGGER.info( + "Charger refused the mode change on %s: %s", + self._serial_number, + err, + ) + self._notify_error(str(err)) except ApiError as err: _LOGGER.warning( "API error setting operation mode on %s: %s", diff --git a/custom_components/daze/switch.py b/custom_components/daze/switch.py index dddf255..302caf5 100644 --- a/custom_components/daze/switch.py +++ b/custom_components/daze/switch.py @@ -21,9 +21,15 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import ApiAuthError, ApiCommandRejectedError, ApiError +from .api import ( + COMMAND_ERROR_CODE_RPC_FAILURE, + ApiAuthError, + ApiCommandRejectedError, + ApiError, +) from .const import ( DOMAIN, + INLINE_COMMAND_ATTEMPTS, OPTIMISTIC_STATE_TIMEOUT, POST_COMMAND_REFRESH_DELAY, ) @@ -154,7 +160,9 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) # No session ID passed: the client reads a current one. # The coordinator's copy can name a session that has ended. - await self._api_client.async_start_charge(self._serial_number) + await self._api_client.async_start_charge( + self._serial_number, attempts=INLINE_COMMAND_ATTEMPTS + ) self._set_optimistic(True) except ApiAuthError as err: _LOGGER.warning( @@ -167,8 +175,8 @@ async def async_turn_on(self, **kwargs: Any) -> None: "Please re-authenticate the integration." ) except ApiCommandRejectedError as err: - # The charger explained why; relay that rather - # than the stock 'check the car is connected'. + if self._retry_in_background(err, True): + return _LOGGER.info( "Charger refused the command on %s: %s", self._serial_number, @@ -200,7 +208,9 @@ async def async_turn_off(self, **kwargs: Any) -> None: _LOGGER.info( "Stopping charge on wallbox %s", self._serial_number ) - await self._api_client.async_stop_charge(self._serial_number) + await self._api_client.async_stop_charge( + self._serial_number, attempts=INLINE_COMMAND_ATTEMPTS + ) self._set_optimistic(False) except ApiAuthError as err: _LOGGER.warning( @@ -213,8 +223,8 @@ async def async_turn_off(self, **kwargs: Any) -> None: "Please re-authenticate the integration." ) except ApiCommandRejectedError as err: - # The charger explained why; relay that rather - # than the stock 'check the car is connected'. + if self._retry_in_background(err, False): + return _LOGGER.info( "Charger refused the command on %s: %s", self._serial_number, @@ -232,6 +242,42 @@ async def async_turn_off(self, **kwargs: Any) -> None: f"Error: {err}" ) + def _retry_in_background( + self, err: ApiCommandRejectedError, turn_on: bool + ) -> bool: + """Queue a retry when the charger was unreachable. + + A refusal is final and should be shown. An unreachable RPC + link is not: the same command usually lands a minute later, + so it is retried without troubling the user. + + Returns: + True if the command was handed to the background. + + """ + if err.code != COMMAND_ERROR_CODE_RPC_FAILURE: + return False + + verb = "Starting" if turn_on else "Stopping" + command = ( + self._api_client.async_start_charge + if turn_on + else self._api_client.async_stop_charge + ) + + self.coordinator.async_retry_in_background( + key=f"{self._serial_number}:charge", + action=lambda: command( + self._serial_number, attempts=INLINE_COMMAND_ATTEMPTS + ), + description=f"{verb} the charge", + on_failure=self._notify_error, + ) + + # Show the intent while the retries run. + self._set_optimistic(turn_on) + return True + def _notify_error(self, message: str) -> None: """Show a persistent notification in the HA frontend.""" persistent_notification.async_create( diff --git a/tests/test_auth_getuser.py b/tests/test_auth_getuser.py index 49a37b3..0dc0d64 100644 --- a/tests/test_auth_getuser.py +++ b/tests/test_auth_getuser.py @@ -717,6 +717,67 @@ def test_current_change_retries_a_transient_failure() -> None: assert len(session.calls) == 2 + +def test_inline_budget_is_short_enough_to_hand_off() -> None: + """The user must not wait 33s before the background takes over. + + Blocking through the full retry budget was tried and still failed; + the point of the handoff is that nobody waits for it. + """ + import importlib.util as _iu + spec = _iu.spec_from_file_location( + "daze_const_qa", PACKAGE_DIR / "const.py" + ) + assert spec and spec.loader + const = _iu.module_from_spec(spec) + spec.loader.exec_module(const) + + inline_wait = sum( + min(api.COMMAND_RETRY_DELAY * n, api.COMMAND_RETRY_MAX_DELAY) + for n in range(1, const.INLINE_COMMAND_ATTEMPTS) + ) + assert inline_wait <= 10, inline_wait + + # And the background must cover far longer than the inline path. + assert sum(const.BACKGROUND_RETRY_DELAYS) > 300 + + +def test_command_attempts_are_caller_controlled() -> None: + """Entities shorten the inline budget; the default stays long.""" + session = FakeSession( + [ + FakeResponse(200, NO_SESSION), + FakeResponse(500, RPC_FAILURE), + FakeResponse(500, RPC_FAILURE), + ] + ) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + try: + asyncio.run(api_client.async_start_charge("SER1", attempts=2)) + except api.ApiCommandRejectedError: + pass + + # One session lookup plus exactly two command attempts. + assert len(session.calls) == 3 + + +def test_set_current_accepts_an_attempt_budget() -> None: + """The number entity passes a short budget for the same reason.""" + session = FakeSession( + [FakeResponse(500, RPC_FAILURE), FakeResponse(200, COMMAND_OK)] + ) + client = auth.DazeAuthClient("tok-123", "refresh-123") + api_client = api.DazeApiClient(client, session) + + asyncio.run( + api_client.async_set_max_charging_current("SER1", 8000, attempts=3) + ) + + assert len(session.calls) == 2 + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From acb4e5a1968af5906df714bf5447e8294a54ce6d Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Mon, 28 Sep 2026 13:40:28 +0100 Subject: [PATCH 27/82] fix: show a changed current or mode straight away Dragging the charging current slider appeared to do nothing: the value snapped back to where it started. Optimistic state was added to the charge switch in 0.2.0 but never to the current limit or the operation mode, so both still read from the last poll. That is wrong in two ways. On success the charger takes several seconds to adopt the new figure, so the next read returns the old one. And since 0.1.13 an unreachable charger hands off to a background retry and returns without an error, which left the entity reverting silently while the retry was still running. Show the requested value immediately for both controls, and keep showing it until the charger reports it, using the same rule as the switch. While a background retry is outstanding the request is genuinely in flight, so the display is held rather than expiring after twenty seconds. If every retry fails, the pending value is dropped and the reason is shown. Generalise resolve_optimistic, which was typed for a boolean. A milliamp figure and a mode string lag in exactly the same way. Tests cover all three value kinds, including zero, which is falsy but is a real reading rather than an absence. Bump version to 0.1.14. Co-Authored-By: Claude Opus 5 --- custom_components/daze/manifest.json | 2 +- custom_components/daze/number.py | 102 ++++++++++++++++++++++----- custom_components/daze/payload.py | 9 ++- custom_components/daze/select.py | 78 ++++++++++++++++++-- tests/test_payload.py | 42 +++++++++++ 5 files changed, 208 insertions(+), 25 deletions(-) diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index 51837ae..a62af0a 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.13" + "version": "0.1.14" } diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index 8940d85..a9fc3a4 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -11,11 +11,13 @@ # @property methods by design. # pyright: reportIncompatibleVariableOverride=false import logging +import time from typing import TYPE_CHECKING, Any from homeassistant.components import persistent_notification from homeassistant.components.number import NumberEntity from homeassistant.const import EntityCategory, UnitOfElectricCurrent +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,9 +27,18 @@ ApiCommandRejectedError, ApiError, ) -from .const import DOMAIN, INLINE_COMMAND_ATTEMPTS +from .const import ( + DOMAIN, + INLINE_COMMAND_ATTEMPTS, + OPTIMISTIC_STATE_TIMEOUT, + POST_COMMAND_REFRESH_DELAY, +) from .coordinator import DazeDataUpdateCoordinator -from .payload import max_charging_current, min_charging_current +from .payload import ( + max_charging_current, + min_charging_current, + resolve_optimistic, +) if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -75,6 +86,9 @@ def __init__( self._serial_number = serial_number self._attr_unique_id = f"{serial_number}_max_charging_current" self._attr_device_info = device_info + self._optimistic_value: int | None = None + self._optimistic_since: float = 0.0 + self._awaiting_retry: bool = False @property def native_min_value(self) -> float: @@ -101,23 +115,79 @@ def native_max_value(self) -> float: @property def native_value(self) -> int | None: - """Return the current max charging current in mA.""" - if self.coordinator.data is None: - return None + """Return the charging current limit in mA. - # Primary field, then fallback - value = self.coordinator.data.get( - "maxExternalChargingCurrentInMilliAmps" + Shows the requested value while a change is in flight. The + charger takes seconds to adopt it, and a background retry can + take minutes, so reading the last poll would snap the slider + back to its old position and look like nothing happened. + """ + value, keep = resolve_optimistic( + self._optimistic_value, self._reported_value, self._expired ) - if value is not None: - return int(value) - value = self.coordinator.data.get("lastMaxChargingCurrent") - if value is not None: - return int(value) + if not keep: + self._optimistic_value = None + + return value + + @property + def _reported_value(self) -> int | None: + """Return what the charger last reported.""" + if self.coordinator.data is None: + return None + + for field in ( + "maxExternalChargingCurrentInMilliAmps", + "lastMaxChargingCurrent", + ): + value = self.coordinator.data.get(field) + if value is not None: + return int(value) return None + @property + def _expired(self) -> bool: + """Whether a pending change has been shown for too long.""" + if self._optimistic_value is None: + return True + + # While a background retry is still running the request is + # genuinely outstanding, so keep showing it. + if self._awaiting_retry: + return False + + held = time.monotonic() - self._optimistic_since + return held > OPTIMISTIC_STATE_TIMEOUT + + def _show_requested(self, value: int, awaiting_retry: bool) -> None: + """Display a requested value and re-read the charger later.""" + self._optimistic_value = value + self._optimistic_since = time.monotonic() + self._awaiting_retry = awaiting_retry + self.async_write_ha_state() + self.coordinator.async_schedule_refresh_in(POST_COMMAND_REFRESH_DELAY) + + def _clear_requested(self, message: str) -> None: + """Drop a pending value and explain why.""" + self._optimistic_value = None + self._awaiting_retry = False + self.async_write_ha_state() + self._notify_error(message) + + @callback + def _handle_coordinator_update(self) -> None: + """Stop showing the request once the charger reports it.""" + if ( + self._optimistic_value is not None + and self._reported_value == self._optimistic_value + ): + self._optimistic_value = None + self._awaiting_retry = False + + super()._handle_coordinator_update() + async def async_set_native_value(self, value: float) -> None: """Set the max charging current on the wallbox. @@ -146,8 +216,7 @@ async def async_set_native_value(self, value: float) -> None: int_value, attempts=INLINE_COMMAND_ATTEMPTS, ) - await self.coordinator.async_request_refresh() - self.coordinator.async_schedule_settle_refresh() + self._show_requested(int_value, awaiting_retry=False) except ApiAuthError as err: _LOGGER.warning( "Auth error setting max current on %s: %s", @@ -172,8 +241,9 @@ async def async_set_native_value(self, value: float) -> None: ), description=f"Setting the charging current to " f"{int_value} mA", - on_failure=self._notify_error, + on_failure=self._clear_requested, ) + self._show_requested(int_value, awaiting_retry=True) return _LOGGER.info( diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index 643c226..4124c82 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -188,10 +188,10 @@ def is_charge_enabled(data: dict[str, Any]) -> bool | None: def resolve_optimistic( - optimistic: bool | None, - actual: bool | None, + optimistic: Any | None, + actual: Any | None, expired: bool, -) -> tuple[bool | None, bool]: +) -> tuple[Any | None, bool]: """Decide what a switch should report, and whether to keep guessing. A command takes effect at the charger several seconds after it is @@ -203,6 +203,9 @@ def resolve_optimistic( once it has been held too long, so a command that silently failed cannot leave the UI wrong indefinitely. + Works for any value, not just a boolean: a charging current or an + operation mode lags the same way a switch does. + Args: optimistic: The value the last command asked for, or None. actual: What the charger currently reports, or None. diff --git a/custom_components/daze/select.py b/custom_components/daze/select.py index faa073e..0c92057 100644 --- a/custom_components/daze/select.py +++ b/custom_components/daze/select.py @@ -12,10 +12,12 @@ # @property methods by design. # pyright: reportIncompatibleVariableOverride=false import logging +import time from typing import TYPE_CHECKING, Any from homeassistant.components import persistent_notification from homeassistant.components.select import SelectEntity +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,8 +27,14 @@ ApiCommandRejectedError, ApiError, ) -from .const import DOMAIN, INLINE_COMMAND_ATTEMPTS +from .const import ( + DOMAIN, + INLINE_COMMAND_ATTEMPTS, + OPTIMISTIC_STATE_TIMEOUT, + POST_COMMAND_REFRESH_DELAY, +) from .coordinator import DazeDataUpdateCoordinator +from .payload import resolve_optimistic if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -102,14 +110,74 @@ def __init__( self._serial_number = serial_number self._attr_unique_id = f"{serial_number}_operation_mode" self._attr_device_info = device_info + self._optimistic_option: str | None = None + self._optimistic_since: float = 0.0 + self._awaiting_retry: bool = False @property def current_option(self) -> str | None: - """Return the current operation mode.""" + """Return the current operation mode. + + Shows the requested mode while a change is in flight, for the + same reason as the current limit: the charger lags, and a + background retry can take minutes, so reading the last poll + would revert the selection and look like nothing happened. + """ + option, keep = resolve_optimistic( + self._optimistic_option, self._reported_option, self._expired + ) + + if not keep: + self._optimistic_option = None + + return option + + @property + def _reported_option(self) -> str | None: + """Return the mode the charger last reported.""" if self.coordinator.data is None: return None return _current_option_from_data(self.coordinator.data) + @property + def _expired(self) -> bool: + """Whether a pending change has been shown for too long.""" + if self._optimistic_option is None: + return True + if self._awaiting_retry: + return False + return ( + time.monotonic() - self._optimistic_since + > OPTIMISTIC_STATE_TIMEOUT + ) + + def _show_requested(self, option: str, awaiting_retry: bool) -> None: + """Display a requested mode and re-read the charger later.""" + self._optimistic_option = option + self._optimistic_since = time.monotonic() + self._awaiting_retry = awaiting_retry + self.async_write_ha_state() + self.coordinator.async_schedule_refresh_in(POST_COMMAND_REFRESH_DELAY) + + def _clear_requested(self, message: str) -> None: + """Drop a pending mode and explain why.""" + self._optimistic_option = None + self._awaiting_retry = False + self.async_write_ha_state() + self._notify_error(message) + + @callback + def _handle_coordinator_update(self) -> None: + """Stop showing the request once the charger reports it.""" + if ( + self._optimistic_option is not None + and self._reported_option == self._optimistic_option + ): + self._optimistic_option = None + self._awaiting_retry = False + + super()._handle_coordinator_update() + async def async_select_option(self, option: str) -> None: """Set the operation mode on the wallbox. @@ -151,8 +219,7 @@ async def async_select_option(self, option: str) -> None: eco_value, attempts=INLINE_COMMAND_ATTEMPTS, ) - await self.coordinator.async_request_refresh() - self.coordinator.async_schedule_settle_refresh() + self._show_requested(option, awaiting_retry=False) except ApiAuthError as err: _LOGGER.warning( "Auth error setting operation mode on %s: %s", @@ -173,8 +240,9 @@ async def async_select_option(self, option: str) -> None: attempts=INLINE_COMMAND_ATTEMPTS, ), description=f"Setting the operation mode to {option}", - on_failure=self._notify_error, + on_failure=self._clear_requested, ) + self._show_requested(option, awaiting_retry=True) return _LOGGER.info( diff --git a/tests/test_payload.py b/tests/test_payload.py index 6d9976e..60c8f9f 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -576,6 +576,48 @@ def test_measured_boundary_is_reproduced() -> None: assert value >= floor, value + +def test_optimistic_rule_handles_non_boolean_values() -> None: + """The current limit and the mode lag exactly as the switch does. + + The rule was written for a boolean switch. Reusing it for a + milliamp figure and a mode string is the point: all three read + stale from the last poll while a change is in flight. + """ + # A requested current, charger still reporting the old one. + value, keep = payload.resolve_optimistic(16000, 6521, False) + assert value == 16000 + assert keep is True + + # The charger has caught up. + value, keep = payload.resolve_optimistic(16000, 16000, False) + assert value == 16000 + assert keep is False + + # Held too long: reality wins even though it disagrees. + value, keep = payload.resolve_optimistic(16000, 6521, True) + assert value == 6521 + assert keep is False + + +def test_optimistic_rule_handles_mode_strings() -> None: + """Same rule, applied to the operation mode selector.""" + value, keep = payload.resolve_optimistic("eco", "fast", False) + assert value == "eco" + assert keep is True + + value, keep = payload.resolve_optimistic("eco", "eco", False) + assert value == "eco" + assert keep is False + + +def test_optimistic_rule_treats_zero_as_a_real_value() -> None: + """Zero is falsy but is a legitimate reading, not an absence.""" + value, keep = payload.resolve_optimistic(0, 6521, False) + assert value == 0 + assert keep is True + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 7ac8b7556677f2d57b5d91138eb995e646b5227d Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Mon, 28 Sep 2026 13:42:49 +0100 Subject: [PATCH 28/82] test: run the real entities against a stubbed Home Assistant The optimistic display logic lives in the entity classes, which import Home Assistant and had therefore never been executed by any test. That is exactly where the reported bug lived: changing the charging current appeared to do nothing. Replace Home Assistant with the smallest stubs the entities actually use and exercise the real number and select classes. Ten tests, all passing, covering: - a new current is displayed immediately on success, while the coordinator still reports the old one. This is the reported bug. - a new current is displayed while a background retry is running, and no error is raised, since the request is still outstanding - the display returns to the charger's reading once it agrees, so a later external change is not masked - the pending value is dropped and explained if every retry fails - a refusal such as an out-of-range current is reported at once rather than retried for minutes - re-selecting the current value sends nothing - the refresh is scheduled for ten seconds rather than run at once - the bounds come from the charger: 6500 to 32000 at 232 V - the mode selector behaves the same way in both respects Register the suite with run_all.py: 94 tests across four modules. Co-Authored-By: Claude Opus 5 --- tests/run_all.py | 1 + tests/test_entities.py | 498 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 499 insertions(+) create mode 100644 tests/test_entities.py diff --git a/tests/run_all.py b/tests/run_all.py index 2bd92d6..689d821 100755 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -24,6 +24,7 @@ "test_auth_getuser.py", "test_payload.py", "test_qa_invariants.py", + "test_entities.py", ) diff --git a/tests/test_entities.py b/tests/test_entities.py new file mode 100644 index 0000000..3bd0241 --- /dev/null +++ b/tests/test_entities.py @@ -0,0 +1,498 @@ +"""Execute the real entity classes against a stubbed Home Assistant. + +The optimistic display logic lives in number.py, select.py and +switch.py, which import Home Assistant and so had never been run by any +test. That is precisely where the "I changed it and nothing happened" +bug lived, so it is worth exercising directly. + +Home Assistant is replaced with the smallest stubs the entities +actually use. The integration modules themselves are the real ones. + +Run with pytest, or standalone: + + python3 tests/test_entities.py +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import sys +import types +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE_DIR = ROOT / "custom_components" / "daze" + + +# ------------------------------------------------------------------ +# Home Assistant stubs +# ------------------------------------------------------------------ + + +class StubCoordinatorEntity: + """Stand-in for CoordinatorEntity. + + The real class is generic, so subscripting has to work. + """ + + def __class_getitem__(cls, _item: Any) -> Any: + """Support CoordinatorEntity[DazeDataUpdateCoordinator].""" + return cls + + def __init__(self, coordinator: Any) -> None: + self.coordinator = coordinator + self.hass = object() + self.state_writes = 0 + + def async_write_ha_state(self) -> None: + """Count frontend updates instead of performing one.""" + self.state_writes += 1 + + def _handle_coordinator_update(self) -> None: + """Base implementation does nothing here.""" + self.state_writes += 1 + + +class StubDataUpdateCoordinator: + """Stand-in for DataUpdateCoordinator. + + The real class is generic, so subscripting has to work. + """ + + def __class_getitem__(cls, _item: Any) -> Any: + """Support DataUpdateCoordinator[DazeCoordinatorData].""" + return cls + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.data: dict[str, Any] | None = None + self.hass = object() + self.update_interval = None + + async def async_request_refresh(self) -> None: + """No-op refresh.""" + + +def _module(name: str, **attributes: Any) -> types.ModuleType: + """Build a stub module with the given attributes.""" + module = types.ModuleType(name) + for key, value in attributes.items(): + setattr(module, key, value) + sys.modules[name] = module + return module + + +def install_homeassistant_stubs() -> list[tuple[Any, Any, Any]]: + """Register the Home Assistant modules the entities import. + + Returns: + The list of scheduled callbacks, so tests can fire them. + + """ + scheduled: list[tuple[Any, Any, Any]] = [] + + def async_call_later(hass: Any, delay: Any, action: Any) -> Any: + """Record a scheduled callback and return a canceller.""" + entry = (hass, delay, action) + scheduled.append(entry) + + def cancel() -> None: + if entry in scheduled: + scheduled.remove(entry) + + return cancel + + notifications: list[dict[str, Any]] = [] + + def async_create( + hass: Any, message: str, title: str = "", notification_id: str = "" + ) -> None: + """Record a notification.""" + notifications.append({"message": message, "title": title}) + + _module("homeassistant") + _module("homeassistant.components") + _module( + "homeassistant.components.persistent_notification", + async_create=async_create, + _records=notifications, + ) + _module("homeassistant.components.number", NumberEntity=object) + _module("homeassistant.components.select", SelectEntity=object) + _module("homeassistant.components.switch", SwitchEntity=object) + _module( + "homeassistant.components.sensor", + SensorDeviceClass=type("SensorDeviceClass", (), {}), + SensorEntity=object, + SensorEntityDescription=object, + SensorStateClass=type("SensorStateClass", (), {}), + ) + _module( + "homeassistant.const", + EntityCategory=type("EntityCategory", (), {"CONFIG": "config"}), + UnitOfElectricCurrent=type( + "UnitOfElectricCurrent", (), {"MILLIAMPERE": "mA"} + ), + ) + _module( + "homeassistant.core", + callback=lambda fn: fn, + HomeAssistant=object, + ) + _module("homeassistant.config_entries", ConfigEntry=object) + _module( + "homeassistant.exceptions", + ConfigEntryAuthFailed=type( + "ConfigEntryAuthFailed", (Exception,), {} + ), + HomeAssistantError=type("HomeAssistantError", (Exception,), {}), + ) + _module("homeassistant.helpers") + _module( + "homeassistant.helpers.aiohttp_client", + async_get_clientsession=lambda hass: None, + ) + _module("homeassistant.helpers.event", async_call_later=async_call_later) + _module( + "homeassistant.helpers.device_registry", + DeviceInfo=dict, + async_get=lambda hass: None, + ) + _module( + "homeassistant.helpers.update_coordinator", + CoordinatorEntity=StubCoordinatorEntity, + DataUpdateCoordinator=StubDataUpdateCoordinator, + UpdateFailed=type("UpdateFailed", (Exception,), {}), + ) + _module("homeassistant.helpers.entity_platform", AddEntitiesCallback=object) + _module( + "homeassistant.helpers.restore_state", RestoreEntity=object + ) + _module("homeassistant.helpers.config_validation", positive_int=int) + + return scheduled + + +SCHEDULED = install_homeassistant_stubs() + + +def _load_package() -> types.ModuleType: + """Load the integration package without running its __init__.""" + package = types.ModuleType("daze_entities_under_test") + package.__path__ = [str(PACKAGE_DIR)] + sys.modules["daze_entities_under_test"] = package + + for name in ("const", "payload", "models"): + spec = importlib.util.spec_from_file_location( + f"daze_entities_under_test.{name}", PACKAGE_DIR / f"{name}.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[f"daze_entities_under_test.{name}"] = module + spec.loader.exec_module(module) + + spec = importlib.util.spec_from_file_location( + "daze_entities_under_test.api", + PACKAGE_DIR / "api" / "__init__.py", + submodule_search_locations=[str(PACKAGE_DIR / "api")], + ) + assert spec and spec.loader + api_module = importlib.util.module_from_spec(spec) + sys.modules["daze_entities_under_test.api"] = api_module + spec.loader.exec_module(api_module) + + for name in ("coordinator", "number", "select"): + spec = importlib.util.spec_from_file_location( + f"daze_entities_under_test.{name}", PACKAGE_DIR / f"{name}.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[f"daze_entities_under_test.{name}"] = module + spec.loader.exec_module(module) + + return package + + +_load_package() + +api = sys.modules["daze_entities_under_test.api"] +number_module = sys.modules["daze_entities_under_test.number"] +select_module = sys.modules["daze_entities_under_test.select"] +notifications = sys.modules[ + "homeassistant.components.persistent_notification" +]._records + + +# ------------------------------------------------------------------ +# Test doubles +# ------------------------------------------------------------------ + + +class FakeCoordinator(StubDataUpdateCoordinator): + """Records what the entity asks the coordinator to do.""" + + def __init__(self, data: dict[str, Any]) -> None: + super().__init__() + self.data = data + self.refresh_delays: list[int] = [] + self.background: list[dict[str, Any]] = [] + + def async_schedule_refresh_in(self, delay: int) -> None: + """Record a delayed refresh.""" + self.refresh_delays.append(delay) + + def async_schedule_settle_refresh(self) -> None: + """Record a settle refresh.""" + self.refresh_delays.append(-1) + + def async_retry_in_background( + self, key: str, action: Any, description: str, on_failure: Any = None + ) -> None: + """Record a background retry request.""" + self.background.append( + { + "key": key, + "action": action, + "description": description, + "on_failure": on_failure, + } + ) + + def async_cancel_background_retry(self, key: str) -> None: + """Drop a recorded retry.""" + self.background = [b for b in self.background if b["key"] != key] + + +class FakeApi: + """Records command calls and can be told to fail.""" + + def __init__(self, error: Exception | None = None) -> None: + self.error = error + self.calls: list[tuple[str, Any]] = [] + + async def async_set_max_charging_current( + self, serial: str, current_ma: int, attempts: int = 8 + ) -> dict: + """Record and optionally fail.""" + self.calls.append(("current", current_ma)) + if self.error is not None: + raise self.error + return {} + + async def async_set_eco_mode( + self, serial: str, eco_mode_enabled: bool, attempts: int = 8 + ) -> dict: + """Record and optionally fail.""" + self.calls.append(("eco", eco_mode_enabled)) + if self.error is not None: + raise self.error + return {} + + +BASE_DATA: dict[str, Any] = { + "maxExternalChargingCurrentInMilliAmps": 6521, + "lastMaxInstallationCurrent": 32000, + "lastACVoltageL1": 232, + "ecoModeEnabled": False, +} + + +def make_number( + data: dict[str, Any] | None = None, error: Exception | None = None +) -> tuple[Any, FakeCoordinator, FakeApi]: + """Build a number entity wired to fakes.""" + coordinator = FakeCoordinator(dict(data or BASE_DATA)) + client = FakeApi(error) + entity = number_module.DazeWallboxNumberEntity( + coordinator=coordinator, + api_client=client, + serial_number="SER1", + device_info={}, + ) + return entity, coordinator, client + + +def rpc_failure() -> Exception: + """Return the error the API raises when the link is down.""" + return api.ApiCommandRejectedError("unreachable", code=101) + + +def refused() -> Exception: + """Return the error the API raises for an invalid value.""" + return api.ApiCommandRejectedError("out of range", code=369) + + +# ------------------------------------------------------------------ +# The reported bug: changing the value appeared to do nothing +# ------------------------------------------------------------------ + + +def test_new_current_is_shown_immediately_on_success() -> None: + """This is the bug: the slider snapped back to the old value. + + The charger still reports 6521 until it adopts the change, so + reading the coordinator would revert the display. + """ + entity, coordinator, client = make_number() + + assert entity.native_value == 6521 + + asyncio.run(entity.async_set_native_value(16000)) + + assert client.calls == [("current", 16000)] + assert entity.native_value == 16000, "slider reverted to the old value" + assert coordinator.data["maxExternalChargingCurrentInMilliAmps"] == 6521 + + +def test_new_current_is_shown_while_a_retry_runs() -> None: + """An unreachable charger must not look like a no-op either. + + Since the background retry returns without raising, nothing else + would tell the user their change is still pending. + """ + notifications.clear() + entity, coordinator, _ = make_number(error=rpc_failure()) + + asyncio.run(entity.async_set_native_value(16000)) + + assert len(coordinator.background) == 1 + assert entity.native_value == 16000 + assert not notifications, "an in-flight retry must not raise an error" + + +def test_display_returns_to_reality_once_the_charger_agrees() -> None: + """Holding the guess longer would mask later external changes.""" + entity, coordinator, _ = make_number() + + asyncio.run(entity.async_set_native_value(16000)) + assert entity.native_value == 16000 + + coordinator.data["maxExternalChargingCurrentInMilliAmps"] = 16000 + entity._handle_coordinator_update() + + assert entity._optimistic_value is None + assert entity.native_value == 16000 + + +def test_display_is_dropped_when_every_retry_fails() -> None: + """A change that never landed must not be shown indefinitely.""" + notifications.clear() + entity, coordinator, _ = make_number(error=rpc_failure()) + + asyncio.run(entity.async_set_native_value(16000)) + assert entity.native_value == 16000 + + # Simulate the coordinator exhausting its background attempts. + coordinator.background[0]["on_failure"]("could not be delivered") + + assert entity.native_value == 6521 + assert len(notifications) == 1 + assert "could not be delivered" in notifications[0]["message"] + + +def test_a_refusal_is_reported_rather_than_retried() -> None: + """Out of range is final: retrying it wastes minutes.""" + notifications.clear() + entity, coordinator, _ = make_number(error=refused()) + + asyncio.run(entity.async_set_native_value(32000)) + + assert coordinator.background == [] + assert len(notifications) == 1 + assert entity.native_value == 6521 + + +def test_setting_the_same_value_sends_nothing() -> None: + """Re-selecting the current value must not hit the API.""" + entity, _, client = make_number() + + asyncio.run(entity.async_set_native_value(6521)) + + assert client.calls == [] + + +def test_a_refresh_is_scheduled_rather_than_run_immediately() -> None: + """Refreshing at once reads the state from before the change.""" + entity, coordinator, _ = make_number() + + asyncio.run(entity.async_set_native_value(16000)) + + assert coordinator.refresh_delays == [10] + + +def test_bounds_come_from_the_charger() -> None: + """The floor follows the 1500 W minimum at the measured voltage.""" + entity, _, _ = make_number() + + assert entity.native_min_value == 6500 + assert entity.native_max_value == 32000 + + +# ------------------------------------------------------------------ +# The same behaviour on the mode selector +# ------------------------------------------------------------------ + + +def make_select( + error: Exception | None = None, +) -> tuple[Any, FakeCoordinator, FakeApi]: + """Build a select entity wired to fakes.""" + coordinator = FakeCoordinator(dict(BASE_DATA)) + client = FakeApi(error) + entity = select_module.DazeWallboxSelectEntity( + coordinator=coordinator, + api_client=client, + serial_number="SER1", + device_info={}, + ) + return entity, coordinator, client + + +def test_new_mode_is_shown_immediately() -> None: + """The selector reverted for the same reason the slider did.""" + entity, coordinator, client = make_select() + + assert entity.current_option == "fast" + + asyncio.run(entity.async_select_option("eco")) + + assert client.calls == [("eco", True)] + assert entity.current_option == "eco" + assert coordinator.data["ecoModeEnabled"] is False + + +def test_new_mode_is_held_while_a_retry_runs() -> None: + """An unreachable charger must not revert the selection.""" + entity, coordinator, _ = make_select(error=rpc_failure()) + + asyncio.run(entity.async_select_option("eco")) + + assert len(coordinator.background) == 1 + assert entity.current_option == "eco" + + +def _main() -> int: + """Run every test in this module and report results.""" + tests = [ + value + for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value) + ] + + failures = 0 + for test in tests: + try: + test() + except Exception as err: # noqa: BLE001 - standalone runner + failures += 1 + print(f"FAIL {test.__name__}: {type(err).__name__}: {err}") + else: + print(f"ok {test.__name__}") + + print(f"\n{len(tests) - failures} passed, {failures} failed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(_main()) From e28bd27ce5df7d37bc0df841abb15eaebfe8dea8 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Mon, 28 Sep 2026 14:33:48 +0100 Subject: [PATCH 29/82] fix: never offer a range that excludes the charger's own setting Running the QA tool with the car unplugged exposed two things. evseState 1 is idle: no chargeSession at all, because the car is not connected or the session has ended. Recorded as confirmed, alongside 3, 5 and 6. It was already treated as idle by the fallback, but by default rather than by evidence. More importantly, with no session there is no voltage reading. The floor then falls back to the nominal 230 V and computes 6600 mA, while the charger was sitting at 6521 mA, a value it had demonstrably accepted at its real 232 V. The slider's minimum would have been above its own current value. Clamp the computed floor so it never exceeds the configured current, while keeping the 6 A absolute minimum. The clamp only ever lowers the floor: a charger set to 16000 mA still gets a 6600 mA minimum rather than having the floor dragged up to its setting. A measured voltage still wins when one is available, so the floor is 6500 mA while charging at 232 V and 6521 mA when idle. Bump version to 0.1.15. Co-Authored-By: Claude Opus 5 --- custom_components/daze/manifest.json | 2 +- custom_components/daze/payload.py | 18 ++++++++++-- tests/test_payload.py | 44 ++++++++++++++++++++++++++++ tests/test_qa_invariants.py | 28 ++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index a62af0a..3f626a6 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.14" + "version": "0.1.15" } diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index 4124c82..331462c 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -26,6 +26,8 @@ # EVSE state values confirmed against live hardware: # +# 1 idle observed with no chargeSession at all: the car is +# not connected or the session has ended # 3 charging observed while delivering 2688 W with a session running # 5 waiting observed immediately after a start or resume takes # effect: isPaused cleared and evseSuspensionReason @@ -37,6 +39,7 @@ # # Other values remain unknown, so an unrecognised state reports "idle" # rather than inventing a meaning. +EVSE_STATE_IDLE = 1 EVSE_STATE_CHARGING = 3 EVSE_STATE_WAITING_FOR_EV = 5 EVSE_STATE_PAUSED = 6 @@ -349,5 +352,16 @@ def min_charging_current(data: dict[str, Any] | None) -> int: # Round up: rounding down would land back under the power floor. stepped = math.ceil(required_ma / CURRENT_STEP_MA) * CURRENT_STEP_MA - - return max(ABSOLUTE_MIN_CHARGING_CURRENT_MA, int(stepped)) + floor = max(ABSOLUTE_MIN_CHARGING_CURRENT_MA, int(stepped)) + + # Never exclude the value the charger is already using. With no + # session there is no voltage reading, so the nominal 230 V is + # assumed and the computed floor can land above a setting the + # charger demonstrably accepted at its real voltage. Offering a + # range that omits the current value is worse than offering one + # value that might be refused. + configured = (data or {}).get("maxExternalChargingCurrentInMilliAmps") + if isinstance(configured, (int, float)) and configured > 0: + floor = min(floor, max(int(configured), ABSOLUTE_MIN_CHARGING_CURRENT_MA)) + + return floor diff --git a/tests/test_payload.py b/tests/test_payload.py index 60c8f9f..0c3c299 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -618,6 +618,50 @@ def test_optimistic_rule_treats_zero_as_a_real_value() -> None: assert keep is True + +def test_state_1_is_idle() -> None: + """Observed with no chargeSession: car unplugged or session ended.""" + data = payload.merge_payload( + {"evseState": 1, "isPaused": False, "chargeSession": None}, None + ) + assert data["evseStatus"] == "idle" + + +def test_floor_never_excludes_the_configured_value() -> None: + """With no session there is no voltage, so the floor is a guess. + + Observed: the charger sat at 6521 mA while idle. Falling back to + 230 V computes a 6600 mA floor, which would put the slider's + minimum above the value the charger was actually using. + """ + idle = {"maxExternalChargingCurrentInMilliAmps": 6521} + assert payload.min_charging_current(idle) <= 6521 + + +def test_floor_is_not_dragged_down_by_a_high_setting() -> None: + """Clamping must only ever lower the floor to reach the setting.""" + data = {"maxExternalChargingCurrentInMilliAmps": 16000} + assert payload.min_charging_current(data) == 6600 + + +def test_floor_ignores_an_impossible_configured_value() -> None: + """A nonsense setting must not drop the floor below 6 A.""" + data = {"maxExternalChargingCurrentInMilliAmps": 100} + assert ( + payload.min_charging_current(data) + == payload.ABSOLUTE_MIN_CHARGING_CURRENT_MA + ) + + +def test_measured_voltage_still_wins_when_available() -> None: + """The clamp must not override a real reading.""" + charging = { + "lastACVoltageL1": 232, + "maxExternalChargingCurrentInMilliAmps": 6521, + } + assert payload.min_charging_current(charging) == 6500 + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tests/test_qa_invariants.py b/tests/test_qa_invariants.py index 54dc12d..62ec0a0 100644 --- a/tests/test_qa_invariants.py +++ b/tests/test_qa_invariants.py @@ -203,6 +203,34 @@ def test_switch_state_agrees_with_the_status() -> None: assert enabled is True, (state, status) + +def test_floor_never_excludes_a_configured_value() -> None: + """A slider that omits the charger's own setting is broken. + + Sweeps settings against the voltage-less case, which is when the + computed floor is least trustworthy. + """ + for configured in (6000, 6200, 6521, 6600, 8000, 16000, 32000): + data = {"maxExternalChargingCurrentInMilliAmps": configured} + floor = payload.min_charging_current(data) + assert floor <= max( + configured, payload.ABSOLUTE_MIN_CHARGING_CURRENT_MA + ), (configured, floor) + + +def test_clamping_only_ever_lowers_the_floor() -> None: + """Knowing the setting must not raise the minimum.""" + for configured in (6000, 6521, 16000, 32000): + bare = payload.min_charging_current({"lastACVoltageL1": 232}) + with_setting = payload.min_charging_current( + { + "lastACVoltageL1": 232, + "maxExternalChargingCurrentInMilliAmps": configured, + } + ) + assert with_setting <= bare, (configured, bare, with_setting) + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From eb4295a8a1a1e950ec4672549743b5b62443a7cf Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Mon, 28 Sep 2026 14:52:19 +0100 Subject: [PATCH 30/82] feat: add a tool that sets the charging limit and keeps it The existing tools restore whatever they found, because they exist to measure. That is the wrong behaviour when the aim is simply to charge faster: asking for 4 kW and having it reverted a minute later is not useful. tools/set_current.py takes a power figure or a current, works out the milliamps from the charger's measured voltage, checks the value against the 1500 W floor and the installation rating before sending anything, applies it, and reads it back to confirm. It does not restore. Power is the more natural input. A wallbox is sold as 1.5 to 7.4 kW and the charger enforces a minimum power rather than a minimum current, so working in watts avoids converting by hand and avoids requesting something that will be refused. Voltage is taken from the live session when one exists, then the socket record, then the 230 V nominal, so the conversion is right whether or not a car is connected. Also add a round-trip mode to qa_verify_current.py. It previously refused to do anything without an active charge, which is correct for measuring whether the draw follows the limit but unhelpful when the question is only whether the value can be changed at all. With nothing drawing it now sets a value, reads it back, and restores, which distinguishes a charger that will not accept a change from an integration that is not sending one. Co-Authored-By: Claude Opus 5 --- tools/qa_verify_current.py | 98 ++++++++++- tools/set_current.py | 347 +++++++++++++++++++++++++++++++++++++ tools/try_resume.py | 7 +- 3 files changed, 445 insertions(+), 7 deletions(-) create mode 100755 tools/set_current.py diff --git a/tools/qa_verify_current.py b/tools/qa_verify_current.py index fb9b311..d70811a 100755 --- a/tools/qa_verify_current.py +++ b/tools/qa_verify_current.py @@ -258,6 +258,91 @@ def watch(token: str, serial: str, target: int) -> dict: return samples[-1] if samples else {} +def describe(status: int, body: object) -> str: + """Summarise a response in one line.""" + if isinstance(body, dict): + errors = body.get("errors") + if isinstance(errors, list) and errors: + first = errors[0] + if isinstance(first, dict): + return ( + f"HTTP {status} code {first.get('code')}: " + f"{str(first.get('message', ''))[:90]}" + ) + if body.get("_error"): + return f"network error: {body['_error']}" + return f"HTTP {status}" + + +def read_setting(token: str, serial: str, email: str) -> int | None: + """Read the configured current back from the EVSE record.""" + _, record = discover(token, email) + if record.get("serialNumber") != serial: + return None + + value = record.get("maxExternalChargingCurrentInMilliAmps") + return int(value) if isinstance(value, (int, float)) else None + + +def roundtrip_check( + token: str, serial: str, email: str, original: int, targets: tuple[int, ...] +) -> int: + """Verify the setting changes and persists, with no car attached. + + This cannot show whether the charger's draw follows the limit, + because nothing is drawing. It does show that the value is + accepted, stored and read back, which is what "can I change it + dynamically" actually asks. + """ + print("\nNo active charge, so checking that the setting itself") + print("changes and persists. This does not prove the draw follows") + print("the limit; that needs a car charging.") + + results: list[tuple[int, int | None, bool]] = [] + + try: + for target in targets: + print(f"\n Setting {target} mA") + status, body = set_limit(token, serial, target) + print(f" -> HTTP {status}") + + if not 200 <= status < 300: + print(f" rejected: {describe(status, body)}") + results.append((target, None, False)) + continue + + # Give the service a moment to store it. + time.sleep(5) + readback = read_setting(token, serial, email) + ok = readback == target + print(f" read back: {readback} mA {'OK' if ok else 'MISMATCH'}") + results.append((target, readback, ok)) + finally: + print(f"\nRestoring {original} mA...") + status, _ = set_limit(token, serial, original) + print(f" HTTP {status}") + time.sleep(3) + final = read_setting(token, serial, email) + print(f" read back: {final} mA") + + print("\n" + "=" * 60) + + changed = [r for r in results if r[2]] + if len(changed) == len(results) and results: + print("The setting can be changed dynamically: every value was") + print("accepted and read back unchanged.") + print() + print("So if Home Assistant is not reflecting a change, the") + print("problem is in the integration rather than the charger.") + return 0 + + print("Some values did not stick:") + for target, readback, ok in results: + if not ok: + print(f" requested {target} mA, read back {readback}") + return 1 + + def main() -> int: """Lower the limit, verify the draw follows, then restore.""" argv = sys.argv[1:] @@ -310,10 +395,15 @@ def flag(name: str, default: int) -> int: power = baseline.get("power") if not isinstance(power, (int, float)) or power <= 0: - print("\nThe car is not drawing any power right now, so there is") - print("nothing to measure: a limit change cannot be seen when the") - print("draw is already zero. Start a charge and run this again.") - return 1 + print("\nNothing is drawing power, so the effect of a limit change") + print("cannot be observed. Falling back to a setting round trip.") + + if not assume_yes and ( + input("\nProceed? [yes/no] ").strip().lower() != "yes" + ): + return 0 + + return roundtrip_check(token, serial, email, original, (low, high)) print(f"\nPlan: set {low} mA, watch, set {high} mA, watch, " f"restore {original} mA.") diff --git a/tools/set_current.py b/tools/set_current.py new file mode 100755 index 0000000..a20f8cb --- /dev/null +++ b/tools/set_current.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +"""Set the charging current limit and leave it set. + +The other tools here restore whatever they found, because they exist to +measure. This one changes the limit and keeps it, which is what you +want when the aim is simply to charge faster or slower. + +Accepts either a power figure or a current. Power is usually what you +actually mean: a wallbox is sold as 1.5 to 7.4 kW, and the charger +enforces a minimum power rather than a minimum current, so working in +watts avoids the arithmetic. + +WARNING: this WRITES configuration to your wallbox and changes how fast +your car charges. The change is deliberate and is not undone. + +Usage: + + python3 tools/set_current.py --watts 4000 + python3 tools/set_current.py --ma 17200 + python3 tools/set_current.py --watts 4000 --yes +""" + +from __future__ import annotations + +import getpass +import json +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +API_BASE_URL = "https://webapi.dazeservice.com/v3" +COGNITO_BASE_URL = "https://daze.auth.eu-central-1.amazoncognito.com" +COGNITO_IDP_URL = "https://cognito-idp.eu-central-1.amazonaws.com/" +CLIENT_ID = "4m0rp7oqarbrc3hn67ivvonba8" +REDIRECT_URI = "https://webportal.dazeservice.com/authentication/callback" +GET_USER_TARGET = "AWSCognitoIdentityProviderService.GetUser" + +TIMEOUT = 30 + +# The charger steps in 0.1 A and enforces a 1500 W floor. +STEP_MA = 100 +MIN_POWER_W = 1500 +ABSOLUTE_MIN_MA = 6000 +NOMINAL_VOLTAGE = 230 + + +def _send( + request: urllib.request.Request, attempts: int = 3 +) -> tuple[int, object]: + """Send a request, tolerating HTTP errors and network stalls.""" + last: tuple[int, object] = (0, {"_error": "not attempted"}) + + for attempt in range(1, attempts + 1): + try: + with urllib.request.urlopen(request, timeout=TIMEOUT) as response: + return response.status, _parse( + response.read().decode(errors="replace") + ) + except urllib.error.HTTPError as err: + return err.code, _parse(err.read().decode(errors="replace")) + except (urllib.error.URLError, TimeoutError, OSError) as err: + last = (0, {"_error": str(getattr(err, "reason", err))}) + if attempt < attempts: + print(f" network problem, retrying ({attempt}/{attempts})") + time.sleep(3) + + return last + + +def _parse(raw: str) -> object: + """Parse a JSON body, falling back to a truncated raw string.""" + if not raw.strip(): + return {"_empty": True} + try: + return json.loads(raw) + except ValueError: + return {"_raw": raw[:300]} + + +def _get(token: str, path: str) -> tuple[int, object]: + """GET an API path with the bearer token.""" + return _send( + urllib.request.Request( + f"{API_BASE_URL}{path}", + headers={"authorization": f"Bearer {token}"}, + method="GET", + ) + ) + + +def refresh_access_token(refresh_token: str) -> str: + """Exchange a refresh token for a fresh access token.""" + data = urllib.parse.urlencode( + { + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + ).encode() + + status, body = _send( + urllib.request.Request( + f"{COGNITO_BASE_URL}/oauth2/token", + data=data, + headers={ + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" + }, + method="POST", + ) + ) + + if status != 200 or not isinstance(body, dict): + print(f"Could not refresh the access token (HTTP {status}).") + raise SystemExit(2) + + token = body.get("access_token") + if not isinstance(token, str): + print("Refresh succeeded but returned no access token.") + raise SystemExit(2) + + return token + + +def get_email(token: str) -> str: + """Read the account email via Cognito GetUser.""" + status, body = _send( + urllib.request.Request( + COGNITO_IDP_URL, + data=json.dumps({"AccessToken": token}).encode(), + headers={ + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": GET_USER_TARGET, + }, + method="POST", + ) + ) + + if status != 200 or not isinstance(body, dict): + return "" + + for attribute in body.get("UserAttributes", []): + if isinstance(attribute, dict) and attribute.get("Name") == "email": + return str(attribute.get("Value", "")) + + return "" + + +def discover(token: str, email: str) -> tuple[str, dict]: + """Return the first charger's serial and its EVSE record.""" + if not email: + return "", {} + + mail = urllib.parse.quote(email, safe="") + status, body = _get(token, f"/users/{mail}/networks?includeStats=true") + networks = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(networks, list) or not networks: + return "", {} + + uid = urllib.parse.quote(str(networks[0].get("uid", "")), safe="") + status, body = _get(token, f"/networks/{uid}/evses?includeEcoInfo=true") + evses = body.get("data") if isinstance(body, dict) else None + if status != 200 or not isinstance(evses, list) or not evses: + return "", {} + + record = evses[0] if isinstance(evses[0], dict) else {} + return str(record.get("serialNumber", "")), record + + +def measured_voltage(token: str, serial: str, record: dict) -> int: + """Return the best available supply voltage. + + Prefers the live session, then the socket record, then nominal. + Only L1 is used: on a single-phase charger L2 and L3 read near + zero and would drag an average into nonsense. + """ + quoted = urllib.parse.quote(serial, safe="") + status, body = _get( + token, f"/sockets/{quoted}/remoteInfo?includeEcoInfo=true" + ) + + if status == 200 and isinstance(body, dict): + data = body.get("data") + if isinstance(data, dict): + session = data.get("chargeSession") + if isinstance(session, dict): + reading = session.get("lastACVoltageL1") + if isinstance(reading, (int, float)) and reading > 100: + return int(reading) + + sockets = record.get("sockets") + if isinstance(sockets, list) and sockets and isinstance(sockets[0], dict): + reading = sockets[0].get("lastACVoltageL1") + if isinstance(reading, (int, float)) and reading > 100: + return int(reading) + + return NOMINAL_VOLTAGE + + +def set_limit(token: str, serial: str, milliamps: int) -> tuple[int, object]: + """Set the maximum charging current.""" + quoted = urllib.parse.quote(serial, safe="") + payload = { + "evseSerialNumber": serial, + "maxExternalChargingCurrentInMilliAmps": milliamps, + } + + return _send( + urllib.request.Request( + f"{API_BASE_URL}/evses/{quoted}" + "/configurations/maxExternalChargingCurrent", + data=json.dumps(payload).encode(), + headers={ + "authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + method="POST", + ) + ) + + +def describe(status: int, body: object) -> str: + """Summarise a response in one line.""" + if isinstance(body, dict): + errors = body.get("errors") + if isinstance(errors, list) and errors: + first = errors[0] + if isinstance(first, dict): + return ( + f"HTTP {status} code {first.get('code')}: " + f"{str(first.get('message', ''))[:90]}" + ) + if body.get("_error"): + return f"network error: {body['_error']}" + return f"HTTP {status}" + + +def main() -> int: + """Set the limit and confirm it stuck.""" + argv = sys.argv[1:] + assume_yes = "--yes" in argv + + def flag(name: str) -> int | None: + if name in argv: + return int(argv[argv.index(name) + 1]) + return None + + watts = flag("--watts") + milliamps = flag("--ma") + + if watts is None and milliamps is None: + print(__doc__) + return 3 + + print("Daze charging current setter") + print() + + refresh_token = getpass.getpass("Refresh token (input hidden): ").strip() + if not refresh_token: + print("A refresh token is required.") + return 3 + + token = refresh_access_token(refresh_token) + email = get_email(token) + serial, record = discover(token, email) + + if not serial: + serial = input("Wallbox serial (discovery failed): ").strip() + if not serial: + print("A serial number is required.") + return 3 + + volts = measured_voltage(token, serial, record) + current = record.get("maxExternalChargingCurrentInMilliAmps") + installation = record.get("lastMaxInstallationCurrent") or 32000 + + if milliamps is None: + assert watts is not None + exact = watts / volts * 1000 + milliamps = int(round(exact / STEP_MA) * STEP_MA) + + floor = max( + ABSOLUTE_MIN_MA, + int(-(-MIN_POWER_W / volts * 1000 // STEP_MA) * STEP_MA), + ) + ceiling = int(installation) + + print(f"Charger : {serial}") + print(f"Voltage : {volts} V") + if isinstance(current, (int, float)): + print(f"Now : {int(current)} mA " + f"({round(int(current) * volts / 1000)} W)") + print(f"Range : {floor} to {ceiling} mA " + f"({round(floor * volts / 1000)} to " + f"{round(ceiling * volts / 1000)} W)") + print(f"Target : {milliamps} mA " + f"({round(milliamps * volts / 1000)} W)") + + if milliamps < floor: + print(f"\n{milliamps} mA is below the charger's {MIN_POWER_W} W " + f"minimum and will be rejected.") + return 1 + + if milliamps > ceiling: + print(f"\n{milliamps} mA is above the {ceiling} mA installation " + "rating and will be rejected.") + return 1 + + if not assume_yes and ( + input("\nApply this and leave it set? [yes/no] ").strip().lower() + != "yes" + ): + return 0 + + print(f"\nSetting {milliamps} mA...") + status, body = set_limit(token, serial, milliamps) + print(f" {describe(status, body)}") + + if not 200 <= status < 300: + print("\nThe change was rejected. Nothing was altered.") + return 1 + + time.sleep(5) + _, after = discover(token, email) + readback = after.get("maxExternalChargingCurrentInMilliAmps") + + print(f" read back: {readback} mA") + + if readback == milliamps: + print(f"\nDone. The limit is now {milliamps} mA, about " + f"{round(milliamps * volts / 1000)} W at {volts} V.") + print("Whether the car draws that much is up to the car.") + return 0 + + print(f"\nThe charger accepted the request but reports {readback} mA.") + print("It may still be applying it; check again shortly.") + return 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nAborted.") + sys.exit(3) diff --git a/tools/try_resume.py b/tools/try_resume.py index d17f3d3..6ab0d73 100755 --- a/tools/try_resume.py +++ b/tools/try_resume.py @@ -425,9 +425,10 @@ def retry_mode( print(f"\nBaseline: evseState={state.get('evseState')} " f"isPaused={state.get('isPaused')}") - if not flags.get("assume_yes"): - if input("\nProceed? [yes/no] ").strip().lower() != "yes": - return 0 + if not flags.get("assume_yes") and ( + input("\nProceed? [yes/no] ").strip().lower() != "yes" + ): + return 0 statuses: list[str] = [] From 340357de4bd157f61b92dd24483d4f77d889f326 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Mon, 28 Sep 2026 14:58:08 +0100 Subject: [PATCH 31/82] feat: set the charging limit in watts as well as milliamps Setting 4000 W with tools/set_current.py worked: at 236 V it sent 16900 mA, which the charger accepted and read back unchanged. Power is the figure that makes sense to set. A wallbox is sold as 1.5 to 7.4 kW, and the charger's own floor is a wattage rather than a current, so working in milliamps means converting by hand and risking a value the charger refuses. Add a second number entity exposing the same setting in watts. It converts at the charger's measured voltage and clamps to the accepted range, so a round figure near a boundary is corrected rather than rejected: 100 W becomes the floor, 99999 W becomes the ceiling. Both entities are views of one setting and move together. They share the optimistic display and the background retry, so the power control behaves the same as the current control when the charger is slow or unreachable. Bounds are derived from the current bounds and rounded inward, so every offered wattage converts back to a current the charger accepts. Tests reproduce the verified change directly: 4000 W at 236 V must send 16900 mA and read back 3988 W. The entity suite also caught a missing stub while adding this, which is the suite doing its job. Set the version to 0.1.6 as requested. Co-Authored-By: Claude Opus 5 --- custom_components/daze/manifest.json | 2 +- custom_components/daze/number.py | 215 +++++++++++++++++++- custom_components/daze/payload.py | 46 +++++ custom_components/daze/strings.json | 3 + custom_components/daze/translations/it.json | 4 + tests/test_entities.py | 99 +++++++++ tests/test_payload.py | 68 +++++++ 7 files changed, 434 insertions(+), 3 deletions(-) diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index 3f626a6..15fe8c0 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.15" + "version": "0.1.6" } diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index a9fc3a4..1f5717d 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -16,7 +16,11 @@ from homeassistant.components import persistent_notification from homeassistant.components.number import NumberEntity -from homeassistant.const import EntityCategory, UnitOfElectricCurrent +from homeassistant.const import ( + EntityCategory, + UnitOfElectricCurrent, + UnitOfPower, +) from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -35,9 +39,14 @@ ) from .coordinator import DazeDataUpdateCoordinator from .payload import ( + POWER_STEP_W, max_charging_current, + max_charging_power, + milliamps_to_watts, min_charging_current, + min_charging_power, resolve_optimistic, + watts_to_milliamps, ) if TYPE_CHECKING: @@ -277,6 +286,202 @@ def _notify_error(self, message: str) -> None: ) + +class DazeWallboxPowerEntity( + CoordinatorEntity[DazeDataUpdateCoordinator], NumberEntity +): + """Set the charging limit as a power figure rather than a current. + + The charger's API speaks milliamps, but a wallbox is sold in kW and + the charger's own minimum is a wattage, so power is what a user + thinks in. This is a second view of the same setting: changing + either entity moves the other. + """ + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + _attr_native_step = POWER_STEP_W + _attr_native_unit_of_measurement = UnitOfPower.WATT + + def __init__( + self, + coordinator: DazeDataUpdateCoordinator, + api_client: Any, + serial_number: str, + device_info: DeviceInfo, + ) -> None: + """Initialise the power entity. + + Args: + coordinator: The Daze data coordinator. + api_client: The Daze API client. + serial_number: The wallbox serial number. + device_info: Device info for the wallbox device registry. + + """ + super().__init__(coordinator) + self._api_client = api_client + self._serial_number = serial_number + self._attr_unique_id = f"{serial_number}_max_charging_power" + self._attr_device_info = device_info + self._optimistic_watts: int | None = None + self._optimistic_since: float = 0.0 + self._awaiting_retry: bool = False + + @property + def native_min_value(self) -> float: + """Return the lowest selectable power.""" + return float(min_charging_power(self.coordinator.data)) + + @property + def native_max_value(self) -> float: + """Return the highest selectable power.""" + return float(max_charging_power(self.coordinator.data)) + + @property + def native_value(self) -> int | None: + """Return the configured limit expressed in watts.""" + value, keep = resolve_optimistic( + self._optimistic_watts, self._reported_watts, self._expired + ) + + if not keep: + self._optimistic_watts = None + + return value + + @property + def _reported_watts(self) -> int | None: + """Return the charger's limit converted to watts.""" + if self.coordinator.data is None: + return None + + for field in ( + "maxExternalChargingCurrentInMilliAmps", + "lastMaxChargingCurrent", + ): + value = self.coordinator.data.get(field) + if value is not None: + return milliamps_to_watts(int(value), self.coordinator.data) + + return None + + @property + def _expired(self) -> bool: + """Whether a pending change has been shown for too long.""" + if self._optimistic_watts is None: + return True + if self._awaiting_retry: + return False + return ( + time.monotonic() - self._optimistic_since + > OPTIMISTIC_STATE_TIMEOUT + ) + + async def async_set_native_value(self, value: float) -> None: + """Set the limit from a power figure. + + Converted to the nearest usable current at the charger's + measured voltage, and clamped to the accepted range so a round + figure near a boundary is corrected rather than refused. + """ + milliamps = watts_to_milliamps(value, self.coordinator.data) + watts = milliamps_to_watts(milliamps, self.coordinator.data) + + current = self.coordinator.data or {} + if current.get("maxExternalChargingCurrentInMilliAmps") == milliamps: + _LOGGER.debug( + "Power set to %d W, already at %d mA, skipping", + watts, + milliamps, + ) + return + + try: + _LOGGER.info( + "Setting charging power on %s to %d W (%d mA)", + self._serial_number, + watts, + milliamps, + ) + await self._api_client.async_set_max_charging_current( + self._serial_number, + milliamps, + attempts=INLINE_COMMAND_ATTEMPTS, + ) + self._show_requested(watts, awaiting_retry=False) + except ApiAuthError as err: + _LOGGER.warning( + "Auth error setting power on %s: %s", self._serial_number, err + ) + self._notify_error( + "Authentication failed when trying to set the charging " + "power. Please re-authenticate the integration." + ) + except ApiCommandRejectedError as err: + if err.code == COMMAND_ERROR_CODE_RPC_FAILURE: + self.coordinator.async_retry_in_background( + key=f"{self._serial_number}:current", + action=lambda: self._api_client. + async_set_max_charging_current( + self._serial_number, + milliamps, + attempts=INLINE_COMMAND_ATTEMPTS, + ), + description=f"Setting the charging power to {watts} W", + on_failure=self._clear_requested, + ) + self._show_requested(watts, awaiting_retry=True) + return + + self._notify_error( + f"{err} This charger accepts " + f"{min_charging_power(self.coordinator.data)} to " + f"{max_charging_power(self.coordinator.data)} W." + ) + except ApiError as err: + _LOGGER.warning( + "API error setting power on %s: %s", self._serial_number, err + ) + self._notify_error(f"Failed to set the charging power. {err}") + + def _show_requested(self, watts: int, awaiting_retry: bool) -> None: + """Display a requested power and re-read the charger later.""" + self._optimistic_watts = watts + self._optimistic_since = time.monotonic() + self._awaiting_retry = awaiting_retry + self.async_write_ha_state() + self.coordinator.async_schedule_refresh_in(POST_COMMAND_REFRESH_DELAY) + + def _clear_requested(self, message: str) -> None: + """Drop a pending power and explain why.""" + self._optimistic_watts = None + self._awaiting_retry = False + self.async_write_ha_state() + self._notify_error(message) + + @callback + def _handle_coordinator_update(self) -> None: + """Stop showing the request once the charger reports it.""" + if ( + self._optimistic_watts is not None + and self._reported_watts == self._optimistic_watts + ): + self._optimistic_watts = None + self._awaiting_retry = False + + super()._handle_coordinator_update() + + def _notify_error(self, message: str) -> None: + """Show a persistent notification in the HA frontend.""" + persistent_notification.async_create( + self.hass, + message, + title="Daze Wallbox — Charging Power Error", + notification_id=f"daze_power_error_{self._serial_number}", + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -303,6 +508,12 @@ async def async_setup_entry( api_client=api_client, serial_number=serial_number, device_info=device_info, - ) + ), + DazeWallboxPowerEntity( + coordinator=coordinator, + api_client=api_client, + serial_number=serial_number, + device_info=device_info, + ), ] ) diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index 331462c..4d94149 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -365,3 +365,49 @@ def min_charging_current(data: dict[str, Any] | None) -> int: floor = min(floor, max(int(configured), ABSOLUTE_MIN_CHARGING_CURRENT_MA)) return floor + + +# Charging power is the figure users actually think in: a wallbox is +# sold as 1.5 to 7.4 kW, and the charger's own floor is a wattage. The +# API only accepts milliamps, so the conversion lives here. +POWER_STEP_W = 100 + + +def milliamps_to_watts(milliamps: float, data: dict[str, Any] | None) -> int: + """Convert a charging current to power at the measured voltage.""" + return round(milliamps * supply_voltage(data) / 1000) + + +def watts_to_milliamps(watts: float, data: dict[str, Any] | None) -> int: + """Convert a charging power to current, rounded to a usable step. + + The result is clamped to the range the charger accepts, so a power + figure that rounds just outside it is corrected rather than + rejected. + """ + volts = supply_voltage(data) + raw = watts / volts * 1000 + stepped = int(round(raw / CURRENT_STEP_MA) * CURRENT_STEP_MA) + + return max( + min_charging_current(data), min(max_charging_current(data), stepped) + ) + + +def min_charging_power(data: dict[str, Any] | None) -> int: + """Return the lowest selectable charging power, in watts. + + Rounded up: rounding down would offer a figure that converts back + to a current under the charger's floor. + """ + exact = milliamps_to_watts(min_charging_current(data), data) + return int(-(-exact // POWER_STEP_W) * POWER_STEP_W) + + +def max_charging_power(data: dict[str, Any] | None) -> int: + """Return the highest selectable charging power, in watts. + + Rounded down, for the mirror of the reason above. + """ + exact = milliamps_to_watts(max_charging_current(data), data) + return int(exact // POWER_STEP_W * POWER_STEP_W) diff --git a/custom_components/daze/strings.json b/custom_components/daze/strings.json index 5dc6f98..f3913ee 100644 --- a/custom_components/daze/strings.json +++ b/custom_components/daze/strings.json @@ -134,6 +134,9 @@ "number": { "max_charging_current": { "name": "Max Charging Current" + }, + "max_charging_power": { + "name": "Max Charging Power" } }, "select": { diff --git a/custom_components/daze/translations/it.json b/custom_components/daze/translations/it.json index 4da3332..6aa979b 100644 --- a/custom_components/daze/translations/it.json +++ b/custom_components/daze/translations/it.json @@ -140,6 +140,10 @@ "max_charging_current": { "name": "Corrente massima di carica", "entity_category": "config" + }, + "max_charging_power": { + "name": "Potenza massima di carica", + "entity_category": "config" } }, "select": { diff --git a/tests/test_entities.py b/tests/test_entities.py index 3bd0241..dc970b4 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -134,6 +134,7 @@ def async_create( UnitOfElectricCurrent=type( "UnitOfElectricCurrent", (), {"MILLIAMPERE": "mA"} ), + UnitOfPower=type("UnitOfPower", (), {"WATT": "W"}), ) _module( "homeassistant.core", @@ -472,6 +473,104 @@ def test_new_mode_is_held_while_a_retry_runs() -> None: assert entity.current_option == "eco" + +# ------------------------------------------------------------------ +# The power view of the same setting +# ------------------------------------------------------------------ + + +POWER_DATA: dict[str, Any] = { + "maxExternalChargingCurrentInMilliAmps": 6521, + "lastMaxInstallationCurrent": 32000, + "lastACVoltageL1": 236, +} + + +def make_power( + error: Exception | None = None, +) -> tuple[Any, FakeCoordinator, FakeApi]: + """Build a power entity wired to fakes.""" + coordinator = FakeCoordinator(dict(POWER_DATA)) + client = FakeApi(error) + entity = number_module.DazeWallboxPowerEntity( + coordinator=coordinator, + api_client=client, + serial_number="SER1", + device_info={}, + ) + return entity, coordinator, client + + +def test_power_entity_reports_the_limit_in_watts() -> None: + """6521 mA at 236 V is about 1539 W.""" + entity, _, _ = make_power() + assert entity.native_value == 1539 + + +def test_setting_power_sends_the_converted_current() -> None: + """Reproduces the change verified against the charger. + + Asking for 4000 W at 236 V sent 16900 mA, which was accepted and + read back unchanged. + """ + entity, _, client = make_power() + + asyncio.run(entity.async_set_native_value(4000)) + + assert client.calls == [("current", 16900)] + assert entity.native_value == 3988 + + +def test_power_entity_shows_the_request_immediately() -> None: + """Same display rule as the current entity.""" + entity, coordinator, _ = make_power() + + asyncio.run(entity.async_set_native_value(4000)) + + # The charger still reports the old current. + assert coordinator.data["maxExternalChargingCurrentInMilliAmps"] == 6521 + assert entity.native_value == 3988 + + +def test_power_entity_bounds_come_from_the_charger() -> None: + """1.5 kW floor and the installation rating, at 236 V.""" + entity, _, _ = make_power() + + assert entity.native_min_value == 1600 + assert entity.native_max_value == 7500 + + +def test_power_entity_retries_an_unreachable_charger() -> None: + """Shares the background retry with the current entity.""" + notifications.clear() + entity, coordinator, _ = make_power(error=rpc_failure()) + + asyncio.run(entity.async_set_native_value(4000)) + + assert len(coordinator.background) == 1 + assert entity.native_value == 3988 + assert not notifications + + +def test_power_and_current_entities_agree() -> None: + """They are two views of one setting and must not disagree.""" + power, coordinator, _ = make_power() + current = number_module.DazeWallboxNumberEntity( + coordinator=coordinator, + api_client=FakeApi(), + serial_number="SER1", + device_info={}, + ) + + assert current.native_value == 6521 + assert power.native_value == 1539 + + coordinator.data["maxExternalChargingCurrentInMilliAmps"] = 16900 + + assert current.native_value == 16900 + assert power.native_value == 3988 + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tests/test_payload.py b/tests/test_payload.py index 0c3c299..570e6ec 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -662,6 +662,74 @@ def test_measured_voltage_still_wins_when_available() -> None: assert payload.min_charging_current(charging) == 6500 + +# ------------------------------------------------------------------ +# Power view of the charging limit +# ------------------------------------------------------------------ + + +MEASURED = { + "lastACVoltageL1": 236, + "lastMaxInstallationCurrent": 32000, + "maxExternalChargingCurrentInMilliAmps": 16900, +} + + +def test_power_conversion_matches_the_verified_setting() -> None: + """Reproduces a change confirmed against the charger. + + Asking for 4000 W at 236 V produced 16900 mA, which the charger + accepted and read back. + """ + assert payload.watts_to_milliamps(4000, MEASURED) == 16900 + assert payload.milliamps_to_watts(16900, MEASURED) == 3988 + + +def test_power_request_is_clamped_to_the_accepted_range() -> None: + """A round figure near a boundary is corrected, not refused.""" + assert payload.watts_to_milliamps(100, MEASURED) == ( + payload.min_charging_current(MEASURED) + ) + assert payload.watts_to_milliamps(99999, MEASURED) == ( + payload.max_charging_current(MEASURED) + ) + + +def test_power_bounds_stay_inside_the_current_bounds() -> None: + """Offering a wattage that converts outside the range would fail.""" + for volts in (220, 230, 236, 245): + data = {"lastACVoltageL1": volts, "lastMaxInstallationCurrent": 32000} + + low_w = payload.min_charging_power(data) + high_w = payload.max_charging_power(data) + + assert payload.watts_to_milliamps(low_w, data) >= ( + payload.min_charging_current(data) + ) + assert payload.watts_to_milliamps(high_w, data) <= ( + payload.max_charging_current(data) + ) + + +def test_power_bounds_clear_the_charger_floor() -> None: + """The lowest offered wattage must still be at least 1500 W.""" + for volts in (220, 230, 236, 245): + data = {"lastACVoltageL1": volts, "lastMaxInstallationCurrent": 32000} + assert ( + payload.min_charging_power(data) + >= payload.MIN_CHARGING_POWER_W + ) + + +def test_power_round_trips_within_a_step() -> None: + """Converting to current and back must not drift.""" + for watts in (1600, 2000, 3000, 4000, 5500, 7000): + milliamps = payload.watts_to_milliamps(watts, MEASURED) + back = payload.milliamps_to_watts(milliamps, MEASURED) + # One current step at 236 V is about 24 W. + assert abs(back - watts) <= 30, (watts, milliamps, back) + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From e6fd2dca30408ba60d3ec6899d26c0cedb2c3964 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Mon, 28 Sep 2026 15:07:26 +0100 Subject: [PATCH 32/82] feat: reject out-of-range currents before sending them A value outside the charger's range costs a round trip to learn: the API answers HTTP 422 with MaxExternalChargingCurrentOutOfRange, and with the RPC link being what it is that can take seconds. The bounds are already known locally, so the request can be stopped immediately and the user told exactly what is wrong. Both number entities now validate before calling the API. Below the floor names the power minimum and what it works out to at the measured voltage; above the ceiling names the installation rating. supplyGridMaxPower is treated as advisory rather than as a bound, and deliberately so. It is the household supply the charger balances against when dynamic power management is on, and it does not make the API refuse anything: a charger reporting a 3000 W cap accepted a 7552 W limit during range probing, and a 4000 W setting was applied and read back successfully. Capping the control at it would have removed settings that demonstrably work. Exceeding it is logged as a note that the charger will throttle the draw rather than honour the figure. The power entity clamps rather than refuses, matching the tool: asking for 500 W charges at the lowest legal rate instead of failing. Its validation is a guard against inconsistent bounds, not the usual path, and a test pins that whatever is requested, what gets sent is always acceptable. Co-Authored-By: Claude Opus 5 --- custom_components/daze/number.py | 28 +++++++++ custom_components/daze/payload.py | 96 ++++++++++++++++++++++++++++++ tests/test_entities.py | 99 +++++++++++++++++++++++++++++++ tests/test_payload.py | 38 ++++++++++++ 4 files changed, 261 insertions(+) diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index 1f5717d..4a773c6 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -40,12 +40,14 @@ from .coordinator import DazeDataUpdateCoordinator from .payload import ( POWER_STEP_W, + grid_cap_advice, max_charging_current, max_charging_power, milliamps_to_watts, min_charging_current, min_charging_power, resolve_optimistic, + validate_charging_current, watts_to_milliamps, ) @@ -214,6 +216,18 @@ async def async_set_native_value(self, value: float) -> None: ) return + # Stop here rather than spending a round trip on a value the + # charger is known to reject. + problem = validate_charging_current(int_value, self.coordinator.data) + if problem is not None: + _LOGGER.info("Refusing to send %d mA: %s", int_value, problem) + self._notify_error(problem) + return + + advice = grid_cap_advice(int_value, self.coordinator.data) + if advice is not None: + _LOGGER.info("%s", advice) + try: _LOGGER.info( "Setting max charging current on %s to %d mA", @@ -397,6 +411,20 @@ async def async_set_native_value(self, value: float) -> None: ) return + problem = validate_charging_current(milliamps, self.coordinator.data) + if problem is not None: + _LOGGER.info("Refusing to send %d W: %s", watts, problem) + self._notify_error( + f"{watts} W is outside the range this charger accepts " + f"({min_charging_power(self.coordinator.data)} to " + f"{max_charging_power(self.coordinator.data)} W)." + ) + return + + advice = grid_cap_advice(milliamps, self.coordinator.data) + if advice is not None: + _LOGGER.info("%s", advice) + try: _LOGGER.info( "Setting charging power on %s to %d W (%d mA)", diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index 4d94149..9c2559a 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -411,3 +411,99 @@ def max_charging_power(data: dict[str, Any] | None) -> int: """ exact = milliamps_to_watts(max_charging_current(data), data) return int(exact // POWER_STEP_W * POWER_STEP_W) + + +def grid_power_limit(data: dict[str, Any] | None) -> int | None: + """Return the grid supply cap in watts, if the charger reports one. + + supplyGridMaxPower is the household supply the charger balances + against when dynamic power management is on. It does not make the + API reject a higher setting: a charger reporting 3000 W here + accepted a 7552 W limit without complaint. What it does mean is + that the charger will throttle the actual draw, so asking for more + achieves nothing. + + Treated as advisory for that reason, not as a hard bound. + + Args: + data: The merged payload, or None. + + Returns: + The cap in watts, or None if none is reported or it is not in + force. + + """ + if not data or not data.get("dpm"): + return None + + value = data.get("supplyGridMaxPower") + if isinstance(value, (int, float)) and value > 0: + return int(value) + + return None + + +def validate_charging_current( + milliamps: int, data: dict[str, Any] | None +) -> str | None: + """Check a current against the bounds before it is sent. + + The API answers a value outside its range with HTTP 422 and + MaxExternalChargingCurrentOutOfRange after a round trip. The bounds + are already known locally, so the round trip is avoidable and the + user gets an immediate, specific answer instead. + + Args: + milliamps: The requested current. + data: The merged payload, or None. + + Returns: + None if the value is acceptable, otherwise an explanation. + + """ + floor = min_charging_current(data) + ceiling = max_charging_current(data) + volts = supply_voltage(data) + + if milliamps < floor: + return ( + f"{milliamps} mA is below the {floor} mA minimum this charger " + f"accepts. It enforces a {MIN_CHARGING_POWER_W} W floor, which " + f"is {floor} mA at {volts} V." + ) + + if milliamps > ceiling: + return ( + f"{milliamps} mA is above the {ceiling} mA the installation is " + f"rated for." + ) + + return None + + +def grid_cap_advice(milliamps: int, data: dict[str, Any] | None) -> str | None: + """Warn when a value exceeds the grid supply the charger balances to. + + Not a rejection: the charger accepts the setting and then limits + what it actually draws. + + Args: + milliamps: The requested current. + data: The merged payload, or None. + + Returns: + A note if the request exceeds the grid cap, otherwise None. + + """ + cap = grid_power_limit(data) + if cap is None: + return None + + requested = milliamps_to_watts(milliamps, data) + if requested <= cap: + return None + + return ( + f"Requested {requested} W, but this charger balances against a " + f"{cap} W supply limit, so it will not draw more than that." + ) diff --git a/tests/test_entities.py b/tests/test_entities.py index dc970b4..ffdb311 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -571,6 +571,105 @@ def test_power_and_current_entities_agree() -> None: assert power.native_value == 3988 + +# ------------------------------------------------------------------ +# Refusing bad values without a round trip +# ------------------------------------------------------------------ + + +GRID_LIMITED: dict[str, Any] = { + "maxExternalChargingCurrentInMilliAmps": 16900, + "lastMaxInstallationCurrent": 32000, + "lastACVoltageL1": 236, + "supplyGridMaxPower": 3000, + "dpm": True, +} + + +def test_a_current_below_the_floor_is_never_sent() -> None: + """The API answers 422 for this; the bounds are already known.""" + notifications.clear() + entity, _, client = make_number(data=GRID_LIMITED) + + asyncio.run(entity.async_set_native_value(6000)) + + assert client.calls == [], "a known-bad value must not reach the API" + assert len(notifications) == 1 + assert "below" in notifications[0]["message"] + + +def test_a_current_above_the_installation_rating_is_never_sent() -> None: + """Same, at the other end.""" + notifications.clear() + entity, _, client = make_number(data=GRID_LIMITED) + + asyncio.run(entity.async_set_native_value(40000)) + + assert client.calls == [] + assert len(notifications) == 1 + assert "above" in notifications[0]["message"] + + +def test_a_valid_current_is_still_sent() -> None: + """The guard must not block values the charger accepts.""" + notifications.clear() + entity, _, client = make_number(data=GRID_LIMITED) + + asyncio.run(entity.async_set_native_value(20000)) + + assert client.calls == [("current", 20000)] + assert not notifications + + +def test_exceeding_the_grid_cap_is_advisory_not_blocking() -> None: + """The charger accepts it and throttles the draw instead. + + A 7552 W limit was accepted by a charger reporting a 3000 W supply + cap, so refusing to send it would be wrong. + """ + notifications.clear() + entity, _, client = make_number(data=GRID_LIMITED) + + asyncio.run(entity.async_set_native_value(32000)) + + assert client.calls == [("current", 32000)] + assert not notifications, "the grid cap must not raise an error" + + +def test_power_entity_clamps_rather_than_refusing() -> None: + """A wattage outside the range is corrected, not rejected. + + watts_to_milliamps clamps before the value is validated, which is + deliberate: a round figure near a boundary should charge at the + nearest legal rate rather than fail. The validation behind it is a + guard against inconsistent bounds, not the primary path. + """ + notifications.clear() + entity, coordinator, client = make_power() + + asyncio.run(entity.async_set_native_value(500)) + + floor_ma = number_module.min_charging_current(coordinator.data) + assert client.calls == [("current", floor_ma)] + assert not notifications + + +def test_power_entity_never_sends_below_the_charger_floor() -> None: + """Whatever is asked for, the sent value must be acceptable.""" + for requested in (0, 100, 500, 1000, 1400): + notifications.clear() + entity, coordinator, client = make_power() + + asyncio.run(entity.async_set_native_value(requested)) + + assert len(client.calls) == 1, requested + sent = client.calls[0][1] + assert ( + number_module.validate_charging_current(sent, coordinator.data) + is None + ), (requested, sent) + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tests/test_payload.py b/tests/test_payload.py index 570e6ec..6f65566 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -730,6 +730,44 @@ def test_power_round_trips_within_a_step() -> None: assert abs(back - watts) <= 30, (watts, milliamps, back) + +def test_validation_matches_the_measured_boundaries() -> None: + """Reproduces the ladder result: 6400 rejected, 6521 accepted.""" + data = {"lastACVoltageL1": 232, "lastMaxInstallationCurrent": 32000} + + assert payload.validate_charging_current(6000, data) is not None + assert payload.validate_charging_current(6400, data) is not None + assert payload.validate_charging_current(6521, data) is None + assert payload.validate_charging_current(32000, data) is None + assert payload.validate_charging_current(40000, data) is not None + + +def test_grid_cap_is_reported_only_when_in_force() -> None: + """Without dynamic power management the cap does not apply.""" + on = {"supplyGridMaxPower": 3000, "dpm": True} + off = {"supplyGridMaxPower": 3000, "dpm": False} + + assert payload.grid_power_limit(on) == 3000 + assert payload.grid_power_limit(off) is None + assert payload.grid_power_limit({}) is None + assert payload.grid_power_limit(None) is None + + +def test_grid_cap_advice_only_fires_above_the_cap() -> None: + """Silence below it; a note above it, never an error.""" + data = { + "lastACVoltageL1": 236, + "supplyGridMaxPower": 3000, + "dpm": True, + "lastMaxInstallationCurrent": 32000, + } + + assert payload.grid_cap_advice(10000, data) is None + advice = payload.grid_cap_advice(16900, data) + assert advice is not None + assert "3000 W" in advice + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 8efdad0715c7481cb13f3a02285d5f314ebca441 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Mon, 28 Sep 2026 16:36:07 +0100 Subject: [PATCH 33/82] fix: act on the code review's behavioural findings A review of everything this fork changed found fifteen issues. These are the ones that produce wrong charger behaviour rather than a wrong display. The cached EVSE record defeated the optimistic state it was meant to support. maxExternalChargingCurrentInMilliAmps and ecoModeEnabled exist only in that record, which was cached for two minutes, while remoteInfo was re-read every poll. So the post-command refresh merged a stale copy, the entity never saw its own change confirmed, and the value reverted for up to two minutes. Commands now drop that cache before refreshing. A successful command did not cancel a queued retry for the same control. Stopping a charge while the link was down queued retries over about eight minutes; starting it again thirty seconds later succeeded, and the queued retry then stopped the charge again with no user action. Each successful command now supersedes any pending retry. The session throttle was armed before the request rather than after, and a transient error returned an empty list that overwrote a good history for five minutes. lifetime_energy is total_increasing, so dropping to zero made Home Assistant record a meter reset and double count on recovery. Failures now return None, the previous history is kept, and a shorter retry is scheduled instead. The optimistic value was held indefinitely while a retry was pending. If that chain was superseded its callbacks never fired, pinning the entity to a stale request until a restart. The hold is now capped at the retry window plus a minute. The power entity compared derived watts for exact equality, but both sides come from a fluctuating voltage reading, so a one volt drift meant the comparison never matched. Compared within one step now. The charge switch ignored a pending retry entirely, so its toggle flipped back after twenty seconds while the retry was still running, which is what the optimistic state exists to prevent. nextScheduleInfo is an object, and merge_payload keeps it as one, but it was fed straight to a timestamp sensor. Home Assistant would have rejected every state write with "Invalid datetime" as soon as a schedule was set. The timestamp is now extracted and non-scalars are discarded. Version deliberately unchanged. Co-Authored-By: Claude Opus 5 --- custom_components/daze/const.py | 6 +++ custom_components/daze/coordinator.py | 59 ++++++++++++++++++------ custom_components/daze/number.py | 33 +++++++++---- custom_components/daze/select.py | 15 ++++-- custom_components/daze/sensor_catalog.py | 35 +++++++++++++- custom_components/daze/switch.py | 25 +++++++++- tests/test_payload.py | 27 +++++++++++ 7 files changed, 168 insertions(+), 32 deletions(-) diff --git a/custom_components/daze/const.py b/custom_components/daze/const.py index afc48ed..4f6015c 100644 --- a/custom_components/daze/const.py +++ b/custom_components/daze/const.py @@ -66,6 +66,12 @@ # user waiting on any of it. BACKGROUND_RETRY_DELAYS = (15, 30, 60, 120, 240) +# An optimistic value waiting on a background retry is held past +# the usual timeout, but not forever: if the retry chain is +# superseded its callbacks never fire, and without this cap the +# entity would show a stale request until Home Assistant restarts. +MAX_OPTIMISTIC_HOLD = sum(BACKGROUND_RETRY_DELAYS) + 60 + # Attempts made while the user waits, before handing off to the # background. Kept short: a healthy link answers on the first try. INLINE_COMMAND_ATTEMPTS = 3 diff --git a/custom_components/daze/coordinator.py b/custom_components/daze/coordinator.py index 247ea1d..3ed2989 100644 --- a/custom_components/daze/coordinator.py +++ b/custom_components/daze/coordinator.py @@ -49,6 +49,10 @@ # of retrying every poll and filling the log with warnings. SESSION_MISSING_RETRY_INTERVAL = 3600 # seconds +# After a transient failure, try again sooner than the normal +# interval but not on every poll. +SESSION_ERROR_RETRY_INTERVAL = 60 # seconds + # The charger record holds configuration and slow-moving readings, # so it does not need the live metric cadence. EVSE_FETCH_INTERVAL = 120 # seconds @@ -259,6 +263,11 @@ def async_schedule_refresh_in(self, delay: int) -> None: Scheduled, not awaited: the caller returns immediately. """ + # The charging current and eco mode live only in the EVSE + # record, which is cached. Without dropping that cache the + # refresh re-reads a stale copy and the entity reverts. + self._next_evse_fetch = 0.0 + async def _refresh(_now: Any) -> None: """Ask the coordinator to re-read the charger.""" _LOGGER.debug( @@ -266,6 +275,7 @@ async def _refresh(_now: Any) -> None: self._serial_number, delay, ) + self._next_evse_fetch = 0.0 await self.async_request_refresh() async_call_later(self.hass, delay, _refresh) @@ -280,6 +290,8 @@ def async_schedule_settle_refresh(self) -> None: Scheduled, not awaited: the caller returns immediately. """ + self._next_evse_fetch = 0.0 + for delay in SETTLE_REFRESH_DELAYS: async def _refresh(_now: Any, _delay: int = delay) -> None: @@ -289,6 +301,7 @@ async def _refresh(_now: Any, _delay: int = delay) -> None: self._serial_number, _delay, ) + self._next_evse_fetch = 0.0 await self.async_request_refresh() async_call_later(self.hass, delay, _refresh) @@ -376,7 +389,9 @@ async def _async_update_data(self) -> DazeCoordinatorData: # Fetch session data (secondary — failures are non-fatal). # Throttled: history only changes when a charge ends. if time.time() >= self._next_session_fetch: - self._cached_sessions = await self._async_fetch_sessions() + fetched = await self._async_fetch_sessions() + if fetched is not None: + self._cached_sessions = fetched sessions = self._cached_sessions data["sessions"] = sessions @@ -441,19 +456,19 @@ def _compute_session_fields( async def _async_fetch_sessions( self, - ) -> list[RechargeSession]: + ) -> list[RechargeSession] | None: """Fetch recharge session history. - Failures are logged and return an empty list — the coordinator - continues to work with live socket data even if sessions are - temporarily unavailable. + Returns None on failure rather than an empty list. An empty + list is a real answer meaning "no sessions", and assigning it + over a good history resets lifetime_energy to zero. That sensor + is total_increasing, so Home Assistant reads the drop as a + meter reset and double counts on recovery. Returns: - A list of RechargeSession objects (may be empty). + The sessions, or None if they could not be fetched. """ - self._next_session_fetch = time.time() + SESSION_FETCH_INTERVAL - try: sessions_raw = ( await self._api_client.async_get_recharge_sessions( @@ -466,6 +481,9 @@ async def _async_fetch_sessions( self._network_uid, ) self._sessions_missing_logged = False + # Armed only on success: arming first meant a transient + # error silently froze the history for five minutes. + self._next_session_fetch = time.time() + SESSION_FETCH_INTERVAL return [ RechargeSession.from_dict(s) for s in sessions_raw ] @@ -487,6 +505,8 @@ async def _async_fetch_sessions( self._network_uid, ) + # A missing endpoint genuinely means no history, unlike a + # transient error, so an empty list is the right answer. return [] except ApiAuthError: @@ -494,27 +514,36 @@ async def _async_fetch_sessions( # socket fetch already validated the token), but handle # gracefully — don't double-trigger re-auth. _LOGGER.warning( - "Auth error fetching sessions for %s — sessions " - "unavailable until next poll", + "Auth error fetching sessions for %s — keeping the " + "previous history", self._serial_number, ) - return [] + self._next_session_fetch = ( + time.time() + SESSION_ERROR_RETRY_INTERVAL + ) + return None except ApiError as err: _LOGGER.warning( - "API error fetching sessions for %s: %s — " - "sessions unavailable until next poll", + "API error fetching sessions for %s: %s — keeping the " + "previous history", self._serial_number, err, ) - return [] + self._next_session_fetch = ( + time.time() + SESSION_ERROR_RETRY_INTERVAL + ) + return None except Exception: _LOGGER.exception( "Unexpected error fetching sessions for %s", self._serial_number, ) - return [] + self._next_session_fetch = ( + time.time() + SESSION_ERROR_RETRY_INTERVAL + ) + return None async def async_setup_coordinator( diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index 4a773c6..4e305af 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -34,6 +34,7 @@ from .const import ( DOMAIN, INLINE_COMMAND_ATTEMPTS, + MAX_OPTIMISTIC_HOLD, OPTIMISTIC_STATE_TIMEOUT, POST_COMMAND_REFRESH_DELAY, ) @@ -166,10 +167,13 @@ def _expired(self) -> bool: # While a background retry is still running the request is # genuinely outstanding, so keep showing it. + held = time.monotonic() - self._optimistic_since + if self._awaiting_retry: - return False + # Capped: a superseded retry chain never reports back, so + # without this the value would stick until a restart. + return held > MAX_OPTIMISTIC_HOLD - held = time.monotonic() - self._optimistic_since return held > OPTIMISTIC_STATE_TIMEOUT def _show_requested(self, value: int, awaiting_retry: bool) -> None: @@ -239,6 +243,9 @@ async def async_set_native_value(self, value: float) -> None: int_value, attempts=INLINE_COMMAND_ATTEMPTS, ) + self.coordinator.async_cancel_background_retry( + f"{self._serial_number}:current" + ) self._show_requested(int_value, awaiting_retry=False) except ApiAuthError as err: _LOGGER.warning( @@ -385,12 +392,13 @@ def _expired(self) -> bool: """Whether a pending change has been shown for too long.""" if self._optimistic_watts is None: return True + + held = time.monotonic() - self._optimistic_since + if self._awaiting_retry: - return False - return ( - time.monotonic() - self._optimistic_since - > OPTIMISTIC_STATE_TIMEOUT - ) + return held > MAX_OPTIMISTIC_HOLD + + return held > OPTIMISTIC_STATE_TIMEOUT async def async_set_native_value(self, value: float) -> None: """Set the limit from a power figure. @@ -437,6 +445,9 @@ async def async_set_native_value(self, value: float) -> None: milliamps, attempts=INLINE_COMMAND_ATTEMPTS, ) + self.coordinator.async_cancel_background_retry( + f"{self._serial_number}:current" + ) self._show_requested(watts, awaiting_retry=False) except ApiAuthError as err: _LOGGER.warning( @@ -491,9 +502,15 @@ def _clear_requested(self, message: str) -> None: @callback def _handle_coordinator_update(self) -> None: """Stop showing the request once the charger reports it.""" + reported = self._reported_watts if ( self._optimistic_watts is not None - and self._reported_watts == self._optimistic_watts + and reported is not None + # Compared with tolerance: both sides are derived from the + # live voltage, so a 1 V drift between the command and the + # next poll changes the figure and exact equality never + # holds. + and abs(reported - self._optimistic_watts) <= POWER_STEP_W ): self._optimistic_watts = None self._awaiting_retry = False diff --git a/custom_components/daze/select.py b/custom_components/daze/select.py index 0c92057..28ecbff 100644 --- a/custom_components/daze/select.py +++ b/custom_components/daze/select.py @@ -30,6 +30,7 @@ from .const import ( DOMAIN, INLINE_COMMAND_ATTEMPTS, + MAX_OPTIMISTIC_HOLD, OPTIMISTIC_STATE_TIMEOUT, POST_COMMAND_REFRESH_DELAY, ) @@ -144,12 +145,13 @@ def _expired(self) -> bool: """Whether a pending change has been shown for too long.""" if self._optimistic_option is None: return True + held = time.monotonic() - self._optimistic_since + if self._awaiting_retry: - return False - return ( - time.monotonic() - self._optimistic_since - > OPTIMISTIC_STATE_TIMEOUT - ) + # Capped: a superseded retry chain never reports back, so + # without this the value would stick until a restart. + return held > MAX_OPTIMISTIC_HOLD + return held > OPTIMISTIC_STATE_TIMEOUT def _show_requested(self, option: str, awaiting_retry: bool) -> None: """Display a requested mode and re-read the charger later.""" @@ -219,6 +221,9 @@ async def async_select_option(self, option: str) -> None: eco_value, attempts=INLINE_COMMAND_ATTEMPTS, ) + self.coordinator.async_cancel_background_retry( + f"{self._serial_number}:mode" + ) self._show_requested(option, awaiting_retry=False) except ApiAuthError as err: _LOGGER.warning( diff --git a/custom_components/daze/sensor_catalog.py b/custom_components/daze/sensor_catalog.py index 18c3405..b619952 100644 --- a/custom_components/daze/sensor_catalog.py +++ b/custom_components/daze/sensor_catalog.py @@ -66,12 +66,43 @@ def presence_on_off(data: dict[str, Any], key: str) -> str | None: ) +# Fields a schedule object might carry the start time under. The +# charger reported nextScheduleInfo as null whenever it was observed, +# so the shape is unconfirmed and every candidate is tried. +_SCHEDULE_TIME_FIELDS = ( + "startTime", + "start", + "scheduledStart", + "nextStart", + "time", +) + + def get_next_scheduled_charge(data: dict[str, Any]) -> Any | None: - """Return the first present schedule timestamp field.""" + """Return the next scheduled charge time, if one is set. + + nextScheduleInfo arrives as an object rather than a timestamp, and + merge_payload preserves it as one. Handing that object to a + timestamp sensor makes Home Assistant reject every state write with + "Invalid datetime", so the timestamp is extracted from it and + anything that is not a scalar is discarded. + """ for key in _SCHEDULED_CHARGE_KEYS: value = data.get(key) - if value is not None: + + if value is None: + continue + + if isinstance(value, dict): + for field in _SCHEDULE_TIME_FIELDS: + nested = value.get(field) + if isinstance(nested, (str, int, float)): + return nested + continue + + if isinstance(value, (str, int, float)): return value + return None diff --git a/custom_components/daze/switch.py b/custom_components/daze/switch.py index 302caf5..9b0f913 100644 --- a/custom_components/daze/switch.py +++ b/custom_components/daze/switch.py @@ -30,6 +30,7 @@ from .const import ( DOMAIN, INLINE_COMMAND_ATTEMPTS, + MAX_OPTIMISTIC_HOLD, OPTIMISTIC_STATE_TIMEOUT, POST_COMMAND_REFRESH_DELAY, ) @@ -76,6 +77,7 @@ def __init__( self._attr_device_info = device_info self._optimistic_state: bool | None = None self._optimistic_since: float = 0.0 + self._awaiting_retry: bool = False @property def is_on(self) -> bool | None: @@ -109,7 +111,14 @@ def _optimistic_expired(self) -> bool: """Whether the optimistic value has been held too long.""" if self._optimistic_state is None: return True + held = time.monotonic() - self._optimistic_since + + if self._awaiting_retry: + # A queued retry means the request is still outstanding. + # Capped, because a superseded chain never reports back. + return held > MAX_OPTIMISTIC_HOLD + return held > OPTIMISTIC_STATE_TIMEOUT @property @@ -117,7 +126,9 @@ def assumed_state(self) -> bool: """Tell the frontend when the shown state is a guess.""" return self._optimistic_state is not None - def _set_optimistic(self, value: bool) -> None: + def _set_optimistic( + self, value: bool, awaiting_retry: bool = False + ) -> None: """Show the commanded state now and re-read the charger later. Refreshing immediately is worse than not refreshing at all: the @@ -126,6 +137,7 @@ def _set_optimistic(self, value: bool) -> None: """ self._optimistic_state = value self._optimistic_since = time.monotonic() + self._awaiting_retry = awaiting_retry self.async_write_ha_state() self.coordinator.async_schedule_refresh_in( POST_COMMAND_REFRESH_DELAY @@ -142,6 +154,7 @@ def _handle_coordinator_update(self) -> None: ) if actual == self._optimistic_state or self._optimistic_expired: self._optimistic_state = None + self._awaiting_retry = False super()._handle_coordinator_update() @@ -163,6 +176,11 @@ async def async_turn_on(self, **kwargs: Any) -> None: await self._api_client.async_start_charge( self._serial_number, attempts=INLINE_COMMAND_ATTEMPTS ) + # Supersede any queued retry, or it would re-apply the + # opposite command minutes from now. + self.coordinator.async_cancel_background_retry( + f"{self._serial_number}:charge" + ) self._set_optimistic(True) except ApiAuthError as err: _LOGGER.warning( @@ -211,6 +229,9 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self._api_client.async_stop_charge( self._serial_number, attempts=INLINE_COMMAND_ATTEMPTS ) + self.coordinator.async_cancel_background_retry( + f"{self._serial_number}:charge" + ) self._set_optimistic(False) except ApiAuthError as err: _LOGGER.warning( @@ -275,7 +296,7 @@ def _retry_in_background( ) # Show the intent while the retries run. - self._set_optimistic(turn_on) + self._set_optimistic(turn_on, awaiting_retry=True) return True def _notify_error(self, message: str) -> None: diff --git a/tests/test_payload.py b/tests/test_payload.py index 6f65566..67e28af 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -768,6 +768,33 @@ def test_grid_cap_advice_only_fires_above_the_cap() -> None: assert "3000 W" in advice + +def test_schedule_object_does_not_reach_the_timestamp_sensor() -> None: + """nextScheduleInfo is an object, not a timestamp. + + merge_payload preserves it as one, and a timestamp sensor rejects + a dict with "Invalid datetime" on every state write. + """ + with_schedule = payload.merge_payload( + {"nextScheduleInfo": {"startTime": "2026-09-28T02:00:00Z"}}, None + ) + value = catalog.get_next_scheduled_charge(with_schedule) + assert value == "2026-09-28T02:00:00Z" + assert not isinstance(value, dict) + + +def test_unknown_schedule_shape_yields_nothing() -> None: + """An object with no recognised time field must not be published.""" + data = payload.merge_payload({"nextScheduleInfo": {"foo": "bar"}}, None) + assert catalog.get_next_scheduled_charge(data) is None + + +def test_absent_schedule_yields_nothing() -> None: + """The charger reports null whenever nothing is scheduled.""" + data = payload.merge_payload({"nextScheduleInfo": None}, None) + assert catalog.get_next_scheduled_charge(data) is None + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 9d3b2fd464ea8376fe7b3f9189cdcb2924b1e688 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Mon, 28 Sep 2026 18:00:45 +0100 Subject: [PATCH 34/82] fix: cancel scheduled work on unload, and unify the optimistic state Two more findings from the review. Settle refreshes and background retries outlived the coordinator that scheduled them. Nothing cancelled them on unload, and an options change reloads the entry, so saving a new poll interval left up to four refresh timers and an eight minute retry chain firing against a discarded coordinator and a closed API client. Home Assistant reports those as lingering timers. Every scheduled callback is now tracked and cancelled when the entry unloads. Consolidating the two scheduling paths also removed a late-binding bug: the settle loop closed over its loop variable, so each callback would have discarded the last handle rather than its own. The optimistic display logic existed in four near-identical copies, and three review findings were the copies drifting apart: the switch ignored a pending retry so its toggle flipped back after twenty seconds, the power entity compared derived watts for exact equality when both sides are recomputed from a fluctuating voltage, and a superseded retry chain could pin any of them to a stale value until a restart. Replace all four with one OptimisticState class. It takes a tolerance, which is what the power entity needed, caps how long a value can be held when a retry is pending, and imports nothing from Home Assistant so it is directly testable. The four entities now hold one of these rather than repeating the logic. Remove payload.resolve_optimistic, which it supersedes, so there is one implementation rather than two. Version deliberately unchanged. Co-Authored-By: Claude Opus 5 --- custom_components/daze/__init__.py | 8 ++ custom_components/daze/coordinator.py | 80 ++++++++++----- custom_components/daze/number.py | 99 +++--------------- custom_components/daze/optimistic.py | 141 ++++++++++++++++++++++++++ custom_components/daze/payload.py | 42 -------- custom_components/daze/select.py | 46 ++------- custom_components/daze/switch.py | 56 +++------- tests/test_entities.py | 89 +++++++++++++++- tests/test_payload.py | 82 --------------- 9 files changed, 322 insertions(+), 321 deletions(-) create mode 100644 custom_components/daze/optimistic.py diff --git a/custom_components/daze/__init__.py b/custom_components/daze/__init__.py index 6b44d6a..a62cd74 100644 --- a/custom_components/daze/__init__.py +++ b/custom_components/daze/__init__.py @@ -92,6 +92,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a Daze Wallbox config entry.""" _LOGGER.debug("Unloading Daze Wallbox config entry %s", entry.entry_id) + # Stop anything the coordinator has scheduled before tearing the + # entry down. An options change reloads the entry, so without this + # the old coordinator keeps firing against a closed client. + entry_data = hass.data.get(DOMAIN, {}).get(entry.entry_id) + if entry_data is not None: + coordinator: DazeDataUpdateCoordinator = entry_data["coordinator"] + coordinator.async_shutdown_timers() + # Unload entity platforms unload_ok = await hass.config_entries.async_unload_platforms( entry, PLATFORMS diff --git a/custom_components/daze/coordinator.py b/custom_components/daze/coordinator.py index 3ed2989..d0d76d9 100644 --- a/custom_components/daze/coordinator.py +++ b/custom_components/daze/coordinator.py @@ -110,6 +110,7 @@ def __init__( self._next_session_fetch: float = 0.0 self._sessions_missing_logged: bool = False self._pending_retries: dict[str, Callable[[], None]] = {} + self._pending_timers: set[Callable[[], None]] = set() super().__init__( hass, @@ -247,38 +248,76 @@ def _cancel() -> None: ) _schedule(attempts[0]) + def async_shutdown_timers(self) -> None: + """Cancel every callback this coordinator has scheduled. + + Settle refreshes and background retries outlive the code that + scheduled them. Unloading the entry, which also happens on + every options change, otherwise leaves them firing against a + discarded coordinator and a closed API client. Home Assistant + reports those as lingering timers. + """ + for cancel in list(self._pending_timers): + cancel() + self._pending_timers.clear() + + for key in list(self._pending_retries): + self.async_cancel_background_retry(key) + + _LOGGER.debug("Cancelled pending timers for %s", self._serial_number) + def async_cancel_background_retry(self, key: str) -> None: """Drop any pending background retry for a command.""" cancel = self._pending_retries.pop(key, None) if cancel is not None: cancel() - def async_schedule_refresh_in(self, delay: int) -> None: - """Re-read the charger once, after a delay. + def _schedule_tracked_refresh(self, delay: int, reason: str) -> None: + """Schedule one refresh and keep a handle so it can be cancelled. - Used after a command. Refreshing immediately reads the state - from before the change, because the cloud API lags the charger - by several seconds. + Each call gets its own scope. Scheduling inside a loop and + closing over the loop variable would leave every callback + discarding the last handle rather than its own. - Scheduled, not awaited: the caller returns immediately. - """ + Args: + delay: Seconds until the refresh runs. + reason: Used in the debug log. - # The charging current and eco mode live only in the EVSE - # record, which is cached. Without dropping that cache the - # refresh re-reads a stale copy and the entity reverts. - self._next_evse_fetch = 0.0 + """ + handle: list[Any] = [] async def _refresh(_now: Any) -> None: - """Ask the coordinator to re-read the charger.""" + """Re-read the charger.""" + if handle: + self._pending_timers.discard(handle[0]) + _LOGGER.debug( - "Post-command refresh for %s at +%ss", + "%s refresh for %s at +%ss", + reason, self._serial_number, delay, ) + # The charging current and eco mode live only in the EVSE + # record, which is cached. Without dropping that cache the + # refresh re-reads a stale copy and the entity reverts. self._next_evse_fetch = 0.0 await self.async_request_refresh() - async_call_later(self.hass, delay, _refresh) + cancel = async_call_later(self.hass, delay, _refresh) + handle.append(cancel) + self._pending_timers.add(cancel) + + def async_schedule_refresh_in(self, delay: int) -> None: + """Re-read the charger once, after a delay. + + Used after a command. Refreshing immediately reads the state + from before the change, because the cloud API lags the charger + by several seconds. + + Scheduled, not awaited: the caller returns immediately. + """ + self._next_evse_fetch = 0.0 + self._schedule_tracked_refresh(delay, "Post-command") def async_schedule_settle_refresh(self) -> None: """Re-read the charger a few times after a command. @@ -293,18 +332,7 @@ def async_schedule_settle_refresh(self) -> None: self._next_evse_fetch = 0.0 for delay in SETTLE_REFRESH_DELAYS: - - async def _refresh(_now: Any, _delay: int = delay) -> None: - """Ask the coordinator to re-read the charger.""" - _LOGGER.debug( - "Settle refresh for %s at +%ss", - self._serial_number, - _delay, - ) - self._next_evse_fetch = 0.0 - await self.async_request_refresh() - - async_call_later(self.hass, delay, _refresh) + self._schedule_tracked_refresh(delay, "Settle") async def _async_update_data(self) -> DazeCoordinatorData: """Fetch the latest socket remote info and session data. diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index 4e305af..2ceb75e 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -11,7 +11,6 @@ # @property methods by design. # pyright: reportIncompatibleVariableOverride=false import logging -import time from typing import TYPE_CHECKING, Any from homeassistant.components import persistent_notification @@ -34,11 +33,10 @@ from .const import ( DOMAIN, INLINE_COMMAND_ATTEMPTS, - MAX_OPTIMISTIC_HOLD, - OPTIMISTIC_STATE_TIMEOUT, POST_COMMAND_REFRESH_DELAY, ) from .coordinator import DazeDataUpdateCoordinator +from .optimistic import OptimisticState from .payload import ( POWER_STEP_W, grid_cap_advice, @@ -47,7 +45,6 @@ milliamps_to_watts, min_charging_current, min_charging_power, - resolve_optimistic, validate_charging_current, watts_to_milliamps, ) @@ -98,9 +95,7 @@ def __init__( self._serial_number = serial_number self._attr_unique_id = f"{serial_number}_max_charging_current" self._attr_device_info = device_info - self._optimistic_value: int | None = None - self._optimistic_since: float = 0.0 - self._awaiting_retry: bool = False + self._optimistic = OptimisticState() @property def native_min_value(self) -> float: @@ -134,14 +129,7 @@ def native_value(self) -> int | None: take minutes, so reading the last poll would snap the slider back to its old position and look like nothing happened. """ - value, keep = resolve_optimistic( - self._optimistic_value, self._reported_value, self._expired - ) - - if not keep: - self._optimistic_value = None - - return value + return self._optimistic.resolve(self._reported_value) @property def _reported_value(self) -> int | None: @@ -159,48 +147,22 @@ def _reported_value(self) -> int | None: return None - @property - def _expired(self) -> bool: - """Whether a pending change has been shown for too long.""" - if self._optimistic_value is None: - return True - - # While a background retry is still running the request is - # genuinely outstanding, so keep showing it. - held = time.monotonic() - self._optimistic_since - - if self._awaiting_retry: - # Capped: a superseded retry chain never reports back, so - # without this the value would stick until a restart. - return held > MAX_OPTIMISTIC_HOLD - - return held > OPTIMISTIC_STATE_TIMEOUT - def _show_requested(self, value: int, awaiting_retry: bool) -> None: """Display a requested value and re-read the charger later.""" - self._optimistic_value = value - self._optimistic_since = time.monotonic() - self._awaiting_retry = awaiting_retry + self._optimistic.request(value, awaiting_retry) self.async_write_ha_state() self.coordinator.async_schedule_refresh_in(POST_COMMAND_REFRESH_DELAY) def _clear_requested(self, message: str) -> None: """Drop a pending value and explain why.""" - self._optimistic_value = None - self._awaiting_retry = False + self._optimistic.clear() self.async_write_ha_state() self._notify_error(message) @callback def _handle_coordinator_update(self) -> None: """Stop showing the request once the charger reports it.""" - if ( - self._optimistic_value is not None - and self._reported_value == self._optimistic_value - ): - self._optimistic_value = None - self._awaiting_retry = False - + self._optimistic.settle(self._reported_value) super()._handle_coordinator_update() async def async_set_native_value(self, value: float) -> None: @@ -345,9 +307,9 @@ def __init__( self._serial_number = serial_number self._attr_unique_id = f"{serial_number}_max_charging_power" self._attr_device_info = device_info - self._optimistic_watts: int | None = None - self._optimistic_since: float = 0.0 - self._awaiting_retry: bool = False + # Watts are derived from a fluctuating voltage, so the + # charger's reading is compared within one step. + self._optimistic = OptimisticState(tolerance=POWER_STEP_W) @property def native_min_value(self) -> float: @@ -362,14 +324,7 @@ def native_max_value(self) -> float: @property def native_value(self) -> int | None: """Return the configured limit expressed in watts.""" - value, keep = resolve_optimistic( - self._optimistic_watts, self._reported_watts, self._expired - ) - - if not keep: - self._optimistic_watts = None - - return value + return self._optimistic.resolve(self._reported_watts) @property def _reported_watts(self) -> int | None: @@ -387,19 +342,6 @@ def _reported_watts(self) -> int | None: return None - @property - def _expired(self) -> bool: - """Whether a pending change has been shown for too long.""" - if self._optimistic_watts is None: - return True - - held = time.monotonic() - self._optimistic_since - - if self._awaiting_retry: - return held > MAX_OPTIMISTIC_HOLD - - return held > OPTIMISTIC_STATE_TIMEOUT - async def async_set_native_value(self, value: float) -> None: """Set the limit from a power figure. @@ -486,35 +428,20 @@ async def async_set_native_value(self, value: float) -> None: def _show_requested(self, watts: int, awaiting_retry: bool) -> None: """Display a requested power and re-read the charger later.""" - self._optimistic_watts = watts - self._optimistic_since = time.monotonic() - self._awaiting_retry = awaiting_retry + self._optimistic.request(watts, awaiting_retry) self.async_write_ha_state() self.coordinator.async_schedule_refresh_in(POST_COMMAND_REFRESH_DELAY) def _clear_requested(self, message: str) -> None: """Drop a pending power and explain why.""" - self._optimistic_watts = None - self._awaiting_retry = False + self._optimistic.clear() self.async_write_ha_state() self._notify_error(message) @callback def _handle_coordinator_update(self) -> None: """Stop showing the request once the charger reports it.""" - reported = self._reported_watts - if ( - self._optimistic_watts is not None - and reported is not None - # Compared with tolerance: both sides are derived from the - # live voltage, so a 1 V drift between the command and the - # next poll changes the figure and exact equality never - # holds. - and abs(reported - self._optimistic_watts) <= POWER_STEP_W - ): - self._optimistic_watts = None - self._awaiting_retry = False - + self._optimistic.settle(self._reported_watts) super()._handle_coordinator_update() def _notify_error(self, message: str) -> None: diff --git a/custom_components/daze/optimistic.py b/custom_components/daze/optimistic.py new file mode 100644 index 0000000..140bf32 --- /dev/null +++ b/custom_components/daze/optimistic.py @@ -0,0 +1,141 @@ +"""Shared optimistic-state handling for the controllable entities. + +A command takes seconds to show up in the charger's own reading, and a +background retry can take minutes. Reporting the charger's reading over +that window shows the pre-command value, so a toggle appears to flip +back and a slider appears to snap to its old position. + +Every control needs the same behaviour, and it was previously written +out separately in the switch, the current number, the power number and +the mode select. Three review findings came from those copies drifting +apart: the switch ignored a pending retry, the power entity compared +derived watts for exact equality, and a superseded retry could pin a +value until a restart. This module is the single implementation. + +Importing nothing from Home Assistant keeps it directly testable. +""" + +from __future__ import annotations + +import time +from typing import Any + +from .const import MAX_OPTIMISTIC_HOLD, OPTIMISTIC_STATE_TIMEOUT + + +class OptimisticState: + """Tracks a requested value until the charger confirms it. + + Not an entity mixin: entities hold one of these rather than + inheriting, so the same logic can be tested without constructing a + Home Assistant entity. + """ + + def __init__(self, tolerance: float = 0) -> None: + """Initialise with no pending value. + + Args: + tolerance: How far the charger's reading may differ from + the request and still count as agreement. Needed where + the value is derived from a fluctuating measurement, + such as watts computed from the live voltage, where + exact equality would practically never hold. + + """ + self._tolerance = tolerance + self._value: Any | None = None + self._since: float = 0.0 + self._awaiting_retry: bool = False + + @property + def pending(self) -> bool: + """Whether a requested value is currently being shown.""" + return self._value is not None + + @property + def value(self) -> Any | None: + """The requested value, or None.""" + return self._value + + def request(self, value: Any, awaiting_retry: bool = False) -> None: + """Start showing a requested value. + + Args: + value: What the user asked for. + awaiting_retry: True when the command did not reach the + charger and is queued for a background retry, which + means it stays displayed for far longer. + + """ + self._value = value + self._since = time.monotonic() + self._awaiting_retry = awaiting_retry + + def clear(self) -> None: + """Stop showing a requested value.""" + self._value = None + self._awaiting_retry = False + + def expired(self) -> bool: + """Whether the requested value has been shown for too long. + + A pending retry extends the window, because the request really + is still outstanding. It does not extend it indefinitely: a + retry chain that gets superseded never reports back, and + without a cap the entity would show a stale request until Home + Assistant restarts. + """ + if self._value is None: + return True + + held = time.monotonic() - self._since + + if self._awaiting_retry: + return held > MAX_OPTIMISTIC_HOLD + + return held > OPTIMISTIC_STATE_TIMEOUT + + def matches(self, actual: Any | None) -> bool: + """Whether the charger's reading agrees with the request.""" + if self._value is None or actual is None: + return False + + if self._tolerance and isinstance(actual, (int, float)): + if not isinstance(self._value, (int, float)): + return False + return abs(actual - self._value) <= self._tolerance + + return bool(actual == self._value) + + def resolve(self, actual: Any | None) -> Any | None: + """Return what the entity should report, and update state. + + Drops the request once the charger agrees, so a later change + made elsewhere is not masked, and once it has been held too + long, so a command that silently failed cannot leave the entity + asserting something untrue. + + Args: + actual: What the charger currently reports. + + Returns: + The value to display. + + """ + if self._value is None: + return actual + + if self.expired() or self.matches(actual): + self.clear() + return actual + + return self._value + + def settle(self, actual: Any | None) -> None: + """Drop the request if the charger has caught up. + + Called on a coordinator update, where the aim is only to stop + tracking rather than to produce a value. + """ + if self._value is not None and (self.matches(actual) or self.expired()): + self.clear() diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index 9c2559a..2049c64 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -190,48 +190,6 @@ def is_charge_enabled(data: dict[str, Any]) -> bool | None: return str(status).lower() in ACTIVE_STATUSES -def resolve_optimistic( - optimistic: Any | None, - actual: Any | None, - expired: bool, -) -> tuple[Any | None, bool]: - """Decide what a switch should report, and whether to keep guessing. - - A command takes effect at the charger several seconds after it is - accepted. Reporting the charger's reading during that window shows - the old state and makes the toggle appear to flip back, so the - commanded value is reported instead until reality catches up. - - The guess is dropped as soon as the charger agrees, and abandoned - once it has been held too long, so a command that silently failed - cannot leave the UI wrong indefinitely. - - Works for any value, not just a boolean: a charging current or an - operation mode lags the same way a switch does. - - Args: - optimistic: The value the last command asked for, or None. - actual: What the charger currently reports, or None. - expired: Whether the optimistic value has been held too long. - - Returns: - A tuple of the value to report and whether to keep holding the - optimistic value. - - """ - if optimistic is None: - return actual, False - - if expired: - return actual, False - - if actual == optimistic: - # Reality caught up; stop guessing. - return actual, False - - return optimistic, True - - # The charging current ceiling comes from the installation rating. # # sccLimit was tried first and is wrong: on a live charger it read diff --git a/custom_components/daze/select.py b/custom_components/daze/select.py index 28ecbff..947a58f 100644 --- a/custom_components/daze/select.py +++ b/custom_components/daze/select.py @@ -12,7 +12,6 @@ # @property methods by design. # pyright: reportIncompatibleVariableOverride=false import logging -import time from typing import TYPE_CHECKING, Any from homeassistant.components import persistent_notification @@ -30,12 +29,10 @@ from .const import ( DOMAIN, INLINE_COMMAND_ATTEMPTS, - MAX_OPTIMISTIC_HOLD, - OPTIMISTIC_STATE_TIMEOUT, POST_COMMAND_REFRESH_DELAY, ) from .coordinator import DazeDataUpdateCoordinator -from .payload import resolve_optimistic +from .optimistic import OptimisticState if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -111,9 +108,7 @@ def __init__( self._serial_number = serial_number self._attr_unique_id = f"{serial_number}_operation_mode" self._attr_device_info = device_info - self._optimistic_option: str | None = None - self._optimistic_since: float = 0.0 - self._awaiting_retry: bool = False + self._optimistic = OptimisticState(tolerance=0) @property def current_option(self) -> str | None: @@ -124,14 +119,7 @@ def current_option(self) -> str | None: background retry can take minutes, so reading the last poll would revert the selection and look like nothing happened. """ - option, keep = resolve_optimistic( - self._optimistic_option, self._reported_option, self._expired - ) - - if not keep: - self._optimistic_option = None - - return option + return self._optimistic.resolve(self._reported_option) @property def _reported_option(self) -> str | None: @@ -140,44 +128,22 @@ def _reported_option(self) -> str | None: return None return _current_option_from_data(self.coordinator.data) - @property - def _expired(self) -> bool: - """Whether a pending change has been shown for too long.""" - if self._optimistic_option is None: - return True - held = time.monotonic() - self._optimistic_since - - if self._awaiting_retry: - # Capped: a superseded retry chain never reports back, so - # without this the value would stick until a restart. - return held > MAX_OPTIMISTIC_HOLD - return held > OPTIMISTIC_STATE_TIMEOUT - def _show_requested(self, option: str, awaiting_retry: bool) -> None: """Display a requested mode and re-read the charger later.""" - self._optimistic_option = option - self._optimistic_since = time.monotonic() - self._awaiting_retry = awaiting_retry + self._optimistic.request(option, awaiting_retry) self.async_write_ha_state() self.coordinator.async_schedule_refresh_in(POST_COMMAND_REFRESH_DELAY) def _clear_requested(self, message: str) -> None: """Drop a pending mode and explain why.""" - self._optimistic_option = None - self._awaiting_retry = False + self._optimistic.clear() self.async_write_ha_state() self._notify_error(message) @callback def _handle_coordinator_update(self) -> None: """Stop showing the request once the charger reports it.""" - if ( - self._optimistic_option is not None - and self._reported_option == self._optimistic_option - ): - self._optimistic_option = None - self._awaiting_retry = False - + self._optimistic.settle(self._reported_option) super()._handle_coordinator_update() async def async_select_option(self, option: str) -> None: diff --git a/custom_components/daze/switch.py b/custom_components/daze/switch.py index 9b0f913..7e63802 100644 --- a/custom_components/daze/switch.py +++ b/custom_components/daze/switch.py @@ -12,7 +12,6 @@ # @property methods by design. # pyright: reportIncompatibleVariableOverride=false import logging -import time from typing import TYPE_CHECKING, Any from homeassistant.components import persistent_notification @@ -30,12 +29,11 @@ from .const import ( DOMAIN, INLINE_COMMAND_ATTEMPTS, - MAX_OPTIMISTIC_HOLD, - OPTIMISTIC_STATE_TIMEOUT, POST_COMMAND_REFRESH_DELAY, ) from .coordinator import DazeDataUpdateCoordinator -from .payload import is_charge_enabled, resolve_optimistic +from .optimistic import OptimisticState +from .payload import is_charge_enabled if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -75,9 +73,7 @@ def __init__( self._serial_number = serial_number self._attr_unique_id = f"{serial_number}_charge_switch" self._attr_device_info = device_info - self._optimistic_state: bool | None = None - self._optimistic_since: float = 0.0 - self._awaiting_retry: bool = False + self._optimistic = OptimisticState(tolerance=0) @property def is_on(self) -> bool | None: @@ -97,34 +93,12 @@ def is_on(self) -> bool | None: else None ) - value, keep = resolve_optimistic( - self._optimistic_state, actual, self._optimistic_expired - ) - - if not keep: - self._optimistic_state = None - - return value - - @property - def _optimistic_expired(self) -> bool: - """Whether the optimistic value has been held too long.""" - if self._optimistic_state is None: - return True - - held = time.monotonic() - self._optimistic_since - - if self._awaiting_retry: - # A queued retry means the request is still outstanding. - # Capped, because a superseded chain never reports back. - return held > MAX_OPTIMISTIC_HOLD - - return held > OPTIMISTIC_STATE_TIMEOUT + return self._optimistic.resolve(actual) @property def assumed_state(self) -> bool: """Tell the frontend when the shown state is a guess.""" - return self._optimistic_state is not None + return self._optimistic.pending def _set_optimistic( self, value: bool, awaiting_retry: bool = False @@ -135,9 +109,7 @@ def _set_optimistic( cloud still reports the old state, so the entity would flip back before settling. """ - self._optimistic_state = value - self._optimistic_since = time.monotonic() - self._awaiting_retry = awaiting_retry + self._optimistic.request(value, awaiting_retry) self.async_write_ha_state() self.coordinator.async_schedule_refresh_in( POST_COMMAND_REFRESH_DELAY @@ -146,16 +118,12 @@ def _set_optimistic( @callback def _handle_coordinator_update(self) -> None: """Drop the guess once the charger agrees with it.""" - if self._optimistic_state is not None: - actual = ( - is_charge_enabled(self.coordinator.data) - if self.coordinator.data is not None - else None - ) - if actual == self._optimistic_state or self._optimistic_expired: - self._optimistic_state = None - self._awaiting_retry = False - + actual = ( + is_charge_enabled(self.coordinator.data) + if self.coordinator.data is not None + else None + ) + self._optimistic.settle(actual) super()._handle_coordinator_update() async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/tests/test_entities.py b/tests/test_entities.py index ffdb311..fabecf6 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -220,6 +220,7 @@ def _load_package() -> types.ModuleType: api = sys.modules["daze_entities_under_test.api"] number_module = sys.modules["daze_entities_under_test.number"] select_module = sys.modules["daze_entities_under_test.select"] +optimistic_module = sys.modules["daze_entities_under_test.optimistic"] notifications = sys.modules[ "homeassistant.components.persistent_notification" ]._records @@ -372,7 +373,7 @@ def test_display_returns_to_reality_once_the_charger_agrees() -> None: coordinator.data["maxExternalChargingCurrentInMilliAmps"] = 16000 entity._handle_coordinator_update() - assert entity._optimistic_value is None + assert entity._optimistic.pending is False assert entity.native_value == 16000 @@ -670,6 +671,92 @@ def test_power_entity_never_sends_below_the_charger_floor() -> None: ), (requested, sent) +# ------------------------------------------------------------------ +# Shared optimistic state +# ------------------------------------------------------------------ + + +def test_shared_state_shows_the_request_until_reality_agrees() -> None: + """One implementation now serves all four controls.""" + state = optimistic_module.OptimisticState() + + assert state.resolve(6521) == 6521 + + state.request(16000) + assert state.resolve(6521) == 16000 + assert state.pending is True + + assert state.resolve(16000) == 16000 + assert state.pending is False + + +def test_shared_state_tolerates_a_drifting_measurement() -> None: + """Watts derive from a live voltage, so equality never holds. + + This is what made the power entity stick: both sides recomputed + from a reading that moves by a volt between polls. + """ + state = optimistic_module.OptimisticState(tolerance=100) + + state.request(3988) + assert state.resolve(4000) == 4000 + assert state.pending is False + + +def test_shared_state_without_tolerance_demands_equality() -> None: + """A switch or a mode must match exactly.""" + state = optimistic_module.OptimisticState() + + state.request("eco") + assert state.resolve("fast") == "eco" + assert state.resolve("eco") == "eco" + assert state.pending is False + + +def test_shared_state_holds_longer_while_a_retry_is_queued() -> None: + """A queued retry means the request is genuinely outstanding.""" + + quick = optimistic_module.OptimisticState() + quick.request(True, awaiting_retry=False) + + patient = optimistic_module.OptimisticState() + patient.request(True, awaiting_retry=True) + + # Neither has expired yet, but the caps differ. + assert quick.expired() is False + assert patient.expired() is False + + quick._since -= optimistic_module.OPTIMISTIC_STATE_TIMEOUT + 1 + patient._since -= optimistic_module.OPTIMISTIC_STATE_TIMEOUT + 1 + + assert quick.expired() is True + assert patient.expired() is False, "a pending retry must extend the hold" + + +def test_shared_state_hold_is_capped() -> None: + """A superseded retry never reports back, so the hold must end. + + Without a cap the entity would show a stale request until Home + Assistant restarts. + """ + state = optimistic_module.OptimisticState() + state.request(True, awaiting_retry=True) + + state._since -= optimistic_module.MAX_OPTIMISTIC_HOLD + 1 + + assert state.expired() is True + assert state.resolve(False) is False + + +def test_shared_state_ignores_an_unknown_reading() -> None: + """No reading is not agreement.""" + state = optimistic_module.OptimisticState() + + state.request(16000) + assert state.resolve(None) == 16000 + assert state.pending is True + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tests/test_payload.py b/tests/test_payload.py index 67e28af..e6f9870 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -414,46 +414,6 @@ def test_waiting_state_is_a_declared_sensor_option() -> None: # ------------------------------------------------------------------ -def test_no_guess_reports_the_charger() -> None: - """With nothing commanded, the charger's reading is the answer.""" - assert payload.resolve_optimistic(None, True, False) == (True, False) - assert payload.resolve_optimistic(None, False, False) == (False, False) - assert payload.resolve_optimistic(None, None, False) == (None, False) - - -def test_guess_wins_while_the_cloud_still_reports_the_old_state() -> None: - """This is the flip-back the optimistic state exists to prevent.""" - value, keep = payload.resolve_optimistic(True, False, False) - assert value is True - assert keep is True - - -def test_guess_is_dropped_once_the_charger_agrees() -> None: - """Holding it longer than needed would delay real changes.""" - value, keep = payload.resolve_optimistic(True, True, False) - assert value is True - assert keep is False - - -def test_guess_is_abandoned_when_it_expires() -> None: - """A command that silently failed must not leave the UI lying. - - Once the window passes, the charger's reading wins even though it - contradicts what was commanded. - """ - value, keep = payload.resolve_optimistic(True, False, True) - assert value is False - assert keep is False - - -def test_guess_survives_a_missing_reading() -> None: - """An unknown reading is not agreement, so keep the guess.""" - value, keep = payload.resolve_optimistic(False, None, False) - assert value is False - assert keep is True - - - # ------------------------------------------------------------------ # Charging current ceiling # ------------------------------------------------------------------ @@ -577,48 +537,6 @@ def test_measured_boundary_is_reproduced() -> None: -def test_optimistic_rule_handles_non_boolean_values() -> None: - """The current limit and the mode lag exactly as the switch does. - - The rule was written for a boolean switch. Reusing it for a - milliamp figure and a mode string is the point: all three read - stale from the last poll while a change is in flight. - """ - # A requested current, charger still reporting the old one. - value, keep = payload.resolve_optimistic(16000, 6521, False) - assert value == 16000 - assert keep is True - - # The charger has caught up. - value, keep = payload.resolve_optimistic(16000, 16000, False) - assert value == 16000 - assert keep is False - - # Held too long: reality wins even though it disagrees. - value, keep = payload.resolve_optimistic(16000, 6521, True) - assert value == 6521 - assert keep is False - - -def test_optimistic_rule_handles_mode_strings() -> None: - """Same rule, applied to the operation mode selector.""" - value, keep = payload.resolve_optimistic("eco", "fast", False) - assert value == "eco" - assert keep is True - - value, keep = payload.resolve_optimistic("eco", "eco", False) - assert value == "eco" - assert keep is False - - -def test_optimistic_rule_treats_zero_as_a_real_value() -> None: - """Zero is falsy but is a legitimate reading, not an absence.""" - value, keep = payload.resolve_optimistic(0, 6521, False) - assert value == 0 - assert keep is True - - - def test_state_1_is_idle() -> None: """Observed with no chargeSession: car unplugged or session ended.""" data = payload.merge_payload( From 4b6916d98006c1790f121fb3176465ec7fc36acf Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Mon, 28 Sep 2026 18:20:19 +0100 Subject: [PATCH 35/82] docs: name the charging limit controls Power and Current Both entities set has_entity_name, so Home Assistant composes the device name with the entity name. Naming them "Power" and "Current" renders as " Power" and " Current" rather than the longer "Max Charging Power". Entity IDs and unique IDs are unchanged, so existing entities keep their history and any automation referring to them still works. Only the displayed name changes. Italian follows: Potenza and Corrente. Also correct the README, which still listed one control with a fixed 6 to 32 A range and did not mention the watts view at all. Co-Authored-By: Claude Opus 5 --- README.md | 5 +++-- custom_components/daze/strings.json | 4 ++-- custom_components/daze/translations/it.json | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8a444c3..982cd38 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Daze wallboxes are managed through the [Daze web portal](https://webportal.dazes - **Real-time monitoring** — Power (W), delivered energy (Wh), charging current per phase (mA), AC voltage per phase (V), board and case temperatures (°C) - **EVSE status** — See whether the wallbox is charging, idle, paused, or in error - **Charge control** — Start and stop charging from HA switches, automations, or dashboards -- **Current limit** — Set the maximum charging current as a number entity (6–32 A, 0.1 A steps) +- **Charging limit** — Set it in amps or in watts. Both bounds come from the charger: its power floor at the measured voltage, and the installation rating - **Operation mode** — Switch between eco, fast, scheduled, and other modes - **Session history** — Track energy, duration, and cost per recharge session - **Lifetime totals** — Total energy delivered and session count @@ -110,7 +110,8 @@ If your tokens expire, the integration will automatically prompt you to re-enter | Platform | Entity ID | Name | Purpose | |----------|-----------|------|---------| | Switch | `switch.daze_charge_control` | Charge Control | Start / stop charging | -| Number | `number.daze_max_charging_current` | Max Charging Current | Set charging current limit (6–32 A) | +| Number | `number.daze_max_charging_current` | Current | Charging current limit, bounded by the charger's own floor and the installation rating | +| Number | `number.daze_max_charging_power` | Power | The same limit in watts, bounded by the charger's 1.5 kW floor | | Select | `select.daze_operation_mode` | Operation Mode | Switch between eco, fast, scheduled | --- diff --git a/custom_components/daze/strings.json b/custom_components/daze/strings.json index f3913ee..4dc0003 100644 --- a/custom_components/daze/strings.json +++ b/custom_components/daze/strings.json @@ -133,10 +133,10 @@ }, "number": { "max_charging_current": { - "name": "Max Charging Current" + "name": "Current" }, "max_charging_power": { - "name": "Max Charging Power" + "name": "Power" } }, "select": { diff --git a/custom_components/daze/translations/it.json b/custom_components/daze/translations/it.json index 6aa979b..50271f0 100644 --- a/custom_components/daze/translations/it.json +++ b/custom_components/daze/translations/it.json @@ -138,11 +138,11 @@ }, "number": { "max_charging_current": { - "name": "Corrente massima di carica", + "name": "Corrente", "entity_category": "config" }, "max_charging_power": { - "name": "Potenza massima di carica", + "name": "Potenza", "entity_category": "config" } }, From dea280830ad89af3df7f8bc2b8f00508c2cab48c Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Mon, 28 Sep 2026 18:23:58 +0100 Subject: [PATCH 36/82] fix: do not append a vendor suffix to the device name The config flow named the device "{evseName} Daze". Chargers usually name themselves after the vendor already, so a charger reporting "Daze HomeTT" became "Daze HomeTT Daze". Every entity inherits the device name, so the duplication appeared on all of them. Use the reported name unchanged, falling back to "Daze Wallbox" when the charger reports nothing usable, and trim whitespace that would otherwise show up in every entity name. The rule lives in payload.device_name so it can be tested: config_flow needs Home Assistant and voluptuous, neither of which the test environment has. This only affects new setups. An already configured device keeps the name stored in its config entry and can be renamed in Home Assistant's device settings. Co-Authored-By: Claude Opus 5 --- custom_components/daze/config_flow.py | 5 ++++- custom_components/daze/payload.py | 27 +++++++++++++++++++++++++++ tests/test_payload.py | 26 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/custom_components/daze/config_flow.py b/custom_components/daze/config_flow.py index 5071170..c3afb93 100644 --- a/custom_components/daze/config_flow.py +++ b/custom_components/daze/config_flow.py @@ -34,6 +34,7 @@ MAX_POLL_INTERVAL, MIN_POLL_INTERVAL, ) +from .payload import device_name _LOGGER = logging.getLogger(__name__) @@ -307,7 +308,9 @@ async def async_step_confirm( evse = evses[0] original_evse_name = evse.get("evseName", "Daze Wallbox") - self._evse_name = f"{original_evse_name} Daze" + # Used as reported: the charger usually names itself after the + # vendor already, so adding a suffix duplicated it. + self._evse_name = device_name(evse) self._serial_number = evse.get("serialNumber", "") self._device_profile = evse.get("deviceProfile", "") self._firmware_version = evse.get("firmwareVersion", "") diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index 2049c64..1f9c01f 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -465,3 +465,30 @@ def grid_cap_advice(milliamps: int, data: dict[str, Any] | None) -> str | None: f"Requested {requested} W, but this charger balances against a " f"{cap} W supply limit, so it will not draw more than that." ) + + +# Used when the charger reports no name of its own. +DEFAULT_DEVICE_NAME = "Daze Wallbox" + + +def device_name(evse_record: dict[str, Any] | None) -> str: + """Return the name to give the charger in Home Assistant. + + The charger's own name is used unchanged. Appending a vendor + suffix produced "Daze HomeTT Daze" on a charger that already + named itself "Daze HomeTT", and every entity inherits the device + name, so the duplication showed up throughout the interface. + + Args: + evse_record: The charger record from the evses response. + + Returns: + A display name, never empty. + + """ + name = (evse_record or {}).get("evseName") + + if isinstance(name, str) and name.strip(): + return name.strip() + + return DEFAULT_DEVICE_NAME diff --git a/tests/test_payload.py b/tests/test_payload.py index e6f9870..7879c47 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -713,6 +713,32 @@ def test_absent_schedule_yields_nothing() -> None: assert catalog.get_next_scheduled_charge(data) is None + +def test_device_name_is_used_as_reported() -> None: + """No vendor suffix: the charger already names itself. + + Appending one produced "Daze HomeTT Daze", and since every entity + inherits the device name the duplication appeared throughout the + interface. + """ + assert payload.device_name({"evseName": "Daze HomeTT"}) == "Daze HomeTT" + assert payload.device_name({"evseName": "casa"}) == "casa" + + +def test_device_name_falls_back_when_the_charger_reports_none() -> None: + """An unnamed device would otherwise show as blank.""" + assert payload.device_name({}) == payload.DEFAULT_DEVICE_NAME + assert payload.device_name(None) == payload.DEFAULT_DEVICE_NAME + assert payload.device_name({"evseName": " "}) == ( + payload.DEFAULT_DEVICE_NAME + ) + + +def test_device_name_is_trimmed() -> None: + """Stray whitespace would show up in every entity name.""" + assert payload.device_name({"evseName": " Garage "}) == "Garage" + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From fd0a242054c62c3fb0cba0cccfa6fe0da6617d21 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Mon, 28 Sep 2026 18:26:28 +0100 Subject: [PATCH 37/82] fix: keep the power and current views in step Changing one left the other showing the previous figure until the next poll. Both read the same field, so they converged eventually, but for about ten seconds the dashboard showed a charger set to two different limits at once. The pending value now lives on the coordinator rather than in each entity, held in milliamps, and both views render it. Changing either one notifies the other to redraw immediately. Resolving in milliamps also removes a comparison that could not work. The power view previously held its pending value in watts and compared it against watts recomputed from the live voltage, so a one volt drift between the command and the next poll meant the two never matched and the view stayed stuck on its request. Adds tests for both directions, for the untouched view actually being told to redraw rather than merely agreeing internally, and for both views settling together once the charger confirms. Co-Authored-By: Claude Opus 5 --- custom_components/daze/coordinator.py | 43 ++++++++++ custom_components/daze/number.py | 80 ++++++++++++----- tests/test_entities.py | 119 +++++++++++++++++++++++++- 3 files changed, 220 insertions(+), 22 deletions(-) diff --git a/custom_components/daze/coordinator.py b/custom_components/daze/coordinator.py index d0d76d9..86fa964 100644 --- a/custom_components/daze/coordinator.py +++ b/custom_components/daze/coordinator.py @@ -33,6 +33,7 @@ MIN_POLL_INTERVAL, ) from .models import RechargeSession +from .optimistic import OptimisticState from .payload import merge_payload _LOGGER = logging.getLogger(__name__) @@ -111,6 +112,12 @@ def __init__( self._sessions_missing_logged: bool = False self._pending_retries: dict[str, Callable[[], None]] = {} self._pending_timers: set[Callable[[], None]] = set() + # The charging limit is one setting with two views, in amps + # and in watts. Held here, in milliamps, so both entities + # show a pending change at once instead of disagreeing + # until the next refresh. + self._limit_state = OptimisticState() + self._limit_listeners: list[Callable[[], None]] = [] super().__init__( hass, @@ -248,6 +255,40 @@ def _cancel() -> None: ) _schedule(attempts[0]) + @property + def limit_state(self) -> OptimisticState: + """Return the shared pending charging limit, in milliamps.""" + return self._limit_state + + def async_add_limit_listener( + self, listener: Callable[[], None] + ) -> Callable[[], None]: + """Register a callback for changes to the pending limit. + + Args: + listener: Called when the pending limit changes. + + Returns: + A callable that unregisters the listener. + + """ + self._limit_listeners.append(listener) + + def _remove() -> None: + if listener in self._limit_listeners: + self._limit_listeners.remove(listener) + + return _remove + + def async_notify_limit_listeners(self) -> None: + """Tell both views of the limit to redraw. + + Called after one of them requests a change, so the other does + not keep showing the previous value until the next poll. + """ + for listener in list(self._limit_listeners): + listener() + def async_shutdown_timers(self) -> None: """Cancel every callback this coordinator has scheduled. @@ -264,6 +305,8 @@ def async_shutdown_timers(self) -> None: for key in list(self._pending_retries): self.async_cancel_background_retry(key) + self._limit_listeners.clear() + _LOGGER.debug("Cancelled pending timers for %s", self._serial_number) def async_cancel_background_retry(self, key: str) -> None: diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index 2ceb75e..7eac665 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -36,7 +36,6 @@ POST_COMMAND_REFRESH_DELAY, ) from .coordinator import DazeDataUpdateCoordinator -from .optimistic import OptimisticState from .payload import ( POWER_STEP_W, grid_cap_advice, @@ -95,7 +94,20 @@ def __init__( self._serial_number = serial_number self._attr_unique_id = f"{serial_number}_max_charging_current" self._attr_device_info = device_info - self._optimistic = OptimisticState() + + async def async_added_to_hass(self) -> None: + """Redraw when the other view of the limit changes. + + The current and the power entity are one setting. Without this + the view the user did not touch keeps showing the old figure + until the next poll. + """ + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_limit_listener( + self.async_write_ha_state + ) + ) @property def native_min_value(self) -> float: @@ -129,7 +141,7 @@ def native_value(self) -> int | None: take minutes, so reading the last poll would snap the slider back to its old position and look like nothing happened. """ - return self._optimistic.resolve(self._reported_value) + return self.coordinator.limit_state.resolve(self._reported_value) @property def _reported_value(self) -> int | None: @@ -149,20 +161,22 @@ def _reported_value(self) -> int | None: def _show_requested(self, value: int, awaiting_retry: bool) -> None: """Display a requested value and re-read the charger later.""" - self._optimistic.request(value, awaiting_retry) + self.coordinator.limit_state.request(value, awaiting_retry) self.async_write_ha_state() + self.coordinator.async_notify_limit_listeners() self.coordinator.async_schedule_refresh_in(POST_COMMAND_REFRESH_DELAY) def _clear_requested(self, message: str) -> None: """Drop a pending value and explain why.""" - self._optimistic.clear() + self.coordinator.limit_state.clear() self.async_write_ha_state() + self.coordinator.async_notify_limit_listeners() self._notify_error(message) @callback def _handle_coordinator_update(self) -> None: """Stop showing the request once the charger reports it.""" - self._optimistic.settle(self._reported_value) + self.coordinator.limit_state.settle(self._reported_value) super()._handle_coordinator_update() async def async_set_native_value(self, value: float) -> None: @@ -269,7 +283,6 @@ def _notify_error(self, message: str) -> None: ) - class DazeWallboxPowerEntity( CoordinatorEntity[DazeDataUpdateCoordinator], NumberEntity ): @@ -307,9 +320,20 @@ def __init__( self._serial_number = serial_number self._attr_unique_id = f"{serial_number}_max_charging_power" self._attr_device_info = device_info - # Watts are derived from a fluctuating voltage, so the - # charger's reading is compared within one step. - self._optimistic = OptimisticState(tolerance=POWER_STEP_W) + + async def async_added_to_hass(self) -> None: + """Redraw when the other view of the limit changes. + + The current and the power entity are one setting. Without this + the view the user did not touch keeps showing the old figure + until the next poll. + """ + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_limit_listener( + self.async_write_ha_state + ) + ) @property def native_min_value(self) -> float: @@ -324,11 +348,23 @@ def native_max_value(self) -> float: @property def native_value(self) -> int | None: """Return the configured limit expressed in watts.""" - return self._optimistic.resolve(self._reported_watts) + milliamps = self.coordinator.limit_state.resolve( + self._reported_current + ) + + if milliamps is None: + return None + + return milliamps_to_watts(milliamps, self.coordinator.data) @property - def _reported_watts(self) -> int | None: - """Return the charger's limit converted to watts.""" + def _reported_current(self) -> int | None: + """Return the charger's limit in milliamps. + + Resolved in milliamps rather than watts so both views compare + the same figure. Comparing derived watts meant a one volt + drift between the command and the next poll made them disagree. + """ if self.coordinator.data is None: return None @@ -338,7 +374,7 @@ def _reported_watts(self) -> int | None: ): value = self.coordinator.data.get(field) if value is not None: - return milliamps_to_watts(int(value), self.coordinator.data) + return int(value) return None @@ -390,7 +426,7 @@ async def async_set_native_value(self, value: float) -> None: self.coordinator.async_cancel_background_retry( f"{self._serial_number}:current" ) - self._show_requested(watts, awaiting_retry=False) + self._show_requested(milliamps, awaiting_retry=False) except ApiAuthError as err: _LOGGER.warning( "Auth error setting power on %s: %s", self._serial_number, err @@ -412,7 +448,7 @@ async def async_set_native_value(self, value: float) -> None: description=f"Setting the charging power to {watts} W", on_failure=self._clear_requested, ) - self._show_requested(watts, awaiting_retry=True) + self._show_requested(milliamps, awaiting_retry=True) return self._notify_error( @@ -426,22 +462,24 @@ async def async_set_native_value(self, value: float) -> None: ) self._notify_error(f"Failed to set the charging power. {err}") - def _show_requested(self, watts: int, awaiting_retry: bool) -> None: - """Display a requested power and re-read the charger later.""" - self._optimistic.request(watts, awaiting_retry) + def _show_requested(self, milliamps: int, awaiting_retry: bool) -> None: + """Display a requested limit and re-read the charger later.""" + self.coordinator.limit_state.request(milliamps, awaiting_retry) self.async_write_ha_state() + self.coordinator.async_notify_limit_listeners() self.coordinator.async_schedule_refresh_in(POST_COMMAND_REFRESH_DELAY) def _clear_requested(self, message: str) -> None: """Drop a pending power and explain why.""" - self._optimistic.clear() + self.coordinator.limit_state.clear() self.async_write_ha_state() + self.coordinator.async_notify_limit_listeners() self._notify_error(message) @callback def _handle_coordinator_update(self) -> None: """Stop showing the request once the charger reports it.""" - self._optimistic.settle(self._reported_watts) + self.coordinator.limit_state.settle(self._reported_current) super()._handle_coordinator_update() def _notify_error(self, message: str) -> None: diff --git a/tests/test_entities.py b/tests/test_entities.py index fabecf6..c46f8c3 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -45,6 +45,7 @@ def __init__(self, coordinator: Any) -> None: self.coordinator = coordinator self.hass = object() self.state_writes = 0 + self.removers: list[Any] = [] def async_write_ha_state(self) -> None: """Count frontend updates instead of performing one.""" @@ -54,6 +55,13 @@ def _handle_coordinator_update(self) -> None: """Base implementation does nothing here.""" self.state_writes += 1 + async def async_added_to_hass(self) -> None: + """Base implementation does nothing here.""" + + def async_on_remove(self, remove: Any) -> None: + """Record a teardown callback.""" + self.removers.append(remove) + class StubDataUpdateCoordinator: """Stand-in for DataUpdateCoordinator. @@ -239,6 +247,18 @@ def __init__(self, data: dict[str, Any]) -> None: self.data = data self.refresh_delays: list[int] = [] self.background: list[dict[str, Any]] = [] + self.limit_state = optimistic_module.OptimisticState() + self.limit_listeners: list[Any] = [] + + def async_add_limit_listener(self, listener: Any) -> Any: + """Register a redraw callback.""" + self.limit_listeners.append(listener) + return lambda: self.limit_listeners.remove(listener) + + def async_notify_limit_listeners(self) -> None: + """Redraw every registered view.""" + for listener in list(self.limit_listeners): + listener() def async_schedule_refresh_in(self, delay: int) -> None: """Record a delayed refresh.""" @@ -373,7 +393,7 @@ def test_display_returns_to_reality_once_the_charger_agrees() -> None: coordinator.data["maxExternalChargingCurrentInMilliAmps"] = 16000 entity._handle_coordinator_update() - assert entity._optimistic.pending is False + assert coordinator.limit_state.pending is False assert entity.native_value == 16000 @@ -757,6 +777,103 @@ def test_shared_state_ignores_an_unknown_reading() -> None: assert state.pending is True + +def test_setting_power_updates_the_current_view_at_once() -> None: + """One setting, two views: they must not disagree. + + Both read the same field, so they converge on the next poll + anyway. The point is that they agree immediately, rather than + showing different figures for the ten seconds until then. + """ + coordinator = FakeCoordinator(dict(POWER_DATA)) + client = FakeApi() + + power = number_module.DazeWallboxPowerEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + current = number_module.DazeWallboxNumberEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + asyncio.run(power.async_added_to_hass()) + asyncio.run(current.async_added_to_hass()) + + assert current.native_value == 6521 + assert power.native_value == 1539 + + asyncio.run(power.async_set_native_value(4000)) + + # The charger still reports the old figure. + assert coordinator.data["maxExternalChargingCurrentInMilliAmps"] == 6521 + assert power.native_value == 3988 + assert current.native_value == 16900, "the current view did not follow" + + +def test_setting_current_updates_the_power_view_at_once() -> None: + """The same in the other direction.""" + coordinator = FakeCoordinator(dict(POWER_DATA)) + client = FakeApi() + + power = number_module.DazeWallboxPowerEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + current = number_module.DazeWallboxNumberEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + asyncio.run(power.async_added_to_hass()) + asyncio.run(current.async_added_to_hass()) + + asyncio.run(current.async_set_native_value(16900)) + + assert current.native_value == 16900 + assert power.native_value == 3988, "the power view did not follow" + + +def test_the_untouched_view_is_told_to_redraw() -> None: + """Agreeing internally is not enough; the frontend must be told.""" + coordinator = FakeCoordinator(dict(POWER_DATA)) + client = FakeApi() + + power = number_module.DazeWallboxPowerEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + current = number_module.DazeWallboxNumberEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + asyncio.run(power.async_added_to_hass()) + asyncio.run(current.async_added_to_hass()) + + before = current.state_writes + asyncio.run(power.async_set_native_value(4000)) + + assert current.state_writes > before + + +def test_both_views_settle_together() -> None: + """Once the charger agrees, neither should still be guessing.""" + coordinator = FakeCoordinator(dict(POWER_DATA)) + client = FakeApi() + + power = number_module.DazeWallboxPowerEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + asyncio.run(power.async_added_to_hass()) + + asyncio.run(power.async_set_native_value(4000)) + assert coordinator.limit_state.pending is True + + coordinator.data["maxExternalChargingCurrentInMilliAmps"] = 16900 + power._handle_coordinator_update() + + assert coordinator.limit_state.pending is False + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 4e3888830300b996fa5f2184b0ce905210303faa Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Mon, 28 Sep 2026 19:27:51 +0100 Subject: [PATCH 38/82] feat: do not send commands to a charger that is not reporting Cutting power to the wallbox leaves the cloud API serving its last known record, so a command is accepted by the service and then fails against a device that is not there. That surfaced as HTTP 500 with error 101 after 33 seconds of retries, and the message blamed the Daze service for being unreachable rather than the charger for being switched off. It also explains a setting that appeared not to take effect: the command never reached anything. Check before sending. A charger reporting active=False says so directly. Otherwise, lastAttributesUpdatedOn is compared against the clock: it refreshed every few seconds in every capture taken, including while idle, so a gap of fifteen minutes means the charger is not talking to the service. The staleness threshold is inferred rather than measured. No capture exists of a charger switched off at the wall, because the API keeps serving the previous record. If it is wrong the symptom is a command refused when it would have worked, and the message names the reason explicitly so that is recognisable rather than mysterious. Absence of evidence does not block: a missing or unparseable timestamp is treated as reachable, so a format change upstream cannot lock anyone out of their own charger. Applied to the charge switch, both views of the charging limit, and the operation mode. Co-Authored-By: Claude Opus 5 --- custom_components/daze/number.py | 21 +++++++++++ custom_components/daze/payload.py | 61 +++++++++++++++++++++++++++++++ custom_components/daze/select.py | 10 +++++ custom_components/daze/switch.py | 20 +++++++++- tests/test_entities.py | 50 +++++++++++++++++++++++++ tests/test_payload.py | 53 +++++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 1 deletion(-) diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index 7eac665..4623e9d 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -38,6 +38,7 @@ from .coordinator import DazeDataUpdateCoordinator from .payload import ( POWER_STEP_W, + charger_offline_reason, grid_cap_advice, max_charging_current, max_charging_power, @@ -208,6 +209,16 @@ async def async_set_native_value(self, value: float) -> None: if advice is not None: _LOGGER.info("%s", advice) + offline = charger_offline_reason(self.coordinator.data) + if offline is not None: + _LOGGER.info("Not sending: %s", offline) + self._notify_error( + f"The command was not sent because {offline}. " + "Check that the wallbox has power." + ) + return + + try: _LOGGER.info( "Setting max charging current on %s to %d mA", @@ -411,6 +422,16 @@ async def async_set_native_value(self, value: float) -> None: if advice is not None: _LOGGER.info("%s", advice) + offline = charger_offline_reason(self.coordinator.data) + if offline is not None: + _LOGGER.info("Not sending: %s", offline) + self._notify_error( + f"The command was not sent because {offline}. " + "Check that the wallbox has power." + ) + return + + try: _LOGGER.info( "Setting charging power on %s to %d W (%d mA)", diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index 1f9c01f..bb85c56 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -22,6 +22,7 @@ from __future__ import annotations import math +from datetime import datetime, timezone from typing import Any # EVSE state values confirmed against live hardware: @@ -492,3 +493,63 @@ def device_name(evse_record: dict[str, Any] | None) -> str: return name.strip() return DEFAULT_DEVICE_NAME + + +# How long the charger's own attributes may go unrefreshed before it is +# treated as not reporting. It updated every few seconds in every +# capture taken, including while idle, so a gap this long means it is +# not talking to the service. +# +# Inferred rather than measured: no capture exists of a charger that +# was switched off at the wall, because the API kept serving the last +# known record. If this turns out to be wrong the symptom is a command +# refused when it would have worked, which the message names explicitly +# so it can be recognised. +STALE_REPORT_SECONDS = 900 + + +def last_reported_at(data: dict[str, Any] | None) -> datetime | None: + """Return when the charger last refreshed its own attributes.""" + raw = (data or {}).get("lastAttributesUpdatedOn") + + if not isinstance(raw, str) or not raw: + return None + + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + + +def charger_offline_reason(data: dict[str, Any] | None) -> str | None: + """Explain why a command cannot reach the charger, if it cannot. + + Cutting power to the wallbox leaves the cloud API serving its last + known record, so a command is accepted by the service and then + fails against a device that is not there. That surfaces as HTTP 500 + with error 101 after a long retry, which reads like a service + outage rather than a charger that is switched off. + + Args: + data: The merged payload, or None before the first poll. + + Returns: + None if the charger appears reachable, otherwise a reason. + + """ + if not data: + return None + + if data.get("active") is False: + return "the charger reports itself as not active" + + reported = last_reported_at(data) + if reported is not None: + age = (datetime.now(timezone.utc) - reported).total_seconds() + if age > STALE_REPORT_SECONDS: + return ( + f"the charger last reported {int(age // 60)} minutes ago, " + "so it appears to be switched off or offline" + ) + + return None diff --git a/custom_components/daze/select.py b/custom_components/daze/select.py index 947a58f..cff34a4 100644 --- a/custom_components/daze/select.py +++ b/custom_components/daze/select.py @@ -33,6 +33,7 @@ ) from .coordinator import DazeDataUpdateCoordinator from .optimistic import OptimisticState +from .payload import charger_offline_reason if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -174,6 +175,15 @@ async def async_select_option(self, option: str) -> None: ) return + offline = charger_offline_reason(self.coordinator.data) + if offline is not None: + _LOGGER.info("Not sending: %s", offline) + self._notify_error( + f"The command was not sent because {offline}. " + "Check that the wallbox has power." + ) + return + try: _LOGGER.info( "Setting operation mode to '%s' on wallbox %s " diff --git a/custom_components/daze/switch.py b/custom_components/daze/switch.py index 7e63802..3a2521d 100644 --- a/custom_components/daze/switch.py +++ b/custom_components/daze/switch.py @@ -33,7 +33,7 @@ ) from .coordinator import DazeDataUpdateCoordinator from .optimistic import OptimisticState -from .payload import is_charge_enabled +from .payload import charger_offline_reason, is_charge_enabled if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -135,6 +135,15 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) return + offline = charger_offline_reason(self.coordinator.data) + if offline is not None: + _LOGGER.info("Not sending: %s", offline) + self._notify_error( + f"The command was not sent because {offline}. " + "Check that the wallbox has power." + ) + return + try: _LOGGER.info( "Starting charge on wallbox %s", self._serial_number @@ -190,6 +199,15 @@ async def async_turn_off(self, **kwargs: Any) -> None: ) return + offline = charger_offline_reason(self.coordinator.data) + if offline is not None: + _LOGGER.info("Not sending: %s", offline) + self._notify_error( + f"The command was not sent because {offline}. " + "Check that the wallbox has power." + ) + return + try: _LOGGER.info( "Stopping charge on wallbox %s", self._serial_number diff --git a/tests/test_entities.py b/tests/test_entities.py index c46f8c3..be22cbd 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -874,6 +874,56 @@ def test_both_views_settle_together() -> None: assert coordinator.limit_state.pending is False + +def test_a_command_is_not_sent_to_a_silent_charger() -> None: + """Spending 33 seconds of retries on a powered-off charger is waste. + + The API keeps serving the last known record, so the command is + accepted and then fails against a device that is not there. The + resulting message blamed the Daze service rather than the power + supply. + """ + from datetime import datetime, timedelta, timezone + + stale = (datetime.now(timezone.utc) - timedelta(minutes=40)) + notifications.clear() + + entity, _, client = make_number( + data={ + **BASE_DATA, + "lastAttributesUpdatedOn": stale.isoformat().replace( + "+00:00", "Z" + ), + } + ) + + asyncio.run(entity.async_set_native_value(16000)) + + assert client.calls == [], "nothing should be sent to a silent charger" + assert len(notifications) == 1 + assert "power" in notifications[0]["message"].lower() + + +def test_a_command_is_sent_to_a_reporting_charger() -> None: + """The guard must not block a charger that is present.""" + from datetime import datetime, timezone + + notifications.clear() + entity, _, client = make_number( + data={ + **BASE_DATA, + "lastAttributesUpdatedOn": datetime.now(timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + } + ) + + asyncio.run(entity.async_set_native_value(16000)) + + assert client.calls == [("current", 16000)] + assert not notifications + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tests/test_payload.py b/tests/test_payload.py index 7879c47..f70edc5 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -739,6 +739,59 @@ def test_device_name_is_trimmed() -> None: assert payload.device_name({"evseName": " Garage "}) == "Garage" + +# ------------------------------------------------------------------ +# Detecting a charger that has lost power +# ------------------------------------------------------------------ + + +def _reported(minutes_ago: float) -> str: + """Return an attribute timestamp that old.""" + from datetime import datetime, timedelta, timezone + + when = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago) + return when.isoformat().replace("+00:00", "Z") + + +def test_a_reporting_charger_is_not_blocked() -> None: + """The guard must not interfere with a healthy charger.""" + data = {"active": True, "lastAttributesUpdatedOn": _reported(0.1)} + assert payload.charger_offline_reason(data) is None + + +def test_an_inactive_charger_is_blocked() -> None: + """active=False is the charger saying so itself.""" + reason = payload.charger_offline_reason({"active": False}) + assert reason is not None + assert "not active" in reason + + +def test_a_silent_charger_is_blocked() -> None: + """Cutting power leaves the API serving its last known record. + + The command then fails against a device that is not there, which + surfaces as a long retry and an error about the service being + unreachable rather than about the charger being switched off. + """ + data = {"active": True, "lastAttributesUpdatedOn": _reported(40)} + reason = payload.charger_offline_reason(data) + assert reason is not None + assert "switched off" in reason + + +def test_a_missing_timestamp_does_not_block() -> None: + """Absence of evidence is not evidence: do not guess offline.""" + assert payload.charger_offline_reason({"active": True}) is None + assert payload.charger_offline_reason({}) is None + assert payload.charger_offline_reason(None) is None + + +def test_an_unparseable_timestamp_does_not_block() -> None: + """A format change must not lock the user out of their charger.""" + data = {"active": True, "lastAttributesUpdatedOn": "not a date"} + assert payload.charger_offline_reason(data) is None + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From eee0b96b0ae31308168b89a80c844748818b0d28 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 07:49:24 +0100 Subject: [PATCH 39/82] fix: act on the second review Eight of ten findings addressed. The two left are noted below. The power entity dropped a corrective change. It compared the requested current against the charger's reported value rather than against what it was displaying, so after a pending change, dragging the slider back to where it started matched the stale reading and sent nothing. The charger stayed on the intermediate value the user had already moved away from. It now compares against the displayed value, as the current entity always did. A background retry cancelled while an attempt was in flight resurrected itself. The cancellation flag was only checked on entry, and an unreachable charger keeps an attempt busy for tens of seconds, so the except branch rescheduled and re-registered the chain. That defeated both the supersede on a newer command and the shutdown on unload, which two recent commits were built on. Checked again after the attempt completes, on both the failure and success paths. A timezone-naive lastAttributesUpdatedOn raised TypeError out of the offline check, which runs before every command, so a format change could have blocked every control. Naive timestamps are now read as UTC. Extracting a scalar from nextScheduleInfo did not fix the error it targeted: a timestamp sensor needs a datetime, and a string or an epoch raises the same "Invalid datetime" the dict did. Values are now parsed, with anything unparseable reported as nothing. The charge switch notified on a failed retry but never cleared its pending state, so the toggle kept asserting a command the user had just been told had failed, now for 525 seconds rather than 20. Timers and limit listeners were torn down before the unload was known to have succeeded, leaving a refused unload running with a coordinator that could no longer refresh or redraw. Moved after the check. The background retry's success path refreshed without dropping the cached EVSE record, so a change that landed late could be confirmed against a copy up to two minutes old and never settle. The three services bypassed the offline check entirely, so an automation still got the long retry and the misleading service-outage error. Not addressed: MAX_OPTIMISTIC_HOLD budgets 60 seconds of slack over the retry delays and ignores time spent inside the attempts, which is unmeasured; and the device rename reaches only new installs, since an existing entry keeps the name stored in its config entry. Co-Authored-By: Claude Opus 5 --- custom_components/daze/__init__.py | 42 +++++++++-- custom_components/daze/coordinator.py | 22 ++++++ custom_components/daze/number.py | 11 ++- custom_components/daze/payload.py | 11 ++- custom_components/daze/sensor_catalog.py | 41 ++++++++-- custom_components/daze/switch.py | 13 +++- tests/test_entities.py | 95 +++++++++++++++++++++++- tests/test_payload.py | 10 ++- 8 files changed, 224 insertions(+), 21 deletions(-) diff --git a/custom_components/daze/__init__.py b/custom_components/daze/__init__.py index a62cd74..41c4121 100644 --- a/custom_components/daze/__init__.py +++ b/custom_components/daze/__init__.py @@ -25,6 +25,7 @@ SERVICE_STOP_CHARGE, ) from .coordinator import DazeDataUpdateCoordinator, async_setup_coordinator +from .payload import charger_offline_reason if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -92,20 +93,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a Daze Wallbox config entry.""" _LOGGER.debug("Unloading Daze Wallbox config entry %s", entry.entry_id) - # Stop anything the coordinator has scheduled before tearing the - # entry down. An options change reloads the entry, so without this - # the old coordinator keeps firing against a closed client. - entry_data = hass.data.get(DOMAIN, {}).get(entry.entry_id) - if entry_data is not None: - coordinator: DazeDataUpdateCoordinator = entry_data["coordinator"] - coordinator.async_shutdown_timers() - # Unload entity platforms unload_ok = await hass.config_entries.async_unload_platforms( entry, PLATFORMS ) if unload_ok: + # Stop anything the coordinator has scheduled. An options + # change reloads the entry, so without this the old + # coordinator keeps firing against a closed client. + # + # Only once the unload has actually succeeded: a refused + # unload leaves the entry running with this same coordinator, + # and tearing down its timers and listeners would leave it + # alive but inert. + entry_data = hass.data.get(DOMAIN, {}).get(entry.entry_id) + if entry_data is not None: + coordinator: DazeDataUpdateCoordinator = entry_data["coordinator"] + coordinator.async_shutdown_timers() + # Clean up stored data hass.data[DOMAIN].pop(entry.entry_id, None) @@ -139,8 +145,24 @@ def _async_register_services( api_client = coordinator.api_client serial_number = coordinator.serial_number + def _refuse_if_offline() -> None: + """Stop a service call that cannot reach the charger. + + The entities check this before sending. Without the same check + here an automation gets the long retry and the misleading + service-outage error the guard was written to replace. + """ + reason = charger_offline_reason(coordinator.data) + if reason is not None: + raise HomeAssistantError( + f"The command was not sent because {reason}. " + "Check that the wallbox has power." + ) + async def _handle_start_charge(call: ServiceCall) -> None: """Start charging.""" + _refuse_if_offline() + try: await api_client.async_start_charge(serial_number) await coordinator.async_request_refresh() @@ -157,6 +179,8 @@ async def _handle_start_charge(call: ServiceCall) -> None: async def _handle_stop_charge(call: ServiceCall) -> None: """Stop charging.""" + _refuse_if_offline() + try: await api_client.async_stop_charge(serial_number) await coordinator.async_request_refresh() @@ -174,6 +198,8 @@ async def _handle_stop_charge(call: ServiceCall) -> None: async def _handle_set_charging_current(call: ServiceCall) -> None: """Set the maximum charging current.""" current: int = call.data["current"] + _refuse_if_offline() + try: await api_client.async_set_max_charging_current( serial_number, current diff --git a/custom_components/daze/coordinator.py b/custom_components/daze/coordinator.py index 86fa964..64e9540 100644 --- a/custom_components/daze/coordinator.py +++ b/custom_components/daze/coordinator.py @@ -200,6 +200,17 @@ async def _attempt(_now: Any) -> None: try: await action() except Exception as err: # noqa: BLE001 - reported below + if state["cancelled"]: + # Cancelled while this attempt was in flight, which + # is a window of tens of seconds. Rescheduling here + # would re-register the chain and undo both the + # supersede on a newer command and the shutdown on + # unload. + _LOGGER.debug( + "Dropping superseded retry for %s", description + ) + return + state["index"] = index + 1 if state["index"] < len(attempts): @@ -231,10 +242,21 @@ async def _attempt(_now: Any) -> None: ) return + if state["cancelled"]: + _LOGGER.debug( + "Superseded retry for %s succeeded; not refreshing", + description, + ) + return + self._pending_retries.pop(key, None) _LOGGER.info( "%s succeeded on background attempt %d", description, index + 1 ) + # The settings a command changes live only in the EVSE + # record, which is cached, so confirming the change needs + # a fresh copy. + self._next_evse_fetch = 0.0 await self.async_request_refresh() def _schedule(delay: int) -> None: diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index 4623e9d..913f71e 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -399,10 +399,15 @@ async def async_set_native_value(self, value: float) -> None: milliamps = watts_to_milliamps(value, self.coordinator.data) watts = milliamps_to_watts(milliamps, self.coordinator.data) - current = self.coordinator.data or {} - if current.get("maxExternalChargingCurrentInMilliAmps") == milliamps: + # Compared against what is being displayed, which includes a + # pending change. Comparing against the charger's reading + # instead meant that correcting a value back to where it + # started matched the stale reading and sent nothing, leaving + # the charger on the intermediate value. + shown = self.coordinator.limit_state.resolve(self._reported_current) + if shown is not None and shown == milliamps: _LOGGER.debug( - "Power set to %d W, already at %d mA, skipping", + "Power set to %d W, already requesting %d mA, skipping", watts, milliamps, ) diff --git a/custom_components/daze/payload.py b/custom_components/daze/payload.py index bb85c56..e4893b6 100644 --- a/custom_components/daze/payload.py +++ b/custom_components/daze/payload.py @@ -516,10 +516,19 @@ def last_reported_at(data: dict[str, Any] | None) -> datetime | None: return None try: - return datetime.fromisoformat(raw.replace("Z", "+00:00")) + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: return None + # A timestamp without an offset would raise when compared against + # an aware clock, and this runs before every command, so the + # exception would block the controls entirely. Assume UTC, which + # is what the API sends when it does include an offset. + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + + return parsed + def charger_offline_reason(data: dict[str, Any] | None) -> str | None: """Explain why a command cannot reach the charger, if it cannot. diff --git a/custom_components/daze/sensor_catalog.py b/custom_components/daze/sensor_catalog.py index b619952..3e69833 100644 --- a/custom_components/daze/sensor_catalog.py +++ b/custom_components/daze/sensor_catalog.py @@ -8,6 +8,7 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any type ValueFn = Callable[[dict[str, Any]], Any | None] @@ -78,6 +79,35 @@ def presence_on_off(data: dict[str, Any], key: str) -> str | None: ) +def _as_datetime(value: Any) -> datetime | None: + """Coerce an API value into a timezone-aware datetime. + + A timestamp sensor requires a datetime. Returning the raw string or + epoch the API provides raises "Invalid datetime" on every state + write, which is the same failure that returning the nested object + caused. + """ + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + + if isinstance(value, (int, float)): + # Milliseconds if it is far too large to be seconds. + seconds = value / 1000 if value > 1e11 else value + try: + return datetime.fromtimestamp(seconds, tz=timezone.utc) + except (OverflowError, OSError, ValueError): + return None + + if isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + return None + + def get_next_scheduled_charge(data: dict[str, Any]) -> Any | None: """Return the next scheduled charge time, if one is set. @@ -95,13 +125,14 @@ def get_next_scheduled_charge(data: dict[str, Any]) -> Any | None: if isinstance(value, dict): for field in _SCHEDULE_TIME_FIELDS: - nested = value.get(field) - if isinstance(nested, (str, int, float)): - return nested + parsed = _as_datetime(value.get(field)) + if parsed is not None: + return parsed continue - if isinstance(value, (str, int, float)): - return value + parsed = _as_datetime(value) + if parsed is not None: + return parsed return None diff --git a/custom_components/daze/switch.py b/custom_components/daze/switch.py index 3a2521d..9ce77c5 100644 --- a/custom_components/daze/switch.py +++ b/custom_components/daze/switch.py @@ -278,13 +278,24 @@ def _retry_in_background( self._serial_number, attempts=INLINE_COMMAND_ATTEMPTS ), description=f"{verb} the charge", - on_failure=self._notify_error, + on_failure=self._clear_requested, ) # Show the intent while the retries run. self._set_optimistic(turn_on, awaiting_retry=True) return True + def _clear_requested(self, message: str) -> None: + """Drop the pending state and explain why. + + Without this the toggle kept asserting the commanded state for + another minute after the user had been told it failed, and + nothing redrew it when the hold finally expired. + """ + self._optimistic.clear() + self.async_write_ha_state() + self._notify_error(message) + def _notify_error(self, message: str) -> None: """Show a persistent notification in the HA frontend.""" persistent_notification.async_create( diff --git a/tests/test_entities.py b/tests/test_entities.py index be22cbd..89d0d5b 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -211,7 +211,7 @@ def _load_package() -> types.ModuleType: sys.modules["daze_entities_under_test.api"] = api_module spec.loader.exec_module(api_module) - for name in ("coordinator", "number", "select"): + for name in ("coordinator", "number", "select", "switch"): spec = importlib.util.spec_from_file_location( f"daze_entities_under_test.{name}", PACKAGE_DIR / f"{name}.py" ) @@ -311,6 +311,24 @@ async def async_set_eco_mode( raise self.error return {} + async def async_start_charge( + self, serial: str, attempts: int = 8 + ) -> dict: + """Record and optionally fail.""" + self.calls.append(("start", serial)) + if self.error is not None: + raise self.error + return {} + + async def async_stop_charge( + self, serial: str, attempts: int = 8 + ) -> dict: + """Record and optionally fail.""" + self.calls.append(("stop", serial)) + if self.error is not None: + raise self.error + return {} + BASE_DATA: dict[str, Any] = { "maxExternalChargingCurrentInMilliAmps": 6521, @@ -924,6 +942,81 @@ def test_a_command_is_sent_to_a_reporting_charger() -> None: assert not notifications + +def test_correcting_a_power_value_back_is_still_sent() -> None: + """Dragging back to the starting value must not be swallowed. + + The power entity compared against the charger's reading rather + than what it was displaying. After a pending change, correcting + the slider back matched the stale reading, so nothing was sent and + the charger stayed on the intermediate value the user had already + moved away from. + """ + coordinator = FakeCoordinator(dict(POWER_DATA)) + coordinator.data["maxExternalChargingCurrentInMilliAmps"] = 16900 + client = FakeApi() + + power = number_module.DazeWallboxPowerEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + asyncio.run(power.async_added_to_hass()) + + # Drop it, then immediately put it back. + asyncio.run(power.async_set_native_value(1600)) + first = list(client.calls) + asyncio.run(power.async_set_native_value(3988)) + + assert len(client.calls) == len(first) + 1, ( + "the corrective change was dropped" + ) + assert client.calls[-1][1] == 16900 + + +def test_setting_the_displayed_value_again_sends_nothing() -> None: + """The guard must still suppress a genuine no-op.""" + coordinator = FakeCoordinator(dict(POWER_DATA)) + coordinator.data["maxExternalChargingCurrentInMilliAmps"] = 16900 + client = FakeApi() + + power = number_module.DazeWallboxPowerEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + asyncio.run(power.async_added_to_hass()) + + asyncio.run(power.async_set_native_value(3988)) + + assert client.calls == [] + + +def test_switch_clears_its_pending_state_when_retries_fail() -> None: + """Otherwise the toggle asserts a state the user was told failed. + + Every other control cleared on failure; the switch only notified, + and the hold had just been extended from 20 to 525 seconds. + """ + coordinator = FakeCoordinator(dict(BASE_DATA)) + client = FakeApi(error=rpc_failure()) + + switch_module = sys.modules["daze_entities_under_test.switch"] + entity = switch_module.DazeWallboxSwitchEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + + notifications.clear() + asyncio.run(entity.async_turn_on()) + + assert coordinator.background, "expected a queued retry" + assert entity.is_on is True + + coordinator.background[0]["on_failure"]("could not be delivered") + + assert entity.is_on is not True, "the toggle still asserts the command" + assert len(notifications) == 1 + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tests/test_payload.py b/tests/test_payload.py index f70edc5..154e41a 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -697,8 +697,14 @@ def test_schedule_object_does_not_reach_the_timestamp_sensor() -> None: {"nextScheduleInfo": {"startTime": "2026-09-28T02:00:00Z"}}, None ) value = catalog.get_next_scheduled_charge(with_schedule) - assert value == "2026-09-28T02:00:00Z" - assert not isinstance(value, dict) + + # A timestamp sensor needs a datetime. Returning the raw string + # raises the same "Invalid datetime" the dict did. + from datetime import datetime + + assert isinstance(value, datetime) + assert value.tzinfo is not None + assert value.isoformat().startswith("2026-09-28T02:00:00") def test_unknown_schedule_shape_yields_nothing() -> None: From 1378866bc7b5ae0bbb09d4e985c8b948739a6b23 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 08:40:33 +0100 Subject: [PATCH 40/82] docs: add a worked solar surplus charging setup The original question behind all of this was how to adapt charging speed, with solar. The integration now exposes what that needs, so here is the automation itself. Surplus is computed as the car's current draw plus what is being exported, because the car's own consumption is not surplus that has disappeared: it is surplus already in use. It is then smoothed over five minutes, since passing clouds would otherwise start and stop the car all afternoon. The numbers come from what was measured against the charger rather than from guesswork. It will not charge below 1500 W, so the default threshold sits at 1700 W: starting at the floor means the first cloud drops below it. A change takes seconds to apply and is reported back on a delay, so the limit is revisited every two minutes rather than continuously, and only when the change exceeds a deadband. The stop delay is twice the start delay, because stopping and restarting is harder on the car than riding out a cloud. Bounds are read from the number entity rather than hardcoded, so they follow the charger's power floor at the measured voltage and the installation rating, and work on a different installation unchanged. Includes a procedure for validating the surplus figure with charging disabled before trusting it, and states the limits plainly: a variable supply will interrupt charging, a house battery competes for the same surplus and this does not arbitrate, and the car's state of charge is not visible through the charger at all. Co-Authored-By: Claude Opus 5 --- README.md | 2 + docs/solar-surplus-charging.md | 268 +++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 docs/solar-surplus-charging.md diff --git a/README.md b/README.md index 982cd38..50d615e 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,8 @@ data: ## Automation Examples +For charging from solar surplus, see [docs/solar-surplus-charging.md](docs/solar-surplus-charging.md) — a worked setup that follows your export, respects the charger's 1.5 kW floor, and reads its bounds from the entity rather than hardcoding them. + ### Stop charging when energy price is high ```yaml diff --git a/docs/solar-surplus-charging.md b/docs/solar-surplus-charging.md new file mode 100644 index 0000000..7478a07 --- /dev/null +++ b/docs/solar-surplus-charging.md @@ -0,0 +1,268 @@ +# Charging from solar surplus + +Match the charging rate to whatever your panels are producing beyond +what the house is using, so the car soaks up surplus instead of +exporting it. + +This is a worked example, not part of the integration. Everything here +goes in your Home Assistant configuration. + +--- + +## What you need + +Three entities from this integration, named for your charger. Replace +`daze_homett` with whatever yours is called: + +| Purpose | Entity | +|---|---| +| Charging limit in watts | `number.daze_homett_power` | +| Start and stop | `switch.daze_homett_charge_control` | +| What the charger is doing | `sensor.daze_homett_evse_status` | +| What the car is drawing now | `sensor.daze_homett_instant_power` | + +And one from your own setup, which this example calls +`sensor.grid_power`: **instantaneous grid power in watts, negative when +exporting**. Most energy meters expose this. If yours reports import and +export as two separate positive sensors, combine them first: + +```yaml +template: + - sensor: + - name: "Grid power" + unique_id: grid_power_combined + unit_of_measurement: W + device_class: power + state: > + {{ states('sensor.grid_import') | float(0) + - states('sensor.grid_export') | float(0) }} +``` + +--- + +## Why the numbers below are what they are + +Three constraints come from the charger itself, measured rather than +assumed: + +- **It will not charge below 1500 W.** Asking for less is rejected + outright. So there is no point starting until the surplus can sustain + roughly 1.6 kW, and the car must be stopped rather than turned down + when surplus falls below that. +- **A change takes several seconds to take effect**, and the charger + reports its own state on a delay. Adjusting every few seconds fights + itself; every two minutes is plenty. +- **The car decides what it actually draws.** The limit is a ceiling. + A car that wants less will take less, and raising the limit does not + make it take more. + +--- + +## Available surplus + +Surplus is what you are exporting *plus* what the car is already +taking, because the car's own draw is not surplus that has gone away — +it is surplus you are already using. + +```yaml +template: + - sensor: + - name: "Solar surplus for car" + unique_id: solar_surplus_for_car + unit_of_measurement: W + device_class: power + state: > + {% set grid = states('sensor.grid_power') | float(0) %} + {% set car = states('sensor.daze_homett_instant_power') | float(0) %} + {# grid is negative while exporting, so subtracting adds it #} + {{ [ (car - grid) | round(0), 0 ] | max }} + availability: > + {{ has_value('sensor.grid_power') + and has_value('sensor.daze_homett_instant_power') }} +``` + +Smooth it, or passing clouds will have you starting and stopping all +afternoon: + +```yaml +sensor: + - platform: filter + name: "Solar surplus smoothed" + entity_id: sensor.solar_surplus_for_car + filters: + - filter: time_simple_moving_average + window_size: "00:05" + precision: 0 +``` + +--- + +## Settings you can tune from the dashboard + +```yaml +input_number: + solar_charge_minimum: + name: Minimum surplus to charge + min: 1500 + max: 5000 + step: 100 + unit_of_measurement: W + initial: 1700 + + solar_charge_deadband: + name: Ignore changes smaller than + min: 100 + max: 1000 + step: 50 + unit_of_measurement: W + initial: 300 + +input_boolean: + solar_charging_enabled: + name: Solar charging + icon: mdi:solar-power +``` + +The minimum sits above 1500 W deliberately. Starting exactly at the +floor means the first cloud drops you below it. + +--- + +## Follow the surplus + +```yaml +automation: + - alias: "Solar: follow surplus" + id: solar_follow_surplus + mode: single + trigger: + - platform: time_pattern + minutes: "/2" + condition: + - condition: state + entity_id: input_boolean.solar_charging_enabled + state: "on" + - condition: state + entity_id: sensor.daze_homett_evse_status + state: "charging" + action: + - variables: + surplus: "{{ states('sensor.solar_surplus_smoothed') | float(0) }}" + deadband: "{{ states('input_number.solar_charge_deadband') | float(300) }}" + now_set: "{{ states('number.daze_homett_power') | float(0) }}" + floor: "{{ state_attr('number.daze_homett_power', 'min') | float(1600) }}" + ceiling: "{{ state_attr('number.daze_homett_power', 'max') | float(7400) }}" + target: > + {{ [ [ surplus, floor ] | max, ceiling ] | min | round(0) }} + - condition: template + # Only act on a change worth making. Without this the limit is + # rewritten every two minutes for no benefit. + value_template: "{{ (target - now_set) | abs >= deadband }}" + - service: number.set_value + target: + entity_id: number.daze_homett_power + data: + value: "{{ target }}" +``` + +`min` and `max` are read from the entity rather than hardcoded. The +integration derives them from the charger's power floor at the measured +voltage and from the installation rating, so they move with conditions +and differ between installations. + +--- + +## Start when there is enough, stop when there is not + +```yaml +automation: + - alias: "Solar: start charging" + id: solar_start_charging + mode: single + trigger: + - platform: numeric_state + entity_id: sensor.solar_surplus_smoothed + above: input_number.solar_charge_minimum + for: "00:05:00" + condition: + - condition: state + entity_id: input_boolean.solar_charging_enabled + state: "on" + - condition: state + entity_id: sensor.daze_homett_evse_status + state: + - idle + - paused + action: + # Set the rate before starting, so the first minutes are not + # spent pulling from the grid at whatever the limit happened to be. + - service: number.set_value + target: + entity_id: number.daze_homett_power + data: + value: > + {% set surplus = states('sensor.solar_surplus_smoothed') | float(0) %} + {% set floor = state_attr('number.daze_homett_power', 'min') | float(1600) %} + {% set ceiling = state_attr('number.daze_homett_power', 'max') | float(7400) %} + {{ [ [ surplus, floor ] | max, ceiling ] | min | round(0) }} + - delay: "00:00:15" + - service: switch.turn_on + target: + entity_id: switch.daze_homett_charge_control + + - alias: "Solar: stop charging" + id: solar_stop_charging + mode: single + trigger: + - platform: numeric_state + entity_id: sensor.solar_surplus_smoothed + below: input_number.solar_charge_minimum + # Longer than the start delay: stopping and restarting is + # harder on the car than riding out a cloud. + for: "00:10:00" + condition: + - condition: state + entity_id: input_boolean.solar_charging_enabled + state: "on" + - condition: state + entity_id: sensor.daze_homett_evse_status + state: "charging" + action: + - service: switch.turn_off + target: + entity_id: switch.daze_homett_charge_control +``` + +--- + +## Before you trust it + +Run it with `input_boolean.solar_charging_enabled` **off** for a day and +watch `sensor.solar_surplus_smoothed` against your actual export. If the +surplus figure is wrong, everything built on it is wrong, and that is +much easier to see before the car is involved. + +Then check these, in order: + +1. **Does the surplus sensor go to zero at night?** If not, the sign + convention on your grid sensor is inverted. +2. **Does it rise when the car stops charging?** It should not. If it + does, the car's own draw is being double counted. +3. **With charging enabled, does the limit track the surplus** without + changing more than a few times an hour? If it flaps, raise the + deadband or lengthen the smoothing window. + +--- + +## Known rough edges + +- **A cloudy day will stop and start the car.** The ten minute delay + helps, but nothing here can make a variable supply steady. If your car + dislikes being interrupted, raise `solar_charge_minimum` so it only + runs on genuinely sunny periods. +- **This ignores the battery, if you have one.** A house battery and a + car compete for the same surplus, and deciding which wins is a policy + question this example does not answer. +- **Nothing here reads the car's state of charge.** Home Assistant + cannot see it through the charger, so a nearly full car that stops + drawing looks the same as a cloud. From e685a5168a31e03d4f7a267f8f3fc2dfc1df79ea Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 10:05:23 +0100 Subject: [PATCH 41/82] docs: design for solar surplus control inside the integration Specifies following solar surplus from the integration rather than from user-written automations, after brainstorming the shape with the four decisions that drive it: separate grid import and export sensors, control of both the limit and start/stop, pure solar with no importing to charge, and disarming the moment the user touches the control. The decision logic is a pure function with no Home Assistant imports, following payload.py and optimistic.py. Both are heavily tested and neither has produced a defect in review, while the Home-Assistant coupled code has produced most of them. Records several behaviours that only became apparent while working through it: - A charger that cannot answer must never be read as an absence of surplus, which would produce a stop. This happened in practice when the wallbox lost power. - A car that has finished charging stops drawing while surplus is still high, so a naive controller restarts it, the car ignores it, and the cycle repeats until sunset. Needs an explicit back-off, not just a rate limit. - Timers reset on a Home Assistant restart, which could stop a healthy charge moments after boot unless they are seeded from observed state. - The vendor's own Solar Boost and any charger schedule are competing controllers, so solar control refuses to arm alongside them. - A three-phase supply feeding a single-phase charger can show surplus the charger cannot reach. Also specifies asymmetric timing, since unused cheap power costs nothing but imported expensive power is exactly what this mode exists to avoid, and a dry-run state that is the default on first enable. Not implemented. Open questions are listed at the end of the spec. Co-Authored-By: Claude Opus 5 --- ...2026-09-29-solar-surplus-control-design.md | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md diff --git a/docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md b/docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md new file mode 100644 index 0000000..8f61c9b --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md @@ -0,0 +1,289 @@ +# Solar surplus control + +Design for following solar surplus from inside the integration, rather +than from user-written automations. + +Status: approved in outline, spec pending review. +Date: 2026-09-29 + +--- + +## Purpose + +Charge the car from what the house would otherwise export, adjusting +the charger's limit as production and household load change, and +stopping when there is not enough surplus to charge at all. + +Today this is possible only as user-written YAML +(`docs/solar-surplus-charging.md`). That works, but requires assembling +template sensors, filters, input helpers and three automations, and +substituting entity names correctly. This moves the logic into the +integration so it works after picking two sensors. + +--- + +## Decisions taken + +| Question | Decision | +|---|---| +| Available signal | Separate grid import and export sensors, both positive | +| Control scope | Limit **and** start/stop | +| Below the charger's floor | Stop. Pure solar, never import to charge | +| Manual override | Touching the control disarms solar mode | + +The floor is not a constant: the charger enforces a minimum **power** +of 1500 W, so the minimum current depends on supply voltage. The +ceiling is the installation rating. Both are already computed by +`payload.py` and exposed as entity bounds, and are read from there +rather than restated. + +--- + +## Non-goals + +- **Arbitrating with a house battery.** A battery and a car compete for + the same surplus; deciding which wins is a policy question this does + not answer. +- **Tariff or time-of-use scheduling.** Separate concern, and better + served by an automation that arms and disarms solar mode. +- **Knowing the car's state of charge.** Not visible through the + charger. A full car that stops drawing is indistinguishable from a + cloud. +- **Replacing the YAML guide.** It stays, for setups this does not fit. +- **Three-phase surplus.** Grid meters usually report net across + phases. A three-phase supply feeding a single-phase charger can show + surplus that exists mostly on phases the charger cannot reach, and + following it would overload one. Rather than be quietly wrong, solar + control refuses to arm in that combination; see Error handling. + +--- + +## Architecture + +Four components, split so that the risky logic carries no Home +Assistant coupling. This follows `payload.py` and `optimistic.py`, +which are pure and heavily tested; the defects found in review have +clustered in the Home-Assistant-coupled code. + +### `solar.py` — the decision + +No Home Assistant imports. One function: + +```python +def decide(state: SolarState) -> SolarDecision +``` + +`SolarState` carries the smoothed surplus, the reserve, the charger's +floor and ceiling, whether it is charging, the present limit, whether a +command is pending, whether the charger is reachable, whether the +vendor's own eco mode is on, whether a car is connected, and the +elapsed timers. + +`SolarDecision` carries an action — `start`, `stop`, `set(watts)` or +`nothing` — and a reason string. Every branch produces a reason; it +becomes both the log line and a visible attribute. + +### `solar_controller.py` — the coupling + +Owns a repeating timer, reads sensors from the state machine, computes +and smooths surplus, calls `decide()`, and acts through the existing +API client. Registered in `async_setup_entry` and torn down with the +entry, alongside the coordinator's own timers. + +It writes through the **API client, never through the number entity**. +That makes the manual-override rule mechanical: any call arriving at +`async_set_native_value` is by definition external, so solar mode +disarms. There is no "was that me?" flag to get wrong. + +A consequence worth stating plainly: a user's **own automation** +calling `number.set_value` also disarms solar mode. This is intended — +an automation is external control — but it is surprising if +undocumented. + +### Entities + +| Entity | Purpose | +|---|---| +| `select._solar_control` | `off` / `simulate` / `active` | +| `number._solar_reserve` | Watts to leave for the house first | +| `sensor._solar_surplus` | Smoothed surplus, for visibility | + +**Chosen while writing, flagged for review:** a three-state select +rather than two switches. `simulate` is dry-run — it decides and logs +but sends nothing. A switch pair would make the illegal combination +"dry run on, solar off" representable; a select cannot. + +The select restores its state across restarts, and defaults to +`simulate` the first time it is enabled. + +### Configuration + +The existing options flow gains two entity pickers: the grid import +sensor and the grid export sensor. Both are required before solar +control can leave `off`. + +Timings are constants rather than options. They are derived from +measured charger behaviour, not preference, and exposing them invites +misconfiguration of a feature that drives hardware. The reserve is the +one genuinely site-specific value, so it is an entity. + +--- + +## Surplus + +``` +surplus = car_draw + export − import +``` + +The car's own draw is added back because it is not surplus that has +disappeared — it is surplus already in use. Without that term the +controller would see its own consumption as a deficit and wind itself +down to zero. + +Smoothed internally over a five-minute window. Raw grid readings move +with every kettle and oven cycle; acting on them would thrash a charger +that takes seconds to apply a change. + +--- + +## Decision logic + +Evaluated in order, first match wins: + +1. Charger unreachable, or a command still pending → `nothing` +2. Vendor eco mode enabled, or a charger schedule is set → `nothing`, + entity marked unavailable +3. Not charging and no car connected → `nothing` +4. Backed off after a start the car ignored → `nothing` until the + back-off expires +5. Charging and `surplus − reserve` below floor for the stop delay → `stop` +6. Charging and minimum run time not elapsed → `nothing` +7. Not charging and `surplus − reserve` above floor for the start delay → `start` at target +8. Charging and `|target − current| ≥ deadband` → `set(target)` +9. Otherwise → `nothing` + +``` +target = clamp(surplus − reserve, floor, ceiling) +``` + +Rules 1 to 4 are guards and come first deliberately. A charger that +cannot answer must never be read as "no surplus", which would produce a +stop; this happened in practice when the wallbox lost power, and the +resulting errors blamed the cloud service rather than the power supply. + +### Asymmetric timing + +A drop below the floor is evaluated **immediately** on a sensor update, +bypassing the tick. Everything else waits for the next tick. + +Unused cheap power costs nothing; imported expensive power is exactly +what pure-solar mode exists to avoid. A fixed tick would import for up +to two minutes after every collapse. + +### The car that will not draw + +When a car finishes, it stops drawing while surplus is still high. The +charger goes idle, the controller sees "not charging, plenty of +surplus", and starts again. The car takes nothing, and the cycle +repeats until sunset. + +After issuing a start, the controller watches for the car to draw more +than a nominal amount within a grace period. If it does not, solar +control backs off for a long interval rather than retrying. The rate +limit would blunt this loop but is the wrong instrument: it is a +backstop against bugs, not a substitute for handling a state the design +knows about. + +### Starting from an unknown state + +On a Home Assistant restart the controller's timers begin at zero. If +the car was already charging, an unelapsed minimum-run-time and an +unaccumulated surplus timer could stop a perfectly good charge moments +after boot. + +Timers are therefore seeded from observed state rather than zero: a +charger already charging at startup is treated as having satisfied its +minimum run time, and surplus timers begin accumulating from the first +reading rather than assuming the threshold was only just crossed. + +### Constants + +| Name | Default | Why | +|---|---|---| +| Tick | 120 s | Charger takes seconds to apply a change and may need retries | +| Smoothing window | 5 min | Rides out household load steps | +| Start delay | 5 min | Confirms surplus is real before starting | +| Stop delay | 10 min | Longer than start: interrupting a car is worse than riding out a cloud | +| Minimum run time | 10 min | Prevents cycling when surplus hovers at the threshold | +| Deadband | 300 W | Avoids rewriting the limit for trivial changes | +| Rate limit | 20 commands/hour | Hard ceiling regardless of what the logic decides | +| Reserve | 0 W | Site-specific; user sets it | +| Draw grace period | 5 min | How long a started car has to begin drawing | +| Ignored-start back-off | 60 min | Before retrying a car that did not draw | + +--- + +## Error handling + +| Situation | Behaviour | +|---|---| +| Import or export sensor unknown or unavailable | Skip the cycle, log once, do **not** stop charging | +| Sensor reports a non-numeric state | Same as unavailable | +| Charger unreachable | Skip. Never infer surplus state from it | +| Command fails | Hand to the existing background retry; do not retry here | +| Command still pending | Skip the cycle entirely | +| Vendor eco mode enabled | Refuse to arm; explain why | +| Charger schedule configured | Refuse to arm; explain why | +| Three-phase supply, single-phase charger | Refuse to arm; explain why | +| Car does not draw after a start | Back off; do not retry until the interval expires | +| Rate limit reached | Skip, log at warning, resume next hour | + +The recurring principle: **absence of information is never grounds for +acting.** Every unknown results in doing nothing, because the failure +mode of doing nothing is unused solar, and the failure mode of guessing +is an interrupted charge or an unwanted import. + +--- + +## Testing + +**`solar.py`** — exhaustive, no Home Assistant. Every branch of the +decision table, the ordering between branches, boundary values at the +floor and ceiling, the deadband, and each timer. This is where coverage +matters most and where it is cheapest. + +**`solar_controller.py`** — against the existing stub harness in +`tests/test_entities.py`, which already fakes Home Assistant and the +API client. Covers surplus arithmetic including the car-draw term, +smoothing, the fast path on a collapse, rate limiting, dry-run sending +nothing, and teardown cancelling the timer. + +**Entities** — the select's three states, restoration across restart, +and that manual writes to the number entity disarm solar mode. + +No hardware is required for any of it. Hardware validation is a +separate step: run in `simulate` for a day and compare the logged +decisions against actual production. + +--- + +## Rollout + +1. Ship with solar control defaulting to `off`. +2. First enable lands in `simulate`, not `active`. +3. Document the validation day in the README and in + `docs/solar-surplus-charging.md`, which becomes the "do it yourself" + alternative rather than the only option. + +--- + +## Open questions for review + +1. **Three-state select, or a switch plus a dry-run option?** The select + was chosen while writing this; it is the one structural choice not + discussed beforehand. +2. **Should `simulate` expire?** Left permanent, so nothing starts + driving hardware without an explicit change. +3. **Reserve as a fixed watt figure, or a percentage of surplus?** Watts + is simpler and matches how a house battery reserve is usually + expressed. From e29a2f765da599ea55ef5d0ed71e003825ec890d Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 10:18:08 +0100 Subject: [PATCH 42/82] docs: implementation plan for solar surplus control Ten tasks, each ending in a testable deliverable and a commit. Self-review caught two problems worth recording. The entities task read the controller out of entry data that the wiring task created, so the two were in the wrong order and the first would not have run; they are swapped. And four requirements in the spec had no task at all: reacting immediately to a collapse in surplus, backing off when a started car never draws, seeding timers so a restart does not stop a healthy charge, and refusing a three-phase supply feeding a single-phase charger. Those are now tasks 8 and 9. The plan writes tests before implementations throughout, because the decision logic is where this feature can strand a car, and it is pure enough to test exhaustively without Home Assistant. Nothing is implemented yet. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-29-solar-surplus-control.md | 2657 +++++++++++++++++ 1 file changed, 2657 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-29-solar-surplus-control.md diff --git a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md new file mode 100644 index 0000000..de5be78 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md @@ -0,0 +1,2657 @@ +# Solar Surplus Control 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:** Charge the car from solar surplus by adjusting the charger's limit and starting or stopping it, driven from inside the integration rather than from user-written automations. + +**Architecture:** A pure decision function with no Home Assistant imports decides what to do; a controller object owns a timer, reads the user's grid sensors, and carries the decision out through the existing API client. Three entities expose and control it. This mirrors `payload.py` and `optimistic.py`, which are pure and have produced no review defects, while the Home-Assistant-coupled code has produced most of them. + +**Tech Stack:** Python 3.12+, Home Assistant custom integration, aiohttp (already a dependency). No new third-party packages. Tests run standalone via `python3 tests/run_all.py` — pytest is not installed in this environment. + +**Spec:** `docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md` + +## Global Constraints + +- **Do not bump `manifest.json` version.** The maintainer sets version numbers explicitly; leave `"version": "0.1.6"` untouched. +- **Deploying is `git push`.** There is no separate copy step. Push `main` and force-push the `v0.1.6` tag together, since the tag tracks `main`. +- **Every commit message ends with:** `Co-Authored-By: Claude Opus 5 ` +- **Lint gate:** `ruff check custom_components/daze/` must pass. This is what CI runs. +- **Test gate:** `python3 tests/run_all.py` must report 0 failures. +- **No Home Assistant in the test environment.** Pure modules are imported directly; Home-Assistant-coupled modules are tested through the stub harness in `tests/test_entities.py`. +- **Charger floor is a power figure, not a current.** 1500 W; the equivalent current depends on supply voltage. Always obtain bounds from `payload.min_charging_current` / `payload.max_charging_current`, never hardcode. +- **Absence of information is never grounds for acting.** Every unknown results in `nothing`. + +--- + +### Task 1: The decision function + +**Files:** +- Create: `custom_components/daze/solar.py` +- Test: `tests/test_solar.py` + +**Interfaces:** +- Consumes: nothing. This task has no dependencies. +- Produces: + - `SolarAction` — enum with members `NOTHING`, `START`, `STOP`, `SET` + - `SolarState` — frozen dataclass, keyword-only, fields listed in Step 3 + - `SolarDecision` — frozen dataclass with `action: SolarAction`, `target_watts: int | None`, `reason: str` + - `decide(state: SolarState) -> SolarDecision` + - Constants: `TICK_SECONDS = 120`, `SMOOTHING_SECONDS = 300`, `START_DELAY_SECONDS = 300`, `STOP_DELAY_SECONDS = 600`, `MIN_RUN_SECONDS = 600`, `DEADBAND_W = 300`, `DRAW_GRACE_SECONDS = 300`, `IGNORED_START_BACKOFF_SECONDS = 3600`, `MAX_COMMANDS_PER_HOUR = 20`, `MIN_MEANINGFUL_DRAW_W = 200` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_solar.py`: + +```python +"""Tests for the solar surplus decision function. + +The decision is a pure function so that the risky part of solar +control can be exercised exhaustively without Home Assistant. Every +branch of the decision table is covered here, in the order the table +evaluates them, because the ordering is load-bearing: a guard that +fires late is the same as a guard that does not exist. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE_DIR = ROOT / "custom_components" / "daze" + + +def _load(name: str, filename: str) -> Any: + """Load a single integration module without Home Assistant.""" + spec = importlib.util.spec_from_file_location(name, PACKAGE_DIR / filename) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +solar = _load("daze_solar_under_test", "solar.py") + + +def state(**overrides: Any) -> Any: + """Build a SolarState that is healthy unless overridden. + + Defaults describe a charger that is reachable, idle, with a car + connected and plenty of surplus, so each test changes only the one + thing it is about. + """ + defaults: dict[str, Any] = { + "surplus_w": 4000, + "reserve_w": 0, + "floor_w": 1600, + "ceiling_w": 7400, + "charging": False, + "current_limit_w": 1600, + "command_pending": False, + "charger_reachable": True, + "eco_mode_on": False, + "schedule_set": False, + "car_connected": True, + "seconds_above_threshold": 600, + "seconds_below_threshold": 0, + "seconds_since_start": 0, + "seconds_since_last_command": 3600, + "commands_this_hour": 0, + "backoff_remaining_s": 0, + } + defaults.update(overrides) + return solar.SolarState(**defaults) + + +# ------------------------------------------------------------------ +# Guards, in table order +# ------------------------------------------------------------------ + + +def test_unreachable_charger_does_nothing() -> None: + """A charger that cannot answer must never imply an absence of + surplus, which would produce a stop. Observed in practice when the + wallbox lost power at the wall.""" + decision = solar.decide(state(charger_reachable=False, charging=True)) + assert decision.action is solar.SolarAction.NOTHING + assert "reachable" in decision.reason + + +def test_pending_command_does_nothing() -> None: + """Issuing another command while one is queued stacks requests + against a charger that is already not answering.""" + decision = solar.decide(state(command_pending=True)) + assert decision.action is solar.SolarAction.NOTHING + assert "pending" in decision.reason + + +def test_vendor_eco_mode_does_nothing() -> None: + """Solar Boost is a competing controller on the same setting.""" + decision = solar.decide(state(eco_mode_on=True)) + assert decision.action is solar.SolarAction.NOTHING + assert "eco" in decision.reason.lower() + + +def test_charger_schedule_does_nothing() -> None: + """A schedule decides when the car charges; so does this.""" + decision = solar.decide(state(schedule_set=True)) + assert decision.action is solar.SolarAction.NOTHING + assert "schedule" in decision.reason.lower() + + +def test_no_car_connected_does_not_start() -> None: + """Starting with nothing plugged in only produces errors.""" + decision = solar.decide(state(car_connected=False)) + assert decision.action is solar.SolarAction.NOTHING + + +def test_backoff_blocks_a_restart() -> None: + """A finished car stops drawing while surplus is still high. Without + a back-off the controller restarts it forever.""" + decision = solar.decide(state(backoff_remaining_s=1800)) + assert decision.action is solar.SolarAction.NOTHING + assert "backing off" in decision.reason + + +def test_rate_limit_blocks_everything() -> None: + """A hard ceiling regardless of what the logic wants, so a bug + cannot hammer an API that has already proven fragile.""" + decision = solar.decide( + state(commands_this_hour=solar.MAX_COMMANDS_PER_HOUR) + ) + assert decision.action is solar.SolarAction.NOTHING + assert "rate limit" in decision.reason + + +# ------------------------------------------------------------------ +# Stopping +# ------------------------------------------------------------------ + + +def test_stops_when_surplus_below_floor_for_long_enough() -> None: + """Pure solar: below the charger's floor it cannot charge at all.""" + decision = solar.decide( + state( + charging=True, + surplus_w=1000, + seconds_below_threshold=solar.STOP_DELAY_SECONDS, + seconds_since_start=solar.MIN_RUN_SECONDS + 1, + ) + ) + assert decision.action is solar.SolarAction.STOP + + +def test_does_not_stop_before_the_delay() -> None: + """A passing cloud is not a reason to interrupt the car.""" + decision = solar.decide( + state( + charging=True, + surplus_w=1000, + seconds_below_threshold=60, + seconds_since_start=solar.MIN_RUN_SECONDS + 1, + ) + ) + assert decision.action is not solar.SolarAction.STOP + + +def test_minimum_run_time_outranks_a_stop() -> None: + """Prevents cycling when surplus hovers at the threshold.""" + decision = solar.decide( + state( + charging=True, + surplus_w=1000, + seconds_below_threshold=solar.STOP_DELAY_SECONDS, + seconds_since_start=10, + ) + ) + assert decision.action is solar.SolarAction.NOTHING + assert "minimum run" in decision.reason + + +# ------------------------------------------------------------------ +# Starting +# ------------------------------------------------------------------ + + +def test_starts_when_surplus_sustained() -> None: + decision = solar.decide( + state(surplus_w=4000, seconds_above_threshold=solar.START_DELAY_SECONDS) + ) + assert decision.action is solar.SolarAction.START + assert decision.target_watts == 4000 + + +def test_does_not_start_before_the_delay() -> None: + decision = solar.decide(state(surplus_w=4000, seconds_above_threshold=60)) + assert decision.action is solar.SolarAction.NOTHING + + +def test_does_not_start_below_the_floor() -> None: + decision = solar.decide( + state(surplus_w=1000, seconds_above_threshold=99999) + ) + assert decision.action is solar.SolarAction.NOTHING + + +# ------------------------------------------------------------------ +# Following +# ------------------------------------------------------------------ + + +def test_follows_surplus_when_the_change_is_worth_making() -> None: + decision = solar.decide( + state(charging=True, surplus_w=5000, current_limit_w=1600) + ) + assert decision.action is solar.SolarAction.SET + assert decision.target_watts == 5000 + + +def test_ignores_a_change_inside_the_deadband() -> None: + """Without this the limit is rewritten every tick for no benefit.""" + decision = solar.decide( + state(charging=True, surplus_w=4100, current_limit_w=4000) + ) + assert decision.action is solar.SolarAction.NOTHING + + +def test_target_is_clamped_to_the_ceiling() -> None: + """10 kW of surplus does not make a 32 A charger draw 10 kW.""" + decision = solar.decide( + state(charging=True, surplus_w=10000, current_limit_w=1600) + ) + assert decision.target_watts == 7400 + + +def test_target_is_clamped_to_the_floor() -> None: + decision = solar.decide( + state( + charging=True, + surplus_w=1700, + floor_w=1600, + current_limit_w=7000, + ) + ) + assert decision.target_watts == 1700 + + +def test_reserve_is_subtracted_before_anything_else() -> None: + """The house gets its share first.""" + decision = solar.decide( + state(charging=True, surplus_w=5000, reserve_w=2000, current_limit_w=1600) + ) + assert decision.target_watts == 3000 + + +def test_reserve_can_push_below_the_floor_and_stop() -> None: + decision = solar.decide( + state( + charging=True, + surplus_w=2000, + reserve_w=1000, + seconds_below_threshold=solar.STOP_DELAY_SECONDS, + seconds_since_start=solar.MIN_RUN_SECONDS + 1, + ) + ) + assert decision.action is solar.SolarAction.STOP + + +def test_every_decision_carries_a_reason() -> None: + """The reason becomes the log line and a visible attribute. An + autonomous feature that acts silently cannot be debugged.""" + for decision in ( + solar.decide(state()), + solar.decide(state(charging=True)), + solar.decide(state(charger_reachable=False)), + solar.decide(state(charging=True, surplus_w=5000)), + ): + assert decision.reason + assert decision.reason.strip() == decision.reason + + +def _main() -> int: + """Run every test in this module and report results.""" + tests = [ + value + for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value) + ] + + failures = 0 + for test in tests: + try: + test() + except Exception as err: # noqa: BLE001 - standalone runner + failures += 1 + print(f"FAIL {test.__name__}: {type(err).__name__}: {err}") + else: + print(f"ok {test.__name__}") + + print(f"\n{len(tests) - failures} passed, {failures} failed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(_main()) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 tests/test_solar.py` +Expected: FAIL — `FileNotFoundError` or `ModuleNotFoundError`, because `custom_components/daze/solar.py` does not exist. + +- [ ] **Step 3: Write the decision function** + +Create `custom_components/daze/solar.py`: + +```python +"""Decide what solar control should do, with no Home Assistant coupling. + +The controller reads sensors and issues commands; this module decides. +Keeping the decision pure means the part that can strand a car or +hammer an API is exhaustively testable without a Home Assistant +instance, which is the split that has worked for payload.py and +optimistic.py. + +Ordering in `decide` is load-bearing. The guards come first because a +charger that cannot answer must never be read as an absence of +surplus: that would produce a stop, and it is exactly what happened +when the wallbox lost power at the wall. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +# How often the controller re-evaluates. The charger takes seconds to +# apply a change and may need retries, so a faster cadence fights +# itself. +TICK_SECONDS = 120 + +# Raw grid readings move with every kettle and oven cycle. +SMOOTHING_SECONDS = 300 + +# Confirm surplus is real before starting; be slower to give up than to +# begin, because interrupting a car is worse than riding out a cloud. +START_DELAY_SECONDS = 300 +STOP_DELAY_SECONDS = 600 + +# Once started, stay started, or surplus hovering at the threshold +# cycles the car. +MIN_RUN_SECONDS = 600 + +# Do not rewrite the limit for trivial changes. +DEADBAND_W = 300 + +# A car that has finished stops drawing while surplus is still high. +# Without a back-off the controller restarts it until sunset. +DRAW_GRACE_SECONDS = 300 +IGNORED_START_BACKOFF_SECONDS = 3600 +MIN_MEANINGFUL_DRAW_W = 200 + +# A hard ceiling regardless of what the logic decides, so a bug hits a +# wall rather than an API that has already proven fragile. +MAX_COMMANDS_PER_HOUR = 20 + + +class SolarAction(Enum): + """What the controller should do this cycle.""" + + NOTHING = "nothing" + START = "start" + STOP = "stop" + SET = "set" + + +@dataclass(frozen=True, kw_only=True) +class SolarState: + """Everything the decision depends on. + + Assembled by the controller from the grid sensors, the coordinator + and its own timers. + """ + + surplus_w: float + reserve_w: float + floor_w: int + ceiling_w: int + charging: bool + current_limit_w: int + command_pending: bool + charger_reachable: bool + eco_mode_on: bool + schedule_set: bool + car_connected: bool + seconds_above_threshold: float + seconds_below_threshold: float + seconds_since_start: float + seconds_since_last_command: float + commands_this_hour: int + backoff_remaining_s: float + + +@dataclass(frozen=True, kw_only=True) +class SolarDecision: + """What to do, and why. + + The reason is not decoration: it becomes the log line and an + attribute on the control entity, which is the only way an + autonomous feature can be understood after the fact. + """ + + action: SolarAction + target_watts: int | None + reason: str + + +def _nothing(reason: str) -> SolarDecision: + """Return a do-nothing decision with an explanation.""" + return SolarDecision( + action=SolarAction.NOTHING, target_watts=None, reason=reason + ) + + +def available_watts(state: SolarState) -> float: + """Return the surplus left for the car once the house has its share.""" + return state.surplus_w - state.reserve_w + + +def target_watts(state: SolarState) -> int: + """Return the limit to request, clamped to what the charger accepts.""" + available = available_watts(state) + bounded = max(float(state.floor_w), min(float(state.ceiling_w), available)) + return int(round(bounded)) + + +def decide(state: SolarState) -> SolarDecision: + """Decide what to do this cycle. + + Args: + state: Everything the decision depends on. + + Returns: + The action to take and the reason for it. + + """ + # --- Guards. Nothing below these runs on bad information. --- + + if not state.charger_reachable: + return _nothing("charger is not reachable") + + if state.command_pending: + return _nothing("a command is still pending") + + if state.eco_mode_on: + return _nothing("the charger's own eco mode is controlling it") + + if state.schedule_set: + return _nothing("the charger has a schedule set") + + if state.commands_this_hour >= MAX_COMMANDS_PER_HOUR: + return _nothing("rate limit reached for this hour") + + if state.backoff_remaining_s > 0: + return _nothing( + f"backing off for {int(state.backoff_remaining_s)}s after a " + "start the car ignored" + ) + + available = available_watts(state) + target = target_watts(state) + + # --- Stopping. Checked before starting so a charging car is + # --- considered on its own terms. + + if state.charging: + if available < state.floor_w: + if state.seconds_since_start < MIN_RUN_SECONDS: + return _nothing( + f"surplus {available:.0f} W is below the " + f"{state.floor_w} W floor, but the minimum run time " + "has not elapsed" + ) + + if state.seconds_below_threshold >= STOP_DELAY_SECONDS: + return SolarDecision( + action=SolarAction.STOP, + target_watts=None, + reason=( + f"surplus {available:.0f} W below the " + f"{state.floor_w} W floor for " + f"{int(state.seconds_below_threshold)}s" + ), + ) + + return _nothing( + f"surplus {available:.0f} W is below the floor, waiting " + f"{STOP_DELAY_SECONDS - int(state.seconds_below_threshold)}s " + "before stopping" + ) + + if abs(target - state.current_limit_w) >= DEADBAND_W: + return SolarDecision( + action=SolarAction.SET, + target_watts=target, + reason=( + f"following surplus {available:.0f} W: " + f"{state.current_limit_w} W to {target} W" + ), + ) + + return _nothing( + f"holding at {state.current_limit_w} W, surplus " + f"{available:.0f} W is within the deadband" + ) + + # --- Starting. --- + + if not state.car_connected: + return _nothing("no car is connected") + + if available < state.floor_w: + return _nothing( + f"surplus {available:.0f} W is below the {state.floor_w} W floor" + ) + + if state.seconds_above_threshold < START_DELAY_SECONDS: + return _nothing( + f"surplus {available:.0f} W is sufficient, waiting " + f"{START_DELAY_SECONDS - int(state.seconds_above_threshold)}s " + "to confirm" + ) + + return SolarDecision( + action=SolarAction.START, + target_watts=target, + reason=f"surplus {available:.0f} W sustained, starting at {target} W", + ) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 tests/test_solar.py` +Expected: PASS, `21 passed, 0 failed` + +- [ ] **Step 5: Register the suite with the runner** + +Modify `tests/run_all.py`, in the `STANDALONE` tuple, adding `"test_solar.py"` after `"test_entities.py"`. + +Run: `python3 tests/run_all.py` +Expected: 5 modules, 0 failures. + +- [ ] **Step 6: Lint** + +Run: `ruff check custom_components/daze/ tests/` +Expected: `All checks passed!` + +- [ ] **Step 7: Commit** + +```bash +git add custom_components/daze/solar.py tests/test_solar.py tests/run_all.py +git commit -m "feat: add the solar surplus decision function + +A pure function with no Home Assistant imports, so the part of solar +control that can strand a car or hammer an API is exhaustively +testable. The guards come first deliberately: a charger that cannot +answer must never be read as an absence of surplus, which would +produce a stop. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 2: Surplus arithmetic and smoothing + +**Files:** +- Modify: `custom_components/daze/solar.py` (append) +- Test: `tests/test_solar.py` (append before `_main`) + +**Interfaces:** +- Consumes: `SMOOTHING_SECONDS` from Task 1. +- Produces: + - `compute_surplus(car_draw_w: float, export_w: float, import_w: float) -> float` + - `SurplusSmoother` — class with `add(value: float, now: float) -> None`, `value() -> float | None`, `window_seconds: float` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_solar.py`, immediately before `def _main() -> int:`: + +```python +# ------------------------------------------------------------------ +# Surplus arithmetic +# ------------------------------------------------------------------ + + +def test_surplus_adds_back_the_cars_own_draw() -> None: + """The car's consumption is not surplus that disappeared; it is + surplus already in use. Without this term the controller reads its + own draw as a deficit and winds itself down to zero.""" + assert solar.compute_surplus(car_draw_w=3000, export_w=0, import_w=0) == 3000 + + +def test_surplus_counts_export() -> None: + assert solar.compute_surplus(car_draw_w=0, export_w=4000, import_w=0) == 4000 + + +def test_surplus_subtracts_import() -> None: + """Importing while charging means the car is over-drawing.""" + assert ( + solar.compute_surplus(car_draw_w=3000, export_w=0, import_w=1000) == 2000 + ) + + +def test_surplus_never_goes_negative() -> None: + """A negative surplus is not meaningful to the caller; zero is.""" + assert solar.compute_surplus(car_draw_w=0, export_w=0, import_w=5000) == 0 + + +def test_smoother_reports_nothing_until_it_has_data() -> None: + smoother = solar.SurplusSmoother() + assert smoother.value() is None + + +def test_smoother_averages_its_window() -> None: + smoother = solar.SurplusSmoother() + for index, reading in enumerate((1000, 2000, 3000)): + smoother.add(reading, now=float(index)) + assert smoother.value() == 2000 + + +def test_smoother_discards_readings_outside_the_window() -> None: + """Otherwise this morning's surplus still influences this evening.""" + smoother = solar.SurplusSmoother() + smoother.add(9999, now=0.0) + smoother.add(1000, now=solar.SMOOTHING_SECONDS + 1) + assert smoother.value() == 1000 + + +def test_smoother_survives_a_clock_that_goes_backwards() -> None: + """A restart or a clock correction must not wedge it.""" + smoother = solar.SurplusSmoother() + smoother.add(1000, now=100.0) + smoother.add(2000, now=50.0) + assert smoother.value() is not None +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 tests/test_solar.py` +Expected: FAIL with `AttributeError: module ... has no attribute 'compute_surplus'` + +- [ ] **Step 3: Implement** + +Append to `custom_components/daze/solar.py`: + +```python +def compute_surplus( + car_draw_w: float, export_w: float, import_w: float +) -> float: + """Return the power available to the car, in watts. + + The car's own draw is added back because it is not surplus that has + disappeared: it is surplus already being used. Omitting that term + makes the controller read its own consumption as a deficit and wind + itself down to zero. + + Args: + car_draw_w: What the charger is currently delivering. + export_w: Grid export, positive. + import_w: Grid import, positive. + + Returns: + Available watts, never negative. + + """ + return max(0.0, car_draw_w + export_w - import_w) + + +class SurplusSmoother: + """A moving average over a fixed time window. + + Raw grid readings move with every kettle and oven cycle. Acting on + them would rewrite the charger's limit constantly, against a device + that takes seconds to apply a change. + """ + + def __init__(self, window_seconds: float = SMOOTHING_SECONDS) -> None: + """Initialise an empty window. + + Args: + window_seconds: How much history to average over. + + """ + self.window_seconds = window_seconds + self._samples: list[tuple[float, float]] = [] + + def add(self, value: float, now: float) -> None: + """Record a reading and drop anything that has aged out. + + Args: + value: The reading, in watts. + now: A monotonic timestamp in seconds. + + """ + # A clock that goes backwards, from a restart or a correction, + # would otherwise leave future-dated samples wedged in the + # window forever. + if self._samples and now < self._samples[-1][0]: + self._samples.clear() + + self._samples.append((now, value)) + + cutoff = now - self.window_seconds + self._samples = [ + sample for sample in self._samples if sample[0] >= cutoff + ] + + def value(self) -> float | None: + """Return the average of the window, or None if it is empty.""" + if not self._samples: + return None + + return sum(value for _, value in self._samples) / len(self._samples) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 tests/test_solar.py` +Expected: PASS, `29 passed, 0 failed` + +- [ ] **Step 5: Lint** + +Run: `ruff check custom_components/daze/ tests/` +Expected: `All checks passed!` + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/daze/solar.py tests/test_solar.py +git commit -m "feat: compute and smooth solar surplus + +Surplus is the car's own draw plus export minus import. The car term +matters: its consumption is not surplus that disappeared but surplus +already in use, and without it the controller reads its own draw as a +deficit and winds itself down. + +Smoothed over five minutes, because raw grid readings move with every +kettle cycle and the charger takes seconds to apply a change. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 3: Configuration and constants + +**Files:** +- Modify: `custom_components/daze/const.py` +- Modify: `custom_components/daze/config_flow.py:358-400` (the `DazeOptionsFlowHandler` class) +- Modify: `custom_components/daze/strings.json` +- Modify: `custom_components/daze/translations/it.json` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `CONF_GRID_IMPORT_SENSOR = "grid_import_sensor"` + - `CONF_GRID_EXPORT_SENSOR = "grid_export_sensor"` + - `CONF_SOLAR_RESERVE = "solar_reserve"` + - `DEFAULT_SOLAR_RESERVE = 0` + - `MAX_SOLAR_RESERVE = 5000` + - Options flow accepting both sensor entity IDs as optional strings. + +- [ ] **Step 1: Add the constants** + +Modify `custom_components/daze/const.py`, appending after the `MAX_POLL_INTERVAL` block: + +```python +# Solar surplus control. The two grid sensors are chosen by the user in +# the options flow; both are required before solar control can leave +# "off". +CONF_GRID_IMPORT_SENSOR = "grid_import_sensor" +CONF_GRID_EXPORT_SENSOR = "grid_export_sensor" + +# Watts to leave for the house before the car gets any. Site-specific, +# so it is an entity rather than a constant; this is only its default. +CONF_SOLAR_RESERVE = "solar_reserve" +DEFAULT_SOLAR_RESERVE = 0 +MAX_SOLAR_RESERVE = 5000 +``` + +- [ ] **Step 2: Extend the options flow** + +Modify `custom_components/daze/config_flow.py`. In `DazeOptionsFlowHandler.async_step_init`, replace the `schema = vol.Schema({...})` block with: + +```python + options = self._config_entry.options + schema = vol.Schema( + { + vol.Required( + CONF_POLL_INTERVAL, default=current + ): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_POLL_INTERVAL, max=MAX_POLL_INTERVAL), + ), + # Optional so the integration works without solar. Solar + # control refuses to leave "off" until both are set. + vol.Optional( + CONF_GRID_IMPORT_SENSOR, + description={ + "suggested_value": options.get(CONF_GRID_IMPORT_SENSOR) + }, + ): selector.EntitySelector( + selector.EntitySelectorConfig( + domain="sensor", device_class="power" + ) + ), + vol.Optional( + CONF_GRID_EXPORT_SENSOR, + description={ + "suggested_value": options.get(CONF_GRID_EXPORT_SENSOR) + }, + ): selector.EntitySelector( + selector.EntitySelectorConfig( + domain="sensor", device_class="power" + ) + ), + } + ) +``` + +Add to the imports at the top of `config_flow.py`: + +```python +from homeassistant.helpers import selector +``` + +Add `CONF_GRID_EXPORT_SENSOR` and `CONF_GRID_IMPORT_SENSOR` to the existing `from .const import (...)` block, in alphabetical position. + +- [ ] **Step 3: Add the English strings** + +Modify `custom_components/daze/strings.json`. In `options.step.init.data`, add: + +```json + "grid_import_sensor": "Grid import power sensor", + "grid_export_sensor": "Grid export power sensor" +``` + +And replace `options.step.init.description` with: + +```json + "description": "How often to poll the Daze cloud API, and which sensors report your grid import and export. The grid sensors are only needed for solar control; leave them empty otherwise." +``` + +- [ ] **Step 4: Add the Italian strings** + +Modify `custom_components/daze/translations/it.json`, same keys: + +```json + "grid_import_sensor": "Sensore di potenza prelevata dalla rete", + "grid_export_sensor": "Sensore di potenza immessa in rete" +``` + +And the description: + +```json + "description": "Ogni quanto interrogare l'API cloud di Daze e quali sensori riportano prelievo e immissione in rete. I sensori di rete servono solo per il controllo solare; lasciali vuoti altrimenti." +``` + +- [ ] **Step 5: Verify the JSON parses and lint passes** + +Run: +```bash +python3 -c "import json; [json.load(open(f)) for f in ['custom_components/daze/strings.json','custom_components/daze/translations/it.json']]; print('valid')" +ruff check custom_components/daze/ +python3 tests/run_all.py +``` +Expected: `valid`, `All checks passed!`, 0 failures. + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/daze/const.py custom_components/daze/config_flow.py custom_components/daze/strings.json custom_components/daze/translations/it.json +git commit -m "feat: let the user pick grid import and export sensors + +Both optional, so the integration works unchanged without solar. +Solar control refuses to leave 'off' until both are set, which is +checked where it can be explained rather than by making the fields +required here. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 4: The controller + +**Files:** +- Create: `custom_components/daze/solar_controller.py` +- Test: `tests/test_solar_controller.py` + +**Interfaces:** +- Consumes: everything from Tasks 1 and 2; `CONF_GRID_IMPORT_SENSOR`, `CONF_GRID_EXPORT_SENSOR` from Task 3; `payload.min_charging_current`, `payload.max_charging_current`, `payload.milliamps_to_watts`, `payload.watts_to_milliamps`, `payload.charger_offline_reason`, `payload.is_charge_enabled`; `DazeDataUpdateCoordinator.limit_state`, `.api_client`, `.serial_number`, `.data`, `.async_schedule_refresh_in`. +- Produces: + - `SolarMode` — enum with `OFF = "off"`, `SIMULATE = "simulate"`, `ACTIVE = "active"` + - `SolarController` — class with `async_start()`, `async_stop()`, `mode` property and setter, `last_decision` property, `reserve_w` property and setter, `surplus_w` property, `async_tick()`, `add_listener(cb) -> remove_cb` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_solar_controller.py`: + +```python +"""Tests for the solar controller against a stubbed Home Assistant. + +The controller is where the decision meets real sensors and a real API +client, so these cover the joins: reading the sensors, assembling the +state, honouring simulate, and not fighting the retry machinery. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import sys +import types +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE_DIR = ROOT / "custom_components" / "daze" + + +class StubState: + """A Home Assistant state object.""" + + def __init__(self, state: str) -> None: + self.state = state + + +class StubStates: + """The subset of hass.states the controller uses.""" + + def __init__(self) -> None: + self._states: dict[str, StubState] = {} + + def set(self, entity_id: str, value: str) -> None: + """Set a state.""" + self._states[entity_id] = StubState(value) + + def get(self, entity_id: str) -> StubState | None: + """Return a state, or None if unknown.""" + return self._states.get(entity_id) + + +class StubHass: + """Just enough of HomeAssistant for the controller.""" + + def __init__(self) -> None: + self.states = StubStates() + + +def _install_stubs() -> None: + """Register the Home Assistant modules the controller imports.""" + def _module(name: str, **attributes: Any) -> None: + module = types.ModuleType(name) + for key, value in attributes.items(): + setattr(module, key, value) + sys.modules[name] = module + + scheduled: list[Any] = [] + + def async_call_later(hass: Any, delay: Any, action: Any) -> Any: + scheduled.append((delay, action)) + return lambda: None + + _module("homeassistant") + _module("homeassistant.core", HomeAssistant=StubHass, callback=lambda fn: fn) + _module("homeassistant.helpers") + _module("homeassistant.helpers.event", async_call_later=async_call_later) + + +_install_stubs() + + +def _load_package() -> None: + """Load the integration modules the controller needs.""" + package = types.ModuleType("daze_solar_ctl") + package.__path__ = [str(PACKAGE_DIR)] + sys.modules["daze_solar_ctl"] = package + + for name in ("const", "payload", "optimistic", "solar"): + spec = importlib.util.spec_from_file_location( + f"daze_solar_ctl.{name}", PACKAGE_DIR / f"{name}.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[f"daze_solar_ctl.{name}"] = module + spec.loader.exec_module(module) + + spec = importlib.util.spec_from_file_location( + "daze_solar_ctl.solar_controller", + PACKAGE_DIR / "solar_controller.py", + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules["daze_solar_ctl.solar_controller"] = module + spec.loader.exec_module(module) + + +_load_package() + +solar = sys.modules["daze_solar_ctl.solar"] +optimistic = sys.modules["daze_solar_ctl.optimistic"] +controller_module = sys.modules["daze_solar_ctl.solar_controller"] + + +class FakeApi: + """Records the commands the controller issues.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, Any]] = [] + + async def async_set_max_charging_current( + self, serial: str, current_ma: int, attempts: int = 8 + ) -> dict: + self.calls.append(("current", current_ma)) + return {} + + async def async_start_charge(self, serial: str, attempts: int = 8) -> dict: + self.calls.append(("start", serial)) + return {} + + async def async_stop_charge(self, serial: str, attempts: int = 8) -> dict: + self.calls.append(("stop", serial)) + return {} + + +class FakeCoordinator: + """The coordinator surface the controller touches.""" + + def __init__(self, data: dict[str, Any]) -> None: + self.data = data + self.api_client = FakeApi() + self.serial_number = "SER1" + self.limit_state = optimistic.OptimisticState() + self.refresh_delays: list[int] = [] + + def async_schedule_refresh_in(self, delay: int) -> None: + self.refresh_delays.append(delay) + + +CHARGING_DATA: dict[str, Any] = { + "active": True, + "lastAttributesUpdatedOn": None, + "evseStatus": "charging", + "evseState": 3, + "instantPowerAsWatt": 3000, + "maxExternalChargingCurrentInMilliAmps": 13000, + "lastMaxInstallationCurrent": 32000, + "lastACVoltageL1": 230, + "ecoModeEnabled": False, + "schedules": [], + "chargeSession": {"sessionId": 1}, +} + + +def build(data: dict[str, Any] | None = None) -> tuple[Any, Any, Any]: + """Build a controller wired to stubs.""" + hass = StubHass() + hass.states.set("sensor.grid_import", "0") + hass.states.set("sensor.grid_export", "5000") + + coordinator = FakeCoordinator(dict(data or CHARGING_DATA)) + controller = controller_module.SolarController( + hass=hass, + coordinator=coordinator, + import_entity="sensor.grid_import", + export_entity="sensor.grid_export", + ) + return controller, coordinator, hass + + +def test_surplus_uses_both_sensors_and_the_car_draw() -> None: + """3000 W drawn plus 5000 W exported is 8000 W available.""" + controller, _, _ = build() + asyncio.run(controller.async_tick()) + assert controller.surplus_w == 8000 + + +def test_simulate_decides_but_sends_nothing() -> None: + """The default on first enable. It must be genuinely inert.""" + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.SIMULATE + + asyncio.run(controller.async_tick()) + + assert coordinator.api_client.calls == [] + assert controller.last_decision is not None + + +def test_off_does_not_even_decide() -> None: + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.OFF + + asyncio.run(controller.async_tick()) + + assert coordinator.api_client.calls == [] + + +def test_active_follows_surplus() -> None: + """13000 mA at 230 V is about 2990 W; 8000 W of surplus should + raise it, and the request is made in milliamps.""" + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + # Seed the smoother so the first tick has a usable average. + asyncio.run(controller.async_tick()) + asyncio.run(controller.async_tick()) + + assert any(call[0] == "current" for call in coordinator.api_client.calls) + + +def test_a_missing_sensor_stops_nothing() -> None: + """Absence of information is never grounds for acting.""" + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + hass.states.set("sensor.grid_export", "unavailable") + + asyncio.run(controller.async_tick()) + + assert coordinator.api_client.calls == [] + + +def test_a_pending_command_is_not_piled_on() -> None: + """The integration already retries in the background for minutes.""" + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + coordinator.limit_state.request(20000) + + asyncio.run(controller.async_tick()) + asyncio.run(controller.async_tick()) + + assert coordinator.api_client.calls == [] + + +def test_the_reserve_is_honoured() -> None: + controller, _, _ = build() + controller.reserve_w = 2000 + asyncio.run(controller.async_tick()) + assert controller.surplus_w == 8000 + assert controller.last_decision is None or True + + +def test_listeners_are_told_after_a_tick() -> None: + """The entities redraw from this rather than polling the object.""" + controller, _, _ = build() + seen: list[int] = [] + controller.add_listener(lambda: seen.append(1)) + + asyncio.run(controller.async_tick()) + + assert seen + + +def _main() -> int: + """Run every test in this module and report results.""" + tests = [ + value + for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value) + ] + + failures = 0 + for test in tests: + try: + test() + except Exception as err: # noqa: BLE001 - standalone runner + failures += 1 + print(f"FAIL {test.__name__}: {type(err).__name__}: {err}") + else: + print(f"ok {test.__name__}") + + print(f"\n{len(tests) - failures} passed, {failures} failed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(_main()) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 tests/test_solar_controller.py` +Expected: FAIL — `solar_controller.py` does not exist. + +- [ ] **Step 3: Implement the controller** + +Create `custom_components/daze/solar_controller.py`: + +```python +"""Drive the charger from solar surplus. + +Reads the user's grid sensors, assembles the state the decision needs, +and carries out whatever it returns. The decision itself lives in +solar.py, which has no Home Assistant coupling and is where the +behaviour is tested. + +Commands go through the API client, never through the number entity. +That makes the manual-override rule mechanical: any write arriving at +the entity is by definition external, so solar control disarms itself +without needing a flag that could be wrong. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Callable +from enum import Enum +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_call_later + +from .coordinator import DazeDataUpdateCoordinator +from .payload import ( + charger_offline_reason, + is_charge_enabled, + max_charging_current, + milliamps_to_watts, + min_charging_current, + watts_to_milliamps, +) +from .solar import ( + DRAW_GRACE_SECONDS, + IGNORED_START_BACKOFF_SECONDS, + MAX_COMMANDS_PER_HOUR, + MIN_MEANINGFUL_DRAW_W, + TICK_SECONDS, + SolarAction, + SolarDecision, + SolarState, + SurplusSmoother, + compute_surplus, + decide, +) + +_LOGGER = logging.getLogger(__name__) + + +class SolarMode(Enum): + """How much authority solar control has. + + A single tri-state rather than two switches, so that "dry run on, + solar off" cannot be expressed. + """ + + OFF = "off" + SIMULATE = "simulate" + ACTIVE = "active" + + +class SolarController: + """Evaluates surplus on a timer and acts on the result.""" + + def __init__( + self, + hass: HomeAssistant, + coordinator: DazeDataUpdateCoordinator, + import_entity: str | None, + export_entity: str | None, + ) -> None: + """Initialise in the off state. + + Args: + hass: Used to read the grid sensors and schedule ticks. + coordinator: Source of charger state and the API client. + import_entity: Grid import power sensor, or None. + export_entity: Grid export power sensor, or None. + + """ + self._hass = hass + self._coordinator = coordinator + self._import_entity = import_entity + self._export_entity = export_entity + + self._mode = SolarMode.OFF + self._reserve_w = 0.0 + self._smoother = SurplusSmoother() + self._last_decision: SolarDecision | None = None + self._listeners: list[Callable[[], None]] = [] + self._cancel_tick: Callable[[], None] | None = None + + self._above_since: float | None = None + self._below_since: float | None = None + self._started_at: float | None = None + self._backoff_until: float = 0.0 + self._command_times: list[float] = [] + self._sensor_warning_logged = False + + # ------------------------------------------------------------------ + # Public surface + # ------------------------------------------------------------------ + + @property + def mode(self) -> SolarMode: + """Return the current mode.""" + return self._mode + + @mode.setter + def mode(self, value: SolarMode) -> None: + """Set the mode, resetting timers when it changes.""" + if value is self._mode: + return + + self._mode = value + self._above_since = None + self._below_since = None + _LOGGER.info("Solar control set to %s", value.value) + self._notify() + + @property + def reserve_w(self) -> float: + """Return the watts held back for the house.""" + return self._reserve_w + + @reserve_w.setter + def reserve_w(self, value: float) -> None: + """Set the reserve.""" + self._reserve_w = max(0.0, float(value)) + self._notify() + + @property + def surplus_w(self) -> float | None: + """Return the smoothed surplus, or None before the first read.""" + return self._smoother.value() + + @property + def last_decision(self) -> SolarDecision | None: + """Return the most recent decision, for display and logging.""" + return self._last_decision + + @property + def configured(self) -> bool: + """Whether both grid sensors have been chosen.""" + return bool(self._import_entity and self._export_entity) + + def add_listener(self, listener: Callable[[], None]) -> Callable[[], None]: + """Register a callback for state changes. + + Returns: + A callable that unregisters the listener. + + """ + self._listeners.append(listener) + + def _remove() -> None: + if listener in self._listeners: + self._listeners.remove(listener) + + return _remove + + async def async_start(self) -> None: + """Begin ticking.""" + self._schedule_tick() + + async def async_stop(self) -> None: + """Stop ticking and drop listeners.""" + if self._cancel_tick is not None: + self._cancel_tick() + self._cancel_tick = None + self._listeners.clear() + + # ------------------------------------------------------------------ + # The cycle + # ------------------------------------------------------------------ + + async def async_tick(self) -> None: + """Evaluate once and act if the mode allows it.""" + if self._mode is SolarMode.OFF: + return + + now = time.monotonic() + surplus = self._read_surplus() + + if surplus is None: + # Absence of information is never grounds for acting. + if not self._sensor_warning_logged: + self._sensor_warning_logged = True + _LOGGER.warning( + "Solar control cannot read its grid sensors; doing " + "nothing until they report" + ) + return + + self._sensor_warning_logged = False + self._smoother.add(surplus, now) + + smoothed = self._smoother.value() + if smoothed is None: + return + + state = self._build_state(smoothed, now) + self._track_thresholds(state, now) + + decision = decide(self._build_state(smoothed, now)) + self._last_decision = decision + + if decision.action is SolarAction.NOTHING: + _LOGGER.debug("Solar control: %s", decision.reason) + self._notify() + return + + if self._mode is SolarMode.SIMULATE: + _LOGGER.info( + "Solar control (simulating): would %s — %s", + decision.action.value, + decision.reason, + ) + self._notify() + return + + await self._carry_out(decision, now) + self._notify() + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _schedule_tick(self) -> None: + """Queue the next evaluation.""" + + async def _run(_now: Any) -> None: + self._cancel_tick = None + try: + await self.async_tick() + finally: + self._schedule_tick() + + self._cancel_tick = async_call_later(self._hass, TICK_SECONDS, _run) + + def _read_number(self, entity_id: str | None) -> float | None: + """Read a numeric sensor, or None if it cannot be used.""" + if not entity_id: + return None + + state = self._hass.states.get(entity_id) + if state is None: + return None + + try: + return float(state.state) + except (TypeError, ValueError): + return None + + def _read_surplus(self) -> float | None: + """Compute surplus from the grid sensors and the car's draw.""" + import_w = self._read_number(self._import_entity) + export_w = self._read_number(self._export_entity) + + if import_w is None or export_w is None: + return None + + data = self._coordinator.data or {} + car_draw = data.get("instantPowerAsWatt") + car_w = float(car_draw) if isinstance(car_draw, (int, float)) else 0.0 + + return compute_surplus( + car_draw_w=car_w, export_w=export_w, import_w=import_w + ) + + def _build_state(self, smoothed: float, now: float) -> SolarState: + """Assemble everything the decision depends on.""" + data = self._coordinator.data or {} + + limit_ma = data.get("maxExternalChargingCurrentInMilliAmps") or 0 + charging = bool(is_charge_enabled(data)) + schedules = data.get("schedules") + + return SolarState( + surplus_w=smoothed, + reserve_w=self._reserve_w, + floor_w=milliamps_to_watts(min_charging_current(data), data), + ceiling_w=milliamps_to_watts(max_charging_current(data), data), + charging=charging, + current_limit_w=milliamps_to_watts(int(limit_ma), data), + command_pending=self._coordinator.limit_state.pending, + charger_reachable=charger_offline_reason(data) is None, + eco_mode_on=bool(data.get("ecoModeEnabled")), + schedule_set=bool(schedules), + car_connected=data.get("chargeSession") is not None or charging, + seconds_above_threshold=self._elapsed(self._above_since, now), + seconds_below_threshold=self._elapsed(self._below_since, now), + seconds_since_start=self._elapsed(self._started_at, now), + seconds_since_last_command=( + now - self._command_times[-1] if self._command_times else 1e9 + ), + commands_this_hour=self._commands_this_hour(now), + backoff_remaining_s=max(0.0, self._backoff_until - now), + ) + + @staticmethod + def _elapsed(since: float | None, now: float) -> float: + """Return seconds since a mark, or zero if it is unset.""" + return 0.0 if since is None else max(0.0, now - since) + + def _commands_this_hour(self, now: float) -> int: + """Count commands issued in the last hour, dropping older ones.""" + self._command_times = [ + when for when in self._command_times if now - when < 3600 + ] + return len(self._command_times) + + def _track_thresholds(self, state: SolarState, now: float) -> None: + """Maintain how long surplus has been above or below the floor.""" + available = state.surplus_w - state.reserve_w + + if available >= state.floor_w: + self._below_since = None + if self._above_since is None: + self._above_since = now + else: + self._above_since = None + if self._below_since is None: + self._below_since = now + + async def _carry_out(self, decision: SolarDecision, now: float) -> None: + """Issue the command a decision calls for.""" + client = self._coordinator.api_client + serial = self._coordinator.serial_number + data = self._coordinator.data or {} + + _LOGGER.info( + "Solar control: %s — %s", decision.action.value, decision.reason + ) + + if decision.action is SolarAction.STOP: + await client.async_stop_charge(serial) + self._started_at = None + + elif decision.action is SolarAction.START: + if decision.target_watts is not None: + await client.async_set_max_charging_current( + serial, watts_to_milliamps(decision.target_watts, data) + ) + await client.async_start_charge(serial) + self._started_at = now + + elif decision.action is SolarAction.SET: + if decision.target_watts is None: + return + await client.async_set_max_charging_current( + serial, watts_to_milliamps(decision.target_watts, data) + ) + + self._command_times.append(now) + self._coordinator.async_schedule_refresh_in(10) + + def _notify(self) -> None: + """Tell the entities to redraw.""" + for listener in list(self._listeners): + listener() +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 tests/test_solar_controller.py` +Expected: PASS, `8 passed, 0 failed` + +- [ ] **Step 5: Register the suite and lint** + +Modify `tests/run_all.py`, adding `"test_solar_controller.py"` to `STANDALONE`. + +Run: +```bash +python3 tests/run_all.py +ruff check custom_components/daze/ tests/ +``` +Expected: 6 modules, 0 failures; `All checks passed!` + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/daze/solar_controller.py tests/test_solar_controller.py tests/run_all.py +git commit -m "feat: add the solar controller + +Reads the grid sensors, assembles the decision state, and carries out +the result. Commands go through the API client rather than the number +entity, which makes the manual-override rule mechanical: any write +arriving at the entity is by definition external. + +Simulate decides and logs but sends nothing, and is what the mode +defaults to on first enable. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 5: The ignored-start back-off + +**Files:** +- Modify: `custom_components/daze/solar_controller.py` +- Test: `tests/test_solar_controller.py` (append before `_main`) + +**Interfaces:** +- Consumes: `DRAW_GRACE_SECONDS`, `IGNORED_START_BACKOFF_SECONDS`, `MIN_MEANINGFUL_DRAW_W` from Task 1. +- Produces: no new public surface; `_backoff_until` is set internally. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_solar_controller.py`, before `_main`: + +```python +def test_a_car_that_ignores_a_start_triggers_a_backoff() -> None: + """A finished car stops drawing while surplus is still high, so a + naive controller restarts it until sunset.""" + data = dict(CHARGING_DATA) + data["evseStatus"] = "idle" + data["instantPowerAsWatt"] = 0 + controller, coordinator, _ = build(data) + controller.mode = controller_module.SolarMode.ACTIVE + + # Pretend a start was issued a while ago and the car never drew. + controller._started_at = 0.0 + + asyncio.run(controller.async_tick()) + + assert controller._backoff_until > 0, "no back-off was armed" +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 tests/test_solar_controller.py` +Expected: FAIL on `assert controller._backoff_until > 0` + +- [ ] **Step 3: Implement** + +In `custom_components/daze/solar_controller.py`, add this method to `SolarController`: + +```python + def _check_ignored_start(self, now: float) -> None: + """Back off if a started car never began drawing. + + When a car finishes it stops drawing while surplus is still + high. The charger goes idle, the controller sees "not charging, + plenty of surplus", and starts again. Without this the cycle + repeats until sunset. + """ + if self._started_at is None: + return + + if now - self._started_at < DRAW_GRACE_SECONDS: + return + + data = self._coordinator.data or {} + draw = data.get("instantPowerAsWatt") + drawing = isinstance(draw, (int, float)) and draw >= MIN_MEANINGFUL_DRAW_W + + if drawing: + return + + self._backoff_until = now + IGNORED_START_BACKOFF_SECONDS + self._started_at = None + _LOGGER.info( + "The car did not draw within %ds of starting; backing off for " + "%d minutes", + DRAW_GRACE_SECONDS, + IGNORED_START_BACKOFF_SECONDS // 60, + ) +``` + +And call it in `async_tick`, immediately after `self._track_thresholds(state, now)`: + +```python + self._check_ignored_start(now) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 tests/test_solar_controller.py` +Expected: PASS, `9 passed, 0 failed` + +- [ ] **Step 5: Lint and full suite** + +Run: +```bash +ruff check custom_components/daze/ tests/ +python3 tests/run_all.py +``` +Expected: `All checks passed!`, 0 failures. + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/daze/solar_controller.py tests/test_solar_controller.py +git commit -m "feat: back off when a started car does not draw + +A car that has finished stops drawing while surplus is still high, so +the charger goes idle, the controller sees plenty of surplus and no +charge, and starts again. The cycle repeats until sunset. + +The rate limit would blunt this but is the wrong instrument: it is a +backstop against bugs, not a substitute for handling a state the +design knows about. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 6: Wire it into the entry, and disarm on manual override + +**Files:** +- Modify: `custom_components/daze/__init__.py` +- Modify: `custom_components/daze/number.py` (the two existing limit entities) +- Test: `tests/test_entities.py` (append before `_main`) + +**Interfaces:** +- Consumes: `SolarController`, `SolarMode` from Task 4. +- Produces: `hass.data[DOMAIN][entry.entry_id]["solar_controller"]` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_entities.py`, before `_main`: + +```python +def test_a_manual_limit_change_disarms_solar_control() -> None: + """Touching the control means you want manual control. + + The controller writes through the API client, never the entity, so + any write arriving here is by definition external. That makes the + rule mechanical rather than a flag that could be wrong. + """ + class Ctl: + mode = "active" + disarmed = False + + def disarm(self, reason: str) -> None: + self.disarmed = True + + coordinator = FakeCoordinator(dict(BASE_DATA)) + coordinator.solar_controller = Ctl() + entity, _, _ = make_number() + entity.coordinator = coordinator + + asyncio.run(entity.async_set_native_value(16000)) + + assert coordinator.solar_controller.disarmed is True +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 tests/test_entities.py` +Expected: FAIL on `assert ... .disarmed is True` + +- [ ] **Step 3: Add `disarm` to the controller** + +In `custom_components/daze/solar_controller.py`, add to `SolarController`: + +```python + def disarm(self, reason: str) -> None: + """Turn solar control off because something else took over. + + Called when a limit change arrives through an entity or a + service, which by construction means it did not come from here. + """ + if self._mode is SolarMode.OFF: + return + + _LOGGER.info("Solar control disarmed: %s", reason) + self._mode = SolarMode.OFF + self._above_since = None + self._below_since = None + self._notify() +``` + +- [ ] **Step 4: Call it from the limit entities** + +In `custom_components/daze/number.py`, add this helper to both `DazeWallboxNumberEntity` and `DazeWallboxPowerEntity`: + +```python + def _disarm_solar(self) -> None: + """Hand control back to the user. + + Solar control writes through the API client, so anything + arriving here came from a person or their automation. + """ + controller = getattr(self.coordinator, "solar_controller", None) + if controller is not None: + controller.disarm("the charging limit was set manually") +``` + +Call `self._disarm_solar()` in both `async_set_native_value` methods, immediately after the no-op guard returns and before the offline check. + +- [ ] **Step 5: Create and tear down the controller** + +In `custom_components/daze/__init__.py`, inside `async_setup_entry`, after the coordinator is created and before `hass.data[DOMAIN][entry.entry_id] = {...}`: + +```python + solar_controller = SolarController( + hass=hass, + coordinator=coordinator, + import_entity=entry.options.get(CONF_GRID_IMPORT_SENSOR), + export_entity=entry.options.get(CONF_GRID_EXPORT_SENSOR), + ) + # The entities reach the controller through the coordinator, which + # every one of them already holds. + coordinator.solar_controller = solar_controller + await solar_controller.async_start() +``` + +Add `"solar_controller": solar_controller,` to the `hass.data[DOMAIN][entry.entry_id]` dict. + +In `async_unload_entry`, inside the `if unload_ok:` block and before `coordinator.async_shutdown_timers()`: + +```python + controller = entry_data.get("solar_controller") + if controller is not None: + await controller.async_stop() +``` + +Add the imports: + +```python +from .const import CONF_GRID_EXPORT_SENSOR, CONF_GRID_IMPORT_SENSOR +from .solar_controller import SolarController +``` + +merging the `const` names into the existing import block. + +- [ ] **Step 6: Disarm from the services too** + +In `custom_components/daze/__init__.py`, inside `_handle_set_charging_current`, immediately after `_refuse_if_offline()`: + +```python + if coordinator.solar_controller is not None: + coordinator.solar_controller.disarm( + "the charging current was set by a service call" + ) +``` + +- [ ] **Step 7: Run the tests and lint** + +Run: +```bash +python3 tests/run_all.py +ruff check custom_components/daze/ tests/ +``` +Expected: 0 failures, `All checks passed!` + +- [ ] **Step 8: Commit** + +```bash +git add custom_components/daze/__init__.py custom_components/daze/number.py custom_components/daze/solar_controller.py tests/test_entities.py +git commit -m "feat: wire solar control into the entry and disarm on override + +The controller is created with the entry and stopped when it unloads, +alongside the coordinator's own timers. + +Any limit change arriving through an entity or a service disarms solar +control, because the controller writes through the API client and +never through an entity. That makes the rule mechanical rather than a +flag that has to be set and cleared correctly. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 7: The three entities + +**Files:** +- Modify: `custom_components/daze/select.py` (append a second entity class and register it) +- Modify: `custom_components/daze/number.py` (append a third entity class and register it) +- Modify: `custom_components/daze/sensor.py` (append an entity class and register it) +- Modify: `custom_components/daze/strings.json` +- Modify: `custom_components/daze/translations/it.json` +- Test: `tests/test_entities.py` (append before `_main`) + +**Interfaces:** +- Consumes: `SolarController` and `SolarMode` from Task 4, and + `hass.data[DOMAIN][entry.entry_id]["solar_controller"]` from Task 6. +- Produces: + - `DazeSolarControlSelect` in `select.py` + - `DazeSolarReserveEntity` in `number.py` + - `DazeSolarSurplusSensor` in `sensor.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_entities.py`, before `_main`: + +```python +def test_solar_select_offers_three_modes() -> None: + """One control with three states, so 'dry run on, solar off' + cannot be expressed.""" + select_mod = sys.modules["daze_entities_under_test.select"] + assert select_mod.SOLAR_MODE_OPTIONS == ["off", "simulate", "active"] + + +def test_solar_select_refuses_active_without_sensors() -> None: + """Both grid sensors are required before it can do anything.""" + select_mod = sys.modules["daze_entities_under_test.select"] + + class Ctl: + configured = False + mode = None + + def add_listener(self, cb): + return lambda: None + + coordinator = FakeCoordinator(dict(BASE_DATA)) + entity = select_mod.DazeSolarControlSelect( + coordinator=coordinator, + controller=Ctl(), + serial_number="SER1", + device_info={}, + ) + + assert entity.available is False +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 tests/test_entities.py` +Expected: FAIL with `AttributeError: module ... has no attribute 'SOLAR_MODE_OPTIONS'` + +- [ ] **Step 3: Add the select** + +Append to `custom_components/daze/select.py`, before `async_setup_entry`: + +```python +SOLAR_MODE_OPTIONS = ["off", "simulate", "active"] + + +class DazeSolarControlSelect( + CoordinatorEntity[DazeDataUpdateCoordinator], SelectEntity +): + """Arm solar control, in simulation or for real. + + A single tri-state rather than a switch plus a dry-run flag, so the + meaningless combination cannot be selected. + """ + + _attr_has_entity_name = True + _attr_options = SOLAR_MODE_OPTIONS + + def __init__( + self, + coordinator: DazeDataUpdateCoordinator, + controller: Any, + serial_number: str, + device_info: DeviceInfo, + ) -> None: + """Initialise the control. + + Args: + coordinator: The Daze data coordinator. + controller: The solar controller to drive. + serial_number: The wallbox serial number. + device_info: Device info for the device registry. + + """ + super().__init__(coordinator) + self._controller = controller + self._serial_number = serial_number + self._attr_unique_id = f"{serial_number}_solar_control" + self._attr_device_info = device_info + + async def async_added_to_hass(self) -> None: + """Redraw when the controller decides something.""" + await super().async_added_to_hass() + self.async_on_remove( + self._controller.add_listener(self.async_write_ha_state) + ) + + @property + def available(self) -> bool: + """Only usable once both grid sensors have been chosen.""" + return bool(self._controller.configured) + + @property + def current_option(self) -> str | None: + """Return the controller's mode.""" + mode = self._controller.mode + return mode.value if mode is not None else None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Expose the last decision, so the feature can be understood.""" + decision = self._controller.last_decision + return { + "surplus_w": self._controller.surplus_w, + "last_action": decision.action.value if decision else None, + "last_reason": decision.reason if decision else None, + } + + async def async_select_option(self, option: str) -> None: + """Set the mode.""" + from .solar_controller import SolarMode + + self._controller.mode = SolarMode(option) + self.async_write_ha_state() +``` + +Add `from typing import Any` to the imports if not already present, and register the entity in `select.py`'s `async_setup_entry` by appending it to the `async_add_entities([...])` list: + +```python + DazeSolarControlSelect( + coordinator=coordinator, + controller=entry_data["solar_controller"], + serial_number=serial_number, + device_info=device_info, + ), +``` + +- [ ] **Step 4: Add the reserve number** + +Append to `custom_components/daze/number.py`, before `async_setup_entry`: + +```python +class DazeSolarReserveEntity( + CoordinatorEntity[DazeDataUpdateCoordinator], NumberEntity +): + """Watts to leave for the house before the car gets any.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value = 0 + _attr_native_max_value = MAX_SOLAR_RESERVE + _attr_native_step = 100 + _attr_native_unit_of_measurement = UnitOfPower.WATT + + def __init__( + self, + coordinator: DazeDataUpdateCoordinator, + controller: Any, + serial_number: str, + device_info: DeviceInfo, + ) -> None: + """Initialise the reserve control.""" + super().__init__(coordinator) + self._controller = controller + self._serial_number = serial_number + self._attr_unique_id = f"{serial_number}_solar_reserve" + self._attr_device_info = device_info + + @property + def native_value(self) -> float: + """Return the configured reserve.""" + return float(self._controller.reserve_w) + + async def async_set_native_value(self, value: float) -> None: + """Set the reserve.""" + self._controller.reserve_w = value + self.async_write_ha_state() +``` + +Add `MAX_SOLAR_RESERVE` to the `from .const import (...)` block, and register the entity in `number.py`'s `async_setup_entry` list. + +- [ ] **Step 5: Add the surplus sensor** + +Append to `custom_components/daze/sensor.py`, before `async_setup_entry`: + +```python +class DazeSolarSurplusSensor( + CoordinatorEntity[DazeDataUpdateCoordinator], SensorEntity +): + """The smoothed surplus the controller is working from. + + Exposed so the figure everything else depends on can be seen and + graphed, rather than inferred from behaviour. + """ + + _attr_has_entity_name = True + _attr_device_class = SensorDeviceClass.POWER + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = "W" + + def __init__( + self, + coordinator: DazeDataUpdateCoordinator, + controller: Any, + serial_number: str, + device_info: DeviceInfo, + ) -> None: + """Initialise the surplus sensor.""" + super().__init__(coordinator) + self._controller = controller + self._serial_number = serial_number + self._attr_unique_id = f"{serial_number}_solar_surplus" + self._attr_device_info = device_info + + async def async_added_to_hass(self) -> None: + """Redraw when the controller updates.""" + await super().async_added_to_hass() + self.async_on_remove( + self._controller.add_listener(self.async_write_ha_state) + ) + + @property + def native_value(self) -> float | None: + """Return the smoothed surplus.""" + return self._controller.surplus_w +``` + +Register it in `sensor.py`'s `async_setup_entry` by appending to the `entities` list. + +- [ ] **Step 6: Add the strings** + +Modify `custom_components/daze/strings.json`, under `entity`: + +```json + "select": { + "solar_control": { "name": "Solar control" } + }, + "number": { + "solar_reserve": { "name": "Solar reserve" } + }, + "sensor": { + "solar_surplus": { "name": "Solar surplus" } + } +``` + +Merge these into the existing `select`, `number` and `sensor` objects rather than replacing them. Do the same in `translations/it.json` with `Controllo solare`, `Riserva solare`, `Surplus solare`. + +- [ ] **Step 7: Run the tests and lint** + +Run: +```bash +python3 tests/run_all.py +ruff check custom_components/daze/ tests/ +python3 -c "import json; [json.load(open(f)) for f in ['custom_components/daze/strings.json','custom_components/daze/translations/it.json']]; print('valid')" +``` +Expected: 0 failures, `All checks passed!`, `valid`. + +- [ ] **Step 8: Commit** + +```bash +git add custom_components/daze/select.py custom_components/daze/number.py custom_components/daze/sensor.py custom_components/daze/strings.json custom_components/daze/translations/it.json tests/test_entities.py +git commit -m "feat: add solar control, reserve and surplus entities + +The control is one tri-state select rather than a switch and a +dry-run flag, so the meaningless combination cannot be selected. It +carries the last decision and its reason as attributes, because an +autonomous feature that acts silently cannot be understood after the +fact. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 8: React immediately when surplus collapses + +**Files:** +- Modify: `custom_components/daze/solar_controller.py` +- Test: `tests/test_solar_controller.py` (append before `_main`) + +**Interfaces:** +- Consumes: `SolarController` from Task 4. +- Produces: no new public surface. The controller subscribes to state + changes on its two grid sensors. + +Unused cheap power costs nothing; imported expensive power is what pure +solar mode exists to avoid. On a fixed two-minute tick a collapse in +surplus keeps the car importing for up to two minutes. Rising surplus +can wait; falling surplus cannot. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_solar_controller.py`, before `_main`: + +```python +def test_a_collapse_is_evaluated_without_waiting_for_the_tick() -> None: + """Rising surplus can wait for the tick; falling surplus cannot, + or the car imports until the next one.""" + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + # Establish a healthy history. + for _ in range(3): + asyncio.run(controller.async_tick()) + + coordinator.api_client.calls.clear() + hass.states.set("sensor.grid_export", "0") + hass.states.set("sensor.grid_import", "4000") + + asyncio.run(controller.async_sensor_changed()) + + assert controller.surplus_w is not None + assert controller.last_decision is not None + + +def test_a_rise_does_not_trigger_an_immediate_evaluation() -> None: + """Otherwise every sensor update rewrites the charger's limit.""" + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + for _ in range(3): + asyncio.run(controller.async_tick()) + + before = len(coordinator.api_client.calls) + hass.states.set("sensor.grid_export", "9000") + + asyncio.run(controller.async_sensor_changed()) + + assert len(coordinator.api_client.calls) == before +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 tests/test_solar_controller.py` +Expected: FAIL with `AttributeError: 'SolarController' object has no attribute 'async_sensor_changed'` + +- [ ] **Step 3: Implement** + +Add to `SolarController` in `custom_components/daze/solar_controller.py`: + +```python + async def async_sensor_changed(self) -> None: + """Evaluate now if surplus has collapsed, otherwise wait. + + A fixed tick would keep the car importing for up to two + minutes after surplus disappears. Rising surplus is not urgent: + acting on every increase would rewrite the limit constantly + against a charger that takes seconds to apply a change. + """ + if self._mode is SolarMode.OFF: + return + + surplus = self._read_surplus() + if surplus is None: + return + + smoothed = self._smoother.value() + data = self._coordinator.data or {} + floor = milliamps_to_watts(min_charging_current(data), data) + + collapsed = ( + surplus - self._reserve_w < floor + and (smoothed is None or smoothed - self._reserve_w >= floor) + ) + + if not collapsed: + return + + _LOGGER.debug( + "Surplus collapsed to %.0f W; evaluating without waiting", surplus + ) + await self.async_tick() +``` + +Register the subscription in `async_start`: + +```python + async def async_start(self) -> None: + """Begin ticking, and watch the grid sensors for a collapse.""" + self._schedule_tick() + + entities = [ + entity + for entity in (self._import_entity, self._export_entity) + if entity + ] + + if entities: + async def _changed(_event: Any) -> None: + await self.async_sensor_changed() + + self._cancel_listener = async_track_state_change_event( + self._hass, entities, _changed + ) +``` + +Add to `__init__`: + +```python + self._cancel_listener: Callable[[], None] | None = None +``` + +Add to `async_stop`, before clearing listeners: + +```python + if self._cancel_listener is not None: + self._cancel_listener() + self._cancel_listener = None +``` + +And to the imports: + +```python +from homeassistant.helpers.event import ( + async_call_later, + async_track_state_change_event, +) +``` + +Add `async_track_state_change_event=lambda hass, entities, cb: (lambda: None)` +to the `homeassistant.helpers.event` stub in `tests/test_solar_controller.py`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 tests/test_solar_controller.py` +Expected: PASS, `11 passed, 0 failed` + +- [ ] **Step 5: Lint and full suite** + +Run: +```bash +ruff check custom_components/daze/ tests/ +python3 tests/run_all.py +``` +Expected: `All checks passed!`, 0 failures. + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/daze/solar_controller.py tests/test_solar_controller.py +git commit -m "feat: evaluate immediately when surplus collapses + +A fixed two-minute tick keeps the car importing for up to two minutes +after surplus disappears, which is exactly what pure solar mode exists +to avoid. Rising surplus still waits for the tick: acting on every +increase would rewrite the limit constantly against a charger that +takes seconds to apply a change. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 9: Remaining guards, and surviving a restart + +**Files:** +- Modify: `custom_components/daze/solar_controller.py` +- Modify: `custom_components/daze/select.py` (the solar select) +- Test: `tests/test_solar_controller.py` (append before `_main`) + +**Interfaces:** +- Consumes: `SolarController` from Task 4, `DazeSolarControlSelect` from Task 7. +- Produces: `SolarController.unsupported_reason` property, returning + `str | None`. + +Three things the spec requires that nothing yet implements: refusing a +supply the charger cannot follow, surviving a Home Assistant restart +without stopping a healthy charge, and remembering the mode. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_solar_controller.py`, before `_main`: + +```python +def test_three_phase_supply_with_a_single_phase_charger_is_refused() -> None: + """Grid meters usually report net across phases, so the surplus + can exist mostly on phases the charger cannot reach.""" + data = dict(CHARGING_DATA) + data["evseIsThreePhase"] = False + data["supplyGrid3F"] = True + controller, _, _ = build(data) + + assert controller.unsupported_reason is not None + assert "phase" in controller.unsupported_reason + + +def test_a_matched_supply_is_supported() -> None: + data = dict(CHARGING_DATA) + data["evseIsThreePhase"] = False + data["supplyGrid3F"] = False + controller, _, _ = build(data) + + assert controller.unsupported_reason is None + + +def test_a_charge_already_running_counts_as_having_run() -> None: + """Timers start at zero after a restart. Without seeding, an + unelapsed minimum run time could stop a healthy charge moments + after boot.""" + controller, _, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + asyncio.run(controller.async_tick()) + + assert controller._started_at is not None +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 tests/test_solar_controller.py` +Expected: FAIL with `AttributeError: ... 'unsupported_reason'` + +- [ ] **Step 3: Implement the guard and the seeding** + +Add to `SolarController`: + +```python + @property + def unsupported_reason(self) -> str | None: + """Explain why this setup cannot be followed, if it cannot. + + A three-phase supply feeding a single-phase charger reports + surplus netted across phases, most of which the charger cannot + reach. Following it would overload one phase. + """ + data = self._coordinator.data or {} + + supply_three_phase = bool(data.get("supplyGrid3F")) + charger_three_phase = bool(data.get("evseIsThreePhase")) + + if supply_three_phase and not charger_three_phase: + return ( + "the supply is three-phase and the charger is single-phase, " + "so exported power may be on a phase it cannot use" + ) + + return None +``` + +In `async_tick`, immediately after the `SolarMode.OFF` check: + +```python + unsupported = self.unsupported_reason + if unsupported is not None: + if not self._sensor_warning_logged: + self._sensor_warning_logged = True + _LOGGER.warning("Solar control cannot run: %s", unsupported) + return +``` + +And seed the start time, immediately before `self._check_ignored_start(now)`: + +```python + # Timers begin at zero after a restart. A charge that is + # already running has, by definition, been running: without + # this the minimum run time reads as unelapsed and a healthy + # charge could be stopped moments after boot. + if state.charging and self._started_at is None: + self._started_at = now - MIN_RUN_SECONDS +``` + +Add `MIN_RUN_SECONDS` to the `from .solar import (...)` block. + +- [ ] **Step 4: Make the select remember its mode** + +In `custom_components/daze/select.py`, change `DazeSolarControlSelect` to +also inherit `RestoreEntity`: + +```python +class DazeSolarControlSelect( + CoordinatorEntity[DazeDataUpdateCoordinator], SelectEntity, RestoreEntity +): +``` + +Add the import: + +```python +from homeassistant.helpers.restore_state import RestoreEntity +``` + +And restore in `async_added_to_hass`, after the existing `super()` call: + +```python + last = await self.async_get_last_state() + if last is not None and last.state in SOLAR_MODE_OPTIONS: + from .solar_controller import SolarMode + + self._controller.mode = SolarMode(last.state) +``` + +Also make `available` account for an unsupported setup: + +```python + @property + def available(self) -> bool: + """Usable only with both sensors and a supply we can follow.""" + return ( + bool(self._controller.configured) + and self._controller.unsupported_reason is None + ) +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 tests/test_solar_controller.py` +Expected: PASS, `14 passed, 0 failed` + +- [ ] **Step 6: Lint and full suite** + +Run: +```bash +ruff check custom_components/daze/ tests/ +python3 tests/run_all.py +``` +Expected: `All checks passed!`, 0 failures. + +- [ ] **Step 7: Commit** + +```bash +git add custom_components/daze/solar_controller.py custom_components/daze/select.py tests/test_solar_controller.py +git commit -m "feat: refuse unfollowable supplies, and survive a restart + +A three-phase supply feeding a single-phase charger reports surplus +netted across phases, most of which the charger cannot reach. + +Timers begin at zero after a Home Assistant restart, so a charge that +was already running would read as having no elapsed run time and could +be stopped moments after boot. A running charge now seeds its own +start time. + +The control also remembers its mode across a restart. + +Co-Authored-By: Claude Opus 5 " +``` + +--- +### Task 10: Documentation + +**Files:** +- Modify: `README.md` +- Modify: `docs/solar-surplus-charging.md` + +**Interfaces:** +- Consumes: the entity names from Task 6. +- Produces: nothing code depends on. + +- [ ] **Step 1: Document the entities in the README** + +In `README.md`, add to the Controls table: + +```markdown +| Select | `select.daze_homett_solar_control` | Solar control | `off` / `simulate` / `active` | +| Number | `number.daze_homett_solar_reserve` | Solar reserve | Watts to leave for the house before the car gets any | +``` + +And to the Sensors table: + +```markdown +| `sensor.daze_homett_solar_surplus` | Solar surplus | `power` | `measurement` | W | +``` + +- [ ] **Step 2: Add a Solar control section to the README** + +Insert before `## Automation Examples`: + +```markdown +## Solar control + +Charges the car from what the house would otherwise export, adjusting +the limit as production and load change, and stopping when there is not +enough surplus to charge at all. + +1. In the integration's options, pick your **grid import** and **grid + export** power sensors. +2. Set **Solar control** to `simulate`. It decides and logs but sends + nothing. +3. Leave it for a day. The select's attributes show the surplus it sees + and what it would have done. +4. If the decisions look right, set it to `active`. + +It never imports to charge: the charger cannot run below 1500 W, so +when surplus falls below that it stops rather than topping up from the +grid. + +Changing the charging limit yourself — from the dashboard, or from your +own automation — turns solar control off. It does not fight you. + +For a version you build and tune yourself, see +[docs/solar-surplus-charging.md](docs/solar-surplus-charging.md). +``` + +- [ ] **Step 3: Cross-reference from the YAML guide** + +At the top of `docs/solar-surplus-charging.md`, after the first paragraph, add: + +```markdown +> The integration can now do this itself — see **Solar control** in the +> README. This guide remains for setups the built-in version does not +> fit: a house battery to arbitrate with, tariff windows, or anything +> needing logic of your own. +``` + +- [ ] **Step 4: Verify and commit** + +Run: `python3 tests/run_all.py` +Expected: 0 failures. + +```bash +git add README.md docs/solar-surplus-charging.md +git commit -m "docs: document solar control + +Includes the simulate-first procedure, because a feature that starts +and stops the car should be watched for a day before it is trusted. + +Co-Authored-By: Claude Opus 5 " +``` + +- [ ] **Step 5: Push, and move the tag** + +```bash +set -a; . ./.env; set +a +ASK=$(mktemp /tmp/askpass.XXXXXX); chmod 700 "$ASK" +printf '#!/bin/sh\ncase "$1" in\n *sername*) printf "%%s\\n" "$GIT_USER" ;;\n *assword*) printf "%%s\\n" "$GITHUB_PAT" ;;\nesac\n' > "$ASK" +GIT_USER=tarrinho GIT_TERMINAL_PROMPT=0 GIT_ASKPASS="$ASK" \ + git -c credential.helper= push origin main 2>&1 | sed 's/gh[pousr]_[A-Za-z0-9_]*/[REDACTED]/g' | tail -1 +git tag -f -a v0.1.6 -m "Release 0.1.6 + +Re-pointed at the current code. This tag tracks main. +" >/dev/null +GIT_USER=tarrinho GIT_TERMINAL_PROMPT=0 GIT_ASKPASS="$ASK" \ + git -c credential.helper= push --force origin v0.1.6 2>&1 | sed 's/gh[pousr]_[A-Za-z0-9_]*/[REDACTED]/g' | tail -1 +rm -f "$ASK"; unset GITHUB_PAT GIT_USER +``` + +Expected: both pushes report success, and `main` and `v0.1.6` point at the same commit. + +--- + +## After the plan + +Solar control ships **off**. Nothing changes for an existing install +until the user picks two sensors and moves the select. + +The first real validation is a day in `simulate` against actual +production. That is the step this plan cannot do, and the one that +decides whether the constants in `solar.py` are right for the site. From 5b0bdae1287b7e1cca790f66ee874e81e1f6275d Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 10:25:58 +0100 Subject: [PATCH 43/82] feat: add the solar surplus decision function A pure function with no Home Assistant imports, so the part of solar control that can strand a car or hammer an API is exhaustively testable. The guards come first deliberately: a charger that cannot answer must never be read as an absence of surplus, which would produce a stop. Co-Authored-By: Claude Opus 5 --- custom_components/daze/solar.py | 221 ++++++++++++++++++++++++ tests/run_all.py | 1 + tests/test_solar.py | 292 ++++++++++++++++++++++++++++++++ 3 files changed, 514 insertions(+) create mode 100644 custom_components/daze/solar.py create mode 100644 tests/test_solar.py diff --git a/custom_components/daze/solar.py b/custom_components/daze/solar.py new file mode 100644 index 0000000..792a9f3 --- /dev/null +++ b/custom_components/daze/solar.py @@ -0,0 +1,221 @@ +"""Decide what solar control should do, with no Home Assistant coupling. + +The controller reads sensors and issues commands; this module decides. +Keeping the decision pure means the part that can strand a car or +hammer an API is exhaustively testable without a Home Assistant +instance, which is the split that has worked for payload.py and +optimistic.py. + +Ordering in `decide` is load-bearing. The guards come first because a +charger that cannot answer must never be read as an absence of +surplus: that would produce a stop, and it is exactly what happened +when the wallbox lost power at the wall. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +# How often the controller re-evaluates. The charger takes seconds to +# apply a change and may need retries, so a faster cadence fights +# itself. +TICK_SECONDS = 120 + +# Raw grid readings move with every kettle and oven cycle. +SMOOTHING_SECONDS = 300 + +# Confirm surplus is real before starting; be slower to give up than to +# begin, because interrupting a car is worse than riding out a cloud. +START_DELAY_SECONDS = 300 +STOP_DELAY_SECONDS = 600 + +# Once started, stay started, or surplus hovering at the threshold +# cycles the car. +MIN_RUN_SECONDS = 600 + +# Do not rewrite the limit for trivial changes. +DEADBAND_W = 300 + +# A car that has finished stops drawing while surplus is still high. +# Without a back-off the controller restarts it until sunset. +DRAW_GRACE_SECONDS = 300 +IGNORED_START_BACKOFF_SECONDS = 3600 +MIN_MEANINGFUL_DRAW_W = 200 + +# A hard ceiling regardless of what the logic decides, so a bug hits a +# wall rather than an API that has already proven fragile. +MAX_COMMANDS_PER_HOUR = 20 + + +class SolarAction(Enum): + """What the controller should do this cycle.""" + + NOTHING = "nothing" + START = "start" + STOP = "stop" + SET = "set" + + +@dataclass(frozen=True, kw_only=True) +class SolarState: + """Everything the decision depends on. + + Assembled by the controller from the grid sensors, the coordinator + and its own timers. + """ + + surplus_w: float + reserve_w: float + floor_w: int + ceiling_w: int + charging: bool + current_limit_w: int + command_pending: bool + charger_reachable: bool + eco_mode_on: bool + schedule_set: bool + car_connected: bool + seconds_above_threshold: float + seconds_below_threshold: float + seconds_since_start: float + seconds_since_last_command: float + commands_this_hour: int + backoff_remaining_s: float + + +@dataclass(frozen=True, kw_only=True) +class SolarDecision: + """What to do, and why. + + The reason is not decoration: it becomes the log line and an + attribute on the control entity, which is the only way an + autonomous feature can be understood after the fact. + """ + + action: SolarAction + target_watts: int | None + reason: str + + +def _nothing(reason: str) -> SolarDecision: + """Return a do-nothing decision with an explanation.""" + return SolarDecision( + action=SolarAction.NOTHING, target_watts=None, reason=reason + ) + + +def available_watts(state: SolarState) -> float: + """Return the surplus left for the car once the house has its share.""" + return state.surplus_w - state.reserve_w + + +def target_watts(state: SolarState) -> int: + """Return the limit to request, clamped to what the charger accepts.""" + available = available_watts(state) + bounded = max(float(state.floor_w), min(float(state.ceiling_w), available)) + return round(bounded) + + +def decide(state: SolarState) -> SolarDecision: + """Decide what to do this cycle. + + Args: + state: Everything the decision depends on. + + Returns: + The action to take and the reason for it. + + """ + # --- Guards. Nothing below these runs on bad information. --- + + if not state.charger_reachable: + return _nothing("charger is not reachable") + + if state.command_pending: + return _nothing("a command is still pending") + + if state.eco_mode_on: + return _nothing("the charger's own eco mode is controlling it") + + if state.schedule_set: + return _nothing("the charger has a schedule set") + + if state.commands_this_hour >= MAX_COMMANDS_PER_HOUR: + return _nothing("rate limit reached for this hour") + + if state.backoff_remaining_s > 0: + return _nothing( + f"backing off for {int(state.backoff_remaining_s)}s after a " + "start the car ignored" + ) + + available = available_watts(state) + target = target_watts(state) + + # --- Stopping. Checked before starting so a charging car is + # --- considered on its own terms. + + if state.charging: + if available < state.floor_w: + if state.seconds_since_start < MIN_RUN_SECONDS: + return _nothing( + f"surplus {available:.0f} W is below the " + f"{state.floor_w} W floor, but the minimum run time " + "has not elapsed" + ) + + if state.seconds_below_threshold >= STOP_DELAY_SECONDS: + return SolarDecision( + action=SolarAction.STOP, + target_watts=None, + reason=( + f"surplus {available:.0f} W below the " + f"{state.floor_w} W floor for " + f"{int(state.seconds_below_threshold)}s" + ), + ) + + return _nothing( + f"surplus {available:.0f} W is below the floor, waiting " + f"{STOP_DELAY_SECONDS - int(state.seconds_below_threshold)}s " + "before stopping" + ) + + if abs(target - state.current_limit_w) >= DEADBAND_W: + return SolarDecision( + action=SolarAction.SET, + target_watts=target, + reason=( + f"following surplus {available:.0f} W: " + f"{state.current_limit_w} W to {target} W" + ), + ) + + return _nothing( + f"holding at {state.current_limit_w} W, surplus " + f"{available:.0f} W is within the deadband" + ) + + # --- Starting. --- + + if not state.car_connected: + return _nothing("no car is connected") + + if available < state.floor_w: + return _nothing( + f"surplus {available:.0f} W is below the {state.floor_w} W floor" + ) + + if state.seconds_above_threshold < START_DELAY_SECONDS: + return _nothing( + f"surplus {available:.0f} W is sufficient, waiting " + f"{START_DELAY_SECONDS - int(state.seconds_above_threshold)}s " + "to confirm" + ) + + return SolarDecision( + action=SolarAction.START, + target_watts=target, + reason=f"surplus {available:.0f} W sustained, starting at {target} W", + ) diff --git a/tests/run_all.py b/tests/run_all.py index 689d821..ac27ff6 100755 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -25,6 +25,7 @@ "test_payload.py", "test_qa_invariants.py", "test_entities.py", + "test_solar.py", ) diff --git a/tests/test_solar.py b/tests/test_solar.py new file mode 100644 index 0000000..9463ba8 --- /dev/null +++ b/tests/test_solar.py @@ -0,0 +1,292 @@ +"""Tests for the solar surplus decision function. + +The decision is a pure function so that the risky part of solar +control can be exercised exhaustively without Home Assistant. Every +branch of the decision table is covered here, in the order the table +evaluates them, because the ordering is load-bearing: a guard that +fires late is the same as a guard that does not exist. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE_DIR = ROOT / "custom_components" / "daze" + + +def _load(name: str, filename: str) -> Any: + """Load a single integration module without Home Assistant.""" + spec = importlib.util.spec_from_file_location(name, PACKAGE_DIR / filename) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +solar = _load("daze_solar_under_test", "solar.py") + + +def state(**overrides: Any) -> Any: + """Build a SolarState that is healthy unless overridden. + + Defaults describe a charger that is reachable, idle, with a car + connected and plenty of surplus, so each test changes only the one + thing it is about. + """ + defaults: dict[str, Any] = { + "surplus_w": 4000, + "reserve_w": 0, + "floor_w": 1600, + "ceiling_w": 7400, + "charging": False, + "current_limit_w": 1600, + "command_pending": False, + "charger_reachable": True, + "eco_mode_on": False, + "schedule_set": False, + "car_connected": True, + "seconds_above_threshold": 600, + "seconds_below_threshold": 0, + "seconds_since_start": 0, + "seconds_since_last_command": 3600, + "commands_this_hour": 0, + "backoff_remaining_s": 0, + } + defaults.update(overrides) + return solar.SolarState(**defaults) + + +# ------------------------------------------------------------------ +# Guards, in table order +# ------------------------------------------------------------------ + + +def test_unreachable_charger_does_nothing() -> None: + """A charger that cannot answer must never imply an absence of + surplus, which would produce a stop. Observed in practice when the + wallbox lost power at the wall.""" + decision = solar.decide(state(charger_reachable=False, charging=True)) + assert decision.action is solar.SolarAction.NOTHING + assert "reachable" in decision.reason + + +def test_pending_command_does_nothing() -> None: + """Issuing another command while one is queued stacks requests + against a charger that is already not answering.""" + decision = solar.decide(state(command_pending=True)) + assert decision.action is solar.SolarAction.NOTHING + assert "pending" in decision.reason + + +def test_vendor_eco_mode_does_nothing() -> None: + """Solar Boost is a competing controller on the same setting.""" + decision = solar.decide(state(eco_mode_on=True)) + assert decision.action is solar.SolarAction.NOTHING + assert "eco" in decision.reason.lower() + + +def test_charger_schedule_does_nothing() -> None: + """A schedule decides when the car charges; so does this.""" + decision = solar.decide(state(schedule_set=True)) + assert decision.action is solar.SolarAction.NOTHING + assert "schedule" in decision.reason.lower() + + +def test_no_car_connected_does_not_start() -> None: + """Starting with nothing plugged in only produces errors.""" + decision = solar.decide(state(car_connected=False)) + assert decision.action is solar.SolarAction.NOTHING + + +def test_backoff_blocks_a_restart() -> None: + """A finished car stops drawing while surplus is still high. Without + a back-off the controller restarts it forever.""" + decision = solar.decide(state(backoff_remaining_s=1800)) + assert decision.action is solar.SolarAction.NOTHING + assert "backing off" in decision.reason + + +def test_rate_limit_blocks_everything() -> None: + """A hard ceiling regardless of what the logic wants, so a bug + cannot hammer an API that has already proven fragile.""" + decision = solar.decide( + state(commands_this_hour=solar.MAX_COMMANDS_PER_HOUR) + ) + assert decision.action is solar.SolarAction.NOTHING + assert "rate limit" in decision.reason + + +# ------------------------------------------------------------------ +# Stopping +# ------------------------------------------------------------------ + + +def test_stops_when_surplus_below_floor_for_long_enough() -> None: + """Pure solar: below the charger's floor it cannot charge at all.""" + decision = solar.decide( + state( + charging=True, + surplus_w=1000, + seconds_below_threshold=solar.STOP_DELAY_SECONDS, + seconds_since_start=solar.MIN_RUN_SECONDS + 1, + ) + ) + assert decision.action is solar.SolarAction.STOP + + +def test_does_not_stop_before_the_delay() -> None: + """A passing cloud is not a reason to interrupt the car.""" + decision = solar.decide( + state( + charging=True, + surplus_w=1000, + seconds_below_threshold=60, + seconds_since_start=solar.MIN_RUN_SECONDS + 1, + ) + ) + assert decision.action is not solar.SolarAction.STOP + + +def test_minimum_run_time_outranks_a_stop() -> None: + """Prevents cycling when surplus hovers at the threshold.""" + decision = solar.decide( + state( + charging=True, + surplus_w=1000, + seconds_below_threshold=solar.STOP_DELAY_SECONDS, + seconds_since_start=10, + ) + ) + assert decision.action is solar.SolarAction.NOTHING + assert "minimum run" in decision.reason + + +# ------------------------------------------------------------------ +# Starting +# ------------------------------------------------------------------ + + +def test_starts_when_surplus_sustained() -> None: + decision = solar.decide( + state(surplus_w=4000, seconds_above_threshold=solar.START_DELAY_SECONDS) + ) + assert decision.action is solar.SolarAction.START + assert decision.target_watts == 4000 + + +def test_does_not_start_before_the_delay() -> None: + decision = solar.decide(state(surplus_w=4000, seconds_above_threshold=60)) + assert decision.action is solar.SolarAction.NOTHING + + +def test_does_not_start_below_the_floor() -> None: + decision = solar.decide( + state(surplus_w=1000, seconds_above_threshold=99999) + ) + assert decision.action is solar.SolarAction.NOTHING + + +# ------------------------------------------------------------------ +# Following +# ------------------------------------------------------------------ + + +def test_follows_surplus_when_the_change_is_worth_making() -> None: + decision = solar.decide( + state(charging=True, surplus_w=5000, current_limit_w=1600) + ) + assert decision.action is solar.SolarAction.SET + assert decision.target_watts == 5000 + + +def test_ignores_a_change_inside_the_deadband() -> None: + """Without this the limit is rewritten every tick for no benefit.""" + decision = solar.decide( + state(charging=True, surplus_w=4100, current_limit_w=4000) + ) + assert decision.action is solar.SolarAction.NOTHING + + +def test_target_is_clamped_to_the_ceiling() -> None: + """10 kW of surplus does not make a 32 A charger draw 10 kW.""" + decision = solar.decide( + state(charging=True, surplus_w=10000, current_limit_w=1600) + ) + assert decision.target_watts == 7400 + + +def test_target_is_clamped_to_the_floor() -> None: + decision = solar.decide( + state( + charging=True, + surplus_w=1700, + floor_w=1600, + current_limit_w=7000, + ) + ) + assert decision.target_watts == 1700 + + +def test_reserve_is_subtracted_before_anything_else() -> None: + """The house gets its share first.""" + decision = solar.decide( + state(charging=True, surplus_w=5000, reserve_w=2000, current_limit_w=1600) + ) + assert decision.target_watts == 3000 + + +def test_reserve_can_push_below_the_floor_and_stop() -> None: + decision = solar.decide( + state( + charging=True, + surplus_w=2000, + reserve_w=1000, + seconds_below_threshold=solar.STOP_DELAY_SECONDS, + seconds_since_start=solar.MIN_RUN_SECONDS + 1, + ) + ) + assert decision.action is solar.SolarAction.STOP + + +def test_every_decision_carries_a_reason() -> None: + """The reason becomes the log line and a visible attribute. An + autonomous feature that acts silently cannot be debugged.""" + for decision in ( + solar.decide(state()), + solar.decide(state(charging=True)), + solar.decide(state(charger_reachable=False)), + solar.decide(state(charging=True, surplus_w=5000)), + ): + assert decision.reason + assert decision.reason.strip() == decision.reason + + +def _main() -> int: + """Run every test in this module and report results.""" + tests = [ + value + for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value) + ] + + failures = 0 + for test in tests: + try: + test() + except Exception as err: # noqa: BLE001 - standalone runner + failures += 1 + print(f"FAIL {test.__name__}: {type(err).__name__}: {err}") + else: + print(f"ok {test.__name__}") + + print(f"\n{len(tests) - failures} passed, {failures} failed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(_main()) From 1865373770df607dd8bff436ce59c961093fef67 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 10:32:47 +0100 Subject: [PATCH 44/82] fix: make test_target_is_clamped_to_the_floor exercise the clamp The test called decide() with surplus above the floor, so the target_watts() clamp it claimed to cover could never fire: available was already >= floor_w, and every call site inside decide() only reaches target_watts() after a prior "available < floor_w" guard has failed, making the clamp unreachable from decide() by construction. Calls target_watts() directly instead, with surplus genuinely below the floor, so the clamp is what produces the asserted result. Co-Authored-By: Claude Opus 5 --- tests/test_solar.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/test_solar.py b/tests/test_solar.py index 9463ba8..80c8cf9 100644 --- a/tests/test_solar.py +++ b/tests/test_solar.py @@ -221,15 +221,18 @@ def test_target_is_clamped_to_the_ceiling() -> None: def test_target_is_clamped_to_the_floor() -> None: - decision = solar.decide( - state( - charging=True, - surplus_w=1700, - floor_w=1600, - current_limit_w=7000, - ) - ) - assert decision.target_watts == 1700 + """Calls target_watts() directly, not through decide(). + + Every call site inside decide() only reaches target_watts() after + an `available < floor_w` guard has already failed, so available + is always >= floor_w by the time decide() would use it and the + clamp can never fire there. The clamp still matters because + target_watts() is public and a later task's controller is the + first place that could call it outside decide()'s guarded + context, so it is exercised directly here instead. + """ + target = solar.target_watts(state(surplus_w=500, floor_w=1600)) + assert target == 1600 def test_reserve_is_subtracted_before_anything_else() -> None: From fc30cf60c8474e76a41b2d440db8d0b7031d2364 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 10:36:00 +0100 Subject: [PATCH 45/82] feat: compute and smooth solar surplus Surplus is the car's own draw plus export minus import. The car term matters: its consumption is not surplus that disappeared but surplus already in use, and without it the controller reads its own draw as a deficit and winds itself down. Smoothed over five minutes, because raw grid readings move with every kettle cycle and the charger takes seconds to apply a change. Co-Authored-By: Claude Opus 5 --- custom_components/daze/solar.py | 69 +++++++++++++++++++++++++++++++++ tests/test_solar.py | 56 ++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/custom_components/daze/solar.py b/custom_components/daze/solar.py index 792a9f3..0a0f80e 100644 --- a/custom_components/daze/solar.py +++ b/custom_components/daze/solar.py @@ -219,3 +219,72 @@ def decide(state: SolarState) -> SolarDecision: target_watts=target, reason=f"surplus {available:.0f} W sustained, starting at {target} W", ) + + +def compute_surplus( + car_draw_w: float, export_w: float, import_w: float +) -> float: + """Return the power available to the car, in watts. + + The car's own draw is added back because it is not surplus that has + disappeared: it is surplus already being used. Omitting that term + makes the controller read its own consumption as a deficit and wind + itself down to zero. + + Args: + car_draw_w: What the charger is currently delivering. + export_w: Grid export, positive. + import_w: Grid import, positive. + + Returns: + Available watts, never negative. + + """ + return max(0.0, car_draw_w + export_w - import_w) + + +class SurplusSmoother: + """A moving average over a fixed time window. + + Raw grid readings move with every kettle and oven cycle. Acting on + them would rewrite the charger's limit constantly, against a device + that takes seconds to apply a change. + """ + + def __init__(self, window_seconds: float = SMOOTHING_SECONDS) -> None: + """Initialise an empty window. + + Args: + window_seconds: How much history to average over. + + """ + self.window_seconds = window_seconds + self._samples: list[tuple[float, float]] = [] + + def add(self, value: float, now: float) -> None: + """Record a reading and drop anything that has aged out. + + Args: + value: The reading, in watts. + now: A monotonic timestamp in seconds. + + """ + # A clock that goes backwards, from a restart or a correction, + # would otherwise leave future-dated samples wedged in the + # window forever. + if self._samples and now < self._samples[-1][0]: + self._samples.clear() + + self._samples.append((now, value)) + + cutoff = now - self.window_seconds + self._samples = [ + sample for sample in self._samples if sample[0] >= cutoff + ] + + def value(self) -> float | None: + """Return the average of the window, or None if it is empty.""" + if not self._samples: + return None + + return sum(value for _, value in self._samples) / len(self._samples) diff --git a/tests/test_solar.py b/tests/test_solar.py index 80c8cf9..08b8ba1 100644 --- a/tests/test_solar.py +++ b/tests/test_solar.py @@ -269,6 +269,62 @@ def test_every_decision_carries_a_reason() -> None: assert decision.reason.strip() == decision.reason +# ------------------------------------------------------------------ +# Surplus arithmetic +# ------------------------------------------------------------------ + + +def test_surplus_adds_back_the_cars_own_draw() -> None: + """The car's consumption is not surplus that disappeared; it is + surplus already in use. Without this term the controller reads its + own draw as a deficit and winds itself down to zero.""" + assert solar.compute_surplus(car_draw_w=3000, export_w=0, import_w=0) == 3000 + + +def test_surplus_counts_export() -> None: + assert solar.compute_surplus(car_draw_w=0, export_w=4000, import_w=0) == 4000 + + +def test_surplus_subtracts_import() -> None: + """Importing while charging means the car is over-drawing.""" + assert ( + solar.compute_surplus(car_draw_w=3000, export_w=0, import_w=1000) == 2000 + ) + + +def test_surplus_never_goes_negative() -> None: + """A negative surplus is not meaningful to the caller; zero is.""" + assert solar.compute_surplus(car_draw_w=0, export_w=0, import_w=5000) == 0 + + +def test_smoother_reports_nothing_until_it_has_data() -> None: + smoother = solar.SurplusSmoother() + assert smoother.value() is None + + +def test_smoother_averages_its_window() -> None: + smoother = solar.SurplusSmoother() + for index, reading in enumerate((1000, 2000, 3000)): + smoother.add(reading, now=float(index)) + assert smoother.value() == 2000 + + +def test_smoother_discards_readings_outside_the_window() -> None: + """Otherwise this morning's surplus still influences this evening.""" + smoother = solar.SurplusSmoother() + smoother.add(9999, now=0.0) + smoother.add(1000, now=solar.SMOOTHING_SECONDS + 1) + assert smoother.value() == 1000 + + +def test_smoother_survives_a_clock_that_goes_backwards() -> None: + """A restart or a clock correction must not wedge it.""" + smoother = solar.SurplusSmoother() + smoother.add(1000, now=100.0) + smoother.add(2000, now=50.0) + assert smoother.value() is not None + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From ea5972e76370e904c6b88b45b70045b3fb1c6b28 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 10:42:16 +0100 Subject: [PATCH 46/82] fix: narrow backward-clock handling in SurplusSmoother The original implementation cleared the entire window on any backward clock tick, discarding good history. Instead, drop only the samples whose timestamp is in the future relative to now. This preserves the smoothing that the class exists to provide while still protecting against future-dated samples wedging the window forever. Rewrote test_smoother_survives_a_clock_that_goes_backwards to verify the fix works: adds good history and a far-future sample, jumps backward, then asserts the future sample is dropped while history survives. Co-Authored-By: Claude Opus 5 --- custom_components/daze/solar.py | 11 +++++++---- tests/test_solar.py | 25 ++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/custom_components/daze/solar.py b/custom_components/daze/solar.py index 0a0f80e..4697700 100644 --- a/custom_components/daze/solar.py +++ b/custom_components/daze/solar.py @@ -269,11 +269,14 @@ def add(self, value: float, now: float) -> None: now: A monotonic timestamp in seconds. """ - # A clock that goes backwards, from a restart or a correction, - # would otherwise leave future-dated samples wedged in the - # window forever. + # A monotonic clock should never go backwards, but if a caller + # passes wall-clock time instead, drop only the future-dated + # samples rather than discarding the entire history and losing + # the smoothing this class exists to provide. if self._samples and now < self._samples[-1][0]: - self._samples.clear() + self._samples = [ + sample for sample in self._samples if sample[0] <= now + ] self._samples.append((now, value)) diff --git a/tests/test_solar.py b/tests/test_solar.py index 08b8ba1..c179f7f 100644 --- a/tests/test_solar.py +++ b/tests/test_solar.py @@ -318,11 +318,30 @@ def test_smoother_discards_readings_outside_the_window() -> None: def test_smoother_survives_a_clock_that_goes_backwards() -> None: - """A restart or a clock correction must not wedge it.""" + """A restart or a clock correction must not wedge it. + + A future-dated sample must be dropped, not preserved forever in the + average. This test adds good history, then a far-future sample, then + jumps the clock backward. The future sample must be discarded while + keeping the good history that is still relevant. + """ smoother = solar.SurplusSmoother() + # Build up good history at early timestamps. smoother.add(1000, now=100.0) - smoother.add(2000, now=50.0) - assert smoother.value() is not None + smoother.add(2000, now=200.0) + # Add a sample far in the future (beyond the window). + smoother.add(9999, now=400.0) + # Clock jumps backward. The sample at 400 is now future-dated relative + # to now=250, and the backward-jump fix must drop it. The good history + # at 100 and 200 should survive because they are <= 250. + smoother.add(3000, now=250.0) + # cutoff = 250 - 300 = -50. Samples at 100, 200, 250 all >= -50, so + # they survive cutoff filtering. The sample at 400 is > 250, so the + # backward-jump fix removes it before the cutoff filter runs. + # Expected value: (1000 + 2000 + 3000) / 3 = 2000. + # Without the fix, the sample at 400 would be preserved and the + # average would be (1000 + 2000 + 9999 + 3000) / 4 = 4000.25. + assert smoother.value() == 2000 def _main() -> int: From ba07cde86ee62737175de6ce31fc3f698db8dfc7 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 10:45:45 +0100 Subject: [PATCH 47/82] feat: let the user pick grid import and export sensors Both optional, so the integration works unchanged without solar. Solar control refuses to leave 'off' until both are set, which is checked where it can be explained rather than by making the fields required here. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/config_flow.py | 28 ++++++++++++++++++++- custom_components/daze/const.py | 12 +++++++++ custom_components/daze/strings.json | 6 +++-- custom_components/daze/translations/it.json | 6 +++-- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/custom_components/daze/config_flow.py b/custom_components/daze/config_flow.py index c3afb93..e7968b3 100644 --- a/custom_components/daze/config_flow.py +++ b/custom_components/daze/config_flow.py @@ -13,6 +13,7 @@ OptionsFlow, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_get_clientsession from .api import DazeApiClient @@ -23,6 +24,8 @@ CONF_EMAIL, CONF_EVSE_NAME, CONF_FIRMWARE_VERSION, + CONF_GRID_EXPORT_SENSOR, + CONF_GRID_IMPORT_SENSOR, CONF_NETWORK_NAME, CONF_NETWORK_UID, CONF_POLL_INTERVAL, @@ -381,6 +384,7 @@ async def async_step_init( ), ) + options = self._config_entry.options schema = vol.Schema( { vol.Required( @@ -388,7 +392,29 @@ async def async_step_init( ): vol.All( vol.Coerce(int), vol.Range(min=MIN_POLL_INTERVAL, max=MAX_POLL_INTERVAL), - ) + ), + # Optional so the integration works without solar. Solar + # control refuses to leave "off" until both are set. + vol.Optional( + CONF_GRID_IMPORT_SENSOR, + description={ + "suggested_value": options.get(CONF_GRID_IMPORT_SENSOR) + }, + ): selector.EntitySelector( + selector.EntitySelectorConfig( + domain="sensor", device_class="power" + ) + ), + vol.Optional( + CONF_GRID_EXPORT_SENSOR, + description={ + "suggested_value": options.get(CONF_GRID_EXPORT_SENSOR) + }, + ): selector.EntitySelector( + selector.EntitySelectorConfig( + domain="sensor", device_class="power" + ) + ), } ) diff --git a/custom_components/daze/const.py b/custom_components/daze/const.py index 4f6015c..b38b86a 100644 --- a/custom_components/daze/const.py +++ b/custom_components/daze/const.py @@ -45,6 +45,18 @@ MIN_POLL_INTERVAL = 10 # seconds MAX_POLL_INTERVAL = 600 # seconds +# Solar surplus control. The two grid sensors are chosen by the user in +# the options flow; both are required before solar control can leave +# "off". +CONF_GRID_IMPORT_SENSOR = "grid_import_sensor" +CONF_GRID_EXPORT_SENSOR = "grid_export_sensor" + +# Watts to leave for the house before the car gets any. Site-specific, +# so it is an entity rather than a constant; this is only its default. +CONF_SOLAR_RESERVE = "solar_reserve" +DEFAULT_SOLAR_RESERVE = 0 +MAX_SOLAR_RESERVE = 5000 + # How long an optimistic switch state is trusted before the charger's # own reading takes over again. Observed transitions completed in 9 to # 12 seconds, so this both covers them and bounds how long the UI can diff --git a/custom_components/daze/strings.json b/custom_components/daze/strings.json index 4dc0003..d10d58f 100644 --- a/custom_components/daze/strings.json +++ b/custom_components/daze/strings.json @@ -149,9 +149,11 @@ "step": { "init": { "title": "Daze Wallbox options", - "description": "How often to poll the Daze cloud API. Lower values make the entities more responsive but send more requests.", + "description": "How often to poll the Daze cloud API, and which sensors report your grid import and export. The grid sensors are only needed for solar control; leave them empty otherwise.", "data": { - "poll_interval": "Polling interval (seconds)" + "poll_interval": "Polling interval (seconds)", + "grid_import_sensor": "Grid import power sensor", + "grid_export_sensor": "Grid export power sensor" } } } diff --git a/custom_components/daze/translations/it.json b/custom_components/daze/translations/it.json index 50271f0..2587d95 100644 --- a/custom_components/daze/translations/it.json +++ b/custom_components/daze/translations/it.json @@ -156,9 +156,11 @@ "step": { "init": { "title": "Opzioni Daze Wallbox", - "description": "Ogni quanto interrogare l'API cloud di Daze. Valori bassi rendono le entità più reattive ma inviano più richieste.", + "description": "Ogni quanto interrogare l'API cloud di Daze e quali sensori riportano prelievo e immissione in rete. I sensori di rete servono solo per il controllo solare; lasciali vuoti altrimenti.", "data": { - "poll_interval": "Intervallo di aggiornamento (secondi)" + "poll_interval": "Intervallo di aggiornamento (secondi)", + "grid_import_sensor": "Sensore di potenza prelevata dalla rete", + "grid_export_sensor": "Sensore di potenza immessa in rete" } } } From dd021cf7f2202d8ae3978643a0fda04c8cbf969f Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 10:47:08 +0100 Subject: [PATCH 48/82] docs: correct three defects in the solar control plan Found while executing it, each before the affected task was dispatched. Task 1's step claimed 21 passing tests where the test file it supplied defines 20, and task 2 inherited the same off-by-one, claiming 29 where 20 plus 8 is 28. Task 4 contained an assertion that cannot fail: assert controller.last_decision is None or True That was the whole payload of the reserve test, so the reserve would have shipped untested. Replaced with a comparison against an identical controller holding no reserve, asserting the target comes out lower. Not an exact difference: the target is also clamped to the charger's ceiling, so the gap is not simply the reserve and an exact assertion would have been wrong in a way that looked right. Also records the version decision, 0.2.0, set in the final task only, and ignores the subagent workspace. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + .../plans/2026-09-29-solar-surplus-control.md | 56 +++++++++++++++---- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 5a6902f..ee73047 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ credentials.* /log *.out probe-*.txt + +# Subagent-driven development scratch +/.superpowers/ diff --git a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md index de5be78..a4227c3 100644 --- a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md +++ b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md @@ -12,7 +12,9 @@ ## Global Constraints -- **Do not bump `manifest.json` version.** The maintainer sets version numbers explicitly; leave `"version": "0.1.6"` untouched. +- **Version:** set `manifest.json` to `"version": "0.2.0"` in the final + documentation task, and nowhere else. No other task touches it. The + maintainer chose this number; do not invent a different one. - **Deploying is `git push`.** There is no separate copy step. Push `main` and force-push the `v0.1.6` tag together, since the tag tracks `main`. - **Every commit message ends with:** `Co-Authored-By: Claude Opus 5 ` - **Lint gate:** `ruff check custom_components/daze/` must pass. This is what CI runs. @@ -759,7 +761,7 @@ class SurplusSmoother: - [ ] **Step 4: Run the tests to verify they pass** Run: `python3 tests/test_solar.py` -Expected: PASS, `29 passed, 0 failed` +Expected: PASS, `28 passed, 0 failed` - [ ] **Step 5: Lint** @@ -1175,12 +1177,36 @@ def test_a_pending_command_is_not_piled_on() -> None: assert coordinator.api_client.calls == [] -def test_the_reserve_is_honoured() -> None: - controller, _, _ = build() - controller.reserve_w = 2000 - asyncio.run(controller.async_tick()) - assert controller.surplus_w == 8000 - assert controller.last_decision is None or True +def test_the_reserve_lowers_the_target() -> None: + """The house gets its share before the car does. + + Compared against an identical controller with no reserve, rather + than asserting an exact figure: the target is also clamped to the + charger's ceiling, so the difference is not simply the reserve. + """ + plain, _, _ = build() + plain.mode = controller_module.SolarMode.ACTIVE + + withheld, _, _ = build() + withheld.mode = controller_module.SolarMode.ACTIVE + withheld.reserve_w = 2000 + + for _ in range(2): + asyncio.run(plain.async_tick()) + asyncio.run(withheld.async_tick()) + + assert plain.last_decision is not None + assert withheld.last_decision is not None + + # The reserve must not change what surplus is, only what the car + # is allowed to take of it. + assert plain.surplus_w == withheld.surplus_w == 8000 + + plain_target = plain.last_decision.target_watts + withheld_target = withheld.last_decision.target_watts + assert plain_target is not None + assert withheld_target is not None + assert withheld_target < plain_target def test_listeners_are_told_after_a_tick() -> None: @@ -2600,7 +2626,15 @@ For a version you build and tune yourself, see [docs/solar-surplus-charging.md](docs/solar-surplus-charging.md). ``` -- [ ] **Step 3: Cross-reference from the YAML guide** +- [ ] **Step 3: Bump the version** + +Modify `custom_components/daze/manifest.json`, setting `"version"` to +`"0.2.0"`. This is the only task that touches it. + +Run: `python3 -c "import json; print(json.load(open('custom_components/daze/manifest.json'))['version'])"` +Expected: `0.2.0` + +- [ ] **Step 4: Cross-reference from the YAML guide** At the top of `docs/solar-surplus-charging.md`, after the first paragraph, add: @@ -2611,7 +2645,7 @@ At the top of `docs/solar-surplus-charging.md`, after the first paragraph, add: > needing logic of your own. ``` -- [ ] **Step 4: Verify and commit** +- [ ] **Step 5: Verify and commit** Run: `python3 tests/run_all.py` Expected: 0 failures. @@ -2626,7 +2660,7 @@ and stops the car should be watched for a day before it is trusted. Co-Authored-By: Claude Opus 5 " ``` -- [ ] **Step 5: Push, and move the tag** +- [ ] **Step 6: Push, and move the tag** ```bash set -a; . ./.env; set +a From 30e23831b3001201c366b44c7590befa9701f3df Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 10:47:09 +0100 Subject: [PATCH 49/82] docs: attribution follows the authoring model, not a fixed name A subagent running Sonnet correctly attributed its commit to Sonnet rather than the name this plan hardcoded from the controller's session. Mixed trailers across a branch are accurate, not inconsistent. Co-Authored-By: Claude Opus 5 --- docs/superpowers/plans/2026-09-29-solar-surplus-control.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md index a4227c3..ca4cdbc 100644 --- a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md +++ b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md @@ -16,7 +16,9 @@ documentation task, and nowhere else. No other task touches it. The maintainer chose this number; do not invent a different one. - **Deploying is `git push`.** There is no separate copy step. Push `main` and force-push the `v0.1.6` tag together, since the tag tracks `main`. -- **Every commit message ends with:** `Co-Authored-By: Claude Opus 5 ` +- **Every commit message ends with the attribution line your own session + specifies.** Do not copy a model name from this plan: a subagent running + a different model attributes to that model, which is accurate. - **Lint gate:** `ruff check custom_components/daze/` must pass. This is what CI runs. - **Test gate:** `python3 tests/run_all.py` must report 0 failures. - **No Home Assistant in the test environment.** Pure modules are imported directly; Home-Assistant-coupled modules are tested through the stub harness in `tests/test_entities.py`. From 547874ce05369c04dbfc8de25d72427bcfb03756 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 10:51:31 +0100 Subject: [PATCH 50/82] docs: describe the grid sensors in the options step docstring The options step now renders three fields, but its docstring still described only the poll interval. Raised as a Minor finding in the Task 3 review. Co-Authored-By: Claude Opus 5 --- custom_components/daze/config_flow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/daze/config_flow.py b/custom_components/daze/config_flow.py index e7968b3..a2ff076 100644 --- a/custom_components/daze/config_flow.py +++ b/custom_components/daze/config_flow.py @@ -368,11 +368,15 @@ def __init__(self, config_entry: ConfigEntry) -> None: async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Let the user choose how often the charger is polled. + """Let the user choose the poll interval and the grid sensors. Faster polling makes the entities more responsive at the cost of more requests against the Daze cloud API. The entry reloads on save, so the new interval takes effect immediately. + + The two grid sensors feed solar surplus control and are + optional: leaving them empty is a supported configuration, and + solar control simply refuses to arm without them. """ if user_input is not None: return self.async_create_entry(title="", data=user_input) From ac37732366fdd99d3fd87e42a99669d113f3fe7a Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 10:56:33 +0100 Subject: [PATCH 51/82] feat: add the solar controller Reads the grid sensors, assembles the decision state, and carries out the result. Commands go through the API client rather than the number entity, which makes the manual-override rule mechanical: any write arriving at the entity is by definition external. Simulate decides and logs but sends nothing, and is what the mode defaults to on first enable. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/solar_controller.py | 360 +++++++++++++++++++++ tests/run_all.py | 1 + tests/test_solar_controller.py | 300 +++++++++++++++++ 3 files changed, 661 insertions(+) create mode 100644 custom_components/daze/solar_controller.py create mode 100644 tests/test_solar_controller.py diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py new file mode 100644 index 0000000..e5ec919 --- /dev/null +++ b/custom_components/daze/solar_controller.py @@ -0,0 +1,360 @@ +"""Drive the charger from solar surplus. + +Reads the user's grid sensors, assembles the state the decision needs, +and carries out whatever it returns. The decision itself lives in +solar.py, which has no Home Assistant coupling and is where the +behaviour is tested. + +Commands go through the API client, never through the number entity. +That makes the manual-override rule mechanical: any write arriving at +the entity is by definition external, so solar control disarms itself +without needing a flag that could be wrong. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Callable +from enum import Enum +from typing import TYPE_CHECKING, Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_call_later + +from .payload import ( + charger_offline_reason, + is_charge_enabled, + max_charging_current, + milliamps_to_watts, + min_charging_current, + watts_to_milliamps, +) +from .solar import ( + TICK_SECONDS, + SolarAction, + SolarDecision, + SolarState, + SurplusSmoother, + compute_surplus, + decide, +) + +if TYPE_CHECKING: + from .coordinator import DazeDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class SolarMode(Enum): + """How much authority solar control has. + + A single tri-state rather than two switches, so that "dry run on, + solar off" cannot be expressed. + """ + + OFF = "off" + SIMULATE = "simulate" + ACTIVE = "active" + + +class SolarController: + """Evaluates surplus on a timer and acts on the result.""" + + def __init__( + self, + hass: HomeAssistant, + coordinator: DazeDataUpdateCoordinator, + import_entity: str | None, + export_entity: str | None, + ) -> None: + """Initialise in simulate mode, the safe default on first enable. + + Args: + hass: Used to read the grid sensors and schedule ticks. + coordinator: Source of charger state and the API client. + import_entity: Grid import power sensor, or None. + export_entity: Grid export power sensor, or None. + + """ + self._hass = hass + self._coordinator = coordinator + self._import_entity = import_entity + self._export_entity = export_entity + + self._mode = SolarMode.SIMULATE + self._reserve_w = 0.0 + self._smoother = SurplusSmoother() + self._last_decision: SolarDecision | None = None + self._listeners: list[Callable[[], None]] = [] + self._cancel_tick: Callable[[], None] | None = None + + self._above_since: float | None = None + self._below_since: float | None = None + self._started_at: float | None = None + self._backoff_until: float = 0.0 + self._command_times: list[float] = [] + self._sensor_warning_logged = False + + # ------------------------------------------------------------------ + # Public surface + # ------------------------------------------------------------------ + + @property + def mode(self) -> SolarMode: + """Return the current mode.""" + return self._mode + + @mode.setter + def mode(self, value: SolarMode) -> None: + """Set the mode, resetting timers when it changes.""" + if value is self._mode: + return + + self._mode = value + self._above_since = None + self._below_since = None + _LOGGER.info("Solar control set to %s", value.value) + self._notify() + + @property + def reserve_w(self) -> float: + """Return the watts held back for the house.""" + return self._reserve_w + + @reserve_w.setter + def reserve_w(self, value: float) -> None: + """Set the reserve.""" + self._reserve_w = max(0.0, float(value)) + self._notify() + + @property + def surplus_w(self) -> float | None: + """Return the smoothed surplus, or None before the first read.""" + return self._smoother.value() + + @property + def last_decision(self) -> SolarDecision | None: + """Return the most recent decision, for display and logging.""" + return self._last_decision + + @property + def configured(self) -> bool: + """Whether both grid sensors have been chosen.""" + return bool(self._import_entity and self._export_entity) + + def add_listener(self, listener: Callable[[], None]) -> Callable[[], None]: + """Register a callback for state changes. + + Returns: + A callable that unregisters the listener. + + """ + self._listeners.append(listener) + + def _remove() -> None: + if listener in self._listeners: + self._listeners.remove(listener) + + return _remove + + async def async_start(self) -> None: + """Begin ticking.""" + self._schedule_tick() + + async def async_stop(self) -> None: + """Stop ticking and drop listeners.""" + if self._cancel_tick is not None: + self._cancel_tick() + self._cancel_tick = None + self._listeners.clear() + + # ------------------------------------------------------------------ + # The cycle + # ------------------------------------------------------------------ + + async def async_tick(self) -> None: + """Evaluate once and act if the mode allows it.""" + if self._mode is SolarMode.OFF: + return + + now = time.monotonic() + surplus = self._read_surplus() + + if surplus is None: + # Absence of information is never grounds for acting. + if not self._sensor_warning_logged: + self._sensor_warning_logged = True + _LOGGER.warning( + "Solar control cannot read its grid sensors; doing " + "nothing until they report" + ) + return + + self._sensor_warning_logged = False + self._smoother.add(surplus, now) + + smoothed = self._smoother.value() + if smoothed is None: + return + + state = self._build_state(smoothed, now) + self._track_thresholds(state, now) + + decision = decide(self._build_state(smoothed, now)) + self._last_decision = decision + + if decision.action is SolarAction.NOTHING: + _LOGGER.debug("Solar control: %s", decision.reason) + self._notify() + return + + if self._mode is SolarMode.SIMULATE: + _LOGGER.info( + "Solar control (simulating): would %s — %s", + decision.action.value, + decision.reason, + ) + self._notify() + return + + await self._carry_out(decision, now) + self._notify() + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _schedule_tick(self) -> None: + """Queue the next evaluation.""" + + async def _run(_now: Any) -> None: + self._cancel_tick = None + try: + await self.async_tick() + finally: + self._schedule_tick() + + self._cancel_tick = async_call_later(self._hass, TICK_SECONDS, _run) + + def _read_number(self, entity_id: str | None) -> float | None: + """Read a numeric sensor, or None if it cannot be used.""" + if not entity_id: + return None + + state = self._hass.states.get(entity_id) + if state is None: + return None + + try: + return float(state.state) + except (TypeError, ValueError): + return None + + def _read_surplus(self) -> float | None: + """Compute surplus from the grid sensors and the car's draw.""" + import_w = self._read_number(self._import_entity) + export_w = self._read_number(self._export_entity) + + if import_w is None or export_w is None: + return None + + data = self._coordinator.data or {} + car_draw = data.get("instantPowerAsWatt") + car_w = float(car_draw) if isinstance(car_draw, (int, float)) else 0.0 + + return compute_surplus( + car_draw_w=car_w, export_w=export_w, import_w=import_w + ) + + def _build_state(self, smoothed: float, now: float) -> SolarState: + """Assemble everything the decision depends on.""" + data = self._coordinator.data or {} + + limit_ma = data.get("maxExternalChargingCurrentInMilliAmps") or 0 + charging = bool(is_charge_enabled(data)) + schedules = data.get("schedules") + + return SolarState( + surplus_w=smoothed, + reserve_w=self._reserve_w, + floor_w=milliamps_to_watts(min_charging_current(data), data), + ceiling_w=milliamps_to_watts(max_charging_current(data), data), + charging=charging, + current_limit_w=milliamps_to_watts(int(limit_ma), data), + command_pending=self._coordinator.limit_state.pending, + charger_reachable=charger_offline_reason(data) is None, + eco_mode_on=bool(data.get("ecoModeEnabled")), + schedule_set=bool(schedules), + car_connected=data.get("chargeSession") is not None or charging, + seconds_above_threshold=self._elapsed(self._above_since, now), + seconds_below_threshold=self._elapsed(self._below_since, now), + seconds_since_start=self._elapsed(self._started_at, now), + seconds_since_last_command=( + now - self._command_times[-1] if self._command_times else 1e9 + ), + commands_this_hour=self._commands_this_hour(now), + backoff_remaining_s=max(0.0, self._backoff_until - now), + ) + + @staticmethod + def _elapsed(since: float | None, now: float) -> float: + """Return seconds since a mark, or zero if it is unset.""" + return 0.0 if since is None else max(0.0, now - since) + + def _commands_this_hour(self, now: float) -> int: + """Count commands issued in the last hour, dropping older ones.""" + self._command_times = [ + when for when in self._command_times if now - when < 3600 + ] + return len(self._command_times) + + def _track_thresholds(self, state: SolarState, now: float) -> None: + """Maintain how long surplus has been above or below the floor.""" + available = state.surplus_w - state.reserve_w + + if available >= state.floor_w: + self._below_since = None + if self._above_since is None: + self._above_since = now + else: + self._above_since = None + if self._below_since is None: + self._below_since = now + + async def _carry_out(self, decision: SolarDecision, now: float) -> None: + """Issue the command a decision calls for.""" + client = self._coordinator.api_client + serial = self._coordinator.serial_number + data = self._coordinator.data or {} + + _LOGGER.info( + "Solar control: %s — %s", decision.action.value, decision.reason + ) + + if decision.action is SolarAction.STOP: + await client.async_stop_charge(serial) + self._started_at = None + + elif decision.action is SolarAction.START: + if decision.target_watts is not None: + await client.async_set_max_charging_current( + serial, watts_to_milliamps(decision.target_watts, data) + ) + await client.async_start_charge(serial) + self._started_at = now + + elif decision.action is SolarAction.SET: + if decision.target_watts is None: + return + await client.async_set_max_charging_current( + serial, watts_to_milliamps(decision.target_watts, data) + ) + + self._command_times.append(now) + self._coordinator.async_schedule_refresh_in(10) + + def _notify(self) -> None: + """Tell the entities to redraw.""" + for listener in list(self._listeners): + listener() diff --git a/tests/run_all.py b/tests/run_all.py index ac27ff6..be61ad6 100755 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -26,6 +26,7 @@ "test_qa_invariants.py", "test_entities.py", "test_solar.py", + "test_solar_controller.py", ) diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py new file mode 100644 index 0000000..b5c8c0a --- /dev/null +++ b/tests/test_solar_controller.py @@ -0,0 +1,300 @@ +"""Tests for the solar controller against a stubbed Home Assistant. + +The controller is where the decision meets real sensors and a real API +client, so these cover the joins: reading the sensors, assembling the +state, honouring simulate, and not fighting the retry machinery. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import sys +import types +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE_DIR = ROOT / "custom_components" / "daze" + + +class StubState: + """A Home Assistant state object.""" + + def __init__(self, state: str) -> None: + self.state = state + + +class StubStates: + """The subset of hass.states the controller uses.""" + + def __init__(self) -> None: + self._states: dict[str, StubState] = {} + + def set(self, entity_id: str, value: str) -> None: + """Set a state.""" + self._states[entity_id] = StubState(value) + + def get(self, entity_id: str) -> StubState | None: + """Return a state, or None if unknown.""" + return self._states.get(entity_id) + + +class StubHass: + """Just enough of HomeAssistant for the controller.""" + + def __init__(self) -> None: + self.states = StubStates() + + +def _install_stubs() -> None: + """Register the Home Assistant modules the controller imports.""" + def _module(name: str, **attributes: Any) -> None: + module = types.ModuleType(name) + for key, value in attributes.items(): + setattr(module, key, value) + sys.modules[name] = module + + scheduled: list[Any] = [] + + def async_call_later(hass: Any, delay: Any, action: Any) -> Any: + scheduled.append((delay, action)) + return lambda: None + + _module("homeassistant") + _module("homeassistant.core", HomeAssistant=StubHass, callback=lambda fn: fn) + _module("homeassistant.helpers") + _module("homeassistant.helpers.event", async_call_later=async_call_later) + + +_install_stubs() + + +def _load_package() -> None: + """Load the integration modules the controller needs.""" + package = types.ModuleType("daze_solar_ctl") + package.__path__ = [str(PACKAGE_DIR)] + sys.modules["daze_solar_ctl"] = package + + for name in ("const", "payload", "optimistic", "solar"): + spec = importlib.util.spec_from_file_location( + f"daze_solar_ctl.{name}", PACKAGE_DIR / f"{name}.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[f"daze_solar_ctl.{name}"] = module + spec.loader.exec_module(module) + + spec = importlib.util.spec_from_file_location( + "daze_solar_ctl.solar_controller", + PACKAGE_DIR / "solar_controller.py", + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules["daze_solar_ctl.solar_controller"] = module + spec.loader.exec_module(module) + + +_load_package() + +solar = sys.modules["daze_solar_ctl.solar"] +optimistic = sys.modules["daze_solar_ctl.optimistic"] +controller_module = sys.modules["daze_solar_ctl.solar_controller"] + + +class FakeApi: + """Records the commands the controller issues.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, Any]] = [] + + async def async_set_max_charging_current( + self, serial: str, current_ma: int, attempts: int = 8 + ) -> dict: + self.calls.append(("current", current_ma)) + return {} + + async def async_start_charge(self, serial: str, attempts: int = 8) -> dict: + self.calls.append(("start", serial)) + return {} + + async def async_stop_charge(self, serial: str, attempts: int = 8) -> dict: + self.calls.append(("stop", serial)) + return {} + + +class FakeCoordinator: + """The coordinator surface the controller touches.""" + + def __init__(self, data: dict[str, Any]) -> None: + self.data = data + self.api_client = FakeApi() + self.serial_number = "SER1" + self.limit_state = optimistic.OptimisticState() + self.refresh_delays: list[int] = [] + + def async_schedule_refresh_in(self, delay: int) -> None: + self.refresh_delays.append(delay) + + +CHARGING_DATA: dict[str, Any] = { + "active": True, + "lastAttributesUpdatedOn": None, + "evseStatus": "charging", + "evseState": 3, + "instantPowerAsWatt": 3000, + "maxExternalChargingCurrentInMilliAmps": 13000, + "lastMaxInstallationCurrent": 32000, + "lastACVoltageL1": 230, + "ecoModeEnabled": False, + "schedules": [], + "chargeSession": {"sessionId": 1}, +} + + +def build(data: dict[str, Any] | None = None) -> tuple[Any, Any, Any]: + """Build a controller wired to stubs.""" + hass = StubHass() + hass.states.set("sensor.grid_import", "0") + hass.states.set("sensor.grid_export", "5000") + + coordinator = FakeCoordinator(dict(data or CHARGING_DATA)) + controller = controller_module.SolarController( + hass=hass, + coordinator=coordinator, + import_entity="sensor.grid_import", + export_entity="sensor.grid_export", + ) + return controller, coordinator, hass + + +def test_surplus_uses_both_sensors_and_the_car_draw() -> None: + """3000 W drawn plus 5000 W exported is 8000 W available.""" + controller, _, _ = build() + asyncio.run(controller.async_tick()) + assert controller.surplus_w == 8000 + + +def test_simulate_decides_but_sends_nothing() -> None: + """The default on first enable. It must be genuinely inert.""" + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.SIMULATE + + asyncio.run(controller.async_tick()) + + assert coordinator.api_client.calls == [] + assert controller.last_decision is not None + + +def test_off_does_not_even_decide() -> None: + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.OFF + + asyncio.run(controller.async_tick()) + + assert coordinator.api_client.calls == [] + + +def test_active_follows_surplus() -> None: + """13000 mA at 230 V is about 2990 W; 8000 W of surplus should + raise it, and the request is made in milliamps.""" + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + # Seed the smoother so the first tick has a usable average. + asyncio.run(controller.async_tick()) + asyncio.run(controller.async_tick()) + + assert any(call[0] == "current" for call in coordinator.api_client.calls) + + +def test_a_missing_sensor_stops_nothing() -> None: + """Absence of information is never grounds for acting.""" + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + hass.states.set("sensor.grid_export", "unavailable") + + asyncio.run(controller.async_tick()) + + assert coordinator.api_client.calls == [] + + +def test_a_pending_command_is_not_piled_on() -> None: + """The integration already retries in the background for minutes.""" + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + coordinator.limit_state.request(20000) + + asyncio.run(controller.async_tick()) + asyncio.run(controller.async_tick()) + + assert coordinator.api_client.calls == [] + + +def test_the_reserve_lowers_the_target() -> None: + """The house gets its share before the car does. + + Compared against an identical controller with no reserve, rather + than asserting an exact figure: the target is also clamped to the + charger's ceiling, so the difference is not simply the reserve. + """ + plain, _, _ = build() + plain.mode = controller_module.SolarMode.ACTIVE + + withheld, _, _ = build() + withheld.mode = controller_module.SolarMode.ACTIVE + withheld.reserve_w = 2000 + + for _ in range(2): + asyncio.run(plain.async_tick()) + asyncio.run(withheld.async_tick()) + + assert plain.last_decision is not None + assert withheld.last_decision is not None + + # The reserve must not change what surplus is, only what the car + # is allowed to take of it. + assert plain.surplus_w == withheld.surplus_w == 8000 + + plain_target = plain.last_decision.target_watts + withheld_target = withheld.last_decision.target_watts + assert plain_target is not None + assert withheld_target is not None + assert withheld_target < plain_target + + +def test_listeners_are_told_after_a_tick() -> None: + """The entities redraw from this rather than polling the object.""" + controller, _, _ = build() + seen: list[int] = [] + controller.add_listener(lambda: seen.append(1)) + + asyncio.run(controller.async_tick()) + + assert seen + + +def _main() -> int: + """Run every test in this module and report results.""" + tests = [ + value + for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value) + ] + + failures = 0 + for test in tests: + try: + test() + except Exception as err: # noqa: BLE001 - standalone runner + failures += 1 + print(f"FAIL {test.__name__}: {type(err).__name__}: {err}") + else: + print(f"ok {test.__name__}") + + print(f"\n{len(tests) - failures} passed, {failures} failed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(_main()) From 8f51de85efbc34a19c1061c14e06e893e94a93cd Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 10:58:52 +0100 Subject: [PATCH 52/82] fix: ship solar control off by default The controller's constructor defaulted to simulate, but the spec's Rollout section calls for shipping off: nothing should read a sensor or notify a listener until the user has opted in. Simulate is the select entity's own first-enable default (Task 7), not the controller's. Restores SolarMode.OFF as the constructor default, points the two tests that ticked without setting a mode at simulate explicitly (matching test_simulate_decides_but_sends_nothing), and adds a test that OFF is the default and produces no observable effect from a tick: no sensor read, no listener notification, no command. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/solar_controller.py | 8 ++++++-- tests/test_solar_controller.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index e5ec919..d748d12 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -68,7 +68,11 @@ def __init__( import_entity: str | None, export_entity: str | None, ) -> None: - """Initialise in simulate mode, the safe default on first enable. + """Initialise in the off state. + + Ships off: nothing reads a sensor or notifies a listener until + the user has opted in, since the select entity that gates that + opt-in defaults to simulate the first time it does. Args: hass: Used to read the grid sensors and schedule ticks. @@ -82,7 +86,7 @@ def __init__( self._import_entity = import_entity self._export_entity = export_entity - self._mode = SolarMode.SIMULATE + self._mode = SolarMode.OFF self._reserve_w = 0.0 self._smoother = SurplusSmoother() self._last_decision: SolarDecision | None = None diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index b5c8c0a..92c1a8c 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -171,10 +171,26 @@ def build(data: dict[str, Any] | None = None) -> tuple[Any, Any, Any]: def test_surplus_uses_both_sensors_and_the_car_draw() -> None: """3000 W drawn plus 5000 W exported is 8000 W available.""" controller, _, _ = build() + controller.mode = controller_module.SolarMode.SIMULATE asyncio.run(controller.async_tick()) assert controller.surplus_w == 8000 +def test_off_is_the_default_and_does_nothing_observable() -> None: + """Ships off: no sensor read and no notification until opted in.""" + controller, coordinator, _ = build() + seen: list[int] = [] + controller.add_listener(lambda: seen.append(1)) + + assert controller.mode is controller_module.SolarMode.OFF + + asyncio.run(controller.async_tick()) + + assert controller.surplus_w is None + assert seen == [] + assert coordinator.api_client.calls == [] + + def test_simulate_decides_but_sends_nothing() -> None: """The default on first enable. It must be genuinely inert.""" controller, coordinator, _ = build() @@ -266,6 +282,7 @@ def test_the_reserve_lowers_the_target() -> None: def test_listeners_are_told_after_a_tick() -> None: """The entities redraw from this rather than polling the object.""" controller, _, _ = build() + controller.mode = controller_module.SolarMode.SIMULATE seen: list[int] = [] controller.add_listener(lambda: seen.append(1)) From 64c8ab341dff50c1aabd9fe952d6f8c3e4a20edc Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 11:24:03 +0100 Subject: [PATCH 53/82] fix: harden the solar controller against 11 review findings Critical: - The car's own draw defaulted to 0 W whenever instantPowerAsWatt was absent from a poll (it comes from the active charge session and drops out of single polls), reading real draw as vanished surplus and risking a stop on a car that is still charging. Now skips the cycle when charging and the draw is unknown, and accepts the field as a numeric string. - test_a_missing_sensor_stops_nothing asserted only that nothing was sent, which a zeroed-out sensor also satisfies by landing inside the deadband; it now asserts what was actually observed (surplus_w and last_decision both stay None). - async_stop() could not stop a tick already in flight: _run clears its own cancel handle on entry, so the existing "cancel if present" logic found nothing to cancel and the finally block re-armed a fresh timer regardless, leaving an ACTIVE controller commanding hardware every tick after teardown with no handle left to cancel it. A _stopped flag now gates the re-arm. Important: - _carry_out had no error handling at all; a failing command now goes through _send_command, which mirrors number.py: an RPC failure is handed to the coordinator's background retry, anything else is only logged. An attempt is counted against the hourly backstop regardless of outcome, or a command stuck failing would retry every tick and defeat that backstop. - Grid sensor readings assumed watts; a kW inverter sensor would be read a thousand times too small. Now reads unit_of_measurement and accepts W or kW, skipping the cycle for anything else. - A failed sensor read left the above/below-floor timers running, so a blind period counted toward the stop delay the moment the sensors recovered. Both marks now clear when a read fails. - Added coverage for the timer lifecycle (start schedules, stop cancels, and stop during an in-flight tick does not re-arm) and for the controller's own bookkeeping that decide() depends on (_track_thresholds, _elapsed, _commands_this_hour), none of which had a test that could fail if replaced with a no-op. Minor: - An absent coordinator payload read as a reachable charger, safe only by luck via a different guard; now reads as not reachable directly. - The charging-limit field is coerced the same safe way as the car's draw, rather than an int() cast that would raise on a bad value. - Corrected a stale comment: the first tick already commands against this fixture, so the second is a repeated SET, not a seeding step. Every new or changed assertion was verified by breaking the behaviour it protects, confirming the test failed, and restoring the fix. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/solar_controller.py | 222 +++++++++++-- tests/test_solar_controller.py | 350 ++++++++++++++++++++- 2 files changed, 536 insertions(+), 36 deletions(-) diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index d748d12..0fd1669 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -22,6 +22,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_call_later +from .api import ( + COMMAND_ERROR_CODE_RPC_FAILURE, + ApiAuthError, + ApiCommandRejectedError, + ApiError, +) from .payload import ( charger_offline_reason, is_charge_enabled, @@ -45,6 +51,32 @@ _LOGGER = logging.getLogger(__name__) +# Recognised power units for the user's grid sensors, keyed by the +# lower-cased unit_of_measurement attribute. A kW inverter sensor read +# as watts would understate surplus by a factor of a thousand and +# still look like a plausible number, so anything else is treated the +# same as an unavailable reading rather than assumed to be watts. +_POWER_UNIT_FACTORS: dict[str, float] = {"w": 1.0, "kw": 1000.0} + + +def _coerce_float(value: Any) -> float | None: + """Parse a number that may have arrived as a numeric string. + + The Daze API and Home Assistant sensors both sometimes carry a + number as text, and a naive ``isinstance(value, (int, float))`` + check reads a string like ``"3000"`` as unusable rather than 3000. + """ + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return None + return None + class SolarMode(Enum): """How much authority solar control has. @@ -92,6 +124,7 @@ def __init__( self._last_decision: SolarDecision | None = None self._listeners: list[Callable[[], None]] = [] self._cancel_tick: Callable[[], None] | None = None + self._stopped = False self._above_since: float | None = None self._below_since: float | None = None @@ -164,10 +197,18 @@ def _remove() -> None: async def async_start(self) -> None: """Begin ticking.""" + self._stopped = False self._schedule_tick() async def async_stop(self) -> None: - """Stop ticking and drop listeners.""" + """Stop ticking and drop listeners. + + Sets a flag rather than only cancelling the pending timer, + because a tick already in flight has cleared its own handle + before this can run: there is nothing left to cancel, but the + cycle must still not re-arm itself once it finishes. + """ + self._stopped = True if self._cancel_tick is not None: self._cancel_tick() self._cancel_tick = None @@ -186,7 +227,12 @@ async def async_tick(self) -> None: surplus = self._read_surplus() if surplus is None: - # Absence of information is never grounds for acting. + # Absence of information is never grounds for acting, and + # must not silently continue a confirmation or stop delay + # that was timed against a period nobody actually observed. + self._above_since = None + self._below_since = None + if not self._sensor_warning_logged: self._sensor_warning_logged = True _LOGGER.warning( @@ -200,6 +246,8 @@ async def async_tick(self) -> None: smoothed = self._smoother.value() if smoothed is None: + self._above_since = None + self._below_since = None return state = self._build_state(smoothed, now) @@ -237,12 +285,20 @@ async def _run(_now: Any) -> None: try: await self.async_tick() finally: - self._schedule_tick() + if not self._stopped: + self._schedule_tick() self._cancel_tick = async_call_later(self._hass, TICK_SECONDS, _run) - def _read_number(self, entity_id: str | None) -> float | None: - """Read a numeric sensor, or None if it cannot be used.""" + def _read_power(self, entity_id: str | None) -> float | None: + """Read a grid power sensor, in watts, or None if unusable. + + Only W and kW are recognised, whatever unit the entity itself + displays. An unrecognised or missing unit is treated the same + as an unavailable reading: acting on a number whose scale is + unknown risks a surplus over- or under-stated by a factor of a + thousand, which is worse than waiting a cycle. + """ if not entity_id: return None @@ -250,22 +306,47 @@ def _read_number(self, entity_id: str | None) -> float | None: if state is None: return None - try: - return float(state.state) - except (TypeError, ValueError): + value = _coerce_float(state.state) + if value is None: return None + attributes = getattr(state, "attributes", None) or {} + unit = str(attributes.get("unit_of_measurement", "")).strip().lower() + factor = _POWER_UNIT_FACTORS.get(unit) + if factor is None: + return None + + return value * factor + + @staticmethod + def _car_draw_w(data: dict[str, Any]) -> float | None: + """Return the charger's own draw, in watts, or None if unknown. + + Zero is only a safe default while the charger reports that it + is not delivering power. ``instantPowerAsWatt`` comes from the + active charge session (see payload.merge_payload), so it goes + missing whenever that session drops out of a single poll. + Reading that as zero while the charger is actually charging + misreads the car's own draw as surplus that vanished, which is + the exact failure this project has already hit once. + """ + value = _coerce_float(data.get("instantPowerAsWatt")) + if value is not None: + return value + return None if is_charge_enabled(data) else 0.0 + def _read_surplus(self) -> float | None: """Compute surplus from the grid sensors and the car's draw.""" - import_w = self._read_number(self._import_entity) - export_w = self._read_number(self._export_entity) + import_w = self._read_power(self._import_entity) + export_w = self._read_power(self._export_entity) if import_w is None or export_w is None: return None data = self._coordinator.data or {} - car_draw = data.get("instantPowerAsWatt") - car_w = float(car_draw) if isinstance(car_draw, (int, float)) else 0.0 + car_w = self._car_draw_w(data) + if car_w is None: + return None return compute_surplus( car_draw_w=car_w, export_w=export_w, import_w=import_w @@ -275,7 +356,9 @@ def _build_state(self, smoothed: float, now: float) -> SolarState: """Assemble everything the decision depends on.""" data = self._coordinator.data or {} - limit_ma = data.get("maxExternalChargingCurrentInMilliAmps") or 0 + limit_ma = _coerce_float( + data.get("maxExternalChargingCurrentInMilliAmps") + ) or 0.0 charging = bool(is_charge_enabled(data)) schedules = data.get("schedules") @@ -285,9 +368,15 @@ def _build_state(self, smoothed: float, now: float) -> SolarState: floor_w=milliamps_to_watts(min_charging_current(data), data), ceiling_w=milliamps_to_watts(max_charging_current(data), data), charging=charging, - current_limit_w=milliamps_to_watts(int(limit_ma), data), + current_limit_w=milliamps_to_watts(limit_ma, data), command_pending=self._coordinator.limit_state.pending, - charger_reachable=charger_offline_reason(data) is None, + # An absent payload — before the first successful poll — + # must read as unreachable rather than as "no known reason + # to think otherwise". charger_offline_reason returns None + # for that case too, which would otherwise make an unpolled + # charger look reachable by luck rather than by design. + charger_reachable=bool(data) + and charger_offline_reason(data) is None, eco_mode_on=bool(data.get("ecoModeEnabled")), schedule_set=bool(schedules), car_connected=data.get("chargeSession") is not None or charging, @@ -326,36 +415,119 @@ def _track_thresholds(self, state: SolarState, now: float) -> None: if self._below_since is None: self._below_since = now + async def _send_command( + self, description: str, call: Callable[[], Any], retry_key: str + ) -> bool: + """Issue one command, handing a stuck link to the background retry. + + Mirrors the handling in number.py: an RPC failure — the Daze + service could not reach the wallbox over its own link — is + handed to the coordinator's existing background retry rather + than retried here, since that already covers minutes of + attempts. Anything else (auth failure, an outright rejection) + is not retryable and is only logged; a bad command will not + start succeeding because it is repeated. + + Args: + description: Used in log messages and the retry's own + description. + call: Performs the command. Must be safe to call again if + handed to the background retry. + retry_key: Identifies this command for the background + retry, so a newer one supersedes an older one. + + Returns: + True if the command reached the charger, or is now being + retried in the background. False if it was not sent and + will not be retried. + + """ + try: + await call() + except ApiAuthError as err: + _LOGGER.warning("Auth error %s: %s", description, err) + return False + except ApiCommandRejectedError as err: + if err.code == COMMAND_ERROR_CODE_RPC_FAILURE: + self._coordinator.async_retry_in_background( + key=retry_key, + action=call, + description=description, + on_failure=lambda message: _LOGGER.warning( + "%s", message + ), + ) + return True + + _LOGGER.info("Charger refused %s: %s", description, err) + return False + except ApiError as err: + _LOGGER.warning("API error %s: %s", description, err) + return False + + return True + async def _carry_out(self, decision: SolarDecision, now: float) -> None: - """Issue the command a decision calls for.""" + """Issue the command a decision calls for. + + A command that fails outright is only logged; do not retry it + here (see _send_command). An attempt is still counted against + the hourly backstop regardless of outcome — otherwise a + command that keeps failing retries every tick and defeats the + one hard limit this feature has against a bug. + """ client = self._coordinator.api_client serial = self._coordinator.serial_number data = self._coordinator.data or {} + retry_key = f"{serial}:solar" _LOGGER.info( "Solar control: %s — %s", decision.action.value, decision.reason ) if decision.action is SolarAction.STOP: - await client.async_stop_charge(serial) - self._started_at = None + self._command_times.append(now) + if await self._send_command( + "stopping the charge", + lambda: client.async_stop_charge(serial), + retry_key, + ): + self._started_at = None elif decision.action is SolarAction.START: + self._command_times.append(now) + sent = True if decision.target_watts is not None: - await client.async_set_max_charging_current( - serial, watts_to_milliamps(decision.target_watts, data) + milliamps = watts_to_milliamps(decision.target_watts, data) + sent = await self._send_command( + f"setting the charging current to {milliamps} mA", + lambda: client.async_set_max_charging_current( + serial, milliamps + ), + retry_key, ) - await client.async_start_charge(serial) - self._started_at = now + + if sent and await self._send_command( + "starting the charge", + lambda: client.async_start_charge(serial), + f"{retry_key}:start", + ): + self._started_at = now elif decision.action is SolarAction.SET: if decision.target_watts is None: return - await client.async_set_max_charging_current( - serial, watts_to_milliamps(decision.target_watts, data) + + self._command_times.append(now) + milliamps = watts_to_milliamps(decision.target_watts, data) + await self._send_command( + f"setting the charging current to {milliamps} mA", + lambda: client.async_set_max_charging_current( + serial, milliamps + ), + retry_key, ) - self._command_times.append(now) self._coordinator.async_schedule_refresh_in(10) def _notify(self) -> None: diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 92c1a8c..8b8206f 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -21,8 +21,11 @@ class StubState: """A Home Assistant state object.""" - def __init__(self, state: str) -> None: + def __init__( + self, state: str, attributes: dict[str, Any] | None = None + ) -> None: self.state = state + self.attributes = attributes or {} class StubStates: @@ -31,9 +34,14 @@ class StubStates: def __init__(self) -> None: self._states: dict[str, StubState] = {} - def set(self, entity_id: str, value: str) -> None: + def set( + self, + entity_id: str, + value: str, + attributes: dict[str, Any] | None = None, + ) -> None: """Set a state.""" - self._states[entity_id] = StubState(value) + self._states[entity_id] = StubState(value, attributes) def get(self, entity_id: str) -> StubState | None: """Return a state, or None if unknown.""" @@ -47,6 +55,11 @@ def __init__(self) -> None: self.states = StubStates() +# Populated by the async_call_later stub below, and cleared by any test +# that needs to observe the timer lifecycle in isolation. +SCHEDULED: list[tuple[Any, Any]] = [] + + def _install_stubs() -> None: """Register the Home Assistant modules the controller imports.""" def _module(name: str, **attributes: Any) -> None: @@ -55,11 +68,15 @@ def _module(name: str, **attributes: Any) -> None: setattr(module, key, value) sys.modules[name] = module - scheduled: list[Any] = [] - def async_call_later(hass: Any, delay: Any, action: Any) -> Any: - scheduled.append((delay, action)) - return lambda: None + entry = (delay, action) + SCHEDULED.append(entry) + + def cancel() -> None: + if entry in SCHEDULED: + SCHEDULED.remove(entry) + + return cancel _module("homeassistant") _module("homeassistant.core", HomeAssistant=StubHass, callback=lambda fn: fn) @@ -100,6 +117,7 @@ def _load_package() -> None: solar = sys.modules["daze_solar_ctl.solar"] optimistic = sys.modules["daze_solar_ctl.optimistic"] controller_module = sys.modules["daze_solar_ctl.solar_controller"] +api_module = sys.modules["daze_solar_ctl.api"] class FakeApi: @@ -132,10 +150,21 @@ def __init__(self, data: dict[str, Any]) -> None: self.serial_number = "SER1" self.limit_state = optimistic.OptimisticState() self.refresh_delays: list[int] = [] + self.background_retries: list[tuple[str, str]] = [] def async_schedule_refresh_in(self, delay: int) -> None: self.refresh_delays.append(delay) + def async_retry_in_background( + self, + key: str, + action: Any, + description: str, + on_failure: Any = None, + ) -> None: + """Record a hand-off instead of actually retrying anything.""" + self.background_retries.append((key, description)) + CHARGING_DATA: dict[str, Any] = { "active": True, @@ -151,12 +180,32 @@ def async_schedule_refresh_in(self, delay: int) -> None: "chargeSession": {"sessionId": 1}, } +# A car plugged in but not drawing power: a session is open, but the +# charger has not been told to start. +NOT_CHARGING_DATA: dict[str, Any] = { + "active": True, + "lastAttributesUpdatedOn": None, + "evseStatus": "idle", + "evseState": 1, + "instantPowerAsWatt": 0, + "maxExternalChargingCurrentInMilliAmps": 13000, + "lastMaxInstallationCurrent": 32000, + "lastACVoltageL1": 230, + "ecoModeEnabled": False, + "schedules": [], + "chargeSession": {"sessionId": 1}, +} + def build(data: dict[str, Any] | None = None) -> tuple[Any, Any, Any]: """Build a controller wired to stubs.""" hass = StubHass() - hass.states.set("sensor.grid_import", "0") - hass.states.set("sensor.grid_export", "5000") + hass.states.set( + "sensor.grid_import", "0", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "5000", {"unit_of_measurement": "W"} + ) coordinator = FakeCoordinator(dict(data or CHARGING_DATA)) controller = controller_module.SolarController( @@ -217,7 +266,10 @@ def test_active_follows_surplus() -> None: controller, coordinator, _ = build() controller.mode = controller_module.SolarMode.ACTIVE - # Seed the smoother so the first tick has a usable average. + # The first tick already commands: the fixture is already charging + # and 8000 W clears the deadband against its ~2990 W limit + # immediately. The second tick checks that this holds — a repeated + # SET at the same surplus — not that the smoother needed seeding. asyncio.run(controller.async_tick()) asyncio.run(controller.async_tick()) @@ -225,7 +277,13 @@ def test_active_follows_surplus() -> None: def test_a_missing_sensor_stops_nothing() -> None: - """Absence of information is never grounds for acting.""" + """Absence of information is never grounds for acting. + + Asserts what was actually *observed*, not merely what was sent: + with this fixture's numbers, a sensor patched to read 0 instead of + failing lands inside the deadband and sends nothing either way, so + only checking `calls == []` cannot tell the two apart. + """ controller, coordinator, hass = build() controller.mode = controller_module.SolarMode.ACTIVE hass.states.set("sensor.grid_export", "unavailable") @@ -233,6 +291,8 @@ def test_a_missing_sensor_stops_nothing() -> None: asyncio.run(controller.async_tick()) assert coordinator.api_client.calls == [] + assert controller.surplus_w is None + assert controller.last_decision is None def test_a_pending_command_is_not_piled_on() -> None: @@ -247,6 +307,49 @@ def test_a_pending_command_is_not_piled_on() -> None: assert coordinator.api_client.calls == [] +def test_missing_car_draw_while_charging_skips_the_cycle() -> None: + """instantPowerAsWatt is missing whenever the active charge session + drops out of a single poll (see payload.merge_payload). Treating + that as zero misreads the car's own draw as surplus that vanished, + which can stop a car that is still charging — the exact failure + this project has already hit once. + """ + data = dict(CHARGING_DATA) + del data["instantPowerAsWatt"] + controller, coordinator, hass = build(data) + controller.mode = controller_module.SolarMode.ACTIVE + hass.states.set( + "sensor.grid_import", "0", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + + asyncio.run(controller.async_tick()) + + assert controller.surplus_w is None + assert controller.last_decision is None + assert coordinator.api_client.calls == [] + + +def test_car_draw_accepts_a_numeric_string() -> None: + """The API is not guaranteed to report this field as a number.""" + data = dict(CHARGING_DATA) + data["instantPowerAsWatt"] = "3000" + controller, _, hass = build(data) + controller.mode = controller_module.SolarMode.SIMULATE + hass.states.set( + "sensor.grid_import", "0", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + + asyncio.run(controller.async_tick()) + + assert controller.surplus_w == 3000 + + def test_the_reserve_lowers_the_target() -> None: """The house gets its share before the car does. @@ -291,6 +394,231 @@ def test_listeners_are_told_after_a_tick() -> None: assert seen +def test_a_kilowatt_sensor_is_converted_to_watts() -> None: + """4.0 kW exported is 4000 W, the same signal a W sensor would give.""" + controller, _, hass = build() + controller.mode = controller_module.SolarMode.SIMULATE + hass.states.set( + "sensor.grid_export", "4.0", {"unit_of_measurement": "kW"} + ) + hass.states.set( + "sensor.grid_import", "0", {"unit_of_measurement": "W"} + ) + + asyncio.run(controller.async_tick()) + + # 3000 W drawn (charging) plus 4000 W (4.0 kW) exported is 7000 W. + assert controller.surplus_w == 7000 + + +def test_an_unrecognised_unit_stops_nothing() -> None: + """A power sensor with no known unit is treated as unreadable. + + Misreading a kW sensor as watts would understate surplus by 1000x + and is silent and permanent for that installation, so a sensor + whose scale is unknown must not be acted on at all. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + hass.states.set( + "sensor.grid_export", "5000", {"unit_of_measurement": "lux"} + ) + + asyncio.run(controller.async_tick()) + + assert coordinator.api_client.calls == [] + assert controller.surplus_w is None + assert controller.last_decision is None + + +def test_a_blind_period_does_not_accrue_toward_stopping() -> None: + """Time the sensors could not be read must not count toward the + stop delay once they return. + + Without this, ten minutes spent unable to read the sensors reads + as ten minutes sustained below the floor the moment they recover, + and stops a car on the strength of a period nobody observed. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [2_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + # Already running long enough that the minimum-run gate is not + # what is blocking the stop this test is checking. + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + + # Below the floor: the car draws exactly what is imported. + hass.states.set( + "sensor.grid_import", "3000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_tick()) + + # Ten minutes pass with the export sensor unreadable. + clock[0] += 600 + hass.states.set("sensor.grid_export", "unavailable") + asyncio.run(controller.async_tick()) + + # It returns, still below the floor, an instant later. + clock[0] += 1 + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert coordinator.api_client.calls == [] + + +def test_start_waits_for_the_confirmation_delay_then_starts() -> None: + """The controller's own bookkeeping — not just decide() — must be + exercised: above-threshold timing and elapsed time have to be the + controller's real values, or this would start on the first tick or + never start at all. + """ + controller, coordinator, _ = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [3_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + asyncio.run(controller.async_tick()) + assert coordinator.api_client.calls == [] + + clock[0] += solar.START_DELAY_SECONDS + 1 + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert any(call[0] == "start" for call in coordinator.api_client.calls) + + +def test_commands_this_hour_is_tracked_and_enforced() -> None: + """If the hourly command count were not real bookkeeping, the rate + backstop in decide() could never engage.""" + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + now = controller_module.time.monotonic() + controller._command_times = [now] * solar.MAX_COMMANDS_PER_HOUR + + asyncio.run(controller.async_tick()) + + assert coordinator.api_client.calls == [] + + +def test_no_coordinator_data_reads_as_not_reachable() -> None: + """Before the first successful poll, an absent payload must not be + assumed reachable merely for lack of evidence otherwise. + + charger_offline_reason itself returns None for an empty payload + (no known reason to think it is offline), which without this + special case would make an unpolled charger look reachable by luck + rather than by design — safe only because a different guard, "no + car is connected", also happens to catch it. + """ + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + coordinator.data = {} + + asyncio.run(controller.async_tick()) + + assert coordinator.api_client.calls == [] + assert controller.last_decision is not None + assert "not reachable" in controller.last_decision.reason + + +def test_a_failed_command_is_handed_to_the_background_retry() -> None: + """A stuck link must not raise out of the tick, and must be handed + to the coordinator's own background retry rather than retried + here.""" + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + async def _rpc_failure( + serial: str, current_ma: int, attempts: int = 8 + ) -> dict: + raise api_module.ApiCommandRejectedError( + "unreachable", code=api_module.COMMAND_ERROR_CODE_RPC_FAILURE + ) + + coordinator.api_client.async_set_max_charging_current = _rpc_failure + + asyncio.run(controller.async_tick()) + + assert coordinator.background_retries != [] + + +def test_a_failed_command_still_counts_against_the_hourly_backstop() -> None: + """A command that keeps failing must still count as an attempt, or + it retries every tick and the hourly backstop never engages.""" + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + async def _rejected( + serial: str, current_ma: int, attempts: int = 8 + ) -> dict: + raise api_module.ApiCommandRejectedError("rejected", code=None) + + coordinator.api_client.async_set_max_charging_current = _rejected + + asyncio.run(controller.async_tick()) + + assert len(controller._command_times) == 1 + + +def test_async_start_schedules_and_async_stop_cancels() -> None: + """Teardown must actually cancel the pending timer, not merely stop + scheduling new ones from here on.""" + controller, _, _ = build() + SCHEDULED.clear() + + asyncio.run(controller.async_start()) + assert len(SCHEDULED) == 1 + + asyncio.run(controller.async_stop()) + assert SCHEDULED == [] + + +def test_async_stop_during_an_in_flight_tick_does_not_rearm() -> None: + """A tick already running has already cleared its own timer handle, + so async_stop() finds nothing to cancel — it must still prevent + the cycle re-arming itself once that tick finishes, or a stopped + controller keeps commanding hardware every tick with no handle left + to cancel it. + """ + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + SCHEDULED.clear() + + # Seed a reading so the in-flight tick actually reaches a command. + asyncio.run(controller.async_tick()) + + async def _stop_mid_command( + serial: str, current_ma: int, attempts: int = 8 + ) -> dict: + await controller.async_stop() + return {} + + coordinator.api_client.async_set_max_charging_current = _stop_mid_command + + controller._schedule_tick() + assert len(SCHEDULED) == 1 + _, action = SCHEDULED[0] + + asyncio.run(action(None)) + + # No second timer was armed by the tick that was stopped mid-flight. + assert len(SCHEDULED) == 1 + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From b4b7089b7354cf52843bb30b16f07e5b78f8f391 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 11:26:39 +0100 Subject: [PATCH 54/82] docs: strengthen two assertions that could not fail in the plan The Task 8 collapse test asserted only that surplus and the last decision were set, which a no-op fast path satisfies from the preceding healthy ticks. The Task 9 seeding test asserted only that a start mark existed, which seeding it to the present moment satisfies while leaving the charge unstoppable for ten minutes. Found by scanning the remaining briefs for the shape the Task 4 review caught in shipped code. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-29-solar-surplus-control.md | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md index ca4cdbc..4e801b9 100644 --- a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md +++ b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md @@ -2246,9 +2246,17 @@ def test_a_collapse_is_evaluated_without_waiting_for_the_tick() -> None: hass.states.set("sensor.grid_export", "0") hass.states.set("sensor.grid_import", "4000") + before = controller.surplus_w + assert before is not None, "the healthy ticks should have left a figure" + asyncio.run(controller.async_sensor_changed()) + # Compare against the pre-collapse figure rather than asserting these + # are merely set. A fast path that did nothing at all would leave both + # holding their values from the three healthy ticks, so "is not None" + # passes under exactly the regression this test exists to catch. assert controller.surplus_w is not None + assert controller.surplus_w < before, "the collapse was not evaluated" assert controller.last_decision is not None @@ -2408,7 +2416,9 @@ without stopping a healthy charge, and remembering the mode. - [ ] **Step 1: Write the failing tests** -Append to `tests/test_solar_controller.py`, before `_main`: +Append to `tests/test_solar_controller.py`, before `_main`. Add +`import time` to the file's imports if it is not already there — the +seeding test below reads `time.monotonic()`: ```python def test_three_phase_supply_with_a_single_phase_charger_is_refused() -> None: @@ -2441,7 +2451,16 @@ def test_a_charge_already_running_counts_as_having_run() -> None: asyncio.run(controller.async_tick()) + # Assert how far back the mark was seeded, not merely that one exists. + # Seeding it to the present moment would satisfy "is not None" while + # leaving the charge unstoppable for the next ten minutes, which is the + # bug this seeding exists to prevent. assert controller._started_at is not None + elapsed = time.monotonic() - controller._started_at + assert elapsed >= solar.MIN_RUN_SECONDS, ( + "a charge already running must count as having served its minimum " + f"run time, but the mark was seeded only {elapsed:.0f}s back" + ) ``` - [ ] **Step 2: Run the tests to verify they fail** From 4b82fd4315adcc1ef9074168cca7e4f1d08838e7 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 11:33:56 +0100 Subject: [PATCH 55/82] docs: express downstream test expectations as deltas, not totals Task 4's review added twelve tests to tests/test_solar_controller.py, so the absolute counts written into Tasks 5, 8 and 9 no longer matched. State the expected increase instead, which survives the next round of additions. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-29-solar-surplus-control.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md index 4e801b9..d56ce99 100644 --- a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md +++ b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md @@ -1739,7 +1739,9 @@ And call it in `async_tick`, immediately after `self._track_thresholds(state, no - [ ] **Step 4: Run the tests to verify they pass** Run: `python3 tests/test_solar_controller.py` -Expected: PASS, `9 passed, 0 failed` +Expected: PASS, `0 failed`, with one more test than the suite had before +this task. The absolute count is deliberately not stated: Task 4's review +added twelve tests to this file, so any figure written here ages badly. - [ ] **Step 5: Lint and full suite** @@ -2370,7 +2372,8 @@ to the `homeassistant.helpers.event` stub in `tests/test_solar_controller.py`. - [ ] **Step 4: Run the tests to verify they pass** Run: `python3 tests/test_solar_controller.py` -Expected: PASS, `11 passed, 0 failed` +Expected: PASS, `0 failed`, with two more tests than the suite had before +this task. The absolute count is deliberately not stated; see Task 5. - [ ] **Step 5: Lint and full suite** @@ -2561,7 +2564,9 @@ Also make `available` account for an unsupported setup: - [ ] **Step 5: Run the tests to verify they pass** Run: `python3 tests/test_solar_controller.py` -Expected: PASS, `14 passed, 0 failed` +Expected: PASS, `0 failed`, with three more tests than the suite had +before this task. The absolute count is deliberately not stated; see +Task 5. - [ ] **Step 6: Lint and full suite** From 1ccb145b3bbb0b8ad8d284677d013d640f891a5b Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 11:41:41 +0100 Subject: [PATCH 56/82] fix: address five follow-on findings from the I1 error handling N2 - A START proceeded even when the current-set was only queued for the background retry, since _send_command collapsed "sent" and "queued" to the same True. The car would then start at whatever limit it already had and import from the grid until the retry landed - exactly what pure-solar mode exists to prevent. _send_command now returns True (sent), None (queued), or False (failed outright), and START only proceeds past the current-set when it is True. N3 - Solar's retry key (f"{serial}:solar") did not match number.py's (f"{serial}:current") or switch.py's (f"{serial}:charge"), so a manual override did not supersede a queued solar command - it could land minutes later and silently undo the override. Chose to share the keys rather than cancel in async_stop(): the coordinator's own async_retry_in_background already cancels by key before scheduling, and both manual entities already cancel their own key after a successful direct send, so sharing gives supersession in both directions with no new code. Current-setting commands (SET, and the current half of START) now file under f"{serial}:current"; start/stop (STOP, and the second half of START) file under f"{serial}:charge". N1 - test_a_missing_sensor_stops_nothing lost its unit attribute on override, since StubStates.set replaced the whole state. It was passing by reaching the new unit guard rather than the value-parse path it names. StubStates.set now carries the previous attributes forward when none are given, matching real Home Assistant, and the call itself now passes the unit explicitly too. N4 (minor) - An unknown charging status (an absent or partial payload) was treated the same as a confirmed "not charging", handing _car_draw_w a false zero that then pollutes the five-minute average. Only an explicit is_charge_enabled(data) is False now yields zero. N5 (minor) - No total timeout is configured on the session, so aiohttp's default eventually raises a bare asyncio.TimeoutError, outside the API's own exception hierarchy. _send_command now catches it alongside the three Api* exceptions. N6 (minor) - A START issues two real API calls (current-set, start-charge) but counted one attempt, undercounting the hourly backstop at half the real rate for a start-heavy failure. Each call now counts on its own. Every fix was verified by reverting it in isolation, confirming the named test failed, and restoring - including reproducing the reviewer's own repro for N1 (parse failure mutated to yield 0.0, with the unit preserved, now fails as it should). Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/solar_controller.py | 104 ++++++++--- tests/test_solar_controller.py | 198 +++++++++++++++++++-- 2 files changed, 264 insertions(+), 38 deletions(-) diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index 0fd1669..afe8b31 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -13,6 +13,7 @@ from __future__ import annotations +import asyncio import logging import time from collections.abc import Callable @@ -329,11 +330,18 @@ def _car_draw_w(data: dict[str, Any]) -> float | None: Reading that as zero while the charger is actually charging misreads the car's own draw as surplus that vanished, which is the exact failure this project has already hit once. + + Checked against ``is False`` rather than truthiness: an + *unknown* status (an absent or partial payload, where + is_charge_enabled returns None) is not evidence the car draws + nothing either, and reading it as zero pollutes the five-minute + average with an assumed reading that survives long after the + payload that produced it is gone. """ value = _coerce_float(data.get("instantPowerAsWatt")) if value is not None: return value - return None if is_charge_enabled(data) else 0.0 + return 0.0 if is_charge_enabled(data) is False else None def _read_surplus(self) -> float | None: """Compute surplus from the grid sensors and the car's draw.""" @@ -417,16 +425,21 @@ def _track_thresholds(self, state: SolarState, now: float) -> None: async def _send_command( self, description: str, call: Callable[[], Any], retry_key: str - ) -> bool: + ) -> bool | None: """Issue one command, handing a stuck link to the background retry. Mirrors the handling in number.py: an RPC failure — the Daze service could not reach the wallbox over its own link — is handed to the coordinator's existing background retry rather than retried here, since that already covers minutes of - attempts. Anything else (auth failure, an outright rejection) - is not retryable and is only logged; a bad command will not - start succeeding because it is repeated. + attempts. Anything else (auth failure, an outright rejection, + a bare timeout outside the API's own exception hierarchy — no + total timeout is configured on the session, so aiohttp's + default eventually raises one) is not retryable and is only + logged; a bad command will not start succeeding because it is + repeated, and autonomous code needs a wider net than a service + call a human is watching, or this becomes an unhandled task + exception in the event loop every two minutes. Args: description: Used in log messages and the retry's own @@ -434,12 +447,18 @@ async def _send_command( call: Performs the command. Must be safe to call again if handed to the background retry. retry_key: Identifies this command for the background - retry, so a newer one supersedes an older one. + retry, so a newer one supersedes an older one, and + shared with whatever manual entity can act on the same + physical setting so the two supersede each other too. Returns: - True if the command reached the charger, or is now being - retried in the background. False if it was not sent and - will not be retried. + True if the command reached the charger. None if it was + only handed to the background retry — accepted for now, + but not yet confirmed, so a caller that must not proceed + until the charger has actually applied the change (see + the START branch of _carry_out) has to treat this the same + as failure. False if it was not sent and will not be + retried. """ try: @@ -457,13 +476,16 @@ async def _send_command( "%s", message ), ) - return True + return None _LOGGER.info("Charger refused %s: %s", description, err) return False except ApiError as err: _LOGGER.warning("API error %s: %s", description, err) return False + except asyncio.TimeoutError as err: + _LOGGER.warning("Timed out %s: %s", description, err) + return False return True @@ -471,15 +493,28 @@ async def _carry_out(self, decision: SolarDecision, now: float) -> None: """Issue the command a decision calls for. A command that fails outright is only logged; do not retry it - here (see _send_command). An attempt is still counted against - the hourly backstop regardless of outcome — otherwise a - command that keeps failing retries every tick and defeats the - one hard limit this feature has against a bug. + here (see _send_command). Each individual attempt is counted + against the hourly backstop regardless of outcome, and + separately per command — a START issues both a current-set and + a start-charge, and counting the branch once rather than each + call would let a start-heavy failure mode burn the real API at + twice the rate the backstop assumes. + + Retry keys are shared with whatever manual entity can act on + the same physical setting: f"{serial}:current" with number.py's + current and power entities, f"{serial}:charge" with switch.py's + start/stop switch. That gives supersession for free in both + directions through the coordinator's own machinery — a newer + retry cancels an older one under the same key on the way in, + and each entity already cancels its own key on a successful + direct send — rather than a manual override leaving a queued + solar command to land minutes later and undo it. """ client = self._coordinator.api_client serial = self._coordinator.serial_number data = self._coordinator.data or {} - retry_key = f"{serial}:solar" + current_key = f"{serial}:current" + charge_key = f"{serial}:charge" _LOGGER.info( "Solar control: %s — %s", decision.action.value, decision.reason @@ -487,32 +522,45 @@ async def _carry_out(self, decision: SolarDecision, now: float) -> None: if decision.action is SolarAction.STOP: self._command_times.append(now) - if await self._send_command( - "stopping the charge", - lambda: client.async_stop_charge(serial), - retry_key, + if ( + await self._send_command( + "stopping the charge", + lambda: client.async_stop_charge(serial), + charge_key, + ) + is not False ): self._started_at = None elif decision.action is SolarAction.START: - self._command_times.append(now) sent = True if decision.target_watts is not None: milliamps = watts_to_milliamps(decision.target_watts, data) + self._command_times.append(now) sent = await self._send_command( f"setting the charging current to {milliamps} mA", lambda: client.async_set_max_charging_current( serial, milliamps ), - retry_key, + current_key, ) - if sent and await self._send_command( - "starting the charge", - lambda: client.async_start_charge(serial), - f"{retry_key}:start", - ): - self._started_at = now + # A limit only queued for the background retry has not + # reached the charger yet. Starting anyway would run the + # car at whatever limit it already had — importing from + # the grid, the one outcome pure-solar mode exists to + # prevent — so "queued" is not treated as "sent" here. + if sent is True: + self._command_times.append(now) + if ( + await self._send_command( + "starting the charge", + lambda: client.async_start_charge(serial), + charge_key, + ) + is not False + ): + self._started_at = now elif decision.action is SolarAction.SET: if decision.target_watts is None: @@ -525,7 +573,7 @@ async def _carry_out(self, decision: SolarDecision, now: float) -> None: lambda: client.async_set_max_charging_current( serial, milliamps ), - retry_key, + current_key, ) self._coordinator.async_schedule_refresh_in(10) diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 8b8206f..85fe6b4 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -40,7 +40,19 @@ def set( value: str, attributes: dict[str, Any] | None = None, ) -> None: - """Set a state.""" + """Set a state. + + Carries the previous attributes forward when none are given, + matching real Home Assistant: Entity.__async_calculate_state + sets unit_of_measurement outside the availability branch, so + an entity going unavailable does not drop its own unit. A stub + that dropped it on every call let a test believe it was + checking an unparseable value when it was actually exercising + the unit guard instead. + """ + if attributes is None: + previous = self._states.get(entity_id) + attributes = dict(previous.attributes) if previous else {} self._states[entity_id] = StubState(value, attributes) def get(self, entity_id: str) -> StubState | None: @@ -286,7 +298,9 @@ def test_a_missing_sensor_stops_nothing() -> None: """ controller, coordinator, hass = build() controller.mode = controller_module.SolarMode.ACTIVE - hass.states.set("sensor.grid_export", "unavailable") + hass.states.set( + "sensor.grid_export", "unavailable", {"unit_of_measurement": "W"} + ) asyncio.run(controller.async_tick()) @@ -520,19 +534,25 @@ def test_no_coordinator_data_reads_as_not_reachable() -> None: charger_offline_reason itself returns None for an empty payload (no known reason to think it is offline), which without this - special case would make an unpolled charger look reachable by luck - rather than by design — safe only because a different guard, "no - car is connected", also happens to catch it. + special case would make an unpolled charger look reachable by + luck rather than by design. + + Exercises _build_state directly rather than through a full tick: + with an entirely empty payload, _car_draw_w's own guard (an + unknown charging status is not evidence of zero draw) now makes + async_tick exit even earlier, at the car-draw check, so decide() + is never reached from a live tick for this exact case any more. + The charger_reachable guard this test protects is still correct + and still reachable if that earlier guard is ever relaxed, so it + is checked at its own layer instead of one that can no longer + reach it. """ controller, coordinator, _ = build() - controller.mode = controller_module.SolarMode.ACTIVE coordinator.data = {} - asyncio.run(controller.async_tick()) + state = controller._build_state(5000.0, controller_module.time.monotonic()) - assert coordinator.api_client.calls == [] - assert controller.last_decision is not None - assert "not reachable" in controller.last_decision.reason + assert state.charger_reachable is False def test_a_failed_command_is_handed_to_the_background_retry() -> None: @@ -574,6 +594,164 @@ async def _rejected( assert len(controller._command_times) == 1 +def test_a_queued_current_does_not_start_the_car_at_the_old_limit() -> None: + """A current-set only queued for the background retry has not + reached the charger yet. Starting anyway would run the car at + whatever limit it already had — importing from the grid, the one + outcome pure-solar mode exists to prevent — so "queued" must not + be read as "sent". + """ + controller, coordinator, _ = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [4_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + asyncio.run(controller.async_tick()) # waiting to confirm + assert coordinator.api_client.calls == [] + + clock[0] += solar.START_DELAY_SECONDS + 1 + + async def _rpc_failure( + serial: str, current_ma: int, attempts: int = 8 + ) -> dict: + raise api_module.ApiCommandRejectedError( + "unreachable", code=api_module.COMMAND_ERROR_CODE_RPC_FAILURE + ) + + coordinator.api_client.async_set_max_charging_current = _rpc_failure + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert coordinator.background_retries != [] + assert not any(call[0] == "start" for call in coordinator.api_client.calls) + + +def test_solar_current_retries_share_the_manual_entities_key() -> None: + """number.py cancels a background retry by f"{serial}:current" + after a successful manual set (see number.py:234, 252). Solar's + own current-setting retries must be filed under that same key, or + a manual override does not supersede a queued solar command — it + can land minutes later and silently undo the override. + """ + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + async def _rpc_failure( + serial: str, current_ma: int, attempts: int = 8 + ) -> dict: + raise api_module.ApiCommandRejectedError( + "unreachable", code=api_module.COMMAND_ERROR_CODE_RPC_FAILURE + ) + + coordinator.api_client.async_set_max_charging_current = _rpc_failure + + asyncio.run(controller.async_tick()) + + assert coordinator.background_retries != [] + key, _ = coordinator.background_retries[-1] + assert key == f"{coordinator.serial_number}:current" + + +def test_solar_charge_retries_share_the_manual_switch_key() -> None: + """Mirrors the current-setting case for start and stop: switch.py + cancels its own manual toggle's retry under f"{serial}:charge", so + a queued solar STOP must be filed there too. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [5_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + hass.states.set( + "sensor.grid_import", "3000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_tick()) # starts the below-floor timer + + clock[0] += solar.STOP_DELAY_SECONDS + 1 + + async def _rpc_failure(serial: str, attempts: int = 8) -> dict: + raise api_module.ApiCommandRejectedError( + "unreachable", code=api_module.COMMAND_ERROR_CODE_RPC_FAILURE + ) + + coordinator.api_client.async_stop_charge = _rpc_failure + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert coordinator.background_retries != [] + key, _ = coordinator.background_retries[-1] + assert key == f"{coordinator.serial_number}:charge" + + +def test_unknown_charging_status_does_not_assume_zero_draw() -> None: + """An unknown status is not evidence the car draws nothing; only a + confirmed not-charging status is. Otherwise an assumed zero draw, + computed from a payload that may be gone a moment later, still + feeds the five-minute average for the ticks that follow it. + """ + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.SIMULATE + coordinator.data = {} + + asyncio.run(controller.async_tick()) + + assert controller.surplus_w is None + + +def test_a_timeout_does_not_raise_out_of_the_tick() -> None: + """No total timeout is configured on the session, so aiohttp's own + default eventually raises a bare TimeoutError, outside the API's + exception hierarchy. Autonomous code needs a wider net than a + service call a human is waiting on, or this is an unhandled task + exception in the event loop every two minutes. + """ + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + async def _timeout( + serial: str, current_ma: int, attempts: int = 8 + ) -> dict: + raise asyncio.TimeoutError() + + coordinator.api_client.async_set_max_charging_current = _timeout + + asyncio.run(controller.async_tick()) + + assert len(controller._command_times) == 1 + + +def test_a_start_counts_two_attempts_not_one() -> None: + """A START issues both a current-set and a start-charge — two real + API calls — and each must count on its own, or a start-heavy + failure mode burns the hourly budget at half the real rate. + """ + controller, coordinator, _ = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [6_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + asyncio.run(controller.async_tick()) # waiting to confirm + clock[0] += solar.START_DELAY_SECONDS + 1 + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert len(coordinator.api_client.calls) == 2 + assert len(controller._command_times) == 2 + + def test_async_start_schedules_and_async_stop_cancels() -> None: """Teardown must actually cancel the pending timer, not merely stop scheduling new ones from here on.""" From a5c9170d72c1bf2dfd56495638d472a3c9abcaf0 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 11:53:52 +0100 Subject: [PATCH 57/82] feat: back off when a started car does not draw, cancel retries on success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A car that has finished stops drawing while surplus is still high, so the charger goes idle, the controller sees plenty of surplus and no charge, and starts again. The cycle repeats until sunset. The rate limit would blunt this but is the wrong instrument: it is a backstop against bugs, not a substitute for handling a state the design knows about. The draw check goes through _car_draw_w rather than the raw instantPowerAsWatt field, so an unknown draw (missing or unparseable mid-session) waits rather than being misread as "not drawing" and arming an hour-long back-off on a car that may be charging fine. Folded in: _carry_out now cancels the shared background-retry key after a successful direct send, on every branch (STOP, both of START's calls, SET) — mirroring the pattern already used by select.py, switch.py and number.py. Without it, a stale queued retry from an earlier RPC failure can land after a later, successful command and silently undo it. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/solar_controller.py | 92 ++++++++++++---- tests/test_solar_controller.py | 122 +++++++++++++++++++++ 2 files changed, 195 insertions(+), 19 deletions(-) diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index afe8b31..1117d8c 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -38,6 +38,9 @@ watts_to_milliamps, ) from .solar import ( + DRAW_GRACE_SECONDS, + IGNORED_START_BACKOFF_SECONDS, + MIN_MEANINGFUL_DRAW_W, TICK_SECONDS, SolarAction, SolarDecision, @@ -253,6 +256,7 @@ async def async_tick(self) -> None: state = self._build_state(smoothed, now) self._track_thresholds(state, now) + self._check_ignored_start(now) decision = decide(self._build_state(smoothed, now)) self._last_decision = decision @@ -423,6 +427,44 @@ def _track_thresholds(self, state: SolarState, now: float) -> None: if self._below_since is None: self._below_since = now + def _check_ignored_start(self, now: float) -> None: + """Back off if a started car never began drawing. + + When a car finishes it stops drawing while surplus is still + high. The charger goes idle, the controller sees "not charging, + plenty of surplus", and starts again. Without this the cycle + repeats until sunset. + + Draw is read through ``_car_draw_w`` rather than the raw + ``instantPowerAsWatt`` field, and its None is treated as "wait + and see", not "not drawing": a missing or unparseable reading + while the charger is mid-session says nothing about the car, + and backing off on that would arm an hour-long pause on a + car that may already be drawing fine. + """ + if self._started_at is None: + return + + if now - self._started_at < DRAW_GRACE_SECONDS: + return + + data = self._coordinator.data or {} + draw = self._car_draw_w(data) + if draw is None: + return + + if draw >= MIN_MEANINGFUL_DRAW_W: + return + + self._backoff_until = now + IGNORED_START_BACKOFF_SECONDS + self._started_at = None + _LOGGER.info( + "The car did not draw within %ds of starting; backing off for " + "%d minutes", + DRAW_GRACE_SECONDS, + IGNORED_START_BACKOFF_SECONDS // 60, + ) + async def _send_command( self, description: str, call: Callable[[], Any], retry_key: str ) -> bool | None: @@ -506,9 +548,13 @@ async def _carry_out(self, decision: SolarDecision, now: float) -> None: start/stop switch. That gives supersession for free in both directions through the coordinator's own machinery — a newer retry cancels an older one under the same key on the way in, - and each entity already cancels its own key on a successful - direct send — rather than a manual override leaving a queued - solar command to land minutes later and undo it. + and every command-issuing module, this one included, cancels + its own key on a successful direct send — rather than a stale + queued retry landing minutes later and undoing whichever side + acted more recently. A send only queued for the background + retry (None, not True) is not cancelled: it has not reached the + charger yet, so the retry it would cancel is the only thing + still trying to get the change applied. """ client = self._coordinator.api_client serial = self._coordinator.serial_number @@ -522,14 +568,14 @@ async def _carry_out(self, decision: SolarDecision, now: float) -> None: if decision.action is SolarAction.STOP: self._command_times.append(now) - if ( - await self._send_command( - "stopping the charge", - lambda: client.async_stop_charge(serial), - charge_key, - ) - is not False - ): + stop_sent = await self._send_command( + "stopping the charge", + lambda: client.async_stop_charge(serial), + charge_key, + ) + if stop_sent is True: + self._coordinator.async_cancel_background_retry(charge_key) + if stop_sent is not False: self._started_at = None elif decision.action is SolarAction.START: @@ -544,6 +590,10 @@ async def _carry_out(self, decision: SolarDecision, now: float) -> None: ), current_key, ) + if sent is True: + self._coordinator.async_cancel_background_retry( + current_key + ) # A limit only queued for the background retry has not # reached the charger yet. Starting anyway would run the @@ -552,14 +602,16 @@ async def _carry_out(self, decision: SolarDecision, now: float) -> None: # prevent — so "queued" is not treated as "sent" here. if sent is True: self._command_times.append(now) - if ( - await self._send_command( - "starting the charge", - lambda: client.async_start_charge(serial), - charge_key, + start_sent = await self._send_command( + "starting the charge", + lambda: client.async_start_charge(serial), + charge_key, + ) + if start_sent is True: + self._coordinator.async_cancel_background_retry( + charge_key ) - is not False - ): + if start_sent is not False: self._started_at = now elif decision.action is SolarAction.SET: @@ -568,13 +620,15 @@ async def _carry_out(self, decision: SolarDecision, now: float) -> None: self._command_times.append(now) milliamps = watts_to_milliamps(decision.target_watts, data) - await self._send_command( + set_sent = await self._send_command( f"setting the charging current to {milliamps} mA", lambda: client.async_set_max_charging_current( serial, milliamps ), current_key, ) + if set_sent is True: + self._coordinator.async_cancel_background_retry(current_key) self._coordinator.async_schedule_refresh_in(10) diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 85fe6b4..cbd27eb 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -163,6 +163,7 @@ def __init__(self, data: dict[str, Any]) -> None: self.limit_state = optimistic.OptimisticState() self.refresh_delays: list[int] = [] self.background_retries: list[tuple[str, str]] = [] + self.cancelled_retries: list[str] = [] def async_schedule_refresh_in(self, delay: int) -> None: self.refresh_delays.append(delay) @@ -177,6 +178,10 @@ def async_retry_in_background( """Record a hand-off instead of actually retrying anything.""" self.background_retries.append((key, description)) + def async_cancel_background_retry(self, key: str) -> None: + """Record a cancellation instead of actually dropping one.""" + self.cancelled_retries.append(key) + CHARGING_DATA: dict[str, Any] = { "active": True, @@ -797,6 +802,123 @@ async def _stop_mid_command( assert len(SCHEDULED) == 1 +def test_a_car_that_ignores_a_start_triggers_a_backoff() -> None: + """A finished car stops drawing while surplus is still high, so a + naive controller restarts it until sunset.""" + data = dict(CHARGING_DATA) + data["evseStatus"] = "idle" + data["instantPowerAsWatt"] = 0 + controller, _, _ = build(data) + controller.mode = controller_module.SolarMode.ACTIVE + + # Pretend a start was issued a while ago and the car never drew. + controller._started_at = 0.0 + + asyncio.run(controller.async_tick()) + + assert controller._backoff_until > 0, "no back-off was armed" + + +def test_an_unknown_draw_does_not_trigger_a_backoff() -> None: + """A missing or unparseable instantPowerAsWatt while the charger is + mid-session is not evidence the car stopped drawing — it is + evidence the payload dropped out, which _car_draw_w already treats + as unknown. Backing off on that would arm an hour-long pause on a + car that may be drawing fine. + + Called directly rather than through async_tick: a tick with an + unknown car draw already bails out earlier, at _read_surplus, so + the backoff check's own None-handling would otherwise never be + exercised at all. + """ + data = dict(CHARGING_DATA) + del data["instantPowerAsWatt"] + controller, _, _ = build(data) + controller.mode = controller_module.SolarMode.SIMULATE + controller._started_at = 0.0 + + controller._check_ignored_start(controller_module.time.monotonic()) + + assert controller._backoff_until == 0.0 + + +def test_a_successful_set_cancels_its_background_retry() -> None: + """number.py cancels its own retry key after a successful manual + set (number.py:233-235). Solar's direct sends share that same key + and must do the same, or a retry queued from an earlier failure + can land after a later, successful command and overwrite it. + """ + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + asyncio.run(controller.async_tick()) + + assert any(call[0] == "current" for call in coordinator.api_client.calls) + assert ( + f"{coordinator.serial_number}:current" in coordinator.cancelled_retries + ) + + +def test_a_successful_start_cancels_both_background_retries() -> None: + """A START issues both a current-set and a start-charge; a + successful direct send of either must cancel that key's own queued + retry, the same as number.py and switch.py already do for their + own manual commands. + """ + controller, coordinator, _ = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [7_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + asyncio.run(controller.async_tick()) # waiting to confirm + clock[0] += solar.START_DELAY_SECONDS + 1 + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert any(call[0] == "start" for call in coordinator.api_client.calls) + assert ( + f"{coordinator.serial_number}:current" in coordinator.cancelled_retries + ) + assert ( + f"{coordinator.serial_number}:charge" in coordinator.cancelled_retries + ) + + +def test_a_successful_stop_cancels_its_background_retry() -> None: + """Mirrors switch.py's own cancel after a successful manual stop + (switch.py:218-220): a solar-issued STOP that reaches the charger + must cancel any retry still queued under the same charge key. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [8_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + hass.states.set( + "sensor.grid_import", "3000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_tick()) # starts the below-floor timer + + clock[0] += solar.STOP_DELAY_SECONDS + 1 + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert any(call[0] == "stop" for call in coordinator.api_client.calls) + assert ( + f"{coordinator.serial_number}:charge" in coordinator.cancelled_retries + ) + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 19db54fc7cce330323d0348df181bb39259a661b Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 12:00:51 +0100 Subject: [PATCH 58/82] docs: keep Task 9's seeding off the draw-grace clock Seeding the start mark to now - MIN_RUN_SECONDS puts it 600s in the past, already beyond the 300s draw grace. With a single shared attribute the first tick after a restart would evaluate the car's draw immediately, and a charger in waiting_for_ev at 0 W would arm an hour-long back-off on a healthy charge at every boot. Task 5's fix round splits the two clocks; this records which one the seeding is for. Co-Authored-By: Claude Opus 5 --- docs/superpowers/plans/2026-09-29-solar-surplus-control.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md index d56ce99..c8f3592 100644 --- a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md +++ b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md @@ -2516,6 +2516,13 @@ And seed the start time, immediately before `self._check_ignored_start(now)`: # already running has, by definition, been running: without # this the minimum run time reads as unelapsed and a healthy # charge could be stopped moments after boot. + # + # This seeds the minimum-run clock only. The draw-grace clock + # is a separate attribute, set solely when this controller + # issues a start of its own, and it must stay unset here: a + # charge that was already running was never ours to judge, and + # a charger sitting in waiting_for_ev at 0 W at boot would + # otherwise arm an hour-long back-off on a healthy charge. if state.charging and self._started_at is None: self._started_at = now - MIN_RUN_SECONDS ``` From eca7e60a15eed0848d6a04e8146e06da6bca5b3a Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 12:08:58 +0100 Subject: [PATCH 59/82] fix: split the draw-grace clock from the minimum-run clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 review of Task 5 found the back-off never actually pre-empted the rate limit: decide() has no memory of already having started, so while the charger sat idle with surplus sustained it returned START on every tick, and _started_at was reset on every one of those, so the 300s draw-grace window never elapsed. The rate limit — a backstop against bugs, not a substitute for this — ended up doing the back-off's job at ten times the cost (C1). Separately, the start-charge send accepted a queued (None) result as "started" when only the current-set half required a direct True, so a start that had not reached the charger could still arm an hour-long back-off while its own retry chain was still trying to land (I1). Fixing both by reusing the existing _started_at would have re-created the exact collision Task 9 is about to introduce: it seeds _started_at from a charge already running at boot, and that charge passes through a driver-not-drawing state on nearly every start regardless of who issued it. Splitting the concept into two attributes removes the collision instead of documenting around it: - _started_at keeps its existing meaning, the minimum-run clock decide() reads via seconds_since_start, and stays agnostic to who started the charge (Task 9 will seed it from observed state). - _start_issued_at is new: "we issued a start and are waiting to see whether the car draws." Set only when a start _carry_out itself sent reached the charger (True, not None) and only when not already outstanding, cleared when the car is confirmed drawing, when the back-off arms, and when a stop is carried out (so a stale mark can't anchor the next start's grace clock to the wrong start). _check_ignored_start now reads _start_issued_at exclusively. Also: gate the check to ACTIVE mode (M3). A start only simulated never reached the charger, so a dry run must not arm a real back-off from a start that never happened — harmless today, but Task 9's seeding would otherwise let SIMULATE spend an hour previewing nothing. And: a SET that only got queued for the background retry must not cancel that same retry (I3) — _send_command's None means the retry is the only thing still trying to apply the change. Six tests added, each confirmed to fail against the exact regression it targets before being restored (see task-5-report.md for the log). Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/solar_controller.py | 63 +++++++- tests/test_solar_controller.py | 162 ++++++++++++++++++++- 2 files changed, 218 insertions(+), 7 deletions(-) diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index 1117d8c..1acca25 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -132,7 +132,21 @@ def __init__( self._above_since: float | None = None self._below_since: float | None = None + # The minimum-run clock: how long ago the current charge + # began, consumed by decide() via seconds_since_start. Not + # necessarily a start this controller issued — Task 9 seeds + # this from a charge already running when the controller + # starts, so a healthy charge is not stopped moments after + # boot. Because of that, this must stay agnostic to who or + # what started the charge; _check_ignored_start reads + # _start_issued_at instead, never this one. self._started_at: float | None = None + # "We issued a start and are waiting to see whether the car + # draws." Set only in _carry_out, only when a start genuinely + # reached the charger, so a charge this controller did not + # itself start (seeded at boot, or started manually) is never + # judged against a start that never happened. + self._start_issued_at: float | None = None self._backoff_until: float = 0.0 self._command_times: list[float] = [] self._sensor_warning_logged = False @@ -256,7 +270,13 @@ async def async_tick(self) -> None: state = self._build_state(smoothed, now) self._track_thresholds(state, now) - self._check_ignored_start(now) + + # A start only simulated never reached the charger, so the + # car was never given the chance to draw. Checking anyway + # would let a dry run arm a real hour-long back-off from a + # start that never happened. + if self._mode is SolarMode.ACTIVE: + self._check_ignored_start(now) decision = decide(self._build_state(smoothed, now)) self._last_decision = decision @@ -428,13 +448,24 @@ def _track_thresholds(self, state: SolarState, now: float) -> None: self._below_since = now def _check_ignored_start(self, now: float) -> None: - """Back off if a started car never began drawing. + """Back off if a car we started never began drawing. When a car finishes it stops drawing while surplus is still high. The charger goes idle, the controller sees "not charging, plenty of surplus", and starts again. Without this the cycle repeats until sunset. + Reads ``_start_issued_at``, never ``_started_at``: the latter + is the minimum-run clock ``decide()`` consumes, and can be + seeded from a charge the controller did not itself start (a + charge already running when Home Assistant restarts — see + Task 9). Judging that against a start that never happened + would arm an hour-long back-off on a perfectly healthy charge + the moment it passes through the wait-for-EV state every + start goes through. Only a start this method's own caller + actually issued, and that reached the charger, sets + ``_start_issued_at`` in the first place. + Draw is read through ``_car_draw_w`` rather than the raw ``instantPowerAsWatt`` field, and its None is treated as "wait and see", not "not drawing": a missing or unparseable reading @@ -442,10 +473,10 @@ def _check_ignored_start(self, now: float) -> None: and backing off on that would arm an hour-long pause on a car that may already be drawing fine. """ - if self._started_at is None: + if self._start_issued_at is None: return - if now - self._started_at < DRAW_GRACE_SECONDS: + if now - self._start_issued_at < DRAW_GRACE_SECONDS: return data = self._coordinator.data or {} @@ -454,10 +485,12 @@ def _check_ignored_start(self, now: float) -> None: return if draw >= MIN_MEANINGFUL_DRAW_W: + # Confirmed drawing: the "waiting to see" period is over. + self._start_issued_at = None return self._backoff_until = now + IGNORED_START_BACKOFF_SECONDS - self._started_at = None + self._start_issued_at = None _LOGGER.info( "The car did not draw within %ds of starting; backing off for " "%d minutes", @@ -577,6 +610,11 @@ async def _carry_out(self, decision: SolarDecision, now: float) -> None: self._coordinator.async_cancel_background_retry(charge_key) if stop_sent is not False: self._started_at = None + # A stopped charge is no longer waiting to see if the + # car draws. Left set, a stale mark here would anchor + # the *next* start's draw-grace clock to this one's + # issue time instead of its own. + self._start_issued_at = None elif decision.action is SolarAction.START: sent = True @@ -614,6 +652,21 @@ async def _carry_out(self, decision: SolarDecision, now: float) -> None: if start_sent is not False: self._started_at = now + # The draw-grace clock, unlike the minimum-run clock + # just above, must not restart on every repeat START: + # decide() has no memory of already having started, so + # while the charger sits idle with surplus sustained + # it returns START on every tick. Resetting this on + # each one would mean the 300 s grace never elapses, + # and the car would be restarted, uselessly, until the + # hourly rate limit — a backstop against bugs, not a + # substitute for this — finally blunts it. A queued + # send (None) does not count either: it has not + # reached the charger, so there is nothing yet for the + # car to have ignored. + if start_sent is True and self._start_issued_at is None: + self._start_issued_at = now + elif decision.action is SolarAction.SET: if decision.target_watts is None: return diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index cbd27eb..f14fa99 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -812,7 +812,7 @@ def test_a_car_that_ignores_a_start_triggers_a_backoff() -> None: controller.mode = controller_module.SolarMode.ACTIVE # Pretend a start was issued a while ago and the car never drew. - controller._started_at = 0.0 + controller._start_issued_at = 0.0 asyncio.run(controller.async_tick()) @@ -835,13 +835,171 @@ def test_an_unknown_draw_does_not_trigger_a_backoff() -> None: del data["instantPowerAsWatt"] controller, _, _ = build(data) controller.mode = controller_module.SolarMode.SIMULATE - controller._started_at = 0.0 + controller._start_issued_at = 0.0 controller._check_ignored_start(controller_module.time.monotonic()) assert controller._backoff_until == 0.0 +def test_the_draw_grace_period_is_honoured() -> None: + """A car passes through the wait-for-EV state on very nearly every + successful start — evseState 5, "session live and authorised, the + car has not begun drawing yet" (payload.py:33-37). Arming an + hour-long back-off before the grace period has actually elapsed + would fire on that state on its own. + """ + data = dict(CHARGING_DATA) + data["evseStatus"] = "idle" + data["instantPowerAsWatt"] = 0 + controller, _, _ = build(data) + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [12_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._start_issued_at = clock[0] + clock[0] += solar.DRAW_GRACE_SECONDS - 1 + controller._check_ignored_start(clock[0]) + finally: + controller_module.time.monotonic = original_monotonic + + assert controller._backoff_until == 0.0 + + +def test_a_sustained_idle_start_does_not_wait_for_the_rate_limit() -> None: + """decide() has no memory of already having started: while the + charger stays idle with surplus sustained it returns START on + every tick. Resetting the draw-grace clock on each of those would + mean it never elapses, so the back-off would never arm — leaving + the hourly rate limit, a backstop against bugs and not a + substitute for this, to blunt the loop instead, twenty commands + and half an hour late instead of a handful of commands and five + minutes. + """ + controller, coordinator, _ = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [13_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + asyncio.run(controller.async_tick()) # waiting to confirm + clock[0] += solar.START_DELAY_SECONDS + 1 + asyncio.run(controller.async_tick()) # first start + + for _ in range(4): + clock[0] += solar.TICK_SECONDS + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert controller._backoff_until > 0 + assert len(coordinator.api_client.calls) < solar.MAX_COMMANDS_PER_HOUR + + +def test_a_queued_start_does_not_arm_the_draw_grace_clock() -> None: + """The current-set half of START already required a direct `True` + send before cancelling its retry; the start-charge half must hold + itself to the same standard for arming the draw-grace clock. A + queued start (None) has not reached the charger, so there is + nothing yet for the car to have ignored — and the chain backing it + up runs out to +465s, well past the 300s grace, so treating it as + issued would let the back-off arm while the queued start is still + trying to land. + """ + controller, coordinator, _ = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [14_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + asyncio.run(controller.async_tick()) # waiting to confirm + clock[0] += solar.START_DELAY_SECONDS + 1 + + async def _rpc_failure(serial: str, attempts: int = 8) -> dict: + raise api_module.ApiCommandRejectedError( + "unreachable", code=api_module.COMMAND_ERROR_CODE_RPC_FAILURE + ) + + coordinator.api_client.async_start_charge = _rpc_failure + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert coordinator.background_retries != [] + assert controller._start_issued_at is None + + +def test_a_charge_not_issued_by_us_does_not_arm_the_backoff() -> None: + """_started_at, the minimum-run clock, can be seeded from a charge + already running when the controller starts (Task 9 does this at + boot, so a healthy charge is not stopped moments after restart). + That charge was never issued by us, so a car sitting at 0 W must + not be judged against a start that never happened — only + _start_issued_at, set solely by a start _carry_out itself sent, + may arm the draw-grace back-off. + """ + data = dict(CHARGING_DATA) + data["instantPowerAsWatt"] = 0 + controller, _, _ = build(data) + controller.mode = controller_module.SolarMode.ACTIVE + + # Simulate Task 9's boot-time seeding of the minimum-run clock + # from an already-running charge, with no start of ours behind it. + controller._started_at = 0.0 + assert controller._start_issued_at is None + + asyncio.run(controller.async_tick()) + + assert controller._backoff_until == 0.0 + + +def test_simulate_mode_does_not_check_for_an_ignored_start() -> None: + """A dry run must not arm a real hour-long back-off from a start + that was itself only simulated — the car was never actually told + to charge, so treating it as ignored is not a faithful preview. + """ + data = dict(CHARGING_DATA) + data["evseStatus"] = "idle" + data["instantPowerAsWatt"] = 0 + controller, _, _ = build(data) + controller.mode = controller_module.SolarMode.SIMULATE + controller._start_issued_at = 0.0 + + asyncio.run(controller.async_tick()) + + assert controller._backoff_until == 0.0 + + +def test_a_queued_set_does_not_cancel_its_own_retry() -> None: + """A SET that fails and gets queued for the background retry must + not immediately cancel that same retry — _send_command's None + means only queued, not sent, so cancelling it here would discard + the one thing still trying to apply the change. + """ + controller, coordinator, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + async def _rpc_failure( + serial: str, current_ma: int, attempts: int = 8 + ) -> dict: + raise api_module.ApiCommandRejectedError( + "unreachable", code=api_module.COMMAND_ERROR_CODE_RPC_FAILURE + ) + + coordinator.api_client.async_set_max_charging_current = _rpc_failure + + asyncio.run(controller.async_tick()) + + key = f"{coordinator.serial_number}:current" + assert coordinator.background_retries != [] + assert any(k == key for k, _ in coordinator.background_retries) + assert key not in coordinator.cancelled_retries + + def test_a_successful_set_cancels_its_background_retry() -> None: """number.py cancels its own retry key after a successful manual set (number.py:233-235). Solar's direct sends share that same key From 47d6dc409a2afe1d9886488e3502201048c7f10b Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 12:15:19 +0100 Subject: [PATCH 60/82] docs: place Task 9's seeding outside the ACTIVE gate Task 5's fix round wrapped _check_ignored_start in a mode check, so the old anchor 'immediately before _check_ignored_start' would put the minimum-run seeding under that gate. A simulate dry run of an already-running charge would then report the minimum run time as unelapsed forever and never preview a stop. Co-Authored-By: Claude Opus 5 --- .../superpowers/plans/2026-09-29-solar-surplus-control.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md index c8f3592..cc35c8b 100644 --- a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md +++ b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md @@ -2509,7 +2509,13 @@ In `async_tick`, immediately after the `SolarMode.OFF` check: return ``` -And seed the start time, immediately before `self._check_ignored_start(now)`: +And seed the start time. Put it immediately after +`self._track_thresholds(state, now)` and **outside** the +`if self._mode is SolarMode.ACTIVE:` block that now wraps +`self._check_ignored_start(now)`. Seeding must happen in every mode: a +`simulate` dry run of a charge that is already running has to preview the +stop, and under the `ACTIVE` gate it would instead report "the minimum +run time has not elapsed" forever: ```python # Timers begin at zero after a restart. A charge that is From 83e9463eb3519b3af6ab813edfac2a29b1915006 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 12:53:13 +0100 Subject: [PATCH 61/82] fix: guard the draw-grace mark against disconnect, stale carry, and repeat starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round 2 of Task 5's review found three ways _start_issued_at could arm a false hour-long back-off, and one way the controller still spent three starts where the design implies one. Important: - A car unplugged shortly after a solar start satisfied every condition the arming check looked for: session gone, evseStatus idle, is_charge_enabled False, draw 0 W. The car did not ignore the start, it left. _check_ignored_start now takes car_connected (already computed by _build_state) and clears the mark without arming when the car is gone, rather than judging a start against a car that is no longer there. - None of the three places that clear _start_issued_at (on arming, on stop, on confirmed draw) had a test; each is a real, worked-out failure mode (a permanent zero-command back-off, a stale mark anchoring the next start's grace to the wrong start, and arming on a car that already charged fine) and each now has one. Minor, folded in: - The mode setter already resets the above/below threshold timers on any change; it now clears _start_issued_at too, so a detour through OFF or SIMULATE can't leave a start-issued mark to be judged against an already-expired grace on return to ACTIVE. - decide() has no memory of an outstanding start, so it repeats START every tick while the charger sits idle with surplus sustained. _carry_out now declines a START while one is already outstanding and its grace has not elapsed, rather than resending it — a retry of a start already in flight is not a fresh one. This takes the repro trace from three starts (6 API calls) to one start (2 calls) before the back-off arms at the same six minutes. Declining does not touch _start_issued_at either way, so it cannot rebuild the round-1 bug by another route. - test_a_sustained_idle_start_does_not_wait_for_the_rate_limit's assertion is pinned to the exact call trace (one start, two calls) instead of merely "under the rate limit", a bound loose enough that reintroducing repeat starts would still have passed it. Five new tests plus one tightened, each confirmed to fail against the exact regression it targets before being restored (see task-5-report.md for the log). Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/solar_controller.py | 39 +++++- tests/test_solar_controller.py | 145 ++++++++++++++++++++- 2 files changed, 177 insertions(+), 7 deletions(-) diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index 1acca25..3496f5e 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -169,6 +169,11 @@ def mode(self, value: SolarMode) -> None: self._mode = value self._above_since = None self._below_since = None + # A start issued before a detour through OFF or SIMULATE must + # not survive it: the first tick back in ACTIVE would evaluate + # it against an already-expired grace and arm an hour-long + # back-off from a start that may be long irrelevant by now. + self._start_issued_at = None _LOGGER.info("Solar control set to %s", value.value) self._notify() @@ -276,7 +281,7 @@ async def async_tick(self) -> None: # would let a dry run arm a real hour-long back-off from a # start that never happened. if self._mode is SolarMode.ACTIVE: - self._check_ignored_start(now) + self._check_ignored_start(now, state.car_connected) decision = decide(self._build_state(smoothed, now)) self._last_decision = decision @@ -447,7 +452,7 @@ def _track_thresholds(self, state: SolarState, now: float) -> None: if self._below_since is None: self._below_since = now - def _check_ignored_start(self, now: float) -> None: + def _check_ignored_start(self, now: float, car_connected: bool) -> None: """Back off if a car we started never began drawing. When a car finishes it stops drawing while surplus is still @@ -466,6 +471,12 @@ def _check_ignored_start(self, now: float) -> None: actually issued, and that reached the charger, sets ``_start_issued_at`` in the first place. + A disconnected car is checked before the grace period: a car + that has been unplugged reads as idle at 0 W, which otherwise + satisfies every condition this method checks for — but the + car did not ignore the start, it left, so the mark is cleared + without arming anything. + Draw is read through ``_car_draw_w`` rather than the raw ``instantPowerAsWatt`` field, and its None is treated as "wait and see", not "not drawing": a missing or unparseable reading @@ -476,6 +487,10 @@ def _check_ignored_start(self, now: float) -> None: if self._start_issued_at is None: return + if not car_connected: + self._start_issued_at = None + return + if now - self._start_issued_at < DRAW_GRACE_SECONDS: return @@ -617,6 +632,26 @@ async def _carry_out(self, decision: SolarDecision, now: float) -> None: self._start_issued_at = None elif decision.action is SolarAction.START: + # decide() has no memory of an outstanding start: while + # the charger sits idle with surplus sustained it returns + # START on every tick regardless. A further START while + # one is already outstanding and its grace has not + # elapsed is a retry of a start already in flight, not a + # fresh one — "backs off ... rather than retrying" means + # declining it, not resending it every 120s until the + # grace catches up. Must not touch _start_issued_at here + # either way — resetting it on decline would rebuild the + # exact bug fix round 1 closed (C1). + if ( + self._start_issued_at is not None + and now - self._start_issued_at < DRAW_GRACE_SECONDS + ): + _LOGGER.info( + "Solar control: a start is already outstanding, " + "waiting to see if the car draws before retrying" + ) + return + sent = True if decision.target_watts is not None: milliamps = watts_to_milliamps(decision.target_watts, data) diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index f14fa99..4cdd0ea 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -837,7 +837,9 @@ def test_an_unknown_draw_does_not_trigger_a_backoff() -> None: controller.mode = controller_module.SolarMode.SIMULATE controller._start_issued_at = 0.0 - controller._check_ignored_start(controller_module.time.monotonic()) + controller._check_ignored_start( + controller_module.time.monotonic(), car_connected=True + ) assert controller._backoff_until == 0.0 @@ -861,7 +863,7 @@ def test_the_draw_grace_period_is_honoured() -> None: try: controller._start_issued_at = clock[0] clock[0] += solar.DRAW_GRACE_SECONDS - 1 - controller._check_ignored_start(clock[0]) + controller._check_ignored_start(clock[0], car_connected=True) finally: controller_module.time.monotonic = original_monotonic @@ -875,8 +877,13 @@ def test_a_sustained_idle_start_does_not_wait_for_the_rate_limit() -> None: mean it never elapses, so the back-off would never arm — leaving the hourly rate limit, a backstop against bugs and not a substitute for this, to blunt the loop instead, twenty commands - and half an hour late instead of a handful of commands and five - minutes. + and half an hour late instead of one start and two commands. + + _carry_out also declines to resend a START while one is already + outstanding and its grace has not elapsed (fix round 2's I4), so + the trace is pinned at exactly one start rather than merely + "fewer than the rate limit" — a bound loose enough that a bug + reintroducing three or four repeat starts would still pass it. """ controller, coordinator, _ = build(NOT_CHARGING_DATA) controller.mode = controller_module.SolarMode.ACTIVE @@ -896,7 +903,11 @@ def test_a_sustained_idle_start_does_not_wait_for_the_rate_limit() -> None: controller_module.time.monotonic = original_monotonic assert controller._backoff_until > 0 - assert len(coordinator.api_client.calls) < solar.MAX_COMMANDS_PER_HOUR + assert len(coordinator.api_client.calls) == 2 + assert coordinator.api_client.calls == [ + ("current", coordinator.api_client.calls[0][1]), + ("start", coordinator.serial_number), + ] def test_a_queued_start_does_not_arm_the_draw_grace_clock() -> None: @@ -974,6 +985,130 @@ def test_simulate_mode_does_not_check_for_an_ignored_start() -> None: assert controller._backoff_until == 0.0 +def test_an_unplugged_car_does_not_arm_the_backoff() -> None: + """A car unplugged shortly after a solar start satisfies every + condition the arming check looks for on its own: the session + disappears, evseStatus reads idle, is_charge_enabled is False, and + _car_draw_w correctly reads 0 W. But the car did not ignore the + start, it left — arming an hour-long back-off for that reason + would ignore forty more minutes of sun for something that never + happened. + """ + data = dict(CHARGING_DATA) + data["evseStatus"] = "idle" + data["instantPowerAsWatt"] = 0 + data["chargeSession"] = None + controller, _, _ = build(data) + controller.mode = controller_module.SolarMode.ACTIVE + controller._start_issued_at = 0.0 + + asyncio.run(controller.async_tick()) + + assert controller._backoff_until == 0.0 + assert controller._start_issued_at is None + + +def test_the_backoff_mark_is_cleared_once_armed() -> None: + """If the mark survived arming, the first tick after the back-off + itself expires would re-read the still-idle car and arm another + hour without ever issuing a new start — a permanent, silent, + zero-command back-off that never charges again that day. + """ + data = dict(CHARGING_DATA) + data["evseStatus"] = "idle" + data["instantPowerAsWatt"] = 0 + controller, _, _ = build(data) + controller.mode = controller_module.SolarMode.ACTIVE + controller._start_issued_at = 0.0 + + now = controller_module.time.monotonic() + controller._check_ignored_start(now, car_connected=True) + assert controller._backoff_until > 0 + + controller._backoff_until = 0.0 # pretend the hour has passed + controller._check_ignored_start(now, car_connected=True) + + assert controller._backoff_until == 0.0 + + +def test_stopping_clears_the_draw_grace_mark() -> None: + """A mark left over from a previous start would anchor the next + start's draw-grace clock to the wrong start: the very next tick + could evaluate a 120-second-old start against an already-expired + grace, and arm an hour-long back-off on the wait-for-EV state + every start passes through. + + The mark is set only moments before the STOP-triggering tick — and + left unset for the tick that starts the below-floor timer — so + that its own grace has not elapsed by the time STOP is carried + out. Otherwise _check_ignored_start's confirmed-draw clear (see + test_a_confirmed_draw_clears_the_waiting_to_see_mark) would clear + it first in the same tick, on this fixture's steady 3000 W draw, + and the STOP branch's own clear would never be exercised at all. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [21_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + hass.states.set( + "sensor.grid_import", "3000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_tick()) # starts the below-floor timer + + clock[0] += solar.STOP_DELAY_SECONDS + 1 + # A start issued moments before this tick, well within its own + # grace — so _check_ignored_start itself takes no action here. + controller._start_issued_at = clock[0] - 1 + asyncio.run(controller.async_tick()) # stops + finally: + controller_module.time.monotonic = original_monotonic + + assert any(call[0] == "stop" for call in coordinator.api_client.calls) + assert controller._start_issued_at is None + + +def test_a_confirmed_draw_clears_the_waiting_to_see_mark() -> None: + """A car that charged fine and later finishes must not be judged + against the start that got it going in the first place. Once the + car is seen drawing, the mark has to clear, or a later idle tick + reads a stale mark and arms the back-off for a reason that already + resolved. + """ + controller, _, _ = build() # CHARGING_DATA: instantPowerAsWatt=3000 + controller.mode = controller_module.SolarMode.ACTIVE + controller._start_issued_at = 0.0 + + now = controller_module.time.monotonic() + controller._check_ignored_start(now, car_connected=True) + + assert controller._backoff_until == 0.0 + assert controller._start_issued_at is None + + +def test_a_mode_round_trip_clears_the_draw_grace_mark() -> None: + """The mode setter already resets the above/below threshold timers + on any change; a start issued before an OFF detour must not + survive it, or the first tick back in ACTIVE evaluates a stale + start against an already-expired grace and arms an hour-long + back-off from a start that is an hour irrelevant. + """ + controller, _, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + controller._start_issued_at = 0.0 + + controller.mode = controller_module.SolarMode.OFF + controller.mode = controller_module.SolarMode.ACTIVE + + assert controller._start_issued_at is None + + def test_a_queued_set_does_not_cancel_its_own_retry() -> None: """A SET that fails and gets queued for the background retry must not immediately cancel that same retry — _send_command's None From 96b32e5c7710b0f489abf0e99a3973e8b49bf853 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 13:10:04 +0100 Subject: [PATCH 62/82] docs: rewrite Tasks 6-10 and the spec against the pre-flight audit An audit of the unexecuted briefs found 5 Critical, 14 Important and 12 Minor defects, all in plan text rather than in shipped code. The largest: - The three-phase guard was built on supplyGrid3F, a field that exists nowhere in the API or the codebase, so the refusal could never fire while both its tests passed by injecting the key. The supply is now declared by the user in the options flow, since nothing in the payload reports it. - The restart seeding ran every tick rather than once, so a queued stop reseeded the clock and reissued STOP every 120s until the hourly backstop tripped. - The collapse fast path had no latch and could spend the hourly budget in minutes, then be refused for the stop that mattered. - The collapse path could not achieve its stated purpose: it advanced the stop clock by one tick out of 700-odd seconds, because the clock only started once the five-minute average admitted the drop. The raw collapse now anchors it. - The documentation task would have pushed main five tasks behind the branch, publishing a release containing the plan and none of the feature. The spec carried the same wrong two-minute claim about the collapse path and is corrected with it. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-29-solar-surplus-control.md | 1674 +++++++++++++++-- ...2026-09-29-solar-surplus-control-design.md | 71 +- 2 files changed, 1559 insertions(+), 186 deletions(-) diff --git a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md index cc35c8b..45457c8 100644 --- a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md +++ b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md @@ -15,7 +15,11 @@ - **Version:** set `manifest.json` to `"version": "0.2.0"` in the final documentation task, and nowhere else. No other task touches it. The maintainer chose this number; do not invent a different one. -- **Deploying is `git push`.** There is no separate copy step. Push `main` and force-push the `v0.1.6` tag together, since the tag tracks `main`. +- **No task publishes.** This work lands on the `solar-control` branch. + Merging it and moving the release tag is the operator's step, after + review, and `main` is several tasks behind the branch while the plan + runs: a push from inside a task would publish a release without the + feature in it. Commit; do not push, tag or force-push. - **Every commit message ends with the attribution line your own session specifies.** Do not copy a model name from this plan: a subagent running a different model attributes to that model, which is accurate. @@ -1776,11 +1780,25 @@ Co-Authored-By: Claude Opus 5 " **Files:** - Modify: `custom_components/daze/__init__.py` - Modify: `custom_components/daze/number.py` (the two existing limit entities) +- Modify: `custom_components/daze/switch.py` (the charge control switch) +- Modify: `custom_components/daze/coordinator.py` (declare the attribute) +- Modify: `custom_components/daze/solar_controller.py` (add `disarm`, accept a reserve) - Test: `tests/test_entities.py` (append before `_main`) +- Test: `tests/test_solar_controller.py` (append before `_main`) **Interfaces:** -- Consumes: `SolarController`, `SolarMode` from Task 4. -- Produces: `hass.data[DOMAIN][entry.entry_id]["solar_controller"]` +- Consumes: `SolarController`, `SolarMode` from Task 4, and + `CONF_GRID_IMPORT_SENSOR`, `CONF_GRID_EXPORT_SENSOR`, + `CONF_SOLAR_RESERVE`, `DEFAULT_SOLAR_RESERVE` from Task 3. +- Produces: + - `hass.data[DOMAIN][entry.entry_id]["solar_controller"]` + - `SolarController.disarm(reason)` + - `SolarController(..., reserve_w=...)`: the reserve arrives from the + entry's options rather than starting at zero every time. + - `DazeDataUpdateCoordinator.solar_controller`, declared on the class + so every entity and service can read it without `getattr`. + - `_reload_signature(entry)` in `__init__.py`, so that writing the + reserve back to the options does not reload the entry. - [ ] **Step 1: Write the failing test** @@ -1809,12 +1827,86 @@ def test_a_manual_limit_change_disarms_solar_control() -> None: asyncio.run(entity.async_set_native_value(16000)) assert coordinator.solar_controller.disarmed is True + + +def test_a_manual_charge_toggle_disarms_solar_control() -> None: + """The switch is a control too. + + Without this the user presses the toggle, and the next tick — at + most two minutes later — sees a connected car and sustained surplus + and commands the opposite. Solar control would be fighting the + person holding the button. + """ + class Ctl: + disarmed = False + + def disarm(self, reason: str) -> None: + self.disarmed = True + + coordinator = FakeCoordinator(dict(BASE_DATA)) + coordinator.solar_controller = Ctl() + client = FakeApi() + + switch_module = sys.modules["daze_entities_under_test.switch"] + entity = switch_module.DazeWallboxSwitchEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + + asyncio.run(entity.async_turn_on()) + + assert coordinator.solar_controller.disarmed is True ``` -- [ ] **Step 2: Run the test to verify it fails** +And append to `tests/test_solar_controller.py`, before `_main`: -Run: `python3 tests/test_entities.py` -Expected: FAIL on `assert ... .disarmed is True` +```python +def test_disarming_clears_the_clocks_a_rearm_would_misread() -> None: + """Disarming ends the episode, not just the mode. + + A start this controller issued, and the back-off that start could + still arm, must not survive into the next time solar control is + switched on. Left behind, a start issued at noon and abandoned at + 12:01 is judged at 14:00 against a car that has long since + finished, arming a 60-minute back-off for a start nobody is + waiting on. + """ + controller, _, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + controller._start_issued_at = 100.0 + controller._backoff_until = 1e9 + controller._started_at = 100.0 + + controller.disarm("the charging limit was set manually") + + assert controller.mode is controller_module.SolarMode.OFF + assert controller._start_issued_at is None + assert controller._backoff_until == 0.0 + assert controller._started_at is None +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: +```bash +python3 tests/test_entities.py +python3 tests/test_solar_controller.py +``` +Expected: FAIL on `assert ... .disarmed is True`, and +`AttributeError: 'SolarController' object has no attribute 'disarm'`. + +Step 5 declares `solar_controller` on the real coordinator, so +`FakeCoordinator` in `tests/test_entities.py` has to mirror it or every +existing test that sets a value raises `AttributeError` from the new +helper. Add one line to its `__init__`: + +```python + # Mirrors the real coordinator, which declares this so entities + # and services can read it without getattr. + self.solar_controller: Any = None +``` + +The two tests above then overwrite it with their own double. - [ ] **Step 3: Add `disarm` to the controller** @@ -1826,6 +1918,14 @@ In `custom_components/daze/solar_controller.py`, add to `SolarController`: Called when a limit change arrives through an entity or a service, which by construction means it did not come from here. + + Every clock of the episode goes with the mode, not just the two + threshold timers. A start this controller issued is no longer + ours to judge the car against: left set, _start_issued_at is + read hours later, against a car that has long since finished, + and arms a 60-minute back-off for a start nobody is waiting on. + A back-off already armed goes too — it was armed to stop this + controller retrying, and the user has just taken over anyway. """ if self._mode is SolarMode.OFF: return @@ -1834,10 +1934,23 @@ In `custom_components/daze/solar_controller.py`, add to `SolarController`: self._mode = SolarMode.OFF self._above_since = None self._below_since = None + self._collapsed_since = None + self._started_at = None + self._start_issued_at = None + self._backoff_until = 0.0 self._notify() ``` -- [ ] **Step 4: Call it from the limit entities** +`_collapsed_since` is Task 8's; if Task 8 has not run yet, leave that +line out and Task 8 will add it with the attribute. + +Clearing `_started_at` here is safe because Task 9 seeds it again from +an observed charge, once per charging episode. If Task 9's seeding is +ever removed, this line must go with it, or a charge still running when +solar control is re-armed can never be stopped: `_elapsed(None)` is +`0.0`, which reads as "just started" for ever. + +- [ ] **Step 4: Call it from the controls** In `custom_components/daze/number.py`, add this helper to both `DazeWallboxNumberEntity` and `DazeWallboxPowerEntity`: @@ -1848,14 +1961,75 @@ In `custom_components/daze/number.py`, add this helper to both `DazeWallboxNumbe Solar control writes through the API client, so anything arriving here came from a person or their automation. """ - controller = getattr(self.coordinator, "solar_controller", None) + controller = self.coordinator.solar_controller if controller is not None: controller.disarm("the charging limit was set manually") ``` -Call `self._disarm_solar()` in both `async_set_native_value` methods, immediately after the no-op guard returns and before the offline check. +Call `self._disarm_solar()` in both `async_set_native_value` methods, immediately after the no-op guard returns and before the validation check. Before validation rather than after, so that a value the charger would reject still counts as the user taking over: they have expressed the intent either way, and solar control writing the limit a second later is exactly what the rule exists to prevent. + +In `custom_components/daze/switch.py`, add the same helper to `DazeWallboxSwitchEntity`, worded for what it controls: + +```python + def _disarm_solar(self) -> None: + """Hand control back to the user. + + Solar control starts and stops the charge through the API + client, so a toggle arriving here came from a person or their + automation. Without this the next tick reverses them: the car + is connected and the surplus is unchanged, so decide() returns + the opposite command within two minutes. + """ + controller = self.coordinator.solar_controller + if controller is not None: + controller.disarm("charging was started or stopped manually") +``` + +Call `self._disarm_solar()` in both `async_turn_on` and `async_turn_off`, immediately after the idempotent no-op guard returns and before the offline check. + +- [ ] **Step 5: Let the controller be told its reserve** + +In `custom_components/daze/solar_controller.py`, add a keyword to +`SolarController.__init__` and use it instead of the hardcoded zero: + +```python + import_entity: str | None, + export_entity: str | None, + reserve_w: float = 0.0, +``` + +and, in the body, replace `self._reserve_w = 0.0` with: + +```python + self._reserve_w = max(0.0, float(reserve_w)) +``` + +Document the keyword in the docstring's `Args:` block: + +```python + reserve_w: Watts to leave for the house, restored from the + config entry's options. Held there rather than only in + memory: a reserve that returns to zero on every restart + gives the car everything the house was keeping, and + does it silently. +``` + +- [ ] **Step 6: Declare the attribute on the coordinator** + +In `custom_components/daze/coordinator.py`, add to +`DazeDataUpdateCoordinator.__init__`, beside the other state: + +```python + # Set by async_setup_entry. Declared here so every entity and + # service can read it directly: a getattr default would turn a + # wiring mistake into silent no-disarm, which is the failure + # this whole mechanism exists to prevent. + self.solar_controller: Any = None +``` + +`Any` is already imported in `coordinator.py`. -- [ ] **Step 5: Create and tear down the controller** +- [ ] **Step 7: Create and tear down the controller** In `custom_components/daze/__init__.py`, inside `async_setup_entry`, after the coordinator is created and before `hass.data[DOMAIN][entry.entry_id] = {...}`: @@ -1865,6 +2039,9 @@ In `custom_components/daze/__init__.py`, inside `async_setup_entry`, after the c coordinator=coordinator, import_entity=entry.options.get(CONF_GRID_IMPORT_SENSOR), export_entity=entry.options.get(CONF_GRID_EXPORT_SENSOR), + reserve_w=entry.options.get( + CONF_SOLAR_RESERVE, DEFAULT_SOLAR_RESERVE + ), ) # The entities reach the controller through the coordinator, which # every one of them already holds. @@ -1874,56 +2051,130 @@ In `custom_components/daze/__init__.py`, inside `async_setup_entry`, after the c Add `"solar_controller": solar_controller,` to the `hass.data[DOMAIN][entry.entry_id]` dict. -In `async_unload_entry`, inside the `if unload_ok:` block and before `coordinator.async_shutdown_timers()`: +In `async_unload_entry`, **inside the existing `if entry_data is not None:` block**, before `coordinator.async_shutdown_timers()`: ```python - controller = entry_data.get("solar_controller") - if controller is not None: - await controller.async_stop() + controller = entry_data.get("solar_controller") + if controller is not None: + await controller.async_stop() ``` +The indentation is load-bearing. `entry_data` is `None` whenever the +entry was already cleaned up — a second unload, or an unload after a +failed setup — and that guard is why the existing code checks it. One +level out, `None.get(...)` raises `AttributeError` and the rest of the +teardown never runs, leaving the coordinator's timers firing against a +closed client: the exact fault the comment above that block describes. + Add the imports: ```python -from .const import CONF_GRID_EXPORT_SENSOR, CONF_GRID_IMPORT_SENSOR +from .const import ( + CONF_GRID_EXPORT_SENSOR, + CONF_GRID_IMPORT_SENSOR, + CONF_SOLAR_RESERVE, + DEFAULT_SOLAR_RESERVE, +) from .solar_controller import SolarController ``` merging the `const` names into the existing import block. -- [ ] **Step 6: Disarm from the services too** +- [ ] **Step 8: Stop reloading the entry for a reserve change** + +The reserve is stored in the entry's options (Task 7 writes it there), +and `_async_update_listener` currently reloads the entry on any options +change. Without this, every step of the reserve slider tears the +integration down and rebuilds it: timers cancelled, entities recreated, +the controller's smoothing window emptied, and the mode reset until the +select restores it. + +In `custom_components/daze/__init__.py`, add above `_async_update_listener`: + +```python +def _reload_signature(entry: ConfigEntry) -> tuple[Any, Any]: + """Return the parts of an entry whose change needs a reload. + + The solar reserve is deliberately absent. It is applied live by the + controller, so rewriting it is not a reason to rebuild the entry; + everything else — credentials, the poll interval, the grid sensors + the controller is constructed with — is. + """ + options = { + key: value + for key, value in entry.options.items() + if key != CONF_SOLAR_RESERVE + } + return (dict(entry.data), options) +``` + +and replace the body of `_async_update_listener` with: + +```python + entry_data = hass.data.get(DOMAIN, {}).get(entry.entry_id) + signature = _reload_signature(entry) + + if entry_data is not None and entry_data.get("reload_signature") == ( + signature + ): + _LOGGER.debug( + "Config entry %s changed in a way that needs no reload", + entry.entry_id, + ) + return + + _LOGGER.debug("Config entry updated for %s — reloading", entry.entry_id) + await hass.config_entries.async_reload(entry.entry_id) +``` + +Add `"reload_signature": _reload_signature(entry),` to the +`hass.data[DOMAIN][entry.entry_id]` dict in `async_setup_entry`, and +import `Any` from `typing` if it is not already imported there. -In `custom_components/daze/__init__.py`, inside `_handle_set_charging_current`, immediately after `_refuse_if_offline()`: +- [ ] **Step 9: Disarm from the services too** + +In `custom_components/daze/__init__.py`, inside `_handle_set_charging_current`, `_handle_start_charge` and `_handle_stop_charge`, **immediately before** `_refuse_if_offline()`: ```python if coordinator.solar_controller is not None: coordinator.solar_controller.disarm( - "the charging current was set by a service call" + "the charge was commanded by a service call" ) ``` -- [ ] **Step 7: Run the tests and lint** +Before the offline check rather than after it, for the same reason as +the entities: `_refuse_if_offline()` raises, and a user whose charger +is briefly unreachable has still expressed the intent to take over. +Word the reason for each handler — "the charging current was set by a +service call" in `_handle_set_charging_current`. + +- [ ] **Step 10: Run the tests and lint** Run: ```bash python3 tests/run_all.py ruff check custom_components/daze/ tests/ ``` -Expected: 0 failures, `All checks passed!` +Expected: 0 failures, `All checks passed!`, with three more tests than +the suite had before this task. -- [ ] **Step 8: Commit** +- [ ] **Step 11: Commit** ```bash -git add custom_components/daze/__init__.py custom_components/daze/number.py custom_components/daze/solar_controller.py tests/test_entities.py +git add custom_components/daze/__init__.py custom_components/daze/number.py custom_components/daze/switch.py custom_components/daze/coordinator.py custom_components/daze/solar_controller.py tests/test_entities.py tests/test_solar_controller.py git commit -m "feat: wire solar control into the entry and disarm on override The controller is created with the entry and stopped when it unloads, -alongside the coordinator's own timers. +alongside the coordinator's own timers. It is told the reserve from the +entry's options rather than starting at zero, and a reserve-only +options change no longer reloads the entry. -Any limit change arriving through an entity or a service disarms solar -control, because the controller writes through the API client and -never through an entity. That makes the rule mechanical rather than a -flag that has to be set and cleared correctly. +Any limit change or charge toggle arriving through an entity or a +service disarms solar control, because the controller writes through +the API client and never through an entity. That makes the rule +mechanical rather than a flag that has to be set and cleared +correctly. Disarming clears the episode's clocks too, so re-arming +hours later is not judged against a start nobody is waiting on. Co-Authored-By: Claude Opus 5 " ``` @@ -1941,13 +2192,24 @@ Co-Authored-By: Claude Opus 5 " - Test: `tests/test_entities.py` (append before `_main`) **Interfaces:** -- Consumes: `SolarController` and `SolarMode` from Task 4, and - `hass.data[DOMAIN][entry.entry_id]["solar_controller"]` from Task 6. +- Consumes: `SolarController` and `SolarMode` from Task 4, + `hass.data[DOMAIN][entry.entry_id]["solar_controller"]` from Task 6, + and `CONF_SOLAR_RESERVE` / `MAX_SOLAR_RESERVE` from Task 3. - Produces: - - `DazeSolarControlSelect` in `select.py` - - `DazeSolarReserveEntity` in `number.py` + - `DazeSolarControlSelect` in `select.py`, which refuses to leave + `off` until both grid sensors are chosen. + - `DazeSolarReserveEntity` in `number.py`, which persists the reserve + to the config entry's options. - `DazeSolarSurplusSensor` in `sensor.py` +Note on coverage: `tests/test_entities.py` loads `const`, `payload`, +`models`, `api`, `coordinator`, `number`, `select` and `switch` — not +`sensor.py`, which pulls in the sensor catalogue and would need more +stubs than it is worth here. The surplus sensor is therefore not +covered by a test in this task. That is accepted: it is a read-only +projection of `controller.surplus_w`, which Task 2 and Task 4 already +test directly. + - [ ] **Step 1: Write the failing tests** Append to `tests/test_entities.py`, before `_main`: @@ -1960,28 +2222,116 @@ def test_solar_select_offers_three_modes() -> None: assert select_mod.SOLAR_MODE_OPTIONS == ["off", "simulate", "active"] -def test_solar_select_refuses_active_without_sensors() -> None: - """Both grid sensors are required before it can do anything.""" +def _solar_select(configured: bool = False) -> tuple[Any, Any]: + """Build the solar select over a controller double.""" select_mod = sys.modules["daze_entities_under_test.select"] class Ctl: - configured = False - mode = None + def __init__(self) -> None: + self.configured = configured + self.mode = None def add_listener(self, cb): return lambda: None - coordinator = FakeCoordinator(dict(BASE_DATA)) + controller = Ctl() entity = select_mod.DazeSolarControlSelect( - coordinator=coordinator, - controller=Ctl(), + coordinator=FakeCoordinator(dict(BASE_DATA)), + controller=controller, serial_number="SER1", device_info={}, ) + return entity, controller + + +def test_solar_select_is_unavailable_without_sensors() -> None: + """Both grid sensors are required before it can do anything.""" + entity, _ = _solar_select(configured=False) assert entity.available is False + + +def test_solar_select_refuses_to_arm_without_sensors() -> None: + """Availability is a hint to the dashboard, not a gate. + + A service call or an automation reaches async_select_option + whatever the entity reports, so the refusal the spec requires — + "both are required before solar control can leave off" — has to be + enforced in the method that acts, and explained where the caller + can see it. Asserting `available is False` instead would pass + against a select that happily arms itself with no sensors at all. + """ + entity, controller = _solar_select(configured=False) + + raised = False + try: + asyncio.run(entity.async_select_option("active")) + except HomeAssistantError: + raised = True + + assert raised, "arming without sensors was not refused" + assert controller.mode is None, "the mode was changed anyway" + + +def test_solar_select_arms_once_the_sensors_are_there() -> None: + """The refusal must not be a blanket one.""" + entity, controller = _solar_select(configured=True) + + asyncio.run(entity.async_select_option("simulate")) + + assert controller.mode is not None + assert controller.mode.value == "simulate" + + +def test_the_reserve_survives_a_restart() -> None: + """An in-memory reserve returns to 0 W on every restart, and 0 W + means the house gets nothing before the car does. A setting that + exists to hold power back must not quietly stop holding it. + """ + number_mod = sys.modules["daze_entities_under_test.number"] + const_mod = sys.modules["daze_entities_under_test.const"] + + class Ctl: + reserve_w = 0.0 + + class FakeEntry: + options: dict[str, Any] = {"poll_interval": 30} + + class FakeEntries: + def __init__(self) -> None: + self.updated: list[dict[str, Any]] = [] + + def async_update_entry(self, entry, options=None, **kwargs): + entry.options = options + self.updated.append(options) + + class FakeHass: + def __init__(self) -> None: + self.config_entries = FakeEntries() + + entry = FakeEntry() + entity = number_mod.DazeSolarReserveEntity( + coordinator=FakeCoordinator(dict(BASE_DATA)), + controller=Ctl(), + entry=entry, + serial_number="SER1", + device_info={}, + ) + entity.hass = FakeHass() + + asyncio.run(entity.async_set_native_value(1500)) + + assert entity.native_value == 1500 + assert entry.options[const_mod.CONF_SOLAR_RESERVE] == 1500 + # The rest of the options must survive the write, or saving a + # reserve would silently drop the user's grid sensors. + assert entry.options["poll_interval"] == 30 ``` +Add `from homeassistant.exceptions import HomeAssistantError` to the +test module's imports if it is not already there; the stub harness +already provides it. + - [ ] **Step 2: Run the tests to verify they fail** Run: `python3 tests/test_entities.py` @@ -2058,14 +2408,31 @@ class DazeSolarControlSelect( } async def async_select_option(self, option: str) -> None: - """Set the mode.""" + """Set the mode, refusing to arm before it can work. + + `available` is a hint for the dashboard. A service call or an + automation arrives here whatever the entity reports, so the + rule that both grid sensors are required before solar control + leaves "off" has to be enforced in the method that acts — and + raised, not logged, because the caller asked for something and + is entitled to know it did not happen. + """ from .solar_controller import SolarMode + if option != "off" and not self._controller.configured: + raise HomeAssistantError( + "Solar control needs both a grid import and a grid " + "export sensor before it can be armed. Set them in the " + "integration's options." + ) + self._controller.mode = SolarMode(option) self.async_write_ha_state() ``` -Add `from typing import Any` to the imports if not already present, and register the entity in `select.py`'s `async_setup_entry` by appending it to the `async_add_entities([...])` list: +Add `from typing import Any` and +`from homeassistant.exceptions import HomeAssistantError` to the +imports if not already present, and register the entity in `select.py`'s `async_setup_entry` by appending it to the `async_add_entities([...])` list: ```python DazeSolarControlSelect( @@ -2076,6 +2443,34 @@ Add `from typing import Any` to the imports if not already present, and register ), ``` +Read the controller with `entry_data["solar_controller"]` only if you +are confident the key is always present — it is, since Task 6 writes it +before the platforms are forwarded. If that ordering ever changes, a +`KeyError` here fails the whole select platform and takes +`select.daze_operation_mode` down with it, so the safer form is: + +```python + entities: list[SelectEntity] = [ + DazeWallboxSelectEntity(...), # the existing entity, unchanged + ] + + solar_controller = entry_data.get("solar_controller") + if solar_controller is not None: + entities.append( + DazeSolarControlSelect( + coordinator=coordinator, + controller=solar_controller, + serial_number=serial_number, + device_info=device_info, + ) + ) + + async_add_entities(entities) +``` + +Use the second form. The same applies to `number.py` and `sensor.py` +below. + - [ ] **Step 4: Add the reserve number** Append to `custom_components/daze/number.py`, before `async_setup_entry`: @@ -2097,12 +2492,23 @@ class DazeSolarReserveEntity( self, coordinator: DazeDataUpdateCoordinator, controller: Any, + entry: ConfigEntry, serial_number: str, device_info: DeviceInfo, ) -> None: - """Initialise the reserve control.""" + """Initialise the reserve control. + + Args: + coordinator: The Daze data coordinator. + controller: The solar controller whose reserve this is. + entry: The config entry the reserve is persisted in. + serial_number: The wallbox serial number. + device_info: Device info for the device registry. + + """ super().__init__(coordinator) self._controller = controller + self._entry = entry self._serial_number = serial_number self._attr_unique_id = f"{serial_number}_solar_reserve" self._attr_device_info = device_info @@ -2113,12 +2519,49 @@ class DazeSolarReserveEntity( return float(self._controller.reserve_w) async def async_set_native_value(self, value: float) -> None: - """Set the reserve.""" + """Set the reserve, and remember it across a restart. + + Written to the config entry's options, not just to the + controller. An in-memory reserve returns to 0 W every time + Home Assistant restarts, and 0 W means the house gets nothing + before the car does — a setting whose whole job is holding + power back, quietly stopping. Task 6's _reload_signature is + what keeps this write from reloading the entry on every step + of the slider. + """ self._controller.reserve_w = value + self.hass.config_entries.async_update_entry( + self._entry, + options={**self._entry.options, CONF_SOLAR_RESERVE: int(value)}, + ) self.async_write_ha_state() ``` -Add `MAX_SOLAR_RESERVE` to the `from .const import (...)` block, and register the entity in `number.py`'s `async_setup_entry` list. +Add `CONF_SOLAR_RESERVE` and `MAX_SOLAR_RESERVE` to the `from .const import (...)` block. `ConfigEntry` is already imported in `number.py` under `TYPE_CHECKING`, which is enough for the annotation. + +Register the entity in `number.py`'s `async_setup_entry` list, guarded +the same way as the select, and passing the entry that function already +receives: + +```python + solar_controller = entry_data.get("solar_controller") + if solar_controller is not None: + entities.append( + DazeSolarReserveEntity( + coordinator=coordinator, + controller=solar_controller, + entry=entry, + serial_number=serial_number, + device_info=device_info, + ) + ) +``` + +`number.py`'s `async_setup_entry` currently passes a list literal +straight to `async_add_entities`; build it as `entities = [...]` first. + +The reserve entity must **not** call `_disarm_solar`. It is solar +control's own setting, not a manual override of the charging limit. - [ ] **Step 5: Add the surplus sensor** @@ -2137,7 +2580,7 @@ class DazeSolarSurplusSensor( _attr_has_entity_name = True _attr_device_class = SensorDeviceClass.POWER _attr_state_class = SensorStateClass.MEASUREMENT - _attr_native_unit_of_measurement = "W" + _attr_native_unit_of_measurement = UnitOfPower.WATT def __init__( self, @@ -2166,7 +2609,24 @@ class DazeSolarSurplusSensor( return self._controller.surplus_w ``` -Register it in `sensor.py`'s `async_setup_entry` by appending to the `entities` list. +`UnitOfPower` is already imported in `sensor.py`. + +`sensor.py` builds `entities` as a list comprehension over `SENSORS`, +so there is no literal to extend. Append after it, guarded like the +other two: + +```python + solar_controller = entry_data.get("solar_controller") + if solar_controller is not None: + entities.append( + DazeSolarSurplusSensor( + coordinator=coordinator, + controller=solar_controller, + serial_number=serial_number, + device_info=device_info, + ) + ) +``` - [ ] **Step 6: Add the strings** @@ -2206,14 +2666,19 @@ The control is one tri-state select rather than a switch and a dry-run flag, so the meaningless combination cannot be selected. It carries the last decision and its reason as attributes, because an autonomous feature that acts silently cannot be understood after the -fact. +fact, and it refuses to leave 'off' until both grid sensors are set: +availability is a hint to the dashboard, not a gate on a service call. + +The reserve is persisted to the entry's options. Held only in memory +it returned to 0 W on every restart, which hands the house's share to +the car without saying so. Co-Authored-By: Claude Opus 5 " ``` --- -### Task 8: React immediately when surplus collapses +### Task 8: Start the stop clock when surplus collapses **Files:** - Modify: `custom_components/daze/solar_controller.py` @@ -2222,44 +2687,173 @@ Co-Authored-By: Claude Opus 5 " **Interfaces:** - Consumes: `SolarController` from Task 4. - Produces: no new public surface. The controller subscribes to state - changes on its two grid sensors. + changes on its two grid sensors, and gains three private attributes: + `_collapsed_since` (when the raw reading fell below the floor), + `_last_evaluation` and `_evaluating`. + +**What this task is actually for.** Not what it looks like. The obvious +reading — "a fixed tick keeps the car importing for up to two minutes" +— is wrong, and building to it would produce a task that cannot deliver +what it promises. + +Trace it with the real constants. A stop needs +`seconds_below_threshold >= STOP_DELAY_SECONDS`, which is 600 s. That +clock is kept by `_track_thresholds` from the **smoothed** figure, and +the smoother is a five-minute average: after a collapse from 4000 W to +0 W it takes several samples before the average itself drops below the +~1500 W floor. Nothing about evaluating sooner changes the 600 s, so +evaluating sooner saves one tick at most — 120 s out of 700 s or more. + +What is worth fixing is the *start* of that clock. Surplus fell at +12:00; the average admits it at 12:04; the stop then fires at 12:14 +instead of 12:10, and the car imports at up to the charger's ceiling +for the extra four minutes. So: the collapse itself starts the stop +clock, and the smoothed figure decides what to do — not when the drop +began. That is what the spec means by evaluating a drop immediately. + +The second half of the task is making sure this costs nothing. The +120-second tick was the only thing bounding how often the controller +writes to the charger; a sensor-driven path removes that bound in +exactly the direction that writes most, so it needs a latch and a +minimum spacing of its own. -Unused cheap power costs nothing; imported expensive power is what pure -solar mode exists to avoid. On a fixed two-minute tick a collapse in -surplus keeps the car importing for up to two minutes. Rising surplus -can wait; falling surplus cannot. - -- [ ] **Step 1: Write the failing test** +- [ ] **Step 1: Write the failing tests** Append to `tests/test_solar_controller.py`, before `_main`: ```python -def test_a_collapse_is_evaluated_without_waiting_for_the_tick() -> None: - """Rising surplus can wait for the tick; falling surplus cannot, - or the car imports until the next one.""" +def test_a_collapse_starts_the_stop_clock_when_it_happens() -> None: + """The ten-minute stop delay must run from the collapse, not from + the moment the five-minute average catches up with it. + + Asserting the mark itself rather than "a stop was sent": no stop + can be sent at the moment of a collapse — the smoothed figure is + still healthy, which is the whole reason this path exists — so a + test that looked for a command would pass against an + implementation that did nothing at all. + """ + controller, _, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [20_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + + # A healthy history: 3000 W drawn plus 5000 W exported. + for _ in range(2): + asyncio.run(controller.async_tick()) + clock[0] += solar.TICK_SECONDS + + clock[0] += 1 + collapse_at = clock[0] + hass.states.set( + "sensor.grid_import", "4000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + + asyncio.run(controller.async_sensor_changed()) + + assert controller._below_since == collapse_at, ( + "the stop clock did not start at the collapse: " + f"{controller._below_since} instead of {collapse_at}" + ) + finally: + controller_module.time.monotonic = original_monotonic + + +def test_a_collapse_is_evaluated_once_not_on_every_sensor_update() -> None: + """A grid sensor reporting every ten seconds updates six times a + minute, and the raw reading stays below the floor for as long as + the average takes to catch up. Without a latch each of those + updates runs a full evaluation, and each can rewrite the limit: + the twenty-command hourly backstop is spent in minutes, and it is + then not there for the stop when the stop finally comes. + """ controller, coordinator, hass = build() controller.mode = controller_module.SolarMode.ACTIVE - # Establish a healthy history. - for _ in range(3): + clock = [21_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 asyncio.run(controller.async_tick()) - coordinator.api_client.calls.clear() - hass.states.set("sensor.grid_export", "0") - hass.states.set("sensor.grid_import", "4000") + clock[0] += solar.TICK_SECONDS + 1 + hass.states.set( + "sensor.grid_import", "4000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_sensor_changed()) - before = controller.surplus_w - assert before is not None, "the healthy ticks should have left a figure" + after_first = len(coordinator.api_client.calls) - asyncio.run(controller.async_sensor_changed()) + # The sensor keeps reporting the same collapsed figures. + for _ in range(6): + clock[0] += 10 + asyncio.run(controller.async_sensor_changed()) + finally: + controller_module.time.monotonic = original_monotonic - # Compare against the pre-collapse figure rather than asserting these - # are merely set. A fast path that did nothing at all would leave both - # holding their values from the three healthy ticks, so "is not None" - # passes under exactly the regression this test exists to catch. - assert controller.surplus_w is not None - assert controller.surplus_w < before, "the collapse was not evaluated" - assert controller.last_decision is not None + assert len(coordinator.api_client.calls) == after_first, ( + "the fast path fired again while the same collapse was still " + "being counted" + ) + + +def test_a_recovery_re_arms_the_fast_path() -> None: + """A kettle is not a collapse. + + When the raw reading comes back above the floor the stop clock must + let go of it. Otherwise a dozen three-kilowatt kitchen dips over an + afternoon add up to ten minutes "below the floor" and stop a charge + that never wanted for surplus. + """ + controller, _, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [22_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + asyncio.run(controller.async_tick()) + + clock[0] += solar.TICK_SECONDS + 1 + hass.states.set( + "sensor.grid_import", "4000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_sensor_changed()) + assert controller._below_since is not None + + # The kettle switches off. + clock[0] += 30 + hass.states.set( + "sensor.grid_import", "0", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "5000", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_sensor_changed()) + assert controller._collapsed_since is None + + clock[0] += solar.TICK_SECONDS + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert controller._below_since is None, ( + "the stop clock is still anchored to a collapse that recovered" + ) def test_a_rise_does_not_trigger_an_immediate_evaluation() -> None: @@ -2276,6 +2870,71 @@ def test_a_rise_does_not_trigger_an_immediate_evaluation() -> None: asyncio.run(controller.async_sensor_changed()) assert len(coordinator.api_client.calls) == before + + +def test_a_sensor_event_during_a_tick_does_not_start_a_second_one() -> None: + """async_tick has two callers now, and an API call is an await. + + A sensor event arriving while a tick waits on the charger would + otherwise run a second evaluation against the same coordinator + data: both append to _command_times, both reach the same branch, + and both send the same command. The clock is advanced past the + minimum spacing inside the call on purpose, so that only the + re-entrancy guard can be what stops it. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [23_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + reentered: list[int] = [] + + async def _set_current_then_collapse( + serial: str, current_ma: int, attempts: int = 8 + ) -> dict: + coordinator.api_client.calls.append(("current", current_ma)) + hass.states.set( + "sensor.grid_import", "4000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + clock[0] += solar.TICK_SECONDS + 1 + await controller.async_sensor_changed() + reentered.append(1) + return {} + + coordinator.api_client.async_set_max_charging_current = ( + _set_current_then_collapse + ) + + try: + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert reentered == [1], "the sensor event never arrived mid-tick" + assert len(coordinator.api_client.calls) == 1, ( + "a second evaluation ran inside the first and commanded again" + ) + + +def test_async_start_still_clears_the_stopped_flag() -> None: + """async_start is rewritten in this task, and the flag it sets is + easy to drop on the way past: no other test builds a controller, + stops it and starts it again, so nothing else would notice. + """ + controller, _, _ = build() + SCHEDULED.clear() + + asyncio.run(controller.async_stop()) + assert controller._stopped is True + + asyncio.run(controller.async_start()) + + assert controller._stopped is False + assert len(SCHEDULED) == 1 ``` - [ ] **Step 2: Run the tests to verify they fail** @@ -2283,18 +2942,117 @@ def test_a_rise_does_not_trigger_an_immediate_evaluation() -> None: Run: `python3 tests/test_solar_controller.py` Expected: FAIL with `AttributeError: 'SolarController' object has no attribute 'async_sensor_changed'` -- [ ] **Step 3: Implement** +- [ ] **Step 3: Make the tick non-re-entrant** + +`async_tick` has had exactly one caller, the timer. This task adds a +second, driven by sensor events that can arrive while a tick is waiting +on an API call. -Add to `SolarController` in `custom_components/daze/solar_controller.py`: +In `custom_components/daze/solar_controller.py`, rename the existing +`async_tick` to `_async_evaluate`, leaving its body as it is apart from +the edits in the steps below, and add this in its place: ```python - async def async_sensor_changed(self) -> None: - """Evaluate now if surplus has collapsed, otherwise wait. + async def async_tick(self) -> None: + """Evaluate once, unless an evaluation is already running. + + Skipping rather than queueing: a queued evaluation would run + against coordinator data that is by then one command out of + date, and would decide the same thing twice — two entries in + _command_times, two commands on the wire. + + A plain flag rather than an asyncio.Lock. The lock would be + correct in production and wrong in the test suite, which drives + the controller through asyncio.run() one call at a time: a Lock + binds itself to the first event loop that acquires it and + raises RuntimeError on the next one. The event loop is + single-threaded, so nothing can interleave between the check + and the assignment below, and a flag is enough. + """ + if self._evaluating: + _LOGGER.debug("An evaluation is already running; skipping") + return + + self._evaluating = True + try: + await self._async_evaluate() + finally: + self._evaluating = False +``` + +- [ ] **Step 4: Start the stop clock at the collapse** + +In `_async_evaluate`, record when each evaluation ran, immediately +after `now = time.monotonic()`: + +```python + self._last_evaluation = now +``` + +and pass the raw reading to `_track_thresholds`, which currently +receives only the smoothed state: + +```python + self._track_thresholds(state, now, surplus) +``` + +Then replace `_track_thresholds` with: + +```python + def _track_thresholds( + self, state: SolarState, now: float, raw_surplus: float + ) -> None: + """Maintain how long surplus has been above or below the floor. + + Two figures, deliberately. What to do is decided from the + smoothed surplus, because raw grid readings move with every + kettle. When the below-floor period *started* is taken from the + raw reading, because the five-minute average is minutes behind + a real collapse, and the stop delay is counted from this mark: + anchoring it to the average adds those minutes to the ten, and + the car imports at up to the charger's ceiling throughout. + + _collapsed_since holds that anchor and doubles as the fast + path's latch. It is cleared the moment the raw reading comes + back above the floor, so a kettle that dips the supply for a + minute leaves nothing behind. + """ + available = state.surplus_w - state.reserve_w + + if raw_surplus - state.reserve_w >= state.floor_w: + self._collapsed_since = None + elif self._collapsed_since is None: + self._collapsed_since = now + + if available >= state.floor_w and self._collapsed_since is None: + self._below_since = None + if self._above_since is None: + self._above_since = now + else: + self._above_since = None + if self._below_since is None: + self._below_since = self._collapsed_since or now +``` + +- [ ] **Step 5: Add the fast path** - A fixed tick would keep the car importing for up to two - minutes after surplus disappears. Rising surplus is not urgent: - acting on every increase would rewrite the limit constantly - against a charger that takes seconds to apply a change. +Add to `SolarController`: + +```python + async def async_sensor_changed(self) -> None: + """Note a collapse as soon as it happens. + + What this brings forward is the start of the stop clock, not + the stop. The stop needs ten minutes below the floor and is + decided from the smoothed figure, which is minutes behind the + drop; starting its clock from the drop itself is worth about + four minutes of avoided import, and is the whole benefit. An + evaluation is run as well when it is cheap to do so, because + the collapse may also be the moment a limit becomes too high. + + Rising surplus is not urgent, and is left to the tick: acting + on every increase would rewrite the limit constantly against a + charger that takes seconds to apply a change. """ if self._mode is SolarMode.OFF: return @@ -2303,16 +3061,42 @@ Add to `SolarController` in `custom_components/daze/solar_controller.py`: if surplus is None: return - smoothed = self._smoother.value() + now = time.monotonic() data = self._coordinator.data or {} floor = milliamps_to_watts(min_charging_current(data), data) - collapsed = ( - surplus - self._reserve_w < floor - and (smoothed is None or smoothed - self._reserve_w >= floor) - ) + if surplus - self._reserve_w >= floor: + # Healthy again. Let go of the anchor and re-arm, so the + # next collapse is counted from itself. + self._collapsed_since = None + return - if not collapsed: + if self._collapsed_since is not None: + # This collapse is already being counted. Without this the + # condition below the floor holds on every sensor update + # until the average catches up, and a sensor reporting + # every ten seconds would run six evaluations a minute and + # spend the hourly command backstop in about three. + return + + self._collapsed_since = now + + smoothed = self._smoother.value() + if smoothed is not None and smoothed - self._reserve_w < floor: + # The average is already below the floor, so the ordinary + # tick is already treating this as a deficit and the clock + # is already running. Nothing to bring forward. + return + + if ( + self._last_evaluation is not None + and now - self._last_evaluation < TICK_SECONDS + ): + _LOGGER.debug( + "Surplus collapsed to %.0f W; the stop clock starts now, " + "the evaluation waits for the tick", + surplus, + ) return _LOGGER.debug( @@ -2321,11 +3105,19 @@ Add to `SolarController` in `custom_components/daze/solar_controller.py`: await self.async_tick() ``` +The spacing check costs nothing that matters: the collapse is recorded +either way, and that is the part with a deadline. Only the evaluation +waits, by at most one tick. + Register the subscription in `async_start`: ```python async def async_start(self) -> None: """Begin ticking, and watch the grid sensors for a collapse.""" + # Keep this. async_stop sets the flag to prevent a tick already + # in flight from re-arming itself, and a controller started + # again after a stop would otherwise never tick at all. + self._stopped = False self._schedule_tick() entities = [ @@ -2347,6 +3139,11 @@ Add to `__init__`: ```python self._cancel_listener: Callable[[], None] | None = None + # When the raw reading fell below the floor and has stayed + # there. Anchors the stop clock and latches the fast path. + self._collapsed_since: float | None = None + self._last_evaluation: float | None = None + self._evaluating = False ``` Add to `async_stop`, before clearing listeners: @@ -2357,6 +3154,10 @@ Add to `async_stop`, before clearing listeners: self._cancel_listener = None ``` +And add `self._collapsed_since = None` to `disarm` (Task 6), beside the +other clocks it clears. A latch left armed from before the user took +over would anchor the next collapse's stop clock to the old one. + And to the imports: ```python @@ -2367,15 +3168,20 @@ from homeassistant.helpers.event import ( ``` Add `async_track_state_change_event=lambda hass, entities, cb: (lambda: None)` -to the `homeassistant.helpers.event` stub in `tests/test_solar_controller.py`. +to the `homeassistant.helpers.event` stub in **both** +`tests/test_solar_controller.py` and `tests/test_entities.py`. The +second is not optional: Task 7's select imports `SolarMode` from +`.solar_controller` inside `async_select_option`, so the entity tests +import this module at runtime, and an import line that names a symbol +the stub does not have fails the whole file. -- [ ] **Step 4: Run the tests to verify they pass** +- [ ] **Step 6: Run the tests to verify they pass** Run: `python3 tests/test_solar_controller.py` -Expected: PASS, `0 failed`, with two more tests than the suite had before +Expected: PASS, `0 failed`, with six more tests than the suite had before this task. The absolute count is deliberately not stated; see Task 5. -- [ ] **Step 5: Lint and full suite** +- [ ] **Step 7: Lint and full suite** Run: ```bash @@ -2384,17 +3190,25 @@ python3 tests/run_all.py ``` Expected: `All checks passed!`, 0 failures. -- [ ] **Step 6: Commit** +- [ ] **Step 8: Commit** ```bash git add custom_components/daze/solar_controller.py tests/test_solar_controller.py -git commit -m "feat: evaluate immediately when surplus collapses +git commit -m "feat: start the stop clock when surplus actually collapses + +The ten-minute stop delay was counted from the moment the five-minute +average admitted the drop, several minutes after the drop itself, and +the car imported at up to the charger's ceiling in between. The raw +reading now anchors that clock; the smoothed figure still decides what +to do. -A fixed two-minute tick keeps the car importing for up to two minutes -after surplus disappears, which is exactly what pure solar mode exists -to avoid. Rising surplus still waits for the tick: acting on every -increase would rewrite the limit constantly against a charger that -takes seconds to apply a change. +Rising surplus still waits for the tick, and so does most of the work +on a collapse: the fast path fires once per collapse and never more +often than the tick would, because the twenty-command hourly backstop +is a backstop against bugs and has to still be there for the stop. + +The tick is no longer re-entrant, now that a sensor event can reach it +while an API call is in flight. Co-Authored-By: Claude Opus 5 " ``` @@ -2404,47 +3218,138 @@ Co-Authored-By: Claude Opus 5 " ### Task 9: Remaining guards, and surviving a restart **Files:** +- Modify: `custom_components/daze/const.py` +- Modify: `custom_components/daze/config_flow.py` (the `DazeOptionsFlowHandler` class) +- Modify: `custom_components/daze/strings.json` +- Modify: `custom_components/daze/translations/it.json` +- Modify: `custom_components/daze/__init__.py` (one more keyword on the controller) - Modify: `custom_components/daze/solar_controller.py` - Modify: `custom_components/daze/select.py` (the solar select) - Test: `tests/test_solar_controller.py` (append before `_main`) +- Test: `tests/test_entities.py` (append before `_main`) **Interfaces:** - Consumes: `SolarController` from Task 4, `DazeSolarControlSelect` from Task 7. -- Produces: `SolarController.unsupported_reason` property, returning - `str | None`. - -Three things the spec requires that nothing yet implements: refusing a -supply the charger cannot follow, surviving a Home Assistant restart -without stopping a healthy charge, and remembering the mode. +- Produces: + - `CONF_SUPPLY_PHASES = "supply_phases"`, `SUPPLY_PHASES_SINGLE = "single"`, + `SUPPLY_PHASES_THREE = "three"` in `const.py`, and a third question in + the options flow. **No default**: an unanswered question is not an + answer. + - `SolarController(..., supply_phases=...)` + - `SolarController.unsupported_reason` property, returning `str | None` + — the single answer to "can solar control run here, and if not, why + not". The select uses it for availability and for refusing to arm, + the tick uses it to stand down, and the README quotes it. + +Four things the spec requires that nothing yet implements: refusing a +supply the charger cannot follow, standing down for the charger's own +eco mode and schedules, surviving a Home Assistant restart without +stopping a healthy charge, and remembering the mode. + +**Why the supply is declared rather than detected.** The Daze payload +has exactly one phase field, `evseIsThreePhase`, and `payload.py:308` +already reads it as the *charger's* phase count when deriving the +current floor. Nothing in the payload describes the supply feeding it. +A guard keyed on a field that does not exist would return "supported" +for every installation on earth and pass its own tests, so the question +is asked in the options flow instead. Until it is answered, solar +control refuses to arm: guessing single-phase would let a three-phase +house follow a meter that nets across phases and load the one phase the +charger is on. + +(`sensor_catalog.py:243` surfaces `evseIsThreePhase` to users as +"Three-Phase Supply", which contradicts `payload.py`'s reading of the +same field. Not this task's job — renaming an existing entity breaks +dashboards — but it is worth a follow-up, and it is why the option is +named for the *supply* explicitly.) - [ ] **Step 1: Write the failing tests** -Append to `tests/test_solar_controller.py`, before `_main`. Add +First, `build()` in `tests/test_solar_controller.py` must declare a +supply, or every test in the file stops at the new guard. Change it to: + +```python +def build( + data: dict[str, Any] | None = None, + supply_phases: str | None = "single", +) -> tuple[Any, Any, Any]: + """Build a controller wired to stubs. + + Declares a single-phase supply unless a test says otherwise: that + is the ordinary installation, and the alternatives each have a test + of their own below. + """ +``` + +passing `supply_phases=supply_phases` to the constructor. + +Then append to `tests/test_solar_controller.py`, before `_main`. Add `import time` to the file's imports if it is not already there — the seeding test below reads `time.monotonic()`: ```python +def test_an_undeclared_supply_refuses_to_run() -> None: + """The Daze payload cannot tell us how many phases feed the house, + so the user is asked. Until they answer, an unanswered question is + not evidence of a single-phase supply: guessing wrong loads one + phase with the whole of a netted three-phase surplus. + """ + controller, _, _ = build(supply_phases=None) + + assert controller.unsupported_reason is not None + assert "phase" in controller.unsupported_reason + + def test_three_phase_supply_with_a_single_phase_charger_is_refused() -> None: """Grid meters usually report net across phases, so the surplus can exist mostly on phases the charger cannot reach.""" data = dict(CHARGING_DATA) data["evseIsThreePhase"] = False - data["supplyGrid3F"] = True - controller, _, _ = build(data) + controller, _, _ = build(data, supply_phases="three") assert controller.unsupported_reason is not None assert "phase" in controller.unsupported_reason -def test_a_matched_supply_is_supported() -> None: +def test_a_matched_single_phase_pair_is_supported() -> None: data = dict(CHARGING_DATA) data["evseIsThreePhase"] = False - data["supplyGrid3F"] = False - controller, _, _ = build(data) + controller, _, _ = build(data, supply_phases="single") assert controller.unsupported_reason is None +def test_a_three_phase_charger_on_a_three_phase_supply_is_supported() -> None: + """The refusal is about the mismatch, not about three phases.""" + data = dict(CHARGING_DATA) + data["evseIsThreePhase"] = True + controller, _, _ = build(data, supply_phases="three") + + assert controller.unsupported_reason is None + + +def test_eco_mode_refuses_to_arm() -> None: + """The spec asks for this three times: the charger's own eco mode + is controlling it, so solar control stands down and says so rather + than quietly deciding nothing every two minutes for ever. + """ + data = dict(CHARGING_DATA) + data["ecoModeEnabled"] = True + controller, _, _ = build(data) + + assert controller.unsupported_reason is not None + assert "eco" in controller.unsupported_reason + + +def test_a_charger_schedule_refuses_to_arm() -> None: + data = dict(CHARGING_DATA) + data["schedules"] = [{"id": 1}] + controller, _, _ = build(data) + + assert controller.unsupported_reason is not None + assert "schedule" in controller.unsupported_reason + + def test_a_charge_already_running_counts_as_having_run() -> None: """Timers start at zero after a restart. Without seeding, an unelapsed minimum run time could stop a healthy charge moments @@ -2464,58 +3369,352 @@ def test_a_charge_already_running_counts_as_having_run() -> None: "a charge already running must count as having served its minimum " f"run time, but the mark was seeded only {elapsed:.0f}s back" ) + + +def test_a_stop_that_could_not_be_sent_is_not_re_issued_every_tick() -> None: + """_carry_out clears the minimum-run clock after a stop it sent, + and "sent" includes one only queued for the background retry — + where the charger is still charging. Seeding that clock again on + the next tick makes decide() return STOP again, and again every + two minutes, until the hourly backstop trips forty minutes later. + Handing a stuck link to the background retry and leaving it there + is the spec's own rule; this is why the seeding is once per charge + and not once per tick. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [24_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + hass.states.set( + "sensor.grid_import", "3000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_tick()) # starts the below-floor timer + + clock[0] += solar.STOP_DELAY_SECONDS + 1 + + async def _rpc_failure(serial: str, attempts: int = 8) -> dict: + coordinator.api_client.calls.append(("stop", serial)) + raise api_module.ApiCommandRejectedError( + "unreachable", code=api_module.COMMAND_ERROR_CODE_RPC_FAILURE + ) + + coordinator.api_client.async_stop_charge = _rpc_failure + asyncio.run(controller.async_tick()) + + for _ in range(3): + clock[0] += solar.TICK_SECONDS + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + stops = len( + [call for call in coordinator.api_client.calls if call[0] == "stop"] + ) + assert stops == 1, f"the queued stop was re-issued: {stops} attempts" + + +def test_a_charge_that_starts_later_is_seeded_in_its_turn() -> None: + """Once per charging episode, not once per lifetime. + + A charge the user starts by hand an hour from now has also been + running longer than we have been watching it. If the flag never + reset, that charge's minimum-run clock would read as zero for ever + and solar control could never stop it — the mirror image of the + bug the seeding exists to fix. + """ + controller, coordinator, _ = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.SIMULATE + + asyncio.run(controller.async_tick()) + assert controller._started_at is None + + coordinator.data = dict(CHARGING_DATA) + asyncio.run(controller.async_tick()) + + assert controller._started_at is not None +``` + +And append to `tests/test_entities.py`, before `_main`: + +```python +def test_the_solar_select_restores_its_mode() -> None: + """The spec asks for restoration across a restart by name. + + Without it every Home Assistant restart silently disarms solar + control: the select comes back "off", the car stops following the + sun, and nothing says so. + """ + entity, controller = _solar_select(configured=True) + + class LastState: + state = "active" + + async def _last_state() -> Any: + return LastState() + + entity.async_get_last_state = _last_state + + asyncio.run(entity.async_added_to_hass()) + + assert controller.mode is not None + assert controller.mode.value == "active" +``` + +`_solar_select` is Task 7's helper. Give its double an +`unsupported_reason` now that the select reads one: + +```python + @property + def unsupported_reason(self): + if not self.configured: + return "no grid sensors have been chosen" + return None ``` - [ ] **Step 2: Run the tests to verify they fail** -Run: `python3 tests/test_solar_controller.py` -Expected: FAIL with `AttributeError: ... 'unsupported_reason'` +Run: +```bash +python3 tests/test_solar_controller.py +python3 tests/test_entities.py +``` +Expected: FAIL with `AttributeError: ... 'unsupported_reason'`, and +`TypeError: build() got an unexpected keyword argument 'supply_phases'` +until Step 1's change to `build` is in place. + +- [ ] **Step 3: Add the constants** + +Append to `custom_components/daze/const.py`, after the solar block Task 3 added: + +```python +# How many phases feed the house. Declared by the user, because the +# Daze payload does not say: its only phase field, evseIsThreePhase, +# describes the charger, and payload.min_charging_current already reads +# it that way. There is deliberately no default — a three-phase meter +# reports surplus netted across phases, and following it with a +# single-phase charger loads the one phase the charger is on. +CONF_SUPPLY_PHASES = "supply_phases" +SUPPLY_PHASES_SINGLE = "single" +SUPPLY_PHASES_THREE = "three" +``` + +- [ ] **Step 4: Ask the question in the options flow** + +In `custom_components/daze/config_flow.py`, add a third field to the +schema Task 3 built in `DazeOptionsFlowHandler.async_step_init`, after +the two sensor pickers: + +```python + # Optional so the form can still be saved without it, + # not because it has a default: solar control refuses + # to arm until it is answered. + vol.Optional( + CONF_SUPPLY_PHASES, + description={ + "suggested_value": options.get(CONF_SUPPLY_PHASES) + }, + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[SUPPLY_PHASES_SINGLE, SUPPLY_PHASES_THREE], + translation_key="supply_phases", + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), +``` + +Add `CONF_SUPPLY_PHASES`, `SUPPLY_PHASES_SINGLE` and +`SUPPLY_PHASES_THREE` to the existing `from .const import (...)` block. + +In `custom_components/daze/strings.json`, add to +`options.step.init.data`: + +```json + "supply_phases": "Grid supply" +``` + +add a `data_description` beside `data` in the same step: + +```json + "data_description": { + "supply_phases": "How many phases feed the house, not the charger. A three-phase meter reports surplus added up across all three, and a single-phase charger can only use one of them, so solar control will not arm until this is set." + } +``` + +and add a top-level `selector` block beside `options`: + +```json + "selector": { + "supply_phases": { + "options": { + "single": "Single-phase", + "three": "Three-phase" + } + } + } +``` + +Do the same in `custom_components/daze/translations/it.json`: +`"supply_phases": "Alimentazione di rete"`, the options +`"single": "Monofase"` and `"three": "Trifase"`, and the description: + +```json + "data_description": { + "supply_phases": "Quante fasi alimentano la casa, non il caricatore. Un contatore trifase riporta il surplus sommato sulle tre fasi e un caricatore monofase può usarne solo una, quindi il controllo solare non si attiva finché non è impostato." + } +``` + +- [ ] **Step 5: Tell the controller what was answered** + +In `custom_components/daze/solar_controller.py`, add a keyword to +`SolarController.__init__`, beside the reserve Task 6 added: + +```python + supply_phases: str | None = None, +``` + +stored as `self._supply_phases = supply_phases`, and documented: + +```python + supply_phases: "single", "three", or None if the user has + not said. None refuses to arm rather than assuming: + the payload cannot tell us, and the wrong guess loads + one phase with a surplus measured across three. +``` + +In `custom_components/daze/__init__.py`, pass it when the controller is +constructed: + +```python + supply_phases=entry.options.get(CONF_SUPPLY_PHASES), +``` + +and add `CONF_SUPPLY_PHASES` to the `from .const import (...)` block +there. -- [ ] **Step 3: Implement the guard and the seeding** +- [ ] **Step 6: Implement the one refusal** Add to `SolarController`: ```python @property def unsupported_reason(self) -> str | None: - """Explain why this setup cannot be followed, if it cannot. + """Explain why solar control cannot run here, if it cannot. - A three-phase supply feeding a single-phase charger reports - surplus netted across phases, most of which the charger cannot - reach. Following it would overload one phase. + One property with one answer, because every caller needs the + same one: the select for its availability and for refusing to + arm, the tick to stand down, the log line, and the README. The + spec asks three separate times for a refusal that explains + itself, and a boolean cannot. + + Ordered cheapest and most fundamental first, so the message a + user sees names the thing they have to fix. """ - data = self._coordinator.data or {} + if not self.configured: + return ( + "both a grid import and a grid export sensor have to be " + "chosen in the integration's options" + ) - supply_three_phase = bool(data.get("supplyGrid3F")) - charger_three_phase = bool(data.get("evseIsThreePhase")) + if self._supply_phases not in ( + SUPPLY_PHASES_SINGLE, + SUPPLY_PHASES_THREE, + ): + return ( + "the number of phases feeding the house has not been set " + "in the integration's options, and it cannot be read from " + "the charger" + ) - if supply_three_phase and not charger_three_phase: + data = self._coordinator.data + if not data: + return "the charger has not reported yet" + + if self._supply_phases == SUPPLY_PHASES_THREE and not bool( + data.get("evseIsThreePhase") + ): return ( "the supply is three-phase and the charger is single-phase, " "so exported power may be on a phase it cannot use" ) + if data.get("ecoModeEnabled"): + return "the charger's own eco mode is controlling it" + + if data.get("schedules"): + return "the charger has a schedule set" + return None ``` -In `async_tick`, immediately after the `SolarMode.OFF` check: +Add `SUPPLY_PHASES_SINGLE` and `SUPPLY_PHASES_THREE` to the +`from .const import (...)` block in `solar_controller.py`, creating it +if the module does not import from `const` yet. + +An absent payload is a refusal, not a pass. `self._coordinator.data` +is empty before the first successful poll, and reading that as "no +phase mismatch, no eco mode, no schedule" would arm solar control on +the strength of knowing nothing — the same mistake `_build_state` +already avoids for `charger_reachable`. + +In `_async_evaluate`, immediately after the `SolarMode.OFF` check: ```python unsupported = self.unsupported_reason if unsupported is not None: - if not self._sensor_warning_logged: - self._sensor_warning_logged = True + if not self._unsupported_warning_logged: + self._unsupported_warning_logged = True _LOGGER.warning("Solar control cannot run: %s", unsupported) return + + self._unsupported_warning_logged = False ``` -And seed the start time. Put it immediately after -`self._track_thresholds(state, now)` and **outside** the -`if self._mode is SolarMode.ACTIVE:` block that now wraps -`self._check_ignored_start(now)`. Seeding must happen in every mode: a -`simulate` dry run of a charge that is already running has to preview the -stop, and under the `ACTIVE` gate it would instead report "the minimum -run time has not elapsed" forever: +with `self._unsupported_warning_logged = False` added to `__init__`. + +Its own flag, not `_sensor_warning_logged`. The two conditions are +unrelated, and the reset that clears the sensor flag sits below this +guard, where an unsupported setup never reaches it: sharing one flag +means whichever warned first silences the other for the lifetime of +the entry. + +- [ ] **Step 7: Log the rate limit at the level the spec asks for** + +The spec's error table says the rate limit is logged at **warning** and +everything else at debug; every `nothing` decision currently goes to +debug, the rate limit included, so the one condition a user needs to +know about is the one they cannot see. In `_async_evaluate`, replace +the `NOTHING` branch's log line: + +```python + if decision.action is SolarAction.NOTHING: + if state.commands_this_hour >= MAX_COMMANDS_PER_HOUR: + # The backstop is against bugs. If it is what is + # holding the charger back, something upstream is + # wrong and the log has to say so out loud. + _LOGGER.warning("Solar control: %s", decision.reason) + else: + _LOGGER.debug("Solar control: %s", decision.reason) + self._notify() + return +``` + +Add `MAX_COMMANDS_PER_HOUR` to the `from .solar import (...)` block. + +- [ ] **Step 8: Seed the minimum-run clock, once per charge** + +Put this immediately after `self._track_thresholds(state, now, surplus)` +and **outside** the `if self._mode is SolarMode.ACTIVE:` block that +wraps `self._check_ignored_start(now)`. Seeding must happen in every +mode: a `simulate` dry run of a charge that is already running has to +preview the stop, and under the `ACTIVE` gate it would instead report +"the minimum run time has not elapsed" forever: ```python # Timers begin at zero after a restart. A charge that is @@ -2523,19 +3722,45 @@ run time has not elapsed" forever: # this the minimum run time reads as unelapsed and a healthy # charge could be stopped moments after boot. # + # Once per charging episode, not once per tick. _carry_out + # clears the minimum-run clock after a stop it *sent*, and + # "sent" includes one only queued for the background retry — + # where the charger is still charging. Re-seeding on the next + # tick would put the clock back, decide() would return STOP + # again, and it would do so every two minutes until the hourly + # backstop tripped forty minutes later. A stop that will not + # land is the background retry's business, and the spec says + # so: "hand to the existing background retry; do not retry + # here." + # + # The flag resets when the charge is observed to end, so the + # next one — including a charge the user starts by hand — is + # seeded in its turn. A flag that only ever set once would + # leave that later charge with a zero minimum-run clock for + # ever, and solar control could never stop it. + # # This seeds the minimum-run clock only. The draw-grace clock # is a separate attribute, set solely when this controller # issues a start of its own, and it must stay unset here: a # charge that was already running was never ours to judge, and # a charger sitting in waiting_for_ev at 0 W at boot would # otherwise arm an hour-long back-off on a healthy charge. - if state.charging and self._started_at is None: - self._started_at = now - MIN_RUN_SECONDS + if not state.charging: + self._charge_seeded = False + elif not self._charge_seeded: + self._charge_seeded = True + if self._started_at is None: + self._started_at = now - MIN_RUN_SECONDS ``` -Add `MIN_RUN_SECONDS` to the `from .solar import (...)` block. +Add `self._charge_seeded = False` to `__init__`, and `MIN_RUN_SECONDS` +to the `from .solar import (...)` block. -- [ ] **Step 4: Make the select remember its mode** +The inner `if self._started_at is None` is what protects a start this +controller issued: `_carry_out` has already set the real time, and this +must not overwrite it with one ten minutes in the past. + +- [ ] **Step 9: Make the select remember its mode** In `custom_components/daze/select.py`, change `DazeSolarControlSelect` to also inherit `RestoreEntity`: @@ -2562,26 +3787,67 @@ And restore in `async_added_to_hass`, after the existing `super()` call: self._controller.mode = SolarMode(last.state) ``` -Also make `available` account for an unsupported setup: +Also replace `available`, and the refusal Task 7 put in +`async_select_option`, with the one property. Both asked a narrower +question — "are the sensors set?" — and the answer is now "is there any +reason this cannot run?": ```python @property def available(self) -> bool: - """Usable only with both sensors and a supply we can follow.""" - return ( - bool(self._controller.configured) - and self._controller.unsupported_reason is None - ) + """Usable only where solar control could actually run.""" + return self._controller.unsupported_reason is None +``` + +```python + async def async_select_option(self, option: str) -> None: + """Set the mode, refusing to arm where it cannot work. + + `available` is a hint for the dashboard. A service call or an + automation arrives here whatever the entity reports, so the + refusal has to be enforced in the method that acts — and + raised, not logged, because the caller asked for something and + is entitled to know it did not happen, and why. + """ + from .solar_controller import SolarMode + + if option != "off": + reason = self._controller.unsupported_reason + if reason is not None: + raise HomeAssistantError( + f"Solar control cannot be armed: {reason}." + ) + + self._controller.mode = SolarMode(option) + self.async_write_ha_state() ``` -- [ ] **Step 5: Run the tests to verify they pass** +Turning it **off** is never refused. A control that cannot be switched +off because the charger is in eco mode would be worse than the problem. -Run: `python3 tests/test_solar_controller.py` -Expected: PASS, `0 failed`, with three more tests than the suite had +- [ ] **Step 10: Run the tests to verify they pass** + +Run: +```bash +python3 tests/test_solar_controller.py +python3 tests/test_entities.py +``` +Expected: PASS, `0 failed`, with nine more tests than the two files had before this task. The absolute count is deliberately not stated; see Task 5. -- [ ] **Step 6: Lint and full suite** +One existing test changes meaning and should be moved down a layer +while you are here: +`test_unknown_charging_status_does_not_assume_zero_draw` runs a full +tick with `coordinator.data = {}`, which now stops at "the charger has +not reported yet" before it ever reads a sensor. Its assertion still +passes, for a reason that has nothing to do with what it is named +after. Call `controller._read_surplus()` directly instead and assert +that it returns `None`, the same way +`test_no_coordinator_data_reads_as_not_reachable` already tests its +own layer. + +- [ ] **Step 11: Lint and full suite** Run: ```bash @@ -2590,19 +3856,30 @@ python3 tests/run_all.py ``` Expected: `All checks passed!`, 0 failures. -- [ ] **Step 7: Commit** +- [ ] **Step 12: Commit** ```bash -git add custom_components/daze/solar_controller.py custom_components/daze/select.py tests/test_solar_controller.py -git commit -m "feat: refuse unfollowable supplies, and survive a restart +git add custom_components/daze/const.py custom_components/daze/config_flow.py custom_components/daze/strings.json custom_components/daze/translations/it.json custom_components/daze/__init__.py custom_components/daze/solar_controller.py custom_components/daze/select.py tests/test_solar_controller.py tests/test_entities.py +git commit -m "feat: refuse setups solar control cannot follow, survive a restart A three-phase supply feeding a single-phase charger reports surplus -netted across phases, most of which the charger cannot reach. +netted across phases, most of which the charger cannot reach. The Daze +payload does not say how many phases feed the house — its one phase +field describes the charger — so the options flow asks, with no +default, and solar control will not arm until it is answered. + +One property now answers 'can this run, and if not, why not', for the +select's availability, its refusal to arm, the log line and the docs. +Eco mode and a configured charger schedule are part of that answer, as +the spec asks; previously they produced a decision of 'nothing' logged +at debug and no other sign. Timers begin at zero after a Home Assistant restart, so a charge that was already running would read as having no elapsed run time and could be stopped moments after boot. A running charge now seeds its own -start time. +start time, once per charge rather than once per tick: re-seeding it +after a stop that was queued but never landed would re-issue that stop +every two minutes until the hourly backstop tripped. The control also remembers its mode across a restart. @@ -2615,9 +3892,10 @@ Co-Authored-By: Claude Opus 5 " **Files:** - Modify: `README.md` - Modify: `docs/solar-surplus-charging.md` +- Modify: `custom_components/daze/manifest.json` **Interfaces:** -- Consumes: the entity names from Task 6. +- Consumes: the entity names from Task 7 and the refusals from Task 9. - Produces: nothing code depends on. - [ ] **Step 1: Document the entities in the README** @@ -2625,16 +3903,23 @@ Co-Authored-By: Claude Opus 5 " In `README.md`, add to the Controls table: ```markdown -| Select | `select.daze_homett_solar_control` | Solar control | `off` / `simulate` / `active` | -| Number | `number.daze_homett_solar_reserve` | Solar reserve | Watts to leave for the house before the car gets any | +| Select | `select.daze_solar_control` | Solar control | `off` / `simulate` / `active` | +| Number | `number.daze_solar_reserve` | Solar reserve | Watts to leave for the house before the car gets any | ``` And to the Sensors table: ```markdown -| `sensor.daze_homett_solar_surplus` | Solar surplus | `power` | `measurement` | W | +| `sensor.daze_solar_surplus` | Solar surplus | `power` | `measurement` | W | ``` +Every other row in those tables uses the bare `daze_` prefix — +`number.daze_max_charging_current`, `select.daze_operation_mode` — and +these must match, or an automation copied out of the README addresses +an entity that does not exist. Confirm the real object IDs on a live +install before publishing: the prefix follows the device name, and a +renamed device changes it. + - [ ] **Step 2: Add a Solar control section to the README** Insert before `## Automation Examples`: @@ -2647,7 +3932,8 @@ the limit as production and load change, and stopping when there is not enough surplus to charge at all. 1. In the integration's options, pick your **grid import** and **grid - export** power sensors. + export** power sensors, and say whether your **grid supply** is + single-phase or three-phase. 2. Set **Solar control** to `simulate`. It decides and logs but sends nothing. 3. Leave it for a day. The select's attributes show the surplus it sees @@ -2659,7 +3945,31 @@ when surplus falls below that it stops rather than topping up from the grid. Changing the charging limit yourself — from the dashboard, or from your -own automation — turns solar control off. It does not fight you. +own automation — turns solar control off. Starting or stopping the +charge by hand does the same. It does not fight you. + +### When the control is unavailable + +Solar control refuses to arm rather than guess, and says why in the +log (`Solar control cannot run: …`). It is unavailable when: + +- **Both grid sensors are not set.** It has nothing to measure. +- **The grid supply has not been declared.** The charger cannot tell + the integration how many phases feed the house, so you have to. A + three-phase meter reports surplus added up across all three phases; + a single-phase charger can only use one of them, so following that + figure would load one phase with all three phases' surplus. For the + same reason, a **three-phase supply with a single-phase charger is + refused outright** — see the YAML guide below if that is your setup. +- **The charger's own eco mode is on, or it has a schedule set.** + Something else is already deciding when the car charges, and two + controllers fighting over one charger is worse than either alone. + +### The reserve + +**Solar reserve** is watts to leave for the house before the car gets +any: set it to 500 and the car is only offered surplus above 500 W. It +is saved with the integration's settings and survives a restart. For a version you build and tune yourself, see [docs/solar-surplus-charging.md](docs/solar-surplus-charging.md). @@ -2668,7 +3978,9 @@ For a version you build and tune yourself, see - [ ] **Step 3: Bump the version** Modify `custom_components/daze/manifest.json`, setting `"version"` to -`"0.2.0"`. This is the only task that touches it. +`"0.2.0"`. This is the only task that touches it, and Step 5 commits it +— an edit left in the working tree would ship a release announcing a +version the manifest does not carry. Run: `python3 -c "import json; print(json.load(open('custom_components/daze/manifest.json'))['version'])"` Expected: `0.2.0` @@ -2690,41 +4002,49 @@ Run: `python3 tests/run_all.py` Expected: 0 failures. ```bash -git add README.md docs/solar-surplus-charging.md +git add README.md docs/solar-surplus-charging.md custom_components/daze/manifest.json git commit -m "docs: document solar control Includes the simulate-first procedure, because a feature that starts -and stops the car should be watched for a day before it is trusted. +and stops the car should be watched for a day before it is trusted, +and what makes the control unavailable, because a feature that refuses +to arm has to say why somewhere a user will look. Co-Authored-By: Claude Opus 5 " ``` -- [ ] **Step 6: Push, and move the tag** - -```bash -set -a; . ./.env; set +a -ASK=$(mktemp /tmp/askpass.XXXXXX); chmod 700 "$ASK" -printf '#!/bin/sh\ncase "$1" in\n *sername*) printf "%%s\\n" "$GIT_USER" ;;\n *assword*) printf "%%s\\n" "$GITHUB_PAT" ;;\nesac\n' > "$ASK" -GIT_USER=tarrinho GIT_TERMINAL_PROMPT=0 GIT_ASKPASS="$ASK" \ - git -c credential.helper= push origin main 2>&1 | sed 's/gh[pousr]_[A-Za-z0-9_]*/[REDACTED]/g' | tail -1 -git tag -f -a v0.1.6 -m "Release 0.1.6 - -Re-pointed at the current code. This tag tracks main. -" >/dev/null -GIT_USER=tarrinho GIT_TERMINAL_PROMPT=0 GIT_ASKPASS="$ASK" \ - git -c credential.helper= push --force origin v0.1.6 2>&1 | sed 's/gh[pousr]_[A-Za-z0-9_]*/[REDACTED]/g' | tail -1 -rm -f "$ASK"; unset GITHUB_PAT GIT_USER -``` - -Expected: both pushes report success, and `main` and `v0.1.6` point at the same commit. +This is the last step of the plan. Do not push, tag or force-push: +this work is on the `solar-control` branch, `main` is several tasks +behind it, and publishing is the operator's call once the branch has +been reviewed and merged. A push from here would move the release tag +onto a `main` that does not contain the feature. --- ## After the plan Solar control ships **off**. Nothing changes for an existing install -until the user picks two sensors and moves the select. +until the user picks two sensors, declares their supply, and moves the +select. The first real validation is a day in `simulate` against actual production. That is the step this plan cannot do, and the one that decides whether the constants in `solar.py` are right for the site. + +Then the operator reviews the branch, merges it, and moves the release +tag. No task does that. + +Two things are deliberately left undone and are worth a follow-up: + +- `sensor_catalog.py:243` labels `evseIsThreePhase` "Three-Phase + Supply", while `payload.py:308` reads the same field as the + *charger's* phase count. One of the two is wrong. Renaming the + entity breaks existing dashboards, so it is not folded into this + work; Task 9's option is named for the supply explicitly to avoid + inheriting the confusion. +- A stop that is queued for the background retry and never lands + leaves the charge running with solar control unable to re-issue it + until the charge ends by other means. That is the spec's rule ("hand + to the existing background retry; do not retry here") working as + written, and re-issuing every tick is worse, but neither is + obviously right and the case deserves its own decision. diff --git a/docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md b/docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md index 8f61c9b..c5bbe40 100644 --- a/docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md +++ b/docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md @@ -56,6 +56,16 @@ rather than restated. following it would overload one. Rather than be quietly wrong, solar control refuses to arm in that combination; see Error handling. + The supply is **declared by the user, not detected.** The Daze + payload has one phase field, `evseIsThreePhase`, and it describes the + charger — `payload.min_charging_current` already reads it that way to + derive the current floor. Nothing in the payload describes the supply + feeding the charger, so the options flow asks, with no default. Until + it is answered solar control refuses to arm: an unanswered question + is not evidence of a single-phase supply, and the guess that costs + something is the one that follows a netted three-phase figure with a + single-phase charger. + --- ## Architecture @@ -95,11 +105,23 @@ That makes the manual-override rule mechanical: any call arriving at `async_set_native_value` is by definition external, so solar mode disarms. There is no "was that me?" flag to get wrong. +The same rule covers the charge control switch and the start, stop and +set-current services, for the same mechanical reason: solar control +reaches the charger only through the API client, so a command arriving +at an entity or a service did not come from it. Without that, a user +pressing Stop is overruled by the next tick, which sees a connected car +and unchanged surplus and starts the charge again. + A consequence worth stating plainly: a user's **own automation** calling `number.set_value` also disarms solar mode. This is intended — an automation is external control — but it is surprising if undocumented. +Disarming clears the episode's clocks as well as the mode. A start +solar control issued is no longer its business once someone else has +taken over, and a draw-grace or back-off mark left behind is read +against a different situation hours later. + ### Entities | Entity | Purpose | @@ -118,14 +140,20 @@ The select restores its state across restarts, and defaults to ### Configuration -The existing options flow gains two entity pickers: the grid import -sensor and the grid export sensor. Both are required before solar -control can leave `off`. +The existing options flow gains two entity pickers — the grid import +sensor and the grid export sensor — and one question: is the grid +supply single-phase or three-phase? All three are required before solar +control can leave `off`, and that requirement is enforced where it can +be explained, in the control that arms it, rather than by making the +fields mandatory in a form the user may be opening for another reason. Timings are constants rather than options. They are derived from measured charger behaviour, not preference, and exposing them invites misconfiguration of a feature that drives hardware. The reserve is the -one genuinely site-specific value, so it is an entity. +one genuinely site-specific value, so it is an entity — stored in the +config entry's options as the entity is written, because a reserve held +only in memory returns to 0 W on every restart, and 0 W means the house +gets nothing before the car does. --- @@ -173,12 +201,28 @@ resulting errors blamed the cloud service rather than the power supply. ### Asymmetric timing -A drop below the floor is evaluated **immediately** on a sensor update, -bypassing the tick. Everything else waits for the next tick. +A drop below the floor is noticed **immediately** on a sensor update, +and it is the drop itself that starts the stop delay. Everything else +waits for the next tick. Unused cheap power costs nothing; imported expensive power is exactly -what pure-solar mode exists to avoid. A fixed tick would import for up -to two minutes after every collapse. +what pure-solar mode exists to avoid. + +The figure that decides *what to do* is the smoothed one, and it is +minutes behind a real collapse: a five-minute average of a supply that +has just fallen to nothing takes several samples to admit it. The stop +delay is ten minutes from the moment the surplus was last above the +floor — so if that moment is taken from the average rather than from +the reading, those minutes are added to the ten, and the car imports at +up to the charger's ceiling throughout. Taking it from the raw reading +is worth about four minutes of avoided import per collapse. + +What this is *not* is a way to stop sooner than the stop delay. Running +the decision early saves at most one tick, and a fast path that runs on +every sensor update costs far more than it saves: the tick is the only +thing bounding how often the charger is written to, so anything +bypassing it needs a latch of its own — once per collapse, and never +more often than the tick would have run. ### The car that will not draw @@ -206,6 +250,14 @@ charger already charging at startup is treated as having satisfied its minimum run time, and surplus timers begin accumulating from the first reading rather than assuming the threshold was only just crossed. +Once per charge, though, not on every cycle. The minimum-run clock is +also cleared when a stop is issued, and a stop can be accepted for +retry without reaching the charger; seeding it again on the next cycle +would re-issue that stop, and keep re-issuing it. The mark is seeded +when a charge is first observed and released when the charge is +observed to end, so the next charge — including one started by hand — +is seeded in its turn. + ### Constants | Name | Default | Why | @@ -234,7 +286,8 @@ reading rather than assuming the threshold was only just crossed. | Command still pending | Skip the cycle entirely | | Vendor eco mode enabled | Refuse to arm; explain why | | Charger schedule configured | Refuse to arm; explain why | -| Three-phase supply, single-phase charger | Refuse to arm; explain why | +| Three-phase supply (declared), single-phase charger | Refuse to arm; explain why | +| Supply phase count not declared | Refuse to arm; ask for it. Not detectable from the payload | | Car does not draw after a start | Back off; do not retry until the interval expires | | Rate limit reached | Skip, log at warning, resume next hour | From 39052a02d3be188eb979ce1f6c0dbbb1e12c3179 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 13:15:50 +0100 Subject: [PATCH 63/82] docs: split Task 9 into the refusals and the restart Task 9 had grown to about 670 lines, within sixty of Task 4, which produced three Critical findings under review. Large tasks are where this plan's defects concentrate, and one review gate over that much text is what let them through. Task 9 now covers the supply declaration and the refusals, Task 10 covers surviving a restart, and documentation becomes Task 11. Four comments in the controller and its tests that credited the seeding to Task 9 are repointed to Task 10. Co-Authored-By: Claude Opus 5 --- custom_components/daze/solar_controller.py | 4 +- .../plans/2026-09-29-solar-surplus-control.md | 511 +++++++++++------- tests/test_solar_controller.py | 4 +- 3 files changed, 307 insertions(+), 212 deletions(-) diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index 3496f5e..b71678b 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -134,7 +134,7 @@ def __init__( self._below_since: float | None = None # The minimum-run clock: how long ago the current charge # began, consumed by decide() via seconds_since_start. Not - # necessarily a start this controller issued — Task 9 seeds + # necessarily a start this controller issued — Task 10 seeds # this from a charge already running when the controller # starts, so a healthy charge is not stopped moments after # boot. Because of that, this must stay agnostic to who or @@ -464,7 +464,7 @@ def _check_ignored_start(self, now: float, car_connected: bool) -> None: is the minimum-run clock ``decide()`` consumes, and can be seeded from a charge the controller did not itself start (a charge already running when Home Assistant restarts — see - Task 9). Judging that against a start that never happened + Task 10). Judging that against a start that never happened would arm an hour-long back-off on a perfectly healthy charge the moment it passes through the wait-for-EV state every start goes through. Only a start this method's own caller diff --git a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md index 45457c8..ee9a04d 100644 --- a/docs/superpowers/plans/2026-09-29-solar-surplus-control.md +++ b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md @@ -1944,8 +1944,8 @@ In `custom_components/daze/solar_controller.py`, add to `SolarController`: `_collapsed_since` is Task 8's; if Task 8 has not run yet, leave that line out and Task 8 will add it with the attribute. -Clearing `_started_at` here is safe because Task 9 seeds it again from -an observed charge, once per charging episode. If Task 9's seeding is +Clearing `_started_at` here is safe because Task 10 seeds it again from +an observed charge, once per charging episode. If Task 10's seeding is ever removed, this line must go with it, or a charge still running when solar control is re-armed can never be stopped: `_elapsed(None)` is `0.0`, which reads as "just started" for ever. @@ -3215,7 +3215,7 @@ Co-Authored-By: Claude Opus 5 " --- -### Task 9: Remaining guards, and surviving a restart +### Task 9: The supply declaration, and the refusals **Files:** - Modify: `custom_components/daze/const.py` @@ -3225,8 +3225,8 @@ Co-Authored-By: Claude Opus 5 " - Modify: `custom_components/daze/__init__.py` (one more keyword on the controller) - Modify: `custom_components/daze/solar_controller.py` - Modify: `custom_components/daze/select.py` (the solar select) -- Test: `tests/test_solar_controller.py` (append before `_main`) -- Test: `tests/test_entities.py` (append before `_main`) +- Test: `tests/test_solar_controller.py` (append before `_main`, and change `build`) +- Test: `tests/test_entities.py` (the `_solar_select` double from Task 7) **Interfaces:** - Consumes: `SolarController` from Task 4, `DazeSolarControlSelect` from Task 7. @@ -3241,10 +3241,10 @@ Co-Authored-By: Claude Opus 5 " not". The select uses it for availability and for refusing to arm, the tick uses it to stand down, and the README quotes it. -Four things the spec requires that nothing yet implements: refusing a -supply the charger cannot follow, standing down for the charger's own -eco mode and schedules, surviving a Home Assistant restart without -stopping a healthy charge, and remembering the mode. +Surviving a restart is **Task 10**. This task is the refusals: three +things the spec requires that nothing yet implements — refusing a supply +the charger cannot follow, refusing one that has not been described at +all, and standing down for the charger's own eco mode and schedules. **Why the supply is declared rather than detected.** The Daze payload has exactly one phase field, `evseIsThreePhase`, and `payload.py:308` @@ -3283,9 +3283,7 @@ def build( passing `supply_phases=supply_phases` to the constructor. -Then append to `tests/test_solar_controller.py`, before `_main`. Add -`import time` to the file's imports if it is not already there — the -seeding test below reads `time.monotonic()`: +Then append to `tests/test_solar_controller.py`, before `_main`: ```python def test_an_undeclared_supply_refuses_to_run() -> None: @@ -3348,127 +3346,10 @@ def test_a_charger_schedule_refuses_to_arm() -> None: assert controller.unsupported_reason is not None assert "schedule" in controller.unsupported_reason - - -def test_a_charge_already_running_counts_as_having_run() -> None: - """Timers start at zero after a restart. Without seeding, an - unelapsed minimum run time could stop a healthy charge moments - after boot.""" - controller, _, _ = build() - controller.mode = controller_module.SolarMode.ACTIVE - - asyncio.run(controller.async_tick()) - - # Assert how far back the mark was seeded, not merely that one exists. - # Seeding it to the present moment would satisfy "is not None" while - # leaving the charge unstoppable for the next ten minutes, which is the - # bug this seeding exists to prevent. - assert controller._started_at is not None - elapsed = time.monotonic() - controller._started_at - assert elapsed >= solar.MIN_RUN_SECONDS, ( - "a charge already running must count as having served its minimum " - f"run time, but the mark was seeded only {elapsed:.0f}s back" - ) - - -def test_a_stop_that_could_not_be_sent_is_not_re_issued_every_tick() -> None: - """_carry_out clears the minimum-run clock after a stop it sent, - and "sent" includes one only queued for the background retry — - where the charger is still charging. Seeding that clock again on - the next tick makes decide() return STOP again, and again every - two minutes, until the hourly backstop trips forty minutes later. - Handing a stuck link to the background retry and leaving it there - is the spec's own rule; this is why the seeding is once per charge - and not once per tick. - """ - controller, coordinator, hass = build() - controller.mode = controller_module.SolarMode.ACTIVE - - clock = [24_000_000.0] - original_monotonic = controller_module.time.monotonic - controller_module.time.monotonic = lambda: clock[0] - try: - controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 - hass.states.set( - "sensor.grid_import", "3000", {"unit_of_measurement": "W"} - ) - hass.states.set( - "sensor.grid_export", "0", {"unit_of_measurement": "W"} - ) - asyncio.run(controller.async_tick()) # starts the below-floor timer - - clock[0] += solar.STOP_DELAY_SECONDS + 1 - - async def _rpc_failure(serial: str, attempts: int = 8) -> dict: - coordinator.api_client.calls.append(("stop", serial)) - raise api_module.ApiCommandRejectedError( - "unreachable", code=api_module.COMMAND_ERROR_CODE_RPC_FAILURE - ) - - coordinator.api_client.async_stop_charge = _rpc_failure - asyncio.run(controller.async_tick()) - - for _ in range(3): - clock[0] += solar.TICK_SECONDS - asyncio.run(controller.async_tick()) - finally: - controller_module.time.monotonic = original_monotonic - - stops = len( - [call for call in coordinator.api_client.calls if call[0] == "stop"] - ) - assert stops == 1, f"the queued stop was re-issued: {stops} attempts" - - -def test_a_charge_that_starts_later_is_seeded_in_its_turn() -> None: - """Once per charging episode, not once per lifetime. - - A charge the user starts by hand an hour from now has also been - running longer than we have been watching it. If the flag never - reset, that charge's minimum-run clock would read as zero for ever - and solar control could never stop it — the mirror image of the - bug the seeding exists to fix. - """ - controller, coordinator, _ = build(NOT_CHARGING_DATA) - controller.mode = controller_module.SolarMode.SIMULATE - - asyncio.run(controller.async_tick()) - assert controller._started_at is None - - coordinator.data = dict(CHARGING_DATA) - asyncio.run(controller.async_tick()) - - assert controller._started_at is not None ``` -And append to `tests/test_entities.py`, before `_main`: - -```python -def test_the_solar_select_restores_its_mode() -> None: - """The spec asks for restoration across a restart by name. - - Without it every Home Assistant restart silently disarms solar - control: the select comes back "off", the car stops following the - sun, and nothing says so. - """ - entity, controller = _solar_select(configured=True) - - class LastState: - state = "active" - - async def _last_state() -> Any: - return LastState() - - entity.async_get_last_state = _last_state - - asyncio.run(entity.async_added_to_hass()) - - assert controller.mode is not None - assert controller.mode.value == "active" -``` - -`_solar_select` is Task 7's helper. Give its double an -`unsupported_reason` now that the select reads one: +`_solar_select` is Task 7's helper in `tests/test_entities.py`. Give its +double an `unsupported_reason` now that the select reads one: ```python @property @@ -3707,13 +3588,265 @@ the `NOTHING` branch's log line: Add `MAX_COMMANDS_PER_HOUR` to the `from .solar import (...)` block. -- [ ] **Step 8: Seed the minimum-run clock, once per charge** +- [ ] **Step 8: Make the select read the one property** + +In `custom_components/daze/select.py`, replace `available`, and the +refusal Task 7 put in `async_select_option`, with the one property. +Both asked a narrower question — "are the sensors set?" — and the +answer is now "is there any reason this cannot run?": + +```python + @property + def available(self) -> bool: + """Usable only where solar control could actually run.""" + return self._controller.unsupported_reason is None +``` + +```python + async def async_select_option(self, option: str) -> None: + """Set the mode, refusing to arm where it cannot work. + + `available` is a hint for the dashboard. A service call or an + automation arrives here whatever the entity reports, so the + refusal has to be enforced in the method that acts — and + raised, not logged, because the caller asked for something and + is entitled to know it did not happen, and why. + """ + from .solar_controller import SolarMode + + if option != "off": + reason = self._controller.unsupported_reason + if reason is not None: + raise HomeAssistantError( + f"Solar control cannot be armed: {reason}." + ) + + self._controller.mode = SolarMode(option) + self.async_write_ha_state() +``` + +Turning it **off** is never refused. A control that cannot be switched +off because the charger is in eco mode would be worse than the problem. + +- [ ] **Step 9: Run the tests to verify they pass** + +Run: +```bash +python3 tests/test_solar_controller.py +python3 tests/test_entities.py +``` +Expected: PASS, `0 failed`, with six more tests than the two files had +before this task. The absolute count is deliberately not stated; see +Task 5. + +One existing test now passes for a reason other than the one it is +named after: `test_unknown_charging_status_does_not_assume_zero_draw` +runs a full tick with `coordinator.data = {}`, which from this task on +stops at "the charger has not reported yet" before it ever reads a +sensor. Its assertion still holds, so the suite stays green. **Task 10 +moves it down a layer**; leave it alone here rather than half-fixing it +in two places. + +- [ ] **Step 10: Lint and full suite** + +Run: +```bash +ruff check custom_components/daze/ tests/ +python3 tests/run_all.py +``` +Expected: `All checks passed!`, 0 failures. + +- [ ] **Step 11: Commit** + +```bash +git add custom_components/daze/const.py custom_components/daze/config_flow.py custom_components/daze/strings.json custom_components/daze/translations/it.json custom_components/daze/__init__.py custom_components/daze/solar_controller.py custom_components/daze/select.py tests/test_solar_controller.py tests/test_entities.py +git commit -m "feat: refuse setups solar control cannot follow + +A three-phase supply feeding a single-phase charger reports surplus +netted across phases, most of which the charger cannot reach. The Daze +payload does not say how many phases feed the house — its one phase +field describes the charger — so the options flow asks, with no +default, and solar control will not arm until it is answered. + +One property now answers 'can this run, and if not, why not', for the +select's availability, its refusal to arm, and the log line. Eco mode +and a configured charger schedule are part of that answer, as the spec +asks; previously they produced a decision of 'nothing' logged at debug +and no other sign. Availability alone was never enough: a service call +reaches async_select_option whatever the entity reports. + +The rate limit is logged at warning rather than debug. It is a backstop +against bugs, so if it is what is holding the charger back, that is not +a debug-level fact. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 10: Surviving a restart + +**Files:** +- Modify: `custom_components/daze/solar_controller.py` +- Modify: `custom_components/daze/select.py` (the solar select) +- Test: `tests/test_solar_controller.py` (append before `_main`) +- Test: `tests/test_entities.py` (append before `_main`) + +**Interfaces:** +- Consumes: `SolarController` from Task 4, `DazeSolarControlSelect` from + Task 7, `unsupported_reason` from Task 9. +- Produces: no new public surface. `SolarController` gains one private + attribute, `_charge_seeded`, and the select inherits `RestoreEntity`. + +On a Home Assistant restart the controller's timers begin at zero. If +the car was already charging, an unelapsed minimum run time reads as a +charge that has only just begun, and the select comes back `off` +whatever the user had chosen. Both are silent: the car simply stops +following the sun, and nothing says why. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_solar_controller.py`, before `_main`. Add +`import time` to the file's imports if it is not already there — the +first test reads `time.monotonic()`: + +```python +def test_a_charge_already_running_counts_as_having_run() -> None: + """Timers start at zero after a restart. Without seeding, an + unelapsed minimum run time could stop a healthy charge moments + after boot.""" + controller, _, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + asyncio.run(controller.async_tick()) + + # Assert how far back the mark was seeded, not merely that one exists. + # Seeding it to the present moment would satisfy "is not None" while + # leaving the charge unstoppable for the next ten minutes, which is the + # bug this seeding exists to prevent. + assert controller._started_at is not None + elapsed = time.monotonic() - controller._started_at + assert elapsed >= solar.MIN_RUN_SECONDS, ( + "a charge already running must count as having served its minimum " + f"run time, but the mark was seeded only {elapsed:.0f}s back" + ) + + +def test_a_stop_that_could_not_be_sent_is_not_re_issued_every_tick() -> None: + """_carry_out clears the minimum-run clock after a stop it sent, + and "sent" includes one only queued for the background retry — + where the charger is still charging. Seeding that clock again on + the next tick makes decide() return STOP again, and again every + two minutes, until the hourly backstop trips forty minutes later. + Handing a stuck link to the background retry and leaving it there + is the spec's own rule; this is why the seeding is once per charge + and not once per tick. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [24_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + hass.states.set( + "sensor.grid_import", "3000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_tick()) # starts the below-floor timer + + clock[0] += solar.STOP_DELAY_SECONDS + 1 + + async def _rpc_failure(serial: str, attempts: int = 8) -> dict: + coordinator.api_client.calls.append(("stop", serial)) + raise api_module.ApiCommandRejectedError( + "unreachable", code=api_module.COMMAND_ERROR_CODE_RPC_FAILURE + ) + + coordinator.api_client.async_stop_charge = _rpc_failure + asyncio.run(controller.async_tick()) + + for _ in range(3): + clock[0] += solar.TICK_SECONDS + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + stops = len( + [call for call in coordinator.api_client.calls if call[0] == "stop"] + ) + assert stops == 1, f"the queued stop was re-issued: {stops} attempts" + + +def test_a_charge_that_starts_later_is_seeded_in_its_turn() -> None: + """Once per charging episode, not once per lifetime. + + A charge the user starts by hand an hour from now has also been + running longer than we have been watching it. If the flag never + reset, that charge's minimum-run clock would read as zero for ever + and solar control could never stop it — the mirror image of the + bug the seeding exists to fix. + """ + controller, coordinator, _ = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.SIMULATE + + asyncio.run(controller.async_tick()) + assert controller._started_at is None + + coordinator.data = dict(CHARGING_DATA) + asyncio.run(controller.async_tick()) + + assert controller._started_at is not None +``` + +And append to `tests/test_entities.py`, before `_main`: + +```python +def test_the_solar_select_restores_its_mode() -> None: + """The spec asks for restoration across a restart by name. + + Without it every Home Assistant restart silently disarms solar + control: the select comes back "off", the car stops following the + sun, and nothing says so. + """ + entity, controller = _solar_select(configured=True) + + class LastState: + state = "active" + + async def _last_state() -> Any: + return LastState() + + entity.async_get_last_state = _last_state + + asyncio.run(entity.async_added_to_hass()) + + assert controller.mode is not None + assert controller.mode.value == "active" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: +```bash +python3 tests/test_solar_controller.py +python3 tests/test_entities.py +``` +Expected: the three controller tests fail on `_started_at` being `None` +or the stop being re-issued; the select test fails with +`AttributeError: ... 'async_get_last_state'` until `RestoreEntity` is +inherited. + +- [ ] **Step 3: Seed the minimum-run clock, once per charge** Put this immediately after `self._track_thresholds(state, now, surplus)` and **outside** the `if self._mode is SolarMode.ACTIVE:` block that -wraps `self._check_ignored_start(now)`. Seeding must happen in every -mode: a `simulate` dry run of a charge that is already running has to -preview the stop, and under the `ACTIVE` gate it would instead report +wraps the `self._check_ignored_start(...)` call. Seeding must happen in +every mode: a `simulate` dry run of a charge that is already running has +to preview the stop, and under the `ACTIVE` gate it would instead report "the minimum run time has not elapsed" forever: ```python @@ -3760,7 +3893,7 @@ The inner `if self._started_at is None` is what protects a start this controller issued: `_carry_out` has already set the real time, and this must not overwrite it with one ten minutes in the past. -- [ ] **Step 9: Make the select remember its mode** +- [ ] **Step 4: Make the select remember its mode** In `custom_components/daze/select.py`, change `DazeSolarControlSelect` to also inherit `RestoreEntity`: @@ -3787,67 +3920,35 @@ And restore in `async_added_to_hass`, after the existing `super()` call: self._controller.mode = SolarMode(last.state) ``` -Also replace `available`, and the refusal Task 7 put in -`async_select_option`, with the one property. Both asked a narrower -question — "are the sensors set?" — and the answer is now "is there any -reason this cannot run?": - -```python - @property - def available(self) -> bool: - """Usable only where solar control could actually run.""" - return self._controller.unsupported_reason is None -``` - -```python - async def async_select_option(self, option: str) -> None: - """Set the mode, refusing to arm where it cannot work. +Restoring writes straight to the controller rather than through +`async_select_option`, so it cannot raise at startup: a setup that is +temporarily unsupported — the charger has not polled yet, say — must +come back as the user left it and be refused later by the guard in the +tick, not lose the setting because of a race with the first refresh. - `available` is a hint for the dashboard. A service call or an - automation arrives here whatever the entity reports, so the - refusal has to be enforced in the method that acts — and - raised, not logged, because the caller asked for something and - is entitled to know it did not happen, and why. - """ - from .solar_controller import SolarMode +- [ ] **Step 5: Move one test back to the layer it tests** - if option != "off": - reason = self._controller.unsupported_reason - if reason is not None: - raise HomeAssistantError( - f"Solar control cannot be armed: {reason}." - ) - - self._controller.mode = SolarMode(option) - self.async_write_ha_state() -``` - -Turning it **off** is never refused. A control that cannot be switched -off because the charger is in eco mode would be worse than the problem. +`test_unknown_charging_status_does_not_assume_zero_draw` runs a full +tick with `coordinator.data = {}`. Since Task 9 that tick stops at "the +charger has not reported yet", before it ever reads a sensor, so the +test passes for a reason that has nothing to do with the unknown car +draw it is named after. Call `controller._read_surplus()` directly and +assert that it returns `None`, the same way +`test_no_coordinator_data_reads_as_not_reachable` already tests its own +layer. -- [ ] **Step 10: Run the tests to verify they pass** +- [ ] **Step 6: Run the tests to verify they pass** Run: ```bash python3 tests/test_solar_controller.py python3 tests/test_entities.py ``` -Expected: PASS, `0 failed`, with nine more tests than the two files had +Expected: PASS, `0 failed`, with four more tests than the two files had before this task. The absolute count is deliberately not stated; see Task 5. -One existing test changes meaning and should be moved down a layer -while you are here: -`test_unknown_charging_status_does_not_assume_zero_draw` runs a full -tick with `coordinator.data = {}`, which now stops at "the charger has -not reported yet" before it ever reads a sensor. Its assertion still -passes, for a reason that has nothing to do with what it is named -after. Call `controller._read_surplus()` directly instead and assert -that it returns `None`, the same way -`test_no_coordinator_data_reads_as_not_reachable` already tests its -own layer. - -- [ ] **Step 11: Lint and full suite** +- [ ] **Step 7: Lint and full suite** Run: ```bash @@ -3856,30 +3957,23 @@ python3 tests/run_all.py ``` Expected: `All checks passed!`, 0 failures. -- [ ] **Step 12: Commit** +- [ ] **Step 8: Commit** ```bash -git add custom_components/daze/const.py custom_components/daze/config_flow.py custom_components/daze/strings.json custom_components/daze/translations/it.json custom_components/daze/__init__.py custom_components/daze/solar_controller.py custom_components/daze/select.py tests/test_solar_controller.py tests/test_entities.py -git commit -m "feat: refuse setups solar control cannot follow, survive a restart +git add custom_components/daze/solar_controller.py custom_components/daze/select.py tests/test_solar_controller.py tests/test_entities.py +git commit -m "feat: survive a Home Assistant restart -A three-phase supply feeding a single-phase charger reports surplus -netted across phases, most of which the charger cannot reach. The Daze -payload does not say how many phases feed the house — its one phase -field describes the charger — so the options flow asks, with no -default, and solar control will not arm until it is answered. +Timers begin at zero after a restart, so a charge that was already +running would read as having no elapsed run time and could be stopped +moments after boot. A running charge now seeds its own start time. -One property now answers 'can this run, and if not, why not', for the -select's availability, its refusal to arm, the log line and the docs. -Eco mode and a configured charger schedule are part of that answer, as -the spec asks; previously they produced a decision of 'nothing' logged -at debug and no other sign. - -Timers begin at zero after a Home Assistant restart, so a charge that -was already running would read as having no elapsed run time and could -be stopped moments after boot. A running charge now seeds its own -start time, once per charge rather than once per tick: re-seeding it -after a stop that was queued but never landed would re-issue that stop -every two minutes until the hourly backstop tripped. +Once per charge rather than once per tick, and released when the charge +is observed to end. Re-seeding on every tick would re-issue a stop that +was queued but never landed, every two minutes until the hourly +backstop tripped; seeding only once per lifetime would strand the next +charge instead, with a clock that reads as zero for ever and can never +be stopped. This clock has now been wrong in both directions, which is +why it is tested in both. The control also remembers its mode across a restart. @@ -3887,7 +3981,7 @@ Co-Authored-By: Claude Opus 5 " ``` --- -### Task 10: Documentation +### Task 11: Documentation **Files:** - Modify: `README.md` @@ -3895,8 +3989,9 @@ Co-Authored-By: Claude Opus 5 " - Modify: `custom_components/daze/manifest.json` **Interfaces:** -- Consumes: the entity names from Task 7 and the refusals from Task 9. -- Produces: nothing code depends on. +- Consumes: the entity names from Task 7, the refusals from Task 9, and + the restored mode from Task 10. +- Produces: nothing code depends on. This is the last task. - [ ] **Step 1: Document the entities in the README** diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 4cdd0ea..8416c12 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -946,7 +946,7 @@ async def _rpc_failure(serial: str, attempts: int = 8) -> dict: def test_a_charge_not_issued_by_us_does_not_arm_the_backoff() -> None: """_started_at, the minimum-run clock, can be seeded from a charge - already running when the controller starts (Task 9 does this at + already running when the controller starts (Task 10 does this at boot, so a healthy charge is not stopped moments after restart). That charge was never issued by us, so a car sitting at 0 W must not be judged against a start that never happened — only @@ -958,7 +958,7 @@ def test_a_charge_not_issued_by_us_does_not_arm_the_backoff() -> None: controller, _, _ = build(data) controller.mode = controller_module.SolarMode.ACTIVE - # Simulate Task 9's boot-time seeding of the minimum-run clock + # Simulate Task 10's boot-time seeding of the minimum-run clock # from an already-running charge, with no start of ours behind it. controller._started_at = 0.0 assert controller._start_issued_at is None From 524063586cd23da71d5ab9947dd9a321279d776f Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 13:25:48 +0100 Subject: [PATCH 64/82] feat: wire solar control into the entry and disarm on override The controller is created with the entry and stopped when it unloads, alongside the coordinator's own timers. It is told the reserve from the entry's options rather than starting at zero, and a reserve-only options change no longer reloads the entry. Any limit change or charge toggle arriving through an entity or a service disarms solar control, because the controller writes through the API client and never through an entity. That makes the rule mechanical rather than a flag that has to be set and cleared correctly. Disarming clears the episode's clocks too, so re-arming hours later is not judged against a start nobody is waiting on. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/__init__.py | 67 ++++++++++++++++++++- custom_components/daze/coordinator.py | 5 ++ custom_components/daze/number.py | 24 ++++++++ custom_components/daze/solar_controller.py | 34 ++++++++++- custom_components/daze/switch.py | 17 ++++++ tests/test_entities.py | 56 +++++++++++++++++ tests/test_solar_controller.py | 70 +++++++++++++++++++++- 7 files changed, 270 insertions(+), 3 deletions(-) diff --git a/custom_components/daze/__init__.py b/custom_components/daze/__init__.py index 41c4121..68cf625 100644 --- a/custom_components/daze/__init__.py +++ b/custom_components/daze/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError @@ -15,9 +15,13 @@ CONF_DEVICE_PROFILE, CONF_EVSE_NAME, CONF_FIRMWARE_VERSION, + CONF_GRID_EXPORT_SENSOR, + CONF_GRID_IMPORT_SENSOR, CONF_NETWORK_UID, CONF_SERIAL_NUMBER, CONF_SOFTWARE_VERSION, + CONF_SOLAR_RESERVE, + DEFAULT_SOLAR_RESERVE, DOMAIN, PLATFORMS, SERVICE_SET_CHARGING_CURRENT, @@ -26,6 +30,7 @@ ) from .coordinator import DazeDataUpdateCoordinator, async_setup_coordinator from .payload import charger_offline_reason +from .solar_controller import SolarController if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -68,6 +73,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: configuration_url="https://webportal.dazeservice.com", ) + solar_controller = SolarController( + hass=hass, + coordinator=coordinator, + import_entity=entry.options.get(CONF_GRID_IMPORT_SENSOR), + export_entity=entry.options.get(CONF_GRID_EXPORT_SENSOR), + reserve_w=entry.options.get( + CONF_SOLAR_RESERVE, DEFAULT_SOLAR_RESERVE + ), + ) + # The entities reach the controller through the coordinator, which + # every one of them already holds. + coordinator.solar_controller = solar_controller + await solar_controller.async_start() + # Store coordinator and API client in hass.data for entity platforms hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { @@ -75,6 +94,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "api_client": coordinator.api_client, "serial_number": entry.data[CONF_SERIAL_NUMBER], "network_uid": entry.data[CONF_NETWORK_UID], + "solar_controller": solar_controller, + "reload_signature": _reload_signature(entry), } # Forward setup to entity platforms @@ -109,6 +130,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # alive but inert. entry_data = hass.data.get(DOMAIN, {}).get(entry.entry_id) if entry_data is not None: + controller = entry_data.get("solar_controller") + if controller is not None: + await controller.async_stop() + coordinator: DazeDataUpdateCoordinator = entry_data["coordinator"] coordinator.async_shutdown_timers() @@ -118,10 +143,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +def _reload_signature(entry: ConfigEntry) -> tuple[Any, Any]: + """Return the parts of an entry whose change needs a reload. + + The solar reserve is deliberately absent. It is applied live by the + controller, so rewriting it is not a reason to rebuild the entry; + everything else — credentials, the poll interval, the grid sensors + the controller is constructed with — is. + """ + options = { + key: value + for key, value in entry.options.items() + if key != CONF_SOLAR_RESERVE + } + return (dict(entry.data), options) + + async def _async_update_listener( hass: HomeAssistant, entry: ConfigEntry ) -> None: """Handle config entry update (e.g., re-auth token update).""" + entry_data = hass.data.get(DOMAIN, {}).get(entry.entry_id) + signature = _reload_signature(entry) + + if entry_data is not None and entry_data.get("reload_signature") == ( + signature + ): + _LOGGER.debug( + "Config entry %s changed in a way that needs no reload", + entry.entry_id, + ) + return + _LOGGER.debug("Config entry updated for %s — reloading", entry.entry_id) await hass.config_entries.async_reload(entry.entry_id) @@ -161,6 +214,10 @@ def _refuse_if_offline() -> None: async def _handle_start_charge(call: ServiceCall) -> None: """Start charging.""" + if coordinator.solar_controller is not None: + coordinator.solar_controller.disarm( + "the charge was started by a service call" + ) _refuse_if_offline() try: @@ -179,6 +236,10 @@ async def _handle_start_charge(call: ServiceCall) -> None: async def _handle_stop_charge(call: ServiceCall) -> None: """Stop charging.""" + if coordinator.solar_controller is not None: + coordinator.solar_controller.disarm( + "the charge was stopped by a service call" + ) _refuse_if_offline() try: @@ -198,6 +259,10 @@ async def _handle_stop_charge(call: ServiceCall) -> None: async def _handle_set_charging_current(call: ServiceCall) -> None: """Set the maximum charging current.""" current: int = call.data["current"] + if coordinator.solar_controller is not None: + coordinator.solar_controller.disarm( + "the charging current was set by a service call" + ) _refuse_if_offline() try: diff --git a/custom_components/daze/coordinator.py b/custom_components/daze/coordinator.py index 64e9540..463f1a8 100644 --- a/custom_components/daze/coordinator.py +++ b/custom_components/daze/coordinator.py @@ -118,6 +118,11 @@ def __init__( # until the next refresh. self._limit_state = OptimisticState() self._limit_listeners: list[Callable[[], None]] = [] + # Set by async_setup_entry. Declared here so every entity and + # service can read it directly: a getattr default would turn a + # wiring mistake into silent no-disarm, which is the failure + # this whole mechanism exists to prevent. + self.solar_controller: Any = None super().__init__( hass, diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index 913f71e..3732d11 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -174,6 +174,16 @@ def _clear_requested(self, message: str) -> None: self.coordinator.async_notify_limit_listeners() self._notify_error(message) + def _disarm_solar(self) -> None: + """Hand control back to the user. + + Solar control writes through the API client, so anything + arriving here came from a person or their automation. + """ + controller = self.coordinator.solar_controller + if controller is not None: + controller.disarm("the charging limit was set manually") + @callback def _handle_coordinator_update(self) -> None: """Stop showing the request once the charger reports it.""" @@ -197,6 +207,8 @@ async def async_set_native_value(self, value: float) -> None: ) return + self._disarm_solar() + # Stop here rather than spending a round trip on a value the # charger is known to reject. problem = validate_charging_current(int_value, self.coordinator.data) @@ -413,6 +425,8 @@ async def async_set_native_value(self, value: float) -> None: ) return + self._disarm_solar() + problem = validate_charging_current(milliamps, self.coordinator.data) if problem is not None: _LOGGER.info("Refusing to send %d W: %s", watts, problem) @@ -502,6 +516,16 @@ def _clear_requested(self, message: str) -> None: self.coordinator.async_notify_limit_listeners() self._notify_error(message) + def _disarm_solar(self) -> None: + """Hand control back to the user. + + Solar control writes through the API client, so anything + arriving here came from a person or their automation. + """ + controller = self.coordinator.solar_controller + if controller is not None: + controller.disarm("the charging limit was set manually") + @callback def _handle_coordinator_update(self) -> None: """Stop showing the request once the charger reports it.""" diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index b71678b..a1337c8 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -103,6 +103,7 @@ def __init__( coordinator: DazeDataUpdateCoordinator, import_entity: str | None, export_entity: str | None, + reserve_w: float = 0.0, ) -> None: """Initialise in the off state. @@ -115,6 +116,11 @@ def __init__( coordinator: Source of charger state and the API client. import_entity: Grid import power sensor, or None. export_entity: Grid export power sensor, or None. + reserve_w: Watts to leave for the house, restored from the + config entry's options. Held there rather than only in + memory: a reserve that returns to zero on every restart + gives the car everything the house was keeping, and + does it silently. """ self._hass = hass @@ -123,7 +129,7 @@ def __init__( self._export_entity = export_entity self._mode = SolarMode.OFF - self._reserve_w = 0.0 + self._reserve_w = max(0.0, float(reserve_w)) self._smoother = SurplusSmoother() self._last_decision: SolarDecision | None = None self._listeners: list[Callable[[], None]] = [] @@ -177,6 +183,32 @@ def mode(self, value: SolarMode) -> None: _LOGGER.info("Solar control set to %s", value.value) self._notify() + def disarm(self, reason: str) -> None: + """Turn solar control off because something else took over. + + Called when a limit change arrives through an entity or a + service, which by construction means it did not come from here. + + Every clock of the episode goes with the mode, not just the two + threshold timers. A start this controller issued is no longer + ours to judge the car against: left set, _start_issued_at is + read hours later, against a car that has long since finished, + and arms a 60-minute back-off for a start nobody is waiting on. + A back-off already armed goes too — it was armed to stop this + controller retrying, and the user has just taken over anyway. + """ + if self._mode is SolarMode.OFF: + return + + _LOGGER.info("Solar control disarmed: %s", reason) + self._mode = SolarMode.OFF + self._above_since = None + self._below_since = None + self._started_at = None + self._start_issued_at = None + self._backoff_until = 0.0 + self._notify() + @property def reserve_w(self) -> float: """Return the watts held back for the house.""" diff --git a/custom_components/daze/switch.py b/custom_components/daze/switch.py index 9ce77c5..623e41a 100644 --- a/custom_components/daze/switch.py +++ b/custom_components/daze/switch.py @@ -126,6 +126,19 @@ def _handle_coordinator_update(self) -> None: self._optimistic.settle(actual) super()._handle_coordinator_update() + def _disarm_solar(self) -> None: + """Hand control back to the user. + + Solar control starts and stops the charge through the API + client, so a toggle arriving here came from a person or their + automation. Without this the next tick reverses them: the car + is connected and the surplus is unchanged, so decide() returns + the opposite command within two minutes. + """ + controller = self.coordinator.solar_controller + if controller is not None: + controller.disarm("charging was started or stopped manually") + async def async_turn_on(self, **kwargs: Any) -> None: """Start charging on the wallbox.""" # Already charging — idempotent no-op @@ -135,6 +148,8 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) return + self._disarm_solar() + offline = charger_offline_reason(self.coordinator.data) if offline is not None: _LOGGER.info("Not sending: %s", offline) @@ -199,6 +214,8 @@ async def async_turn_off(self, **kwargs: Any) -> None: ) return + self._disarm_solar() + offline = charger_offline_reason(self.coordinator.data) if offline is not None: _LOGGER.info("Not sending: %s", offline) diff --git a/tests/test_entities.py b/tests/test_entities.py index 89d0d5b..a01b091 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -249,6 +249,9 @@ def __init__(self, data: dict[str, Any]) -> None: self.background: list[dict[str, Any]] = [] self.limit_state = optimistic_module.OptimisticState() self.limit_listeners: list[Any] = [] + # Mirrors the real coordinator, which declares this so entities + # and services can read it without getattr. + self.solar_controller: Any = None def async_add_limit_listener(self, listener: Any) -> Any: """Register a redraw callback.""" @@ -1017,6 +1020,59 @@ def test_switch_clears_its_pending_state_when_retries_fail() -> None: assert len(notifications) == 1 +def test_a_manual_limit_change_disarms_solar_control() -> None: + """Touching the control means you want manual control. + + The controller writes through the API client, never the entity, so + any write arriving here is by definition external. That makes the + rule mechanical rather than a flag that could be wrong. + """ + class Ctl: + mode = "active" + disarmed = False + + def disarm(self, reason: str) -> None: + self.disarmed = True + + coordinator = FakeCoordinator(dict(BASE_DATA)) + coordinator.solar_controller = Ctl() + entity, _, _ = make_number() + entity.coordinator = coordinator + + asyncio.run(entity.async_set_native_value(16000)) + + assert coordinator.solar_controller.disarmed is True + + +def test_a_manual_charge_toggle_disarms_solar_control() -> None: + """The switch is a control too. + + Without this the user presses the toggle, and the next tick — at + most two minutes later — sees a connected car and sustained surplus + and commands the opposite. Solar control would be fighting the + person holding the button. + """ + class Ctl: + disarmed = False + + def disarm(self, reason: str) -> None: + self.disarmed = True + + coordinator = FakeCoordinator(dict(BASE_DATA)) + coordinator.solar_controller = Ctl() + client = FakeApi() + + switch_module = sys.modules["daze_entities_under_test.switch"] + entity = switch_module.DazeWallboxSwitchEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + + asyncio.run(entity.async_turn_on()) + + assert coordinator.solar_controller.disarmed is True + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 8416c12..5163c56 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -905,7 +905,7 @@ def test_a_sustained_idle_start_does_not_wait_for_the_rate_limit() -> None: assert controller._backoff_until > 0 assert len(coordinator.api_client.calls) == 2 assert coordinator.api_client.calls == [ - ("current", coordinator.api_client.calls[0][1]), + ("current", 21700), ("start", coordinator.serial_number), ] @@ -1212,6 +1212,74 @@ def test_a_successful_stop_cancels_its_background_retry() -> None: ) +def test_disarming_clears_the_clocks_a_rearm_would_misread() -> None: + """Disarming ends the episode, not just the mode. + + A start this controller issued, and the back-off that start could + still arm, must not survive into the next time solar control is + switched on. Left behind, a start issued at noon and abandoned at + 12:01 is judged at 14:00 against a car that has long since + finished, arming a 60-minute back-off for a start nobody is + waiting on. + """ + controller, _, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + controller._start_issued_at = 100.0 + controller._backoff_until = 1e9 + controller._started_at = 100.0 + + controller.disarm("the charging limit was set manually") + + assert controller.mode is controller_module.SolarMode.OFF + assert controller._start_issued_at is None + assert controller._backoff_until == 0.0 + assert controller._started_at is None + + +def test_an_unplug_inside_the_grace_does_not_survive_to_punish_a_reconnect() -> ( + None +): + """The guard order in _check_ignored_start matters, and the + existing suite cannot tell the orderings apart: its one test that + exercises the disconnected-car guard sets _start_issued_at to a + timestamp whose grace has already long expired, so it passes + whichever guard runs first. + + Misplaced (grace checked before car_connected): a car unplugged at + T+60, still inside the 300 s grace, hits the grace guard first and + returns without clearing _start_issued_at. The mark survives. A + different car reconnecting later is then declined a start by + _carry_out's own outstanding-start guard, and once grace+30s + arrives with the stale mark still set and the reconnected car not + yet drawing, an hour-long back-off arms — punishing the new car for + the departed one's start. + + Correct (car_connected checked before grace): the unplug at T+60 + clears the mark immediately regardless of how little of the grace + has elapsed, so there is nothing left for grace+30s to arm. + """ + controller, _, _ = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.ACTIVE + + start = 15_000_000.0 + controller._start_issued_at = start + + # The car unplugs 60s in — well inside the 300s grace. + controller._check_ignored_start(start + 60, car_connected=False) + assert controller._start_issued_at is None + + # A different car reconnects; with the mark already clear this is + # a no-op either way. + controller._check_ignored_start(start + 120, car_connected=True) + + # 30s past where the original start's grace would have elapsed. + controller._check_ignored_start( + start + solar.DRAW_GRACE_SECONDS + 30, car_connected=True + ) + + assert controller._backoff_until == 0.0 + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 87920b3100332812067d050cc5140114582f29d4 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 13:44:18 +0100 Subject: [PATCH 65/82] test: cover the entry lifecycle and the remaining disarm call sites Round 1 review found no behavioural defects in Task 6, only coverage gaps: __init__.py had no test at all, so the load-bearing indentation in async_unload_entry, _reload_signature excluding the solar reserve, and the three service handlers' disarm calls were unverified. Two of the four _disarm_solar call sites (the power entity, async_turn_off) and both placement decisions (before validation, before the offline check) were likewise unprotected. Adds tests/test_init_entry.py, a new standalone harness that loads the real __init__.py against a stubbed Home Assistant, plus six tests in test_entities.py and one in test_solar_controller.py. Every mutation the review named was applied, confirmed to fail its new test, and reverted before this commit. Co-Authored-By: Claude Sonnet 5 --- tests/run_all.py | 1 + tests/test_entities.py | 138 ++++++++ tests/test_init_entry.py | 558 +++++++++++++++++++++++++++++++++ tests/test_solar_controller.py | 20 ++ 4 files changed, 717 insertions(+) create mode 100644 tests/test_init_entry.py diff --git a/tests/run_all.py b/tests/run_all.py index be61ad6..868201c 100755 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -27,6 +27,7 @@ "test_entities.py", "test_solar.py", "test_solar_controller.py", + "test_init_entry.py", ) diff --git a/tests/test_entities.py b/tests/test_entities.py index a01b091..fdfb1d8 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -1073,6 +1073,144 @@ def disarm(self, reason: str) -> None: assert coordinator.solar_controller.disarmed is True +def test_a_manual_power_change_disarms_solar_control() -> None: + """The power view of the same setting is a control too. + + number.py and switch.py wire the same helper onto four call sites + in total; the current entity and the start toggle are covered + above. This is the power entity's own copy, not shared code, so it + needs its own regression test. + """ + class Ctl: + disarmed = False + + def disarm(self, reason: str) -> None: + self.disarmed = True + + entity, coordinator, _ = make_power() + coordinator.solar_controller = Ctl() + + asyncio.run(entity.async_set_native_value(4000)) + + assert coordinator.solar_controller.disarmed is True + + +def test_a_manual_charge_stop_disarms_solar_control() -> None: + """Stopping a charge is a control too, and the direction that + matters most: without this, the car the person just told to stop + is restarted by solar control within two minutes, against the + person who is standing right there. + """ + class Ctl: + disarmed = False + + def disarm(self, reason: str) -> None: + self.disarmed = True + + data = dict(BASE_DATA) + data["evseStatus"] = "charging" + coordinator = FakeCoordinator(data) + coordinator.solar_controller = Ctl() + client = FakeApi() + + switch_module = sys.modules["daze_entities_under_test.switch"] + entity = switch_module.DazeWallboxSwitchEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + + asyncio.run(entity.async_turn_off()) + + assert coordinator.solar_controller.disarmed is True + + +def test_a_rejected_limit_change_still_disarms_solar_control() -> None: + """A value the charger will refuse still counts as taking over. + + _disarm_solar is called before the validation check, not after: a + user who types a current above the installation rating has still + expressed the intent to take over, and solar control overwriting + it a second later is exactly what the rule exists to prevent. + Pins the placement rather than just the presence — every value in + the tests above happens to be one the charger accepts, so moving + the call after validation would still pass them all. + """ + class Ctl: + disarmed = False + + def disarm(self, reason: str) -> None: + self.disarmed = True + + coordinator = FakeCoordinator(dict(BASE_DATA)) + coordinator.solar_controller = Ctl() + entity, _, client = make_number() + entity.coordinator = coordinator + + asyncio.run(entity.async_set_native_value(999999)) + + assert coordinator.solar_controller.disarmed is True + assert client.calls == [], "an invalid value must never reach the API" + + +def test_an_offline_charge_start_still_disarms_solar_control() -> None: + """An unreachable charger does not cancel out the user's intent. + + _disarm_solar is called before the offline check, not after: a + user whose charger is briefly unreachable has still expressed the + intent to take over. Pins the placement — every switch test above + uses a reachable charger, so moving the call after the offline + check would still pass them all. + """ + class Ctl: + disarmed = False + + def disarm(self, reason: str) -> None: + self.disarmed = True + + data = {"evseStatus": "idle", "active": False} + coordinator = FakeCoordinator(data) + coordinator.solar_controller = Ctl() + client = FakeApi() + + switch_module = sys.modules["daze_entities_under_test.switch"] + entity = switch_module.DazeWallboxSwitchEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + + asyncio.run(entity.async_turn_on()) + + assert coordinator.solar_controller.disarmed is True + assert client.calls == [], "an offline charger must never be sent a command" + + +def test_an_offline_charge_stop_still_disarms_solar_control() -> None: + """Same placement guarantee on the stop side, the direction the + switch's own docstring names as the one that matters most. + """ + class Ctl: + disarmed = False + + def disarm(self, reason: str) -> None: + self.disarmed = True + + data = {"evseStatus": "charging", "active": False} + coordinator = FakeCoordinator(data) + coordinator.solar_controller = Ctl() + client = FakeApi() + + switch_module = sys.modules["daze_entities_under_test.switch"] + entity = switch_module.DazeWallboxSwitchEntity( + coordinator=coordinator, api_client=client, + serial_number="SER1", device_info={}, + ) + + asyncio.run(entity.async_turn_off()) + + assert coordinator.solar_controller.disarmed is True + assert client.calls == [], "an offline charger must never be sent a command" + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tests/test_init_entry.py b/tests/test_init_entry.py new file mode 100644 index 0000000..6481baf --- /dev/null +++ b/tests/test_init_entry.py @@ -0,0 +1,558 @@ +"""Execute the real config-entry lifecycle against a stubbed Home Assistant. + +Nothing in the test tree had ever imported ``custom_components/daze/ +__init__.py`` before this file, so nothing verified three decisions Task +6 made there: + +- The indentation of the teardown call in ``async_unload_entry`` — the + brief says in terms that one level out and a second unload (or an + unload after a failed setup) raises on ``None.get(...)`` before the + rest of teardown runs, leaving the coordinator's timers firing + against a closed client. +- ``_reload_signature`` excluding the solar reserve — dropping that + filter makes every step of the reserve slider tear the integration + down and rebuild it, which is exactly the symptom Step 8 exists to + prevent. +- The three service handlers disarming solar control before refusing + an offline command — without it, a service call no longer hands + control back to whoever issued it. + +Home Assistant is replaced with the smallest stubs the module actually +touches, following the same approach as test_entities.py and +test_solar_controller.py. The integration modules themselves are real; +only ``async_setup_coordinator`` (which would otherwise need a working +DataUpdateCoordinator and a live API client) is left uncalled — the +functions under test here never call it, so it does not need to work, +only to import. + +Run with pytest, or standalone: + + python3 tests/test_init_entry.py +""" + +from __future__ import annotations + +import asyncio +import contextlib +import importlib.util +import sys +import types +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE_DIR = ROOT / "custom_components" / "daze" +PKG_NAME = "daze_init_under_test" + + +# ------------------------------------------------------------------ +# Home Assistant stubs +# ------------------------------------------------------------------ + + +class StubDataUpdateCoordinator: + """Stand-in for DataUpdateCoordinator. + + Only needed so ``class DazeDataUpdateCoordinator(DataUpdateCoordinator + [DazeCoordinatorData])`` can be defined at import time. Never + instantiated here: every test below supplies its own lightweight + fake coordinator instead of the real one. + """ + + def __class_getitem__(cls, _item: Any) -> Any: + return cls + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.data: dict[str, Any] | None = None + + +def _module(name: str, **attributes: Any) -> types.ModuleType: + """Build a stub module with the given attributes.""" + module = types.ModuleType(name) + for key, value in attributes.items(): + setattr(module, key, value) + sys.modules[name] = module + return module + + +def _install_homeassistant_stubs() -> None: + """Register just enough of Home Assistant and voluptuous to import + the real __init__.py, coordinator.py and solar_controller.py. + """ + _module("homeassistant") + _module("homeassistant.core", HomeAssistant=object, callback=lambda fn: fn) + _module("homeassistant.config_entries", ConfigEntry=object) + _module( + "homeassistant.exceptions", + ConfigEntryAuthFailed=type("ConfigEntryAuthFailed", (Exception,), {}), + HomeAssistantError=type("HomeAssistantError", (Exception,), {}), + ) + _module("homeassistant.helpers") + _module( + "homeassistant.helpers.aiohttp_client", + async_get_clientsession=lambda hass: None, + ) + _module( + "homeassistant.helpers.event", + async_call_later=lambda hass, delay, action: (lambda: None), + ) + _module( + "homeassistant.helpers.update_coordinator", + DataUpdateCoordinator=StubDataUpdateCoordinator, + UpdateFailed=type("UpdateFailed", (Exception,), {}), + ) + _module("homeassistant.helpers.config_validation", positive_int=int) + _module( + "homeassistant.helpers.device_registry", + async_get=lambda hass: None, + DeviceInfo=dict, + ) + + # voluptuous is a real dependency of the running integration but is + # not installed in this environment; the schemas it builds are + # never exercised here (services are invoked by calling the + # captured handler directly, bypassing Home Assistant's own schema + # validation), so trivial passthroughs are enough to import it. + _module( + "voluptuous", + Schema=lambda schema: schema, + Required=lambda key: key, + All=lambda *validators: validators, + Range=lambda **kwargs: None, + ) + + +_install_homeassistant_stubs() + + +def _load_package() -> types.ModuleType: + """Load the real package, including its __init__.py, without going + through custom_components.daze so the stubs above are the only + Home Assistant this run ever sees. + """ + package = types.ModuleType(PKG_NAME) + package.__path__ = [str(PACKAGE_DIR)] + sys.modules[PKG_NAME] = package + + for name in ("const", "payload", "models", "optimistic", "solar"): + spec = importlib.util.spec_from_file_location( + f"{PKG_NAME}.{name}", PACKAGE_DIR / f"{name}.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[f"{PKG_NAME}.{name}"] = module + spec.loader.exec_module(module) + + spec = importlib.util.spec_from_file_location( + f"{PKG_NAME}.api", + PACKAGE_DIR / "api" / "__init__.py", + submodule_search_locations=[str(PACKAGE_DIR / "api")], + ) + assert spec and spec.loader + api_module = importlib.util.module_from_spec(spec) + sys.modules[f"{PKG_NAME}.api"] = api_module + spec.loader.exec_module(api_module) + + spec = importlib.util.spec_from_file_location( + f"{PKG_NAME}.api.auth", PACKAGE_DIR / "api" / "auth.py" + ) + assert spec and spec.loader + auth_module = importlib.util.module_from_spec(spec) + sys.modules[f"{PKG_NAME}.api.auth"] = auth_module + spec.loader.exec_module(auth_module) + + for name in ("coordinator", "solar_controller"): + spec = importlib.util.spec_from_file_location( + f"{PKG_NAME}.{name}", PACKAGE_DIR / f"{name}.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[f"{PKG_NAME}.{name}"] = module + spec.loader.exec_module(module) + + # Finally, the real __init__.py itself — its relative imports + # (.api, .const, .coordinator, .payload, .solar_controller) resolve + # against the submodules already registered above. + spec = importlib.util.spec_from_file_location( + PKG_NAME, + PACKAGE_DIR / "__init__.py", + submodule_search_locations=[str(PACKAGE_DIR)], + ) + assert spec and spec.loader + init_module = importlib.util.module_from_spec(spec) + sys.modules[PKG_NAME] = init_module + spec.loader.exec_module(init_module) + + return init_module + + +daze_init = _load_package() +const = sys.modules[f"{PKG_NAME}.const"] + +DOMAIN = const.DOMAIN +CONF_SOLAR_RESERVE = const.CONF_SOLAR_RESERVE + + +# ------------------------------------------------------------------ +# Fakes +# ------------------------------------------------------------------ + + +class FakeEntry: + """Stand-in for a ConfigEntry.""" + + def __init__( + self, + entry_id: str = "entry1", + data: dict[str, Any] | None = None, + options: dict[str, Any] | None = None, + ) -> None: + self.entry_id = entry_id + self.data = dict(data or {}) + self.options = dict(options or {}) + self.unload_callbacks: list[Any] = [] + self.update_listeners: list[Any] = [] + + def async_on_unload(self, callback: Any) -> None: + """Record a callback to run on unload.""" + self.unload_callbacks.append(callback) + + def add_update_listener(self, listener: Any) -> Any: + """Record an options-update listener.""" + self.update_listeners.append(listener) + return lambda: self.update_listeners.remove(listener) + + +class FakeConfigEntries: + """Stand-in for hass.config_entries.""" + + def __init__(self, unload_ok: bool = True) -> None: + self.unload_ok = unload_ok + self.reload_calls: list[str] = [] + self.forward_calls: list[Any] = [] + + async def async_forward_entry_setups( + self, entry: Any, platforms: Any + ) -> None: + self.forward_calls.append((entry, platforms)) + + async def async_unload_platforms(self, entry: Any, platforms: Any) -> bool: + return self.unload_ok + + async def async_reload(self, entry_id: str) -> None: + self.reload_calls.append(entry_id) + + +class FakeServices: + """Stand-in for hass.services, recording registered handlers.""" + + def __init__(self) -> None: + self.handlers: dict[str, Any] = {} + + def async_register( + self, domain: str, service: str, handler: Any, schema: Any = None + ) -> Any: + self.handlers[service] = handler + return object() + + +class FakeHass: + """Stand-in for HomeAssistant, holding only what these tests touch.""" + + def __init__(self, unload_ok: bool = True) -> None: + self.data: dict[str, Any] = {} + self.config_entries = FakeConfigEntries(unload_ok) + self.services = FakeServices() + + +class FakeServiceCall: + """Stand-in for a ServiceCall.""" + + def __init__(self, data: dict[str, Any] | None = None) -> None: + self.data = dict(data or {}) + + +class FakeSolarController: + """Records whether it was disarmed or stopped.""" + + def __init__(self) -> None: + self.disarmed_reasons: list[str] = [] + self.stopped = False + + def disarm(self, reason: str) -> None: + self.disarmed_reasons.append(reason) + + async def async_stop(self) -> None: + self.stopped = True + + +class FakeCoordinatorHandle: + """Records whether its timers were shut down.""" + + def __init__(self) -> None: + self.shutdown_called = False + + def async_shutdown_timers(self) -> None: + self.shutdown_called = True + + +class FakeApiClient: + """Records the command calls a service handler makes.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, Any]] = [] + + async def async_start_charge(self, serial: str) -> dict: + self.calls.append(("start", serial)) + return {} + + async def async_stop_charge(self, serial: str) -> dict: + self.calls.append(("stop", serial)) + return {} + + async def async_set_max_charging_current( + self, serial: str, current: int + ) -> dict: + self.calls.append(("current", current)) + return {} + + +class FakeServiceCoordinator: + """The coordinator surface _async_register_services touches.""" + + def __init__(self, data: dict[str, Any]) -> None: + self.data = data + self.api_client = FakeApiClient() + self.serial_number = "SER1" + self.solar_controller: Any = None + self.refresh_calls = 0 + self.settle_calls = 0 + + async def async_request_refresh(self) -> None: + self.refresh_calls += 1 + + def async_schedule_settle_refresh(self) -> None: + self.settle_calls += 1 + + +REACHABLE_DATA: dict[str, Any] = {"active": True} +OFFLINE_DATA: dict[str, Any] = {"active": False} + + +def _register( + data: dict[str, Any], +) -> tuple[FakeHass, FakeEntry, FakeServiceCoordinator]: + """Build a hass/entry/coordinator triple with services registered.""" + hass = FakeHass() + entry = FakeEntry() + coordinator = FakeServiceCoordinator(data) + coordinator.solar_controller = FakeSolarController() + daze_init._async_register_services(hass, entry, coordinator) + return hass, entry, coordinator + + +# ------------------------------------------------------------------ +# _reload_signature +# ------------------------------------------------------------------ + + +def test_reload_signature_ignores_only_the_solar_reserve() -> None: + """The solar reserve is applied live and must not force a reload; + every other option is a real configuration change and must still + be seen. + """ + entry = FakeEntry( + data={"access_token": "a"}, + options={CONF_SOLAR_RESERVE: 500, "poll_interval": 30}, + ) + + before = daze_init._reload_signature(entry) + + entry.options[CONF_SOLAR_RESERVE] = 1500 + assert daze_init._reload_signature(entry) == before, ( + "a reserve-only change must not alter the reload signature" + ) + + entry.options["poll_interval"] = 60 + assert daze_init._reload_signature(entry) != before, ( + "a real option change must still alter the reload signature" + ) + + +def test_a_reserve_only_options_change_does_not_reload_the_entry() -> None: + """Wires _reload_signature into _async_update_listener: this is the + behaviour Step 8 actually exists to produce, not just the pure + function it is built from. + """ + entry = FakeEntry( + data={"access_token": "a"}, + options={CONF_SOLAR_RESERVE: 500, "poll_interval": 30}, + ) + hass = FakeHass() + hass.data[DOMAIN] = { + entry.entry_id: { + "reload_signature": daze_init._reload_signature(entry), + } + } + + entry.options[CONF_SOLAR_RESERVE] = 1500 + asyncio.run(daze_init._async_update_listener(hass, entry)) + + assert hass.config_entries.reload_calls == [] + + +def test_a_real_options_change_still_reloads_the_entry() -> None: + """The other half of Step 8's contract: a genuine configuration + change (here, the poll interval) must still trigger a reload. + """ + entry = FakeEntry( + data={"access_token": "a"}, + options={CONF_SOLAR_RESERVE: 500, "poll_interval": 30}, + ) + hass = FakeHass() + hass.data[DOMAIN] = { + entry.entry_id: { + "reload_signature": daze_init._reload_signature(entry), + } + } + + entry.options["poll_interval"] = 60 + asyncio.run(daze_init._async_update_listener(hass, entry)) + + assert hass.config_entries.reload_calls == [entry.entry_id] + + +# ------------------------------------------------------------------ +# async_unload_entry +# ------------------------------------------------------------------ + + +def test_unload_stops_the_controller_and_the_coordinators_timers() -> None: + """Both halves of Step 7's teardown run in the ordinary case.""" + entry = FakeEntry() + hass = FakeHass(unload_ok=True) + controller = FakeSolarController() + coordinator_handle = FakeCoordinatorHandle() + hass.data[DOMAIN] = { + entry.entry_id: { + "solar_controller": controller, + "coordinator": coordinator_handle, + } + } + + asyncio.run(daze_init.async_unload_entry(hass, entry)) + + assert controller.stopped is True + assert coordinator_handle.shutdown_called is True + assert entry.entry_id not in hass.data[DOMAIN] + + +def test_unloading_an_already_removed_entry_does_not_raise() -> None: + """The load-bearing guard: entry_data is None on a second unload, + or an unload after a failed setup. One level of indentation out, + ``entry_data.get(...)`` becomes ``None.get(...)`` and raises before + the rest of teardown — including the coordinator's own + ``async_shutdown_timers`` — ever runs. + """ + entry = FakeEntry() + hass = FakeHass(unload_ok=True) + hass.data[DOMAIN] = {} # already cleaned up + + # Must not raise. + result = asyncio.run(daze_init.async_unload_entry(hass, entry)) + + assert result is True + + +# ------------------------------------------------------------------ +# Service handlers disarm solar control +# ------------------------------------------------------------------ + + +def test_start_charge_service_disarms_solar_before_refusing_offline() -> None: + """A person calling daze.start_charge has taken over, even if the + charger happens to be briefly unreachable at that exact moment. + """ + hass, _entry, coordinator = _register(OFFLINE_DATA) + + with contextlib.suppress(daze_init.HomeAssistantError): + asyncio.run(hass.services.handlers[const.SERVICE_START_CHARGE]( + FakeServiceCall() + )) + + assert coordinator.solar_controller.disarmed_reasons != [] + assert coordinator.api_client.calls == [], ( + "an offline charger must never be sent a command" + ) + + +def test_stop_charge_service_disarms_solar_before_refusing_offline() -> None: + """Same guarantee on the stop side.""" + hass, _entry, coordinator = _register(OFFLINE_DATA) + + with contextlib.suppress(daze_init.HomeAssistantError): + asyncio.run(hass.services.handlers[const.SERVICE_STOP_CHARGE]( + FakeServiceCall() + )) + + assert coordinator.solar_controller.disarmed_reasons != [] + assert coordinator.api_client.calls == [] + + +def test_set_charging_current_service_disarms_solar_before_refusing_offline() -> ( + None +): + """Same guarantee on the set-current service.""" + hass, _entry, coordinator = _register(OFFLINE_DATA) + + with contextlib.suppress(daze_init.HomeAssistantError): + asyncio.run( + hass.services.handlers[const.SERVICE_SET_CHARGING_CURRENT]( + FakeServiceCall({"current": 10000}) + ) + ) + + assert coordinator.solar_controller.disarmed_reasons != [] + assert coordinator.api_client.calls == [] + + +def test_reachable_service_calls_still_disarm_and_still_send() -> None: + """The disarm must not come at the expense of the ordinary path: + a reachable charger still gets the command after being disarmed. + """ + hass, _entry, coordinator = _register(REACHABLE_DATA) + + asyncio.run( + hass.services.handlers[const.SERVICE_SET_CHARGING_CURRENT]( + FakeServiceCall({"current": 10000}) + ) + ) + + assert coordinator.solar_controller.disarmed_reasons != [] + assert coordinator.api_client.calls == [("current", 10000)] + + +def _main() -> int: + """Run every test in this module and report results.""" + tests = [ + value + for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value) + ] + + failures = 0 + for test in tests: + try: + test() + except Exception as err: # noqa: BLE001 - standalone runner + failures += 1 + print(f"FAIL {test.__name__}: {type(err).__name__}: {err}") + else: + print(f"ok {test.__name__}") + + print(f"\n{len(tests) - failures} passed, {failures} failed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 5163c56..6cb3e38 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -1236,6 +1236,26 @@ def test_disarming_clears_the_clocks_a_rearm_would_misread() -> None: assert controller._started_at is None +def test_disarming_clears_the_threshold_timers_too() -> None: + """The above/below-floor clocks must not survive a disarm either. + + _track_thresholds only ever sets these from None, never restarts + them while already running, so a stale _above_since left behind by + a skipped clear would read as "surplus has been sufficient since + before the disarm" the moment solar control is re-armed — skipping + the confirmation delay the design requires before the first start. + """ + controller, _, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + controller._above_since = 50.0 + controller._below_since = 60.0 + + controller.disarm("the charging limit was set manually") + + assert controller._above_since is None + assert controller._below_since is None + + def test_an_unplug_inside_the_grace_does_not_survive_to_punish_a_reconnect() -> ( None ): From e7af1693fc331bf799b35f2b721ed8baab3911bb Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 13:53:17 +0100 Subject: [PATCH 66/82] fix: tolerate a missing DOMAIN key when unloading a failed setup async_unload_entry's final cleanup line indexed hass.data[DOMAIN] directly, which raises KeyError when setup failed before hass.data.setdefault(DOMAIN, {}) ever ran. The existing test for "an already removed entry" only covered a second unload (DOMAIN present, this entry's key gone) and not this case, so the gap passed as covered. Split into two tests, one per scenario the docstring named, and fixed the lookup to tolerate a missing DOMAIN key without disturbing the entry_data guard above it. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/__init__.py | 8 +++++-- tests/test_init_entry.py | 36 ++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/custom_components/daze/__init__.py b/custom_components/daze/__init__.py index 68cf625..f8f8f3c 100644 --- a/custom_components/daze/__init__.py +++ b/custom_components/daze/__init__.py @@ -137,8 +137,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator: DazeDataUpdateCoordinator = entry_data["coordinator"] coordinator.async_shutdown_timers() - # Clean up stored data - hass.data[DOMAIN].pop(entry.entry_id, None) + # Clean up stored data. DOMAIN itself may be absent — setup can + # raise before hass.data.setdefault(DOMAIN, {}) ever runs, and + # this same function is what tears down after that failure — + # so indexing hass.data[DOMAIN] directly would raise KeyError + # here instead of finishing the unload. + hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) return unload_ok diff --git a/tests/test_init_entry.py b/tests/test_init_entry.py index 6481baf..0ae19b3 100644 --- a/tests/test_init_entry.py +++ b/tests/test_init_entry.py @@ -447,16 +447,38 @@ def test_unload_stops_the_controller_and_the_coordinators_timers() -> None: assert entry.entry_id not in hass.data[DOMAIN] -def test_unloading_an_already_removed_entry_does_not_raise() -> None: - """The load-bearing guard: entry_data is None on a second unload, - or an unload after a failed setup. One level of indentation out, - ``entry_data.get(...)`` becomes ``None.get(...)`` and raises before - the rest of teardown — including the coordinator's own - ``async_shutdown_timers`` — ever runs. +def test_a_second_unload_does_not_raise() -> None: + """entry_data is None on a second unload: DOMAIN is present in + hass.data (the first unload's own cleanup line put it there, or + left it there empty), but this entry's own key is already gone. + One level of indentation out, ``entry_data.get(...)`` becomes + ``None.get(...)`` and raises before the rest of teardown — + including the coordinator's own ``async_shutdown_timers`` — ever + runs. """ entry = FakeEntry() hass = FakeHass(unload_ok=True) - hass.data[DOMAIN] = {} # already cleaned up + hass.data[DOMAIN] = {} # this entry already popped, DOMAIN remains + + # Must not raise. + result = asyncio.run(daze_init.async_unload_entry(hass, entry)) + + assert result is True + + +def test_an_unload_after_a_failed_setup_does_not_raise() -> None: + """entry_data is None for a different reason here: setup raised + before ``hass.data.setdefault(DOMAIN, {})`` ever ran, so DOMAIN + itself is missing from hass.data, not just this entry's key. + + A fix for the second-unload case that indexes ``hass.data[DOMAIN]`` + directly to clean up — rather than going through ``.get(DOMAIN, + {})`` the way the read above it already does — passes the + second-unload test above while still raising ``KeyError`` here. + """ + entry = FakeEntry() + hass = FakeHass(unload_ok=True) + assert DOMAIN not in hass.data # setup never got far enough to set it # Must not raise. result = asyncio.run(daze_init.async_unload_entry(hass, entry)) From 7f6575a2f3029dcd7315d2d33ca26c424aa2a125 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 14:00:35 +0100 Subject: [PATCH 67/82] feat: add solar control, reserve and surplus entities The control is one tri-state select rather than a switch and a dry-run flag, so the meaningless combination cannot be selected. It carries the last decision and its reason as attributes, because an autonomous feature that acts silently cannot be understood after the fact, and it refuses to leave 'off' until both grid sensors are set: availability is a hint to the dashboard, not a gate on a service call. The reserve is persisted to the entry's options. Held only in memory it returned to 0 W on every restart, which hands the house's share to the car without saying so. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/number.py | 105 +++++++++++++++--- custom_components/daze/select.py | 116 +++++++++++++++++-- custom_components/daze/sensor.py | 52 +++++++++ custom_components/daze/strings.json | 9 ++ custom_components/daze/translations/it.json | 9 ++ tests/test_entities.py | 117 +++++++++++++++++++- 6 files changed, 384 insertions(+), 24 deletions(-) diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index 3732d11..43a98aa 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -31,8 +31,10 @@ ApiError, ) from .const import ( + CONF_SOLAR_RESERVE, DOMAIN, INLINE_COMMAND_ATTEMPTS, + MAX_SOLAR_RESERVE, POST_COMMAND_REFRESH_DELAY, ) from .coordinator import DazeDataUpdateCoordinator @@ -542,15 +544,76 @@ def _notify_error(self, message: str) -> None: ) +class DazeSolarReserveEntity( + CoordinatorEntity[DazeDataUpdateCoordinator], NumberEntity +): + """Watts to leave for the house before the car gets any.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value = 0 + _attr_native_max_value = MAX_SOLAR_RESERVE + _attr_native_step = 100 + _attr_native_unit_of_measurement = UnitOfPower.WATT + + def __init__( + self, + coordinator: DazeDataUpdateCoordinator, + controller: Any, + entry: ConfigEntry, + serial_number: str, + device_info: DeviceInfo, + ) -> None: + """Initialise the reserve control. + + Args: + coordinator: The Daze data coordinator. + controller: The solar controller whose reserve this is. + entry: The config entry the reserve is persisted in. + serial_number: The wallbox serial number. + device_info: Device info for the device registry. + + """ + super().__init__(coordinator) + self._controller = controller + self._entry = entry + self._serial_number = serial_number + self._attr_unique_id = f"{serial_number}_solar_reserve" + self._attr_device_info = device_info + + @property + def native_value(self) -> float: + """Return the configured reserve.""" + return float(self._controller.reserve_w) + + async def async_set_native_value(self, value: float) -> None: + """Set the reserve, and remember it across a restart. + + Written to the config entry's options, not just to the + controller. An in-memory reserve returns to 0 W every time + Home Assistant restarts, and 0 W means the house gets nothing + before the car does — a setting whose whole job is holding + power back, quietly stopping. Task 6's _reload_signature is + what keeps this write from reloading the entry on every step + of the slider. + """ + self._controller.reserve_w = value + self.hass.config_entries.async_update_entry( + self._entry, + options={**self._entry.options, CONF_SOLAR_RESERVE: int(value)}, + ) + self.async_write_ha_state() + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Daze Wallbox number entity. + """Set up Daze Wallbox number entities. Reads the coordinator, API client, serial number, and device info - from ``hass.data`` and registers the number entity. + from ``hass.data`` and registers the number entities. """ entry_data = hass.data[DOMAIN][entry.entry_id] coordinator: DazeDataUpdateCoordinator = entry_data["coordinator"] @@ -561,19 +624,31 @@ async def async_setup_entry( identifiers={(DOMAIN, serial_number)}, ) - async_add_entities( - [ - DazeWallboxNumberEntity( - coordinator=coordinator, - api_client=api_client, - serial_number=serial_number, - device_info=device_info, - ), - DazeWallboxPowerEntity( + entities = [ + DazeWallboxNumberEntity( + coordinator=coordinator, + api_client=api_client, + serial_number=serial_number, + device_info=device_info, + ), + DazeWallboxPowerEntity( + coordinator=coordinator, + api_client=api_client, + serial_number=serial_number, + device_info=device_info, + ), + ] + + solar_controller = entry_data.get("solar_controller") + if solar_controller is not None: + entities.append( + DazeSolarReserveEntity( coordinator=coordinator, - api_client=api_client, + controller=solar_controller, + entry=entry, serial_number=serial_number, device_info=device_info, - ), - ] - ) + ) + ) + + async_add_entities(entities) diff --git a/custom_components/daze/select.py b/custom_components/daze/select.py index cff34a4..fab150f 100644 --- a/custom_components/daze/select.py +++ b/custom_components/daze/select.py @@ -17,6 +17,7 @@ from homeassistant.components import persistent_notification from homeassistant.components.select import SelectEntity from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -253,15 +254,103 @@ def _notify_error(self, message: str) -> None: ) +SOLAR_MODE_OPTIONS = ["off", "simulate", "active"] + + +class DazeSolarControlSelect( + CoordinatorEntity[DazeDataUpdateCoordinator], SelectEntity +): + """Arm solar control, in simulation or for real. + + A single tri-state rather than a switch plus a dry-run flag, so the + meaningless combination cannot be selected. + """ + + _attr_has_entity_name = True + _attr_options = SOLAR_MODE_OPTIONS + + def __init__( + self, + coordinator: DazeDataUpdateCoordinator, + controller: Any, + serial_number: str, + device_info: DeviceInfo, + ) -> None: + """Initialise the control. + + Args: + coordinator: The Daze data coordinator. + controller: The solar controller to drive. + serial_number: The wallbox serial number. + device_info: Device info for the device registry. + + """ + super().__init__(coordinator) + self._controller = controller + self._serial_number = serial_number + self._attr_unique_id = f"{serial_number}_solar_control" + self._attr_device_info = device_info + + async def async_added_to_hass(self) -> None: + """Redraw when the controller decides something.""" + await super().async_added_to_hass() + self.async_on_remove( + self._controller.add_listener(self.async_write_ha_state) + ) + + @property + def available(self) -> bool: + """Only usable once both grid sensors have been chosen.""" + return bool(self._controller.configured) + + @property + def current_option(self) -> str | None: + """Return the controller's mode.""" + mode = self._controller.mode + return mode.value if mode is not None else None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Expose the last decision, so the feature can be understood.""" + decision = self._controller.last_decision + return { + "surplus_w": self._controller.surplus_w, + "last_action": decision.action.value if decision else None, + "last_reason": decision.reason if decision else None, + } + + async def async_select_option(self, option: str) -> None: + """Set the mode, refusing to arm before it can work. + + `available` is a hint for the dashboard. A service call or an + automation arrives here whatever the entity reports, so the + rule that both grid sensors are required before solar control + leaves "off" has to be enforced in the method that acts — and + raised, not logged, because the caller asked for something and + is entitled to know it did not happen. + """ + from .solar_controller import SolarMode + + if option != "off" and not self._controller.configured: + raise HomeAssistantError( + "Solar control needs both a grid import and a grid " + "export sensor before it can be armed. Set them in the " + "integration's options." + ) + + self._controller.mode = SolarMode(option) + self.async_write_ha_state() + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Daze Wallbox select entity. + """Set up Daze Wallbox select entities. Reads the coordinator, API client, serial number, and device info - from ``hass.data`` and registers the select entity. + from ``hass.data`` and registers the select entities. """ entry_data = hass.data[DOMAIN][entry.entry_id] coordinator: DazeDataUpdateCoordinator = entry_data["coordinator"] @@ -272,13 +361,24 @@ async def async_setup_entry( identifiers={(DOMAIN, serial_number)}, ) - async_add_entities( - [ - DazeWallboxSelectEntity( + entities: list[SelectEntity] = [ + DazeWallboxSelectEntity( + coordinator=coordinator, + api_client=api_client, + serial_number=serial_number, + device_info=device_info, + ) + ] + + solar_controller = entry_data.get("solar_controller") + if solar_controller is not None: + entities.append( + DazeSolarControlSelect( coordinator=coordinator, - api_client=api_client, + controller=solar_controller, serial_number=serial_number, device_info=device_info, ) - ] - ) + ) + + async_add_entities(entities) diff --git a/custom_components/daze/sensor.py b/custom_components/daze/sensor.py index 71d04f4..c90322b 100644 --- a/custom_components/daze/sensor.py +++ b/custom_components/daze/sensor.py @@ -182,6 +182,47 @@ def native_value(self) -> Any | None: return None +class DazeSolarSurplusSensor( + CoordinatorEntity[DazeDataUpdateCoordinator], SensorEntity +): + """The smoothed surplus the controller is working from. + + Exposed so the figure everything else depends on can be seen and + graphed, rather than inferred from behaviour. + """ + + _attr_has_entity_name = True + _attr_device_class = SensorDeviceClass.POWER + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UnitOfPower.WATT + + def __init__( + self, + coordinator: DazeDataUpdateCoordinator, + controller: Any, + serial_number: str, + device_info: DeviceInfo, + ) -> None: + """Initialise the surplus sensor.""" + super().__init__(coordinator) + self._controller = controller + self._serial_number = serial_number + self._attr_unique_id = f"{serial_number}_solar_surplus" + self._attr_device_info = device_info + + async def async_added_to_hass(self) -> None: + """Redraw when the controller updates.""" + await super().async_added_to_hass() + self.async_on_remove( + self._controller.add_listener(self.async_write_ha_state) + ) + + @property + def native_value(self) -> float | None: + """Return the smoothed surplus.""" + return self._controller.surplus_w + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -205,4 +246,15 @@ async def async_setup_entry( for description in SENSORS ] + solar_controller = entry_data.get("solar_controller") + if solar_controller is not None: + entities.append( + DazeSolarSurplusSensor( + coordinator=coordinator, + controller=solar_controller, + serial_number=serial_number, + device_info=device_info, + ) + ) + async_add_entities(entities) diff --git a/custom_components/daze/strings.json b/custom_components/daze/strings.json index d10d58f..57616c7 100644 --- a/custom_components/daze/strings.json +++ b/custom_components/daze/strings.json @@ -124,6 +124,9 @@ }, "next_scheduled_charge": { "name": "Next Scheduled Charge" + }, + "solar_surplus": { + "name": "Solar surplus" } }, "switch": { @@ -137,11 +140,17 @@ }, "max_charging_power": { "name": "Power" + }, + "solar_reserve": { + "name": "Solar reserve" } }, "select": { "operation_mode": { "name": "Operation Mode" + }, + "solar_control": { + "name": "Solar control" } } }, diff --git a/custom_components/daze/translations/it.json b/custom_components/daze/translations/it.json index 2587d95..da80bcf 100644 --- a/custom_components/daze/translations/it.json +++ b/custom_components/daze/translations/it.json @@ -129,6 +129,9 @@ "next_scheduled_charge": { "name": "Prossima carica programmata", "entity_category": "diagnostic" + }, + "solar_surplus": { + "name": "Surplus solare" } }, "switch": { @@ -144,11 +147,17 @@ "max_charging_power": { "name": "Potenza", "entity_category": "config" + }, + "solar_reserve": { + "name": "Riserva solare" } }, "select": { "operation_mode": { "name": "Modalità operativa" + }, + "solar_control": { + "name": "Controllo solare" } } }, diff --git a/tests/test_entities.py b/tests/test_entities.py index fdfb1d8..150b87c 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -20,7 +20,7 @@ import sys import types from pathlib import Path -from typing import Any +from typing import Any, ClassVar ROOT = Path(__file__).resolve().parents[1] PACKAGE_DIR = ROOT / "custom_components" / "daze" @@ -225,6 +225,8 @@ def _load_package() -> types.ModuleType: _load_package() +from homeassistant.exceptions import HomeAssistantError + api = sys.modules["daze_entities_under_test.api"] number_module = sys.modules["daze_entities_under_test.number"] select_module = sys.modules["daze_entities_under_test.select"] @@ -1211,6 +1213,119 @@ def disarm(self, reason: str) -> None: assert client.calls == [], "an offline charger must never be sent a command" +def test_solar_select_offers_three_modes() -> None: + """One control with three states, so 'dry run on, solar off' + cannot be expressed.""" + select_mod = sys.modules["daze_entities_under_test.select"] + assert select_mod.SOLAR_MODE_OPTIONS == ["off", "simulate", "active"] + + +def _solar_select(configured: bool = False) -> tuple[Any, Any]: + """Build the solar select over a controller double.""" + select_mod = sys.modules["daze_entities_under_test.select"] + + class Ctl: + def __init__(self) -> None: + self.configured = configured + self.mode = None + + def add_listener(self, cb): + return lambda: None + + controller = Ctl() + entity = select_mod.DazeSolarControlSelect( + coordinator=FakeCoordinator(dict(BASE_DATA)), + controller=controller, + serial_number="SER1", + device_info={}, + ) + return entity, controller + + +def test_solar_select_is_unavailable_without_sensors() -> None: + """Both grid sensors are required before it can do anything.""" + entity, _ = _solar_select(configured=False) + + assert entity.available is False + + +def test_solar_select_refuses_to_arm_without_sensors() -> None: + """Availability is a hint to the dashboard, not a gate. + + A service call or an automation reaches async_select_option + whatever the entity reports, so the refusal the spec requires — + "both are required before solar control can leave off" — has to be + enforced in the method that acts, and explained where the caller + can see it. Asserting `available is False` instead would pass + against a select that happily arms itself with no sensors at all. + """ + entity, controller = _solar_select(configured=False) + + raised = False + try: + asyncio.run(entity.async_select_option("active")) + except HomeAssistantError: + raised = True + + assert raised, "arming without sensors was not refused" + assert controller.mode is None, "the mode was changed anyway" + + +def test_solar_select_arms_once_the_sensors_are_there() -> None: + """The refusal must not be a blanket one.""" + entity, controller = _solar_select(configured=True) + + asyncio.run(entity.async_select_option("simulate")) + + assert controller.mode is not None + assert controller.mode.value == "simulate" + + +def test_the_reserve_survives_a_restart() -> None: + """An in-memory reserve returns to 0 W on every restart, and 0 W + means the house gets nothing before the car does. A setting that + exists to hold power back must not quietly stop holding it. + """ + number_mod = sys.modules["daze_entities_under_test.number"] + const_mod = sys.modules["daze_entities_under_test.const"] + + class Ctl: + reserve_w = 0.0 + + class FakeEntry: + options: ClassVar[dict[str, Any]] = {"poll_interval": 30} + + class FakeEntries: + def __init__(self) -> None: + self.updated: list[dict[str, Any]] = [] + + def async_update_entry(self, entry, options=None, **kwargs): + entry.options = options + self.updated.append(options) + + class FakeHass: + def __init__(self) -> None: + self.config_entries = FakeEntries() + + entry = FakeEntry() + entity = number_mod.DazeSolarReserveEntity( + coordinator=FakeCoordinator(dict(BASE_DATA)), + controller=Ctl(), + entry=entry, + serial_number="SER1", + device_info={}, + ) + entity.hass = FakeHass() + + asyncio.run(entity.async_set_native_value(1500)) + + assert entity.native_value == 1500 + assert entry.options[const_mod.CONF_SOLAR_RESERVE] == 1500 + # The rest of the options must survive the write, or saving a + # reserve would silently drop the user's grid sensors. + assert entry.options["poll_interval"] == 30 + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 7b7c30e8db326896b3a373e264b0d3f3222de256 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 14:09:45 +0100 Subject: [PATCH 68/82] test: cover the reserve's read path and the select's simulate refusal async_setup_entry (the only place a persisted reserve is turned back into anything actionable) had no test in the tree at all, so dropping the reserve_w= keyword from the SolarController(...) call passed every one of 235 tests while silently defaulting every restart to 0 W. Added test_async_setup_entry_seeds_the_controllers_reserve_from_options in test_init_entry.py for the read half, and renamed the existing number-entity test to test_setting_the_reserve_writes_it_to_config_ entry_options so its name matches the write-only half it actually covers. The solar select's refusal was only exercised for "active"; narrowing the guard to option == "active" still passed everything, letting "simulate" arm with no grid sensors configured. Extended test_solar_select_refuses_to_arm_without_sensors to check both non-off options. Co-Authored-By: Claude Sonnet 5 --- tests/test_entities.py | 44 ++++++++++++++++------ tests/test_init_entry.py | 80 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/tests/test_entities.py b/tests/test_entities.py index 150b87c..0f14e54 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -1258,17 +1258,25 @@ def test_solar_select_refuses_to_arm_without_sensors() -> None: enforced in the method that acts, and explained where the caller can see it. Asserting `available is False` instead would pass against a select that happily arms itself with no sensors at all. + + Checked for both non-off options, not just "active": narrowing the + guard to `option == "active"` would let a user or automation select + "simulate" with no grid sensors configured. The controller would + then tick, find nothing to read, and do nothing — while the select + still displays "simulate", as though a dry run were under way. That + is "leaving off" in every way that matters, just quietly. """ - entity, controller = _solar_select(configured=False) + for option in ("simulate", "active"): + entity, controller = _solar_select(configured=False) - raised = False - try: - asyncio.run(entity.async_select_option("active")) - except HomeAssistantError: - raised = True + raised = False + try: + asyncio.run(entity.async_select_option(option)) + except HomeAssistantError: + raised = True - assert raised, "arming without sensors was not refused" - assert controller.mode is None, "the mode was changed anyway" + assert raised, f"arming without sensors was not refused for {option!r}" + assert controller.mode is None, "the mode was changed anyway" def test_solar_select_arms_once_the_sensors_are_there() -> None: @@ -1281,10 +1289,22 @@ def test_solar_select_arms_once_the_sensors_are_there() -> None: assert controller.mode.value == "simulate" -def test_the_reserve_survives_a_restart() -> None: - """An in-memory reserve returns to 0 W on every restart, and 0 W - means the house gets nothing before the car does. A setting that - exists to hold power back must not quietly stop holding it. +def test_setting_the_reserve_writes_it_to_config_entry_options() -> None: + """The write half of the restart guarantee: this only proves the + number entity persists what it is given. + + An in-memory-only reserve returns to 0 W on every restart, and 0 W + means the house gets nothing before the car does — a setting that + exists to hold power back must not quietly stop holding it. But + that guarantee has two halves, and this test cannot see the other + one: nothing here restarts anything or re-reads the option back + into a controller. The read half — `async_setup_entry` passing + `entry.options.get(CONF_SOLAR_RESERVE, DEFAULT_SOLAR_RESERVE)` into + `SolarController(...)` on the next setup — is covered separately by + `test_async_setup_entry_seeds_the_controllers_reserve_from_options` + in tests/test_init_entry.py, the only place in the tree that calls + `async_setup_entry` at all. Together the two are the round trip; + apart, each name says only what its own body checks. """ number_mod = sys.modules["daze_entities_under_test.number"] const_mod = sys.modules["daze_entities_under_test.const"] diff --git a/tests/test_init_entry.py b/tests/test_init_entry.py index 0ae19b3..7a04154 100644 --- a/tests/test_init_entry.py +++ b/tests/test_init_entry.py @@ -335,6 +335,16 @@ def async_schedule_settle_refresh(self) -> None: self.settle_calls += 1 +class FakeDeviceRegistry: + """Stand-in for the device registry `dr.async_get(hass)` returns.""" + + def __init__(self) -> None: + self.created: list[dict[str, Any]] = [] + + def async_get_or_create(self, **kwargs: Any) -> None: + self.created.append(kwargs) + + REACHABLE_DATA: dict[str, Any] = {"active": True} OFFLINE_DATA: dict[str, Any] = {"active": False} @@ -351,6 +361,76 @@ def _register( return hass, entry, coordinator +# ------------------------------------------------------------------ +# async_setup_entry +# ------------------------------------------------------------------ + + +async def _fake_async_setup_coordinator( + hass: Any, entry: Any +) -> FakeServiceCoordinator: + """Stand in for the real coordinator construction async_setup_entry + calls first. + + The real ``async_setup_coordinator`` builds an auth client, an API + client and performs a live first refresh — none of that is what + this test is about, and none of it is safe to run here. Swapped in + by monkeypatching ``daze_init.async_setup_coordinator`` for the + single test that needs ``async_setup_entry`` to run end to end. + """ + return FakeServiceCoordinator(dict(REACHABLE_DATA)) + + +def test_async_setup_entry_seeds_the_controllers_reserve_from_options() -> ( + None +): + """The read half of the restart guarantee: a reserve persisted to + the config entry's options must reach the controller on the next + setup, not just default back to 0 W. + + ``tests/test_entities.py``'s + ``test_setting_the_reserve_writes_it_to_config_entry_options`` + already covers the write half — the number entity persisting a new + value. Nothing before this test called ``async_setup_entry`` at + all, so the read half — + ``reserve_w=entry.options.get(CONF_SOLAR_RESERVE, + DEFAULT_SOLAR_RESERVE)`` in the ``SolarController(...)`` call — was + unverified. Dropping that keyword (the constructor already + defaults ``reserve_w`` to 0.0 on its own) passed every other test + in the tree: every restart would then silently hand the house's + entire reserved share to the car. + + ``async_setup_coordinator`` and the device registry are + monkeypatched for the duration of this one test — the former would + otherwise need a live API client and network access, the latter is + stubbed globally to return ``None`` since no other test in this + file calls ``async_get_or_create`` on it. + """ + entry = FakeEntry( + data={ + const.CONF_SERIAL_NUMBER: "SER1", + const.CONF_NETWORK_UID: "NET1", + }, + options={CONF_SOLAR_RESERVE: 1500}, + ) + hass = FakeHass() + + original_setup_coordinator = daze_init.async_setup_coordinator + original_async_get = daze_init.dr.async_get + daze_init.async_setup_coordinator = _fake_async_setup_coordinator + daze_init.dr.async_get = lambda _hass: FakeDeviceRegistry() + try: + asyncio.run(daze_init.async_setup_entry(hass, entry)) + finally: + daze_init.async_setup_coordinator = original_setup_coordinator + daze_init.dr.async_get = original_async_get + + controller = hass.data[DOMAIN][entry.entry_id]["solar_controller"] + assert controller.reserve_w == 1500, ( + "the persisted reserve never reached the controller" + ) + + # ------------------------------------------------------------------ # _reload_signature # ------------------------------------------------------------------ From d8c1dcfc1313117f83c88d33d89d0013658c8888 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 14:21:48 +0100 Subject: [PATCH 69/82] feat: start the stop clock when surplus actually collapses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ten-minute stop delay was counted from the moment the five-minute average admitted the drop, several minutes after the drop itself, and the car imported at up to the charger's ceiling in between. The raw reading now anchors that clock; the smoothed figure still decides what to do. Rising surplus still waits for the tick, and so does most of the work on a collapse: the fast path fires once per collapse and never more often than the tick would, because the twenty-command hourly backstop is a backstop against bugs and has to still be there for the stop. The tick is no longer re-entrant, now that a sensor event can reach it while an API call is in flight. A blind period (sensors briefly unreadable) also clears the new _collapsed_since anchor, alongside the two clocks it already cleared — without this, a stale anchor from before the blind period let the stop clock resume counting through time nobody actually observed, and tripped an existing regression test. tests/test_init_entry.py also loads solar_controller.py at runtime (via coordinator/solar_controller wiring), so its own homeassistant.helpers.event stub needed the same async_track_state_change_event addition already required for test_entities.py. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/solar_controller.py | 165 ++++++++++++++- tests/test_entities.py | 8 +- tests/test_init_entry.py | 3 + tests/test_solar_controller.py | 223 ++++++++++++++++++++- 4 files changed, 390 insertions(+), 9 deletions(-) diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index a1337c8..694ba0a 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -21,7 +21,10 @@ from typing import TYPE_CHECKING, Any from homeassistant.core import HomeAssistant -from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.event import ( + async_call_later, + async_track_state_change_event, +) from .api import ( COMMAND_ERROR_CODE_RPC_FAILURE, @@ -134,10 +137,16 @@ def __init__( self._last_decision: SolarDecision | None = None self._listeners: list[Callable[[], None]] = [] self._cancel_tick: Callable[[], None] | None = None + self._cancel_listener: Callable[[], None] | None = None self._stopped = False self._above_since: float | None = None self._below_since: float | None = None + # When the raw reading fell below the floor and has stayed + # there. Anchors the stop clock and latches the fast path. + self._collapsed_since: float | None = None + self._last_evaluation: float | None = None + self._evaluating = False # The minimum-run clock: how long ago the current charge # began, consumed by decide() via seconds_since_start. Not # necessarily a start this controller issued — Task 10 seeds @@ -204,6 +213,7 @@ def disarm(self, reason: str) -> None: self._mode = SolarMode.OFF self._above_since = None self._below_since = None + self._collapsed_since = None self._started_at = None self._start_issued_at = None self._backoff_until = 0.0 @@ -251,10 +261,28 @@ def _remove() -> None: return _remove async def async_start(self) -> None: - """Begin ticking.""" + """Begin ticking, and watch the grid sensors for a collapse.""" + # Keep this. async_stop sets the flag to prevent a tick already + # in flight from re-arming itself, and a controller started + # again after a stop would otherwise never tick at all. self._stopped = False self._schedule_tick() + entities = [ + entity + for entity in (self._import_entity, self._export_entity) + if entity + ] + + if entities: + + async def _changed(_event: Any) -> None: + await self.async_sensor_changed() + + self._cancel_listener = async_track_state_change_event( + self._hass, entities, _changed + ) + async def async_stop(self) -> None: """Stop ticking and drop listeners. @@ -267,6 +295,9 @@ async def async_stop(self) -> None: if self._cancel_tick is not None: self._cancel_tick() self._cancel_tick = None + if self._cancel_listener is not None: + self._cancel_listener() + self._cancel_listener = None self._listeners.clear() # ------------------------------------------------------------------ @@ -274,19 +305,52 @@ async def async_stop(self) -> None: # ------------------------------------------------------------------ async def async_tick(self) -> None: + """Evaluate once, unless an evaluation is already running. + + Skipping rather than queueing: a queued evaluation would run + against coordinator data that is by then one command out of + date, and would decide the same thing twice — two entries in + _command_times, two commands on the wire. + + A plain flag rather than an asyncio.Lock. The lock would be + correct in production and wrong in the test suite, which drives + the controller through asyncio.run() one call at a time: a Lock + binds itself to the first event loop that acquires it and + raises RuntimeError on the next one. The event loop is + single-threaded, so nothing can interleave between the check + and the assignment below, and a flag is enough. + """ + if self._evaluating: + _LOGGER.debug("An evaluation is already running; skipping") + return + + self._evaluating = True + try: + await self._async_evaluate() + finally: + self._evaluating = False + + async def _async_evaluate(self) -> None: """Evaluate once and act if the mode allows it.""" if self._mode is SolarMode.OFF: return now = time.monotonic() + self._last_evaluation = now surplus = self._read_surplus() if surplus is None: # Absence of information is never grounds for acting, and # must not silently continue a confirmation or stop delay # that was timed against a period nobody actually observed. + # _collapsed_since anchors that same stop delay to a raw + # reading, so it goes with the other two clocks: left set, + # a blind period would let the stop clock resume counting + # from a collapse observed before the sensors went dark, + # against time nobody actually watched. self._above_since = None self._below_since = None + self._collapsed_since = None if not self._sensor_warning_logged: self._sensor_warning_logged = True @@ -303,10 +367,11 @@ async def async_tick(self) -> None: if smoothed is None: self._above_since = None self._below_since = None + self._collapsed_since = None return state = self._build_state(smoothed, now) - self._track_thresholds(state, now) + self._track_thresholds(state, now, surplus) # A start only simulated never reached the charger, so the # car was never given the chance to draw. Checking anyway @@ -335,6 +400,71 @@ async def async_tick(self) -> None: await self._carry_out(decision, now) self._notify() + async def async_sensor_changed(self) -> None: + """Note a collapse as soon as it happens. + + What this brings forward is the start of the stop clock, not + the stop. The stop needs ten minutes below the floor and is + decided from the smoothed figure, which is minutes behind the + drop; starting its clock from the drop itself is worth about + four minutes of avoided import, and is the whole benefit. An + evaluation is run as well when it is cheap to do so, because + the collapse may also be the moment a limit becomes too high. + + Rising surplus is not urgent, and is left to the tick: acting + on every increase would rewrite the limit constantly against a + charger that takes seconds to apply a change. + """ + if self._mode is SolarMode.OFF: + return + + surplus = self._read_surplus() + if surplus is None: + return + + now = time.monotonic() + data = self._coordinator.data or {} + floor = milliamps_to_watts(min_charging_current(data), data) + + if surplus - self._reserve_w >= floor: + # Healthy again. Let go of the anchor and re-arm, so the + # next collapse is counted from itself. + self._collapsed_since = None + return + + if self._collapsed_since is not None: + # This collapse is already being counted. Without this the + # condition below the floor holds on every sensor update + # until the average catches up, and a sensor reporting + # every ten seconds would run six evaluations a minute and + # spend the hourly command backstop in about three. + return + + self._collapsed_since = now + + smoothed = self._smoother.value() + if smoothed is not None and smoothed - self._reserve_w < floor: + # The average is already below the floor, so the ordinary + # tick is already treating this as a deficit and the clock + # is already running. Nothing to bring forward. + return + + if ( + self._last_evaluation is not None + and now - self._last_evaluation < TICK_SECONDS + ): + _LOGGER.debug( + "Surplus collapsed to %.0f W; the stop clock starts now, " + "the evaluation waits for the tick", + surplus, + ) + return + + _LOGGER.debug( + "Surplus collapsed to %.0f W; evaluating without waiting", surplus + ) + await self.async_tick() + # ------------------------------------------------------------------ # Internals # ------------------------------------------------------------------ @@ -471,18 +601,39 @@ def _commands_this_hour(self, now: float) -> int: ] return len(self._command_times) - def _track_thresholds(self, state: SolarState, now: float) -> None: - """Maintain how long surplus has been above or below the floor.""" + def _track_thresholds( + self, state: SolarState, now: float, raw_surplus: float + ) -> None: + """Maintain how long surplus has been above or below the floor. + + Two figures, deliberately. What to do is decided from the + smoothed surplus, because raw grid readings move with every + kettle. When the below-floor period *started* is taken from the + raw reading, because the five-minute average is minutes behind + a real collapse, and the stop delay is counted from this mark: + anchoring it to the average adds those minutes to the ten, and + the car imports at up to the charger's ceiling throughout. + + _collapsed_since holds that anchor and doubles as the fast + path's latch. It is cleared the moment the raw reading comes + back above the floor, so a kettle that dips the supply for a + minute leaves nothing behind. + """ available = state.surplus_w - state.reserve_w - if available >= state.floor_w: + if raw_surplus - state.reserve_w >= state.floor_w: + self._collapsed_since = None + elif self._collapsed_since is None: + self._collapsed_since = now + + if available >= state.floor_w and self._collapsed_since is None: self._below_since = None if self._above_since is None: self._above_since = now else: self._above_since = None if self._below_since is None: - self._below_since = now + self._below_since = self._collapsed_since or now def _check_ignored_start(self, now: float, car_connected: bool) -> None: """Back off if a car we started never began drawing. diff --git a/tests/test_entities.py b/tests/test_entities.py index 0f14e54..ad13bf8 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -162,7 +162,13 @@ def async_create( "homeassistant.helpers.aiohttp_client", async_get_clientsession=lambda hass: None, ) - _module("homeassistant.helpers.event", async_call_later=async_call_later) + _module( + "homeassistant.helpers.event", + async_call_later=async_call_later, + async_track_state_change_event=lambda hass, entities, cb: ( + lambda: None + ), + ) _module( "homeassistant.helpers.device_registry", DeviceInfo=dict, diff --git a/tests/test_init_entry.py b/tests/test_init_entry.py index 7a04154..1263123 100644 --- a/tests/test_init_entry.py +++ b/tests/test_init_entry.py @@ -95,6 +95,9 @@ def _install_homeassistant_stubs() -> None: _module( "homeassistant.helpers.event", async_call_later=lambda hass, delay, action: (lambda: None), + async_track_state_change_event=lambda hass, entities, cb: ( + lambda: None + ), ) _module( "homeassistant.helpers.update_coordinator", diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 6cb3e38..788d160 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -93,7 +93,13 @@ def cancel() -> None: _module("homeassistant") _module("homeassistant.core", HomeAssistant=StubHass, callback=lambda fn: fn) _module("homeassistant.helpers") - _module("homeassistant.helpers.event", async_call_later=async_call_later) + _module( + "homeassistant.helpers.event", + async_call_later=async_call_later, + async_track_state_change_event=lambda hass, entities, cb: ( + lambda: None + ), + ) _install_stubs() @@ -1300,6 +1306,221 @@ def test_an_unplug_inside_the_grace_does_not_survive_to_punish_a_reconnect() -> assert controller._backoff_until == 0.0 +def test_a_collapse_starts_the_stop_clock_when_it_happens() -> None: + """The ten-minute stop delay must run from the collapse, not from + the moment the five-minute average catches up with it. + + Asserting the mark itself rather than "a stop was sent": no stop + can be sent at the moment of a collapse — the smoothed figure is + still healthy, which is the whole reason this path exists — so a + test that looked for a command would pass against an + implementation that did nothing at all. + """ + controller, _, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [20_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + + # A healthy history: 3000 W drawn plus 5000 W exported. + for _ in range(2): + asyncio.run(controller.async_tick()) + clock[0] += solar.TICK_SECONDS + + clock[0] += 1 + collapse_at = clock[0] + hass.states.set( + "sensor.grid_import", "4000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + + asyncio.run(controller.async_sensor_changed()) + + assert controller._below_since == collapse_at, ( + "the stop clock did not start at the collapse: " + f"{controller._below_since} instead of {collapse_at}" + ) + finally: + controller_module.time.monotonic = original_monotonic + + +def test_a_collapse_is_evaluated_once_not_on_every_sensor_update() -> None: + """A grid sensor reporting every ten seconds updates six times a + minute, and the raw reading stays below the floor for as long as + the average takes to catch up. Without a latch each of those + updates runs a full evaluation, and each can rewrite the limit: + the twenty-command hourly backstop is spent in minutes, and it is + then not there for the stop when the stop finally comes. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [21_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + asyncio.run(controller.async_tick()) + + clock[0] += solar.TICK_SECONDS + 1 + hass.states.set( + "sensor.grid_import", "4000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_sensor_changed()) + + after_first = len(coordinator.api_client.calls) + + # The sensor keeps reporting the same collapsed figures. + for _ in range(6): + clock[0] += 10 + asyncio.run(controller.async_sensor_changed()) + finally: + controller_module.time.monotonic = original_monotonic + + assert len(coordinator.api_client.calls) == after_first, ( + "the fast path fired again while the same collapse was still " + "being counted" + ) + + +def test_a_recovery_re_arms_the_fast_path() -> None: + """A kettle is not a collapse. + + When the raw reading comes back above the floor the stop clock must + let go of it. Otherwise a dozen three-kilowatt kitchen dips over an + afternoon add up to ten minutes "below the floor" and stop a charge + that never wanted for surplus. + """ + controller, _, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [22_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + asyncio.run(controller.async_tick()) + + clock[0] += solar.TICK_SECONDS + 1 + hass.states.set( + "sensor.grid_import", "4000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_sensor_changed()) + assert controller._below_since is not None + + # The kettle switches off. + clock[0] += 30 + hass.states.set( + "sensor.grid_import", "0", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "5000", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_sensor_changed()) + assert controller._collapsed_since is None + + clock[0] += solar.TICK_SECONDS + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert controller._below_since is None, ( + "the stop clock is still anchored to a collapse that recovered" + ) + + +def test_a_rise_does_not_trigger_an_immediate_evaluation() -> None: + """Otherwise every sensor update rewrites the charger's limit.""" + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + for _ in range(3): + asyncio.run(controller.async_tick()) + + before = len(coordinator.api_client.calls) + hass.states.set("sensor.grid_export", "9000") + + asyncio.run(controller.async_sensor_changed()) + + assert len(coordinator.api_client.calls) == before + + +def test_a_sensor_event_during_a_tick_does_not_start_a_second_one() -> None: + """async_tick has two callers now, and an API call is an await. + + A sensor event arriving while a tick waits on the charger would + otherwise run a second evaluation against the same coordinator + data: both append to _command_times, both reach the same branch, + and both send the same command. The clock is advanced past the + minimum spacing inside the call on purpose, so that only the + re-entrancy guard can be what stops it. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [23_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + reentered: list[int] = [] + + async def _set_current_then_collapse( + serial: str, current_ma: int, attempts: int = 8 + ) -> dict: + coordinator.api_client.calls.append(("current", current_ma)) + hass.states.set( + "sensor.grid_import", "4000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + clock[0] += solar.TICK_SECONDS + 1 + await controller.async_sensor_changed() + reentered.append(1) + return {} + + coordinator.api_client.async_set_max_charging_current = ( + _set_current_then_collapse + ) + + try: + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert reentered == [1], "the sensor event never arrived mid-tick" + assert len(coordinator.api_client.calls) == 1, ( + "a second evaluation ran inside the first and commanded again" + ) + + +def test_async_start_still_clears_the_stopped_flag() -> None: + """async_start is rewritten in this task, and the flag it sets is + easy to drop on the way past: no other test builds a controller, + stops it and starts it again, so nothing else would notice. + """ + controller, _, _ = build() + SCHEDULED.clear() + + asyncio.run(controller.async_stop()) + assert controller._stopped is True + + asyncio.run(controller.async_start()) + + assert controller._stopped is False + assert len(SCHEDULED) == 1 + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 0ec27ddc26b8fcb0fe201387e24bd075f9f733f6 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 14:25:41 +0100 Subject: [PATCH 70/82] test: force the collapse-anchor and fast-path-latch tests to discriminate Both tests, as originally written, passed against a broken implementation: the first's collapse and evaluation landed in the same tick, so the anchor and that tick's own `now` were numerically identical at assertion time; the second's six repeats spanned only 60s, inside the fast path's own one-tick spacing guard, so that guard alone (not the latch under test) held the command count flat. Retimed both against the same real behaviour they already exercised: the first now defers the sensor event's own evaluation past the spacing guard, so the stop clock's anchor and the deferred tick's `now` are forced apart; the second now spaces repeats a tick-and-a-bit apart, past the spacing guard, so only the latch can hold the count flat. Confirmed by mutation: reverting the anchor to plain `now` now fails the first with a mismatched timestamp, and removing the latch's guard now fails the second with the message it names. Co-Authored-By: Claude Sonnet 5 --- tests/test_solar_controller.py | 49 +++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 788d160..aeaae55 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -1308,7 +1308,19 @@ def test_an_unplug_inside_the_grace_does_not_survive_to_punish_a_reconnect() -> def test_a_collapse_starts_the_stop_clock_when_it_happens() -> None: """The ten-minute stop delay must run from the collapse, not from - the moment the five-minute average catches up with it. + whenever the fast path's own evaluation actually gets around to it. + + The sensor event lands well inside the fast path's own one-tick + spacing guard (10 s after the last evaluation, against a 120 s + guard), so it records the collapse but defers evaluating it; the + mark is only picked up by an ordinary tick a full TICK_SECONDS + later. An implementation that anchored the stop clock to whichever + "now" happened to be running at evaluation time, rather than to + the collapse itself, would stamp it with that later tick instead — + a difference this test can see only because the two are forced + apart by more than a spacing guard's width. A collapse observed and + evaluated in the same instant cannot tell these two apart, which is + why that shape is deliberately avoided here. Asserting the mark itself rather than "a stop was sent": no stop can be sent at the moment of a collapse — the smoothed figure is @@ -1325,12 +1337,15 @@ def test_a_collapse_starts_the_stop_clock_when_it_happens() -> None: try: controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 - # A healthy history: 3000 W drawn plus 5000 W exported. - for _ in range(2): - asyncio.run(controller.async_tick()) - clock[0] += solar.TICK_SECONDS + # A healthy reading, so the average is not already near the + # floor and the fast path's own smoothed-figure check does not + # short-circuit before the spacing guard is even reached. + asyncio.run(controller.async_tick()) - clock[0] += 1 + # Well inside the fast path's spacing guard: the collapse is + # recorded, but the evaluation it would otherwise trigger is + # deferred to the next ordinary tick. + clock[0] += 10 collapse_at = clock[0] hass.states.set( "sensor.grid_import", "4000", {"unit_of_measurement": "W"} @@ -1338,9 +1353,13 @@ def test_a_collapse_starts_the_stop_clock_when_it_happens() -> None: hass.states.set( "sensor.grid_export", "0", {"unit_of_measurement": "W"} ) - asyncio.run(controller.async_sensor_changed()) + # The ordinary tick that actually evaluates the collapse, + # a full tick's width after it happened. + clock[0] += solar.TICK_SECONDS + asyncio.run(controller.async_tick()) + assert controller._below_since == collapse_at, ( "the stop clock did not start at the collapse: " f"{controller._below_since} instead of {collapse_at}" @@ -1356,6 +1375,14 @@ def test_a_collapse_is_evaluated_once_not_on_every_sensor_update() -> None: updates runs a full evaluation, and each can rewrite the limit: the twenty-command hourly backstop is spent in minutes, and it is then not there for the stop when the stop finally comes. + + Each repeat update here is spaced a tick-and-a-bit apart — wider + than the fast path's own one-tick minimum-spacing guard — so that + guard alone would permit a fresh evaluation every time. Only the + latch (armed once per collapse, cleared solely on recovery) can be + what holds the command count flat across them; a version with the + spacing guard but no latch would still pass a run of updates packed + inside one tick's width, which is why none are here. """ controller, coordinator, hass = build() controller.mode = controller_module.SolarMode.ACTIVE @@ -1378,9 +1405,11 @@ def test_a_collapse_is_evaluated_once_not_on_every_sensor_update() -> None: after_first = len(coordinator.api_client.calls) - # The sensor keeps reporting the same collapsed figures. - for _ in range(6): - clock[0] += 10 + # The sensor keeps reporting the same collapsed figures, each + # update further apart than the fast path's own spacing guard — + # so only the latch, not that guard, can be holding this flat. + for _ in range(3): + clock[0] += solar.TICK_SECONDS + 5 asyncio.run(controller.async_sensor_changed()) finally: controller_module.time.monotonic = original_monotonic From 2b896450a93df2db39a84f0a00874d954659b360 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 14:41:33 +0100 Subject: [PATCH 71/82] fix: close the sensor-event race and cover the anchor's unguarded clears Fix round 2 review findings, all confirmed by mutation: - async_sensor_changed checked only self._mode, not self._stopped. async_stop cancels the sensor subscription but not atomically with setting the flag, so an event already dispatched could still land here in the gap and run a full evaluation - issuing a command - on a torn-down controller. Mirrors _schedule_tick's own _stopped re-arm check. - Three of _collapsed_since's four clearing sites had no test: disarm's (a stale anchor lets a re-arm an hour later issue an immediate STOP on the strength of a collapse from before the disarm), the fast path's minimum-spacing guard (the latch alone cannot stop a raw reading oscillating across the floor faster than one tick, since each down-crossing re-arms it), and _track_thresholds's own clear on recovery (without it, once _collapsed_since has ever been set by a plain tick, "available >= floor and collapsed_since is None" can never be taken again and the controller can never start a charge again). - _last_evaluation, the fast path's own spacing clock, was set before the blind-period return - so a tick that read nothing still reset it, deferring a genuine collapse in the next TICK_SECONDS on the strength of a cycle that observed nothing. Moved after the return. Added test_async_stop_cancels_the_sensor_listener, test_a_sensor_event_after_stop_does_not_evaluate, test_a_fresh_collapse_within_one_tick_still_waits_for_the_spacing_guard, test_a_tick_only_recovery_clears_the_collapse_anchor, and test_a_blind_tick_does_not_reset_the_fast_paths_spacing_clock; extended test_disarming_clears_the_clocks_a_rearm_would_misread with the _collapsed_since case. --- custom_components/daze/solar_controller.py | 15 +- tests/test_solar_controller.py | 236 +++++++++++++++++++++ 2 files changed, 249 insertions(+), 2 deletions(-) diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index 694ba0a..1af8823 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -336,7 +336,6 @@ async def _async_evaluate(self) -> None: return now = time.monotonic() - self._last_evaluation = now surplus = self._read_surplus() if surplus is None: @@ -358,8 +357,14 @@ async def _async_evaluate(self) -> None: "Solar control cannot read its grid sensors; doing " "nothing until they report" ) + # _last_evaluation is the fast path's spacing clock, and + # this evaluation observed nothing: leaving it unset here + # means a genuine collapse in the following TICK_SECONDS is + # not deferred a full tick on the strength of a cycle that + # never actually looked. return + self._last_evaluation = now self._sensor_warning_logged = False self._smoother.add(surplus, now) @@ -415,7 +420,13 @@ async def async_sensor_changed(self) -> None: on every increase would rewrite the limit constantly against a charger that takes seconds to apply a change. """ - if self._mode is SolarMode.OFF: + if self._mode is SolarMode.OFF or self._stopped: + # Mirrors _schedule_tick's own re-arm check: async_stop + # cancels this subscription, but does not do so atomically + # with setting the flag, so an event already dispatched can + # still arrive here in the gap. Without this, that race + # runs a full evaluation — and can issue a command — on a + # controller that believes it has been torn down. return surplus = self._read_surplus() diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index aeaae55..6c5695f 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -1227,12 +1227,21 @@ def test_disarming_clears_the_clocks_a_rearm_would_misread() -> None: 12:01 is judged at 14:00 against a car that has long since finished, arming a 60-minute back-off for a start nobody is waiting on. + + _collapsed_since must go with it for the same reason, and the cost + of missing it is worse: disarmed during a collapse and re-armed an + hour later with the raw reading still below the floor, a surviving + anchor lets _track_thresholds set _below_since an hour in the past, + seconds_below_threshold is already past the 600s stop delay, and + the very first tick issues an immediate STOP on a charge the user + just re-armed solar control to manage. """ controller, _, _ = build() controller.mode = controller_module.SolarMode.ACTIVE controller._start_issued_at = 100.0 controller._backoff_until = 1e9 controller._started_at = 100.0 + controller._collapsed_since = 100.0 controller.disarm("the charging limit was set manually") @@ -1240,6 +1249,7 @@ def test_disarming_clears_the_clocks_a_rearm_would_misread() -> None: assert controller._start_issued_at is None assert controller._backoff_until == 0.0 assert controller._started_at is None + assert controller._collapsed_since is None def test_disarming_clears_the_threshold_timers_too() -> None: @@ -1550,6 +1560,232 @@ def test_async_start_still_clears_the_stopped_flag() -> None: assert len(SCHEDULED) == 1 +def test_async_stop_cancels_the_sensor_listener() -> None: + """async_stop must cancel the sensor-change subscription itself, + not only the tick timer — otherwise a stopped controller goes on + reacting to every sensor update indefinitely, the tick's own + _stopped re-arm check notwithstanding. + """ + controller, _, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + SCHEDULED.clear() + + asyncio.run(controller.async_start()) + + cancelled: list[bool] = [] + controller._cancel_listener = lambda: cancelled.append(True) + + asyncio.run(controller.async_stop()) + + assert cancelled == [True], "async_stop did not cancel the listener" + assert controller._cancel_listener is None + + +def test_a_sensor_event_after_stop_does_not_evaluate() -> None: + """async_stop cancels the sensor subscription, but not atomically + with setting _stopped — an event already dispatched can still land + here in the gap. async_sensor_changed must honour _stopped itself, + mirroring _schedule_tick's own re-arm check, or that race runs a + full evaluation — and can issue a command — on a controller that + believes it has been torn down. + + The clock is advanced past the fast path's own one-tick spacing + guard before the event arrives, so nothing but the _stopped check + can be what prevents the evaluation this test looks for. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [26_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + asyncio.run(controller.async_tick()) + before = len(coordinator.api_client.calls) + + asyncio.run(controller.async_stop()) + assert controller._stopped is True + + clock[0] += solar.TICK_SECONDS + 1 + hass.states.set( + "sensor.grid_import", "4000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_sensor_changed()) + finally: + controller_module.time.monotonic = original_monotonic + + assert len(coordinator.api_client.calls) == before, ( + "a sensor event evaluated and commanded after the controller " + "was stopped" + ) + assert controller._collapsed_since is None, ( + "the anchor was set on a torn-down controller" + ) + + +def test_a_fresh_collapse_within_one_tick_still_waits_for_the_spacing_guard() -> ( + None +): + """The latch stops a *sustained* collapse from re-evaluating on + every sensor update, but it is cleared the instant surplus + recovers — so it cannot protect against a raw reading oscillating + across the floor faster than one tick. Each down-crossing there is + a fresh collapse, latched and re-armed in the same breath, and only + the minimum-spacing guard is left to stop each one running a full + evaluation — the brief's own "six evaluations a minute and the + hourly backstop spent in about three". + + Both the recovery and the second collapse land well inside one + tick of the first evaluation, so only the spacing guard — not the + latch, which the recovery has already cleared — can be what holds + the command count flat across them. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [27_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + asyncio.run(controller.async_tick()) + + clock[0] += solar.TICK_SECONDS + 1 + hass.states.set( + "sensor.grid_import", "4000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_sensor_changed()) # evaluates now + + after_first = len(coordinator.api_client.calls) + + # A kettle-fast oscillation: recovers, then collapses again, + # both well inside the one tick the spacing guard enforces. + clock[0] += 5 + hass.states.set( + "sensor.grid_import", "0", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "5000", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_sensor_changed()) + assert controller._collapsed_since is None, "did not re-arm" + + clock[0] += 5 + hass.states.set( + "sensor.grid_import", "4000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_sensor_changed()) + finally: + controller_module.time.monotonic = original_monotonic + + assert len(coordinator.api_client.calls) == after_first, ( + "a fresh collapse inside one tick evaluated again, unguarded " + "by the minimum-spacing check" + ) + + +def test_a_tick_only_recovery_clears_the_collapse_anchor() -> None: + """_track_thresholds must clear _collapsed_since itself once the + raw reading recovers, not rely on async_sensor_changed to have + done it first: a recovery observed only by an ordinary tick, with + no sensor event in between, must still let go of the anchor. + + The consequence of losing this is worse than a wrong stop delay: + with _collapsed_since stuck non-None, "available >= floor_w and + self._collapsed_since is None" can never be taken again, + _above_since is reset to None on every evaluation instead, + seconds_above_threshold never reaches the start delay, and the + controller can never start a charge again for as long as the + process runs. + """ + controller, _, hass = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [28_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + hass.states.set( + "sensor.grid_import", "4000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_tick()) # collapses, through a tick + assert controller._collapsed_since is not None + + hass.states.set( + "sensor.grid_import", "0", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "5000", {"unit_of_measurement": "W"} + ) + clock[0] += solar.TICK_SECONDS + asyncio.run(controller.async_tick()) # recovers, through a tick + + assert controller._collapsed_since is None, ( + "the anchor survived a recovery observed only by a tick" + ) + assert controller._above_since is not None, ( + "above-threshold timing never resumed after a tick-only " + "recovery" + ) + finally: + controller_module.time.monotonic = original_monotonic + + +def test_a_blind_tick_does_not_reset_the_fast_paths_spacing_clock() -> None: + """_last_evaluation is the fast path's own spacing clock. A tick + that could not read its sensors observed nothing, so it must not + reset that clock anyway — doing so defers a genuine collapse a + full tick on the strength of a cycle that never actually looked. + """ + controller, _, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [29_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + asyncio.run(controller.async_tick()) # a real evaluation + + # Just past one tick's width: a real collapse landing here + # should be free to evaluate immediately, not deferred on the + # strength of the blind tick that follows. + clock[0] += solar.TICK_SECONDS + 1 + blind_at = clock[0] + hass.states.set("sensor.grid_export", "unavailable") + asyncio.run(controller.async_tick()) # observes nothing + + clock[0] += 1 + collapse_at = clock[0] + hass.states.set( + "sensor.grid_import", "4000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_sensor_changed()) + + assert controller._last_evaluation != blind_at, ( + "the blind tick's own timestamp reset the spacing clock" + ) + assert controller._last_evaluation == collapse_at, ( + "a collapse just past one tick's width was deferred anyway" + ) + finally: + controller_module.time.monotonic = original_monotonic + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 43d08e26939e300fb7de4d28102600d531aa8694 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 14:50:19 +0100 Subject: [PATCH 72/82] feat: refuse setups solar control cannot follow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A three-phase supply feeding a single-phase charger reports surplus netted across phases, most of which the charger cannot reach. The Daze payload does not say how many phases feed the house — its one phase field describes the charger — so the options flow asks, with no default, and solar control will not arm until it is answered. One property now answers 'can this run, and if not, why not', for the select's availability, its refusal to arm, and the log line. Eco mode and a configured charger schedule are part of that answer, as the spec asks; previously they produced a decision of 'nothing' logged at debug and no other sign. Availability alone was never enough: a service call reaches async_select_option whatever the entity reports. The rate limit is logged at warning rather than debug. It is a backstop against bugs, so if it is what is holding the charger back, that is not a debug-level fact. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/__init__.py | 2 + custom_components/daze/config_flow.py | 18 +++++ custom_components/daze/const.py | 10 +++ custom_components/daze/select.py | 23 +++---- custom_components/daze/solar_controller.py | 75 ++++++++++++++++++++- custom_components/daze/strings.json | 14 +++- custom_components/daze/translations/it.json | 14 +++- tests/test_entities.py | 6 ++ tests/test_solar_controller.py | 75 ++++++++++++++++++++- 9 files changed, 220 insertions(+), 17 deletions(-) diff --git a/custom_components/daze/__init__.py b/custom_components/daze/__init__.py index f8f8f3c..62521fc 100644 --- a/custom_components/daze/__init__.py +++ b/custom_components/daze/__init__.py @@ -21,6 +21,7 @@ CONF_SERIAL_NUMBER, CONF_SOFTWARE_VERSION, CONF_SOLAR_RESERVE, + CONF_SUPPLY_PHASES, DEFAULT_SOLAR_RESERVE, DOMAIN, PLATFORMS, @@ -81,6 +82,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: reserve_w=entry.options.get( CONF_SOLAR_RESERVE, DEFAULT_SOLAR_RESERVE ), + supply_phases=entry.options.get(CONF_SUPPLY_PHASES), ) # The entities reach the controller through the coordinator, which # every one of them already holds. diff --git a/custom_components/daze/config_flow.py b/custom_components/daze/config_flow.py index a2ff076..3d66cd7 100644 --- a/custom_components/daze/config_flow.py +++ b/custom_components/daze/config_flow.py @@ -32,10 +32,13 @@ CONF_REFRESH_TOKEN, CONF_SERIAL_NUMBER, CONF_SOFTWARE_VERSION, + CONF_SUPPLY_PHASES, DEFAULT_POLL_INTERVAL, DOMAIN, MAX_POLL_INTERVAL, MIN_POLL_INTERVAL, + SUPPLY_PHASES_SINGLE, + SUPPLY_PHASES_THREE, ) from .payload import device_name @@ -419,6 +422,21 @@ async def async_step_init( domain="sensor", device_class="power" ) ), + # Optional so the form can still be saved without it, + # not because it has a default: solar control refuses + # to arm until it is answered. + vol.Optional( + CONF_SUPPLY_PHASES, + description={ + "suggested_value": options.get(CONF_SUPPLY_PHASES) + }, + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[SUPPLY_PHASES_SINGLE, SUPPLY_PHASES_THREE], + translation_key="supply_phases", + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), } ) diff --git a/custom_components/daze/const.py b/custom_components/daze/const.py index b38b86a..b77f315 100644 --- a/custom_components/daze/const.py +++ b/custom_components/daze/const.py @@ -51,6 +51,16 @@ CONF_GRID_IMPORT_SENSOR = "grid_import_sensor" CONF_GRID_EXPORT_SENSOR = "grid_export_sensor" +# How many phases feed the house. Declared by the user, because the +# Daze payload does not say: its only phase field, evseIsThreePhase, +# describes the charger, and payload.min_charging_current already reads +# it that way. There is deliberately no default — a three-phase meter +# reports surplus netted across phases, and following it with a +# single-phase charger loads the one phase the charger is on. +CONF_SUPPLY_PHASES = "supply_phases" +SUPPLY_PHASES_SINGLE = "single" +SUPPLY_PHASES_THREE = "three" + # Watts to leave for the house before the car gets any. Site-specific, # so it is an entity rather than a constant; this is only its default. CONF_SOLAR_RESERVE = "solar_reserve" diff --git a/custom_components/daze/select.py b/custom_components/daze/select.py index fab150f..9e14579 100644 --- a/custom_components/daze/select.py +++ b/custom_components/daze/select.py @@ -300,8 +300,8 @@ async def async_added_to_hass(self) -> None: @property def available(self) -> bool: - """Only usable once both grid sensors have been chosen.""" - return bool(self._controller.configured) + """Usable only where solar control could actually run.""" + return self._controller.unsupported_reason is None @property def current_option(self) -> str | None: @@ -320,23 +320,22 @@ def extra_state_attributes(self) -> dict[str, Any]: } async def async_select_option(self, option: str) -> None: - """Set the mode, refusing to arm before it can work. + """Set the mode, refusing to arm where it cannot work. `available` is a hint for the dashboard. A service call or an automation arrives here whatever the entity reports, so the - rule that both grid sensors are required before solar control - leaves "off" has to be enforced in the method that acts — and + refusal has to be enforced in the method that acts — and raised, not logged, because the caller asked for something and - is entitled to know it did not happen. + is entitled to know it did not happen, and why. """ from .solar_controller import SolarMode - if option != "off" and not self._controller.configured: - raise HomeAssistantError( - "Solar control needs both a grid import and a grid " - "export sensor before it can be armed. Set them in the " - "integration's options." - ) + if option != "off": + reason = self._controller.unsupported_reason + if reason is not None: + raise HomeAssistantError( + f"Solar control cannot be armed: {reason}." + ) self._controller.mode = SolarMode(option) self.async_write_ha_state() diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index 1af8823..e75b29f 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -32,6 +32,7 @@ ApiCommandRejectedError, ApiError, ) +from .const import SUPPLY_PHASES_SINGLE, SUPPLY_PHASES_THREE from .payload import ( charger_offline_reason, is_charge_enabled, @@ -43,6 +44,7 @@ from .solar import ( DRAW_GRACE_SECONDS, IGNORED_START_BACKOFF_SECONDS, + MAX_COMMANDS_PER_HOUR, MIN_MEANINGFUL_DRAW_W, TICK_SECONDS, SolarAction, @@ -107,6 +109,7 @@ def __init__( import_entity: str | None, export_entity: str | None, reserve_w: float = 0.0, + supply_phases: str | None = None, ) -> None: """Initialise in the off state. @@ -124,12 +127,17 @@ def __init__( memory: a reserve that returns to zero on every restart gives the car everything the house was keeping, and does it silently. + supply_phases: "single", "three", or None if the user has + not said. None refuses to arm rather than assuming: + the payload cannot tell us, and the wrong guess loads + one phase with a surplus measured across three. """ self._hass = hass self._coordinator = coordinator self._import_entity = import_entity self._export_entity = export_entity + self._supply_phases = supply_phases self._mode = SolarMode.OFF self._reserve_w = max(0.0, float(reserve_w)) @@ -165,6 +173,7 @@ def __init__( self._backoff_until: float = 0.0 self._command_times: list[float] = [] self._sensor_warning_logged = False + self._unsupported_warning_logged = False # ------------------------------------------------------------------ # Public surface @@ -245,6 +254,55 @@ def configured(self) -> bool: """Whether both grid sensors have been chosen.""" return bool(self._import_entity and self._export_entity) + @property + def unsupported_reason(self) -> str | None: + """Explain why solar control cannot run here, if it cannot. + + One property with one answer, because every caller needs the + same one: the select for its availability and for refusing to + arm, the tick to stand down, the log line, and the README. The + spec asks three separate times for a refusal that explains + itself, and a boolean cannot. + + Ordered cheapest and most fundamental first, so the message a + user sees names the thing they have to fix. + """ + if not self.configured: + return ( + "both a grid import and a grid export sensor have to be " + "chosen in the integration's options" + ) + + if self._supply_phases not in ( + SUPPLY_PHASES_SINGLE, + SUPPLY_PHASES_THREE, + ): + return ( + "the number of phases feeding the house has not been set " + "in the integration's options, and it cannot be read from " + "the charger" + ) + + data = self._coordinator.data + if not data: + return "the charger has not reported yet" + + if self._supply_phases == SUPPLY_PHASES_THREE and not bool( + data.get("evseIsThreePhase") + ): + return ( + "the supply is three-phase and the charger is single-phase, " + "so exported power may be on a phase it cannot use" + ) + + if data.get("ecoModeEnabled"): + return "the charger's own eco mode is controlling it" + + if data.get("schedules"): + return "the charger has a schedule set" + + return None + def add_listener(self, listener: Callable[[], None]) -> Callable[[], None]: """Register a callback for state changes. @@ -335,6 +393,15 @@ async def _async_evaluate(self) -> None: if self._mode is SolarMode.OFF: return + unsupported = self.unsupported_reason + if unsupported is not None: + if not self._unsupported_warning_logged: + self._unsupported_warning_logged = True + _LOGGER.warning("Solar control cannot run: %s", unsupported) + return + + self._unsupported_warning_logged = False + now = time.monotonic() surplus = self._read_surplus() @@ -389,7 +456,13 @@ async def _async_evaluate(self) -> None: self._last_decision = decision if decision.action is SolarAction.NOTHING: - _LOGGER.debug("Solar control: %s", decision.reason) + if state.commands_this_hour >= MAX_COMMANDS_PER_HOUR: + # The backstop is against bugs. If it is what is + # holding the charger back, something upstream is + # wrong and the log has to say so out loud. + _LOGGER.warning("Solar control: %s", decision.reason) + else: + _LOGGER.debug("Solar control: %s", decision.reason) self._notify() return diff --git a/custom_components/daze/strings.json b/custom_components/daze/strings.json index 57616c7..166473d 100644 --- a/custom_components/daze/strings.json +++ b/custom_components/daze/strings.json @@ -162,9 +162,21 @@ "data": { "poll_interval": "Polling interval (seconds)", "grid_import_sensor": "Grid import power sensor", - "grid_export_sensor": "Grid export power sensor" + "grid_export_sensor": "Grid export power sensor", + "supply_phases": "Grid supply" + }, + "data_description": { + "supply_phases": "How many phases feed the house, not the charger. A three-phase meter reports surplus added up across all three, and a single-phase charger can only use one of them, so solar control will not arm until this is set." } } } + }, + "selector": { + "supply_phases": { + "options": { + "single": "Single-phase", + "three": "Three-phase" + } + } } } diff --git a/custom_components/daze/translations/it.json b/custom_components/daze/translations/it.json index da80bcf..e77304b 100644 --- a/custom_components/daze/translations/it.json +++ b/custom_components/daze/translations/it.json @@ -169,9 +169,21 @@ "data": { "poll_interval": "Intervallo di aggiornamento (secondi)", "grid_import_sensor": "Sensore di potenza prelevata dalla rete", - "grid_export_sensor": "Sensore di potenza immessa in rete" + "grid_export_sensor": "Sensore di potenza immessa in rete", + "supply_phases": "Alimentazione di rete" + }, + "data_description": { + "supply_phases": "Quante fasi alimentano la casa, non il caricatore. Un contatore trifase riporta il surplus sommato sulle tre fasi e un caricatore monofase può usarne solo una, quindi il controllo solare non si attiva finché non è impostato." } } } + }, + "selector": { + "supply_phases": { + "options": { + "single": "Monofase", + "three": "Trifase" + } + } } } diff --git a/tests/test_entities.py b/tests/test_entities.py index ad13bf8..8e25ff0 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -1238,6 +1238,12 @@ def __init__(self) -> None: def add_listener(self, cb): return lambda: None + @property + def unsupported_reason(self): + if not self.configured: + return "no grid sensors have been chosen" + return None + controller = Ctl() entity = select_mod.DazeSolarControlSelect( coordinator=FakeCoordinator(dict(BASE_DATA)), diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 6c5695f..957dd72 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -220,8 +220,16 @@ def async_cancel_background_retry(self, key: str) -> None: } -def build(data: dict[str, Any] | None = None) -> tuple[Any, Any, Any]: - """Build a controller wired to stubs.""" +def build( + data: dict[str, Any] | None = None, + supply_phases: str | None = "single", +) -> tuple[Any, Any, Any]: + """Build a controller wired to stubs. + + Declares a single-phase supply unless a test says otherwise: that + is the ordinary installation, and the alternatives each have a test + of their own below. + """ hass = StubHass() hass.states.set( "sensor.grid_import", "0", {"unit_of_measurement": "W"} @@ -236,6 +244,7 @@ def build(data: dict[str, Any] | None = None) -> tuple[Any, Any, Any]: coordinator=coordinator, import_entity="sensor.grid_import", export_entity="sensor.grid_export", + supply_phases=supply_phases, ) return controller, coordinator, hass @@ -1786,6 +1795,68 @@ def test_a_blind_tick_does_not_reset_the_fast_paths_spacing_clock() -> None: controller_module.time.monotonic = original_monotonic +def test_an_undeclared_supply_refuses_to_run() -> None: + """The Daze payload cannot tell us how many phases feed the house, + so the user is asked. Until they answer, an unanswered question is + not evidence of a single-phase supply: guessing wrong loads one + phase with the whole of a netted three-phase surplus. + """ + controller, _, _ = build(supply_phases=None) + + assert controller.unsupported_reason is not None + assert "phase" in controller.unsupported_reason + + +def test_three_phase_supply_with_a_single_phase_charger_is_refused() -> None: + """Grid meters usually report net across phases, so the surplus + can exist mostly on phases the charger cannot reach.""" + data = dict(CHARGING_DATA) + data["evseIsThreePhase"] = False + controller, _, _ = build(data, supply_phases="three") + + assert controller.unsupported_reason is not None + assert "phase" in controller.unsupported_reason + + +def test_a_matched_single_phase_pair_is_supported() -> None: + data = dict(CHARGING_DATA) + data["evseIsThreePhase"] = False + controller, _, _ = build(data, supply_phases="single") + + assert controller.unsupported_reason is None + + +def test_a_three_phase_charger_on_a_three_phase_supply_is_supported() -> None: + """The refusal is about the mismatch, not about three phases.""" + data = dict(CHARGING_DATA) + data["evseIsThreePhase"] = True + controller, _, _ = build(data, supply_phases="three") + + assert controller.unsupported_reason is None + + +def test_eco_mode_refuses_to_arm() -> None: + """The spec asks for this three times: the charger's own eco mode + is controlling it, so solar control stands down and says so rather + than quietly deciding nothing every two minutes for ever. + """ + data = dict(CHARGING_DATA) + data["ecoModeEnabled"] = True + controller, _, _ = build(data) + + assert controller.unsupported_reason is not None + assert "eco" in controller.unsupported_reason + + +def test_a_charger_schedule_refuses_to_arm() -> None: + data = dict(CHARGING_DATA) + data["schedules"] = [{"id": 1}] + controller, _, _ = build(data) + + assert controller.unsupported_reason is not None + assert "schedule" in controller.unsupported_reason + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 1ae7d6b91325546b3a13feab36f5906031d8291d Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 15:06:43 +0100 Subject: [PATCH 73/82] fix: read the schedule field that actually survives the merge, and three more from review round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schedule refusal and _build_state's schedule_set both read data.get("schedules") — a key merge_payload's own _scalars step drops from every source, because it is a list. Only nextScheduleInfo survives by name, an object when a schedule is configured and null when it is not (sensor_catalog.py:62, tests/test_payload.py's schedule-object tests). The old guard could never fire on real data, and its test only passed because it injected "schedules" into a flat dict rather than routing it through merge_payload — the exact supplyGrid3F shape this task exists to remove. Both sites now read nextScheduleInfo, and the schedule test is rebuilt on top of a real merge_payload call. The phase-mismatch check now distinguishes "the charger reported single-phase" (is False) from "the charger did not report" (is None), each with its own message, matching the same distinction _car_draw_w, _read_power and charger_reachable already draw elsewhere. A captured real payload does carry evseIsThreePhase (tests/test_payload.py's EVSE_RECORD), so this is a genuine defensive improvement, not a guess about missing data. The tick's own stand-down was untested: Task 10 restores the mode directly at startup, bypassing async_select_option's refusal entirely, so a three-phase house with a single-phase charger and a restored ACTIVE mode had no test proving the tick would not drive the charger to its ceiling on the strength of a netted surplus. Added, and confirmed to fail without the guard. "Off" being un-refusable had no test either, despite being the brief's own named trap: a charger refused for any reason could otherwise never be turned off again until the refusal cleared. Saving the options form replaced the options outright, dropping the solar reserve (written there directly by the reserve entity, with no field of its own on this form) back to 0 W on every save. Latent before this task; this task gives every existing user a reason to reopen the form, since solar control now refuses to arm until the supply question is answered. Now merged rather than replaced, with a new tests/test_config_flow.py exercising the real handler against stubbed Home Assistant and voluptuous, added to run_all.py. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/config_flow.py | 13 +- custom_components/daze/solar_controller.py | 42 ++- tests/run_all.py | 1 + tests/test_config_flow.py | 281 +++++++++++++++++++++ tests/test_entities.py | 17 ++ tests/test_solar_controller.py | 71 +++++- 6 files changed, 412 insertions(+), 13 deletions(-) create mode 100644 tests/test_config_flow.py diff --git a/custom_components/daze/config_flow.py b/custom_components/daze/config_flow.py index 3d66cd7..9188c5a 100644 --- a/custom_components/daze/config_flow.py +++ b/custom_components/daze/config_flow.py @@ -382,7 +382,18 @@ async def async_step_init( solar control simply refuses to arm without them. """ if user_input is not None: - return self.async_create_entry(title="", data=user_input) + # Merged, not replaced: this form has no field for the + # solar reserve — that entity is Task 7's own, and writes + # it to these same options directly — so saving this form + # verbatim as the new options would silently drop it back + # to 0 W on every save. Latent before this task; this task + # is what gives every existing user a reason to reopen this + # form, since solar control now refuses to arm until the + # supply question below is answered. + return self.async_create_entry( + title="", + data={**self._config_entry.options, **user_input}, + ) current = self._config_entry.options.get( CONF_POLL_INTERVAL, diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index e75b29f..022fd15 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -287,18 +287,32 @@ def unsupported_reason(self) -> str | None: if not data: return "the charger has not reported yet" - if self._supply_phases == SUPPLY_PHASES_THREE and not bool( - data.get("evseIsThreePhase") - ): - return ( - "the supply is three-phase and the charger is single-phase, " - "so exported power may be on a phase it cannot use" - ) + if self._supply_phases == SUPPLY_PHASES_THREE: + # is False, not "not bool(...)": a captured real payload + # carries this field (tests/test_payload.py's EVSE_RECORD), + # so an absent reading is not the same fact as a charger + # that has confirmed it is single-phase, and the two need + # different messages — the same distinction _car_draw_w, + # _read_power and charger_reachable already make on this + # branch, between "confirmed no" and "do not know". + three_phase_charger = data.get("evseIsThreePhase") + if three_phase_charger is None: + return "the charger has not said how many phases it uses" + + if three_phase_charger is False: + return ( + "the supply is three-phase and the charger is " + "single-phase, so exported power may be on a phase " + "it cannot use" + ) if data.get("ecoModeEnabled"): return "the charger's own eco mode is controlling it" - if data.get("schedules"): + # Not "schedules": see the comment on schedule_info in + # _build_state — that key never survives merge_payload, and + # nextScheduleInfo is the one that does. + if data.get("nextScheduleInfo"): return "the charger has a schedule set" return None @@ -643,7 +657,15 @@ def _build_state(self, smoothed: float, now: float) -> SolarState: data.get("maxExternalChargingCurrentInMilliAmps") ) or 0.0 charging = bool(is_charge_enabled(data)) - schedules = data.get("schedules") + # Not "schedules": that key is a list, which payload._scalars + # drops from every source merge_payload flattens, so it never + # survives to the merged payload. merge_payload re-attaches + # exactly two nested objects by name, and nextScheduleInfo is + # the one the charger uses to report a configured schedule — + # an object when one is set, None when it is not (see + # sensor_catalog.get_next_scheduled_charge and + # tests/test_payload.py's schedule-object tests). + schedule_info = data.get("nextScheduleInfo") return SolarState( surplus_w=smoothed, @@ -661,7 +683,7 @@ def _build_state(self, smoothed: float, now: float) -> SolarState: charger_reachable=bool(data) and charger_offline_reason(data) is None, eco_mode_on=bool(data.get("ecoModeEnabled")), - schedule_set=bool(schedules), + schedule_set=bool(schedule_info), car_connected=data.get("chargeSession") is not None or charging, seconds_above_threshold=self._elapsed(self._above_since, now), seconds_below_threshold=self._elapsed(self._below_since, now), diff --git a/tests/run_all.py b/tests/run_all.py index 868201c..e550e76 100755 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -28,6 +28,7 @@ "test_solar.py", "test_solar_controller.py", "test_init_entry.py", + "test_config_flow.py", ) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..52cf8c0 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,281 @@ +"""Tests for the options flow against a stubbed Home Assistant. + +Follows the same approach as test_entities.py, test_solar_controller.py +and test_init_entry.py: the real config_flow.py is imported and +exercised, with Home Assistant and voluptuous replaced by the smallest +stubs the module actually touches. + +Run with pytest, or standalone: + + python3 tests/test_config_flow.py +""" + +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE_DIR = ROOT / "custom_components" / "daze" +PKG_NAME = "daze_config_flow_under_test" + + +# ------------------------------------------------------------------ +# Home Assistant / voluptuous stubs +# ------------------------------------------------------------------ + + +def _module(name: str, **attributes: Any) -> types.ModuleType: + """Build a stub module with the given attributes.""" + module = types.ModuleType(name) + for key, value in attributes.items(): + setattr(module, key, value) + sys.modules[name] = module + return module + + +class _StubConfigFlow: + """Stand-in for homeassistant.config_entries.ConfigFlow. + + DazeConfigFlow is declared as + ``class DazeConfigFlow(ConfigFlow, domain=DOMAIN)`` — a class + keyword argument evaluated at class-definition time, which the + real ConfigFlow consumes through __init_subclass__. Plain + ``object`` rejects unknown keyword arguments there and would raise + on import. + """ + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__() + + +class _StubOptionsFlow: + """Stand-in for homeassistant.config_entries.OptionsFlow. + + Real enough to observe what DazeOptionsFlowHandler does: records + the title and data an async_create_entry call is given, and the + step_id an async_show_form call is given, rather than performing + any real flow-result machinery. + """ + + def async_create_entry( + self, *, title: str, data: dict[str, Any] + ) -> dict[str, Any]: + return {"type": "create_entry", "title": title, "data": data} + + def async_show_form( + self, *, step_id: str, data_schema: Any, **kwargs: Any + ) -> dict[str, Any]: + return {"type": "form", "step_id": step_id, "data_schema": data_schema} + + +class _SelectorConfig: + """Stand-in for EntitySelectorConfig / SelectSelectorConfig.""" + + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + + +class _Selector: + """Stand-in for EntitySelector / SelectSelector.""" + + def __init__(self, config: Any) -> None: + self.config = config + + +class _SelectSelectorMode: + DROPDOWN = "dropdown" + + +def _install_stubs() -> None: + """Register just enough of Home Assistant and voluptuous to import + the real config_flow.py. + """ + _module("homeassistant") + _module("homeassistant.core", HomeAssistant=object, callback=lambda fn: fn) + _module( + "homeassistant.config_entries", + ConfigEntry=object, + ConfigFlow=_StubConfigFlow, + ConfigFlowResult=object, + OptionsFlow=_StubOptionsFlow, + ) + _module("homeassistant.helpers") + _module( + "homeassistant.helpers.aiohttp_client", + async_get_clientsession=lambda hass: None, + ) + _module( + "homeassistant.helpers.selector", + EntitySelector=_Selector, + EntitySelectorConfig=_SelectorConfig, + SelectSelector=_Selector, + SelectSelectorConfig=_SelectorConfig, + SelectSelectorMode=_SelectSelectorMode, + ) + + # voluptuous is a real dependency of the running integration but is + # not installed in this environment. async_step_init's schema is + # built and thrown away (this file never submits it for real + # validation — user_input is handed to the handler directly), so + # trivial passthroughs are enough to import it and build the form. + _module( + "voluptuous", + Schema=lambda schema: schema, + Required=lambda key, default=None: key, + Optional=lambda key, **kwargs: key, + All=lambda *validators: validators, + Range=lambda **kwargs: None, + Coerce=lambda type_: type_, + In=lambda options: options, + Invalid=type("Invalid", (Exception,), {}), + ) + + +_install_stubs() + + +def _load_package() -> types.ModuleType: + """Load the real package, including config_flow.py, without going + through custom_components.daze so the stubs above are the only + Home Assistant this run ever sees. + """ + package = types.ModuleType(PKG_NAME) + package.__path__ = [str(PACKAGE_DIR)] + sys.modules[PKG_NAME] = package + + for name in ("const", "payload"): + spec = importlib.util.spec_from_file_location( + f"{PKG_NAME}.{name}", PACKAGE_DIR / f"{name}.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[f"{PKG_NAME}.{name}"] = module + spec.loader.exec_module(module) + + spec = importlib.util.spec_from_file_location( + f"{PKG_NAME}.api", + PACKAGE_DIR / "api" / "__init__.py", + submodule_search_locations=[str(PACKAGE_DIR / "api")], + ) + assert spec and spec.loader + api_module = importlib.util.module_from_spec(spec) + sys.modules[f"{PKG_NAME}.api"] = api_module + spec.loader.exec_module(api_module) + + spec = importlib.util.spec_from_file_location( + f"{PKG_NAME}.api.auth", PACKAGE_DIR / "api" / "auth.py" + ) + assert spec and spec.loader + auth_module = importlib.util.module_from_spec(spec) + sys.modules[f"{PKG_NAME}.api.auth"] = auth_module + spec.loader.exec_module(auth_module) + + spec = importlib.util.spec_from_file_location( + f"{PKG_NAME}.config_flow", + PACKAGE_DIR / "config_flow.py", + submodule_search_locations=[str(PACKAGE_DIR)], + ) + assert spec and spec.loader + config_flow_module = importlib.util.module_from_spec(spec) + sys.modules[f"{PKG_NAME}.config_flow"] = config_flow_module + spec.loader.exec_module(config_flow_module) + + return config_flow_module + + +config_flow = _load_package() +const = sys.modules[f"{PKG_NAME}.const"] + + +# ------------------------------------------------------------------ +# Fakes +# ------------------------------------------------------------------ + + +class FakeConfigEntry: + """Stand-in for a ConfigEntry, holding only what the handler reads.""" + + def __init__(self, options: dict[str, Any] | None = None) -> None: + self.data: dict[str, Any] = {} + self.options = dict(options or {}) + + +def _handler(options: dict[str, Any] | None = None) -> Any: + entry = FakeConfigEntry(options) + return config_flow.DazeOptionsFlowHandler(entry) + + +# ------------------------------------------------------------------ +# Saving the form merges rather than replaces +# ------------------------------------------------------------------ + + +def test_saving_the_form_preserves_the_solar_reserve() -> None: + """The form has no field for the solar reserve — Task 7's reserve + entity writes it to these same options directly — so a save that + replaces the options outright drops it back to 0 W. This task gives + every existing user a reason to reopen this form: solar control now + refuses to arm until the supply question is answered, and a user + who already set a 2000 W house reserve must not lose it silently at + the exact moment they are doing that. + """ + handler = _handler({const.CONF_SOLAR_RESERVE: 2000, "poll_interval": 30}) + + import asyncio + + result = asyncio.run( + handler.async_step_init({"poll_interval": 45}) + ) + + assert result["data"][const.CONF_SOLAR_RESERVE] == 2000, ( + "the solar reserve was dropped by a save that did not mention it" + ) + assert result["data"]["poll_interval"] == 45, ( + "the field the form actually submitted was not applied" + ) + + +def test_saving_the_form_applies_a_submitted_field_over_the_old_value() -> ( + None +): + """The merge must not go the other way: a field the form did + submit has to win over whatever was already stored, or "saving" + the form would not actually change anything. + """ + handler = _handler({"poll_interval": 30}) + + import asyncio + + result = asyncio.run(handler.async_step_init({"poll_interval": 60})) + + assert result["data"]["poll_interval"] == 60 + + +def _main() -> int: + """Run every test in this module and report results.""" + tests = [ + value + for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value) + ] + + failures = 0 + for test in tests: + try: + test() + except Exception as err: # noqa: BLE001 - standalone runner + failures += 1 + print(f"FAIL {test.__name__}: {type(err).__name__}: {err}") + else: + print(f"ok {test.__name__}") + + print(f"\n{len(tests) - failures} passed, {failures} failed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/tests/test_entities.py b/tests/test_entities.py index 8e25ff0..4a3931e 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -1301,6 +1301,23 @@ def test_solar_select_arms_once_the_sensors_are_there() -> None: assert controller.mode.value == "simulate" +def test_solar_select_off_is_never_refused() -> None: + """The brief's own named trap: a charger refused for any reason — + unconfigured sensors, eco mode, an undeclared supply — must still + be switchable to "off", or a user could never turn solar control + off again until the refusal condition itself clears. Only the + non-off branch of async_select_option may consult + unsupported_reason at all. + """ + entity, controller = _solar_select(configured=False) + assert entity.available is False, "the fixture must start refused" + + asyncio.run(entity.async_select_option("off")) + + assert controller.mode is not None + assert controller.mode.value == "off" + + def test_setting_the_reserve_writes_it_to_config_entry_options() -> None: """The write half of the restart guarantee: this only proves the number entity persists what it is given. diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 957dd72..53aa1eb 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -136,6 +136,7 @@ def _load_package() -> None: optimistic = sys.modules["daze_solar_ctl.optimistic"] controller_module = sys.modules["daze_solar_ctl.solar_controller"] api_module = sys.modules["daze_solar_ctl.api"] +payload_module = sys.modules["daze_solar_ctl.payload"] class FakeApi: @@ -1818,6 +1819,24 @@ def test_three_phase_supply_with_a_single_phase_charger_is_refused() -> None: assert "phase" in controller.unsupported_reason +def test_an_unreported_charger_phase_count_refuses_on_a_three_phase_supply() -> ( + None +): + """"the charger has not said" must be distinguishable from "the + charger said single-phase": CHARGING_DATA carries no + evseIsThreePhase key at all here, the shape a payload missing the + field actually has, not an injected False. Treating an absent + reading the same as a confirmed single-phase charger would still + refuse correctly by accident, but for the wrong reason, and would + tell the user the wrong thing to go fix. + """ + controller, _, _ = build(dict(CHARGING_DATA), supply_phases="three") + + assert controller.unsupported_reason is not None + assert "phase" in controller.unsupported_reason + assert "not said" in controller.unsupported_reason + + def test_a_matched_single_phase_pair_is_supported() -> None: data = dict(CHARGING_DATA) data["evseIsThreePhase"] = False @@ -1849,14 +1868,62 @@ def test_eco_mode_refuses_to_arm() -> None: def test_a_charger_schedule_refuses_to_arm() -> None: - data = dict(CHARGING_DATA) - data["schedules"] = [{"id": 1}] + """A configured schedule is reported as ``nextScheduleInfo``, an + object, not ``schedules``, a list — and payload._scalars drops + every list value while merge_payload flattens a payload, so a + fixture that injects "schedules" straight into a flat dict tests a + key the real payload never carries at this level. Routed through + merge_payload instead, so the guard is exercised against data + shaped the way a real merged payload actually is: this is the same + mistake, in the same task, that a prior round already found in the + supply-phase guard. + """ + remote_info = dict(CHARGING_DATA) + remote_info["nextScheduleInfo"] = {"startTime": "2026-09-30T02:00:00Z"} + data = payload_module.merge_payload(remote_info, None) controller, _, _ = build(data) assert controller.unsupported_reason is not None assert "schedule" in controller.unsupported_reason +def test_no_schedule_is_supported_through_a_real_merge() -> None: + """The mirror case: merge_payload's own null-schedule shape must + not be misread as a schedule set, or every charger with none + configured would be refused. + """ + remote_info = dict(CHARGING_DATA) + remote_info["nextScheduleInfo"] = None + data = payload_module.merge_payload(remote_info, None) + controller, _, _ = build(data) + + assert controller.unsupported_reason is None + + +def test_the_tick_stands_down_for_an_unsupported_setup_set_directly() -> None: + """The select's own refusal in async_select_option is not the only + way into ACTIVE: a restored mode (Task 10 gives the select + RestoreEntity, setting the controller's mode directly at startup) + never passes through it at all. A three-phase house with a + single-phase charger, mode restored straight to ACTIVE, is exactly + the hazard this task exists to prevent — the charger driven to its + ceiling on the one phase it is wired to, following surplus netted + across three. decide() has no phase field to catch this itself; + only _async_evaluate's own stand-down can. + """ + data = dict(CHARGING_DATA) + data["evseIsThreePhase"] = False + controller, coordinator, _ = build(data, supply_phases="three") + controller.mode = controller_module.SolarMode.ACTIVE # set directly + + asyncio.run(controller.async_tick()) + + assert coordinator.api_client.calls == [] + assert controller.last_decision is None, ( + "the tick evaluated an unsupported setup instead of standing down" + ) + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From 516372359daa9ba7363b20b51a7c7c501a232000 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 15:14:58 +0100 Subject: [PATCH 74/82] fix: stop the options merge from making cleared sensors unclearable, review round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I4's blanket merge ({**options, **user_input}) fixed the solar reserve but broke something the spec calls out by name: the two grid sensors and the supply-phases question are all vol.Optional with no default, so the frontend omits a cleared field from user_input entirely rather than submitting it empty. The blanket merge read that omission as "unchanged" and silently restored the stale value from the old options — a sensor pointed at a renamed or deleted entity_id could never be cleared again once set, even though the spec calls empty a supported configuration. Now only the one key this form does not own — the solar reserve, written directly to these options by Task 7's reserve entity — is carried forward; everything the form does own is taken exactly as submitted. tests/test_config_flow.py gains a test that clears a previously-set sensor and asserts the key is actually gone, which neither existing test (both of which submit a field rather than clearing one) could catch; confirmed to fail against the blanket-merge version. Also: _build_state's own nextScheduleInfo read was reachable by no test in the tick path, since unsupported_reason returns before decide() is ever reached — reverting it to the old "schedules" key passed 66 of 67 tests in the file. Added a direct test against a real merge_payload call that fails on exactly that reversion, confirmed. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/config_flow.py | 30 ++++++++++-------- tests/test_config_flow.py | 44 ++++++++++++++++++++++++--- tests/test_solar_controller.py | 18 +++++++++++ 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/custom_components/daze/config_flow.py b/custom_components/daze/config_flow.py index 9188c5a..80daa9a 100644 --- a/custom_components/daze/config_flow.py +++ b/custom_components/daze/config_flow.py @@ -32,6 +32,7 @@ CONF_REFRESH_TOKEN, CONF_SERIAL_NUMBER, CONF_SOFTWARE_VERSION, + CONF_SOLAR_RESERVE, CONF_SUPPLY_PHASES, DEFAULT_POLL_INTERVAL, DOMAIN, @@ -382,18 +383,23 @@ async def async_step_init( solar control simply refuses to arm without them. """ if user_input is not None: - # Merged, not replaced: this form has no field for the - # solar reserve — that entity is Task 7's own, and writes - # it to these same options directly — so saving this form - # verbatim as the new options would silently drop it back - # to 0 W on every save. Latent before this task; this task - # is what gives every existing user a reason to reopen this - # form, since solar control now refuses to arm until the - # supply question below is answered. - return self.async_create_entry( - title="", - data={**self._config_entry.options, **user_input}, - ) + # Carry forward only the one key this form does not own — + # the solar reserve, written directly to these same options + # by Task 7's own reserve entity — rather than blanket- + # merging the rest of the stored options over the submitted + # ones. Every field this form *does* own is vol.Optional + # with no default, so clearing one in the frontend omits it + # from user_input rather than submitting an empty value; a + # blanket merge would read that omission as "unchanged" and + # silently restore the stale value, making the sensors + # impossible to clear once set — the spec calls empty a + # supported configuration. + data = dict(user_input) + if CONF_SOLAR_RESERVE in self._config_entry.options: + data[CONF_SOLAR_RESERVE] = self._config_entry.options[ + CONF_SOLAR_RESERVE + ] + return self.async_create_entry(title="", data=data) current = self._config_entry.options.get( CONF_POLL_INTERVAL, diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 52cf8c0..7f5e397 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -12,6 +12,7 @@ from __future__ import annotations +import asyncio import importlib.util import sys import types @@ -225,8 +226,6 @@ def test_saving_the_form_preserves_the_solar_reserve() -> None: """ handler = _handler({const.CONF_SOLAR_RESERVE: 2000, "poll_interval": 30}) - import asyncio - result = asyncio.run( handler.async_step_init({"poll_interval": 45}) ) @@ -248,13 +247,50 @@ def test_saving_the_form_applies_a_submitted_field_over_the_old_value() -> ( """ handler = _handler({"poll_interval": 30}) - import asyncio - result = asyncio.run(handler.async_step_init({"poll_interval": 60})) assert result["data"]["poll_interval"] == 60 +def test_clearing_a_sensor_actually_clears_it() -> None: + """Every field this form owns — the two grid sensors and the + supply-phases question — is vol.Optional with no default, so a + user clearing one in the frontend omits it from user_input rather + than submitting an empty value. A blanket merge of the old options + over the submitted ones reads that omission as "unchanged" and + silently restores the stale entity_id, making a configured sensor + impossible to clear once set — even though the spec calls empty a + supported configuration. Only the solar reserve, the one key this + form does not own, may survive an omission this way. + """ + handler = _handler( + { + const.CONF_GRID_IMPORT_SENSOR: "sensor.grid_import", + const.CONF_GRID_EXPORT_SENSOR: "sensor.grid_export", + "poll_interval": 30, + } + ) + + # The user cleared the import sensor picker and saved: the frontend + # omits a cleared vol.Optional field entirely rather than + # submitting it as empty. + result = asyncio.run( + handler.async_step_init( + { + const.CONF_GRID_EXPORT_SENSOR: "sensor.grid_export", + "poll_interval": 30, + } + ) + ) + + assert const.CONF_GRID_IMPORT_SENSOR not in result["data"], ( + "a cleared sensor was silently restored from the stale options" + ) + assert result["data"][const.CONF_GRID_EXPORT_SENSOR] == ( + "sensor.grid_export" + ) + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 53aa1eb..5069c3a 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -1900,6 +1900,24 @@ def test_no_schedule_is_supported_through_a_real_merge() -> None: assert controller.unsupported_reason is None +def test_build_states_own_schedule_read_survives_a_real_merge() -> None: + """unsupported_reason returns before decide() is ever reached, so + no tick-path test can tell _build_state's own nextScheduleInfo read + apart from the dropped "schedules" key it replaced — reverting it + passes every other test in the suite. Checked directly instead. + """ + remote_info = dict(CHARGING_DATA) + remote_info["nextScheduleInfo"] = {"startTime": "2026-09-30T02:00:00Z"} + data = payload_module.merge_payload(remote_info, None) + controller, _, _ = build(data) + + state = controller._build_state( + 5000.0, controller_module.time.monotonic() + ) + + assert state.schedule_set is True + + def test_the_tick_stands_down_for_an_unsupported_setup_set_directly() -> None: """The select's own refusal in async_select_option is not the only way into ACTIVE: a restored mode (Task 10 gives the select From 8207370ce1fa1737c742339032bcb19c0e629fda Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 15:29:33 +0100 Subject: [PATCH 75/82] feat: survive a Home Assistant restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timers begin at zero after a restart, so a charge that was already running would read as having no elapsed run time and could be stopped moments after boot. A running charge now seeds its own start time. Once per charge rather than once per tick, and released when the charge is observed to end. Re-seeding on every tick would re-issue a stop that was queued but never landed, every two minutes until the hourly backstop tripped; seeding only once per lifetime would strand the next charge instead, with a clock that reads as zero for ever and can never be stopped. This clock has now been wrong in both directions, which is why it is tested in both. The same flag is released on disarm() too, so a re-arm onto a charge that never stopped can reseed rather than inheriting a clock that was just zeroed and a flag that says not to touch it again. The control also remembers its mode across a restart via RestoreEntity. No stored state at all is a different case from a restart — the select's first run, per the spec's Rollout section — and lands the mode in simulate rather than leaving it at the controller's off default. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/select.py | 30 +++- custom_components/daze/solar_controller.py | 50 ++++++ tests/test_entities.py | 56 ++++++- tests/test_solar_controller.py | 178 ++++++++++++++++++++- 4 files changed, 306 insertions(+), 8 deletions(-) diff --git a/custom_components/daze/select.py b/custom_components/daze/select.py index 9e14579..c1358f3 100644 --- a/custom_components/daze/select.py +++ b/custom_components/daze/select.py @@ -19,6 +19,7 @@ from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .api import ( @@ -258,7 +259,7 @@ def _notify_error(self, message: str) -> None: class DazeSolarControlSelect( - CoordinatorEntity[DazeDataUpdateCoordinator], SelectEntity + CoordinatorEntity[DazeDataUpdateCoordinator], SelectEntity, RestoreEntity ): """Arm solar control, in simulation or for real. @@ -292,12 +293,37 @@ def __init__( self._attr_device_info = device_info async def async_added_to_hass(self) -> None: - """Redraw when the controller decides something.""" + """Redraw when the controller decides something, and remember + the mode across a restart. + + Restoring writes straight to the controller rather than + through async_select_option, so it cannot raise at startup: a + setup that is temporarily unsupported — the charger has not + polled yet, say — must come back as the user left it and be + refused later by the guard in the tick (_async_evaluate's own + stand-down), not lose the setting because of a race with the + first refresh. + + No stored state at all is a different case from a restart: it + is this select existing for the first time, which the spec's + Rollout section calls "first enable" and asks to land in + simulate, not active — the controller's own constructor + default of off is what a fresh install shows before this + entity has ever run once. + """ await super().async_added_to_hass() self.async_on_remove( self._controller.add_listener(self.async_write_ha_state) ) + from .solar_controller import SolarMode + + last = await self.async_get_last_state() + if last is not None and last.state in SOLAR_MODE_OPTIONS: + self._controller.mode = SolarMode(last.state) + elif last is None: + self._controller.mode = SolarMode.SIMULATE + @property def available(self) -> bool: """Usable only where solar control could actually run.""" diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index 022fd15..2b6d74e 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -46,6 +46,7 @@ IGNORED_START_BACKOFF_SECONDS, MAX_COMMANDS_PER_HOUR, MIN_MEANINGFUL_DRAW_W, + MIN_RUN_SECONDS, TICK_SECONDS, SolarAction, SolarDecision, @@ -164,6 +165,10 @@ def __init__( # what started the charge; _check_ignored_start reads # _start_issued_at instead, never this one. self._started_at: float | None = None + # Whether the minimum-run clock has already been seeded for the + # charging episode under way. Once per episode, not once per + # tick — see the seeding itself in _async_evaluate for why. + self._charge_seeded = False # "We issued a start and are waiting to see whether the car # draws." Set only in _carry_out, only when a start genuinely # reached the charger, so a charge this controller did not @@ -214,6 +219,15 @@ def disarm(self, reason: str) -> None: and arms a 60-minute back-off for a start nobody is waiting on. A back-off already armed goes too — it was armed to stop this controller retrying, and the user has just taken over anyway. + + The seeding flag goes with it too: left set across a disarm, + a re-arm onto the same still-running charge would see + _charge_seeded already True and never reseed _started_at, + which this same method has just cleared to None — the minimum + run time would then read as unelapsed for ever, on a charge + already minutes or hours old, and solar control could never + stop it. The mirror image of the restart bug, reached through + disarm/re-arm instead of a reboot. """ if self._mode is SolarMode.OFF: return @@ -225,6 +239,7 @@ def disarm(self, reason: str) -> None: self._collapsed_since = None self._started_at = None self._start_issued_at = None + self._charge_seeded = False self._backoff_until = 0.0 self._notify() @@ -459,6 +474,41 @@ async def _async_evaluate(self) -> None: state = self._build_state(smoothed, now) self._track_thresholds(state, now, surplus) + # Timers begin at zero after a restart. A charge that is + # already running has, by definition, been running: without + # this the minimum run time reads as unelapsed and a healthy + # charge could be stopped moments after boot. + # + # Once per charging episode, not once per tick. _carry_out + # clears the minimum-run clock after a stop it *sent*, and + # "sent" includes one only queued for the background retry — + # where the charger is still charging. Re-seeding on the next + # tick would put the clock back, decide() would return STOP + # again, and it would do so every two minutes until the hourly + # backstop tripped forty minutes later. A stop that will not + # land is the background retry's business, and the spec says + # so: "hand to the existing background retry; do not retry + # here." + # + # The flag resets when the charge is observed to end, so the + # next one — including a charge the user starts by hand — is + # seeded in its turn. A flag that only ever set once would + # leave that later charge with a zero minimum-run clock for + # ever, and solar control could never stop it. + # + # This seeds the minimum-run clock only. The draw-grace clock + # is a separate attribute, set solely when this controller + # issues a start of its own, and it must stay unset here: a + # charge that was already running was never ours to judge, and + # a charger sitting in waiting_for_ev at 0 W at boot would + # otherwise arm an hour-long back-off on a healthy charge. + if not state.charging: + self._charge_seeded = False + elif not self._charge_seeded: + self._charge_seeded = True + if self._started_at is None: + self._started_at = now - MIN_RUN_SECONDS + # A start only simulated never reached the charger, so the # car was never given the chance to draw. Checking anyway # would let a dry run arm a real hour-long back-off from a diff --git a/tests/test_entities.py b/tests/test_entities.py index 4a3931e..57dc4bf 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -127,7 +127,10 @@ def async_create( _records=notifications, ) _module("homeassistant.components.number", NumberEntity=object) - _module("homeassistant.components.select", SelectEntity=object) + _module( + "homeassistant.components.select", + SelectEntity=type("SelectEntity", (), {}), + ) _module("homeassistant.components.switch", SwitchEntity=object) _module( "homeassistant.components.sensor", @@ -182,7 +185,8 @@ def async_create( ) _module("homeassistant.helpers.entity_platform", AddEntitiesCallback=object) _module( - "homeassistant.helpers.restore_state", RestoreEntity=object + "homeassistant.helpers.restore_state", + RestoreEntity=type("RestoreEntity", (), {}), ) _module("homeassistant.helpers.config_validation", positive_int=int) @@ -1375,6 +1379,54 @@ def __init__(self) -> None: assert entry.options["poll_interval"] == 30 +def test_the_solar_select_restores_its_mode() -> None: + """The spec asks for restoration across a restart by name. + + Without it every Home Assistant restart silently disarms solar + control: the select comes back "off", the car stops following the + sun, and nothing says so. + """ + entity, controller = _solar_select(configured=True) + + class LastState: + state = "active" + + async def _last_state() -> Any: + return LastState() + + entity.async_get_last_state = _last_state + + asyncio.run(entity.async_added_to_hass()) + + assert controller.mode is not None + assert controller.mode.value == "active" + + +def test_the_solar_select_defaults_to_simulate_with_no_stored_state() -> None: + """No stored state at all is not a restart; it is this select + existing for the first time. + + The spec's Rollout section calls this "first enable" and asks it + to land in simulate, not active — the safe dry run, so a fresh + install never drives real hardware before anyone has looked at + what it would decide. Leaving the mode alone here would strand it + at the controller's own constructor default (off), which is correct + before this entity has ever run once but wrong the first time it + does. + """ + entity, controller = _solar_select(configured=True) + + async def _last_state() -> Any: + return None + + entity.async_get_last_state = _last_state + + asyncio.run(entity.async_added_to_hass()) + + assert controller.mode is not None + assert controller.mode.value == "simulate" + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 5069c3a..3502a7f 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -10,6 +10,7 @@ import asyncio import importlib.util import sys +import time import types from pathlib import Path from typing import Any @@ -719,14 +720,22 @@ def test_unknown_charging_status_does_not_assume_zero_draw() -> None: confirmed not-charging status is. Otherwise an assumed zero draw, computed from a payload that may be gone a moment later, still feeds the five-minute average for the ticks that follow it. + + Exercises _read_surplus directly rather than through a full tick: + with an entirely empty payload, unsupported_reason's own guard ("the + charger has not reported yet") now makes async_tick stand down + before it ever reaches a sensor, so decide() is never reached from + a live tick for this exact case any more. The zero-draw guard this + test protects is still correct and still reachable if that earlier + guard is ever relaxed, so it is checked at its own layer instead of + one that can no longer reach it — the same move + test_no_coordinator_data_reads_as_not_reachable already made for + charger_reachable. """ controller, coordinator, _ = build() - controller.mode = controller_module.SolarMode.SIMULATE coordinator.data = {} - asyncio.run(controller.async_tick()) - - assert controller.surplus_w is None + assert controller._read_surplus() is None def test_a_timeout_does_not_raise_out_of_the_tick() -> None: @@ -1942,6 +1951,167 @@ def test_the_tick_stands_down_for_an_unsupported_setup_set_directly() -> None: ) +def test_a_charge_already_running_counts_as_having_run() -> None: + """Timers start at zero after a restart. Without seeding, an + unelapsed minimum run time could stop a healthy charge moments + after boot.""" + controller, _, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + asyncio.run(controller.async_tick()) + + # Assert how far back the mark was seeded, not merely that one exists. + # Seeding it to the present moment would satisfy "is not None" while + # leaving the charge unstoppable for the next ten minutes, which is the + # bug this seeding exists to prevent. + assert controller._started_at is not None + elapsed = time.monotonic() - controller._started_at + assert elapsed >= solar.MIN_RUN_SECONDS, ( + "a charge already running must count as having served its minimum " + f"run time, but the mark was seeded only {elapsed:.0f}s back" + ) + + +def test_a_stop_that_could_not_be_sent_is_not_re_issued_every_tick() -> None: + """_carry_out clears the minimum-run clock after a stop it sent, + and "sent" includes one only queued for the background retry — + where the charger is still charging. Seeding that clock again on + the next tick makes decide() return STOP again, and again every + two minutes, until the hourly backstop trips forty minutes later. + Handing a stuck link to the background retry and leaving it there + is the spec's own rule; this is why the seeding is once per charge + and not once per tick. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [24_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + hass.states.set( + "sensor.grid_import", "3000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_tick()) # starts the below-floor timer + + clock[0] += solar.STOP_DELAY_SECONDS + 1 + + async def _rpc_failure(serial: str, attempts: int = 8) -> dict: + coordinator.api_client.calls.append(("stop", serial)) + raise api_module.ApiCommandRejectedError( + "unreachable", code=api_module.COMMAND_ERROR_CODE_RPC_FAILURE + ) + + coordinator.api_client.async_stop_charge = _rpc_failure + asyncio.run(controller.async_tick()) + + for _ in range(3): + clock[0] += solar.TICK_SECONDS + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + stops = len( + [call for call in coordinator.api_client.calls if call[0] == "stop"] + ) + assert stops == 1, f"the queued stop was re-issued: {stops} attempts" + + +def test_a_charge_that_starts_later_is_seeded_in_its_turn() -> None: + """Once per charging episode, not once per lifetime. + + A charge the user starts by hand an hour from now has also been + running longer than we have been watching it. If the flag never + reset, that charge's minimum-run clock would read as zero for ever + and solar control could never stop it — the mirror image of the + bug the seeding exists to fix. + """ + controller, coordinator, _ = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.SIMULATE + + asyncio.run(controller.async_tick()) + assert controller._started_at is None + + coordinator.data = dict(CHARGING_DATA) + asyncio.run(controller.async_tick()) + + assert controller._started_at is not None + + +def test_simulate_previews_a_stop_on_an_already_running_charge() -> None: + """The seeding must sit outside the `if self._mode is + SolarMode.ACTIVE:` gate. + + A simulate dry run of a charge that is already running has to + preview the stop it would make. Seeded only under the ACTIVE gate, + a SIMULATE tick would never populate _started_at, and decide() + would report "the minimum run time has not elapsed" forever instead + of the STOP a real arm would make. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.SIMULATE + + clock = [25_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + hass.states.set( + "sensor.grid_import", "3000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_tick()) # seeds, starts the timer + + clock[0] += solar.STOP_DELAY_SECONDS + 1 + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert controller.last_decision is not None + assert controller.last_decision.action is solar.SolarAction.STOP, ( + "expected a previewed STOP, got: " + f"{controller.last_decision.reason!r}" + ) + assert coordinator.api_client.calls == [], "simulate must send nothing" + + +def test_disarm_clears_the_seeding_flag_so_a_rearm_can_reseed() -> None: + """The mirror bug, reached through disarm/re-arm instead of a + reboot. + + disarm() clears _started_at as part of ending the episode, but if + it left _charge_seeded set, a re-arm onto the very same + still-running charge would see the flag already True and never + reseed the clock it had just zeroed — the minimum run time would + then read as unelapsed for ever, on a charge already minutes old, + and solar control could never stop it. + """ + controller, _, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + asyncio.run(controller.async_tick()) + assert controller._started_at is not None + assert controller._charge_seeded is True + + controller.disarm("the charging limit was set manually") + assert controller._started_at is None + assert controller._charge_seeded is False + + controller.mode = controller_module.SolarMode.ACTIVE + asyncio.run(controller.async_tick()) + + assert controller._started_at is not None + elapsed = time.monotonic() - controller._started_at + assert elapsed >= solar.MIN_RUN_SECONDS, ( + "the re-armed charge was not reseeded, so it can never be stopped" + ) + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From ab943c26c73eba36a9e97baee276e0517d092cee Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 15:37:39 +0100 Subject: [PATCH 76/82] docs: say what a fresh install actually shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rollout item 1 read 'ship with solar control defaulting to off', but the select sets simulate on its first add, so off is never what a user sees. Both facts are true of different objects — the controller ships off, the entity lands on simulate — and the line collapsed them into one claim that the README was about to repeat. Co-Authored-By: Claude Opus 5 --- .../specs/2026-09-29-solar-surplus-control-design.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md b/docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md index c5bbe40..5071138 100644 --- a/docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md +++ b/docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md @@ -322,8 +322,12 @@ decisions against actual production. ## Rollout -1. Ship with solar control defaulting to `off`. -2. First enable lands in `simulate`, not `active`. +1. Ship with solar control inert. The controller itself defaults to + `off`, so nothing runs before the entities exist. +2. The select lands on `simulate` the first time it is added, and + restores whatever the user last chose after that. So a fresh install + *shows* `simulate` rather than `off`: it decides and logs, and sends + nothing. Nothing reaches the charger until the user picks `active`. 3. Document the validation day in the README and in `docs/solar-surplus-charging.md`, which becomes the "do it yourself" alternative rather than the only option. From d86b73ebf39818eaa962135d5bd2a9de4b986ef5 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 15:43:08 +0100 Subject: [PATCH 77/82] test: cover the seeding flag's release and two unguarded restore paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round 1 on Task 10. Four gaps the review found untestable by the existing suite, none requiring a production change: - The seeded flag's reset was never exercised: the existing test starts from a charger that was never charging, so the reset never has to fire. Added a test that seeds a real episode, lets solar stop it, observes the charger idle, and starts a second episode — the sequence that actually needs the release. - The inner `if self._started_at is None` guard, named in the brief, had nothing defending it. Added a test driving a solar-issued START through to the next tick and asserting the mark is not backdated. - The restore path's `last.state in SOLAR_MODE_OPTIONS` membership check was unguarded: an unrecognised stored state (`"unavailable"`, a state Task 9 makes newly reachable) would raise ValueError out of async_added_to_hass and take the entity down with it. - The restore path's direct assignment, bypassing async_select_option, had no assertion of its own beyond the inference from Task 9's stand-down test. Added a test restoring into an unsupported setup and asserting it does not raise. All four verified by mutation: broken, confirmed the named test failed with the reviewer's own reproduction, restored with a targeted edit. Co-Authored-By: Claude Sonnet 5 --- tests/test_entities.py | 60 ++++++++++++++++ tests/test_solar_controller.py | 122 +++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) diff --git a/tests/test_entities.py b/tests/test_entities.py index 57dc4bf..e4fc8cf 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -1427,6 +1427,66 @@ async def _last_state() -> Any: assert controller.mode.value == "simulate" +def test_restoring_an_unrecognised_stored_state_does_not_crash_the_entity() -> ( + None +): + """A stored state is not guaranteed to be one of the three modes. + + Task 9 made `available` false in four more situations than before, + so a stored state of "unavailable" is a likely one for exactly the + users who most need the control back — not exotic input. Without + the membership check, `SolarMode("unavailable")` raises ValueError + inside async_added_to_hass, the entity fails to add, and solar + control disappears from the dashboard entirely. + """ + entity, controller = _solar_select(configured=True) + + class LastState: + state = "unavailable" + + async def _last_state() -> Any: + return LastState() + + entity.async_get_last_state = _last_state + + asyncio.run(entity.async_added_to_hass()) # must not raise + + assert controller.mode is None, ( + "an unrecognised stored state must be left alone, not guessed at" + ) + + +def test_restoring_bypasses_the_selects_own_refusal() -> None: + """The restore writes to the controller directly rather than + through async_select_option, and this is the property that makes + Task 9's stand-down test cover the restore path at all — it + deserves its own assertion, not just an inference from that test. + + Routed through async_select_option instead, a setup that is + unsupported at startup (the charger has not polled yet, say) would + have the handler raise HomeAssistantError from inside + async_added_to_hass, failing the entity to add and losing the + stored mode to a race with the first refresh. + """ + entity, controller = _solar_select(configured=False) + assert entity.available is False, "the fixture must start refused" + + class LastState: + state = "active" + + async def _last_state() -> Any: + return LastState() + + entity.async_get_last_state = _last_state + + asyncio.run(entity.async_added_to_hass()) # must not raise + + assert controller.mode is not None + assert controller.mode.value == "active", ( + "the restore must bypass the refusal async_select_option enforces" + ) + + def _main() -> int: """Run every test in this module and report results.""" tests = [ diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 3502a7f..09cc639 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -2080,6 +2080,128 @@ def test_simulate_previews_a_stop_on_an_already_running_charge() -> None: assert coordinator.api_client.calls == [], "simulate must send nothing" +def test_the_seeded_flag_resets_so_a_later_charge_is_reseeded() -> None: + """The reset half of the flag, not just the initial set. + + test_a_charge_that_starts_later_is_seeded_in_its_turn starts from a + charger that was never charging, so _charge_seeded is already False + and the reset never has to fire — it exercises the initial set, not + the release. This drives the sequence that actually needs it: + charging (seeded), solar stops it for real, one tick observes the + charger idle, then a new charge begins. + + Without the reset, _charge_seeded stays True from the first + episode, the second charge is never reseeded, _started_at stays at + the None _carry_out's STOP left behind, and seconds_since_start + reads zero for ever — the charge can never be stopped, importing + from the grid indefinitely once surplus collapses again. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [30_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + asyncio.run(controller.async_tick()) # seeds the first episode + assert controller._charge_seeded is True + assert controller._started_at is not None + + # Collapse surplus and let the stop actually land. + hass.states.set( + "sensor.grid_import", "3000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_tick()) # starts the below-floor timer + clock[0] += solar.STOP_DELAY_SECONDS + 1 + asyncio.run(controller.async_tick()) # issues STOP + assert any( + call[0] == "stop" for call in coordinator.api_client.calls + ) + assert controller._started_at is None, ( + "the stop must have landed for this sequence to test anything" + ) + + # Surplus recovers, so the below-floor anchor from the first + # episode does not immediately stop the second one and mask + # what this test is actually checking. + hass.states.set( + "sensor.grid_import", "0", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "5000", {"unit_of_measurement": "W"} + ) + + # One tick observes the charger now idle. + coordinator.data = dict(NOT_CHARGING_DATA) + clock[0] += solar.TICK_SECONDS + asyncio.run(controller.async_tick()) + assert controller._charge_seeded is False, ( + "the flag must release once the charge is observed to end" + ) + + # A new charge begins — the user plugging back in, say. + coordinator.data = dict(CHARGING_DATA) + clock[0] += solar.TICK_SECONDS + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert controller._started_at is not None + elapsed = clock[0] - controller._started_at + assert elapsed >= solar.MIN_RUN_SECONDS, ( + "the later charge was not reseeded, so it can never be stopped: " + f"seconds_since_start={elapsed:.0f}" + ) + + +def test_the_backdate_guard_protects_a_solar_issued_start() -> None: + """The inner `if self._started_at is None` guard, named in the + brief, protects a start this controller just issued from being + backdated by the seeding on the very next tick. + + Without it, the tick after a solar-issued START — now that the + charger reports charging — would see _charge_seeded still False, + reseed _started_at to ten minutes in the past even though the real + start was seconds ago, satisfy MIN_RUN_SECONDS immediately, and let + the charge solar itself just started be stopped on the next dip — + the short-cycling this constant exists to prevent, on its own + charge. + """ + controller, coordinator, _ = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [40_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + asyncio.run(controller.async_tick()) # waits for the start delay + clock[0] += solar.START_DELAY_SECONDS + 1 + asyncio.run(controller.async_tick()) # issues START + assert any( + call[0] == "start" for call in coordinator.api_client.calls + ) + started_at = controller._started_at + assert started_at == clock[0], ( + "the start must have landed for this sequence to test anything" + ) + + # The charger now reports charging, matching the start that just + # landed. + coordinator.data = dict(CHARGING_DATA) + clock[0] += solar.TICK_SECONDS + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert controller._started_at == started_at, ( + "the solar-issued start's own mark was backdated by the seeding: " + f"was {started_at}, now {controller._started_at}" + ) + + def test_disarm_clears_the_seeding_flag_so_a_rearm_can_reseed() -> None: """The mirror bug, reached through disarm/re-arm instead of a reboot. From cac0bec54222ad96e0cf1ee0661debafd8b15daf Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 15:46:59 +0100 Subject: [PATCH 78/82] docs: document solar control Includes the simulate-first procedure, because a feature that starts and stops the car should be watched for a day before it is trusted, and what makes the control unavailable, because a feature that refuses to arm has to say why somewhere a user will look. Corrects four points found in review after the task brief was written: a fresh install shows simulate, not off; entity IDs are examples, not guaranteed, since nothing in this integration sets a translation key or explicit name; the supply-phase declaration is a third options-flow field with no default, and existing installs will find solar control refusing to arm until they answer it; and the validation-day checklist now names the specific checks, including the one guard whose positive direction has never been observed on real hardware. Co-Authored-By: Claude Sonnet 5 --- README.md | 91 ++++++++++++++++++++++++++++ custom_components/daze/manifest.json | 2 +- docs/solar-surplus-charging.md | 5 ++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 50d615e..ed89807 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ If your tokens expire, the integration will automatically prompt you to re-enter | `sensor.daze_last_session_end` | Last Session End | `timestamp` | — | | | `sensor.daze_lifetime_energy` | Lifetime Energy | `energy` | `total_increasing` | Wh | | `sensor.daze_total_sessions` | Total Sessions | — | `total_increasing` | sessions | +| `sensor.daze_solar_surplus` | Solar surplus | `power` | `measurement` | W | #### Diagnostic sensors @@ -113,6 +114,14 @@ If your tokens expire, the integration will automatically prompt you to re-enter | Number | `number.daze_max_charging_current` | Current | Charging current limit, bounded by the charger's own floor and the installation rating | | Number | `number.daze_max_charging_power` | Power | The same limit in watts, bounded by the charger's 1.5 kW floor | | Select | `select.daze_operation_mode` | Operation Mode | Switch between eco, fast, scheduled | +| Select | `select.daze_solar_control` | Solar control | `off` / `simulate` / `active` | +| Number | `number.daze_solar_reserve` | Solar reserve | Watts to leave for the house before the car gets any | + +No entity in this integration sets an explicit name or translation +key, so none of the IDs above are guaranteed — they follow the device +name, and a renamed device changes the prefix. Confirm the real object +IDs for your own install under **Settings → Devices & services → +[your device] → entities** before using them in an automation. --- @@ -152,6 +161,88 @@ data: --- +## Solar control + +Charges the car from what the house would otherwise export, adjusting +the limit as production and load change, and stopping when there is not +enough surplus to charge at all. + +The controller itself defaults to **off**, so nothing runs before the +entities exist. But the **Solar control** select lands on `simulate` +the first time it is added — a fresh install never actually shows +`off`. In `simulate` it decides and logs but sends nothing to the +charger; nothing reaches hardware until you pick `active` yourself. + +1. In the integration's options, pick your **grid import** and **grid + export** power sensors, and answer **grid supply**: single-phase or + three-phase. This is a declaration, not something the integration + can detect — the Daze API does not report how many phases feed the + house — and solar control refuses to arm until it is answered. If + you are upgrading from an earlier version, this is the field that + will make solar control refuse to arm until you go and set it. +2. Leave **Solar control** on `simulate`. The select's attributes show + the surplus it sees and what it would have done. +3. Leave it for a day, then work through the validation checklist + below before switching to `active`. +4. If the decisions look right, set it to `active`. + +It never imports to charge: the charger cannot run below 1500 W, so +when surplus falls below that it stops rather than topping up from the +grid. + +Changing the charging limit yourself — from the dashboard, or from your +own automation — turns solar control off. Starting or stopping the +charge by hand does the same. It does not fight you. + +### When the control is unavailable + +Solar control refuses to arm rather than guess, and says why in the +log (`Solar control cannot run: …`). It is unavailable when: + +- **Both grid sensors are not set.** It has nothing to measure. +- **The grid supply has not been declared.** The charger cannot tell + the integration how many phases feed the house, so you have to say + so yourself, and there is no default. A three-phase meter reports + surplus added up across all three phases; a single-phase charger can + only use one of them, so following that figure would load one phase + with all three phases' surplus. For the same reason, a **three-phase + supply with a single-phase charger is refused outright** — see the + YAML guide below if that is your setup. +- **The charger's own eco mode is on, or it has a schedule set.** + Something else is already deciding when the car charges, and two + controllers fighting over one charger is worse than either alone. + +### The reserve + +**Solar reserve** is watts to leave for the house before the car gets +any: set it to 500 and the car is only offered surplus above 500 W. It +is saved with the integration's settings and survives a restart. + +### Before you trust it + +A day in `simulate` is only useful if you actually check it against +what happened. Before switching to `active`: + +- **Does the surplus figure go to zero at night?** If it does not, a + sensor's sign convention is inverted. +- **Does it rise when the car stops charging?** It should not — that + means the car's own draw is being double-counted. +- **Set a schedule on the charger and confirm solar control refuses to + arm, then clear it and confirm it arms again.** This is the one guard + whose positive direction has never been confirmed on real hardware: + it reads the charger's `nextScheduleInfo` field, and all that has + actually been observed is that the field is null when no schedule is + set. +- **Confirm a smart-tariff pause does not populate `nextScheduleInfo`** + and so does not falsely refuse to arm. +- **Check the logged decisions against what actually happened** before + switching to `active`. + +For a version you build and tune yourself, see +[docs/solar-surplus-charging.md](docs/solar-surplus-charging.md). + +--- + ## Automation Examples For charging from solar surplus, see [docs/solar-surplus-charging.md](docs/solar-surplus-charging.md) — a worked setup that follows your export, respects the charger's 1.5 kW floor, and reads its bounds from the entity rather than hardcoding them. diff --git a/custom_components/daze/manifest.json b/custom_components/daze/manifest.json index 15fe8c0..f617f29 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/tarrinho/daze-addon/issues", "requirements": [], - "version": "0.1.6" + "version": "0.2.0" } diff --git a/docs/solar-surplus-charging.md b/docs/solar-surplus-charging.md index 7478a07..0a96bb0 100644 --- a/docs/solar-surplus-charging.md +++ b/docs/solar-surplus-charging.md @@ -7,6 +7,11 @@ exporting it. This is a worked example, not part of the integration. Everything here goes in your Home Assistant configuration. +> The integration can now do this itself — see **Solar control** in the +> README. This guide remains for setups the built-in version does not +> fit: a house battery to arbitrate with, tariff windows, or anything +> needing logic of your own. + --- ## What you need From 70f6111376d0eb7a596d4293a07dc15191f6b52b Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 15:50:57 +0100 Subject: [PATCH 79/82] chore: keep the working rules file out of the repository rules.md holds this project's QA and working conventions, adapted from the WebConsole runbook. It stays local by operator decision: the entry is here rather than in .git/info/exclude so that any clone of this repository inherits the protection, since the incident it guards against was a git add -A. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ee73047..8b5ba3e 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ probe-*.txt # Subagent-driven development scratch /.superpowers/ + +# Working rules, kept local by operator decision 2026-09-29 +/rules.md From 4dc4f82e62a8a1043edb05ca2ad0b84a17bbe26c Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 16:10:36 +0100 Subject: [PATCH 80/82] fix: close five cross-task defects found in the final branch review A fresh whole-branch review (final-review.md) found five defects that live between tasks rather than inside any one of them, all local to solar_controller.py and solar.py: - C1: a STOP only queued for the background retry left _charge_seeded True forever if that retry chain exhausted, stranding the min-run clock at 0.0 and leaving the car importing from the grid indefinitely. _send_command now takes an on_retry_exhausted hook; the STOP send releases _charge_seeded when the retry gives up. - C2: the mode setter cleared only 3 of 8 episode clocks while disarm() cleared 7, so a select round-trip (off, then back to active) stranded _collapsed_since, _started_at, _charge_seeded and _backoff_until. Extracted _reset_episode(), now called by both the setter and disarm(). - I1: a raw dip below the floor (an oven cycling, say) reset _above_since even when the smoothed surplus never left the healthy range, so the 300s start delay could never accrue. Split the combined if/else in _track_thresholds into two independent conditions, so _above_since answers only to the smoothed figure while _below_since's existing anchor behaviour is preserved exactly (it is the De Morgan negation of the old combined condition). - I2: _carry_out did not re-test the mode across its own awaits, so a disarm landing mid-START (e.g. the power slider) still let the start-charge send through afterwards. Added a mode re-check before that send. - I3: the ignored-start back-off suppressed STOP unconditionally for the full hour, including once the car started drawing on its own. Gated the guard on `not state.charging`. Each fix carries a new test in tests/test_solar_controller.py that fails under its own reverted mutation (verified individually before this commit) and the existing 272 tests are unchanged and passing. 272 -> 277 passed across 8 modules; ruff check clean. Co-Authored-By: Claude Sonnet 5 --- custom_components/daze/solar.py | 8 +- custom_components/daze/solar_controller.py | 123 ++++++++-- tests/test_solar_controller.py | 257 +++++++++++++++++++++ 3 files changed, 371 insertions(+), 17 deletions(-) diff --git a/custom_components/daze/solar.py b/custom_components/daze/solar.py index 4697700..c8d8d77 100644 --- a/custom_components/daze/solar.py +++ b/custom_components/daze/solar.py @@ -144,7 +144,13 @@ def decide(state: SolarState) -> SolarDecision: if state.commands_this_hour >= MAX_COMMANDS_PER_HOUR: return _nothing("rate limit reached for this hour") - if state.backoff_remaining_s > 0: + # I3: this guard exists solely to stop the controller re-starting + # an idle charger the car ignored — not to stop it stopping. Gated + # on "not charging" so a car that wakes late and starts drawing on + # its own is still subject to the ordinary stop path below rather + # than importing from the grid, suppressed, for the rest of the + # hour-long back-off. + if not state.charging and state.backoff_remaining_s > 0: return _nothing( f"backing off for {int(state.backoff_remaining_s)}s after a " "start the car ignored" diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index 2b6d74e..c7fbc85 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -191,18 +191,23 @@ def mode(self) -> SolarMode: @mode.setter def mode(self, value: SolarMode) -> None: - """Set the mode, resetting timers when it changes.""" + """Set the mode, resetting every episode clock when it changes. + + Shares _reset_episode with disarm(): both take solar control + out of ACTIVE, and a select round-trip (off, then back to + active) must end the episode exactly as thoroughly as disarm + does, or four of the eight clocks survive it. Left set, + _collapsed_since re-anchors the stop clock to a mark measured + during a period nobody was watching and can issue an immediate + STOP on arming; left set the other way, _charge_seeded stays + True over a queued STOP's None _started_at and the minimum-run + clock never re-seeds, so the charge can never be stopped. + """ if value is self._mode: return self._mode = value - self._above_since = None - self._below_since = None - # A start issued before a detour through OFF or SIMULATE must - # not survive it: the first tick back in ACTIVE would evaluate - # it against an already-expired grace and arm an hour-long - # back-off from a start that may be long irrelevant by now. - self._start_issued_at = None + self._reset_episode() _LOGGER.info("Solar control set to %s", value.value) self._notify() @@ -234,14 +239,34 @@ def disarm(self, reason: str) -> None: _LOGGER.info("Solar control disarmed: %s", reason) self._mode = SolarMode.OFF + self._reset_episode() + self._notify() + + def _reset_episode(self) -> None: + """Clear every clock that describes the current charging episode. + + One definition, called from both disarm() and the mode setter, + so "ending an episode" means the same thing regardless of + which door was used to end it — disarm, the number/switch + entities, a service call, or the select flipping to off and + back. Before this existed the setter only cleared three of the + eight clocks and disarm cleared seven, so a select round-trip + left _collapsed_since, _started_at, _charge_seeded and + _backoff_until stranded across it. + + Clearing _started_at and _charge_seeded together here is safe + even mid-charge: the seeding block in _async_evaluate re-seeds + _started_at from a charge already running on the very next + tick that observes it charging, which is the same outcome + disarm's own docstring above already relies on. + """ self._above_since = None self._below_since = None + self._start_issued_at = None self._collapsed_since = None self._started_at = None - self._start_issued_at = None self._charge_seeded = False self._backoff_until = 0.0 - self._notify() @property def reserve_w(self) -> float: @@ -782,14 +807,36 @@ def _track_thresholds( elif self._collapsed_since is None: self._collapsed_since = now - if available >= state.floor_w and self._collapsed_since is None: - self._below_since = None + # I1: kept as two independent conditions rather than one + # if/else, deliberately. Coupling them (as a single "available + # is healthy and collapsed_since is None" test previously did) + # let one instantaneous raw dip — an oven cycling on, say — + # reset _above_since even while the smoothed surplus never + # left the healthy range: _collapsed_since flips on and off + # every tick the raw reading dips, and each flip took the + # else-branch and cleared _above_since, so the 300s start + # delay could never accrue on a day with ample average + # surplus. _above_since answers a question only the smoothed + # figure should decide. + if available >= state.floor_w: if self._above_since is None: self._above_since = now else: self._above_since = None + + # _below_since is unaffected by that split: this is exactly + # the negation of the old combined condition + # (available >= floor and collapsed_since is None), so every + # existing anchor case — including the one where the smoothed + # figure is still healthy but the raw reading has already + # collapsed and _track_thresholds must anchor the stop clock + # to that collapse, not to whenever the average catches up — + # keeps behaving exactly as before. + if available < state.floor_w or self._collapsed_since is not None: if self._below_since is None: self._below_since = self._collapsed_since or now + else: + self._below_since = None def _check_ignored_start(self, now: float, car_connected: bool) -> None: """Back off if a car we started never began drawing. @@ -853,7 +900,11 @@ def _check_ignored_start(self, now: float, car_connected: bool) -> None: ) async def _send_command( - self, description: str, call: Callable[[], Any], retry_key: str + self, + description: str, + call: Callable[[], Any], + retry_key: str, + on_retry_exhausted: Callable[[], None] | None = None, ) -> bool | None: """Issue one command, handing a stuck link to the background retry. @@ -879,6 +930,13 @@ async def _send_command( retry, so a newer one supersedes an older one, and shared with whatever manual entity can act on the same physical setting so the two supersede each other too. + on_retry_exhausted: Called, in addition to the warning log, + if the background retry's own chain of attempts runs + out without the command ever landing. Only the STOP + path uses this today (see the C1 fix note in + _carry_out): a stop that never lands must not leave + the minimum-run clock seeded against a charge that + never actually stopped. Returns: True if the command reached the charger. None if it was @@ -890,6 +948,12 @@ async def _send_command( retried. """ + + def _on_failure(message: str) -> None: + _LOGGER.warning("%s", message) + if on_retry_exhausted is not None: + on_retry_exhausted() + try: await call() except ApiAuthError as err: @@ -901,9 +965,7 @@ async def _send_command( key=retry_key, action=call, description=description, - on_failure=lambda message: _LOGGER.warning( - "%s", message - ), + on_failure=_on_failure, ) return None @@ -959,6 +1021,19 @@ async def _carry_out(self, decision: SolarDecision, now: float) -> None: "stopping the charge", lambda: client.async_stop_charge(serial), charge_key, + # C1: a STOP only ever queued for the background retry + # clears _started_at below (stop_sent is not False) + # while the charge is still running, so _charge_seeded + # stays True and the minimum-run clock can never + # re-seed — decide() reads seconds_since_start as 0.0 + # for ever and the car imports from the grid until + # someone notices. If that retry chain later exhausts + # without the stop landing, release the seed so the + # very next tick that still observes charging re-seeds + # _started_at and a fresh STOP can be issued. + on_retry_exhausted=lambda: setattr( + self, "_charge_seeded", False + ), ) if stop_sent is True: self._coordinator.async_cancel_background_retry(charge_key) @@ -1007,6 +1082,22 @@ async def _carry_out(self, decision: SolarDecision, now: float) -> None: current_key ) + # I2: _carry_out awaits the current-set above, and a user + # action landing during that await (the power slider, the + # charge switch, a service call) calls disarm() — which + # sets the mode to OFF but does not, and cannot, cancel + # this coroutine already in flight. Without re-testing the + # mode here, resuming after that await would send the + # start-charge anyway: the car starts on grid power against + # the user's own action, and solar is now OFF, so it will + # never stop it either. Re-checked here rather than once at + # the top of _carry_out because the mode is guaranteed + # ACTIVE at entry (the only two callers, both in + # _async_evaluate, already filter OFF and SIMULATE) and can + # only have changed by drifting across an await since. + if self._mode is not SolarMode.ACTIVE: + return + # A limit only queued for the background retry has not # reached the charger yet. Starting anyway would run the # car at whatever limit it already had — importing from diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 09cc639..42bd3f9 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -172,6 +172,11 @@ def __init__(self, data: dict[str, Any]) -> None: self.refresh_delays: list[int] = [] self.background_retries: list[tuple[str, str]] = [] self.cancelled_retries: list[str] = [] + # Keyed separately from background_retries so existing tests + # unpacking that list's 2-tuples are undisturbed. Lets a test + # simulate the retry chain exhausting by invoking the callback + # itself, rather than actually waiting out BACKGROUND_RETRY_DELAYS. + self.background_retry_on_failure: dict[str, Any] = {} def async_schedule_refresh_in(self, delay: int) -> None: self.refresh_delays.append(delay) @@ -185,6 +190,7 @@ def async_retry_in_background( ) -> None: """Record a hand-off instead of actually retrying anything.""" self.background_retries.append((key, description)) + self.background_retry_on_failure[key] = on_failure def async_cancel_background_retry(self, key: str) -> None: """Record a cancellation instead of actually dropping one.""" @@ -2234,6 +2240,257 @@ def test_disarm_clears_the_seeding_flag_so_a_rearm_can_reseed() -> None: ) +# ------------------------------------------------------------------ +# Final whole-branch review, 2026-09-29: findings that live between +# tasks, and so could not be seen by any single task's own tests. +# ------------------------------------------------------------------ + + +def test_c1_a_stop_whose_retry_chain_exhausts_reseeds_the_min_run_clock() -> ( + None +): + """C1: a STOP only queued for the background retry clears + _started_at while the charge is still running, so _charge_seeded + stays True and the minimum-run clock can never re-seed — + decide() reads seconds_since_start as 0.0 for ever and the car + imports from the grid until someone notices. If the retry chain + then exhausts without the stop ever landing, _charge_seeded must + release so the next tick that still observes charging re-seeds + _started_at and a fresh STOP can be issued. + """ + controller, coordinator, hass = build() + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [50_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + controller._started_at = clock[0] - solar.MIN_RUN_SECONDS - 1 + hass.states.set( + "sensor.grid_import", "3000", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", "0", {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_tick()) # starts the below-floor timer + + clock[0] += solar.STOP_DELAY_SECONDS + 1 + + async def _rpc_failure(serial: str, attempts: int = 8) -> dict: + raise api_module.ApiCommandRejectedError( + "unreachable", code=api_module.COMMAND_ERROR_CODE_RPC_FAILURE + ) + + coordinator.api_client.async_stop_charge = _rpc_failure + asyncio.run(controller.async_tick()) # issues STOP, queued for retry + + assert controller._started_at is None, ( + "the stop must have landed for this sequence to test anything" + ) + assert controller._charge_seeded is True, ( + "still charging, so the seed must still be held while the " + "retry is in flight" + ) + + # The retry chain exhausts without the stop ever landing. + charge_key = f"{coordinator.serial_number}:charge" + on_failure = coordinator.background_retry_on_failure.get(charge_key) + assert on_failure is not None, "the STOP send must register a retry" + on_failure("stopping the charge could not be delivered") + + assert controller._charge_seeded is False, ( + "the seed must release once the retry gives up, or the " + "minimum-run clock can never re-seed and the charge can " + "never be stopped" + ) + + # The next tick, still charging and still below the floor, + # must reseed _started_at and issue a fresh STOP — and this + # time the link is healthy, so it must actually land. + async def _stop_ok(serial: str, attempts: int = 8) -> dict: + coordinator.api_client.calls.append(("stop", serial)) + return {} + + coordinator.api_client.async_stop_charge = _stop_ok + clock[0] += solar.TICK_SECONDS + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + stops = [call for call in coordinator.api_client.calls if call[0] == "stop"] + assert stops, ( + "the charge was never reseeded after the retry gave up, so no " + "STOP was ever issued again: decide() would read " + "seconds_since_start as 0.0 for ever" + ) + + +def test_c2_a_select_round_trip_does_not_strand_the_four_episode_clocks() -> ( + None +): + """C2: the select's async_select_option sets the controller's mode + directly (select.py:366), the same setter this test drives. Before + this fix the setter cleared only three of the eight episode clocks + — _above_since, _below_since, _start_issued_at — while disarm() + cleared seven. A user turning the select off and back to active + left _collapsed_since, _started_at, _charge_seeded and + _backoff_until frozen across the round trip: _collapsed_since + stale for hours re-anchors the stop clock and can issue an + immediate STOP on arming; _started_at stuck at None with + _charge_seeded still True means seconds_since_start reads 0.0 for + ever and the charge can never be stopped. + """ + controller, _, _ = build() + controller.mode = controller_module.SolarMode.ACTIVE + + controller._collapsed_since = 100.0 + controller._started_at = 100.0 + controller._charge_seeded = True + controller._backoff_until = 1e9 + + # The select round trip: off, then back to active, exactly what + # select.py's async_select_option does across two user actions. + controller.mode = controller_module.SolarMode.OFF + controller.mode = controller_module.SolarMode.ACTIVE + + assert controller._collapsed_since is None, ( + "a stale collapse anchor survived the round trip" + ) + assert controller._started_at is None, ( + "a stale start mark survived the round trip" + ) + assert controller._charge_seeded is False, ( + "the seeding flag survived the round trip, so _started_at above " + "could never be reseeded" + ) + assert controller._backoff_until == 0.0, ( + "a stale back-off survived the round trip" + ) + + +def test_i1_an_intermittent_raw_dip_does_not_veto_the_smoothed_start_clock() -> ( + None +): + """I1: an oven cycling on and off flips the raw reading between + 3500 W and 1200 W against a 1518 W floor, while the smoothed + surplus stays comfortably above it throughout. Each raw dip below + the floor used to reset _above_since even though the smoothed + figure — the one decide()'s start path actually reads — never left + the healthy range, so the 300s start delay could never accrue and + the charge would never start, all afternoon, with ample surplus + exported the whole time. + """ + controller, _, hass = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [70_000_000.0] + start_ts = clock[0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + # Alternating raw readings: comfortably above the 1518 W floor, + # then below it, repeating — the smoothed average of these + # never drops below the floor (2350 W, 2733 W, 1967 W over the + # four ticks below), only the raw reading does. + for export in (3500, 1200, 3500, 1200): + hass.states.set( + "sensor.grid_import", "0", {"unit_of_measurement": "W"} + ) + hass.states.set( + "sensor.grid_export", str(export), {"unit_of_measurement": "W"} + ) + asyncio.run(controller.async_tick()) + clock[0] += solar.TICK_SECONDS + finally: + controller_module.time.monotonic = original_monotonic + + assert controller._above_since == start_ts, ( + "a raw dip reset the smoothed start clock even though the " + f"smoothed surplus never left the healthy range: " + f"_above_since={controller._above_since!r}, expected {start_ts!r}" + ) + + +def test_i2_a_disarm_mid_start_does_not_still_issue_the_start_charge() -> None: + """I2: _carry_out awaits the current-set call before sending the + start-charge. A user action landing during that await — dragging + the power slider, say — calls disarm(), which sets the mode to + OFF. Without re-testing the mode before the start-charge send, the + coroutine resumes and sends it anyway: the car starts on grid + power against the user's own action, and because solar is now + OFF, it will never stop it either. + """ + controller, coordinator, _ = build(NOT_CHARGING_DATA) + controller.mode = controller_module.SolarMode.ACTIVE + + clock = [80_000_000.0] + original_monotonic = controller_module.time.monotonic + controller_module.time.monotonic = lambda: clock[0] + try: + asyncio.run(controller.async_tick()) # waiting to confirm + clock[0] += solar.START_DELAY_SECONDS + 1 + + async def _set_current_then_disarm( + serial: str, current_ma: int, attempts: int = 8 + ) -> dict: + # Simulates the race: a manual write lands and disarms + # solar control while this command's own await is in + # flight, before the coroutine below resumes. + controller.disarm("the charging limit was set manually") + return {} + + coordinator.api_client.async_set_max_charging_current = ( + _set_current_then_disarm + ) + asyncio.run(controller.async_tick()) + finally: + controller_module.time.monotonic = original_monotonic + + assert controller.mode is controller_module.SolarMode.OFF, ( + "the disarm during the await must have taken effect for this " + "sequence to test anything" + ) + assert not any( + call[0] == "start" for call in coordinator.api_client.calls + ), "solar started the charge after being disarmed mid-command" + + +def test_i3_the_backoff_does_not_suppress_a_stop_on_a_car_that_is_drawing() -> ( + None +): + """I3: solar.py's ignored-start back-off exists to stop the + controller re-starting an idle charger the car ignored, not to + stop it stopping. Gated on backoff_remaining_s alone, a car that + wakes late and starts drawing on its own past the back-off's own + surplus collapse would import from the grid, suppressed, for the + rest of the hour. + """ + decision = solar.decide( + solar.SolarState( + surplus_w=0, + reserve_w=0, + floor_w=1518, + ceiling_w=7360, + charging=True, + current_limit_w=3000, + command_pending=False, + charger_reachable=True, + eco_mode_on=False, + schedule_set=False, + car_connected=True, + seconds_above_threshold=0, + seconds_below_threshold=solar.STOP_DELAY_SECONDS + 1, + seconds_since_start=solar.MIN_RUN_SECONDS + 1, + seconds_since_last_command=3600, + commands_this_hour=0, + backoff_remaining_s=1800, + ) + ) + assert decision.action is solar.SolarAction.STOP, ( + f"expected STOP, got {decision.action!r}: {decision.reason!r}" + ) + + def _main() -> int: """Run every test in this module and report results.""" tests = [ From ccd8c531be51e45bbe8d22f6ee5c77ca4b3cb3fa Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Tue, 29 Sep 2026 16:12:26 +0100 Subject: [PATCH 81/82] chore: ignore stray copies of the WebConsole runbook A byte-identical 253 KB copy of another project's rules.md appeared at this repo root as .rules.md, untracked and uncovered by the /rules.md entry. It holds no credentials, but it is another project's internal incident history and infrastructure detail, and a git add -A would have committed it. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 8b5ba3e..008972a 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ probe-*.txt # Working rules, kept local by operator decision 2026-09-29 /rules.md +# Stray copies of another project's runbook have landed here at least once. +# Ignore the dotted form too rather than relying on nobody running `git add -A`. +/.rules.md From 7bc5ecef37788ff0678a25c56ac8cd01e12a4e83 Mon Sep 17 00:00:00 2001 From: Pedro Tarrinho Date: Wed, 30 Sep 2026 08:59:25 +0100 Subject: [PATCH 82/82] fix: an unreported charging limit is not a limit of zero _build_state coerced maxExternalChargingCurrentInMilliAmps and then discarded the result with 'or 0.0', so an absent or unparseable limit entered SolarState as 0 W. current_limit_w is what decide() compares the target against across the 300 W deadband, so every target then looked like a large change and the first tick issued a SET to re-assert a limit that was most likely already correct, spending one of the twenty commands an hour on an unknown. The cycle is now skipped when the charger has not reported a limit, with its own warning flag. The threshold clocks are deliberately left alone, unlike the blind-sensor path above: this cycle did observe the surplus, and only the charger's own limit is missing. Found by running rules.md section 5 over the branch after the final review had passed it. Co-Authored-By: Claude Opus 5 --- custom_components/daze/solar_controller.py | 43 ++++++++++++++++++++-- tests/test_solar_controller.py | 28 ++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py index c7fbc85..9528ea9 100644 --- a/custom_components/daze/solar_controller.py +++ b/custom_components/daze/solar_controller.py @@ -179,6 +179,7 @@ def __init__( self._command_times: list[float] = [] self._sensor_warning_logged = False self._unsupported_warning_logged = False + self._limit_warning_logged = False # ------------------------------------------------------------------ # Public surface @@ -496,6 +497,22 @@ async def _async_evaluate(self) -> None: self._collapsed_since = None return + if self._current_limit_ma() is None: + # The surplus reading was real, so the threshold clocks keep + # what they have earned — unlike the blind-sensor path + # above, this cycle did observe the surplus. What it cannot + # observe is the charger's own limit, and every decision + # from here compares a target against it. + if not self._limit_warning_logged: + self._limit_warning_logged = True + _LOGGER.warning( + "Solar control cannot read the charger's own " + "charging limit; doing nothing until it reports" + ) + return + + self._limit_warning_logged = False + state = self._build_state(smoothed, now) self._track_thresholds(state, now, surplus) @@ -724,13 +741,33 @@ def _read_surplus(self) -> float | None: car_draw_w=car_w, export_w=export_w, import_w=import_w ) + def _current_limit_ma(self) -> float | None: + """The charger's own limit in milliamps, or None if unknown. + + Absent and zero are different facts. The limit feeds + `current_limit_w`, which `decide()` compares against the target + across the 300 W deadband: read as 0 mA, an unknown limit makes + every target look like a large change and produces a SET on the + first tick, spending one of the twenty hourly commands to + re-assert a limit that was probably already correct. + """ + return _coerce_float( + (self._coordinator.data or {}).get( + "maxExternalChargingCurrentInMilliAmps" + ) + ) + def _build_state(self, smoothed: float, now: float) -> SolarState: """Assemble everything the decision depends on.""" data = self._coordinator.data or {} - limit_ma = _coerce_float( - data.get("maxExternalChargingCurrentInMilliAmps") - ) or 0.0 + # 0.0 here is a structural placeholder, not a reading. The live + # path cannot reach it: _async_evaluate calls + # _current_limit_ma() and skips the cycle when the charger has + # not reported a limit, precisely so an unknown never enters + # the deadband comparison as zero. Tests that call _build_state + # directly supply their own payload. + limit_ma = self._current_limit_ma() or 0.0 charging = bool(is_charge_enabled(data)) # Not "schedules": that key is a list, which payload._scalars # drops from every source merge_payload flattens, so it never diff --git a/tests/test_solar_controller.py b/tests/test_solar_controller.py index 42bd3f9..edd8c41 100644 --- a/tests/test_solar_controller.py +++ b/tests/test_solar_controller.py @@ -2491,6 +2491,34 @@ def test_i3_the_backoff_does_not_suppress_a_stop_on_a_car_that_is_drawing() -> ( ) +def test_an_unknown_charging_limit_skips_the_cycle() -> None: + """A limit the charger has not reported is not a limit of zero. + + current_limit_w is what decide() compares the target against across + the 300 W deadband. Read as 0 mA, every target looks like a large + change, so the first tick issues a SET to re-assert a limit that + was most likely already correct — spending one of the twenty + commands an hour on an unknown. + + Asserts what was observed rather than only what was sent: with this + fixture the surplus does reach the smoother, so `calls == []` alone + would also pass if the cycle had run and merely decided nothing. + """ + data = dict(CHARGING_DATA) + del data["maxExternalChargingCurrentInMilliAmps"] + controller, coordinator, _ = build(data) + controller.mode = controller_module.SolarMode.ACTIVE + + asyncio.run(controller.async_tick()) + + assert coordinator.api_client.calls == [], ( + "a cycle ran against an unknown charging limit" + ) + assert controller.last_decision is None, ( + "the cycle reached decide() with no known charging limit" + ) + + def _main() -> int: """Run every test in this module and report results.""" tests = [