switch2 0.1.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.0/PKG-INFO +57 -0
- switch2-0.1.0/README.md +34 -0
- switch2-0.1.0/pyproject.toml +41 -0
- switch2-0.1.0/setup.cfg +4 -0
- switch2-0.1.0/switch2/__init__.py +21 -0
- switch2-0.1.0/switch2/api.py +289 -0
- switch2-0.1.0/switch2.egg-info/PKG-INFO +57 -0
- switch2-0.1.0/switch2.egg-info/SOURCES.txt +10 -0
- switch2-0.1.0/switch2.egg-info/dependency_links.txt +1 -0
- switch2-0.1.0/switch2.egg-info/requires.txt +2 -0
- switch2-0.1.0/switch2.egg-info/top_level.txt +1 -0
- switch2-0.1.0/tests/test_api.py +148 -0
switch2-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: switch2
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async client library for the Switch2 energy portal
|
|
5
|
+
Author-email: Jelmer Vernooij <jelmer@jelmer.uk>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/jelmer/python-switch2
|
|
8
|
+
Project-URL: Repository, https://github.com/jelmer/python-switch2
|
|
9
|
+
Project-URL: Issues, https://github.com/jelmer/python-switch2/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Home Automation
|
|
18
|
+
Classifier: Framework :: AsyncIO
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: aiohttp>=3.8
|
|
22
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
23
|
+
|
|
24
|
+
# python-switch2
|
|
25
|
+
|
|
26
|
+
Async Python client library for the [Switch2](https://my.switch2.co.uk) energy portal.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install switch2
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import asyncio
|
|
38
|
+
from switch2 import Switch2ApiClient
|
|
39
|
+
|
|
40
|
+
async def main():
|
|
41
|
+
client = Switch2ApiClient("you@example.com", "your-password")
|
|
42
|
+
try:
|
|
43
|
+
data = await client.fetch_data()
|
|
44
|
+
print(f"Customer: {data.customer.name}")
|
|
45
|
+
for reading in data.readings:
|
|
46
|
+
print(f" {reading.date}: {reading.amount} {reading.unit}")
|
|
47
|
+
for bill in data.bills:
|
|
48
|
+
print(f" {bill.date}: £{bill.amount}")
|
|
49
|
+
finally:
|
|
50
|
+
await client.close()
|
|
51
|
+
|
|
52
|
+
asyncio.run(main())
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
Apache-2.0
|
switch2-0.1.0/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
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
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "switch2"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async client library for the Switch2 energy portal"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Jelmer Vernooij", email = "jelmer@jelmer.uk" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Home Automation",
|
|
24
|
+
"Framework :: AsyncIO",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"aiohttp>=3.8",
|
|
28
|
+
"beautifulsoup4>=4.12",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/jelmer/python-switch2"
|
|
33
|
+
Repository = "https://github.com/jelmer/python-switch2"
|
|
34
|
+
Issues = "https://github.com/jelmer/python-switch2/issues"
|
|
35
|
+
|
|
36
|
+
[tool.mypy]
|
|
37
|
+
packages = ["switch2"]
|
|
38
|
+
strict = true
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = ["E", "F", "I", "W"]
|
switch2-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Standalone client library for the Switch2 energy portal."""
|
|
2
|
+
|
|
3
|
+
from .api import (
|
|
4
|
+
Bill,
|
|
5
|
+
CustomerInfo,
|
|
6
|
+
MeterReading,
|
|
7
|
+
Switch2ApiClient,
|
|
8
|
+
Switch2AuthError,
|
|
9
|
+
Switch2ConnectionError,
|
|
10
|
+
Switch2Data,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Bill",
|
|
15
|
+
"CustomerInfo",
|
|
16
|
+
"MeterReading",
|
|
17
|
+
"Switch2ApiClient",
|
|
18
|
+
"Switch2AuthError",
|
|
19
|
+
"Switch2ConnectionError",
|
|
20
|
+
"Switch2Data",
|
|
21
|
+
]
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""API client for the Switch2 energy portal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
from bs4 import BeautifulSoup, Tag
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
BASE_URL = "https://my.switch2.co.uk"
|
|
15
|
+
LOGIN_URL = f"{BASE_URL}/Login"
|
|
16
|
+
METER_HISTORY_URL = f"{BASE_URL}/MeterReadings/History"
|
|
17
|
+
BILL_HISTORY_URL = f"{BASE_URL}/Credit/BillHistory"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Switch2AuthError(Exception):
|
|
21
|
+
"""Raised when authentication fails."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Switch2ConnectionError(Exception):
|
|
25
|
+
"""Raised when a connection error occurs."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CustomerInfo:
|
|
30
|
+
"""Customer information from the Switch2 portal."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
account_number: str
|
|
34
|
+
address: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class MeterReading:
|
|
39
|
+
"""A single meter reading."""
|
|
40
|
+
|
|
41
|
+
date: datetime
|
|
42
|
+
amount: float
|
|
43
|
+
unit: str
|
|
44
|
+
reading_type: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Bill:
|
|
49
|
+
"""A single bill."""
|
|
50
|
+
|
|
51
|
+
date: datetime
|
|
52
|
+
amount: float
|
|
53
|
+
detail_url: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Switch2Data:
|
|
58
|
+
"""All data fetched from the Switch2 portal."""
|
|
59
|
+
|
|
60
|
+
customer: CustomerInfo
|
|
61
|
+
readings: list[MeterReading]
|
|
62
|
+
registers: dict[str, str] # register_id -> register_name
|
|
63
|
+
bills: list[Bill]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _get_attr(tag: Tag, attr: str, default: str = "") -> str:
|
|
67
|
+
"""Get a string attribute from a BeautifulSoup tag, handling list values."""
|
|
68
|
+
value = tag.get(attr, default)
|
|
69
|
+
if isinstance(value, list):
|
|
70
|
+
return value[0] if value else default
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Switch2ApiClient:
|
|
75
|
+
"""Client to interact with the Switch2 web portal."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, email: str, password: str) -> None:
|
|
78
|
+
self._email = email
|
|
79
|
+
self._password = password
|
|
80
|
+
self._session: aiohttp.ClientSession | None = None
|
|
81
|
+
|
|
82
|
+
async def _ensure_session(self) -> aiohttp.ClientSession:
|
|
83
|
+
if self._session is None or self._session.closed:
|
|
84
|
+
self._session = aiohttp.ClientSession()
|
|
85
|
+
return self._session
|
|
86
|
+
|
|
87
|
+
async def close(self) -> None:
|
|
88
|
+
"""Close the underlying HTTP session."""
|
|
89
|
+
if self._session and not self._session.closed:
|
|
90
|
+
await self._session.close()
|
|
91
|
+
|
|
92
|
+
async def authenticate(self) -> CustomerInfo:
|
|
93
|
+
"""Log in to the Switch2 portal and return customer info."""
|
|
94
|
+
session = await self._ensure_session()
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
# Step 1: GET the login page to get any CSRF tokens
|
|
98
|
+
async with session.get(LOGIN_URL) as resp:
|
|
99
|
+
if resp.status != 200:
|
|
100
|
+
raise Switch2ConnectionError(
|
|
101
|
+
f"Failed to load login page: HTTP {resp.status}"
|
|
102
|
+
)
|
|
103
|
+
html = await resp.text()
|
|
104
|
+
|
|
105
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
106
|
+
token_input = soup.select_one('input[name="__RequestVerificationToken"]')
|
|
107
|
+
form_data: dict[str, str] = {}
|
|
108
|
+
if token_input:
|
|
109
|
+
form_data["__RequestVerificationToken"] = _get_attr(
|
|
110
|
+
token_input, "value"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Step 2: POST login credentials
|
|
114
|
+
form_data["UserName"] = self._email
|
|
115
|
+
form_data["Password"] = self._password
|
|
116
|
+
|
|
117
|
+
async with session.post(
|
|
118
|
+
LOGIN_URL, data=form_data, allow_redirects=True
|
|
119
|
+
) as resp:
|
|
120
|
+
if resp.status != 200:
|
|
121
|
+
raise Switch2ConnectionError(
|
|
122
|
+
f"Login request failed: HTTP {resp.status}"
|
|
123
|
+
)
|
|
124
|
+
html = await resp.text()
|
|
125
|
+
|
|
126
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
127
|
+
customer = _parse_customer_info(soup)
|
|
128
|
+
if not customer.name:
|
|
129
|
+
raise Switch2AuthError("Login failed: no customer info returned")
|
|
130
|
+
|
|
131
|
+
return customer
|
|
132
|
+
|
|
133
|
+
except aiohttp.ClientError as err:
|
|
134
|
+
raise Switch2ConnectionError(
|
|
135
|
+
f"Connection error during login: {err}"
|
|
136
|
+
) from err
|
|
137
|
+
|
|
138
|
+
async def fetch_data(self) -> Switch2Data:
|
|
139
|
+
"""Authenticate and fetch all meter data."""
|
|
140
|
+
customer = await self.authenticate()
|
|
141
|
+
session = await self._ensure_session()
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
# GET meter readings history page
|
|
145
|
+
async with session.get(METER_HISTORY_URL) as resp:
|
|
146
|
+
if resp.status != 200:
|
|
147
|
+
raise Switch2ConnectionError(
|
|
148
|
+
f"Failed to load meter history: HTTP {resp.status}"
|
|
149
|
+
)
|
|
150
|
+
html = await resp.text()
|
|
151
|
+
|
|
152
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
153
|
+
|
|
154
|
+
# Parse available registers
|
|
155
|
+
registers: dict[str, str] = {}
|
|
156
|
+
register_select = soup.select_one("#RegisterId")
|
|
157
|
+
if register_select:
|
|
158
|
+
for option in register_select.select("option"):
|
|
159
|
+
reg_id = option.get("value", "")
|
|
160
|
+
if reg_id:
|
|
161
|
+
registers[str(reg_id)] = option.text.strip()
|
|
162
|
+
|
|
163
|
+
# Try to get all readings by POSTing with a large page size
|
|
164
|
+
token_input = soup.select_one('input[name="__RequestVerificationToken"]')
|
|
165
|
+
selected_register = soup.select_one("#RegisterId option[selected]")
|
|
166
|
+
|
|
167
|
+
if token_input and selected_register:
|
|
168
|
+
form_data = {
|
|
169
|
+
"__RequestVerificationToken": _get_attr(token_input, "value"),
|
|
170
|
+
"Page": "1",
|
|
171
|
+
"TotalPages": "1",
|
|
172
|
+
"RegisterId": _get_attr(selected_register, "value"),
|
|
173
|
+
"PageSize": "1000000",
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async with session.post(
|
|
177
|
+
METER_HISTORY_URL, data=form_data, allow_redirects=True
|
|
178
|
+
) as resp:
|
|
179
|
+
if resp.status == 200:
|
|
180
|
+
html = await resp.text()
|
|
181
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
182
|
+
|
|
183
|
+
readings = _parse_readings(soup)
|
|
184
|
+
|
|
185
|
+
# Fetch bill history
|
|
186
|
+
async with session.get(BILL_HISTORY_URL) as resp:
|
|
187
|
+
if resp.status != 200:
|
|
188
|
+
raise Switch2ConnectionError(
|
|
189
|
+
f"Failed to load bill history: HTTP {resp.status}"
|
|
190
|
+
)
|
|
191
|
+
html = await resp.text()
|
|
192
|
+
|
|
193
|
+
bills = _parse_bills(BeautifulSoup(html, "html.parser"))
|
|
194
|
+
|
|
195
|
+
return Switch2Data(
|
|
196
|
+
customer=customer,
|
|
197
|
+
readings=readings,
|
|
198
|
+
registers=registers,
|
|
199
|
+
bills=bills,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
except aiohttp.ClientError as err:
|
|
203
|
+
raise Switch2ConnectionError(
|
|
204
|
+
f"Connection error fetching data: {err}"
|
|
205
|
+
) from err
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _parse_date(text: str) -> datetime:
|
|
209
|
+
"""Parse a date string like '27th February 2026'."""
|
|
210
|
+
# Strip ordinal suffixes (1st, 2nd, 3rd, 4th, etc.)
|
|
211
|
+
for suffix in ("st ", "nd ", "rd ", "th "):
|
|
212
|
+
text = text.replace(suffix, " ")
|
|
213
|
+
for fmt in ("%d %B %Y", "%d %b %Y"):
|
|
214
|
+
try:
|
|
215
|
+
return datetime.strptime(text, fmt)
|
|
216
|
+
except ValueError:
|
|
217
|
+
continue
|
|
218
|
+
raise ValueError(f"Cannot parse date: {text!r}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _parse_customer_info(soup: BeautifulSoup) -> CustomerInfo:
|
|
222
|
+
"""Extract customer info from the dashboard page."""
|
|
223
|
+
name_el = soup.select_one(".customer-info-name")
|
|
224
|
+
acn_el = soup.select_one(".customer-info-account-number")
|
|
225
|
+
addr_el = soup.select_one(".customer-info-address")
|
|
226
|
+
|
|
227
|
+
return CustomerInfo(
|
|
228
|
+
name=name_el.text.strip() if name_el else "",
|
|
229
|
+
account_number=acn_el.text.strip() if acn_el else "",
|
|
230
|
+
address=addr_el.text.strip() if addr_el else "",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _parse_readings(soup: BeautifulSoup) -> list[MeterReading]:
|
|
235
|
+
"""Extract meter readings from the history page."""
|
|
236
|
+
readings = []
|
|
237
|
+
rows = soup.select(".meter-reading-history-table-data-row.desktop-layout")
|
|
238
|
+
for row in rows:
|
|
239
|
+
date_el = row.select_one(".meter-reading-history-table-data-date-row-item")
|
|
240
|
+
amount_el = row.select_one(".meter-reading-history-table-data-amount-row-item")
|
|
241
|
+
type_el = row.select_one(".meter-reading-history-table-data-type-row-item")
|
|
242
|
+
if date_el and amount_el:
|
|
243
|
+
try:
|
|
244
|
+
date = _parse_date(date_el.text.strip())
|
|
245
|
+
# Parse amount and unit (e.g. "8551 kWh" -> 8551, "kWh")
|
|
246
|
+
amount_parts = amount_el.text.strip().split()
|
|
247
|
+
amount = float(amount_parts[0])
|
|
248
|
+
unit = amount_parts[1] if len(amount_parts) > 1 else ""
|
|
249
|
+
reading_type = type_el.text.strip() if type_el else ""
|
|
250
|
+
readings.append(
|
|
251
|
+
MeterReading(
|
|
252
|
+
date=date,
|
|
253
|
+
amount=amount,
|
|
254
|
+
unit=unit,
|
|
255
|
+
reading_type=reading_type,
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
except (ValueError, TypeError) as err:
|
|
259
|
+
_LOGGER.debug("Failed to parse meter reading row: %s", err)
|
|
260
|
+
|
|
261
|
+
return readings
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _parse_bills(soup: BeautifulSoup) -> list[Bill]:
|
|
265
|
+
"""Extract bills from the bill history page."""
|
|
266
|
+
bills = []
|
|
267
|
+
rows = soup.select(".bill-history-table-data-row")
|
|
268
|
+
for row in rows:
|
|
269
|
+
date_el = row.select_one(".bill-history-table-data-row-text-item")
|
|
270
|
+
amount_el = row.select_one(
|
|
271
|
+
".bill-history-table-data-row-item-right"
|
|
272
|
+
".bill-history-table-data-row-text-item"
|
|
273
|
+
)
|
|
274
|
+
link_el = row.select_one("a.bill-history-view-bill-button")
|
|
275
|
+
if date_el and amount_el:
|
|
276
|
+
try:
|
|
277
|
+
date = _parse_date(date_el.text.strip())
|
|
278
|
+
# Parse amount (e.g. "£172.26" -> 172.26)
|
|
279
|
+
amount_text = amount_el.text.strip().lstrip("£").replace(",", "")
|
|
280
|
+
amount = float(amount_text)
|
|
281
|
+
detail_url = ""
|
|
282
|
+
if link_el:
|
|
283
|
+
href = _get_attr(link_el, "href")
|
|
284
|
+
detail_url = f"{BASE_URL}{href}" if href else ""
|
|
285
|
+
bills.append(Bill(date=date, amount=amount, detail_url=detail_url))
|
|
286
|
+
except (ValueError, TypeError) as err:
|
|
287
|
+
_LOGGER.debug("Failed to parse bill row: %s", err)
|
|
288
|
+
|
|
289
|
+
return bills
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: switch2
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async client library for the Switch2 energy portal
|
|
5
|
+
Author-email: Jelmer Vernooij <jelmer@jelmer.uk>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/jelmer/python-switch2
|
|
8
|
+
Project-URL: Repository, https://github.com/jelmer/python-switch2
|
|
9
|
+
Project-URL: Issues, https://github.com/jelmer/python-switch2/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Home Automation
|
|
18
|
+
Classifier: Framework :: AsyncIO
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: aiohttp>=3.8
|
|
22
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
23
|
+
|
|
24
|
+
# python-switch2
|
|
25
|
+
|
|
26
|
+
Async Python client library for the [Switch2](https://my.switch2.co.uk) energy portal.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install switch2
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import asyncio
|
|
38
|
+
from switch2 import Switch2ApiClient
|
|
39
|
+
|
|
40
|
+
async def main():
|
|
41
|
+
client = Switch2ApiClient("you@example.com", "your-password")
|
|
42
|
+
try:
|
|
43
|
+
data = await client.fetch_data()
|
|
44
|
+
print(f"Customer: {data.customer.name}")
|
|
45
|
+
for reading in data.readings:
|
|
46
|
+
print(f" {reading.date}: {reading.amount} {reading.unit}")
|
|
47
|
+
for bill in data.bills:
|
|
48
|
+
print(f" {bill.date}: £{bill.amount}")
|
|
49
|
+
finally:
|
|
50
|
+
await client.close()
|
|
51
|
+
|
|
52
|
+
asyncio.run(main())
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
Apache-2.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
switch2
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Tests for the Switch2 API client."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from bs4 import BeautifulSoup
|
|
7
|
+
|
|
8
|
+
from switch2.api import (
|
|
9
|
+
_parse_bills,
|
|
10
|
+
_parse_customer_info,
|
|
11
|
+
_parse_date,
|
|
12
|
+
_parse_readings,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ParseDateTests(unittest.TestCase):
|
|
17
|
+
def test_ordinal_th(self) -> None:
|
|
18
|
+
self.assertEqual(_parse_date("27th February 2026"), datetime(2026, 2, 27))
|
|
19
|
+
|
|
20
|
+
def test_ordinal_st(self) -> None:
|
|
21
|
+
self.assertEqual(_parse_date("1st January 2025"), datetime(2025, 1, 1))
|
|
22
|
+
|
|
23
|
+
def test_ordinal_nd(self) -> None:
|
|
24
|
+
self.assertEqual(_parse_date("2nd March 2025"), datetime(2025, 3, 2))
|
|
25
|
+
|
|
26
|
+
def test_ordinal_rd(self) -> None:
|
|
27
|
+
self.assertEqual(_parse_date("3rd April 2025"), datetime(2025, 4, 3))
|
|
28
|
+
|
|
29
|
+
def test_abbreviated_month(self) -> None:
|
|
30
|
+
self.assertEqual(_parse_date("15th Jan 2025"), datetime(2025, 1, 15))
|
|
31
|
+
|
|
32
|
+
def test_invalid_date(self) -> None:
|
|
33
|
+
with self.assertRaises(ValueError):
|
|
34
|
+
_parse_date("not a date")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ParseCustomerInfoTests(unittest.TestCase):
|
|
38
|
+
def test_full_info(self) -> None:
|
|
39
|
+
html = """
|
|
40
|
+
<div>
|
|
41
|
+
<span class="customer-info-name">John Doe</span>
|
|
42
|
+
<span class="customer-info-account-number">ACC-123</span>
|
|
43
|
+
<span class="customer-info-address">42 Test Lane</span>
|
|
44
|
+
</div>
|
|
45
|
+
"""
|
|
46
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
47
|
+
info = _parse_customer_info(soup)
|
|
48
|
+
self.assertEqual(info.name, "John Doe")
|
|
49
|
+
self.assertEqual(info.account_number, "ACC-123")
|
|
50
|
+
self.assertEqual(info.address, "42 Test Lane")
|
|
51
|
+
|
|
52
|
+
def test_missing_elements(self) -> None:
|
|
53
|
+
soup = BeautifulSoup("<div></div>", "html.parser")
|
|
54
|
+
info = _parse_customer_info(soup)
|
|
55
|
+
self.assertEqual(info.name, "")
|
|
56
|
+
self.assertEqual(info.account_number, "")
|
|
57
|
+
self.assertEqual(info.address, "")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ParseReadingsTests(unittest.TestCase):
|
|
61
|
+
def test_single_reading(self) -> None:
|
|
62
|
+
html = """
|
|
63
|
+
<div class="meter-reading-history-table-data-row desktop-layout">
|
|
64
|
+
<div class="meter-reading-history-table-data-date-row-item">
|
|
65
|
+
27th February 2026
|
|
66
|
+
</div>
|
|
67
|
+
<div class="meter-reading-history-table-data-amount-row-item">
|
|
68
|
+
8551 kWh
|
|
69
|
+
</div>
|
|
70
|
+
<div class="meter-reading-history-table-data-type-row-item">
|
|
71
|
+
Actual
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
"""
|
|
75
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
76
|
+
readings = _parse_readings(soup)
|
|
77
|
+
self.assertEqual(len(readings), 1)
|
|
78
|
+
self.assertEqual(readings[0].date, datetime(2026, 2, 27))
|
|
79
|
+
self.assertEqual(readings[0].amount, 8551.0)
|
|
80
|
+
self.assertEqual(readings[0].unit, "kWh")
|
|
81
|
+
self.assertEqual(readings[0].reading_type, "Actual")
|
|
82
|
+
|
|
83
|
+
def test_no_readings(self) -> None:
|
|
84
|
+
soup = BeautifulSoup("<div></div>", "html.parser")
|
|
85
|
+
self.assertEqual(_parse_readings(soup), [])
|
|
86
|
+
|
|
87
|
+
def test_amount_without_unit(self) -> None:
|
|
88
|
+
html = """
|
|
89
|
+
<div class="meter-reading-history-table-data-row desktop-layout">
|
|
90
|
+
<div class="meter-reading-history-table-data-date-row-item">
|
|
91
|
+
1st January 2025
|
|
92
|
+
</div>
|
|
93
|
+
<div class="meter-reading-history-table-data-amount-row-item">
|
|
94
|
+
1234
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
"""
|
|
98
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
99
|
+
readings = _parse_readings(soup)
|
|
100
|
+
self.assertEqual(len(readings), 1)
|
|
101
|
+
self.assertEqual(readings[0].unit, "")
|
|
102
|
+
self.assertEqual(readings[0].reading_type, "")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ParseBillsTests(unittest.TestCase):
|
|
106
|
+
def test_single_bill(self) -> None:
|
|
107
|
+
bill_amount_cls = (
|
|
108
|
+
"bill-history-table-data-row-item-right"
|
|
109
|
+
" bill-history-table-data-row-text-item"
|
|
110
|
+
)
|
|
111
|
+
html = f"""
|
|
112
|
+
<div class="bill-history-table-data-row">
|
|
113
|
+
<div class="bill-history-table-data-row-text-item">
|
|
114
|
+
15th March 2025
|
|
115
|
+
</div>
|
|
116
|
+
<div class="{bill_amount_cls}">£172.26</div>
|
|
117
|
+
<a class="bill-history-view-bill-button"
|
|
118
|
+
href="/Credit/Bill/42">View</a>
|
|
119
|
+
</div>
|
|
120
|
+
"""
|
|
121
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
122
|
+
bills = _parse_bills(soup)
|
|
123
|
+
self.assertEqual(len(bills), 1)
|
|
124
|
+
self.assertEqual(bills[0].date, datetime(2025, 3, 15))
|
|
125
|
+
self.assertEqual(bills[0].amount, 172.26)
|
|
126
|
+
self.assertEqual(bills[0].detail_url, "https://my.switch2.co.uk/Credit/Bill/42")
|
|
127
|
+
|
|
128
|
+
def test_no_bills(self) -> None:
|
|
129
|
+
soup = BeautifulSoup("<div></div>", "html.parser")
|
|
130
|
+
self.assertEqual(_parse_bills(soup), [])
|
|
131
|
+
|
|
132
|
+
def test_bill_without_link(self) -> None:
|
|
133
|
+
bill_amount_cls = (
|
|
134
|
+
"bill-history-table-data-row-item-right"
|
|
135
|
+
" bill-history-table-data-row-text-item"
|
|
136
|
+
)
|
|
137
|
+
html = f"""
|
|
138
|
+
<div class="bill-history-table-data-row">
|
|
139
|
+
<div class="bill-history-table-data-row-text-item">
|
|
140
|
+
1st January 2025
|
|
141
|
+
</div>
|
|
142
|
+
<div class="{bill_amount_cls}">£50.00</div>
|
|
143
|
+
</div>
|
|
144
|
+
"""
|
|
145
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
146
|
+
bills = _parse_bills(soup)
|
|
147
|
+
self.assertEqual(len(bills), 1)
|
|
148
|
+
self.assertEqual(bills[0].detail_url, "")
|