feat: 链路控制、延迟校准与长测工具,补全文档
- 固件:新增 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:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user