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
+228
View File
@@ -0,0 +1,228 @@
//! 帧率统计。
//!
//! # 必须按"帧数除以墙钟",不是"帧间隔的平均"
//!
//! 这两种算法在理想链路上给出同一个数,所以错误的那一版能一路跑到现场才暴露。
//!
//! 界面每 33ms 轮询一次,而相机出帧间隔是 45ms。也就是说**一次轮询之间可能完成零帧
//! 或多帧**。对帧间隔取平均,量到的是**显示**的刷新节奏而不是**相机**的出帧节奏——
//! 于是上限 22fps 的传感器被报成 36fps。**一个笃定的错误数字比没有数字更糟**:现场
//! 会以为还有性能余量,然后花时间去查一个不存在的问题。
//!
//! 这个错误在本项目里出现过两次(旧客户端的 `View::update_fps`、探测脚本的分窗打印),
//! 两次都是同一个形状:分子用累计值,分母用窗口值,于是报出随时间单调上升的假数字。
//! 现在它的正确性由一个直接对着数字写的测试盯着。
//!
//! # 时钟是可注入的
//!
//! [`FpsWindow::observe`] 收 `now` 而不是自己取 `Instant::now()`,这样测试可以用
//! 明确的时刻验证窗口边界——否则只能 sleep,而 sleep 出来的测试既慢又不稳。
use std::time::{Duration, Instant};
/// 帧率统计窗口。默认 2 秒。
pub const DEFAULT_WINDOW: Duration = Duration::from_secs(2);
/// 某一路的帧率窗口。
#[derive(Debug, Clone)]
pub struct FpsWindow {
window: Duration,
started: Instant,
/// 上一次结算时的累计帧数。
frames: u64,
/// 已经结算过几个窗口。
///
/// 不能拿"累计帧数非零"来代替它:窗口的基线是**播种**进去的(见 [`FpsWindow::seeded`]),
/// 一块掉线又回来的板子一建立窗口就带着一个很大的累计值。用帧数判断的话,会在一个
/// 窗口都还没走完时就宣称"有读数了",界面于是先显示 0.0,再跳到真值。
completed: u32,
fps: f32,
}
impl Default for FpsWindow {
fn default() -> Self {
Self::new(DEFAULT_WINDOW)
}
}
impl FpsWindow {
pub fn new(window: Duration) -> Self {
Self::starting_at(window, Instant::now())
}
/// 窗口从指定的时刻开始,并把累计帧数的基线设成给定值。
///
/// 基线不能总是 0:`frames_shown` 是**自进程启动以来**的累计值,而一块掉线又回来的
/// 板子会带着一个已经涨了很大的数字回来。基线设错(比如设成 0)时,下一个窗口会
/// 报出几百 fps 的假数字。
pub fn seeded(window: Duration, started: Instant, frames_shown: u64) -> Self {
Self {
window,
started,
frames: frames_shown,
completed: 0,
fps: 0.0,
}
}
/// 窗口从指定的时刻开始,基线为 0。测试用。
pub fn starting_at(window: Duration, started: Instant) -> Self {
Self::seeded(window, started, 0)
}
/// 最近一次结算出的帧率。窗口还没满时是 0。
pub fn fps(&self) -> f32 {
self.fps
}
/// 窗口是否已经结算过至少一次。
///
/// 界面用它在"还没收到帧"和"帧率真的是 0"之间区分——前者显示 0.0 会被读成"板子
/// 卡死了"。
pub fn has_reading(&self) -> bool {
self.completed > 0
}
/// 喂进当前的累计帧数,返回本窗口的帧率(窗口未满时返回 `None`)。
///
/// `frames_shown` 是**自进程启动以来**的累计值,不是窗口内的增量——这正是重点:
/// 窗口内的增量由这里相减得到,调用方不需要(也就不会弄错)维护它。
pub fn observe(&mut self, frames_shown: u64, now: Instant) -> Option<f32> {
let elapsed = now.saturating_duration_since(self.started);
if elapsed < self.window {
return None;
}
self.fps = (frames_shown.saturating_sub(self.frames)) as f32 / elapsed.as_secs_f32();
self.frames = frames_shown;
self.started = now;
self.completed += 1;
Some(self.fps)
}
/// 换了一路画面:窗口重开。
///
/// 不重开的话,新画面的第一段统计里带着上一块板子的累计帧数,会报出一个凭空多出来
/// 的数字。旧客户端对此的处理是"`view.sender_id` 变了就整体重建 `View`",那条
/// 规则现在收在这里。
pub fn reset(&mut self, now: Instant) {
self.started = now;
self.frames = 0;
self.completed = 0;
self.fps = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_is_frames_over_wallclock() {
let t0 = Instant::now();
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
// 2 秒里到了 44 帧 = 22 fps。
let fps = window
.observe(44, t0 + Duration::from_secs(2))
.expect("the window is full");
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
}
#[test]
fn a_half_full_window_reports_nothing() {
let t0 = Instant::now();
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
assert_eq!(window.observe(10, t0 + Duration::from_millis(1999)), None);
assert_eq!(window.fps(), 0.0);
assert!(!window.has_reading());
}
#[test]
fn a_stalled_stream_reports_zero_not_a_stale_number() {
let t0 = Instant::now();
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
window.observe(44, t0 + Duration::from_secs(2));
// 板子不出帧了:累计值不变,下一个窗口必须报 0。
let fps = window
.observe(44, t0 + Duration::from_secs(4))
.expect("second window");
assert_eq!(fps, 0.0);
}
#[test]
fn averaging_frame_gaps_is_the_wrong_answer() {
// 这条测试把"错误算法会给出什么"钉下来,避免有人"顺手修一下"。
//
// 场景:界面每 33ms 轮询一次,相机 45ms 出一帧。一次轮询里轮空、下一次里
// 完成一帧,还有一次轮询之间完成两帧。对帧间隔取平均会算成约 33ms 一帧
// (30fps 上下),而墙钟给出的才是真实的 22fps。
let t0 = Instant::now();
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
// 2 秒内累计 44 帧,但最后一次轮询与上一帧之间只隔了 33ms。
let fps = window
.observe(44, t0 + Duration::from_secs(2))
.expect("window full");
assert!((fps - 22.0).abs() < 0.001, "wallclock rate: {fps}");
assert!(
fps < 25.0,
"a frame-gap average would have reported ~30fps here"
);
}
#[test]
fn a_completed_window_is_not_a_reading_yet_until_it_is_full() {
// 建立窗口的那一轮不该产出数字:它连一个完整窗口都还没过。
let t0 = Instant::now();
let mut window = FpsWindow::seeded(Duration::from_secs(2), t0, 9_000);
assert_eq!(window.observe(9_010, t0 + Duration::from_millis(10)), None);
assert!(!window.has_reading());
// 2 秒后:这 2 秒里到了 44 帧。
let fps = window
.observe(9_044, t0 + Duration::from_secs(2))
.expect("window full");
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
}
#[test]
fn resetting_clears_the_cumulative_count() {
let t0 = Instant::now();
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
window.observe(1000, t0 + Duration::from_secs(2));
// 换了一路画面:累计值从 0 重新开始。
window.reset(t0 + Duration::from_secs(2));
let fps = window
.observe(44, t0 + Duration::from_secs(4))
.expect("window full");
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
}
#[test]
fn a_counter_that_goes_backwards_does_not_underflow() {
// 累计值只应该单调增。真出现回退(换了板子但没 reset),宁可报 0 也不要用
// u64 回绕算出一个天文数字。
let t0 = Instant::now();
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
window.observe(500, t0 + Duration::from_secs(2));
let fps = window
.observe(10, t0 + Duration::from_secs(4))
.expect("window full");
assert_eq!(fps, 0.0);
}
}