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/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,
|