python-mytnb 0.1.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/.github/workflows/ci.yml +2 -2
  2. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/.github/workflows/publish.yml +2 -3
  3. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/PKG-INFO +28 -16
  4. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/README.md +26 -14
  5. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/pyproject.toml +2 -2
  6. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/__init__.py +6 -0
  7. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/cli.py +42 -0
  8. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/client/client.py +51 -25
  9. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/client/config.py +1 -0
  10. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/client/legacy.py +11 -4
  11. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/models.py +140 -2
  12. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/tests/test_cli.py +60 -2
  13. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/tests/test_client.py +65 -2
  14. python_mytnb-0.3.0/tests/test_models.py +513 -0
  15. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/uv.lock +36 -12
  16. python_mytnb-0.1.0/tests/test_models.py +0 -284
  17. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/.gitignore +0 -0
  18. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/LICENSE +0 -0
  19. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/__main__.py +0 -0
  20. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/auth.py +0 -0
  21. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/client/__init__.py +0 -0
  22. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/client/auth.py +0 -0
  23. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/client/rest.py +0 -0
  24. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/crypto.py +0 -0
  25. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/exceptions.py +0 -0
  26. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/tests/test_auth.py +0 -0
  27. {python_mytnb-0.1.0 → python_mytnb-0.3.0}/tests/test_crypto.py +0 -0
@@ -2,9 +2,9 @@ name: CI
2
2
 
3
3
  on:
4
4
  push:
5
- branches: [dev, main]
5
+ branches: [dev, master]
6
6
  pull_request:
7
- branches: [dev, main]
7
+ branches: [dev, master]
8
8
 
9
9
  jobs:
10
10
  lint:
@@ -1,9 +1,8 @@
1
1
  name: Publish to PyPI
2
2
 
3
3
  on:
4
- push:
5
- tags:
6
- - "v*"
4
+ release:
5
+ types: [published]
7
6
 
8
7
  jobs:
9
8
  publish:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mytnb
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Python library to interface with the myTNB API (Tenaga Nasional Berhad)
5
5
  Project-URL: Repository, https://github.com/danieyal/python-mytnb
6
6
  License-Expression: MIT
@@ -18,10 +18,10 @@ Classifier: Topic :: Software Development :: Libraries
18
18
  Requires-Python: >=3.10
19
19
  Requires-Dist: click>=8.3.3
20
20
  Requires-Dist: cryptography>=42.0
21
+ Requires-Dist: curl-cffi>=0.7
21
22
  Requires-Dist: httpx>=0.27
22
23
  Requires-Dist: pydantic>=2.0
23
24
  Requires-Dist: rich>=15.0.0
24
- Requires-Dist: tls-client>=1.0
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: pylint>=3.0; extra == 'dev'
27
27
  Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
@@ -29,6 +29,9 @@ Requires-Dist: pytest>=7.0; extra == 'dev'
29
29
  Requires-Dist: ruff>=0.6; extra == 'dev'
30
30
  Description-Content-Type: text/markdown
31
31
 
32
+ [![PyPI version](https://badge.fury.io/py/python-mytnb.svg)](https://badge.fury.io/py/python-mytnb)
33
+ [![CI](https://github.com/danieyal/python-mytnb/actions/workflows/ci.yml/badge.svg)](https://github.com/danieyal/python-mytnb/actions/workflows/ci.yml)
34
+
32
35
  # python-mytnb
33
36
 
34
37
  A Python library to interface with the myTNB (Tenaga Nasional Berhad) API for electricity account management and usage monitoring in Malaysia.
@@ -55,6 +58,11 @@ async def main():
55
58
  client = await MyTNBClient.login("user@example.com", "your-password")
56
59
 
57
60
  async with client:
61
+ # Auto-discover linked accounts
62
+ accounts = await client.get_customer_accounts()
63
+ for acc in accounts:
64
+ print(f"{acc.account_number} — {acc.owner_name} (SMR: {acc.is_smart_meter})")
65
+
58
66
  # Get smart meter usage & billing history
59
67
  usage = await client.get_account_usage_smart("220123456789")
60
68
 
@@ -89,6 +97,8 @@ All commands:
89
97
 
90
98
  ```
91
99
  mytnb login # Test login, show user info
100
+ mytnb accounts # List all linked accounts (auto-discovery)
101
+ mytnb accounts --json # Account list as JSON
92
102
  mytnb usage <account> # Monthly usage & billing summary
93
103
  mytnb usage --daily <account> # Daily usage breakdown
94
104
  mytnb usage --json <account> # Full usage data as JSON
@@ -109,25 +119,27 @@ mytnb init-config # Generate a starter config file
109
119
 
110
120
  myTNB uses two API backends, both handled transparently by this library:
111
121
 
112
- | Backend | Domain | Auth | Used for |
113
- | ----------- | --------------------- | ------------------------------------------- | ----------------------------------- |
114
- | REST | `api.mytnb.com.my` | JWT + API key | Account listing, eligibility checks |
115
- | Legacy ASMX | `mytnbapp.tnb.com.my` | Encrypted payloads (AES-256-CBC + RSA-OAEP) | Usage data, billing, services |
122
+ | Backend | Domain | Auth | Used for |
123
+ | ----------- | --------------------------- | ------------------------------------------- | ----------------------------------- |
124
+ | REST | `api.mytnb.com.my` | JWT + API key | Bill eligibility, eligibility icons |
125
+ | AWS Gateway | `api.mytnb.com.my/core/api` | Encrypted payloads (AES-256-CBC + RSA-OAEP) | Account listing (auto-discovery) |
126
+ | Legacy ASMX | `mytnbapp.tnb.com.my` | Encrypted payloads (AES-256-CBC + RSA-OAEP) | Usage data, billing, services |
116
127
 
117
128
  Request encryption for the ASMX API is automatic — just pass plaintext parameters.
118
129
 
119
130
  ## Data Models
120
131
 
121
- | Model | Description |
122
- | --------------- | ---------------------------------------------------- |
123
- | `AccountUsage` | Full usage response: metrics, monthly and daily data |
124
- | `UsageMetric` | Current/average usage (kWh) |
125
- | `CostMetric` | Current/projected cost (RM) |
126
- | `BillingMonth` | Monthly billing record with tariff blocks |
127
- | `DailyUsage` | Daily consumption and cost |
128
- | `TariffBlock` | Tariff pricing block details |
129
- | `SMRAccount` | Smart Meter Reading eligibility status |
130
- | `BREligibility` | Bill rendering opt-in status |
132
+ | Model | Description |
133
+ | ----------------- | ---------------------------------------------------- |
134
+ | `CustomerAccount` | Linked account: number, owner, address, SMR status |
135
+ | `AccountUsage` | Full usage response: metrics, monthly and daily data |
136
+ | `UsageMetric` | Current/average usage (kWh) |
137
+ | `CostMetric` | Current/projected cost (RM) |
138
+ | `BillingMonth` | Monthly billing record with tariff blocks |
139
+ | `DailyUsage` | Daily consumption and cost |
140
+ | `TariffBlock` | Tariff pricing block details |
141
+ | `SMRAccount` | Smart Meter Reading eligibility status |
142
+ | `BREligibility` | Bill rendering opt-in status |
131
143
 
132
144
  ## Geographic Restrictions
133
145
 
@@ -1,3 +1,6 @@
1
+ [![PyPI version](https://badge.fury.io/py/python-mytnb.svg)](https://badge.fury.io/py/python-mytnb)
2
+ [![CI](https://github.com/danieyal/python-mytnb/actions/workflows/ci.yml/badge.svg)](https://github.com/danieyal/python-mytnb/actions/workflows/ci.yml)
3
+
1
4
  # python-mytnb
2
5
 
3
6
  A Python library to interface with the myTNB (Tenaga Nasional Berhad) API for electricity account management and usage monitoring in Malaysia.
@@ -24,6 +27,11 @@ async def main():
24
27
  client = await MyTNBClient.login("user@example.com", "your-password")
25
28
 
26
29
  async with client:
30
+ # Auto-discover linked accounts
31
+ accounts = await client.get_customer_accounts()
32
+ for acc in accounts:
33
+ print(f"{acc.account_number} — {acc.owner_name} (SMR: {acc.is_smart_meter})")
34
+
27
35
  # Get smart meter usage & billing history
28
36
  usage = await client.get_account_usage_smart("220123456789")
29
37
 
@@ -58,6 +66,8 @@ All commands:
58
66
 
59
67
  ```
60
68
  mytnb login # Test login, show user info
69
+ mytnb accounts # List all linked accounts (auto-discovery)
70
+ mytnb accounts --json # Account list as JSON
61
71
  mytnb usage <account> # Monthly usage & billing summary
62
72
  mytnb usage --daily <account> # Daily usage breakdown
63
73
  mytnb usage --json <account> # Full usage data as JSON
@@ -78,25 +88,27 @@ mytnb init-config # Generate a starter config file
78
88
 
79
89
  myTNB uses two API backends, both handled transparently by this library:
80
90
 
81
- | Backend | Domain | Auth | Used for |
82
- | ----------- | --------------------- | ------------------------------------------- | ----------------------------------- |
83
- | REST | `api.mytnb.com.my` | JWT + API key | Account listing, eligibility checks |
84
- | Legacy ASMX | `mytnbapp.tnb.com.my` | Encrypted payloads (AES-256-CBC + RSA-OAEP) | Usage data, billing, services |
91
+ | Backend | Domain | Auth | Used for |
92
+ | ----------- | --------------------------- | ------------------------------------------- | ----------------------------------- |
93
+ | REST | `api.mytnb.com.my` | JWT + API key | Bill eligibility, eligibility icons |
94
+ | AWS Gateway | `api.mytnb.com.my/core/api` | Encrypted payloads (AES-256-CBC + RSA-OAEP) | Account listing (auto-discovery) |
95
+ | Legacy ASMX | `mytnbapp.tnb.com.my` | Encrypted payloads (AES-256-CBC + RSA-OAEP) | Usage data, billing, services |
85
96
 
86
97
  Request encryption for the ASMX API is automatic — just pass plaintext parameters.
87
98
 
88
99
  ## Data Models
89
100
 
90
- | Model | Description |
91
- | --------------- | ---------------------------------------------------- |
92
- | `AccountUsage` | Full usage response: metrics, monthly and daily data |
93
- | `UsageMetric` | Current/average usage (kWh) |
94
- | `CostMetric` | Current/projected cost (RM) |
95
- | `BillingMonth` | Monthly billing record with tariff blocks |
96
- | `DailyUsage` | Daily consumption and cost |
97
- | `TariffBlock` | Tariff pricing block details |
98
- | `SMRAccount` | Smart Meter Reading eligibility status |
99
- | `BREligibility` | Bill rendering opt-in status |
101
+ | Model | Description |
102
+ | ----------------- | ---------------------------------------------------- |
103
+ | `CustomerAccount` | Linked account: number, owner, address, SMR status |
104
+ | `AccountUsage` | Full usage response: metrics, monthly and daily data |
105
+ | `UsageMetric` | Current/average usage (kWh) |
106
+ | `CostMetric` | Current/projected cost (RM) |
107
+ | `BillingMonth` | Monthly billing record with tariff blocks |
108
+ | `DailyUsage` | Daily consumption and cost |
109
+ | `TariffBlock` | Tariff pricing block details |
110
+ | `SMRAccount` | Smart Meter Reading eligibility status |
111
+ | `BREligibility` | Bill rendering opt-in status |
100
112
 
101
113
  ## Geographic Restrictions
102
114
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mytnb"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  description = "Python library to interface with the myTNB API (Tenaga Nasional Berhad)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -25,7 +25,7 @@ dependencies = [
25
25
  "httpx>=0.27",
26
26
  "pydantic>=2.0",
27
27
  "cryptography>=42.0",
28
- "tls_client>=1.0",
28
+ "curl_cffi>=0.7",
29
29
  "click>=8.3.3",
30
30
  "rich>=15.0.0",
31
31
  ]
@@ -6,9 +6,12 @@ from mytnb.models import (
6
6
  AccountUsage,
7
7
  BillingMonth,
8
8
  CostMetric,
9
+ CustomerAccount,
9
10
  DailyUsage,
10
11
  Metric,
11
12
  TariffBlock,
13
+ TariffBlockLegendGroup,
14
+ TariffBlockLegendItem,
12
15
  UsageMetric,
13
16
  )
14
17
 
@@ -19,8 +22,11 @@ __all__ = [
19
22
  "AccountUsage",
20
23
  "BillingMonth",
21
24
  "CostMetric",
25
+ "CustomerAccount",
22
26
  "DailyUsage",
23
27
  "Metric",
24
28
  "TariffBlock",
29
+ "TariffBlockLegendGroup",
30
+ "TariffBlockLegendItem",
25
31
  "UsageMetric",
26
32
  ]
@@ -371,6 +371,48 @@ def init_config(output):
371
371
  console.print(f"[green]Config written to[/] {target}")
372
372
 
373
373
 
374
+ @cli.command()
375
+ @click.option("--json", "as_json", is_flag=True, help="Output full JSON.")
376
+ @click.pass_context
377
+ def accounts(ctx, as_json):
378
+ """List all linked accounts (auto-discovery)."""
379
+
380
+ async def _accounts():
381
+ client = await _get_client(ctx)
382
+ async with client:
383
+ with console.status("[bold green]Fetching accounts..."):
384
+ result = await client.get_customer_accounts()
385
+
386
+ if as_json:
387
+ _print_json(result)
388
+ return
389
+
390
+ if not result:
391
+ console.print("[yellow]No linked accounts found.[/]")
392
+ return
393
+
394
+ table = Table(title="Linked Accounts")
395
+ table.add_column("Account No", style="cyan")
396
+ table.add_column("Owner", style="bold")
397
+ table.add_column("Address")
398
+ table.add_column("SMR", justify="center")
399
+ table.add_column("Owned", justify="center")
400
+
401
+ for acc in result:
402
+ table.add_row(
403
+ acc.account_number,
404
+ acc.owner_name or "—",
405
+ acc.account_st_address or "—",
406
+ "✓" if acc.is_smart_meter else "—",
407
+ "✓" if acc.is_owned else "—",
408
+ )
409
+
410
+ console.print(table)
411
+ console.print(f"\n[dim]{len(result)} account(s) found.[/]")
412
+
413
+ _run_async(_accounts())
414
+
415
+
374
416
  def main() -> None:
375
417
  cli() # pylint: disable=no-value-for-parameter
376
418
 
@@ -5,14 +5,16 @@ from __future__ import annotations
5
5
  from typing import Any, Optional
6
6
 
7
7
  import httpx
8
- import tls_client
8
+ from curl_cffi import requests as curl_requests
9
9
 
10
10
  from mytnb.auth import Credentials
11
11
  from mytnb.client.auth import login
12
- from mytnb.client.config import USER_AGENT
12
+ from mytnb.client.config import AWS_API_BASE_URL, USER_AGENT, _check_http_status
13
13
  from mytnb.client.legacy import _LegacyTransport
14
14
  from mytnb.client.rest import _RestTransport
15
- from mytnb.models import AccountUsage, BREligibility, SMRAccount
15
+ from mytnb.crypto import encrypt_request
16
+ from mytnb.exceptions import APIError
17
+ from mytnb.models import AccountUsage, BREligibility, CustomerAccount, SMRAccount
16
18
 
17
19
 
18
20
  class MyTNBClient:
@@ -38,7 +40,7 @@ class MyTNBClient:
38
40
  self._timeout = timeout
39
41
  self._use_staging_key = use_staging_key
40
42
  self._http_client: Optional[httpx.AsyncClient] = None
41
- self._tls_session: Optional[tls_client.Session] = None
43
+ self._tls_session: Optional[curl_requests.Session] = None
42
44
  self._rest: Optional[_RestTransport] = None
43
45
  self._legacy: Optional[_LegacyTransport] = None
44
46
 
@@ -57,14 +59,14 @@ class MyTNBClient:
57
59
  return self._http_client
58
60
 
59
61
  @property
60
- def _legacy_client(self) -> tls_client.Session:
61
- """Get or create a tls_client session for legacy ASMX requests.
62
+ def _legacy_client(self) -> curl_requests.Session:
63
+ """Get or create a curl_cffi session for legacy ASMX requests.
62
64
 
63
- Uses an Android TLS fingerprint to bypass CloudFront WAF.
65
+ Uses a TLS fingerprint to bypass CloudFront WAF.
64
66
  """
65
67
  if self._tls_session is None:
66
- self._tls_session = tls_client.Session(
67
- client_identifier="okhttp4_android_13",
68
+ self._tls_session = curl_requests.Session(
69
+ impersonate="chrome131",
68
70
  )
69
71
  return self._tls_session
70
72
 
@@ -178,25 +180,49 @@ class MyTNBClient:
178
180
  accounts = result.get("data", [])
179
181
  return [SMRAccount.model_validate(acc) for acc in accounts]
180
182
 
181
- async def get_services(self) -> dict:
182
- """Get available services (V4)."""
183
- data = {"usrInf": self._legacy_transport.base_user_info()}
184
- return await self._legacy_transport.post("GetServicesV4", data)
183
+ async def get_customer_accounts(self) -> list[CustomerAccount]:
184
+ """Get all accounts linked to the current user.
185
185
 
186
- async def get_energy_recommendations(
187
- self,
188
- account_number: str,
189
- *,
190
- is_owner: bool = True,
191
- ) -> dict:
192
- """Get energy budget recommendations."""
186
+ This is the auto-discovery endpoint — call this first to
187
+ discover which accounts are available, then pass individual
188
+ account numbers to get_account_usage_smart(), etc.
189
+
190
+ Uses POST /v3/account/GetAccount via the AWS API gateway.
191
+ The payload is encrypted (same encryption as legacy ASMX)
192
+ but the response is plain JSON (no ``{"d":{...}}`` wrapper).
193
+ """
194
+ usr_inf = self._legacy_transport.base_user_info()
195
+ usr_inf["IsWhiteList"] = False
193
196
  data = {
194
- "contractAccount": account_number,
195
- "isOwner": "true" if is_owner else "false",
196
- "usrInf": self._legacy_transport.base_user_info(),
197
+ "usrInf": usr_inf,
198
+ "deviceInf": self._legacy_transport.base_device_info(),
199
+ "featureInfo": [],
197
200
  }
198
- result = await self._legacy_transport.post("GetUserEBRecommendations", data)
199
- return result.get("data") or result
201
+
202
+ payload = encrypt_request(data, use_staging_key=self._use_staging_key)
203
+ body = {"dt": payload.to_dict()}
204
+
205
+ response = await self._client.post(
206
+ f"{AWS_API_BASE_URL}/v3/account/GetAccount",
207
+ headers={
208
+ "Content-Type": "application/json",
209
+ "Accept": "application/json,text/json,text/x-json,text/javascript,application/xml,text/xml",
210
+ "User-Agent": USER_AGENT,
211
+ },
212
+ json=body,
213
+ )
214
+
215
+ _check_http_status(response.status_code, context="AWS account API")
216
+
217
+ if response.status_code != 200:
218
+ raise APIError(
219
+ message=f"AWS account API request failed with status {response.status_code}",
220
+ error_code=str(response.status_code),
221
+ )
222
+
223
+ data = response.json()
224
+ accounts = data.get("data", [])
225
+ return [CustomerAccount.model_validate(acc) for acc in accounts]
200
226
 
201
227
  async def get_account_due_amount(
202
228
  self,
@@ -9,6 +9,7 @@ from mytnb.exceptions import AuthenticationError, GeoBlockedError, RateLimitErro
9
9
  # Base URLs
10
10
  LEGACY_BASE_URL = "https://mytnbapp.tnb.com.my/v7/mytnbws.asmx"
11
11
  REST_BASE_URL = "https://api.mytnb.com.my"
12
+ AWS_API_BASE_URL = "https://api.mytnb.com.my/core/api"
12
13
  SITECORE_LOGIN_URL = "https://www.mytnb.com.my/api/sitecore/Account/Login"
13
14
  SSO_HANDLER_URL = "https://myaccount.mytnb.com.my/SSO/SSOHandler"
14
15
 
@@ -7,7 +7,7 @@ import json
7
7
  import logging
8
8
  from typing import Any
9
9
 
10
- import tls_client
10
+ from curl_cffi import requests as curl_requests
11
11
 
12
12
  from mytnb.auth import Credentials
13
13
  from mytnb.client.config import (
@@ -30,7 +30,7 @@ class _LegacyTransport:
30
30
 
31
31
  def __init__(
32
32
  self,
33
- session: tls_client.Session,
33
+ session: curl_requests.Session,
34
34
  credentials: Credentials,
35
35
  timeout: float,
36
36
  use_staging_key: bool,
@@ -74,11 +74,18 @@ class _LegacyTransport:
74
74
  "ses_param2": "",
75
75
  }
76
76
 
77
+ def base_device_info(self) -> dict:
78
+ """Build the deviceInf object from credentials for legacy ASMX requests."""
79
+ di = self._credentials.device_info
80
+ if not di:
81
+ return {}
82
+ return di.to_dict()
83
+
77
84
  async def post(self, endpoint: str, data: Any) -> dict:
78
85
  """Make a POST request to the legacy ASMX API.
79
86
 
80
87
  Automatically encrypts the data using AES-256-CBC + RSA-OAEP.
81
- Uses tls_client with an Android TLS fingerprint to bypass CloudFront WAF.
88
+ Uses curl_cffi with a TLS fingerprint to bypass CloudFront WAF.
82
89
  """
83
90
  url = f"{LEGACY_BASE_URL}/{endpoint}"
84
91
  req_headers = self.headers()
@@ -92,7 +99,7 @@ class _LegacyTransport:
92
99
  url,
93
100
  headers=req_headers,
94
101
  json=body,
95
- timeout_seconds=int(self._timeout),
102
+ timeout=int(self._timeout),
96
103
  )
97
104
  logger.debug("Legacy POST %s → %s", endpoint, response.status_code)
98
105
 
@@ -2,9 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Optional
5
+ from typing import Any, Optional
6
6
 
7
- from pydantic import BaseModel, Field
7
+ from pydantic import AliasChoices, BaseModel, Field, field_validator
8
8
 
9
9
 
10
10
  class TariffBlock(BaseModel):
@@ -150,6 +150,26 @@ class ByMonthData(BaseModel):
150
150
  model_config = {"populate_by_name": True}
151
151
 
152
152
 
153
+ class TariffBlockLegendItem(BaseModel):
154
+ """A single tariff rate block in the legend (e.g. BLK1: RM 0.218/kWh)."""
155
+
156
+ block_id: str = Field(alias="BlockId")
157
+ block_range: str = Field(default="", alias="BlockRange")
158
+ block_price: str = Field(default="", alias="BlockPrice")
159
+
160
+ model_config = {"populate_by_name": True, "extra": "ignore"}
161
+
162
+
163
+ class TariffBlockLegendGroup(BaseModel):
164
+ """A monthly group of RP4 tariff legend items."""
165
+
166
+ items: list[TariffBlockLegendItem] = Field(
167
+ default_factory=list, alias="TariffBlocksLegend"
168
+ )
169
+
170
+ model_config = {"populate_by_name": True, "extra": "ignore"}
171
+
172
+
153
173
  class AccountUsage(BaseModel):
154
174
  """Full account usage response from GetAccountUsageSmart."""
155
175
 
@@ -161,6 +181,8 @@ class AccountUsage(BaseModel):
161
181
  start_date: Optional[str] = None
162
182
  end_date: Optional[str] = None
163
183
  date_range: Optional[str] = None
184
+ tariff_blocks_legend: list[TariffBlockLegendItem] = Field(default_factory=list)
185
+ tariff_blocks_legend_rp4: list[TariffBlockLegendGroup] = Field(default_factory=list)
164
186
 
165
187
  @classmethod
166
188
  def from_api_response(cls, data: dict) -> "AccountUsage":
@@ -178,6 +200,18 @@ class AccountUsage(BaseModel):
178
200
  by_day_raw = data.get("ByDay", [])
179
201
  by_day = [DailyUsageWeek.model_validate(w) for w in by_day_raw]
180
202
 
203
+ # Tariff block legends (residential)
204
+ legend_raw = data.get("TariffBlocksLegend", [])
205
+ tariff_blocks_legend = [
206
+ TariffBlockLegendItem.model_validate(item) for item in legend_raw
207
+ ]
208
+
209
+ # Tariff block legends by month (RP4 commercial)
210
+ rp4_raw = data.get("TariffBlocksLegendByMonthListRP4", [])
211
+ tariff_blocks_legend_rp4 = [
212
+ TariffBlockLegendGroup.model_validate(group) for group in rp4_raw
213
+ ]
214
+
181
215
  return cls(
182
216
  usage_metrics=usage_metrics,
183
217
  cost_metrics=cost_metrics,
@@ -187,6 +221,8 @@ class AccountUsage(BaseModel):
187
221
  start_date=data.get("StartDate"),
188
222
  end_date=data.get("EndDate"),
189
223
  date_range=data.get("DateRange"),
224
+ tariff_blocks_legend=tariff_blocks_legend,
225
+ tariff_blocks_legend_rp4=tariff_blocks_legend_rp4,
190
226
  )
191
227
 
192
228
  @property
@@ -234,3 +270,105 @@ class BREligibility(BaseModel):
234
270
  is_tenant_already_opt_in: bool = Field(default=False, alias="isTenantAlreadyOptIn")
235
271
 
236
272
  model_config = {"populate_by_name": True}
273
+
274
+
275
+ class CustomerAccount(BaseModel):
276
+ """A linked/customer account returned by the account auto-discovery endpoint.
277
+
278
+ Uses GET /v3/account/GetAccount via the AWS API gateway.
279
+ Returns all accounts linked to the current user without needing
280
+ to know account numbers ahead of time.
281
+ """
282
+
283
+ account_number: str = Field(alias="accNum")
284
+ user_account_id: str = Field(
285
+ default="",
286
+ validation_alias=AliasChoices("userAccountId", "userAccountID"),
287
+ )
288
+ account_desc: str = Field(default="", alias="accDesc")
289
+ ic_num: str = Field(default="", alias="icNum")
290
+ current_charges: str = Field(default="", alias="amCurrentChg")
291
+ is_registered: str = Field(default="", alias="isRegistered")
292
+ is_paid: str = Field(default="", alias="isPaid")
293
+ is_owned: str = Field(default="", alias="isOwned")
294
+ is_error: str = Field(default="", alias="isError")
295
+ message: Optional[str] = Field(default=None, alias="message")
296
+ account_type_id: str = Field(default="", alias="accountTypeId")
297
+ account_st_address: str = Field(default="", alias="accountStAddress")
298
+ owner_name: str = Field(default="", alias="ownerName")
299
+ account_category_id: str = Field(default="", alias="accountCategoryId")
300
+ smart_meter_code: str = Field(
301
+ default="",
302
+ validation_alias=AliasChoices("smartMeterCode", "SmartMeterCode"),
303
+ )
304
+ is_tagged_smr: str = Field(default="", alias="isTaggedSMR")
305
+ is_have_access: bool = Field(default=False, alias="IsHaveAccess")
306
+ is_apply_ebilling: bool = Field(default=False, alias="IsApplyEBilling")
307
+ budget_amount: str = Field(default="", alias="BudgetAmount")
308
+ installation_type: str = Field(default="", alias="InstallationType")
309
+ created_date: str = Field(default="", alias="CreatedDate")
310
+ business_area: str = Field(default="", alias="BusinessArea")
311
+ rate_category: str = Field(default="", alias="RateCategory")
312
+
313
+ model_config = {"populate_by_name": True, "extra": "ignore"}
314
+
315
+ # -- Coerce fields that can arrive as null, numbers, or string bools --
316
+
317
+ @field_validator(
318
+ "account_desc",
319
+ "ic_num",
320
+ "current_charges",
321
+ "budget_amount",
322
+ "account_type_id",
323
+ "account_st_address",
324
+ "owner_name",
325
+ "account_category_id",
326
+ "smart_meter_code",
327
+ "installation_type",
328
+ "created_date",
329
+ "business_area",
330
+ "rate_category",
331
+ "message",
332
+ "is_registered",
333
+ "is_paid",
334
+ "is_owned",
335
+ "is_error",
336
+ "is_tagged_smr",
337
+ mode="before",
338
+ )
339
+ @classmethod
340
+ def _coerce_to_str(cls, v: Any) -> str:
341
+ """Normalise values that may arrive as null, int, float, or bool."""
342
+ if v is None:
343
+ return ""
344
+ if isinstance(v, bool):
345
+ return str(v)
346
+ if isinstance(v, (int, float)):
347
+ return str(v)
348
+ return str(v)
349
+
350
+ # -- Helpers ----------------------------------------------------------
351
+
352
+ @property
353
+ def is_smart_meter(self) -> bool:
354
+ """Whether this account has a smart meter."""
355
+ # pylint: disable=no-member
356
+ return self.is_tagged_smr.lower() == "true"
357
+
358
+ @property
359
+ def is_registered_bool(self) -> bool:
360
+ """isRegistered as a boolean."""
361
+ # pylint: disable=no-member
362
+ return self.is_registered.lower() == "true"
363
+
364
+ @property
365
+ def is_owned_bool(self) -> bool:
366
+ """isOwned as a boolean."""
367
+ # pylint: disable=no-member
368
+ return self.is_owned.lower() == "true"
369
+
370
+ @property
371
+ def is_paid_bool(self) -> bool:
372
+ """isPaid as a boolean."""
373
+ # pylint: disable=no-member
374
+ return self.is_paid.lower() == "true"