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.debug(f"[{self.username}] Log out successful") 74 return True 75 76 logger.debug(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.debug(f"[{self.username}] Log in successful") 88 return True 89 90 logger.debug(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.debug(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.
93class AsyncUSMSClient(BaseUSMSClient, httpx.AsyncClient): 94 """Async HTTP client for interacting with USMS.""" 95 96 async def initialize(self) -> None: 97 """Actual initialization logic of Client object.""" 98 super().initialize() 99 100 @classmethod 101 async def create(cls, auth: USMSAuth) -> "AsyncUSMSClient": 102 """Initialize and return instance of this class as an object.""" 103 self = cls(auth) 104 await self.initialize() 105 return self 106 107 @requires_init 108 async def post(self, url: str, data: dict | None = None) -> httpx.Response: 109 """Send a POST request with ASP.NET hidden fields included.""" 110 return await super().post(url=url, data=data) 111 112 @requires_init 113 async def _update_asp_state(self, response: httpx.Response) -> None: 114 """Extract ASP.NET hidden fields from responses to maintain session state.""" 115 super()._extract_asp_state(await response.aread())
Async HTTP client for interacting with USMS.
96 async def initialize(self) -> None: 97 """Actual initialization logic of Client object.""" 98 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.debug(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.debug(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=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=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 = pd.Series( 164 dtype=float, 165 index=pd.DatetimeIndex([], tz=BRUNEI_TZ, freq="h"), 166 name=self.get_unit(), 167 ) 168 169 upper_date = datetime.now(tz=BRUNEI_TZ) 170 lower_date = upper_date - timedelta(days=n) 171 for i in range(n + 1): 172 date = lower_date + timedelta(days=i) 173 hourly_consumptions = await self.fetch_hourly_consumptions(date) 174 175 if not hourly_consumptions.empty: 176 last_n_days_hourly_consumptions = hourly_consumptions.combine_first( 177 last_n_days_hourly_consumptions 178 ) 179 180 return last_n_days_hourly_consumptions 181 182 @requires_init 183 async def refresh_data(self) -> bool: 184 """Fetch new data and update the meter info.""" 185 logger.info(f"[{self.no}] Checking for updates") 186 187 try: 188 # Initialize a temporary meter to fetch fresh details in one call 189 temp_meter = AsyncUSMSMeter(self._account, self.node_no) 190 temp_info = await temp_meter.fetch_info() 191 except Exception as error: # noqa: BLE001 192 logger.warning(f"[{self.no}] Failed to fetch update with error: {error}") 193 return False 194 195 self.last_refresh = datetime.now(tz=BRUNEI_TZ) 196 197 if temp_info.get("last_update") > self.last_update: 198 logger.info(f"[{self.no}] New updates found") 199 self.from_json(temp_info) 200 return True 201 202 logger.info(f"[{self.no}] No new updates found") 203 return False 204 205 @requires_init 206 async def check_update_and_refresh(self) -> bool: 207 """Refresh data if an update is due, then return True if update successful.""" 208 try: 209 if self.is_update_due(): 210 return await self.refresh_data() 211 except Exception as error: # noqa: BLE001 212 logger.warning(f"[{self.no}] Failed to fetch update with error: {error}") 213 return False 214 215 # Update not dued, data not refreshed 216 return False 217 218 @requires_init 219 async def get_all_hourly_consumptions(self) -> pd.Series: 220 """Get the hourly unit consumptions for all days and months.""" 221 logger.debug(f"[{self.no}] Getting all hourly consumptions") 222 223 upper_date = datetime.now(tz=BRUNEI_TZ) 224 lower_date = await self.find_earliest_consumption_date() 225 range_date = (upper_date - lower_date).days + 1 226 for i in range(range_date): 227 date = lower_date + timedelta(days=i) 228 await self.fetch_hourly_consumptions(date) 229 logger.debug( 230 f"[{self.no}] Getting all hourly consumptions progress: {i} out of {range_date}, {i / range_date * 100}%" 231 ) 232 233 return self.hourly_consumptions 234 235 @requires_init 236 async def find_earliest_consumption_date(self) -> datetime: 237 """Determine the earliest date for which hourly consumption data is available.""" 238 if self.earliest_consumption_date is not None: 239 return self.earliest_consumption_date 240 241 if self.hourly_consumptions.empty: 242 now = datetime.now(tz=BRUNEI_TZ) 243 date = datetime( 244 now.year, 245 now.month, 246 now.day, 247 0, 248 0, 249 0, 250 tzinfo=BRUNEI_TZ, 251 ) 252 else: 253 date = self.hourly_consumptions.index.min() 254 logger.debug(f"[{self.no}] Finding earliest consumption date, starting from: {date.date()}") 255 256 # Exponential backoff to find a missing date 257 step = 1 258 while True: 259 hourly_consumptions = await self.fetch_hourly_consumptions(date) 260 261 if not hourly_consumptions.empty: 262 step *= 2 # Exponentially increase step 263 date -= timedelta(days=step) 264 logger.debug(f"[{self.no}] Stepping {step} days from {date}") 265 elif step == 1: 266 # Already at base step, this is the earliest available data 267 date += timedelta(days=step) 268 self.earliest_consumption_date = date 269 logger.debug(f"[{self.no}] Found earliest consumption date: {date}") 270 return date 271 else: 272 # Went too far — reverse the last large step and reset step to 1 273 date += timedelta(days=step) 274 logger.debug(f"[{self.no}] Stepped too far, going back to: {date}") 275 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.debug(f"[{self.username}] Log out successful") 73 return True 74 75 logger.debug(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.debug(f"[{self.username}] Log in successful") 87 return True 88 89 logger.debug(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.debug(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.
77class USMSClient(BaseUSMSClient, httpx.Client): 78 """Sync HTTP client for interacting with USMS.""" 79 80 @classmethod 81 def create(cls, auth: USMSAuth) -> "USMSClient": 82 """Initialize and return instance of this class as an object.""" 83 self = cls(auth) 84 self.initialize() 85 return self 86 87 @requires_init 88 def _update_asp_state(self, response: httpx.Response) -> None: 89 """Extract ASP.NET hidden fields from responses to maintain session state.""" 90 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.debug(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.debug(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=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=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 = pd.Series( 160 dtype=float, 161 index=pd.DatetimeIndex([], tz=BRUNEI_TZ, freq="h"), 162 name=self.get_unit(), 163 ) 164 165 upper_date = datetime.now(tz=BRUNEI_TZ) 166 lower_date = upper_date - timedelta(days=n) 167 range_date = (upper_date - lower_date).days + 1 168 for i in range(range_date): 169 date = lower_date + timedelta(days=i) 170 hourly_consumptions = self.fetch_hourly_consumptions(date) 171 172 if not hourly_consumptions.empty: 173 last_n_days_hourly_consumptions = hourly_consumptions.combine_first( 174 last_n_days_hourly_consumptions 175 ) 176 177 return last_n_days_hourly_consumptions 178 179 @requires_init 180 def refresh_data(self) -> bool: 181 """Fetch new data and update the meter info.""" 182 logger.info(f"[{self.no}] Checking for updates") 183 184 try: 185 # Initialize a temporary meter to fetch fresh details in one call 186 temp_meter = USMSMeter(self._account, self.node_no) 187 temp_info = temp_meter.fetch_info() 188 except Exception as error: # noqa: BLE001 189 logger.warning(f"[{self.no}] Failed to fetch update with error: {error}") 190 return False 191 192 self.last_refresh = datetime.now(tz=BRUNEI_TZ) 193 194 if temp_info.get("last_update") > self.last_update: 195 logger.info(f"[{self.no}] New updates found") 196 self.from_json(temp_info) 197 return True 198 199 logger.info(f"[{self.no}] No new updates found") 200 return False 201 202 @requires_init 203 def check_update_and_refresh(self) -> bool: 204 """Refresh data if an update is due, then return True if update successful.""" 205 try: 206 if self.is_update_due(): 207 return self.refresh_data() 208 except Exception as error: # noqa: BLE001 209 logger.warning(f"[{self.no}] Failed to fetch update with error: {error}") 210 return False 211 212 # Update not dued, data not refreshed 213 return False 214 215 @requires_init 216 def get_all_hourly_consumptions(self) -> pd.Series: 217 """Get the hourly unit consumptions for all days and months.""" 218 logger.debug(f"[{self.no}] Getting all hourly consumptions") 219 220 upper_date = datetime.now(tz=BRUNEI_TZ) 221 lower_date = self.find_earliest_consumption_date() 222 range_date = (upper_date - lower_date).days + 1 223 for i in range(range_date): 224 date = lower_date + timedelta(days=i) 225 self.fetch_hourly_consumptions(date) 226 logger.debug( 227 f"[{self.no}] Getting all hourly consumptions progress: {i} out of {range_date}, {i / range_date * 100}%" 228 ) 229 230 return self.hourly_consumptions 231 232 @requires_init 233 def find_earliest_consumption_date(self) -> datetime: 234 """Determine the earliest date for which hourly consumption data is available.""" 235 if self.earliest_consumption_date is not None: 236 return self.earliest_consumption_date 237 238 if self.hourly_consumptions.empty: 239 now = datetime.now(tz=BRUNEI_TZ) 240 date = datetime( 241 now.year, 242 now.month, 243 now.day, 244 0, 245 0, 246 0, 247 tzinfo=BRUNEI_TZ, 248 ) 249 else: 250 date = self.hourly_consumptions.index.min() 251 logger.debug(f"[{self.no}] Finding earliest consumption date, starting from: {date.date()}") 252 253 # Exponential backoff to find a missing date 254 step = 1 255 while True: 256 hourly_consumptions = self.fetch_hourly_consumptions(date) 257 258 if not hourly_consumptions.empty: 259 step *= 2 # Exponentially increase step 260 date -= timedelta(days=step) 261 logger.debug(f"[{self.no}] Stepping {step} days from {date}") 262 elif step == 1: 263 # Already at base step, this is the earliest available data 264 date += timedelta(days=step) 265 self.earliest_consumption_date = date 266 logger.debug(f"[{self.no}] Found earliest consumption date: {date}") 267 return date 268 else: 269 # Went too far — reverse the last large step and reset step to 1 270 date += timedelta(days=step) 271 logger.debug(f"[{self.no}] Stepped too far, going back to: {date}") 272 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.