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
+598
View File
@@ -0,0 +1,598 @@
//! 装甲板:监听 5601,把主板广播的 `ArmorHealth` 变成界面要的东西。
//!
//! 两件事,各自服务一种需求:
//!
//! - **血量**([`ArmorSource::health`]):某一边四条上报都在新鲜期内才交给界面。
//! - **发现**([`ArmorHealthStore::boards`]):场上有哪几块装甲板主板,各自的 id、IP、
//! 自报的队伍、最后一次血量。
//!
//! 第二种以前没有,因为队伍端不能选装甲板。现在能选了(要密码),于是"列出候选"
//! 成为必需。**这里不需要改固件**:主板一直用 `encode_from(sender_id)` 把自己的
//! MAC 派生 id 打进帧头,只是旧的解码路径把它丢了。
//!
//! # 为什么按 `(team, index)` 存最后一次读数
//!
//! 主板每 200 ms 广播四条(`index` 0..3,整车一条血所以 `hp` 相同)。UDP 会丢包,
//! 界面不能"收到哪条显示哪条"——那样四个格子会各自跳。所以**四条都在新鲜期内**才交给
//! 界面;否则退回占位符:显示一个可能已经过期的血量,比显示"未接入"更危险(现场会以为
//! 对方还有血)。
//!
//! # 为什么绑 0.0.0.0
//!
//! 板子往 `255.255.255.255:5601` 广播,只有绑到 `INADDR_ANY` 的 socket 才收得到。
//! 绑具体网卡地址的话,换个网络就得改配置。
//!
//! # 两端都开
//!
//! 旧客户端只在裁判端绑 5601(队伍端不显示血量)。现在队伍端要显示自己的血条,所以
//! 两端都要收。这反而去掉了同一台机器上"队伍端与裁判端抢 5601"的争用点——两套界面
//! 现在在同一个进程里。
//!
//! # 命中检测
//!
//! 掉血事件([`HealthTracker`])由这里派生:主板只上报"现在是多少血",不报"刚刚被
//! 打了一下"。所以命中反馈是靠**相邻两次读数之差**还原的,见那个结构体的文档。
use std::collections::HashMap;
use std::io::ErrorKind;
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use protocol::{ArmorHealth, ARMOR_PORT};
use crate::receiver::ARMOR_BOARDS;
/// 多久没收到就当作板子掉线。
///
/// 心跳 200 ms,所以这是连续丢了 7 个包:偶发丢一两个不会让界面闪回占位符。
pub const STALE_AFTER: Duration = Duration::from_millis(1500);
/// 一边的装甲板数量(0/1 两支队伍)。
const TEAMS: usize = 2;
/// 某个 `(team, index)` 的最后一次读数。
#[derive(Debug, Clone, Copy)]
struct Reading {
hp: u16,
at: Instant,
}
/// 一块装甲板主板。
#[derive(Debug, Clone)]
pub struct ArmorBoard {
/// MAC 派生的板子 id,重启不变,所以绑定它不会失效。
pub id: u32,
/// 最后一次收到它广播的来源地址。
pub addr: SocketAddrV4,
/// 板子自报的队伍(编译期定的 `ARMOR_TEAM`)。
pub team: u8,
/// 最后一次读数里的血量。整车一条血,所以取任意一条都一样。
pub hp: u16,
/// 见过的 `index` 位图:低 4 位分别对应 0..3。
///
/// 用来区分"这块板子只报了第 0 条"和"四条都报了"——前者通常意味着配置或固件不对,
/// 现场值得看一眼。
pub plates_seen: u8,
pub last_seen: Instant,
}
impl ArmorBoard {
/// 多久没有广播了。
pub fn silence(&self) -> Duration {
self.last_seen.elapsed()
}
/// 板子在线吗?
pub fn is_online(&self) -> bool {
self.silence() < STALE_AFTER
}
/// 四条装甲板都上报过吗。
pub fn reports_all_plates(&self) -> bool {
self.plates_seen == 0b1111
}
}
/// 收到的一条装甲板上报。
///
/// 比 [`protocol::ArmorHealth`] 多一个"谁发的"——那正是能列出候选、能绑定、能把命中
/// 归到具体某块板子上的前提。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ArmorReport {
pub board: u32,
pub addr: SocketAddrV4,
pub team: u8,
pub index: u8,
pub hp: u16,
}
/// 每次收到上报就调一次。
///
/// 在接收线程里被调用,所以必须是非阻塞的。Tauri 层在这里做掉血检测并把结果推给界面。
pub type ArmorSink = Arc<dyn Fn(ArmorReport) + Send + Sync>;
/// 已收到的最新血量。
#[derive(Debug, Default)]
struct Inner {
/// `readings[team][index]`。
readings: [[Option<Reading>; ARMOR_BOARDS]; TEAMS],
/// 按板子 id 索引的发现状态。
boards: HashMap<u32, ArmorBoard>,
/// 已经打过"这块板子在线"的板子 id,避免刷屏。
announced: Vec<u32>,
}
/// 相邻两次读数之差还原出来的掉血事件。
///
/// 主板不报"被打了一下",只报"现在是 97"。所以命中反馈只能是派生的:
///
/// - 血量**减少** ⇒ 一次命中,伤害是两个读数之差。
/// - 血量**增加** ⇒ 不是命中,是长按 BOOT 的重置。忽略,但值得记一条日志。
/// - **第一次**见到一块板子不产生命中:没有基线,此时 `hp = 0` 只是"已经没血了",
/// 不是"刚刚被打死"。
///
/// 已知的精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段
/// 窗口里的两次打击会被合并成一次——对"闪一下红"的用途足够,对判定不够,判定看的是
/// 界面上的绝对血量。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Hit {
pub board: u32,
pub team: u8,
pub index: u8,
pub from: u16,
pub to: u16,
pub damage: u16,
}
/// 从连续的读数里派生命中。
///
/// 按**板子**而不是按 `(板子, index)` 记基线:主板一次广播四条、`hp` 相同,按四条各记
/// 一份基线会让一次掉血被报成四次命中。
#[derive(Debug, Default)]
pub struct HealthTracker {
last: HashMap<u32, u16>,
}
impl HealthTracker {
pub fn new() -> Self {
Self::default()
}
/// 喂进一条上报,返回派生的命中(如果有)。
pub fn observe(&mut self, report: &ArmorReport) -> Option<Hit> {
let previous = self.last.insert(report.board, report.hp);
let Some(from) = previous else {
// 第一次见到这块板子,没有基线。
return None;
};
if report.hp >= from {
// 持平(同一次广播的另外三条)或回升(重置)。
return None;
}
Some(Hit {
board: report.board,
team: report.team,
index: report.index,
from,
to: report.hp,
damage: from - report.hp,
})
}
/// 忘掉一块板子的基线(掉线重连后重新建)。
pub fn forget(&mut self, board: u32) {
self.last.remove(&board);
}
}
/// 监听装甲板广播的血量源。
pub struct ArmorHealthStore {
inner: Mutex<Inner>,
sink: Mutex<Option<ArmorSink>>,
}
impl Default for ArmorHealthStore {
fn default() -> Self {
Self::new()
}
}
impl ArmorHealthStore {
/// 建一个 store,但先不绑端口。测试从这里喂数据报。
pub fn new() -> Self {
Self {
inner: Mutex::new(Inner::default()),
sink: Mutex::new(None),
}
}
/// 绑定 5601 并起一个接收线程。
///
/// 绑定失败(端口被占、权限问题)返回 `Err`,调用方退回 [`crate::NoArmorYet`] 即可
/// ——血量显示不了不该让整个客户端起不来。
pub fn start() -> std::io::Result<(Arc<Self>, UdpSocket)> {
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, ARMOR_PORT))?;
socket.set_read_timeout(Some(Duration::from_millis(200)))?;
log::info!("armor: listening for health broadcasts on UDP {ARMOR_PORT}");
Ok((Arc::new(Self::new()), socket))
}
/// 在单独的线程上跑接收循环。
pub fn spawn(self: &Arc<Self>, socket: UdpSocket) {
let store = Arc::clone(self);
// 线程名带上前缀,日志里一眼能看出是装甲板那条链路。
let _ = thread::Builder::new()
.name("armor-reader".into())
.spawn(move || store.run(socket));
}
/// 接收循环。这个线程活到进程结束,不需要退出条件。
pub fn run(&self, socket: UdpSocket) {
let mut buf = [0u8; 256];
loop {
let (len, from) = match socket.recv_from(&mut buf) {
Ok(pair) => pair,
Err(err)
if err.kind() == ErrorKind::WouldBlock
|| err.kind() == ErrorKind::TimedOut =>
{
continue;
}
Err(err) => {
log::warn!("armor: receive failed: {err}");
// 出错后别做没有间隔的忙等(比如网卡热插拔时的 ENOBUFS)。
thread::sleep(Duration::from_millis(200));
continue;
}
};
self.datagram(&buf[..len], from);
}
}
/// 处理一条上报。测试从这里喂字节。
pub fn datagram(&self, bytes: &[u8], from: SocketAddr) {
let SocketAddr::V4(addr) = from else {
return;
};
// 同一个端口上可能有别的东西(图传板的心跳、别的协议),解不出来就当不是给
// 我的,安静跳过。
let Some((board, health)) = ArmorHealth::decode_from(bytes) else {
return;
};
let (team, index) = (health.team as usize, health.index as usize);
if team >= TEAMS || index >= ARMOR_BOARDS {
log::warn!(
"armor: ignoring out-of-range report team={} index={}",
health.team,
health.index
);
return;
}
let report = ArmorReport {
board,
addr,
team: health.team,
index: health.index,
hp: health.hp,
};
{
let mut inner = self.inner.lock().expect("armor mutex poisoned");
// 第一次见到某块板子就报一声:现场判断"到底连上没有"最快的线索。
if !inner.announced.contains(&board) {
inner.announced.push(board);
log::info!(
"armor: board 0x{board:08X} is online (team {}, hp {}) via {addr}",
health.team,
health.hp
);
}
// 血量变化也要能看见:窗口是给别人看的,抓日志的人得知道掉血没有。整车一条
// 血、四条同值,所以只对 index 0 报一次。
if index == 0 {
if let Some(previous) = inner.readings[team][0].map(|reading| reading.hp) {
if previous != health.hp {
log::info!(
"armor: board 0x{board:08X} team {team} hp {previous} -> {}",
health.hp
);
}
}
}
inner.readings[team][index] = Some(Reading {
hp: health.hp,
at: Instant::now(),
});
let entry = inner.boards.entry(board).or_insert(ArmorBoard {
id: board,
addr,
team: health.team,
hp: health.hp,
plates_seen: 0,
last_seen: Instant::now(),
});
// 队伍是编译期定的,但同队两块板子都自报同一个 team 是正常的;这里只在
// 板子换了 team(重刷了固件)时跟着更新。
entry.addr = addr;
entry.team = health.team;
entry.hp = health.hp;
entry.plates_seen |= 1 << index;
entry.last_seen = Instant::now();
}
let sink = self.sink.lock().expect("armor sink mutex poisoned").clone();
if let Some(sink) = sink {
sink(report);
}
}
/// 注入上报的消费者。
pub fn set_sink(&self, sink: ArmorSink) {
*self.sink.lock().expect("armor sink mutex poisoned") = Some(sink);
}
/// 场上的装甲板主板,按 id 排序。
///
/// 包含已经掉线的(界面要能把"掉线"显示出来),是否在线由 [`ArmorBoard::is_online`]
/// 判断。
pub fn boards(&self) -> Vec<ArmorBoard> {
let inner = self.inner.lock().expect("armor mutex poisoned");
let mut list: Vec<ArmorBoard> = inner.boards.values().cloned().collect();
list.sort_by_key(|b| b.id);
list
}
/// 某一块板子的最新血量,太旧就返回 `None`。
pub fn health_of_board(&self, board: u32) -> Option<u16> {
let inner = self.inner.lock().expect("armor mutex poisoned");
let entry = inner.boards.get(&board)?;
if entry.silence() > STALE_AFTER {
return None;
}
Some(entry.hp)
}
/// 四个格子的当前血量;只要有一格不新鲜就返回 `None`。
fn fresh_hp(&self, team: u8) -> Option<[u16; ARMOR_BOARDS]> {
let team = usize::from(team);
if team >= TEAMS {
return None;
}
let now = Instant::now();
let inner = self.inner.lock().expect("armor mutex poisoned");
let mut out = [0u16; ARMOR_BOARDS];
for (index, reading) in inner.readings[team].iter().enumerate() {
let reading = reading.as_ref()?;
if now.duration_since(reading.at) > STALE_AFTER {
return None;
}
out[index] = reading.hp;
}
Some(out)
}
}
impl crate::receiver::ArmorSource for ArmorHealthStore {
fn health(&self, team: u8) -> Option<[u16; ARMOR_BOARDS]> {
self.fresh_hp(team)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::receiver::ArmorSource;
fn from(ip: [u8; 4]) -> SocketAddr {
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::from(ip), 5601))
}
/// 主板一次广播四条,`hp` 相同。
fn tick(store: &ArmorHealthStore, board: u32, team: u8, hp: u16, ip: [u8; 4]) {
for index in 0..ARMOR_BOARDS as u8 {
let health = ArmorHealth { team, index, hp };
store.datagram(&health.encode_from(board), from(ip));
}
}
#[test]
fn one_broadcast_yields_four_plates_of_health() {
let store = ArmorHealthStore::new();
tick(&store, 0xAAAA_0001, 0, 100, [192, 168, 1, 50]);
let health = store.health(0).expect("all four plates are fresh");
assert_eq!(health, [100, 100, 100, 100]);
assert_eq!(store.health(1), None, "the other team sent nothing");
}
#[test]
fn a_single_missing_plate_falls_back_to_the_placeholder() {
let store = ArmorHealthStore::new();
// 只来三条:界面宁可说"未接入",也不要显示一个可能过期的血量。
for index in 0..3u8 {
let health = ArmorHealth {
team: 0,
index,
hp: 88,
};
store.datagram(&health.encode_from(0xAAAA_0001), from([192, 168, 1, 50]));
}
assert_eq!(store.health(0), None);
}
#[test]
fn boards_are_listed_by_their_mac_derived_id() {
let store = ArmorHealthStore::new();
tick(&store, 0xBBBB_0002, 1, 100, [192, 168, 1, 60]);
tick(&store, 0xAAAA_0001, 0, 100, [192, 168, 1, 50]);
let boards = store.boards();
let ids: Vec<u32> = boards.iter().map(|b| b.id).collect();
assert_eq!(ids, vec![0xAAAA_0001, 0xBBBB_0002], "sorted, stable order");
assert_eq!(boards[0].team, 0);
assert_eq!(boards[1].team, 1);
assert!(boards[0].reports_all_plates());
assert!(boards[0].is_online());
}
#[test]
fn health_of_board_ignores_out_of_range_and_garbage() {
let store = ArmorHealthStore::new();
// team/index 越界:丢掉并留一条警告,而不是写进 readings 把数组撑爆。
let bad = ArmorHealth {
team: 9,
index: 0,
hp: 100,
};
store.datagram(&bad.encode_from(1), from([192, 168, 1, 1]));
assert!(store.boards().is_empty());
// 不是装甲板上报的字节:安静跳过。
store.datagram(&[0u8; 64], from([192, 168, 1, 1]));
assert!(store.boards().is_empty());
}
#[test]
fn the_sink_sees_every_report_with_its_board_id() {
let store = ArmorHealthStore::new();
let seen = Arc::new(Mutex::new(Vec::new()));
let tap = Arc::clone(&seen);
store.set_sink(Arc::new(move |report: ArmorReport| {
tap.lock().unwrap().push((report.board, report.index, report.hp));
}));
tick(&store, 7, 0, 100, [192, 168, 1, 50]);
assert_eq!(seen.lock().unwrap().len(), ARMOR_BOARDS);
}
#[test]
fn a_falling_hp_is_exactly_one_hit() {
let mut tracker = HealthTracker::new();
let report = |hp: u16, index: u8| ArmorReport {
board: 1,
addr: "192.168.1.50:5601".parse().unwrap(),
team: 0,
index,
hp,
};
// 第一次上报只是建立基线,不是"刚刚被打死"。
for index in 0..4u8 {
assert_eq!(tracker.observe(&report(100, index)), None);
}
// 掉血:四条同值的上报里只有第一条该产生命中。
let hits: Vec<Option<Hit>> = (0..4u8).map(|i| tracker.observe(&report(97, i))).collect();
assert_eq!(
hits[0],
Some(Hit {
board: 1,
team: 0,
index: 0,
from: 100,
to: 97,
damage: 3,
})
);
assert!(
hits[1..].iter().all(Option::is_none),
"the other three datagrams carry the same hp and must not be hits"
);
}
#[test]
fn a_reset_is_not_a_hit() {
let mut tracker = HealthTracker::new();
let addr = "192.168.1.50:5601".parse().unwrap();
let report = |hp: u16| ArmorReport {
board: 1,
addr,
team: 0,
index: 0,
hp,
};
tracker.observe(&report(100));
assert!(tracker.observe(&report(0)).is_some(), "death is a hit");
assert_eq!(
tracker.observe(&report(100)),
None,
"长按 BOOT 重置成满血不是一次命中"
);
}
#[test]
fn a_board_first_seen_at_zero_hp_produces_no_hit() {
let mut tracker = HealthTracker::new();
let report = ArmorReport {
board: 1,
addr: "192.168.1.50:5601".parse().unwrap(),
team: 0,
index: 0,
hp: 0,
};
assert_eq!(tracker.observe(&report), None);
}
#[test]
fn two_boards_keep_separate_baselines() {
let mut tracker = HealthTracker::new();
let addr = "192.168.1.50:5601".parse().unwrap();
let report = |board: u32, hp: u16| ArmorReport {
board,
addr,
team: 0,
index: 0,
hp,
};
tracker.observe(&report(1, 100));
tracker.observe(&report(2, 100));
let hit = tracker.observe(&report(2, 90)).expect("board 2 was hit");
assert_eq!(hit.board, 2);
assert_eq!(hit.damage, 10);
assert_eq!(
tracker.observe(&report(1, 100)),
None,
"board 1 is untouched, not a 10-point heal"
);
}
}
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
//! cam-core —— 桌面端的非界面部分。
//!
//! 这一层不认识 Tauri,也不认识任何界面:它只负责
//!
//! - [`receiver`]:收 UDP、发现摄像头、按发送方重组 JPEG 帧;
//! - [`armor`]:收装甲板主板广播的血量,并列出场上有哪几块板子;
//! - [`stats`]:帧率统计(按帧数除以墙钟,不是对帧间隔取平均);
//! - [`config`]:磁盘上的配置(身份、绑定、HUD、密码哈希);
//! - [`password`]:密码的哈希与校验。
//!
//! # 为什么单独一层
//!
//! 旧的 Slint 客户端把这几件事和界面耦合在一个 crate 里,于是**它们只能通过跑起
//! 整个界面来验证**。抽出来之后,重组、发现、帧率窗口、血量新鲜度都能用单元测试
//! 直接盯住——而它们恰好是最容易错、错了又最难在现场看出来的部分。
//!
//! # 界面怎么拿数据
//!
//! 两条路,都是**推**而不是轮询:
//!
//! - 视频帧走 [`receiver::Receiver::set_frame_sink`]:接收线程重组好一张 JPEG 就
//! 交给 sink,由上层转成 WebSocket 的二进制消息。sink 必须是非阻塞的。
//! - 血量走 [`armor::ArmorHealthStore::set_sink`]:每收到一条上报就交给 sink。
//!
//! 同时两边的**最新值**都留在内存里([`receiver::LatestFrames`] 与
//! [`armor::ArmorHealthStore::boards`]),给"截图"和"设置界面里的下拉列表"用——
//! 那两处要的是当前状态,不是每一帧。
pub mod armor;
pub mod config;
pub mod password;
pub mod receiver;
pub mod slots;
pub mod snapshot;
pub mod stats;
pub use config::{Config, HudConfig, Role};
pub use receiver::{
ArmorSource, CameraInfo, FrameSink, LatestFrames, NoArmorYet, Receiver, SourceStats,
ARMOR_BOARDS,
};
pub use slots::SlotAllocator;
pub use stats::FpsWindow;
+148
View File
@@ -0,0 +1,148 @@
//! 密码:哈希、校验、强度下限。
//!
//! # 这把锁挡的是谁
//!
//! 必须说清楚,否则会被当成一把它并不是的锁:
//!
//! - **挡得住**:现场随手改绑定。选手想把自己的画面换到对手那块板子上,得先知道密码;
//! 围观的人翻不到明文密码(只有 Argon2id 哈希),也不能靠改配置文件把绑定改掉——
//! 改了之后界面会要求先解锁。
//! - **挡不住**:一个愿意在自己机器上重编这个客户端的人。配置文件与二进制都在他手上,
//! 删掉哈希、或者直接改源码都行。真正封住这件事要靠固件侧的配对 token(见根 README
//! 里"这挡不住什么"那一节)。
//!
//! 所以它的定位是**防误触与防随手改**,不是密码学意义上的对抗。这也是为什么强度下限
//! 只要 4 位:一个 8 位强密码在这里换不来任何实际的安全增益,却会让现场每次改绑都要
//! 掏手机查一遍。
//!
//! # 为什么是 Argon2 而不是 SHA-256
//!
//! 密码短(4 位数字只有 1 万种),任何**快**哈希都能在一秒内被穷举完。Argon2id 是
//! 内存硬的,把每次尝试的代价抬到几十毫秒——即使密码很短,"改配置文件暴力试密码"
//! 这条路也变得不划算。存的是标准的 PHC 字符串(`$argon2id$v=19$m=...`),参数跟着
//! 哈希一起躺在配置里,所以以后调参数不需要做迁移。
use anyhow::{anyhow, Result};
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use rand::RngCore;
/// 密码的最短长度。
///
/// 见模块文档:这不是安全边界,长度下限只用来挡住"密码是 1 个字符"这种显然是随手打的
/// 情况。
pub const MIN_PASSWORD_LEN: usize = 4;
/// 算一个密码的 PHC 字符串。
///
/// 每次调用都用新的随机盐,所以同一个密码两次得到的结果不同——这样哈希不能当查找表用。
pub fn hash_password(password: &str) -> Result<String> {
check_strength(password)?;
let mut salt = [0u8; 16];
rand::rng().fill_bytes(&mut salt);
let salt = SaltString::encode_b64(&salt)
.map_err(|err| anyhow!("could not encode the salt: {err}"))?;
// Argon2::default() 就是 Argon2id、v19、Params::DEFAULT(19 MiB,t=2)。
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| anyhow!("could not hash the password: {err}"))?;
Ok(hash.to_string())
}
/// 校验一个密码。
///
/// 返回 `Ok(false)` 表示"密码不对",返回 `Err` 表示"存下来的哈希本身坏了"——这两种
/// 必须区分开:前者是用户输错了,后者是配置文件被改坏了,界面上要给完全不同的提示。
pub fn verify_password(password: &str, phc: &str) -> Result<bool> {
let parsed = PasswordHash::new(phc)
.map_err(|err| anyhow!("the stored password hash is not a valid PHC string: {err}"))?;
// `password-hash` 的解析器很宽松:`$argon2id$broken` 这种**截断**的字符串也能解析
// 成功,只是解析出来的对象里没有盐、没有摘要。于是所有校验都失败,被 `is_ok()`
// 读成"密码错了"——现场表现是"密码明明是对的,却一直说密码错",然后所有人开始
// 怀疑键盘。所以这里显式要求盐与摘要都在。
if parsed.salt.is_none() || parsed.hash.is_none() {
return Err(anyhow!(
"the stored password hash is incomplete (no salt or no digest): {phc:?}"
));
}
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}
/// 检查长度下限。
pub fn check_strength(password: &str) -> Result<()> {
if password.chars().count() < MIN_PASSWORD_LEN {
return Err(anyhow!(
"密码至少 {MIN_PASSWORD_LEN} 位(现在是 {} 位)",
password.chars().count()
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_password_verifies_against_its_own_hash() {
let phc = hash_password("1234").expect("hashes");
assert!(verify_password("1234", &phc).expect("verifies"));
}
#[test]
fn a_wrong_password_is_false_not_an_error() {
let phc = hash_password("1234").expect("hashes");
assert!(!verify_password("1235", &phc).expect("the hash itself is fine"));
assert!(!verify_password("", &phc).expect("the hash itself is fine"));
}
#[test]
fn the_same_password_hashes_differently_every_time() {
// 随机盐:否则配置文件里的哈希就是一个可以拿去查表的指纹。
let a = hash_password("1234").expect("hashes");
let b = hash_password("1234").expect("hashes");
assert_ne!(a, b, "a fresh salt each time");
assert!(verify_password("1234", &a).unwrap());
assert!(verify_password("1234", &b).unwrap());
}
#[test]
fn the_hash_is_argon2id_and_carries_its_parameters() {
// 参数写在 PHC 字符串里,所以以后调参数不需要迁移旧配置。
let phc = hash_password("1234").expect("hashes");
assert!(phc.starts_with("$argon2id$"), "got {phc}");
assert!(phc.contains("$v=19$"), "got {phc}");
}
#[test]
fn a_corrupt_stored_hash_is_an_error_not_a_wrong_password() {
// 这两种情况必须能分开:一个是输错了,一个是配置文件坏了。
assert!(verify_password("1234", "not a phc string").is_err());
assert!(verify_password("1234", "$argon2id$broken").is_err());
}
#[test]
fn a_too_short_password_is_refused_at_set_time() {
assert!(hash_password("123").is_err());
assert!(hash_password("").is_err());
assert!(hash_password("1234").is_ok());
}
#[test]
fn a_non_ascii_password_works() {
let phc = hash_password("口令甲乙").expect("hashes");
assert!(verify_password("口令甲乙", &phc).unwrap());
assert!(!verify_password("口令甲丙", &phc).unwrap());
}
}
+764
View File
@@ -0,0 +1,764 @@
//! UDP 接收、发现、多路帧重组。
//!
//! # 多路
//!
//! 裁判终端同时收两块图传板,队伍终端也会收到两块(只是界面上只显示绑定的那一块)。
//! 所以重组状态**必须按发送方分开**——把两块板的帧塞进同一个重组器,会让它们各自的
//! `frame_id` 互相顶掉,两路都出不来。
//!
//! # 发现与订阅
//!
//! 两个方向各司其职:
//!
//! - 板子每 2 秒广播一次 [`protocol::MsgType::Hello`],客户端被动收到就知道有谁在线。
//! - 客户端广播 [`protocol::MsgType::Subscribe`],板子收到后开始定向推流。
//!
//! 板子靠**源地址**记住客户端,所以客户端只要周期性续订就能维持推流,不存在任何
//! 超时淘汰的竞态。
//!
//! # 丢帧策略
//!
//! 缺了分片的帧整个丢弃、下一帧顶上,不重传也不尝试补洞。对实时画面这是对的:等
//! 重传补齐时,那一帧早就不新鲜了。代价是丢帧率必须摆在界面上,否则"画面卡"没法
//! 区分是链路问题还是传感器问题。
//!
//! # 两道闸门,不是一个
//!
//! 旧客户端只有一道 `only_sender`:队伍端绑定了之后,连**发现**别人的板子都做不到,
//! 于是"改绑"只能靠手改配置文件或删文件。
//!
//! 现在绑定可以在界面上改(需密码),所以闸门分成两道:
//!
//! | 闸门 | 管什么 | 什么时候开 |
//! |---|---|---|
//! | `only_sender` | 帧的重组与投递 | 队伍端始终是绑定的那一块 |
//! | `discovery_all` | 是否记录**其它**板子的 Hello | 只在密码解锁后的设置界面里打开 |
//!
//! 这是有意的加强而不是放松:设置界面需要列出候选摄像头(否则无从选起),但**画面
//! 不需要**——对手的板子即使把流推到本机端口、即使被记进发现列表,它的帧仍然进不了
//! 重组器。旧版把两件事绑在一道闸门上,只能在"能列候选"和"挡住对手"之间二选一。
//!
//! # 可测性
//!
//! 数据报的处理([`Receiver::datagram`])与 socket 完全分开:测试直接喂字节进去,
//! 不需要绑 5600 端口,也就不需要真实网络。
use std::collections::HashMap;
use std::io::ErrorKind;
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use protocol::{
Fragment, FrameAssembler, Subscribe, SubscribeRole, DEFAULT_PORT, HEADER_LEN,
};
/// 接受的最大数据报。高于协议的理论上限(1427),留出余量,这样一个被截断的读只可能
/// 来自网络,而不是来自过小的缓冲。
const RECV_BUFFER: usize = 2048;
/// 重新广播订阅的间隔。
///
/// 比板子判定"客户端掉线"的超时(6 秒)短得多,所以偶发丢一两个包不会中断推流。
const SUBSCRIBE_INTERVAL: Duration = Duration::from_millis(800);
/// 一张帧没收齐就放弃的时限。
///
/// 一帧在 22fps 下约 45ms 到,所以几百毫秒还没齐的永远不会齐了。
const FRAME_TIMEOUT: Duration = Duration::from_millis(500);
/// 一块已知的图传板。
#[derive(Debug, Clone)]
pub struct CameraInfo {
pub id: u32,
pub addr: SocketAddrV4,
pub firmware: u16,
/// 最近一次收到它广播的时刻。
pub last_seen: Instant,
}
impl CameraInfo {
/// 多久没有广播了。
pub fn silence(&self) -> Duration {
self.last_seen.elapsed()
}
/// 板子在线吗?
///
/// 阈值是广播间隔的数倍,所以偶发丢包不会让界面上的在线状态闪烁。
pub fn is_online(&self) -> bool {
self.silence() < Duration::from_secs(7)
}
}
/// 一块板的运行时统计。
#[derive(Debug, Clone, Copy, Default)]
pub struct SourceStats {
pub frames_shown: u64,
/// 因分片缺失被放弃的帧。
pub frames_dropped: u64,
pub datagrams: u64,
pub last_frame_len: usize,
pub width: u16,
pub height: u16,
/// 上一帧到这一帧之间的间隔。
pub last_interval: Option<Duration>,
last_shown_at: Option<Instant>,
}
impl SourceStats {
/// 丢弃的帧占已收到帧的比例。
pub fn loss_percent(&self) -> f32 {
let total = self.frames_shown + self.frames_dropped;
if total == 0 {
0.0
} else {
self.frames_dropped as f32 * 100.0 / total as f32
}
}
/// 上一帧到这一帧的间隔,毫秒。
pub fn gap_ms(&self) -> Option<f32> {
self.last_interval.map(|d| d.as_secs_f32() * 1000.0)
}
fn on_frame(&mut self, width: u16, height: u16, len: usize) {
self.last_interval = self.last_shown_at.map(|t| t.elapsed());
self.last_shown_at = Some(Instant::now());
self.frames_shown += 1;
self.width = width;
self.height = height;
self.last_frame_len = len;
}
}
/// 每一路的**最新**一帧。
///
/// 每路只保留最新的一张:这张图有两个用途,都不需要历史——截图(存盘的那一张应该
/// 是按下按钮时看到的画面)和慢速客户端的补帧。真正的实时画面走
/// [`Receiver::set_frame_sink`] 的推送路径,不从这里取。
///
/// 用 `Arc` 而不是 `Vec<u8>`:同一张帧要同时给 sink 和这个缓存,克隆一个 22 kB 的
/// 缓冲区是每秒 22 次不必要的分配。
#[derive(Default)]
pub struct LatestFrames {
inner: Mutex<HashMap<u32, Arc<protocol::Frame>>>,
}
impl LatestFrames {
pub fn put(&self, frame: Arc<protocol::Frame>) {
self.inner
.lock()
.expect("latest-frames mutex poisoned")
.insert(frame.sender_id, frame);
}
/// 取某一路的最新帧(克隆的是 `Arc`,不是 JPEG)。
pub fn latest(&self, sender_id: u32) -> Option<Arc<protocol::Frame>> {
self.inner
.lock()
.expect("latest-frames mutex poisoned")
.get(&sender_id)
.cloned()
}
/// 所有已知来源的最新帧,按 id 排序,让界面上的顺序稳定。
pub fn latest_all(&self) -> Vec<Arc<protocol::Frame>> {
let mut list: Vec<Arc<protocol::Frame>> = self
.inner
.lock()
.expect("latest-frames mutex poisoned")
.values()
.cloned()
.collect();
list.sort_by_key(|f| f.sender_id);
list
}
}
/// 帧的消费者。
///
/// 在接收线程里被调用,所以**必须是非阻塞的**:一次 `send` 到一个有界广播通道、
/// 一次 `try_send`,都可以;写文件、等锁、发网络请求都不行——那会把接收循环拖慢,
/// 表现为链路上本不存在的丢帧。
pub type FrameSink = Arc<dyn Fn(Arc<protocol::Frame>) + Send + Sync>;
/// 所有板子的共享状态,跨线程只读。
pub struct Discovery {
cameras: Mutex<HashMap<u32, CameraInfo>>,
stats: Mutex<HashMap<u32, SourceStats>>,
}
impl Discovery {
fn new() -> Self {
Self {
cameras: Mutex::new(HashMap::new()),
stats: Mutex::new(HashMap::new()),
}
}
/// 当前已知的板子,按 id 排序。
pub fn cameras(&self) -> Vec<CameraInfo> {
let mut list: Vec<CameraInfo> = self
.cameras
.lock()
.expect("cameras mutex poisoned")
.values()
.cloned()
.collect();
list.sort_by_key(|c| c.id);
list
}
/// 在线且刚刚广播过的板子。
pub fn online_cameras(&self) -> Vec<CameraInfo> {
self.cameras()
.into_iter()
.filter(CameraInfo::is_online)
.collect()
}
/// 某一路的统计。
pub fn stats(&self, sender_id: u32) -> SourceStats {
self.stats
.lock()
.expect("stats mutex poisoned")
.get(&sender_id)
.copied()
.unwrap_or_default()
}
/// 只刷新存活时间戳,不改地址或固件版本。
///
/// 收到视频帧时调用:正在推流本身就是最可靠的存活证据。
fn touch(&self, id: u32) {
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
if let Some(info) = cameras.get_mut(&id) {
info.last_seen = Instant::now();
}
}
fn note_hello(&self, id: u32, addr: SocketAddrV4, firmware: u16) -> bool {
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
match cameras.get_mut(&id) {
Some(info) => {
info.addr = addr;
info.last_seen = Instant::now();
info.firmware = firmware;
false
}
None => {
cameras.insert(
id,
CameraInfo {
id,
addr,
firmware,
last_seen: Instant::now(),
},
);
true
}
}
}
fn note_frame(&self, id: u32, width: u16, height: u16, len: usize) {
let mut stats = self.stats.lock().expect("stats mutex poisoned");
stats.entry(id).or_default().on_frame(width, height, len);
}
fn count(&self, id: u32, f: impl FnOnce(&mut SourceStats)) {
let mut stats = self.stats.lock().expect("stats mutex poisoned");
f(stats.entry(id).or_default());
}
/// 超过 `max_idle` 没有广播的板子从列表里移除。
///
/// 界面上不会留下一堆永远离线的板子。
pub fn forget_silent_cameras(&self, max_idle: Duration) {
self.cameras
.lock()
.expect("cameras mutex poisoned")
.retain(|_, c| c.silence() < max_idle);
}
}
/// 客户端身份。
///
/// 常量而非 MAC 派生:队伍客户端和裁判终端各自唯一,进程内也不需要区分自己,而一个
/// 确定的常量让日志更好读。
pub const CLIENT_ID: u32 = 0x0C11_0001;
/// 接收循环的可变状态:只由接收线程在跑循环时访问。
struct Ingest {
/// 每个发送方一个重组器。
assemblers: HashMap<u32, FrameAssembler>,
/// 只处理这一块板子的**帧**,`None` 表示全都要(裁判端)。
only_sender: Option<u32>,
/// 是否记录**其它**板子的 Hello。
discovery_all: bool,
}
/// 接收器。
///
/// 构造([`Receiver::new`])不碰网络,`run` 才需要 socket。这样数据报处理的逻辑可以
/// 在没有网络的情况下被单元测试直接喂字节。
pub struct Receiver {
discovery: Arc<Discovery>,
latest: Arc<LatestFrames>,
/// 帧的非阻塞消费者,由上层注入。
sink: Mutex<Option<FrameSink>>,
ingest: Mutex<Ingest>,
/// 身份可以运行时切换(同一个进程里从裁判端切到用户端),所以订阅里声明的角色
/// 也得能改——它决定板子要不要给这个客户端推流。
role: Mutex<SubscribeRole>,
camera: Ipv4Addr,
stop: Arc<AtomicBool>,
}
impl Receiver {
/// 建一个接收器,但先不绑端口。
///
/// `only_sender` 为 `Some` 时只接受那一块板子的帧。
pub fn new(camera: Ipv4Addr, role: SubscribeRole, only_sender: Option<u32>) -> Self {
Self {
discovery: Arc::new(Discovery::new()),
latest: Arc::new(LatestFrames::default()),
sink: Mutex::new(None),
ingest: Mutex::new(Ingest {
assemblers: HashMap::new(),
only_sender,
discovery_all: false,
}),
role: Mutex::new(role),
camera,
stop: Arc::new(AtomicBool::new(false)),
}
}
/// 绑定视频端口并准备好发现。
///
/// 绑定固定端口(而非临时端口)是必须的:板子是从订阅的**源端口**学到推流目标的。
/// 绑定在这里而不是在接收线程里,这样"端口被占用"会立刻报错,而不是被线程吞掉、
/// 表现为一个永远不出画面的空窗口。
pub fn start(
camera: Ipv4Addr,
role: SubscribeRole,
only_sender: Option<u32>,
) -> std::io::Result<(Self, UdpSocket)> {
let socket =
UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?;
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
let broadcast = socket.set_broadcast(true).is_ok();
if !broadcast {
log::warn!("could not enable broadcast on the socket");
}
Ok((Self::new(camera, role, only_sender), socket))
}
/// 绑定之后切换限制目标,或解除限制。
///
/// 队伍端在设置界面里改绑一块板子时调用。
pub fn restrict_to(&self, only_sender: Option<u32>) {
self.ingest
.lock()
.expect("ingest mutex poisoned")
.only_sender = only_sender;
}
/// 当前限制到哪一块板子。
pub fn sender_limit(&self) -> Option<u32> {
self.ingest.lock().expect("ingest mutex poisoned").only_sender
}
/// 切换身份,下一次续订就生效(800 ms 内)。
pub fn set_role(&self, role: SubscribeRole) {
*self.role.lock().expect("role mutex poisoned") = role;
}
/// 当前在订阅里声明的身份。
pub fn role(&self) -> SubscribeRole {
*self.role.lock().expect("role mutex poisoned")
}
/// 是否允许发现列表里出现**其它**板子。
///
/// 只在密码解锁后的设置界面里打开,见本模块的文档。
pub fn set_discovery_all(&self, all: bool) {
self.ingest
.lock()
.expect("ingest mutex poisoned")
.discovery_all = all;
}
/// 注入帧的消费者。重复调用会替换掉旧的。
pub fn set_frame_sink(&self, sink: FrameSink) {
*self.sink.lock().expect("sink mutex poisoned") = Some(sink);
}
pub fn discovery(&self) -> Arc<Discovery> {
Arc::clone(&self.discovery)
}
pub fn latest_frames(&self) -> Arc<LatestFrames> {
Arc::clone(&self.latest)
}
/// 处理一个数据报。测试从这里喂字节。
pub fn datagram(&self, bytes: &[u8], from: SocketAddr) {
if bytes.len() < HEADER_LEN {
return;
}
let (limit, discovery_all) = {
let ingest = self.ingest.lock().expect("ingest mutex poisoned");
(ingest.only_sender, ingest.discovery_all)
};
// 板子的在线通告。注意这里不看来源端口:广播来的源端口没有意义,身份由头里
// 的 sender_id 决定。
if let Some(hello) = protocol::Hello::decode(bytes) {
let SocketAddr::V4(v4) = from else {
return;
};
// 限制生效且没开发现:别人的板子连记都不记。不记的理由不只是"不显示"——
// 记进 discovery 之后,界面上任何一处遍历 cameras() 的地方都会间接泄露
// 场上还有谁。
if let Some(only) = limit {
if !discovery_all && hello.sender_id != only {
return;
}
}
if self.discovery.note_hello(hello.sender_id, v4, hello.firmware) {
log::info!("found camera 0x{:08X} at {v4}", hello.sender_id);
}
return;
}
let Some(frag) = Fragment::decode(bytes) else {
// 既不是 Hello 也不是合法分片:将来的其它消息,或一次损坏。
return;
};
let id = frag.header.sender_id;
// 帧的重组与投递过的是**另一道**闸门:发现列表里能看到对手的板子,不等于
// 它的画面会进来。
if let Some(only) = limit {
if id != only {
return;
}
}
// 视频帧也算存活证据。
//
// `is_online` 看的是 `last_seen`,而它此前只由 Hello 更新。于是会出现一个
// 荒唐的状态:画面在每秒刷 22 帧,状态栏却显示"离线"(橙色)。正在推流的板子
// 比广播更可靠地证明自己活着。
self.discovery.touch(id);
let mut ingest = self.ingest.lock().expect("ingest mutex poisoned");
let assembler = ingest
.assemblers
.entry(id)
.or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT));
match assembler.push(frag) {
Ok(frame) => {
drop(ingest);
self.discovery.count(id, |s| s.datagrams += 1);
self.discovery
.note_frame(id, frame.width, frame.height, frame.data.len());
let frame = Arc::new(frame);
self.latest.put(Arc::clone(&frame));
let sink = self.sink.lock().expect("sink mutex poisoned").clone();
if let Some(sink) = sink {
sink(frame);
}
}
Err(protocol::DropReason::Superseded) => {
// 被更新的帧追上了。这不是丢帧:半截的那张只是被超越,在健康链路上
// 这是帧被放弃的**正常**方式。
self.discovery.count(id, |s| s.datagrams += 1);
}
Err(protocol::DropReason::TimedOut) => {
self.discovery.count(id, |s| {
s.datagrams += 1;
s.frames_dropped += 1;
});
}
}
}
/// 放弃等太久的半截帧。接收循环周期性调用。
pub fn expire_stalled_frames(&self) {
let mut ingest = self.ingest.lock().expect("ingest mutex poisoned");
let mut expired = Vec::new();
for (id, assembler) in ingest.assemblers.iter_mut() {
if assembler.expire().is_some() {
expired.push(*id);
}
}
drop(ingest);
for id in expired {
self.discovery.count(id, |s| s.frames_dropped += 1);
}
}
/// 运行接收循环,直到 [`Receiver::shutdown`]。
///
/// 阻塞,所以放在单独的线程上。
pub fn run(&self, socket: UdpSocket) {
let target = SocketAddrV4::new(self.camera, DEFAULT_PORT);
let mut buf = [0u8; RECV_BUFFER];
let mut last_subscribe = Instant::now() - SUBSCRIBE_INTERVAL;
let mut last_sweep = Instant::now();
log::info!(
"listening on UDP {DEFAULT_PORT}, announcing {:?} to {target}",
self.role()
);
while !self.stop.load(Ordering::Relaxed) {
// 周期性续订就是保活:板子在 6 秒收不到就认为我们走了。
//
// 每次现编订阅包而不是循环外编一次:身份可以运行时切换,编一次的话切换后
// 板子收到的还是旧角色。800 ms 一次,这点分配无所谓。
if last_subscribe.elapsed() >= SUBSCRIBE_INTERVAL {
let subscribe = Subscribe {
role: self.role(),
client_id: CLIENT_ID,
};
if let Err(err) = socket.send_to(&subscribe.encode(), target) {
log::warn!("could not subscribe at {target}: {err}");
}
last_subscribe = Instant::now();
}
match socket.recv_from(&mut buf) {
Ok((n, from)) => self.datagram(&buf[..n], from),
Err(err)
if err.kind() == ErrorKind::WouldBlock
|| err.kind() == ErrorKind::TimedOut =>
{
// 帧之间的常态。读超时正是让退出能及时响应的原因。
}
Err(err) => {
log::warn!("recv failed: {err}");
thread::sleep(Duration::from_millis(100));
}
}
self.expire_stalled_frames();
if last_sweep.elapsed() >= Duration::from_secs(5) {
self.discovery
.forget_silent_cameras(Duration::from_secs(60));
last_sweep = Instant::now();
}
}
log::info!("receive loop stopped");
}
pub fn shutdown(&self) {
self.stop.store(true, Ordering::Relaxed);
}
}
/// 装甲板血量的来源。
///
/// 真实实现是 [`crate::armor::ArmorHealthStore`]。这里只定接口,解码与新鲜度判断都在
/// 那个模块里——也让"没有数据源"这件事有一个明确的替身([`NoArmorYet`]),而不是
/// 让界面去处理 `Option<Option<...>>`。
pub trait ArmorSource: Send + Sync {
/// 某一边的血量。
///
/// 返回 4 个装甲板的当前血量,按 index 排列。没有数据时返回 `None`,界面据此显示
/// 占位符而不是 0——0 是一个看起来像真值的假值。
fn health(&self, team: u8) -> Option<[u16; 4]>;
}
/// 一边的装甲板数量。
///
/// RoboMaster 规则是每队 4 块。界面按这个数量画格子,所以数据还没接上时也能看到位置
/// ——空着比画 0 个格子更让人以为布局坏了。
pub const ARMOR_BOARDS: usize = 4;
/// 没有数据源时的替身:什么都不提供,界面显示占位符。
pub struct NoArmorYet;
impl ArmorSource for NoArmorYet {
fn health(&self, _team: u8) -> Option<[u16; 4]> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use protocol::{fragment_frame, Hello, MsgType};
/// 一个完整的 JPEG 分片集合,足够让重组器交出一张帧。
fn jpeg() -> Vec<u8> {
(0..5000u32).map(|i| (i % 251) as u8).collect()
}
fn from(ip: [u8; 4]) -> SocketAddr {
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::from(ip), 5600))
}
fn feed_frame(rx: &Receiver, sender: u32, frame_id: u32, from_ip: [u8; 4]) {
for frag in fragment_frame(sender, frame_id, &jpeg(), 640, 480) {
rx.datagram(&frag.encode(), from(from_ip));
}
}
fn feed_hello(rx: &Receiver, sender: u32, from_ip: [u8; 4]) {
let hello = Hello {
sender_id: sender,
firmware: 7,
};
rx.datagram(&hello.encode(MsgType::Hello), from(from_ip));
}
#[test]
fn delivers_the_latest_frame_of_the_bound_sender_only() {
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Team, Some(1));
feed_frame(&rx, 1, 10, [192, 168, 1, 10]);
feed_frame(&rx, 2, 20, [192, 168, 1, 20]);
assert!(
rx.latest_frames().latest(1).is_some(),
"the bound camera must come through"
);
assert!(
rx.latest_frames().latest(2).is_none(),
"an opponent's board must not even be reassembled"
);
}
#[test]
fn the_sink_sees_exactly_the_frames_the_gate_allows() {
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Team, Some(1));
let seen = Arc::new(Mutex::new(Vec::new()));
let tap = Arc::clone(&seen);
rx.set_frame_sink(Arc::new(move |frame: Arc<protocol::Frame>| {
tap.lock().unwrap().push(frame.sender_id);
}));
feed_frame(&rx, 1, 1, [192, 168, 1, 10]);
feed_frame(&rx, 2, 2, [192, 168, 1, 20]);
assert_eq!(*seen.lock().unwrap(), vec![1]);
}
#[test]
fn discovery_can_be_opened_without_opening_the_video_gate() {
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Team, Some(1));
// 设置界面没开:别人的 Hello 不记。
feed_hello(&rx, 2, [192, 168, 1, 20]);
assert!(
rx.discovery().cameras().is_empty(),
"locked down: other boards are not even recorded"
);
// 设置界面解锁后列出候选。
rx.set_discovery_all(true);
feed_hello(&rx, 2, [192, 168, 1, 20]);
let ids: Vec<u32> = rx.discovery().cameras().iter().map(|c| c.id).collect();
assert_eq!(ids, vec![2], "the candidate list is what the dropdown needs");
// 但画面仍然进不来。这正是把闸门分成两道的目的。
feed_frame(&rx, 2, 5, [192, 168, 1, 20]);
assert!(rx.latest_frames().latest(2).is_none());
}
#[test]
fn rebinding_moves_the_gate() {
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Team, Some(1));
feed_frame(&rx, 1, 1, [192, 168, 1, 10]);
rx.restrict_to(Some(2));
feed_frame(&rx, 1, 2, [192, 168, 1, 10]);
feed_frame(&rx, 2, 3, [192, 168, 1, 20]);
assert_eq!(rx.sender_limit(), Some(2));
assert!(
rx.latest_frames().latest(1).is_some(),
"the old frame stays cached; nothing pretends it never arrived"
);
assert!(rx.latest_frames().latest(2).is_some());
}
#[test]
fn an_unrestricted_receiver_takes_everything() {
// 裁判端的形态。
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Referee, None);
feed_hello(&rx, 1, [192, 168, 1, 10]);
feed_hello(&rx, 2, [192, 168, 1, 20]);
feed_frame(&rx, 1, 1, [192, 168, 1, 10]);
feed_frame(&rx, 2, 1, [192, 168, 1, 20]);
assert_eq!(rx.discovery().cameras().len(), 2);
assert_eq!(rx.latest_frames().latest_all().len(), 2);
}
#[test]
fn a_streaming_board_counts_as_online_without_a_hello() {
// 曾经出现过"画面在刷新、状态栏却显示离线"的状态:is_online 只看 Hello。
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Referee, None);
feed_hello(&rx, 1, [192, 168, 1, 10]);
// 让发现列表先知道它,再把它的 last_seen 交给 touch。
feed_frame(&rx, 1, 1, [192, 168, 1, 10]);
let camera = rx.discovery().cameras().pop().expect("known");
assert_eq!(camera.id, 1);
assert!(camera.is_online());
}
#[test]
fn the_same_frame_id_from_two_boards_does_not_collide() {
// 两块板的 frame_id 各自从 0 开始,混用一个重组器会互相顶掉。
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Referee, None);
feed_frame(&rx, 1, 3, [192, 168, 1, 10]);
feed_frame(&rx, 2, 3, [192, 168, 1, 20]);
assert_eq!(rx.latest_frames().latest(1).map(|f| f.frame_id), Some(3));
assert_eq!(rx.latest_frames().latest(2).map(|f| f.frame_id), Some(3));
}
#[test]
fn a_truncated_datagram_is_ignored_instead_of_panicking() {
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Referee, None);
rx.datagram(&[0u8; 3], from([192, 168, 1, 10]));
rx.datagram(&[], from([192, 168, 1, 10]));
assert!(rx.discovery().cameras().is_empty());
}
}
+188
View File
@@ -0,0 +1,188 @@
//! 裁判端两个槽位对应哪两块摄像头。
//!
//! # 规则
//!
//! 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,界面才能说清在等谁"
);
}
}
+511
View File
@@ -0,0 +1,511 @@
//! 推给界面的快照类型。
//!
//! # 为什么单独一个模块
//!
//! 这些结构体的字段名就是**界面看到的 JSON 字段名**,所以它们是接口的一部分,值得
//! 集中放、集中改。放在这里而不是 Tauri 层,是因为其中几个的构造规则(比如"关掉
//! 诊断时不留数字")需要单元测试。
//!
//! # 为什么 id 同时给数字和文本
//!
//! 界面显示、日志、配置文件里用的都是 `0x3D4D8A9A` 这种十六进制形式。让 Rust 侧拼好
//! (`id_text`) 而不是让前端各处自己 `toString(16)`,可以避免"某处少补了前导零"这种
//! 只能靠肉眼比对才发现的不一致。
use serde::Serialize;
use crate::armor::ArmorBoard;
use crate::config::{Config, CrosshairConfig, GridConfig, HealthConfig, HitFeedbackConfig, Role};
use crate::receiver::{CameraInfo, SourceStats};
/// 把 id 写成界面上、配置文件里、抓包时都一致的形式。
pub fn id_text(id: u32) -> String {
format!("0x{id:08X}")
}
/// 一块图传板。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CameraDto {
pub id: u32,
pub id_text: String,
pub addr: String,
pub online: bool,
pub silence_ms: u64,
pub firmware: u16,
}
impl From<&CameraInfo> for CameraDto {
fn from(camera: &CameraInfo) -> Self {
Self {
id: camera.id,
id_text: id_text(camera.id),
addr: camera.addr.ip().to_string(),
online: camera.is_online(),
silence_ms: camera.silence().as_millis() as u64,
firmware: camera.firmware,
}
}
}
/// 一块装甲板主板。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ArmorBoardDto {
pub id: u32,
pub id_text: String,
pub addr: String,
pub online: bool,
/// 板子自报的队伍(编译期定的 `ARMOR_TEAM`)。
pub team: u8,
/// 最后一次读数。整车一条血,所以这里一个数。
pub hp: u16,
pub plates_seen: u8,
pub reports_all_plates: bool,
pub silence_ms: u64,
}
impl From<&ArmorBoard> for ArmorBoardDto {
fn from(board: &ArmorBoard) -> Self {
Self {
id: board.id,
id_text: id_text(board.id),
addr: board.addr.ip().to_string(),
online: board.is_online(),
team: board.team,
hp: board.hp,
plates_seen: board.plates_seen.count_ones() as u8,
reports_all_plates: board.reports_all_plates(),
silence_ms: board.silence().as_millis() as u64,
}
}
}
/// 一路画面的读数。
///
/// # 关掉诊断时这些数字**不存在**,而不是被界面藏起来
///
/// 旧客户端在队伍端 release 下把这些字段填成空字符串。现在换成数字,规则不变:非诊断
/// 模式下它们被置零/置空。由编译模式决定而不是命令行参数——命令行开关意味着二进制里
/// 带着那套东西,忘开一次就泄露了。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CameraStatsDto {
pub camera: u32,
pub camera_text: String,
pub online: bool,
/// 界面直接显示这一行,所以"在线但还没画面"与"离线"的区别由这里给出,而不是让
/// 前端从 online + hasFrames 猜。
pub status: String,
pub has_frames: bool,
pub fps: f32,
pub width: u16,
pub height: u16,
pub frames_shown: u64,
pub frames_dropped: u64,
pub loss_percent: f32,
pub datagrams: u64,
pub last_frame_bytes: usize,
pub gap_ms: Option<f32>,
}
impl CameraStatsDto {
/// `diagnostics` 为 false 时不产出技术数字(见类型文档)。
pub fn build(
diagnostics: bool,
camera: u32,
online: bool,
stats: &SourceStats,
fps: f32,
) -> Self {
let has_frames = stats.frames_shown > 0;
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
// 比让人以为板子挂了诚实。
//
// 有画面时显示 id —— 这对调试有用。队伍端 release 下这一路只有自己绑定的板子,
// 显示它不构成泄露(界面上根本没有别人的板子可选)。
let status = if !has_frames {
if online {
"已连接,等待画面…".to_string()
} else {
"离线".to_string()
}
} else {
id_text(camera)
};
if !diagnostics {
return Self {
camera,
camera_text: id_text(camera),
online,
status,
has_frames,
fps: 0.0,
width: 0,
height: 0,
frames_shown: 0,
frames_dropped: 0,
loss_percent: 0.0,
datagrams: 0,
last_frame_bytes: 0,
gap_ms: None,
};
}
// 没出过一个字节的源,`stats.width`/`height` 还是默认值 0,于是界面会显示成
// "0x0"——一个看起来像分辨率的假分辨率。用 has_frames 把它挡掉。
Self {
camera,
camera_text: id_text(camera),
online,
status,
has_frames,
fps: if has_frames { fps } else { 0.0 },
width: if has_frames { stats.width } else { 0 },
height: if has_frames { stats.height } else { 0 },
frames_shown: stats.frames_shown,
frames_dropped: stats.frames_dropped,
loss_percent: stats.loss_percent(),
datagrams: stats.datagrams,
last_frame_bytes: stats.last_frame_len,
gap_ms: stats.gap_ms(),
}
}
}
/// 一边的血量。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TeamHealthDto {
pub team: u8,
pub name: String,
/// 有没有可信的数据。false 时界面显示 `placeholder` 而不是 0——0 是一个看起来像
/// 真值的假值,现场看到 0 会以为对方已经没血了。
pub available: bool,
pub placeholder: String,
/// 整车血量(四条装甲板同值,所以取第一条)。
pub hp: Option<u16>,
/// 四条装甲板各自的读数;`available` 为 true 时有效。
pub hps: Vec<u16>,
/// 绑定的装甲板主板 id(用户端)/ 分配到的(裁判端)。
pub board: Option<u32>,
pub board_text: Option<String>,
/// 血量偏低或已归零。
pub critical: bool,
}
impl TeamHealthDto {
/// 还没有数据源。
pub fn absent(team: u8, board: Option<u32>) -> Self {
Self {
team,
name: team_name(team),
available: false,
placeholder: format!(
"装甲板未接入:等主板在 UDP {} 上广播血量",
protocol::ARMOR_PORT
),
hp: None,
// 依然给出 4 个格子:界面用 `available` 决定颜色,空数组会让占位框整个消失,
// 看起来像布局出错,而不是像"还没有数据"。
hps: vec![0; crate::receiver::ARMOR_BOARDS],
board,
board_text: board.map(id_text),
critical: false,
}
}
/// 有数据。
pub fn present(team: u8, board: Option<u32>, hps: [u16; crate::receiver::ARMOR_BOARDS], low: u16) -> Self {
let hp = hps.first().copied().unwrap_or(0);
Self {
team,
name: team_name(team),
available: true,
placeholder: String::new(),
hp: Some(hp),
hps: hps.to_vec(),
board,
board_text: board.map(id_text),
critical: hp == 0 || hp <= low,
}
}
}
/// 队伍名。
pub fn team_name(team: u8) -> String {
match team {
0 => "队伍一".to_string(),
_ => "队伍二".to_string(),
}
}
/// 一次掉血(由 [`crate::armor::HealthTracker`] 派生)。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HitDto {
pub board: u32,
pub team: u8,
pub index: u8,
pub from: u16,
pub to: u16,
pub damage: u16,
/// Unix 毫秒。前端用它驱动闪红/抖动的动画,不需要自己计时。
pub at_ms: u64,
}
/// 录制状态。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordingDto {
pub camera: u32,
pub camera_text: String,
pub active: bool,
pub path: Option<String>,
/// 告诉 ffmpeg 的输入帧率。回放快放/慢放时,第一个要看的就是这个数。
pub input_fps: f32,
pub frames_written: u64,
/// 因为写入端跟不上而丢掉的帧。丢帧不报出来的话,回放里"少了半秒"会看起来像
/// 相机的问题。
pub frames_dropped: u64,
pub started_ms: Option<u64>,
pub error: Option<String>,
}
/// 裁判端两个槽位。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SlotsDto {
/// 左槽位 → 摄像头 id。
pub camera_a: Option<u32>,
pub camera_b: Option<u32>,
/// 左槽位 → 装甲板主板 id。
pub armor_a: Option<u32>,
pub armor_b: Option<u32>,
pub manual: bool,
}
/// 推给界面的完整状态。
///
/// 一次性给全,而不是让界面按需多次查询:5 Hz 下这条消息只有几百字节,而"界面各处
/// 分别拉数据"会引入一类难查的问题——两个面板显示的是不同时刻的状态(比如绑定改了
/// 一半)。前端因此只需要一个 reducer。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Snapshot {
pub role: Role,
pub team: u8,
pub mirrored: bool,
/// 演示模式:画面与血量是本地合成的,不是场上的板子。界面必须把它显示出来,
/// 否则截图与验收会被误读成真实链路。
pub demo: bool,
/// 是否产出技术读数(帧率/丢帧)。用户端 release 下为 false。
pub diagnostics: bool,
/// 密码闸门是否已经打开(会话内)。
pub unlocked: bool,
/// 发现列表是否放开(设置面板打开时)。开着才可能列出别人的板子。
pub discovery_open: bool,
pub uptime_ms: u64,
/// 连着这个后端的界面数。
pub clients: usize,
pub config: ConfigView,
pub cameras: Vec<CameraDto>,
pub armor_boards: Vec<ArmorBoardDto>,
/// 每块已知摄像头的读数。
pub stats: Vec<CameraStatsDto>,
/// 裁判端两个槽位。用户端的 `a` 就是它绑定的那一块。
pub slots: SlotsDto,
/// 用户端一条(自己那边),裁判端两条。
pub health: Vec<TeamHealthDto>,
pub recordings: Vec<RecordingDto>,
/// 启动时的说明(旧配置迁移、端口占用等)。
pub notes: Vec<String>,
}
/// 配置里可以给界面看的那部分。
///
/// **不含密码哈希**:界面只需要知道"设过没有",把 PHC 字符串送进 WebView 没有任何用处,
/// 只是多一个能被日志、崩溃报告带走的东西。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigView {
pub role: Role,
/// 身份是否已由人选过。false 时界面先问一次(见 `Config::role_chosen`)。
pub role_chosen: bool,
pub team: u8,
pub camera_id: Option<u32>,
pub camera_id_text: Option<String>,
pub armor_id: Option<u32>,
pub armor_id_text: Option<String>,
pub mirror: bool,
pub password_set: bool,
pub hud: HudView,
pub recording_dir: String,
pub ffmpeg: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HudView {
pub health: HealthConfig,
pub crosshair: CrosshairConfig,
pub grid: GridConfig,
pub hit_feedback: HitFeedbackConfig,
pub show_zoom: bool,
pub show_stats: bool,
}
impl From<&Config> for ConfigView {
fn from(config: &Config) -> Self {
Self {
role: config.role,
role_chosen: config.role_chosen,
team: config.team,
camera_id: config.camera_id,
camera_id_text: config.camera_id.map(id_text),
armor_id: config.armor_id,
armor_id_text: config.armor_id.map(id_text),
mirror: config.mirror,
password_set: config.password.is_some(),
hud: HudView {
health: config.hud.health.clone(),
crosshair: config.hud.crosshair.clone(),
grid: config.hud.grid.clone(),
hit_feedback: config.hud.hit_feedback.clone(),
show_zoom: config.hud.show_zoom,
show_stats: config.hud.show_stats,
},
recording_dir: config.recordings_dir().display().to_string(),
ffmpeg: config.recording.ffmpeg.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::receiver::SourceStats;
fn stats() -> SourceStats {
let mut stats = SourceStats::default();
// 直接构造:SourceStats 的字段是 pub 的,只有 last_shown_at 是私有的。
stats.frames_shown = 440;
stats.frames_dropped = 10;
stats.datagrams = 8000;
stats.width = 640;
stats.height = 480;
stats.last_frame_len = 22_000;
stats
}
#[test]
fn diagnostics_off_leaves_no_numbers_behind() {
// 不是"界面藏起来",是**不存在**。
let dto = CameraStatsDto::build(false, 0x3D4D_8A9A, true, &stats(), 22.0);
assert_eq!(dto.frames_shown, 0);
assert_eq!(dto.frames_dropped, 0);
assert_eq!(dto.datagrams, 0);
assert_eq!(dto.fps, 0.0);
assert_eq!(dto.width, 0);
assert_eq!(dto.gap_ms, None);
assert_eq!(dto.last_frame_bytes, 0);
// 但界面仍然需要知道画面来没来。
assert!(dto.has_frames);
assert!(dto.online);
}
#[test]
fn diagnostics_on_reports_the_real_numbers() {
let dto = CameraStatsDto::build(true, 0x3D4D_8A9A, true, &stats(), 22.0);
assert_eq!(dto.frames_shown, 440);
assert_eq!(dto.frames_dropped, 10);
assert!((dto.loss_percent - 2.2222).abs() < 0.01);
assert_eq!((dto.width, dto.height), (640, 480));
assert_eq!(dto.fps, 22.0);
assert_eq!(dto.status, "0x3D4D8A9A");
}
#[test]
fn a_never_seen_camera_does_not_report_a_fake_resolution() {
let dto = CameraStatsDto::build(true, 1, true, &SourceStats::default(), 0.0);
assert_eq!(dto.status, "已连接,等待画面…");
assert_eq!((dto.width, dto.height), (0, 0));
assert!(!dto.has_frames, "0x0 would read like a real resolution");
}
#[test]
fn an_offline_camera_says_offline() {
let dto = CameraStatsDto::build(true, 1, false, &SourceStats::default(), 0.0);
assert_eq!(dto.status, "离线");
}
#[test]
fn absent_health_is_a_placeholder_with_four_cells() {
let dto = TeamHealthDto::absent(0, None);
assert!(!dto.available);
assert_eq!(dto.hp, None);
assert_eq!(dto.hps.len(), crate::receiver::ARMOR_BOARDS);
assert!(!dto.placeholder.is_empty());
}
#[test]
fn critical_health_is_flagged_at_zero_and_at_the_threshold() {
let low = TeamHealthDto::present(0, Some(1), [30, 30, 30, 30], 30);
assert!(low.critical, "at the threshold");
let ok = TeamHealthDto::present(0, Some(1), [31, 31, 31, 31], 30);
assert!(!ok.critical);
let dead = TeamHealthDto::present(0, Some(1), [0, 0, 0, 0], 30);
assert!(dead.critical);
assert_eq!(dead.hp, Some(0));
}
#[test]
fn a_recording_reports_the_framerate_it_told_ffmpeg() {
// 回放速度不对时,这是唯一能解释原因的数字。
let dto = RecordingDto {
camera: 1,
camera_text: id_text(1),
active: true,
path: None,
input_fps: 21.5,
frames_written: 0,
frames_dropped: 0,
started_ms: None,
error: None,
};
let json = serde_json::to_string(&dto).unwrap();
assert!(json.contains(r#""inputFps":21.5"#), "got {json}");
}
#[test]
fn the_config_view_never_carries_the_password_hash() {
let mut config = Config::default();
config.role = Role::Team;
config.password = Some("$argon2id$super$secret$hash".into());
let view = ConfigView::from(&config);
assert!(view.password_set);
let json = serde_json::to_string(&view).unwrap();
assert!(
!json.contains("argon2id"),
"the hash must not reach the webview: {json}"
);
}
#[test]
fn ids_are_formatted_like_everywhere_else() {
assert_eq!(id_text(0x3D4D_8A9A), "0x3D4D8A9A");
assert_eq!(id_text(1), "0x00000001", "leading zeros matter for eyeballing");
}
}
+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);
}
}