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 AsyncUSMSClient, USMSClient 10from usms.models.tariff import USMSTariff, USMSTariffTier 11from usms.services.async_.account import AsyncUSMSAccount 12from usms.services.async_.meter import AsyncUSMSMeter 13from usms.services.sync.account import USMSAccount 14from usms.services.sync.meter import USMSMeter 15 16__all__ = [ 17 "BRUNEI_TZ", 18 "TARIFFS", 19 "UNITS", 20 "AsyncUSMSAccount", 21 "AsyncUSMSClient", 22 "AsyncUSMSMeter", 23 "USMSAccount", 24 "USMSClient", 25 "USMSMeter", 26 "USMSTariff", 27 "USMSTariffTier", 28]
13class AsyncUSMSAccount(BaseUSMSAccount): 14 """Async USMS Account Service that inherits BaseUSMSAccount.""" 15 16 session: AsyncUSMSClient 17 18 async def initialize(self): 19 """Initialize session object, fetch account info and set class attributes.""" 20 logger.debug(f"[{self.username}] Initializing account {self.username}") 21 self.session = await AsyncUSMSClient.create(self.auth) 22 await self.fetch_info() 23 24 self._initialized = True 25 logger.debug(f"[{self.username}] Initialized account") 26 27 @classmethod 28 async def create(cls, username: str, password: str) -> "AsyncUSMSAccount": 29 """Initialize and return instance of this class as an object.""" 30 self = cls(username, password) 31 await self.initialize() 32 await self.initialize_meters() 33 return self 34 35 @requires_init 36 async def initialize_meters(self): 37 """Initialize all USMSMeters under this account.""" 38 for meter in self.meters: 39 await meter.initialize() 40 41 async def fetch_info(self) -> dict: 42 """Fetch account information, parse data, initialize class attributes and return as json.""" 43 logger.debug(f"[{self.username}] Fetching account details") 44 45 response = await self.session.get("/AccountInfo") 46 47 data = self.parse_info(response) 48 await self.from_json(data) 49 50 logger.debug(f"[{self.username}] Fetched account details") 51 return data 52 53 async def from_json(self, data: dict) -> None: 54 """Initialize base attributes from a json/dict data.""" 55 self.reg_no = data.get("reg_no", "") 56 self.name = data.get("name", "") 57 self.contact_no = data.get("contact_no", "") 58 self.email = data.get("email", "") 59 60 self.meters = [] 61 for meter_node_no in data.get("meters", []): 62 self.meters.append(AsyncUSMSMeter(self, meter_node_no)) 63 64 @requires_init 65 async def log_out(self) -> bool: 66 """Log the user out of the USMS session by clearing session cookies.""" 67 logger.debug(f"[{self.username}] Logging out {self.username}...") 68 69 await self.session.get("/ResLogin") 70 self.session.cookies = {} 71 72 if not await self.is_authenticated(): 73 logger.info(f"[{self.username}] Log out successful") 74 return True 75 76 logger.error(f"[{self.username}] Log out fail") 77 return False 78 79 @requires_init 80 async def log_in(self) -> bool: 81 """Log in the user.""" 82 logger.debug(f"[{self.username}] Logging in {self.username}...") 83 84 await self.session.get("/AccountInfo") 85 86 if await self.is_authenticated(): 87 logger.info(f"[{self.username}] Log in successful") 88 return True 89 90 logger.error(f"[{self.username}] Log in fail") 91 return False 92 93 @requires_init 94 async def is_authenticated(self) -> bool: 95 """ 96 Check if the current session is authenticated. 97 98 Check if the current session is authenticated 99 by sending a request without retrying or triggering auth logic. 100 """ 101 is_authenticated = False 102 try: 103 response = await self.session.get("/AccountInfo", auth=None) 104 is_authenticated = not self.auth.is_expired(response) 105 except httpx.HTTPError as error: 106 logger.error(f"[{self.username}] Login check failed: {error}") 107 108 if is_authenticated: 109 logger.debug(f"[{self.username}] Account is authenticated") 110 else: 111 logger.error(f"[{self.username}] Account is NOT authenticated") 112 return is_authenticated
Async USMS Account Service that inherits BaseUSMSAccount.
18 async def initialize(self): 19 """Initialize session object, fetch account info and set class attributes.""" 20 logger.debug(f"[{self.username}] Initializing account {self.username}") 21 self.session = await AsyncUSMSClient.create(self.auth) 22 await self.fetch_info() 23 24 self._initialized = True 25 logger.debug(f"[{self.username}] Initialized account")
Initialize session object, fetch account info and set class attributes.
27 @classmethod 28 async def create(cls, username: str, password: str) -> "AsyncUSMSAccount": 29 """Initialize and return instance of this class as an object.""" 30 self = cls(username, password) 31 await self.initialize() 32 await self.initialize_meters() 33 return self
Initialize and return instance of this class as an object.
41 async def fetch_info(self) -> dict: 42 """Fetch account information, parse data, initialize class attributes and return as json.""" 43 logger.debug(f"[{self.username}] Fetching account details") 44 45 response = await self.session.get("/AccountInfo") 46 47 data = self.parse_info(response) 48 await self.from_json(data) 49 50 logger.debug(f"[{self.username}] Fetched account details") 51 return data
Fetch account information, parse data, initialize class attributes and return as json.
53 async def from_json(self, data: dict) -> None: 54 """Initialize base attributes from a json/dict data.""" 55 self.reg_no = data.get("reg_no", "") 56 self.name = data.get("name", "") 57 self.contact_no = data.get("contact_no", "") 58 self.email = data.get("email", "") 59 60 self.meters = [] 61 for meter_node_no in data.get("meters", []): 62 self.meters.append(AsyncUSMSMeter(self, meter_node_no))
Initialize base attributes from a json/dict data.
99class AsyncUSMSClient(BaseUSMSClient, httpx.AsyncClient): 100 """Async HTTP client for interacting with USMS.""" 101 102 async def initialize(self) -> None: 103 """Actual initialization logic of Client object.""" 104 self.ssl_context = await create_ssl_context() 105 super().initialize() 106 107 @classmethod 108 async def create(cls, auth: USMSAuth) -> "AsyncUSMSClient": 109 """Initialize and return instance of this class as an object.""" 110 self = cls(auth) 111 await self.initialize() 112 return self 113 114 @requires_init 115 async def post(self, url: str, data: dict | None = None) -> httpx.Response: 116 """Send a POST request with ASP.NET hidden fields included.""" 117 return await super().post(url=url, data=data) 118 119 @requires_init 120 async def _update_asp_state(self, response: httpx.Response) -> None: 121 """Extract ASP.NET hidden fields from responses to maintain session state.""" 122 super()._extract_asp_state(await response.aread())
Async HTTP client for interacting with USMS.
102 async def initialize(self) -> None: 103 """Actual initialization logic of Client object.""" 104 self.ssl_context = await create_ssl_context() 105 super().initialize()
Actual initialization logic of Client object.
20class AsyncUSMSMeter(BaseUSMSMeter): 21 """Async USMS Meter Service that inherits BaseUSMSMeter.""" 22 23 async def initialize(self): 24 """Fetch meter info and then set initial class attributes.""" 25 logger.debug(f"[{self._account.username}] Initializing meter {self.node_no}") 26 await self.fetch_info() 27 super().initialize() 28 logger.debug(f"[{self._account.username}] Initialized meter {self.node_no}") 29 30 @classmethod 31 async def create( 32 cls, account: Union["USMSAccount", "AsyncUSMSAccount"], node_no: str 33 ) -> "AsyncUSMSMeter": 34 """Initialize and return instance of this class as an object.""" 35 self = cls(account, node_no) 36 await self.initialize() 37 return self 38 39 async def fetch_info(self) -> dict: 40 """Fetch meter information, parse data, initialize class attributes and return as json.""" 41 payload = self._build_info_payload() 42 await self.session.get("/AccountInfo") 43 response = await self.session.post("/AccountInfo", data=payload) 44 45 data = self.parse_info(response) 46 self.from_json(data) 47 48 logger.debug(f"[{self.no}] Fetched {self.type} meter {self.no}") 49 return data 50 51 @requires_init 52 async def fetch_hourly_consumptions(self, date: datetime) -> pd.Series: 53 """Fetch hourly consumptions for a given date and return as pd.Series.""" 54 date = sanitize_date(date) 55 56 day_consumption = self.get_hourly_consumptions(date) 57 if not day_consumption.empty: 58 return day_consumption 59 60 logger.debug(f"[{self.no}] Fetching consumptions for: {date.date()}") 61 # build payload and perform requests 62 payload = self._build_hourly_consumptions_payload(date) 63 await self._account.session.get(f"/Report/UsageHistory?p={self.id}") 64 await self._account.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 65 payload = self._build_hourly_consumptions_payload(date) 66 response = await self._account.session.post( 67 f"/Report/UsageHistory?p={self.id}", 68 data=payload, 69 ) 70 71 hourly_consumptions = self.parse_hourly_consumptions(response) 72 73 # convert dict to pd.DataFrame 74 hourly_consumptions = pd.DataFrame.from_dict( 75 hourly_consumptions, 76 dtype=float, 77 orient="index", 78 columns=[self.get_unit()], 79 ) 80 hourly_consumptions.index = pd.to_datetime( 81 [date + timedelta(hours=hour) for hour in hourly_consumptions.index] 82 ) 83 hourly_consumptions = hourly_consumptions.asfreq("h") 84 hourly_consumptions["last_checked"] = datetime.now(tz=BRUNEI_TZ) 85 86 if hourly_consumptions.empty: 87 logger.warning(f"[{self.no}] No consumptions data for : {date.date()}") 88 return hourly_consumptions[self.get_unit()] 89 90 self.hourly_consumptions = hourly_consumptions.combine_first(self.hourly_consumptions) 91 92 logger.debug(f"[{self.no}] Fetched consumptions for: {date.date()}") 93 return hourly_consumptions[self.get_unit()] 94 95 @requires_init 96 async def fetch_daily_consumptions(self, date: datetime) -> pd.Series: 97 """Fetch daily consumptions for a given date and return as pd.Series.""" 98 date = sanitize_date(date) 99 100 month_consumption = self.get_daily_consumptions(date) 101 if not month_consumption.empty: 102 return month_consumption 103 104 logger.debug(f"[{self.no}] Fetching consumptions for: {date.year}-{date.month}") 105 # build payload and perform requests 106 payload = self._build_daily_consumptions_payload(date) 107 108 await self._account.session.get(f"/Report/UsageHistory?p={self.id}") 109 await self._account.session.post(f"/Report/UsageHistory?p={self.id}") 110 await self._account.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 111 response = await self._account.session.post( 112 f"/Report/UsageHistory?p={self.id}", data=payload 113 ) 114 115 daily_consumptions = self.parse_daily_consumptions(response) 116 117 # convert dict to pd.DataFrame 118 daily_consumptions = pd.DataFrame.from_dict( 119 daily_consumptions, 120 dtype=float, 121 orient="index", 122 columns=[self.get_unit()], 123 ) 124 daily_consumptions.index = pd.to_datetime(daily_consumptions.index, format="%d/%m/%Y") 125 daily_consumptions = daily_consumptions.asfreq("D") 126 daily_consumptions["last_checked"] = datetime.now(tz=BRUNEI_TZ) 127 128 if daily_consumptions.empty: 129 logger.warning(f"[{self.no}] No consumptions data for : {date.year}-{date.month}") 130 return daily_consumptions[self.get_unit()] 131 132 self.daily_consumptions = daily_consumptions.combine_first(self.daily_consumptions) 133 134 logger.debug(f"[{self.no}] Fetched consumptions for: {date.year}-{date.month}") 135 return daily_consumptions[self.get_unit()] 136 137 @requires_init 138 async def get_previous_n_month_consumptions(self, n: int = 0) -> pd.Series: 139 """ 140 Return the consumptions for previous n month. 141 142 e.g. 143 n=0 : data for this month only 144 n=1 : data for previous month only 145 n=2 : data for previous 2 months only 146 """ 147 date = datetime.now(tz=BRUNEI_TZ) 148 for _ in range(n): 149 date = date.replace(day=1) 150 date = date - timedelta(days=1) 151 return await self.fetch_daily_consumptions(date) 152 153 @requires_init 154 async def get_last_n_days_hourly_consumptions(self, n: int = 0) -> pd.Series: 155 """ 156 Return the hourly unit consumptions for the last n days accumulatively. 157 158 e.g. 159 n=0 : data for today 160 n=1 : data from yesterday until today 161 n=2 : data from 2 days ago until today 162 """ 163 last_n_days_hourly_consumptions = new_consumptions_dataframe( 164 self.get_unit(), 165 "h", 166 )[self.get_unit()] 167 168 upper_date = datetime.now(tz=BRUNEI_TZ) 169 lower_date = upper_date - timedelta(days=n) 170 for i in range(n + 1): 171 date = lower_date + timedelta(days=i) 172 hourly_consumptions = await self.fetch_hourly_consumptions(date) 173 174 if not hourly_consumptions.empty: 175 last_n_days_hourly_consumptions = hourly_consumptions.combine_first( 176 last_n_days_hourly_consumptions 177 ) 178 179 if n > 3: # noqa: PLR2004 180 progress = round((i + 1) / (n + 1) * 100, 1) 181 logger.info( 182 f"[{self.no}] Getting last {n} days hourly consumptions progress: {(i + 1)} out of {(n + 1)}, {progress}%" 183 ) 184 185 return last_n_days_hourly_consumptions 186 187 @requires_init 188 async def refresh_data(self) -> bool: 189 """Fetch new data and update the meter info.""" 190 logger.info(f"[{self.no}] Checking for updates") 191 192 try: 193 # Initialize a temporary meter to fetch fresh details in one call 194 temp_meter = AsyncUSMSMeter(self._account, self.node_no) 195 temp_info = await temp_meter.fetch_info() 196 except Exception as error: # noqa: BLE001 197 logger.error(f"[{self.no}] Failed to fetch update with error: {error}") 198 return False 199 200 self.last_refresh = datetime.now(tz=BRUNEI_TZ) 201 202 if temp_info.get("last_update") > self.last_update: 203 logger.info(f"[{self.no}] New updates found") 204 self.from_json(temp_info) 205 return True 206 207 logger.info(f"[{self.no}] No new updates found") 208 return False 209 210 @requires_init 211 async def check_update_and_refresh(self) -> bool: 212 """Refresh data if an update is due, then return True if update successful.""" 213 try: 214 if self.is_update_due(): 215 return await self.refresh_data() 216 except Exception as error: # noqa: BLE001 217 logger.error(f"[{self.no}] Failed to fetch update with error: {error}") 218 return False 219 220 # Update not dued, data not refreshed 221 return False 222 223 @requires_init 224 async def get_all_hourly_consumptions(self) -> pd.Series: 225 """Get the hourly unit consumptions for all days and months.""" 226 logger.debug(f"[{self.no}] Getting all hourly consumptions") 227 228 upper_date = datetime.now(tz=BRUNEI_TZ) 229 lower_date = await self.find_earliest_consumption_date() 230 range_date = (upper_date - lower_date).days + 1 231 for i in range(range_date): 232 date = lower_date + timedelta(days=i) 233 await self.fetch_hourly_consumptions(date) 234 progress = round((i + 1) / range_date * 100, 1) 235 logger.info( 236 f"[{self.no}] Getting all hourly consumptions progress: {(i + 1)} out of {range_date}, {progress}%" 237 ) 238 239 return self.hourly_consumptions[self.get_unit()] 240 241 @requires_init 242 async def find_earliest_consumption_date(self) -> datetime: 243 """Determine the earliest date for which hourly consumption data is available.""" 244 if self.earliest_consumption_date is not None: 245 return self.earliest_consumption_date 246 247 if self.hourly_consumptions.empty: 248 now = datetime.now(tz=BRUNEI_TZ) 249 date = datetime( 250 now.year, 251 now.month, 252 now.day, 253 0, 254 0, 255 0, 256 tzinfo=BRUNEI_TZ, 257 ) 258 else: 259 date = self.hourly_consumptions.index.min() 260 logger.info(f"[{self.no}] Finding earliest consumption date, starting from: {date.date()}") 261 262 # Exponential backoff to find a missing date 263 step = 1 264 while True: 265 hourly_consumptions = await self.fetch_hourly_consumptions(date) 266 267 if not hourly_consumptions.empty: 268 step *= 2 # Exponentially increase step 269 date -= timedelta(days=step) 270 logger.info(f"[{self.no}] Stepping {step} days from {date}") 271 elif step == 1: 272 # Already at base step, this is the earliest available data 273 date += timedelta(days=step) 274 self.earliest_consumption_date = date 275 logger.info(f"[{self.no}] Found earliest consumption date: {date}") 276 return date 277 else: 278 # Went too far — reverse the last large step and reset step to 1 279 date += timedelta(days=step) 280 logger.debug(f"[{self.no}] Stepped too far, going back to: {date}") 281 step /= 4 # Half the last step
Async USMS Meter Service that inherits BaseUSMSMeter.
23 async def initialize(self): 24 """Fetch meter info and then set initial class attributes.""" 25 logger.debug(f"[{self._account.username}] Initializing meter {self.node_no}") 26 await self.fetch_info() 27 super().initialize() 28 logger.debug(f"[{self._account.username}] Initialized meter {self.node_no}")
Fetch meter info and then set initial class attributes.
30 @classmethod 31 async def create( 32 cls, account: Union["USMSAccount", "AsyncUSMSAccount"], node_no: str 33 ) -> "AsyncUSMSMeter": 34 """Initialize and return instance of this class as an object.""" 35 self = cls(account, node_no) 36 await self.initialize() 37 return self
Initialize and return instance of this class as an object.
39 async def fetch_info(self) -> dict: 40 """Fetch meter information, parse data, initialize class attributes and return as json.""" 41 payload = self._build_info_payload() 42 await self.session.get("/AccountInfo") 43 response = await self.session.post("/AccountInfo", data=payload) 44 45 data = self.parse_info(response) 46 self.from_json(data) 47 48 logger.debug(f"[{self.no}] Fetched {self.type} meter {self.no}") 49 return data
Fetch meter information, parse data, initialize class attributes and return as json.
13class USMSAccount(BaseUSMSAccount): 14 """Sync USMS Account Service that inherits BaseUSMSAccount.""" 15 16 session: USMSClient 17 18 def initialize(self): 19 """Initialize session object, fetch account info and set class attributes.""" 20 logger.debug(f"[{self.username}] Initializing account {self.username}") 21 self.session = USMSClient.create(self.auth) 22 self.fetch_info() 23 24 self._initialized = True 25 logger.debug(f"[{self.username}] Initialized account") 26 27 @classmethod 28 def create(cls, username: str, password: str) -> "USMSAccount": 29 """Initialize and return instance of this class as an object.""" 30 self = cls(username, password) 31 self.initialize() 32 self.initialize_meters() 33 return self 34 35 @requires_init 36 def initialize_meters(self): 37 """Initialize all USMSMeters under this account.""" 38 for meter in self.meters: 39 meter.initialize() 40 41 def fetch_info(self) -> dict: 42 """Fetch account information, parse data, initialize class attributes and return as json.""" 43 logger.debug(f"[{self.username}] Fetching account details") 44 45 response = self.session.get("/AccountInfo") 46 47 data = self.parse_info(response) 48 self.from_json(data) 49 50 logger.debug(f"[{self.username}] Fetched account details") 51 return data 52 53 def from_json(self, data: dict) -> None: 54 """Initialize base attributes from a json/dict data.""" 55 self.reg_no = data.get("reg_no", "") 56 self.name = data.get("name", "") 57 self.contact_no = data.get("contact_no", "") 58 self.email = data.get("email", "") 59 60 self.meters = [] 61 for meter_node_no in data.get("meters", []): 62 self.meters.append(USMSMeter(self, meter_node_no)) 63 64 @requires_init 65 def log_out(self) -> bool: 66 """Log the user out of the USMS session by clearing session cookies.""" 67 logger.debug(f"[{self.username}] Logging out {self.username}...") 68 self.session.get("/ResLogin") 69 self.session.cookies = {} 70 71 if not self.is_authenticated(): 72 logger.info(f"[{self.username}] Log out successful") 73 return True 74 75 logger.error(f"[{self.username}] Log out fail") 76 return False 77 78 @requires_init 79 def log_in(self) -> bool: 80 """Log in the user.""" 81 logger.debug(f"[{self.username}] Logging in {self.username}...") 82 83 self.session.get("/AccountInfo") 84 85 if self.is_authenticated(): 86 logger.info(f"[{self.username}] Log in successful") 87 return True 88 89 logger.error(f"[{self.username}] Log in fail") 90 return False 91 92 @requires_init 93 def is_authenticated(self) -> bool: 94 """ 95 Check if the current session is authenticated. 96 97 Check if the current session is authenticated 98 by sending a request without retrying or triggering auth logic. 99 """ 100 is_authenticated = False 101 try: 102 response = self.session.get("/AccountInfo", auth=None) 103 is_authenticated = not self.auth.is_expired(response) 104 except httpx.HTTPError as error: 105 logger.error(f"[{self.username}] Login check failed: {error}") 106 107 if is_authenticated: 108 logger.debug(f"[{self.username}] Account is authenticated") 109 else: 110 logger.error(f"[{self.username}] Account is NOT authenticated") 111 return is_authenticated
Sync USMS Account Service that inherits BaseUSMSAccount.
18 def initialize(self): 19 """Initialize session object, fetch account info and set class attributes.""" 20 logger.debug(f"[{self.username}] Initializing account {self.username}") 21 self.session = USMSClient.create(self.auth) 22 self.fetch_info() 23 24 self._initialized = True 25 logger.debug(f"[{self.username}] Initialized account")
Initialize session object, fetch account info and set class attributes.
27 @classmethod 28 def create(cls, username: str, password: str) -> "USMSAccount": 29 """Initialize and return instance of this class as an object.""" 30 self = cls(username, password) 31 self.initialize() 32 self.initialize_meters() 33 return self
Initialize and return instance of this class as an object.
10 def wrapper(self, *args, **kwargs): 11 if not getattr(self, "_initialized", False): 12 raise USMSNotInitializedError(self.__class__.__name__) 13 return method(self, *args, **kwargs)
Initialize all USMSMeters under this account.
41 def fetch_info(self) -> dict: 42 """Fetch account information, parse data, initialize class attributes and return as json.""" 43 logger.debug(f"[{self.username}] Fetching account details") 44 45 response = self.session.get("/AccountInfo") 46 47 data = self.parse_info(response) 48 self.from_json(data) 49 50 logger.debug(f"[{self.username}] Fetched account details") 51 return data
Fetch account information, parse data, initialize class attributes and return as json.
53 def from_json(self, data: dict) -> None: 54 """Initialize base attributes from a json/dict data.""" 55 self.reg_no = data.get("reg_no", "") 56 self.name = data.get("name", "") 57 self.contact_no = data.get("contact_no", "") 58 self.email = data.get("email", "") 59 60 self.meters = [] 61 for meter_node_no in data.get("meters", []): 62 self.meters.append(USMSMeter(self, meter_node_no))
Initialize base attributes from a json/dict data.
10 def wrapper(self, *args, **kwargs): 11 if not getattr(self, "_initialized", False): 12 raise USMSNotInitializedError(self.__class__.__name__) 13 return method(self, *args, **kwargs)
Log the user out of the USMS session by clearing session cookies.
10 def wrapper(self, *args, **kwargs): 11 if not getattr(self, "_initialized", False): 12 raise USMSNotInitializedError(self.__class__.__name__) 13 return method(self, *args, **kwargs)
Log in the user.
10 def wrapper(self, *args, **kwargs): 11 if not getattr(self, "_initialized", False): 12 raise USMSNotInitializedError(self.__class__.__name__) 13 return method(self, *args, **kwargs)
Check if the current session is authenticated.
Check if the current session is authenticated by sending a request without retrying or triggering auth logic.
83class USMSClient(BaseUSMSClient, httpx.Client): 84 """Sync HTTP client for interacting with USMS.""" 85 86 @classmethod 87 def create(cls, auth: USMSAuth) -> "USMSClient": 88 """Initialize and return instance of this class as an object.""" 89 self = cls(auth) 90 self.initialize() 91 return self 92 93 @requires_init 94 def _update_asp_state(self, response: httpx.Response) -> None: 95 """Extract ASP.NET hidden fields from responses to maintain session state.""" 96 super()._extract_asp_state(response.read())
Sync HTTP client for interacting with USMS.
20class USMSMeter(BaseUSMSMeter): 21 """Sync USMS Meter Service that inherits BaseUSMSMeter.""" 22 23 def initialize(self): 24 """Fetch meter info and then set initial class attributes.""" 25 logger.debug(f"[{self._account.username}] Initializing meter {self.node_no}") 26 self.fetch_info() 27 super().initialize() 28 logger.debug(f"[{self._account.username}] Initialized meter {self.node_no}") 29 30 @classmethod 31 def create(cls, account: Union["USMSAccount", "AsyncUSMSAccount"], node_no: str) -> "USMSMeter": 32 """Initialize and return instance of this class as an object.""" 33 self = cls(account, node_no) 34 self.initialize() 35 return self 36 37 def fetch_info(self) -> dict: 38 """Fetch meter information, parse data, initialize class attributes and return as json.""" 39 payload = self._build_info_payload() 40 self.session.get("/AccountInfo") 41 response = self.session.post("/AccountInfo", data=payload) 42 43 data = self.parse_info(response) 44 self.from_json(data) 45 46 logger.debug(f"[{self.no}] Fetched {self.type} meter {self.no}") 47 return data 48 49 @requires_init 50 def fetch_hourly_consumptions(self, date: datetime) -> pd.Series: 51 """Fetch hourly consumptions for a given date and return as pd.Series.""" 52 date = sanitize_date(date) 53 54 day_consumption = self.get_hourly_consumptions(date) 55 if not day_consumption.empty: 56 return day_consumption 57 58 logger.debug(f"[{self.no}] Fetching consumptions for: {date.date()}") 59 # build payload and perform requests 60 payload = self._build_hourly_consumptions_payload(date) 61 self._account.session.get(f"/Report/UsageHistory?p={self.id}") 62 self._account.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 63 payload = self._build_hourly_consumptions_payload(date) 64 response = self._account.session.post( 65 f"/Report/UsageHistory?p={self.id}", 66 data=payload, 67 ) 68 69 hourly_consumptions = self.parse_hourly_consumptions(response) 70 71 # convert dict to pd.DataFrame 72 hourly_consumptions = pd.DataFrame.from_dict( 73 hourly_consumptions, 74 dtype=float, 75 orient="index", 76 columns=[self.get_unit()], 77 ) 78 hourly_consumptions.index = pd.to_datetime( 79 [date + timedelta(hours=hour) for hour in hourly_consumptions.index] 80 ) 81 hourly_consumptions = hourly_consumptions.asfreq("h") 82 hourly_consumptions["last_checked"] = datetime.now(tz=BRUNEI_TZ) 83 84 if hourly_consumptions.empty: 85 logger.warning(f"[{self.no}] No consumptions data for : {date.date()}") 86 return hourly_consumptions[self.get_unit()] 87 88 self.hourly_consumptions = hourly_consumptions.combine_first(self.hourly_consumptions) 89 90 logger.debug(f"[{self.no}] Fetched consumptions for: {date.date()}") 91 return hourly_consumptions[self.get_unit()] 92 93 @requires_init 94 def fetch_daily_consumptions(self, date: datetime) -> pd.Series: 95 """Fetch daily consumptions for a given date and return as pd.Series.""" 96 date = sanitize_date(date) 97 98 month_consumption = self.get_daily_consumptions(date) 99 if not month_consumption.empty: 100 return month_consumption 101 102 logger.debug(f"[{self.no}] Fetching consumptions for: {date.year}-{date.month}") 103 # build payload and perform requests 104 payload = self._build_daily_consumptions_payload(date) 105 106 self._account.session.get(f"/Report/UsageHistory?p={self.id}") 107 self._account.session.post(f"/Report/UsageHistory?p={self.id}") 108 self._account.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 109 response = self._account.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 110 111 daily_consumptions = self.parse_daily_consumptions(response) 112 113 # convert dict to pd.DataFrame 114 daily_consumptions = pd.DataFrame.from_dict( 115 daily_consumptions, 116 dtype=float, 117 orient="index", 118 columns=[self.get_unit()], 119 ) 120 daily_consumptions.index = pd.to_datetime(daily_consumptions.index, format="%d/%m/%Y") 121 daily_consumptions = daily_consumptions.asfreq("D") 122 daily_consumptions["last_checked"] = datetime.now(tz=BRUNEI_TZ) 123 124 if daily_consumptions.empty: 125 logger.warning(f"[{self.no}] No consumptions data for : {date.year}-{date.month}") 126 return daily_consumptions[self.get_unit()] 127 128 self.daily_consumptions = daily_consumptions.combine_first(self.daily_consumptions) 129 130 logger.debug(f"[{self.no}] Fetched consumptions for: {date.year}-{date.month}") 131 return daily_consumptions[self.get_unit()] 132 133 @requires_init 134 def get_previous_n_month_consumptions(self, n: int = 0) -> pd.Series: 135 """ 136 Return the consumptions for previous n month. 137 138 e.g. 139 n=0 : data for this month only 140 n=1 : data for previous month only 141 n=2 : data for previous 2 months only 142 """ 143 date = datetime.now(tz=BRUNEI_TZ) 144 for _ in range(n): 145 date = date.replace(day=1) 146 date = date - timedelta(days=1) 147 return self.fetch_daily_consumptions(date) 148 149 @requires_init 150 def get_last_n_days_hourly_consumptions(self, n: int = 0) -> pd.Series: 151 """ 152 Return the hourly unit consumptions for the last n days accumulatively. 153 154 e.g. 155 n=0 : data for today 156 n=1 : data from yesterday until today 157 n=2 : data from 2 days ago until today 158 """ 159 last_n_days_hourly_consumptions = new_consumptions_dataframe( 160 self.get_unit(), 161 "h", 162 )[self.get_unit()] 163 164 upper_date = datetime.now(tz=BRUNEI_TZ) 165 lower_date = upper_date - timedelta(days=n) 166 range_date = (upper_date - lower_date).days + 1 167 for i in range(range_date): 168 date = lower_date + timedelta(days=i) 169 hourly_consumptions = self.fetch_hourly_consumptions(date) 170 171 if not hourly_consumptions.empty: 172 last_n_days_hourly_consumptions = hourly_consumptions.combine_first( 173 last_n_days_hourly_consumptions 174 ) 175 176 if n > 3: # noqa: PLR2004 177 progress = round((i + 1) / (n + 1) * 100, 1) 178 logger.info( 179 f"[{self.no}] Getting last {n} days hourly consumptions progress: {(i + 1)} out of {(n + 1)}, {progress}%" 180 ) 181 182 return last_n_days_hourly_consumptions 183 184 @requires_init 185 def refresh_data(self) -> bool: 186 """Fetch new data and update the meter info.""" 187 logger.info(f"[{self.no}] Checking for updates") 188 189 try: 190 # Initialize a temporary meter to fetch fresh details in one call 191 temp_meter = USMSMeter(self._account, self.node_no) 192 temp_info = temp_meter.fetch_info() 193 except Exception as error: # noqa: BLE001 194 logger.error(f"[{self.no}] Failed to fetch update with error: {error}") 195 return False 196 197 self.last_refresh = datetime.now(tz=BRUNEI_TZ) 198 199 if temp_info.get("last_update") > self.last_update: 200 logger.info(f"[{self.no}] New updates found") 201 self.from_json(temp_info) 202 return True 203 204 logger.info(f"[{self.no}] No new updates found") 205 return False 206 207 @requires_init 208 def check_update_and_refresh(self) -> bool: 209 """Refresh data if an update is due, then return True if update successful.""" 210 try: 211 if self.is_update_due(): 212 return self.refresh_data() 213 except Exception as error: # noqa: BLE001 214 logger.error(f"[{self.no}] Failed to fetch update with error: {error}") 215 return False 216 217 # Update not dued, data not refreshed 218 return False 219 220 @requires_init 221 def get_all_hourly_consumptions(self) -> pd.Series: 222 """Get the hourly unit consumptions for all days and months.""" 223 logger.debug(f"[{self.no}] Getting all hourly consumptions") 224 225 upper_date = datetime.now(tz=BRUNEI_TZ) 226 lower_date = self.find_earliest_consumption_date() 227 range_date = (upper_date - lower_date).days + 1 228 for i in range(range_date): 229 date = lower_date + timedelta(days=i) 230 self.fetch_hourly_consumptions(date) 231 progress = round((i + 1) / range_date * 100, 1) 232 logger.info( 233 f"[{self.no}] Getting all hourly consumptions progress: {(i + 1)} out of {range_date}, {progress}%" 234 ) 235 236 return self.hourly_consumptions[self.get_unit()] 237 238 @requires_init 239 def find_earliest_consumption_date(self) -> datetime: 240 """Determine the earliest date for which hourly consumption data is available.""" 241 if self.earliest_consumption_date is not None: 242 return self.earliest_consumption_date 243 244 if self.hourly_consumptions.empty: 245 now = datetime.now(tz=BRUNEI_TZ) 246 date = datetime( 247 now.year, 248 now.month, 249 now.day, 250 0, 251 0, 252 0, 253 tzinfo=BRUNEI_TZ, 254 ) 255 else: 256 date = self.hourly_consumptions.index.min() 257 logger.info(f"[{self.no}] Finding earliest consumption date, starting from: {date.date()}") 258 259 # Exponential backoff to find a missing date 260 step = 1 261 while True: 262 hourly_consumptions = self.fetch_hourly_consumptions(date) 263 264 if not hourly_consumptions.empty: 265 step *= 2 # Exponentially increase step 266 date -= timedelta(days=step) 267 logger.info(f"[{self.no}] Stepping {step} days from {date}") 268 elif step == 1: 269 # Already at base step, this is the earliest available data 270 date += timedelta(days=step) 271 self.earliest_consumption_date = date 272 logger.info(f"[{self.no}] Found earliest consumption date: {date}") 273 return date 274 else: 275 # Went too far — reverse the last large step and reset step to 1 276 date += timedelta(days=step) 277 logger.debug(f"[{self.no}] Stepped too far, going back to: {date}") 278 step /= 4 # Half the last step
Sync USMS Meter Service that inherits BaseUSMSMeter.
23 def initialize(self): 24 """Fetch meter info and then set initial class attributes.""" 25 logger.debug(f"[{self._account.username}] Initializing meter {self.node_no}") 26 self.fetch_info() 27 super().initialize() 28 logger.debug(f"[{self._account.username}] Initialized meter {self.node_no}")
Fetch meter info and then set initial class attributes.
30 @classmethod 31 def create(cls, account: Union["USMSAccount", "AsyncUSMSAccount"], node_no: str) -> "USMSMeter": 32 """Initialize and return instance of this class as an object.""" 33 self = cls(account, node_no) 34 self.initialize() 35 return self
Initialize and return instance of this class as an object.
37 def fetch_info(self) -> dict: 38 """Fetch meter information, parse data, initialize class attributes and return as json.""" 39 payload = self._build_info_payload() 40 self.session.get("/AccountInfo") 41 response = self.session.post("/AccountInfo", data=payload) 42 43 data = self.parse_info(response) 44 self.from_json(data) 45 46 logger.debug(f"[{self.no}] Fetched {self.type} meter {self.no}") 47 return data
Fetch meter information, parse data, initialize class attributes and return as json.
10 def wrapper(self, *args, **kwargs): 11 if not getattr(self, "_initialized", False): 12 raise USMSNotInitializedError(self.__class__.__name__) 13 return method(self, *args, **kwargs)
Fetch hourly consumptions for a given date and return as pd.Series.
10 def wrapper(self, *args, **kwargs): 11 if not getattr(self, "_initialized", False): 12 raise USMSNotInitializedError(self.__class__.__name__) 13 return method(self, *args, **kwargs)
Fetch daily consumptions for a given date and return as pd.Series.
10 def wrapper(self, *args, **kwargs): 11 if not getattr(self, "_initialized", False): 12 raise USMSNotInitializedError(self.__class__.__name__) 13 return method(self, *args, **kwargs)
Return the consumptions for previous n month.
e.g. n=0 : data for this month only n=1 : data for previous month only n=2 : data for previous 2 months only
10 def wrapper(self, *args, **kwargs): 11 if not getattr(self, "_initialized", False): 12 raise USMSNotInitializedError(self.__class__.__name__) 13 return method(self, *args, **kwargs)
Return the hourly unit consumptions for the last n days accumulatively.
e.g. n=0 : data for today n=1 : data from yesterday until today n=2 : data from 2 days ago until today
10 def wrapper(self, *args, **kwargs): 11 if not getattr(self, "_initialized", False): 12 raise USMSNotInitializedError(self.__class__.__name__) 13 return method(self, *args, **kwargs)
Fetch new data and update the meter info.
10 def wrapper(self, *args, **kwargs): 11 if not getattr(self, "_initialized", False): 12 raise USMSNotInitializedError(self.__class__.__name__) 13 return method(self, *args, **kwargs)
Refresh data if an update is due, then return True if update successful.
10 def wrapper(self, *args, **kwargs): 11 if not getattr(self, "_initialized", False): 12 raise USMSNotInitializedError(self.__class__.__name__) 13 return method(self, *args, **kwargs)
Get the hourly unit consumptions for all days and months.
10 def wrapper(self, *args, **kwargs): 11 if not getattr(self, "_initialized", False): 12 raise USMSNotInitializedError(self.__class__.__name__) 13 return method(self, *args, **kwargs)
Determine the earliest date for which hourly consumption data is available.
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.