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
+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 {