把桌面端从 Slint 迁到 Tauri v2,后端先落地,界面待接。拆成三层: - desktop/core(cam-core,67 测试):从旧 client/ 抽出的非界面部分。UDP 接收按 发送方重组、装甲板发现与血量、帧率窗口、槽位分配、配置与密码。 - desktop/src-tauri(27 测试):Tauri 后端。回环 WebSocket 推二进制 JPEG、 每 200ms 一份状态快照、密码闸门、截图、ffmpeg 录制 mp4、演示数据源。 - protocol:新增 ArmorHealth::decode_from,把帧头里的 sender_id 交出来—— 装甲板一直带着 MAC 派生 id 在广播,旧解码路径把它丢了,所以"装甲板下拉可选" 不需要改固件。 几个改对了的旧问题: - 槽位分配:旧实现只排除"槽位自己原来那个 id",于是队伍一的板子一离线,它的槽位 会去拿队伍二那块,两格显示同一块板子。现在候选要排除另一个槽位占着的 id。 - 帧率窗口的基线:掉线又回来的板子带着一个很大的累计帧数回来,从 0 起算会报出 几百 fps。改成 seeded + 用"已结算窗口数"判断有没有读数。 - 密码哈希:password-hash 能解析 `$argon2id$broken` 这种截断串,于是"配置坏了" 会被读成"密码错了"。现在要求盐与摘要都在。 - 录制文件名:同一秒停一次再开一次会用同一个名字,ffmpeg 的 -y 静默覆盖上一段。 现在先收尾上一段再开新的,并且重名往后排号。 - 两道闸门:帧的限制与发现列表的限制分开。设置界面需要列候选摄像头,但**画面** 不需要——旧版把两件事绑在一道闸门上,只能在"能列候选"和"挡住对手"之间二选一。 测试:protocol 15、cam-core 67、desktop 27,其中录制那条真的起 ffmpeg 并用 ffprobe 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
189 lines
6.8 KiB
Rust
189 lines
6.8 KiB
Rust
//! 裁判端两个槽位对应哪两块摄像头。
|
|
//!
|
|
//! # 规则
|
|
//!
|
|
//! 1. **还在线的槽位不换人。** 板子短暂掉线又回来时,画面留在原位。
|
|
//! 2. **没人用的板子才拿来填。** 候选里排除已经被另一个槽位占着的 id——这一条是
|
|
//! 修一个真 bug:以前的实现只排除"这个槽位自己原来那个 id",于是队伍一那块板子
|
|
//! 一离线,它的槽位就会去拿队伍二那块,**两格显示同一块板子**,看起来像图传串了。
|
|
//! 3. **掉线的槽位保留它的 id。** 显示"离线"而不是变回空槽,这样它回来时还在原位,
|
|
//! 也不会把画面顺序重排。
|
|
//! 4. **没有候选就不动。** 只有一块板在场时,另一个槽位空着——空着比显示一份复制品
|
|
//! 诚实。
|
|
//!
|
|
//! # 为什么值得单独一个模块加测试
|
|
//!
|
|
//! 这个逻辑出错的**表现**是"两路画面看起来串了"。现场的排查方向会是网线、AP、
|
|
//! 板子,而不是二十行分配代码。旧客户端在 README 里为它写了整整一节,但仍然漏掉了
|
|
//! 规则 2。所以现在它有了直接对着场景写的测试。
|
|
|
|
/// 两个槽位。索引 0 是左(队伍一),1 是右(队伍二)。
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
pub struct SlotAllocator {
|
|
slots: [Option<u32>; 2],
|
|
}
|
|
|
|
impl SlotAllocator {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// 当前分配。
|
|
pub fn slots(&self) -> [Option<u32>; 2] {
|
|
self.slots
|
|
}
|
|
|
|
pub fn slot(&self, index: usize) -> Option<u32> {
|
|
self.slots.get(index).copied().flatten()
|
|
}
|
|
|
|
/// 手动指定一个槽位(裁判端的"允许手动覆盖")。
|
|
pub fn force(&mut self, index: usize, id: Option<u32>) {
|
|
if let Some(slot) = self.slots.get_mut(index) {
|
|
*slot = id;
|
|
}
|
|
}
|
|
|
|
/// 按当前在线的摄像头更新分配,返回是否发生了变化。
|
|
///
|
|
/// `online` 必须按 id 排序,调用方用 [`crate::receiver::Discovery::online_cameras`]
|
|
/// 得到的就是排好序的——顺序稳定,分配才不会每轮抖动。
|
|
pub fn assign(&mut self, online: &[u32]) -> bool {
|
|
let before = self.slots;
|
|
|
|
for index in 0..self.slots.len() {
|
|
// 规则 1:还在场就不动。
|
|
if self.slots[index].is_some_and(|id| online.contains(&id)) {
|
|
continue;
|
|
}
|
|
|
|
// 规则 2:另一个槽位占着的 id 不能拿。
|
|
let taken: Vec<u32> = (0..self.slots.len())
|
|
.filter(|other| *other != index)
|
|
.filter_map(|other| self.slots[other])
|
|
.collect();
|
|
|
|
let candidate = online
|
|
.iter()
|
|
.copied()
|
|
.find(|id| !taken.contains(id) && self.slots[index] != Some(*id));
|
|
|
|
// 规则 4:没有候选就保留原样(规则 3:掉线的 id 留着)。
|
|
if let Some(id) = candidate {
|
|
self.slots[index] = Some(id);
|
|
}
|
|
}
|
|
|
|
self.slots != before
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn two_boards_fill_both_slots_left_to_right() {
|
|
let mut slots = SlotAllocator::new();
|
|
|
|
assert!(slots.assign(&[0xAAAA, 0xBBBB]));
|
|
assert_eq!(slots.slots(), [Some(0xAAAA), Some(0xBBBB)]);
|
|
assert!(!slots.assign(&[0xAAAA, 0xBBBB]), "nothing changed");
|
|
}
|
|
|
|
#[test]
|
|
fn one_board_never_fills_both_slots() {
|
|
// 旧实现会在两块都空时每轮取前两个,只有一块板在场时两格显示同一块画面。
|
|
// 裁判看到两边一模一样,会以为两队的图传串了。
|
|
let mut slots = SlotAllocator::new();
|
|
slots.assign(&[0xAAAA]);
|
|
|
|
assert_eq!(slots.slots(), [Some(0xAAAA), None]);
|
|
}
|
|
|
|
#[test]
|
|
fn an_online_slot_keeps_its_board_when_the_other_goes_offline() {
|
|
// 这是本项目里真实出现过的 bug:队伍一的板子一离线,它的槽位去拿了队伍二
|
|
// 那块,于是两格都显示队伍二——而 B 的槽位本身没动。
|
|
let mut slots = SlotAllocator::new();
|
|
slots.assign(&[0xAAAA, 0xBBBB]);
|
|
|
|
slots.assign(&[0xBBBB]);
|
|
|
|
assert_eq!(
|
|
slots.slots(),
|
|
[Some(0xAAAA), Some(0xBBBB)],
|
|
"A 掉线的槽位保留它的 id(显示离线),B 原地不动,绝不能出现两块 B"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_returning_board_lands_back_in_its_own_slot() {
|
|
let mut slots = SlotAllocator::new();
|
|
slots.assign(&[0xAAAA, 0xBBBB]);
|
|
slots.assign(&[0xBBBB]);
|
|
slots.assign(&[0xAAAA, 0xBBBB]);
|
|
|
|
assert_eq!(slots.slots(), [Some(0xAAAA), Some(0xBBBB)]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_spare_board_does_not_land_twice() {
|
|
let mut slots = SlotAllocator::new();
|
|
slots.assign(&[0xAAAA, 0xBBBB]);
|
|
|
|
// 两块都掉线,然后来了一块新的 C:只填一个槽位。
|
|
slots.assign(&[0xCCCC]);
|
|
|
|
assert_eq!(slots.slots(), [Some(0xCCCC), Some(0xBBBB)]);
|
|
|
|
// C 填了左边,右边仍是掉线的 B;再来一块 D 才填右边。
|
|
slots.assign(&[0xCCCC, 0xDDDD]);
|
|
assert_eq!(slots.slots(), [Some(0xCCCC), Some(0xDDDD)]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_board_coming_back_does_not_thrash_the_slot_it_left() {
|
|
// "板子短暂掉线又回来,槽位原地打转":掉线的槽位不该立刻把自己换掉。
|
|
let mut slots = SlotAllocator::new();
|
|
slots.assign(&[0xAAAA]);
|
|
|
|
// A 掉线、B 上线:A 的槽位改拿 B。
|
|
slots.assign(&[0xBBBB]);
|
|
assert_eq!(slots.slots(), [Some(0xBBBB), None]);
|
|
|
|
// A 回来,两块都在:B 已经在槽位里且在线,A 只能等另一个槽位——它空着,于是
|
|
// A 落到右边。这是有意的:已经显示出来的画面不该因为一块板子回来就跳到别处。
|
|
slots.assign(&[0xAAAA, 0xBBBB]);
|
|
assert_eq!(slots.slots(), [Some(0xBBBB), Some(0xAAAA)]);
|
|
}
|
|
|
|
#[test]
|
|
fn forcing_a_slot_overrides_the_automatic_choice() {
|
|
let mut slots = SlotAllocator::new();
|
|
slots.assign(&[0xAAAA, 0xBBBB]);
|
|
|
|
slots.force(0, Some(0xBBBB));
|
|
slots.force(1, Some(0xAAAA));
|
|
|
|
assert_eq!(slots.slots(), [Some(0xBBBB), Some(0xAAAA)]);
|
|
|
|
// 手动模式下依然是手动值:assign 只对"不在线"的槽位动手。
|
|
slots.assign(&[0xAAAA, 0xBBBB]);
|
|
assert_eq!(slots.slots(), [Some(0xBBBB), Some(0xAAAA)]);
|
|
}
|
|
|
|
#[test]
|
|
fn losing_everything_leaves_the_last_known_ids() {
|
|
let mut slots = SlotAllocator::new();
|
|
slots.assign(&[0xAAAA, 0xBBBB]);
|
|
|
|
assert!(!slots.assign(&[]), "nothing to reassign");
|
|
assert_eq!(
|
|
slots.slots(),
|
|
[Some(0xAAAA), Some(0xBBBB)],
|
|
"掉线的槽位保留 id,界面才能说清在等谁"
|
|
);
|
|
}
|
|
}
|