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/setup.py
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
"""CLI command for the tiered onboarding wizard (``tescmd setup``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from tescmd._internal.async_utils import run_async
|
|
12
|
+
from tescmd.cli._options import global_options
|
|
13
|
+
from tescmd.models.config import AppSettings
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from tescmd.cli.main import AppContext
|
|
17
|
+
from tescmd.output.formatter import OutputFormatter
|
|
18
|
+
|
|
19
|
+
# Callable signature for ``formatter.rich.info`` (and mocks in tests).
|
|
20
|
+
_InfoFn = Callable[..., object]
|
|
21
|
+
|
|
22
|
+
TIER_READONLY = "readonly"
|
|
23
|
+
TIER_FULL = "full"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Click command
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@click.command("setup")
|
|
32
|
+
@global_options
|
|
33
|
+
def setup_cmd(app_ctx: AppContext) -> None:
|
|
34
|
+
"""Interactive setup wizard for first-time configuration."""
|
|
35
|
+
run_async(_cmd_setup(app_ctx))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Main wizard
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _cmd_setup(app_ctx: AppContext) -> None:
|
|
44
|
+
"""Run the tiered onboarding wizard."""
|
|
45
|
+
formatter = app_ctx.formatter
|
|
46
|
+
settings = AppSettings()
|
|
47
|
+
|
|
48
|
+
# Phase 0: Welcome + tier selection
|
|
49
|
+
tier = _prompt_tier(formatter, settings)
|
|
50
|
+
if not tier:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Phase 1: Domain setup via GitHub Pages (must happen before developer
|
|
54
|
+
# portal because Tesla requires the Allowed Origin URL to match the
|
|
55
|
+
# registration domain)
|
|
56
|
+
domain = _domain_setup(formatter, settings)
|
|
57
|
+
if not domain:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
# Re-read settings after potential .env changes
|
|
61
|
+
settings = AppSettings()
|
|
62
|
+
|
|
63
|
+
# Phase 2: Developer portal walkthrough (credentials — uses domain for
|
|
64
|
+
# the Allowed Origin URL instructions)
|
|
65
|
+
client_id, client_secret = _developer_portal_setup(formatter, app_ctx, settings, domain=domain)
|
|
66
|
+
if not client_id:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
# Re-read settings again
|
|
70
|
+
settings = AppSettings()
|
|
71
|
+
|
|
72
|
+
# Phase 3: Key generation + deployment (full tier only)
|
|
73
|
+
if tier == TIER_FULL:
|
|
74
|
+
_key_setup(formatter, settings, domain)
|
|
75
|
+
|
|
76
|
+
# Phase 3.5: Key enrollment (full tier only)
|
|
77
|
+
if tier == TIER_FULL:
|
|
78
|
+
await _enrollment_step(formatter, app_ctx, settings)
|
|
79
|
+
|
|
80
|
+
# Phase 4: Fleet API partner registration
|
|
81
|
+
await _registration_step(formatter, app_ctx, settings, client_id, client_secret, domain)
|
|
82
|
+
|
|
83
|
+
# Phase 5: OAuth login
|
|
84
|
+
await _oauth_login_step(formatter, app_ctx, settings, client_id, client_secret)
|
|
85
|
+
|
|
86
|
+
# Phase 6: Summary
|
|
87
|
+
_print_next_steps(formatter, tier)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Phase 0: Tier selection
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _prompt_tier(formatter: OutputFormatter, settings: AppSettings) -> str:
|
|
96
|
+
"""Ask the user which tier they want and persist the choice."""
|
|
97
|
+
info = formatter.rich.info
|
|
98
|
+
|
|
99
|
+
# If already configured, offer to keep or change
|
|
100
|
+
existing_tier = settings.setup_tier
|
|
101
|
+
if existing_tier in (TIER_READONLY, TIER_FULL):
|
|
102
|
+
info(f"Setup tier: [cyan]{existing_tier}[/cyan] (previously configured)")
|
|
103
|
+
info("")
|
|
104
|
+
|
|
105
|
+
if existing_tier == TIER_FULL:
|
|
106
|
+
return existing_tier
|
|
107
|
+
|
|
108
|
+
# Offer upgrade from readonly → full
|
|
109
|
+
try:
|
|
110
|
+
answer = input("Upgrade to full control? [y/N] ").strip()
|
|
111
|
+
except (EOFError, KeyboardInterrupt):
|
|
112
|
+
info("")
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
if answer.lower() != "y":
|
|
116
|
+
return existing_tier
|
|
117
|
+
|
|
118
|
+
tier = TIER_FULL
|
|
119
|
+
else:
|
|
120
|
+
info("")
|
|
121
|
+
info("[bold cyan]Welcome to tescmd![/bold cyan]")
|
|
122
|
+
info("")
|
|
123
|
+
info("How would you like to use tescmd?")
|
|
124
|
+
info("")
|
|
125
|
+
info(
|
|
126
|
+
" [bold]1.[/bold] [cyan]Read-only[/cyan]"
|
|
127
|
+
" — view vehicle data, location, battery status"
|
|
128
|
+
)
|
|
129
|
+
info(" (Requires: Tesla Developer app + domain for registration)")
|
|
130
|
+
info("")
|
|
131
|
+
info(
|
|
132
|
+
" [bold]2.[/bold] [cyan]Full control[/cyan]"
|
|
133
|
+
" — read data + lock/unlock, charge, climate, etc."
|
|
134
|
+
)
|
|
135
|
+
info(" (Requires: all of the above + EC key pair deployed to your domain)")
|
|
136
|
+
info(
|
|
137
|
+
" [dim]Enables Fleet Telemetry streaming — up to 97% cost"
|
|
138
|
+
" reduction vs polling the REST API.[/dim]"
|
|
139
|
+
)
|
|
140
|
+
info("")
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
choice = input("Choose [1] or [2] (default: 1): ").strip()
|
|
144
|
+
except (EOFError, KeyboardInterrupt):
|
|
145
|
+
info("")
|
|
146
|
+
return ""
|
|
147
|
+
|
|
148
|
+
tier = TIER_FULL if choice == "2" else TIER_READONLY
|
|
149
|
+
|
|
150
|
+
# Persist tier
|
|
151
|
+
from tescmd.cli.auth import _write_env_value
|
|
152
|
+
|
|
153
|
+
_write_env_value("TESLA_SETUP_TIER", tier)
|
|
154
|
+
info(f"[green]Tier set to: {tier}[/green]")
|
|
155
|
+
info("")
|
|
156
|
+
|
|
157
|
+
return tier
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# Phase 2: Developer portal walkthrough
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _developer_portal_setup(
|
|
166
|
+
formatter: OutputFormatter,
|
|
167
|
+
app_ctx: AppContext,
|
|
168
|
+
settings: AppSettings,
|
|
169
|
+
*,
|
|
170
|
+
domain: str = "",
|
|
171
|
+
) -> tuple[str, str]:
|
|
172
|
+
"""Walk through Tesla Developer Portal setup if credentials are missing."""
|
|
173
|
+
info = formatter.rich.info
|
|
174
|
+
|
|
175
|
+
client_id = settings.client_id
|
|
176
|
+
client_secret = settings.client_secret
|
|
177
|
+
|
|
178
|
+
if client_id:
|
|
179
|
+
info(f"Client ID: [cyan]{client_id[:8]}...[/cyan] (already configured)")
|
|
180
|
+
return (client_id, client_secret or "")
|
|
181
|
+
|
|
182
|
+
info("[bold]Phase 2: Tesla Developer Portal Setup[/bold]")
|
|
183
|
+
info("")
|
|
184
|
+
|
|
185
|
+
# Delegate to the existing interactive setup wizard, passing the domain
|
|
186
|
+
# so the portal instructions show the correct Allowed Origin URL
|
|
187
|
+
port = 8085
|
|
188
|
+
redirect_uri = f"http://localhost:{port}/callback"
|
|
189
|
+
from tescmd.cli.auth import _interactive_setup
|
|
190
|
+
|
|
191
|
+
return _interactive_setup(formatter, port, redirect_uri, domain=domain)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Phase 1: Domain setup
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
|
|
200
|
+
"""Set up a domain via GitHub Pages or manual entry."""
|
|
201
|
+
info = formatter.rich.info
|
|
202
|
+
|
|
203
|
+
if settings.domain:
|
|
204
|
+
info(f"Domain: [cyan]{settings.domain}[/cyan] (already configured)")
|
|
205
|
+
info("")
|
|
206
|
+
return settings.domain
|
|
207
|
+
|
|
208
|
+
info("[bold]Phase 1: Domain Setup[/bold]")
|
|
209
|
+
info("")
|
|
210
|
+
info(
|
|
211
|
+
"Tesla requires a registered domain for Fleet API access."
|
|
212
|
+
" The easiest approach is a free GitHub Pages site."
|
|
213
|
+
)
|
|
214
|
+
info("")
|
|
215
|
+
|
|
216
|
+
# Try automated GitHub Pages setup
|
|
217
|
+
from tescmd.deploy.github_pages import is_gh_authenticated, is_gh_available
|
|
218
|
+
|
|
219
|
+
if is_gh_available() and is_gh_authenticated():
|
|
220
|
+
return _automated_domain_setup(formatter, settings)
|
|
221
|
+
|
|
222
|
+
# Fall back to manual domain entry
|
|
223
|
+
return _manual_domain_setup(formatter)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _automated_domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
|
|
227
|
+
"""Offer to auto-create a GitHub Pages site."""
|
|
228
|
+
info = formatter.rich.info
|
|
229
|
+
|
|
230
|
+
from tescmd.deploy.github_pages import (
|
|
231
|
+
create_pages_repo,
|
|
232
|
+
get_gh_username,
|
|
233
|
+
get_pages_domain,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
username = get_gh_username()
|
|
237
|
+
suggested_domain = f"{username}.github.io".lower()
|
|
238
|
+
|
|
239
|
+
info(f"GitHub CLI detected. Logged in as [cyan]{username}[/cyan].")
|
|
240
|
+
info(f"Suggested domain: [cyan]{suggested_domain}[/cyan]")
|
|
241
|
+
info("")
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
answer = input(f"Create/use {suggested_domain} as your domain? [Y/n] ").strip()
|
|
245
|
+
except (EOFError, KeyboardInterrupt):
|
|
246
|
+
info("")
|
|
247
|
+
return ""
|
|
248
|
+
|
|
249
|
+
if answer.lower() == "n":
|
|
250
|
+
return _manual_domain_setup(formatter)
|
|
251
|
+
|
|
252
|
+
info("Creating GitHub Pages repo...")
|
|
253
|
+
repo_name = create_pages_repo(username)
|
|
254
|
+
domain = get_pages_domain(repo_name)
|
|
255
|
+
|
|
256
|
+
# Persist domain and repo to .env
|
|
257
|
+
from tescmd.cli.auth import _write_env_value
|
|
258
|
+
|
|
259
|
+
_write_env_value("TESLA_DOMAIN", domain)
|
|
260
|
+
_write_env_value("TESLA_GITHUB_REPO", repo_name)
|
|
261
|
+
|
|
262
|
+
info(f"[green]Domain configured: {domain}[/green]")
|
|
263
|
+
info(f"[green]GitHub repo: {repo_name}[/green]")
|
|
264
|
+
info("")
|
|
265
|
+
|
|
266
|
+
return domain
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _manual_domain_setup(formatter: OutputFormatter) -> str:
|
|
270
|
+
"""Prompt for a domain manually."""
|
|
271
|
+
from tescmd.cli.auth import _prompt_for_domain
|
|
272
|
+
|
|
273
|
+
return _prompt_for_domain(formatter)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# Phase 3: Key generation + deployment
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -> None:
|
|
282
|
+
"""Generate keys and deploy to GitHub Pages (full tier only)."""
|
|
283
|
+
info = formatter.rich.info
|
|
284
|
+
|
|
285
|
+
info("[bold]Phase 3: EC Key Generation & Deployment[/bold]")
|
|
286
|
+
info("")
|
|
287
|
+
|
|
288
|
+
key_dir = Path(settings.config_dir).expanduser() / "keys"
|
|
289
|
+
|
|
290
|
+
from tescmd.crypto.keys import (
|
|
291
|
+
generate_ec_key_pair,
|
|
292
|
+
get_key_fingerprint,
|
|
293
|
+
has_key_pair,
|
|
294
|
+
load_public_key_pem,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Generate keys if needed
|
|
298
|
+
if has_key_pair(key_dir):
|
|
299
|
+
info(f"Key pair: [cyan]exists[/cyan] (fingerprint: {get_key_fingerprint(key_dir)})")
|
|
300
|
+
else:
|
|
301
|
+
info("Generating EC P-256 key pair...")
|
|
302
|
+
generate_ec_key_pair(key_dir)
|
|
303
|
+
info("[green]Key pair generated.[/green]")
|
|
304
|
+
info(f" Fingerprint: {get_key_fingerprint(key_dir)}")
|
|
305
|
+
|
|
306
|
+
info("")
|
|
307
|
+
|
|
308
|
+
# Deploy key via GitHub Pages if gh is available
|
|
309
|
+
from tescmd.deploy.github_pages import (
|
|
310
|
+
deploy_public_key,
|
|
311
|
+
get_key_url,
|
|
312
|
+
is_gh_authenticated,
|
|
313
|
+
is_gh_available,
|
|
314
|
+
validate_key_url,
|
|
315
|
+
wait_for_pages_deployment,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
github_repo = settings.github_repo
|
|
319
|
+
if not github_repo:
|
|
320
|
+
info("[yellow]No GitHub repo configured for key deployment.[/yellow]")
|
|
321
|
+
info(" Run [cyan]tescmd key deploy[/cyan] to deploy your public key.")
|
|
322
|
+
info("")
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
if not (is_gh_available() and is_gh_authenticated()):
|
|
326
|
+
info("[yellow]GitHub CLI not available or not authenticated.[/yellow]")
|
|
327
|
+
info(" Run [cyan]tescmd key deploy[/cyan] after setting up gh CLI.")
|
|
328
|
+
info("")
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
# Check if key is already deployed
|
|
332
|
+
if validate_key_url(domain):
|
|
333
|
+
info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
|
|
334
|
+
info("")
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
info("Deploying public key to GitHub Pages...")
|
|
338
|
+
pem = load_public_key_pem(key_dir)
|
|
339
|
+
deploy_public_key(pem, github_repo)
|
|
340
|
+
|
|
341
|
+
info("[green]Key committed and pushed.[/green]")
|
|
342
|
+
info("Waiting for GitHub Pages to publish...")
|
|
343
|
+
|
|
344
|
+
deployed = wait_for_pages_deployment(domain)
|
|
345
|
+
if deployed:
|
|
346
|
+
info(f"[green]Key is live at:[/green] {get_key_url(domain)}")
|
|
347
|
+
else:
|
|
348
|
+
info(
|
|
349
|
+
"[yellow]Key deployed but not yet accessible."
|
|
350
|
+
" GitHub Pages may still be building.[/yellow]"
|
|
351
|
+
)
|
|
352
|
+
info(" Run [cyan]tescmd key validate[/cyan] to check later.")
|
|
353
|
+
info("")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# Phase 3.5: Key enrollment
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
async def _enrollment_step(
|
|
362
|
+
formatter: OutputFormatter,
|
|
363
|
+
app_ctx: AppContext,
|
|
364
|
+
settings: AppSettings,
|
|
365
|
+
) -> None:
|
|
366
|
+
"""Guide the user through key enrollment via the Tesla app portal."""
|
|
367
|
+
import webbrowser
|
|
368
|
+
|
|
369
|
+
from tescmd.crypto.keys import get_key_fingerprint, has_key_pair
|
|
370
|
+
from tescmd.deploy.github_pages import get_key_url, validate_key_url
|
|
371
|
+
|
|
372
|
+
info = formatter.rich.info
|
|
373
|
+
key_dir = Path(settings.config_dir).expanduser() / "keys"
|
|
374
|
+
|
|
375
|
+
if not has_key_pair(key_dir):
|
|
376
|
+
return # No keys to enroll
|
|
377
|
+
|
|
378
|
+
domain = settings.domain
|
|
379
|
+
if not domain:
|
|
380
|
+
info("[yellow]No domain configured — skipping enrollment.[/yellow]")
|
|
381
|
+
info(" Run [cyan]tescmd key enroll[/cyan] after setting TESLA_DOMAIN.")
|
|
382
|
+
info("")
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
info("[bold]Phase 3.5: Key Enrollment[/bold]")
|
|
386
|
+
info("")
|
|
387
|
+
info(" Your key is generated and deployed. To control a vehicle, the key")
|
|
388
|
+
info(" must also be enrolled via the Tesla app.")
|
|
389
|
+
info("")
|
|
390
|
+
|
|
391
|
+
# Verify the public key is accessible
|
|
392
|
+
key_url = get_key_url(domain)
|
|
393
|
+
key_accessible = validate_key_url(domain)
|
|
394
|
+
if not key_accessible:
|
|
395
|
+
info(f" [yellow]Public key not accessible at {key_url}[/yellow]")
|
|
396
|
+
info(" Enrollment requires the key to be live. Skipping for now.")
|
|
397
|
+
info(" After deploying, run [cyan]tescmd key enroll[/cyan].")
|
|
398
|
+
info("")
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
fingerprint = get_key_fingerprint(key_dir)
|
|
402
|
+
enroll_url = f"https://tesla.com/_ak/{domain}"
|
|
403
|
+
|
|
404
|
+
info(f" Domain: {domain}")
|
|
405
|
+
info(f" Fingerprint: {fingerprint[:8]}…")
|
|
406
|
+
info(f" Public key: [green]accessible[/green] at {key_url}")
|
|
407
|
+
info("")
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
answer = input(" Open enrollment URL in your browser? [Y/n] ").strip()
|
|
411
|
+
except (EOFError, KeyboardInterrupt):
|
|
412
|
+
info("")
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
if answer.lower() not in ("n", "no"):
|
|
416
|
+
info("")
|
|
417
|
+
info(f" Opening [link={enroll_url}]{enroll_url}[/link]…")
|
|
418
|
+
webbrowser.open(enroll_url)
|
|
419
|
+
info("")
|
|
420
|
+
|
|
421
|
+
info(" " + "━" * 49)
|
|
422
|
+
info(" [bold yellow]ACTION REQUIRED: Add virtual key in the Tesla app[/bold yellow]")
|
|
423
|
+
info("")
|
|
424
|
+
info(f" Enrollment URL: {enroll_url}")
|
|
425
|
+
info("")
|
|
426
|
+
info(" 1. Open the URL above [bold]on your phone[/bold]")
|
|
427
|
+
info(" 2. Tap [bold]Finish Setup[/bold] on the web page")
|
|
428
|
+
info(" 3. The Tesla app shows an [bold]Add Virtual Key[/bold] prompt")
|
|
429
|
+
info(" 4. Approve it")
|
|
430
|
+
info("")
|
|
431
|
+
info(" [dim]If the prompt doesn't appear, force-quit the Tesla app,[/dim]")
|
|
432
|
+
info(" [dim]go back to your browser, and tap Finish Setup again.[/dim]")
|
|
433
|
+
info(" " + "━" * 49)
|
|
434
|
+
info("")
|
|
435
|
+
info(" After approving, try: [cyan]tescmd charge status --wake[/cyan]")
|
|
436
|
+
info("")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ---------------------------------------------------------------------------
|
|
440
|
+
# Phase 4: Fleet API registration
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def _registration_step(
|
|
445
|
+
formatter: OutputFormatter,
|
|
446
|
+
app_ctx: AppContext,
|
|
447
|
+
settings: AppSettings,
|
|
448
|
+
client_id: str,
|
|
449
|
+
client_secret: str,
|
|
450
|
+
domain: str,
|
|
451
|
+
) -> None:
|
|
452
|
+
"""Register with the Tesla Fleet API."""
|
|
453
|
+
info = formatter.rich.info
|
|
454
|
+
|
|
455
|
+
if not client_secret:
|
|
456
|
+
info("[yellow]Skipping Fleet API registration (no client secret).[/yellow]")
|
|
457
|
+
info(" Run [cyan]tescmd auth register[/cyan] after adding TESLA_CLIENT_SECRET.")
|
|
458
|
+
info("")
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
info("[bold]Phase 4: Fleet API Registration[/bold]")
|
|
462
|
+
info("")
|
|
463
|
+
|
|
464
|
+
# Pre-check: Tesla requires the public key to be accessible before
|
|
465
|
+
# registration will succeed (HTTP 424 otherwise).
|
|
466
|
+
key_ready = _precheck_public_key(formatter, settings, domain)
|
|
467
|
+
if not key_ready:
|
|
468
|
+
info("[yellow]Skipping registration — public key not accessible.[/yellow]")
|
|
469
|
+
info(" Run [cyan]tescmd auth register[/cyan] once the key is live.")
|
|
470
|
+
info("")
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
from tescmd.auth.oauth import register_partner_account
|
|
474
|
+
|
|
475
|
+
region = app_ctx.region or settings.region
|
|
476
|
+
|
|
477
|
+
info(f"Registering with Fleet API ({region} region)...")
|
|
478
|
+
try:
|
|
479
|
+
_result, _scopes = await register_partner_account(
|
|
480
|
+
client_id=client_id,
|
|
481
|
+
client_secret=client_secret,
|
|
482
|
+
domain=domain,
|
|
483
|
+
region=region,
|
|
484
|
+
)
|
|
485
|
+
info("[green]Registration successful.[/green]")
|
|
486
|
+
except Exception as exc:
|
|
487
|
+
status_code = getattr(exc, "status_code", None)
|
|
488
|
+
exc_text = str(exc)
|
|
489
|
+
|
|
490
|
+
if status_code == 412 or "must match registered allowed origin" in exc_text:
|
|
491
|
+
_remediate_412(info, domain)
|
|
492
|
+
elif status_code == 424 or "Public key download failed" in exc_text:
|
|
493
|
+
_remediate_424(info, domain)
|
|
494
|
+
else:
|
|
495
|
+
info(f"[yellow]Registration failed:[/yellow] {exc}")
|
|
496
|
+
info(" Run [cyan]tescmd auth register[/cyan] to retry.")
|
|
497
|
+
info("")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _precheck_public_key(
|
|
501
|
+
formatter: OutputFormatter,
|
|
502
|
+
settings: AppSettings,
|
|
503
|
+
domain: str,
|
|
504
|
+
) -> bool:
|
|
505
|
+
"""Verify the public key is accessible; offer to deploy if not.
|
|
506
|
+
|
|
507
|
+
Returns True when the key is confirmed live (or was already live),
|
|
508
|
+
False when the user declines or deployment/validation fails.
|
|
509
|
+
"""
|
|
510
|
+
info = formatter.rich.info
|
|
511
|
+
|
|
512
|
+
from tescmd.deploy.github_pages import get_key_url, validate_key_url
|
|
513
|
+
|
|
514
|
+
info("Checking public key availability...")
|
|
515
|
+
|
|
516
|
+
if validate_key_url(domain):
|
|
517
|
+
info(f" Public key: [green]accessible[/green] at {get_key_url(domain)}")
|
|
518
|
+
info("")
|
|
519
|
+
return True
|
|
520
|
+
|
|
521
|
+
info(f" Public key: [yellow]not found[/yellow] at {get_key_url(domain)}")
|
|
522
|
+
info("")
|
|
523
|
+
info(" Tesla requires your public key to be accessible before registration will succeed.")
|
|
524
|
+
info("")
|
|
525
|
+
|
|
526
|
+
# Offer to automate key generation + deployment
|
|
527
|
+
try:
|
|
528
|
+
answer = input("Generate and deploy the public key now? [Y/n] ").strip()
|
|
529
|
+
except (EOFError, KeyboardInterrupt):
|
|
530
|
+
info("")
|
|
531
|
+
return False
|
|
532
|
+
|
|
533
|
+
if answer.lower() == "n":
|
|
534
|
+
_remediate_424(info, domain)
|
|
535
|
+
return False
|
|
536
|
+
|
|
537
|
+
# Automate: generate (if needed) + deploy + wait
|
|
538
|
+
return _auto_deploy_key(formatter, settings, domain)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _auto_deploy_key(
|
|
542
|
+
formatter: OutputFormatter,
|
|
543
|
+
settings: AppSettings,
|
|
544
|
+
domain: str,
|
|
545
|
+
) -> bool:
|
|
546
|
+
"""Generate a key pair (if needed), deploy to GitHub Pages, and wait.
|
|
547
|
+
|
|
548
|
+
Returns True when the key is confirmed accessible, False otherwise.
|
|
549
|
+
"""
|
|
550
|
+
info = formatter.rich.info
|
|
551
|
+
key_dir = Path(settings.config_dir).expanduser() / "keys"
|
|
552
|
+
|
|
553
|
+
from tescmd.crypto.keys import (
|
|
554
|
+
generate_ec_key_pair,
|
|
555
|
+
get_key_fingerprint,
|
|
556
|
+
has_key_pair,
|
|
557
|
+
load_public_key_pem,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
# 1. Generate keys if needed
|
|
561
|
+
if has_key_pair(key_dir):
|
|
562
|
+
info(f"Key pair: [cyan]exists[/cyan] (fingerprint: {get_key_fingerprint(key_dir)})")
|
|
563
|
+
else:
|
|
564
|
+
info("Generating EC P-256 key pair...")
|
|
565
|
+
generate_ec_key_pair(key_dir)
|
|
566
|
+
info(f"[green]Key pair generated.[/green] Fingerprint: {get_key_fingerprint(key_dir)}")
|
|
567
|
+
info("")
|
|
568
|
+
|
|
569
|
+
# 2. Deploy to GitHub Pages
|
|
570
|
+
from tescmd.deploy.github_pages import (
|
|
571
|
+
deploy_public_key,
|
|
572
|
+
get_key_url,
|
|
573
|
+
is_gh_authenticated,
|
|
574
|
+
is_gh_available,
|
|
575
|
+
validate_key_url,
|
|
576
|
+
wait_for_pages_deployment,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
github_repo = settings.github_repo
|
|
580
|
+
if not github_repo:
|
|
581
|
+
info("[yellow]No GitHub repo configured for key deployment.[/yellow]")
|
|
582
|
+
info(" Run [cyan]tescmd key deploy[/cyan] to deploy your public key.")
|
|
583
|
+
info("")
|
|
584
|
+
return False
|
|
585
|
+
|
|
586
|
+
if not (is_gh_available() and is_gh_authenticated()):
|
|
587
|
+
info("[yellow]GitHub CLI not available or not authenticated.[/yellow]")
|
|
588
|
+
info(" Run [cyan]tescmd key deploy[/cyan] after setting up gh CLI.")
|
|
589
|
+
info("")
|
|
590
|
+
return False
|
|
591
|
+
|
|
592
|
+
# Already deployed?
|
|
593
|
+
if validate_key_url(domain):
|
|
594
|
+
info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
|
|
595
|
+
info("")
|
|
596
|
+
return True
|
|
597
|
+
|
|
598
|
+
info("Deploying public key to GitHub Pages...")
|
|
599
|
+
pem = load_public_key_pem(key_dir)
|
|
600
|
+
deploy_public_key(pem, github_repo)
|
|
601
|
+
|
|
602
|
+
info("[green]Key committed and pushed.[/green]")
|
|
603
|
+
info("Waiting for GitHub Pages to publish...")
|
|
604
|
+
|
|
605
|
+
deployed = wait_for_pages_deployment(domain)
|
|
606
|
+
if deployed:
|
|
607
|
+
info(f"[green]Key is live at:[/green] {get_key_url(domain)}")
|
|
608
|
+
info("")
|
|
609
|
+
return True
|
|
610
|
+
|
|
611
|
+
info(
|
|
612
|
+
"[yellow]Key deployed but not yet accessible. GitHub Pages may still be building.[/yellow]"
|
|
613
|
+
)
|
|
614
|
+
info(
|
|
615
|
+
" Run [cyan]tescmd key validate[/cyan] to check, then [cyan]tescmd auth register[/cyan]."
|
|
616
|
+
)
|
|
617
|
+
info("")
|
|
618
|
+
return False
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _remediate_412(info: _InfoFn, domain: str) -> None:
|
|
622
|
+
"""Print remediation steps for HTTP 412 (origin mismatch)."""
|
|
623
|
+
info("[yellow]Registration failed (HTTP 412): origin mismatch.[/yellow]")
|
|
624
|
+
info("")
|
|
625
|
+
info("[bold]How to fix:[/bold]")
|
|
626
|
+
info(
|
|
627
|
+
" The [cyan]Allowed Origin URL[/cyan] in your Tesla Developer"
|
|
628
|
+
" app must match your registration domain."
|
|
629
|
+
)
|
|
630
|
+
info("")
|
|
631
|
+
info(" 1. Go to [cyan]https://developer.tesla.com[/cyan]")
|
|
632
|
+
info(" 2. Open your application")
|
|
633
|
+
info(" 3. Set [cyan]Allowed Origin URL[/cyan] to:")
|
|
634
|
+
info(f" [bold]https://{domain}[/bold]")
|
|
635
|
+
info(" 4. Save, then re-run [cyan]tescmd setup[/cyan]")
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _remediate_424(info: _InfoFn, domain: str) -> None:
|
|
639
|
+
"""Print remediation steps for HTTP 424 (public key not found)."""
|
|
640
|
+
from tescmd.deploy.github_pages import WELL_KNOWN_PATH
|
|
641
|
+
|
|
642
|
+
key_url = f"https://{domain}/{WELL_KNOWN_PATH}"
|
|
643
|
+
|
|
644
|
+
info("[yellow]Registration failed (HTTP 424): public key not found.[/yellow]")
|
|
645
|
+
info("")
|
|
646
|
+
info("[bold]How to fix:[/bold]")
|
|
647
|
+
info(" Tesla tried to download your public key during registration but got a 404.")
|
|
648
|
+
info(f" Expected URL: [cyan]{key_url}[/cyan]")
|
|
649
|
+
info("")
|
|
650
|
+
info(" 1. Generate a key pair (if you haven't already):")
|
|
651
|
+
info(" [cyan]tescmd key generate[/cyan]")
|
|
652
|
+
info("")
|
|
653
|
+
info(" 2. Deploy the public key to your domain:")
|
|
654
|
+
info(" [cyan]tescmd key deploy[/cyan]")
|
|
655
|
+
info("")
|
|
656
|
+
info(" 3. Verify the key is accessible:")
|
|
657
|
+
info(" [cyan]tescmd key validate[/cyan]")
|
|
658
|
+
info("")
|
|
659
|
+
info(
|
|
660
|
+
" 4. Once the key is live, re-run [cyan]tescmd setup[/cyan]"
|
|
661
|
+
" (or [cyan]tescmd auth register[/cyan])."
|
|
662
|
+
)
|
|
663
|
+
info("")
|
|
664
|
+
info(
|
|
665
|
+
"[dim]If you just deployed the key, GitHub Pages may still"
|
|
666
|
+
" be building. Wait a minute and try again.[/dim]"
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# ---------------------------------------------------------------------------
|
|
671
|
+
# Phase 5: OAuth login
|
|
672
|
+
# ---------------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
async def _oauth_login_step(
|
|
676
|
+
formatter: OutputFormatter,
|
|
677
|
+
app_ctx: AppContext,
|
|
678
|
+
settings: AppSettings,
|
|
679
|
+
client_id: str,
|
|
680
|
+
client_secret: str,
|
|
681
|
+
) -> None:
|
|
682
|
+
"""Run the OAuth2 login flow."""
|
|
683
|
+
info = formatter.rich.info
|
|
684
|
+
|
|
685
|
+
from tescmd.auth.token_store import TokenStore
|
|
686
|
+
from tescmd.models.auth import DEFAULT_SCOPES
|
|
687
|
+
|
|
688
|
+
store = TokenStore(
|
|
689
|
+
profile=app_ctx.profile,
|
|
690
|
+
token_file=settings.token_file,
|
|
691
|
+
config_dir=settings.config_dir,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
if store.has_token:
|
|
695
|
+
# Check whether the stored scopes cover what we need.
|
|
696
|
+
# A readonly→full upgrade requires vehicle_cmds + vehicle_charging_cmds
|
|
697
|
+
# that the original readonly token may not have.
|
|
698
|
+
stored_scopes = set((store.metadata or {}).get("scopes", []))
|
|
699
|
+
required_scopes = set(DEFAULT_SCOPES)
|
|
700
|
+
missing = required_scopes - stored_scopes
|
|
701
|
+
|
|
702
|
+
if not missing:
|
|
703
|
+
info("Already logged in with required scopes. Skipping OAuth flow.")
|
|
704
|
+
info("")
|
|
705
|
+
return
|
|
706
|
+
|
|
707
|
+
info("[bold]Phase 5: OAuth Login[/bold]")
|
|
708
|
+
info("")
|
|
709
|
+
info("[yellow]Your existing token is missing scopes needed for full control:[/yellow]")
|
|
710
|
+
for scope in sorted(missing):
|
|
711
|
+
info(f" - {scope}")
|
|
712
|
+
info("")
|
|
713
|
+
info("Re-authenticating to request all required scopes...")
|
|
714
|
+
info("")
|
|
715
|
+
else:
|
|
716
|
+
info("[bold]Phase 5: OAuth Login[/bold]")
|
|
717
|
+
info("")
|
|
718
|
+
|
|
719
|
+
port = 8085
|
|
720
|
+
redirect_uri = f"http://localhost:{port}/callback"
|
|
721
|
+
|
|
722
|
+
info("Opening your browser to sign in to Tesla...")
|
|
723
|
+
info(
|
|
724
|
+
"When prompted, click [cyan]Select All[/cyan] and then"
|
|
725
|
+
" [cyan]Allow[/cyan] to grant tescmd access."
|
|
726
|
+
)
|
|
727
|
+
info("[dim]If the browser doesn't open, visit the URL printed below.[/dim]")
|
|
728
|
+
|
|
729
|
+
from tescmd.auth.oauth import login_flow
|
|
730
|
+
|
|
731
|
+
region = app_ctx.region or settings.region
|
|
732
|
+
|
|
733
|
+
await login_flow(
|
|
734
|
+
client_id=client_id,
|
|
735
|
+
client_secret=client_secret,
|
|
736
|
+
redirect_uri=redirect_uri,
|
|
737
|
+
scopes=DEFAULT_SCOPES,
|
|
738
|
+
port=port,
|
|
739
|
+
token_store=store,
|
|
740
|
+
region=region,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
info("[bold green]Login successful![/bold green]")
|
|
744
|
+
info("")
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
# ---------------------------------------------------------------------------
|
|
748
|
+
# Phase 6: Next steps
|
|
749
|
+
# ---------------------------------------------------------------------------
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _print_next_steps(formatter: OutputFormatter, tier: str) -> None:
|
|
753
|
+
"""Print a summary of what the user can do next."""
|
|
754
|
+
info = formatter.rich.info
|
|
755
|
+
|
|
756
|
+
info("[bold cyan]Setup complete![/bold cyan]")
|
|
757
|
+
info("")
|
|
758
|
+
info("Try these commands:")
|
|
759
|
+
info(" [cyan]tescmd vehicle list[/cyan] — list your vehicles")
|
|
760
|
+
info(" [cyan]tescmd vehicle data[/cyan] — view detailed vehicle data")
|
|
761
|
+
info(" [cyan]tescmd vehicle location[/cyan] — view vehicle location")
|
|
762
|
+
info("")
|
|
763
|
+
|
|
764
|
+
if tier == TIER_FULL:
|
|
765
|
+
info("[bold]For vehicle commands:[/bold]")
|
|
766
|
+
info(" If you haven't already, enroll your key on each vehicle:")
|
|
767
|
+
info(" [cyan]tescmd key enroll[/cyan]")
|
|
768
|
+
info("")
|
|
769
|
+
info(" Once enrolled, try:")
|
|
770
|
+
info(" [cyan]tescmd vehicle wake[/cyan] — wake up your vehicle")
|
|
771
|
+
info("")
|
|
772
|
+
info("[bold]For real-time streaming data:[/bold]")
|
|
773
|
+
info(
|
|
774
|
+
" Fleet Telemetry can replace REST polling with server-push,"
|
|
775
|
+
" cutting API costs by up to 97%."
|
|
776
|
+
)
|
|
777
|
+
info(
|
|
778
|
+
" See: [cyan]https://developer.tesla.com/docs/fleet-api"
|
|
779
|
+
"/getting-started/fleet-telemetry[/cyan]"
|
|
780
|
+
)
|
|
781
|
+
info("")
|
|
782
|
+
else:
|
|
783
|
+
info(
|
|
784
|
+
"[dim]Upgrade to full control later by running [cyan]tescmd setup[/cyan] again.[/dim]"
|
|
785
|
+
)
|
|
786
|
+
info("")
|