From 7bafc8d02dc000ce2b8b64c626bde13c2d0efc96 Mon Sep 17 00:00:00 2001 From: matjhgc534z67umb <153743740+matjhgc534z67umb@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:15:40 +0200 Subject: [PATCH] feat: add PVForecastHomeAssistant provider (#1258) * feat: add PVForecastHomeAssistant provider Reads a PV forecast time series directly from a Home Assistant entity attribute (matching the {"forecast": [{"datetime", "watts"}]} shape already exposed by common HA PV forecast integrations, e.g. Helios Forecast and Solcast) and feeds it into pvforecast_ac_power, following the same self-polling provider pattern as PVForecastVrm. Closes #1232. * fix: use MagicMock instead of monkeypatched Response for mypy requests.Response().json is a typed bound method; reassigning it to a lambda fails mypy's method-assign check. Use MagicMock(spec=...) instead, which mocks the response without fighting its static type. * fix: address PR review on PVForecastHomeAssistant provider Fixes two issues raised in review on PR #1258: - _update_data() left stale pvforecast_ac_power values in place when a refreshed forecast was empty or shorter than a previous one; it now clears the active forecast window before writing and raises instead of silently no-op'ing when the response has no usable data. - pvforecast.homeassistant.entity_id used a "select" widget with no manual-entry fallback in EOSdash, leaving it unusable in standalone mode where the entity list can't be resolved without SUPERVISOR_TOKEN; switched to a plain text field. Also fills in the config docs and openapi.json for the new pvforecast.homeassistant.* fields, which were missing from the original commit. Co-Authored-By: Claude Sonnet 5 * fix: preserve retained forecast history when clearing stale entries The previous fix cleared pvforecast_ac_power from start-of-day, but PredictionProvider deliberately retains historical records back to keep_datetime (prediction.historic_hours). Since Home Assistant forecasts are future-only, that clear wiped out retained history between midnight and the EMS start on every refresh. Narrow the clear to [ems_start_datetime, end_datetime) - the actual active forecast window, DST-adjusted - instead of the day boundary. Addresses review feedback from @NormannK on PR #1258. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Mathias Co-authored-by: Mathias Co-authored-by: Claude Sonnet 5 --- docs/_generated/configexample.md | 9 + docs/_generated/configpvforecast.md | 60 +++++ docs/_generated/openapi.md | 2 +- openapi.json | 97 +++++++- src/akkudoktoreos/prediction/prediction.py | 5 + src/akkudoktoreos/prediction/pvforecast.py | 9 + .../prediction/pvforecasthomeassistant.py | 179 ++++++++++++++ src/akkudoktoreos/server/dash/uihints.py | 6 + tests/test_prediction.py | 14 +- tests/test_pvforecasthomeassistant.py | 226 ++++++++++++++++++ 10 files changed, 600 insertions(+), 7 deletions(-) create mode 100644 src/akkudoktoreos/prediction/pvforecasthomeassistant.py create mode 100644 tests/test_pvforecasthomeassistant.py diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index 5a168116..c8827d32 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -244,6 +244,15 @@ "token": "your-token", "site_id": 12345 }, + "homeassistant": { + "entity_id": "sensor.pv_forecast", + "attribute": "forecast", + "datetime_key": "datetime", + "value_key": "watts", + "value_unit": "W", + "base_url": null, + "token": null + }, "pvlib": {}, "pvnode": { "api_key": "", diff --git a/docs/_generated/configpvforecast.md b/docs/_generated/configpvforecast.md index cd9fddab..478091bc 100644 --- a/docs/_generated/configpvforecast.md +++ b/docs/_generated/configpvforecast.md @@ -8,6 +8,7 @@ | Name | Environment Variable | Type | Read-Only | Default | Description | | ---- | -------------------- | ---- | --------- | ------- | ----------- | | forecastsolar | `EOS_PVFORECAST__FORECASTSOLAR` | `PVForecastForecastSolarCommonSettings` | `rw` | `required` | ForecastSolar provider settings | +| homeassistant | `EOS_PVFORECAST__HOMEASSISTANT` | `PVForecastHomeAssistantCommonSettings` | `rw` | `required` | Home Assistant provider settings | | max_planes | `EOS_PVFORECAST__MAX_PLANES` | `int | None` | `rw` | `0` | Maximum number of planes that can be set | | planes | `EOS_PVFORECAST__PLANES` | `list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting] | None` | `rw` | `None` | Plane configuration. | | planes_azimuth | | `List[float]` | `ro` | `N/A` | Compute a list of the azimuths per active planes. | @@ -42,6 +43,15 @@ "token": "your-token", "site_id": 12345 }, + "homeassistant": { + "entity_id": "sensor.pv_forecast", + "attribute": "forecast", + "datetime_key": "datetime", + "value_key": "watts", + "value_unit": "W", + "base_url": null, + "token": null + }, "pvlib": {}, "pvnode": { "api_key": "", @@ -124,6 +134,15 @@ "token": "your-token", "site_id": 12345 }, + "homeassistant": { + "entity_id": "sensor.pv_forecast", + "attribute": "forecast", + "datetime_key": "datetime", + "value_key": "watts", + "value_unit": "W", + "base_url": null, + "token": null + }, "pvlib": {}, "pvnode": { "api_key": "", @@ -257,6 +276,47 @@ ``` +### Common settings for pvforecast data from a Home Assistant entity + + +:::{table} pvforecast::homeassistant +:widths: 10 10 5 5 30 +:align: left + +| Name | Type | Read-Only | Default | Description | +| ---- | ---- | --------- | ------- | ----------- | +| entity_id | `str` | `rw` | `sensor.pv_forecast` | Home Assistant entity providing the PV forecast. | +| attribute | `str` | `rw` | `forecast` | Entity attribute holding the forecast list. | +| datetime_key | `str` | `rw` | `datetime` | Key for the timestamp in each forecast entry. | +| value_key | `str` | `rw` | `watts` | Key for the AC power value in each forecast entry. | +| value_unit | `Literal['W', 'kW']` | `rw` | `W` | Unit of the forecast value. Converted to W internally. | +| base_url | `str | None` | `rw` | `None` | Base URL of the Home Assistant instance. Only required when EOS is not running as a Home Assistant add-on (no SUPERVISOR_TOKEN available). | +| token | `str | None` | `rw` | `None` | Long-lived access token for the Home Assistant instance. Only required when EOS is not running as a Home Assistant add-on. | +::: + + + +**Example Input/Output** + + + +```json + { + "pvforecast": { + "homeassistant": { + "entity_id": "sensor.pv_forecast", + "attribute": "forecast", + "datetime_key": "datetime", + "value_key": "watts", + "value_unit": "W", + "base_url": null, + "token": null + } + } + } +``` + + ### Common settings for the Solcast PV forecast provider diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index c0ed688a..3a471e17 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -1,6 +1,6 @@ # Akkudoktor-EOS -**Version**: `v0.3.0.dev2608221553448643` +**Version**: `v0.3.0.dev2609011977897641` **Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period. diff --git a/openapi.json b/openapi.json index 2a3ea7ff..5e2406a9 100644 --- a/openapi.json +++ b/openapi.json @@ -8,7 +8,7 @@ "name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html" }, - "version": "v0.3.0.dev2608221553448643" + "version": "v0.3.0.dev2609011977897641" }, "paths": { "/v1/admin/cache/clear": { @@ -10803,6 +10803,10 @@ "$ref": "#/components/schemas/PVForecastVrmCommonSettings", "description": "Victron Remote Management (VRM) provider settings" }, + "homeassistant": { + "$ref": "#/components/schemas/PVForecastHomeAssistantCommonSettings", + "description": "Home Assistant provider settings" + }, "pvlib": { "$ref": "#/components/schemas/PVForecastPVLibCommonSettings", "description": "PVLib provider settings" @@ -10924,6 +10928,10 @@ "$ref": "#/components/schemas/PVForecastVrmCommonSettings", "description": "Victron Remote Management (VRM) provider settings" }, + "homeassistant": { + "$ref": "#/components/schemas/PVForecastHomeAssistantCommonSettings", + "description": "Home Assistant provider settings" + }, "pvlib": { "$ref": "#/components/schemas/PVForecastPVLibCommonSettings", "description": "PVLib provider settings" @@ -11097,6 +11105,93 @@ "title": "PVForecastForecastSolarCommonSettings", "description": "Common settings for the Forecast.Solar PV forecast provider." }, + "PVForecastHomeAssistantCommonSettings": { + "properties": { + "entity_id": { + "type": "string", + "title": "Entity Id", + "description": "Home Assistant entity providing the PV forecast.", + "default": "sensor.pv_forecast", + "examples": [ + "sensor.pv1_power_now" + ] + }, + "attribute": { + "type": "string", + "title": "Attribute", + "description": "Entity attribute holding the forecast list.", + "default": "forecast", + "examples": [ + "forecast" + ] + }, + "datetime_key": { + "type": "string", + "title": "Datetime Key", + "description": "Key for the timestamp in each forecast entry.", + "default": "datetime", + "examples": [ + "datetime" + ] + }, + "value_key": { + "type": "string", + "title": "Value Key", + "description": "Key for the AC power value in each forecast entry.", + "default": "watts", + "examples": [ + "watts" + ] + }, + "value_unit": { + "type": "string", + "enum": [ + "W", + "kW" + ], + "title": "Value Unit", + "description": "Unit of the forecast value. Converted to W internally.", + "default": "W", + "examples": [ + "W", + "kW" + ] + }, + "base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Base Url", + "description": "Base URL of the Home Assistant instance. Only required when EOS is not running as a Home Assistant add-on (no SUPERVISOR_TOKEN available).", + "examples": [ + "http://homeassistant.local:8123" + ] + }, + "token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token", + "description": "Long-lived access token for the Home Assistant instance. Only required when EOS is not running as a Home Assistant add-on.", + "examples": [ + null + ] + } + }, + "type": "object", + "title": "PVForecastHomeAssistantCommonSettings", + "description": "Common settings for pvforecast data from a Home Assistant entity." + }, "PVForecastImportCommonSettings": { "properties": { "import_file_path": { diff --git a/src/akkudoktoreos/prediction/prediction.py b/src/akkudoktoreos/prediction/prediction.py index 8c6cc8cf..cdad0198 100644 --- a/src/akkudoktoreos/prediction/prediction.py +++ b/src/akkudoktoreos/prediction/prediction.py @@ -55,6 +55,7 @@ from akkudoktoreos.prediction.loadvrm import LoadVrm from akkudoktoreos.prediction.predictionabc import PredictionContainer from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar +from akkudoktoreos.prediction.pvforecasthomeassistant import PVForecastHomeAssistant from akkudoktoreos.prediction.pvforecastimport import PVForecastImport from akkudoktoreos.prediction.pvforecastpvlib import PVForecastPVLib from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode @@ -106,6 +107,7 @@ loadforecast_vrm = LoadVrm() loadforecast_import = LoadImport() pvforecast_akkudoktor = PVForecastAkkudoktor() pvforecast_vrm = PVForecastVrm() +pvforecast_homeassistant = PVForecastHomeAssistant() pvforecast_pvlib = PVForecastPVLib() pvforecast_pvnode = PVForecastPVNode() pvforecast_forecastsolar = PVForecastForecastSolar() @@ -177,6 +179,7 @@ def prediction_providers() -> list[ loadforecast_import, \ pvforecast_akkudoktor, \ pvforecast_vrm, \ + pvforecast_homeassistant, \ pvforecast_pvlib, \ pvforecast_pvnode, \ pvforecast_forecastsolar, \ @@ -217,6 +220,7 @@ def prediction_providers() -> list[ loadforecast_vrm, pvforecast_akkudoktor, pvforecast_forecastsolar, + pvforecast_homeassistant, pvforecast_import, pvforecast_pvlib, pvforecast_pvnode, @@ -251,6 +255,7 @@ class Prediction(PredictionContainer): LoadVrm, PVForecastAkkudoktor, PVForecastForecastSolar, + PVForecastHomeAssistant, PVForecastImport, PVForecastPVLib, PVForecastPVNode, diff --git a/src/akkudoktoreos/prediction/pvforecast.py b/src/akkudoktoreos/prediction/pvforecast.py index 28a00ef4..7d0d6650 100644 --- a/src/akkudoktoreos/prediction/pvforecast.py +++ b/src/akkudoktoreos/prediction/pvforecast.py @@ -10,6 +10,9 @@ from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider from akkudoktoreos.prediction.pvforecastforecastsolar import ( PVForecastForecastSolarCommonSettings, ) +from akkudoktoreos.prediction.pvforecasthomeassistant import ( + PVForecastHomeAssistantCommonSettings, +) from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings from akkudoktoreos.prediction.pvforecastpvlib import PVForecastPVLibCommonSettings from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNodeCommonSettings @@ -27,6 +30,7 @@ def pvforecast_provider_ids() -> list[str]: return [ "PVForecastAkkudoktor", "PVForecastForecastSolar", + "PVForecastHomeAssistant", "PVForecastImport", "PVForecastPVLib", "PVForecastPVNode", @@ -208,6 +212,11 @@ class PVForecastCommonSettings(SettingsBaseModel): json_schema_extra={"description": "Victron Remote Management (VRM) provider settings"}, ) + homeassistant: PVForecastHomeAssistantCommonSettings = Field( + default_factory=PVForecastHomeAssistantCommonSettings, + json_schema_extra={"description": "Home Assistant provider settings"}, + ) + pvlib: PVForecastPVLibCommonSettings = Field( default_factory=PVForecastPVLibCommonSettings, json_schema_extra={"description": "PVLib provider settings"}, diff --git a/src/akkudoktoreos/prediction/pvforecasthomeassistant.py b/src/akkudoktoreos/prediction/pvforecasthomeassistant.py new file mode 100644 index 00000000..05493a0a --- /dev/null +++ b/src/akkudoktoreos/prediction/pvforecasthomeassistant.py @@ -0,0 +1,179 @@ +"""Retrieves pvforecast data from a Home Assistant entity attribute.""" + +import os +from typing import Any, Literal, Optional + +import requests +from loguru import logger +from pydantic import Field + +from akkudoktoreos.config.configabc import SettingsBaseModel +from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider +from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime + +# Supervisor API endpoint (injected automatically when running as a Home Assistant add-on) +CORE_API = "http://supervisor/core/api" + + +class PVForecastHomeAssistantCommonSettings(SettingsBaseModel): + """Common settings for pvforecast data from a Home Assistant entity.""" + + entity_id: str = Field( + default="sensor.pv_forecast", + json_schema_extra={ + "description": "Home Assistant entity providing the PV forecast.", + "examples": ["sensor.pv1_power_now"], + }, + ) + attribute: str = Field( + default="forecast", + json_schema_extra={ + "description": "Entity attribute holding the forecast list.", + "examples": ["forecast"], + }, + ) + datetime_key: str = Field( + default="datetime", + json_schema_extra={ + "description": "Key for the timestamp in each forecast entry.", + "examples": ["datetime"], + }, + ) + value_key: str = Field( + default="watts", + json_schema_extra={ + "description": "Key for the AC power value in each forecast entry.", + "examples": ["watts"], + }, + ) + value_unit: Literal["W", "kW"] = Field( + default="W", + json_schema_extra={ + "description": "Unit of the forecast value. Converted to W internally.", + "examples": ["W", "kW"], + }, + ) + base_url: Optional[str] = Field( + default=None, + json_schema_extra={ + "description": ( + "Base URL of the Home Assistant instance. Only required when EOS is not " + "running as a Home Assistant add-on (no SUPERVISOR_TOKEN available)." + ), + "examples": ["http://homeassistant.local:8123"], + }, + ) + token: Optional[str] = Field( + default=None, + json_schema_extra={ + "description": ( + "Long-lived access token for the Home Assistant instance. Only required " + "when EOS is not running as a Home Assistant add-on." + ), + "examples": [None], + }, + ) + + +class PVForecastHomeAssistant(PVForecastProvider): + """Fetch and process PV forecast data from a Home Assistant entity attribute. + + Reads a list of ``{: ..., : ...}`` entries from the + configured entity attribute (matching the ``forecast`` attribute shape exposed + by common Home Assistant PV forecast integrations, e.g. Helios Forecast or + Solcast) and maps it to ``pvforecast_ac_power``. + """ + + @classmethod + def provider_id(cls) -> str: + """Return the unique identifier for the PVForecastHomeAssistant provider.""" + return "PVForecastHomeAssistant" + + def _api_base_and_token(self) -> tuple[str, str]: + settings = self.config.pvforecast.homeassistant + if settings.base_url: + base_url = settings.base_url.rstrip("/") + "/api" + token = settings.token + else: + base_url = CORE_API + token = os.environ.get("SUPERVISOR_TOKEN") + if not token: + raise RuntimeError( + "No Home Assistant access token available. Set " + "'pvforecast.homeassistant.token' (and 'base_url') when EOS is not " + "running as a Home Assistant add-on." + ) + return base_url, token + + def _request_entity_state(self) -> dict[str, Any]: + settings = self.config.pvforecast.homeassistant + base_url, token = self._api_base_and_token() + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + url = f"{base_url}/states/{settings.entity_id}" + try: + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + except requests.RequestException as e: + logger.error(f"Failed to fetch pvforecast entity '{settings.entity_id}': {e}") + raise RuntimeError( + f"Failed to fetch pvforecast entity '{settings.entity_id}' from Home Assistant" + ) from e + return response.json() + + async def _update_data(self, force_update: Optional[bool] = False) -> None: + """Update forecast data in the PVForecastDataRecord format.""" + settings = self.config.pvforecast.homeassistant + data = self._request_entity_state() + attributes = data.get("attributes", {}) + forecast = attributes.get(settings.attribute) + if not forecast: + error_msg = ( + f"Entity '{settings.entity_id}' has no '{settings.attribute}' attribute " + "or it is empty." + ) + logger.error(error_msg) + raise ValueError(error_msg) + + factor = 1000.0 if settings.value_unit == "kW" else 1.0 + parsed: list[tuple[DateTime, float]] = [] + for entry in forecast: + try: + dt = to_datetime( + entry[settings.datetime_key], in_timezone=self.config.general.timezone + ) + watts = round(float(entry[settings.value_key]) * factor, 2) + except (KeyError, TypeError, ValueError) as e: + logger.error(f"Skipping malformed forecast entry {entry!r}: {e}") + continue + parsed.append((dt, watts)) + + if not parsed: + error_msg = ( + f"Entity '{settings.entity_id}' attribute '{settings.attribute}' contained no " + "usable forecast entries." + ) + logger.error(error_msg) + raise ValueError(error_msg) + + # Clear the active forecast window first, so a response that is shorter than a + # previous one (or has gaps) can't leave stale pvforecast_ac_power values behind at + # timestamps the new data no longer covers. Bounded to [ems_start_datetime, + # end_datetime) rather than the start of the day: PredictionProvider deliberately + # retains historical records back to keep_datetime (prediction.historic_hours), and + # Home Assistant forecasts are future-only, so clearing from midnight would wipe out + # that retained history on every refresh. + start_date = self.ems_start_datetime + end_date = self.end_datetime + if start_date is None or end_date is None: + raise RuntimeError("Cannot update PV forecast without a valid prediction window") + await self.key_delete_by_datetime( + "pvforecast_ac_power", start_datetime=start_date, end_datetime=end_date + ) + + for dt, watts in parsed: + await self.update_value(dt, {"pvforecast_ac_power": watts}) + + logger.debug( + f"Updated pvforecast_ac_power with {len(parsed)} entries from '{settings.entity_id}'." + ) + self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) diff --git a/src/akkudoktoreos/server/dash/uihints.py b/src/akkudoktoreos/server/dash/uihints.py index 6d84bedf..aad8168b 100644 --- a/src/akkudoktoreos/server/dash/uihints.py +++ b/src/akkudoktoreos/server/dash/uihints.py @@ -308,6 +308,12 @@ UI_HINTS: dict[str, UiHint] = { item_path="pvforecast.planes", max_items_from="pvforecast.max_planes", ), + # Plain text: the "select" widget has no manual-entry fallback, but + # adapter.homeassistant.homeassistant_entity_ids only resolves when + # SUPERVISOR_TOKEN is set, which leaves standalone setups (using this + # provider's own base_url/token) with an unusable, permanently empty + # dropdown. + "pvforecast.homeassistant.entity_id": UiHint(form="text"), # Per-plane sub-fields; resolved by hint_for_indexed_field() "pvforecast.planes.pvtechchoice": UiHint( form="select", diff --git a/tests/test_prediction.py b/tests/test_prediction.py index 778e2331..b947548c 100644 --- a/tests/test_prediction.py +++ b/tests/test_prediction.py @@ -29,6 +29,7 @@ from akkudoktoreos.prediction.prediction import ( ) from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar +from akkudoktoreos.prediction.pvforecasthomeassistant import PVForecastHomeAssistant from akkudoktoreos.prediction.pvforecastimport import PVForecastImport from akkudoktoreos.prediction.pvforecastpvlib import PVForecastPVLib from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode @@ -75,6 +76,7 @@ def forecast_providers(): LoadVrm(), PVForecastAkkudoktor(), PVForecastForecastSolar(), + PVForecastHomeAssistant(), PVForecastImport(), PVForecastPVLib(), PVForecastPVNode(), @@ -137,11 +139,12 @@ def test_provider_sequence(prediction): assert isinstance(prediction.providers[22], LoadVrm) assert isinstance(prediction.providers[23], PVForecastAkkudoktor) assert isinstance(prediction.providers[24], PVForecastForecastSolar) - assert isinstance(prediction.providers[25], PVForecastImport) - assert isinstance(prediction.providers[26], PVForecastPVLib) - assert isinstance(prediction.providers[27], PVForecastPVNode) - assert isinstance(prediction.providers[28], PVForecastSolcast) - assert isinstance(prediction.providers[29], PVForecastVrm) + assert isinstance(prediction.providers[25], PVForecastHomeAssistant) + assert isinstance(prediction.providers[26], PVForecastImport) + assert isinstance(prediction.providers[27], PVForecastPVLib) + assert isinstance(prediction.providers[28], PVForecastPVNode) + assert isinstance(prediction.providers[29], PVForecastSolcast) + assert isinstance(prediction.providers[30], PVForecastVrm) def test_provider_by_id(prediction, forecast_providers): @@ -175,6 +178,7 @@ def test_prediction_repr(prediction): assert "LoadVrm" in result assert "PVForecastAkkudoktor" in result assert "PVForecastForecastSolar" in result + assert "PVForecastHomeAssistant" in result assert "PVForecastImport" in result assert "PVForecastPVLib" in result assert "PVForecastPVNode" in result diff --git a/tests/test_pvforecasthomeassistant.py b/tests/test_pvforecasthomeassistant.py new file mode 100644 index 00000000..1cf2b947 --- /dev/null +++ b/tests/test_pvforecasthomeassistant.py @@ -0,0 +1,226 @@ +from unittest.mock import MagicMock, call, patch + +import pendulum +import pytest +import requests + +from akkudoktoreos.prediction.pvforecasthomeassistant import PVForecastHomeAssistant + +# Fixed "current" EMS time so tests can assert on the [ems_start_datetime, end_datetime) +# window that _update_data clears before writing, independent of wall-clock time. +FIXED_EMS_START = pendulum.datetime(2026, 8, 18, 5, 0, tz="Europe/Berlin") + +# Trimmed excerpt of a real Home Assistant response, captured live from a +# Helios Forecast "Power now" sensor (sensor.pv1_power_now). +REAL_HA_RESPONSE = { + "entity_id": "sensor.pv1_power_now", + "state": "0.0", + "attributes": { + "state_class": "measurement", + "forecast": [ + {"datetime": "2026-08-18T06:00:00+02:00", "watts": 0}, + {"datetime": "2026-08-18T06:15:00+02:00", "watts": 0}, + {"datetime": "2026-08-18T06:30:00+02:00", "watts": 17.19}, + {"datetime": "2026-08-18T06:45:00+02:00", "watts": 43.24}, + {"datetime": "2026-08-18T07:00:00+02:00", "watts": 75.68}, + ], + "unit_of_measurement": "W", + "device_class": "power", + "friendly_name": "PV1 Power now", + }, +} + + +@pytest.fixture +def pvforecast_instance(config_eos): + settings = { + "pvforecast": { + "homeassistant": { + "entity_id": "sensor.pv1_power_now", + "base_url": "http://homeassistant.local:8123", + "token": "dummy-token", + }, + } + } + config_eos.merge_settings_from_dict(settings) + start_dt = pendulum.datetime(2026, 8, 18, tz="Europe/Berlin") + return PVForecastHomeAssistant(config=config_eos.load, start_datetime=start_dt) + + +def mock_response(json_data, status_code=200): + mock = MagicMock(spec=requests.Response) + mock.status_code = status_code + mock.json.return_value = json_data + return mock + + +@pytest.mark.asyncio +async def test_update_data_updates_ac_power(pvforecast_instance): + with ( + patch("requests.get", return_value=mock_response(REAL_HA_RESPONSE)), + patch.object(PVForecastHomeAssistant, "update_value") as mock_update, + ): + await pvforecast_instance._update_data() + + assert mock_update.call_count == 5 + expected_calls = [ + call(pendulum.parse("2026-08-18T06:00:00+02:00"), {"pvforecast_ac_power": 0.0}), + call(pendulum.parse("2026-08-18T06:15:00+02:00"), {"pvforecast_ac_power": 0.0}), + call(pendulum.parse("2026-08-18T06:30:00+02:00"), {"pvforecast_ac_power": 17.19}), + call(pendulum.parse("2026-08-18T06:45:00+02:00"), {"pvforecast_ac_power": 43.24}), + call(pendulum.parse("2026-08-18T07:00:00+02:00"), {"pvforecast_ac_power": 75.68}), + ] + mock_update.assert_has_calls(expected_calls, any_order=False) + + +@pytest.mark.asyncio +async def test_update_data_converts_kw_to_w(pvforecast_instance): + pvforecast_instance.config.pvforecast.homeassistant.value_unit = "kW" + response = { + "state": "0.0", + "attributes": {"forecast": [{"datetime": "2026-08-18T12:00:00+02:00", "watts": 2.5}]}, + } + with ( + patch("requests.get", return_value=mock_response(response)), + patch.object(PVForecastHomeAssistant, "update_value") as mock_update, + ): + await pvforecast_instance._update_data() + + mock_update.assert_called_once_with( + pendulum.parse("2026-08-18T12:00:00+02:00"), {"pvforecast_ac_power": 2500.0} + ) + + +@pytest.mark.asyncio +async def test_update_data_raises_on_missing_attribute(pvforecast_instance): + response = {"state": "0.0", "attributes": {}} + with ( + patch("requests.get", return_value=mock_response(response)), + patch.object(PVForecastHomeAssistant, "update_value") as mock_update, + ): + with pytest.raises(ValueError, match="no 'forecast' attribute"): + await pvforecast_instance._update_data() + mock_update.assert_not_called() + + +@pytest.mark.asyncio +async def test_update_data_raises_on_empty_forecast(pvforecast_instance): + response = {"state": "0.0", "attributes": {"forecast": []}} + with ( + patch("requests.get", return_value=mock_response(response)), + patch.object(PVForecastHomeAssistant, "update_value") as mock_update, + ): + with pytest.raises(ValueError, match="no 'forecast' attribute"): + await pvforecast_instance._update_data() + mock_update.assert_not_called() + + +@pytest.mark.asyncio +async def test_update_data_raises_when_all_entries_malformed(pvforecast_instance): + """A non-empty but entirely unusable forecast must fail explicitly, not succeed as a no-op.""" + response = { + "state": "0.0", + "attributes": { + "forecast": [ + {"datetime": "2026-08-18T06:00:00+02:00"}, # missing "watts" + {"watts": "not-a-number", "datetime": "2026-08-18T06:15:00+02:00"}, + ] + }, + } + with ( + patch("requests.get", return_value=mock_response(response)), + patch.object(PVForecastHomeAssistant, "update_value") as mock_update, + ): + with pytest.raises(ValueError, match="no usable forecast entries"): + await pvforecast_instance._update_data() + mock_update.assert_not_called() + + +@pytest.mark.asyncio +async def test_update_data_clears_stale_entries_missing_from_shorter_forecast( + pvforecast_instance, +): + """A shorter refresh must not leave stale values behind at now-omitted timestamps, + and must not touch retained historical records before the active forecast window. + + Regression test for two bugs: + - An early return on empty/short responses left previously-written + pvforecast_ac_power values in place, letting EOS optimize against a mixture of + current and stale forecast data. + - The fix for that (clearing from start-of-day) then wiped out historical records + PredictionProvider deliberately retains (keep_datetime / prediction.historic_hours), + since Home Assistant forecasts are future-only and every refresh would blank + everything between midnight and the EMS start. + """ + with patch("akkudoktoreos.core.coreabc.get_ems") as mock_get_ems: + mock_get_ems.return_value.start_datetime = FIXED_EMS_START + + # Seed a historical value from before the active window: must survive. + historical_dt = FIXED_EMS_START.subtract(hours=1) + await pvforecast_instance.update_value(historical_dt, {"pvforecast_ac_power": 321.0}) + + # Seed a value as if a previous, longer forecast had covered this timestamp. + stale_dt = pendulum.datetime(2026, 8, 18, 10, 0, tz="Europe/Berlin") + await pvforecast_instance.update_value(stale_dt, {"pvforecast_ac_power": 999.0}) + + shorter_response = { + "state": "0.0", + "attributes": { + "forecast": [{"datetime": "2026-08-18T06:00:00+02:00", "watts": 12.0}] + }, + } + with patch("requests.get", return_value=mock_response(shorter_response)): + await pvforecast_instance._update_data() + + values = { + pendulum.parse(dt): value + for dt, value in ( + await pvforecast_instance.key_to_dict("pvforecast_ac_power", dropna=False) + ).items() + } + assert values[historical_dt] == 321.0 + assert values[stale_dt] is None + assert values[pendulum.parse("2026-08-18T06:00:00+02:00")] == 12.0 + + +@pytest.mark.asyncio +async def test_update_data_skips_malformed_entries(pvforecast_instance): + response = { + "state": "0.0", + "attributes": { + "forecast": [ + {"datetime": "2026-08-18T06:00:00+02:00", "watts": 12.0}, + {"datetime": "2026-08-18T06:15:00+02:00"}, # missing "watts" + {"watts": "not-a-number", "datetime": "2026-08-18T06:30:00+02:00"}, + {"datetime": "2026-08-18T06:45:00+02:00", "watts": 34.0}, + ] + }, + } + with ( + patch("requests.get", return_value=mock_response(response)), + patch.object(PVForecastHomeAssistant, "update_value") as mock_update, + ): + await pvforecast_instance._update_data() + + assert mock_update.call_count == 2 + expected_calls = [ + call(pendulum.parse("2026-08-18T06:00:00+02:00"), {"pvforecast_ac_power": 12.0}), + call(pendulum.parse("2026-08-18T06:45:00+02:00"), {"pvforecast_ac_power": 34.0}), + ] + mock_update.assert_has_calls(expected_calls, any_order=False) + + +def test_request_entity_state_raises_on_http_error(pvforecast_instance): + with patch("requests.get", side_effect=requests.Timeout("Request timed out")) as mock_get: + with pytest.raises(RuntimeError) as exc_info: + pvforecast_instance._request_entity_state() + + assert "Failed to fetch pvforecast entity" in str(exc_info.value) + mock_get.assert_called_once() + + +def test_request_entity_state_raises_without_token(pvforecast_instance): + pvforecast_instance.config.pvforecast.homeassistant.token = None + with pytest.raises(RuntimeError) as exc_info: + pvforecast_instance._request_entity_state() + assert "No Home Assistant access token available" in str(exc_info.value)