@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 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
- `weclawbot_publish_screen_document`, `weclawbot_validate_activity`,
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;
@@ -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, { credentials: credentialsPath(), force: false });
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
- await publishControl(await requireCredentials(expandPath(options.credentials)), {
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
- console.log(JSON.stringify({ ok: true, id: document.id, pages: document.pages.length, force_replace: document.force_replace === true }));
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
- await publishControl(await requireCredentials(credentials_path), {
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,
@@ -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,
@@ -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
- if (!control || typeof control !== "object" || control.schema !== "weclawbot.control.v1") {
6
- throw new Error("invalid_control_message");
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
- const client = mqtt.connect(config.url, {
9
- clientId: config.clientId,
10
- username: config.username,
11
- password: config.password,
12
- clean: true,
13
- reconnectPeriod: 0,
14
- connectTimeout: 12_000,
15
- protocolVersion: 5,
16
- properties: { sessionExpiryInterval: 0 },
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 new Promise((resolve, reject) => {
21
- client.publish(config.controlTopic, JSON.stringify(control), { qos: 1, retain: false }, (error) => {
22
- if (error) reject(error);
23
- else resolve();
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 = mqtt.connect(config.url, {
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) {
@@ -6,6 +6,7 @@
6
6
  "tools": [
7
7
  "weclawbot_status",
8
8
  "weclawbot_validate_screen_document",
9
+ "weclawbot_clear_screen",
9
10
  "weclawbot_publish_screen_document",
10
11
  "weclawbot_validate_activity",
11
12
  "weclawbot_publish_activity"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/weclawbotctl",
3
- "version": "0.1.8",
3
+ "version": "0.1.14",
4
4
  "description": "WeClawBot pairing and screen-control CLI for local AI agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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`; use `weclawbot_validate_activity` first. It is a temporary
90
- overlay that restores the exact prior page, not a screen document and not a
91
- status to leave on indefinitely. Do not publish it when
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");
@@ -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.