diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 0c6dbe6186..ead74952c3 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -1713,6 +1713,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..e33d9544b5 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 @@ -8,12 +9,13 @@ from control import data from control.ocpp import OcppMixin -from control.optional_data import FlexibleTariff, GridFee, OptionalData, PricingGet +from control.optional_data import FlexibleTariff, ForecastGet, GridFee, OptionalData, PricingGet from helpermodules import hardware_configuration from helpermodules.constants import NO_ERROR 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,43 @@ 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]): + if (value is None or + (self._forecast_module and value and + self._forecast_module.config != value.config)): + self.data.forecast.get = ForecastGet() + self._reset_forecast_state() + previous_configured = self.data.forecast.configured + 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) + 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) + + def _reset_forecast_state(self): + 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) + @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..66260f6804 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) @@ -81,6 +116,17 @@ def ep_factory() -> ElectricityPricing: return ElectricityPricing() +@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 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/control/optional_test.py b/packages/control/optional_test.py index f729577bca..4ba580664a 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -1,9 +1,19 @@ +from dataclasses import dataclass from unittest.mock import Mock import pytest from helpermodules import timecheck from control.optional import Optional +@dataclass +class _FakeForecastConfig: + type: str = "fake" + + +def _fake_forecast_module(forecast_type: str = "fake") -> Mock: + return Mock(config=_FakeForecastConfig(type=forecast_type)) + + ONE_HOUR_SECONDS = 3600 IGNORED = 0.0001 CHEAP = 0.0002 @@ -487,3 +497,39 @@ def test_et_price_update_required(monkeypatch, flexible_tariff, grid_fee, curren # evaluation assert result == expected + + +class TestForecastModule: + def _configured_opt(self) -> Optional: + opt = Optional() + opt.forecast_module = _fake_forecast_module("forecast_solar") + opt.data.forecast.get.values = {"1": 1.0} + opt.data.forecast.get.today_kwh = 5.0 + return opt + + def test_selecting_none_clears_configured_and_data(self): + opt = self._configured_opt() + + opt.forecast_module = None + + assert opt.data.forecast.configured is False + assert opt.data.forecast.provider is None + assert opt.data.forecast.get.values == {} + assert opt.data.forecast.get.today_kwh == 0.0 + + def test_switching_provider_clears_old_data(self): + opt = self._configured_opt() + + opt.forecast_module = _fake_forecast_module("open_meteo") + + assert opt.data.forecast.configured is True + assert opt.data.forecast.get.values == {} + assert opt.data.forecast.get.today_kwh == 0.0 + + def test_same_provider_config_keeps_data(self): + opt = self._configured_opt() + + opt.forecast_module = _fake_forecast_module("forecast_solar") + + assert opt.data.forecast.get.values == {"1": 1.0} + assert opt.data.forecast.get.today_kwh == 5.0 diff --git a/packages/helpermodules/create_debug.py b/packages/helpermodules/create_debug.py index eae3d69e55..3f512780d1 100644 --- a/packages/helpermodules/create_debug.py +++ b/packages/helpermodules/create_debug.py @@ -490,6 +490,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 ae13db41b2..39ec78f8cd 100644 --- a/packages/helpermodules/logger.py +++ b/packages/helpermodules/logger.py @@ -302,6 +302,14 @@ def mb_to_bytes(megabytes: float) -> 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) + # Totals generation logger setup_generate_totals_logging() diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 8c122f4d6e..231f71a432 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -911,6 +911,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 dce64e761f..30c7dc0471 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -40,6 +40,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 ( @@ -826,6 +827,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/subdata_test.py b/packages/helpermodules/subdata_test.py index 6d610bde64..1720e89884 100644 --- a/packages/helpermodules/subdata_test.py +++ b/packages/helpermodules/subdata_test.py @@ -7,6 +7,7 @@ from control.consumer.consumer import Consumer from control.consumer.usage import ConsumerUsage from control.counter_all.counter_all import CounterAll +from control.optional import Optional from helpermodules.subdata import SubData @@ -190,3 +191,22 @@ def test_process_consumer_extra_meter_switches_linked_counter_in_hierarchy(monke ], }] mock_pub.pub.assert_called_once_with("openWB/set/counter/get/hierarchy", counter_all_data.data.get.hierarchy) + + +def test_process_optional_topic_forecast_provider_none_clears_state(mock_pub): + subdata = SubData.__new__(SubData) + subdata.event_subdata_initialized = Mock() + subdata.event_subdata_initialized.is_set.return_value = True + var = Optional() + var._forecast_module = Mock() + var.data.forecast.configured = True + var.data.forecast.provider = {"type": "forecast_solar"} + var.data.forecast.get.values = {"1": 1.0} + msg = Mock(topic="openWB/set/optional/forecast/provider", payload=json.dumps({"type": None}).encode()) + + subdata.process_optional_topic(var, msg) + + assert var.forecast_module is None + assert var.data.forecast.configured is False + assert var.data.forecast.provider is None + assert var.data.forecast.get.values == {} diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 7b7fb3402d..f8b1cadcd0 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -423,6 +423,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$", @@ -582,6 +595,7 @@ class UpdateConfig: "^openWB/system/configurable/consumers$", "^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$", @@ -631,6 +645,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/ConsumerConfiguration$", @@ -725,6 +740,8 @@ class UpdateConfig: ("openWB/internal_chargepoint/1/data/parent_cp", None), ("openWB/optional/ep/flexible_tariff/provider", NO_MODULE), ("openWB/optional/ep/grid_fee/provider", NO_MODULE), + ("openWB/optional/forecast/configured", False), + ("openWB/optional/forecast/provider", NO_MODULE), ("openWB/optional/int_display/active", True), ("openWB/optional/int_display/detected", True), ("openWB/optional/int_display/on_if_plugged_in", True), @@ -774,6 +791,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/ConsumerConfiguration", True), diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index 6609c21d2b..fae55fbbdd 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -250,6 +250,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 e85fed327e..0219453ac8 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() @@ -111,6 +112,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/forecast_solar/__init__.py b/packages/modules/forecast/forecast_solar/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/forecast/forecast_solar/config.py b/packages/modules/forecast/forecast_solar/config.py new file mode 100644 index 0000000000..290b791d1e --- /dev/null +++ b/packages/modules/forecast/forecast_solar/config.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, field +from typing import List, Optional, TypedDict + + +class ForecastSolarStringConfiguration(TypedDict): + name: Optional[str] + peak_power_kw: float + tilt: float + azimuth: float + + +@dataclass +class ForecastSolarConfiguration: + latitude: float = 0.0 + longitude: float = 0.0 + api_key: Optional[str] = None + strings: List[ForecastSolarStringConfiguration] = field(default_factory=list) + + +@dataclass +class ForecastSolar: + name: str = "Forecast.Solar" + type: str = "forecast_solar" + official: bool = False + configuration: ForecastSolarConfiguration = field(default_factory=ForecastSolarConfiguration) diff --git a/packages/modules/forecast/forecast_solar/forecast.py b/packages/modules/forecast/forecast_solar/forecast.py new file mode 100644 index 0000000000..8cd39f453c --- /dev/null +++ b/packages/modules/forecast/forecast_solar/forecast.py @@ -0,0 +1,145 @@ +from datetime import datetime +import logging +from typing import 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.forecast_solar.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.""" + return len(config.strings) > 0 + + +def _require(value, field_name: str): + if ( + value is None + or (isinstance(value, str) and value.strip() == "") + or (isinstance(value, list) and len(value) == 0) + ): + raise ValueError(f"Missing required forecast config field: {field_name}") + return value + + +def _parse_forecast_solar_response(payload: Dict) -> Dict[str, float]: + # Vorhersage-API-Endpunkte ("estimate") geben das Ergebnis direkt als flaches {Zeitstempel: Wert} Dict zurück. + watts_raw = payload.get("result") if isinstance(payload, 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) + return values + + +def fetch_forecast(config: ForecastSolarConfiguration) -> Tuple[Dict[str, float], Dict[str, float]]: + latitude = _require(config.latitude, "latitude") + longitude = _require(config.longitude, "longitude") + string_configs = _require(config.strings, "strings") + if len(string_configs) > 6: + log.warning(f"Es wurden {len(string_configs)} Strings konfiguriert. Es werden nur die ersten 6 verwendet.") + string_configs = string_configs[:6] + + log.info(f"Forecast.Solar-Abruf gestartet (Strings={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(f"Forecast.Solar rate limit hit for {url}: " + f"remaining={remaining} limit={limit} period={period} retry_at={retry_at}") + raise + + response = response_obj.json() + log.debug(f"Forecast.Solar Antwort für String {string_config.get('name')}: {response}") + string_values = _parse_forecast_solar_response(response) + for timestamp, value in string_values.items(): + values[timestamp] = values.get(timestamp, 0.0) + value + + # Die genutzte /estimate/watts-Route liefert grundsätzlich keine vorab aggregierten + # Tageswerte (weder mit noch ohne API-Key), daher werden sie aus den Stundenwerten berechnet. + daily_kwh = _calculate_daily_kwh_from_values(values) + + log.info(f"Forecast.Solar-Abruf beendet (Werte={len(values)}, Tage={len(daily_kwh)})") + return values, daily_kwh + + +def _calculate_daily_kwh_from_values(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: + points.append((datetime.fromtimestamp(int(timestamp)), 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 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/open_meteo/__init__.py b/packages/modules/forecast/open_meteo/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/forecast/open_meteo/config.py b/packages/modules/forecast/open_meteo/config.py new file mode 100644 index 0000000000..3ba7e9f01b --- /dev/null +++ b/packages/modules/forecast/open_meteo/config.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field +from typing import List, TypedDict, Optional + + +class StringConfiguration(TypedDict): + name: Optional[str] + peak_power_kw: float + tilt: float + azimuth: float + + +@dataclass +class OpenMeteoForecastConfiguration: + latitude: float = 0.0 + longitude: float = 0.0 + timezone: str = "Europe/Berlin" + system_loss: float = 0.14 + strings: List[StringConfiguration] = field(default_factory=list) + + +@dataclass +class OpenMeteoForecast: + name: str = "Open-Meteo PV Forecast" + type: str = "open_meteo" + official: bool = False + configuration: OpenMeteoForecastConfiguration = field(default_factory=OpenMeteoForecastConfiguration) diff --git a/packages/modules/forecast/open_meteo/forecast.py b/packages/modules/forecast/open_meteo/forecast.py new file mode 100644 index 0000000000..b83cb5d0b8 --- /dev/null +++ b/packages/modules/forecast/open_meteo/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.open_meteo.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.""" + return len(config.strings) > 0 + + +def _require(value, field_name: str): + if ( + value is None + or (isinstance(value, str) and len(value.strip()) == 0) + or (isinstance(value, list) and len(value) == 0) + ): + 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 len(string_configs_raw) == 0: + raise ValueError("Es wurden noch keine Strings konfiguriert!") + if len(string_configs_raw) > 6: + log.warning("Es wurden mehr als 6 Strings konfiguriert. Es werden nur die ersten 6 verwendet.") + string_configs = string_configs_raw[:6] + + log.info(f"Open-Meteo-Abruf gestartet (Strings={len(string_configs)}, Zeitzone={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() + log.debug(f"Open-Meteo-Antwort erhalten: {response}") + 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(f"Open-Meteo-Abruf beendet (Werte={len(values)}, Tage={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..e3ac08c611 --- /dev/null +++ b/packages/modules/forecast/pvnode/config.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class PvNodeConfiguration: + api_key: Optional[str] = None + plant_id: Optional[str] = None + + +@dataclass +class PvNode: + name: str = "PVNode V2" + type: str = "pvnode" + official: bool = False + 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..7ffe235e00 --- /dev/null +++ b/packages/modules/forecast/pvnode/forecast.py @@ -0,0 +1,120 @@ +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 (config.plant_id is not None and len(config.plant_id.strip()) > 0 + and config.api_key is not None and len(config.api_key.strip()) > 0) + + +def _require(value, field_name: str): + if value is None or isinstance(value, str) and len(value.strip()) == 0: + 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(f"PVNode-Abruf gestartet (plant_id={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() + log.debug(f"PVNode-Antwort erhalten: {response}") + 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) or + (date_key := entry.get("date")) is None or + (energy_kwh := entry.get("pv_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 (power_kw := entry.get("power_kw")) is not None: + power_w = float(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(f"PVNode-Abruf beendet (Werte={len(values)}, Tage={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 a2dfab43bd..c9b2000f61 100644 --- a/packages/modules/loadvars.py +++ b/packages/modules/loadvars.py @@ -44,6 +44,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") @@ -216,6 +218,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")