tescmd 0.1.2__py3-none-any.whl → 0.3.1__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 (90) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +49 -5
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +13 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/api/vehicle.py +19 -1
  7. tescmd/auth/oauth.py +5 -1
  8. tescmd/auth/server.py +6 -1
  9. tescmd/auth/token_store.py +8 -1
  10. tescmd/cache/response_cache.py +11 -3
  11. tescmd/cli/_client.py +142 -20
  12. tescmd/cli/_options.py +2 -4
  13. tescmd/cli/auth.py +121 -11
  14. tescmd/cli/energy.py +2 -0
  15. tescmd/cli/key.py +149 -14
  16. tescmd/cli/main.py +70 -7
  17. tescmd/cli/mcp_cmd.py +153 -0
  18. tescmd/cli/nav.py +3 -1
  19. tescmd/cli/openclaw.py +169 -0
  20. tescmd/cli/security.py +7 -1
  21. tescmd/cli/serve.py +923 -0
  22. tescmd/cli/setup.py +244 -25
  23. tescmd/cli/sharing.py +2 -0
  24. tescmd/cli/status.py +1 -1
  25. tescmd/cli/trunk.py +8 -17
  26. tescmd/cli/user.py +16 -1
  27. tescmd/cli/vehicle.py +156 -20
  28. tescmd/crypto/__init__.py +3 -1
  29. tescmd/crypto/ecdh.py +9 -0
  30. tescmd/crypto/schnorr.py +191 -0
  31. tescmd/deploy/github_pages.py +8 -0
  32. tescmd/deploy/tailscale_serve.py +154 -0
  33. tescmd/mcp/__init__.py +7 -0
  34. tescmd/mcp/server.py +648 -0
  35. tescmd/models/__init__.py +0 -2
  36. tescmd/models/auth.py +24 -2
  37. tescmd/models/config.py +1 -0
  38. tescmd/models/energy.py +0 -9
  39. tescmd/openclaw/__init__.py +23 -0
  40. tescmd/openclaw/bridge.py +330 -0
  41. tescmd/openclaw/config.py +167 -0
  42. tescmd/openclaw/dispatcher.py +522 -0
  43. tescmd/openclaw/emitter.py +175 -0
  44. tescmd/openclaw/filters.py +123 -0
  45. tescmd/openclaw/gateway.py +687 -0
  46. tescmd/openclaw/telemetry_store.py +53 -0
  47. tescmd/output/rich_output.py +46 -14
  48. tescmd/protocol/commands.py +2 -2
  49. tescmd/protocol/encoder.py +16 -13
  50. tescmd/protocol/payloads.py +132 -11
  51. tescmd/protocol/session.py +18 -8
  52. tescmd/protocol/signer.py +3 -17
  53. tescmd/telemetry/__init__.py +28 -0
  54. tescmd/telemetry/cache_sink.py +154 -0
  55. tescmd/telemetry/csv_sink.py +180 -0
  56. tescmd/telemetry/dashboard.py +227 -0
  57. tescmd/telemetry/decoder.py +284 -0
  58. tescmd/telemetry/fanout.py +49 -0
  59. tescmd/telemetry/fields.py +427 -0
  60. tescmd/telemetry/flatbuf.py +162 -0
  61. tescmd/telemetry/mapper.py +239 -0
  62. tescmd/telemetry/protos/__init__.py +4 -0
  63. tescmd/telemetry/protos/vehicle_alert.proto +31 -0
  64. tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
  65. tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
  66. tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
  67. tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
  68. tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
  69. tescmd/telemetry/protos/vehicle_data.proto +768 -0
  70. tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
  71. tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
  72. tescmd/telemetry/protos/vehicle_error.proto +23 -0
  73. tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
  74. tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
  75. tescmd/telemetry/protos/vehicle_metric.proto +22 -0
  76. tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
  77. tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
  78. tescmd/telemetry/server.py +300 -0
  79. tescmd/telemetry/setup.py +468 -0
  80. tescmd/telemetry/tailscale.py +300 -0
  81. tescmd/telemetry/tui.py +1716 -0
  82. tescmd/triggers/__init__.py +18 -0
  83. tescmd/triggers/manager.py +264 -0
  84. tescmd/triggers/models.py +93 -0
  85. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
  86. tescmd-0.3.1.dist-info/RECORD +128 -0
  87. tescmd-0.1.2.dist-info/RECORD +0 -81
  88. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
  89. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
  90. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
tescmd/cli/setup.py CHANGED
@@ -182,13 +182,22 @@ def _developer_portal_setup(
182
182
  info("[bold]Phase 2: Tesla Developer Portal Setup[/bold]")
183
183
  info("")
184
184
 
185
+ # Detect Tailscale hostname if domain was set to Tailscale in Phase 1
186
+ ts_hostname = ""
187
+ if settings.hosting_method == "tailscale" and settings.domain:
188
+ ts_hostname = settings.domain
189
+
185
190
  # Delegate to the existing interactive setup wizard, passing the domain
186
191
  # so the portal instructions show the correct Allowed Origin URL
187
- port = 8085
192
+ from tescmd.models.auth import DEFAULT_PORT
193
+
194
+ port = DEFAULT_PORT
188
195
  redirect_uri = f"http://localhost:{port}/callback"
189
196
  from tescmd.cli.auth import _interactive_setup
190
197
 
191
- return _interactive_setup(formatter, port, redirect_uri, domain=domain)
198
+ return _interactive_setup(
199
+ formatter, port, redirect_uri, domain=domain, tailscale_hostname=ts_hostname
200
+ )
192
201
 
193
202
 
194
203
  # ---------------------------------------------------------------------------
@@ -197,7 +206,7 @@ def _developer_portal_setup(
197
206
 
198
207
 
199
208
  def _domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
200
- """Set up a domain via GitHub Pages or manual entry."""
209
+ """Set up a domain via GitHub Pages, Tailscale Funnel, or manual entry."""
201
210
  info = formatter.rich.info
202
211
 
203
212
  if settings.domain:
@@ -207,22 +216,30 @@ def _domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
207
216
 
208
217
  info("[bold]Phase 1: Domain Setup[/bold]")
209
218
  info("")
210
- info(
211
- "Tesla requires a registered domain for Fleet API access."
212
- " The easiest approach is a free GitHub Pages site."
213
- )
219
+ info("Tesla requires a registered domain for Fleet API access.")
214
220
  info("")
215
221
 
216
- # Try automated GitHub Pages setup
222
+ # Priority 1: GitHub Pages (always-on hosting)
217
223
  from tescmd.deploy.github_pages import is_gh_authenticated, is_gh_available
218
224
 
219
225
  if is_gh_available() and is_gh_authenticated():
220
226
  return _automated_domain_setup(formatter, settings)
221
227
 
222
- # Fall back to manual domain entry
228
+ # Priority 2: Tailscale Funnel (requires local machine running)
229
+ if run_async(_is_tailscale_ready()):
230
+ return _tailscale_domain_setup(formatter, settings)
231
+
232
+ # Priority 3: Manual
223
233
  return _manual_domain_setup(formatter)
224
234
 
225
235
 
236
+ async def _is_tailscale_ready() -> bool:
237
+ """Wrapper to check Tailscale readiness without raising."""
238
+ from tescmd.deploy.tailscale_serve import is_tailscale_serve_ready
239
+
240
+ return await is_tailscale_serve_ready()
241
+
242
+
226
243
  def _automated_domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
227
244
  """Offer to auto-create a GitHub Pages site."""
228
245
  info = formatter.rich.info
@@ -239,6 +256,11 @@ def _automated_domain_setup(formatter: OutputFormatter, settings: AppSettings) -
239
256
  info(f"GitHub CLI detected. Logged in as [cyan]{username}[/cyan].")
240
257
  info(f"Suggested domain: [cyan]{suggested_domain}[/cyan]")
241
258
  info("")
259
+ info("[dim]Note: GitHub Pages provides always-on key hosting but cannot[/dim]")
260
+ info("[dim]serve as a Fleet Telemetry server. If you plan to use telemetry[/dim]")
261
+ info("[dim]streaming, choose Tailscale instead (install Tailscale, then[/dim]")
262
+ info("[dim]re-run setup).[/dim]")
263
+ info("")
242
264
 
243
265
  try:
244
266
  answer = input(f"Create/use {suggested_domain} as your domain? [Y/n] ").strip()
@@ -266,6 +288,56 @@ def _automated_domain_setup(formatter: OutputFormatter, settings: AppSettings) -
266
288
  return domain
267
289
 
268
290
 
291
+ def _tailscale_domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
292
+ """Offer to use Tailscale Funnel for key hosting."""
293
+ info = formatter.rich.info
294
+
295
+ from tescmd.telemetry.tailscale import TailscaleManager
296
+
297
+ hostname: str = run_async(TailscaleManager().get_hostname())
298
+
299
+ info("Tailscale detected. Your key would be hosted at:")
300
+ info(f" [cyan]https://{hostname}/{_WELL_KNOWN_PATH}[/cyan]")
301
+ info("")
302
+ info("[yellow]Important:[/yellow] Tailscale Funnel requires your machine to be running.")
303
+ info("If your machine is off or Tailscale stops, Tesla cannot reach your")
304
+ info("public key. This is fine for development and testing.")
305
+ info("For always-on hosting, use GitHub Pages instead (install gh CLI).")
306
+ info("")
307
+ info("[green]Telemetry streaming:[/green] If you plan to use Fleet Telemetry")
308
+ info("streaming (tescmd vehicle telemetry stream), you should use your")
309
+ info("Tailscale hostname as your domain. Tesla requires the telemetry")
310
+ info("server hostname to match your registered domain.")
311
+ info("")
312
+
313
+ try:
314
+ answer = input(f"Use {hostname} as your domain? [Y/n] ").strip()
315
+ except (EOFError, KeyboardInterrupt):
316
+ info("")
317
+ return ""
318
+
319
+ if answer.lower() == "n":
320
+ return _manual_domain_setup(formatter)
321
+
322
+ domain = hostname
323
+
324
+ # Persist domain and hosting method to .env
325
+ from tescmd.cli.auth import _write_env_value
326
+
327
+ _write_env_value("TESLA_DOMAIN", domain)
328
+ _write_env_value("TESLA_HOSTING_METHOD", "tailscale")
329
+
330
+ info(f"[green]Domain configured: {domain}[/green]")
331
+ info("[green]Hosting method: Tailscale Funnel[/green]")
332
+ info("")
333
+
334
+ return domain
335
+
336
+
337
+ # Well-known path constant (shared with deploy modules)
338
+ _WELL_KNOWN_PATH = ".well-known/appspecific/com.tesla.3p.public-key.pem"
339
+
340
+
269
341
  def _manual_domain_setup(formatter: OutputFormatter) -> str:
270
342
  """Prompt for a domain manually."""
271
343
  from tescmd.cli.auth import _prompt_for_domain
@@ -279,7 +351,7 @@ def _manual_domain_setup(formatter: OutputFormatter) -> str:
279
351
 
280
352
 
281
353
  def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -> None:
282
- """Generate keys and deploy to GitHub Pages (full tier only)."""
354
+ """Generate keys and deploy via the configured hosting method (full tier only)."""
283
355
  info = formatter.rich.info
284
356
 
285
357
  info("[bold]Phase 3: EC Key Generation & Deployment[/bold]")
@@ -291,7 +363,6 @@ def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -
291
363
  generate_ec_key_pair,
292
364
  get_key_fingerprint,
293
365
  has_key_pair,
294
- load_public_key_pem,
295
366
  )
296
367
 
297
368
  # Generate keys if needed
@@ -305,7 +376,69 @@ def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -
305
376
 
306
377
  info("")
307
378
 
308
- # Deploy key via GitHub Pages if gh is available
379
+ # Branch on hosting method
380
+ hosting = settings.hosting_method
381
+
382
+ if hosting == "tailscale":
383
+ _deploy_key_tailscale(formatter, settings, key_dir, domain)
384
+ else:
385
+ _deploy_key_github(formatter, settings, key_dir, domain)
386
+
387
+
388
+ def _deploy_key_tailscale(
389
+ formatter: OutputFormatter,
390
+ settings: AppSettings,
391
+ key_dir: Path,
392
+ domain: str,
393
+ ) -> None:
394
+ """Deploy key via Tailscale Funnel."""
395
+ info = formatter.rich.info
396
+
397
+ from tescmd.crypto.keys import load_public_key_pem
398
+ from tescmd.deploy.tailscale_serve import (
399
+ deploy_public_key_tailscale,
400
+ get_key_url,
401
+ start_key_serving,
402
+ validate_tailscale_key_url,
403
+ wait_for_tailscale_deployment,
404
+ )
405
+
406
+ # Check if key is already deployed
407
+ if run_async(validate_tailscale_key_url(domain)):
408
+ info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
409
+ info("")
410
+ return
411
+
412
+ info("Deploying public key via Tailscale Funnel...")
413
+ pem = load_public_key_pem(key_dir)
414
+ run_async(deploy_public_key_tailscale(pem))
415
+ run_async(start_key_serving())
416
+
417
+ info("[green]Tailscale serve + Funnel started.[/green]")
418
+ info("Waiting for key to become accessible...")
419
+
420
+ deployed = run_async(wait_for_tailscale_deployment(domain))
421
+ if deployed:
422
+ info(f"[green]Key is live at:[/green] {get_key_url(domain)}")
423
+ else:
424
+ info(
425
+ "[yellow]Key deployed but not yet accessible."
426
+ " Tailscale Funnel may still be propagating.[/yellow]"
427
+ )
428
+ info(" Run [cyan]tescmd key validate[/cyan] to check later.")
429
+ info("")
430
+
431
+
432
+ def _deploy_key_github(
433
+ formatter: OutputFormatter,
434
+ settings: AppSettings,
435
+ key_dir: Path,
436
+ domain: str,
437
+ ) -> None:
438
+ """Deploy key via GitHub Pages."""
439
+ info = formatter.rich.info
440
+
441
+ from tescmd.crypto.keys import load_public_key_pem
309
442
  from tescmd.deploy.github_pages import (
310
443
  deploy_public_key,
311
444
  get_key_url,
@@ -367,7 +500,6 @@ async def _enrollment_step(
367
500
  import webbrowser
368
501
 
369
502
  from tescmd.crypto.keys import get_key_fingerprint, has_key_pair
370
- from tescmd.deploy.github_pages import get_key_url, validate_key_url
371
503
 
372
504
  info = formatter.rich.info
373
505
  key_dir = Path(settings.config_dir).expanduser() / "keys"
@@ -388,9 +520,21 @@ async def _enrollment_step(
388
520
  info(" must also be enrolled via the Tesla app.")
389
521
  info("")
390
522
 
391
- # Verify the public key is accessible
392
- key_url = get_key_url(domain)
393
- key_accessible = validate_key_url(domain)
523
+ # Verify the public key is accessible (method-aware)
524
+ if settings.hosting_method == "tailscale":
525
+ from tescmd.deploy.tailscale_serve import get_key_url
526
+ from tescmd.deploy.tailscale_serve import (
527
+ validate_tailscale_key_url as _validate,
528
+ )
529
+
530
+ key_url = get_key_url(domain)
531
+ key_accessible = await _validate(domain)
532
+ else:
533
+ from tescmd.deploy.github_pages import get_key_url as gh_get_key_url
534
+ from tescmd.deploy.github_pages import validate_key_url
535
+
536
+ key_url = gh_get_key_url(domain)
537
+ key_accessible = validate_key_url(domain)
394
538
  if not key_accessible:
395
539
  info(f" [yellow]Public key not accessible at {key_url}[/yellow]")
396
540
  info(" Enrollment requires the key to be live. Skipping for now.")
@@ -509,16 +653,30 @@ def _precheck_public_key(
509
653
  """
510
654
  info = formatter.rich.info
511
655
 
512
- from tescmd.deploy.github_pages import get_key_url, validate_key_url
513
-
514
656
  info("Checking public key availability...")
515
657
 
516
- if validate_key_url(domain):
517
- info(f" Public key: [green]accessible[/green] at {get_key_url(domain)}")
658
+ # Check accessibility via the appropriate method
659
+ if settings.hosting_method == "tailscale":
660
+ from tescmd.deploy.tailscale_serve import (
661
+ get_key_url,
662
+ validate_tailscale_key_url,
663
+ )
664
+
665
+ key_url = get_key_url(domain)
666
+ accessible = run_async(validate_tailscale_key_url(domain))
667
+ else:
668
+ from tescmd.deploy.github_pages import get_key_url as gh_get_key_url
669
+ from tescmd.deploy.github_pages import validate_key_url
670
+
671
+ key_url = gh_get_key_url(domain)
672
+ accessible = validate_key_url(domain)
673
+
674
+ if accessible:
675
+ info(f" Public key: [green]accessible[/green] at {key_url}")
518
676
  info("")
519
677
  return True
520
678
 
521
- info(f" Public key: [yellow]not found[/yellow] at {get_key_url(domain)}")
679
+ info(f" Public key: [yellow]not found[/yellow] at {key_url}")
522
680
  info("")
523
681
  info(" Tesla requires your public key to be accessible before registration will succeed.")
524
682
  info("")
@@ -543,7 +701,7 @@ def _auto_deploy_key(
543
701
  settings: AppSettings,
544
702
  domain: str,
545
703
  ) -> bool:
546
- """Generate a key pair (if needed), deploy to GitHub Pages, and wait.
704
+ """Generate a key pair (if needed), deploy via configured method, and wait.
547
705
 
548
706
  Returns True when the key is confirmed accessible, False otherwise.
549
707
  """
@@ -554,7 +712,6 @@ def _auto_deploy_key(
554
712
  generate_ec_key_pair,
555
713
  get_key_fingerprint,
556
714
  has_key_pair,
557
- load_public_key_pem,
558
715
  )
559
716
 
560
717
  # 1. Generate keys if needed
@@ -566,7 +723,63 @@ def _auto_deploy_key(
566
723
  info(f"[green]Key pair generated.[/green] Fingerprint: {get_key_fingerprint(key_dir)}")
567
724
  info("")
568
725
 
569
- # 2. Deploy to GitHub Pages
726
+ # 2. Deploy via the appropriate method
727
+ if settings.hosting_method == "tailscale":
728
+ return _auto_deploy_key_tailscale(info, key_dir, domain)
729
+ return _auto_deploy_key_github(info, settings, key_dir, domain)
730
+
731
+
732
+ def _auto_deploy_key_tailscale(
733
+ info: _InfoFn,
734
+ key_dir: Path,
735
+ domain: str,
736
+ ) -> bool:
737
+ """Deploy key via Tailscale Funnel and wait."""
738
+ from tescmd.crypto.keys import load_public_key_pem
739
+ from tescmd.deploy.tailscale_serve import (
740
+ deploy_public_key_tailscale,
741
+ get_key_url,
742
+ start_key_serving,
743
+ validate_tailscale_key_url,
744
+ wait_for_tailscale_deployment,
745
+ )
746
+
747
+ # Already deployed?
748
+ if run_async(validate_tailscale_key_url(domain)):
749
+ info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
750
+ info("")
751
+ return True
752
+
753
+ info("Deploying public key via Tailscale Funnel...")
754
+ pem = load_public_key_pem(key_dir)
755
+ run_async(deploy_public_key_tailscale(pem))
756
+ run_async(start_key_serving())
757
+
758
+ info("[green]Tailscale serve + Funnel started.[/green]")
759
+ info("Waiting for key to become accessible...")
760
+
761
+ deployed = run_async(wait_for_tailscale_deployment(domain))
762
+ if deployed:
763
+ info(f"[green]Key is live at:[/green] {get_key_url(domain)}")
764
+ info("")
765
+ return True
766
+
767
+ info("[yellow]Key deployed but not yet accessible.[/yellow]")
768
+ info(
769
+ " Run [cyan]tescmd key validate[/cyan] to check, then [cyan]tescmd auth register[/cyan]."
770
+ )
771
+ info("")
772
+ return False
773
+
774
+
775
+ def _auto_deploy_key_github(
776
+ info: _InfoFn,
777
+ settings: AppSettings,
778
+ key_dir: Path,
779
+ domain: str,
780
+ ) -> bool:
781
+ """Deploy key via GitHub Pages and wait."""
782
+ from tescmd.crypto.keys import load_public_key_pem
570
783
  from tescmd.deploy.github_pages import (
571
784
  deploy_public_key,
572
785
  get_key_url,
@@ -632,6 +845,10 @@ def _remediate_412(info: _InfoFn, domain: str) -> None:
632
845
  info(" 2. Open your application")
633
846
  info(" 3. Set [cyan]Allowed Origin URL[/cyan] to:")
634
847
  info(f" [bold]https://{domain}[/bold]")
848
+ info(
849
+ " [dim]For telemetry streaming, also add your Tailscale origin"
850
+ " (e.g. https://<machine>.tailnet.ts.net)[/dim]"
851
+ )
635
852
  info(" 4. Save, then re-run [cyan]tescmd setup[/cyan]")
636
853
 
637
854
 
@@ -716,7 +933,9 @@ async def _oauth_login_step(
716
933
  info("[bold]Phase 5: OAuth Login[/bold]")
717
934
  info("")
718
935
 
719
- port = 8085
936
+ from tescmd.models.auth import DEFAULT_PORT as _DEFAULT_PORT
937
+
938
+ port = _DEFAULT_PORT
720
939
  redirect_uri = f"http://localhost:{port}/callback"
721
940
 
722
941
  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]")