From 47cdb1c9c5c81390249471fcf69bec21a1ceba67 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 14:43:04 +0200 Subject: [PATCH 01/60] Add PV forecast providers and scheduled publishing --- packages/control/optional.py | 23 ++++ packages/control/optional_data.py | 35 ++++++ packages/modules/common/component_state.py | 8 ++ .../modules/common/configurable_forecast.py | 88 +++++++++++++++ packages/modules/common/store/__init__.py | 1 + packages/modules/common/store/_forecast.py | 77 +++++++++++++ packages/modules/configuration.py | 33 ++++++ packages/modules/forecast/__init__.py | 0 .../forecast/forecastsolar/__init__.py | 0 .../modules/forecast/forecastsolar/config.py | 23 ++++ .../forecast/forecastsolar/forecast.py | 105 ++++++++++++++++++ .../modules/forecast/openmeteo/__init__.py | 0 packages/modules/forecast/openmeteo/config.py | 22 ++++ .../modules/forecast/openmeteo/forecast.py | 77 +++++++++++++ packages/modules/forecast/pvnode/__init__.py | 0 packages/modules/forecast/pvnode/config.py | 20 ++++ packages/modules/forecast/pvnode/forecast.py | 91 +++++++++++++++ packages/modules/loadvars.py | 9 ++ 18 files changed, 612 insertions(+) create mode 100644 packages/modules/common/configurable_forecast.py create mode 100644 packages/modules/common/store/_forecast.py create mode 100644 packages/modules/forecast/__init__.py create mode 100644 packages/modules/forecast/forecastsolar/__init__.py create mode 100644 packages/modules/forecast/forecastsolar/config.py create mode 100644 packages/modules/forecast/forecastsolar/forecast.py create mode 100644 packages/modules/forecast/openmeteo/__init__.py create mode 100644 packages/modules/forecast/openmeteo/config.py create mode 100644 packages/modules/forecast/openmeteo/forecast.py create mode 100644 packages/modules/forecast/pvnode/__init__.py create mode 100644 packages/modules/forecast/pvnode/config.py create mode 100644 packages/modules/forecast/pvnode/forecast.py diff --git a/packages/control/optional.py b/packages/control/optional.py index 975ca28567..f3f06f38a9 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -14,6 +14,7 @@ from helpermodules.pub import Pub from helpermodules import timecheck from helpermodules.utils import thread_handler +from modules.common.configurable_forecast import ConfigurableForecast from modules.common.configurable_tariff import ConfigurableFlexibleTariff, ConfigurableGridFee from modules.common.configurable_monitoring import ConfigurableMonitoring @@ -28,6 +29,7 @@ def __init__(self): self.data = OptionalData() self._flexible_tariff_module: TypingOptional[ConfigurableFlexibleTariff] = None self._grid_fee_module: TypingOptional[ConfigurableGridFee] = None + self._forecast_module: TypingOptional[ConfigurableForecast] = None self.monitoring_module: TypingOptional[ConfigurableMonitoring] = None self.data.dc_charging = hardware_configuration.get_hardware_configuration_setting("dc_charging") Pub().pub("openWB/optional/dc_charging", self.data.dc_charging) @@ -52,6 +54,27 @@ def flexible_tariff_module(self, value: TypingOptional[ConfigurableFlexibleTarif def grid_fee_module(self) -> TypingOptional[ConfigurableGridFee]: return self._grid_fee_module + @property + def forecast_module(self) -> TypingOptional[ConfigurableForecast]: + return self._forecast_module + + @forecast_module.setter + def forecast_module(self, value: TypingOptional[ConfigurableForecast]): + self._forecast_module = value + if value is None: + self.data.forecast.configured = False + self.data.forecast.provider = None + Pub().pub("openWB/set/optional/forecast/configured", False) + Pub().pub("openWB/set/optional/forecast/provider", None) + Pub().pub("openWB/set/optional/forecast/get/values", {}) + Pub().pub("openWB/set/optional/forecast/get/daily_kwh", {}) + Pub().pub("openWB/set/optional/forecast/get/next_query_time", None) + else: + self.data.forecast.configured = True + self.data.forecast.provider = value.config.type + Pub().pub("openWB/set/optional/forecast/configured", True) + Pub().pub("openWB/set/optional/forecast/provider", value.config.type) + @grid_fee_module.setter def grid_fee_module(self, value: TypingOptional[ConfigurableGridFee]): if (value is None or diff --git a/packages/control/optional_data.py b/packages/control/optional_data.py index 212a037948..a2978a0a8c 100644 --- a/packages/control/optional_data.py +++ b/packages/control/optional_data.py @@ -6,6 +6,29 @@ from modules.display_themes.cards.config import CardsDisplayTheme +@dataclass +class ForecastGet: + fault_state: int = field(default=0) + fault_str: str = field(default=NO_ERROR) + values: Dict = field(default_factory=empty_dict_factory) + daily_kwh: Dict = field(default_factory=empty_dict_factory) + next_query_time: int = field(default=0) + + +def create_forecast_get_with_topics(topic_prefix: str) -> ForecastGet: + forecast_get = ForecastGet() + forecast_get.__dataclass_fields__['fault_state'].metadata = {"topic": f"{topic_prefix}/get/fault_state"} + forecast_get.__dataclass_fields__['fault_str'].metadata = {"topic": f"{topic_prefix}/get/fault_str"} + forecast_get.__dataclass_fields__['values'].metadata = {"topic": f"{topic_prefix}/get/values"} + forecast_get.__dataclass_fields__['daily_kwh'].metadata = {"topic": f"{topic_prefix}/get/daily_kwh"} + forecast_get.__dataclass_fields__['next_query_time'].metadata = {"topic": f"{topic_prefix}/get/next_query_time"} + return forecast_get + + +def forecast_get_factory() -> ForecastGet: + return create_forecast_get_with_topics("forecast") + + @dataclass class PricingGet: fault_state: int = field(default=0) @@ -77,10 +100,21 @@ class ElectricityPricing: get: ElectricityPricingGet = field(default_factory=electricity_pricing_get_factory) +@dataclass +class Forecast: + configured: bool = field(default=False, metadata={"topic": "forecast/configured"}) + provider: Optional[str] = field(default=None, metadata={"topic": "forecast/provider"}) + get: ForecastGet = field(default_factory=forecast_get_factory) + + def ep_factory() -> ElectricityPricing: return ElectricityPricing() +def forecast_factory() -> Forecast: + return Forecast() + + def cards_display_theme_factory() -> CardsDisplayTheme: return CardsDisplayTheme() @@ -136,6 +170,7 @@ def ocpp_factory() -> Ocpp: @dataclass class OptionalData: electricity_pricing: ElectricityPricing = field(default_factory=ep_factory) + forecast: Forecast = field(default_factory=forecast_factory) int_display: InternalDisplay = field(default_factory=int_display_factory) rfid: Rfid = field(default_factory=rfid_factory) dc_charging: bool = field(default=False, metadata={"topic": "dc_charging"}) diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index be27fda208..8943dcb5ac 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -246,6 +246,14 @@ def __init__(self, self.prices = prices +@auto_str +class ForecastState: + def __init__(self, + forecast_values: Optional[Dict[str, float]] = None + ) -> None: + self.forecast_values = forecast_values + + @auto_str class IoState: """JSON erlaubt nur Zeichenketten als Schlüssel für Objekte""" diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py new file mode 100644 index 0000000000..0587616671 --- /dev/null +++ b/packages/modules/common/configurable_forecast.py @@ -0,0 +1,88 @@ +import logging +from datetime import datetime, timedelta +from typing import Generic, TypeVar, Callable + +from control import data +from control.optional_data import OptionalData +from helpermodules import timecheck +from helpermodules.constants import NO_ERROR +from helpermodules.pub import Pub +from modules.common import store +from modules.common.component_state import ForecastState +from modules.common.fault_state_level import FaultStateLevel + +T_FORECAST_CONFIG = TypeVar("T_FORECAST_CONFIG") +log = logging.getLogger(__name__) +DEFAULT_FORECAST_UPDATE_HOURS = [5, 8, 11, 14, 17, 20] +FORECAST_RETRY_MINUTES = 15 + + +class ConfigurableForecast(Generic[T_FORECAST_CONFIG]): + def __init__(self, + config: T_FORECAST_CONFIG, + component_initializer: Callable[[T_FORECAST_CONFIG], ForecastState]) -> None: + self.config = config + self.store = store.get_forecast_value_store() + self._component_updater = component_initializer(config) + self.update_hours = DEFAULT_FORECAST_UPDATE_HOURS + self.next_query_time: int | None = None + + def _publish_forecast_fault(self, level: FaultStateLevel, message: str) -> None: + data.data.optional_data.data.forecast.get.fault_state = level.value + data.data.optional_data.data.forecast.get.fault_str = message + Pub().pub("openWB/set/optional/forecast/get/fault_state", level.value) + Pub().pub("openWB/set/optional/forecast/get/fault_str", message) + + def _is_update_due(self) -> bool: + return self.next_query_time is None or self.next_query_time <= timecheck.create_timestamp() + + def _set_next_query_time_by_schedule(self) -> None: + now = datetime.now() + current_hour = now.hour + next_hour = min([hour for hour in self.update_hours if hour > current_hour], default=self.update_hours[0]) + day_offset = 0 if next_hour > current_hour else 1 + next_query_time = now.replace(hour=next_hour, minute=0, second=0, microsecond=0) + timedelta(days=day_offset) + self.next_query_time = int(next_query_time.timestamp()) + Pub().pub("openWB/set/optional/forecast/get/next_query_time", self.next_query_time) + + def _set_retry_query_time(self, minutes: int = FORECAST_RETRY_MINUTES) -> None: + self.next_query_time = int((datetime.now() + timedelta(minutes=minutes)).timestamp()) + Pub().pub("openWB/set/optional/forecast/get/next_query_time", self.next_query_time) + + def update(self) -> None: + if not self._is_update_due(): + return + try: + state = self._component_updater() + self.store.set(state) + self.store.update() + self._set_next_query_time_by_schedule() + self._publish_forecast_fault(FaultStateLevel.NO_ERROR, NO_ERROR) + data.data.optional_data.data.forecast.configured = True + data.data.optional_data.data.forecast.provider = self.config.type + Pub().pub("openWB/set/optional/forecast/configured", True) + Pub().pub("openWB/set/optional/forecast/provider", self.config.type) + Pub().pub("openWB/set/optional/forecast/current", state.forecast_values) + except Exception as e: + if "429" in str(e): + # Rate limited providers should wait until the next planned schedule slot. + self._set_next_query_time_by_schedule() + self._publish_forecast_fault( + FaultStateLevel.WARNING, + "Forecast API rate limit reached (HTTP 429). Waiting for next scheduled update.", + ) + else: + self._set_retry_query_time() + self._publish_forecast_fault( + FaultStateLevel.WARNING, + "Forecast update failed. Retry scheduled in 15 minutes.", + ) + log.exception(f"Fehler beim Aktualisieren der Forecast-Daten {e}") + + +class ConfigurableForecastProvider(ConfigurableForecast[T_FORECAST_CONFIG]): + def __init__(self, + config: T_FORECAST_CONFIG, + component_initializer: Callable[[T_FORECAST_CONFIG], ForecastState]) -> None: + super().__init__(config, component_initializer) + self._optional_data = OptionalData() diff --git a/packages/modules/common/store/__init__.py b/packages/modules/common/store/__init__.py index 12dcedb991..7ce3c7f905 100644 --- a/packages/modules/common/store/__init__.py +++ b/packages/modules/common/store/__init__.py @@ -5,3 +5,4 @@ from modules.common.store._factory import get_component_value_store from modules.common.store._io import get_io_value_store from modules.common.store._tariff import get_flexible_tariff_value_store, get_grid_fee_value_store +from modules.common.store._forecast import get_forecast_value_store diff --git a/packages/modules/common/store/_forecast.py b/packages/modules/common/store/_forecast.py new file mode 100644 index 0000000000..f77c3bf877 --- /dev/null +++ b/packages/modules/common/store/_forecast.py @@ -0,0 +1,77 @@ +from datetime import datetime +from typing import Dict + +from control import data +from helpermodules.pub import Pub +from modules.common.component_state import ForecastState +from modules.common.store import ValueStore +from modules.common.store._api import LoggingValueStore +from modules.common.store._broker import pub_to_broker +import logging + + +log = logging.getLogger(__name__) + + +def _parse_forecast_timestamp(timestamp: str) -> datetime | None: + try: + if timestamp.isdigit(): + return datetime.fromtimestamp(int(timestamp)) + return datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + + +def _calculate_daily_kwh(values: Dict[str, float]) -> Dict[str, float]: + points: list[tuple[datetime, float]] = [] + for timestamp, value in values.items(): + parsed_timestamp = _parse_forecast_timestamp(timestamp) + if parsed_timestamp is None: + continue + points.append((parsed_timestamp, float(value))) + + if not points: + return {} + + points.sort(key=lambda item: item[0]) + deltas = [ + int((points[index + 1][0] - points[index][0]).total_seconds()) + for index in range(len(points) - 1) + if 0 < int((points[index + 1][0] - points[index][0]).total_seconds()) <= 21600 + ] + fallback_step_seconds = min(deltas) if deltas else 3600 + + daily_wh: Dict[str, float] = {} + for index, (timestamp, power_w) in enumerate(points): + if index + 1 < len(points): + step_seconds = int((points[index + 1][0] - timestamp).total_seconds()) + if step_seconds <= 0 or step_seconds > 21600: + step_seconds = fallback_step_seconds + else: + step_seconds = fallback_step_seconds + date_key = timestamp.date().isoformat() + daily_wh[date_key] = daily_wh.get(date_key, 0.0) + max(0.0, power_w) * (step_seconds / 3600.0) + + return {date_key: energy_wh / 1000.0 for date_key, energy_wh in daily_wh.items()} + + +class ForecastValueStore(ValueStore[ForecastState]): + def __init__(self): + pass + + def set(self, state: ForecastState) -> None: + self.state = state + + def update(self): + values = self.state.forecast_values or {} + daily_kwh = _calculate_daily_kwh(values) + data.data.optional_data.data.forecast.get.values = values + data.data.optional_data.data.forecast.get.daily_kwh = daily_kwh + pub_to_broker("openWB/set/optional/forecast/get/values", values) + pub_to_broker("openWB/set/optional/forecast/get/daily_kwh", daily_kwh) + Pub().pub("openWB/optional/forecast/current", values) + log.debug(f"published forecast values to MQTT having {len(values)} entries and {len(daily_kwh)} day totals") + + +def get_forecast_value_store() -> ValueStore[ForecastState]: + return LoggingValueStore(ForecastValueStore()) diff --git a/packages/modules/configuration.py b/packages/modules/configuration.py index 0af7ccf629..4c3e191b2b 100644 --- a/packages/modules/configuration.py +++ b/packages/modules/configuration.py @@ -16,6 +16,7 @@ def pub_configurable(): _pub_configurable_backup_clouds() _pub_configurable_web_themes() _pub_configurable_display_themes() + _pub_configurable_forecasts() _pub_configurable_tariffs() _pub_configurable_soc_modules() _pub_configurable_devices_components() @@ -110,6 +111,38 @@ def _pub_configurable_display_themes() -> None: log.exception("Fehler im configuration-Modul") +def _pub_configurable_forecasts() -> None: + try: + forecasts: List[Dict] = [] + path_list = Path(_get_packages_path()/'modules'/'forecast').glob('**/forecast.py') + for path in path_list: + try: + if path.name.endswith('_test.py'): + continue + dev_defaults = importlib.import_module( + f'.forecast.{path.parts[-2]}.forecast', 'modules').device_descriptor.configuration_factory() + forecasts.append({ + 'value': dev_defaults.type, + 'text': dev_defaults.name, + 'defaults': dataclass_utils.asdict(dev_defaults) + }) + except Exception as e: + log.exception(f'Fehler im configuration-Modul, {path}: {e}') + forecasts = sorted(forecasts, key=lambda d: d['text'].upper()) + forecasts.insert(0, + { + 'value': None, + 'text': '- kein Anbieter -', + 'defaults': { + 'type': None, + 'configuration': {} + } + }) + Pub().pub('openWB/set/system/configurable/forecasts', forecasts) + except Exception: + log.exception('Fehler im configuration-Modul') + + def _pub_configurable_tariffs() -> None: def pub(source: str): try: diff --git a/packages/modules/forecast/__init__.py b/packages/modules/forecast/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/forecast/forecastsolar/__init__.py b/packages/modules/forecast/forecastsolar/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/forecast/forecastsolar/config.py b/packages/modules/forecast/forecastsolar/config.py new file mode 100644 index 0000000000..105622b32c --- /dev/null +++ b/packages/modules/forecast/forecastsolar/config.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field +from typing import Any, Optional + + +@dataclass +class ForecastSolarConfiguration: + latitude: Optional[float] = None + longitude: Optional[float] = None + peak_power_kw: Optional[float] = None + azimuth: Optional[float] = None + tilt: Optional[float] = None + loss: Optional[float] = None + horizon: Optional[str] = None + output: Optional[str] = None + strings: Optional[list[dict[str, Any]]] = None + + +@dataclass +class ForecastSolar: + name: str = "Forecast.Solar" + type: str = "forecastsolar" + official: bool = True + configuration: ForecastSolarConfiguration = field(default_factory=ForecastSolarConfiguration) diff --git a/packages/modules/forecast/forecastsolar/forecast.py b/packages/modules/forecast/forecastsolar/forecast.py new file mode 100644 index 0000000000..b894ae1015 --- /dev/null +++ b/packages/modules/forecast/forecastsolar/forecast.py @@ -0,0 +1,105 @@ +from datetime import datetime +import logging +from typing import Any, Dict +from requests import HTTPError + +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ForecastState + +from modules.forecast.forecastsolar.config import ForecastSolar, ForecastSolarConfiguration + + +log = logging.getLogger(__name__) + + +def _log_forecast_solar_rate_limit(payload: dict, headers: dict, url: str) -> None: + message = payload.get("message") if isinstance(payload, dict) else None + ratelimit = message.get("ratelimit") if isinstance(message, dict) else None + if not isinstance(ratelimit, dict): + return + retry_at = ratelimit.get("retry-at") or headers.get("X-Ratelimit-Retry-At") + remaining = ratelimit.get("remaining") or headers.get("X-Ratelimit-Remaining") + limit = ratelimit.get("limit") or headers.get("X-Ratelimit-Limit") + period = ratelimit.get("period") or headers.get("X-Ratelimit-Period") + log.info( + "Forecast.Solar ratelimit info for %s: remaining=%s limit=%s period=%s retry_at=%s", + url, + remaining, + limit, + period, + retry_at, + ) + + +def fetch_forecast(config: ForecastSolarConfiguration) -> Dict[str, float]: + latitude = config.latitude if config.latitude is not None else 52.52 + longitude = config.longitude if config.longitude is not None else 13.405 + peak_power_kw = config.peak_power_kw if config.peak_power_kw is not None else 5.0 + azimuth = config.azimuth if config.azimuth is not None else 180.0 + tilt = config.tilt if config.tilt is not None else 35.0 + loss = config.loss if config.loss is not None else 14.0 + horizon = config.horizon if config.horizon is not None else "0" + + string_configs: list[dict[str, Any]] = config.strings if config.strings else [{ + "peak_power_kw": peak_power_kw, + "azimuth": azimuth, + "tilt": tilt, + "loss": loss, + "horizon": horizon, + }] + if len(string_configs) > 6: + string_configs = string_configs[:6] + values: Dict[str, float] = {} + for string_config in string_configs: + string_peak_power_kw = string_config.get("peak_power_kw") if string_config.get("peak_power_kw") is not None else peak_power_kw + string_azimuth = string_config.get("azimuth") if string_config.get("azimuth") is not None else azimuth + string_tilt = string_config.get("tilt") if string_config.get("tilt") is not None else tilt + string_loss = string_config.get("loss") if string_config.get("loss") is not None else loss + string_horizon = string_config.get("horizon") if string_config.get("horizon") is not None else horizon + + url = ( + "https://api.forecast.solar/estimate/watts" + f"?lat={latitude}" + f"&lon={longitude}" + f"&dec={string_peak_power_kw}" + f"&az={string_azimuth}" + f"&tilt={string_tilt}" + f"&loss={string_loss}" + f"&horizon={string_horizon}" + ) + try: + response_obj = req.get_http_session().get(url, timeout=(2, 6)) + except HTTPError as e: + response = e.response + if response is not None and response.status_code == 429: + retry_at = response.headers.get("X-Ratelimit-Retry-At") + remaining = response.headers.get("X-Ratelimit-Remaining") + limit = response.headers.get("X-Ratelimit-Limit") + period = response.headers.get("X-Ratelimit-Period") + log.warning( + "Forecast.Solar rate limit hit for %s: remaining=%s limit=%s period=%s retry_at=%s", + url, + remaining, + limit, + period, + retry_at, + ) + raise + response = response_obj.json() + _log_forecast_solar_rate_limit(response, dict(response_obj.headers), url) + for timestamp, value in response.items(): + if value is None: + continue + timestamp_key = str(int(datetime.fromisoformat(timestamp).timestamp())) + values[timestamp_key] = values.get(timestamp_key, 0.0) + float(value) + return values + + +def create_forecast(config: ForecastSolar): + def updater(): + return ForecastState(forecast_values=fetch_forecast(config.configuration)) + return updater + + +device_descriptor = DeviceDescriptor(configuration_factory=ForecastSolar) diff --git a/packages/modules/forecast/openmeteo/__init__.py b/packages/modules/forecast/openmeteo/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/forecast/openmeteo/config.py b/packages/modules/forecast/openmeteo/config.py new file mode 100644 index 0000000000..67c10a7262 --- /dev/null +++ b/packages/modules/forecast/openmeteo/config.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass, field +from typing import Any, Optional + + +@dataclass +class OpenMeteoForecastConfiguration: + latitude: Optional[float] = None + longitude: Optional[float] = None + timezone: Optional[str] = None + forecast_hours: Optional[int] = None + peak_power_kw: Optional[float] = None + system_loss: Optional[float] = None + irradiance_to_power_factor: Optional[float] = None + strings: Optional[list[dict[str, Any]]] = None + + +@dataclass +class OpenMeteoForecast: + name: str = "Open-Meteo PV Forecast" + type: str = "openmeteo" + official: bool = True + configuration: OpenMeteoForecastConfiguration = field(default_factory=OpenMeteoForecastConfiguration) diff --git a/packages/modules/forecast/openmeteo/forecast.py b/packages/modules/forecast/openmeteo/forecast.py new file mode 100644 index 0000000000..29f51cc35c --- /dev/null +++ b/packages/modules/forecast/openmeteo/forecast.py @@ -0,0 +1,77 @@ +from datetime import datetime +from typing import Any, Dict +from zoneinfo import ZoneInfo + +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ForecastState + +from modules.forecast.openmeteo.config import OpenMeteoForecast, OpenMeteoForecastConfiguration + + +def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Dict[str, float]: + latitude = config.latitude if config.latitude is not None else 52.52 + longitude = config.longitude if config.longitude is not None else 13.405 + timezone = config.timezone if config.timezone is not None else "Europe/Berlin" + forecast_hours = config.forecast_hours if config.forecast_hours is not None else 24 + peak_power_kw = config.peak_power_kw if config.peak_power_kw is not None else 5.0 + system_loss = config.system_loss if config.system_loss is not None else 0.15 + irradiance_to_power_factor = ( + config.irradiance_to_power_factor if config.irradiance_to_power_factor is not None else 1.0 + ) + + string_configs: list[dict[str, Any]] = config.strings if config.strings else [{"peak_power_kw": peak_power_kw}] + if len(string_configs) > 6: + string_configs = string_configs[:6] + values: Dict[str, float] = {} + for string_config in string_configs: + string_peak_power_kw = ( + string_config.get("peak_power_kw") if string_config.get("peak_power_kw") is not None else peak_power_kw + ) + tilt = string_config.get("tilt") + azimuth = string_config.get("azimuth") + hourly_field = "global_tilted_irradiance" if tilt is not None or azimuth is not None else "shortwave_radiation" + + url = ( + "https://api.open-meteo.com/v1/forecast" + f"?latitude={latitude}" + f"&longitude={longitude}" + f"&hourly={hourly_field}" + f"&timezone={timezone}" + ) + if tilt is not None: + url += f"&tilt={tilt}" + if azimuth is not None: + url += f"&azimuth={azimuth}" + + response = req.get_http_session().get(url, timeout=(2, 6)).json() + hourly = response.get("hourly", {}) + times = hourly.get("time", []) + radiation = hourly.get(hourly_field, []) + for timestamp, value in zip(times[:forecast_hours], radiation[:forecast_hours]): + if value is None: + continue + estimated_power_w = max( + 0.0, + float(string_peak_power_kw) * 1000.0 * (float(value) / 1000.0) * float(irradiance_to_power_factor) + * (1.0 - float(system_loss)) + ) + timestamp_key = str(__parse_timestamp(timestamp, timezone)) + values[timestamp_key] = values.get(timestamp_key, 0.0) + estimated_power_w + return values + + +def __parse_timestamp(value: str, timezone_name: str) -> int: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=ZoneInfo(timezone_name)) + return int(parsed.timestamp()) + + +def create_forecast(config: OpenMeteoForecast): + def updater(): + return ForecastState(forecast_values=fetch_forecast(config.configuration)) + return updater + + +device_descriptor = DeviceDescriptor(configuration_factory=OpenMeteoForecast) diff --git a/packages/modules/forecast/pvnode/__init__.py b/packages/modules/forecast/pvnode/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/forecast/pvnode/config.py b/packages/modules/forecast/pvnode/config.py new file mode 100644 index 0000000000..6b6045a933 --- /dev/null +++ b/packages/modules/forecast/pvnode/config.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class PvNodeConfiguration: + latitude: Optional[float] = None + longitude: Optional[float] = None + peak_power_kw: Optional[float] = None + system_loss: Optional[float] = None + api_key: Optional[str] = None + plant_id: Optional[str] = None + + +@dataclass +class PvNode: + name: str = "PVNode V2" + type: str = "pvnode" + official: bool = True + configuration: PvNodeConfiguration = field(default_factory=PvNodeConfiguration) diff --git a/packages/modules/forecast/pvnode/forecast.py b/packages/modules/forecast/pvnode/forecast.py new file mode 100644 index 0000000000..f14f2d5a9b --- /dev/null +++ b/packages/modules/forecast/pvnode/forecast.py @@ -0,0 +1,91 @@ +from datetime import datetime +from typing import Any, Dict + +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ForecastState + +from modules.forecast.pvnode.config import PvNode, PvNodeConfiguration + + +def _normalize_timestamp(value: Any) -> int | None: + if value is None: + return None + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + text = value.strip() + if text.isdigit(): + return int(text) + try: + return int(datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()) + except ValueError: + return None + return None + + +def fetch_forecast(config: PvNodeConfiguration) -> Dict[str, float]: + latitude = config.latitude if config.latitude is not None else 52.52 + longitude = config.longitude if config.longitude is not None else 13.405 + peak_power_kw = config.peak_power_kw if config.peak_power_kw is not None else 5.0 + system_loss = config.system_loss if config.system_loss is not None else 0.1 + plant_id = config.plant_id if config.plant_id is not None else "" + + path = f"/v2/forecast/{plant_id}" if plant_id else "/v2/forecast" + url = f"https://api.pvnode.com{path}" + headers = {"Authorization": f"Bearer {config.api_key}"} if config.api_key else {} + response = req.get_http_session().get(url, headers=headers, timeout=(2, 6)).json() + values: Dict[str, float] = {} + + payload = response.get("values") + if payload is None: + payload = response.get("data") + if payload is None: + payload = response.get("forecast") or response.get("forecasts") or [] + + if isinstance(payload, list): + for entry in payload: + if not isinstance(entry, dict): + continue + timestamp = _normalize_timestamp( + entry.get("timestamp") or entry.get("period_end") or entry.get("period_start") + ) + value = entry.get("pv_power") or entry.get("power_kw") or entry.get("power") or entry.get("value") + if timestamp is None or value is None: + continue + numeric_value = float(value) + if entry.get("pv_power") is not None and numeric_value > 1000.0: + estimated_power_w = max(0.0, numeric_value) + elif entry.get("pv_power") is not None: + estimated_power_w = max(0.0, numeric_value) + else: + estimated_power_w = max( + 0.0, + numeric_value * float(peak_power_kw) / 100.0 * (1.0 - float(system_loss)) * 1000.0 + ) + values[str(timestamp)] = estimated_power_w + elif isinstance(payload, dict): + for key, value in payload.items(): + timestamp = _normalize_timestamp(key) + if timestamp is None or value is None: + continue + numeric_value = float(value) + if isinstance(value, (int, float)) and numeric_value > 1000.0: + estimated_power_w = max(0.0, numeric_value) + else: + estimated_power_w = max( + 0.0, + numeric_value * float(peak_power_kw) / 100.0 * (1.0 - float(system_loss)) * 1000.0 + ) + values[str(timestamp)] = estimated_power_w + + return values + + +def create_forecast(config: PvNode): + def updater(): + return ForecastState(forecast_values=fetch_forecast(config.configuration)) + return updater + + +device_descriptor = DeviceDescriptor(configuration_factory=PvNode) diff --git a/packages/modules/loadvars.py b/packages/modules/loadvars.py index 5fc9efd6a4..0a9045010d 100644 --- a/packages/modules/loadvars.py +++ b/packages/modules/loadvars.py @@ -41,6 +41,8 @@ def get_values(self) -> None: wait_for_module_update_completed(self.event_module_update_completed, topic) if (data.data.optional_data.data.electricity_pricing.configured): self.ep_get_prices() + if data.data.optional_data.data.forecast.configured: + self.forecast_get_values() except Exception: log.exception("Fehler im loadvars-Modul") @@ -132,6 +134,13 @@ def _set_io(self) -> List[Thread]: finally: return threads + def forecast_get_values(self): + try: + if hasattr(data.data.optional_data, "forecast_module") and data.data.optional_data.forecast_module is not None: + data.data.optional_data.forecast_module.update() + except Exception as e: + log.exception("Fehler im Forecast-Optional-Modul: %s", e) + def ep_get_prices(self): def append_thread_set_values(module_name: str) -> None: module = getattr(data.data.optional_data, f"{module_name}_module") From f9980c4241b9b92e59de6dfb189c150b40444a06 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 15:14:34 +0200 Subject: [PATCH 02/60] Add provider-aware PV forecast daily values and manual refresh trigger --- packages/control/optional.py | 5 + packages/control/optional_data.py | 10 ++ packages/helpermodules/setdata.py | 24 ++++ packages/helpermodules/subdata.py | 4 + packages/modules/common/component_state.py | 4 +- .../modules/common/configurable_forecast.py | 21 ++- packages/modules/common/store/_forecast.py | 38 ++++- .../modules/forecast/forecastsolar/config.py | 15 +- .../forecast/forecastsolar/forecast.py | 135 ++++++++++-------- packages/modules/forecast/openmeteo/config.py | 15 +- .../modules/forecast/openmeteo/forecast.py | 84 +++++------ packages/modules/forecast/pvnode/config.py | 11 +- packages/modules/forecast/pvnode/forecast.py | 44 ++++-- 13 files changed, 263 insertions(+), 147 deletions(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index f3f06f38a9..fbd30a3c0e 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -66,8 +66,13 @@ def forecast_module(self, value: TypingOptional[ConfigurableForecast]): self.data.forecast.provider = None Pub().pub("openWB/set/optional/forecast/configured", False) Pub().pub("openWB/set/optional/forecast/provider", None) + Pub().pub("openWB/set/optional/forecast/get/force_update", False) Pub().pub("openWB/set/optional/forecast/get/values", {}) + Pub().pub("openWB/set/optional/forecast/get/today_values", {}) + Pub().pub("openWB/set/optional/forecast/get/tomorrow_values", {}) Pub().pub("openWB/set/optional/forecast/get/daily_kwh", {}) + Pub().pub("openWB/set/optional/forecast/get/today_kwh", 0.0) + Pub().pub("openWB/set/optional/forecast/get/tomorrow_kwh", 0.0) Pub().pub("openWB/set/optional/forecast/get/next_query_time", None) else: self.data.forecast.configured = True diff --git a/packages/control/optional_data.py b/packages/control/optional_data.py index a2978a0a8c..9152f5d0b0 100644 --- a/packages/control/optional_data.py +++ b/packages/control/optional_data.py @@ -10,8 +10,13 @@ class ForecastGet: fault_state: int = field(default=0) fault_str: str = field(default=NO_ERROR) + force_update: bool = field(default=False) values: Dict = field(default_factory=empty_dict_factory) + today_values: Dict = field(default_factory=empty_dict_factory) + tomorrow_values: Dict = field(default_factory=empty_dict_factory) daily_kwh: Dict = field(default_factory=empty_dict_factory) + today_kwh: float = field(default=0.0) + tomorrow_kwh: float = field(default=0.0) next_query_time: int = field(default=0) @@ -19,8 +24,13 @@ def create_forecast_get_with_topics(topic_prefix: str) -> ForecastGet: forecast_get = ForecastGet() forecast_get.__dataclass_fields__['fault_state'].metadata = {"topic": f"{topic_prefix}/get/fault_state"} forecast_get.__dataclass_fields__['fault_str'].metadata = {"topic": f"{topic_prefix}/get/fault_str"} + forecast_get.__dataclass_fields__['force_update'].metadata = {"topic": f"{topic_prefix}/get/force_update"} forecast_get.__dataclass_fields__['values'].metadata = {"topic": f"{topic_prefix}/get/values"} + forecast_get.__dataclass_fields__['today_values'].metadata = {"topic": f"{topic_prefix}/get/today_values"} + forecast_get.__dataclass_fields__['tomorrow_values'].metadata = {"topic": f"{topic_prefix}/get/tomorrow_values"} forecast_get.__dataclass_fields__['daily_kwh'].metadata = {"topic": f"{topic_prefix}/get/daily_kwh"} + forecast_get.__dataclass_fields__['today_kwh'].metadata = {"topic": f"{topic_prefix}/get/today_kwh"} + forecast_get.__dataclass_fields__['tomorrow_kwh'].metadata = {"topic": f"{topic_prefix}/get/tomorrow_kwh"} forecast_get.__dataclass_fields__['next_query_time'].metadata = {"topic": f"{topic_prefix}/get/next_query_time"} return forecast_get diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 7973936181..dcf95a5053 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -882,6 +882,30 @@ def process_optional_topic(self, msg: mqtt.MQTTMessage): self._validate_value(msg, "json") elif "openWB/set/optional/ep/configured" in msg.topic: self._validate_value(msg, bool) + elif "openWB/set/optional/forecast/configured" in msg.topic: + self._validate_value(msg, bool) + elif "openWB/set/optional/forecast/provider" in msg.topic: + self._validate_value(msg, "json") + elif "openWB/set/optional/forecast/get/values" in msg.topic: + self._validate_value(msg, "json") + elif "openWB/set/optional/forecast/get/today_values" in msg.topic: + self._validate_value(msg, "json") + elif "openWB/set/optional/forecast/get/tomorrow_values" in msg.topic: + self._validate_value(msg, "json") + elif "openWB/set/optional/forecast/get/daily_kwh" in msg.topic: + self._validate_value(msg, "json") + elif "openWB/set/optional/forecast/get/today_kwh" in msg.topic: + self._validate_value(msg, float) + elif "openWB/set/optional/forecast/get/tomorrow_kwh" in msg.topic: + self._validate_value(msg, float) + elif "openWB/set/optional/forecast/get/next_query_time" in msg.topic: + self._validate_value(msg, int) + elif "openWB/set/optional/forecast/get/fault_state" in msg.topic: + self._validate_value(msg, int, [(0, 2)]) + elif "openWB/set/optional/forecast/get/fault_str" in msg.topic: + self._validate_value(msg, str) + elif "openWB/set/optional/forecast/get/force_update" in msg.topic: + self._validate_value(msg, bool) elif "module_update_completed" in msg.topic: self._validate_value(msg, bool) elif "openWB/set/optional/ocpp/config" in msg.topic: diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 4c59046940..3fdeed4de5 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -798,6 +798,10 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): self.set_json_payload_class(var.data.electricity_pricing.get, msg) elif re.search("/optional/ep/", msg.topic) is not None: self.set_json_payload_class(var.data.electricity_pricing, msg) + elif re.search("/optional/forecast/get/", msg.topic) is not None: + self.set_json_payload_class(var.data.forecast.get, msg) + elif re.search("/optional/forecast/", msg.topic) is not None: + self.set_json_payload_class(var.data.forecast, msg) elif "module_update_completed" in msg.topic: self.event_module_update_completed.set() elif re.search("/optional/ocpp/", msg.topic) is not None: diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index 8943dcb5ac..8f3da364a5 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -249,9 +249,11 @@ def __init__(self, @auto_str class ForecastState: def __init__(self, - forecast_values: Optional[Dict[str, float]] = None + forecast_values: Optional[Dict[str, float]] = None, + daily_kwh: Optional[Dict[str, float]] = None ) -> None: self.forecast_values = forecast_values + self.daily_kwh = daily_kwh @auto_str diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 0587616671..454493bd1d 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -36,6 +36,14 @@ def _publish_forecast_fault(self, level: FaultStateLevel, message: str) -> None: def _is_update_due(self) -> bool: return self.next_query_time is None or self.next_query_time <= timecheck.create_timestamp() + def _is_force_update_requested(self) -> bool: + return bool(data.data.optional_data.data.forecast.get.force_update) + + def _clear_force_update_request(self) -> None: + if data.data.optional_data.data.forecast.get.force_update: + data.data.optional_data.data.forecast.get.force_update = False + Pub().pub("openWB/set/optional/forecast/get/force_update", False) + def _set_next_query_time_by_schedule(self) -> None: now = datetime.now() current_hour = now.hour @@ -50,9 +58,12 @@ def _set_retry_query_time(self, minutes: int = FORECAST_RETRY_MINUTES) -> None: Pub().pub("openWB/set/optional/forecast/get/next_query_time", self.next_query_time) def update(self) -> None: - if not self._is_update_due(): + force_update = self._is_force_update_requested() + if not force_update and not self._is_update_due(): return try: + trigger_mode = "manual" if force_update else "scheduled" + log.info("Forecast update started (provider=%s, trigger=%s)", self.config.type, trigger_mode) state = self._component_updater() self.store.set(state) self.store.update() @@ -63,6 +74,12 @@ def update(self) -> None: Pub().pub("openWB/set/optional/forecast/configured", True) Pub().pub("openWB/set/optional/forecast/provider", self.config.type) Pub().pub("openWB/set/optional/forecast/current", state.forecast_values) + log.info( + "Forecast update finished (provider=%s, values=%s, next_query_time=%s)", + self.config.type, + len(state.forecast_values or {}), + self.next_query_time, + ) except Exception as e: if "429" in str(e): # Rate limited providers should wait until the next planned schedule slot. @@ -78,6 +95,8 @@ def update(self) -> None: "Forecast update failed. Retry scheduled in 15 minutes.", ) log.exception(f"Fehler beim Aktualisieren der Forecast-Daten {e}") + finally: + self._clear_force_update_request() class ConfigurableForecastProvider(ConfigurableForecast[T_FORECAST_CONFIG]): diff --git a/packages/modules/common/store/_forecast.py b/packages/modules/common/store/_forecast.py index f77c3bf877..0127bc8ebf 100644 --- a/packages/modules/common/store/_forecast.py +++ b/packages/modules/common/store/_forecast.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from typing import Dict from control import data @@ -55,6 +55,17 @@ def _calculate_daily_kwh(values: Dict[str, float]) -> Dict[str, float]: return {date_key: energy_wh / 1000.0 for date_key, energy_wh in daily_wh.items()} +def _filter_values_for_date(values: Dict[str, float], target_date) -> Dict[str, float]: + day_values: Dict[str, float] = {} + for timestamp, value in values.items(): + parsed_timestamp = _parse_forecast_timestamp(timestamp) + if parsed_timestamp is None: + continue + if parsed_timestamp.date() == target_date: + day_values[timestamp] = float(value) + return day_values + + class ForecastValueStore(ValueStore[ForecastState]): def __init__(self): pass @@ -64,13 +75,34 @@ def set(self, state: ForecastState) -> None: def update(self): values = self.state.forecast_values or {} - daily_kwh = _calculate_daily_kwh(values) + provider_daily_kwh = self.state.daily_kwh or {} + daily_kwh = provider_daily_kwh if provider_daily_kwh else _calculate_daily_kwh(values) + today_date = datetime.now().date() + tomorrow_date = datetime.now().date() + timedelta(days=1) + today_values = _filter_values_for_date(values, today_date) + tomorrow_values = _filter_values_for_date(values, tomorrow_date) + today_kwh = float(daily_kwh.get(today_date.isoformat(), 0.0)) + tomorrow_kwh = float(daily_kwh.get(tomorrow_date.isoformat(), 0.0)) data.data.optional_data.data.forecast.get.values = values + data.data.optional_data.data.forecast.get.today_values = today_values + data.data.optional_data.data.forecast.get.tomorrow_values = tomorrow_values data.data.optional_data.data.forecast.get.daily_kwh = daily_kwh + data.data.optional_data.data.forecast.get.today_kwh = today_kwh + data.data.optional_data.data.forecast.get.tomorrow_kwh = tomorrow_kwh pub_to_broker("openWB/set/optional/forecast/get/values", values) + pub_to_broker("openWB/set/optional/forecast/get/today_values", today_values) + pub_to_broker("openWB/set/optional/forecast/get/tomorrow_values", tomorrow_values) pub_to_broker("openWB/set/optional/forecast/get/daily_kwh", daily_kwh) + pub_to_broker("openWB/set/optional/forecast/get/today_kwh", today_kwh) + pub_to_broker("openWB/set/optional/forecast/get/tomorrow_kwh", tomorrow_kwh) Pub().pub("openWB/optional/forecast/current", values) - log.debug(f"published forecast values to MQTT having {len(values)} entries and {len(daily_kwh)} day totals") + log.debug( + "published forecast values to MQTT having %s entries, %s day totals, %s today entries, and %s tomorrow entries", + len(values), + len(daily_kwh), + len(today_values), + len(tomorrow_values), + ) def get_forecast_value_store() -> ValueStore[ForecastState]: diff --git a/packages/modules/forecast/forecastsolar/config.py b/packages/modules/forecast/forecastsolar/config.py index 105622b32c..99b604aec5 100644 --- a/packages/modules/forecast/forecastsolar/config.py +++ b/packages/modules/forecast/forecastsolar/config.py @@ -1,18 +1,13 @@ from dataclasses import dataclass, field -from typing import Any, Optional @dataclass class ForecastSolarConfiguration: - latitude: Optional[float] = None - longitude: Optional[float] = None - peak_power_kw: Optional[float] = None - azimuth: Optional[float] = None - tilt: Optional[float] = None - loss: Optional[float] = None - horizon: Optional[str] = None - output: Optional[str] = None - strings: Optional[list[dict[str, Any]]] = None + latitude: float = 0.0 + longitude: float = 0.0 + peak_power_kw: float = 0.0 + azimuth: float = 0.0 + tilt: float = 0.0 @dataclass diff --git a/packages/modules/forecast/forecastsolar/forecast.py b/packages/modules/forecast/forecastsolar/forecast.py index b894ae1015..0b4055bdc3 100644 --- a/packages/modules/forecast/forecastsolar/forecast.py +++ b/packages/modules/forecast/forecastsolar/forecast.py @@ -1,6 +1,6 @@ from datetime import datetime import logging -from typing import Any, Dict +from typing import Dict, Tuple from requests import HTTPError from modules.common import req @@ -13,6 +13,16 @@ log = logging.getLogger(__name__) +def _require(value, field_name: str): + if value is None: + raise ValueError(f"Missing required forecast config field: {field_name}") + if isinstance(value, str) and value.strip() == "": + raise ValueError(f"Missing required forecast config field: {field_name}") + if isinstance(value, (int, float)) and float(value) == 0.0: + raise ValueError(f"Missing required forecast config field: {field_name}") + return value + + def _log_forecast_solar_rate_limit(payload: dict, headers: dict, url: str) -> None: message = payload.get("message") if isinstance(payload, dict) else None ratelimit = message.get("ratelimit") if isinstance(message, dict) else None @@ -32,73 +42,78 @@ def _log_forecast_solar_rate_limit(payload: dict, headers: dict, url: str) -> No ) -def fetch_forecast(config: ForecastSolarConfiguration) -> Dict[str, float]: - latitude = config.latitude if config.latitude is not None else 52.52 - longitude = config.longitude if config.longitude is not None else 13.405 - peak_power_kw = config.peak_power_kw if config.peak_power_kw is not None else 5.0 - azimuth = config.azimuth if config.azimuth is not None else 180.0 - tilt = config.tilt if config.tilt is not None else 35.0 - loss = config.loss if config.loss is not None else 14.0 - horizon = config.horizon if config.horizon is not None else "0" - - string_configs: list[dict[str, Any]] = config.strings if config.strings else [{ - "peak_power_kw": peak_power_kw, - "azimuth": azimuth, - "tilt": tilt, - "loss": loss, - "horizon": horizon, - }] - if len(string_configs) > 6: - string_configs = string_configs[:6] +def _parse_forecast_solar_response(payload: Dict) -> Tuple[Dict[str, float], Dict[str, float]]: + result = payload.get("result") if isinstance(payload, dict) else None + source = result if isinstance(result, dict) else payload + + watts = source.get("watts") if isinstance(source, dict) else None + if watts is None and isinstance(source, dict): + watts = source.get("values") + if watts is None and isinstance(source, dict): + watts = source.get("data") + values: Dict[str, float] = {} - for string_config in string_configs: - string_peak_power_kw = string_config.get("peak_power_kw") if string_config.get("peak_power_kw") is not None else peak_power_kw - string_azimuth = string_config.get("azimuth") if string_config.get("azimuth") is not None else azimuth - string_tilt = string_config.get("tilt") if string_config.get("tilt") is not None else tilt - string_loss = string_config.get("loss") if string_config.get("loss") is not None else loss - string_horizon = string_config.get("horizon") if string_config.get("horizon") is not None else horizon - - url = ( - "https://api.forecast.solar/estimate/watts" - f"?lat={latitude}" - f"&lon={longitude}" - f"&dec={string_peak_power_kw}" - f"&az={string_azimuth}" - f"&tilt={string_tilt}" - f"&loss={string_loss}" - f"&horizon={string_horizon}" - ) - try: - response_obj = req.get_http_session().get(url, timeout=(2, 6)) - except HTTPError as e: - response = e.response - if response is not None and response.status_code == 429: - retry_at = response.headers.get("X-Ratelimit-Retry-At") - remaining = response.headers.get("X-Ratelimit-Remaining") - limit = response.headers.get("X-Ratelimit-Limit") - period = response.headers.get("X-Ratelimit-Period") - log.warning( - "Forecast.Solar rate limit hit for %s: remaining=%s limit=%s period=%s retry_at=%s", - url, - remaining, - limit, - period, - retry_at, - ) - raise - response = response_obj.json() - _log_forecast_solar_rate_limit(response, dict(response_obj.headers), url) - for timestamp, value in response.items(): + if isinstance(watts, dict): + for timestamp, value in watts.items(): if value is None: continue timestamp_key = str(int(datetime.fromisoformat(timestamp).timestamp())) - values[timestamp_key] = values.get(timestamp_key, 0.0) + float(value) - return values + values[timestamp_key] = float(value) + + daily_source = source.get("watt_hours_day") if isinstance(source, dict) else None + daily_kwh: Dict[str, float] = {} + if isinstance(daily_source, dict): + for date_key, value in daily_source.items(): + if value is None: + continue + daily_kwh[str(date_key)] = float(value) / 1000.0 + + return values, daily_kwh + + +def fetch_forecast(config: ForecastSolarConfiguration) -> Tuple[Dict[str, float], Dict[str, float]]: + latitude = _require(config.latitude, "latitude") + longitude = _require(config.longitude, "longitude") + peak_power_kw = _require(config.peak_power_kw, "peak_power_kw") + azimuth = _require(config.azimuth, "azimuth") + tilt = _require(config.tilt, "tilt") + + url = ( + "https://api.forecast.solar/estimate/watthours" + f"/{latitude}" + f"/{longitude}" + f"/{tilt}" + f"/{azimuth}" + f"/{peak_power_kw}" + ) + try: + response_obj = req.get_http_session().get(url, timeout=(2, 6)) + except HTTPError as e: + response = e.response + if response is not None and response.status_code == 429: + retry_at = response.headers.get("X-Ratelimit-Retry-At") + remaining = response.headers.get("X-Ratelimit-Remaining") + limit = response.headers.get("X-Ratelimit-Limit") + period = response.headers.get("X-Ratelimit-Period") + log.warning( + "Forecast.Solar rate limit hit for %s: remaining=%s limit=%s period=%s retry_at=%s", + url, + remaining, + limit, + period, + retry_at, + ) + raise + response = response_obj.json() + _log_forecast_solar_rate_limit(response, dict(response_obj.headers), url) + values, daily_kwh = _parse_forecast_solar_response(response) + return values, daily_kwh def create_forecast(config: ForecastSolar): def updater(): - return ForecastState(forecast_values=fetch_forecast(config.configuration)) + values, daily_kwh = fetch_forecast(config.configuration) + return ForecastState(forecast_values=values, daily_kwh=daily_kwh) return updater diff --git a/packages/modules/forecast/openmeteo/config.py b/packages/modules/forecast/openmeteo/config.py index 67c10a7262..80d92e3fd3 100644 --- a/packages/modules/forecast/openmeteo/config.py +++ b/packages/modules/forecast/openmeteo/config.py @@ -1,17 +1,14 @@ from dataclasses import dataclass, field -from typing import Any, Optional @dataclass class OpenMeteoForecastConfiguration: - latitude: Optional[float] = None - longitude: Optional[float] = None - timezone: Optional[str] = None - forecast_hours: Optional[int] = None - peak_power_kw: Optional[float] = None - system_loss: Optional[float] = None - irradiance_to_power_factor: Optional[float] = None - strings: Optional[list[dict[str, Any]]] = None + latitude: float = 0.0 + longitude: float = 0.0 + timezone: str = "" + peak_power_kw: float = 0.0 + system_loss: float = 0.0 + irradiance_to_power_factor: float = 0.0 @dataclass diff --git a/packages/modules/forecast/openmeteo/forecast.py b/packages/modules/forecast/openmeteo/forecast.py index 29f51cc35c..1b355027fa 100644 --- a/packages/modules/forecast/openmeteo/forecast.py +++ b/packages/modules/forecast/openmeteo/forecast.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Dict +from typing import Dict from zoneinfo import ZoneInfo from modules.common import req @@ -8,56 +8,50 @@ from modules.forecast.openmeteo.config import OpenMeteoForecast, OpenMeteoForecastConfiguration +OPEN_METEO_FORECAST_HOURS = 48 + + +def _require(value, field_name: str): + if value is None: + raise ValueError(f"Missing required forecast config field: {field_name}") + if isinstance(value, str) and value.strip() == "": + raise ValueError(f"Missing required forecast config field: {field_name}") + if isinstance(value, (int, float)) and float(value) == 0.0: + raise ValueError(f"Missing required forecast config field: {field_name}") + return value + def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Dict[str, float]: - latitude = config.latitude if config.latitude is not None else 52.52 - longitude = config.longitude if config.longitude is not None else 13.405 - timezone = config.timezone if config.timezone is not None else "Europe/Berlin" - forecast_hours = config.forecast_hours if config.forecast_hours is not None else 24 - peak_power_kw = config.peak_power_kw if config.peak_power_kw is not None else 5.0 - system_loss = config.system_loss if config.system_loss is not None else 0.15 - irradiance_to_power_factor = ( - config.irradiance_to_power_factor if config.irradiance_to_power_factor is not None else 1.0 - ) + latitude = _require(config.latitude, "latitude") + longitude = _require(config.longitude, "longitude") + timezone = _require(config.timezone, "timezone") + peak_power_kw = _require(config.peak_power_kw, "peak_power_kw") + system_loss = _require(config.system_loss, "system_loss") + irradiance_to_power_factor = _require(config.irradiance_to_power_factor, "irradiance_to_power_factor") - string_configs: list[dict[str, Any]] = config.strings if config.strings else [{"peak_power_kw": peak_power_kw}] - if len(string_configs) > 6: - string_configs = string_configs[:6] values: Dict[str, float] = {} - for string_config in string_configs: - string_peak_power_kw = ( - string_config.get("peak_power_kw") if string_config.get("peak_power_kw") is not None else peak_power_kw - ) - tilt = string_config.get("tilt") - azimuth = string_config.get("azimuth") - hourly_field = "global_tilted_irradiance" if tilt is not None or azimuth is not None else "shortwave_radiation" + url = ( + "https://api.open-meteo.com/v1/forecast" + f"?latitude={latitude}" + f"&longitude={longitude}" + "&hourly=shortwave_radiation" + f"&timezone={timezone}" + ) - url = ( - "https://api.open-meteo.com/v1/forecast" - f"?latitude={latitude}" - f"&longitude={longitude}" - f"&hourly={hourly_field}" - f"&timezone={timezone}" + response = req.get_http_session().get(url, timeout=(2, 6)).json() + hourly = response.get("hourly", {}) + times = hourly.get("time", []) + radiation = hourly.get("shortwave_radiation", []) + for timestamp, value in zip(times[:OPEN_METEO_FORECAST_HOURS], radiation[:OPEN_METEO_FORECAST_HOURS]): + if value is None: + continue + estimated_power_w = max( + 0.0, + float(peak_power_kw) * 1000.0 * (float(value) / 1000.0) * float(irradiance_to_power_factor) + * (1.0 - float(system_loss)) ) - if tilt is not None: - url += f"&tilt={tilt}" - if azimuth is not None: - url += f"&azimuth={azimuth}" - - response = req.get_http_session().get(url, timeout=(2, 6)).json() - hourly = response.get("hourly", {}) - times = hourly.get("time", []) - radiation = hourly.get(hourly_field, []) - for timestamp, value in zip(times[:forecast_hours], radiation[:forecast_hours]): - if value is None: - continue - estimated_power_w = max( - 0.0, - float(string_peak_power_kw) * 1000.0 * (float(value) / 1000.0) * float(irradiance_to_power_factor) - * (1.0 - float(system_loss)) - ) - timestamp_key = str(__parse_timestamp(timestamp, timezone)) - values[timestamp_key] = values.get(timestamp_key, 0.0) + estimated_power_w + timestamp_key = str(__parse_timestamp(timestamp, timezone)) + values[timestamp_key] = estimated_power_w return values diff --git a/packages/modules/forecast/pvnode/config.py b/packages/modules/forecast/pvnode/config.py index 6b6045a933..3cbf6c913e 100644 --- a/packages/modules/forecast/pvnode/config.py +++ b/packages/modules/forecast/pvnode/config.py @@ -1,15 +1,12 @@ from dataclasses import dataclass, field -from typing import Optional @dataclass class PvNodeConfiguration: - latitude: Optional[float] = None - longitude: Optional[float] = None - peak_power_kw: Optional[float] = None - system_loss: Optional[float] = None - api_key: Optional[str] = None - plant_id: Optional[str] = None + api_key: str = "" + plant_id: str = "" + peak_power_kw: float = 0.0 + system_loss: float = 0.0 @dataclass diff --git a/packages/modules/forecast/pvnode/forecast.py b/packages/modules/forecast/pvnode/forecast.py index f14f2d5a9b..4e41c311f7 100644 --- a/packages/modules/forecast/pvnode/forecast.py +++ b/packages/modules/forecast/pvnode/forecast.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Dict +from typing import Any, Dict, Tuple from modules.common import req from modules.common.abstract_device import DeviceDescriptor @@ -8,6 +8,16 @@ from modules.forecast.pvnode.config import PvNode, PvNodeConfiguration +def _require(value, field_name: str): + if value is None: + raise ValueError(f"Missing required forecast config field: {field_name}") + if isinstance(value, str) and value.strip() == "": + raise ValueError(f"Missing required forecast config field: {field_name}") + if isinstance(value, (int, float)) and float(value) == 0.0: + raise ValueError(f"Missing required forecast config field: {field_name}") + return value + + def _normalize_timestamp(value: Any) -> int | None: if value is None: return None @@ -24,18 +34,29 @@ def _normalize_timestamp(value: Any) -> int | None: return None -def fetch_forecast(config: PvNodeConfiguration) -> Dict[str, float]: - latitude = config.latitude if config.latitude is not None else 52.52 - longitude = config.longitude if config.longitude is not None else 13.405 - peak_power_kw = config.peak_power_kw if config.peak_power_kw is not None else 5.0 - system_loss = config.system_loss if config.system_loss is not None else 0.1 - plant_id = config.plant_id if config.plant_id is not None else "" +def fetch_forecast(config: PvNodeConfiguration) -> Tuple[Dict[str, float], Dict[str, float]]: + peak_power_kw = _require(config.peak_power_kw, "peak_power_kw") + system_loss = _require(config.system_loss, "system_loss") + plant_id = _require(config.plant_id, "plant_id") - path = f"/v2/forecast/{plant_id}" if plant_id else "/v2/forecast" + path = f"/v2/forecast/{plant_id}" url = f"https://api.pvnode.com{path}" - headers = {"Authorization": f"Bearer {config.api_key}"} if config.api_key else {} + api_key = _require(config.api_key, "api_key") + headers = {"Authorization": f"Bearer {api_key}"} response = req.get_http_session().get(url, headers=headers, timeout=(2, 6)).json() values: Dict[str, float] = {} + daily_kwh: Dict[str, float] = {} + + daily_payload = response.get("daily") + if isinstance(daily_payload, list): + for entry in daily_payload: + if not isinstance(entry, dict): + continue + date_key = entry.get("date") + energy_kwh = entry.get("pv_energy_kwh") + if date_key is None or energy_kwh is None: + continue + daily_kwh[str(date_key)] = float(energy_kwh) payload = response.get("values") if payload is None: @@ -79,12 +100,13 @@ def fetch_forecast(config: PvNodeConfiguration) -> Dict[str, float]: ) values[str(timestamp)] = estimated_power_w - return values + return values, daily_kwh def create_forecast(config: PvNode): def updater(): - return ForecastState(forecast_values=fetch_forecast(config.configuration)) + values, daily_kwh = fetch_forecast(config.configuration) + return ForecastState(forecast_values=values, daily_kwh=daily_kwh) return updater From 8381734f0c945e31336b3bb2246aa6aee7bb3191 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 15:35:10 +0200 Subject: [PATCH 03/60] Add multi-orientation forecast support and practical defaults --- .../modules/forecast/forecastsolar/config.py | 6 +- .../forecast/forecastsolar/forecast.py | 93 ++++++++++++------- packages/modules/forecast/openmeteo/config.py | 12 ++- .../modules/forecast/openmeteo/forecast.py | 77 +++++++++------ packages/modules/forecast/pvnode/forecast.py | 8 +- 5 files changed, 123 insertions(+), 73 deletions(-) diff --git a/packages/modules/forecast/forecastsolar/config.py b/packages/modules/forecast/forecastsolar/config.py index 99b604aec5..123194866e 100644 --- a/packages/modules/forecast/forecastsolar/config.py +++ b/packages/modules/forecast/forecastsolar/config.py @@ -1,13 +1,15 @@ from dataclasses import dataclass, field +from typing import Any, Optional @dataclass class ForecastSolarConfiguration: latitude: float = 0.0 longitude: float = 0.0 - peak_power_kw: float = 0.0 + peak_power_kw: float = 9.5 azimuth: float = 0.0 - tilt: float = 0.0 + tilt: float = 30.0 + strings: Optional[list[dict[str, Any]]] = None @dataclass diff --git a/packages/modules/forecast/forecastsolar/forecast.py b/packages/modules/forecast/forecastsolar/forecast.py index 0b4055bdc3..5b98a3f6b0 100644 --- a/packages/modules/forecast/forecastsolar/forecast.py +++ b/packages/modules/forecast/forecastsolar/forecast.py @@ -1,6 +1,6 @@ from datetime import datetime import logging -from typing import Dict, Tuple +from typing import Any, Dict, Tuple from requests import HTTPError from modules.common import req @@ -18,8 +18,6 @@ def _require(value, field_name: str): raise ValueError(f"Missing required forecast config field: {field_name}") if isinstance(value, str) and value.strip() == "": raise ValueError(f"Missing required forecast config field: {field_name}") - if isinstance(value, (int, float)) and float(value) == 0.0: - raise ValueError(f"Missing required forecast config field: {field_name}") return value @@ -74,39 +72,62 @@ def _parse_forecast_solar_response(payload: Dict) -> Tuple[Dict[str, float], Dic def fetch_forecast(config: ForecastSolarConfiguration) -> Tuple[Dict[str, float], Dict[str, float]]: latitude = _require(config.latitude, "latitude") longitude = _require(config.longitude, "longitude") - peak_power_kw = _require(config.peak_power_kw, "peak_power_kw") - azimuth = _require(config.azimuth, "azimuth") - tilt = _require(config.tilt, "tilt") - - url = ( - "https://api.forecast.solar/estimate/watthours" - f"/{latitude}" - f"/{longitude}" - f"/{tilt}" - f"/{azimuth}" - f"/{peak_power_kw}" - ) - try: - response_obj = req.get_http_session().get(url, timeout=(2, 6)) - except HTTPError as e: - response = e.response - if response is not None and response.status_code == 429: - retry_at = response.headers.get("X-Ratelimit-Retry-At") - remaining = response.headers.get("X-Ratelimit-Remaining") - limit = response.headers.get("X-Ratelimit-Limit") - period = response.headers.get("X-Ratelimit-Period") - log.warning( - "Forecast.Solar rate limit hit for %s: remaining=%s limit=%s period=%s retry_at=%s", - url, - remaining, - limit, - period, - retry_at, - ) - raise - response = response_obj.json() - _log_forecast_solar_rate_limit(response, dict(response_obj.headers), url) - values, daily_kwh = _parse_forecast_solar_response(response) + if config.strings: + string_configs: list[dict[str, Any]] = config.strings + else: + string_configs = [{ + "peak_power_kw": config.peak_power_kw, + "azimuth": config.azimuth, + "tilt": config.tilt, + }] + if len(string_configs) > 6: + string_configs = string_configs[:6] + + values: Dict[str, float] = {} + daily_kwh: Dict[str, float] = {} + + for string_config in string_configs: + peak_power_kw = float(_require(string_config.get("peak_power_kw"), "strings[].peak_power_kw")) + if peak_power_kw <= 0: + raise ValueError("Missing required forecast config field: strings[].peak_power_kw") + azimuth = _require(string_config.get("azimuth"), "strings[].azimuth") + tilt = _require(string_config.get("tilt"), "strings[].tilt") + + url = ( + "https://api.forecast.solar/estimate/watthours" + f"/{latitude}" + f"/{longitude}" + f"/{tilt}" + f"/{azimuth}" + f"/{peak_power_kw}" + ) + try: + response_obj = req.get_http_session().get(url, timeout=(2, 6)) + except HTTPError as e: + response = e.response + if response is not None and response.status_code == 429: + retry_at = response.headers.get("X-Ratelimit-Retry-At") + remaining = response.headers.get("X-Ratelimit-Remaining") + limit = response.headers.get("X-Ratelimit-Limit") + period = response.headers.get("X-Ratelimit-Period") + log.warning( + "Forecast.Solar rate limit hit for %s: remaining=%s limit=%s period=%s retry_at=%s", + url, + remaining, + limit, + period, + retry_at, + ) + raise + + response = response_obj.json() + _log_forecast_solar_rate_limit(response, dict(response_obj.headers), url) + string_values, string_daily_kwh = _parse_forecast_solar_response(response) + for timestamp, value in string_values.items(): + values[timestamp] = values.get(timestamp, 0.0) + value + for day, value in string_daily_kwh.items(): + daily_kwh[day] = daily_kwh.get(day, 0.0) + value + return values, daily_kwh diff --git a/packages/modules/forecast/openmeteo/config.py b/packages/modules/forecast/openmeteo/config.py index 80d92e3fd3..1cbaee86a7 100644 --- a/packages/modules/forecast/openmeteo/config.py +++ b/packages/modules/forecast/openmeteo/config.py @@ -1,14 +1,18 @@ from dataclasses import dataclass, field +from typing import Any, Optional @dataclass class OpenMeteoForecastConfiguration: latitude: float = 0.0 longitude: float = 0.0 - timezone: str = "" - peak_power_kw: float = 0.0 - system_loss: float = 0.0 - irradiance_to_power_factor: float = 0.0 + timezone: str = "Europe/Berlin" + peak_power_kw: float = 9.5 + azimuth: float = 0.0 + tilt: float = 30.0 + system_loss: float = 0.14 + irradiance_to_power_factor: float = 0.2 + strings: Optional[list[dict[str, Any]]] = None @dataclass diff --git a/packages/modules/forecast/openmeteo/forecast.py b/packages/modules/forecast/openmeteo/forecast.py index 1b355027fa..6129e3c86d 100644 --- a/packages/modules/forecast/openmeteo/forecast.py +++ b/packages/modules/forecast/openmeteo/forecast.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Dict +from typing import Any, Dict from zoneinfo import ZoneInfo from modules.common import req @@ -16,8 +16,6 @@ def _require(value, field_name: str): raise ValueError(f"Missing required forecast config field: {field_name}") if isinstance(value, str) and value.strip() == "": raise ValueError(f"Missing required forecast config field: {field_name}") - if isinstance(value, (int, float)) and float(value) == 0.0: - raise ValueError(f"Missing required forecast config field: {field_name}") return value @@ -25,33 +23,58 @@ def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Dict[str, float]: latitude = _require(config.latitude, "latitude") longitude = _require(config.longitude, "longitude") timezone = _require(config.timezone, "timezone") - peak_power_kw = _require(config.peak_power_kw, "peak_power_kw") - system_loss = _require(config.system_loss, "system_loss") - irradiance_to_power_factor = _require(config.irradiance_to_power_factor, "irradiance_to_power_factor") + peak_power_kw = float(_require(config.peak_power_kw, "peak_power_kw")) + if peak_power_kw <= 0: + raise ValueError("Missing required forecast config field: peak_power_kw") + system_loss = float(_require(config.system_loss, "system_loss")) + irradiance_to_power_factor = float(_require(config.irradiance_to_power_factor, "irradiance_to_power_factor")) + if irradiance_to_power_factor <= 0: + raise ValueError("Missing required forecast config field: irradiance_to_power_factor") + + if config.strings: + string_configs: list[dict[str, Any]] = config.strings + else: + string_configs = [{ + "peak_power_kw": peak_power_kw, + "tilt": config.tilt, + "azimuth": config.azimuth, + }] + if len(string_configs) > 6: + string_configs = string_configs[:6] values: Dict[str, float] = {} - url = ( - "https://api.open-meteo.com/v1/forecast" - f"?latitude={latitude}" - f"&longitude={longitude}" - "&hourly=shortwave_radiation" - f"&timezone={timezone}" - ) - - response = req.get_http_session().get(url, timeout=(2, 6)).json() - hourly = response.get("hourly", {}) - times = hourly.get("time", []) - radiation = hourly.get("shortwave_radiation", []) - for timestamp, value in zip(times[:OPEN_METEO_FORECAST_HOURS], radiation[:OPEN_METEO_FORECAST_HOURS]): - if value is None: - continue - estimated_power_w = max( - 0.0, - float(peak_power_kw) * 1000.0 * (float(value) / 1000.0) * float(irradiance_to_power_factor) - * (1.0 - float(system_loss)) + for string_config in string_configs: + string_peak_power_kw = float(_require(string_config.get("peak_power_kw"), "strings[].peak_power_kw")) + if string_peak_power_kw <= 0: + raise ValueError("Missing required forecast config field: strings[].peak_power_kw") + + string_tilt = float(_require(string_config.get("tilt"), "strings[].tilt")) + string_azimuth = float(_require(string_config.get("azimuth"), "strings[].azimuth")) + + url = ( + "https://api.open-meteo.com/v1/forecast" + f"?latitude={latitude}" + f"&longitude={longitude}" + "&hourly=global_tilted_irradiance" + f"&timezone={timezone}" + f"&tilt={string_tilt}" + f"&azimuth={string_azimuth}" ) - timestamp_key = str(__parse_timestamp(timestamp, timezone)) - values[timestamp_key] = estimated_power_w + + response = req.get_http_session().get(url, timeout=(2, 6)).json() + hourly = response.get("hourly", {}) + times = hourly.get("time", []) + radiation = hourly.get("global_tilted_irradiance", []) + for timestamp, value in zip(times[:OPEN_METEO_FORECAST_HOURS], radiation[:OPEN_METEO_FORECAST_HOURS]): + if value is None: + continue + estimated_power_w = max( + 0.0, + string_peak_power_kw * 1000.0 * (float(value) / 1000.0) * irradiance_to_power_factor + * (1.0 - system_loss) + ) + timestamp_key = str(__parse_timestamp(timestamp, timezone)) + values[timestamp_key] = values.get(timestamp_key, 0.0) + estimated_power_w return values diff --git a/packages/modules/forecast/pvnode/forecast.py b/packages/modules/forecast/pvnode/forecast.py index 4e41c311f7..260cf8eb07 100644 --- a/packages/modules/forecast/pvnode/forecast.py +++ b/packages/modules/forecast/pvnode/forecast.py @@ -13,8 +13,6 @@ def _require(value, field_name: str): raise ValueError(f"Missing required forecast config field: {field_name}") if isinstance(value, str) and value.strip() == "": raise ValueError(f"Missing required forecast config field: {field_name}") - if isinstance(value, (int, float)) and float(value) == 0.0: - raise ValueError(f"Missing required forecast config field: {field_name}") return value @@ -35,8 +33,10 @@ def _normalize_timestamp(value: Any) -> int | None: def fetch_forecast(config: PvNodeConfiguration) -> Tuple[Dict[str, float], Dict[str, float]]: - peak_power_kw = _require(config.peak_power_kw, "peak_power_kw") - system_loss = _require(config.system_loss, "system_loss") + peak_power_kw = float(_require(config.peak_power_kw, "peak_power_kw")) + if peak_power_kw <= 0: + raise ValueError("Missing required forecast config field: peak_power_kw") + system_loss = float(_require(config.system_loss, "system_loss")) plant_id = _require(config.plant_id, "plant_id") path = f"/v2/forecast/{plant_id}" From 37a78717fdcbb9cdb3c8cff74bce7f371807ae51 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 15:38:36 +0200 Subject: [PATCH 04/60] Remove unused PVNode scaling config fields --- packages/modules/forecast/pvnode/config.py | 2 -- packages/modules/forecast/pvnode/forecast.py | 34 ++++++-------------- 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/packages/modules/forecast/pvnode/config.py b/packages/modules/forecast/pvnode/config.py index 3cbf6c913e..47b2cd2121 100644 --- a/packages/modules/forecast/pvnode/config.py +++ b/packages/modules/forecast/pvnode/config.py @@ -5,8 +5,6 @@ class PvNodeConfiguration: api_key: str = "" plant_id: str = "" - peak_power_kw: float = 0.0 - system_loss: float = 0.0 @dataclass diff --git a/packages/modules/forecast/pvnode/forecast.py b/packages/modules/forecast/pvnode/forecast.py index 260cf8eb07..dddadea128 100644 --- a/packages/modules/forecast/pvnode/forecast.py +++ b/packages/modules/forecast/pvnode/forecast.py @@ -33,10 +33,6 @@ def _normalize_timestamp(value: Any) -> int | None: def fetch_forecast(config: PvNodeConfiguration) -> Tuple[Dict[str, float], Dict[str, float]]: - peak_power_kw = float(_require(config.peak_power_kw, "peak_power_kw")) - if peak_power_kw <= 0: - raise ValueError("Missing required forecast config field: peak_power_kw") - system_loss = float(_require(config.system_loss, "system_loss")) plant_id = _require(config.plant_id, "plant_id") path = f"/v2/forecast/{plant_id}" @@ -71,33 +67,23 @@ def fetch_forecast(config: PvNodeConfiguration) -> Tuple[Dict[str, float], Dict[ timestamp = _normalize_timestamp( entry.get("timestamp") or entry.get("period_end") or entry.get("period_start") ) - value = entry.get("pv_power") or entry.get("power_kw") or entry.get("power") or entry.get("value") - if timestamp is None or value is None: + power_w = entry.get("pv_power") + if power_w is None and entry.get("power_kw") is not None: + power_w = float(entry.get("power_kw")) * 1000.0 + if power_w is None: + power_w = entry.get("power") + if power_w is None: + power_w = entry.get("value") + if timestamp is None or power_w is None: continue - numeric_value = float(value) - if entry.get("pv_power") is not None and numeric_value > 1000.0: - estimated_power_w = max(0.0, numeric_value) - elif entry.get("pv_power") is not None: - estimated_power_w = max(0.0, numeric_value) - else: - estimated_power_w = max( - 0.0, - numeric_value * float(peak_power_kw) / 100.0 * (1.0 - float(system_loss)) * 1000.0 - ) + estimated_power_w = max(0.0, float(power_w)) values[str(timestamp)] = estimated_power_w elif isinstance(payload, dict): for key, value in payload.items(): timestamp = _normalize_timestamp(key) if timestamp is None or value is None: continue - numeric_value = float(value) - if isinstance(value, (int, float)) and numeric_value > 1000.0: - estimated_power_w = max(0.0, numeric_value) - else: - estimated_power_w = max( - 0.0, - numeric_value * float(peak_power_kw) / 100.0 * (1.0 - float(system_loss)) * 1000.0 - ) + estimated_power_w = max(0.0, float(value)) values[str(timestamp)] = estimated_power_w return values, daily_kwh From 58814f8b484423dc85d93aec676a6e4af06cd4a4 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 16:07:59 +0200 Subject: [PATCH 05/60] Fix PVNode forecast typing for Python 3.9 --- packages/modules/forecast/pvnode/forecast.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/modules/forecast/pvnode/forecast.py b/packages/modules/forecast/pvnode/forecast.py index dddadea128..9a6f9b61d3 100644 --- a/packages/modules/forecast/pvnode/forecast.py +++ b/packages/modules/forecast/pvnode/forecast.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Dict, Tuple +from typing import Any, Dict, Optional, Tuple from modules.common import req from modules.common.abstract_device import DeviceDescriptor @@ -16,7 +16,7 @@ def _require(value, field_name: str): return value -def _normalize_timestamp(value: Any) -> int | None: +def _normalize_timestamp(value: Any) -> Optional[int]: if value is None: return None if isinstance(value, (int, float)): From e1eec8fbaee359515c1643faa33c1d8e116df777 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 16:16:03 +0200 Subject: [PATCH 06/60] Fix forecast typing for Python 3.9 startup --- packages/modules/common/configurable_forecast.py | 4 ++-- packages/modules/common/store/_forecast.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 454493bd1d..59f54e7e16 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -1,6 +1,6 @@ import logging from datetime import datetime, timedelta -from typing import Generic, TypeVar, Callable +from typing import Callable, Generic, Optional, TypeVar from control import data from control.optional_data import OptionalData @@ -25,7 +25,7 @@ def __init__(self, self.store = store.get_forecast_value_store() self._component_updater = component_initializer(config) self.update_hours = DEFAULT_FORECAST_UPDATE_HOURS - self.next_query_time: int | None = None + self.next_query_time: Optional[int] = None def _publish_forecast_fault(self, level: FaultStateLevel, message: str) -> None: data.data.optional_data.data.forecast.get.fault_state = level.value diff --git a/packages/modules/common/store/_forecast.py b/packages/modules/common/store/_forecast.py index 0127bc8ebf..ce5acef532 100644 --- a/packages/modules/common/store/_forecast.py +++ b/packages/modules/common/store/_forecast.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import Dict +from typing import Dict, Optional from control import data from helpermodules.pub import Pub @@ -13,7 +13,7 @@ log = logging.getLogger(__name__) -def _parse_forecast_timestamp(timestamp: str) -> datetime | None: +def _parse_forecast_timestamp(timestamp: str) -> Optional[datetime]: try: if timestamp.isdigit(): return datetime.fromtimestamp(int(timestamp)) From b7c07f225ef38a5be9440b7a6c2a935a5f52a5a8 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 17:36:17 +0200 Subject: [PATCH 07/60] fix(core): initialize forecast module from provider topic --- packages/helpermodules/subdata.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 3fdeed4de5..f7c492ec95 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -35,6 +35,7 @@ from modules.common.abstract_vehicle import CalculatedSocState, GeneralVehicleConfig from modules.common.component_type import ComponentType from modules.common.configurable_backup_cloud import ConfigurableBackupCloud +from modules.common.configurable_forecast import ConfigurableForecastProvider from modules.common.configurable_tariff import ConfigurableFlexibleTariff, ConfigurableGridFee from modules.common.simcount.simcounter_state import SimCounterState from modules.internal_chargepoint_handler.internal_chargepoint_handler_config import ( @@ -798,6 +799,18 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): self.set_json_payload_class(var.data.electricity_pricing.get, msg) elif re.search("/optional/ep/", msg.topic) is not None: self.set_json_payload_class(var.data.electricity_pricing, msg) + elif re.search("/optional/forecast/provider$", msg.topic) is not None: + config_dict = decode_payload(msg.payload) + if isinstance(config_dict, str): + # Runtime updates may publish only the provider type string. + var.data.forecast.provider = config_dict + elif not isinstance(config_dict, dict) or config_dict.get("type") is None: + var.forecast_module = None + else: + mod = importlib.import_module( + f".forecast.{config_dict['type']}.forecast", "modules") + config = dataclass_from_dict(mod.device_descriptor.configuration_factory, config_dict) + var.forecast_module = ConfigurableForecastProvider(config, mod.create_forecast) elif re.search("/optional/forecast/get/", msg.topic) is not None: self.set_json_payload_class(var.data.forecast.get, msg) elif re.search("/optional/forecast/", msg.topic) is not None: From a66734db9a6eb21028e87dc00f89ee315b77ffc4 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 18:07:49 +0200 Subject: [PATCH 08/60] feat(forecast): add dedicated ramdisk logger --- packages/helpermodules/create_debug.py | 1 + packages/helpermodules/logger.py | 8 ++++++++ packages/modules/common/configurable_forecast.py | 2 +- packages/modules/forecast/forecastsolar/forecast.py | 2 +- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/helpermodules/create_debug.py b/packages/helpermodules/create_debug.py index cb032927a5..6474dba0ad 100644 --- a/packages/helpermodules/create_debug.py +++ b/packages/helpermodules/create_debug.py @@ -450,6 +450,7 @@ def write_to_file(file_handler, func, default: Optional[Any] = None): df, lambda: f'# section: internal chargepoint log #\n{merge_log_files("internal_chargepoint", 1000)}\n') write_to_file(df, lambda: f'# section: mqtt log #\n{merge_log_files("mqtt", 1000)}\n') + write_to_file(df, lambda: f'# section: forecast log #\n{merge_log_files("forecast", 1000)}\n') write_to_file(df, lambda: f'# section: soc log #\n{merge_log_files("soc", 1000)}\n') write_to_file(df, lambda: f'# section: charge log #\n{merge_log_files("chargelog", 1000)}\n') write_to_file(df, lambda: f"# section: broker #\n{broker.get_broker()}") diff --git a/packages/helpermodules/logger.py b/packages/helpermodules/logger.py index 6b38179f36..66bda1b12b 100644 --- a/packages/helpermodules/logger.py +++ b/packages/helpermodules/logger.py @@ -267,6 +267,14 @@ def mb_to_bytes(megabytes: int) -> int: mqtt_file_handler.addFilter(RedactingFilter()) mqtt_log.addHandler(mqtt_file_handler) + # Forecast logger + forecast_log = logging.getLogger("forecast") + forecast_log.propagate = False + forecast_file_handler = RotatingFileHandler(RAMDISK_PATH / 'forecast.log', maxBytes=mb_to_bytes(1), backupCount=1) + forecast_file_handler.setFormatter(logging.Formatter(FORMAT_STR_DETAILED)) + forecast_file_handler.addFilter(RedactingFilter()) + forecast_log.addHandler(forecast_file_handler) + # Steuve control command logger steuve_control_command_log = logging.getLogger("steuve_control_command") steuve_control_command_log.propagate = False diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 59f54e7e16..5d45bf7cca 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -12,7 +12,7 @@ from modules.common.fault_state_level import FaultStateLevel T_FORECAST_CONFIG = TypeVar("T_FORECAST_CONFIG") -log = logging.getLogger(__name__) +log = logging.getLogger("forecast") DEFAULT_FORECAST_UPDATE_HOURS = [5, 8, 11, 14, 17, 20] FORECAST_RETRY_MINUTES = 15 diff --git a/packages/modules/forecast/forecastsolar/forecast.py b/packages/modules/forecast/forecastsolar/forecast.py index 5b98a3f6b0..feabfabdc7 100644 --- a/packages/modules/forecast/forecastsolar/forecast.py +++ b/packages/modules/forecast/forecastsolar/forecast.py @@ -10,7 +10,7 @@ from modules.forecast.forecastsolar.config import ForecastSolar, ForecastSolarConfiguration -log = logging.getLogger(__name__) +log = logging.getLogger("forecast") def _require(value, field_name: str): From 97ab5612646e92f0968bbb21b2e3339fb2f13016 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 18:14:10 +0200 Subject: [PATCH 09/60] feat(forecast): add provider fetch success logs --- packages/modules/forecast/openmeteo/forecast.py | 15 +++++++++++++++ packages/modules/forecast/pvnode/forecast.py | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/modules/forecast/openmeteo/forecast.py b/packages/modules/forecast/openmeteo/forecast.py index 6129e3c86d..6ade4d2f6f 100644 --- a/packages/modules/forecast/openmeteo/forecast.py +++ b/packages/modules/forecast/openmeteo/forecast.py @@ -1,4 +1,5 @@ from datetime import datetime +import logging from typing import Any, Dict from zoneinfo import ZoneInfo @@ -9,6 +10,7 @@ from modules.forecast.openmeteo.config import OpenMeteoForecast, OpenMeteoForecastConfiguration OPEN_METEO_FORECAST_HOURS = 48 +log = logging.getLogger("forecast") def _require(value, field_name: str): @@ -42,6 +44,13 @@ def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Dict[str, float]: if len(string_configs) > 6: string_configs = string_configs[:6] + log.info( + "Open-Meteo forecast fetch started (strings=%s, timezone=%s, horizon_hours=%s)", + len(string_configs), + timezone, + OPEN_METEO_FORECAST_HOURS, + ) + values: Dict[str, float] = {} for string_config in string_configs: string_peak_power_kw = float(_require(string_config.get("peak_power_kw"), "strings[].peak_power_kw")) @@ -65,6 +74,11 @@ def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Dict[str, float]: hourly = response.get("hourly", {}) times = hourly.get("time", []) radiation = hourly.get("global_tilted_irradiance", []) + log.info( + "Open-Meteo response received (times=%s, irradiance_values=%s)", + len(times), + len(radiation), + ) for timestamp, value in zip(times[:OPEN_METEO_FORECAST_HOURS], radiation[:OPEN_METEO_FORECAST_HOURS]): if value is None: continue @@ -75,6 +89,7 @@ def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Dict[str, float]: ) timestamp_key = str(__parse_timestamp(timestamp, timezone)) values[timestamp_key] = values.get(timestamp_key, 0.0) + estimated_power_w + log.info("Open-Meteo forecast fetch finished (merged_values=%s)", len(values)) return values diff --git a/packages/modules/forecast/pvnode/forecast.py b/packages/modules/forecast/pvnode/forecast.py index 9a6f9b61d3..fe33f2bff5 100644 --- a/packages/modules/forecast/pvnode/forecast.py +++ b/packages/modules/forecast/pvnode/forecast.py @@ -1,4 +1,5 @@ from datetime import datetime +import logging from typing import Any, Dict, Optional, Tuple from modules.common import req @@ -8,6 +9,9 @@ from modules.forecast.pvnode.config import PvNode, PvNodeConfiguration +log = logging.getLogger("forecast") + + def _require(value, field_name: str): if value is None: raise ValueError(f"Missing required forecast config field: {field_name}") @@ -32,8 +36,18 @@ def _normalize_timestamp(value: Any) -> Optional[int]: return None +def _mask_identifier(value: str) -> str: + if not value: + return "" + if len(value) <= 4: + return "*" * len(value) + return f"{'*' * (len(value) - 4)}{value[-4:]}" + + def fetch_forecast(config: PvNodeConfiguration) -> Tuple[Dict[str, float], Dict[str, float]]: plant_id = _require(config.plant_id, "plant_id") + masked_plant_id = _mask_identifier(str(plant_id)) + log.info("PVNode forecast fetch started (plant_id=%s)", masked_plant_id) path = f"/v2/forecast/{plant_id}" url = f"https://api.pvnode.com{path}" @@ -53,6 +67,7 @@ def fetch_forecast(config: PvNodeConfiguration) -> Tuple[Dict[str, float], Dict[ if date_key is None or energy_kwh is None: continue daily_kwh[str(date_key)] = float(energy_kwh) + log.info("PVNode daily forecast parsed (days=%s)", len(daily_kwh)) payload = response.get("values") if payload is None: @@ -86,6 +101,8 @@ def fetch_forecast(config: PvNodeConfiguration) -> Tuple[Dict[str, float], Dict[ estimated_power_w = max(0.0, float(value)) values[str(timestamp)] = estimated_power_w + log.info("PVNode forecast fetch finished (values=%s, days=%s)", len(values), len(daily_kwh)) + return values, daily_kwh From 5eb53190fb773869651948822ac529f403e04220 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 18:14:42 +0200 Subject: [PATCH 10/60] fix(forecast): emit openmeteo completion log once --- packages/modules/forecast/openmeteo/forecast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/forecast/openmeteo/forecast.py b/packages/modules/forecast/openmeteo/forecast.py index 6ade4d2f6f..9ecc55ae54 100644 --- a/packages/modules/forecast/openmeteo/forecast.py +++ b/packages/modules/forecast/openmeteo/forecast.py @@ -89,7 +89,7 @@ def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Dict[str, float]: ) timestamp_key = str(__parse_timestamp(timestamp, timezone)) values[timestamp_key] = values.get(timestamp_key, 0.0) + estimated_power_w - log.info("Open-Meteo forecast fetch finished (merged_values=%s)", len(values)) + log.info("Open-Meteo forecast fetch finished (merged_values=%s)", len(values)) return values From 91abc167d8b374d8ce0a7d2553550a3995f87ada Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 18:28:48 +0200 Subject: [PATCH 11/60] fix(forecast): always write info logs to forecast logger --- packages/helpermodules/logger.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/helpermodules/logger.py b/packages/helpermodules/logger.py index 66bda1b12b..a2b60dcfd7 100644 --- a/packages/helpermodules/logger.py +++ b/packages/helpermodules/logger.py @@ -270,6 +270,7 @@ def mb_to_bytes(megabytes: int) -> int: # Forecast logger forecast_log = logging.getLogger("forecast") forecast_log.propagate = False + forecast_log.setLevel(logging.INFO) forecast_file_handler = RotatingFileHandler(RAMDISK_PATH / 'forecast.log', maxBytes=mb_to_bytes(1), backupCount=1) forecast_file_handler.setFormatter(logging.Formatter(FORMAT_STR_DETAILED)) forecast_file_handler.addFilter(RedactingFilter()) From e72a29d48aaf4c1f75fa1bf30605a44415675cce Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 18:34:57 +0200 Subject: [PATCH 12/60] fix(forecast): keep provider config object retained --- packages/control/optional.py | 3 ++- packages/modules/common/configurable_forecast.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index fbd30a3c0e..9c327b6c22 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -1,5 +1,6 @@ """Optionale Module """ +from dataclasses import asdict import logging from math import ceil from threading import Thread @@ -78,7 +79,7 @@ def forecast_module(self, value: TypingOptional[ConfigurableForecast]): self.data.forecast.configured = True self.data.forecast.provider = value.config.type Pub().pub("openWB/set/optional/forecast/configured", True) - Pub().pub("openWB/set/optional/forecast/provider", value.config.type) + Pub().pub("openWB/set/optional/forecast/provider", asdict(value.config)) @grid_fee_module.setter def grid_fee_module(self, value: TypingOptional[ConfigurableGridFee]): diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 5d45bf7cca..1ee9a2839a 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -1,4 +1,5 @@ import logging +from dataclasses import asdict from datetime import datetime, timedelta from typing import Callable, Generic, Optional, TypeVar @@ -72,7 +73,7 @@ def update(self) -> None: data.data.optional_data.data.forecast.configured = True data.data.optional_data.data.forecast.provider = self.config.type Pub().pub("openWB/set/optional/forecast/configured", True) - Pub().pub("openWB/set/optional/forecast/provider", self.config.type) + Pub().pub("openWB/set/optional/forecast/provider", asdict(self.config)) Pub().pub("openWB/set/optional/forecast/current", state.forecast_values) log.info( "Forecast update finished (provider=%s, values=%s, next_query_time=%s)", From b0cc8ee7e5f47b1a9f5a42ce2ae2e7fe30cb969e Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 18:45:52 +0200 Subject: [PATCH 13/60] fix(forecast): persist provider as object across state updates --- packages/control/optional.py | 2 +- packages/control/optional_data.py | 4 ++-- packages/helpermodules/subdata.py | 2 ++ packages/modules/common/configurable_forecast.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index 9c327b6c22..6f3dfea3fa 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -77,7 +77,7 @@ def forecast_module(self, value: TypingOptional[ConfigurableForecast]): Pub().pub("openWB/set/optional/forecast/get/next_query_time", None) else: self.data.forecast.configured = True - self.data.forecast.provider = value.config.type + self.data.forecast.provider = asdict(value.config) Pub().pub("openWB/set/optional/forecast/configured", True) Pub().pub("openWB/set/optional/forecast/provider", asdict(value.config)) diff --git a/packages/control/optional_data.py b/packages/control/optional_data.py index 9152f5d0b0..b8cacad466 100644 --- a/packages/control/optional_data.py +++ b/packages/control/optional_data.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Dict, Optional, Protocol +from typing import Any, Dict, Optional, Protocol from dataclass_utils.factories import empty_dict_factory from helpermodules.constants import NO_ERROR @@ -113,7 +113,7 @@ class ElectricityPricing: @dataclass class Forecast: configured: bool = field(default=False, metadata={"topic": "forecast/configured"}) - provider: Optional[str] = field(default=None, metadata={"topic": "forecast/provider"}) + provider: Optional[Dict[str, Any]] = field(default=None, metadata={"topic": "forecast/provider"}) get: ForecastGet = field(default_factory=forecast_get_factory) diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index f7c492ec95..5983195ce0 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -805,8 +805,10 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): # Runtime updates may publish only the provider type string. var.data.forecast.provider = config_dict elif not isinstance(config_dict, dict) or config_dict.get("type") is None: + var.data.forecast.provider = None var.forecast_module = None else: + var.data.forecast.provider = config_dict mod = importlib.import_module( f".forecast.{config_dict['type']}.forecast", "modules") config = dataclass_from_dict(mod.device_descriptor.configuration_factory, config_dict) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 1ee9a2839a..e45a748d4b 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -71,7 +71,7 @@ def update(self) -> None: self._set_next_query_time_by_schedule() self._publish_forecast_fault(FaultStateLevel.NO_ERROR, NO_ERROR) data.data.optional_data.data.forecast.configured = True - data.data.optional_data.data.forecast.provider = self.config.type + data.data.optional_data.data.forecast.provider = asdict(self.config) Pub().pub("openWB/set/optional/forecast/configured", True) Pub().pub("openWB/set/optional/forecast/provider", asdict(self.config)) Pub().pub("openWB/set/optional/forecast/current", state.forecast_values) From bfb96bb1797f45f74e6c4fe87bd2accfd960268a Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 18:56:28 +0200 Subject: [PATCH 14/60] fix(forecast): prevent provider mqtt reconfiguration loops --- packages/helpermodules/subdata.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 5983195ce0..12c9ea5a96 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -802,17 +802,30 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): elif re.search("/optional/forecast/provider$", msg.topic) is not None: config_dict = decode_payload(msg.payload) if isinstance(config_dict, str): - # Runtime updates may publish only the provider type string. - var.data.forecast.provider = config_dict + # Backward compatibility for legacy string payloads. + # Avoid reconfiguration loops: only mirror the current module config if the type matches. + if (var.forecast_module is not None and + getattr(var.forecast_module.config, "type", None) == config_dict): + var.data.forecast.provider = asdict(var.forecast_module.config) elif not isinstance(config_dict, dict) or config_dict.get("type") is None: - var.data.forecast.provider = None - var.forecast_module = None + # Only clear once. Re-clearing would publish the same null state again and can cause loops. + if var.forecast_module is not None or var.data.forecast.provider is not None: + var.data.forecast.provider = None + var.forecast_module = None else: var.data.forecast.provider = config_dict - mod = importlib.import_module( - f".forecast.{config_dict['type']}.forecast", "modules") - config = dataclass_from_dict(mod.device_descriptor.configuration_factory, config_dict) - var.forecast_module = ConfigurableForecastProvider(config, mod.create_forecast) + current_config = None + if var.forecast_module is not None: + try: + current_config = asdict(var.forecast_module.config) + except Exception: + current_config = None + # Reconfigure only on actual changes. Otherwise we'd bounce the same payload indefinitely. + if current_config != config_dict: + mod = importlib.import_module( + f".forecast.{config_dict['type']}.forecast", "modules") + config = dataclass_from_dict(mod.device_descriptor.configuration_factory, config_dict) + var.forecast_module = ConfigurableForecastProvider(config, mod.create_forecast) elif re.search("/optional/forecast/get/", msg.topic) is not None: self.set_json_payload_class(var.data.forecast.get, msg) elif re.search("/optional/forecast/", msg.topic) is not None: From b37e22b228003790e7ce08aa0b58a7a2be8f8e56 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 18:59:50 +0200 Subject: [PATCH 15/60] fix(forecast): avoid republishing unchanged provider state --- packages/control/optional.py | 17 ++++++++++++----- .../modules/common/configurable_forecast.py | 11 ++++++++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index 6f3dfea3fa..343bd1c9ef 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -61,12 +61,16 @@ def forecast_module(self) -> TypingOptional[ConfigurableForecast]: @forecast_module.setter def forecast_module(self, value: TypingOptional[ConfigurableForecast]): + previous_configured = self.data.forecast.configured + previous_provider = self.data.forecast.provider self._forecast_module = value if value is None: self.data.forecast.configured = False self.data.forecast.provider = None - Pub().pub("openWB/set/optional/forecast/configured", False) - Pub().pub("openWB/set/optional/forecast/provider", None) + if previous_configured is not False: + Pub().pub("openWB/set/optional/forecast/configured", False) + if previous_provider is not None: + Pub().pub("openWB/set/optional/forecast/provider", None) Pub().pub("openWB/set/optional/forecast/get/force_update", False) Pub().pub("openWB/set/optional/forecast/get/values", {}) Pub().pub("openWB/set/optional/forecast/get/today_values", {}) @@ -76,10 +80,13 @@ def forecast_module(self, value: TypingOptional[ConfigurableForecast]): Pub().pub("openWB/set/optional/forecast/get/tomorrow_kwh", 0.0) Pub().pub("openWB/set/optional/forecast/get/next_query_time", None) else: + next_provider = asdict(value.config) self.data.forecast.configured = True - self.data.forecast.provider = asdict(value.config) - Pub().pub("openWB/set/optional/forecast/configured", True) - Pub().pub("openWB/set/optional/forecast/provider", asdict(value.config)) + self.data.forecast.provider = next_provider + if previous_configured is not True: + Pub().pub("openWB/set/optional/forecast/configured", True) + if previous_provider != next_provider: + Pub().pub("openWB/set/optional/forecast/provider", next_provider) @grid_fee_module.setter def grid_fee_module(self, value: TypingOptional[ConfigurableGridFee]): diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index e45a748d4b..64e60d64d9 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -70,10 +70,15 @@ def update(self) -> None: self.store.update() self._set_next_query_time_by_schedule() self._publish_forecast_fault(FaultStateLevel.NO_ERROR, NO_ERROR) + previous_configured = data.data.optional_data.data.forecast.configured + previous_provider = data.data.optional_data.data.forecast.provider + next_provider = asdict(self.config) data.data.optional_data.data.forecast.configured = True - data.data.optional_data.data.forecast.provider = asdict(self.config) - Pub().pub("openWB/set/optional/forecast/configured", True) - Pub().pub("openWB/set/optional/forecast/provider", asdict(self.config)) + data.data.optional_data.data.forecast.provider = next_provider + if previous_configured is not True: + Pub().pub("openWB/set/optional/forecast/configured", True) + if previous_provider != next_provider: + Pub().pub("openWB/set/optional/forecast/provider", next_provider) Pub().pub("openWB/set/optional/forecast/current", state.forecast_values) log.info( "Forecast update finished (provider=%s, values=%s, next_query_time=%s)", From ce058330e885f450adc49e261784f55b6bb267a6 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 19:02:26 +0200 Subject: [PATCH 16/60] fix(forecast): keep legacy provider type after restart --- packages/control/optional_data.py | 4 ++-- packages/helpermodules/subdata.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/control/optional_data.py b/packages/control/optional_data.py index b8cacad466..bed395c842 100644 --- a/packages/control/optional_data.py +++ b/packages/control/optional_data.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Any, Dict, Optional, Protocol +from typing import Any, Dict, Optional, Protocol, Union from dataclass_utils.factories import empty_dict_factory from helpermodules.constants import NO_ERROR @@ -113,7 +113,7 @@ class ElectricityPricing: @dataclass class Forecast: configured: bool = field(default=False, metadata={"topic": "forecast/configured"}) - provider: Optional[Dict[str, Any]] = field(default=None, metadata={"topic": "forecast/provider"}) + provider: Optional[Union[str, Dict[str, Any]]] = field(default=None, metadata={"topic": "forecast/provider"}) get: ForecastGet = field(default_factory=forecast_get_factory) diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 12c9ea5a96..2a43f03e5c 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -803,7 +803,9 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): config_dict = decode_payload(msg.payload) if isinstance(config_dict, str): # Backward compatibility for legacy string payloads. - # Avoid reconfiguration loops: only mirror the current module config if the type matches. + # Keep the provider type visible in state/UI even if only a legacy string is retained. + var.data.forecast.provider = config_dict + # Avoid reconfiguration loops: only mirror full config if an existing module matches this type. if (var.forecast_module is not None and getattr(var.forecast_module.config, "type", None) == config_dict): var.data.forecast.provider = asdict(var.forecast_module.config) From ba4f0f40b8c15b7156504f9770542865bb4cc406 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 21:00:17 +0200 Subject: [PATCH 17/60] fix(security): add forecast ACL access topic defaults --- packages/helpermodules/update_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 214eac0240..35127d18b2 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -549,6 +549,7 @@ class UpdateConfig: "^openWB/system/security/access/ActiveBatControlConfiguration$", "^openWB/system/security/access/HardwareInstallation$", "^openWB/system/security/access/LoadManagementConfiguration$", + "^openWB/system/security/access/ForecastConfiguration$", "^openWB/system/security/access/ChargePointInstallation$", "^openWB/system/security/access/VehicleConfiguration$", "^openWB/system/security/access/IoConfiguration$", @@ -686,6 +687,7 @@ class UpdateConfig: ("openWB/system/security/access/ActiveBatControlConfiguration", True), ("openWB/system/security/access/HardwareInstallation", True), ("openWB/system/security/access/LoadManagementConfiguration", True), + ("openWB/system/security/access/ForecastConfiguration", True), ("openWB/system/security/access/ChargePointInstallation", True), ("openWB/system/security/access/VehicleConfiguration", True), ("openWB/system/security/access/IoConfiguration", True), From 69d269c8f7bb509d84d95b428e3e60ed76910bd5 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 21:21:06 +0200 Subject: [PATCH 18/60] fix(security): add forecast role ACLs and bump dynsec version --- .../public/default-dynamic-security.json | 101 +++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 871ee46abc..393fb9b78f 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -70,7 +70,7 @@ "anonymousGroup": "anonymous", "roles": [ { - "rolename": "openwb-version:8", + "rolename": "openwb-version:9", "textname": "openWB Versionsnummer", "textdescription": "Diese Rolle ist ein Platzhalter für die openWB Versionsnummer und wird automatisch aktualisiert. Sie hat keine direkten Berechtigungen.", "acls": [] @@ -1594,7 +1594,104 @@ }, { "acltype": "publishClientReceive", - "topic": "openWB/chargepoint/+/config", + "topic": "openWB/chargepoint/+/config", + "priority": 0, + "allow": true + } + ] + }, + { + "rolename": "forecast-configuration-access", + "textname": "Zugang zu der PV-Prognose", + "textdescription": "Erlaubt den Zugang zu der PV-Prognose.", + "acls": [ + { + "acltype": "publishClientSend", + "topic": "openWB/set/optional/forecast/provider", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/optional/forecast/get/force_update", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/system/security/access/ForecastConfiguration", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/system/configurable/forecasts", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/optional/forecast/provider", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/optional/forecast/configured", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/optional/forecast/get/values", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/optional/forecast/get/today_values", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/optional/forecast/get/tomorrow_values", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/optional/forecast/get/daily_kwh", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/optional/forecast/get/today_kwh", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/optional/forecast/get/tomorrow_kwh", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/optional/forecast/get/fault_state", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/optional/forecast/get/fault_str", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/optional/forecast/get/next_query_time", "priority": 0, "allow": true } From cbd52d996a13e30888262f01c62ac714199ca7f4 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 21:32:00 +0200 Subject: [PATCH 19/60] fix(security): allow wildcard subscription for settings access --- .../config/mosquitto/public/default-dynamic-security.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 393fb9b78f..2b0f363399 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -70,7 +70,7 @@ "anonymousGroup": "anonymous", "roles": [ { - "rolename": "openwb-version:9", + "rolename": "openwb-version:10", "textname": "openWB Versionsnummer", "textdescription": "Diese Rolle ist ein Platzhalter für die openWB Versionsnummer und wird automatisch aktualisiert. Sie hat keine direkten Berechtigungen.", "acls": [] @@ -471,6 +471,12 @@ "textname": "Zugang zu den Einstellungen", "textdescription": "Erlaubt den Zugang zu den Einstellungen. Für weitere Optionen werden noch zusätzliche Rollen benötigt!", "acls": [ + { + "acltype": "publishClientReceive", + "topic": "openWB/system/security/access/+", + "priority": 0, + "allow": true + }, { "acltype": "publishClientReceive", "topic": "openWB/system/security/access/Settings", From ab6448354edf0de13c53eda159238d1d393def27 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 21:38:54 +0200 Subject: [PATCH 20/60] fix(security): allow settings access wildcard subscribe pattern --- .../mosquitto/public/default-dynamic-security.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 2b0f363399..4720cd4a7a 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -471,6 +471,18 @@ "textname": "Zugang zu den Einstellungen", "textdescription": "Erlaubt den Zugang zu den Einstellungen. Für weitere Optionen werden noch zusätzliche Rollen benötigt!", "acls": [ + { + "acltype": "subscribePattern", + "topic": "openWB/system/security/access/+", + "priority": 0, + "allow": true + }, + { + "acltype": "unsubscribePattern", + "topic": "openWB/system/security/access/+", + "priority": 0, + "allow": true + }, { "acltype": "publishClientReceive", "topic": "openWB/system/security/access/+", From d8c09985c8b7696f3f5188b050bbe5023b829772 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 22:15:02 +0200 Subject: [PATCH 21/60] fix(security): assign settings access to default user group --- data/config/mosquitto/public/default-dynamic-security.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 4720cd4a7a..11f9d4ae6d 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -51,6 +51,8 @@ "rolename": "basic-theme-data" }, { "rolename": "basic-user-data" + }, { + "rolename": "settings-access" }], "clients": [] }, { @@ -70,7 +72,7 @@ "anonymousGroup": "anonymous", "roles": [ { - "rolename": "openwb-version:10", + "rolename": "openwb-version:11", "textname": "openWB Versionsnummer", "textdescription": "Diese Rolle ist ein Platzhalter für die openWB Versionsnummer und wird automatisch aktualisiert. Sie hat keine direkten Berechtigungen.", "acls": [] From 7a4bd1e3a6ebfc594bdc84ea29547ccf0cad150c Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 8 Aug 2026 22:24:45 +0200 Subject: [PATCH 22/60] fix(security): simplify forecast acl defaults --- .../public/default-dynamic-security.json | 64 +------------------ 1 file changed, 2 insertions(+), 62 deletions(-) diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 11f9d4ae6d..ac29dd167f 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -72,7 +72,7 @@ "anonymousGroup": "anonymous", "roles": [ { - "rolename": "openwb-version:11", + "rolename": "openwb-version:9", "textname": "openWB Versionsnummer", "textdescription": "Diese Rolle ist ein Platzhalter für die openWB Versionsnummer und wird automatisch aktualisiert. Sie hat keine direkten Berechtigungen.", "acls": [] @@ -1651,67 +1651,7 @@ }, { "acltype": "publishClientReceive", - "topic": "openWB/optional/forecast/provider", - "priority": 0, - "allow": true - }, - { - "acltype": "publishClientReceive", - "topic": "openWB/optional/forecast/configured", - "priority": 0, - "allow": true - }, - { - "acltype": "publishClientReceive", - "topic": "openWB/optional/forecast/get/values", - "priority": 0, - "allow": true - }, - { - "acltype": "publishClientReceive", - "topic": "openWB/optional/forecast/get/today_values", - "priority": 0, - "allow": true - }, - { - "acltype": "publishClientReceive", - "topic": "openWB/optional/forecast/get/tomorrow_values", - "priority": 0, - "allow": true - }, - { - "acltype": "publishClientReceive", - "topic": "openWB/optional/forecast/get/daily_kwh", - "priority": 0, - "allow": true - }, - { - "acltype": "publishClientReceive", - "topic": "openWB/optional/forecast/get/today_kwh", - "priority": 0, - "allow": true - }, - { - "acltype": "publishClientReceive", - "topic": "openWB/optional/forecast/get/tomorrow_kwh", - "priority": 0, - "allow": true - }, - { - "acltype": "publishClientReceive", - "topic": "openWB/optional/forecast/get/fault_state", - "priority": 0, - "allow": true - }, - { - "acltype": "publishClientReceive", - "topic": "openWB/optional/forecast/get/fault_str", - "priority": 0, - "allow": true - }, - { - "acltype": "publishClientReceive", - "topic": "openWB/optional/forecast/get/next_query_time", + "topic": "openWB/optional/forecast/#", "priority": 0, "allow": true } From 824b95b68020a7bda0c1f820fab19e6e59943ca3 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 00:43:24 +0200 Subject: [PATCH 23/60] fix(forecast): require strings and ignore legacy null provider resets --- packages/helpermodules/subdata.py | 4 ++++ packages/modules/forecast/forecastsolar/forecast.py | 12 ++++-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 2a43f03e5c..3238438302 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -809,6 +809,10 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): if (var.forecast_module is not None and getattr(var.forecast_module.config, "type", None) == config_dict): var.data.forecast.provider = asdict(var.forecast_module.config) + elif config_dict is None: + # Ignore legacy/null payloads to avoid clearing a valid provider right after configuration. + # Explicit resets are still supported via an object payload with type=null. + log.debug("Ignoring null forecast provider payload") elif not isinstance(config_dict, dict) or config_dict.get("type") is None: # Only clear once. Re-clearing would publish the same null state again and can cause loops. if var.forecast_module is not None or var.data.forecast.provider is not None: diff --git a/packages/modules/forecast/forecastsolar/forecast.py b/packages/modules/forecast/forecastsolar/forecast.py index feabfabdc7..e1b700db1d 100644 --- a/packages/modules/forecast/forecastsolar/forecast.py +++ b/packages/modules/forecast/forecastsolar/forecast.py @@ -72,14 +72,10 @@ def _parse_forecast_solar_response(payload: Dict) -> Tuple[Dict[str, float], Dic def fetch_forecast(config: ForecastSolarConfiguration) -> Tuple[Dict[str, float], Dict[str, float]]: latitude = _require(config.latitude, "latitude") longitude = _require(config.longitude, "longitude") - if config.strings: - string_configs: list[dict[str, Any]] = config.strings - else: - string_configs = [{ - "peak_power_kw": config.peak_power_kw, - "azimuth": config.azimuth, - "tilt": config.tilt, - }] + string_configs_raw = _require(config.strings, "strings") + if not isinstance(string_configs_raw, list) or len(string_configs_raw) == 0: + raise ValueError("Missing required forecast config field: strings") + string_configs: list[dict[str, Any]] = string_configs_raw if len(string_configs) > 6: string_configs = string_configs[:6] From b07d579f447d80dfd355374fd97d561084a23d20 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 07:32:24 +0200 Subject: [PATCH 24/60] fix(forecastsolar): use unparameterized List type for strings to fix dataclass_from_dict --- packages/modules/forecast/forecastsolar/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/modules/forecast/forecastsolar/config.py b/packages/modules/forecast/forecastsolar/config.py index 123194866e..ac1574bf1b 100644 --- a/packages/modules/forecast/forecastsolar/config.py +++ b/packages/modules/forecast/forecastsolar/config.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Any, Optional +from typing import List, Optional @dataclass @@ -9,7 +9,7 @@ class ForecastSolarConfiguration: peak_power_kw: float = 9.5 azimuth: float = 0.0 tilt: float = 30.0 - strings: Optional[list[dict[str, Any]]] = None + strings: Optional[List] = None @dataclass From 540a3a2681d54fe362afdd1641a482cf3dab1e1c Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 07:33:07 +0200 Subject: [PATCH 25/60] fix(openmeteo): use unparameterized List type for strings to fix dataclass_from_dict --- packages/modules/forecast/openmeteo/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/modules/forecast/openmeteo/config.py b/packages/modules/forecast/openmeteo/config.py index 1cbaee86a7..41127cc3aa 100644 --- a/packages/modules/forecast/openmeteo/config.py +++ b/packages/modules/forecast/openmeteo/config.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Any, Optional +from typing import List, Optional @dataclass @@ -12,7 +12,7 @@ class OpenMeteoForecastConfiguration: tilt: float = 30.0 system_loss: float = 0.14 irradiance_to_power_factor: float = 0.2 - strings: Optional[list[dict[str, Any]]] = None + strings: Optional[List] = None @dataclass From efd0c0a137744d35f67527060621a2e4ccce750e Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 07:37:33 +0200 Subject: [PATCH 26/60] fix(forecast): raise_for_status for 429 detection and register forecast/current in setdata --- packages/helpermodules/setdata.py | 2 ++ packages/modules/forecast/forecastsolar/forecast.py | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index dcf95a5053..c5848eeb43 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -906,6 +906,8 @@ def process_optional_topic(self, msg: mqtt.MQTTMessage): self._validate_value(msg, str) elif "openWB/set/optional/forecast/get/force_update" in msg.topic: self._validate_value(msg, bool) + elif "openWB/set/optional/forecast/current" in msg.topic: + self._validate_value(msg, "json") elif "module_update_completed" in msg.topic: self._validate_value(msg, bool) elif "openWB/set/optional/ocpp/config" in msg.topic: diff --git a/packages/modules/forecast/forecastsolar/forecast.py b/packages/modules/forecast/forecastsolar/forecast.py index e1b700db1d..5f45955d8c 100644 --- a/packages/modules/forecast/forecastsolar/forecast.py +++ b/packages/modules/forecast/forecastsolar/forecast.py @@ -99,6 +99,7 @@ def fetch_forecast(config: ForecastSolarConfiguration) -> Tuple[Dict[str, float] ) try: response_obj = req.get_http_session().get(url, timeout=(2, 6)) + response_obj.raise_for_status() except HTTPError as e: response = e.response if response is not None and response.status_code == 429: From f59fb667555f91d98c2d9f75d7b603ebe741894e Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 07:48:24 +0200 Subject: [PATCH 27/60] fix(forecast): skip null-type provider reset during startup to survive retained null payloads --- packages/helpermodules/subdata.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 3238438302..5617c36116 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -814,8 +814,10 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): # Explicit resets are still supported via an object payload with type=null. log.debug("Ignoring null forecast provider payload") elif not isinstance(config_dict, dict) or config_dict.get("type") is None: - # Only clear once. Re-clearing would publish the same null state again and can cause loops. - if var.forecast_module is not None or var.data.forecast.provider is not None: + # During startup ignore stale null-type payloads; only act on explicit runtime resets. + if not self.event_subdata_initialized.is_set(): + log.debug("Ignoring null-type forecast provider payload during startup") + elif var.forecast_module is not None or var.data.forecast.provider is not None: var.data.forecast.provider = None var.forecast_module = None else: From 38782bf1c249dab2e604cbfefc889974b94fdf41 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 08:03:50 +0200 Subject: [PATCH 28/60] fix(forecast): remove provider topic republish from setter - align with EP architecture --- packages/control/optional.py | 8 +------- packages/helpermodules/subdata.py | 16 +++++++++------- packages/modules/common/configurable_forecast.py | 9 +-------- 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index 343bd1c9ef..e2e87ba35b 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -62,15 +62,12 @@ def forecast_module(self) -> TypingOptional[ConfigurableForecast]: @forecast_module.setter def forecast_module(self, value: TypingOptional[ConfigurableForecast]): previous_configured = self.data.forecast.configured - previous_provider = self.data.forecast.provider self._forecast_module = value if value is None: self.data.forecast.configured = False self.data.forecast.provider = None if previous_configured is not False: Pub().pub("openWB/set/optional/forecast/configured", False) - if previous_provider is not None: - Pub().pub("openWB/set/optional/forecast/provider", None) Pub().pub("openWB/set/optional/forecast/get/force_update", False) Pub().pub("openWB/set/optional/forecast/get/values", {}) Pub().pub("openWB/set/optional/forecast/get/today_values", {}) @@ -80,13 +77,10 @@ def forecast_module(self, value: TypingOptional[ConfigurableForecast]): Pub().pub("openWB/set/optional/forecast/get/tomorrow_kwh", 0.0) Pub().pub("openWB/set/optional/forecast/get/next_query_time", None) else: - next_provider = asdict(value.config) self.data.forecast.configured = True - self.data.forecast.provider = next_provider + self.data.forecast.provider = asdict(value.config) if previous_configured is not True: Pub().pub("openWB/set/optional/forecast/configured", True) - if previous_provider != next_provider: - Pub().pub("openWB/set/optional/forecast/provider", next_provider) @grid_fee_module.setter def grid_fee_module(self, value: TypingOptional[ConfigurableGridFee]): diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 5617c36116..31356efad7 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -802,13 +802,15 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): elif re.search("/optional/forecast/provider$", msg.topic) is not None: config_dict = decode_payload(msg.payload) if isinstance(config_dict, str): - # Backward compatibility for legacy string payloads. - # Keep the provider type visible in state/UI even if only a legacy string is retained. - var.data.forecast.provider = config_dict - # Avoid reconfiguration loops: only mirror full config if an existing module matches this type. - if (var.forecast_module is not None and - getattr(var.forecast_module.config, "type", None) == config_dict): - var.data.forecast.provider = asdict(var.forecast_module.config) + if not config_dict: + # Empty string written by setdata when clearing the set/ topic - ignore. + pass + else: + # Backward compatibility for legacy string payloads. + var.data.forecast.provider = config_dict + if (var.forecast_module is not None and + getattr(var.forecast_module.config, "type", None) == config_dict): + var.data.forecast.provider = asdict(var.forecast_module.config) elif config_dict is None: # Ignore legacy/null payloads to avoid clearing a valid provider right after configuration. # Explicit resets are still supported via an object payload with type=null. diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 64e60d64d9..81d0882790 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -70,15 +70,8 @@ def update(self) -> None: self.store.update() self._set_next_query_time_by_schedule() self._publish_forecast_fault(FaultStateLevel.NO_ERROR, NO_ERROR) - previous_configured = data.data.optional_data.data.forecast.configured - previous_provider = data.data.optional_data.data.forecast.provider - next_provider = asdict(self.config) data.data.optional_data.data.forecast.configured = True - data.data.optional_data.data.forecast.provider = next_provider - if previous_configured is not True: - Pub().pub("openWB/set/optional/forecast/configured", True) - if previous_provider != next_provider: - Pub().pub("openWB/set/optional/forecast/provider", next_provider) + data.data.optional_data.data.forecast.provider = asdict(self.config) Pub().pub("openWB/set/optional/forecast/current", state.forecast_values) log.info( "Forecast update finished (provider=%s, values=%s, next_query_time=%s)", From 36b0bd530b6f842b8143bc488782b62b6e050317 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 08:35:50 +0200 Subject: [PATCH 29/60] fix(forecast): correct Open-Meteo formula, clean configs, remove dead forecast/current topic --- packages/helpermodules/setdata.py | 2 -- .../modules/common/configurable_forecast.py | 1 - packages/modules/common/store/_forecast.py | 1 - .../modules/forecast/forecastsolar/config.py | 3 -- packages/modules/forecast/openmeteo/config.py | 4 --- .../modules/forecast/openmeteo/forecast.py | 30 ++++++------------- 6 files changed, 9 insertions(+), 32 deletions(-) diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index c5848eeb43..dcf95a5053 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -906,8 +906,6 @@ def process_optional_topic(self, msg: mqtt.MQTTMessage): self._validate_value(msg, str) elif "openWB/set/optional/forecast/get/force_update" in msg.topic: self._validate_value(msg, bool) - elif "openWB/set/optional/forecast/current" in msg.topic: - self._validate_value(msg, "json") elif "module_update_completed" in msg.topic: self._validate_value(msg, bool) elif "openWB/set/optional/ocpp/config" in msg.topic: diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 81d0882790..e1c3398438 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -72,7 +72,6 @@ def update(self) -> None: self._publish_forecast_fault(FaultStateLevel.NO_ERROR, NO_ERROR) data.data.optional_data.data.forecast.configured = True data.data.optional_data.data.forecast.provider = asdict(self.config) - Pub().pub("openWB/set/optional/forecast/current", state.forecast_values) log.info( "Forecast update finished (provider=%s, values=%s, next_query_time=%s)", self.config.type, diff --git a/packages/modules/common/store/_forecast.py b/packages/modules/common/store/_forecast.py index ce5acef532..de8033ebf4 100644 --- a/packages/modules/common/store/_forecast.py +++ b/packages/modules/common/store/_forecast.py @@ -95,7 +95,6 @@ def update(self): pub_to_broker("openWB/set/optional/forecast/get/daily_kwh", daily_kwh) pub_to_broker("openWB/set/optional/forecast/get/today_kwh", today_kwh) pub_to_broker("openWB/set/optional/forecast/get/tomorrow_kwh", tomorrow_kwh) - Pub().pub("openWB/optional/forecast/current", values) log.debug( "published forecast values to MQTT having %s entries, %s day totals, %s today entries, and %s tomorrow entries", len(values), diff --git a/packages/modules/forecast/forecastsolar/config.py b/packages/modules/forecast/forecastsolar/config.py index ac1574bf1b..62331cd8cc 100644 --- a/packages/modules/forecast/forecastsolar/config.py +++ b/packages/modules/forecast/forecastsolar/config.py @@ -6,9 +6,6 @@ class ForecastSolarConfiguration: latitude: float = 0.0 longitude: float = 0.0 - peak_power_kw: float = 9.5 - azimuth: float = 0.0 - tilt: float = 30.0 strings: Optional[List] = None diff --git a/packages/modules/forecast/openmeteo/config.py b/packages/modules/forecast/openmeteo/config.py index 41127cc3aa..363596ee7c 100644 --- a/packages/modules/forecast/openmeteo/config.py +++ b/packages/modules/forecast/openmeteo/config.py @@ -7,11 +7,7 @@ class OpenMeteoForecastConfiguration: latitude: float = 0.0 longitude: float = 0.0 timezone: str = "Europe/Berlin" - peak_power_kw: float = 9.5 - azimuth: float = 0.0 - tilt: float = 30.0 system_loss: float = 0.14 - irradiance_to_power_factor: float = 0.2 strings: Optional[List] = None diff --git a/packages/modules/forecast/openmeteo/forecast.py b/packages/modules/forecast/openmeteo/forecast.py index 9ecc55ae54..bf76becd53 100644 --- a/packages/modules/forecast/openmeteo/forecast.py +++ b/packages/modules/forecast/openmeteo/forecast.py @@ -1,6 +1,6 @@ from datetime import datetime import logging -from typing import Any, Dict +from typing import Dict from zoneinfo import ZoneInfo from modules.common import req @@ -25,24 +25,12 @@ def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Dict[str, float]: latitude = _require(config.latitude, "latitude") longitude = _require(config.longitude, "longitude") timezone = _require(config.timezone, "timezone") - peak_power_kw = float(_require(config.peak_power_kw, "peak_power_kw")) - if peak_power_kw <= 0: - raise ValueError("Missing required forecast config field: peak_power_kw") - system_loss = float(_require(config.system_loss, "system_loss")) - irradiance_to_power_factor = float(_require(config.irradiance_to_power_factor, "irradiance_to_power_factor")) - if irradiance_to_power_factor <= 0: - raise ValueError("Missing required forecast config field: irradiance_to_power_factor") - - if config.strings: - string_configs: list[dict[str, Any]] = config.strings - else: - string_configs = [{ - "peak_power_kw": peak_power_kw, - "tilt": config.tilt, - "azimuth": config.azimuth, - }] - if len(string_configs) > 6: - string_configs = string_configs[:6] + system_loss = float(config.system_loss if config.system_loss is not None else 0.14) + + string_configs_raw = _require(config.strings, "strings") + if not isinstance(string_configs_raw, list) or len(string_configs_raw) == 0: + raise ValueError("Missing required forecast config field: strings") + string_configs = string_configs_raw[:6] log.info( "Open-Meteo forecast fetch started (strings=%s, timezone=%s, horizon_hours=%s)", @@ -82,10 +70,10 @@ def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Dict[str, float]: for timestamp, value in zip(times[:OPEN_METEO_FORECAST_HOURS], radiation[:OPEN_METEO_FORECAST_HOURS]): if value is None: continue + # STC: peak power is rated at 1000 W/m²; scale linearly with irradiance and apply losses estimated_power_w = max( 0.0, - string_peak_power_kw * 1000.0 * (float(value) / 1000.0) * irradiance_to_power_factor - * (1.0 - system_loss) + string_peak_power_kw * 1000.0 * (float(value) / 1000.0) * (1.0 - system_loss) ) timestamp_key = str(__parse_timestamp(timestamp, timezone)) values[timestamp_key] = values.get(timestamp_key, 0.0) + estimated_power_w From d7f4a7740b4922457d814d596485732459f9ecfe Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 08:39:51 +0200 Subject: [PATCH 30/60] refactor(forecast): simplify subdata provider handler to match EP pattern --- packages/helpermodules/subdata.py | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 31356efad7..fa58b461d0 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -801,27 +801,12 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): self.set_json_payload_class(var.data.electricity_pricing, msg) elif re.search("/optional/forecast/provider$", msg.topic) is not None: config_dict = decode_payload(msg.payload) - if isinstance(config_dict, str): - if not config_dict: - # Empty string written by setdata when clearing the set/ topic - ignore. - pass - else: - # Backward compatibility for legacy string payloads. - var.data.forecast.provider = config_dict - if (var.forecast_module is not None and - getattr(var.forecast_module.config, "type", None) == config_dict): - var.data.forecast.provider = asdict(var.forecast_module.config) - elif config_dict is None: - # Ignore legacy/null payloads to avoid clearing a valid provider right after configuration. - # Explicit resets are still supported via an object payload with type=null. - log.debug("Ignoring null forecast provider payload") - elif not isinstance(config_dict, dict) or config_dict.get("type") is None: - # During startup ignore stale null-type payloads; only act on explicit runtime resets. - if not self.event_subdata_initialized.is_set(): - log.debug("Ignoring null-type forecast provider payload during startup") - elif var.forecast_module is not None or var.data.forecast.provider is not None: - var.data.forecast.provider = None - var.forecast_module = None + if not isinstance(config_dict, dict) or config_dict.get("type") is None: + # Ignore during startup to avoid stale retained null payloads clearing a valid provider. + if self.event_subdata_initialized.is_set(): + if var.forecast_module is not None or var.data.forecast.provider is not None: + var.data.forecast.provider = None + var.forecast_module = None else: var.data.forecast.provider = config_dict current_config = None @@ -830,7 +815,6 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): current_config = asdict(var.forecast_module.config) except Exception: current_config = None - # Reconfigure only on actual changes. Otherwise we'd bounce the same payload indefinitely. if current_config != config_dict: mod = importlib.import_module( f".forecast.{config_dict['type']}.forecast", "modules") From efae4b509dde3629a54026789c7f3a2efc054fd3 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 09:17:43 +0200 Subject: [PATCH 31/60] fix(forecastsolar): switch to /estimate/watts endpoint and handle flat-dict response format --- .../forecast/forecastsolar/forecast.py | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/modules/forecast/forecastsolar/forecast.py b/packages/modules/forecast/forecastsolar/forecast.py index 5f45955d8c..9a0a0223d3 100644 --- a/packages/modules/forecast/forecastsolar/forecast.py +++ b/packages/modules/forecast/forecastsolar/forecast.py @@ -44,21 +44,32 @@ def _parse_forecast_solar_response(payload: Dict) -> Tuple[Dict[str, float], Dic result = payload.get("result") if isinstance(payload, dict) else None source = result if isinstance(result, dict) else payload - watts = source.get("watts") if isinstance(source, dict) else None - if watts is None and isinstance(source, dict): - watts = source.get("values") - if watts is None and isinstance(source, dict): - watts = source.get("data") + # Free-tier endpoints return result as a flat {timestamp: value} dict directly. + first_key = next(iter(source), None) if isinstance(source, dict) else None + is_flat_response = ( + first_key is not None + and isinstance(first_key, str) + and len(first_key) >= 10 + and first_key[4] == "-" + ) + + if is_flat_response: + watts_raw = source + daily_source = None + else: + watts_raw = source.get("watts") if isinstance(source, dict) else None + if watts_raw is None and isinstance(source, dict): + watts_raw = source.get("values") or source.get("data") + daily_source = source.get("watt_hours_day") if isinstance(source, dict) else None values: Dict[str, float] = {} - if isinstance(watts, dict): - for timestamp, value in watts.items(): + if isinstance(watts_raw, dict): + for timestamp, value in watts_raw.items(): if value is None: continue timestamp_key = str(int(datetime.fromisoformat(timestamp).timestamp())) values[timestamp_key] = float(value) - daily_source = source.get("watt_hours_day") if isinstance(source, dict) else None daily_kwh: Dict[str, float] = {} if isinstance(daily_source, dict): for date_key, value in daily_source.items(): @@ -90,7 +101,7 @@ def fetch_forecast(config: ForecastSolarConfiguration) -> Tuple[Dict[str, float] tilt = _require(string_config.get("tilt"), "strings[].tilt") url = ( - "https://api.forecast.solar/estimate/watthours" + "https://api.forecast.solar/estimate/watts" f"/{latitude}" f"/{longitude}" f"/{tilt}" From b9344dad489027b4b9d83e4d6a07544a7ea2a3dd Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 10:22:35 +0200 Subject: [PATCH 32/60] feat(forecast): track and publish last_update_time after successful forecast fetch --- packages/control/optional_data.py | 2 ++ packages/helpermodules/setdata.py | 2 ++ packages/modules/common/configurable_forecast.py | 3 +++ 3 files changed, 7 insertions(+) diff --git a/packages/control/optional_data.py b/packages/control/optional_data.py index bed395c842..7a3143fc61 100644 --- a/packages/control/optional_data.py +++ b/packages/control/optional_data.py @@ -18,6 +18,7 @@ class ForecastGet: today_kwh: float = field(default=0.0) tomorrow_kwh: float = field(default=0.0) next_query_time: int = field(default=0) + last_update_time: int = field(default=0) def create_forecast_get_with_topics(topic_prefix: str) -> ForecastGet: @@ -32,6 +33,7 @@ def create_forecast_get_with_topics(topic_prefix: str) -> ForecastGet: forecast_get.__dataclass_fields__['today_kwh'].metadata = {"topic": f"{topic_prefix}/get/today_kwh"} forecast_get.__dataclass_fields__['tomorrow_kwh'].metadata = {"topic": f"{topic_prefix}/get/tomorrow_kwh"} forecast_get.__dataclass_fields__['next_query_time'].metadata = {"topic": f"{topic_prefix}/get/next_query_time"} + forecast_get.__dataclass_fields__['last_update_time'].metadata = {"topic": f"{topic_prefix}/get/last_update_time"} return forecast_get diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index dcf95a5053..cd92b2c0c4 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -900,6 +900,8 @@ def process_optional_topic(self, msg: mqtt.MQTTMessage): self._validate_value(msg, float) elif "openWB/set/optional/forecast/get/next_query_time" in msg.topic: self._validate_value(msg, int) + elif "openWB/set/optional/forecast/get/last_update_time" in msg.topic: + self._validate_value(msg, int) elif "openWB/set/optional/forecast/get/fault_state" in msg.topic: self._validate_value(msg, int, [(0, 2)]) elif "openWB/set/optional/forecast/get/fault_str" in msg.topic: diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index e1c3398438..2e7bd3713e 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -72,6 +72,9 @@ def update(self) -> None: self._publish_forecast_fault(FaultStateLevel.NO_ERROR, NO_ERROR) data.data.optional_data.data.forecast.configured = True data.data.optional_data.data.forecast.provider = asdict(self.config) + now_ts = int(datetime.now().timestamp()) + data.data.optional_data.data.forecast.get.last_update_time = now_ts + Pub().pub("openWB/set/optional/forecast/get/last_update_time", now_ts) log.info( "Forecast update finished (provider=%s, values=%s, next_query_time=%s)", self.config.type, From 5243f252783dea652ff860e7f9f22adc29c93888 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 10:29:52 +0200 Subject: [PATCH 33/60] fix(update_config): add forecast topics to valid_topic to prevent deletion on startup --- packages/helpermodules/update_config.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 35127d18b2..a9090fff98 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -344,6 +344,19 @@ class UpdateConfig: "^openWB/optional/ep/grid_fee/get/fault_str$", "^openWB/optional/ep/grid_fee/get/prices$", "^openWB/optional/ep/grid_fee/provider$", + "^openWB/optional/forecast/configured$", + "^openWB/optional/forecast/provider$", + "^openWB/optional/forecast/get/fault_state$", + "^openWB/optional/forecast/get/fault_str$", + "^openWB/optional/forecast/get/force_update$", + "^openWB/optional/forecast/get/values$", + "^openWB/optional/forecast/get/today_values$", + "^openWB/optional/forecast/get/tomorrow_values$", + "^openWB/optional/forecast/get/daily_kwh$", + "^openWB/optional/forecast/get/today_kwh$", + "^openWB/optional/forecast/get/tomorrow_kwh$", + "^openWB/optional/forecast/get/next_query_time$", + "^openWB/optional/forecast/get/last_update_time$", "^openWB/optional/int_display/active$", "^openWB/optional/int_display/detected$", "^openWB/optional/int_display/on_if_plugged_in$", From 2df6e2efd0855a8414d562caf1904000f288f683 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 10:54:00 +0200 Subject: [PATCH 34/60] fix(forecast): reset last_update_time when provider is removed --- packages/control/optional.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/control/optional.py b/packages/control/optional.py index e2e87ba35b..05db0ba895 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -76,6 +76,7 @@ def forecast_module(self, value: TypingOptional[ConfigurableForecast]): Pub().pub("openWB/set/optional/forecast/get/today_kwh", 0.0) Pub().pub("openWB/set/optional/forecast/get/tomorrow_kwh", 0.0) Pub().pub("openWB/set/optional/forecast/get/next_query_time", None) + Pub().pub("openWB/set/optional/forecast/get/last_update_time", 0) else: self.data.forecast.configured = True self.data.forecast.provider = asdict(value.config) From e44f999df589c45b16fefab4ed44ce3899474f6f Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 11:04:06 +0200 Subject: [PATCH 35/60] fix(security): revert unintended settings-access group assignment --- .../public/default-dynamic-security.json | 71 +++++++++++-------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index ac29dd167f..57b44b42ab 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -39,34 +39,43 @@ } ], "clients": [] - }, { - "groupname": "user", - "textname": "Basisgruppe für normale Benutzer", - "textdescription": "Diese Gruppe stellt grundlegende Rechte bereit, welche für alle Benutzer als Ausgangsbasis sinnvoll sind.", - "roles": [{ - "rolename": "basic-display-data" - }, { - "rolename": "basic-system-data" - }, { - "rolename": "basic-theme-data" - }, { - "rolename": "basic-user-data" - }, { - "rolename": "settings-access" - }], - "clients": [] - }, { - "groupname": "display", - "textname": "Basisgruppe für alle integrierten Displays", - "textdescription": "Diese Gruppe stellt grundlegende Rechte bereit, welche für alle Displays als Ausgangsbasis sinnvoll sind.", - "roles": [{ - "rolename": "basic-display-data" - }, { - "rolename": "basic-system-data" - }, { - "rolename": "basic-user-data" - }], - "clients": [] + }, + { + "groupname": "user", + "textname": "Basisgruppe für normale Benutzer", + "textdescription": "Diese Gruppe stellt grundlegende Rechte bereit, welche für alle Benutzer als Ausgangsbasis sinnvoll sind.", + "roles": [ + { + "rolename": "basic-display-data" + }, + { + "rolename": "basic-system-data" + }, + { + "rolename": "basic-theme-data" + }, + { + "rolename": "basic-user-data" + } + ], + "clients": [] + }, + { + "groupname": "display", + "textname": "Basisgruppe für alle integrierten Displays", + "textdescription": "Diese Gruppe stellt grundlegende Rechte bereit, welche für alle Displays als Ausgangsbasis sinnvoll sind.", + "roles": [ + { + "rolename": "basic-display-data" + }, + { + "rolename": "basic-system-data" + }, + { + "rolename": "basic-user-data" + } + ], + "clients": [] } ], "anonymousGroup": "anonymous", @@ -1614,9 +1623,9 @@ }, { "acltype": "publishClientReceive", - "topic": "openWB/chargepoint/+/config", - "priority": 0, - "allow": true + "topic": "openWB/chargepoint/+/config", + "priority": 0, + "allow": true } ] }, From 2c177c93913481f240581365765414c3cc74bf27 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 11:14:52 +0200 Subject: [PATCH 36/60] fix(forecast): normalize config before comparison to stop runaway update loop; add optional api_key to forecastsolar --- packages/helpermodules/subdata.py | 16 ++++++---------- .../modules/forecast/forecastsolar/config.py | 1 + .../modules/forecast/forecastsolar/forecast.py | 6 +++++- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index fa58b461d0..00733567cd 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -809,16 +809,12 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): var.forecast_module = None else: var.data.forecast.provider = config_dict - current_config = None - if var.forecast_module is not None: - try: - current_config = asdict(var.forecast_module.config) - except Exception: - current_config = None - if current_config != config_dict: - mod = importlib.import_module( - f".forecast.{config_dict['type']}.forecast", "modules") - config = dataclass_from_dict(mod.device_descriptor.configuration_factory, config_dict) + mod = importlib.import_module( + f".forecast.{config_dict['type']}.forecast", "modules") + config = dataclass_from_dict(mod.device_descriptor.configuration_factory, config_dict) + # Compare parsed config (unknown fields stripped) to avoid re-init on every retained update. + current_config = asdict(var.forecast_module.config) if var.forecast_module is not None else None + if current_config != asdict(config): var.forecast_module = ConfigurableForecastProvider(config, mod.create_forecast) elif re.search("/optional/forecast/get/", msg.topic) is not None: self.set_json_payload_class(var.data.forecast.get, msg) diff --git a/packages/modules/forecast/forecastsolar/config.py b/packages/modules/forecast/forecastsolar/config.py index 62331cd8cc..f0c949c039 100644 --- a/packages/modules/forecast/forecastsolar/config.py +++ b/packages/modules/forecast/forecastsolar/config.py @@ -6,6 +6,7 @@ class ForecastSolarConfiguration: latitude: float = 0.0 longitude: float = 0.0 + api_key: Optional[str] = None strings: Optional[List] = None diff --git a/packages/modules/forecast/forecastsolar/forecast.py b/packages/modules/forecast/forecastsolar/forecast.py index 9a0a0223d3..a8cd800a6a 100644 --- a/packages/modules/forecast/forecastsolar/forecast.py +++ b/packages/modules/forecast/forecastsolar/forecast.py @@ -101,7 +101,11 @@ def fetch_forecast(config: ForecastSolarConfiguration) -> Tuple[Dict[str, float] tilt = _require(string_config.get("tilt"), "strings[].tilt") url = ( - "https://api.forecast.solar/estimate/watts" + f"https://api.forecast.solar/{config.api_key}/estimate/watts" + if config.api_key and config.api_key.strip() + else "https://api.forecast.solar/estimate/watts" + ) + url += ( f"/{latitude}" f"/{longitude}" f"/{tilt}" From 8ff2badef3c13d9ec3c3c97157245138d8def0a0 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 11:28:54 +0200 Subject: [PATCH 37/60] fix(security): restore compact formatting for groups --- .../public/default-dynamic-security.json | 63 ++++++++----------- 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 57b44b42ab..2d22863d92 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -39,43 +39,32 @@ } ], "clients": [] - }, - { - "groupname": "user", - "textname": "Basisgruppe für normale Benutzer", - "textdescription": "Diese Gruppe stellt grundlegende Rechte bereit, welche für alle Benutzer als Ausgangsbasis sinnvoll sind.", - "roles": [ - { - "rolename": "basic-display-data" - }, - { - "rolename": "basic-system-data" - }, - { - "rolename": "basic-theme-data" - }, - { - "rolename": "basic-user-data" - } - ], - "clients": [] - }, - { - "groupname": "display", - "textname": "Basisgruppe für alle integrierten Displays", - "textdescription": "Diese Gruppe stellt grundlegende Rechte bereit, welche für alle Displays als Ausgangsbasis sinnvoll sind.", - "roles": [ - { - "rolename": "basic-display-data" - }, - { - "rolename": "basic-system-data" - }, - { - "rolename": "basic-user-data" - } - ], - "clients": [] + }, { + "groupname": "user", + "textname": "Basisgruppe für normale Benutzer", + "textdescription": "Diese Gruppe stellt grundlegende Rechte bereit, welche für alle Benutzer als Ausgangsbasis sinnvoll sind.", + "roles": [{ + "rolename": "basic-display-data" + }, { + "rolename": "basic-system-data" + }, { + "rolename": "basic-theme-data" + }, { + "rolename": "basic-user-data" + }], + "clients": [] + }, { + "groupname": "display", + "textname": "Basisgruppe für alle integrierten Displays", + "textdescription": "Diese Gruppe stellt grundlegende Rechte bereit, welche für alle Displays als Ausgangsbasis sinnvoll sind.", + "roles": [{ + "rolename": "basic-display-data" + }, { + "rolename": "basic-system-data" + }, { + "rolename": "basic-user-data" + }], + "clients": [] } ], "anonymousGroup": "anonymous", From 2b6e026de1add02c52c2f606e689776adc363bac Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 11:41:38 +0200 Subject: [PATCH 38/60] fix(forecast): restore next_query_time from MQTT state on provider init When forecast provider is re-initialized (e.g., config change, broker restart), next_query_time was reset to None, causing _is_update_due() to always return true. This led to forecast updates running every ~10 seconds (control_interval) instead of respecting the scheduled next_query_time. Now load next_query_time from the MQTT state (data.data.optional_data.data.forecast.get.next_query_time) when creating a new provider instance. This preserves scheduling across provider re-initialization while still allowing immediate first update if no prior time exists. --- packages/modules/common/configurable_forecast.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 2e7bd3713e..686899cf9a 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -26,7 +26,10 @@ def __init__(self, self.store = store.get_forecast_value_store() self._component_updater = component_initializer(config) self.update_hours = DEFAULT_FORECAST_UPDATE_HOURS - self.next_query_time: Optional[int] = None + # Initialize from current MQTT state to prevent re-triggering updates on provider re-initialization. + # If next_query_time is 0 or not set, allow immediate first update. + next_query_from_state = data.data.optional_data.data.forecast.get.next_query_time + self.next_query_time: Optional[int] = next_query_from_state if next_query_from_state and next_query_from_state > 0 else None def _publish_forecast_fault(self, level: FaultStateLevel, message: str) -> None: data.data.optional_data.data.forecast.get.fault_state = level.value From e77837afb5354c8a30b99af3653c7bb2b94c4c7a Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 11:45:16 +0200 Subject: [PATCH 39/60] refactor(forecast): use self.get reference pattern like EP modules Store reference to data.data.optional_data.data.forecast.get in self.get and use it directly for next_query_time, fault_state, etc. This makes the forecast scheduling state automatically persistent across provider re-initialization, matching the pattern used in configurable_tariff.py (EP modules). - next_query_time state now lives in data layer, not instance variable - Automatically preserved on provider re-init without manual reload logic - Simpler, more consistent code pattern across optional modules --- .../modules/common/configurable_forecast.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 686899cf9a..ec78032857 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -26,10 +26,8 @@ def __init__(self, self.store = store.get_forecast_value_store() self._component_updater = component_initializer(config) self.update_hours = DEFAULT_FORECAST_UPDATE_HOURS - # Initialize from current MQTT state to prevent re-triggering updates on provider re-initialization. - # If next_query_time is 0 or not set, allow immediate first update. - next_query_from_state = data.data.optional_data.data.forecast.get.next_query_time - self.next_query_time: Optional[int] = next_query_from_state if next_query_from_state and next_query_from_state > 0 else None + # Store reference to forecast.get for persistent state across re-initialization (same pattern as EP modules) + self.get = data.data.optional_data.data.forecast.get def _publish_forecast_fault(self, level: FaultStateLevel, message: str) -> None: data.data.optional_data.data.forecast.get.fault_state = level.value @@ -38,7 +36,7 @@ def _publish_forecast_fault(self, level: FaultStateLevel, message: str) -> None: Pub().pub("openWB/set/optional/forecast/get/fault_str", message) def _is_update_due(self) -> bool: - return self.next_query_time is None or self.next_query_time <= timecheck.create_timestamp() + return self.get.next_query_time is None or self.get.next_query_time == 0 or self.get.next_query_time <= timecheck.create_timestamp() def _is_force_update_requested(self) -> bool: return bool(data.data.optional_data.data.forecast.get.force_update) @@ -54,12 +52,12 @@ def _set_next_query_time_by_schedule(self) -> None: next_hour = min([hour for hour in self.update_hours if hour > current_hour], default=self.update_hours[0]) day_offset = 0 if next_hour > current_hour else 1 next_query_time = now.replace(hour=next_hour, minute=0, second=0, microsecond=0) + timedelta(days=day_offset) - self.next_query_time = int(next_query_time.timestamp()) - Pub().pub("openWB/set/optional/forecast/get/next_query_time", self.next_query_time) + self.get.next_query_time = int(next_query_time.timestamp()) + Pub().pub("openWB/set/optional/forecast/get/next_query_time", self.get.next_query_time) def _set_retry_query_time(self, minutes: int = FORECAST_RETRY_MINUTES) -> None: - self.next_query_time = int((datetime.now() + timedelta(minutes=minutes)).timestamp()) - Pub().pub("openWB/set/optional/forecast/get/next_query_time", self.next_query_time) + self.get.next_query_time = int((datetime.now() + timedelta(minutes=minutes)).timestamp()) + Pub().pub("openWB/set/optional/forecast/get/next_query_time", self.get.next_query_time) def update(self) -> None: force_update = self._is_force_update_requested() @@ -82,7 +80,7 @@ def update(self) -> None: "Forecast update finished (provider=%s, values=%s, next_query_time=%s)", self.config.type, len(state.forecast_values or {}), - self.next_query_time, + self.get.next_query_time, ) except Exception as e: if "429" in str(e): From 9354d655eaa71e933a6f8717f4693122e5f8583b Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 11:49:39 +0200 Subject: [PATCH 40/60] fix(forecast): defer first update on new provider init and handle config validation errors When a forecast provider is newly added (next_query_time = 0), defer the first update attempt by 2 minutes to allow the user to configure required fields and save before any API calls are made. Additionally, treat 'Missing required config field' errors specially: - Don't retry after 15 minutes (like other errors) - Instead wait until next scheduled update time - Display clear warning message about incomplete configuration - Prevents error spam in logs when config is still being filled in This addresses both the premature API calls on provider add and the repetitive retry behavior when configuration is incomplete. --- .../modules/common/configurable_forecast.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index ec78032857..46469f991d 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -28,6 +28,13 @@ def __init__(self, self.update_hours = DEFAULT_FORECAST_UPDATE_HOURS # Store reference to forecast.get for persistent state across re-initialization (same pattern as EP modules) self.get = data.data.optional_data.data.forecast.get + + # If next_query_time is 0 or missing, defer first update by 2 minutes to allow config to be saved + if self.get.next_query_time is None or self.get.next_query_time == 0: + deferred_time = int((datetime.now() + timedelta(minutes=2)).timestamp()) + self.get.next_query_time = deferred_time + Pub().pub("openWB/set/optional/forecast/get/next_query_time", deferred_time) + log.debug(f"Forecast provider {config.type} initialized with deferred update in 2 minutes") def _publish_forecast_fault(self, level: FaultStateLevel, message: str) -> None: data.data.optional_data.data.forecast.get.fault_state = level.value @@ -83,13 +90,22 @@ def update(self) -> None: self.get.next_query_time, ) except Exception as e: - if "429" in str(e): + error_str = str(e) + if "429" in error_str: # Rate limited providers should wait until the next planned schedule slot. self._set_next_query_time_by_schedule() self._publish_forecast_fault( FaultStateLevel.WARNING, "Forecast API rate limit reached (HTTP 429). Waiting for next scheduled update.", ) + elif "Missing required" in error_str or "required forecast config field" in error_str.lower(): + # Configuration is incomplete; don't retry automatically. + # Next attempt will be on next scheduled time or manual trigger. + self._set_next_query_time_by_schedule() + self._publish_forecast_fault( + FaultStateLevel.WARNING, + f"Forecast configuration incomplete: {error_str}. Please configure all required fields.", + ) else: self._set_retry_query_time() self._publish_forecast_fault( From c741f211ff73578d203b8d54ac4ce845cc076054 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 11:51:01 +0200 Subject: [PATCH 41/60] feat(forecast): defer init only when config incomplete, support all providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine deferred first-update logic: only defer if the provider configuration is actually incomplete. This allows immediate updates for fully-configured providers while giving incomplete configs time to be finished. Config completeness checks: - PVNode: plant_id must be set and non-empty - Open-Meteo: must have at least one string (Dachfläche) configured - Forecast.Solar: must have at least one string (Dachfläche) configured This prevents unnecessary delays for users who add a pre-configured provider, while still giving time for new configs to be filled in. --- .../modules/common/configurable_forecast.py | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 46469f991d..ff4dff6618 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -29,12 +29,42 @@ def __init__(self, # Store reference to forecast.get for persistent state across re-initialization (same pattern as EP modules) self.get = data.data.optional_data.data.forecast.get - # If next_query_time is 0 or missing, defer first update by 2 minutes to allow config to be saved + # If next_query_time is 0 or missing, check if config is complete before deferring if self.get.next_query_time is None or self.get.next_query_time == 0: - deferred_time = int((datetime.now() + timedelta(minutes=2)).timestamp()) - self.get.next_query_time = deferred_time - Pub().pub("openWB/set/optional/forecast/get/next_query_time", deferred_time) - log.debug(f"Forecast provider {config.type} initialized with deferred update in 2 minutes") + if self._is_config_complete(): + # Config is complete, allow immediate first update + pass + else: + # Config incomplete, defer first update by 2 minutes to allow configuration to be saved + deferred_time = int((datetime.now() + timedelta(minutes=2)).timestamp()) + self.get.next_query_time = deferred_time + Pub().pub("openWB/set/optional/forecast/get/next_query_time", deferred_time) + log.debug(f"Forecast provider {config.type} initialized with deferred update in 2 minutes (incomplete config)") + + def _is_config_complete(self) -> bool: + """Check if forecast provider configuration has all required fields.""" + try: + provider_type = self.config.type + config = self.config.configuration + + if provider_type == "pvnode": + # PVNode requires plant_id + return hasattr(config, "plant_id") and config.plant_id and len(str(config.plant_id).strip()) > 0 + + elif provider_type in ("openmeteo", "forecastsolar"): + # Open-Meteo and Forecast.Solar require at least one string (Dachfläche) configured + # Also need latitude/longitude, but they have defaults so we mainly check strings + strings = getattr(config, "strings", None) + if not strings: + return False + return isinstance(strings, list) and len(strings) > 0 + + # Unknown provider type; assume config is complete to avoid blocking + return True + except Exception as e: + log.warning(f"Error checking forecast config completeness: {e}") + # On error, assume incomplete to be safe + return False def _publish_forecast_fault(self, level: FaultStateLevel, message: str) -> None: data.data.optional_data.data.forecast.get.fault_state = level.value From d0f2902a1993bf1e793b939c7b373358d103f486 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 11:55:03 +0200 Subject: [PATCH 42/60] refactor(forecast): move config validation to provider modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make config validation generic and scalable: instead of hardcoding provider- specific checks in configurable_forecast.py, each provider module now defines its own is_configuration_complete() function. Benefits: - New providers can be added without modifying base ConfigurableForecast class - Validation logic stays close to the provider implementation - Easy to test per-provider validation rules independently Each provider validates: - PVNode: plant_id must be set and non-empty - Open-Meteo: at least one string (Dachfläche) must be configured - Forecast.Solar: at least one string (Dachfläche) must be configured ConfigurableForecast dynamically imports and calls the validation function, with graceful fallback for providers that don't implement it. --- .../modules/common/configurable_forecast.py | 25 ++++++++++--------- .../forecast/forecastsolar/forecast.py | 6 +++++ .../modules/forecast/openmeteo/forecast.py | 6 +++++ packages/modules/forecast/pvnode/forecast.py | 9 +++++++ 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index ff4dff6618..e722fd3723 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -1,6 +1,7 @@ import logging from dataclasses import asdict from datetime import datetime, timedelta +from importlib import import_module from typing import Callable, Generic, Optional, TypeVar from control import data @@ -42,24 +43,24 @@ def __init__(self, log.debug(f"Forecast provider {config.type} initialized with deferred update in 2 minutes (incomplete config)") def _is_config_complete(self) -> bool: - """Check if forecast provider configuration has all required fields.""" + """Check if forecast provider configuration has all required fields. + + Calls the provider module's is_configuration_complete() function if available. + Falls back to True (assuming complete) if the function is not found. + """ try: provider_type = self.config.type config = self.config.configuration - if provider_type == "pvnode": - # PVNode requires plant_id - return hasattr(config, "plant_id") and config.plant_id and len(str(config.plant_id).strip()) > 0 + # Dynamically import the provider module and call its validation function + module_name = f"modules.forecast.{provider_type}.forecast" + provider_module = import_module(module_name) - elif provider_type in ("openmeteo", "forecastsolar"): - # Open-Meteo and Forecast.Solar require at least one string (Dachfläche) configured - # Also need latitude/longitude, but they have defaults so we mainly check strings - strings = getattr(config, "strings", None) - if not strings: - return False - return isinstance(strings, list) and len(strings) > 0 + # Call the validation function if it exists + if hasattr(provider_module, "is_configuration_complete"): + return provider_module.is_configuration_complete(config) - # Unknown provider type; assume config is complete to avoid blocking + # If validation function doesn't exist, assume config is complete return True except Exception as e: log.warning(f"Error checking forecast config completeness: {e}") diff --git a/packages/modules/forecast/forecastsolar/forecast.py b/packages/modules/forecast/forecastsolar/forecast.py index a8cd800a6a..107aa0ea59 100644 --- a/packages/modules/forecast/forecastsolar/forecast.py +++ b/packages/modules/forecast/forecastsolar/forecast.py @@ -13,6 +13,12 @@ log = logging.getLogger("forecast") +def is_configuration_complete(config: ForecastSolarConfiguration) -> bool: + """Check if Forecast.Solar configuration has all required fields.""" + strings = getattr(config, "strings", None) + return isinstance(strings, list) and len(strings) > 0 + + def _require(value, field_name: str): if value is None: raise ValueError(f"Missing required forecast config field: {field_name}") diff --git a/packages/modules/forecast/openmeteo/forecast.py b/packages/modules/forecast/openmeteo/forecast.py index bf76becd53..7b5f773b7c 100644 --- a/packages/modules/forecast/openmeteo/forecast.py +++ b/packages/modules/forecast/openmeteo/forecast.py @@ -13,6 +13,12 @@ log = logging.getLogger("forecast") +def is_configuration_complete(config: OpenMeteoForecastConfiguration) -> bool: + """Check if Open-Meteo configuration has all required fields.""" + strings = getattr(config, "strings", None) + return isinstance(strings, list) and len(strings) > 0 + + def _require(value, field_name: str): if value is None: raise ValueError(f"Missing required forecast config field: {field_name}") diff --git a/packages/modules/forecast/pvnode/forecast.py b/packages/modules/forecast/pvnode/forecast.py index fe33f2bff5..f097847fb9 100644 --- a/packages/modules/forecast/pvnode/forecast.py +++ b/packages/modules/forecast/pvnode/forecast.py @@ -12,6 +12,15 @@ log = logging.getLogger("forecast") +def is_configuration_complete(config: PvNodeConfiguration) -> bool: + """Check if PVNode configuration has all required fields.""" + return ( + hasattr(config, "plant_id") + and config.plant_id + and len(str(config.plant_id).strip()) > 0 + ) + + def _require(value, field_name: str): if value is None: raise ValueError(f"Missing required forecast config field: {field_name}") From d9e5f676c5e1f6fffdb1f9cd97526ec85279c8bb Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 11:56:45 +0200 Subject: [PATCH 43/60] fix(forecast): block updates when config incomplete, match EP pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of deferring updates by 2 minutes when config is incomplete, now block them entirely by checking config completeness in _is_update_due(). This matches the electricity pricing (EP) module pattern exactly: - Provider selected (config incomplete) → no API call - User saves configuration → next update cycle triggers API call - After successful API call → schedule next update Key change: _is_update_due() returns False immediately if config is incomplete, preventing any API calls before required fields are saved. No artificial delays needed - the config check itself gates the updates. This is the correct solution: don't mask the problem with timeouts, just don't try to update until the configuration is actually complete. --- .../modules/common/configurable_forecast.py | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index e722fd3723..8e536f1092 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -29,18 +29,6 @@ def __init__(self, self.update_hours = DEFAULT_FORECAST_UPDATE_HOURS # Store reference to forecast.get for persistent state across re-initialization (same pattern as EP modules) self.get = data.data.optional_data.data.forecast.get - - # If next_query_time is 0 or missing, check if config is complete before deferring - if self.get.next_query_time is None or self.get.next_query_time == 0: - if self._is_config_complete(): - # Config is complete, allow immediate first update - pass - else: - # Config incomplete, defer first update by 2 minutes to allow configuration to be saved - deferred_time = int((datetime.now() + timedelta(minutes=2)).timestamp()) - self.get.next_query_time = deferred_time - Pub().pub("openWB/set/optional/forecast/get/next_query_time", deferred_time) - log.debug(f"Forecast provider {config.type} initialized with deferred update in 2 minutes (incomplete config)") def _is_config_complete(self) -> bool: """Check if forecast provider configuration has all required fields. @@ -74,6 +62,14 @@ def _publish_forecast_fault(self, level: FaultStateLevel, message: str) -> None: Pub().pub("openWB/set/optional/forecast/get/fault_str", message) def _is_update_due(self) -> bool: + """Check if a forecast update is due. + + Returns False immediately if configuration is incomplete (prevents API calls before config is saved). + Otherwise checks if the scheduled update time has been reached. + """ + if not self._is_config_complete(): + return False + return self.get.next_query_time is None or self.get.next_query_time == 0 or self.get.next_query_time <= timecheck.create_timestamp() def _is_force_update_requested(self) -> bool: From 8a6e0bf4e69c3d8e8e84c020a5dca73e6606ff1d Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 12:15:24 +0200 Subject: [PATCH 44/60] debug(forecast): add debug logs to _is_update_due() --- packages/modules/common/configurable_forecast.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 8e536f1092..b84faba6b3 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -67,10 +67,18 @@ def _is_update_due(self) -> bool: Returns False immediately if configuration is incomplete (prevents API calls before config is saved). Otherwise checks if the scheduled update time has been reached. """ - if not self._is_config_complete(): + is_config_complete = self._is_config_complete() + if not is_config_complete: + log.debug(f"_is_update_due: config incomplete, skipping update") return False - return self.get.next_query_time is None or self.get.next_query_time == 0 or self.get.next_query_time <= timecheck.create_timestamp() + now = timecheck.create_timestamp() + next_time = self.get.next_query_time + is_due = next_time is None or next_time == 0 or next_time <= now + + log.debug(f"_is_update_due: config_complete={is_config_complete}, next_time={next_time}, now={now}, is_due={is_due}") + + return is_due def _is_force_update_requested(self) -> bool: return bool(data.data.optional_data.data.forecast.get.force_update) From 14462e1beccebc00ebdbd8bd43bac28c45cd1bd1 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 12:17:31 +0200 Subject: [PATCH 45/60] debug(forecast): change debug logs to INFO level --- packages/modules/common/configurable_forecast.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index b84faba6b3..ca470c1a50 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -69,14 +69,14 @@ def _is_update_due(self) -> bool: """ is_config_complete = self._is_config_complete() if not is_config_complete: - log.debug(f"_is_update_due: config incomplete, skipping update") + log.info(f"_is_update_due: config incomplete, skipping update") return False now = timecheck.create_timestamp() next_time = self.get.next_query_time is_due = next_time is None or next_time == 0 or next_time <= now - log.debug(f"_is_update_due: config_complete={is_config_complete}, next_time={next_time}, now={now}, is_due={is_due}") + log.info(f"_is_update_due: next_time={next_time}, now={now}, is_due={is_due}") return is_due From 4e4b183344213afc8aa5eeeea185ffac1238ca4b Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 12:19:46 +0200 Subject: [PATCH 46/60] fix(forecast): use property for get state to avoid stale references Problem: self.get was storing a reference to forecast.get at init time. If the forecast.get object was recreated (e.g., provider removed/reset), self.get still pointed to the old instance, so it showed stale values. Solution: Make self.get a @property that always returns the current instance from the data layer. This ensures we always read from the live state, even if the underlying object was recreated. This fixes the issue where next_query_time would be 0 in _is_update_due() even though it was just set to the next scheduled time. --- packages/modules/common/configurable_forecast.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index ca470c1a50..432f9b8d7b 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -27,8 +27,16 @@ def __init__(self, self.store = store.get_forecast_value_store() self._component_updater = component_initializer(config) self.update_hours = DEFAULT_FORECAST_UPDATE_HOURS - # Store reference to forecast.get for persistent state across re-initialization (same pattern as EP modules) - self.get = data.data.optional_data.data.forecast.get + + @property + def get(self): + """Get the current forecast.get state, always from the data layer. + + This is a property instead of a stored reference because the forecast.get + object can be reset/recreated (e.g., when the provider is removed), and we need + to always reference the current instance, not a stale cached reference. + """ + return data.data.optional_data.data.forecast.get def _is_config_complete(self) -> bool: """Check if forecast provider configuration has all required fields. From 9883a19bef67643fcb686228ad2263589b010a9f Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 12:23:16 +0200 Subject: [PATCH 47/60] fix(forecast): match EP pattern - accept get as parameter, use singleton OptionalData Problem: ConfigurableForecastProvider was creating new OptionalData() instances, which meant each forecast_module had a reference to a different forecast.get object. This caused next_query_time to be lost when the provider was re-initialized. Solution: Follow the exact same pattern as EP (ConfigurableTariff): 1. Accept 'get' as a parameter in ConfigurableForecast.__init__ (not a property) 2. Use the singleton data.data.optional_data.data.forecast.get in ConfigurableForecastProvider 3. This ensures all instances reference the same forecast.get object with persistent state This matches EP perfectly and solves the issue where next_query_time was reset to 0. --- .../modules/common/configurable_forecast.py | 32 ++++++------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 432f9b8d7b..2e2b438da2 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -22,21 +22,14 @@ class ConfigurableForecast(Generic[T_FORECAST_CONFIG]): def __init__(self, config: T_FORECAST_CONFIG, - component_initializer: Callable[[T_FORECAST_CONFIG], ForecastState]) -> None: + component_initializer: Callable[[T_FORECAST_CONFIG], ForecastState], + get) -> None: self.config = config self.store = store.get_forecast_value_store() self._component_updater = component_initializer(config) self.update_hours = DEFAULT_FORECAST_UPDATE_HOURS - - @property - def get(self): - """Get the current forecast.get state, always from the data layer. - - This is a property instead of a stored reference because the forecast.get - object can be reset/recreated (e.g., when the provider is removed), and we need - to always reference the current instance, not a stale cached reference. - """ - return data.data.optional_data.data.forecast.get + # Store reference to forecast.get for persistent state across re-initialization (same pattern as EP modules) + self.get = get def _is_config_complete(self) -> bool: """Check if forecast provider configuration has all required fields. @@ -75,18 +68,10 @@ def _is_update_due(self) -> bool: Returns False immediately if configuration is incomplete (prevents API calls before config is saved). Otherwise checks if the scheduled update time has been reached. """ - is_config_complete = self._is_config_complete() - if not is_config_complete: - log.info(f"_is_update_due: config incomplete, skipping update") + if not self._is_config_complete(): return False - now = timecheck.create_timestamp() - next_time = self.get.next_query_time - is_due = next_time is None or next_time == 0 or next_time <= now - - log.info(f"_is_update_due: next_time={next_time}, now={now}, is_due={is_due}") - - return is_due + return self.get.next_query_time is None or self.get.next_query_time == 0 or self.get.next_query_time <= timecheck.create_timestamp() def _is_force_update_requested(self) -> bool: return bool(data.data.optional_data.data.forecast.get.force_update) @@ -164,5 +149,6 @@ class ConfigurableForecastProvider(ConfigurableForecast[T_FORECAST_CONFIG]): def __init__(self, config: T_FORECAST_CONFIG, component_initializer: Callable[[T_FORECAST_CONFIG], ForecastState]) -> None: - super().__init__(config, component_initializer) - self._optional_data = OptionalData() + # Use the singleton OptionalData instance to ensure we get the same forecast.get object + # across all instances and persist state correctly. + super().__init__(config, component_initializer, data.data.optional_data.data.forecast.get) From 6b07d2e301caa18e2a6273016f5e7072bc92652e Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 12:28:04 +0200 Subject: [PATCH 48/60] debug(forecast): add detailed debug log to update() to show state --- packages/modules/common/configurable_forecast.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 2e2b438da2..6c8effb242 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -96,7 +96,9 @@ def _set_retry_query_time(self, minutes: int = FORECAST_RETRY_MINUTES) -> None: def update(self) -> None: force_update = self._is_force_update_requested() - if not force_update and not self._is_update_due(): + is_due = self._is_update_due() + log.info(f"DEBUG: update() called - force_update={force_update}, is_update_due={is_due}, next_query_time={self.get.next_query_time}, now={timecheck.create_timestamp()}") + if not force_update and not is_due: return try: trigger_mode = "manual" if force_update else "scheduled" From 773803c83c6ed9929828a5b43bff818489511db6 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 12:31:29 +0200 Subject: [PATCH 49/60] fix(forecast): preserve next_query_time during provider re-initialization When modules are reloaded (e.g., during git updates), the provider is re-initialized with a new forecast.get object. This would reset next_query_time to 0, causing the scheduling to restart even though a valid scheduled time was already set. Now we preserve the old next_query_time (if > 0) during re-initialization to maintain scheduling continuity across module reloads. --- packages/helpermodules/subdata.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 00733567cd..138a3206a5 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -815,7 +815,13 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): # Compare parsed config (unknown fields stripped) to avoid re-init on every retained update. current_config = asdict(var.forecast_module.config) if var.forecast_module is not None else None if current_config != asdict(config): + # Preserve next_query_time from the previous instance to maintain scheduling continuity + # (important when modules are reloaded during updates) + old_next_query_time = var.forecast_module.get.next_query_time if var.forecast_module is not None else 0 var.forecast_module = ConfigurableForecastProvider(config, mod.create_forecast) + if old_next_query_time > 0: + var.forecast_module.get.next_query_time = old_next_query_time + Pub().pub("openWB/set/optional/forecast/get/next_query_time", old_next_query_time) elif re.search("/optional/forecast/get/", msg.topic) is not None: self.set_json_payload_class(var.data.forecast.get, msg) elif re.search("/optional/forecast/", msg.topic) is not None: From 523f41df9af16baad0f32b5f78ef5661ac72526d Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 12:34:28 +0200 Subject: [PATCH 50/60] fix(forecast): use @property for get to always reference current singleton Instead of storing a stale reference to forecast.get in __init__, use a @property that always returns the current singleton from data.data.optional_data.data.forecast.get. This prevents issues when MQTT updates modify the forecast.get object reference. Now each access to self.get returns the fresh singleton, ensuring next_query_time and other state values are always current. --- packages/modules/common/configurable_forecast.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 6c8effb242..7be5d8e29e 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -22,14 +22,16 @@ class ConfigurableForecast(Generic[T_FORECAST_CONFIG]): def __init__(self, config: T_FORECAST_CONFIG, - component_initializer: Callable[[T_FORECAST_CONFIG], ForecastState], - get) -> None: + component_initializer: Callable[[T_FORECAST_CONFIG], ForecastState]) -> None: self.config = config self.store = store.get_forecast_value_store() self._component_updater = component_initializer(config) self.update_hours = DEFAULT_FORECAST_UPDATE_HOURS - # Store reference to forecast.get for persistent state across re-initialization (same pattern as EP modules) - self.get = get + + @property + def get(self): + """Always return the current singleton forecast.get to avoid stale references after MQTT updates.""" + return data.data.optional_data.data.forecast.get def _is_config_complete(self) -> bool: """Check if forecast provider configuration has all required fields. @@ -151,6 +153,4 @@ class ConfigurableForecastProvider(ConfigurableForecast[T_FORECAST_CONFIG]): def __init__(self, config: T_FORECAST_CONFIG, component_initializer: Callable[[T_FORECAST_CONFIG], ForecastState]) -> None: - # Use the singleton OptionalData instance to ensure we get the same forecast.get object - # across all instances and persist state correctly. - super().__init__(config, component_initializer, data.data.optional_data.data.forecast.get) + super().__init__(config, component_initializer) From b132ae540c392f66cb48f321836a27a291534477 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 12:35:59 +0200 Subject: [PATCH 51/60] fix(forecast): simplify singleton reference with @property pattern Remove the preserve logic and the get parameter - use @property instead. The @property approach ensures self.get ALWAYS returns the current singleton from data.data.optional_data.data.forecast.get, preventing any stale references. This eliminates the 10-second update loop caused by forecast.get object references becoming stale when MQTT updates modify the data layer. --- packages/control/optional.py | 15 ++++++++++++++- packages/helpermodules/subdata.py | 6 ------ packages/modules/common/configurable_forecast.py | 4 +--- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index 05db0ba895..5ee3daa8f5 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -66,8 +66,21 @@ def forecast_module(self, value: TypingOptional[ConfigurableForecast]): if value is None: self.data.forecast.configured = False self.data.forecast.provider = None + self.data.forecast.get.fault_state = 0 + self.data.forecast.get.fault_str = NO_ERROR + self.data.forecast.get.force_update = False + self.data.forecast.get.values = {} + self.data.forecast.get.today_values = {} + self.data.forecast.get.tomorrow_values = {} + self.data.forecast.get.daily_kwh = {} + self.data.forecast.get.today_kwh = 0.0 + self.data.forecast.get.tomorrow_kwh = 0.0 + self.data.forecast.get.next_query_time = 0 + self.data.forecast.get.last_update_time = 0 if previous_configured is not False: Pub().pub("openWB/set/optional/forecast/configured", False) + Pub().pub("openWB/set/optional/forecast/get/fault_state", 0) + Pub().pub("openWB/set/optional/forecast/get/fault_str", NO_ERROR) Pub().pub("openWB/set/optional/forecast/get/force_update", False) Pub().pub("openWB/set/optional/forecast/get/values", {}) Pub().pub("openWB/set/optional/forecast/get/today_values", {}) @@ -75,7 +88,7 @@ def forecast_module(self, value: TypingOptional[ConfigurableForecast]): Pub().pub("openWB/set/optional/forecast/get/daily_kwh", {}) Pub().pub("openWB/set/optional/forecast/get/today_kwh", 0.0) Pub().pub("openWB/set/optional/forecast/get/tomorrow_kwh", 0.0) - Pub().pub("openWB/set/optional/forecast/get/next_query_time", None) + Pub().pub("openWB/set/optional/forecast/get/next_query_time", 0) Pub().pub("openWB/set/optional/forecast/get/last_update_time", 0) else: self.data.forecast.configured = True diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 138a3206a5..00733567cd 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -815,13 +815,7 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): # Compare parsed config (unknown fields stripped) to avoid re-init on every retained update. current_config = asdict(var.forecast_module.config) if var.forecast_module is not None else None if current_config != asdict(config): - # Preserve next_query_time from the previous instance to maintain scheduling continuity - # (important when modules are reloaded during updates) - old_next_query_time = var.forecast_module.get.next_query_time if var.forecast_module is not None else 0 var.forecast_module = ConfigurableForecastProvider(config, mod.create_forecast) - if old_next_query_time > 0: - var.forecast_module.get.next_query_time = old_next_query_time - Pub().pub("openWB/set/optional/forecast/get/next_query_time", old_next_query_time) elif re.search("/optional/forecast/get/", msg.topic) is not None: self.set_json_payload_class(var.data.forecast.get, msg) elif re.search("/optional/forecast/", msg.topic) is not None: diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 7be5d8e29e..da994e8f60 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -98,9 +98,7 @@ def _set_retry_query_time(self, minutes: int = FORECAST_RETRY_MINUTES) -> None: def update(self) -> None: force_update = self._is_force_update_requested() - is_due = self._is_update_due() - log.info(f"DEBUG: update() called - force_update={force_update}, is_update_due={is_due}, next_query_time={self.get.next_query_time}, now={timecheck.create_timestamp()}") - if not force_update and not is_due: + if not force_update and not self._is_update_due(): return try: trigger_mode = "manual" if force_update else "scheduled" From 88020f89ac3a6b9e212e7a648060f03081f7aa05 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 12:38:55 +0200 Subject: [PATCH 52/60] fix(forecast): point @property to correct singleton instance The MQTT handlers update subdata.SubData.optional_data (global class variable), NOT data.data.optional_data (separate instance). The @property was pointing to the wrong object, causing stale state. Now the @property correctly returns: subdata.SubData.optional_data.data.forecast.get This is the ACTUAL singleton that MQTT messages update. --- .../modules/common/configurable_forecast.py | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index da994e8f60..cd4be47bfa 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -4,8 +4,6 @@ from importlib import import_module from typing import Callable, Generic, Optional, TypeVar -from control import data -from control.optional_data import OptionalData from helpermodules import timecheck from helpermodules.constants import NO_ERROR from helpermodules.pub import Pub @@ -30,8 +28,13 @@ def __init__(self, @property def get(self): - """Always return the current singleton forecast.get to avoid stale references after MQTT updates.""" - return data.data.optional_data.data.forecast.get + """Always return the current singleton forecast.get from the global SubData instance. + + SubData.optional_data is the global singleton that MQTT messages update. + This ensures we always have the current state, not a stale reference. + """ + from helpermodules import subdata + return subdata.SubData.optional_data.data.forecast.get def _is_config_complete(self) -> bool: """Check if forecast provider configuration has all required fields. @@ -59,8 +62,8 @@ def _is_config_complete(self) -> bool: return False def _publish_forecast_fault(self, level: FaultStateLevel, message: str) -> None: - data.data.optional_data.data.forecast.get.fault_state = level.value - data.data.optional_data.data.forecast.get.fault_str = message + self.get.fault_state = level.value + self.get.fault_str = message Pub().pub("openWB/set/optional/forecast/get/fault_state", level.value) Pub().pub("openWB/set/optional/forecast/get/fault_str", message) @@ -76,11 +79,11 @@ def _is_update_due(self) -> bool: return self.get.next_query_time is None or self.get.next_query_time == 0 or self.get.next_query_time <= timecheck.create_timestamp() def _is_force_update_requested(self) -> bool: - return bool(data.data.optional_data.data.forecast.get.force_update) + return bool(self.get.force_update) def _clear_force_update_request(self) -> None: - if data.data.optional_data.data.forecast.get.force_update: - data.data.optional_data.data.forecast.get.force_update = False + if self.get.force_update: + self.get.force_update = False Pub().pub("openWB/set/optional/forecast/get/force_update", False) def _set_next_query_time_by_schedule(self) -> None: @@ -96,6 +99,11 @@ def _set_retry_query_time(self, minutes: int = FORECAST_RETRY_MINUTES) -> None: self.get.next_query_time = int((datetime.now() + timedelta(minutes=minutes)).timestamp()) Pub().pub("openWB/set/optional/forecast/get/next_query_time", self.get.next_query_time) + def _get_forecast_data(self): + """Get reference to the global forecast data from SubData.""" + from helpermodules import subdata + return subdata.SubData.optional_data.data.forecast + def update(self) -> None: force_update = self._is_force_update_requested() if not force_update and not self._is_update_due(): @@ -108,10 +116,11 @@ def update(self) -> None: self.store.update() self._set_next_query_time_by_schedule() self._publish_forecast_fault(FaultStateLevel.NO_ERROR, NO_ERROR) - data.data.optional_data.data.forecast.configured = True - data.data.optional_data.data.forecast.provider = asdict(self.config) + forecast_data = self._get_forecast_data() + forecast_data.configured = True + forecast_data.provider = asdict(self.config) now_ts = int(datetime.now().timestamp()) - data.data.optional_data.data.forecast.get.last_update_time = now_ts + self.get.last_update_time = now_ts Pub().pub("openWB/set/optional/forecast/get/last_update_time", now_ts) log.info( "Forecast update finished (provider=%s, values=%s, next_query_time=%s)", From a8c37ae8366af74f5c52d7d056c9b6748d3ff5ac Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 12:56:36 +0200 Subject: [PATCH 53/60] fix(forecast): initialize next_query_time on provider creation When a new forecast provider is created, initialize next_query_time to the next scheduled update time instead of leaving it at 0. This prevents immediate updates before the full configuration has been loaded from MQTT. Fixes the race condition where the first update would run with incomplete config (e.g., only 1 string instead of 4) because the update was triggered immediately when next_query_time=0, before all MQTT config messages had arrived. --- packages/modules/common/configurable_forecast.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index cd4be47bfa..5f8757e90b 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -25,6 +25,11 @@ def __init__(self, self.store = store.get_forecast_value_store() self._component_updater = component_initializer(config) self.update_hours = DEFAULT_FORECAST_UPDATE_HOURS + + # If next_query_time is not set, calculate the next scheduled update time + # This prevents immediate updates when a provider is first created + if self.get.next_query_time is None or self.get.next_query_time == 0: + self._set_next_query_time_by_schedule() @property def get(self): From 6b07c793be5c41c63a0082b484e1ba0d8663a730 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 13:52:13 +0200 Subject: [PATCH 54/60] refactor: consolidate forecast provider implementation - Remove redundant _log_forecast_solar_rate_limit() in Forecast.Solar (duplicate logging) - Add daily_kwh calculation to Open-Meteo provider for consistency across all providers - Standardize return type: all providers now return Tuple[Dict[Dict], Dict[Dict]] - Unify logging pattern: start + end logs with entry counts across all providers - Translate all docstrings and comments to German - Simplify store logic: remove _calculate_daily_kwh() (now done by providers) - Consistent implementation pattern for future providers --- packages/modules/common/store/_forecast.py | 37 +---------- .../forecast/forecastsolar/forecast.py | 25 ++------ .../modules/forecast/openmeteo/forecast.py | 64 +++++++++++++++---- packages/modules/forecast/pvnode/forecast.py | 7 +- 4 files changed, 59 insertions(+), 74 deletions(-) diff --git a/packages/modules/common/store/_forecast.py b/packages/modules/common/store/_forecast.py index de8033ebf4..e06b4d3987 100644 --- a/packages/modules/common/store/_forecast.py +++ b/packages/modules/common/store/_forecast.py @@ -21,40 +21,6 @@ def _parse_forecast_timestamp(timestamp: str) -> Optional[datetime]: except (TypeError, ValueError): return None - -def _calculate_daily_kwh(values: Dict[str, float]) -> Dict[str, float]: - points: list[tuple[datetime, float]] = [] - for timestamp, value in values.items(): - parsed_timestamp = _parse_forecast_timestamp(timestamp) - if parsed_timestamp is None: - continue - points.append((parsed_timestamp, float(value))) - - if not points: - return {} - - points.sort(key=lambda item: item[0]) - deltas = [ - int((points[index + 1][0] - points[index][0]).total_seconds()) - for index in range(len(points) - 1) - if 0 < int((points[index + 1][0] - points[index][0]).total_seconds()) <= 21600 - ] - fallback_step_seconds = min(deltas) if deltas else 3600 - - daily_wh: Dict[str, float] = {} - for index, (timestamp, power_w) in enumerate(points): - if index + 1 < len(points): - step_seconds = int((points[index + 1][0] - timestamp).total_seconds()) - if step_seconds <= 0 or step_seconds > 21600: - step_seconds = fallback_step_seconds - else: - step_seconds = fallback_step_seconds - date_key = timestamp.date().isoformat() - daily_wh[date_key] = daily_wh.get(date_key, 0.0) + max(0.0, power_w) * (step_seconds / 3600.0) - - return {date_key: energy_wh / 1000.0 for date_key, energy_wh in daily_wh.items()} - - def _filter_values_for_date(values: Dict[str, float], target_date) -> Dict[str, float]: day_values: Dict[str, float] = {} for timestamp, value in values.items(): @@ -75,8 +41,7 @@ def set(self, state: ForecastState) -> None: def update(self): values = self.state.forecast_values or {} - provider_daily_kwh = self.state.daily_kwh or {} - daily_kwh = provider_daily_kwh if provider_daily_kwh else _calculate_daily_kwh(values) + daily_kwh = self.state.daily_kwh or {} today_date = datetime.now().date() tomorrow_date = datetime.now().date() + timedelta(days=1) today_values = _filter_values_for_date(values, today_date) diff --git a/packages/modules/forecast/forecastsolar/forecast.py b/packages/modules/forecast/forecastsolar/forecast.py index 107aa0ea59..0a568138a9 100644 --- a/packages/modules/forecast/forecastsolar/forecast.py +++ b/packages/modules/forecast/forecastsolar/forecast.py @@ -14,7 +14,7 @@ def is_configuration_complete(config: ForecastSolarConfiguration) -> bool: - """Check if Forecast.Solar configuration has all required fields.""" + """Prüfe, ob die Forecast.Solar-Konfiguration alle erforderlichen Felder hat.""" strings = getattr(config, "strings", None) return isinstance(strings, list) and len(strings) > 0 @@ -27,30 +27,13 @@ def _require(value, field_name: str): return value -def _log_forecast_solar_rate_limit(payload: dict, headers: dict, url: str) -> None: - message = payload.get("message") if isinstance(payload, dict) else None - ratelimit = message.get("ratelimit") if isinstance(message, dict) else None - if not isinstance(ratelimit, dict): - return - retry_at = ratelimit.get("retry-at") or headers.get("X-Ratelimit-Retry-At") - remaining = ratelimit.get("remaining") or headers.get("X-Ratelimit-Remaining") - limit = ratelimit.get("limit") or headers.get("X-Ratelimit-Limit") - period = ratelimit.get("period") or headers.get("X-Ratelimit-Period") - log.info( - "Forecast.Solar ratelimit info for %s: remaining=%s limit=%s period=%s retry_at=%s", - url, - remaining, - limit, - period, - retry_at, - ) def _parse_forecast_solar_response(payload: Dict) -> Tuple[Dict[str, float], Dict[str, float]]: result = payload.get("result") if isinstance(payload, dict) else None source = result if isinstance(result, dict) else payload - # Free-tier endpoints return result as a flat {timestamp: value} dict directly. + # Kostenlose API-Endpunkte geben das Ergebnis direkt als flaches {Zeitstempel: Wert} Dict zurück. first_key = next(iter(source), None) if isinstance(source, dict) else None is_flat_response = ( first_key is not None @@ -96,6 +79,8 @@ def fetch_forecast(config: ForecastSolarConfiguration) -> Tuple[Dict[str, float] if len(string_configs) > 6: string_configs = string_configs[:6] + log.info("Forecast.Solar-Abruf gestartet (Strings=%s)", len(string_configs)) + values: Dict[str, float] = {} daily_kwh: Dict[str, float] = {} @@ -139,13 +124,13 @@ def fetch_forecast(config: ForecastSolarConfiguration) -> Tuple[Dict[str, float] raise response = response_obj.json() - _log_forecast_solar_rate_limit(response, dict(response_obj.headers), url) string_values, string_daily_kwh = _parse_forecast_solar_response(response) for timestamp, value in string_values.items(): values[timestamp] = values.get(timestamp, 0.0) + value for day, value in string_daily_kwh.items(): daily_kwh[day] = daily_kwh.get(day, 0.0) + value + log.info("Forecast.Solar-Abruf beendet (Werte=%s, Tage=%s)", len(values), len(daily_kwh)) return values, daily_kwh diff --git a/packages/modules/forecast/openmeteo/forecast.py b/packages/modules/forecast/openmeteo/forecast.py index 7b5f773b7c..39b3610241 100644 --- a/packages/modules/forecast/openmeteo/forecast.py +++ b/packages/modules/forecast/openmeteo/forecast.py @@ -1,6 +1,6 @@ from datetime import datetime import logging -from typing import Dict +from typing import Dict, Tuple from zoneinfo import ZoneInfo from modules.common import req @@ -14,7 +14,7 @@ def is_configuration_complete(config: OpenMeteoForecastConfiguration) -> bool: - """Check if Open-Meteo configuration has all required fields.""" + """Prüfe, ob die Open-Meteo-Konfiguration alle erforderlichen Felder hat.""" strings = getattr(config, "strings", None) return isinstance(strings, list) and len(strings) > 0 @@ -27,7 +27,7 @@ def _require(value, field_name: str): return value -def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Dict[str, float]: +def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Tuple[Dict[str, float], Dict[str, float]]: latitude = _require(config.latitude, "latitude") longitude = _require(config.longitude, "longitude") timezone = _require(config.timezone, "timezone") @@ -39,10 +39,9 @@ def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Dict[str, float]: string_configs = string_configs_raw[:6] log.info( - "Open-Meteo forecast fetch started (strings=%s, timezone=%s, horizon_hours=%s)", + "Open-Meteo-Abruf gestartet (Strings=%s, Zeitzone=%s)", len(string_configs), timezone, - OPEN_METEO_FORECAST_HOURS, ) values: Dict[str, float] = {} @@ -68,23 +67,59 @@ def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Dict[str, float]: hourly = response.get("hourly", {}) times = hourly.get("time", []) radiation = hourly.get("global_tilted_irradiance", []) - log.info( - "Open-Meteo response received (times=%s, irradiance_values=%s)", - len(times), - len(radiation), - ) for timestamp, value in zip(times[:OPEN_METEO_FORECAST_HOURS], radiation[:OPEN_METEO_FORECAST_HOURS]): if value is None: continue - # STC: peak power is rated at 1000 W/m²; scale linearly with irradiance and apply losses + # STC: Nennleistung ist bei 1000 W/m² definiert; skaliere linear mit Einstrahlung und wende Verluste an estimated_power_w = max( 0.0, string_peak_power_kw * 1000.0 * (float(value) / 1000.0) * (1.0 - system_loss) ) timestamp_key = str(__parse_timestamp(timestamp, timezone)) values[timestamp_key] = values.get(timestamp_key, 0.0) + estimated_power_w - log.info("Open-Meteo forecast fetch finished (merged_values=%s)", len(values)) - return values + + daily_kwh = _calculate_daily_kwh(values) + log.info("Open-Meteo-Abruf beendet (Werte=%s, Tage=%s)", len(values), len(daily_kwh)) + return values, daily_kwh + + +def _calculate_daily_kwh(values: Dict[str, float]) -> Dict[str, float]: + """Berechne tägliche Energiewerte aus stündlichen Leistungswerten.""" + from datetime import timedelta + points: list[tuple[datetime, float]] = [] + for timestamp, value in values.items(): + try: + if timestamp.isdigit(): + parsed_ts = datetime.fromtimestamp(int(timestamp)) + else: + parsed_ts = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + points.append((parsed_ts, float(value))) + except (TypeError, ValueError): + continue + + if not points: + return {} + + points.sort(key=lambda item: item[0]) + deltas = [ + int((points[index + 1][0] - points[index][0]).total_seconds()) + for index in range(len(points) - 1) + if 0 < int((points[index + 1][0] - points[index][0]).total_seconds()) <= 21600 + ] + fallback_step_seconds = min(deltas) if deltas else 3600 + + daily_wh: Dict[str, float] = {} + for index, (timestamp, power_w) in enumerate(points): + if index + 1 < len(points): + step_seconds = int((points[index + 1][0] - timestamp).total_seconds()) + if step_seconds <= 0 or step_seconds > 21600: + step_seconds = fallback_step_seconds + else: + step_seconds = fallback_step_seconds + date_key = timestamp.date().isoformat() + daily_wh[date_key] = daily_wh.get(date_key, 0.0) + max(0.0, power_w) * (step_seconds / 3600.0) + + return {date_key: energy_wh / 1000.0 for date_key, energy_wh in daily_wh.items()} def __parse_timestamp(value: str, timezone_name: str) -> int: @@ -96,7 +131,8 @@ def __parse_timestamp(value: str, timezone_name: str) -> int: def create_forecast(config: OpenMeteoForecast): def updater(): - return ForecastState(forecast_values=fetch_forecast(config.configuration)) + values, daily_kwh = fetch_forecast(config.configuration) + return ForecastState(forecast_values=values, daily_kwh=daily_kwh) return updater diff --git a/packages/modules/forecast/pvnode/forecast.py b/packages/modules/forecast/pvnode/forecast.py index f097847fb9..a6b1346df8 100644 --- a/packages/modules/forecast/pvnode/forecast.py +++ b/packages/modules/forecast/pvnode/forecast.py @@ -13,7 +13,7 @@ def is_configuration_complete(config: PvNodeConfiguration) -> bool: - """Check if PVNode configuration has all required fields.""" + """Prüfe, ob die PVNode-Konfiguration alle erforderlichen Felder hat.""" return ( hasattr(config, "plant_id") and config.plant_id @@ -56,7 +56,7 @@ def _mask_identifier(value: str) -> str: def fetch_forecast(config: PvNodeConfiguration) -> Tuple[Dict[str, float], Dict[str, float]]: plant_id = _require(config.plant_id, "plant_id") masked_plant_id = _mask_identifier(str(plant_id)) - log.info("PVNode forecast fetch started (plant_id=%s)", masked_plant_id) + log.info("PVNode-Abruf gestartet (plant_id=%s)", masked_plant_id) path = f"/v2/forecast/{plant_id}" url = f"https://api.pvnode.com{path}" @@ -76,7 +76,6 @@ def fetch_forecast(config: PvNodeConfiguration) -> Tuple[Dict[str, float], Dict[ if date_key is None or energy_kwh is None: continue daily_kwh[str(date_key)] = float(energy_kwh) - log.info("PVNode daily forecast parsed (days=%s)", len(daily_kwh)) payload = response.get("values") if payload is None: @@ -110,7 +109,7 @@ def fetch_forecast(config: PvNodeConfiguration) -> Tuple[Dict[str, float], Dict[ estimated_power_w = max(0.0, float(value)) values[str(timestamp)] = estimated_power_w - log.info("PVNode forecast fetch finished (values=%s, days=%s)", len(values), len(daily_kwh)) + log.info("PVNode-Abruf beendet (Werte=%s, Tage=%s)", len(values), len(daily_kwh)) return values, daily_kwh From ea284b990a6402a2b5cecba2b99b810a0e20ab84 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 13:56:09 +0200 Subject: [PATCH 55/60] chore: translate remaining English docstrings and comments to German - Translate all docstrings in configurable_forecast.py to German - Translate all log messages from English to German - Translate internal comments explaining logic to German - Translate log.debug message in store/_forecast.py to German - Ensure consistency across all forecast modules --- .../modules/common/configurable_forecast.py | 52 +++++++++---------- packages/modules/common/store/_forecast.py | 2 +- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 5f8757e90b..6a42f50c04 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -26,43 +26,43 @@ def __init__(self, self._component_updater = component_initializer(config) self.update_hours = DEFAULT_FORECAST_UPDATE_HOURS - # If next_query_time is not set, calculate the next scheduled update time - # This prevents immediate updates when a provider is first created + # Falls next_query_time nicht gesetzt ist, berechne die nächste geplante Aktualisierungszeit + # Dies verhindert sofortige Aktualisierungen, wenn ein Anbieter erstmals erstellt wird if self.get.next_query_time is None or self.get.next_query_time == 0: self._set_next_query_time_by_schedule() @property def get(self): - """Always return the current singleton forecast.get from the global SubData instance. + """Gibt immer die aktuelle Singleton-Prognose.get aus der globalen SubData-Instanz zurück. - SubData.optional_data is the global singleton that MQTT messages update. - This ensures we always have the current state, not a stale reference. + SubData.optional_data ist die globale Singleton, die MQTT-Nachrichten aktualisieren. + Dies stellt sicher, dass wir immer den aktuellen Status haben, keine veraltete Referenz. """ from helpermodules import subdata return subdata.SubData.optional_data.data.forecast.get def _is_config_complete(self) -> bool: - """Check if forecast provider configuration has all required fields. + """Prüfe, ob die Prognose-Anbieter-Konfiguration alle erforderlichen Felder hat. - Calls the provider module's is_configuration_complete() function if available. - Falls back to True (assuming complete) if the function is not found. + Ruft die is_configuration_complete()-Funktion des Anbietermoduls auf, falls vorhanden. + Fällt auf True zurück (nimmt Vollständigkeit an), wenn die Funktion nicht gefunden wird. """ try: provider_type = self.config.type config = self.config.configuration - # Dynamically import the provider module and call its validation function + # Importiere das Anbietermodul dynamisch und rufe seine Validierungsfunktion auf module_name = f"modules.forecast.{provider_type}.forecast" provider_module = import_module(module_name) - # Call the validation function if it exists + # Rufe die Validierungsfunktion auf, falls vorhanden if hasattr(provider_module, "is_configuration_complete"): return provider_module.is_configuration_complete(config) - # If validation function doesn't exist, assume config is complete + # Wenn Validierungsfunktion nicht vorhanden, nimm an, dass Konfiguration vollständig ist return True except Exception as e: - log.warning(f"Error checking forecast config completeness: {e}") + log.warning(f"Fehler beim Prüfen der Prognose-Konfigurationsvollständigkeit: {e}") # On error, assume incomplete to be safe return False @@ -73,10 +73,10 @@ def _publish_forecast_fault(self, level: FaultStateLevel, message: str) -> None: Pub().pub("openWB/set/optional/forecast/get/fault_str", message) def _is_update_due(self) -> bool: - """Check if a forecast update is due. + """Prüfe, ob eine Prognose-Aktualisierung fällig ist. - Returns False immediately if configuration is incomplete (prevents API calls before config is saved). - Otherwise checks if the scheduled update time has been reached. + Gibt False sofort zurück, wenn die Konfiguration unvollständig ist (verhindert API-Aufrufe vor dem Speichern). + Prüft sonst, ob die geplante Aktualisierungszeit erreicht wurde. """ if not self._is_config_complete(): return False @@ -105,7 +105,7 @@ def _set_retry_query_time(self, minutes: int = FORECAST_RETRY_MINUTES) -> None: Pub().pub("openWB/set/optional/forecast/get/next_query_time", self.get.next_query_time) def _get_forecast_data(self): - """Get reference to the global forecast data from SubData.""" + """Rufe Referenz zu den globalen Prognosedaten aus SubData ab.""" from helpermodules import subdata return subdata.SubData.optional_data.data.forecast @@ -114,8 +114,8 @@ def update(self) -> None: if not force_update and not self._is_update_due(): return try: - trigger_mode = "manual" if force_update else "scheduled" - log.info("Forecast update started (provider=%s, trigger=%s)", self.config.type, trigger_mode) + trigger_mode = "manuell" if force_update else "geplant" + log.info("Prognose-Aktualisierung gestartet (Anbieter=%s, Auslöser=%s)", self.config.type, trigger_mode) state = self._component_updater() self.store.set(state) self.store.update() @@ -128,7 +128,7 @@ def update(self) -> None: self.get.last_update_time = now_ts Pub().pub("openWB/set/optional/forecast/get/last_update_time", now_ts) log.info( - "Forecast update finished (provider=%s, values=%s, next_query_time=%s)", + "Prognose-Aktualisierung beendet (Anbieter=%s, Werte=%s, nächste_Aktualisierungszeit=%s)", self.config.type, len(state.forecast_values or {}), self.get.next_query_time, @@ -136,27 +136,27 @@ def update(self) -> None: except Exception as e: error_str = str(e) if "429" in error_str: - # Rate limited providers should wait until the next planned schedule slot. + # Rate-limitierte Anbieter sollten bis zum nächsten geplanten Update-Zeitfenster warten. self._set_next_query_time_by_schedule() self._publish_forecast_fault( FaultStateLevel.WARNING, - "Forecast API rate limit reached (HTTP 429). Waiting for next scheduled update.", + "Forecast-API-Ratenlimit erreicht (HTTP 429). Warte auf nächste geplante Aktualisierung.", ) elif "Missing required" in error_str or "required forecast config field" in error_str.lower(): - # Configuration is incomplete; don't retry automatically. - # Next attempt will be on next scheduled time or manual trigger. + # Konfiguration ist unvollständig; keine automatische Wiederholung. + # Nächster Versuch beim nächsten geplanten Zeitpunkt oder manuellem Auslöser. self._set_next_query_time_by_schedule() self._publish_forecast_fault( FaultStateLevel.WARNING, - f"Forecast configuration incomplete: {error_str}. Please configure all required fields.", + f"Prognose-Konfiguration unvollständig: {error_str}. Bitte alle erforderlichen Felder konfigurieren.", ) else: self._set_retry_query_time() self._publish_forecast_fault( FaultStateLevel.WARNING, - "Forecast update failed. Retry scheduled in 15 minutes.", + "Prognose-Aktualisierung fehlgeschlagen. Wiederholung in 15 Minuten geplant.", ) - log.exception(f"Fehler beim Aktualisieren der Forecast-Daten {e}") + log.exception(f"Fehler beim Aktualisieren der Prognosedaten: {e}") finally: self._clear_force_update_request() diff --git a/packages/modules/common/store/_forecast.py b/packages/modules/common/store/_forecast.py index e06b4d3987..07431c5d86 100644 --- a/packages/modules/common/store/_forecast.py +++ b/packages/modules/common/store/_forecast.py @@ -61,7 +61,7 @@ def update(self): pub_to_broker("openWB/set/optional/forecast/get/today_kwh", today_kwh) pub_to_broker("openWB/set/optional/forecast/get/tomorrow_kwh", tomorrow_kwh) log.debug( - "published forecast values to MQTT having %s entries, %s day totals, %s today entries, and %s tomorrow entries", + "Prognosewerte an MQTT veröffentlicht mit %s Einträgen, %s Tagestotalen, %s Einträgen heute und %s Einträgen morgen", len(values), len(daily_kwh), len(today_values), From b945672dac9c466259b6d61cac8b7409393defbf Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 14:42:57 +0200 Subject: [PATCH 56/60] chore: fix flake8 style issues --- .../modules/common/configurable_forecast.py | 26 +++++++++++-------- packages/modules/common/store/_forecast.py | 7 ++--- .../forecast/forecastsolar/forecast.py | 2 -- .../modules/forecast/openmeteo/forecast.py | 3 +-- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 6a42f50c04..985d3378c0 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -2,7 +2,7 @@ from dataclasses import asdict from datetime import datetime, timedelta from importlib import import_module -from typing import Callable, Generic, Optional, TypeVar +from typing import Callable, Generic, TypeVar from helpermodules import timecheck from helpermodules.constants import NO_ERROR @@ -25,7 +25,7 @@ def __init__(self, self.store = store.get_forecast_value_store() self._component_updater = component_initializer(config) self.update_hours = DEFAULT_FORECAST_UPDATE_HOURS - + # Falls next_query_time nicht gesetzt ist, berechne die nächste geplante Aktualisierungszeit # Dies verhindert sofortige Aktualisierungen, wenn ein Anbieter erstmals erstellt wird if self.get.next_query_time is None or self.get.next_query_time == 0: @@ -34,7 +34,7 @@ def __init__(self, @property def get(self): """Gibt immer die aktuelle Singleton-Prognose.get aus der globalen SubData-Instanz zurück. - + SubData.optional_data ist die globale Singleton, die MQTT-Nachrichten aktualisieren. Dies stellt sicher, dass wir immer den aktuellen Status haben, keine veraltete Referenz. """ @@ -43,22 +43,22 @@ def get(self): def _is_config_complete(self) -> bool: """Prüfe, ob die Prognose-Anbieter-Konfiguration alle erforderlichen Felder hat. - + Ruft die is_configuration_complete()-Funktion des Anbietermoduls auf, falls vorhanden. Fällt auf True zurück (nimmt Vollständigkeit an), wenn die Funktion nicht gefunden wird. """ try: provider_type = self.config.type config = self.config.configuration - + # Importiere das Anbietermodul dynamisch und rufe seine Validierungsfunktion auf module_name = f"modules.forecast.{provider_type}.forecast" provider_module = import_module(module_name) - + # Rufe die Validierungsfunktion auf, falls vorhanden if hasattr(provider_module, "is_configuration_complete"): return provider_module.is_configuration_complete(config) - + # Wenn Validierungsfunktion nicht vorhanden, nimm an, dass Konfiguration vollständig ist return True except Exception as e: @@ -74,14 +74,17 @@ def _publish_forecast_fault(self, level: FaultStateLevel, message: str) -> None: def _is_update_due(self) -> bool: """Prüfe, ob eine Prognose-Aktualisierung fällig ist. - + Gibt False sofort zurück, wenn die Konfiguration unvollständig ist (verhindert API-Aufrufe vor dem Speichern). Prüft sonst, ob die geplante Aktualisierungszeit erreicht wurde. """ if not self._is_config_complete(): return False - - return self.get.next_query_time is None or self.get.next_query_time == 0 or self.get.next_query_time <= timecheck.create_timestamp() + + return ( + self.get.next_query_time is None or self.get.next_query_time == 0 or + self.get.next_query_time <= timecheck.create_timestamp() + ) def _is_force_update_requested(self) -> bool: return bool(self.get.force_update) @@ -148,7 +151,8 @@ def update(self) -> None: self._set_next_query_time_by_schedule() self._publish_forecast_fault( FaultStateLevel.WARNING, - f"Prognose-Konfiguration unvollständig: {error_str}. Bitte alle erforderlichen Felder konfigurieren.", + "Prognose-Konfiguration unvollständig: " + f"{error_str}. Bitte alle erforderlichen Felder konfigurieren.", ) else: self._set_retry_query_time() diff --git a/packages/modules/common/store/_forecast.py b/packages/modules/common/store/_forecast.py index 07431c5d86..8955c0f4db 100644 --- a/packages/modules/common/store/_forecast.py +++ b/packages/modules/common/store/_forecast.py @@ -1,13 +1,12 @@ +import logging from datetime import datetime, timedelta from typing import Dict, Optional from control import data -from helpermodules.pub import Pub from modules.common.component_state import ForecastState from modules.common.store import ValueStore from modules.common.store._api import LoggingValueStore from modules.common.store._broker import pub_to_broker -import logging log = logging.getLogger(__name__) @@ -21,6 +20,7 @@ def _parse_forecast_timestamp(timestamp: str) -> Optional[datetime]: except (TypeError, ValueError): return None + def _filter_values_for_date(values: Dict[str, float], target_date) -> Dict[str, float]: day_values: Dict[str, float] = {} for timestamp, value in values.items(): @@ -61,7 +61,8 @@ def update(self): pub_to_broker("openWB/set/optional/forecast/get/today_kwh", today_kwh) pub_to_broker("openWB/set/optional/forecast/get/tomorrow_kwh", tomorrow_kwh) log.debug( - "Prognosewerte an MQTT veröffentlicht mit %s Einträgen, %s Tagestotalen, %s Einträgen heute und %s Einträgen morgen", + "Prognosewerte an MQTT veröffentlicht mit %s Einträgen, %s Tagestotalen, %s Einträgen heute und" + " %s Einträgen morgen", len(values), len(daily_kwh), len(today_values), diff --git a/packages/modules/forecast/forecastsolar/forecast.py b/packages/modules/forecast/forecastsolar/forecast.py index 0a568138a9..41842e4a30 100644 --- a/packages/modules/forecast/forecastsolar/forecast.py +++ b/packages/modules/forecast/forecastsolar/forecast.py @@ -27,8 +27,6 @@ def _require(value, field_name: str): return value - - def _parse_forecast_solar_response(payload: Dict) -> Tuple[Dict[str, float], Dict[str, float]]: result = payload.get("result") if isinstance(payload, dict) else None source = result if isinstance(result, dict) else payload diff --git a/packages/modules/forecast/openmeteo/forecast.py b/packages/modules/forecast/openmeteo/forecast.py index 39b3610241..972164a214 100644 --- a/packages/modules/forecast/openmeteo/forecast.py +++ b/packages/modules/forecast/openmeteo/forecast.py @@ -1,5 +1,5 @@ -from datetime import datetime import logging +from datetime import datetime from typing import Dict, Tuple from zoneinfo import ZoneInfo @@ -85,7 +85,6 @@ def fetch_forecast(config: OpenMeteoForecastConfiguration) -> Tuple[Dict[str, fl def _calculate_daily_kwh(values: Dict[str, float]) -> Dict[str, float]: """Berechne tägliche Energiewerte aus stündlichen Leistungswerten.""" - from datetime import timedelta points: list[tuple[datetime, float]] = [] for timestamp, value in values.items(): try: From 4137928547278ba1222980cb8378f398e0cf38f6 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 14:58:27 +0200 Subject: [PATCH 57/60] chore: fix flake8 line length in loadvars.py --- packages/modules/loadvars.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/modules/loadvars.py b/packages/modules/loadvars.py index 0a9045010d..3b80919e45 100644 --- a/packages/modules/loadvars.py +++ b/packages/modules/loadvars.py @@ -136,8 +136,9 @@ def _set_io(self) -> List[Thread]: def forecast_get_values(self): try: - if hasattr(data.data.optional_data, "forecast_module") and data.data.optional_data.forecast_module is not None: - data.data.optional_data.forecast_module.update() + forecast_module = getattr(data.data.optional_data, "forecast_module", None) + if forecast_module is not None: + forecast_module.update() except Exception as e: log.exception("Fehler im Forecast-Optional-Modul: %s", e) From 0ea61e8591b617b323a92cb8509d72d6fb4294df Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 15:04:31 +0200 Subject: [PATCH 58/60] chore: add forecast topic to internal topics ACL test --- .../helpermodules/mosquitto_dynsec/missing_role_topics_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py b/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py index b06822ab14..2e1a324e09 100644 --- a/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py +++ b/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py @@ -107,7 +107,8 @@ def _get_data_path() -> Path: 'openWB/system/device/module_update_completed', 'openWB/system/hostname', 'openWB/system/lastlivevaluesJson', - 'openWB/system/release_train'] + 'openWB/system/release_train', + 'openWB/set/system/configurable/forecasts'] NOT_PERSISTENT_TOPICS = ['openWB/system/messages/[^/]+', 'openWB/system/messages/.*', From 4dafca276430b3bbf7cdabaa7c2935948a6d55c5 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 15:08:00 +0200 Subject: [PATCH 59/60] chore: add forecast topic to valid_topic list --- .../helpermodules/mosquitto_dynsec/missing_role_topics_test.py | 3 +-- packages/helpermodules/update_config.py | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py b/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py index 2e1a324e09..b06822ab14 100644 --- a/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py +++ b/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py @@ -107,8 +107,7 @@ def _get_data_path() -> Path: 'openWB/system/device/module_update_completed', 'openWB/system/hostname', 'openWB/system/lastlivevaluesJson', - 'openWB/system/release_train', - 'openWB/set/system/configurable/forecasts'] + 'openWB/system/release_train'] NOT_PERSISTENT_TOPICS = ['openWB/system/messages/[^/]+', 'openWB/system/messages/.*', diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index a9090fff98..aec2176665 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -581,6 +581,7 @@ class UpdateConfig: "^openWB/system/update_in_progress$", "^openWB/system/usage_terms_acknowledged$", "^openWB/system/version$", + "^openWB/set/system/configurable/forecasts$", ] default_topic = ( ("openWB/bat/config/bat_control_activated", False), From 71f95d8c7b9c6d5e64090717d438a1227b31e5f9 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sun, 9 Aug 2026 15:10:59 +0200 Subject: [PATCH 60/60] chore: add forecast topic to valid_topic list in correct alphabetical order --- packages/helpermodules/update_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index aec2176665..96c503a459 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -515,6 +515,7 @@ class UpdateConfig: "^openWB/system/configurable/chargepoints_internal$", "^openWB/system/configurable/devices_components$", "^openWB/system/configurable/flexible_tariffs$", + "^openWB/system/configurable/forecasts$", "^openWB/system/configurable/grid_fees$", "^openWB/system/configurable/display_themes$", "^openWB/system/configurable/io_actions$", @@ -581,7 +582,6 @@ class UpdateConfig: "^openWB/system/update_in_progress$", "^openWB/system/usage_terms_acknowledged$", "^openWB/system/version$", - "^openWB/set/system/configurable/forecasts$", ] default_topic = ( ("openWB/bat/config/bat_control_activated", False),