switch2 0.1.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switch2
3
- Version: 0.1.3
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
@@ -53,6 +53,24 @@ async def main():
53
53
  asyncio.run(main())
54
54
  ```
55
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
+
56
74
  ## License
57
75
 
58
76
  Apache-2.0
@@ -30,6 +30,24 @@ async def main():
30
30
  asyncio.run(main())
31
31
  ```
32
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
+
33
51
  ## License
34
52
 
35
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.1.3"
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"
@@ -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()
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  from collections.abc import Callable
7
- from dataclasses import dataclass
7
+ from dataclasses import dataclass, field
8
8
  from datetime import datetime
9
9
  from types import TracebackType
10
10
 
@@ -21,14 +21,22 @@ BILL_HISTORY_URL = f"{BASE_URL}/Credit/BillHistory"
21
21
  DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=60)
22
22
 
23
23
 
24
- class Switch2AuthError(Exception):
25
- """Raised when authentication fails."""
24
+ class Switch2Error(Exception):
25
+ """Base class for errors raised by this library."""
26
26
 
27
27
 
28
- class Switch2ConnectionError(Exception):
28
+ class Switch2AuthError(Switch2Error):
29
+ """Raised when the portal rejects the credentials."""
30
+
31
+
32
+ class Switch2ConnectionError(Switch2Error):
29
33
  """Raised when a connection error occurs."""
30
34
 
31
35
 
36
+ class Switch2ParseError(Switch2Error):
37
+ """Raised when a portal page does not have the expected structure."""
38
+
39
+
32
40
  @dataclass
33
41
  class CustomerInfo:
34
42
  """Customer information from the Switch2 portal."""
@@ -65,6 +73,20 @@ class BillCharge:
65
73
  units: str
66
74
  charge: float
67
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
+
68
90
 
69
91
  @dataclass
70
92
  class BillDetail:
@@ -98,10 +120,13 @@ class Switch2Data:
98
120
  """All data fetched from the Switch2 portal."""
99
121
 
100
122
  customer: CustomerInfo
123
+ # Readings for the register the portal selects by default.
101
124
  readings: list[MeterReading]
102
125
  registers: dict[str, str] # register_id -> register_name
103
126
  bills: list[Bill]
104
127
  account_balance: AccountBalance | None
128
+ # register_id -> readings, newest first
129
+ register_readings: dict[str, list[MeterReading]] = field(default_factory=dict)
105
130
 
106
131
 
107
132
  def _get_attr(tag: Tag, attr: str, default: str = "") -> str:
@@ -115,9 +140,21 @@ def _get_attr(tag: Tag, attr: str, default: str = "") -> str:
115
140
  class Switch2ApiClient:
116
141
  """Client to interact with the Switch2 web portal."""
117
142
 
118
- def __init__(self, email: str, password: str) -> None:
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
+ """
119
155
  self._email = email
120
156
  self._password = password
157
+ self._on_page = on_page
121
158
  self._session: aiohttp.ClientSession | None = None
122
159
 
123
160
  async def _ensure_session(self) -> aiohttp.ClientSession:
@@ -141,51 +178,44 @@ class Switch2ApiClient:
141
178
  ) -> None:
142
179
  await self.close()
143
180
 
144
- async def authenticate(self) -> tuple[CustomerInfo, BeautifulSoup]:
145
- """Log in to the Switch2 portal and return customer info and dashboard soup."""
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."""
146
185
  session = await self._ensure_session()
147
-
148
186
  try:
149
- # Step 1: GET the login page to get any CSRF tokens
150
- async with session.get(LOGIN_URL) 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:
151
192
  if resp.status != 200:
152
193
  raise Switch2ConnectionError(
153
- f"Failed to load login page: HTTP {resp.status}"
194
+ f"Failed to load {what}: HTTP {resp.status}"
154
195
  )
155
196
  html = await resp.text()
156
-
157
- soup = BeautifulSoup(html, "html.parser")
158
- token_input = soup.select_one('input[name="__RequestVerificationToken"]')
159
- form_data: dict[str, str] = {}
160
- if token_input:
161
- form_data["__RequestVerificationToken"] = _get_attr(
162
- token_input, "value"
163
- )
164
-
165
- # Step 2: POST login credentials
166
- form_data["UserName"] = self._email
167
- form_data["Password"] = self._password
168
-
169
- async with session.post(
170
- LOGIN_URL, data=form_data, allow_redirects=True
171
- ) as resp:
172
- if resp.status != 200:
173
- raise Switch2ConnectionError(
174
- f"Login request failed: HTTP {resp.status}"
175
- )
176
- html = await resp.text()
177
-
178
- soup = BeautifulSoup(html, "html.parser")
179
- customer = _parse_customer_info(soup)
180
- if not customer.name:
181
- raise Switch2AuthError("Login failed: no customer info returned")
182
-
183
- return customer, soup
184
-
185
197
  except aiohttp.ClientError as err:
186
198
  raise Switch2ConnectionError(
187
- f"Connection error during login: {err}"
199
+ f"Connection error loading {what}: {err}"
188
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
189
219
 
190
220
  async def fetch_bill_detail(self, bill: Bill) -> BillDetail:
191
221
  """Fetch and parse the detail page for a bill.
@@ -194,90 +224,72 @@ class Switch2ApiClient:
194
224
  """
195
225
  if not bill.detail_url:
196
226
  raise ValueError("Bill has no detail URL")
197
- session = await self._ensure_session()
227
+ soup = await self._fetch(bill.detail_url, "bill detail")
198
228
  try:
199
- async with session.get(bill.detail_url) as resp:
200
- if resp.status != 200:
201
- raise Switch2ConnectionError(
202
- f"Failed to load bill detail: HTTP {resp.status}"
203
- )
204
- html = await resp.text()
205
- except aiohttp.ClientError as err:
206
- raise Switch2ConnectionError(
207
- 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}"
208
233
  ) from err
209
- return _parse_bill_detail(BeautifulSoup(html, "html.parser"))
210
234
 
211
235
  async def fetch_data(self) -> Switch2Data:
212
236
  """Authenticate and fetch all meter data."""
213
237
  customer, dashboard_soup = await self.authenticate()
214
238
  account_balance = _parse_account_balance(dashboard_soup)
215
- session = await self._ensure_session()
216
239
 
217
- try:
218
- # GET meter readings history page
219
- async with session.get(METER_HISTORY_URL) as resp:
220
- if resp.status != 200:
221
- raise Switch2ConnectionError(
222
- f"Failed to load meter history: HTTP {resp.status}"
223
- )
224
- html = await resp.text()
225
-
226
- soup = BeautifulSoup(html, "html.parser")
227
-
228
- # Parse available registers
229
- registers: dict[str, str] = {}
230
- register_select = soup.select_one("#RegisterId")
231
- if register_select:
232
- for option in register_select.select("option"):
233
- reg_id = option.get("value", "")
234
- if reg_id:
235
- registers[str(reg_id)] = option.text.strip()
236
-
237
- # Try to get all readings by POSTing with a large page size
238
- token_input = soup.select_one('input[name="__RequestVerificationToken"]')
239
- selected_register = soup.select_one("#RegisterId option[selected]")
240
-
241
- if token_input and selected_register:
242
- form_data = {
243
- "__RequestVerificationToken": _get_attr(token_input, "value"),
244
- "Page": "1",
245
- "TotalPages": "1",
246
- "RegisterId": _get_attr(selected_register, "value"),
247
- "PageSize": "1000000",
248
- }
249
-
250
- async with session.post(
251
- METER_HISTORY_URL, data=form_data, allow_redirects=True
252
- ) as resp:
253
- if resp.status == 200:
254
- html = await resp.text()
255
- soup = BeautifulSoup(html, "html.parser")
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)
256
269
 
270
+ if selected_id in register_readings:
271
+ readings = register_readings[selected_id]
272
+ else:
257
273
  readings = _parse_readings(soup)
258
274
 
259
- # Fetch bill history
260
- async with session.get(BILL_HISTORY_URL) as resp:
261
- if resp.status != 200:
262
- raise Switch2ConnectionError(
263
- f"Failed to load bill history: HTTP {resp.status}"
264
- )
265
- html = await resp.text()
275
+ bills = _parse_bills(await self._fetch(BILL_HISTORY_URL, "bill history"))
266
276
 
267
- bills = _parse_bills(BeautifulSoup(html, "html.parser"))
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
+ )
268
285
 
269
- return Switch2Data(
270
- customer=customer,
271
- readings=readings,
272
- registers=registers,
273
- bills=bills,
274
- account_balance=account_balance,
275
- )
276
286
 
277
- except aiohttp.ClientError as err:
278
- raise Switch2ConnectionError(
279
- f"Connection error fetching data: {err}"
280
- ) from err
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")}
281
293
 
282
294
 
283
295
  def _parse_date(text: str) -> datetime:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switch2
3
- Version: 0.1.3
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
@@ -53,6 +53,24 @@ async def main():
53
53
  asyncio.run(main())
54
54
  ```
55
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
+
56
74
  ## License
57
75
 
58
76
  Apache-2.0
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  switch2/__init__.py
4
+ switch2/__main__.py
4
5
  switch2/api.py
5
6
  switch2/py.typed
6
7
  switch2.egg-info/PKG-INFO
@@ -7,7 +7,11 @@ from datetime import datetime
7
7
  from bs4 import BeautifulSoup
8
8
 
9
9
  from switch2.api import (
10
+ Bill,
11
+ BillCharge,
10
12
  Switch2ApiClient,
13
+ Switch2AuthError,
14
+ Switch2ParseError,
11
15
  _parse_account_balance,
12
16
  _parse_bill_detail,
13
17
  _parse_bills,
@@ -402,3 +406,145 @@ class ParseAccountBalanceTests(unittest.TestCase):
402
406
  def test_no_balance_element(self) -> None:
403
407
  soup = BeautifulSoup("<div></div>", "html.parser")
404
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])
File without changes
File without changes