diff --git a/.gitignore b/.gitignore index 4585443..008972a 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,17 @@ credentials.* *.key *.cert *.p12 -*.pfx \ No newline at end of file +*.pfx +# Probe and diagnostic output (may contain device identifiers) +/log +*.out +probe-*.txt + +# Subagent-driven development scratch +/.superpowers/ + +# 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 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..d4a93e2 --- /dev/null +++ b/NOTICE @@ -0,0 +1,33 @@ +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 + +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 +------- + +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 4c83ff1..ed89807 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,16 @@ # 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/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. 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, 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). + --- ## Features @@ -15,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 @@ -33,7 +36,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 @@ -84,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 | @@ -92,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 @@ -107,8 +111,17 @@ 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 | +| 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. --- @@ -148,8 +161,92 @@ 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. + ### Stop charging when energy price is high ```yaml @@ -231,6 +328,76 @@ 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. + +### 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 -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. diff --git a/custom_components/daze/__init__.py b/custom_components/daze/__init__.py index 62cb78d..62521fc 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,14 @@ 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, + CONF_SUPPLY_PHASES, + DEFAULT_SOLAR_RESERVE, DOMAIN, PLATFORMS, SERVICE_SET_CHARGING_CURRENT, @@ -25,6 +30,8 @@ SERVICE_STOP_CHARGE, ) 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 @@ -67,6 +74,21 @@ 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 + ), + supply_phases=entry.options.get(CONF_SUPPLY_PHASES), + ) + # 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] = { @@ -74,6 +96,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 @@ -98,16 +122,65 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) if unload_ok: - # Clean up stored data - hass.data[DOMAIN].pop(entry.entry_id, None) + # 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: + controller = entry_data.get("solar_controller") + if controller is not None: + await controller.async_stop() + + coordinator: DazeDataUpdateCoordinator = entry_data["coordinator"] + coordinator.async_shutdown_timers() + + # 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 +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) @@ -131,11 +204,32 @@ 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.""" + if coordinator.solar_controller is not None: + coordinator.solar_controller.disarm( + "the charge was started by a service call" + ) + _refuse_if_offline() + try: await api_client.async_start_charge(serial_number) await coordinator.async_request_refresh() + coordinator.async_schedule_settle_refresh() except ApiAuthError as err: raise ConfigEntryAuthFailed( "Authentication failed when starting charge. " @@ -148,9 +242,16 @@ 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: await api_client.async_stop_charge(serial_number) await coordinator.async_request_refresh() + coordinator.async_schedule_settle_refresh() except ApiAuthError as err: raise ConfigEntryAuthFailed( "Authentication failed when stopping charge. " @@ -164,11 +265,18 @@ 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: await api_client.async_set_max_charging_current( 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/api/__init__.py b/custom_components/daze/api/__init__.py index 52e88a2..9019160 100644 --- a/custom_components/daze/api/__init__.py +++ b/custom_components/daze/api/__init__.py @@ -2,17 +2,39 @@ from __future__ import annotations +import asyncio import logging +import re from typing import Any from aiohttp import ClientSession 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__) +# 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): """Raised when the API returns 401 after a token refresh attempt. @@ -25,6 +47,112 @@ 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. + + 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_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. + COMMAND_ERROR_CODE_WRONG_SESSION: ( + "there is no paused charging session to resume" + ), + # 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" + ), + # 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" + ), +} + + +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. + + 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. + + 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. + + 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. @@ -65,6 +193,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. @@ -76,6 +205,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: @@ -103,18 +235,37 @@ 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( - "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}" + f"{response.status}: {body}", + status=response.status, ) return await response.json() @@ -182,6 +333,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( @@ -193,7 +349,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() @@ -211,14 +368,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 @@ -256,6 +457,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]: @@ -278,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. @@ -300,10 +530,13 @@ 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, 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. @@ -325,37 +558,207 @@ 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, attempts=attempts) + + async def _post_command( + self, + url: str, + payload: dict[str, Any], + attempts: int = COMMAND_RETRY_ATTEMPTS, + delay: float = COMMAND_RETRY_DELAY, + ) -> dict[str, Any]: + """POST a charger command, retrying transient RPC failures. + + 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, + or if every attempt hit the transient failure. + + """ + last_error: ApiError | None = None + + for attempt_number in range(1, attempts + 1): + try: + result = await self._request( + "POST", url, log_errors=False, 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: + 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, + wait, + ) + await asyncio.sleep(wait) + 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 %s gave up after %d attempts over %.0fs. The Daze " + "service could not reach the wallbox. Last error: %s", + url.rsplit("/", 1)[-1], + attempts, + _total_retry_seconds(attempts, delay), + last_error, + ) + raise ApiCommandRejectedError( + f"The Daze service could not reach the wallbox after " + 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 + + 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. - async def async_start_charge(self, serial: str) -> dict[str, Any]: - """Start charging on a wallbox. + 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, + attempts: int = COMMAND_RETRY_ATTEMPTS, + ) -> 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. 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" - 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._post_command(url, payload, attempts=attempts) - 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, + attempts: int = COMMAND_RETRY_ATTEMPTS, + ) -> 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. """ + if session_id is None: + session_id = await self._current_session_id(serial) + 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._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/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/config_flow.py b/custom_components/daze/config_flow.py index e5bfc78..80daa9a 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,13 +24,24 @@ 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, CONF_REFRESH_TOKEN, CONF_SERIAL_NUMBER, CONF_SOFTWARE_VERSION, + CONF_SOLAR_RESERVE, + CONF_SUPPLY_PHASES, + DEFAULT_POLL_INTERVAL, DOMAIN, + MAX_POLL_INTERVAL, + MIN_POLL_INTERVAL, + SUPPLY_PHASES_SINGLE, + SUPPLY_PHASES_THREE, ) +from .payload import device_name _LOGGER = logging.getLogger(__name__) @@ -303,7 +315,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", "") @@ -358,8 +372,89 @@ 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 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) + # 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, + self._config_entry.data.get( + CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL + ), + ) + + 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" + ) + ), + # 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, + ) + ), + } + ) - return self.async_show_form(step_id="init", data_schema=vol.Schema({})) + 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 0ba5689..b77f315 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" @@ -26,6 +38,66 @@ # 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 + +# 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" + +# 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" +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 +# 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 + +# 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) + +# 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 + DEFAULT_TOKEN_EXPIRY_BUFFER = 60 # seconds # Platform list diff --git a/custom_components/daze/coordinator.py b/custom_components/daze/coordinator.py index 9c568ba..463f1a8 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 @@ -11,27 +12,63 @@ 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, ) -from .api import ApiAuthError, ApiError, DazeApiClient +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, CONF_REFRESH_TOKEN, CONF_SERIAL_NUMBER, DEFAULT_POLL_INTERVAL, DOMAIN, + MAX_POLL_INTERVAL, + MIN_POLL_INTERVAL, ) from .models import RechargeSession +from .optimistic import OptimisticState +from .payload import merge_payload _LOGGER = logging.getLogger(__name__) 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 + +# 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 + +# 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] @@ -68,6 +105,24 @@ 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._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 + 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]] = [] + # 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, @@ -111,6 +166,244 @@ 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 + 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): + 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 + + 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: + """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]) + + @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. + + 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) + + self._limit_listeners.clear() + + _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 _schedule_tracked_refresh(self, delay: int, reason: str) -> None: + """Schedule one refresh and keep a handle so it can be cancelled. + + 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. + + Args: + delay: Seconds until the refresh runs. + reason: Used in the debug log. + + """ + handle: list[Any] = [] + + async def _refresh(_now: Any) -> None: + """Re-read the charger.""" + if handle: + self._pending_timers.discard(handle[0]) + + _LOGGER.debug( + "%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() + + 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. + + 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. + """ + self._next_evse_fetch = 0.0 + + for delay in SETTLE_REFRESH_DELAYS: + self._schedule_tracked_refresh(delay, "Settle") + async def _async_update_data(self) -> DazeCoordinatorData: """Fetch the latest socket remote info and session data. @@ -132,12 +425,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() @@ -170,8 +484,14 @@ 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: + fetched = await self._async_fetch_sessions() + if fetched is not None: + self._cached_sessions = fetched + + sessions = self._cached_sessions data["sessions"] = sessions data.update(self._compute_session_fields(sessions)) @@ -234,15 +554,17 @@ 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. """ try: @@ -256,36 +578,70 @@ async def _async_fetch_sessions( len(sessions_raw), 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 ] + 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, + ) + + # A missing endpoint genuinely means no history, unlike a + # transient error, so an empty list is the right answer. + return [] + except ApiAuthError: # Auth errors on session endpoint are unexpected (the # 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( @@ -305,6 +661,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] @@ -319,6 +688,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 0831407..f617f29 100644 --- a/custom_components/daze/manifest.json +++ b/custom_components/daze/manifest.json @@ -1,13 +1,15 @@ { "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.0" + "version": "0.2.0" } diff --git a/custom_components/daze/number.py b/custom_components/daze/number.py index 29314be..43a98aa 100644 --- a/custom_components/daze/number.py +++ b/custom_components/daze/number.py @@ -15,13 +15,41 @@ 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 -from .api import ApiAuthError, ApiError -from .const import DOMAIN +from .api import ( + COMMAND_ERROR_CODE_RPC_FAILURE, + ApiAuthError, + ApiCommandRejectedError, + ApiError, +) +from .const import ( + CONF_SOLAR_RESERVE, + DOMAIN, + INLINE_COMMAND_ATTEMPTS, + MAX_SOLAR_RESERVE, + POST_COMMAND_REFRESH_DELAY, +) from .coordinator import DazeDataUpdateCoordinator +from .payload import ( + POWER_STEP_W, + charger_offline_reason, + grid_cap_advice, + max_charging_current, + max_charging_power, + milliamps_to_watts, + min_charging_current, + min_charging_power, + validate_charging_current, + watts_to_milliamps, +) if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -30,9 +58,11 @@ _LOGGER = logging.getLogger(__name__) -# Industry-standard range for EVSE charging current limits -NATIVE_MIN_VALUE = 6000 # 6 A -NATIVE_MAX_VALUE = 32000 # 32 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 @@ -43,8 +73,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,25 +98,100 @@ def __init__( self._attr_unique_id = f"{serial_number}_max_charging_current" self._attr_device_info = device_info + 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: + """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. + + 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.""" + """Return the charging current limit in mA. + + 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. + """ + return self.coordinator.limit_state.resolve(self._reported_value) + + @property + def _reported_value(self) -> int | None: + """Return what the charger last reported.""" if self.coordinator.data is None: return None - # Primary field, then fallback - value = self.coordinator.data.get( - "maxExternalChargingCurrentInMilliAmps" - ) - if value is not None: - return int(value) - - value = self.coordinator.data.get("lastMaxChargingCurrent") - if value is not None: - return int(value) + for field in ( + "maxExternalChargingCurrentInMilliAmps", + "lastMaxChargingCurrent", + ): + value = self.coordinator.data.get(field) + if value is not None: + return int(value) return None + def _show_requested(self, value: int, awaiting_retry: bool) -> None: + """Display a requested value and re-read the charger later.""" + 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.coordinator.limit_state.clear() + self.async_write_ha_state() + 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.""" + self.coordinator.limit_state.settle(self._reported_value) + super()._handle_coordinator_update() + async def async_set_native_value(self, value: float) -> None: """Set the max charging current on the wallbox. @@ -106,6 +209,30 @@ 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) + 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) + + 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", @@ -113,9 +240,14 @@ 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_cancel_background_retry( + f"{self._serial_number}:current" + ) + self._show_requested(int_value, awaiting_retry=False) except ApiAuthError as err: _LOGGER.warning( "Auth error setting max current on %s: %s", @@ -126,6 +258,35 @@ 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: + 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._clear_requested, + ) + self._show_requested(int_value, awaiting_retry=True) + return + + _LOGGER.info( + "Charger refused the current change on %s: %s", + self._serial_number, + err, + ) + self._notify_error( + 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( "API error setting max current on %s: %s", @@ -147,15 +308,312 @@ 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 + + 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: + """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.""" + 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_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 + + for field in ( + "maxExternalChargingCurrentInMilliAmps", + "lastMaxChargingCurrent", + ): + value = self.coordinator.data.get(field) + if value is not None: + return int(value) + + return None + + 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) + + # 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 requesting %d mA, skipping", + watts, + milliamps, + ) + 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) + 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) + + 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)", + self._serial_number, + watts, + milliamps, + ) + await self._api_client.async_set_max_charging_current( + self._serial_number, + milliamps, + attempts=INLINE_COMMAND_ATTEMPTS, + ) + self.coordinator.async_cancel_background_retry( + f"{self._serial_number}:current" + ) + self._show_requested(milliamps, 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(milliamps, 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, 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.coordinator.limit_state.clear() + self.async_write_ha_state() + 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.""" + self.coordinator.limit_state.settle(self._reported_current) + 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}", + ) + + +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"] @@ -166,13 +624,31 @@ async def async_setup_entry( identifiers={(DOMAIN, serial_number)}, ) - async_add_entities( - [ - DazeWallboxNumberEntity( + 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/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 new file mode 100644 index 0000000..e4893b6 --- /dev/null +++ b/custom_components/daze/payload.py @@ -0,0 +1,564 @@ +"""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 + +import math +from datetime import datetime, timezone +from typing import Any + +# 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 +# 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_IDLE = 1 +EVSE_STATE_CHARGING = 3 +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" +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 + + if state == EVSE_STATE_PAUSED: + return STATUS_PAUSED + + if state == EVSE_STATE_WAITING_FOR_EV: + return STATUS_WAITING_FOR_EV + + 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 + + +# 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 + + +# The charging current ceiling comes from the installation rating. +# +# 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",) + +# 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 + + +def max_charging_current(data: dict[str, Any] | None) -> int: + """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. + + 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. + + 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)), 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 + 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 + + +# 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) + + +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." + ) + + +# 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 + + +# 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: + 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. + + 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 803879d..c1358f3 100644 --- a/custom_components/daze/select.py +++ b/custom_components/daze/select.py @@ -16,12 +16,26 @@ 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.restore_state import RestoreEntity 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, + POST_COMMAND_REFRESH_DELAY, +) from .coordinator import DazeDataUpdateCoordinator +from .optimistic import OptimisticState +from .payload import charger_offline_reason if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -97,14 +111,44 @@ def __init__( self._serial_number = serial_number self._attr_unique_id = f"{serial_number}_operation_mode" self._attr_device_info = device_info + self._optimistic = OptimisticState(tolerance=0) @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. + """ + return self._optimistic.resolve(self._reported_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) + def _show_requested(self, option: str, awaiting_retry: bool) -> None: + """Display a requested mode and re-read the charger later.""" + 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.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.""" + self._optimistic.settle(self._reported_option) + super()._handle_coordinator_update() + async def async_select_option(self, option: str) -> None: """Set the operation mode on the wallbox. @@ -133,6 +177,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 " @@ -142,9 +195,14 @@ 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_cancel_background_retry( + f"{self._serial_number}:mode" + ) + self._show_requested(option, awaiting_retry=False) except ApiAuthError as err: _LOGGER.warning( "Auth error setting operation mode on %s: %s", @@ -155,6 +213,27 @@ 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._clear_requested, + ) + self._show_requested(option, awaiting_retry=True) + 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", @@ -176,15 +255,127 @@ def _notify_error(self, message: str) -> None: ) +SOLAR_MODE_OPTIONS = ["off", "simulate", "active"] + + +class DazeSolarControlSelect( + CoordinatorEntity[DazeDataUpdateCoordinator], SelectEntity, RestoreEntity +): + """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, 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.""" + return self._controller.unsupported_reason is None + + @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 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() + + 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"] @@ -195,13 +386,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/sensor_catalog.py b/custom_components/daze/sensor_catalog.py index 4402e17..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] @@ -32,7 +33,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", @@ -57,6 +59,7 @@ def presence_on_off(data: dict[str, Any], key: str) -> str | None: _SCHEDULED_CHARGE_KEYS: tuple[str, ...] = ( + "nextScheduleInfo", "nextScheduledCharge", "scheduledChargeTime", "scheduledStart", @@ -64,12 +67,73 @@ 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 _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 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: - return value + + if value is None: + continue + + if isinstance(value, dict): + for field in _SCHEDULE_TIME_FIELDS: + parsed = _as_datetime(value.get(field)) + if parsed is not None: + return parsed + continue + + parsed = _as_datetime(value) + if parsed is not None: + return parsed + return None @@ -135,19 +199,26 @@ 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", device_class="enum", - options=("idle", "charging", "paused", "error", "offline"), + options=( + "idle", + "waiting_for_ev", + "charging", + "paused", + "error", + "offline", + ), value_fn=get_evse_status, ), EVSESensorSpec( @@ -155,14 +226,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/custom_components/daze/solar.py b/custom_components/daze/solar.py new file mode 100644 index 0000000..c8d8d77 --- /dev/null +++ b/custom_components/daze/solar.py @@ -0,0 +1,299 @@ +"""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") + + # 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" + ) + + 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", + ) + + +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 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 = [ + sample for sample in self._samples if sample[0] <= now + ] + + 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/custom_components/daze/solar_controller.py b/custom_components/daze/solar_controller.py new file mode 100644 index 0000000..9528ea9 --- /dev/null +++ b/custom_components/daze/solar_controller.py @@ -0,0 +1,1193 @@ +"""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 asyncio +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, + async_track_state_change_event, +) + +from .api import ( + COMMAND_ERROR_CODE_RPC_FAILURE, + ApiAuthError, + ApiCommandRejectedError, + ApiError, +) +from .const import SUPPLY_PHASES_SINGLE, SUPPLY_PHASES_THREE +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, + MIN_RUN_SECONDS, + TICK_SECONDS, + SolarAction, + SolarDecision, + SolarState, + SurplusSmoother, + compute_surplus, + decide, +) + +if TYPE_CHECKING: + from .coordinator import DazeDataUpdateCoordinator + +_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. + + 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, + reserve_w: float = 0.0, + supply_phases: str | None = None, + ) -> None: + """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. + 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. + 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)) + self._smoother = SurplusSmoother() + 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 + # 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 + # 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 + # 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 + self._unsupported_warning_logged = False + self._limit_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 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._reset_episode() + _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. + + 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 + + _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._charge_seeded = False + self._backoff_until = 0.0 + + @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) + + @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: + # 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" + + # 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 + + 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, 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. + + 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 + if self._cancel_listener is not None: + self._cancel_listener() + self._cancel_listener = None + self._listeners.clear() + + # ------------------------------------------------------------------ + # The cycle + # ------------------------------------------------------------------ + + 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 + + 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() + + 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 + _LOGGER.warning( + "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) + + smoothed = self._smoother.value() + if smoothed is None: + self._above_since = None + self._below_since = 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) + + # 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 + # start that never happened. + if self._mode is SolarMode.ACTIVE: + self._check_ignored_start(now, state.car_connected) + + decision = decide(self._build_state(smoothed, now)) + self._last_decision = decision + + 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 + + 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() + + 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 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() + 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 + # ------------------------------------------------------------------ + + 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: + if not self._stopped: + self._schedule_tick() + + self._cancel_tick = async_call_later(self._hass, TICK_SECONDS, _run) + + 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 + + state = self._hass.states.get(entity_id) + if state is None: + return None + + 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. + + 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 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.""" + 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_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 + ) + + 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 {} + + # 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 + # 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, + 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(limit_ma, data), + command_pending=self._coordinator.limit_state.pending, + # 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(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), + 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, 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 + + # 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. + + 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 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 + 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 + 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._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 + + data = self._coordinator.data or {} + draw = self._car_draw_w(data) + if draw is 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._start_issued_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, + on_retry_exhausted: Callable[[], None] | None = None, + ) -> 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, + 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 + 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, 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 + 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. + + """ + + 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: + _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=_on_failure, + ) + 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 + + 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). 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 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 + data = self._coordinator.data or {} + current_key = f"{serial}:current" + charge_key = f"{serial}:charge" + + _LOGGER.info( + "Solar control: %s — %s", decision.action.value, decision.reason + ) + + if decision.action is SolarAction.STOP: + self._command_times.append(now) + stop_sent = await self._send_command( + "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) + 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: + # 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) + 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 + ), + current_key, + ) + if sent is True: + self._coordinator.async_cancel_background_retry( + 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 + # 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) + 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 + ) + 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 + + self._command_times.append(now) + milliamps = watts_to_milliamps(decision.target_watts, data) + 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) + + def _notify(self) -> None: + """Tell the entities to redraw.""" + for listener in list(self._listeners): + listener() diff --git a/custom_components/daze/strings.json b/custom_components/daze/strings.json index 2887c9d..166473d 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" @@ -116,6 +124,9 @@ }, "next_scheduled_charge": { "name": "Next Scheduled Charge" + }, + "solar_surplus": { + "name": "Solar surplus" } }, "switch": { @@ -125,12 +136,46 @@ }, "number": { "max_charging_current": { - "name": "Max Charging Current" + "name": "Current" + }, + "max_charging_power": { + "name": "Power" + }, + "solar_reserve": { + "name": "Solar reserve" } }, "select": { "operation_mode": { "name": "Operation Mode" + }, + "solar_control": { + "name": "Solar control" + } + } + }, + "options": { + "step": { + "init": { + "title": "Daze Wallbox options", + "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)", + "grid_import_sensor": "Grid import 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/switch.py b/custom_components/daze/switch.py index c0ae91a..623e41a 100644 --- a/custom_components/daze/switch.py +++ b/custom_components/daze/switch.py @@ -16,12 +16,24 @@ 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, ApiError -from .const import DOMAIN +from .api import ( + COMMAND_ERROR_CODE_RPC_FAILURE, + ApiAuthError, + ApiCommandRejectedError, + ApiError, +) +from .const import ( + DOMAIN, + INLINE_COMMAND_ATTEMPTS, + POST_COMMAND_REFRESH_DELAY, +) from .coordinator import DazeDataUpdateCoordinator +from .optimistic import OptimisticState +from .payload import charger_offline_reason, is_charge_enabled if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -61,16 +73,71 @@ def __init__( self._serial_number = serial_number self._attr_unique_id = f"{serial_number}_charge_switch" self._attr_device_info = device_info + self._optimistic = OptimisticState(tolerance=0) @property def is_on(self) -> bool | None: - """Return True if the wallbox is currently charging.""" - 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 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. + + 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. + """ + actual = ( + is_charge_enabled(self.coordinator.data) + if self.coordinator.data is not None + else None + ) + + return self._optimistic.resolve(actual) + + @property + def assumed_state(self) -> bool: + """Tell the frontend when the shown state is a guess.""" + return self._optimistic.pending + + 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 + cloud still reports the old state, so the entity would flip + back before settling. + """ + self._optimistic.request(value, awaiting_retry) + 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.""" + actual = ( + is_charge_enabled(self.coordinator.data) + if self.coordinator.data is not None + else 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.""" @@ -81,12 +148,32 @@ 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) + 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 ) - await self._api_client.async_start_charge(self._serial_number) - await self.coordinator.async_request_refresh() + # 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, 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( "Auth error starting charge on %s: %s", @@ -97,6 +184,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: + if self._retry_in_background(err, True): + return + _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", @@ -118,12 +214,28 @@ 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) + 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 ) - await self._api_client.async_stop_charge(self._serial_number) - await self.coordinator.async_request_refresh() + 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( "Auth error stopping charge on %s: %s", @@ -134,6 +246,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: + if self._retry_in_background(err, False): + return + _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", @@ -145,6 +266,53 @@ 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._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/custom_components/daze/translations/it.json b/custom_components/daze/translations/it.json index ee4dd91..e77304b 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", @@ -121,6 +129,9 @@ "next_scheduled_charge": { "name": "Prossima carica programmata", "entity_category": "diagnostic" + }, + "solar_surplus": { + "name": "Surplus solare" } }, "switch": { @@ -130,13 +141,48 @@ }, "number": { "max_charging_current": { - "name": "Corrente massima di carica", + "name": "Corrente", + "entity_category": "config" + }, + "max_charging_power": { + "name": "Potenza", "entity_category": "config" + }, + "solar_reserve": { + "name": "Riserva solare" } }, "select": { "operation_mode": { "name": "Modalità operativa" + }, + "solar_control": { + "name": "Controllo solare" + } + } + }, + "options": { + "step": { + "init": { + "title": "Opzioni Daze Wallbox", + "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)", + "grid_import_sensor": "Sensore di potenza prelevata dalla 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/docs/solar-surplus-charging.md b/docs/solar-surplus-charging.md new file mode 100644 index 0000000..0a96bb0 --- /dev/null +++ b/docs/solar-surplus-charging.md @@ -0,0 +1,273 @@ +# 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. + +> 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 + +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. 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..ee9a04d --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-solar-surplus-control.md @@ -0,0 +1,4145 @@ +# 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 + +- **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. +- **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. +- **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, `28 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_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()) +``` + +- [ ] **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, `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** + +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) +- 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, 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** + +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 + + +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 +``` + +And append to `tests/test_solar_controller.py`, before `_main`: + +```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** + +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. + + 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._collapsed_since = None + self._started_at = None + self._start_issued_at = None + self._backoff_until = 0.0 + self._notify() +``` + +`_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 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. + +- [ ] **Step 4: Call it from the controls** + +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 = 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 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 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] = {...}`: + +```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), + 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() +``` + +Add `"solar_controller": solar_controller,` to the `hass.data[DOMAIN][entry.entry_id]` dict. + +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() +``` + +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, + CONF_SOLAR_RESERVE, + DEFAULT_SOLAR_RESERVE, +) +from .solar_controller import SolarController +``` + +merging the `const` names into the existing import block. + +- [ ] **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. + +- [ ] **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 charge was commanded by a service call" + ) +``` + +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!`, with three more tests than +the suite had before this task. + +- [ ] **Step 11: Commit** + +```bash +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. 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 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, + `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`, 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`: + +```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 _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: 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` +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, 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` 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( + coordinator=coordinator, + controller=entry_data["solar_controller"], + serial_number=serial_number, + device_info=device_info, + ), +``` + +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`: + +```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, + 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() +``` + +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** + +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 = 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 +``` + +`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** + +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, 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: Start the stop clock 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, 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. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_solar_controller.py`, before `_main`: + +```python +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 +``` + +- [ ] **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: 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. + +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_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** + +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 + + 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() +``` + +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 = [ + 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 + # 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: + +```python + if self._cancel_listener is not None: + self._cancel_listener() + 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 +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 **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 6: Run the tests to verify they pass** + +Run: `python3 tests/test_solar_controller.py` +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 7: Lint and full suite** + +Run: +```bash +ruff check custom_components/daze/ tests/ +python3 tests/run_all.py +``` +Expected: `All checks passed!`, 0 failures. + +- [ ] **Step 8: Commit** + +```bash +git add custom_components/daze/solar_controller.py tests/test_solar_controller.py +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. + +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 " +``` + +--- + +### Task 9: The supply declaration, and the refusals + +**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`, 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. +- 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. + +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` +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** + +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`: + +```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 + 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 +``` + +`_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 + 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: +```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 6: Implement the one refusal** + +Add to `SolarController`: + +```python + @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 +``` + +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._unsupported_warning_logged: + self._unsupported_warning_logged = True + _LOGGER.warning("Solar control cannot run: %s", unsupported) + return + + self._unsupported_warning_logged = False +``` + +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: 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 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 + # 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 +``` + +Add `self._charge_seeded = False` to `__init__`, and `MIN_RUN_SECONDS` +to the `from .solar import (...)` block. + +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 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) +``` + +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. + +- [ ] **Step 5: Move one test back to the layer it tests** + +`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 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 four more tests than the two files had +before this task. The absolute count is deliberately not stated; see +Task 5. + +- [ ] **Step 7: Lint and full suite** + +Run: +```bash +ruff check custom_components/daze/ tests/ +python3 tests/run_all.py +``` +Expected: `All checks passed!`, 0 failures. + +- [ ] **Step 8: Commit** + +```bash +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 + +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 control also remembers its mode across a restart. + +Co-Authored-By: Claude Opus 5 " +``` + +--- +### Task 11: Documentation + +**Files:** +- Modify: `README.md` +- Modify: `docs/solar-surplus-charging.md` +- Modify: `custom_components/daze/manifest.json` + +**Interfaces:** +- 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** + +In `README.md`, add to the Controls table: + +```markdown +| 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_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`: + +```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, 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 + 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. 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). +``` + +- [ ] **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, 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` + +- [ ] **Step 4: 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 5: Verify and commit** + +Run: `python3 tests/run_all.py` +Expected: 0 failures. + +```bash +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 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 " +``` + +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, 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 new file mode 100644 index 0000000..5071138 --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-solar-surplus-control-design.md @@ -0,0 +1,346 @@ +# 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. + + 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 + +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. + +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 | +|---|---| +| `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 — 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 — 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. + +--- + +## 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 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. + +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 + +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. + +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 | +|---|---|---| +| 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 (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 | + +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 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. + +--- + +## 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. diff --git a/tests/run_all.py b/tests/run_all.py new file mode 100755 index 0000000..e550e76 --- /dev/null +++ b/tests/run_all.py @@ -0,0 +1,89 @@ +#!/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", + "test_entities.py", + "test_solar.py", + "test_solar_controller.py", + "test_init_entry.py", + "test_config_flow.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_auth_getuser.py b/tests/test_auth_getuser.py new file mode 100644 index 0000000..0dc0d64 --- /dev/null +++ b/tests/test_auth_getuser.py @@ -0,0 +1,804 @@ +"""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 json +import logging +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() + +# 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 + + +# ------------------------------------------------------------------ +# 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 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 + + 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 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 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, NO_SESSION), + 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[-1]["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": []} + +# 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.""" + 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 "could not reach the wallbox" 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(200, NO_SESSION), 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) + # One lookup plus one command: the rejection is not retried. + assert len(session.calls) == 2 + 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 + + + +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 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") + + + +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"} + + + +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 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 = [ + 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_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..7f5e397 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,317 @@ +"""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 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" +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}) + + 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}) + + 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 = [ + 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 new file mode 100644 index 0000000..e4fc8cf --- /dev/null +++ b/tests/test_entities.py @@ -0,0 +1,1513 @@ +"""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, ClassVar + +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 + self.removers: list[Any] = [] + + 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 + + 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. + + 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=type("SelectEntity", (), {}), + ) + _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"} + ), + UnitOfPower=type("UnitOfPower", (), {"WATT": "W"}), + ) + _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, + async_track_state_change_event=lambda hass, entities, cb: ( + lambda: None + ), + ) + _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=type("RestoreEntity", (), {}), + ) + _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", "switch"): + 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() + +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"] +optimistic_module = sys.modules["daze_entities_under_test.optimistic"] +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]] = [] + 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.""" + 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.""" + 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 {} + + 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, + "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 coordinator.limit_state.pending is False + 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" + + + +# ------------------------------------------------------------------ +# 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 + + + +# ------------------------------------------------------------------ +# 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) + + +# ------------------------------------------------------------------ +# 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 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 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 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 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 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 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 + + @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)), + 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. + + 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. + """ + for option in ("simulate", "active"): + entity, controller = _solar_select(configured=False) + + raised = False + try: + asyncio.run(entity.async_select_option(option)) + except HomeAssistantError: + raised = True + + 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: + """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_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. + + 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"] + + 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 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 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 = [ + 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_init_entry.py b/tests/test_init_entry.py new file mode 100644 index 0000000..1263123 --- /dev/null +++ b/tests/test_init_entry.py @@ -0,0 +1,663 @@ +"""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), + async_track_state_change_event=lambda hass, entities, cb: ( + 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 + + +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} + + +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 + + +# ------------------------------------------------------------------ +# 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 +# ------------------------------------------------------------------ + + +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_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] = {} # 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)) + + 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_payload.py b/tests/test_payload.py new file mode 100644 index 0000000..154e41a --- /dev/null +++ b/tests/test_payload.py @@ -0,0 +1,824 @@ +"""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. + + 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 + + +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" + + + +# 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 _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, + } + 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" + + + +# ------------------------------------------------------------------ +# Optimistic switch state +# ------------------------------------------------------------------ + + +# ------------------------------------------------------------------ +# Charging current ceiling +# ------------------------------------------------------------------ + + +def test_ceiling_ignores_scc_limit() -> None: + """sccLimit mirrors the current setting, so it is not a ceiling. + + 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) == 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: + """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({"lastMaxInstallationCurrent": 100}) + == 6000 + ) + + +def test_ceiling_ignores_non_numeric_and_zero_values() -> None: + """Zero appears in unset fields and must not win.""" + data = {"lastMaxInstallationCurrent": 0} + assert payload.max_charging_current(data) == 32000 + + data = {"lastMaxInstallationCurrent": 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 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 + + + +# ------------------------------------------------------------------ +# 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 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 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) + + # 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: + """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 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" + + + +# ------------------------------------------------------------------ +# 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 = [ + 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_qa_invariants.py b/tests/test_qa_invariants.py new file mode 100644 index 0000000..62ec0a0 --- /dev/null +++ b/tests/test_qa_invariants.py @@ -0,0 +1,257 @@ +"""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 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 = [ + 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/tests/test_solar.py b/tests/test_solar.py new file mode 100644 index 0000000..c179f7f --- /dev/null +++ b/tests/test_solar.py @@ -0,0 +1,370 @@ +"""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: + """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: + """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 + + +# ------------------------------------------------------------------ +# 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. + + 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=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: + """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 new file mode 100644 index 0000000..edd8c41 --- /dev/null +++ b/tests/test_solar_controller.py @@ -0,0 +1,2545 @@ +"""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 time +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, attributes: dict[str, Any] | None = None + ) -> None: + self.state = state + self.attributes = attributes or {} + + +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, + attributes: dict[str, Any] | None = None, + ) -> None: + """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: + """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() + + +# 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: + module = types.ModuleType(name) + for key, value in attributes.items(): + setattr(module, key, value) + sys.modules[name] = module + + def async_call_later(hass: Any, delay: Any, action: Any) -> Any: + 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) + _module("homeassistant.helpers") + _module( + "homeassistant.helpers.event", + async_call_later=async_call_later, + async_track_state_change_event=lambda hass, entities, cb: ( + lambda: None + ), + ) + + +_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"] +api_module = sys.modules["daze_solar_ctl.api"] +payload_module = sys.modules["daze_solar_ctl.payload"] + + +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] = [] + 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) + + 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)) + 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.""" + self.cancelled_retries.append(key) + + +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}, +} + +# 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, + 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"} + ) + hass.states.set( + "sensor.grid_export", "5000", {"unit_of_measurement": "W"} + ) + + 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", + supply_phases=supply_phases, + ) + 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() + 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() + 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 + + # 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()) + + 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. + + 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", {"unit_of_measurement": "W"} + ) + + 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: + """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_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. + + 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() + controller.mode = controller_module.SolarMode.SIMULATE + seen: list[int] = [] + controller.add_listener(lambda: seen.append(1)) + + asyncio.run(controller.async_tick()) + + 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. + + 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() + coordinator.data = {} + + state = controller._build_state(5000.0, controller_module.time.monotonic()) + + assert state.charger_reachable is False + + +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_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. + + 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() + coordinator.data = {} + + assert controller._read_surplus() 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.""" + 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 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._start_issued_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._start_issued_at = 0.0 + + controller._check_ignored_start( + controller_module.time.monotonic(), car_connected=True + ) + + 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], car_connected=True) + 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 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 + + 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) == 2 + assert coordinator.api_client.calls == [ + ("current", 21700), + ("start", coordinator.serial_number), + ] + + +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 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 + _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 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 + + 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_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 + 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 + 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 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. + + _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") + + 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 + assert controller._collapsed_since 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 +): + """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 test_a_collapse_starts_the_stop_clock_when_it_happens() -> None: + """The ten-minute stop delay must run from the collapse, not from + 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 + 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 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()) + + # 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"} + ) + 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}" + ) + 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. + + 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 + + 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, 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 + + 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 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 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_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 + 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: + """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_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 + 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 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_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. + + 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" + ) + + +# ------------------------------------------------------------------ +# 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 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 = [ + 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) diff --git a/tools/probe_current_range.py b/tools/probe_current_range.py new file mode 100755 index 0000000..9dde32a --- /dev/null +++ b/tools/probe_current_range.py @@ -0,0 +1,353 @@ +#!/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" + +# 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, 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: + """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.""" + 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() + 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 and ( + 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: + status, body = set_current(token, serial, value) + line = describe(status, body) + verdict = "OK " if 200 <= status < 300 else " " + watts = round(value * voltage / 1000) + print(f" {verdict}{value:>6} mA ({watts:>5} W) {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: + 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 + + +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) diff --git a/tools/probe_sessions_endpoint.py b/tools/probe_sessions_endpoint.py new file mode 100755 index 0000000..660a6ee --- /dev/null +++ b/tools/probe_sessions_endpoint.py @@ -0,0 +1,336 @@ +#!/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 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") + 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 + + 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'}") + + 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") + + 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) 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) diff --git a/tools/qa_verify_current.py b/tools/qa_verify_current.py new file mode 100755 index 0000000..d70811a --- /dev/null +++ b/tools/qa_verify_current.py @@ -0,0 +1,483 @@ +#!/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 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:] + 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("\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.") + 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/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 new file mode 100755 index 0000000..6ab0d73 --- /dev/null +++ b/tools/try_resume.py @@ -0,0 +1,626 @@ +#!/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 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 + + +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 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 resume variants, most likely first. + + 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. + ( + 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 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, 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 + 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="") + + 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" + 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 + + 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 + + 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}") + print(f" body {json.dumps(body)}") + print(f"\nBaseline: evseState={state.get('evseState')} " + f"isPaused={state.get('isPaused')}") + + if not flags.get("assume_yes") and ( + 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 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") + 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}") + + 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, flags) + + 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) + + 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)