@openbrt/weclawbotctl 0.1.8 → 0.1.9
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 +10 -0
- package/bin/weclawbotctl.mjs +47 -7
- package/index.mjs +25 -4
- package/lib/mqtt-control.mjs +105 -28
- package/package.json +1 -1
- package/skills/weclawbot-curator/SKILL.md +6 -0
- package/workspace/AGENTS.md +5 -0
package/README.md
CHANGED
|
@@ -60,6 +60,11 @@ it does not lay out text, choose fonts, or split pages for agents.
|
|
|
60
60
|
weclawbotctl screen /path/to/screen-document.json
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
`screen` waits for the device status topic by default. It exits successfully
|
|
64
|
+
only after the firmware reports `applied`; a firmware `rejected` status or a
|
|
65
|
+
timeout is a failed delivery. Use `--no-wait` only for diagnostics where MQTT
|
|
66
|
+
publish acknowledgement is enough.
|
|
67
|
+
|
|
63
68
|
The package also includes an OpenClaw integration: the `weclawbot-curator`
|
|
64
69
|
skill, `weclawbot_status`, `weclawbot_validate_screen_document`,
|
|
65
70
|
`weclawbot_publish_screen_document`, `weclawbot_validate_activity`,
|
|
@@ -254,6 +259,11 @@ the firmware supports forced replacement, use:
|
|
|
254
259
|
weclawbotctl screen /path/to/screen-document.json --force
|
|
255
260
|
```
|
|
256
261
|
|
|
262
|
+
The command waits for the device's `applied`/`rejected` status by default, so
|
|
263
|
+
an Agent must not tell the user the content is on the screen until the command
|
|
264
|
+
returns success. If the device reports `stale_screen_revision`, regenerate the
|
|
265
|
+
document with the current revision or intentionally overwrite with `--force`.
|
|
266
|
+
|
|
257
267
|
These commands use MQTT/TLS directly, publish QoS 1 without retain, and
|
|
258
268
|
never create an offline command queue. See
|
|
259
269
|
`docs/agent-direct-control-protocol.md` in the firmware repository for the
|
package/bin/weclawbotctl.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import process from "node:process";
|
|
|
9
9
|
|
|
10
10
|
import { validateActivity } from "../lib/activity.mjs";
|
|
11
11
|
import { validateScreenDocument } from "../lib/direct-control.mjs";
|
|
12
|
-
import { normalizeCredentials, publishControl, testConnection } from "../lib/mqtt-control.mjs";
|
|
12
|
+
import { normalizeCredentials, publishControl, publishControlAndWaitStatus, testConnection } from "../lib/mqtt-control.mjs";
|
|
13
13
|
|
|
14
14
|
const DEFAULT_ENDPOINT = "https://weclawbot.link/byoa";
|
|
15
15
|
const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
|
|
@@ -202,10 +202,15 @@ async function commandUnbind(values) {
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
async function commandScreen(values) {
|
|
205
|
-
const options = parseOptions(values, {
|
|
205
|
+
const options = parseOptions(values, {
|
|
206
|
+
credentials: credentialsPath(),
|
|
207
|
+
force: false,
|
|
208
|
+
wait: true,
|
|
209
|
+
timeout: 12,
|
|
210
|
+
});
|
|
206
211
|
const file = String(options._[0] || "").trim();
|
|
207
212
|
if (!file || options._.length !== 1) {
|
|
208
|
-
throw new Error("Usage: weclawbotctl screen <document.json> [--force]");
|
|
213
|
+
throw new Error("Usage: weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds]");
|
|
209
214
|
}
|
|
210
215
|
const document = JSON.parse(await fs.readFile(file, "utf8"));
|
|
211
216
|
if (options.force) {
|
|
@@ -218,13 +223,41 @@ async function commandScreen(values) {
|
|
|
218
223
|
if (!validation.ok) {
|
|
219
224
|
throw new Error(`Invalid screen document: ${validation.errors.join("; ")}`);
|
|
220
225
|
}
|
|
221
|
-
|
|
226
|
+
const control = {
|
|
222
227
|
schema: "weclawbot.control.v1",
|
|
223
228
|
id: `screen_${crypto.randomUUID()}`,
|
|
224
229
|
kind: "screen_document",
|
|
225
230
|
document,
|
|
226
|
-
}
|
|
227
|
-
|
|
231
|
+
};
|
|
232
|
+
const credentials = await requireCredentials(expandPath(options.credentials));
|
|
233
|
+
if (options.wait) {
|
|
234
|
+
const delivery = await publishControlAndWaitStatus(credentials, control, {
|
|
235
|
+
expectedDetail: document.id,
|
|
236
|
+
timeoutMs: Math.max(1, Number(options.timeout) || 12) * 1000,
|
|
237
|
+
});
|
|
238
|
+
if (delivery.status.kind !== "applied") {
|
|
239
|
+
throw new Error(`Device rejected screen document: ${delivery.status.detail || "unknown"}`);
|
|
240
|
+
}
|
|
241
|
+
console.log(JSON.stringify({
|
|
242
|
+
ok: true,
|
|
243
|
+
published: true,
|
|
244
|
+
applied: true,
|
|
245
|
+
id: document.id,
|
|
246
|
+
pages: document.pages.length,
|
|
247
|
+
force_replace: document.force_replace === true,
|
|
248
|
+
status: delivery.status,
|
|
249
|
+
}));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
await publishControl(credentials, control);
|
|
253
|
+
console.log(JSON.stringify({
|
|
254
|
+
ok: true,
|
|
255
|
+
published: true,
|
|
256
|
+
applied: null,
|
|
257
|
+
id: document.id,
|
|
258
|
+
pages: document.pages.length,
|
|
259
|
+
force_replace: document.force_replace === true,
|
|
260
|
+
}));
|
|
228
261
|
}
|
|
229
262
|
|
|
230
263
|
async function commandActivity(state, values) {
|
|
@@ -350,6 +383,13 @@ function parseOptions(values, defaults = {}) {
|
|
|
350
383
|
const [rawKey, inlineValue] = value.slice(2).split("=", 2);
|
|
351
384
|
const key = rawKey.trim();
|
|
352
385
|
if (!key) continue;
|
|
386
|
+
if (key.startsWith("no-")) {
|
|
387
|
+
const positive = key.slice(3);
|
|
388
|
+
if (typeof options[positive] === "boolean" && inlineValue === undefined) {
|
|
389
|
+
options[positive] = false;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
353
393
|
if (typeof options[key] === "boolean") {
|
|
354
394
|
options[key] = inlineValue === undefined ? true : !/^(0|false|no|off)$/iu.test(inlineValue);
|
|
355
395
|
} else {
|
|
@@ -620,7 +660,7 @@ function usage() {
|
|
|
620
660
|
weclawbotctl unbind --yes
|
|
621
661
|
weclawbotctl thinking [--ttl seconds] [--id correlation-id]
|
|
622
662
|
weclawbotctl idle [--id correlation-id]
|
|
623
|
-
weclawbotctl screen <document.json> [--force]
|
|
663
|
+
weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds]
|
|
624
664
|
weclawbotctl openclaw install [--spec @openbrt/weclawbotctl] [--bin /path/to/openclaw] [--force=false]
|
|
625
665
|
weclawbotctl openclaw doctor [--gateway=false] [--bin /path/to/openclaw] [--json]`);
|
|
626
666
|
}
|
package/index.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
|
|
|
8
8
|
|
|
9
9
|
import { validateActivity } from "./lib/activity.mjs";
|
|
10
10
|
import { validateScreenDocument } from "./lib/direct-control.mjs";
|
|
11
|
-
import { normalizeCredentials, publishControl, testConnection } from "./lib/mqtt-control.mjs";
|
|
11
|
+
import { normalizeCredentials, publishControl, publishControlAndWaitStatus, testConnection } from "./lib/mqtt-control.mjs";
|
|
12
12
|
|
|
13
13
|
const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
|
|
14
14
|
|
|
@@ -68,8 +68,10 @@ export default defineToolPlugin({
|
|
|
68
68
|
device_context: Type.Optional(Type.Any()),
|
|
69
69
|
credentials_path: Type.Optional(Type.String()),
|
|
70
70
|
force_replace: Type.Optional(Type.Boolean()),
|
|
71
|
+
wait_status: Type.Optional(Type.Boolean()),
|
|
72
|
+
timeout_seconds: Type.Optional(Type.Number()),
|
|
71
73
|
}, { additionalProperties: false }),
|
|
72
|
-
execute: async ({ document, device_context, credentials_path, force_replace }) => {
|
|
74
|
+
execute: async ({ document, device_context, credentials_path, force_replace, wait_status, timeout_seconds }) => {
|
|
73
75
|
const outbound = cloneObject(document);
|
|
74
76
|
if (force_replace) {
|
|
75
77
|
outbound.force_replace = true;
|
|
@@ -81,15 +83,34 @@ export default defineToolPlugin({
|
|
|
81
83
|
if (!validation.ok) {
|
|
82
84
|
return { ok: false, published: false, errors: validation.errors, validation };
|
|
83
85
|
}
|
|
84
|
-
|
|
86
|
+
const control = {
|
|
85
87
|
schema: "weclawbot.control.v1",
|
|
86
88
|
id: `screen_${crypto.randomUUID()}`,
|
|
87
89
|
kind: "screen_document",
|
|
88
90
|
document: outbound,
|
|
89
|
-
}
|
|
91
|
+
};
|
|
92
|
+
const credentials = await requireCredentials(credentials_path);
|
|
93
|
+
if (wait_status !== false) {
|
|
94
|
+
const delivery = await publishControlAndWaitStatus(credentials, control, {
|
|
95
|
+
expectedDetail: outbound.id,
|
|
96
|
+
timeoutMs: Math.max(1, Number(timeout_seconds) || 12) * 1000,
|
|
97
|
+
});
|
|
98
|
+
return {
|
|
99
|
+
ok: delivery.status.kind === "applied",
|
|
100
|
+
published: true,
|
|
101
|
+
applied: delivery.status.kind === "applied",
|
|
102
|
+
rejected: delivery.status.kind === "rejected",
|
|
103
|
+
id: outbound.id,
|
|
104
|
+
pages: outbound.pages.length,
|
|
105
|
+
force_replace: outbound.force_replace === true,
|
|
106
|
+
status: delivery.status,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
await publishControl(credentials, control);
|
|
90
110
|
return {
|
|
91
111
|
ok: true,
|
|
92
112
|
published: true,
|
|
113
|
+
applied: null,
|
|
93
114
|
id: outbound.id,
|
|
94
115
|
pages: outbound.pages.length,
|
|
95
116
|
force_replace: outbound.force_replace === true,
|
package/lib/mqtt-control.mjs
CHANGED
|
@@ -2,27 +2,33 @@ import mqtt from "mqtt";
|
|
|
2
2
|
|
|
3
3
|
export async function publishControl(credentials, control) {
|
|
4
4
|
const config = normalizeCredentials(credentials);
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
validateControl(control);
|
|
6
|
+
const client = connectMqtt(config);
|
|
7
|
+
try {
|
|
8
|
+
await onceConnected(client);
|
|
9
|
+
await publishJson(client, config.controlTopic, control);
|
|
10
|
+
} finally {
|
|
11
|
+
client.end(true);
|
|
7
12
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function publishControlAndWaitStatus(credentials, control, options = {}) {
|
|
16
|
+
const config = normalizeCredentials(credentials);
|
|
17
|
+
validateControl(control);
|
|
18
|
+
if (!config.statusTopic) throw new Error("agent_status_topic_missing");
|
|
19
|
+
const timeoutMs = Math.max(1000, Number(options.timeoutMs || 12_000));
|
|
20
|
+
const expectedDetail = string(options.expectedDetail);
|
|
21
|
+
const client = connectMqtt(config);
|
|
18
22
|
try {
|
|
19
23
|
await onceConnected(client);
|
|
20
|
-
await
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
});
|
|
24
|
+
await subscribe(client, config.statusTopic);
|
|
25
|
+
const statusPromise = waitForStatus(client, {
|
|
26
|
+
timeoutMs,
|
|
27
|
+
expectedDetail,
|
|
25
28
|
});
|
|
29
|
+
await publishJson(client, config.controlTopic, control);
|
|
30
|
+
const status = await statusPromise;
|
|
31
|
+
return { ok: status.kind === "applied", status };
|
|
26
32
|
} finally {
|
|
27
33
|
client.end(true);
|
|
28
34
|
}
|
|
@@ -30,16 +36,7 @@ export async function publishControl(credentials, control) {
|
|
|
30
36
|
|
|
31
37
|
export async function testConnection(credentials) {
|
|
32
38
|
const config = normalizeCredentials(credentials);
|
|
33
|
-
const client =
|
|
34
|
-
clientId: config.clientId,
|
|
35
|
-
username: config.username,
|
|
36
|
-
password: config.password,
|
|
37
|
-
clean: true,
|
|
38
|
-
reconnectPeriod: 0,
|
|
39
|
-
connectTimeout: 12_000,
|
|
40
|
-
protocolVersion: 5,
|
|
41
|
-
properties: { sessionExpiryInterval: 0 },
|
|
42
|
-
});
|
|
39
|
+
const client = connectMqtt(config);
|
|
43
40
|
try {
|
|
44
41
|
await onceConnected(client);
|
|
45
42
|
} finally {
|
|
@@ -62,11 +59,91 @@ export function normalizeCredentials(value) {
|
|
|
62
59
|
const password = string(mqttConfig?.password);
|
|
63
60
|
const clientId = string(mqttConfig?.client_id);
|
|
64
61
|
const controlTopic = string(topics?.control);
|
|
62
|
+
const statusTopic = string(topics?.status);
|
|
65
63
|
if (!url || !username || !password || !clientId || !controlTopic) {
|
|
66
64
|
throw new Error("agent_credentials_incomplete");
|
|
67
65
|
}
|
|
68
66
|
if (!/^wss:\/\//u.test(url)) throw new Error("agent_mqtt_requires_wss");
|
|
69
|
-
return { url, username, password, clientId, controlTopic };
|
|
67
|
+
return { url, username, password, clientId, controlTopic, statusTopic };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function connectMqtt(config) {
|
|
71
|
+
return mqtt.connect(config.url, {
|
|
72
|
+
clientId: config.clientId,
|
|
73
|
+
username: config.username,
|
|
74
|
+
password: config.password,
|
|
75
|
+
clean: true,
|
|
76
|
+
reconnectPeriod: 0,
|
|
77
|
+
connectTimeout: 12_000,
|
|
78
|
+
protocolVersion: 5,
|
|
79
|
+
properties: { sessionExpiryInterval: 0 },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateControl(control) {
|
|
84
|
+
if (!control || typeof control !== "object" || control.schema !== "weclawbot.control.v1") {
|
|
85
|
+
throw new Error("invalid_control_message");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function publishJson(client, topic, value) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
client.publish(topic, JSON.stringify(value), { qos: 1, retain: false }, (error) => {
|
|
92
|
+
if (error) reject(error);
|
|
93
|
+
else resolve();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function subscribe(client, topic) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
client.subscribe(topic, { qos: 1 }, (error) => {
|
|
101
|
+
if (error) reject(error);
|
|
102
|
+
else resolve();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function waitForStatus(client, { timeoutMs, expectedDetail }) {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const timeout = setTimeout(() => finish(new Error("device_status_timeout")), timeoutMs);
|
|
110
|
+
const finish = (error, status) => {
|
|
111
|
+
clearTimeout(timeout);
|
|
112
|
+
client.removeListener("message", onMessage);
|
|
113
|
+
client.removeListener("error", onError);
|
|
114
|
+
if (error) reject(error);
|
|
115
|
+
else resolve(status);
|
|
116
|
+
};
|
|
117
|
+
const onError = (error) => finish(error);
|
|
118
|
+
const onMessage = (_topic, payload) => {
|
|
119
|
+
const status = parseStatus(payload);
|
|
120
|
+
if (!status) return;
|
|
121
|
+
if (status.kind === "applied") {
|
|
122
|
+
if (expectedDetail && status.detail !== expectedDetail) return;
|
|
123
|
+
finish(null, status);
|
|
124
|
+
} else if (status.kind === "rejected") {
|
|
125
|
+
finish(null, status);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
client.on("message", onMessage);
|
|
129
|
+
client.once("error", onError);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseStatus(payload) {
|
|
134
|
+
try {
|
|
135
|
+
const status = JSON.parse(payload.toString("utf8"));
|
|
136
|
+
if (status?.schema !== "weclawbot.device_status.v1") return null;
|
|
137
|
+
if (status.kind !== "applied" && status.kind !== "rejected") return null;
|
|
138
|
+
return {
|
|
139
|
+
schema: status.schema,
|
|
140
|
+
kind: status.kind,
|
|
141
|
+
device_id: string(status.device_id),
|
|
142
|
+
detail: string(status.detail),
|
|
143
|
+
};
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
70
147
|
}
|
|
71
148
|
|
|
72
149
|
function onceConnected(client) {
|
package/package.json
CHANGED
|
@@ -59,6 +59,12 @@ OpenClaw Canvas for requests that mention WeClawBot, the physical screen, or
|
|
|
59
59
|
not send raw text to firmware. The agent owns text layout, font choice, image
|
|
60
60
|
rasterization, and page splitting; the device consumes pixels.
|
|
61
61
|
|
|
62
|
+
`weclawbotctl screen` and `weclawbot_publish_screen_document` wait for the
|
|
63
|
+
device status topic by default. Treat only `applied` as success. If the device
|
|
64
|
+
returns `rejected`, tell the user the real rejection reason. Use
|
|
65
|
+
`force_replace` or `weclawbotctl screen --force` only when the user explicitly
|
|
66
|
+
intends to overwrite the currently shown BYOA screen.
|
|
67
|
+
|
|
62
68
|
Only return the normal WeChat decision shape below when processing an explicit
|
|
63
69
|
`WECLAWBOT_CURATOR_EVENT` envelope.
|
|
64
70
|
|
package/workspace/AGENTS.md
CHANGED
|
@@ -37,6 +37,11 @@ content viewport, then publish it:
|
|
|
37
37
|
weclawbotctl screen /path/to/screen-document.json
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
This command waits for firmware `applied`/`rejected` status by default. Report
|
|
41
|
+
success only after it exits with success. If the user explicitly asks to
|
|
42
|
+
replace whatever is currently shown, use `--force`; otherwise use the current
|
|
43
|
+
screen revision in `base_revision`.
|
|
44
|
+
|
|
40
45
|
The firmware receives pixels. Do not send raw text to firmware, and do not
|
|
41
46
|
answer that direct delivery is unavailable before checking the local
|
|
42
47
|
`weclawbotctl` profile.
|