diff --git a/src/opower/opower.py b/src/opower/opower.py index d602d76..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: @@ -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) 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 8349e55..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, @@ -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