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:
@@ -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
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,界面才能说清在等谁"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user