usms
USMS: A client library for interacting with the utility portal.
This package provides programmatic access to login, retrieve meter information, fetch billing details, and more from the USMS platform.
1""" 2USMS: A client library for interacting with the utility portal. 3 4This package provides programmatic access to login, retrieve meter information, 5fetch billing details, and more from the USMS platform. 6""" 7 8from usms.config.constants import BRUNEI_TZ, TARIFFS, UNITS 9from usms.core.client import USMSClient 10from usms.factory import initialize_usms_account 11from usms.models.tariff import USMSTariff, USMSTariffTier 12from usms.services.account import BaseUSMSAccount 13from usms.services.async_.account import AsyncUSMSAccount 14from usms.services.async_.meter import AsyncUSMSMeter 15from usms.services.meter import BaseUSMSMeter 16from usms.services.sync.account import USMSAccount 17from usms.services.sync.meter import USMSMeter 18from usms.utils.helpers import get_storage_manager 19 20__all__ = [ 21 "BRUNEI_TZ", 22 "TARIFFS", 23 "UNITS", 24 "AsyncUSMSAccount", 25 "AsyncUSMSMeter", 26 "BaseUSMSAccount", 27 "BaseUSMSMeter", 28 "USMSAccount", 29 "USMSClient", 30 "USMSMeter", 31 "USMSTariff", 32 "USMSTariffTier", 33 "get_storage_manager", 34 "initialize_usms_account", 35]
19class AsyncUSMSAccount(BaseUSMSAccount): 20 """Async USMS Account Service that inherits BaseUSMSAccount.""" 21 22 async def initialize(self): 23 """Initialize session object, fetch account info and set class attributes.""" 24 logger.debug(f"[{self.reg_no}] Initializing account {self.reg_no}") 25 26 data = await self.fetch_info() 27 await self.update_from_json(data) 28 29 self._initialized = True 30 logger.debug(f"[{self.reg_no}] Initialized account") 31 32 @classmethod 33 async def create( 34 cls, 35 session: USMSClient, 36 storage_manager: "BaseUSMSStorage" = None, 37 ) -> "AsyncUSMSAccount": 38 """Initialize and return instance of this class as an object.""" 39 self = cls( 40 session, 41 storage_manager, 42 ) 43 await self.initialize() 44 return self 45 46 async def fetch_info(self) -> dict[str, str]: 47 """ 48 Fetch minimal account and meters information. 49 50 Fetch minimal account and meters information, parse data, 51 initialize class attributes and return as json. 52 """ 53 logger.debug(f"[{self.reg_no}] Fetching account details") 54 55 response = await self.session.get("/Home") 56 response_content = await response.aread() 57 data = AccountInfoParser.parse(response_content) 58 59 logger.debug(f"[{self.reg_no}] Fetched account details") 60 return data 61 62 async def update_from_json(self, data: dict[str, str]) -> None: 63 """Initialize base attributes from a json/dict data.""" 64 super().update_from_json(data) 65 66 if not hasattr(self, "meters") or self.meters == []: 67 self.meters = [] 68 for meter_data in data.get("meters", []): 69 meter = await AsyncUSMSMeter.create(self, meter_data) 70 self.meters.append(meter) 71 72 @requires_init 73 async def log_out(self) -> bool: 74 """Log the user out of the USMS session by clearing session cookies.""" 75 logger.debug(f"[{self.reg_no}] Logging out {self.reg_no}...") 76 77 await self.session.get("/ResLogin") 78 self.session.cookies = {} 79 80 if not await self.is_authenticated(): 81 logger.debug(f"[{self.reg_no}] Log out successful") 82 return True 83 84 logger.error(f"[{self.reg_no}] Log out fail") 85 return False 86 87 @requires_init 88 async def log_in(self) -> bool: 89 """Log in the user.""" 90 logger.debug(f"[{self.reg_no}] Logging in {self.reg_no}...") 91 92 await self.session.get("/AccountInfo") 93 94 if await self.is_authenticated(): 95 logger.debug(f"[{self.reg_no}] Log in successful") 96 return True 97 98 logger.error(f"[{self.reg_no}] Log in fail") 99 return False 100 101 @requires_init 102 async def is_authenticated(self) -> bool: 103 """ 104 Check if the current session is authenticated. 105 106 Check if the current session is authenticated 107 by sending a request without retrying or triggering auth logic. 108 """ 109 response = await self.session.get("/AccountInfo", auth=None) 110 is_authenticated = not self.auth.is_expired(response) 111 112 if is_authenticated: 113 logger.debug(f"[{self.reg_no}] Account is authenticated") 114 else: 115 logger.debug(f"[{self.reg_no}] Account is NOT authenticated") 116 return is_authenticated 117 118 @requires_init 119 async def refresh_data(self) -> bool: 120 """Fetch new data and update the meter info.""" 121 logger.debug(f"[{self.reg_no}] Checking for updates") 122 123 try: 124 fresh_info = await self.fetch_info() 125 except Exception as error: # noqa: BLE001 126 logger.error(f"[{self.reg_no}] Failed to fetch update with error: {error}") 127 return False 128 129 self.last_refresh = datetime.now().astimezone() 130 131 for meter in fresh_info.get("meters", []): 132 if meter.get("last_update") > self.get_latest_update(): 133 logger.debug(f"[{self.reg_no}] New updates found") 134 await self.update_from_json(fresh_info) 135 return True 136 137 logger.debug(f"[{self.reg_no}] No new updates found") 138 return False 139 140 @requires_init 141 async def check_update_and_refresh(self) -> bool: 142 """Refresh data if an update is due, then return True if update successful.""" 143 try: 144 if self.is_update_due(): 145 return await self.refresh_data() 146 except Exception as error: # noqa: BLE001 147 logger.error(f"[{self.reg_no}] Failed to fetch update with error: {error}") 148 return False 149 150 # Update not dued, data not refreshed 151 return False
Async USMS Account Service that inherits BaseUSMSAccount.
22 async def initialize(self): 23 """Initialize session object, fetch account info and set class attributes.""" 24 logger.debug(f"[{self.reg_no}] Initializing account {self.reg_no}") 25 26 data = await self.fetch_info() 27 await self.update_from_json(data) 28 29 self._initialized = True 30 logger.debug(f"[{self.reg_no}] Initialized account")
Initialize session object, fetch account info and set class attributes.
32 @classmethod 33 async def create( 34 cls, 35 session: USMSClient, 36 storage_manager: "BaseUSMSStorage" = None, 37 ) -> "AsyncUSMSAccount": 38 """Initialize and return instance of this class as an object.""" 39 self = cls( 40 session, 41 storage_manager, 42 ) 43 await self.initialize() 44 return self
Initialize and return instance of this class as an object.
46 async def fetch_info(self) -> dict[str, str]: 47 """ 48 Fetch minimal account and meters information. 49 50 Fetch minimal account and meters information, parse data, 51 initialize class attributes and return as json. 52 """ 53 logger.debug(f"[{self.reg_no}] Fetching account details") 54 55 response = await self.session.get("/Home") 56 response_content = await response.aread() 57 data = AccountInfoParser.parse(response_content) 58 59 logger.debug(f"[{self.reg_no}] Fetched account details") 60 return data
Fetch minimal account and meters information.
Fetch minimal account and meters information, parse data, initialize class attributes and return as json.
62 async def update_from_json(self, data: dict[str, str]) -> None: 63 """Initialize base attributes from a json/dict data.""" 64 super().update_from_json(data) 65 66 if not hasattr(self, "meters") or self.meters == []: 67 self.meters = [] 68 for meter_data in data.get("meters", []): 69 meter = await AsyncUSMSMeter.create(self, meter_data) 70 self.meters.append(meter)
Initialize base attributes from a json/dict data.
26class AsyncUSMSMeter(BaseUSMSMeter): 27 """Async USMS Meter Service that inherits BaseUSMSMeter.""" 28 29 async def initialize(self, data: dict[str, str]) -> None: 30 """Fetch meter info and then set initial class attributes.""" 31 logger.debug(f"[{self._account.reg_no}] Initializing meter") 32 self.update_from_json(data) 33 super().initialize() 34 35 if self.storage_manager is not None: 36 consumptions = await asyncio.to_thread( 37 self.storage_manager.get_all_consumptions, 38 self.no, 39 ) 40 self.hourly_consumptions = consumptions_storage_to_dataframe(consumptions) 41 42 self.hourly_consumptions.rename( 43 columns={"consumption": self.unit}, 44 inplace=True, 45 ) 46 47 logger.debug(f"[{self._account.reg_no}] Initialized meter") 48 49 @classmethod 50 async def create(cls, account: "AsyncUSMSAccount", data: dict[str, str]) -> "AsyncUSMSMeter": 51 """Initialize and return instance of this class as an object.""" 52 self = cls(account) 53 await self.initialize(data) 54 return self 55 56 @requires_init 57 async def fetch_hourly_consumptions( 58 self, 59 date: datetime, 60 *, 61 force_refresh: bool = False, 62 ) -> pd.Series: 63 """Fetch hourly consumptions for a given date and return as pd.Series.""" 64 date = sanitize_date(date) 65 66 if not force_refresh: 67 day_consumption = self.get_hourly_consumptions(date) 68 if not day_consumption.empty: 69 return day_consumption 70 71 logger.debug(f"[{self.no}] Fetching consumptions for: {date.date()}") 72 # build payload and perform requests 73 payload = self._build_hourly_consumptions_payload(date) 74 await self.session.get(f"/Report/UsageHistory?p={self.id}") 75 await self.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 76 payload = self._build_hourly_consumptions_payload(date) 77 response = await self.session.post( 78 f"/Report/UsageHistory?p={self.id}", 79 data=payload, 80 ) 81 response_content = await response.aread() 82 83 error_message = ErrorMessageParser.parse(response_content).get("error_message") 84 if error_message == "consumption history not found.": 85 # this error message is somehow not always true 86 # ignore it for now, and check for the table properly instead 87 pass 88 elif error_message is not None and error_message != "": 89 logger.error(f"[{self.no}] Error fetching consumptions: {error_message}") 90 91 hourly_consumptions = MeterConsumptionsParser.parse(response_content) 92 93 # convert dict to pd.DataFrame 94 hourly_consumptions = pd.DataFrame.from_dict( 95 hourly_consumptions, 96 dtype=float, 97 orient="index", 98 columns=[self.unit], 99 ) 100 101 if hourly_consumptions.empty: 102 logger.warning(f"[{self.no}] No consumptions data for : {date.date()}") 103 return hourly_consumptions[self.unit] 104 105 hourly_consumptions.index = pd.to_datetime( 106 [date + timedelta(hours=int(hour) - 1) for hour in hourly_consumptions.index] 107 ) 108 hourly_consumptions = hourly_consumptions.asfreq("h") 109 hourly_consumptions["last_checked"] = datetime.now().astimezone() 110 111 self.hourly_consumptions = hourly_consumptions.combine_first(self.hourly_consumptions) 112 113 logger.debug(f"[{self.no}] Fetched consumptions for: {date.date()}") 114 return hourly_consumptions[self.unit] 115 116 @requires_init 117 async def fetch_daily_consumptions( 118 self, 119 date: datetime, 120 *, 121 force_refresh: bool = False, 122 ) -> pd.Series: 123 """Fetch daily consumptions for a given date and return as pd.Series.""" 124 date = sanitize_date(date) 125 126 if not force_refresh: 127 month_consumption = self.get_daily_consumptions(date) 128 if not month_consumption.empty: 129 return month_consumption 130 131 logger.debug(f"[{self.no}] Fetching consumptions for: {date.year}-{date.month}") 132 # build payload and perform requests 133 payload = self._build_daily_consumptions_payload(date) 134 135 await self.session.get(f"/Report/UsageHistory?p={self.id}") 136 await self.session.post(f"/Report/UsageHistory?p={self.id}") 137 await self.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 138 response = await self.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 139 response_content = await response.aread() 140 141 error_message = ErrorMessageParser.parse(response_content).get("error_message") 142 if error_message: 143 daily_consumptions = new_consumptions_dataframe(self.unit, "D") 144 else: 145 daily_consumptions = MeterConsumptionsParser.parse(response_content) 146 147 # convert dict to pd.DataFrame 148 daily_consumptions = pd.DataFrame.from_dict( 149 daily_consumptions, 150 dtype=float, 151 orient="index", 152 columns=[self.unit], 153 ) 154 daily_consumptions.index = pd.to_datetime( 155 [f"{date.year}-{date.month:02d}-{int(day) + 1}" for day in daily_consumptions.index] 156 ) 157 daily_consumptions = daily_consumptions.asfreq("D") 158 daily_consumptions["last_checked"] = datetime.now().astimezone() 159 160 if daily_consumptions.empty: 161 logger.warning(f"[{self.no}] No consumptions data for : {date.year}-{date.month}") 162 return daily_consumptions[self.unit] 163 164 self.daily_consumptions = daily_consumptions.combine_first(self.daily_consumptions) 165 166 logger.debug(f"[{self.no}] Fetched consumptions for: {date.year}-{date.month}") 167 return daily_consumptions[self.unit] 168 169 @requires_init 170 async def get_previous_n_month_consumptions(self, n: int = 0) -> pd.Series: 171 """ 172 Return the consumptions for previous n month. 173 174 e.g. 175 n=0 : data for this month only 176 n=1 : data for previous month only 177 n=2 : data for previous 2 months only 178 """ 179 date = datetime.now().astimezone() 180 for _ in range(n): 181 date = date.replace(day=1) 182 date = date - timedelta(days=1) 183 return await self.fetch_daily_consumptions(date) 184 185 @requires_init 186 async def get_last_n_days_hourly_consumptions(self, n: int = 0) -> pd.Series: 187 """ 188 Return the hourly unit consumptions for the last n days accumulatively. 189 190 e.g. 191 n=0 : data for today 192 n=1 : data from yesterday until today 193 n=2 : data from 2 days ago until today 194 """ 195 last_n_days_hourly_consumptions = new_consumptions_dataframe( 196 self.unit, 197 "h", 198 )[self.unit] 199 200 upper_date = datetime.now().astimezone() 201 lower_date = upper_date - timedelta(days=n) 202 for i in range(n + 1): 203 date = lower_date + timedelta(days=i) 204 hourly_consumptions = await self.fetch_hourly_consumptions(date) 205 206 if not hourly_consumptions.empty: 207 last_n_days_hourly_consumptions = hourly_consumptions.combine_first( 208 last_n_days_hourly_consumptions 209 ) 210 211 if n > 3: # noqa: PLR2004 212 progress = round((i + 1) / (n + 1) * 100, 1) 213 logger.info( 214 f"[{self.no}] Getting last {n} days hourly consumptions progress: {(i + 1)} out of {(n + 1)}, {progress}%" 215 ) 216 217 return last_n_days_hourly_consumptions 218 219 @requires_init 220 async def get_all_hourly_consumptions(self) -> pd.Series: 221 """Get the hourly unit consumptions for all days and months.""" 222 logger.debug(f"[{self.no}] Getting all hourly consumptions") 223 224 upper_date = datetime.now().astimezone() 225 lower_date = await self.find_earliest_consumption_date() 226 range_date = (upper_date - lower_date).days + 1 227 for i in range(range_date): 228 date = lower_date + timedelta(days=i) 229 await self.fetch_hourly_consumptions(date) 230 progress = round((i + 1) / range_date * 100, 1) 231 logger.info( 232 f"[{self.no}] Getting all hourly consumptions progress: {(i + 1)} out of {range_date}, {progress}%" 233 ) 234 235 return self.hourly_consumptions[self.unit] 236 237 @requires_init 238 async def find_earliest_consumption_date(self) -> datetime: 239 """Determine the earliest date for which hourly consumption data is available.""" 240 if self.earliest_consumption_date is not None: 241 return self.earliest_consumption_date 242 243 now = datetime.now().astimezone() 244 if self.hourly_consumptions.empty: 245 for i in range(7): 246 date = now - timedelta(days=i) 247 hourly_consumptions = await self.fetch_hourly_consumptions(date) 248 if not hourly_consumptions.empty: 249 break 250 else: 251 date = self.hourly_consumptions.index.min() 252 logger.info(f"[{self.no}] Finding earliest consumption date, starting from: {date.date()}") 253 254 # Exponential backoff to find a missing date 255 step = 1 256 while True: 257 hourly_consumptions = await self.fetch_hourly_consumptions(date) 258 259 if not hourly_consumptions.empty: 260 step *= 2 # Exponentially increase step 261 logger.info(f"[{self.no}] Stepping {step} days from {date}") 262 date -= timedelta(days=step) 263 elif step == 1: 264 if self.hourly_consumptions.empty: 265 logger.error(f"[{self.no}] Cannot determine earliest available date") 266 return now 267 # Already at base step, this is the earliest available data 268 date += timedelta(days=step) 269 self.earliest_consumption_date = date 270 logger.info(f"[{self.no}] Found earliest consumption date: {date}") 271 return date 272 else: 273 # Went too far — reverse the last large step and reset step to 1 274 date += timedelta(days=step) 275 logger.debug(f"[{self.no}] Stepped too far, going back to: {date}") 276 step /= 4 # Half the last step 277 278 @requires_init 279 async def store_consumptions(self, consumptions: pd.DataFrame) -> None: 280 """Insert consumptions in the given dataframe to the database.""" 281 new_statistics_df = dataframe_diff(self.hourly_consumptions, consumptions) 282 283 for row in new_statistics_df.itertuples(index=True, name="Row"): 284 await asyncio.to_thread( 285 self.storage_manager.insert_or_replace, 286 meter_no=self.no, 287 timestamp=int(row.Index.timestamp()), 288 consumption=getattr(row, self.unit), 289 last_checked=int(row.last_checked.timestamp()), 290 )
Async USMS Meter Service that inherits BaseUSMSMeter.
29 async def initialize(self, data: dict[str, str]) -> None: 30 """Fetch meter info and then set initial class attributes.""" 31 logger.debug(f"[{self._account.reg_no}] Initializing meter") 32 self.update_from_json(data) 33 super().initialize() 34 35 if self.storage_manager is not None: 36 consumptions = await asyncio.to_thread( 37 self.storage_manager.get_all_consumptions, 38 self.no, 39 ) 40 self.hourly_consumptions = consumptions_storage_to_dataframe(consumptions) 41 42 self.hourly_consumptions.rename( 43 columns={"consumption": self.unit}, 44 inplace=True, 45 ) 46 47 logger.debug(f"[{self._account.reg_no}] Initialized meter")
Fetch meter info and then set initial class attributes.
49 @classmethod 50 async def create(cls, account: "AsyncUSMSAccount", data: dict[str, str]) -> "AsyncUSMSMeter": 51 """Initialize and return instance of this class as an object.""" 52 self = cls(account) 53 await self.initialize(data) 54 return self
Initialize and return instance of this class as an object.
21class BaseUSMSAccount(ABC, USMSAccountModel): 22 """Base USMS Account Service to be inherited.""" 23 24 session: USMSClient 25 26 last_refresh: datetime 27 28 def __init__( 29 self, 30 session: USMSClient, 31 storage_manager: "BaseUSMSStorage" = None, 32 ) -> None: 33 """Initialize reg_no variable and USMSAuth object.""" 34 self.session = session 35 self.storage_manager = storage_manager 36 37 self.reg_no = self.session.username 38 39 self.last_refresh = datetime.now().astimezone() 40 41 self._initialized = False 42 43 @requires_init 44 def get_meter(self, meter_no: str | int) -> "BaseUSMSMeter": 45 """Return meter associated with the given meter number.""" 46 for meter in self.meters: 47 if str(meter_no) in (str(meter.no), (meter.id)): 48 return meter 49 raise USMSMeterNumberError(meter_no) 50 51 @requires_init 52 def get_latest_update(self) -> datetime: 53 """Return the latest time a meter was updated.""" 54 latest_update = datetime.fromtimestamp(0).astimezone() 55 for meter in self.meters: 56 latest_update = max(latest_update, meter.get_last_updated()) 57 return latest_update 58 59 @requires_init 60 def is_update_due(self) -> bool: 61 """Check if an update is due (based on last update timestamp).""" 62 now = datetime.now().astimezone() 63 latest_update = self.get_latest_update() 64 65 # Interval between checking for new updates 66 logger.debug(f"[{self.reg_no}] update_interval: {UPDATE_INTERVAL}") 67 logger.debug(f"[{self.reg_no}] refresh_interval: {REFRESH_INTERVAL}") 68 69 # Elapsed time since the meter was last updated by USMS 70 time_since_last_update = now - latest_update 71 logger.debug(f"[{self.reg_no}] last_update: {latest_update}") 72 logger.debug(f"[{self.reg_no}] time_since_last_update: {time_since_last_update}") 73 74 # Elapsed time since a refresh was last attempted 75 time_since_last_refresh = now - self.last_refresh 76 logger.debug(f"[{self.reg_no}] last_refresh: {self.last_refresh}") 77 logger.debug(f"[{self.reg_no}] time_since_last_refresh: {time_since_last_refresh}") 78 79 # If 60 minutes has passed since meter was last updated by USMS 80 if time_since_last_update > UPDATE_INTERVAL: 81 logger.debug(f"[{self.reg_no}] time_since_last_update > update_interval") 82 # If 15 minutes has passed since a refresh was last attempted 83 if time_since_last_refresh > REFRESH_INTERVAL: 84 logger.debug(f"[{self.reg_no}] time_since_last_refresh > refresh_interval") 85 logger.debug(f"[{self.reg_no}] Account is due for an update") 86 return True 87 88 logger.debug(f"[{self.reg_no}] time_since_last_refresh < refresh_interval") 89 logger.debug(f"[{self.reg_no}] Account is NOT due for an update") 90 return False 91 92 logger.debug(f"[{self.reg_no}] time_since_last_update < update_interval") 93 logger.debug(f"[{self.reg_no}] Account is NOT due for an update") 94 return False
Base USMS Account Service to be inherited.
28 def __init__( 29 self, 30 session: USMSClient, 31 storage_manager: "BaseUSMSStorage" = None, 32 ) -> None: 33 """Initialize reg_no variable and USMSAuth object.""" 34 self.session = session 35 self.storage_manager = storage_manager 36 37 self.reg_no = self.session.username 38 39 self.last_refresh = datetime.now().astimezone() 40 41 self._initialized = False
Initialize reg_no variable and USMSAuth object.
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 return method(self, *args, **kwargs)
Return meter associated with the given meter number.
22class BaseUSMSMeter(ABC, USMSMeterModel): 23 """Base USMS Meter Service to be inherited.""" 24 25 _account: "BaseUSMSAccount" 26 session: "USMSClient" 27 28 earliest_consumption_date: datetime 29 hourly_consumptions: "pd.DataFrame" 30 daily_consumptions: "pd.DataFrame" 31 32 def __init__(self, account: "BaseUSMSAccount") -> None: 33 """Set initial class variables.""" 34 self._account = account 35 self.session = account.session 36 self.storage_manager = account.storage_manager 37 38 self._initialized = False 39 40 def initialize(self) -> None: 41 """Set initial values for class variables.""" 42 self.earliest_consumption_date = None 43 44 self._initialized = True 45 46 self.hourly_consumptions = new_consumptions_dataframe(self.unit, "h") 47 self.daily_consumptions = new_consumptions_dataframe(self.unit, "D") 48 49 def _build_hourly_consumptions_payload(self, date: datetime) -> dict[str, str]: 50 """Build and return the payload for the hourly consumptions page from a given date.""" 51 epoch = date.replace(tzinfo=ZoneInfo("UTC")).timestamp() * 1000 52 53 yyyy = date.year 54 mm = str(date.month).zfill(2) 55 dd = str(date.day).zfill(2) 56 57 # build payload 58 payload = {} 59 payload["cboType_VI"] = "3" 60 payload["cboType"] = "Hourly (Max 1 day)" 61 62 payload["btnRefresh"] = ["Search", ""] 63 payload["cboDateFrom"] = f"{dd}/{mm}/{yyyy}" 64 payload["cboDateTo"] = f"{dd}/{mm}/{yyyy}" 65 payload["cboDateFrom$State"] = "{" + f""rawValue":"{epoch}"" + "}" 66 payload["cboDateTo$State"] = "{" + f""rawValue":"{epoch}"" + "}" 67 68 return payload 69 70 def _build_daily_consumptions_payload(self, date: datetime) -> dict[str, str]: 71 """Build and return the payload for the daily consumptions page from a given date.""" 72 date_from = datetime( 73 date.year, 74 date.month, 75 1, 76 8, 77 0, 78 0, 79 tzinfo=BRUNEI_TZ, 80 ) 81 epoch_from = date_from.replace(tzinfo=ZoneInfo("UTC")).timestamp() * 1000 82 83 now = sanitize_date(datetime.now().astimezone()) 84 # check if given month is still ongoing 85 if date.year == now.year and date.month == now.month: 86 # then get consumption up until yesterday only 87 date = now - timedelta(days=1) 88 else: 89 # otherwise get until the last day of the month 90 next_month = date.replace(day=28) + timedelta(days=4) 91 last_day = next_month - timedelta(days=next_month.day) 92 date = date.replace(day=last_day.day) 93 yyyy = date.year 94 mm = str(date.month).zfill(2) 95 dd = str(date.day).zfill(2) 96 epoch_to = date.replace(tzinfo=ZoneInfo("UTC")).timestamp() * 1000 97 98 payload = {} 99 payload["cboType_VI"] = "1" 100 payload["cboType"] = "Daily (Max 1 month)" 101 payload["btnRefresh"] = "Search" 102 payload["cboDateFrom"] = f"01/{mm}/{yyyy}" 103 payload["cboDateTo"] = f"{dd}/{mm}/{yyyy}" 104 payload["cboDateFrom$State"] = "{" + f""rawValue":"{epoch_from}"" + "}" 105 payload["cboDateTo$State"] = "{" + f""rawValue":"{epoch_to}"" + "}" 106 107 return payload 108 109 @requires_init 110 def get_hourly_consumptions(self, date: datetime) -> "pd.Series": 111 """Check and return consumptions found for a given day.""" 112 day_consumption = self.hourly_consumptions[ 113 self.hourly_consumptions.index.date == date.date() 114 ] 115 # Check if consumption for this date was already fetched 116 if not day_consumption.empty: 117 now = datetime.now().astimezone() 118 119 last_checked = day_consumption["last_checked"].min() 120 time_since_last_checked = now - last_checked 121 122 time_since_given_date = now - date 123 124 # If not enough time has passed since the last check 125 if (time_since_last_checked < REFRESH_INTERVAL) or ( 126 # Or the date requested is over 3 days ago 127 time_since_given_date > timedelta(days=3) 128 ): 129 # Then just use stored data 130 logger.debug(f"[{self.no}] Found consumptions for: {date.date()}") 131 return day_consumption[self.unit] 132 return new_consumptions_dataframe(self.unit, "h")[self.unit] 133 134 @requires_init 135 def get_daily_consumptions(self, date: datetime) -> "pd.Series": 136 """Check and return consumptions found for a given month.""" 137 month_consumption = self.daily_consumptions[ 138 (self.daily_consumptions.index.month == date.month) 139 & (self.daily_consumptions.index.year == date.year) 140 ] 141 # Check if consumption for this date was already fetched 142 if not month_consumption.empty: 143 now = datetime.now().astimezone() 144 145 last_checked = month_consumption["last_checked"].min() 146 time_since_last_checked = now - last_checked 147 148 time_since_given_date = now - date 149 150 # If not enough time has passed since the last check 151 if (time_since_last_checked < REFRESH_INTERVAL) or ( 152 # Or the date requested is over 1 month + 3 days ago 153 time_since_given_date > timedelta(days=34) 154 ): 155 # Then just use stored data 156 logger.debug(f"[{self.no}] Found consumptions for: {date.year}-{date.month}") 157 return month_consumption[self.unit] 158 return new_consumptions_dataframe(self.unit, "D")[self.unit] 159 160 def calculate_total_consumption(self, consumptions: "pd.Series") -> float: 161 """Calculate the total consumption from a given pd.Series.""" 162 if consumptions.empty: 163 return 0.0 164 total_consumption = round(consumptions.sum(), 3) 165 166 return total_consumption 167 168 def calculate_total_cost(self, consumptions: "pd.Series") -> float: 169 """Calculate the total cost from a given pd.Series.""" 170 total_consumption = self.calculate_total_consumption(consumptions) 171 172 tariff = None 173 for meter_type, meter_tariff in TARIFFS.items(): 174 if meter_type.upper() in self.type.upper(): 175 tariff = meter_tariff 176 if tariff is None: 177 return 0.0 178 179 total_cost = tariff.calculate_cost(total_consumption) 180 return total_cost
Base USMS Meter Service to be inherited.
32 def __init__(self, account: "BaseUSMSAccount") -> None: 33 """Set initial class variables.""" 34 self._account = account 35 self.session = account.session 36 self.storage_manager = account.storage_manager 37 38 self._initialized = False
Set initial class variables.
40 def initialize(self) -> None: 41 """Set initial values for class variables.""" 42 self.earliest_consumption_date = None 43 44 self._initialized = True 45 46 self.hourly_consumptions = new_consumptions_dataframe(self.unit, "h") 47 self.daily_consumptions = new_consumptions_dataframe(self.unit, "D")
Set initial values for class variables.
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 return method(self, *args, **kwargs)
Check and return consumptions found for a given day.
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 return method(self, *args, **kwargs)
Check and return consumptions found for a given month.
160 def calculate_total_consumption(self, consumptions: "pd.Series") -> float: 161 """Calculate the total consumption from a given pd.Series.""" 162 if consumptions.empty: 163 return 0.0 164 total_consumption = round(consumptions.sum(), 3) 165 166 return total_consumption
Calculate the total consumption from a given pd.Series.
168 def calculate_total_cost(self, consumptions: "pd.Series") -> float: 169 """Calculate the total cost from a given pd.Series.""" 170 total_consumption = self.calculate_total_consumption(consumptions) 171 172 tariff = None 173 for meter_type, meter_tariff in TARIFFS.items(): 174 if meter_type.upper() in self.type.upper(): 175 tariff = meter_tariff 176 if tariff is None: 177 return 0.0 178 179 total_cost = tariff.calculate_cost(total_consumption) 180 return total_cost
Calculate the total cost from a given pd.Series.
18class USMSAccount(BaseUSMSAccount): 19 """Sync USMS Account Service that inherits BaseUSMSAccount.""" 20 21 def initialize(self): 22 """Initialize session object, fetch account info and set class attributes.""" 23 logger.debug(f"[{self.reg_no}] Initializing account {self.reg_no}") 24 25 data = self.fetch_info() 26 self.update_from_json(data) 27 28 self._initialized = True 29 logger.debug(f"[{self.reg_no}] Initialized account") 30 31 @classmethod 32 def create( 33 cls, 34 session: USMSClient, 35 storage_manager: "BaseUSMSStorage" = None, 36 ) -> "USMSAccount": 37 """Initialize and return instance of this class as an object.""" 38 self = cls( 39 session, 40 storage_manager, 41 ) 42 self.initialize() 43 return self 44 45 def fetch_info(self) -> dict[str, str]: 46 """ 47 Fetch minimal account and meters information. 48 49 Fetch minimal account and meters information, parse data, 50 initialize class attributes and return as json. 51 """ 52 logger.debug(f"[{self.reg_no}] Fetching account details") 53 54 response = self.session.get("/Home") 55 response_content = response.read() 56 data = AccountInfoParser.parse(response_content) 57 58 logger.debug(f"[{self.reg_no}] Fetched account details") 59 return data 60 61 def update_from_json(self, data: dict[str, str]) -> None: 62 """Initialize base attributes from a json/dict data.""" 63 super().update_from_json(data) 64 65 if not hasattr(self, "meters") or self.get_meters() == []: 66 self.meters = [] 67 for meter_data in data.get("meters", []): 68 meter = USMSMeter.create(self, meter_data) 69 self.meters.append(meter) 70 71 @requires_init 72 def log_out(self) -> bool: 73 """Log the user out of the USMS session by clearing session cookies.""" 74 logger.debug(f"[{self.reg_no}] Logging out {self.reg_no}...") 75 76 self.session.get("/ResLogin") 77 self.session.cookies = {} 78 79 if not self.is_authenticated(): 80 logger.debug(f"[{self.reg_no}] Log out successful") 81 return True 82 83 logger.error(f"[{self.reg_no}] Log out fail") 84 return False 85 86 @requires_init 87 def log_in(self) -> bool: 88 """Log in the user.""" 89 logger.debug(f"[{self.reg_no}] Logging in {self.reg_no}...") 90 91 self.session.get("/AccountInfo") 92 93 if self.is_authenticated(): 94 logger.debug(f"[{self.reg_no}] Log in successful") 95 return True 96 97 logger.error(f"[{self.reg_no}] Log in fail") 98 return False 99 100 @requires_init 101 def is_authenticated(self) -> bool: 102 """ 103 Check if the current session is authenticated. 104 105 Check if the current session is authenticated 106 by sending a request without retrying or triggering auth logic. 107 """ 108 response = self.session.get("/AccountInfo", auth=None) 109 is_authenticated = not self.auth.is_expired(response) 110 111 if is_authenticated: 112 logger.debug(f"[{self.reg_no}] Account is authenticated") 113 else: 114 logger.debug(f"[{self.reg_no}] Account is NOT authenticated") 115 return is_authenticated 116 117 @requires_init 118 def refresh_data(self) -> bool: 119 """Fetch new data and update the meter info.""" 120 logger.debug(f"[{self.reg_no}] Checking for updates") 121 122 try: 123 fresh_info = self.fetch_info() 124 except Exception as error: # noqa: BLE001 125 logger.error(f"[{self.reg_no}] Failed to fetch update with error: {error}") 126 return False 127 128 self.last_refresh = datetime.now().astimezone() 129 130 for meter in fresh_info.get("meters", []): 131 if meter.get("last_update") > self.get_latest_update(): 132 logger.debug(f"[{self.reg_no}] New updates found") 133 self.update_from_json(fresh_info) 134 return True 135 136 logger.debug(f"[{self.reg_no}] No new updates found") 137 return False 138 139 @requires_init 140 def check_update_and_refresh(self) -> bool: 141 """Refresh data if an update is due, then return True if update successful.""" 142 try: 143 if self.is_update_due(): 144 return self.refresh_data() 145 except Exception as error: # noqa: BLE001 146 logger.error(f"[{self.reg_no}] Failed to fetch update with error: {error}") 147 return False 148 149 # Update not dued, data not refreshed 150 return False
Sync USMS Account Service that inherits BaseUSMSAccount.
21 def initialize(self): 22 """Initialize session object, fetch account info and set class attributes.""" 23 logger.debug(f"[{self.reg_no}] Initializing account {self.reg_no}") 24 25 data = self.fetch_info() 26 self.update_from_json(data) 27 28 self._initialized = True 29 logger.debug(f"[{self.reg_no}] Initialized account")
Initialize session object, fetch account info and set class attributes.
31 @classmethod 32 def create( 33 cls, 34 session: USMSClient, 35 storage_manager: "BaseUSMSStorage" = None, 36 ) -> "USMSAccount": 37 """Initialize and return instance of this class as an object.""" 38 self = cls( 39 session, 40 storage_manager, 41 ) 42 self.initialize() 43 return self
Initialize and return instance of this class as an object.
45 def fetch_info(self) -> dict[str, str]: 46 """ 47 Fetch minimal account and meters information. 48 49 Fetch minimal account and meters information, parse data, 50 initialize class attributes and return as json. 51 """ 52 logger.debug(f"[{self.reg_no}] Fetching account details") 53 54 response = self.session.get("/Home") 55 response_content = response.read() 56 data = AccountInfoParser.parse(response_content) 57 58 logger.debug(f"[{self.reg_no}] Fetched account details") 59 return data
Fetch minimal account and meters information.
Fetch minimal account and meters information, parse data, initialize class attributes and return as json.
61 def update_from_json(self, data: dict[str, str]) -> None: 62 """Initialize base attributes from a json/dict data.""" 63 super().update_from_json(data) 64 65 if not hasattr(self, "meters") or self.get_meters() == []: 66 self.meters = [] 67 for meter_data in data.get("meters", []): 68 meter = USMSMeter.create(self, meter_data) 69 self.meters.append(meter)
Initialize base attributes from a json/dict data.
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 return method(self, *args, **kwargs)
Log the user out of the USMS session by clearing session cookies.
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 return method(self, *args, **kwargs)
Log in the user.
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 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.
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 return method(self, *args, **kwargs)
Fetch new data and update the meter info.
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 return method(self, *args, **kwargs)
Refresh data if an update is due, then return True if update successful.
21class USMSClient(USMSClientASPStateMixin, USMSClientAuthMixin): 22 """USMS Client for interacting with USMS.""" 23 24 BASE_URL = "https://www.usms.com.bn/SmartMeter/" 25 26 def __init__( 27 self, 28 username: str, 29 password: str, 30 client: "HTTPXClientProtocol", 31 ) -> None: 32 """Initialize USMS Client.""" 33 # Initialize mixin classes 34 USMSClientAuthMixin.__init__(self, username=username, password=password) 35 USMSClientASPStateMixin.__init__(self) 36 37 client.follow_redirects = True 38 self.async_mode = inspect.iscoroutinefunction(client.get) 39 40 self.client = client 41 42 def get(self, url: str, **kwargs: Any) -> Callable: 43 """Return a sync/async GET request method.""" 44 if self.async_mode: 45 return self._request_async("get", url, **kwargs) # has to be awaited 46 return self._request_sync("get", url, **kwargs) 47 48 def post(self, url: str, **kwargs: Any) -> Callable: 49 """Return a sync/async POST request method, with ASP.net state injection.""" 50 kwargs["data"] = self._inject_asp_state(kwargs.get("data", {})) 51 52 if self.async_mode: 53 return self._request_async("post", url, **kwargs) # has to be awaited 54 return self._request_sync("post", url, **kwargs) 55 56 def _request_sync(self, http_method: str, url: str, **kwargs: Any) -> "HTTPXResponseProtocol": 57 """Send sync HTTP request, with URL building, auto-reauth and ASP.net state extraction.""" 58 if not url.startswith("http"): 59 url = f"{self.BASE_URL}{url}" 60 61 request_method = getattr(self.client, http_method.lower()) 62 63 for _ in range(3): 64 response = request_method(url, **kwargs) 65 if self.is_expired(response): 66 self.authenticate() 67 else: 68 break 69 70 response_content = response.read() 71 self._extract_asp_state(response_content) 72 73 return response 74 75 async def _request_async( 76 self, http_method: str, url: str, **kwargs: Any 77 ) -> "HTTPXResponseProtocol": 78 """Send async HTTP request, with URL building, auto-reauth and ASP.net state extraction.""" 79 if not url.startswith("http"): 80 url = f"{self.BASE_URL}{url}" 81 82 request_method = getattr(self.client, http_method.lower()) 83 84 for _ in range(3): 85 response = await request_method(url, **kwargs) 86 if await self.is_expired(response): 87 await self.authenticate() 88 else: 89 break 90 91 response_content = await response.aread() 92 self._extract_asp_state(response_content) 93 94 return response 95 96 @property 97 def username(self) -> str: 98 """Account username.""" 99 return self._username
USMS Client for interacting with USMS.
26 def __init__( 27 self, 28 username: str, 29 password: str, 30 client: "HTTPXClientProtocol", 31 ) -> None: 32 """Initialize USMS Client.""" 33 # Initialize mixin classes 34 USMSClientAuthMixin.__init__(self, username=username, password=password) 35 USMSClientASPStateMixin.__init__(self) 36 37 client.follow_redirects = True 38 self.async_mode = inspect.iscoroutinefunction(client.get) 39 40 self.client = client
Initialize USMS Client.
42 def get(self, url: str, **kwargs: Any) -> Callable: 43 """Return a sync/async GET request method.""" 44 if self.async_mode: 45 return self._request_async("get", url, **kwargs) # has to be awaited 46 return self._request_sync("get", url, **kwargs)
Return a sync/async GET request method.
48 def post(self, url: str, **kwargs: Any) -> Callable: 49 """Return a sync/async POST request method, with ASP.net state injection.""" 50 kwargs["data"] = self._inject_asp_state(kwargs.get("data", {})) 51 52 if self.async_mode: 53 return self._request_async("post", url, **kwargs) # has to be awaited 54 return self._request_sync("post", url, **kwargs)
Return a sync/async POST request method, with ASP.net state injection.
25class USMSMeter(BaseUSMSMeter): 26 """Sync USMS Meter Service that inherits BaseUSMSMeter.""" 27 28 def initialize(self, data: dict[str, str]) -> None: 29 """Fetch meter info and then set initial class attributes.""" 30 logger.debug(f"[{self._account.reg_no}] Initializing meter") 31 self.update_from_json(data) 32 super().initialize() 33 34 if self.storage_manager is not None: 35 consumptions = self.storage_manager.get_all_consumptions(self.no) 36 self.hourly_consumptions = consumptions_storage_to_dataframe(consumptions) 37 38 self.hourly_consumptions.rename( 39 columns={"consumption": self.unit}, 40 inplace=True, 41 ) 42 43 logger.debug(f"[{self._account.reg_no}] Initialized meter") 44 45 @classmethod 46 def create(cls, account: "USMSAccount", data: dict[str, str]) -> "USMSMeter": 47 """Initialize and return instance of this class as an object.""" 48 self = cls(account) 49 self.initialize(data) 50 return self 51 52 @requires_init 53 def fetch_hourly_consumptions( 54 self, 55 date: datetime, 56 *, 57 force_refresh: bool = False, 58 ) -> pd.Series: 59 """Fetch hourly consumptions for a given date and return as pd.Series.""" 60 date = sanitize_date(date) 61 62 if not force_refresh: 63 day_consumption = self.get_hourly_consumptions(date) 64 if not day_consumption.empty: 65 return day_consumption 66 67 logger.debug(f"[{self.no}] Fetching consumptions for: {date.date()}") 68 # build payload and perform requests 69 payload = self._build_hourly_consumptions_payload(date) 70 self.session.get(f"/Report/UsageHistory?p={self.id}") 71 self.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 72 payload = self._build_hourly_consumptions_payload(date) 73 response = self.session.post( 74 f"/Report/UsageHistory?p={self.id}", 75 data=payload, 76 ) 77 response_content = response.read() 78 79 error_message = ErrorMessageParser.parse(response_content).get("error_message") 80 if error_message == "consumption history not found.": 81 # this error message is somehow not always true 82 # ignore it for now, and check for the table properly instead 83 pass 84 elif error_message is not None and error_message != "": 85 logger.error(f"[{self.no}] Error fetching consumptions: {error_message}") 86 87 hourly_consumptions = MeterConsumptionsParser.parse(response_content) 88 89 # convert dict to pd.DataFrame 90 hourly_consumptions = pd.DataFrame.from_dict( 91 hourly_consumptions, 92 dtype=float, 93 orient="index", 94 columns=[self.unit], 95 ) 96 97 if hourly_consumptions.empty: 98 logger.warning(f"[{self.no}] No consumptions data for : {date.date()}") 99 return hourly_consumptions[self.unit] 100 101 hourly_consumptions.index = pd.to_datetime( 102 [date + timedelta(hours=int(hour) - 1) for hour in hourly_consumptions.index] 103 ) 104 hourly_consumptions = hourly_consumptions.asfreq("h") 105 hourly_consumptions["last_checked"] = datetime.now().astimezone() 106 107 if self.storage_manager is not None: 108 self.store_consumptions(hourly_consumptions) 109 110 self.hourly_consumptions = hourly_consumptions.combine_first(self.hourly_consumptions) 111 112 logger.debug(f"[{self.no}] Fetched consumptions for: {date.date()}") 113 return hourly_consumptions[self.unit] 114 115 @requires_init 116 def fetch_daily_consumptions( 117 self, 118 date: datetime, 119 *, 120 force_refresh: bool = False, 121 ) -> pd.Series: 122 """Fetch daily consumptions for a given date and return as pd.Series.""" 123 date = sanitize_date(date) 124 125 if not force_refresh: 126 month_consumption = self.get_daily_consumptions(date) 127 if not month_consumption.empty: 128 return month_consumption 129 130 logger.debug(f"[{self.no}] Fetching consumptions for: {date.year}-{date.month}") 131 # build payload and perform requests 132 payload = self._build_daily_consumptions_payload(date) 133 134 self.session.get(f"/Report/UsageHistory?p={self.id}") 135 self.session.post(f"/Report/UsageHistory?p={self.id}") 136 self.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 137 response = self.session.post(f"/Report/UsageHistory?p={self.id}", data=payload) 138 response_content = response.read() 139 140 error_message = ErrorMessageParser.parse(response_content).get("error_message") 141 if error_message: 142 daily_consumptions = new_consumptions_dataframe(self.unit, "D") 143 else: 144 daily_consumptions = MeterConsumptionsParser.parse(response_content) 145 146 # convert dict to pd.DataFrame 147 daily_consumptions = pd.DataFrame.from_dict( 148 daily_consumptions, 149 dtype=float, 150 orient="index", 151 columns=[self.unit], 152 ) 153 daily_consumptions.index = pd.to_datetime( 154 [f"{date.year}-{date.month:02d}-{int(day) + 1}" for day in daily_consumptions.index] 155 ) 156 daily_consumptions = daily_consumptions.asfreq("D") 157 daily_consumptions["last_checked"] = datetime.now().astimezone() 158 159 if daily_consumptions.empty: 160 logger.warning(f"[{self.no}] No consumptions data for : {date.year}-{date.month}") 161 return daily_consumptions[self.unit] 162 163 self.daily_consumptions = daily_consumptions.combine_first(self.daily_consumptions) 164 165 logger.debug(f"[{self.no}] Fetched consumptions for: {date.year}-{date.month}") 166 return daily_consumptions[self.unit] 167 168 @requires_init 169 def get_previous_n_month_consumptions(self, n: int = 0) -> pd.Series: 170 """ 171 Return the consumptions for previous n month. 172 173 e.g. 174 n=0 : data for this month only 175 n=1 : data for previous month only 176 n=2 : data for previous 2 months only 177 """ 178 date = datetime.now().astimezone() 179 for _ in range(n): 180 date = date.replace(day=1) 181 date = date - timedelta(days=1) 182 return self.fetch_daily_consumptions(date) 183 184 @requires_init 185 def get_last_n_days_hourly_consumptions(self, n: int = 0) -> pd.Series: 186 """ 187 Return the hourly unit consumptions for the last n days accumulatively. 188 189 e.g. 190 n=0 : data for today 191 n=1 : data from yesterday until today 192 n=2 : data from 2 days ago until today 193 """ 194 last_n_days_hourly_consumptions = new_consumptions_dataframe( 195 self.unit, 196 "h", 197 )[self.unit] 198 199 upper_date = datetime.now().astimezone() 200 lower_date = upper_date - timedelta(days=n) 201 for i in range(n + 1): 202 date = lower_date + timedelta(days=i) 203 hourly_consumptions = self.fetch_hourly_consumptions(date) 204 205 if not hourly_consumptions.empty: 206 last_n_days_hourly_consumptions = hourly_consumptions.combine_first( 207 last_n_days_hourly_consumptions 208 ) 209 210 if n > 3: # noqa: PLR2004 211 progress = round((i + 1) / (n + 1) * 100, 1) 212 logger.info( 213 f"[{self.no}] Getting last {n} days hourly consumptions progress: {(i + 1)} out of {(n + 1)}, {progress}%" 214 ) 215 216 return last_n_days_hourly_consumptions 217 218 @requires_init 219 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().astimezone() 224 lower_date = 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 self.fetch_hourly_consumptions(date) 229 progress = round((i + 1) / range_date * 100, 1) 230 logger.info( 231 f"[{self.no}] Getting all hourly consumptions progress: {(i + 1)} out of {range_date}, {progress}%" 232 ) 233 234 return self.hourly_consumptions[self.unit] 235 236 @requires_init 237 def find_earliest_consumption_date(self) -> datetime: 238 """Determine the earliest date for which hourly consumption data is available.""" 239 if self.earliest_consumption_date is not None: 240 return self.earliest_consumption_date 241 242 now = datetime.now().astimezone() 243 if self.hourly_consumptions.empty: 244 for i in range(7): 245 date = now - timedelta(days=i) 246 hourly_consumptions = self.fetch_hourly_consumptions(date) 247 if not hourly_consumptions.empty: 248 break 249 else: 250 date = self.hourly_consumptions.index.min() 251 logger.info(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.info(f"[{self.no}] Stepping {step} days from {date}") 262 elif step == 1: 263 if self.hourly_consumptions.empty: 264 logger.error(f"[{self.no}] Cannot determine earliest available date") 265 return now 266 # Already at base step, this is the earliest available data 267 date += timedelta(days=step) 268 self.earliest_consumption_date = date 269 logger.info(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 276 277 @requires_init 278 def store_consumptions(self, consumptions: pd.DataFrame) -> None: 279 """Insert consumptions in the given dataframe to the database.""" 280 new_statistics_df = dataframe_diff(self.hourly_consumptions, consumptions) 281 282 for row in new_statistics_df.itertuples(index=True, name="Row"): 283 self.storage_manager.insert_or_replace( 284 meter_no=self.no, 285 timestamp=int(row.Index.timestamp()), 286 consumption=getattr(row, self.unit), 287 last_checked=int(row.last_checked.timestamp()), 288 )
Sync USMS Meter Service that inherits BaseUSMSMeter.
28 def initialize(self, data: dict[str, str]) -> None: 29 """Fetch meter info and then set initial class attributes.""" 30 logger.debug(f"[{self._account.reg_no}] Initializing meter") 31 self.update_from_json(data) 32 super().initialize() 33 34 if self.storage_manager is not None: 35 consumptions = self.storage_manager.get_all_consumptions(self.no) 36 self.hourly_consumptions = consumptions_storage_to_dataframe(consumptions) 37 38 self.hourly_consumptions.rename( 39 columns={"consumption": self.unit}, 40 inplace=True, 41 ) 42 43 logger.debug(f"[{self._account.reg_no}] Initialized meter")
Fetch meter info and then set initial class attributes.
45 @classmethod 46 def create(cls, account: "USMSAccount", data: dict[str, str]) -> "USMSMeter": 47 """Initialize and return instance of this class as an object.""" 48 self = cls(account) 49 self.initialize(data) 50 return self
Initialize and return instance of this class as an object.
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 return method(self, *args, **kwargs)
Fetch hourly consumptions for a given date and return as pd.Series.
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 return method(self, *args, **kwargs)
Fetch daily consumptions for a given date and return as pd.Series.
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 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
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 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
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 return method(self, *args, **kwargs)
Get the hourly unit consumptions for all days and months.
12 def wrapper(self, *args, **kwargs): 13 if not getattr(self, "_initialized", False): 14 raise USMSNotInitializedError(self.__class__.__name__) 15 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.
71def get_storage_manager(storage_type: str, storage_path: Path | None = None) -> BaseUSMSStorage: 72 """Return the storage manager based on given storage type and path.""" 73 if "sql" in storage_type.lower(): 74 if storage_path is None: 75 return SQLiteUSMSStorage(Path("usms.db")) 76 return SQLiteUSMSStorage(storage_path) 77 78 if "csv" in storage_type.lower(): 79 if storage_path is None: 80 return CSVUSMSStorage(Path("usms.csv")) 81 return CSVUSMSStorage(storage_path) 82 83 raise USMSUnsupportedStorageError(storage_type)
Return the storage manager based on given storage type and path.
23def initialize_usms_account( # noqa: PLR0913 24 username: str | None = None, 25 password: str | None = None, 26 client: "HTTPXClientProtocol" = None, 27 usms_client: "USMSClient" = None, 28 storage_type: str | None = None, 29 storage_path: str | None = None, 30 storage_manager: "BaseUSMSStorage" = None, 31 async_mode: bool | None = None, 32) -> "BaseUSMSAccount": 33 """ 34 Initialize and return a USMSAccount or AsyncUSMSAccount instance. 35 36 This factory method provides a flexible way to create USMS accounts, 37 supporting both synchronous and asynchronous modes. It allows for 38 custom authentication, HTTP clients, and storage management. 39 40 Parameters 41 ---------- 42 username : str | None 43 Username for USMS authentication. Required if `usms_client` is not provided. 44 password : str | None 45 Password for USMS authentication. Required if `usms_client` is not provided. 46 client : HTTPXClientProtocol | None 47 A HTTPX client (sync or async) to use for USMS requests. 48 usms_client : BaseUSMSClient | None 49 Initialized USMSClient or AsyncUSMSClient instance. 50 storage_type : str | None 51 Type of storage for data persistence (e.g., 'csv', 'json'). 52 storage_path : str | None 53 File path for the storage file (if applicable). 54 storage_manager : BaseUSMSStorage | None 55 A pre-initialized storage manager instance. 56 async_mode : bool | None 57 Whether to use asynchronous mode. If True, has to be awaited. 58 59 Returns 60 ------- 61 USMSAccount | AsyncUSMSAccount 62 A fully initialized USMS account object. 63 64 Raises 65 ------ 66 USMSMissingCredentialsError 67 If neither `auth` nor both `username` and `password` are provided. 68 USMSIncompatibleAsyncModeError 69 If async_mode is incompatible with the provided client or client mode. 70 71 Examples 72 -------- 73 # Synchronous usage with automatic configuration: 74 account = initialize_usms_account(username="username", password="password") 75 76 # Asynchronous usage: 77 account = await initialize_usms_account( 78 username="username", 79 password="password", 80 async_mode=True, 81 ) 82 83 # Custom client and storage: 84 import httpx 85 from usms.utils.helpers import get_storage_manager 86 storage_manager = get_storage_manager(storage_type="csv") 87 account = initialize_usms_account( 88 username="username", 89 password="password", 90 client=httpx.Client(), 91 storage_manager=storage_manager, 92 ) 93 """ 94 if not isinstance(usms_client, USMSClient): 95 if username is None and password is None: 96 raise USMSMissingCredentialsError 97 98 if not isinstance(client, HTTPXClientProtocol): 99 import httpx 100 101 client = httpx.AsyncClient(http2=True) if async_mode else httpx.Client(http2=True) 102 103 usms_client = USMSClient(client=client, username=username, password=password) 104 105 if async_mode is not None: 106 if async_mode != usms_client.async_mode: 107 raise USMSIncompatibleAsyncModeError 108 else: 109 async_mode = usms_client.async_mode 110 111 if not isinstance(storage_manager, BaseUSMSStorage) and storage_type is not None: 112 storage_manager = get_storage_manager(storage_type=storage_type, storage_path=storage_path) 113 114 if async_mode: 115 return AsyncUSMSAccount.create(session=usms_client, storage_manager=storage_manager) 116 return USMSAccount.create(session=usms_client, storage_manager=storage_manager)
Initialize and return a USMSAccount or AsyncUSMSAccount instance.
This factory method provides a flexible way to create USMS accounts, supporting both synchronous and asynchronous modes. It allows for custom authentication, HTTP clients, and storage management.
Parameters
- username (str | None):
Username for USMS authentication. Required if
usms_client
is not provided. - password (str | None):
Password for USMS authentication. Required if
usms_client
is not provided. - client (HTTPXClientProtocol | None): A HTTPX client (sync or async) to use for USMS requests.
- usms_client (BaseUSMSClient | None): Initialized USMSClient or AsyncUSMSClient instance.
- storage_type (str | None): Type of storage for data persistence (e.g., 'csv', 'json').
- storage_path (str | None): File path for the storage file (if applicable).
- storage_manager (BaseUSMSStorage | None): A pre-initialized storage manager instance.
- async_mode (bool | None): Whether to use asynchronous mode. If True, has to be awaited.
Returns
- USMSAccount | AsyncUSMSAccount: A fully initialized USMS account object.
Raises
- USMSMissingCredentialsError: If neither
auth
nor bothusername
andpassword
are provided. - USMSIncompatibleAsyncModeError: If async_mode is incompatible with the provided client or client mode.
Examples
Synchronous usage with automatic configuration:
account = initialize_usms_account(username="username", password="password")
Asynchronous usage:
account = await initialize_usms_account( username="username", password="password", async_mode=True, )
Custom client and storage:
import httpx from usms.utils.helpers import get_storage_manager storage_manager = get_storage_manager(storage_type="csv") account = initialize_usms_account( username="username", password="password", client=httpx.Client(), storage_manager=storage_manager, )