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 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
This commit is contained in:
2026-10-14 19:14:16 +08:00
parent eafa112959
commit a89d7ae5fb
50 changed files with 15615 additions and 11 deletions
+78 -8
View File
@@ -453,14 +453,14 @@ impl Hello {
}
// ---------------------------------------------------------------------------
// 装甲板(预留)
// 装甲板
// ---------------------------------------------------------------------------
/// 一块装甲板的上报。
///
/// 现在没有实现也没有数据源,但格式在这里定下来,并且 [`MsgType::ArmorHealth`]
/// 与 [`ARMOR_PORT`] 已经就位:板子接上以后向 `ARMOR_PORT` 发这个结构,裁判终端
/// 监听即可,界面与其它代码都不用改。
/// 由 `armor/armor-firmware` 里的主板(ESP32-C3)广播到 [`ARMOR_PORT`],裁判终端监听
/// 该端口并把它显示成四格血量。整车一条血量,所以四条上报的 `hp` 相同、`index`
/// 分别是 0..3。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ArmorHealth {
/// 哪一边:0 = 队伍一,1 = 队伍二。
@@ -472,10 +472,20 @@ pub struct ArmorHealth {
}
impl ArmorHealth {
/// 编码成线上字节,帧头的 `sender_id` 留 0。
///
/// 0 是 [`REFEREE_ID`],所以板子发的时候应该用 [`encode_from`](Self::encode_from)
/// 填上自己的 id(由 MAC 派生),抓包时才能分辨是哪块板子。
pub fn encode(&self) -> Vec<u8> {
self.encode_from(0)
}
/// 与 [`encode`](Self::encode) 相同,但帧头带上发送方自己的 id。
pub fn encode_from(&self, sender_id: u32) -> Vec<u8> {
let header = Header {
msg_type: MsgType::ArmorHealth,
version: PROTOCOL_VERSION,
sender_id,
..Default::default()
};
@@ -488,17 +498,32 @@ impl ArmorHealth {
}
pub fn decode(bytes: &[u8]) -> Option<Self> {
Self::decode_from(bytes).map(|(_, health)| health)
}
/// 与 [`decode`](Self::decode) 相同,但把帧头里的 `sender_id` 一起返回。
///
/// 板子用 [`encode_from`](Self::encode_from) 把自己的 MAC 派生 id 填进去,所以
/// 这一个 u32 就是**哪块装甲板主板在说话**。裁判端与队伍端要列出"场上有哪几块
/// 装甲板"并让用户选中其中一块,靠的就是它——没有它就只剩 `team` 字段,
/// 同队的两块板子无从区分。
///
/// 保持 [`decode`](Self::decode) 不变是有意的:`team`/`index`/`hp` 是上报的语义,
/// 而 `sender_id` 是链路层的信封,不需要它的人不该被迫处理它。
pub fn decode_from(bytes: &[u8]) -> Option<(u32, Self)> {
let header = decode_header(bytes)?;
if header.msg_type != MsgType::ArmorHealth || bytes.len() < HEADER_LEN + 4 {
return None;
}
Some(ArmorHealth {
let health = 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()?),
})
};
Some((header.sender_id, health))
}
}
@@ -859,8 +884,7 @@ mod tests {
#[test]
fn armor_health_roundtrips() {
// 现在没有实现,但格式必须先定下来并且可往返,否则接上板子时才发现布局
// 不兼容就晚了。
// 现在有实现了(armor/ 里的 ESP32-C3 固件在发它),布局必须保持可往返。
let hp = ArmorHealth {
team: 1,
index: 2,
@@ -871,6 +895,52 @@ mod tests {
assert_eq!(ArmorHealth::decode(&encoded), Some(hp));
}
#[test]
fn armor_health_can_carry_the_sending_board_id() {
let hp = ArmorHealth {
team: 0,
index: 3,
hp: 97,
};
let encoded = hp.encode_from(0x1234_5678);
assert_eq!(
decode_header(&encoded).map(|h| h.sender_id),
Some(0x1234_5678),
"抓包时要能分辨是哪块装甲板发的"
);
assert_eq!(ArmorHealth::decode(&encoded), Some(hp));
// 帧头 id 不影响帧体,也不该影响旧调用方的行为。
assert_eq!(ArmorHealth::decode(&hp.encode()), Some(hp));
assert_eq!(decode_header(&hp.encode()).map(|h| h.sender_id), Some(0));
}
#[test]
fn armor_health_decode_from_returns_the_board_id() {
// 客户端要列出"场上有哪几块装甲板",只能靠帧头里这个 id:team 只有 0/1,
// 同队的两块板子在 team 上无法区分。
let hp = ArmorHealth {
team: 1,
index: 1,
hp: 100,
};
assert_eq!(
ArmorHealth::decode_from(&hp.encode_from(0xABCD_1234)),
Some((0xABCD_1234, hp))
);
// decode 是 decode_from 丢掉信封的那一半,两者必须一致。
assert_eq!(
ArmorHealth::decode(&hp.encode_from(0xABCD_1234)),
ArmorHealth::decode_from(&hp.encode_from(0xABCD_1234)).map(|(_, h)| h)
);
// 不是装甲板上报就不该解出来。
assert!(ArmorHealth::decode_from(&[0u8; 8]).is_none());
}
#[test]
fn ids_are_stable_and_never_zero() {
let mac = [0x90, 0x70, 0x69, 0xF8, 0xF2, 0x14];