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
@@ -0,0 +1,687 @@
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
+ Raises :class:`GatewayConnectionError` on failure.
284
+ """
285
+ await self._establish_connection()
286
+
287
+ if self._on_request is not None:
288
+ self._recv_task = asyncio.create_task(self._receive_loop())
289
+ logger.info("Inbound receive loop started")
290
+
291
+ async def _establish_connection(self) -> None:
292
+ """Open WebSocket and complete the handshake.
293
+
294
+ This is the low-level connection method used by both initial
295
+ :meth:`connect` and automatic reconnection. It does **not**
296
+ start the receive loop — that is the caller's responsibility.
297
+
298
+ Raises :class:`GatewayConnectionError` on failure.
299
+ """
300
+ import contextlib
301
+
302
+ import websockets.asyncio.client as ws_client
303
+ from websockets.exceptions import ConnectionClosed
304
+
305
+ # Clean up any stale WebSocket from a previous connection.
306
+ if self._ws is not None:
307
+ with contextlib.suppress(ConnectionClosed, OSError):
308
+ await self._ws.close()
309
+ self._ws = None
310
+
311
+ headers: dict[str, str] = {}
312
+ if self._token:
313
+ headers["Authorization"] = f"Bearer {self._token}"
314
+
315
+ try:
316
+ self._ws = await ws_client.connect(
317
+ self._url,
318
+ additional_headers=headers,
319
+ )
320
+ except Exception as exc:
321
+ raise GatewayConnectionError(
322
+ f"Failed to connect to gateway at {self._url}: {exc}"
323
+ ) from exc
324
+
325
+ try:
326
+ await self._handshake()
327
+ except GatewayConnectionError:
328
+ raise
329
+ except Exception as exc:
330
+ raise GatewayConnectionError(f"Handshake failed with {self._url}: {exc}") from exc
331
+
332
+ self._connected = True
333
+ logger.info("Connected to OpenClaw Gateway at %s", self._url)
334
+
335
+ async def _handshake(self) -> None:
336
+ """Complete the OpenClaw connect challenge → hello-ok handshake.
337
+
338
+ Protocol:
339
+ 1. Receive ``{type:"event", event:"connect.challenge", ...}``
340
+ 2. Sign pipe-delimited auth payload with Ed25519 device key
341
+ 3. Send ``{type:"req", method:"connect", params:{...}}``
342
+ 4. Receive ``{type:"event", event:"hello-ok", ...}``
343
+ """
344
+ assert self._ws is not None
345
+
346
+ # 1. Receive challenge event
347
+ raw = await asyncio.wait_for(self._ws.recv(), timeout=10)
348
+ msg = json.loads(raw)
349
+ logger.debug("Gateway challenge: %s", raw)
350
+
351
+ event_name = msg.get("event", "")
352
+ if event_name != "connect.challenge":
353
+ raise GatewayConnectionError(
354
+ f"Expected connect.challenge, got: {event_name or msg.get('type', 'unknown')}"
355
+ )
356
+
357
+ payload = msg.get("payload") or msg.get("data") or {}
358
+ nonce = payload.get("nonce", "")
359
+
360
+ # 2. Build signed device auth
361
+ device_key = _ensure_device_key()
362
+ dev_id = _device_id(device_key)
363
+ self._node_id = dev_id
364
+ signed_at_ms = int(datetime.now(UTC).timestamp() * 1000)
365
+ scopes = ["node.telemetry", "node.command"]
366
+
367
+ auth_payload = _build_auth_payload(
368
+ device_id=dev_id,
369
+ client_id=self._client_id,
370
+ client_mode="node",
371
+ role="node",
372
+ scopes=scopes,
373
+ signed_at_ms=signed_at_ms,
374
+ token=self._token,
375
+ nonce=nonce or None,
376
+ )
377
+ signature = _sign_payload(device_key, auth_payload)
378
+
379
+ # 3. Send connect request (typed frame)
380
+ params: dict[str, Any] = {
381
+ "role": "node",
382
+ "scopes": scopes,
383
+ "minProtocol": _PROTOCOL_VERSION,
384
+ "maxProtocol": _PROTOCOL_VERSION,
385
+ "client": {
386
+ "id": self._client_id,
387
+ "version": self._client_version,
388
+ "platform": "tescmd",
389
+ "deviceFamily": self._device_family,
390
+ "modelIdentifier": self._model_identifier,
391
+ "mode": "node",
392
+ **({"displayName": self._display_name} if self._display_name else {}),
393
+ },
394
+ "device": {
395
+ "id": dev_id,
396
+ "publicKey": _public_key_raw_b64url(device_key),
397
+ "signature": signature,
398
+ "signedAt": signed_at_ms,
399
+ "nonce": nonce,
400
+ },
401
+ }
402
+ if self._token:
403
+ params["auth"] = {"token": self._token}
404
+ if self._capabilities is not None:
405
+ cap_params = self._capabilities.to_connect_params()
406
+ logger.info(
407
+ "Node capabilities: caps=%d commands=%d permissions=%d — commands=%s",
408
+ len(cap_params.get("caps", [])),
409
+ len(cap_params.get("commands", [])),
410
+ len(cap_params.get("permissions", {})),
411
+ cap_params.get("commands", []),
412
+ )
413
+ params.update(cap_params)
414
+
415
+ connect_msg: dict[str, Any] = {
416
+ "type": "req",
417
+ "id": self._next_id(),
418
+ "method": "connect",
419
+ "params": params,
420
+ }
421
+ logger.debug("Gateway connect: %s", json.dumps(connect_msg))
422
+ await self._ws.send(json.dumps(connect_msg))
423
+
424
+ # 4. Receive hello-ok (event) or error (res)
425
+ raw = await asyncio.wait_for(self._ws.recv(), timeout=10)
426
+ msg = json.loads(raw)
427
+ logger.debug("Gateway response: %s", raw)
428
+
429
+ msg_type = msg.get("type", "")
430
+ event_name = msg.get("event", "")
431
+
432
+ # Success: {type:"event", event:"hello-ok"} or {type:"res", ok:true}
433
+ if event_name == "hello-ok":
434
+ return
435
+ if msg_type == "res" and msg.get("ok", False):
436
+ return
437
+
438
+ # Error: {type:"res", ok:false, error:...}
439
+ if msg_type == "res" and not msg.get("ok", False):
440
+ error = msg.get("error", "unknown error")
441
+ raise GatewayConnectionError(f"Handshake failed: {error}")
442
+
443
+ raise GatewayConnectionError(
444
+ f"Unexpected handshake response: type={msg_type}, event={event_name}"
445
+ )
446
+
447
+ # -- Inbound request handling -----------------------------------------------
448
+
449
+ async def _receive_loop(self) -> None:
450
+ """Listen for inbound frames from the gateway with auto-reconnect.
451
+
452
+ The gateway sends commands as ``node.invoke.request`` events::
453
+
454
+ {type: "event", event: "node.invoke.request",
455
+ payload: {id, nodeId, command, paramsJSON, timeoutMs}}
456
+
457
+ The node responds with a ``node.invoke.result`` request::
458
+
459
+ {type: "req", method: "node.invoke.result",
460
+ params: {id, nodeId, ok, payloadJSON|error}}
461
+
462
+ On disconnect, the loop automatically attempts to reconnect with
463
+ exponential backoff before resuming.
464
+ """
465
+ from websockets.exceptions import ConnectionClosed
466
+
467
+ while True:
468
+ try:
469
+ assert self._ws is not None
470
+ logger.info("Receive loop running — waiting for inbound frames")
471
+ async for raw in self._ws:
472
+ try:
473
+ msg = json.loads(raw)
474
+ except (json.JSONDecodeError, TypeError):
475
+ logger.warning("Received non-JSON frame — ignoring")
476
+ continue
477
+
478
+ msg_type = msg.get("type", "")
479
+ event_name = msg.get("event", "")
480
+
481
+ logger.debug(
482
+ "Recv frame: type=%s event=%s method=%s id=%s",
483
+ msg_type,
484
+ event_name,
485
+ msg.get("method", ""),
486
+ msg.get("id", ""),
487
+ )
488
+
489
+ if msg_type == "event" and event_name == "node.invoke.request":
490
+ await self._handle_invoke(msg.get("payload") or {})
491
+ elif msg_type == "event" and event_name not in ("ping", "pong", ""):
492
+ logger.debug("Unhandled event: %s", event_name)
493
+ # Iterator exhausted normally (clean close)
494
+ logger.info("Gateway closed connection cleanly")
495
+ self._connected = False
496
+ except asyncio.CancelledError:
497
+ logger.debug("Receive loop cancelled")
498
+ raise
499
+ except ConnectionClosed as exc:
500
+ code = exc.rcvd.code if exc.rcvd else "?"
501
+ reason = exc.rcvd.reason if exc.rcvd else "unknown"
502
+ logger.info("Gateway connection closed (code=%s reason=%s)", code, reason)
503
+ self._connected = False
504
+ except Exception:
505
+ logger.warning("Receive loop error", exc_info=True)
506
+ self._connected = False
507
+
508
+ # Attempt reconnect with backoff
509
+ if not await self._try_reconnect():
510
+ logger.error("Reconnection failed — receive loop exiting")
511
+ break
512
+
513
+ async def _try_reconnect(self) -> bool:
514
+ """Attempt to re-establish the gateway connection with exponential backoff.
515
+
516
+ Returns ``True`` on success, ``False`` after exhausting all attempts.
517
+ Unlimited retries — runs until reconnected or cancelled.
518
+ """
519
+ try:
520
+ await _retry_with_backoff(
521
+ self._establish_connection,
522
+ label=f"Reconnecting to {self._url}",
523
+ )
524
+ logger.info("Reconnected to OpenClaw Gateway")
525
+ return True
526
+ except GatewayConnectionError:
527
+ return False
528
+
529
+ async def _handle_invoke(self, payload: dict[str, Any]) -> None:
530
+ """Handle a ``node.invoke.request`` event from the gateway."""
531
+ invoke_id = payload.get("id", "")
532
+ command = payload.get("command", "")
533
+ params_json = payload.get("paramsJSON", "{}")
534
+ logger.info("Invoke request: id=%s command=%s", invoke_id, command)
535
+
536
+ if not self._on_request:
537
+ await self._send_invoke_result(invoke_id, ok=False, error="no handler configured")
538
+ return
539
+
540
+ self._recv_count += 1
541
+
542
+ # Parse the stringified params
543
+ try:
544
+ params = json.loads(params_json) if params_json else {}
545
+ except (json.JSONDecodeError, TypeError):
546
+ logger.warning(
547
+ "Malformed paramsJSON for invoke %s (command=%s) — using empty params",
548
+ invoke_id,
549
+ command,
550
+ )
551
+ params = {}
552
+
553
+ # Build the message dict the dispatcher expects
554
+ dispatch_msg: dict[str, Any] = {
555
+ "method": command,
556
+ "params": params,
557
+ "id": invoke_id,
558
+ }
559
+
560
+ try:
561
+ result = await asyncio.wait_for(self._on_request(dispatch_msg), timeout=30)
562
+ if result is None:
563
+ await self._send_invoke_result(
564
+ invoke_id, ok=False, error=f"unknown command: {command}"
565
+ )
566
+ else:
567
+ await self._send_invoke_result(invoke_id, ok=True, result_payload=result)
568
+ except TimeoutError:
569
+ await self._send_invoke_result(invoke_id, ok=False, error="handler timeout (30s)")
570
+ except Exception as exc:
571
+ logger.warning("Invoke handler error for %s", command, exc_info=True)
572
+ await self._send_invoke_result(invoke_id, ok=False, error=str(exc))
573
+
574
+ async def _send_invoke_result(
575
+ self,
576
+ invoke_id: str,
577
+ *,
578
+ ok: bool,
579
+ result_payload: dict[str, Any] | None = None,
580
+ error: str | None = None,
581
+ ) -> None:
582
+ """Send a ``node.invoke.result`` request back to the gateway."""
583
+ if self._ws is None:
584
+ logger.warning("Cannot send invoke result for %s — not connected", invoke_id)
585
+ return
586
+
587
+ params: dict[str, Any] = {
588
+ "id": invoke_id,
589
+ "nodeId": self._node_id or "",
590
+ "ok": ok,
591
+ }
592
+ if ok and result_payload is not None:
593
+ params["payloadJSON"] = json.dumps(result_payload, default=str)
594
+ if not ok and error is not None:
595
+ params["error"] = {"message": error}
596
+
597
+ frame: dict[str, Any] = {
598
+ "type": "req",
599
+ "id": self._next_id(),
600
+ "method": "node.invoke.result",
601
+ "params": params,
602
+ }
603
+
604
+ wire = json.dumps(frame)
605
+ logger.info("Sending invoke result: %s", wire[:500])
606
+ try:
607
+ await self._ws.send(wire)
608
+ except Exception:
609
+ logger.warning("Failed to send invoke result for %s", invoke_id, exc_info=True)
610
+ self._connected = False
611
+
612
+ # -- Outbound event sending -----------------------------------------------
613
+
614
+ async def send_event(self, event: dict[str, Any]) -> None:
615
+ """Send an event to the gateway as a typed request frame.
616
+
617
+ Wraps the event dict in the ``{type:"req", id, method, params}``
618
+ envelope if it doesn't already have a ``type`` field.
619
+
620
+ Silently drops the event if not connected. Never raises on send
621
+ failure — logs and marks as disconnected instead.
622
+ """
623
+ if not self._connected or self._ws is None:
624
+ self._drop_count += 1
625
+ if self._drop_count == 1 or self._drop_count % 100 == 0:
626
+ logger.warning("Event dropped (not connected) — total drops: %d", self._drop_count)
627
+ return
628
+
629
+ if "type" not in event:
630
+ event = {
631
+ "type": "req",
632
+ "id": self._next_id(),
633
+ **event,
634
+ }
635
+
636
+ try:
637
+ wire = json.dumps(event, default=str)
638
+ except (TypeError, ValueError):
639
+ self._drop_count += 1
640
+ logger.error(
641
+ "Event serialization failed (total drops: %d) — event keys: %s",
642
+ self._drop_count,
643
+ list(event.keys()),
644
+ exc_info=True,
645
+ )
646
+ return
647
+
648
+ try:
649
+ await self._ws.send(wire)
650
+ self._send_count += 1
651
+ except Exception:
652
+ self._drop_count += 1
653
+ logger.warning(
654
+ "Send failed — marking disconnected (total drops: %d)", self._drop_count
655
+ )
656
+ self._connected = False
657
+
658
+ async def close(self) -> None:
659
+ """Close the gateway connection gracefully."""
660
+ import contextlib
661
+
662
+ from websockets.exceptions import ConnectionClosed
663
+
664
+ self._connected = False
665
+ if self._recv_task is not None and not self._recv_task.done():
666
+ self._recv_task.cancel()
667
+ with contextlib.suppress(asyncio.CancelledError):
668
+ await self._recv_task
669
+ self._recv_task = None
670
+ if self._ws is not None:
671
+ with contextlib.suppress(ConnectionClosed, OSError):
672
+ await self._ws.close()
673
+ self._ws = None
674
+
675
+ async def connect_with_backoff(self, *, max_attempts: int = 0) -> None:
676
+ """Connect with exponential backoff retry.
677
+
678
+ Parameters
679
+ ----------
680
+ max_attempts:
681
+ Maximum connection attempts. ``0`` means infinite.
682
+ """
683
+ await _retry_with_backoff(
684
+ self.connect,
685
+ label=f"Connecting to {self._url}",
686
+ max_attempts=max_attempts,
687
+ )