made-bridge 0.3.1 → 0.3.3
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/package.json +2 -2
- package/src/agent-sprites.mjs +21 -3
- package/src/board-link.mjs +7 -1
- package/src/plugins/contracts.d.ts +1 -1
- package/src/plugins/registry.mjs +3 -1
- package/src/server.mjs +5 -2
- package/src/usb-receiver.mjs +25 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "made-bridge",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "码得(made)独立语音编程设备的电脑侧桥接器:工作台 API、设备接口、任务调度与插件运行时。一条命令启动,配合 ESP32 固件使用,源码在 github.com/jobop/made。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,4 +47,4 @@
|
|
|
47
47
|
"doctor": "node src/server.mjs --doctor",
|
|
48
48
|
"test": "node --test --experimental-test-isolation=none --test-concurrency=1"
|
|
49
49
|
}
|
|
50
|
-
}
|
|
50
|
+
}
|
package/src/agent-sprites.mjs
CHANGED
|
@@ -54,7 +54,7 @@ function readManifestFile(dir) {
|
|
|
54
54
|
|
|
55
55
|
function validateManifest(raw) {
|
|
56
56
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new SpriteError('manifest 必须是对象');
|
|
57
|
-
if (Object.keys(raw).some((key) => !['version', 'canvas', 'layers', 'motion', 'emotions', 'actions'].includes(key))) {
|
|
57
|
+
if (Object.keys(raw).some((key) => !['version', 'canvas', 'layers', 'motion', 'emotions', 'actions', 'commands'].includes(key))) {
|
|
58
58
|
throw new SpriteError('manifest 包含不支持的字段');
|
|
59
59
|
}
|
|
60
60
|
if (!isInt(raw.version, 1, 2147483647)) throw new SpriteError('manifest.version 必须是正整数');
|
|
@@ -204,9 +204,27 @@ function validateManifest(raw) {
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
|
+
// 两级绑定模型(docs/SPRITES.md):commands 表(可选)定义「命令绑素材」
|
|
208
|
+
// ——抽象命令号 A1–A9 → actions 表动作名,纯编号不定语义。事件绑定(增强
|
|
209
|
+
// 插件)只发命令号;声明了本表的素材只认命令号,语义名直发废弃。
|
|
210
|
+
if (raw.commands !== undefined) {
|
|
211
|
+
const commands = raw.commands;
|
|
212
|
+
if (!commands || typeof commands !== 'object' || Array.isArray(commands)) {
|
|
213
|
+
throw new SpriteError('commands 必须是对象(A1–A9 → 动作名)');
|
|
214
|
+
}
|
|
215
|
+
const keys = Object.keys(commands);
|
|
216
|
+
if (keys.length > 9) throw new SpriteError('commands 最多 9 个命令号(A1–A9)');
|
|
217
|
+
const actionNames = new Set(Object.keys(raw.actions ?? {}));
|
|
218
|
+
for (const [slot, target] of Object.entries(commands)) {
|
|
219
|
+
if (!/^A[1-9]$/.test(slot)) throw new SpriteError(`commands.${slot} 命令号必须是 A1–A9`);
|
|
220
|
+
if (typeof target !== 'string' || !actionNames.has(target)) {
|
|
221
|
+
throw new SpriteError(`commands.${slot} 映射目标必须是 actions 表已声明的动作名`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
207
225
|
return {
|
|
208
226
|
version: raw.version, canvas: canvas, layers: raw.layers, motion: raw.motion ?? null,
|
|
209
|
-
emotions: raw.emotions ?? null, actions: raw.actions ?? null,
|
|
227
|
+
emotions: raw.emotions ?? null, actions: raw.actions ?? null, commands: raw.commands ?? null,
|
|
210
228
|
};
|
|
211
229
|
}
|
|
212
230
|
|
|
@@ -299,7 +317,7 @@ export function readSprite(packageDir) {
|
|
|
299
317
|
const value = Object.freeze({
|
|
300
318
|
dir: spriteDir, version: manifest.version, canvas: manifest.canvas,
|
|
301
319
|
layers: manifest.layers, motion: manifest.motion, emotions: manifest.emotions,
|
|
302
|
-
actions: manifest.actions,
|
|
320
|
+
actions: manifest.actions, commands: manifest.commands,
|
|
303
321
|
files: Object.freeze(files.map((file) => Object.freeze(file))),
|
|
304
322
|
});
|
|
305
323
|
cache.set(spriteDir, { stamp, value });
|
package/src/board-link.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
// 能力名:段 = 字母开头的 [A-Za-z0-9_]{0,15},1–3 段点分。大写段用于行动
|
|
2
|
+
// 系统的抽象命令号(action.A1–A9,两级绑定模型 docs/SPRITES.md);老语义
|
|
3
|
+
// 动作名与事件名全小写不受影响。
|
|
4
|
+
export const CAPABILITY_NAME = /^[a-zA-Z][a-zA-Z0-9_]{0,15}(?:\.[a-zA-Z][a-zA-Z0-9_]{0,15}){0,2}$/;
|
|
2
5
|
const FIELD_KEY = /^[a-z][a-z0-9_]{0,15}$/;
|
|
3
6
|
const COMMAND_ID = /^[1-9][0-9]{0,15}$/;
|
|
4
7
|
const MAX_ITEMS = 32;
|
|
@@ -245,6 +248,9 @@ export function createBoardLink() {
|
|
|
245
248
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw fail('事件无效');
|
|
246
249
|
if (typeof payload.name !== 'string' || !CAPABILITY_NAME.test(payload.name)) throw fail('事件无效');
|
|
247
250
|
const fields = parseFields(payload.fields);
|
|
251
|
+
// 设备事件一律落日志(含未订阅被拒的):调试/烧机观察通道依赖它,
|
|
252
|
+
// 例如 burn-in 压测的水位事件 burnin.mem 就没有任何插件订阅。
|
|
253
|
+
console.log('[board-event]', deviceId.slice(-6), payload.name, JSON.stringify(fields));
|
|
248
254
|
const catalog = catalogs.get(deviceId);
|
|
249
255
|
if (!catalog || !catalog.events.some((item) => item.name === payload.name)) throw fail('事件不在板侧清单');
|
|
250
256
|
const subscribed = this.subscription(deviceId, runtime, config);
|
|
@@ -159,7 +159,7 @@ export interface BridgeFacade {
|
|
|
159
159
|
cancelJob(id: string): Record<string, unknown>;
|
|
160
160
|
themes(): { name: string; title: string; sound: boolean; icon: boolean; background: boolean }[];
|
|
161
161
|
devices(): string[];
|
|
162
|
-
/** Action system:
|
|
162
|
+
/** Action system: command names derived from the device command catalog (`action.<name>` entries). Two-level binding model (docs/SPRITES.md): sprites declaring a `commands` map expose abstract slots `action.A1`–`action.A9` ("command binds artwork", decided by the sprite author); legacy sprites expose semantic action names (deprecated). Advisory only — the board silently ignores unknown action commands. */
|
|
163
163
|
deviceActions(deviceId: string): string[];
|
|
164
164
|
/** Enqueue a board command directly; only the board-side catalog constrains the name. */
|
|
165
165
|
send(deviceId: string, command: BoardCommand, audio?: Uint8Array): QueuedBoardCommand;
|
package/src/plugins/registry.mjs
CHANGED
|
@@ -33,7 +33,9 @@ export function validatePlugin(plugin) {
|
|
|
33
33
|
} else if (plugin.kind === 'board-plugin') {
|
|
34
34
|
const names = (key, min) => {
|
|
35
35
|
const list = plugin[key];
|
|
36
|
-
|
|
36
|
+
// 能力名与桥接器 CAPABILITY_NAME 同形(字母开头的段,1–3 段点分);
|
|
37
|
+
// 大写段用于行动系统的抽象命令号(action.A1–A9,docs/SPRITES.md)。
|
|
38
|
+
if (!Array.isArray(list) || list.length > 16 || list.length < min || list.some((name) => typeof name !== 'string' || !/^[a-zA-Z][a-zA-Z0-9_]{0,15}(?:\.[a-zA-Z][a-zA-Z0-9_]{0,15}){0,2}$/.test(name))) fail(`${key} 无效`);
|
|
37
39
|
if (new Set(list).size !== list.length) fail(`${key} 有重复项`);
|
|
38
40
|
};
|
|
39
41
|
names('events', 1);
|
package/src/server.mjs
CHANGED
|
@@ -582,7 +582,9 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
|
|
|
582
582
|
}
|
|
583
583
|
}
|
|
584
584
|
if (!queued) throw new Error(lastError || '命令未能下发');
|
|
585
|
-
|
|
585
|
+
// USB 分片上传 240×320 帧实测 >15s(base64 走请求通道),15s 会
|
|
586
|
+
// 误报失败——其实 PNG 随后正常落盘。放宽到 45s。
|
|
587
|
+
const deadline = Date.now() + 45000;
|
|
586
588
|
while (Date.now() < deadline) {
|
|
587
589
|
const file = screenshots.latest(before);
|
|
588
590
|
if (file) {
|
|
@@ -599,7 +601,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
|
|
|
599
601
|
}
|
|
600
602
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
601
603
|
}
|
|
602
|
-
throw new Error('码得未在
|
|
604
|
+
throw new Error('码得未在 45 秒内回传截图(固件需要更新到支持 debug.screenshot 的版本)');
|
|
603
605
|
}
|
|
604
606
|
if (url.pathname === '/api/firmware') {
|
|
605
607
|
const type = String(req.headers['content-type'] || '');
|
|
@@ -966,6 +968,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
|
|
|
966
968
|
layers: plugin.sprite.layers, ...(plugin.sprite.motion ? { motion: plugin.sprite.motion } : {}),
|
|
967
969
|
...(plugin.sprite.emotions ? { emotions: plugin.sprite.emotions } : {}),
|
|
968
970
|
...(plugin.sprite.actions ? { actions: plugin.sprite.actions } : {}),
|
|
971
|
+
...(plugin.sprite.commands ? { commands: plugin.sprite.commands } : {}),
|
|
969
972
|
});
|
|
970
973
|
return;
|
|
971
974
|
}
|
package/src/usb-receiver.mjs
CHANGED
|
@@ -220,6 +220,9 @@ export class UsbReceiverProtocol {
|
|
|
220
220
|
|
|
221
221
|
feed(chunk) {
|
|
222
222
|
if (this.closed) return;
|
|
223
|
+
if (chunk.includes(0x22) && chunk.includes(0x3a) && chunk.length > 60 && !chunk.slice(0, 30).includes('hello')) {
|
|
224
|
+
console.log('[usb-raw]', chunk.length, JSON.stringify(chunk.subarray(0, 120).toString('utf8')));
|
|
225
|
+
}
|
|
223
226
|
let start = 0;
|
|
224
227
|
for (let index = 0; index < chunk.length; index += 1) {
|
|
225
228
|
if (chunk[index] !== 10) continue;
|
|
@@ -264,7 +267,7 @@ export class UsbReceiverProtocol {
|
|
|
264
267
|
console.error(`[usb-link] log: ${line.slice(0, 240)}`);
|
|
265
268
|
return;
|
|
266
269
|
}
|
|
267
|
-
console.error(`[usb-link] unparseable
|
|
270
|
+
console.error(`[usb-link] unparseable len=${line.length} tail=${JSON.stringify(line.slice(-40))}`);
|
|
268
271
|
this.rejectCurrent(400, 'USB 帧格式无效');
|
|
269
272
|
return;
|
|
270
273
|
}
|
|
@@ -272,6 +275,9 @@ export class UsbReceiverProtocol {
|
|
|
272
275
|
this.rejectCurrent(400, 'USB 帧格式无效');
|
|
273
276
|
return;
|
|
274
277
|
}
|
|
278
|
+
if (frame && typeof frame.type === 'string' && frame.type !== 'hello') {
|
|
279
|
+
console.log('[usb-frame]', frame.type, frame.id ?? '', String(frame.path || '').slice(0, 60));
|
|
280
|
+
}
|
|
275
281
|
if (frame.type === 'hello') {
|
|
276
282
|
if (frame.version !== 1 || ![undefined, 'receiver', 'direct'].includes(frame.mode)) return;
|
|
277
283
|
const direct = frame.mode === 'direct';
|
|
@@ -285,13 +291,25 @@ export class UsbReceiverProtocol {
|
|
|
285
291
|
ssid: direct ? '' : frame.ssid, password: direct ? '' : frame.password };
|
|
286
292
|
if (this.peer && (this.peer.mode !== peer.mode || this.peer.deviceId !== peer.deviceId)) return;
|
|
287
293
|
if (this.onHello(peer) === false) return;
|
|
288
|
-
console.error(`[usb-link] hello: mode=${peer.mode} ${new Date().toISOString()}`);
|
|
294
|
+
console.error(`[usb-link] hello: mode=${peer.mode} diag=${frame.diag || '-'} ${new Date().toISOString()}`);
|
|
289
295
|
this.peer = peer;
|
|
290
296
|
this.ackWindow = peer.mode === 'direct' ? ACK_WINDOW_DIRECT : ACK_WINDOW;
|
|
291
297
|
this.helloReceived = true;
|
|
292
298
|
this.onActivity();
|
|
293
299
|
// The receiver waits for this acknowledgement before forwarding HTTP.
|
|
294
300
|
// A USB cable being present does not mean the desktop process is ready.
|
|
301
|
+
// ready 直发不进 pendingWork:pendingWork 同时承载 writeResponse 的
|
|
302
|
+
// ack 流控等待——设备偶发丢 ack 时该链最长堵 15s,期间所有重握手
|
|
303
|
+
// 都等不到 ready,设备侧表现为连环 E263 + UI 闪「连接电脑」(实锤
|
|
304
|
+
// 于 2026-10-20 冒烟:ack TIMEOUT id=52 前后 15s 全是 1.5s 间隔握手
|
|
305
|
+
// 超时)。sendLine 走 link.writeQueue,帧原子性与 pendingWork 相同;
|
|
306
|
+
// direct 固件 read_request_frame 本就支持在响应流中处理 ready。
|
|
307
|
+
// receiver 模式保持原排队路径(ready 语义 = 桥接进程就绪)。
|
|
308
|
+
if (peer.mode === 'direct' && !this.closed) {
|
|
309
|
+
this.sendLine(JSON.stringify({ type: 'ready', version: 1, connectionId: this.connectionId }))
|
|
310
|
+
.catch(() => this.dispose());
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
295
313
|
const generation = this.generation;
|
|
296
314
|
this.pendingWork = this.pendingWork.then(async () => {
|
|
297
315
|
if (!this.closed && this.helloReceived && this.generation === generation) {
|
|
@@ -318,6 +336,7 @@ export class UsbReceiverProtocol {
|
|
|
318
336
|
return;
|
|
319
337
|
}
|
|
320
338
|
if (frame.type === 'request') {
|
|
339
|
+
console.log('[usb-req]', frame.method, frame.path, 'id=' + frame.id);
|
|
321
340
|
if (this.current || this.responding || !validId(frame.id)) {
|
|
322
341
|
if (this.current) this.rejectCurrent(409, 'USB 请求重叠');
|
|
323
342
|
return;
|
|
@@ -382,6 +401,7 @@ export class UsbReceiverProtocol {
|
|
|
382
401
|
return;
|
|
383
402
|
}
|
|
384
403
|
if (frame.type === 'end') {
|
|
404
|
+
console.log('[usb-end] id=' + current.id + ' received=' + current.received + ' path=' + current.path.slice(0, 40));
|
|
385
405
|
if (current.received !== current.length) {
|
|
386
406
|
this.rejectCurrent(400, 'USB 请求长度不符');
|
|
387
407
|
return;
|
|
@@ -392,6 +412,8 @@ export class UsbReceiverProtocol {
|
|
|
392
412
|
try {
|
|
393
413
|
if (this.closed || this.current !== current || current.controller.signal.aborted) return;
|
|
394
414
|
const response = await this.forward(request, current.controller.signal);
|
|
415
|
+
console.log('[usb-resp]', response.status, request.method, request.path.slice(0, 40),
|
|
416
|
+
'body=' + (response.body ? response.body.length : -1));
|
|
395
417
|
if (this.closed || this.current !== current) return;
|
|
396
418
|
clearTimeout(current.timer);
|
|
397
419
|
await this.writeResponse(current.id, response.status, response.contentType, response.body);
|
|
@@ -485,7 +507,7 @@ export class UsbReceiverProtocol {
|
|
|
485
507
|
console.error(`[usb-link] ack TIMEOUT id=${id} ${new Date().toISOString()}`);
|
|
486
508
|
this.ackWaiters.delete(id);
|
|
487
509
|
reject(new Error('USB ACK 超时'));
|
|
488
|
-
},
|
|
510
|
+
}, 3_000);
|
|
489
511
|
timer.unref?.();
|
|
490
512
|
const check = () => {
|
|
491
513
|
if ((this.ackCredits.get(id) || 0) < target) return;
|