switch2 0.1.2__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {switch2-0.1.2 → switch2-0.2.0}/PKG-INFO +24 -5
- switch2-0.2.0/README.md +53 -0
- {switch2-0.1.2 → switch2-0.2.0}/pyproject.toml +4 -1
- {switch2-0.1.2 → switch2-0.2.0}/switch2/__init__.py +4 -0
- switch2-0.2.0/switch2/__main__.py +75 -0
- {switch2-0.1.2 → switch2-0.2.0}/switch2/api.py +254 -201
- switch2-0.2.0/switch2/py.typed +0 -0
- {switch2-0.1.2 → switch2-0.2.0}/switch2.egg-info/PKG-INFO +24 -5
- {switch2-0.1.2 → switch2-0.2.0}/switch2.egg-info/SOURCES.txt +2 -0
- {switch2-0.1.2 → switch2-0.2.0}/tests/test_api.py +157 -0
- switch2-0.1.2/README.md +0 -34
- {switch2-0.1.2 → switch2-0.2.0}/setup.cfg +0 -0
- {switch2-0.1.2 → switch2-0.2.0}/switch2.egg-info/dependency_links.txt +0 -0
- {switch2-0.1.2 → switch2-0.2.0}/switch2.egg-info/requires.txt +0 -0
- {switch2-0.1.2 → switch2-0.2.0}/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.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -38,20 +38,39 @@ import asyncio
|
|
|
38
38
|
from switch2 import Switch2ApiClient
|
|
39
39
|
|
|
40
40
|
async def main():
|
|
41
|
-
|
|
42
|
-
try:
|
|
41
|
+
async with Switch2ApiClient("you@example.com", "your-password") as client:
|
|
43
42
|
data = await client.fetch_data()
|
|
44
43
|
print(f"Customer: {data.customer.name}")
|
|
44
|
+
if data.account_balance is not None:
|
|
45
|
+
print(f"Balance: £{data.account_balance.balance}")
|
|
45
46
|
for reading in data.readings:
|
|
46
47
|
print(f" {reading.date}: {reading.amount} {reading.unit}")
|
|
47
48
|
for bill in data.bills:
|
|
48
49
|
print(f" {bill.date}: £{bill.amount}")
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
detail = await client.fetch_bill_detail(bill)
|
|
51
|
+
print(f" invoice {detail.invoice_number}, total £{detail.total}")
|
|
51
52
|
|
|
52
53
|
asyncio.run(main())
|
|
53
54
|
```
|
|
54
55
|
|
|
56
|
+
All exceptions derive from `Switch2Error`. `Switch2AuthError` means the
|
|
57
|
+
portal rejected the credentials; `Switch2ParseError` means a page did not
|
|
58
|
+
look the way the parser expects, which usually means the portal changed.
|
|
59
|
+
|
|
60
|
+
`data.readings` holds the readings of the register the portal selects by
|
|
61
|
+
default; `data.register_readings` maps each register in `data.registers`
|
|
62
|
+
to its readings.
|
|
63
|
+
|
|
64
|
+
## Command line
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
SWITCH2_PASSWORD=... python -m switch2 you@example.com
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
prints what the portal reports. With `--dump-html DIR` it also saves every
|
|
71
|
+
page it fetches, which is the easiest way to capture a page the parser gets
|
|
72
|
+
wrong.
|
|
73
|
+
|
|
55
74
|
## License
|
|
56
75
|
|
|
57
76
|
Apache-2.0
|
switch2-0.2.0/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# python-switch2
|
|
2
|
+
|
|
3
|
+
Async Python client library for the [Switch2](https://my.switch2.co.uk) energy portal.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install switch2
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import asyncio
|
|
15
|
+
from switch2 import Switch2ApiClient
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
async with Switch2ApiClient("you@example.com", "your-password") as client:
|
|
19
|
+
data = await client.fetch_data()
|
|
20
|
+
print(f"Customer: {data.customer.name}")
|
|
21
|
+
if data.account_balance is not None:
|
|
22
|
+
print(f"Balance: £{data.account_balance.balance}")
|
|
23
|
+
for reading in data.readings:
|
|
24
|
+
print(f" {reading.date}: {reading.amount} {reading.unit}")
|
|
25
|
+
for bill in data.bills:
|
|
26
|
+
print(f" {bill.date}: £{bill.amount}")
|
|
27
|
+
detail = await client.fetch_bill_detail(bill)
|
|
28
|
+
print(f" invoice {detail.invoice_number}, total £{detail.total}")
|
|
29
|
+
|
|
30
|
+
asyncio.run(main())
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
All exceptions derive from `Switch2Error`. `Switch2AuthError` means the
|
|
34
|
+
portal rejected the credentials; `Switch2ParseError` means a page did not
|
|
35
|
+
look the way the parser expects, which usually means the portal changed.
|
|
36
|
+
|
|
37
|
+
`data.readings` holds the readings of the register the portal selects by
|
|
38
|
+
default; `data.register_readings` maps each register in `data.registers`
|
|
39
|
+
to its readings.
|
|
40
|
+
|
|
41
|
+
## Command line
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
SWITCH2_PASSWORD=... python -m switch2 you@example.com
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
prints what the portal reports. With `--dump-html DIR` it also saves every
|
|
48
|
+
page it fetches, which is the easiest way to capture a page the parser gets
|
|
49
|
+
wrong.
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
Apache-2.0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "switch2"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Async client library for the Switch2 energy portal"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -33,6 +33,9 @@ Homepage = "https://github.com/jelmer/python-switch2"
|
|
|
33
33
|
Repository = "https://github.com/jelmer/python-switch2"
|
|
34
34
|
Issues = "https://github.com/jelmer/python-switch2/issues"
|
|
35
35
|
|
|
36
|
+
[tool.setuptools.package-data]
|
|
37
|
+
switch2 = ["py.typed"]
|
|
38
|
+
|
|
36
39
|
[tool.mypy]
|
|
37
40
|
packages = ["switch2"]
|
|
38
41
|
strict = true
|
|
@@ -11,6 +11,8 @@ from .api import (
|
|
|
11
11
|
Switch2AuthError,
|
|
12
12
|
Switch2ConnectionError,
|
|
13
13
|
Switch2Data,
|
|
14
|
+
Switch2Error,
|
|
15
|
+
Switch2ParseError,
|
|
14
16
|
)
|
|
15
17
|
|
|
16
18
|
__all__ = [
|
|
@@ -24,4 +26,6 @@ __all__ = [
|
|
|
24
26
|
"Switch2AuthError",
|
|
25
27
|
"Switch2ConnectionError",
|
|
26
28
|
"Switch2Data",
|
|
29
|
+
"Switch2Error",
|
|
30
|
+
"Switch2ParseError",
|
|
27
31
|
]
|
|
@@ -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,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
from
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
7
8
|
from datetime import datetime
|
|
9
|
+
from types import TracebackType
|
|
8
10
|
|
|
9
11
|
import aiohttp
|
|
10
12
|
from bs4 import BeautifulSoup, Tag
|
|
@@ -16,15 +18,25 @@ LOGIN_URL = f"{BASE_URL}/Login"
|
|
|
16
18
|
METER_HISTORY_URL = f"{BASE_URL}/MeterReadings/History"
|
|
17
19
|
BILL_HISTORY_URL = f"{BASE_URL}/Credit/BillHistory"
|
|
18
20
|
|
|
21
|
+
DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=60)
|
|
19
22
|
|
|
20
|
-
class Switch2AuthError(Exception):
|
|
21
|
-
"""Raised when authentication fails."""
|
|
22
23
|
|
|
24
|
+
class Switch2Error(Exception):
|
|
25
|
+
"""Base class for errors raised by this library."""
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
|
|
28
|
+
class Switch2AuthError(Switch2Error):
|
|
29
|
+
"""Raised when the portal rejects the credentials."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Switch2ConnectionError(Switch2Error):
|
|
25
33
|
"""Raised when a connection error occurs."""
|
|
26
34
|
|
|
27
35
|
|
|
36
|
+
class Switch2ParseError(Switch2Error):
|
|
37
|
+
"""Raised when a portal page does not have the expected structure."""
|
|
38
|
+
|
|
39
|
+
|
|
28
40
|
@dataclass
|
|
29
41
|
class CustomerInfo:
|
|
30
42
|
"""Customer information from the Switch2 portal."""
|
|
@@ -61,6 +73,20 @@ class BillCharge:
|
|
|
61
73
|
units: str
|
|
62
74
|
charge: float
|
|
63
75
|
|
|
76
|
+
@property
|
|
77
|
+
def quantity(self) -> float | None:
|
|
78
|
+
"""The number of units charged for, e.g. 252.0 for "252 kWh"."""
|
|
79
|
+
try:
|
|
80
|
+
return float(self.units.split()[0].replace(",", ""))
|
|
81
|
+
except (IndexError, ValueError):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def unit(self) -> str:
|
|
86
|
+
"""The unit charged for, e.g. "kWh" for "252 kWh"."""
|
|
87
|
+
parts = self.units.split(maxsplit=1)
|
|
88
|
+
return parts[1] if len(parts) > 1 else ""
|
|
89
|
+
|
|
64
90
|
|
|
65
91
|
@dataclass
|
|
66
92
|
class BillDetail:
|
|
@@ -94,10 +120,13 @@ class Switch2Data:
|
|
|
94
120
|
"""All data fetched from the Switch2 portal."""
|
|
95
121
|
|
|
96
122
|
customer: CustomerInfo
|
|
123
|
+
# Readings for the register the portal selects by default.
|
|
97
124
|
readings: list[MeterReading]
|
|
98
125
|
registers: dict[str, str] # register_id -> register_name
|
|
99
126
|
bills: list[Bill]
|
|
100
127
|
account_balance: AccountBalance | None
|
|
128
|
+
# register_id -> readings, newest first
|
|
129
|
+
register_readings: dict[str, list[MeterReading]] = field(default_factory=dict)
|
|
101
130
|
|
|
102
131
|
|
|
103
132
|
def _get_attr(tag: Tag, attr: str, default: str = "") -> str:
|
|
@@ -111,14 +140,26 @@ def _get_attr(tag: Tag, attr: str, default: str = "") -> str:
|
|
|
111
140
|
class Switch2ApiClient:
|
|
112
141
|
"""Client to interact with the Switch2 web portal."""
|
|
113
142
|
|
|
114
|
-
def __init__(
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
email: str,
|
|
146
|
+
password: str,
|
|
147
|
+
on_page: Callable[[str, str], None] | None = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Create a client.
|
|
150
|
+
|
|
151
|
+
``on_page`` is called with the URL and HTML of every page fetched,
|
|
152
|
+
which is useful for capturing pages when the parser falls behind
|
|
153
|
+
changes to the portal.
|
|
154
|
+
"""
|
|
115
155
|
self._email = email
|
|
116
156
|
self._password = password
|
|
157
|
+
self._on_page = on_page
|
|
117
158
|
self._session: aiohttp.ClientSession | None = None
|
|
118
159
|
|
|
119
160
|
async def _ensure_session(self) -> aiohttp.ClientSession:
|
|
120
161
|
if self._session is None or self._session.closed:
|
|
121
|
-
self._session = aiohttp.ClientSession()
|
|
162
|
+
self._session = aiohttp.ClientSession(timeout=DEFAULT_TIMEOUT)
|
|
122
163
|
return self._session
|
|
123
164
|
|
|
124
165
|
async def close(self) -> None:
|
|
@@ -126,51 +167,55 @@ class Switch2ApiClient:
|
|
|
126
167
|
if self._session and not self._session.closed:
|
|
127
168
|
await self._session.close()
|
|
128
169
|
|
|
129
|
-
async def
|
|
130
|
-
|
|
170
|
+
async def __aenter__(self) -> "Switch2ApiClient":
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
async def __aexit__(
|
|
174
|
+
self,
|
|
175
|
+
exc_type: type[BaseException] | None,
|
|
176
|
+
exc: BaseException | None,
|
|
177
|
+
tb: TracebackType | None,
|
|
178
|
+
) -> None:
|
|
179
|
+
await self.close()
|
|
180
|
+
|
|
181
|
+
async def _fetch(
|
|
182
|
+
self, url: str, what: str, data: dict[str, str] | None = None
|
|
183
|
+
) -> BeautifulSoup:
|
|
184
|
+
"""GET (or with ``data``, POST to) a page and parse it."""
|
|
131
185
|
session = await self._ensure_session()
|
|
132
|
-
|
|
133
186
|
try:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
)
|
|
140
|
-
html = await resp.text()
|
|
141
|
-
|
|
142
|
-
soup = BeautifulSoup(html, "html.parser")
|
|
143
|
-
token_input = soup.select_one('input[name="__RequestVerificationToken"]')
|
|
144
|
-
form_data: dict[str, str] = {}
|
|
145
|
-
if token_input:
|
|
146
|
-
form_data["__RequestVerificationToken"] = _get_attr(
|
|
147
|
-
token_input, "value"
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
# Step 2: POST login credentials
|
|
151
|
-
form_data["UserName"] = self._email
|
|
152
|
-
form_data["Password"] = self._password
|
|
153
|
-
|
|
154
|
-
async with session.post(
|
|
155
|
-
LOGIN_URL, data=form_data, allow_redirects=True
|
|
156
|
-
) as resp:
|
|
187
|
+
if data is None:
|
|
188
|
+
request = session.get(url)
|
|
189
|
+
else:
|
|
190
|
+
request = session.post(url, data=data, allow_redirects=True)
|
|
191
|
+
async with request as resp:
|
|
157
192
|
if resp.status != 200:
|
|
158
193
|
raise Switch2ConnectionError(
|
|
159
|
-
f"
|
|
194
|
+
f"Failed to load {what}: HTTP {resp.status}"
|
|
160
195
|
)
|
|
161
196
|
html = await resp.text()
|
|
162
|
-
|
|
163
|
-
soup = BeautifulSoup(html, "html.parser")
|
|
164
|
-
customer = _parse_customer_info(soup)
|
|
165
|
-
if not customer.name:
|
|
166
|
-
raise Switch2AuthError("Login failed: no customer info returned")
|
|
167
|
-
|
|
168
|
-
return customer, soup
|
|
169
|
-
|
|
170
197
|
except aiohttp.ClientError as err:
|
|
171
198
|
raise Switch2ConnectionError(
|
|
172
|
-
f"Connection error
|
|
199
|
+
f"Connection error loading {what}: {err}"
|
|
173
200
|
) from err
|
|
201
|
+
if self._on_page is not None:
|
|
202
|
+
self._on_page(url, html)
|
|
203
|
+
return BeautifulSoup(html, "html.parser")
|
|
204
|
+
|
|
205
|
+
async def authenticate(self) -> tuple[CustomerInfo, BeautifulSoup]:
|
|
206
|
+
"""Log in to the Switch2 portal and return customer info and dashboard soup."""
|
|
207
|
+
soup = await self._fetch(LOGIN_URL, "login page")
|
|
208
|
+
form_data = _form_token(soup)
|
|
209
|
+
form_data["UserName"] = self._email
|
|
210
|
+
form_data["Password"] = self._password
|
|
211
|
+
|
|
212
|
+
soup = await self._fetch(LOGIN_URL, "login response", form_data)
|
|
213
|
+
if soup.select_one('input[name="Password"]') is not None:
|
|
214
|
+
raise Switch2AuthError("Login rejected by the Switch2 portal")
|
|
215
|
+
customer = _parse_customer_info(soup)
|
|
216
|
+
if not customer.name:
|
|
217
|
+
raise Switch2ParseError("No customer info on the page after logging in")
|
|
218
|
+
return customer, soup
|
|
174
219
|
|
|
175
220
|
async def fetch_bill_detail(self, bill: Bill) -> BillDetail:
|
|
176
221
|
"""Fetch and parse the detail page for a bill.
|
|
@@ -179,90 +224,72 @@ class Switch2ApiClient:
|
|
|
179
224
|
"""
|
|
180
225
|
if not bill.detail_url:
|
|
181
226
|
raise ValueError("Bill has no detail URL")
|
|
182
|
-
|
|
227
|
+
soup = await self._fetch(bill.detail_url, "bill detail")
|
|
183
228
|
try:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
)
|
|
189
|
-
html = await resp.text()
|
|
190
|
-
except aiohttp.ClientError as err:
|
|
191
|
-
raise Switch2ConnectionError(
|
|
192
|
-
f"Connection error fetching bill detail: {err}"
|
|
229
|
+
return _parse_bill_detail(soup)
|
|
230
|
+
except ValueError as err:
|
|
231
|
+
raise Switch2ParseError(
|
|
232
|
+
f"Unrecognised bill detail page {bill.detail_url}: {err}"
|
|
193
233
|
) from err
|
|
194
|
-
return _parse_bill_detail(BeautifulSoup(html, "html.parser"))
|
|
195
234
|
|
|
196
235
|
async def fetch_data(self) -> Switch2Data:
|
|
197
236
|
"""Authenticate and fetch all meter data."""
|
|
198
237
|
customer, dashboard_soup = await self.authenticate()
|
|
199
238
|
account_balance = _parse_account_balance(dashboard_soup)
|
|
200
|
-
session = await self._ensure_session()
|
|
201
239
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
"RegisterId": _get_attr(selected_register, "value"),
|
|
232
|
-
"PageSize": "1000000",
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
async with session.post(
|
|
236
|
-
METER_HISTORY_URL, data=form_data, allow_redirects=True
|
|
237
|
-
) as resp:
|
|
238
|
-
if resp.status == 200:
|
|
239
|
-
html = await resp.text()
|
|
240
|
-
soup = BeautifulSoup(html, "html.parser")
|
|
240
|
+
soup = await self._fetch(METER_HISTORY_URL, "meter history")
|
|
241
|
+
registers: dict[str, str] = {}
|
|
242
|
+
register_select = soup.select_one("#RegisterId")
|
|
243
|
+
if register_select:
|
|
244
|
+
for option in register_select.select("option"):
|
|
245
|
+
reg_id = _get_attr(option, "value")
|
|
246
|
+
if reg_id:
|
|
247
|
+
registers[reg_id] = option.text.strip()
|
|
248
|
+
|
|
249
|
+
selected = soup.select_one("#RegisterId option[selected]")
|
|
250
|
+
selected_id = _get_attr(selected, "value") if selected else ""
|
|
251
|
+
token = _form_token(soup)
|
|
252
|
+
|
|
253
|
+
register_readings: dict[str, list[MeterReading]] = {}
|
|
254
|
+
if token:
|
|
255
|
+
for register_id in registers:
|
|
256
|
+
# Ask for everything on a single page.
|
|
257
|
+
history = await self._fetch(
|
|
258
|
+
METER_HISTORY_URL,
|
|
259
|
+
f"meter history for register {register_id}",
|
|
260
|
+
{
|
|
261
|
+
**token,
|
|
262
|
+
"Page": "1",
|
|
263
|
+
"TotalPages": "1",
|
|
264
|
+
"RegisterId": register_id,
|
|
265
|
+
"PageSize": "1000000",
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
register_readings[register_id] = _parse_readings(history)
|
|
241
269
|
|
|
270
|
+
if selected_id in register_readings:
|
|
271
|
+
readings = register_readings[selected_id]
|
|
272
|
+
else:
|
|
242
273
|
readings = _parse_readings(soup)
|
|
243
274
|
|
|
244
|
-
|
|
245
|
-
async with session.get(BILL_HISTORY_URL) as resp:
|
|
246
|
-
if resp.status != 200:
|
|
247
|
-
raise Switch2ConnectionError(
|
|
248
|
-
f"Failed to load bill history: HTTP {resp.status}"
|
|
249
|
-
)
|
|
250
|
-
html = await resp.text()
|
|
275
|
+
bills = _parse_bills(await self._fetch(BILL_HISTORY_URL, "bill history"))
|
|
251
276
|
|
|
252
|
-
|
|
277
|
+
return Switch2Data(
|
|
278
|
+
customer=customer,
|
|
279
|
+
readings=readings,
|
|
280
|
+
registers=registers,
|
|
281
|
+
bills=bills,
|
|
282
|
+
account_balance=account_balance,
|
|
283
|
+
register_readings=register_readings,
|
|
284
|
+
)
|
|
253
285
|
|
|
254
|
-
return Switch2Data(
|
|
255
|
-
customer=customer,
|
|
256
|
-
readings=readings,
|
|
257
|
-
registers=registers,
|
|
258
|
-
bills=bills,
|
|
259
|
-
account_balance=account_balance,
|
|
260
|
-
)
|
|
261
286
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
287
|
+
def _form_token(soup: BeautifulSoup) -> dict[str, str]:
|
|
288
|
+
"""Return the anti-forgery token of the form on a page, if it has one."""
|
|
289
|
+
token_input = soup.select_one('input[name="__RequestVerificationToken"]')
|
|
290
|
+
if token_input is None:
|
|
291
|
+
return {}
|
|
292
|
+
return {"__RequestVerificationToken": _get_attr(token_input, "value")}
|
|
266
293
|
|
|
267
294
|
|
|
268
295
|
def _parse_date(text: str) -> datetime:
|
|
@@ -375,6 +402,30 @@ def _parse_account_balance(soup: BeautifulSoup) -> AccountBalance | None:
|
|
|
375
402
|
return AccountBalance(balance=balance, last_updated=last_updated)
|
|
376
403
|
|
|
377
404
|
|
|
405
|
+
def _has_class(tag: Tag, class_name: str) -> bool:
|
|
406
|
+
"""Return True if the tag has the given CSS class."""
|
|
407
|
+
classes = tag.get("class")
|
|
408
|
+
if classes is None:
|
|
409
|
+
return False
|
|
410
|
+
if isinstance(classes, str):
|
|
411
|
+
return class_name in classes.split()
|
|
412
|
+
return class_name in classes
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _parse_charge_row(row: Tag) -> BillCharge | None:
|
|
416
|
+
"""Parse a single .bill-table-row into a BillCharge, or None if incomplete."""
|
|
417
|
+
desc_el = row.select_one(".bill-table-row-item-left")
|
|
418
|
+
units_el = row.select_one(".bill-table-row-item")
|
|
419
|
+
charge_el = row.select_one(".bill-table-row-item-right")
|
|
420
|
+
if not desc_el or not charge_el:
|
|
421
|
+
return None
|
|
422
|
+
return BillCharge(
|
|
423
|
+
description=desc_el.text.strip(),
|
|
424
|
+
units=units_el.text.strip() if units_el else "",
|
|
425
|
+
charge=_parse_currency(charge_el.text),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
378
429
|
def _parse_bill_charges(
|
|
379
430
|
soup: BeautifulSoup, container_selector: str
|
|
380
431
|
) -> list[BillCharge]:
|
|
@@ -382,20 +433,51 @@ def _parse_bill_charges(
|
|
|
382
433
|
charges: list[BillCharge] = []
|
|
383
434
|
rows = soup.select(f"{container_selector} .bill-table-row-desktop .bill-table-row")
|
|
384
435
|
for row in rows:
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if desc_el and charge_el:
|
|
389
|
-
charges.append(
|
|
390
|
-
BillCharge(
|
|
391
|
-
description=desc_el.text.strip(),
|
|
392
|
-
units=units_el.text.strip() if units_el else "",
|
|
393
|
-
charge=_parse_currency(charge_el.text),
|
|
394
|
-
)
|
|
395
|
-
)
|
|
436
|
+
charge = _parse_charge_row(row)
|
|
437
|
+
if charge is not None:
|
|
438
|
+
charges.append(charge)
|
|
396
439
|
return charges
|
|
397
440
|
|
|
398
441
|
|
|
442
|
+
def _parse_keyed_currency_rows(
|
|
443
|
+
container: Tag | None,
|
|
444
|
+
fields: dict[str, Callable[[str], bool]],
|
|
445
|
+
) -> dict[str, float]:
|
|
446
|
+
"""Parse .bill-total-table-row rows, matching labels to keys via predicates.
|
|
447
|
+
|
|
448
|
+
Returns a mapping for each predicate that matched a row; missing keys are
|
|
449
|
+
logged at warning level so silent zeros don't hide upstream HTML changes.
|
|
450
|
+
"""
|
|
451
|
+
found: dict[str, float] = {}
|
|
452
|
+
if container is None:
|
|
453
|
+
for key in fields:
|
|
454
|
+
_LOGGER.warning("Missing expected bill-total field: %s", key)
|
|
455
|
+
return found
|
|
456
|
+
for row in container.select(".bill-total-table-row"):
|
|
457
|
+
label_el = row.select_one(".bill-total-table-row-item-left")
|
|
458
|
+
value_el = row.select_one(".bill-total-table-row-item-right")
|
|
459
|
+
if not label_el or not value_el:
|
|
460
|
+
continue
|
|
461
|
+
label = label_el.text.strip()
|
|
462
|
+
for key, matches in fields.items():
|
|
463
|
+
if key not in found and matches(label):
|
|
464
|
+
found[key] = _parse_currency(value_el.text)
|
|
465
|
+
break
|
|
466
|
+
for key in fields:
|
|
467
|
+
if key not in found:
|
|
468
|
+
_LOGGER.warning("Missing expected bill-total field: %s", key)
|
|
469
|
+
return found
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _parse_currency_header(soup: BeautifulSoup, selector: str, name: str) -> float:
|
|
473
|
+
"""Parse a single currency value from a header selector, defaulting to 0.0."""
|
|
474
|
+
el = soup.select_one(selector)
|
|
475
|
+
if el is None:
|
|
476
|
+
_LOGGER.warning("Missing expected %s header", name)
|
|
477
|
+
return 0.0
|
|
478
|
+
return _parse_currency(el.text)
|
|
479
|
+
|
|
480
|
+
|
|
399
481
|
def _parse_bill_detail(soup: BeautifulSoup) -> BillDetail:
|
|
400
482
|
"""Extract bill detail from the bill detail page."""
|
|
401
483
|
# Header: invoice number and date of issue
|
|
@@ -437,110 +519,81 @@ def _parse_bill_detail(soup: BeautifulSoup) -> BillDetail:
|
|
|
437
519
|
soup, "#BillItemsContainer > .bill-table-row:first-of-type"
|
|
438
520
|
)
|
|
439
521
|
# If the CSS child selector doesn't match, fall back to looking at
|
|
440
|
-
# desktop rows that come before .other-charges-table-row
|
|
522
|
+
# desktop rows that come before .other-charges-table-row.
|
|
441
523
|
if not consumption_charges:
|
|
442
524
|
items_container = soup.select_one("#BillItemsContainer")
|
|
443
525
|
if items_container:
|
|
444
|
-
consumption_rows: list[Tag] = []
|
|
445
526
|
for child in items_container.children:
|
|
446
527
|
if not isinstance(child, Tag):
|
|
447
528
|
continue
|
|
448
|
-
if "other-charges-table-row"
|
|
529
|
+
if _has_class(child, "other-charges-table-row"):
|
|
449
530
|
break
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
consumption_charges.append(
|
|
463
|
-
BillCharge(
|
|
464
|
-
description=desc_el.text.strip(),
|
|
465
|
-
units=(units_el.text.strip() if units_el else ""),
|
|
466
|
-
charge=_parse_currency(charge_el.text),
|
|
467
|
-
)
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
# Other charges
|
|
531
|
+
desktop_row = child.select_one(
|
|
532
|
+
".bill-table-row-desktop .bill-table-row"
|
|
533
|
+
)
|
|
534
|
+
if desktop_row is None and _has_class(child, "bill-table-row-desktop"):
|
|
535
|
+
desktop_row = child.select_one(".bill-table-row")
|
|
536
|
+
if desktop_row is not None:
|
|
537
|
+
charge = _parse_charge_row(desktop_row)
|
|
538
|
+
if charge is not None:
|
|
539
|
+
consumption_charges.append(charge)
|
|
540
|
+
|
|
541
|
+
# Other charges: desktop rows that follow the .other-charges-table-row header,
|
|
542
|
+
# stopping at the first sibling that isn't a desktop or narrow row.
|
|
471
543
|
other_charges: list[BillCharge] = []
|
|
472
544
|
other_header = soup.select_one(".other-charges-table-row")
|
|
473
545
|
if other_header:
|
|
474
546
|
sibling = other_header.next_sibling
|
|
475
547
|
while sibling:
|
|
476
548
|
if isinstance(sibling, Tag):
|
|
477
|
-
if "bill-table-row-desktop"
|
|
549
|
+
if _has_class(sibling, "bill-table-row-desktop"):
|
|
478
550
|
charge_row = sibling.select_one(".bill-table-row")
|
|
479
|
-
if charge_row:
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
other_charges.append(
|
|
485
|
-
BillCharge(
|
|
486
|
-
description=desc_el.text.strip(),
|
|
487
|
-
units=(units_el.text.strip() if units_el else ""),
|
|
488
|
-
charge=_parse_currency(charge_el.text),
|
|
489
|
-
)
|
|
490
|
-
)
|
|
491
|
-
elif "bill-table-row-narrow" not in sibling.get("class", []):
|
|
551
|
+
if charge_row is not None:
|
|
552
|
+
charge = _parse_charge_row(charge_row)
|
|
553
|
+
if charge is not None:
|
|
554
|
+
other_charges.append(charge)
|
|
555
|
+
elif not _has_class(sibling, "bill-table-row-narrow"):
|
|
492
556
|
break
|
|
493
557
|
sibling = sibling.next_sibling
|
|
494
558
|
|
|
495
|
-
# Totals
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
559
|
+
# Totals. Defaults to 0.0 stay for backwards compatibility with callers, but
|
|
560
|
+
# log when an expected field is missing so problems aren't completely silent.
|
|
561
|
+
totals = _parse_keyed_currency_rows(
|
|
562
|
+
soup.select_one("#BillTotalsCollapsibleContent"),
|
|
563
|
+
{
|
|
564
|
+
"total_excl_vat": lambda label: label.startswith(
|
|
565
|
+
"Total charges excluding VAT"
|
|
566
|
+
),
|
|
567
|
+
"vat": lambda label: label.startswith("VAT"),
|
|
568
|
+
},
|
|
501
569
|
)
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
value_el = row.select_one(".bill-total-table-row-item-right")
|
|
505
|
-
if label_el and value_el:
|
|
506
|
-
label = label_el.text.strip()
|
|
507
|
-
if label.startswith("Total charges excluding VAT"):
|
|
508
|
-
total_excl_vat = _parse_currency(value_el.text)
|
|
509
|
-
elif label.startswith("VAT"):
|
|
510
|
-
vat = _parse_currency(value_el.text)
|
|
570
|
+
total_excl_vat = totals.get("total_excl_vat", 0.0)
|
|
571
|
+
vat = totals.get("vat", 0.0)
|
|
511
572
|
|
|
512
573
|
# Bill total from the collapsible header
|
|
513
|
-
total =
|
|
514
|
-
|
|
515
|
-
"#BillTotalsContainer > .collapsible-header .bill-total-table-row-item-right"
|
|
574
|
+
total = _parse_currency_header(
|
|
575
|
+
soup,
|
|
576
|
+
"#BillTotalsContainer > .collapsible-header .bill-total-table-row-item-right",
|
|
577
|
+
"bill total",
|
|
516
578
|
)
|
|
517
|
-
if total_header:
|
|
518
|
-
total = _parse_currency(total_header.text)
|
|
519
579
|
|
|
520
580
|
# Account balance
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
581
|
+
balances = _parse_keyed_currency_rows(
|
|
582
|
+
soup.select_one("#AccountBalanceCollapsibleContent"),
|
|
583
|
+
{
|
|
584
|
+
"previous_balance": lambda label: label == "Previous account balance",
|
|
585
|
+
"payments_received": lambda label: label == "Payments received",
|
|
586
|
+
},
|
|
526
587
|
)
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
if label == "Previous account balance":
|
|
533
|
-
previous_balance = _parse_currency(value_el.text)
|
|
534
|
-
elif label == "Payments received":
|
|
535
|
-
payments_received = _parse_currency(value_el.text)
|
|
536
|
-
|
|
537
|
-
balance = 0.0
|
|
538
|
-
balance_header = soup.select_one(
|
|
588
|
+
previous_balance = balances.get("previous_balance", 0.0)
|
|
589
|
+
payments_received = balances.get("payments_received", 0.0)
|
|
590
|
+
|
|
591
|
+
balance = _parse_currency_header(
|
|
592
|
+
soup,
|
|
539
593
|
"#AccountBalanceContainer > .collapsible-header"
|
|
540
|
-
" .bill-total-table-row-item-right"
|
|
594
|
+
" .bill-total-table-row-item-right",
|
|
595
|
+
"account balance",
|
|
541
596
|
)
|
|
542
|
-
if balance_header:
|
|
543
|
-
balance = _parse_currency(balance_header.text)
|
|
544
597
|
|
|
545
598
|
# Download URL
|
|
546
599
|
download_url = ""
|
|
File without changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: switch2
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -38,20 +38,39 @@ import asyncio
|
|
|
38
38
|
from switch2 import Switch2ApiClient
|
|
39
39
|
|
|
40
40
|
async def main():
|
|
41
|
-
|
|
42
|
-
try:
|
|
41
|
+
async with Switch2ApiClient("you@example.com", "your-password") as client:
|
|
43
42
|
data = await client.fetch_data()
|
|
44
43
|
print(f"Customer: {data.customer.name}")
|
|
44
|
+
if data.account_balance is not None:
|
|
45
|
+
print(f"Balance: £{data.account_balance.balance}")
|
|
45
46
|
for reading in data.readings:
|
|
46
47
|
print(f" {reading.date}: {reading.amount} {reading.unit}")
|
|
47
48
|
for bill in data.bills:
|
|
48
49
|
print(f" {bill.date}: £{bill.amount}")
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
detail = await client.fetch_bill_detail(bill)
|
|
51
|
+
print(f" invoice {detail.invoice_number}, total £{detail.total}")
|
|
51
52
|
|
|
52
53
|
asyncio.run(main())
|
|
53
54
|
```
|
|
54
55
|
|
|
56
|
+
All exceptions derive from `Switch2Error`. `Switch2AuthError` means the
|
|
57
|
+
portal rejected the credentials; `Switch2ParseError` means a page did not
|
|
58
|
+
look the way the parser expects, which usually means the portal changed.
|
|
59
|
+
|
|
60
|
+
`data.readings` holds the readings of the register the portal selects by
|
|
61
|
+
default; `data.register_readings` maps each register in `data.registers`
|
|
62
|
+
to its readings.
|
|
63
|
+
|
|
64
|
+
## Command line
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
SWITCH2_PASSWORD=... python -m switch2 you@example.com
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
prints what the portal reports. With `--dump-html DIR` it also saves every
|
|
71
|
+
page it fetches, which is the easiest way to capture a page the parser gets
|
|
72
|
+
wrong.
|
|
73
|
+
|
|
55
74
|
## License
|
|
56
75
|
|
|
57
76
|
Apache-2.0
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"""Tests for the Switch2 API client."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import unittest
|
|
4
5
|
from datetime import datetime
|
|
5
6
|
|
|
6
7
|
from bs4 import BeautifulSoup
|
|
7
8
|
|
|
8
9
|
from switch2.api import (
|
|
10
|
+
Bill,
|
|
11
|
+
BillCharge,
|
|
12
|
+
Switch2ApiClient,
|
|
13
|
+
Switch2AuthError,
|
|
14
|
+
Switch2ParseError,
|
|
9
15
|
_parse_account_balance,
|
|
10
16
|
_parse_bill_detail,
|
|
11
17
|
_parse_bills,
|
|
@@ -353,6 +359,15 @@ class ParseBillDetailTests(unittest.TestCase):
|
|
|
353
359
|
)
|
|
354
360
|
|
|
355
361
|
|
|
362
|
+
class ClientContextManagerTests(unittest.TestCase):
|
|
363
|
+
def test_aclose_without_session(self) -> None:
|
|
364
|
+
async def run() -> None:
|
|
365
|
+
async with Switch2ApiClient("e", "p") as client:
|
|
366
|
+
self.assertIsNone(client._session)
|
|
367
|
+
|
|
368
|
+
asyncio.run(run())
|
|
369
|
+
|
|
370
|
+
|
|
356
371
|
class ParseAccountBalanceTests(unittest.TestCase):
|
|
357
372
|
def test_balance_from_dashboard(self) -> None:
|
|
358
373
|
html = """
|
|
@@ -391,3 +406,145 @@ class ParseAccountBalanceTests(unittest.TestCase):
|
|
|
391
406
|
def test_no_balance_element(self) -> None:
|
|
392
407
|
soup = BeautifulSoup("<div></div>", "html.parser")
|
|
393
408
|
self.assertIsNone(_parse_account_balance(soup))
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class BillChargeTests(unittest.TestCase):
|
|
412
|
+
def test_kwh(self) -> None:
|
|
413
|
+
charge = BillCharge("Heat", "252 kWh", 42.87)
|
|
414
|
+
self.assertEqual(charge.quantity, 252.0)
|
|
415
|
+
self.assertEqual(charge.unit, "kWh")
|
|
416
|
+
|
|
417
|
+
def test_days(self) -> None:
|
|
418
|
+
charge = BillCharge("Heat Standing Charge", "30.00 days", 22.46)
|
|
419
|
+
self.assertEqual(charge.quantity, 30.0)
|
|
420
|
+
self.assertEqual(charge.unit, "days")
|
|
421
|
+
|
|
422
|
+
def test_thousands(self) -> None:
|
|
423
|
+
self.assertEqual(BillCharge("Heat", "1,234 kWh", 1.0).quantity, 1234.0)
|
|
424
|
+
|
|
425
|
+
def test_no_units(self) -> None:
|
|
426
|
+
charge = BillCharge("Adjustment", "", -3.0)
|
|
427
|
+
self.assertIsNone(charge.quantity)
|
|
428
|
+
self.assertEqual(charge.unit, "")
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
LOGIN_FORM = """
|
|
432
|
+
<form>
|
|
433
|
+
<input name="__RequestVerificationToken" value="tok">
|
|
434
|
+
<input name="UserName"><input name="Password" type="password">
|
|
435
|
+
</form>
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
DASHBOARD = """
|
|
439
|
+
<span class="customer-info-name">John Doe</span>
|
|
440
|
+
<span class="customer-info-account-number">ACC-123</span>
|
|
441
|
+
<span class="customer-info-address">42 Test Lane</span>
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
METER_HISTORY = """
|
|
445
|
+
<form>
|
|
446
|
+
<input name="__RequestVerificationToken" value="tok2">
|
|
447
|
+
<select id="RegisterId">
|
|
448
|
+
<option value="R1" selected>Heat Meter</option>
|
|
449
|
+
<option value="R2">Hot Water Meter</option>
|
|
450
|
+
</select>
|
|
451
|
+
</form>
|
|
452
|
+
"""
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _reading_row(date: str, amount: str) -> str:
|
|
456
|
+
return f"""
|
|
457
|
+
<div class="meter-reading-history-table-data-row desktop-layout">
|
|
458
|
+
<div class="meter-reading-history-table-data-date-row-item">{date}</div>
|
|
459
|
+
<div class="meter-reading-history-table-data-amount-row-item">{amount}</div>
|
|
460
|
+
<div class="meter-reading-history-table-data-type-row-item">Actual</div>
|
|
461
|
+
</div>
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class FakePortal:
|
|
466
|
+
"""Serves canned pages in place of Switch2ApiClient._fetch."""
|
|
467
|
+
|
|
468
|
+
def __init__(self, login_response: str) -> None:
|
|
469
|
+
self.login_response = login_response
|
|
470
|
+
self.posts: list[tuple[str, dict[str, str]]] = []
|
|
471
|
+
|
|
472
|
+
async def __call__(
|
|
473
|
+
self, url: str, what: str, data: dict[str, str] | None = None
|
|
474
|
+
) -> BeautifulSoup:
|
|
475
|
+
if data is not None:
|
|
476
|
+
self.posts.append((url, data))
|
|
477
|
+
if url.endswith("/Login"):
|
|
478
|
+
html = LOGIN_FORM if data is None else self.login_response
|
|
479
|
+
elif url.endswith("/MeterReadings/History"):
|
|
480
|
+
if data is None:
|
|
481
|
+
html = METER_HISTORY
|
|
482
|
+
elif data["RegisterId"] == "R1":
|
|
483
|
+
html = _reading_row("26th October 2026", "9166 kWh")
|
|
484
|
+
else:
|
|
485
|
+
html = _reading_row("26th October 2026", "512 kWh")
|
|
486
|
+
elif url.endswith("/Credit/BillHistory"):
|
|
487
|
+
html = "<div></div>"
|
|
488
|
+
else:
|
|
489
|
+
raise AssertionError(f"unexpected fetch of {url}")
|
|
490
|
+
return BeautifulSoup(html, "html.parser")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _client(portal: FakePortal) -> Switch2ApiClient:
|
|
494
|
+
client = Switch2ApiClient("e@example.com", "pw")
|
|
495
|
+
client._fetch = portal # type: ignore[method-assign]
|
|
496
|
+
return client
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class AuthenticateTests(unittest.TestCase):
|
|
500
|
+
def test_success(self) -> None:
|
|
501
|
+
portal = FakePortal(DASHBOARD)
|
|
502
|
+
customer, _ = asyncio.run(_client(portal).authenticate())
|
|
503
|
+
self.assertEqual(customer.account_number, "ACC-123")
|
|
504
|
+
self.assertEqual(
|
|
505
|
+
portal.posts,
|
|
506
|
+
[
|
|
507
|
+
(
|
|
508
|
+
"https://my.switch2.co.uk/Login",
|
|
509
|
+
{
|
|
510
|
+
"__RequestVerificationToken": "tok",
|
|
511
|
+
"UserName": "e@example.com",
|
|
512
|
+
"Password": "pw",
|
|
513
|
+
},
|
|
514
|
+
)
|
|
515
|
+
],
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
def test_rejected(self) -> None:
|
|
519
|
+
portal = FakePortal(LOGIN_FORM + "<p>Invalid login attempt.</p>")
|
|
520
|
+
with self.assertRaises(Switch2AuthError):
|
|
521
|
+
asyncio.run(_client(portal).authenticate())
|
|
522
|
+
|
|
523
|
+
def test_unrecognised_page(self) -> None:
|
|
524
|
+
portal = FakePortal("<p>Down for maintenance</p>")
|
|
525
|
+
with self.assertRaises(Switch2ParseError):
|
|
526
|
+
asyncio.run(_client(portal).authenticate())
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class FetchBillDetailTests(unittest.TestCase):
|
|
530
|
+
def test_unrecognised_page(self) -> None:
|
|
531
|
+
client = Switch2ApiClient("e", "p")
|
|
532
|
+
|
|
533
|
+
async def fetch(url: str, what: str) -> BeautifulSoup:
|
|
534
|
+
return BeautifulSoup("<p>Something else</p>", "html.parser")
|
|
535
|
+
|
|
536
|
+
client._fetch = fetch # type: ignore[method-assign,assignment]
|
|
537
|
+
bill = Bill(datetime(2026, 10, 27), 104.03, "https://example/bill/1")
|
|
538
|
+
with self.assertRaises(Switch2ParseError):
|
|
539
|
+
asyncio.run(client.fetch_bill_detail(bill))
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class FetchDataTests(unittest.TestCase):
|
|
543
|
+
def test_readings_per_register(self) -> None:
|
|
544
|
+
data = asyncio.run(_client(FakePortal(DASHBOARD)).fetch_data())
|
|
545
|
+
self.assertEqual(data.registers, {"R1": "Heat Meter", "R2": "Hot Water Meter"})
|
|
546
|
+
self.assertEqual(
|
|
547
|
+
{r: [x.amount for x in v] for r, v in data.register_readings.items()},
|
|
548
|
+
{"R1": [9166.0], "R2": [512.0]},
|
|
549
|
+
)
|
|
550
|
+
self.assertEqual([x.amount for x in data.readings], [9166.0])
|
switch2-0.1.2/README.md
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# python-switch2
|
|
2
|
-
|
|
3
|
-
Async Python client library for the [Switch2](https://my.switch2.co.uk) energy portal.
|
|
4
|
-
|
|
5
|
-
## Installation
|
|
6
|
-
|
|
7
|
-
```sh
|
|
8
|
-
pip install switch2
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Usage
|
|
12
|
-
|
|
13
|
-
```python
|
|
14
|
-
import asyncio
|
|
15
|
-
from switch2 import Switch2ApiClient
|
|
16
|
-
|
|
17
|
-
async def main():
|
|
18
|
-
client = Switch2ApiClient("you@example.com", "your-password")
|
|
19
|
-
try:
|
|
20
|
-
data = await client.fetch_data()
|
|
21
|
-
print(f"Customer: {data.customer.name}")
|
|
22
|
-
for reading in data.readings:
|
|
23
|
-
print(f" {reading.date}: {reading.amount} {reading.unit}")
|
|
24
|
-
for bill in data.bills:
|
|
25
|
-
print(f" {bill.date}: £{bill.amount}")
|
|
26
|
-
finally:
|
|
27
|
-
await client.close()
|
|
28
|
-
|
|
29
|
-
asyncio.run(main())
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## License
|
|
33
|
-
|
|
34
|
-
Apache-2.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|