Skip to content

Commit 2f7bb9e

Browse files
committed
fix(#303): support evcc Solar-Prognose forecast format and handle None unit
- Add Format 1 parser in _parse_forecast_from_attributes for the sensor.solar_forecast_ml_evcc_solar_prognose style data: {"forecast": [{"start": ISO_TS, "end": ISO_TS, "value": WH}]} Absolute timestamps are converted to hour offsets relative to current hour. - Handle unit_of_measurement=None in _check_sensor_unit_async: log a warning and assume Wh (factor 1.0) instead of raising ValueError. - Add TestEvccForecastFormat with 7 tests covering the new parser.
1 parent 9d8ebc7 commit 2f7bb9e

2 files changed

Lines changed: 313 additions & 6 deletions

File tree

src/batcontrol/forecastsolar/forecast_homeassistant_ml.py

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
HomeAssistant Solar Forecast ML integration (HACS).
55
66
Based on HACS integration: https://zara-toorox.github.io/
7-
Sensor: sensor.solar_forecast_ml_prognose_nachste_stunde
7+
Supported sensors:
8+
- sensor.solar_forecast_ml_prognose_nachste_stunde (hours_list / hour_N format)
9+
- sensor.solar_forecast_ml_evcc_solar_prognose (forecast list with start/end/value)
810
911
"""
1012

1113
import asyncio
14+
import datetime
1215
import json
1316
import logging
1417
from typing import Dict, Optional
@@ -224,6 +227,16 @@ async def _check_sensor_unit_async(self) -> float:
224227
"Unit is kWh, will multiply values by 1000 to convert to Wh")
225228
return 1000.0
226229

230+
if unit is None:
231+
logger.warning(
232+
"Entity '%s' has no unit_of_measurement. "
233+
"Assuming values are already in Wh "
234+
"(typical for evcc Solar-Prognose sensor). "
235+
"Set 'sensor_unit: Wh' in config to suppress this warning.",
236+
self.entity_id
237+
)
238+
return 1.0
239+
227240
raise ValueError(
228241
f"Unsupported unit_of_measurement '{unit}' for entity "
229242
f"'{self.entity_id}'. Only 'Wh' and 'kWh' are supported.")
@@ -407,10 +420,10 @@ def get_forecast_from_raw_data(self) -> Dict[int, float]:
407420

408421
# Parse forecast data from attributes
409422
attributes = raw_data.get("attributes", {})
410-
423+
411424
try:
412425
forecast_dict = self._parse_forecast_from_attributes(attributes)
413-
426+
414427
if forecast_dict:
415428
values = list(forecast_dict.values())
416429
logger.debug(
@@ -422,7 +435,8 @@ def get_forecast_from_raw_data(self) -> Dict[int, float]:
422435
)
423436
else:
424437
logger.error("Parsed empty forecast from attributes")
425-
raise RuntimeError("No solar forecast data available in entity attributes")
438+
raise RuntimeError(
439+
"No solar forecast data available in entity attributes")
426440

427441
return forecast_dict
428442

@@ -437,8 +451,12 @@ def _parse_forecast_from_attributes(
437451
"""Parse forecast data from sensor attributes
438452
439453
Supports multiple formats:
440-
1. Primary: hours_list array with {time, kwh} objects
441-
2. Fallback: hour_1, hour_2, ... attributes with times
454+
1. Primary: forecast array with {start, end, value} objects (evcc Solar Forecast style)
455+
- Uses absolute timestamps; values are mapped to hour offsets from now
456+
- Typically used with sensor.solar_forecast_ml_evcc_solar_prognose
457+
2. Secondary: hours_list array with {time, kwh} objects
458+
- Used with sensor.solar_forecast_ml_prognose_nachste_stunde
459+
3. Fallback: hour_1, hour_2, ... attributes
442460
443461
Args:
444462
attributes: Sensor attributes dict from HomeAssistant
@@ -451,6 +469,72 @@ def _parse_forecast_from_attributes(
451469
"""
452470
forecast_dict: Dict[int, float] = {}
453471

472+
# Format 1: forecast list with {start, end, value} - evcc Solar Forecast style
473+
forecast_list = attributes.get("forecast")
474+
if forecast_list and isinstance(forecast_list, list):
475+
first = forecast_list[0] if forecast_list else {}
476+
if isinstance(first, dict) and "start" in first and "value" in first:
477+
logger_ha_details.debug(
478+
"Parsing forecast from 'forecast' list (%d entries)",
479+
len(forecast_list)
480+
)
481+
now = datetime.datetime.now(self.timezone)
482+
current_hour = now.replace(minute=0, second=0, microsecond=0)
483+
484+
for entry in forecast_list:
485+
if not isinstance(entry, dict):
486+
continue
487+
488+
start_str = entry.get("start")
489+
value = entry.get("value")
490+
491+
if start_str is None or value is None:
492+
continue
493+
494+
try:
495+
entry_start = datetime.datetime.fromisoformat(
496+
start_str)
497+
# If the timestamp is naive, assume it is in the local timezone
498+
if entry_start.tzinfo is None:
499+
entry_start = self.timezone.localize(entry_start)
500+
else:
501+
entry_start = entry_start.astimezone(self.timezone)
502+
503+
delta = entry_start - current_hour
504+
hour_offset = int(delta.total_seconds() / 3600)
505+
506+
if hour_offset < 0:
507+
# Past hour - skip
508+
continue
509+
510+
wh_value = float(value) * self.unit_conversion_factor
511+
forecast_dict[hour_offset] = wh_value
512+
logger_ha_details.debug(
513+
"Offset %d (start=%s): %.2f Wh",
514+
hour_offset, start_str, wh_value
515+
)
516+
except (ValueError, TypeError, OverflowError) as exc:
517+
logger_ha_details.debug(
518+
"Skipping entry with start=%s: %s", start_str, exc
519+
)
520+
continue
521+
522+
if forecast_dict:
523+
values = list(forecast_dict.values())
524+
logger.debug(
525+
"Parsed %d slots from 'forecast' list: "
526+
"avg=%.1f Wh, min=%.1f Wh, max=%.1f Wh",
527+
len(forecast_dict),
528+
sum(values) / len(values),
529+
min(values),
530+
max(values)
531+
)
532+
return forecast_dict
533+
534+
logger_ha_details.warning(
535+
"'forecast' list present but no valid future entries found")
536+
537+
# Format 2: hours_list (existing primary format)
454538
# Try primary format: hours_list
455539
hours_list = attributes.get("hours_list")
456540
if hours_list and isinstance(hours_list, list) and len(hours_list) > 0:

tests/test_forecast_solar_homeassistant_ml.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Comprehensive test coverage for HomeAssistant Solar Forecast ML integration.
44
"""
55

6+
import datetime
67
import json
78
from unittest.mock import AsyncMock, patch
89

@@ -544,5 +545,227 @@ def test_large_forecast_values(self, pv_installations, timezone):
544545
assert forecast[1] == 50500.0
545546

546547

548+
# Tests for evcc Solar Forecast format (start/end/value with absolute timestamps)
549+
550+
class TestEvccForecastFormat:
551+
"""Tests for the evcc Solar Forecast sensor format with absolute timestamps.
552+
553+
The sensor sensor.solar_forecast_ml_evcc_solar_prognose provides data as:
554+
{
555+
"forecast": [
556+
{"start": "2026-03-21T14:00:00", "end": "2026-03-21T15:00:00", "value": 3613.0},
557+
...
558+
]
559+
}
560+
Values are in Wh (no unit_of_measurement on the sensor).
561+
"""
562+
563+
def _make_provider_wh(self, pv_installations, timezone):
564+
"""Helper: create a provider configured for Wh (evcc sensor)"""
565+
return ForecastSolarHomeAssistantML(
566+
pvinstallations=pv_installations,
567+
timezone=timezone,
568+
base_url="http://homeassistant.local:8123",
569+
api_token="test_token",
570+
entity_id="sensor.solar_forecast_ml_evcc_solar_prognose",
571+
sensor_unit="Wh"
572+
)
573+
574+
def _hour_str(self, tz, offset_hours: int) -> str:
575+
"""Return an ISO timestamp string for current-hour + offset_hours in local tz."""
576+
now = datetime.datetime.now(tz)
577+
hour_start = now.replace(minute=0, second=0, microsecond=0)
578+
target = hour_start + datetime.timedelta(hours=offset_hours)
579+
# Return naive local time (as the sensor provides)
580+
return target.strftime("%Y-%m-%dT%H:%M:%S")
581+
582+
def test_parse_forecast_list_basic(self, pv_installations, timezone):
583+
"""Test parsing evcc-style forecast list - current and future entries mapped correctly"""
584+
provider = self._make_provider_wh(pv_installations, timezone)
585+
586+
attributes = {
587+
"forecast": [
588+
{"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1),
589+
"value": 3613.0},
590+
{"start": self._hour_str(timezone, 1), "end": self._hour_str(timezone, 2),
591+
"value": 1540.0},
592+
{"start": self._hour_str(timezone, 2), "end": self._hour_str(timezone, 3),
593+
"value": 800.0},
594+
]
595+
}
596+
597+
forecast = provider._parse_forecast_from_attributes(attributes)
598+
599+
assert forecast[0] == 3613.0
600+
assert forecast[1] == 1540.0
601+
assert forecast[2] == 800.0
602+
603+
def test_parse_forecast_list_skips_past_entries(self, pv_installations, timezone):
604+
"""Test that past entries (before current hour) are skipped"""
605+
provider = self._make_provider_wh(pv_installations, timezone)
606+
607+
attributes = {
608+
"forecast": [
609+
# Past entries
610+
{"start": self._hour_str(timezone, -3), "end": self._hour_str(timezone, -2),
611+
"value": 9999.0},
612+
{"start": self._hour_str(timezone, -1), "end": self._hour_str(timezone, 0),
613+
"value": 8888.0},
614+
# Current and future
615+
{"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1),
616+
"value": 800.0},
617+
{"start": self._hour_str(timezone, 1), "end": self._hour_str(timezone, 2),
618+
"value": 200.0},
619+
]
620+
}
621+
622+
forecast = provider._parse_forecast_from_attributes(attributes)
623+
624+
assert forecast[0] == 800.0
625+
assert forecast[1] == 200.0
626+
# Past hours must not appear
627+
assert -3 not in forecast
628+
assert -1 not in forecast
629+
assert len(forecast) == 2
630+
631+
def test_parse_forecast_list_multi_day(self, pv_installations, timezone):
632+
"""Test that forecast entries 12 and 24 hours ahead map to correct offsets.
633+
634+
We deliberately avoid 48h offsets here because the test date (March 27)
635+
is 2 days before the DST transition (March 29) in Europe/Berlin,
636+
which would cause wall-clock vs absolute-time discrepancy.
637+
"""
638+
provider = self._make_provider_wh(pv_installations, timezone)
639+
640+
attributes = {
641+
"forecast": [
642+
{"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1),
643+
"value": 0.0},
644+
{"start": self._hour_str(timezone, 12), "end": self._hour_str(timezone, 13),
645+
"value": 5000.0},
646+
{"start": self._hour_str(timezone, 24), "end": self._hour_str(timezone, 25),
647+
"value": 3000.0},
648+
]
649+
}
650+
651+
forecast = provider._parse_forecast_from_attributes(attributes)
652+
653+
assert forecast[0] == 0.0
654+
assert forecast[12] == 5000.0
655+
assert forecast[24] == 3000.0
656+
657+
def test_parse_forecast_list_wh_no_conversion(self, pv_installations, timezone):
658+
"""Test that Wh values from forecast list are NOT multiplied (factor=1.0)"""
659+
provider = self._make_provider_wh(pv_installations, timezone)
660+
661+
attributes = {
662+
"forecast": [
663+
{"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1),
664+
"value": 7835.0},
665+
]
666+
}
667+
668+
forecast = provider._parse_forecast_from_attributes(attributes)
669+
670+
# sensor_unit=Wh → unit_conversion_factor=1.0 → no multiplication
671+
assert forecast[0] == 7835.0
672+
673+
def test_parse_forecast_list_invalid_entries_skipped(self, pv_installations, timezone):
674+
"""Test that entries with invalid start timestamps or missing values are skipped"""
675+
provider = self._make_provider_wh(pv_installations, timezone)
676+
677+
attributes = {
678+
"forecast": [
679+
# Valid
680+
{"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1),
681+
"value": 1000.0},
682+
# Invalid start timestamp
683+
{"start": "not-a-date",
684+
"end": self._hour_str(timezone, 2), "value": 500.0},
685+
# Valid (no 'end' key is fine)
686+
{"start": self._hour_str(timezone, 2), "value": 2000.0},
687+
# Missing start → skipped
688+
{"end": self._hour_str(timezone, 3), "value": 300.0},
689+
# Missing value → skipped
690+
{"start": self._hour_str(timezone, 4),
691+
"end": self._hour_str(timezone, 5)},
692+
]
693+
}
694+
695+
forecast = provider._parse_forecast_from_attributes(attributes)
696+
697+
assert forecast[0] == 1000.0 # valid entry at offset 0
698+
assert forecast[2] == 2000.0 # valid entry at offset 2
699+
assert 4 not in forecast # no value
700+
# no start entries must not appear
701+
assert len([k for k in forecast if k >= 0]) == 2
702+
703+
def test_parse_forecast_list_priority_over_hours_list(self, pv_installations, timezone):
704+
"""Test that 'forecast' list takes priority over 'hours_list' when both are present"""
705+
provider = self._make_provider_wh(pv_installations, timezone)
706+
707+
attributes = {
708+
"forecast": [
709+
{"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1),
710+
"value": 111.0},
711+
],
712+
"hours_list": [
713+
{"time": "10:00", "kwh": 9.0},
714+
{"time": "11:00", "kwh": 9.0},
715+
]
716+
}
717+
718+
forecast = provider._parse_forecast_from_attributes(attributes)
719+
720+
# forecast list wins
721+
assert forecast[0] == 111.0
722+
assert len(forecast) == 1
723+
724+
def test_auto_detect_none_unit_defaults_to_wh(self, pv_installations, timezone):
725+
"""Test that auto-detecting a sensor with unit_of_measurement=None defaults to Wh"""
726+
provider_state = {
727+
"entity_id": "sensor.solar_forecast_ml_evcc_solar_prognose",
728+
"state": "69 slots",
729+
"attributes": {
730+
"forecast": [],
731+
"friendly_name": "Solar Forecast ML evcc Solar-Prognose"
732+
# no unit_of_measurement key → evaluates to None
733+
}
734+
}
735+
736+
# Build a provider with explicit Wh unit, then directly test the async
737+
# unit-check by calling _check_sensor_unit_async with a mocked WebSocket.
738+
provider = ForecastSolarHomeAssistantML(
739+
pvinstallations=pv_installations,
740+
timezone=timezone,
741+
base_url="http://homeassistant.local:8123",
742+
api_token="test_token",
743+
entity_id="sensor.solar_forecast_ml_evcc_solar_prognose",
744+
sensor_unit="Wh" # start with an explicit unit; we'll override below
745+
)
746+
747+
# Patch _websocket_connect and the subsequent recv messages so that the
748+
# async unit-check path returns an entity with no unit_of_measurement.
749+
mock_ws = AsyncMock()
750+
mock_ws.recv = AsyncMock(side_effect=[
751+
json.dumps({"type": "auth_required", "ha_version": "2026.3.0"}),
752+
json.dumps({"type": "auth_ok", "ha_version": "2026.3.0"}),
753+
json.dumps({"type": "result", "id": 1, "success": True,
754+
"result": [provider_state]}),
755+
])
756+
mock_ws.close = AsyncMock()
757+
758+
with patch(
759+
'src.batcontrol.forecastsolar.forecast_homeassistant_ml.connect',
760+
new_callable=AsyncMock,
761+
return_value=mock_ws
762+
):
763+
import asyncio
764+
factor = asyncio.run(provider._check_sensor_unit_async())
765+
766+
# unit_of_measurement is None → should default to 1.0 (Wh) with a warning
767+
assert factor == 1.0
768+
769+
547770
if __name__ == "__main__":
548771
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)