usms

USMS: A client library for interacting with the utility portal.

This package provides programmatic access to login, retrieve meter information, fetch billing details, and more from the USMS platform.

 1"""
 2USMS: A client library for interacting with the utility portal.
 3
 4This package provides programmatic access to login, retrieve meter information,
 5fetch billing details, and more from the USMS platform.
 6"""
 7
 8from usms.config.constants import BRUNEI_TZ, TARIFFS, UNITS
 9from usms.core.client import USMSClient
10from usms.models.account import USMSAccount
11from usms.models.tariff import USMSTariff, USMSTariffTier
12
13__all__ = [
14    "BRUNEI_TZ",
15    "TARIFFS",
16    "UNITS",
17    "USMSAccount",
18    "USMSClient",
19    "USMSTariff",
20    "USMSTariffTier",
21]
BRUNEI_TZ = zoneinfo.ZoneInfo(key='Asia/Brunei')
TARIFFS = {'ELECTRIC': USMSTariff(tiers=[USMSTariffTier(lower_bound=1, upper_bound=600, rate=0.01), USMSTariffTier(lower_bound=601, upper_bound=2000, rate=0.08), USMSTariffTier(lower_bound=2001, upper_bound=4000, rate=0.1), USMSTariffTier(lower_bound=4001, upper_bound=inf, rate=0.12)]), 'WATER': USMSTariff(tiers=[USMSTariffTier(lower_bound=1, upper_bound=54.54, rate=0.11), USMSTariffTier(lower_bound=54.54, upper_bound=inf, rate=0.44)])}
UNITS = {'ELECTRIC': 'kWh', 'WATER': 'meter cube'}
class USMSAccount:
 22class USMSAccount:
 23    """
 24    Represents a USMS account.
 25
 26    Represents a USMS account, allowing access to account details
 27    and associated meters.
 28    """
 29
 30    session: None
 31
 32    """USMS Account class attributes."""
 33    reg_no: str
 34    name: str
 35    contact_no: str
 36    email: str
 37    meters: list
 38
 39    def __init__(self, username: str, password: str) -> None:
 40        """
 41        Initialize a USMSAccount instance.
 42
 43        Initialize a USMSAccount instance by authenticating the user
 44        and retrieving account details.
 45        """
 46        self.username = username
 47
 48        self.session = USMSClient(username, password)
 49
 50        logger.debug(f"[{self.username}] Initializing account {self.username}")
 51        self.fetch_details()
 52        logger.debug(f"[{self.username}] Initialized account")
 53
 54    def fetch_details(self) -> None:
 55        """
 56        Fetch and set account details.
 57
 58        Fetch and set account details including registration number,
 59        name, contact number, email, and associated meters.
 60        """
 61        logger.debug(f"[{self.username}] Fetching account details")
 62
 63        response = self.session.get("/AccountInfo")
 64        response_html = lxml.html.fromstring(response.content)
 65
 66        self.reg_no = response_html.find(
 67            """.//span[@id="ASPxFormLayout1_lblIDNumber"]"""
 68        ).text_content()
 69        self.name = response_html.find(""".//span[@id="ASPxFormLayout1_lblName"]""").text_content()
 70        self.contact_no = response_html.find(
 71            """.//span[@id="ASPxFormLayout1_lblContactNo"]"""
 72        ).text_content()
 73        self.email = response_html.find(
 74            """.//span[@id="ASPxFormLayout1_lblEmail"]"""
 75        ).text_content()
 76
 77        # Get all meters associated with this account
 78        self.meters = []
 79        root = response_html.find(""".//div[@id="ASPxPanel1_ASPxTreeView1_CD"]""")  # Nx_y_z
 80        for x, lvl1 in enumerate(root.findall("./ul/li")):
 81            for y, lvl2 in enumerate(lvl1.findall("./ul/li")):
 82                for z, _ in enumerate(lvl2.findall("./ul/li")):
 83                    meter = USMSMeter(self, f"N{x}_{y}_{z}")
 84                    self.meters.append(meter)
 85
 86        logger.debug(f"[{self.username}] Fetched account details: {self.name}")
 87
 88    def get_meter(self, meter_no: str | int) -> USMSMeter:
 89        """Retrieve a specific USMSMeter object by its ID or meter number."""
 90        if isinstance(meter_no, int):
 91            meter_no = str(meter_no)
 92
 93        for meter in self.meters:
 94            if meter_no in (meter.id, meter.no):
 95                return meter
 96
 97        raise USMSMeterNumberError(meter_no)
 98
 99    def get_latest_update(self) -> None:
100        """Determine the most recent update timestamp among all meters."""
101        latest_update = datetime.min.replace(tzinfo=BRUNEI_TZ)
102
103        for meter in self.meters:
104            last_update = meter.get_last_updated()
105            latest_update = max(latest_update, last_update)
106
107        return latest_update
108
109    def log_out(self) -> None:
110        """Log the user out of the USMS session by clearing session cookies."""
111        self.session.get("/ResLogin")
112        self.session.cookies = {}

Represents a USMS account.

Represents a USMS account, allowing access to account details and associated meters.

USMSAccount(username: str, password: str)
39    def __init__(self, username: str, password: str) -> None:
40        """
41        Initialize a USMSAccount instance.
42
43        Initialize a USMSAccount instance by authenticating the user
44        and retrieving account details.
45        """
46        self.username = username
47
48        self.session = USMSClient(username, password)
49
50        logger.debug(f"[{self.username}] Initializing account {self.username}")
51        self.fetch_details()
52        logger.debug(f"[{self.username}] Initialized account")

Initialize a USMSAccount instance.

Initialize a USMSAccount instance by authenticating the user and retrieving account details.

session: None

USMS Account class attributes.

reg_no: str
name: str
contact_no: str
email: str
meters: list
username
def fetch_details(self) -> None:
54    def fetch_details(self) -> None:
55        """
56        Fetch and set account details.
57
58        Fetch and set account details including registration number,
59        name, contact number, email, and associated meters.
60        """
61        logger.debug(f"[{self.username}] Fetching account details")
62
63        response = self.session.get("/AccountInfo")
64        response_html = lxml.html.fromstring(response.content)
65
66        self.reg_no = response_html.find(
67            """.//span[@id="ASPxFormLayout1_lblIDNumber"]"""
68        ).text_content()
69        self.name = response_html.find(""".//span[@id="ASPxFormLayout1_lblName"]""").text_content()
70        self.contact_no = response_html.find(
71            """.//span[@id="ASPxFormLayout1_lblContactNo"]"""
72        ).text_content()
73        self.email = response_html.find(
74            """.//span[@id="ASPxFormLayout1_lblEmail"]"""
75        ).text_content()
76
77        # Get all meters associated with this account
78        self.meters = []
79        root = response_html.find(""".//div[@id="ASPxPanel1_ASPxTreeView1_CD"]""")  # Nx_y_z
80        for x, lvl1 in enumerate(root.findall("./ul/li")):
81            for y, lvl2 in enumerate(lvl1.findall("./ul/li")):
82                for z, _ in enumerate(lvl2.findall("./ul/li")):
83                    meter = USMSMeter(self, f"N{x}_{y}_{z}")
84                    self.meters.append(meter)
85
86        logger.debug(f"[{self.username}] Fetched account details: {self.name}")

Fetch and set account details.

Fetch and set account details including registration number, name, contact number, email, and associated meters.

def get_meter(self, meter_no: str | int) -> usms.models.meter.USMSMeter:
88    def get_meter(self, meter_no: str | int) -> USMSMeter:
89        """Retrieve a specific USMSMeter object by its ID or meter number."""
90        if isinstance(meter_no, int):
91            meter_no = str(meter_no)
92
93        for meter in self.meters:
94            if meter_no in (meter.id, meter.no):
95                return meter
96
97        raise USMSMeterNumberError(meter_no)

Retrieve a specific USMSMeter object by its ID or meter number.

def get_latest_update(self) -> None:
 99    def get_latest_update(self) -> None:
100        """Determine the most recent update timestamp among all meters."""
101        latest_update = datetime.min.replace(tzinfo=BRUNEI_TZ)
102
103        for meter in self.meters:
104            last_update = meter.get_last_updated()
105            latest_update = max(latest_update, last_update)
106
107        return latest_update

Determine the most recent update timestamp among all meters.

def log_out(self) -> None:
109    def log_out(self) -> None:
110        """Log the user out of the USMS session by clearing session cookies."""
111        self.session.get("/ResLogin")
112        self.session.cookies = {}

Log the user out of the USMS session by clearing session cookies.

class USMSClient(httpx.Client):
17class USMSClient(httpx.Client):
18    """Custom HTTP client for interacting with USMS."""
19
20    BASE_URL = "https://www.usms.com.bn/SmartMeter/"
21
22    def __init__(self, username: str, password: str, timeout: float = 30.0) -> None:
23        """Initialize a USMSClient instance."""
24        super().__init__(
25            auth=USMSAuth(username, password),
26            base_url=self.BASE_URL,
27            http2=True,
28            timeout=timeout,
29        )
30
31        self.event_hooks["response"] = [self._update_asp_state]
32        self._asp_state = {}
33
34    def post(self, url: str, data: dict | None = None) -> httpx.Response:
35        """Send a POST request with ASP.NET hidden fields included."""
36        if data is None:
37            data = {}
38
39        # Merge stored ASP state with request data
40        if self._asp_state and data:
41            for asp_key, asp_value in self._asp_state.items():
42                if not data.get(asp_key):
43                    data[asp_key] = asp_value
44
45        return super().post(url=url, data=data)
46
47    def _update_asp_state(self, response: httpx.Response) -> None:
48        """Extract ASP.NET hidden fields from responses to maintain session state."""
49        try:
50            response_html = lxml.html.fromstring(response.read())
51
52            for hidden_input in response_html.findall(""".//input[@type="hidden"]"""):
53                if hidden_input.value:
54                    self._asp_state[hidden_input.name] = hidden_input.value
55        except Exception as e:  # noqa: BLE001
56            logger.warning(f"Failed to parse ASP.NET state: {e}")

Custom HTTP client for interacting with USMS.

USMSClient(username: str, password: str, timeout: float = 30.0)
22    def __init__(self, username: str, password: str, timeout: float = 30.0) -> None:
23        """Initialize a USMSClient instance."""
24        super().__init__(
25            auth=USMSAuth(username, password),
26            base_url=self.BASE_URL,
27            http2=True,
28            timeout=timeout,
29        )
30
31        self.event_hooks["response"] = [self._update_asp_state]
32        self._asp_state = {}

Initialize a USMSClient instance.

BASE_URL = 'https://www.usms.com.bn/SmartMeter/'
def post(self, url: str, data: dict | None = None) -> httpx.Response:
34    def post(self, url: str, data: dict | None = None) -> httpx.Response:
35        """Send a POST request with ASP.NET hidden fields included."""
36        if data is None:
37            data = {}
38
39        # Merge stored ASP state with request data
40        if self._asp_state and data:
41            for asp_key, asp_value in self._asp_state.items():
42                if not data.get(asp_key):
43                    data[asp_key] = asp_value
44
45        return super().post(url=url, data=data)

Send a POST request with ASP.NET hidden fields included.

@dataclass(frozen=True)
class USMSTariff:
24@dataclass(frozen=True)
25class USMSTariff:
26    """Represents a tariff and its tiers for USMS meter."""
27
28    tiers: list[USMSTariffTier]
29
30    def calculate_cost(self, consumption: float) -> float:
31        """Calculate the cost for given unit consumption, according to the tariff."""
32        cost = 0.0
33
34        for tier in self.tiers:
35            bound_range = tier.upper_bound - tier.lower_bound + 1
36
37            if consumption <= bound_range:
38                cost += consumption * tier.rate
39                break
40
41            consumption -= bound_range
42            cost += bound_range * tier.rate
43
44        return round(cost, 2)
45
46    def calculate_unit(self, cost: float) -> float:
47        """Calculate the unit received for the cost paid, according to the tariff."""
48        unit = 0.0
49
50        for tier in self.tiers:
51            bound_range = tier.upper_bound - tier.lower_bound + 1
52            bound_cost = bound_range * tier.rate
53
54            if cost <= bound_cost:
55                unit += cost / tier.rate
56                break
57
58            cost -= bound_cost
59            unit += bound_range
60
61        return round(unit, 2)

Represents a tariff and its tiers for USMS meter.

USMSTariff(tiers: list[USMSTariffTier])
tiers: list[USMSTariffTier]
def calculate_cost(self, consumption: float) -> float:
30    def calculate_cost(self, consumption: float) -> float:
31        """Calculate the cost for given unit consumption, according to the tariff."""
32        cost = 0.0
33
34        for tier in self.tiers:
35            bound_range = tier.upper_bound - tier.lower_bound + 1
36
37            if consumption <= bound_range:
38                cost += consumption * tier.rate
39                break
40
41            consumption -= bound_range
42            cost += bound_range * tier.rate
43
44        return round(cost, 2)

Calculate the cost for given unit consumption, according to the tariff.

def calculate_unit(self, cost: float) -> float:
46    def calculate_unit(self, cost: float) -> float:
47        """Calculate the unit received for the cost paid, according to the tariff."""
48        unit = 0.0
49
50        for tier in self.tiers:
51            bound_range = tier.upper_bound - tier.lower_bound + 1
52            bound_cost = bound_range * tier.rate
53
54            if cost <= bound_cost:
55                unit += cost / tier.rate
56                break
57
58            cost -= bound_cost
59            unit += bound_range
60
61        return round(unit, 2)

Calculate the unit received for the cost paid, according to the tariff.

@dataclass(frozen=True)
class USMSTariffTier:
15@dataclass(frozen=True)
16class USMSTariffTier:
17    """Represents a tariff tier for USMS meter."""
18
19    lower_bound: int
20    upper_bound: int | float  # can be None for an open-ended range
21    rate: float

Represents a tariff tier for USMS meter.

USMSTariffTier(lower_bound: int, upper_bound: int | float, rate: float)
lower_bound: int
upper_bound: int | float
rate: float