feat: 链路控制、延迟校准与长测工具,补全文档
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s

- 固件:新增 link_control / stream_control,覆盖运行中 Wi-Fi 断线恢复、取帧节拍、
  温控与质量/帧率减载;UDP 发送增加有界重试预算与帧期限,PSRAM JPEG 直发减少拷贝。
- 协议:重组器严格校验分片布局,统计未收齐、超时与完全缺失的帧,处理帧号回绕与设备重启。
- 桌面端:视频改 latest-value 通道并前置帧时间标记,拒绝积压和慢解码的过期画面;新增
  延迟标记页与 --latency-report 校准,完善录制、服务端与退出流程。
- 文档与脚本:新增硬件、开发、延迟、功耗、实时性、稳定性文档和改动记录,重写根 README;
  新增构建、性能、延迟与串口长测脚本,以及长测客户端脚本。
- 构建:固定 esp-1.97.0.0 工具链,分区表随构建复制,更新 sdkconfig 与忽略规则。
This commit is contained in:
2026-10-19 00:19:07 +08:00
parent 4794bed2cf
commit cc6b82d7ec
60 changed files with 4347 additions and 655 deletions
+143
View File
@@ -0,0 +1,143 @@
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import ts from 'typescript';
const source = readFileSync(new URL('../src/lib/stream.ts', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
const exports = {};
new Function('require', 'exports', compiled)(name => {
assert.equal(name, './types');
return { FRAME_HEADER_LEN: 16 };
}, exports);
const { StreamClient } = exports;
globalThis.window = { clearTimeout, setTimeout };
const tick = () => new Promise(resolve => setImmediate(resolve));
const bitmap = () => ({ closed: false, close() { this.closed = true; } });
function frame(id, camera = 42) {
const bytes = new ArrayBuffer(20);
const header = new DataView(bytes);
header.setUint32(0, camera, true);
header.setUint32(4, id, true);
header.setUint16(8, 640, true);
header.setUint16(10, 480, true);
header.setUint32(12, 4, true);
return bytes;
}
function client(now, wallNow) { return new StreamClient({ onMessage() {}, onConnection() {} },now,wallNow); }
function deferredDecoder() {
const requests = [];
globalThis.createImageBitmap = () => new Promise((resolve, reject) => requests.push({ resolve, reject }));
return requests;
}
test('a slow decoder keeps only the latest pending frame for each camera', async () => {
const requests = deferredDecoder();
const rx = client();
const finished = rx.onBinary(frame(1));
for (let id = 2; id <= 100; id++) await rx.onBinary(frame(id));
assert.equal(requests.length, 1);
assert.equal(rx.pending.size, 1);
const first = bitmap();
requests[0].resolve(first);
await tick();
assert.equal(requests.length, 2);
requests[1].resolve(bitmap());
await finished;
assert.equal(rx.latest(42).frameId, 100);
assert.equal(rx.counters.skippedDecodes, 98);
assert.equal(first.closed, true);
rx.close();
});
test('two cameras decode independently without queues growing behind either', async () => {
const requests = deferredDecoder();
const rx = client();
const a = rx.onBinary(frame(1, 1));
const b = rx.onBinary(frame(2, 2));
assert.equal(requests.length, 2);
requests[1].resolve(bitmap());
await b;
assert.equal(rx.latest(2).frameId, 2);
assert.equal(rx.latest(1), undefined);
requests[0].resolve(bitmap());
await a;
rx.close();
});
test('closing during decode releases the late bitmap and discards pending work', async () => {
const requests = deferredDecoder();
const rx = client();
const work = rx.onBinary(frame(1));
await rx.onBinary(frame(2));
rx.close();
const late = bitmap();
requests[0].resolve(late);
await work;
assert.equal(late.closed, true);
assert.equal(requests.length, 1);
assert.equal(rx.latest(42), undefined);
});
test('a corrupt JPEG does not stop the next frame from being decoded', async () => {
const requests = deferredDecoder();
const rx = client();
const work = rx.onBinary(frame(1));
await rx.onBinary(frame(2));
requests[0].reject(new Error('bad JPEG'));
await tick();
requests[1].resolve(bitmap());
await work;
assert.equal(rx.counters.decodeFailures, 1);
assert.equal(rx.latest(42).frameId, 2);
rx.close();
});
test('late frames are rejected but a reboot after silence can restart the counter', () => {
const rx = client();
rx.injectFrame(42, 100, 640, 480, bitmap());
const old = bitmap();
rx.injectFrame(42, 99, 640, 480, old);
assert.equal(old.closed, true);
assert.equal(rx.latest(42).frameId, 100);
rx.latest(42).receivedAt -= 2000;
rx.injectFrame(42, 1, 640, 480, bitmap());
assert.equal(rx.latest(42).frameId, 1);
rx.close();
});
test('a WebView backlog is discarded before JPEG decoding using native host timing', async () => {
const requests = deferredDecoder();
const rx = client(() => 0, () => 2000);
rx.onTiming({camera:42,frameId:1,publishedUnixMs:1500});
await rx.onBinary(frame(1));
assert.equal(requests.length,0);
assert.equal(rx.counters.staleFrames,1);
rx.onTiming({camera:42,frameId:2,publishedUnixMs:2000});
const work = rx.onBinary(frame(2));
requests[0].resolve(bitmap());await work;
assert.equal(rx.latest(42).frameId,2);
rx.close();
});
test('a blocked decoder releases its old result and resumes with the fresh pending frame', async () => {
let clock=0;
const requests=deferredDecoder(),rx=client(()=>clock);
const work=rx.onBinary(frame(1));
clock=110;await rx.onBinary(frame(2));
const old=bitmap();requests[0].resolve(old);await tick();
assert.equal(old.closed,true);
assert.equal(rx.latest(42),undefined);
requests[1].resolve(bitmap());await work;
assert.equal(rx.latest(42).frameId,2);
assert.equal(rx.counters.staleFrames,1);rx.close();
});
test('pending frames that expired during a decoder stall never reach the decoder', async () => {
let clock=0;
const requests=deferredDecoder(),rx=client(()=>clock);
const work=rx.onBinary(frame(1));await rx.onBinary(frame(2));
clock=200;const old=bitmap();requests[0].resolve(old);await work;
assert.equal(old.closed,true);assert.equal(requests.length,1);
assert.equal(rx.counters.staleFrames,2);assert.equal(rx.latest(42),undefined);rx.close();
});