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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Mathias <mathias@Mathiass-MacBook-Air.local>
Co-authored-by: Mathias <mathias@Mathiass-Air.localdomain>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
matjhgc534z67umb
2026-09-03 00:15:40 +02:00
committed by GitHub
co-authored by Claude Sonnet 5 Mathias Mathias
parent ba76087db9
commit 7bafc8d02d
10 changed files with 600 additions and 7 deletions
@@ -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,
@@ -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"},
@@ -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 ``{<datetime_key>: ..., <value_key>: ...}`` 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)
+6
View File
@@ -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",