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
+146
View File
@@ -0,0 +1,146 @@
// Observe the native client's local video channel without adding a UDP peer.
// Read the token internally from a deliberately enabled debug log; never print it.
import { readFile, writeFile, appendFile, mkdir, access } from 'node:fs/promises';
import { dirname } from 'node:path';
const args = Object.fromEntries(process.argv.slice(2).reduce((pairs, value, i, all) => {
if (i % 2 === 0) pairs.push([value.replace(/^--/, ''), all[i+1]]);
return pairs;
}, []));
if (!args.log || !args.out) throw new Error('Use --log <backend-debug-log> --out <output-prefix> [--seconds 1800] [--warmup 30]');
const seconds = Number(args.seconds ?? 300);
const warmup = Number(args.warmup ?? 10);
const sampleEveryMs = 1000*Number(args['sample-every'] ?? 0);
const camera = Number(args.camera ?? '0x3D4D8A9A');
await mkdir(dirname(args.out), { recursive: true });
const log = await readFile(args.log, 'utf8');
const url = [...log.matchAll(/ws:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+/g)].at(-1)?.[0];
if (!url) throw new Error('No local video-channel URL in the debug log');
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
let latest = null, demo = true, diagnostics = false, clients = 0, connected = false;
let binary = 0, invalid = 0, lastArrival = null, gaps = [], bytes = 0;
let firstFrame = null;
let sampleCount = 0, nextSampleAt = 0;
const allGaps = [], windows = [];
let measurementStarted = null, previousAt = null, previousStats = null;
let windowBinary = 0, windowInvalid = 0, windowBytes = 0;
let totalFrames = 0, totalDropped = 0, counterResets = 0, socketCloses = 0;
let offlineSnapshots = 0;
let diagnosticsUnavailableSnapshots = 0;
let finished = false;
ws.addEventListener('close', () => { connected = false; if (!finished) socketCloses++; });
ws.addEventListener('message', event => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
if (msg.t === 'snapshot') {
latest = msg.stats.find(stat => stat.camera === camera) ?? null;
demo = msg.demo;
diagnostics = msg.diagnostics;
clients = msg.clients;
if (measurementStarted !== null && (!latest || !latest.online)) offlineSnapshots++;
if (measurementStarted !== null && !diagnostics) diagnosticsUnavailableSnapshots++;
}
return;
}
const data = Buffer.from(event.data);
if (data.length < 16 || data.readUInt32LE(0) !== camera) return;
if (measurementStarted === null) return;
const valid = data.length === data.readUInt32LE(12)+16 && data[16] === 255 && data[17] === 216 && data.at(-2) === 255 && data.at(-1) === 217;
if (!valid) { invalid++; windowInvalid++; return; }
const now = performance.now();
if (lastArrival !== null) { gaps.push(now-lastArrival); allGaps.push(now-lastArrival); }
lastArrival = now;
binary++; windowBinary++;
bytes += data.length-16; windowBytes += data.length-16;
if (firstFrame === null) {
firstFrame = Buffer.from(data.subarray(16));
void writeFile(`${args.out}.first.jpg`, firstFrame).catch(error => console.error('Could not save sample JPEG:', error.message));
}
if (sampleEveryMs > 0 && now >= nextSampleAt) {
nextSampleAt = now+sampleEveryMs;
const path = `${args.out}.sample-${String(++sampleCount).padStart(4,'0')}.jpg`;
void writeFile(path, data.subarray(16)).catch(error => console.error('Could not save diagnostic JPEG:', error.message));
}
});
await Promise.race([
new Promise((resolve, reject) => { ws.addEventListener('open', () => { connected = true; resolve(); }, { once:true }); ws.addEventListener('error', reject, { once:true }); }),
new Promise((_, reject) => setTimeout(() => reject(new Error('Video channel open timeout')), 15_000)),
]);
await new Promise(resolve => setTimeout(resolve, warmup*1000));
if (demo || !latest?.online || !latest?.hasFrames || clients < 2) throw new Error('Native GUI and online real hardware video are required before starting the soak');
if (!diagnostics) throw new Error('Receiver counters require referee diagnostics; team release mode hides them');
measurementStarted = previousAt = performance.now();
previousStats = latest;
await writeFile(`${args.out}.windows.ndjson`, '');
function percentile(values, p) {
if (!values.length) return null;
const sorted = [...values].sort((a,b) => a-b);
return Number(sorted[Math.floor((sorted.length-1)*p)].toFixed(3));
}
async function sample() {
const now = performance.now();
const elapsed = (now-previousAt)/1000;
let complete = 0, dropped = 0;
if (latest && previousStats) {
if (latest.framesShown < previousStats.framesShown || latest.framesDropped < previousStats.framesDropped) counterResets++;
else { complete = latest.framesShown-previousStats.framesShown; dropped = latest.framesDropped-previousStats.framesDropped; }
}
totalFrames += complete; totalDropped += dropped;
const row = {
utc:new Date().toISOString(), elapsedSeconds:(now-measurementStarted)/1000, windowSeconds:elapsed,
completeFrames:complete, framesDropped:dropped, receiverFps:complete/elapsed,
frameLossPercent:100*dropped/Math.max(1,complete+dropped),
binaryFrames:windowBinary, channelFps:windowBinary/elapsed, invalidJpegs:windowInvalid,
meanJpegBytes:windowBinary ? windowBytes/windowBinary : 0,
gapP95Ms:percentile(gaps,.95), gapMaxMs:Math.max(0,...gaps, lastArrival===null ? (now-measurementStarted) : now-lastArrival),
cameraOnline:latest?.online ?? false, frontendConnected:clients>=2, observerConnected:connected,
};
windows.push(row);
await appendFile(`${args.out}.windows.ndjson`, JSON.stringify(row)+'\n');
await writeFile(`${args.out}.latest.json`, JSON.stringify(row,null,2)+'\n');
console.log(JSON.stringify(row));
previousAt = now;
previousStats = latest;
windowBinary = windowInvalid = windowBytes = 0;
gaps = [];
}
let stoppedByRequest = false;
while ((performance.now()-measurementStarted)/1000 < seconds) {
if (args['stop-file'] && await access(args['stop-file']).then(() => true, () => false)) {
stoppedByRequest = true;
break;
}
const remaining = seconds-(performance.now()-measurementStarted)/1000;
await new Promise(resolve => setTimeout(resolve, Math.min(1,remaining)*1000));
if (performance.now()-previousAt >= 60_000 || (performance.now()-measurementStarted)/1000 >= seconds) await sample();
}
if (stoppedByRequest && performance.now()-previousAt > 1_000) await sample();
finished = true;
ws.close();
const actualSeconds = (performance.now()-measurementStarted)/1000;
const receiverMetricsValid = counterResets === 0 && diagnosticsUnavailableSnapshots === 0;
const summary = {
actualSeconds, requestedSeconds:seconds, stoppedByRequest, warmupSeconds:warmup, camera, demo, windows, binaryFrames:binary,
invalidJpegs:invalid, receiverMetricsValid,
completeFrames:receiverMetricsValid ? totalFrames : null, framesDropped:receiverMetricsValid ? totalDropped : null,
rawWindowCountedCompleteFrames:totalFrames, rawWindowCountedDroppedFrames:totalDropped,
receiverFps:receiverMetricsValid ? totalFrames/actualSeconds : null, channelFps:binary/actualSeconds,
frameLossPercent:receiverMetricsValid ? 100*totalDropped/Math.max(1,totalFrames+totalDropped) : null,
gapP95Ms:percentile(allGaps,.95), gapMaxMs:Math.max(0,...allGaps),
meanJpegBytes:binary ? bytes/binary : 0, counterResets, socketCloses,
offlineSnapshots,
diagnosticsUnavailableSnapshots,
diagnosticSamplesSaved:sampleCount,
allWindowsOnline:windows.every(window => window.cameraOnline),
zeroVideoWindows:windows.filter(window => window.binaryFrames === 0).length,
noVideoSecondsAtEnd:lastArrival === null ? actualSeconds : (performance.now()-lastArrival)/1000,
maxObservedSilenceMs:Math.max(0,...windows.map(window => window.gapMaxMs)),
frontendConnected:clients>=2,
measurementScope:'Native receiver and local JPEG WebSocket channel; not optical display latency',
};
await writeFile(`${args.out}.summary.json`, JSON.stringify(summary,null,2)+'\n');
if (firstFrame) await writeFile(`${args.out}.first.jpg`, firstFrame);
console.log(JSON.stringify({ ...summary, windows:windows.length }));
if (demo || !binary || invalid || socketCloses || counterResets || offlineSnapshots || diagnosticsUnavailableSnapshots) process.exitCode = 1;
+50
View File
@@ -0,0 +1,50 @@
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/latency-marker.ts',import.meta.url),'utf8');
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
const exports={};new Function('exports',code)(exports);
const {drawMarker,decodeMarker}=exports;
function image(counter,w=320,h=240) {
const pixels=new Uint8ClampedArray(w*h*4);
const ctx={canvas:{width:w,height:h},fillStyle:'',fillRect(x,y,width,height){
const c=this.fillStyle.startsWith('rgb(')?this.fillStyle.slice(4,-1).split(',').map(Number):
this.fillStyle==='#fff'?[255,255,255]:this.fillStyle==='#000'?[0,0,0]:[60,60,60];
for(let yy=Math.max(0,Math.ceil(y));yy<Math.min(h,y+height);yy++)
for(let xx=Math.max(0,Math.ceil(x));xx<Math.min(w,x+width);xx++)pixels.set([...c,255],(yy*w+xx)*4);
}};
drawMarker(ctx,counter);return pixels;
}
test('screen IDs decode at Gray-code boundaries and across counter wrap',()=>{
for(const id of [0,1,2,3,63,127,1023,2047,2048,4094,4095,4096]) {
assert.equal(decodeMarker(image(id),320,240)?.counter,id&4095);
}
});
test('camera rotation and reflection preserve the observed ID',()=>{
const original=image(1371),rotated=new Uint8ClampedArray(original.length),flipped=new Uint8ClampedArray(original.length);
for(let y=0;y<240;y++)for(let x=0;x<320;x++){
rotated.set(original.subarray((y*320+x)*4,(y*320+x)*4+4),(x*240+239-y)*4);
flipped.set(original.subarray((y*320+x)*4,(y*320+x)*4+4),(y*320+319-x)*4);
}
assert.equal(decodeMarker(rotated,240,320)?.counter,1371);
assert.equal(decodeMarker(flipped,320,240)?.counter,1371);
});
test('missing fiducials and ambiguous rolling transitions do not invent latency samples',()=>{
assert.equal(decodeMarker(new Uint8ClampedArray(320*240*4),320,240),null);
const pixels=image(123);
for(let y=90;y<152;y++)for(let x=44;x<276;x++)pixels.set([128,128,128,255],(y*320+x)*4);
assert.equal(decodeMarker(pixels,320,240),null);
});
test('washed screen primaries survive without mistaking a blue-tinted background',()=>{
const pixels=image(1371);
for(let i=0;i<pixels.length;i+=4){
const r=pixels[i],g=pixels[i+1],b=pixels[i+2];
if(r===32&&g===255&&b===32)pixels.set([190,255,208,255],i);
else if(r===32&&g===96&&b===255)pixels.set([78,205,255,255],i);
else if(r===255&&g===32&&b===255)pixels.set([255,112,255,255],i);
else if(r===60&&g===60&&b===60)pixels.set([165,181,225,255],i);
}
assert.equal(decodeMarker(pixels,320,240)?.counter,1371);
});
+41
View File
@@ -0,0 +1,41 @@
// Exercise the production StreamClient against native hardware video, while
// deliberately stalling only this auxiliary local consumer. Adds no UDP peer.
// Bitmap decoding is stubbed here; actual JPEG decoding is checked separately.
import {readFileSync,writeFileSync} from 'node:fs';
import assert from 'node:assert/strict';
import ts from 'typescript';
const [logPath,outPath]=process.argv.slice(2);
if(!logPath||!outPath)throw new Error('Use <private native debug log> <result JSON>');
const log=readFileSync(logPath,'utf8');
const url=[...log.matchAll(/ws:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+/g)].at(-1)?.[0];
if(!url)throw new Error('No local video URL in the private debug log');
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)(()=>({FRAME_HEADER_LEN:16}),exports);
globalThis.window={setTimeout,clearTimeout};
globalThis.createImageBitmap=async()=>({close(){}});
const rx=new exports.StreamClient({onMessage(){},onConnection(){}});
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
try {
rx.connect(url);
const deadline=performance.now()+10_000;
while(rx.counters.frames<10&&performance.now()<deadline)await delay(30);
assert.ok(rx.counters.frames>=10,'Real native JPEG video did not arrive');
const camera=[...rx.frames.keys()][0];
const before=rx.latest(camera)?.frameId;
const staleBefore=rx.counters.staleFrames;
const blockedAt=performance.now();
while(performance.now()-blockedAt<350) { /* synthetic consumer stall */ }
await delay(700);
const latest=rx.latest(camera);
assert.ok(rx.counters.staleFrames>staleBefore,'Stale native-timestamped frames were not rejected');
assert.ok(latest&&latest.frameId!==before,'The consumer did not recover to new video');
assert.ok(performance.now()-latest.receivedAt<150,'Recovered image is still stale locally');
const result={passed:true,stall_ms:350,stale_frames_rejected:rx.counters.staleFrames-staleBefore,
frame_before:before,frame_after:latest.frameId,frames_received:rx.counters.frames,
additional_udp_subscribers:0,jpeg_decoder_stubbed:true,
scope:'Production StreamClient over real native WebSocket; tests queued-frame age and recovery, not physical display latency'};
writeFileSync(outPath,JSON.stringify(result,null,2)+'\n');
console.log(JSON.stringify(result));
} finally {rx.close();}
+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();
});