desktop: Tauri v2 后端 + 共享网络层(用户端/裁判端双身份)
把桌面端从 Slint 迁到 Tauri v2,后端先落地,界面待接。拆成三层: - desktop/core(cam-core,67 测试):从旧 client/ 抽出的非界面部分。UDP 接收按 发送方重组、装甲板发现与血量、帧率窗口、槽位分配、配置与密码。 - desktop/src-tauri(27 测试):Tauri 后端。回环 WebSocket 推二进制 JPEG、 每 200ms 一份状态快照、密码闸门、截图、ffmpeg 录制 mp4、演示数据源。 - protocol:新增 ArmorHealth::decode_from,把帧头里的 sender_id 交出来—— 装甲板一直带着 MAC 派生 id 在广播,旧解码路径把它丢了,所以"装甲板下拉可选" 不需要改固件。 几个改对了的旧问题: - 槽位分配:旧实现只排除"槽位自己原来那个 id",于是队伍一的板子一离线,它的槽位 会去拿队伍二那块,两格显示同一块板子。现在候选要排除另一个槽位占着的 id。 - 帧率窗口的基线:掉线又回来的板子带着一个很大的累计帧数回来,从 0 起算会报出 几百 fps。改成 seeded + 用"已结算窗口数"判断有没有读数。 - 密码哈希:password-hash 能解析 `$argon2id$broken` 这种截断串,于是"配置坏了" 会被读成"密码错了"。现在要求盐与摘要都在。 - 录制文件名:同一秒停一次再开一次会用同一个名字,ffmpeg 的 -y 静默覆盖上一段。 现在先收尾上一段再开新的,并且重名往后排号。 - 两道闸门:帧的限制与发现列表的限制分开。设置界面需要列候选摄像头,但**画面** 不需要——旧版把两件事绑在一道闸门上,只能在"能列候选"和"挡住对手"之间二选一。 测试:protocol 15、cam-core 67、desktop 27,其中录制那条真的起 ffmpeg 并用 ffprobe 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
//! 界面能调用的命令。
|
||||
//!
|
||||
//! # 每个会改东西的命令都过密码闸门
|
||||
//!
|
||||
//! 界面里有一个解锁对话框,但那只是入口:这些函数才是执行点。所以每个改变配置的命令
|
||||
//! 都显式调用 [`AppState::require_unlocked`],而 `presented` 允许在同一次调用里带上
|
||||
//! 密码(前端在解锁后立刻改绑定时就是这样)。
|
||||
//!
|
||||
//! # 为什么错误是 `String`
|
||||
//!
|
||||
//! Tauri 把 `Err` 序列化给前端,而 `anyhow::Error` 不实现 `Serialize`。这些错误是给
|
||||
//! **现场的人**看的("密码不对"、"还没有收到这一路的画面"),本来就该是给人读的句子,
|
||||
//! 所以直接传字符串,同时用 `log::warn!` 留下带上下文的版本。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use cam_core::config::Role;
|
||||
use cam_core::snapshot::{ConfigView, Snapshot};
|
||||
use cam_core::HudConfig;
|
||||
|
||||
use crate::recording::{list_media, MediaFileDto};
|
||||
use crate::server::StreamInfo;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 还没有测到帧率时,告诉 ffmpeg 的默认输入帧率。
|
||||
///
|
||||
/// 传感器在这个配置下实测 22 fps,但写死 22 是在撒谎——真正的问题不是"猜得准不准",
|
||||
/// 而是这个数会决定回放速度,所以它一律写进日志与 [`cam_core::snapshot::RecordingDto`]。
|
||||
const FALLBACK_INPUT_FPS: f32 = 20.0;
|
||||
|
||||
type Shared<'a> = State<'a, Arc<AppState>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 只读
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 视频通道的地址与 token。前端启动时拉一次。
|
||||
#[tauri::command]
|
||||
pub fn stream_info(state: Shared<'_>) -> StreamInfo {
|
||||
state.stream_info()
|
||||
}
|
||||
|
||||
/// 当前完整状态。
|
||||
#[tauri::command]
|
||||
pub fn get_snapshot(state: Shared<'_>) -> Snapshot {
|
||||
state.snapshot()
|
||||
}
|
||||
|
||||
/// 配置里可以给界面看的那部分(不含密码哈希)。用户端第一次启动用它判断"要不要设密码"。
|
||||
#[tauri::command]
|
||||
pub fn get_config(state: Shared<'_>) -> ConfigView {
|
||||
ConfigView::from(&state.config())
|
||||
}
|
||||
|
||||
/// 录制目录里的 mp4 与 png,新的在前。
|
||||
#[tauri::command]
|
||||
pub fn list_media_files(state: Shared<'_>) -> Vec<MediaFileDto> {
|
||||
list_media(&state.config().recordings_dir())
|
||||
}
|
||||
|
||||
/// 配置文件的路径。现场排查时要能告诉人"去看这个文件"。
|
||||
#[tauri::command]
|
||||
pub fn config_path(state: Shared<'_>) -> String {
|
||||
state.config_path.display().to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 密码
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 校验密码并把本次运行标记为已解锁。
|
||||
#[tauri::command]
|
||||
pub fn unlock(password: String, state: Shared<'_>) -> Result<bool, String> {
|
||||
state
|
||||
.unlock(&password)
|
||||
.map_err(|err| format!("存下来的密码哈希读不出来:{err}"))
|
||||
}
|
||||
|
||||
/// 手动重新上锁(并顺手把发现列表关掉)。
|
||||
#[tauri::command]
|
||||
pub fn lock(state: Shared<'_>) {
|
||||
state.lock();
|
||||
}
|
||||
|
||||
/// 首次设置密码,或修改密码。
|
||||
#[tauri::command]
|
||||
pub fn set_password(
|
||||
current: Option<String>,
|
||||
fresh: String,
|
||||
state: Shared<'_>,
|
||||
) -> Result<ConfigView, String> {
|
||||
state
|
||||
.set_password(current.as_deref(), &fresh)
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
|
||||
Ok(ConfigView::from(&state.config()))
|
||||
}
|
||||
|
||||
/// 设置面板打开/关闭带来的发现列表。打开需要已解锁。
|
||||
#[tauri::command]
|
||||
pub fn set_discovery_open(open: bool, state: Shared<'_>) -> Result<(), String> {
|
||||
state.set_discovery_open(open)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 绑定与身份
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 绑定(或解除绑定)一块图传板。
|
||||
#[tauri::command]
|
||||
pub fn bind_camera(
|
||||
camera_id: Option<u32>,
|
||||
password: Option<String>,
|
||||
state: Shared<'_>,
|
||||
) -> Result<ConfigView, String> {
|
||||
state.require_unlocked(password.as_deref())?;
|
||||
|
||||
state
|
||||
.update_config(move |config| config.camera_id = camera_id)
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
|
||||
Ok(ConfigView::from(&state.config()))
|
||||
}
|
||||
|
||||
/// 绑定(或解除绑定)一块装甲板主板。
|
||||
#[tauri::command]
|
||||
pub fn bind_armor(
|
||||
armor_id: Option<u32>,
|
||||
password: Option<String>,
|
||||
state: Shared<'_>,
|
||||
) -> Result<ConfigView, String> {
|
||||
state.require_unlocked(password.as_deref())?;
|
||||
|
||||
state
|
||||
.update_config(move |config| config.armor_id = armor_id)
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
|
||||
Ok(ConfigView::from(&state.config()))
|
||||
}
|
||||
|
||||
/// 自己属于哪一边(0/1)。改错队号会让血条显示对手的血量,所以也要密码。
|
||||
#[tauri::command]
|
||||
pub fn set_team(
|
||||
team: u8,
|
||||
password: Option<String>,
|
||||
state: Shared<'_>,
|
||||
) -> Result<ConfigView, String> {
|
||||
state.require_unlocked(password.as_deref())?;
|
||||
|
||||
if team > 1 {
|
||||
return Err("队伍号只能是 0(队伍一)或 1(队伍二)".into());
|
||||
}
|
||||
|
||||
state
|
||||
.update_config(move |config| config.team = team)
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
|
||||
Ok(ConfigView::from(&state.config()))
|
||||
}
|
||||
|
||||
/// 切换身份。
|
||||
///
|
||||
/// **离开用户端要密码**:否则解锁界面就形同虚设——切到裁判端就能看两路画面、还能随便
|
||||
/// 改配置。反过来(裁判端 → 用户端)不需要:裁判端本来就没有密码。
|
||||
#[tauri::command]
|
||||
pub fn set_role(
|
||||
role: Role,
|
||||
password: Option<String>,
|
||||
state: Shared<'_>,
|
||||
) -> Result<ConfigView, String> {
|
||||
let current = state.config().role;
|
||||
|
||||
if current.needs_password() && current != role {
|
||||
state.require_unlocked(password.as_deref())?;
|
||||
}
|
||||
|
||||
state
|
||||
.update_config(move |config| {
|
||||
config.role = role;
|
||||
// 记下"人选过了":下次启动直接进这个身份,不再问。
|
||||
config.role_chosen = true;
|
||||
})
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
|
||||
Ok(ConfigView::from(&state.config()))
|
||||
}
|
||||
|
||||
/// 左右镜像。摄像头装反了才需要,不是判罚相关的东西,所以不设密码。
|
||||
#[tauri::command]
|
||||
pub fn set_mirror(mirror: bool, state: Shared<'_>) -> Result<ConfigView, String> {
|
||||
state
|
||||
.update_config(move |config| config.mirror = mirror)
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
|
||||
Ok(ConfigView::from(&state.config()))
|
||||
}
|
||||
|
||||
/// 保存 HUD 设置(血条、瞄准器、网格、命中反馈)。
|
||||
#[tauri::command]
|
||||
pub fn save_hud(hud: HudConfig, state: Shared<'_>) -> Result<ConfigView, String> {
|
||||
state
|
||||
.update_config(move |config| config.hud = hud)
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
|
||||
Ok(ConfigView::from(&state.config()))
|
||||
}
|
||||
|
||||
/// 裁判端两个槽位的手动指定。
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_referee_slots(
|
||||
camera_a: Option<u32>,
|
||||
camera_b: Option<u32>,
|
||||
armor_a: Option<u32>,
|
||||
armor_b: Option<u32>,
|
||||
manual: bool,
|
||||
state: Shared<'_>,
|
||||
) -> Result<ConfigView, String> {
|
||||
state
|
||||
.update_config(move |config| {
|
||||
config.referee.slot_a = camera_a;
|
||||
config.referee.slot_b = camera_b;
|
||||
config.referee.armor_a = armor_a;
|
||||
config.referee.armor_b = armor_b;
|
||||
config.referee.manual_slots = manual;
|
||||
})
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
|
||||
Ok(ConfigView::from(&state.config()))
|
||||
}
|
||||
|
||||
/// 录制目录与 ffmpeg 路径。
|
||||
#[tauri::command]
|
||||
pub fn set_recording_options(
|
||||
dir: Option<String>,
|
||||
ffmpeg: Option<String>,
|
||||
crf: Option<u8>,
|
||||
preset: Option<String>,
|
||||
state: Shared<'_>,
|
||||
) -> Result<ConfigView, String> {
|
||||
state
|
||||
.update_config(move |config| {
|
||||
config.recording.dir = dir.filter(|d| !d.trim().is_empty());
|
||||
if let Some(ffmpeg) = ffmpeg.filter(|f| !f.trim().is_empty()) {
|
||||
config.recording.ffmpeg = ffmpeg;
|
||||
}
|
||||
if let Some(crf) = crf {
|
||||
config.recording.crf = crf;
|
||||
}
|
||||
if let Some(preset) = preset.filter(|p| !p.trim().is_empty()) {
|
||||
config.recording.preset = preset;
|
||||
}
|
||||
})
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
|
||||
Ok(ConfigView::from(&state.config()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 截图与录制
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 把某一路最新的一帧存成 PNG。
|
||||
#[tauri::command]
|
||||
pub fn screenshot(camera: u32, state: Shared<'_>) -> Result<String, String> {
|
||||
let frame = state
|
||||
.latest_frames()
|
||||
.latest(camera)
|
||||
.ok_or_else(|| format!("还没有收到 0x{camera:08X} 的画面,暂时截不了"))?;
|
||||
|
||||
let config = state.config();
|
||||
let dir = config.recordings_dir().join("shots");
|
||||
std::fs::create_dir_all(&dir).map_err(|err| format!("建不了截图目录 {dir:?}:{err}"))?;
|
||||
|
||||
let path = dir.join(format!(
|
||||
"shot-{camera:08X}-{}.png",
|
||||
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S")
|
||||
));
|
||||
|
||||
let image = image::load_from_memory_with_format(&frame.data, image::ImageFormat::Jpeg)
|
||||
.map_err(|err| format!("这一帧不是能解码的 JPEG:{err}"))?;
|
||||
|
||||
image
|
||||
.save_with_format(&path, image::ImageFormat::Png)
|
||||
.map_err(|err| format!("写不了 {}:{err}", path.display()))?;
|
||||
|
||||
log::info!(
|
||||
"screenshot of 0x{camera:08X} (frame {}) -> {}",
|
||||
frame.frame_id,
|
||||
path.display()
|
||||
);
|
||||
|
||||
Ok(path.display().to_string())
|
||||
}
|
||||
|
||||
/// 开始录制若干路。
|
||||
#[tauri::command]
|
||||
pub fn start_recording(
|
||||
cameras: Vec<u32>,
|
||||
state: Shared<'_>,
|
||||
) -> Result<Vec<cam_core::snapshot::RecordingDto>, String> {
|
||||
if cameras.is_empty() {
|
||||
return Err("没有指定要录哪一路".into());
|
||||
}
|
||||
|
||||
let config = state.config();
|
||||
let dir = config.recordings_dir();
|
||||
|
||||
let mut started = Vec::new();
|
||||
|
||||
for camera in cameras {
|
||||
// 帧率:配置里指定了就用它,否则用实测值。两者都没有时用保守默认值并记日志
|
||||
// ——猜错的表现是回放速度不对,而那个数会写进 DTO 与日志。
|
||||
let fps = if config.recording.fps > 0.0 {
|
||||
config.recording.fps
|
||||
} else {
|
||||
match state.fps_of(camera) {
|
||||
Some(measured) if measured > 1.0 => measured,
|
||||
_ => {
|
||||
log::warn!(
|
||||
"0x{camera:08X} 还没有测到帧率,录制先按 {FALLBACK_INPUT_FPS} fps 走;\
|
||||
回放速度可能不准,可以改用 [recording] fps 明确指定"
|
||||
);
|
||||
FALLBACK_INPUT_FPS
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let dto = state
|
||||
.recordings
|
||||
.start(
|
||||
camera,
|
||||
&dir,
|
||||
&config.recording.ffmpeg,
|
||||
fps,
|
||||
config.recording.crf,
|
||||
&config.recording.preset,
|
||||
)
|
||||
.map_err(|err| format!("起不了录制:{err:#}"))?;
|
||||
|
||||
started.push(dto);
|
||||
}
|
||||
|
||||
Ok(started)
|
||||
}
|
||||
|
||||
/// 停止录制。`cameras` 为空表示全部停止。
|
||||
#[tauri::command]
|
||||
pub fn stop_recording(
|
||||
cameras: Vec<u32>,
|
||||
state: Shared<'_>,
|
||||
) -> Result<Vec<cam_core::snapshot::RecordingDto>, String> {
|
||||
let stopped = if cameras.is_empty() {
|
||||
state.recordings.stop_all()
|
||||
} else {
|
||||
cameras
|
||||
.into_iter()
|
||||
.filter_map(|camera| state.recordings.stop(camera))
|
||||
.collect()
|
||||
};
|
||||
|
||||
Ok(stopped)
|
||||
}
|
||||
|
||||
/// 在访达/资源管理器里显示一个文件。
|
||||
///
|
||||
/// 只允许指向录制目录里的东西:这是一个"用系统程序打开路径"的命令,把它做成任意路径
|
||||
/// 都能传的通用工具没有任何好处。
|
||||
#[tauri::command]
|
||||
pub fn reveal(path: String, state: Shared<'_>) -> Result<(), String> {
|
||||
let dir = state.config().recordings_dir();
|
||||
let target = PathBuf::from(&path);
|
||||
|
||||
if !is_within(&dir, &target) {
|
||||
return Err(format!("只能打开录制目录({})里的文件", dir.display()));
|
||||
}
|
||||
|
||||
if !target.exists() {
|
||||
return Err(format!("{} 不存在了", target.display()));
|
||||
}
|
||||
|
||||
tauri_plugin_opener::reveal_item_in_dir(&target)
|
||||
.map_err(|err| format!("打开 {path} 失败:{err}"))
|
||||
}
|
||||
|
||||
/// `child` 在 `parent` 里面吗。
|
||||
///
|
||||
/// 逐段比较而不是字符串前缀:`/a/bc` 以 `/a/b` 为前缀,但它不在 `/a/b` 里面。
|
||||
fn is_within(parent: &Path, child: &Path) -> bool {
|
||||
let Ok(parent) = parent.canonicalize() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match child.canonicalize() {
|
||||
Ok(child) => child.starts_with(parent),
|
||||
// 还不存在(比如刚被删掉)时退回文本比较,但仍然要求是父目录下的相对路径。
|
||||
Err(_) => child.parent().is_some_and(|p| {
|
||||
p.canonicalize()
|
||||
.map(|p| p.starts_with(&parent))
|
||||
.unwrap_or(false)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 退出与清空
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 清空配置:把文件挪到 `.bak`,然后回到"第一次启动"的状态。
|
||||
///
|
||||
/// 不直接删的原因是现场排查:一个人说"我重置过了还是这样"时,能看一眼旧文件是最快的
|
||||
/// 结论。
|
||||
#[tauri::command]
|
||||
pub fn clear_config(password: Option<String>, state: Shared<'_>) -> Result<String, String> {
|
||||
state.require_unlocked(password.as_deref())?;
|
||||
|
||||
let path = state.config_path.clone();
|
||||
|
||||
if path.exists() {
|
||||
let backup = PathBuf::from(format!("{}.bak", path.display()));
|
||||
std::fs::rename(&path, &backup)
|
||||
.map_err(|err| format!("挪走 {} 失败:{err}", path.display()))?;
|
||||
|
||||
log::warn!("配置已重置,旧文件留在 {}", backup.display());
|
||||
}
|
||||
|
||||
state.lock();
|
||||
|
||||
Ok(format!(
|
||||
"配置已重置(旧文件备份在 {})。请重启程序。",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// 退出程序。用户端要密码——现场"随手关掉"也是一种需要挡住的误操作。
|
||||
#[tauri::command]
|
||||
pub fn quit_app(
|
||||
password: Option<String>,
|
||||
app: AppHandle,
|
||||
state: Shared<'_>,
|
||||
) -> Result<(), String> {
|
||||
state.require_unlocked(password.as_deref())?;
|
||||
|
||||
// 先把录制收尾,否则 ffmpeg 会留下一个没有索引的 mp4。
|
||||
let stopped = state.recordings.stop_all();
|
||||
for dto in stopped {
|
||||
log::info!(
|
||||
"退出前收尾录制 0x{:08X}:{} 帧 -> {:?}",
|
||||
dto.camera,
|
||||
dto.frames_written,
|
||||
dto.path
|
||||
);
|
||||
}
|
||||
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 主动推一次快照。绑定改动之后前端不必等 200 ms。
|
||||
#[tauri::command]
|
||||
pub fn refresh(state: Shared<'_>) {
|
||||
state.push_snapshot();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_within;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn a_sibling_directory_with_a_shared_prefix_is_not_inside() {
|
||||
let dir = std::env::temp_dir().join(format!("cam-reveal-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("rec")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("rec-evil")).unwrap();
|
||||
|
||||
let inside = dir.join("rec").join("a.mp4");
|
||||
std::fs::write(&inside, b"x").unwrap();
|
||||
|
||||
let outside = dir.join("rec-evil").join("b.mp4");
|
||||
std::fs::write(&outside, b"x").unwrap();
|
||||
|
||||
assert!(is_within(&dir.join("rec"), &inside));
|
||||
assert!(
|
||||
!is_within(&dir.join("rec"), &outside),
|
||||
"`/a/rec-evil` 以 `/a/rec` 为前缀,但它不在里面"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absolute_path_outside_is_refused() {
|
||||
let dir = std::env::temp_dir().join(format!("cam-reveal2-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
assert!(!is_within(&dir, &PathBuf::from("/etc/hosts")));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! 演示模式:本地合成画面与血量,喂进**真实的**接收路径。
|
||||
//!
|
||||
//! # 为什么不是"假界面"
|
||||
//!
|
||||
//! 演示模式不伪造数据给界面看,而是合成**协议数据报**,从
|
||||
//! [`cam_core::receiver::Receiver::datagram`] 与
|
||||
//! [`cam_core::armor::ArmorHealthStore::datagram`] 进去。也就是说它跑的是同一条真实链路:
|
||||
//!
|
||||
//! ```text
|
||||
//! 合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
|
||||
//! 合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈
|
||||
//! ```
|
||||
//!
|
||||
//! 于是演示模式顺带成了这条链路的端到端验证:分片、重组、发现、统计、掉血派生、
|
||||
//! 两道闸门(用户端只收自己那一块)全都在跑真代码。截图、录制也因此**不需要特殊分支**。
|
||||
//!
|
||||
//! # 它绝不能被误读成真实链路
|
||||
//!
|
||||
//! 只在 `--demo` / `CAM_DEMO=1` 下启动,而且 [`cam_core::snapshot::Snapshot::demo`] 为
|
||||
//! true,界面必须显示"演示模式"标记。板子 id 也刻意取成 `0x0DE00001` 这种一眼能看出
|
||||
//! 是人造的形状。
|
||||
//!
|
||||
//! # 线程
|
||||
//!
|
||||
//! 三个:两路画面各一个(各 15fps 合成 JPEG),加一个广播装甲板血量的。它们活到进程
|
||||
//! 结束、不需要退出条件——和真实的接收线程一样。
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::sync::Arc;
|
||||
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,
|
||||
};
|
||||
|
||||
/// 合成画面的尺寸。与实机一致(VGA),这样界面的缩放/比例在演示下也说得通。
|
||||
const WIDTH: u32 = 640;
|
||||
const HEIGHT: u32 = 480;
|
||||
|
||||
/// 合成帧率。实机是 22fps;15 够看出流畅度,又把 CPU 留给真正要验证的界面。
|
||||
const FPS: u64 = 15;
|
||||
|
||||
/// 装甲板广播周期,与固件一致(200 ms)。
|
||||
const ARMOR_PERIOD: Duration = Duration::from_millis(200);
|
||||
|
||||
/// 演示里一次"命中"打掉多少血。故意取一组差异大的值,好看出闪红强度是不是跟着伤害走。
|
||||
const DAMAGE_CYCLE: [u16; 4] = [3, 7, 12, 25];
|
||||
|
||||
pub fn spawn(state: Arc<AppState>) {
|
||||
log::warn!("演示模式:画面与血量都是本地合成的,不是场上的板子");
|
||||
|
||||
for (camera, team) in [(DEMO_CAMERA_A, 0u8), (DEMO_CAMERA_B, 1u8)] {
|
||||
spawn_camera(Arc::clone(&state), camera, team);
|
||||
}
|
||||
|
||||
spawn_armor(state);
|
||||
}
|
||||
|
||||
/// 一路合成画面:周期广播 Hello(让发现列表有东西),并按 15fps 推分片。
|
||||
fn spawn_camera(state: Arc<AppState>, camera: u32, team: u8) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name(format!("demo-cam-{camera:08X}"))
|
||||
.spawn(move || {
|
||||
let from = SocketAddr::V4(SocketAddrV4::new(
|
||||
Ipv4Addr::new(127, 0, 0, 1),
|
||||
// 端口只是让日志/发现里的地址看起来像回事。
|
||||
41000 + team as u16,
|
||||
));
|
||||
|
||||
let hello = protocol::Hello {
|
||||
sender_id: camera,
|
||||
firmware: 0x0DE0,
|
||||
};
|
||||
|
||||
let frame_interval = Duration::from_micros(1_000_000 / FPS);
|
||||
let mut frame_id: u32 = 0;
|
||||
let mut last_hello = std::time::Instant::now() - Duration::from_secs(10);
|
||||
|
||||
loop {
|
||||
let started = std::time::Instant::now();
|
||||
|
||||
// 板子每 2 秒广播一次在线状态,与固件一致。
|
||||
if last_hello.elapsed() >= Duration::from_secs(2) {
|
||||
state
|
||||
.receiver
|
||||
.datagram(&hello.encode(MsgType::Hello), from);
|
||||
last_hello = std::time::Instant::now();
|
||||
}
|
||||
|
||||
let jpeg = render(camera, team, frame_id);
|
||||
|
||||
for fragment in fragment_frame(camera, frame_id, &jpeg, WIDTH as u16, HEIGHT as u16)
|
||||
{
|
||||
state.receiver.datagram(&fragment.encode(), from);
|
||||
}
|
||||
|
||||
frame_id = frame_id.wrapping_add(1);
|
||||
|
||||
// 按目标周期睡,而不是固定 sleep:合成一张 640x480 的 JPEG 要几毫秒,
|
||||
// 不扣掉这段时间的话实际帧率会明显低于 15,演示看起来像在卡。
|
||||
if let Some(rest) = frame_interval.checked_sub(started.elapsed()) {
|
||||
std::thread::sleep(rest);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 两块装甲板主板:各自按脚本掉血、打空、重置。
|
||||
fn spawn_armor(state: Arc<AppState>) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("demo-armor".into())
|
||||
.spawn(move || {
|
||||
let boards = [(DEMO_ARMOR_A, 0u8, 41050u16), (DEMO_ARMOR_B, 1u8, 41051)];
|
||||
|
||||
let mut hp = [100u16, 100u16];
|
||||
// 两块板子的掉血节奏错开,界面上两边的血量不会同时变。
|
||||
let mut ticks = [0u32, 7];
|
||||
let mut hits = [0usize, 1];
|
||||
let mut dead_for = [0u32, 0];
|
||||
|
||||
loop {
|
||||
for (slot, (board, team, port)) in boards.iter().enumerate() {
|
||||
ticks[slot] += 1;
|
||||
|
||||
if hp[slot] == 0 {
|
||||
// 打空之后停一会儿再重置:让界面上"已击毁"的状态真的能被看到。
|
||||
dead_for[slot] += 1;
|
||||
if dead_for[slot] >= 10 {
|
||||
hp[slot] = 100;
|
||||
dead_for[slot] = 0;
|
||||
ticks[slot] = 0;
|
||||
log::info!("demo: 0x{board:08X} 重置为满血(长按 BOOT 的等价动作)");
|
||||
}
|
||||
} else if ticks[slot] % 15 == 0 {
|
||||
// 约 3 秒一次。
|
||||
let damage = DAMAGE_CYCLE[hits[slot] % DAMAGE_CYCLE.len()];
|
||||
hits[slot] += 1;
|
||||
hp[slot] = hp[slot].saturating_sub(damage);
|
||||
}
|
||||
|
||||
let from = SocketAddr::V4(SocketAddrV4::new(
|
||||
Ipv4Addr::new(127, 0, 0, 1),
|
||||
*port,
|
||||
));
|
||||
|
||||
// 主板一次广播四条,整车一条血所以 hp 相同、index 0..3。
|
||||
for index in 0..4u8 {
|
||||
let health = ArmorHealth {
|
||||
team: *team,
|
||||
index,
|
||||
hp: hp[slot],
|
||||
};
|
||||
|
||||
state.armor.datagram(&health.encode_from(*board), from);
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(ARMOR_PERIOD);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 合成一张 PNG/JPEG 用的画面。
|
||||
///
|
||||
/// 内容刻意是为**验收**挑的:
|
||||
///
|
||||
/// - 两路的底色不同(青 / 橙),一眼能分辨左右两格有没有串;
|
||||
/// - 正中有一组同心圆 + 十字,用来核对瞄准器的偏移校准(偏移为 0 时应该正好套住圆心);
|
||||
/// - 每 40 px 一条淡网格,用来核对缩放与平移;
|
||||
/// - 左上角 16 个方块是二进制帧号,截图里能看出画面在推进;
|
||||
/// - 一个随时间走 Lissajous 的实心圆,用来判断两路是不是同一时刻的画面。
|
||||
fn render(camera: u32, team: u8, frame_id: u32) -> Vec<u8> {
|
||||
// 底色:竖直渐变。队伍一偏青、队伍二偏橙。
|
||||
//
|
||||
// 直接铺一块裸缓冲再交给 `from_raw`,而不是逐像素 `put_pixel`:一帧 30 万像素,
|
||||
// 每像素一次带边界检查的写入在 15fps 下是白白烧掉的 CPU。
|
||||
let tint: [i16; 3] = if team == 0 { [0, 46, 40] } else { [58, 30, 0] };
|
||||
let mut raw = vec![0u8; (WIDTH * HEIGHT * 3) as usize];
|
||||
|
||||
for y in 0..HEIGHT {
|
||||
let shade = (y as i16 * 60 / HEIGHT as i16) as i16;
|
||||
let row = &mut raw[(y * WIDTH * 3) as usize..((y + 1) * WIDTH * 3) as usize];
|
||||
|
||||
for pixel in row.chunks_exact_mut(3) {
|
||||
pixel[0] = (18 + tint[0] / 3 + shade) as u8;
|
||||
pixel[1] = (18 + tint[1] / 3 + shade) as u8;
|
||||
pixel[2] = (22 + tint[2] / 3 + shade) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
let mut img = image::RgbImage::from_raw(WIDTH, HEIGHT, raw)
|
||||
.expect("the buffer is exactly WIDTH * HEIGHT * 3 bytes");
|
||||
|
||||
let (cx, cy) = (WIDTH as i32 / 2, HEIGHT as i32 / 2);
|
||||
let t = frame_id as f32 / FPS as f32;
|
||||
|
||||
// 淡网格:每 40 px。缩放/平移时这组线是最直观的参照。
|
||||
for x in (0..WIDTH as i32).step_by(40) {
|
||||
for y in 0..HEIGHT as i32 {
|
||||
if let Some(pixel) = img.get_pixel_mut_checked(x as u32, y as u32) {
|
||||
*pixel = brighten(*pixel, 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in (0..HEIGHT as i32).step_by(40) {
|
||||
for x in 0..WIDTH as i32 {
|
||||
if let Some(pixel) = img.get_pixel_mut_checked(x as u32, y as u32) {
|
||||
*pixel = brighten(*pixel, 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 中心的靶心:两个同心圆 + 四臂。瞄准器偏移校准时对着它看。
|
||||
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]));
|
||||
|
||||
// 二进制帧号:16 个方块,最左边是低位。
|
||||
for bit in 0..16i32 {
|
||||
let on = (frame_id >> bit) & 1 == 1;
|
||||
let x = 12 + bit * 14;
|
||||
let color = if on {
|
||||
image::Rgb([120, 255, 160])
|
||||
} else {
|
||||
image::Rgb([52, 52, 60])
|
||||
};
|
||||
|
||||
fill_rect(&mut img, x, 12, 10, 10, color);
|
||||
}
|
||||
|
||||
// Lissajous 圆点:两路同一个 t 下位置不同,用来分辨"这两格是不是同一块板子"。
|
||||
let phase = if team == 0 { 0.0 } else { 1.7 };
|
||||
let dot_x = cx + (250.0 * (t * 0.9 + phase).sin()) as i32;
|
||||
let dot_y = cy + (150.0 * (t * 1.3 + phase).cos()) as i32;
|
||||
disc(&mut img, dot_x, dot_y, 26, image::Rgb([255, 210, 90]));
|
||||
|
||||
// 底部随时间右移的指示条:截图里能判断这一帧是不是较新的。
|
||||
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]));
|
||||
|
||||
// 摄像头 id 的最低位用一个角标表示,进一步区分两路。
|
||||
let corner = if camera == DEMO_CAMERA_A { 0 } else { 1 };
|
||||
fill_rect(
|
||||
&mut img,
|
||||
WIDTH as i32 - 40 - corner * 24,
|
||||
12,
|
||||
18,
|
||||
18,
|
||||
if corner == 0 {
|
||||
image::Rgb([120, 255, 160])
|
||||
} else {
|
||||
image::Rgb([255, 140, 120])
|
||||
},
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 78);
|
||||
encoder
|
||||
.encode_image(&img)
|
||||
.expect("a synthetic RGB image always encodes");
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn brighten(pixel: image::Rgb<u8>, by: u8) -> image::Rgb<u8> {
|
||||
image::Rgb([
|
||||
pixel[0].saturating_add(by),
|
||||
pixel[1].saturating_add(by),
|
||||
pixel[2].saturating_add(by),
|
||||
])
|
||||
}
|
||||
|
||||
fn put(img: &mut image::RgbImage, x: i32, y: i32, color: image::Rgb<u8>) {
|
||||
if x < 0 || y < 0 || x >= WIDTH as i32 || y >= HEIGHT as i32 {
|
||||
return;
|
||||
}
|
||||
|
||||
img.put_pixel(x as u32, y as u32, color);
|
||||
}
|
||||
|
||||
fn fill_rect(img: &mut image::RgbImage, x: i32, y: i32, w: i32, h: i32, color: image::Rgb<u8>) {
|
||||
for dy in 0..h {
|
||||
for dx in 0..w {
|
||||
put(img, x + dx, y + dy, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn disc(img: &mut image::RgbImage, cx: i32, cy: i32, radius: i32, color: image::Rgb<u8>) {
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
if dx * dx + dy * dy <= radius * radius {
|
||||
put(img, cx + dx, cy + dy, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 细圆环(只画一圈,不填充)。
|
||||
fn ring(img: &mut image::RgbImage, cx: i32, cy: i32, radius: i32, color: image::Rgb<u8>) {
|
||||
let steps = 720;
|
||||
for step in 0..steps {
|
||||
let angle = step as f32 * std::f32::consts::TAU / steps as f32;
|
||||
put(
|
||||
img,
|
||||
cx + (radius as f32 * angle.cos()) as i32,
|
||||
cy + (radius as f32 * angle.sin()) as i32,
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一像素宽的线段(只用来画靶心的四臂,够用就行)。
|
||||
fn arm(img: &mut image::RgbImage, x0: i32, y0: i32, x1: i32, y1: i32, color: image::Rgb<u8>) {
|
||||
let steps = (x1 - x0).abs().max((y1 - y0).abs()).max(1);
|
||||
|
||||
for step in 0..=steps {
|
||||
let f = step as f32 / steps as f32;
|
||||
put(
|
||||
img,
|
||||
x0 + ((x1 - x0) as f32 * f) as i32,
|
||||
y0 + ((y1 - y0) as f32 * f) as i32,
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_synthetic_frame_is_a_decodable_jpeg_of_the_right_size() {
|
||||
let jpeg = render(DEMO_CAMERA_A, 0, 42);
|
||||
|
||||
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");
|
||||
|
||||
assert_eq!((decoded.width(), decoded.height()), (WIDTH, HEIGHT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_cameras_look_different() {
|
||||
// 两路画面必须能被一眼分辨,否则"左右两格是不是同一块板子"就看不出来。
|
||||
let a = render(DEMO_CAMERA_A, 0, 10);
|
||||
let b = render(DEMO_CAMERA_B, 1, 10);
|
||||
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_frames_differ() {
|
||||
// 画面必须真的在动,否则截图看不出链路是活的。
|
||||
assert_ne!(render(DEMO_CAMERA_A, 0, 1), render(DEMO_CAMERA_A, 0, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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() < 200_000,
|
||||
"suspiciously large: {} bytes",
|
||||
jpeg.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! cam-desktop —— ESP32 图传与装甲板系统的桌面端。
|
||||
//!
|
||||
//! 一个进程、两套页面:
|
||||
//!
|
||||
//! - **用户端**(`--role team`):一路画面 + 血条 + 瞄准器,绑定一块图传板与一块装甲板。
|
||||
//! - **裁判端**(`--role referee`,默认):两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。
|
||||
//!
|
||||
//! 界面是 WebView(React 渲染到 canvas),后端是这里的 Rust:UDP 收流、发现、装甲板、
|
||||
//! 配置与密码、录制。视频不经 IPC,而是走一个只监听回环的 WebSocket(见 [`server`])。
|
||||
//!
|
||||
//! # 命令行
|
||||
//!
|
||||
//! ```text
|
||||
//! --role <team|referee> 身份。默认看配置文件,没有配置文件时是 referee
|
||||
//! --config <路径> 配置文件位置
|
||||
//! --demo 演示模式:本地合成画面与血量(标了"演示模式")
|
||||
//! --list 打印发现的摄像头与装甲板后退出
|
||||
//! -h, --help 显示这段帮助
|
||||
//! ```
|
||||
//!
|
||||
//! **未知参数只记一条日志,不报错**:macOS 从访达启动 .app 时会塞进来一个
|
||||
//! `-psn_0_123456`,把它当错误处理的表现是"双击图标什么都没发生"。
|
||||
|
||||
mod commands;
|
||||
mod demo;
|
||||
mod recording;
|
||||
mod server;
|
||||
mod state;
|
||||
|
||||
use std::error::Error;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use cam_core::config::{self, Config, Role};
|
||||
use tauri::{Manager, RunEvent, WindowEvent};
|
||||
|
||||
use state::AppState;
|
||||
|
||||
const USAGE: &str = "\
|
||||
用法: cam-desktop [选项]
|
||||
|
||||
--role <team|referee> 身份。默认看配置文件;没有配置文件时是 referee
|
||||
--config <路径> 配置文件位置
|
||||
--demo 演示模式:画面与血量都是本地合成的
|
||||
--list 打印发现的摄像头与装甲板后退出
|
||||
-h, --help 显示这段帮助
|
||||
|
||||
身份由配置文件记住(~/.config/cam-desktop/config.toml),首次启动会在界面上问。
|
||||
用户端的绑定改动需要密码;裁判端不需要。
|
||||
|
||||
环境变量 CAM_DEMO=1 与 --demo 等价。
|
||||
";
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
if let Err(err) = run() {
|
||||
log::error!("{err}");
|
||||
|
||||
// 走到这里说明连窗口都没起来(配置坏了、回环端口绑不上)。GUI 程序从访达启动时
|
||||
// 没有终端可看,所以把原因也写进一个旁边的文件——现场排查时这是唯一线索。
|
||||
let path = config::default_config_path().with_extension("startup-error.txt");
|
||||
let _ = std::fs::write(&path, format!("{err}\n"));
|
||||
eprintln!("{USAGE}");
|
||||
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// 命令行参数。全部可选——什么都不给时按配置文件走。
|
||||
#[derive(Debug, Default)]
|
||||
struct Args {
|
||||
role: Option<Role>,
|
||||
config: Option<PathBuf>,
|
||||
demo: bool,
|
||||
list: bool,
|
||||
help: bool,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse(argv: impl Iterator<Item = String>) -> Self {
|
||||
let mut args = Args {
|
||||
demo: std::env::var("CAM_DEMO").is_ok_and(|v| v == "1" || v == "true"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut it = argv.peekable();
|
||||
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"-h" | "--help" => args.help = true,
|
||||
"--demo" => args.demo = true,
|
||||
"--list" => args.list = true,
|
||||
"--role" => {
|
||||
args.role = it.next().and_then(|v| Role::parse(&v));
|
||||
}
|
||||
"--config" => {
|
||||
args.config = it.next().map(PathBuf::from);
|
||||
}
|
||||
other => {
|
||||
// 见模块文档:macOS 的 .app 启动参数与 shell 里的笔误都可能出现在这里,
|
||||
// 但报错的代价(窗口起不来)远大于收益。
|
||||
log::debug!("ignoring unknown argument {other:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), Box<dyn Error>> {
|
||||
let args = Args::parse(std::env::args().skip(1));
|
||||
|
||||
if args.help {
|
||||
print!("{USAGE}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_path = args.config.clone().unwrap_or_else(config::default_config_path);
|
||||
|
||||
// 首次启动时导入旧客户端的配置,并把旧文件改名成 .bak。
|
||||
let (mut config, notes) = config::load_with_migration(&config_path)?;
|
||||
|
||||
// 命令行覆盖配置文件,反过来不成立(只在内存里改)。这样现场可以把一台机器临时当
|
||||
// 另一台用,而不动文件。
|
||||
if let Some(role) = args.role {
|
||||
config.role = role;
|
||||
}
|
||||
|
||||
for note in ¬es {
|
||||
log::info!("{note}");
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"role={} team={} camera={:?} armor={:?} mirror={} demo={} config={}",
|
||||
config.role.as_str(),
|
||||
config.team,
|
||||
config.camera_id,
|
||||
config.armor_id,
|
||||
config.mirror,
|
||||
args.demo,
|
||||
config_path.display()
|
||||
);
|
||||
|
||||
let (state, video_socket, armor_socket) =
|
||||
AppState::new(config_path, config, notes, args.demo)?;
|
||||
|
||||
// 视频端口绑不上时没有 socket,接收线程也就没有意义——界面会显示那条说明。
|
||||
if let Some(socket) = video_socket {
|
||||
let receiver = Arc::clone(&state.receiver);
|
||||
std::thread::Builder::new()
|
||||
.name("udp-reader".into())
|
||||
.spawn(move || receiver.run(socket))?;
|
||||
}
|
||||
|
||||
if let Some(socket) = armor_socket {
|
||||
state.armor.spawn(socket);
|
||||
}
|
||||
|
||||
// 顺序有讲究:sink 必须先装好,后台任务才开始推——否则头几帧会掉在空处。
|
||||
state.install_sinks();
|
||||
|
||||
if args.demo {
|
||||
state.note("演示模式已打开:画面与血量由本机合成,不是场上的板子。");
|
||||
demo::spawn(Arc::clone(&state));
|
||||
}
|
||||
|
||||
// --list:等一个广播周期,把发现到的东西打出来。
|
||||
//
|
||||
// 旧的 Slint 客户端在 release 的用户端直接拒绝这个选项(它会列出场上所有板子的 id
|
||||
// 与 IP)。现在这条限制落在别处:用户端的发现列表只在**解锁后的设置面板**里放开,
|
||||
// 而这里的输出只进日志,不进界面。
|
||||
if args.list {
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
|
||||
println!("摄像头:");
|
||||
for camera in state.snapshot().cameras {
|
||||
println!(
|
||||
" {} {} {}",
|
||||
camera.id_text,
|
||||
camera.addr,
|
||||
if camera.online { "在线" } else { "离线" }
|
||||
);
|
||||
}
|
||||
|
||||
println!("装甲板:");
|
||||
for board in state.snapshot().armor_boards {
|
||||
println!(
|
||||
" {} {} 队伍{} {} 血",
|
||||
board.id_text,
|
||||
board.addr,
|
||||
board.team + 1,
|
||||
board.hp
|
||||
);
|
||||
}
|
||||
|
||||
state.recordings.stop_all();
|
||||
_ = state;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let listener_state = Arc::clone(&state);
|
||||
|
||||
let app = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(Arc::clone(&state))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::stream_info,
|
||||
commands::get_snapshot,
|
||||
commands::get_config,
|
||||
commands::list_media_files,
|
||||
commands::config_path,
|
||||
commands::unlock,
|
||||
commands::lock,
|
||||
commands::set_password,
|
||||
commands::set_discovery_open,
|
||||
commands::bind_camera,
|
||||
commands::bind_armor,
|
||||
commands::set_team,
|
||||
commands::set_role,
|
||||
commands::set_mirror,
|
||||
commands::save_hud,
|
||||
commands::set_referee_slots,
|
||||
commands::set_recording_options,
|
||||
commands::screenshot,
|
||||
commands::start_recording,
|
||||
commands::stop_recording,
|
||||
commands::reveal,
|
||||
commands::clear_config,
|
||||
commands::quit_app,
|
||||
commands::refresh,
|
||||
])
|
||||
.setup(move |_app| {
|
||||
listener_state.spawn_background();
|
||||
Ok(())
|
||||
})
|
||||
// 用户端未解锁时拦住"关窗口"与"退出"。
|
||||
//
|
||||
// 这是需求里明确要的(退出/关闭也要密码),所以这里把 close 拦下来并让前端弹
|
||||
// 解锁框。拦不住的路径仍然存在——`kill -9`、强退、拔电源——所以它同样是"防
|
||||
// 随手误操作",不是安全边界。
|
||||
.on_window_event(|window, event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
let state = window.state::<Arc<AppState>>();
|
||||
|
||||
if state.config().role.needs_password() && !state.is_unlocked() {
|
||||
api.prevent_close();
|
||||
state.push(server::WireMsg::ConfirmQuit);
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(tauri::generate_context!())?;
|
||||
|
||||
app.run(|app, event| {
|
||||
if let RunEvent::ExitRequested { api, .. } = &event {
|
||||
let state = app.state::<Arc<AppState>>();
|
||||
|
||||
if state.config().role.needs_password() && !state.is_unlocked() {
|
||||
api.prevent_exit();
|
||||
state.push(server::WireMsg::ConfirmQuit);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let RunEvent::Exit = event {
|
||||
// 收尾录制:ffmpeg 需要把索引写完,否则留下一个放不了的 mp4。
|
||||
let state = app.state::<Arc<AppState>>();
|
||||
let stopped = state.recordings.stop_all();
|
||||
|
||||
for dto in stopped {
|
||||
log::info!(
|
||||
"退出:0x{:08X} 收了 {} 帧 -> {:?}",
|
||||
dto.camera,
|
||||
dto.frames_written,
|
||||
dto.path
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 让 [`Config`] 在日志里可读。
|
||||
///
|
||||
/// 实现成自由函数而不是给 `Config` 加 `Display`:那是 cam-core 的公共类型,而"日志里长
|
||||
/// 什么样"是这个二进制自己的事。
|
||||
#[allow(dead_code)]
|
||||
fn describe(config: &Config) -> String {
|
||||
format!(
|
||||
"role={} team={} camera={:?} armor={:?}",
|
||||
config.role.as_str(),
|
||||
config.team,
|
||||
config.camera_id,
|
||||
config.armor_id
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Args;
|
||||
|
||||
fn args(list: &[&str]) -> Args {
|
||||
Args::parse(list.iter().map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_all_optional() {
|
||||
let parsed = args(&[]);
|
||||
assert!(parsed.role.is_none(), "the config file decides");
|
||||
assert!(!parsed.demo);
|
||||
assert!(!parsed.help);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_and_config_are_read() {
|
||||
let parsed = args(&["--role", "team", "--config", "/tmp/x.toml"]);
|
||||
assert_eq!(parsed.role, Some(cam_core::Role::Team));
|
||||
assert_eq!(parsed.config.unwrap().to_string_lossy(), "/tmp/x.toml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mac_app_launch_argument_does_not_break_startup() {
|
||||
// 从访达双击 .app 时系统会塞进来这个。把它当错误处理的表现是"双击没反应"。
|
||||
let parsed = args(&["-psn_0_123456"]);
|
||||
assert!(parsed.role.is_none());
|
||||
assert!(!parsed.help);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_role_value_falls_back_to_the_config_rather_than_dying() {
|
||||
let parsed = args(&["--role", "teem"]);
|
||||
assert!(parsed.role.is_none(), "typo must not kill the window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_can_be_turned_on_from_the_command_line() {
|
||||
assert!(args(&["--demo"]).demo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
//! 录制为 mp4,以及录制文件的清点。
|
||||
//!
|
||||
//! # 为什么用 ffmpeg 子进程而不是 Rust 编码器
|
||||
//!
|
||||
//! 两路 640x480@22fps 的 H.264 编码在 Rust 侧要引一个大而重的编码器绑定;而现场机器上
|
||||
//! 已经有 ffmpeg(`/opt/homebrew/bin/ffmpeg`),它是硬编码/多线程都调好的。代价是多一个
|
||||
//! **外部依赖**——所以 [Recorder::start] 在 ffmpeg 起不来时返回一个明确的错误,而不是
|
||||
//! 让界面看起来"在录",其实什么都没写。
|
||||
//!
|
||||
//! # 为什么帧率要显式告诉 ffmpeg
|
||||
//!
|
||||
//! `image2pipe` 里的 JPEG 序列**没有时间戳**:一串帧在 ffmpeg 看来就是"以固定帧率播放
|
||||
//! 的图片"。所以必须由我们给出这个数,而它只能来自实测([`crate::state`] 里的帧率窗口)。
|
||||
//! 猜错的表现是回放快放或慢放——而且看不出来是哪里错了,所以在 DTO 与文档里都写明了。
|
||||
//!
|
||||
//! # 为什么经由一个线程和一条有界队列
|
||||
//!
|
||||
//! 喂帧发生在 UDP 接收线程里,而**接收线程绝不能被写盘拖慢**:那一天的表现是链路上
|
||||
//! 本不存在的丢帧。所以中间隔一条长度 64 的队列:写不进去就把帧丢掉并计数,丢帧数会
|
||||
//! 出现在界面上——少了半秒却不说,会看起来像相机的问题。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{ErrorKind, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use cam_core::snapshot::{id_text, RecordingDto};
|
||||
use protocol::Frame;
|
||||
|
||||
/// 写入线程的队列长度。
|
||||
///
|
||||
/// 22fps 下这是约 3 秒的缓冲:够吸收一次写盘的抖动,又不至于在 ffmpeg 卡死时把内存
|
||||
/// 堆起来。
|
||||
const QUEUE: usize = 64;
|
||||
|
||||
/// Unix 毫秒。
|
||||
pub fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 一次录制。
|
||||
pub struct Recorder {
|
||||
camera: u32,
|
||||
path: PathBuf,
|
||||
started_ms: u64,
|
||||
input_fps: f32,
|
||||
/// `None` 表示已经停止喂帧(队列那头的线程会随之收尾)。
|
||||
tx: Option<SyncSender<Arc<Frame>>>,
|
||||
frames_written: Arc<AtomicU64>,
|
||||
frames_dropped: Arc<AtomicU64>,
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
finish: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
/// 启动一路录制。
|
||||
///
|
||||
/// `fps` 是喂给 ffmpeg 的输入帧率,调用方负责给出实测值。
|
||||
pub fn start(
|
||||
camera: u32,
|
||||
dir: &Path,
|
||||
ffmpeg: &str,
|
||||
fps: f32,
|
||||
crf: u8,
|
||||
preset: &str,
|
||||
) -> Result<Self> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.with_context(|| format!("creating the recording directory {}", dir.display()))?;
|
||||
|
||||
// 文件名里带秒级时间戳,但**必须**再查一次有没有同名——同一秒里停一次再开一次
|
||||
// 是很自然的操作("这局重录"),而重名意味着 ffmpeg 的 `-y` 会把上一段直接覆盖掉,
|
||||
// 没有任何提示。现场丢掉的那段往往正好是要复盘的那一段。
|
||||
let path = unique_take_path(dir, camera);
|
||||
|
||||
let mut child = Command::new(ffmpeg)
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y"])
|
||||
// 输入:一串 JPEG。-framerate 必须在 -i 之前,它是 image2 解复用器的选项。
|
||||
.args(["-f", "image2pipe", "-c:v", "mjpeg"])
|
||||
.args(["-framerate", &format!("{fps:.4}")])
|
||||
.args(["-i", "pipe:0"])
|
||||
.args(["-an"])
|
||||
.args(["-c:v", "libx264", "-preset", preset])
|
||||
.args(["-crf", &crf.to_string()])
|
||||
// yuv420p + faststart:前者让文件在任何播放器里都能放,后者把索引挪到文件头,
|
||||
// 于是分享出去的 mp4 可以边下边播。
|
||||
.args(["-pix_fmt", "yuv420p", "-movflags", "+faststart"])
|
||||
.arg(&path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"could not start ffmpeg ({ffmpeg}); 用 [recording] ffmpeg 指定它的路径"
|
||||
)
|
||||
})?;
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| anyhow!("ffmpeg was started without a stdin pipe"))?;
|
||||
|
||||
let (tx, rx) = sync_channel::<Arc<Frame>>(QUEUE);
|
||||
let frames_written = Arc::new(AtomicU64::new(0));
|
||||
let frames_dropped = Arc::new(AtomicU64::new(0));
|
||||
let error = Arc::new(Mutex::new(None));
|
||||
|
||||
let finish = {
|
||||
let frames_written = Arc::clone(&frames_written);
|
||||
let error = Arc::clone(&error);
|
||||
let path = path.clone();
|
||||
|
||||
thread::Builder::new()
|
||||
.name(format!("rec-writer-{}", id_text(camera)))
|
||||
.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:#}"));
|
||||
}
|
||||
})?
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"recording 0x{camera:08X} -> {} ({fps:.1} fps in, crf {crf}, {preset})",
|
||||
path.display()
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
camera,
|
||||
path,
|
||||
started_ms: now_ms(),
|
||||
input_fps: fps,
|
||||
tx: Some(tx),
|
||||
frames_written,
|
||||
frames_dropped,
|
||||
error,
|
||||
finish: Some(finish),
|
||||
})
|
||||
}
|
||||
|
||||
/// 喂一帧。写不进去就计一次丢帧,不阻塞。
|
||||
pub fn feed(&self, frame: &Arc<Frame>) {
|
||||
let Some(tx) = self.tx.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match tx.try_send(Arc::clone(frame)) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(_)) => {
|
||||
self.frames_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
// 写入线程已经结束(ffmpeg 退出):由 `error` 记录原因,这里不再重复报。
|
||||
Err(TrySendError::Disconnected(_)) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frames_written(&self) -> u64 {
|
||||
self.frames_written.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn frames_dropped(&self) -> u64 {
|
||||
self.frames_dropped.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<String> {
|
||||
self.error.lock().expect("rec error mutex poisoned").clone()
|
||||
}
|
||||
|
||||
/// 停止并等 ffmpeg 把文件收尾(`+faststart` 要重写一遍索引,所以这一步不是瞬时的)。
|
||||
pub fn stop(mut self) -> RecordingDto {
|
||||
// 丢掉发送端 ⇒ 写入线程的 recv 返回 Err ⇒ 它关掉 stdin 并 wait 子进程。
|
||||
drop(self.tx.take());
|
||||
|
||||
if let Some(handle) = self.finish.take() {
|
||||
if handle.join().is_err() {
|
||||
log::warn!("the recording writer thread panicked");
|
||||
}
|
||||
}
|
||||
|
||||
let mut dto = self.dto();
|
||||
dto.active = false;
|
||||
|
||||
if let Some(error) = dto.error.as_deref() {
|
||||
log::warn!("recording 0x{:08X} finished with an error: {error}", self.camera);
|
||||
} else {
|
||||
log::info!(
|
||||
"recording 0x{:08X} stopped: {} frames -> {}",
|
||||
self.camera,
|
||||
dto.frames_written,
|
||||
self.path.display()
|
||||
);
|
||||
}
|
||||
|
||||
dto
|
||||
}
|
||||
|
||||
pub fn dto(&self) -> RecordingDto {
|
||||
RecordingDto {
|
||||
camera: self.camera,
|
||||
camera_text: id_text(self.camera),
|
||||
active: self.tx.is_some(),
|
||||
path: Some(self.path.display().to_string()),
|
||||
input_fps: self.input_fps,
|
||||
frames_written: self.frames_written(),
|
||||
frames_dropped: self.frames_dropped(),
|
||||
started_ms: Some(self.started_ms),
|
||||
error: self.error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 给一段新录制挑一个不会被占用的文件名。
|
||||
///
|
||||
/// `rec-00000001-2026-10-14_19-12-05.mp4`,同秒重名时往后排 `-2`、`-3`。
|
||||
fn unique_take_path(dir: &Path, camera: u32) -> PathBuf {
|
||||
let base = format!(
|
||||
"rec-{}-{}",
|
||||
id_text(camera).trim_start_matches("0x"),
|
||||
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S")
|
||||
);
|
||||
|
||||
let mut candidate = dir.join(format!("{base}.mp4"));
|
||||
|
||||
for n in 2..1000 {
|
||||
if !candidate.exists() {
|
||||
break;
|
||||
}
|
||||
candidate = dir.join(format!("{base}-{n}.mp4"));
|
||||
}
|
||||
|
||||
candidate
|
||||
}
|
||||
|
||||
/// 写入线程的主体:把队列里的帧写进 ffmpeg 的 stdin,队列关闭后收尾。
|
||||
fn pump(
|
||||
rx: &std::sync::mpsc::Receiver<Arc<Frame>>,
|
||||
mut stdin: ChildStdin,
|
||||
child: &mut Child,
|
||||
frames_written: &AtomicU64,
|
||||
) -> Result<()> {
|
||||
while let Ok(frame) = rx.recv() {
|
||||
if let Err(err) = stdin.write_all(&frame.data) {
|
||||
// 管道断了通常意味着 ffmpeg 已经退出(参数不对、磁盘满)。把它的 stderr
|
||||
// 读出来拼进错误里:只说"Broken pipe"对现场毫无帮助。
|
||||
let reason = child_stderr(child);
|
||||
return Err(anyhow!(
|
||||
"writing to ffmpeg failed: {err}{}",
|
||||
reason.map(|r| format!(";ffmpeg 说:{r}")).unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
frames_written.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// 关掉 stdin 是让 ffmpeg 知道"没有更多帧了"的唯一方式。
|
||||
drop(stdin);
|
||||
|
||||
match child.wait() {
|
||||
Ok(status) if status.success() => Ok(()),
|
||||
Ok(status) => {
|
||||
let reason = child_stderr(child);
|
||||
Err(anyhow!(
|
||||
"ffmpeg exited with {status}{}",
|
||||
reason.map(|r| format!(":{r}")).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
Err(err) => Err(anyhow!("could not wait for ffmpeg: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读一下子进程的 stderr(已经读完则返回 `None`)。
|
||||
fn child_stderr(child: &mut Child) -> Option<String> {
|
||||
use std::io::Read;
|
||||
|
||||
let mut buf = String::new();
|
||||
let stderr = child.stderr.as_mut()?;
|
||||
|
||||
// 管道是非阻塞读的话这里会直接返回;否则 ffmpeg 已经退出,读得到 EOF。
|
||||
match stderr.read_to_string(&mut buf) {
|
||||
Ok(0) => None,
|
||||
Ok(_) => Some(buf.trim().to_string()),
|
||||
Err(err) if err.kind() == ErrorKind::WouldBlock => None,
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 所有正在进行的录制。
|
||||
#[derive(Default)]
|
||||
pub struct RecordingSet {
|
||||
inner: Mutex<HashMap<u32, Recorder>>,
|
||||
}
|
||||
|
||||
impl RecordingSet {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
&self,
|
||||
camera: u32,
|
||||
dir: &Path,
|
||||
ffmpeg: &str,
|
||||
fps: f32,
|
||||
crf: u8,
|
||||
preset: &str,
|
||||
) -> Result<RecordingDto> {
|
||||
// **先停掉同一路已有的那次录制,再开新的。**
|
||||
//
|
||||
// 顺序不是随便定的:文件名唯一性靠"这个路径已经被占用了"来判断,而 ffmpeg 的输出
|
||||
// 文件是它启动之后才创建的。先开新的话,新的一段会挑到与上一段相同的名字,然后被
|
||||
// 它的 `-y` 覆盖掉——而上一段刚要收尾。先停就能保证上一个文件已经落地,`exists()`
|
||||
// 于是是可信的。
|
||||
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");
|
||||
drop(previous.stop());
|
||||
}
|
||||
|
||||
let recorder = Recorder::start(camera, dir, ffmpeg, fps, crf, preset)?;
|
||||
let dto = recorder.dto();
|
||||
|
||||
self.inner
|
||||
.lock()
|
||||
.expect("recording mutex poisoned")
|
||||
.insert(camera, recorder);
|
||||
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
pub fn stop(&self, camera: u32) -> Option<RecordingDto> {
|
||||
let recorder = self
|
||||
.inner
|
||||
.lock()
|
||||
.expect("recording mutex poisoned")
|
||||
.remove(&camera)?;
|
||||
|
||||
Some(recorder.stop())
|
||||
}
|
||||
|
||||
pub fn stop_all(&self) -> Vec<RecordingDto> {
|
||||
let recorders: Vec<Recorder> = self
|
||||
.inner
|
||||
.lock()
|
||||
.expect("recording mutex poisoned")
|
||||
.drain()
|
||||
.map(|(_, recorder)| recorder)
|
||||
.collect();
|
||||
|
||||
recorders.into_iter().map(Recorder::stop).collect()
|
||||
}
|
||||
|
||||
/// 把一帧喂给对应那一路的录制器。在 UDP 接收线程里被调用,所以必须快。
|
||||
pub fn feed(&self, frame: &Arc<Frame>) {
|
||||
// 先快速判断有没有在录,避免每帧都抢锁。
|
||||
if self.inner.lock().expect("recording mutex poisoned").is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let inner = self.inner.lock().expect("recording mutex poisoned");
|
||||
if let Some(recorder) = inner.get(&frame.sender_id) {
|
||||
recorder.feed(frame);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active(&self) -> Vec<RecordingDto> {
|
||||
let inner = self.inner.lock().expect("recording mutex poisoned");
|
||||
let mut list: Vec<RecordingDto> = inner.values().map(Recorder::dto).collect();
|
||||
|
||||
list.sort_by_key(|dto| dto.camera);
|
||||
list
|
||||
}
|
||||
}
|
||||
|
||||
/// 一段已经录好的文件或截图。
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaFileDto {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub kind: String,
|
||||
pub bytes: u64,
|
||||
pub modified_ms: u64,
|
||||
}
|
||||
|
||||
/// 列出目录里的 mp4 与 png,新的在前。
|
||||
///
|
||||
/// 只认这两种后缀:目录是给人看的,不该因为里面放了个 `.DS_Store` 就报错或显示出来。
|
||||
pub fn list_media(dir: &Path) -> Vec<MediaFileDto> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let kind = match ext.to_ascii_lowercase().as_str() {
|
||||
"mp4" => "video",
|
||||
"png" => "image",
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let Ok(meta) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
out.push(MediaFileDto {
|
||||
name: path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
path: path.display().to_string(),
|
||||
kind: kind.to_string(),
|
||||
bytes: meta.len(),
|
||||
modified_ms: meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
|
||||
out.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use protocol::fragment_frame;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 合成一张能被 ffmpeg 解出来的 JPEG。
|
||||
fn jpeg(width: u32, height: u32, seed: u8) -> Vec<u8> {
|
||||
let mut img = image::RgbImage::new(width, height);
|
||||
for (x, y, pixel) in img.enumerate_pixels_mut() {
|
||||
*pixel = image::Rgb([
|
||||
(x as u8).wrapping_add(seed),
|
||||
(y as u8).wrapping_add(seed),
|
||||
seed,
|
||||
]);
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut encoder =
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80);
|
||||
encoder.encode_image(&img).expect("encodes");
|
||||
out
|
||||
}
|
||||
|
||||
fn frame(camera: u32, id: u32) -> Arc<Frame> {
|
||||
Arc::new(Frame {
|
||||
sender_id: camera,
|
||||
frame_id: id,
|
||||
width: 64,
|
||||
height: 48,
|
||||
data: jpeg(64, 48, id as u8),
|
||||
})
|
||||
}
|
||||
|
||||
fn has_ffmpeg() -> bool {
|
||||
Command::new("ffmpeg")
|
||||
.arg("-version")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_ffmpeg_is_an_error_not_a_silent_no_op() {
|
||||
let dir = temp_dir("no-ffmpeg");
|
||||
// 不用 expect_err:那要求 Ok 那一侧实现 Debug,而 Recorder 持有着管道与线程,
|
||||
// 给它加 Debug 只是为了写测试,不划算。
|
||||
let err = match Recorder::start(1, &dir, "definitely-not-ffmpeg", 20.0, 20, "veryfast") {
|
||||
Ok(_) => panic!("must refuse to pretend it is recording"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("ffmpeg"),
|
||||
"the message must name the missing tool: {err}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_produces_a_playable_mp4() {
|
||||
// 这一条真的会起 ffmpeg 并写出一个文件,然后用 ffprobe 读回它的帧数与分辨率。
|
||||
// 只用"文件存在且非空"来断言是不够的:ffmpeg 参数写错时也会留下一个空的
|
||||
// 容器,而那正是最难在现场发现的一种失败。
|
||||
if !has_ffmpeg() {
|
||||
eprintln!("skipping: no ffmpeg on this machine");
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = temp_dir("mp4");
|
||||
let set = RecordingSet::new();
|
||||
|
||||
set.start(0xAAAA, &dir, "ffmpeg", 20.0, 23, "veryfast")
|
||||
.expect("starts");
|
||||
for i in 0..20 {
|
||||
set.feed(&frame(0xAAAA, i));
|
||||
}
|
||||
// 让写入线程把队列排空再停。
|
||||
for _ in 0..50 {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
if set.active()[0].frames_written >= 20 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let dto = set.stop(0xAAAA).expect("was recording");
|
||||
assert_eq!(dto.frames_written, 20, "all 20 frames reached ffmpeg");
|
||||
assert_eq!(dto.frames_dropped, 0);
|
||||
assert!(dto.error.is_none(), "ffmpeg said: {:?}", dto.error);
|
||||
|
||||
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");
|
||||
|
||||
if let Some((frames, width, height)) = ffprobe_frames(&path) {
|
||||
assert_eq!(frames, 20, "ffmpeg wrote every frame we fed it");
|
||||
assert_eq!((width, height), (64, 48));
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feeding_after_stop_does_not_panic() {
|
||||
if !has_ffmpeg() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = temp_dir("after-stop");
|
||||
let set = RecordingSet::new();
|
||||
set.start(0xBBBB, &dir, "ffmpeg", 20.0, 23, "veryfast")
|
||||
.unwrap();
|
||||
let held = frame(0xBBBB, 0);
|
||||
set.stop(0xBBBB);
|
||||
|
||||
// 停止之后接收线程可能还有一帧在手。
|
||||
set.feed(&held);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starting_twice_closes_the_first_take_instead_of_leaking_it() {
|
||||
if !has_ffmpeg() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = temp_dir("twice");
|
||||
let set = RecordingSet::new();
|
||||
|
||||
// 第一段:喂几帧并等它们真的落到 ffmpeg。写盘是异步的,不等到位的话这次录制会是
|
||||
// 零帧,而零帧时 ffmpeg 连输出文件都不会创建——那测的就不是同一件事了。
|
||||
set.start(1, &dir, "ffmpeg", 20.0, 23, "veryfast").unwrap();
|
||||
for i in 0..8 {
|
||||
set.feed(&frame(1, i));
|
||||
}
|
||||
wait_for_frames(&set, 1, 8);
|
||||
|
||||
// 第二段:这里会把第一段收尾,并另取一个文件名。
|
||||
let second = set.start(1, &dir, "ffmpeg", 20.0, 23, "veryfast").unwrap();
|
||||
for i in 10..18 {
|
||||
set.feed(&frame(1, i));
|
||||
}
|
||||
wait_for_frames(&set, 1, 8);
|
||||
|
||||
assert_eq!(set.active().len(), 1, "只有一路在录");
|
||||
assert_eq!(set.stop_all().len(), 1);
|
||||
|
||||
let files = list_media(&dir);
|
||||
assert_eq!(files.len(), 2, "两段各自留下一个文件:{files:?}");
|
||||
assert!(
|
||||
files.iter().all(|file| file.bytes > 0),
|
||||
"两段都得有内容,空容器不算一次录制:{files:?}"
|
||||
);
|
||||
assert!(second.path.is_some(), "第二段也应该有自己的路径");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// 等到某一路写进了至少 `want` 帧,或者超时。
|
||||
fn wait_for_frames(set: &RecordingSet, camera: u32, want: u64) {
|
||||
for _ in 0..100 {
|
||||
let written = set
|
||||
.active()
|
||||
.iter()
|
||||
.find(|dto| dto.camera == camera)
|
||||
.map(|dto| dto.frames_written)
|
||||
.unwrap_or(0);
|
||||
|
||||
if written >= want {
|
||||
return;
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
panic!("0x{camera:08X} 没有把 {want} 帧写进 ffmpeg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_takes_in_the_same_second_do_not_overwrite_each_other() {
|
||||
// 同一秒里停一次再开一次是现场很自然的操作("这局重录")。重名的后果是 ffmpeg 的
|
||||
// `-y` 静默覆盖上一段——而丢掉的那段往往正好是要复盘的那段。
|
||||
let dir = temp_dir("unique");
|
||||
let first = unique_take_path(&dir, 0xAAAA);
|
||||
std::fs::write(&first, b"").unwrap();
|
||||
|
||||
let second = unique_take_path(&dir, 0xAAAA);
|
||||
|
||||
assert_ne!(first, second);
|
||||
assert!(
|
||||
second.to_string_lossy().ends_with("-2.mp4"),
|
||||
"got {}",
|
||||
second.display()
|
||||
);
|
||||
|
||||
std::fs::write(&second, b"").unwrap();
|
||||
let third = unique_take_path(&dir, 0xAAAA);
|
||||
assert!(third.to_string_lossy().ends_with("-3.mp4"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_listing_ignores_anything_that_is_not_a_take() {
|
||||
let dir = temp_dir("listing");
|
||||
std::fs::write(dir.join("a.mp4"), b"x").unwrap();
|
||||
std::fs::write(dir.join("b.PNG"), b"x").unwrap();
|
||||
std::fs::write(dir.join("notes.txt"), b"x").unwrap();
|
||||
std::fs::write(dir.join(".DS_Store"), b"x").unwrap();
|
||||
|
||||
let files = list_media(&dir);
|
||||
let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
|
||||
|
||||
assert_eq!(names.len(), 2, "got {names:?}");
|
||||
assert!(names.contains(&"a.mp4"));
|
||||
assert!(names.contains(&"b.PNG"), "后缀大小写不该决定它是不是一张截图");
|
||||
assert_eq!(files[0].kind, "video");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_a_missing_directory_is_empty_not_an_error() {
|
||||
assert!(list_media(Path::new("/nonexistent/recordings")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_name_says_which_camera_and_when() {
|
||||
// 现场翻目录时要能一眼看出是哪一路、哪一局。
|
||||
assert!(id_text(0x3D4D_8A9A).ends_with("3D4D8A9A"));
|
||||
}
|
||||
|
||||
/// 用 ffprobe 读回帧数与分辨率。ffprobe 不在就返回 `None`(跳过那几条断言)。
|
||||
fn ffprobe_frames(path: &str) -> Option<(i64, u32, u32)> {
|
||||
let out = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-count_frames",
|
||||
"-show_entries",
|
||||
"stream=nb_read_frames,width,height",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1",
|
||||
path,
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let mut frames = None;
|
||||
let mut width = None;
|
||||
let mut height = None;
|
||||
|
||||
for line in text.lines() {
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
match key {
|
||||
"nb_read_frames" => frames = value.parse().ok(),
|
||||
"width" => width = value.parse().ok(),
|
||||
"height" => height = value.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some((frames?, width?, height?))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragmented_frames_are_not_what_the_recorder_wants() {
|
||||
// 录制器收的是**重组之后**的整帧(sink 传进来的就是),不是分片。这里把这个
|
||||
// 约定钉下来:如果哪天有人在接收路径上提前喂分片,测试会立刻说明原因。
|
||||
let frags = fragment_frame(1, 1, &jpeg(64, 48, 1), 64, 48);
|
||||
assert!(frags.len() == 1, "one small JPEG is one fragment");
|
||||
|
||||
let whole = frame(1, 1);
|
||||
assert!(whole.data.len() > 2, "a real JPEG, not a fragment header");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//! 界面与后端之间的视频通道:一个只监听回环地址的 WebSocket。
|
||||
//!
|
||||
//! # 为什么不是 Tauri 的 IPC
|
||||
//!
|
||||
//! 两路 22fps、每帧约 22 kB,也就是每秒约 1 MB 的裸 JPEG。走 IPC 的话它要被序列化成
|
||||
//! JSON(base64 再涨 1/3),再在 JS 侧解回来——CPU 花在没人需要的编解码上,延迟也白
|
||||
//! 涨一截。WebSocket 的二进制帧把原始 JPEG 字节直接交给前端,浏览器用原生解码器
|
||||
//! (`createImageBitmap`)解,**后端连 JPEG 都不用解**。
|
||||
//!
|
||||
//! # 为什么要有 token
|
||||
//!
|
||||
//! 端口是随机分配的,但同机器上任何进程都能连回环地址。这条通道里有实时画面,给它一
|
||||
//! 个每次启动随机生成的 token(前端从 `stream_info` 命令拿到),至少让"另一个本地程序
|
||||
//! 顺手连上来看画面"变成一件需要先读到那个 token 的事。
|
||||
//!
|
||||
//! 说清楚它的边界:这**不是**防对手的措施——对手要看画面得先在自己的机器上跑这套客户
|
||||
//! 端,那道闸门在 [`cam_core::receiver::Receiver`] 里。这里的 token 只防止本机上的顺手
|
||||
//! 连接。
|
||||
//!
|
||||
//! # 二进制帧的布局
|
||||
//!
|
||||
//! ```text
|
||||
//! 0 4 8 10 12 16
|
||||
//! +--------------+--------------+------+------+--------------+---------+
|
||||
//! | sender_id u32| frame_id u32 | w u16| h u16| jpeg_len u32 | JPEG... |
|
||||
//! +--------------+--------------+------+------+--------------+---------+
|
||||
//! ```
|
||||
//!
|
||||
//! 小端。16 字节头,前端切掉它就能拿到一段完整的 JPEG。`sender_id` 让前端知道这一帧是
|
||||
//! 哪块板子的——用户端只画绑定的那一路,裁判端按槽位分配画两路。
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::Serialize;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
|
||||
use tokio_tungstenite::tungstenite::http::StatusCode;
|
||||
use tokio_tungstenite::tungstenite::{Bytes, Message};
|
||||
|
||||
use cam_core::snapshot::{HitDto, Snapshot};
|
||||
|
||||
/// 二进制帧头长度。
|
||||
pub const FRAME_HEADER_LEN: usize = 16;
|
||||
|
||||
/// 广播通道的容量。
|
||||
///
|
||||
/// 满了之后 `send` 会挤掉最老的(接收慢的连接会拿到 `Lagged`)——对实时画面这正是想要
|
||||
/// 的:落后 200 帧的客户端需要的是最新的那一帧,不是补齐历史。按每帧 16+22 kB 算,
|
||||
/// 256 个槽位约 5.6 MB 上限。
|
||||
pub const CHANNEL_CAPACITY: usize = 256;
|
||||
|
||||
/// 向界面推送的消息。
|
||||
pub enum ServerMsg {
|
||||
/// 一帧原始 JPEG(已经拼好 16 字节头)。
|
||||
///
|
||||
/// 载荷用 `Bytes` 而不是 `Arc<Vec<u8>>`:它本身就是引用计数的缓冲,克隆是加一次
|
||||
/// 引用计数,而且正好是 tungstenite 要的类型——每个连接一次多余的拷贝就此消失。
|
||||
Binary(Bytes),
|
||||
/// JSON 文本。
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// JSON 文本消息的信封。
|
||||
///
|
||||
/// 用内部标签 `t` 而不是两个字段:前端一个 `switch (msg.t)` 就能分发,且不需要为每种
|
||||
/// 消息各写一次 `JSON.parse` 的错误处理。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "t", rename_all = "camelCase")]
|
||||
pub enum WireMsg {
|
||||
/// 完整状态快照,5 Hz。
|
||||
Snapshot(Box<Snapshot>),
|
||||
/// 一次掉血,立即推。
|
||||
Hit(HitDto),
|
||||
/// 后端拦截了一次"关闭/退出"(用户端还没解锁)。前端据此弹出解锁对话框。
|
||||
ConfirmQuit,
|
||||
/// 一条说明(迁移、录制报错等),前端显示成一行提示。
|
||||
///
|
||||
/// 写成带字段的结构体而不是 `Note(String)`:serde 的**内部标签**(`#[serde(tag)]`)
|
||||
/// 只支持载荷是 map 的变体,newtype 里放一个字符串会在运行时报错,而不是编译期。
|
||||
Note { message: String },
|
||||
}
|
||||
|
||||
/// 前端连接这条通道需要的信息。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StreamInfo {
|
||||
/// `ws://127.0.0.1:<port>/?token=...`
|
||||
pub url: String,
|
||||
pub port: u16,
|
||||
/// 当前连着的界面数。用于诊断"画面不动"到底是后端没推还是前端没连。
|
||||
pub clients: usize,
|
||||
}
|
||||
|
||||
/// 把一张帧编码成二进制消息。
|
||||
pub fn encode_frame(frame: &protocol::Frame) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(FRAME_HEADER_LEN + frame.data.len());
|
||||
|
||||
out.extend_from_slice(&frame.sender_id.to_le_bytes());
|
||||
out.extend_from_slice(&frame.frame_id.to_le_bytes());
|
||||
out.extend_from_slice(&frame.width.to_le_bytes());
|
||||
out.extend_from_slice(&frame.height.to_le_bytes());
|
||||
out.extend_from_slice(&(frame.data.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&frame.data);
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// 从二进制消息里解出帧头。前后端对这段字节布局的约定由测试盯着。
|
||||
#[cfg(test)]
|
||||
pub fn decode_frame_header(message: &[u8]) -> Option<(u32, u32, u16, u16, usize)> {
|
||||
if message.len() < FRAME_HEADER_LEN {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sender_id = u32::from_le_bytes(message[0..4].try_into().ok()?);
|
||||
let frame_id = u32::from_le_bytes(message[4..8].try_into().ok()?);
|
||||
let width = u16::from_le_bytes(message[8..10].try_into().ok()?);
|
||||
let height = u16::from_le_bytes(message[10..12].try_into().ok()?);
|
||||
let len = u32::from_le_bytes(message[12..16].try_into().ok()?) as usize;
|
||||
|
||||
if message.len() < FRAME_HEADER_LEN + len {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((sender_id, frame_id, width, height, len))
|
||||
}
|
||||
|
||||
/// 绑一个随机端口的回环 listener。
|
||||
///
|
||||
/// 随机端口而不是固定端口:同一台机器上同时开用户端和裁判端时不会撞车。
|
||||
pub async fn bind() -> std::io::Result<(TcpListener, u16)> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
|
||||
Ok((listener, port))
|
||||
}
|
||||
|
||||
/// 接受循环。每个连接一个任务,活到连接断开。
|
||||
pub async fn serve(
|
||||
listener: TcpListener,
|
||||
token: String,
|
||||
tx: broadcast::Sender<Arc<ServerMsg>>,
|
||||
clients: Arc<AtomicUsize>,
|
||||
) {
|
||||
loop {
|
||||
let (stream, peer) = match listener.accept().await {
|
||||
Ok(pair) => pair,
|
||||
Err(err) => {
|
||||
log::warn!("stream: accept failed: {err}");
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let tx = tx.clone();
|
||||
let token = token.clone();
|
||||
let clients = Arc::clone(&clients);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(err) = handle_client(stream, peer, &token, tx, clients).await {
|
||||
log::debug!("stream: client {peer} ended: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client(
|
||||
stream: TcpStream,
|
||||
peer: std::net::SocketAddr,
|
||||
token: &str,
|
||||
tx: broadcast::Sender<Arc<ServerMsg>>,
|
||||
clients: Arc<AtomicUsize>,
|
||||
) -> anyhow::Result<()> {
|
||||
stream.set_nodelay(true).ok();
|
||||
|
||||
let expected = token.to_string();
|
||||
let callback = move |req: &Request, res: Response| -> Result<Response, ErrorResponse> {
|
||||
let query = req.uri().query().unwrap_or("");
|
||||
let presented = query
|
||||
.split('&')
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.find(|(key, _)| *key == "token")
|
||||
.map(|(_, value)| value.to_string());
|
||||
|
||||
match presented {
|
||||
Some(value) if constant_time_eq(&value, &expected) => Ok(res),
|
||||
_ => {
|
||||
log::warn!("stream: refusing {peer}: wrong or missing token");
|
||||
let mut response = ErrorResponse::new(Some("bad token".into()));
|
||||
*response.status_mut() = StatusCode::FORBIDDEN;
|
||||
Err(response)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let ws = tokio_tungstenite::accept_hdr_async(stream, callback).await?;
|
||||
let (mut sink, mut incoming) = ws.split();
|
||||
|
||||
let mut rx = tx.subscribe();
|
||||
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)");
|
||||
|
||||
// 连上就先推一条提示,前端能据此在界面上显示"通道已接通"。
|
||||
let hello = serde_json::to_string(&WireMsg::Note {
|
||||
message: "video channel ready".into(),
|
||||
})
|
||||
.unwrap_or_else(|_| "{}".into());
|
||||
let _ = sink.send(Message::text(hello)).await;
|
||||
|
||||
let result = loop {
|
||||
tokio::select! {
|
||||
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()),
|
||||
};
|
||||
|
||||
if let Err(err) = sink.send(out).await {
|
||||
break Err(anyhow::anyhow!("send failed: {err}"));
|
||||
}
|
||||
}
|
||||
// 接收慢,积压被挤掉了。对视频这是正常且期望的:跳过旧帧,继续送新的。
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
log::debug!("stream: {peer} lagged {skipped} messages; skipping ahead");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
break Ok(());
|
||||
}
|
||||
},
|
||||
|
||||
_ = keepalive.tick() => {
|
||||
// WKWebView 侧的连接可能在休眠后静默失效。ping 让这种情况变成一次明确的
|
||||
// 发送失败,而不是一个永远不刷新的窗口。
|
||||
if let Err(err) = sink.send(Message::Ping(Bytes::new())).await {
|
||||
break Err(anyhow::anyhow!("keepalive failed: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
incoming_msg = incoming.next() => match incoming_msg {
|
||||
None => break Ok(()),
|
||||
Some(Err(err)) => break Err(anyhow::anyhow!("receive failed: {err}")),
|
||||
// 这条通道是单向的:前端发来的东西(pong 除外)一律忽略。
|
||||
Some(Ok(_)) => {}
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
let count = clients.fetch_sub(1, Ordering::Relaxed) - 1;
|
||||
log::info!("stream: interface disconnected ({count} connected)");
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 定长比较,避免 token 校验泄露时间信息。
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// 生成一个随机 token。
|
||||
pub fn random_token() -> String {
|
||||
use rand::RngCore;
|
||||
|
||||
let mut bytes = [0u8; 24];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn frame(sender: u32, id: u32, len: usize) -> protocol::Frame {
|
||||
protocol::Frame {
|
||||
sender_id: sender,
|
||||
frame_id: id,
|
||||
width: 640,
|
||||
height: 480,
|
||||
data: vec![0xAB; len],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_binary_header_roundtrips() {
|
||||
let encoded = encode_frame(&frame(0x3D4D_8A9A, 4242, 22_000));
|
||||
|
||||
assert_eq!(encoded.len(), FRAME_HEADER_LEN + 22_000);
|
||||
assert_eq!(
|
||||
decode_frame_header(&encoded),
|
||||
Some((0x3D4D_8A9A, 4242, 640, 480, 22_000))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_message_is_refused_rather_than_read_past_the_end() {
|
||||
let mut encoded = encode_frame(&frame(1, 1, 100));
|
||||
encoded.truncate(FRAME_HEADER_LEN + 40);
|
||||
|
||||
// 头里声明 100 字节,实际只有 40:宁可返回 None,也不要让前端拿到一段不完整的
|
||||
// JPEG(那会解码失败,或者更糟——解出一张半截的图)。
|
||||
assert_eq!(decode_frame_header(&encoded), None);
|
||||
assert_eq!(decode_frame_header(&[0u8; 4]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_comparison_is_exact() {
|
||||
assert!(constant_time_eq("abc", "abc"));
|
||||
assert!(!constant_time_eq("abc", "abd"));
|
||||
assert!(!constant_time_eq("abc", "abcd"));
|
||||
assert!(!constant_time_eq("", "a"));
|
||||
assert!(constant_time_eq("", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_differ_between_runs() {
|
||||
assert_ne!(random_token(), random_token());
|
||||
assert_eq!(random_token().len(), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wire_envelope_carries_the_message_kind() {
|
||||
let json = serde_json::to_string(&WireMsg::Hit(HitDto {
|
||||
board: 1,
|
||||
team: 0,
|
||||
index: 2,
|
||||
from: 100,
|
||||
to: 97,
|
||||
damage: 3,
|
||||
at_ms: 1,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert!(json.contains(r#""t":"hit""#), "got {json}");
|
||||
assert!(json.contains(r#""damage":3"#), "got {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_unit_variant_is_just_a_tag() {
|
||||
let json = serde_json::to_string(&WireMsg::ConfirmQuit).unwrap();
|
||||
assert_eq!(json, r#"{"t":"confirmQuit"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_is_tagged_too() {
|
||||
let json = serde_json::to_string(&WireMsg::Note {
|
||||
message: "hi".into(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(json, r#"{"t":"note","message":"hi"}"#);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
//! 进程内的共享状态。
|
||||
//!
|
||||
//! 这里是"网络层的推"与"界面的拉"交汇的地方:
|
||||
//!
|
||||
//! - **推**:接收线程拿到帧就交给 sink,sink 直接发进广播通道(不阻塞、不排队);
|
||||
//! 装甲板上报推进一个 [`HealthTracker`],派生出掉血事件就立刻推一条。
|
||||
//! - **拉**:一个 5 Hz 的循环把当前状态拼成一份 [`Snapshot`] 推出去。界面不需要任何
|
||||
//! 轮询命令,它只是渲染收到的那份状态。
|
||||
//!
|
||||
//! # 密码闸门的执行点在这里,不在界面里
|
||||
//!
|
||||
//! 所有会改绑定的命令都过 [`AppState::require_unlocked`]。界面上有一个解锁对话框,但那
|
||||
//! **只是入口**:即使有人从控制台直接调 `invoke("bind_camera")`,一样要过这一关。这和
|
||||
//! 旧客户端把防作弊放在**接收端**而不是界面里是同一条原则。
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
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::{
|
||||
ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, Snapshot, SlotsDto, TeamHealthDto,
|
||||
};
|
||||
use cam_core::stats::{FpsWindow, DEFAULT_WINDOW};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::recording::{now_ms, RecordingSet};
|
||||
use crate::server::{self, ServerMsg, WireMsg};
|
||||
|
||||
/// 状态快照的推送频率。
|
||||
///
|
||||
/// 5 Hz:血量心跳是 200 ms,所以这个频率刚好不漏掉一次掉血;而再快也只是让界面做
|
||||
/// 无意义的重绘。
|
||||
pub const SNAPSHOT_INTERVAL: Duration = Duration::from_millis(200);
|
||||
|
||||
/// 界面端是否产出技术读数。
|
||||
///
|
||||
/// 由**编译模式**决定而不是命令行参数:命令行开关意味着二进制里带着那套东西,忘开
|
||||
/// 一次就泄露了。裁判端不受影响——它是中立方,双方遥测对判罚有用。
|
||||
pub const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
|
||||
|
||||
pub struct AppState {
|
||||
pub config_path: PathBuf,
|
||||
config: Mutex<Config>,
|
||||
/// 会话内解锁(见模块文档)。
|
||||
unlocked: AtomicBool,
|
||||
/// 设置面板是否打开,决定发现列表放不放开。
|
||||
discovery_open: AtomicBool,
|
||||
|
||||
pub receiver: Arc<Receiver>,
|
||||
pub armor: Arc<ArmorHealthStore>,
|
||||
discovery: Arc<Discovery>,
|
||||
latest: Arc<LatestFrames>,
|
||||
|
||||
/// 每一路的帧率窗口。掉线的摄像头会被清掉,回来时重新建。
|
||||
fps: Mutex<HashMap<u32, FpsWindow>>,
|
||||
slots: Mutex<SlotAllocator>,
|
||||
pub recordings: Arc<RecordingSet>,
|
||||
|
||||
tx: broadcast::Sender<Arc<ServerMsg>>,
|
||||
/// 回环上已经绑好的 WebSocket listener,等 `spawn_background` 取走。
|
||||
///
|
||||
/// 先绑再交给后台任务,是为了让"端口绑不上"在启动阶段就报错,而不是变成一个永远
|
||||
/// 连不上的界面。
|
||||
listener: Mutex<Option<tokio::net::TcpListener>>,
|
||||
pub port: u16,
|
||||
token: String,
|
||||
clients: Arc<AtomicUsize>,
|
||||
|
||||
pub demo: bool,
|
||||
notes: Mutex<Vec<String>>,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 建好状态,并绑好两个 UDP 端口。
|
||||
///
|
||||
/// 视频端口(5600)绑不上是**致命**的:整个程序的存在意义就是收画面,绑不上应该
|
||||
/// 立刻报错,而不是开一个永远不出画面的窗口。装甲板端口(5601)绑不上只是没有
|
||||
/// 血量,退回占位符即可——血量显示不了不该让整个客户端起不来。
|
||||
pub fn new(
|
||||
config_path: PathBuf,
|
||||
config: Config,
|
||||
mut notes: Vec<String>,
|
||||
demo: bool,
|
||||
) -> 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 };
|
||||
|
||||
// 视频端口绑不上**不**致命:界面照常起来,并把原因写在最显眼的地方。
|
||||
//
|
||||
// 一开始这里是 `?`(直接退出)。但那样一来,现场看到的是一次闪退加上一句只出现
|
||||
// 在 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}。多半是同一个端口上已经跑着另一个客户端 \
|
||||
(旧的 Slint 版或另一份本程序)。画面收不到,其它功能照常。",
|
||||
protocol::DEFAULT_PORT
|
||||
));
|
||||
(
|
||||
Arc::new(Receiver::new(
|
||||
protocol::BROADCAST_ADDR,
|
||||
role.to_protocol(),
|
||||
bound,
|
||||
)),
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let discovery = receiver.discovery();
|
||||
let latest = receiver.latest_frames();
|
||||
|
||||
// 装甲板:两端都要收。用户端要显示自己的血条,裁判端要显示双方。
|
||||
let (armor, armor_socket) = match ArmorHealthStore::start() {
|
||||
Ok((store, socket)) => (store, Some(socket)),
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"armor: could not listen on UDP {}: {err}; health stays a placeholder",
|
||||
protocol::ARMOR_PORT
|
||||
);
|
||||
(Arc::new(ArmorHealthStore::new()), None)
|
||||
}
|
||||
};
|
||||
|
||||
let (tx, _) = broadcast::channel(server::CHANNEL_CAPACITY);
|
||||
let (listener, port) = tauri::async_runtime::block_on(server::bind())
|
||||
.context("绑不上本地 WebSocket 端口(回环地址)")?;
|
||||
|
||||
let state = Arc::new(Self {
|
||||
config_path,
|
||||
config: Mutex::new(config),
|
||||
unlocked: AtomicBool::new(false),
|
||||
discovery_open: AtomicBool::new(false),
|
||||
receiver,
|
||||
armor,
|
||||
discovery,
|
||||
latest,
|
||||
fps: Mutex::new(HashMap::new()),
|
||||
slots: Mutex::new(SlotAllocator::new()),
|
||||
recordings: RecordingSet::new(),
|
||||
tx,
|
||||
listener: Mutex::new(Some(listener)),
|
||||
port,
|
||||
token: server::random_token(),
|
||||
clients: Arc::new(AtomicUsize::new(0)),
|
||||
demo,
|
||||
notes: Mutex::new(notes),
|
||||
started: Instant::now(),
|
||||
});
|
||||
|
||||
state.apply_gates();
|
||||
|
||||
Ok((state, video_socket, armor_socket))
|
||||
}
|
||||
|
||||
/// 让视频通道开始工作:把帧与装甲板上报接到广播通道上。
|
||||
///
|
||||
/// 必须在 `Arc<AppState>` 建好之后调用(sink 里要持有 state 的通道)。
|
||||
pub fn install_sinks(self: &Arc<Self>) {
|
||||
let tx = self.tx.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())));
|
||||
}));
|
||||
|
||||
let tx = self.tx.clone();
|
||||
let tracker = Arc::new(Mutex::new(HealthTracker::new()));
|
||||
|
||||
// 装甲板的 sink:把"现在多少血"派生为"刚刚掉了几点",立刻推给界面。
|
||||
self.armor.set_sink(Arc::new(move |report| {
|
||||
let hit = tracker
|
||||
.lock()
|
||||
.expect("health tracker mutex poisoned")
|
||||
.observe(&report);
|
||||
|
||||
let Some(hit) = hit else {
|
||||
return;
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"armor: board 0x{:08X} took {} damage ({} -> {})",
|
||||
hit.board,
|
||||
hit.damage,
|
||||
hit.from,
|
||||
hit.to
|
||||
);
|
||||
|
||||
let dto = HitDto {
|
||||
board: hit.board,
|
||||
team: hit.team,
|
||||
index: hit.index,
|
||||
from: hit.from,
|
||||
to: hit.to,
|
||||
damage: hit.damage,
|
||||
at_ms: now_ms(),
|
||||
};
|
||||
|
||||
let _ = tx.send(Arc::new(ServerMsg::Text(
|
||||
serde_json::to_string(&WireMsg::Hit(dto)).unwrap_or_default(),
|
||||
)));
|
||||
}));
|
||||
}
|
||||
|
||||
/// 起 WebSocket 服务与快照循环。
|
||||
pub fn spawn_background(self: &Arc<Self>) {
|
||||
let Some(listener) = self
|
||||
.listener
|
||||
.lock()
|
||||
.expect("listener mutex poisoned")
|
||||
.take()
|
||||
else {
|
||||
log::warn!("spawn_background called twice; the stream server is already running");
|
||||
return;
|
||||
};
|
||||
|
||||
let state = Arc::clone(self);
|
||||
let token = self.token.clone();
|
||||
let tx = self.tx.clone();
|
||||
let clients = Arc::clone(&self.clients);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
server::serve(listener, token, tx, clients).await;
|
||||
});
|
||||
|
||||
let state_for_loop = Arc::clone(&state);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(SNAPSHOT_INTERVAL);
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
state_for_loop.tick();
|
||||
state_for_loop.push_snapshot();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 配置
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn config(&self) -> Config {
|
||||
self.config.lock().expect("config mutex poisoned").clone()
|
||||
}
|
||||
|
||||
/// 改配置、落盘、重新套用闸门。
|
||||
pub fn update_config(&self, change: impl FnOnce(&mut Config)) -> Result<Config> {
|
||||
let updated = {
|
||||
let mut config = self.config.lock().expect("config mutex poisoned");
|
||||
change(&mut config);
|
||||
// Guard 上不能直接调 `normalized()`(它取走 self)。克隆一份再归一化:配置
|
||||
// 只有几百字节,而这里每次都是人点一下才发生。
|
||||
config.clone().normalized()
|
||||
};
|
||||
|
||||
updated
|
||||
.save(&self.config_path)
|
||||
.context("写入配置文件失败;改动只在本次运行内有效")?;
|
||||
|
||||
*self.config.lock().expect("config mutex poisoned") = updated.clone();
|
||||
self.apply_gates();
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// 按当前配置重新设置两道闸门。
|
||||
///
|
||||
/// 每一次可能改变身份、绑定或发现开合的改动之后都要调用它——把这件事收在一个函数
|
||||
/// 里,是为了不让"某个命令忘了重新套用闸门"这种漏洞有时间长出来。
|
||||
pub fn apply_gates(&self) {
|
||||
let config = self.config();
|
||||
let role = config.role;
|
||||
|
||||
self.receiver.set_role(role.to_protocol());
|
||||
|
||||
// 第一道闸门:帧。用户端只看绑定的那一块;裁判端看全部。
|
||||
let limit = match role {
|
||||
Role::Team => config.camera_id,
|
||||
Role::Referee => None,
|
||||
};
|
||||
self.receiver.restrict_to(limit);
|
||||
|
||||
// 第二道闸门:发现列表。只有用户端在**解锁后**打开设置面板时才放开;裁判端
|
||||
// 本来就要看到全场。
|
||||
let open = match role {
|
||||
Role::Referee => true,
|
||||
Role::Team => self.discovery_open.load(Ordering::Relaxed) && self.unlocked.load(Ordering::Relaxed),
|
||||
};
|
||||
self.receiver.set_discovery_all(open);
|
||||
|
||||
if !open {
|
||||
self.discovery_open.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 密码闸门
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
// 裁判端没有密码(见 Role::needs_password),所以它永远是"已解锁"。
|
||||
!self.config().role.needs_password() || self.unlocked.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// 校验一次密码,成功就把会话标记为已解锁。
|
||||
pub fn unlock(&self, presented: &str) -> Result<bool> {
|
||||
let stored = self.config().password;
|
||||
|
||||
let Some(stored) = stored else {
|
||||
// 还没设过密码:任何输入都不该被当成"通过"。
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let ok = password::verify_password(presented, &stored)?;
|
||||
|
||||
if ok {
|
||||
self.unlocked.store(true, Ordering::Relaxed);
|
||||
log::info!("密码校验通过;本次运行内不再询问");
|
||||
} else {
|
||||
log::warn!("密码校验失败");
|
||||
}
|
||||
|
||||
Ok(ok)
|
||||
}
|
||||
|
||||
/// 手动重新上锁。
|
||||
pub fn lock(&self) {
|
||||
self.unlocked.store(false, Ordering::Relaxed);
|
||||
self.discovery_open.store(false, Ordering::Relaxed);
|
||||
self.apply_gates();
|
||||
}
|
||||
|
||||
/// 改绑之前的守门人。
|
||||
///
|
||||
/// `presented` 允许调用方在同一次调用里带上密码(界面上就是这样:解锁对话框收起后
|
||||
/// 用户立刻做改动),但会话已经解锁时不需要它。
|
||||
pub fn require_unlocked(&self, presented: Option<&str>) -> Result<(), String> {
|
||||
if self.is_unlocked() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(presented) = presented {
|
||||
match self.unlock(presented) {
|
||||
Ok(true) => return Ok(()),
|
||||
Ok(false) => {
|
||||
return Err("密码不对。改摄像头/装甲板绑定、切换队伍都需要密码。".into())
|
||||
}
|
||||
Err(err) => return Err(format!("存下来的密码哈希读不出来:{err}")),
|
||||
}
|
||||
}
|
||||
|
||||
Err("这个改动需要密码。先解锁(密码只是防止现场随手改绑,见 README 的说明)。".into())
|
||||
}
|
||||
|
||||
/// 打开/关闭设置面板带来的发现列表。
|
||||
pub fn set_discovery_open(&self, open: bool) -> Result<(), String> {
|
||||
if open {
|
||||
self.require_unlocked(None)?;
|
||||
}
|
||||
|
||||
self.discovery_open.store(open, Ordering::Relaxed);
|
||||
self.apply_gates();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置或修改密码。用户端第一次启动必须设。
|
||||
pub fn set_password(&self, current: Option<&str>, fresh: &str) -> Result<()> {
|
||||
let existing = self.config().password;
|
||||
|
||||
if let Some(existing) = existing {
|
||||
// 已有密码:必须能证明知道旧的(或者在本次运行里已经解锁过)。
|
||||
let trusted = self.unlocked.load(Ordering::Relaxed)
|
||||
|| current
|
||||
.map(|current| password::verify_password(current, &existing).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !trusted {
|
||||
anyhow::bail!("修改密码需要先输入当前密码");
|
||||
}
|
||||
}
|
||||
|
||||
let hash = password::hash_password(fresh)?;
|
||||
self.update_config(move |config| config.password = Some(hash))?;
|
||||
self.unlocked.store(true, Ordering::Relaxed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 推送
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn push(&self, msg: WireMsg) {
|
||||
let text = match serde_json::to_string(&msg) {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
log::error!("could not serialise a message for the interface: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 没有连接时发送会失败,这是正常情况(界面还没起来,或者刚刷新)。
|
||||
let _ = self.tx.send(Arc::new(ServerMsg::Text(text)));
|
||||
}
|
||||
|
||||
pub fn push_snapshot(&self) {
|
||||
self.push(WireMsg::Snapshot(Box::new(self.snapshot())));
|
||||
}
|
||||
|
||||
pub fn note(&self, message: impl Into<String>) {
|
||||
let message = message.into();
|
||||
log::info!("{message}");
|
||||
self.notes
|
||||
.lock()
|
||||
.expect("notes mutex poisoned")
|
||||
.push(message.clone());
|
||||
self.push(WireMsg::Note { message });
|
||||
}
|
||||
|
||||
pub fn stream_info(&self) -> server::StreamInfo {
|
||||
server::StreamInfo {
|
||||
url: format!("ws://127.0.0.1:{}/?token={}", self.port, self.token),
|
||||
port: self.port,
|
||||
clients: self.clients.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn latest_frames(&self) -> Arc<LatestFrames> {
|
||||
Arc::clone(&self.latest)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 每 200ms 推进一次
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// 推进帧率窗口与槽位分配。
|
||||
pub fn tick(&self) {
|
||||
let online: Vec<u32> = self.discovery.online_cameras().iter().map(|c| c.id).collect();
|
||||
let online_set: HashSet<u32> = online.iter().copied().collect();
|
||||
|
||||
{
|
||||
let mut fps = self.fps.lock().expect("fps mutex poisoned");
|
||||
let now = Instant::now();
|
||||
|
||||
// 掉线的摄像头把窗口丢掉。留着的话,它回来时累计帧数已经涨了一大截,下一
|
||||
// 次结算会报出一个几百 fps 的假数字。
|
||||
fps.retain(|id, _| online_set.contains(id));
|
||||
|
||||
for id in &online {
|
||||
let stats = self.discovery.stats(*id);
|
||||
fps.entry(*id)
|
||||
.or_insert_with(|| FpsWindow::seeded(DEFAULT_WINDOW, now, stats.frames_shown))
|
||||
.observe(stats.frames_shown, now);
|
||||
}
|
||||
}
|
||||
|
||||
let config = self.config();
|
||||
let mut slots = self.slots.lock().expect("slots mutex poisoned");
|
||||
|
||||
match config.role {
|
||||
Role::Referee => {
|
||||
if config.referee.manual_slots {
|
||||
// 手动:以配置为准。现场有烧错固件的板子时,人需要能压过自动分配。
|
||||
slots.force(0, config.referee.slot_a);
|
||||
slots.force(1, config.referee.slot_b);
|
||||
} else {
|
||||
slots.assign(&online);
|
||||
}
|
||||
}
|
||||
// 用户端只有一个"槽位":它绑定的那一块。
|
||||
Role::Team => {
|
||||
slots.force(0, config.camera_id);
|
||||
slots.force(1, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostics(&self) -> bool {
|
||||
self.config().role == Role::Referee || TEAM_DIAGNOSTICS
|
||||
}
|
||||
|
||||
/// 某块摄像头的帧率(用于录制时告诉 ffmpeg 输入帧率)。
|
||||
pub fn fps_of(&self, camera: u32) -> Option<f32> {
|
||||
let fps = self.fps.lock().expect("fps mutex poisoned");
|
||||
let window = fps.get(&camera)?;
|
||||
|
||||
if window.has_reading() {
|
||||
Some(window.fps())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 快照
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn snapshot(&self) -> Snapshot {
|
||||
let config = self.config();
|
||||
let diagnostics = self.diagnostics();
|
||||
|
||||
let cameras: Vec<CameraDto> = self
|
||||
.discovery
|
||||
.cameras()
|
||||
.iter()
|
||||
.map(CameraDto::from)
|
||||
.collect();
|
||||
|
||||
let stats: Vec<CameraStatsDto> = self
|
||||
.discovery
|
||||
.cameras()
|
||||
.iter()
|
||||
.map(|camera| {
|
||||
let stats = self.discovery.stats(camera.id);
|
||||
let fps = self.fps_of(camera.id).unwrap_or(0.0);
|
||||
CameraStatsDto::build(diagnostics, camera.id, camera.is_online(), &stats, fps)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let armor_boards: Vec<ArmorBoardDto> =
|
||||
self.armor.boards().iter().map(ArmorBoardDto::from).collect();
|
||||
|
||||
let slots = self.slots.lock().expect("slots mutex poisoned").slots();
|
||||
|
||||
let health = match config.role {
|
||||
// 用户端只显示自己那一边。
|
||||
Role::Team => vec![self.health_for(config.team)],
|
||||
Role::Referee => vec![self.health_for(0), self.health_for(1)],
|
||||
};
|
||||
|
||||
Snapshot {
|
||||
role: config.role,
|
||||
team: config.team,
|
||||
mirrored: config.mirror,
|
||||
demo: self.demo,
|
||||
diagnostics,
|
||||
unlocked: self.is_unlocked(),
|
||||
discovery_open: self.discovery_open.load(Ordering::Relaxed),
|
||||
uptime_ms: self.started.elapsed().as_millis() as u64,
|
||||
clients: self.clients.load(Ordering::Relaxed),
|
||||
config: ConfigView::from(&config),
|
||||
cameras,
|
||||
armor_boards,
|
||||
stats,
|
||||
slots: SlotsDto {
|
||||
camera_a: slots[0],
|
||||
camera_b: slots[1],
|
||||
armor_a: self.assigned_armor(&config, 0),
|
||||
armor_b: self.assigned_armor(&config, 1),
|
||||
manual: config.referee.manual_slots,
|
||||
},
|
||||
health,
|
||||
recordings: self.recordings.active(),
|
||||
notes: self.notes.lock().expect("notes mutex poisoned").clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一边的装甲板主板:手动指定优先,否则按板子自报的 `team` 自动找。
|
||||
fn assigned_armor(&self, config: &Config, team: u8) -> Option<u32> {
|
||||
let explicit = if team == 0 {
|
||||
config.referee.armor_a
|
||||
} else {
|
||||
config.referee.armor_b
|
||||
};
|
||||
|
||||
if config.referee.manual_slots {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
explicit.or_else(|| {
|
||||
self.armor
|
||||
.boards()
|
||||
.iter()
|
||||
.find(|board| board.team == team && board.is_online())
|
||||
.map(|board| board.id)
|
||||
})
|
||||
}
|
||||
|
||||
/// 一边的血量。
|
||||
///
|
||||
/// 顺序是有意的:先看绑定的那块板子,再看"这一边有没有人报血量"。绑定了却在掉线
|
||||
/// 状态时要显示**未接入**而不是拿别人的数据顶上——现场会以为对手还有血。
|
||||
fn health_for(&self, team: u8) -> TeamHealthDto {
|
||||
let config = self.config();
|
||||
let low = config.hud.health.low_threshold;
|
||||
|
||||
let board = match config.role {
|
||||
Role::Team => config
|
||||
.armor_id
|
||||
.or_else(|| self.assigned_armor(&config, team)),
|
||||
Role::Referee => self.assigned_armor(&config, team),
|
||||
};
|
||||
|
||||
if let Some(board) = board {
|
||||
return match self.armor.health_of_board(board) {
|
||||
Some(hp) => TeamHealthDto::present(team, Some(board), [hp; 4], low),
|
||||
None => TeamHealthDto::absent(team, Some(board)),
|
||||
};
|
||||
}
|
||||
|
||||
match self.armor.health(team) {
|
||||
Some(hps) => TeamHealthDto::present(team, None, hps, low),
|
||||
None => TeamHealthDto::absent(team, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 演示模式下合成的板子 id。
|
||||
///
|
||||
/// 取成合法十六进制、又不与真实 MAC 派生值相像的形状:截图里一眼能看出这是演示。
|
||||
pub const DEMO_CAMERA_A: u32 = 0x0DE0_0001;
|
||||
pub const DEMO_CAMERA_B: u32 = 0x0DE0_0002;
|
||||
pub const DEMO_ARMOR_A: u32 = 0x0A20_0001;
|
||||
pub const DEMO_ARMOR_B: u32 = 0x0A20_0002;
|
||||
Reference in New Issue
Block a user