tescmd 0.1.2__py3-none-any.whl → 0.2.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 (47) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +8 -1
  3. tescmd/api/errors.py +8 -0
  4. tescmd/api/vehicle.py +19 -1
  5. tescmd/cache/response_cache.py +3 -2
  6. tescmd/cli/auth.py +30 -2
  7. tescmd/cli/key.py +149 -14
  8. tescmd/cli/main.py +44 -0
  9. tescmd/cli/setup.py +230 -22
  10. tescmd/cli/vehicle.py +464 -1
  11. tescmd/crypto/__init__.py +3 -1
  12. tescmd/crypto/ecdh.py +9 -0
  13. tescmd/crypto/schnorr.py +191 -0
  14. tescmd/deploy/tailscale_serve.py +154 -0
  15. tescmd/models/__init__.py +0 -2
  16. tescmd/models/auth.py +19 -0
  17. tescmd/models/config.py +1 -0
  18. tescmd/models/energy.py +0 -9
  19. tescmd/protocol/session.py +10 -3
  20. tescmd/telemetry/__init__.py +19 -0
  21. tescmd/telemetry/dashboard.py +227 -0
  22. tescmd/telemetry/decoder.py +284 -0
  23. tescmd/telemetry/fields.py +248 -0
  24. tescmd/telemetry/flatbuf.py +162 -0
  25. tescmd/telemetry/protos/__init__.py +4 -0
  26. tescmd/telemetry/protos/vehicle_alert.proto +31 -0
  27. tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
  28. tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
  29. tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
  30. tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
  31. tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
  32. tescmd/telemetry/protos/vehicle_data.proto +768 -0
  33. tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
  34. tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
  35. tescmd/telemetry/protos/vehicle_error.proto +23 -0
  36. tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
  37. tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
  38. tescmd/telemetry/protos/vehicle_metric.proto +22 -0
  39. tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
  40. tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
  41. tescmd/telemetry/server.py +293 -0
  42. tescmd/telemetry/tailscale.py +300 -0
  43. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/METADATA +72 -35
  44. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/RECORD +47 -22
  45. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/WHEEL +0 -0
  46. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/entry_points.txt +0 -0
  47. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/licenses/LICENSE +0 -0
tescmd/cli/setup.py CHANGED
@@ -197,7 +197,7 @@ def _developer_portal_setup(
197
197
 
198
198
 
199
199
  def _domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
200
- """Set up a domain via GitHub Pages or manual entry."""
200
+ """Set up a domain via GitHub Pages, Tailscale Funnel, or manual entry."""
201
201
  info = formatter.rich.info
202
202
 
203
203
  if settings.domain:
@@ -207,22 +207,30 @@ def _domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
207
207
 
208
208
  info("[bold]Phase 1: Domain Setup[/bold]")
209
209
  info("")
210
- info(
211
- "Tesla requires a registered domain for Fleet API access."
212
- " The easiest approach is a free GitHub Pages site."
213
- )
210
+ info("Tesla requires a registered domain for Fleet API access.")
214
211
  info("")
215
212
 
216
- # Try automated GitHub Pages setup
213
+ # Priority 1: GitHub Pages (always-on hosting)
217
214
  from tescmd.deploy.github_pages import is_gh_authenticated, is_gh_available
218
215
 
219
216
  if is_gh_available() and is_gh_authenticated():
220
217
  return _automated_domain_setup(formatter, settings)
221
218
 
222
- # Fall back to manual domain entry
219
+ # Priority 2: Tailscale Funnel (requires local machine running)
220
+ if run_async(_is_tailscale_ready()):
221
+ return _tailscale_domain_setup(formatter, settings)
222
+
223
+ # Priority 3: Manual
223
224
  return _manual_domain_setup(formatter)
224
225
 
225
226
 
227
+ async def _is_tailscale_ready() -> bool:
228
+ """Wrapper to check Tailscale readiness without raising."""
229
+ from tescmd.deploy.tailscale_serve import is_tailscale_serve_ready
230
+
231
+ return await is_tailscale_serve_ready()
232
+
233
+
226
234
  def _automated_domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
227
235
  """Offer to auto-create a GitHub Pages site."""
228
236
  info = formatter.rich.info
@@ -239,6 +247,11 @@ def _automated_domain_setup(formatter: OutputFormatter, settings: AppSettings) -
239
247
  info(f"GitHub CLI detected. Logged in as [cyan]{username}[/cyan].")
240
248
  info(f"Suggested domain: [cyan]{suggested_domain}[/cyan]")
241
249
  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]")
254
+ info("")
242
255
 
243
256
  try:
244
257
  answer = input(f"Create/use {suggested_domain} as your domain? [Y/n] ").strip()
@@ -266,6 +279,56 @@ def _automated_domain_setup(formatter: OutputFormatter, settings: AppSettings) -
266
279
  return domain
267
280
 
268
281
 
282
+ def _tailscale_domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
283
+ """Offer to use Tailscale Funnel for key hosting."""
284
+ info = formatter.rich.info
285
+
286
+ from tescmd.telemetry.tailscale import TailscaleManager
287
+
288
+ hostname: str = run_async(TailscaleManager().get_hostname())
289
+
290
+ info("Tailscale detected. Your key would be hosted at:")
291
+ info(f" [cyan]https://{hostname}/{_WELL_KNOWN_PATH}[/cyan]")
292
+ info("")
293
+ info("[yellow]Important:[/yellow] Tailscale Funnel requires your machine to be running.")
294
+ info("If your machine is off or Tailscale stops, Tesla cannot reach your")
295
+ info("public key. This is fine for development and testing.")
296
+ info("For always-on hosting, use GitHub Pages instead (install gh CLI).")
297
+ info("")
298
+ info("[green]Telemetry streaming:[/green] If you plan to use Fleet Telemetry")
299
+ info("streaming (tescmd vehicle telemetry stream), you should use your")
300
+ info("Tailscale hostname as your domain. Tesla requires the telemetry")
301
+ info("server hostname to match your registered domain.")
302
+ info("")
303
+
304
+ try:
305
+ answer = input(f"Use {hostname} as your domain? [Y/n] ").strip()
306
+ except (EOFError, KeyboardInterrupt):
307
+ info("")
308
+ return ""
309
+
310
+ if answer.lower() == "n":
311
+ return _manual_domain_setup(formatter)
312
+
313
+ domain = hostname
314
+
315
+ # Persist domain and hosting method to .env
316
+ from tescmd.cli.auth import _write_env_value
317
+
318
+ _write_env_value("TESLA_DOMAIN", domain)
319
+ _write_env_value("TESLA_HOSTING_METHOD", "tailscale")
320
+
321
+ info(f"[green]Domain configured: {domain}[/green]")
322
+ info("[green]Hosting method: Tailscale Funnel[/green]")
323
+ info("")
324
+
325
+ return domain
326
+
327
+
328
+ # Well-known path constant (shared with deploy modules)
329
+ _WELL_KNOWN_PATH = ".well-known/appspecific/com.tesla.3p.public-key.pem"
330
+
331
+
269
332
  def _manual_domain_setup(formatter: OutputFormatter) -> str:
270
333
  """Prompt for a domain manually."""
271
334
  from tescmd.cli.auth import _prompt_for_domain
@@ -279,7 +342,7 @@ def _manual_domain_setup(formatter: OutputFormatter) -> str:
279
342
 
280
343
 
281
344
  def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -> None:
282
- """Generate keys and deploy to GitHub Pages (full tier only)."""
345
+ """Generate keys and deploy via the configured hosting method (full tier only)."""
283
346
  info = formatter.rich.info
284
347
 
285
348
  info("[bold]Phase 3: EC Key Generation & Deployment[/bold]")
@@ -291,7 +354,6 @@ def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -
291
354
  generate_ec_key_pair,
292
355
  get_key_fingerprint,
293
356
  has_key_pair,
294
- load_public_key_pem,
295
357
  )
296
358
 
297
359
  # Generate keys if needed
@@ -305,7 +367,69 @@ def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -
305
367
 
306
368
  info("")
307
369
 
308
- # Deploy key via GitHub Pages if gh is available
370
+ # Branch on hosting method
371
+ hosting = settings.hosting_method
372
+
373
+ if hosting == "tailscale":
374
+ _deploy_key_tailscale(formatter, settings, key_dir, domain)
375
+ else:
376
+ _deploy_key_github(formatter, settings, key_dir, domain)
377
+
378
+
379
+ def _deploy_key_tailscale(
380
+ formatter: OutputFormatter,
381
+ settings: AppSettings,
382
+ key_dir: Path,
383
+ domain: str,
384
+ ) -> None:
385
+ """Deploy key via Tailscale Funnel."""
386
+ info = formatter.rich.info
387
+
388
+ from tescmd.crypto.keys import load_public_key_pem
389
+ from tescmd.deploy.tailscale_serve import (
390
+ deploy_public_key_tailscale,
391
+ get_key_url,
392
+ start_key_serving,
393
+ validate_tailscale_key_url,
394
+ wait_for_tailscale_deployment,
395
+ )
396
+
397
+ # Check if key is already deployed
398
+ if run_async(validate_tailscale_key_url(domain)):
399
+ info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
400
+ info("")
401
+ return
402
+
403
+ info("Deploying public key via Tailscale Funnel...")
404
+ pem = load_public_key_pem(key_dir)
405
+ run_async(deploy_public_key_tailscale(pem))
406
+ run_async(start_key_serving())
407
+
408
+ info("[green]Tailscale serve + Funnel started.[/green]")
409
+ info("Waiting for key to become accessible...")
410
+
411
+ deployed = run_async(wait_for_tailscale_deployment(domain))
412
+ if deployed:
413
+ info(f"[green]Key is live at:[/green] {get_key_url(domain)}")
414
+ else:
415
+ info(
416
+ "[yellow]Key deployed but not yet accessible."
417
+ " Tailscale Funnel may still be propagating.[/yellow]"
418
+ )
419
+ info(" Run [cyan]tescmd key validate[/cyan] to check later.")
420
+ info("")
421
+
422
+
423
+ def _deploy_key_github(
424
+ formatter: OutputFormatter,
425
+ settings: AppSettings,
426
+ key_dir: Path,
427
+ domain: str,
428
+ ) -> None:
429
+ """Deploy key via GitHub Pages."""
430
+ info = formatter.rich.info
431
+
432
+ from tescmd.crypto.keys import load_public_key_pem
309
433
  from tescmd.deploy.github_pages import (
310
434
  deploy_public_key,
311
435
  get_key_url,
@@ -367,7 +491,6 @@ async def _enrollment_step(
367
491
  import webbrowser
368
492
 
369
493
  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
494
 
372
495
  info = formatter.rich.info
373
496
  key_dir = Path(settings.config_dir).expanduser() / "keys"
@@ -388,9 +511,21 @@ async def _enrollment_step(
388
511
  info(" must also be enrolled via the Tesla app.")
389
512
  info("")
390
513
 
391
- # Verify the public key is accessible
392
- key_url = get_key_url(domain)
393
- key_accessible = validate_key_url(domain)
514
+ # Verify the public key is accessible (method-aware)
515
+ if settings.hosting_method == "tailscale":
516
+ from tescmd.deploy.tailscale_serve import get_key_url
517
+ from tescmd.deploy.tailscale_serve import (
518
+ validate_tailscale_key_url as _validate,
519
+ )
520
+
521
+ key_url = get_key_url(domain)
522
+ key_accessible = await _validate(domain)
523
+ else:
524
+ from tescmd.deploy.github_pages import get_key_url as gh_get_key_url
525
+ from tescmd.deploy.github_pages import validate_key_url
526
+
527
+ key_url = gh_get_key_url(domain)
528
+ key_accessible = validate_key_url(domain)
394
529
  if not key_accessible:
395
530
  info(f" [yellow]Public key not accessible at {key_url}[/yellow]")
396
531
  info(" Enrollment requires the key to be live. Skipping for now.")
@@ -509,16 +644,30 @@ def _precheck_public_key(
509
644
  """
510
645
  info = formatter.rich.info
511
646
 
512
- from tescmd.deploy.github_pages import get_key_url, validate_key_url
513
-
514
647
  info("Checking public key availability...")
515
648
 
516
- if validate_key_url(domain):
517
- info(f" Public key: [green]accessible[/green] at {get_key_url(domain)}")
649
+ # Check accessibility via the appropriate method
650
+ if settings.hosting_method == "tailscale":
651
+ from tescmd.deploy.tailscale_serve import (
652
+ get_key_url,
653
+ validate_tailscale_key_url,
654
+ )
655
+
656
+ key_url = get_key_url(domain)
657
+ accessible = run_async(validate_tailscale_key_url(domain))
658
+ else:
659
+ from tescmd.deploy.github_pages import get_key_url as gh_get_key_url
660
+ from tescmd.deploy.github_pages import validate_key_url
661
+
662
+ key_url = gh_get_key_url(domain)
663
+ accessible = validate_key_url(domain)
664
+
665
+ if accessible:
666
+ info(f" Public key: [green]accessible[/green] at {key_url}")
518
667
  info("")
519
668
  return True
520
669
 
521
- info(f" Public key: [yellow]not found[/yellow] at {get_key_url(domain)}")
670
+ info(f" Public key: [yellow]not found[/yellow] at {key_url}")
522
671
  info("")
523
672
  info(" Tesla requires your public key to be accessible before registration will succeed.")
524
673
  info("")
@@ -543,7 +692,7 @@ def _auto_deploy_key(
543
692
  settings: AppSettings,
544
693
  domain: str,
545
694
  ) -> bool:
546
- """Generate a key pair (if needed), deploy to GitHub Pages, and wait.
695
+ """Generate a key pair (if needed), deploy via configured method, and wait.
547
696
 
548
697
  Returns True when the key is confirmed accessible, False otherwise.
549
698
  """
@@ -554,7 +703,6 @@ def _auto_deploy_key(
554
703
  generate_ec_key_pair,
555
704
  get_key_fingerprint,
556
705
  has_key_pair,
557
- load_public_key_pem,
558
706
  )
559
707
 
560
708
  # 1. Generate keys if needed
@@ -566,7 +714,63 @@ def _auto_deploy_key(
566
714
  info(f"[green]Key pair generated.[/green] Fingerprint: {get_key_fingerprint(key_dir)}")
567
715
  info("")
568
716
 
569
- # 2. Deploy to GitHub Pages
717
+ # 2. Deploy via the appropriate method
718
+ if settings.hosting_method == "tailscale":
719
+ return _auto_deploy_key_tailscale(info, key_dir, domain)
720
+ return _auto_deploy_key_github(info, settings, key_dir, domain)
721
+
722
+
723
+ def _auto_deploy_key_tailscale(
724
+ info: _InfoFn,
725
+ key_dir: Path,
726
+ domain: str,
727
+ ) -> bool:
728
+ """Deploy key via Tailscale Funnel and wait."""
729
+ from tescmd.crypto.keys import load_public_key_pem
730
+ from tescmd.deploy.tailscale_serve import (
731
+ deploy_public_key_tailscale,
732
+ get_key_url,
733
+ start_key_serving,
734
+ validate_tailscale_key_url,
735
+ wait_for_tailscale_deployment,
736
+ )
737
+
738
+ # Already deployed?
739
+ if run_async(validate_tailscale_key_url(domain)):
740
+ info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
741
+ info("")
742
+ return True
743
+
744
+ info("Deploying public key via Tailscale Funnel...")
745
+ pem = load_public_key_pem(key_dir)
746
+ run_async(deploy_public_key_tailscale(pem))
747
+ run_async(start_key_serving())
748
+
749
+ info("[green]Tailscale serve + Funnel started.[/green]")
750
+ info("Waiting for key to become accessible...")
751
+
752
+ deployed = run_async(wait_for_tailscale_deployment(domain))
753
+ if deployed:
754
+ info(f"[green]Key is live at:[/green] {get_key_url(domain)}")
755
+ info("")
756
+ return True
757
+
758
+ info("[yellow]Key deployed but not yet accessible.[/yellow]")
759
+ info(
760
+ " Run [cyan]tescmd key validate[/cyan] to check, then [cyan]tescmd auth register[/cyan]."
761
+ )
762
+ info("")
763
+ return False
764
+
765
+
766
+ def _auto_deploy_key_github(
767
+ info: _InfoFn,
768
+ settings: AppSettings,
769
+ key_dir: Path,
770
+ domain: str,
771
+ ) -> bool:
772
+ """Deploy key via GitHub Pages and wait."""
773
+ from tescmd.crypto.keys import load_public_key_pem
570
774
  from tescmd.deploy.github_pages import (
571
775
  deploy_public_key,
572
776
  get_key_url,
@@ -632,6 +836,10 @@ def _remediate_412(info: _InfoFn, domain: str) -> None:
632
836
  info(" 2. Open your application")
633
837
  info(" 3. Set [cyan]Allowed Origin URL[/cyan] to:")
634
838
  info(f" [bold]https://{domain}[/bold]")
839
+ info(
840
+ " [dim]For telemetry streaming, also add your Tailscale origin"
841
+ " (e.g. https://<machine>.tailnet.ts.net)[/dim]"
842
+ )
635
843
  info(" 4. Save, then re-run [cyan]tescmd setup[/cyan]")
636
844
 
637
845