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.
Files changed (83) hide show
  1. tariffkit/__init__.py +52 -0
  2. tariffkit/account/__init__.py +41 -0
  3. tariffkit/account/cli.py +493 -0
  4. tariffkit/account/errors.py +25 -0
  5. tariffkit/account/model.py +641 -0
  6. tariffkit/account/rates.py +61 -0
  7. tariffkit/account/repository.py +329 -0
  8. tariffkit/billing/__init__.py +56 -0
  9. tariffkit/billing/engine.py +505 -0
  10. tariffkit/billing/ledger.py +365 -0
  11. tariffkit/billing/models.py +274 -0
  12. tariffkit/billing/netting.py +137 -0
  13. tariffkit/billing/trueup.py +498 -0
  14. tariffkit/cca.py +122 -0
  15. tariffkit/cli.py +809 -0
  16. tariffkit/config.py +296 -0
  17. tariffkit/data/__init__.py +40 -0
  18. tariffkit/data/cca/mce/2023-01-01.toml +61 -0
  19. tariffkit/data/cca/mce/2026-04-01.toml +152 -0
  20. tariffkit/data/export/pge/acc_plus/2023-04-15.toml +43 -0
  21. tariffkit/data/export/pge/nbt00.json.gz +0 -0
  22. tariffkit/data/export/pge/nbt23.json.gz +0 -0
  23. tariffkit/data/export/pge/nbt24.json.gz +0 -0
  24. tariffkit/data/export/pge/nbt25.json.gz +0 -0
  25. tariffkit/data/export/pge/nbt26.json.gz +0 -0
  26. tariffkit/data/holidays.toml +36 -0
  27. tariffkit/data/manifest.json +56 -0
  28. tariffkit/data/nsc/pge.toml +57 -0
  29. tariffkit/data/tariff/pge/eelec/2025-01-01.toml +151 -0
  30. tariffkit/data/tariff/pge/eelec/2025-03-01.toml +150 -0
  31. tariffkit/data/tariff/pge/eelec/2025-09-01.toml +150 -0
  32. tariffkit/data/tariff/pge/eelec/2026-01-01.toml +153 -0
  33. tariffkit/data/tariff/pge/eelec/2026-03-01.toml +157 -0
  34. tariffkit/data/tariff/pge/etouc/2025-01-01.toml +222 -0
  35. tariffkit/data/tariff/pge/etouc/2025-03-01.toml +221 -0
  36. tariffkit/data/tariff/pge/etouc/2025-09-01.toml +221 -0
  37. tariffkit/data/tariff/pge/etouc/2026-01-01.toml +224 -0
  38. tariffkit/data/tariff/pge/etouc/2026-03-01.toml +231 -0
  39. tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +144 -0
  40. tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +143 -0
  41. tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +143 -0
  42. tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +146 -0
  43. tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +153 -0
  44. tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +27 -0
  45. tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +27 -0
  46. tariffkit/data/versioned.py +118 -0
  47. tariffkit/engine.py +82 -0
  48. tariffkit/errors.py +19 -0
  49. tariffkit/export/__init__.py +5 -0
  50. tariffkit/export/nbt.py +207 -0
  51. tariffkit/interop/__init__.py +21 -0
  52. tariffkit/interop/emhass.py +79 -0
  53. tariffkit/interop/predbat.py +102 -0
  54. tariffkit/interop/slots.py +63 -0
  55. tariffkit/models.py +164 -0
  56. tariffkit/mqtt/__init__.py +6 -0
  57. tariffkit/mqtt/discovery.py +84 -0
  58. tariffkit/mqtt/publisher.py +305 -0
  59. tariffkit/providers/__init__.py +1 -0
  60. tariffkit/providers/pge/__init__.py +33 -0
  61. tariffkit/providers/pge/reconcile.py +828 -0
  62. tariffkit/providers/pge/statements/__init__.py +26 -0
  63. tariffkit/providers/pge/statements/errors.py +20 -0
  64. tariffkit/providers/pge/statements/model.py +320 -0
  65. tariffkit/providers/pge/statements/ocr.py +193 -0
  66. tariffkit/providers/pge/statements/parse.py +813 -0
  67. tariffkit/py.typed +0 -0
  68. tariffkit/secrets.py +164 -0
  69. tariffkit/sources/__init__.py +71 -0
  70. tariffkit/sources/greenbutton.py +318 -0
  71. tariffkit/sources/homeassistant.py +342 -0
  72. tariffkit/sources/influx.py +359 -0
  73. tariffkit/sources/pge.py +1153 -0
  74. tariffkit/tariff/__init__.py +5 -0
  75. tariffkit/tariff/retail.py +271 -0
  76. tariffkit/timeutil.py +120 -0
  77. tariffkit/web/__init__.py +5 -0
  78. tariffkit/web/app.py +220 -0
  79. tariffkit-0.2.0.dist-info/METADATA +260 -0
  80. tariffkit-0.2.0.dist-info/RECORD +83 -0
  81. tariffkit-0.2.0.dist-info/WHEEL +4 -0
  82. tariffkit-0.2.0.dist-info/entry_points.txt +2 -0
  83. tariffkit-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -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)