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/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(formatter, app_ctx, settings, domain=domain)
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
- port = 8085
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(formatter, port, redirect_uri, domain=domain)
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(formatter: OutputFormatter, settings: AppSettings) -> str:
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 but cannot")
251
- info("serve as a Fleet Telemetry server. If you plan to use telemetry")
252
- info("streaming, choose Tailscale instead (install Tailscale, then")
253
- info("re-run setup).[/dim]")
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(formatter: OutputFormatter, settings: AppSettings, domain: str) -> None:
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
- # Check if key is already deployed
456
- if validate_key_url(domain):
457
- info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
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
- info("Deploying public key to GitHub Pages...")
462
- pem = load_public_key_pem(key_dir)
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
- try:
545
- answer = input(" Open enrollment URL in your browser? [Y/n] ").strip()
546
- except (EOFError, KeyboardInterrupt):
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(f" Enrollment URL: {enroll_url}")
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(" [dim]If the prompt doesn't appear, force-quit the Tesla app,[/dim]")
567
- info(" [dim]go back to your browser, and tap Finish Setup again.[/dim]")
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
- info("[green]Registration successful.[/green]")
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
- port = 8085
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 is not None
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
- cmd_str = "vent"
208
- use_lat = lat if lat is not None else 0.0
209
- use_lon = lon if lon is not None else 0.0
210
- else:
211
- cmd_str = "close"
212
- if lat is not None and lon is not None:
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
- formatter.rich.info(f" {key}: {val}")
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]")