mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-09-03 14:36:37 +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
@@ -244,6 +244,15 @@
|
|||||||
"token": "your-token",
|
"token": "your-token",
|
||||||
"site_id": 12345
|
"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": {},
|
"pvlib": {},
|
||||||
"pvnode": {
|
"pvnode": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||||
| forecastsolar | `EOS_PVFORECAST__FORECASTSOLAR` | `PVForecastForecastSolarCommonSettings` | `rw` | `required` | ForecastSolar provider settings |
|
| 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 |
|
| 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 | `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. |
|
| planes_azimuth | | `List[float]` | `ro` | `N/A` | Compute a list of the azimuths per active planes. |
|
||||||
@@ -42,6 +43,15 @@
|
|||||||
"token": "your-token",
|
"token": "your-token",
|
||||||
"site_id": 12345
|
"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": {},
|
"pvlib": {},
|
||||||
"pvnode": {
|
"pvnode": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
@@ -124,6 +134,15 @@
|
|||||||
"token": "your-token",
|
"token": "your-token",
|
||||||
"site_id": 12345
|
"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": {},
|
"pvlib": {},
|
||||||
"pvnode": {
|
"pvnode": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
@@ -257,6 +276,47 @@
|
|||||||
```
|
```
|
||||||
<!-- pyml enable line-length -->
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
### Common settings for pvforecast data from a Home Assistant entity
|
||||||
|
|
||||||
|
<!-- pyml disable line-length -->
|
||||||
|
:::{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. |
|
||||||
|
:::
|
||||||
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
|
<!-- pyml disable no-emphasis-as-heading -->
|
||||||
|
**Example Input/Output**
|
||||||
|
<!-- pyml enable no-emphasis-as-heading -->
|
||||||
|
|
||||||
|
<!-- pyml disable line-length -->
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pvforecast": {
|
||||||
|
"homeassistant": {
|
||||||
|
"entity_id": "sensor.pv_forecast",
|
||||||
|
"attribute": "forecast",
|
||||||
|
"datetime_key": "datetime",
|
||||||
|
"value_key": "watts",
|
||||||
|
"value_unit": "W",
|
||||||
|
"base_url": null,
|
||||||
|
"token": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
<!-- pyml enable line-length -->
|
||||||
|
|
||||||
### Common settings for the Solcast PV forecast provider
|
### Common settings for the Solcast PV forecast provider
|
||||||
|
|
||||||
<!-- pyml disable line-length -->
|
<!-- pyml disable line-length -->
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Akkudoktor-EOS
|
# Akkudoktor-EOS
|
||||||
|
|
||||||
**Version**: `v0.3.0.dev2608221553448643`
|
**Version**: `v0.3.0.dev2609011977897641`
|
||||||
|
|
||||||
<!-- pyml disable line-length -->
|
<!-- pyml disable line-length -->
|
||||||
**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.
|
**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.
|
||||||
|
|||||||
+96
-1
@@ -8,7 +8,7 @@
|
|||||||
"name": "Apache 2.0",
|
"name": "Apache 2.0",
|
||||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
||||||
},
|
},
|
||||||
"version": "v0.3.0.dev2608221553448643"
|
"version": "v0.3.0.dev2609011977897641"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/v1/admin/cache/clear": {
|
"/v1/admin/cache/clear": {
|
||||||
@@ -10803,6 +10803,10 @@
|
|||||||
"$ref": "#/components/schemas/PVForecastVrmCommonSettings",
|
"$ref": "#/components/schemas/PVForecastVrmCommonSettings",
|
||||||
"description": "Victron Remote Management (VRM) provider settings"
|
"description": "Victron Remote Management (VRM) provider settings"
|
||||||
},
|
},
|
||||||
|
"homeassistant": {
|
||||||
|
"$ref": "#/components/schemas/PVForecastHomeAssistantCommonSettings",
|
||||||
|
"description": "Home Assistant provider settings"
|
||||||
|
},
|
||||||
"pvlib": {
|
"pvlib": {
|
||||||
"$ref": "#/components/schemas/PVForecastPVLibCommonSettings",
|
"$ref": "#/components/schemas/PVForecastPVLibCommonSettings",
|
||||||
"description": "PVLib provider settings"
|
"description": "PVLib provider settings"
|
||||||
@@ -10924,6 +10928,10 @@
|
|||||||
"$ref": "#/components/schemas/PVForecastVrmCommonSettings",
|
"$ref": "#/components/schemas/PVForecastVrmCommonSettings",
|
||||||
"description": "Victron Remote Management (VRM) provider settings"
|
"description": "Victron Remote Management (VRM) provider settings"
|
||||||
},
|
},
|
||||||
|
"homeassistant": {
|
||||||
|
"$ref": "#/components/schemas/PVForecastHomeAssistantCommonSettings",
|
||||||
|
"description": "Home Assistant provider settings"
|
||||||
|
},
|
||||||
"pvlib": {
|
"pvlib": {
|
||||||
"$ref": "#/components/schemas/PVForecastPVLibCommonSettings",
|
"$ref": "#/components/schemas/PVForecastPVLibCommonSettings",
|
||||||
"description": "PVLib provider settings"
|
"description": "PVLib provider settings"
|
||||||
@@ -11097,6 +11105,93 @@
|
|||||||
"title": "PVForecastForecastSolarCommonSettings",
|
"title": "PVForecastForecastSolarCommonSettings",
|
||||||
"description": "Common settings for the Forecast.Solar PV forecast provider."
|
"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": {
|
"PVForecastImportCommonSettings": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"import_file_path": {
|
"import_file_path": {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ from akkudoktoreos.prediction.loadvrm import LoadVrm
|
|||||||
from akkudoktoreos.prediction.predictionabc import PredictionContainer
|
from akkudoktoreos.prediction.predictionabc import PredictionContainer
|
||||||
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
|
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
|
||||||
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
|
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
|
||||||
|
from akkudoktoreos.prediction.pvforecasthomeassistant import PVForecastHomeAssistant
|
||||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
|
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
|
||||||
from akkudoktoreos.prediction.pvforecastpvlib import PVForecastPVLib
|
from akkudoktoreos.prediction.pvforecastpvlib import PVForecastPVLib
|
||||||
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
|
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
|
||||||
@@ -106,6 +107,7 @@ loadforecast_vrm = LoadVrm()
|
|||||||
loadforecast_import = LoadImport()
|
loadforecast_import = LoadImport()
|
||||||
pvforecast_akkudoktor = PVForecastAkkudoktor()
|
pvforecast_akkudoktor = PVForecastAkkudoktor()
|
||||||
pvforecast_vrm = PVForecastVrm()
|
pvforecast_vrm = PVForecastVrm()
|
||||||
|
pvforecast_homeassistant = PVForecastHomeAssistant()
|
||||||
pvforecast_pvlib = PVForecastPVLib()
|
pvforecast_pvlib = PVForecastPVLib()
|
||||||
pvforecast_pvnode = PVForecastPVNode()
|
pvforecast_pvnode = PVForecastPVNode()
|
||||||
pvforecast_forecastsolar = PVForecastForecastSolar()
|
pvforecast_forecastsolar = PVForecastForecastSolar()
|
||||||
@@ -177,6 +179,7 @@ def prediction_providers() -> list[
|
|||||||
loadforecast_import, \
|
loadforecast_import, \
|
||||||
pvforecast_akkudoktor, \
|
pvforecast_akkudoktor, \
|
||||||
pvforecast_vrm, \
|
pvforecast_vrm, \
|
||||||
|
pvforecast_homeassistant, \
|
||||||
pvforecast_pvlib, \
|
pvforecast_pvlib, \
|
||||||
pvforecast_pvnode, \
|
pvforecast_pvnode, \
|
||||||
pvforecast_forecastsolar, \
|
pvforecast_forecastsolar, \
|
||||||
@@ -217,6 +220,7 @@ def prediction_providers() -> list[
|
|||||||
loadforecast_vrm,
|
loadforecast_vrm,
|
||||||
pvforecast_akkudoktor,
|
pvforecast_akkudoktor,
|
||||||
pvforecast_forecastsolar,
|
pvforecast_forecastsolar,
|
||||||
|
pvforecast_homeassistant,
|
||||||
pvforecast_import,
|
pvforecast_import,
|
||||||
pvforecast_pvlib,
|
pvforecast_pvlib,
|
||||||
pvforecast_pvnode,
|
pvforecast_pvnode,
|
||||||
@@ -251,6 +255,7 @@ class Prediction(PredictionContainer):
|
|||||||
LoadVrm,
|
LoadVrm,
|
||||||
PVForecastAkkudoktor,
|
PVForecastAkkudoktor,
|
||||||
PVForecastForecastSolar,
|
PVForecastForecastSolar,
|
||||||
|
PVForecastHomeAssistant,
|
||||||
PVForecastImport,
|
PVForecastImport,
|
||||||
PVForecastPVLib,
|
PVForecastPVLib,
|
||||||
PVForecastPVNode,
|
PVForecastPVNode,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
|
|||||||
from akkudoktoreos.prediction.pvforecastforecastsolar import (
|
from akkudoktoreos.prediction.pvforecastforecastsolar import (
|
||||||
PVForecastForecastSolarCommonSettings,
|
PVForecastForecastSolarCommonSettings,
|
||||||
)
|
)
|
||||||
|
from akkudoktoreos.prediction.pvforecasthomeassistant import (
|
||||||
|
PVForecastHomeAssistantCommonSettings,
|
||||||
|
)
|
||||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
|
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
|
||||||
from akkudoktoreos.prediction.pvforecastpvlib import PVForecastPVLibCommonSettings
|
from akkudoktoreos.prediction.pvforecastpvlib import PVForecastPVLibCommonSettings
|
||||||
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNodeCommonSettings
|
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNodeCommonSettings
|
||||||
@@ -27,6 +30,7 @@ def pvforecast_provider_ids() -> list[str]:
|
|||||||
return [
|
return [
|
||||||
"PVForecastAkkudoktor",
|
"PVForecastAkkudoktor",
|
||||||
"PVForecastForecastSolar",
|
"PVForecastForecastSolar",
|
||||||
|
"PVForecastHomeAssistant",
|
||||||
"PVForecastImport",
|
"PVForecastImport",
|
||||||
"PVForecastPVLib",
|
"PVForecastPVLib",
|
||||||
"PVForecastPVNode",
|
"PVForecastPVNode",
|
||||||
@@ -208,6 +212,11 @@ class PVForecastCommonSettings(SettingsBaseModel):
|
|||||||
json_schema_extra={"description": "Victron Remote Management (VRM) provider settings"},
|
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(
|
pvlib: PVForecastPVLibCommonSettings = Field(
|
||||||
default_factory=PVForecastPVLibCommonSettings,
|
default_factory=PVForecastPVLibCommonSettings,
|
||||||
json_schema_extra={"description": "PVLib provider settings"},
|
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",
|
item_path="pvforecast.planes",
|
||||||
max_items_from="pvforecast.max_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()
|
# Per-plane sub-fields; resolved by hint_for_indexed_field()
|
||||||
"pvforecast.planes.pvtechchoice": UiHint(
|
"pvforecast.planes.pvtechchoice": UiHint(
|
||||||
form="select",
|
form="select",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from akkudoktoreos.prediction.prediction import (
|
|||||||
)
|
)
|
||||||
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
|
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
|
||||||
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
|
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
|
||||||
|
from akkudoktoreos.prediction.pvforecasthomeassistant import PVForecastHomeAssistant
|
||||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
|
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
|
||||||
from akkudoktoreos.prediction.pvforecastpvlib import PVForecastPVLib
|
from akkudoktoreos.prediction.pvforecastpvlib import PVForecastPVLib
|
||||||
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
|
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
|
||||||
@@ -75,6 +76,7 @@ def forecast_providers():
|
|||||||
LoadVrm(),
|
LoadVrm(),
|
||||||
PVForecastAkkudoktor(),
|
PVForecastAkkudoktor(),
|
||||||
PVForecastForecastSolar(),
|
PVForecastForecastSolar(),
|
||||||
|
PVForecastHomeAssistant(),
|
||||||
PVForecastImport(),
|
PVForecastImport(),
|
||||||
PVForecastPVLib(),
|
PVForecastPVLib(),
|
||||||
PVForecastPVNode(),
|
PVForecastPVNode(),
|
||||||
@@ -137,11 +139,12 @@ def test_provider_sequence(prediction):
|
|||||||
assert isinstance(prediction.providers[22], LoadVrm)
|
assert isinstance(prediction.providers[22], LoadVrm)
|
||||||
assert isinstance(prediction.providers[23], PVForecastAkkudoktor)
|
assert isinstance(prediction.providers[23], PVForecastAkkudoktor)
|
||||||
assert isinstance(prediction.providers[24], PVForecastForecastSolar)
|
assert isinstance(prediction.providers[24], PVForecastForecastSolar)
|
||||||
assert isinstance(prediction.providers[25], PVForecastImport)
|
assert isinstance(prediction.providers[25], PVForecastHomeAssistant)
|
||||||
assert isinstance(prediction.providers[26], PVForecastPVLib)
|
assert isinstance(prediction.providers[26], PVForecastImport)
|
||||||
assert isinstance(prediction.providers[27], PVForecastPVNode)
|
assert isinstance(prediction.providers[27], PVForecastPVLib)
|
||||||
assert isinstance(prediction.providers[28], PVForecastSolcast)
|
assert isinstance(prediction.providers[28], PVForecastPVNode)
|
||||||
assert isinstance(prediction.providers[29], PVForecastVrm)
|
assert isinstance(prediction.providers[29], PVForecastSolcast)
|
||||||
|
assert isinstance(prediction.providers[30], PVForecastVrm)
|
||||||
|
|
||||||
|
|
||||||
def test_provider_by_id(prediction, forecast_providers):
|
def test_provider_by_id(prediction, forecast_providers):
|
||||||
@@ -175,6 +178,7 @@ def test_prediction_repr(prediction):
|
|||||||
assert "LoadVrm" in result
|
assert "LoadVrm" in result
|
||||||
assert "PVForecastAkkudoktor" in result
|
assert "PVForecastAkkudoktor" in result
|
||||||
assert "PVForecastForecastSolar" in result
|
assert "PVForecastForecastSolar" in result
|
||||||
|
assert "PVForecastHomeAssistant" in result
|
||||||
assert "PVForecastImport" in result
|
assert "PVForecastImport" in result
|
||||||
assert "PVForecastPVLib" in result
|
assert "PVForecastPVLib" in result
|
||||||
assert "PVForecastPVNode" in result
|
assert "PVForecastPVNode" in result
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user