feat: 链路控制、延迟校准与长测工具,补全文档
- 固件:新增 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:
@@ -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;
|
||||
Reference in New Issue
Block a user