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/serve.py
ADDED
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
"""Unified ``tescmd serve`` command — MCP + telemetry cache warming + optional OpenClaw."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import random
|
|
7
|
+
import signal
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from tescmd._internal.async_utils import run_async
|
|
16
|
+
from tescmd.cli._client import require_vin
|
|
17
|
+
from tescmd.cli._options import global_options
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.command("serve")
|
|
23
|
+
@click.argument("vin_positional", required=False, default=None, metavar="VIN")
|
|
24
|
+
@click.option(
|
|
25
|
+
"--transport",
|
|
26
|
+
type=click.Choice(["stdio", "streamable-http"]),
|
|
27
|
+
default="streamable-http",
|
|
28
|
+
help="MCP transport (default: streamable-http)",
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"--port",
|
|
32
|
+
type=int,
|
|
33
|
+
default=8080,
|
|
34
|
+
envvar="TESCMD_MCP_PORT",
|
|
35
|
+
help="MCP HTTP port (streamable-http only)",
|
|
36
|
+
)
|
|
37
|
+
@click.option(
|
|
38
|
+
"--host",
|
|
39
|
+
default="127.0.0.1",
|
|
40
|
+
envvar="TESCMD_HOST",
|
|
41
|
+
help="Bind address (default: 127.0.0.1)",
|
|
42
|
+
)
|
|
43
|
+
@click.option(
|
|
44
|
+
"--telemetry-port",
|
|
45
|
+
type=int,
|
|
46
|
+
default=None,
|
|
47
|
+
help="WebSocket port for telemetry (random if omitted)",
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--fields",
|
|
51
|
+
default="default",
|
|
52
|
+
help="Telemetry field preset or comma-separated names",
|
|
53
|
+
)
|
|
54
|
+
@click.option(
|
|
55
|
+
"--interval",
|
|
56
|
+
type=int,
|
|
57
|
+
default=None,
|
|
58
|
+
help="Override telemetry interval for all fields",
|
|
59
|
+
)
|
|
60
|
+
@click.option(
|
|
61
|
+
"--no-telemetry",
|
|
62
|
+
is_flag=True,
|
|
63
|
+
default=False,
|
|
64
|
+
help="MCP-only mode — skip telemetry and cache warming",
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--no-mcp",
|
|
68
|
+
is_flag=True,
|
|
69
|
+
default=False,
|
|
70
|
+
help="Telemetry-only mode — skip MCP server",
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--no-log",
|
|
74
|
+
is_flag=True,
|
|
75
|
+
default=False,
|
|
76
|
+
help="Disable CSV telemetry log (enabled by default when telemetry active)",
|
|
77
|
+
)
|
|
78
|
+
@click.option(
|
|
79
|
+
"--legacy-dashboard",
|
|
80
|
+
is_flag=True,
|
|
81
|
+
default=False,
|
|
82
|
+
help="Use the legacy Rich Live dashboard instead of the full-screen TUI",
|
|
83
|
+
)
|
|
84
|
+
@click.option(
|
|
85
|
+
"--openclaw",
|
|
86
|
+
"openclaw_url",
|
|
87
|
+
default=None,
|
|
88
|
+
help="Also bridge to an OpenClaw gateway (ws://...)",
|
|
89
|
+
)
|
|
90
|
+
@click.option(
|
|
91
|
+
"--openclaw-token",
|
|
92
|
+
default=None,
|
|
93
|
+
envvar="OPENCLAW_GATEWAY_TOKEN",
|
|
94
|
+
help="OpenClaw gateway auth token (env: OPENCLAW_GATEWAY_TOKEN)",
|
|
95
|
+
)
|
|
96
|
+
@click.option(
|
|
97
|
+
"--openclaw-config",
|
|
98
|
+
"openclaw_config_path",
|
|
99
|
+
type=click.Path(exists=True),
|
|
100
|
+
default=None,
|
|
101
|
+
help="OpenClaw bridge config file (JSON)",
|
|
102
|
+
)
|
|
103
|
+
@click.option(
|
|
104
|
+
"--dry-run",
|
|
105
|
+
is_flag=True,
|
|
106
|
+
default=False,
|
|
107
|
+
help="OpenClaw dry-run: log events as JSONL instead of sending",
|
|
108
|
+
)
|
|
109
|
+
@click.option("--tailscale", is_flag=True, default=False, help="Expose MCP via Tailscale Funnel")
|
|
110
|
+
@click.option(
|
|
111
|
+
"--client-id",
|
|
112
|
+
envvar="TESCMD_MCP_CLIENT_ID",
|
|
113
|
+
default=None,
|
|
114
|
+
help="MCP client ID (env: TESCMD_MCP_CLIENT_ID)",
|
|
115
|
+
)
|
|
116
|
+
@click.option(
|
|
117
|
+
"--client-secret",
|
|
118
|
+
envvar="TESCMD_MCP_CLIENT_SECRET",
|
|
119
|
+
default=None,
|
|
120
|
+
help="MCP client secret / bearer token (env: TESCMD_MCP_CLIENT_SECRET)",
|
|
121
|
+
)
|
|
122
|
+
@global_options
|
|
123
|
+
def serve_cmd(
|
|
124
|
+
app_ctx: object,
|
|
125
|
+
vin_positional: str | None,
|
|
126
|
+
transport: str,
|
|
127
|
+
port: int,
|
|
128
|
+
host: str,
|
|
129
|
+
telemetry_port: int | None,
|
|
130
|
+
fields: str,
|
|
131
|
+
interval: int | None,
|
|
132
|
+
no_telemetry: bool,
|
|
133
|
+
no_mcp: bool,
|
|
134
|
+
no_log: bool,
|
|
135
|
+
legacy_dashboard: bool,
|
|
136
|
+
openclaw_url: str | None,
|
|
137
|
+
openclaw_token: str | None,
|
|
138
|
+
openclaw_config_path: str | None,
|
|
139
|
+
dry_run: bool,
|
|
140
|
+
tailscale: bool,
|
|
141
|
+
client_id: str | None,
|
|
142
|
+
client_secret: str | None,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Start a unified MCP + telemetry server.
|
|
145
|
+
|
|
146
|
+
Combines the MCP server with telemetry-driven cache warming so that
|
|
147
|
+
agent reads are free while telemetry is active. Optionally bridges
|
|
148
|
+
to an OpenClaw gateway.
|
|
149
|
+
|
|
150
|
+
When telemetry is active on a TTY, a full-screen dashboard shows live
|
|
151
|
+
data, server info, and operational metadata. A wide-format CSV log
|
|
152
|
+
is written by default (disable with --no-log).
|
|
153
|
+
|
|
154
|
+
\b
|
|
155
|
+
Modes:
|
|
156
|
+
Default MCP + telemetry cache warming + TUI dashboard
|
|
157
|
+
--no-telemetry MCP-only (same as 'tescmd mcp serve')
|
|
158
|
+
--no-mcp Telemetry-only (dashboard or JSONL)
|
|
159
|
+
--openclaw URL Also bridge telemetry to OpenClaw
|
|
160
|
+
|
|
161
|
+
\b
|
|
162
|
+
Examples:
|
|
163
|
+
tescmd serve 5YJ3... # MCP + cache warming
|
|
164
|
+
tescmd serve --no-telemetry # MCP only
|
|
165
|
+
tescmd serve 5YJ3... --no-mcp # Telemetry dashboard only
|
|
166
|
+
tescmd serve --openclaw ws://gw.example.com:18789 # MCP + cache + OpenClaw
|
|
167
|
+
tescmd serve --transport stdio # stdio for Claude Desktop
|
|
168
|
+
tescmd serve --legacy-dashboard # Use Rich Live dashboard
|
|
169
|
+
"""
|
|
170
|
+
from tescmd.cli.main import AppContext
|
|
171
|
+
|
|
172
|
+
assert isinstance(app_ctx, AppContext)
|
|
173
|
+
|
|
174
|
+
# -- Validation --
|
|
175
|
+
if no_mcp and no_telemetry:
|
|
176
|
+
raise click.UsageError("--no-mcp and --no-telemetry cannot both be set (nothing to run).")
|
|
177
|
+
|
|
178
|
+
if no_mcp and transport == "stdio":
|
|
179
|
+
raise click.UsageError(
|
|
180
|
+
"--no-mcp cannot be used with --transport stdio (stdio is MCP-only)."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if not no_mcp and (not client_id or not client_secret):
|
|
184
|
+
raise click.UsageError(
|
|
185
|
+
"MCP client credentials required.\n"
|
|
186
|
+
"Set TESCMD_MCP_CLIENT_ID and TESCMD_MCP_CLIENT_SECRET "
|
|
187
|
+
"in your .env file or environment, or pass --client-id and --client-secret.\n"
|
|
188
|
+
"Use --no-mcp to run in telemetry-only mode without credentials."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if tailscale and transport == "stdio":
|
|
192
|
+
raise click.UsageError("--tailscale cannot be used with --transport stdio")
|
|
193
|
+
|
|
194
|
+
if openclaw_url and no_telemetry:
|
|
195
|
+
raise click.UsageError("--openclaw requires telemetry. Remove --no-telemetry.")
|
|
196
|
+
|
|
197
|
+
if dry_run and not openclaw_url:
|
|
198
|
+
raise click.UsageError("--dry-run requires --openclaw.")
|
|
199
|
+
|
|
200
|
+
if openclaw_config_path and not openclaw_url:
|
|
201
|
+
raise click.UsageError("--openclaw-config requires --openclaw.")
|
|
202
|
+
|
|
203
|
+
run_async(
|
|
204
|
+
_cmd_serve(
|
|
205
|
+
app_ctx,
|
|
206
|
+
vin_positional=vin_positional,
|
|
207
|
+
transport=transport,
|
|
208
|
+
mcp_port=port,
|
|
209
|
+
mcp_host=host,
|
|
210
|
+
telemetry_port=telemetry_port,
|
|
211
|
+
fields_spec=fields,
|
|
212
|
+
interval_override=interval,
|
|
213
|
+
no_telemetry=no_telemetry,
|
|
214
|
+
no_mcp=no_mcp,
|
|
215
|
+
no_log=no_log,
|
|
216
|
+
legacy_dashboard=legacy_dashboard,
|
|
217
|
+
openclaw_url=openclaw_url,
|
|
218
|
+
openclaw_token=openclaw_token,
|
|
219
|
+
openclaw_config_path=openclaw_config_path,
|
|
220
|
+
dry_run=dry_run,
|
|
221
|
+
tailscale=tailscale,
|
|
222
|
+
client_id=client_id or "",
|
|
223
|
+
client_secret=client_secret or "",
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def _cmd_serve(
|
|
229
|
+
app_ctx: object,
|
|
230
|
+
*,
|
|
231
|
+
vin_positional: str | None,
|
|
232
|
+
transport: str,
|
|
233
|
+
mcp_port: int,
|
|
234
|
+
mcp_host: str = "127.0.0.1",
|
|
235
|
+
telemetry_port: int | None,
|
|
236
|
+
fields_spec: str,
|
|
237
|
+
interval_override: int | None,
|
|
238
|
+
no_telemetry: bool,
|
|
239
|
+
no_mcp: bool,
|
|
240
|
+
no_log: bool,
|
|
241
|
+
legacy_dashboard: bool,
|
|
242
|
+
openclaw_url: str | None,
|
|
243
|
+
openclaw_token: str | None,
|
|
244
|
+
openclaw_config_path: str | None,
|
|
245
|
+
dry_run: bool,
|
|
246
|
+
tailscale: bool,
|
|
247
|
+
client_id: str,
|
|
248
|
+
client_secret: str,
|
|
249
|
+
) -> None:
|
|
250
|
+
import asyncio
|
|
251
|
+
import contextlib
|
|
252
|
+
import json
|
|
253
|
+
import sys
|
|
254
|
+
|
|
255
|
+
from tescmd.cli.main import AppContext
|
|
256
|
+
from tescmd.telemetry.fanout import FrameFanout
|
|
257
|
+
|
|
258
|
+
assert isinstance(app_ctx, AppContext)
|
|
259
|
+
formatter = app_ctx.formatter
|
|
260
|
+
is_rich = formatter.format != "json"
|
|
261
|
+
|
|
262
|
+
is_tty = sys.stdin.isatty() and transport != "stdio"
|
|
263
|
+
interactive = is_tty
|
|
264
|
+
|
|
265
|
+
# -- MCP server setup (unless --no-mcp) --
|
|
266
|
+
mcp_server = None
|
|
267
|
+
tool_count = 0
|
|
268
|
+
if not no_mcp:
|
|
269
|
+
from tescmd.mcp.server import create_mcp_server
|
|
270
|
+
|
|
271
|
+
mcp_server = create_mcp_server(client_id=client_id, client_secret=client_secret)
|
|
272
|
+
tool_count = len(mcp_server.list_tools())
|
|
273
|
+
|
|
274
|
+
# -- stdio mode: no telemetry, no fanout --
|
|
275
|
+
if transport == "stdio" and mcp_server is not None:
|
|
276
|
+
print(f"tescmd serve starting (stdio, {tool_count} tools)", file=sys.stderr)
|
|
277
|
+
await mcp_server.run_stdio()
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
# -- Build telemetry fanout --
|
|
281
|
+
fanout = FrameFanout()
|
|
282
|
+
cache_sink = None
|
|
283
|
+
csv_sink = None
|
|
284
|
+
gw = None
|
|
285
|
+
oc_bridge = None
|
|
286
|
+
dashboard = None
|
|
287
|
+
tui = None
|
|
288
|
+
trigger_manager = None
|
|
289
|
+
vin: str | None = None
|
|
290
|
+
field_config: dict[str, dict[str, int]] | None = None
|
|
291
|
+
|
|
292
|
+
if not no_telemetry:
|
|
293
|
+
from tescmd.cli._client import get_cache
|
|
294
|
+
from tescmd.telemetry.cache_sink import CacheSink
|
|
295
|
+
from tescmd.telemetry.fields import resolve_fields
|
|
296
|
+
from tescmd.telemetry.mapper import TelemetryMapper
|
|
297
|
+
|
|
298
|
+
vin = require_vin(vin_positional, app_ctx.vin)
|
|
299
|
+
|
|
300
|
+
from tescmd.triggers.manager import TriggerManager
|
|
301
|
+
|
|
302
|
+
trigger_manager = TriggerManager(vin=vin)
|
|
303
|
+
|
|
304
|
+
if telemetry_port is None:
|
|
305
|
+
telemetry_port = random.randint(49152, 65534)
|
|
306
|
+
|
|
307
|
+
field_config = resolve_fields(fields_spec, interval_override)
|
|
308
|
+
|
|
309
|
+
# Cache sink — warms the response cache from telemetry
|
|
310
|
+
cache = get_cache(app_ctx)
|
|
311
|
+
mapper = TelemetryMapper()
|
|
312
|
+
cache_sink = CacheSink(cache, mapper, vin)
|
|
313
|
+
fanout.add_sink(cache_sink.on_frame)
|
|
314
|
+
|
|
315
|
+
if is_rich:
|
|
316
|
+
formatter.rich.info(f"Cache warming enabled for {vin}")
|
|
317
|
+
|
|
318
|
+
# CSV log sink — wide-format telemetry log (default on)
|
|
319
|
+
if not no_log:
|
|
320
|
+
from tescmd.telemetry.csv_sink import CSVLogSink, create_log_path
|
|
321
|
+
|
|
322
|
+
csv_path = create_log_path(vin)
|
|
323
|
+
csv_sink = CSVLogSink(csv_path, vin=vin)
|
|
324
|
+
fanout.add_sink(csv_sink.on_frame)
|
|
325
|
+
|
|
326
|
+
if is_rich:
|
|
327
|
+
formatter.rich.info(f"CSV log: {csv_path}")
|
|
328
|
+
|
|
329
|
+
# Display sink: TUI (default) / legacy Rich.Live dashboard / JSONL
|
|
330
|
+
if interactive and is_rich:
|
|
331
|
+
if legacy_dashboard:
|
|
332
|
+
# Legacy Rich Live dashboard (fallback)
|
|
333
|
+
from tescmd.telemetry.dashboard import TelemetryDashboard
|
|
334
|
+
|
|
335
|
+
dashboard = TelemetryDashboard(formatter.console, formatter.rich._units)
|
|
336
|
+
|
|
337
|
+
async def _dashboard_on_frame(frame: object) -> None:
|
|
338
|
+
from tescmd.telemetry.decoder import TelemetryFrame
|
|
339
|
+
|
|
340
|
+
assert isinstance(frame, TelemetryFrame)
|
|
341
|
+
assert dashboard is not None
|
|
342
|
+
dashboard.update(frame)
|
|
343
|
+
|
|
344
|
+
fanout.add_sink(_dashboard_on_frame)
|
|
345
|
+
else:
|
|
346
|
+
# Full-screen Textual TUI (new default)
|
|
347
|
+
from tescmd.telemetry.tui import TelemetryTUI
|
|
348
|
+
|
|
349
|
+
tui = TelemetryTUI(
|
|
350
|
+
formatter.rich._units,
|
|
351
|
+
vin=vin,
|
|
352
|
+
telemetry_port=telemetry_port,
|
|
353
|
+
)
|
|
354
|
+
fanout.add_sink(tui.push_frame)
|
|
355
|
+
elif no_mcp:
|
|
356
|
+
# JSONL output when in telemetry-only piped mode
|
|
357
|
+
async def _jsonl_sink(frame: object) -> None:
|
|
358
|
+
from tescmd.telemetry.decoder import TelemetryFrame
|
|
359
|
+
|
|
360
|
+
assert isinstance(frame, TelemetryFrame)
|
|
361
|
+
line = json.dumps(
|
|
362
|
+
{
|
|
363
|
+
"vin": frame.vin,
|
|
364
|
+
"timestamp": frame.created_at.isoformat(),
|
|
365
|
+
"data": {d.field_name: d.value for d in frame.data},
|
|
366
|
+
},
|
|
367
|
+
default=str,
|
|
368
|
+
)
|
|
369
|
+
print(line, flush=True)
|
|
370
|
+
|
|
371
|
+
fanout.add_sink(_jsonl_sink)
|
|
372
|
+
|
|
373
|
+
# OpenClaw sink — optional bridge to an OpenClaw gateway
|
|
374
|
+
if openclaw_url:
|
|
375
|
+
from pathlib import Path
|
|
376
|
+
|
|
377
|
+
from tescmd.openclaw.bridge import build_openclaw_pipeline
|
|
378
|
+
from tescmd.openclaw.config import BridgeConfig
|
|
379
|
+
|
|
380
|
+
if openclaw_config_path:
|
|
381
|
+
config = BridgeConfig.load(Path(openclaw_config_path))
|
|
382
|
+
else:
|
|
383
|
+
config = BridgeConfig.load()
|
|
384
|
+
config = config.merge_overrides(
|
|
385
|
+
gateway_url=openclaw_url,
|
|
386
|
+
gateway_token=openclaw_token,
|
|
387
|
+
)
|
|
388
|
+
oc_pipeline = build_openclaw_pipeline(
|
|
389
|
+
config, vin, app_ctx, trigger_manager=trigger_manager, dry_run=dry_run
|
|
390
|
+
)
|
|
391
|
+
gw = oc_pipeline.gateway
|
|
392
|
+
oc_bridge = oc_pipeline.bridge
|
|
393
|
+
|
|
394
|
+
# Push trigger notifications to gateway
|
|
395
|
+
if trigger_manager is not None:
|
|
396
|
+
push_cb = oc_bridge.make_trigger_push_callback()
|
|
397
|
+
if push_cb is not None:
|
|
398
|
+
trigger_manager.add_on_fire(push_cb)
|
|
399
|
+
|
|
400
|
+
if not dry_run:
|
|
401
|
+
if is_rich:
|
|
402
|
+
formatter.rich.info(f"Connecting to OpenClaw Gateway: {config.gateway_url}")
|
|
403
|
+
await gw.connect_with_backoff(max_attempts=5)
|
|
404
|
+
if is_rich:
|
|
405
|
+
formatter.rich.info("[green]Connected to OpenClaw gateway.[/green]")
|
|
406
|
+
else:
|
|
407
|
+
if is_rich:
|
|
408
|
+
formatter.rich.info(
|
|
409
|
+
"[yellow]Dry-run mode — events will be logged as JSONL to stderr.[/yellow]"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# Register sink AFTER gateway is connected (or dry-run confirmed)
|
|
413
|
+
# so early telemetry frames aren't silently dropped.
|
|
414
|
+
fanout.add_sink(oc_bridge.on_frame)
|
|
415
|
+
|
|
416
|
+
# Lightweight trigger sink — evaluates triggers when there is no
|
|
417
|
+
# OpenClaw bridge (which handles evaluation itself).
|
|
418
|
+
if trigger_manager is not None and not openclaw_url:
|
|
419
|
+
from tescmd.openclaw.telemetry_store import TelemetryStore as _TStore
|
|
420
|
+
|
|
421
|
+
_trigger_store = _TStore()
|
|
422
|
+
|
|
423
|
+
async def _trigger_sink(frame: object) -> None:
|
|
424
|
+
from tescmd.telemetry.decoder import TelemetryFrame
|
|
425
|
+
|
|
426
|
+
assert isinstance(frame, TelemetryFrame)
|
|
427
|
+
assert trigger_manager is not None
|
|
428
|
+
for datum in frame.data:
|
|
429
|
+
prev_snap = _trigger_store.get(datum.field_name)
|
|
430
|
+
prev_value = prev_snap.value if prev_snap is not None else None
|
|
431
|
+
_trigger_store.update(datum.field_name, datum.value, frame.created_at)
|
|
432
|
+
await trigger_manager.evaluate(
|
|
433
|
+
datum.field_name, datum.value, prev_value, frame.created_at
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
fanout.add_sink(_trigger_sink)
|
|
437
|
+
|
|
438
|
+
# -- Register MCP trigger tools (when both MCP and telemetry are active) --
|
|
439
|
+
if mcp_server is not None and trigger_manager is not None:
|
|
440
|
+
_register_trigger_tools(mcp_server, trigger_manager)
|
|
441
|
+
tool_count = len(mcp_server.list_tools())
|
|
442
|
+
|
|
443
|
+
# -- Tailscale Funnel setup (optional, MCP-only mode) --
|
|
444
|
+
public_url: str | None = None
|
|
445
|
+
ts = None
|
|
446
|
+
if tailscale and no_telemetry and not no_mcp:
|
|
447
|
+
# MCP-only with --tailscale: funnel directly to MCP port.
|
|
448
|
+
# When telemetry is active the funnel is managed by
|
|
449
|
+
# telemetry_session (pointing to the combined app port).
|
|
450
|
+
from tescmd.telemetry.tailscale import TailscaleManager
|
|
451
|
+
|
|
452
|
+
ts = TailscaleManager()
|
|
453
|
+
await ts.check_available()
|
|
454
|
+
await ts.check_running()
|
|
455
|
+
funnel_url = await ts.start_funnel(mcp_port)
|
|
456
|
+
public_url = funnel_url
|
|
457
|
+
|
|
458
|
+
if is_rich:
|
|
459
|
+
formatter.rich.info(f"Tailscale Funnel active: {funnel_url}/mcp")
|
|
460
|
+
else:
|
|
461
|
+
print(f'{{"url": "{funnel_url}/mcp"}}', file=sys.stderr)
|
|
462
|
+
|
|
463
|
+
# -- Combined mode: pre-determine tunnel hostname for MCP public_url --
|
|
464
|
+
# When both MCP and telemetry are active, telemetry_session will start
|
|
465
|
+
# a Tailscale Funnel. We need the hostname NOW so the MCP app's auth
|
|
466
|
+
# settings (issuer_url) are correct before the app is built.
|
|
467
|
+
if not no_telemetry and not no_mcp and public_url is None:
|
|
468
|
+
try:
|
|
469
|
+
from tescmd.telemetry.tailscale import TailscaleManager
|
|
470
|
+
|
|
471
|
+
_ts_pre = TailscaleManager()
|
|
472
|
+
await _ts_pre.check_available()
|
|
473
|
+
await _ts_pre.check_running()
|
|
474
|
+
_pre_hostname = await _ts_pre.get_hostname()
|
|
475
|
+
public_url = f"https://{_pre_hostname}"
|
|
476
|
+
except Exception:
|
|
477
|
+
logger.warning("Tailscale auto-detection failed — using localhost")
|
|
478
|
+
|
|
479
|
+
# -- Populate TUI with server info --
|
|
480
|
+
if tui is not None:
|
|
481
|
+
mcp_url = ""
|
|
482
|
+
if not no_mcp:
|
|
483
|
+
mcp_url = f"{public_url}/mcp" if public_url else f"http://{mcp_host}:{mcp_port}/mcp"
|
|
484
|
+
tui.set_mcp_url(mcp_url)
|
|
485
|
+
if public_url:
|
|
486
|
+
tui.set_tunnel_url(public_url)
|
|
487
|
+
tui.set_sink_count(fanout.sink_count)
|
|
488
|
+
if csv_sink is not None:
|
|
489
|
+
tui.set_log_path(csv_sink.log_path)
|
|
490
|
+
|
|
491
|
+
# -- Start everything --
|
|
492
|
+
if not no_mcp and is_rich and tui is None:
|
|
493
|
+
base_url = f"{public_url}/mcp" if public_url else f"http://{mcp_host}:{mcp_port}/mcp"
|
|
494
|
+
formatter.rich.info(
|
|
495
|
+
f"MCP server starting on {base_url} ({tool_count} tools, "
|
|
496
|
+
f"{fanout.sink_count} telemetry sink(s))"
|
|
497
|
+
)
|
|
498
|
+
if (not no_mcp or not no_telemetry) and is_rich and tui is None:
|
|
499
|
+
formatter.rich.info("Press Ctrl+C to stop.")
|
|
500
|
+
|
|
501
|
+
# -- SIGTERM handler for graceful container/systemd shutdown --
|
|
502
|
+
shutdown_event = asyncio.Event()
|
|
503
|
+
|
|
504
|
+
def _handle_sigterm() -> None:
|
|
505
|
+
logger.info("SIGTERM received — shutting down gracefully")
|
|
506
|
+
shutdown_event.set()
|
|
507
|
+
|
|
508
|
+
loop = asyncio.get_running_loop()
|
|
509
|
+
# Only add signal handler on Unix (Windows doesn't support loop.add_signal_handler)
|
|
510
|
+
if hasattr(signal, "SIGTERM"):
|
|
511
|
+
import contextlib
|
|
512
|
+
|
|
513
|
+
with contextlib.suppress(NotImplementedError):
|
|
514
|
+
loop.add_signal_handler(signal.SIGTERM, _handle_sigterm)
|
|
515
|
+
|
|
516
|
+
combined_task: asyncio.Task[None] | None = None
|
|
517
|
+
_uvi_server: Any = None # uvicorn.Server — for graceful shutdown
|
|
518
|
+
try:
|
|
519
|
+
if fanout.has_sinks() and vin is not None and field_config is not None:
|
|
520
|
+
from tescmd.telemetry.setup import telemetry_session
|
|
521
|
+
|
|
522
|
+
assert telemetry_port is not None
|
|
523
|
+
|
|
524
|
+
# When MCP is co-located, build a combined Starlette app
|
|
525
|
+
# that serves MCP (HTTP/auth) and telemetry (WebSocket) on
|
|
526
|
+
# the same port so a single Tailscale Funnel covers both.
|
|
527
|
+
combined_app = None
|
|
528
|
+
serve_port = telemetry_port
|
|
529
|
+
if mcp_server is not None:
|
|
530
|
+
from pathlib import Path
|
|
531
|
+
|
|
532
|
+
import uvicorn
|
|
533
|
+
|
|
534
|
+
from tescmd.crypto.keys import load_public_key_pem
|
|
535
|
+
from tescmd.models.config import AppSettings
|
|
536
|
+
|
|
537
|
+
_app_settings = AppSettings()
|
|
538
|
+
_key_dir = Path(_app_settings.config_dir).expanduser() / "keys"
|
|
539
|
+
_pub_pem = load_public_key_pem(_key_dir)
|
|
540
|
+
|
|
541
|
+
combined_app = _build_combined_app(
|
|
542
|
+
mcp_server, mcp_port, public_url, fanout.on_frame, _pub_pem
|
|
543
|
+
)
|
|
544
|
+
serve_port = mcp_port
|
|
545
|
+
|
|
546
|
+
# Start the combined app BEFORE telemetry_session so that
|
|
547
|
+
# Tesla's domain-verification HEAD requests (during partner
|
|
548
|
+
# registration inside the session) hit a running server.
|
|
549
|
+
# Keep a handle on the uvicorn.Server so we can signal a
|
|
550
|
+
# graceful shutdown (should_exit) instead of cancelling.
|
|
551
|
+
_uvi_cfg = uvicorn.Config(
|
|
552
|
+
combined_app, host=mcp_host, port=mcp_port, log_level="warning"
|
|
553
|
+
)
|
|
554
|
+
_uvi_server = uvicorn.Server(_uvi_cfg)
|
|
555
|
+
combined_task = asyncio.create_task(_uvi_server.serve())
|
|
556
|
+
# Give uvicorn a moment to bind the port.
|
|
557
|
+
await asyncio.sleep(0.5)
|
|
558
|
+
if combined_task.done():
|
|
559
|
+
exc = combined_task.exception()
|
|
560
|
+
if exc is not None:
|
|
561
|
+
raise OSError(f"Failed to start server on port {mcp_port}: {exc}") from exc
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
async with telemetry_session(
|
|
565
|
+
app_ctx,
|
|
566
|
+
vin,
|
|
567
|
+
serve_port,
|
|
568
|
+
field_config,
|
|
569
|
+
fanout.on_frame,
|
|
570
|
+
interactive=interactive,
|
|
571
|
+
skip_server=(combined_task is not None),
|
|
572
|
+
) as session:
|
|
573
|
+
if tui is not None:
|
|
574
|
+
tui.set_tunnel_url(session.tunnel_url)
|
|
575
|
+
|
|
576
|
+
if is_rich and tui is None:
|
|
577
|
+
formatter.rich.info("Telemetry pipeline active.")
|
|
578
|
+
|
|
579
|
+
if tui is not None:
|
|
580
|
+
await _race_shutdown(tui.run_async(), shutdown_event)
|
|
581
|
+
elif dashboard is not None:
|
|
582
|
+
from rich.live import Live
|
|
583
|
+
|
|
584
|
+
dashboard.set_tunnel_url(session.tunnel_url)
|
|
585
|
+
with Live(
|
|
586
|
+
dashboard,
|
|
587
|
+
console=formatter.console,
|
|
588
|
+
refresh_per_second=4,
|
|
589
|
+
) as live:
|
|
590
|
+
dashboard.set_live(live)
|
|
591
|
+
await _wait_for_interrupt(shutdown_event)
|
|
592
|
+
else:
|
|
593
|
+
await _wait_for_interrupt(shutdown_event)
|
|
594
|
+
finally:
|
|
595
|
+
# Signal uvicorn to shut down gracefully rather than
|
|
596
|
+
# cancelling (which causes a CancelledError traceback
|
|
597
|
+
# inside Starlette's lifespan handler).
|
|
598
|
+
if _uvi_server is not None:
|
|
599
|
+
_uvi_server.should_exit = True
|
|
600
|
+
if combined_task is not None and not combined_task.done():
|
|
601
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
602
|
+
await combined_task
|
|
603
|
+
elif mcp_server is not None:
|
|
604
|
+
await _race_shutdown(
|
|
605
|
+
mcp_server.run_http(host=mcp_host, port=mcp_port, public_url=public_url),
|
|
606
|
+
shutdown_event,
|
|
607
|
+
)
|
|
608
|
+
finally:
|
|
609
|
+
if ts is not None:
|
|
610
|
+
await ts.stop_funnel()
|
|
611
|
+
if is_rich:
|
|
612
|
+
formatter.rich.info("Tailscale Funnel stopped.")
|
|
613
|
+
if oc_bridge is not None:
|
|
614
|
+
await oc_bridge.send_disconnecting()
|
|
615
|
+
if gw is not None:
|
|
616
|
+
await gw.close()
|
|
617
|
+
if csv_sink is not None:
|
|
618
|
+
csv_sink.close()
|
|
619
|
+
if is_rich:
|
|
620
|
+
formatter.rich.info(
|
|
621
|
+
f"[dim]CSV log: {csv_sink.log_path} ({csv_sink.frame_count} frames)[/dim]"
|
|
622
|
+
)
|
|
623
|
+
else:
|
|
624
|
+
print(
|
|
625
|
+
f'{{"csv_log": "{csv_sink.log_path}", "frames": {csv_sink.frame_count}}}',
|
|
626
|
+
file=sys.stderr,
|
|
627
|
+
)
|
|
628
|
+
if tui is not None:
|
|
629
|
+
cmd_log = getattr(tui, "_cmd_log_path", "")
|
|
630
|
+
if cmd_log and is_rich:
|
|
631
|
+
formatter.rich.info(f"[dim]Command log: {cmd_log}[/dim]")
|
|
632
|
+
activity_log = getattr(tui, "_activity_log_path", "")
|
|
633
|
+
if activity_log and is_rich:
|
|
634
|
+
formatter.rich.info(f"[dim]Activity log: {activity_log}[/dim]")
|
|
635
|
+
if cache_sink is not None:
|
|
636
|
+
cache_sink.flush()
|
|
637
|
+
if is_rich:
|
|
638
|
+
formatter.rich.info(
|
|
639
|
+
f"[dim]Cache sink: {cache_sink.frame_count} frames, "
|
|
640
|
+
f"{cache_sink.field_count} field updates[/dim]"
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _register_trigger_tools(mcp_server: Any, trigger_manager: Any) -> None:
|
|
645
|
+
"""Register trigger CRUD tools on the MCP server."""
|
|
646
|
+
from tescmd.triggers.models import TriggerCondition, TriggerDefinition, TriggerOperator
|
|
647
|
+
|
|
648
|
+
def _handle_create(params: dict[str, Any]) -> dict[str, Any]:
|
|
649
|
+
field = params.get("field")
|
|
650
|
+
if not field:
|
|
651
|
+
raise ValueError("trigger_create requires 'field' parameter")
|
|
652
|
+
op_str = params.get("operator")
|
|
653
|
+
if not op_str:
|
|
654
|
+
raise ValueError("trigger_create requires 'operator' parameter")
|
|
655
|
+
condition = TriggerCondition(
|
|
656
|
+
field=field,
|
|
657
|
+
operator=TriggerOperator(op_str),
|
|
658
|
+
value=params.get("value"),
|
|
659
|
+
)
|
|
660
|
+
trigger = TriggerDefinition(
|
|
661
|
+
condition=condition,
|
|
662
|
+
once=params.get("once", False),
|
|
663
|
+
cooldown_seconds=params.get("cooldown_seconds", 60.0),
|
|
664
|
+
)
|
|
665
|
+
created = trigger_manager.create(trigger)
|
|
666
|
+
return dict(created.model_dump(mode="json"))
|
|
667
|
+
|
|
668
|
+
def _handle_delete(params: dict[str, Any]) -> dict[str, Any]:
|
|
669
|
+
trigger_id = params.get("id")
|
|
670
|
+
if not trigger_id:
|
|
671
|
+
raise ValueError("trigger_delete requires 'id' parameter")
|
|
672
|
+
deleted = trigger_manager.delete(trigger_id)
|
|
673
|
+
return {"deleted": deleted, "id": trigger_id}
|
|
674
|
+
|
|
675
|
+
def _handle_list(params: dict[str, Any]) -> dict[str, Any]:
|
|
676
|
+
triggers = trigger_manager.list_all()
|
|
677
|
+
return {"triggers": [t.model_dump(mode="json") for t in triggers]}
|
|
678
|
+
|
|
679
|
+
def _handle_poll(params: dict[str, Any]) -> dict[str, Any]:
|
|
680
|
+
notifications = trigger_manager.drain_pending()
|
|
681
|
+
return {"notifications": [n.model_dump(mode="json") for n in notifications]}
|
|
682
|
+
|
|
683
|
+
mcp_server.register_custom_tool(
|
|
684
|
+
"trigger_create",
|
|
685
|
+
_handle_create,
|
|
686
|
+
"Create a telemetry trigger condition",
|
|
687
|
+
{
|
|
688
|
+
"type": "object",
|
|
689
|
+
"properties": {
|
|
690
|
+
"field": {"type": "string", "description": "Telemetry field name"},
|
|
691
|
+
"operator": {
|
|
692
|
+
"type": "string",
|
|
693
|
+
"description": "Operator: lt, gt, lte, gte, eq, neq, changed, enter, leave",
|
|
694
|
+
},
|
|
695
|
+
"value": {"description": "Threshold value (optional for 'changed')"},
|
|
696
|
+
"once": {
|
|
697
|
+
"type": "boolean",
|
|
698
|
+
"description": "Fire once then auto-delete (default: false)",
|
|
699
|
+
},
|
|
700
|
+
"cooldown_seconds": {
|
|
701
|
+
"type": "number",
|
|
702
|
+
"description": "Cooldown between firings (default: 60)",
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
"required": ["field", "operator"],
|
|
706
|
+
},
|
|
707
|
+
is_write=True,
|
|
708
|
+
)
|
|
709
|
+
mcp_server.register_custom_tool(
|
|
710
|
+
"trigger_delete",
|
|
711
|
+
_handle_delete,
|
|
712
|
+
"Delete a telemetry trigger by ID",
|
|
713
|
+
{
|
|
714
|
+
"type": "object",
|
|
715
|
+
"properties": {"id": {"type": "string", "description": "Trigger ID"}},
|
|
716
|
+
"required": ["id"],
|
|
717
|
+
},
|
|
718
|
+
is_write=True,
|
|
719
|
+
)
|
|
720
|
+
mcp_server.register_custom_tool(
|
|
721
|
+
"trigger_list",
|
|
722
|
+
_handle_list,
|
|
723
|
+
"List all active telemetry triggers",
|
|
724
|
+
{"type": "object", "properties": {}},
|
|
725
|
+
)
|
|
726
|
+
mcp_server.register_custom_tool(
|
|
727
|
+
"trigger_poll",
|
|
728
|
+
_handle_poll,
|
|
729
|
+
"Poll for fired trigger notifications",
|
|
730
|
+
{"type": "object", "properties": {}},
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
class _LoggingASGI:
|
|
735
|
+
"""Thin ASGI wrapper that logs every HTTP and WebSocket request."""
|
|
736
|
+
|
|
737
|
+
def __init__(self, app: Any) -> None:
|
|
738
|
+
self._app = app
|
|
739
|
+
|
|
740
|
+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
|
741
|
+
if scope["type"] == "http":
|
|
742
|
+
method = scope.get("method", "?")
|
|
743
|
+
path = scope.get("path", "/")
|
|
744
|
+
status: int | None = None
|
|
745
|
+
|
|
746
|
+
async def _logging_send(message: Any) -> None:
|
|
747
|
+
nonlocal status
|
|
748
|
+
if message.get("type") == "http.response.start":
|
|
749
|
+
status = message.get("status")
|
|
750
|
+
await send(message)
|
|
751
|
+
|
|
752
|
+
logger.info("HTTP %s %s", method, path)
|
|
753
|
+
await self._app(scope, receive, _logging_send)
|
|
754
|
+
if status is not None:
|
|
755
|
+
logger.info("HTTP %s %s → %d", method, path, status)
|
|
756
|
+
elif scope["type"] == "websocket":
|
|
757
|
+
logger.info("WS %s", scope.get("path", "/"))
|
|
758
|
+
await self._app(scope, receive, send)
|
|
759
|
+
else:
|
|
760
|
+
# lifespan, etc.
|
|
761
|
+
await self._app(scope, receive, send)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _build_combined_app(
|
|
765
|
+
mcp_server: object,
|
|
766
|
+
mcp_port: int,
|
|
767
|
+
public_url: str | None,
|
|
768
|
+
on_frame: object,
|
|
769
|
+
public_key_pem: str | None = None,
|
|
770
|
+
) -> Any:
|
|
771
|
+
"""Build an ASGI app combining MCP (HTTP/auth) and telemetry (WebSocket).
|
|
772
|
+
|
|
773
|
+
Uses a raw ASGI dispatcher instead of Starlette ``Mount`` so the MCP
|
|
774
|
+
app receives requests with an **unmodified scope**. ``Mount`` rewrites
|
|
775
|
+
``scope["path"]`` and ``scope["root_path"]``, which breaks the MCP
|
|
776
|
+
SDK's internal middleware (transport-security validation, session
|
|
777
|
+
manager lookup, SSE streaming).
|
|
778
|
+
|
|
779
|
+
Dispatch order:
|
|
780
|
+
|
|
781
|
+
1. ``lifespan`` → forwarded to the MCP app (initialises the session
|
|
782
|
+
manager's task group).
|
|
783
|
+
2. ``websocket`` at ``/`` → Tesla Fleet Telemetry binary frames.
|
|
784
|
+
3. ``http GET/HEAD /.well-known/…/public-key.pem`` → EC public key.
|
|
785
|
+
4. ``http HEAD *`` → fast 200 (Tesla domain validation).
|
|
786
|
+
5. Everything else → MCP app (``/authorize``, ``/token``, ``/mcp``, …).
|
|
787
|
+
"""
|
|
788
|
+
from starlette.responses import Response
|
|
789
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
790
|
+
|
|
791
|
+
from tescmd.mcp.server import MCPServer
|
|
792
|
+
from tescmd.telemetry.decoder import TelemetryDecoder
|
|
793
|
+
|
|
794
|
+
assert isinstance(mcp_server, MCPServer)
|
|
795
|
+
mcp_app = mcp_server.create_http_app(port=mcp_port, public_url=public_url)
|
|
796
|
+
decoder = TelemetryDecoder()
|
|
797
|
+
_well_known = "/.well-known/appspecific/com.tesla.3p.public-key.pem"
|
|
798
|
+
|
|
799
|
+
async def _app(scope: Any, receive: Any, send: Any) -> None:
|
|
800
|
+
# 1. Lifespan — forwarded so the MCP session manager starts.
|
|
801
|
+
if scope["type"] == "lifespan":
|
|
802
|
+
await mcp_app(scope, receive, send)
|
|
803
|
+
return
|
|
804
|
+
|
|
805
|
+
# 2. Tesla telemetry WebSocket at root path.
|
|
806
|
+
if scope["type"] == "websocket" and scope.get("path", "/") == "/":
|
|
807
|
+
websocket = WebSocket(scope, receive, send)
|
|
808
|
+
await websocket.accept()
|
|
809
|
+
try:
|
|
810
|
+
while True:
|
|
811
|
+
data = await websocket.receive_bytes()
|
|
812
|
+
try:
|
|
813
|
+
frame = decoder.decode(data)
|
|
814
|
+
except Exception:
|
|
815
|
+
logger.warning(
|
|
816
|
+
"Failed to decode telemetry frame (%d bytes) — skipping",
|
|
817
|
+
len(data),
|
|
818
|
+
exc_info=True,
|
|
819
|
+
)
|
|
820
|
+
continue
|
|
821
|
+
try:
|
|
822
|
+
await on_frame(frame) # type: ignore[operator]
|
|
823
|
+
except Exception:
|
|
824
|
+
logger.warning(
|
|
825
|
+
"Failed to process telemetry frame — skipping",
|
|
826
|
+
exc_info=True,
|
|
827
|
+
)
|
|
828
|
+
except WebSocketDisconnect:
|
|
829
|
+
pass
|
|
830
|
+
except Exception:
|
|
831
|
+
logger.debug("WS closed", exc_info=True)
|
|
832
|
+
return
|
|
833
|
+
|
|
834
|
+
if scope["type"] == "http":
|
|
835
|
+
method = scope.get("method", "")
|
|
836
|
+
path = scope.get("path", "/")
|
|
837
|
+
|
|
838
|
+
# 3. Tesla public-key endpoint.
|
|
839
|
+
if path == _well_known and method in ("GET", "HEAD"):
|
|
840
|
+
if public_key_pem:
|
|
841
|
+
resp = Response(content=public_key_pem, media_type="application/x-pem-file")
|
|
842
|
+
else:
|
|
843
|
+
resp = Response(status_code=404)
|
|
844
|
+
await resp(scope, receive, send)
|
|
845
|
+
return
|
|
846
|
+
|
|
847
|
+
# 4. Fast 200 for HEAD — Tesla domain validation.
|
|
848
|
+
if method == "HEAD":
|
|
849
|
+
await Response(status_code=200)(scope, receive, send)
|
|
850
|
+
return
|
|
851
|
+
|
|
852
|
+
# 5. Everything else → MCP app (scope passed through unmodified).
|
|
853
|
+
await mcp_app(scope, receive, send)
|
|
854
|
+
|
|
855
|
+
return _LoggingASGI(_app)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
async def _wait_for_interrupt(shutdown_event: asyncio.Event | None = None) -> None:
|
|
859
|
+
"""Block until Ctrl+C, 'q' is pressed, or *shutdown_event* is set."""
|
|
860
|
+
import asyncio
|
|
861
|
+
import sys
|
|
862
|
+
|
|
863
|
+
def _should_stop() -> bool:
|
|
864
|
+
return shutdown_event is not None and shutdown_event.is_set()
|
|
865
|
+
|
|
866
|
+
if not sys.stdin.isatty():
|
|
867
|
+
try:
|
|
868
|
+
while not _should_stop():
|
|
869
|
+
await asyncio.sleep(1)
|
|
870
|
+
except asyncio.CancelledError:
|
|
871
|
+
pass
|
|
872
|
+
return
|
|
873
|
+
|
|
874
|
+
try:
|
|
875
|
+
import selectors
|
|
876
|
+
import termios
|
|
877
|
+
import tty
|
|
878
|
+
except ImportError:
|
|
879
|
+
try:
|
|
880
|
+
while not _should_stop():
|
|
881
|
+
await asyncio.sleep(1)
|
|
882
|
+
except asyncio.CancelledError:
|
|
883
|
+
pass
|
|
884
|
+
return
|
|
885
|
+
|
|
886
|
+
fd = sys.stdin.fileno()
|
|
887
|
+
old_settings = termios.tcgetattr(fd)
|
|
888
|
+
sel = selectors.DefaultSelector()
|
|
889
|
+
try:
|
|
890
|
+
tty.setcbreak(fd)
|
|
891
|
+
sel.register(sys.stdin, selectors.EVENT_READ)
|
|
892
|
+
while not _should_stop():
|
|
893
|
+
await asyncio.sleep(0.1)
|
|
894
|
+
for _key, _ in sel.select(timeout=0):
|
|
895
|
+
ch = sys.stdin.read(1)
|
|
896
|
+
if ch in ("q", "Q"):
|
|
897
|
+
return
|
|
898
|
+
except asyncio.CancelledError:
|
|
899
|
+
pass
|
|
900
|
+
finally:
|
|
901
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
902
|
+
sel.close()
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
async def _race_shutdown(
|
|
906
|
+
coro: Any,
|
|
907
|
+
shutdown_event: asyncio.Event,
|
|
908
|
+
) -> None:
|
|
909
|
+
"""Run *coro* but return early if *shutdown_event* fires (SIGTERM)."""
|
|
910
|
+
import asyncio
|
|
911
|
+
|
|
912
|
+
task = asyncio.ensure_future(coro)
|
|
913
|
+
shutdown_waiter = asyncio.create_task(shutdown_event.wait())
|
|
914
|
+
done, pending = await asyncio.wait(
|
|
915
|
+
[task, shutdown_waiter],
|
|
916
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
917
|
+
)
|
|
918
|
+
for t in pending:
|
|
919
|
+
t.cancel()
|
|
920
|
+
# Re-raise exceptions from the main task if it finished with an error.
|
|
921
|
+
for t in done:
|
|
922
|
+
if t is not shutdown_waiter:
|
|
923
|
+
t.result()
|