- 固件:新增 link_control / stream_control,覆盖运行中 Wi-Fi 断线恢复、取帧节拍、 温控与质量/帧率减载;UDP 发送增加有界重试预算与帧期限,PSRAM JPEG 直发减少拷贝。 - 协议:重组器严格校验分片布局,统计未收齐、超时与完全缺失的帧,处理帧号回绕与设备重启。 - 桌面端:视频改 latest-value 通道并前置帧时间标记,拒绝积压和慢解码的过期画面;新增 延迟标记页与 --latency-report 校准,完善录制、服务端与退出流程。 - 文档与脚本:新增硬件、开发、延迟、功耗、实时性、稳定性文档和改动记录,重写根 README; 新增构建、性能、延迟与串口长测脚本,以及长测客户端脚本。 - 构建:固定 esp-1.97.0.0 工具链,分区表随构建复制,更新 sdkconfig 与忽略规则。
670 lines
25 KiB
Rust
670 lines
25 KiB
Rust
//! 进程内的共享状态。
|
||
//!
|
||
//! 这里是"网络层的推"与"界面的拉"交汇的地方:
|
||
//!
|
||
//! - **推**:接收线程拿到帧就交给 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::slots::SlotAllocator;
|
||
use cam_core::snapshot::{
|
||
resolve_outcomes, ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, SlotsDto,
|
||
Snapshot, TeamHealthDto,
|
||
};
|
||
use cam_core::stats::{FpsWindow, DEFAULT_WINDOW};
|
||
use cam_core::ArmorSource;
|
||
use tokio::sync::{broadcast, watch};
|
||
|
||
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>>,
|
||
video: server::VideoSender,
|
||
/// 回环上已经绑好的 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 (video, _) = watch::channel(server::VideoFrames::new());
|
||
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,
|
||
video,
|
||
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 video = self.video.clone();
|
||
let recordings = Arc::clone(&self.recordings);
|
||
|
||
// 帧的 sink:在接收线程里跑,只做"编码一次 + 广播"。这一份字节所有界面共用。
|
||
self.receiver
|
||
.set_frame_sink(Arc::new(move |frame: Arc<protocol::Frame>| {
|
||
recordings.feed(&frame);
|
||
server::publish_video(&video, &frame);
|
||
}));
|
||
|
||
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);
|
||
let video = self.video.clone();
|
||
|
||
tauri::async_runtime::spawn(async move {
|
||
server::serve(listener, token, tx, video, 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();
|
||
|
||
// 两边都给:用户端的画面右上角要显示对手的血量,裁判端本来就要双方。
|
||
//
|
||
// 顺序固定为队伍号 0、1,界面按 `team` 取那一格——**不靠数组下标**判断"哪个是我",
|
||
// 因为下标在"用户端绑的是队伍二"时就不再等于自己的队伍号了。
|
||
let mut health = vec![self.health_for(0), self.health_for(1)];
|
||
|
||
// 胜负是**两边合起来**才能算出来的东西,所以在这里补一步。
|
||
resolve_outcomes(&mut health);
|
||
|
||
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` 自动找。
|
||
///
|
||
/// 槽位指定只有**裁判端**有:`referee.armor_a/b` 是裁判端界面上的"左格/右格",用户端
|
||
/// 拿它当自己的板子会让绑定被一份不相干的配置盖掉。
|
||
fn assigned_armor(&self, config: &Config, team: u8) -> Option<u32> {
|
||
let explicit = if config.role == Role::Referee {
|
||
if team == 0 {
|
||
config.referee.armor_a
|
||
} else {
|
||
config.referee.armor_b
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if config.role == Role::Referee && 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)
|
||
})
|
||
}
|
||
|
||
/// 一边的血量。
|
||
///
|
||
/// 顺序是有意的:先看绑定的那块板子,再看"这一边有没有人报血量"。绑定了却在掉线
|
||
/// 状态时要显示**未接入**而不是拿别人的数据顶上——现场会以为对手还有血。
|
||
///
|
||
/// # 对手那一块绝不能用 `armor_id`
|
||
///
|
||
/// `armor_id` 是**我自己**绑定的主板。拿它去填对手那一格,右上角显示的就是自己的血——
|
||
/// 比不显示更糟:现场会照着一个假的对手血量做决策。
|
||
fn health_for(&self, team: u8) -> TeamHealthDto {
|
||
let config = self.config();
|
||
let low = config.hud.health.low_threshold;
|
||
|
||
let own = config.role == Role::Team && team == config.team;
|
||
let bound = if own { config.armor_id } else { None };
|
||
let board = bound.or_else(|| 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, &config.team_names)
|
||
}
|
||
None => TeamHealthDto::absent(team, Some(board), &config.team_names),
|
||
};
|
||
}
|
||
|
||
match self.armor.health(team) {
|
||
Some(hps) => TeamHealthDto::present(team, None, hps, low, &config.team_names),
|
||
None => TeamHealthDto::absent(team, None, &config.team_names),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 演示模式下合成的板子 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;
|