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:
+140
-24
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user