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
|
@@ -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
|
+
)
|