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.
- 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 +5 -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 +96 -14
- tescmd/cli/energy.py +2 -0
- 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 +18 -7
- 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 +8 -0
- 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 +522 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +687 -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/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/METADATA +80 -32
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/RECORD +61 -39
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
tescmd/cli/auth.py
CHANGED
|
@@ -33,7 +33,7 @@ if TYPE_CHECKING:
|
|
|
33
33
|
from tescmd.cli.main import AppContext
|
|
34
34
|
from tescmd.output.formatter import OutputFormatter
|
|
35
35
|
|
|
36
|
-
DEVELOPER_PORTAL_URL = "https://developer.tesla.com"
|
|
36
|
+
DEVELOPER_PORTAL_URL = "https://developer.tesla.com/dashboard"
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
# ---------------------------------------------------------------------------
|
|
@@ -49,7 +49,12 @@ auth_group = click.Group("auth", help="Authentication commands")
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
@auth_group.command("login")
|
|
52
|
-
@click.option(
|
|
52
|
+
@click.option(
|
|
53
|
+
"--port",
|
|
54
|
+
type=int,
|
|
55
|
+
default=None,
|
|
56
|
+
help="Local callback port (default: 8085 or TESCMD_OAUTH_PORT)",
|
|
57
|
+
)
|
|
53
58
|
@click.option(
|
|
54
59
|
"--reconsent",
|
|
55
60
|
is_flag=True,
|
|
@@ -57,15 +62,20 @@ auth_group = click.Group("auth", help="Authentication commands")
|
|
|
57
62
|
help="Force Tesla to re-display the scope consent screen.",
|
|
58
63
|
)
|
|
59
64
|
@global_options
|
|
60
|
-
def login_cmd(app_ctx: AppContext, port: int, reconsent: bool) -> None:
|
|
65
|
+
def login_cmd(app_ctx: AppContext, port: int | None, reconsent: bool) -> None:
|
|
61
66
|
"""Log in via OAuth2 PKCE flow."""
|
|
62
67
|
run_async(_cmd_login(app_ctx, port, reconsent=reconsent))
|
|
63
68
|
|
|
64
69
|
|
|
65
|
-
async def _cmd_login(app_ctx: AppContext, port: int, *, reconsent: bool = False) -> None:
|
|
70
|
+
async def _cmd_login(app_ctx: AppContext, port: int | None, *, reconsent: bool = False) -> None:
|
|
71
|
+
from tescmd.models.auth import DEFAULT_PORT
|
|
72
|
+
|
|
66
73
|
formatter = app_ctx.formatter
|
|
67
74
|
settings = AppSettings()
|
|
68
75
|
|
|
76
|
+
if port is None:
|
|
77
|
+
port = DEFAULT_PORT
|
|
78
|
+
|
|
69
79
|
client_id = settings.client_id
|
|
70
80
|
client_secret = settings.client_secret
|
|
71
81
|
|
|
@@ -267,11 +277,24 @@ async def _cmd_refresh(app_ctx: AppContext) -> None:
|
|
|
267
277
|
scopes: list[str] = meta.get("scopes", DEFAULT_SCOPES)
|
|
268
278
|
region: str = meta.get("region", "na")
|
|
269
279
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
280
|
+
from tescmd.api.errors import AuthError
|
|
281
|
+
from tescmd.cli._client import reauth_on_expired_refresh
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
token_data = await refresh_access_token(
|
|
285
|
+
refresh_token=rt,
|
|
286
|
+
client_id=settings.client_id,
|
|
287
|
+
client_secret=settings.client_secret,
|
|
288
|
+
)
|
|
289
|
+
except AuthError as exc:
|
|
290
|
+
if "login_required" not in str(exc):
|
|
291
|
+
raise
|
|
292
|
+
await reauth_on_expired_refresh(store, settings)
|
|
293
|
+
if formatter.format == "json":
|
|
294
|
+
formatter.output({"status": "re-authenticated"}, command="auth.refresh")
|
|
295
|
+
else:
|
|
296
|
+
formatter.rich.info("")
|
|
297
|
+
return
|
|
275
298
|
|
|
276
299
|
store.save(
|
|
277
300
|
access_token=token_data.access_token,
|
|
@@ -445,12 +468,17 @@ def _interactive_setup(
|
|
|
445
468
|
redirect_uri: str,
|
|
446
469
|
*,
|
|
447
470
|
domain: str = "",
|
|
471
|
+
tailscale_hostname: str = "",
|
|
448
472
|
) -> tuple[str, str]:
|
|
449
473
|
"""Walk the user through first-time Tesla API credential setup.
|
|
450
474
|
|
|
451
475
|
When *domain* is provided (e.g. from the setup wizard), the developer
|
|
452
476
|
portal instructions show ``https://{domain}`` as the Allowed Origin URL.
|
|
453
477
|
Tesla's Fleet API requires the origin to match the registration domain.
|
|
478
|
+
|
|
479
|
+
When *tailscale_hostname* is provided (or auto-detected), the user is
|
|
480
|
+
offered the chance to start a Tailscale Funnel so Tesla can verify the
|
|
481
|
+
origin URL when the portal app config is saved.
|
|
454
482
|
"""
|
|
455
483
|
info = formatter.rich.info
|
|
456
484
|
origin_url = f"https://{domain}" if domain else f"http://localhost:{port}"
|
|
@@ -497,6 +525,47 @@ def _interactive_setup(
|
|
|
497
525
|
info(" Click Next.")
|
|
498
526
|
info("")
|
|
499
527
|
|
|
528
|
+
# Detect Tailscale and offer to start Funnel for origin URL verification
|
|
529
|
+
ts_hostname = tailscale_hostname
|
|
530
|
+
ts_funnel_started = False
|
|
531
|
+
if not ts_hostname:
|
|
532
|
+
try:
|
|
533
|
+
from tescmd.deploy.tailscale_serve import is_tailscale_serve_ready
|
|
534
|
+
|
|
535
|
+
if run_async(is_tailscale_serve_ready()):
|
|
536
|
+
from tescmd.telemetry.tailscale import TailscaleManager
|
|
537
|
+
|
|
538
|
+
ts_hostname = run_async(TailscaleManager().get_hostname())
|
|
539
|
+
except Exception:
|
|
540
|
+
pass
|
|
541
|
+
|
|
542
|
+
if ts_hostname:
|
|
543
|
+
ts_origin = f"https://{ts_hostname}"
|
|
544
|
+
info("")
|
|
545
|
+
info(f"[green]Tailscale detected:[/green] {ts_hostname}")
|
|
546
|
+
info("")
|
|
547
|
+
info(f" Adding [cyan]{ts_origin}[/cyan] as an Allowed Origin URL")
|
|
548
|
+
info(" will let [cyan]tescmd serve[/cyan] stream telemetry without")
|
|
549
|
+
info(" extra portal changes later.")
|
|
550
|
+
info("")
|
|
551
|
+
try:
|
|
552
|
+
answer = input("Start Tailscale Funnel so Tesla can verify the URL? [Y/n] ").strip()
|
|
553
|
+
except (EOFError, KeyboardInterrupt):
|
|
554
|
+
answer = "n"
|
|
555
|
+
|
|
556
|
+
if answer.lower() != "n":
|
|
557
|
+
info("Starting Tailscale Funnel...")
|
|
558
|
+
try:
|
|
559
|
+
from tescmd.telemetry.tailscale import TailscaleManager as _TsMgr
|
|
560
|
+
|
|
561
|
+
run_async(_TsMgr().enable_funnel())
|
|
562
|
+
ts_funnel_started = True
|
|
563
|
+
info(f"[green]Funnel active — {ts_origin} is reachable.[/green]")
|
|
564
|
+
except Exception as exc:
|
|
565
|
+
info(f"[yellow]Could not start Funnel: {exc}[/yellow]")
|
|
566
|
+
info("[dim]You can add the origin URL manually later.[/dim]")
|
|
567
|
+
ts_hostname = ""
|
|
568
|
+
|
|
500
569
|
# Step 3 — Client Details
|
|
501
570
|
info("[bold]Step 3 — Client Details[/bold]")
|
|
502
571
|
info(
|
|
@@ -504,14 +573,17 @@ def _interactive_setup(
|
|
|
504
573
|
" Machine-to-Machine[/cyan] (the default)"
|
|
505
574
|
)
|
|
506
575
|
info(f" Allowed Origin URL: [cyan]{origin_url}[/cyan]")
|
|
576
|
+
if ts_hostname:
|
|
577
|
+
info(f" [bold]Also add:[/bold] [cyan]https://{ts_hostname}[/cyan]")
|
|
507
578
|
info(f" Allowed Redirect URI: [cyan]{redirect_uri}[/cyan]")
|
|
508
579
|
info(" Allowed Returned URL: (leave empty)")
|
|
509
580
|
info("")
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
581
|
+
if not ts_hostname:
|
|
582
|
+
info(
|
|
583
|
+
" [dim]For telemetry streaming, add your Tailscale hostname"
|
|
584
|
+
" as an additional origin:[/dim]"
|
|
585
|
+
)
|
|
586
|
+
info(" [dim] https://<machine>.tailnet.ts.net[/dim]")
|
|
515
587
|
info(" Click Next.")
|
|
516
588
|
info("")
|
|
517
589
|
|
|
@@ -572,6 +644,16 @@ def _interactive_setup(
|
|
|
572
644
|
_write_env_file(client_id, client_secret)
|
|
573
645
|
info("[green]Credentials saved to .env[/green]")
|
|
574
646
|
|
|
647
|
+
# Clean up Tailscale Funnel if we started it during this session
|
|
648
|
+
if ts_funnel_started:
|
|
649
|
+
try:
|
|
650
|
+
from tescmd.telemetry.tailscale import TailscaleManager as _TsMgr
|
|
651
|
+
|
|
652
|
+
run_async(_TsMgr._run("tailscale", "funnel", "--bg", "off"))
|
|
653
|
+
except Exception as exc:
|
|
654
|
+
info(f"[yellow]Warning: Failed to stop Tailscale Funnel: {exc}[/yellow]")
|
|
655
|
+
info("[dim]You may need to run 'tailscale funnel --bg off' manually.[/dim]")
|
|
656
|
+
|
|
575
657
|
info("")
|
|
576
658
|
return (client_id, client_secret)
|
|
577
659
|
|
tescmd/cli/energy.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_SLOW, cached_api_call, get_energy_api, invalidate_cache_for_site
|
|
11
11
|
from tescmd.cli._options import global_options
|
|
12
|
+
from tescmd.models.energy import SiteInfo
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
14
15
|
from tescmd.cli.main import AppContext
|
|
@@ -66,6 +67,7 @@ async def _cmd_status(app_ctx: AppContext, site_id: int) -> None:
|
|
|
66
67
|
endpoint="energy.status",
|
|
67
68
|
fetch=lambda: api.site_info(site_id),
|
|
68
69
|
ttl=TTL_SLOW,
|
|
70
|
+
model_class=SiteInfo,
|
|
69
71
|
)
|
|
70
72
|
finally:
|
|
71
73
|
await client.close()
|
tescmd/cli/main.py
CHANGED
|
@@ -6,6 +6,7 @@ import dataclasses
|
|
|
6
6
|
import logging
|
|
7
7
|
|
|
8
8
|
import click
|
|
9
|
+
from dotenv import load_dotenv
|
|
9
10
|
|
|
10
11
|
from tescmd._internal.async_utils import run_async
|
|
11
12
|
from tescmd.api.errors import (
|
|
@@ -62,6 +63,13 @@ class AppContext:
|
|
|
62
63
|
return self._formatter
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Load .env into os.environ before Click resolves any envvar= options.
|
|
68
|
+
# This ensures TESLA_VIN, TESCMD_MCP_CLIENT_ID, etc. from .env files
|
|
69
|
+
# are visible to Click's envvar resolution.
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
load_dotenv(override=False)
|
|
72
|
+
|
|
65
73
|
# ---------------------------------------------------------------------------
|
|
66
74
|
# Root Click group
|
|
67
75
|
# ---------------------------------------------------------------------------
|
|
@@ -160,11 +168,14 @@ def _register_commands() -> None:
|
|
|
160
168
|
from tescmd.cli.climate import climate_group
|
|
161
169
|
from tescmd.cli.energy import energy_group
|
|
162
170
|
from tescmd.cli.key import key_group
|
|
171
|
+
from tescmd.cli.mcp_cmd import mcp_group
|
|
163
172
|
from tescmd.cli.media import media_group
|
|
164
173
|
from tescmd.cli.nav import nav_group
|
|
174
|
+
from tescmd.cli.openclaw import openclaw_group
|
|
165
175
|
from tescmd.cli.partner import partner_group
|
|
166
176
|
from tescmd.cli.raw import raw_group
|
|
167
177
|
from tescmd.cli.security import security_group
|
|
178
|
+
from tescmd.cli.serve import serve_cmd
|
|
168
179
|
from tescmd.cli.setup import setup_cmd
|
|
169
180
|
from tescmd.cli.sharing import sharing_group
|
|
170
181
|
from tescmd.cli.software import software_group
|
|
@@ -180,11 +191,14 @@ def _register_commands() -> None:
|
|
|
180
191
|
cli.add_command(climate_group)
|
|
181
192
|
cli.add_command(energy_group)
|
|
182
193
|
cli.add_command(key_group)
|
|
194
|
+
cli.add_command(mcp_group)
|
|
183
195
|
cli.add_command(media_group)
|
|
184
196
|
cli.add_command(nav_group)
|
|
197
|
+
cli.add_command(openclaw_group)
|
|
185
198
|
cli.add_command(partner_group)
|
|
186
199
|
cli.add_command(raw_group)
|
|
187
200
|
cli.add_command(security_group)
|
|
201
|
+
cli.add_command(serve_cmd)
|
|
188
202
|
cli.add_command(setup_cmd)
|
|
189
203
|
cli.add_command(sharing_group)
|
|
190
204
|
cli.add_command(status_cmd)
|
|
@@ -563,14 +577,19 @@ def _handle_session_error(
|
|
|
563
577
|
formatter.rich.error(message)
|
|
564
578
|
formatter.rich.info("")
|
|
565
579
|
formatter.rich.info("Possible causes:")
|
|
566
|
-
formatter.rich.info(" - Vehicle is temporarily unreachable")
|
|
567
|
-
formatter.rich.info(" -
|
|
568
|
-
formatter.rich.info(
|
|
580
|
+
formatter.rich.info(" - Vehicle is temporarily unreachable or asleep")
|
|
581
|
+
formatter.rich.info(" - Session expired — retry once and a fresh handshake will occur")
|
|
582
|
+
formatter.rich.info(
|
|
583
|
+
" - Local key pair is corrupted or was re-generated without re-enrollment"
|
|
584
|
+
)
|
|
569
585
|
formatter.rich.info("")
|
|
570
|
-
formatter.rich.info("Try
|
|
571
|
-
formatter.rich.info("
|
|
572
|
-
formatter.rich.info(" [cyan]
|
|
573
|
-
formatter.rich.info("
|
|
586
|
+
formatter.rich.info("Try:")
|
|
587
|
+
formatter.rich.info(" 1. Re-run the command (sessions auto-refresh)")
|
|
588
|
+
formatter.rich.info(" 2. Use [cyan]--verbose[/cyan] to see fault codes and signing details")
|
|
589
|
+
formatter.rich.info(" 3. If repeated failures, re-enroll:")
|
|
590
|
+
formatter.rich.info(
|
|
591
|
+
" [cyan]tescmd key generate --force && tescmd key deploy && tescmd key enroll[/cyan]"
|
|
592
|
+
)
|
|
574
593
|
|
|
575
594
|
|
|
576
595
|
def _handle_keyring_error(
|
|
@@ -642,4 +661,4 @@ def _handle_tunnel_error(
|
|
|
642
661
|
" [cyan]https://login.tailscale.com/admin/acls[/cyan]"
|
|
643
662
|
)
|
|
644
663
|
formatter.rich.info("")
|
|
645
|
-
formatter.rich.info("
|
|
664
|
+
formatter.rich.info("Telemetry deps are included with tescmd.")
|
tescmd/cli/mcp_cmd.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""CLI commands for the MCP (Model Context Protocol) server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from tescmd.cli._options import global_options
|
|
8
|
+
|
|
9
|
+
mcp_group = click.Group("mcp", help="MCP (Model Context Protocol) server.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@mcp_group.command("serve")
|
|
13
|
+
@click.option(
|
|
14
|
+
"--transport",
|
|
15
|
+
type=click.Choice(["stdio", "streamable-http"]),
|
|
16
|
+
default="streamable-http",
|
|
17
|
+
help="MCP transport (default: streamable-http)",
|
|
18
|
+
)
|
|
19
|
+
@click.option(
|
|
20
|
+
"--port",
|
|
21
|
+
type=int,
|
|
22
|
+
default=8080,
|
|
23
|
+
envvar="TESCMD_MCP_PORT",
|
|
24
|
+
help="HTTP port (streamable-http only)",
|
|
25
|
+
)
|
|
26
|
+
@click.option(
|
|
27
|
+
"--host",
|
|
28
|
+
default="127.0.0.1",
|
|
29
|
+
envvar="TESCMD_HOST",
|
|
30
|
+
help="Bind address (default: 127.0.0.1)",
|
|
31
|
+
)
|
|
32
|
+
@click.option("--tailscale", is_flag=True, default=False, help="Expose via Tailscale Funnel")
|
|
33
|
+
@click.option(
|
|
34
|
+
"--client-id",
|
|
35
|
+
envvar="TESCMD_MCP_CLIENT_ID",
|
|
36
|
+
default=None,
|
|
37
|
+
help="MCP client ID (env: TESCMD_MCP_CLIENT_ID)",
|
|
38
|
+
)
|
|
39
|
+
@click.option(
|
|
40
|
+
"--client-secret",
|
|
41
|
+
envvar="TESCMD_MCP_CLIENT_SECRET",
|
|
42
|
+
default=None,
|
|
43
|
+
help="MCP client secret / bearer token (env: TESCMD_MCP_CLIENT_SECRET)",
|
|
44
|
+
)
|
|
45
|
+
@global_options
|
|
46
|
+
def serve_cmd(
|
|
47
|
+
app_ctx: object,
|
|
48
|
+
transport: str,
|
|
49
|
+
port: int,
|
|
50
|
+
host: str,
|
|
51
|
+
tailscale: bool,
|
|
52
|
+
client_id: str | None,
|
|
53
|
+
client_secret: str | None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Start an MCP server exposing tescmd commands as tools.
|
|
56
|
+
|
|
57
|
+
Agents (Claude Desktop, Claude Code, etc.) connect to this server
|
|
58
|
+
and invoke tescmd commands as MCP tools with JSON output.
|
|
59
|
+
|
|
60
|
+
\b
|
|
61
|
+
Transports:
|
|
62
|
+
streamable-http HTTP server on --port (default)
|
|
63
|
+
stdio Read/write JSON-RPC on stdin/stdout
|
|
64
|
+
|
|
65
|
+
\b
|
|
66
|
+
Authentication (required for all transports):
|
|
67
|
+
Set TESCMD_MCP_CLIENT_ID and TESCMD_MCP_CLIENT_SECRET, or pass
|
|
68
|
+
--client-id / --client-secret. HTTP clients authenticate with
|
|
69
|
+
Authorization: Bearer <client-secret>.
|
|
70
|
+
|
|
71
|
+
\b
|
|
72
|
+
Examples:
|
|
73
|
+
tescmd mcp serve # HTTP on :8080
|
|
74
|
+
tescmd mcp serve --transport stdio # stdio for Claude Desktop
|
|
75
|
+
tescmd mcp serve --tailscale # expose via Tailscale Funnel
|
|
76
|
+
"""
|
|
77
|
+
from tescmd._internal.async_utils import run_async
|
|
78
|
+
from tescmd.cli.main import AppContext
|
|
79
|
+
|
|
80
|
+
assert isinstance(app_ctx, AppContext)
|
|
81
|
+
|
|
82
|
+
if not client_id or not client_secret:
|
|
83
|
+
raise click.UsageError(
|
|
84
|
+
"MCP client credentials required.\n"
|
|
85
|
+
"Set TESCMD_MCP_CLIENT_ID and TESCMD_MCP_CLIENT_SECRET "
|
|
86
|
+
"in your .env file or environment, or pass --client-id and --client-secret."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if tailscale and transport == "stdio":
|
|
90
|
+
raise click.UsageError("--tailscale cannot be used with --transport stdio")
|
|
91
|
+
|
|
92
|
+
run_async(_cmd_serve(app_ctx, transport, port, host, tailscale, client_id, client_secret))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _cmd_serve(
|
|
96
|
+
app_ctx: object,
|
|
97
|
+
transport: str,
|
|
98
|
+
port: int,
|
|
99
|
+
host: str,
|
|
100
|
+
tailscale: bool,
|
|
101
|
+
client_id: str,
|
|
102
|
+
client_secret: str,
|
|
103
|
+
) -> None:
|
|
104
|
+
import sys
|
|
105
|
+
|
|
106
|
+
from tescmd.cli.main import AppContext
|
|
107
|
+
from tescmd.mcp.server import create_mcp_server
|
|
108
|
+
|
|
109
|
+
assert isinstance(app_ctx, AppContext)
|
|
110
|
+
formatter = app_ctx.formatter
|
|
111
|
+
server = create_mcp_server(client_id=client_id, client_secret=client_secret)
|
|
112
|
+
tool_count = len(server.list_tools())
|
|
113
|
+
|
|
114
|
+
if transport == "stdio":
|
|
115
|
+
# Log to stderr so stdout stays clean for JSON-RPC
|
|
116
|
+
print(f"tescmd MCP server starting (stdio, {tool_count} tools)", file=sys.stderr)
|
|
117
|
+
await server.run_stdio()
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if not tailscale:
|
|
121
|
+
if formatter.format != "json":
|
|
122
|
+
formatter.rich.info(
|
|
123
|
+
f"MCP server starting on http://{host}:{port}/mcp ({tool_count} tools)"
|
|
124
|
+
)
|
|
125
|
+
formatter.rich.info("Press Ctrl+C to stop.")
|
|
126
|
+
await server.run_http(host=host, port=port)
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# Tailscale Funnel mode
|
|
130
|
+
from tescmd.telemetry.tailscale import TailscaleManager
|
|
131
|
+
|
|
132
|
+
ts = TailscaleManager()
|
|
133
|
+
await ts.check_available()
|
|
134
|
+
await ts.check_running()
|
|
135
|
+
|
|
136
|
+
if formatter.format != "json":
|
|
137
|
+
formatter.rich.info(f"MCP server starting on port {port} ({tool_count} tools)")
|
|
138
|
+
|
|
139
|
+
url = await ts.start_funnel(port)
|
|
140
|
+
public_url = f"{url}/mcp"
|
|
141
|
+
|
|
142
|
+
if formatter.format != "json":
|
|
143
|
+
formatter.rich.info(f"Tailscale Funnel active: {public_url}")
|
|
144
|
+
formatter.rich.info("Press Ctrl+C to stop.")
|
|
145
|
+
else:
|
|
146
|
+
print(f'{{"url": "{public_url}"}}', file=sys.stderr)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
await server.run_http(host=host, port=port, public_url=url)
|
|
150
|
+
finally:
|
|
151
|
+
await ts.stop_funnel()
|
|
152
|
+
if formatter.format != "json":
|
|
153
|
+
formatter.rich.info("Tailscale Funnel stopped.")
|
tescmd/cli/nav.py
CHANGED
|
@@ -176,7 +176,9 @@ async def _cmd_homelink(
|
|
|
176
176
|
if lat is None or lon is None:
|
|
177
177
|
client, vehicle_api = get_vehicle_api(app_ctx)
|
|
178
178
|
try:
|
|
179
|
-
vdata = await cached_vehicle_data(
|
|
179
|
+
vdata = await cached_vehicle_data(
|
|
180
|
+
app_ctx, vehicle_api, vin, endpoints=["location_data"]
|
|
181
|
+
)
|
|
180
182
|
finally:
|
|
181
183
|
await client.close()
|
|
182
184
|
|
tescmd/cli/openclaw.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""CLI commands for OpenClaw integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import random
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from tescmd._internal.async_utils import run_async
|
|
11
|
+
from tescmd.cli._client import require_vin
|
|
12
|
+
from tescmd.cli._options import global_options
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
openclaw_group = click.Group("openclaw", help="OpenClaw integration commands.")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@openclaw_group.command("bridge")
|
|
20
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
21
|
+
@click.option(
|
|
22
|
+
"--gateway", default=None, help="Gateway WebSocket URL (default: ws://127.0.0.1:18789)"
|
|
23
|
+
)
|
|
24
|
+
@click.option(
|
|
25
|
+
"--token",
|
|
26
|
+
default=None,
|
|
27
|
+
envvar="OPENCLAW_GATEWAY_TOKEN",
|
|
28
|
+
help="Gateway auth token (env: OPENCLAW_GATEWAY_TOKEN)",
|
|
29
|
+
)
|
|
30
|
+
@click.option("--config", "config_path", default=None, help="Bridge config JSON path")
|
|
31
|
+
@click.option(
|
|
32
|
+
"--port", type=int, default=None, help="Local telemetry server port (random if omitted)"
|
|
33
|
+
)
|
|
34
|
+
@click.option("--fields", default="default", help="Field preset or comma-separated names")
|
|
35
|
+
@click.option(
|
|
36
|
+
"--interval", type=int, default=None, help="Override telemetry interval for all fields"
|
|
37
|
+
)
|
|
38
|
+
@click.option("--dry-run", is_flag=True, default=False, help="Log events as JSONL without sending")
|
|
39
|
+
@global_options
|
|
40
|
+
def bridge_cmd(
|
|
41
|
+
app_ctx: object,
|
|
42
|
+
vin_positional: str | None,
|
|
43
|
+
gateway: str | None,
|
|
44
|
+
token: str | None,
|
|
45
|
+
config_path: str | None,
|
|
46
|
+
port: int | None,
|
|
47
|
+
fields: str,
|
|
48
|
+
interval: int | None,
|
|
49
|
+
dry_run: bool,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Stream Fleet Telemetry to an OpenClaw Gateway.
|
|
52
|
+
|
|
53
|
+
Starts a local WebSocket server, exposes it via Tailscale Funnel,
|
|
54
|
+
configures the vehicle to push telemetry, and bridges events to
|
|
55
|
+
an OpenClaw Gateway with delta+throttle filtering.
|
|
56
|
+
|
|
57
|
+
Requires Tailscale with Funnel enabled.
|
|
58
|
+
|
|
59
|
+
\b
|
|
60
|
+
Examples:
|
|
61
|
+
tescmd openclaw bridge 5YJ3... # default gateway (localhost:18789)
|
|
62
|
+
tescmd openclaw bridge --dry-run # log events without sending
|
|
63
|
+
tescmd openclaw bridge --gateway ws://gw.example.com:18789
|
|
64
|
+
"""
|
|
65
|
+
from tescmd.cli.main import AppContext
|
|
66
|
+
|
|
67
|
+
assert isinstance(app_ctx, AppContext)
|
|
68
|
+
run_async(
|
|
69
|
+
_cmd_bridge(
|
|
70
|
+
app_ctx,
|
|
71
|
+
vin_positional,
|
|
72
|
+
gateway,
|
|
73
|
+
token,
|
|
74
|
+
config_path,
|
|
75
|
+
port,
|
|
76
|
+
fields,
|
|
77
|
+
interval,
|
|
78
|
+
dry_run,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _cmd_bridge(
|
|
84
|
+
app_ctx: object,
|
|
85
|
+
vin_positional: str | None,
|
|
86
|
+
gateway_url: str | None,
|
|
87
|
+
gateway_token: str | None,
|
|
88
|
+
config_path: str | None,
|
|
89
|
+
port: int | None,
|
|
90
|
+
fields_spec: str,
|
|
91
|
+
interval_override: int | None,
|
|
92
|
+
dry_run: bool,
|
|
93
|
+
) -> None:
|
|
94
|
+
from tescmd.cli.main import AppContext
|
|
95
|
+
from tescmd.openclaw.bridge import build_openclaw_pipeline
|
|
96
|
+
from tescmd.openclaw.config import BridgeConfig
|
|
97
|
+
from tescmd.telemetry.fields import resolve_fields
|
|
98
|
+
from tescmd.telemetry.setup import telemetry_session
|
|
99
|
+
|
|
100
|
+
assert isinstance(app_ctx, AppContext)
|
|
101
|
+
formatter = app_ctx.formatter
|
|
102
|
+
vin = require_vin(vin_positional, app_ctx.vin)
|
|
103
|
+
|
|
104
|
+
if port is None:
|
|
105
|
+
port = random.randint(49152, 65534)
|
|
106
|
+
|
|
107
|
+
field_config = resolve_fields(fields_spec, interval_override)
|
|
108
|
+
|
|
109
|
+
# Load bridge config
|
|
110
|
+
config = BridgeConfig.load(config_path)
|
|
111
|
+
config = config.merge_overrides(
|
|
112
|
+
gateway_url=gateway_url,
|
|
113
|
+
gateway_token=gateway_token,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Build pipeline via shared factory
|
|
117
|
+
from tescmd.triggers.manager import TriggerManager
|
|
118
|
+
|
|
119
|
+
trigger_manager = TriggerManager(vin=vin)
|
|
120
|
+
pipeline = build_openclaw_pipeline(
|
|
121
|
+
config, vin, app_ctx, trigger_manager=trigger_manager, dry_run=dry_run
|
|
122
|
+
)
|
|
123
|
+
gw = pipeline.gateway
|
|
124
|
+
bridge = pipeline.bridge
|
|
125
|
+
|
|
126
|
+
# Build fanout with the OpenClaw bridge as the primary sink
|
|
127
|
+
from tescmd.telemetry.fanout import FrameFanout
|
|
128
|
+
|
|
129
|
+
fanout = FrameFanout()
|
|
130
|
+
fanout.add_sink(bridge.on_frame)
|
|
131
|
+
|
|
132
|
+
# Register trigger push callback — sends notifications to gateway
|
|
133
|
+
push_cb = bridge.make_trigger_push_callback()
|
|
134
|
+
if push_cb is not None:
|
|
135
|
+
trigger_manager.add_on_fire(push_cb)
|
|
136
|
+
|
|
137
|
+
# Connect to gateway (unless dry-run)
|
|
138
|
+
if not dry_run:
|
|
139
|
+
if formatter.format != "json":
|
|
140
|
+
formatter.rich.info(f"Connecting to OpenClaw Gateway: {config.gateway_url}")
|
|
141
|
+
await gw.connect_with_backoff(max_attempts=5)
|
|
142
|
+
if formatter.format != "json":
|
|
143
|
+
formatter.rich.info("[green]Connected to gateway.[/green]")
|
|
144
|
+
else:
|
|
145
|
+
if formatter.format != "json":
|
|
146
|
+
formatter.rich.info("[yellow]Dry-run mode — events will be logged as JSONL.[/yellow]")
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
async with telemetry_session(
|
|
150
|
+
app_ctx, vin, port, field_config, fanout.on_frame, interactive=False
|
|
151
|
+
):
|
|
152
|
+
if formatter.format != "json":
|
|
153
|
+
formatter.rich.info(f"Bridge running: telemetry → {config.gateway_url}")
|
|
154
|
+
|
|
155
|
+
if formatter.format != "json":
|
|
156
|
+
formatter.rich.info("Press Ctrl+C to stop.")
|
|
157
|
+
formatter.rich.info("")
|
|
158
|
+
|
|
159
|
+
from tescmd.cli.serve import _wait_for_interrupt
|
|
160
|
+
|
|
161
|
+
await _wait_for_interrupt()
|
|
162
|
+
|
|
163
|
+
if formatter.format != "json":
|
|
164
|
+
formatter.rich.info(
|
|
165
|
+
f"\n[dim]Events sent: {bridge.event_count}, dropped: {bridge.drop_count}[/dim]"
|
|
166
|
+
)
|
|
167
|
+
finally:
|
|
168
|
+
await bridge.send_disconnecting()
|
|
169
|
+
await gw.close()
|
tescmd/cli/security.py
CHANGED
|
@@ -133,7 +133,13 @@ def valet_reset_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
|
133
133
|
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
134
134
|
@global_options
|
|
135
135
|
def remote_start_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
|
|
136
|
-
"""Enable
|
|
136
|
+
"""Enable keyless driving for 2 minutes.
|
|
137
|
+
|
|
138
|
+
\b
|
|
139
|
+
Allows starting the vehicle without a key card or phone key present.
|
|
140
|
+
The driver must press the brake pedal and shift into gear within 2
|
|
141
|
+
minutes or keyless driving is disabled again.
|
|
142
|
+
"""
|
|
137
143
|
run_async(
|
|
138
144
|
execute_command(
|
|
139
145
|
app_ctx,
|