From 9964141e71742d9f4c0103ff43f1635845235f7e Mon Sep 17 00:00:00 2001 From: MartinRinas Date: Wed, 12 Feb 2025 21:29:15 +0000 Subject: [PATCH 1/6] Add Octopus Energy tariff client and configuration classes --- .../octopusenergy/config.py | 18 ++ .../octopusenergy/tariff.py | 164 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 packages/modules/electricity_tariffs/octopusenergy/config.py create mode 100644 packages/modules/electricity_tariffs/octopusenergy/tariff.py diff --git a/packages/modules/electricity_tariffs/octopusenergy/config.py b/packages/modules/electricity_tariffs/octopusenergy/config.py new file mode 100644 index 0000000000..297f5d27f7 --- /dev/null +++ b/packages/modules/electricity_tariffs/octopusenergy/config.py @@ -0,0 +1,18 @@ +from typing import Optional + + +class OctopusEnergyTariffConfiguration: + def __init__(self, email: Optional[str] = None, accountId: Optional[str] = None, password: Optional[str] = None): + self.email = email + self.accountId = accountId + self.password = password + + +class OctopusEnergyTariff: + def __init__(self, + name: str = "Octopus Energy Deutschland", + type: str = "octopusenergy", + configuration: OctopusEnergyTariffConfiguration = None) -> None: + self.name = name + self.type = type + self.configuration = configuration or OctopusEnergyTariffConfiguration() diff --git a/packages/modules/electricity_tariffs/octopusenergy/tariff.py b/packages/modules/electricity_tariffs/octopusenergy/tariff.py new file mode 100644 index 0000000000..382d5e28a9 --- /dev/null +++ b/packages/modules/electricity_tariffs/octopusenergy/tariff.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +import logging + +from modules.electricity_tariffs.octopusenergy.config import OctopusEnergyTariffConfiguration, OctopusEnergyTariff +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import TariffState +from typing import Dict +from datetime import datetime, timedelta, timezone + +log = logging.getLogger(__name__) + + +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.""" + headers = { + "Authorization": f"{self.token}" if self.token else "", + "Content-Type": "application/json" + } + payload = {"query": query, "variables": variables} + + 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}") + + def authenticate(self, email: str, password: str): + """Authenticate and store the token.""" + mutation = """ + mutation krakenTokenAuthentication($email: String!, $password: String!) { + obtainKrakenToken(input: {email: $email, password: $password}) { + token + } + } + """ + 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") + + def get_property_ids(self, account_number: str): + """Retrieve property IDs for a given account.""" + query = """ + query getPropertyIds($accountNumber: String!) { + account(accountNumber: $accountNumber) { + properties { + id + occupancyPeriods { + effectiveFrom + effectiveTo + } + } + } + } + """ + 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.""" + query = """ + query getSmartMeterUsage($accountNumber: String!, $propertyId: ID!) { + account(accountNumber: $accountNumber) { + property(id: $propertyId) { + electricityMalos { + agreements { + id + unitRateInformation { + ... on SimpleProductUnitRateInformation { + __typename + latestGrossUnitRateCentsPerKwh + } + ... on TimeOfUseProductUnitRateInformation { + __typename + rates { + latestGrossUnitRateCentsPerKwh + timeslotName + timeslotActivationRules { + activeFromTime + activeToTime + } + } + } + } + validFrom + validTo + } + } + } + } + } + """ + variables = {"accountNumber": account_number, "propertyId": property_id} + return self._graphql_request(query, variables) + + +def build_tariff_state(data) -> Dict[str, float]: + current_time = datetime.now(timezone.utc) + prices: Dict[str, float] = {} + + for hour in range(24): + hour_time = current_time + timedelta(hours=hour) + for agreement in data['account']['property']['electricityMalos'][0]['agreements']: + valid_from = datetime.fromisoformat(agreement['validFrom'].replace('Z', '+00:00')) + valid_to = datetime.fromisoformat(agreement['validTo'].replace('Z', '+00:00')) + + if valid_from <= hour_time <= valid_to: + unit_rate_info = agreement['unitRateInformation'] + if unit_rate_info['__typename'] == 'SimpleProductUnitRateInformation': + rate = float(unit_rate_info['latestGrossUnitRateCentsPerKwh'])/100/1000 + timestamp = str(int(hour_time.replace(minute=0, second=0, microsecond=0).timestamp())) + prices[timestamp] = rate + elif unit_rate_info['__typename'] == 'TimeOfUseProductUnitRateInformation': + for rate_info in unit_rate_info['rates']: + active_from = datetime.strptime( + rate_info['timeslotActivationRules'][0]['activeFromTime'], '%H:%M:%S' + ).time() + active_to = datetime.strptime( + rate_info['timeslotActivationRules'][0]['activeToTime'], '%H:%M:%S' + ).time() + if active_from <= hour_time.time() < active_to or (active_to == datetime.min.time() + and hour_time.time() >= active_from): + timestamp = str(int(hour_time.replace(minute=0, second=0, microsecond=0).timestamp())) + prices[timestamp] = float(rate_info['latestGrossUnitRateCentsPerKwh'])/100/1000 + break + + sorted_prices = dict(sorted(prices.items())) + return sorted_prices + + +def fetch(config: OctopusEnergyTariffConfiguration) -> TariffState: + # request prices + # call OctopusEnergyClient with the email and password from the config + client = OctopusEnergyClient(email=config.email, password=config.password) + property_data = client.get_property_ids(config.accountId) + property_id = property_data["account"]["properties"][0]["id"] + log.debug("Property IDs: %s", property_data) + tariffs = client.get_smart_meter_usage(config.accountId, property_id) + log.debug("Tariff Info: %s", tariffs) + prices = build_tariff_state(tariffs) + log.debug("Prices: %s", prices) + + return TariffState(prices=prices) + + +def create_electricity_tariff(config: OctopusEnergyTariff): + def updater(): + return fetch(config.configuration) + return updater + + +device_descriptor = DeviceDescriptor(configuration_factory=OctopusEnergyTariff) From 32bdcca0212c23a745401afad1b1e2f2906db338 Mon Sep 17 00:00:00 2001 From: MartinRinas Date: Wed, 12 Feb 2025 21:40:00 +0000 Subject: [PATCH 2/6] remove debug logs --- packages/modules/electricity_tariffs/octopusenergy/tariff.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/modules/electricity_tariffs/octopusenergy/tariff.py b/packages/modules/electricity_tariffs/octopusenergy/tariff.py index 382d5e28a9..f07cb0ac61 100644 --- a/packages/modules/electricity_tariffs/octopusenergy/tariff.py +++ b/packages/modules/electricity_tariffs/octopusenergy/tariff.py @@ -141,16 +141,11 @@ def build_tariff_state(data) -> Dict[str, float]: def fetch(config: OctopusEnergyTariffConfiguration) -> TariffState: - # request prices - # call OctopusEnergyClient with the email and password from the config client = OctopusEnergyClient(email=config.email, password=config.password) property_data = client.get_property_ids(config.accountId) property_id = property_data["account"]["properties"][0]["id"] - log.debug("Property IDs: %s", property_data) tariffs = client.get_smart_meter_usage(config.accountId, property_id) - log.debug("Tariff Info: %s", tariffs) prices = build_tariff_state(tariffs) - log.debug("Prices: %s", prices) return TariffState(prices=prices) From 37e09780e1f2ad5ecbf3c714369fdcec51f406b7 Mon Sep 17 00:00:00 2001 From: MartinRinas Date: Thu, 13 Feb 2025 07:21:15 +0000 Subject: [PATCH 3/6] refactor for readability --- .../octopusenergy/tariff.py | 59 +++++++++++-------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/modules/electricity_tariffs/octopusenergy/tariff.py b/packages/modules/electricity_tariffs/octopusenergy/tariff.py index f07cb0ac61..487c89a29c 100644 --- a/packages/modules/electricity_tariffs/octopusenergy/tariff.py +++ b/packages/modules/electricity_tariffs/octopusenergy/tariff.py @@ -106,6 +106,40 @@ def get_smart_meter_usage(self, account_number: str, property_id: str): return self._graphql_request(query, variables) +def parse_datetime(datetime_str: str) -> datetime: + return datetime.fromisoformat(datetime_str.replace('Z', '+00:00')) + + +def get_rate_from_simple_product(unit_rate_info: dict) -> float: + return float(unit_rate_info['latestGrossUnitRateCentsPerKwh']) / 100 / 1000 + + +def get_rate_from_time_of_use_product(unit_rate_info: dict, hour_time: datetime) -> float: + for rate_info in unit_rate_info['rates']: + active_from = datetime.strptime(rate_info['timeslotActivationRules'][0]['activeFromTime'], '%H:%M:%S').time() + active_to = datetime.strptime(rate_info['timeslotActivationRules'][0]['activeToTime'], '%H:%M:%S').time() + if active_from <= hour_time.time() < active_to or ( + active_to == datetime.min.time() and hour_time.time() >= active_from): + return float(rate_info['latestGrossUnitRateCentsPerKwh']) / 100 / 1000 + return None + + +def process_agreement(agreement: dict, hour_time: datetime, prices: Dict[str, float]): + valid_from = parse_datetime(agreement['validFrom']) + valid_to = parse_datetime(agreement['validTo']) + + if valid_from <= hour_time <= valid_to: + unit_rate_info = agreement['unitRateInformation'] + timestamp = str(int(hour_time.replace(minute=0, second=0, microsecond=0).timestamp())) + + if unit_rate_info['__typename'] == 'SimpleProductUnitRateInformation': + prices[timestamp] = get_rate_from_simple_product(unit_rate_info) + elif unit_rate_info['__typename'] == 'TimeOfUseProductUnitRateInformation': + rate = get_rate_from_time_of_use_product(unit_rate_info, hour_time) + if rate is not None: + prices[timestamp] = rate + + def build_tariff_state(data) -> Dict[str, float]: current_time = datetime.now(timezone.utc) prices: Dict[str, float] = {} @@ -113,28 +147,7 @@ def build_tariff_state(data) -> Dict[str, float]: for hour in range(24): hour_time = current_time + timedelta(hours=hour) for agreement in data['account']['property']['electricityMalos'][0]['agreements']: - valid_from = datetime.fromisoformat(agreement['validFrom'].replace('Z', '+00:00')) - valid_to = datetime.fromisoformat(agreement['validTo'].replace('Z', '+00:00')) - - if valid_from <= hour_time <= valid_to: - unit_rate_info = agreement['unitRateInformation'] - if unit_rate_info['__typename'] == 'SimpleProductUnitRateInformation': - rate = float(unit_rate_info['latestGrossUnitRateCentsPerKwh'])/100/1000 - timestamp = str(int(hour_time.replace(minute=0, second=0, microsecond=0).timestamp())) - prices[timestamp] = rate - elif unit_rate_info['__typename'] == 'TimeOfUseProductUnitRateInformation': - for rate_info in unit_rate_info['rates']: - active_from = datetime.strptime( - rate_info['timeslotActivationRules'][0]['activeFromTime'], '%H:%M:%S' - ).time() - active_to = datetime.strptime( - rate_info['timeslotActivationRules'][0]['activeToTime'], '%H:%M:%S' - ).time() - if active_from <= hour_time.time() < active_to or (active_to == datetime.min.time() - and hour_time.time() >= active_from): - timestamp = str(int(hour_time.replace(minute=0, second=0, microsecond=0).timestamp())) - prices[timestamp] = float(rate_info['latestGrossUnitRateCentsPerKwh'])/100/1000 - break + process_agreement(agreement, hour_time, prices) sorted_prices = dict(sorted(prices.items())) return sorted_prices @@ -150,7 +163,7 @@ def fetch(config: OctopusEnergyTariffConfiguration) -> TariffState: return TariffState(prices=prices) -def create_electricity_tariff(config: OctopusEnergyTariff): +def create_electricity_tariff(config: OctopusEnergyTariff) -> callable: def updater(): return fetch(config.configuration) return updater From cdf78dea35c9773a88d368740d85f3a2b92194f5 Mon Sep 17 00:00:00 2001 From: MartinRinas Date: Thu, 13 Feb 2025 11:06:49 +0000 Subject: [PATCH 4/6] refactor to improve readability --- packages/modules/electricity_tariffs/octopusenergy/config.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/modules/electricity_tariffs/octopusenergy/config.py b/packages/modules/electricity_tariffs/octopusenergy/config.py index 297f5d27f7..00d75619b8 100644 --- a/packages/modules/electricity_tariffs/octopusenergy/config.py +++ b/packages/modules/electricity_tariffs/octopusenergy/config.py @@ -2,7 +2,10 @@ class OctopusEnergyTariffConfiguration: - def __init__(self, email: Optional[str] = None, accountId: Optional[str] = None, password: Optional[str] = None): + def __init__(self, + email: Optional[str] = None, + accountId: Optional[str] = None, + password: Optional[str] = None): self.email = email self.accountId = accountId self.password = password From 243b7a2e3773fcff6bd7a66429f99953592c0ed5 Mon Sep 17 00:00:00 2001 From: MartinRinas Date: Sat, 1 Mar 2025 21:49:16 +0000 Subject: [PATCH 5/6] fix timezone handling --- packages/modules/electricity_tariffs/octopusenergy/tariff.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/modules/electricity_tariffs/octopusenergy/tariff.py b/packages/modules/electricity_tariffs/octopusenergy/tariff.py index 487c89a29c..362f478e8b 100644 --- a/packages/modules/electricity_tariffs/octopusenergy/tariff.py +++ b/packages/modules/electricity_tariffs/octopusenergy/tariff.py @@ -118,7 +118,8 @@ def get_rate_from_time_of_use_product(unit_rate_info: dict, hour_time: datetime) for rate_info in unit_rate_info['rates']: active_from = datetime.strptime(rate_info['timeslotActivationRules'][0]['activeFromTime'], '%H:%M:%S').time() active_to = datetime.strptime(rate_info['timeslotActivationRules'][0]['activeToTime'], '%H:%M:%S').time() - if active_from <= hour_time.time() < active_to or ( + local_hour_time = hour_time.astimezone().time() # hour_time is UTC, time of use returns local time + if active_from <= local_hour_time < active_to or ( active_to == datetime.min.time() and hour_time.time() >= active_from): return float(rate_info['latestGrossUnitRateCentsPerKwh']) / 100 / 1000 return None @@ -131,12 +132,12 @@ def process_agreement(agreement: dict, hour_time: datetime, prices: Dict[str, fl if valid_from <= hour_time <= valid_to: unit_rate_info = agreement['unitRateInformation'] timestamp = str(int(hour_time.replace(minute=0, second=0, microsecond=0).timestamp())) - if unit_rate_info['__typename'] == 'SimpleProductUnitRateInformation': prices[timestamp] = get_rate_from_simple_product(unit_rate_info) elif unit_rate_info['__typename'] == 'TimeOfUseProductUnitRateInformation': rate = get_rate_from_time_of_use_product(unit_rate_info, hour_time) if rate is not None: + log.debug(f"Adding rate: {rate} for timestamp: {timestamp} with hour_time: {hour_time}") prices[timestamp] = rate From dae0e2ddb2a90a553440c6aabcc4dfbbb8f0a214 Mon Sep 17 00:00:00 2001 From: MartinRinas Date: Sun, 2 Mar 2025 11:03:14 +0000 Subject: [PATCH 6/6] get tariffs for next 28hrs --- packages/modules/electricity_tariffs/octopusenergy/tariff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/electricity_tariffs/octopusenergy/tariff.py b/packages/modules/electricity_tariffs/octopusenergy/tariff.py index 362f478e8b..c5a742ed4b 100644 --- a/packages/modules/electricity_tariffs/octopusenergy/tariff.py +++ b/packages/modules/electricity_tariffs/octopusenergy/tariff.py @@ -145,7 +145,7 @@ def build_tariff_state(data) -> Dict[str, float]: current_time = datetime.now(timezone.utc) prices: Dict[str, float] = {} - for hour in range(24): + for hour in range(28): hour_time = current_time + timedelta(hours=hour) for agreement in data['account']['property']['electricityMalos'][0]['agreements']: process_agreement(agreement, hour_time, prices)