Files
ESP32S3_vt/desktop/core/src/slots.rs
T
Horldsence a89d7ae5fb desktop: Tauri v2 后端 + 共享网络层(用户端/裁判端双身份)
把桌面端从 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 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
2026-10-14 19:14:16 +08:00

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,界面才能说清在等谁"
);
}
}