diff --git a/auroraplus/__init__.py b/auroraplus/__init__.py index 804fb79..f4c7904 100644 --- a/auroraplus/__init__.py +++ b/auroraplus/__init__.py @@ -1,83 +1,302 @@ """Abstraction of the Aurora+ API""" -import requests + +import base64 +import hashlib +import json +import random +import string +import uuid + from requests.adapters import HTTPAdapter from requests.exceptions import Timeout +from requests_oauthlib import OAuth2Session + +from .get_token import get_token class api: + """ + A client to interact with the Aurora+ API. + + Obtaining a new OAuth token is done in two steps: + + >>> import auroraplus + >>> api = auroraplus.api() + >>> api.oauth_authorize() + 'https://customers.auroraenergy.com.au/...' + + After following the prompts, the URL of the (error) page that's returned should be passed as the redirect URI. + + >>> token = api.oauth_redirect('https://my.auroraenergy.com.au/login/redirect?state=...') + + The `api` object is now authenticated. + + + Data can then be fetched with + + >>> api.getcurrent() + >>> api.getday() + >>> api.getmonth() + >>> api.getquarter() + >>> api.getyear() + + and inspected in, e.g., + + >>> api.day + {'StartDate': '2023-12-25T13:00:00Z', 'EndDate': '2023-12-26T13:00:00Z', 'TimeMeasureCount': 1, ... + + """ + + USER_AGENT = "python/auroraplus" + + OAUTH_BASE_URL = ( + "https://customers.auroraenergy.com.au" + "/auroracustomers1p.onmicrosoft.com/b2c_1a_sign_in/" + ) + AUTHORIZE_URL = OAUTH_BASE_URL + "/oauth2/v2.0/authorize" + TOKEN_URL = OAUTH_BASE_URL + "/oauth2/v2.0/token" + CLIENT_ID = "2ff9da64-8629-4a92-a4b6-850a3f02053d" + REDIRECT_URI = "https://my.auroraenergy.com.au/login/redirect" - def __init__(self, username, password): + API_URL = "https://api.auroraenergy.com.au/api" + BEARER_TOKEN_URL = API_URL + "/identity/LoginToken" + + SCOPE = ["openid", "profile", "offline_access"] + + def __init__(self, username: str = None, password: str = None, token: dict = None): + """Initialise the API. + + An authenticated object can be recreated from a preexisting OAuth `token` with + + >>> api = auroraplus.api(token) + + + Alternatively, a backward compatible way exists to only pass the `access_token` + + >>> api = auroraplus.api(password=token['access_token']) + + Parameters: + ----------- + + username: str + Deprecated, kept for backward compatibility + + password: str + Deprecated, kept for backward compatibility. If passed with an empty + username and no token, the password will be use as a bearer + access_token. + + token : dict + A pre-established token. It should contain at least an access_token + and a token_type. + + """ self.Error = None - self.token = None - self.url = 'https://api.auroraenergy.com.au/api' + backward_compat = False + if not token and password and not username: + # Backward compatibility: if no username and no token, + # assume the password is a bearer access token + token = {"access_token": password, "token_type": "bearer"} + backward_compat = True + self.token = token api_adapter = HTTPAdapter(max_retries=2) """Create a session and perform all requests in the same session""" - session = requests.Session() - session.mount(self.url, api_adapter) - session.headers.update({'Accept': 'application/json', 'User-Agent': 'AuroraPlus.py', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive'}) + session = OAuth2Session( + self.CLIENT_ID, + redirect_uri=self.REDIRECT_URI, + scope=self.SCOPE, + token=token, + ) + session.mount(self.API_URL, api_adapter) + session.headers.update( + { + "Accept": "application/json", + "User-Agent": self.USER_AGENT, + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + } + ) self.session = session - self._username = username - self._password = password - """Try to get token, retry if failed""" - self.gettoken(username, password) + if backward_compat and self.token: + self.get_info() - def gettoken(self, username=None, password=None): - """Get access token""" - if not username: - username = self._username - if not password: - password = self._password - try: - token = self.session.post(self.url + '/identity/login', data={'username': username, 'password': password}, timeout=(6)) + def oauth_authorize(self) -> str: + """ + Start an OAuth Web Application authentication with Aurora Plus - if (token.status_code == 200): - tokenjson = token.json() - self.token = tokenjson['accessToken'] + Returns: + -------- - """Get CustomerID and ServiceAgreementID""" - current = self.session.get(self.url + '/customers/current', headers={'Authorization': self.token}).json()[0] - self.customerId = current['CustomerID'] + str: the URL to query interactively to authorize this session - """Loop through premises to get active """ - premises = current['Premises'] - for premise in premises: - if (premise['ServiceAgreementStatus'] == 'Active'): - self.Active = premise['ServiceAgreementStatus'] - self.serviceAgreementID = premise['ServiceAgreementID'] - if (self.Active != 'Active'): - self.Error = 'No active premise found' - else: - self.Error = 'Token request failed: ' + token.reason + """ + state = { + "id": str(uuid.uuid4()), + "meta": {"interactionType": "redirect"}, + } + + self.code_verifier = "".join( + [ + random.choice(string.ascii_letters + string.digits + "-_") + for i in range(43) + ] + ) + code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(self.code_verifier.encode()).digest() + ).strip(b"=") + + self.authorization_url, _ = self.session.authorization_url( + self.AUTHORIZE_URL, + client_request_id=uuid.uuid4(), + client_info=1, + code_challenge=code_challenge, + code_challenge_method="S256", + state=base64.encodebytes(json.dumps(state).encode()), + ) + + return self.authorization_url + + def oauth_redirect(self, authorization_response: str): + """ + Continue an OAuth Web Application authentication with Aurora Plus. + + Needs to be called after oauth_authorize. + + Parameters: + ----------- + + authorization_response: str + + The full URI of the response (error) page after authentication. + + Returns: + -------- + + dict: full token information + + """ + if not self.session.compliance_hook["access_token_response"]: + self.session.register_compliance_hook( + "access_token_response", self._include_access_token + ) + + return self.session.fetch_token( + self.TOKEN_URL, + authorization_response=authorization_response, + code_verifier=self.code_verifier, + ) + + def oauth_dump(self) -> dict: + """ + Export partial OAuth state, for use in asynchronous or request/response-based + workflows. + + Returns: + -------- + + dict: a dict of all the relevant state + """ + return { + "authorization_url": self.authorization_url, + "code_verifier": self.code_verifier, + } + + def oauth_load( + self, + authorization_url: str, + code_verifier: str, + ): + """ + Import partial OAuth state. + + Params: + ------- + + kwargs: pass the state dict returned from oauth_dump. + """ + self.authorization_url = authorization_url + self.code_verifier = code_verifier + + def _include_access_token(self, r) -> dict: + """ + OAuth compliance hook to fetch the bespoke LoginToken, + and present it as a standard access_token. + + Returns: + -------- + + dict: the full token + """ + rjs = r.json() + id_token = rjs.get("id_token") + + atr = self.session.post(self.BEARER_TOKEN_URL, json={"token": id_token}) + access_token = atr.json().get("accessToken") + + rjs.update( + { + "access_token": access_token.split()[1], + "scope": "openid profile offline_access", + } + ) + + r._content = json.dumps(rjs).encode() + + return r + + def gettoken(self, username=None, password=None): + """ + Deprecated, kept for backward compatibility + """ + self.get_info() + + def get_info(self): + """Get CustomerID and ServiceAgreementID""" + try: + r = self.session.get(self.API_URL + "/customers/current") + r.raise_for_status() + current = r.json()[0] + self.customerId = current["CustomerID"] + + """Loop through premises to get active """ + premises = current["Premises"] + for premise in premises: + if premise["ServiceAgreementStatus"] == "Active": + self.Active = premise["ServiceAgreementStatus"] + self.serviceAgreementID = premise["ServiceAgreementID"] + if self.Active != "Active": + self.Error = "No active premise found" except Timeout: - self.Error = 'Token request timed out' + self.Error = "Info request timed out" def request(self, timespan, index=-1): + if not self.serviceAgreementID: + self.get_info() try: request = self.session.get( - self.url - + '/usage/' + self.API_URL + + "/usage/" + timespan - + '?serviceAgreementID=' + + "?serviceAgreementID=" + self.serviceAgreementID - + '&customerId=' + + "&customerId=" + self.customerId - + '&index=' - + str(index), - headers={'Authorization': self.token} + + "&index=" + + str(index) ) - if (request.status_code == 200): + if request.status_code == 200: return request.json() else: - self.Error = 'Data request failed: ' + request.reason + self.Error = "Data request failed: " + request.reason except Timeout: - self.Error = 'Data request timed out' + self.Error = "Data request timed out" def getsummary(self, index=-1): summarydata = self.request("day", index) - self.DollarValueUsage = summarydata['SummaryTotals']['DollarValueUsage'] - self.KilowattHourUsage = summarydata['SummaryTotals']['KilowattHourUsage'] + self.DollarValueUsage = summarydata["SummaryTotals"]["DollarValueUsage"] + self.KilowattHourUsage = summarydata["SummaryTotals"]["KilowattHourUsage"] def getday(self, index=-1): self.day = self.request("day", index) @@ -97,29 +316,37 @@ def getyear(self, index=-1): def getcurrent(self): try: """Request current customer data""" - current = self.session.get(self.url + '/customers/current', headers={'Authorization': self.token}) + current = self.session.get(self.API_URL + "/customers/current") - if (current.status_code == 200): + if current.status_code == 200: currentjson = current.json()[0] """Loop through premises to match serviceAgreementID already found in token request""" - premises = currentjson['Premises'] - found = '' + premises = currentjson["Premises"] + found = "" for premise in premises: - if (premise['ServiceAgreementID'] == self.serviceAgreementID): - found = 'true' - self.AmountOwed = "{:.2f}".format(premise['AmountOwed']) - self.EstimatedBalance = "{:.2f}".format(premise['EstimatedBalance']) - self.AverageDailyUsage = "{:.2f}".format(premise['AverageDailyUsage']) - self.UsageDaysRemaining = premise['UsageDaysRemaining'] - self.ActualBalance = "{:.2f}".format(premise['ActualBalance']) - self.UnbilledAmount = "{:.2f}".format(premise['UnbilledAmount']) - self.BillTotalAmount = "{:.2f}".format(premise['BillTotalAmount']) - self.NumberOfUnpaidBills = premise['NumberOfUnpaidBills'] - self.BillOverDueAmount = "{:.2f}".format(premise['BillOverDueAmount']) - if (found != 'true'): - self.Error = 'ServiceAgreementID not found' + if premise["ServiceAgreementID"] == self.serviceAgreementID: + found = "true" + self.AmountOwed = "{:.2f}".format(premise["AmountOwed"]) + self.EstimatedBalance = "{:.2f}".format( + premise["EstimatedBalance"] + ) + self.AverageDailyUsage = "{:.2f}".format( + premise["AverageDailyUsage"] + ) + self.UsageDaysRemaining = premise["UsageDaysRemaining"] + self.ActualBalance = "{:.2f}".format(premise["ActualBalance"]) + self.UnbilledAmount = "{:.2f}".format(premise["UnbilledAmount"]) + self.BillTotalAmount = "{:.2f}".format( + premise["BillTotalAmount"] + ) + self.NumberOfUnpaidBills = premise["NumberOfUnpaidBills"] + self.BillOverDueAmount = "{:.2f}".format( + premise["BillOverDueAmount"] + ) + if found != "true": + self.Error = "ServiceAgreementID not found" else: - self.Error = 'Current request failed: ' + current.reason + self.Error = "Current request failed: " + current.reason except Timeout: - self.Error = 'Current request timed out' + self.Error = "Current request timed out" diff --git a/auroraplus/get_token.py b/auroraplus/get_token.py new file mode 100755 index 0000000..50ec7af --- /dev/null +++ b/auroraplus/get_token.py @@ -0,0 +1,30 @@ +#!/bin/env python + +import auroraplus + + +def get_token(): + api = auroraplus.api() + url = api.oauth_authorize() + + print( + "Please visit the following URL in a browser, " + "and follow the login prompts ...\n" + ) + print(url) + + print("\nThis will redirect to an error page (Cradle Mountain).\n") + + redirect_uri = input("Please enter the full URL of the error page: ") + + token = api.oauth_redirect(redirect_uri) + + print("\nThe new token is\n") + print(token) + + print("\n The access token is\n") + print(token["access_token"]) + + +if __name__ == "__main__": + get_token() diff --git a/readme.md b/readme.md index 9802551..2b0d01b 100644 --- a/readme.md +++ b/readme.md @@ -8,12 +8,45 @@ AuroraPlus is a package to pull data from https://api.auroraenergy.com.au/api. T ## Usage -Connect to Aurora+ API: +### Obtain a token + +The easieast way to obtain a new token is to use the `auroraplus_get_token` +script. + +To do this more programatically, obtaining a token is an interactive process +where the user needs to log on to the AuroraPlus web application. + + >>> import auroraplus + >>> api = auroraplus.api() + >>> api.oauth_authorize() + 'https://customers.auroraenergy.com.au/auroracustomers1p.onmicrosoft.com/b2c_1a_sign_in//oauth2/v2.0/authorize?response_type=code&client_id=2ff9da64-8629-4a92-a4b6-850a3f02053d&redirect_uri=https%3A%2F%2Fmy.auroraenergy.coom.au%2Flogin%2Fredirect&scope=openid+profile+offline_access&state=...&client_info=1' + +Follow the URL above in a browser to authenticate with username+password and +MFA. This will redirect to an error page (Cradle Mountain). Copy the full URL, +of the error page and continue. + + >>> api.oauth_redirect('https://my.auroraenergy.com.au/login/redirect?state=...') + {'id_token': 'ey...', 'access_token': 'ey...', 'token_type': 'bearer', ...} + +The `api` object is now ready to use (start with `api.get_info()`. The token +returned in the last step can be saved for later use when re-initialising the +`api` object without having to follow the OAuth flow to obtain a new +authorisation. + +### Connect to Aurora+ API with a pre-issued token import auroraplus - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") + AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) + AuroraPlus.get_info() + +For backward compatibility with users of the login/password method, the +`access_token` can be passed as the `password` if the `user` is empty. -To get current account information use the following: + import auroraplus + AuroraPlus = auroraplus.api(password=) + AuroraPlus.get_info() + +### Get current account information AuroraPlus.getcurrent() @@ -34,14 +67,15 @@ getcurrent() gets the following data: An example getting specific data with getcurrent: import auroraplus - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") + AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) + AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getcurrent() print(AuroraPlus.AmountOwed) else: print(AuroraPlus.Error) -To get summary usage information use the following: +### Get summary usage information AuroraPlus.getsummary() @@ -50,7 +84,8 @@ To get summary usage information use the following: An example getting specific data with getsummary: import auroraplus - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") + AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) + AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getsummary() print(AuroraPlus.DollarValueUsage['T41']) @@ -65,7 +100,9 @@ An example getting specific data with getsummary: Note: Offpeak tarrifs not listed -To get usage data use the following, this returns all available data in json format for each timespan: +### Get usage data use the following + +The following returns all available data in json format for each timespan: AuroraPlus.getday() AuroraPlus.getweek() @@ -75,7 +112,8 @@ To get usage data use the following, this returns all available data in json for Full example: - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") + AuroraPlus = auroraplus.api(token={"access_token": "...", "token_type": "bearer"}) + AuroraPlus.get_info() if (not AuroraPlus.Error): AuroraPlus.getcurrent() print(AuroraPlus.AmountOwed) diff --git a/setup.cfg b/setup.cfg index e0358b0..31a594e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,7 @@ # Inside of setup.cfg [metadata] -description_file = README.md +description_file = readme.md + +[options.entry_points] +console_scripts = + auroraplus_get_token = auroraplus:get_token diff --git a/setup.py b/setup.py index 97f63cf..5d4586e 100644 --- a/setup.py +++ b/setup.py @@ -2,122 +2,36 @@ from distutils.core import setup from os import path -setup( - name = 'auroraplus', - packages = ['auroraplus'], - version = '1.1.6', - license='MIT', - description = 'Python library to access the Aurora+ API: https://api.auroraenergy.com.au/api', - long_description="""AuroraPlus is a package to pull data from https://api.auroraenergy.com.au/api. To use the Aurora+ API you need a valid account with Aurora. - -## Requirements -- Install Python 3.9 (for all users) -- Pip install requests - -## Usage - -Connect to Aurora+ API: - - import auroraplus - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") - -To get current account information use the following: - - AuroraPlus.getcurrent() - -getcurrent() gets the following data: - - EstimatedBalance - This is shown in the Aurora+ app as 'Balance' - UsageDaysRemaining - This is shown in the Aurora+ app as 'Days Prepaid' - AverageDailyUsage - AmountOwed - ActualBalance - UnbilledAmount - BillTotalAmount - NumberOfUnpaidBills - BillOverDueAmount - - Note: All data except AverageDailyUsage is updated Daily. +from pathlib import Path +this_directory = Path(__file__).parent +long_description = (this_directory / "readme.md").read_text() -An example getting specific data with getcurrent: - - import auroraplus - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") - if (not AuroraPlus.Error): - AuroraPlus.getcurrent() - print(AuroraPlus.AmountOwed) - else: - print(AuroraPlus.Error) - -To get summary usage information use the following: - - AuroraPlus.getsummary() - - Note: This returns two collections, DollarValueUsage and KilowattHourUsage. - -An example getting specific data with getsummary: - - import auroraplus - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") - if (not AuroraPlus.Error): - AuroraPlus.getsummary() - print(AuroraPlus.DollarValueUsage['T41']) - print(AuroraPlus.DollarValueUsage['T31']) - print(AuroraPlus.DollarValueUsage['Other']) - print(AuroraPlus.DollarValueUsage['Total']) - print(AuroraPlus.KilowattHourUsage['T41']) - print(AuroraPlus.KilowattHourUsage['T31']) - print(AuroraPlus.KilowattHourUsage['Total']) - else: - print(AuroraPlus.Error) - - Note: Offpeak tarrifs not listed - -To get usage data use the following, this returns all available data in json format for each timespan: - - AuroraPlus.getday() - AuroraPlus.getweek() - AuroraPlus.getmonth() - AuroraPlus.getquarter() - AuroraPlus.getyear() - -Full example: - - AuroraPlus = auroraplus.api("user.name@outlook.com", "password") - if (not AuroraPlus.Error): - AuroraPlus.getcurrent() - print(AuroraPlus.AmountOwed) - - AuroraPlus.getday() - print(AuroraPlus.day) - - AuroraPlus.getweek() - print(AuroraPlus.week) - - AuroraPlus.getmonth() - print(AuroraPlus.month - - AuroraPlus.getyear() - print(AuroraPlus.year) - else: - print(AuroraPlus.Error)""", - long_description_content_type='text/markdown', - author = 'Leigh Curran', - author_email = 'AuroraPlusPy@outlook.com', - url = 'https://github.com/leighcurran/AuroraPlus', - keywords = ['Aurora+', 'AuroraPlus', 'Aurora', 'Tasmania', 'API'], - install_requires=[ - 'requests', - ], - classifiers=[ - 'Development Status :: 3 - Alpha', # Chose either "3 - Alpha", "4 - Beta" or "5 - Production/Stable" as the current state of your package - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Build Tools', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - ], +setup( + name='auroraplus', + packages=['auroraplus'], + version='1.1.6', + license='MIT', + description='Python library to access the Aurora+ API: https://api.auroraenergy.com.au/api', + long_description=long_description, + long_description_content_type='text/markdown', + author='Leigh Curran', + author_email='AuroraPlusPy@outlook.com', + url='https://github.com/leighcurran/AuroraPlus', + keywords=['Aurora+', 'AuroraPlus', 'Aurora', 'Tasmania', 'API'], + install_requires=[ + 'brotli', + 'requests', + 'requests_oauthlib', + ], + classifiers=[ + 'Development Status :: 3 - Alpha', # Chose either "3 - Alpha", "4 - Beta" or "5 - Production/Stable" as the current state of your package + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Build Tools', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + ], )