feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s

- 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: 补充界面语言、版本机制与协议通告布局的说明
This commit is contained in:
2026-10-19 02:40:08 +08:00
parent cc6b82d7ec
commit 896b21fe44
332 changed files with 16729 additions and 2765 deletions
+5 -1
View File
@@ -619,9 +619,13 @@ const HEADER: &str = "\
// ---------------------------------------------------------------------------
fn home_dir() -> PathBuf {
// Windows 的 Tauri/服务进程里通常没有 `HOME`,只有 `USERPROFILE`。
// 只看 `HOME` 会落到 `.`,配置文件就变成相对于启动目录——安装版从开始菜单启动时
// 启动目录是 Program Files,配置写不进去,卸载后也留不下任何东西。
std::env::var_os("HOME")
.filter(|p| !p.is_empty())
.or_else(|| std::env::var_os("USERPROFILE").filter(|p| !p.is_empty()))
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| PathBuf::from("."))
}
+96 -7
View File
@@ -75,11 +75,68 @@ const FRAME_TIMEOUT: Duration = Duration::from_millis(500);
pub struct CameraInfo {
pub id: u32,
pub addr: SocketAddrV4,
pub firmware: u16,
/// 板子自报的固件版本(来自它的 Hello)。
pub firmware: FirmwareInfo,
/// 最近一次收到它广播的时刻。
pub last_seen: Instant,
}
/// 板子自报的固件版本。
///
/// # 为什么不是一个 u16
///
/// 原来只有 `Hello.firmware: u16`,是个没有定义的数——显示出来也没人能解释。现在它是
/// 一组**能对得上**的数:语义化版本、构建号、以及板子编译进去的线路协议版本。
///
/// 最后一项是这套机制的重点:一块烧了别的协议版本的板子,视频帧会被严格解析挡掉,
/// 于是它在客户端看来等于不存在。但它的通告仍然读得出来,所以界面可以说
/// "板子是协议 2,客户端是协议 1",而不是只能报"没有画面"。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FirmwareInfo {
/// 打包的语义化版本(`major << 16 | minor << 8 | patch`)。0 = 固件太老,没带。
pub version: u32,
/// 自增构建号。0 = 未知。
pub build: u16,
/// 板子自报的线路协议版本。
pub protocol: u16,
/// 旧的两字节字段原值,只用于日志。
pub legacy: u16,
}
impl FirmwareInfo {
pub fn from_hello(hello: &protocol::Hello) -> Self {
Self {
version: hello.version,
build: hello.build,
protocol: hello.protocol,
legacy: hello.firmware,
}
}
/// `"0.2.0"`;固件没带版本时返回 `None`(而不是 "0.0.0")。
pub fn version_text(&self) -> Option<String> {
(self.version != 0).then(|| protocol::format_version(self.version))
}
/// 这块板子的协议和本客户端一致吗。
///
/// 老固件没有这个字段,`Hello::decode` 会把帧头里的版本号填进来——那个值已经被
/// 严格解析校验过,所以到这一步一定匹配。
pub fn is_compatible(&self) -> bool {
self.protocol == protocol::PROTOCOL_VERSION
}
/// 一行给人看的描述,日志与界面提示共用同一句。
pub fn describe(&self) -> String {
match (self.version_text(), self.build) {
(Some(version), 0) => format!("v{version}"),
(Some(version), build) => format!("v{version}.{build}"),
(None, 0) => "版本未知".to_string(),
(None, build) => format!("构建 {build}"),
}
}
}
impl CameraInfo {
/// 多久没有广播了。
pub fn silence(&self) -> Duration {
@@ -244,15 +301,21 @@ impl Discovery {
}
}
fn note_hello(&self, id: u32, addr: SocketAddrV4, firmware: u16) -> bool {
/// 记下一次通告。
///
/// 返回值只在**新出现**或**版本/协议发生变化**时有意义:`Some(true)` 表示它是兼容的,
/// `Some(false)` 表示它自报的协议与本客户端不同。周期性的重复通告返回 `None`——
/// 它每 2 秒来一次,每次都打日志会把真正的事件淹掉。
fn note_hello(&self, id: u32, addr: SocketAddrV4, firmware: FirmwareInfo) -> Option<bool> {
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
match cameras.get_mut(&id) {
Some(info) => {
let changed = info.firmware != firmware;
info.addr = addr;
info.last_seen = Instant::now();
info.firmware = firmware;
false
changed.then(|| firmware.is_compatible())
}
None => {
cameras.insert(
@@ -264,7 +327,7 @@ impl Discovery {
last_seen: Instant::now(),
},
);
true
Some(firmware.is_compatible())
}
}
}
@@ -454,8 +517,28 @@ impl Receiver {
}
}
if self.discovery.note_hello(hello.sender_id, v4, hello.firmware) {
log::info!("found camera 0x{:08X} at {v4}", hello.sender_id);
let firmware = FirmwareInfo::from_hello(&hello);
if let Some(compatible) = self.discovery.note_hello(hello.sender_id, v4, firmware) {
if compatible {
log::info!(
"found camera 0x{:08X} at {v4} ({}, protocol {})",
hello.sender_id,
firmware.describe(),
firmware.protocol
);
} else {
// 这条日志值钱的地方在于:它把"没有画面"翻译成了一个具体的原因。
// 严格解析会把这些板子的视频帧全部丢掉,所以除了这里,没有任何地方
// 会留下"场上有一块协议不匹配的板子"的痕迹。
log::warn!(
"camera 0x{:08X} at {v4} speaks protocol {}, this client speaks {} - \
its video will be ignored; reflash the board with a matching firmware",
hello.sender_id,
firmware.protocol,
protocol::PROTOCOL_VERSION
);
}
}
return;
}
@@ -668,6 +751,9 @@ mod tests {
let hello = Hello {
sender_id: sender,
firmware: 7,
version: protocol::pack_version(0, 2, 0),
build: 57,
protocol: protocol::PROTOCOL_VERSION,
};
rx.datagram(&hello.encode(MsgType::Hello), from(from_ip));
}
@@ -675,7 +761,10 @@ mod tests {
#[test]
fn discovered_boards_are_subscribed_directly_when_broadcast_uses_another_interface() {
let rx = Receiver::new(Ipv4Addr::BROADCAST, SubscribeRole::Referee, None);
let hello = Hello { sender_id: 1, firmware: 7 };
let hello = Hello {
sender_id: 1,
..Default::default()
};
// Hello 可从其它端口发来,订阅仍需发往板子的固定监听端口。
rx.datagram(&hello.encode(MsgType::Hello), "192.168.1.139:12345".parse().unwrap());
feed_hello(&rx, 2, [192, 168, 1, 140]);
+15 -2
View File
@@ -32,7 +32,17 @@ pub struct CameraDto {
pub addr: String,
pub online: bool,
pub silence_ms: u64,
pub firmware: u16,
/// 固件版本字符串(`"0.2.0"`)。固件太老、没带这个字段时为 `null`。
pub firmware_version: Option<String>,
/// 自增构建号。0 = 未知。
pub firmware_build: u16,
/// 板子自报的线路协议版本。
pub protocol: u16,
/// 本客户端能不能和它通信。
///
/// 为 `false` 时它不会有画面——帧被严格解析挡掉了——但界面仍然能说出原因,
/// 这就是这个字段存在的全部意义。
pub compatible: bool,
}
impl From<&CameraInfo> for CameraDto {
@@ -43,7 +53,10 @@ impl From<&CameraInfo> for CameraDto {
addr: camera.addr.ip().to_string(),
online: camera.is_online(),
silence_ms: camera.silence().as_millis() as u64,
firmware: camera.firmware,
firmware_version: camera.firmware.version_text(),
firmware_build: camera.firmware.build,
protocol: camera.firmware.protocol,
compatible: camera.firmware.is_compatible(),
}
}
}