auroraplus 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
auroraplus/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ from .api import AuroraPlusApi, AuroraPlusAuthenticationError, AuroraPlusToken, api
2
+ from .get_token import get_token
3
+ from .repl import repl
4
+
5
+ __all__ = [
6
+ "AuroraPlusApi",
7
+ "AuroraPlusAuthenticationError",
8
+ "AuroraPlusToken",
9
+ "api",
10
+ "get_token",
11
+ "repl",
12
+ ]
auroraplus/api.py ADDED
@@ -0,0 +1,608 @@
1
+ """Abstraction of the Aurora+ API"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import random
10
+ import string
11
+ import uuid
12
+ from datetime import datetime
13
+ from typing import Any, ClassVar, TypeAlias
14
+ from warnings import deprecated
15
+
16
+ from requests import Response
17
+ from requests.adapters import HTTPAdapter
18
+ from requests.exceptions import HTTPError, Timeout
19
+ from requests_oauthlib import OAuth2Session
20
+
21
+ LOGGER = logging.getLogger(__name__)
22
+
23
+ AuroraPlusToken: TypeAlias = dict[str, Any]
24
+
25
+
26
+ class AuroraPlusAuthenticationError(Exception):
27
+ pass
28
+
29
+
30
+ class AuroraPlusApi:
31
+ """
32
+ A client to interact with the Aurora+ API.
33
+
34
+ Obtaining a new OAuth token is done in two steps:
35
+
36
+ >>> import auroraplus
37
+ >>> api = auroraplus.AuroraPlusApi()
38
+ >>> api.oauth_authorize()
39
+ 'https://customers.auroraenergy.com.au/...'
40
+
41
+ After following the prompts, the URL of the (error) page that's returned should be passed as the redirect URI.
42
+
43
+ >>> token = api.oauth_redirect('https://my.auroraenergy.com.au/login/redirect?state=...')
44
+
45
+ The `api` object is now authenticated.
46
+
47
+
48
+ Data can then be fetched with
49
+
50
+ >>> api.getcurrent()
51
+ >>> api.getday()
52
+ >>> api.getmonth()
53
+ >>> api.getquarter()
54
+ >>> api.getyear()
55
+
56
+ and inspected in, e.g.,
57
+
58
+ >>> api.day
59
+ {'StartDate': '2023-12-25T13:00:00Z', 'EndDate': '2023-12-26T13:00:00Z', 'TimeMeasureCount': 1, ...
60
+
61
+ Power Hour data can be obtained in a similar fashion
62
+
63
+ >>> api.getpowerhour()
64
+ >>> api.powerhour
65
+ [{'EventName': 'April Flash Event', 'PowerHourEventId': 92, 'StartDateTime': '2026-04-09T08:55:47', 'OfferExpiryDateTime': '2026-04-26T15:55:00', 'TimeslotAccepted': ...
66
+
67
+ """
68
+
69
+ USER_AGENT: str = "python/auroraplus"
70
+
71
+ OAUTH_BASE_URL: str = (
72
+ "https://customers.auroraenergy.com.au"
73
+ "/auroracustomers1p.onmicrosoft.com/b2c_1a_sign_in"
74
+ )
75
+ AUTHORIZE_URL: str = OAUTH_BASE_URL + "/oauth2/v2.0/authorize"
76
+ TOKEN_URL: str = OAUTH_BASE_URL + "/oauth2/v2.0/token"
77
+ CLIENT_ID: str = "2ff9da64-8629-4a92-a4b6-850a3f02053d"
78
+ REDIRECT_URI: str = "https://my.auroraenergy.com.au/login/redirect"
79
+
80
+ API_URL: str = "https://api.auroraenergy.com.au/api"
81
+ BEARER_TOKEN_URL: str = API_URL + "/identity/LoginToken"
82
+ BEARER_TOKEN_REFRESH_URL: str = API_URL + "/identity/refreshToken"
83
+ COOKIE_DOMAIN: str = "api.auroraenergy.com.au"
84
+
85
+ SCOPE: ClassVar[list[str]] = ["openid", "profile", "offline_access"]
86
+
87
+ session: OAuth2Session
88
+ token: AuroraPlusToken
89
+
90
+ Error: str | None = None
91
+
92
+ Active: str = ""
93
+ customerId: str = ""
94
+ premiseAddress: str = ""
95
+ serviceAgreementID: str = ""
96
+
97
+ day: dict[str, Any] | None = None
98
+ week: dict[str, Any] | None = None
99
+ month: dict[str, Any] | None = None
100
+ quarter: dict[str, Any] | None = None
101
+ year: dict[str, Any] | None = None
102
+
103
+ powerhour: list[dict] | None = None
104
+
105
+ DollarValueUsage: float | None = None
106
+ KilowattHourUsage: float | None = None
107
+ AmountOwed: str = ""
108
+ EstimatedBalance: str = ""
109
+ AverageDailyUsage: str = ""
110
+ UsageDaysRemaining: str = ""
111
+ ActualBalance: str = ""
112
+ UnbilledAmount: str = ""
113
+ BillTotalAmount: str = ""
114
+ NumberOfUnpaidBills: str = ""
115
+ BillOverDueAmount: str = ""
116
+
117
+ CurrentTimeOfUse: str | None = None
118
+ CurrentTimeOfUsePeriodEndDate: datetime | None = None
119
+ CurrentTimeOfUseType: str | None = None
120
+
121
+ _code_verifier: str = ""
122
+ _authorization_url: str = ""
123
+
124
+ def __init__(
125
+ self,
126
+ username: str | None = None,
127
+ password: str | None = None,
128
+ *,
129
+ token: AuroraPlusToken | None = None,
130
+ id_token: str | None = None,
131
+ access_token: str | None = None,
132
+ ):
133
+ """Initialise the API.
134
+
135
+ An authenticated object can be recreated from a preexisting OAuth `token` with
136
+
137
+ >>> api = auroraplus.api(token=token)
138
+
139
+ A bearer `access_token` can be provided for for one-off queries.
140
+
141
+ >>> api = auroraplus.api(access_token=token['access_token'])
142
+
143
+ For queries over a longer period of time (>24h), it is possible to pass
144
+ a recent `id_token`, and let the library handle refreshes.
145
+
146
+ >>> api = auroraplus.api(id_token=token['id_token'])
147
+
148
+ Backward-compatible ways exists to only pass the `access_token` or `id_token`,
149
+ as `password` and `username`, respectively>
150
+
151
+ >>> api = auroraplus.api(username=token['id_token'])
152
+ >>> api = auroraplus.api(password=token['access_token'])
153
+
154
+ Parameters:
155
+ -----------
156
+
157
+ username: str
158
+
159
+ Deprecated, kept for backward compatibility. If passed with an empty
160
+ `password` and no `token`, the `username` will be used as an `id_token`.
161
+
162
+ password: str
163
+
164
+ Deprecated, kept for backward compatibility. If passed with an empty
165
+ `username` and no `token`, the `password` will be used as a bearer
166
+ `access_token`.
167
+
168
+ token : AuroraPlusToken
169
+
170
+ A pre-established token. For one-off use, it should contain at least an
171
+ `access_token` and a `token_type`. For use over 24h, the `RefreshToken`,
172
+ cookie obtainable by presenting the `id_token` to the `LoginToken` endpoint, should be
173
+ present as `cookie_RefreshToken`. Any token obtain using the `oauth_*` methods
174
+ will contain the necessary information.
175
+
176
+ access_token : str
177
+
178
+ Used for short-lived (<24h) sessions.
179
+
180
+ id_token : str
181
+
182
+ Used to retrieve an `access_token` and an `cookie_RefreshToken` for
183
+ longer-lived sessions.
184
+
185
+ """
186
+ self.Error = None
187
+ backward_compat = False
188
+ if not token:
189
+ if password and not username:
190
+ # Backward compatibility: if no username and no token,
191
+ # assume the password is a bearer access token
192
+ LOGGER.warning(
193
+ "received deprecated password only, assuming access_token..."
194
+ )
195
+ access_token = password
196
+ backward_compat = True
197
+ elif username and not password:
198
+ # Backward compatibility to pass a full token as the first, and only, argument.
199
+ LOGGER.warning(
200
+ "received deprecated username only, assuming id_token..."
201
+ )
202
+ id_token = username
203
+
204
+ token = token or {}
205
+
206
+ if access_token:
207
+ token["access_token"] = access_token
208
+ token["token_type"] = "bearer"
209
+ if id_token:
210
+ token["id_token"] = id_token
211
+
212
+ self.token = token
213
+ api_adapter = HTTPAdapter(max_retries=2)
214
+
215
+ """Create a session and perform all requests in the same session"""
216
+ session = OAuth2Session(
217
+ self.CLIENT_ID,
218
+ redirect_uri=self.REDIRECT_URI,
219
+ scope=self.SCOPE,
220
+ token=token,
221
+ )
222
+ session.mount(self.API_URL, api_adapter)
223
+ session.headers.update(
224
+ {
225
+ "Accept": "application/json",
226
+ "User-Agent": self.USER_AGENT,
227
+ "Accept-Encoding": "gzip, deflate, br",
228
+ "Connection": "keep-alive",
229
+ }
230
+ )
231
+ self.session = session
232
+
233
+ self._update_tokens(token.get("access_token"), token.get("cookie_RefreshToken"))
234
+
235
+ if backward_compat and self.token:
236
+ self.get_info()
237
+
238
+ def oauth_authorize(self) -> str:
239
+ """
240
+ Start an OAuth Web Application authentication with Aurora Plus
241
+
242
+ Returns:
243
+ --------
244
+
245
+ str: the URL to query interactively to authorize this session
246
+
247
+ """
248
+ state = {
249
+ "id": str(uuid.uuid4()),
250
+ "meta": {"interactionType": "redirect"},
251
+ }
252
+
253
+ self._code_verifier = "".join(
254
+ [
255
+ random.choice(string.ascii_letters + string.digits + "-_")
256
+ for _ in range(43)
257
+ ]
258
+ )
259
+ code_challenge = base64.urlsafe_b64encode(
260
+ hashlib.sha256(self._code_verifier.encode()).digest()
261
+ ).strip(b"=")
262
+
263
+ self._authorization_url, _ = self.session.authorization_url(
264
+ self.AUTHORIZE_URL,
265
+ client_request_id=uuid.uuid4(),
266
+ client_info=1,
267
+ code_challenge=code_challenge,
268
+ code_challenge_method="S256",
269
+ state=base64.encodebytes(json.dumps(state).encode()),
270
+ )
271
+
272
+ return self._authorization_url
273
+
274
+ def oauth_redirect(self, authorization_response: str):
275
+ """
276
+ Continue an OAuth Web Application authentication with Aurora Plus.
277
+
278
+ Needs to be called after oauth_authorize.
279
+
280
+ Parameters:
281
+ -----------
282
+
283
+ authorization_response: str
284
+
285
+ The full URI of the response (error) page after authentication.
286
+
287
+ Returns:
288
+ --------
289
+
290
+ dict: full token information
291
+
292
+ """
293
+ if not self.session.compliance_hook["access_token_response"]:
294
+ self.session.register_compliance_hook(
295
+ "access_token_response", self._include_access_token
296
+ )
297
+
298
+ return self.session.fetch_token(
299
+ self.TOKEN_URL,
300
+ authorization_response=authorization_response,
301
+ code_verifier=self._code_verifier,
302
+ )
303
+
304
+ def oauth_dump(self) -> dict[str, str]:
305
+ """
306
+ Export partial OAuth state, for use in asynchronous or request/response-based
307
+ workflows.
308
+
309
+ Returns:
310
+ --------
311
+
312
+ dict: a dict of all the relevant state
313
+ """
314
+ return {
315
+ "authorization_url": self._authorization_url,
316
+ "code_verifier": self._code_verifier,
317
+ }
318
+
319
+ def oauth_load(
320
+ self,
321
+ authorization_url: str,
322
+ code_verifier: str,
323
+ ):
324
+ """
325
+ Import partial OAuth state.
326
+
327
+ Params:
328
+ -------
329
+
330
+ kwargs: pass the state dict returned from oauth_dump.
331
+ """
332
+ self._authorization_url = authorization_url
333
+ self._code_verifier = code_verifier
334
+
335
+ def _include_access_token(self, r: Response) -> Response:
336
+ """
337
+ OAuth compliance hook to fetch the bespoke LoginToken,
338
+ and present it as a standard access_token, as well as the value of the
339
+ RefreshToken cookie, for later requests to BEARER_TOKEN_REFRESH_URL.
340
+
341
+ Returns:
342
+ --------
343
+
344
+ Response: the original respones, with the full token, and additional
345
+ cookie_RefreshToken attribute, if available..
346
+
347
+ Response
348
+ """
349
+ rjs: dict[str, Any] = r.json()
350
+ id_token: str | None = rjs.get("id_token")
351
+
352
+ access_token, refresh_token_cookie = self._get_access_token(id_token)
353
+
354
+ rjs.update(
355
+ {
356
+ "access_token": access_token,
357
+ "cookie_RefreshToken": refresh_token_cookie,
358
+ "scope": "openid profile offline_access",
359
+ }
360
+ )
361
+
362
+ r._content = json.dumps(rjs).encode()
363
+
364
+ return r
365
+
366
+ def gettoken(self, *args, **kwargs):
367
+ """
368
+ Deprecated, kept for backward compatibility
369
+
370
+ args and kwargs are to catch obsolete arguments.
371
+ """
372
+ self.get_info()
373
+
374
+ def get_info(self):
375
+ """Get CustomerID and ServiceAgreementID"""
376
+ data = self.request("/customers/current")
377
+ if not data:
378
+ return
379
+ current: dict[str, Any] = data[0]
380
+ self.customerId = current["CustomerID"]
381
+
382
+ """Loop through premises to get active """
383
+ premises: dict[str, Any] = current["Premises"]
384
+ for premise in premises:
385
+ if premise["ServiceAgreementStatus"] == "Active":
386
+ self.Active = premise["ServiceAgreementStatus"]
387
+ self.serviceAgreementID = premise["ServiceAgreementID"]
388
+ self.premiseAddress = premise["Address"]
389
+ if self.Active != "Active":
390
+ self.Error = "No active premise found"
391
+
392
+ def request_usage(self, period: str, index: int = -1) -> dict[str, Any] | None:
393
+ if not hasattr(self, "serviceAgreementID") or not self.serviceAgreementID:
394
+ self.get_info()
395
+ try:
396
+ resp = self._fetch(
397
+ self.API_URL
398
+ + "/usage/"
399
+ + period
400
+ + "?serviceAgreementID="
401
+ + self.serviceAgreementID
402
+ + "&customerId="
403
+ + self.customerId
404
+ + "&index="
405
+ + str(index)
406
+ )
407
+ if resp.status_code == 200:
408
+ return resp.json()
409
+ else:
410
+ self.Error = f"Usage data request for {period} failed: " + resp.reason
411
+ except Timeout:
412
+ self.Error = f"Usage data request for {period} timed out"
413
+
414
+ def getsummary(self, index: int = -1):
415
+ summarydata = self.request_usage("day", index)
416
+ self.DollarValueUsage = summarydata["SummaryTotals"]["DollarValueUsage"]
417
+ self.KilowattHourUsage = summarydata["SummaryTotals"]["KilowattHourUsage"]
418
+
419
+ def getday(self, index: int = -1):
420
+ self.day = self.request_usage("day", index)
421
+
422
+ def getweek(self, index: int = -1):
423
+ self.week = self.request_usage("week", index)
424
+
425
+ def getmonth(self, index: int = -1):
426
+ self.month = self.request_usage("month", index)
427
+
428
+ def getquarter(self, index: int = -1):
429
+ self.quarter = self.request_usage("quarter", index)
430
+
431
+ def getyear(self, index: int = -1):
432
+ self.year = self.request_usage("year", index)
433
+
434
+ def getcurrent(self):
435
+ """Request current customer data"""
436
+ data = self.request("/customers/current")
437
+ if not data:
438
+ return
439
+
440
+ current: dict[str, Any] = data[0]
441
+
442
+ """Loop through premises to match serviceAgreementID already found in token request"""
443
+ premises: dict[str, Any] = current["Premises"]
444
+ found = ""
445
+ for premise in premises:
446
+ if premise["ServiceAgreementID"] == self.serviceAgreementID:
447
+ found = "true"
448
+ self.AmountOwed = "{:.2f}".format(premise["AmountOwed"])
449
+ self.EstimatedBalance = "{:.2f}".format(premise["EstimatedBalance"])
450
+ self.AverageDailyUsage = "{:.2f}".format(premise["AverageDailyUsage"])
451
+ self.UsageDaysRemaining = premise["UsageDaysRemaining"]
452
+ self.ActualBalance = "{:.2f}".format(premise["ActualBalance"])
453
+ self.UnbilledAmount = "{:.2f}".format(premise["UnbilledAmount"])
454
+ self.BillTotalAmount = "{:.2f}".format(premise["BillTotalAmount"])
455
+ self.NumberOfUnpaidBills = premise["NumberOfUnpaidBills"]
456
+ self.BillOverDueAmount = "{:.2f}".format(premise["BillOverDueAmount"])
457
+
458
+ try:
459
+ self.CurrentTimeOfUse = premise["CurrentTimeOfUse"]
460
+ self.CurrentTimeOfUsePeriodEndDate = premise[
461
+ "CurrentTimeOfUsePeriodEndDate"
462
+ ]
463
+ self.CurrentTimeOfUseType = premise["CurrentTimeOfUseType"]
464
+ except (KeyError, ValueError):
465
+ pass
466
+
467
+ if found != "true":
468
+ self.Error = "ServiceAgreementID not found"
469
+
470
+ def getpowerhour(self):
471
+ self.powerhour = self.request("/powerhour/upcoming-active")
472
+
473
+ def request(self, url: str) -> dict[str, Any] | list | None:
474
+ try:
475
+ resp = self._fetch(self.API_URL + url)
476
+ if resp.status_code == 200:
477
+ return resp.json()
478
+ else:
479
+ self.Error = f"Data request for {url} failed: " + resp.reason
480
+ except Timeout:
481
+ self.Error = f"Data request for {url} timed out"
482
+
483
+ def _fetch(self, url: str) -> Response:
484
+ if not self.token.get("access_token"):
485
+ LOGGER.debug("access_token missing, obtaining...")
486
+
487
+ if id_token := self.token.get("id_token"):
488
+ try:
489
+ access_token, refresh_token_cookie = self._get_access_token(
490
+ id_token
491
+ )
492
+ except (HTTPError, AuroraPlusAuthenticationError) as exc:
493
+ # We'll continue, fail, and hopefully get a chance to use the
494
+ # RefreshToken cookie.
495
+ LOGGER.warning(f"can't obtain access_token: {exc}")
496
+ else:
497
+ self._update_tokens(access_token, refresh_token_cookie)
498
+
499
+ r = self.session.get(url)
500
+
501
+ if r.status_code in [401, 403]:
502
+ LOGGER.info("access_token refused, refreshing...")
503
+
504
+ if not (refresh_token := self.token.get("cookie_RefreshToken")):
505
+ raise AuroraPlusAuthenticationError(
506
+ "can't refresh access_token: RefreshToken cookie unknown"
507
+ )
508
+
509
+ try:
510
+ access_token, refresh_token_cookie = self._refresh_access_token(
511
+ refresh_token
512
+ )
513
+ except (HTTPError, AuroraPlusAuthenticationError) as exc:
514
+ # We'll continue, fail, and hopefully get a chance to use the
515
+ # RefreshToken cookie.
516
+ LOGGER.warning(f"can't obtain RefreshToken cookie: {exc}")
517
+ else:
518
+ self._update_tokens(access_token, refresh_token_cookie)
519
+
520
+ r = self.session.get(url)
521
+
522
+ r.raise_for_status()
523
+
524
+ return r
525
+
526
+ def _get_access_token(self, id_token: str) -> tuple[str, str | None]:
527
+ LOGGER.debug(f"retrieving access_token with {id_token=}...")
528
+
529
+ # Incorrect, but looks the part for validation.
530
+ self.session.token["access_token"] = id_token
531
+
532
+ atr = self.session.post(
533
+ self.BEARER_TOKEN_URL, json={"remember": "True", "token": id_token}
534
+ )
535
+ atr.raise_for_status()
536
+
537
+ return self._extract_access_refresh_token(atr)
538
+
539
+ def _refresh_access_token(self, refresh_token: str) -> tuple[str, str | None]:
540
+ LOGGER.debug(f"refreshing access_token with cookie {refresh_token=}...")
541
+
542
+ rtr = self.session.post(self.BEARER_TOKEN_REFRESH_URL, json={})
543
+ rtr.raise_for_status()
544
+
545
+ return self._extract_access_refresh_token(rtr)
546
+
547
+ def _extract_access_refresh_token(
548
+ self, response: Response
549
+ ) -> tuple[str, str | None]:
550
+ refresh_token_cookie = response.cookies.get("RefreshToken")
551
+ if not refresh_token_cookie:
552
+ LOGGER.warning(
553
+ f"Missing RefreshToken cookie in {self.BEARER_TOKEN_URL} response; did you check `Keep me logged in` on the aurora+ login page?"
554
+ )
555
+
556
+ access_token: str = response.json().get("accessToken").split()[1]
557
+ if not access_token:
558
+ raise AuroraPlusAuthenticationError(
559
+ f"Missing access_token in {self.BEARER_TOKEN_URL} response"
560
+ )
561
+
562
+ return access_token, refresh_token_cookie
563
+
564
+ def _update_tokens(
565
+ self, access_token: str | None, refresh_token_cookie: str | None
566
+ ):
567
+ if not access_token and not refresh_token_cookie:
568
+ LOGGER.warning(
569
+ "neither access_token nor RefreshToken cookie known; "
570
+ + "skipping session tokens update"
571
+ )
572
+ return
573
+
574
+ new_token = {}
575
+ if access_token:
576
+ LOGGER.debug(f"updating token in session: {access_token=}")
577
+ self.session.access_token = access_token
578
+ new_token.update(
579
+ {
580
+ "access_token": access_token,
581
+ "token_type": "bearer",
582
+ }
583
+ )
584
+ if refresh_token_cookie:
585
+ LOGGER.debug(f"updating RefreshToken in session: {refresh_token_cookie=}")
586
+ self.session.cookies.set(
587
+ "RefreshToken", refresh_token_cookie, domain=self.COOKIE_DOMAIN
588
+ )
589
+ new_token.update(
590
+ {
591
+ "cookie_RefreshToken": refresh_token_cookie,
592
+ }
593
+ )
594
+ self.token.update(new_token)
595
+ self.session.token = self.token
596
+
597
+
598
+ @deprecated(
599
+ "Use of auroraplus.api is deprecated, please use auroraplus.AuroraPlusApi instead"
600
+ )
601
+ class api(AuroraPlusApi):
602
+ """Backward compatibility adapter."""
603
+
604
+ def __init__(self, *args, **kwargs):
605
+ LOGGER.warning(
606
+ "Use of auroraplus.api is deprecated, please use auroraplus.AuroraPlusApi instead"
607
+ )
608
+ super().__init__(*args, **kwargs)
@@ -0,0 +1,40 @@
1
+ #!/bin/env python
2
+
3
+ import json
4
+ from textwrap import dedent
5
+
6
+ from .api import AuroraPlusApi
7
+
8
+
9
+ def get_token():
10
+ api = AuroraPlusApi()
11
+ url = api.oauth_authorize()
12
+
13
+ print(
14
+ dedent("""
15
+ Please visit the following URL in a browser, and follow the login prompts ...
16
+
17
+ Make sure to check `Keep me logged in` to obtain a long-lived refresh token.
18
+ """)
19
+ )
20
+ print(url)
21
+
22
+ print("\nThis will redirect to an error page (Cradle Mountain).\n")
23
+
24
+ redirect_uri = input("Please enter the full URL of the error page: ")
25
+
26
+ token = api.oauth_redirect(redirect_uri)
27
+
28
+ print("\nThe new token is\n")
29
+ print(json.dumps(token))
30
+
31
+ print("\nThe access token (to use as a bearer token for one-off requests) is\n")
32
+ print(json.dumps(token["access_token"]))
33
+
34
+ print()
35
+
36
+ return token
37
+
38
+
39
+ if __name__ == "__main__":
40
+ get_token()
auroraplus/repl.py ADDED
@@ -0,0 +1,47 @@
1
+ #!/bin/env python
2
+
3
+ import code
4
+ import json
5
+ import logging
6
+ import os
7
+ import sys
8
+
9
+ from .api import AuroraPlusApi
10
+ from .get_token import get_token
11
+
12
+ log_level = logging.DEBUG
13
+ logger = logging.getLogger(__name__)
14
+
15
+ AURORAPLUS_TOKEN = "AURORAPLUS_TOKEN"
16
+
17
+
18
+ def repl():
19
+ logging.basicConfig(level=log_level)
20
+
21
+ token = None
22
+ if token_json := os.getenv(AURORAPLUS_TOKEN):
23
+ try:
24
+ token = json.loads(token_json)
25
+ except json.JSONDecodeError:
26
+ logger.warning(f"{AURORAPLUS_TOKEN} is not valid JSON")
27
+
28
+ if not token:
29
+ logger.info(
30
+ f"{AURORAPLUS_TOKEN} environment variable is empty, requesting interactively ..."
31
+ )
32
+ token = get_token()
33
+
34
+ try:
35
+ api = AuroraPlusApi(token=token)
36
+ api.get_info()
37
+ except Exception as exc:
38
+ logger.exception("exception when setting up", exc_info=exc)
39
+ sys.ps1 = "AuroraPlus >>> "
40
+ code.interact(
41
+ local=locals(),
42
+ banner="\nInitialised AuroraPlus API object available as `api`.\n",
43
+ )
44
+
45
+
46
+ if __name__ == "__main__":
47
+ repl()
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.4
2
+ Name: auroraplus
3
+ Version: 1.7.0
4
+ Summary: Python library to access the Aurora+ API: https://api.auroraenergy.com.au/api
5
+ Author-email: Leigh Curran <AuroraPlusPy@outlook.com>
6
+ License-Expression: MIT
7
+ Keywords: Aurora+,AuroraPlus,Aurora,Tasmania,API
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Topic :: Software Development :: Build Tools
11
+ Classifier: Programming Language :: Python :: 3.5
12
+ Classifier: Programming Language :: Python :: 3.6
13
+ Classifier: Programming Language :: Python :: 3.7
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE.txt
22
+ Requires-Dist: brotli
23
+ Requires-Dist: requests
24
+ Requires-Dist: requests_oauthlib
25
+ Provides-Extra: build
26
+ Requires-Dist: build<0.10.0; extra == "build"
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest; extra == "test"
29
+ Requires-Dist: pytest-ruff; extra == "test"
30
+ Requires-Dist: requests-mock; extra == "test"
31
+ Dynamic: license-file
32
+
33
+ [![buy me a coffee](https://img.shields.io/badge/If%20you%20like%20it-Buy%20us%20a%20coffee-green.svg?style=for-the-badge)](https://www.buymeacoffee.com/leighcurran)
34
+
35
+ 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.
36
+
37
+ ## Requirements
38
+ - Install Python 3.9 (for all users)
39
+ - `pip install .`
40
+
41
+ ## Usage
42
+
43
+ ### Obtain a token
44
+
45
+ The easieast way to obtain a new token is to use the `auroraplus_get_token`
46
+ script.
47
+
48
+ To do this more programatically, obtaining a token is an interactive process
49
+ where the user needs to log on to the AuroraPlus web application.
50
+
51
+ >>> import auroraplus
52
+ >>> api = auroraplus.AuroraPlusApi()
53
+ >>> api.oauth_authorize()
54
+ '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'
55
+
56
+ Follow the URL above in a browser to authenticate with username+password and
57
+ MFA. This will redirect to an error page (Cradle Mountain). Copy the full URL,
58
+ of the error page and continue.
59
+
60
+ >>> api.oauth_redirect('https://my.auroraenergy.com.au/login/redirect?state=...')
61
+ {'id_token': 'ey...', 'access_token': 'ey...', 'token_type': 'bearer', ...}
62
+
63
+ The `api` object is now ready to use (start with `api.get_info()`. The token
64
+ returned in the last step can be saved for later use when re-initialising the
65
+ `api` object without having to follow the OAuth flow to obtain a new
66
+ authorisation.
67
+
68
+ ### Connect to Aurora+ API with a pre-issued token
69
+
70
+ import auroraplus
71
+ api = auroraplus.AuroraPlusApi(token={"access_token": "...", "token_type": "bearer"})
72
+ api.get_info()
73
+
74
+ For backward compatibility with users of the login/password method, the
75
+ `access_token` can be passed as the `password` if the `user` is empty.
76
+
77
+ import auroraplus
78
+ api = auroraplus.AuroraPlusApi(password=<ACCESS_TOKEN>)
79
+ api.get_info()
80
+
81
+ ### Get current account information
82
+
83
+ api.getcurrent()
84
+
85
+ getcurrent() gets the following data:
86
+
87
+ EstimatedBalance - This is shown in the Aurora+ app as 'Balance'
88
+ UsageDaysRemaining - This is shown in the Aurora+ app as 'Days Prepaid'
89
+ AverageDailyUsage
90
+ AmountOwed
91
+ ActualBalance
92
+ UnbilledAmount
93
+ BillTotalAmount
94
+ NumberOfUnpaidBills
95
+ BillOverDueAmount
96
+
97
+ Note: All data except EstimatedBalance is updated Daily.
98
+
99
+ If relevant, time-of-use tariffs can be found in the following attributes:
100
+
101
+ CurrentTimeOfUse
102
+ CurrentTimeOfUsePeriodEndDate
103
+ CurrentTimeOfUseType
104
+
105
+ An example getting specific data with getcurrent:
106
+
107
+ import auroraplus
108
+ api = auroraplus.AuroraPlusApi(token={"access_token": "...", "token_type": "bearer"})
109
+ api.get_info()
110
+ if (not api.Error):
111
+ api.getcurrent()
112
+ print(api.AmountOwed)
113
+ else:
114
+ print(api.Error)
115
+
116
+ ### Get summary usage information
117
+
118
+ api.getsummary()
119
+
120
+ Note: This returns two collections, DollarValueUsage and KilowattHourUsage.
121
+
122
+ An example getting specific data with getsummary:
123
+
124
+ import auroraplus
125
+ api = auroraplus.AuroraPlusApi(token={"access_token": "...", "token_type": "bearer"})
126
+ api.get_info()
127
+ if (not api.Error):
128
+ api.getsummary()
129
+ print(api.DollarValueUsage['T41'])
130
+ print(api.DollarValueUsage['T31'])
131
+ print(api.DollarValueUsage['Other'])
132
+ print(api.DollarValueUsage['Total'])
133
+ print(api.KilowattHourUsage['T41'])
134
+ print(api.KilowattHourUsage['T31'])
135
+ print(api.KilowattHourUsage['Total'])
136
+ else:
137
+ print(api.Error)
138
+
139
+ Note: Offpeak tarrifs not listed
140
+
141
+ ### Get usage data
142
+
143
+ The following returns all available data in json format for each timespan:
144
+
145
+ api.getday()
146
+ api.getweek()
147
+ api.getmonth()
148
+ api.getquarter()
149
+ api.getyear()
150
+
151
+ Full example:
152
+
153
+ api = auroraplus.AuroraPlusApi(token={"access_token": "...", "token_type": "bearer"})
154
+ api.get_info()
155
+ if (not api.Error):
156
+ api.getcurrent()
157
+ print(api.AmountOwed)
158
+
159
+ api.getday()
160
+ print(api.day)
161
+
162
+ api.getweek()
163
+ print(api.week)
164
+
165
+ api.getmonth()
166
+ print(api.month
167
+
168
+ api.getyear()
169
+ print(api.year)
170
+ else:
171
+ print(api.Error)
172
+
173
+ ### Get Power Hour
174
+
175
+ Information about upcoming Power Hour can be obtained with the `getpowerhour` method. It ppopulates a `powerhour` attribute.
176
+
177
+ import json
178
+ api = auroraplus.AuroraPlusApi(token={"access_token": "...", "token_type": "bearer"})
179
+ api.getcurrent()
180
+ api.getpowerhour()
181
+ print(json.dumps(api.powerhour, indent=2))
182
+ [
183
+ {
184
+ "EventName": "Grand Final Weekend",
185
+ "PowerHourEventId": 114,
186
+ "StartDateTime": "2026-08-25T09:32:42",
187
+ "OfferExpiryDateTime": "2026-09-27T12:55:00",
188
+ "TimeslotAccepted": {
189
+ "PowerHourTimeSlotId": 906,
190
+ "StartDateTime": "2026-09-25T13:00:00",
191
+ "EndDateTime": "2026-09-25T17:00:00",
192
+ "ExpiryDateTime": "2026-09-25T12:55:00"
193
+ },
194
+ "TimeslotAll": [
195
+ {
196
+ "PowerHourTimeSlotId": 901,
197
+ "StartDateTime": "2026-09-24T11:00:00",
198
+ "EndDateTime": "2026-09-24T15:00:00",
199
+ "ExpiryDateTime": "2026-09-24T10:55:00"
200
+ },
201
+ ...
202
+
203
+ ## Development
204
+
205
+ ### Tests
206
+
207
+ Currently, tests only do a lint check.
208
+
209
+ pip install -e .[test]
210
+ pytest
211
+
212
+ Some errors can be fixed automatically with
213
+
214
+ ruff check --fix
@@ -0,0 +1,10 @@
1
+ auroraplus/__init__.py,sha256=v74jI5kX4o5aV3n-cBQPCdu4Onzv5HQTkbrHSHuHITE,276
2
+ auroraplus/api.py,sha256=JF30BSmQJap6EmOnaErRHVfoolKedThvmjMRnCL4FWQ,20524
3
+ auroraplus/get_token.py,sha256=FqQ1PHD-nJB4H_tt5nJUZptwK3UZANZkK-s7bHh1IB8,870
4
+ auroraplus/repl.py,sha256=OKThVbX9-VmFb1Ej5JqGNOp4Qg4GDi1VHNbnnNRFq1s,1071
5
+ auroraplus-1.7.0.dist-info/licenses/LICENSE.txt,sha256=qiJqOuL6ktuNDwGBADXIdEDssTFY4Xgl89oT6Nn4Cys,1064
6
+ auroraplus-1.7.0.dist-info/METADATA,sha256=C0zb_nYl0YvAmOVfvxl4aYgThNYFiTI4bMZRUY41THk,6822
7
+ auroraplus-1.7.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ auroraplus-1.7.0.dist-info/entry_points.txt,sha256=bAepK_dEkLQdaH-yrmpqCskWKTNJvLos_IgPgyWO1TE,101
9
+ auroraplus-1.7.0.dist-info/top_level.txt,sha256=0NUDFhgyvYFE_Ol0WpknG9O8SbTFyKEq1fEQO8scxdw,11
10
+ auroraplus-1.7.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ auroraplus_get_token = auroraplus:get_token
3
+ auroraplus_repl = auroraplus.repl:repl
@@ -0,0 +1,17 @@
1
+ MIT License
2
+ Copyright (c) 2021 Leigh Curran
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+ The above copyright notice and this permission notice shall be included in all
10
+ copies or substantial portions of the Software.
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ auroraplus