@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 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
@@ -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, { credentials: credentialsPath(), force: false });
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
- await publishControl(await requireCredentials(expandPath(options.credentials)), {
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
- console.log(JSON.stringify({ ok: true, id: document.id, pages: document.pages.length, force_replace: document.force_replace === true }));
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
- await publishControl(await requireCredentials(credentials_path), {
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,
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/weclawbotctl",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "WeClawBot pairing and screen-control CLI for local AI agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
 
@@ -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.