diff --git a/README.md b/README.md index 19bb2a8..b32c669 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ This library is used by the [Opower integration in Home Assistant](https://www.h - Sacramento Municipal Utility District (SMUD) - Seattle City Light (SCL) - Southern Maryland Electric Cooperative (SMECO) +- Southwest Gas ## Contributing diff --git a/src/opower/utilities/swgas.py b/src/opower/utilities/swgas.py new file mode 100644 index 0000000..02c0e4d --- /dev/null +++ b/src/opower/utilities/swgas.py @@ -0,0 +1,105 @@ +"""Southwest Gas (SWG).""" + +from typing import Any + +import aiohttp + +# --- FIX: Import the USER_AGENT constant --- +from ..const import USER_AGENT +from ..exceptions import InvalidAuth +from .base import UtilityBase + + +class SouthwestGas(UtilityBase): + """Southwest Gas (SWG). + + This utility uses the Opower portal at `swg.opower.com`. + Login is handled via the 'user-account-control-v1' API endpoint. + """ + + @staticmethod + def name() -> str: + """Return a distinct, human-readable name for this utility.""" + return "Southwest Gas" + + def subdomain(self) -> str: + """Return the opower.com subdomain for this utility.""" + return "swg" + + @staticmethod + def timezone() -> str: + """Return the timezone for this utility.""" + return "America/Phoenix" + + @staticmethod + def is_dss() -> bool: + """Indicate that this utility uses the DSS version of the portal.""" + return False + + async def async_login( + self, + session: aiohttp.ClientSession, + username: str, + password: str, + login_data: dict[str, Any], + ) -> str | None: + """Authenticate against the SWG Opower portal.""" + # 1. Define URLs + base_url = f"https://{self.subdomain()}.opower.com" + login_page_url = f"{base_url}/ei/x/sign-in-wall?source=intercepted" + api_url = f"{base_url}/ei/edge/apis/user-account-control-v1/cws/v1/{self.subdomain()}/account/signin" + + # 2. Define Headers + # --- FIX: Use the imported USER_AGENT constant instead of hardcoding one --- + headers = { + "User-Agent": USER_AGENT, + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + } + + # 3. Warm up the session + # We just call the context manager to get the cookies + async with session.get(login_page_url, headers=headers): + pass + + # 4. Prepare Login Headers + login_headers = headers.copy() + login_headers.update( + { + "Content-Type": "application/json", + "Origin": base_url, + "Referer": login_page_url, + "X-Requested-With": "XMLHttpRequest", + } + ) + + # 5. Execute Login + payload = {"username": username, "password": password} + + async with session.post( + api_url, + json=payload, + headers=login_headers, + raise_for_status=False, + ) as resp: + # --- HANDLE 204 SUCCESS --- + if resp.status == 204: + return "cookie-auth-success" + + # If it's not 200 or 204, fail. + if resp.status != 200: + error_text = await resp.text() + raise InvalidAuth(f"Login failed: {resp.status} - {error_text}") + + try: + result = await resp.json() + except Exception as exc: + raise InvalidAuth("Unexpected response from SWG login") from exc + + # 6. Extract Token (Only if response was 200 JSON) + token = result.get("sessionToken") or result.get("accessToken") + + if not token: + raise InvalidAuth(f"Login failed; token not found. Response keys: {list(result.keys())}") + + return str(token)