From 4d55a6251c59b12cd1be138287f6ca780a63e5a7 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 5 Dec 2025 10:18:11 +0100 Subject: [PATCH 1/2] add westfalen wind --- .../westfalen_wind/__init__.py | 0 .../flexible_tariffs/westfalen_wind/config.py | 39 ++++ .../flexible_tariffs/westfalen_wind/tariff.py | 178 ++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/__init__.py create mode 100644 packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/config.py create mode 100644 packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/tariff.py diff --git a/packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/__init__.py b/packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/config.py b/packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/config.py new file mode 100644 index 0000000000..8cb3718fe0 --- /dev/null +++ b/packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/config.py @@ -0,0 +1,39 @@ +from typing import Optional + + +class WestfalenWindToken(): + def __init__(self, + access_token: Optional[str] = None, + refresh_token: Optional[str] = None, + token_type: Optional[str] = None, + expires: Optional[int] = None, + created_at: Optional[float] = None) -> None: + self.access_token = access_token # don't show in UI + self.refresh_token = refresh_token # don't show in UI + self.token_type = token_type # don't show in UI + self.expires = expires # don't show in UI + self.created_at = created_at # don't show in UI + + +class WestfalenWindTariffConfiguration: + def __init__(self, + username: Optional[str] = None, + password: Optional[str] = None, + contract_id: Optional[str] = None, + token: WestfalenWindToken = None): + self.username = username + self.password = password + self.contract_id = contract_id + self.token = token or WestfalenWindToken() + + +class WestfalenWindTariff: + def __init__(self, + name: str = "WestfalenWind", + type: str = "westfalen_wind", + official: bool = True, + configuration: WestfalenWindTariffConfiguration = None) -> None: + self.name = name + self.type = type + self.official = official + self.configuration = configuration or WestfalenWindTariffConfiguration() diff --git a/packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/tariff.py b/packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/tariff.py new file mode 100644 index 0000000000..e0f84ed124 --- /dev/null +++ b/packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/tariff.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +import datetime +import logging +from typing import Dict +from requests.exceptions import HTTPError + + +from dataclass_utils import asdict +from helpermodules import timecheck +from helpermodules.pub import Pub +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import TariffState +from modules.electricity_pricing.flexible_tariffs.westfalen_wind.config import WestfalenWindTariff, WestfalenWindToken + +log = logging.getLogger(__name__) + + +def validate_token(config: WestfalenWindTariff) -> None: + """Prüft ob ein gültiger Token vorhanden ist, ansonsten wird ein neuer abgerufen.""" + if (config.configuration.token.access_token and + config.configuration.token.expires and + config.configuration.token.created_at): + + # Prüfe ob Token noch gültig ist (mit 5 Min Puffer) + expires_timestamp = config.configuration.token.created_at + config.configuration.token.expires - 300 + current_timestamp = timecheck.create_timestamp() + + if current_timestamp < expires_timestamp: + log.debug("Access Token ist noch gültig.") + return + else: + log.debug("Access Token ist abgelaufen. Versuche Refresh Token.") + if config.configuration.token.refresh_token: + _refresh_token(config) + else: + _authenticate(config) + else: + log.debug("Kein gültiger Token vorhanden. Authentifizierung erforderlich.") + _authenticate(config) + + +def _authenticate(config: WestfalenWindTariff) -> None: + """Authentifizierung mit Benutzername und Passwort.""" + if not config.configuration.username or not config.configuration.password: + raise ValueError("Benutzername und Passwort sind für die Authentifizierung erforderlich.") + + data = { + 'grant_type': 'password', + 'username': config.configuration.username, + 'password': config.configuration.password + } + + try: + response = req.get_http_session().post( + 'https://api.wws.tarifdynamik.de/public/tokens', + data=data, + headers={'Content-Type': 'application/x-www-form-urlencoded'} + ) + response.raise_for_status() + token_data = response.json() + + config.configuration.token = WestfalenWindToken( + access_token=token_data.get("access_token"), + refresh_token=token_data.get("refresh_token"), + token_type=token_data.get("token_type", "Bearer"), + expires=token_data.get("expires"), + created_at=timecheck.create_timestamp() + ) + + # Konfiguration speichern + Pub().pub("openWB/set/optional/ep/flexible_tariff/provider", asdict(config)) + log.debug("Erfolgreich authentifiziert.") + + except HTTPError as e: + log.error(f"Authentifizierung fehlgeschlagen: {e}") + raise + + +def _refresh_token(config: WestfalenWindTariff) -> None: + """Erneuert den Access Token mit dem Refresh Token.""" + if not config.configuration.token.refresh_token: + log.debug("Kein Refresh Token vorhanden. Führe neue Authentifizierung durch.") + _authenticate(config) + return + + data = { + 'grant_type': 'refresh_token', + 'refresh_token': config.configuration.token.refresh_token + } + + try: + response = req.get_http_session().post( + 'https://api.wws.tarifdynamik.de/public/tokens', + data=data, + headers={'Content-Type': 'application/x-www-form-urlencoded'} + ) + response.raise_for_status() + token_data = response.json() + + config.configuration.token = WestfalenWindToken( + access_token=token_data.get("access_token"), + refresh_token=token_data.get("refresh_token"), + token_type=token_data.get("token_type", "Bearer"), + expires=token_data.get("expires"), + created_at=timecheck.create_timestamp() + ) + + # Konfiguration speichern + Pub().pub("openWB/set/optional/ep/flexible_tariff/provider", asdict(config)) + log.debug("Token erfolgreich erneuert.") + + except HTTPError as e: + log.error(f"Token-Erneuerung fehlgeschlagen: {e}. Führe neue Authentifizierung durch.") + _authenticate(config) + + +def fetch(config: WestfalenWindTariff) -> Dict[str, float]: + """Holt die aktuellen Strompreise von der WestfalenWind API.""" + + def get_raw_prices(): + headers = { + "Content-Type": "application/json", + "Authorization": f"{config.configuration.token.token_type} {config.configuration.token.access_token}" + } + + params = {} + if config.configuration.contract_id: + params['contract_id'] = config.configuration.contract_id + + return req.get_http_session().get( + "https://api.wws.tarifdynamik.de/public/energyprices/latest", + headers=headers, + params=params + ).json() + + validate_token(config) + + try: + raw_data = get_raw_prices() + except HTTPError as error: + if error.response.status_code == 401: + log.debug("401 Unauthorized - Token ungültig. Versuche Erneuerung.") + _authenticate(config) + raw_data = get_raw_prices() + else: + raise error + + prices: Dict[str, float] = {} + + if 'data' in raw_data: + for price_entry in raw_data['data']: + # Konvertiere ISO 8601 Zeitstempel zu Unix Timestamp + start_time = datetime.datetime.fromisoformat( + price_entry['start'].replace('Z', '+00:00') + ) + timestamp = int(start_time.timestamp()) + + # Konvertiere ct/kWh zu €/Wh + price_euro_per_wh = price_entry['price_ct_kwh'] / 100000 + + prices[str(timestamp)] = price_euro_per_wh + + log.debug(f"WestfalenWind: {len(prices)} Preise abgerufen.") + return prices + + +def create_electricity_tariff(config: WestfalenWindTariff): + """Erstellt den Tariff-Updater für WestfalenWind.""" + validate_token(config) + + def updater(): + return TariffState(prices=fetch(config)) + + return updater + + +device_descriptor = DeviceDescriptor(configuration_factory=WestfalenWindTariff) From 8e0d5182baa87860e3f0c4e4e99c6f74b56a9f21 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 5 Dec 2025 10:40:35 +0100 Subject: [PATCH 2/2] imrpove --- .../flexible_tariffs/westfalen_wind/tariff.py | 124 +++++++----------- 1 file changed, 51 insertions(+), 73 deletions(-) diff --git a/packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/tariff.py b/packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/tariff.py index e0f84ed124..dd8ec6c4e9 100644 --- a/packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/tariff.py +++ b/packages/modules/electricity_pricing/flexible_tariffs/westfalen_wind/tariff.py @@ -2,6 +2,7 @@ import datetime import logging from typing import Dict +import pytz from requests.exceptions import HTTPError @@ -50,31 +51,21 @@ def _authenticate(config: WestfalenWindTariff) -> None: 'username': config.configuration.username, 'password': config.configuration.password } - - try: - response = req.get_http_session().post( - 'https://api.wws.tarifdynamik.de/public/tokens', - data=data, - headers={'Content-Type': 'application/x-www-form-urlencoded'} - ) - response.raise_for_status() - token_data = response.json() - - config.configuration.token = WestfalenWindToken( - access_token=token_data.get("access_token"), - refresh_token=token_data.get("refresh_token"), - token_type=token_data.get("token_type", "Bearer"), - expires=token_data.get("expires"), - created_at=timecheck.create_timestamp() - ) - - # Konfiguration speichern - Pub().pub("openWB/set/optional/ep/flexible_tariff/provider", asdict(config)) - log.debug("Erfolgreich authentifiziert.") - - except HTTPError as e: - log.error(f"Authentifizierung fehlgeschlagen: {e}") - raise + token_data = req.get_http_session().post( + 'https://api.wws.tarifdynamik.de/public/tokens', + data=data, + headers={'Content-Type': 'application/x-www-form-urlencoded'} + ).json() + + config.configuration.token = WestfalenWindToken( + access_token=token_data.get("access_token"), + refresh_token=token_data.get("refresh_token"), + token_type=token_data.get("token_type", "Bearer"), + expires=token_data.get("expires"), + created_at=timecheck.create_timestamp() + ) + Pub().pub("openWB/set/optional/ep/flexible_tariff/provider", asdict(config)) + log.debug("Erfolgreich authentifiziert.") def _refresh_token(config: WestfalenWindTariff) -> None: @@ -88,16 +79,12 @@ def _refresh_token(config: WestfalenWindTariff) -> None: 'grant_type': 'refresh_token', 'refresh_token': config.configuration.token.refresh_token } - try: - response = req.get_http_session().post( + token_data = req.get_http_session().post( 'https://api.wws.tarifdynamik.de/public/tokens', data=data, headers={'Content-Type': 'application/x-www-form-urlencoded'} - ) - response.raise_for_status() - token_data = response.json() - + ).json() config.configuration.token = WestfalenWindToken( access_token=token_data.get("access_token"), refresh_token=token_data.get("refresh_token"), @@ -105,73 +92,64 @@ def _refresh_token(config: WestfalenWindTariff) -> None: expires=token_data.get("expires"), created_at=timecheck.create_timestamp() ) - - # Konfiguration speichern Pub().pub("openWB/set/optional/ep/flexible_tariff/provider", asdict(config)) log.debug("Token erfolgreich erneuert.") - except HTTPError as e: log.error(f"Token-Erneuerung fehlgeschlagen: {e}. Führe neue Authentifizierung durch.") _authenticate(config) -def fetch(config: WestfalenWindTariff) -> Dict[str, float]: - """Holt die aktuellen Strompreise von der WestfalenWind API.""" - - def get_raw_prices(): - headers = { - "Content-Type": "application/json", - "Authorization": f"{config.configuration.token.token_type} {config.configuration.token.access_token}" - } - - params = {} - if config.configuration.contract_id: - params['contract_id'] = config.configuration.contract_id - - return req.get_http_session().get( - "https://api.wws.tarifdynamik.de/public/energyprices/latest", - headers=headers, - params=params - ).json() +def _get_raw_prices(config: WestfalenWindTariff): + headers = { + "Content-Type": "application/json", + "Authorization": f"{config.configuration.token.token_type} {config.configuration.token.access_token}" + } + now = datetime.datetime.now(pytz.timezone("Europe/Berlin")) + start_of_today = now.replace(hour=0, minute=0, second=0, microsecond=0) + end_of_tomorrow = start_of_today + datetime.timedelta(days=2) - datetime.timedelta(minutes=15) + filters = [ + f"valid_from:gte:{now.isoformat()}", + f"valid_from:lte:{end_of_tomorrow.isoformat()}" + ] + params = { + 'page_size': 200, # Maximal 200 Einträge (2 Tage * 96 Viertelstunden) + 'sort': 'valid_from:asc', + 'filters': filters + } + if config.configuration.contract_id: + params['contract_id'] = config.configuration.contract_id - validate_token(config) + return req.get_http_session().get( + "https://api.wws.tarifdynamik.de/public/energyprices", + headers=headers, + params=params + ).json() + +def fetch(config: WestfalenWindTariff) -> Dict[str, float]: + validate_token(config) try: - raw_data = get_raw_prices() + raw_data = _get_raw_prices(config) except HTTPError as error: if error.response.status_code == 401: log.debug("401 Unauthorized - Token ungültig. Versuche Erneuerung.") _authenticate(config) - raw_data = get_raw_prices() + raw_data = _get_raw_prices(config) else: raise error - prices: Dict[str, float] = {} - - if 'data' in raw_data: - for price_entry in raw_data['data']: - # Konvertiere ISO 8601 Zeitstempel zu Unix Timestamp - start_time = datetime.datetime.fromisoformat( - price_entry['start'].replace('Z', '+00:00') - ) - timestamp = int(start_time.timestamp()) - - # Konvertiere ct/kWh zu €/Wh - price_euro_per_wh = price_entry['price_ct_kwh'] / 100000 - - prices[str(timestamp)] = price_euro_per_wh - - log.debug(f"WestfalenWind: {len(prices)} Preise abgerufen.") + for price_entry in raw_data['data']: + timestamp = int(datetime.datetime.fromisoformat(price_entry['start'].replace('Z', '+00:00')).timestamp()) + price_euro_per_wh = price_entry['price_ct_kwh'] / 100000 + prices[str(timestamp)] = price_euro_per_wh return prices def create_electricity_tariff(config: WestfalenWindTariff): - """Erstellt den Tariff-Updater für WestfalenWind.""" validate_token(config) def updater(): return TariffState(prices=fetch(config)) - return updater