tescmd 0.2.0__py3-none-any.whl → 0.4.0__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 +1 -1
- tescmd/api/client.py +41 -4
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +5 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/auth/oauth.py +15 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +8 -1
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +255 -106
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +6 -7
- tescmd/cli/main.py +27 -8
- tescmd/cli/mcp_cmd.py +153 -0
- tescmd/cli/nav.py +3 -1
- tescmd/cli/openclaw.py +169 -0
- tescmd/cli/security.py +7 -1
- tescmd/cli/serve.py +923 -0
- tescmd/cli/setup.py +147 -58
- tescmd/cli/sharing.py +2 -0
- tescmd/cli/status.py +1 -1
- tescmd/cli/trunk.py +8 -17
- tescmd/cli/user.py +16 -1
- tescmd/cli/vehicle.py +135 -462
- tescmd/deploy/github_pages.py +21 -2
- tescmd/deploy/tailscale_serve.py +96 -8
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/auth.py +5 -2
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +529 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +700 -0
- tescmd/openclaw/telemetry_store.py +53 -0
- tescmd/output/rich_output.py +46 -14
- tescmd/protocol/commands.py +2 -2
- tescmd/protocol/encoder.py +16 -13
- tescmd/protocol/payloads.py +132 -11
- tescmd/protocol/session.py +8 -5
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +9 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +4 -4
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +308 -129
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/server.py +26 -19
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +78 -16
- tescmd/telemetry/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- tescmd-0.4.0.dist-info/METADATA +300 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/RECORD +64 -42
- tescmd-0.2.0.dist-info/METADATA +0 -495
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/licenses/LICENSE +0 -0
tescmd/cli/setup.py
CHANGED
|
@@ -29,10 +29,11 @@ TIER_FULL = "full"
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
@click.command("setup")
|
|
32
|
+
@click.option("--force", is_flag=True, help="Reconfigure everything from scratch.")
|
|
32
33
|
@global_options
|
|
33
|
-
def setup_cmd(app_ctx: AppContext) -> None:
|
|
34
|
+
def setup_cmd(app_ctx: AppContext, force: bool) -> None:
|
|
34
35
|
"""Interactive setup wizard for first-time configuration."""
|
|
35
|
-
run_async(_cmd_setup(app_ctx))
|
|
36
|
+
run_async(_cmd_setup(app_ctx, force=force))
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
# ---------------------------------------------------------------------------
|
|
@@ -40,29 +41,35 @@ def setup_cmd(app_ctx: AppContext) -> None:
|
|
|
40
41
|
# ---------------------------------------------------------------------------
|
|
41
42
|
|
|
42
43
|
|
|
43
|
-
async def _cmd_setup(app_ctx: AppContext) -> None:
|
|
44
|
+
async def _cmd_setup(app_ctx: AppContext, *, force: bool = False) -> None:
|
|
44
45
|
"""Run the tiered onboarding wizard."""
|
|
45
46
|
formatter = app_ctx.formatter
|
|
46
47
|
settings = AppSettings()
|
|
47
48
|
|
|
48
49
|
# Phase 0: Welcome + tier selection
|
|
49
|
-
tier = _prompt_tier(formatter, settings)
|
|
50
|
+
tier = _prompt_tier(formatter, settings, force=force)
|
|
50
51
|
if not tier:
|
|
51
52
|
return
|
|
52
53
|
|
|
53
54
|
# Phase 1: Domain setup via GitHub Pages (must happen before developer
|
|
54
55
|
# portal because Tesla requires the Allowed Origin URL to match the
|
|
55
56
|
# registration domain)
|
|
56
|
-
domain = _domain_setup(formatter, settings)
|
|
57
|
+
domain = _domain_setup(formatter, settings, force=force)
|
|
57
58
|
if not domain:
|
|
58
59
|
return
|
|
59
60
|
|
|
60
61
|
# Re-read settings after potential .env changes
|
|
61
62
|
settings = AppSettings()
|
|
62
63
|
|
|
64
|
+
# Early check: warn if remote key differs from local
|
|
65
|
+
if tier == TIER_FULL:
|
|
66
|
+
_check_key_mismatch(formatter, settings, domain)
|
|
67
|
+
|
|
63
68
|
# Phase 2: Developer portal walkthrough (credentials — uses domain for
|
|
64
69
|
# the Allowed Origin URL instructions)
|
|
65
|
-
client_id, client_secret = _developer_portal_setup(
|
|
70
|
+
client_id, client_secret = _developer_portal_setup(
|
|
71
|
+
formatter, app_ctx, settings, domain=domain, force=force, tier=tier
|
|
72
|
+
)
|
|
66
73
|
if not client_id:
|
|
67
74
|
return
|
|
68
75
|
|
|
@@ -71,17 +78,17 @@ async def _cmd_setup(app_ctx: AppContext) -> None:
|
|
|
71
78
|
|
|
72
79
|
# Phase 3: Key generation + deployment (full tier only)
|
|
73
80
|
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)
|
|
81
|
+
_key_setup(formatter, settings, domain, force=force)
|
|
79
82
|
|
|
80
|
-
# Phase 4: Fleet API partner registration
|
|
83
|
+
# Phase 4: Fleet API partner registration (closes out the setup phases)
|
|
81
84
|
await _registration_step(formatter, app_ctx, settings, client_id, client_secret, domain)
|
|
82
85
|
|
|
83
86
|
# Phase 5: OAuth login
|
|
84
|
-
await _oauth_login_step(formatter, app_ctx, settings, client_id, client_secret)
|
|
87
|
+
await _oauth_login_step(formatter, app_ctx, settings, client_id, client_secret, force=force)
|
|
88
|
+
|
|
89
|
+
# Phase 3.5: Key enrollment (full tier only — after login)
|
|
90
|
+
if tier == TIER_FULL:
|
|
91
|
+
await _enrollment_step(formatter, app_ctx, settings)
|
|
85
92
|
|
|
86
93
|
# Phase 6: Summary
|
|
87
94
|
_print_next_steps(formatter, tier)
|
|
@@ -92,13 +99,13 @@ async def _cmd_setup(app_ctx: AppContext) -> None:
|
|
|
92
99
|
# ---------------------------------------------------------------------------
|
|
93
100
|
|
|
94
101
|
|
|
95
|
-
def _prompt_tier(formatter: OutputFormatter, settings: AppSettings) -> str:
|
|
102
|
+
def _prompt_tier(formatter: OutputFormatter, settings: AppSettings, *, force: bool = False) -> str:
|
|
96
103
|
"""Ask the user which tier they want and persist the choice."""
|
|
97
104
|
info = formatter.rich.info
|
|
98
105
|
|
|
99
106
|
# If already configured, offer to keep or change
|
|
100
107
|
existing_tier = settings.setup_tier
|
|
101
|
-
if existing_tier in (TIER_READONLY, TIER_FULL):
|
|
108
|
+
if existing_tier in (TIER_READONLY, TIER_FULL) and not force:
|
|
102
109
|
info(f"Setup tier: [cyan]{existing_tier}[/cyan] (previously configured)")
|
|
103
110
|
info("")
|
|
104
111
|
|
|
@@ -157,6 +164,47 @@ def _prompt_tier(formatter: OutputFormatter, settings: AppSettings) -> str:
|
|
|
157
164
|
return tier
|
|
158
165
|
|
|
159
166
|
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Key mismatch warning (between Phase 1 and Phase 2)
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _check_key_mismatch(
|
|
173
|
+
formatter: OutputFormatter,
|
|
174
|
+
settings: AppSettings,
|
|
175
|
+
domain: str,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Warn early if the remote public key differs from the local key."""
|
|
178
|
+
info = formatter.rich.info
|
|
179
|
+
key_dir = Path(settings.config_dir).expanduser() / "keys"
|
|
180
|
+
|
|
181
|
+
from tescmd.crypto.keys import has_key_pair, load_public_key_pem
|
|
182
|
+
|
|
183
|
+
if not has_key_pair(key_dir):
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
pem = load_public_key_pem(key_dir)
|
|
187
|
+
|
|
188
|
+
# Fetch remote key (method-aware)
|
|
189
|
+
if settings.hosting_method == "tailscale":
|
|
190
|
+
from tescmd.deploy.tailscale_serve import fetch_tailscale_key_pem, get_key_url
|
|
191
|
+
|
|
192
|
+
url = get_key_url(domain)
|
|
193
|
+
remote_pem = fetch_tailscale_key_pem(domain)
|
|
194
|
+
else:
|
|
195
|
+
from tescmd.deploy.github_pages import fetch_key_pem, get_key_url
|
|
196
|
+
|
|
197
|
+
url = get_key_url(domain)
|
|
198
|
+
remote_pem = fetch_key_pem(domain)
|
|
199
|
+
|
|
200
|
+
if remote_pem is not None and remote_pem != pem.strip():
|
|
201
|
+
info("[yellow]The public key on your domain differs from your local key.[/yellow]")
|
|
202
|
+
info(f" Remote: {url}")
|
|
203
|
+
info(" This can happen after regenerating your key pair.")
|
|
204
|
+
info(" The key will be redeployed in Phase 3.")
|
|
205
|
+
info("")
|
|
206
|
+
|
|
207
|
+
|
|
160
208
|
# ---------------------------------------------------------------------------
|
|
161
209
|
# Phase 2: Developer portal walkthrough
|
|
162
210
|
# ---------------------------------------------------------------------------
|
|
@@ -168,6 +216,8 @@ def _developer_portal_setup(
|
|
|
168
216
|
settings: AppSettings,
|
|
169
217
|
*,
|
|
170
218
|
domain: str = "",
|
|
219
|
+
force: bool = False,
|
|
220
|
+
tier: str = "",
|
|
171
221
|
) -> tuple[str, str]:
|
|
172
222
|
"""Walk through Tesla Developer Portal setup if credentials are missing."""
|
|
173
223
|
info = formatter.rich.info
|
|
@@ -175,20 +225,35 @@ def _developer_portal_setup(
|
|
|
175
225
|
client_id = settings.client_id
|
|
176
226
|
client_secret = settings.client_secret
|
|
177
227
|
|
|
178
|
-
if client_id:
|
|
228
|
+
if client_id and not force:
|
|
179
229
|
info(f"Client ID: [cyan]{client_id[:8]}...[/cyan] (already configured)")
|
|
180
230
|
return (client_id, client_secret or "")
|
|
181
231
|
|
|
182
232
|
info("[bold]Phase 2: Tesla Developer Portal Setup[/bold]")
|
|
183
233
|
info("")
|
|
184
234
|
|
|
235
|
+
# Detect Tailscale hostname if domain was set to Tailscale in Phase 1
|
|
236
|
+
ts_hostname = ""
|
|
237
|
+
if settings.hosting_method == "tailscale" and settings.domain:
|
|
238
|
+
ts_hostname = settings.domain
|
|
239
|
+
|
|
185
240
|
# Delegate to the existing interactive setup wizard, passing the domain
|
|
186
241
|
# so the portal instructions show the correct Allowed Origin URL
|
|
187
|
-
|
|
242
|
+
from tescmd.models.auth import DEFAULT_PORT
|
|
243
|
+
|
|
244
|
+
port = DEFAULT_PORT
|
|
188
245
|
redirect_uri = f"http://localhost:{port}/callback"
|
|
189
246
|
from tescmd.cli.auth import _interactive_setup
|
|
190
247
|
|
|
191
|
-
return _interactive_setup(
|
|
248
|
+
return _interactive_setup(
|
|
249
|
+
formatter,
|
|
250
|
+
port,
|
|
251
|
+
redirect_uri,
|
|
252
|
+
domain=domain,
|
|
253
|
+
tailscale_hostname=ts_hostname,
|
|
254
|
+
full_tier=(tier == TIER_FULL),
|
|
255
|
+
force=force,
|
|
256
|
+
)
|
|
192
257
|
|
|
193
258
|
|
|
194
259
|
# ---------------------------------------------------------------------------
|
|
@@ -196,11 +261,13 @@ def _developer_portal_setup(
|
|
|
196
261
|
# ---------------------------------------------------------------------------
|
|
197
262
|
|
|
198
263
|
|
|
199
|
-
def _domain_setup(
|
|
264
|
+
def _domain_setup(
|
|
265
|
+
formatter: OutputFormatter, settings: AppSettings, *, force: bool = False
|
|
266
|
+
) -> str:
|
|
200
267
|
"""Set up a domain via GitHub Pages, Tailscale Funnel, or manual entry."""
|
|
201
268
|
info = formatter.rich.info
|
|
202
269
|
|
|
203
|
-
if settings.domain:
|
|
270
|
+
if settings.domain and not force:
|
|
204
271
|
info(f"Domain: [cyan]{settings.domain}[/cyan] (already configured)")
|
|
205
272
|
info("")
|
|
206
273
|
return settings.domain
|
|
@@ -247,10 +314,10 @@ def _automated_domain_setup(formatter: OutputFormatter, settings: AppSettings) -
|
|
|
247
314
|
info(f"GitHub CLI detected. Logged in as [cyan]{username}[/cyan].")
|
|
248
315
|
info(f"Suggested domain: [cyan]{suggested_domain}[/cyan]")
|
|
249
316
|
info("")
|
|
250
|
-
info("[dim]Note: GitHub Pages provides always-on key hosting
|
|
251
|
-
info("
|
|
252
|
-
info("
|
|
253
|
-
info("
|
|
317
|
+
info("[dim]Note: GitHub Pages provides always-on key hosting. For Fleet[/dim]")
|
|
318
|
+
info("[dim]Telemetry streaming, Tailscale will also be used alongside[/dim]")
|
|
319
|
+
info("[dim]GitHub Pages — just add your Tailscale hostname as an extra[/dim]")
|
|
320
|
+
info("[dim]Allowed Origin URL in the developer portal.[/dim]")
|
|
254
321
|
info("")
|
|
255
322
|
|
|
256
323
|
try:
|
|
@@ -341,7 +408,9 @@ def _manual_domain_setup(formatter: OutputFormatter) -> str:
|
|
|
341
408
|
# ---------------------------------------------------------------------------
|
|
342
409
|
|
|
343
410
|
|
|
344
|
-
def _key_setup(
|
|
411
|
+
def _key_setup(
|
|
412
|
+
formatter: OutputFormatter, settings: AppSettings, domain: str, *, force: bool = False
|
|
413
|
+
) -> None:
|
|
345
414
|
"""Generate keys and deploy via the configured hosting method (full tier only)."""
|
|
346
415
|
info = formatter.rich.info
|
|
347
416
|
|
|
@@ -356,12 +425,12 @@ def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -
|
|
|
356
425
|
has_key_pair,
|
|
357
426
|
)
|
|
358
427
|
|
|
359
|
-
# Generate keys if needed
|
|
360
|
-
if has_key_pair(key_dir):
|
|
428
|
+
# Generate keys if needed (force → overwrite existing keys)
|
|
429
|
+
if has_key_pair(key_dir) and not force:
|
|
361
430
|
info(f"Key pair: [cyan]exists[/cyan] (fingerprint: {get_key_fingerprint(key_dir)})")
|
|
362
431
|
else:
|
|
363
432
|
info("Generating EC P-256 key pair...")
|
|
364
|
-
generate_ec_key_pair(key_dir)
|
|
433
|
+
generate_ec_key_pair(key_dir, overwrite=force)
|
|
365
434
|
info("[green]Key pair generated.[/green]")
|
|
366
435
|
info(f" Fingerprint: {get_key_fingerprint(key_dir)}")
|
|
367
436
|
|
|
@@ -371,9 +440,9 @@ def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -
|
|
|
371
440
|
hosting = settings.hosting_method
|
|
372
441
|
|
|
373
442
|
if hosting == "tailscale":
|
|
374
|
-
_deploy_key_tailscale(formatter, settings, key_dir, domain)
|
|
443
|
+
_deploy_key_tailscale(formatter, settings, key_dir, domain, force=force)
|
|
375
444
|
else:
|
|
376
|
-
_deploy_key_github(formatter, settings, key_dir, domain)
|
|
445
|
+
_deploy_key_github(formatter, settings, key_dir, domain, force=force)
|
|
377
446
|
|
|
378
447
|
|
|
379
448
|
def _deploy_key_tailscale(
|
|
@@ -381,6 +450,8 @@ def _deploy_key_tailscale(
|
|
|
381
450
|
settings: AppSettings,
|
|
382
451
|
key_dir: Path,
|
|
383
452
|
domain: str,
|
|
453
|
+
*,
|
|
454
|
+
force: bool = False,
|
|
384
455
|
) -> None:
|
|
385
456
|
"""Deploy key via Tailscale Funnel."""
|
|
386
457
|
info = formatter.rich.info
|
|
@@ -395,7 +466,7 @@ def _deploy_key_tailscale(
|
|
|
395
466
|
)
|
|
396
467
|
|
|
397
468
|
# Check if key is already deployed
|
|
398
|
-
if run_async(validate_tailscale_key_url(domain)):
|
|
469
|
+
if run_async(validate_tailscale_key_url(domain)) and not force:
|
|
399
470
|
info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
|
|
400
471
|
info("")
|
|
401
472
|
return
|
|
@@ -425,6 +496,8 @@ def _deploy_key_github(
|
|
|
425
496
|
settings: AppSettings,
|
|
426
497
|
key_dir: Path,
|
|
427
498
|
domain: str,
|
|
499
|
+
*,
|
|
500
|
+
force: bool = False,
|
|
428
501
|
) -> None:
|
|
429
502
|
"""Deploy key via GitHub Pages."""
|
|
430
503
|
info = formatter.rich.info
|
|
@@ -432,10 +505,10 @@ def _deploy_key_github(
|
|
|
432
505
|
from tescmd.crypto.keys import load_public_key_pem
|
|
433
506
|
from tescmd.deploy.github_pages import (
|
|
434
507
|
deploy_public_key,
|
|
508
|
+
fetch_key_pem,
|
|
435
509
|
get_key_url,
|
|
436
510
|
is_gh_authenticated,
|
|
437
511
|
is_gh_available,
|
|
438
|
-
validate_key_url,
|
|
439
512
|
wait_for_pages_deployment,
|
|
440
513
|
)
|
|
441
514
|
|
|
@@ -452,14 +525,33 @@ def _deploy_key_github(
|
|
|
452
525
|
info("")
|
|
453
526
|
return
|
|
454
527
|
|
|
455
|
-
#
|
|
456
|
-
|
|
457
|
-
|
|
528
|
+
# Compare remote key to local key
|
|
529
|
+
pem = load_public_key_pem(key_dir)
|
|
530
|
+
remote_pem = fetch_key_pem(domain)
|
|
531
|
+
|
|
532
|
+
if remote_pem is not None and remote_pem == pem.strip() and not force:
|
|
533
|
+
info(f"Public key: [green]matches GitHub[/green] at {get_key_url(domain)}")
|
|
458
534
|
info("")
|
|
459
535
|
return
|
|
460
536
|
|
|
461
|
-
|
|
462
|
-
|
|
537
|
+
if remote_pem is not None:
|
|
538
|
+
# Remote key exists but differs — confirm before overwriting
|
|
539
|
+
info("[yellow]The public key on GitHub differs from your local key.[/yellow]")
|
|
540
|
+
info(f" Remote: {get_key_url(domain)}")
|
|
541
|
+
info(" This can happen after regenerating your key pair.")
|
|
542
|
+
info("")
|
|
543
|
+
try:
|
|
544
|
+
answer = input("Update GitHub with the local key? [y/N] ").strip()
|
|
545
|
+
except (EOFError, KeyboardInterrupt):
|
|
546
|
+
answer = "n"
|
|
547
|
+
if answer.lower() != "y":
|
|
548
|
+
info("[dim]Skipped — GitHub key left unchanged.[/dim]")
|
|
549
|
+
info("")
|
|
550
|
+
return
|
|
551
|
+
info("Updating public key on GitHub Pages...")
|
|
552
|
+
else:
|
|
553
|
+
info("Deploying public key to GitHub Pages...")
|
|
554
|
+
|
|
463
555
|
deploy_public_key(pem, github_repo)
|
|
464
556
|
|
|
465
557
|
info("[green]Key committed and pushed.[/green]")
|
|
@@ -541,34 +633,24 @@ async def _enrollment_step(
|
|
|
541
633
|
info(f" Public key: [green]accessible[/green] at {key_url}")
|
|
542
634
|
info("")
|
|
543
635
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
info("")
|
|
548
|
-
return
|
|
549
|
-
|
|
550
|
-
if answer.lower() not in ("n", "no"):
|
|
551
|
-
info("")
|
|
552
|
-
info(f" Opening [link={enroll_url}]{enroll_url}[/link]…")
|
|
553
|
-
webbrowser.open(enroll_url)
|
|
554
|
-
info("")
|
|
636
|
+
info(f" Opening [link={enroll_url}]{enroll_url}[/link]…")
|
|
637
|
+
webbrowser.open(enroll_url)
|
|
638
|
+
info("")
|
|
555
639
|
|
|
556
640
|
info(" " + "━" * 49)
|
|
557
641
|
info(" [bold yellow]ACTION REQUIRED: Add virtual key in the Tesla app[/bold yellow]")
|
|
558
642
|
info("")
|
|
559
|
-
info(
|
|
560
|
-
info("")
|
|
561
|
-
info(" 1. Open the URL above [bold]on your phone[/bold]")
|
|
643
|
+
info(" 1. Scan the QR code with your phone (iOS / Android)")
|
|
562
644
|
info(" 2. Tap [bold]Finish Setup[/bold] on the web page")
|
|
563
645
|
info(" 3. The Tesla app shows an [bold]Add Virtual Key[/bold] prompt")
|
|
564
646
|
info(" 4. Approve it")
|
|
565
647
|
info("")
|
|
566
|
-
info(
|
|
567
|
-
|
|
648
|
+
info(
|
|
649
|
+
" [dim]If the prompt doesn't appear, force-quit the Tesla app,"
|
|
650
|
+
" scan the QR code again, and tap Finish Setup.[/dim]"
|
|
651
|
+
)
|
|
568
652
|
info(" " + "━" * 49)
|
|
569
653
|
info("")
|
|
570
|
-
info(" After approving, try: [cyan]tescmd charge status --wake[/cyan]")
|
|
571
|
-
info("")
|
|
572
654
|
|
|
573
655
|
|
|
574
656
|
# ---------------------------------------------------------------------------
|
|
@@ -617,7 +699,10 @@ async def _registration_step(
|
|
|
617
699
|
domain=domain,
|
|
618
700
|
region=region,
|
|
619
701
|
)
|
|
620
|
-
|
|
702
|
+
if _result.get("already_registered"):
|
|
703
|
+
info("[green]Already registered — no action needed.[/green]")
|
|
704
|
+
else:
|
|
705
|
+
info("[green]Registration successful.[/green]")
|
|
621
706
|
except Exception as exc:
|
|
622
707
|
status_code = getattr(exc, "status_code", None)
|
|
623
708
|
exc_text = str(exc)
|
|
@@ -886,6 +971,8 @@ async def _oauth_login_step(
|
|
|
886
971
|
settings: AppSettings,
|
|
887
972
|
client_id: str,
|
|
888
973
|
client_secret: str,
|
|
974
|
+
*,
|
|
975
|
+
force: bool = False,
|
|
889
976
|
) -> None:
|
|
890
977
|
"""Run the OAuth2 login flow."""
|
|
891
978
|
info = formatter.rich.info
|
|
@@ -899,7 +986,7 @@ async def _oauth_login_step(
|
|
|
899
986
|
config_dir=settings.config_dir,
|
|
900
987
|
)
|
|
901
988
|
|
|
902
|
-
if store.has_token:
|
|
989
|
+
if store.has_token and not force:
|
|
903
990
|
# Check whether the stored scopes cover what we need.
|
|
904
991
|
# A readonly→full upgrade requires vehicle_cmds + vehicle_charging_cmds
|
|
905
992
|
# that the original readonly token may not have.
|
|
@@ -924,7 +1011,9 @@ async def _oauth_login_step(
|
|
|
924
1011
|
info("[bold]Phase 5: OAuth Login[/bold]")
|
|
925
1012
|
info("")
|
|
926
1013
|
|
|
927
|
-
|
|
1014
|
+
from tescmd.models.auth import DEFAULT_PORT as _DEFAULT_PORT
|
|
1015
|
+
|
|
1016
|
+
port = _DEFAULT_PORT
|
|
928
1017
|
redirect_uri = f"http://localhost:{port}/callback"
|
|
929
1018
|
|
|
930
1019
|
info("Opening your browser to sign in to Tesla...")
|
tescmd/cli/sharing.py
CHANGED
|
@@ -15,6 +15,7 @@ from tescmd.cli._client import (
|
|
|
15
15
|
require_vin,
|
|
16
16
|
)
|
|
17
17
|
from tescmd.cli._options import global_options
|
|
18
|
+
from tescmd.models.sharing import ShareInvite
|
|
18
19
|
|
|
19
20
|
if TYPE_CHECKING:
|
|
20
21
|
from tescmd.cli.main import AppContext
|
|
@@ -172,6 +173,7 @@ async def _cmd_list_invites(app_ctx: AppContext, vin_positional: str | None) ->
|
|
|
172
173
|
endpoint="sharing.list-invites",
|
|
173
174
|
fetch=lambda: api.list_invites(vin),
|
|
174
175
|
ttl=TTL_SLOW,
|
|
176
|
+
model_class=ShareInvite,
|
|
175
177
|
)
|
|
176
178
|
finally:
|
|
177
179
|
await client.close()
|
tescmd/cli/status.py
CHANGED
|
@@ -36,7 +36,7 @@ def status_cmd(app_ctx: AppContext) -> None:
|
|
|
36
36
|
meta = store.metadata or {}
|
|
37
37
|
expires_at = meta.get("expires_at", 0.0)
|
|
38
38
|
expires_in = max(0, int(expires_at - time.time())) if has_token else 0
|
|
39
|
-
has_refresh = store.refresh_token
|
|
39
|
+
has_refresh = bool(store.refresh_token)
|
|
40
40
|
|
|
41
41
|
# Key info
|
|
42
42
|
key_dir = Path(settings.config_dir).expanduser() / "keys"
|
tescmd/cli/trunk.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import TYPE_CHECKING
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
6
|
|
|
7
7
|
import click
|
|
8
8
|
|
|
@@ -203,22 +203,13 @@ async def _cmd_window(
|
|
|
203
203
|
try:
|
|
204
204
|
|
|
205
205
|
async def _execute_window() -> CommandResponse:
|
|
206
|
-
if vent
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
use_lat, use_lon = lat, lon
|
|
214
|
-
else:
|
|
215
|
-
vdata = await vehicle_api.get_vehicle_data(vin, endpoints=["drive_state"])
|
|
216
|
-
ds = vdata.drive_state
|
|
217
|
-
if ds and ds.latitude is not None and ds.longitude is not None:
|
|
218
|
-
use_lat, use_lon = ds.latitude, ds.longitude
|
|
219
|
-
else:
|
|
220
|
-
use_lat, use_lon = 0.0, 0.0
|
|
221
|
-
return await cmd_api.window_control(vin, command=cmd_str, lat=use_lat, lon=use_lon)
|
|
206
|
+
cmd_str = "vent" if vent else "close"
|
|
207
|
+
kwargs: dict[str, Any] = {"command": cmd_str}
|
|
208
|
+
if lat is not None:
|
|
209
|
+
kwargs["lat"] = lat
|
|
210
|
+
if lon is not None:
|
|
211
|
+
kwargs["lon"] = lon
|
|
212
|
+
return await cmd_api.window_control(vin, **kwargs)
|
|
222
213
|
|
|
223
214
|
result = await auto_wake(
|
|
224
215
|
formatter,
|
tescmd/cli/user.py
CHANGED
|
@@ -9,6 +9,7 @@ import click
|
|
|
9
9
|
from tescmd._internal.async_utils import run_async
|
|
10
10
|
from tescmd.cli._client import TTL_DEFAULT, TTL_STATIC, cached_api_call, get_user_api
|
|
11
11
|
from tescmd.cli._options import global_options
|
|
12
|
+
from tescmd.models.user import FeatureConfig, UserInfo, UserRegion, VehicleOrder
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
14
15
|
from tescmd.cli.main import AppContext
|
|
@@ -34,6 +35,7 @@ async def _cmd_me(app_ctx: AppContext) -> None:
|
|
|
34
35
|
endpoint="user.me",
|
|
35
36
|
fetch=lambda: api.me(),
|
|
36
37
|
ttl=TTL_STATIC,
|
|
38
|
+
model_class=UserInfo,
|
|
37
39
|
)
|
|
38
40
|
finally:
|
|
39
41
|
await client.close()
|
|
@@ -62,6 +64,7 @@ async def _cmd_region(app_ctx: AppContext) -> None:
|
|
|
62
64
|
endpoint="user.region",
|
|
63
65
|
fetch=lambda: api.region(),
|
|
64
66
|
ttl=TTL_STATIC,
|
|
67
|
+
model_class=UserRegion,
|
|
65
68
|
)
|
|
66
69
|
finally:
|
|
67
70
|
await client.close()
|
|
@@ -90,6 +93,7 @@ async def _cmd_orders(app_ctx: AppContext) -> None:
|
|
|
90
93
|
endpoint="user.orders",
|
|
91
94
|
fetch=lambda: api.orders(),
|
|
92
95
|
ttl=TTL_DEFAULT,
|
|
96
|
+
model_class=VehicleOrder,
|
|
93
97
|
)
|
|
94
98
|
finally:
|
|
95
99
|
await client.close()
|
|
@@ -130,6 +134,7 @@ async def _cmd_features(app_ctx: AppContext) -> None:
|
|
|
130
134
|
endpoint="user.features",
|
|
131
135
|
fetch=lambda: api.feature_config(),
|
|
132
136
|
ttl=TTL_STATIC,
|
|
137
|
+
model_class=FeatureConfig,
|
|
133
138
|
)
|
|
134
139
|
finally:
|
|
135
140
|
await client.close()
|
|
@@ -139,7 +144,17 @@ async def _cmd_features(app_ctx: AppContext) -> None:
|
|
|
139
144
|
else:
|
|
140
145
|
dumped = data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data
|
|
141
146
|
if dumped:
|
|
147
|
+
from rich.table import Table
|
|
148
|
+
|
|
149
|
+
table = Table(title="Feature Flags")
|
|
150
|
+
table.add_column("Feature", style="bold")
|
|
151
|
+
table.add_column("Value")
|
|
142
152
|
for key, val in sorted(dumped.items()):
|
|
143
|
-
|
|
153
|
+
if isinstance(val, dict):
|
|
154
|
+
parts = [f"{k}={v}" for k, v in val.items()]
|
|
155
|
+
table.add_row(key, ", ".join(parts))
|
|
156
|
+
else:
|
|
157
|
+
table.add_row(key, str(val))
|
|
158
|
+
formatter.rich._con.print(table)
|
|
144
159
|
else:
|
|
145
160
|
formatter.rich.info("[dim]No feature flags available.[/dim]")
|