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': 'meter cube'}
class AsyncUSMSAccount(usms.services.account.BaseUSMSAccount):
 13class AsyncUSMSAccount(BaseUSMSAccount):
 14    """Async USMS Account Service that inherits BaseUSMSAccount."""
 15
 16    session: AsyncUSMSClient
 17
 18    async def initialize(self):
 19        """Initialize session object, fetch account info and set class attributes."""
 20        logger.debug(f"[{self.username}] Initializing account {self.username}")
 21        self.session = await AsyncUSMSClient.create(self.auth)
 22        await self.fetch_info()
 23
 24        self._initialized = True
 25        logger.debug(f"[{self.username}] Initialized account")
 26
 27    @classmethod
 28    async def create(cls, username: str, password: str) -> "AsyncUSMSAccount":
 29        """Initialize and return instance of this class as an object."""
 30        self = cls(username, password)
 31        await self.initialize()
 32        await self.initialize_meters()
 33        return self
 34
 35    @requires_init
 36    async def initialize_meters(self):
 37        """Initialize all USMSMeters under this account."""
 38        for meter in self.meters:
 39            await meter.initialize()
 40
 41    async def fetch_info(self) -> dict:
 42        """Fetch account information, parse data, initialize class attributes and return as json."""
 43        logger.debug(f"[{self.username}] Fetching account details")
 44
 45        response = await self.session.get("/AccountInfo")
 46
 47        data = self.parse_info(response)
 48        await self.from_json(data)
 49
 50        logger.debug(f"[{self.username}] Fetched account details")
 51        return data
 52
 53    async def from_json(self, data: dict) -> None:
 54        """Initialize base attributes from a json/dict data."""
 55        self.reg_no = data.get("reg_no", "")
 56        self.name = data.get("name", "")
 57        self.contact_no = data.get("contact_no", "")
 58        self.email = data.get("email", "")
 59
 60        self.meters = []
 61        for meter_node_no in data.get("meters", []):
 62            self.meters.append(AsyncUSMSMeter(self, meter_node_no))
 63
 64    @requires_init
 65    async def log_out(self) -> bool:
 66        """Log the user out of the USMS session by clearing session cookies."""
 67        logger.debug(f"[{self.username}] Logging out {self.username}...")
 68
 69        await self.session.get("/ResLogin")
 70        self.session.cookies = {}
 71
 72        if not await self.is_authenticated():
 73            logger.debug(f"[{self.username}] Log out successful")
 74            return True
 75
 76        logger.debug(f"[{self.username}] Log out fail")
 77        return False
 78
 79    @requires_init
 80    async def log_in(self) -> bool:
 81        """Log in the user."""
 82        logger.debug(f"[{self.username}] Logging in {self.username}...")
 83
 84        await self.session.get("/AccountInfo")
 85
 86        if await self.is_authenticated():
 87            logger.debug(f"[{self.username}] Log in successful")
 88            return True
 89
 90        logger.debug(f"[{self.username}] Log in fail")
 91        return False
 92
 93    @requires_init
 94    async def is_authenticated(self) -> bool:
 95        """
 96        Check if the current session is authenticated.
 97
 98        Check if the current session is authenticated
 99        by sending a request without retrying or triggering auth logic.
100        """
101        is_authenticated = False
102        try:
103            response = await self.session.get("/AccountInfo", auth=None)
104            is_authenticated = not self.auth.is_expired(response)
105        except httpx.HTTPError as error:
106            logger.error(f"[{self.username}] Login check failed: {error}")
107
108        if is_authenticated:
109            logger.debug(f"[{self.username}] Account is authenticated")
110        else:
111            logger.debug(f"[{self.username}] Account is NOT authenticated")
112        return is_authenticated

Async USMS Account Service that inherits BaseUSMSAccount.

session: AsyncUSMSClient
async def initialize(self):
18    async def initialize(self):
19        """Initialize session object, fetch account info and set class attributes."""
20        logger.debug(f"[{self.username}] Initializing account {self.username}")
21        self.session = await AsyncUSMSClient.create(self.auth)
22        await self.fetch_info()
23
24        self._initialized = True
25        logger.debug(f"[{self.username}] Initialized account")

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

@classmethod
async def create( cls, username: str, password: str) -> AsyncUSMSAccount:
27    @classmethod
28    async def create(cls, username: str, password: str) -> "AsyncUSMSAccount":
29        """Initialize and return instance of this class as an object."""
30        self = cls(username, password)
31        await self.initialize()
32        await self.initialize_meters()
33        return self

Initialize and return instance of this class as an object.

def initialize_meters(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)
async def fetch_info(self) -> dict:
41    async def fetch_info(self) -> dict:
42        """Fetch account information, parse data, initialize class attributes and return as json."""
43        logger.debug(f"[{self.username}] Fetching account details")
44
45        response = await self.session.get("/AccountInfo")
46
47        data = self.parse_info(response)
48        await self.from_json(data)
49
50        logger.debug(f"[{self.username}] Fetched account details")
51        return data

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

async def from_json(self, data: dict) -> None:
53    async def from_json(self, data: dict) -> None:
54        """Initialize base attributes from a json/dict data."""
55        self.reg_no = data.get("reg_no", "")
56        self.name = data.get("name", "")
57        self.contact_no = data.get("contact_no", "")
58        self.email = data.get("email", "")
59
60        self.meters = []
61        for meter_node_no in data.get("meters", []):
62            self.meters.append(AsyncUSMSMeter(self, meter_node_no))

Initialize base attributes from a json/dict data.

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)
class AsyncUSMSClient(usms.core.client.BaseUSMSClient, httpx.AsyncClient):
 93class AsyncUSMSClient(BaseUSMSClient, httpx.AsyncClient):
 94    """Async HTTP client for interacting with USMS."""
 95
 96    async def initialize(self) -> None:
 97        """Actual initialization logic of Client object."""
 98        super().initialize()
 99
100    @classmethod
101    async def create(cls, auth: USMSAuth) -> "AsyncUSMSClient":
102        """Initialize and return instance of this class as an object."""
103        self = cls(auth)
104        await self.initialize()
105        return self
106
107    @requires_init
108    async def post(self, url: str, data: dict | None = None) -> httpx.Response:
109        """Send a POST request with ASP.NET hidden fields included."""
110        return await super().post(url=url, data=data)
111
112    @requires_init
113    async def _update_asp_state(self, response: httpx.Response) -> None:
114        """Extract ASP.NET hidden fields from responses to maintain session state."""
115        super()._extract_asp_state(await response.aread())

Async HTTP client for interacting with USMS.

async def initialize(self) -> None:
96    async def initialize(self) -> None:
97        """Actual initialization logic of Client object."""
98        super().initialize()

Actual initialization logic of Client object.

@classmethod
async def create(cls, auth: usms.core.auth.USMSAuth) -> AsyncUSMSClient:
100    @classmethod
101    async def create(cls, auth: USMSAuth) -> "AsyncUSMSClient":
102        """Initialize and return instance of this class as an object."""
103        self = cls(auth)
104        await self.initialize()
105        return self

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

Async USMS Meter Service that inherits BaseUSMSMeter.

async def initialize(self):
23    async def initialize(self):
24        """Fetch meter info and then set initial class attributes."""
25        logger.debug(f"[{self._account.username}] Initializing meter {self.node_no}")
26        await self.fetch_info()
27        super().initialize()
28        logger.debug(f"[{self._account.username}] Initialized meter {self.node_no}")

Fetch meter info and then set initial class attributes.

@classmethod
async def create( cls, account: Union[USMSAccount, AsyncUSMSAccount], node_no: str) -> AsyncUSMSMeter:
30    @classmethod
31    async def create(
32        cls, account: Union["USMSAccount", "AsyncUSMSAccount"], node_no: str
33    ) -> "AsyncUSMSMeter":
34        """Initialize and return instance of this class as an object."""
35        self = cls(account, node_no)
36        await self.initialize()
37        return self

Initialize and return instance of this class as an object.

async def fetch_info(self) -> dict:
39    async def fetch_info(self) -> dict:
40        """Fetch meter information, parse data, initialize class attributes and return as json."""
41        payload = self._build_info_payload()
42        await self.session.get("/AccountInfo")
43        response = await self.session.post("/AccountInfo", data=payload)
44
45        data = self.parse_info(response)
46        self.from_json(data)
47
48        logger.debug(f"[{self.no}] Fetched {self.type} meter {self.no}")
49        return data

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

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 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)
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):
 13class USMSAccount(BaseUSMSAccount):
 14    """Sync USMS Account Service that inherits BaseUSMSAccount."""
 15
 16    session: USMSClient
 17
 18    def initialize(self):
 19        """Initialize session object, fetch account info and set class attributes."""
 20        logger.debug(f"[{self.username}] Initializing account {self.username}")
 21        self.session = USMSClient.create(self.auth)
 22        self.fetch_info()
 23
 24        self._initialized = True
 25        logger.debug(f"[{self.username}] Initialized account")
 26
 27    @classmethod
 28    def create(cls, username: str, password: str) -> "USMSAccount":
 29        """Initialize and return instance of this class as an object."""
 30        self = cls(username, password)
 31        self.initialize()
 32        self.initialize_meters()
 33        return self
 34
 35    @requires_init
 36    def initialize_meters(self):
 37        """Initialize all USMSMeters under this account."""
 38        for meter in self.meters:
 39            meter.initialize()
 40
 41    def fetch_info(self) -> dict:
 42        """Fetch account information, parse data, initialize class attributes and return as json."""
 43        logger.debug(f"[{self.username}] Fetching account details")
 44
 45        response = self.session.get("/AccountInfo")
 46
 47        data = self.parse_info(response)
 48        self.from_json(data)
 49
 50        logger.debug(f"[{self.username}] Fetched account details")
 51        return data
 52
 53    def from_json(self, data: dict) -> None:
 54        """Initialize base attributes from a json/dict data."""
 55        self.reg_no = data.get("reg_no", "")
 56        self.name = data.get("name", "")
 57        self.contact_no = data.get("contact_no", "")
 58        self.email = data.get("email", "")
 59
 60        self.meters = []
 61        for meter_node_no in data.get("meters", []):
 62            self.meters.append(USMSMeter(self, meter_node_no))
 63
 64    @requires_init
 65    def log_out(self) -> bool:
 66        """Log the user out of the USMS session by clearing session cookies."""
 67        logger.debug(f"[{self.username}] Logging out {self.username}...")
 68        self.session.get("/ResLogin")
 69        self.session.cookies = {}
 70
 71        if not self.is_authenticated():
 72            logger.debug(f"[{self.username}] Log out successful")
 73            return True
 74
 75        logger.debug(f"[{self.username}] Log out fail")
 76        return False
 77
 78    @requires_init
 79    def log_in(self) -> bool:
 80        """Log in the user."""
 81        logger.debug(f"[{self.username}] Logging in {self.username}...")
 82
 83        self.session.get("/AccountInfo")
 84
 85        if self.is_authenticated():
 86            logger.debug(f"[{self.username}] Log in successful")
 87            return True
 88
 89        logger.debug(f"[{self.username}] Log in fail")
 90        return False
 91
 92    @requires_init
 93    def is_authenticated(self) -> bool:
 94        """
 95        Check if the current session is authenticated.
 96
 97        Check if the current session is authenticated
 98        by sending a request without retrying or triggering auth logic.
 99        """
100        is_authenticated = False
101        try:
102            response = self.session.get("/AccountInfo", auth=None)
103            is_authenticated = not self.auth.is_expired(response)
104        except httpx.HTTPError as error:
105            logger.error(f"[{self.username}] Login check failed: {error}")
106
107        if is_authenticated:
108            logger.debug(f"[{self.username}] Account is authenticated")
109        else:
110            logger.debug(f"[{self.username}] Account is NOT authenticated")
111        return is_authenticated

Sync USMS Account Service that inherits BaseUSMSAccount.

session: USMSClient
def initialize(self):
18    def initialize(self):
19        """Initialize session object, fetch account info and set class attributes."""
20        logger.debug(f"[{self.username}] Initializing account {self.username}")
21        self.session = USMSClient.create(self.auth)
22        self.fetch_info()
23
24        self._initialized = True
25        logger.debug(f"[{self.username}] Initialized account")

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

@classmethod
def create( cls, username: str, password: str) -> USMSAccount:
27    @classmethod
28    def create(cls, username: str, password: str) -> "USMSAccount":
29        """Initialize and return instance of this class as an object."""
30        self = cls(username, password)
31        self.initialize()
32        self.initialize_meters()
33        return self

Initialize and return instance of this class as an object.

def initialize_meters(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)

Initialize all USMSMeters under this account.

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

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

def from_json(self, data: dict) -> None:
53    def from_json(self, data: dict) -> None:
54        """Initialize base attributes from a json/dict data."""
55        self.reg_no = data.get("reg_no", "")
56        self.name = data.get("name", "")
57        self.contact_no = data.get("contact_no", "")
58        self.email = data.get("email", "")
59
60        self.meters = []
61        for meter_node_no in data.get("meters", []):
62            self.meters.append(USMSMeter(self, meter_node_no))

Initialize base attributes from a json/dict data.

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.

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

Sync HTTP client for interacting with USMS.

@classmethod
def create(cls, auth: usms.core.auth.USMSAuth) -> USMSClient:
80    @classmethod
81    def create(cls, auth: USMSAuth) -> "USMSClient":
82        """Initialize and return instance of this class as an object."""
83        self = cls(auth)
84        self.initialize()
85        return self

Initialize and return instance of this class as an object.

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

Sync USMS Meter Service that inherits BaseUSMSMeter.

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

Fetch meter info and then set initial class attributes.

@classmethod
def create( cls, account: Union[USMSAccount, AsyncUSMSAccount], node_no: str) -> USMSMeter:
30    @classmethod
31    def create(cls, account: Union["USMSAccount", "AsyncUSMSAccount"], node_no: str) -> "USMSMeter":
32        """Initialize and return instance of this class as an object."""
33        self = cls(account, node_no)
34        self.initialize()
35        return self

Initialize and return instance of this class as an object.

def fetch_info(self) -> dict:
37    def fetch_info(self) -> dict:
38        """Fetch meter information, parse data, initialize class attributes and return as json."""
39        payload = self._build_info_payload()
40        self.session.get("/AccountInfo")
41        response = self.session.post("/AccountInfo", data=payload)
42
43        data = self.parse_info(response)
44        self.from_json(data)
45
46        logger.debug(f"[{self.no}] Fetched {self.type} meter {self.no}")
47        return data

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

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 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.

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