python-mytnb 0.1.0__py3-none-any.whl

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.
mytnb/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """Python library for the myTNB API."""
2
+
3
+ from mytnb.client import MyTNBClient
4
+ from mytnb.crypto import EncryptedPayload, encrypt_request
5
+ from mytnb.models import (
6
+ AccountUsage,
7
+ BillingMonth,
8
+ CostMetric,
9
+ DailyUsage,
10
+ Metric,
11
+ TariffBlock,
12
+ UsageMetric,
13
+ )
14
+
15
+ __all__ = [
16
+ "MyTNBClient",
17
+ "EncryptedPayload",
18
+ "encrypt_request",
19
+ "AccountUsage",
20
+ "BillingMonth",
21
+ "CostMetric",
22
+ "DailyUsage",
23
+ "Metric",
24
+ "TariffBlock",
25
+ "UsageMetric",
26
+ ]
mytnb/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m mytnb`."""
2
+
3
+ from mytnb.cli import main
4
+
5
+ main()
mytnb/auth.py ADDED
@@ -0,0 +1,67 @@
1
+ """Authentication handling for myTNB API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+
9
+ @dataclass
10
+ class DeviceInfo:
11
+ """Device information sent with API requests."""
12
+
13
+ device_id: str
14
+ app_version: str = "4.0.2"
15
+ os_type: str = "2" # 1=Android, 2=iOS
16
+ os_version: str = "18.0"
17
+ device_desc: str = "EN"
18
+ version_code: str = "1425"
19
+
20
+ def to_dict(self) -> dict:
21
+ return {
22
+ "deviceId": self.device_id,
23
+ "appVersion": self.app_version,
24
+ "osType": self.os_type,
25
+ "osVersion": self.os_version,
26
+ "deviceDesc": self.device_desc,
27
+ "versionCode": self.version_code,
28
+ }
29
+
30
+
31
+ @dataclass
32
+ class UserInfo:
33
+ """User information for authenticated requests."""
34
+
35
+ user_name: str
36
+ user_id: str
37
+ display_name: str = ""
38
+ role_id: str = "16"
39
+ language: str = "EN"
40
+
41
+ def to_dict(self) -> dict:
42
+ return {
43
+ "RoleId": self.role_id,
44
+ "UserId": self.user_id,
45
+ "UserName": self.user_name,
46
+ "Lang": self.language,
47
+ }
48
+
49
+
50
+ @dataclass
51
+ class Credentials:
52
+ """API credentials for myTNB."""
53
+
54
+ # REST API (api.mytnb.com.my)
55
+ api_key: str
56
+ authorization_token: str
57
+ # Optional bearer token for some endpoints
58
+ bearer_token: Optional[str] = None
59
+ # Channel API key (static JWT)
60
+ channel_api_key: Optional[str] = None
61
+
62
+ # Legacy API (mytnbapp.tnb.com.my)
63
+ secure_key: Optional[str] = None
64
+
65
+ # User context
66
+ user_info: Optional[UserInfo] = None
67
+ device_info: Optional[DeviceInfo] = None
mytnb/cli.py ADDED
@@ -0,0 +1,379 @@
1
+ """Command-line interface for myTNB API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import click
12
+ from rich.console import Console
13
+ from rich.json import JSON
14
+ from rich.panel import Panel
15
+ from rich.table import Table
16
+
17
+ from mytnb.auth import Credentials, DeviceInfo, UserInfo
18
+ from mytnb.client import MyTNBClient
19
+ from mytnb.exceptions import MyTNBError
20
+
21
+ console = Console()
22
+ err_console = Console(stderr=True)
23
+
24
+
25
+ # ─── Exception handling ──────────────────────────────────────────────────────
26
+
27
+
28
+ def _handle_exception(debug: bool, exc: BaseException) -> None:
29
+ """Handle exceptions with nice output."""
30
+ if isinstance(exc, click.ClickException):
31
+ raise exc
32
+ if isinstance(exc, click.exceptions.Exit):
33
+ sys.exit(exc.code)
34
+ if isinstance(exc, click.exceptions.Abort):
35
+ sys.exit(0)
36
+
37
+ if isinstance(exc, MyTNBError):
38
+ code = f" [{exc.error_code}]" if exc.error_code else ""
39
+ err_console.print(f"[bold red]Error[/] ({type(exc).__name__}{code}): {exc}")
40
+ else:
41
+ err_console.print(f"[bold red]Error[/]: {exc}")
42
+
43
+ if debug:
44
+ err_console.print_exception()
45
+ else:
46
+ err_console.print("[dim]Run with --debug to see the full traceback[/]")
47
+ sys.exit(1)
48
+
49
+
50
+ class CatchAllGroup(click.Group):
51
+ """Click Group that catches all exceptions and prints them nicely."""
52
+
53
+ def invoke(self, ctx: click.Context):
54
+ try:
55
+ return super().invoke(ctx)
56
+ except Exception as exc:
57
+ _handle_exception(ctx.params.get("debug", False), exc)
58
+ return None
59
+
60
+ def main(self, *args, **kwargs):
61
+ try:
62
+ return super().main(*args, **kwargs)
63
+ except KeyboardInterrupt:
64
+ err_console.print("\n[yellow]Aborted![/]")
65
+ sys.exit(1)
66
+
67
+
68
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
69
+
70
+
71
+ def _load_config(path: str | None = None) -> dict:
72
+ """Load credentials from a JSON config file."""
73
+ candidates = []
74
+ if path:
75
+ candidates.append(Path(path))
76
+ if env := os.environ.get("MYTNB_CONFIG"):
77
+ candidates.append(Path(env))
78
+ candidates.append(Path("mytnb.json"))
79
+ candidates.append(Path.home() / ".config" / "mytnb" / "config.json")
80
+
81
+ for p in candidates:
82
+ if p.is_file():
83
+ with open(p, encoding="utf-8") as f:
84
+ return json.load(f)
85
+
86
+ return {}
87
+
88
+
89
+ def _build_credentials(cfg: dict) -> Credentials:
90
+ """Build Credentials from a config dict."""
91
+ user_info = None
92
+ if "user" in cfg:
93
+ u = cfg["user"]
94
+ user_info = UserInfo(
95
+ user_name=u.get("user_name", ""),
96
+ user_id=u.get("user_id", ""),
97
+ language=u.get("language", "EN"),
98
+ )
99
+
100
+ device_info = None
101
+ if "device" in cfg:
102
+ d = cfg["device"]
103
+ device_info = DeviceInfo(
104
+ device_id=d.get("device_id", ""),
105
+ app_version=d.get("app_version", "4.0.2"),
106
+ os_type=d.get("os_type", "2"),
107
+ )
108
+
109
+ return Credentials(
110
+ api_key=cfg.get("api_key", ""),
111
+ authorization_token=cfg.get("authorization_token", ""),
112
+ bearer_token=cfg.get("bearer_token"),
113
+ channel_api_key=cfg.get("channel_api_key"),
114
+ secure_key=cfg.get("secure_key"),
115
+ user_info=user_info,
116
+ device_info=device_info,
117
+ )
118
+
119
+
120
+ def _to_json(data: object) -> str:
121
+ """Serialize any object to JSON string."""
122
+ if hasattr(data, "model_dump"):
123
+ data = data.model_dump()
124
+ elif hasattr(data, "__dict__") and not isinstance(data, dict):
125
+ data = data.__dict__
126
+ return json.dumps(data, indent=2, default=str)
127
+
128
+
129
+ def _print_json(data: object) -> None:
130
+ """Pretty-print any object as highlighted JSON."""
131
+ console.print(JSON(_to_json(data)))
132
+
133
+
134
+ def _run_async(coro):
135
+ """Run an async coroutine."""
136
+ return asyncio.run(coro)
137
+
138
+
139
+ async def _get_client(ctx: click.Context) -> MyTNBClient:
140
+ """Build and return a MyTNBClient from CLI context."""
141
+ email = ctx.obj.get("email")
142
+ password = ctx.obj.get("password")
143
+ config_path = ctx.obj.get("config")
144
+
145
+ if email and password:
146
+ with console.status("[bold green]Logging in..."):
147
+ return await MyTNBClient.login(email, password)
148
+
149
+ cfg = _load_config(config_path)
150
+ if not cfg:
151
+ raise click.UsageError(
152
+ "No credentials. Use --email/--password, set MYTNB_EMAIL/MYTNB_PASSWORD "
153
+ "env vars, or create a config file (run: mytnb init-config)"
154
+ )
155
+ creds = _build_credentials(cfg)
156
+ staging = cfg.get("staging", False)
157
+ return MyTNBClient(creds, use_staging_key=staging)
158
+
159
+
160
+ # ─── CLI definition ─────────────────────────────────────────────────────────
161
+
162
+
163
+ @click.group(cls=CatchAllGroup)
164
+ @click.option("-c", "--config", help="Path to config JSON file.")
165
+ @click.option("-e", "--email", envvar="MYTNB_EMAIL", help="myTNB account email.")
166
+ @click.option("-p", "--password", envvar="MYTNB_PASSWORD", help="myTNB account password.")
167
+ @click.option("--debug", is_flag=True, help="Show full traceback on errors.")
168
+ @click.version_option(package_name="python-mytnb")
169
+ @click.pass_context
170
+ def cli(ctx, config, email, password, debug):
171
+ """CLI for the myTNB API (Tenaga Nasional Berhad)."""
172
+ ctx.ensure_object(dict)
173
+ ctx.obj["config"] = config
174
+ ctx.obj["email"] = email
175
+ ctx.obj["password"] = password
176
+ ctx.obj["debug"] = debug
177
+
178
+
179
+ @cli.command()
180
+ @click.pass_context
181
+ def login(ctx):
182
+ """Test login and show user info."""
183
+
184
+ async def _login():
185
+ client = await _get_client(ctx)
186
+ async with client:
187
+ ui = client.credentials.user_info
188
+ table = Table(title="Login Successful", show_header=False)
189
+ table.add_column("Field", style="bold cyan")
190
+ table.add_column("Value")
191
+ table.add_row("User ID", ui.user_id)
192
+ table.add_row("Email", ui.user_name)
193
+ table.add_row("Display Name", getattr(ui, "display_name", "") or "")
194
+ console.print(table)
195
+
196
+ _run_async(_login())
197
+
198
+
199
+ @cli.command()
200
+ @click.argument("account")
201
+ @click.option("--json", "as_json", is_flag=True, help="Output full JSON instead of table.")
202
+ @click.option("--daily", is_flag=True, help="Show daily usage breakdown instead of monthly.")
203
+ @click.pass_context
204
+ def usage(ctx, account, as_json, daily):
205
+ """Get smart meter usage & billing data."""
206
+
207
+ async def _usage():
208
+ client = await _get_client(ctx)
209
+ async with client:
210
+ with console.status("[bold green]Fetching usage data..."):
211
+ result = await client.get_account_usage_smart(account)
212
+
213
+ if as_json:
214
+ _print_json(result)
215
+ return
216
+
217
+ if daily:
218
+ # Show daily usage table
219
+ for period in result.by_day:
220
+ table = Table(title=f"Daily Usage — {period.range}")
221
+ table.add_column("Date", style="cyan")
222
+ table.add_column("Usage (kWh)", justify="right")
223
+ table.add_column("Cost (RM)", justify="right", style="green")
224
+ for day in period.days:
225
+ table.add_row(
226
+ day.date,
227
+ day.consumption or "0",
228
+ day.amount or "0.00",
229
+ )
230
+ console.print(table)
231
+ console.print()
232
+ return
233
+
234
+ # Show monthly summary table
235
+ table = Table(title=f"Usage — {account}")
236
+ table.add_column("Month", style="cyan")
237
+ table.add_column("Usage (kWh)", justify="right")
238
+ table.add_column("Amount (RM)", justify="right", style="green")
239
+
240
+ if result.by_month:
241
+ for m in result.by_month.months:
242
+ table.add_row(
243
+ f"{m.month} {m.year}",
244
+ m.usage_total or "--",
245
+ m.amount_total or "--",
246
+ )
247
+
248
+ console.print(table)
249
+
250
+ # Current usage metrics
251
+ if result.usage_metrics or result.cost_metrics:
252
+ console.print()
253
+ for metric in result.usage_metrics:
254
+ if metric.value and metric.value != "--":
255
+ console.print(f" [cyan]{metric.title}:[/] {metric.value} {metric.value_unit}")
256
+ for metric in result.cost_metrics:
257
+ if metric.value and metric.value != "--":
258
+ console.print(f" [green]{metric.title}:[/] {metric.value_unit} {metric.value}")
259
+
260
+ _run_async(_usage())
261
+
262
+
263
+ @cli.command("current-usage")
264
+ @click.argument("account")
265
+ @click.pass_context
266
+ def current_usage(ctx, account):
267
+ """Get simplified current usage summary."""
268
+
269
+ async def _current():
270
+ client = await _get_client(ctx)
271
+ async with client:
272
+ with console.status("[bold green]Fetching..."):
273
+ result = await client.get_current_usage(account)
274
+ _print_json(result)
275
+
276
+ _run_async(_current())
277
+
278
+
279
+ @cli.command("due-amount")
280
+ @click.argument("account")
281
+ @click.option("--json", "as_json", is_flag=True, help="Output full JSON.")
282
+ @click.pass_context
283
+ def due_amount(ctx, account, as_json):
284
+ """Get account outstanding balance."""
285
+
286
+ async def _due():
287
+ client = await _get_client(ctx)
288
+ async with client:
289
+ with console.status("[bold green]Fetching..."):
290
+ result = await client.get_account_due_amount(account)
291
+
292
+ if as_json:
293
+ _print_json(result)
294
+ return
295
+
296
+ data = result.get("AccountAmountDue", result) if isinstance(result, dict) else result
297
+ if isinstance(data, dict):
298
+ amount = data.get("amountDue", "--")
299
+ due_date = data.get("billDueDate", "--")
300
+ console.print(Panel(
301
+ f"[bold green]RM {amount}[/]\nDue by [cyan]{due_date}[/]",
302
+ title=f"Due Amount — {account}",
303
+ ))
304
+ else:
305
+ _print_json(result)
306
+
307
+ _run_async(_due())
308
+
309
+
310
+ @cli.command("bill-history")
311
+ @click.argument("account")
312
+ @click.option("--json", "as_json", is_flag=True, help="Output full JSON.")
313
+ @click.pass_context
314
+ def bill_history(ctx, account, as_json):
315
+ """Get bill payment history."""
316
+
317
+ async def _history():
318
+ client = await _get_client(ctx)
319
+ async with client:
320
+ with console.status("[bold green]Fetching..."):
321
+ result = await client.get_bill_history(account)
322
+
323
+ if as_json:
324
+ _print_json(result)
325
+ return
326
+
327
+ if isinstance(result, list):
328
+ table = Table(title=f"Bill History — {account}")
329
+ table.add_column("Date", style="cyan")
330
+ table.add_column("Bill No")
331
+ table.add_column("Amount (RM)", justify="right", style="green")
332
+ for bill in result:
333
+ table.add_row(
334
+ bill.get("DtBill", "--"),
335
+ bill.get("BillingNo", "--"),
336
+ bill.get("AmPayable", "--"),
337
+ )
338
+ console.print(table)
339
+ else:
340
+ _print_json(result)
341
+
342
+ _run_async(_history())
343
+
344
+
345
+ @cli.command("init-config")
346
+ @click.option("-o", "--output", default="mytnb.json", help="Output path.")
347
+ def init_config(output):
348
+ """Generate a starter config file."""
349
+ target = Path(output)
350
+ if target.exists():
351
+ raise click.ClickException(f"{target} already exists")
352
+
353
+ template = {
354
+ "api_key": "",
355
+ "authorization_token": "",
356
+ "secure_key": "",
357
+ "user": {
358
+ "user_name": "",
359
+ "user_id": "",
360
+ "language": "EN",
361
+ },
362
+ "device": {
363
+ "device_id": "",
364
+ "app_version": "4.0.2",
365
+ "os_type": "2",
366
+ },
367
+ }
368
+ target.parent.mkdir(parents=True, exist_ok=True)
369
+ with open(target, "w", encoding="utf-8") as f:
370
+ json.dump(template, f, indent=2)
371
+ console.print(f"[green]Config written to[/] {target}")
372
+
373
+
374
+ def main() -> None:
375
+ cli() # pylint: disable=no-value-for-parameter
376
+
377
+
378
+ if __name__ == "__main__":
379
+ main()
@@ -0,0 +1,3 @@
1
+ from mytnb.client.client import MyTNBClient
2
+
3
+ __all__ = ["MyTNBClient"]
mytnb/client/auth.py ADDED
@@ -0,0 +1,159 @@
1
+ """Authentication login flow for myTNB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import logging
8
+ import re
9
+ import uuid
10
+ from typing import TYPE_CHECKING
11
+
12
+ import httpx
13
+
14
+ from mytnb.auth import Credentials, DeviceInfo, UserInfo
15
+ from mytnb.client.config import (
16
+ DEFAULT_API_KEY,
17
+ REST_BASE_URL,
18
+ SITECORE_LOGIN_URL,
19
+ SSO_HANDLER_URL,
20
+ USER_AGENT,
21
+ )
22
+ from mytnb.exceptions import AuthenticationError, GeoBlockedError
23
+
24
+ if TYPE_CHECKING:
25
+ from mytnb.client.client import MyTNBClient
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ async def login(
31
+ cls: type[MyTNBClient],
32
+ email: str,
33
+ password: str,
34
+ *,
35
+ device_id: str | None = None,
36
+ timeout: float = 30.0,
37
+ use_staging_key: bool = False,
38
+ ) -> MyTNBClient:
39
+ """Authenticate with email and password, returning a ready-to-use client.
40
+
41
+ Performs the full login flow:
42
+ 1. Authenticate via Sitecore web login (plaintext credentials)
43
+ 2. Submit SSO form to get user identity (userId)
44
+ 3. Generate an access token via the REST Identity API
45
+ """
46
+ if not device_id:
47
+ device_id = str(uuid.uuid4()).upper()
48
+
49
+ device_info = DeviceInfo(device_id=device_id)
50
+
51
+ async with httpx.AsyncClient(
52
+ follow_redirects=False,
53
+ timeout=timeout,
54
+ headers={"User-Agent": USER_AGENT},
55
+ ) as http:
56
+ await http.get("https://www.mytnb.com.my/")
57
+
58
+ login_resp = await http.post(
59
+ SITECORE_LOGIN_URL,
60
+ data={"Email": email, "Password": password},
61
+ headers={
62
+ "Content-Type": "application/x-www-form-urlencoded",
63
+ "Origin": "https://www.mytnb.com.my",
64
+ "Referer": "https://www.mytnb.com.my/",
65
+ },
66
+ )
67
+ logger.info("Sitecore login response: %s", login_resp.status_code)
68
+
69
+ if login_resp.status_code == 403:
70
+ raise GeoBlockedError()
71
+ if login_resp.status_code != 200:
72
+ raise AuthenticationError(
73
+ "Login failed",
74
+ error_code=str(login_resp.status_code),
75
+ )
76
+
77
+ sso_fields = dict(
78
+ re.findall(
79
+ r'name="([^"]+)"\s+value="([^"]*)"',
80
+ login_resp.text,
81
+ )
82
+ )
83
+ if "USERNAME" not in sso_fields:
84
+ raise AuthenticationError(
85
+ "Invalid credentials or unexpected login response",
86
+ error_code="LOGIN_FAILED",
87
+ )
88
+
89
+ sso_resp = await http.post(
90
+ SSO_HANDLER_URL,
91
+ data=sso_fields,
92
+ headers={
93
+ "Content-Type": "application/x-www-form-urlencoded",
94
+ "Origin": "https://www.mytnb.com.my",
95
+ },
96
+ )
97
+
98
+ if sso_resp.status_code != 200:
99
+ raise AuthenticationError(
100
+ "SSO authentication failed",
101
+ error_code=str(sso_resp.status_code),
102
+ )
103
+
104
+ user_id = None
105
+ user_name = email
106
+ display_name = ""
107
+ for cookie_header in sso_resp.headers.get_list("set-cookie"):
108
+ if "eyJhbGci" not in cookie_header:
109
+ continue
110
+ jwt_match = re.search(r"=(eyJ[^;]+)", cookie_header)
111
+ if not jwt_match:
112
+ continue
113
+ parts = jwt_match.group(1).split(".")
114
+ payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
115
+ jwt_data = json.loads(base64.b64decode(payload_b64))
116
+ ui = json.loads(jwt_data["UserInfo"])
117
+ user_id = ui["UserId"]
118
+ user_name = ui.get("UserName", email)
119
+ display_name = ui.get("DisplayName", "")
120
+ break
121
+
122
+ if not user_id:
123
+ raise AuthenticationError(
124
+ "Failed to extract user identity from SSO response",
125
+ error_code="SSO_PARSE_FAILED",
126
+ )
127
+
128
+ token_resp = await http.post(
129
+ f"{REST_BASE_URL}/Identity/api/v1/Identity/GenerateAccessToken",
130
+ headers={
131
+ "Content-Type": "application/json",
132
+ "x-api-key": DEFAULT_API_KEY,
133
+ "Accept": "application/json",
134
+ },
135
+ params={"environment": "Prod"},
136
+ json={
137
+ "channel": "myTNB_API_Mobile",
138
+ "userId": user_id,
139
+ },
140
+ )
141
+ token_resp.raise_for_status()
142
+ token_data = token_resp.json()
143
+ access_token = token_data["content"]["accessToken"]
144
+
145
+ secure_key = str(uuid.uuid4()).upper()
146
+
147
+ credentials = Credentials(
148
+ api_key=DEFAULT_API_KEY,
149
+ authorization_token=access_token,
150
+ secure_key=secure_key,
151
+ user_info=UserInfo(
152
+ user_name=user_name,
153
+ user_id=user_id,
154
+ display_name=display_name,
155
+ ),
156
+ device_info=device_info,
157
+ )
158
+
159
+ return cls(credentials, timeout=timeout, use_staging_key=use_staging_key)