Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 129 additions & 19 deletions src/opower/opower.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import dataclasses
import json
import logging
from datetime import date, datetime
from datetime import date, datetime, timedelta
from enum import Enum
from typing import Any
from typing import Any, ClassVar
from urllib.parse import urlencode

import aiohttp
Expand All @@ -21,10 +21,11 @@


class MeterType(Enum):
"""Meter type. Electric or gas."""
"""Meter type."""

ELEC = "ELEC"
GAS = "GAS"
WATER = "WATER"

def __str__(self) -> str:
"""Return the value of the enum."""
Expand Down Expand Up @@ -356,24 +357,82 @@ async def async_get_forecast(self) -> list[Forecast]:
)
return forecasts

_DSS_SERVICE_TYPE_TO_METER: ClassVar[dict[str, str]] = {
"ELECTRICITY": "ELEC",
"ELECTRIC": "ELEC",
"NATURAL_GAS": "GAS",
"GAS": "GAS",
"WATER": "WATER",
"WASTE_WATER": "WATER",
"WASTEWATER": "WATER",
}

async def _async_get_customers(self) -> list[Any]:
"""Get customers associated to the user."""
# Cache the customers
if not self.customers:
if self.utility.is_dss() and not self.user_accounts:
await self._async_get_user_accounts()

url = (
f"https://{self._get_subdomain()}.opower.com/{self._get_api_root()}"
f"/edge/apis/multi-account-v1/cws/{self.utility.utilitycode()}"
"/customers?offset=0&batchSize=100&addressFilter="
)
result = await self._async_get_request(url, {}, self._get_headers())
for customer in result["customers"]:
self.customers.append(customer)
if self.utility.is_dss():
# The multi-account-v1/customers endpoint requires a server-side
# AUTHORIZED_CUSTOMERS_LIST that is only populated via SAML cookie
# auth. Bearer token sessions (from ott/confirm) never have it, so
# the endpoint always returns 403 EMPTY_AUTHORIZED_CUSTOMERS_LIST.
# The browser avoids /customers entirely and uses
# bill-trends-v1/serviceAgreements instead — we do the same.
await self._async_get_dss_customers()
else:
url = (
f"https://{self._get_subdomain()}.opower.com/{self._get_api_root()}"
f"/edge/apis/multi-account-v1/cws/{self.utility.utilitycode()}"
"/customers?offset=0&batchSize=100&addressFilter="
)
result = await self._async_get_request(url, {}, self._get_headers())
for customer in result["customers"]:
self.customers.append(customer)
assert self.customers
return self.customers

async def _async_get_dss_customers(self) -> None:
"""Populate self.customers for DSS utilities via service agreements.

DSS portals expose service/meter data through bill-trends-v1 rather than
the multi-account-v1/customers endpoint. We fetch service agreements,
map their service types to MeterType values, and construct synthetic
customer records that the rest of the library can consume.
"""
if not self.user_accounts:
await self._async_get_user_accounts()

account_id = self._get_account_id()

# Use the webUserId stored during login as the customer UUID (it is the
# only UUID-format identifier the identity-management API exposes via
# Bearer token auth). Fall back to accountId if unavailable.
customer_uuid: str = getattr(self.utility, "_web_user_id", None) or account_id

sa_url = (
f"https://{self._get_subdomain()}.opower.com/{self._get_api_root()}/edge/apis/bill-trends-v1/cws/serviceAgreements"
)
sa_result = await self._async_get_request(sa_url, {}, self._get_headers())

utility_accounts: list[Any] = []
for sa in sa_result.get("serviceAgreements", []):
service_type = sa.get("serviceType", "")
meter_type = self._DSS_SERVICE_TYPE_TO_METER.get(service_type)
if meter_type is None:
_LOGGER.debug("Skipping unknown DSS serviceType %r (saId=%s)", service_type, sa.get("saId"))
continue
utility_accounts.append(
{
"uuid": sa["saId"],
"preferredUtilityAccountId": account_id,
"meterType": meter_type,
"readResolution": "DAY",
}
)

if utility_accounts:
self.customers.append({"uuid": customer_uuid, "utilityAccounts": utility_accounts})

async def _async_get_user_accounts(self) -> list[Any]:
"""Get accounts associated to the user."""
# Cache the accounts
Expand Down Expand Up @@ -561,6 +620,45 @@ async def _async_get_dated_data(
result = reads + result
req_end = req_start.shift(days=-1)

async def _async_fetch_dss_bills(self) -> list[Any]:
"""Fetch bill-level cost data for DSS utilities via bill-trends-v1/billHistory.

DataBrowser-v1 is not accessible for DSS portals that use SAML-only auth,
so we fall back here. Consumption values are set to 0 because the
billHistory endpoint does not expose metered usage. Date range filtering
is intentionally omitted: bill data is always returned in full because
monthly billing cycles rarely align with the caller's requested window.
"""
url = f"https://{self._get_subdomain()}.opower.com/{self._get_api_root()}/edge/apis/bill-trends-v1/cws/billHistory"
result = await self._async_get_request(url, {"numMonths": "36"}, self._get_headers())

bills = result.get("bills", [])
if len(bills) < 2:
return []

# Bills are newest-first; reverse so we can compute period start dates
# from the preceding bill's date.
bills_asc = list(reversed(bills))

reads: list[Any] = []
for i in range(1, len(bills_asc)):
prev_date = datetime.fromisoformat(bills_asc[i - 1]["billDate"])
bill_date = datetime.fromisoformat(bills_asc[i]["billDate"])
period_start = prev_date + timedelta(days=1)
if period_start > bill_date:
# Two bills share the same date; skip the degenerate entry.
continue
reads.append(
{
"startTime": period_start.isoformat(),
"endTime": bill_date.isoformat(),
"value": 0,
"providedCost": bills_asc[i]["cost"],
}
)

return reads

async def _async_fetch(
self,
account: Account,
Expand Down Expand Up @@ -596,6 +694,11 @@ async def _async_fetch(
if err.status == 500 and aggregate_type == AggregateType.BILL:
_LOGGER.debug("Ignoring error while fetching bill data: %s", err)
return []
# DSS utilities with a bill-trends fallback: if DataBrowser-v1 is
# inaccessible (403) fall back to monthly bill history.
if err.status == 403 and self.utility.uses_bill_trends_for_reads() and not usage_only:
_LOGGER.debug("DataBrowser-v1 returned 403 for DSS, falling back to bill history: %s", err)
return await self._async_fetch_dss_bills()
raise

def _get_account_id(self) -> str:
Expand All @@ -613,11 +716,18 @@ def _get_headers(self, customer_uuid: str | None = None) -> dict[str, str]:
headers["authorization"] = f"Bearer {self.access_token}"

opower_selected_entities: list[str] = []
if self.utility.is_dss() and self.user_accounts:
# Required for DSS endpoints
opower_selected_entities.append(f"urn:session:account:{self._get_account_id()}")

if customer_uuid:
if self.utility.is_dss():
if self.user_accounts:
# Required for DSS endpoints
opower_selected_entities.append(f"urn:session:account:{self._get_account_id()}")
# Required for all DSS endpoints; without this the customers endpoint returns
# 403 EMPTY_AUTHORIZED_CUSTOMERS_LIST (confirmed via browser HAR analysis)
opower_selected_entities.append("urn:session:account:provider:dsst")

# For DSS, only include the customer UUID claim when it is a true UUID (the
# webUserId captured at login). A numeric CIS accountId is not accepted.
# Non-DSS utilities always include it (comes from the /customers response).
if customer_uuid and ("-" in customer_uuid or not self.utility.is_dss()):
opower_selected_entities.append(f"urn:opower:customer:uuid:{customer_uuid}")
if opower_selected_entities:
headers["Opower-Selected-Entities"] = json.dumps(opower_selected_entities)
Expand Down
10 changes: 10 additions & 0 deletions src/opower/utilities/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ def is_dss() -> bool:
"""Check if Utility using DSS version of the portal."""
return False

@staticmethod
def uses_bill_trends_for_reads() -> bool:
"""Return True if this utility reads historical data from bill-trends-v1 instead of DataBrowser-v1.

Override in utilities where DataBrowser-v1 is inaccessible (e.g. SAML-only DSS portals).
Only bill-level cost data will be available; sub-bill granularity and consumption values
are not supported by this path.
"""
return False

def utilitycode(self) -> str:
"""Return the utilitycode identifier for the utility."""
return self.subdomain()
Expand Down
54 changes: 53 additions & 1 deletion src/opower/utilities/coautilities.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""City of Austin Utilities."""

import logging
from typing import Any
from urllib.parse import parse_qs, quote, urlparse

Expand All @@ -11,10 +12,16 @@
from .base import UtilityBase
from .helpers import get_form_action_url_and_hidden_inputs

_LOGGER = logging.getLogger(__name__)


class COAUtilities(UtilityBase):
"""City of Austin Utilities."""

def __init__(self) -> None:
"""Initialize."""
self._web_user_id: str | None = None

@staticmethod
def name() -> str:
"""Distinct recognizable name of the utility."""
Expand All @@ -37,6 +44,11 @@ def is_dss() -> bool:
"""Check if Utility using DSS version of the portal."""
return True

@staticmethod
def uses_bill_trends_for_reads() -> bool:
"""COA DSS uses SAML-only sessions so DataBrowser-v1 is inaccessible via Bearer token."""
return True

async def async_login(
self,
session: aiohttp.ClientSession,
Expand Down Expand Up @@ -155,4 +167,44 @@ async def async_login(
raise_for_status=True,
) as response:
content = await response.json()
return str(content["sessionToken"])
session_token = str(content["sessionToken"])

# After a successful OTT exchange, call user-details with the Bearer token.
# Post-SSO this endpoint returns the real user object including webUserId
# (a UUID). We store it so _async_get_customers can include it as
# urn:opower:customer:uuid in the Opower-Selected-Entities header —
# the /customers endpoint requires at least one customer UUID or it
# returns 403 EMPTY_AUTHORIZED_CUSTOMERS_LIST.
async with session.get(
"https://dss-coa.opower.com/webcenter/edge/apis/identity-management-v1/cws/v1/auth/coa/user-details",
headers={
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {session_token}",
"Opower-Selected-Entities": '["urn:session:account:provider:dsst"]',
"Opower-Auth-Mode": "sso",
},
) as user_response:
if user_response.status == 200:
user_data = await user_response.json()
self._web_user_id = user_data.get("webUserId")
_LOGGER.debug("user-details webUserId=%s", self._web_user_id)
else:
_LOGGER.debug("user-details returned status=%s", user_response.status)

# Sync user details to establish customer session context on the server.
# The browser does this immediately after login.
async with session.put(
"https://dss-coa.opower.com/webcenter/edge/apis/customer-sync-v1/cws/v1/coa/sync",
headers={
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {session_token}",
"Opower-Selected-Entities": '["urn:session:account:provider:dsst"]',
"Opower-Auth-Mode": "sso",
},
json={"operations": [{"type": "USER_DETAILS"}]},
) as sync_response:
sync_text = await sync_response.text()
_LOGGER.debug("customer-sync status=%s body=%s", sync_response.status, sync_text)
sync_response.raise_for_status()

return session_token