From 803ace5329aebe4329c4cbbc7d1e39ccb96fbc75 Mon Sep 17 00:00:00 2001 From: Zach Bardwell <4950357+grutamu@users.noreply.github.com> Date: Tue, 5 May 2026 22:30:06 -0500 Subject: [PATCH 1/3] Fix COA customers endpoint 403 by adding provider:dsst entity to DSS headers The /multi-account-v1/cws/coa/customers endpoint returns 403 EMPTY_AUTHORIZED_CUSTOMERS_LIST when Opower-Selected-Entities does not include "urn:session:account:provider:dsst". Browser HAR analysis shows that every successful DSS API call includes this entity in addition to the numeric account ID. Adding it fixes the 403 for City of Austin Utilities. Co-Authored-By: Claude Sonnet 4.6 --- src/opower/opower.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/opower/opower.py b/src/opower/opower.py index d602d76..fc90974 100644 --- a/src/opower/opower.py +++ b/src/opower/opower.py @@ -613,9 +613,13 @@ 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 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") if customer_uuid: opower_selected_entities.append(f"urn:opower:customer:uuid:{customer_uuid}") From 91b304aacdedcef216edd5f46769b442ed44ee92 Mon Sep 17 00:00:00 2001 From: Zach Bardwell <4950357+grutamu@users.noreply.github.com> Date: Tue, 5 May 2026 22:46:15 -0500 Subject: [PATCH 2/3] Add customer-sync call after login to establish session context for COA The browser performs a PUT to customer-sync-v1/.../coa/sync with {"operations":[{"type":"USER_DETAILS"}]} immediately after obtaining the sessionToken. Without this step, the server has no customer session context and the /customers endpoint returns 403 EMPTY_AUTHORIZED_CUSTOMERS_LIST. Adding this call mirrors the browser flow and should unblock data fetching. Co-Authored-By: Claude Sonnet 4.6 --- src/opower/utilities/coautilities.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/opower/utilities/coautilities.py b/src/opower/utilities/coautilities.py index 8349e55..f58a112 100644 --- a/src/opower/utilities/coautilities.py +++ b/src/opower/utilities/coautilities.py @@ -155,4 +155,20 @@ async def async_login( raise_for_status=True, ) as response: content = await response.json() - return str(content["sessionToken"]) + session_token = str(content["sessionToken"]) + + # Sync user details to establish customer session context on the server. + # The browser does this immediately after login; without it the /customers + # endpoint returns 403 EMPTY_AUTHORIZED_CUSTOMERS_LIST. + await 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"}]}, + ) + + return session_token From 84b86136e6b595f753a915b45ac1a9ca2aec79a1 Mon Sep 17 00:00:00 2001 From: Zach Bardwell <4950357+grutamu@users.noreply.github.com> Date: Wed, 6 May 2026 08:00:00 -0500 Subject: [PATCH 3/3] Add City of Austin Utilities (COA) DSS support COA uses the Opower Digital Self-Service portal (dss-coa.opower.com) with SAML-based SSO. The standard multi-account-v1/customers endpoint returns 403 EMPTY_AUTHORIZED_CUSTOMERS_LIST for Bearer token sessions because the server's authorized-customers list is only populated via SAML cookie auth. The browser avoids /customers entirely; we do the same. Changes: - Add MeterType.WATER for water/wastewater utilities - Add uses_bill_trends_for_reads() hook to UtilityBase (default False); COA overrides to True - Add _async_get_dss_customers(): discovers accounts via bill-trends-v1/serviceAgreements instead of multi-account-v1/customers; maps WATER/WASTE_WATER/ELECTRICITY/GAS service types to MeterType - Capture webUserId (UUID) from user-details after OTT exchange; use it as the customer UUID in Opower-Selected-Entities (only when UUID-format) - Add _async_fetch_dss_bills(): fetches 36 months of bill history via bill-trends-v1/billHistory; infers billing periods from consecutive bill dates; skips degenerate entries where two bills share the same date - _async_fetch(): try DataBrowser-v1 first for all utilities; on 403 fall back to _async_fetch_dss_bills() for uses_bill_trends_for_reads utilities Result: login, account discovery (WATER + WASTE_WATER), and 3-year bill history with cost data all work. Sub-bill granularity is not available via the opower portal (daily water reads live in a separate WaterSmart portal). Co-Authored-By: Claude Sonnet 4.6 --- src/opower/opower.py | 136 ++++++++++++++++++++++++--- src/opower/utilities/base.py | 10 ++ src/opower/utilities/coautilities.py | 44 ++++++++- 3 files changed, 171 insertions(+), 19 deletions(-) diff --git a/src/opower/opower.py b/src/opower/opower.py index fc90974..03c3c2d 100644 --- a/src/opower/opower.py +++ b/src/opower/opower.py @@ -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 @@ -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.""" @@ -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 @@ -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, @@ -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: @@ -621,7 +724,10 @@ def _get_headers(self, customer_uuid: str | None = None) -> dict[str, str]: # 403 EMPTY_AUTHORIZED_CUSTOMERS_LIST (confirmed via browser HAR analysis) opower_selected_entities.append("urn:session:account:provider:dsst") - if customer_uuid: + # 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) diff --git a/src/opower/utilities/base.py b/src/opower/utilities/base.py index 779c5e8..f942944 100644 --- a/src/opower/utilities/base.py +++ b/src/opower/utilities/base.py @@ -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() diff --git a/src/opower/utilities/coautilities.py b/src/opower/utilities/coautilities.py index f58a112..aebc9a6 100644 --- a/src/opower/utilities/coautilities.py +++ b/src/opower/utilities/coautilities.py @@ -1,5 +1,6 @@ """City of Austin Utilities.""" +import logging from typing import Any from urllib.parse import parse_qs, quote, urlparse @@ -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.""" @@ -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, @@ -157,10 +169,31 @@ async def async_login( content = await response.json() 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; without it the /customers - # endpoint returns 403 EMPTY_AUTHORIZED_CUSTOMERS_LIST. - await session.put( + # 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, @@ -169,6 +202,9 @@ async def async_login( "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