feat: 链路控制、延迟校准与长测工具,补全文档
构建客户端 / 检查(类型 + 单元测试) (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

- 固件:新增 link_control / stream_control,覆盖运行中 Wi-Fi 断线恢复、取帧节拍、
  温控与质量/帧率减载;UDP 发送增加有界重试预算与帧期限,PSRAM JPEG 直发减少拷贝。
- 协议:重组器严格校验分片布局,统计未收齐、超时与完全缺失的帧,处理帧号回绕与设备重启。
- 桌面端:视频改 latest-value 通道并前置帧时间标记,拒绝积压和慢解码的过期画面;新增
  延迟标记页与 --latency-report 校准,完善录制、服务端与退出流程。
- 文档与脚本:新增硬件、开发、延迟、功耗、实时性、稳定性文档和改动记录,重写根 README;
  新增构建、性能、延迟与串口长测脚本,以及长测客户端脚本。
- 构建:固定 esp-1.97.0.0 工具链,分区表随构建复制,更新 sdkconfig 与忽略规则。
This commit is contained in:
2026-10-19 00:19:07 +08:00
parent 4794bed2cf
commit cc6b82d7ec
60 changed files with 4347 additions and 655 deletions
+58 -37
View File
@@ -24,14 +24,14 @@ 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::{
resolve_outcomes, ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, Snapshot,
SlotsDto, TeamHealthDto,
resolve_outcomes, ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, SlotsDto,
Snapshot, TeamHealthDto,
};
use cam_core::stats::{FpsWindow, DEFAULT_WINDOW};
use tokio::sync::broadcast;
use cam_core::ArmorSource;
use tokio::sync::{broadcast, watch};
use crate::recording::{now_ms, RecordingSet};
use crate::server::{self, ServerMsg, WireMsg};
@@ -67,6 +67,7 @@ pub struct AppState {
pub recordings: Arc<RecordingSet>,
tx: broadcast::Sender<Arc<ServerMsg>>,
video: server::VideoSender,
/// 回环上已经绑好的 WebSocket listener,等 `spawn_background` 取走。
///
/// 先绑再交给后台任务,是为了让"端口绑不上"在启动阶段就报错,而不是变成一个永远
@@ -92,38 +93,43 @@ impl AppState {
config: Config,
mut notes: Vec<String>,
demo: bool,
) -> Result<(Arc<Self>, Option<std::net::UdpSocket>, Option<std::net::UdpSocket>)> {
) -> 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 };
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}。多半是同一个端口上已经跑着另一个客户端 \
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,
)
}
};
protocol::DEFAULT_PORT
));
(
Arc::new(Receiver::new(
protocol::BROADCAST_ADDR,
role.to_protocol(),
bound,
)),
None,
)
}
};
let discovery = receiver.discovery();
let latest = receiver.latest_frames();
@@ -141,6 +147,7 @@ impl AppState {
};
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 端口(回环地址)")?;
@@ -157,6 +164,7 @@ impl AppState {
slots: Mutex::new(SlotAllocator::new()),
recordings: RecordingSet::new(),
tx,
video,
listener: Mutex::new(Some(listener)),
port,
token: server::random_token(),
@@ -175,15 +183,14 @@ impl AppState {
///
/// 必须在 `Arc<AppState>` 建好之后调用(sink 里要持有 state 的通道)。
pub fn install_sinks(self: &Arc<Self>) {
let tx = self.tx.clone();
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);
let bytes = server::encode_frame(&frame);
let _ = tx.send(Arc::new(ServerMsg::Binary(bytes.into())));
server::publish_video(&video, &frame);
}));
let tx = self.tx.clone();
@@ -240,9 +247,10 @@ impl AppState {
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, clients).await;
server::serve(listener, token, tx, video, clients).await;
});
let state_for_loop = Arc::clone(&state);
@@ -306,7 +314,9 @@ impl AppState {
// 本来就要看到全场。
let open = match role {
Role::Referee => true,
Role::Team => self.discovery_open.load(Ordering::Relaxed) && self.unlocked.load(Ordering::Relaxed),
Role::Team => {
self.discovery_open.load(Ordering::Relaxed) && self.unlocked.load(Ordering::Relaxed)
}
};
self.receiver.set_discovery_all(open);
@@ -457,7 +467,12 @@ impl AppState {
/// 推进帧率窗口与槽位分配。
pub fn tick(&self) {
let online: Vec<u32> = self.discovery.online_cameras().iter().map(|c| c.id).collect();
let online: Vec<u32> = self
.discovery
.online_cameras()
.iter()
.map(|c| c.id)
.collect();
let online_set: HashSet<u32> = online.iter().copied().collect();
{
@@ -539,8 +554,12 @@ impl AppState {
})
.collect();
let armor_boards: Vec<ArmorBoardDto> =
self.armor.boards().iter().map(ArmorBoardDto::from).collect();
let armor_boards: Vec<ArmorBoardDto> = self
.armor
.boards()
.iter()
.map(ArmorBoardDto::from)
.collect();
let slots = self.slots.lock().expect("slots mutex poisoned").slots();
@@ -627,7 +646,9 @@ impl AppState {
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),
Some(hp) => {
TeamHealthDto::present(team, Some(board), [hp; 4], low, &config.team_names)
}
None => TeamHealthDto::absent(team, Some(board), &config.team_names),
};
}