tescmd 0.1.2__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 (90) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +49 -5
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +13 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/api/vehicle.py +19 -1
  7. tescmd/auth/oauth.py +5 -1
  8. tescmd/auth/server.py +6 -1
  9. tescmd/auth/token_store.py +8 -1
  10. tescmd/cache/response_cache.py +11 -3
  11. tescmd/cli/_client.py +142 -20
  12. tescmd/cli/_options.py +2 -4
  13. tescmd/cli/auth.py +121 -11
  14. tescmd/cli/energy.py +2 -0
  15. tescmd/cli/key.py +149 -14
  16. tescmd/cli/main.py +70 -7
  17. tescmd/cli/mcp_cmd.py +153 -0
  18. tescmd/cli/nav.py +3 -1
  19. tescmd/cli/openclaw.py +169 -0
  20. tescmd/cli/security.py +7 -1
  21. tescmd/cli/serve.py +923 -0
  22. tescmd/cli/setup.py +244 -25
  23. tescmd/cli/sharing.py +2 -0
  24. tescmd/cli/status.py +1 -1
  25. tescmd/cli/trunk.py +8 -17
  26. tescmd/cli/user.py +16 -1
  27. tescmd/cli/vehicle.py +156 -20
  28. tescmd/crypto/__init__.py +3 -1
  29. tescmd/crypto/ecdh.py +9 -0
  30. tescmd/crypto/schnorr.py +191 -0
  31. tescmd/deploy/github_pages.py +8 -0
  32. tescmd/deploy/tailscale_serve.py +154 -0
  33. tescmd/mcp/__init__.py +7 -0
  34. tescmd/mcp/server.py +648 -0
  35. tescmd/models/__init__.py +0 -2
  36. tescmd/models/auth.py +24 -2
  37. tescmd/models/config.py +1 -0
  38. tescmd/models/energy.py +0 -9
  39. tescmd/openclaw/__init__.py +23 -0
  40. tescmd/openclaw/bridge.py +330 -0
  41. tescmd/openclaw/config.py +167 -0
  42. tescmd/openclaw/dispatcher.py +522 -0
  43. tescmd/openclaw/emitter.py +175 -0
  44. tescmd/openclaw/filters.py +123 -0
  45. tescmd/openclaw/gateway.py +687 -0
  46. tescmd/openclaw/telemetry_store.py +53 -0
  47. tescmd/output/rich_output.py +46 -14
  48. tescmd/protocol/commands.py +2 -2
  49. tescmd/protocol/encoder.py +16 -13
  50. tescmd/protocol/payloads.py +132 -11
  51. tescmd/protocol/session.py +18 -8
  52. tescmd/protocol/signer.py +3 -17
  53. tescmd/telemetry/__init__.py +28 -0
  54. tescmd/telemetry/cache_sink.py +154 -0
  55. tescmd/telemetry/csv_sink.py +180 -0
  56. tescmd/telemetry/dashboard.py +227 -0
  57. tescmd/telemetry/decoder.py +284 -0
  58. tescmd/telemetry/fanout.py +49 -0
  59. tescmd/telemetry/fields.py +427 -0
  60. tescmd/telemetry/flatbuf.py +162 -0
  61. tescmd/telemetry/mapper.py +239 -0
  62. tescmd/telemetry/protos/__init__.py +4 -0
  63. tescmd/telemetry/protos/vehicle_alert.proto +31 -0
  64. tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
  65. tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
  66. tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
  67. tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
  68. tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
  69. tescmd/telemetry/protos/vehicle_data.proto +768 -0
  70. tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
  71. tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
  72. tescmd/telemetry/protos/vehicle_error.proto +23 -0
  73. tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
  74. tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
  75. tescmd/telemetry/protos/vehicle_metric.proto +22 -0
  76. tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
  77. tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
  78. tescmd/telemetry/server.py +300 -0
  79. tescmd/telemetry/setup.py +468 -0
  80. tescmd/telemetry/tailscale.py +300 -0
  81. tescmd/telemetry/tui.py +1716 -0
  82. tescmd/triggers/__init__.py +18 -0
  83. tescmd/triggers/manager.py +264 -0
  84. tescmd/triggers/models.py +93 -0
  85. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
  86. tescmd-0.3.1.dist-info/RECORD +128 -0
  87. tescmd-0.1.2.dist-info/RECORD +0 -81
  88. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
  89. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
  90. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
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,