Files
ESP32S3_vt/desktop/src-tauri/src/state.rs
T
sloray cc6b82d7ec
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
feat: 链路控制、延迟校准与长测工具,补全文档
- 固件:新增 link_control / stream_control,覆盖运行中 Wi-Fi 断线恢复、取帧节拍、
  温控与质量/帧率减载;UDP 发送增加有界重试预算与帧期限,PSRAM JPEG 直发减少拷贝。
- 协议:重组器严格校验分片布局,统计未收齐、超时与完全缺失的帧,处理帧号回绕与设备重启。
- 桌面端:视频改 latest-value 通道并前置帧时间标记,拒绝积压和慢解码的过期画面;新增
  延迟标记页与 --latency-report 校准,完善录制、服务端与退出流程。
- 文档与脚本:新增硬件、开发、延迟、功耗、实时性、稳定性文档和改动记录,重写根 README;
  新增构建、性能、延迟与串口长测脚本,以及长测客户端脚本。
- 构建:固定 esp-1.97.0.0 工具链,分区表随构建复制,更新 sdkconfig 与忽略规则。
2026-10-19 00:19:07 +08:00

670 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 进程内的共享状态。
//!
//! 这里是"网络层的推"与"界面的拉"交汇的地方:
//!
//! - **推**:接收线程拿到帧就交给 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;