- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形 - feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退 - feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页 - feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路 - feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码 - feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔 - feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写 - feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容 - feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因 - feat(firmware): 固件包版本与客户端同源,都从 version.json 读 - fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载 - fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页 - fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系 - fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失 - fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径 - fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录 - build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO - build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-* - docs: 补充界面语言、版本机制与协议通告布局的说明
472 lines
17 KiB
Rust
472 lines
17 KiB
Rust
//! RMIC::2026 —— 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 version;
|
||
mod latency;
|
||
mod maintenance;
|
||
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 = "\
|
||
用法: RMIC::2026 [选项]
|
||
|
||
--role <team|referee> 身份。默认看配置文件;没有配置文件时是 referee
|
||
--config <路径> 配置文件位置
|
||
--latency-report <路径> 打开裁判端屏幕标记校准,写入新的本地 NDJSON 报告
|
||
--maintenance-probe <串口> 连接设备、打印芯片/分区表/App 信息后退出(会复位一次)
|
||
--maintenance-bundle <固件包> 只校验 .zip/目录包并打印清单,不连设备
|
||
--maintenance-plan <串口> <固件包> 打印烧录计划后退出(只读,不写 Flash)
|
||
--maintenance-write-test <串口> <固件包目录> <偏移> 写 App 并回读,不切分区表
|
||
--demo 演示模式:画面与血量都是本地合成的
|
||
--list 打印发现的摄像头与装甲板后退出
|
||
-h, --help 显示这段帮助
|
||
|
||
身份由配置文件记住(~/.config/cam-desktop/config.toml),首次启动会在界面上问。
|
||
用户端的绑定改动需要密码;裁判端不需要。
|
||
|
||
环境变量:
|
||
CAM_DEMO=1 等价于 --demo
|
||
CAM_CONFIG=<路径> 等价于 --config <路径>
|
||
|
||
两个环境变量存在的理由很实际:`tauri dev` 要用两个 `--` 才能把参数递给应用
|
||
(`tauri dev -- -- --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>,
|
||
latency_report: Option<PathBuf>,
|
||
maintenance_probe: Option<String>,
|
||
maintenance_bundle: Option<PathBuf>,
|
||
maintenance_plan: Option<(String, PathBuf)>,
|
||
maintenance_write_test: Option<(String, PathBuf, u32)>,
|
||
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"),
|
||
config: std::env::var_os("CAM_CONFIG").map(PathBuf::from),
|
||
..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);
|
||
}
|
||
"--latency-report" => {
|
||
args.latency_report = it.next().map(PathBuf::from);
|
||
}
|
||
"--maintenance-probe" => {
|
||
args.maintenance_probe = it.next();
|
||
}
|
||
"--maintenance-bundle" => {
|
||
args.maintenance_bundle = it.next().map(PathBuf::from);
|
||
}
|
||
"--maintenance-plan" => {
|
||
let port = it.next();
|
||
let bundle = it.next();
|
||
if let (Some(port), Some(bundle)) = (port, bundle) {
|
||
args.maintenance_plan = Some((port, PathBuf::from(bundle)));
|
||
}
|
||
}
|
||
"--maintenance-write-test" => {
|
||
let port = it.next();
|
||
let bundle = it.next();
|
||
let offset = it.next().and_then(|value| parse_offset(&value));
|
||
if let (Some(port), Some(bundle), Some(offset)) = (port, bundle, offset) {
|
||
args.maintenance_write_test = Some((port, PathBuf::from(bundle), offset));
|
||
}
|
||
}
|
||
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(());
|
||
}
|
||
|
||
// 维护探测:不加载配置、不绑 UDP 端口,也不起窗口。现场可以用它确认串口和分区表。
|
||
if let Some(port) = &args.maintenance_probe {
|
||
let temp = std::env::current_dir()?.join("target").join("flash-tmp");
|
||
maintenance::set_temp_dir(temp);
|
||
let snapshot = maintenance::probe_snapshot(port)?;
|
||
println!("{}", serde_json::to_string_pretty(&snapshot)?);
|
||
return Ok(());
|
||
}
|
||
|
||
if let Some(bundle) = &args.maintenance_bundle {
|
||
let temp = std::env::current_dir()?.join("target").join("flash-tmp");
|
||
maintenance::set_temp_dir(temp);
|
||
let info = maintenance::inspect_bundle(bundle)?;
|
||
println!("{}", serde_json::to_string_pretty(&info)?);
|
||
return Ok(());
|
||
}
|
||
|
||
if let Some((port, bundle)) = &args.maintenance_plan {
|
||
let temp = std::env::current_dir()?.join("target").join("flash-tmp");
|
||
maintenance::set_temp_dir(temp);
|
||
let plan = maintenance::plan_snapshot(port, bundle)?;
|
||
println!("{}", serde_json::to_string_pretty(&plan)?);
|
||
return Ok(());
|
||
}
|
||
|
||
if let Some((port, bundle, offset)) = &args.maintenance_write_test {
|
||
let temp = std::env::current_dir()?.join("target").join("flash-tmp");
|
||
maintenance::set_temp_dir(temp);
|
||
let attempt = maintenance::write_test(port, bundle, *offset)?;
|
||
println!("{}", serde_json::to_string_pretty(&attempt)?);
|
||
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;
|
||
}
|
||
if args.latency_report.is_some() && config.role != Role::Referee {
|
||
return Err("Latency calibration requires a referee test configuration".into());
|
||
}
|
||
let latency_recorder = latency::Recorder::new(args.latency_report.clone())?;
|
||
|
||
for note in ¬es {
|
||
log::info!("{note}");
|
||
}
|
||
|
||
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();
|
||
|
||
// 视频通道的地址。
|
||
//
|
||
// 带 token 的那一版只在 debug 级:它是给本机开发者用 `wscat` 连上去看这条通道用的
|
||
// ("画面不动"时能立刻分清是后端没推还是前端没接)。info 级只报端口。
|
||
log::info!("stream: listening on ws://127.0.0.1:{}", state.port);
|
||
log::debug!("stream: {}", state.stream_info().url);
|
||
|
||
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 build = version::build_info();
|
||
log::info!(
|
||
"RMIC::2026 client {} (build {}, wire protocol {})",
|
||
build.version_text(),
|
||
build.build,
|
||
build.protocol
|
||
);
|
||
}
|
||
|
||
let app = tauri::Builder::default()
|
||
.plugin(tauri_plugin_opener::init())
|
||
.plugin(tauri_plugin_dialog::init())
|
||
.manage(Arc::clone(&state))
|
||
.manage(latency_recorder)
|
||
.manage(Arc::new(maintenance::MaintenanceState::default()))
|
||
.invoke_handler(tauri::generate_handler![
|
||
maintenance::maintenance_list_ports,
|
||
maintenance::maintenance_load_bundle,
|
||
maintenance::maintenance_probe,
|
||
maintenance::maintenance_plan,
|
||
maintenance::maintenance_flash,
|
||
maintenance::maintenance_restore_table,
|
||
maintenance::maintenance_cancel,
|
||
latency::latency_enabled,
|
||
latency::latency_sample,
|
||
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_team_name,
|
||
commands::set_referee_slots,
|
||
commands::set_recording_options,
|
||
commands::screenshot,
|
||
commands::start_recording,
|
||
commands::stop_recording,
|
||
commands::reveal,
|
||
commands::open_file,
|
||
commands::clear_config,
|
||
commands::quit_app,
|
||
commands::refresh,
|
||
])
|
||
.setup(move |app| {
|
||
let temp = app
|
||
.path()
|
||
.app_cache_dir()
|
||
.unwrap_or_else(|_| std::env::temp_dir())
|
||
.join("flash-tmp");
|
||
maintenance::set_temp_dir(temp);
|
||
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(())
|
||
}
|
||
|
||
fn parse_offset(text: &str) -> Option<u32> {
|
||
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
|
||
u32::from_str_radix(hex, 16).ok()
|
||
} else {
|
||
text.parse::<u32>().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);
|
||
}
|
||
}
|