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:
+13
-3
@@ -1,15 +1,25 @@
|
||||
# 构建产物。
|
||||
#
|
||||
# 三条都必要:根目录是 firmware crate,protocol/ 和 client/ 各自也是独立的
|
||||
# crate(各有自己的 .cargo/config.toml 与 target 目录),所以只写 `target/`
|
||||
# 匹配不到它们。
|
||||
# 四行都必要:根目录是 firmware crate,protocol/ 和 client/ 各自也是独立的
|
||||
# crate(各有自己的 .cargo/config.toml 与 target 目录),armor/ 是一套独立
|
||||
# workspace(协议层 + ESP32-C3 固件),同理。所以只写 `target/` 匹配不到它们。
|
||||
#
|
||||
# 全部锚定在根目录(前置 /),这是有意的:components/esp32-camera/target/ 看着
|
||||
# 像构建产物,其实是上游组件的**按芯片分目录**(esp32/ esp32s2/ esp32s3/ …),
|
||||
# 里面是 ll_cam.c 等源码,包含本项目打的补丁。不要把它一起忽略掉。
|
||||
#
|
||||
# desktop/ 自己还有一份 .gitignore(node_modules、dist、src-tauri/target)。
|
||||
/target/
|
||||
/protocol/target/
|
||||
/client/target/
|
||||
/armor/target/
|
||||
|
||||
# 本机专属的 cargo 注册表缓存。
|
||||
#
|
||||
# 这个仓库所在的目录树在 DSH 的文件沙箱里可写,而 ~/.cargo 不可写,所以 desktop/ 的
|
||||
# 构建把 CARGO_HOME 指到了这里(里面是从 ~/.cargo 搬过来的 crates 缓存)。它是几 GB 的
|
||||
# 机器状态,不是项目的一部分。
|
||||
/.cargo-home/
|
||||
|
||||
# WiFi 凭据,编译期由 env! 读取。不要提交。
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# 桌面端构建为宿主机目标,不是 ESP32。
|
||||
#
|
||||
# desktop/ 嵌在固件的目录树里,cargo 会向上找到 ../../.cargo/config.toml,那里面
|
||||
# `target = "xtensa-esp32s3-espidf"` 加一个 ldproxy linker,只有在 ESP-IDF 交叉编译
|
||||
# 里才成立。没有这个覆盖,桌面端会被交叉编译到 Xtensa,然后在依赖深处失败——
|
||||
# `libc` 找不到 `MAP_FAILED` 之类的东西,因为它正在为一个没有 `mmap` 的嵌入式目标
|
||||
# 编译。
|
||||
#
|
||||
# 覆盖 target 一项就够了:父配置里 `cfg(target_os = "espidf")` 那一段对 macOS 不
|
||||
# 生效,`[env]` 里两个 ESP-IDF 变量与宿主构建无关。
|
||||
#
|
||||
# 这一行是**换机器时必须改的**:换到 Intel Mac 是 x86_64-apple-darwin,换到 Linux
|
||||
# 是 x86_64-unknown-linux-gnu。
|
||||
[build]
|
||||
target = "aarch64-apple-darwin"
|
||||
@@ -0,0 +1,12 @@
|
||||
# 前端依赖与产物。
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Rust 构建产物。desktop/ 下面是三个各自独立的 crate(core / src-tauri / 以及构建时
|
||||
# 可能出现的根 crate),各有各的 target 目录,所以三个都要写。
|
||||
/target/
|
||||
/core/target/
|
||||
/src-tauri/target/
|
||||
|
||||
# Tauri 生成的权限 schema(构建产物,随 tauri-build 版本变化)。
|
||||
/src-tauri/gen/schemas/
|
||||
Generated
+436
@@ -0,0 +1,436 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cam-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"log",
|
||||
"protocol",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protocol"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||
dependencies = [
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "cam-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "桌面端的非界面部分:UDP 接收/重组、装甲板发现、配置与密码"
|
||||
|
||||
[dependencies]
|
||||
# 与固件共享的线路格式。用 path 而不是复制,两边不可能悄悄分叉。
|
||||
protocol = { path = "../../protocol" }
|
||||
|
||||
anyhow = "1"
|
||||
log = "0.4"
|
||||
|
||||
# 配置的序列化。这里与旧客户端(client/)的做法相反:那一版的配置只有十来个标量
|
||||
# 字段,手写 TOML 换来零依赖是划算的。现在配置里有 HUD 的嵌套结构和密码哈希,手写
|
||||
# 解析器的对称性成本超过了它的收益,所以换成真正的 TOML。
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.9"
|
||||
|
||||
# 密码哈希。Argon2id 是目前的推荐选择(内存硬),而 SHA-256 之类的快哈希对
|
||||
# 一个 4 位数字密码来说等于没有保护。
|
||||
argon2 = "0.5"
|
||||
rand = "0.9"
|
||||
|
||||
[dev-dependencies]
|
||||
# 只用来验证喂给界面的 JSON:断言"密码哈希不在里面"这件事只能对着序列化结果做。
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,598 @@
|
||||
//! 装甲板:监听 5601,把主板广播的 `ArmorHealth` 变成界面要的东西。
|
||||
//!
|
||||
//! 两件事,各自服务一种需求:
|
||||
//!
|
||||
//! - **血量**([`ArmorSource::health`]):某一边四条上报都在新鲜期内才交给界面。
|
||||
//! - **发现**([`ArmorHealthStore::boards`]):场上有哪几块装甲板主板,各自的 id、IP、
|
||||
//! 自报的队伍、最后一次血量。
|
||||
//!
|
||||
//! 第二种以前没有,因为队伍端不能选装甲板。现在能选了(要密码),于是"列出候选"
|
||||
//! 成为必需。**这里不需要改固件**:主板一直用 `encode_from(sender_id)` 把自己的
|
||||
//! MAC 派生 id 打进帧头,只是旧的解码路径把它丢了。
|
||||
//!
|
||||
//! # 为什么按 `(team, index)` 存最后一次读数
|
||||
//!
|
||||
//! 主板每 200 ms 广播四条(`index` 0..3,整车一条血所以 `hp` 相同)。UDP 会丢包,
|
||||
//! 界面不能"收到哪条显示哪条"——那样四个格子会各自跳。所以**四条都在新鲜期内**才交给
|
||||
//! 界面;否则退回占位符:显示一个可能已经过期的血量,比显示"未接入"更危险(现场会以为
|
||||
//! 对方还有血)。
|
||||
//!
|
||||
//! # 为什么绑 0.0.0.0
|
||||
//!
|
||||
//! 板子往 `255.255.255.255:5601` 广播,只有绑到 `INADDR_ANY` 的 socket 才收得到。
|
||||
//! 绑具体网卡地址的话,换个网络就得改配置。
|
||||
//!
|
||||
//! # 两端都开
|
||||
//!
|
||||
//! 旧客户端只在裁判端绑 5601(队伍端不显示血量)。现在队伍端要显示自己的血条,所以
|
||||
//! 两端都要收。这反而去掉了同一台机器上"队伍端与裁判端抢 5601"的争用点——两套界面
|
||||
//! 现在在同一个进程里。
|
||||
//!
|
||||
//! # 命中检测
|
||||
//!
|
||||
//! 掉血事件([`HealthTracker`])由这里派生:主板只上报"现在是多少血",不报"刚刚被
|
||||
//! 打了一下"。所以命中反馈是靠**相邻两次读数之差**还原的,见那个结构体的文档。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::ErrorKind;
|
||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use protocol::{ArmorHealth, ARMOR_PORT};
|
||||
|
||||
use crate::receiver::ARMOR_BOARDS;
|
||||
|
||||
/// 多久没收到就当作板子掉线。
|
||||
///
|
||||
/// 心跳 200 ms,所以这是连续丢了 7 个包:偶发丢一两个不会让界面闪回占位符。
|
||||
pub const STALE_AFTER: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// 一边的装甲板数量(0/1 两支队伍)。
|
||||
const TEAMS: usize = 2;
|
||||
|
||||
/// 某个 `(team, index)` 的最后一次读数。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Reading {
|
||||
hp: u16,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
/// 一块装甲板主板。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArmorBoard {
|
||||
/// MAC 派生的板子 id,重启不变,所以绑定它不会失效。
|
||||
pub id: u32,
|
||||
/// 最后一次收到它广播的来源地址。
|
||||
pub addr: SocketAddrV4,
|
||||
/// 板子自报的队伍(编译期定的 `ARMOR_TEAM`)。
|
||||
pub team: u8,
|
||||
/// 最后一次读数里的血量。整车一条血,所以取任意一条都一样。
|
||||
pub hp: u16,
|
||||
/// 见过的 `index` 位图:低 4 位分别对应 0..3。
|
||||
///
|
||||
/// 用来区分"这块板子只报了第 0 条"和"四条都报了"——前者通常意味着配置或固件不对,
|
||||
/// 现场值得看一眼。
|
||||
pub plates_seen: u8,
|
||||
pub last_seen: Instant,
|
||||
}
|
||||
|
||||
impl ArmorBoard {
|
||||
/// 多久没有广播了。
|
||||
pub fn silence(&self) -> Duration {
|
||||
self.last_seen.elapsed()
|
||||
}
|
||||
|
||||
/// 板子在线吗?
|
||||
pub fn is_online(&self) -> bool {
|
||||
self.silence() < STALE_AFTER
|
||||
}
|
||||
|
||||
/// 四条装甲板都上报过吗。
|
||||
pub fn reports_all_plates(&self) -> bool {
|
||||
self.plates_seen == 0b1111
|
||||
}
|
||||
}
|
||||
|
||||
/// 收到的一条装甲板上报。
|
||||
///
|
||||
/// 比 [`protocol::ArmorHealth`] 多一个"谁发的"——那正是能列出候选、能绑定、能把命中
|
||||
/// 归到具体某块板子上的前提。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ArmorReport {
|
||||
pub board: u32,
|
||||
pub addr: SocketAddrV4,
|
||||
pub team: u8,
|
||||
pub index: u8,
|
||||
pub hp: u16,
|
||||
}
|
||||
|
||||
/// 每次收到上报就调一次。
|
||||
///
|
||||
/// 在接收线程里被调用,所以必须是非阻塞的。Tauri 层在这里做掉血检测并把结果推给界面。
|
||||
pub type ArmorSink = Arc<dyn Fn(ArmorReport) + Send + Sync>;
|
||||
|
||||
/// 已收到的最新血量。
|
||||
#[derive(Debug, Default)]
|
||||
struct Inner {
|
||||
/// `readings[team][index]`。
|
||||
readings: [[Option<Reading>; ARMOR_BOARDS]; TEAMS],
|
||||
/// 按板子 id 索引的发现状态。
|
||||
boards: HashMap<u32, ArmorBoard>,
|
||||
/// 已经打过"这块板子在线"的板子 id,避免刷屏。
|
||||
announced: Vec<u32>,
|
||||
}
|
||||
|
||||
/// 相邻两次读数之差还原出来的掉血事件。
|
||||
///
|
||||
/// 主板不报"被打了一下",只报"现在是 97"。所以命中反馈只能是派生的:
|
||||
///
|
||||
/// - 血量**减少** ⇒ 一次命中,伤害是两个读数之差。
|
||||
/// - 血量**增加** ⇒ 不是命中,是长按 BOOT 的重置。忽略,但值得记一条日志。
|
||||
/// - **第一次**见到一块板子不产生命中:没有基线,此时 `hp = 0` 只是"已经没血了",
|
||||
/// 不是"刚刚被打死"。
|
||||
///
|
||||
/// 已知的精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段
|
||||
/// 窗口里的两次打击会被合并成一次——对"闪一下红"的用途足够,对判定不够,判定看的是
|
||||
/// 界面上的绝对血量。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Hit {
|
||||
pub board: u32,
|
||||
pub team: u8,
|
||||
pub index: u8,
|
||||
pub from: u16,
|
||||
pub to: u16,
|
||||
pub damage: u16,
|
||||
}
|
||||
|
||||
/// 从连续的读数里派生命中。
|
||||
///
|
||||
/// 按**板子**而不是按 `(板子, index)` 记基线:主板一次广播四条、`hp` 相同,按四条各记
|
||||
/// 一份基线会让一次掉血被报成四次命中。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct HealthTracker {
|
||||
last: HashMap<u32, u16>,
|
||||
}
|
||||
|
||||
impl HealthTracker {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 喂进一条上报,返回派生的命中(如果有)。
|
||||
pub fn observe(&mut self, report: &ArmorReport) -> Option<Hit> {
|
||||
let previous = self.last.insert(report.board, report.hp);
|
||||
|
||||
let Some(from) = previous else {
|
||||
// 第一次见到这块板子,没有基线。
|
||||
return None;
|
||||
};
|
||||
|
||||
if report.hp >= from {
|
||||
// 持平(同一次广播的另外三条)或回升(重置)。
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Hit {
|
||||
board: report.board,
|
||||
team: report.team,
|
||||
index: report.index,
|
||||
from,
|
||||
to: report.hp,
|
||||
damage: from - report.hp,
|
||||
})
|
||||
}
|
||||
|
||||
/// 忘掉一块板子的基线(掉线重连后重新建)。
|
||||
pub fn forget(&mut self, board: u32) {
|
||||
self.last.remove(&board);
|
||||
}
|
||||
}
|
||||
|
||||
/// 监听装甲板广播的血量源。
|
||||
pub struct ArmorHealthStore {
|
||||
inner: Mutex<Inner>,
|
||||
sink: Mutex<Option<ArmorSink>>,
|
||||
}
|
||||
|
||||
impl Default for ArmorHealthStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ArmorHealthStore {
|
||||
/// 建一个 store,但先不绑端口。测试从这里喂数据报。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(Inner::default()),
|
||||
sink: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定 5601 并起一个接收线程。
|
||||
///
|
||||
/// 绑定失败(端口被占、权限问题)返回 `Err`,调用方退回 [`crate::NoArmorYet`] 即可
|
||||
/// ——血量显示不了不该让整个客户端起不来。
|
||||
pub fn start() -> std::io::Result<(Arc<Self>, UdpSocket)> {
|
||||
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, ARMOR_PORT))?;
|
||||
socket.set_read_timeout(Some(Duration::from_millis(200)))?;
|
||||
|
||||
log::info!("armor: listening for health broadcasts on UDP {ARMOR_PORT}");
|
||||
Ok((Arc::new(Self::new()), socket))
|
||||
}
|
||||
|
||||
/// 在单独的线程上跑接收循环。
|
||||
pub fn spawn(self: &Arc<Self>, socket: UdpSocket) {
|
||||
let store = Arc::clone(self);
|
||||
|
||||
// 线程名带上前缀,日志里一眼能看出是装甲板那条链路。
|
||||
let _ = thread::Builder::new()
|
||||
.name("armor-reader".into())
|
||||
.spawn(move || store.run(socket));
|
||||
}
|
||||
|
||||
/// 接收循环。这个线程活到进程结束,不需要退出条件。
|
||||
pub fn run(&self, socket: UdpSocket) {
|
||||
let mut buf = [0u8; 256];
|
||||
|
||||
loop {
|
||||
let (len, from) = match socket.recv_from(&mut buf) {
|
||||
Ok(pair) => pair,
|
||||
Err(err)
|
||||
if err.kind() == ErrorKind::WouldBlock
|
||||
|| err.kind() == ErrorKind::TimedOut =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("armor: receive failed: {err}");
|
||||
// 出错后别做没有间隔的忙等(比如网卡热插拔时的 ENOBUFS)。
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
self.datagram(&buf[..len], from);
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理一条上报。测试从这里喂字节。
|
||||
pub fn datagram(&self, bytes: &[u8], from: SocketAddr) {
|
||||
let SocketAddr::V4(addr) = from else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 同一个端口上可能有别的东西(图传板的心跳、别的协议),解不出来就当不是给
|
||||
// 我的,安静跳过。
|
||||
let Some((board, health)) = ArmorHealth::decode_from(bytes) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (team, index) = (health.team as usize, health.index as usize);
|
||||
|
||||
if team >= TEAMS || index >= ARMOR_BOARDS {
|
||||
log::warn!(
|
||||
"armor: ignoring out-of-range report team={} index={}",
|
||||
health.team,
|
||||
health.index
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let report = ArmorReport {
|
||||
board,
|
||||
addr,
|
||||
team: health.team,
|
||||
index: health.index,
|
||||
hp: health.hp,
|
||||
};
|
||||
|
||||
{
|
||||
let mut inner = self.inner.lock().expect("armor mutex poisoned");
|
||||
|
||||
// 第一次见到某块板子就报一声:现场判断"到底连上没有"最快的线索。
|
||||
if !inner.announced.contains(&board) {
|
||||
inner.announced.push(board);
|
||||
log::info!(
|
||||
"armor: board 0x{board:08X} is online (team {}, hp {}) via {addr}",
|
||||
health.team,
|
||||
health.hp
|
||||
);
|
||||
}
|
||||
|
||||
// 血量变化也要能看见:窗口是给别人看的,抓日志的人得知道掉血没有。整车一条
|
||||
// 血、四条同值,所以只对 index 0 报一次。
|
||||
if index == 0 {
|
||||
if let Some(previous) = inner.readings[team][0].map(|reading| reading.hp) {
|
||||
if previous != health.hp {
|
||||
log::info!(
|
||||
"armor: board 0x{board:08X} team {team} hp {previous} -> {}",
|
||||
health.hp
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner.readings[team][index] = Some(Reading {
|
||||
hp: health.hp,
|
||||
at: Instant::now(),
|
||||
});
|
||||
|
||||
let entry = inner.boards.entry(board).or_insert(ArmorBoard {
|
||||
id: board,
|
||||
addr,
|
||||
team: health.team,
|
||||
hp: health.hp,
|
||||
plates_seen: 0,
|
||||
last_seen: Instant::now(),
|
||||
});
|
||||
|
||||
// 队伍是编译期定的,但同队两块板子都自报同一个 team 是正常的;这里只在
|
||||
// 板子换了 team(重刷了固件)时跟着更新。
|
||||
entry.addr = addr;
|
||||
entry.team = health.team;
|
||||
entry.hp = health.hp;
|
||||
entry.plates_seen |= 1 << index;
|
||||
entry.last_seen = Instant::now();
|
||||
}
|
||||
|
||||
let sink = self.sink.lock().expect("armor sink mutex poisoned").clone();
|
||||
if let Some(sink) = sink {
|
||||
sink(report);
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入上报的消费者。
|
||||
pub fn set_sink(&self, sink: ArmorSink) {
|
||||
*self.sink.lock().expect("armor sink mutex poisoned") = Some(sink);
|
||||
}
|
||||
|
||||
/// 场上的装甲板主板,按 id 排序。
|
||||
///
|
||||
/// 包含已经掉线的(界面要能把"掉线"显示出来),是否在线由 [`ArmorBoard::is_online`]
|
||||
/// 判断。
|
||||
pub fn boards(&self) -> Vec<ArmorBoard> {
|
||||
let inner = self.inner.lock().expect("armor mutex poisoned");
|
||||
let mut list: Vec<ArmorBoard> = inner.boards.values().cloned().collect();
|
||||
|
||||
list.sort_by_key(|b| b.id);
|
||||
list
|
||||
}
|
||||
|
||||
/// 某一块板子的最新血量,太旧就返回 `None`。
|
||||
pub fn health_of_board(&self, board: u32) -> Option<u16> {
|
||||
let inner = self.inner.lock().expect("armor mutex poisoned");
|
||||
let entry = inner.boards.get(&board)?;
|
||||
|
||||
if entry.silence() > STALE_AFTER {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(entry.hp)
|
||||
}
|
||||
|
||||
/// 四个格子的当前血量;只要有一格不新鲜就返回 `None`。
|
||||
fn fresh_hp(&self, team: u8) -> Option<[u16; ARMOR_BOARDS]> {
|
||||
let team = usize::from(team);
|
||||
|
||||
if team >= TEAMS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let inner = self.inner.lock().expect("armor mutex poisoned");
|
||||
let mut out = [0u16; ARMOR_BOARDS];
|
||||
|
||||
for (index, reading) in inner.readings[team].iter().enumerate() {
|
||||
let reading = reading.as_ref()?;
|
||||
|
||||
if now.duration_since(reading.at) > STALE_AFTER {
|
||||
return None;
|
||||
}
|
||||
|
||||
out[index] = reading.hp;
|
||||
}
|
||||
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::receiver::ArmorSource for ArmorHealthStore {
|
||||
fn health(&self, team: u8) -> Option<[u16; ARMOR_BOARDS]> {
|
||||
self.fresh_hp(team)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::receiver::ArmorSource;
|
||||
|
||||
fn from(ip: [u8; 4]) -> SocketAddr {
|
||||
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::from(ip), 5601))
|
||||
}
|
||||
|
||||
/// 主板一次广播四条,`hp` 相同。
|
||||
fn tick(store: &ArmorHealthStore, board: u32, team: u8, hp: u16, ip: [u8; 4]) {
|
||||
for index in 0..ARMOR_BOARDS as u8 {
|
||||
let health = ArmorHealth { team, index, hp };
|
||||
store.datagram(&health.encode_from(board), from(ip));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_broadcast_yields_four_plates_of_health() {
|
||||
let store = ArmorHealthStore::new();
|
||||
tick(&store, 0xAAAA_0001, 0, 100, [192, 168, 1, 50]);
|
||||
|
||||
let health = store.health(0).expect("all four plates are fresh");
|
||||
assert_eq!(health, [100, 100, 100, 100]);
|
||||
assert_eq!(store.health(1), None, "the other team sent nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_missing_plate_falls_back_to_the_placeholder() {
|
||||
let store = ArmorHealthStore::new();
|
||||
|
||||
// 只来三条:界面宁可说"未接入",也不要显示一个可能过期的血量。
|
||||
for index in 0..3u8 {
|
||||
let health = ArmorHealth {
|
||||
team: 0,
|
||||
index,
|
||||
hp: 88,
|
||||
};
|
||||
store.datagram(&health.encode_from(0xAAAA_0001), from([192, 168, 1, 50]));
|
||||
}
|
||||
|
||||
assert_eq!(store.health(0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boards_are_listed_by_their_mac_derived_id() {
|
||||
let store = ArmorHealthStore::new();
|
||||
tick(&store, 0xBBBB_0002, 1, 100, [192, 168, 1, 60]);
|
||||
tick(&store, 0xAAAA_0001, 0, 100, [192, 168, 1, 50]);
|
||||
|
||||
let boards = store.boards();
|
||||
let ids: Vec<u32> = boards.iter().map(|b| b.id).collect();
|
||||
|
||||
assert_eq!(ids, vec![0xAAAA_0001, 0xBBBB_0002], "sorted, stable order");
|
||||
assert_eq!(boards[0].team, 0);
|
||||
assert_eq!(boards[1].team, 1);
|
||||
assert!(boards[0].reports_all_plates());
|
||||
assert!(boards[0].is_online());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_of_board_ignores_out_of_range_and_garbage() {
|
||||
let store = ArmorHealthStore::new();
|
||||
|
||||
// team/index 越界:丢掉并留一条警告,而不是写进 readings 把数组撑爆。
|
||||
let bad = ArmorHealth {
|
||||
team: 9,
|
||||
index: 0,
|
||||
hp: 100,
|
||||
};
|
||||
store.datagram(&bad.encode_from(1), from([192, 168, 1, 1]));
|
||||
assert!(store.boards().is_empty());
|
||||
|
||||
// 不是装甲板上报的字节:安静跳过。
|
||||
store.datagram(&[0u8; 64], from([192, 168, 1, 1]));
|
||||
assert!(store.boards().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sink_sees_every_report_with_its_board_id() {
|
||||
let store = ArmorHealthStore::new();
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let tap = Arc::clone(&seen);
|
||||
store.set_sink(Arc::new(move |report: ArmorReport| {
|
||||
tap.lock().unwrap().push((report.board, report.index, report.hp));
|
||||
}));
|
||||
|
||||
tick(&store, 7, 0, 100, [192, 168, 1, 50]);
|
||||
|
||||
assert_eq!(seen.lock().unwrap().len(), ARMOR_BOARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_falling_hp_is_exactly_one_hit() {
|
||||
let mut tracker = HealthTracker::new();
|
||||
|
||||
let report = |hp: u16, index: u8| ArmorReport {
|
||||
board: 1,
|
||||
addr: "192.168.1.50:5601".parse().unwrap(),
|
||||
team: 0,
|
||||
index,
|
||||
hp,
|
||||
};
|
||||
|
||||
// 第一次上报只是建立基线,不是"刚刚被打死"。
|
||||
for index in 0..4u8 {
|
||||
assert_eq!(tracker.observe(&report(100, index)), None);
|
||||
}
|
||||
|
||||
// 掉血:四条同值的上报里只有第一条该产生命中。
|
||||
let hits: Vec<Option<Hit>> = (0..4u8).map(|i| tracker.observe(&report(97, i))).collect();
|
||||
|
||||
assert_eq!(
|
||||
hits[0],
|
||||
Some(Hit {
|
||||
board: 1,
|
||||
team: 0,
|
||||
index: 0,
|
||||
from: 100,
|
||||
to: 97,
|
||||
damage: 3,
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
hits[1..].iter().all(Option::is_none),
|
||||
"the other three datagrams carry the same hp and must not be hits"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reset_is_not_a_hit() {
|
||||
let mut tracker = HealthTracker::new();
|
||||
let addr = "192.168.1.50:5601".parse().unwrap();
|
||||
let report = |hp: u16| ArmorReport {
|
||||
board: 1,
|
||||
addr,
|
||||
team: 0,
|
||||
index: 0,
|
||||
hp,
|
||||
};
|
||||
|
||||
tracker.observe(&report(100));
|
||||
assert!(tracker.observe(&report(0)).is_some(), "death is a hit");
|
||||
assert_eq!(
|
||||
tracker.observe(&report(100)),
|
||||
None,
|
||||
"长按 BOOT 重置成满血不是一次命中"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_board_first_seen_at_zero_hp_produces_no_hit() {
|
||||
let mut tracker = HealthTracker::new();
|
||||
let report = ArmorReport {
|
||||
board: 1,
|
||||
addr: "192.168.1.50:5601".parse().unwrap(),
|
||||
team: 0,
|
||||
index: 0,
|
||||
hp: 0,
|
||||
};
|
||||
|
||||
assert_eq!(tracker.observe(&report), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_boards_keep_separate_baselines() {
|
||||
let mut tracker = HealthTracker::new();
|
||||
let addr = "192.168.1.50:5601".parse().unwrap();
|
||||
let report = |board: u32, hp: u16| ArmorReport {
|
||||
board,
|
||||
addr,
|
||||
team: 0,
|
||||
index: 0,
|
||||
hp,
|
||||
};
|
||||
|
||||
tracker.observe(&report(1, 100));
|
||||
tracker.observe(&report(2, 100));
|
||||
|
||||
let hit = tracker.observe(&report(2, 90)).expect("board 2 was hit");
|
||||
assert_eq!(hit.board, 2);
|
||||
assert_eq!(hit.damage, 10);
|
||||
|
||||
assert_eq!(
|
||||
tracker.observe(&report(1, 100)),
|
||||
None,
|
||||
"board 1 is untouched, not a 10-point heal"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
//! cam-core —— 桌面端的非界面部分。
|
||||
//!
|
||||
//! 这一层不认识 Tauri,也不认识任何界面:它只负责
|
||||
//!
|
||||
//! - [`receiver`]:收 UDP、发现摄像头、按发送方重组 JPEG 帧;
|
||||
//! - [`armor`]:收装甲板主板广播的血量,并列出场上有哪几块板子;
|
||||
//! - [`stats`]:帧率统计(按帧数除以墙钟,不是对帧间隔取平均);
|
||||
//! - [`config`]:磁盘上的配置(身份、绑定、HUD、密码哈希);
|
||||
//! - [`password`]:密码的哈希与校验。
|
||||
//!
|
||||
//! # 为什么单独一层
|
||||
//!
|
||||
//! 旧的 Slint 客户端把这几件事和界面耦合在一个 crate 里,于是**它们只能通过跑起
|
||||
//! 整个界面来验证**。抽出来之后,重组、发现、帧率窗口、血量新鲜度都能用单元测试
|
||||
//! 直接盯住——而它们恰好是最容易错、错了又最难在现场看出来的部分。
|
||||
//!
|
||||
//! # 界面怎么拿数据
|
||||
//!
|
||||
//! 两条路,都是**推**而不是轮询:
|
||||
//!
|
||||
//! - 视频帧走 [`receiver::Receiver::set_frame_sink`]:接收线程重组好一张 JPEG 就
|
||||
//! 交给 sink,由上层转成 WebSocket 的二进制消息。sink 必须是非阻塞的。
|
||||
//! - 血量走 [`armor::ArmorHealthStore::set_sink`]:每收到一条上报就交给 sink。
|
||||
//!
|
||||
//! 同时两边的**最新值**都留在内存里([`receiver::LatestFrames`] 与
|
||||
//! [`armor::ArmorHealthStore::boards`]),给"截图"和"设置界面里的下拉列表"用——
|
||||
//! 那两处要的是当前状态,不是每一帧。
|
||||
|
||||
pub mod armor;
|
||||
pub mod config;
|
||||
pub mod password;
|
||||
pub mod receiver;
|
||||
pub mod slots;
|
||||
pub mod snapshot;
|
||||
pub mod stats;
|
||||
|
||||
pub use config::{Config, HudConfig, Role};
|
||||
pub use receiver::{
|
||||
ArmorSource, CameraInfo, FrameSink, LatestFrames, NoArmorYet, Receiver, SourceStats,
|
||||
ARMOR_BOARDS,
|
||||
};
|
||||
pub use slots::SlotAllocator;
|
||||
pub use stats::FpsWindow;
|
||||
@@ -0,0 +1,148 @@
|
||||
//! 密码:哈希、校验、强度下限。
|
||||
//!
|
||||
//! # 这把锁挡的是谁
|
||||
//!
|
||||
//! 必须说清楚,否则会被当成一把它并不是的锁:
|
||||
//!
|
||||
//! - **挡得住**:现场随手改绑定。选手想把自己的画面换到对手那块板子上,得先知道密码;
|
||||
//! 围观的人翻不到明文密码(只有 Argon2id 哈希),也不能靠改配置文件把绑定改掉——
|
||||
//! 改了之后界面会要求先解锁。
|
||||
//! - **挡不住**:一个愿意在自己机器上重编这个客户端的人。配置文件与二进制都在他手上,
|
||||
//! 删掉哈希、或者直接改源码都行。真正封住这件事要靠固件侧的配对 token(见根 README
|
||||
//! 里"这挡不住什么"那一节)。
|
||||
//!
|
||||
//! 所以它的定位是**防误触与防随手改**,不是密码学意义上的对抗。这也是为什么强度下限
|
||||
//! 只要 4 位:一个 8 位强密码在这里换不来任何实际的安全增益,却会让现场每次改绑都要
|
||||
//! 掏手机查一遍。
|
||||
//!
|
||||
//! # 为什么是 Argon2 而不是 SHA-256
|
||||
//!
|
||||
//! 密码短(4 位数字只有 1 万种),任何**快**哈希都能在一秒内被穷举完。Argon2id 是
|
||||
//! 内存硬的,把每次尝试的代价抬到几十毫秒——即使密码很短,"改配置文件暴力试密码"
|
||||
//! 这条路也变得不划算。存的是标准的 PHC 字符串(`$argon2id$v=19$m=...`),参数跟着
|
||||
//! 哈希一起躺在配置里,所以以后调参数不需要做迁移。
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use argon2::Argon2;
|
||||
use rand::RngCore;
|
||||
|
||||
/// 密码的最短长度。
|
||||
///
|
||||
/// 见模块文档:这不是安全边界,长度下限只用来挡住"密码是 1 个字符"这种显然是随手打的
|
||||
/// 情况。
|
||||
pub const MIN_PASSWORD_LEN: usize = 4;
|
||||
|
||||
/// 算一个密码的 PHC 字符串。
|
||||
///
|
||||
/// 每次调用都用新的随机盐,所以同一个密码两次得到的结果不同——这样哈希不能当查找表用。
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
check_strength(password)?;
|
||||
|
||||
let mut salt = [0u8; 16];
|
||||
rand::rng().fill_bytes(&mut salt);
|
||||
|
||||
let salt = SaltString::encode_b64(&salt)
|
||||
.map_err(|err| anyhow!("could not encode the salt: {err}"))?;
|
||||
|
||||
// Argon2::default() 就是 Argon2id、v19、Params::DEFAULT(19 MiB,t=2)。
|
||||
let hash = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|err| anyhow!("could not hash the password: {err}"))?;
|
||||
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// 校验一个密码。
|
||||
///
|
||||
/// 返回 `Ok(false)` 表示"密码不对",返回 `Err` 表示"存下来的哈希本身坏了"——这两种
|
||||
/// 必须区分开:前者是用户输错了,后者是配置文件被改坏了,界面上要给完全不同的提示。
|
||||
pub fn verify_password(password: &str, phc: &str) -> Result<bool> {
|
||||
let parsed = PasswordHash::new(phc)
|
||||
.map_err(|err| anyhow!("the stored password hash is not a valid PHC string: {err}"))?;
|
||||
|
||||
// `password-hash` 的解析器很宽松:`$argon2id$broken` 这种**截断**的字符串也能解析
|
||||
// 成功,只是解析出来的对象里没有盐、没有摘要。于是所有校验都失败,被 `is_ok()`
|
||||
// 读成"密码错了"——现场表现是"密码明明是对的,却一直说密码错",然后所有人开始
|
||||
// 怀疑键盘。所以这里显式要求盐与摘要都在。
|
||||
if parsed.salt.is_none() || parsed.hash.is_none() {
|
||||
return Err(anyhow!(
|
||||
"the stored password hash is incomplete (no salt or no digest): {phc:?}"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
/// 检查长度下限。
|
||||
pub fn check_strength(password: &str) -> Result<()> {
|
||||
if password.chars().count() < MIN_PASSWORD_LEN {
|
||||
return Err(anyhow!(
|
||||
"密码至少 {MIN_PASSWORD_LEN} 位(现在是 {} 位)",
|
||||
password.chars().count()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_password_verifies_against_its_own_hash() {
|
||||
let phc = hash_password("1234").expect("hashes");
|
||||
assert!(verify_password("1234", &phc).expect("verifies"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_password_is_false_not_an_error() {
|
||||
let phc = hash_password("1234").expect("hashes");
|
||||
|
||||
assert!(!verify_password("1235", &phc).expect("the hash itself is fine"));
|
||||
assert!(!verify_password("", &phc).expect("the hash itself is fine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_password_hashes_differently_every_time() {
|
||||
// 随机盐:否则配置文件里的哈希就是一个可以拿去查表的指纹。
|
||||
let a = hash_password("1234").expect("hashes");
|
||||
let b = hash_password("1234").expect("hashes");
|
||||
|
||||
assert_ne!(a, b, "a fresh salt each time");
|
||||
assert!(verify_password("1234", &a).unwrap());
|
||||
assert!(verify_password("1234", &b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hash_is_argon2id_and_carries_its_parameters() {
|
||||
// 参数写在 PHC 字符串里,所以以后调参数不需要迁移旧配置。
|
||||
let phc = hash_password("1234").expect("hashes");
|
||||
assert!(phc.starts_with("$argon2id$"), "got {phc}");
|
||||
assert!(phc.contains("$v=19$"), "got {phc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_stored_hash_is_an_error_not_a_wrong_password() {
|
||||
// 这两种情况必须能分开:一个是输错了,一个是配置文件坏了。
|
||||
assert!(verify_password("1234", "not a phc string").is_err());
|
||||
assert!(verify_password("1234", "$argon2id$broken").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_too_short_password_is_refused_at_set_time() {
|
||||
assert!(hash_password("123").is_err());
|
||||
assert!(hash_password("").is_err());
|
||||
assert!(hash_password("1234").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_ascii_password_works() {
|
||||
let phc = hash_password("口令甲乙").expect("hashes");
|
||||
assert!(verify_password("口令甲乙", &phc).unwrap());
|
||||
assert!(!verify_password("口令甲丙", &phc).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
//! UDP 接收、发现、多路帧重组。
|
||||
//!
|
||||
//! # 多路
|
||||
//!
|
||||
//! 裁判终端同时收两块图传板,队伍终端也会收到两块(只是界面上只显示绑定的那一块)。
|
||||
//! 所以重组状态**必须按发送方分开**——把两块板的帧塞进同一个重组器,会让它们各自的
|
||||
//! `frame_id` 互相顶掉,两路都出不来。
|
||||
//!
|
||||
//! # 发现与订阅
|
||||
//!
|
||||
//! 两个方向各司其职:
|
||||
//!
|
||||
//! - 板子每 2 秒广播一次 [`protocol::MsgType::Hello`],客户端被动收到就知道有谁在线。
|
||||
//! - 客户端广播 [`protocol::MsgType::Subscribe`],板子收到后开始定向推流。
|
||||
//!
|
||||
//! 板子靠**源地址**记住客户端,所以客户端只要周期性续订就能维持推流,不存在任何
|
||||
//! 超时淘汰的竞态。
|
||||
//!
|
||||
//! # 丢帧策略
|
||||
//!
|
||||
//! 缺了分片的帧整个丢弃、下一帧顶上,不重传也不尝试补洞。对实时画面这是对的:等
|
||||
//! 重传补齐时,那一帧早就不新鲜了。代价是丢帧率必须摆在界面上,否则"画面卡"没法
|
||||
//! 区分是链路问题还是传感器问题。
|
||||
//!
|
||||
//! # 两道闸门,不是一个
|
||||
//!
|
||||
//! 旧客户端只有一道 `only_sender`:队伍端绑定了之后,连**发现**别人的板子都做不到,
|
||||
//! 于是"改绑"只能靠手改配置文件或删文件。
|
||||
//!
|
||||
//! 现在绑定可以在界面上改(需密码),所以闸门分成两道:
|
||||
//!
|
||||
//! | 闸门 | 管什么 | 什么时候开 |
|
||||
//! |---|---|---|
|
||||
//! | `only_sender` | 帧的重组与投递 | 队伍端始终是绑定的那一块 |
|
||||
//! | `discovery_all` | 是否记录**其它**板子的 Hello | 只在密码解锁后的设置界面里打开 |
|
||||
//!
|
||||
//! 这是有意的加强而不是放松:设置界面需要列出候选摄像头(否则无从选起),但**画面
|
||||
//! 不需要**——对手的板子即使把流推到本机端口、即使被记进发现列表,它的帧仍然进不了
|
||||
//! 重组器。旧版把两件事绑在一道闸门上,只能在"能列候选"和"挡住对手"之间二选一。
|
||||
//!
|
||||
//! # 可测性
|
||||
//!
|
||||
//! 数据报的处理([`Receiver::datagram`])与 socket 完全分开:测试直接喂字节进去,
|
||||
//! 不需要绑 5600 端口,也就不需要真实网络。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::ErrorKind;
|
||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use protocol::{
|
||||
Fragment, FrameAssembler, Subscribe, SubscribeRole, DEFAULT_PORT, HEADER_LEN,
|
||||
};
|
||||
|
||||
/// 接受的最大数据报。高于协议的理论上限(1427),留出余量,这样一个被截断的读只可能
|
||||
/// 来自网络,而不是来自过小的缓冲。
|
||||
const RECV_BUFFER: usize = 2048;
|
||||
|
||||
/// 重新广播订阅的间隔。
|
||||
///
|
||||
/// 比板子判定"客户端掉线"的超时(6 秒)短得多,所以偶发丢一两个包不会中断推流。
|
||||
const SUBSCRIBE_INTERVAL: Duration = Duration::from_millis(800);
|
||||
|
||||
/// 一张帧没收齐就放弃的时限。
|
||||
///
|
||||
/// 一帧在 22fps 下约 45ms 到,所以几百毫秒还没齐的永远不会齐了。
|
||||
const FRAME_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
/// 一块已知的图传板。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CameraInfo {
|
||||
pub id: u32,
|
||||
pub addr: SocketAddrV4,
|
||||
pub firmware: u16,
|
||||
/// 最近一次收到它广播的时刻。
|
||||
pub last_seen: Instant,
|
||||
}
|
||||
|
||||
impl CameraInfo {
|
||||
/// 多久没有广播了。
|
||||
pub fn silence(&self) -> Duration {
|
||||
self.last_seen.elapsed()
|
||||
}
|
||||
|
||||
/// 板子在线吗?
|
||||
///
|
||||
/// 阈值是广播间隔的数倍,所以偶发丢包不会让界面上的在线状态闪烁。
|
||||
pub fn is_online(&self) -> bool {
|
||||
self.silence() < Duration::from_secs(7)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一块板的运行时统计。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SourceStats {
|
||||
pub frames_shown: u64,
|
||||
/// 因分片缺失被放弃的帧。
|
||||
pub frames_dropped: u64,
|
||||
pub datagrams: u64,
|
||||
pub last_frame_len: usize,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
/// 上一帧到这一帧之间的间隔。
|
||||
pub last_interval: Option<Duration>,
|
||||
last_shown_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl SourceStats {
|
||||
/// 丢弃的帧占已收到帧的比例。
|
||||
pub fn loss_percent(&self) -> f32 {
|
||||
let total = self.frames_shown + self.frames_dropped;
|
||||
if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.frames_dropped as f32 * 100.0 / total as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// 上一帧到这一帧的间隔,毫秒。
|
||||
pub fn gap_ms(&self) -> Option<f32> {
|
||||
self.last_interval.map(|d| d.as_secs_f32() * 1000.0)
|
||||
}
|
||||
|
||||
fn on_frame(&mut self, width: u16, height: u16, len: usize) {
|
||||
self.last_interval = self.last_shown_at.map(|t| t.elapsed());
|
||||
self.last_shown_at = Some(Instant::now());
|
||||
self.frames_shown += 1;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
self.last_frame_len = len;
|
||||
}
|
||||
}
|
||||
|
||||
/// 每一路的**最新**一帧。
|
||||
///
|
||||
/// 每路只保留最新的一张:这张图有两个用途,都不需要历史——截图(存盘的那一张应该
|
||||
/// 是按下按钮时看到的画面)和慢速客户端的补帧。真正的实时画面走
|
||||
/// [`Receiver::set_frame_sink`] 的推送路径,不从这里取。
|
||||
///
|
||||
/// 用 `Arc` 而不是 `Vec<u8>`:同一张帧要同时给 sink 和这个缓存,克隆一个 22 kB 的
|
||||
/// 缓冲区是每秒 22 次不必要的分配。
|
||||
#[derive(Default)]
|
||||
pub struct LatestFrames {
|
||||
inner: Mutex<HashMap<u32, Arc<protocol::Frame>>>,
|
||||
}
|
||||
|
||||
impl LatestFrames {
|
||||
pub fn put(&self, frame: Arc<protocol::Frame>) {
|
||||
self.inner
|
||||
.lock()
|
||||
.expect("latest-frames mutex poisoned")
|
||||
.insert(frame.sender_id, frame);
|
||||
}
|
||||
|
||||
/// 取某一路的最新帧(克隆的是 `Arc`,不是 JPEG)。
|
||||
pub fn latest(&self, sender_id: u32) -> Option<Arc<protocol::Frame>> {
|
||||
self.inner
|
||||
.lock()
|
||||
.expect("latest-frames mutex poisoned")
|
||||
.get(&sender_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// 所有已知来源的最新帧,按 id 排序,让界面上的顺序稳定。
|
||||
pub fn latest_all(&self) -> Vec<Arc<protocol::Frame>> {
|
||||
let mut list: Vec<Arc<protocol::Frame>> = self
|
||||
.inner
|
||||
.lock()
|
||||
.expect("latest-frames mutex poisoned")
|
||||
.values()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
list.sort_by_key(|f| f.sender_id);
|
||||
list
|
||||
}
|
||||
}
|
||||
|
||||
/// 帧的消费者。
|
||||
///
|
||||
/// 在接收线程里被调用,所以**必须是非阻塞的**:一次 `send` 到一个有界广播通道、
|
||||
/// 一次 `try_send`,都可以;写文件、等锁、发网络请求都不行——那会把接收循环拖慢,
|
||||
/// 表现为链路上本不存在的丢帧。
|
||||
pub type FrameSink = Arc<dyn Fn(Arc<protocol::Frame>) + Send + Sync>;
|
||||
|
||||
/// 所有板子的共享状态,跨线程只读。
|
||||
pub struct Discovery {
|
||||
cameras: Mutex<HashMap<u32, CameraInfo>>,
|
||||
stats: Mutex<HashMap<u32, SourceStats>>,
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
cameras: Mutex::new(HashMap::new()),
|
||||
stats: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前已知的板子,按 id 排序。
|
||||
pub fn cameras(&self) -> Vec<CameraInfo> {
|
||||
let mut list: Vec<CameraInfo> = self
|
||||
.cameras
|
||||
.lock()
|
||||
.expect("cameras mutex poisoned")
|
||||
.values()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
list.sort_by_key(|c| c.id);
|
||||
list
|
||||
}
|
||||
|
||||
/// 在线且刚刚广播过的板子。
|
||||
pub fn online_cameras(&self) -> Vec<CameraInfo> {
|
||||
self.cameras()
|
||||
.into_iter()
|
||||
.filter(CameraInfo::is_online)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 某一路的统计。
|
||||
pub fn stats(&self, sender_id: u32) -> SourceStats {
|
||||
self.stats
|
||||
.lock()
|
||||
.expect("stats mutex poisoned")
|
||||
.get(&sender_id)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 只刷新存活时间戳,不改地址或固件版本。
|
||||
///
|
||||
/// 收到视频帧时调用:正在推流本身就是最可靠的存活证据。
|
||||
fn touch(&self, id: u32) {
|
||||
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
|
||||
|
||||
if let Some(info) = cameras.get_mut(&id) {
|
||||
info.last_seen = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn note_hello(&self, id: u32, addr: SocketAddrV4, firmware: u16) -> bool {
|
||||
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
|
||||
|
||||
match cameras.get_mut(&id) {
|
||||
Some(info) => {
|
||||
info.addr = addr;
|
||||
info.last_seen = Instant::now();
|
||||
info.firmware = firmware;
|
||||
false
|
||||
}
|
||||
None => {
|
||||
cameras.insert(
|
||||
id,
|
||||
CameraInfo {
|
||||
id,
|
||||
addr,
|
||||
firmware,
|
||||
last_seen: Instant::now(),
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn note_frame(&self, id: u32, width: u16, height: u16, len: usize) {
|
||||
let mut stats = self.stats.lock().expect("stats mutex poisoned");
|
||||
stats.entry(id).or_default().on_frame(width, height, len);
|
||||
}
|
||||
|
||||
fn count(&self, id: u32, f: impl FnOnce(&mut SourceStats)) {
|
||||
let mut stats = self.stats.lock().expect("stats mutex poisoned");
|
||||
f(stats.entry(id).or_default());
|
||||
}
|
||||
|
||||
/// 超过 `max_idle` 没有广播的板子从列表里移除。
|
||||
///
|
||||
/// 界面上不会留下一堆永远离线的板子。
|
||||
pub fn forget_silent_cameras(&self, max_idle: Duration) {
|
||||
self.cameras
|
||||
.lock()
|
||||
.expect("cameras mutex poisoned")
|
||||
.retain(|_, c| c.silence() < max_idle);
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端身份。
|
||||
///
|
||||
/// 常量而非 MAC 派生:队伍客户端和裁判终端各自唯一,进程内也不需要区分自己,而一个
|
||||
/// 确定的常量让日志更好读。
|
||||
pub const CLIENT_ID: u32 = 0x0C11_0001;
|
||||
|
||||
/// 接收循环的可变状态:只由接收线程在跑循环时访问。
|
||||
struct Ingest {
|
||||
/// 每个发送方一个重组器。
|
||||
assemblers: HashMap<u32, FrameAssembler>,
|
||||
/// 只处理这一块板子的**帧**,`None` 表示全都要(裁判端)。
|
||||
only_sender: Option<u32>,
|
||||
/// 是否记录**其它**板子的 Hello。
|
||||
discovery_all: bool,
|
||||
}
|
||||
|
||||
/// 接收器。
|
||||
///
|
||||
/// 构造([`Receiver::new`])不碰网络,`run` 才需要 socket。这样数据报处理的逻辑可以
|
||||
/// 在没有网络的情况下被单元测试直接喂字节。
|
||||
pub struct Receiver {
|
||||
discovery: Arc<Discovery>,
|
||||
latest: Arc<LatestFrames>,
|
||||
/// 帧的非阻塞消费者,由上层注入。
|
||||
sink: Mutex<Option<FrameSink>>,
|
||||
ingest: Mutex<Ingest>,
|
||||
/// 身份可以运行时切换(同一个进程里从裁判端切到用户端),所以订阅里声明的角色
|
||||
/// 也得能改——它决定板子要不要给这个客户端推流。
|
||||
role: Mutex<SubscribeRole>,
|
||||
camera: Ipv4Addr,
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Receiver {
|
||||
/// 建一个接收器,但先不绑端口。
|
||||
///
|
||||
/// `only_sender` 为 `Some` 时只接受那一块板子的帧。
|
||||
pub fn new(camera: Ipv4Addr, role: SubscribeRole, only_sender: Option<u32>) -> Self {
|
||||
Self {
|
||||
discovery: Arc::new(Discovery::new()),
|
||||
latest: Arc::new(LatestFrames::default()),
|
||||
sink: Mutex::new(None),
|
||||
ingest: Mutex::new(Ingest {
|
||||
assemblers: HashMap::new(),
|
||||
only_sender,
|
||||
discovery_all: false,
|
||||
}),
|
||||
role: Mutex::new(role),
|
||||
camera,
|
||||
stop: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定视频端口并准备好发现。
|
||||
///
|
||||
/// 绑定固定端口(而非临时端口)是必须的:板子是从订阅的**源端口**学到推流目标的。
|
||||
/// 绑定在这里而不是在接收线程里,这样"端口被占用"会立刻报错,而不是被线程吞掉、
|
||||
/// 表现为一个永远不出画面的空窗口。
|
||||
pub fn start(
|
||||
camera: Ipv4Addr,
|
||||
role: SubscribeRole,
|
||||
only_sender: Option<u32>,
|
||||
) -> std::io::Result<(Self, UdpSocket)> {
|
||||
let socket =
|
||||
UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?;
|
||||
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
|
||||
|
||||
let broadcast = socket.set_broadcast(true).is_ok();
|
||||
if !broadcast {
|
||||
log::warn!("could not enable broadcast on the socket");
|
||||
}
|
||||
|
||||
Ok((Self::new(camera, role, only_sender), socket))
|
||||
}
|
||||
|
||||
/// 绑定之后切换限制目标,或解除限制。
|
||||
///
|
||||
/// 队伍端在设置界面里改绑一块板子时调用。
|
||||
pub fn restrict_to(&self, only_sender: Option<u32>) {
|
||||
self.ingest
|
||||
.lock()
|
||||
.expect("ingest mutex poisoned")
|
||||
.only_sender = only_sender;
|
||||
}
|
||||
|
||||
/// 当前限制到哪一块板子。
|
||||
pub fn sender_limit(&self) -> Option<u32> {
|
||||
self.ingest.lock().expect("ingest mutex poisoned").only_sender
|
||||
}
|
||||
|
||||
/// 切换身份,下一次续订就生效(800 ms 内)。
|
||||
pub fn set_role(&self, role: SubscribeRole) {
|
||||
*self.role.lock().expect("role mutex poisoned") = role;
|
||||
}
|
||||
|
||||
/// 当前在订阅里声明的身份。
|
||||
pub fn role(&self) -> SubscribeRole {
|
||||
*self.role.lock().expect("role mutex poisoned")
|
||||
}
|
||||
|
||||
/// 是否允许发现列表里出现**其它**板子。
|
||||
///
|
||||
/// 只在密码解锁后的设置界面里打开,见本模块的文档。
|
||||
pub fn set_discovery_all(&self, all: bool) {
|
||||
self.ingest
|
||||
.lock()
|
||||
.expect("ingest mutex poisoned")
|
||||
.discovery_all = all;
|
||||
}
|
||||
|
||||
/// 注入帧的消费者。重复调用会替换掉旧的。
|
||||
pub fn set_frame_sink(&self, sink: FrameSink) {
|
||||
*self.sink.lock().expect("sink mutex poisoned") = Some(sink);
|
||||
}
|
||||
|
||||
pub fn discovery(&self) -> Arc<Discovery> {
|
||||
Arc::clone(&self.discovery)
|
||||
}
|
||||
|
||||
pub fn latest_frames(&self) -> Arc<LatestFrames> {
|
||||
Arc::clone(&self.latest)
|
||||
}
|
||||
|
||||
/// 处理一个数据报。测试从这里喂字节。
|
||||
pub fn datagram(&self, bytes: &[u8], from: SocketAddr) {
|
||||
if bytes.len() < HEADER_LEN {
|
||||
return;
|
||||
}
|
||||
|
||||
let (limit, discovery_all) = {
|
||||
let ingest = self.ingest.lock().expect("ingest mutex poisoned");
|
||||
(ingest.only_sender, ingest.discovery_all)
|
||||
};
|
||||
|
||||
// 板子的在线通告。注意这里不看来源端口:广播来的源端口没有意义,身份由头里
|
||||
// 的 sender_id 决定。
|
||||
if let Some(hello) = protocol::Hello::decode(bytes) {
|
||||
let SocketAddr::V4(v4) = from else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 限制生效且没开发现:别人的板子连记都不记。不记的理由不只是"不显示"——
|
||||
// 记进 discovery 之后,界面上任何一处遍历 cameras() 的地方都会间接泄露
|
||||
// 场上还有谁。
|
||||
if let Some(only) = limit {
|
||||
if !discovery_all && hello.sender_id != only {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if self.discovery.note_hello(hello.sender_id, v4, hello.firmware) {
|
||||
log::info!("found camera 0x{:08X} at {v4}", hello.sender_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(frag) = Fragment::decode(bytes) else {
|
||||
// 既不是 Hello 也不是合法分片:将来的其它消息,或一次损坏。
|
||||
return;
|
||||
};
|
||||
|
||||
let id = frag.header.sender_id;
|
||||
|
||||
// 帧的重组与投递过的是**另一道**闸门:发现列表里能看到对手的板子,不等于
|
||||
// 它的画面会进来。
|
||||
if let Some(only) = limit {
|
||||
if id != only {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 视频帧也算存活证据。
|
||||
//
|
||||
// `is_online` 看的是 `last_seen`,而它此前只由 Hello 更新。于是会出现一个
|
||||
// 荒唐的状态:画面在每秒刷 22 帧,状态栏却显示"离线"(橙色)。正在推流的板子
|
||||
// 比广播更可靠地证明自己活着。
|
||||
self.discovery.touch(id);
|
||||
|
||||
let mut ingest = self.ingest.lock().expect("ingest mutex poisoned");
|
||||
let assembler = ingest
|
||||
.assemblers
|
||||
.entry(id)
|
||||
.or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT));
|
||||
|
||||
match assembler.push(frag) {
|
||||
Ok(frame) => {
|
||||
drop(ingest);
|
||||
|
||||
self.discovery.count(id, |s| s.datagrams += 1);
|
||||
self.discovery
|
||||
.note_frame(id, frame.width, frame.height, frame.data.len());
|
||||
|
||||
let frame = Arc::new(frame);
|
||||
self.latest.put(Arc::clone(&frame));
|
||||
|
||||
let sink = self.sink.lock().expect("sink mutex poisoned").clone();
|
||||
if let Some(sink) = sink {
|
||||
sink(frame);
|
||||
}
|
||||
}
|
||||
Err(protocol::DropReason::Superseded) => {
|
||||
// 被更新的帧追上了。这不是丢帧:半截的那张只是被超越,在健康链路上
|
||||
// 这是帧被放弃的**正常**方式。
|
||||
self.discovery.count(id, |s| s.datagrams += 1);
|
||||
}
|
||||
Err(protocol::DropReason::TimedOut) => {
|
||||
self.discovery.count(id, |s| {
|
||||
s.datagrams += 1;
|
||||
s.frames_dropped += 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 放弃等太久的半截帧。接收循环周期性调用。
|
||||
pub fn expire_stalled_frames(&self) {
|
||||
let mut ingest = self.ingest.lock().expect("ingest mutex poisoned");
|
||||
let mut expired = Vec::new();
|
||||
|
||||
for (id, assembler) in ingest.assemblers.iter_mut() {
|
||||
if assembler.expire().is_some() {
|
||||
expired.push(*id);
|
||||
}
|
||||
}
|
||||
|
||||
drop(ingest);
|
||||
|
||||
for id in expired {
|
||||
self.discovery.count(id, |s| s.frames_dropped += 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行接收循环,直到 [`Receiver::shutdown`]。
|
||||
///
|
||||
/// 阻塞,所以放在单独的线程上。
|
||||
pub fn run(&self, socket: UdpSocket) {
|
||||
let target = SocketAddrV4::new(self.camera, DEFAULT_PORT);
|
||||
let mut buf = [0u8; RECV_BUFFER];
|
||||
let mut last_subscribe = Instant::now() - SUBSCRIBE_INTERVAL;
|
||||
let mut last_sweep = Instant::now();
|
||||
|
||||
log::info!(
|
||||
"listening on UDP {DEFAULT_PORT}, announcing {:?} to {target}",
|
||||
self.role()
|
||||
);
|
||||
|
||||
while !self.stop.load(Ordering::Relaxed) {
|
||||
// 周期性续订就是保活:板子在 6 秒收不到就认为我们走了。
|
||||
//
|
||||
// 每次现编订阅包而不是循环外编一次:身份可以运行时切换,编一次的话切换后
|
||||
// 板子收到的还是旧角色。800 ms 一次,这点分配无所谓。
|
||||
if last_subscribe.elapsed() >= SUBSCRIBE_INTERVAL {
|
||||
let subscribe = Subscribe {
|
||||
role: self.role(),
|
||||
client_id: CLIENT_ID,
|
||||
};
|
||||
|
||||
if let Err(err) = socket.send_to(&subscribe.encode(), target) {
|
||||
log::warn!("could not subscribe at {target}: {err}");
|
||||
}
|
||||
last_subscribe = Instant::now();
|
||||
}
|
||||
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((n, from)) => self.datagram(&buf[..n], from),
|
||||
Err(err)
|
||||
if err.kind() == ErrorKind::WouldBlock
|
||||
|| err.kind() == ErrorKind::TimedOut =>
|
||||
{
|
||||
// 帧之间的常态。读超时正是让退出能及时响应的原因。
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("recv failed: {err}");
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
self.expire_stalled_frames();
|
||||
|
||||
if last_sweep.elapsed() >= Duration::from_secs(5) {
|
||||
self.discovery
|
||||
.forget_silent_cameras(Duration::from_secs(60));
|
||||
last_sweep = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("receive loop stopped");
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// 装甲板血量的来源。
|
||||
///
|
||||
/// 真实实现是 [`crate::armor::ArmorHealthStore`]。这里只定接口,解码与新鲜度判断都在
|
||||
/// 那个模块里——也让"没有数据源"这件事有一个明确的替身([`NoArmorYet`]),而不是
|
||||
/// 让界面去处理 `Option<Option<...>>`。
|
||||
pub trait ArmorSource: Send + Sync {
|
||||
/// 某一边的血量。
|
||||
///
|
||||
/// 返回 4 个装甲板的当前血量,按 index 排列。没有数据时返回 `None`,界面据此显示
|
||||
/// 占位符而不是 0——0 是一个看起来像真值的假值。
|
||||
fn health(&self, team: u8) -> Option<[u16; 4]>;
|
||||
}
|
||||
|
||||
/// 一边的装甲板数量。
|
||||
///
|
||||
/// RoboMaster 规则是每队 4 块。界面按这个数量画格子,所以数据还没接上时也能看到位置
|
||||
/// ——空着比画 0 个格子更让人以为布局坏了。
|
||||
pub const ARMOR_BOARDS: usize = 4;
|
||||
|
||||
/// 没有数据源时的替身:什么都不提供,界面显示占位符。
|
||||
pub struct NoArmorYet;
|
||||
|
||||
impl ArmorSource for NoArmorYet {
|
||||
fn health(&self, _team: u8) -> Option<[u16; 4]> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use protocol::{fragment_frame, Hello, MsgType};
|
||||
|
||||
/// 一个完整的 JPEG 分片集合,足够让重组器交出一张帧。
|
||||
fn jpeg() -> Vec<u8> {
|
||||
(0..5000u32).map(|i| (i % 251) as u8).collect()
|
||||
}
|
||||
|
||||
fn from(ip: [u8; 4]) -> SocketAddr {
|
||||
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::from(ip), 5600))
|
||||
}
|
||||
|
||||
fn feed_frame(rx: &Receiver, sender: u32, frame_id: u32, from_ip: [u8; 4]) {
|
||||
for frag in fragment_frame(sender, frame_id, &jpeg(), 640, 480) {
|
||||
rx.datagram(&frag.encode(), from(from_ip));
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_hello(rx: &Receiver, sender: u32, from_ip: [u8; 4]) {
|
||||
let hello = Hello {
|
||||
sender_id: sender,
|
||||
firmware: 7,
|
||||
};
|
||||
rx.datagram(&hello.encode(MsgType::Hello), from(from_ip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivers_the_latest_frame_of_the_bound_sender_only() {
|
||||
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Team, Some(1));
|
||||
|
||||
feed_frame(&rx, 1, 10, [192, 168, 1, 10]);
|
||||
feed_frame(&rx, 2, 20, [192, 168, 1, 20]);
|
||||
|
||||
assert!(
|
||||
rx.latest_frames().latest(1).is_some(),
|
||||
"the bound camera must come through"
|
||||
);
|
||||
assert!(
|
||||
rx.latest_frames().latest(2).is_none(),
|
||||
"an opponent's board must not even be reassembled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sink_sees_exactly_the_frames_the_gate_allows() {
|
||||
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Team, Some(1));
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let tap = Arc::clone(&seen);
|
||||
rx.set_frame_sink(Arc::new(move |frame: Arc<protocol::Frame>| {
|
||||
tap.lock().unwrap().push(frame.sender_id);
|
||||
}));
|
||||
|
||||
feed_frame(&rx, 1, 1, [192, 168, 1, 10]);
|
||||
feed_frame(&rx, 2, 2, [192, 168, 1, 20]);
|
||||
|
||||
assert_eq!(*seen.lock().unwrap(), vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_can_be_opened_without_opening_the_video_gate() {
|
||||
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Team, Some(1));
|
||||
|
||||
// 设置界面没开:别人的 Hello 不记。
|
||||
feed_hello(&rx, 2, [192, 168, 1, 20]);
|
||||
assert!(
|
||||
rx.discovery().cameras().is_empty(),
|
||||
"locked down: other boards are not even recorded"
|
||||
);
|
||||
|
||||
// 设置界面解锁后列出候选。
|
||||
rx.set_discovery_all(true);
|
||||
feed_hello(&rx, 2, [192, 168, 1, 20]);
|
||||
|
||||
let ids: Vec<u32> = rx.discovery().cameras().iter().map(|c| c.id).collect();
|
||||
assert_eq!(ids, vec![2], "the candidate list is what the dropdown needs");
|
||||
|
||||
// 但画面仍然进不来。这正是把闸门分成两道的目的。
|
||||
feed_frame(&rx, 2, 5, [192, 168, 1, 20]);
|
||||
assert!(rx.latest_frames().latest(2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebinding_moves_the_gate() {
|
||||
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Team, Some(1));
|
||||
feed_frame(&rx, 1, 1, [192, 168, 1, 10]);
|
||||
|
||||
rx.restrict_to(Some(2));
|
||||
feed_frame(&rx, 1, 2, [192, 168, 1, 10]);
|
||||
feed_frame(&rx, 2, 3, [192, 168, 1, 20]);
|
||||
|
||||
assert_eq!(rx.sender_limit(), Some(2));
|
||||
assert!(
|
||||
rx.latest_frames().latest(1).is_some(),
|
||||
"the old frame stays cached; nothing pretends it never arrived"
|
||||
);
|
||||
assert!(rx.latest_frames().latest(2).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrestricted_receiver_takes_everything() {
|
||||
// 裁判端的形态。
|
||||
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Referee, None);
|
||||
|
||||
feed_hello(&rx, 1, [192, 168, 1, 10]);
|
||||
feed_hello(&rx, 2, [192, 168, 1, 20]);
|
||||
feed_frame(&rx, 1, 1, [192, 168, 1, 10]);
|
||||
feed_frame(&rx, 2, 1, [192, 168, 1, 20]);
|
||||
|
||||
assert_eq!(rx.discovery().cameras().len(), 2);
|
||||
assert_eq!(rx.latest_frames().latest_all().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_streaming_board_counts_as_online_without_a_hello() {
|
||||
// 曾经出现过"画面在刷新、状态栏却显示离线"的状态:is_online 只看 Hello。
|
||||
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Referee, None);
|
||||
|
||||
feed_hello(&rx, 1, [192, 168, 1, 10]);
|
||||
// 让发现列表先知道它,再把它的 last_seen 交给 touch。
|
||||
feed_frame(&rx, 1, 1, [192, 168, 1, 10]);
|
||||
|
||||
let camera = rx.discovery().cameras().pop().expect("known");
|
||||
assert_eq!(camera.id, 1);
|
||||
assert!(camera.is_online());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_frame_id_from_two_boards_does_not_collide() {
|
||||
// 两块板的 frame_id 各自从 0 开始,混用一个重组器会互相顶掉。
|
||||
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Referee, None);
|
||||
|
||||
feed_frame(&rx, 1, 3, [192, 168, 1, 10]);
|
||||
feed_frame(&rx, 2, 3, [192, 168, 1, 20]);
|
||||
|
||||
assert_eq!(rx.latest_frames().latest(1).map(|f| f.frame_id), Some(3));
|
||||
assert_eq!(rx.latest_frames().latest(2).map(|f| f.frame_id), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_datagram_is_ignored_instead_of_panicking() {
|
||||
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Referee, None);
|
||||
|
||||
rx.datagram(&[0u8; 3], from([192, 168, 1, 10]));
|
||||
rx.datagram(&[], from([192, 168, 1, 10]));
|
||||
|
||||
assert!(rx.discovery().cameras().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! 裁判端两个槽位对应哪两块摄像头。
|
||||
//!
|
||||
//! # 规则
|
||||
//!
|
||||
//! 1. **还在线的槽位不换人。** 板子短暂掉线又回来时,画面留在原位。
|
||||
//! 2. **没人用的板子才拿来填。** 候选里排除已经被另一个槽位占着的 id——这一条是
|
||||
//! 修一个真 bug:以前的实现只排除"这个槽位自己原来那个 id",于是队伍一那块板子
|
||||
//! 一离线,它的槽位就会去拿队伍二那块,**两格显示同一块板子**,看起来像图传串了。
|
||||
//! 3. **掉线的槽位保留它的 id。** 显示"离线"而不是变回空槽,这样它回来时还在原位,
|
||||
//! 也不会把画面顺序重排。
|
||||
//! 4. **没有候选就不动。** 只有一块板在场时,另一个槽位空着——空着比显示一份复制品
|
||||
//! 诚实。
|
||||
//!
|
||||
//! # 为什么值得单独一个模块加测试
|
||||
//!
|
||||
//! 这个逻辑出错的**表现**是"两路画面看起来串了"。现场的排查方向会是网线、AP、
|
||||
//! 板子,而不是二十行分配代码。旧客户端在 README 里为它写了整整一节,但仍然漏掉了
|
||||
//! 规则 2。所以现在它有了直接对着场景写的测试。
|
||||
|
||||
/// 两个槽位。索引 0 是左(队伍一),1 是右(队伍二)。
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SlotAllocator {
|
||||
slots: [Option<u32>; 2],
|
||||
}
|
||||
|
||||
impl SlotAllocator {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 当前分配。
|
||||
pub fn slots(&self) -> [Option<u32>; 2] {
|
||||
self.slots
|
||||
}
|
||||
|
||||
pub fn slot(&self, index: usize) -> Option<u32> {
|
||||
self.slots.get(index).copied().flatten()
|
||||
}
|
||||
|
||||
/// 手动指定一个槽位(裁判端的"允许手动覆盖")。
|
||||
pub fn force(&mut self, index: usize, id: Option<u32>) {
|
||||
if let Some(slot) = self.slots.get_mut(index) {
|
||||
*slot = id;
|
||||
}
|
||||
}
|
||||
|
||||
/// 按当前在线的摄像头更新分配,返回是否发生了变化。
|
||||
///
|
||||
/// `online` 必须按 id 排序,调用方用 [`crate::receiver::Discovery::online_cameras`]
|
||||
/// 得到的就是排好序的——顺序稳定,分配才不会每轮抖动。
|
||||
pub fn assign(&mut self, online: &[u32]) -> bool {
|
||||
let before = self.slots;
|
||||
|
||||
for index in 0..self.slots.len() {
|
||||
// 规则 1:还在场就不动。
|
||||
if self.slots[index].is_some_and(|id| online.contains(&id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 规则 2:另一个槽位占着的 id 不能拿。
|
||||
let taken: Vec<u32> = (0..self.slots.len())
|
||||
.filter(|other| *other != index)
|
||||
.filter_map(|other| self.slots[other])
|
||||
.collect();
|
||||
|
||||
let candidate = online
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|id| !taken.contains(id) && self.slots[index] != Some(*id));
|
||||
|
||||
// 规则 4:没有候选就保留原样(规则 3:掉线的 id 留着)。
|
||||
if let Some(id) = candidate {
|
||||
self.slots[index] = Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
self.slots != before
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn two_boards_fill_both_slots_left_to_right() {
|
||||
let mut slots = SlotAllocator::new();
|
||||
|
||||
assert!(slots.assign(&[0xAAAA, 0xBBBB]));
|
||||
assert_eq!(slots.slots(), [Some(0xAAAA), Some(0xBBBB)]);
|
||||
assert!(!slots.assign(&[0xAAAA, 0xBBBB]), "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_board_never_fills_both_slots() {
|
||||
// 旧实现会在两块都空时每轮取前两个,只有一块板在场时两格显示同一块画面。
|
||||
// 裁判看到两边一模一样,会以为两队的图传串了。
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA]);
|
||||
|
||||
assert_eq!(slots.slots(), [Some(0xAAAA), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_online_slot_keeps_its_board_when_the_other_goes_offline() {
|
||||
// 这是本项目里真实出现过的 bug:队伍一的板子一离线,它的槽位去拿了队伍二
|
||||
// 那块,于是两格都显示队伍二——而 B 的槽位本身没动。
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
|
||||
slots.assign(&[0xBBBB]);
|
||||
|
||||
assert_eq!(
|
||||
slots.slots(),
|
||||
[Some(0xAAAA), Some(0xBBBB)],
|
||||
"A 掉线的槽位保留它的 id(显示离线),B 原地不动,绝不能出现两块 B"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_returning_board_lands_back_in_its_own_slot() {
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
slots.assign(&[0xBBBB]);
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
|
||||
assert_eq!(slots.slots(), [Some(0xAAAA), Some(0xBBBB)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spare_board_does_not_land_twice() {
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
|
||||
// 两块都掉线,然后来了一块新的 C:只填一个槽位。
|
||||
slots.assign(&[0xCCCC]);
|
||||
|
||||
assert_eq!(slots.slots(), [Some(0xCCCC), Some(0xBBBB)]);
|
||||
|
||||
// C 填了左边,右边仍是掉线的 B;再来一块 D 才填右边。
|
||||
slots.assign(&[0xCCCC, 0xDDDD]);
|
||||
assert_eq!(slots.slots(), [Some(0xCCCC), Some(0xDDDD)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_board_coming_back_does_not_thrash_the_slot_it_left() {
|
||||
// "板子短暂掉线又回来,槽位原地打转":掉线的槽位不该立刻把自己换掉。
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA]);
|
||||
|
||||
// A 掉线、B 上线:A 的槽位改拿 B。
|
||||
slots.assign(&[0xBBBB]);
|
||||
assert_eq!(slots.slots(), [Some(0xBBBB), None]);
|
||||
|
||||
// A 回来,两块都在:B 已经在槽位里且在线,A 只能等另一个槽位——它空着,于是
|
||||
// A 落到右边。这是有意的:已经显示出来的画面不该因为一块板子回来就跳到别处。
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
assert_eq!(slots.slots(), [Some(0xBBBB), Some(0xAAAA)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forcing_a_slot_overrides_the_automatic_choice() {
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
|
||||
slots.force(0, Some(0xBBBB));
|
||||
slots.force(1, Some(0xAAAA));
|
||||
|
||||
assert_eq!(slots.slots(), [Some(0xBBBB), Some(0xAAAA)]);
|
||||
|
||||
// 手动模式下依然是手动值:assign 只对"不在线"的槽位动手。
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
assert_eq!(slots.slots(), [Some(0xBBBB), Some(0xAAAA)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_everything_leaves_the_last_known_ids() {
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
|
||||
assert!(!slots.assign(&[]), "nothing to reassign");
|
||||
assert_eq!(
|
||||
slots.slots(),
|
||||
[Some(0xAAAA), Some(0xBBBB)],
|
||||
"掉线的槽位保留 id,界面才能说清在等谁"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
//! 推给界面的快照类型。
|
||||
//!
|
||||
//! # 为什么单独一个模块
|
||||
//!
|
||||
//! 这些结构体的字段名就是**界面看到的 JSON 字段名**,所以它们是接口的一部分,值得
|
||||
//! 集中放、集中改。放在这里而不是 Tauri 层,是因为其中几个的构造规则(比如"关掉
|
||||
//! 诊断时不留数字")需要单元测试。
|
||||
//!
|
||||
//! # 为什么 id 同时给数字和文本
|
||||
//!
|
||||
//! 界面显示、日志、配置文件里用的都是 `0x3D4D8A9A` 这种十六进制形式。让 Rust 侧拼好
|
||||
//! (`id_text`) 而不是让前端各处自己 `toString(16)`,可以避免"某处少补了前导零"这种
|
||||
//! 只能靠肉眼比对才发现的不一致。
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::armor::ArmorBoard;
|
||||
use crate::config::{Config, CrosshairConfig, GridConfig, HealthConfig, HitFeedbackConfig, Role};
|
||||
use crate::receiver::{CameraInfo, SourceStats};
|
||||
|
||||
/// 把 id 写成界面上、配置文件里、抓包时都一致的形式。
|
||||
pub fn id_text(id: u32) -> String {
|
||||
format!("0x{id:08X}")
|
||||
}
|
||||
|
||||
/// 一块图传板。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CameraDto {
|
||||
pub id: u32,
|
||||
pub id_text: String,
|
||||
pub addr: String,
|
||||
pub online: bool,
|
||||
pub silence_ms: u64,
|
||||
pub firmware: u16,
|
||||
}
|
||||
|
||||
impl From<&CameraInfo> for CameraDto {
|
||||
fn from(camera: &CameraInfo) -> Self {
|
||||
Self {
|
||||
id: camera.id,
|
||||
id_text: id_text(camera.id),
|
||||
addr: camera.addr.ip().to_string(),
|
||||
online: camera.is_online(),
|
||||
silence_ms: camera.silence().as_millis() as u64,
|
||||
firmware: camera.firmware,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一块装甲板主板。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArmorBoardDto {
|
||||
pub id: u32,
|
||||
pub id_text: String,
|
||||
pub addr: String,
|
||||
pub online: bool,
|
||||
/// 板子自报的队伍(编译期定的 `ARMOR_TEAM`)。
|
||||
pub team: u8,
|
||||
/// 最后一次读数。整车一条血,所以这里一个数。
|
||||
pub hp: u16,
|
||||
pub plates_seen: u8,
|
||||
pub reports_all_plates: bool,
|
||||
pub silence_ms: u64,
|
||||
}
|
||||
|
||||
impl From<&ArmorBoard> for ArmorBoardDto {
|
||||
fn from(board: &ArmorBoard) -> Self {
|
||||
Self {
|
||||
id: board.id,
|
||||
id_text: id_text(board.id),
|
||||
addr: board.addr.ip().to_string(),
|
||||
online: board.is_online(),
|
||||
team: board.team,
|
||||
hp: board.hp,
|
||||
plates_seen: board.plates_seen.count_ones() as u8,
|
||||
reports_all_plates: board.reports_all_plates(),
|
||||
silence_ms: board.silence().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一路画面的读数。
|
||||
///
|
||||
/// # 关掉诊断时这些数字**不存在**,而不是被界面藏起来
|
||||
///
|
||||
/// 旧客户端在队伍端 release 下把这些字段填成空字符串。现在换成数字,规则不变:非诊断
|
||||
/// 模式下它们被置零/置空。由编译模式决定而不是命令行参数——命令行开关意味着二进制里
|
||||
/// 带着那套东西,忘开一次就泄露了。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CameraStatsDto {
|
||||
pub camera: u32,
|
||||
pub camera_text: String,
|
||||
pub online: bool,
|
||||
/// 界面直接显示这一行,所以"在线但还没画面"与"离线"的区别由这里给出,而不是让
|
||||
/// 前端从 online + hasFrames 猜。
|
||||
pub status: String,
|
||||
pub has_frames: bool,
|
||||
pub fps: f32,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub frames_shown: u64,
|
||||
pub frames_dropped: u64,
|
||||
pub loss_percent: f32,
|
||||
pub datagrams: u64,
|
||||
pub last_frame_bytes: usize,
|
||||
pub gap_ms: Option<f32>,
|
||||
}
|
||||
|
||||
impl CameraStatsDto {
|
||||
/// `diagnostics` 为 false 时不产出技术数字(见类型文档)。
|
||||
pub fn build(
|
||||
diagnostics: bool,
|
||||
camera: u32,
|
||||
online: bool,
|
||||
stats: &SourceStats,
|
||||
fps: f32,
|
||||
) -> Self {
|
||||
let has_frames = stats.frames_shown > 0;
|
||||
|
||||
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
|
||||
// 比让人以为板子挂了诚实。
|
||||
//
|
||||
// 有画面时显示 id —— 这对调试有用。队伍端 release 下这一路只有自己绑定的板子,
|
||||
// 显示它不构成泄露(界面上根本没有别人的板子可选)。
|
||||
let status = if !has_frames {
|
||||
if online {
|
||||
"已连接,等待画面…".to_string()
|
||||
} else {
|
||||
"离线".to_string()
|
||||
}
|
||||
} else {
|
||||
id_text(camera)
|
||||
};
|
||||
|
||||
if !diagnostics {
|
||||
return Self {
|
||||
camera,
|
||||
camera_text: id_text(camera),
|
||||
online,
|
||||
status,
|
||||
has_frames,
|
||||
fps: 0.0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
frames_shown: 0,
|
||||
frames_dropped: 0,
|
||||
loss_percent: 0.0,
|
||||
datagrams: 0,
|
||||
last_frame_bytes: 0,
|
||||
gap_ms: None,
|
||||
};
|
||||
}
|
||||
|
||||
// 没出过一个字节的源,`stats.width`/`height` 还是默认值 0,于是界面会显示成
|
||||
// "0x0"——一个看起来像分辨率的假分辨率。用 has_frames 把它挡掉。
|
||||
Self {
|
||||
camera,
|
||||
camera_text: id_text(camera),
|
||||
online,
|
||||
status,
|
||||
has_frames,
|
||||
fps: if has_frames { fps } else { 0.0 },
|
||||
width: if has_frames { stats.width } else { 0 },
|
||||
height: if has_frames { stats.height } else { 0 },
|
||||
frames_shown: stats.frames_shown,
|
||||
frames_dropped: stats.frames_dropped,
|
||||
loss_percent: stats.loss_percent(),
|
||||
datagrams: stats.datagrams,
|
||||
last_frame_bytes: stats.last_frame_len,
|
||||
gap_ms: stats.gap_ms(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一边的血量。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TeamHealthDto {
|
||||
pub team: u8,
|
||||
pub name: String,
|
||||
/// 有没有可信的数据。false 时界面显示 `placeholder` 而不是 0——0 是一个看起来像
|
||||
/// 真值的假值,现场看到 0 会以为对方已经没血了。
|
||||
pub available: bool,
|
||||
pub placeholder: String,
|
||||
/// 整车血量(四条装甲板同值,所以取第一条)。
|
||||
pub hp: Option<u16>,
|
||||
/// 四条装甲板各自的读数;`available` 为 true 时有效。
|
||||
pub hps: Vec<u16>,
|
||||
/// 绑定的装甲板主板 id(用户端)/ 分配到的(裁判端)。
|
||||
pub board: Option<u32>,
|
||||
pub board_text: Option<String>,
|
||||
/// 血量偏低或已归零。
|
||||
pub critical: bool,
|
||||
}
|
||||
|
||||
impl TeamHealthDto {
|
||||
/// 还没有数据源。
|
||||
pub fn absent(team: u8, board: Option<u32>) -> Self {
|
||||
Self {
|
||||
team,
|
||||
name: team_name(team),
|
||||
available: false,
|
||||
placeholder: format!(
|
||||
"装甲板未接入:等主板在 UDP {} 上广播血量",
|
||||
protocol::ARMOR_PORT
|
||||
),
|
||||
hp: None,
|
||||
// 依然给出 4 个格子:界面用 `available` 决定颜色,空数组会让占位框整个消失,
|
||||
// 看起来像布局出错,而不是像"还没有数据"。
|
||||
hps: vec![0; crate::receiver::ARMOR_BOARDS],
|
||||
board,
|
||||
board_text: board.map(id_text),
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 有数据。
|
||||
pub fn present(team: u8, board: Option<u32>, hps: [u16; crate::receiver::ARMOR_BOARDS], low: u16) -> Self {
|
||||
let hp = hps.first().copied().unwrap_or(0);
|
||||
|
||||
Self {
|
||||
team,
|
||||
name: team_name(team),
|
||||
available: true,
|
||||
placeholder: String::new(),
|
||||
hp: Some(hp),
|
||||
hps: hps.to_vec(),
|
||||
board,
|
||||
board_text: board.map(id_text),
|
||||
critical: hp == 0 || hp <= low,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 队伍名。
|
||||
pub fn team_name(team: u8) -> String {
|
||||
match team {
|
||||
0 => "队伍一".to_string(),
|
||||
_ => "队伍二".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次掉血(由 [`crate::armor::HealthTracker`] 派生)。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HitDto {
|
||||
pub board: u32,
|
||||
pub team: u8,
|
||||
pub index: u8,
|
||||
pub from: u16,
|
||||
pub to: u16,
|
||||
pub damage: u16,
|
||||
/// Unix 毫秒。前端用它驱动闪红/抖动的动画,不需要自己计时。
|
||||
pub at_ms: u64,
|
||||
}
|
||||
|
||||
/// 录制状态。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecordingDto {
|
||||
pub camera: u32,
|
||||
pub camera_text: String,
|
||||
pub active: bool,
|
||||
pub path: Option<String>,
|
||||
/// 告诉 ffmpeg 的输入帧率。回放快放/慢放时,第一个要看的就是这个数。
|
||||
pub input_fps: f32,
|
||||
pub frames_written: u64,
|
||||
/// 因为写入端跟不上而丢掉的帧。丢帧不报出来的话,回放里"少了半秒"会看起来像
|
||||
/// 相机的问题。
|
||||
pub frames_dropped: u64,
|
||||
pub started_ms: Option<u64>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 裁判端两个槽位。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SlotsDto {
|
||||
/// 左槽位 → 摄像头 id。
|
||||
pub camera_a: Option<u32>,
|
||||
pub camera_b: Option<u32>,
|
||||
/// 左槽位 → 装甲板主板 id。
|
||||
pub armor_a: Option<u32>,
|
||||
pub armor_b: Option<u32>,
|
||||
pub manual: bool,
|
||||
}
|
||||
|
||||
/// 推给界面的完整状态。
|
||||
///
|
||||
/// 一次性给全,而不是让界面按需多次查询:5 Hz 下这条消息只有几百字节,而"界面各处
|
||||
/// 分别拉数据"会引入一类难查的问题——两个面板显示的是不同时刻的状态(比如绑定改了
|
||||
/// 一半)。前端因此只需要一个 reducer。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Snapshot {
|
||||
pub role: Role,
|
||||
pub team: u8,
|
||||
pub mirrored: bool,
|
||||
/// 演示模式:画面与血量是本地合成的,不是场上的板子。界面必须把它显示出来,
|
||||
/// 否则截图与验收会被误读成真实链路。
|
||||
pub demo: bool,
|
||||
/// 是否产出技术读数(帧率/丢帧)。用户端 release 下为 false。
|
||||
pub diagnostics: bool,
|
||||
/// 密码闸门是否已经打开(会话内)。
|
||||
pub unlocked: bool,
|
||||
/// 发现列表是否放开(设置面板打开时)。开着才可能列出别人的板子。
|
||||
pub discovery_open: bool,
|
||||
pub uptime_ms: u64,
|
||||
/// 连着这个后端的界面数。
|
||||
pub clients: usize,
|
||||
pub config: ConfigView,
|
||||
pub cameras: Vec<CameraDto>,
|
||||
pub armor_boards: Vec<ArmorBoardDto>,
|
||||
/// 每块已知摄像头的读数。
|
||||
pub stats: Vec<CameraStatsDto>,
|
||||
/// 裁判端两个槽位。用户端的 `a` 就是它绑定的那一块。
|
||||
pub slots: SlotsDto,
|
||||
/// 用户端一条(自己那边),裁判端两条。
|
||||
pub health: Vec<TeamHealthDto>,
|
||||
pub recordings: Vec<RecordingDto>,
|
||||
/// 启动时的说明(旧配置迁移、端口占用等)。
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// 配置里可以给界面看的那部分。
|
||||
///
|
||||
/// **不含密码哈希**:界面只需要知道"设过没有",把 PHC 字符串送进 WebView 没有任何用处,
|
||||
/// 只是多一个能被日志、崩溃报告带走的东西。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigView {
|
||||
pub role: Role,
|
||||
/// 身份是否已由人选过。false 时界面先问一次(见 `Config::role_chosen`)。
|
||||
pub role_chosen: bool,
|
||||
pub team: u8,
|
||||
pub camera_id: Option<u32>,
|
||||
pub camera_id_text: Option<String>,
|
||||
pub armor_id: Option<u32>,
|
||||
pub armor_id_text: Option<String>,
|
||||
pub mirror: bool,
|
||||
pub password_set: bool,
|
||||
pub hud: HudView,
|
||||
pub recording_dir: String,
|
||||
pub ffmpeg: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HudView {
|
||||
pub health: HealthConfig,
|
||||
pub crosshair: CrosshairConfig,
|
||||
pub grid: GridConfig,
|
||||
pub hit_feedback: HitFeedbackConfig,
|
||||
pub show_zoom: bool,
|
||||
pub show_stats: bool,
|
||||
}
|
||||
|
||||
impl From<&Config> for ConfigView {
|
||||
fn from(config: &Config) -> Self {
|
||||
Self {
|
||||
role: config.role,
|
||||
role_chosen: config.role_chosen,
|
||||
team: config.team,
|
||||
camera_id: config.camera_id,
|
||||
camera_id_text: config.camera_id.map(id_text),
|
||||
armor_id: config.armor_id,
|
||||
armor_id_text: config.armor_id.map(id_text),
|
||||
mirror: config.mirror,
|
||||
password_set: config.password.is_some(),
|
||||
hud: HudView {
|
||||
health: config.hud.health.clone(),
|
||||
crosshair: config.hud.crosshair.clone(),
|
||||
grid: config.hud.grid.clone(),
|
||||
hit_feedback: config.hud.hit_feedback.clone(),
|
||||
show_zoom: config.hud.show_zoom,
|
||||
show_stats: config.hud.show_stats,
|
||||
},
|
||||
recording_dir: config.recordings_dir().display().to_string(),
|
||||
ffmpeg: config.recording.ffmpeg.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::receiver::SourceStats;
|
||||
|
||||
fn stats() -> SourceStats {
|
||||
let mut stats = SourceStats::default();
|
||||
// 直接构造:SourceStats 的字段是 pub 的,只有 last_shown_at 是私有的。
|
||||
stats.frames_shown = 440;
|
||||
stats.frames_dropped = 10;
|
||||
stats.datagrams = 8000;
|
||||
stats.width = 640;
|
||||
stats.height = 480;
|
||||
stats.last_frame_len = 22_000;
|
||||
stats
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_off_leaves_no_numbers_behind() {
|
||||
// 不是"界面藏起来",是**不存在**。
|
||||
let dto = CameraStatsDto::build(false, 0x3D4D_8A9A, true, &stats(), 22.0);
|
||||
|
||||
assert_eq!(dto.frames_shown, 0);
|
||||
assert_eq!(dto.frames_dropped, 0);
|
||||
assert_eq!(dto.datagrams, 0);
|
||||
assert_eq!(dto.fps, 0.0);
|
||||
assert_eq!(dto.width, 0);
|
||||
assert_eq!(dto.gap_ms, None);
|
||||
assert_eq!(dto.last_frame_bytes, 0);
|
||||
// 但界面仍然需要知道画面来没来。
|
||||
assert!(dto.has_frames);
|
||||
assert!(dto.online);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_on_reports_the_real_numbers() {
|
||||
let dto = CameraStatsDto::build(true, 0x3D4D_8A9A, true, &stats(), 22.0);
|
||||
|
||||
assert_eq!(dto.frames_shown, 440);
|
||||
assert_eq!(dto.frames_dropped, 10);
|
||||
assert!((dto.loss_percent - 2.2222).abs() < 0.01);
|
||||
assert_eq!((dto.width, dto.height), (640, 480));
|
||||
assert_eq!(dto.fps, 22.0);
|
||||
assert_eq!(dto.status, "0x3D4D8A9A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_never_seen_camera_does_not_report_a_fake_resolution() {
|
||||
let dto = CameraStatsDto::build(true, 1, true, &SourceStats::default(), 0.0);
|
||||
|
||||
assert_eq!(dto.status, "已连接,等待画面…");
|
||||
assert_eq!((dto.width, dto.height), (0, 0));
|
||||
assert!(!dto.has_frames, "0x0 would read like a real resolution");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offline_camera_says_offline() {
|
||||
let dto = CameraStatsDto::build(true, 1, false, &SourceStats::default(), 0.0);
|
||||
assert_eq!(dto.status, "离线");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_health_is_a_placeholder_with_four_cells() {
|
||||
let dto = TeamHealthDto::absent(0, None);
|
||||
|
||||
assert!(!dto.available);
|
||||
assert_eq!(dto.hp, None);
|
||||
assert_eq!(dto.hps.len(), crate::receiver::ARMOR_BOARDS);
|
||||
assert!(!dto.placeholder.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn critical_health_is_flagged_at_zero_and_at_the_threshold() {
|
||||
let low = TeamHealthDto::present(0, Some(1), [30, 30, 30, 30], 30);
|
||||
assert!(low.critical, "at the threshold");
|
||||
|
||||
let ok = TeamHealthDto::present(0, Some(1), [31, 31, 31, 31], 30);
|
||||
assert!(!ok.critical);
|
||||
|
||||
let dead = TeamHealthDto::present(0, Some(1), [0, 0, 0, 0], 30);
|
||||
assert!(dead.critical);
|
||||
assert_eq!(dead.hp, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_reports_the_framerate_it_told_ffmpeg() {
|
||||
// 回放速度不对时,这是唯一能解释原因的数字。
|
||||
let dto = RecordingDto {
|
||||
camera: 1,
|
||||
camera_text: id_text(1),
|
||||
active: true,
|
||||
path: None,
|
||||
input_fps: 21.5,
|
||||
frames_written: 0,
|
||||
frames_dropped: 0,
|
||||
started_ms: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&dto).unwrap();
|
||||
assert!(json.contains(r#""inputFps":21.5"#), "got {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_config_view_never_carries_the_password_hash() {
|
||||
let mut config = Config::default();
|
||||
config.role = Role::Team;
|
||||
config.password = Some("$argon2id$super$secret$hash".into());
|
||||
|
||||
let view = ConfigView::from(&config);
|
||||
assert!(view.password_set);
|
||||
|
||||
let json = serde_json::to_string(&view).unwrap();
|
||||
assert!(
|
||||
!json.contains("argon2id"),
|
||||
"the hash must not reach the webview: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_formatted_like_everywhere_else() {
|
||||
assert_eq!(id_text(0x3D4D_8A9A), "0x3D4D8A9A");
|
||||
assert_eq!(id_text(1), "0x00000001", "leading zeros matter for eyeballing");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! 帧率统计。
|
||||
//!
|
||||
//! # 必须按"帧数除以墙钟",不是"帧间隔的平均"
|
||||
//!
|
||||
//! 这两种算法在理想链路上给出同一个数,所以错误的那一版能一路跑到现场才暴露。
|
||||
//!
|
||||
//! 界面每 33ms 轮询一次,而相机出帧间隔是 45ms。也就是说**一次轮询之间可能完成零帧
|
||||
//! 或多帧**。对帧间隔取平均,量到的是**显示**的刷新节奏而不是**相机**的出帧节奏——
|
||||
//! 于是上限 22fps 的传感器被报成 36fps。**一个笃定的错误数字比没有数字更糟**:现场
|
||||
//! 会以为还有性能余量,然后花时间去查一个不存在的问题。
|
||||
//!
|
||||
//! 这个错误在本项目里出现过两次(旧客户端的 `View::update_fps`、探测脚本的分窗打印),
|
||||
//! 两次都是同一个形状:分子用累计值,分母用窗口值,于是报出随时间单调上升的假数字。
|
||||
//! 现在它的正确性由一个直接对着数字写的测试盯着。
|
||||
//!
|
||||
//! # 时钟是可注入的
|
||||
//!
|
||||
//! [`FpsWindow::observe`] 收 `now` 而不是自己取 `Instant::now()`,这样测试可以用
|
||||
//! 明确的时刻验证窗口边界——否则只能 sleep,而 sleep 出来的测试既慢又不稳。
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 帧率统计窗口。默认 2 秒。
|
||||
pub const DEFAULT_WINDOW: Duration = Duration::from_secs(2);
|
||||
|
||||
/// 某一路的帧率窗口。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FpsWindow {
|
||||
window: Duration,
|
||||
started: Instant,
|
||||
/// 上一次结算时的累计帧数。
|
||||
frames: u64,
|
||||
/// 已经结算过几个窗口。
|
||||
///
|
||||
/// 不能拿"累计帧数非零"来代替它:窗口的基线是**播种**进去的(见 [`FpsWindow::seeded`]),
|
||||
/// 一块掉线又回来的板子一建立窗口就带着一个很大的累计值。用帧数判断的话,会在一个
|
||||
/// 窗口都还没走完时就宣称"有读数了",界面于是先显示 0.0,再跳到真值。
|
||||
completed: u32,
|
||||
fps: f32,
|
||||
}
|
||||
|
||||
impl Default for FpsWindow {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_WINDOW)
|
||||
}
|
||||
}
|
||||
|
||||
impl FpsWindow {
|
||||
pub fn new(window: Duration) -> Self {
|
||||
Self::starting_at(window, Instant::now())
|
||||
}
|
||||
|
||||
/// 窗口从指定的时刻开始,并把累计帧数的基线设成给定值。
|
||||
///
|
||||
/// 基线不能总是 0:`frames_shown` 是**自进程启动以来**的累计值,而一块掉线又回来的
|
||||
/// 板子会带着一个已经涨了很大的数字回来。基线设错(比如设成 0)时,下一个窗口会
|
||||
/// 报出几百 fps 的假数字。
|
||||
pub fn seeded(window: Duration, started: Instant, frames_shown: u64) -> Self {
|
||||
Self {
|
||||
window,
|
||||
started,
|
||||
frames: frames_shown,
|
||||
completed: 0,
|
||||
fps: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 窗口从指定的时刻开始,基线为 0。测试用。
|
||||
pub fn starting_at(window: Duration, started: Instant) -> Self {
|
||||
Self::seeded(window, started, 0)
|
||||
}
|
||||
|
||||
/// 最近一次结算出的帧率。窗口还没满时是 0。
|
||||
pub fn fps(&self) -> f32 {
|
||||
self.fps
|
||||
}
|
||||
|
||||
/// 窗口是否已经结算过至少一次。
|
||||
///
|
||||
/// 界面用它在"还没收到帧"和"帧率真的是 0"之间区分——前者显示 0.0 会被读成"板子
|
||||
/// 卡死了"。
|
||||
pub fn has_reading(&self) -> bool {
|
||||
self.completed > 0
|
||||
}
|
||||
|
||||
/// 喂进当前的累计帧数,返回本窗口的帧率(窗口未满时返回 `None`)。
|
||||
///
|
||||
/// `frames_shown` 是**自进程启动以来**的累计值,不是窗口内的增量——这正是重点:
|
||||
/// 窗口内的增量由这里相减得到,调用方不需要(也就不会弄错)维护它。
|
||||
pub fn observe(&mut self, frames_shown: u64, now: Instant) -> Option<f32> {
|
||||
let elapsed = now.saturating_duration_since(self.started);
|
||||
|
||||
if elapsed < self.window {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.fps = (frames_shown.saturating_sub(self.frames)) as f32 / elapsed.as_secs_f32();
|
||||
self.frames = frames_shown;
|
||||
self.started = now;
|
||||
self.completed += 1;
|
||||
|
||||
Some(self.fps)
|
||||
}
|
||||
|
||||
/// 换了一路画面:窗口重开。
|
||||
///
|
||||
/// 不重开的话,新画面的第一段统计里带着上一块板子的累计帧数,会报出一个凭空多出来
|
||||
/// 的数字。旧客户端对此的处理是"`view.sender_id` 变了就整体重建 `View`",那条
|
||||
/// 规则现在收在这里。
|
||||
pub fn reset(&mut self, now: Instant) {
|
||||
self.started = now;
|
||||
self.frames = 0;
|
||||
self.completed = 0;
|
||||
self.fps = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rate_is_frames_over_wallclock() {
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||||
|
||||
// 2 秒里到了 44 帧 = 22 fps。
|
||||
let fps = window
|
||||
.observe(44, t0 + Duration::from_secs(2))
|
||||
.expect("the window is full");
|
||||
|
||||
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_half_full_window_reports_nothing() {
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||||
|
||||
assert_eq!(window.observe(10, t0 + Duration::from_millis(1999)), None);
|
||||
assert_eq!(window.fps(), 0.0);
|
||||
assert!(!window.has_reading());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stalled_stream_reports_zero_not_a_stale_number() {
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||||
|
||||
window.observe(44, t0 + Duration::from_secs(2));
|
||||
// 板子不出帧了:累计值不变,下一个窗口必须报 0。
|
||||
let fps = window
|
||||
.observe(44, t0 + Duration::from_secs(4))
|
||||
.expect("second window");
|
||||
|
||||
assert_eq!(fps, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn averaging_frame_gaps_is_the_wrong_answer() {
|
||||
// 这条测试把"错误算法会给出什么"钉下来,避免有人"顺手修一下"。
|
||||
//
|
||||
// 场景:界面每 33ms 轮询一次,相机 45ms 出一帧。一次轮询里轮空、下一次里
|
||||
// 完成一帧,还有一次轮询之间完成两帧。对帧间隔取平均会算成约 33ms 一帧
|
||||
// (30fps 上下),而墙钟给出的才是真实的 22fps。
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||||
|
||||
// 2 秒内累计 44 帧,但最后一次轮询与上一帧之间只隔了 33ms。
|
||||
let fps = window
|
||||
.observe(44, t0 + Duration::from_secs(2))
|
||||
.expect("window full");
|
||||
|
||||
assert!((fps - 22.0).abs() < 0.001, "wallclock rate: {fps}");
|
||||
assert!(
|
||||
fps < 25.0,
|
||||
"a frame-gap average would have reported ~30fps here"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_completed_window_is_not_a_reading_yet_until_it_is_full() {
|
||||
// 建立窗口的那一轮不该产出数字:它连一个完整窗口都还没过。
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::seeded(Duration::from_secs(2), t0, 9_000);
|
||||
|
||||
assert_eq!(window.observe(9_010, t0 + Duration::from_millis(10)), None);
|
||||
assert!(!window.has_reading());
|
||||
|
||||
// 2 秒后:这 2 秒里到了 44 帧。
|
||||
let fps = window
|
||||
.observe(9_044, t0 + Duration::from_secs(2))
|
||||
.expect("window full");
|
||||
|
||||
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resetting_clears_the_cumulative_count() {
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||||
|
||||
window.observe(1000, t0 + Duration::from_secs(2));
|
||||
// 换了一路画面:累计值从 0 重新开始。
|
||||
window.reset(t0 + Duration::from_secs(2));
|
||||
|
||||
let fps = window
|
||||
.observe(44, t0 + Duration::from_secs(4))
|
||||
.expect("window full");
|
||||
|
||||
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_counter_that_goes_backwards_does_not_underflow() {
|
||||
// 累计值只应该单调增。真出现回退(换了板子但没 reset),宁可报 0 也不要用
|
||||
// u64 回绕算出一个天文数字。
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||||
|
||||
window.observe(500, t0 + Duration::from_secs(2));
|
||||
let fps = window
|
||||
.observe(10, t0 + Duration::from_secs(4))
|
||||
.expect("window full");
|
||||
|
||||
assert_eq!(fps, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>cam-desktop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "cam-desktop",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "ESP32 图传与装甲板系统的桌面端:用户端(血条 + 瞄准器)与裁判端(双路 + 装甲板 + 录制)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"app": "tauri dev",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"react": "^19",
|
||||
"react-dom": "^19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^5",
|
||||
"typescript": "^5.9",
|
||||
"vite": "^7"
|
||||
}
|
||||
}
|
||||
Generated
+1295
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
# pnpm 11 不再读取 package.json 里的 `pnpm` 字段,设置搬到了这里。
|
||||
# 这个文件是 pnpm 自己在遇到被忽略的构建脚本时生成的。
|
||||
#
|
||||
# esbuild 的 postinstall 是它把自己平台的二进制接好的那一步。不允许它跑,`pnpm exec`
|
||||
# 每次都会以 ERR_PNPM_IGNORED_BUILDS 退出 1 —— 即使 vite 本身还能跑,这个非零退出码
|
||||
# 也会让 `tauri dev` 的 beforeDevCommand 失败。所以这里显式放行。
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -0,0 +1,8 @@
|
||||
# 这个目录里的一切都用宿主机的稳定版工具链构建。
|
||||
#
|
||||
# 仓库根目录的 rust-toolchain.toml 钉的是 `esp`(带 Xtensa 支持的定制工具链),
|
||||
# 那是给图传板与装甲板固件用的。桌面端不需要它,而且 Tauri 依赖里的若干 crate
|
||||
# 要求较新的稳定版。rustup 取**最近的**那一个 rust-toolchain.toml,所以这个文件
|
||||
# 就是 desktop/ 下面所有 cargo 调用的实际选择。
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
Generated
+5287
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
[package]
|
||||
name = "cam-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "ESP32 图传与装甲板系统的桌面端"
|
||||
rust-version = "1.77"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# `protocol-asset` 打开自定义资源协议,录制出来的 mp4 才能在界面里直接回放
|
||||
# (前端用 convertFileSrc 把它变成一个 <video> 能播的 URL)。
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
|
||||
# 只用来"在访达里显示"与"用系统播放器打开"。所有调用都走我们自己的 Rust 命令,
|
||||
# 所以前端不需要装 @tauri-apps/plugin-opener,也不需要给它开权限。
|
||||
tauri-plugin-opener = "2"
|
||||
|
||||
# 非界面部分:UDP 接收/重组、装甲板、配置、密码、槽位分配。它自己带 64 个测试。
|
||||
cam-core = { path = "../core" }
|
||||
|
||||
# 线路格式与固件共享。
|
||||
protocol = { path = "../../protocol" }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
anyhow = "1"
|
||||
|
||||
# 回环通道的随机 token。
|
||||
rand = "0.9"
|
||||
|
||||
# 本地 WebSocket 视频通道。用 tokio-tungstenite 而不是 Tauri 的自定义协议:
|
||||
# 二进制消息不经 JSON/IPC 序列化,前端拿到的就是原始 JPEG 字节,可以直接交给
|
||||
# 浏览器的原生 JPEG 解码器(createImageBitmap)。
|
||||
tokio = { version = "1", features = [
|
||||
"rt-multi-thread",
|
||||
"net",
|
||||
"sync",
|
||||
"io-util",
|
||||
"macros",
|
||||
"time",
|
||||
] }
|
||||
tokio-tungstenite = "0.30"
|
||||
futures-util = "0.3"
|
||||
|
||||
# 截图要 PNG(相机出的 JPEG 不带 alpha、也可能被播放器当照片处理),所以要解码再编码。
|
||||
# demo 模式也用它合成假画面。
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||
|
||||
# 文件名里的时间戳:现场看目录时要能一眼看出是哪一局录的。
|
||||
chrono = "0.4"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Tauri 的构建脚本。
|
||||
//!
|
||||
//! 它做两件事:把 `tauri.conf.json` 读进来生成上下文,以及(在 Windows 上)挂上清单。
|
||||
//! 桌面端不需要自定义资源编译,所以这里没有 `tauri_build::Attributes`。
|
||||
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "窗口与事件这些核心能力。业务命令(绑定、解锁、截图、录制)都是本应用自己的 #[tauri::command],不在权限系统里,所以这里没有它们的条目。",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -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;
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "cam-desktop",
|
||||
"version": "0.1.0",
|
||||
"identifier": "org.camdesktop.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "cam-desktop",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"maximized": true,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": [
|
||||
"$HOME/Movies/cam-desktop/**",
|
||||
"$HOME/Videos/cam-desktop/**",
|
||||
"$APPDATA/**",
|
||||
"$TEMP/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app", "dmg"],
|
||||
"category": "Utility",
|
||||
"shortDescription": "ESP32 图传与装甲板桌面端",
|
||||
"longDescription": "用户端:一路画面 + 血条与瞄准器。裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 首次启动的两道门:先选身份,再设密码。
|
||||
*
|
||||
* 身份必须问一次,因为 `role` 的默认值(裁判端)既可能是人选了裁判端,也可能只是还没人
|
||||
* 碰过这个文件——后者让用户端的人第一次打开会直接进裁判端,然后以为这个程序只有那一种
|
||||
* 界面。所以后端单独记了一个 `roleChosen`。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "../lib/ipc";
|
||||
import { pushNote } from "../lib/store";
|
||||
import type { Role } from "../lib/types";
|
||||
|
||||
export function RolePicker() {
|
||||
const [busy, setBusy] = useState<Role | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const choose = async (role: Role) => {
|
||||
setBusy(role);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.setRole(role);
|
||||
await api.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gate">
|
||||
<h1 className="gate__title">这台机器是哪一端?</h1>
|
||||
<p className="gate__hint">选定之后记进配置,下次启动直接进这一端。之后可以在设置里改。</p>
|
||||
|
||||
<div className="gate__choices">
|
||||
<button className="choice" disabled={busy !== null} onClick={() => void choose("team")}>
|
||||
<span className="choice__name">用户端</span>
|
||||
<span className="choice__desc">
|
||||
一路画面 + 血条 + 瞄准器。需要绑定一块图传板与一块装甲板,绑定改动要密码。
|
||||
</span>
|
||||
{busy === "team" && <span className="choice__busy">正在切换…</span>}
|
||||
</button>
|
||||
|
||||
<button className="choice" disabled={busy !== null} onClick={() => void choose("referee")}>
|
||||
<span className="choice__name">裁判端</span>
|
||||
<span className="choice__desc">
|
||||
两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。不需要绑定,也不需要密码。
|
||||
</span>
|
||||
{busy === "referee" && <span className="choice__busy">正在切换…</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="gate__error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupPassword() {
|
||||
const [first, setFirst] = useState("");
|
||||
const [again, setAgain] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const input = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
input.current?.focus();
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
if (busy) return;
|
||||
|
||||
if (first !== again) {
|
||||
setError("两次输入不一致");
|
||||
setAgain("");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.setPassword(first);
|
||||
await api.refresh();
|
||||
pushNote("密码已设置。改摄像头/装甲板绑定、切换队伍号、退出都需要它。");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gate">
|
||||
<h1 className="gate__title">先设一个密码</h1>
|
||||
<p className="gate__hint">
|
||||
用户端改摄像头与装甲板绑定、切换队伍号、退出都要用它。至少 4 位,本机存的是
|
||||
Argon2id 哈希,不是明文。
|
||||
</p>
|
||||
|
||||
<div className="gate__form">
|
||||
<input
|
||||
ref={input}
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
autoComplete="new-password"
|
||||
value={first}
|
||||
onChange={(event) => setFirst(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="再输一次"
|
||||
autoComplete="new-password"
|
||||
value={again}
|
||||
onChange={(event) => setAgain(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void submit();
|
||||
}}
|
||||
/>
|
||||
{error && <p className="gate__error">{error}</p>}
|
||||
<button className="button button--primary" onClick={() => void submit()} disabled={busy}>
|
||||
{busy ? "保存中…" : "设置密码并继续"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="gate__note">
|
||||
忘了密码的处理:删掉配置文件里的 password 一行,或者删掉整个配置文件。旧客户端
|
||||
(Slint 版)的配置会在首次启动时自动导入并备份成 .bak。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 底部胶囊血条。
|
||||
*
|
||||
* # 为什么它在画面**下面**而不是叠在画面上
|
||||
*
|
||||
* 用户端要的是"一眼看到还剩多少血",而不是把画面挡住。放在画面下沿一条窄带里,
|
||||
* 视频区域一个像素都不占;裁判端并排两路时也正好对称。
|
||||
*
|
||||
* # 未接入时不显示 0
|
||||
*
|
||||
* `available` 为 false 时显示占位说明而不是 0:0 是一个看起来像真值的假值,现场看到
|
||||
* 两个 0 会以为对方已经没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { teamColor, withAlpha } from "../lib/format";
|
||||
import type { HealthConfig, TeamHealthDto } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
health: TeamHealthDto;
|
||||
config: HealthConfig;
|
||||
/** 紧凑模式:裁判端两路并排时用。 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** 脉动的周期。与 CSS 动画保持一致。 */
|
||||
const PULSE_MS = 900;
|
||||
|
||||
export function HealthBar({ health, config, compact = false }: Props) {
|
||||
const [pulseOn, setPulseOn] = useState(true);
|
||||
|
||||
// 低血脉动:只有真的低血时才起定时器,否则一个空转的 setInterval 会让整个界面
|
||||
// 每秒重渲染一次。
|
||||
const pulsing = config.pulse && health.available && health.critical;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pulsing) {
|
||||
setPulseOn(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => setPulseOn((on) => !on), PULSE_MS / 2);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [pulsing]);
|
||||
|
||||
const color = health.critical ? "#ff5f5f" : teamColor(health.team);
|
||||
const total = 100;
|
||||
const ratio = health.available ? Math.max(0, Math.min(1, (health.hp ?? 0) / total)) : 0;
|
||||
|
||||
return (
|
||||
<div className={`healthbar${compact ? " healthbar--compact" : ""}`}>
|
||||
<div
|
||||
className="healthbar__track"
|
||||
style={{ opacity: pulsing && !pulseOn ? 0.35 : 1 }}
|
||||
title={health.available ? undefined : health.placeholder}
|
||||
>
|
||||
<div
|
||||
className="healthbar__fill"
|
||||
style={{
|
||||
width: `${ratio * 100}%`,
|
||||
background: `linear-gradient(90deg, ${withAlpha(color, 0.75)}, ${color})`,
|
||||
boxShadow: `0 0 12px ${withAlpha(color, 0.45)}`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 低血刻度:一根竖线标出阈值,让人知道"还有多久开始报警"。 */}
|
||||
{config.lowThreshold > 0 && config.lowThreshold < total && (
|
||||
<div
|
||||
className="healthbar__threshold"
|
||||
style={{ left: `${(config.lowThreshold / total) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="healthbar__labels">
|
||||
{config.showTeamName && <span className="healthbar__team">{health.name}</span>}
|
||||
|
||||
{health.available ? (
|
||||
config.showNumbers && (
|
||||
<span className="healthbar__value" style={{ color }}>
|
||||
{health.hp}
|
||||
{health.critical && <span className="healthbar__dead"> · 已击毁</span>}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="healthbar__missing">{health.placeholder}</span>
|
||||
)}
|
||||
|
||||
{health.available && health.boardText && (
|
||||
<span className="healthbar__board" title="绑定的装甲板主板">
|
||||
{compact ? "" : "装甲板 "}
|
||||
{health.boardText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 命中反馈的整屏层:闪红 + 中央提示。
|
||||
*
|
||||
* 抖动与伤害数字由 `VideoSurface` 画在 canvas 上(它们要跟着瞄准点走),这里只负责
|
||||
* 那一层全屏的、不需要对准任何东西的视觉反馈。
|
||||
*
|
||||
* `key={hit.seq}` 是让 CSS 动画每次都从头开始的关键:同一个元素上换属性不会重启动画,
|
||||
* 于是第二下命中就再也闪不出来了——一个"第一次有效"的 bug。
|
||||
*/
|
||||
|
||||
import type { HitPulseRecord } from "../lib/store";
|
||||
import type { HitFeedbackConfig } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
hit: HitPulseRecord | null;
|
||||
config: HitFeedbackConfig;
|
||||
/** 这一下是不是打在我这一边(裁判端两边都显示,但只有相关那一路闪)。 */
|
||||
relevant: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function HitFlash({ hit, config, relevant, label }: Props) {
|
||||
if (!hit || !relevant || !config.flash) return null;
|
||||
|
||||
const strength = Math.min(0.55, 0.16 * config.intensity);
|
||||
|
||||
return (
|
||||
<div key={hit.seq} className="hit-flash" style={{ ["--flash" as string]: strength }}>
|
||||
{label && <div className="hit-flash__label">{label}</div>}
|
||||
<div className="hit-flash__value">
|
||||
-{hit.damage}
|
||||
<span className="hit-flash__sub">
|
||||
{hit.from} → {hit.to}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 解锁对话框。
|
||||
*
|
||||
* 它只是**入口**,不是闸门:真正拦住改绑的是后端的 `require_unlocked`。所以这里即使
|
||||
* 被人绕过、甚至被改成永远返回成功,配置也改不动。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "../lib/ipc";
|
||||
|
||||
interface Props {
|
||||
/** 为什么需要密码:显示在标题下。 */
|
||||
reason: string;
|
||||
onUnlocked: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function UnlockDialog({ reason, onUnlocked, onCancel }: Props) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const input = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
input.current?.focus();
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
if (busy) return;
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const ok = await api.unlock(password);
|
||||
if (ok) {
|
||||
onUnlocked();
|
||||
} else {
|
||||
setError("密码不对");
|
||||
setPassword("");
|
||||
input.current?.focus();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2 className="dialog__title">需要密码</h2>
|
||||
<p className="dialog__hint">{reason}</p>
|
||||
|
||||
<input
|
||||
ref={input}
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
placeholder="密码"
|
||||
autoComplete="off"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void submit();
|
||||
if (event.key === "Escape") onCancel();
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <p className="dialog__error">{error}</p>}
|
||||
|
||||
<p className="dialog__note">
|
||||
密码只挡现场随手改绑,不是安全边界:配置文件与程序都在本机,愿意改的人绕得过去。
|
||||
忘了密码就删掉配置里的 password 一行。
|
||||
</p>
|
||||
|
||||
<div className="dialog__actions">
|
||||
<button className="button button--ghost" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button className="button button--primary" onClick={() => void submit()} disabled={busy}>
|
||||
{busy ? "校验中…" : "解锁"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* 一路画面:canvas 绘制 + 缩放/平移 + 网格 + 瞄准器 + 命中反馈。
|
||||
*
|
||||
* # 为什么画在 canvas 上而不是用 CSS 变换 `<img>`
|
||||
*
|
||||
* 瞄准器必须跟着**画面**走:它标的是相机光轴上的一个点。用 DOM 摆一个 `<img>` 再叠一层
|
||||
* `transform` 的话,"图像落在哪、瞄准点落在哪"要算两遍,而两份实现迟早会不一致——表现
|
||||
* 是放大之后瞄准器偏了,恰恰发生在这件事最要紧的时候。
|
||||
*
|
||||
* 所以变换只算一次(`layout()`),图像、网格、瞄准器、伤害数字全都用同一组数画出来。
|
||||
*
|
||||
* # 尺寸与缩放的关系
|
||||
*
|
||||
* - 图像按 `contain` 放进窗格,再乘 `zoom`。
|
||||
* - **网格**画在图像矩形里,跟着缩放走:它标的是画面里的比例,用来估距离。
|
||||
* - **瞄准器**的**位置**跟着图像走,**大小**不跟着:像真瞄具一样,放大之后准星不会
|
||||
* 跟着变粗,而是覆盖画面里更小的一块——那正是放大有用武之地的原因。
|
||||
* - 镜像靠 `scale(-1, 1)`,同时把拖动方向也翻过来,否则向右拖画面会往左走。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
import { streamClient } from "../lib/store";
|
||||
import { withAlpha } from "../lib/format";
|
||||
import type { CrosshairConfig, GridConfig, HitFeedbackConfig } from "../lib/types";
|
||||
|
||||
export interface ViewTransform {
|
||||
zoom: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export const IDENTITY_VIEW: ViewTransform = { zoom: 1, x: 0, y: 0 };
|
||||
|
||||
/** 缩放上下限。再多只是把像素拉大。 */
|
||||
export const MIN_ZOOM = 1;
|
||||
export const MAX_ZOOM = 8;
|
||||
export const ZOOM_STEP = 1.25;
|
||||
|
||||
export interface HitPulse {
|
||||
/** 每次命中都不同,用来重启动画。 */
|
||||
seq: number;
|
||||
damage: number;
|
||||
/** `performance.now()` 时刻。 */
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
camera: number | null;
|
||||
emptyText: string;
|
||||
mirrored: boolean;
|
||||
crosshair: CrosshairConfig | null;
|
||||
grid: GridConfig | null;
|
||||
view: ViewTransform;
|
||||
onView: (view: ViewTransform) => void;
|
||||
/** 校准模式:拖动画面来挪瞄准点。 */
|
||||
calibrating?: boolean;
|
||||
onCalibrate?: (dxFraction: number, dyFraction: number) => void;
|
||||
/** 命中脉冲。只有属于这一路的命中才该传进来。 */
|
||||
hit?: HitPulse | null;
|
||||
hitFeedback: HitFeedbackConfig;
|
||||
showZoom: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function VideoSurface({
|
||||
camera,
|
||||
emptyText,
|
||||
mirrored,
|
||||
crosshair,
|
||||
grid,
|
||||
view,
|
||||
onView,
|
||||
calibrating = false,
|
||||
onCalibrate,
|
||||
hit = null,
|
||||
hitFeedback,
|
||||
showZoom,
|
||||
children,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
// 绘制循环不随 props 重建,所以最新一份 props 放在 ref 里给它读。
|
||||
const props = useRef({ camera, mirrored, crosshair, grid, view, hit, hitFeedback });
|
||||
props.current = { camera, mirrored, crosshair, grid, view, hit, hitFeedback };
|
||||
|
||||
const drag = useRef<{ x: number; y: number; originX: number; originY: number } | null>(null);
|
||||
const dirty = useRef(true);
|
||||
const lastFrameId = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dirty.current = true;
|
||||
}, [camera, mirrored, crosshair, grid, view, hit, hitFeedback]);
|
||||
|
||||
// canvas 的像素尺寸跟着 CSS 尺寸与 DPR 走。不这么做的话在 Retina 上会糊。
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const resize = () => {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
|
||||
const height = Math.max(1, Math.round(canvas.clientHeight * dpr));
|
||||
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
dirty.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
resize();
|
||||
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
let raf = 0;
|
||||
|
||||
const loop = (timestamp: number) => {
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
const current = props.current;
|
||||
const frame = streamClient().latest(current.camera);
|
||||
|
||||
if (frame && frame.frameId !== lastFrameId.current) {
|
||||
lastFrameId.current = frame.frameId;
|
||||
dirty.current = true;
|
||||
}
|
||||
|
||||
// 命中动画期间必须持续重绘,否则抖动只画了一帧就停住。
|
||||
const pulseAge = current.hit ? timestamp - current.hit.at : Number.POSITIVE_INFINITY;
|
||||
const animating =
|
||||
pulseAge < 700 &&
|
||||
(current.hitFeedback.shake || current.hitFeedback.marker || current.hitFeedback.flash);
|
||||
|
||||
if (!dirty.current && !animating) return;
|
||||
dirty.current = false;
|
||||
|
||||
draw(canvas, current, frame, timestamp);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
// ---- 输入 ----
|
||||
|
||||
const onWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||
const factor = event.deltaY > 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
|
||||
const zoom = clamp(view.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
|
||||
// 缩回 1 时顺便归位:此时画面完整可见,平移没有意义,留着会让下一次放大从偏移位置
|
||||
// 开始,看起来像"跳了一下"。
|
||||
onView(zoom <= MIN_ZOOM ? IDENTITY_VIEW : { ...view, zoom });
|
||||
};
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
drag.current = { x: event.clientX, y: event.clientY, originX: view.x, originY: view.y };
|
||||
};
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const start = drag.current;
|
||||
if (!start) return;
|
||||
|
||||
const dx = event.clientX - start.x;
|
||||
const dy = event.clientY - start.y;
|
||||
|
||||
if (calibrating) {
|
||||
// 校准:把屏幕上的位移换算成画面里的比例位移。用当前显示出来的图像尺寸做分母,
|
||||
// 所以放大之后校准更精细——这正是需要的。
|
||||
const box = canvasRef.current;
|
||||
if (!box || !onCalibrate) return;
|
||||
|
||||
const frame = streamClient().latest(camera);
|
||||
if (!frame) return;
|
||||
|
||||
const { drawWidth, drawHeight } = layout(box, frame.width, frame.height, view);
|
||||
if (drawWidth <= 0 || drawHeight <= 0) return;
|
||||
|
||||
// 一次移动一小步,然后把起点挪过来。
|
||||
drag.current = { ...start, x: event.clientX, y: event.clientY };
|
||||
const sign = mirrored ? -1 : 1;
|
||||
onCalibrate((sign * dx) / drawWidth, dy / drawHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
if (view.zoom <= MIN_ZOOM) return;
|
||||
|
||||
// 镜像时把横向拖动也翻过来:否则向右拖,画面往左走,看起来像拖动坏了。
|
||||
const sign = mirrored ? -1 : 1;
|
||||
onView({ ...view, x: start.originX + sign * dx, y: start.originY + dy });
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
drag.current = null;
|
||||
};
|
||||
|
||||
const cursor = calibrating ? "crosshair" : view.zoom > MIN_ZOOM ? "grab" : "default";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="video-surface"
|
||||
style={{ cursor }}
|
||||
onWheel={onWheel}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onDoubleClick={() => onView(IDENTITY_VIEW)}
|
||||
>
|
||||
<canvas ref={canvasRef} className="video-canvas" />
|
||||
|
||||
{emptyText !== "" && <div className="video-empty">{emptyText}</div>}
|
||||
|
||||
{showZoom && view.zoom > MIN_ZOOM + 0.05 && (
|
||||
<div className="video-badge video-badge--zoom">×{view.zoom.toFixed(2)}</div>
|
||||
)}
|
||||
|
||||
{(view.zoom > MIN_ZOOM + 0.05 || view.x !== 0 || view.y !== 0) && (
|
||||
<button className="video-badge video-badge--reset" onClick={() => onView(IDENTITY_VIEW)}>
|
||||
复位
|
||||
</button>
|
||||
)}
|
||||
|
||||
{calibrating && <div className="video-calibrating">校准中:拖动画面挪动瞄准点</div>}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 绘制
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CanvasProps {
|
||||
camera: number | null;
|
||||
mirrored: boolean;
|
||||
crosshair: CrosshairConfig | null;
|
||||
grid: GridConfig | null;
|
||||
view: ViewTransform;
|
||||
hit: HitPulse | null;
|
||||
hitFeedback: HitFeedbackConfig;
|
||||
}
|
||||
|
||||
interface Box {
|
||||
clientWidth: number;
|
||||
clientHeight: number;
|
||||
}
|
||||
|
||||
/** 图像在窗格里的落点与大小(CSS 像素)。 */
|
||||
function layout(box: Box, frameWidth: number, frameHeight: number, view: ViewTransform) {
|
||||
const width = box.clientWidth;
|
||||
const height = box.clientHeight;
|
||||
|
||||
if (frameWidth <= 0 || frameHeight <= 0) {
|
||||
return { left: 0, top: 0, drawWidth: 0, drawHeight: 0 };
|
||||
}
|
||||
|
||||
const fit = Math.min(width / frameWidth, height / frameHeight);
|
||||
const drawWidth = frameWidth * fit * view.zoom;
|
||||
const drawHeight = frameHeight * fit * view.zoom;
|
||||
|
||||
return {
|
||||
left: (width - drawWidth) / 2 + view.x,
|
||||
top: (height - drawHeight) / 2 + view.y,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function draw(
|
||||
canvas: HTMLCanvasElement,
|
||||
current: CanvasProps,
|
||||
frame: { bitmap: ImageBitmap; width: number; height: number } | undefined,
|
||||
timestamp: number,
|
||||
) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const box = { clientWidth: width, clientHeight: height };
|
||||
|
||||
// 没有画面时把瞄准器留在窗格中心:录像断了,准星不该跟着消失。
|
||||
const fallback = { left: 0, top: 0, drawWidth: 0, drawHeight: 0 };
|
||||
const geometry = frame
|
||||
? layout(box, frame.width, frame.height, current.view)
|
||||
: fallback;
|
||||
|
||||
const shake = current.hit ? shakeOffset(current.hit, timestamp, current.hitFeedback) : { x: 0, y: 0 };
|
||||
|
||||
if (frame) {
|
||||
ctx.save();
|
||||
ctx.translate(shake.x, shake.y);
|
||||
|
||||
if (current.mirrored) {
|
||||
ctx.translate(width, 0);
|
||||
ctx.scale(-1, 1);
|
||||
}
|
||||
|
||||
// 放大超过 3 倍时关掉插值:这时候要看的是像素本身,双线性插值只会把它糊掉。
|
||||
ctx.imageSmoothingEnabled = current.view.zoom < 3;
|
||||
ctx.drawImage(frame.bitmap, geometry.left, geometry.top, geometry.drawWidth, geometry.drawHeight);
|
||||
ctx.restore();
|
||||
|
||||
if (current.grid?.visible) {
|
||||
drawGrid(ctx, geometry, current.grid, shake);
|
||||
}
|
||||
}
|
||||
|
||||
// 瞄准点的屏幕位置:图像坐标系里的 (0.5 + offsetX, 0.5 + offsetY)。
|
||||
const crosshair = current.crosshair;
|
||||
let aimX = width / 2;
|
||||
let aimY = height / 2;
|
||||
|
||||
if (crosshair && frame && geometry.drawWidth > 0) {
|
||||
const rawX = geometry.left + (0.5 + crosshair.offsetX) * geometry.drawWidth;
|
||||
aimX = current.mirrored ? width - rawX : rawX;
|
||||
aimY = geometry.top + (0.5 + crosshair.offsetY) * geometry.drawHeight;
|
||||
}
|
||||
|
||||
if (crosshair?.visible) {
|
||||
drawCrosshair(ctx, aimX + shake.x, aimY + shake.y, Math.min(width, height), crosshair);
|
||||
}
|
||||
|
||||
// 伤害数字:画在屏幕坐标里,不跟着缩放走。
|
||||
if (current.hit && current.hitFeedback.marker) {
|
||||
const age = timestamp - current.hit.at;
|
||||
if (age >= 0 && age < 700) {
|
||||
const progress = age / 700;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 1 - progress;
|
||||
ctx.fillStyle = "#ff8a8a";
|
||||
ctx.font = `600 ${Math.round(Math.max(14, Math.min(width, height) * 0.05))}px ui-sans-serif, system-ui, sans-serif`;
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(
|
||||
`-${current.hit.damage}`,
|
||||
aimX + shake.x + 24,
|
||||
aimY + shake.y - 26 - progress * 26,
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shakeOffset(hit: HitPulse, timestamp: number, feedback: HitFeedbackConfig) {
|
||||
if (!feedback.shake) return { x: 0, y: 0 };
|
||||
|
||||
const age = timestamp - hit.at;
|
||||
if (age < 0 || age > 400) return { x: 0, y: 0 };
|
||||
|
||||
const amplitude = 7 * feedback.intensity * (1 - age / 400);
|
||||
return {
|
||||
x: Math.sin(age * 0.09) * amplitude,
|
||||
y: Math.cos(age * 0.11) * amplitude,
|
||||
};
|
||||
}
|
||||
|
||||
function drawGrid(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
geometry: { left: number; top: number; drawWidth: number; drawHeight: number },
|
||||
grid: GridConfig,
|
||||
shake: { x: number; y: number },
|
||||
) {
|
||||
if (geometry.drawWidth <= 0 || geometry.drawHeight <= 0) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(shake.x, shake.y);
|
||||
ctx.strokeStyle = `rgba(255, 255, 255, ${grid.opacity})`;
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
const x0 = geometry.left;
|
||||
const y0 = geometry.top;
|
||||
const x1 = x0 + geometry.drawWidth;
|
||||
const y1 = y0 + geometry.drawHeight;
|
||||
|
||||
if (grid.style === "thirds") {
|
||||
for (const fraction of [1 / 3, 2 / 3]) {
|
||||
line(ctx, x0 + geometry.drawWidth * fraction, y0, x0 + geometry.drawWidth * fraction, y1);
|
||||
line(ctx, x0, y0 + geometry.drawHeight * fraction, x1, y0 + geometry.drawHeight * fraction);
|
||||
}
|
||||
} else if (grid.style === "squares") {
|
||||
const divisions = Math.max(2, grid.divisions);
|
||||
for (let i = 1; i < divisions; i += 1) {
|
||||
const fraction = i / divisions;
|
||||
line(ctx, x0 + geometry.drawWidth * fraction, y0, x0 + geometry.drawWidth * fraction, y1);
|
||||
line(ctx, x0, y0 + geometry.drawHeight * fraction, x1, y0 + geometry.drawHeight * fraction);
|
||||
}
|
||||
} else {
|
||||
// 中心十字。
|
||||
line(ctx, x0 + geometry.drawWidth / 2, y0, x0 + geometry.drawWidth / 2, y1);
|
||||
line(ctx, x0, y0 + geometry.drawHeight / 2, x1, y0 + geometry.drawHeight / 2);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function line(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number) {
|
||||
// 半像素偏移,让 1px 的线落在像素中心而不是跨两个像素发虚。
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.round(x0) + 0.5, Math.round(y0) + 0.5);
|
||||
ctx.lineTo(Math.round(x1) + 0.5, Math.round(y1) + 0.5);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawCrosshair(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
shortSide: number,
|
||||
config: CrosshairConfig,
|
||||
) {
|
||||
const arm = Math.max(3, config.size * shortSide);
|
||||
const gap = config.gap * shortSide;
|
||||
const thickness = Math.max(1, config.thickness * shortSide);
|
||||
|
||||
// 外描一圈深色:浅色背景下亮绿准星会消失,这一圈让它始终看得见。
|
||||
const halo = withAlpha("#000000", 0.55);
|
||||
|
||||
const stroke = (color: string, width: number, radius: number) => {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.beginPath();
|
||||
|
||||
if (config.style === "circle-dot") {
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
} else {
|
||||
// 四臂,中心留 gap 的空隙。
|
||||
ctx.moveTo(cx - radius, cy);
|
||||
ctx.lineTo(cx - gap, cy);
|
||||
ctx.moveTo(cx + gap, cy);
|
||||
ctx.lineTo(cx + radius, cy);
|
||||
ctx.moveTo(cx, cy - radius);
|
||||
ctx.lineTo(cx, cy - gap);
|
||||
ctx.moveTo(cx, cy + gap);
|
||||
ctx.lineTo(cx, cy + radius);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
stroke(halo, thickness + 2, arm + 1);
|
||||
stroke(config.color, thickness, arm);
|
||||
|
||||
// 中心点:`cross` 之外都画。
|
||||
if (config.style !== "cross") {
|
||||
const dot = Math.max(1.2, thickness * 0.9);
|
||||
ctx.fillStyle = halo;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, dot + 1, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = config.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, dot, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/** 设置面板里复用的几个小控件。分成单独文件只是为了不把面板本身撑成一千行。 */
|
||||
|
||||
import type { ChangeEvent, ReactNode } from "react";
|
||||
|
||||
export function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="section">
|
||||
<h3 className="section__title">{title}</h3>
|
||||
{hint && <p className="section__hint">{hint}</p>}
|
||||
<div className="section__body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field__label">{label}</span>
|
||||
<span className="field__control">{children}</span>
|
||||
{hint && <span className="field__hint">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
disabled = false,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className={`switch${disabled ? " switch--disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => onChange(event.target.checked)}
|
||||
/>
|
||||
<span className="switch__track">
|
||||
<span className="switch__knob" />
|
||||
</span>
|
||||
<span className="switch__label">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Slider({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
onChange,
|
||||
format,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
onChange: (value: number) => void;
|
||||
format?: (value: number) => string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="slider">
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
<span className="slider__value">{format ? format(value) : value.toFixed(2)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select<T extends string | number>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: T | null;
|
||||
options: { value: T; label: string; hint?: string }[];
|
||||
onChange: (value: T) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className="select"
|
||||
value={value === null ? "" : String(value)}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value;
|
||||
const match = options.find((option) => String(option.value) === raw);
|
||||
if (match) onChange(match.value);
|
||||
}}
|
||||
>
|
||||
{value === null && <option value="">(未选择)</option>}
|
||||
{options.map((option) => (
|
||||
<option key={String(option.value)} value={String(option.value)}>
|
||||
{option.hint ? `${option.label} — ${option.hint}` : option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
onClick,
|
||||
variant = "default",
|
||||
disabled = false,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "primary" | "danger" | "ghost";
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button className={`button button--${variant}`} onClick={onClick} disabled={disabled} title={title}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一行只读信息:左边标题、右边值。 */
|
||||
export function Readout({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="readout">
|
||||
<span className="readout__label">{label}</span>
|
||||
<span className="readout__value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/** 纯格式化函数。界面里所有"给人看的数字"都从这里过一遍。 */
|
||||
|
||||
/** `0x3D4D8A9A`。与 Rust 侧的 `snapshot::id_text` 输出一致。 */
|
||||
export function idText(id: number | null | undefined): string {
|
||||
if (id === null || id === undefined) return "—";
|
||||
return `0x${id.toString(16).toUpperCase().padStart(8, "0")}`;
|
||||
}
|
||||
|
||||
/** 22000 → `21.5 kB`。 */
|
||||
export function bytes(value: number): string {
|
||||
if (value <= 0) return "—";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} kB`;
|
||||
return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
/** 毫秒 → 人话。 */
|
||||
export function elapsed(ms: number | null | undefined): string {
|
||||
if (ms === null || ms === undefined) return "—";
|
||||
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||||
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes < 60) return `${minutes} 分 ${seconds} 秒`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours} 小时 ${minutes % 60} 分`;
|
||||
}
|
||||
|
||||
/** 录制时长:从开始到现在的墙钟。 */
|
||||
export function since(ms: number | null | undefined, now = Date.now()): string {
|
||||
if (!ms) return "—";
|
||||
return elapsed(now - ms);
|
||||
}
|
||||
|
||||
/** Unix 毫秒 → `14:03:22`。 */
|
||||
export function clockTime(ms: number | null | undefined): string {
|
||||
if (!ms) return "—";
|
||||
|
||||
const date = new Date(ms);
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** 小数位固定的帧率。0 表示"还没读数",显示成 `—` 而不是 `0.0`。 */
|
||||
export function fps(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "—";
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
/** 队伍名。与 Rust 侧的 `team_name` 一致。 */
|
||||
export function teamName(team: number): string {
|
||||
return team === 0 ? "队伍一" : "队伍二";
|
||||
}
|
||||
|
||||
/** 队伍配色:与装甲板灯色一致(队伍一红、队伍二蓝)。 */
|
||||
export function teamColor(team: number): string {
|
||||
return team === 0 ? "#ff5f5f" : "#5aa9ff";
|
||||
}
|
||||
|
||||
/** 把 `#RRGGBB` 变成 `rgba(...)`。瞄准器颜色要带透明度时用。 */
|
||||
export function withAlpha(hex: string, alpha: number): string {
|
||||
const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!match) return hex;
|
||||
|
||||
const value = parseInt(match[1], 16);
|
||||
const r = (value >> 16) & 0xff;
|
||||
const g = (value >> 8) & 0xff;
|
||||
const b = value & 0xff;
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 后端命令的封装。
|
||||
*
|
||||
* 每个函数对应 `desktop/src-tauri/src/commands.rs` 里的一个 `#[tauri::command]`。
|
||||
* Tauri 会把 JS 侧的 camelCase 参数名转成 Rust 侧的 snake_case,所以这里的参数名写成
|
||||
* 前端习惯的样子即可。
|
||||
*
|
||||
* # 错误
|
||||
*
|
||||
* 后端的 `Err(String)` 会以 rejected promise 的形式回来,字符串就是给现场的人看的那句
|
||||
* 话。`call()` 把 `unknown` 归一成 `Error`,调用方只管 `catch (err) { err.message }`。
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
ConfigView,
|
||||
HudView,
|
||||
MediaFileDto,
|
||||
RecordingDto,
|
||||
Role,
|
||||
Snapshot,
|
||||
StreamInfo,
|
||||
} from "./types";
|
||||
|
||||
/** 把后端的字符串错误变成真正的 `Error`。 */
|
||||
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
try {
|
||||
return await invoke<T>(command, args);
|
||||
} catch (raw) {
|
||||
throw new Error(typeof raw === "string" ? raw : String(raw));
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// ---- 只读 ----
|
||||
streamInfo: () => call<StreamInfo>("stream_info"),
|
||||
snapshot: () => call<Snapshot>("get_snapshot"),
|
||||
config: () => call<ConfigView>("get_config"),
|
||||
mediaFiles: () => call<MediaFileDto[]>("list_media_files"),
|
||||
configPath: () => call<string>("config_path"),
|
||||
|
||||
// ---- 密码 ----
|
||||
unlock: (password: string) => call<boolean>("unlock", { password }),
|
||||
lock: () => call<void>("lock"),
|
||||
setPassword: (fresh: string, current?: string) =>
|
||||
call<ConfigView>("set_password", { current: current ?? null, fresh }),
|
||||
setDiscoveryOpen: (open: boolean) => call<void>("set_discovery_open", { open }),
|
||||
|
||||
// ---- 绑定与身份 ----
|
||||
bindCamera: (cameraId: number | null, password?: string) =>
|
||||
call<ConfigView>("bind_camera", { cameraId, password: password ?? null }),
|
||||
bindArmor: (armorId: number | null, password?: string) =>
|
||||
call<ConfigView>("bind_armor", { armorId, password: password ?? null }),
|
||||
setTeam: (team: number, password?: string) =>
|
||||
call<ConfigView>("set_team", { team, password: password ?? null }),
|
||||
setRole: (role: Role, password?: string) =>
|
||||
call<ConfigView>("set_role", { role, password: password ?? null }),
|
||||
setMirror: (mirror: boolean) => call<ConfigView>("set_mirror", { mirror }),
|
||||
saveHud: (hud: HudView) => call<ConfigView>("save_hud", { hud }),
|
||||
setRefereeSlots: (slots: {
|
||||
cameraA: number | null;
|
||||
cameraB: number | null;
|
||||
armorA: number | null;
|
||||
armorB: number | null;
|
||||
manual: boolean;
|
||||
}) => call<ConfigView>("set_referee_slots", slots),
|
||||
setRecordingOptions: (options: {
|
||||
dir: string | null;
|
||||
ffmpeg: string | null;
|
||||
crf: number | null;
|
||||
preset: string | null;
|
||||
}) => call<ConfigView>("set_recording_options", options),
|
||||
|
||||
// ---- 截图与录制 ----
|
||||
screenshot: (camera: number) => call<string>("screenshot", { camera }),
|
||||
startRecording: (cameras: number[]) => call<RecordingDto[]>("start_recording", { cameras }),
|
||||
stopRecording: (cameras: number[]) => call<RecordingDto[]>("stop_recording", { cameras }),
|
||||
reveal: (path: string) => call<void>("reveal", { path }),
|
||||
|
||||
// ---- 退出与重置 ----
|
||||
clearConfig: (password?: string) =>
|
||||
call<string>("clear_config", { password: password ?? null }),
|
||||
quit: (password?: string) => call<void>("quit_app", { password: password ?? null }),
|
||||
|
||||
refresh: () => call<void>("refresh"),
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 后端状态的订阅式存储。
|
||||
*
|
||||
* 后端每 200 ms 推一份完整快照,前端只做渲染——没有轮询、没有"各处分别拉数据"带来的
|
||||
* 两种时刻的状态混在一起。实现用一个模块级单例 + `useSyncExternalStore`。
|
||||
*
|
||||
* 快照对象**每次整体替换**:`useSyncExternalStore` 靠引用比较判断要不要重渲染,就地
|
||||
* 修改字段不会触发更新。
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import { StreamClient } from "./stream";
|
||||
import type { HitEvent, Snapshot, WireMsg } from "./types";
|
||||
|
||||
/**
|
||||
* 一次掉血在界面上的记录。
|
||||
*
|
||||
* `receivedAt` 用 `performance.now()` 而不是后端给的 `atMs`(Unix 毫秒):动画的时钟必须
|
||||
* 与 `requestAnimationFrame` 的时间戳同源,把两个时钟相减会得到一个荒唐的年龄,动画也
|
||||
* 就不会出现。
|
||||
*/
|
||||
export interface HitPulseRecord extends HitEvent {
|
||||
seq: number;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
export interface AppStore {
|
||||
/** 还没收到第一份快照时为 null。 */
|
||||
snapshot: Snapshot | null;
|
||||
/** 与后端的视频通道是否连着。 */
|
||||
connected: boolean;
|
||||
/** 最近一次掉血,界面用它做闪红/抖动。 */
|
||||
lastHit: HitPulseRecord | null;
|
||||
/** 后端要求弹出解锁框(用户端点关闭窗口时)。 */
|
||||
confirmQuit: number;
|
||||
/** 一条条要显示的说明。 */
|
||||
notes: string[];
|
||||
/** 通道本身的问题(连不上、丢帧)。 */
|
||||
channelError: string | null;
|
||||
}
|
||||
|
||||
const initial: AppStore = {
|
||||
snapshot: null,
|
||||
connected: false,
|
||||
lastHit: null,
|
||||
confirmQuit: 0,
|
||||
notes: [],
|
||||
channelError: null,
|
||||
};
|
||||
|
||||
let store: AppStore = initial;
|
||||
let hitSeq = 0;
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(next: Partial<AppStore>) {
|
||||
store = { ...store, ...next };
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** 组件里读状态。 */
|
||||
export function useAppStore(): AppStore {
|
||||
return useSyncExternalStore(subscribe, () => store);
|
||||
}
|
||||
|
||||
/** 组件外读状态(事件处理、命令回调里用)。 */
|
||||
export function currentStore(): AppStore {
|
||||
return store;
|
||||
}
|
||||
|
||||
function handleMessage(msg: WireMsg) {
|
||||
switch (msg.t) {
|
||||
case "snapshot": {
|
||||
const { t: _tag, ...snapshot } = msg;
|
||||
emit({ snapshot: snapshot as Snapshot });
|
||||
return;
|
||||
}
|
||||
case "hit":
|
||||
hitSeq += 1;
|
||||
emit({ lastHit: { ...msg, seq: hitSeq, receivedAt: performance.now() } });
|
||||
return;
|
||||
case "note":
|
||||
emit({ notes: [...store.notes, msg.message].slice(-6) });
|
||||
return;
|
||||
case "confirmQuit":
|
||||
emit({ confirmQuit: store.confirmQuit + 1 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let client: StreamClient | null = null;
|
||||
|
||||
/**
|
||||
* 建立与后端的通道。整个界面只调一次(在 `App` 里)。
|
||||
*/
|
||||
export function startStream(url: string): StreamClient {
|
||||
client?.close();
|
||||
|
||||
client = new StreamClient({
|
||||
onMessage: handleMessage,
|
||||
onConnection: (connected) =>
|
||||
emit({
|
||||
connected,
|
||||
channelError: connected ? null : "与后端的视频通道断开,正在重连…",
|
||||
}),
|
||||
});
|
||||
|
||||
client.connect(url);
|
||||
return client;
|
||||
}
|
||||
|
||||
export function streamClient(): StreamClient {
|
||||
if (!client) throw new Error("startStream 还没有被调用");
|
||||
return client;
|
||||
}
|
||||
|
||||
/** 手动塞一条说明(本地校验失败之类)。 */
|
||||
export function pushNote(message: string) {
|
||||
emit({ notes: [...store.notes, message].slice(-6) });
|
||||
}
|
||||
|
||||
/** 关掉一条说明。 */
|
||||
export function dismissNote(index: number) {
|
||||
emit({ notes: store.notes.filter((_, i) => i !== index) });
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 视频与状态通道的客户端。
|
||||
*
|
||||
* # 为什么是 WebSocket 而不是 invoke
|
||||
*
|
||||
* 两路 22fps、每帧约 22 kB,也就是每秒约 1 MB 的裸 JPEG。走 IPC 的话它要变成 base64
|
||||
* 再涨 1/3,然后在 JS 里解回来——CPU 花在没人需要的编解码上。二进制 WebSocket 帧把
|
||||
* 原始 JPEG 字节直接交给我们,交给 `createImageBitmap` 解码。
|
||||
*
|
||||
* # 解码是异步的,所以帧号要看着用
|
||||
*
|
||||
* `createImageBitmap` 返回 Promise,两个相邻帧的解码**可能**乱序完成。所以每路记住最后
|
||||
* 画过的帧号,只有更新的那帧才会被采用。没有这一步,画面会偶发地往回跳一帧——看起来
|
||||
* 像"卡了一下",极难归因。
|
||||
*
|
||||
* # `ImageBitmap` 必须显式 close
|
||||
*
|
||||
* 它持有解码后的像素(不吃 GC 的账)。每收到新帧就替换旧的那张并不够,必须把旧的那张
|
||||
* `close()` 掉,否则两路 22fps 跑上几分钟就是几百 MB 的显存/内存。
|
||||
*/
|
||||
|
||||
import { FRAME_HEADER_LEN, type WireMsg } from "./types";
|
||||
|
||||
/** 每路最新一张画面。 */
|
||||
export interface FrameInfo {
|
||||
camera: number;
|
||||
frameId: number;
|
||||
width: number;
|
||||
height: number;
|
||||
bitmap: ImageBitmap;
|
||||
/** 收到的时刻(`performance.now()`),界面用来显示端到端延迟。 */
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
interface Handlers {
|
||||
onMessage: (msg: WireMsg) => void;
|
||||
onConnection: (connected: boolean) => void;
|
||||
}
|
||||
|
||||
/** 断线重连的退避上限。 */
|
||||
const MAX_BACKOFF_MS = 3000;
|
||||
|
||||
export class StreamClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string | null = null;
|
||||
private readonly frames = new Map<number, FrameInfo>();
|
||||
private readonly handlers: Handlers;
|
||||
private retryTimer: number | null = null;
|
||||
private backoff = 250;
|
||||
private closed = false;
|
||||
/** 统计:收到多少帧、解码失败多少张。界面用它区分"链路没数据"和"数据坏了"。 */
|
||||
readonly counters = { frames: 0, decodeFailures: 0, reconnects: 0 };
|
||||
|
||||
constructor(handlers: Handlers) {
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
connect(url: string) {
|
||||
this.url = url;
|
||||
this.closed = false;
|
||||
this.open();
|
||||
}
|
||||
|
||||
private open() {
|
||||
if (!this.url || this.closed) return;
|
||||
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = new WebSocket(this.url);
|
||||
} catch {
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.binaryType = "arraybuffer";
|
||||
this.ws = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
this.backoff = 250;
|
||||
this.handlers.onConnection(true);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
this.handlers.onConnection(false);
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
// onerror 之后一定会有 onclose,重连交给那一条路径。
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === "string") {
|
||||
try {
|
||||
this.handlers.onMessage(JSON.parse(event.data) as WireMsg);
|
||||
} catch {
|
||||
// 前端解析不了的消息只可能是版本不匹配,忽略比崩掉好。
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void this.onBinary(event.data as ArrayBuffer);
|
||||
};
|
||||
}
|
||||
|
||||
private async onBinary(buffer: ArrayBuffer) {
|
||||
if (buffer.byteLength < FRAME_HEADER_LEN) return;
|
||||
|
||||
const view = new DataView(buffer);
|
||||
const camera = view.getUint32(0, true);
|
||||
const frameId = view.getUint32(4, true);
|
||||
const width = view.getUint16(8, true);
|
||||
const height = view.getUint16(10, true);
|
||||
const length = view.getUint32(12, true);
|
||||
|
||||
if (buffer.byteLength < FRAME_HEADER_LEN + length) return;
|
||||
|
||||
this.counters.frames += 1;
|
||||
|
||||
const previous = this.frames.get(camera);
|
||||
|
||||
// 乱序完成时丢弃更旧的那一帧。用带符号差处理 u32 回绕。
|
||||
if (previous && ((frameId - previous.frameId) | 0) <= 0) return;
|
||||
|
||||
const jpeg = new Uint8Array(buffer, FRAME_HEADER_LEN, length);
|
||||
let bitmap: ImageBitmap;
|
||||
|
||||
try {
|
||||
bitmap = await createImageBitmap(new Blob([jpeg], { type: "image/jpeg" }));
|
||||
} catch {
|
||||
this.counters.decodeFailures += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// 解码期间可能已经来了更新的帧:那就把刚解出来的这张丢掉。
|
||||
const current = this.frames.get(camera);
|
||||
if (current && ((frameId - current.frameId) | 0) <= 0) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
|
||||
current?.bitmap.close();
|
||||
|
||||
this.frames.set(camera, {
|
||||
camera,
|
||||
frameId,
|
||||
width,
|
||||
height,
|
||||
bitmap,
|
||||
receivedAt: performance.now(),
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.closed || this.retryTimer !== null) return;
|
||||
|
||||
this.retryTimer = window.setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
this.counters.reconnects += 1;
|
||||
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
||||
this.open();
|
||||
}, this.backoff);
|
||||
}
|
||||
|
||||
/** 某一路最新的一帧。 */
|
||||
latest(camera: number | null | undefined): FrameInfo | undefined {
|
||||
if (camera === null || camera === undefined) return undefined;
|
||||
return this.frames.get(camera);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
|
||||
if (this.retryTimer !== null) {
|
||||
window.clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
|
||||
for (const frame of this.frames.values()) frame.bitmap.close();
|
||||
this.frames.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 与 Rust 侧 DTO 一一对应的类型。
|
||||
*
|
||||
* 这些名字不是随手起的:Rust 那边的结构体带 `#[serde(rename_all = "camelCase")]`,
|
||||
* 字段名就是这里写的这样。改任何一边都要同时改另一边——**编译器不会帮你发现这件事**,
|
||||
* 所以两边都留了指向对方的注释。
|
||||
*
|
||||
* 对应的 Rust 定义在 `desktop/core/src/snapshot.rs` 与 `desktop/src-tauri/src/recording.rs`。
|
||||
*/
|
||||
|
||||
export type Role = "team" | "referee";
|
||||
|
||||
/** `cam_core::config::CrosshairStyle` */
|
||||
export type CrosshairStyle = "cross" | "cross-dot" | "circle-dot" | "dot";
|
||||
|
||||
/** `cam_core::config::GridStyle` */
|
||||
export type GridStyle = "thirds" | "squares" | "cross";
|
||||
|
||||
/** `cam_core::config::HealthConfig` */
|
||||
export interface HealthConfig {
|
||||
visible: boolean;
|
||||
showNumbers: boolean;
|
||||
showTeamName: boolean;
|
||||
lowThreshold: number;
|
||||
pulse: boolean;
|
||||
/** 血条宽度占画面宽度的比例。 */
|
||||
width: number;
|
||||
}
|
||||
|
||||
/** `cam_core::config::CrosshairConfig` */
|
||||
export interface CrosshairConfig {
|
||||
visible: boolean;
|
||||
style: CrosshairStyle;
|
||||
/** 臂长 / 半径,画面短边的比例。 */
|
||||
size: number;
|
||||
/** 线宽,画面短边的比例。 */
|
||||
thickness: number;
|
||||
/** 中心空隙,画面短边的比例。 */
|
||||
gap: number;
|
||||
/** `#RRGGBB` */
|
||||
color: string;
|
||||
/** 校准偏移,画面宽度的比例,正数向右。 */
|
||||
offsetX: number;
|
||||
/** 校准偏移,画面高度的比例,正数向下。 */
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
/** `cam_core::config::GridConfig` */
|
||||
export interface GridConfig {
|
||||
visible: boolean;
|
||||
style: GridStyle;
|
||||
divisions: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
/** `cam_core::config::HitFeedbackConfig` */
|
||||
export interface HitFeedbackConfig {
|
||||
flash: boolean;
|
||||
shake: boolean;
|
||||
marker: boolean;
|
||||
intensity: number;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::HudView` */
|
||||
export interface HudView {
|
||||
health: HealthConfig;
|
||||
crosshair: CrosshairConfig;
|
||||
grid: GridConfig;
|
||||
hitFeedback: HitFeedbackConfig;
|
||||
showZoom: boolean;
|
||||
showStats: boolean;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::ConfigView`(不含密码哈希) */
|
||||
export interface ConfigView {
|
||||
role: Role;
|
||||
roleChosen: boolean;
|
||||
team: number;
|
||||
cameraId: number | null;
|
||||
cameraIdText: string | null;
|
||||
armorId: number | null;
|
||||
armorIdText: string | null;
|
||||
mirror: boolean;
|
||||
passwordSet: boolean;
|
||||
hud: HudView;
|
||||
recordingDir: string;
|
||||
ffmpeg: string;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::CameraDto` */
|
||||
export interface CameraDto {
|
||||
id: number;
|
||||
idText: string;
|
||||
addr: string;
|
||||
online: boolean;
|
||||
silenceMs: number;
|
||||
firmware: number;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::ArmorBoardDto` */
|
||||
export interface ArmorBoardDto {
|
||||
id: number;
|
||||
idText: string;
|
||||
addr: string;
|
||||
online: boolean;
|
||||
team: number;
|
||||
hp: number;
|
||||
platesSeen: number;
|
||||
reportsAllPlates: boolean;
|
||||
silenceMs: number;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::CameraStatsDto` */
|
||||
export interface CameraStatsDto {
|
||||
camera: number;
|
||||
cameraText: string;
|
||||
online: boolean;
|
||||
status: string;
|
||||
hasFrames: boolean;
|
||||
fps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
framesShown: number;
|
||||
framesDropped: number;
|
||||
lossPercent: number;
|
||||
datagrams: number;
|
||||
lastFrameBytes: number;
|
||||
gapMs: number | null;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::SlotsDto` */
|
||||
export interface SlotsDto {
|
||||
cameraA: number | null;
|
||||
cameraB: number | null;
|
||||
armorA: number | null;
|
||||
armorB: number | null;
|
||||
manual: boolean;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::TeamHealthDto` */
|
||||
export interface TeamHealthDto {
|
||||
team: number;
|
||||
name: string;
|
||||
available: boolean;
|
||||
placeholder: string;
|
||||
hp: number | null;
|
||||
hps: number[];
|
||||
board: number | null;
|
||||
boardText: string | null;
|
||||
critical: boolean;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::RecordingDto` */
|
||||
export interface RecordingDto {
|
||||
camera: number;
|
||||
cameraText: string;
|
||||
active: boolean;
|
||||
path: string | null;
|
||||
inputFps: number;
|
||||
framesWritten: number;
|
||||
framesDropped: number;
|
||||
startedMs: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** `desktop/src-tauri/src/recording.rs` 的 `MediaFileDto` */
|
||||
export interface MediaFileDto {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: "video" | "image";
|
||||
bytes: number;
|
||||
modifiedMs: number;
|
||||
}
|
||||
|
||||
/** `cam_core::snapshot::Snapshot` */
|
||||
export interface Snapshot {
|
||||
role: Role;
|
||||
team: number;
|
||||
mirrored: boolean;
|
||||
demo: boolean;
|
||||
diagnostics: boolean;
|
||||
unlocked: boolean;
|
||||
discoveryOpen: boolean;
|
||||
uptimeMs: number;
|
||||
clients: number;
|
||||
config: ConfigView;
|
||||
cameras: CameraDto[];
|
||||
armorBoards: ArmorBoardDto[];
|
||||
stats: CameraStatsDto[];
|
||||
slots: SlotsDto;
|
||||
health: TeamHealthDto[];
|
||||
recordings: RecordingDto[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** `desktop/src-tauri/src/server.rs` 的 `StreamInfo` */
|
||||
export interface StreamInfo {
|
||||
url: string;
|
||||
port: number;
|
||||
clients: number;
|
||||
}
|
||||
|
||||
/** `server::WireMsg` 的 `Hit`。 */
|
||||
export interface HitEvent {
|
||||
board: number;
|
||||
team: number;
|
||||
index: number;
|
||||
from: number;
|
||||
to: number;
|
||||
damage: number;
|
||||
atMs: number;
|
||||
}
|
||||
|
||||
/** `server::WireMsg` 的文本信封。`t` 是 serde 的内部标签。 */
|
||||
export type WireMsg =
|
||||
| ({ t: "snapshot" } & Snapshot)
|
||||
| ({ t: "hit" } & HitEvent)
|
||||
| { t: "note"; message: string }
|
||||
| { t: "confirmQuit" };
|
||||
|
||||
/** 二进制视频帧的头部长度,与 `server::FRAME_HEADER_LEN` 一致。 */
|
||||
export const FRAME_HEADER_LEN = 16;
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// Tauri 在开发模式下把 devUrl 指向这个端口,所以它必须固定——换成随机端口的话
|
||||
// tauri.conf.json 里的 devUrl 就对不上了。
|
||||
//
|
||||
// `clearScreen: false` 让 vite 不要清掉终端:外面是 tauri-cli 在跑,cargo 的编译
|
||||
// 输出与 vite 的输出共用这一个终端,清屏会把编译错误冲掉。
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: "127.0.0.1",
|
||||
// src-tauri 与 core 的改动由 cargo 负责重建,让 vite 不要跟着刷新两遍。
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**", "**/core/**", "**/target/**"],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// macOS 上跑的是 WKWebView,不是需要兼容老浏览器的页面。
|
||||
target: "safari15",
|
||||
sourcemap: false,
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
+78
-8
@@ -453,14 +453,14 @@ impl Hello {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 装甲板(预留)
|
||||
// 装甲板
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 一块装甲板的上报。
|
||||
///
|
||||
/// 现在没有实现也没有数据源,但格式在这里定下来,并且 [`MsgType::ArmorHealth`]
|
||||
/// 与 [`ARMOR_PORT`] 已经就位:板子接上以后向 `ARMOR_PORT` 发这个结构,裁判终端
|
||||
/// 监听即可,界面与其它代码都不用改。
|
||||
/// 由 `armor/armor-firmware` 里的主板(ESP32-C3)广播到 [`ARMOR_PORT`],裁判终端监听
|
||||
/// 该端口并把它显示成四格血量。整车一条血量,所以四条上报的 `hp` 相同、`index`
|
||||
/// 分别是 0..3。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ArmorHealth {
|
||||
/// 哪一边:0 = 队伍一,1 = 队伍二。
|
||||
@@ -472,10 +472,20 @@ pub struct ArmorHealth {
|
||||
}
|
||||
|
||||
impl ArmorHealth {
|
||||
/// 编码成线上字节,帧头的 `sender_id` 留 0。
|
||||
///
|
||||
/// 0 是 [`REFEREE_ID`],所以板子发的时候应该用 [`encode_from`](Self::encode_from)
|
||||
/// 填上自己的 id(由 MAC 派生),抓包时才能分辨是哪块板子。
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
self.encode_from(0)
|
||||
}
|
||||
|
||||
/// 与 [`encode`](Self::encode) 相同,但帧头带上发送方自己的 id。
|
||||
pub fn encode_from(&self, sender_id: u32) -> Vec<u8> {
|
||||
let header = Header {
|
||||
msg_type: MsgType::ArmorHealth,
|
||||
version: PROTOCOL_VERSION,
|
||||
sender_id,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -488,17 +498,32 @@ impl ArmorHealth {
|
||||
}
|
||||
|
||||
pub fn decode(bytes: &[u8]) -> Option<Self> {
|
||||
Self::decode_from(bytes).map(|(_, health)| health)
|
||||
}
|
||||
|
||||
/// 与 [`decode`](Self::decode) 相同,但把帧头里的 `sender_id` 一起返回。
|
||||
///
|
||||
/// 板子用 [`encode_from`](Self::encode_from) 把自己的 MAC 派生 id 填进去,所以
|
||||
/// 这一个 u32 就是**哪块装甲板主板在说话**。裁判端与队伍端要列出"场上有哪几块
|
||||
/// 装甲板"并让用户选中其中一块,靠的就是它——没有它就只剩 `team` 字段,
|
||||
/// 同队的两块板子无从区分。
|
||||
///
|
||||
/// 保持 [`decode`](Self::decode) 不变是有意的:`team`/`index`/`hp` 是上报的语义,
|
||||
/// 而 `sender_id` 是链路层的信封,不需要它的人不该被迫处理它。
|
||||
pub fn decode_from(bytes: &[u8]) -> Option<(u32, Self)> {
|
||||
let header = decode_header(bytes)?;
|
||||
|
||||
if header.msg_type != MsgType::ArmorHealth || bytes.len() < HEADER_LEN + 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ArmorHealth {
|
||||
let health = ArmorHealth {
|
||||
team: bytes[HEADER_LEN],
|
||||
index: bytes[HEADER_LEN + 1],
|
||||
hp: u16::from_le_bytes(bytes[HEADER_LEN + 2..HEADER_LEN + 4].try_into().ok()?),
|
||||
})
|
||||
};
|
||||
|
||||
Some((header.sender_id, health))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,8 +884,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn armor_health_roundtrips() {
|
||||
// 现在没有实现,但格式必须先定下来并且可往返,否则接上板子时才发现布局
|
||||
// 不兼容就晚了。
|
||||
// 现在有实现了(armor/ 里的 ESP32-C3 固件在发它),布局必须保持可往返。
|
||||
let hp = ArmorHealth {
|
||||
team: 1,
|
||||
index: 2,
|
||||
@@ -871,6 +895,52 @@ mod tests {
|
||||
assert_eq!(ArmorHealth::decode(&encoded), Some(hp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_health_can_carry_the_sending_board_id() {
|
||||
let hp = ArmorHealth {
|
||||
team: 0,
|
||||
index: 3,
|
||||
hp: 97,
|
||||
};
|
||||
|
||||
let encoded = hp.encode_from(0x1234_5678);
|
||||
assert_eq!(
|
||||
decode_header(&encoded).map(|h| h.sender_id),
|
||||
Some(0x1234_5678),
|
||||
"抓包时要能分辨是哪块装甲板发的"
|
||||
);
|
||||
assert_eq!(ArmorHealth::decode(&encoded), Some(hp));
|
||||
|
||||
// 帧头 id 不影响帧体,也不该影响旧调用方的行为。
|
||||
assert_eq!(ArmorHealth::decode(&hp.encode()), Some(hp));
|
||||
assert_eq!(decode_header(&hp.encode()).map(|h| h.sender_id), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_health_decode_from_returns_the_board_id() {
|
||||
// 客户端要列出"场上有哪几块装甲板",只能靠帧头里这个 id:team 只有 0/1,
|
||||
// 同队的两块板子在 team 上无法区分。
|
||||
let hp = ArmorHealth {
|
||||
team: 1,
|
||||
index: 1,
|
||||
hp: 100,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
ArmorHealth::decode_from(&hp.encode_from(0xABCD_1234)),
|
||||
Some((0xABCD_1234, hp))
|
||||
);
|
||||
|
||||
// decode 是 decode_from 丢掉信封的那一半,两者必须一致。
|
||||
assert_eq!(
|
||||
ArmorHealth::decode(&hp.encode_from(0xABCD_1234)),
|
||||
ArmorHealth::decode_from(&hp.encode_from(0xABCD_1234)).map(|(_, h)| h)
|
||||
);
|
||||
|
||||
// 不是装甲板上报就不该解出来。
|
||||
assert!(ArmorHealth::decode_from(&[0u8; 8]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_stable_and_never_zero() {
|
||||
let mac = [0x90, 0x70, 0x69, 0xF8, 0xF2, 0x14];
|
||||
|
||||
Reference in New Issue
Block a user