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/auth.py
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import sys
|
|
7
8
|
import time
|
|
9
|
+
import uuid
|
|
8
10
|
import webbrowser
|
|
9
11
|
from pathlib import Path
|
|
10
12
|
from typing import TYPE_CHECKING
|
|
@@ -31,9 +33,11 @@ from tescmd.models.config import AppSettings
|
|
|
31
33
|
|
|
32
34
|
if TYPE_CHECKING:
|
|
33
35
|
from tescmd.cli.main import AppContext
|
|
36
|
+
from tescmd.deploy.tailscale_serve import KeyServer
|
|
34
37
|
from tescmd.output.formatter import OutputFormatter
|
|
38
|
+
from tescmd.telemetry.tailscale import TailscaleManager
|
|
35
39
|
|
|
36
|
-
DEVELOPER_PORTAL_URL = "https://developer.tesla.com"
|
|
40
|
+
DEVELOPER_PORTAL_URL = "https://developer.tesla.com/dashboard"
|
|
37
41
|
|
|
38
42
|
|
|
39
43
|
# ---------------------------------------------------------------------------
|
|
@@ -49,7 +53,12 @@ auth_group = click.Group("auth", help="Authentication commands")
|
|
|
49
53
|
|
|
50
54
|
|
|
51
55
|
@auth_group.command("login")
|
|
52
|
-
@click.option(
|
|
56
|
+
@click.option(
|
|
57
|
+
"--port",
|
|
58
|
+
type=int,
|
|
59
|
+
default=None,
|
|
60
|
+
help="Local callback port (default: 8085 or TESCMD_OAUTH_PORT)",
|
|
61
|
+
)
|
|
53
62
|
@click.option(
|
|
54
63
|
"--reconsent",
|
|
55
64
|
is_flag=True,
|
|
@@ -57,15 +66,20 @@ auth_group = click.Group("auth", help="Authentication commands")
|
|
|
57
66
|
help="Force Tesla to re-display the scope consent screen.",
|
|
58
67
|
)
|
|
59
68
|
@global_options
|
|
60
|
-
def login_cmd(app_ctx: AppContext, port: int, reconsent: bool) -> None:
|
|
69
|
+
def login_cmd(app_ctx: AppContext, port: int | None, reconsent: bool) -> None:
|
|
61
70
|
"""Log in via OAuth2 PKCE flow."""
|
|
62
71
|
run_async(_cmd_login(app_ctx, port, reconsent=reconsent))
|
|
63
72
|
|
|
64
73
|
|
|
65
|
-
async def _cmd_login(app_ctx: AppContext, port: int, *, reconsent: bool = False) -> None:
|
|
74
|
+
async def _cmd_login(app_ctx: AppContext, port: int | None, *, reconsent: bool = False) -> None:
|
|
75
|
+
from tescmd.models.auth import DEFAULT_PORT
|
|
76
|
+
|
|
66
77
|
formatter = app_ctx.formatter
|
|
67
78
|
settings = AppSettings()
|
|
68
79
|
|
|
80
|
+
if port is None:
|
|
81
|
+
port = DEFAULT_PORT
|
|
82
|
+
|
|
69
83
|
client_id = settings.client_id
|
|
70
84
|
client_secret = settings.client_secret
|
|
71
85
|
|
|
@@ -267,11 +281,24 @@ async def _cmd_refresh(app_ctx: AppContext) -> None:
|
|
|
267
281
|
scopes: list[str] = meta.get("scopes", DEFAULT_SCOPES)
|
|
268
282
|
region: str = meta.get("region", "na")
|
|
269
283
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
284
|
+
from tescmd.api.errors import AuthError
|
|
285
|
+
from tescmd.cli._client import reauth_on_expired_refresh
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
token_data = await refresh_access_token(
|
|
289
|
+
refresh_token=rt,
|
|
290
|
+
client_id=settings.client_id,
|
|
291
|
+
client_secret=settings.client_secret,
|
|
292
|
+
)
|
|
293
|
+
except AuthError as exc:
|
|
294
|
+
if "login_required" not in str(exc):
|
|
295
|
+
raise
|
|
296
|
+
await reauth_on_expired_refresh(store, settings)
|
|
297
|
+
if formatter.format == "json":
|
|
298
|
+
formatter.output({"status": "re-authenticated"}, command="auth.refresh")
|
|
299
|
+
else:
|
|
300
|
+
formatter.rich.info("")
|
|
301
|
+
return
|
|
275
302
|
|
|
276
303
|
store.save(
|
|
277
304
|
access_token=token_data.access_token,
|
|
@@ -351,9 +378,10 @@ async def _cmd_register(app_ctx: AppContext) -> None:
|
|
|
351
378
|
region=region,
|
|
352
379
|
)
|
|
353
380
|
|
|
381
|
+
already = bool(_result.get("already_registered"))
|
|
354
382
|
if formatter.format == "json":
|
|
355
383
|
data: dict[str, object] = {
|
|
356
|
-
"status": "registered",
|
|
384
|
+
"status": "already_registered" if already else "registered",
|
|
357
385
|
"region": region,
|
|
358
386
|
"domain": domain,
|
|
359
387
|
}
|
|
@@ -361,7 +389,10 @@ async def _cmd_register(app_ctx: AppContext) -> None:
|
|
|
361
389
|
data["partner_scopes"] = partner_scopes
|
|
362
390
|
formatter.output(data, command="auth.register")
|
|
363
391
|
else:
|
|
364
|
-
|
|
392
|
+
if already:
|
|
393
|
+
formatter.rich.info("[green]Already registered — no action needed.[/green]")
|
|
394
|
+
else:
|
|
395
|
+
formatter.rich.info("[green]Registration successful.[/green]")
|
|
365
396
|
if partner_scopes:
|
|
366
397
|
formatter.rich.info(f"Partner scopes: {', '.join(partner_scopes)}")
|
|
367
398
|
missing = sorted(set(PARTNER_SCOPES) - set(partner_scopes))
|
|
@@ -425,7 +456,10 @@ async def _auto_register(
|
|
|
425
456
|
domain=domain,
|
|
426
457
|
region=region,
|
|
427
458
|
)
|
|
428
|
-
|
|
459
|
+
if _result.get("already_registered"):
|
|
460
|
+
formatter.rich.info("[green]Already registered — no action needed.[/green]")
|
|
461
|
+
else:
|
|
462
|
+
formatter.rich.info("[green]Registration successful.[/green]")
|
|
429
463
|
if partner_scopes:
|
|
430
464
|
missing = sorted(set(PARTNER_SCOPES) - set(partner_scopes))
|
|
431
465
|
if missing:
|
|
@@ -445,12 +479,21 @@ def _interactive_setup(
|
|
|
445
479
|
redirect_uri: str,
|
|
446
480
|
*,
|
|
447
481
|
domain: str = "",
|
|
482
|
+
tailscale_hostname: str = "",
|
|
483
|
+
full_tier: bool = False,
|
|
484
|
+
force: bool = False,
|
|
448
485
|
) -> tuple[str, str]:
|
|
449
486
|
"""Walk the user through first-time Tesla API credential setup.
|
|
450
487
|
|
|
451
488
|
When *domain* is provided (e.g. from the setup wizard), the developer
|
|
452
489
|
portal instructions show ``https://{domain}`` as the Allowed Origin URL.
|
|
453
490
|
Tesla's Fleet API requires the origin to match the registration domain.
|
|
491
|
+
|
|
492
|
+
When *full_tier* is True and *tailscale_hostname* is provided (or
|
|
493
|
+
auto-detected), the user is offered the chance to start a Tailscale
|
|
494
|
+
Funnel so Tesla can verify the origin URL when the portal app config
|
|
495
|
+
is saved. The Tailscale prompt appears **before** the browser is
|
|
496
|
+
opened so the developer portal steps (1-6) are uninterrupted.
|
|
454
497
|
"""
|
|
455
498
|
info = formatter.rich.info
|
|
456
499
|
origin_url = f"https://{domain}" if domain else f"http://localhost:{port}"
|
|
@@ -464,116 +507,222 @@ def _interactive_setup(
|
|
|
464
507
|
)
|
|
465
508
|
info("")
|
|
466
509
|
|
|
467
|
-
#
|
|
510
|
+
# --- Tailscale detection + Funnel prompt (before browser opens) ----
|
|
511
|
+
# Only shown during the full setup wizard — the standalone ``auth
|
|
512
|
+
# setup`` command is credentials-only and defaults full_tier=False.
|
|
513
|
+
ts_hostname = tailscale_hostname if full_tier else ""
|
|
514
|
+
ts_funnel_started = False
|
|
515
|
+
_ts_manager: TailscaleManager | None = None
|
|
516
|
+
_key_server: KeyServer | None = None
|
|
517
|
+
|
|
518
|
+
if full_tier:
|
|
519
|
+
if not ts_hostname:
|
|
520
|
+
try:
|
|
521
|
+
from tescmd.deploy.tailscale_serve import is_tailscale_serve_ready
|
|
522
|
+
|
|
523
|
+
if run_async(is_tailscale_serve_ready()):
|
|
524
|
+
from tescmd.telemetry.tailscale import TailscaleManager
|
|
525
|
+
|
|
526
|
+
ts_hostname = run_async(TailscaleManager().get_hostname())
|
|
527
|
+
except Exception:
|
|
528
|
+
pass
|
|
529
|
+
|
|
530
|
+
if ts_hostname:
|
|
531
|
+
ts_origin = f"https://{ts_hostname}"
|
|
532
|
+
|
|
533
|
+
info(f"[green]Tailscale detected:[/green] {ts_hostname}")
|
|
534
|
+
info("")
|
|
535
|
+
info(f" Adding [cyan]{ts_origin}[/cyan] as an Allowed Origin URL")
|
|
536
|
+
info(" will let [cyan]tescmd serve[/cyan] stream telemetry without")
|
|
537
|
+
info(" extra portal changes later.")
|
|
538
|
+
info("")
|
|
539
|
+
try:
|
|
540
|
+
answer = input(
|
|
541
|
+
"Start Tailscale Funnel so Tesla can verify the URL? [Y/n] "
|
|
542
|
+
).strip()
|
|
543
|
+
except (EOFError, KeyboardInterrupt):
|
|
544
|
+
answer = "n"
|
|
545
|
+
|
|
546
|
+
if answer.lower() != "n":
|
|
547
|
+
info("Starting Tailscale Funnel...")
|
|
548
|
+
try:
|
|
549
|
+
from tescmd.crypto.keys import (
|
|
550
|
+
generate_ec_key_pair,
|
|
551
|
+
has_key_pair,
|
|
552
|
+
load_public_key_pem,
|
|
553
|
+
)
|
|
554
|
+
from tescmd.deploy.tailscale_serve import KeyServer
|
|
555
|
+
from tescmd.telemetry.tailscale import TailscaleManager
|
|
556
|
+
|
|
557
|
+
key_dir = Path(AppSettings().config_dir).expanduser() / "keys"
|
|
558
|
+
|
|
559
|
+
if not has_key_pair(key_dir):
|
|
560
|
+
info("Generating EC P-256 key pair...")
|
|
561
|
+
generate_ec_key_pair(key_dir)
|
|
562
|
+
|
|
563
|
+
pem = load_public_key_pem(key_dir)
|
|
564
|
+
_key_server = KeyServer(pem, port=0)
|
|
565
|
+
_local_port = _key_server.server_address[1]
|
|
566
|
+
_key_server.start()
|
|
567
|
+
|
|
568
|
+
_ts_manager = TailscaleManager()
|
|
569
|
+
run_async(_ts_manager.start_funnel(_local_port))
|
|
570
|
+
|
|
571
|
+
ts_funnel_started = True
|
|
572
|
+
info(f"[green]Funnel active — {ts_origin} is reachable.[/green]")
|
|
573
|
+
except Exception as exc:
|
|
574
|
+
if _key_server is not None:
|
|
575
|
+
_key_server.stop()
|
|
576
|
+
_key_server = None
|
|
577
|
+
_ts_manager = None
|
|
578
|
+
info(f"[yellow]Could not start Funnel: {exc}[/yellow]")
|
|
579
|
+
info("[dim]You can add the origin URL manually later.[/dim]")
|
|
580
|
+
ts_hostname = ""
|
|
581
|
+
|
|
582
|
+
# --- Open browser + credential prompts (with guaranteed cleanup) ---
|
|
468
583
|
try:
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
if answer.strip().lower() != "n":
|
|
475
|
-
webbrowser.open(DEVELOPER_PORTAL_URL)
|
|
476
|
-
info("[dim]Browser opened.[/dim]")
|
|
477
|
-
|
|
478
|
-
info("")
|
|
479
|
-
info(
|
|
480
|
-
"Follow these steps to create a Fleet API application."
|
|
481
|
-
" If you already have one, skip to the credentials prompt below."
|
|
482
|
-
)
|
|
483
|
-
info("")
|
|
484
|
-
|
|
485
|
-
# Step 1 — Registration
|
|
486
|
-
info("[bold]Step 1 — Registration[/bold]")
|
|
487
|
-
info(" Select [cyan]Just for me[/cyan] and click Next.")
|
|
488
|
-
info("")
|
|
489
|
-
|
|
490
|
-
# Step 2 — Application Details
|
|
491
|
-
info("[bold]Step 2 — Application Details[/bold]")
|
|
492
|
-
info(" Application Name: [cyan]tescmd[/cyan] (or anything you like)")
|
|
493
|
-
info(" Description: [cyan]Personal CLI tool for vehicle status and control[/cyan]")
|
|
494
|
-
info(
|
|
495
|
-
" Purpose of Usage: [cyan]Query vehicle data and send commands from the terminal[/cyan]"
|
|
496
|
-
)
|
|
497
|
-
info(" Click Next.")
|
|
498
|
-
info("")
|
|
584
|
+
try:
|
|
585
|
+
answer = input("Open the Tesla Developer Portal in your browser? [Y/n] ")
|
|
586
|
+
except (EOFError, KeyboardInterrupt):
|
|
587
|
+
info("")
|
|
588
|
+
return ("", "")
|
|
499
589
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
" OAuth Grant Type: [cyan]Authorization Code and"
|
|
504
|
-
" Machine-to-Machine[/cyan] (the default)"
|
|
505
|
-
)
|
|
506
|
-
info(f" Allowed Origin URL: [cyan]{origin_url}[/cyan]")
|
|
507
|
-
info(f" Allowed Redirect URI: [cyan]{redirect_uri}[/cyan]")
|
|
508
|
-
info(" Allowed Returned URL: (leave empty)")
|
|
509
|
-
info("")
|
|
510
|
-
info(
|
|
511
|
-
" [dim]For telemetry streaming, add your Tailscale hostname"
|
|
512
|
-
" as an additional origin:[/dim]"
|
|
513
|
-
)
|
|
514
|
-
info(" [dim] https://<machine>.tailnet.ts.net[/dim]")
|
|
515
|
-
info(" Click Next.")
|
|
516
|
-
info("")
|
|
590
|
+
if answer.strip().lower() != "n":
|
|
591
|
+
webbrowser.open(DEVELOPER_PORTAL_URL)
|
|
592
|
+
info("[dim]Browser opened.[/dim]")
|
|
517
593
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
info(" [cyan]Vehicle Charging Management[/cyan]")
|
|
525
|
-
info(" Click Next.")
|
|
526
|
-
info("")
|
|
594
|
+
info("")
|
|
595
|
+
info(
|
|
596
|
+
"Follow these steps to create a Fleet API application."
|
|
597
|
+
" If you already have one, skip to the credentials prompt below."
|
|
598
|
+
)
|
|
599
|
+
info("")
|
|
527
600
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
601
|
+
# Step 1 — Create New Application
|
|
602
|
+
info("[bold]Step 1 — Create New Application[/bold]")
|
|
603
|
+
info(" Select [cyan]Just for me[/cyan] and click Next.")
|
|
604
|
+
info("")
|
|
532
605
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
"
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
606
|
+
# Step 2 — Application Details
|
|
607
|
+
#
|
|
608
|
+
# Generate a unique app name so the user doesn't collide with anyone
|
|
609
|
+
# else on the Tesla Developer Portal. Reuse a previously saved name
|
|
610
|
+
# if setup is being re-run.
|
|
611
|
+
saved_app_name = "" if force else os.environ.get("TESLA_APP_NAME", "")
|
|
612
|
+
app_name = saved_app_name or f"tescmd-{uuid.uuid4().hex[:8]}"
|
|
613
|
+
|
|
614
|
+
info("[bold]Step 2 — Application Details[/bold]")
|
|
615
|
+
info(f" Application Name: [cyan]{app_name}[/cyan]")
|
|
616
|
+
info(" Description: [cyan]Personal CLI tool for vehicle status and control[/cyan]")
|
|
617
|
+
info(
|
|
618
|
+
" Purpose of Usage: [cyan]Query vehicle data and send commands"
|
|
619
|
+
" from the terminal[/cyan]"
|
|
620
|
+
)
|
|
621
|
+
info(" Click Next.")
|
|
622
|
+
info("")
|
|
544
623
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
624
|
+
# Step 3 — Client Details
|
|
625
|
+
info("[bold]Step 3 — Client Details[/bold]")
|
|
626
|
+
info(
|
|
627
|
+
" OAuth Grant Type: [cyan]Authorization Code and"
|
|
628
|
+
" Machine-to-Machine[/cyan] (the default)"
|
|
629
|
+
)
|
|
630
|
+
info(f" Allowed Origin URL: [cyan]{origin_url}[/cyan]")
|
|
631
|
+
if ts_hostname:
|
|
632
|
+
info(f" [bold]Also add:[/bold] [cyan]https://{ts_hostname}[/cyan]")
|
|
633
|
+
info(f" Allowed Redirect URI: [cyan]{redirect_uri}[/cyan]")
|
|
634
|
+
info(" Allowed Returned URL: (leave empty)")
|
|
635
|
+
info("")
|
|
636
|
+
if not ts_hostname:
|
|
637
|
+
info(
|
|
638
|
+
" [dim]For telemetry streaming, add your Tailscale hostname"
|
|
639
|
+
" as an additional origin:[/dim]"
|
|
640
|
+
)
|
|
641
|
+
info(" [dim] https://<machine>.tailnet.ts.net[/dim]")
|
|
642
|
+
info(" Click Next.")
|
|
549
643
|
info("")
|
|
550
|
-
return ("", "")
|
|
551
644
|
|
|
552
|
-
|
|
553
|
-
info("[
|
|
554
|
-
|
|
645
|
+
# Step 4 — API & Scopes
|
|
646
|
+
info("[bold]Step 4 — API & Scopes[/bold]")
|
|
647
|
+
if full_tier:
|
|
648
|
+
info(" Under [bold]Fleet API[/bold], click [cyan]Select All[/cyan].")
|
|
649
|
+
else:
|
|
650
|
+
info(" Under [bold]Fleet API[/bold], check at least:")
|
|
651
|
+
info(" [cyan]Vehicle Information[/cyan]")
|
|
652
|
+
info(" [cyan]Vehicle Location[/cyan]")
|
|
653
|
+
info(" [cyan]Energy Information[/cyan]")
|
|
654
|
+
info(" [cyan]User Data[/cyan]")
|
|
655
|
+
info(" Click Next.")
|
|
656
|
+
info("")
|
|
555
657
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
except (EOFError, KeyboardInterrupt):
|
|
658
|
+
# Step 5 — Billing Details
|
|
659
|
+
info("[bold]Step 5 — Billing Details (Optional)[/bold]")
|
|
660
|
+
info(" Click [cyan]Skip and Submit[/cyan] at the bottom of the page.")
|
|
560
661
|
info("")
|
|
561
|
-
return ("", "")
|
|
562
662
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
663
|
+
# Post-creation
|
|
664
|
+
info("[bold]Step 6 — Copy your credentials[/bold]")
|
|
665
|
+
info(
|
|
666
|
+
" Open your dashboard:"
|
|
667
|
+
" [link=https://developer.tesla.com/en_US/dashboard]"
|
|
668
|
+
"developer.tesla.com/dashboard[/link]"
|
|
669
|
+
)
|
|
670
|
+
info(" Click [cyan]View Details[/cyan] on your app.")
|
|
671
|
+
info(" Under the [cyan]Credentials & APIs[/cyan] tab you'll see your")
|
|
672
|
+
info(" Client ID (copy icon) and Client Secret (eye icon to reveal).")
|
|
568
673
|
info("")
|
|
569
|
-
return (client_id, client_secret)
|
|
570
674
|
|
|
571
|
-
|
|
675
|
+
# Prompt for Client ID (required — retry on empty)
|
|
676
|
+
client_id = ""
|
|
677
|
+
for _ in range(3):
|
|
678
|
+
try:
|
|
679
|
+
client_id = input("Client ID: ").strip()
|
|
680
|
+
except (EOFError, KeyboardInterrupt):
|
|
681
|
+
info("")
|
|
682
|
+
return ("", "")
|
|
683
|
+
if client_id:
|
|
684
|
+
break
|
|
685
|
+
info("[yellow]Client ID is required.[/yellow]")
|
|
686
|
+
|
|
687
|
+
if not client_id:
|
|
688
|
+
info("[yellow]No Client ID provided. Setup cancelled.[/yellow]")
|
|
689
|
+
return ("", "")
|
|
690
|
+
|
|
691
|
+
# Prompt for Client Secret (required — retry on empty)
|
|
692
|
+
client_secret = ""
|
|
693
|
+
for _ in range(3):
|
|
694
|
+
try:
|
|
695
|
+
client_secret = input("Client Secret: ").strip()
|
|
696
|
+
except (EOFError, KeyboardInterrupt):
|
|
697
|
+
info("")
|
|
698
|
+
return ("", "")
|
|
699
|
+
if client_secret:
|
|
700
|
+
break
|
|
701
|
+
info("[yellow]Client Secret is required.[/yellow]")
|
|
702
|
+
|
|
703
|
+
if not client_secret:
|
|
704
|
+
info("[yellow]No Client Secret provided. Setup cancelled.[/yellow]")
|
|
705
|
+
return ("", "")
|
|
706
|
+
|
|
707
|
+
# Persist credentials to .env
|
|
708
|
+
info("")
|
|
572
709
|
_write_env_file(client_id, client_secret)
|
|
710
|
+
if not saved_app_name:
|
|
711
|
+
_write_env_value("TESLA_APP_NAME", app_name)
|
|
573
712
|
info("[green]Credentials saved to .env[/green]")
|
|
574
713
|
|
|
575
|
-
|
|
576
|
-
|
|
714
|
+
info("")
|
|
715
|
+
return (client_id, client_secret)
|
|
716
|
+
finally:
|
|
717
|
+
if ts_funnel_started:
|
|
718
|
+
if _key_server is not None:
|
|
719
|
+
_key_server.stop()
|
|
720
|
+
if _ts_manager is not None:
|
|
721
|
+
try:
|
|
722
|
+
run_async(_ts_manager.stop_funnel())
|
|
723
|
+
except Exception as exc:
|
|
724
|
+
info(f"[yellow]Warning: Failed to stop Tailscale Funnel: {exc}[/yellow]")
|
|
725
|
+
info("[dim]You may need to run 'tailscale funnel --bg off' manually.[/dim]")
|
|
577
726
|
|
|
578
727
|
|
|
579
728
|
def _prompt_for_domain(formatter: OutputFormatter) -> str:
|
tescmd/cli/energy.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_SLOW, cached_api_call, get_energy_api, invalidate_cache_for_site
|
|
11
11
|
from tescmd.cli._options import global_options
|
|
12
|
+
from tescmd.models.energy import SiteInfo
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
14
15
|
from tescmd.cli.main import AppContext
|
|
@@ -66,6 +67,7 @@ async def _cmd_status(app_ctx: AppContext, site_id: int) -> None:
|
|
|
66
67
|
endpoint="energy.status",
|
|
67
68
|
fetch=lambda: api.site_info(site_id),
|
|
68
69
|
ttl=TTL_SLOW,
|
|
70
|
+
model_class=SiteInfo,
|
|
69
71
|
)
|
|
70
72
|
finally:
|
|
71
73
|
await client.close()
|
tescmd/cli/key.py
CHANGED
|
@@ -585,13 +585,12 @@ async def _cmd_enroll(
|
|
|
585
585
|
formatter.rich.info("")
|
|
586
586
|
formatter.rich.info(f" Enrollment URL: [link={enroll_url}]{enroll_url}[/link]")
|
|
587
587
|
formatter.rich.info("")
|
|
588
|
-
formatter.rich.info(" 1.
|
|
589
|
-
formatter.rich.info(" 2.
|
|
590
|
-
formatter.rich.info(" 3.
|
|
591
|
-
formatter.rich.info(" 4. Approve it")
|
|
588
|
+
formatter.rich.info(" 1. Scan the QR code on the page above with your phone[/bold]")
|
|
589
|
+
formatter.rich.info(" 2. The Tesla app will show an [bold]Add Virtual Key[/bold] prompt")
|
|
590
|
+
formatter.rich.info(" 3. Select all Scopes (if shown) and approve it")
|
|
592
591
|
formatter.rich.info("")
|
|
593
592
|
formatter.rich.info(" [dim]If the prompt doesn't appear, force-quit the Tesla app,[/dim]")
|
|
594
|
-
formatter.rich.info(" [dim]
|
|
593
|
+
formatter.rich.info(" [dim]and scan the QR code again.[/dim]")
|
|
595
594
|
formatter.rich.info("━" * 55)
|
|
596
595
|
formatter.rich.info("")
|
|
597
596
|
|
|
@@ -601,11 +600,11 @@ async def _cmd_enroll(
|
|
|
601
600
|
formatter.rich.info("")
|
|
602
601
|
|
|
603
602
|
formatter.rich.info("After approving in the Tesla app, try a command:")
|
|
604
|
-
formatter.rich.info(" [cyan]tescmd
|
|
603
|
+
formatter.rich.info(" [cyan]tescmd vehicle list[/cyan]")
|
|
605
604
|
formatter.rich.info(" [cyan]tescmd charge status --wake[/cyan]")
|
|
606
605
|
formatter.rich.info("")
|
|
607
606
|
formatter.rich.info(
|
|
608
|
-
"[dim]Tip:
|
|
607
|
+
"[dim]Tip: The QR code must be scanned on your phone that has the Tesla app installed.[/dim]"
|
|
609
608
|
)
|
|
610
609
|
|
|
611
610
|
|
tescmd/cli/main.py
CHANGED
|
@@ -6,6 +6,7 @@ import dataclasses
|
|
|
6
6
|
import logging
|
|
7
7
|
|
|
8
8
|
import click
|
|
9
|
+
from dotenv import load_dotenv
|
|
9
10
|
|
|
10
11
|
from tescmd._internal.async_utils import run_async
|
|
11
12
|
from tescmd.api.errors import (
|
|
@@ -62,6 +63,13 @@ class AppContext:
|
|
|
62
63
|
return self._formatter
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Load .env into os.environ before Click resolves any envvar= options.
|
|
68
|
+
# This ensures TESLA_VIN, TESCMD_MCP_CLIENT_ID, etc. from .env files
|
|
69
|
+
# are visible to Click's envvar resolution.
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
load_dotenv(override=False)
|
|
72
|
+
|
|
65
73
|
# ---------------------------------------------------------------------------
|
|
66
74
|
# Root Click group
|
|
67
75
|
# ---------------------------------------------------------------------------
|
|
@@ -160,11 +168,14 @@ def _register_commands() -> None:
|
|
|
160
168
|
from tescmd.cli.climate import climate_group
|
|
161
169
|
from tescmd.cli.energy import energy_group
|
|
162
170
|
from tescmd.cli.key import key_group
|
|
171
|
+
from tescmd.cli.mcp_cmd import mcp_group
|
|
163
172
|
from tescmd.cli.media import media_group
|
|
164
173
|
from tescmd.cli.nav import nav_group
|
|
174
|
+
from tescmd.cli.openclaw import openclaw_group
|
|
165
175
|
from tescmd.cli.partner import partner_group
|
|
166
176
|
from tescmd.cli.raw import raw_group
|
|
167
177
|
from tescmd.cli.security import security_group
|
|
178
|
+
from tescmd.cli.serve import serve_cmd
|
|
168
179
|
from tescmd.cli.setup import setup_cmd
|
|
169
180
|
from tescmd.cli.sharing import sharing_group
|
|
170
181
|
from tescmd.cli.software import software_group
|
|
@@ -180,11 +191,14 @@ def _register_commands() -> None:
|
|
|
180
191
|
cli.add_command(climate_group)
|
|
181
192
|
cli.add_command(energy_group)
|
|
182
193
|
cli.add_command(key_group)
|
|
194
|
+
cli.add_command(mcp_group)
|
|
183
195
|
cli.add_command(media_group)
|
|
184
196
|
cli.add_command(nav_group)
|
|
197
|
+
cli.add_command(openclaw_group)
|
|
185
198
|
cli.add_command(partner_group)
|
|
186
199
|
cli.add_command(raw_group)
|
|
187
200
|
cli.add_command(security_group)
|
|
201
|
+
cli.add_command(serve_cmd)
|
|
188
202
|
cli.add_command(setup_cmd)
|
|
189
203
|
cli.add_command(sharing_group)
|
|
190
204
|
cli.add_command(status_cmd)
|
|
@@ -563,14 +577,19 @@ def _handle_session_error(
|
|
|
563
577
|
formatter.rich.error(message)
|
|
564
578
|
formatter.rich.info("")
|
|
565
579
|
formatter.rich.info("Possible causes:")
|
|
566
|
-
formatter.rich.info(" - Vehicle is temporarily unreachable")
|
|
567
|
-
formatter.rich.info(" -
|
|
568
|
-
formatter.rich.info(
|
|
580
|
+
formatter.rich.info(" - Vehicle is temporarily unreachable or asleep")
|
|
581
|
+
formatter.rich.info(" - Session expired — retry once and a fresh handshake will occur")
|
|
582
|
+
formatter.rich.info(
|
|
583
|
+
" - Local key pair is corrupted or was re-generated without re-enrollment"
|
|
584
|
+
)
|
|
569
585
|
formatter.rich.info("")
|
|
570
|
-
formatter.rich.info("Try
|
|
571
|
-
formatter.rich.info("
|
|
572
|
-
formatter.rich.info(" [cyan]
|
|
573
|
-
formatter.rich.info("
|
|
586
|
+
formatter.rich.info("Try:")
|
|
587
|
+
formatter.rich.info(" 1. Re-run the command (sessions auto-refresh)")
|
|
588
|
+
formatter.rich.info(" 2. Use [cyan]--verbose[/cyan] to see fault codes and signing details")
|
|
589
|
+
formatter.rich.info(" 3. If repeated failures, re-enroll:")
|
|
590
|
+
formatter.rich.info(
|
|
591
|
+
" [cyan]tescmd key generate --force && tescmd key deploy && tescmd key enroll[/cyan]"
|
|
592
|
+
)
|
|
574
593
|
|
|
575
594
|
|
|
576
595
|
def _handle_keyring_error(
|
|
@@ -642,4 +661,4 @@ def _handle_tunnel_error(
|
|
|
642
661
|
" [cyan]https://login.tailscale.com/admin/acls[/cyan]"
|
|
643
662
|
)
|
|
644
663
|
formatter.rich.info("")
|
|
645
|
-
formatter.rich.info("
|
|
664
|
+
formatter.rich.info("Telemetry deps are included with tescmd.")
|