FastAPI app that pulls interval usage from Sungrow iSolarCloud (OAuth2), caches AER Consumer Data Right electricity plan data, and compares actual metered usage against every available plan to estimate real annual cost (TOU, tiered feed-in, and flat tariffs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
93 lines
3.6 KiB
Python
93 lines
3.6 KiB
Python
"""Client for the AER's public "Get Generic Plans" / "Get Generic Plan
|
|
Detail" Consumer Data Right APIs.
|
|
|
|
These are unauthenticated, government-operated endpoints (no accreditation
|
|
required), but they are hosted per-retailer-brand rather than as one
|
|
aggregated feed: each retailer publishes its own plans under
|
|
`https://cdr.energymadeeasy.gov.au/<brand-slug>/cds-au/v1/energy/plans`.
|
|
There is no single "give me every QLD plan" call -- this client queries a
|
|
maintained list of known retailer brand slugs and merges the results.
|
|
|
|
Confirmed against the live API while building this:
|
|
- The plans-list endpoint only supports API version 1 (`x-v: 1`); the
|
|
plan-detail endpoint supports version 3 (`x-v: 3`). Using the wrong
|
|
version returns an HTTP 406.
|
|
- Distributor names in `geography.distributors` are plain strings like
|
|
"Energex" (QLD's south-east distributor); Ergon's exact string should be
|
|
confirmed for a given retailer/plan once one is found, hence the
|
|
case-insensitive substring match used when filtering by distributor.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
import httpx
|
|
|
|
BASE_HOST = "https://cdr.energymadeeasy.gov.au"
|
|
|
|
# Seed list of retailer CDR "brand" slugs confirmed to respond while
|
|
# building this. Not exhaustive, and retailers can add/rename/retire brands.
|
|
# To add one: find the retailer's own published CDR base URI (usually
|
|
# linked from their "Consumer Data Right" policy page) or check the AER's
|
|
# register at
|
|
# https://www.aer.gov.au/documents/consumer-data-right-energy-retailer-base-uris-and-cdr-brands
|
|
KNOWN_RETAILER_BRANDS: list[str] = [
|
|
"agl",
|
|
"origin",
|
|
"energyaustralia",
|
|
"alintaenergy",
|
|
"redenergy",
|
|
"powershop",
|
|
"tango",
|
|
"globird",
|
|
"sumo",
|
|
"dodo",
|
|
"click",
|
|
"ovoenergy",
|
|
"simplyenergy",
|
|
"qenergy",
|
|
"nectr",
|
|
"amberelectric",
|
|
]
|
|
|
|
|
|
class AERClient:
|
|
def __init__(self, brands: list[str] | None = None) -> None:
|
|
self.brands = brands or KNOWN_RETAILER_BRANDS
|
|
self._client = httpx.Client(timeout=30)
|
|
|
|
def fetch_all_plans(self, *, fuel_type: str = "ELECTRICITY") -> list[dict]:
|
|
"""Fetch every current, generally-available plan summary across all
|
|
configured retailer brands (paginating each retailer's own list)."""
|
|
plans: list[dict] = []
|
|
for brand in self.brands:
|
|
plans.extend(self._fetch_brand_plans(brand, fuel_type))
|
|
return plans
|
|
|
|
def _fetch_brand_plans(self, brand: str, fuel_type: str) -> list[dict]:
|
|
url = f"{BASE_HOST}/{brand}/cds-au/v1/energy/plans"
|
|
params = {"type": "ALL", "fuelType": fuel_type, "effective": "CURRENT", "page-size": 100, "page": 1}
|
|
plans: list[dict] = []
|
|
while True:
|
|
resp = self._client.get(url, params=params, headers={"x-v": "1"})
|
|
if resp.status_code == 404:
|
|
# Brand slug no longer valid, or this retailer hasn't published a CDR endpoint.
|
|
return plans
|
|
resp.raise_for_status()
|
|
body = resp.json()
|
|
for plan in body["data"]["plans"]:
|
|
plan.setdefault("brand", brand)
|
|
plans.append(plan)
|
|
total_pages = body.get("meta", {}).get("totalPages", 1)
|
|
if params["page"] >= total_pages:
|
|
break
|
|
params["page"] += 1
|
|
time.sleep(0.2) # be a polite citizen of a public government API
|
|
return plans
|
|
|
|
def fetch_plan_detail(self, brand: str, plan_id: str) -> dict:
|
|
url = f"{BASE_HOST}/{brand}/cds-au/v1/energy/plans/{plan_id}"
|
|
resp = self._client.get(url, headers={"x-v": "3"})
|
|
resp.raise_for_status()
|
|
return resp.json()["data"]
|