tescmd 0.2.0__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 (61) 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 +5 -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 +96 -14
  13. tescmd/cli/energy.py +2 -0
  14. tescmd/cli/main.py +27 -8
  15. tescmd/cli/mcp_cmd.py +153 -0
  16. tescmd/cli/nav.py +3 -1
  17. tescmd/cli/openclaw.py +169 -0
  18. tescmd/cli/security.py +7 -1
  19. tescmd/cli/serve.py +923 -0
  20. tescmd/cli/setup.py +18 -7
  21. tescmd/cli/sharing.py +2 -0
  22. tescmd/cli/status.py +1 -1
  23. tescmd/cli/trunk.py +8 -17
  24. tescmd/cli/user.py +16 -1
  25. tescmd/cli/vehicle.py +135 -462
  26. tescmd/deploy/github_pages.py +8 -0
  27. tescmd/mcp/__init__.py +7 -0
  28. tescmd/mcp/server.py +648 -0
  29. tescmd/models/auth.py +5 -2
  30. tescmd/openclaw/__init__.py +23 -0
  31. tescmd/openclaw/bridge.py +330 -0
  32. tescmd/openclaw/config.py +167 -0
  33. tescmd/openclaw/dispatcher.py +522 -0
  34. tescmd/openclaw/emitter.py +175 -0
  35. tescmd/openclaw/filters.py +123 -0
  36. tescmd/openclaw/gateway.py +687 -0
  37. tescmd/openclaw/telemetry_store.py +53 -0
  38. tescmd/output/rich_output.py +46 -14
  39. tescmd/protocol/commands.py +2 -2
  40. tescmd/protocol/encoder.py +16 -13
  41. tescmd/protocol/payloads.py +132 -11
  42. tescmd/protocol/session.py +8 -5
  43. tescmd/protocol/signer.py +3 -17
  44. tescmd/telemetry/__init__.py +9 -0
  45. tescmd/telemetry/cache_sink.py +154 -0
  46. tescmd/telemetry/csv_sink.py +180 -0
  47. tescmd/telemetry/dashboard.py +4 -4
  48. tescmd/telemetry/fanout.py +49 -0
  49. tescmd/telemetry/fields.py +308 -129
  50. tescmd/telemetry/mapper.py +239 -0
  51. tescmd/telemetry/server.py +26 -19
  52. tescmd/telemetry/setup.py +468 -0
  53. tescmd/telemetry/tui.py +1716 -0
  54. tescmd/triggers/__init__.py +18 -0
  55. tescmd/triggers/manager.py +264 -0
  56. tescmd/triggers/models.py +93 -0
  57. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/METADATA +80 -32
  58. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/RECORD +61 -39
  59. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
  60. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
  61. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
tescmd/cli/vehicle.py CHANGED
@@ -5,13 +5,13 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import contextlib
7
7
  import logging
8
- import random
8
+ import os
9
9
  from typing import TYPE_CHECKING
10
10
 
11
11
  import click
12
12
 
13
13
  from tescmd._internal.async_utils import run_async
14
- from tescmd.api.errors import TunnelError, VehicleAsleepError
14
+ from tescmd.api.errors import VehicleAsleepError
15
15
  from tescmd.cli._client import (
16
16
  TTL_DEFAULT,
17
17
  TTL_FAST,
@@ -24,14 +24,13 @@ from tescmd.cli._client import (
24
24
  require_vin,
25
25
  )
26
26
  from tescmd.cli._options import global_options
27
+ from tescmd.models.sharing import ShareDriverInfo
28
+ from tescmd.models.vehicle import NearbyChargingSites, Vehicle
27
29
 
28
30
  logger = logging.getLogger(__name__)
29
31
 
30
32
  if TYPE_CHECKING:
31
- from collections.abc import Awaitable, Callable
32
-
33
33
  from tescmd.cli.main import AppContext
34
- from tescmd.output.formatter import OutputFormatter
35
34
 
36
35
 
37
36
  # ---------------------------------------------------------------------------
@@ -67,6 +66,7 @@ async def _cmd_list(app_ctx: AppContext) -> None:
67
66
  endpoint="vehicle.list",
68
67
  fetch=lambda: api.list_vehicles(),
69
68
  ttl=TTL_SLOW,
69
+ model_class=Vehicle,
70
70
  )
71
71
  finally:
72
72
  await client.close()
@@ -97,6 +97,7 @@ async def _cmd_get(app_ctx: AppContext, vin_positional: str | None) -> None:
97
97
  endpoint="vehicle.get",
98
98
  fetch=lambda: api.get_vehicle(vin),
99
99
  ttl=TTL_DEFAULT,
100
+ model_class=Vehicle,
100
101
  )
101
102
  finally:
102
103
  await client.close()
@@ -181,7 +182,7 @@ async def _cmd_location(app_ctx: AppContext, vin_positional: str | None) -> None
181
182
  vin = require_vin(vin_positional, app_ctx.vin)
182
183
  client, api = get_vehicle_api(app_ctx)
183
184
  try:
184
- vdata = await cached_vehicle_data(app_ctx, api, vin, endpoints=["drive_state"])
185
+ vdata = await cached_vehicle_data(app_ctx, api, vin, endpoints=["location_data"])
185
186
  finally:
186
187
  await client.close()
187
188
 
@@ -329,6 +330,7 @@ async def _cmd_nearby_chargers(app_ctx: AppContext, vin_positional: str | None)
329
330
  endpoint="vehicle.nearby-chargers",
330
331
  fetch=lambda: api.nearby_charging_sites(vin),
331
332
  ttl=TTL_FAST,
333
+ model_class=NearbyChargingSites,
332
334
  )
333
335
  finally:
334
336
  await client.close()
@@ -368,9 +370,12 @@ async def _cmd_alerts(app_ctx: AppContext, vin_positional: str | None) -> None:
368
370
  else:
369
371
  if alerts:
370
372
  for alert in alerts:
371
- name = alert.get("name", "Unknown")
372
- ts = alert.get("time", "")
373
- formatter.rich.info(f" {name} [dim]{ts}[/dim]")
373
+ if isinstance(alert, dict):
374
+ name = alert.get("name", "Unknown")
375
+ ts = alert.get("time", "")
376
+ formatter.rich.info(f" {name} [dim]{ts}[/dim]")
377
+ else:
378
+ formatter.rich.info(f" {alert}")
374
379
  else:
375
380
  formatter.rich.info("[dim]No recent alerts.[/dim]")
376
381
 
@@ -455,6 +460,7 @@ async def _cmd_drivers(app_ctx: AppContext, vin_positional: str | None) -> None:
455
460
  endpoint="vehicle.drivers",
456
461
  fetch=lambda: api.list_drivers(vin),
457
462
  ttl=TTL_SLOW,
463
+ model_class=ShareDriverInfo,
458
464
  )
459
465
  finally:
460
466
  await client.close()
@@ -463,39 +469,98 @@ async def _cmd_drivers(app_ctx: AppContext, vin_positional: str | None) -> None:
463
469
  formatter.output(drivers, command="vehicle.drivers")
464
470
  else:
465
471
  if drivers:
472
+ formatter.rich.info("[bold]Drivers[/bold]")
466
473
  for d in drivers:
467
- email = (d.get("email") if isinstance(d, dict) else d.email) or "unknown"
468
- status = (d.get("status") if isinstance(d, dict) else d.status) or ""
469
- formatter.rich.info(f" {email} [dim]{status}[/dim]")
474
+ row = d if isinstance(d, dict) else d.model_dump(exclude_none=True)
475
+ user_id = row.get("share_user_id", "")
476
+ email = row.get("email") or "(no email)"
477
+ status = row.get("status") or ""
478
+ parts = [f" {email}"]
479
+ if status:
480
+ parts.append(f"[dim]{status}[/dim]")
481
+ if user_id:
482
+ parts.append(f"[dim](id: {user_id})[/dim]")
483
+ formatter.rich.info(" ".join(parts))
470
484
  else:
471
485
  formatter.rich.info("[dim]No drivers found.[/dim]")
472
486
 
473
487
 
474
488
  @vehicle_group.command("calendar")
475
489
  @click.argument("vin_positional", required=False, default=None, metavar="VIN")
476
- @click.argument("calendar_data")
490
+ @click.option(
491
+ "--file",
492
+ "-f",
493
+ "calendar_file",
494
+ type=click.File("r"),
495
+ default=None,
496
+ help="Read calendar JSON from file (use '-' for stdin)",
497
+ )
498
+ @click.argument("calendar_data", required=False, default=None)
477
499
  @global_options
478
- def calendar_cmd(app_ctx: AppContext, vin_positional: str | None, calendar_data: str) -> None:
500
+ def calendar_cmd(
501
+ app_ctx: AppContext,
502
+ vin_positional: str | None,
503
+ calendar_file: click.utils.LazyFile | None,
504
+ calendar_data: str | None,
505
+ ) -> None:
479
506
  """Send calendar entries to the vehicle.
480
507
 
481
- CALENDAR_DATA should be a JSON string of calendar entries.
508
+ Pass calendar JSON as an argument, via --file, or piped through stdin.
509
+
510
+ \b
511
+ Example JSON:
512
+ {
513
+ "calendars": [{
514
+ "name": "Work",
515
+ "color": "#4285F4",
516
+ "events": [{
517
+ "name": "Team Standup",
518
+ "location": "1 Market St, San Francisco, CA",
519
+ "start": 1738573200000,
520
+ "end": 1738576800000,
521
+ "all_day": false,
522
+ "organizer": "alice@example.com"
523
+ }]
524
+ }],
525
+ "phone_name": "My Phone",
526
+ "uuid": "abc-123"
527
+ }
528
+
529
+ \b
530
+ Usage:
531
+ tescmd vehicle calendar --file cal.json
532
+ tescmd vehicle calendar '{"calendars": [...]}'
533
+ cat cal.json | tescmd vehicle calendar --file -
482
534
  """
535
+ import sys
536
+
537
+ if calendar_file is not None:
538
+ data = calendar_file.read()
539
+ elif calendar_data is not None:
540
+ data = calendar_data
541
+ elif not sys.stdin.isatty():
542
+ data = sys.stdin.read()
543
+ else:
544
+ raise click.UsageError(
545
+ "No calendar data provided.\n\n"
546
+ "Usage:\n"
547
+ " tescmd vehicle calendar --file cal.json\n"
548
+ " tescmd vehicle calendar '{\"calendars\": [...]}'\n"
549
+ " cat cal.json | tescmd vehicle calendar --file -\n\n"
550
+ "Run --help for a full JSON example."
551
+ )
552
+
483
553
  run_async(
484
554
  execute_command(
485
555
  app_ctx,
486
556
  vin_positional,
487
557
  "upcoming_calendar_entries",
488
558
  "vehicle.calendar",
489
- body={"calendar_data": calendar_data},
559
+ body={"calendar_data": data.strip()},
490
560
  )
491
561
  )
492
562
 
493
563
 
494
- # ---------------------------------------------------------------------------
495
- # Power management commands
496
- # ---------------------------------------------------------------------------
497
-
498
-
499
564
  # ---------------------------------------------------------------------------
500
565
  # Extended vehicle data endpoints
501
566
  # ---------------------------------------------------------------------------
@@ -622,13 +687,14 @@ async def _cmd_specs(app_ctx: AppContext, vin_positional: str | None) -> None:
622
687
 
623
688
 
624
689
  @vehicle_group.command("warranty")
690
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
625
691
  @global_options
626
- def warranty_cmd(app_ctx: AppContext) -> None:
627
- """Fetch warranty details for the account."""
628
- run_async(_cmd_warranty(app_ctx))
692
+ def warranty_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
693
+ """Fetch warranty details for the vehicle."""
694
+ run_async(_cmd_warranty(app_ctx, vin_positional))
629
695
 
630
696
 
631
- async def _cmd_warranty(app_ctx: AppContext) -> None:
697
+ async def _cmd_warranty(app_ctx: AppContext, vin_positional: str | None) -> None:
632
698
  formatter = app_ctx.formatter
633
699
  client, api = get_vehicle_api(app_ctx)
634
700
  try:
@@ -803,458 +869,65 @@ async def _cmd_telemetry_errors(app_ctx: AppContext, vin_positional: str | None)
803
869
 
804
870
  @telemetry_group.command("stream")
805
871
  @click.argument("vin_positional", required=False, default=None, metavar="VIN")
806
- @click.option("--port", type=int, default=None, help="Local server port (random if omitted)")
872
+ @click.option(
873
+ "--port",
874
+ "telemetry_port",
875
+ type=int,
876
+ default=None,
877
+ help="Local server port (random if omitted)",
878
+ )
807
879
  @click.option("--fields", default="default", help="Field preset or comma-separated names")
808
880
  @click.option("--interval", type=int, default=None, help="Override interval for all fields")
881
+ @click.option("--no-log", is_flag=True, default=False, help="Disable CSV telemetry log")
882
+ @click.option(
883
+ "--legacy-dashboard",
884
+ is_flag=True,
885
+ default=False,
886
+ help="Use legacy Rich Live dashboard",
887
+ )
809
888
  @global_options
810
889
  def telemetry_stream_cmd(
811
890
  app_ctx: AppContext,
812
891
  vin_positional: str | None,
813
- port: int | None,
892
+ telemetry_port: int | None,
814
893
  fields: str,
815
894
  interval: int | None,
895
+ no_log: bool,
896
+ legacy_dashboard: bool,
816
897
  ) -> None:
817
898
  """Stream real-time telemetry via Tailscale Funnel.
818
899
 
819
- Starts a local WebSocket server, exposes it via Tailscale Funnel,
820
- configures the vehicle to push telemetry, and displays it in an
821
- interactive dashboard (TTY) or JSONL stream (piped).
900
+ Alias for ``tescmd serve --no-mcp``. Starts a local WebSocket server,
901
+ exposes it via Tailscale Funnel, configures the vehicle to push
902
+ telemetry, and displays a full-screen TUI dashboard (TTY) or JSONL
903
+ stream (piped). A CSV log is written by default.
822
904
 
823
- Requires: pip install tescmd[telemetry] and Tailscale (with Funnel enabled).
905
+ Requires Tailscale with Funnel enabled.
824
906
  """
825
- run_async(_cmd_telemetry_stream(app_ctx, vin_positional, port, fields, interval))
907
+ from tescmd.cli.serve import _cmd_serve
826
908
 
827
-
828
- async def _noop_stop() -> None:
829
- """No-op tunnel cleanup (used when tunnel wasn't started yet)."""
830
-
831
-
832
- async def _setup_tunnel(
833
- *,
834
- port: int,
835
- formatter: OutputFormatter,
836
- ) -> tuple[str, str, str, Callable[[], Awaitable[None]]]:
837
- """Start Tailscale Funnel and return ``(url, hostname, ca_pem, stop_fn)``."""
838
- from tescmd.telemetry.tailscale import TailscaleManager
839
-
840
- ts = TailscaleManager()
841
- await ts.check_available()
842
- await ts.check_running()
843
-
844
- url = await ts.start_funnel(port)
845
- if formatter.format != "json":
846
- formatter.rich.info(f"Tailscale Funnel active: {url}")
847
-
848
- hostname = await ts.get_hostname()
849
- ca_pem = await ts.get_cert_pem()
850
- return url, hostname, ca_pem, ts.stop_funnel
851
-
852
-
853
- async def _cmd_telemetry_stream(
854
- app_ctx: AppContext,
855
- vin_positional: str | None,
856
- port: int | None,
857
- fields_spec: str,
858
- interval_override: int | None,
859
- ) -> None:
860
- from tescmd.telemetry.decoder import TelemetryDecoder
861
- from tescmd.telemetry.fields import resolve_fields
862
- from tescmd.telemetry.server import TelemetryServer
863
-
864
- formatter = app_ctx.formatter
865
- vin = require_vin(vin_positional, app_ctx.vin)
866
-
867
- # Pick a random high port if none specified
868
- if port is None:
869
- port = random.randint(49152, 65535)
870
-
871
- # Resolve fields
872
- field_config = resolve_fields(fields_spec, interval_override)
873
-
874
- # Build API client
875
- client, api = get_vehicle_api(app_ctx)
876
- decoder = TelemetryDecoder()
877
-
878
- # Output callback
879
- if formatter.format == "json":
880
- import json as json_mod
881
-
882
- async def on_frame(frame: TelemetryDecoder | object) -> None:
883
- from tescmd.telemetry.decoder import TelemetryFrame
884
-
885
- assert isinstance(frame, TelemetryFrame)
886
- line = json_mod.dumps(
887
- {
888
- "vin": frame.vin,
889
- "timestamp": frame.created_at.isoformat(),
890
- "data": {d.field_name: d.value for d in frame.data},
891
- },
892
- default=str,
893
- )
894
- print(line, flush=True)
895
-
896
- dashboard = None
897
- else:
898
- from tescmd.telemetry.dashboard import TelemetryDashboard
899
-
900
- dashboard = TelemetryDashboard(formatter.console, formatter.rich._units)
901
-
902
- async def on_frame(frame: TelemetryDecoder | object) -> None:
903
- from tescmd.telemetry.decoder import TelemetryFrame
904
-
905
- assert isinstance(frame, TelemetryFrame)
906
- assert dashboard is not None
907
- dashboard.update(frame)
908
-
909
- # Load settings and public key before server creation — the key must be
910
- # served at /.well-known/ because Tesla fetches it during partner registration.
911
- from pathlib import Path
912
-
913
- from tescmd.crypto.keys import load_public_key_pem
914
- from tescmd.models.config import AppSettings
915
-
916
- _settings = AppSettings()
917
- key_dir = Path(_settings.config_dir).expanduser() / "keys"
918
- public_key_pem = load_public_key_pem(key_dir)
919
-
920
- # Server + Tunnel + Config with guaranteed cleanup
921
- server = TelemetryServer(
922
- port=port, decoder=decoder, on_frame=on_frame, public_key_pem=public_key_pem
923
- )
924
- tunnel_url: str | None = None
925
- config_created = False
926
-
927
- # Cleanup callbacks — set by tunnel provider
928
- stop_tunnel: Callable[[], Awaitable[None]] = _noop_stop
929
- original_partner_domain: str | None = None
930
-
931
- try:
932
- await server.start()
933
-
934
- if formatter.format != "json":
935
- formatter.rich.info(f"WebSocket server listening on port {port}")
936
-
937
- tunnel_url, hostname, ca_pem, stop_tunnel = await _setup_tunnel(
938
- port=port,
939
- formatter=formatter,
909
+ run_async(
910
+ _cmd_serve(
911
+ app_ctx,
912
+ vin_positional=vin_positional,
913
+ transport="streamable-http",
914
+ mcp_port=int(os.environ.get("TESCMD_MCP_PORT", "8080")),
915
+ telemetry_port=telemetry_port,
916
+ fields_spec=fields,
917
+ interval_override=interval,
918
+ no_telemetry=False,
919
+ no_mcp=True,
920
+ no_log=no_log,
921
+ legacy_dashboard=legacy_dashboard,
922
+ openclaw_url=None,
923
+ openclaw_token=None,
924
+ openclaw_config_path=None,
925
+ dry_run=False,
926
+ tailscale=False,
927
+ client_id="",
928
+ client_secret="",
940
929
  )
941
-
942
- # --- Re-register partner domain if tunnel hostname differs ---
943
- registered_domain = (_settings.domain or "").lower().rstrip(".")
944
- tunnel_host = hostname.lower().rstrip(".")
945
-
946
- if tunnel_host != registered_domain:
947
- from tescmd.api.errors import AuthError
948
- from tescmd.auth.oauth import register_partner_account
949
-
950
- if not _settings.client_id or not _settings.client_secret:
951
- raise TunnelError(
952
- "Client credentials required for partner domain "
953
- "re-registration. Ensure TESLA_CLIENT_ID and "
954
- "TESLA_CLIENT_SECRET are set."
955
- )
956
-
957
- reg_client_id = _settings.client_id
958
- reg_client_secret = _settings.client_secret
959
- region = app_ctx.region or _settings.region
960
- if formatter.format != "json":
961
- formatter.rich.info(f"Re-registering partner domain: {hostname}")
962
-
963
- async def _try_register() -> None:
964
- await register_partner_account(
965
- client_id=reg_client_id,
966
- client_secret=reg_client_secret,
967
- domain=hostname,
968
- region=region,
969
- )
970
-
971
- # Try registration with auto-retry for transient tunnel errors.
972
- # 412 = Allowed Origin URL missing in Developer Portal.
973
- # 424 = Tesla failed to reach the tunnel (key fetch failed) —
974
- # typically a propagation delay after tunnel start.
975
- # Tunnel propagation delays can cause transient failures,
976
- # so we retry patiently (12 x 5s = 60s).
977
- max_retries = 12
978
- for attempt in range(max_retries):
979
- try:
980
- await _try_register()
981
- if attempt > 0 and formatter.format != "json":
982
- formatter.rich.info(
983
- "[green]Tunnel is reachable — registration succeeded.[/green]"
984
- )
985
- break
986
- except AuthError as exc:
987
- status = getattr(exc, "status_code", None)
988
-
989
- # 424 = key download failed — likely tunnel propagation delay
990
- if status == 424 and attempt < max_retries - 1:
991
- if formatter.format != "json":
992
- formatter.rich.info(
993
- f"[yellow]Waiting for tunnel to become reachable "
994
- f"(HTTP 424)... "
995
- f"({attempt + 1}/{max_retries})[/yellow]"
996
- )
997
- await asyncio.sleep(5)
998
- continue
999
-
1000
- if status not in (412, 424):
1001
- raise TunnelError(
1002
- f"Partner re-registration failed for {hostname}: {exc}"
1003
- ) from exc
1004
-
1005
- # 412 or exhausted 424 retries — need user intervention
1006
- if formatter.format == "json":
1007
- if status == 412:
1008
- raise TunnelError(
1009
- f"Add https://{hostname} as an Allowed Origin "
1010
- f"URL in your Tesla Developer Portal app, "
1011
- f"then try again."
1012
- ) from exc
1013
- raise TunnelError(
1014
- f"Tesla could not fetch the public key from "
1015
- f"https://{hostname}. Verify the tunnel is "
1016
- f"accessible and try again."
1017
- ) from exc
1018
-
1019
- formatter.rich.info("")
1020
- if status == 412:
1021
- formatter.rich.info(
1022
- "[yellow]Tesla requires the tunnel domain as "
1023
- "an Allowed Origin URL.[/yellow]"
1024
- )
1025
- else:
1026
- formatter.rich.info(
1027
- "[yellow]Tesla could not reach the tunnel to "
1028
- "verify the public key (HTTP 424).[/yellow]"
1029
- )
1030
- formatter.rich.info("")
1031
- formatter.rich.info(" 1. Open your Tesla Developer app:")
1032
- formatter.rich.info(" [cyan]https://developer.tesla.com[/cyan]")
1033
- formatter.rich.info(" 2. Add this as an Allowed Origin URL:")
1034
- formatter.rich.info(f" [cyan]https://{hostname}[/cyan]")
1035
- formatter.rich.info(" 3. Save the changes")
1036
- formatter.rich.info("")
1037
-
1038
- # Wait for user to fix, then retry
1039
- while True:
1040
- formatter.rich.info(
1041
- "Press [bold]Enter[/bold] when done (or Ctrl+C to cancel)..."
1042
- )
1043
- await asyncio.get_event_loop().run_in_executor(None, input)
1044
- try:
1045
- await _try_register()
1046
- formatter.rich.info("[green]Registration succeeded![/green]")
1047
- break
1048
- except AuthError as retry_exc:
1049
- retry_status = getattr(retry_exc, "status_code", None)
1050
- if retry_status in (412, 424):
1051
- formatter.rich.info(
1052
- f"[yellow]Tesla returned HTTP "
1053
- f"{retry_status}. There is a propagation "
1054
- f"delay on Tesla's end after adding an "
1055
- f"Allowed Origin URL — this can take up "
1056
- f"to 5 minutes.[/yellow]"
1057
- )
1058
- formatter.rich.info(
1059
- "Press [bold]Enter[/bold] to retry, or "
1060
- "wait and try again (Ctrl+C to cancel)..."
1061
- )
1062
- continue
1063
- raise TunnelError(
1064
- f"Partner re-registration failed: {retry_exc}"
1065
- ) from retry_exc
1066
- break # registration succeeded in the inner loop
1067
-
1068
- original_partner_domain = _settings.domain
1069
-
1070
- # --- Common path: configure fleet telemetry ---
1071
- inner_config: dict[str, object] = {
1072
- "hostname": hostname,
1073
- "port": 443, # Tailscale Funnel terminates TLS on 443
1074
- "ca": ca_pem,
1075
- "fields": field_config,
1076
- "alert_types": ["service"],
1077
- }
1078
-
1079
- # Sign the config with the fleet key and use the JWS endpoint
1080
- # (Tesla requires the Vehicle Command HTTP Proxy or JWS signing).
1081
- from tescmd.api.errors import MissingScopesError
1082
- from tescmd.crypto.keys import load_private_key
1083
- from tescmd.crypto.schnorr import sign_fleet_telemetry_config
1084
-
1085
- private_key = load_private_key(key_dir)
1086
- jws_token = sign_fleet_telemetry_config(private_key, inner_config)
1087
-
1088
- try:
1089
- await api.fleet_telemetry_config_create_jws(vins=[vin], token=jws_token)
1090
- except MissingScopesError:
1091
- # Token lacks required scopes (e.g. vehicle_location was added
1092
- # after the token was issued, or the partner domain changed).
1093
- # A full re-login is needed — refresh alone doesn't update scopes.
1094
- if formatter.format == "json":
1095
- raise TunnelError(
1096
- "Your OAuth token is missing required scopes for "
1097
- "telemetry streaming. Run:\n"
1098
- " 1. tescmd auth register (restore partner domain)\n"
1099
- " 2. tescmd auth login (obtain token with updated scopes)\n"
1100
- "Then retry the stream command."
1101
- ) from None
1102
-
1103
- from tescmd.auth.oauth import login_flow
1104
- from tescmd.auth.token_store import TokenStore
1105
- from tescmd.models.auth import DEFAULT_SCOPES
1106
-
1107
- formatter.rich.info("")
1108
- formatter.rich.info(
1109
- "[yellow]Token is missing required scopes — re-authenticating...[/yellow]"
1110
- )
1111
- formatter.rich.info("Opening your browser to sign in to Tesla...")
1112
- formatter.rich.info(
1113
- "When prompted, click [cyan]Select All[/cyan] and then"
1114
- " [cyan]Allow[/cyan] to grant tescmd access."
1115
- )
1116
-
1117
- login_port = 8085
1118
- login_redirect = f"http://localhost:{login_port}/callback"
1119
- login_store = TokenStore(
1120
- profile=app_ctx.profile,
1121
- token_file=_settings.token_file,
1122
- config_dir=_settings.config_dir,
1123
- )
1124
- token_data = await login_flow(
1125
- client_id=_settings.client_id or "",
1126
- client_secret=_settings.client_secret,
1127
- redirect_uri=login_redirect,
1128
- scopes=DEFAULT_SCOPES,
1129
- port=login_port,
1130
- token_store=login_store,
1131
- region=app_ctx.region or _settings.region,
1132
- )
1133
- client.update_token(token_data.access_token)
1134
- formatter.rich.info("[green]Login successful — retrying config...[/green]")
1135
- await api.fleet_telemetry_config_create_jws(vins=[vin], token=jws_token)
1136
-
1137
- config_created = True
1138
-
1139
- if formatter.format != "json":
1140
- formatter.rich.info(f"Fleet telemetry configured for VIN {vin}")
1141
- formatter.rich.info("")
1142
-
1143
- # Run dashboard or wait for interrupt
1144
- if dashboard is not None:
1145
- from rich.live import Live
1146
-
1147
- dashboard.set_tunnel_url(tunnel_url)
1148
- with Live(
1149
- dashboard,
1150
- console=formatter.console,
1151
- refresh_per_second=4,
1152
- ) as live:
1153
- dashboard.set_live(live)
1154
- await _wait_for_interrupt()
1155
- else:
1156
- await _wait_for_interrupt()
1157
-
1158
- finally:
1159
- # Cleanup in reverse order — each tolerates failure.
1160
- # Show progress so the user knows what's happening on 'q'/Ctrl+C.
1161
- is_rich = formatter.format != "json"
1162
-
1163
- if config_created:
1164
- if is_rich:
1165
- formatter.rich.info("[dim]Removing fleet telemetry config...[/dim]")
1166
- try:
1167
- await api.fleet_telemetry_config_delete(vin)
1168
- except Exception:
1169
- if is_rich:
1170
- formatter.rich.info(
1171
- "[yellow]Warning: failed to remove telemetry config."
1172
- " It may expire or can be removed manually.[/yellow]"
1173
- )
1174
-
1175
- if original_partner_domain is not None:
1176
- if is_rich:
1177
- formatter.rich.info(
1178
- f"[dim]Restoring partner domain to {original_partner_domain}...[/dim]"
1179
- )
1180
- try:
1181
- from tescmd.auth.oauth import register_partner_account
1182
-
1183
- assert _settings.client_id is not None
1184
- assert _settings.client_secret is not None
1185
- await register_partner_account(
1186
- client_id=_settings.client_id,
1187
- client_secret=_settings.client_secret,
1188
- domain=original_partner_domain,
1189
- region=app_ctx.region or _settings.region,
1190
- )
1191
- except Exception:
1192
- msg = (
1193
- f"Failed to restore partner domain to {original_partner_domain}. "
1194
- "Run 'tescmd auth register' to fix this manually."
1195
- )
1196
- logger.warning(msg)
1197
- if is_rich:
1198
- formatter.rich.info(f"[yellow]Warning: {msg}[/yellow]")
1199
-
1200
- if is_rich:
1201
- formatter.rich.info("[dim]Stopping tunnel...[/dim]")
1202
- with contextlib.suppress(Exception):
1203
- await stop_tunnel()
1204
-
1205
- if is_rich:
1206
- formatter.rich.info("[dim]Stopping server...[/dim]")
1207
- with contextlib.suppress(Exception):
1208
- await server.stop()
1209
-
1210
- await client.close()
1211
- if is_rich:
1212
- formatter.rich.info("[green]Stream stopped.[/green]")
1213
-
1214
-
1215
- async def _wait_for_interrupt() -> None:
1216
- """Block until Ctrl+C or 'q' is pressed."""
1217
- import sys
1218
-
1219
- if not sys.stdin.isatty():
1220
- # Non-TTY (piped / JSON mode): just wait for cancellation.
1221
- try:
1222
- while True:
1223
- await asyncio.sleep(1)
1224
- except asyncio.CancelledError:
1225
- pass
1226
- return
1227
-
1228
- try:
1229
- import selectors
1230
- import termios
1231
- import tty
1232
- except ImportError:
1233
- # Non-Unix: fall back to Ctrl+C only.
1234
- try:
1235
- while True:
1236
- await asyncio.sleep(1)
1237
- except asyncio.CancelledError:
1238
- pass
1239
- return
1240
-
1241
- fd = sys.stdin.fileno()
1242
- old_settings = termios.tcgetattr(fd)
1243
- sel = selectors.DefaultSelector()
1244
- try:
1245
- tty.setcbreak(fd) # Chars available immediately; Ctrl+C still sends SIGINT
1246
- sel.register(sys.stdin, selectors.EVENT_READ)
1247
- while True:
1248
- await asyncio.sleep(0.1)
1249
- for _key, _ in sel.select(timeout=0):
1250
- ch = sys.stdin.read(1)
1251
- if ch in ("q", "Q"):
1252
- return
1253
- except asyncio.CancelledError:
1254
- pass
1255
- finally:
1256
- termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1257
- sel.close()
930
+ )
1258
931
 
1259
932
 
1260
933
  # ---------------------------------------------------------------------------