From 134703ad425e496a53f130b908d4c6fd9986fbbe Mon Sep 17 00:00:00 2001 From: Ryan Moffett Date: Fri, 2 Jan 2026 16:29:05 -0700 Subject: [PATCH 1/6] Added Southwest Gas Utility --- src/opower/utilities/swg.py | 106 ++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/opower/utilities/swg.py diff --git a/src/opower/utilities/swg.py b/src/opower/utilities/swg.py new file mode 100644 index 0000000..cde9554 --- /dev/null +++ b/src/opower/utilities/swg.py @@ -0,0 +1,106 @@ +"""Southwest Gas (SWG).""" + +from typing import Any + +import aiohttp + +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 (Standard Chrome) + standard_ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + + headers = { + "User-Agent": standard_ua, + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + } + + # 3. Warm up the session + async with session.get(login_page_url, headers=headers) as warm_resp: + 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: + # 204 means success with no body. Authentication is stored in cookies. + # We return a dummy string because the library expects a string. + # The session.cookie_jar now holds the real auth. + 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) \ No newline at end of file From cdb9b598329c476ba52d85465a4a21070efea486 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 2 Jan 2026 23:32:58 +0000 Subject: [PATCH 2/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/opower/utilities/swg.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/opower/utilities/swg.py b/src/opower/utilities/swg.py index cde9554..5501781 100644 --- a/src/opower/utilities/swg.py +++ b/src/opower/utilities/swg.py @@ -42,15 +42,16 @@ async def async_login( 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 (Standard Chrome) - standard_ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - + standard_ua = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ) + headers = { "User-Agent": standard_ua, "Accept": "application/json, text/plain, */*", @@ -63,23 +64,24 @@ async def async_login( # 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", - }) + 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, + raise_for_status=False, ) as resp: - # --- HANDLE 204 SUCCESS --- if resp.status == 204: # 204 means success with no body. Authentication is stored in cookies. @@ -99,8 +101,8 @@ async def async_login( # 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) \ No newline at end of file + return str(token) From af3a7cfc87627c46ab7cd5246c766e74c2259e82 Mon Sep 17 00:00:00 2001 From: Ryan Moffett Date: Fri, 2 Jan 2026 16:36:50 -0700 Subject: [PATCH 3/6] Fix lint error: Remove unused variable --- src/opower/utilities/swg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/opower/utilities/swg.py b/src/opower/utilities/swg.py index cde9554..0aa196c 100644 --- a/src/opower/utilities/swg.py +++ b/src/opower/utilities/swg.py @@ -58,7 +58,7 @@ async def async_login( } # 3. Warm up the session - async with session.get(login_page_url, headers=headers) as warm_resp: + async with session.get(login_page_url, headers=headers): pass # 4. Prepare Login Headers From 43e60d7af3d730ec3f4ae5590489f8c7d6ecf6c6 Mon Sep 17 00:00:00 2001 From: Ryan Moffett Date: Sat, 3 Jan 2026 09:39:37 -0700 Subject: [PATCH 4/6] Use shared USER_AGENT constant per review --- src/opower/utilities/swg.py | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/opower/utilities/swg.py b/src/opower/utilities/swg.py index e8f9515..2e3bb02 100644 --- a/src/opower/utilities/swg.py +++ b/src/opower/utilities/swg.py @@ -4,6 +4,8 @@ import aiohttp +# --- FIX: Import the USER_AGENT constant --- +from ..const import USER_AGENT from ..exceptions import InvalidAuth from .base import UtilityBase @@ -42,36 +44,33 @@ async def async_login( 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 (Standard Chrome) - standard_ua = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - ) - + # 2. Define Headers + # --- FIX: Use the imported USER_AGENT constant instead of hardcoding one --- headers = { - "User-Agent": standard_ua, + "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", - } - ) + 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} @@ -82,11 +81,9 @@ async def async_login( headers=login_headers, raise_for_status=False, ) as resp: + # --- HANDLE 204 SUCCESS --- if resp.status == 204: - # 204 means success with no body. Authentication is stored in cookies. - # We return a dummy string because the library expects a string. - # The session.cookie_jar now holds the real auth. return "cookie-auth-success" # If it's not 200 or 204, fail. @@ -105,4 +102,4 @@ async def async_login( if not token: raise InvalidAuth(f"Login failed; token not found. Response keys: {list(result.keys())}") - return str(token) + return str(token) \ No newline at end of file From a35401eba933425f23cff189a7032b1e01eef98d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 3 Jan 2026 16:40:00 +0000 Subject: [PATCH 5/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/opower/utilities/swg.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/opower/utilities/swg.py b/src/opower/utilities/swg.py index 2e3bb02..02c0e4d 100644 --- a/src/opower/utilities/swg.py +++ b/src/opower/utilities/swg.py @@ -44,7 +44,6 @@ async def async_login( 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" @@ -65,12 +64,14 @@ async def async_login( # 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", - }) + 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} @@ -81,7 +82,6 @@ async def async_login( headers=login_headers, raise_for_status=False, ) as resp: - # --- HANDLE 204 SUCCESS --- if resp.status == 204: return "cookie-auth-success" @@ -102,4 +102,4 @@ async def async_login( if not token: raise InvalidAuth(f"Login failed; token not found. Response keys: {list(result.keys())}") - return str(token) \ No newline at end of file + return str(token) From 86b8bdcc8f8cb4d16370157011c17e26e1c49bb0 Mon Sep 17 00:00:00 2001 From: Ryan Moffett Date: Mon, 5 Jan 2026 20:46:06 -0700 Subject: [PATCH 6/6] Rename swg.py to swgas.py and update README --- README.md | 1 + src/opower/utilities/{swg.py => swgas.py} | 0 2 files changed, 1 insertion(+) rename src/opower/utilities/{swg.py => swgas.py} (100%) 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/swg.py b/src/opower/utilities/swgas.py similarity index 100% rename from src/opower/utilities/swg.py rename to src/opower/utilities/swgas.py