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
+26
View File
@@ -55,6 +55,7 @@ dependencies = [
"rand",
"serde",
"serde_json",
"socket2",
"toml",
]
@@ -297,6 +298,16 @@ dependencies = [
"serde_core",
]
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -391,6 +402,21 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.15"
+1
View File
@@ -10,6 +10,7 @@ protocol = { path = "../../protocol" }
anyhow = "1"
log = "0.4"
socket2 = "0.6"
# 配置的序列化。这里与旧客户端(client/)的做法相反:那一版的配置只有十来个标量
# 字段,手写 TOML 换来零依赖是划算的。现在配置里有 HUD 的嵌套结构和密码哈希,手写
+114 -22
View File
@@ -11,7 +11,8 @@
//! 两个方向各司其职:
//!
//! - 板子每 2 秒广播一次 [`protocol::MsgType::Hello`],客户端被动收到就知道有谁在线。
//! - 客户端广播 [`protocol::MsgType::Subscribe`],板子收到后开始定向推流。
//! - 客户端广播 [`protocol::MsgType::Subscribe`],并向已发现的板子单播续订。
//! 多网卡 / VPN 可能把全局广播路由到虚拟网卡;单播按板子 IP 选择局域网接口。
//!
//! 板子靠**源地址**记住客户端,所以客户端只要周期性续订就能维持推流,不存在任何
//! 超时淘汰的竞态。
@@ -59,7 +60,7 @@ use protocol::{
/// 来自网络,而不是来自过小的缓冲。
const RECV_BUFFER: usize = 2048;
/// 重新广播订阅的间隔。
/// 广播及定向续订的间隔。
///
/// 比板子判定"客户端掉线"的超时(6 秒)短得多,所以偶发丢一两个包不会中断推流。
const SUBSCRIBE_INTERVAL: Duration = Duration::from_millis(800);
@@ -291,8 +292,7 @@ impl Discovery {
/// 客户端身份。
///
/// 常量而非 MAC 派生:队伍客户端和裁判终端各自唯一,进程内也不需要区分自己,而一个
/// 确定的常量让日志更好读。
/// 旧客户端使用的固定标识,保留用于兼容判断。
pub const CLIENT_ID: u32 = 0x0C11_0001;
/// 接收循环的可变状态:只由接收线程在跑循环时访问。
@@ -318,6 +318,7 @@ pub struct Receiver {
/// 身份可以运行时切换(同一个进程里从裁判端切到用户端),所以订阅里声明的角色
/// 也得能改——它决定板子要不要给这个客户端推流。
role: Mutex<SubscribeRole>,
client_id: u32,
camera: Ipv4Addr,
stop: Arc<AtomicBool>,
}
@@ -327,6 +328,10 @@ impl Receiver {
///
/// `only_sender` 为 `Some` 时只接受那一块板子的帧。
pub fn new(camera: Ipv4Addr, role: SubscribeRole, only_sender: Option<u32>) -> Self {
let client_id = loop {
let id = rand::random::<u32>();
if id != 0 && id != CLIENT_ID { break id; }
};
Self {
discovery: Arc::new(Discovery::new()),
latest: Arc::new(LatestFrames::default()),
@@ -337,6 +342,7 @@ impl Receiver {
discovery_all: false,
}),
role: Mutex::new(role),
client_id,
camera,
stop: Arc::new(AtomicBool::new(false)),
}
@@ -354,6 +360,15 @@ impl Receiver {
) -> std::io::Result<(Self, UdpSocket)> {
let socket =
UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?;
// Absorb short scheduler/NIC bursts without losing JPEG fragments. The
// reader still drains continuously; decoded video uses latest frames.
let socket_ref = socket2::SockRef::from(&socket);
if let Err(err) = socket_ref.set_recv_buffer_size(1024 * 1024) {
log::warn!("could not enlarge the video receive buffer: {err}");
}
if let Ok(bytes) = socket_ref.recv_buffer_size() {
log::info!("video UDP receive buffer: {bytes} bytes");
}
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
let broadcast = socket.set_broadcast(true).is_ok();
@@ -473,11 +488,17 @@ impl Receiver {
.entry(id)
.or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT));
match assembler.push(frag) {
Ok(frame) => {
drop(ingest);
let dropped_before = assembler.dropped_frames();
let result = assembler.push(frag);
let dropped = assembler.dropped_frames() - dropped_before;
drop(ingest);
self.discovery.count(id, |s| {
s.datagrams += 1;
s.frames_dropped += dropped;
});
self.discovery.count(id, |s| s.datagrams += 1);
match result {
Ok(frame) => {
self.discovery
.note_frame(id, frame.width, frame.height, frame.data.len());
@@ -489,17 +510,7 @@ impl Receiver {
sink(frame);
}
}
Err(protocol::DropReason::Superseded) => {
// 被更新的帧追上了。这不是丢帧:半截的那张只是被超越,在健康链路上
// 这是帧被放弃的**正常**方式。
self.discovery.count(id, |s| s.datagrams += 1);
}
Err(protocol::DropReason::TimedOut) => {
self.discovery.count(id, |s| {
s.datagrams += 1;
s.frames_dropped += 1;
});
}
Err(_) => {}
}
}
@@ -521,6 +532,25 @@ impl Receiver {
}
}
/// 全局广播用于发现;已知板子还需按 IP 续订,避免广播被 VPN / 虚拟网卡接走。
fn subscription_targets(&self) -> Vec<SocketAddrV4> {
let mut targets = vec![SocketAddrV4::new(self.camera, DEFAULT_PORT)];
let only_sender = self.sender_limit();
for camera in self.discovery.online_cameras() {
if only_sender.is_some_and(|id| camera.id != id) {
continue;
}
// Hello 的来源端口不一定是订阅监听端口;协议固定使用 DEFAULT_PORT。
let target = SocketAddrV4::new(*camera.addr.ip(), DEFAULT_PORT);
if !targets.contains(&target) {
targets.push(target);
}
}
targets
}
/// 运行接收循环,直到 [`Receiver::shutdown`]。
///
/// 阻塞,所以放在单独的线程上。
@@ -543,11 +573,14 @@ impl Receiver {
if last_subscribe.elapsed() >= SUBSCRIBE_INTERVAL {
let subscribe = Subscribe {
role: self.role(),
client_id: CLIENT_ID,
client_id: self.client_id,
};
if let Err(err) = socket.send_to(&subscribe.encode(), target) {
log::warn!("could not subscribe at {target}: {err}");
let encoded = subscribe.encode();
for target in self.subscription_targets() {
if let Err(err) = socket.send_to(&encoded, target) {
log::warn!("could not subscribe at {target}: {err}");
}
}
last_subscribe = Instant::now();
}
@@ -639,6 +672,48 @@ mod tests {
rx.datagram(&hello.encode(MsgType::Hello), from(from_ip));
}
#[test]
fn discovered_boards_are_subscribed_directly_when_broadcast_uses_another_interface() {
let rx = Receiver::new(Ipv4Addr::BROADCAST, SubscribeRole::Referee, None);
let hello = Hello { sender_id: 1, firmware: 7 };
// Hello 可从其它端口发来,订阅仍需发往板子的固定监听端口。
rx.datagram(&hello.encode(MsgType::Hello), "192.168.1.139:12345".parse().unwrap());
feed_hello(&rx, 2, [192, 168, 1, 140]);
assert_eq!(rx.subscription_targets(), vec![
"255.255.255.255:5600".parse().unwrap(),
"192.168.1.139:5600".parse().unwrap(),
"192.168.1.140:5600".parse().unwrap(),
]);
// DHCP 地址变化后只使用新地址。
feed_hello(&rx, 1, [192, 168, 1, 141]);
assert_eq!(rx.subscription_targets(), vec![
"255.255.255.255:5600".parse().unwrap(),
"192.168.1.141:5600".parse().unwrap(),
"192.168.1.140:5600".parse().unwrap(),
]);
}
#[test]
fn discovery_of_other_teams_does_not_directly_subscribe_to_their_video() {
let rx = Receiver::new(Ipv4Addr::BROADCAST, SubscribeRole::Team, Some(1));
rx.set_discovery_all(true);
feed_hello(&rx, 1, [192, 168, 1, 10]);
feed_hello(&rx, 2, [192, 168, 1, 20]);
assert_eq!(rx.discovery().cameras().len(), 2);
assert_eq!(rx.subscription_targets(), vec![
"255.255.255.255:5600".parse().unwrap(),
"192.168.1.10:5600".parse().unwrap(),
]);
rx.restrict_to(Some(2));
assert_eq!(rx.subscription_targets(), vec![
"255.255.255.255:5600".parse().unwrap(),
"192.168.1.20:5600".parse().unwrap(),
]);
}
#[test]
fn delivers_the_latest_frame_of_the_bound_sender_only() {
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Team, Some(1));
@@ -761,4 +836,21 @@ mod tests {
assert!(rx.discovery().cameras().is_empty());
}
#[test]
fn loss_statistics_include_superseded_frames_and_entirely_missing_ids() {
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Referee, None);
feed_hello(&rx, 1, [192, 168, 1, 10]);
feed_frame(&rx, 1, 1, [192, 168, 1, 10]);
let partial = fragment_frame(1, 2, &jpeg(), 640, 480);
rx.datagram(&partial[0].encode(), from([192, 168, 1, 10]));
assert_eq!(rx.discovery().stats(1).frames_dropped, 0, "normal pending fragments are not drops");
feed_frame(&rx, 1, 4, [192, 168, 1, 10]);
let stats = rx.discovery().stats(1);
assert_eq!(stats.frames_shown, 2);
assert_eq!(stats.frames_dropped, 2, "partial 2 and entirely missing 3");
assert_eq!(stats.loss_percent(), 50.0);
for frag in partial { rx.datagram(&frag.encode(), from([192, 168, 1, 10])); }
assert_eq!(rx.discovery().stats(1).frames_dropped, 2);
}
}