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]
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.