pgw-api 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pgw_api-1.0.0/PKG-INFO +11 -0
- pgw_api-1.0.0/pgw_api/__init__.py +13 -0
- pgw_api-1.0.0/pgw_api/client.py +230 -0
- pgw_api-1.0.0/pgw_api/exceptions.py +13 -0
- pgw_api-1.0.0/pgw_api/models.py +24 -0
- pgw_api-1.0.0/pgw_api.egg-info/PKG-INFO +11 -0
- pgw_api-1.0.0/pgw_api.egg-info/SOURCES.txt +10 -0
- pgw_api-1.0.0/pgw_api.egg-info/dependency_links.txt +1 -0
- pgw_api-1.0.0/pgw_api.egg-info/requires.txt +1 -0
- pgw_api-1.0.0/pgw_api.egg-info/top_level.txt +1 -0
- pgw_api-1.0.0/pyproject.toml +24 -0
- pgw_api-1.0.0/setup.cfg +4 -0
pgw_api-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pgw-api
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python API client for Philadelphia Gas Works (PGW)
|
|
5
|
+
Author: zackwag
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/zackwag/pgw-api
|
|
8
|
+
Project-URL: Issues, https://github.com/zackwag/pgw-api/issues
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Python API client for Philadelphia Gas Works (PGW)."""
|
|
2
|
+
|
|
3
|
+
from .client import PGWApiClient
|
|
4
|
+
from .exceptions import PGWAuthError, PGWConnectionError, PGWError
|
|
5
|
+
from .models import GasUsage
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"GasUsage",
|
|
9
|
+
"PGWApiClient",
|
|
10
|
+
"PGWAuthError",
|
|
11
|
+
"PGWConnectionError",
|
|
12
|
+
"PGWError",
|
|
13
|
+
]
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""API client for Philadelphia Gas Works (PGW) portal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import date
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from .exceptions import PGWAuthError, PGWConnectionError
|
|
12
|
+
from .models import GasUsage
|
|
13
|
+
|
|
14
|
+
BASE_URL = "https://myaccount.pgworks.com/portal"
|
|
15
|
+
LOGIN_URL = f"{BASE_URL}/"
|
|
16
|
+
VALIDATE_LOGIN_URL = f"{BASE_URL}/Default.aspx/validateLogin"
|
|
17
|
+
DASHBOARD_URL = f"{BASE_URL}/Dashboard.aspx"
|
|
18
|
+
USAGE_URL = f"{BASE_URL}/usages.aspx"
|
|
19
|
+
LOAD_GAS_URL = f"{BASE_URL}/Usages.aspx/LoadGasUsage"
|
|
20
|
+
|
|
21
|
+
_HEADERS = {
|
|
22
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
23
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
24
|
+
"Origin": "https://myaccount.pgworks.com",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PGWApiClient:
|
|
29
|
+
"""Client for interacting with the PGW portal."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, username: str, password: str) -> None:
|
|
32
|
+
self._username = username
|
|
33
|
+
self._password = password
|
|
34
|
+
|
|
35
|
+
async def async_get_usage(
|
|
36
|
+
self, session: aiohttp.ClientSession
|
|
37
|
+
) -> list[GasUsage]:
|
|
38
|
+
"""Authenticate and fetch gas usage data from PGW."""
|
|
39
|
+
await self._establish_session(session)
|
|
40
|
+
await self._authenticate(session)
|
|
41
|
+
csrf_token = await self._get_csrf_token(session)
|
|
42
|
+
return await self._load_gas_usage(session, csrf_token)
|
|
43
|
+
|
|
44
|
+
async def async_validate_credentials(
|
|
45
|
+
self, session: aiohttp.ClientSession
|
|
46
|
+
) -> bool:
|
|
47
|
+
"""Validate credentials without fetching usage data."""
|
|
48
|
+
await self._establish_session(session)
|
|
49
|
+
await self._authenticate(session)
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
async def _establish_session(self, session: aiohttp.ClientSession) -> None:
|
|
53
|
+
"""GET the login page to establish ASP.NET session cookies."""
|
|
54
|
+
try:
|
|
55
|
+
async with session.get(LOGIN_URL, allow_redirects=True) as resp:
|
|
56
|
+
if resp.status != 200:
|
|
57
|
+
raise PGWConnectionError(
|
|
58
|
+
f"Login page returned status {resp.status}"
|
|
59
|
+
)
|
|
60
|
+
await resp.text()
|
|
61
|
+
except aiohttp.ClientError as err:
|
|
62
|
+
raise PGWConnectionError(f"Failed to connect to PGW: {err}") from err
|
|
63
|
+
|
|
64
|
+
async def _authenticate(self, session: aiohttp.ClientSession) -> None:
|
|
65
|
+
"""Authenticate via the AJAX login endpoint."""
|
|
66
|
+
payload = {
|
|
67
|
+
"username": self._username,
|
|
68
|
+
"password": self._password,
|
|
69
|
+
"rememberme": False,
|
|
70
|
+
}
|
|
71
|
+
headers = {**_HEADERS, "Referer": LOGIN_URL}
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
async with session.post(
|
|
75
|
+
VALIDATE_LOGIN_URL, json=payload, headers=headers
|
|
76
|
+
) as resp:
|
|
77
|
+
if resp.status != 200:
|
|
78
|
+
raise PGWAuthError(
|
|
79
|
+
f"Login endpoint returned status {resp.status}"
|
|
80
|
+
)
|
|
81
|
+
body = await resp.text()
|
|
82
|
+
except aiohttp.ClientError as err:
|
|
83
|
+
raise PGWConnectionError(
|
|
84
|
+
f"Failed during authentication: {err}"
|
|
85
|
+
) from err
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
data = json.loads(body)
|
|
89
|
+
inner = json.loads(data["d"])
|
|
90
|
+
except (json.JSONDecodeError, KeyError) as err:
|
|
91
|
+
raise PGWConnectionError(
|
|
92
|
+
"Unexpected response from login endpoint"
|
|
93
|
+
) from err
|
|
94
|
+
|
|
95
|
+
if isinstance(inner, dict) and "dtException" in inner:
|
|
96
|
+
msg = inner["dtException"][0].get("MessageInformation", "Unknown error")
|
|
97
|
+
raise PGWAuthError(msg)
|
|
98
|
+
|
|
99
|
+
if not isinstance(inner, list) or not inner:
|
|
100
|
+
raise PGWAuthError("Authentication failed - unexpected response")
|
|
101
|
+
|
|
102
|
+
first = inner[0]
|
|
103
|
+
if isinstance(first, dict) and first.get("STATUS") == 0:
|
|
104
|
+
raise PGWAuthError(
|
|
105
|
+
first.get("Message", "Invalid username or password")
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def _get_csrf_token(self, session: aiohttp.ClientSession) -> str:
|
|
109
|
+
"""Navigate to the usage page and extract the CSRF token."""
|
|
110
|
+
try:
|
|
111
|
+
async with session.get(DASHBOARD_URL, allow_redirects=True) as resp:
|
|
112
|
+
await resp.text()
|
|
113
|
+
|
|
114
|
+
async with session.get(
|
|
115
|
+
USAGE_URL, params={"type": "GU"}, allow_redirects=True
|
|
116
|
+
) as resp:
|
|
117
|
+
if resp.status != 200:
|
|
118
|
+
raise PGWConnectionError(
|
|
119
|
+
f"Usage page returned status {resp.status}"
|
|
120
|
+
)
|
|
121
|
+
html = await resp.text()
|
|
122
|
+
except aiohttp.ClientError as err:
|
|
123
|
+
raise PGWConnectionError(
|
|
124
|
+
f"Failed to fetch usage page: {err}"
|
|
125
|
+
) from err
|
|
126
|
+
|
|
127
|
+
match = re.search(r'id="hdnCSRFToken"[^>]*value="([^"]+)"', html)
|
|
128
|
+
if not match:
|
|
129
|
+
raise PGWConnectionError("Could not extract CSRF token from usage page")
|
|
130
|
+
|
|
131
|
+
return match.group(1)
|
|
132
|
+
|
|
133
|
+
async def _load_gas_usage(
|
|
134
|
+
self, session: aiohttp.ClientSession, csrf_token: str
|
|
135
|
+
) -> list[GasUsage]:
|
|
136
|
+
"""Call the LoadGasUsage WebMethod to get monthly usage data."""
|
|
137
|
+
payload = {
|
|
138
|
+
"Type": "C",
|
|
139
|
+
"Mode": "M",
|
|
140
|
+
"strDate": "",
|
|
141
|
+
"hourlyType": "",
|
|
142
|
+
"seasonId": "",
|
|
143
|
+
"weatherOverlay": "0",
|
|
144
|
+
"usageyear": "",
|
|
145
|
+
"MeterNumber": "",
|
|
146
|
+
"DateFromDaily": "",
|
|
147
|
+
"DateToDaily": "",
|
|
148
|
+
"HistID": "0",
|
|
149
|
+
"requiredDataType": 0,
|
|
150
|
+
}
|
|
151
|
+
headers = {
|
|
152
|
+
**_HEADERS,
|
|
153
|
+
"Referer": f"{USAGE_URL}?type=GU",
|
|
154
|
+
"CSRFToken": csrf_token,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
async with session.post(
|
|
159
|
+
LOAD_GAS_URL, json=payload, headers=headers
|
|
160
|
+
) as resp:
|
|
161
|
+
if resp.status != 200:
|
|
162
|
+
raise PGWConnectionError(
|
|
163
|
+
f"LoadGasUsage returned status {resp.status}"
|
|
164
|
+
)
|
|
165
|
+
body = await resp.text()
|
|
166
|
+
except aiohttp.ClientError as err:
|
|
167
|
+
raise PGWConnectionError(
|
|
168
|
+
f"Failed to fetch gas usage: {err}"
|
|
169
|
+
) from err
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
data = json.loads(body)
|
|
173
|
+
inner = json.loads(data["d"])
|
|
174
|
+
except (json.JSONDecodeError, KeyError) as err:
|
|
175
|
+
raise PGWConnectionError(
|
|
176
|
+
"Unexpected response from LoadGasUsage"
|
|
177
|
+
) from err
|
|
178
|
+
|
|
179
|
+
if isinstance(inner, dict) and "dtException" in inner:
|
|
180
|
+
msg = inner["dtException"][0].get("MessageInformation", "Unknown error")
|
|
181
|
+
if "CSRF" in msg:
|
|
182
|
+
raise PGWAuthError("CSRF token invalid - session may have expired")
|
|
183
|
+
raise PGWConnectionError(msg)
|
|
184
|
+
|
|
185
|
+
usage_entries = inner.get("objUsageGenerationResultSetTwo", [])
|
|
186
|
+
if not usage_entries:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
results: list[GasUsage] = []
|
|
190
|
+
for entry in usage_entries:
|
|
191
|
+
month_num = entry.get("Month")
|
|
192
|
+
year = entry.get("Year")
|
|
193
|
+
ccf = entry.get("UsageValue")
|
|
194
|
+
|
|
195
|
+
if not all((month_num, year, ccf is not None)):
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
month_date = date(year, month_num, 1)
|
|
199
|
+
|
|
200
|
+
period_start = _parse_date(entry.get("FromDate"))
|
|
201
|
+
period_end = _parse_date(entry.get("ToDate"))
|
|
202
|
+
|
|
203
|
+
results.append(
|
|
204
|
+
GasUsage(
|
|
205
|
+
month=month_date,
|
|
206
|
+
ccf=float(ccf),
|
|
207
|
+
period_start=period_start,
|
|
208
|
+
period_end=period_end,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return sorted(results, key=lambda u: u.month, reverse=True)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _parse_date(date_str: str | None) -> date | None:
|
|
216
|
+
"""Parse a date string in MM/DD/YY format."""
|
|
217
|
+
if not date_str:
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
parts = date_str.split("/")
|
|
221
|
+
if len(parts) == 3:
|
|
222
|
+
month = int(parts[0])
|
|
223
|
+
day = int(parts[1])
|
|
224
|
+
year = int(parts[2])
|
|
225
|
+
if year < 100:
|
|
226
|
+
year += 2000
|
|
227
|
+
return date(year, month, day)
|
|
228
|
+
except (ValueError, IndexError):
|
|
229
|
+
pass
|
|
230
|
+
return None
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Exceptions for the PGW API client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PGWError(Exception):
|
|
5
|
+
"""Base exception for PGW API errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PGWAuthError(PGWError):
|
|
9
|
+
"""Raised when authentication fails."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PGWConnectionError(PGWError):
|
|
13
|
+
"""Raised when connection to PGW fails."""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Data models for PGW API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import date
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
CCF_TO_CF = 100.0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class GasUsage:
|
|
14
|
+
"""A single month of gas usage data."""
|
|
15
|
+
|
|
16
|
+
month: date
|
|
17
|
+
ccf: float
|
|
18
|
+
period_start: date | None = None
|
|
19
|
+
period_end: date | None = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def cf(self) -> float:
|
|
23
|
+
"""Usage in cubic feet."""
|
|
24
|
+
return self.ccf * CCF_TO_CF
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pgw-api
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python API client for Philadelphia Gas Works (PGW)
|
|
5
|
+
Author: zackwag
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/zackwag/pgw-api
|
|
8
|
+
Project-URL: Issues, https://github.com/zackwag/pgw-api/issues
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
pgw_api/__init__.py
|
|
3
|
+
pgw_api/client.py
|
|
4
|
+
pgw_api/exceptions.py
|
|
5
|
+
pgw_api/models.py
|
|
6
|
+
pgw_api.egg-info/PKG-INFO
|
|
7
|
+
pgw_api.egg-info/SOURCES.txt
|
|
8
|
+
pgw_api.egg-info/dependency_links.txt
|
|
9
|
+
pgw_api.egg-info/requires.txt
|
|
10
|
+
pgw_api.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiohttp>=3.9.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pgw_api
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pgw-api"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Python API client for Philadelphia Gas Works (PGW)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "zackwag"},
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"aiohttp>=3.9.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/zackwag/pgw-api"
|
|
21
|
+
Issues = "https://github.com/zackwag/pgw-api/issues"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
include = ["pgw_api*"]
|
pgw_api-1.0.0/setup.cfg
ADDED