tello-usage 2026.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Jackson
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.
@@ -0,0 +1,229 @@
1
+ Metadata-Version: 2.4
2
+ Name: tello-usage
3
+ Version: 2026.0
4
+ Summary: Retrieve data balances and billing cycle information for every line in a Tello Mobile account.
5
+ Author: William Jackson
6
+ Author-email: William Jackson <william@subtlecoolness.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: beautifulsoup4>=4.15.0
10
+ Requires-Dist: httpx2>=2.16.0
11
+ Requires-Python: >=3.11
12
+ Project-URL: Homepage, https://github.com/williamjacksn/tello-usage
13
+ Project-URL: Issues, https://github.com/williamjacksn/tello-usage/issues
14
+ Description-Content-Type: text/markdown
15
+
16
+ # tello-usage
17
+
18
+ tello-usage is a Python package and command-line tool that retrieves data balances for every line in a Tello Mobile
19
+ account. It reports remaining data, percentage available, renewal dates, and days remaining in each billing cycle.
20
+
21
+ The package uses httpx2 and Beautiful Soup to access Tello Mobile's account pages. It does not require a browser or
22
+ browser automation software.
23
+
24
+ This is an independent, unofficial package. It is not developed by, affiliated with, or endorsed by Tello Mobile.
25
+
26
+ ## Requirements
27
+
28
+ The package requires Python 3.11 or later and a Tello Mobile account with an email address and password.
29
+
30
+ ## Installation
31
+
32
+ Install the command-line tool with uv:
33
+
34
+ ```sh
35
+ uv tool install tello-usage
36
+ ```
37
+
38
+ To use the package in a Python project, add it as a dependency:
39
+
40
+ ```sh
41
+ uv add tello-usage
42
+ ```
43
+
44
+ The distribution is named `tello-usage`. The Python import is named `tello_usage`,
45
+ and the command-line executable is named `tello-usage`.
46
+
47
+ ## Command-line usage
48
+
49
+ Run the following command to retrieve balances:
50
+
51
+ ```sh
52
+ tello-usage
53
+ ```
54
+
55
+ The command prompts for your Tello Mobile email address and password. The password is hidden while you type.
56
+
57
+ Each output line includes the account's line label, remaining data, percentage available, and days left in the billing
58
+ cycle:
59
+
60
+ ```text
61
+ (555) 010-0100 - Example: 457.59 MB remaining / 10 GB (4.58% available); 4 days left in billing cycle
62
+ ```
63
+
64
+ Use `--json` to produce JSON output:
65
+
66
+ ```sh
67
+ tello-usage --json
68
+ ```
69
+
70
+ The output contains one object for each line:
71
+
72
+ ```json
73
+ [
74
+ {
75
+ "subscription_id": "12345678",
76
+ "line": "(555) 010-0100 - Example",
77
+ "remaining": "457.59 MB",
78
+ "allowance": "10 GB",
79
+ "renewal_date": "2026-10-23",
80
+ "percentage_available": 4.58,
81
+ "days_left": 4
82
+ }
83
+ ]
84
+ ```
85
+
86
+ This example assumes the command runs on October 19, 2026.
87
+
88
+ If you installed the package as a project dependency, run the command through uv:
89
+
90
+ ```sh
91
+ uv run tello-usage --json
92
+ ```
93
+
94
+ ### Credentials
95
+
96
+ You can supply your email address with `--username`:
97
+
98
+ ```sh
99
+ tello-usage --username you@example.com
100
+ ```
101
+
102
+ The command also accepts the following environment variables:
103
+
104
+ - `TELLO_USERNAME` supplies the account email address.
105
+ - `TELLO_PASSWORD` supplies the account password.
106
+
107
+ The `--username` argument takes precedence over `TELLO_USERNAME`. Missing credentials are requested through terminal
108
+ prompts.
109
+
110
+ For unattended use, supply both credentials through your process environment. You can also provide the email address
111
+ with `--username`. The command exits with an error if credentials are missing and a terminal prompt is unavailable.
112
+
113
+ There is no password command-line argument. The package does not load `.env` files automatically.
114
+
115
+ ## Python usage
116
+
117
+ Use `TelloClient` in a `with` statement so its HTTP connections are closed after use:
118
+
119
+ ```python
120
+ import os
121
+
122
+ from tello_usage import TelloClient
123
+
124
+ with TelloClient() as client:
125
+ balances = client.balances(
126
+ os.environ["TELLO_USERNAME"],
127
+ os.environ["TELLO_PASSWORD"],
128
+ )
129
+
130
+ for balance in balances:
131
+ print(
132
+ balance.line,
133
+ balance.remaining,
134
+ balance.percentage_available,
135
+ balance.renewal_date,
136
+ balance.days_left,
137
+ )
138
+ ```
139
+
140
+ The synchronous `balances(username, password)` method logs in and returns a list of `Balance` objects.
141
+
142
+ The default HTTP timeout is 30 seconds. You can override it when creating the client:
143
+
144
+ ```python
145
+ with TelloClient(timeout=60) as client:
146
+ balances = client.balances(username, password)
147
+ ```
148
+
149
+ ### Balance values
150
+
151
+ Each `Balance` object provides the following values. JSON output uses the same names.
152
+
153
+ - `subscription_id` is Tello Mobile's identifier for the line.
154
+ - `line` is the label displayed in the account's line selector.
155
+ - `remaining` is the displayed remaining data amount, including its unit.
156
+ - `allowance` is the displayed data allowance, including its unit.
157
+ - `renewal_date` is the data plan's renewal or expiration date in `YYYY-MM-DD` format.
158
+ - `percentage_available` is the remaining data expressed as a percentage of the displayed allowance.
159
+ - `days_left` is the number of calendar days until the renewal date.
160
+
161
+ The percentage calculation uses the displayed amounts, assumes 1 GB equals 1,000 MB, and rounds the result to two
162
+ decimal places. The result is limited to 0–100 percent. A rollover balance above the allowance therefore reports 100
163
+ percent available.
164
+
165
+ An unlimited or zero allowance produces `None` for `percentage_available`.
166
+
167
+ Days remaining are calculated using the computer's local date. A renewal date of today or a date in the past produces
168
+ zero days remaining. This value does not measure the exact number of hours until renewal.
169
+
170
+ If the account page does not provide a recognized renewal date, `renewal_date` and `days_left` are `None`. Python `None`
171
+ values appear as `null` in JSON.
172
+
173
+ ## Error handling
174
+
175
+ The client raises `TelloError` when login fails, required account information is missing, a line cannot be selected, or
176
+ a data balance cannot be read. Network and HTTP errors are raised as httpx2 exceptions.
177
+
178
+ ```python
179
+ import os
180
+
181
+ import httpx2
182
+ from tello_usage import TelloClient, TelloError
183
+
184
+ try:
185
+ with TelloClient() as client:
186
+ balances = client.balances(
187
+ os.environ["TELLO_USERNAME"],
188
+ os.environ["TELLO_PASSWORD"],
189
+ )
190
+ except TelloError as error:
191
+ print(f"Could not read account balances: {error}")
192
+ except httpx2.HTTPError as error:
193
+ print(f"The HTTP request failed: {error}")
194
+ ```
195
+
196
+ The command-line tool writes errors to standard error and exits with a nonzero status.
197
+
198
+ A missing data balance stops the operation without returning partial results. This can occur when a line has no active
199
+ data plan.
200
+
201
+ ## Website access and limitations
202
+
203
+ The package accesses Tello Mobile's website forms rather than a documented API. It logs in, discovers the account's
204
+ lines, and selects each line to read its balance. It maintains session cookies and reads fresh form tokens between
205
+ requests.
206
+
207
+ The client changes the selected account line while retrieving balances. It does not purchase data or change plan
208
+ settings.
209
+
210
+ Changes to Tello Mobile's website, a CAPTCHA, or an additional account verification step may prevent the client from
211
+ working.
212
+
213
+ Credentials and session cookies are held in memory and are not saved by the package. No HAR recording is required.
214
+
215
+ ## Development
216
+
217
+ From a source checkout, install dependencies and run the tests with uv:
218
+
219
+ ```sh
220
+ uv sync
221
+ uv run python -m unittest discover -s tests -v
222
+ ```
223
+
224
+ The tests use generated account pages and simulated HTTP responses. They do not require account credentials or make
225
+ requests to Tello Mobile.
226
+
227
+ ## License
228
+
229
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,214 @@
1
+ # tello-usage
2
+
3
+ tello-usage is a Python package and command-line tool that retrieves data balances for every line in a Tello Mobile
4
+ account. It reports remaining data, percentage available, renewal dates, and days remaining in each billing cycle.
5
+
6
+ The package uses httpx2 and Beautiful Soup to access Tello Mobile's account pages. It does not require a browser or
7
+ browser automation software.
8
+
9
+ This is an independent, unofficial package. It is not developed by, affiliated with, or endorsed by Tello Mobile.
10
+
11
+ ## Requirements
12
+
13
+ The package requires Python 3.11 or later and a Tello Mobile account with an email address and password.
14
+
15
+ ## Installation
16
+
17
+ Install the command-line tool with uv:
18
+
19
+ ```sh
20
+ uv tool install tello-usage
21
+ ```
22
+
23
+ To use the package in a Python project, add it as a dependency:
24
+
25
+ ```sh
26
+ uv add tello-usage
27
+ ```
28
+
29
+ The distribution is named `tello-usage`. The Python import is named `tello_usage`,
30
+ and the command-line executable is named `tello-usage`.
31
+
32
+ ## Command-line usage
33
+
34
+ Run the following command to retrieve balances:
35
+
36
+ ```sh
37
+ tello-usage
38
+ ```
39
+
40
+ The command prompts for your Tello Mobile email address and password. The password is hidden while you type.
41
+
42
+ Each output line includes the account's line label, remaining data, percentage available, and days left in the billing
43
+ cycle:
44
+
45
+ ```text
46
+ (555) 010-0100 - Example: 457.59 MB remaining / 10 GB (4.58% available); 4 days left in billing cycle
47
+ ```
48
+
49
+ Use `--json` to produce JSON output:
50
+
51
+ ```sh
52
+ tello-usage --json
53
+ ```
54
+
55
+ The output contains one object for each line:
56
+
57
+ ```json
58
+ [
59
+ {
60
+ "subscription_id": "12345678",
61
+ "line": "(555) 010-0100 - Example",
62
+ "remaining": "457.59 MB",
63
+ "allowance": "10 GB",
64
+ "renewal_date": "2026-10-23",
65
+ "percentage_available": 4.58,
66
+ "days_left": 4
67
+ }
68
+ ]
69
+ ```
70
+
71
+ This example assumes the command runs on October 19, 2026.
72
+
73
+ If you installed the package as a project dependency, run the command through uv:
74
+
75
+ ```sh
76
+ uv run tello-usage --json
77
+ ```
78
+
79
+ ### Credentials
80
+
81
+ You can supply your email address with `--username`:
82
+
83
+ ```sh
84
+ tello-usage --username you@example.com
85
+ ```
86
+
87
+ The command also accepts the following environment variables:
88
+
89
+ - `TELLO_USERNAME` supplies the account email address.
90
+ - `TELLO_PASSWORD` supplies the account password.
91
+
92
+ The `--username` argument takes precedence over `TELLO_USERNAME`. Missing credentials are requested through terminal
93
+ prompts.
94
+
95
+ For unattended use, supply both credentials through your process environment. You can also provide the email address
96
+ with `--username`. The command exits with an error if credentials are missing and a terminal prompt is unavailable.
97
+
98
+ There is no password command-line argument. The package does not load `.env` files automatically.
99
+
100
+ ## Python usage
101
+
102
+ Use `TelloClient` in a `with` statement so its HTTP connections are closed after use:
103
+
104
+ ```python
105
+ import os
106
+
107
+ from tello_usage import TelloClient
108
+
109
+ with TelloClient() as client:
110
+ balances = client.balances(
111
+ os.environ["TELLO_USERNAME"],
112
+ os.environ["TELLO_PASSWORD"],
113
+ )
114
+
115
+ for balance in balances:
116
+ print(
117
+ balance.line,
118
+ balance.remaining,
119
+ balance.percentage_available,
120
+ balance.renewal_date,
121
+ balance.days_left,
122
+ )
123
+ ```
124
+
125
+ The synchronous `balances(username, password)` method logs in and returns a list of `Balance` objects.
126
+
127
+ The default HTTP timeout is 30 seconds. You can override it when creating the client:
128
+
129
+ ```python
130
+ with TelloClient(timeout=60) as client:
131
+ balances = client.balances(username, password)
132
+ ```
133
+
134
+ ### Balance values
135
+
136
+ Each `Balance` object provides the following values. JSON output uses the same names.
137
+
138
+ - `subscription_id` is Tello Mobile's identifier for the line.
139
+ - `line` is the label displayed in the account's line selector.
140
+ - `remaining` is the displayed remaining data amount, including its unit.
141
+ - `allowance` is the displayed data allowance, including its unit.
142
+ - `renewal_date` is the data plan's renewal or expiration date in `YYYY-MM-DD` format.
143
+ - `percentage_available` is the remaining data expressed as a percentage of the displayed allowance.
144
+ - `days_left` is the number of calendar days until the renewal date.
145
+
146
+ The percentage calculation uses the displayed amounts, assumes 1 GB equals 1,000 MB, and rounds the result to two
147
+ decimal places. The result is limited to 0–100 percent. A rollover balance above the allowance therefore reports 100
148
+ percent available.
149
+
150
+ An unlimited or zero allowance produces `None` for `percentage_available`.
151
+
152
+ Days remaining are calculated using the computer's local date. A renewal date of today or a date in the past produces
153
+ zero days remaining. This value does not measure the exact number of hours until renewal.
154
+
155
+ If the account page does not provide a recognized renewal date, `renewal_date` and `days_left` are `None`. Python `None`
156
+ values appear as `null` in JSON.
157
+
158
+ ## Error handling
159
+
160
+ The client raises `TelloError` when login fails, required account information is missing, a line cannot be selected, or
161
+ a data balance cannot be read. Network and HTTP errors are raised as httpx2 exceptions.
162
+
163
+ ```python
164
+ import os
165
+
166
+ import httpx2
167
+ from tello_usage import TelloClient, TelloError
168
+
169
+ try:
170
+ with TelloClient() as client:
171
+ balances = client.balances(
172
+ os.environ["TELLO_USERNAME"],
173
+ os.environ["TELLO_PASSWORD"],
174
+ )
175
+ except TelloError as error:
176
+ print(f"Could not read account balances: {error}")
177
+ except httpx2.HTTPError as error:
178
+ print(f"The HTTP request failed: {error}")
179
+ ```
180
+
181
+ The command-line tool writes errors to standard error and exits with a nonzero status.
182
+
183
+ A missing data balance stops the operation without returning partial results. This can occur when a line has no active
184
+ data plan.
185
+
186
+ ## Website access and limitations
187
+
188
+ The package accesses Tello Mobile's website forms rather than a documented API. It logs in, discovers the account's
189
+ lines, and selects each line to read its balance. It maintains session cookies and reads fresh form tokens between
190
+ requests.
191
+
192
+ The client changes the selected account line while retrieving balances. It does not purchase data or change plan
193
+ settings.
194
+
195
+ Changes to Tello Mobile's website, a CAPTCHA, or an additional account verification step may prevent the client from
196
+ working.
197
+
198
+ Credentials and session cookies are held in memory and are not saved by the package. No HAR recording is required.
199
+
200
+ ## Development
201
+
202
+ From a source checkout, install dependencies and run the tests with uv:
203
+
204
+ ```sh
205
+ uv sync
206
+ uv run python -m unittest discover -s tests -v
207
+ ```
208
+
209
+ The tests use generated account pages and simulated HTTP responses. They do not require account credentials or make
210
+ requests to Tello Mobile.
211
+
212
+ ## License
213
+
214
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "tello-usage"
3
+ version = "2026.0"
4
+ description = "Retrieve data balances and billing cycle information for every line in a Tello Mobile account."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ dependencies = [
10
+ "beautifulsoup4>=4.15.0",
11
+ "httpx2>=2.16.0",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "William Jackson"
16
+ email = "william@subtlecoolness.com"
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/williamjacksn/tello-usage"
20
+ Issues = "https://github.com/williamjacksn/tello-usage/issues"
21
+
22
+ [project.scripts]
23
+ tello-usage = "tello_usage:main"
24
+
25
+ [build-system]
26
+ requires = ["uv-build>=0.13.5,<0.14.0"]
27
+ build-backend = "uv_build"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "ruff>=0.17.1",
32
+ "ty>=0.0.90",
33
+ "uv-build>=0.13.5",
34
+ ]
35
+
36
+ [tool.ruff.lint]
37
+ extend-select = [
38
+ "ANN",
39
+ "DTZ",
40
+ "E",
41
+ "F",
42
+ "FURB",
43
+ "I",
44
+ "ICN",
45
+ "PERF",
46
+ "Q",
47
+ "RSE",
48
+ "RUF",
49
+ "S",
50
+ "UP",
51
+ ]
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "tello-usage"
3
+ version = "2026.0"
4
+ description = "Retrieve data balances and billing cycle information for every line in a Tello Mobile account."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ dependencies = [
10
+ "beautifulsoup4>=4.15.0",
11
+ "httpx2>=2.16.0",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "William Jackson"
16
+ email = "william@subtlecoolness.com"
17
+
18
+ [project.urls]
19
+ "Homepage" = "https://github.com/williamjacksn/tello-usage"
20
+ "Issues" = "https://github.com/williamjacksn/tello-usage/issues"
21
+
22
+ [project.scripts]
23
+ tello-usage = "tello_usage:main"
24
+
25
+ [build-system]
26
+ requires = ["uv-build>=0.13.5,<0.14.0"]
27
+ build-backend = "uv_build"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "ruff>=0.17.1",
32
+ "ty>=0.0.90",
33
+ "uv-build>=0.13.5",
34
+ ]
35
+
36
+ [tool.ruff.lint]
37
+ extend-select = ["ANN", "DTZ", "E", "F", "FURB", "I", "ICN", "PERF", "Q", "RSE", "RUF", "S", "UP"]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+ from .client import Balance, TelloClient, TelloError
3
+
4
+ __all__ = ["Balance", "TelloClient", "TelloError", "main"]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,86 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import sys
5
+ from dataclasses import asdict
6
+ from getpass import getpass
7
+
8
+ import httpx2
9
+
10
+ from .client import TelloClient, TelloError
11
+
12
+
13
+ def main() -> None:
14
+ parser = argparse.ArgumentParser(
15
+ description="Get remaining data balances for all Tello Mobile lines."
16
+ )
17
+ parser.add_argument(
18
+ "--username",
19
+ help="Tello Mobile email address (defaults to TELLO_USERNAME or a prompt)",
20
+ )
21
+ parser.add_argument("--json", action="store_true", help="Print balances as JSON")
22
+ args = parser.parse_args()
23
+ username = args.username or os.environ.get("TELLO_USERNAME")
24
+ password = os.environ.get("TELLO_PASSWORD")
25
+ try:
26
+ if not username:
27
+ if not sys.stdin.isatty():
28
+ parser.error(
29
+ "set TELLO_USERNAME or pass --username "
30
+ "when running without a terminal"
31
+ )
32
+ username = input("Tello Mobile email: ").strip()
33
+ if not password:
34
+ if not sys.stdin.isatty():
35
+ parser.error("set TELLO_PASSWORD when running without a terminal")
36
+ password = getpass("Tello Mobile password: ")
37
+ if not username or not password:
38
+ parser.error("username and password must not be empty")
39
+ with TelloClient() as client:
40
+ balances = client.balances(username, password)
41
+ except (EOFError, KeyboardInterrupt):
42
+ parser.exit(130, "\nCanceled.\n")
43
+ except TelloError as error:
44
+ parser.exit(1, f"Error: {error}\n")
45
+ except httpx2.HTTPStatusError as error:
46
+ parser.exit(
47
+ 1, f"Error: Tello Mobile returned HTTP {error.response.status_code}.\n"
48
+ )
49
+ except httpx2.RequestError:
50
+ parser.exit(
51
+ 1,
52
+ "Error: Could not connect to Tello Mobile. "
53
+ "Check your network connection and try again.\n",
54
+ )
55
+ if args.json:
56
+ print(
57
+ json.dumps(
58
+ [
59
+ asdict(balance)
60
+ | {
61
+ "percentage_available": balance.percentage_available,
62
+ "days_left": balance.days_left,
63
+ }
64
+ for balance in balances
65
+ ],
66
+ indent=2,
67
+ )
68
+ )
69
+ else:
70
+ for balance in balances:
71
+ available = balance.percentage_available
72
+ percentage = (
73
+ f"{available:.2f}% available"
74
+ if available is not None
75
+ else "percentage unavailable"
76
+ )
77
+ days = balance.days_left
78
+ cycle = (
79
+ f"{days} {'day' if days == 1 else 'days'} left in billing cycle"
80
+ if days is not None
81
+ else "billing cycle days unavailable"
82
+ )
83
+ print(
84
+ f"{balance.line}: {balance.remaining} remaining / "
85
+ f"{balance.allowance} ({percentage}); {cycle}"
86
+ )
@@ -0,0 +1,259 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+ from datetime import date
4
+ from decimal import Decimal
5
+ from importlib.metadata import version
6
+ from types import TracebackType
7
+ from typing import Self
8
+ from urllib.parse import urljoin, urlsplit
9
+
10
+ import httpx2
11
+ from bs4 import BeautifulSoup, Tag
12
+
13
+ BASE_URL = "https://tello.com"
14
+ DATA_AMOUNT = r"\d[\d,]*(?:\.\d+)?\s*(?:KB|MB|GB|TB)"
15
+ DATA_BALANCE = re.compile(
16
+ rf"(?P<remaining>{DATA_AMOUNT})\s+remaining\s*/\s*"
17
+ rf"(?P<allowance>{DATA_AMOUNT}|Unlimited(?:\s+data)?)",
18
+ re.IGNORECASE,
19
+ )
20
+
21
+
22
+ class TelloError(Exception):
23
+ """Authentication failed or Tello Mobile returned an unexpected account page."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Balance:
28
+ subscription_id: str
29
+ line: str
30
+ remaining: str
31
+ allowance: str
32
+ renewal_date: str | None = None
33
+
34
+ @property
35
+ def days_left(self) -> int | None:
36
+ """Calendar days until renewal, using the computer's local date."""
37
+ if self.renewal_date is None:
38
+ return None
39
+ # Billing cycles use the local calendar date, not a timestamp.
40
+ today = date.today() # noqa: DTZ011
41
+ return max(0, (date.fromisoformat(self.renewal_date) - today).days)
42
+
43
+ @property
44
+ def percentage_available(self) -> float | None:
45
+ """Estimate available data from displayed balances, using 1 GB = 1,000 MB."""
46
+
47
+ def amount(value: str) -> Decimal | None:
48
+ match = re.fullmatch(r"([\d,.]+)\s*(KB|MB|GB|TB)", value, re.IGNORECASE)
49
+ if match is None:
50
+ return None
51
+ unit = match[2].upper()
52
+ return Decimal(match[1].replace(",", "")) * 1000 ** (
53
+ "KB",
54
+ "MB",
55
+ "GB",
56
+ "TB",
57
+ ).index(unit)
58
+
59
+ remaining = amount(self.remaining)
60
+ allowance = amount(self.allowance)
61
+ if remaining is None or allowance is None or allowance <= 0:
62
+ return None
63
+ available = remaining / allowance * 100
64
+ return float(
65
+ max(Decimal(0), min(Decimal(100), available)).quantize(Decimal("0.01"))
66
+ )
67
+
68
+
69
+ def _form_fields(form: Tag) -> dict[str, str]:
70
+ """Include fresh CSRF, snowman and dynamically named honeypot fields."""
71
+ return {
72
+ str(field["name"]): str(field.get("value", ""))
73
+ for field in form.select("input[name]")
74
+ if not field.has_attr("disabled")
75
+ and field.get("type", "text") not in {"submit", "button", "checkbox", "radio"}
76
+ }
77
+
78
+
79
+ def _form(soup: BeautifulSoup, selector: str, action: str) -> Tag:
80
+ form = soup.select_one(selector)
81
+ if form is None:
82
+ raise TelloError(
83
+ "Expected Tello Mobile form is missing. Login may require verification, "
84
+ "or the website has changed."
85
+ )
86
+ if (
87
+ urljoin(BASE_URL, str(form.get("action", ""))) != BASE_URL + action
88
+ or str(form.get("method", "get")).lower() != "post"
89
+ ):
90
+ raise TelloError("Tello Mobile returned an unexpected form action or method.")
91
+ if not _form_fields(form).get("_token"):
92
+ raise TelloError("Tello Mobile's form is missing its CSRF token.")
93
+ return form
94
+
95
+
96
+ def _account_page(html: str) -> BeautifulSoup:
97
+ soup = BeautifulSoup(html, "html.parser")
98
+ if soup.select_one('#account_login_form, input[name="password"]'):
99
+ raise TelloError(
100
+ "Tello Mobile login failed or the session expired. Check your credentials "
101
+ "and whether Tello Mobile requires account verification."
102
+ )
103
+ return soup
104
+
105
+
106
+ def _lines(soup: BeautifulSoup) -> tuple[dict[str, str], str, Tag]:
107
+ selector = soup.select_one('select[name="selected_subscription_id"]')
108
+ if selector is None:
109
+ raise TelloError(
110
+ "Tello Mobile's account line selector is missing; cannot enumerate lines."
111
+ )
112
+ form = _form(
113
+ soup,
114
+ 'form:has(select[name="selected_subscription_id"])',
115
+ "/account/process_home",
116
+ )
117
+ lines = {
118
+ str(option["value"]): option.get_text(" ", strip=True)
119
+ for option in selector.select("option[value]")
120
+ if option["value"] and not option.has_attr("disabled")
121
+ }
122
+ selected = selector.select_one("option[selected]")
123
+ if selected is None:
124
+ selected = selector.select_one("option[value]")
125
+ current = str(selected.get("value", "")) if selected else ""
126
+ if not lines or current not in lines:
127
+ raise TelloError("Tello Mobile returned no selectable account lines.")
128
+ if _form_fields(form).get("action") != "change_selected_subscription":
129
+ raise TelloError("Tello Mobile returned an unexpected line-selection action.")
130
+ return lines, current, form
131
+
132
+
133
+ def _renewal_date(detail: Tag) -> str | None:
134
+ # Use the date beside the data balance, not voice/text or add-on dates.
135
+ holder = detail.find_parent(class_="progress_holder")
136
+ if holder is None:
137
+ return None
138
+ for node in holder.select(".valid-date"):
139
+ match = re.fullmatch(
140
+ r"(?:Renewal|Expiration|Expiry) date:\s*(\d{1,2}/\d{1,2}/\d{4})",
141
+ node.get_text(" ", strip=True),
142
+ re.IGNORECASE,
143
+ )
144
+ if match:
145
+ try:
146
+ month, day, year = map(int, match[1].split("/"))
147
+ return date(year, month, day).isoformat()
148
+ except ValueError:
149
+ return None
150
+ return None
151
+
152
+
153
+ def _balance(soup: BeautifulSoup, subscription_id: str, label: str) -> Balance:
154
+ # Restrict parsing to balance blocks, excluding plan offers and add-ons.
155
+ for detail in soup.select(".balance-details .balance-from"):
156
+ match = DATA_BALANCE.fullmatch(detail.get_text(" ", strip=True))
157
+ if match:
158
+ return Balance(
159
+ subscription_id,
160
+ label,
161
+ match["remaining"],
162
+ match["allowance"],
163
+ _renewal_date(detail),
164
+ )
165
+ raise TelloError(
166
+ "Could not find a remaining data balance for an account line. The line may "
167
+ "have no active data plan, or Tello Mobile's page format has changed."
168
+ )
169
+
170
+
171
+ class TelloClient:
172
+ """A fresh, in-memory cookie session; close it after fetching balances."""
173
+
174
+ def __init__(
175
+ self,
176
+ *,
177
+ timeout: float = 30,
178
+ transport: httpx2.BaseTransport | None = None,
179
+ ) -> None:
180
+ _version = version("tello-usage")
181
+ self._client = httpx2.Client(
182
+ base_url=BASE_URL,
183
+ timeout=timeout,
184
+ follow_redirects=True,
185
+ headers={"User-Agent": f"tello-usage/{_version}"},
186
+ event_hooks={"request": [self._check_origin]},
187
+ transport=transport,
188
+ )
189
+
190
+ @staticmethod
191
+ def _check_origin(request: httpx2.Request) -> None:
192
+ url = urlsplit(str(request.url))
193
+ if (url.scheme, url.hostname, url.port) != ("https", "tello.com", None):
194
+ raise TelloError(
195
+ "Tello Mobile redirected outside the expected secure account website."
196
+ )
197
+
198
+ def __enter__(self) -> Self:
199
+ return self
200
+
201
+ def __exit__(
202
+ self,
203
+ exc_type: type[BaseException] | None,
204
+ exc: BaseException | None,
205
+ traceback: TracebackType | None,
206
+ ) -> None:
207
+ self._client.close()
208
+
209
+ def _request(
210
+ self,
211
+ method: str,
212
+ path: str,
213
+ *,
214
+ data: dict[str, str] | None = None,
215
+ headers: dict[str, str] | None = None,
216
+ ) -> str:
217
+ response = self._client.request(method, path, data=data, headers=headers)
218
+ response.raise_for_status()
219
+ return response.text
220
+
221
+ def balances(self, username: str, password: str) -> list[Balance]:
222
+ """Log in, enumerate every line and read each line's data balance."""
223
+ login = BeautifulSoup(self._request("GET", "/account/login"), "html.parser")
224
+ form = _form(login, "#account_login_form", "/account/process_login")
225
+ fields = _form_fields(form)
226
+ fields.update(username=username, password=password, return_to="/account/home")
227
+ soup = _account_page(
228
+ self._request(
229
+ "POST",
230
+ "/account/process_login",
231
+ data=fields,
232
+ headers={"Origin": BASE_URL, "Referer": BASE_URL + "/account/login"},
233
+ )
234
+ )
235
+ lines, current, _ = _lines(soup)
236
+ results = {current: _balance(soup, current, lines[current])}
237
+ for subscription_id, label in lines.items():
238
+ if subscription_id in results:
239
+ continue
240
+ # Tokens and honeypot field names rotate after every line switch.
241
+ _, _, switch_form = _lines(soup)
242
+ fields = _form_fields(switch_form)
243
+ fields["selected_subscription_id"] = subscription_id
244
+ soup = _account_page(
245
+ self._request(
246
+ "POST",
247
+ "/account/process_home",
248
+ data=fields,
249
+ headers={"Origin": BASE_URL, "Referer": BASE_URL + "/account/home"},
250
+ )
251
+ )
252
+ _, selected, _ = _lines(soup)
253
+ if selected != subscription_id:
254
+ raise TelloError(
255
+ "Tello Mobile did not switch to the requested line; "
256
+ "stopping to avoid mislabeling balances."
257
+ )
258
+ results[subscription_id] = _balance(soup, subscription_id, label)
259
+ return [results[subscription_id] for subscription_id in lines]