switch2 0.1.3__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {switch2-0.1.3 → switch2-0.2.1}/PKG-INFO +23 -1
- {switch2-0.1.3 → switch2-0.2.1}/README.md +22 -0
- {switch2-0.1.3 → switch2-0.2.1}/pyproject.toml +1 -1
- {switch2-0.1.3 → switch2-0.2.1}/switch2/__init__.py +6 -0
- switch2-0.2.1/switch2/__main__.py +75 -0
- {switch2-0.1.3 → switch2-0.2.1}/switch2/api.py +136 -113
- {switch2-0.1.3 → switch2-0.2.1}/switch2.egg-info/PKG-INFO +23 -1
- {switch2-0.1.3 → switch2-0.2.1}/switch2.egg-info/SOURCES.txt +1 -0
- {switch2-0.1.3 → switch2-0.2.1}/tests/test_api.py +168 -0
- {switch2-0.1.3 → switch2-0.2.1}/setup.cfg +0 -0
- {switch2-0.1.3 → switch2-0.2.1}/switch2/py.typed +0 -0
- {switch2-0.1.3 → switch2-0.2.1}/switch2.egg-info/dependency_links.txt +0 -0
- {switch2-0.1.3 → switch2-0.2.1}/switch2.egg-info/requires.txt +0 -0
- {switch2-0.1.3 → switch2-0.2.1}/switch2.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: switch2
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Async client library for the Switch2 energy portal
|
|
5
5
|
Author-email: Jelmer Vernooij <jelmer@jelmer.uk>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -37,6 +37,7 @@ pip install switch2
|
|
|
37
37
|
import asyncio
|
|
38
38
|
from switch2 import Switch2ApiClient
|
|
39
39
|
|
|
40
|
+
|
|
40
41
|
async def main():
|
|
41
42
|
async with Switch2ApiClient("you@example.com", "your-password") as client:
|
|
42
43
|
data = await client.fetch_data()
|
|
@@ -50,9 +51,30 @@ async def main():
|
|
|
50
51
|
detail = await client.fetch_bill_detail(bill)
|
|
51
52
|
print(f" invoice {detail.invoice_number}, total £{detail.total}")
|
|
52
53
|
|
|
54
|
+
|
|
53
55
|
asyncio.run(main())
|
|
54
56
|
```
|
|
55
57
|
|
|
58
|
+
All exceptions derive from `Switch2Error`. `Switch2AuthError` means the
|
|
59
|
+
portal rejected the credentials; `Switch2ParseError` means a page did not
|
|
60
|
+
look the way the parser expects, which usually means the portal changed;
|
|
61
|
+
`Switch2UnavailableError` means the portal failed to load data it normally
|
|
62
|
+
has, which tends to pass if you try again later.
|
|
63
|
+
|
|
64
|
+
`data.readings` holds the readings of the register the portal selects by
|
|
65
|
+
default; `data.register_readings` maps each register in `data.registers`
|
|
66
|
+
to its readings.
|
|
67
|
+
|
|
68
|
+
## Command line
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
SWITCH2_PASSWORD=... python -m switch2 you@example.com
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
prints what the portal reports. With `--dump-html DIR` it also saves every
|
|
75
|
+
page it fetches, which is the easiest way to capture a page the parser gets
|
|
76
|
+
wrong.
|
|
77
|
+
|
|
56
78
|
## License
|
|
57
79
|
|
|
58
80
|
Apache-2.0
|
|
@@ -14,6 +14,7 @@ pip install switch2
|
|
|
14
14
|
import asyncio
|
|
15
15
|
from switch2 import Switch2ApiClient
|
|
16
16
|
|
|
17
|
+
|
|
17
18
|
async def main():
|
|
18
19
|
async with Switch2ApiClient("you@example.com", "your-password") as client:
|
|
19
20
|
data = await client.fetch_data()
|
|
@@ -27,9 +28,30 @@ async def main():
|
|
|
27
28
|
detail = await client.fetch_bill_detail(bill)
|
|
28
29
|
print(f" invoice {detail.invoice_number}, total £{detail.total}")
|
|
29
30
|
|
|
31
|
+
|
|
30
32
|
asyncio.run(main())
|
|
31
33
|
```
|
|
32
34
|
|
|
35
|
+
All exceptions derive from `Switch2Error`. `Switch2AuthError` means the
|
|
36
|
+
portal rejected the credentials; `Switch2ParseError` means a page did not
|
|
37
|
+
look the way the parser expects, which usually means the portal changed;
|
|
38
|
+
`Switch2UnavailableError` means the portal failed to load data it normally
|
|
39
|
+
has, which tends to pass if you try again later.
|
|
40
|
+
|
|
41
|
+
`data.readings` holds the readings of the register the portal selects by
|
|
42
|
+
default; `data.register_readings` maps each register in `data.registers`
|
|
43
|
+
to its readings.
|
|
44
|
+
|
|
45
|
+
## Command line
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
SWITCH2_PASSWORD=... python -m switch2 you@example.com
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
prints what the portal reports. With `--dump-html DIR` it also saves every
|
|
52
|
+
page it fetches, which is the easiest way to capture a page the parser gets
|
|
53
|
+
wrong.
|
|
54
|
+
|
|
33
55
|
## License
|
|
34
56
|
|
|
35
57
|
Apache-2.0
|
|
@@ -11,6 +11,9 @@ from .api import (
|
|
|
11
11
|
Switch2AuthError,
|
|
12
12
|
Switch2ConnectionError,
|
|
13
13
|
Switch2Data,
|
|
14
|
+
Switch2Error,
|
|
15
|
+
Switch2ParseError,
|
|
16
|
+
Switch2UnavailableError,
|
|
14
17
|
)
|
|
15
18
|
|
|
16
19
|
__all__ = [
|
|
@@ -24,4 +27,7 @@ __all__ = [
|
|
|
24
27
|
"Switch2AuthError",
|
|
25
28
|
"Switch2ConnectionError",
|
|
26
29
|
"Switch2Data",
|
|
30
|
+
"Switch2Error",
|
|
31
|
+
"Switch2ParseError",
|
|
32
|
+
"Switch2UnavailableError",
|
|
27
33
|
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Print what the Switch2 portal reports for an account.
|
|
2
|
+
|
|
3
|
+
Usage: python -m switch2 [--dump-html DIR] EMAIL
|
|
4
|
+
|
|
5
|
+
The password is read from $SWITCH2_PASSWORD, or prompted for.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import getpass
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
|
|
18
|
+
from .api import Switch2ApiClient
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _page_saver(directory: Path) -> Callable[[str, str], None]:
|
|
22
|
+
counts: dict[str, int] = {}
|
|
23
|
+
|
|
24
|
+
def save(url: str, html: str) -> None:
|
|
25
|
+
name = urlparse(url).path.strip("/").replace("/", "_") or "index"
|
|
26
|
+
counts[name] = counts.get(name, 0) + 1
|
|
27
|
+
path = directory / f"{name}-{counts[name]}.html"
|
|
28
|
+
path.write_text(html)
|
|
29
|
+
print(f"Saved {url} to {path}")
|
|
30
|
+
|
|
31
|
+
return save
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _run(email: str, password: str, dump_dir: Path | None) -> None:
|
|
35
|
+
on_page: Callable[[str, str], None] | None = None
|
|
36
|
+
if dump_dir is not None:
|
|
37
|
+
dump_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
on_page = _page_saver(dump_dir)
|
|
39
|
+
|
|
40
|
+
async with Switch2ApiClient(email, password, on_page=on_page) as client:
|
|
41
|
+
data = await client.fetch_data()
|
|
42
|
+
print(f"Customer: {data.customer}")
|
|
43
|
+
print(f"Account balance: {data.account_balance}")
|
|
44
|
+
for register_id, name in data.registers.items():
|
|
45
|
+
readings = data.register_readings.get(register_id, [])
|
|
46
|
+
print(f"Register {register_id} ({name}): {len(readings)} readings")
|
|
47
|
+
for reading in readings[:3]:
|
|
48
|
+
print(f" {reading}")
|
|
49
|
+
print(f"Bills: {len(data.bills)}")
|
|
50
|
+
if data.bills:
|
|
51
|
+
print(f"Latest bill: {data.bills[0]}")
|
|
52
|
+
detail = await client.fetch_bill_detail(data.bills[0])
|
|
53
|
+
for charge in detail.consumption_charges:
|
|
54
|
+
print(f" consumption: {charge}")
|
|
55
|
+
for charge in detail.other_charges:
|
|
56
|
+
print(f" other: {charge}")
|
|
57
|
+
print(f" total excl. VAT: {detail.total_excl_vat}, VAT: {detail.vat}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main() -> None:
|
|
61
|
+
parser = argparse.ArgumentParser(prog="python -m switch2")
|
|
62
|
+
parser.add_argument("email")
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--dump-html",
|
|
65
|
+
type=Path,
|
|
66
|
+
metavar="DIR",
|
|
67
|
+
help="save every page fetched to DIR",
|
|
68
|
+
)
|
|
69
|
+
args = parser.parse_args()
|
|
70
|
+
password = os.environ.get("SWITCH2_PASSWORD") or getpass.getpass()
|
|
71
|
+
asyncio.run(_run(args.email, password, args.dump_html))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import re
|
|
6
7
|
from collections.abc import Callable
|
|
7
|
-
from dataclasses import dataclass
|
|
8
|
+
from dataclasses import dataclass, field
|
|
8
9
|
from datetime import datetime
|
|
9
10
|
from types import TracebackType
|
|
10
11
|
|
|
@@ -21,14 +22,30 @@ BILL_HISTORY_URL = f"{BASE_URL}/Credit/BillHistory"
|
|
|
21
22
|
DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=60)
|
|
22
23
|
|
|
23
24
|
|
|
24
|
-
class
|
|
25
|
-
"""
|
|
25
|
+
class Switch2Error(Exception):
|
|
26
|
+
"""Base class for errors raised by this library."""
|
|
26
27
|
|
|
27
28
|
|
|
28
|
-
class
|
|
29
|
+
class Switch2AuthError(Switch2Error):
|
|
30
|
+
"""Raised when the portal rejects the credentials."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Switch2ConnectionError(Switch2Error):
|
|
29
34
|
"""Raised when a connection error occurs."""
|
|
30
35
|
|
|
31
36
|
|
|
37
|
+
class Switch2ParseError(Switch2Error):
|
|
38
|
+
"""Raised when a portal page does not have the expected structure."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Switch2UnavailableError(Switch2Error):
|
|
42
|
+
"""Raised when the portal fails to load data it normally has.
|
|
43
|
+
|
|
44
|
+
This happens from time to time, e.g. it lists no meters for an account
|
|
45
|
+
that has them; trying again later usually works.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
32
49
|
@dataclass
|
|
33
50
|
class CustomerInfo:
|
|
34
51
|
"""Customer information from the Switch2 portal."""
|
|
@@ -65,6 +82,20 @@ class BillCharge:
|
|
|
65
82
|
units: str
|
|
66
83
|
charge: float
|
|
67
84
|
|
|
85
|
+
@property
|
|
86
|
+
def quantity(self) -> float | None:
|
|
87
|
+
"""The number of units charged for, e.g. 252.0 for "252 kWh"."""
|
|
88
|
+
try:
|
|
89
|
+
return float(self.units.split()[0].replace(",", ""))
|
|
90
|
+
except (IndexError, ValueError):
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def unit(self) -> str:
|
|
95
|
+
"""The unit charged for, e.g. "kWh" for "252 kWh"."""
|
|
96
|
+
parts = self.units.split(maxsplit=1)
|
|
97
|
+
return parts[1] if len(parts) > 1 else ""
|
|
98
|
+
|
|
68
99
|
|
|
69
100
|
@dataclass
|
|
70
101
|
class BillDetail:
|
|
@@ -98,10 +129,13 @@ class Switch2Data:
|
|
|
98
129
|
"""All data fetched from the Switch2 portal."""
|
|
99
130
|
|
|
100
131
|
customer: CustomerInfo
|
|
132
|
+
# Readings for the register the portal selects by default.
|
|
101
133
|
readings: list[MeterReading]
|
|
102
134
|
registers: dict[str, str] # register_id -> register_name
|
|
103
135
|
bills: list[Bill]
|
|
104
136
|
account_balance: AccountBalance | None
|
|
137
|
+
# register_id -> readings, newest first
|
|
138
|
+
register_readings: dict[str, list[MeterReading]] = field(default_factory=dict)
|
|
105
139
|
|
|
106
140
|
|
|
107
141
|
def _get_attr(tag: Tag, attr: str, default: str = "") -> str:
|
|
@@ -115,9 +149,21 @@ def _get_attr(tag: Tag, attr: str, default: str = "") -> str:
|
|
|
115
149
|
class Switch2ApiClient:
|
|
116
150
|
"""Client to interact with the Switch2 web portal."""
|
|
117
151
|
|
|
118
|
-
def __init__(
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
email: str,
|
|
155
|
+
password: str,
|
|
156
|
+
on_page: Callable[[str, str], None] | None = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Create a client.
|
|
159
|
+
|
|
160
|
+
``on_page`` is called with the URL and HTML of every page fetched,
|
|
161
|
+
which is useful for capturing pages when the parser falls behind
|
|
162
|
+
changes to the portal.
|
|
163
|
+
"""
|
|
119
164
|
self._email = email
|
|
120
165
|
self._password = password
|
|
166
|
+
self._on_page = on_page
|
|
121
167
|
self._session: aiohttp.ClientSession | None = None
|
|
122
168
|
|
|
123
169
|
async def _ensure_session(self) -> aiohttp.ClientSession:
|
|
@@ -141,51 +187,44 @@ class Switch2ApiClient:
|
|
|
141
187
|
) -> None:
|
|
142
188
|
await self.close()
|
|
143
189
|
|
|
144
|
-
async def
|
|
145
|
-
|
|
190
|
+
async def _fetch(
|
|
191
|
+
self, url: str, what: str, data: dict[str, str] | None = None
|
|
192
|
+
) -> BeautifulSoup:
|
|
193
|
+
"""GET (or with ``data``, POST to) a page and parse it."""
|
|
146
194
|
session = await self._ensure_session()
|
|
147
|
-
|
|
148
195
|
try:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
)
|
|
155
|
-
html = await resp.text()
|
|
156
|
-
|
|
157
|
-
soup = BeautifulSoup(html, "html.parser")
|
|
158
|
-
token_input = soup.select_one('input[name="__RequestVerificationToken"]')
|
|
159
|
-
form_data: dict[str, str] = {}
|
|
160
|
-
if token_input:
|
|
161
|
-
form_data["__RequestVerificationToken"] = _get_attr(
|
|
162
|
-
token_input, "value"
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
# Step 2: POST login credentials
|
|
166
|
-
form_data["UserName"] = self._email
|
|
167
|
-
form_data["Password"] = self._password
|
|
168
|
-
|
|
169
|
-
async with session.post(
|
|
170
|
-
LOGIN_URL, data=form_data, allow_redirects=True
|
|
171
|
-
) as resp:
|
|
196
|
+
if data is None:
|
|
197
|
+
request = session.get(url)
|
|
198
|
+
else:
|
|
199
|
+
request = session.post(url, data=data, allow_redirects=True)
|
|
200
|
+
async with request as resp:
|
|
172
201
|
if resp.status != 200:
|
|
173
202
|
raise Switch2ConnectionError(
|
|
174
|
-
f"
|
|
203
|
+
f"Failed to load {what}: HTTP {resp.status}"
|
|
175
204
|
)
|
|
176
205
|
html = await resp.text()
|
|
177
|
-
|
|
178
|
-
soup = BeautifulSoup(html, "html.parser")
|
|
179
|
-
customer = _parse_customer_info(soup)
|
|
180
|
-
if not customer.name:
|
|
181
|
-
raise Switch2AuthError("Login failed: no customer info returned")
|
|
182
|
-
|
|
183
|
-
return customer, soup
|
|
184
|
-
|
|
185
206
|
except aiohttp.ClientError as err:
|
|
186
207
|
raise Switch2ConnectionError(
|
|
187
|
-
f"Connection error
|
|
208
|
+
f"Connection error loading {what}: {err}"
|
|
188
209
|
) from err
|
|
210
|
+
if self._on_page is not None:
|
|
211
|
+
self._on_page(url, html)
|
|
212
|
+
return BeautifulSoup(html, "html.parser")
|
|
213
|
+
|
|
214
|
+
async def authenticate(self) -> tuple[CustomerInfo, BeautifulSoup]:
|
|
215
|
+
"""Log in to the Switch2 portal and return customer info and dashboard soup."""
|
|
216
|
+
soup = await self._fetch(LOGIN_URL, "login page")
|
|
217
|
+
form_data = _form_token(soup)
|
|
218
|
+
form_data["UserName"] = self._email
|
|
219
|
+
form_data["Password"] = self._password
|
|
220
|
+
|
|
221
|
+
soup = await self._fetch(LOGIN_URL, "login response", form_data)
|
|
222
|
+
if soup.select_one('input[name="Password"]') is not None:
|
|
223
|
+
raise Switch2AuthError("Login rejected by the Switch2 portal")
|
|
224
|
+
customer = _parse_customer_info(soup)
|
|
225
|
+
if not customer.name:
|
|
226
|
+
raise Switch2ParseError("No customer info on the page after logging in")
|
|
227
|
+
return customer, soup
|
|
189
228
|
|
|
190
229
|
async def fetch_bill_detail(self, bill: Bill) -> BillDetail:
|
|
191
230
|
"""Fetch and parse the detail page for a bill.
|
|
@@ -194,97 +233,81 @@ class Switch2ApiClient:
|
|
|
194
233
|
"""
|
|
195
234
|
if not bill.detail_url:
|
|
196
235
|
raise ValueError("Bill has no detail URL")
|
|
197
|
-
|
|
236
|
+
soup = await self._fetch(bill.detail_url, "bill detail")
|
|
198
237
|
try:
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
)
|
|
204
|
-
html = await resp.text()
|
|
205
|
-
except aiohttp.ClientError as err:
|
|
206
|
-
raise Switch2ConnectionError(
|
|
207
|
-
f"Connection error fetching bill detail: {err}"
|
|
238
|
+
return _parse_bill_detail(soup)
|
|
239
|
+
except ValueError as err:
|
|
240
|
+
raise Switch2ParseError(
|
|
241
|
+
f"Unrecognised bill detail page {bill.detail_url}: {err}"
|
|
208
242
|
) from err
|
|
209
|
-
return _parse_bill_detail(BeautifulSoup(html, "html.parser"))
|
|
210
243
|
|
|
211
244
|
async def fetch_data(self) -> Switch2Data:
|
|
212
245
|
"""Authenticate and fetch all meter data."""
|
|
213
246
|
customer, dashboard_soup = await self.authenticate()
|
|
214
247
|
account_balance = _parse_account_balance(dashboard_soup)
|
|
215
|
-
session = await self._ensure_session()
|
|
216
248
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
249
|
+
soup = await self._fetch(METER_HISTORY_URL, "meter history")
|
|
250
|
+
registers: dict[str, str] = {}
|
|
251
|
+
register_select = soup.select_one("#RegisterId")
|
|
252
|
+
if register_select:
|
|
253
|
+
for option in register_select.select("option"):
|
|
254
|
+
reg_id = _get_attr(option, "value")
|
|
255
|
+
if reg_id:
|
|
256
|
+
registers[reg_id] = option.text.strip()
|
|
257
|
+
if "-1" in registers:
|
|
258
|
+
raise Switch2UnavailableError(
|
|
259
|
+
f"Portal could not load any meters: {registers['-1']!r}"
|
|
260
|
+
)
|
|
225
261
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
"RegisterId": _get_attr(selected_register, "value"),
|
|
247
|
-
"PageSize": "1000000",
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
async with session.post(
|
|
251
|
-
METER_HISTORY_URL, data=form_data, allow_redirects=True
|
|
252
|
-
) as resp:
|
|
253
|
-
if resp.status == 200:
|
|
254
|
-
html = await resp.text()
|
|
255
|
-
soup = BeautifulSoup(html, "html.parser")
|
|
262
|
+
selected = soup.select_one("#RegisterId option[selected]")
|
|
263
|
+
selected_id = _get_attr(selected, "value") if selected else ""
|
|
264
|
+
token = _form_token(soup)
|
|
265
|
+
|
|
266
|
+
register_readings: dict[str, list[MeterReading]] = {}
|
|
267
|
+
if token:
|
|
268
|
+
for register_id in registers:
|
|
269
|
+
# Ask for everything on a single page.
|
|
270
|
+
history = await self._fetch(
|
|
271
|
+
METER_HISTORY_URL,
|
|
272
|
+
f"meter history for register {register_id}",
|
|
273
|
+
{
|
|
274
|
+
**token,
|
|
275
|
+
"Page": "1",
|
|
276
|
+
"TotalPages": "1",
|
|
277
|
+
"RegisterId": register_id,
|
|
278
|
+
"PageSize": "1000000",
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
register_readings[register_id] = _parse_readings(history)
|
|
256
282
|
|
|
283
|
+
if selected_id in register_readings:
|
|
284
|
+
readings = register_readings[selected_id]
|
|
285
|
+
else:
|
|
257
286
|
readings = _parse_readings(soup)
|
|
258
287
|
|
|
259
|
-
|
|
260
|
-
async with session.get(BILL_HISTORY_URL) as resp:
|
|
261
|
-
if resp.status != 200:
|
|
262
|
-
raise Switch2ConnectionError(
|
|
263
|
-
f"Failed to load bill history: HTTP {resp.status}"
|
|
264
|
-
)
|
|
265
|
-
html = await resp.text()
|
|
288
|
+
bills = _parse_bills(await self._fetch(BILL_HISTORY_URL, "bill history"))
|
|
266
289
|
|
|
267
|
-
|
|
290
|
+
return Switch2Data(
|
|
291
|
+
customer=customer,
|
|
292
|
+
readings=readings,
|
|
293
|
+
registers=registers,
|
|
294
|
+
bills=bills,
|
|
295
|
+
account_balance=account_balance,
|
|
296
|
+
register_readings=register_readings,
|
|
297
|
+
)
|
|
268
298
|
|
|
269
|
-
return Switch2Data(
|
|
270
|
-
customer=customer,
|
|
271
|
-
readings=readings,
|
|
272
|
-
registers=registers,
|
|
273
|
-
bills=bills,
|
|
274
|
-
account_balance=account_balance,
|
|
275
|
-
)
|
|
276
299
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
300
|
+
def _form_token(soup: BeautifulSoup) -> dict[str, str]:
|
|
301
|
+
"""Return the anti-forgery token of the form on a page, if it has one."""
|
|
302
|
+
token_input = soup.select_one('input[name="__RequestVerificationToken"]')
|
|
303
|
+
if token_input is None:
|
|
304
|
+
return {}
|
|
305
|
+
return {"__RequestVerificationToken": _get_attr(token_input, "value")}
|
|
281
306
|
|
|
282
307
|
|
|
283
308
|
def _parse_date(text: str) -> datetime:
|
|
284
309
|
"""Parse a date string like '27th February 2026'."""
|
|
285
|
-
|
|
286
|
-
for suffix in ("st ", "nd ", "rd ", "th "):
|
|
287
|
-
text = text.replace(suffix, " ")
|
|
310
|
+
text = re.sub(r"^(\d+)(?:st|nd|rd|th)\b", r"\1", text.strip())
|
|
288
311
|
for fmt in ("%d %B %Y", "%d %b %Y"):
|
|
289
312
|
try:
|
|
290
313
|
return datetime.strptime(text, fmt)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: switch2
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Async client library for the Switch2 energy portal
|
|
5
5
|
Author-email: Jelmer Vernooij <jelmer@jelmer.uk>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -37,6 +37,7 @@ pip install switch2
|
|
|
37
37
|
import asyncio
|
|
38
38
|
from switch2 import Switch2ApiClient
|
|
39
39
|
|
|
40
|
+
|
|
40
41
|
async def main():
|
|
41
42
|
async with Switch2ApiClient("you@example.com", "your-password") as client:
|
|
42
43
|
data = await client.fetch_data()
|
|
@@ -50,9 +51,30 @@ async def main():
|
|
|
50
51
|
detail = await client.fetch_bill_detail(bill)
|
|
51
52
|
print(f" invoice {detail.invoice_number}, total £{detail.total}")
|
|
52
53
|
|
|
54
|
+
|
|
53
55
|
asyncio.run(main())
|
|
54
56
|
```
|
|
55
57
|
|
|
58
|
+
All exceptions derive from `Switch2Error`. `Switch2AuthError` means the
|
|
59
|
+
portal rejected the credentials; `Switch2ParseError` means a page did not
|
|
60
|
+
look the way the parser expects, which usually means the portal changed;
|
|
61
|
+
`Switch2UnavailableError` means the portal failed to load data it normally
|
|
62
|
+
has, which tends to pass if you try again later.
|
|
63
|
+
|
|
64
|
+
`data.readings` holds the readings of the register the portal selects by
|
|
65
|
+
default; `data.register_readings` maps each register in `data.registers`
|
|
66
|
+
to its readings.
|
|
67
|
+
|
|
68
|
+
## Command line
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
SWITCH2_PASSWORD=... python -m switch2 you@example.com
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
prints what the portal reports. With `--dump-html DIR` it also saves every
|
|
75
|
+
page it fetches, which is the easiest way to capture a page the parser gets
|
|
76
|
+
wrong.
|
|
77
|
+
|
|
56
78
|
## License
|
|
57
79
|
|
|
58
80
|
Apache-2.0
|
|
@@ -7,7 +7,12 @@ from datetime import datetime
|
|
|
7
7
|
from bs4 import BeautifulSoup
|
|
8
8
|
|
|
9
9
|
from switch2.api import (
|
|
10
|
+
Bill,
|
|
11
|
+
BillCharge,
|
|
10
12
|
Switch2ApiClient,
|
|
13
|
+
Switch2AuthError,
|
|
14
|
+
Switch2ParseError,
|
|
15
|
+
Switch2UnavailableError,
|
|
11
16
|
_parse_account_balance,
|
|
12
17
|
_parse_bill_detail,
|
|
13
18
|
_parse_bills,
|
|
@@ -31,6 +36,10 @@ class ParseDateTests(unittest.TestCase):
|
|
|
31
36
|
def test_ordinal_rd(self) -> None:
|
|
32
37
|
self.assertEqual(_parse_date("3rd April 2025"), datetime(2025, 4, 3))
|
|
33
38
|
|
|
39
|
+
def test_month_containing_suffix(self) -> None:
|
|
40
|
+
self.assertEqual(_parse_date("27th August 2026"), datetime(2026, 8, 27))
|
|
41
|
+
self.assertEqual(_parse_date("1st August 2026"), datetime(2026, 8, 1))
|
|
42
|
+
|
|
34
43
|
def test_abbreviated_month(self) -> None:
|
|
35
44
|
self.assertEqual(_parse_date("15th Jan 2025"), datetime(2025, 1, 15))
|
|
36
45
|
|
|
@@ -402,3 +411,162 @@ class ParseAccountBalanceTests(unittest.TestCase):
|
|
|
402
411
|
def test_no_balance_element(self) -> None:
|
|
403
412
|
soup = BeautifulSoup("<div></div>", "html.parser")
|
|
404
413
|
self.assertIsNone(_parse_account_balance(soup))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class BillChargeTests(unittest.TestCase):
|
|
417
|
+
def test_kwh(self) -> None:
|
|
418
|
+
charge = BillCharge("Heat", "252 kWh", 42.87)
|
|
419
|
+
self.assertEqual(charge.quantity, 252.0)
|
|
420
|
+
self.assertEqual(charge.unit, "kWh")
|
|
421
|
+
|
|
422
|
+
def test_days(self) -> None:
|
|
423
|
+
charge = BillCharge("Heat Standing Charge", "30.00 days", 22.46)
|
|
424
|
+
self.assertEqual(charge.quantity, 30.0)
|
|
425
|
+
self.assertEqual(charge.unit, "days")
|
|
426
|
+
|
|
427
|
+
def test_thousands(self) -> None:
|
|
428
|
+
self.assertEqual(BillCharge("Heat", "1,234 kWh", 1.0).quantity, 1234.0)
|
|
429
|
+
|
|
430
|
+
def test_no_units(self) -> None:
|
|
431
|
+
charge = BillCharge("Adjustment", "", -3.0)
|
|
432
|
+
self.assertIsNone(charge.quantity)
|
|
433
|
+
self.assertEqual(charge.unit, "")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
LOGIN_FORM = """
|
|
437
|
+
<form>
|
|
438
|
+
<input name="__RequestVerificationToken" value="tok">
|
|
439
|
+
<input name="UserName"><input name="Password" type="password">
|
|
440
|
+
</form>
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
DASHBOARD = """
|
|
444
|
+
<span class="customer-info-name">John Doe</span>
|
|
445
|
+
<span class="customer-info-account-number">ACC-123</span>
|
|
446
|
+
<span class="customer-info-address">42 Test Lane</span>
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
METER_HISTORY = """
|
|
450
|
+
<form>
|
|
451
|
+
<input name="__RequestVerificationToken" value="tok2">
|
|
452
|
+
<select id="RegisterId">
|
|
453
|
+
<option value="R1" selected>Heat Meter</option>
|
|
454
|
+
<option value="R2">Hot Water Meter</option>
|
|
455
|
+
</select>
|
|
456
|
+
</form>
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _reading_row(date: str, amount: str) -> str:
|
|
461
|
+
return f"""
|
|
462
|
+
<div class="meter-reading-history-table-data-row desktop-layout">
|
|
463
|
+
<div class="meter-reading-history-table-data-date-row-item">{date}</div>
|
|
464
|
+
<div class="meter-reading-history-table-data-amount-row-item">{amount}</div>
|
|
465
|
+
<div class="meter-reading-history-table-data-type-row-item">Actual</div>
|
|
466
|
+
</div>
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class FakePortal:
|
|
471
|
+
"""Serves canned pages in place of Switch2ApiClient._fetch."""
|
|
472
|
+
|
|
473
|
+
def __init__(self, login_response: str, meter_history: str = METER_HISTORY) -> None:
|
|
474
|
+
self.login_response = login_response
|
|
475
|
+
self.meter_history = meter_history
|
|
476
|
+
self.posts: list[tuple[str, dict[str, str]]] = []
|
|
477
|
+
|
|
478
|
+
async def __call__(
|
|
479
|
+
self, url: str, what: str, data: dict[str, str] | None = None
|
|
480
|
+
) -> BeautifulSoup:
|
|
481
|
+
if data is not None:
|
|
482
|
+
self.posts.append((url, data))
|
|
483
|
+
if url.endswith("/Login"):
|
|
484
|
+
html = LOGIN_FORM if data is None else self.login_response
|
|
485
|
+
elif url.endswith("/MeterReadings/History"):
|
|
486
|
+
if data is None:
|
|
487
|
+
html = self.meter_history
|
|
488
|
+
elif data["RegisterId"] == "R1":
|
|
489
|
+
html = _reading_row("26th October 2026", "9166 kWh")
|
|
490
|
+
else:
|
|
491
|
+
html = _reading_row("26th October 2026", "512 kWh")
|
|
492
|
+
elif url.endswith("/Credit/BillHistory"):
|
|
493
|
+
html = "<div></div>"
|
|
494
|
+
else:
|
|
495
|
+
raise AssertionError(f"unexpected fetch of {url}")
|
|
496
|
+
return BeautifulSoup(html, "html.parser")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _client(portal: FakePortal) -> Switch2ApiClient:
|
|
500
|
+
client = Switch2ApiClient("e@example.com", "pw")
|
|
501
|
+
client._fetch = portal # type: ignore[method-assign]
|
|
502
|
+
return client
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class AuthenticateTests(unittest.TestCase):
|
|
506
|
+
def test_success(self) -> None:
|
|
507
|
+
portal = FakePortal(DASHBOARD)
|
|
508
|
+
customer, _ = asyncio.run(_client(portal).authenticate())
|
|
509
|
+
self.assertEqual(customer.account_number, "ACC-123")
|
|
510
|
+
self.assertEqual(
|
|
511
|
+
portal.posts,
|
|
512
|
+
[
|
|
513
|
+
(
|
|
514
|
+
"https://my.switch2.co.uk/Login",
|
|
515
|
+
{
|
|
516
|
+
"__RequestVerificationToken": "tok",
|
|
517
|
+
"UserName": "e@example.com",
|
|
518
|
+
"Password": "pw",
|
|
519
|
+
},
|
|
520
|
+
)
|
|
521
|
+
],
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
def test_rejected(self) -> None:
|
|
525
|
+
portal = FakePortal(LOGIN_FORM + "<p>Invalid login attempt.</p>")
|
|
526
|
+
with self.assertRaises(Switch2AuthError):
|
|
527
|
+
asyncio.run(_client(portal).authenticate())
|
|
528
|
+
|
|
529
|
+
def test_unrecognised_page(self) -> None:
|
|
530
|
+
portal = FakePortal("<p>Down for maintenance</p>")
|
|
531
|
+
with self.assertRaises(Switch2ParseError):
|
|
532
|
+
asyncio.run(_client(portal).authenticate())
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
class FetchBillDetailTests(unittest.TestCase):
|
|
536
|
+
def test_unrecognised_page(self) -> None:
|
|
537
|
+
client = Switch2ApiClient("e", "p")
|
|
538
|
+
|
|
539
|
+
async def fetch(url: str, what: str) -> BeautifulSoup:
|
|
540
|
+
return BeautifulSoup("<p>Something else</p>", "html.parser")
|
|
541
|
+
|
|
542
|
+
client._fetch = fetch # type: ignore[method-assign,assignment]
|
|
543
|
+
bill = Bill(datetime(2026, 10, 27), 104.03, "https://example/bill/1")
|
|
544
|
+
with self.assertRaises(Switch2ParseError):
|
|
545
|
+
asyncio.run(client.fetch_bill_detail(bill))
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class FetchDataTests(unittest.TestCase):
|
|
549
|
+
def test_readings_per_register(self) -> None:
|
|
550
|
+
data = asyncio.run(_client(FakePortal(DASHBOARD)).fetch_data())
|
|
551
|
+
self.assertEqual(data.registers, {"R1": "Heat Meter", "R2": "Hot Water Meter"})
|
|
552
|
+
self.assertEqual(
|
|
553
|
+
{r: [x.amount for x in v] for r, v in data.register_readings.items()},
|
|
554
|
+
{"R1": [9166.0], "R2": [512.0]},
|
|
555
|
+
)
|
|
556
|
+
self.assertEqual([x.amount for x in data.readings], [9166.0])
|
|
557
|
+
|
|
558
|
+
def test_no_meters_available(self) -> None:
|
|
559
|
+
portal = FakePortal(
|
|
560
|
+
DASHBOARD,
|
|
561
|
+
meter_history="""
|
|
562
|
+
<form>
|
|
563
|
+
<input name="__RequestVerificationToken" value="tok2">
|
|
564
|
+
<select id="RegisterId">
|
|
565
|
+
<option value="-1" selected>No meters available</option>
|
|
566
|
+
</select>
|
|
567
|
+
</form>
|
|
568
|
+
""",
|
|
569
|
+
)
|
|
570
|
+
with self.assertRaises(Switch2UnavailableError):
|
|
571
|
+
asyncio.run(_client(portal).fetch_data())
|
|
572
|
+
self.assertEqual(portal.posts[1:], [])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|