Skip to content

Commit

Permalink
Add an opinionated client implementation
Browse files Browse the repository at this point in the history
Though it's nice to have a raw restful client, it's better to
have something that exposes a class model. This is that client,
focussed towards the type of calls useful for energy consumers.

This is just the start, and has a method to simply return the
meters attached to an account as a list of python objects.

This also drops support for python 3.7
  • Loading branch information
markallanson committed Jan 23, 2021
1 parent 6505c61 commit 8497dbf
Show file tree
Hide file tree
Showing 19 changed files with 1,163 additions and 531 deletions.
4 changes: 1 addition & 3 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ dist: focal
matrix:
fast_finish: true
include:
- python: '3.7'
env: TOXENV=py37
- python: '3.8'
env: TOXENV=py38
- python: '3.9'
Expand All @@ -28,7 +26,7 @@ deploy:
tags: true
branch: main
repo: markallanson/octopus-energy
python: '3.7'
python: '3.8'
skip_existing: true
env:
global:
Expand Down
32 changes: 26 additions & 6 deletions octopus_energy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
"""Python client for the Octopus Energy RESTful API"""

from .rest_client import OctopusEnergyRestClient
from .models import (
Address,
Aggregate,
Consumption,
EnergyType,
EnergyTariffType,
IntervalConsumption,
MeterType,
UnitType,
Aggregate,
SortOrder,
Meter,
MeterDirection,
MeterGeneration,
MeterPoint,
RateType,
SortOrder,
Tariff,
UnitType,
ElectricityMeter,
GasMeter,
)
from .exceptions import ApiAuthenticationError, ApiError, ApiNotFoundError, ApiBadRequestError
from .rest_client import OctopusEnergyRestClient
from .client import OctopusEnergyConsumerClient

__all__ = [
"OctopusEnergyRestClient",
Expand All @@ -20,9 +30,19 @@
"ApiBadRequestError",
"Consumption",
"IntervalConsumption",
"MeterType",
"MeterGeneration",
"UnitType",
"Aggregate",
"SortOrder",
"RateType",
"Meter",
"EnergyType",
"EnergyTariffType",
"OctopusEnergyConsumerClient",
"Tariff",
"MeterDirection",
"MeterPoint",
"Address",
"ElectricityMeter",
"GasMeter",
]
51 changes: 51 additions & 0 deletions octopus_energy/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from typing import List

from .mappers import meters_from_response
from octopus_energy import Meter, OctopusEnergyRestClient


class OctopusEnergyConsumerClient:
"""An opinionated take on the consumer features of the Octopus Energy API.
This client uses python model classes instead of raw json-as-a-dict. It does not expose all of
the octopus energy API, only bits that are useful for a consumer of octopus energy products. It
focuses on consumption and prices, and blending the two of them together to provide useful
data for a consumer.
This client is useful if you want to know your own personal energy consumption statistics.
This client uses async i/o.
"""

def __init__(self, account_number: str, api_token: str):
"""Initializes the Octopus Energy Consumer Client.
Args:
account_number: Your Octopus Energy Account Number.
api_token: Your Octopus Energy API Key.
"""
self.account_number = account_number
self.rest_client = OctopusEnergyRestClient(api_token)

def __enter__(self):
raise TypeError("Use async context manager (async with) instead")

def __exit__(self, exc_type, exc_val, exc_tb):
pass

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()

async def close(self):
"""Clean up resources used by the client.
Once the client is closed, you cannot use it to make any further calls.
"""
await self.rest_client.close()

async def get_meters(self) -> List[Meter]:
"""Gets all meters associated with your account."""
return meters_from_response(await self.rest_client.get_account_details(self.account_number))
4 changes: 2 additions & 2 deletions octopus_energy/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ def __init__(self, response, message="") -> None:
self.message = message

def __str__(self) -> str:
return f"{self.response.status} - {self.response.text}"
return f"{self.response.status}: {self.response.text}"


class ApiAuthenticationError(Exception):
Expand All @@ -15,7 +15,7 @@ class ApiAuthenticationError(Exception):
pass


class ApiBadRequestError(Exception):
class ApiBadRequestError(ApiError):
"""Data posted to an API is incorrect. Typically the response code was 400."""

pass
Expand Down
114 changes: 96 additions & 18 deletions octopus_energy/mappers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
from typing import List, Optional

from datetime import datetime

import dateutil
from dateutil.parser import isoparse

from .models import IntervalConsumption, UnitType, Consumption, MeterType
from .models import (
IntervalConsumption,
UnitType,
Consumption,
ElectricityMeter,
MeterDirection,
EnergyType,
MeterGeneration,
GasMeter,
Tariff,
Meter,
Address,
MeterPoint,
)

_CUBIC_METERS_TO_KWH_MULTIPLIER = 11.1868
_KWH_TO_KWH_MULTIPLIER = 1
Expand All @@ -12,14 +28,88 @@
}


def to_timestamp_str(timestamp: datetime) -> str:
"""Convert a datetime to an iso timestamp string expected by the octopus energy APIs.
Args:
timestamp: The timestamp to convert.
Returns:
The timestamp in a iso format required by the octopus energy APIs
"""
return timestamp.replace(microsecond=0).isoformat() if timestamp else None


def from_timestamp_str(timestamp: str) -> Optional[datetime]:
"""Convert am Octopus Energy timestamp string to an datetime.
Args:
timestamp: The timestamp to convert.
Returns:
The timestamp as a datetime object.
"""
return dateutil.parser.isoparse(timestamp) if timestamp else None


def _map_tariffs(input_tariffs: List[dict]) -> List[Tariff]:
return [
Tariff(
t["tariff_code"], from_timestamp_str(t["valid_from"]), from_timestamp_str(t["valid_to"])
)
for t in input_tariffs
]


def meters_from_response(response: dict) -> List[Meter]:
meters = []
for property_ in response["properties"]:
address = Address(
property_.get("address_line_1", None),
property_.get("address_line_2", None),
property_.get("address_line_3", None),
property_.get("county", None),
property_.get("town", None),
property_.get("postcode", None),
# the property is active if it has no moved out date
property_.get("moved_out_at", None) is None,
)
for meter_point in property_.get("electricity_meter_points", []):
for meter in meter_point["meters"]:
meters.append(
ElectricityMeter(
meter_point=MeterPoint(meter_point["mpan"], address),
serial_number=meter["serial_number"],
tariffs=_map_tariffs(meter_point["agreements"]),
direction=MeterDirection.EXPORT
if meter_point["is_export"]
else MeterDirection.IMPORT,
energy_type=EnergyType.ELECTRICITY,
generation=MeterGeneration.SMETS1_ELECTRICITY,
)
)
for meter_point in property_.get("gas_meter_points", []):
for meter in meter_point["meters"]:
meters.append(
GasMeter(
meter_point=MeterPoint(meter_point["mprn"], address),
serial_number=meter["serial_number"],
tariffs=_map_tariffs(meter_point["agreements"]),
energy_type=EnergyType.GAS,
generation=MeterGeneration.SMETS1_GAS,
)
)
return meters


def consumption_from_response(
response: dict, meter_type: MeterType, desired_unit_type: UnitType
response: dict, meter: Meter, desired_unit_type: UnitType
) -> Consumption:
"""Generates the Consumption model from an octopus energy API response.
Args:
response: The API response object.
meter_type: The type of meter the reading is from.
meter: The meter the consumption is related to.
desired_unit_type: The desired unit for the consumption intervals. The mapping will
convert from the meters units to the desired units.
Expand All @@ -28,14 +118,14 @@ def consumption_from_response(
"""
if "results" not in response:
return Consumption(unit_type=desired_unit_type, meter_type=meter_type)
return Consumption(unit_type=desired_unit_type, meter=meter)
return Consumption(
desired_unit_type,
meter_type,
meter,
[
IntervalConsumption(
consumed_units=_calculate_unit(
result["consumption"], meter_type.unit_type, desired_unit_type
result["consumption"], meter.generation.unit_type, desired_unit_type
),
interval_start=isoparse(result["interval_start"]),
interval_end=isoparse(result["interval_end"]),
Expand All @@ -45,18 +135,6 @@ def consumption_from_response(
)


def to_timestamp_str(timestamp: datetime) -> str:
"""Convert a datetime to an iso timestamp string expected by the octopus energy APIs.
Args:
timestamp: The timestamp to convert.
Returns:
The timestamp in a iso format required by the octopus energy APIs
"""
return timestamp.replace(microsecond=0).isoformat() if timestamp else None


def _calculate_unit(consumption, actual_unit, desired_unit):
"""Converts unit values from one unit to another unit.
Expand Down
Loading

0 comments on commit 8497dbf

Please sign in to comment.