switch2 0.1.0__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switch2
3
- Version: 0.1.0
3
+ Version: 0.1.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "switch2"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Async client library for the Switch2 energy portal"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,7 +1,10 @@
1
1
  """Standalone client library for the Switch2 energy portal."""
2
2
 
3
3
  from .api import (
4
+ AccountBalance,
4
5
  Bill,
6
+ BillCharge,
7
+ BillDetail,
5
8
  CustomerInfo,
6
9
  MeterReading,
7
10
  Switch2ApiClient,
@@ -11,7 +14,10 @@ from .api import (
11
14
  )
12
15
 
13
16
  __all__ = [
17
+ "AccountBalance",
14
18
  "Bill",
19
+ "BillCharge",
20
+ "BillDetail",
15
21
  "CustomerInfo",
16
22
  "MeterReading",
17
23
  "Switch2ApiClient",
@@ -0,0 +1,566 @@
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 BillCharge:
58
+ """A single line item on a bill."""
59
+
60
+ description: str
61
+ units: str
62
+ charge: float
63
+
64
+
65
+ @dataclass
66
+ class BillDetail:
67
+ """Detailed breakdown of a bill."""
68
+
69
+ invoice_number: str
70
+ date_of_issue: datetime
71
+ period_from: datetime
72
+ period_to: datetime
73
+ consumption_charges: list[BillCharge]
74
+ other_charges: list[BillCharge]
75
+ total_excl_vat: float
76
+ vat: float
77
+ total: float
78
+ previous_balance: float
79
+ payments_received: float
80
+ balance: float
81
+ download_url: str
82
+
83
+
84
+ @dataclass
85
+ class AccountBalance:
86
+ """Current account balance from the Switch2 portal."""
87
+
88
+ balance: float
89
+ last_updated: datetime
90
+
91
+
92
+ @dataclass
93
+ class Switch2Data:
94
+ """All data fetched from the Switch2 portal."""
95
+
96
+ customer: CustomerInfo
97
+ readings: list[MeterReading]
98
+ registers: dict[str, str] # register_id -> register_name
99
+ bills: list[Bill]
100
+ account_balance: AccountBalance | None
101
+
102
+
103
+ def _get_attr(tag: Tag, attr: str, default: str = "") -> str:
104
+ """Get a string attribute from a BeautifulSoup tag, handling list values."""
105
+ value = tag.get(attr, default)
106
+ if isinstance(value, list):
107
+ return str(value[0]) if value else default
108
+ return str(value) if value is not None else default
109
+
110
+
111
+ class Switch2ApiClient:
112
+ """Client to interact with the Switch2 web portal."""
113
+
114
+ def __init__(self, email: str, password: str) -> None:
115
+ self._email = email
116
+ self._password = password
117
+ self._session: aiohttp.ClientSession | None = None
118
+
119
+ async def _ensure_session(self) -> aiohttp.ClientSession:
120
+ if self._session is None or self._session.closed:
121
+ self._session = aiohttp.ClientSession()
122
+ return self._session
123
+
124
+ async def close(self) -> None:
125
+ """Close the underlying HTTP session."""
126
+ if self._session and not self._session.closed:
127
+ await self._session.close()
128
+
129
+ async def authenticate(self) -> tuple[CustomerInfo, BeautifulSoup]:
130
+ """Log in to the Switch2 portal and return customer info and dashboard soup."""
131
+ session = await self._ensure_session()
132
+
133
+ try:
134
+ # Step 1: GET the login page to get any CSRF tokens
135
+ async with session.get(LOGIN_URL) as resp:
136
+ if resp.status != 200:
137
+ raise Switch2ConnectionError(
138
+ f"Failed to load login page: HTTP {resp.status}"
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:
157
+ if resp.status != 200:
158
+ raise Switch2ConnectionError(
159
+ f"Login request failed: HTTP {resp.status}"
160
+ )
161
+ 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
+ except aiohttp.ClientError as err:
171
+ raise Switch2ConnectionError(
172
+ f"Connection error during login: {err}"
173
+ ) from err
174
+
175
+ async def fetch_bill_detail(self, bill: Bill) -> BillDetail:
176
+ """Fetch and parse the detail page for a bill.
177
+
178
+ The caller must be authenticated before calling this method.
179
+ """
180
+ if not bill.detail_url:
181
+ raise ValueError("Bill has no detail URL")
182
+ session = await self._ensure_session()
183
+ try:
184
+ async with session.get(bill.detail_url) as resp:
185
+ if resp.status != 200:
186
+ raise Switch2ConnectionError(
187
+ f"Failed to load bill detail: HTTP {resp.status}"
188
+ )
189
+ html = await resp.text()
190
+ except aiohttp.ClientError as err:
191
+ raise Switch2ConnectionError(
192
+ f"Connection error fetching bill detail: {err}"
193
+ ) from err
194
+ return _parse_bill_detail(BeautifulSoup(html, "html.parser"))
195
+
196
+ async def fetch_data(self) -> Switch2Data:
197
+ """Authenticate and fetch all meter data."""
198
+ customer, dashboard_soup = await self.authenticate()
199
+ account_balance = _parse_account_balance(dashboard_soup)
200
+ session = await self._ensure_session()
201
+
202
+ try:
203
+ # GET meter readings history page
204
+ async with session.get(METER_HISTORY_URL) as resp:
205
+ if resp.status != 200:
206
+ raise Switch2ConnectionError(
207
+ f"Failed to load meter history: HTTP {resp.status}"
208
+ )
209
+ html = await resp.text()
210
+
211
+ soup = BeautifulSoup(html, "html.parser")
212
+
213
+ # Parse available registers
214
+ registers: dict[str, str] = {}
215
+ register_select = soup.select_one("#RegisterId")
216
+ if register_select:
217
+ for option in register_select.select("option"):
218
+ reg_id = option.get("value", "")
219
+ if reg_id:
220
+ registers[str(reg_id)] = option.text.strip()
221
+
222
+ # Try to get all readings by POSTing with a large page size
223
+ token_input = soup.select_one('input[name="__RequestVerificationToken"]')
224
+ selected_register = soup.select_one("#RegisterId option[selected]")
225
+
226
+ if token_input and selected_register:
227
+ form_data = {
228
+ "__RequestVerificationToken": _get_attr(token_input, "value"),
229
+ "Page": "1",
230
+ "TotalPages": "1",
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")
241
+
242
+ readings = _parse_readings(soup)
243
+
244
+ # Fetch bill history
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()
251
+
252
+ bills = _parse_bills(BeautifulSoup(html, "html.parser"))
253
+
254
+ return Switch2Data(
255
+ customer=customer,
256
+ readings=readings,
257
+ registers=registers,
258
+ bills=bills,
259
+ account_balance=account_balance,
260
+ )
261
+
262
+ except aiohttp.ClientError as err:
263
+ raise Switch2ConnectionError(
264
+ f"Connection error fetching data: {err}"
265
+ ) from err
266
+
267
+
268
+ def _parse_date(text: str) -> datetime:
269
+ """Parse a date string like '27th February 2026'."""
270
+ # Strip ordinal suffixes (1st, 2nd, 3rd, 4th, etc.)
271
+ for suffix in ("st ", "nd ", "rd ", "th "):
272
+ text = text.replace(suffix, " ")
273
+ for fmt in ("%d %B %Y", "%d %b %Y"):
274
+ try:
275
+ return datetime.strptime(text, fmt)
276
+ except ValueError:
277
+ continue
278
+ raise ValueError(f"Cannot parse date: {text!r}")
279
+
280
+
281
+ def _parse_customer_info(soup: BeautifulSoup) -> CustomerInfo:
282
+ """Extract customer info from the dashboard page."""
283
+ name_el = soup.select_one(".customer-info-name")
284
+ acn_el = soup.select_one(".customer-info-account-number")
285
+ addr_el = soup.select_one(".customer-info-address")
286
+
287
+ return CustomerInfo(
288
+ name=name_el.text.strip() if name_el else "",
289
+ account_number=acn_el.text.strip() if acn_el else "",
290
+ address=addr_el.text.strip() if addr_el else "",
291
+ )
292
+
293
+
294
+ def _parse_readings(soup: BeautifulSoup) -> list[MeterReading]:
295
+ """Extract meter readings from the history page."""
296
+ readings = []
297
+ rows = soup.select(".meter-reading-history-table-data-row.desktop-layout")
298
+ for row in rows:
299
+ date_el = row.select_one(".meter-reading-history-table-data-date-row-item")
300
+ amount_el = row.select_one(".meter-reading-history-table-data-amount-row-item")
301
+ type_el = row.select_one(".meter-reading-history-table-data-type-row-item")
302
+ if date_el and amount_el:
303
+ try:
304
+ date = _parse_date(date_el.text.strip())
305
+ # Parse amount and unit (e.g. "8551 kWh" -> 8551, "kWh")
306
+ amount_parts = amount_el.text.strip().split()
307
+ amount = float(amount_parts[0])
308
+ unit = amount_parts[1] if len(amount_parts) > 1 else ""
309
+ reading_type = type_el.text.strip() if type_el else ""
310
+ readings.append(
311
+ MeterReading(
312
+ date=date,
313
+ amount=amount,
314
+ unit=unit,
315
+ reading_type=reading_type,
316
+ )
317
+ )
318
+ except (ValueError, TypeError) as err:
319
+ _LOGGER.debug("Failed to parse meter reading row: %s", err)
320
+
321
+ return readings
322
+
323
+
324
+ def _parse_bills(soup: BeautifulSoup) -> list[Bill]:
325
+ """Extract bills from the bill history page."""
326
+ bills = []
327
+ rows = soup.select(".bill-history-table-data-row")
328
+ for row in rows:
329
+ date_el = row.select_one(".bill-history-table-data-row-text-item")
330
+ amount_el = row.select_one(
331
+ ".bill-history-table-data-row-item-right"
332
+ ".bill-history-table-data-row-text-item"
333
+ )
334
+ link_el = row.select_one("a.bill-history-view-bill-button")
335
+ if date_el and amount_el:
336
+ try:
337
+ date = _parse_date(date_el.text.strip())
338
+ # Parse amount (e.g. "£172.26" -> 172.26)
339
+ amount_text = amount_el.text.strip().lstrip("£").replace(",", "")
340
+ amount = float(amount_text)
341
+ detail_url = ""
342
+ if link_el:
343
+ href = _get_attr(link_el, "href")
344
+ detail_url = f"{BASE_URL}{href}" if href else ""
345
+ bills.append(Bill(date=date, amount=amount, detail_url=detail_url))
346
+ except (ValueError, TypeError) as err:
347
+ _LOGGER.debug("Failed to parse bill row: %s", err)
348
+
349
+ return bills
350
+
351
+
352
+ def _parse_currency(text: str) -> float:
353
+ """Parse a currency string like '£172.26' or '-£205.13'."""
354
+ text = text.strip()
355
+ negative = text.startswith("-")
356
+ text = text.lstrip("-").lstrip("£").lstrip("\xa3").replace(",", "")
357
+ value = float(text)
358
+ return -value if negative else value
359
+
360
+
361
+ def _parse_account_balance(soup: BeautifulSoup) -> AccountBalance | None:
362
+ """Extract the current account balance from the dashboard page."""
363
+ amount_el = soup.select_one(".dashboard-credit-amount-desktop")
364
+ if not amount_el:
365
+ return None
366
+ balance = _parse_currency(amount_el.text)
367
+ updated_el = soup.select_one(".dashboard-credit-lastUpdated")
368
+ if not updated_el:
369
+ return None
370
+ updated_text = updated_el.text.strip()
371
+ # Text is like "Last updated 27/02/2026 10:13"
372
+ if updated_text.lower().startswith("last updated"):
373
+ updated_text = updated_text[len("last updated") :].strip()
374
+ last_updated = datetime.strptime(updated_text, "%d/%m/%Y %H:%M")
375
+ return AccountBalance(balance=balance, last_updated=last_updated)
376
+
377
+
378
+ def _parse_bill_charges(
379
+ soup: BeautifulSoup, container_selector: str
380
+ ) -> list[BillCharge]:
381
+ """Extract charge line items from desktop-layout rows."""
382
+ charges: list[BillCharge] = []
383
+ rows = soup.select(f"{container_selector} .bill-table-row-desktop .bill-table-row")
384
+ for row in rows:
385
+ desc_el = row.select_one(".bill-table-row-item-left")
386
+ units_el = row.select_one(".bill-table-row-item")
387
+ charge_el = row.select_one(".bill-table-row-item-right")
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
+ )
396
+ return charges
397
+
398
+
399
+ def _parse_bill_detail(soup: BeautifulSoup) -> BillDetail:
400
+ """Extract bill detail from the bill detail page."""
401
+ # Header: invoice number and date of issue
402
+ header_rows = soup.select(".bill-header-row")
403
+ invoice_number = ""
404
+ date_of_issue_text = ""
405
+ for row in header_rows:
406
+ label_el = row.select_one(".bill-header-row-item")
407
+ value_el = row.select_one(".bill-header-row-item-right")
408
+ if label_el and value_el:
409
+ label = label_el.text.strip().rstrip(":")
410
+ if label == "Invoice Number":
411
+ invoice_number = value_el.text.strip()
412
+ elif label == "Date of issue":
413
+ date_of_issue_text = value_el.text.strip()
414
+
415
+ date_of_issue = _parse_date(date_of_issue_text)
416
+
417
+ # Period: from/to dates
418
+ from_el = soup.select_one(".bill-table-row-item-left")
419
+ to_el = soup.select_one(".bill-table-row-item-dateto")
420
+ period_from_text = ""
421
+ period_to_text = ""
422
+ if from_el:
423
+ # Text is like "From: 27th January 2026"
424
+ period_from_text = from_el.text.strip()
425
+ if period_from_text.lower().startswith("from:"):
426
+ period_from_text = period_from_text[5:].strip()
427
+ if to_el:
428
+ period_to_text = to_el.text.strip()
429
+ if period_to_text.lower().startswith("to:"):
430
+ period_to_text = period_to_text[3:].strip()
431
+
432
+ period_from = _parse_date(period_from_text)
433
+ period_to = _parse_date(period_to_text)
434
+
435
+ # Consumption charges (desktop layout rows within BillItemsContainer)
436
+ consumption_charges = _parse_bill_charges(
437
+ soup, "#BillItemsContainer > .bill-table-row:first-of-type"
438
+ )
439
+ # If the CSS child selector doesn't match, fall back to looking at
440
+ # desktop rows that come before .other-charges-table-row
441
+ if not consumption_charges:
442
+ items_container = soup.select_one("#BillItemsContainer")
443
+ if items_container:
444
+ consumption_rows: list[Tag] = []
445
+ for child in items_container.children:
446
+ if not isinstance(child, Tag):
447
+ continue
448
+ if "other-charges-table-row" in child.get("class", []):
449
+ break
450
+ desktop = child.select_one(".bill-table-row-desktop .bill-table-row")
451
+ if desktop:
452
+ consumption_rows.append(desktop)
453
+ elif "bill-table-row-desktop" in child.get("class", []):
454
+ inner = child.select_one(".bill-table-row")
455
+ if inner:
456
+ consumption_rows.append(inner)
457
+ for row in consumption_rows:
458
+ desc_el = row.select_one(".bill-table-row-item-left")
459
+ units_el = row.select_one(".bill-table-row-item")
460
+ charge_el = row.select_one(".bill-table-row-item-right")
461
+ if desc_el and charge_el:
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
471
+ other_charges: list[BillCharge] = []
472
+ other_header = soup.select_one(".other-charges-table-row")
473
+ if other_header:
474
+ sibling = other_header.next_sibling
475
+ while sibling:
476
+ if isinstance(sibling, Tag):
477
+ if "bill-table-row-desktop" in sibling.get("class", []):
478
+ charge_row = sibling.select_one(".bill-table-row")
479
+ if charge_row:
480
+ desc_el = charge_row.select_one(".bill-table-row-item-left")
481
+ units_el = charge_row.select_one(".bill-table-row-item")
482
+ charge_el = charge_row.select_one(".bill-table-row-item-right")
483
+ if desc_el and charge_el:
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", []):
492
+ break
493
+ sibling = sibling.next_sibling
494
+
495
+ # Totals
496
+ totals_container = soup.select_one("#BillTotalsCollapsibleContent")
497
+ total_excl_vat = 0.0
498
+ vat = 0.0
499
+ total_rows = (
500
+ totals_container.select(".bill-total-table-row") if totals_container else []
501
+ )
502
+ for row in total_rows:
503
+ label_el = row.select_one(".bill-total-table-row-item-left")
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)
511
+
512
+ # Bill total from the collapsible header
513
+ total = 0.0
514
+ total_header = soup.select_one(
515
+ "#BillTotalsContainer > .collapsible-header .bill-total-table-row-item-right"
516
+ )
517
+ if total_header:
518
+ total = _parse_currency(total_header.text)
519
+
520
+ # Account balance
521
+ balance_container = soup.select_one("#AccountBalanceCollapsibleContent")
522
+ previous_balance = 0.0
523
+ payments_received = 0.0
524
+ balance_rows = (
525
+ balance_container.select(".bill-total-table-row") if balance_container else []
526
+ )
527
+ for row in balance_rows:
528
+ label_el = row.select_one(".bill-total-table-row-item-left")
529
+ value_el = row.select_one(".bill-total-table-row-item-right")
530
+ if label_el and value_el:
531
+ label = label_el.text.strip()
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(
539
+ "#AccountBalanceContainer > .collapsible-header"
540
+ " .bill-total-table-row-item-right"
541
+ )
542
+ if balance_header:
543
+ balance = _parse_currency(balance_header.text)
544
+
545
+ # Download URL
546
+ download_url = ""
547
+ download_el = soup.select_one("#DownloadBillButton")
548
+ if download_el:
549
+ href = _get_attr(download_el, "href")
550
+ download_url = f"{BASE_URL}{href}" if href else ""
551
+
552
+ return BillDetail(
553
+ invoice_number=invoice_number,
554
+ date_of_issue=date_of_issue,
555
+ period_from=period_from,
556
+ period_to=period_to,
557
+ consumption_charges=consumption_charges,
558
+ other_charges=other_charges,
559
+ total_excl_vat=total_excl_vat,
560
+ vat=vat,
561
+ total=total,
562
+ previous_balance=previous_balance,
563
+ payments_received=payments_received,
564
+ balance=balance,
565
+ download_url=download_url,
566
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switch2
3
- Version: 0.1.0
3
+ Version: 0.1.2
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