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.
- package/dist/src/monitor.js +4 -2
- package/dist/src/mqtt-client.d.ts +27 -4
- package/dist/src/mqtt-client.js +125 -29
- package/package.json +1 -1
package/dist/src/monitor.js
CHANGED
|
@@ -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
|
|
15
|
-
|
|
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
|
|
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
|
}
|
package/dist/src/mqtt-client.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
156
|
+
// 2. ACK immediately (goes to shared upstream topic)
|
|
126
157
|
this.publishAck(raw.id);
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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) {
|