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
+140 -24
View File
@@ -305,16 +305,8 @@ impl Fragment {
return None;
}
// 一帧至少一个分片,且分片下标必须落在范围内。
if header.frag_count == 0 || header.frag_index >= header.frag_count {
return None;
}
let payload = &bytes[HEADER_LEN..];
// JPEG 不可能比承载它的分片还短。检查很便宜,却能在造成一次大额分配之前
// 挡掉损坏的头。
if payload.len() as u64 > header.total_len as u64 {
if !valid_fragment_layout(&header, payload.len()) {
return None;
}
@@ -325,6 +317,17 @@ impl Fragment {
}
}
fn valid_fragment_layout(header: &Header, payload_len: usize) -> bool {
let size = header.total_len as u64;
let chunk = MAX_FRAGMENT_PAYLOAD as u64;
let count = ((size + chunk - 1) / chunk).max(1);
let offset = header.frag_index as u64 * chunk;
header.frag_count as u64 == count
&& header.frag_index < header.frag_count
&& offset <= size
&& payload_len as u64 == (size - offset).min(chunk)
}
/// 一张完整重组好的 JPEG。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
@@ -572,6 +575,10 @@ pub struct FrameAssembler {
/// 存在的原因是 `seen` 会被重复包灌水:UDP 会重复投递,一个丢失的分片可以
/// 被另一个包的副本凑数,于是"收够了"却留着空洞,交出去一张尾部是 0 的坏图。
have: Vec<bool>,
total_len: u32,
latest_id: Option<u32>,
last_fragment_at: Option<std::time::Instant>,
dropped_frames: u64,
}
impl FrameAssembler {
@@ -588,6 +595,10 @@ impl FrameAssembler {
timeout,
seen: 0,
have: Vec::new(),
total_len: 0,
latest_id: None,
last_fragment_at: None,
dropped_frames: 0,
}
}
@@ -596,31 +607,52 @@ impl FrameAssembler {
self.sender_id
}
/// 未收齐、超时以及完全缺失的帧累计数;等待分片和重复包不增加此值。
pub fn dropped_frames(&self) -> u64 {
self.dropped_frames
}
/// 收到一个分片。
///
/// 这张分片补齐一张帧时返回 [`Frame`]。
///
/// `Err(Superseded)` 表示这个分片属于一张**已被放弃**的帧。多路复用时(裁判
/// 终端同时收两块板子)必须每个发送方一个重组器——混在一起会让两块板子的分片
/// 互相顶掉。这里不做检查,是调用方的责任。
/// 为兼容已有调用,等待更多分片也返回 `Err(Superseded)`;丢帧统计应读取
/// `dropped_frames()` 的增量,不能把每次 Err 当作丢帧或全部忽略。
pub fn push(&mut self, frag: Fragment) -> Result<Frame, DropReason> {
if !valid_fragment_layout(&frag.header, frag.payload.len()) {
return Err(DropReason::Superseded);
}
let sender_id = frag.header.sender_id;
let frame_id = frag.header.frame_id;
// 换了发送方:上一张属于别人的帧就此作废。
if self.sender_id != 0 && self.sender_id != sender_id {
self.reset();
self.latest_id = None;
self.last_fragment_at = None;
}
self.sender_id = sender_id;
let superseded = match self.current_id {
None => false,
Some(id) => id != frame_id,
};
if superseded {
self.reset();
if let Some(latest) = self.latest_id {
let distance = frame_id.wrapping_sub(latest) as i32;
let restarted = distance < 0 && self.last_fragment_at.is_some_and(|at| {
at.elapsed() >= self.timeout.max(std::time::Duration::from_secs(1))
});
if restarted {
self.reset();
self.latest_id = None;
} else if distance < 0 || (distance == 0 && self.current_id.is_none()) {
// A late packet cannot replace a newer frame or republish one
// already completed/expired. Signed subtraction handles u32 wrap.
return Err(DropReason::Superseded);
} else if distance > 0 {
self.dropped_frames += u64::from(self.current_id.is_some())
+ distance.saturating_sub(1) as u64;
self.reset();
}
}
self.latest_id = Some(frame_id);
self.last_fragment_at = Some(std::time::Instant::now());
match self.current_id {
None => {
@@ -629,6 +661,7 @@ impl FrameAssembler {
self.buffer.clear();
// 按 total_len 预留,最后拼接时不再重新分配。
self.buffer.reserve(frag.header.total_len as usize);
self.total_len = frag.header.total_len;
self.width = frag.header.width;
self.height = frag.header.height;
self.since = Some(std::time::Instant::now());
@@ -636,7 +669,14 @@ impl FrameAssembler {
self.have.clear();
self.have.resize(self.expected, false);
}
Some(id) if id == frame_id => {}
Some(id) if id == frame_id => {
if frag.header.total_len != self.total_len
|| frag.header.frag_count as usize != self.expected
|| frag.header.width != self.width || frag.header.height != self.height
{
return Err(DropReason::Superseded);
}
}
_ => unreachable!("current_id was just reconciled with frame_id"),
}
@@ -697,6 +737,7 @@ impl FrameAssembler {
pub fn expire(&mut self) -> Option<DropReason> {
match self.since {
Some(started) if started.elapsed() > self.timeout => {
self.dropped_frames += 1;
self.reset();
Some(DropReason::TimedOut)
}
@@ -712,11 +753,10 @@ impl FrameAssembler {
fn reset(&mut self) {
self.current_id = None;
self.expected = 0;
self.buffer = Vec::new();
self.buffer.shrink_to_fit();
self.buffer.clear();
self.since = None;
self.seen = 0;
self.have = Vec::new();
self.have.clear();
}
}
@@ -1013,4 +1053,80 @@ mod tests {
assert!(f.encode().len() < 1500, "must fit an Ethernet MTU");
}
}
}
#[test]
fn counts_partial_and_wholly_missing_frames_without_counting_pending_packets() {
let mut asm = FrameAssembler::new(std::time::Duration::from_secs(1));
let partial = fragment_frame(1, 10, &[1; 3000], 640, 480);
let _ = asm.push(partial[0].clone());
let _ = asm.push(partial[0].clone());
assert_eq!(asm.dropped_frames(), 0);
let next = fragment_frame(1, 13, &[2; 3000], 640, 480);
let _ = asm.push(next[0].clone());
assert_eq!(asm.dropped_frames(), 3, "partial 10 plus missing 11 and 12");
let _ = asm.push(next[1].clone());
assert!(asm.push(next[2].clone()).is_ok());
assert_eq!(asm.dropped_frames(), 3);
}
#[test]
fn late_packets_cannot_replace_newer_frames_or_publish_a_frame_twice() {
let mut asm = FrameAssembler::new(std::time::Duration::from_secs(1));
let old = fragment_frame(1, 1, &[1; 3000], 640, 480);
let new = fragment_frame(1, 2, &[2; 3000], 640, 480);
let _ = asm.push(old[0].clone());
let _ = asm.push(new[0].clone());
let _ = asm.push(old[1].clone());
let _ = asm.push(new[2].clone());
let completed = asm.push(new[1].clone()).unwrap();
assert_eq!(completed.frame_id, 2);
assert_eq!(completed.data, vec![2; 3000]);
for frag in new { assert!(asm.push(frag).is_err()); }
assert!(!asm.is_in_progress());
assert_eq!(asm.dropped_frames(), 1);
}
#[test]
fn frame_ids_wrap_and_restart_after_a_quiet_period() {
let mut asm = FrameAssembler::new(std::time::Duration::from_millis(100));
for id in [u32::MAX, 0, 1, 100] {
assert_eq!(asm.push(fragment_frame(1, id, &[1], 1, 1).remove(0)).unwrap().frame_id, id);
}
let before_restart = asm.dropped_frames();
asm.last_fragment_at = Some(std::time::Instant::now() - std::time::Duration::from_secs(2));
assert!(asm.push(fragment_frame(1, 1, &[1], 1, 1).remove(0)).is_ok());
assert_eq!(asm.dropped_frames(), before_restart, "a reboot is not a huge frame gap");
}
#[test]
fn an_expired_frame_is_counted_once_and_cannot_be_resurrected() {
let mut asm = FrameAssembler::new(std::time::Duration::from_millis(100));
let frags = fragment_frame(1, 1, &[1; 3000], 640, 480);
let _ = asm.push(frags[0].clone());
asm.since = Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
assert_eq!(asm.expire(), Some(DropReason::TimedOut));
assert_eq!(asm.expire(), None);
for frag in frags { assert!(asm.push(frag).is_err()); }
assert!(!asm.is_in_progress());
assert_eq!(asm.dropped_frames(), 1);
}
#[test]
fn contradictory_fragment_lengths_and_metadata_are_ignored() {
let mut asm = FrameAssembler::new(std::time::Duration::from_secs(1));
let frags = fragment_frame(1, 1, &[1; 3000], 640, 480);
let mut bad = frags[0].clone();
bad.header.frag_count += 1;
assert!(Fragment::decode(&bad.encode()).is_none());
let mut short = frags[0].clone();
short.payload.pop();
assert!(Fragment::decode(&short.encode()).is_none());
let _ = asm.push(frags[0].clone());
let mut changed = frags[1].clone();
changed.header.width = 320;
assert!(asm.push(changed).is_err());
let _ = asm.push(frags[1].clone());
assert_eq!(asm.push(frags[2].clone()).unwrap().width, 640);
assert_eq!(asm.dropped_frames(), 0);
}
}