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/exceptions.py ADDED
@@ -0,0 +1,47 @@
1
+ """Custom exceptions for the myTNB API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class MyTNBError(Exception):
7
+ """Base exception for myTNB API errors."""
8
+
9
+ def __init__(self, message: str, error_code: str | None = None):
10
+ self.error_code = error_code
11
+ super().__init__(message)
12
+
13
+
14
+ class AuthenticationError(MyTNBError):
15
+ """Raised when authentication fails."""
16
+
17
+
18
+ class APIError(MyTNBError):
19
+ """Raised when the API returns an error response."""
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ error_code: str | None = None,
25
+ display_message: str | None = None,
26
+ ):
27
+ self.display_message = display_message
28
+ super().__init__(message, error_code)
29
+
30
+
31
+ class RateLimitError(MyTNBError):
32
+ """Raised when rate limited by the API."""
33
+
34
+
35
+ class GeoBlockedError(MyTNBError):
36
+ """Raised when the request is blocked due to geographic restrictions.
37
+
38
+ The myTNB API only allows connections from Malaysian IP addresses
39
+ and blocks most VPNs.
40
+ """
41
+
42
+ def __init__(self):
43
+ super().__init__(
44
+ "Access denied — the myTNB API is restricted to Malaysian IP addresses "
45
+ "and blocks VPN connections. Connect from a Malaysian network without a VPN.",
46
+ error_code="403",
47
+ )
mytnb/models.py ADDED
@@ -0,0 +1,236 @@
1
+ """Data models for myTNB API responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class TariffBlock(BaseModel):
11
+ """A tariff block within a billing period."""
12
+
13
+ block_id: str = Field(alias="BlockId")
14
+ amount: float = Field(alias="Amount")
15
+ usage: float = Field(alias="Usage")
16
+ is_block_available: bool = Field(alias="IsBlockAvailable")
17
+ block_pricing: Optional[str] = Field(default=None, alias="BlockPricing")
18
+ peak_usage: Optional[str] = Field(default=None, alias="PeakUsage")
19
+ off_peak_usage: Optional[str] = Field(default=None, alias="OffPeakUsage")
20
+ start_date: Optional[str] = Field(default=None, alias="StartDate")
21
+ end_date: Optional[str] = Field(default=None, alias="EndDate")
22
+
23
+ model_config = {"populate_by_name": True}
24
+
25
+
26
+ class Metric(BaseModel):
27
+ """A usage or cost metric returned by the myTNB API."""
28
+
29
+ key: str = Field(alias="Key")
30
+ title: str = Field(alias="Title")
31
+ sub_title: str = Field(alias="SubTitle")
32
+ value: str = Field(alias="Value")
33
+ value_unit: str = Field(alias="ValueUnit")
34
+ value_indicator: str = Field(default="", alias="ValueIndicator")
35
+
36
+ model_config = {"populate_by_name": True}
37
+
38
+ @property
39
+ def numeric_value(self) -> float:
40
+ try:
41
+ return float(self.value)
42
+ except (ValueError, TypeError):
43
+ return 0.0
44
+
45
+
46
+ UsageMetric = Metric # backward-compatible alias
47
+ CostMetric = Metric # backward-compatible alias
48
+
49
+
50
+ class DailyUsage(BaseModel):
51
+ """Daily electricity usage data."""
52
+
53
+ date: str = Field(alias="Date")
54
+ year: str = Field(alias="Year")
55
+ month: str = Field(alias="Month")
56
+ day: str = Field(alias="Day")
57
+ consumption: str = Field(alias="Consumption")
58
+ amount: str = Field(alias="Amount")
59
+ is_estimated_reading: bool = Field(default=False, alias="IsEstimatedReading")
60
+ is_missing_reading: bool = Field(default=False, alias="IsMissingReading")
61
+ is_current_bill_cycle: bool = Field(default=False, alias="IsCurrentBillCycle")
62
+ tariff_blocks: list[TariffBlock] = Field(default_factory=list, alias="tariffBlocks")
63
+
64
+ model_config = {"populate_by_name": True}
65
+
66
+ @property
67
+ def consumption_kwh(self) -> float:
68
+ return float(self.consumption)
69
+
70
+ @property
71
+ def amount_rm(self) -> float:
72
+ return float(self.amount)
73
+
74
+
75
+ class DailyUsageWeek(BaseModel):
76
+ """A week range of daily usage data."""
77
+
78
+ range: str = Field(alias="Range")
79
+ days: list[DailyUsage] = Field(default_factory=list, alias="Days")
80
+
81
+ model_config = {"populate_by_name": True}
82
+
83
+
84
+ class RP4Usage(BaseModel):
85
+ """RP4 tariff usage breakdown."""
86
+
87
+ block_id: str = Field(alias="BlockId")
88
+ amount: str = Field(alias="Amount")
89
+ usage: str = Field(alias="Usage")
90
+ is_block_available: bool = Field(default=False, alias="IsBlockAvailable")
91
+ peak_usage: Optional[str] = Field(default=None, alias="PeakUsage")
92
+ off_peak_usage: Optional[str] = Field(default=None, alias="OffPeakUsage")
93
+ start_date: Optional[str] = Field(default=None, alias="StartDate")
94
+ end_date: Optional[str] = Field(default=None, alias="EndDate")
95
+
96
+ model_config = {"populate_by_name": True}
97
+
98
+
99
+ class MonthlyTariffBlock(BaseModel):
100
+ """Tariff block for monthly billing."""
101
+
102
+ block_id: str = Field(alias="BlockId")
103
+ amount: float = Field(alias="Amount")
104
+ usage: float = Field(alias="Usage")
105
+ is_block_available: bool = Field(alias="IsBlockAvailable")
106
+ block_pricing: Optional[str] = Field(default=None, alias="BlockPricing")
107
+ rp4_usage: Optional[list[RP4Usage]] = Field(default=None, alias="RP4Usage")
108
+
109
+ model_config = {"populate_by_name": True}
110
+
111
+
112
+ class BillingMonth(BaseModel):
113
+ """Monthly billing data."""
114
+
115
+ billing_no: Optional[str] = Field(default=None, alias="BillingNo")
116
+ date: str = Field(alias="Date")
117
+ year: str = Field(alias="Year")
118
+ month: str = Field(alias="Month")
119
+ day: str = Field(alias="Day")
120
+ amount_total: str = Field(alias="AmountTotal")
121
+ usage_total: str = Field(alias="UsageTotal")
122
+ currency: str = Field(default="RM", alias="Currency")
123
+ usage_unit: str = Field(default="kWh", alias="UsageUnit")
124
+ is_estimated_reading: bool = Field(default=False, alias="IsEstimatedReading")
125
+ is_unbilled: bool = Field(default=False, alias="IsUnbilled")
126
+ tariff_blocks: list[MonthlyTariffBlock] = Field(
127
+ default_factory=list, alias="tariffBlocks"
128
+ )
129
+ billing_start_date: Optional[str] = Field(default=None, alias="BillingStartDate")
130
+ billing_end_date: Optional[str] = Field(default=None, alias="BillingEndDate")
131
+ has_periodic_billing: bool = Field(default=False, alias="HasPeriodicBilling")
132
+
133
+ model_config = {"populate_by_name": True}
134
+
135
+ @property
136
+ def amount_rm(self) -> float:
137
+ return float(self.amount_total)
138
+
139
+ @property
140
+ def usage_kwh(self) -> float:
141
+ return float(self.usage_total)
142
+
143
+
144
+ class ByMonthData(BaseModel):
145
+ """Monthly billing history."""
146
+
147
+ range: str = Field(alias="Range")
148
+ months: list[BillingMonth] = Field(default_factory=list, alias="Months")
149
+
150
+ model_config = {"populate_by_name": True}
151
+
152
+
153
+ class AccountUsage(BaseModel):
154
+ """Full account usage response from GetAccountUsageSmart."""
155
+
156
+ usage_metrics: list[Metric] = Field(default_factory=list)
157
+ cost_metrics: list[Metric] = Field(default_factory=list)
158
+ current_cycle_start_date: Optional[str] = None
159
+ by_month: Optional[ByMonthData] = None
160
+ by_day: list[DailyUsageWeek] = Field(default_factory=list)
161
+ start_date: Optional[str] = None
162
+ end_date: Optional[str] = None
163
+ date_range: Optional[str] = None
164
+
165
+ @classmethod
166
+ def from_api_response(cls, data: dict) -> "AccountUsage":
167
+ """Parse the API response data into an AccountUsage model."""
168
+ other_usage = data.get("OtherUsageMetrics", {})
169
+ usage_list = other_usage.get("Usage", [])
170
+ cost_list = other_usage.get("Cost", [])
171
+
172
+ usage_metrics = [Metric.model_validate(u) for u in usage_list]
173
+ cost_metrics = [Metric.model_validate(c) for c in cost_list]
174
+
175
+ by_month_raw = data.get("ByMonth")
176
+ by_month = ByMonthData.model_validate(by_month_raw) if by_month_raw else None
177
+
178
+ by_day_raw = data.get("ByDay", [])
179
+ by_day = [DailyUsageWeek.model_validate(w) for w in by_day_raw]
180
+
181
+ return cls(
182
+ usage_metrics=usage_metrics,
183
+ cost_metrics=cost_metrics,
184
+ current_cycle_start_date=other_usage.get("CurrentCycleStartDate"),
185
+ by_month=by_month,
186
+ by_day=by_day,
187
+ start_date=data.get("StartDate"),
188
+ end_date=data.get("EndDate"),
189
+ date_range=data.get("DateRange"),
190
+ )
191
+
192
+ @property
193
+ def current_usage_kwh(self) -> Optional[float]:
194
+ for m in self.usage_metrics:
195
+ if m.key == "CURRENTUSAGE":
196
+ return m.numeric_value
197
+ return None
198
+
199
+ @property
200
+ def current_cost_rm(self) -> Optional[float]:
201
+ for m in self.cost_metrics:
202
+ if m.key == "CURRENTCOST":
203
+ return m.numeric_value
204
+ return None
205
+
206
+ @property
207
+ def projected_cost_rm(self) -> Optional[float]:
208
+ for m in self.cost_metrics:
209
+ if m.key == "PROJECTEDCOST":
210
+ return m.numeric_value
211
+ return None
212
+
213
+
214
+ class SMRAccount(BaseModel):
215
+ """Smart Meter Reading account status."""
216
+
217
+ contract_account: str = Field(alias="ContractAccount")
218
+ smr_eligibility: str = Field(alias="SMREligibility")
219
+ is_tagged_smr: str = Field(alias="IsTaggedSMR")
220
+
221
+ model_config = {"populate_by_name": True}
222
+
223
+ @property
224
+ def is_smart_meter(self) -> bool:
225
+ return self.is_tagged_smr.lower() == "true" # pylint: disable=no-member
226
+
227
+
228
+ class BREligibility(BaseModel):
229
+ """Bill Rendering eligibility indicator."""
230
+
231
+ ca_no: str = Field(alias="caNo")
232
+ is_owner_over_rule: bool = Field(default=False, alias="isOwnerOverRule")
233
+ is_owner_already_opt_in: bool = Field(default=False, alias="isOwnerAlreadyOptIn")
234
+ is_tenant_already_opt_in: bool = Field(default=False, alias="isTenantAlreadyOptIn")
235
+
236
+ model_config = {"populate_by_name": True}
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-mytnb
3
+ Version: 0.1.0
4
+ Summary: Python library to interface with the myTNB API (Tenaga Nasional Berhad)
5
+ Project-URL: Repository, https://github.com/danieyal/python-mytnb
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: click>=8.3.3
20
+ Requires-Dist: cryptography>=42.0
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: pydantic>=2.0
23
+ Requires-Dist: rich>=15.0.0
24
+ Requires-Dist: tls-client>=1.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pylint>=3.0; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
28
+ Requires-Dist: pytest>=7.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.6; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # python-mytnb
33
+
34
+ A Python library to interface with the myTNB (Tenaga Nasional Berhad) API for electricity account management and usage monitoring in Malaysia.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ uv add python-mytnb
40
+ ```
41
+
42
+ Or with pip:
43
+
44
+ ```bash
45
+ pip install python-mytnb
46
+ ```
47
+
48
+ ## Quick Start
49
+
50
+ ```python
51
+ import asyncio
52
+ from mytnb import MyTNBClient
53
+
54
+ async def main():
55
+ client = await MyTNBClient.login("user@example.com", "your-password")
56
+
57
+ async with client:
58
+ # Get smart meter usage & billing history
59
+ usage = await client.get_account_usage_smart("220123456789")
60
+
61
+ # Monthly billing breakdown
62
+ for month in usage.by_month.months:
63
+ print(f"{month.month} {month.year}: {month.usage_total} kWh — RM {month.amount_total}")
64
+
65
+ # Bill history & due amount
66
+ history = await client.get_bill_history("220123456789")
67
+ due = await client.get_account_due_amount("220123456789")
68
+
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ ## CLI
73
+
74
+ Pass credentials directly:
75
+
76
+ ```bash
77
+ mytnb --email user@example.com --password yourpass usage 220123456789
78
+ ```
79
+
80
+ Or via environment variables:
81
+
82
+ ```bash
83
+ export MYTNB_EMAIL=user@example.com
84
+ export MYTNB_PASSWORD=yourpass
85
+ mytnb usage 220123456789
86
+ ```
87
+
88
+ All commands:
89
+
90
+ ```
91
+ mytnb login # Test login, show user info
92
+ mytnb usage <account> # Monthly usage & billing summary
93
+ mytnb usage --daily <account> # Daily usage breakdown
94
+ mytnb usage --json <account> # Full usage data as JSON
95
+ mytnb current-usage <account> # Simplified current usage summary
96
+ mytnb due-amount <account> # Outstanding balance
97
+ mytnb bill-history <account> # Payment history
98
+ ```
99
+
100
+ Global options: `--debug` for full tracebacks, `--version`.
101
+
102
+ Use a config file instead of flags (`--config <path>`, `mytnb.json`, or `~/.config/mytnb/config.json`):
103
+
104
+ ```bash
105
+ mytnb init-config # Generate a starter config file
106
+ ```
107
+
108
+ ## API Architecture
109
+
110
+ myTNB uses two API backends, both handled transparently by this library:
111
+
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 |
116
+
117
+ Request encryption for the ASMX API is automatic — just pass plaintext parameters.
118
+
119
+ ## Data Models
120
+
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 |
131
+
132
+ ## Geographic Restrictions
133
+
134
+ The myTNB API only accepts connections from **Malaysian IP addresses** and blocks most VPN services. If you get a `GeoBlockedError` (HTTP 403), make sure you are connecting from a Malaysian network without a VPN.
135
+
136
+ ## Error Handling
137
+
138
+ ```python
139
+ from mytnb.exceptions import MyTNBError, APIError, AuthenticationError, GeoBlockedError
140
+
141
+ try:
142
+ client = await MyTNBClient.login("user@example.com", "password")
143
+ async with client:
144
+ usage = await client.get_account_usage_smart("220123456789")
145
+ except GeoBlockedError:
146
+ print("Blocked — connect from a Malaysian IP without VPN")
147
+ except AuthenticationError:
148
+ print("Invalid email or password")
149
+ except APIError as e:
150
+ print(f"API error {e.error_code}: {e.display_message}")
151
+ except MyTNBError as e:
152
+ print(f"Error: {e}")
153
+ ```
154
+
155
+ ## Development
156
+
157
+ ```bash
158
+ uv sync --extra dev
159
+ uv run pytest
160
+ uv run pylint src/mytnb/
161
+ ```
162
+
163
+ ## License
164
+
165
+ MIT
@@ -0,0 +1,18 @@
1
+ mytnb/__init__.py,sha256=XTMrHOGpHL5XSpQ7_jh-cuuwMNrdZAH3eh4QXkuuwjw,489
2
+ mytnb/__main__.py,sha256=JmAjczPoUDep52BmNaMngOBE1IZSfGtQPAvMPzNbpHA,78
3
+ mytnb/auth.py,sha256=KygAcTVb_AXP3ZIcmNt4Jb5vCKfLAPMu2OC9wTaFZBE,1620
4
+ mytnb/cli.py,sha256=2wPLzo75Q6_ZrzPm-Hy2PxIishgt1E4tl1ke6L2KL-w,12649
5
+ mytnb/crypto.py,sha256=E39ZRHQwTl0jbKtITp9vyRMjXjwFVTbscSc-Z1wIYog,5102
6
+ mytnb/exceptions.py,sha256=lpttDB08gRo74e3RoO8ee7AYMdnsRekoWo0VZ2WwiBQ,1283
7
+ mytnb/models.py,sha256=BcC56KkriM1RXmCdT9guVghhqGtX2Gvt_u_jLvbLPCo,8150
8
+ mytnb/client/__init__.py,sha256=k5TEcrevXSts0rcT1Vvzv9qRSawgCfJLKwRWOkCw-y0,71
9
+ mytnb/client/auth.py,sha256=l-BUha0suxncRhMD8bgP4ECZ9ZTHBB3OCC-MZZyG3eE,4861
10
+ mytnb/client/client.py,sha256=zsdv6j_ESMuMK5-qyJs_AL1QueB0FTy2XjmyoFTvtNg,8476
11
+ mytnb/client/config.py,sha256=bQIg3vOjHCXZw3LFrgkopXcm9qA3jBgUyhjM_IHK0kQ,1243
12
+ mytnb/client/legacy.py,sha256=A4etA3ViDafsfulKGSq59OdwpNqX7bXVaNw0yHVmDwk,3739
13
+ mytnb/client/rest.py,sha256=mW9Und9e4cvemTy64uuNRmN4h7Kx0CQ5-K4uRoVnQGI,3794
14
+ python_mytnb-0.1.0.dist-info/METADATA,sha256=fpmak3IGBqg8DcIqjXiA-rNAeWWimi2W08Ct0esPdMQ,5508
15
+ python_mytnb-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
16
+ python_mytnb-0.1.0.dist-info/entry_points.txt,sha256=8Fm6WPzObtoBS3j0vIXSansgPZBREAVgysltc3ypvTk,41
17
+ python_mytnb-0.1.0.dist-info/licenses/LICENSE,sha256=m-85-NGNTFu9N-mdpF0sYLupMloWk6_kcGbQlEmTfpQ,1071
18
+ python_mytnb-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mytnb = mytnb.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Latiff Danieyal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.