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
+1 -5
View File
@@ -485,11 +485,7 @@ pub fn clear_config(password: Option<String>, state: Shared<'_>) -> Result<Strin
/// 退出程序。用户端要密码——现场"随手关掉"也是一种需要挡住的误操作。
#[tauri::command]
pub fn quit_app(
password: Option<String>,
app: AppHandle,
state: Shared<'_>,
) -> Result<(), String> {
pub fn quit_app(password: Option<String>, app: AppHandle, state: Shared<'_>) -> Result<(), String> {
state.require_unlocked(password.as_deref())?;
// 先把录制收尾,否则 ffmpeg 会留下一个没有索引的 mp4。
+51 -18
View File
@@ -31,9 +31,7 @@ use std::time::Duration;
use protocol::{fragment_frame, ArmorHealth, MsgType};
use crate::state::{
AppState, DEMO_ARMOR_A, DEMO_ARMOR_B, DEMO_CAMERA_A, DEMO_CAMERA_B,
};
use crate::state::{AppState, DEMO_ARMOR_A, DEMO_ARMOR_B, DEMO_CAMERA_A, DEMO_CAMERA_B};
/// 合成画面的尺寸。与实机一致(VGA),这样界面的缩放/比例在演示下也说得通。
const WIDTH: u32 = 640;
@@ -83,9 +81,7 @@ fn spawn_camera(state: Arc<AppState>, camera: u32, team: u8) {
// 板子每 2 秒广播一次在线状态,与固件一致。
if last_hello.elapsed() >= Duration::from_secs(2) {
state
.receiver
.datagram(&hello.encode(MsgType::Hello), from);
state.receiver.datagram(&hello.encode(MsgType::Hello), from);
last_hello = std::time::Instant::now();
}
@@ -140,10 +136,8 @@ fn spawn_armor(state: Arc<AppState>) {
hp[slot] = hp[slot].saturating_sub(damage);
}
let from = SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::new(127, 0, 0, 1),
*port,
));
let from =
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), *port));
// 主板一次广播四条,整车一条血所以 hp 相同、index 0..3。
for index in 0..4u8 {
@@ -215,10 +209,38 @@ fn render(camera: u32, team: u8, frame_id: u32) -> Vec<u8> {
// 中心的靶心:两个同心圆 + 四臂。瞄准器偏移校准时对着它看。
ring(&mut img, cx, cy, 60, image::Rgb([200, 200, 210]));
ring(&mut img, cx, cy, 28, image::Rgb([150, 150, 160]));
arm(&mut img, cx - 90, cy, cx - 40, cy, image::Rgb([120, 120, 130]));
arm(&mut img, cx + 40, cy, cx + 90, cy, image::Rgb([120, 120, 130]));
arm(&mut img, cx, cy - 90, cx, cy - 40, image::Rgb([120, 120, 130]));
arm(&mut img, cx, cy + 40, cx, cy + 90, image::Rgb([120, 120, 130]));
arm(
&mut img,
cx - 90,
cy,
cx - 40,
cy,
image::Rgb([120, 120, 130]),
);
arm(
&mut img,
cx + 40,
cy,
cx + 90,
cy,
image::Rgb([120, 120, 130]),
);
arm(
&mut img,
cx,
cy - 90,
cx,
cy - 40,
image::Rgb([120, 120, 130]),
);
arm(
&mut img,
cx,
cy + 40,
cx,
cy + 90,
image::Rgb([120, 120, 130]),
);
// 二进制帧号:16 个方块,最左边是低位。
for bit in 0..16i32 {
@@ -241,7 +263,14 @@ fn render(camera: u32, team: u8, frame_id: u32) -> Vec<u8> {
// 底部随时间右移的指示条:截图里能判断这一帧是不是较新的。
let bar = ((t * 60.0) as i32).rem_euclid(WIDTH as i32 - 80);
fill_rect(&mut img, bar, HEIGHT as i32 - 24, 60, 8, image::Rgb([90, 200, 255]));
fill_rect(
&mut img,
bar,
HEIGHT as i32 - 24,
60,
8,
image::Rgb([90, 200, 255]),
);
// 摄像头 id 的最低位用一个角标表示,进一步区分两路。
let corner = if camera == DEMO_CAMERA_A { 0 } else { 1 };
@@ -341,8 +370,8 @@ mod tests {
assert!(jpeg.starts_with(&[0xFF, 0xD8]), "JPEG SOI");
assert!(jpeg.ends_with(&[0xFF, 0xD9]), "JPEG EOI");
let decoded = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg)
.expect("decodes");
let decoded =
image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg).expect("decodes");
assert_eq!((decoded.width(), decoded.height()), (WIDTH, HEIGHT));
}
@@ -366,7 +395,11 @@ mod tests {
fn a_frame_is_big_enough_to_be_a_realistic_load() {
// 太小说明画的内容退化了(比如全是纯色),那分片/带宽的验证就失去意义。
let jpeg = render(DEMO_CAMERA_A, 0, 1);
assert!(jpeg.len() > 3_000, "suspiciously small: {} bytes", jpeg.len());
assert!(
jpeg.len() > 3_000,
"suspiciously small: {} bytes",
jpeg.len()
);
assert!(
jpeg.len() < 200_000,
"suspiciously large: {} bytes",
+77
View File
@@ -0,0 +1,77 @@
//! Opt-in, local-only calibration reports. No images, tokens or user config.
use serde::Deserialize;
use std::{
fs::{File, OpenOptions},
io::Write,
path::PathBuf,
sync::Mutex,
};
pub struct Recorder {
output: Mutex<Option<File>>,
}
impl Recorder {
pub fn new(path: Option<PathBuf>) -> std::io::Result<Self> {
let output = if let Some(path) = path {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
Some(OpenOptions::new().write(true).create_new(true).open(path)?)
} else {
None
};
Ok(Self {
output: Mutex::new(output),
})
}
}
#[derive(Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Sample {
frame_id: u32,
counter: u16,
source_draw_ms: f64,
draw_ms: f64,
next_raf_ms: f64,
lower_ms: f64,
upper_ms: f64,
decode_to_draw_ms: f64,
analysis_ms: f64,
}
#[tauri::command]
pub fn latency_enabled(recorder: tauri::State<'_, Recorder>) -> bool {
recorder
.output
.lock()
.expect("latency output mutex")
.is_some()
}
#[tauri::command]
pub fn latency_sample(sample: Sample, recorder: tauri::State<'_, Recorder>) -> Result<(), String> {
if ![
sample.source_draw_ms,
sample.draw_ms,
sample.next_raf_ms,
sample.lower_ms,
sample.upper_ms,
sample.decode_to_draw_ms,
sample.analysis_ms,
]
.iter()
.all(|v| v.is_finite())
|| sample.lower_ms < 0.0
|| sample.upper_ms < sample.lower_ms
|| sample.upper_ms > 5000.0
{
return Err("Invalid latency sample".into());
}
let mut guard = recorder.output.lock().map_err(|_| "latency output mutex")?;
let out = guard.as_mut().ok_or("Latency calibration is disabled")?;
serde_json::to_writer(&mut *out, &sample).map_err(|e| e.to_string())?;
out.write_all(b"\n")
.and_then(|_| out.flush())
.map_err(|e| e.to_string())
}
+18 -3
View File
@@ -23,6 +23,7 @@
mod commands;
mod demo;
mod latency;
mod recording;
mod server;
mod state;
@@ -42,6 +43,7 @@ const USAGE: &str = "\
--role <team|referee> 身份。默认看配置文件;没有配置文件时是 referee
--config <路径> 配置文件位置
--latency-report <路径> 打开裁判端屏幕标记校准,写入新的本地 NDJSON 报告
--demo 演示模式:画面与血量都是本地合成的
--list 打印发现的摄像头与装甲板后退出
-h, --help 显示这段帮助
@@ -78,6 +80,7 @@ fn main() {
struct Args {
role: Option<Role>,
config: Option<PathBuf>,
latency_report: Option<PathBuf>,
demo: bool,
list: bool,
help: bool,
@@ -104,6 +107,9 @@ impl Args {
"--config" => {
args.config = it.next().map(PathBuf::from);
}
"--latency-report" => {
args.latency_report = it.next().map(PathBuf::from);
}
other => {
// 见模块文档:macOS 的 .app 启动参数与 shell 里的笔误都可能出现在这里,
// 但报错的代价(窗口起不来)远大于收益。
@@ -124,7 +130,10 @@ fn run() -> Result<(), Box<dyn Error>> {
return Ok(());
}
let config_path = args.config.clone().unwrap_or_else(config::default_config_path);
let config_path = args
.config
.clone()
.unwrap_or_else(config::default_config_path);
// 首次启动时导入旧客户端的配置,并把旧文件改名成 .bak。
let (mut config, notes) = config::load_with_migration(&config_path)?;
@@ -134,6 +143,10 @@ fn run() -> Result<(), Box<dyn Error>> {
if let Some(role) = args.role {
config.role = role;
}
if args.latency_report.is_some() && config.role != Role::Referee {
return Err("Latency calibration requires a referee test configuration".into());
}
let latency_recorder = latency::Recorder::new(args.latency_report.clone())?;
for note in &notes {
log::info!("{note}");
@@ -150,8 +163,7 @@ fn run() -> Result<(), Box<dyn Error>> {
config_path.display()
);
let (state, video_socket, armor_socket) =
AppState::new(config_path, config, notes, args.demo)?;
let (state, video_socket, armor_socket) = AppState::new(config_path, config, notes, args.demo)?;
// 视频端口绑不上时没有 socket,接收线程也就没有意义——界面会显示那条说明。
if let Some(socket) = video_socket {
@@ -219,7 +231,10 @@ fn run() -> Result<(), Box<dyn Error>> {
let app = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(Arc::clone(&state))
.manage(latency_recorder)
.invoke_handler(tauri::generate_handler![
latency::latency_enabled,
latency::latency_sample,
commands::stream_info,
commands::get_snapshot,
commands::get_config,
+39 -19
View File
@@ -99,9 +99,7 @@ impl Recorder {
.stderr(Stdio::piped())
.spawn()
.with_context(|| {
format!(
"could not start ffmpeg ({ffmpeg}); 用 [recording] ffmpeg 指定它的路径"
)
format!("could not start ffmpeg ({ffmpeg}); 用 [recording] ffmpeg 指定它的路径")
})?;
let stdin = child
@@ -124,8 +122,7 @@ impl Recorder {
.spawn(move || {
if let Err(err) = pump(&rx, stdin, &mut child, &frames_written) {
log::error!("recording {} stopped: {err:#}", path.display());
*error.lock().expect("rec error mutex poisoned") =
Some(format!("{err:#}"));
*error.lock().expect("rec error mutex poisoned") = Some(format!("{err:#}"));
}
})?
};
@@ -191,7 +188,10 @@ impl Recorder {
dto.active = false;
if let Some(error) = dto.error.as_deref() {
log::warn!("recording 0x{:08X} finished with an error: {error}", self.camera);
log::warn!(
"recording 0x{:08X} finished with an error: {error}",
self.camera
);
} else {
log::info!(
"recording 0x{:08X} stopped: {} frames -> {}",
@@ -291,7 +291,9 @@ fn pump(
let reason = child_stderr(child);
return Err(anyhow!(
"writing to ffmpeg failed: {err}{}",
reason.map(|r| format!(";ffmpeg 说:{r}")).unwrap_or_default()
reason
.map(|r| format!(";ffmpeg 说:{r}"))
.unwrap_or_default()
));
}
@@ -356,7 +358,11 @@ impl RecordingSet {
// 文件是它启动之后才创建的。先开新的话,新的一段会挑到与上一段相同的名字,然后被
// 它的 `-y` 覆盖掉——而上一段刚要收尾。先停就能保证上一个文件已经落地,`exists()`
// 于是是可信的。
let previous = self.inner.lock().expect("recording mutex poisoned").remove(&camera);
let previous = self
.inner
.lock()
.expect("recording mutex poisoned")
.remove(&camera);
if let Some(previous) = previous {
log::warn!("0x{camera:08X} was already recording; the previous take is being closed");
@@ -399,7 +405,12 @@ impl RecordingSet {
/// 把一帧喂给对应那一路的录制器。在 UDP 接收线程里被调用,所以必须快。
pub fn feed(&self, frame: &Arc<Frame>) {
// 先快速判断有没有在录,避免每帧都抢锁。
if self.inner.lock().expect("recording mutex poisoned").is_empty() {
if self
.inner
.lock()
.expect("recording mutex poisoned")
.is_empty()
{
return;
}
@@ -498,8 +509,7 @@ mod tests {
}
let mut out = Vec::new();
let mut encoder =
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80);
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80);
encoder.encode_image(&img).expect("encodes");
out
}
@@ -525,10 +535,8 @@ mod tests {
}
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"cam-desktop-rec-{tag}-{}",
std::process::id()
));
let dir =
std::env::temp_dir().join(format!("cam-desktop-rec-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("creates the temp dir");
dir
@@ -585,7 +593,10 @@ mod tests {
let path = dto.path.expect("has a path");
let meta = std::fs::metadata(&path).expect("the file exists");
assert!(meta.len() > 0, "an empty container is a failure, not a take");
assert!(
meta.len() > 0,
"an empty container is a failure, not a take"
);
if let Some((frames, width, height)) = ffprobe_frames(&path) {
assert_eq!(frames, 20, "ffmpeg wrote every frame we fed it");
@@ -704,7 +715,10 @@ mod tests {
let decoded = image::open(&path).expect("the PNG is readable");
assert_eq!((decoded.width(), decoded.height()), (64, 48));
assert!(
path.file_name().unwrap().to_string_lossy().starts_with("shot-3D4D8A9A-"),
path.file_name()
.unwrap()
.to_string_lossy()
.starts_with("shot-3D4D8A9A-"),
"文件名要能看出是哪一路截的:{path:?}"
);
@@ -727,7 +741,10 @@ mod tests {
let forced = unique_path(&dir, "shot-00000001-fixed", "png");
std::fs::write(&forced, b"").unwrap();
let forced2 = unique_path(&dir, "shot-00000001-fixed", "png");
assert!(forced2.to_string_lossy().ends_with("-2.png"), "got {forced2:?}");
assert!(
forced2.to_string_lossy().ends_with("-2.png"),
"got {forced2:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
@@ -755,7 +772,10 @@ mod tests {
assert_eq!(names.len(), 2, "got {names:?}");
assert!(names.contains(&"a.mp4"));
assert!(names.contains(&"b.PNG"), "后缀大小写不该决定它是不是一张截图");
assert!(
names.contains(&"b.PNG"),
"后缀大小写不该决定它是不是一张截图"
);
assert_eq!(files[0].kind, "video");
let _ = std::fs::remove_dir_all(&dir);
+179 -15
View File
@@ -29,14 +29,15 @@
//! 小端。16 字节头,前端切掉它就能拿到一段完整的 JPEG。`sender_id` 让前端知道这一帧是
//! 哪块板子的——用户端只画绑定的那一路,裁判端按槽位分配画两路。
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use futures_util::{SinkExt, StreamExt};
use serde::Serialize;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::broadcast;
use tokio::sync::{broadcast, watch};
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
use tokio_tungstenite::tungstenite::http::StatusCode;
use tokio_tungstenite::tungstenite::{Bytes, Message};
@@ -46,24 +47,43 @@ use cam_core::snapshot::{HitDto, Snapshot};
/// 二进制帧头长度。
pub const FRAME_HEADER_LEN: usize = 16;
/// 广播通道的容量。
///
/// 满了之后 `send` 会挤掉最老的(接收慢的连接会拿到 `Lagged`)——对实时画面这正是想要
/// 的:落后 200 帧的客户端需要的是最新的那一帧,不是补齐历史。按每帧 16+22 kB 算,
/// 256 个槽位约 5.6 MB 上限。
/// Ordered control-message capacity. JPEGs use a separate latest-value channel.
pub const CHANNEL_CAPACITY: usize = 256;
const LOCAL_VIDEO_MAX_AGE: Duration = Duration::from_millis(100);
/// 向界面推送的消息。
pub enum ServerMsg {
/// 一帧原始 JPEG(已经拼好 16 字节头)。
///
/// 载荷用 `Bytes` 而不是 `Arc<Vec<u8>>`:它本身就是引用计数的缓冲,克隆是加一次
/// 引用计数,而且正好是 tungstenite 要的类型——每个连接一次多余的拷贝就此消失。
Binary(Bytes),
/// JSON 文本。
Text(String),
}
#[derive(Clone)]
pub struct VideoPacket {
frame_id: u32,
bytes: Bytes,
published_at: Instant,
published_unix_ms: u64,
}
pub type VideoFrames = HashMap<u32, VideoPacket>;
pub type VideoSender = watch::Sender<VideoFrames>;
/// Video uses latest-value semantics; control messages keep ordered delivery.
pub fn publish_video(tx: &VideoSender, frame: &protocol::Frame) {
let packet = VideoPacket {
frame_id: frame.frame_id,
bytes: encode_frame(frame).into(),
published_at: Instant::now(),
published_unix_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64,
};
tx.send_modify(|latest| {
latest.insert(frame.sender_id, packet);
});
}
/// JSON 文本消息的信封。
///
/// 用内部标签 `t` 而不是两个字段:前端一个 `switch (msg.t)` 就能分发,且不需要为每种
@@ -82,6 +102,14 @@ pub enum WireMsg {
/// 写成带字段的结构体而不是 `Note(String)`:serde 的**内部标签**(`#[serde(tag)]`)
/// 只支持载荷是 map 的变体,newtype 里放一个字符串会在运行时报错,而不是编译期。
Note { message: String },
/// Timestamp precedes its JPEG so a stalled WebView can reject queued data.
FrameTiming {
camera: u32,
#[serde(rename = "frameId")]
frame_id: u32,
#[serde(rename = "publishedUnixMs")]
published_unix_ms: u64,
},
}
/// 前端连接这条通道需要的信息。
@@ -144,6 +172,7 @@ pub async fn serve(
listener: TcpListener,
token: String,
tx: broadcast::Sender<Arc<ServerMsg>>,
video: VideoSender,
clients: Arc<AtomicUsize>,
) {
loop {
@@ -159,9 +188,10 @@ pub async fn serve(
let tx = tx.clone();
let token = token.clone();
let clients = Arc::clone(&clients);
let video = video.subscribe();
tauri::async_runtime::spawn(async move {
if let Err(err) = handle_client(stream, peer, &token, tx, clients).await {
if let Err(err) = handle_client(stream, peer, &token, tx, video, clients).await {
log::debug!("stream: client {peer} ended: {err}");
}
});
@@ -173,6 +203,7 @@ async fn handle_client(
peer: std::net::SocketAddr,
token: &str,
tx: broadcast::Sender<Arc<ServerMsg>>,
mut video: watch::Receiver<VideoFrames>,
clients: Arc<AtomicUsize>,
) -> anyhow::Result<()> {
stream.set_nodelay(true).ok();
@@ -201,6 +232,8 @@ async fn handle_client(
let (mut sink, mut incoming) = ws.split();
let mut rx = tx.subscribe();
video.mark_changed();
let mut last_sent = HashMap::new();
let mut keepalive = tokio::time::interval(Duration::from_secs(20));
let count = clients.fetch_add(1, Ordering::Relaxed) + 1;
log::info!("stream: interface connected from {peer} ({count} connected)");
@@ -212,12 +245,35 @@ async fn handle_client(
.unwrap_or_else(|_| "{}".into());
let _ = sink.send(Message::text(hello)).await;
let result = loop {
let result = 'messages: loop {
tokio::select! {
changed = video.changed() => {
if changed.is_err() { break Ok(()); }
// Release the watch read lock before awaiting socket writes.
let latest: Vec<_> = video.borrow_and_update().iter()
.map(|(&camera, packet)| (camera, packet.clone())).collect();
for (camera, packet) in latest {
if last_sent.get(&camera) == Some(&packet.frame_id) { continue; }
if packet.published_at.elapsed() >= LOCAL_VIDEO_MAX_AGE { continue; }
let timing = serde_json::to_string(&WireMsg::FrameTiming {
camera, frame_id: packet.frame_id, published_unix_ms: packet.published_unix_ms,
})?;
// Keep the 16-byte JPEG header compatible. This small text
// message carries host queue age through TCP/WebView queues.
let send = async {
sink.send(Message::text(timing)).await?;
sink.send(Message::Binary(packet.bytes)).await
};
match tokio::time::timeout(LOCAL_VIDEO_MAX_AGE, send).await {
Ok(Ok(())) => { last_sent.insert(camera, packet.frame_id); }
Ok(Err(err)) => break 'messages Err(anyhow::anyhow!("video send failed: {err}")),
Err(_) => break 'messages Err(anyhow::anyhow!("video client stalled")),
}
}
},
message = rx.recv() => match message {
Ok(msg) => {
let out = match &*msg {
ServerMsg::Binary(bytes) => Message::Binary(bytes.clone()),
ServerMsg::Text(text) => Message::text(text.clone()),
};
@@ -334,6 +390,114 @@ mod tests {
assert_eq!(random_token().len(), 48);
}
#[test]
fn a_slow_video_consumer_keeps_only_the_latest_frame_of_each_camera() {
let (video, mut reader) = watch::channel(VideoFrames::new());
for id in 1..=1000 {
publish_video(&video, &frame(1, id, 100));
publish_video(&video, &frame(2, id + 10, 100));
}
let latest = reader.borrow_and_update();
assert_eq!(latest.len(), 2);
assert_eq!(latest[&1].frame_id, 1000);
assert_eq!(latest[&2].frame_id, 1010);
}
#[tokio::test]
async fn a_reconnecting_interface_receives_cached_latest_frames_without_waiting_for_capture() {
let (listener, port) = bind().await.unwrap();
let (control, _) = broadcast::channel(CHANNEL_CAPACITY);
let (video, _) = watch::channel(VideoFrames::new());
for id in 1..=100 {
publish_video(&video, &frame(1, id, 100));
publish_video(&video, &frame(2, id + 10, 100));
}
let task = tokio::spawn(serve(
listener,
"test-token".into(),
control,
video,
Arc::new(AtomicUsize::new(0)),
));
let (mut client, _) =
tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/?token=test-token"))
.await
.unwrap();
let mut received = Vec::new();
let mut timings = HashMap::new();
while received.len() < 2 {
let message = tokio::time::timeout(Duration::from_secs(2), client.next())
.await
.unwrap()
.unwrap()
.unwrap();
if let Message::Text(text) = &message {
let value: serde_json::Value = serde_json::from_str(text).unwrap();
if value["t"] == "frameTiming" {
assert!(value["publishedUnixMs"].as_u64().unwrap() > 0);
timings.insert(
value["camera"].as_u64().unwrap() as u32,
value["frameId"].as_u64().unwrap() as u32,
);
}
}
if let Message::Binary(bytes) = message {
let (camera, frame_id, _, _, _) = decode_frame_header(&bytes).unwrap();
assert_eq!(timings.get(&camera), Some(&frame_id));
received.push((camera, frame_id));
}
}
received.sort_unstable();
assert_eq!(received, vec![(1, 100), (2, 110)]);
client.close(None).await.unwrap();
task.abort();
}
#[tokio::test]
async fn reconnect_does_not_replay_expired_cached_video() {
let (listener, port) = bind().await.unwrap();
let (control, _) = broadcast::channel(CHANNEL_CAPACITY);
let (video, _) = watch::channel(VideoFrames::new());
publish_video(&video, &frame(1, 1, 100));
video.send_modify(|latest| {
latest.get_mut(&1).unwrap().published_at = Instant::now() - Duration::from_secs(1)
});
let task = tokio::spawn(serve(
listener,
"test-token".into(),
control,
video.clone(),
Arc::new(AtomicUsize::new(0)),
));
let (mut client, _) =
tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/?token=test-token"))
.await
.unwrap();
let stale = tokio::time::timeout(Duration::from_millis(100), async {
while let Some(message) = client.next().await {
if matches!(message.unwrap(), Message::Binary(_)) {
return;
}
}
})
.await;
assert!(stale.is_err());
publish_video(&video, &frame(1, 2, 100));
let fresh = tokio::time::timeout(Duration::from_secs(2), async {
while let Some(message) = client.next().await {
if let Message::Binary(bytes) = message.unwrap() {
return decode_frame_header(&bytes).unwrap().1;
}
}
panic!("video connection closed");
})
.await
.unwrap();
assert_eq!(fresh, 2);
client.close(None).await.unwrap();
task.abort();
}
#[test]
fn the_wire_envelope_carries_the_message_kind() {
let json = serde_json::to_string(&WireMsg::Hit(HitDto {
+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),
};
}