tescmd 0.1.2__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.
- tescmd/__init__.py +3 -0
- tescmd/__main__.py +5 -0
- tescmd/_internal/__init__.py +0 -0
- tescmd/_internal/async_utils.py +25 -0
- tescmd/_internal/permissions.py +43 -0
- tescmd/_internal/vin.py +44 -0
- tescmd/api/__init__.py +1 -0
- tescmd/api/charging.py +102 -0
- tescmd/api/client.py +189 -0
- tescmd/api/command.py +540 -0
- tescmd/api/energy.py +146 -0
- tescmd/api/errors.py +76 -0
- tescmd/api/partner.py +40 -0
- tescmd/api/sharing.py +65 -0
- tescmd/api/signed_command.py +277 -0
- tescmd/api/user.py +38 -0
- tescmd/api/vehicle.py +150 -0
- tescmd/auth/__init__.py +1 -0
- tescmd/auth/oauth.py +312 -0
- tescmd/auth/server.py +108 -0
- tescmd/auth/token_store.py +273 -0
- tescmd/ble/__init__.py +0 -0
- tescmd/cache/__init__.py +6 -0
- tescmd/cache/keys.py +51 -0
- tescmd/cache/response_cache.py +213 -0
- tescmd/cli/__init__.py +0 -0
- tescmd/cli/_client.py +603 -0
- tescmd/cli/_options.py +126 -0
- tescmd/cli/auth.py +682 -0
- tescmd/cli/billing.py +240 -0
- tescmd/cli/cache.py +85 -0
- tescmd/cli/charge.py +610 -0
- tescmd/cli/climate.py +501 -0
- tescmd/cli/energy.py +385 -0
- tescmd/cli/key.py +611 -0
- tescmd/cli/main.py +601 -0
- tescmd/cli/media.py +146 -0
- tescmd/cli/nav.py +242 -0
- tescmd/cli/partner.py +112 -0
- tescmd/cli/raw.py +75 -0
- tescmd/cli/security.py +495 -0
- tescmd/cli/setup.py +786 -0
- tescmd/cli/sharing.py +188 -0
- tescmd/cli/software.py +81 -0
- tescmd/cli/status.py +106 -0
- tescmd/cli/trunk.py +240 -0
- tescmd/cli/user.py +145 -0
- tescmd/cli/vehicle.py +837 -0
- tescmd/config/__init__.py +0 -0
- tescmd/crypto/__init__.py +19 -0
- tescmd/crypto/ecdh.py +46 -0
- tescmd/crypto/keys.py +122 -0
- tescmd/deploy/__init__.py +0 -0
- tescmd/deploy/github_pages.py +268 -0
- tescmd/models/__init__.py +85 -0
- tescmd/models/auth.py +108 -0
- tescmd/models/command.py +18 -0
- tescmd/models/config.py +63 -0
- tescmd/models/energy.py +56 -0
- tescmd/models/sharing.py +26 -0
- tescmd/models/user.py +37 -0
- tescmd/models/vehicle.py +185 -0
- tescmd/output/__init__.py +5 -0
- tescmd/output/formatter.py +132 -0
- tescmd/output/json_output.py +83 -0
- tescmd/output/rich_output.py +809 -0
- tescmd/protocol/__init__.py +23 -0
- tescmd/protocol/commands.py +175 -0
- tescmd/protocol/encoder.py +122 -0
- tescmd/protocol/metadata.py +116 -0
- tescmd/protocol/payloads.py +621 -0
- tescmd/protocol/protobuf/__init__.py +6 -0
- tescmd/protocol/protobuf/messages.py +564 -0
- tescmd/protocol/session.py +318 -0
- tescmd/protocol/signer.py +84 -0
- tescmd/py.typed +0 -0
- tescmd-0.1.2.dist-info/METADATA +458 -0
- tescmd-0.1.2.dist-info/RECORD +81 -0
- tescmd-0.1.2.dist-info/WHEEL +4 -0
- tescmd-0.1.2.dist-info/entry_points.txt +2 -0
- tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
tescmd/cli/auth.py
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"""CLI commands for authentication (login, logout, status, refresh, export, import, register)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from tescmd._internal.async_utils import run_async
|
|
15
|
+
from tescmd.api.errors import ConfigError
|
|
16
|
+
from tescmd.auth.oauth import (
|
|
17
|
+
login_flow,
|
|
18
|
+
refresh_access_token,
|
|
19
|
+
register_partner_account,
|
|
20
|
+
)
|
|
21
|
+
from tescmd.auth.token_store import TokenStore
|
|
22
|
+
from tescmd.cli._options import global_options
|
|
23
|
+
from tescmd.models.auth import DEFAULT_SCOPES, PARTNER_SCOPES, TokenData, decode_jwt_scopes
|
|
24
|
+
from tescmd.models.config import AppSettings
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from tescmd.cli.main import AppContext
|
|
28
|
+
from tescmd.output.formatter import OutputFormatter
|
|
29
|
+
|
|
30
|
+
DEVELOPER_PORTAL_URL = "https://developer.tesla.com"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Command group
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
auth_group = click.Group("auth", help="Authentication commands")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Commands
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@auth_group.command("login")
|
|
46
|
+
@click.option("--port", type=int, default=8085, help="Local callback port")
|
|
47
|
+
@click.option(
|
|
48
|
+
"--reconsent",
|
|
49
|
+
is_flag=True,
|
|
50
|
+
default=False,
|
|
51
|
+
help="Force Tesla to re-display the scope consent screen.",
|
|
52
|
+
)
|
|
53
|
+
@global_options
|
|
54
|
+
def login_cmd(app_ctx: AppContext, port: int, reconsent: bool) -> None:
|
|
55
|
+
"""Log in via OAuth2 PKCE flow."""
|
|
56
|
+
run_async(_cmd_login(app_ctx, port, reconsent=reconsent))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _cmd_login(app_ctx: AppContext, port: int, *, reconsent: bool = False) -> None:
|
|
60
|
+
formatter = app_ctx.formatter
|
|
61
|
+
settings = AppSettings()
|
|
62
|
+
|
|
63
|
+
client_id = settings.client_id
|
|
64
|
+
client_secret = settings.client_secret
|
|
65
|
+
|
|
66
|
+
redirect_uri = f"http://localhost:{port}/callback"
|
|
67
|
+
|
|
68
|
+
if not client_id:
|
|
69
|
+
if formatter.format == "json":
|
|
70
|
+
formatter.output_error(
|
|
71
|
+
code="missing_client_id",
|
|
72
|
+
message=(
|
|
73
|
+
"TESLA_CLIENT_ID is not set. Register an application at"
|
|
74
|
+
" https://developer.tesla.com and set TESLA_CLIENT_ID"
|
|
75
|
+
" in your environment or .env file."
|
|
76
|
+
),
|
|
77
|
+
command="auth.login",
|
|
78
|
+
)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
# Redirect first-run to the setup wizard for a guided experience
|
|
82
|
+
from tescmd.cli.setup import _cmd_setup
|
|
83
|
+
|
|
84
|
+
await _cmd_setup(app_ctx)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
store = TokenStore(
|
|
88
|
+
profile=app_ctx.profile,
|
|
89
|
+
token_file=settings.token_file,
|
|
90
|
+
config_dir=settings.config_dir,
|
|
91
|
+
)
|
|
92
|
+
region = app_ctx.region or settings.region
|
|
93
|
+
|
|
94
|
+
formatter.rich.info("")
|
|
95
|
+
formatter.rich.info("Opening your browser to sign in to Tesla...")
|
|
96
|
+
formatter.rich.info(
|
|
97
|
+
"When prompted, click [cyan]Select All[/cyan] and then"
|
|
98
|
+
" [cyan]Allow[/cyan] to grant tescmd access."
|
|
99
|
+
)
|
|
100
|
+
formatter.rich.info("[dim]If the browser doesn't open, visit the URL printed below.[/dim]")
|
|
101
|
+
|
|
102
|
+
token = await login_flow(
|
|
103
|
+
client_id=client_id,
|
|
104
|
+
client_secret=client_secret,
|
|
105
|
+
redirect_uri=redirect_uri,
|
|
106
|
+
scopes=DEFAULT_SCOPES,
|
|
107
|
+
port=port,
|
|
108
|
+
token_store=store,
|
|
109
|
+
region=region,
|
|
110
|
+
force_consent=reconsent,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
formatter.rich.info("")
|
|
114
|
+
formatter.rich.info("[bold green]Login successful![/bold green]")
|
|
115
|
+
_warn_missing_scopes(formatter, token, requested=DEFAULT_SCOPES)
|
|
116
|
+
|
|
117
|
+
# Auto-register with the Fleet API (requires client_secret + domain)
|
|
118
|
+
if client_secret and settings.domain:
|
|
119
|
+
await _auto_register(formatter, client_id, client_secret, settings.domain, region)
|
|
120
|
+
else:
|
|
121
|
+
formatter.rich.info("")
|
|
122
|
+
formatter.rich.info("[yellow]Next step:[/yellow] Register your app with the Fleet API.")
|
|
123
|
+
formatter.rich.info(" [cyan]tescmd auth register[/cyan]")
|
|
124
|
+
|
|
125
|
+
formatter.rich.info("")
|
|
126
|
+
formatter.rich.info("Try it out:")
|
|
127
|
+
formatter.rich.info(" [cyan]tescmd vehicle list[/cyan]")
|
|
128
|
+
formatter.rich.info("")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@auth_group.command("logout")
|
|
132
|
+
@global_options
|
|
133
|
+
def logout_cmd(app_ctx: AppContext) -> None:
|
|
134
|
+
"""Clear stored tokens."""
|
|
135
|
+
run_async(_cmd_logout(app_ctx))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _cmd_logout(app_ctx: AppContext) -> None:
|
|
139
|
+
formatter = app_ctx.formatter
|
|
140
|
+
settings = AppSettings()
|
|
141
|
+
store = TokenStore(
|
|
142
|
+
profile=app_ctx.profile,
|
|
143
|
+
token_file=settings.token_file,
|
|
144
|
+
config_dir=settings.config_dir,
|
|
145
|
+
)
|
|
146
|
+
store.clear()
|
|
147
|
+
|
|
148
|
+
if formatter.format == "json":
|
|
149
|
+
formatter.output({"status": "logged_out"}, command="auth.logout")
|
|
150
|
+
else:
|
|
151
|
+
formatter.rich.info("Tokens cleared.")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@auth_group.command("status")
|
|
155
|
+
@global_options
|
|
156
|
+
def status_cmd(app_ctx: AppContext) -> None:
|
|
157
|
+
"""Show authentication status."""
|
|
158
|
+
run_async(_cmd_status(app_ctx))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _cmd_status(app_ctx: AppContext) -> None:
|
|
162
|
+
formatter = app_ctx.formatter
|
|
163
|
+
settings = AppSettings()
|
|
164
|
+
store = TokenStore(
|
|
165
|
+
profile=app_ctx.profile,
|
|
166
|
+
token_file=settings.token_file,
|
|
167
|
+
config_dir=settings.config_dir,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if not store.has_token:
|
|
171
|
+
if formatter.format == "json":
|
|
172
|
+
formatter.output({"authenticated": False}, command="auth.status")
|
|
173
|
+
else:
|
|
174
|
+
formatter.rich.info("Not logged in.")
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
meta = store.metadata or {}
|
|
178
|
+
expires_at = meta.get("expires_at", 0.0)
|
|
179
|
+
now = time.time()
|
|
180
|
+
expires_in = max(0, int(expires_at - now))
|
|
181
|
+
scopes: list[str] = meta.get("scopes", [])
|
|
182
|
+
region: str = meta.get("region", "unknown")
|
|
183
|
+
has_refresh = store.refresh_token is not None
|
|
184
|
+
|
|
185
|
+
# Decode the JWT to show the *actual* granted scopes
|
|
186
|
+
token_scopes: list[str] | None = None
|
|
187
|
+
access_token = store.access_token
|
|
188
|
+
if access_token:
|
|
189
|
+
token_scopes = decode_jwt_scopes(access_token)
|
|
190
|
+
|
|
191
|
+
if formatter.format == "json":
|
|
192
|
+
data: dict[str, object] = {
|
|
193
|
+
"authenticated": True,
|
|
194
|
+
"expires_in": expires_in,
|
|
195
|
+
"scopes": scopes,
|
|
196
|
+
"region": region,
|
|
197
|
+
"has_refresh_token": has_refresh,
|
|
198
|
+
}
|
|
199
|
+
if token_scopes is not None:
|
|
200
|
+
data["token_scopes"] = token_scopes
|
|
201
|
+
formatter.output(data, command="auth.status")
|
|
202
|
+
else:
|
|
203
|
+
formatter.rich.info("Authenticated: yes")
|
|
204
|
+
formatter.rich.info(f"Expires in: {expires_in}s")
|
|
205
|
+
formatter.rich.info(f"Scopes (stored): {', '.join(scopes)}")
|
|
206
|
+
if token_scopes is not None:
|
|
207
|
+
formatter.rich.info(f"Scopes (token): {', '.join(token_scopes)}")
|
|
208
|
+
missing = set(scopes) - set(token_scopes)
|
|
209
|
+
if missing:
|
|
210
|
+
not_granted = ", ".join(sorted(missing))
|
|
211
|
+
formatter.rich.info(
|
|
212
|
+
f" [yellow]Warning: requested but not granted: {not_granted}[/yellow]"
|
|
213
|
+
)
|
|
214
|
+
formatter.rich.info(f"Region: {region}")
|
|
215
|
+
formatter.rich.info(f"Refresh token: {'yes' if has_refresh else 'no'}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@auth_group.command("refresh")
|
|
219
|
+
@global_options
|
|
220
|
+
def refresh_cmd(app_ctx: AppContext) -> None:
|
|
221
|
+
"""Refresh the access token using the stored refresh token."""
|
|
222
|
+
run_async(_cmd_refresh(app_ctx))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def _cmd_refresh(app_ctx: AppContext) -> None:
|
|
226
|
+
formatter = app_ctx.formatter
|
|
227
|
+
settings = AppSettings()
|
|
228
|
+
store = TokenStore(
|
|
229
|
+
profile=app_ctx.profile,
|
|
230
|
+
token_file=settings.token_file,
|
|
231
|
+
config_dir=settings.config_dir,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
rt = store.refresh_token
|
|
235
|
+
if not rt:
|
|
236
|
+
raise ConfigError("No refresh token found. Run 'tescmd auth login' first.")
|
|
237
|
+
|
|
238
|
+
if not settings.client_id:
|
|
239
|
+
raise ConfigError(
|
|
240
|
+
"TESLA_CLIENT_ID is required for token refresh. "
|
|
241
|
+
"Add it to your .env file or set it as an environment variable."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
meta = store.metadata or {}
|
|
245
|
+
scopes: list[str] = meta.get("scopes", DEFAULT_SCOPES)
|
|
246
|
+
region: str = meta.get("region", "na")
|
|
247
|
+
|
|
248
|
+
token_data = await refresh_access_token(
|
|
249
|
+
refresh_token=rt,
|
|
250
|
+
client_id=settings.client_id,
|
|
251
|
+
client_secret=settings.client_secret,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
store.save(
|
|
255
|
+
access_token=token_data.access_token,
|
|
256
|
+
refresh_token=token_data.refresh_token or rt,
|
|
257
|
+
expires_at=time.time() + token_data.expires_in,
|
|
258
|
+
scopes=scopes,
|
|
259
|
+
region=region,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if formatter.format == "json":
|
|
263
|
+
formatter.output({"status": "refreshed"}, command="auth.refresh")
|
|
264
|
+
else:
|
|
265
|
+
formatter.rich.info("Token refreshed successfully.")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@auth_group.command("export")
|
|
269
|
+
@global_options
|
|
270
|
+
def export_cmd(app_ctx: AppContext) -> None:
|
|
271
|
+
"""Export tokens as JSON to stdout."""
|
|
272
|
+
run_async(_cmd_export(app_ctx))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def _cmd_export(app_ctx: AppContext) -> None:
|
|
276
|
+
settings = AppSettings()
|
|
277
|
+
store = TokenStore(
|
|
278
|
+
profile=app_ctx.profile,
|
|
279
|
+
token_file=settings.token_file,
|
|
280
|
+
config_dir=settings.config_dir,
|
|
281
|
+
)
|
|
282
|
+
data = store.export_dict()
|
|
283
|
+
print(json.dumps(data, indent=2))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@auth_group.command("register")
|
|
287
|
+
@global_options
|
|
288
|
+
def register_cmd(app_ctx: AppContext) -> None:
|
|
289
|
+
"""Register app with the Fleet API (one-time)."""
|
|
290
|
+
run_async(_cmd_register(app_ctx))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def _cmd_register(app_ctx: AppContext) -> None:
|
|
294
|
+
formatter = app_ctx.formatter
|
|
295
|
+
settings = AppSettings()
|
|
296
|
+
|
|
297
|
+
if not settings.client_id:
|
|
298
|
+
raise ConfigError(
|
|
299
|
+
"TESLA_CLIENT_ID is required. Run 'tescmd auth login' to set up your credentials."
|
|
300
|
+
)
|
|
301
|
+
if not settings.client_secret:
|
|
302
|
+
raise ConfigError(
|
|
303
|
+
"TESLA_CLIENT_SECRET is required for Fleet API registration. "
|
|
304
|
+
"Add it to your .env file or set it as an environment variable."
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
region = app_ctx.region or settings.region
|
|
308
|
+
domain = settings.domain
|
|
309
|
+
|
|
310
|
+
# Prompt for domain if not configured
|
|
311
|
+
if not domain and formatter.format != "json":
|
|
312
|
+
domain = _prompt_for_domain(formatter)
|
|
313
|
+
if not domain:
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
if not domain:
|
|
317
|
+
raise ConfigError(
|
|
318
|
+
"TESLA_DOMAIN is required for Fleet API registration. "
|
|
319
|
+
"Set it in your .env file (e.g. TESLA_DOMAIN=myapp.example.com)."
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
if formatter.format != "json":
|
|
323
|
+
formatter.rich.info(f"Registering application with Fleet API ({region} region)...")
|
|
324
|
+
|
|
325
|
+
_result, partner_scopes = await register_partner_account(
|
|
326
|
+
client_id=settings.client_id,
|
|
327
|
+
client_secret=settings.client_secret,
|
|
328
|
+
domain=domain,
|
|
329
|
+
region=region,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if formatter.format == "json":
|
|
333
|
+
data: dict[str, object] = {
|
|
334
|
+
"status": "registered",
|
|
335
|
+
"region": region,
|
|
336
|
+
"domain": domain,
|
|
337
|
+
}
|
|
338
|
+
if partner_scopes:
|
|
339
|
+
data["partner_scopes"] = partner_scopes
|
|
340
|
+
formatter.output(data, command="auth.register")
|
|
341
|
+
else:
|
|
342
|
+
formatter.rich.info("[green]Registration successful.[/green]")
|
|
343
|
+
if partner_scopes:
|
|
344
|
+
formatter.rich.info(f"Partner scopes: {', '.join(partner_scopes)}")
|
|
345
|
+
missing = sorted(set(PARTNER_SCOPES) - set(partner_scopes))
|
|
346
|
+
if missing:
|
|
347
|
+
scope_list = ", ".join(missing)
|
|
348
|
+
formatter.rich.info(
|
|
349
|
+
f"[yellow]Warning: partner token is missing: {scope_list}[/yellow]"
|
|
350
|
+
)
|
|
351
|
+
formatter.rich.info(" These scopes won't be available in user tokens.")
|
|
352
|
+
formatter.rich.info(" Check your Tesla Developer Portal app configuration.")
|
|
353
|
+
formatter.rich.info("")
|
|
354
|
+
formatter.rich.info("Try it out:")
|
|
355
|
+
formatter.rich.info(" [cyan]tescmd vehicle list[/cyan]")
|
|
356
|
+
formatter.rich.info("")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@auth_group.command("import")
|
|
360
|
+
@global_options
|
|
361
|
+
def import_cmd(app_ctx: AppContext) -> None:
|
|
362
|
+
"""Import tokens from JSON on stdin."""
|
|
363
|
+
run_async(_cmd_import(app_ctx))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def _cmd_import(app_ctx: AppContext) -> None:
|
|
367
|
+
formatter = app_ctx.formatter
|
|
368
|
+
settings = AppSettings()
|
|
369
|
+
raw = sys.stdin.read()
|
|
370
|
+
data = json.loads(raw)
|
|
371
|
+
store = TokenStore(
|
|
372
|
+
profile=app_ctx.profile,
|
|
373
|
+
token_file=settings.token_file,
|
|
374
|
+
config_dir=settings.config_dir,
|
|
375
|
+
)
|
|
376
|
+
store.import_dict(data)
|
|
377
|
+
|
|
378
|
+
if formatter.format == "json":
|
|
379
|
+
formatter.output({"status": "imported"}, command="auth.import")
|
|
380
|
+
else:
|
|
381
|
+
formatter.rich.info("Tokens imported successfully.")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# Private helpers
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def _auto_register(
|
|
390
|
+
formatter: OutputFormatter,
|
|
391
|
+
client_id: str,
|
|
392
|
+
client_secret: str,
|
|
393
|
+
domain: str,
|
|
394
|
+
region: str,
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Attempt Fleet API registration silently after login."""
|
|
397
|
+
formatter.rich.info("")
|
|
398
|
+
formatter.rich.info("Registering with the Fleet API...")
|
|
399
|
+
try:
|
|
400
|
+
_result, partner_scopes = await register_partner_account(
|
|
401
|
+
client_id=client_id,
|
|
402
|
+
client_secret=client_secret,
|
|
403
|
+
domain=domain,
|
|
404
|
+
region=region,
|
|
405
|
+
)
|
|
406
|
+
formatter.rich.info("[green]Registration successful.[/green]")
|
|
407
|
+
if partner_scopes:
|
|
408
|
+
missing = sorted(set(PARTNER_SCOPES) - set(partner_scopes))
|
|
409
|
+
if missing:
|
|
410
|
+
scope_list = ", ".join(missing)
|
|
411
|
+
formatter.rich.info(
|
|
412
|
+
f"[yellow]Warning: partner token missing scopes: {scope_list}[/yellow]"
|
|
413
|
+
)
|
|
414
|
+
except Exception:
|
|
415
|
+
formatter.rich.info(
|
|
416
|
+
"[yellow]Registration failed. Run [cyan]tescmd auth register[/cyan] to retry.[/yellow]"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _interactive_setup(
|
|
421
|
+
formatter: OutputFormatter,
|
|
422
|
+
port: int,
|
|
423
|
+
redirect_uri: str,
|
|
424
|
+
*,
|
|
425
|
+
domain: str = "",
|
|
426
|
+
) -> tuple[str, str]:
|
|
427
|
+
"""Walk the user through first-time Tesla API credential setup.
|
|
428
|
+
|
|
429
|
+
When *domain* is provided (e.g. from the setup wizard), the developer
|
|
430
|
+
portal instructions show ``https://{domain}`` as the Allowed Origin URL.
|
|
431
|
+
Tesla's Fleet API requires the origin to match the registration domain.
|
|
432
|
+
"""
|
|
433
|
+
info = formatter.rich.info
|
|
434
|
+
origin_url = f"https://{domain}" if domain else f"http://localhost:{port}"
|
|
435
|
+
|
|
436
|
+
info("")
|
|
437
|
+
info("[bold cyan]Welcome to tescmd![/bold cyan]")
|
|
438
|
+
info("")
|
|
439
|
+
info(
|
|
440
|
+
"To talk to your Tesla you need API credentials from the"
|
|
441
|
+
" Tesla Developer Portal. This wizard will walk you through it."
|
|
442
|
+
)
|
|
443
|
+
info("")
|
|
444
|
+
|
|
445
|
+
# Offer to open the developer portal
|
|
446
|
+
try:
|
|
447
|
+
answer = input("Open the Tesla Developer Portal in your browser? [Y/n] ")
|
|
448
|
+
except (EOFError, KeyboardInterrupt):
|
|
449
|
+
info("")
|
|
450
|
+
return ("", "")
|
|
451
|
+
|
|
452
|
+
if answer.strip().lower() != "n":
|
|
453
|
+
webbrowser.open(DEVELOPER_PORTAL_URL)
|
|
454
|
+
info("[dim]Browser opened.[/dim]")
|
|
455
|
+
|
|
456
|
+
info("")
|
|
457
|
+
info(
|
|
458
|
+
"Follow these steps to create a Fleet API application."
|
|
459
|
+
" If you already have one, skip to the credentials prompt below."
|
|
460
|
+
)
|
|
461
|
+
info("")
|
|
462
|
+
|
|
463
|
+
# Step 1 — Registration
|
|
464
|
+
info("[bold]Step 1 — Registration[/bold]")
|
|
465
|
+
info(" Select [cyan]Just for me[/cyan] and click Next.")
|
|
466
|
+
info("")
|
|
467
|
+
|
|
468
|
+
# Step 2 — Application Details
|
|
469
|
+
info("[bold]Step 2 — Application Details[/bold]")
|
|
470
|
+
info(" Application Name: [cyan]tescmd[/cyan] (or anything you like)")
|
|
471
|
+
info(" Description: [cyan]Personal CLI tool for vehicle status and control[/cyan]")
|
|
472
|
+
info(
|
|
473
|
+
" Purpose of Usage: [cyan]Query vehicle data and send commands from the terminal[/cyan]"
|
|
474
|
+
)
|
|
475
|
+
info(" Click Next.")
|
|
476
|
+
info("")
|
|
477
|
+
|
|
478
|
+
# Step 3 — Client Details
|
|
479
|
+
info("[bold]Step 3 — Client Details[/bold]")
|
|
480
|
+
info(
|
|
481
|
+
" OAuth Grant Type: [cyan]Authorization Code and"
|
|
482
|
+
" Machine-to-Machine[/cyan] (the default)"
|
|
483
|
+
)
|
|
484
|
+
info(f" Allowed Origin URL: [cyan]{origin_url}[/cyan]")
|
|
485
|
+
info(f" Allowed Redirect URI: [cyan]{redirect_uri}[/cyan]")
|
|
486
|
+
info(" Allowed Returned URL: (leave empty)")
|
|
487
|
+
info(" Click Next.")
|
|
488
|
+
info("")
|
|
489
|
+
|
|
490
|
+
# Step 4 — API & Scopes
|
|
491
|
+
info("[bold]Step 4 — API & Scopes[/bold]")
|
|
492
|
+
info(" Under [bold]Fleet API[/bold], check at least:")
|
|
493
|
+
info(" [cyan]Vehicle Information[/cyan]")
|
|
494
|
+
info(" [cyan]Vehicle Location[/cyan]")
|
|
495
|
+
info(" [cyan]Vehicle Commands[/cyan]")
|
|
496
|
+
info(" [cyan]Vehicle Charging Management[/cyan]")
|
|
497
|
+
info(" Click Next.")
|
|
498
|
+
info("")
|
|
499
|
+
|
|
500
|
+
# Step 5 — Billing Details
|
|
501
|
+
info("[bold]Step 5 — Billing Details[/bold]")
|
|
502
|
+
info(" Click [cyan]Skip and Submit[/cyan] at the bottom of the page.")
|
|
503
|
+
info("")
|
|
504
|
+
|
|
505
|
+
# Post-creation
|
|
506
|
+
info("[bold]Step 6 — Copy your credentials[/bold]")
|
|
507
|
+
info(
|
|
508
|
+
" Open your dashboard:"
|
|
509
|
+
" [link=https://developer.tesla.com/en_US/dashboard]"
|
|
510
|
+
"developer.tesla.com/dashboard[/link]"
|
|
511
|
+
)
|
|
512
|
+
info(" Click [cyan]View Details[/cyan] on your app.")
|
|
513
|
+
info(" Under the [cyan]Credentials & APIs[/cyan] tab you'll see your")
|
|
514
|
+
info(" Client ID (copy icon) and Client Secret (eye icon to reveal).")
|
|
515
|
+
info("")
|
|
516
|
+
|
|
517
|
+
# Prompt for Client ID
|
|
518
|
+
try:
|
|
519
|
+
client_id = input("Client ID: ").strip()
|
|
520
|
+
except (EOFError, KeyboardInterrupt):
|
|
521
|
+
info("")
|
|
522
|
+
return ("", "")
|
|
523
|
+
|
|
524
|
+
if not client_id:
|
|
525
|
+
info("[yellow]No Client ID provided. Setup cancelled.[/yellow]")
|
|
526
|
+
return ("", "")
|
|
527
|
+
|
|
528
|
+
# Prompt for Client Secret (optional for public clients)
|
|
529
|
+
try:
|
|
530
|
+
client_secret = input("Client Secret (optional, press Enter to skip): ").strip()
|
|
531
|
+
except (EOFError, KeyboardInterrupt):
|
|
532
|
+
info("")
|
|
533
|
+
return ("", "")
|
|
534
|
+
|
|
535
|
+
# Offer to persist credentials to .env
|
|
536
|
+
info("")
|
|
537
|
+
try:
|
|
538
|
+
save = input("Save credentials to .env file? [Y/n] ")
|
|
539
|
+
except (EOFError, KeyboardInterrupt):
|
|
540
|
+
info("")
|
|
541
|
+
return (client_id, client_secret)
|
|
542
|
+
|
|
543
|
+
if save.strip().lower() != "n":
|
|
544
|
+
_write_env_file(client_id, client_secret)
|
|
545
|
+
info("[green]Credentials saved to .env[/green]")
|
|
546
|
+
|
|
547
|
+
info("")
|
|
548
|
+
return (client_id, client_secret)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _prompt_for_domain(formatter: OutputFormatter) -> str:
|
|
552
|
+
"""Prompt the user for a domain to use for Fleet API registration."""
|
|
553
|
+
info = formatter.rich.info
|
|
554
|
+
info("")
|
|
555
|
+
info("Tesla requires a [bold]registered domain[/bold] to activate your Fleet API access.")
|
|
556
|
+
info("")
|
|
557
|
+
info(" The easiest option is a free [cyan]GitHub Pages[/cyan] site:")
|
|
558
|
+
info(" 1. Create a public repo named [cyan]<username>.github.io[/cyan]")
|
|
559
|
+
info(" 2. Enable GitHub Pages in the repo settings")
|
|
560
|
+
info(" 3. Enter [cyan]<username>.github.io[/cyan] as your domain below")
|
|
561
|
+
info("")
|
|
562
|
+
info(
|
|
563
|
+
"[dim]Any domain you control works. For vehicle commands"
|
|
564
|
+
" (post-MVP) you'll also host a public key there.[/dim]"
|
|
565
|
+
)
|
|
566
|
+
info("")
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
domain = input("Domain (e.g. yourname.github.io): ").strip()
|
|
570
|
+
except (EOFError, KeyboardInterrupt):
|
|
571
|
+
info("")
|
|
572
|
+
return ""
|
|
573
|
+
|
|
574
|
+
if not domain:
|
|
575
|
+
info("[yellow]No domain provided. Registration cancelled.[/yellow]")
|
|
576
|
+
return ""
|
|
577
|
+
|
|
578
|
+
# Strip protocol if user included it
|
|
579
|
+
for prefix in ("https://", "http://"):
|
|
580
|
+
if domain.startswith(prefix):
|
|
581
|
+
domain = domain[len(prefix) :]
|
|
582
|
+
break
|
|
583
|
+
|
|
584
|
+
# Strip trailing slash and lowercase (Tesla Fleet API rejects uppercase)
|
|
585
|
+
domain = domain.rstrip("/").lower()
|
|
586
|
+
|
|
587
|
+
# Save domain to .env for future use
|
|
588
|
+
_write_env_value("TESLA_DOMAIN", domain)
|
|
589
|
+
info(f"[green]Domain saved to .env: {domain}[/green]")
|
|
590
|
+
|
|
591
|
+
return domain
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _warn_missing_scopes(
|
|
595
|
+
formatter: OutputFormatter,
|
|
596
|
+
token: TokenData,
|
|
597
|
+
*,
|
|
598
|
+
requested: list[str],
|
|
599
|
+
) -> None:
|
|
600
|
+
"""Warn the user if the token has fewer scopes than requested."""
|
|
601
|
+
granted = decode_jwt_scopes(token.access_token)
|
|
602
|
+
if granted is None:
|
|
603
|
+
return
|
|
604
|
+
|
|
605
|
+
# offline_access is a token-lifetime directive, not present in JWTs
|
|
606
|
+
requested_set = {s for s in requested if s != "offline_access"}
|
|
607
|
+
missing = sorted(requested_set - set(granted))
|
|
608
|
+
if not missing:
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
scope_list = ", ".join(missing)
|
|
612
|
+
if formatter.format == "json":
|
|
613
|
+
formatter.output(
|
|
614
|
+
{
|
|
615
|
+
"warning": "missing_scopes",
|
|
616
|
+
"missing": missing,
|
|
617
|
+
"message": (
|
|
618
|
+
f"Token is missing requested scopes: {scope_list}. "
|
|
619
|
+
"Run 'tescmd auth login --reconsent' to re-approve scopes."
|
|
620
|
+
),
|
|
621
|
+
},
|
|
622
|
+
command="auth.login",
|
|
623
|
+
)
|
|
624
|
+
else:
|
|
625
|
+
formatter.rich.info("")
|
|
626
|
+
formatter.rich.info(f"[yellow]Warning: token is missing scopes: {scope_list}[/yellow]")
|
|
627
|
+
formatter.rich.info(" Tesla is using a cached consent that predates these scopes.")
|
|
628
|
+
formatter.rich.info(
|
|
629
|
+
" Run [cyan]tescmd auth login --reconsent[/cyan] to re-approve all scopes."
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _write_env_file(
|
|
634
|
+
client_id: str,
|
|
635
|
+
client_secret: str,
|
|
636
|
+
domain: str = "",
|
|
637
|
+
) -> None:
|
|
638
|
+
"""Write Tesla API credentials to a ``.env`` file in the working directory."""
|
|
639
|
+
values: dict[str, str] = {"TESLA_CLIENT_ID": client_id}
|
|
640
|
+
if client_secret:
|
|
641
|
+
values["TESLA_CLIENT_SECRET"] = client_secret
|
|
642
|
+
if domain:
|
|
643
|
+
values["TESLA_DOMAIN"] = domain
|
|
644
|
+
|
|
645
|
+
env_path = Path(".env")
|
|
646
|
+
lines: list[str] = []
|
|
647
|
+
|
|
648
|
+
if env_path.exists():
|
|
649
|
+
existing = env_path.read_text()
|
|
650
|
+
for line in existing.splitlines():
|
|
651
|
+
stripped = line.strip()
|
|
652
|
+
if any(stripped.startswith(f"{k}=") for k in values):
|
|
653
|
+
continue
|
|
654
|
+
lines.append(line)
|
|
655
|
+
if lines and lines[-1] != "":
|
|
656
|
+
lines.append("")
|
|
657
|
+
|
|
658
|
+
for key, val in values.items():
|
|
659
|
+
lines.append(f"{key}={val}")
|
|
660
|
+
lines.append("")
|
|
661
|
+
|
|
662
|
+
env_path.write_text("\n".join(lines))
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _write_env_value(key: str, value: str) -> None:
|
|
666
|
+
"""Write or update a single key in the ``.env`` file."""
|
|
667
|
+
env_path = Path(".env")
|
|
668
|
+
lines: list[str] = []
|
|
669
|
+
|
|
670
|
+
if env_path.exists():
|
|
671
|
+
existing = env_path.read_text()
|
|
672
|
+
for line in existing.splitlines():
|
|
673
|
+
if line.strip().startswith(f"{key}="):
|
|
674
|
+
continue
|
|
675
|
+
lines.append(line)
|
|
676
|
+
if lines and lines[-1] != "":
|
|
677
|
+
lines.append("")
|
|
678
|
+
|
|
679
|
+
lines.append(f"{key}={value}")
|
|
680
|
+
lines.append("")
|
|
681
|
+
|
|
682
|
+
env_path.write_text("\n".join(lines))
|