Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,26 @@
GERMAN_TZ = pytz.timezone("Europe/Berlin")


class OctopusEnergyApiError(Exception):
"""Wird geworfen, wenn die Kraken-API kein verwertbares 'data'-Feld liefert
(z.B. GraphQL-Fehler, abgelaufener Token, Wartungsarbeiten bei Octopus)."""
pass


class OctopusEnergyClient:
def __init__(self, email: str, password: str, base_url="https://api.oeg-kraken.energy/v1/graphql/"):
self.base_url = base_url
self.token = None
self.session = req.get_http_session()
self.authenticate(email, password)

def _graphql_request(self, query: str, variables: dict):
"""Send a GraphQL request with authentication."""
def _graphql_request(self, query: str, variables: dict) -> dict:
"""Sendet einen GraphQL-Request mit Authentifizierung.

Wirft OctopusEnergyApiError, wenn die Antwort kein 'data'-Feld enthält
(statt stillschweigend None zurückzugeben und den Fehler an den Aufrufer
weiterzureichen, wo er als kryptischer NoneType-Fehler auftaucht).
"""
headers = {
"Authorization": f"{self.token}" if self.token else "",
"Content-Type": "application/json"
Expand All @@ -32,13 +43,27 @@ def _graphql_request(self, query: str, variables: dict):

response = self.session.post(self.base_url, json=payload, headers=headers)

if response.status_code == 200:
return response.json().get("data")
else:
raise Exception(f"API request failed: {response.text}")
if response.status_code != 200:
raise OctopusEnergyApiError(f"API request failed: {response.text}")

body = response.json()

# GraphQL-APIs liefern bei fachlichen Fehlern trotzdem HTTP 200,
# aber ein "errors"-Array statt (oder zusätzlich zu) "data".
if body.get("errors"):
error_messages = "; ".join(
err.get("message", str(err)) for err in body["errors"]
)
raise OctopusEnergyApiError(f"GraphQL-Fehler: {error_messages}")

data = body.get("data")
if data is None:
raise OctopusEnergyApiError("Antwort enthält kein 'data'-Feld: " + str(body)[:500])

return data

def authenticate(self, email: str, password: str):
"""Authenticate and store the token."""
"""Authentifiziert und speichert den Token."""
mutation = """
mutation krakenTokenAuthentication($email: String!, $password: String!) {
obtainKrakenToken(input: {email: $email, password: $password}) {
Expand All @@ -49,13 +74,13 @@ def authenticate(self, email: str, password: str):
variables = {"email": email, "password": password}
data = self._graphql_request(mutation, variables)

if data and "obtainKrakenToken" in data:
self.token = data["obtainKrakenToken"]["token"]
else:
raise Exception("Authentication failed")
if "obtainKrakenToken" not in data or data["obtainKrakenToken"] is None:
raise OctopusEnergyApiError("Authentifizierung fehlgeschlagen: Kein Token in der Antwort enthalten.")

def get_property_ids(self, account_number: str):
"""Retrieve property IDs for a given account."""
self.token = data["obtainKrakenToken"]["token"]

def get_property_ids(self, account_number: str) -> dict:
"""Ruft die Property-IDs für einen Account ab."""
query = """
query getPropertyIds($accountNumber: String!) {
account(accountNumber: $accountNumber) {
Expand All @@ -72,8 +97,8 @@ def get_property_ids(self, account_number: str):
variables = {"accountNumber": account_number}
return self._graphql_request(query, variables)

def get_smart_meter_usage(self, account_number: str, property_id: str):
"""Retrieve tariff and usage information for a property."""
def get_smart_meter_usage(self, account_number: str, property_id: str) -> dict:
"""Ruft Tarif- und Verbrauchsinformationen für eine Property ab."""
query = """
query getSmartMeterUsage($accountNumber: String!, $propertyId: ID!) {
account(accountNumber: $accountNumber) {
Expand Down Expand Up @@ -156,13 +181,23 @@ def process_agreement(agreement: dict, hour_time_utc: datetime, prices: Dict[str
prices[timestamp] = rate


def build_tariff_state(data) -> Dict[str, float]:
def build_tariff_state(data: dict) -> Dict[str, float]:
current_utc = datetime.now(timezone.utc)
prices: Dict[str, float] = {}

property_data = data.get('account', {}).get('property')
if not property_data:
raise OctopusEnergyApiError("Keine Property-Daten in der Antwort enthalten.")

malos = property_data.get('electricityMalos')
if not malos:
error_message = "Kein electricityMalos-Eintrag in der Antwort enthalten " \
"(Zählpunkt evtl. noch nicht aktiv/verknüpft)."
raise OctopusEnergyApiError(error_message)

for hour in range(28):
hour_time_utc = current_utc + timedelta(hours=hour)
for agreement in data['account']['property']['electricityMalos'][0]['agreements']:
for agreement in malos[0]['agreements']:
process_agreement(agreement, hour_time_utc, prices)

sorted_prices = dict(sorted(prices.items()))
Expand All @@ -171,8 +206,13 @@ def build_tariff_state(data) -> Dict[str, float]:

def fetch(config: OctopusEnergyTariffConfiguration) -> TariffState:
client = OctopusEnergyClient(email=config.email, password=config.password)

property_data = client.get_property_ids(config.accountId)
property_id = property_data["account"]["properties"][0]["id"]
properties = property_data.get('account', {}).get('properties')
if not properties:
raise OctopusEnergyApiError(f"Kein Property zum Account {config.accountId} gefunden.")
property_id = properties[0]['id']

tariffs = client.get_smart_meter_usage(config.accountId, property_id)
prices = build_tariff_state(tariffs)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@

from datetime import datetime, timezone
from typing import Dict
from unittest.mock import MagicMock

import pytest

from modules.electricity_pricing.flexible_tariffs.octopusenergy import tariff
from modules.electricity_pricing.flexible_tariffs.octopusenergy.tariff import build_tariff_state
from modules.electricity_pricing.flexible_tariffs.octopusenergy.tariff import build_tariff_state, OctopusEnergyApiError


TEST_DATA = {'data': {'account': {'property': {'electricityMalos': [
Expand Down Expand Up @@ -106,3 +105,62 @@ def test_build_tariff_state(now: datetime, expected_prices: Dict[str, float], mo

# assert
assert prices == expected_prices


# --- Zusätzliche Tests für die Fehlerbehandlung bei fehlenden/kaputten API-Daten ---

def test_build_tariff_state_raises_on_missing_property():
# setup: 'property' ist None, z.B. wenn die API keine gültige Property zurückgibt
data = {'account': {'property': None}}

# execution & assert
with pytest.raises(OctopusEnergyApiError, match="Property"):
build_tariff_state(data)


def test_build_tariff_state_raises_on_missing_account():
# setup: 'account' fehlt komplett in der Antwort
data = {}

# execution & assert
with pytest.raises(OctopusEnergyApiError, match="Property"):
build_tariff_state(data)


def test_build_tariff_state_raises_on_empty_malos():
# setup: 'electricityMalos' ist leer, z.B. Zählpunkt noch nicht verknüpft
data = {'account': {'property': {'electricityMalos': []}}}

# execution & assert
with pytest.raises(OctopusEnergyApiError, match="electricityMalos"):
build_tariff_state(data)


def test_graphql_request_raises_on_graphql_errors(monkeypatch):
# setup: HTTP 200, aber GraphQL liefert ein 'errors'-Array statt 'data'
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"errors": [{"message": "Token invalid or expired"}]
}
mock_session = MagicMock()
mock_session.post.return_value = mock_response
monkeypatch.setattr(tariff.req, "get_http_session", lambda: mock_session)

# execution & assert: Authentifizierung im Konstruktor schlägt fehl
with pytest.raises(OctopusEnergyApiError, match="Token invalid or expired"):
tariff.OctopusEnergyClient(email="test@example.com", password="secret")


def test_graphql_request_raises_on_missing_data_field(monkeypatch):
# setup: HTTP 200, aber weder 'data' noch 'errors' im Body (unerwartetes Format)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {}
mock_session = MagicMock()
mock_session.post.return_value = mock_response
monkeypatch.setattr(tariff.req, "get_http_session", lambda: mock_session)

# execution & assert
with pytest.raises(OctopusEnergyApiError, match="data"):
tariff.OctopusEnergyClient(email="test@example.com", password="secret")