Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions switch2/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Standalone client library for the Switch2 energy portal."""

from .api import (
AccountBalance,
Bill,
BillCharge,
BillDetail,
Expand All @@ -13,6 +14,7 @@
)

__all__ = [
"AccountBalance",
"Bill",
"BillCharge",
"BillDetail",
Expand Down
36 changes: 32 additions & 4 deletions switch2/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down
41 changes: 41 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from bs4 import BeautifulSoup

from switch2.api import (
_parse_account_balance,
_parse_bill_detail,
_parse_bills,
_parse_currency,
Expand Down Expand Up @@ -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 = """
<div id="CreditPaymentBalanceDashTile">
<div class="dashboard-credit-amount-desktop font-largest">
&#163;0.00
</div>
<div class="dashboard-credit-lastUpdated font-small-light">
Last updated 27/02/2026 10:13
</div>
</div>
"""
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 = """
<div id="CreditPaymentBalanceDashTile">
<div class="dashboard-credit-amount-desktop font-largest">
&#163;172.26
</div>
<div class="dashboard-credit-lastUpdated font-small-light">
Last updated 15/03/2026 14:30
</div>
</div>
"""
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("<div></div>", "html.parser")
self.assertIsNone(_parse_account_balance(soup))