openclaw-elys 1.5.0 → 1.7.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/README.md +16 -0
- package/dist/index.js +2 -0
- package/dist/src/channel.d.ts +0 -1
- package/dist/src/channel.js +0 -1
- package/dist/src/monitor.d.ts +1 -33
- package/dist/src/monitor.js +57 -39
- package/dist/src/mqtt-client.d.ts +27 -4
- package/dist/src/mqtt-client.js +125 -29
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,22 @@ OpenClaw channel plugin for [Elys App](https://elys.ai) — connects your local
|
|
|
4
4
|
|
|
5
5
|
Elys App 的 OpenClaw 频道插件 — 将本地 OpenClaw 智能体连接到 Elys 手机应用。
|
|
6
6
|
|
|
7
|
+
## Requirements / 版本要求
|
|
8
|
+
|
|
9
|
+
OpenClaw >= **2026.2.19**(通过 `openclaw -v` 查看当前版本)
|
|
10
|
+
|
|
11
|
+
低于该版本可能导致 AI 推理无法正常工作(缺少 channelRuntime 支持)。
|
|
12
|
+
|
|
13
|
+
升级方式:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g openclaw
|
|
17
|
+
# 国内网络慢可使用镜像源:
|
|
18
|
+
npm install -g openclaw --registry=https://registry.npmmirror.com
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
其他升级方式参考:[OpenClaw 安装文档](https://docs.openclaw.ai/install/development-channels#switching-channels)
|
|
22
|
+
|
|
7
23
|
## Install & Setup / 安装与配置
|
|
8
24
|
|
|
9
25
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { elysPlugin } from "./src/channel.js";
|
|
2
|
+
import { setElysRuntime } from "./src/runtime.js";
|
|
2
3
|
export { elysPlugin } from "./src/channel.js";
|
|
3
4
|
export { monitorElysProvider } from "./src/monitor.js";
|
|
4
5
|
export { registerDevice } from "./src/register.js";
|
|
@@ -10,6 +11,7 @@ const plugin = {
|
|
|
10
11
|
description: "Elys App channel plugin — connects to Elys App via MQTT gateway",
|
|
11
12
|
configSchema: { type: "object", properties: {} },
|
|
12
13
|
register(api) {
|
|
14
|
+
setElysRuntime(api.runtime);
|
|
13
15
|
api.registerChannel({ plugin: elysPlugin });
|
|
14
16
|
},
|
|
15
17
|
};
|
package/dist/src/channel.d.ts
CHANGED
package/dist/src/channel.js
CHANGED
package/dist/src/monitor.d.ts
CHANGED
|
@@ -3,39 +3,8 @@ export interface MonitorElysOpts {
|
|
|
3
3
|
runtime?: Record<string, unknown>;
|
|
4
4
|
abortSignal?: AbortSignal;
|
|
5
5
|
accountId?: string;
|
|
6
|
-
channelRuntime?:
|
|
6
|
+
channelRuntime?: unknown;
|
|
7
7
|
}
|
|
8
|
-
/** Subset of OpenClaw's PluginRuntime["channel"] that we use */
|
|
9
|
-
type ReplyPayload = {
|
|
10
|
-
text?: string;
|
|
11
|
-
mediaUrl?: string;
|
|
12
|
-
audioAsVoice?: boolean;
|
|
13
|
-
[key: string]: unknown;
|
|
14
|
-
};
|
|
15
|
-
type ChannelRuntimeReply = {
|
|
16
|
-
reply?: {
|
|
17
|
-
dispatchReplyWithBufferedBlockDispatcher?: (params: {
|
|
18
|
-
ctx: Record<string, unknown>;
|
|
19
|
-
cfg: Record<string, unknown>;
|
|
20
|
-
dispatcherOptions: {
|
|
21
|
-
deliver: (payload: ReplyPayload, info: {
|
|
22
|
-
kind: "tool" | "block" | "final";
|
|
23
|
-
}) => Promise<void>;
|
|
24
|
-
onError?: (err: unknown, info: {
|
|
25
|
-
kind: string;
|
|
26
|
-
}) => void;
|
|
27
|
-
};
|
|
28
|
-
replyOptions?: Record<string, unknown>;
|
|
29
|
-
}) => Promise<{
|
|
30
|
-
queuedFinal: boolean;
|
|
31
|
-
counts: Record<string, number>;
|
|
32
|
-
}>;
|
|
33
|
-
finalizeInboundContext?: (ctx: Record<string, unknown>) => Record<string, unknown>;
|
|
34
|
-
};
|
|
35
|
-
routing?: {
|
|
36
|
-
resolveAgentRoute?: (params: Record<string, unknown>) => Record<string, unknown>;
|
|
37
|
-
};
|
|
38
|
-
};
|
|
39
8
|
/**
|
|
40
9
|
* The main monitor loop for the Elys channel.
|
|
41
10
|
* Ensures device is registered, then connects to MQTT and dispatches
|
|
@@ -43,4 +12,3 @@ type ChannelRuntimeReply = {
|
|
|
43
12
|
* channelRuntime dispatch API (supports streaming).
|
|
44
13
|
*/
|
|
45
14
|
export declare function monitorElysProvider(opts: MonitorElysOpts): Promise<void>;
|
|
46
|
-
export {};
|
package/dist/src/monitor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadCredentials } from "./config.js";
|
|
2
2
|
import { registerDevice } from "./register.js";
|
|
3
3
|
import { ElysDeviceMQTTClient } from "./mqtt-client.js";
|
|
4
|
+
import { getElysRuntime } from "./runtime.js";
|
|
4
5
|
/**
|
|
5
6
|
* The main monitor loop for the Elys channel.
|
|
6
7
|
* Ensures device is registered, then connects to MQTT and dispatches
|
|
@@ -26,19 +27,18 @@ export async function monitorElysProvider(opts) {
|
|
|
26
27
|
}
|
|
27
28
|
// 2. Connect MQTT
|
|
28
29
|
const mqttClient = new ElysDeviceMQTTClient(credentials, log);
|
|
29
|
-
// 3. Set up command handler
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
// 3. Set up command handler using PluginRuntime (same pattern as feishu)
|
|
31
|
+
const core = getElysRuntime();
|
|
32
|
+
const dispatchReplyFromConfig = core?.channel?.reply?.dispatchReplyFromConfig;
|
|
33
|
+
const withReplyDispatcher = core?.channel?.reply?.withReplyDispatcher;
|
|
34
|
+
const finalizeCtx = core?.channel?.reply?.finalizeInboundContext;
|
|
35
|
+
log(`[elys] pluginRuntime available: ${!!core}, dispatchReplyFromConfig: ${!!dispatchReplyFromConfig}, finalizeCtx: ${!!finalizeCtx}`);
|
|
36
|
+
const commandHandler = async (cmd, signal) => {
|
|
35
37
|
log(`[elys] executing command: ${cmd.command}`, cmd.args);
|
|
36
|
-
|
|
37
|
-
if (dispatchReply && finalizeCtx) {
|
|
38
|
+
if (dispatchReplyFromConfig && finalizeCtx) {
|
|
38
39
|
try {
|
|
39
40
|
let seq = 0;
|
|
40
41
|
let fullText = "";
|
|
41
|
-
// Build inbound context following OpenClaw protocol
|
|
42
42
|
const inboundCtx = finalizeCtx({
|
|
43
43
|
Body: formatCommandAsText(cmd),
|
|
44
44
|
BodyForAgent: formatCommandAsText(cmd),
|
|
@@ -55,35 +55,53 @@ export async function monitorElysProvider(opts) {
|
|
|
55
55
|
OriginatingChannel: "elys",
|
|
56
56
|
OriginatingTo: credentials.deviceId,
|
|
57
57
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
58
|
+
// Deliver callback: stream chunks back via MQTT
|
|
59
|
+
const deliver = async (payload, info) => {
|
|
60
|
+
if (signal.aborted)
|
|
61
|
+
return;
|
|
62
|
+
if (payload.text) {
|
|
63
|
+
fullText += payload.text;
|
|
64
|
+
seq++;
|
|
65
|
+
const done = info.kind === "final";
|
|
66
|
+
mqttClient.publishStreamChunk(cmd.id, payload.text, seq, done, cmd.reply_to);
|
|
67
|
+
if (info.kind === "block") {
|
|
68
|
+
log(`[elys] stream chunk #${seq}: ${payload.text.slice(0, 80)}...`);
|
|
69
|
+
}
|
|
70
|
+
else if (info.kind === "final") {
|
|
71
|
+
log(`[elys] final reply delivered`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (info.kind === "final") {
|
|
75
|
+
seq++;
|
|
76
|
+
mqttClient.publishStreamChunk(cmd.id, "", seq, true, cmd.reply_to);
|
|
77
|
+
log(`[elys] final reply delivered (empty)`);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
if (withReplyDispatcher) {
|
|
81
|
+
// Preferred path: withReplyDispatcher + dispatchReplyFromConfig (same as feishu)
|
|
82
|
+
await withReplyDispatcher({
|
|
83
|
+
ctx: inboundCtx,
|
|
84
|
+
cfg: opts.config,
|
|
85
|
+
dispatcher: { deliver },
|
|
86
|
+
fn: (dispatchCtx) => dispatchReplyFromConfig({
|
|
87
|
+
ctx: dispatchCtx,
|
|
88
|
+
cfg: opts.config,
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// Direct dispatch fallback
|
|
94
|
+
await dispatchReplyFromConfig({
|
|
95
|
+
ctx: inboundCtx,
|
|
96
|
+
cfg: opts.config,
|
|
97
|
+
dispatcherOptions: {
|
|
98
|
+
deliver,
|
|
99
|
+
onError: (err, info) => {
|
|
100
|
+
log(`[elys] dispatch error (${info.kind}):`, err);
|
|
101
|
+
},
|
|
84
102
|
},
|
|
85
|
-
}
|
|
86
|
-
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
87
105
|
return {
|
|
88
106
|
id: cmd.id,
|
|
89
107
|
type: "result",
|
|
@@ -103,8 +121,8 @@ export async function monitorElysProvider(opts) {
|
|
|
103
121
|
};
|
|
104
122
|
}
|
|
105
123
|
}
|
|
106
|
-
// Fallback: echo the command back (no
|
|
107
|
-
log(`[elys] no
|
|
124
|
+
// Fallback: echo the command back (no pluginRuntime available)
|
|
125
|
+
log(`[elys] no pluginRuntime — using fallback echo handler`);
|
|
108
126
|
return {
|
|
109
127
|
id: cmd.id,
|
|
110
128
|
type: "result",
|
|
@@ -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) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Stores the PluginRuntime passed via api.runtime during plugin registration.
|
|
2
|
+
// This is the same pattern used by the built-in feishu channel plugin.
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
4
|
+
let runtime = null;
|
|
5
|
+
export function setElysRuntime(next) {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
export function getElysRuntime() {
|
|
9
|
+
return runtime;
|
|
10
|
+
}
|