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.
- tescmd/__init__.py +1 -1
- tescmd/api/client.py +41 -4
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +5 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/auth/oauth.py +15 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +8 -1
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +255 -106
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +6 -7
- tescmd/cli/main.py +27 -8
- tescmd/cli/mcp_cmd.py +153 -0
- tescmd/cli/nav.py +3 -1
- tescmd/cli/openclaw.py +169 -0
- tescmd/cli/security.py +7 -1
- tescmd/cli/serve.py +923 -0
- tescmd/cli/setup.py +147 -58
- tescmd/cli/sharing.py +2 -0
- tescmd/cli/status.py +1 -1
- tescmd/cli/trunk.py +8 -17
- tescmd/cli/user.py +16 -1
- tescmd/cli/vehicle.py +135 -462
- tescmd/deploy/github_pages.py +21 -2
- tescmd/deploy/tailscale_serve.py +96 -8
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/auth.py +5 -2
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +529 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +700 -0
- tescmd/openclaw/telemetry_store.py +53 -0
- tescmd/output/rich_output.py +46 -14
- tescmd/protocol/commands.py +2 -2
- tescmd/protocol/encoder.py +16 -13
- tescmd/protocol/payloads.py +132 -11
- tescmd/protocol/session.py +8 -5
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +9 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +4 -4
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +308 -129
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/server.py +26 -19
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +78 -16
- tescmd/telemetry/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- tescmd-0.4.0.dist-info/METADATA +300 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/RECORD +64 -42
- tescmd-0.2.0.dist-info/METADATA +0 -495
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.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
|
|
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
|
|
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=["
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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":
|
|
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
|
|
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(
|
|
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
|
-
|
|
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,
|
|
820
|
-
|
|
821
|
-
|
|
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
|
|
905
|
+
Requires Tailscale with Funnel enabled.
|
|
824
906
|
"""
|
|
825
|
-
|
|
907
|
+
from tescmd.cli.serve import _cmd_serve
|
|
826
908
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
# ---------------------------------------------------------------------------
|