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
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Summarize opt-in client marker reports using explicit, half-open row ranges."""
import argparse
import json
import math
from pathlib import Path
import statistics
def describe(values):
ordered = sorted(values)
if not ordered:
raise ValueError('No valid marker samples in the selected range')
return {
'count': len(ordered),
'mean': statistics.mean(ordered),
'p50': statistics.median(ordered),
'p95': ordered[int((len(ordered)-1)*.95)],
'p99': ordered[int((len(ordered)-1)*.99)],
'minimum': ordered[0],
'maximum': ordered[-1],
}
def summarize(rows, selection):
start, end = selection['start_row'], selection['end_row']
if not 0 <= start < end <= len(rows):
raise ValueError('Range must select complete rows from the report')
samples = rows[start:end]
seen = set()
for sample in samples:
if not all(isinstance(v, (int, float)) and math.isfinite(v) for v in sample.values()):
raise ValueError('Sample contains a nonfinite value')
if not 0 <= sample['lowerMs'] <= sample['upperMs'] <= 5000:
raise ValueError('Invalid refresh bracket')
key = (sample['counter'], sample['sourceDrawMs'])
if key in seen:
raise ValueError('A source marker was counted more than once')
seen.add(key)
return {
**selection,
'samples': len(samples),
'sample_span_seconds': (samples[-1]['drawMs']-samples[0]['drawMs'])/1000,
'marker_event_to_paint_ms': describe([(s['lowerMs']+s['upperMs'])/2 for s in samples]),
'lower_bound_ms': describe([s['lowerMs'] for s in samples]),
'upper_bound_ms': describe([s['upperMs'] for s in samples]),
'refresh_bracket_width_ms': describe([s['upperMs']-s['lowerMs'] for s in samples]),
'decoded_to_draw_ms': describe([s['decodeToDrawMs'] for s in samples]),
'marker_analysis_ms': describe([s['analysisMs'] for s in samples]),
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--report', type=Path, required=True)
parser.add_argument('--range', type=Path, action='append', required=True, dest='ranges')
parser.add_argument('--out', type=Path, required=True)
args = parser.parse_args()
rows = [json.loads(line) for line in args.report.read_text().splitlines() if line.strip()]
trials = [summarize(rows, json.loads(path.read_text())) for path in args.ranges]
result = {
'method': '50 ms source marker events; first decodable echo on production VideoSurface; midpoint of adjacent-rAF brackets',
'scope': 'Source-screen marker to camera exposure/capture, Wi-Fi/UDP, native reassembly, local WebSocket, JPEG decode and canvas draw',
'limitations': [
'rAF brackets estimate display scheduling; panel scanout and pixel response are not measured',
'Mixed-exposure or unrecognized markers are excluded; first decodable echo can follow an earlier ambiguous image',
'Not every displayed frame is a sample; accepted samples describe this calibration scene and network',
'decodeToDrawMs starts after JPEG decoding, not at UDP or WebSocket arrival',
],
'physical_panel_response_measured': False,
'trials': trials,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
for trial in trials:
print(json.dumps({k: trial[k] for k in ('fb_count', 'samples', 'marker_event_to_paint_ms')}))
if __name__ == '__main__':
main()