openclaw-elys 1.5.0 → 1.6.0

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.
@@ -31,7 +31,7 @@ export async function monitorElysProvider(opts) {
31
31
  const dispatchReply = channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher;
32
32
  const finalizeCtx = channelRuntime?.reply?.finalizeInboundContext;
33
33
  log(`[elys] channelRuntime available: ${!!channelRuntime}, dispatchReply: ${!!dispatchReply}, finalizeCtx: ${!!finalizeCtx}`);
34
- const commandHandler = async (cmd) => {
34
+ const commandHandler = async (cmd, signal) => {
35
35
  log(`[elys] executing command: ${cmd.command}`, cmd.args);
36
36
  // If we have the full OpenClaw channelRuntime, use the standard dispatch path
37
37
  if (dispatchReply && finalizeCtx) {
@@ -59,8 +59,10 @@ export async function monitorElysProvider(opts) {
59
59
  ctx: inboundCtx,
60
60
  cfg: opts.config,
61
61
  dispatcherOptions: {
62
- // Single deliver callback with kind discriminator (matches OpenClaw API)
63
62
  deliver: async (payload, info) => {
63
+ // Skip publishing if this command was aborted by a newer one
64
+ if (signal.aborted)
65
+ return;
64
66
  if (payload.text) {
65
67
  fullText += payload.text;
66
68
  seq++;
@@ -1,8 +1,20 @@
1
1
  import type { DeviceCredentials, CommandMessage, ResultMessage } from "./types.js";
2
- export type CommandHandler = (cmd: CommandMessage) => Promise<ResultMessage>;
2
+ export type CommandHandler = (cmd: CommandMessage, signal: AbortSignal) => Promise<ResultMessage>;
3
+ export interface MQTTClientOptions {
4
+ /** Debounce window in ms. Rapid messages within this window are merged. Default: 500 */
5
+ debounceMs?: number;
6
+ /** Command execution timeout in ms. Default: 120000 (2 min) */
7
+ commandTimeoutMs?: number;
8
+ }
3
9
  /**
4
10
  * Manages the MQTT connection to EMQX for a single device.
5
11
  * Subscribes to elys/down/{device_id}, publishes to elys/up/{device_id}.
12
+ *
13
+ * Features:
14
+ * - Debounce: rapid messages within a window are merged into one
15
+ * - Dedup: QoS 1 retransmissions are silently skipped
16
+ * - Abort: new command aborts the currently running one
17
+ * - Timeout: commands that run too long are killed
6
18
  */
7
19
  export declare class ElysDeviceMQTTClient {
8
20
  private client;
@@ -11,14 +23,25 @@ export declare class ElysDeviceMQTTClient {
11
23
  private log;
12
24
  private consecutiveFailures;
13
25
  private static readonly MAX_FAILURES_BEFORE_REVOKE_WARNING;
14
- private commandQueue;
15
- constructor(credentials: DeviceCredentials, log?: (...args: unknown[]) => void);
26
+ private readonly debounceMs;
27
+ private debounceTimer;
28
+ private debounceBuffer;
29
+ private recentCommandIds;
30
+ private static readonly DEDUP_TTL_MS;
31
+ private currentAbort;
32
+ private readonly commandTimeoutMs;
33
+ constructor(credentials: DeviceCredentials, log?: (...args: unknown[]) => void, options?: MQTTClientOptions);
16
34
  setCommandHandler(handler: CommandHandler): void;
17
35
  connect(abortSignal?: AbortSignal): Promise<void>;
18
36
  disconnect(): void;
19
37
  /** Send a stream chunk (for streaming AI responses) */
20
38
  publishStreamChunk(commandId: string, chunk: string, seq: number, done: boolean, replyTo?: string): void;
21
- private handleMessage;
39
+ private onMessage;
40
+ private flushDebounce;
41
+ /** Merge multiple buffered commands into one. Joins text args with newline. */
42
+ private mergeCommands;
43
+ private executeCommand;
44
+ private cleanupDedup;
22
45
  private publishAck;
23
46
  private publish;
24
47
  }
@@ -2,6 +2,12 @@ import mqtt from "mqtt";
2
2
  /**
3
3
  * Manages the MQTT connection to EMQX for a single device.
4
4
  * Subscribes to elys/down/{device_id}, publishes to elys/up/{device_id}.
5
+ *
6
+ * Features:
7
+ * - Debounce: rapid messages within a window are merged into one
8
+ * - Dedup: QoS 1 retransmissions are silently skipped
9
+ * - Abort: new command aborts the currently running one
10
+ * - Timeout: commands that run too long are killed
5
11
  */
6
12
  export class ElysDeviceMQTTClient {
7
13
  client = null;
@@ -10,10 +16,21 @@ export class ElysDeviceMQTTClient {
10
16
  log;
11
17
  consecutiveFailures = 0;
12
18
  static MAX_FAILURES_BEFORE_REVOKE_WARNING = 3;
13
- commandQueue = Promise.resolve();
14
- constructor(credentials, log) {
19
+ // Debounce
20
+ debounceMs;
21
+ debounceTimer = null;
22
+ debounceBuffer = [];
23
+ // Dedup (command id → receive timestamp)
24
+ recentCommandIds = new Map();
25
+ static DEDUP_TTL_MS = 60_000;
26
+ // Abort & timeout
27
+ currentAbort = null;
28
+ commandTimeoutMs;
29
+ constructor(credentials, log, options) {
15
30
  this.credentials = credentials;
16
31
  this.log = log ?? console.log;
32
+ this.debounceMs = options?.debounceMs ?? 500;
33
+ this.commandTimeoutMs = options?.commandTimeoutMs ?? 120_000;
17
34
  }
18
35
  setCommandHandler(handler) {
19
36
  this.commandHandler = handler;
@@ -27,7 +44,7 @@ export class ElysDeviceMQTTClient {
27
44
  password: deviceToken,
28
45
  reconnectPeriod: 5000,
29
46
  keepalive: 30,
30
- rejectUnauthorized: true, // verify TLS cert
47
+ rejectUnauthorized: true,
31
48
  });
32
49
  this.client.on("connect", () => {
33
50
  this.log(`[elys] MQTT connected as ${deviceId}`);
@@ -43,10 +60,7 @@ export class ElysDeviceMQTTClient {
43
60
  });
44
61
  });
45
62
  this.client.on("message", (_topic, payload) => {
46
- // Serial queue: commands are processed one at a time in arrival order
47
- this.commandQueue = this.commandQueue.then(() => this.handleMessage(payload), () => this.handleMessage(payload)).catch((err) => {
48
- this.log("[elys] error handling message:", err);
49
- });
63
+ this.onMessage(payload);
50
64
  });
51
65
  this.client.on("reconnect", () => {
52
66
  this.consecutiveFailures++;
@@ -60,7 +74,6 @@ export class ElysDeviceMQTTClient {
60
74
  }
61
75
  });
62
76
  this.client.on("error", (err) => {
63
- // MQTT auth failures indicate revoked credentials
64
77
  if (err.message.includes("Not authorized") || err.message.includes("Connection refused")) {
65
78
  this.log(`[elys] ERROR: MQTT authentication failed — device credentials may have been revoked. ` +
66
79
  `Please re-register: npx openclaw-elys setup <gateway_url> <register_token>`);
@@ -72,13 +85,11 @@ export class ElysDeviceMQTTClient {
72
85
  this.client.on("close", () => {
73
86
  this.log("[elys] MQTT connection closed");
74
87
  });
75
- // Handle abort signal for graceful shutdown
76
88
  if (abortSignal) {
77
89
  abortSignal.addEventListener("abort", () => {
78
90
  this.disconnect();
79
91
  });
80
92
  }
81
- // Wait for initial connection
82
93
  return new Promise((resolve, reject) => {
83
94
  const onConnect = () => {
84
95
  cleanup();
@@ -97,6 +108,11 @@ export class ElysDeviceMQTTClient {
97
108
  });
98
109
  }
99
110
  disconnect() {
111
+ if (this.debounceTimer) {
112
+ clearTimeout(this.debounceTimer);
113
+ this.debounceTimer = null;
114
+ }
115
+ this.currentAbort?.abort();
100
116
  if (this.client) {
101
117
  this.client.end(true);
102
118
  this.client = null;
@@ -115,42 +131,122 @@ export class ElysDeviceMQTTClient {
115
131
  };
116
132
  this.publish(msg, replyTo);
117
133
  }
118
- async handleMessage(payload) {
119
- const raw = JSON.parse(payload.toString());
134
+ // ─── Inbound message pipeline: dedup → ack → debounce → abort → execute ───
135
+ onMessage(payload) {
136
+ let raw;
137
+ try {
138
+ raw = JSON.parse(payload.toString());
139
+ }
140
+ catch {
141
+ this.log("[elys] failed to parse MQTT message");
142
+ return;
143
+ }
120
144
  if (raw.type !== "command") {
121
145
  this.log(`[elys] ignoring non-command message type: ${raw.type}`);
122
146
  return;
123
147
  }
148
+ // 1. Dedup — skip QoS 1 retransmissions
149
+ if (this.recentCommandIds.has(raw.id)) {
150
+ this.log(`[elys] dedup: skipping duplicate command ${raw.id}`);
151
+ return;
152
+ }
153
+ this.recentCommandIds.set(raw.id, Date.now());
154
+ this.cleanupDedup();
124
155
  this.log(`[elys] received command: ${raw.command} (id: ${raw.id})`);
125
- // ACK always goes to shared upstream topic (any gateway instance can process it)
156
+ // 2. ACK immediately (goes to shared upstream topic)
126
157
  this.publishAck(raw.id);
127
- // Result/stream goes to reply_to if present (routes to the specific gateway instance)
128
- const replyTo = raw.reply_to;
129
- // Execute command
130
- if (this.commandHandler) {
131
- try {
132
- const result = await this.commandHandler(raw);
158
+ // 3. Debounce buffer and wait for more messages
159
+ this.debounceBuffer.push(raw);
160
+ if (this.debounceTimer) {
161
+ clearTimeout(this.debounceTimer);
162
+ }
163
+ this.debounceTimer = setTimeout(() => {
164
+ this.flushDebounce();
165
+ }, this.debounceMs);
166
+ }
167
+ flushDebounce() {
168
+ const buffered = this.debounceBuffer;
169
+ this.debounceBuffer = [];
170
+ this.debounceTimer = null;
171
+ if (buffered.length === 0)
172
+ return;
173
+ // 4. Abort previous running command
174
+ this.currentAbort?.abort();
175
+ this.currentAbort = new AbortController();
176
+ const merged = this.mergeCommands(buffered);
177
+ // 5. Execute (fire-and-forget, errors handled inside)
178
+ this.executeCommand(merged, this.currentAbort.signal).catch((err) => {
179
+ this.log("[elys] unexpected error in executeCommand:", err);
180
+ });
181
+ }
182
+ /** Merge multiple buffered commands into one. Joins text args with newline. */
183
+ mergeCommands(cmds) {
184
+ if (cmds.length === 1)
185
+ return cmds[0];
186
+ const last = cmds[cmds.length - 1];
187
+ const texts = cmds
188
+ .map((c) => c.args?.text ?? "")
189
+ .filter(Boolean);
190
+ if (texts.length > 1) {
191
+ this.log(`[elys] debounce: merged ${cmds.length} commands`);
192
+ return {
193
+ ...last,
194
+ args: { ...last.args, text: texts.join("\n") },
195
+ };
196
+ }
197
+ // Can't merge non-text commands, use the latest one
198
+ this.log(`[elys] debounce: using latest of ${cmds.length} commands`);
199
+ return last;
200
+ }
201
+ async executeCommand(cmd, signal) {
202
+ if (!this.commandHandler)
203
+ return;
204
+ const replyTo = cmd.reply_to;
205
+ try {
206
+ const result = await Promise.race([
207
+ this.commandHandler(cmd, signal),
208
+ new Promise((_, reject) => {
209
+ const timer = setTimeout(() => {
210
+ reject(new Error(`command timed out after ${this.commandTimeoutMs / 1000}s`));
211
+ }, this.commandTimeoutMs);
212
+ // Clean up timer if command completes or is aborted
213
+ signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
214
+ }),
215
+ ]);
216
+ if (!signal.aborted) {
133
217
  this.publish(result, replyTo);
134
218
  }
135
- catch (err) {
136
- const errMsg = err instanceof Error ? err.message : String(err);
137
- this.publish({
138
- id: raw.id,
139
- type: "result",
140
- timestamp: Date.now() / 1000,
141
- status: "error",
142
- error: errMsg,
143
- }, replyTo);
219
+ }
220
+ catch (err) {
221
+ if (signal.aborted) {
222
+ this.log(`[elys] command ${cmd.id} aborted`);
223
+ return;
224
+ }
225
+ const errMsg = err instanceof Error ? err.message : String(err);
226
+ this.publish({
227
+ id: cmd.id,
228
+ type: "result",
229
+ timestamp: Math.floor(Date.now() / 1000),
230
+ status: "error",
231
+ error: errMsg,
232
+ }, replyTo);
233
+ }
234
+ }
235
+ cleanupDedup() {
236
+ const now = Date.now();
237
+ for (const [id, ts] of this.recentCommandIds) {
238
+ if (now - ts > ElysDeviceMQTTClient.DEDUP_TTL_MS) {
239
+ this.recentCommandIds.delete(id);
144
240
  }
145
241
  }
146
242
  }
243
+ // ─── Outbound helpers ───
147
244
  publishAck(commandId) {
148
245
  const msg = {
149
246
  id: commandId,
150
247
  type: "ack",
151
248
  timestamp: Math.floor(Date.now() / 1000),
152
249
  };
153
- // ACK always to default upstream topic (shared subscription)
154
250
  this.publish(msg);
155
251
  }
156
252
  publish(msg, replyTo) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-elys",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "OpenClaw Elys channel plugin — connects to Elys App",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",