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]
BRUNEI_TZ = zoneinfo.ZoneInfo(key='Asia/Brunei')
TARIFFS = {'ELECTRIC': USMSTariff(tiers=[USMSTariffTier(lower_bound=1, upper_bound=600, rate=0.01), USMSTariffTier(lower_bound=601, upper_bound=2000, rate=0.08), USMSTariffTier(lower_bound=2001, upper_bound=4000, rate=0.1), USMSTariffTier(lower_bound=4001, upper_bound=inf, rate=0.12)]), 'WATER': USMSTariff(tiers=[USMSTariffTier(lower_bound=1, upper_bound=54.54, rate=0.11), USMSTariffTier(lower_bound=54.54, upper_bound=inf, rate=0.44)])}
UNITS = {'ELECTRIC': 'kWh', 'WATER': 'm³'}
class AsyncUSMSAccount(usms.BaseUSMSAccount):
 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.

async def initialize(self):
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.

@classmethod
async def create( cls, session: USMSClient, storage_manager: usms.storage.base_storage.BaseUSMSStorage = None) -> AsyncUSMSAccount:
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.

async def fetch_info(self) -> dict[str, str]:
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.

async def update_from_json(self, data: dict[str, str]) -> None:
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.

def log_out(self, *args, **kwargs):
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)
def log_in(self, *args, **kwargs):
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)
def is_authenticated(self, *args, **kwargs):
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)
def refresh_data(self, *args, **kwargs):
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)
def check_update_and_refresh(self, *args, **kwargs):
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)
class AsyncUSMSMeter(usms.BaseUSMSMeter):
 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.

async def initialize(self, data: dict[str, str]) -> None:
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.

@classmethod
async def create( cls, account: AsyncUSMSAccount, data: dict[str, str]) -> AsyncUSMSMeter:
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.

def fetch_hourly_consumptions(self, *args, **kwargs):
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)
def fetch_daily_consumptions(self, *args, **kwargs):
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)
def get_previous_n_month_consumptions(self, *args, **kwargs):
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)
def get_last_n_days_hourly_consumptions(self, *args, **kwargs):
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)
def get_all_hourly_consumptions(self, *args, **kwargs):
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)
def find_earliest_consumption_date(self, *args, **kwargs):
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)
def store_consumptions(self, *args, **kwargs):
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)
class BaseUSMSAccount(abc.ABC, usms.models.account.USMSAccount):
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.

BaseUSMSAccount( session: USMSClient, storage_manager: usms.storage.base_storage.BaseUSMSStorage = None)
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.

session: USMSClient
last_refresh: datetime.datetime
storage_manager
reg_no
def get_meter(self, *args, **kwargs):
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.

def get_latest_update(self, *args, **kwargs):
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 latest time a meter was updated.

def is_update_due(self, *args, **kwargs):
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 an update is due (based on last update timestamp).

class BaseUSMSMeter(abc.ABC, usms.models.meter.USMSMeter):
 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"&quot;rawValue&quot;:&quot;{epoch}&quot;" + "}"
 66        payload["cboDateTo$State"] = "{" + f"&quot;rawValue&quot;:&quot;{epoch}&quot;" + "}"
 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"&quot;rawValue&quot;:&quot;{epoch_from}&quot;" + "}"
105        payload["cboDateTo$State"] = "{" + f"&quot;rawValue&quot;:&quot;{epoch_to}&quot;" + "}"
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.

BaseUSMSMeter(account: BaseUSMSAccount)
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.

session: USMSClient
earliest_consumption_date: datetime.datetime
hourly_consumptions: pandas.core.frame.DataFrame
daily_consumptions: pandas.core.frame.DataFrame
storage_manager
def initialize(self) -> None:
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.

def get_hourly_consumptions(self, *args, **kwargs):
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.

def get_daily_consumptions(self, *args, **kwargs):
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.

def calculate_total_consumption(self, consumptions: pandas.core.series.Series) -> float:
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.

def calculate_total_cost(self, consumptions: pandas.core.series.Series) -> float:
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.

class USMSAccount(usms.BaseUSMSAccount):
 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.

def initialize(self):
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.

@classmethod
def create( cls, session: USMSClient, storage_manager: usms.storage.base_storage.BaseUSMSStorage = None) -> USMSAccount:
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.

def fetch_info(self) -> dict[str, str]:
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.

def update_from_json(self, data: dict[str, str]) -> None:
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.

def log_out(self, *args, **kwargs):
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.

def log_in(self, *args, **kwargs):
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.

def is_authenticated(self, *args, **kwargs):
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.

def refresh_data(self, *args, **kwargs):
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.

def check_update_and_refresh(self, *args, **kwargs):
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.

class USMSClient(usms.core.state_manager.USMSClientASPStateMixin, usms.core.auth.USMSClientAuthMixin):
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.

USMSClient( username: str, password: str, client: usms.core.protocols.HTTPXClientProtocol)
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.

BASE_URL = 'https://www.usms.com.bn/SmartMeter/'
async_mode
client
def get(self, url: str, **kwargs: Any) -> Callable:
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.

def post(self, url: str, **kwargs: Any) -> Callable:
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.

username: str
96    @property
97    def username(self) -> str:
98        """Account username."""
99        return self._username

Account username.

class USMSMeter(usms.BaseUSMSMeter):
 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.

def initialize(self, data: dict[str, str]) -> None:
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.

@classmethod
def create( cls, account: USMSAccount, data: dict[str, str]) -> USMSMeter:
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.

def fetch_hourly_consumptions(self, *args, **kwargs):
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.

def fetch_daily_consumptions(self, *args, **kwargs):
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.

def get_previous_n_month_consumptions(self, *args, **kwargs):
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

def get_last_n_days_hourly_consumptions(self, *args, **kwargs):
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

def get_all_hourly_consumptions(self, *args, **kwargs):
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.

def find_earliest_consumption_date(self, *args, **kwargs):
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.

def store_consumptions(self, *args, **kwargs):
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)

Insert consumptions in the given dataframe to the database.

@dataclass(frozen=True)
class USMSTariff:
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.

USMSTariff(tiers: list[USMSTariffTier])
tiers: list[USMSTariffTier]
def calculate_cost(self, consumption: float) -> float:
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.

def calculate_unit(self, cost: float) -> float:
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.

@dataclass(frozen=True)
class USMSTariffTier:
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.

USMSTariffTier(lower_bound: int, upper_bound: int | float, rate: float)
lower_bound: int
upper_bound: int | float
rate: float
def get_storage_manager( storage_type: str, storage_path: pathlib.Path | None = None) -> usms.storage.base_storage.BaseUSMSStorage:
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.

def initialize_usms_account( username: str | None = None, password: str | None = None, client: usms.core.protocols.HTTPXClientProtocol = None, usms_client: USMSClient = None, storage_type: str | None = None, storage_path: str | None = None, storage_manager: usms.storage.base_storage.BaseUSMSStorage = None, async_mode: bool | None = None) -> BaseUSMSAccount:
 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 both username and password 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, )