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,635 @@
|
||||
//! 进程内的共享状态。
|
||||
//!
|
||||
//! 这里是"网络层的推"与"界面的拉"交汇的地方:
|
||||
//!
|
||||
//! - **推**:接收线程拿到帧就交给 sink,sink 直接发进广播通道(不阻塞、不排队);
|
||||
//! 装甲板上报推进一个 [`HealthTracker`],派生出掉血事件就立刻推一条。
|
||||
//! - **拉**:一个 5 Hz 的循环把当前状态拼成一份 [`Snapshot`] 推出去。界面不需要任何
|
||||
//! 轮询命令,它只是渲染收到的那份状态。
|
||||
//!
|
||||
//! # 密码闸门的执行点在这里,不在界面里
|
||||
//!
|
||||
//! 所有会改绑定的命令都过 [`AppState::require_unlocked`]。界面上有一个解锁对话框,但那
|
||||
//! **只是入口**:即使有人从控制台直接调 `invoke("bind_camera")`,一样要过这一关。这和
|
||||
//! 旧客户端把防作弊放在**接收端**而不是界面里是同一条原则。
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use cam_core::armor::{ArmorHealthStore, HealthTracker};
|
||||
use cam_core::config::{Config, Role};
|
||||
use cam_core::password;
|
||||
use cam_core::receiver::{Discovery, LatestFrames, Receiver};
|
||||
use cam_core::ArmorSource;
|
||||
use cam_core::slots::SlotAllocator;
|
||||
use cam_core::snapshot::{
|
||||
ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, Snapshot, SlotsDto, TeamHealthDto,
|
||||
};
|
||||
use cam_core::stats::{FpsWindow, DEFAULT_WINDOW};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::recording::{now_ms, RecordingSet};
|
||||
use crate::server::{self, ServerMsg, WireMsg};
|
||||
|
||||
/// 状态快照的推送频率。
|
||||
///
|
||||
/// 5 Hz:血量心跳是 200 ms,所以这个频率刚好不漏掉一次掉血;而再快也只是让界面做
|
||||
/// 无意义的重绘。
|
||||
pub const SNAPSHOT_INTERVAL: Duration = Duration::from_millis(200);
|
||||
|
||||
/// 界面端是否产出技术读数。
|
||||
///
|
||||
/// 由**编译模式**决定而不是命令行参数:命令行开关意味着二进制里带着那套东西,忘开
|
||||
/// 一次就泄露了。裁判端不受影响——它是中立方,双方遥测对判罚有用。
|
||||
pub const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
|
||||
|
||||
pub struct AppState {
|
||||
pub config_path: PathBuf,
|
||||
config: Mutex<Config>,
|
||||
/// 会话内解锁(见模块文档)。
|
||||
unlocked: AtomicBool,
|
||||
/// 设置面板是否打开,决定发现列表放不放开。
|
||||
discovery_open: AtomicBool,
|
||||
|
||||
pub receiver: Arc<Receiver>,
|
||||
pub armor: Arc<ArmorHealthStore>,
|
||||
discovery: Arc<Discovery>,
|
||||
latest: Arc<LatestFrames>,
|
||||
|
||||
/// 每一路的帧率窗口。掉线的摄像头会被清掉,回来时重新建。
|
||||
fps: Mutex<HashMap<u32, FpsWindow>>,
|
||||
slots: Mutex<SlotAllocator>,
|
||||
pub recordings: Arc<RecordingSet>,
|
||||
|
||||
tx: broadcast::Sender<Arc<ServerMsg>>,
|
||||
/// 回环上已经绑好的 WebSocket listener,等 `spawn_background` 取走。
|
||||
///
|
||||
/// 先绑再交给后台任务,是为了让"端口绑不上"在启动阶段就报错,而不是变成一个永远
|
||||
/// 连不上的界面。
|
||||
listener: Mutex<Option<tokio::net::TcpListener>>,
|
||||
pub port: u16,
|
||||
token: String,
|
||||
clients: Arc<AtomicUsize>,
|
||||
|
||||
pub demo: bool,
|
||||
notes: Mutex<Vec<String>>,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 建好状态,并绑好两个 UDP 端口。
|
||||
///
|
||||
/// 视频端口(5600)绑不上是**致命**的:整个程序的存在意义就是收画面,绑不上应该
|
||||
/// 立刻报错,而不是开一个永远不出画面的窗口。装甲板端口(5601)绑不上只是没有
|
||||
/// 血量,退回占位符即可——血量显示不了不该让整个客户端起不来。
|
||||
pub fn new(
|
||||
config_path: PathBuf,
|
||||
config: Config,
|
||||
mut notes: Vec<String>,
|
||||
demo: bool,
|
||||
) -> Result<(Arc<Self>, Option<std::net::UdpSocket>, Option<std::net::UdpSocket>)> {
|
||||
let role = config.role;
|
||||
let bound = if role == Role::Team { config.camera_id } else { None };
|
||||
|
||||
// 视频端口绑不上**不**致命:界面照常起来,并把原因写在最显眼的地方。
|
||||
//
|
||||
// 一开始这里是 `?`(直接退出)。但那样一来,现场看到的是一次闪退加上一句只出现
|
||||
// 在 stderr 的话——从访达双击启动时连 stderr 都没有。让窗口起来并说明原因,比
|
||||
// 静默退出有用得多;而且"另一个客户端已经在跑"恰好是最常见的原因。
|
||||
let (receiver, video_socket) = match Receiver::start(
|
||||
protocol::BROADCAST_ADDR,
|
||||
role.to_protocol(),
|
||||
bound,
|
||||
) {
|
||||
Ok((receiver, socket)) => (Arc::new(receiver), Some(socket)),
|
||||
Err(err) => {
|
||||
log::error!("could not bind UDP {}: {err}", protocol::DEFAULT_PORT);
|
||||
notes.push(format!(
|
||||
"绑不上 UDP {}:{err}。多半是同一个端口上已经跑着另一个客户端 \
|
||||
(旧的 Slint 版或另一份本程序)。画面收不到,其它功能照常。",
|
||||
protocol::DEFAULT_PORT
|
||||
));
|
||||
(
|
||||
Arc::new(Receiver::new(
|
||||
protocol::BROADCAST_ADDR,
|
||||
role.to_protocol(),
|
||||
bound,
|
||||
)),
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let discovery = receiver.discovery();
|
||||
let latest = receiver.latest_frames();
|
||||
|
||||
// 装甲板:两端都要收。用户端要显示自己的血条,裁判端要显示双方。
|
||||
let (armor, armor_socket) = match ArmorHealthStore::start() {
|
||||
Ok((store, socket)) => (store, Some(socket)),
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"armor: could not listen on UDP {}: {err}; health stays a placeholder",
|
||||
protocol::ARMOR_PORT
|
||||
);
|
||||
(Arc::new(ArmorHealthStore::new()), None)
|
||||
}
|
||||
};
|
||||
|
||||
let (tx, _) = broadcast::channel(server::CHANNEL_CAPACITY);
|
||||
let (listener, port) = tauri::async_runtime::block_on(server::bind())
|
||||
.context("绑不上本地 WebSocket 端口(回环地址)")?;
|
||||
|
||||
let state = Arc::new(Self {
|
||||
config_path,
|
||||
config: Mutex::new(config),
|
||||
unlocked: AtomicBool::new(false),
|
||||
discovery_open: AtomicBool::new(false),
|
||||
receiver,
|
||||
armor,
|
||||
discovery,
|
||||
latest,
|
||||
fps: Mutex::new(HashMap::new()),
|
||||
slots: Mutex::new(SlotAllocator::new()),
|
||||
recordings: RecordingSet::new(),
|
||||
tx,
|
||||
listener: Mutex::new(Some(listener)),
|
||||
port,
|
||||
token: server::random_token(),
|
||||
clients: Arc::new(AtomicUsize::new(0)),
|
||||
demo,
|
||||
notes: Mutex::new(notes),
|
||||
started: Instant::now(),
|
||||
});
|
||||
|
||||
state.apply_gates();
|
||||
|
||||
Ok((state, video_socket, armor_socket))
|
||||
}
|
||||
|
||||
/// 让视频通道开始工作:把帧与装甲板上报接到广播通道上。
|
||||
///
|
||||
/// 必须在 `Arc<AppState>` 建好之后调用(sink 里要持有 state 的通道)。
|
||||
pub fn install_sinks(self: &Arc<Self>) {
|
||||
let tx = self.tx.clone();
|
||||
let recordings = Arc::clone(&self.recordings);
|
||||
|
||||
// 帧的 sink:在接收线程里跑,只做"编码一次 + 广播"。这一份字节所有界面共用。
|
||||
self.receiver
|
||||
.set_frame_sink(Arc::new(move |frame: Arc<protocol::Frame>| {
|
||||
recordings.feed(&frame);
|
||||
let bytes = server::encode_frame(&frame);
|
||||
let _ = tx.send(Arc::new(ServerMsg::Binary(bytes.into())));
|
||||
}));
|
||||
|
||||
let tx = self.tx.clone();
|
||||
let tracker = Arc::new(Mutex::new(HealthTracker::new()));
|
||||
|
||||
// 装甲板的 sink:把"现在多少血"派生为"刚刚掉了几点",立刻推给界面。
|
||||
self.armor.set_sink(Arc::new(move |report| {
|
||||
let hit = tracker
|
||||
.lock()
|
||||
.expect("health tracker mutex poisoned")
|
||||
.observe(&report);
|
||||
|
||||
let Some(hit) = hit else {
|
||||
return;
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"armor: board 0x{:08X} took {} damage ({} -> {})",
|
||||
hit.board,
|
||||
hit.damage,
|
||||
hit.from,
|
||||
hit.to
|
||||
);
|
||||
|
||||
let dto = HitDto {
|
||||
board: hit.board,
|
||||
team: hit.team,
|
||||
index: hit.index,
|
||||
from: hit.from,
|
||||
to: hit.to,
|
||||
damage: hit.damage,
|
||||
at_ms: now_ms(),
|
||||
};
|
||||
|
||||
let _ = tx.send(Arc::new(ServerMsg::Text(
|
||||
serde_json::to_string(&WireMsg::Hit(dto)).unwrap_or_default(),
|
||||
)));
|
||||
}));
|
||||
}
|
||||
|
||||
/// 起 WebSocket 服务与快照循环。
|
||||
pub fn spawn_background(self: &Arc<Self>) {
|
||||
let Some(listener) = self
|
||||
.listener
|
||||
.lock()
|
||||
.expect("listener mutex poisoned")
|
||||
.take()
|
||||
else {
|
||||
log::warn!("spawn_background called twice; the stream server is already running");
|
||||
return;
|
||||
};
|
||||
|
||||
let state = Arc::clone(self);
|
||||
let token = self.token.clone();
|
||||
let tx = self.tx.clone();
|
||||
let clients = Arc::clone(&self.clients);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
server::serve(listener, token, tx, clients).await;
|
||||
});
|
||||
|
||||
let state_for_loop = Arc::clone(&state);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(SNAPSHOT_INTERVAL);
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
state_for_loop.tick();
|
||||
state_for_loop.push_snapshot();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 配置
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn config(&self) -> Config {
|
||||
self.config.lock().expect("config mutex poisoned").clone()
|
||||
}
|
||||
|
||||
/// 改配置、落盘、重新套用闸门。
|
||||
pub fn update_config(&self, change: impl FnOnce(&mut Config)) -> Result<Config> {
|
||||
let updated = {
|
||||
let mut config = self.config.lock().expect("config mutex poisoned");
|
||||
change(&mut config);
|
||||
// Guard 上不能直接调 `normalized()`(它取走 self)。克隆一份再归一化:配置
|
||||
// 只有几百字节,而这里每次都是人点一下才发生。
|
||||
config.clone().normalized()
|
||||
};
|
||||
|
||||
updated
|
||||
.save(&self.config_path)
|
||||
.context("写入配置文件失败;改动只在本次运行内有效")?;
|
||||
|
||||
*self.config.lock().expect("config mutex poisoned") = updated.clone();
|
||||
self.apply_gates();
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// 按当前配置重新设置两道闸门。
|
||||
///
|
||||
/// 每一次可能改变身份、绑定或发现开合的改动之后都要调用它——把这件事收在一个函数
|
||||
/// 里,是为了不让"某个命令忘了重新套用闸门"这种漏洞有时间长出来。
|
||||
pub fn apply_gates(&self) {
|
||||
let config = self.config();
|
||||
let role = config.role;
|
||||
|
||||
self.receiver.set_role(role.to_protocol());
|
||||
|
||||
// 第一道闸门:帧。用户端只看绑定的那一块;裁判端看全部。
|
||||
let limit = match role {
|
||||
Role::Team => config.camera_id,
|
||||
Role::Referee => None,
|
||||
};
|
||||
self.receiver.restrict_to(limit);
|
||||
|
||||
// 第二道闸门:发现列表。只有用户端在**解锁后**打开设置面板时才放开;裁判端
|
||||
// 本来就要看到全场。
|
||||
let open = match role {
|
||||
Role::Referee => true,
|
||||
Role::Team => self.discovery_open.load(Ordering::Relaxed) && self.unlocked.load(Ordering::Relaxed),
|
||||
};
|
||||
self.receiver.set_discovery_all(open);
|
||||
|
||||
if !open {
|
||||
self.discovery_open.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 密码闸门
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
// 裁判端没有密码(见 Role::needs_password),所以它永远是"已解锁"。
|
||||
!self.config().role.needs_password() || self.unlocked.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// 校验一次密码,成功就把会话标记为已解锁。
|
||||
pub fn unlock(&self, presented: &str) -> Result<bool> {
|
||||
let stored = self.config().password;
|
||||
|
||||
let Some(stored) = stored else {
|
||||
// 还没设过密码:任何输入都不该被当成"通过"。
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let ok = password::verify_password(presented, &stored)?;
|
||||
|
||||
if ok {
|
||||
self.unlocked.store(true, Ordering::Relaxed);
|
||||
log::info!("密码校验通过;本次运行内不再询问");
|
||||
} else {
|
||||
log::warn!("密码校验失败");
|
||||
}
|
||||
|
||||
Ok(ok)
|
||||
}
|
||||
|
||||
/// 手动重新上锁。
|
||||
pub fn lock(&self) {
|
||||
self.unlocked.store(false, Ordering::Relaxed);
|
||||
self.discovery_open.store(false, Ordering::Relaxed);
|
||||
self.apply_gates();
|
||||
}
|
||||
|
||||
/// 改绑之前的守门人。
|
||||
///
|
||||
/// `presented` 允许调用方在同一次调用里带上密码(界面上就是这样:解锁对话框收起后
|
||||
/// 用户立刻做改动),但会话已经解锁时不需要它。
|
||||
pub fn require_unlocked(&self, presented: Option<&str>) -> Result<(), String> {
|
||||
if self.is_unlocked() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(presented) = presented {
|
||||
match self.unlock(presented) {
|
||||
Ok(true) => return Ok(()),
|
||||
Ok(false) => {
|
||||
return Err("密码不对。改摄像头/装甲板绑定、切换队伍都需要密码。".into())
|
||||
}
|
||||
Err(err) => return Err(format!("存下来的密码哈希读不出来:{err}")),
|
||||
}
|
||||
}
|
||||
|
||||
Err("这个改动需要密码。先解锁(密码只是防止现场随手改绑,见 README 的说明)。".into())
|
||||
}
|
||||
|
||||
/// 打开/关闭设置面板带来的发现列表。
|
||||
pub fn set_discovery_open(&self, open: bool) -> Result<(), String> {
|
||||
if open {
|
||||
self.require_unlocked(None)?;
|
||||
}
|
||||
|
||||
self.discovery_open.store(open, Ordering::Relaxed);
|
||||
self.apply_gates();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置或修改密码。用户端第一次启动必须设。
|
||||
pub fn set_password(&self, current: Option<&str>, fresh: &str) -> Result<()> {
|
||||
let existing = self.config().password;
|
||||
|
||||
if let Some(existing) = existing {
|
||||
// 已有密码:必须能证明知道旧的(或者在本次运行里已经解锁过)。
|
||||
let trusted = self.unlocked.load(Ordering::Relaxed)
|
||||
|| current
|
||||
.map(|current| password::verify_password(current, &existing).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !trusted {
|
||||
anyhow::bail!("修改密码需要先输入当前密码");
|
||||
}
|
||||
}
|
||||
|
||||
let hash = password::hash_password(fresh)?;
|
||||
self.update_config(move |config| config.password = Some(hash))?;
|
||||
self.unlocked.store(true, Ordering::Relaxed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 推送
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn push(&self, msg: WireMsg) {
|
||||
let text = match serde_json::to_string(&msg) {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
log::error!("could not serialise a message for the interface: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 没有连接时发送会失败,这是正常情况(界面还没起来,或者刚刷新)。
|
||||
let _ = self.tx.send(Arc::new(ServerMsg::Text(text)));
|
||||
}
|
||||
|
||||
pub fn push_snapshot(&self) {
|
||||
self.push(WireMsg::Snapshot(Box::new(self.snapshot())));
|
||||
}
|
||||
|
||||
pub fn note(&self, message: impl Into<String>) {
|
||||
let message = message.into();
|
||||
log::info!("{message}");
|
||||
self.notes
|
||||
.lock()
|
||||
.expect("notes mutex poisoned")
|
||||
.push(message.clone());
|
||||
self.push(WireMsg::Note { message });
|
||||
}
|
||||
|
||||
pub fn stream_info(&self) -> server::StreamInfo {
|
||||
server::StreamInfo {
|
||||
url: format!("ws://127.0.0.1:{}/?token={}", self.port, self.token),
|
||||
port: self.port,
|
||||
clients: self.clients.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn latest_frames(&self) -> Arc<LatestFrames> {
|
||||
Arc::clone(&self.latest)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 每 200ms 推进一次
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// 推进帧率窗口与槽位分配。
|
||||
pub fn tick(&self) {
|
||||
let online: Vec<u32> = self.discovery.online_cameras().iter().map(|c| c.id).collect();
|
||||
let online_set: HashSet<u32> = online.iter().copied().collect();
|
||||
|
||||
{
|
||||
let mut fps = self.fps.lock().expect("fps mutex poisoned");
|
||||
let now = Instant::now();
|
||||
|
||||
// 掉线的摄像头把窗口丢掉。留着的话,它回来时累计帧数已经涨了一大截,下一
|
||||
// 次结算会报出一个几百 fps 的假数字。
|
||||
fps.retain(|id, _| online_set.contains(id));
|
||||
|
||||
for id in &online {
|
||||
let stats = self.discovery.stats(*id);
|
||||
fps.entry(*id)
|
||||
.or_insert_with(|| FpsWindow::seeded(DEFAULT_WINDOW, now, stats.frames_shown))
|
||||
.observe(stats.frames_shown, now);
|
||||
}
|
||||
}
|
||||
|
||||
let config = self.config();
|
||||
let mut slots = self.slots.lock().expect("slots mutex poisoned");
|
||||
|
||||
match config.role {
|
||||
Role::Referee => {
|
||||
if config.referee.manual_slots {
|
||||
// 手动:以配置为准。现场有烧错固件的板子时,人需要能压过自动分配。
|
||||
slots.force(0, config.referee.slot_a);
|
||||
slots.force(1, config.referee.slot_b);
|
||||
} else {
|
||||
slots.assign(&online);
|
||||
}
|
||||
}
|
||||
// 用户端只有一个"槽位":它绑定的那一块。
|
||||
Role::Team => {
|
||||
slots.force(0, config.camera_id);
|
||||
slots.force(1, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostics(&self) -> bool {
|
||||
self.config().role == Role::Referee || TEAM_DIAGNOSTICS
|
||||
}
|
||||
|
||||
/// 某块摄像头的帧率(用于录制时告诉 ffmpeg 输入帧率)。
|
||||
pub fn fps_of(&self, camera: u32) -> Option<f32> {
|
||||
let fps = self.fps.lock().expect("fps mutex poisoned");
|
||||
let window = fps.get(&camera)?;
|
||||
|
||||
if window.has_reading() {
|
||||
Some(window.fps())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 快照
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn snapshot(&self) -> Snapshot {
|
||||
let config = self.config();
|
||||
let diagnostics = self.diagnostics();
|
||||
|
||||
let cameras: Vec<CameraDto> = self
|
||||
.discovery
|
||||
.cameras()
|
||||
.iter()
|
||||
.map(CameraDto::from)
|
||||
.collect();
|
||||
|
||||
let stats: Vec<CameraStatsDto> = self
|
||||
.discovery
|
||||
.cameras()
|
||||
.iter()
|
||||
.map(|camera| {
|
||||
let stats = self.discovery.stats(camera.id);
|
||||
let fps = self.fps_of(camera.id).unwrap_or(0.0);
|
||||
CameraStatsDto::build(diagnostics, camera.id, camera.is_online(), &stats, fps)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let armor_boards: Vec<ArmorBoardDto> =
|
||||
self.armor.boards().iter().map(ArmorBoardDto::from).collect();
|
||||
|
||||
let slots = self.slots.lock().expect("slots mutex poisoned").slots();
|
||||
|
||||
let health = match config.role {
|
||||
// 用户端只显示自己那一边。
|
||||
Role::Team => vec![self.health_for(config.team)],
|
||||
Role::Referee => vec![self.health_for(0), self.health_for(1)],
|
||||
};
|
||||
|
||||
Snapshot {
|
||||
role: config.role,
|
||||
team: config.team,
|
||||
mirrored: config.mirror,
|
||||
demo: self.demo,
|
||||
diagnostics,
|
||||
unlocked: self.is_unlocked(),
|
||||
discovery_open: self.discovery_open.load(Ordering::Relaxed),
|
||||
uptime_ms: self.started.elapsed().as_millis() as u64,
|
||||
clients: self.clients.load(Ordering::Relaxed),
|
||||
config: ConfigView::from(&config),
|
||||
cameras,
|
||||
armor_boards,
|
||||
stats,
|
||||
slots: SlotsDto {
|
||||
camera_a: slots[0],
|
||||
camera_b: slots[1],
|
||||
armor_a: self.assigned_armor(&config, 0),
|
||||
armor_b: self.assigned_armor(&config, 1),
|
||||
manual: config.referee.manual_slots,
|
||||
},
|
||||
health,
|
||||
recordings: self.recordings.active(),
|
||||
notes: self.notes.lock().expect("notes mutex poisoned").clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一边的装甲板主板:手动指定优先,否则按板子自报的 `team` 自动找。
|
||||
fn assigned_armor(&self, config: &Config, team: u8) -> Option<u32> {
|
||||
let explicit = if team == 0 {
|
||||
config.referee.armor_a
|
||||
} else {
|
||||
config.referee.armor_b
|
||||
};
|
||||
|
||||
if config.referee.manual_slots {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
explicit.or_else(|| {
|
||||
self.armor
|
||||
.boards()
|
||||
.iter()
|
||||
.find(|board| board.team == team && board.is_online())
|
||||
.map(|board| board.id)
|
||||
})
|
||||
}
|
||||
|
||||
/// 一边的血量。
|
||||
///
|
||||
/// 顺序是有意的:先看绑定的那块板子,再看"这一边有没有人报血量"。绑定了却在掉线
|
||||
/// 状态时要显示**未接入**而不是拿别人的数据顶上——现场会以为对手还有血。
|
||||
fn health_for(&self, team: u8) -> TeamHealthDto {
|
||||
let config = self.config();
|
||||
let low = config.hud.health.low_threshold;
|
||||
|
||||
let board = match config.role {
|
||||
Role::Team => config
|
||||
.armor_id
|
||||
.or_else(|| self.assigned_armor(&config, team)),
|
||||
Role::Referee => self.assigned_armor(&config, team),
|
||||
};
|
||||
|
||||
if let Some(board) = board {
|
||||
return match self.armor.health_of_board(board) {
|
||||
Some(hp) => TeamHealthDto::present(team, Some(board), [hp; 4], low),
|
||||
None => TeamHealthDto::absent(team, Some(board)),
|
||||
};
|
||||
}
|
||||
|
||||
match self.armor.health(team) {
|
||||
Some(hps) => TeamHealthDto::present(team, None, hps, low),
|
||||
None => TeamHealthDto::absent(team, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 演示模式下合成的板子 id。
|
||||
///
|
||||
/// 取成合法十六进制、又不与真实 MAC 派生值相像的形状:截图里一眼能看出这是演示。
|
||||
pub const DEMO_CAMERA_A: u32 = 0x0DE0_0001;
|
||||
pub const DEMO_CAMERA_B: u32 = 0x0DE0_0002;
|
||||
pub const DEMO_ARMOR_A: u32 = 0x0A20_0001;
|
||||
pub const DEMO_ARMOR_B: u32 = 0x0A20_0002;
|
||||
Reference in New Issue
Block a user