@openbrt/weclawbotctl 0.1.8 → 0.1.14
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 +45 -1
- package/bin/weclawbot-openclaw-bridge.mjs +34 -0
- package/bin/weclawbotctl.mjs +104 -7
- package/index.mjs +78 -4
- package/lib/direct-control.mjs +29 -4
- package/lib/mqtt-control.mjs +105 -28
- package/openclaw.plugin.json +1 -0
- package/package.json +1 -1
- package/skills/weclawbot-curator/SKILL.md +39 -3
- package/systemd/weclawbot-openclaw-curator.service +1 -0
- package/test/direct-control.test.mjs +19 -0
- package/workspace/AGENTS.md +11 -0
package/README.md
CHANGED
|
@@ -52,6 +52,12 @@ weclawbotctl thinking --id "$task_id" --ttl 45
|
|
|
52
52
|
weclawbotctl idle --id "$task_id"
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
`idle` must use the same id as the active `thinking` message. Newer firmware
|
|
56
|
+
rejects stale or unrelated `idle` messages and keeps the visible thinking state.
|
|
57
|
+
The bundled OpenClaw bridge publishes `thinking` before every curator job and
|
|
58
|
+
`idle` after it finishes, so WeChat-origin official-mode work also gets a visible
|
|
59
|
+
processing state without relying on the model to remember it.
|
|
60
|
+
|
|
55
61
|
To put text, status, diagrams, or images on the screen, render them into a
|
|
56
62
|
pre-rendered `weclawbot.screen_document.v1` first. The firmware receives pixels;
|
|
57
63
|
it does not lay out text, choose fonts, or split pages for agents.
|
|
@@ -60,9 +66,24 @@ it does not lay out text, choose fonts, or split pages for agents.
|
|
|
60
66
|
weclawbotctl screen /path/to/screen-document.json
|
|
61
67
|
```
|
|
62
68
|
|
|
69
|
+
`screen` waits for the device status topic by default. It exits successfully
|
|
70
|
+
only after the firmware reports `applied`; a firmware `rejected` status or a
|
|
71
|
+
timeout is a failed delivery. Use `--no-wait` only for diagnostics where MQTT
|
|
72
|
+
publish acknowledgement is enough.
|
|
73
|
+
|
|
74
|
+
To clear the current note, use the firmware clear command:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
weclawbotctl clear
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Do not emulate clear by publishing a blank, white, or black screen document.
|
|
81
|
+
That creates a new note and can leave the physical screen looking black.
|
|
82
|
+
|
|
63
83
|
The package also includes an OpenClaw integration: the `weclawbot-curator`
|
|
64
84
|
skill, `weclawbot_status`, `weclawbot_validate_screen_document`,
|
|
65
|
-
`
|
|
85
|
+
`weclawbot_clear_screen`, `weclawbot_publish_screen_document`,
|
|
86
|
+
`weclawbot_validate_activity`,
|
|
66
87
|
`weclawbot_publish_activity`, and a small outbound bridge service. The bridge
|
|
67
88
|
polls `weclawbot.link`; no public HTTP endpoint, port forwarding, or WeChat
|
|
68
89
|
credential is required on the OpenClaw host.
|
|
@@ -243,6 +264,24 @@ three `mono1` pages, and a future UTC expiry. Agents may use PIL, Canvas, SVG
|
|
|
243
264
|
rasterization, screenshots, or any local renderer, but the MQTT payload must be
|
|
244
265
|
pixels:
|
|
245
266
|
|
|
267
|
+
There is no canonical WeClawBot renderer that agents must use. The stable
|
|
268
|
+
contract is the bounded pixel document plus device feedback. Keep layout,
|
|
269
|
+
typography, and page-composition decisions in the agent/tool layer so skills and
|
|
270
|
+
models can improve the result without requiring users to flash firmware.
|
|
271
|
+
Preserve any layout preferences, visual language, page rhythm, font choices, or
|
|
272
|
+
review habits that the user and agent have already developed; package upgrades
|
|
273
|
+
should add capabilities without resetting that accumulated practice.
|
|
274
|
+
|
|
275
|
+
The hardware facts are stable: the content viewport is 368 x 206 mono1 pixels,
|
|
276
|
+
content documents may contain one to three pages, and the firmware will not split
|
|
277
|
+
a single pixel page after receiving it. If `pages.length === 1`, the physical
|
|
278
|
+
screen has exactly one page.
|
|
279
|
+
|
|
280
|
+
Before publishing, agents should inspect or otherwise self-evaluate the rendered
|
|
281
|
+
pages against the user's preferences and their own learned standards when their
|
|
282
|
+
runtime supports it. Regenerate the document if the bitmap does not satisfy those
|
|
283
|
+
standards.
|
|
284
|
+
|
|
246
285
|
```bash
|
|
247
286
|
weclawbotctl screen /path/to/screen-document.json
|
|
248
287
|
```
|
|
@@ -254,6 +293,11 @@ the firmware supports forced replacement, use:
|
|
|
254
293
|
weclawbotctl screen /path/to/screen-document.json --force
|
|
255
294
|
```
|
|
256
295
|
|
|
296
|
+
The command waits for the device's `applied`/`rejected` status by default, so
|
|
297
|
+
an Agent must not tell the user the content is on the screen until the command
|
|
298
|
+
returns success. If the device reports `stale_screen_revision`, regenerate the
|
|
299
|
+
document with the current revision or intentionally overwrite with `--force`.
|
|
300
|
+
|
|
257
301
|
These commands use MQTT/TLS directly, publish QoS 1 without retain, and
|
|
258
302
|
never create an offline command queue. See
|
|
259
303
|
`docs/agent-direct-control-protocol.md` in the firmware repository for the
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
+
import path from "node:path";
|
|
5
6
|
|
|
6
7
|
const ACTIONS = new Set([
|
|
7
8
|
"ignore",
|
|
@@ -53,6 +54,10 @@ function stop() {
|
|
|
53
54
|
|
|
54
55
|
async function handleJob(job) {
|
|
55
56
|
const started = Date.now();
|
|
57
|
+
const activityId = `openclaw-${String(job.id || cryptoRandom()).replace(/[^a-zA-Z0-9_.-]/gu, "_").slice(0, 64)}`;
|
|
58
|
+
await publishBridgeActivity("thinking", activityId, {
|
|
59
|
+
ttlSeconds: Math.min(120, Math.max(5, config.agentTimeoutSeconds + 15)),
|
|
60
|
+
});
|
|
56
61
|
try {
|
|
57
62
|
const decision = await curateWithOpenClaw(job);
|
|
58
63
|
await gatewayJson("POST", `${config.jobsPath}/${encodeURIComponent(job.id)}/result`, {
|
|
@@ -71,6 +76,24 @@ async function handleJob(job) {
|
|
|
71
76
|
} catch (resultError) {
|
|
72
77
|
log("failure_report_failed", { job: job.id, error: errorMessage(resultError) });
|
|
73
78
|
}
|
|
79
|
+
} finally {
|
|
80
|
+
await publishBridgeActivity("idle", activityId);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function publishBridgeActivity(state, id, options = {}) {
|
|
85
|
+
if (!config.screenActivity) return;
|
|
86
|
+
const args = [state, "--id", id];
|
|
87
|
+
if (state === "thinking") args.push("--ttl", String(options.ttlSeconds || 45));
|
|
88
|
+
try {
|
|
89
|
+
const result = await run(config.weclawbotctlBin, args, 8000);
|
|
90
|
+
if (result.code !== 0) {
|
|
91
|
+
log("activity_failed", { state, id, error: shortText(result.stderr || result.stdout || `exit ${result.code}`) });
|
|
92
|
+
} else {
|
|
93
|
+
log("activity_sent", { state, id });
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
log("activity_failed", { state, id, error: errorMessage(error) });
|
|
74
97
|
}
|
|
75
98
|
}
|
|
76
99
|
|
|
@@ -316,8 +339,10 @@ function loadConfig(env) {
|
|
|
316
339
|
jobsPath: String(env.WEC_GATEWAY_JOBS_PATH || "/api/agent/curator/jobs"),
|
|
317
340
|
agentId: String(env.WEC_OPENCLAW_AGENT || "weclawbot"),
|
|
318
341
|
openclawBin: String(env.WEC_OPENCLAW_BIN || "openclaw"),
|
|
342
|
+
weclawbotctlBin: String(env.WEC_WECLAWBOTCTL_BIN || siblingBin("weclawbotctl")),
|
|
319
343
|
transport: env.WEC_OPENCLAW_TRANSPORT === "local" ? "local" : "gateway",
|
|
320
344
|
thinking: String(env.WEC_OPENCLAW_THINKING || "low"),
|
|
345
|
+
screenActivity: env.WEC_SCREEN_ACTIVITY !== "0" && env.WEC_SCREEN_ACTIVITY !== "false",
|
|
321
346
|
pollWaitMs: positiveInteger(env.WEC_POLL_WAIT_MS, 20000),
|
|
322
347
|
retryMs: positiveInteger(env.WEC_RETRY_MS, 3000),
|
|
323
348
|
agentTimeoutSeconds: positiveInteger(env.WEC_AGENT_TIMEOUT_SECONDS, 20),
|
|
@@ -326,6 +351,15 @@ function loadConfig(env) {
|
|
|
326
351
|
};
|
|
327
352
|
}
|
|
328
353
|
|
|
354
|
+
function siblingBin(name) {
|
|
355
|
+
const script = process.argv[1] || "";
|
|
356
|
+
return script ? path.join(path.dirname(script), name) : name;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function cryptoRandom() {
|
|
360
|
+
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
329
363
|
function positiveInteger(value, fallback) {
|
|
330
364
|
const parsed = Number.parseInt(String(value || ""), 10);
|
|
331
365
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
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");
|
|
@@ -17,7 +17,7 @@ const DEFAULT_OPENCLAW_PLUGIN_SPEC = "@openbrt/weclawbotctl";
|
|
|
17
17
|
const MIN_OPENCLAW_VERSION = "2026.6.9";
|
|
18
18
|
|
|
19
19
|
const [command, ...args] = process.argv.slice(2);
|
|
20
|
-
const commands = new Set(["bind", "status", "doctor", "export", "unbind", "thinking", "idle", "screen", "openclaw"]);
|
|
20
|
+
const commands = new Set(["bind", "status", "doctor", "export", "unbind", "thinking", "idle", "screen", "clear", "openclaw"]);
|
|
21
21
|
if (!commands.has(command)) {
|
|
22
22
|
usage();
|
|
23
23
|
process.exit(64);
|
|
@@ -30,6 +30,7 @@ try {
|
|
|
30
30
|
else if (command === "export") await commandExport(args);
|
|
31
31
|
else if (command === "unbind") await commandUnbind(args);
|
|
32
32
|
else if (command === "screen") await commandScreen(args);
|
|
33
|
+
else if (command === "clear") await commandClear(args);
|
|
33
34
|
else if (command === "openclaw") await commandOpenClaw(args);
|
|
34
35
|
else await commandActivity(command, args);
|
|
35
36
|
} catch (error) {
|
|
@@ -202,10 +203,15 @@ async function commandUnbind(values) {
|
|
|
202
203
|
}
|
|
203
204
|
|
|
204
205
|
async function commandScreen(values) {
|
|
205
|
-
const options = parseOptions(values, {
|
|
206
|
+
const options = parseOptions(values, {
|
|
207
|
+
credentials: credentialsPath(),
|
|
208
|
+
force: false,
|
|
209
|
+
wait: true,
|
|
210
|
+
timeout: 12,
|
|
211
|
+
});
|
|
206
212
|
const file = String(options._[0] || "").trim();
|
|
207
213
|
if (!file || options._.length !== 1) {
|
|
208
|
-
throw new Error("Usage: weclawbotctl screen <document.json> [--force]");
|
|
214
|
+
throw new Error("Usage: weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds]");
|
|
209
215
|
}
|
|
210
216
|
const document = JSON.parse(await fs.readFile(file, "utf8"));
|
|
211
217
|
if (options.force) {
|
|
@@ -218,13 +224,84 @@ async function commandScreen(values) {
|
|
|
218
224
|
if (!validation.ok) {
|
|
219
225
|
throw new Error(`Invalid screen document: ${validation.errors.join("; ")}`);
|
|
220
226
|
}
|
|
221
|
-
|
|
227
|
+
const control = {
|
|
222
228
|
schema: "weclawbot.control.v1",
|
|
223
229
|
id: `screen_${crypto.randomUUID()}`,
|
|
224
230
|
kind: "screen_document",
|
|
225
231
|
document,
|
|
232
|
+
};
|
|
233
|
+
const credentials = await requireCredentials(expandPath(options.credentials));
|
|
234
|
+
if (options.wait) {
|
|
235
|
+
const delivery = await publishControlAndWaitStatus(credentials, control, {
|
|
236
|
+
expectedDetail: document.id,
|
|
237
|
+
timeoutMs: Math.max(1, Number(options.timeout) || 12) * 1000,
|
|
238
|
+
});
|
|
239
|
+
if (delivery.status.kind !== "applied") {
|
|
240
|
+
throw new Error(`Device rejected screen document: ${delivery.status.detail || "unknown"}`);
|
|
241
|
+
}
|
|
242
|
+
console.log(JSON.stringify({
|
|
243
|
+
ok: true,
|
|
244
|
+
published: true,
|
|
245
|
+
applied: true,
|
|
246
|
+
id: document.id,
|
|
247
|
+
pages: document.pages.length,
|
|
248
|
+
force_replace: document.force_replace === true,
|
|
249
|
+
warnings: validation.warnings,
|
|
250
|
+
layout_guidance: validation.layout_guidance,
|
|
251
|
+
status: delivery.status,
|
|
252
|
+
}));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
await publishControl(credentials, control);
|
|
256
|
+
console.log(JSON.stringify({
|
|
257
|
+
ok: true,
|
|
258
|
+
published: true,
|
|
259
|
+
applied: null,
|
|
260
|
+
id: document.id,
|
|
261
|
+
pages: document.pages.length,
|
|
262
|
+
force_replace: document.force_replace === true,
|
|
263
|
+
warnings: validation.warnings,
|
|
264
|
+
layout_guidance: validation.layout_guidance,
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function commandClear(values) {
|
|
269
|
+
const options = parseOptions(values, {
|
|
270
|
+
credentials: credentialsPath(),
|
|
271
|
+
target: "note",
|
|
272
|
+
wait: true,
|
|
273
|
+
timeout: 12,
|
|
226
274
|
});
|
|
227
|
-
|
|
275
|
+
if (options._.length > 1) {
|
|
276
|
+
throw new Error("Usage: weclawbotctl clear [--target note|idle_photo] [--no-wait] [--timeout seconds]");
|
|
277
|
+
}
|
|
278
|
+
const target = normalizeClearTarget(options._[0] || options.target);
|
|
279
|
+
const control = {
|
|
280
|
+
schema: "weclawbot.control.v1",
|
|
281
|
+
id: `clear_${crypto.randomUUID()}`,
|
|
282
|
+
kind: "screen_clear",
|
|
283
|
+
target,
|
|
284
|
+
};
|
|
285
|
+
const credentials = await requireCredentials(expandPath(options.credentials));
|
|
286
|
+
if (options.wait) {
|
|
287
|
+
const delivery = await publishControlAndWaitStatus(credentials, control, {
|
|
288
|
+
expectedDetail: clearStatusDetail(target),
|
|
289
|
+
timeoutMs: Math.max(1, Number(options.timeout) || 12) * 1000,
|
|
290
|
+
});
|
|
291
|
+
if (delivery.status.kind !== "applied") {
|
|
292
|
+
throw new Error(`Device rejected screen clear: ${delivery.status.detail || "unknown"}`);
|
|
293
|
+
}
|
|
294
|
+
console.log(JSON.stringify({
|
|
295
|
+
ok: true,
|
|
296
|
+
published: true,
|
|
297
|
+
applied: true,
|
|
298
|
+
target,
|
|
299
|
+
status: delivery.status,
|
|
300
|
+
}));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
await publishControl(credentials, control);
|
|
304
|
+
console.log(JSON.stringify({ ok: true, published: true, applied: null, target }));
|
|
228
305
|
}
|
|
229
306
|
|
|
230
307
|
async function commandActivity(state, values) {
|
|
@@ -350,6 +427,13 @@ function parseOptions(values, defaults = {}) {
|
|
|
350
427
|
const [rawKey, inlineValue] = value.slice(2).split("=", 2);
|
|
351
428
|
const key = rawKey.trim();
|
|
352
429
|
if (!key) continue;
|
|
430
|
+
if (key.startsWith("no-")) {
|
|
431
|
+
const positive = key.slice(3);
|
|
432
|
+
if (typeof options[positive] === "boolean" && inlineValue === undefined) {
|
|
433
|
+
options[positive] = false;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
353
437
|
if (typeof options[key] === "boolean") {
|
|
354
438
|
options[key] = inlineValue === undefined ? true : !/^(0|false|no|off)$/iu.test(inlineValue);
|
|
355
439
|
} else {
|
|
@@ -603,6 +687,18 @@ function compactText(value) {
|
|
|
603
687
|
return String(value || "").replace(/\s+/gu, " ").trim().slice(0, 500);
|
|
604
688
|
}
|
|
605
689
|
|
|
690
|
+
function normalizeClearTarget(value) {
|
|
691
|
+
const target = String(value || "note").trim();
|
|
692
|
+
if (target === "note" || target === "idle_photo" || target === "photo") {
|
|
693
|
+
return target === "photo" ? "idle_photo" : target;
|
|
694
|
+
}
|
|
695
|
+
throw new Error("clear target must be note or idle_photo");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function clearStatusDetail(target) {
|
|
699
|
+
return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
|
|
700
|
+
}
|
|
701
|
+
|
|
606
702
|
function shellValue(value) {
|
|
607
703
|
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
608
704
|
}
|
|
@@ -620,7 +716,8 @@ function usage() {
|
|
|
620
716
|
weclawbotctl unbind --yes
|
|
621
717
|
weclawbotctl thinking [--ttl seconds] [--id correlation-id]
|
|
622
718
|
weclawbotctl idle [--id correlation-id]
|
|
623
|
-
weclawbotctl screen <document.json> [--force]
|
|
719
|
+
weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds]
|
|
720
|
+
weclawbotctl clear [--target note|idle_photo] [--no-wait] [--timeout seconds]
|
|
624
721
|
weclawbotctl openclaw install [--spec @openbrt/weclawbotctl] [--bin /path/to/openclaw] [--force=false]
|
|
625
722
|
weclawbotctl openclaw doctor [--gateway=false] [--bin /path/to/openclaw] [--json]`);
|
|
626
723
|
}
|
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
|
|
|
@@ -59,6 +59,43 @@ export default defineToolPlugin({
|
|
|
59
59
|
}, { additionalProperties: false }),
|
|
60
60
|
execute: ({ document, device_context }) => validateScreenDocument(document, device_context),
|
|
61
61
|
}),
|
|
62
|
+
tool({
|
|
63
|
+
name: "weclawbot_clear_screen",
|
|
64
|
+
label: "Clear WeClawBot screen",
|
|
65
|
+
description: "Clear the paired WeClawBot note or idle-photo state with the firmware screen_clear control. Do not emulate clearing by publishing a blank or black bitmap.",
|
|
66
|
+
parameters: Type.Object({
|
|
67
|
+
target: Type.Optional(Type.String()),
|
|
68
|
+
credentials_path: Type.Optional(Type.String()),
|
|
69
|
+
wait_status: Type.Optional(Type.Boolean()),
|
|
70
|
+
timeout_seconds: Type.Optional(Type.Number()),
|
|
71
|
+
}, { additionalProperties: false }),
|
|
72
|
+
execute: async ({ target, credentials_path, wait_status, timeout_seconds }) => {
|
|
73
|
+
const clearTarget = normalizeClearTarget(target);
|
|
74
|
+
const control = {
|
|
75
|
+
schema: "weclawbot.control.v1",
|
|
76
|
+
id: `clear_${crypto.randomUUID()}`,
|
|
77
|
+
kind: "screen_clear",
|
|
78
|
+
target: clearTarget,
|
|
79
|
+
};
|
|
80
|
+
const credentials = await requireCredentials(credentials_path);
|
|
81
|
+
if (wait_status !== false) {
|
|
82
|
+
const delivery = await publishControlAndWaitStatus(credentials, control, {
|
|
83
|
+
expectedDetail: clearStatusDetail(clearTarget),
|
|
84
|
+
timeoutMs: Math.max(1, Number(timeout_seconds) || 12) * 1000,
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
ok: delivery.status.kind === "applied",
|
|
88
|
+
published: true,
|
|
89
|
+
applied: delivery.status.kind === "applied",
|
|
90
|
+
rejected: delivery.status.kind === "rejected",
|
|
91
|
+
target: clearTarget,
|
|
92
|
+
status: delivery.status,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
await publishControl(credentials, control);
|
|
96
|
+
return { ok: true, published: true, applied: null, target: clearTarget };
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
62
99
|
tool({
|
|
63
100
|
name: "weclawbot_publish_screen_document",
|
|
64
101
|
label: "Publish WeClawBot screen document",
|
|
@@ -68,8 +105,10 @@ export default defineToolPlugin({
|
|
|
68
105
|
device_context: Type.Optional(Type.Any()),
|
|
69
106
|
credentials_path: Type.Optional(Type.String()),
|
|
70
107
|
force_replace: Type.Optional(Type.Boolean()),
|
|
108
|
+
wait_status: Type.Optional(Type.Boolean()),
|
|
109
|
+
timeout_seconds: Type.Optional(Type.Number()),
|
|
71
110
|
}, { additionalProperties: false }),
|
|
72
|
-
execute: async ({ document, device_context, credentials_path, force_replace }) => {
|
|
111
|
+
execute: async ({ document, device_context, credentials_path, force_replace, wait_status, timeout_seconds }) => {
|
|
73
112
|
const outbound = cloneObject(document);
|
|
74
113
|
if (force_replace) {
|
|
75
114
|
outbound.force_replace = true;
|
|
@@ -81,18 +120,41 @@ export default defineToolPlugin({
|
|
|
81
120
|
if (!validation.ok) {
|
|
82
121
|
return { ok: false, published: false, errors: validation.errors, validation };
|
|
83
122
|
}
|
|
84
|
-
|
|
123
|
+
const control = {
|
|
85
124
|
schema: "weclawbot.control.v1",
|
|
86
125
|
id: `screen_${crypto.randomUUID()}`,
|
|
87
126
|
kind: "screen_document",
|
|
88
127
|
document: outbound,
|
|
89
|
-
}
|
|
128
|
+
};
|
|
129
|
+
const credentials = await requireCredentials(credentials_path);
|
|
130
|
+
if (wait_status !== false) {
|
|
131
|
+
const delivery = await publishControlAndWaitStatus(credentials, control, {
|
|
132
|
+
expectedDetail: outbound.id,
|
|
133
|
+
timeoutMs: Math.max(1, Number(timeout_seconds) || 12) * 1000,
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
ok: delivery.status.kind === "applied",
|
|
137
|
+
published: true,
|
|
138
|
+
applied: delivery.status.kind === "applied",
|
|
139
|
+
rejected: delivery.status.kind === "rejected",
|
|
140
|
+
id: outbound.id,
|
|
141
|
+
pages: outbound.pages.length,
|
|
142
|
+
force_replace: outbound.force_replace === true,
|
|
143
|
+
warnings: validation.warnings,
|
|
144
|
+
layout_guidance: validation.layout_guidance,
|
|
145
|
+
status: delivery.status,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
await publishControl(credentials, control);
|
|
90
149
|
return {
|
|
91
150
|
ok: true,
|
|
92
151
|
published: true,
|
|
152
|
+
applied: null,
|
|
93
153
|
id: outbound.id,
|
|
94
154
|
pages: outbound.pages.length,
|
|
95
155
|
force_replace: outbound.force_replace === true,
|
|
156
|
+
warnings: validation.warnings,
|
|
157
|
+
layout_guidance: validation.layout_guidance,
|
|
96
158
|
};
|
|
97
159
|
},
|
|
98
160
|
}),
|
|
@@ -165,6 +227,18 @@ function cloneObject(value) {
|
|
|
165
227
|
return JSON.parse(JSON.stringify(value));
|
|
166
228
|
}
|
|
167
229
|
|
|
230
|
+
function normalizeClearTarget(value) {
|
|
231
|
+
const target = String(value || "note").trim();
|
|
232
|
+
if (target === "note" || target === "idle_photo" || target === "photo") {
|
|
233
|
+
return target === "photo" ? "idle_photo" : target;
|
|
234
|
+
}
|
|
235
|
+
throw new Error("clear target must be note or idle_photo");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function clearStatusDetail(target) {
|
|
239
|
+
return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
|
|
240
|
+
}
|
|
241
|
+
|
|
168
242
|
function maskedMqtt(config, topics) {
|
|
169
243
|
return {
|
|
170
244
|
url: config.url,
|
package/lib/direct-control.mjs
CHANGED
|
@@ -29,6 +29,7 @@ export function resolveDeviceContext(value) {
|
|
|
29
29
|
export function validateScreenDocument(value, suppliedContext) {
|
|
30
30
|
const context = resolveDeviceContext(suppliedContext);
|
|
31
31
|
const errors = [];
|
|
32
|
+
const pageStats = [];
|
|
32
33
|
const document = value && typeof value === "object" ? value : null;
|
|
33
34
|
const viewport = context.content_viewport;
|
|
34
35
|
if (!document) return result(context, errors.concat("document must be an object"));
|
|
@@ -48,12 +49,15 @@ export function validateScreenDocument(value, suppliedContext) {
|
|
|
48
49
|
if (!Array.isArray(document.pages) || document.pages.length < 1 || document.pages.length > viewport.max_pages) {
|
|
49
50
|
errors.push(`pages must contain 1..${viewport.max_pages} items`);
|
|
50
51
|
} else {
|
|
51
|
-
document.pages.forEach((page, index) => validatePage(page, index, viewport, errors));
|
|
52
|
+
document.pages.forEach((page, index) => validatePage(page, index, viewport, errors, pageStats));
|
|
53
|
+
if (pageStats.length > 0 && pageStats.every((stat) => stat.uniform)) {
|
|
54
|
+
errors.push("uniform_screen_document: content pages cannot all be a single color; use screen_clear for clearing");
|
|
55
|
+
}
|
|
52
56
|
}
|
|
53
|
-
return result(context, errors);
|
|
57
|
+
return result(context, errors, document);
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
function validatePage(page, index, viewport, errors) {
|
|
60
|
+
function validatePage(page, index, viewport, errors, pageStats) {
|
|
57
61
|
if (!page || typeof page !== "object") {
|
|
58
62
|
errors.push(`pages[${index}] must be an object`);
|
|
59
63
|
return;
|
|
@@ -77,14 +81,35 @@ function validatePage(page, index, viewport, errors) {
|
|
|
77
81
|
errors.push(`pages[${index}].data_b64 is not valid base64`);
|
|
78
82
|
} else if (Number.isInteger(stride) && Number.isInteger(height) && bytes.length !== stride * height) {
|
|
79
83
|
errors.push(`pages[${index}].data_b64 byte length does not match stride * height`);
|
|
84
|
+
} else if (bytes.length > 0) {
|
|
85
|
+
pageStats.push({ index, uniform: bytes.every((byte) => byte === bytes[0]) });
|
|
80
86
|
}
|
|
81
87
|
}
|
|
82
88
|
|
|
83
|
-
function result(context, errors) {
|
|
89
|
+
function result(context, errors, document = null) {
|
|
90
|
+
const pageCount = Array.isArray(document?.pages) ? document.pages.length : 0;
|
|
91
|
+
const warnings = [];
|
|
92
|
+
if (pageCount === 1) {
|
|
93
|
+
warnings.push("single_page_document: firmware will not split pixels; verify the page remains readable before publishing");
|
|
94
|
+
} else if (pageCount > 1) {
|
|
95
|
+
warnings.push("multi_page_document: firmware will auto-flip pages and manual left/right buttons can change pages");
|
|
96
|
+
}
|
|
84
97
|
return {
|
|
85
98
|
ok: errors.length === 0,
|
|
86
99
|
errors,
|
|
100
|
+
warnings,
|
|
87
101
|
viewport: context.content_viewport,
|
|
102
|
+
layout_guidance: {
|
|
103
|
+
hardware_contract: "agent supplies pre-rendered mono1 pixels; firmware validates geometry and does not lay out text or split pages",
|
|
104
|
+
page_contract: "pages.length is the physical page count; content documents support 1-3 pages",
|
|
105
|
+
preference_policy: "preserve user-agent learned layout preferences unless they violate hardware bounds or the user asks to change them",
|
|
106
|
+
review_policy: "agent should inspect rendered bitmap pages against user preferences and learned standards before publishing when possible",
|
|
107
|
+
max_pages: context.content_viewport?.max_pages,
|
|
108
|
+
content_viewport_px: {
|
|
109
|
+
width: context.content_viewport?.width,
|
|
110
|
+
height: context.content_viewport?.height,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
88
113
|
agent_transport: context.agent_transport || null,
|
|
89
114
|
direct_delivery_ready: context.agent_transport?.screen_document_available === true
|
|
90
115
|
|| context.agent_transport?.available === 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/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -59,6 +59,40 @@ 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
|
+
For clear requests such as “清屏”, “清理屏幕”, “clear screen”, or removing the
|
|
63
|
+
current note, call `weclawbot_clear_screen` or run `weclawbotctl clear`. Never
|
|
64
|
+
simulate clearing by publishing a blank, white, or black `screen_document`; that
|
|
65
|
+
creates a new note instead of clearing firmware state.
|
|
66
|
+
|
|
67
|
+
## Layout, preferences, and page splitting
|
|
68
|
+
|
|
69
|
+
Treat this skill as a hardware contract and starting point, not as a fixed house
|
|
70
|
+
style. If the user and agent have already developed layout preferences, visual
|
|
71
|
+
language, page rhythm, font choices, or review habits, preserve those choices
|
|
72
|
+
unless the user asks to change them or they violate the device bounds below.
|
|
73
|
+
Skill upgrades must be additive and compatible with accumulated user-agent
|
|
74
|
+
practice; do not reset or overwrite local style memory just because this package
|
|
75
|
+
changed.
|
|
76
|
+
|
|
77
|
+
Hardware facts: the content viewport is 368 x 206 mono1 pixels, and a document
|
|
78
|
+
may contain one to three content pages. The firmware will not split a single
|
|
79
|
+
pixel page after receiving it; if the document has `pages.length === 1`, the
|
|
80
|
+
physical screen has exactly one page. Multi-page documents can be auto-flipped by
|
|
81
|
+
firmware and changed with the physical left/right buttons.
|
|
82
|
+
|
|
83
|
+
Before publishing, review the actual rendered bitmap pages when your runtime can
|
|
84
|
+
inspect images. Judge the preview against the user's preferences and the agent's
|
|
85
|
+
own learned standards: legibility, margins, crowding, page count, and continuity
|
|
86
|
+
across pages. If the preview does not satisfy those standards, regenerate the
|
|
87
|
+
pages before publishing. This review loop belongs in the agent/tool layer; do not
|
|
88
|
+
expect firmware to fix typography or split pages after the pixels arrive.
|
|
89
|
+
|
|
90
|
+
`weclawbotctl screen` and `weclawbot_publish_screen_document` wait for the
|
|
91
|
+
device status topic by default. Treat only `applied` as success. If the device
|
|
92
|
+
returns `rejected`, tell the user the real rejection reason. Use
|
|
93
|
+
`force_replace` or `weclawbotctl screen --force` only when the user explicitly
|
|
94
|
+
intends to overwrite the currently shown BYOA screen.
|
|
95
|
+
|
|
62
96
|
Only return the normal WeChat decision shape below when processing an explicit
|
|
63
97
|
`WECLAWBOT_CURATOR_EVENT` envelope.
|
|
64
98
|
|
|
@@ -86,9 +120,11 @@ For a paired device, publish `weclawbot.activity.v1` with `state: "thinking"`
|
|
|
86
120
|
immediately before an LLM call, long retrieval, or multi-step operation, then
|
|
87
121
|
always publish `state: "idle"` in a `finally` path after success or failure.
|
|
88
122
|
The thinking message requires a 5-120 second `ttl_seconds` and a stable
|
|
89
|
-
`correlation_id`;
|
|
90
|
-
|
|
91
|
-
|
|
123
|
+
`correlation_id`; the matching idle message must reuse the same id. Newer
|
|
124
|
+
firmware rejects stale or unrelated idle messages and keeps the active thinking
|
|
125
|
+
state. Use `weclawbot_validate_activity` first. It is a temporary overlay that
|
|
126
|
+
restores the exact prior page, not a screen document and not a status to leave on
|
|
127
|
+
indefinitely. Do not publish it when
|
|
92
128
|
`agent_transport.available` is false.
|
|
93
129
|
|
|
94
130
|
Return this shape:
|
|
@@ -7,6 +7,7 @@ Wants=network-online.target
|
|
|
7
7
|
Type=simple
|
|
8
8
|
EnvironmentFile=%h/.config/weclawbot/openclaw-curator.env
|
|
9
9
|
Environment=NODE_EXTRA_CA_CERTS=%h/.openclaw/gateway/tls/gateway-cert.pem
|
|
10
|
+
Environment=WEC_WECLAWBOTCTL_BIN=%h/.npm-global/bin/weclawbotctl
|
|
10
11
|
ExecStart=%h/.npm-global/bin/weclawbot-openclaw-bridge
|
|
11
12
|
Restart=always
|
|
12
13
|
RestartSec=3
|
|
@@ -5,6 +5,7 @@ import { resolveDeviceContext, validateScreenDocument } from "../lib/direct-cont
|
|
|
5
5
|
const context = resolveDeviceContext();
|
|
6
6
|
const viewport = context.content_viewport;
|
|
7
7
|
const bytes = Buffer.alloc(Math.ceil(viewport.width / 8) * viewport.height, 0xff);
|
|
8
|
+
bytes[0] = 0x00;
|
|
8
9
|
const valid = validateScreenDocument({
|
|
9
10
|
schema: "weclawbot.screen_document.v1",
|
|
10
11
|
id: "test-card",
|
|
@@ -33,4 +34,22 @@ const invalid = validateScreenDocument({
|
|
|
33
34
|
assert.equal(invalid.ok, false);
|
|
34
35
|
assert.ok(invalid.errors.some((error) => error.includes("width")));
|
|
35
36
|
|
|
37
|
+
const uniform = validateScreenDocument({
|
|
38
|
+
schema: "weclawbot.screen_document.v1",
|
|
39
|
+
id: "bad-clear",
|
|
40
|
+
base_revision: "",
|
|
41
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
42
|
+
target: "content",
|
|
43
|
+
kind: "replace",
|
|
44
|
+
pages: [{
|
|
45
|
+
format: "mono1",
|
|
46
|
+
width: viewport.width,
|
|
47
|
+
height: viewport.height,
|
|
48
|
+
stride: Math.ceil(viewport.width / 8),
|
|
49
|
+
data_b64: Buffer.alloc(Math.ceil(viewport.width / 8) * viewport.height, 0x00).toString("base64"),
|
|
50
|
+
}],
|
|
51
|
+
}, context);
|
|
52
|
+
assert.equal(uniform.ok, false);
|
|
53
|
+
assert.ok(uniform.errors.some((error) => error.includes("uniform_screen_document")));
|
|
54
|
+
|
|
36
55
|
console.log("direct-control validator: ok");
|
package/workspace/AGENTS.md
CHANGED
|
@@ -37,6 +37,17 @@ 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.
|
|
48
|
+
|
|
49
|
+
The content viewport is 368 x 206 mono1 pixels, with one to three content pages.
|
|
50
|
+
The firmware will not split a single pixel page after receiving it; `pages.length`
|
|
51
|
+
is the page count on the physical screen. Preserve user-agent layout preferences,
|
|
52
|
+
visual language, and review habits across plugin upgrades unless they violate
|
|
53
|
+
the hardware limits or the user asks to change them.
|