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/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("--port", type=int, default=8085, help="Local callback port")
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
- token_data = await refresh_access_token(
271
- refresh_token=rt,
272
- client_id=settings.client_id,
273
- client_secret=settings.client_secret,
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
- info(
511
- " [dim]For telemetry streaming, add your Tailscale hostname"
512
- " as an additional origin:[/dim]"
513
- )
514
- info(" [dim] https://<machine>.tailnet.ts.net[/dim]")
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(" - Local key pair is corrupted")
568
- formatter.rich.info(" - Key was re-generated but not re-enrolled")
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 again, or if the problem persists:")
571
- formatter.rich.info(" [cyan]tescmd key generate --force[/cyan] (regenerate key pair)")
572
- formatter.rich.info(" [cyan]tescmd key deploy[/cyan] (re-deploy public key)")
573
- formatter.rich.info(" [cyan]tescmd key enroll[/cyan] (re-enroll on vehicle)")
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("Install telemetry deps: [cyan]pip install tescmd[telemetry][/cyan]")
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(app_ctx, vehicle_api, vin, endpoints=["drive_state"])
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 remote start."""
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,