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/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()