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:
Horldsence
2026-10-05 18:22:19 +08:00
commit 4f893ce140
134 changed files with 41128 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# This crate is built for the host, not the ESP32.
#
# It sits inside the firmware's directory tree, so cargo would otherwise inherit
# ../.cargo/config.toml and try to cross-compile it for the Xtensa target - which
# fails, because that config's ldproxy linker is only valid inside the ESP-IDF
# build. Overriding `target` back to the host triple is enough to make `cargo test`
# work here; the firmware pulls this crate in as a path dependency and cross-compiles
# it along with everything else, unaffected by this file.
[build]
target = "aarch64-apple-darwin"
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "protocol"
version = "0.1.0"
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "protocol"
version = "0.1.0"
edition = "2021"
[dependencies]
+95
View File
@@ -0,0 +1,95 @@
# protocol
摄像头 UDP 图传的线路格式,**固件与客户端共用**。
这是本仓库里最该被测试的部分:帧重组和编码都极易出微妙错误,而这两件事都无法
在固件上验证——你只会看到"不出画面"。
因此它没有 ESP-IDF 依赖,会为宿主架构编译:
```sh
cargo test
```
```
7 passed
```
## 线路格式
每个数据报 = 20 字节头 + 至多 1400 字节 JPEG 数据:
```
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) | frame_id (u32) |
+-------------------------------+-------------------------------+
| frag_index (u16) | frag_count (u16) |
+-------------------------------+-------------------------------+
| total_len (u32) | width (u16) | height (u16) |
+-------------------------------+---------------+---------------+
| payload (0..=1400) |
+---------------------------------------------------------------+
```
全部整数为**小端**——ESP32-S3(Xtensa)和所有桌面架构都原生如此,编解码就是
`to_le_bytes` / `from_le_bytes`,没有字节序转换步骤。
- `magic` 固定值,用于把同端口上的无关 UDP 包挡掉。
- `total_len` 是整张 JPEG 的长度,接收端据此预分配缓冲并判断帧是否完整。
- `width`/`height` 每个分片都重复携带(不只是第 0 个),这样每个数据报都能独立
解析,中途加入的接收端也能立刻知道分辨率。
## 为什么是 UDP 而不是 TCP
这是本 crate 存在的原因,也是实测数据:
| | TCP(HTTP MJPEG) | UDP |
|---|---|---|
| 传感器实际产出 | 22 fps | 22 fps |
| 客户端实际收到 | **1.9 fps** | **20.8 fps** |
| 实际吞吐 | 0.28 Mbit/s | 1.72 Mbit/s |
关键在于**链路是空的**:TCP 那一栏只用了链路能力的 1% 左右(ESP32-S3 在良好的
2.4GHz 链路上,TCP 实测上限约 20–30 Mbit/s),帧却堵在队头阻塞后面等一个实时画面
等不起的重传计时器。
UDP 没有任何重传与确认:缺了分片的帧被整个丢弃,下一帧顶上。这换来低延迟,也把
本来就有的带宽真正用起来了——但即便如此也只用掉 6–9%,说明瓶颈从头到尾都不在
带宽上。
## 接收端的取舍
`FrameAssembler` 的策略就是全部要点:**只有分片全部到齐才显示该帧,且最新的帧永远
优先。**
UDP 不保证跨数据报的顺序或到达,所以试图"补洞"的接收端做的功比"等下一张完整帧"
更多——而且补的过程中会显示撕裂的画面。
另外有一个有界超时,用来堵住另一种失败模式:发送方在帧中途死掉时,半截缓冲会
永远留在这里。
| 字段 | 含义 |
|---|---|
| `frag_count == 0` | 非法,立即拒绝 |
| `frag_index >= frag_count` | 自相矛盾,立即拒绝 |
| `payload_len > total_len` | 损坏,立即拒绝(在造成大额分配之前就挡住) |
## 发送端
`fragment_frame()` 从 JPEG 长度推出分片数,因此调用方不可能产出与自身矛盾的
分片集合。
`Fragment::encode_into()` 追加写入而不分配:发送端对每帧的每个分片复用同一个缓冲,
在 22fps 下这是每秒约 250 次分配与不分配的区别——在 WiFi 栈也要抢内部 RAM 的芯片
上,这个差别很实在。
## 不变量
- 一个数据报不超过 `HEADER_LEN + MAX_FRAGMENT_PAYLOAD` = 1420 字节,低于典型以太网
的 1500 MTU。
这一点比看起来重要:超过 MTU 的数据报要么被 AP 丢掉,要么被 IP 分片,而两种情况
的丢包都会**记在应用账上**而不是网络账上——后者本来更容易排查。
- VGA 下 `MAX_FRAGMENT_PAYLOAD` 对应每帧 8–10 个数据报。
- `DEFAULT_PORT` = 5600,两端共用同一个常量。
+870
View File
@@ -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");
}
}
}