usms

USMS: A client library for interacting with the utility portal.

This package provides programmatic access to login, retrieve meter information, fetch billing details, and more from the USMS platform.

 1"""
 2USMS: A client library for interacting with the utility portal.
 3
 4This package provides programmatic access to login, retrieve meter information,
 5fetch billing details, and more from the USMS platform.
 6"""
 7
 8from usms.config.constants import BRUNEI_TZ, TARIFFS, UNITS
 9from usms.core.client import AsyncUSMSClient, USMSClient
10from usms.models.tariff import USMSTariff, USMSTariffTier
11from usms.services.async_.account import AsyncUSMSAccount
12from usms.services.async_.meter import AsyncUSMSMeter
13from usms.services.sync.account import USMSAccount
14from usms.services.sync.meter import USMSMeter
15
16__all__ = [
17    "BRUNEI_TZ",
18    "TARIFFS",
19    "UNITS",
20    "AsyncUSMSAccount",
21    "AsyncUSMSClient",
22    "AsyncUSMSMeter",
23    "USMSAccount",
24    "USMSClient",
25    "USMSMeter",
26    "USMSTariff",
27    "USMSTariffTier",
28]
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.services.account.BaseUSMSAccount):
 16class AsyncUSMSAccount(BaseUSMSAccount):
 17    """Async USMS Account Service that inherits BaseUSMSAccount."""
 18
 19    session: AsyncUSMSClient
 20
 21    async def initialize(self):
 22        """Initialize session object, fetch account info and set class attributes."""
 23        logger.debug(f"[{self.username}] Initializing account {self.username}")
 24
 25        self.session = await AsyncUSMSClient.create(self.auth)
 26
 27        data = await self.fetch_info()
 28        await self.from_json(data)
 29
 30        self._initialized = True
 31        logger.debug(f"[{self.username}] Initialized account")
 32
 33    @classmethod
 34    async def create(cls, username: str, password: str) -> "AsyncUSMSAccount":
 35        """Initialize and return instance of this class as an object."""
 36        self = cls(username, password)
 37        await self.initialize()
 38        return self
 39
 40    async def fetch_more_info(self) -> dict:
 41        """Fetch account information, parse data, initialize class attributes and return as json."""
 42        logger.debug(f"[{self.username}] Fetching more account details")
 43
 44        response = await self.session.get("/AccountInfo")
 45        data = self.parse_more_info(response)
 46
 47        logger.debug(f"[{self.username}] Fetched more account details")
 48        return data
 49
 50    async def fetch_info(self) -> dict:
 51        """
 52        Fetch minimal account and meters information.
 53
 54        Fetch minimal account and meters information, parse data,
 55        initialize class attributes and return as json.
 56        """
 57        logger.debug(f"[{self.username}] Fetching account details")
 58
 59        response = await self.session.get("/Home")
 60        data = self.parse_info(response)
 61
 62        logger.debug(f"[{self.username}] Fetched account details")
 63        return data
 64
 65    async def from_json(self, data: dict) -> None:
 66        """Initialize base attributes from a json/dict data."""
 67        super().from_json(data)
 68
 69        if not hasattr(self, "meters") or self.get_meters() == []:
 70            self.meters = []
 71            for meter_data in data.get("meters", []):
 72                meter = await AsyncUSMSMeter.create(self, meter_data)
 73                self.meters.append(meter)
 74
 75    @requires_init
 76    async def log_out(self) -> bool:
 77        """Log the user out of the USMS session by clearing session cookies."""
 78        logger.debug(f"[{self.username}] Logging out {self.username}...")
 79
 80        await self.session.get("/ResLogin")
 81        self.session.cookies = {}
 82
 83        if not await self.is_authenticated():
 84            logger.debug(f"[{self.username}] Log out successful")
 85            return True
 86
 87        logger.error(f"[{self.username}] Log out fail")
 88        return False
 89
 90    @requires_init
 91    async def log_in(self) -> bool:
 92        """Log in the user."""
 93        logger.debug(f"[{self.username}] Logging in {self.username}...")
 94
 95        await self.session.get("/AccountInfo")
 96
 97        if await self.is_authenticated():
 98            logger.debug(f"[{self.username}] Log in successful")
 99            return True
100
101        logger.error(f"[{self.username}] Log in fail")
102        return False
103
104    @requires_init
105    async def is_authenticated(self) -> bool:
106        """
107        Check if the current session is authenticated.
108
109        Check if the current session is authenticated
110        by sending a request without retrying or triggering auth logic.
111        """
112        is_authenticated = False
113        try:
114            response = await self.session.get("/AccountInfo", auth=None)
115            is_authenticated = not self.auth.is_expired(response)
116        except httpx.HTTPError as error:
117            logger.error(f"[{self.username}] Login check failed: {error}")
118
119        if is_authenticated:
120            logger.debug(f"[{self.username}] Account is authenticated")
121        else:
122            logger.debug(f"[{self.username}] Account is NOT authenticated")
123        return is_authenticated
124
125    @requires_init
126    async def refresh_data(self) -> bool:
127        """Fetch new data and update the meter info."""
128        logger.debug(f"[{self.username}] Checking for updates")
129
130        try:
131            fresh_info = await self.fetch_info()
132        except Exception as error:  # noqa: BLE001
133            logger.error(f"[{self.username}] Failed to fetch update with error: {error}")
134            return False
135
136        self.last_refresh = datetime.now(tz=BRUNEI_TZ)
137
138        for meter in fresh_info.get("meters", []):
139            if meter.get("last_update") > self.get_latest_update():
140                logger.debug(f"[{self.username}] New updates found")
141                await self.from_json(fresh_info)
142                return True
143
144        logger.debug(f"[{self.username}] No new updates found")
145        return False
146
147    @requires_init
148    async def check_update_and_refresh(self) -> bool:
149        """Refresh data if an update is due, then return True if update successful."""
150        try:
151            if self.is_update_due():
152                return await self.refresh_data()
153        except Exception as error:  # noqa: BLE001
154            logger.error(f"[{self.username}] Failed to fetch update with error: {error}")
155            return False
156
157        # Update not dued, data not refreshed
158        return False

Async USMS Account Service that inherits BaseUSMSAccount.

session: AsyncUSMSClient
async def initialize(self):
21    async def initialize(self):
22        """Initialize session object, fetch account info and set class attributes."""
23        logger.debug(f"[{self.username}] Initializing account {self.username}")
24
25        self.session = await AsyncUSMSClient.create(self.auth)
26
27        data = await self.fetch_info()
28        await self.from_json(data)
29
30        self._initialized = True
31        logger.debug(f"[{self.username}] Initialized account")

Initialize session object, fetch account info and set class attributes.

@classmethod
async def create( cls, username: str, password: str) -> AsyncUSMSAccount:
33    @classmethod
34    async def create(cls, username: str, password: str) -> "AsyncUSMSAccount":
35        """Initialize and return instance of this class as an object."""
36        self = cls(username, password)
37        await self.initialize()
38        return self

Initialize and return instance of this class as an object.

async def fetch_more_info(self) -> dict:
40    async def fetch_more_info(self) -> dict:
41        """Fetch account information, parse data, initialize class attributes and return as json."""
42        logger.debug(f"[{self.username}] Fetching more account details")
43
44        response = await self.session.get("/AccountInfo")
45        data = self.parse_more_info(response)
46
47        logger.debug(f"[{self.username}] Fetched more account details")
48        return data

Fetch account information, parse data, initialize class attributes and return as json.

async def fetch_info(self) -> dict:
50    async def fetch_info(self) -> dict:
51        """
52        Fetch minimal account and meters information.
53
54        Fetch minimal account and meters information, parse data,
55        initialize class attributes and return as json.
56        """
57        logger.debug(f"[{self.username}] Fetching account details")
58
59        response = await self.session.get("/Home")
60        data = self.parse_info(response)
61
62        logger.debug(f"[{self.username}] Fetched account details")
63        return data

Fetch minimal account and meters information.

Fetch minimal account and meters information, parse data, initialize class attributes and return as json.

async def from_json(self, data: dict) -> None:
65    async def from_json(self, data: dict) -> None:
66        """Initialize base attributes from a json/dict data."""
67        super().from_json(data)
68
69        if not hasattr(self, "meters") or self.get_meters() == []:
70            self.meters = []
71            for meter_data in data.get("meters", []):
72                meter = await AsyncUSMSMeter.create(self, meter_data)
73                self.meters.append(meter)

Initialize base attributes from a json/dict data.

def log_out(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)
def log_in(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)
def is_authenticated(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)
def refresh_data(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)
def check_update_and_refresh(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)
class AsyncUSMSClient(usms.core.client.BaseUSMSClient, httpx.AsyncClient):
 99class AsyncUSMSClient(BaseUSMSClient, httpx.AsyncClient):
100    """Async HTTP client for interacting with USMS."""
101
102    async def initialize(self) -> None:
103        """Actual initialization logic of Client object."""
104        self.ssl_context = await create_ssl_context()
105        super().initialize()
106
107    @classmethod
108    async def create(cls, auth: USMSAuth) -> "AsyncUSMSClient":
109        """Initialize and return instance of this class as an object."""
110        self = cls(auth)
111        await self.initialize()
112        return self
113
114    @requires_init
115    async def post(self, url: str, data: dict | None = None) -> httpx.Response:
116        """Send a POST request with ASP.NET hidden fields included."""
117        return await super().post(url=url, data=data)
118
119    @requires_init
120    async def _update_asp_state(self, response: httpx.Response) -> None:
121        """Extract ASP.NET hidden fields from responses to maintain session state."""
122        super()._extract_asp_state(await response.aread())

Async HTTP client for interacting with USMS.

async def initialize(self) -> None:
102    async def initialize(self) -> None:
103        """Actual initialization logic of Client object."""
104        self.ssl_context = await create_ssl_context()
105        super().initialize()

Actual initialization logic of Client object.

@classmethod
async def create(cls, auth: usms.core.auth.USMSAuth) -> AsyncUSMSClient:
107    @classmethod
108    async def create(cls, auth: USMSAuth) -> "AsyncUSMSClient":
109        """Initialize and return instance of this class as an object."""
110        self = cls(auth)
111        await self.initialize()
112        return self

Initialize and return instance of this class as an object.

def post(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)

Send a POST request with ASP.NET hidden fields included.

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

Async USMS Meter Service that inherits BaseUSMSMeter.

async def initialize(self, data: dict):
22    async def initialize(self, data: dict):
23        """Fetch meter info and then set initial class attributes."""
24        logger.debug(f"[{self._account.username}] Initializing meter")
25        self.from_json(data)
26        super().initialize()
27        logger.debug(f"[{self._account.username}] Initialized meter")

Fetch meter info and then set initial class attributes.

@classmethod
async def create( cls, account: AsyncUSMSAccount, data: dict) -> AsyncUSMSMeter:
29    @classmethod
30    async def create(cls, account: "AsyncUSMSAccount", data: dict) -> "AsyncUSMSMeter":
31        """Initialize and return instance of this class as an object."""
32        self = cls(account)
33        await self.initialize(data)
34        return self

Initialize and return instance of this class as an object.

def fetch_hourly_consumptions(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)
def fetch_daily_consumptions(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)
def get_previous_n_month_consumptions(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)
def get_last_n_days_hourly_consumptions(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)
def get_all_hourly_consumptions(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)
def find_earliest_consumption_date(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)
class USMSAccount(usms.services.account.BaseUSMSAccount):
 16class USMSAccount(BaseUSMSAccount):
 17    """Sync USMS Account Service that inherits BaseUSMSAccount."""
 18
 19    session: USMSClient
 20
 21    def initialize(self):
 22        """Initialize session object, fetch account info and set class attributes."""
 23        logger.debug(f"[{self.username}] Initializing account {self.username}")
 24
 25        self.session = USMSClient.create(self.auth)
 26
 27        data = self.fetch_info()
 28        self.from_json(data)
 29
 30        self._initialized = True
 31        logger.debug(f"[{self.username}] Initialized account")
 32
 33    @classmethod
 34    def create(cls, username: str, password: str) -> "USMSAccount":
 35        """Initialize and return instance of this class as an object."""
 36        self = cls(username, password)
 37        self.initialize()
 38        return self
 39
 40    def fetch_more_info(self) -> dict:
 41        """Fetch account information, parse data, initialize class attributes and return as json."""
 42        logger.debug(f"[{self.username}] Fetching more account details")
 43
 44        response = self.session.get("/AccountInfo")
 45        data = self.parse_more_info(response)
 46
 47        logger.debug(f"[{self.username}] Fetched more account details")
 48        return data
 49
 50    def fetch_info(self) -> dict:
 51        """
 52        Fetch minimal account and meters information.
 53
 54        Fetch minimal account and meters information, parse data,
 55        initialize class attributes and return as json.
 56        """
 57        logger.debug(f"[{self.username}] Fetching account details")
 58
 59        response = self.session.get("/Home")
 60        data = self.parse_info(response)
 61
 62        logger.debug(f"[{self.username}] Fetched account details")
 63        return data
 64
 65    def from_json(self, data: dict) -> None:
 66        """Initialize base attributes from a json/dict data."""
 67        super().from_json(data)
 68
 69        if not hasattr(self, "meters") or self.get_meters() == []:
 70            self.meters = []
 71            for meter_data in data.get("meters", []):
 72                meter = USMSMeter.create(self, meter_data)
 73                self.meters.append(meter)
 74
 75    @requires_init
 76    def log_out(self) -> bool:
 77        """Log the user out of the USMS session by clearing session cookies."""
 78        logger.debug(f"[{self.username}] Logging out {self.username}...")
 79
 80        self.session.get("/ResLogin")
 81        self.session.cookies = {}
 82
 83        if not self.is_authenticated():
 84            logger.debug(f"[{self.username}] Log out successful")
 85            return True
 86
 87        logger.error(f"[{self.username}] Log out fail")
 88        return False
 89
 90    @requires_init
 91    def log_in(self) -> bool:
 92        """Log in the user."""
 93        logger.debug(f"[{self.username}] Logging in {self.username}...")
 94
 95        self.session.get("/AccountInfo")
 96
 97        if self.is_authenticated():
 98            logger.debug(f"[{self.username}] Log in successful")
 99            return True
100
101        logger.error(f"[{self.username}] Log in fail")
102        return False
103
104    @requires_init
105    def is_authenticated(self) -> bool:
106        """
107        Check if the current session is authenticated.
108
109        Check if the current session is authenticated
110        by sending a request without retrying or triggering auth logic.
111        """
112        is_authenticated = False
113        try:
114            response = self.session.get("/AccountInfo", auth=None)
115            is_authenticated = not self.auth.is_expired(response)
116        except httpx.HTTPError as error:
117            logger.error(f"[{self.username}] Login check failed: {error}")
118
119        if is_authenticated:
120            logger.debug(f"[{self.username}] Account is authenticated")
121        else:
122            logger.debug(f"[{self.username}] Account is NOT authenticated")
123        return is_authenticated
124
125    @requires_init
126    def refresh_data(self) -> bool:
127        """Fetch new data and update the meter info."""
128        logger.debug(f"[{self.username}] Checking for updates")
129
130        try:
131            fresh_info = self.fetch_info()
132        except Exception as error:  # noqa: BLE001
133            logger.error(f"[{self.username}] Failed to fetch update with error: {error}")
134            return False
135
136        self.last_refresh = datetime.now(tz=BRUNEI_TZ)
137
138        for meter in fresh_info.get("meters", []):
139            if meter.get("last_update") > self.get_latest_update():
140                logger.debug(f"[{self.username}] New updates found")
141                self.from_json(fresh_info)
142                return True
143
144        logger.debug(f"[{self.username}] No new updates found")
145        return False
146
147    @requires_init
148    def check_update_and_refresh(self) -> bool:
149        """Refresh data if an update is due, then return True if update successful."""
150        try:
151            if self.is_update_due():
152                return self.refresh_data()
153        except Exception as error:  # noqa: BLE001
154            logger.error(f"[{self.username}] Failed to fetch update with error: {error}")
155            return False
156
157        # Update not dued, data not refreshed
158        return False

Sync USMS Account Service that inherits BaseUSMSAccount.

session: USMSClient
def initialize(self):
21    def initialize(self):
22        """Initialize session object, fetch account info and set class attributes."""
23        logger.debug(f"[{self.username}] Initializing account {self.username}")
24
25        self.session = USMSClient.create(self.auth)
26
27        data = self.fetch_info()
28        self.from_json(data)
29
30        self._initialized = True
31        logger.debug(f"[{self.username}] Initialized account")

Initialize session object, fetch account info and set class attributes.

@classmethod
def create( cls, username: str, password: str) -> USMSAccount:
33    @classmethod
34    def create(cls, username: str, password: str) -> "USMSAccount":
35        """Initialize and return instance of this class as an object."""
36        self = cls(username, password)
37        self.initialize()
38        return self

Initialize and return instance of this class as an object.

def fetch_more_info(self) -> dict:
40    def fetch_more_info(self) -> dict:
41        """Fetch account information, parse data, initialize class attributes and return as json."""
42        logger.debug(f"[{self.username}] Fetching more account details")
43
44        response = self.session.get("/AccountInfo")
45        data = self.parse_more_info(response)
46
47        logger.debug(f"[{self.username}] Fetched more account details")
48        return data

Fetch account information, parse data, initialize class attributes and return as json.

def fetch_info(self) -> dict:
50    def fetch_info(self) -> dict:
51        """
52        Fetch minimal account and meters information.
53
54        Fetch minimal account and meters information, parse data,
55        initialize class attributes and return as json.
56        """
57        logger.debug(f"[{self.username}] Fetching account details")
58
59        response = self.session.get("/Home")
60        data = self.parse_info(response)
61
62        logger.debug(f"[{self.username}] Fetched account details")
63        return data

Fetch minimal account and meters information.

Fetch minimal account and meters information, parse data, initialize class attributes and return as json.

def from_json(self, data: dict) -> None:
65    def from_json(self, data: dict) -> None:
66        """Initialize base attributes from a json/dict data."""
67        super().from_json(data)
68
69        if not hasattr(self, "meters") or self.get_meters() == []:
70            self.meters = []
71            for meter_data in data.get("meters", []):
72                meter = USMSMeter.create(self, meter_data)
73                self.meters.append(meter)

Initialize base attributes from a json/dict data.

def log_out(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)

Log the user out of the USMS session by clearing session cookies.

def log_in(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)

Log in the user.

def is_authenticated(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)

Check if the current session is authenticated.

Check if the current session is authenticated by sending a request without retrying or triggering auth logic.

def refresh_data(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)

Fetch new data and update the meter info.

def check_update_and_refresh(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)

Refresh data if an update is due, then return True if update successful.

class USMSClient(usms.core.client.BaseUSMSClient, httpx.Client):
83class USMSClient(BaseUSMSClient, httpx.Client):
84    """Sync HTTP client for interacting with USMS."""
85
86    @classmethod
87    def create(cls, auth: USMSAuth) -> "USMSClient":
88        """Initialize and return instance of this class as an object."""
89        self = cls(auth)
90        self.initialize()
91        return self
92
93    @requires_init
94    def _update_asp_state(self, response: httpx.Response) -> None:
95        """Extract ASP.NET hidden fields from responses to maintain session state."""
96        super()._extract_asp_state(response.read())

Sync HTTP client for interacting with USMS.

@classmethod
def create(cls, auth: usms.core.auth.USMSAuth) -> USMSClient:
86    @classmethod
87    def create(cls, auth: USMSAuth) -> "USMSClient":
88        """Initialize and return instance of this class as an object."""
89        self = cls(auth)
90        self.initialize()
91        return self

Initialize and return instance of this class as an object.

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

Sync USMS Meter Service that inherits BaseUSMSMeter.

def initialize(self, data: dict):
22    def initialize(self, data: dict):
23        """Fetch meter info and then set initial class attributes."""
24        logger.debug(f"[{self._account.username}] Initializing meter")
25        self.from_json(data)
26        super().initialize()
27        logger.debug(f"[{self._account.username}] Initialized meter")

Fetch meter info and then set initial class attributes.

@classmethod
def create( cls, account: USMSAccount, data: dict) -> USMSMeter:
29    @classmethod
30    def create(cls, account: "USMSAccount", data: dict) -> "USMSMeter":
31        """Initialize and return instance of this class as an object."""
32        self = cls(account)
33        self.initialize(data)
34        return self

Initialize and return instance of this class as an object.

def fetch_hourly_consumptions(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)

Fetch hourly consumptions for a given date and return as pd.Series.

def fetch_daily_consumptions(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)

Fetch daily consumptions for a given date and return as pd.Series.

def get_previous_n_month_consumptions(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)

Return the consumptions for previous n month.

e.g. n=0 : data for this month only n=1 : data for previous month only n=2 : data for previous 2 months only

def get_last_n_days_hourly_consumptions(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)

Return the hourly unit consumptions for the last n days accumulatively.

e.g. n=0 : data for today n=1 : data from yesterday until today n=2 : data from 2 days ago until today

def get_all_hourly_consumptions(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)

Get the hourly unit consumptions for all days and months.

def find_earliest_consumption_date(self, *args, **kwargs):
10    def wrapper(self, *args, **kwargs):
11        if not getattr(self, "_initialized", False):
12            raise USMSNotInitializedError(self.__class__.__name__)
13        return method(self, *args, **kwargs)

Determine the earliest date for which hourly consumption data is available.

@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