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.
- tello_usage-2026.0/LICENSE +21 -0
- tello_usage-2026.0/PKG-INFO +229 -0
- tello_usage-2026.0/README.md +214 -0
- tello_usage-2026.0/pyproject.toml +51 -0
- tello_usage-2026.0/pyproject.toml.orig +37 -0
- tello_usage-2026.0/src/tello_usage/__init__.py +4 -0
- tello_usage-2026.0/src/tello_usage/__main__.py +4 -0
- tello_usage-2026.0/src/tello_usage/cli.py +86 -0
- tello_usage-2026.0/src/tello_usage/client.py +259 -0
|
@@ -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,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]
|