switch2 0.1.1__tar.gz → 0.1.3__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.1 → switch2-0.1.3}/PKG-INFO +6 -5
- {switch2-0.1.1 → switch2-0.1.3}/README.md +5 -4
- {switch2-0.1.1 → switch2-0.1.3}/pyproject.toml +4 -1
- {switch2-0.1.1 → switch2-0.1.3}/switch2/__init__.py +2 -0
- {switch2-0.1.1 → switch2-0.1.3}/switch2/api.py +163 -94
- switch2-0.1.3/switch2/py.typed +0 -0
- {switch2-0.1.1 → switch2-0.1.3}/switch2.egg-info/PKG-INFO +6 -5
- {switch2-0.1.1 → switch2-0.1.3}/switch2.egg-info/SOURCES.txt +1 -0
- {switch2-0.1.1 → switch2-0.1.3}/tests/test_api.py +52 -0
- {switch2-0.1.1 → switch2-0.1.3}/setup.cfg +0 -0
- {switch2-0.1.1 → switch2-0.1.3}/switch2.egg-info/dependency_links.txt +0 -0
- {switch2-0.1.1 → switch2-0.1.3}/switch2.egg-info/requires.txt +0 -0
- {switch2-0.1.1 → switch2-0.1.3}/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.1.3
|
|
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,16 +38,17 @@ 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
|
```
|
|
@@ -15,16 +15,17 @@ import asyncio
|
|
|
15
15
|
from switch2 import Switch2ApiClient
|
|
16
16
|
|
|
17
17
|
async def main():
|
|
18
|
-
|
|
19
|
-
try:
|
|
18
|
+
async with Switch2ApiClient("you@example.com", "your-password") as client:
|
|
20
19
|
data = await client.fetch_data()
|
|
21
20
|
print(f"Customer: {data.customer.name}")
|
|
21
|
+
if data.account_balance is not None:
|
|
22
|
+
print(f"Balance: £{data.account_balance.balance}")
|
|
22
23
|
for reading in data.readings:
|
|
23
24
|
print(f" {reading.date}: {reading.amount} {reading.unit}")
|
|
24
25
|
for bill in data.bills:
|
|
25
26
|
print(f" {bill.date}: £{bill.amount}")
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
detail = await client.fetch_bill_detail(bill)
|
|
28
|
+
print(f" invoice {detail.invoice_number}, total £{detail.total}")
|
|
28
29
|
|
|
29
30
|
asyncio.run(main())
|
|
30
31
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "switch2"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Standalone client library for the Switch2 energy portal."""
|
|
2
2
|
|
|
3
3
|
from .api import (
|
|
4
|
+
AccountBalance,
|
|
4
5
|
Bill,
|
|
5
6
|
BillCharge,
|
|
6
7
|
BillDetail,
|
|
@@ -13,6 +14,7 @@ from .api import (
|
|
|
13
14
|
)
|
|
14
15
|
|
|
15
16
|
__all__ = [
|
|
17
|
+
"AccountBalance",
|
|
16
18
|
"Bill",
|
|
17
19
|
"BillCharge",
|
|
18
20
|
"BillDetail",
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from dataclasses import dataclass
|
|
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,6 +18,8 @@ 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)
|
|
22
|
+
|
|
19
23
|
|
|
20
24
|
class Switch2AuthError(Exception):
|
|
21
25
|
"""Raised when authentication fails."""
|
|
@@ -81,6 +85,14 @@ class BillDetail:
|
|
|
81
85
|
download_url: str
|
|
82
86
|
|
|
83
87
|
|
|
88
|
+
@dataclass
|
|
89
|
+
class AccountBalance:
|
|
90
|
+
"""Current account balance from the Switch2 portal."""
|
|
91
|
+
|
|
92
|
+
balance: float
|
|
93
|
+
last_updated: datetime
|
|
94
|
+
|
|
95
|
+
|
|
84
96
|
@dataclass
|
|
85
97
|
class Switch2Data:
|
|
86
98
|
"""All data fetched from the Switch2 portal."""
|
|
@@ -89,6 +101,7 @@ class Switch2Data:
|
|
|
89
101
|
readings: list[MeterReading]
|
|
90
102
|
registers: dict[str, str] # register_id -> register_name
|
|
91
103
|
bills: list[Bill]
|
|
104
|
+
account_balance: AccountBalance | None
|
|
92
105
|
|
|
93
106
|
|
|
94
107
|
def _get_attr(tag: Tag, attr: str, default: str = "") -> str:
|
|
@@ -109,7 +122,7 @@ class Switch2ApiClient:
|
|
|
109
122
|
|
|
110
123
|
async def _ensure_session(self) -> aiohttp.ClientSession:
|
|
111
124
|
if self._session is None or self._session.closed:
|
|
112
|
-
self._session = aiohttp.ClientSession()
|
|
125
|
+
self._session = aiohttp.ClientSession(timeout=DEFAULT_TIMEOUT)
|
|
113
126
|
return self._session
|
|
114
127
|
|
|
115
128
|
async def close(self) -> None:
|
|
@@ -117,8 +130,19 @@ class Switch2ApiClient:
|
|
|
117
130
|
if self._session and not self._session.closed:
|
|
118
131
|
await self._session.close()
|
|
119
132
|
|
|
120
|
-
async def
|
|
121
|
-
|
|
133
|
+
async def __aenter__(self) -> "Switch2ApiClient":
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
async def __aexit__(
|
|
137
|
+
self,
|
|
138
|
+
exc_type: type[BaseException] | None,
|
|
139
|
+
exc: BaseException | None,
|
|
140
|
+
tb: TracebackType | None,
|
|
141
|
+
) -> None:
|
|
142
|
+
await self.close()
|
|
143
|
+
|
|
144
|
+
async def authenticate(self) -> tuple[CustomerInfo, BeautifulSoup]:
|
|
145
|
+
"""Log in to the Switch2 portal and return customer info and dashboard soup."""
|
|
122
146
|
session = await self._ensure_session()
|
|
123
147
|
|
|
124
148
|
try:
|
|
@@ -156,7 +180,7 @@ class Switch2ApiClient:
|
|
|
156
180
|
if not customer.name:
|
|
157
181
|
raise Switch2AuthError("Login failed: no customer info returned")
|
|
158
182
|
|
|
159
|
-
return customer
|
|
183
|
+
return customer, soup
|
|
160
184
|
|
|
161
185
|
except aiohttp.ClientError as err:
|
|
162
186
|
raise Switch2ConnectionError(
|
|
@@ -186,7 +210,8 @@ class Switch2ApiClient:
|
|
|
186
210
|
|
|
187
211
|
async def fetch_data(self) -> Switch2Data:
|
|
188
212
|
"""Authenticate and fetch all meter data."""
|
|
189
|
-
customer = await self.authenticate()
|
|
213
|
+
customer, dashboard_soup = await self.authenticate()
|
|
214
|
+
account_balance = _parse_account_balance(dashboard_soup)
|
|
190
215
|
session = await self._ensure_session()
|
|
191
216
|
|
|
192
217
|
try:
|
|
@@ -246,6 +271,7 @@ class Switch2ApiClient:
|
|
|
246
271
|
readings=readings,
|
|
247
272
|
registers=registers,
|
|
248
273
|
bills=bills,
|
|
274
|
+
account_balance=account_balance,
|
|
249
275
|
)
|
|
250
276
|
|
|
251
277
|
except aiohttp.ClientError as err:
|
|
@@ -347,6 +373,47 @@ def _parse_currency(text: str) -> float:
|
|
|
347
373
|
return -value if negative else value
|
|
348
374
|
|
|
349
375
|
|
|
376
|
+
def _parse_account_balance(soup: BeautifulSoup) -> AccountBalance | None:
|
|
377
|
+
"""Extract the current account balance from the dashboard page."""
|
|
378
|
+
amount_el = soup.select_one(".dashboard-credit-amount-desktop")
|
|
379
|
+
if not amount_el:
|
|
380
|
+
return None
|
|
381
|
+
balance = _parse_currency(amount_el.text)
|
|
382
|
+
updated_el = soup.select_one(".dashboard-credit-lastUpdated")
|
|
383
|
+
if not updated_el:
|
|
384
|
+
return None
|
|
385
|
+
updated_text = updated_el.text.strip()
|
|
386
|
+
# Text is like "Last updated 27/02/2026 10:13"
|
|
387
|
+
if updated_text.lower().startswith("last updated"):
|
|
388
|
+
updated_text = updated_text[len("last updated") :].strip()
|
|
389
|
+
last_updated = datetime.strptime(updated_text, "%d/%m/%Y %H:%M")
|
|
390
|
+
return AccountBalance(balance=balance, last_updated=last_updated)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _has_class(tag: Tag, class_name: str) -> bool:
|
|
394
|
+
"""Return True if the tag has the given CSS class."""
|
|
395
|
+
classes = tag.get("class")
|
|
396
|
+
if classes is None:
|
|
397
|
+
return False
|
|
398
|
+
if isinstance(classes, str):
|
|
399
|
+
return class_name in classes.split()
|
|
400
|
+
return class_name in classes
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _parse_charge_row(row: Tag) -> BillCharge | None:
|
|
404
|
+
"""Parse a single .bill-table-row into a BillCharge, or None if incomplete."""
|
|
405
|
+
desc_el = row.select_one(".bill-table-row-item-left")
|
|
406
|
+
units_el = row.select_one(".bill-table-row-item")
|
|
407
|
+
charge_el = row.select_one(".bill-table-row-item-right")
|
|
408
|
+
if not desc_el or not charge_el:
|
|
409
|
+
return None
|
|
410
|
+
return BillCharge(
|
|
411
|
+
description=desc_el.text.strip(),
|
|
412
|
+
units=units_el.text.strip() if units_el else "",
|
|
413
|
+
charge=_parse_currency(charge_el.text),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
350
417
|
def _parse_bill_charges(
|
|
351
418
|
soup: BeautifulSoup, container_selector: str
|
|
352
419
|
) -> list[BillCharge]:
|
|
@@ -354,20 +421,51 @@ def _parse_bill_charges(
|
|
|
354
421
|
charges: list[BillCharge] = []
|
|
355
422
|
rows = soup.select(f"{container_selector} .bill-table-row-desktop .bill-table-row")
|
|
356
423
|
for row in rows:
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if desc_el and charge_el:
|
|
361
|
-
charges.append(
|
|
362
|
-
BillCharge(
|
|
363
|
-
description=desc_el.text.strip(),
|
|
364
|
-
units=units_el.text.strip() if units_el else "",
|
|
365
|
-
charge=_parse_currency(charge_el.text),
|
|
366
|
-
)
|
|
367
|
-
)
|
|
424
|
+
charge = _parse_charge_row(row)
|
|
425
|
+
if charge is not None:
|
|
426
|
+
charges.append(charge)
|
|
368
427
|
return charges
|
|
369
428
|
|
|
370
429
|
|
|
430
|
+
def _parse_keyed_currency_rows(
|
|
431
|
+
container: Tag | None,
|
|
432
|
+
fields: dict[str, Callable[[str], bool]],
|
|
433
|
+
) -> dict[str, float]:
|
|
434
|
+
"""Parse .bill-total-table-row rows, matching labels to keys via predicates.
|
|
435
|
+
|
|
436
|
+
Returns a mapping for each predicate that matched a row; missing keys are
|
|
437
|
+
logged at warning level so silent zeros don't hide upstream HTML changes.
|
|
438
|
+
"""
|
|
439
|
+
found: dict[str, float] = {}
|
|
440
|
+
if container is None:
|
|
441
|
+
for key in fields:
|
|
442
|
+
_LOGGER.warning("Missing expected bill-total field: %s", key)
|
|
443
|
+
return found
|
|
444
|
+
for row in container.select(".bill-total-table-row"):
|
|
445
|
+
label_el = row.select_one(".bill-total-table-row-item-left")
|
|
446
|
+
value_el = row.select_one(".bill-total-table-row-item-right")
|
|
447
|
+
if not label_el or not value_el:
|
|
448
|
+
continue
|
|
449
|
+
label = label_el.text.strip()
|
|
450
|
+
for key, matches in fields.items():
|
|
451
|
+
if key not in found and matches(label):
|
|
452
|
+
found[key] = _parse_currency(value_el.text)
|
|
453
|
+
break
|
|
454
|
+
for key in fields:
|
|
455
|
+
if key not in found:
|
|
456
|
+
_LOGGER.warning("Missing expected bill-total field: %s", key)
|
|
457
|
+
return found
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _parse_currency_header(soup: BeautifulSoup, selector: str, name: str) -> float:
|
|
461
|
+
"""Parse a single currency value from a header selector, defaulting to 0.0."""
|
|
462
|
+
el = soup.select_one(selector)
|
|
463
|
+
if el is None:
|
|
464
|
+
_LOGGER.warning("Missing expected %s header", name)
|
|
465
|
+
return 0.0
|
|
466
|
+
return _parse_currency(el.text)
|
|
467
|
+
|
|
468
|
+
|
|
371
469
|
def _parse_bill_detail(soup: BeautifulSoup) -> BillDetail:
|
|
372
470
|
"""Extract bill detail from the bill detail page."""
|
|
373
471
|
# Header: invoice number and date of issue
|
|
@@ -409,110 +507,81 @@ def _parse_bill_detail(soup: BeautifulSoup) -> BillDetail:
|
|
|
409
507
|
soup, "#BillItemsContainer > .bill-table-row:first-of-type"
|
|
410
508
|
)
|
|
411
509
|
# If the CSS child selector doesn't match, fall back to looking at
|
|
412
|
-
# desktop rows that come before .other-charges-table-row
|
|
510
|
+
# desktop rows that come before .other-charges-table-row.
|
|
413
511
|
if not consumption_charges:
|
|
414
512
|
items_container = soup.select_one("#BillItemsContainer")
|
|
415
513
|
if items_container:
|
|
416
|
-
consumption_rows: list[Tag] = []
|
|
417
514
|
for child in items_container.children:
|
|
418
515
|
if not isinstance(child, Tag):
|
|
419
516
|
continue
|
|
420
|
-
if "other-charges-table-row"
|
|
517
|
+
if _has_class(child, "other-charges-table-row"):
|
|
421
518
|
break
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
consumption_charges.append(
|
|
435
|
-
BillCharge(
|
|
436
|
-
description=desc_el.text.strip(),
|
|
437
|
-
units=(units_el.text.strip() if units_el else ""),
|
|
438
|
-
charge=_parse_currency(charge_el.text),
|
|
439
|
-
)
|
|
440
|
-
)
|
|
441
|
-
|
|
442
|
-
# Other charges
|
|
519
|
+
desktop_row = child.select_one(
|
|
520
|
+
".bill-table-row-desktop .bill-table-row"
|
|
521
|
+
)
|
|
522
|
+
if desktop_row is None and _has_class(child, "bill-table-row-desktop"):
|
|
523
|
+
desktop_row = child.select_one(".bill-table-row")
|
|
524
|
+
if desktop_row is not None:
|
|
525
|
+
charge = _parse_charge_row(desktop_row)
|
|
526
|
+
if charge is not None:
|
|
527
|
+
consumption_charges.append(charge)
|
|
528
|
+
|
|
529
|
+
# Other charges: desktop rows that follow the .other-charges-table-row header,
|
|
530
|
+
# stopping at the first sibling that isn't a desktop or narrow row.
|
|
443
531
|
other_charges: list[BillCharge] = []
|
|
444
532
|
other_header = soup.select_one(".other-charges-table-row")
|
|
445
533
|
if other_header:
|
|
446
534
|
sibling = other_header.next_sibling
|
|
447
535
|
while sibling:
|
|
448
536
|
if isinstance(sibling, Tag):
|
|
449
|
-
if "bill-table-row-desktop"
|
|
537
|
+
if _has_class(sibling, "bill-table-row-desktop"):
|
|
450
538
|
charge_row = sibling.select_one(".bill-table-row")
|
|
451
|
-
if charge_row:
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
other_charges.append(
|
|
457
|
-
BillCharge(
|
|
458
|
-
description=desc_el.text.strip(),
|
|
459
|
-
units=(units_el.text.strip() if units_el else ""),
|
|
460
|
-
charge=_parse_currency(charge_el.text),
|
|
461
|
-
)
|
|
462
|
-
)
|
|
463
|
-
elif "bill-table-row-narrow" not in sibling.get("class", []):
|
|
539
|
+
if charge_row is not None:
|
|
540
|
+
charge = _parse_charge_row(charge_row)
|
|
541
|
+
if charge is not None:
|
|
542
|
+
other_charges.append(charge)
|
|
543
|
+
elif not _has_class(sibling, "bill-table-row-narrow"):
|
|
464
544
|
break
|
|
465
545
|
sibling = sibling.next_sibling
|
|
466
546
|
|
|
467
|
-
# Totals
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
547
|
+
# Totals. Defaults to 0.0 stay for backwards compatibility with callers, but
|
|
548
|
+
# log when an expected field is missing so problems aren't completely silent.
|
|
549
|
+
totals = _parse_keyed_currency_rows(
|
|
550
|
+
soup.select_one("#BillTotalsCollapsibleContent"),
|
|
551
|
+
{
|
|
552
|
+
"total_excl_vat": lambda label: label.startswith(
|
|
553
|
+
"Total charges excluding VAT"
|
|
554
|
+
),
|
|
555
|
+
"vat": lambda label: label.startswith("VAT"),
|
|
556
|
+
},
|
|
473
557
|
)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
value_el = row.select_one(".bill-total-table-row-item-right")
|
|
477
|
-
if label_el and value_el:
|
|
478
|
-
label = label_el.text.strip()
|
|
479
|
-
if label.startswith("Total charges excluding VAT"):
|
|
480
|
-
total_excl_vat = _parse_currency(value_el.text)
|
|
481
|
-
elif label.startswith("VAT"):
|
|
482
|
-
vat = _parse_currency(value_el.text)
|
|
558
|
+
total_excl_vat = totals.get("total_excl_vat", 0.0)
|
|
559
|
+
vat = totals.get("vat", 0.0)
|
|
483
560
|
|
|
484
561
|
# Bill total from the collapsible header
|
|
485
|
-
total =
|
|
486
|
-
|
|
487
|
-
"#BillTotalsContainer > .collapsible-header .bill-total-table-row-item-right"
|
|
562
|
+
total = _parse_currency_header(
|
|
563
|
+
soup,
|
|
564
|
+
"#BillTotalsContainer > .collapsible-header .bill-total-table-row-item-right",
|
|
565
|
+
"bill total",
|
|
488
566
|
)
|
|
489
|
-
if total_header:
|
|
490
|
-
total = _parse_currency(total_header.text)
|
|
491
567
|
|
|
492
568
|
# Account balance
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
569
|
+
balances = _parse_keyed_currency_rows(
|
|
570
|
+
soup.select_one("#AccountBalanceCollapsibleContent"),
|
|
571
|
+
{
|
|
572
|
+
"previous_balance": lambda label: label == "Previous account balance",
|
|
573
|
+
"payments_received": lambda label: label == "Payments received",
|
|
574
|
+
},
|
|
498
575
|
)
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
if label == "Previous account balance":
|
|
505
|
-
previous_balance = _parse_currency(value_el.text)
|
|
506
|
-
elif label == "Payments received":
|
|
507
|
-
payments_received = _parse_currency(value_el.text)
|
|
508
|
-
|
|
509
|
-
balance = 0.0
|
|
510
|
-
balance_header = soup.select_one(
|
|
576
|
+
previous_balance = balances.get("previous_balance", 0.0)
|
|
577
|
+
payments_received = balances.get("payments_received", 0.0)
|
|
578
|
+
|
|
579
|
+
balance = _parse_currency_header(
|
|
580
|
+
soup,
|
|
511
581
|
"#AccountBalanceContainer > .collapsible-header"
|
|
512
|
-
" .bill-total-table-row-item-right"
|
|
582
|
+
" .bill-total-table-row-item-right",
|
|
583
|
+
"account balance",
|
|
513
584
|
)
|
|
514
|
-
if balance_header:
|
|
515
|
-
balance = _parse_currency(balance_header.text)
|
|
516
585
|
|
|
517
586
|
# Download URL
|
|
518
587
|
download_url = ""
|
|
File without changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: switch2
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
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,16 +38,17 @@ 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
|
```
|
|
@@ -1,11 +1,14 @@
|
|
|
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
|
+
Switch2ApiClient,
|
|
11
|
+
_parse_account_balance,
|
|
9
12
|
_parse_bill_detail,
|
|
10
13
|
_parse_bills,
|
|
11
14
|
_parse_currency,
|
|
@@ -350,3 +353,52 @@ class ParseBillDetailTests(unittest.TestCase):
|
|
|
350
353
|
detail.download_url,
|
|
351
354
|
"https://my.switch2.co.uk/Credit/Bill/Download/2289896",
|
|
352
355
|
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class ClientContextManagerTests(unittest.TestCase):
|
|
359
|
+
def test_aclose_without_session(self) -> None:
|
|
360
|
+
async def run() -> None:
|
|
361
|
+
async with Switch2ApiClient("e", "p") as client:
|
|
362
|
+
self.assertIsNone(client._session)
|
|
363
|
+
|
|
364
|
+
asyncio.run(run())
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class ParseAccountBalanceTests(unittest.TestCase):
|
|
368
|
+
def test_balance_from_dashboard(self) -> None:
|
|
369
|
+
html = """
|
|
370
|
+
<div id="CreditPaymentBalanceDashTile">
|
|
371
|
+
<div class="dashboard-credit-amount-desktop font-largest">
|
|
372
|
+
£0.00
|
|
373
|
+
</div>
|
|
374
|
+
<div class="dashboard-credit-lastUpdated font-small-light">
|
|
375
|
+
Last updated 27/02/2026 10:13
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
"""
|
|
379
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
380
|
+
balance = _parse_account_balance(soup)
|
|
381
|
+
assert balance is not None
|
|
382
|
+
self.assertEqual(balance.balance, 0.00)
|
|
383
|
+
self.assertEqual(balance.last_updated, datetime(2026, 2, 27, 10, 13))
|
|
384
|
+
|
|
385
|
+
def test_balance_with_amount_owed(self) -> None:
|
|
386
|
+
html = """
|
|
387
|
+
<div id="CreditPaymentBalanceDashTile">
|
|
388
|
+
<div class="dashboard-credit-amount-desktop font-largest">
|
|
389
|
+
£172.26
|
|
390
|
+
</div>
|
|
391
|
+
<div class="dashboard-credit-lastUpdated font-small-light">
|
|
392
|
+
Last updated 15/03/2026 14:30
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
"""
|
|
396
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
397
|
+
balance = _parse_account_balance(soup)
|
|
398
|
+
assert balance is not None
|
|
399
|
+
self.assertEqual(balance.balance, 172.26)
|
|
400
|
+
self.assertEqual(balance.last_updated, datetime(2026, 3, 15, 14, 30))
|
|
401
|
+
|
|
402
|
+
def test_no_balance_element(self) -> None:
|
|
403
|
+
soup = BeautifulSoup("<div></div>", "html.parser")
|
|
404
|
+
self.assertIsNone(_parse_account_balance(soup))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|