diff --git a/switch2/__init__.py b/switch2/__init__.py index e6c8af1..bfb0296 100644 --- a/switch2/__init__.py +++ b/switch2/__init__.py @@ -1,6 +1,7 @@ """Standalone client library for the Switch2 energy portal.""" from .api import ( + AccountBalance, Bill, BillCharge, BillDetail, @@ -13,6 +14,7 @@ ) __all__ = [ + "AccountBalance", "Bill", "BillCharge", "BillDetail", diff --git a/switch2/api.py b/switch2/api.py index a48c85a..bf192b8 100644 --- a/switch2/api.py +++ b/switch2/api.py @@ -81,6 +81,14 @@ class BillDetail: download_url: str +@dataclass +class AccountBalance: + """Current account balance from the Switch2 portal.""" + + balance: float + last_updated: datetime + + @dataclass class Switch2Data: """All data fetched from the Switch2 portal.""" @@ -89,6 +97,7 @@ class Switch2Data: readings: list[MeterReading] registers: dict[str, str] # register_id -> register_name bills: list[Bill] + account_balance: AccountBalance | None def _get_attr(tag: Tag, attr: str, default: str = "") -> str: @@ -117,8 +126,8 @@ async def close(self) -> None: if self._session and not self._session.closed: await self._session.close() - async def authenticate(self) -> CustomerInfo: - """Log in to the Switch2 portal and return customer info.""" + async def authenticate(self) -> tuple[CustomerInfo, BeautifulSoup]: + """Log in to the Switch2 portal and return customer info and dashboard soup.""" session = await self._ensure_session() try: @@ -156,7 +165,7 @@ async def authenticate(self) -> CustomerInfo: if not customer.name: raise Switch2AuthError("Login failed: no customer info returned") - return customer + return customer, soup except aiohttp.ClientError as err: raise Switch2ConnectionError( @@ -186,7 +195,8 @@ async def fetch_bill_detail(self, bill: Bill) -> BillDetail: async def fetch_data(self) -> Switch2Data: """Authenticate and fetch all meter data.""" - customer = await self.authenticate() + customer, dashboard_soup = await self.authenticate() + account_balance = _parse_account_balance(dashboard_soup) session = await self._ensure_session() try: @@ -246,6 +256,7 @@ async def fetch_data(self) -> Switch2Data: readings=readings, registers=registers, bills=bills, + account_balance=account_balance, ) except aiohttp.ClientError as err: @@ -347,6 +358,23 @@ def _parse_currency(text: str) -> float: return -value if negative else value +def _parse_account_balance(soup: BeautifulSoup) -> AccountBalance | None: + """Extract the current account balance from the dashboard page.""" + amount_el = soup.select_one(".dashboard-credit-amount-desktop") + if not amount_el: + return None + balance = _parse_currency(amount_el.text) + updated_el = soup.select_one(".dashboard-credit-lastUpdated") + if not updated_el: + return None + updated_text = updated_el.text.strip() + # Text is like "Last updated 27/02/2026 10:13" + if updated_text.lower().startswith("last updated"): + updated_text = updated_text[len("last updated") :].strip() + last_updated = datetime.strptime(updated_text, "%d/%m/%Y %H:%M") + return AccountBalance(balance=balance, last_updated=last_updated) + + def _parse_bill_charges( soup: BeautifulSoup, container_selector: str ) -> list[BillCharge]: diff --git a/tests/test_api.py b/tests/test_api.py index 1041b64..af5208f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,6 +6,7 @@ from bs4 import BeautifulSoup from switch2.api import ( + _parse_account_balance, _parse_bill_detail, _parse_bills, _parse_currency, @@ -350,3 +351,43 @@ def test_download_url(self) -> None: detail.download_url, "https://my.switch2.co.uk/Credit/Bill/Download/2289896", ) + + +class ParseAccountBalanceTests(unittest.TestCase): + def test_balance_from_dashboard(self) -> None: + html = """ +
+
+ £0.00 +
+
+ Last updated 27/02/2026 10:13 +
+
+ """ + soup = BeautifulSoup(html, "html.parser") + balance = _parse_account_balance(soup) + assert balance is not None + self.assertEqual(balance.balance, 0.00) + self.assertEqual(balance.last_updated, datetime(2026, 2, 27, 10, 13)) + + def test_balance_with_amount_owed(self) -> None: + html = """ +
+
+ £172.26 +
+
+ Last updated 15/03/2026 14:30 +
+
+ """ + soup = BeautifulSoup(html, "html.parser") + balance = _parse_account_balance(soup) + assert balance is not None + self.assertEqual(balance.balance, 172.26) + self.assertEqual(balance.last_updated, datetime(2026, 3, 15, 14, 30)) + + def test_no_balance_element(self) -> None: + soup = BeautifulSoup("
", "html.parser") + self.assertIsNone(_parse_account_balance(soup))