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
+33
View File
@@ -87,6 +87,39 @@ cd protocol && cargo test
**`version` 不匹配就拒收**,而不是按错误布局继续解析——后者会表现为随机崩溃或画面
错乱,比拒绝连接难查得多。
## 通告里的版本信息
`Hello` / `Heartbeat` 的负载是**向后兼容的一截**,现在 10 字节:
```text
相对 HEADER_LEN 的偏移
0 2 6 8 10
+-----+-----+-----+-----+
| fw | ver | bld |proto| fw: u16 旧字段(老客户端还读它)
+-----+-----+-----+-----+ ver: u32 major<<16|minor<<8|patch
bld: u16 构建号(git 提交数)
proto: u16 板子编译进去的线路协议
```
三个决定:
- **旧的 2 字节 `fw` 保留不动**。老客户端只读它,多余的字节不看,所以一次固件更新
不会把还在用旧客户端的机器踢下线。
- **新客户端接受只有 2 字节的老固件**:版本与构建号回到 0("未知"),协议号退回帧头里
的那个。界面显示"版本未知",而不是一个看起来像真值的 `0.0.0`。
- **通告用宽松的帧头解析,视频帧不用**。见下一条。
### 为什么通告允许协议不匹配
严格解析会把 `version != PROTOCOL_VERSION` 的数据报全部丢掉。对视频帧这是对的——按错的
布局去解读一段 JPEG 只会得到垃圾。但对**通告**,那意味着一块烧了别的协议版本的板子在
客户端看来根本不存在,现场看到的现象只有"没有画面"。
所以 `Hello::decode` 走 `decode_header_lenient`:魔数、长度、字段照常校验,只是不看版本。
读出 `proto` 之后,客户端就能直接说"板子是协议 2,客户端是协议 1,画面会被丢弃,请重新
烧录",而不是让人去猜。
这不削弱边界:视频、订阅、装甲板上报仍然走严格解析。
## 为什么视频走 UDP 而不是 TCP
实测(VGA 640x480,传感器产出 22 fps):
+189 -10
View File
@@ -195,6 +195,24 @@ impl Default for Header {
/// 任何不满足的输入都返回 `None`,调用方据此当作"不是给我的"跳过,而不是当作
/// 错误——同一端口上可能出现板子的存活探测包,也可能日后出现其他协议。
pub fn decode_header(bytes: &[u8]) -> Option<Header> {
let header = parse_header(bytes)?;
if header.version != PROTOCOL_VERSION {
return None;
}
Some(header)
}
/// 解析帧头,但**不校验版本**。
///
/// 只给"公告"类消息(Hello / Heartbeat)用。理由是诊断价值:一块烧了别的协议版本的
/// 板子,用严格解析时在客户端看来等于不存在,现场看到的现象只有"没有画面";而它每次
/// 开机都在广播"我是谁、我是什么版本"。允许把这条消息读出来,界面就能直接说
/// "板子是协议 2,客户端是协议 1"。
///
/// 这不削弱安全边界:视频帧、订阅、装甲板上报仍然走严格解析,协议不匹配的帧一律丢弃。
fn parse_header(bytes: &[u8]) -> Option<Header> {
if bytes.len() < HEADER_LEN {
return None;
}
@@ -205,11 +223,7 @@ pub fn decode_header(bytes: &[u8]) -> Option<Header> {
}
let msg_type = MsgType::from_u8(bytes[4])?;
let version = u16::from_le_bytes(bytes[5..7].try_into().ok()?);
if version != PROTOCOL_VERSION {
return None;
}
Some(Header {
msg_type,
@@ -224,6 +238,11 @@ pub fn decode_header(bytes: &[u8]) -> Option<Header> {
})
}
/// 宽松版:[`parse_header`] 的公开别名,供 Hello 使用。
pub fn decode_header_lenient(bytes: &[u8]) -> Option<Header> {
parse_header(bytes)
}
/// 把帧头写进 `out`(追加,不清空)。
pub fn encode_header_into(header: &Header, out: &mut Vec<u8>) {
out.extend_from_slice(&MAGIC.to_le_bytes());
@@ -412,11 +431,68 @@ impl Subscribe {
}
/// 板子的在线通告与心跳。
///
/// # 负载为什么是"向后兼容的一截"
///
/// 最早只有 2 字节(`firmware`)。加了版本联动之后,同一条消息里多了语义化版本、构建号
/// 与板子自报的协议号,于是负载变成 10 字节。**旧客户端只读前 2 字节,多余的它不看**;
/// 新客户端遇到只有 2 字节的旧固件,会把后面几项当作"未知"而不是 0。这样一次固件更新
/// 不会把还在用旧客户端的机器踢下线。
///
/// ```text
/// 0 2 6 8 10 (相对 HEADER_LEN 的偏移)
/// +----+----+----+----+
/// |fw |ver |bld |proto| fw: u16 旧字段, ver: u32 打包版本,
/// +----+----+----+----+ bld: u16 构建号, proto: u16 板子自报协议
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Hello {
pub sender_id: u32,
/// 固件版本,便于现场排查烧录了哪一版。
/// 旧的两字节固件版本。保留它只有一个理由:**老客户端还在读它**。
/// 新代码一律用 [`Hello::version`] / [`Hello::build`]。
pub firmware: u16,
/// 打包的语义化版本:`major << 16 | minor << 8 | patch`。0 表示"固件没带这个字段"。
pub version: u32,
/// 自增构建号(通常是 git 提交数)。0 表示未知。
pub build: u16,
/// 板子**自己编译进去的**线路协议版本。
///
/// 它和帧头里的 `version` 通常是同一个数——但帧头那个会被
/// [`decode_header`] 挡掉,所以一块协议不匹配的板子在客户端看来"根本不存在"。
/// 这里再报一遍,是为了让客户端能说出"它是协议 2,我是协议 1",
/// 而不是只表现为"没有画面"。
pub protocol: u16,
}
impl Default for Hello {
fn default() -> Self {
Self {
sender_id: 0,
firmware: 1,
version: 0,
build: 0,
protocol: PROTOCOL_VERSION,
}
}
}
/// 语义化版本打包成 u32 与解包。
pub const fn pack_version(major: u16, minor: u16, patch: u16) -> u32 {
((major as u32) << 16) | (((minor as u32) & 0xff) << 8) | ((patch as u32) & 0xff)
}
pub const fn unpack_version(packed: u32) -> (u16, u16, u16) {
(
((packed >> 16) & 0xffff) as u16,
((packed >> 8) & 0xff) as u16,
(packed & 0xff) as u16,
)
}
/// `0x0001_0203` → `"1.2.3"`。
pub fn format_version(packed: u32) -> String {
let (major, minor, patch) = unpack_version(packed);
format!("{major}.{minor}.{patch}")
}
impl Hello {
@@ -428,33 +504,66 @@ impl Hello {
..Default::default()
};
let mut out = Vec::with_capacity(HEADER_LEN + 2);
let mut out = Vec::with_capacity(HELLO_PAYLOAD_LEN + HEADER_LEN);
encode_header_into(&header, &mut out);
out.extend_from_slice(&self.firmware.to_le_bytes());
out.extend_from_slice(&self.version.to_le_bytes());
out.extend_from_slice(&self.build.to_le_bytes());
out.extend_from_slice(&self.protocol.to_le_bytes());
out
}
pub fn decode(bytes: &[u8]) -> Option<Self> {
let header = decode_header(bytes)?;
// **用宽松的帧头解析**:协议不匹配的板子也要能被看见。
// 严格解析会让"烧错固件"表现为"板子不存在",而那正是这条消息要解释的事。
let header = decode_header_lenient(bytes)?;
if !matches!(header.msg_type, MsgType::Hello | MsgType::Heartbeat) {
return None;
}
// 固件版本是可选的:早期或精简的通告可能不带这个字段。
let firmware = if bytes.len() >= HEADER_LEN + 2 {
u16::from_le_bytes(bytes[HEADER_LEN..HEADER_LEN + 2].try_into().ok()?)
// 负载三段都可选:老固件只有 2 字节,中间版本可能只有 6 字节。
let payload = &bytes[HEADER_LEN..];
let firmware = if payload.len() >= 2 {
u16::from_le_bytes(payload[0..2].try_into().ok()?)
} else {
0
};
let version = if payload.len() >= 6 {
u32::from_le_bytes(payload[2..6].try_into().ok()?)
} else {
0
};
let build = if payload.len() >= 8 {
u16::from_le_bytes(payload[6..8].try_into().ok()?)
} else {
0
};
// 缺这一段说明固件比本客户端老:把帧头里的协议号当作它的协议号。
let protocol = if payload.len() >= 10 {
u16::from_le_bytes(payload[8..10].try_into().ok()?)
} else {
header.version
};
Some(Hello {
sender_id: header.sender_id,
firmware,
version,
build,
protocol,
})
}
}
/// 通告消息的完整负载长度:`fw`(2) + `version`(4) + `build`(2) + `protocol`(2)。
pub const HELLO_PAYLOAD_LEN: usize = 10;
// ---------------------------------------------------------------------------
// 装甲板
// ---------------------------------------------------------------------------
@@ -914,6 +1023,9 @@ mod tests {
let hello = Hello {
sender_id: 0x8765_4321,
firmware: 1,
version: pack_version(0, 2, 0),
build: 57,
protocol: PROTOCOL_VERSION,
};
assert_eq!(Hello::decode(&hello.encode(MsgType::Hello)), Some(hello));
assert_eq!(
@@ -922,6 +1034,73 @@ mod tests {
);
}
#[test]
fn versions_pack_and_format() {
assert_eq!(pack_version(0, 2, 0), 0x0000_0200);
assert_eq!(pack_version(1, 2, 3), 0x0001_0203);
assert_eq!(unpack_version(0x0001_0203), (1, 2, 3));
assert_eq!(format_version(0x0001_0203), "1.2.3");
assert_eq!(format_version(pack_version(12, 0, 7)), "12.0.7");
// minor / patch 只占 8 位:256 被截断而不是进位成 2.0.0。
// 宁可少报,也不要给出一个"看起来是对的"谎话。
assert_eq!(unpack_version(pack_version(1, 256, 0)).1, 0);
}
#[test]
fn a_two_byte_legacy_hello_still_decodes() {
// 旧固件只发 2 字节:版本号与构建号必须回到 0("未知"),
// 协议号退回帧头里的那一个,而不是变成 0 被误判成不兼容。
let header = Header {
msg_type: MsgType::Hello,
version: PROTOCOL_VERSION,
sender_id: 0xAAAA_BBBB,
..Default::default()
};
let mut bytes = Vec::new();
encode_header_into(&header, &mut bytes);
bytes.extend_from_slice(&7u16.to_le_bytes());
let decoded = Hello::decode(&bytes).expect("legacy hello");
assert_eq!(decoded.sender_id, 0xAAAA_BBBB);
assert_eq!(decoded.firmware, 7);
assert_eq!(decoded.version, 0);
assert_eq!(decoded.build, 0);
assert_eq!(decoded.protocol, PROTOCOL_VERSION);
}
#[test]
fn a_protocol_mismatched_hello_is_visible_but_its_frames_are_not() {
// 这块板子编译进去的是协议 2。严格解析必须把它挡掉(视频不能按错布局解读),
// 但通告要能被读出来——这是"烧错固件"能被解释清楚的唯一机会。
let hello = Hello {
sender_id: 0x1111_2222,
firmware: 1,
version: pack_version(0, 3, 0),
build: 99,
protocol: 2,
};
let mut bytes = Vec::new();
let header = Header {
msg_type: MsgType::Hello,
version: 2,
sender_id: hello.sender_id,
..Default::default()
};
encode_header_into(&header, &mut bytes);
bytes.extend_from_slice(&hello.firmware.to_le_bytes());
bytes.extend_from_slice(&hello.version.to_le_bytes());
bytes.extend_from_slice(&hello.build.to_le_bytes());
bytes.extend_from_slice(&hello.protocol.to_le_bytes());
assert!(decode_header(&bytes).is_none(), "strict header must reject v2");
assert!(decode_header_lenient(&bytes).is_some());
let decoded = Hello::decode(&bytes).expect("lenient hello");
assert_ne!(decoded.protocol, PROTOCOL_VERSION);
assert_eq!(format_version(decoded.version), "0.3.0");
assert_eq!(decoded.build, 99);
}
#[test]
fn armor_health_roundtrips() {
// 现在有实现了(armor/ 里的 ESP32-C3 固件在发它),布局必须保持可往返。