diff --git a/AuroraPlus/__init__.py b/AuroraPlus/__init__.py deleted file mode 100644 index 9f038d6..0000000 --- a/AuroraPlus/__init__.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Abstraction of the Aurora+ API""" -import requests -from requests.adapters import HTTPAdapter -from requests.exceptions import Timeout - -class api: - - def __init__(self, username, password): - self.Error = None - self.token = None - self.url = 'https://api.auroraenergy.com.au/api' - 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' }) - self.session = session - - """Try to get token, retry if failed""" - self.gettoken(username, password) - - - def gettoken(self, username, password): - """Get access token""" - try: - token = self.session.post(self.url+'/identity/login',data={'username': username, 'password': password}, timeout=(6)) - - if (token.status_code == 200): - tokenjson = token.json() - self.token = tokenjson['accessToken'] - - """Get CustomerID and ServiceAgreementID""" - current = self.session.get(self.url+'/customers/current',headers={'Authorization': self.token}).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' - else: - self.Error = 'Token request failed: ' + token.reason - except Timeout: - self.Error = 'Token request timed out' - - def request(self, timespan): - try: - request = self.session.get(self.url + '/usage/' + timespan +'?serviceAgreementID=' + self.serviceAgreementID + '&customerId=' + self.customerId + '&index=-1', headers={'Authorization': self.token}) - if (request.status_code == 200): - return request.json() - else: - self.Error = 'Data request failed: ' + request.reason - except Timeout: - self.Error = 'Data request timed out' - - def getsummary(self): - summarydata = self.request("day") - self.DollarValueUsage = summarydata['SummaryTotals']['DollarValueUsage'] - self.KilowattHourUsage = summarydata['SummaryTotals']['KilowattHourUsage'] - - def getday(self): - self.day = self.request("day") - - def getweek(self): - self.week = self.request("week") - - def getmonth(self): - self.month = self.request("month") - - def getquarter(self): - self.quarter = self.request("quarter") - - def getyear(self): - self.year = self.request("year") - - def getcurrent(self): - try: - """Request current customer data""" - current = self.session.get(self.url+'/customers/current',headers={'Authorization': self.token}) - - if (current.status_code == 200): - currentjson = current.json()[0] - - """Loop through premises to match serviceAgreementID already found in token request""" - 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' - else: - self.Error = 'Current request failed: ' + current.reason - except Timeout: - self.Error = 'Current request timed out' \ No newline at end of file diff --git a/auroraplus/__init__.py b/auroraplus/__init__.py new file mode 100644 index 0000000..eb42fa0 --- /dev/null +++ b/auroraplus/__init__.py @@ -0,0 +1,340 @@ +"""Abstraction of the Aurora+ API""" +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' + + 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 + 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 = 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 + + if backward_compat and self.token: + self.get_info() + + def oauth_authorize(self) -> str: + """ + Start an OAuth Web Application authentication with Aurora Plus + + Returns: + -------- + + str: the URL to query interactively to authorize this session + + """ + 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 = 'Info request timed out' + + def request(self, timespan, index=-1): + if not self.serviceAgreementID: + self.get_info() + try: + request = self.session.get( + self.API_URL + + '/usage/' + + timespan + + '?serviceAgreementID=' + + self.serviceAgreementID + + '&customerId=' + + self.customerId + + '&index=' + + str(index) + ) + if (request.status_code == 200): + return request.json() + else: + self.Error = 'Data request failed: ' + request.reason + except Timeout: + 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'] + + def getday(self, index=-1): + self.day = self.request("day", index) + + def getweek(self, index=-1): + self.week = self.request("week", index) + + def getmonth(self, index=-1): + self.month = self.request("month", index) + + def getquarter(self, index=-1): + self.quarter = self.request("quarter", index) + + def getyear(self, index=-1): + self.year = self.request("year", index) + + def getcurrent(self): + try: + """Request current customer data""" + current = self.session.get( + self.API_URL + '/customers/current' + ) + + if (current.status_code == 200): + currentjson = current.json()[0] + + """Loop through premises to match serviceAgreementID already found in token request""" + 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' + else: + self.Error = 'Current request failed: ' + current.reason + except Timeout: + self.Error = 'Current request timed out' diff --git a/auroraplus/get_token.py b/auroraplus/get_token.py new file mode 100755 index 0000000..8f46d9a --- /dev/null +++ b/auroraplus/get_token.py @@ -0,0 +1,28 @@ +#!/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 9d5f797..31a594e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,7 @@ # Inside of setup.cfg [metadata] -description-file = README.md \ No newline at end of file +description_file = readme.md + +[options.entry_points] +console_scripts = + auroraplus_get_token = auroraplus:get_token diff --git a/setup.py b/setup.py index 0fee5aa..b2c00dd 100644 --- a/setup.py +++ b/setup.py @@ -2,122 +2,35 @@ 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', - ], -) \ No newline at end of file +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=[ + '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', + ], +)