From ea747d16ece8df4cd5ce6fd59c72f4f1bf2ef2f8 Mon Sep 17 00:00:00 2001 From: Alexander Hartung Date: Wed, 22 Jul 2026 13:20:06 +0200 Subject: [PATCH 1/4] Add Naturstrom --- .../flexible_tariffs/naturstrom/__init__.py | 0 .../flexible_tariffs/naturstrom/config.py | 29 +++++ .../flexible_tariffs/naturstrom/tariff.py | 116 ++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 packages/modules/electricity_pricing/flexible_tariffs/naturstrom/__init__.py create mode 100644 packages/modules/electricity_pricing/flexible_tariffs/naturstrom/config.py create mode 100644 packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py diff --git a/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/__init__.py b/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/config.py b/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/config.py new file mode 100644 index 0000000000..4861ece206 --- /dev/null +++ b/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/config.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class NaturstromToken: + access_token: Optional[str] = field(default=None, repr=False) # don't show in UI + refresh_token: Optional[str] = field(default=None, repr=False) # don't show in UI + token_type: Optional[str] = field(default=None) # don't show in UI + expires: Optional[int] = field(default=None) # don't show in UI + created_at: Optional[float] = field(default=None) # don't show in UI + + +@dataclass +class NaturstromTariffConfiguration: + token: NaturstromToken = field(default_factory=NaturstromToken, compare=False) + account_id: Optional[str] = None + account_name: Optional[str] = None + + # Rabot publishes once daily at 00:00 for the following day + update_hours: list[int] = field(default_factory=lambda: [0]) + + +@dataclass +class NaturstromTariff: + name: str = "Naturstrom" + type: str = "naturstrom" + official: bool = True + configuration: NaturstromTariffConfiguration = field(default_factory=NaturstromTariffConfiguration) diff --git a/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py b/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py new file mode 100644 index 0000000000..fc9cca405f --- /dev/null +++ b/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +import datetime +import pytz +import logging +from typing import Dict + +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.naturstrom.config import NaturstromTariff, NaturstromToken + +from dataclass_utils import asdict +from helpermodules import timecheck +from helpermodules.pub import Pub +from requests.exceptions import HTTPError + +log = logging.getLogger(__name__) + + +def _get_raw_prices(config: NaturstromTariff): + headers = { + 'Accept': '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_today = start_of_today + datetime.timedelta(days=1) - datetime.timedelta(minutes=1) + + params = { + # Hier dann nochmal fragen, ob wir die Daten für den aktuellen Tag passen + 'start': start_of_today.isoformat(), + 'stop': end_of_today.isoformat(), + } + return req.get_http_session().get( + f"https://naturstrom-staging.powerquartier.de/api/public/accounts/{config.configuration.account_id}/rate", + headers=headers, + params=params + ).json() + + +def validate_token(config: NaturstromTariff) -> None: + """Prüft ob ein gültiger Token vorhanden ist, ansonsten wird ein neuer abgerufen.""" + if (config.configuration.token.access_token is not None): + # Access Token vorhanden + if (config.configuration.token.expires and config.configuration.token.created_at): + # Prüfe ob Token noch gültig ist + 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 + # Kein gültiger Token vorhanden oder abgelaufen, versuche Refresh Token + if config.configuration.token.refresh_token: + # Refresh Token vorhanden, versuche Access Token zu erneuern + log.debug("Access Token ist abgelaufen. Versuche Refresh Token.") + _refresh_token(config) + else: + # Kein Refresh Token vorhanden. Authentifizierung erforderlich. + raise Exception( + "Refresh Token ist nicht vorhanden. Bitte authentifizieren Sie sich erneut.") + + +def _refresh_token(config: NaturstromTariff) -> None: + """Erneuert den Access Token mit dem Refresh Token.""" + if not config.configuration.token.refresh_token: + raise Exception( + "Access Token ist abgelaufen und kein Refresh Token vorhanden. Bitte authentifizieren Sie sich erneut.") + + log.debug("Versuche Access Token mit Refresh Token zu erneuern.") + data = { + 'grant_type': 'refresh_token', + 'client_id': 'exnaton-public', + 'refresh_token': config.configuration.token.refresh_token + } + try: + token_data = req.get_http_session().post( + 'https://naturstrom-staging.powerquartier.de/api/public-auth/oauth2/token', + data=data, + headers={'Content-Type': 'application/x-www-form-urlencoded'} + ).json() + config.configuration.token = NaturstromToken( + 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_in"), + created_at=timecheck.create_timestamp() + ) + Pub().pub("openWB/set/optional/ep/flexible_tariff/provider", asdict(config)) + log.debug("Token erfolgreich erneuert.") + except HTTPError as e: + raise Exception(f"Token-Erneuerung fehlgeschlagen: {e}.") + + +def fetch(config: NaturstromTariff) -> None: + validate_token(config) + try: + raw_prices = _get_raw_prices(config) + except HTTPError as error: + raise error + + prices: Dict[int, float] = {} + for data in raw_prices["data"]: + formatted_price = data["price"] / 100000 # ct/kWh -> €/Wh + timestamp = datetime.datetime.strptime(data["start_time"], "%Y-%m-%dT%H:%M:%S.%fZ").timestamp() + prices.update({str(int(timestamp)): formatted_price}) + return prices + + +def create_electricity_tariff(config: NaturstromTariff): + def updater(): + return TariffState(prices=fetch(config)) + return updater + + +device_descriptor = DeviceDescriptor(configuration_factory=NaturstromTariff) From 310c589764842b7280981efd3be57f621ece47eb Mon Sep 17 00:00:00 2001 From: Alexander Hartung Date: Wed, 22 Jul 2026 13:33:59 +0200 Subject: [PATCH 2/4] Fix indentation --- .../electricity_pricing/flexible_tariffs/naturstrom/tariff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py b/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py index fc9cca405f..5ebee92635 100644 --- a/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py +++ b/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py @@ -41,7 +41,7 @@ def _get_raw_prices(config: NaturstromTariff): def validate_token(config: NaturstromTariff) -> None: """Prüft ob ein gültiger Token vorhanden ist, ansonsten wird ein neuer abgerufen.""" if (config.configuration.token.access_token is not None): - # Access Token vorhanden + # Access Token vorhanden if (config.configuration.token.expires and config.configuration.token.created_at): # Prüfe ob Token noch gültig ist expires_timestamp = config.configuration.token.created_at + config.configuration.token.expires - 300 From cec136d85a2ee0ee7eca36a6698931cf8decb2b5 Mon Sep 17 00:00:00 2001 From: Alexander Hartung Date: Thu, 23 Jul 2026 08:56:43 +0200 Subject: [PATCH 3/4] Add suggestions --- .../flexible_tariffs/naturstrom/config.py | 3 --- .../flexible_tariffs/naturstrom/tariff.py | 18 +++++++++--------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/config.py b/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/config.py index 4861ece206..c5245c64b0 100644 --- a/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/config.py +++ b/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/config.py @@ -17,9 +17,6 @@ class NaturstromTariffConfiguration: account_id: Optional[str] = None account_name: Optional[str] = None - # Rabot publishes once daily at 00:00 for the following day - update_hours: list[int] = field(default_factory=lambda: [0]) - @dataclass class NaturstromTariff: diff --git a/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py b/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py index 5ebee92635..bb66090922 100644 --- a/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py +++ b/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py @@ -3,16 +3,16 @@ import pytz import logging from typing import Dict +from requests.exceptions import HTTPError +from dataclass_utils import asdict 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.naturstrom.config import NaturstromTariff, NaturstromToken -from dataclass_utils import asdict from helpermodules import timecheck from helpermodules.pub import Pub -from requests.exceptions import HTTPError log = logging.getLogger(__name__) @@ -20,7 +20,7 @@ def _get_raw_prices(config: NaturstromTariff): headers = { 'Accept': 'application/json', - 'Authorization': f"{config.configuration.token.token_type} {config.configuration.token.access_token}" + 'Authorization': f"{config.configuration.token.token_type or 'Bearer'} {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) @@ -89,21 +89,21 @@ def _refresh_token(config: NaturstromTariff) -> None: Pub().pub("openWB/set/optional/ep/flexible_tariff/provider", asdict(config)) log.debug("Token erfolgreich erneuert.") except HTTPError as e: - raise Exception(f"Token-Erneuerung fehlgeschlagen: {e}.") + raise Exception(f"Token-Erneuerung fehlgeschlagen: {e}.") from e -def fetch(config: NaturstromTariff) -> None: +def fetch(config: NaturstromTariff) -> Dict[str, float]: validate_token(config) try: raw_prices = _get_raw_prices(config) except HTTPError as error: raise error - prices: Dict[int, float] = {} + prices: Dict[str, float] = {} for data in raw_prices["data"]: - formatted_price = data["price"] / 100000 # ct/kWh -> €/Wh - timestamp = datetime.datetime.strptime(data["start_time"], "%Y-%m-%dT%H:%M:%S.%fZ").timestamp() - prices.update({str(int(timestamp)): formatted_price}) + formatted_price = data["price"] / 1000 # €/kWh -> €/Wh + timestamp = int(datetime.datetime.fromisoformat(data["start_time"].replace("Z", "+00:00")).timestamp()) + prices[str(timestamp)] = formatted_price return prices From a44fa74d693b997e4499bc80efc68de62634a3a5 Mon Sep 17 00:00:00 2001 From: Alexander Hartung Date: Thu, 23 Jul 2026 09:01:59 +0200 Subject: [PATCH 4/4] Fix line too long --- .../flexible_tariffs/naturstrom/tariff.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py b/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py index bb66090922..efa3814894 100644 --- a/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py +++ b/packages/modules/electricity_pricing/flexible_tariffs/naturstrom/tariff.py @@ -20,7 +20,10 @@ def _get_raw_prices(config: NaturstromTariff): headers = { 'Accept': 'application/json', - 'Authorization': f"{config.configuration.token.token_type or 'Bearer'} {config.configuration.token.access_token}" + "Authorization": ( + f"{config.configuration.token.token_type or 'Bearer'} " + f"{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)