mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-08 23:46:38 +00:00
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:
co-authored by
Claude Sonnet 5
Mathias
Mathias
parent
ba76087db9
commit
7bafc8d02d
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user