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