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

Async USMS Account Service that inherits BaseUSMSAccount.

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

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

async def initialize_meters(self):
25    async def initialize_meters(self):
26        """Initialize all USMSMeters under this account."""
27        for meter in self.meters:
28            await meter.initialize()

Initialize all USMSMeters under this account.

async def fetch_info(self) -> dict:
30    async def fetch_info(self) -> dict:
31        """Fetch account information, parse data, initialize class attributes and return as json."""
32        logger.debug(f"[{self.username}] Fetching account details")
33
34        response = await self.session.get("/AccountInfo")
35
36        data = self.parse_info(response)
37        await self.from_json(data)
38
39        logger.debug(f"[{self.username}] Fetched account details")
40        return data

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

async def from_json(self, data: dict) -> None:
42    async def from_json(self, data: dict) -> None:
43        """Initialize base attributes from a json/dict data."""
44        self.reg_no = data.get("reg_no", "")
45        self.name = data.get("name", "")
46        self.contact_no = data.get("contact_no", "")
47        self.email = data.get("email", "")
48
49        self.meters = []
50        for meter_node_no in data.get("meters", []):
51            self.meters.append(AsyncUSMSMeter(self, meter_node_no))

Initialize base attributes from a json/dict data.

async def log_out(self) -> bool:
53    async def log_out(self) -> bool:
54        """Log the user out of the USMS session by clearing session cookies."""
55        logger.debug(f"[{self.username}] Logging out {self.username}...")
56
57        await self.session.get("/ResLogin")
58        self.session.cookies = {}
59
60        if not await self.is_authenticated():
61            logger.debug(f"[{self.username}] Log out successful")
62            return True
63
64        logger.debug(f"[{self.username}] Log out fail")
65        return False

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

async def log_in(self) -> bool:
67    async def log_in(self) -> bool:
68        """Log in the user."""
69        logger.debug(f"[{self.username}] Logging in {self.username}...")
70
71        await self.session.get("/AccountInfo")
72
73        if await self.is_authenticated():
74            logger.debug(f"[{self.username}] Log in successful")
75            return True
76
77        logger.debug(f"[{self.username}] Log in fail")
78        return False

Log in the user.

async def is_authenticated(self) -> bool:
80    async def is_authenticated(self) -> bool:
81        """
82        Check if the current session is authenticated.
83
84        Check if the current session is authenticated
85        by sending a request without retrying or triggering auth logic.
86        """
87        is_authenticated = False
88        try:
89            response = await self.session.get("/AccountInfo", auth=None)
90            is_authenticated = not self.auth.is_expired(response)
91        except httpx.HTTPError as error:
92            logger.error(f"[{self.username}] Login check failed: {error}")
93
94        if is_authenticated:
95            logger.debug(f"[{self.username}] Account is authenticated")
96        else:
97            logger.debug(f"[{self.username}] Account is NOT authenticated")
98        return is_authenticated

Check if the current session is authenticated.

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

class AsyncUSMSClient(usms.core.client.BaseUSMSClient, httpx.AsyncClient):
76class AsyncUSMSClient(BaseUSMSClient, httpx.AsyncClient):
77    """Async HTTP client for interacting with USMS."""
78
79    async def initialize(self) -> None:
80        """Actual initialization logic of Client object."""
81        super().initialize()
82
83    async def post(self, url: str, data: dict | None = None) -> httpx.Response:
84        """Send a POST request with ASP.NET hidden fields included."""
85        return await super().post(url=url, data=data)
86
87    async def _update_asp_state(self, response: httpx.Response) -> None:
88        """Extract ASP.NET hidden fields from responses to maintain session state."""
89        super()._extract_asp_state(await response.aread())

Async HTTP client for interacting with USMS.

async def initialize(self) -> None:
79    async def initialize(self) -> None:
80        """Actual initialization logic of Client object."""
81        super().initialize()

Actual initialization logic of Client object.

async def post(self, url: str, data: dict | None = None) -> httpx.Response:
83    async def post(self, url: str, data: dict | None = None) -> httpx.Response:
84        """Send a POST request with ASP.NET hidden fields included."""
85        return await super().post(url=url, data=data)

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

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

Async USMS Meter Service that inherits BaseUSMSMeter.

async def initialize(self):
17    async def initialize(self):
18        """Fetch meter info and then set initial class attributes."""
19        logger.debug(f"[{self._account.username}] Initializing meter {self.node_no}")
20        await self.fetch_info()
21        super().initialize()
22        logger.debug(f"[{self._account.username}] Initialized meter {self.node_no}")

Fetch meter info and then set initial class attributes.

async def fetch_info(self) -> dict:
24    async def fetch_info(self) -> dict:
25        """Fetch meter information, parse data, initialize class attributes and return as json."""
26        payload = self._build_info_payload()
27        await self.session.get("/AccountInfo")
28        response = await self.session.post("/AccountInfo", data=payload)
29
30        data = self.parse_info(response)
31        self.from_json(data)
32
33        logger.debug(f"[{self.no}] Fetched {self.type} meter {self.no}")
34        return data

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

async def fetch_hourly_consumptions(self, date: datetime.datetime) -> pandas.core.series.Series:
36    async def fetch_hourly_consumptions(self, date: datetime) -> pd.Series:
37        """Fetch hourly consumptions for a given date and return as pd.Series."""
38        date = sanitize_date(date)
39
40        day_consumption = self.get_hourly_consumptions(date)
41        if not day_consumption.empty:
42            return day_consumption
43
44        logger.debug(f"[{self.no}] Fetching consumptions for: {date.date()}")
45        # build payload and perform requests
46        payload = self._build_hourly_consumptions_payload(date)
47        await self._account.session.get(f"/Report/UsageHistory?p={self.id}")
48        await self._account.session.post(f"/Report/UsageHistory?p={self.id}", data=payload)
49        payload = self._build_hourly_consumptions_payload(date)
50        response = await self._account.session.post(
51            f"/Report/UsageHistory?p={self.id}",
52            data=payload,
53        )
54
55        hourly_consumptions = self.parse_hourly_consumptions(response)
56
57        # convert dict to pd.DataFrame
58        hourly_consumptions = pd.DataFrame.from_dict(
59            hourly_consumptions,
60            dtype=float,
61            orient="index",
62            columns=[self.get_unit()],
63        )
64        hourly_consumptions.index = pd.to_datetime(
65            [date + timedelta(hours=hour) for hour in hourly_consumptions.index]
66        )
67        hourly_consumptions = hourly_consumptions.asfreq("h")
68        hourly_consumptions["last_checked"] = datetime.now(tz=BRUNEI_TZ)
69
70        if hourly_consumptions.empty:
71            logger.debug(f"[{self.no}] No consumptions data for : {date.date()}")
72            return hourly_consumptions[self.get_unit()]
73
74        self.hourly_consumptions = hourly_consumptions.combine_first(self.hourly_consumptions)
75
76        logger.debug(f"[{self.no}] Fetched consumptions for: {date.date()}")
77        return hourly_consumptions[self.get_unit()]

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

async def fetch_daily_consumptions(self, date: datetime.datetime) -> pandas.core.series.Series:
 79    async def fetch_daily_consumptions(self, date: datetime) -> pd.Series:
 80        """Fetch daily consumptions for a given date and return as pd.Series."""
 81        date = sanitize_date(date)
 82
 83        month_consumption = self.get_daily_consumptions(date)
 84        if not month_consumption.empty:
 85            return month_consumption
 86
 87        logger.debug(f"[{self.no}] Fetching consumptions for: {date.year}-{date.month}")
 88        # build payload and perform requests
 89        payload = self._build_daily_consumptions_payload(date)
 90
 91        await self._account.session.get(f"/Report/UsageHistory?p={self.id}")
 92        await self._account.session.post(f"/Report/UsageHistory?p={self.id}")
 93        await self._account.session.post(f"/Report/UsageHistory?p={self.id}", data=payload)
 94        response = await self._account.session.post(
 95            f"/Report/UsageHistory?p={self.id}", data=payload
 96        )
 97
 98        daily_consumptions = self.parse_daily_consumptions(response)
 99
100        # convert dict to pd.DataFrame
101        daily_consumptions = pd.DataFrame.from_dict(
102            daily_consumptions,
103            dtype=float,
104            orient="index",
105            columns=[self.get_unit()],
106        )
107        daily_consumptions.index = pd.to_datetime(daily_consumptions.index, format="%d/%m/%Y")
108        daily_consumptions = daily_consumptions.asfreq("D")
109        daily_consumptions["last_checked"] = datetime.now(tz=BRUNEI_TZ)
110
111        if daily_consumptions.empty:
112            logger.debug(f"[{self.no}] No consumptions data for : {date.year}-{date.month}")
113            return daily_consumptions[self.get_unit()]
114
115        self.daily_consumptions = daily_consumptions.combine_first(self.daily_consumptions)
116
117        logger.debug(f"[{self.no}] Fetched consumptions for: {date.year}-{date.month}")
118        return daily_consumptions[self.get_unit()]

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

async def get_previous_n_month_consumptions(self, n=0) -> pandas.core.series.Series:
120    async def get_previous_n_month_consumptions(self, n=0) -> pd.Series:
121        """
122        Return the consumptions for previous n month.
123
124        e.g.
125        n=0 : data for this month only
126        n=1 : data for previous month only
127        n=2 : data for previous 2 months only
128        """
129        date = datetime.now(tz=BRUNEI_TZ)
130        for _ in range(n):
131            date = date.replace(day=1)
132            date = date - timedelta(days=1)
133        return await self.fetch_daily_consumptions(date)

Return the consumptions for previous n month.

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

async def get_last_n_days_hourly_consumptions(self, n=0) -> pandas.core.series.Series:
135    async def get_last_n_days_hourly_consumptions(self, n=0) -> pd.Series:
136        """
137        Return the hourly unit consumptions for the last n days accumulatively.
138
139        e.g.
140        n=0 : data for today
141        n=1 : data from yesterday until today
142        n=2 : data from 2 days ago until today
143        """
144        last_n_days_hourly_consumptions = pd.Series(
145            dtype=float,
146            index=pd.DatetimeIndex([], tz=BRUNEI_TZ, freq="h"),
147            name=self.get_unit(),
148        )
149
150        upper_date = datetime.now(tz=BRUNEI_TZ)
151        lower_date = upper_date - timedelta(days=n)
152        for i in range(n + 1):
153            date = lower_date + timedelta(days=i)
154            hourly_consumptions = await self.fetch_hourly_consumptions(date)
155
156            if not hourly_consumptions.empty:
157                last_n_days_hourly_consumptions = hourly_consumptions.combine_first(
158                    last_n_days_hourly_consumptions
159                )
160
161        return last_n_days_hourly_consumptions

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

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

async def refresh_data(self) -> bool:
163    async def refresh_data(self) -> bool:
164        """Fetch new data and update the meter info."""
165        logger.info(f"[{self.no}] Checking for updates")
166
167        try:
168            # Initialize a temporary meter to fetch fresh details in one call
169            temp_meter = AsyncUSMSMeter(self._account, self.node_no)
170            temp_info = await temp_meter.fetch_info()
171        except Exception as error:  # noqa: BLE001
172            logger.warning(f"[{self.no}] Failed to fetch update with error: {error}")
173            return False
174
175        self.last_refresh = datetime.now(tz=BRUNEI_TZ)
176
177        if temp_info.get("last_update") > self.last_update:
178            logger.info(f"[{self.no}] New updates found")
179            self.from_json(temp_info)
180            return True
181
182        logger.info(f"[{self.no}] No new updates found")
183        return False

Fetch new data and update the meter info.

async def check_update_and_refresh(self) -> bool:
185    async def check_update_and_refresh(self) -> bool:
186        """Refresh data if an update is due, then return True if update successful."""
187        try:
188            if self.is_update_due():
189                return await self.refresh_data()
190        except Exception as error:  # noqa: BLE001
191            logger.warning(f"[{self.no}] Failed to fetch update with error: {error}")
192            return False
193
194        # Update not dued, data not refreshed
195        return False

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

async def get_all_hourly_consumptions(self) -> pandas.core.series.Series:
197    async def get_all_hourly_consumptions(self) -> pd.Series:
198        """Get the hourly unit consumptions for all days and months."""
199        logger.debug(f"[{self.no}] Getting all hourly consumptions")
200
201        upper_date = datetime.now(tz=BRUNEI_TZ)
202        lower_date = await self.find_earliest_consumption_date()
203        range_date = (upper_date - lower_date).days + 1
204        for i in range(range_date):
205            date = lower_date + timedelta(days=i)
206            await self.fetch_hourly_consumptions(date)
207            logger.debug(
208                f"[{self.no}] Getting all hourly consumptions progress: {i} out of {range_date}, {i / range_date * 100}%"
209            )
210
211        return self.hourly_consumptions

Get the hourly unit consumptions for all days and months.

async def find_earliest_consumption_date(self) -> datetime.datetime:
213    async def find_earliest_consumption_date(self) -> datetime:
214        """Determine the earliest date for which hourly consumption data is available."""
215        if self.earliest_consumption_date is not None:
216            return self.earliest_consumption_date
217
218        if self.hourly_consumptions.empty:
219            now = datetime.now(tz=BRUNEI_TZ)
220            date = datetime(
221                now.year,
222                now.month,
223                now.day,
224                0,
225                0,
226                0,
227                tzinfo=BRUNEI_TZ,
228            )
229        else:
230            date = self.hourly_consumptions.index.min()
231        logger.debug(f"[{self.no}] Finding earliest consumption date, starting from: {date.date()}")
232
233        # Exponential backoff to find a missing date
234        step = 1
235        while True:
236            hourly_consumptions = await self.fetch_hourly_consumptions(date)
237
238            if not hourly_consumptions.empty:
239                step *= 2  # Exponentially increase step
240                date -= timedelta(days=step)
241                logger.debug(f"[{self.no}] Stepping {step} days from {date}")
242            elif step == 1:
243                # Already at base step, this is the earliest available data
244                date += timedelta(days=step)
245                self.earliest_consumption_date = date
246                logger.debug(f"[{self.no}] Found earliest consumption date: {date}")
247                return date
248            else:
249                # Went too far — reverse the last large step and reset step to 1
250                date += timedelta(days=step)
251                logger.debug(f"[{self.no}] Stepped too far, going back to: {date}")
252                step /= 4  # Half the last step

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

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

Sync USMS Account Service that inherits BaseUSMSAccount.

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

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

def initialize_meters(self):
25    def initialize_meters(self):
26        """Initialize all USMSMeters under this account."""
27        for meter in self.meters:
28            meter.initialize()

Initialize all USMSMeters under this account.

def fetch_info(self) -> dict:
30    def fetch_info(self) -> dict:
31        """Fetch account information, parse data, initialize class attributes and return as json."""
32        logger.debug(f"[{self.username}] Fetching account details")
33
34        response = self.session.get("/AccountInfo")
35
36        data = self.parse_info(response)
37        self.from_json(data)
38
39        logger.debug(f"[{self.username}] Fetched account details")
40        return data

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

def from_json(self, data: dict) -> None:
42    def from_json(self, data: dict) -> None:
43        """Initialize base attributes from a json/dict data."""
44        self.reg_no = data.get("reg_no", "")
45        self.name = data.get("name", "")
46        self.contact_no = data.get("contact_no", "")
47        self.email = data.get("email", "")
48
49        self.meters = []
50        for meter_node_no in data.get("meters", []):
51            self.meters.append(USMSMeter(self, meter_node_no))

Initialize base attributes from a json/dict data.

def log_out(self) -> bool:
53    def log_out(self) -> bool:
54        """Log the user out of the USMS session by clearing session cookies."""
55        logger.debug(f"[{self.username}] Logging out {self.username}...")
56        self.session.get("/ResLogin")
57        self.session.cookies = {}
58
59        if not self.is_authenticated():
60            logger.debug(f"[{self.username}] Log out successful")
61            return True
62
63        logger.debug(f"[{self.username}] Log out fail")
64        return False

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

def log_in(self) -> bool:
66    def log_in(self) -> bool:
67        """Log in the user."""
68        logger.debug(f"[{self.username}] Logging in {self.username}...")
69
70        self.session.get("/AccountInfo")
71
72        if self.is_authenticated():
73            logger.debug(f"[{self.username}] Log in successful")
74            return True
75
76        logger.debug(f"[{self.username}] Log in fail")
77        return False

Log in the user.

def is_authenticated(self) -> bool:
79    def is_authenticated(self) -> bool:
80        """
81        Check if the current session is authenticated.
82
83        Check if the current session is authenticated
84        by sending a request without retrying or triggering auth logic.
85        """
86        is_authenticated = False
87        try:
88            response = self.session.get("/AccountInfo", auth=None)
89            is_authenticated = not self.auth.is_expired(response)
90        except httpx.HTTPError as error:
91            logger.error(f"[{self.username}] Login check failed: {error}")
92
93        if is_authenticated:
94            logger.debug(f"[{self.username}] Account is authenticated")
95        else:
96            logger.debug(f"[{self.username}] Account is NOT authenticated")
97        return is_authenticated

Check if the current session is authenticated.

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

class USMSClient(usms.core.client.BaseUSMSClient, httpx.Client):
68class USMSClient(BaseUSMSClient, httpx.Client):
69    """Sync HTTP client for interacting with USMS."""
70
71    def _update_asp_state(self, response: httpx.Response) -> None:
72        """Extract ASP.NET hidden fields from responses to maintain session state."""
73        super()._extract_asp_state(response.read())

Sync HTTP client for interacting with USMS.

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

Sync USMS Meter Service that inherits BaseUSMSMeter.

def initialize(self):
17    def initialize(self):
18        """Fetch meter info and then set initial class attributes."""
19        logger.debug(f"[{self._account.username}] Initializing meter {self.node_no}")
20        self.fetch_info()
21        super().initialize()
22        logger.debug(f"[{self._account.username}] Initialized meter {self.node_no}")

Fetch meter info and then set initial class attributes.

def fetch_info(self) -> dict:
24    def fetch_info(self) -> dict:
25        """Fetch meter information, parse data, initialize class attributes and return as json."""
26        payload = self._build_info_payload()
27        self.session.get("/AccountInfo")
28        response = self.session.post("/AccountInfo", data=payload)
29
30        data = self.parse_info(response)
31        self.from_json(data)
32
33        logger.debug(f"[{self.no}] Fetched {self.type} meter {self.no}")
34        return data

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

def fetch_hourly_consumptions(self, date: datetime.datetime) -> pandas.core.series.Series:
36    def fetch_hourly_consumptions(self, date: datetime) -> pd.Series:
37        """Fetch hourly consumptions for a given date and return as pd.Series."""
38        date = sanitize_date(date)
39
40        day_consumption = self.get_hourly_consumptions(date)
41        if not day_consumption.empty:
42            return day_consumption
43
44        logger.debug(f"[{self.no}] Fetching consumptions for: {date.date()}")
45        # build payload and perform requests
46        payload = self._build_hourly_consumptions_payload(date)
47        self._account.session.get(f"/Report/UsageHistory?p={self.id}")
48        self._account.session.post(f"/Report/UsageHistory?p={self.id}", data=payload)
49        payload = self._build_hourly_consumptions_payload(date)
50        response = self._account.session.post(
51            f"/Report/UsageHistory?p={self.id}",
52            data=payload,
53        )
54
55        hourly_consumptions = self.parse_hourly_consumptions(response)
56
57        # convert dict to pd.DataFrame
58        hourly_consumptions = pd.DataFrame.from_dict(
59            hourly_consumptions,
60            dtype=float,
61            orient="index",
62            columns=[self.get_unit()],
63        )
64        hourly_consumptions.index = pd.to_datetime(
65            [date + timedelta(hours=hour) for hour in hourly_consumptions.index]
66        )
67        hourly_consumptions = hourly_consumptions.asfreq("h")
68        hourly_consumptions["last_checked"] = datetime.now(tz=BRUNEI_TZ)
69
70        if hourly_consumptions.empty:
71            logger.debug(f"[{self.no}] No consumptions data for : {date.date()}")
72            return hourly_consumptions[self.get_unit()]
73
74        self.hourly_consumptions = hourly_consumptions.combine_first(self.hourly_consumptions)
75
76        logger.debug(f"[{self.no}] Fetched consumptions for: {date.date()}")
77        return hourly_consumptions[self.get_unit()]

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

def fetch_daily_consumptions(self, date: datetime.datetime) -> pandas.core.series.Series:
 79    def fetch_daily_consumptions(self, date: datetime) -> pd.Series:
 80        """Fetch daily consumptions for a given date and return as pd.Series."""
 81        date = sanitize_date(date)
 82
 83        month_consumption = self.get_daily_consumptions(date)
 84        if not month_consumption.empty:
 85            return month_consumption
 86
 87        logger.debug(f"[{self.no}] Fetching consumptions for: {date.year}-{date.month}")
 88        # build payload and perform requests
 89        payload = self._build_daily_consumptions_payload(date)
 90
 91        self._account.session.get(f"/Report/UsageHistory?p={self.id}")
 92        self._account.session.post(f"/Report/UsageHistory?p={self.id}")
 93        self._account.session.post(f"/Report/UsageHistory?p={self.id}", data=payload)
 94        response = self._account.session.post(f"/Report/UsageHistory?p={self.id}", data=payload)
 95
 96        daily_consumptions = self.parse_daily_consumptions(response)
 97
 98        # convert dict to pd.DataFrame
 99        daily_consumptions = pd.DataFrame.from_dict(
100            daily_consumptions,
101            dtype=float,
102            orient="index",
103            columns=[self.get_unit()],
104        )
105        daily_consumptions.index = pd.to_datetime(daily_consumptions.index, format="%d/%m/%Y")
106        daily_consumptions = daily_consumptions.asfreq("D")
107        daily_consumptions["last_checked"] = datetime.now(tz=BRUNEI_TZ)
108
109        if daily_consumptions.empty:
110            logger.debug(f"[{self.no}] No consumptions data for : {date.year}-{date.month}")
111            return daily_consumptions[self.get_unit()]
112
113        self.daily_consumptions = daily_consumptions.combine_first(self.daily_consumptions)
114
115        logger.debug(f"[{self.no}] Fetched consumptions for: {date.year}-{date.month}")
116        return daily_consumptions[self.get_unit()]

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

def get_previous_n_month_consumptions(self, n=0) -> pandas.core.series.Series:
118    def get_previous_n_month_consumptions(self, n=0) -> pd.Series:
119        """
120        Return the consumptions for previous n month.
121
122        e.g.
123        n=0 : data for this month only
124        n=1 : data for previous month only
125        n=2 : data for previous 2 months only
126        """
127        date = datetime.now(tz=BRUNEI_TZ)
128        for _ in range(n):
129            date = date.replace(day=1)
130            date = date - timedelta(days=1)
131        return self.fetch_daily_consumptions(date)

Return the consumptions for previous n month.

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

def get_last_n_days_hourly_consumptions(self, n=0) -> pandas.core.series.Series:
133    def get_last_n_days_hourly_consumptions(self, n=0) -> pd.Series:
134        """
135        Return the hourly unit consumptions for the last n days accumulatively.
136
137        e.g.
138        n=0 : data for today
139        n=1 : data from yesterday until today
140        n=2 : data from 2 days ago until today
141        """
142        last_n_days_hourly_consumptions = pd.Series(
143            dtype=float,
144            index=pd.DatetimeIndex([], tz=BRUNEI_TZ, freq="h"),
145            name=self.get_unit(),
146        )
147
148        upper_date = datetime.now(tz=BRUNEI_TZ)
149        lower_date = upper_date - timedelta(days=n)
150        range_date = (upper_date - lower_date).days + 1
151        for i in range(range_date):
152            date = lower_date + timedelta(days=i)
153            hourly_consumptions = self.fetch_hourly_consumptions(date)
154
155            if not hourly_consumptions.empty:
156                last_n_days_hourly_consumptions = hourly_consumptions.combine_first(
157                    last_n_days_hourly_consumptions
158                )
159
160        return last_n_days_hourly_consumptions

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

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

def refresh_data(self) -> bool:
162    def refresh_data(self) -> bool:
163        """Fetch new data and update the meter info."""
164        logger.info(f"[{self.no}] Checking for updates")
165
166        try:
167            # Initialize a temporary meter to fetch fresh details in one call
168            temp_meter = USMSMeter(self._account, self.node_no)
169            temp_info = temp_meter.fetch_info()
170        except Exception as error:  # noqa: BLE001
171            logger.warning(f"[{self.no}] Failed to fetch update with error: {error}")
172            return False
173
174        self.last_refresh = datetime.now(tz=BRUNEI_TZ)
175
176        if temp_info.get("last_update") > self.last_update:
177            logger.info(f"[{self.no}] New updates found")
178            self.from_json(temp_info)
179            return True
180
181        logger.info(f"[{self.no}] No new updates found")
182        return False

Fetch new data and update the meter info.

def check_update_and_refresh(self) -> bool:
184    def check_update_and_refresh(self) -> bool:
185        """Refresh data if an update is due, then return True if update successful."""
186        try:
187            if self.is_update_due():
188                return self.refresh_data()
189        except Exception as error:  # noqa: BLE001
190            logger.warning(f"[{self.no}] Failed to fetch update with error: {error}")
191            return False
192
193        # Update not dued, data not refreshed
194        return False

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

def get_all_hourly_consumptions(self) -> pandas.core.series.Series:
196    def get_all_hourly_consumptions(self) -> pd.Series:
197        """Get the hourly unit consumptions for all days and months."""
198        logger.debug(f"[{self.no}] Getting all hourly consumptions")
199
200        upper_date = datetime.now(tz=BRUNEI_TZ)
201        lower_date = self.find_earliest_consumption_date()
202        range_date = (upper_date - lower_date).days + 1
203        for i in range(range_date):
204            date = lower_date + timedelta(days=i)
205            self.fetch_hourly_consumptions(date)
206            logger.debug(
207                f"[{self.no}] Getting all hourly consumptions progress: {i} out of {range_date}, {i / range_date * 100}%"
208            )
209
210        return self.hourly_consumptions

Get the hourly unit consumptions for all days and months.

def find_earliest_consumption_date(self) -> datetime.datetime:
212    def find_earliest_consumption_date(self) -> datetime:
213        """Determine the earliest date for which hourly consumption data is available."""
214        if self.earliest_consumption_date is not None:
215            return self.earliest_consumption_date
216
217        if self.hourly_consumptions.empty:
218            now = datetime.now(tz=BRUNEI_TZ)
219            date = datetime(
220                now.year,
221                now.month,
222                now.day,
223                0,
224                0,
225                0,
226                tzinfo=BRUNEI_TZ,
227            )
228        else:
229            date = self.hourly_consumptions.index.min()
230        logger.debug(f"[{self.no}] Finding earliest consumption date, starting from: {date.date()}")
231
232        # Exponential backoff to find a missing date
233        step = 1
234        while True:
235            hourly_consumptions = self.fetch_hourly_consumptions(date)
236
237            if not hourly_consumptions.empty:
238                step *= 2  # Exponentially increase step
239                date -= timedelta(days=step)
240                logger.debug(f"[{self.no}] Stepping {step} days from {date}")
241            elif step == 1:
242                # Already at base step, this is the earliest available data
243                date += timedelta(days=step)
244                self.earliest_consumption_date = date
245                logger.debug(f"[{self.no}] Found earliest consumption date: {date}")
246                return date
247            else:
248                # Went too far — reverse the last large step and reset step to 1
249                date += timedelta(days=step)
250                logger.debug(f"[{self.no}] Stepped too far, going back to: {date}")
251                step /= 4  # Half the last step

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

@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