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.
Files changed (65) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +41 -4
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +5 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/auth/oauth.py +15 -1
  7. tescmd/auth/server.py +6 -1
  8. tescmd/auth/token_store.py +8 -1
  9. tescmd/cache/response_cache.py +8 -1
  10. tescmd/cli/_client.py +142 -20
  11. tescmd/cli/_options.py +2 -4
  12. tescmd/cli/auth.py +255 -106
  13. tescmd/cli/energy.py +2 -0
  14. tescmd/cli/key.py +6 -7
  15. tescmd/cli/main.py +27 -8
  16. tescmd/cli/mcp_cmd.py +153 -0
  17. tescmd/cli/nav.py +3 -1
  18. tescmd/cli/openclaw.py +169 -0
  19. tescmd/cli/security.py +7 -1
  20. tescmd/cli/serve.py +923 -0
  21. tescmd/cli/setup.py +147 -58
  22. tescmd/cli/sharing.py +2 -0
  23. tescmd/cli/status.py +1 -1
  24. tescmd/cli/trunk.py +8 -17
  25. tescmd/cli/user.py +16 -1
  26. tescmd/cli/vehicle.py +135 -462
  27. tescmd/deploy/github_pages.py +21 -2
  28. tescmd/deploy/tailscale_serve.py +96 -8
  29. tescmd/mcp/__init__.py +7 -0
  30. tescmd/mcp/server.py +648 -0
  31. tescmd/models/auth.py +5 -2
  32. tescmd/openclaw/__init__.py +23 -0
  33. tescmd/openclaw/bridge.py +330 -0
  34. tescmd/openclaw/config.py +167 -0
  35. tescmd/openclaw/dispatcher.py +529 -0
  36. tescmd/openclaw/emitter.py +175 -0
  37. tescmd/openclaw/filters.py +123 -0
  38. tescmd/openclaw/gateway.py +700 -0
  39. tescmd/openclaw/telemetry_store.py +53 -0
  40. tescmd/output/rich_output.py +46 -14
  41. tescmd/protocol/commands.py +2 -2
  42. tescmd/protocol/encoder.py +16 -13
  43. tescmd/protocol/payloads.py +132 -11
  44. tescmd/protocol/session.py +8 -5
  45. tescmd/protocol/signer.py +3 -17
  46. tescmd/telemetry/__init__.py +9 -0
  47. tescmd/telemetry/cache_sink.py +154 -0
  48. tescmd/telemetry/csv_sink.py +180 -0
  49. tescmd/telemetry/dashboard.py +4 -4
  50. tescmd/telemetry/fanout.py +49 -0
  51. tescmd/telemetry/fields.py +308 -129
  52. tescmd/telemetry/mapper.py +239 -0
  53. tescmd/telemetry/server.py +26 -19
  54. tescmd/telemetry/setup.py +468 -0
  55. tescmd/telemetry/tailscale.py +78 -16
  56. tescmd/telemetry/tui.py +1716 -0
  57. tescmd/triggers/__init__.py +18 -0
  58. tescmd/triggers/manager.py +264 -0
  59. tescmd/triggers/models.py +93 -0
  60. tescmd-0.4.0.dist-info/METADATA +300 -0
  61. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/RECORD +64 -42
  62. tescmd-0.2.0.dist-info/METADATA +0 -495
  63. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
  64. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
  65. {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("--port", type=int, default=8085, help="Local callback port")
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
- token_data = await refresh_access_token(
271
- refresh_token=rt,
272
- client_id=settings.client_id,
273
- client_secret=settings.client_secret,
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
- formatter.rich.info("[green]Registration successful.[/green]")
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
- formatter.rich.info("[green]Registration successful.[/green]")
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
- # Offer to open the developer portal
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
- answer = input("Open the Tesla Developer Portal in your browser? [Y/n] ")
470
- except (EOFError, KeyboardInterrupt):
471
- info("")
472
- return ("", "")
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
- # Step 3 — Client Details
501
- info("[bold]Step 3 — Client Details[/bold]")
502
- info(
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
- # Step 4 — API & Scopes
519
- info("[bold]Step 4 — API & Scopes[/bold]")
520
- info(" Under [bold]Fleet API[/bold], check at least:")
521
- info(" [cyan]Vehicle Information[/cyan]")
522
- info(" [cyan]Vehicle Location[/cyan]")
523
- info(" [cyan]Vehicle Commands[/cyan]")
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
- # Step 5Billing Details
529
- info("[bold]Step 5Billing Details[/bold]")
530
- info(" Click [cyan]Skip and Submit[/cyan] at the bottom of the page.")
531
- info("")
601
+ # Step 1Create New Application
602
+ info("[bold]Step 1Create New Application[/bold]")
603
+ info(" Select [cyan]Just for me[/cyan] and click Next.")
604
+ info("")
532
605
 
533
- # Post-creation
534
- info("[bold]Step 6 — Copy your credentials[/bold]")
535
- info(
536
- " Open your dashboard:"
537
- " [link=https://developer.tesla.com/en_US/dashboard]"
538
- "developer.tesla.com/dashboard[/link]"
539
- )
540
- info(" Click [cyan]View Details[/cyan] on your app.")
541
- info(" Under the [cyan]Credentials & APIs[/cyan] tab you'll see your")
542
- info(" Client ID (copy icon) and Client Secret (eye icon to reveal).")
543
- info("")
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
- # Prompt for Client ID
546
- try:
547
- client_id = input("Client ID: ").strip()
548
- except (EOFError, KeyboardInterrupt):
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
- if not client_id:
553
- info("[yellow]No Client ID provided. Setup cancelled.[/yellow]")
554
- return ("", "")
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
- # Prompt for Client Secret (optional for public clients)
557
- try:
558
- client_secret = input("Client Secret (optional, press Enter to skip): ").strip()
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
- # Offer to persist credentials to .env
564
- info("")
565
- try:
566
- save = input("Save credentials to .env file? [Y/n] ")
567
- except (EOFError, KeyboardInterrupt):
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
- if save.strip().lower() != "n":
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
- info("")
576
- return (client_id, client_secret)
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. Open the URL above [bold]on your phone[/bold]")
589
- formatter.rich.info(" 2. Tap [bold]Finish Setup[/bold] on the web page")
590
- formatter.rich.info(" 3. The Tesla app will show an [bold]Add Virtual Key[/bold] prompt")
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]go back to your browser, and tap Finish Setup again.[/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 security lock --wake[/cyan]")
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: This URL must be opened on your phone, not a desktop browser.[/dim]"
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(" - Local key pair is corrupted")
568
- formatter.rich.info(" - Key was re-generated but not re-enrolled")
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 again, or if the problem persists:")
571
- formatter.rich.info(" [cyan]tescmd key generate --force[/cyan] (regenerate key pair)")
572
- formatter.rich.info(" [cyan]tescmd key deploy[/cyan] (re-deploy public key)")
573
- formatter.rich.info(" [cyan]tescmd key enroll[/cyan] (re-enroll on vehicle)")
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("Install telemetry deps: [cyan]pip install tescmd[telemetry][/cyan]")
664
+ formatter.rich.info("Telemetry deps are included with tescmd.")