mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-09-03 14:36:37 +00:00
* 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>
227 lines
9.1 KiB
Python
227 lines
9.1 KiB
Python
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)
|