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