made-bridge 0.2.1 → 0.2.2
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/config.example.json +2 -0
- package/package.json +1 -1
- package/public/bean-icon.png +0 -0
- package/src/agent-sprites.mjs +206 -0
- package/src/agents/shared.mjs +27 -2
- package/src/board-link.mjs +3 -0
- package/src/board-screenshot.mjs +132 -0
- package/src/config.mjs +16 -1
- package/src/jobs.mjs +18 -1
- package/src/plugins/registry.mjs +9 -0
- package/src/providers.mjs +2 -0
- package/src/server.mjs +96 -1
- package/src/usb-receiver.mjs +33 -4
package/config.example.json
CHANGED
package/package.json
CHANGED
package/public/bean-icon.png
CHANGED
|
Binary file
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent 形象素材(sprite/)读取与校验。
|
|
3
|
+
*
|
|
4
|
+
* 规范真源是主仓库 docs/SPRITES.md:agent 插件包可选携带 sprite/ 目录
|
|
5
|
+
* (manifest.json + 分层 PNG),桥接器把素材清单暴露给码得,素材本体走
|
|
6
|
+
* /device/sprite 分片拉取端点(单次 ≤192KB,兼容 USB 帧转发 256KB 上限)。
|
|
7
|
+
*
|
|
8
|
+
* 这里只做「读、验、切片」:不改素材、不缓存像素。清单结果按目录 mtime
|
|
9
|
+
* 缓存,避免每次 /device/config 都重算哈希。
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
export const SPRITE_MAX_TOTAL_BYTES = 512 * 1024;
|
|
16
|
+
export const SPRITE_MAX_CHUNK = 192 * 1024;
|
|
17
|
+
export const SPRITE_MAX_FILE_BYTES = 256 * 1024;
|
|
18
|
+
const MANIFEST_MAX_BYTES = 16 * 1024;
|
|
19
|
+
const MAX_LAYERS = 8;
|
|
20
|
+
const MAX_FRAMES = 4;
|
|
21
|
+
// ≤20 字符:固件 SPIFFS 缓存对象名 = '/' + "s<hash6>_" + 文件名 + "~",
|
|
22
|
+
// 而 SPIFFS 只给 31 字节,20 是留了 1 字节余量的上限。
|
|
23
|
+
const FILE_NAME = /^[A-Za-z0-9_-]{1,16}\.png$/i;
|
|
24
|
+
const LAYER_ID = /^[A-Za-z][A-Za-z0-9_]{0,15}$/;
|
|
25
|
+
const RESERVED_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
26
|
+
|
|
27
|
+
class SpriteError extends Error {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.preserveMessage = true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isInt = (value, min, max) => Number.isInteger(value) && value >= min && value <= max;
|
|
35
|
+
|
|
36
|
+
function readManifestFile(dir) {
|
|
37
|
+
const file = path.join(dir, 'manifest.json');
|
|
38
|
+
let text;
|
|
39
|
+
try {
|
|
40
|
+
text = fs.readFileSync(file, 'utf8');
|
|
41
|
+
} catch {
|
|
42
|
+
return null; // 包没带 sprite/ —— 合法,用固件兜底形象。
|
|
43
|
+
}
|
|
44
|
+
if (Buffer.byteLength(text) > MANIFEST_MAX_BYTES) throw new SpriteError('sprite/manifest.json 超过 16 KB');
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
} catch {
|
|
48
|
+
throw new SpriteError('sprite/manifest.json 不是合法 JSON');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateManifest(raw) {
|
|
53
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new SpriteError('manifest 必须是对象');
|
|
54
|
+
if (Object.keys(raw).some((key) => !['version', 'canvas', 'layers', 'motion'].includes(key))) {
|
|
55
|
+
throw new SpriteError('manifest 包含不支持的字段');
|
|
56
|
+
}
|
|
57
|
+
if (!isInt(raw.version, 1, 2147483647)) throw new SpriteError('manifest.version 必须是正整数');
|
|
58
|
+
const canvas = raw.canvas;
|
|
59
|
+
if (!canvas || typeof canvas !== 'object' || Array.isArray(canvas) ||
|
|
60
|
+
!isInt(canvas.w, 1, 512) || !isInt(canvas.h, 1, 512) ||
|
|
61
|
+
Object.keys(canvas).some((key) => !['w', 'h'].includes(key))) {
|
|
62
|
+
throw new SpriteError('manifest.canvas 必须是 {w, h}(各 1–512)');
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(raw.layers) || raw.layers.length < 1 || raw.layers.length > MAX_LAYERS) {
|
|
65
|
+
throw new SpriteError(`manifest.layers 需要 1–${MAX_LAYERS} 层`);
|
|
66
|
+
}
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const referenced = new Set();
|
|
69
|
+
for (const layer of raw.layers) {
|
|
70
|
+
if (!layer || typeof layer !== 'object' || Array.isArray(layer)) throw new SpriteError('layers 条目必须是对象');
|
|
71
|
+
const keys = Object.keys(layer);
|
|
72
|
+
if (keys.some((key) => !['id', 'file', 'frames', 'x', 'y', 'z', 'blink', 'talk', 'alert'].includes(key))) {
|
|
73
|
+
throw new SpriteError(`层 ${layer.id || '?'} 包含不支持的字段`);
|
|
74
|
+
}
|
|
75
|
+
if (typeof layer.id !== 'string' || !LAYER_ID.test(layer.id) || RESERVED_IDS.has(layer.id) || seen.has(layer.id)) {
|
|
76
|
+
throw new SpriteError('层 id 必须是 1–16 位字母开头的唯一标识');
|
|
77
|
+
}
|
|
78
|
+
seen.add(layer.id);
|
|
79
|
+
const files = layer.frames !== undefined ? layer.frames : [layer.file];
|
|
80
|
+
if (!Array.isArray(files) || files.length < 1 || files.length > MAX_FRAMES ||
|
|
81
|
+
files.some((file) => typeof file !== 'string' || !FILE_NAME.test(file))) {
|
|
82
|
+
throw new SpriteError(`层 ${layer.id} 需要单帧 file 或 1–${MAX_FRAMES} 帧 frames(PNG 文件名)`);
|
|
83
|
+
}
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
if (referenced.has(file)) throw new SpriteError(`素材文件被多层引用:${file}`);
|
|
86
|
+
referenced.add(file);
|
|
87
|
+
}
|
|
88
|
+
if (!isInt(layer.x, 0, canvas.w) || !isInt(layer.y, 0, canvas.h) || !isInt(layer.z, 0, 100)) {
|
|
89
|
+
throw new SpriteError(`层 ${layer.id} 的 x/y/z 超出画布或范围`);
|
|
90
|
+
}
|
|
91
|
+
if (layer.blink !== undefined) {
|
|
92
|
+
const blink = layer.blink;
|
|
93
|
+
if (!blink || typeof blink !== 'object' || Array.isArray(blink) ||
|
|
94
|
+
!isInt(blink.period_ms, 200, 60000) || !isInt(blink.closed_ms, 20, 2000) ||
|
|
95
|
+
Object.keys(blink).some((key) => !['period_ms', 'closed_ms'].includes(key))) {
|
|
96
|
+
throw new SpriteError(`层 ${layer.id} 的 blink 必须是 {period_ms, closed_ms}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (layer.talk !== undefined && typeof layer.talk !== 'boolean') {
|
|
100
|
+
throw new SpriteError(`层 ${layer.id} 的 talk 必须是布尔值`);
|
|
101
|
+
}
|
|
102
|
+
if (layer.alert !== undefined && layer.alert !== 'pulse') {
|
|
103
|
+
throw new SpriteError(`层 ${layer.id} 的 alert 只支持 "pulse"`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (raw.motion !== undefined) {
|
|
107
|
+
const motion = raw.motion;
|
|
108
|
+
if (!motion || typeof motion !== 'object' || Array.isArray(motion)) throw new SpriteError('motion 必须是对象');
|
|
109
|
+
const keys = Object.keys(motion);
|
|
110
|
+
if (keys.some((key) => !['breath', 'sway_deg', 'sway_period_ms'].includes(key))) {
|
|
111
|
+
throw new SpriteError('motion 包含不支持的字段');
|
|
112
|
+
}
|
|
113
|
+
if (motion.breath !== undefined) {
|
|
114
|
+
const breath = motion.breath;
|
|
115
|
+
if (!breath || typeof breath !== 'object' || Array.isArray(breath) ||
|
|
116
|
+
!isInt(breath.scale_pct, 100, 120) || !isInt(breath.period_ms, 500, 10000) ||
|
|
117
|
+
Object.keys(breath).some((key) => !['scale_pct', 'period_ms'].includes(key))) {
|
|
118
|
+
throw new SpriteError('motion.breath 必须是 {scale_pct, period_ms}');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (motion.sway_deg !== undefined && !isInt(motion.sway_deg, 0, 15)) throw new SpriteError('motion.sway_deg 范围 0–15');
|
|
122
|
+
if (motion.sway_period_ms !== undefined && !isInt(motion.sway_period_ms, 200, 10000)) {
|
|
123
|
+
throw new SpriteError('motion.sway_period_ms 范围 200–10000');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { version: raw.version, canvas: canvas, layers: raw.layers, motion: raw.motion ?? null };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 按目录内容 mtime 缓存读取结果;素材被替换时自动失效。
|
|
130
|
+
* 键要覆盖目录内每个文件:原地覆盖文件不会更新目录自身的 mtime。 */
|
|
131
|
+
const cache = new Map();
|
|
132
|
+
|
|
133
|
+
function spriteStamp(spriteDir) {
|
|
134
|
+
let stamp;
|
|
135
|
+
try {
|
|
136
|
+
stamp = `${fs.statSync(spriteDir).mtimeMs}`;
|
|
137
|
+
for (const name of fs.readdirSync(spriteDir).sort()) {
|
|
138
|
+
stamp += `|${name}:${fs.statSync(path.join(spriteDir, name)).mtimeMs}`;
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return stamp;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 读取并校验一个插件包的 sprite/ 目录。
|
|
148
|
+
* @returns 清单对象(含每个文件的 bytes/sha256);包没带 sprite/ 时返回 null;
|
|
149
|
+
* manifest 或素材不合法时抛错(调用方决定是拒绝加载还是降级兜底)。
|
|
150
|
+
*/
|
|
151
|
+
export function readSprite(packageDir) {
|
|
152
|
+
const spriteDir = path.join(packageDir, 'sprite');
|
|
153
|
+
const stamp = spriteStamp(spriteDir);
|
|
154
|
+
if (!stamp) return null; // 目录不存在:没有素材,合法。
|
|
155
|
+
const hit = cache.get(spriteDir);
|
|
156
|
+
if (hit && hit.stamp === stamp) return hit.value;
|
|
157
|
+
const raw = readManifestFile(spriteDir);
|
|
158
|
+
if (!raw) return null;
|
|
159
|
+
const manifest = validateManifest(raw);
|
|
160
|
+
// 引用完整性:目录里的 PNG 必须全部被 manifest 引用,帮开发者抓住文件名拼写错误。
|
|
161
|
+
for (const name of fs.readdirSync(spriteDir)) {
|
|
162
|
+
if (!name.toLowerCase().endsWith('.png')) continue;
|
|
163
|
+
const referenced = manifest.layers.some((layer) =>
|
|
164
|
+
(layer.frames !== undefined ? layer.frames : [layer.file]).includes(name));
|
|
165
|
+
if (!referenced) throw new SpriteError(`sprite/${name} 未被 manifest 引用`);
|
|
166
|
+
}
|
|
167
|
+
let total = 0;
|
|
168
|
+
const files = [];
|
|
169
|
+
for (const layer of manifest.layers) {
|
|
170
|
+
for (const file of layer.frames !== undefined ? layer.frames : [layer.file]) {
|
|
171
|
+
const target = path.join(spriteDir, file);
|
|
172
|
+
let bytes;
|
|
173
|
+
try {
|
|
174
|
+
bytes = fs.statSync(target).size;
|
|
175
|
+
} catch {
|
|
176
|
+
throw new SpriteError(`manifest 引用的素材不存在:sprite/${file}`);
|
|
177
|
+
}
|
|
178
|
+
if (bytes > SPRITE_MAX_FILE_BYTES) throw new SpriteError(`素材单文件超过 256 KB:${file}`);
|
|
179
|
+
total += bytes;
|
|
180
|
+
if (total > SPRITE_MAX_TOTAL_BYTES) throw new SpriteError(`sprite/ 整包超过 512 KB`);
|
|
181
|
+
const data = fs.readFileSync(target);
|
|
182
|
+
files.push({ file, bytes, sha256: createHash('sha256').update(data).digest('hex') });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const value = Object.freeze({
|
|
186
|
+
dir: spriteDir, version: manifest.version, canvas: manifest.canvas,
|
|
187
|
+
layers: manifest.layers, motion: manifest.motion,
|
|
188
|
+
files: Object.freeze(files.map((file) => Object.freeze(file))),
|
|
189
|
+
});
|
|
190
|
+
cache.set(spriteDir, { stamp, value });
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 从已校验的 sprite 目录切一片文件数据。越界与超长都会收敛到合法范围,
|
|
196
|
+
* 返回空 Buffer 表示 offset 已到文件尾(调用方据此判断下载完成)。
|
|
197
|
+
*/
|
|
198
|
+
export function spriteChunk(sprite, file, offset, length) {
|
|
199
|
+
const entry = sprite?.files?.find((item) => item.file === file);
|
|
200
|
+
if (!entry) return null;
|
|
201
|
+
const start = Math.max(0, Math.min(Math.trunc(offset) || 0, entry.bytes));
|
|
202
|
+
const wanted = length === undefined || length === null ? SPRITE_MAX_CHUNK : Math.trunc(length) || 0;
|
|
203
|
+
const end = Math.min(entry.bytes, start + Math.max(1, Math.min(wanted, SPRITE_MAX_CHUNK)));
|
|
204
|
+
const data = fs.readFileSync(path.join(sprite.dir, file));
|
|
205
|
+
return data.subarray(start, end);
|
|
206
|
+
}
|
package/src/agents/shared.mjs
CHANGED
|
@@ -134,6 +134,22 @@ export function runCli({ job, project, signal, onProgress, onSession, config, mo
|
|
|
134
134
|
let stderr = '';
|
|
135
135
|
let settled = false;
|
|
136
136
|
let timer;
|
|
137
|
+
// 卡死看门狗:编程工具进程连续 idleMs 没有任何输出(进度、流式、stderr 都算)
|
|
138
|
+
// 视为卡死,杀掉进程树并把错误标记为可自动重跑,由任务车道决定是否重试。
|
|
139
|
+
const idleMs = Number(config?.stuckIdleMs)
|
|
140
|
+
|| Math.max(1, Number(config?.stuckIdleMinutes) || 5) * 60 * 1000;
|
|
141
|
+
let idleTimer;
|
|
142
|
+
const bumpIdle = () => {
|
|
143
|
+
clearTimeout(idleTimer);
|
|
144
|
+
idleTimer = setTimeout(() => {
|
|
145
|
+
stop();
|
|
146
|
+
const error = new Error(`任务 ${Math.round(idleMs / 60000)} 分钟没有任何输出,疑似卡死:已终止编程工具进程`);
|
|
147
|
+
error.retryable = true;
|
|
148
|
+
error.autoRetry = true;
|
|
149
|
+
finish(error);
|
|
150
|
+
}, idleMs);
|
|
151
|
+
if (idleTimer.unref) idleTimer.unref();
|
|
152
|
+
};
|
|
137
153
|
const stop = () => {
|
|
138
154
|
if (child.pid && process.platform !== 'win32') {
|
|
139
155
|
try { process.kill(-child.pid, 'SIGTERM'); } catch { /* Already exited. */ }
|
|
@@ -151,19 +167,28 @@ export function runCli({ job, project, signal, onProgress, onSession, config, mo
|
|
|
151
167
|
if (settled) return;
|
|
152
168
|
settled = true;
|
|
153
169
|
clearTimeout(timer);
|
|
170
|
+
clearTimeout(idleTimer);
|
|
154
171
|
signal.removeEventListener('abort', stop);
|
|
155
172
|
if (error) reject(error); else resolve(value);
|
|
156
173
|
};
|
|
157
174
|
signal.addEventListener('abort', stop, { once: true });
|
|
158
175
|
if (signal.aborted) stop();
|
|
176
|
+
bumpIdle();
|
|
159
177
|
timer = setTimeout(() => {
|
|
160
178
|
stop();
|
|
161
|
-
|
|
179
|
+
const error = new Error('任务运行超过 20 分钟,已停止');
|
|
180
|
+
error.retryable = true;
|
|
181
|
+
error.autoRetry = true;
|
|
182
|
+
finish(error);
|
|
162
183
|
}, 20 * 60 * 1000);
|
|
163
184
|
child.on('error', (error) => finish(error));
|
|
164
185
|
child.stdout.setEncoding('utf8');
|
|
165
|
-
child.stdout.on('data', (chunk) =>
|
|
186
|
+
child.stdout.on('data', (chunk) => {
|
|
187
|
+
bumpIdle();
|
|
188
|
+
events.write(chunk);
|
|
189
|
+
});
|
|
166
190
|
child.stderr.on('data', (chunk) => {
|
|
191
|
+
bumpIdle();
|
|
167
192
|
stderr = `${stderr}${chunk.toString('utf8')}`.slice(-8000);
|
|
168
193
|
});
|
|
169
194
|
child.on('close', (code) => {
|
package/src/board-link.mjs
CHANGED
|
@@ -182,6 +182,9 @@ export function createBoardLink() {
|
|
|
182
182
|
deviceIds() {
|
|
183
183
|
return [...catalogs.keys()];
|
|
184
184
|
},
|
|
185
|
+
hasCatalog(deviceId) {
|
|
186
|
+
return catalogs.has(deviceId);
|
|
187
|
+
},
|
|
185
188
|
connect(deviceId, runtime, config, bridge) {
|
|
186
189
|
if (!runtime?.boardPlugins) return;
|
|
187
190
|
for (const plugin of runtime.boardPlugins()) {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// 板端截屏:固件把当前 LVGL 画面以 RGB565 裸帧 POST 上来,这里转 PNG 落盘。
|
|
2
|
+
// 触发端走桌面本地 API(POST /api/screenshot),向设备队列塞 debug.screenshot 命令。
|
|
3
|
+
// 仅调试用途:命令在固件能力清单里声明(esp_brookesia_app_vibe.cpp announceBoardCatalog)。
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import zlib from 'node:zlib';
|
|
7
|
+
|
|
8
|
+
const MAX_WIDTH = 1024;
|
|
9
|
+
const MAX_HEIGHT = 1024;
|
|
10
|
+
const MAX_UPLOAD = 2 * 1024 * 1024;
|
|
11
|
+
const KEEP_FILES = 20;
|
|
12
|
+
|
|
13
|
+
const crcTable = (() => {
|
|
14
|
+
const table = new Uint32Array(256);
|
|
15
|
+
for (let n = 0; n < 256; n += 1) {
|
|
16
|
+
let c = n;
|
|
17
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
18
|
+
table[n] = c >>> 0;
|
|
19
|
+
}
|
|
20
|
+
return table;
|
|
21
|
+
})();
|
|
22
|
+
|
|
23
|
+
function crc32(buffer) {
|
|
24
|
+
let crc = 0xffffffff;
|
|
25
|
+
for (const byte of buffer) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
26
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function chunk(type, data) {
|
|
30
|
+
const length = Buffer.alloc(4);
|
|
31
|
+
length.writeUInt32BE(data.length, 0);
|
|
32
|
+
const typeBuffer = Buffer.from(type, 'ascii');
|
|
33
|
+
const crc = Buffer.alloc(4);
|
|
34
|
+
crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
|
|
35
|
+
return Buffer.concat([length, typeBuffer, data, crc]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// LVGL RGB565 小端:低字节 GGGBBBBB,高字节 RRRRRGGG;stride 为每行字节数(可能含行尾填充)。
|
|
39
|
+
export function rgb565ToPng(width, height, rgb565, stride = width * 2) {
|
|
40
|
+
if (!Number.isInteger(width) || width < 16 || width > MAX_WIDTH) throw new Error('宽度无效');
|
|
41
|
+
if (!Number.isInteger(height) || height < 16 || height > MAX_HEIGHT) throw new Error('高度无效');
|
|
42
|
+
if (!Buffer.isBuffer(rgb565)) throw new Error('像素数据无效');
|
|
43
|
+
if (!Number.isInteger(stride) || stride < width * 2 || stride > MAX_WIDTH * 4) throw new Error('行距无效');
|
|
44
|
+
const expected = stride * height;
|
|
45
|
+
if (rgb565.length !== expected) throw new Error(`数据长度不符:期望 ${expected} 字节,实得 ${rgb565.length}`);
|
|
46
|
+
const raw = Buffer.alloc(height * (1 + width * 3));
|
|
47
|
+
let out = 0;
|
|
48
|
+
for (let y = 0; y < height; y += 1) {
|
|
49
|
+
raw[out] = 0; // filter: none
|
|
50
|
+
out += 1;
|
|
51
|
+
const row = y * stride;
|
|
52
|
+
for (let x = 0; x < width; x += 1) {
|
|
53
|
+
const value = rgb565.readUInt16LE(row + x * 2);
|
|
54
|
+
const r5 = (value >> 11) & 0x1f;
|
|
55
|
+
const g6 = (value >> 5) & 0x3f;
|
|
56
|
+
const b5 = value & 0x1f;
|
|
57
|
+
raw[out] = (r5 << 3) | (r5 >> 2);
|
|
58
|
+
raw[out + 1] = (g6 << 2) | (g6 >> 4);
|
|
59
|
+
raw[out + 2] = (b5 << 3) | (b5 >> 2);
|
|
60
|
+
out += 3;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const ihdr = Buffer.alloc(13);
|
|
64
|
+
ihdr.writeUInt32BE(width, 0);
|
|
65
|
+
ihdr.writeUInt32BE(height, 4);
|
|
66
|
+
ihdr[8] = 8; // bit depth
|
|
67
|
+
ihdr[9] = 2; // color type: truecolor RGB
|
|
68
|
+
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
69
|
+
return Buffer.concat([
|
|
70
|
+
signature,
|
|
71
|
+
chunk('IHDR', ihdr),
|
|
72
|
+
chunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
|
|
73
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function validateScreenshotParams(query) {
|
|
78
|
+
const width = Number(query.get('w'));
|
|
79
|
+
const height = Number(query.get('h'));
|
|
80
|
+
const stride = query.has('stride') ? Number(query.get('stride')) : width * 2;
|
|
81
|
+
if (!Number.isInteger(width) || width < 16 || width > MAX_WIDTH) throw new Error('宽度无效');
|
|
82
|
+
if (!Number.isInteger(height) || height < 16 || height > MAX_HEIGHT) throw new Error('高度无效');
|
|
83
|
+
if (!Number.isInteger(stride) || stride < width * 2 || stride > MAX_WIDTH * 4) throw new Error('行距无效');
|
|
84
|
+
if ((query.get('fmt') || 'rgb565') !== 'rgb565') throw new Error('仅支持 rgb565 像素格式');
|
|
85
|
+
return { width, height, stride };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createScreenshotStore(dir) {
|
|
89
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
90
|
+
const sanitize = (deviceId) => String(deviceId).replace(/[^0-9a-f-]/gi, '').slice(0, 40) || 'unknown';
|
|
91
|
+
const prune = () => {
|
|
92
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.png')).sort();
|
|
93
|
+
for (const stale of files.slice(0, Math.max(0, files.length - KEEP_FILES))) {
|
|
94
|
+
try { fs.unlinkSync(path.join(dir, stale)); } catch { /* 并发清理可忽略 */ }
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
save(deviceId, png) {
|
|
99
|
+
const stamp = Date.now();
|
|
100
|
+
const dev = sanitize(deviceId);
|
|
101
|
+
// 同毫秒多次保存不得互相覆盖(连拍/快速重试会撞毫秒,曾有测试因
|
|
102
|
+
// 27 次保存挤在几个毫秒里而假失败,实机连拍也会丢图)。
|
|
103
|
+
let name = `${stamp}-${dev}.png`;
|
|
104
|
+
for (let n = 1; fs.existsSync(path.join(dir, name)); n += 1) {
|
|
105
|
+
name = `${stamp}-${dev}-${n}.png`;
|
|
106
|
+
}
|
|
107
|
+
const file = path.join(dir, name);
|
|
108
|
+
fs.writeFileSync(file, png);
|
|
109
|
+
prune();
|
|
110
|
+
return file;
|
|
111
|
+
},
|
|
112
|
+
latest(afterMs) {
|
|
113
|
+
let best = null;
|
|
114
|
+
let bestTime = 0;
|
|
115
|
+
for (const name of fs.readdirSync(dir)) {
|
|
116
|
+
if (!name.endsWith('.png')) continue;
|
|
117
|
+
const stamp = Number(name.split('-')[0]);
|
|
118
|
+
if (!Number.isFinite(stamp) || stamp < afterMs) continue;
|
|
119
|
+
const file = path.join(dir, name);
|
|
120
|
+
let mtime = 0;
|
|
121
|
+
try { mtime = fs.statSync(file).mtimeMs; } catch { continue; }
|
|
122
|
+
if (mtime > bestTime) {
|
|
123
|
+
best = file;
|
|
124
|
+
bestTime = mtime;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return best;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export { MAX_UPLOAD };
|
package/src/config.mjs
CHANGED
|
@@ -133,7 +133,9 @@ export function loadConfig(env = process.env) {
|
|
|
133
133
|
}
|
|
134
134
|
const port = Number(raw.port || 8787);
|
|
135
135
|
const devicePort = Number(raw.devicePort || 8788);
|
|
136
|
-
|
|
136
|
+
// UDP 发现端口:默认与官方端口一致;跑第二个实例(开发/测试)时必须错开。
|
|
137
|
+
const discoveryPort = Number(env.VIBE_DISCOVERY_PORT || raw.discoveryPort || 8789);
|
|
138
|
+
if (![port, devicePort, discoveryPort].every((p) => Number.isInteger(p) && p > 0 && p < 65536)) {
|
|
137
139
|
throw new Error('端口号必须在 1 到 65535 之间');
|
|
138
140
|
}
|
|
139
141
|
// 同时执行的会话数。同一会话恒为串行(编程工具按会话线程加写锁,并行必然冲突),
|
|
@@ -142,6 +144,16 @@ export function loadConfig(env = process.env) {
|
|
|
142
144
|
if (!Number.isInteger(maxParallelJobs) || maxParallelJobs < 1 || maxParallelJobs > 4) {
|
|
143
145
|
throw new Error('并行任务数必须是 1 到 4 之间的整数');
|
|
144
146
|
}
|
|
147
|
+
// 卡死自愈:任务连续 N 分钟没有任何输出(进度/流式/stderr 都算)视为卡死,
|
|
148
|
+
// 桥接器杀掉编程工具进程并自动重跑,最多 maxAutoRetries 次。
|
|
149
|
+
const stuckIdleMinutes = Number(env.VIBE_STUCK_IDLE_MINUTES ?? raw.stuckIdleMinutes ?? 5);
|
|
150
|
+
if (!Number.isInteger(stuckIdleMinutes) || stuckIdleMinutes < 1 || stuckIdleMinutes > 60) {
|
|
151
|
+
throw new Error('卡死判定时长必须是 1 到 60 之间的整数(分钟)');
|
|
152
|
+
}
|
|
153
|
+
const maxAutoRetries = Number(env.VIBE_MAX_AUTO_RETRIES ?? raw.maxAutoRetries ?? 1);
|
|
154
|
+
if (!Number.isInteger(maxAutoRetries) || maxAutoRetries < 0 || maxAutoRetries > 3) {
|
|
155
|
+
throw new Error('卡死自动重跑次数必须是 0 到 3 之间的整数');
|
|
156
|
+
}
|
|
145
157
|
const stateDir = path.resolve(env.VIBE_STATE_DIR
|
|
146
158
|
|| (hasUserConfig ? path.join(path.dirname(configPath), 'state') : dataHome(env)));
|
|
147
159
|
const usbPort = validateUsbPort(env.VIBE_USB_PORT || '');
|
|
@@ -172,7 +184,10 @@ export function loadConfig(env = process.env) {
|
|
|
172
184
|
defaultProvider,
|
|
173
185
|
port,
|
|
174
186
|
devicePort,
|
|
187
|
+
discoveryPort,
|
|
175
188
|
maxParallelJobs,
|
|
189
|
+
stuckIdleMinutes,
|
|
190
|
+
maxAutoRetries,
|
|
176
191
|
codexModel: models.codex,
|
|
177
192
|
models,
|
|
178
193
|
asrMode,
|
package/src/jobs.mjs
CHANGED
|
@@ -11,7 +11,7 @@ function now() {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export class JobStore {
|
|
14
|
-
constructor({ file, projects, defaultProject, defaultProvider, workspaceRoot, maxParallel, run, providerAvailable, pluginsRuntime, hooks }) {
|
|
14
|
+
constructor({ file, projects, defaultProject, defaultProvider, workspaceRoot, maxParallel, maxAutoRetries, run, providerAvailable, pluginsRuntime, hooks }) {
|
|
15
15
|
this.agent = pluginsRuntime ? (id) => pluginsRuntime.agent(id) : run.agent || builtinAgent;
|
|
16
16
|
this.hooks = hooks || null;
|
|
17
17
|
this.file = file;
|
|
@@ -24,6 +24,9 @@ export class JobStore {
|
|
|
24
24
|
// 同时执行的会话数。缺省 1 = 完全串行(保持既有语义,便于测试与降级)。
|
|
25
25
|
const lanes = Number(maxParallel);
|
|
26
26
|
this.maxParallel = Number.isFinite(lanes) ? Math.min(Math.max(Math.trunc(lanes), 1), 4) : 1;
|
|
27
|
+
// 卡死自愈:看门狗杀掉无输出的编程工具进程后,同车道自动重跑的次数上限。
|
|
28
|
+
const retries = Number(maxAutoRetries);
|
|
29
|
+
this.maxAutoRetries = Number.isFinite(retries) ? Math.min(Math.max(Math.trunc(retries), 0), 3) : 1;
|
|
27
30
|
// 正在占用车道的会话 ID。一个会话同时只会有一条指令在跑,所以按会话占位即可。
|
|
28
31
|
this.lanes = new Set();
|
|
29
32
|
this.sessions = new SessionStore({
|
|
@@ -344,6 +347,20 @@ export class JobStore {
|
|
|
344
347
|
}
|
|
345
348
|
} catch (error) {
|
|
346
349
|
if (job.status === 'running') {
|
|
350
|
+
// 卡死自愈:看门狗杀掉无输出的编程工具进程后,同车道自动重跑,
|
|
351
|
+
// 最多 maxAutoRetries 次;重跑的是同一条指令(native 会话会 resume 同一 CLI 线程)。
|
|
352
|
+
const attempts = job.attempts || 0;
|
|
353
|
+
if (error?.autoRetry && attempts < this.maxAutoRetries) {
|
|
354
|
+
job.attempts = attempts + 1;
|
|
355
|
+
job.status = 'queued';
|
|
356
|
+
job.progress = '任务疑似卡死,已终止编程工具进程并自动重跑';
|
|
357
|
+
job.progressAt = now();
|
|
358
|
+
job.error = '';
|
|
359
|
+
job.updatedAt = now();
|
|
360
|
+
this.save();
|
|
361
|
+
this.hooks?.notify('retrying', job);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
347
364
|
const retryableConflict = error?.retryable === true;
|
|
348
365
|
job.status = retryableConflict ? 'waiting_confirmation' : 'failed';
|
|
349
366
|
job.retryPending = retryableConflict;
|
package/src/plugins/registry.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { isPackageEntry, packageDirectories, readPackageSettings } from '../plug
|
|
|
6
6
|
import { MODEL_ID } from '../model-settings.mjs';
|
|
7
7
|
import { builtinAgents } from '../agents/index.mjs';
|
|
8
8
|
import { normalizeAgentIcon } from '../agent-icons.mjs';
|
|
9
|
+
import { readSprite } from '../agent-sprites.mjs';
|
|
9
10
|
import { builtinSpeechRecognizers } from '../speech/index.mjs';
|
|
10
11
|
|
|
11
12
|
export const PLUGIN_API_VERSION = 1;
|
|
@@ -95,8 +96,16 @@ export async function loadConfiguredPlugins(config) {
|
|
|
95
96
|
const exported = (await import(pathToFileURL(filename).href)).default;
|
|
96
97
|
const bundle = Array.isArray(exported) ? exported : [exported];
|
|
97
98
|
const pack = isPackageEntry(filename) ? readPackageSettings(path.dirname(filename)) : null;
|
|
99
|
+
const packageDir = isPackageEntry(filename) ? path.dirname(filename) : null;
|
|
98
100
|
for (const p of bundle) {
|
|
99
101
|
validatePlugin(p);
|
|
102
|
+
// 素材挂包目录:sprite/ 校验失败只降级(该助手用固件兜底形象),不阻断插件加载。
|
|
103
|
+
if (packageDir && p.kind === 'coding-agent') {
|
|
104
|
+
try { p.sprite = readSprite(packageDir) || undefined; } catch (error) {
|
|
105
|
+
console.warn(`插件 ${p.id}:${error.message},改用内置兜底形象`);
|
|
106
|
+
p.sprite = undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
100
109
|
if (p.kind !== 'coding-agent' && p.kind !== 'board-plugin') throw new Error(`插件 ${p.id}:外部插件仅支持 coding-agent;语音识别请配置兼容接口的 URL、API Key 和模型名`);
|
|
101
110
|
if (pack) {
|
|
102
111
|
const mismatch = pack.id && pack.id !== p.id;
|
package/src/providers.mjs
CHANGED
|
@@ -39,6 +39,8 @@ export function providerStates(config, locale = config.locale) {
|
|
|
39
39
|
},
|
|
40
40
|
model: plugin.capabilities.model && plugin.model
|
|
41
41
|
? { default: plugin.model.default, required: plugin.model.required } : null,
|
|
42
|
+
// 形象素材清单:码得按 version 决定是否分片重拉(docs/SPRITES.md)。
|
|
43
|
+
sprite: plugin.sprite ? { version: plugin.sprite.version, files: plugin.sprite.files } : null,
|
|
42
44
|
canCancelRunning: plugin.capabilities.cancel,
|
|
43
45
|
};
|
|
44
46
|
});
|
package/src/server.mjs
CHANGED
|
@@ -25,6 +25,8 @@ import { createPluginRegistry, loadConfiguredPlugins } from './plugins/registry.
|
|
|
25
25
|
import { createEnhancement } from './plugins/enhance.mjs';
|
|
26
26
|
import { listInstalled, listThemes, removePlugin, removeTheme, savePluginSettings, setPluginEnabled, themeExists } from './installed.mjs';
|
|
27
27
|
import { createBoardLink } from './board-link.mjs';
|
|
28
|
+
import { spriteChunk } from './agent-sprites.mjs';
|
|
29
|
+
import { createScreenshotStore, rgb565ToPng, validateScreenshotParams, MAX_UPLOAD } from './board-screenshot.mjs';
|
|
28
30
|
import { initializeSpeechSettings, saveSpeechSettings } from './speech-settings.mjs';
|
|
29
31
|
import { builtinSpeechRecognizers } from './speech/index.mjs';
|
|
30
32
|
const PAIRED_DISCOVERY_REQUEST = /^VIBE_DISCOVER_PAIRED_V2 ([0-9a-f]{12}) ([0-9a-f]{32})$/;
|
|
@@ -253,6 +255,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
|
|
|
253
255
|
const usbForwardSecret = randomUUID();
|
|
254
256
|
const pairing = new PairingStore(config.stateDir);
|
|
255
257
|
const boardLink = createBoardLink();
|
|
258
|
+
const screenshots = createScreenshotStore(path.join(config.stateDir, 'screenshots'));
|
|
256
259
|
// 增强运行时:回合管道与生命周期通知给 JobStore 用,bridge 句柄给插件上下文用。
|
|
257
260
|
const enhance = createEnhancement(config);
|
|
258
261
|
const tunnel = providedTunnel || new TunnelManager({
|
|
@@ -306,6 +309,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
|
|
|
306
309
|
defaultProject: config.defaultProject,
|
|
307
310
|
defaultProvider: config.defaultProvider,
|
|
308
311
|
maxParallel: config.maxParallelJobs,
|
|
312
|
+
maxAutoRetries: config.maxAutoRetries,
|
|
309
313
|
pluginsRuntime: config.pluginsRuntime,
|
|
310
314
|
run: run || createRunner(config),
|
|
311
315
|
hooks: enhance,
|
|
@@ -545,6 +549,41 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
|
|
|
545
549
|
send(res, 200, { command: boardLink.send(named, { name: payload.name, fields: payload.fields }) });
|
|
546
550
|
return;
|
|
547
551
|
}
|
|
552
|
+
// 板端截屏触发:向在线的码得下发 debug.screenshot 命令,等待固件回传并返回 PNG。
|
|
553
|
+
if (url.pathname === '/api/screenshot') {
|
|
554
|
+
const online = boardLink.deviceIds();
|
|
555
|
+
if (!online.length) throw new Error('码得未连接(板侧能力清单未上报)');
|
|
556
|
+
const before = Date.now() - 1500;
|
|
557
|
+
let queued = 0;
|
|
558
|
+
let lastError = '';
|
|
559
|
+
for (const deviceId of online) {
|
|
560
|
+
try {
|
|
561
|
+
boardLink.send(deviceId, { name: 'debug.screenshot' });
|
|
562
|
+
queued += 1;
|
|
563
|
+
} catch (error) {
|
|
564
|
+
lastError = error.message;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (!queued) throw new Error(lastError || '命令未能下发');
|
|
568
|
+
const deadline = Date.now() + 15000;
|
|
569
|
+
while (Date.now() < deadline) {
|
|
570
|
+
const file = screenshots.latest(before);
|
|
571
|
+
if (file) {
|
|
572
|
+
const png = fs.readFileSync(file);
|
|
573
|
+
res.writeHead(200, {
|
|
574
|
+
'Content-Type': 'image/png',
|
|
575
|
+
'Content-Length': png.length,
|
|
576
|
+
'X-Screenshot-File': path.basename(file),
|
|
577
|
+
'Cache-Control': 'no-store',
|
|
578
|
+
'X-Content-Type-Options': 'nosniff',
|
|
579
|
+
});
|
|
580
|
+
res.end(png);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
584
|
+
}
|
|
585
|
+
throw new Error('码得未在 15 秒内回传截图(固件需要更新到支持 debug.screenshot 的版本)');
|
|
586
|
+
}
|
|
548
587
|
const pairAction = url.pathname.match(/^\/api\/pair\/([0-9a-f]{12})\/(confirm|reject)$/);
|
|
549
588
|
if (pairAction) {
|
|
550
589
|
const { nonce } = await body(req);
|
|
@@ -696,6 +735,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
|
|
|
696
735
|
return;
|
|
697
736
|
}
|
|
698
737
|
const deviceId = pairing.deviceIdForToken(token);
|
|
738
|
+
if (process.env.VIBE_UI_DIAGNOSTICS === '1') console.info('[device-req]', req.method, url.pathname);
|
|
699
739
|
if (req.method === 'POST' && url.pathname === '/device/capabilities') {
|
|
700
740
|
boardLink.setCatalog(deviceId, await body(req));
|
|
701
741
|
boardLink.connect(deviceId, config.pluginsRuntime, config, enhance.bridge());
|
|
@@ -795,16 +835,62 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
|
|
|
795
835
|
// picker lives on the desktop. Keep room for twelve bounded bitmaps.
|
|
796
836
|
const payload = {
|
|
797
837
|
locale: config.locale,
|
|
798
|
-
providers: providerStates(config, deviceLocale).map(({ id, label, available, reason, icon, capabilities }) => ({ id, label, available, reason: devicePreview(reason, 120), icon, capabilities })),
|
|
838
|
+
providers: providerStates(config, deviceLocale).map(({ id, label, available, reason, icon, capabilities, sprite }) => ({ id, label, available, reason: devicePreview(reason, 120), icon, capabilities, sprite })),
|
|
799
839
|
projects: config.projects.filter(({ id }) => id === config.defaultProject).map(({ id, label }) => ({ id, label: devicePreview(label, 64) })),
|
|
800
840
|
defaultProject: config.defaultProject,
|
|
801
841
|
voice: { mode: voice.mode, available: voice.available },
|
|
802
842
|
board: { events: boardLink.subscription(pairing.deviceIdForToken(token), config.pluginsRuntime, config) },
|
|
843
|
+
// 桥接器重启会丢掉板侧命令清单:让板子看到 announce 后重新上报。
|
|
844
|
+
announce: !boardLink.hasCatalog(pairing.deviceIdForToken(token)),
|
|
803
845
|
};
|
|
804
846
|
if (Buffer.byteLength(JSON.stringify(payload)) > 32 * 1024) throw new Error('码得配置超过 32 KB,请检查助手元数据');
|
|
847
|
+
if (process.env.VIBE_UI_DIAGNOSTICS === '1') console.info('[device-config]', deviceId, 'announce=', payload.announce,
|
|
848
|
+
'providers=', payload.providers.map((p, i) => `${i}:${p.id}${p.sprite ? '@' + p.sprite.version : ''}`).join(' ') || 'none');
|
|
805
849
|
deviceSend(res, 200, payload);
|
|
806
850
|
return;
|
|
807
851
|
}
|
|
852
|
+
// 形象素材分片拉取:单次 ≤192KB,兼容 USB 帧转发 256KB 上限(docs/SPRITES.md)。
|
|
853
|
+
const spriteMatch = req.method === 'GET' &&
|
|
854
|
+
url.pathname.match(/^\/device\/sprite\/([a-z0-9][a-z0-9_-]{0,39})\/([A-Za-z0-9_-]+\.png|manifest\.json)$/);
|
|
855
|
+
if (spriteMatch) {
|
|
856
|
+
const plugin = config.pluginsRuntime.agent(spriteMatch[1]);
|
|
857
|
+
if (spriteMatch[2] === 'manifest.json') {
|
|
858
|
+
if (!plugin?.sprite) {
|
|
859
|
+
deviceSend(res, 404, { error: '素材不存在' });
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
deviceSend(res, 200, {
|
|
863
|
+
version: plugin.sprite.version, canvas: plugin.sprite.canvas,
|
|
864
|
+
layers: plugin.sprite.layers, ...(plugin.sprite.motion ? { motion: plugin.sprite.motion } : {}),
|
|
865
|
+
});
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
// 单次应答上限:固件按 offset 循环续拉整文件,这里故意切小。USB 链路
|
|
869
|
+
// (尤其叠着固件控制台输出时)对十几 KB 以上的帧偶发丢失;4KB 应答在
|
|
870
|
+
// 实测中始终送达,代价只是多几个来回。固件侧对下载失败已有 15s×5 次
|
|
871
|
+
// 重试(59f4043),此处小分片是为了把单次失败概率压到最低。
|
|
872
|
+
const SPRITE_DEVICE_CHUNK = 4 * 1024;
|
|
873
|
+
const requested = url.searchParams.has('len') ? Number(url.searchParams.get('len')) : undefined;
|
|
874
|
+
const chunk = plugin?.sprite ? spriteChunk(plugin.sprite, spriteMatch[2],
|
|
875
|
+
Number(url.searchParams.get('offset')) || 0,
|
|
876
|
+
requested === undefined ? SPRITE_DEVICE_CHUNK : Math.min(requested, SPRITE_DEVICE_CHUNK)) : null;
|
|
877
|
+
if (process.env.VIBE_UI_DIAGNOSTICS) {
|
|
878
|
+
console.log(`[device-sprite] ${spriteMatch[2]} offset=${url.searchParams.get('offset') ?? 0} len=${url.searchParams.get('len') ?? '-'} -> ${chunk ? chunk.length : -1}B`);
|
|
879
|
+
}
|
|
880
|
+
if (!chunk) {
|
|
881
|
+
deviceSend(res, 404, { error: '素材不存在' });
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
res.writeHead(200, {
|
|
885
|
+
'Content-Type': 'application/octet-stream',
|
|
886
|
+
'Content-Length': chunk.length,
|
|
887
|
+
'X-Sprite-Total': plugin.sprite.files.find((file) => file.file === spriteMatch[2])?.bytes ?? 0,
|
|
888
|
+
'Cache-Control': 'no-store',
|
|
889
|
+
'X-Content-Type-Options': 'nosniff',
|
|
890
|
+
});
|
|
891
|
+
res.end(chunk);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
808
894
|
if (req.method === 'POST' && url.pathname === '/device/voice') {
|
|
809
895
|
if (voiceBusy) {
|
|
810
896
|
deviceSend(res, 429, { error: '另一段录音正在识别,请稍后再试' });
|
|
@@ -854,6 +940,15 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
|
|
|
854
940
|
deviceSend(res, 200, publicJob(match[2] === 'confirm' ? store.confirm(match[1]) : store.cancel(match[1]), deviceLocale));
|
|
855
941
|
return;
|
|
856
942
|
}
|
|
943
|
+
// 板端截屏回传:固件把当前画面以 RGB565 裸帧 POST 上来(tools/shot.mjs 触发)。
|
|
944
|
+
if (req.method === 'POST' && url.pathname === '/device/screenshot') {
|
|
945
|
+
const { width, height, stride } = validateScreenshotParams(url.searchParams);
|
|
946
|
+
const raw = await binaryBody(req, MAX_UPLOAD);
|
|
947
|
+
const png = rgb565ToPng(width, height, raw, stride);
|
|
948
|
+
const saved = screenshots.save(deviceId, png);
|
|
949
|
+
deviceSend(res, 201, { saved: path.basename(saved) }, false);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
857
952
|
deviceSend(res, 404, { error: '未找到接口' });
|
|
858
953
|
} catch (error) {
|
|
859
954
|
deviceSend(res, error.message === '电脑配对窗口未开启' ? 403 :
|
package/src/usb-receiver.mjs
CHANGED
|
@@ -15,6 +15,9 @@ const MAX_CHUNK = 1024;
|
|
|
15
15
|
// 8×1KB 的 base64 行大约 11KB,小于接收器 16KB 接收环。窗口内不等确认,
|
|
16
16
|
// 避免 224KB 提示音被逐块往返拖过接收器 30 秒转发上限。
|
|
17
17
|
const ACK_WINDOW = 8;
|
|
18
|
+
// USB 直连(码得)走 ESP 的 USB Serial/JTAG,固件 RX 环只有 4KB;连发 8 块
|
|
19
|
+
// 会灌爆环、丢字节,表现为大响应偶发超时。直连模式收紧到 2 块(约 2.8KB)。
|
|
20
|
+
const ACK_WINDOW_DIRECT = 2;
|
|
18
21
|
const MAX_REQUEST = 1_100_000;
|
|
19
22
|
const MAX_RESPONSE = 262_144;
|
|
20
23
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
@@ -169,6 +172,8 @@ export class UsbReceiverProtocol {
|
|
|
169
172
|
this.locale = locale;
|
|
170
173
|
this.line = Buffer.alloc(0);
|
|
171
174
|
this.discardLine = false;
|
|
175
|
+
this.discardIsFrame = false;
|
|
176
|
+
this.ackWindow = ACK_WINDOW;
|
|
172
177
|
this.helloReceived = false;
|
|
173
178
|
this.peer = null;
|
|
174
179
|
this.connectionId = randomBytes(16).toString('hex');
|
|
@@ -191,9 +196,14 @@ export class UsbReceiverProtocol {
|
|
|
191
196
|
const segment = chunk.subarray(start, index);
|
|
192
197
|
start = index + 1;
|
|
193
198
|
if (this.discardLine || this.line.length + segment.length + 1 > MAX_LINE) {
|
|
199
|
+
// 超长行同样要区分日志与坏帧:固件的长日志行不该掐掉在途请求。
|
|
200
|
+
const looksLikeFrame = this.discardLine
|
|
201
|
+
? this.discardIsFrame
|
|
202
|
+
: (this.line.length ? this.line[0] : segment[0]) === 0x7b;
|
|
203
|
+
if (looksLikeFrame) this.rejectCurrent(400, 'USB 帧过长');
|
|
194
204
|
this.line = Buffer.alloc(0);
|
|
195
205
|
this.discardLine = false;
|
|
196
|
-
this.
|
|
206
|
+
this.discardIsFrame = false;
|
|
197
207
|
continue;
|
|
198
208
|
}
|
|
199
209
|
const line = Buffer.concat([this.line, segment]).toString('utf8').replace(/\r$/, '');
|
|
@@ -203,6 +213,7 @@ export class UsbReceiverProtocol {
|
|
|
203
213
|
const remainder = chunk.subarray(start);
|
|
204
214
|
if (this.discardLine) return;
|
|
205
215
|
if (this.line.length + remainder.length + 1 > MAX_LINE) {
|
|
216
|
+
this.discardIsFrame = (this.line.length ? this.line[0] : remainder[0]) === 0x7b;
|
|
206
217
|
this.line = Buffer.alloc(0);
|
|
207
218
|
this.discardLine = true;
|
|
208
219
|
return;
|
|
@@ -213,6 +224,13 @@ export class UsbReceiverProtocol {
|
|
|
213
224
|
handleLine(line) {
|
|
214
225
|
let frame;
|
|
215
226
|
try { frame = JSON.parse(line); } catch {
|
|
227
|
+
// 固件控制台日志和协议帧共用这条 USB 流(USB Serial/JTAG),日志行随时
|
|
228
|
+
// 会插进来。只有「看着像帧」的坏行才算协议错误;日志必须原样放行,
|
|
229
|
+
// 否则板子一打日志就会掐掉正在传输的响应(素材分片下载失败即此因)。
|
|
230
|
+
if (!line.startsWith('{')) {
|
|
231
|
+
console.error(`[usb-link] log: ${line.slice(0, 240)}`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
216
234
|
console.error(`[usb-link] unparseable: ${line.slice(0, 240)}`);
|
|
217
235
|
this.rejectCurrent(400, 'USB 帧格式无效');
|
|
218
236
|
return;
|
|
@@ -236,6 +254,7 @@ export class UsbReceiverProtocol {
|
|
|
236
254
|
if (this.onHello(peer) === false) return;
|
|
237
255
|
console.error(`[usb-link] hello: mode=${peer.mode} ${new Date().toISOString()}`);
|
|
238
256
|
this.peer = peer;
|
|
257
|
+
this.ackWindow = peer.mode === 'direct' ? ACK_WINDOW_DIRECT : ACK_WINDOW;
|
|
239
258
|
this.helloReceived = true;
|
|
240
259
|
this.onActivity();
|
|
241
260
|
// The receiver waits for this acknowledgement before forwarding HTTP.
|
|
@@ -399,11 +418,21 @@ export class UsbReceiverProtocol {
|
|
|
399
418
|
length: bytes.length }));
|
|
400
419
|
let sentChunks = 0;
|
|
401
420
|
for (let offset = 0; offset < bytes.length; offset += MAX_CHUNK) {
|
|
402
|
-
while (sentChunks - (this.ackCredits.get(id) || 0) >=
|
|
403
|
-
await this.waitForCredits(id, sentChunks -
|
|
421
|
+
while (sentChunks - (this.ackCredits.get(id) || 0) >= this.ackWindow) {
|
|
422
|
+
await this.waitForCredits(id, sentChunks - this.ackWindow + 1);
|
|
423
|
+
}
|
|
424
|
+
const piece = bytes.subarray(offset, offset + MAX_CHUNK);
|
|
425
|
+
// 逐帧指纹:这是【所有】USB 响应分片(sessions/commands 轮询也会打),
|
|
426
|
+
// 不是素材专属日志。与固件 [sprite-rx] 对齐比对仅在素材下载窗口内有效,
|
|
427
|
+
// 能定位到具体哪一帧被改——别的请求别拿这里的指纹对账(2026-10-14 排障
|
|
428
|
+
// 曾因把 15B/42B 轮询响应当素材分片绕了远路)。
|
|
429
|
+
if (process.env.VIBE_UI_DIAGNOSTICS === '1') {
|
|
430
|
+
let fnv = 2166136261 >>> 0;
|
|
431
|
+
for (const byte of piece) fnv = Math.imul(fnv ^ byte, 16777619) >>> 0;
|
|
432
|
+
console.log(`[usb-tx] id=${id} off=${offset} len=${piece.length} fnv1a=${fnv.toString(16).padStart(8, '0')}`);
|
|
404
433
|
}
|
|
405
434
|
await this.sendLine(JSON.stringify({ type: 'data', id,
|
|
406
|
-
chunk:
|
|
435
|
+
chunk: piece.toString('base64') }));
|
|
407
436
|
sentChunks += 1;
|
|
408
437
|
}
|
|
409
438
|
if (sentChunks > (this.ackCredits.get(id) || 0)) await this.waitForCredits(id, sentChunks);
|