tariffkit 0.2.0__py3-none-any.whl
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.
- tariffkit/__init__.py +52 -0
- tariffkit/account/__init__.py +41 -0
- tariffkit/account/cli.py +493 -0
- tariffkit/account/errors.py +25 -0
- tariffkit/account/model.py +641 -0
- tariffkit/account/rates.py +61 -0
- tariffkit/account/repository.py +329 -0
- tariffkit/billing/__init__.py +56 -0
- tariffkit/billing/engine.py +505 -0
- tariffkit/billing/ledger.py +365 -0
- tariffkit/billing/models.py +274 -0
- tariffkit/billing/netting.py +137 -0
- tariffkit/billing/trueup.py +498 -0
- tariffkit/cca.py +122 -0
- tariffkit/cli.py +809 -0
- tariffkit/config.py +296 -0
- tariffkit/data/__init__.py +40 -0
- tariffkit/data/cca/mce/2023-01-01.toml +61 -0
- tariffkit/data/cca/mce/2026-04-01.toml +152 -0
- tariffkit/data/export/pge/acc_plus/2023-04-15.toml +43 -0
- tariffkit/data/export/pge/nbt00.json.gz +0 -0
- tariffkit/data/export/pge/nbt23.json.gz +0 -0
- tariffkit/data/export/pge/nbt24.json.gz +0 -0
- tariffkit/data/export/pge/nbt25.json.gz +0 -0
- tariffkit/data/export/pge/nbt26.json.gz +0 -0
- tariffkit/data/holidays.toml +36 -0
- tariffkit/data/manifest.json +56 -0
- tariffkit/data/nsc/pge.toml +57 -0
- tariffkit/data/tariff/pge/eelec/2025-01-01.toml +151 -0
- tariffkit/data/tariff/pge/eelec/2025-03-01.toml +150 -0
- tariffkit/data/tariff/pge/eelec/2025-09-01.toml +150 -0
- tariffkit/data/tariff/pge/eelec/2026-01-01.toml +153 -0
- tariffkit/data/tariff/pge/eelec/2026-03-01.toml +157 -0
- tariffkit/data/tariff/pge/etouc/2025-01-01.toml +222 -0
- tariffkit/data/tariff/pge/etouc/2025-03-01.toml +221 -0
- tariffkit/data/tariff/pge/etouc/2025-09-01.toml +221 -0
- tariffkit/data/tariff/pge/etouc/2026-01-01.toml +224 -0
- tariffkit/data/tariff/pge/etouc/2026-03-01.toml +231 -0
- tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +144 -0
- tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +143 -0
- tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +143 -0
- tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +146 -0
- tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +153 -0
- tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +27 -0
- tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +27 -0
- tariffkit/data/versioned.py +118 -0
- tariffkit/engine.py +82 -0
- tariffkit/errors.py +19 -0
- tariffkit/export/__init__.py +5 -0
- tariffkit/export/nbt.py +207 -0
- tariffkit/interop/__init__.py +21 -0
- tariffkit/interop/emhass.py +79 -0
- tariffkit/interop/predbat.py +102 -0
- tariffkit/interop/slots.py +63 -0
- tariffkit/models.py +164 -0
- tariffkit/mqtt/__init__.py +6 -0
- tariffkit/mqtt/discovery.py +84 -0
- tariffkit/mqtt/publisher.py +305 -0
- tariffkit/providers/__init__.py +1 -0
- tariffkit/providers/pge/__init__.py +33 -0
- tariffkit/providers/pge/reconcile.py +828 -0
- tariffkit/providers/pge/statements/__init__.py +26 -0
- tariffkit/providers/pge/statements/errors.py +20 -0
- tariffkit/providers/pge/statements/model.py +320 -0
- tariffkit/providers/pge/statements/ocr.py +193 -0
- tariffkit/providers/pge/statements/parse.py +813 -0
- tariffkit/py.typed +0 -0
- tariffkit/secrets.py +164 -0
- tariffkit/sources/__init__.py +71 -0
- tariffkit/sources/greenbutton.py +318 -0
- tariffkit/sources/homeassistant.py +342 -0
- tariffkit/sources/influx.py +359 -0
- tariffkit/sources/pge.py +1153 -0
- tariffkit/tariff/__init__.py +5 -0
- tariffkit/tariff/retail.py +271 -0
- tariffkit/timeutil.py +120 -0
- tariffkit/web/__init__.py +5 -0
- tariffkit/web/app.py +220 -0
- tariffkit-0.2.0.dist-info/METADATA +260 -0
- tariffkit-0.2.0.dist-info/RECORD +83 -0
- tariffkit-0.2.0.dist-info/WHEEL +4 -0
- tariffkit-0.2.0.dist-info/entry_points.txt +2 -0
- tariffkit-0.2.0.dist-info/licenses/LICENSE +21 -0
tariffkit/sources/pge.py
ADDED
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
"""An authenticated session against PG&E's account portal.
|
|
2
|
+
|
|
3
|
+
This module exists because a Green Button CSV fetched over HTTP is still a
|
|
4
|
+
metered record, which is what :mod:`tariffkit.sources` is for. It is a separate
|
|
5
|
+
module from :mod:`~tariffkit.sources.greenbutton` rather than part of it because
|
|
6
|
+
that one documents itself as stdlib-only with no download step, and because what
|
|
7
|
+
is needed here is not a *format* but a *portal client* -- the same session also
|
|
8
|
+
serves statements, which are not this package's business.
|
|
9
|
+
|
|
10
|
+
**The portal is Salesforce Experience Cloud.** There are no REST endpoints. Every
|
|
11
|
+
call, including fetching a bill, is a POST to ``/s/sfsites/aura`` naming an Apex
|
|
12
|
+
class and method, and the response is JSON with any file base64-encoded inside
|
|
13
|
+
it. Anything talking to this portal has to speak Aura, which is why this is more
|
|
14
|
+
than a wrapper around ``httpx.get``.
|
|
15
|
+
|
|
16
|
+
Two of its inputs change out from under you and are therefore read fresh on every
|
|
17
|
+
session rather than stored:
|
|
18
|
+
|
|
19
|
+
``fwuid``
|
|
20
|
+
The Lightning framework build id, which changes on each Salesforce release.
|
|
21
|
+
Hardcoding it works until it abruptly does not.
|
|
22
|
+
``aura.token``
|
|
23
|
+
A per-session CSRF token.
|
|
24
|
+
|
|
25
|
+
Both are scraped from the login page, which a plain client can fetch: verified
|
|
26
|
+
2026-08-12, HTTP 200, ``fwuid`` present, ``app`` = ``siteforce:loginApp2``.
|
|
27
|
+
|
|
28
|
+
Credentials come from the environment only, never from a config file, and are
|
|
29
|
+
never logged. See ``audit/pge/PORTAL.md`` for how the protocol was captured and
|
|
30
|
+
how to re-capture it when PG&E changes something.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import base64
|
|
36
|
+
import binascii
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
from collections.abc import Mapping
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from datetime import date, datetime, time
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from time import sleep
|
|
45
|
+
from typing import Any
|
|
46
|
+
from uuid import uuid4
|
|
47
|
+
|
|
48
|
+
from ..billing import IntervalReading
|
|
49
|
+
from ..config import default_config_path
|
|
50
|
+
from ..errors import ConfigError, DataError
|
|
51
|
+
from ..secrets import get_secret
|
|
52
|
+
from .greenbutton import GreenButtonLayout, read_green_button
|
|
53
|
+
from .homeassistant import load_dotenv
|
|
54
|
+
|
|
55
|
+
BASE = "https://myaccount.pge.com"
|
|
56
|
+
LOGIN_PATH = "/myaccount/s/login/"
|
|
57
|
+
AURA_PATH = "/myaccount/s/sfsites/aura"
|
|
58
|
+
|
|
59
|
+
#: Where a session is cached between runs. Under .cache/, already gitignored.
|
|
60
|
+
DEFAULT_COOKIE_PATH = Path(".cache/pge/cookies.json")
|
|
61
|
+
|
|
62
|
+
USER_AGENT = (
|
|
63
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
64
|
+
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
TIMEOUT_SECONDS = 60.0
|
|
68
|
+
|
|
69
|
+
#: The framework build id, embedded in every page's bootstrap.
|
|
70
|
+
FWUID = re.compile(r'"fwuid"\s*:\s*"([^"]+)"')
|
|
71
|
+
#: The per-session CSRF token, present once authenticated.
|
|
72
|
+
TOKEN = re.compile(r'"token"\s*:\s*"([^"]{20,})"')
|
|
73
|
+
#: Where an authenticated page keeps its CSRF token: not in the HTML, but in a
|
|
74
|
+
#: one-shot cookie whose *name* the page carries. The key is "tokencookie"
|
|
75
|
+
#: spelled backwards, which is presumably the point. The page reads the cookie
|
|
76
|
+
#: into its config and immediately deletes it, so a client that only greps the
|
|
77
|
+
#: markup finds nothing and every authenticated call comes back invalidSession.
|
|
78
|
+
TOKEN_COOKIE_NAME = re.compile(r'"eikoocnekot"\s*[\]:]?\s*=?\s*:?\s*"([^"]+)"')
|
|
79
|
+
#: Salesforce's own descriptor for invoking an Apex method.
|
|
80
|
+
APEX_ACTION = "aura://ApexActionController/ACTION$execute"
|
|
81
|
+
|
|
82
|
+
#: The Visualforce page that embeds the usage-export widget. Its HTML carries
|
|
83
|
+
#: the Opower host and bearer token, which is why it is fetched at all.
|
|
84
|
+
GREEN_BUTTON_PATH = "/myaccount/apex/myAcct_VF_GreenButton"
|
|
85
|
+
#: Where the usage platform's GraphQL API lives on its own host.
|
|
86
|
+
GRAPHQL_PATH = "/ei/edge/apis/dsm-graphql-v1/cws/graphql"
|
|
87
|
+
|
|
88
|
+
OPOWER_HOST = re.compile(r"https://([a-z0-9][a-z0-9.-]*\.opower\.com)")
|
|
89
|
+
#: Not anchored on quotes: the Visualforce shell embeds the bearer unquoted,
|
|
90
|
+
#: while the rendered page quotes it. Matching the JWT's own shape works for
|
|
91
|
+
#: both and does not care how the page chose to spell it.
|
|
92
|
+
OPOWER_TOKEN = re.compile(r"(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})")
|
|
93
|
+
OPOWER_URN = re.compile(r"(urn:opower:v1:account:[a-z]+:uuid:[0-9a-f-]+)")
|
|
94
|
+
|
|
95
|
+
#: How far back the platform will export. Its own answer, not our choice.
|
|
96
|
+
MAX_HISTORY_DAYS = 1095
|
|
97
|
+
#: The export is a job. Roughly two minutes at the default interval, which is
|
|
98
|
+
#: far longer than the few seconds it has taken in practice.
|
|
99
|
+
EXPORT_POLL_LIMIT = 60
|
|
100
|
+
|
|
101
|
+
GENERATE_EXPORT = (
|
|
102
|
+
"mutation WUE_GenerateUsageExportFile("
|
|
103
|
+
"$usageExportFileConfigurationInput: UsageExportFileConfigurationInput) {\n"
|
|
104
|
+
" generateUsageExportFile(\n"
|
|
105
|
+
" usageExportFileConfigurationInput: $usageExportFileConfigurationInput\n"
|
|
106
|
+
" ) {\n uuid\n __typename\n }\n}"
|
|
107
|
+
)
|
|
108
|
+
EXPORT_JOB = (
|
|
109
|
+
"query WUE_GetExportJob($jobUuid: ID!) {\n"
|
|
110
|
+
" exportJob(jobUuid: $jobUuid) {\n"
|
|
111
|
+
" uuid\n result\n isRunning\n isFailed\n isFinished\n __typename\n }\n}"
|
|
112
|
+
)
|
|
113
|
+
ACCOUNT_URN_QUERY = (
|
|
114
|
+
"query WUE_GetMetadata($selectedAccount: ID) {\n"
|
|
115
|
+
" billingAccountByAuthContext(selectedAccount: $selectedAccount) {\n"
|
|
116
|
+
" id\n __typename\n }\n}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class Endpoint:
|
|
122
|
+
"""One Apex action, and how sure we are that it is still the right one."""
|
|
123
|
+
|
|
124
|
+
name: str
|
|
125
|
+
classname: str
|
|
126
|
+
method: str
|
|
127
|
+
#: Managed-package classes need their namespace in its own field. Glued onto
|
|
128
|
+
#: the class name the portal answers "No apex action available".
|
|
129
|
+
namespace: str = ""
|
|
130
|
+
#: Whether this was observed answering, rather than inferred. An inferred
|
|
131
|
+
#: endpoint is not a bug, but a failure against one means "check this first".
|
|
132
|
+
captured: bool = False
|
|
133
|
+
note: str = ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
#: Actions this client knows. Captured entries were observed in a live session;
|
|
137
|
+
#: see audit/pge/PORTAL.md for the procedure.
|
|
138
|
+
ENDPOINTS: Mapping[str, Endpoint] = {
|
|
139
|
+
"session_check": Endpoint(
|
|
140
|
+
"session_check",
|
|
141
|
+
"MyAcct_SessionValidatorController",
|
|
142
|
+
"isGuestUserCheck",
|
|
143
|
+
captured=True,
|
|
144
|
+
note="cheap authenticated call; used to test whether a cached session is live",
|
|
145
|
+
),
|
|
146
|
+
"bill_pdf": Endpoint(
|
|
147
|
+
"bill_pdf",
|
|
148
|
+
"MyAcct_DownloadBillPdf",
|
|
149
|
+
"httpCalloutDownloadBill",
|
|
150
|
+
captured=True,
|
|
151
|
+
note="takes billidfrombillhistory; returns the PDF base64-encoded in JSON",
|
|
152
|
+
),
|
|
153
|
+
"bill_history": Endpoint(
|
|
154
|
+
"bill_history",
|
|
155
|
+
"BusinessProcessDisplayController",
|
|
156
|
+
"GenericInvoke2NoCont",
|
|
157
|
+
namespace="vlocity_cmt",
|
|
158
|
+
captured=True,
|
|
159
|
+
note="the bill list is an OmniStudio Integration Procedure, not an Apex "
|
|
160
|
+
"controller, which is why no amount of grepping for a Bill*List class "
|
|
161
|
+
"finds it. Dispatches sClassName/sMethodName with a JSON string input.",
|
|
162
|
+
),
|
|
163
|
+
"login": Endpoint(
|
|
164
|
+
"login",
|
|
165
|
+
"MyAcct_customLoginLWCController",
|
|
166
|
+
"login",
|
|
167
|
+
captured=True,
|
|
168
|
+
note="PG&E's own controller, not Salesforce's stock LightningLoginFormController. "
|
|
169
|
+
"Takes username, password, startUrl, uuid, browsercookie and validationCookie; "
|
|
170
|
+
"the last two are cookies the login page sets before the form is submitted.",
|
|
171
|
+
),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#: The one-shot CSRF token cookie, minted under a fresh random name on every
|
|
175
|
+
#: sign-in.
|
|
176
|
+
TOKEN_COOKIE_PREFIX = "__Host-ERIC_PROD"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _prune(jar: Any) -> list[Any]:
|
|
180
|
+
"""Everything worth keeping between runs, which excludes the token cookie.
|
|
181
|
+
|
|
182
|
+
Two separate failures come from persisting it, and caching it buys nothing
|
|
183
|
+
either way because it is single-use.
|
|
184
|
+
|
|
185
|
+
Spending it is the subtle one. The next run finds the *session* cookie
|
|
186
|
+
valid, so :meth:`PgeSession.signed_in` says yes and no sign-in happens --
|
|
187
|
+
but the token alongside it was already spent, and the portal re-issues one
|
|
188
|
+
only to a session that asks for it. The result is a session that is
|
|
189
|
+
genuinely live and cannot make a single authenticated call.
|
|
190
|
+
|
|
191
|
+
Hoarding them is the loud one. A jar that accumulates one per run grows by
|
|
192
|
+
~340 bytes each time until the request headers no longer fit and the portal
|
|
193
|
+
answers 431 -- which reads as the portal breaking rather than as the client
|
|
194
|
+
slowly poisoning itself.
|
|
195
|
+
"""
|
|
196
|
+
return [c for c in jar if not c.name.startswith(TOKEN_COOKIE_PREFIX)]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
#: Cookies the login page sets and then asks to have handed back. Reading them
|
|
200
|
+
#: from the jar rather than inventing them is what makes the call look like the
|
|
201
|
+
#: page's own.
|
|
202
|
+
BROWSER_COOKIE = "LSKey-c$browsercookie"
|
|
203
|
+
VALIDATION_COOKIE = "LSKey-c$validationCookie"
|
|
204
|
+
#: The login form runs in its own Lightning app, not the authenticated one.
|
|
205
|
+
LOGIN_APP = "siteforce:loginApp2"
|
|
206
|
+
COMMUNITY_APP = "siteforce:communityApp"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True, slots=True)
|
|
210
|
+
class PgeSettings:
|
|
211
|
+
username: str
|
|
212
|
+
#: Never printed. `repr=False` keeps it out of tracebacks, which render
|
|
213
|
+
#: dataclass frames.
|
|
214
|
+
password: str = field(repr=False, default="")
|
|
215
|
+
account_id: str = ""
|
|
216
|
+
cookie_path: Path = DEFAULT_COOKIE_PATH
|
|
217
|
+
#: This machine's identity to the portal. Not a second factor: the account
|
|
218
|
+
#: has no MFA. The portal verifies *devices*, and a browser that has been
|
|
219
|
+
#: verified once carries the result for 180 days, which is why a person
|
|
220
|
+
#: never sees a challenge.
|
|
221
|
+
#:
|
|
222
|
+
#: These are created by the login page's own JavaScript, so they never
|
|
223
|
+
#: arrive over Set-Cookie and a scripted client cannot obtain them by
|
|
224
|
+
#: fetching anything. Left empty, every run looks like a brand-new device
|
|
225
|
+
#: and the portal asks to verify it. Copy them once from a signed-in
|
|
226
|
+
#: browser -- see audit/pge/PORTAL.md.
|
|
227
|
+
browser_cookie: str = field(repr=False, default="")
|
|
228
|
+
validation_cookie: str = field(repr=False, default="")
|
|
229
|
+
#: Which account the usage platform answers for, e.g.
|
|
230
|
+
#: ``urn:opower:v1:account:pge:uuid:...``. Every usage query is scoped by it,
|
|
231
|
+
#: and the portal publishes it nowhere a client can read: the page hands it
|
|
232
|
+
#: to the embedded widget. Read it once from a signed-in browser (see
|
|
233
|
+
#: audit/pge/PORTAL.md), or leave it unset and let the platform try to derive
|
|
234
|
+
#: it from the token.
|
|
235
|
+
account_urn: str = ""
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def load(
|
|
239
|
+
cls,
|
|
240
|
+
config_path: str | Path | None = None,
|
|
241
|
+
dotenv_path: str | Path = ".env",
|
|
242
|
+
**overrides: str | None,
|
|
243
|
+
) -> PgeSettings:
|
|
244
|
+
"""Mirrors ``InfluxSettings.load``: config file, then .env, then env.
|
|
245
|
+
|
|
246
|
+
Credentials are deliberately *not* read from the config file. A config
|
|
247
|
+
file is the kind of thing that gets copied into a bug report.
|
|
248
|
+
"""
|
|
249
|
+
values: dict[str, str] = {}
|
|
250
|
+
path = Path(config_path) if config_path else default_config_path()
|
|
251
|
+
if path.is_file():
|
|
252
|
+
import tomllib
|
|
253
|
+
|
|
254
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
255
|
+
table = raw.get("pge", {})
|
|
256
|
+
for key in ("account_id", "cookie_path"):
|
|
257
|
+
if key in table:
|
|
258
|
+
values[key] = str(table[key])
|
|
259
|
+
|
|
260
|
+
env = {**load_dotenv(dotenv_path), **os.environ}
|
|
261
|
+
username = (
|
|
262
|
+
overrides.get("username") or env.get("PGE_USERNAME") or get_secret("pge.username") or ""
|
|
263
|
+
)
|
|
264
|
+
password = (
|
|
265
|
+
overrides.get("password") or env.get("PGE_PASSWORD") or get_secret("pge.password") or ""
|
|
266
|
+
)
|
|
267
|
+
account_id = (
|
|
268
|
+
overrides.get("account_id")
|
|
269
|
+
or env.get("PGE_ACCOUNT_ID", "")
|
|
270
|
+
or values.get("account_id", "")
|
|
271
|
+
)
|
|
272
|
+
if not username or not password:
|
|
273
|
+
raise ConfigError(
|
|
274
|
+
"PG&E credentials not found; store pge.username and pge.password with "
|
|
275
|
+
"`tariffkit credentials set`, or set PGE_USERNAME and PGE_PASSWORD"
|
|
276
|
+
)
|
|
277
|
+
cookie_path = Path(values.get("cookie_path", str(DEFAULT_COOKIE_PATH))).expanduser()
|
|
278
|
+
return cls(
|
|
279
|
+
username=username,
|
|
280
|
+
password=password,
|
|
281
|
+
account_id=account_id,
|
|
282
|
+
cookie_path=cookie_path,
|
|
283
|
+
browser_cookie=env.get("PGE_BROWSER_COOKIE") or get_secret("pge.browser_cookie") or "",
|
|
284
|
+
validation_cookie=env.get("PGE_VALIDATION_COOKIE")
|
|
285
|
+
or get_secret("pge.validation_cookie")
|
|
286
|
+
or "",
|
|
287
|
+
account_urn=env.get("PGE_ACCOUNT_URN") or get_secret("pge.account_urn") or "",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class PortalError(DataError):
|
|
292
|
+
"""The portal did not answer the way it was captured answering."""
|
|
293
|
+
|
|
294
|
+
def __init__(self, message: str, *, endpoint: str = "", step: str = "") -> None:
|
|
295
|
+
super().__init__(message)
|
|
296
|
+
self.endpoint = endpoint
|
|
297
|
+
self.step = step
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class InvalidSessionError(PortalError):
|
|
301
|
+
"""The CSRF token is stale, and here is the replacement.
|
|
302
|
+
|
|
303
|
+
Its own type because the answer is specific and recoverable: Salesforce
|
|
304
|
+
rotates the token and hands the new one back inside the rejection, so the
|
|
305
|
+
call simply retries with it. Treating this as a generic failure means every
|
|
306
|
+
authenticated call fails once and stays failed.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
def __init__(self, message: str, *, endpoint: str = "", new_token: str = "") -> None:
|
|
310
|
+
super().__init__(message, endpoint=endpoint)
|
|
311
|
+
self.new_token = new_token
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _bill_rows(payload: Any) -> list[dict[str, Any]]:
|
|
315
|
+
"""Pull the statement rows out of an Integration Procedure's reply.
|
|
316
|
+
|
|
317
|
+
The shape is nested and version-dependent, so this walks for the first list
|
|
318
|
+
of records that looks like bills rather than hard-coding a path that a
|
|
319
|
+
release note could invalidate.
|
|
320
|
+
"""
|
|
321
|
+
wanted = ("billpdf", "invoice", "amount", "billdate", "statement", "duedate", "billid")
|
|
322
|
+
|
|
323
|
+
# Integration Procedures return their payload as a JSON *string*, so the
|
|
324
|
+
# structure to search for is one decode further in than it looks.
|
|
325
|
+
if isinstance(payload, Mapping) and isinstance(payload.get("returnValue"), str):
|
|
326
|
+
try:
|
|
327
|
+
payload = json.loads(payload["returnValue"])
|
|
328
|
+
except ValueError:
|
|
329
|
+
return []
|
|
330
|
+
elif isinstance(payload, str):
|
|
331
|
+
try:
|
|
332
|
+
payload = json.loads(payload)
|
|
333
|
+
except ValueError:
|
|
334
|
+
return []
|
|
335
|
+
|
|
336
|
+
def walk(node: Any, depth: int = 0) -> list[dict[str, Any]] | None:
|
|
337
|
+
if depth > 10:
|
|
338
|
+
return None
|
|
339
|
+
# Integration Procedures nest JSON strings inside JSON strings -- the
|
|
340
|
+
# rows sit two decodes down, under a `returnValue` that decodes to an
|
|
341
|
+
# `IPResult` that is itself still a string. Decoding only the outer one
|
|
342
|
+
# walks straight past them and reports an empty history, which reads as
|
|
343
|
+
# "this account has no bills" rather than as a parsing failure.
|
|
344
|
+
if isinstance(node, str) and node[:1] in "{[":
|
|
345
|
+
try:
|
|
346
|
+
return walk(json.loads(node), depth + 1)
|
|
347
|
+
except ValueError:
|
|
348
|
+
return None
|
|
349
|
+
if isinstance(node, list) and node and isinstance(node[0], dict):
|
|
350
|
+
keys = " ".join(node[0]).lower()
|
|
351
|
+
if any(w in keys for w in wanted):
|
|
352
|
+
return [r for r in node if isinstance(r, dict)]
|
|
353
|
+
if isinstance(node, Mapping):
|
|
354
|
+
for value in node.values():
|
|
355
|
+
found = walk(value, depth + 1)
|
|
356
|
+
if found:
|
|
357
|
+
return found
|
|
358
|
+
elif isinstance(node, list):
|
|
359
|
+
for value in node:
|
|
360
|
+
found = walk(value, depth + 1)
|
|
361
|
+
if found:
|
|
362
|
+
return found
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
return walk(payload) or []
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _frontdoor(answer: Any) -> str:
|
|
369
|
+
"""The session-handoff URL a successful sign-in returns, if there is one.
|
|
370
|
+
|
|
371
|
+
Salesforce hands back a ``/secur/frontdoor.jsp`` link carrying a one-time
|
|
372
|
+
code rather than setting the session cookie on the POST itself.
|
|
373
|
+
"""
|
|
374
|
+
if not isinstance(answer, Mapping):
|
|
375
|
+
return ""
|
|
376
|
+
value = answer.get("returnValue")
|
|
377
|
+
message = str(value.get("retMessage", "")) if isinstance(value, Mapping) else ""
|
|
378
|
+
return message if message.startswith("http") else ""
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _check_device_trust(answer: Any, *, configured: bool) -> None:
|
|
382
|
+
"""Turn the portal's device-verification prompt into a useful message.
|
|
383
|
+
|
|
384
|
+
The portal answers ``retMessage: "verifymfa :"`` when it does not recognise
|
|
385
|
+
the device. That wording is misleading: it is not a second factor and does
|
|
386
|
+
not mean the account has MFA enabled. It means this machine has never been
|
|
387
|
+
verified, and a browser only avoids it by carrying the result of an earlier
|
|
388
|
+
verification for 180 days.
|
|
389
|
+
|
|
390
|
+
Worth being precise about, because reading it as "MFA is on" sends you off
|
|
391
|
+
building a one-time-code flow for a challenge that a correctly identified
|
|
392
|
+
device never sees.
|
|
393
|
+
"""
|
|
394
|
+
if not isinstance(answer, Mapping):
|
|
395
|
+
return
|
|
396
|
+
value = answer.get("returnValue")
|
|
397
|
+
message = str(value.get("retMessage", "")) if isinstance(value, Mapping) else ""
|
|
398
|
+
if "verifymfa" not in message.lower():
|
|
399
|
+
return
|
|
400
|
+
raise PortalError(
|
|
401
|
+
"the portal does not recognise this device"
|
|
402
|
+
+ (
|
|
403
|
+
", even with the configured PGE_BROWSER_COOKIE and PGE_VALIDATION_COOKIE; "
|
|
404
|
+
"they may have expired (the portal trusts a device for 180 days) or been "
|
|
405
|
+
"copied from a different browser profile"
|
|
406
|
+
if configured
|
|
407
|
+
else "; set PGE_BROWSER_COOKIE and PGE_VALIDATION_COOKIE from a signed-in "
|
|
408
|
+
"browser (see audit/pge/PORTAL.md). They are created by the login page's own "
|
|
409
|
+
"JavaScript, so no amount of fetching will produce them"
|
|
410
|
+
)
|
|
411
|
+
+ ". This is device verification, not multi-factor authentication -- the account "
|
|
412
|
+
"needs no second factor, and a recognised device is never challenged.",
|
|
413
|
+
endpoint="login",
|
|
414
|
+
step="device-trust",
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class PgeSession:
|
|
419
|
+
"""An authenticated Aura session.
|
|
420
|
+
|
|
421
|
+
Used as a context manager. Public so the out-of-repo audit harness can reuse
|
|
422
|
+
one session for both Green Button data and statements rather than logging in
|
|
423
|
+
twice and holding credentials in two places.
|
|
424
|
+
"""
|
|
425
|
+
|
|
426
|
+
def __init__(self, settings: PgeSettings) -> None:
|
|
427
|
+
self.settings = settings
|
|
428
|
+
self._client: Any = None
|
|
429
|
+
self._fwuid = ""
|
|
430
|
+
self._token = ""
|
|
431
|
+
|
|
432
|
+
def __enter__(self) -> PgeSession:
|
|
433
|
+
try:
|
|
434
|
+
import httpx
|
|
435
|
+
except ImportError as exc: # pragma: no cover - depends on the extra
|
|
436
|
+
raise DataError(
|
|
437
|
+
"reaching the PG&E portal needs the 'pge' extra: pip install 'tariffkit[pge]'"
|
|
438
|
+
) from exc
|
|
439
|
+
self._client = httpx.Client(
|
|
440
|
+
base_url=BASE,
|
|
441
|
+
follow_redirects=True,
|
|
442
|
+
timeout=TIMEOUT_SECONDS,
|
|
443
|
+
headers={"User-Agent": USER_AGENT},
|
|
444
|
+
)
|
|
445
|
+
self._load_cookies()
|
|
446
|
+
return self
|
|
447
|
+
|
|
448
|
+
def __exit__(self, *exc: object) -> None:
|
|
449
|
+
if self._client is not None:
|
|
450
|
+
self._save_cookies()
|
|
451
|
+
self._client.close()
|
|
452
|
+
self._client = None
|
|
453
|
+
|
|
454
|
+
# -- session state -------------------------------------------------
|
|
455
|
+
|
|
456
|
+
def _load_cookies(self) -> None:
|
|
457
|
+
path = self.settings.cookie_path
|
|
458
|
+
if not path.is_file() or self._client is None:
|
|
459
|
+
return
|
|
460
|
+
try:
|
|
461
|
+
stored = json.loads(path.read_text(encoding="utf-8"))
|
|
462
|
+
except OSError, ValueError:
|
|
463
|
+
return
|
|
464
|
+
if not isinstance(stored, list):
|
|
465
|
+
# An older format, or something else entirely. A cached session is
|
|
466
|
+
# only an optimisation, so an unreadable cache is discarded rather
|
|
467
|
+
# than raised over: the next call signs in again.
|
|
468
|
+
return
|
|
469
|
+
for entry in stored:
|
|
470
|
+
if not isinstance(entry, dict) or "name" not in entry:
|
|
471
|
+
continue
|
|
472
|
+
self._client.cookies.set(
|
|
473
|
+
str(entry["name"]),
|
|
474
|
+
str(entry.get("value", "")),
|
|
475
|
+
domain=str(entry.get("domain", "")),
|
|
476
|
+
path=str(entry.get("path", "/")),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
def _save_cookies(self) -> None:
|
|
480
|
+
if self._client is None:
|
|
481
|
+
return
|
|
482
|
+
path = self.settings.cookie_path
|
|
483
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
484
|
+
# Keyed by name *and* domain and path, not name alone: the portal sets
|
|
485
|
+
# several cookies that share a name across domains (renderCtx among
|
|
486
|
+
# them), and collapsing them to a dict raises rather than silently
|
|
487
|
+
# picking one.
|
|
488
|
+
payload = json.dumps(
|
|
489
|
+
[
|
|
490
|
+
{"name": c.name, "value": c.value or "", "domain": c.domain, "path": c.path}
|
|
491
|
+
for c in _prune(self._client.cookies.jar)
|
|
492
|
+
]
|
|
493
|
+
)
|
|
494
|
+
# 0600 via os.open: a session cookie is a bearer credential, and
|
|
495
|
+
# Path.write_text would leave it world-readable under a lax umask.
|
|
496
|
+
handle = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
|
|
497
|
+
with os.fdopen(handle, "w", encoding="utf-8") as out:
|
|
498
|
+
out.write(payload)
|
|
499
|
+
|
|
500
|
+
def refresh_token(self) -> str:
|
|
501
|
+
"""Mint a fresh CSRF token without signing in again.
|
|
502
|
+
|
|
503
|
+
Any authenticated page load issues one. Separated from :meth:`login`
|
|
504
|
+
because the two failures are unrelated and the cures have very
|
|
505
|
+
different costs: a stale token is free to replace, whereas a re-login
|
|
506
|
+
risks a device check.
|
|
507
|
+
"""
|
|
508
|
+
if self._client is None:
|
|
509
|
+
raise PortalError("session is not open; use PgeSession as a context manager")
|
|
510
|
+
# Bootstrap first, deliberately. It fetches the *login* page for the
|
|
511
|
+
# framework id, and doing that after minting a token invalidates the
|
|
512
|
+
# one just minted -- so leaving it to happen lazily inside `apex`
|
|
513
|
+
# produces a token that was valid when it was read and stale by the
|
|
514
|
+
# time it is sent.
|
|
515
|
+
if not self._fwuid:
|
|
516
|
+
self.bootstrap()
|
|
517
|
+
token = self._token_from(self._client.get("/myaccount/s/").text)
|
|
518
|
+
if token:
|
|
519
|
+
self._token = token
|
|
520
|
+
return self._token
|
|
521
|
+
|
|
522
|
+
def bootstrap(self) -> tuple[str, str]:
|
|
523
|
+
"""Read the framework id and CSRF token off the portal.
|
|
524
|
+
|
|
525
|
+
Read every session rather than stored: ``fwuid`` changes on each
|
|
526
|
+
Salesforce release, and a stored one fails in a way that looks like a
|
|
527
|
+
broken endpoint rather than a stale constant.
|
|
528
|
+
"""
|
|
529
|
+
if self._client is None:
|
|
530
|
+
raise PortalError("session is not open; use PgeSession as a context manager")
|
|
531
|
+
response = self._client.get(LOGIN_PATH)
|
|
532
|
+
if response.status_code != 200:
|
|
533
|
+
raise PortalError(
|
|
534
|
+
f"the portal answered {response.status_code} for the login page, so the "
|
|
535
|
+
f"session could not be started",
|
|
536
|
+
step="bootstrap",
|
|
537
|
+
)
|
|
538
|
+
found = FWUID.search(response.text)
|
|
539
|
+
if not found:
|
|
540
|
+
raise PortalError(
|
|
541
|
+
"no framework id in the login page, so this is not the Aura bootstrap "
|
|
542
|
+
"we captured; PG&E may have changed the portal (see audit/pge/PORTAL.md)",
|
|
543
|
+
step="bootstrap",
|
|
544
|
+
)
|
|
545
|
+
self._fwuid = found.group(1)
|
|
546
|
+
token = TOKEN.search(response.text)
|
|
547
|
+
self._token = token.group(1) if token else ""
|
|
548
|
+
return self._fwuid, self._token
|
|
549
|
+
|
|
550
|
+
def _token_from(self, page: str) -> str:
|
|
551
|
+
"""The CSRF token an authenticated page is carrying, if any."""
|
|
552
|
+
if self._client is None:
|
|
553
|
+
return ""
|
|
554
|
+
named = TOKEN_COOKIE_NAME.search(page)
|
|
555
|
+
if named:
|
|
556
|
+
jar = {c.name: c.value or "" for c in self._client.cookies.jar}
|
|
557
|
+
carried = jar.get(named.group(1), "")
|
|
558
|
+
if carried:
|
|
559
|
+
return carried
|
|
560
|
+
found = TOKEN.search(page)
|
|
561
|
+
return found.group(1) if found else ""
|
|
562
|
+
|
|
563
|
+
def context(self, app: str = COMMUNITY_APP) -> str:
|
|
564
|
+
if not self._fwuid:
|
|
565
|
+
self.bootstrap()
|
|
566
|
+
return json.dumps({"mode": "PROD", "fwuid": self._fwuid, "app": app, "loaded": {}})
|
|
567
|
+
|
|
568
|
+
# -- the one call everything else is made of -----------------------
|
|
569
|
+
|
|
570
|
+
def apex(
|
|
571
|
+
self,
|
|
572
|
+
endpoint: str,
|
|
573
|
+
params: Mapping[str, Any] | None = None,
|
|
574
|
+
*,
|
|
575
|
+
page: str = "/myaccount/s/",
|
|
576
|
+
app: str = COMMUNITY_APP,
|
|
577
|
+
) -> Any:
|
|
578
|
+
"""Invoke one Apex action and return its ``returnValue``."""
|
|
579
|
+
if self._client is None:
|
|
580
|
+
raise PortalError("session is not open; use PgeSession as a context manager")
|
|
581
|
+
known = ENDPOINTS.get(endpoint)
|
|
582
|
+
if known is None:
|
|
583
|
+
raise PortalError(f"unknown endpoint {endpoint!r}", endpoint=endpoint)
|
|
584
|
+
|
|
585
|
+
message = {
|
|
586
|
+
"actions": [
|
|
587
|
+
{
|
|
588
|
+
"id": "1;a",
|
|
589
|
+
"descriptor": APEX_ACTION,
|
|
590
|
+
"callingDescriptor": "UNKNOWN",
|
|
591
|
+
"params": {
|
|
592
|
+
"namespace": known.namespace,
|
|
593
|
+
"classname": known.classname,
|
|
594
|
+
"method": known.method,
|
|
595
|
+
"params": dict(params or {}),
|
|
596
|
+
"cacheable": False,
|
|
597
|
+
"isContinuation": False,
|
|
598
|
+
},
|
|
599
|
+
}
|
|
600
|
+
]
|
|
601
|
+
}
|
|
602
|
+
response = self._client.post(
|
|
603
|
+
AURA_PATH,
|
|
604
|
+
params={"r": 1, "aura.ApexAction.execute": 1},
|
|
605
|
+
data={
|
|
606
|
+
"message": json.dumps(message),
|
|
607
|
+
"aura.context": self.context(app),
|
|
608
|
+
"aura.pageURI": page,
|
|
609
|
+
"aura.token": self._token,
|
|
610
|
+
},
|
|
611
|
+
)
|
|
612
|
+
try:
|
|
613
|
+
return _unwrap(response, known)
|
|
614
|
+
except InvalidSessionError as stale:
|
|
615
|
+
# Salesforce rotates the CSRF token and returns the replacement in
|
|
616
|
+
# the rejection itself. Retry once with it; a second failure is real.
|
|
617
|
+
spent = self._token
|
|
618
|
+
replacement = stale.new_token
|
|
619
|
+
if not replacement or replacement == spent:
|
|
620
|
+
# No replacement offered, which does not mean the session died:
|
|
621
|
+
# a token can go stale while the session stays perfectly good,
|
|
622
|
+
# and any authenticated page load mints a fresh one. Re-read
|
|
623
|
+
# rather than give up, because the caller's only other move is
|
|
624
|
+
# a re-login -- and on this portal a needless re-login is a
|
|
625
|
+
# needless device check, which is the expensive failure.
|
|
626
|
+
replacement = self._token_from(self._client.get("/myaccount/s/").text)
|
|
627
|
+
if (not replacement or replacement == spent) and endpoint != "login":
|
|
628
|
+
# The token cookie is one-shot. A later run finds the session
|
|
629
|
+
# cookie still valid -- so `signed_in()` says yes -- while the
|
|
630
|
+
# token it was issued alongside has already been spent, and no
|
|
631
|
+
# page load re-mints one for a session that thinks it has it.
|
|
632
|
+
# Signing in again is the only way to be issued another. Safe
|
|
633
|
+
# to do unprompted *because* the device cookies are configured:
|
|
634
|
+
# the portal recognises the machine and does not challenge it.
|
|
635
|
+
self.login(force=True)
|
|
636
|
+
replacement = self._token
|
|
637
|
+
if not replacement or replacement == spent:
|
|
638
|
+
raise
|
|
639
|
+
self._token = replacement
|
|
640
|
+
retry = self._client.post(
|
|
641
|
+
AURA_PATH,
|
|
642
|
+
params={"r": 1, "aura.ApexAction.execute": 1},
|
|
643
|
+
data={
|
|
644
|
+
"message": json.dumps(message),
|
|
645
|
+
"aura.context": self.context(app),
|
|
646
|
+
"aura.pageURI": page,
|
|
647
|
+
"aura.token": self._token,
|
|
648
|
+
},
|
|
649
|
+
)
|
|
650
|
+
return _unwrap(retry, known)
|
|
651
|
+
|
|
652
|
+
def bill_history(self, history_filter: str = "BILL") -> list[dict[str, Any]]:
|
|
653
|
+
"""Every statement and payment the portal will list.
|
|
654
|
+
|
|
655
|
+
Dispatched through OmniStudio rather than a plain Apex controller, which
|
|
656
|
+
is why searching the page's JavaScript for a bill-list class finds
|
|
657
|
+
nothing: the list is an Integration Procedure named in the payload.
|
|
658
|
+
"""
|
|
659
|
+
if self._client is None:
|
|
660
|
+
raise PortalError("session is not open; use PgeSession as a context manager")
|
|
661
|
+
account = self.settings.account_id or ""
|
|
662
|
+
payload = self.apex(
|
|
663
|
+
"bill_history",
|
|
664
|
+
{
|
|
665
|
+
"sClassName": "vlocity_cmt.IntegrationProcedureService",
|
|
666
|
+
"sMethodName": "MyAcct_IP_GetBillPayHistoryData",
|
|
667
|
+
"input": json.dumps(
|
|
668
|
+
{
|
|
669
|
+
"billingAccount": account,
|
|
670
|
+
"userProfile": "MyAcct Customer Community User",
|
|
671
|
+
"userTimeZoneName": "America/Los_Angeles",
|
|
672
|
+
"userCurrencyCode": "USD",
|
|
673
|
+
# Capital H, and it is load-bearing: the service reads
|
|
674
|
+
# this exact key and rejects an empty value rather than
|
|
675
|
+
# defaulting, so a lowercase `historyFilter` produces
|
|
676
|
+
# "Invalid value '' for query parameter historyFilter"
|
|
677
|
+
# -- an error about the *value* that is really about
|
|
678
|
+
# the key.
|
|
679
|
+
#
|
|
680
|
+
# The value is an enum whose members are nothing like
|
|
681
|
+
# the labels the page shows. "Bill Charges" in the UI
|
|
682
|
+
# is `BILL` on the wire; `Payments` is `PAYMENTS`, and
|
|
683
|
+
# there is no member for "All Activity" at all.
|
|
684
|
+
"HistoryFilter": history_filter,
|
|
685
|
+
}
|
|
686
|
+
),
|
|
687
|
+
"options": json.dumps({"ignoreCache": False, "useContinuation": False}),
|
|
688
|
+
},
|
|
689
|
+
page="/myaccount/s/bill-and-payment-history",
|
|
690
|
+
)
|
|
691
|
+
return _bill_rows(payload)
|
|
692
|
+
|
|
693
|
+
def download_bill(self, bill_id: str) -> bytes:
|
|
694
|
+
"""One statement, as PDF bytes.
|
|
695
|
+
|
|
696
|
+
There is no URL for this. The portal returns the whole document
|
|
697
|
+
base64-encoded inside a JSON reply and the page builds a ``blob:`` link
|
|
698
|
+
client-side, so "sign in and GET the PDF" has nothing to GET -- the
|
|
699
|
+
download is itself an Aura action.
|
|
700
|
+
"""
|
|
701
|
+
payload = self.apex(
|
|
702
|
+
"bill_pdf",
|
|
703
|
+
{"billidfrombillhistory": bill_id},
|
|
704
|
+
page="/myaccount/s/bill-and-payment-history",
|
|
705
|
+
)
|
|
706
|
+
# This action answers with the document wrapped one level deeper than
|
|
707
|
+
# the others, so accept either shape rather than assume.
|
|
708
|
+
if isinstance(payload, Mapping) and isinstance(payload.get("returnValue"), Mapping):
|
|
709
|
+
payload = payload["returnValue"]
|
|
710
|
+
encoded = payload.get("imageData") if isinstance(payload, Mapping) else None
|
|
711
|
+
if not encoded:
|
|
712
|
+
raise PortalError("the portal returned no document for this bill", endpoint="bill_pdf")
|
|
713
|
+
try:
|
|
714
|
+
pdf = base64.b64decode(encoded)
|
|
715
|
+
except (ValueError, binascii.Error) as bad:
|
|
716
|
+
raise PortalError(
|
|
717
|
+
"the portal's document was not valid base64", endpoint="bill_pdf"
|
|
718
|
+
) from bad
|
|
719
|
+
# Check the magic rather than trust the reply: a session that has
|
|
720
|
+
# quietly expired hands back an HTML sign-in page with a 200, and
|
|
721
|
+
# writing that to a .pdf turns a portal problem into a parser problem
|
|
722
|
+
# several steps downstream.
|
|
723
|
+
if not pdf.startswith(b"%PDF"):
|
|
724
|
+
raise PortalError(
|
|
725
|
+
"the portal returned something that is not a PDF; the session may have expired",
|
|
726
|
+
endpoint="bill_pdf",
|
|
727
|
+
)
|
|
728
|
+
return pdf
|
|
729
|
+
|
|
730
|
+
def signed_in(self) -> bool:
|
|
731
|
+
"""Whether the session is authenticated.
|
|
732
|
+
|
|
733
|
+
The probe is ``isGuestUserCheck``, which answers the *opposite*
|
|
734
|
+
question: it succeeds for an anonymous visitor too, and reports that
|
|
735
|
+
they are a guest. Treating a successful call as proof of being signed in
|
|
736
|
+
makes every anonymous session look live, so login is skipped and the
|
|
737
|
+
first real request fails with a 401 far from the cause.
|
|
738
|
+
"""
|
|
739
|
+
if self._client is None:
|
|
740
|
+
return False
|
|
741
|
+
# An HTTP probe rather than an Aura call. The authenticated community
|
|
742
|
+
# does not publish a CSRF token in its page, so every Aura call after
|
|
743
|
+
# sign-in needs a token this client cannot obtain -- and none of the
|
|
744
|
+
# work it actually does needs one. What distinguishes the two states is
|
|
745
|
+
# which Lightning app the portal serves.
|
|
746
|
+
landing = self._client.get("/myaccount/s/")
|
|
747
|
+
if landing.status_code != 200:
|
|
748
|
+
return False
|
|
749
|
+
if COMMUNITY_APP in landing.text and LOGIN_APP not in landing.text:
|
|
750
|
+
return True
|
|
751
|
+
|
|
752
|
+
try:
|
|
753
|
+
answer = self.apex("session_check")
|
|
754
|
+
except PortalError:
|
|
755
|
+
return False
|
|
756
|
+
# Observed shape: {"returnValue": true, "cacheable": false}, where true
|
|
757
|
+
# means "yes, a guest". Other spellings are accepted because this is one
|
|
758
|
+
# utility's controller and the field could be renamed.
|
|
759
|
+
if isinstance(answer, Mapping):
|
|
760
|
+
for key in ("returnValue", "isGuestUser", "isGuest", "guest"):
|
|
761
|
+
if key in answer:
|
|
762
|
+
return not bool(answer[key])
|
|
763
|
+
return False
|
|
764
|
+
if isinstance(answer, bool):
|
|
765
|
+
return not answer
|
|
766
|
+
# An unrecognised answer is treated as "not signed in": signing in again
|
|
767
|
+
# is cheap, while assuming a live session fails later and further away.
|
|
768
|
+
return False
|
|
769
|
+
|
|
770
|
+
def login(self, *, force: bool = False) -> None:
|
|
771
|
+
"""Sign in, unless a cached session is already live.
|
|
772
|
+
|
|
773
|
+
The portal's own controller, not Salesforce's stock one, and it wants
|
|
774
|
+
two cookies back that the login page sets when it is fetched -- so the
|
|
775
|
+
page has to be loaded first, which :meth:`bootstrap` does anyway for the
|
|
776
|
+
framework id. Skipping straight to the POST fails in a way that looks
|
|
777
|
+
like bad credentials.
|
|
778
|
+
"""
|
|
779
|
+
# A resumed session is the common case, and it arrives with a valid
|
|
780
|
+
# session cookie and no token, because the token is one-shot and is
|
|
781
|
+
# deliberately not cached. Any authenticated page load mints another,
|
|
782
|
+
# so ask for one rather than signing in: submitting the login form
|
|
783
|
+
# while already signed in fails outright, since the login page then
|
|
784
|
+
# redirects to the community and the token it carries belongs to the
|
|
785
|
+
# community app rather than to `siteforce:loginApp2`, which is the app
|
|
786
|
+
# the form posts under.
|
|
787
|
+
if not force and self.signed_in() and (self._token or self.refresh_token()):
|
|
788
|
+
return
|
|
789
|
+
if self._client is None:
|
|
790
|
+
raise PortalError("session is not open; use PgeSession as a context manager")
|
|
791
|
+
|
|
792
|
+
self.bootstrap()
|
|
793
|
+
|
|
794
|
+
# Present this machine as the device it is. Configured values come from
|
|
795
|
+
# a browser that the portal already trusts; without them the portal
|
|
796
|
+
# cannot recognise the device and asks to verify it, which reads as an
|
|
797
|
+
# MFA prompt on an account that has no MFA.
|
|
798
|
+
browser = self.settings.browser_cookie or str(uuid4())
|
|
799
|
+
validation = self.settings.validation_cookie or str(uuid4())
|
|
800
|
+
for name, value in ((BROWSER_COOKIE, browser), (VALIDATION_COOKIE, validation)):
|
|
801
|
+
self._client.cookies.set(name, value, domain="myaccount.pge.com", path="/")
|
|
802
|
+
|
|
803
|
+
answer = self.apex(
|
|
804
|
+
"login",
|
|
805
|
+
{
|
|
806
|
+
"username": self.settings.username,
|
|
807
|
+
"password": self.settings.password,
|
|
808
|
+
"startUrl": "/myaccount/s/",
|
|
809
|
+
"uuid": str(uuid4()),
|
|
810
|
+
"browsercookie": browser,
|
|
811
|
+
"validationCookie": validation,
|
|
812
|
+
},
|
|
813
|
+
page=LOGIN_PATH,
|
|
814
|
+
app=LOGIN_APP,
|
|
815
|
+
)
|
|
816
|
+
_check_device_trust(answer, configured=bool(self.settings.browser_cookie))
|
|
817
|
+
|
|
818
|
+
# The call does not itself establish the session. It returns a
|
|
819
|
+
# Salesforce frontdoor URL, and *following* that is what sets the
|
|
820
|
+
# session cookie -- so a login that stops at a successful POST leaves an
|
|
821
|
+
# anonymous client holding a success message.
|
|
822
|
+
door = _frontdoor(answer)
|
|
823
|
+
if door:
|
|
824
|
+
self._client.get(door)
|
|
825
|
+
# The authenticated community issues its own CSRF token, and the
|
|
826
|
+
# one scraped from the login page stops being valid the moment the
|
|
827
|
+
# session exists. Re-read it, or every later call comes back as
|
|
828
|
+
# aura:invalidSession.
|
|
829
|
+
landing = self._client.get("/myaccount/s/")
|
|
830
|
+
token = self._token_from(landing.text)
|
|
831
|
+
if token:
|
|
832
|
+
self._token = token
|
|
833
|
+
# The response carries a redirect rather than a session flag, so the
|
|
834
|
+
# only honest confirmation is a call that needs authentication.
|
|
835
|
+
if not self.signed_in():
|
|
836
|
+
raise PortalError(
|
|
837
|
+
"the sign-in call succeeded but the session is still anonymous; the "
|
|
838
|
+
"credentials may be wrong, or the portal may have added a step",
|
|
839
|
+
endpoint="login",
|
|
840
|
+
step="login",
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
# -- the usage platform, which is a different system entirely -------
|
|
844
|
+
|
|
845
|
+
def opower(self) -> tuple[str, str, str]:
|
|
846
|
+
"""``(host, bearer token, account urn)`` for the usage platform.
|
|
847
|
+
|
|
848
|
+
Usage data does not live in Salesforce. It lives in Oracle Opower, behind
|
|
849
|
+
a GraphQL API on its own host with its own bearer token, and the utility
|
|
850
|
+
portal embeds a Visualforce page whose HTML carries all three. So they
|
|
851
|
+
are scraped from that page rather than configured: the token is
|
|
852
|
+
short-lived and the host is not ours to pin.
|
|
853
|
+
"""
|
|
854
|
+
if self._client is None:
|
|
855
|
+
raise PortalError("session is not open; use PgeSession as a context manager")
|
|
856
|
+
response = self._client.get(GREEN_BUTTON_PATH)
|
|
857
|
+
if response.status_code != 200:
|
|
858
|
+
raise PortalError(
|
|
859
|
+
f"the usage page answered {response.status_code}; the session has probably expired",
|
|
860
|
+
step="opower",
|
|
861
|
+
)
|
|
862
|
+
page = response.text
|
|
863
|
+
host = OPOWER_HOST.search(page)
|
|
864
|
+
token = OPOWER_TOKEN.search(page)
|
|
865
|
+
if not host or not token:
|
|
866
|
+
raise PortalError(
|
|
867
|
+
"the usage page carries no Opower host and bearer token, so either the "
|
|
868
|
+
"session is not signed in or PG&E has changed the page "
|
|
869
|
+
"(see audit/pge/PORTAL.md)",
|
|
870
|
+
step="opower",
|
|
871
|
+
)
|
|
872
|
+
# Configured first: the platform does not publish the account urn
|
|
873
|
+
# anywhere a client can read, because the page hands it to the widget.
|
|
874
|
+
if self.settings.account_urn:
|
|
875
|
+
return host.group(1), token.group(1), self.settings.account_urn
|
|
876
|
+
urn = OPOWER_URN.search(page)
|
|
877
|
+
if urn:
|
|
878
|
+
return host.group(1), token.group(1), urn.group(1)
|
|
879
|
+
return host.group(1), token.group(1), self._discover_urn(host.group(1), token.group(1))
|
|
880
|
+
|
|
881
|
+
def _discover_urn(self, host: str, token: str) -> str:
|
|
882
|
+
"""Ask the platform which account this token is for.
|
|
883
|
+
|
|
884
|
+
The page does not always carry the account's urn, but every query is
|
|
885
|
+
scoped by it, so it has to come from somewhere. ``billingAccountByAuthContext``
|
|
886
|
+
derives it from the token itself, which is the one source that cannot
|
|
887
|
+
disagree with the credentials being used.
|
|
888
|
+
"""
|
|
889
|
+
payload = self.graphql(host, token, "", "WUE_GetMetadata", ACCOUNT_URN_QUERY, {})
|
|
890
|
+
account = payload.get("billingAccountByAuthContext") or {}
|
|
891
|
+
urn = account.get("id") or account.get("urn")
|
|
892
|
+
if not urn:
|
|
893
|
+
raise PortalError(
|
|
894
|
+
"could not determine which account this session is for", step="opower"
|
|
895
|
+
)
|
|
896
|
+
return str(urn)
|
|
897
|
+
|
|
898
|
+
def graphql(
|
|
899
|
+
self, host: str, token: str, urn: str, operation: str, query: str, variables: Any
|
|
900
|
+
) -> dict[str, Any]:
|
|
901
|
+
"""One GraphQL call against the usage platform."""
|
|
902
|
+
if self._client is None:
|
|
903
|
+
raise PortalError("session is not open; use PgeSession as a context manager")
|
|
904
|
+
headers = {
|
|
905
|
+
"authorization": f"Bearer {token}",
|
|
906
|
+
"content-type": "application/json",
|
|
907
|
+
"accept": "application/json",
|
|
908
|
+
"x-requested-with": "XMLHttpRequest",
|
|
909
|
+
}
|
|
910
|
+
if urn:
|
|
911
|
+
headers["opower-selected-entities"] = urn
|
|
912
|
+
response = self._client.post(
|
|
913
|
+
f"https://{host}{GRAPHQL_PATH}",
|
|
914
|
+
headers=headers,
|
|
915
|
+
json={
|
|
916
|
+
"operationName": operation,
|
|
917
|
+
"query": query,
|
|
918
|
+
"variables": {**variables, "locale": "en-US"}
|
|
919
|
+
if isinstance(variables, dict)
|
|
920
|
+
else variables,
|
|
921
|
+
},
|
|
922
|
+
)
|
|
923
|
+
if response.status_code != 200:
|
|
924
|
+
raise PortalError(
|
|
925
|
+
f"{operation}: the usage platform answered {response.status_code}",
|
|
926
|
+
endpoint=operation,
|
|
927
|
+
)
|
|
928
|
+
body = response.json()
|
|
929
|
+
if body.get("errors"):
|
|
930
|
+
first = body["errors"][0]
|
|
931
|
+
raise PortalError(f"{operation}: {first.get('message')}", endpoint=operation)
|
|
932
|
+
return dict(body.get("data") or {})
|
|
933
|
+
|
|
934
|
+
def fetch_bytes(self, url: str) -> bytes:
|
|
935
|
+
"""Fetch a pre-authenticated result URL.
|
|
936
|
+
|
|
937
|
+
The export lands in Oracle object storage behind a signed URL, so this
|
|
938
|
+
deliberately sends none of our headers or cookies with it.
|
|
939
|
+
"""
|
|
940
|
+
import httpx
|
|
941
|
+
|
|
942
|
+
with httpx.Client(follow_redirects=True, timeout=TIMEOUT_SECONDS) as plain:
|
|
943
|
+
response = plain.get(url)
|
|
944
|
+
if response.status_code != 200:
|
|
945
|
+
raise PortalError(
|
|
946
|
+
f"the export file answered {response.status_code}", endpoint="green_button"
|
|
947
|
+
)
|
|
948
|
+
return bytes(response.content)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _unwrap(response: Any, endpoint: Endpoint) -> Any:
|
|
952
|
+
"""Pull the Apex return value out of an Aura response, or explain why not."""
|
|
953
|
+
kind = response.headers.get("content-type", "")
|
|
954
|
+
if response.status_code != 200 or "json" not in kind:
|
|
955
|
+
hint = (
|
|
956
|
+
"this endpoint has never been confirmed against the live portal"
|
|
957
|
+
if not endpoint.captured
|
|
958
|
+
else "the session may have expired, or PG&E may have changed the portal"
|
|
959
|
+
)
|
|
960
|
+
raise PortalError(
|
|
961
|
+
f"{endpoint.name}: expected JSON, got {response.status_code} {kind or 'no type'}; "
|
|
962
|
+
f"{hint} (see audit/pge/PORTAL.md)",
|
|
963
|
+
endpoint=endpoint.name,
|
|
964
|
+
)
|
|
965
|
+
# Aura prefixes its JSON with an anti-hijacking guard ("*/" or "while(1);")
|
|
966
|
+
# on some responses and not others, so the payload starts at the first
|
|
967
|
+
# brace rather than at byte zero.
|
|
968
|
+
text = response.text
|
|
969
|
+
brace = text.find("{")
|
|
970
|
+
if brace < 0:
|
|
971
|
+
raise PortalError(
|
|
972
|
+
f"{endpoint.name}: the response carried no JSON body", endpoint=endpoint.name
|
|
973
|
+
)
|
|
974
|
+
try:
|
|
975
|
+
# raw_decode rather than loads: the guard prefix is sometimes a comment
|
|
976
|
+
# whose own brace defeats slicing, and some responses carry trailing
|
|
977
|
+
# bytes after the object. This reads exactly one value and ignores the
|
|
978
|
+
# rest.
|
|
979
|
+
body, _ = json.JSONDecoder().raw_decode(text[brace:])
|
|
980
|
+
except ValueError as exc:
|
|
981
|
+
raise PortalError(
|
|
982
|
+
f"{endpoint.name}: unreadable response: {exc}", endpoint=endpoint.name
|
|
983
|
+
) from exc
|
|
984
|
+
|
|
985
|
+
# A stale CSRF token comes back as an event, not an error, and the
|
|
986
|
+
# replacement travels with it.
|
|
987
|
+
event = body.get("event") or {}
|
|
988
|
+
if "invalidSession" in str(event.get("descriptor", "")):
|
|
989
|
+
values = ((event.get("attributes") or {}).get("values")) or {}
|
|
990
|
+
raise InvalidSessionError(
|
|
991
|
+
f"{endpoint.name}: the session token is stale",
|
|
992
|
+
endpoint=endpoint.name,
|
|
993
|
+
new_token=str(values.get("newToken") or ""),
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
actions = body.get("actions") or []
|
|
997
|
+
if not actions:
|
|
998
|
+
raise PortalError(f"{endpoint.name}: no actions in the response", endpoint=endpoint.name)
|
|
999
|
+
action = actions[0]
|
|
1000
|
+
if action.get("state") != "SUCCESS":
|
|
1001
|
+
errors = action.get("error") or []
|
|
1002
|
+
detail = errors[0].get("message") if errors else action.get("state")
|
|
1003
|
+
raise PortalError(f"{endpoint.name}: {detail}", endpoint=endpoint.name)
|
|
1004
|
+
return action.get("returnValue")
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def parse_green_button(text: str, layout: GreenButtonLayout | None = None) -> list[IntervalReading]:
|
|
1008
|
+
"""Parse downloaded Green Button CSV text.
|
|
1009
|
+
|
|
1010
|
+
A thin seam over the file-based parser, which already accepts any text
|
|
1011
|
+
stream, so downloaded data and a file on disk go through exactly one
|
|
1012
|
+
implementation. Kept as its own function because that equivalence is the
|
|
1013
|
+
thing worth testing.
|
|
1014
|
+
"""
|
|
1015
|
+
import io
|
|
1016
|
+
|
|
1017
|
+
return read_green_button(io.StringIO(text), layout)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def time_interval(start: date, end: date) -> str:
|
|
1021
|
+
"""The half-open-looking interval Opower expects, in Pacific wall time.
|
|
1022
|
+
|
|
1023
|
+
``2025-12-30T00:00:00-08:00/2026-01-29T23:59:59-08:00``. The offsets are the
|
|
1024
|
+
ones in force on each end, so a cycle spanning a daylight-saving change
|
|
1025
|
+
carries two different offsets -- which is exactly what the portal itself
|
|
1026
|
+
sends, and why this is built from zoned datetimes rather than string
|
|
1027
|
+
concatenation.
|
|
1028
|
+
"""
|
|
1029
|
+
from ..timeutil import PACIFIC
|
|
1030
|
+
|
|
1031
|
+
first = datetime.combine(start, time(0, 0, 0), PACIFIC)
|
|
1032
|
+
last = datetime.combine(end, time(23, 59, 59), PACIFIC)
|
|
1033
|
+
return f"{first.isoformat()}/{last.isoformat()}"
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _export_config(interval: str, urn: str, fmt: str = "CSV") -> dict[str, Any]:
|
|
1037
|
+
"""The export request the portal sends, minus its localisation strings.
|
|
1038
|
+
|
|
1039
|
+
The portal also sends ~80 ``messages`` entries carrying the CSV's column
|
|
1040
|
+
headings. They are omitted: the file is parsed by column *position* and
|
|
1041
|
+
recognised heading names, so supplying English defaults changes nothing, and
|
|
1042
|
+
reproducing eighty translated strings would be eighty things to keep in step.
|
|
1043
|
+
"""
|
|
1044
|
+
return {
|
|
1045
|
+
"urns": [urn],
|
|
1046
|
+
"utilityCode": "pge",
|
|
1047
|
+
"format": fmt,
|
|
1048
|
+
"timeInterval": interval,
|
|
1049
|
+
"forceLegacyData": False,
|
|
1050
|
+
# Three years, which is what the portal offers and how far back a
|
|
1051
|
+
# historical bill can be re-derived from meter data.
|
|
1052
|
+
"maxAgeOfDataInDays": MAX_HISTORY_DAYS,
|
|
1053
|
+
"enableFinerResolutions": False,
|
|
1054
|
+
"enableServiceAgreementAliasing": True,
|
|
1055
|
+
"accountNicknameSource": "VMODEL",
|
|
1056
|
+
"displayNameStrategy": "UTILITY_ACCOUNT_NICKNAME_AS_DISPLAY_NAME_STRATEGY",
|
|
1057
|
+
"fileUtilityCode": "",
|
|
1058
|
+
"hideBillingCosts": False,
|
|
1059
|
+
"hideIntervalCosts": False,
|
|
1060
|
+
"showAccountNickname": False,
|
|
1061
|
+
"showAccountNumber": True,
|
|
1062
|
+
"showDevice": False,
|
|
1063
|
+
"showOnlyNetUsage": False,
|
|
1064
|
+
"showServicePoint": False,
|
|
1065
|
+
"messages": [],
|
|
1066
|
+
"unitsOfMeasureAllowed": [],
|
|
1067
|
+
"utilityServiceQuantityIdentifiersAllowed": [],
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def download_green_button(
|
|
1072
|
+
session: PgeSession, start: date, end: date, *, fmt: str = "CSV", poll_seconds: float = 2.0
|
|
1073
|
+
) -> str:
|
|
1074
|
+
"""The Green Button CSV for ``[start, end]``, as text.
|
|
1075
|
+
|
|
1076
|
+
Returns text rather than a path so the caller decides whether it ever
|
|
1077
|
+
reaches disk -- it carries the customer's name and address.
|
|
1078
|
+
|
|
1079
|
+
Three steps, because the portal makes it three: ask for a file, poll until
|
|
1080
|
+
the job finishes, then fetch the pre-authenticated URL it hands back. The
|
|
1081
|
+
file is a ZIP containing one CSV whose name says ``interval_data``: 15-minute
|
|
1082
|
+
readings, despite the archive being called DailyUsageData.
|
|
1083
|
+
"""
|
|
1084
|
+
host, token, urn = session.opower()
|
|
1085
|
+
job = session.graphql(
|
|
1086
|
+
host,
|
|
1087
|
+
token,
|
|
1088
|
+
urn,
|
|
1089
|
+
"WUE_GenerateUsageExportFile",
|
|
1090
|
+
GENERATE_EXPORT,
|
|
1091
|
+
{"usageExportFileConfigurationInput": _export_config(time_interval(start, end), urn, fmt)},
|
|
1092
|
+
)
|
|
1093
|
+
uuid = (job.get("generateUsageExportFile") or {}).get("uuid")
|
|
1094
|
+
if not uuid:
|
|
1095
|
+
raise PortalError("the export request returned no job id", endpoint="green_button")
|
|
1096
|
+
|
|
1097
|
+
for _ in range(EXPORT_POLL_LIMIT):
|
|
1098
|
+
state = session.graphql(
|
|
1099
|
+
host,
|
|
1100
|
+
token,
|
|
1101
|
+
urn,
|
|
1102
|
+
"WUE_GetExportJob",
|
|
1103
|
+
EXPORT_JOB,
|
|
1104
|
+
{"jobUuid": uuid, "forceLegacyData": True, "locale": "en-US"},
|
|
1105
|
+
)
|
|
1106
|
+
report = state.get("exportJob") or {}
|
|
1107
|
+
if report.get("isFailed"):
|
|
1108
|
+
raise PortalError(f"the export job {uuid} failed", endpoint="green_button")
|
|
1109
|
+
if report.get("isFinished") and report.get("result"):
|
|
1110
|
+
return _unzip_csv(session.fetch_bytes(str(report["result"])))
|
|
1111
|
+
sleep(poll_seconds)
|
|
1112
|
+
|
|
1113
|
+
raise PortalError(
|
|
1114
|
+
f"the export job {uuid} did not finish within {EXPORT_POLL_LIMIT * poll_seconds:.0f}s",
|
|
1115
|
+
endpoint="green_button",
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _unzip_csv(payload: bytes) -> str:
|
|
1120
|
+
"""The one CSV inside the exported archive."""
|
|
1121
|
+
import io
|
|
1122
|
+
import zipfile
|
|
1123
|
+
|
|
1124
|
+
if not payload.startswith(b"PK"):
|
|
1125
|
+
# Served the CSV directly. Accepted rather than rejected: the shape of
|
|
1126
|
+
# the response is the portal's business, and either is usable.
|
|
1127
|
+
return payload.decode("utf-8-sig")
|
|
1128
|
+
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
|
1129
|
+
names = [n for n in archive.namelist() if n.lower().endswith(".csv")]
|
|
1130
|
+
if not names:
|
|
1131
|
+
raise PortalError(
|
|
1132
|
+
f"the export archive holds no CSV, only {archive.namelist()}",
|
|
1133
|
+
endpoint="green_button",
|
|
1134
|
+
)
|
|
1135
|
+
return archive.read(names[0]).decode("utf-8-sig")
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def read_green_button_download(
|
|
1139
|
+
settings: PgeSettings,
|
|
1140
|
+
start: date,
|
|
1141
|
+
end: date,
|
|
1142
|
+
*,
|
|
1143
|
+
layout: GreenButtonLayout | None = None,
|
|
1144
|
+
) -> list[IntervalReading]:
|
|
1145
|
+
"""Download and parse Green Button data for ``[start, end]``.
|
|
1146
|
+
|
|
1147
|
+
Follows the ``read_*(settings, start, end, ...)`` shape the other sources
|
|
1148
|
+
use. Dates rather than datetimes: a billing cycle is date-bounded, and the
|
|
1149
|
+
portal's own selector is a date range.
|
|
1150
|
+
"""
|
|
1151
|
+
with PgeSession(settings) as session:
|
|
1152
|
+
session.login()
|
|
1153
|
+
return parse_green_button(download_green_button(session, start, end), layout)
|