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
+287
View File
@@ -0,0 +1,287 @@
//! Frame pacing and a hysteretic, configurable thermal load limit.
//! Times are monotonic microseconds so the scheduling policy is host-testable.
pub struct Pacer {
period_us: u64,
next_due_us: u64,
}
impl Pacer {
pub fn new(fps: u32) -> Self {
Self::with_gap_us(period(fps))
}
pub fn with_gap_us(period_us: u64) -> Self {
Self {
period_us,
next_due_us: 0,
}
}
pub fn delay_us(&self, now_us: u64) -> u64 {
self.next_due_us.saturating_sub(now_us)
}
pub fn started(&mut self, now_us: u64) {
// An overrun starts a new period; never burst to repay missed slots.
self.next_due_us = now_us.saturating_add(self.period_us);
}
pub fn set_fps(&mut self, fps: u32, now_us: u64) {
self.period_us = period(fps);
self.started(now_us);
}
}
pub const MAX_SEND_HOLD_US: u64 = 25_000;
pub const MAX_CAPTURE_AGE_US: u64 = 100_000;
pub const DEFAULT_RETRY_BUDGET_US: u64 = 8_000;
/// One current frame, no pending-send FIFO. A short retry budget is shared by
/// all fragments/peers; success never renews the frame's absolute deadline.
pub struct FrameBudget {
deadline_us: u64,
retry_left_us: u64,
}
impl FrameBudget {
pub fn new(now_us: u64, captured_us: u64, retry_us: u64) -> Self {
Self {
deadline_us: now_us
.saturating_add(MAX_SEND_HOLD_US)
.min(captured_us.min(now_us).saturating_add(MAX_CAPTURE_AGE_US)),
retry_left_us: retry_us.min(MAX_SEND_HOLD_US),
}
}
pub fn expired(&self, now_us: u64) -> bool {
now_us >= self.deadline_us
}
pub fn allow_retry(&mut self, now_us: u64, wait_us: u64) -> bool {
if wait_us == 0
|| wait_us > self.retry_left_us
|| now_us.saturating_add(wait_us) >= self.deadline_us
{
return false;
}
self.retry_left_us -= wait_us;
true
}
}
fn period(fps: u32) -> u64 {
if fps == 0 {
0
} else {
1_000_000u64.div_ceil(fps as u64)
}
}
/// Adjust JPEG compression from five-second submission windows. The sensor's
/// quality number increases with compression; restore detail more slowly than
/// we shed load, and do not interpret absent subscribers as a healthy link.
pub struct QualityController {
base: u8,
ceiling: u8,
current: u8,
healthy_windows: u8,
}
impl QualityController {
pub fn new(base: u8, ceiling: u8) -> Self {
Self {
base,
ceiling,
current: base,
healthy_windows: 0,
}
}
pub fn observe(&mut self, peer_frames: u64, dropped: u32, send_ms: f32) -> Option<u8> {
if peer_frames < 5 || !send_ms.is_finite() {
self.healthy_windows = 0;
return None;
}
let previous = self.current;
if u64::from(dropped) * 100 >= peer_frames.saturating_mul(2) || send_ms >= 8.0 {
self.current = self.current.saturating_add(3).min(self.ceiling);
self.healthy_windows = 0;
} else if dropped == 0 && send_ms < 4.0 {
self.healthy_windows += 1;
if self.healthy_windows >= 3 {
self.current = self.current.saturating_sub(1).max(self.base);
self.healthy_windows = 0;
}
} else {
self.healthy_windows = 0;
}
(self.current != previous).then_some(self.current)
}
}
pub struct ThermalLimiter {
normal_fps: u32,
limit_c: f32,
limited: bool,
}
impl ThermalLimiter {
pub fn new(normal_fps: u32, limit_c: f32) -> Self {
Self {
normal_fps,
limit_c,
limited: false,
}
}
pub fn observe(&mut self, temperature: Option<f32>) -> u32 {
if self.limit_c > 0.0 {
if let Some(t) = temperature.filter(|t| t.is_finite()) {
if t >= self.limit_c {
self.limited = true;
} else if t <= self.limit_c - 5.0 {
self.limited = false;
}
}
}
self.fps()
}
pub fn fps(&self) -> u32 {
if !self.limited {
self.normal_fps
} else if self.normal_fps == 0 {
8
} else {
self.normal_fps.div_ceil(2)
}
}
pub fn limited(&self) -> bool {
self.limited
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pacing_caps_rate_without_catching_up_after_an_overrun() {
let mut pacer = Pacer::new(15);
assert_eq!(pacer.delay_us(100), 0);
pacer.started(100);
assert_eq!(pacer.delay_us(101), 66_666);
assert_eq!(pacer.delay_us(300_000), 0);
pacer.started(300_000);
assert_eq!(pacer.delay_us(300_001), 66_666);
}
#[test]
fn unlimited_and_runtime_rate_changes_do_not_burst() {
let mut pacer = Pacer::new(0);
pacer.started(1_000);
assert_eq!(pacer.delay_us(1_001), 0);
pacer.set_fps(10, 1_001);
assert_eq!(pacer.delay_us(1_002), 99_999);
}
#[test]
fn thermal_limit_has_five_degree_hysteresis() {
let mut limit = ThermalLimiter::new(15, 75.0);
assert_eq!(limit.observe(Some(74.9)), 15);
assert_eq!(limit.observe(Some(75.0)), 8);
assert_eq!(limit.observe(Some(72.0)), 8);
assert_eq!(limit.observe(None), 8);
assert_eq!(limit.observe(Some(f32::NAN)), 8);
assert_eq!(limit.observe(Some(70.0)), 15);
}
#[test]
fn thermal_guard_can_be_disabled_or_used_with_uncapped_streams() {
assert_eq!(ThermalLimiter::new(15, 0.0).observe(Some(90.0)), 15);
assert_eq!(ThermalLimiter::new(0, 75.0).observe(Some(80.0)), 8);
assert_eq!(ThermalLimiter::new(1, 75.0).observe(Some(80.0)), 1);
}
#[test]
fn packet_spacing_starts_from_actual_submission_without_catch_up() {
let mut pacer = Pacer::with_gap_us(1000);
pacer.started(100);
assert_eq!(pacer.delay_us(900), 200);
assert_eq!(pacer.delay_us(5000), 0);
pacer.started(5000);
assert_eq!(pacer.delay_us(5001), 999);
}
#[test]
fn frame_deadline_is_not_renewed_by_successful_fragments_or_other_peers() {
let frame = FrameBudget::new(50_000, 20_000, 8_000);
assert!(!frame.expired(74_999));
assert!(frame.expired(75_000));
}
#[test]
fn an_old_capture_cannot_gain_a_fresh_send_lifetime() {
let frame = FrameBudget::new(99_000, 0, 8_000);
assert!(!frame.expired(99_999));
assert!(frame.expired(100_000));
assert!(FrameBudget::new(100_001, 0, 8_000).expired(100_001));
}
#[test]
fn retry_allowance_is_shared_by_all_fragments_and_stops_before_deadline() {
let mut frame = FrameBudget::new(50_000, 20_000, 2_000);
assert!(frame.allow_retry(51_000, 1000));
assert!(frame.allow_retry(52_000, 1000));
assert!(!frame.allow_retry(53_000, 1000));
let mut late = FrameBudget::new(50_000, 20_000, 8_000);
assert!(!late.allow_retry(74_000, 1000));
}
#[test]
fn zero_retry_mode_still_allows_immediate_sends_and_abandons_backpressure() {
let mut frame = FrameBudget::new(50_000, 20_000, 0);
assert!(!frame.expired(50_001));
assert!(!frame.allow_retry(50_001, 1000));
}
#[test]
fn compression_sheds_congestion_load_quickly_and_stays_in_bounds() {
let mut control = QualityController::new(18, 24);
assert_eq!(control.observe(100, 2, 3.0), Some(21));
assert_eq!(control.observe(100, 0, 8.0), Some(24));
assert_eq!(control.observe(100, 20, 20.0), None);
let mut disabled = QualityController::new(18, 18);
assert_eq!(disabled.observe(100, 50, 20.0), None);
}
#[test]
fn compression_recovers_only_after_three_consecutive_healthy_windows() {
let mut control = QualityController::new(18, 24);
control.observe(100, 10, 10.0);
assert_eq!(control.observe(100, 0, 2.0), None);
assert_eq!(control.observe(100, 0, 2.0), None);
assert_eq!(control.observe(100, 1, 5.0), None);
assert_eq!(control.observe(100, 0, 2.0), None);
assert_eq!(control.observe(100, 0, 2.0), None);
assert_eq!(control.observe(100, 0, 2.0), Some(20));
for _ in 0..20 {
control.observe(100, 0, 2.0);
}
assert_eq!(control.current, 18);
}
#[test]
fn missing_traffic_or_invalid_timing_does_not_restore_quality() {
let mut control = QualityController::new(18, 24);
control.observe(100, 10, 10.0);
control.observe(100, 0, 2.0);
control.observe(100, 0, 2.0);
assert_eq!(control.observe(0, 0, 0.0), None);
assert_eq!(control.observe(100, 0, f32::NAN), None);
assert_eq!(control.observe(100, 0, 2.0), None);
assert_eq!(control.current, 21);
}
}