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]
16class AsyncUSMSAccount(BaseUSMSAccount): 17 """Async USMS Account Service that inherits BaseUSMSAccount.""" 18 19 session: AsyncUSMSClient 20 21 async def initialize(self): 22 """Initialize session object, fetch account info and set class attributes.""" 23 logger.debug(f"[{self.username}] Initializing account {self.username}") 24 25 self.session = await AsyncUSMSClient.create(self.auth) 26 27 data = await self.fetch_info() 28 await self.from_json(data) 29 30 self._initialized = True 31 logger.debug(f"[{self.username}] Initialized account") 32 33 @classmethod 34 async def create(cls, username: str, password: str) -> "AsyncUSMSAccount": 35 """Initialize and return instance of this class as an object.""" 36 self = cls(username, password) 37 await self.initialize() 38 return self 39 40 async def fetch_more_info(self) -> dict: 41 """Fetch account information, parse data, initialize class attributes and return as json.""" 42 logger.debug(f"[{self.username}] Fetching more account details") 43 44 response = await self.session.get("/AccountInfo") 45 data = self.parse_more_info(response) 46 47 logger.debug(f"[{self.username}] Fetched more account details") 48 return data 49 50 async def fetch_info(self) -> dict: 51 """ 52 Fetch minimal account and meters information. 53 54 Fetch minimal account and meters information, parse data, 55 initialize class attributes and return as json. 56 """ 57 logger.debug(f"[{self.username}] Fetching account details") 58 59 response = await self.session.get("/Home") 60 data = self.parse_info(response) 61 62 logger.debug(f"[{self.username}] Fetched account details") 63 return data 64 65 async def from_json(self, data: dict) -> None: 66 """Initialize base attributes from a json/dict data.""" 67 super().from_json(data) 68 69 if not hasattr(self, "meters") or self.get_meters() == []: 70 self.meters = [] 71 for meter_data in data.get("meters", []): 72 meter = await AsyncUSMSMeter.create(self, meter_data) 73 self.meters.append(meter) 74 75 @requires_init 76 async def log_out(self) -> bool: 77 """Log the user out of the USMS session by clearing session cookies.""" 78 logger.debug(f"[{self.username}] Logging out {self.username}...") 79 80 await self.session.get("/ResLogin") 81 self.session.cookies = {} 82 83 if not await self.is_authenticated(): 84 logger.debug(f"[{self.username}] Log out successful") 85 return True 86 87 logger.error(f"[{self.username}] Log out fail") 88 return False 89 90 @requires_init 91 async def log_in(self) -> bool: 92 """Log in the user.""" 93 logger.debug(f"[{self.username}] Logging in {self.username}...") 94 95 await self.session.get("/AccountInfo") 96 97 if await self.is_authenticated(): 98 logger.debug(f"[{self.username}] Log in successful") 99 return True 100 101 logger.error(f"[{self.username}] Log in fail") 102 return False 103 104 @requires_init 105 async def is_authenticated(self) -> bool: 106 """ 107 Check if the current session is authenticated. 108 109 Check if the current session is authenticated 110 by sending a request without retrying or triggering auth logic. 111 """ 112 is_authenticated = False 113 try: 114 response = await self.session.get("/AccountInfo", auth=None) 115 is_authenticated = not self.auth.is_expired(response) 116 except httpx.HTTPError as error: 117 logger.error(f"[{self.username}] Login check failed: {error}") 118 119 if is_authenticated: 120 logger.debug(f"[{self.username}] Account is authenticated") 121 else: 122 logger.debug(f"[{self.username}] Account is NOT authenticated") 123 return is_authenticated 124 125 @requires_init 126 async def refresh_data(self) -> bool: 127 """Fetch new data and update the meter info.""" 128 logger.debug(f"[{self.username}] Checking for updates") 129 130 try: 131 fresh_info = await self.fetch_info() 132 except Exception as error: # noqa: BLE001 133 logger.error(f"[{self.username}] Failed to fetch update with error: {error}") 134 return False 135 136 self.last_refresh = datetime.now(tz=BRUNEI_TZ) 137 138 for meter in fresh_info.get("meters", []): 139 if meter.get("last_update") > self.get_latest_update(): 140 logger.debug(f"[{self.username}] New updates found") 141 await self.from_json(fresh_info) 142 return True 143 144 logger.debug(f"[{self.username}] No new updates found") 145 return False 146 147 @requires_init 148 async def check_update_and_refresh(self) -> bool: 149 """Refresh data if an update is due, then return True if update successful.""" 150 try: 151 if self.is_update_due(): 152 return await self.refresh_data() 153 except Exception as error: # noqa: BLE001 154 logger.error(f"[{self.username}] Failed to fetch update with error: {error}") 155 return False 156 157 # Update not dued, data not refreshed 158 return False
Async USMS Account Service that inherits BaseUSMSAccount.
21 async def initialize(self): 22 """Initialize session object, fetch account info and set class attributes.""" 23 logger.debug(f"[{self.username}] Initializing account {self.username}") 24 25 self.session = await AsyncUSMSClient.create(self.auth) 26 27 data = await self.fetch_info() 28 await self.from_json(data) 29 30 self._initialized = True 31 logger.debug(f"[{self.username}] Initialized account")
Initialize session object, fetch account info and set class attributes.
33 @classmethod 34 async def create(cls, username: str, password: str) -> "AsyncUSMSAccount": 35 """Initialize and return instance of this class as an object.""" 36 self = cls(username, password) 37 await self.initialize() 38 return self
Initialize and return instance of this class as an object.
40 async def fetch_more_info(self) -> dict: 41 """Fetch account information, parse data, initialize class attributes and return as json.""" 42 logger.debug(f"[{self.username}] Fetching more account details") 43 44 response = await self.session.get("/AccountInfo") 45 data = self.parse_more_info(response) 46 47 logger.debug(f"[{self.username}] Fetched more account details") 48 return data
Fetch account information, parse data, initialize class attributes and return as json.
50 async def fetch_info(self) -> dict: 51 """ 52 Fetch minimal account and meters information. 53 54 Fetch minimal account and meters information, parse data, 55 initialize class attributes and return as json. 56 """ 57 logger.debug(f"[{self.username}] Fetching account details") 58 59 response = await self.session.get("/Home") 60 data = self.parse_info(response) 61 62 logger.debug(f"[{self.username}] Fetched account details") 63 return data
Fetch minimal account and meters information.
Fetch minimal account and meters information, parse data, initialize class attributes and return as json.
65 async def from_json(self, data: dict) -> None: 66 """Initialize base attributes from a json/dict data.""" 67 super().from_json(data) 68 69 if not hasattr(self, "meters") or self.get_meters() == []: 70 self.meters = [] 71 for meter_data in data.get("meters", []): 72 meter = await AsyncUSMSMeter.create(self, meter_data) 73 self.meters.append(meter)
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.
19class AsyncUSMSMeter(BaseUSMSMeter): 20 """Async USMS Meter Service that inherits BaseUSMSMeter.""" 21 22 async def initialize(self, data: dict): 23 """Fetch meter info and then set initial class attributes.""" 24 logger.debug(f"[{self._account.username}] Initializing meter") 25 self.from_json(data) 26 super().initialize() 27 logger.debug(f"[{self._account.username}] Initialized meter") 28 29 @classmethod 30 async def create(cls, account: "AsyncUSMSAccount", data: dict) -> "AsyncUSMSMeter": 31 """Initialize and return instance of this class as an object.""" 32 self = cls(account) 33 await self.initialize(data) 34 return self 35 36 @requires_init 37 async def fetch_hourly_consumptions(self, date: datetime) -> pd.Series: 38 """Fetch hourly consumptions for a given date and return as pd.Series.""" 39 date = sanitize_date(date) 40 41 day_consumption = self.get_hourly_consumptions(date) 42 if not day_consumption.empty: 43 return day_consumption 44 45 logger.debug(f"[{self.no}] Fetching consumptions for: {date.date()}") 46 # build payload and perform requests 47 payload = self._build_hourly_consumptions_payload(date) 48 await self.session.get(f"/Report/UsageHistory?p={self.id}") 49 await self.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 50 payload = self._build_hourly_consumptions_payload(date) 51 response = await self.session.post( 52 f"/Report/UsageHistory?p={self.id}", 53 data=payload, 54 ) 55 56 hourly_consumptions = self.parse_hourly_consumptions(response) 57 58 # convert dict to pd.DataFrame 59 hourly_consumptions = pd.DataFrame.from_dict( 60 hourly_consumptions, 61 dtype=float, 62 orient="index", 63 columns=[self.get_unit()], 64 ) 65 66 if hourly_consumptions.empty: 67 logger.warning(f"[{self.no}] No consumptions data for : {date.date()}") 68 return hourly_consumptions[self.get_unit()] 69 70 hourly_consumptions.index = pd.to_datetime( 71 [date + timedelta(hours=hour - 1) for hour in hourly_consumptions.index] 72 ) 73 hourly_consumptions = hourly_consumptions.asfreq("h") 74 hourly_consumptions["last_checked"] = datetime.now(tz=BRUNEI_TZ) 75 76 self.hourly_consumptions = hourly_consumptions.combine_first(self.hourly_consumptions) 77 78 logger.debug(f"[{self.no}] Fetched consumptions for: {date.date()}") 79 return hourly_consumptions[self.get_unit()] 80 81 @requires_init 82 async def fetch_daily_consumptions(self, date: datetime) -> pd.Series: 83 """Fetch daily consumptions for a given date and return as pd.Series.""" 84 date = sanitize_date(date) 85 86 month_consumption = self.get_daily_consumptions(date) 87 if not month_consumption.empty: 88 return month_consumption 89 90 logger.debug(f"[{self.no}] Fetching consumptions for: {date.year}-{date.month}") 91 # build payload and perform requests 92 payload = self._build_daily_consumptions_payload(date) 93 94 await self.session.get(f"/Report/UsageHistory?p={self.id}") 95 await self.session.post(f"/Report/UsageHistory?p={self.id}") 96 await self.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 97 response = await self.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 98 99 daily_consumptions = self.parse_daily_consumptions(response) 100 101 # convert dict to pd.DataFrame 102 daily_consumptions = pd.DataFrame.from_dict( 103 daily_consumptions, 104 dtype=float, 105 orient="index", 106 columns=[self.get_unit()], 107 ) 108 daily_consumptions.index = pd.to_datetime(daily_consumptions.index, format="%d/%m/%Y") 109 daily_consumptions = daily_consumptions.asfreq("D") 110 daily_consumptions["last_checked"] = datetime.now(tz=BRUNEI_TZ) 111 112 if daily_consumptions.empty: 113 logger.warning(f"[{self.no}] No consumptions data for : {date.year}-{date.month}") 114 return daily_consumptions[self.get_unit()] 115 116 self.daily_consumptions = daily_consumptions.combine_first(self.daily_consumptions) 117 118 logger.debug(f"[{self.no}] Fetched consumptions for: {date.year}-{date.month}") 119 return daily_consumptions[self.get_unit()] 120 121 @requires_init 122 async def get_previous_n_month_consumptions(self, n: int = 0) -> pd.Series: 123 """ 124 Return the consumptions for previous n month. 125 126 e.g. 127 n=0 : data for this month only 128 n=1 : data for previous month only 129 n=2 : data for previous 2 months only 130 """ 131 date = datetime.now(tz=BRUNEI_TZ) 132 for _ in range(n): 133 date = date.replace(day=1) 134 date = date - timedelta(days=1) 135 return await self.fetch_daily_consumptions(date) 136 137 @requires_init 138 async def get_last_n_days_hourly_consumptions(self, n: int = 0) -> pd.Series: 139 """ 140 Return the hourly unit consumptions for the last n days accumulatively. 141 142 e.g. 143 n=0 : data for today 144 n=1 : data from yesterday until today 145 n=2 : data from 2 days ago until today 146 """ 147 last_n_days_hourly_consumptions = new_consumptions_dataframe( 148 self.get_unit(), 149 "h", 150 )[self.get_unit()] 151 152 upper_date = datetime.now(tz=BRUNEI_TZ) 153 lower_date = upper_date - timedelta(days=n) 154 for i in range(n + 1): 155 date = lower_date + timedelta(days=i) 156 hourly_consumptions = await self.fetch_hourly_consumptions(date) 157 158 if not hourly_consumptions.empty: 159 last_n_days_hourly_consumptions = hourly_consumptions.combine_first( 160 last_n_days_hourly_consumptions 161 ) 162 163 if n > 3: # noqa: PLR2004 164 progress = round((i + 1) / (n + 1) * 100, 1) 165 logger.info( 166 f"[{self.no}] Getting last {n} days hourly consumptions progress: {(i + 1)} out of {(n + 1)}, {progress}%" 167 ) 168 169 return last_n_days_hourly_consumptions 170 171 @requires_init 172 async def get_all_hourly_consumptions(self) -> pd.Series: 173 """Get the hourly unit consumptions for all days and months.""" 174 logger.debug(f"[{self.no}] Getting all hourly consumptions") 175 176 upper_date = datetime.now(tz=BRUNEI_TZ) 177 lower_date = await self.find_earliest_consumption_date() 178 range_date = (upper_date - lower_date).days + 1 179 for i in range(range_date): 180 date = lower_date + timedelta(days=i) 181 await self.fetch_hourly_consumptions(date) 182 progress = round((i + 1) / range_date * 100, 1) 183 logger.info( 184 f"[{self.no}] Getting all hourly consumptions progress: {(i + 1)} out of {range_date}, {progress}%" 185 ) 186 187 return self.hourly_consumptions[self.get_unit()] 188 189 @requires_init 190 async def find_earliest_consumption_date(self) -> datetime: 191 """Determine the earliest date for which hourly consumption data is available.""" 192 if self.earliest_consumption_date is not None: 193 return self.earliest_consumption_date 194 195 if self.hourly_consumptions.empty: 196 now = datetime.now(tz=BRUNEI_TZ) 197 for i in range(5): 198 date = datetime( 199 now.year, 200 now.month, 201 now.day - i, 202 0, 203 0, 204 0, 205 tzinfo=BRUNEI_TZ, 206 ) 207 hourly_consumptions = await self.fetch_hourly_consumptions(date) 208 if not hourly_consumptions.empty: 209 break 210 else: 211 date = self.hourly_consumptions.index.min() 212 logger.info(f"[{self.no}] Finding earliest consumption date, starting from: {date.date()}") 213 214 # Exponential backoff to find a missing date 215 step = 1 216 while True: 217 hourly_consumptions = await self.fetch_hourly_consumptions(date) 218 219 if not hourly_consumptions.empty: 220 step *= 2 # Exponentially increase step 221 date -= timedelta(days=step) 222 logger.info(f"[{self.no}] Stepping {step} days from {date}") 223 elif step == 1: 224 # Already at base step, this is the earliest available data 225 date += timedelta(days=step) 226 self.earliest_consumption_date = date 227 logger.info(f"[{self.no}] Found earliest consumption date: {date}") 228 return date 229 else: 230 # Went too far — reverse the last large step and reset step to 1 231 date += timedelta(days=step) 232 logger.debug(f"[{self.no}] Stepped too far, going back to: {date}") 233 step /= 4 # Half the last step
Async USMS Meter Service that inherits BaseUSMSMeter.
22 async def initialize(self, data: dict): 23 """Fetch meter info and then set initial class attributes.""" 24 logger.debug(f"[{self._account.username}] Initializing meter") 25 self.from_json(data) 26 super().initialize() 27 logger.debug(f"[{self._account.username}] Initialized meter")
Fetch meter info and then set initial class attributes.
29 @classmethod 30 async def create(cls, account: "AsyncUSMSAccount", data: dict) -> "AsyncUSMSMeter": 31 """Initialize and return instance of this class as an object.""" 32 self = cls(account) 33 await self.initialize(data) 34 return self
Initialize and return instance of this class as an object.
16class USMSAccount(BaseUSMSAccount): 17 """Sync USMS Account Service that inherits BaseUSMSAccount.""" 18 19 session: USMSClient 20 21 def initialize(self): 22 """Initialize session object, fetch account info and set class attributes.""" 23 logger.debug(f"[{self.username}] Initializing account {self.username}") 24 25 self.session = USMSClient.create(self.auth) 26 27 data = self.fetch_info() 28 self.from_json(data) 29 30 self._initialized = True 31 logger.debug(f"[{self.username}] Initialized account") 32 33 @classmethod 34 def create(cls, username: str, password: str) -> "USMSAccount": 35 """Initialize and return instance of this class as an object.""" 36 self = cls(username, password) 37 self.initialize() 38 return self 39 40 def fetch_more_info(self) -> dict: 41 """Fetch account information, parse data, initialize class attributes and return as json.""" 42 logger.debug(f"[{self.username}] Fetching more account details") 43 44 response = self.session.get("/AccountInfo") 45 data = self.parse_more_info(response) 46 47 logger.debug(f"[{self.username}] Fetched more account details") 48 return data 49 50 def fetch_info(self) -> dict: 51 """ 52 Fetch minimal account and meters information. 53 54 Fetch minimal account and meters information, parse data, 55 initialize class attributes and return as json. 56 """ 57 logger.debug(f"[{self.username}] Fetching account details") 58 59 response = self.session.get("/Home") 60 data = self.parse_info(response) 61 62 logger.debug(f"[{self.username}] Fetched account details") 63 return data 64 65 def from_json(self, data: dict) -> None: 66 """Initialize base attributes from a json/dict data.""" 67 super().from_json(data) 68 69 if not hasattr(self, "meters") or self.get_meters() == []: 70 self.meters = [] 71 for meter_data in data.get("meters", []): 72 meter = USMSMeter.create(self, meter_data) 73 self.meters.append(meter) 74 75 @requires_init 76 def log_out(self) -> bool: 77 """Log the user out of the USMS session by clearing session cookies.""" 78 logger.debug(f"[{self.username}] Logging out {self.username}...") 79 80 self.session.get("/ResLogin") 81 self.session.cookies = {} 82 83 if not self.is_authenticated(): 84 logger.debug(f"[{self.username}] Log out successful") 85 return True 86 87 logger.error(f"[{self.username}] Log out fail") 88 return False 89 90 @requires_init 91 def log_in(self) -> bool: 92 """Log in the user.""" 93 logger.debug(f"[{self.username}] Logging in {self.username}...") 94 95 self.session.get("/AccountInfo") 96 97 if self.is_authenticated(): 98 logger.debug(f"[{self.username}] Log in successful") 99 return True 100 101 logger.error(f"[{self.username}] Log in fail") 102 return False 103 104 @requires_init 105 def is_authenticated(self) -> bool: 106 """ 107 Check if the current session is authenticated. 108 109 Check if the current session is authenticated 110 by sending a request without retrying or triggering auth logic. 111 """ 112 is_authenticated = False 113 try: 114 response = self.session.get("/AccountInfo", auth=None) 115 is_authenticated = not self.auth.is_expired(response) 116 except httpx.HTTPError as error: 117 logger.error(f"[{self.username}] Login check failed: {error}") 118 119 if is_authenticated: 120 logger.debug(f"[{self.username}] Account is authenticated") 121 else: 122 logger.debug(f"[{self.username}] Account is NOT authenticated") 123 return is_authenticated 124 125 @requires_init 126 def refresh_data(self) -> bool: 127 """Fetch new data and update the meter info.""" 128 logger.debug(f"[{self.username}] Checking for updates") 129 130 try: 131 fresh_info = self.fetch_info() 132 except Exception as error: # noqa: BLE001 133 logger.error(f"[{self.username}] Failed to fetch update with error: {error}") 134 return False 135 136 self.last_refresh = datetime.now(tz=BRUNEI_TZ) 137 138 for meter in fresh_info.get("meters", []): 139 if meter.get("last_update") > self.get_latest_update(): 140 logger.debug(f"[{self.username}] New updates found") 141 self.from_json(fresh_info) 142 return True 143 144 logger.debug(f"[{self.username}] No new updates found") 145 return False 146 147 @requires_init 148 def check_update_and_refresh(self) -> bool: 149 """Refresh data if an update is due, then return True if update successful.""" 150 try: 151 if self.is_update_due(): 152 return self.refresh_data() 153 except Exception as error: # noqa: BLE001 154 logger.error(f"[{self.username}] Failed to fetch update with error: {error}") 155 return False 156 157 # Update not dued, data not refreshed 158 return False
Sync USMS Account Service that inherits BaseUSMSAccount.
21 def initialize(self): 22 """Initialize session object, fetch account info and set class attributes.""" 23 logger.debug(f"[{self.username}] Initializing account {self.username}") 24 25 self.session = USMSClient.create(self.auth) 26 27 data = self.fetch_info() 28 self.from_json(data) 29 30 self._initialized = True 31 logger.debug(f"[{self.username}] Initialized account")
Initialize session object, fetch account info and set class attributes.
33 @classmethod 34 def create(cls, username: str, password: str) -> "USMSAccount": 35 """Initialize and return instance of this class as an object.""" 36 self = cls(username, password) 37 self.initialize() 38 return self
Initialize and return instance of this class as an object.
40 def fetch_more_info(self) -> dict: 41 """Fetch account information, parse data, initialize class attributes and return as json.""" 42 logger.debug(f"[{self.username}] Fetching more account details") 43 44 response = self.session.get("/AccountInfo") 45 data = self.parse_more_info(response) 46 47 logger.debug(f"[{self.username}] Fetched more account details") 48 return data
Fetch account information, parse data, initialize class attributes and return as json.
50 def fetch_info(self) -> dict: 51 """ 52 Fetch minimal account and meters information. 53 54 Fetch minimal account and meters information, parse data, 55 initialize class attributes and return as json. 56 """ 57 logger.debug(f"[{self.username}] Fetching account details") 58 59 response = self.session.get("/Home") 60 data = self.parse_info(response) 61 62 logger.debug(f"[{self.username}] Fetched account details") 63 return data
Fetch minimal account and meters information.
Fetch minimal account and meters information, parse data, initialize class attributes and return as json.
65 def from_json(self, data: dict) -> None: 66 """Initialize base attributes from a json/dict data.""" 67 super().from_json(data) 68 69 if not hasattr(self, "meters") or self.get_meters() == []: 70 self.meters = [] 71 for meter_data in data.get("meters", []): 72 meter = USMSMeter.create(self, meter_data) 73 self.meters.append(meter)
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.
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.
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.
19class USMSMeter(BaseUSMSMeter): 20 """Sync USMS Meter Service that inherits BaseUSMSMeter.""" 21 22 def initialize(self, data: dict): 23 """Fetch meter info and then set initial class attributes.""" 24 logger.debug(f"[{self._account.username}] Initializing meter") 25 self.from_json(data) 26 super().initialize() 27 logger.debug(f"[{self._account.username}] Initialized meter") 28 29 @classmethod 30 def create(cls, account: "USMSAccount", data: dict) -> "USMSMeter": 31 """Initialize and return instance of this class as an object.""" 32 self = cls(account) 33 self.initialize(data) 34 return self 35 36 @requires_init 37 def fetch_hourly_consumptions(self, date: datetime) -> pd.Series: 38 """Fetch hourly consumptions for a given date and return as pd.Series.""" 39 date = sanitize_date(date) 40 41 day_consumption = self.get_hourly_consumptions(date) 42 if not day_consumption.empty: 43 return day_consumption 44 45 logger.debug(f"[{self.no}] Fetching consumptions for: {date.date()}") 46 # build payload and perform requests 47 payload = self._build_hourly_consumptions_payload(date) 48 self.session.get(f"/Report/UsageHistory?p={self.id}") 49 self.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 50 payload = self._build_hourly_consumptions_payload(date) 51 response = self.session.post( 52 f"/Report/UsageHistory?p={self.id}", 53 data=payload, 54 ) 55 56 hourly_consumptions = self.parse_hourly_consumptions(response) 57 58 # convert dict to pd.DataFrame 59 hourly_consumptions = pd.DataFrame.from_dict( 60 hourly_consumptions, 61 dtype=float, 62 orient="index", 63 columns=[self.get_unit()], 64 ) 65 66 if hourly_consumptions.empty: 67 logger.warning(f"[{self.no}] No consumptions data for : {date.date()}") 68 return hourly_consumptions[self.get_unit()] 69 70 hourly_consumptions.index = pd.to_datetime( 71 [date + timedelta(hours=hour - 1) for hour in hourly_consumptions.index] 72 ) 73 hourly_consumptions = hourly_consumptions.asfreq("h") 74 hourly_consumptions["last_checked"] = datetime.now(tz=BRUNEI_TZ) 75 76 self.hourly_consumptions = hourly_consumptions.combine_first(self.hourly_consumptions) 77 78 logger.debug(f"[{self.no}] Fetched consumptions for: {date.date()}") 79 return hourly_consumptions[self.get_unit()] 80 81 @requires_init 82 def fetch_daily_consumptions(self, date: datetime) -> pd.Series: 83 """Fetch daily consumptions for a given date and return as pd.Series.""" 84 date = sanitize_date(date) 85 86 month_consumption = self.get_daily_consumptions(date) 87 if not month_consumption.empty: 88 return month_consumption 89 90 logger.debug(f"[{self.no}] Fetching consumptions for: {date.year}-{date.month}") 91 # build payload and perform requests 92 payload = self._build_daily_consumptions_payload(date) 93 94 self.session.get(f"/Report/UsageHistory?p={self.id}") 95 self.session.post(f"/Report/UsageHistory?p={self.id}") 96 self.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 97 response = self.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 98 99 daily_consumptions = self.parse_daily_consumptions(response) 100 101 # convert dict to pd.DataFrame 102 daily_consumptions = pd.DataFrame.from_dict( 103 daily_consumptions, 104 dtype=float, 105 orient="index", 106 columns=[self.get_unit()], 107 ) 108 daily_consumptions.index = pd.to_datetime(daily_consumptions.index, format="%d/%m/%Y") 109 daily_consumptions = daily_consumptions.asfreq("D") 110 daily_consumptions["last_checked"] = datetime.now(tz=BRUNEI_TZ) 111 112 if daily_consumptions.empty: 113 logger.warning(f"[{self.no}] No consumptions data for : {date.year}-{date.month}") 114 return daily_consumptions[self.get_unit()] 115 116 self.daily_consumptions = daily_consumptions.combine_first(self.daily_consumptions) 117 118 logger.debug(f"[{self.no}] Fetched consumptions for: {date.year}-{date.month}") 119 return daily_consumptions[self.get_unit()] 120 121 @requires_init 122 def get_previous_n_month_consumptions(self, n: int = 0) -> pd.Series: 123 """ 124 Return the consumptions for previous n month. 125 126 e.g. 127 n=0 : data for this month only 128 n=1 : data for previous month only 129 n=2 : data for previous 2 months only 130 """ 131 date = datetime.now(tz=BRUNEI_TZ) 132 for _ in range(n): 133 date = date.replace(day=1) 134 date = date - timedelta(days=1) 135 return self.fetch_daily_consumptions(date) 136 137 @requires_init 138 def get_last_n_days_hourly_consumptions(self, n: int = 0) -> pd.Series: 139 """ 140 Return the hourly unit consumptions for the last n days accumulatively. 141 142 e.g. 143 n=0 : data for today 144 n=1 : data from yesterday until today 145 n=2 : data from 2 days ago until today 146 """ 147 last_n_days_hourly_consumptions = new_consumptions_dataframe( 148 self.get_unit(), 149 "h", 150 )[self.get_unit()] 151 152 upper_date = datetime.now(tz=BRUNEI_TZ) 153 lower_date = upper_date - timedelta(days=n) 154 for i in range(n + 1): 155 date = lower_date + timedelta(days=i) 156 hourly_consumptions = self.fetch_hourly_consumptions(date) 157 158 if not hourly_consumptions.empty: 159 last_n_days_hourly_consumptions = hourly_consumptions.combine_first( 160 last_n_days_hourly_consumptions 161 ) 162 163 if n > 3: # noqa: PLR2004 164 progress = round((i + 1) / (n + 1) * 100, 1) 165 logger.info( 166 f"[{self.no}] Getting last {n} days hourly consumptions progress: {(i + 1)} out of {(n + 1)}, {progress}%" 167 ) 168 169 return last_n_days_hourly_consumptions 170 171 @requires_init 172 def get_all_hourly_consumptions(self) -> pd.Series: 173 """Get the hourly unit consumptions for all days and months.""" 174 logger.debug(f"[{self.no}] Getting all hourly consumptions") 175 176 upper_date = datetime.now(tz=BRUNEI_TZ) 177 lower_date = self.find_earliest_consumption_date() 178 range_date = (upper_date - lower_date).days + 1 179 for i in range(range_date): 180 date = lower_date + timedelta(days=i) 181 self.fetch_hourly_consumptions(date) 182 progress = round((i + 1) / range_date * 100, 1) 183 logger.info( 184 f"[{self.no}] Getting all hourly consumptions progress: {(i + 1)} out of {range_date}, {progress}%" 185 ) 186 187 return self.hourly_consumptions[self.get_unit()] 188 189 @requires_init 190 def find_earliest_consumption_date(self) -> datetime: 191 """Determine the earliest date for which hourly consumption data is available.""" 192 if self.earliest_consumption_date is not None: 193 return self.earliest_consumption_date 194 195 if self.hourly_consumptions.empty: 196 now = datetime.now(tz=BRUNEI_TZ) 197 for i in range(5): 198 date = datetime( 199 now.year, 200 now.month, 201 now.day - i, 202 0, 203 0, 204 0, 205 tzinfo=BRUNEI_TZ, 206 ) 207 hourly_consumptions = self.fetch_hourly_consumptions(date) 208 if not hourly_consumptions.empty: 209 break 210 else: 211 date = self.hourly_consumptions.index.min() 212 logger.info(f"[{self.no}] Finding earliest consumption date, starting from: {date.date()}") 213 214 # Exponential backoff to find a missing date 215 step = 1 216 while True: 217 hourly_consumptions = self.fetch_hourly_consumptions(date) 218 219 if not hourly_consumptions.empty: 220 step *= 2 # Exponentially increase step 221 date -= timedelta(days=step) 222 logger.info(f"[{self.no}] Stepping {step} days from {date}") 223 elif step == 1: 224 # Already at base step, this is the earliest available data 225 date += timedelta(days=step) 226 self.earliest_consumption_date = date 227 logger.info(f"[{self.no}] Found earliest consumption date: {date}") 228 return date 229 else: 230 # Went too far — reverse the last large step and reset step to 1 231 date += timedelta(days=step) 232 logger.debug(f"[{self.no}] Stepped too far, going back to: {date}") 233 step /= 4 # Half the last step
Sync USMS Meter Service that inherits BaseUSMSMeter.
22 def initialize(self, data: dict): 23 """Fetch meter info and then set initial class attributes.""" 24 logger.debug(f"[{self._account.username}] Initializing meter") 25 self.from_json(data) 26 super().initialize() 27 logger.debug(f"[{self._account.username}] Initialized meter")
Fetch meter info and then set initial class attributes.
29 @classmethod 30 def create(cls, account: "USMSAccount", data: dict) -> "USMSMeter": 31 """Initialize and return instance of this class as an object.""" 32 self = cls(account) 33 self.initialize(data) 34 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)
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)
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.