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.
Files changed (65) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +41 -4
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +5 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/auth/oauth.py +15 -1
  7. tescmd/auth/server.py +6 -1
  8. tescmd/auth/token_store.py +8 -1
  9. tescmd/cache/response_cache.py +8 -1
  10. tescmd/cli/_client.py +142 -20
  11. tescmd/cli/_options.py +2 -4
  12. tescmd/cli/auth.py +255 -106
  13. tescmd/cli/energy.py +2 -0
  14. tescmd/cli/key.py +6 -7
  15. tescmd/cli/main.py +27 -8
  16. tescmd/cli/mcp_cmd.py +153 -0
  17. tescmd/cli/nav.py +3 -1
  18. tescmd/cli/openclaw.py +169 -0
  19. tescmd/cli/security.py +7 -1
  20. tescmd/cli/serve.py +923 -0
  21. tescmd/cli/setup.py +147 -58
  22. tescmd/cli/sharing.py +2 -0
  23. tescmd/cli/status.py +1 -1
  24. tescmd/cli/trunk.py +8 -17
  25. tescmd/cli/user.py +16 -1
  26. tescmd/cli/vehicle.py +135 -462
  27. tescmd/deploy/github_pages.py +21 -2
  28. tescmd/deploy/tailscale_serve.py +96 -8
  29. tescmd/mcp/__init__.py +7 -0
  30. tescmd/mcp/server.py +648 -0
  31. tescmd/models/auth.py +5 -2
  32. tescmd/openclaw/__init__.py +23 -0
  33. tescmd/openclaw/bridge.py +330 -0
  34. tescmd/openclaw/config.py +167 -0
  35. tescmd/openclaw/dispatcher.py +529 -0
  36. tescmd/openclaw/emitter.py +175 -0
  37. tescmd/openclaw/filters.py +123 -0
  38. tescmd/openclaw/gateway.py +700 -0
  39. tescmd/openclaw/telemetry_store.py +53 -0
  40. tescmd/output/rich_output.py +46 -14
  41. tescmd/protocol/commands.py +2 -2
  42. tescmd/protocol/encoder.py +16 -13
  43. tescmd/protocol/payloads.py +132 -11
  44. tescmd/protocol/session.py +8 -5
  45. tescmd/protocol/signer.py +3 -17
  46. tescmd/telemetry/__init__.py +9 -0
  47. tescmd/telemetry/cache_sink.py +154 -0
  48. tescmd/telemetry/csv_sink.py +180 -0
  49. tescmd/telemetry/dashboard.py +4 -4
  50. tescmd/telemetry/fanout.py +49 -0
  51. tescmd/telemetry/fields.py +308 -129
  52. tescmd/telemetry/mapper.py +239 -0
  53. tescmd/telemetry/server.py +26 -19
  54. tescmd/telemetry/setup.py +468 -0
  55. tescmd/telemetry/tailscale.py +78 -16
  56. tescmd/telemetry/tui.py +1716 -0
  57. tescmd/triggers/__init__.py +18 -0
  58. tescmd/triggers/manager.py +264 -0
  59. tescmd/triggers/models.py +93 -0
  60. tescmd-0.4.0.dist-info/METADATA +300 -0
  61. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/RECORD +64 -42
  62. tescmd-0.2.0.dist-info/METADATA +0 -495
  63. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
  64. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
  65. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,700 @@
1
+ """WebSocket client for the OpenClaw Gateway.
2
+
3
+ Implements the OpenClaw node protocol (bidirectional):
4
+
5
+ 1. Receive ``connect.challenge`` event (type=event) with nonce + ts
6
+ 2. Sign a pipe-delimited auth payload with the device Ed25519 key
7
+ 3. Send ``connect`` request (type=req) with role, scopes, capabilities,
8
+ auth, device
9
+ 4. Receive ``hello-ok`` event
10
+ 5. OUTBOUND: Emit events via ``req:agent`` method (type=req)
11
+ 6. INBOUND: Receive ``node.invoke.request`` events → dispatch →
12
+ send ``node.invoke.result`` requests
13
+
14
+ Frame types:
15
+ - Request: ``{type: "req", id, method, params}``
16
+ - Response: ``{type: "res", id, ok, payload|error}``
17
+ - Event: ``{type: "event", event, payload, seq?, stateVersion?}``
18
+
19
+ Includes exponential backoff reconnection (1s base → 60s max) with jitter.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import base64
26
+ import hashlib
27
+ import json
28
+ import logging
29
+ import platform
30
+ import random
31
+ from datetime import UTC, datetime
32
+ from pathlib import Path
33
+ from typing import TYPE_CHECKING, Any
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Awaitable, Callable
37
+
38
+ from websockets.asyncio.client import ClientConnection
39
+
40
+ from tescmd.openclaw.config import NodeCapabilities
41
+
42
+ from cryptography.hazmat.primitives import serialization
43
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
44
+ Ed25519PrivateKey,
45
+ )
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ _BACKOFF_BASE = 1.0
50
+ _BACKOFF_MAX = 60.0
51
+ _BACKOFF_FACTOR = 2.0
52
+
53
+ _PROTOCOL_VERSION = 3
54
+
55
+ _DEVICE_KEY_FILE = "device-key.pem"
56
+
57
+
58
+ def _device_key_dir() -> Path:
59
+ """Return the directory for the OpenClaw device key, respecting TESLA_CONFIG_DIR."""
60
+ import os
61
+
62
+ config_dir = os.environ.get("TESLA_CONFIG_DIR", "~/.config/tescmd")
63
+ return Path(config_dir).expanduser() / "openclaw"
64
+
65
+
66
+ # -- Helpers ----------------------------------------------------------------
67
+
68
+
69
+ async def _retry_with_backoff(
70
+ operation: Callable[[], Awaitable[None]],
71
+ *,
72
+ label: str = "operation",
73
+ max_attempts: int = 0,
74
+ ) -> None:
75
+ """Retry *operation* with exponential backoff and jitter.
76
+
77
+ Parameters
78
+ ----------
79
+ operation:
80
+ Async callable to attempt.
81
+ label:
82
+ Human-readable label for log messages.
83
+ max_attempts:
84
+ Maximum number of attempts. ``0`` means unlimited.
85
+
86
+ Raises the last exception if *max_attempts* is reached.
87
+ """
88
+ attempt = 0
89
+ backoff = _BACKOFF_BASE
90
+ while max_attempts == 0 or attempt < max_attempts:
91
+ attempt += 1
92
+ try:
93
+ await operation()
94
+ return
95
+ except asyncio.CancelledError:
96
+ raise
97
+ except Exception as exc:
98
+ if max_attempts > 0 and attempt >= max_attempts:
99
+ raise
100
+ jitter = random.uniform(0, backoff * 0.1)
101
+ wait = min(backoff + jitter, _BACKOFF_MAX)
102
+ logger.info(
103
+ "%s attempt %d failed: %s — retrying in %.1fs",
104
+ label,
105
+ attempt,
106
+ exc,
107
+ wait,
108
+ )
109
+ await asyncio.sleep(wait)
110
+ backoff = min(backoff * _BACKOFF_FACTOR, _BACKOFF_MAX)
111
+
112
+
113
+ def _b64url(data: bytes) -> str:
114
+ """Base64URL-encode without padding."""
115
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
116
+
117
+
118
+ # -- Device identity helpers ------------------------------------------------
119
+
120
+
121
+ def _ensure_device_key() -> Ed25519PrivateKey:
122
+ """Load or generate the device Ed25519 keypair for gateway auth."""
123
+ key_dir = _device_key_dir()
124
+ key_dir.mkdir(parents=True, exist_ok=True)
125
+ key_path = key_dir / _DEVICE_KEY_FILE
126
+
127
+ if key_path.exists():
128
+ key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
129
+ if isinstance(key, Ed25519PrivateKey):
130
+ return key
131
+
132
+ # Generate a new Ed25519 device key.
133
+ private_key = Ed25519PrivateKey.generate()
134
+ pem = private_key.private_bytes(
135
+ encoding=serialization.Encoding.PEM,
136
+ format=serialization.PrivateFormat.PKCS8,
137
+ encryption_algorithm=serialization.NoEncryption(),
138
+ )
139
+ key_path.write_bytes(pem)
140
+ from tescmd._internal.permissions import secure_file
141
+
142
+ secure_file(key_path)
143
+ logger.info("Generated OpenClaw device key: %s", key_path)
144
+ return private_key
145
+
146
+
147
+ def _public_key_raw(key: Ed25519PrivateKey) -> bytes:
148
+ """Return the raw 32-byte Ed25519 public key."""
149
+ return key.public_key().public_bytes(
150
+ encoding=serialization.Encoding.Raw,
151
+ format=serialization.PublicFormat.Raw,
152
+ )
153
+
154
+
155
+ def _public_key_raw_b64url(key: Ed25519PrivateKey) -> str:
156
+ """Return the raw 32-byte Ed25519 public key as base64url."""
157
+ return _b64url(_public_key_raw(key))
158
+
159
+
160
+ def _device_id(key: Ed25519PrivateKey) -> str:
161
+ """Derive a stable device ID from the public key (full SHA-256 hex)."""
162
+ return hashlib.sha256(_public_key_raw(key)).hexdigest()
163
+
164
+
165
+ def _build_auth_payload(
166
+ *,
167
+ device_id: str,
168
+ client_id: str,
169
+ client_mode: str,
170
+ role: str,
171
+ scopes: list[str],
172
+ signed_at_ms: int,
173
+ token: str | None,
174
+ nonce: str | None,
175
+ ) -> str:
176
+ """Build the pipe-delimited payload string that gets signed.
177
+
178
+ v2 (with nonce): ``v2|deviceId|clientId|mode|role|scopes|ts|token|nonce``
179
+ v1 (no nonce): ``v1|deviceId|clientId|mode|role|scopes|ts|token``
180
+ """
181
+ version = "v2" if nonce else "v1"
182
+ parts: list[str] = [
183
+ version,
184
+ device_id,
185
+ client_id,
186
+ client_mode,
187
+ role,
188
+ ",".join(scopes),
189
+ str(signed_at_ms),
190
+ token or "",
191
+ ]
192
+ if nonce:
193
+ parts.append(nonce)
194
+ return "|".join(parts)
195
+
196
+
197
+ def _sign_payload(key: Ed25519PrivateKey, payload: str) -> str:
198
+ """Sign the auth payload with Ed25519 and return base64url signature."""
199
+ sig = key.sign(payload.encode("utf-8"))
200
+ return _b64url(sig)
201
+
202
+
203
+ # -- Gateway client ---------------------------------------------------------
204
+
205
+
206
+ class GatewayConnectionError(Exception):
207
+ """Failed to connect or authenticate with the OpenClaw Gateway."""
208
+
209
+
210
+ class GatewayClient:
211
+ """Manages WebSocket connection to an OpenClaw Gateway (node role).
212
+
213
+ When *on_request* is provided, incoming ``node.invoke.request`` events
214
+ are dispatched to that callback and the result is sent back as a
215
+ ``node.invoke.result`` request frame. Without *on_request*, the client
216
+ operates in outbound-only mode (still connects as a node but ignores
217
+ inbound commands).
218
+ """
219
+
220
+ def __init__(
221
+ self,
222
+ url: str,
223
+ *,
224
+ token: str | None = None,
225
+ client_id: str = "node-host",
226
+ client_version: str | None = None,
227
+ display_name: str | None = None,
228
+ device_family: str | None = None,
229
+ model_identifier: str | None = None,
230
+ capabilities: NodeCapabilities | None = None,
231
+ on_request: Callable[[dict[str, Any]], Awaitable[dict[str, Any] | None]] | None = None,
232
+ ) -> None:
233
+ self._url = url
234
+ self._token = token
235
+ self._client_id = client_id
236
+ if client_version is None:
237
+ from tescmd import __version__
238
+
239
+ client_version = f"tescmd/{__version__}"
240
+ self._client_version = client_version
241
+ self._display_name = display_name
242
+ self._device_family = device_family or platform.system().lower()
243
+ self._model_identifier = model_identifier or "tescmd"
244
+ self._capabilities = capabilities
245
+ self._on_request = on_request
246
+ self._ws: ClientConnection | None = None
247
+ self._connected = False
248
+ self._send_count = 0
249
+ self._recv_count = 0
250
+ self._drop_count = 0
251
+ self._msg_id = 0
252
+ self._node_id: str | None = None
253
+ self._recv_task: asyncio.Task[None] | None = None
254
+
255
+ @property
256
+ def is_connected(self) -> bool:
257
+ return self._connected
258
+
259
+ @property
260
+ def send_count(self) -> int:
261
+ return self._send_count
262
+
263
+ @property
264
+ def recv_count(self) -> int:
265
+ return self._recv_count
266
+
267
+ @property
268
+ def drop_count(self) -> int:
269
+ return self._drop_count
270
+
271
+ def _next_id(self) -> str:
272
+ """Return an incrementing message ID for request frames."""
273
+ self._msg_id += 1
274
+ return str(self._msg_id)
275
+
276
+ async def connect(self) -> None:
277
+ """Connect to the gateway and complete the handshake.
278
+
279
+ Passes the auth token as a Bearer header during the HTTP upgrade
280
+ so gateways that enforce authentication at the transport layer
281
+ accept the connection before the OpenClaw handshake begins.
282
+
283
+ If a receive loop is already running (e.g. from a previous
284
+ connection or a concurrent reconnect attempt), it is cancelled
285
+ before establishing the new connection to prevent duplicate
286
+ ``recv()`` calls on the same WebSocket.
287
+
288
+ Raises :class:`GatewayConnectionError` on failure.
289
+ """
290
+ import contextlib
291
+
292
+ if self._recv_task is not None and not self._recv_task.done():
293
+ self._recv_task.cancel()
294
+ with contextlib.suppress(asyncio.CancelledError):
295
+ await self._recv_task
296
+ self._recv_task = None
297
+
298
+ await self._establish_connection()
299
+
300
+ if self._on_request is not None:
301
+ self._recv_task = asyncio.create_task(self._receive_loop())
302
+ logger.info("Inbound receive loop started")
303
+
304
+ async def _establish_connection(self) -> None:
305
+ """Open WebSocket and complete the handshake.
306
+
307
+ This is the low-level connection method used by both initial
308
+ :meth:`connect` and automatic reconnection. It does **not**
309
+ start the receive loop — that is the caller's responsibility.
310
+
311
+ Raises :class:`GatewayConnectionError` on failure.
312
+ """
313
+ import contextlib
314
+
315
+ import websockets.asyncio.client as ws_client
316
+ from websockets.exceptions import ConnectionClosed
317
+
318
+ # Clean up any stale WebSocket from a previous connection.
319
+ if self._ws is not None:
320
+ with contextlib.suppress(ConnectionClosed, OSError):
321
+ await self._ws.close()
322
+ self._ws = None
323
+
324
+ headers: dict[str, str] = {}
325
+ if self._token:
326
+ headers["Authorization"] = f"Bearer {self._token}"
327
+
328
+ try:
329
+ self._ws = await ws_client.connect(
330
+ self._url,
331
+ additional_headers=headers,
332
+ )
333
+ except Exception as exc:
334
+ raise GatewayConnectionError(
335
+ f"Failed to connect to gateway at {self._url}: {exc}"
336
+ ) from exc
337
+
338
+ try:
339
+ await self._handshake()
340
+ except GatewayConnectionError:
341
+ raise
342
+ except Exception as exc:
343
+ raise GatewayConnectionError(f"Handshake failed with {self._url}: {exc}") from exc
344
+
345
+ self._connected = True
346
+ logger.info("Connected to OpenClaw Gateway at %s", self._url)
347
+
348
+ async def _handshake(self) -> None:
349
+ """Complete the OpenClaw connect challenge → hello-ok handshake.
350
+
351
+ Protocol:
352
+ 1. Receive ``{type:"event", event:"connect.challenge", ...}``
353
+ 2. Sign pipe-delimited auth payload with Ed25519 device key
354
+ 3. Send ``{type:"req", method:"connect", params:{...}}``
355
+ 4. Receive ``{type:"event", event:"hello-ok", ...}``
356
+ """
357
+ assert self._ws is not None
358
+
359
+ # 1. Receive challenge event
360
+ raw = await asyncio.wait_for(self._ws.recv(), timeout=10)
361
+ msg = json.loads(raw)
362
+ logger.debug("Gateway challenge: %s", raw)
363
+
364
+ event_name = msg.get("event", "")
365
+ if event_name != "connect.challenge":
366
+ raise GatewayConnectionError(
367
+ f"Expected connect.challenge, got: {event_name or msg.get('type', 'unknown')}"
368
+ )
369
+
370
+ payload = msg.get("payload") or msg.get("data") or {}
371
+ nonce = payload.get("nonce", "")
372
+
373
+ # 2. Build signed device auth
374
+ device_key = _ensure_device_key()
375
+ dev_id = _device_id(device_key)
376
+ self._node_id = dev_id
377
+ signed_at_ms = int(datetime.now(UTC).timestamp() * 1000)
378
+ scopes = ["node.telemetry", "node.command"]
379
+
380
+ auth_payload = _build_auth_payload(
381
+ device_id=dev_id,
382
+ client_id=self._client_id,
383
+ client_mode="node",
384
+ role="node",
385
+ scopes=scopes,
386
+ signed_at_ms=signed_at_ms,
387
+ token=self._token,
388
+ nonce=nonce or None,
389
+ )
390
+ signature = _sign_payload(device_key, auth_payload)
391
+
392
+ # 3. Send connect request (typed frame)
393
+ params: dict[str, Any] = {
394
+ "role": "node",
395
+ "scopes": scopes,
396
+ "minProtocol": _PROTOCOL_VERSION,
397
+ "maxProtocol": _PROTOCOL_VERSION,
398
+ "client": {
399
+ "id": self._client_id,
400
+ "version": self._client_version,
401
+ "platform": "tescmd",
402
+ "deviceFamily": self._device_family,
403
+ "modelIdentifier": self._model_identifier,
404
+ "mode": "node",
405
+ **({"displayName": self._display_name} if self._display_name else {}),
406
+ },
407
+ "device": {
408
+ "id": dev_id,
409
+ "publicKey": _public_key_raw_b64url(device_key),
410
+ "signature": signature,
411
+ "signedAt": signed_at_ms,
412
+ "nonce": nonce,
413
+ },
414
+ }
415
+ if self._token:
416
+ params["auth"] = {"token": self._token}
417
+ if self._capabilities is not None:
418
+ cap_params = self._capabilities.to_connect_params()
419
+ logger.info(
420
+ "Node capabilities: caps=%d commands=%d permissions=%d — commands=%s",
421
+ len(cap_params.get("caps", [])),
422
+ len(cap_params.get("commands", [])),
423
+ len(cap_params.get("permissions", {})),
424
+ cap_params.get("commands", []),
425
+ )
426
+ params.update(cap_params)
427
+
428
+ connect_msg: dict[str, Any] = {
429
+ "type": "req",
430
+ "id": self._next_id(),
431
+ "method": "connect",
432
+ "params": params,
433
+ }
434
+ logger.debug("Gateway connect: %s", json.dumps(connect_msg))
435
+ await self._ws.send(json.dumps(connect_msg))
436
+
437
+ # 4. Receive hello-ok (event) or error (res)
438
+ raw = await asyncio.wait_for(self._ws.recv(), timeout=10)
439
+ msg = json.loads(raw)
440
+ logger.debug("Gateway response: %s", raw)
441
+
442
+ msg_type = msg.get("type", "")
443
+ event_name = msg.get("event", "")
444
+
445
+ # Success: {type:"event", event:"hello-ok"} or {type:"res", ok:true}
446
+ if event_name == "hello-ok":
447
+ return
448
+ if msg_type == "res" and msg.get("ok", False):
449
+ return
450
+
451
+ # Error: {type:"res", ok:false, error:...}
452
+ if msg_type == "res" and not msg.get("ok", False):
453
+ error = msg.get("error", "unknown error")
454
+ raise GatewayConnectionError(f"Handshake failed: {error}")
455
+
456
+ raise GatewayConnectionError(
457
+ f"Unexpected handshake response: type={msg_type}, event={event_name}"
458
+ )
459
+
460
+ # -- Inbound request handling -----------------------------------------------
461
+
462
+ async def _receive_loop(self) -> None:
463
+ """Listen for inbound frames from the gateway with auto-reconnect.
464
+
465
+ The gateway sends commands as ``node.invoke.request`` events::
466
+
467
+ {type: "event", event: "node.invoke.request",
468
+ payload: {id, nodeId, command, paramsJSON, timeoutMs}}
469
+
470
+ The node responds with a ``node.invoke.result`` request::
471
+
472
+ {type: "req", method: "node.invoke.result",
473
+ params: {id, nodeId, ok, payloadJSON|error}}
474
+
475
+ On disconnect, the loop automatically attempts to reconnect with
476
+ exponential backoff before resuming.
477
+ """
478
+ from websockets.exceptions import ConnectionClosed
479
+
480
+ while True:
481
+ try:
482
+ assert self._ws is not None
483
+ logger.info("Receive loop running — waiting for inbound frames")
484
+ async for raw in self._ws:
485
+ try:
486
+ msg = json.loads(raw)
487
+ except (json.JSONDecodeError, TypeError):
488
+ logger.warning("Received non-JSON frame — ignoring")
489
+ continue
490
+
491
+ msg_type = msg.get("type", "")
492
+ event_name = msg.get("event", "")
493
+
494
+ logger.debug(
495
+ "Recv frame: type=%s event=%s method=%s id=%s",
496
+ msg_type,
497
+ event_name,
498
+ msg.get("method", ""),
499
+ msg.get("id", ""),
500
+ )
501
+
502
+ if msg_type == "event" and event_name == "node.invoke.request":
503
+ await self._handle_invoke(msg.get("payload") or {})
504
+ elif msg_type == "event" and event_name not in ("ping", "pong", ""):
505
+ logger.debug("Unhandled event: %s", event_name)
506
+ # Iterator exhausted normally (clean close)
507
+ logger.info("Gateway closed connection cleanly")
508
+ self._connected = False
509
+ except asyncio.CancelledError:
510
+ logger.debug("Receive loop cancelled")
511
+ raise
512
+ except ConnectionClosed as exc:
513
+ code = exc.rcvd.code if exc.rcvd else "?"
514
+ reason = exc.rcvd.reason if exc.rcvd else "unknown"
515
+ logger.info("Gateway connection closed (code=%s reason=%s)", code, reason)
516
+ self._connected = False
517
+ except Exception:
518
+ logger.warning("Receive loop error", exc_info=True)
519
+ self._connected = False
520
+
521
+ # Attempt reconnect with backoff
522
+ if not await self._try_reconnect():
523
+ logger.error("Reconnection failed — receive loop exiting")
524
+ break
525
+
526
+ async def _try_reconnect(self) -> bool:
527
+ """Attempt to re-establish the gateway connection with exponential backoff.
528
+
529
+ Returns ``True`` on success, ``False`` after exhausting all attempts.
530
+ Unlimited retries — runs until reconnected or cancelled.
531
+ """
532
+ try:
533
+ await _retry_with_backoff(
534
+ self._establish_connection,
535
+ label=f"Reconnecting to {self._url}",
536
+ )
537
+ logger.info("Reconnected to OpenClaw Gateway")
538
+ return True
539
+ except GatewayConnectionError:
540
+ return False
541
+
542
+ async def _handle_invoke(self, payload: dict[str, Any]) -> None:
543
+ """Handle a ``node.invoke.request`` event from the gateway."""
544
+ invoke_id = payload.get("id", "")
545
+ command = payload.get("command", "")
546
+ params_json = payload.get("paramsJSON", "{}")
547
+ logger.info("Invoke request: id=%s command=%s", invoke_id, command)
548
+
549
+ if not self._on_request:
550
+ await self._send_invoke_result(invoke_id, ok=False, error="no handler configured")
551
+ return
552
+
553
+ self._recv_count += 1
554
+
555
+ # Parse the stringified params
556
+ try:
557
+ params = json.loads(params_json) if params_json else {}
558
+ except (json.JSONDecodeError, TypeError):
559
+ logger.warning(
560
+ "Malformed paramsJSON for invoke %s (command=%s) — using empty params",
561
+ invoke_id,
562
+ command,
563
+ )
564
+ params = {}
565
+
566
+ # Build the message dict the dispatcher expects
567
+ dispatch_msg: dict[str, Any] = {
568
+ "method": command,
569
+ "params": params,
570
+ "id": invoke_id,
571
+ }
572
+
573
+ try:
574
+ result = await asyncio.wait_for(self._on_request(dispatch_msg), timeout=30)
575
+ if result is None:
576
+ await self._send_invoke_result(
577
+ invoke_id, ok=False, error=f"unknown command: {command}"
578
+ )
579
+ else:
580
+ await self._send_invoke_result(invoke_id, ok=True, result_payload=result)
581
+ except TimeoutError:
582
+ await self._send_invoke_result(invoke_id, ok=False, error="handler timeout (30s)")
583
+ except Exception as exc:
584
+ logger.warning("Invoke handler error for %s", command, exc_info=True)
585
+ await self._send_invoke_result(invoke_id, ok=False, error=str(exc))
586
+
587
+ async def _send_invoke_result(
588
+ self,
589
+ invoke_id: str,
590
+ *,
591
+ ok: bool,
592
+ result_payload: dict[str, Any] | None = None,
593
+ error: str | None = None,
594
+ ) -> None:
595
+ """Send a ``node.invoke.result`` request back to the gateway."""
596
+ if self._ws is None:
597
+ logger.warning("Cannot send invoke result for %s — not connected", invoke_id)
598
+ return
599
+
600
+ params: dict[str, Any] = {
601
+ "id": invoke_id,
602
+ "nodeId": self._node_id or "",
603
+ "ok": ok,
604
+ }
605
+ if ok and result_payload is not None:
606
+ params["payloadJSON"] = json.dumps(result_payload, default=str)
607
+ if not ok and error is not None:
608
+ params["error"] = {"message": error}
609
+
610
+ frame: dict[str, Any] = {
611
+ "type": "req",
612
+ "id": self._next_id(),
613
+ "method": "node.invoke.result",
614
+ "params": params,
615
+ }
616
+
617
+ wire = json.dumps(frame)
618
+ logger.info("Sending invoke result: %s", wire[:500])
619
+ try:
620
+ await self._ws.send(wire)
621
+ except Exception:
622
+ logger.warning("Failed to send invoke result for %s", invoke_id, exc_info=True)
623
+ self._connected = False
624
+
625
+ # -- Outbound event sending -----------------------------------------------
626
+
627
+ async def send_event(self, event: dict[str, Any]) -> None:
628
+ """Send an event to the gateway as a typed request frame.
629
+
630
+ Wraps the event dict in the ``{type:"req", id, method, params}``
631
+ envelope if it doesn't already have a ``type`` field.
632
+
633
+ Silently drops the event if not connected. Never raises on send
634
+ failure — logs and marks as disconnected instead.
635
+ """
636
+ if not self._connected or self._ws is None:
637
+ self._drop_count += 1
638
+ if self._drop_count == 1 or self._drop_count % 100 == 0:
639
+ logger.warning("Event dropped (not connected) — total drops: %d", self._drop_count)
640
+ return
641
+
642
+ if "type" not in event:
643
+ event = {
644
+ "type": "req",
645
+ "id": self._next_id(),
646
+ **event,
647
+ }
648
+
649
+ try:
650
+ wire = json.dumps(event, default=str)
651
+ except (TypeError, ValueError):
652
+ self._drop_count += 1
653
+ logger.error(
654
+ "Event serialization failed (total drops: %d) — event keys: %s",
655
+ self._drop_count,
656
+ list(event.keys()),
657
+ exc_info=True,
658
+ )
659
+ return
660
+
661
+ try:
662
+ await self._ws.send(wire)
663
+ self._send_count += 1
664
+ except Exception:
665
+ self._drop_count += 1
666
+ logger.warning(
667
+ "Send failed — marking disconnected (total drops: %d)", self._drop_count
668
+ )
669
+ self._connected = False
670
+
671
+ async def close(self) -> None:
672
+ """Close the gateway connection gracefully."""
673
+ import contextlib
674
+
675
+ from websockets.exceptions import ConnectionClosed
676
+
677
+ self._connected = False
678
+ if self._recv_task is not None and not self._recv_task.done():
679
+ self._recv_task.cancel()
680
+ with contextlib.suppress(asyncio.CancelledError):
681
+ await self._recv_task
682
+ self._recv_task = None
683
+ if self._ws is not None:
684
+ with contextlib.suppress(ConnectionClosed, OSError):
685
+ await self._ws.close()
686
+ self._ws = None
687
+
688
+ async def connect_with_backoff(self, *, max_attempts: int = 0) -> None:
689
+ """Connect with exponential backoff retry.
690
+
691
+ Parameters
692
+ ----------
693
+ max_attempts:
694
+ Maximum connection attempts. ``0`` means infinite.
695
+ """
696
+ await _retry_with_backoff(
697
+ self.connect,
698
+ label=f"Connecting to {self._url}",
699
+ max_attempts=max_attempts,
700
+ )