python-mytnb 0.1.0__tar.gz → 0.2.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.2.0}/.github/workflows/ci.yml +2 -2
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/.github/workflows/publish.yml +2 -3
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/PKG-INFO +27 -15
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/README.md +26 -14
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/pyproject.toml +1 -1
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/__init__.py +2 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/cli.py +42 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/client/client.py +44 -18
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/client/config.py +1 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/client/legacy.py +7 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/models.py +104 -2
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/tests/test_cli.py +60 -2
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/tests/test_client.py +64 -1
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/tests/test_models.py +139 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/uv.lock +1 -1
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/.gitignore +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/LICENSE +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/__main__.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/auth.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/client/__init__.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/client/auth.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/client/rest.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/crypto.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/exceptions.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.0}/tests/test_auth.py +0 -0
- {python_mytnb-0.1.0 → python_mytnb-0.2.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.2.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
|
|
@@ -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
|
|
|
@@ -6,6 +6,7 @@ from mytnb.models import (
|
|
|
6
6
|
AccountUsage,
|
|
7
7
|
BillingMonth,
|
|
8
8
|
CostMetric,
|
|
9
|
+
CustomerAccount,
|
|
9
10
|
DailyUsage,
|
|
10
11
|
Metric,
|
|
11
12
|
TariffBlock,
|
|
@@ -19,6 +20,7 @@ __all__ = [
|
|
|
19
20
|
"AccountUsage",
|
|
20
21
|
"BillingMonth",
|
|
21
22
|
"CostMetric",
|
|
23
|
+
"CustomerAccount",
|
|
22
24
|
"DailyUsage",
|
|
23
25
|
"Metric",
|
|
24
26
|
"TariffBlock",
|
|
@@ -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
|
|
|
@@ -9,10 +9,12 @@ import tls_client
|
|
|
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:
|
|
@@ -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
|
|
|
@@ -74,6 +74,13 @@ 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
|
|
|
@@ -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):
|
|
@@ -234,3 +234,105 @@ class BREligibility(BaseModel):
|
|
|
234
234
|
is_tenant_already_opt_in: bool = Field(default=False, alias="isTenantAlreadyOptIn")
|
|
235
235
|
|
|
236
236
|
model_config = {"populate_by_name": True}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class CustomerAccount(BaseModel):
|
|
240
|
+
"""A linked/customer account returned by the account auto-discovery endpoint.
|
|
241
|
+
|
|
242
|
+
Uses GET /v3/account/GetAccount via the AWS API gateway.
|
|
243
|
+
Returns all accounts linked to the current user without needing
|
|
244
|
+
to know account numbers ahead of time.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
account_number: str = Field(alias="accNum")
|
|
248
|
+
user_account_id: str = Field(
|
|
249
|
+
default="",
|
|
250
|
+
validation_alias=AliasChoices("userAccountId", "userAccountID"),
|
|
251
|
+
)
|
|
252
|
+
account_desc: str = Field(default="", alias="accDesc")
|
|
253
|
+
ic_num: str = Field(default="", alias="icNum")
|
|
254
|
+
current_charges: str = Field(default="", alias="amCurrentChg")
|
|
255
|
+
is_registered: str = Field(default="", alias="isRegistered")
|
|
256
|
+
is_paid: str = Field(default="", alias="isPaid")
|
|
257
|
+
is_owned: str = Field(default="", alias="isOwned")
|
|
258
|
+
is_error: str = Field(default="", alias="isError")
|
|
259
|
+
message: Optional[str] = Field(default=None, alias="message")
|
|
260
|
+
account_type_id: str = Field(default="", alias="accountTypeId")
|
|
261
|
+
account_st_address: str = Field(default="", alias="accountStAddress")
|
|
262
|
+
owner_name: str = Field(default="", alias="ownerName")
|
|
263
|
+
account_category_id: str = Field(default="", alias="accountCategoryId")
|
|
264
|
+
smart_meter_code: str = Field(
|
|
265
|
+
default="",
|
|
266
|
+
validation_alias=AliasChoices("smartMeterCode", "SmartMeterCode"),
|
|
267
|
+
)
|
|
268
|
+
is_tagged_smr: str = Field(default="", alias="isTaggedSMR")
|
|
269
|
+
is_have_access: bool = Field(default=False, alias="IsHaveAccess")
|
|
270
|
+
is_apply_ebilling: bool = Field(default=False, alias="IsApplyEBilling")
|
|
271
|
+
budget_amount: str = Field(default="", alias="BudgetAmount")
|
|
272
|
+
installation_type: str = Field(default="", alias="InstallationType")
|
|
273
|
+
created_date: str = Field(default="", alias="CreatedDate")
|
|
274
|
+
business_area: str = Field(default="", alias="BusinessArea")
|
|
275
|
+
rate_category: str = Field(default="", alias="RateCategory")
|
|
276
|
+
|
|
277
|
+
model_config = {"populate_by_name": True, "extra": "ignore"}
|
|
278
|
+
|
|
279
|
+
# -- Coerce fields that can arrive as null, numbers, or string bools --
|
|
280
|
+
|
|
281
|
+
@field_validator(
|
|
282
|
+
"account_desc",
|
|
283
|
+
"ic_num",
|
|
284
|
+
"current_charges",
|
|
285
|
+
"budget_amount",
|
|
286
|
+
"account_type_id",
|
|
287
|
+
"account_st_address",
|
|
288
|
+
"owner_name",
|
|
289
|
+
"account_category_id",
|
|
290
|
+
"smart_meter_code",
|
|
291
|
+
"installation_type",
|
|
292
|
+
"created_date",
|
|
293
|
+
"business_area",
|
|
294
|
+
"rate_category",
|
|
295
|
+
"message",
|
|
296
|
+
"is_registered",
|
|
297
|
+
"is_paid",
|
|
298
|
+
"is_owned",
|
|
299
|
+
"is_error",
|
|
300
|
+
"is_tagged_smr",
|
|
301
|
+
mode="before",
|
|
302
|
+
)
|
|
303
|
+
@classmethod
|
|
304
|
+
def _coerce_to_str(cls, v: Any) -> str:
|
|
305
|
+
"""Normalise values that may arrive as null, int, float, or bool."""
|
|
306
|
+
if v is None:
|
|
307
|
+
return ""
|
|
308
|
+
if isinstance(v, bool):
|
|
309
|
+
return str(v)
|
|
310
|
+
if isinstance(v, (int, float)):
|
|
311
|
+
return str(v)
|
|
312
|
+
return str(v)
|
|
313
|
+
|
|
314
|
+
# -- Helpers ----------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def is_smart_meter(self) -> bool:
|
|
318
|
+
"""Whether this account has a smart meter."""
|
|
319
|
+
# pylint: disable=no-member
|
|
320
|
+
return self.is_tagged_smr.lower() == "true"
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def is_registered_bool(self) -> bool:
|
|
324
|
+
"""isRegistered as a boolean."""
|
|
325
|
+
# pylint: disable=no-member
|
|
326
|
+
return self.is_registered.lower() == "true"
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def is_owned_bool(self) -> bool:
|
|
330
|
+
"""isOwned as a boolean."""
|
|
331
|
+
# pylint: disable=no-member
|
|
332
|
+
return self.is_owned.lower() == "true"
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def is_paid_bool(self) -> bool:
|
|
336
|
+
"""isPaid as a boolean."""
|
|
337
|
+
# pylint: disable=no-member
|
|
338
|
+
return self.is_paid.lower() == "true"
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
"""Tests for mytnb.cli module."""
|
|
2
2
|
|
|
3
|
+
# pylint: disable=duplicate-code
|
|
4
|
+
|
|
3
5
|
import json
|
|
4
|
-
from unittest.mock import patch
|
|
6
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
5
7
|
|
|
6
8
|
import pytest
|
|
9
|
+
from click.testing import CliRunner
|
|
7
10
|
|
|
8
|
-
from mytnb.cli import _build_credentials, _load_config, main
|
|
11
|
+
from mytnb.cli import _build_credentials, _load_config, cli, main
|
|
12
|
+
from mytnb.models import CustomerAccount
|
|
9
13
|
|
|
10
14
|
|
|
11
15
|
class TestLoadConfig:
|
|
@@ -87,3 +91,57 @@ class TestInitConfig:
|
|
|
87
91
|
with pytest.raises(SystemExit) as exc_info:
|
|
88
92
|
main()
|
|
89
93
|
assert exc_info.value.code != 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestAccountsCommand:
|
|
97
|
+
"""Tests for the `mytnb accounts` auto-discovery command."""
|
|
98
|
+
|
|
99
|
+
def test_json_output(self, tmp_path):
|
|
100
|
+
"""Test accounts --json produces valid JSON."""
|
|
101
|
+
cfg_file = tmp_path / "config.json"
|
|
102
|
+
cfg_file.write_text(json.dumps({
|
|
103
|
+
"api_key": "k",
|
|
104
|
+
"authorization_token": "t",
|
|
105
|
+
"secure_key": "sk",
|
|
106
|
+
"user": {"user_name": "u@t.com", "user_id": "uid"},
|
|
107
|
+
"device": {"device_id": "did"},
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
sample_acc = CustomerAccount.model_validate({"accNum": "220123456789"})
|
|
111
|
+
mock_client = MagicMock()
|
|
112
|
+
mock_client.get_customer_accounts = AsyncMock(return_value=[sample_acc])
|
|
113
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
114
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
115
|
+
|
|
116
|
+
with patch("mytnb.cli._get_client", AsyncMock(return_value=mock_client)):
|
|
117
|
+
runner = CliRunner()
|
|
118
|
+
result = runner.invoke(
|
|
119
|
+
cli,
|
|
120
|
+
["-c", str(cfg_file), "accounts", "--json"],
|
|
121
|
+
)
|
|
122
|
+
assert result.exit_code == 0
|
|
123
|
+
|
|
124
|
+
def test_no_accounts(self, tmp_path):
|
|
125
|
+
"""Test accounts shows empty message when no linked accounts."""
|
|
126
|
+
cfg_file = tmp_path / "config.json"
|
|
127
|
+
cfg_file.write_text(json.dumps({
|
|
128
|
+
"api_key": "k",
|
|
129
|
+
"authorization_token": "t",
|
|
130
|
+
"secure_key": "sk",
|
|
131
|
+
"user": {"user_name": "u@t.com", "user_id": "uid"},
|
|
132
|
+
"device": {"device_id": "did"},
|
|
133
|
+
}))
|
|
134
|
+
|
|
135
|
+
mock_client = MagicMock()
|
|
136
|
+
mock_client.get_customer_accounts = AsyncMock(return_value=[])
|
|
137
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
138
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
139
|
+
|
|
140
|
+
with patch("mytnb.cli._get_client", AsyncMock(return_value=mock_client)):
|
|
141
|
+
runner = CliRunner()
|
|
142
|
+
result = runner.invoke(
|
|
143
|
+
cli,
|
|
144
|
+
["-c", str(cfg_file), "accounts"],
|
|
145
|
+
)
|
|
146
|
+
assert result.exit_code == 0
|
|
147
|
+
assert "No linked accounts" in result.output
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Tests for mytnb.client API client."""
|
|
2
2
|
|
|
3
|
-
# pylint: disable=protected-access
|
|
3
|
+
# pylint: disable=duplicate-code, protected-access
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
6
|
import json
|
|
@@ -318,6 +318,69 @@ class TestEndpoints:
|
|
|
318
318
|
assert len(accounts) == 1
|
|
319
319
|
assert accounts[0].is_smart_meter is True
|
|
320
320
|
|
|
321
|
+
@pytest.mark.asyncio
|
|
322
|
+
async def test_get_customer_accounts(self):
|
|
323
|
+
response_data = {
|
|
324
|
+
"data": [
|
|
325
|
+
{
|
|
326
|
+
"accNum": "220123456789",
|
|
327
|
+
"userAccountID": "ua-001",
|
|
328
|
+
"accDesc": "JALAN EXAMPLE 123",
|
|
329
|
+
"icNum": "900101-01-1234",
|
|
330
|
+
"amCurrentChg": 45.50,
|
|
331
|
+
"isRegistered": "True",
|
|
332
|
+
"isPaid": "False",
|
|
333
|
+
"isOwned": "True",
|
|
334
|
+
"isError": "false",
|
|
335
|
+
"message": None,
|
|
336
|
+
"accountTypeId": "1",
|
|
337
|
+
"accountStAddress": "NO 123, JALAN EXAMPLE, KL",
|
|
338
|
+
"ownerName": "AHMAD BIN ALI",
|
|
339
|
+
"accountCategoryId": "2",
|
|
340
|
+
"SmartMeterCode": "SMC001",
|
|
341
|
+
"isTaggedSMR": "true",
|
|
342
|
+
"IsHaveAccess": True,
|
|
343
|
+
"IsApplyEBilling": True,
|
|
344
|
+
"BudgetAmount": 150.00,
|
|
345
|
+
"InstallationType": "Residential",
|
|
346
|
+
"CreatedDate": "2024-01-15",
|
|
347
|
+
"BusinessArea": "KL",
|
|
348
|
+
"RateCategory": "Tariff A",
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
"accNum": "220987654321",
|
|
352
|
+
"userAccountID": "ua-002",
|
|
353
|
+
"isOwned": "False",
|
|
354
|
+
"isTaggedSMR": "false",
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
}
|
|
358
|
+
async with MyTNBClient(_creds()) as client:
|
|
359
|
+
with patch.object(
|
|
360
|
+
client._client, "post", new_callable=AsyncMock,
|
|
361
|
+
return_value=_mock_response(response_data),
|
|
362
|
+
):
|
|
363
|
+
result = await client.get_customer_accounts()
|
|
364
|
+
assert len(result) == 2
|
|
365
|
+
assert result[0].account_number == "220123456789"
|
|
366
|
+
assert result[0].owner_name == "AHMAD BIN ALI"
|
|
367
|
+
assert result[0].is_smart_meter is True
|
|
368
|
+
assert result[0].is_owned_bool is True
|
|
369
|
+
assert result[1].account_number == "220987654321"
|
|
370
|
+
assert result[1].is_smart_meter is False
|
|
371
|
+
assert result[1].is_owned_bool is False
|
|
372
|
+
|
|
373
|
+
@pytest.mark.asyncio
|
|
374
|
+
async def test_get_customer_accounts_empty(self):
|
|
375
|
+
response_data = {"data": []}
|
|
376
|
+
async with MyTNBClient(_creds()) as client:
|
|
377
|
+
with patch.object(
|
|
378
|
+
client._client, "post", new_callable=AsyncMock,
|
|
379
|
+
return_value=_mock_response(response_data),
|
|
380
|
+
):
|
|
381
|
+
result = await client.get_customer_accounts()
|
|
382
|
+
assert result == []
|
|
383
|
+
|
|
321
384
|
@pytest.mark.asyncio
|
|
322
385
|
async def test_get_current_usage(self):
|
|
323
386
|
response_data = {
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""Tests for mytnb.models data models."""
|
|
2
2
|
|
|
3
|
+
# pylint: disable=duplicate-code
|
|
4
|
+
|
|
3
5
|
from mytnb.models import (
|
|
4
6
|
AccountUsage,
|
|
5
7
|
BillingMonth,
|
|
6
8
|
BREligibility,
|
|
9
|
+
CustomerAccount,
|
|
7
10
|
DailyUsage,
|
|
8
11
|
Metric,
|
|
9
12
|
MonthlyTariffBlock,
|
|
@@ -282,3 +285,139 @@ class TestBREligibility:
|
|
|
282
285
|
e = BREligibility.model_validate({"caNo": "123"})
|
|
283
286
|
assert e.is_owner_over_rule is False
|
|
284
287
|
assert e.is_owner_already_opt_in is False
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class TestCustomerAccount:
|
|
291
|
+
"""Tests for the GetAccountsV4 / GetAccount response model."""
|
|
292
|
+
|
|
293
|
+
SAMPLE = {
|
|
294
|
+
"accNum": "220123456789",
|
|
295
|
+
"userAccountID": "user-abc-123",
|
|
296
|
+
"accDesc": "JALAN EXAMPLE 123",
|
|
297
|
+
"icNum": "900101-01-1234",
|
|
298
|
+
"amCurrentChg": 45.50,
|
|
299
|
+
"isRegistered": "True",
|
|
300
|
+
"isPaid": "False",
|
|
301
|
+
"isOwned": "True",
|
|
302
|
+
"isError": "false",
|
|
303
|
+
"message": None,
|
|
304
|
+
"accountTypeId": "1",
|
|
305
|
+
"accountStAddress": "NO 123, JALAN EXAMPLE, 50000 KL",
|
|
306
|
+
"ownerName": "AHMAD BIN ALI",
|
|
307
|
+
"accountCategoryId": "2",
|
|
308
|
+
"SmartMeterCode": "SMC001",
|
|
309
|
+
"isTaggedSMR": "true",
|
|
310
|
+
"IsHaveAccess": True,
|
|
311
|
+
"IsApplyEBilling": True,
|
|
312
|
+
"BudgetAmount": 150.00,
|
|
313
|
+
"InstallationType": "Residential",
|
|
314
|
+
"CreatedDate": "2024-01-15",
|
|
315
|
+
"BusinessArea": "KL",
|
|
316
|
+
"RateCategory": "Tariff A",
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
def test_parse_full(self):
|
|
320
|
+
acc = CustomerAccount.model_validate(self.SAMPLE)
|
|
321
|
+
assert acc.account_number == "220123456789"
|
|
322
|
+
assert acc.user_account_id == "user-abc-123"
|
|
323
|
+
assert acc.account_desc == "JALAN EXAMPLE 123"
|
|
324
|
+
assert acc.ic_num == "900101-01-1234"
|
|
325
|
+
assert acc.current_charges == "45.5"
|
|
326
|
+
assert acc.is_registered == "True"
|
|
327
|
+
assert acc.is_paid == "False"
|
|
328
|
+
assert acc.is_owned == "True"
|
|
329
|
+
assert acc.is_error == "false"
|
|
330
|
+
assert acc.message == ""
|
|
331
|
+
assert acc.account_type_id == "1"
|
|
332
|
+
assert acc.account_st_address == "NO 123, JALAN EXAMPLE, 50000 KL"
|
|
333
|
+
assert acc.owner_name == "AHMAD BIN ALI"
|
|
334
|
+
assert acc.account_category_id == "2"
|
|
335
|
+
assert acc.smart_meter_code == "SMC001"
|
|
336
|
+
assert acc.is_tagged_smr == "true"
|
|
337
|
+
assert acc.is_have_access is True
|
|
338
|
+
assert acc.is_apply_ebilling is True
|
|
339
|
+
assert acc.budget_amount == "150.0"
|
|
340
|
+
assert acc.installation_type == "Residential"
|
|
341
|
+
assert acc.created_date == "2024-01-15"
|
|
342
|
+
assert acc.business_area == "KL"
|
|
343
|
+
assert acc.rate_category == "Tariff A"
|
|
344
|
+
|
|
345
|
+
def test_is_smart_meter_true(self):
|
|
346
|
+
acc = CustomerAccount.model_validate(self.SAMPLE)
|
|
347
|
+
assert acc.is_smart_meter is True
|
|
348
|
+
|
|
349
|
+
def test_is_smart_meter_false(self):
|
|
350
|
+
data = {**self.SAMPLE, "isTaggedSMR": "false"}
|
|
351
|
+
acc = CustomerAccount.model_validate(data)
|
|
352
|
+
assert acc.is_smart_meter is False
|
|
353
|
+
|
|
354
|
+
def test_is_smart_meter_case_insensitive(self):
|
|
355
|
+
data = {**self.SAMPLE, "isTaggedSMR": "True"}
|
|
356
|
+
acc = CustomerAccount.model_validate(data)
|
|
357
|
+
assert acc.is_smart_meter is True
|
|
358
|
+
|
|
359
|
+
def test_boolean_helpers(self):
|
|
360
|
+
acc = CustomerAccount.model_validate(self.SAMPLE)
|
|
361
|
+
assert acc.is_registered_bool is True
|
|
362
|
+
assert acc.is_owned_bool is True
|
|
363
|
+
assert acc.is_paid_bool is False
|
|
364
|
+
|
|
365
|
+
def test_coerces_numeric_fields(self):
|
|
366
|
+
"""Fields like amCurrentChg and BudgetAmount arrive as numbers."""
|
|
367
|
+
data = {**self.SAMPLE, "amCurrentChg": 0.0, "BudgetAmount": 0}
|
|
368
|
+
acc = CustomerAccount.model_validate(data)
|
|
369
|
+
assert acc.current_charges == "0.0"
|
|
370
|
+
assert acc.budget_amount == "0"
|
|
371
|
+
|
|
372
|
+
def test_coerces_bool_fields(self):
|
|
373
|
+
"""isRegistered etc arrive as string 'True'/'False'."""
|
|
374
|
+
data = {**self.SAMPLE, "isRegistered": "False", "isOwned": "False"}
|
|
375
|
+
acc = CustomerAccount.model_validate(data)
|
|
376
|
+
assert acc.is_registered == "False"
|
|
377
|
+
assert acc.is_owned == "False"
|
|
378
|
+
|
|
379
|
+
def test_accepts_useraccount_id_camelcase(self):
|
|
380
|
+
"""Should accept userAccountId (lowercase 'd') as well."""
|
|
381
|
+
data = {**self.SAMPLE}
|
|
382
|
+
del data["userAccountID"]
|
|
383
|
+
data["userAccountId"] = "ua-camel"
|
|
384
|
+
acc = CustomerAccount.model_validate(data)
|
|
385
|
+
assert acc.user_account_id == "ua-camel"
|
|
386
|
+
|
|
387
|
+
def test_accepts_smartmeter_code_camelcase(self):
|
|
388
|
+
"""Should accept smartMeterCode (lowercase 's') as well."""
|
|
389
|
+
data = {**self.SAMPLE}
|
|
390
|
+
del data["SmartMeterCode"]
|
|
391
|
+
data["smartMeterCode"] = "SM002"
|
|
392
|
+
acc = CustomerAccount.model_validate(data)
|
|
393
|
+
assert acc.smart_meter_code == "SM002"
|
|
394
|
+
|
|
395
|
+
def test_minimal_fields(self):
|
|
396
|
+
acc = CustomerAccount.model_validate({"accNum": "220000000000"})
|
|
397
|
+
assert acc.account_number == "220000000000"
|
|
398
|
+
assert acc.user_account_id == ""
|
|
399
|
+
assert acc.owner_name == ""
|
|
400
|
+
assert acc.is_smart_meter is False
|
|
401
|
+
|
|
402
|
+
def test_ignores_extra_fields(self):
|
|
403
|
+
"""Extra fields from the API (unitNo, building, etc.) are ignored."""
|
|
404
|
+
data = {**self.SAMPLE, "unitNo": "21-7", "building": "RESIDENSI"}
|
|
405
|
+
acc = CustomerAccount.model_validate(data)
|
|
406
|
+
assert acc.account_number == "220123456789" # still parses fine
|
|
407
|
+
|
|
408
|
+
def test_null_fields_become_empty_strings(self):
|
|
409
|
+
"""API sends null for optional fields like icNum, message, etc."""
|
|
410
|
+
data = {
|
|
411
|
+
"accNum": "220123456789",
|
|
412
|
+
"icNum": None,
|
|
413
|
+
"message": None,
|
|
414
|
+
"accDesc": None,
|
|
415
|
+
"ownerName": None,
|
|
416
|
+
"accountStAddress": None,
|
|
417
|
+
}
|
|
418
|
+
acc = CustomerAccount.model_validate(data)
|
|
419
|
+
assert acc.ic_num == ""
|
|
420
|
+
assert acc.message == ""
|
|
421
|
+
assert acc.account_desc == ""
|
|
422
|
+
assert acc.owner_name == ""
|
|
423
|
+
assert acc.account_st_address == ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|