ESP32-S3 摄像头图传:固件 + Slint 客户端 + 共享协议
两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV2640/OV3660/OV5640), 向队伍客户端与裁判终端推 UDP 图传。固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;客户端是 Rust + Slint 桌面程序。 三个 crate 各自独立构建: . firmware,交叉编译到 Xtensa protocol/ 线路格式与帧重组,两端共用,无 ESP-IDF 依赖,有单元测试 client/ Slint 桌面客户端 视频走 UDP 而非 TCP:实测传感器产出 22 fps,TCP MJPEG 只送出 1.9 fps, 且只占用链路能力的 1%——链路是空的,帧堵在队头阻塞后面等一个实时画面 等不起的重传计时器。改为独立数据报、不重传、缺片丢整帧后达到 20.8 fps。 发现是双向的:板子每 2 秒广播 Hello,客户端广播 Subscribe,板子据此定向 推流。板子身份由 MAC 派生,重启不变,所以客户端可以把绑定存进配置。 裁判端同时收两块板子(每个发送方一个重组器),队伍端绑定其中一块并只显示 自己那一边的血量。装甲板接口已预留(ArmorSource trait + ARMOR_PORT), 当前无数据源,界面显示占位符。 vendor 了 esp32-camera 2.1.7:组件注册表的版本求解器找不到它,且需要打一处 补丁(见 components/esp32-camera/target/esp32s3/ll_cam.c)。 .gitignore 三条 target/ 都锚定在根目录,因为 protocol/ 与 client/ 各自也是 独立 crate;而 components/esp32-camera/target/ 是上游的按芯片分目录,里面是 源码,必须保留。
This commit is contained in:
@@ -0,0 +1,870 @@
|
||||
//! 线路格式:图传视频 + 设备发现 + 装甲板预留接口。
|
||||
//!
|
||||
//! 三端共用这一个 crate:两块 ESP32 图传板、队伍客户端、裁判终端。
|
||||
//!
|
||||
//! # 拓扑
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌──────────────────────────┐
|
||||
//! │ 裁判终端(PC,仅显示) │
|
||||
//! │ 队伍一 + 队伍二 + 血量 │
|
||||
//! └───────────┬──────────────┘
|
||||
//! │ 同一个局域网,广播可用
|
||||
//! ┌──────────────────┼──────────────────┐
|
||||
//! │ │ │
|
||||
//! ┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
|
||||
//! │ 图传板 A │ │ 图传板 B │ │ 装甲板 ×4 │
|
||||
//! │ (队伍一) │ │ (队伍二) │ │ (预留,未实现) │
|
||||
//! └───────────────┘ └───────────────┘ └───────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! # 为什么视频走 UDP 而不是 TCP
|
||||
//!
|
||||
//! 实测:传感器产出 22 fps,TCP MJPEG 只送出 1.9 fps,且只占用了链路能力的 1%。
|
||||
//! 链路是空的,帧却堵在 TCP 的队头阻塞后面等一个实时画面等不起的重传计时器。
|
||||
//! UDP 没有重传与确认,缺了分片的帧整个丢弃、下一帧顶上。
|
||||
//!
|
||||
//! # 分片
|
||||
//!
|
||||
//! 每个视频数据报 = [`HEADER_LEN`] 字节头 + 至多 [`MAX_FRAGMENT_PAYLOAD`]
|
||||
//! 字节 JPEG 数据。头里的 `sender_id` 让接收端能在同一端口上区分多块板子,
|
||||
//! 这是裁判终端能同时看两路的前提。
|
||||
//!
|
||||
//! ```text
|
||||
//! 0 1 2 3
|
||||
//! 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||||
//! +-------------------------------+-------+-----------------------+
|
||||
//! | magic (u32) | type | version (u16) |
|
||||
//! +-------------------------------+-------+-----------------------+
|
||||
//! | sender_id (u32) | frame_id (u32) |
|
||||
//! +-------------------------------+-------+-----------------------+
|
||||
//! | frag_index (u16) | frag_count (u16) |
|
||||
//! +-------------------------------+-------+-----------------------+
|
||||
//! | total_len (u32) | width (u16) | height (u16) |
|
||||
//! +-------------------------------+---------------+---------------+
|
||||
//! | payload (0..=1400) |
|
||||
//! +---------------------------------------------------------------+
|
||||
//! ```
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddrV4};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 端口
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 视频与设备发现共用端口。
|
||||
///
|
||||
/// 发现和控制消息体积小、频率低,与视频混在一个端口上可以让板子只开一个 socket,
|
||||
/// 简化固件侧。视频与控制的数据报头不同(`msg_type`),不会混淆。
|
||||
pub const DEFAULT_PORT: u16 = 5600;
|
||||
|
||||
/// 装甲板预留端口。
|
||||
///
|
||||
/// 现在没有实现,裁判终端只是监听它并把血量显示为占位符。板子接上以后往这个端口
|
||||
/// 发消息即可,不需要改动线路格式或界面结构。
|
||||
pub const ARMOR_PORT: u16 = 5601;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 帧头
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 线路格式版本。
|
||||
///
|
||||
/// 字段增删时递增。不匹配的对端会拒绝处理,而不是按错误布局解析出垃圾数据——
|
||||
/// 那会表现为随机崩溃或画面错乱,比拒绝连接难查得多。
|
||||
pub const PROTOCOL_VERSION: u16 = 1;
|
||||
|
||||
/// 标识本协议。
|
||||
///
|
||||
/// 取值使得它不可能出现在 JPEG 数据开头,所以一个同端口上的无关 UDP 包会被挡掉。
|
||||
pub const MAGIC: u32 = 0x4D56_4331; // "MVC1"
|
||||
|
||||
/// 帧头长度(字节)。
|
||||
///
|
||||
/// `magic`(4) + `msg_type`(1) + `version`(2) + `sender_id`(4) + `frame_id`(4) +
|
||||
/// `frag_index`(2) + `frag_count`(2) + `total_len`(4) + `width`(2) + `height`(2)
|
||||
/// = **27**。
|
||||
///
|
||||
/// 27 不如 4 字节对齐,但这里刻意不去凑:头只占每个 1427 字节数据报的 1.9%,而
|
||||
/// 为对齐在固件和客户端两侧各写一段手写的偏移常量,是引入静默错位的更划算的代价。
|
||||
pub const HEADER_LEN: usize = 27;
|
||||
|
||||
/// 单个数据报的最大 JPEG 载荷。
|
||||
///
|
||||
/// 1400 + 27 = 1427 字节,低于典型以太网的 1500 MTU。这一点比看起来重要:超 MTU
|
||||
/// 的数据报要么被 AP 丢弃,要么被 IP 分片,两种情况的丢包都记在应用账上而不是网络账
|
||||
/// 上——后者本来更容易排查。
|
||||
pub const MAX_FRAGMENT_PAYLOAD: usize = 1400;
|
||||
|
||||
/// 数据报类型。
|
||||
///
|
||||
/// 决定同一端口上的字节如何解释。把控制和视频放在一个端口的前提就是这个字段
|
||||
/// 一定在最前面,且值互不相同。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum MsgType {
|
||||
/// 一帧 JPEG 的第 N 个分片。
|
||||
Video = 1,
|
||||
/// 客户端广播:"我是这个角色,请给我画面。"
|
||||
Subscribe = 2,
|
||||
/// 板子回复:"我在线,我是这个 id。"
|
||||
Hello = 3,
|
||||
/// 心跳,用于发现与保活。
|
||||
Heartbeat = 4,
|
||||
/// 装甲板上报血量(预留,当前无实现)。
|
||||
ArmorHealth = 5,
|
||||
}
|
||||
|
||||
impl MsgType {
|
||||
/// 从线上的字节还原。
|
||||
pub fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
1 => MsgType::Video,
|
||||
2 => MsgType::Subscribe,
|
||||
3 => MsgType::Hello,
|
||||
4 => MsgType::Heartbeat,
|
||||
5 => MsgType::ArmorHealth,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端在订阅里声明的角色。
|
||||
///
|
||||
/// 板子据此决定要不要给这个客户端推流。裁判终端用
|
||||
/// [`SubscribeRole::Referee`](SubscribeRole::Referee) 表示"我要两路都看",但它仍然
|
||||
/// 是靠收到两块板子的 Hello 后分别订阅来完成——角色只是省掉了手工筛选。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum SubscribeRole {
|
||||
/// 队伍客户端,只要一台板子的画面。
|
||||
Team = 1,
|
||||
/// 裁判终端,要全部板子的画面。
|
||||
Referee = 2,
|
||||
}
|
||||
|
||||
impl SubscribeRole {
|
||||
pub fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
1 => Some(SubscribeRole::Team),
|
||||
2 => Some(SubscribeRole::Referee),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据报头,20 字节,小端。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(C)]
|
||||
pub struct Header {
|
||||
pub msg_type: MsgType,
|
||||
pub version: u16,
|
||||
/// 发送方标识。
|
||||
///
|
||||
/// 板子用 MAC 派生(见 [`id_from_mac`]),这样重启后 id 不变,客户端保存的绑定
|
||||
/// 不会失效。裁判终端用固定的 [`REFEREE_ID`]。
|
||||
pub sender_id: u32,
|
||||
/// 帧序号,按发送方独立计数,回绕无害。
|
||||
pub frame_id: u32,
|
||||
pub frag_index: u16,
|
||||
pub frag_count: u16,
|
||||
/// 整张 JPEG 的长度。
|
||||
pub total_len: u32,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
impl Default for Header {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
msg_type: MsgType::Video,
|
||||
version: PROTOCOL_VERSION,
|
||||
sender_id: 0,
|
||||
frame_id: 0,
|
||||
frag_index: 0,
|
||||
frag_count: 0,
|
||||
total_len: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析帧头并校验魔数与版本。
|
||||
///
|
||||
/// 任何不满足的输入都返回 `None`,调用方据此当作"不是给我的"跳过,而不是当作
|
||||
/// 错误——同一端口上可能出现板子的存活探测包,也可能日后出现其他协议。
|
||||
pub fn decode_header(bytes: &[u8]) -> Option<Header> {
|
||||
if bytes.len() < HEADER_LEN {
|
||||
return None;
|
||||
}
|
||||
|
||||
let magic = u32::from_le_bytes(bytes[0..4].try_into().ok()?);
|
||||
if magic != MAGIC {
|
||||
return None;
|
||||
}
|
||||
|
||||
let msg_type = MsgType::from_u8(bytes[4])?;
|
||||
|
||||
let version = u16::from_le_bytes(bytes[5..7].try_into().ok()?);
|
||||
if version != PROTOCOL_VERSION {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Header {
|
||||
msg_type,
|
||||
version,
|
||||
sender_id: u32::from_le_bytes(bytes[7..11].try_into().ok()?),
|
||||
frame_id: u32::from_le_bytes(bytes[11..15].try_into().ok()?),
|
||||
frag_index: u16::from_le_bytes(bytes[15..17].try_into().ok()?),
|
||||
frag_count: u16::from_le_bytes(bytes[17..19].try_into().ok()?),
|
||||
total_len: u32::from_le_bytes(bytes[19..23].try_into().ok()?),
|
||||
width: u16::from_le_bytes(bytes[23..25].try_into().ok()?),
|
||||
height: u16::from_le_bytes(bytes[25..27].try_into().ok()?),
|
||||
})
|
||||
}
|
||||
|
||||
/// 把帧头写进 `out`(追加,不清空)。
|
||||
pub fn encode_header_into(header: &Header, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&MAGIC.to_le_bytes());
|
||||
out.push(header.msg_type as u8);
|
||||
out.extend_from_slice(&header.version.to_le_bytes());
|
||||
out.extend_from_slice(&header.sender_id.to_le_bytes());
|
||||
out.extend_from_slice(&header.frame_id.to_le_bytes());
|
||||
out.extend_from_slice(&header.frag_index.to_le_bytes());
|
||||
out.extend_from_slice(&header.frag_count.to_le_bytes());
|
||||
out.extend_from_slice(&header.total_len.to_le_bytes());
|
||||
out.extend_from_slice(&header.width.to_le_bytes());
|
||||
out.extend_from_slice(&header.height.to_le_bytes());
|
||||
|
||||
debug_assert_eq!(out.len(), HEADER_LEN);
|
||||
}
|
||||
|
||||
/// 裁判终端的固定标识。
|
||||
///
|
||||
/// 固定值而非 MAC 派生:裁判终端不是网络意义上的"设备集合"的一员,它只有一个,
|
||||
/// 用常量比让它参与 MAC 派生更简单也更清楚。
|
||||
pub const REFEREE_ID: u32 = 0;
|
||||
|
||||
/// 从 MAC 地址派生发送方标识。
|
||||
///
|
||||
/// 用固定长度输入的 FNV-1a:ESP32 没有随机数源可依赖,而 id 必须在重启后保持不变,
|
||||
/// 否则客户端保存的绑定每次重启都会失效。
|
||||
///
|
||||
/// 返回 0 的输入(组播地址)被避开,因为 0 留给裁判终端。
|
||||
pub fn id_from_mac(mac: &[u8; 6]) -> u32 {
|
||||
const FNV_OFFSET: u32 = 0x811c9dc5;
|
||||
const FNV_PRIME: u32 = 0x01000193;
|
||||
|
||||
let mut hash = FNV_OFFSET;
|
||||
|
||||
for byte in mac {
|
||||
hash ^= *byte as u32;
|
||||
hash = hash.wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
|
||||
// 避开 0,它是裁判终端的保留值。
|
||||
if hash == 0 {
|
||||
1
|
||||
} else {
|
||||
hash
|
||||
}
|
||||
}
|
||||
|
||||
/// 视频分片:一个数据报。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Fragment {
|
||||
pub header: Header,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Fragment {
|
||||
/// 追加写入 `out`,不分配。
|
||||
///
|
||||
/// 发送端对每帧的每个分片复用同一个缓冲:在 22fps、每帧 8–10 个分片下,这是
|
||||
/// 每秒约 250 次分配与不分配的区别——在这块同时要抢内部 RAM 的芯片上很实在。
|
||||
pub fn encode_into(&self, out: &mut Vec<u8>) {
|
||||
encode_header_into(&self.header, out);
|
||||
out.extend_from_slice(&self.payload);
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(HEADER_LEN + self.payload.len());
|
||||
self.encode_into(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// 解析一个数据报。
|
||||
///
|
||||
/// 头之后的所有字节都算载荷。校验不一致的分片在这里就被挡住,因此重组器不必
|
||||
/// 再操心。
|
||||
pub fn decode(bytes: &[u8]) -> Option<Self> {
|
||||
let header = decode_header(bytes)?;
|
||||
|
||||
if header.msg_type != MsgType::Video {
|
||||
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 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Fragment {
|
||||
header,
|
||||
payload: payload.to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一张完整重组好的 JPEG。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Frame {
|
||||
/// 来源板子的标识。
|
||||
pub sender_id: u32,
|
||||
pub frame_id: u32,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 把一张 JPEG 切成数据报。
|
||||
///
|
||||
/// 分片数由载荷大小推出,因此调用方不可能产出与自身矛盾的集合。
|
||||
pub fn fragment_frame(
|
||||
sender_id: u32,
|
||||
frame_id: u32,
|
||||
jpeg: &[u8],
|
||||
width: u16,
|
||||
height: u16,
|
||||
) -> Vec<Fragment> {
|
||||
let count = jpeg.len().div_ceil(MAX_FRAGMENT_PAYLOAD).max(1) as u16;
|
||||
let total_len = jpeg.len() as u32;
|
||||
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let start = i as usize * MAX_FRAGMENT_PAYLOAD;
|
||||
let end = (start + MAX_FRAGMENT_PAYLOAD).min(jpeg.len());
|
||||
|
||||
Fragment {
|
||||
header: Header {
|
||||
msg_type: MsgType::Video,
|
||||
version: PROTOCOL_VERSION,
|
||||
sender_id,
|
||||
frame_id,
|
||||
frag_index: i,
|
||||
frag_count: count,
|
||||
total_len,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
payload: jpeg[start..end].to_vec(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 客户端的订阅请求。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Subscribe {
|
||||
pub role: SubscribeRole,
|
||||
pub client_id: u32,
|
||||
}
|
||||
|
||||
impl Subscribe {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let header = Header {
|
||||
msg_type: MsgType::Subscribe,
|
||||
version: PROTOCOL_VERSION,
|
||||
sender_id: self.client_id,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut out = Vec::with_capacity(HEADER_LEN + 1);
|
||||
encode_header_into(&header, &mut out);
|
||||
out.push(self.role as u8);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn decode(bytes: &[u8]) -> Option<Self> {
|
||||
let header = decode_header(bytes)?;
|
||||
|
||||
if header.msg_type != MsgType::Subscribe || bytes.len() < HEADER_LEN + 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Subscribe {
|
||||
role: SubscribeRole::from_u8(bytes[HEADER_LEN])?,
|
||||
client_id: header.sender_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 板子的在线通告与心跳。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Hello {
|
||||
pub sender_id: u32,
|
||||
/// 固件版本,便于现场排查烧录了哪一版。
|
||||
pub firmware: u16,
|
||||
}
|
||||
|
||||
impl Hello {
|
||||
pub fn encode(&self, msg_type: MsgType) -> Vec<u8> {
|
||||
let header = Header {
|
||||
msg_type,
|
||||
version: PROTOCOL_VERSION,
|
||||
sender_id: self.sender_id,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut out = Vec::with_capacity(HEADER_LEN + 2);
|
||||
encode_header_into(&header, &mut out);
|
||||
out.extend_from_slice(&self.firmware.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
pub fn decode(bytes: &[u8]) -> Option<Self> {
|
||||
let header = decode_header(bytes)?;
|
||||
|
||||
if !matches!(header.msg_type, MsgType::Hello | MsgType::Heartbeat) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 固件版本是可选的:早期或精简的通告可能不带这个字段。
|
||||
let firmware = if bytes.len() >= HEADER_LEN + 2 {
|
||||
u16::from_le_bytes(bytes[HEADER_LEN..HEADER_LEN + 2].try_into().ok()?)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Some(Hello {
|
||||
sender_id: header.sender_id,
|
||||
firmware,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 装甲板(预留)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 一块装甲板的上报。
|
||||
///
|
||||
/// 现在没有实现也没有数据源,但格式在这里定下来,并且 [`MsgType::ArmorHealth`]
|
||||
/// 与 [`ARMOR_PORT`] 已经就位:板子接上以后向 `ARMOR_PORT` 发这个结构,裁判终端
|
||||
/// 监听即可,界面与其它代码都不用改。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ArmorHealth {
|
||||
/// 哪一边:0 = 队伍一,1 = 队伍二。
|
||||
pub team: u8,
|
||||
/// 机器人在该队伍的装甲板序号,0..3。
|
||||
pub index: u8,
|
||||
/// 当前血量。
|
||||
pub hp: u16,
|
||||
}
|
||||
|
||||
impl ArmorHealth {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let header = Header {
|
||||
msg_type: MsgType::ArmorHealth,
|
||||
version: PROTOCOL_VERSION,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut out = Vec::with_capacity(HEADER_LEN + 4);
|
||||
encode_header_into(&header, &mut out);
|
||||
out.push(self.team);
|
||||
out.push(self.index);
|
||||
out.extend_from_slice(&self.hp.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
pub fn decode(bytes: &[u8]) -> Option<Self> {
|
||||
let header = decode_header(bytes)?;
|
||||
|
||||
if header.msg_type != MsgType::ArmorHealth || bytes.len() < HEADER_LEN + 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ArmorHealth {
|
||||
team: bytes[HEADER_LEN],
|
||||
index: bytes[HEADER_LEN + 1],
|
||||
hp: u16::from_le_bytes(bytes[HEADER_LEN + 2..HEADER_LEN + 4].try_into().ok()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 视频端口地址。
|
||||
pub fn video_endpoint(ip: Ipv4Addr) -> SocketAddrV4 {
|
||||
SocketAddrV4::new(ip, DEFAULT_PORT)
|
||||
}
|
||||
|
||||
/// 255.255.255.255,用于广播发现。
|
||||
pub const BROADCAST_ADDR: Ipv4Addr = Ipv4Addr::new(255, 255, 255, 255);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 重组
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 帧被丢弃的原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DropReason {
|
||||
/// 新的帧开始了,而这一张还没收齐。
|
||||
Superseded,
|
||||
/// 发送方沉默过久。
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// 从乱序、有丢失的 UDP 数据报里重组帧。
|
||||
///
|
||||
/// 策略就是全部要点:**一张帧只有所有分片都到齐才交付,且最新的帧永远优先。**
|
||||
///
|
||||
/// UDP 不保证跨数据报的顺序与到达,所以试图"补洞"的接收端做的功比"等下一张完整
|
||||
/// 帧"更多——而且补的过程中会显示撕裂的画面。
|
||||
///
|
||||
/// 超时堵住另一种失败:发送方在帧中途死掉时,半截缓冲会永远留在这里。
|
||||
pub struct FrameAssembler {
|
||||
sender_id: u32,
|
||||
current_id: Option<u32>,
|
||||
expected: usize,
|
||||
buffer: Vec<u8>,
|
||||
width: u16,
|
||||
height: u16,
|
||||
since: Option<std::time::Instant>,
|
||||
timeout: std::time::Duration,
|
||||
/// 已收到的分片数,含重复。用来统计网络丢了多少。
|
||||
seen: usize,
|
||||
}
|
||||
|
||||
impl FrameAssembler {
|
||||
/// 新建一个重组器,`timeout` 后放弃未收齐的帧。
|
||||
pub fn new(timeout: std::time::Duration) -> Self {
|
||||
Self {
|
||||
sender_id: 0,
|
||||
current_id: None,
|
||||
expected: 0,
|
||||
buffer: Vec::new(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
since: None,
|
||||
timeout,
|
||||
seen: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 这个重组器正在处理哪个发送方。
|
||||
pub fn sender_id(&self) -> u32 {
|
||||
self.sender_id
|
||||
}
|
||||
|
||||
/// 收到一个分片。
|
||||
///
|
||||
/// 这张分片补齐一张帧时返回 [`Frame`]。
|
||||
///
|
||||
/// `Err(Superseded)` 表示这个分片属于一张**已被放弃**的帧。多路复用时(裁判
|
||||
/// 终端同时收两块板子)必须每个发送方一个重组器——混在一起会让两块板子的分片
|
||||
/// 互相顶掉。这里不做检查,是调用方的责任。
|
||||
pub fn push(&mut self, frag: Fragment) -> Result<Frame, DropReason> {
|
||||
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.sender_id = sender_id;
|
||||
|
||||
let superseded = match self.current_id {
|
||||
None => false,
|
||||
Some(id) => id != frame_id,
|
||||
};
|
||||
|
||||
if superseded {
|
||||
self.reset();
|
||||
}
|
||||
|
||||
match self.current_id {
|
||||
None => {
|
||||
self.current_id = Some(frame_id);
|
||||
self.expected = frag.header.frag_count as usize;
|
||||
self.buffer.clear();
|
||||
// 按 total_len 预留,最后拼接时不再重新分配。
|
||||
self.buffer.reserve(frag.header.total_len as usize);
|
||||
self.width = frag.header.width;
|
||||
self.height = frag.header.height;
|
||||
self.since = Some(std::time::Instant::now());
|
||||
self.seen = 0;
|
||||
}
|
||||
Some(id) if id == frame_id => {}
|
||||
_ => unreachable!("current_id was just reconciled with frame_id"),
|
||||
}
|
||||
|
||||
let start = frag.header.frag_index as usize * MAX_FRAGMENT_PAYLOAD;
|
||||
let end = start + frag.payload.len();
|
||||
|
||||
if end > self.buffer.len() {
|
||||
self.buffer.resize(end, 0);
|
||||
}
|
||||
self.buffer[start..end].copy_from_slice(&frag.payload);
|
||||
self.seen += 1;
|
||||
|
||||
if self.seen >= self.expected {
|
||||
let total = frag.header.total_len as usize;
|
||||
self.buffer.truncate(total.min(self.buffer.len()));
|
||||
|
||||
let frame = Frame {
|
||||
sender_id,
|
||||
frame_id,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
data: std::mem::take(&mut self.buffer),
|
||||
};
|
||||
|
||||
self.reset();
|
||||
return Ok(frame);
|
||||
}
|
||||
|
||||
Err(DropReason::Superseded)
|
||||
}
|
||||
|
||||
/// 若未收齐的帧等待过久则丢弃它。
|
||||
///
|
||||
/// 需要由定时器调用。否则发送方在首末分片之间死掉时,重组器会一直留着那张帧。
|
||||
pub fn expire(&mut self) -> Option<DropReason> {
|
||||
match self.since {
|
||||
Some(started) if started.elapsed() > self.timeout => {
|
||||
self.reset();
|
||||
Some(DropReason::TimedOut)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否有一张帧正在收。
|
||||
pub fn is_in_progress(&self) -> bool {
|
||||
self.current_id.is_some()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.current_id = None;
|
||||
self.expected = 0;
|
||||
self.buffer = Vec::new();
|
||||
self.buffer.shrink_to_fit();
|
||||
self.since = None;
|
||||
self.seen = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn header_roundtrips() {
|
||||
let header = Header {
|
||||
msg_type: MsgType::Video,
|
||||
version: PROTOCOL_VERSION,
|
||||
sender_id: 0xDEAD_BEEF,
|
||||
frame_id: 12345,
|
||||
frag_index: 3,
|
||||
frag_count: 9,
|
||||
total_len: 20_000,
|
||||
width: 640,
|
||||
height: 480,
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
encode_header_into(&header, &mut buf);
|
||||
|
||||
assert_eq!(buf.len(), HEADER_LEN);
|
||||
assert_eq!(decode_header(&buf), Some(header));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_roundtrips_and_is_multiple_sources() {
|
||||
let jpeg: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
|
||||
let frags = fragment_frame(0xAAAA_BBBB, 7, &jpeg, 640, 480);
|
||||
|
||||
assert_eq!(frags.len(), 4);
|
||||
|
||||
for f in &frags {
|
||||
assert_eq!(f.header.sender_id, 0xAAAA_BBBB);
|
||||
let decoded = Fragment::decode(&f.encode()).expect("decodes");
|
||||
assert_eq!(&decoded, f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassembles_and_separates_senders() {
|
||||
let jpeg: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
|
||||
|
||||
// 模拟裁判终端:两个发送方各自的重组器。
|
||||
let mut a = FrameAssembler::new(std::time::Duration::from_secs(2));
|
||||
let mut b = FrameAssembler::new(std::time::Duration::from_secs(2));
|
||||
|
||||
let mut frame_a = None;
|
||||
let mut frame_b = None;
|
||||
|
||||
for f in fragment_frame(1, 1, &jpeg, 640, 480) {
|
||||
if let Ok(frame) = a.push(f) {
|
||||
frame_a = Some(frame);
|
||||
}
|
||||
}
|
||||
for f in fragment_frame(2, 1, &jpeg, 320, 240) {
|
||||
if let Ok(frame) = b.push(f) {
|
||||
frame_b = Some(frame);
|
||||
}
|
||||
}
|
||||
|
||||
let fa = frame_a.expect("sender 1 completed");
|
||||
let fb = frame_b.expect("sender 2 completed");
|
||||
|
||||
assert_eq!(fa.sender_id, 1);
|
||||
assert_eq!(fb.sender_id, 2);
|
||||
assert_eq!(fa.data, jpeg);
|
||||
assert_eq!(fb.data, jpeg);
|
||||
assert_eq!((fa.width, fa.height), (640, 480));
|
||||
assert_eq!((fb.width, fb.height), (320, 240));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_means_no_frame_until_the_next_one() {
|
||||
let jpeg = vec![0xAAu8; 5000];
|
||||
let frags = fragment_frame(1, 1, &jpeg, 640, 480);
|
||||
let mut asm = FrameAssembler::new(std::time::Duration::from_secs(2));
|
||||
|
||||
// 丢掉第 2 个分片。
|
||||
for f in frags.iter().filter(|f| f.header.frag_index != 2) {
|
||||
assert!(asm.push(f.clone()).is_err());
|
||||
}
|
||||
assert!(asm.is_in_progress(), "still waiting on the missing fragment");
|
||||
|
||||
// 新的帧取代没收齐的那张,而不是去补洞。
|
||||
let next = fragment_frame(1, 2, &jpeg, 640, 480);
|
||||
assert!(asm.push(next[0].clone()).is_err());
|
||||
|
||||
let mut got = None;
|
||||
for f in next.iter().skip(1) {
|
||||
if let Ok(frame) = asm.push(f.clone()) {
|
||||
got = Some(frame);
|
||||
}
|
||||
}
|
||||
assert_eq!(got.expect("second frame completes").frame_id, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expires_a_stalled_frame() {
|
||||
let jpeg = vec![0x11u8; 3000];
|
||||
let frags = fragment_frame(9, 1, &jpeg, 640, 480);
|
||||
let mut asm = FrameAssembler::new(std::time::Duration::from_millis(1));
|
||||
|
||||
let _ = asm.push(frags[0].clone());
|
||||
assert!(asm.is_in_progress());
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
|
||||
assert_eq!(asm.expire(), Some(DropReason::TimedOut));
|
||||
assert!(!asm.is_in_progress());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_foreign_and_malformed_datagrams() {
|
||||
assert!(decode_header(b"").is_none());
|
||||
assert!(decode_header(&[0u8; 8]).is_none());
|
||||
// 长度够但魔数不对。
|
||||
assert!(decode_header(&[0u8; HEADER_LEN]).is_none());
|
||||
|
||||
// 正确的魔数但版本不对:宁可拒绝,也不能按错误布局解析。
|
||||
let header = Header {
|
||||
sender_id: 1,
|
||||
frag_count: 1,
|
||||
total_len: 10,
|
||||
..Default::default()
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
encode_header_into(&header, &mut buf);
|
||||
buf[5] = 99; // version 低字节
|
||||
assert!(decode_header(&buf).is_none(), "wrong version must be refused");
|
||||
|
||||
let jpeg = vec![0u8; 100];
|
||||
|
||||
// frag_index >= frag_count 自相矛盾。
|
||||
let mut bad = fragment_frame(1, 1, &jpeg, 64, 48).remove(0).encode();
|
||||
bad[15..17].copy_from_slice(&9u16.to_le_bytes());
|
||||
assert!(Fragment::decode(&bad).is_none());
|
||||
|
||||
// 载荷比声明的总长还大:损坏。
|
||||
let mut over = fragment_frame(1, 1, &jpeg, 64, 48).remove(0).encode();
|
||||
over[19..23].copy_from_slice(&4u32.to_le_bytes());
|
||||
assert!(Fragment::decode(&over).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribe_and_hello_roundtrip() {
|
||||
let sub = Subscribe {
|
||||
role: SubscribeRole::Referee,
|
||||
client_id: 0x1234_5678,
|
||||
};
|
||||
assert_eq!(Subscribe::decode(&sub.encode()), Some(sub));
|
||||
|
||||
let hello = Hello {
|
||||
sender_id: 0x8765_4321,
|
||||
firmware: 1,
|
||||
};
|
||||
assert_eq!(Hello::decode(&hello.encode(MsgType::Hello)), Some(hello));
|
||||
assert_eq!(
|
||||
Hello::decode(&hello.encode(MsgType::Heartbeat)),
|
||||
Some(hello)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_health_roundtrips() {
|
||||
// 现在没有实现,但格式必须先定下来并且可往返,否则接上板子时才发现布局
|
||||
// 不兼容就晚了。
|
||||
let hp = ArmorHealth {
|
||||
team: 1,
|
||||
index: 2,
|
||||
hp: 420,
|
||||
};
|
||||
|
||||
let encoded = hp.encode();
|
||||
assert_eq!(ArmorHealth::decode(&encoded), Some(hp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_stable_and_never_zero() {
|
||||
let mac = [0x90, 0x70, 0x69, 0xF8, 0xF2, 0x14];
|
||||
let a = id_from_mac(&mac);
|
||||
let b = id_from_mac(&mac);
|
||||
|
||||
assert_eq!(a, b, "the same MAC must always give the same id");
|
||||
assert_ne!(a, 0, "0 is reserved for the referee terminal");
|
||||
|
||||
// 不同 MAC 不应撞车。
|
||||
assert_ne!(a, id_from_mac(&[1, 2, 3, 4, 5, 6]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_jpeg_still_produces_one_fragment() {
|
||||
let frags = fragment_frame(1, 1, &[], 64, 48);
|
||||
assert_eq!(frags.len(), 1);
|
||||
assert!(frags[0].payload.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datagrams_stay_under_a_typical_mtu() {
|
||||
let jpeg = vec![0u8; 100_000];
|
||||
for f in fragment_frame(1, 1, &jpeg, 800, 600) {
|
||||
assert!(f.encode().len() <= HEADER_LEN + MAX_FRAGMENT_PAYLOAD);
|
||||
assert!(f.encode().len() < 1500, "must fit an Ethernet MTU");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user