@openbrt/weclawbotctl 0.1.9 → 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.
@@ -65,9 +71,19 @@ only after the firmware reports `applied`; a firmware `rejected` status or a
65
71
  timeout is a failed delivery. Use `--no-wait` only for diagnostics where MQTT
66
72
  publish acknowledgement is enough.
67
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
+
68
83
  The package also includes an OpenClaw integration: the `weclawbot-curator`
69
84
  skill, `weclawbot_status`, `weclawbot_validate_screen_document`,
70
- `weclawbot_publish_screen_document`, `weclawbot_validate_activity`,
85
+ `weclawbot_clear_screen`, `weclawbot_publish_screen_document`,
86
+ `weclawbot_validate_activity`,
71
87
  `weclawbot_publish_activity`, and a small outbound bridge service. The bridge
72
88
  polls `weclawbot.link`; no public HTTP endpoint, port forwarding, or WeChat
73
89
  credential is required on the OpenClaw host.
@@ -248,6 +264,24 @@ three `mono1` pages, and a future UTC expiry. Agents may use PIL, Canvas, SVG
248
264
  rasterization, screenshots, or any local renderer, but the MQTT payload must be
249
265
  pixels:
250
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
+
251
285
  ```bash
252
286
  weclawbotctl screen /path/to/screen-document.json
253
287
  ```
@@ -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;
@@ -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) {
@@ -245,6 +246,8 @@ async function commandScreen(values) {
245
246
  id: document.id,
246
247
  pages: document.pages.length,
247
248
  force_replace: document.force_replace === true,
249
+ warnings: validation.warnings,
250
+ layout_guidance: validation.layout_guidance,
248
251
  status: delivery.status,
249
252
  }));
250
253
  return;
@@ -257,9 +260,50 @@ async function commandScreen(values) {
257
260
  id: document.id,
258
261
  pages: document.pages.length,
259
262
  force_replace: document.force_replace === true,
263
+ warnings: validation.warnings,
264
+ layout_guidance: validation.layout_guidance,
260
265
  }));
261
266
  }
262
267
 
268
+ async function commandClear(values) {
269
+ const options = parseOptions(values, {
270
+ credentials: credentialsPath(),
271
+ target: "note",
272
+ wait: true,
273
+ timeout: 12,
274
+ });
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 }));
305
+ }
306
+
263
307
  async function commandActivity(state, values) {
264
308
  const options = parseOptions(values, {
265
309
  credentials: credentialsPath(),
@@ -643,6 +687,18 @@ function compactText(value) {
643
687
  return String(value || "").replace(/\s+/gu, " ").trim().slice(0, 500);
644
688
  }
645
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
+
646
702
  function shellValue(value) {
647
703
  return `'${String(value).replaceAll("'", "'\\''")}'`;
648
704
  }
@@ -661,6 +717,7 @@ function usage() {
661
717
  weclawbotctl thinking [--ttl seconds] [--id correlation-id]
662
718
  weclawbotctl idle [--id correlation-id]
663
719
  weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds]
720
+ weclawbotctl clear [--target note|idle_photo] [--no-wait] [--timeout seconds]
664
721
  weclawbotctl openclaw install [--spec @openbrt/weclawbotctl] [--bin /path/to/openclaw] [--force=false]
665
722
  weclawbotctl openclaw doctor [--gateway=false] [--bin /path/to/openclaw] [--json]`);
666
723
  }
package/index.mjs CHANGED
@@ -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",
@@ -103,6 +140,8 @@ export default defineToolPlugin({
103
140
  id: outbound.id,
104
141
  pages: outbound.pages.length,
105
142
  force_replace: outbound.force_replace === true,
143
+ warnings: validation.warnings,
144
+ layout_guidance: validation.layout_guidance,
106
145
  status: delivery.status,
107
146
  };
108
147
  }
@@ -114,6 +153,8 @@ export default defineToolPlugin({
114
153
  id: outbound.id,
115
154
  pages: outbound.pages.length,
116
155
  force_replace: outbound.force_replace === true,
156
+ warnings: validation.warnings,
157
+ layout_guidance: validation.layout_guidance,
117
158
  };
118
159
  },
119
160
  }),
@@ -186,6 +227,18 @@ function cloneObject(value) {
186
227
  return JSON.parse(JSON.stringify(value));
187
228
  }
188
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
+
189
242
  function maskedMqtt(config, topics) {
190
243
  return {
191
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,
@@ -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.9",
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,34 @@ 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
+
62
90
  `weclawbotctl screen` and `weclawbot_publish_screen_document` wait for the
63
91
  device status topic by default. Treat only `applied` as success. If the device
64
92
  returns `rejected`, tell the user the real rejection reason. Use
@@ -92,9 +120,11 @@ For a paired device, publish `weclawbot.activity.v1` with `state: "thinking"`
92
120
  immediately before an LLM call, long retrieval, or multi-step operation, then
93
121
  always publish `state: "idle"` in a `finally` path after success or failure.
94
122
  The thinking message requires a 5-120 second `ttl_seconds` and a stable
95
- `correlation_id`; use `weclawbot_validate_activity` first. It is a temporary
96
- overlay that restores the exact prior page, not a screen document and not a
97
- 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
98
128
  `agent_transport.available` is false.
99
129
 
100
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");
@@ -45,3 +45,9 @@ screen revision in `base_revision`.
45
45
  The firmware receives pixels. Do not send raw text to firmware, and do not
46
46
  answer that direct delivery is unavailable before checking the local
47
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.