diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 871ee46abc..2d22863d92 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": [] @@ -471,6 +471,24 @@ "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/+", + "priority": 0, + "allow": true + }, { "acltype": "publishClientReceive", "topic": "openWB/system/security/access/Settings", @@ -1600,6 +1618,43 @@ } ] }, + { + "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/#", + "priority": 0, + "allow": true + } + ] + }, { "rolename": "charge-point-installation-access", "textname": "Zugang zu der Ladepunkt-Installation", diff --git a/packages/control/optional.py b/packages/control/optional.py index 975ca28567..5ee3daa8f5 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 @@ -14,6 +15,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 +30,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 +55,47 @@ 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]): + previous_configured = self.data.forecast.configured + self._forecast_module = value + 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", {}) + 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", 0) + Pub().pub("openWB/set/optional/forecast/get/last_update_time", 0) + else: + self.data.forecast.configured = True + self.data.forecast.provider = asdict(value.config) + if previous_configured is not True: + Pub().pub("openWB/set/optional/forecast/configured", True) + @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..7a3143fc61 100644 --- a/packages/control/optional_data.py +++ b/packages/control/optional_data.py @@ -1,11 +1,46 @@ from dataclasses import dataclass, field -from typing import 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 from modules.display_themes.cards.config import CardsDisplayTheme +@dataclass +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) + last_update_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__['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"} + forecast_get.__dataclass_fields__['last_update_time'].metadata = {"topic": f"{topic_prefix}/get/last_update_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 +112,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[Union[str, Dict[str, Any]]] = 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 +182,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/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..a2b60dcfd7 100644 --- a/packages/helpermodules/logger.py +++ b/packages/helpermodules/logger.py @@ -267,6 +267,15 @@ 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_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()) + 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/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 7973936181..cd92b2c0c4 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -882,6 +882,32 @@ 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/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: + 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..00733567cd 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,27 @@ 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 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 + 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) + 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/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 214eac0240..96c503a459 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$", @@ -502,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$", @@ -549,6 +563,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 +701,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), diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index be27fda208..8f3da364a5 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -246,6 +246,16 @@ def __init__(self, self.prices = prices +@auto_str +class ForecastState: + def __init__(self, + 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 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..985d3378c0 --- /dev/null +++ b/packages/modules/common/configurable_forecast.py @@ -0,0 +1,172 @@ +import logging +from dataclasses import asdict +from datetime import datetime, timedelta +from importlib import import_module +from typing import Callable, Generic, TypeVar + +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("forecast") +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 + + # 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): + """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. + """ + from helpermodules import subdata + return subdata.SubData.optional_data.data.forecast.get + + 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: + log.warning(f"Fehler beim Prüfen der Prognose-Konfigurationsvollständigkeit: {e}") + # On error, assume incomplete to be safe + return False + + def _publish_forecast_fault(self, level: FaultStateLevel, message: str) -> None: + 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) + + 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() + ) + + def _is_force_update_requested(self) -> bool: + return bool(self.get.force_update) + + def _clear_force_update_request(self) -> None: + 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: + 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.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.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): + """Rufe Referenz zu den globalen Prognosedaten aus SubData ab.""" + 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(): + return + try: + 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() + self._set_next_query_time_by_schedule() + self._publish_forecast_fault(FaultStateLevel.NO_ERROR, NO_ERROR) + forecast_data = self._get_forecast_data() + forecast_data.configured = True + forecast_data.provider = asdict(self.config) + now_ts = int(datetime.now().timestamp()) + self.get.last_update_time = now_ts + Pub().pub("openWB/set/optional/forecast/get/last_update_time", now_ts) + log.info( + "Prognose-Aktualisierung beendet (Anbieter=%s, Werte=%s, nächste_Aktualisierungszeit=%s)", + self.config.type, + len(state.forecast_values or {}), + self.get.next_query_time, + ) + except Exception as e: + error_str = str(e) + if "429" in error_str: + # 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-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(): + # 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, + "Prognose-Konfiguration unvollständig: " + f"{error_str}. Bitte alle erforderlichen Felder konfigurieren.", + ) + else: + self._set_retry_query_time() + self._publish_forecast_fault( + FaultStateLevel.WARNING, + "Prognose-Aktualisierung fehlgeschlagen. Wiederholung in 15 Minuten geplant.", + ) + log.exception(f"Fehler beim Aktualisieren der Prognosedaten: {e}") + finally: + self._clear_force_update_request() + + +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) 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..8955c0f4db --- /dev/null +++ b/packages/modules/common/store/_forecast.py @@ -0,0 +1,74 @@ +import logging +from datetime import datetime, timedelta +from typing import Dict, Optional + +from control import data +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 + + +log = logging.getLogger(__name__) + + +def _parse_forecast_timestamp(timestamp: str) -> Optional[datetime]: + try: + if timestamp.isdigit(): + return datetime.fromtimestamp(int(timestamp)) + return datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + 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(): + 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 + + def set(self, state: ForecastState) -> None: + self.state = state + + def update(self): + values = self.state.forecast_values or {} + 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) + 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) + log.debug( + "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), + len(tomorrow_values), + ) + + +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..f0c949c039 --- /dev/null +++ b/packages/modules/forecast/forecastsolar/config.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class ForecastSolarConfiguration: + latitude: float = 0.0 + longitude: float = 0.0 + api_key: Optional[str] = None + strings: Optional[List] = 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..41842e4a30 --- /dev/null +++ b/packages/modules/forecast/forecastsolar/forecast.py @@ -0,0 +1,142 @@ +from datetime import datetime +import logging +from typing import Any, Dict, Tuple +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("forecast") + + +def is_configuration_complete(config: ForecastSolarConfiguration) -> bool: + """Prüfe, ob die Forecast.Solar-Konfiguration alle erforderlichen Felder hat.""" + 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}") + if isinstance(value, str) and value.strip() == "": + raise ValueError(f"Missing required forecast config field: {field_name}") + 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 + + # 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 + 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_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_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") + 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] + + log.info("Forecast.Solar-Abruf gestartet (Strings=%s)", len(string_configs)) + + 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 = ( + 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}" + f"/{azimuth}" + f"/{peak_power_kw}" + ) + 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: + 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() + 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 + + +def create_forecast(config: ForecastSolar): + def updater(): + values, daily_kwh = fetch_forecast(config.configuration) + return ForecastState(forecast_values=values, daily_kwh=daily_kwh) + 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..363596ee7c --- /dev/null +++ b/packages/modules/forecast/openmeteo/config.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class OpenMeteoForecastConfiguration: + latitude: float = 0.0 + longitude: float = 0.0 + timezone: str = "Europe/Berlin" + system_loss: float = 0.14 + strings: Optional[List] = 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..972164a214 --- /dev/null +++ b/packages/modules/forecast/openmeteo/forecast.py @@ -0,0 +1,138 @@ +import logging +from datetime import datetime +from typing import Dict, Tuple +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 + +OPEN_METEO_FORECAST_HOURS = 48 +log = logging.getLogger("forecast") + + +def is_configuration_complete(config: OpenMeteoForecastConfiguration) -> bool: + """Prüfe, ob die Open-Meteo-Konfiguration alle erforderlichen Felder hat.""" + 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}") + if isinstance(value, str) and value.strip() == "": + raise ValueError(f"Missing required forecast config field: {field_name}") + return value + + +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") + 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-Abruf gestartet (Strings=%s, Zeitzone=%s)", + len(string_configs), + timezone, + ) + + 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")) + 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}" + ) + + 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 + # 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 + + 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.""" + 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: + 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(): + values, daily_kwh = fetch_forecast(config.configuration) + return ForecastState(forecast_values=values, daily_kwh=daily_kwh) + 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..47b2cd2121 --- /dev/null +++ b/packages/modules/forecast/pvnode/config.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass, field + + +@dataclass +class PvNodeConfiguration: + api_key: str = "" + plant_id: str = "" + + +@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..a6b1346df8 --- /dev/null +++ b/packages/modules/forecast/pvnode/forecast.py @@ -0,0 +1,124 @@ +from datetime import datetime +import logging +from typing import Any, Dict, Optional, Tuple + +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 + + +log = logging.getLogger("forecast") + + +def is_configuration_complete(config: PvNodeConfiguration) -> bool: + """Prüfe, ob die PVNode-Konfiguration alle erforderlichen Felder hat.""" + 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}") + if isinstance(value, str) and value.strip() == "": + raise ValueError(f"Missing required forecast config field: {field_name}") + return value + + +def _normalize_timestamp(value: Any) -> Optional[int]: + 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 _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-Abruf gestartet (plant_id=%s)", masked_plant_id) + + path = f"/v2/forecast/{plant_id}" + url = f"https://api.pvnode.com{path}" + 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: + 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") + ) + 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 + 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 + estimated_power_w = max(0.0, float(value)) + values[str(timestamp)] = estimated_power_w + + log.info("PVNode-Abruf beendet (Werte=%s, Tage=%s)", len(values), len(daily_kwh)) + + return values, daily_kwh + + +def create_forecast(config: PvNode): + def updater(): + values, daily_kwh = fetch_forecast(config.configuration) + return ForecastState(forecast_values=values, daily_kwh=daily_kwh) + return updater + + +device_descriptor = DeviceDescriptor(configuration_factory=PvNode) diff --git a/packages/modules/loadvars.py b/packages/modules/loadvars.py index 5fc9efd6a4..3b80919e45 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,14 @@ def _set_io(self) -> List[Thread]: finally: return threads + def forecast_get_values(self): + try: + 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) + def ep_get_prices(self): def append_thread_set_values(module_name: str) -> None: module = getattr(data.data.optional_data, f"{module_name}_module")