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/key.py
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
"""CLI commands for EC key management (generate, deploy, validate, show, enroll)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from tescmd._internal.async_utils import run_async
|
|
11
|
+
from tescmd.cli._options import global_options
|
|
12
|
+
from tescmd.crypto.keys import (
|
|
13
|
+
generate_ec_key_pair,
|
|
14
|
+
get_key_fingerprint,
|
|
15
|
+
get_public_key_path,
|
|
16
|
+
has_key_pair,
|
|
17
|
+
load_public_key_pem,
|
|
18
|
+
)
|
|
19
|
+
from tescmd.deploy.github_pages import (
|
|
20
|
+
get_key_url,
|
|
21
|
+
validate_key_url,
|
|
22
|
+
wait_for_pages_deployment,
|
|
23
|
+
)
|
|
24
|
+
from tescmd.models.config import AppSettings
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from tescmd.cli.main import AppContext
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Command group
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
key_group = click.Group("key", help="EC key management")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Commands
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@key_group.command("generate")
|
|
43
|
+
@click.option("--force", is_flag=True, help="Overwrite existing keys")
|
|
44
|
+
@global_options
|
|
45
|
+
def generate_cmd(app_ctx: AppContext, force: bool) -> None:
|
|
46
|
+
"""Generate an EC P-256 key pair for Tesla Fleet API command signing."""
|
|
47
|
+
run_async(_cmd_generate(app_ctx, force))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _cmd_generate(app_ctx: AppContext, force: bool) -> None:
|
|
51
|
+
formatter = app_ctx.formatter
|
|
52
|
+
settings = AppSettings()
|
|
53
|
+
key_dir = Path(settings.config_dir).expanduser() / "keys"
|
|
54
|
+
|
|
55
|
+
if has_key_pair(key_dir) and not force:
|
|
56
|
+
if formatter.format == "json":
|
|
57
|
+
formatter.output(
|
|
58
|
+
{
|
|
59
|
+
"status": "exists",
|
|
60
|
+
"path": str(key_dir),
|
|
61
|
+
"fingerprint": get_key_fingerprint(key_dir),
|
|
62
|
+
},
|
|
63
|
+
command="key.generate",
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
formatter.rich.info(
|
|
67
|
+
"[yellow]Key pair already exists.[/yellow] Use --force to overwrite."
|
|
68
|
+
)
|
|
69
|
+
formatter.rich.info(f" Path: {key_dir}")
|
|
70
|
+
formatter.rich.info(f" Fingerprint: {get_key_fingerprint(key_dir)}")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
priv_path, pub_path = generate_ec_key_pair(key_dir, overwrite=force)
|
|
74
|
+
|
|
75
|
+
if formatter.format == "json":
|
|
76
|
+
formatter.output(
|
|
77
|
+
{
|
|
78
|
+
"status": "generated",
|
|
79
|
+
"private_key": str(priv_path),
|
|
80
|
+
"public_key": str(pub_path),
|
|
81
|
+
"fingerprint": get_key_fingerprint(key_dir),
|
|
82
|
+
},
|
|
83
|
+
command="key.generate",
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
formatter.rich.info("[green]Key pair generated.[/green]")
|
|
87
|
+
formatter.rich.info(f" Private key: {priv_path}")
|
|
88
|
+
formatter.rich.info(f" Public key: {pub_path}")
|
|
89
|
+
formatter.rich.info(f" Fingerprint: {get_key_fingerprint(key_dir)}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@key_group.command("deploy")
|
|
93
|
+
@click.option(
|
|
94
|
+
"--repo",
|
|
95
|
+
default=None,
|
|
96
|
+
help="GitHub repo (e.g. user/user.github.io). Auto-detected if omitted.",
|
|
97
|
+
)
|
|
98
|
+
@global_options
|
|
99
|
+
def deploy_cmd(app_ctx: AppContext, repo: str | None) -> None:
|
|
100
|
+
"""Deploy the public key to GitHub Pages."""
|
|
101
|
+
run_async(_cmd_deploy(app_ctx, repo))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def _cmd_deploy(app_ctx: AppContext, repo: str | None) -> None:
|
|
105
|
+
from tescmd.deploy.github_pages import (
|
|
106
|
+
create_pages_repo,
|
|
107
|
+
deploy_public_key,
|
|
108
|
+
get_gh_username,
|
|
109
|
+
get_pages_domain,
|
|
110
|
+
is_gh_authenticated,
|
|
111
|
+
is_gh_available,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
formatter = app_ctx.formatter
|
|
115
|
+
settings = AppSettings()
|
|
116
|
+
key_dir = Path(settings.config_dir).expanduser() / "keys"
|
|
117
|
+
|
|
118
|
+
# Ensure keys exist
|
|
119
|
+
if not has_key_pair(key_dir):
|
|
120
|
+
if formatter.format == "json":
|
|
121
|
+
formatter.output_error(
|
|
122
|
+
code="no_keys",
|
|
123
|
+
message="No key pair found. Run 'tescmd key generate' first.",
|
|
124
|
+
command="key.deploy",
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
formatter.rich.error("No key pair found. Run [cyan]tescmd key generate[/cyan] first.")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
# Check gh CLI
|
|
131
|
+
if not is_gh_available():
|
|
132
|
+
if formatter.format == "json":
|
|
133
|
+
formatter.output_error(
|
|
134
|
+
code="gh_not_found",
|
|
135
|
+
message="GitHub CLI (gh) is not installed. Install from https://cli.github.com",
|
|
136
|
+
command="key.deploy",
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
formatter.rich.error(
|
|
140
|
+
"GitHub CLI ([cyan]gh[/cyan]) is not installed."
|
|
141
|
+
" Install from [link=https://cli.github.com]cli.github.com[/link]"
|
|
142
|
+
)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
if not is_gh_authenticated():
|
|
146
|
+
if formatter.format == "json":
|
|
147
|
+
formatter.output_error(
|
|
148
|
+
code="gh_not_authenticated",
|
|
149
|
+
message="GitHub CLI is not authenticated. Run 'gh auth login' first.",
|
|
150
|
+
command="key.deploy",
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
formatter.rich.error(
|
|
154
|
+
"GitHub CLI is not authenticated. Run [cyan]gh auth login[/cyan] first."
|
|
155
|
+
)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Determine repo
|
|
159
|
+
repo_name: str | None = repo or settings.github_repo
|
|
160
|
+
if not repo_name:
|
|
161
|
+
username = get_gh_username()
|
|
162
|
+
repo_name = f"{username}/{username}.github.io"
|
|
163
|
+
if formatter.format != "json":
|
|
164
|
+
formatter.rich.info(f"Using repo: [cyan]{repo_name}[/cyan]")
|
|
165
|
+
|
|
166
|
+
# Create repo if needed and deploy
|
|
167
|
+
if formatter.format != "json":
|
|
168
|
+
formatter.rich.info("Creating repo if needed...")
|
|
169
|
+
create_pages_repo(repo_name.split("/")[0])
|
|
170
|
+
|
|
171
|
+
if formatter.format != "json":
|
|
172
|
+
formatter.rich.info("Deploying public key...")
|
|
173
|
+
|
|
174
|
+
pem = load_public_key_pem(key_dir)
|
|
175
|
+
deploy_public_key(pem, repo_name)
|
|
176
|
+
|
|
177
|
+
domain = get_pages_domain(repo_name)
|
|
178
|
+
|
|
179
|
+
if formatter.format != "json":
|
|
180
|
+
formatter.rich.info("[green]Key deployed.[/green]")
|
|
181
|
+
formatter.rich.info(f" URL: {get_key_url(domain)}")
|
|
182
|
+
formatter.rich.info("")
|
|
183
|
+
formatter.rich.info("Waiting for GitHub Pages to publish (this may take a few minutes)...")
|
|
184
|
+
|
|
185
|
+
deployed = wait_for_pages_deployment(domain)
|
|
186
|
+
|
|
187
|
+
if formatter.format == "json":
|
|
188
|
+
formatter.output(
|
|
189
|
+
{
|
|
190
|
+
"status": "deployed" if deployed else "pending",
|
|
191
|
+
"repo": repo_name,
|
|
192
|
+
"domain": domain,
|
|
193
|
+
"url": get_key_url(domain),
|
|
194
|
+
"accessible": deployed,
|
|
195
|
+
},
|
|
196
|
+
command="key.deploy",
|
|
197
|
+
)
|
|
198
|
+
elif deployed:
|
|
199
|
+
formatter.rich.info("[green]Key is live and accessible.[/green]")
|
|
200
|
+
else:
|
|
201
|
+
formatter.rich.info(
|
|
202
|
+
"[yellow]Key deployed but not yet accessible."
|
|
203
|
+
" GitHub Pages may still be building.[/yellow]"
|
|
204
|
+
)
|
|
205
|
+
formatter.rich.info(" Run [cyan]tescmd key validate[/cyan] to check again later.")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@key_group.command("validate")
|
|
209
|
+
@global_options
|
|
210
|
+
def validate_cmd(app_ctx: AppContext) -> None:
|
|
211
|
+
"""Check that the public key is accessible at the expected URL."""
|
|
212
|
+
run_async(_cmd_validate(app_ctx))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def _cmd_validate(app_ctx: AppContext) -> None:
|
|
216
|
+
formatter = app_ctx.formatter
|
|
217
|
+
settings = AppSettings()
|
|
218
|
+
domain = settings.domain
|
|
219
|
+
|
|
220
|
+
if not domain:
|
|
221
|
+
if formatter.format == "json":
|
|
222
|
+
formatter.output_error(
|
|
223
|
+
code="no_domain",
|
|
224
|
+
message=(
|
|
225
|
+
"TESLA_DOMAIN is not set. Set it in your .env file or run"
|
|
226
|
+
" 'tescmd setup' to configure."
|
|
227
|
+
),
|
|
228
|
+
command="key.validate",
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
formatter.rich.error(
|
|
232
|
+
"No domain configured. Run [cyan]tescmd setup[/cyan]"
|
|
233
|
+
" or set TESLA_DOMAIN in your .env file."
|
|
234
|
+
)
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
url = get_key_url(domain)
|
|
238
|
+
accessible = validate_key_url(domain)
|
|
239
|
+
|
|
240
|
+
if formatter.format == "json":
|
|
241
|
+
formatter.output(
|
|
242
|
+
{"url": url, "accessible": accessible, "domain": domain},
|
|
243
|
+
command="key.validate",
|
|
244
|
+
)
|
|
245
|
+
elif accessible:
|
|
246
|
+
formatter.rich.info(f"[green]Public key is accessible at:[/green] {url}")
|
|
247
|
+
else:
|
|
248
|
+
formatter.rich.info(f"[red]Public key NOT accessible at:[/red] {url}")
|
|
249
|
+
formatter.rich.info("")
|
|
250
|
+
formatter.rich.info("Possible causes:")
|
|
251
|
+
formatter.rich.info(" - Key has not been deployed yet")
|
|
252
|
+
formatter.rich.info(" - GitHub Pages is still building")
|
|
253
|
+
formatter.rich.info(" - Domain is not configured correctly")
|
|
254
|
+
formatter.rich.info("")
|
|
255
|
+
formatter.rich.info("Run [cyan]tescmd key deploy[/cyan] to deploy your key.")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@key_group.command("show")
|
|
259
|
+
@global_options
|
|
260
|
+
def show_cmd(app_ctx: AppContext) -> None:
|
|
261
|
+
"""Display key path and fingerprint."""
|
|
262
|
+
run_async(_cmd_show(app_ctx))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def _cmd_show(app_ctx: AppContext) -> None:
|
|
266
|
+
formatter = app_ctx.formatter
|
|
267
|
+
settings = AppSettings()
|
|
268
|
+
key_dir = Path(settings.config_dir).expanduser() / "keys"
|
|
269
|
+
|
|
270
|
+
if not has_key_pair(key_dir):
|
|
271
|
+
if formatter.format == "json":
|
|
272
|
+
formatter.output(
|
|
273
|
+
{"status": "not_found", "path": str(key_dir)},
|
|
274
|
+
command="key.show",
|
|
275
|
+
)
|
|
276
|
+
else:
|
|
277
|
+
formatter.rich.info(
|
|
278
|
+
"No key pair found. Run [cyan]tescmd key generate[/cyan] to create one."
|
|
279
|
+
)
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
pub_path = get_public_key_path(key_dir)
|
|
283
|
+
fingerprint = get_key_fingerprint(key_dir)
|
|
284
|
+
|
|
285
|
+
if formatter.format == "json":
|
|
286
|
+
formatter.output(
|
|
287
|
+
{
|
|
288
|
+
"status": "found",
|
|
289
|
+
"path": str(key_dir),
|
|
290
|
+
"public_key": str(pub_path),
|
|
291
|
+
"fingerprint": fingerprint,
|
|
292
|
+
},
|
|
293
|
+
command="key.show",
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
formatter.rich.info(f"Key directory: {key_dir}")
|
|
297
|
+
formatter.rich.info(f"Public key: {pub_path}")
|
|
298
|
+
formatter.rich.info(f"Fingerprint: {fingerprint}")
|
|
299
|
+
|
|
300
|
+
domain = settings.domain
|
|
301
|
+
if domain:
|
|
302
|
+
formatter.rich.info(f"Expected URL: {get_key_url(domain)}")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
# Enroll command
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
# Tesla app portal URL for key enrollment.
|
|
310
|
+
# Initial key enrollment is NOT available via REST API or signed_command.
|
|
311
|
+
# Tesla's Go SDK explicitly blocks add_key_request for Fleet API connections
|
|
312
|
+
# (ErrRequiresBLE). For Fleet API apps, enrollment happens through the
|
|
313
|
+
# Tesla app portal: the user opens https://tesla.com/_ak/<domain> on their
|
|
314
|
+
# phone, and the Tesla app handles the actual key pairing with the vehicle.
|
|
315
|
+
_TESLA_APP_KEY_URL = "https://tesla.com/_ak/{domain}"
|
|
316
|
+
|
|
317
|
+
# Tesla consent revocation URL. Revoking consent removes the app's
|
|
318
|
+
# OAuth access and its virtual key from all vehicles on the account.
|
|
319
|
+
# There is no Fleet API endpoint to remove a virtual key — Tesla requires
|
|
320
|
+
# the owner to revoke access through their account or the vehicle itself.
|
|
321
|
+
_TESLA_CONSENT_REVOKE_URL = (
|
|
322
|
+
"https://auth.tesla.com/user/revoke/consent"
|
|
323
|
+
"?revoke_client_id={client_id}&back_url=https://tesla.com"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@key_group.command("enroll")
|
|
328
|
+
@click.option(
|
|
329
|
+
"--open/--no-open",
|
|
330
|
+
default=True,
|
|
331
|
+
help="Open the enrollment URL in the default browser (default: open)",
|
|
332
|
+
)
|
|
333
|
+
@global_options
|
|
334
|
+
def enroll_cmd(
|
|
335
|
+
app_ctx: AppContext,
|
|
336
|
+
open: bool,
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Enroll your EC key on a vehicle via the Tesla app.
|
|
339
|
+
|
|
340
|
+
Opens the Tesla app enrollment page for your domain. The vehicle
|
|
341
|
+
owner approves the key in the Tesla app (Profile >
|
|
342
|
+
Security & Privacy > Third-Party Apps). Once approved, signed commands work
|
|
343
|
+
automatically.
|
|
344
|
+
"""
|
|
345
|
+
run_async(_cmd_enroll(app_ctx, open_browser=open))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def _cmd_enroll(
|
|
349
|
+
app_ctx: AppContext,
|
|
350
|
+
*,
|
|
351
|
+
open_browser: bool,
|
|
352
|
+
) -> None:
|
|
353
|
+
import webbrowser
|
|
354
|
+
|
|
355
|
+
formatter = app_ctx.formatter
|
|
356
|
+
settings = AppSettings()
|
|
357
|
+
key_dir = Path(settings.config_dir).expanduser() / "keys"
|
|
358
|
+
|
|
359
|
+
# Step 1: Check key pair
|
|
360
|
+
if not has_key_pair(key_dir):
|
|
361
|
+
if formatter.format == "json":
|
|
362
|
+
formatter.output_error(
|
|
363
|
+
code="no_keys",
|
|
364
|
+
message="No key pair found. Run 'tescmd key generate' first.",
|
|
365
|
+
command="key.enroll",
|
|
366
|
+
)
|
|
367
|
+
else:
|
|
368
|
+
formatter.rich.error("No key pair found. Run [cyan]tescmd key generate[/cyan] first.")
|
|
369
|
+
raise SystemExit(1)
|
|
370
|
+
|
|
371
|
+
fingerprint = get_key_fingerprint(key_dir)
|
|
372
|
+
|
|
373
|
+
if formatter.format != "json":
|
|
374
|
+
formatter.rich.info("[bold]Step 1: Checking key pair…[/bold]")
|
|
375
|
+
formatter.rich.info(f" [green]✓[/green] Key pair found (fingerprint: {fingerprint[:8]}…)")
|
|
376
|
+
formatter.rich.info("")
|
|
377
|
+
|
|
378
|
+
# Step 2: Check domain is configured
|
|
379
|
+
domain = settings.domain
|
|
380
|
+
if not domain:
|
|
381
|
+
if formatter.format == "json":
|
|
382
|
+
formatter.output_error(
|
|
383
|
+
code="no_domain",
|
|
384
|
+
message=(
|
|
385
|
+
"No domain configured. Run 'tescmd setup' or set TESLA_DOMAIN"
|
|
386
|
+
" in your .env file."
|
|
387
|
+
),
|
|
388
|
+
command="key.enroll",
|
|
389
|
+
)
|
|
390
|
+
else:
|
|
391
|
+
formatter.rich.error(
|
|
392
|
+
"No domain configured. Run [cyan]tescmd setup[/cyan]"
|
|
393
|
+
" or set TESLA_DOMAIN in your .env file."
|
|
394
|
+
)
|
|
395
|
+
raise SystemExit(1)
|
|
396
|
+
|
|
397
|
+
if formatter.format != "json":
|
|
398
|
+
formatter.rich.info("[bold]Step 2: Checking domain & public key…[/bold]")
|
|
399
|
+
|
|
400
|
+
# Step 3: Verify public key is accessible
|
|
401
|
+
key_accessible = validate_key_url(domain)
|
|
402
|
+
key_url = get_key_url(domain)
|
|
403
|
+
|
|
404
|
+
if not key_accessible:
|
|
405
|
+
if formatter.format == "json":
|
|
406
|
+
formatter.output_error(
|
|
407
|
+
code="key_not_accessible",
|
|
408
|
+
message=(
|
|
409
|
+
f"Public key not accessible at {key_url}. "
|
|
410
|
+
"Deploy with 'tescmd key deploy' first."
|
|
411
|
+
),
|
|
412
|
+
command="key.enroll",
|
|
413
|
+
)
|
|
414
|
+
else:
|
|
415
|
+
formatter.rich.error(f"Public key not accessible at [cyan]{key_url}[/cyan]")
|
|
416
|
+
formatter.rich.info(" Deploy with [cyan]tescmd key deploy[/cyan] first.")
|
|
417
|
+
raise SystemExit(1)
|
|
418
|
+
|
|
419
|
+
if formatter.format != "json":
|
|
420
|
+
formatter.rich.info(f" [green]✓[/green] Domain: {domain}")
|
|
421
|
+
formatter.rich.info(f" [green]✓[/green] Public key accessible at {key_url}")
|
|
422
|
+
formatter.rich.info("")
|
|
423
|
+
|
|
424
|
+
# Step 4: Build enrollment URL and guide user
|
|
425
|
+
enroll_url = _TESLA_APP_KEY_URL.format(domain=domain)
|
|
426
|
+
|
|
427
|
+
if formatter.format == "json":
|
|
428
|
+
formatter.output(
|
|
429
|
+
{
|
|
430
|
+
"status": "ready",
|
|
431
|
+
"domain": domain,
|
|
432
|
+
"fingerprint": fingerprint,
|
|
433
|
+
"enroll_url": enroll_url,
|
|
434
|
+
"key_url": key_url,
|
|
435
|
+
"message": (
|
|
436
|
+
f"Open {enroll_url} on your phone."
|
|
437
|
+
" Tap 'Finish Setup', then approve 'Add Virtual Key' in the Tesla app."
|
|
438
|
+
),
|
|
439
|
+
},
|
|
440
|
+
command="key.enroll",
|
|
441
|
+
)
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
formatter.rich.info("[bold]Step 3: Key enrollment[/bold]")
|
|
445
|
+
formatter.rich.info("")
|
|
446
|
+
formatter.rich.info("━" * 55)
|
|
447
|
+
formatter.rich.info(
|
|
448
|
+
" [bold yellow]ACTION REQUIRED: Add virtual key in the Tesla app[/bold yellow]"
|
|
449
|
+
)
|
|
450
|
+
formatter.rich.info("")
|
|
451
|
+
formatter.rich.info(f" Enrollment URL: [link={enroll_url}]{enroll_url}[/link]")
|
|
452
|
+
formatter.rich.info("")
|
|
453
|
+
formatter.rich.info(" 1. Open the URL above [bold]on your phone[/bold]")
|
|
454
|
+
formatter.rich.info(" 2. Tap [bold]Finish Setup[/bold] on the web page")
|
|
455
|
+
formatter.rich.info(" 3. The Tesla app will show an [bold]Add Virtual Key[/bold] prompt")
|
|
456
|
+
formatter.rich.info(" 4. Approve it")
|
|
457
|
+
formatter.rich.info("")
|
|
458
|
+
formatter.rich.info(" [dim]If the prompt doesn't appear, force-quit the Tesla app,[/dim]")
|
|
459
|
+
formatter.rich.info(" [dim]go back to your browser, and tap Finish Setup again.[/dim]")
|
|
460
|
+
formatter.rich.info("━" * 55)
|
|
461
|
+
formatter.rich.info("")
|
|
462
|
+
|
|
463
|
+
if open_browser:
|
|
464
|
+
formatter.rich.info("Opening enrollment URL in browser…")
|
|
465
|
+
webbrowser.open(enroll_url)
|
|
466
|
+
formatter.rich.info("")
|
|
467
|
+
|
|
468
|
+
formatter.rich.info("After approving in the Tesla app, try a command:")
|
|
469
|
+
formatter.rich.info(" [cyan]tescmd security lock --wake[/cyan]")
|
|
470
|
+
formatter.rich.info(" [cyan]tescmd charge status --wake[/cyan]")
|
|
471
|
+
formatter.rich.info("")
|
|
472
|
+
formatter.rich.info(
|
|
473
|
+
"[dim]Tip: This URL must be opened on your phone, not a desktop browser.[/dim]"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ---------------------------------------------------------------------------
|
|
478
|
+
# Unenroll command
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@key_group.command("unenroll")
|
|
483
|
+
@click.option(
|
|
484
|
+
"--open/--no-open",
|
|
485
|
+
default=True,
|
|
486
|
+
help="Open the revocation URL in the default browser (default: open)",
|
|
487
|
+
)
|
|
488
|
+
@global_options
|
|
489
|
+
def unenroll_cmd(
|
|
490
|
+
app_ctx: AppContext,
|
|
491
|
+
open: bool,
|
|
492
|
+
) -> None:
|
|
493
|
+
"""Remove your virtual key and revoke app access.
|
|
494
|
+
|
|
495
|
+
Shows instructions for removing the tescmd virtual key from your
|
|
496
|
+
vehicle(s) and optionally opens the Tesla consent revocation page
|
|
497
|
+
to revoke OAuth access entirely.
|
|
498
|
+
"""
|
|
499
|
+
run_async(_cmd_unenroll(app_ctx, open_browser=open))
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
async def _cmd_unenroll(
|
|
503
|
+
app_ctx: AppContext,
|
|
504
|
+
*,
|
|
505
|
+
open_browser: bool,
|
|
506
|
+
) -> None:
|
|
507
|
+
import webbrowser
|
|
508
|
+
|
|
509
|
+
formatter = app_ctx.formatter
|
|
510
|
+
settings = AppSettings()
|
|
511
|
+
client_id = settings.client_id
|
|
512
|
+
|
|
513
|
+
# Build revocation URL if client_id is available
|
|
514
|
+
revoke_url: str | None = None
|
|
515
|
+
if client_id:
|
|
516
|
+
revoke_url = _TESLA_CONSENT_REVOKE_URL.format(client_id=client_id)
|
|
517
|
+
|
|
518
|
+
if formatter.format == "json":
|
|
519
|
+
data: dict[str, object] = {
|
|
520
|
+
"status": "instructions",
|
|
521
|
+
"revoke_url": revoke_url,
|
|
522
|
+
"methods": [
|
|
523
|
+
{
|
|
524
|
+
"name": "vehicle_touchscreen",
|
|
525
|
+
"steps": "Controls > Locks > tap trash icon on key > scan key card",
|
|
526
|
+
"speed": "immediate",
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
"name": "tesla_app",
|
|
530
|
+
"steps": "Profile > Security & Privacy > Third-Party Apps > Remove",
|
|
531
|
+
"speed": "up to 2 hours",
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
"name": "tesla_account_web",
|
|
535
|
+
"steps": "accounts.tesla.com > Security > Third Party Apps > Manage",
|
|
536
|
+
"speed": "up to 2 hours",
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
"message": (
|
|
540
|
+
"Remove the virtual key via the vehicle touchscreen, Tesla app, "
|
|
541
|
+
"or accounts.tesla.com. "
|
|
542
|
+
+ (
|
|
543
|
+
f"Revoke OAuth access at {revoke_url}"
|
|
544
|
+
if revoke_url
|
|
545
|
+
else "Set TESLA_CLIENT_ID to generate a consent revocation URL."
|
|
546
|
+
)
|
|
547
|
+
),
|
|
548
|
+
}
|
|
549
|
+
formatter.output(data, command="key.unenroll")
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
formatter.rich.info("[bold]How to remove the tescmd virtual key[/bold]")
|
|
553
|
+
formatter.rich.info("")
|
|
554
|
+
formatter.rich.info("━" * 55)
|
|
555
|
+
formatter.rich.info("")
|
|
556
|
+
|
|
557
|
+
# Method 1: Vehicle touchscreen (immediate)
|
|
558
|
+
formatter.rich.info(" [bold]Option 1: Vehicle touchscreen[/bold] [green](immediate)[/green]")
|
|
559
|
+
formatter.rich.info(" 1. On the vehicle touchscreen, tap [bold]Controls > Locks[/bold]")
|
|
560
|
+
formatter.rich.info(" 2. Find the tescmd key in the key list")
|
|
561
|
+
formatter.rich.info(" 3. Tap the [bold]trash icon[/bold] next to it")
|
|
562
|
+
formatter.rich.info(" 4. Scan your [bold]key card[/bold] on the card reader to confirm")
|
|
563
|
+
formatter.rich.info("")
|
|
564
|
+
|
|
565
|
+
# Method 2: Tesla app
|
|
566
|
+
formatter.rich.info(
|
|
567
|
+
" [bold]Option 2: Tesla app[/bold] [yellow](may take up to 2 hours)[/yellow]"
|
|
568
|
+
)
|
|
569
|
+
formatter.rich.info(" 1. Open the Tesla app > [bold]Profile > Security & Privacy[/bold]")
|
|
570
|
+
formatter.rich.info(" 2. Tap [bold]Third-Party Apps[/bold]")
|
|
571
|
+
formatter.rich.info(" 3. Find tescmd and tap [bold]Remove[/bold]")
|
|
572
|
+
formatter.rich.info("")
|
|
573
|
+
|
|
574
|
+
# Method 3: Tesla account website
|
|
575
|
+
formatter.rich.info(
|
|
576
|
+
" [bold]Option 3: Tesla account website[/bold] [yellow](may take up to 2 hours)[/yellow]"
|
|
577
|
+
)
|
|
578
|
+
formatter.rich.info(
|
|
579
|
+
" 1. Sign in at [link=https://accounts.tesla.com]accounts.tesla.com[/link]"
|
|
580
|
+
)
|
|
581
|
+
formatter.rich.info(" 2. Go to [bold]Security > Third Party Apps[/bold]")
|
|
582
|
+
formatter.rich.info(" 3. Find tescmd and tap [bold]Manage > Remove[/bold]")
|
|
583
|
+
formatter.rich.info("")
|
|
584
|
+
formatter.rich.info("━" * 55)
|
|
585
|
+
formatter.rich.info("")
|
|
586
|
+
|
|
587
|
+
# OAuth consent revocation
|
|
588
|
+
if revoke_url:
|
|
589
|
+
formatter.rich.info("[bold]Revoke OAuth access entirely[/bold]")
|
|
590
|
+
formatter.rich.info("")
|
|
591
|
+
formatter.rich.info(" To also revoke this app's OAuth access to your Tesla account:")
|
|
592
|
+
formatter.rich.info(f" [link={revoke_url}]{revoke_url}[/link]")
|
|
593
|
+
formatter.rich.info("")
|
|
594
|
+
|
|
595
|
+
if open_browser:
|
|
596
|
+
formatter.rich.info("Opening revocation page in browser…")
|
|
597
|
+
webbrowser.open(revoke_url)
|
|
598
|
+
formatter.rich.info("")
|
|
599
|
+
else:
|
|
600
|
+
formatter.rich.info("[dim]Set TESLA_CLIENT_ID to generate a consent revocation URL.[/dim]")
|
|
601
|
+
formatter.rich.info("")
|
|
602
|
+
|
|
603
|
+
# Cleanup guidance
|
|
604
|
+
formatter.rich.info("[bold]Local cleanup[/bold]")
|
|
605
|
+
formatter.rich.info("")
|
|
606
|
+
formatter.rich.info(" To also remove local credentials and keys:")
|
|
607
|
+
formatter.rich.info(" [cyan]tescmd auth logout[/cyan] — clear stored OAuth tokens")
|
|
608
|
+
formatter.rich.info(" [cyan]tescmd cache clear[/cyan] — clear cached API responses")
|
|
609
|
+
formatter.rich.info(
|
|
610
|
+
f" [dim]Key files are stored in: {Path(settings.config_dir).expanduser() / 'keys'}[/dim]"
|
|
611
|
+
)
|