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.
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/.github/workflows/ci.yml +2 -2
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/.github/workflows/publish.yml +2 -3
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/PKG-INFO +28 -16
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/README.md +26 -14
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/pyproject.toml +2 -2
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/__init__.py +6 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/cli.py +42 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/client/client.py +51 -25
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/client/config.py +1 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/client/legacy.py +11 -4
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/models.py +140 -2
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/tests/test_cli.py +60 -2
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/tests/test_client.py +65 -2
- python_mytnb-0.3.0/tests/test_models.py +513 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/uv.lock +36 -12
- python_mytnb-0.1.0/tests/test_models.py +0 -284
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/.gitignore +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/LICENSE +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/__main__.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/auth.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/client/__init__.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/client/auth.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/client/rest.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/crypto.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/src/mytnb/exceptions.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/tests/test_auth.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.3.0}/tests/test_crypto.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mytnb
|
|
3
|
-
Version: 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
|
+
[](https://badge.fury.io/py/python-mytnb)
|
|
33
|
+
[](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
|
|
113
|
-
| ----------- |
|
|
114
|
-
| REST | `api.mytnb.com.my`
|
|
115
|
-
|
|
|
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
|
|
122
|
-
|
|
|
123
|
-
| `
|
|
124
|
-
| `
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
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
|
+
[](https://badge.fury.io/py/python-mytnb)
|
|
2
|
+
[](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
|
|
82
|
-
| ----------- |
|
|
83
|
-
| REST | `api.mytnb.com.my`
|
|
84
|
-
|
|
|
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
|
|
91
|
-
|
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
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[
|
|
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) ->
|
|
61
|
-
"""Get or create a
|
|
62
|
+
def _legacy_client(self) -> curl_requests.Session:
|
|
63
|
+
"""Get or create a curl_cffi session for legacy ASMX requests.
|
|
62
64
|
|
|
63
|
-
Uses
|
|
65
|
+
Uses a TLS fingerprint to bypass CloudFront WAF.
|
|
64
66
|
"""
|
|
65
67
|
if self._tls_session is None:
|
|
66
|
-
self._tls_session =
|
|
67
|
-
|
|
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
|
|
182
|
-
"""Get
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
"
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
+
"usrInf": usr_inf,
|
|
198
|
+
"deviceInf": self._legacy_transport.base_device_info(),
|
|
199
|
+
"featureInfo": [],
|
|
197
200
|
}
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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"
|