feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
- 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: 补充界面语言、版本机制与协议通告布局的说明
@@ -53,5 +53,19 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"]
|
||||
# 文件名里的时间戳:现场看目录时要能一眼看出是哪一局录的。
|
||||
chrono = "0.4"
|
||||
|
||||
# 固件维护页:串口枚举、ESP32 ROM 下载协议、分区表与读写校验。
|
||||
# espflash 只开库所需的 serialport feature,不把它的 CLI 依赖拖进来。
|
||||
espflash = { version = "4.6", default-features = false, features = ["serialport"] }
|
||||
serialport = { version = "4.7", default-features = false }
|
||||
sha2 = "0.10"
|
||||
md-5 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
# 官方固件包:客户端可直接读 .zip,不必先解压。
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
# 原生目录选择框(选择官方固件包)。
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2
|
||||
|
||||
@@ -3,5 +3,10 @@
|
||||
"identifier": "default",
|
||||
"description": "窗口与事件这些核心能力。业务命令(绑定、解锁、截图、录制)都是本应用自己的 #[tauri::command],不在权限系统里,所以这里没有它们的条目。",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:default",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,26 @@
|
||||
<!-- RMIC::2026 应用图标(矢量源)。
|
||||
由 https://simba-robotics.com/logo.svg 生成:把原图 1024 画布的 viewBox 收窄、标志居中,
|
||||
底色用界面的 --deep。位图(PNG / ICO / ICNS)由这一份光栅化得到;
|
||||
小尺寸另有一版更大的缩放比(见 desktop/README.md 的「队标」一节)。 -->
|
||||
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93333" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="270.93333" height="270.93333" fill="#080b12"/>
|
||||
<g transform="translate(135.4667 135.4667) scale(0.82) translate(-135.28 -136.22)">
|
||||
<g
|
||||
inkscape:label="图层 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<path
|
||||
d="m 120.18387,65.065937 -8.00358,13.862771 h 27.77765 l 0.0541,0.09409 30.76276,53.283672 -35.41234,61.33599 -0.008,0.0143 -7.99546,13.84855 -0.008,0.0143 h 16.00718 44.01701 l -5.80572,10.05592 h -66.94744 l -0.007,0.0105 h -6.48395 l 5.81384,-10.07013 8.00358,-13.86278 28.83631,-49.94524 8.0036,-13.86276 H 142.78056 114.57375 107.01421 83.392663 67.385498 l -8.004262,13.86276 -42.562363,73.72043 -8.0035837,13.86278 H 24.822458 84.211033 l -0.0914,0.15771 h 16.007157 11.36098 l 0.007,-0.0105 h 62.07111 16.00784 l 0.0105,-0.0175 h 41.16526 15.05748 16.00718 l -8.00358,-13.86278 -5.81181,-10.06608 -8.00358,-13.86276 h -16.00718 -12.5848 -16.00784 l -0.0156,0.0278 h -1.6347 -0.80551 -12.33705 l 27.39928,-47.45699 8.00358,-13.86276 -5.11392,-8.85783 -11.76441,-20.37651 h 31.64205 l 5.06519,-8.773893 2.9384,-5.088885 h -15.13465 -48.55152 l 8.00358,13.862778 h 0.0297 l 16.87765,29.23434 -35.40286,61.31975 h -13.23258 l 27.41688,-47.48812 8.00358,-13.8621 -8.00358,-13.86276 -22.75918,-39.420912 h 60.26447 l -8.00332,-13.862894 h -32.42792 v -0.09409 z M 75.389113,143.70806 h 23.62222 7.558857 28.2075 l -28.83633,49.94523 -8.003587,13.86278 -5.11257,8.85512 -5.1119,-8.85512 -8.00427,-13.86278 H 63.701872 46.553462 l 8.574205,-14.85103 8.003585,-13.86277 z"
|
||||
style="fill:#ffffff;stroke-width:0.904925;paint-order:stroke fill markers"
|
||||
id="path24" />
|
||||
<path
|
||||
d="m 134.72691,41.313757 -8.00358,13.862771 h 62.89352 16.00718 31.56419 l -8.00358,-13.862771 h -31.5642 -16.00717 z"
|
||||
style="fill:#ffffff;stroke-width:1.17159;paint-order:stroke fill markers"
|
||||
id="path26" />
|
||||
<path
|
||||
d="M 243.26673,60.098895 A 11.644196,11.644196 0 0 0 231.62281,71.742811 11.644196,11.644196 0 0 0 243.26673,83.386727 11.644196,11.644196 0 0 0 254.91131,71.742811 11.644196,11.644196 0 0 0 243.26673,60.098895 Z"
|
||||
style="fill:#ff0000;stroke-width:0.513476;paint-order:stroke fill markers"
|
||||
id="path25" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -67,9 +67,16 @@ fn spawn_camera(state: Arc<AppState>, camera: u32, team: u8) {
|
||||
41000 + team as u16,
|
||||
));
|
||||
|
||||
// 演示模式合成的通告带上**本客户端自己的版本**(同一份 version.json),
|
||||
// 所以界面上的"固件 v0.2.0 · 34"与这一份二进制是一致的。演示模式靠顶栏
|
||||
// 那条琥珀标签区分,不靠版本号露馅——版本号是真实信息,不该为了演示造假。
|
||||
let build = crate::version::build_info();
|
||||
let hello = protocol::Hello {
|
||||
sender_id: camera,
|
||||
firmware: 0x0DE0,
|
||||
firmware: 1,
|
||||
version: build.packed(),
|
||||
build: build.build,
|
||||
protocol: build.protocol,
|
||||
};
|
||||
|
||||
let frame_interval = Duration::from_micros(1_000_000 / FPS);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! cam-desktop —— ESP32 图传与装甲板系统的桌面端。
|
||||
//! RMIC::2026 —— ESP32 图传与装甲板系统的桌面端。
|
||||
//!
|
||||
//! 一个进程、两套页面:
|
||||
//!
|
||||
@@ -23,7 +23,9 @@
|
||||
|
||||
mod commands;
|
||||
mod demo;
|
||||
mod version;
|
||||
mod latency;
|
||||
mod maintenance;
|
||||
mod recording;
|
||||
mod server;
|
||||
mod state;
|
||||
@@ -39,11 +41,15 @@ use tauri::{Manager, RunEvent, WindowEvent};
|
||||
use state::AppState;
|
||||
|
||||
const USAGE: &str = "\
|
||||
用法: cam-desktop [选项]
|
||||
用法: 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 显示这段帮助
|
||||
@@ -63,7 +69,7 @@ fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
if let Err(err) = run() {
|
||||
log::error!("{err}");
|
||||
log::error!("{err:#}");
|
||||
|
||||
// 走到这里说明连窗口都没起来(配置坏了、回环端口绑不上)。GUI 程序从访达启动时
|
||||
// 没有终端可看,所以把原因也写进一个旁边的文件——现场排查时这是唯一线索。
|
||||
@@ -81,6 +87,10 @@ 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,
|
||||
@@ -110,6 +120,27 @@ impl Args {
|
||||
"--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 里的笔误都可能出现在这里,
|
||||
// 但报错的代价(窗口起不来)远大于收益。
|
||||
@@ -130,6 +161,39 @@ fn run() -> Result<(), Box<dyn Error>> {
|
||||
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()
|
||||
@@ -228,11 +292,32 @@ fn run() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
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,
|
||||
@@ -262,7 +347,13 @@ fn run() -> Result<(), Box<dyn Error>> {
|
||||
commands::quit_app,
|
||||
commands::refresh,
|
||||
])
|
||||
.setup(move |_app| {
|
||||
.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(())
|
||||
})
|
||||
@@ -313,6 +404,14 @@ fn run() -> Result<(), Box<dyn Error>> {
|
||||
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 的公共类型,而"日志里长
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! 编译期嵌入的构建信息。
|
||||
//!
|
||||
//! # 为什么读仓库根的那个文件
|
||||
//!
|
||||
//! `version.json` 由 `scripts/version.mjs` 从 git 历史算出来,是版本号的唯一来源。
|
||||
//! 这里用 `include_str!` 把仓库根的那一份**嵌进二进制**:
|
||||
//!
|
||||
//! - 不依赖运行时的文件(装到别人机器上也能读到);
|
||||
//! - `include_str!` 会让 cargo 把它登记成依赖,所以它一变,这个 crate 就会重编——
|
||||
//! 不需要再手写 `cargo:rerun-if-changed`。
|
||||
//!
|
||||
//! 二进制里的版本号因此一定与它编译时的那棵源码树一致,不会出现"装包写着 0.1.0、
|
||||
//! 其实是两周后的代码"。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// 仓库根 `version.json` 的原文。
|
||||
const RAW: &str = include_str!("../../../version.json");
|
||||
|
||||
/// 一份构建的全部身份信息。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BuildInfo {
|
||||
pub major: u16,
|
||||
pub minor: u16,
|
||||
pub patch: u16,
|
||||
/// git 提交数,单调递增。
|
||||
pub build: u16,
|
||||
/// 线路协议版本。
|
||||
pub protocol: u16,
|
||||
}
|
||||
|
||||
impl BuildInfo {
|
||||
/// 打包成 `major << 16 | minor << 8 | patch`,与协议里的 `Hello.version` 同一套编码。
|
||||
pub fn packed(&self) -> u32 {
|
||||
((self.major as u32) << 16) | (((self.minor as u32) & 0xff) << 8) | ((self.patch as u32) & 0xff)
|
||||
}
|
||||
|
||||
pub fn version_text(&self) -> String {
|
||||
format!("{}.{}.{}", self.major, self.minor, self.patch)
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析失败时退回全 0——那会让界面显示"版本未知",比编不过或者编出一个假版本好。
|
||||
pub fn build_info() -> BuildInfo {
|
||||
let Ok(json) = serde_json::from_str::<Value>(RAW) else {
|
||||
return BuildInfo {
|
||||
major: 0,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
build: 0,
|
||||
protocol: 0,
|
||||
};
|
||||
};
|
||||
|
||||
let field = |name: &str| -> u16 {
|
||||
json.get(name)
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0)
|
||||
.min(u16::MAX as u64) as u16
|
||||
};
|
||||
|
||||
BuildInfo {
|
||||
major: field("major"),
|
||||
minor: field("minor"),
|
||||
patch: field("patch"),
|
||||
build: field("build"),
|
||||
protocol: field("protocol"),
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "cam-desktop",
|
||||
"version": "0.1.0",
|
||||
"identifier": "org.camdesktop.client",
|
||||
"productName": "RMIC-2026",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.simbarobotics.rmic2026",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
@@ -13,7 +13,7 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "cam-desktop",
|
||||
"title": "RMIC::2026",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 900,
|
||||
@@ -38,10 +38,10 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app", "dmg"],
|
||||
"targets": "all",
|
||||
"category": "Utility",
|
||||
"shortDescription": "ESP32 图传与装甲板桌面端",
|
||||
"longDescription": "用户端:一路画面 + 血条与瞄准器。裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。",
|
||||
"shortDescription": "RMIC::2026 —— 视野链路终端",
|
||||
"longDescription": "RMIC::2026 视野链路终端。用户端:一路画面 + 装甲条与瞄准器。裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||