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:
@@ -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。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 ¬es {
|
||||
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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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