34 Commits
Author SHA1 Message Date
sloray d9a27b69f3 fix(ci): 版本校验在浅克隆下必然误报
构建客户端 / 检查(类型 + 单元测试) (push) Waiting to run
构建客户端 / Linux x64(deb + AppImage) (push) Blocked by required conditions
构建客户端 / Windows x64(NSIS 安装包) (push) Blocked by required conditions
构建客户端 / macOS 通用(.app + .dmg) (push) Blocked by required conditions
- fix(ci): checkout 改成 fetch-depth: 0——没有标签、提交数恒为 1 时,
  version.mjs --check 算出来的版本一定是 0.0.0+1,于是每次推送都红
- fix(version): CHANGELOG 按标签区间重算全部历史,不再只渲染当前版本,
  否则每次发布都会把上一版那一段覆盖掉
- fix(build): 固件包文件名用算出来的版本,不再用 BUILD-INFO.json 里那套旧编号
2026-10-19 02:42:53 +08:00
sloray 896b21fe44 feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形
- feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退
- feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页
- feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路
- feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码
- feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔
- feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写
- feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容
- feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因
- feat(firmware): 固件包版本与客户端同源,都从 version.json 读
- fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载
- fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页
- fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系
- fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失
- fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径
- fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录
- build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO
- build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-*
- docs: 补充界面语言、版本机制与协议通告布局的说明
2026-10-19 02:40:08 +08:00
sloray cc6b82d7ec feat: 链路控制、延迟校准与长测工具,补全文档
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
- 固件:新增 link_control / stream_control,覆盖运行中 Wi-Fi 断线恢复、取帧节拍、
  温控与质量/帧率减载;UDP 发送增加有界重试预算与帧期限,PSRAM JPEG 直发减少拷贝。
- 协议:重组器严格校验分片布局,统计未收齐、超时与完全缺失的帧,处理帧号回绕与设备重启。
- 桌面端:视频改 latest-value 通道并前置帧时间标记,拒绝积压和慢解码的过期画面;新增
  延迟标记页与 --latency-report 校准,完善录制、服务端与退出流程。
- 文档与脚本:新增硬件、开发、延迟、功耗、实时性、稳定性文档和改动记录,重写根 README;
  新增构建、性能、延迟与串口长测脚本,以及长测客户端脚本。
- 构建:固定 esp-1.97.0.0 工具链,分区表随构建复制,更新 sdkconfig 与忽略规则。
2026-10-19 00:19:07 +08:00
sloray 4794bed2cf ci: npm 装 pnpm 前先清掉残留影子文件(EEXIST),并去掉 npm 不认的 store-dir
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m10s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 32s
构建客户端 / Linux x64(deb + AppImage) (push) Successful in 6m20s
2026-10-15 23:29:39 +08:00
sloray e6070f91ee ci: Windows 改用 npm 装的 JS 版 pnpm(standalone 二进制在低完整性沙箱里必崩)
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m9s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 22s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 1m14s
2026-10-15 23:27:06 +08:00
sloray 26731958ea ci: Windows 的 pnpm install 加 corepack 退路(standalone 在低完整性沙箱里栈溢出)
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m10s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 40s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 1m37s
2026-10-15 23:24:13 +08:00
sloray de7263569c ci: Windows 的 pnpm 安装目录改成每次运行都干净的路径
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m11s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 28s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 1m36s
run 13 里 装 Rust 这次是好的(上次那个 panic 是两轮运行重叠、同时写 ci-rustup 造成的),
但 装 pnpm 挂了:

    [Error: ENOTEMPTY: directory not empty, rmdir
      'C:\Users\admin\runner\windows\cache\ci-pnpm\node_modules\.bin\store\v11\projects']

原因是这个 dest 是固定路径:pnpm 把自己算出来的 store 建在 dest 里面(就是之前日志里
那个很怪的 <dest>\node_modules\.bin\store\v11),下一轮 action 的自安装器要清掉旧目录
时,嵌套的 store\v11\projects 非空,rmdir 就失败了。

改成 ${{ github.workspace }}\..\ci-pnpm —— 落在本次运行的 work 目录里,每次都是干净的,
同时也还满足"低完整性只能写 runner 自己的工作目录"。机器上那个被污染的
cache\ci-pnpm 已经删掉。
2026-10-15 23:21:16 +08:00
sloray c9ce7b64ef ci: 删掉两段临时诊断(Windows 的坑已经查清并写进文件头第 5 条)
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m13s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 17s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 1m1s
2026-10-15 23:18:53 +08:00
sloray f3b09ca9a6 ci: Windows 的 pnpm 那步写死 shell: pwsh(免得 actionlint 拿 bash 规则挑反斜杠路径)
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m9s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 19s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 2m1s
2026-10-15 23:15:36 +08:00
sloray 1cccc1aa00 ci: Windows job 给 pnpm install 明确指定 store 与导入方式
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 6s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
run 10 已经过了 store 操作锁那一关(pnpm 开始下载包了),但接着崩了:

    Downloading @tauri-apps/cli-win32-x64-msvc@2.12.2: 0.00 B/7.98 MB
    Content-addressable store is at:
      C:\Users\admin\runner\windows\cache\ci-pnpm\node_modules\.bin\store\v11
    thread '<unknown>' (6500) has overflowed its stack
    ##[error]Process completed with exit code 1.

两个可疑点:一是 pnpm 自己算出来的 store 路径很怪(挂在 pnpm 安装目录的
node_modules\.bin 下面,而且我在 env 里设的 npm_config_store_dir 它压根没用);
二是硬链接导入。所以这一步直接给 CLI 参数:显式短路径 store、copy 导入(不走硬链接)、
append-only 输出(默认 reporter 在 CI 日志里全是重画)。
2026-10-15 23:15:20 +08:00
sloray f0538fa233 ci: Windows job 加 USERPROFILE 重定向 —— pnpm 的操作锁按它算路径
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m9s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 33s
构建客户端 / Linux x64(deb + AppImage) (push) Successful in 6m4s
run 9 的诊断2 已经把话说死了:

    LOCALAPPDATA = C:\Users\admin\runner\windows\cache\ci-localappdata   <- 重定向生效
    OK   ...\ci-localappdata\pnpm-store-operation-locks                  <- 目录能建
    Error: ERR_PNPM_STORE_DIR_OPEN_OPERATION_LOCK
      Failed to open the store operation lock at
      "C:\Users\admin\AppData\Local\pnpm-store-operation-locks\all-stores.lock"

也就是说 LOCALAPPDATA 指走了,pnpm 还是去开原来那个路径。本机复现出真正的规则:
只改 USERPROFILE(LOCALAPPDATA 不动)、store 换成一个全新的空目录时,
    <新 USERPROFILE>\AppData\Local\pnpm-store-operation-locks\all-stores.lock
    <新 USERPROFILE>\AppData\Local\pnpm-store-operation-locks\<hash>.lock
立刻出现 —— pnpm 是按 <USERPROFILE>\AppData\Local 算的,跟 LOCALAPPDATA 无关。

所以在那一步里再建一个 ci-profile,把 AppData\Local / AppData\Roaming 先建好,然后
把 USERPROFILE 写进 GITHUB_ENV。文件头第 5 条同步更新。
2026-10-15 23:06:35 +08:00
sloray 47ef7c63dd ci(diag): 再探一次 —— pnpm 眼里的目录到底是哪个
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m15s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 23s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 2m38s
run 8 里 LOCALAPPDATA 已经在 job env 里指向 ci-localappdata,但 pnpm 仍然去开
C:\Users\admin\AppData\Local\pnpm-store-operation-locks\all-stores.lock(拒绝访问)。
所以要么这个变量没进到那一步,要么 pnpm 不是从环境变量推这个路径。加一步(在装完
pnpm 之后、装依赖之前)把它问清楚:LOCALAPPDATA/APPDATA 的实际值、pnpm 的路径与版本、
pnpm config list 的输出,以及"两个候选锁目录能不能建"。查完删。
2026-10-15 23:02:12 +08:00
sloray b74c6654e5 ci: Windows job 再加 LOCALAPPDATA/APPDATA 重定向(pnpm 的 store 锁挂在这里)
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m7s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 22s
构建客户端 / Linux x64(deb + AppImage) (push) Successful in 5m54s
run 7 的 Windows job 已经过了 rustup / pnpm / node 三步 —— 低完整性那套重定向起效了
(诊断输出确认:RUSTUP_HOME / CARGO_HOME / RUNNER_TOOL_CACHE / npm_config_* 都指到了
runner 自己的工作目录)。新的失败在"前端依赖":

    pnpm install --frozen-lockfile
    ... pnpm-store-operation-locks\all-stores.lock": 拒绝访问。(os error 5)

pnpm 12 的 store 操作锁与 cache/state 不在 store-dir 下面,而是挂在 %LOCALAPPDATA%
(本机实测:把 LOCALAPPDATA 指到别的目录后,pnpm 会在那里建 pnpm-cache / pnpm-state,
install 正常退出)。所以 job env 里再加两条:

    LOCALAPPDATA: C:\Users\admin\runner\windows\cache\ci-localappdata
    APPDATA:      C:\Users\admin\runner\windows\cache\ci-appdata

顺带覆盖到 tauri 打包时下载 NSIS 的缓存目录(它也在 LOCALAPPDATA 下)。
2026-10-15 22:52:46 +08:00
sloray ac3d639d6b ci: Windows job 低完整性适配 —— 把 CI 的"家目录"挪到 runner 自己的工作目录
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m12s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 57s
构建客户端 / Linux x64(deb + AppImage) (push) Successful in 6m5s
诊断步骤(在 job 里跑的写探测)给出了确定答案:

    whoami    = timi\admin
    integrity = Mandatory Label\Low Mandatory Level  S-1-16-4096     <-- 低完整性
    FAIL: C:\Users\admin\.rustup\tmp\probe.txt      Access denied
    FAIL: C:\Users\admin\.cargo\probe.txt           Access denied
    FAIL: C:\Users\admin\.cargo\registry\probe.txt  Access denied
    OK  : C:\Users\admin\runner\windows
    OK  : C:\Users\admin\runner\windows\cache
    OK  : <work>\ci-probe

不是 ACL 问题(SYSTEM / Administrators / timi\admin 全 F,没有 Deny),是 Windows 的
"低完整性不能写中完整性"。同一个账户在交互式会话(Medium,实测 S-1-16-8192)里往
~/.rustup、~/.cargo 写得进去,job 里就全被拒。于是前面几个看起来毫不相干的错误都是
同一个原因:Git Bash /tmp 被拒(TEMP 不可写时它退回 <Git>\tmp)、rustup 建不了临时
文件。%TEMP%、%LOCALAPPDATA% 也一样中招。

处置(Windows job 内):
  * env 增加 RUSTUP_HOME / CARGO_HOME / npm_config_cache / npm_config_store_dir /
    RUNNER_TOOL_CACHE,全部指向 C:\Users\admin\runner\windows\cache\ci-*(探测过可写)
  * pnpm/action-setup 的 dest 从默认 ~/setup-pnpm 改到同一个 cache 下
  * TEMP/TMP/TMPDIR 上一版已经指到 workspace 同级的 ci-tmp
文件头第 5 条写清了这个坑、表现、以及从根上的解法(让 gitea-runner 以中完整性启动)。
临时诊断步骤暂时保留,等 Windows 真编出包来再删。
2026-10-15 22:44:05 +08:00
sloray 2ed16ff88d ci(diag): 在 Windows job 里加一段临时诊断(查完删)
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m8s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 13s
构建客户端 / Linux x64(deb + AppImage) (push) Successful in 6m7s
run 5 里 bash 那两个坑都过去了,卡在第三处:rustup 建临时文件被拒

    error: could not create temp file C:\Users\admin\.rustup\tmp\ajq4dt9h0_e7oonj_file
    拒绝访问。 (os error 5)

同一个账户在这个交互式会话里能往那个目录写、ACL 也是正常的(SYSTEM/Administrators/
TIMI\admin 全 F,没有 Deny)。runner 进程属主是 TIMI\admin,计划任务 GiteaRunner-Windows
是 admin / Limited(非提权)。所以差异只在 job 内部的令牌/完整性级别上 —— 让 job 自己
报:whoami、Mandatory Label、TEMP/TMP、RUSTUP_HOME/CARGO_HOME,并对 6 个候选目录逐个
做"建目录 + 写文件 + 删文件"探测。这是临时步骤,拿到结论就删。
2026-10-15 22:31:56 +08:00
sloray 6f83ebbe3c ci: 修 Windows job 第二个坑 —— TEMP/TMP 为空,Git Bash 的 mktemp 写不进 <Git>\tmp
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m27s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 12s
构建客户端 / Linux x64(deb + AppImage) (push) Successful in 6m4s
run 4 里 bash 已经换成 Git for Windows(那一步本身就成功了),但 dtolnay/rust-toolchain
还是挂在它的脚本里:

    log=$(mktemp)
    mktemp: failed to create file via template '/tmp/tmp.XXXXXXXXXX': Permission denied

原因:这个 job 的环境里 TEMP/TMP 是空的,而 Git Bash 的 /tmp 是从 TEMP 推出来的;
没有 TEMP,MSYS 就退回 <Git 安装目录>\tmp(C:\Program Files\Git\tmp,实测存在),
那个目录普通权限写不进去 —— mktemp 于是 Permission denied,不是"没目录"也不是
"没权限建文件"那么直白。

本机(就是这台 runner)复现 + 验证:
    清空 TEMP/TMP                -> mktemp: ... Permission denied(与 CI 一字不差)
    TEMP/TMP 指向可写目录        -> mount 显示 "... on /tmp",mktemp 正常
                                    rustup toolchain install / target add 也全过

修法:把 Windows job 里"改 PATH 那一步"扩成同时安排临时目录 —— 在 workspace 的同级
建 ci-tmp,把 TEMP/TMP/TMPDIR 写进 GITHUB_ENV,后续步骤(包括 composite action 内部
的 bash)都用它。
2026-10-15 22:21:36 +08:00
sloray 4667d94c45 ci: 修 Windows job 0 秒失败 —— 宿主 runner 上的 bash 是 WSL,不是 Git Bash
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m7s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 13s
构建客户端 / Linux x64(deb + AppImage) (push) Successful in 6m51s
run 3 里 Windows job 挂在第二步、耗时 0 秒:
    装 Rust(stable)  [failure]  14:01:42 → 14:01:42
    装 pnpm / 装 Node / 前端依赖 / 打包 / 上传产物  [skipped]
日志里是一串被当 UTF-8 解码的 GBK:
    ?b?~? 0   ?Nx?  B a s h / E _ A C C E S S D E N I E D
    ... https://github.com/microsoft/WSL/issues
就是 WSL 的 E_ACCESSDENIED:这台 runner 是宿主直跑,PATH 上的 `bash` 指向
C:\Windows\System32\bash.exe(WSL 启动器),而 WSL 在 runner 账户下起不来。于是
所有 shell: bash 的步骤、以及用 bash 写的 composite action(dtolnay/rust-toolchain
就是),都会在 0 秒内失败。

修法:Windows job 的 checkout 之后插一步(shell: pwsh),把
"C:\Program Files\Git\bin" 写进 GITHUB_PATH —— 它的语义是前置插入,会盖过 System32
那个 WSL 版 bash。本机(就是这台 runner)实测通过:
    bash -> C:\Program Files\Git\bin\bash.exe
    rustup toolchain install stable --profile minimal      exit=0
    rustup target add --toolchain stable x86_64-pc-windows-msvc  exit=0
    rustup run stable rustc --version / cargo --version    exit=0
在仓库根目录跑也一样:根 rust-toolchain.toml 的 `esp` override 不影响这几条命令,
只有裸 rustc/cargo 才会撞上它,而 workflow 里没有这种步骤。

顺带:run 3 里 Linux 那条是好的(检查 2m42s、Linux 9m14s 全绿)。
2026-10-15 22:10:57 +08:00
sloray 76b96cc374 ci: Windows 产物默认就编(平台开关从 vars 换成 inputs)
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 2m42s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Failing after 27s
构建客户端 / Linux x64(deb + AppImage) (push) Successful in 9m14s
现象:Windows runner 明明在线、job 也确实派给了 windows-runner-01(它自己的日志:
"Runner labels: windows-latest, windows-2025, windows-2022"),但每次运行里
Windows 产物都是 skipped。

原因在 job 级 if 上:
    (github.event_name != 'workflow_dispatch' && vars.ENABLE_WINDOWS == 'true')
Gitea 的 runner 在 job 级 if 里拿不到(或拿不对)vars 上下文,条件恒为假 —— 而这个
条件写反的代价是"静悄悄不编",不看 runner 日志根本看不出来。同一份文件里
`github.event.inputs.linux != 'false'` 这个形式在 runner 日志里能看到正常求值
(evaluating expression '...' evaluated to 'true'),所以三个平台统一成这种形式:

  Linux / Windows : github.event.inputs.<平台> != 'false'   → 默认跑
  macOS           : github.event.inputs.macos == 'true'     → 默认不跑(还没有机器)

同时把 workflow_dispatch 里 windows 的默认值改成 true,并更新文件头:写清三台
runner 各自声明的标签、为什么不能拿 Linux 交叉编 Windows/macOS、以及第 4 条——
平台开关不要用仓库变量(附上这次的证据)。

runs-on 里的 vars.RUNNER_* 保持不变(标签不匹配时用它们覆盖)。
2026-10-15 21:58:02 +08:00
sloray 03f7279b53 ci: 修两处 Gitea 上跑不了的东西(upload-artifact v4 → v3、去掉缓存)
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 1m22s
构建客户端 / Linux x64(deb + AppImage) (push) Successful in 7m2s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Skipped
第一次真实运行的日志(job 14)钉出来的两件事:

1. 上传产物那一步一直是失败的:
     GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
     and download-artifact@v4+ are not currently supported on GHES
   v4 里的 @actions/artifact 会拒绝非 github.com 的服务器。Gitea 实现的是 v3 那套
   接口(ACTIONS_RUNTIME_URL),所以三个平台都换成 upload-artifact@v3,并加一个
   .github/actionlint.yaml 把"v3 太旧"这条 GitHub 规则的误报关掉(原因写在里面)。

   产物路径本身没问题:日志里 tauri build 已经产出 deb/AppImage,v4 在报错之前也
   认出了那批文件("The least common ancestor is .../release/bundle")。

2. 缓存服务在 job 容器里连不上:
     Failed to restore: getCacheEntry failed: connect ETIMEDOUT 172.18.0.2:37315
   setup-node 的 cache: pnpm 每次要干等 4 分 39 秒才放弃,cargo 缓存那步也白等 26 秒。
   先全部去掉(两个 job 每轮省约 10 分钟),文件头第 3 条写明了等缓存修好怎么加回来。

其余在真机上都是绿的:rustup 兜底、pnpm 12.11.2、Node 24.22.0、tsc、vite、
protocol 15 / cam-core 80 / src-tauri 30 个测试、tauri build(deb + AppImage)全过。
2026-10-15 20:49:25 +08:00
sloray 98dc87ec6a ci: 构建客户端 workflow(Windows / Linux / macOS)
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 7m59s
构建客户端 / Linux x64(deb + AppImage) (push) Failing after 10m45s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Skipped
触发:push 到 main(只认 desktop/ 与 protocol/ 的改动)、v* tag、PR、手动。
产物:Linux deb + AppImage、Windows NSIS 安装包、macOS 通用 dmg,各自传 artifact。

几个不是随手写的点:
- 三个平台都显式 --target / CARGO_BUILD_TARGET。desktop/.cargo/config.toml 里钉着
  aarch64-apple-darwin,不覆盖的话 Linux/Windows 的 runner 会去编 macOS 的目标,
  然后在依赖深处以 libc 找不到 MAP_FAILED 这类形式失败。
- protocol 的测试写 cargo +stable:protocol/ 没有自己的 rust-toolchain.toml,会被
  根目录那个 `esp` 接住,而 CI 上没有那个工具链(espup 要下几百 MB)。
- Windows 与 macOS 两个 job 默认不跑(本仓库目前只有 Linux runner),要用仓库变量
  ENABLE_WINDOWS / ENABLE_MACOS 打开;没有 runner 时它们只会一直排队。
- cargo 只缓存 registry、不缓存 target/:容器化的 runner 每次都是干净的,拉几 GB
  的 target 缓存常常比重新编译还慢。
- 守门 job 先跑 tsc + vite build + protocol/core 的单元测试,三个构建都 needs 它。

本地(Windows)实测:同一条 pnpm tauri build --target x86_64-pc-windows-msvc
--bundles nsis 真的编出了 src-tauri/target/x86_64-pc-windows-msvc/release/bundle/
nsis/cam-desktop_0.1.0_x64-setup.exe(2.97 MiB),也验证了 --target 确实盖住了
.cargo/config.toml;protocol 15 个测试、cam-core 80 个测试、tsc、vite build 都过;
三个 Cargo.lock 与 --locked 一致;workflow 本身过 actionlint(带 shellcheck)。
Linux 与 macOS 的打包只能在你们的 runner 上验,第一次跑完把日志贴回来。
2026-10-15 18:41:31 +08:00
Horldsence a93ae065cf desktop: 双方血条(角上平行四边形)、归零即击败、去掉受击抖动、裁判端可改队名
界面改动都在浏览器预览里逐条核对过(DOM 尺寸 + 视觉),截图留在 desktop/screenshots/
(该目录不进仓库)。

1. 客户端显示对手的血量
   快照里两端都给两支队伍(顺序固定为队伍号 0/1),界面按 `team` 取那一格,不再靠
   数组下标。顺带修掉一个真 bug:用户端"对手那一格"以前会走 `armor_id`——那是我自己
   绑定的主板,于是右上角显示的是自己的血,比不显示更糟。现在只有"自己那一块"才用
   `armor_id`;同理 `assigned_armor` 里的裁判端槽位指定对用户端不再生效。

2. 血条改到画面两个上角,平行四边形,一条分 5 格
   左上角自己、右上角对手(裁判端两格各贴自己的角,读法一致)。整条 skewX(-15deg),
   右条再 scaleX(-1) 镜像,两条斜边朝外对称、**都从靠中心的一侧开始灭**。一格 20 点血。
   条挂在绝对定位的一层上并 pointer-events:none(那一层盖着画面,而画面要能滚轮缩放、
   拖动),倍率与复位徽标因此从上沿挪到下沿。未接入显示"装甲板未接入"而不是 0。
   注意:不要在 JS 里再反转一次数组——那会和 CSS 的 scaleX(-1) 互相抵消,表现是右条
   从外侧开始灭(代码里写了注释钉住这件事)。

3. 受击画面不抖动
   删掉整幅图(含瞄准器)的位移与 shakeOffset。`HitFeedbackConfig::shake` 字段保留但
   **不再生效**:配置文件是 deny_unknown_fields 的,删字段会让所有写过 shake = true 的
   配置解析失败,而配置解析失败等于程序起不来。默认值改 false,设置面板去掉开关,
   并有单测钉住"旧配置读得进来、默认不抖"。

4. 血量归零算击败,不显示"损毁"
   血条上不再出现"已击毁/损毁",归零的结论放到图传中央:胜利 / 击败 / 平局。结论在
   Rust 侧算(snapshot::resolve_outcomes,4 条单测),规则有意不对称——**输只看自己**
   (自己归零就是输,哪怕对手主板掉线),**赢要看两边**(对手归零且自己还有血),
   两边同时归零是平局。它一直显示到血量被重置,不做几秒淡出,否则截图与回放里就再也
   没有"这局结束了"的痕迹。

5. 裁判端可以改队伍名
   两格画面各自下面一个输入框:回车/失焦生效、Esc 取消、超 12 字截断、留空回落到
   「队伍一 / 队伍二」。命令是 `set_team_name(team, name)`,**一次只改一边**——传整个
   两条的数组就成了"读两条、改一条、写两条",而读来的那两条来自最多 200ms 前的快照,
   紧接着改另一边会把上一次的改动覆盖回去。规范化统一在 `config::sanitize_team_name`
   (合并空白、按字符截断、留空回落),并同样过密码闸门(裁判端本来就没有密码)。
   名字只有一个来源(快照的 `teamNames` / DTO 的 `name`):装甲板表、槽位标签、设置
   面板与"等待图传"的占位文字都跟着改,不再有第二处自己拼"队伍一/队伍二"。

测试:core 80(69→80)、src-tauri 30、protocol 15;`pnpm build`(tsc + vite)通过。
2026-10-14 20:48:12 +08:00
Horldsence fd71b83e3a reference: 早先那版 C 写的装甲板固件(ESP-IDF 工程)
`armor/` 是它的 Rust 改写版,这一份留着当参考:ADC 阈值的 2000 就是从它标定出来的
(`.env.example` 里那句 "same as the reference firmware" 指的就是它),而且它的 README
记着实测设备与 MAC,换硬件时是有用的现场记录。

只收了 20 个源文件。它自带的 `.gitignore` 已经排掉 `build/`(211 MB 的 ESP-IDF 构建产物)
与生成的 `sdkconfig`,所以仓库里不会多出这些东西。

两处顺带处理:
- `reference/armor/.git` 是一个**空的**仓库壳(`git init` 过,零提交、零 remote、零 ref),
  而外层 git 正因为它的存在而拒绝索引 `reference/` 下的任何文件
  (`error: 'reference/armor/' does not have a commit checked out`)。它被挪到
  `desktop/.dev/backup/reference-armor-git`(可逆,且该目录已被忽略),不是删除。
- 它的 README 指着 `../armor-platformio-backup/`,那份备份并不存在。改成明说"没有进仓库",
  免得以后有人照着找一个不存在的目录。

另外把 `/docs/` 加进 .gitignore:那是工作区里另起的一个 Astro 文档站,与本仓库无关。
2026-10-14 19:45:15 +08:00
Horldsence a30eb3200a armor: ESP32-C3 装甲板固件(一主三从)+ 烧录脚本,之前一直没提交
这一块代码本来就写好了、根 README 里也按"已实现"描述,但一直没有进仓库,
只存在于工作区里。同一个提交里带上 `.env.example` 的两个新变量(ARMOR_TEAM、
ARMOR_THRESHOLD)——它们的说明文字就是为这套固件写的。

内容:
- armor-protocol:规则层(血量、每板独立 50ms 受击冷却、受击后只有那一路灯带全黑、
  按键 FSM、上报编码)。纯计算,主机上 22 个测试,已跑通。
- armor-firmware:四路压电片 ADC 连续采样、四路 WS2812(C3 只有一个 RMT 通道可用,
  靠 rmt_tx_switch_gpio 轮流驱动)、NVS、BOOT 键、WiFi + UDP 广播。
- scripts:build_armor.sh / flash_armor.sh / armor_probe.py(不开界面单看广播)。

armor/ 是独立 workspace(目标 RISC-V 的 ESP32-C3),与外层 Xtensa 的图传固件互不相干,
各自 .cargo/config.toml 就是这条边界。构建产物由根 .gitignore 的 `/armor/target/` 覆盖,
本次没有把任何 target/ 或 build/ 下的东西加进来。

顺带把 `.DS_Store` 加进 .gitignore。
2026-10-14 19:43:14 +08:00
Horldsence 7fdebe3ec7 desktop: 修两处小问题(预览模式的命中节奏、设置面板里漏出来的 **)
- 浏览器预览的掉血脚本写成"打空就重置,然后按 to >= from 判断"——两块板子都打空之后
  每一次都是重置,命中再也不出现,盯着界面等闪红会等到超时。现在打空那一拍只重置、
  其余每一拍都出命中。
- 设置面板里"偏移存的是**画面比例**"直接写进了 JSX,`**` 原样显示出来了。JSX 不渲染
  Markdown,改成 <span class="em">。

顺带把界面截图落到 desktop/screenshots/(不进仓库):这个环境里 Page.captureScreenshot
不可用(空白页也超时),改用 Page.printToPDF + sips 转 PNG;打印路径不渲染 box-shadow
的模糊半径(会变成实心块),所以 .dev/shoot.js 在拍之前把它去掉。
2026-10-14 19:37:59 +08:00
Horldsence 4f7a142a39 desktop: 用户端与裁判端两套页面(Tauri v2 + React),删掉 Slint 客户端
界面部分。后端在前一个提交里,这一提交把它接上,并删掉 client/。

用户端:
- 底部胶囊血条(宽度/数字/阈值/脉动可配),未接入时显示占位说明而不是 0;
- 瞄准器画在 canvas 上并与画面共用同一次变换计算——位置跟着缩放走、大小不跟,
  像真瞄具;偏移校准存的是画面比例而不是像素,换分辨率不会失效;
- 命中反馈:整屏闪红 + 抖动 + 准星旁的伤害数字。大号数字刻意不放画面正中:
  第一版放了,正好压住瞄准点。掉血事件由相邻两次读数之差派生(主板只报"现在多少血");
- 摄像头/装甲板下拉绑定、队伍号、镜像;改动要密码,退出/关闭也要。

裁判端:
- 两路等宽(flex: 1 1 0 + min-width: 0)各自独立缩放;
- 装甲板发现列表:id、地址、自报队伍、血量、四条齐不齐、静默多久、分到哪个槽位;
- 截图 PNG + ffmpeg 录 mp4,界面内回放(资源协议白名单外的目录给"用系统播放器打开"的退路)。

密码:Argon2id PHC,首启强制设置,会话内解锁一次。执行点在 Rust 侧命令里,界面上的
解锁对话框只是入口。故意不做 --camera-id 这类命令行覆盖:那是一条不用密码就能改绑的路。

其它:
- 配置换成 serde + toml,字段名统一 camelCase(磁盘=IPC=TS),仍然拒绝未知键名,
  原子写;旧客户端配置首次启动导入并备份成 .bak(只在默认路径下做);
- 演示模式(CAM_DEMO=1)合成协议数据报喂进真实接收路径,没有板子也能把整条链路跑起来;
- 仅 dev 的浏览器预览(?preview=referee),两重门保证不进正式产物(已验证);
- CSP 收紧成白名单,验证方式是"前端能不能连上后端":clients 从 0 变成 1 同时证明
  JS 加载、IPC、WebSocket、React effect 四件事。

删掉 client/:它的功能全部被 desktop/ 覆盖,且其中有几处真 bug 在新实现里改掉了
(槽位分配会让两格显示同一块板子、帧率窗口基线、密码哈希截断被当成密码错、录制文件名
同秒互相覆盖)。注意 client/ 里那几处**未提交**的改动随之丢弃,历史里的版本还在。

测试:protocol 15、cam-core 69、src-tauri 30;前端 tsc + vite build 通过。
2026-10-14 19:28:49 +08:00
Horldsence a89d7ae5fb 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 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
2026-10-14 19:14:16 +08:00
Horldsence eafa112959 文档:写防作弊、缩放布局、槽位语义
client/README.md 新增三节:

  防作弊  说明为什么必须在**接收端**挡而不是界面里隐藏——Hello 广播已经带着
          MAC 派生 id 和 IP,对手 tcpdump 一下就有,而数据本来也已收下解码。同时
          明确写出这一版挡不住什么:对手知道 IP 后可以自己发 Subscribe,固件会向
          任何订阅者推流。要真正封住得回固件加配对 token。

  缩放    image-fit 必须跟着 zoom 在 contain/fill 之间切,否则放大不会改变画面;
          缩回 1.0 要归位,否则下次放大从偏移开始像跳了一下。

  槽位    记下"按 id 顺序对号入座"这个做法错在哪,以及为什么会出现画面左右互换。

根 README 排查表加两条:两格宽窄不一的检查点,以及放大后画面没变清晰的原因。
2026-10-05 21:45:24 +08:00
Horldsence 3631a037d8 client: 槽位对应队伍,不再按 id 顺序对号入座
裁判端两个槽位的语义应该是"队伍一 / 队伍二",而不是"第 N 块发现的板子"。之前
两者混为一谈,出现两个都会被现场误读成故障的现象:

1. **两块槽位显示同一块板。** 每轮都从 `online_cameras()` 取前两个 id 填满两
   个槽位,于是只有一块板在场时两边一模一样。裁判会以为两队的图传串了。

2. **板子掉线时画面左右互换。** 低 id 的板子一离线,候选列表缩短,后面那块就
   跳到前面的槽位——同样像串线,而实际上只是排序。

规则改成:每轮最多消费一个候选 id;已填且仍在线的槽位不动;掉线的槽位保留它的
id 并显示"离线",等它回来时还在原位。

空槽的提示文案也改准:左槽"等待队伍一的图传"、右槽"等待队伍二的图传",而不是笼
统的"未收到画面"。

顺带修两个假数字:`frames_shown == 0` 时 `stats.width`/`height` 还是默认值 0,界
面上显示成 `0x0`——一个看起来像分辨率的假分辨率;fps 显示 `0.0` 读起来像"板子
卡死了",而真相是"还没收到"。状态行已经写了"已连接,等待画面…",不该再补一个矛
盾的数字。

关于"别把刚掉线的板子又立刻选回同一个槽位"那条过滤:它只在槽位掉线时起作用,
且会跳过当前 id 去找下一个候选,槽位仍然有机会回来。实测唯一那块在推流的板子正
常占用一个槽位,另一槽保留着在线但不推流的真板子——符合预期。
2026-10-05 21:45:24 +08:00
Horldsence cd8344c698 client: 修裁判端两格不等宽,加滚轮缩放与拖动平移,启动即最大化
**塌缩问题**(用户报的现象):裁判端窗口放大时,左边那格吃掉约 55% 宽度,右边
只有 45%;空的那一侧更极端时会被压成一条缝。

原因不是缺 stretch,而是 `HorizontalLayout` 先按内容的**最大**需求分配、剩下
的才按 stretch 分。左格写着"已连接,等待画面…"(8 个字),右格"未收到画面"
(5 个字),左格就多要十几个像素。而没有画面的一格最小宽度接近 0,可以被压没。

三处一起改:

  1. 占位文字改成绝对定位(给出 x/y),不再参与父级尺寸计算。文字长短本不该
     决定画面大小。
  2. 两格统一 `preferred-width: 480px` + `horizontal-stretch: 1`,需求变成相同,
     于是精确平分。`min-width: 320px` 兜底,保证任何一侧至少有一个能看画的宽度。
  3. 缩放指示与复位按钮也绝对定位。否则它们一出现就改变这一格的尺寸需求,把
     不等宽重新引进来——而且是在用户放大之后才出现,最难排查。

**缩放**:Slint 的 `Image` 没有缩放因子属性,所以把它的 `width`/`height` 按 zoom
放大后放进 `clip: true` 的容器,靠 offset 平移。滚轮改 zoom、拖动改 offset、双击
复位、缩回 1.0 时自动归位(否则下一次放大会从偏移位置开始,看起来像"跳了一下")。

`image-fit` 要跟着 zoom 切:`contain` 在 zoom=1 时保证完整可见且不变形,`fill`
才让画面跟着放大——只用 contain 的话放大不会有任何视觉变化。

**启动即最大化**:Slint 没有"最大化"这个状态,但有 `set_maximized`,它铺满工作区
且保留菜单栏和 Dock(真全屏 `set_fullscreen` 会盖掉它们,裁判终端要切去别的窗口
核对数据)。必须在 `show()` 之前调用,之后调用会被后端当成"用户点了最大化按钮"。

抽出 `VideoPane` 与 `StatusBar` 两个组件给两种身份复用。两者都不加 `export`——
它们只在 .slint 内部使用,加了会触发"doesn't inherit Window. No code will be
generated for it"的警告。

Slint 的两处写法记在这里,都是编译器试出来的:
  - `TouchArea` 的 `pressed` 是**属性**不是回调,写 `pressed => {}` 编译不过。
    拖动要用 `pointer-event(e)` 配 `PointerEventKind.down` / `.move`。
  - `EventResult.accept` 要写全限定;`accept` 单独写无法解析。
2026-10-05 21:28:32 +08:00
Horldsence a16ad8595e client: 队伍端 release 只接收绑定的那一块板子
对抗赛里两队都用这套系统看自己小车的图传。需求是防止对手开第二个窗口连到另一
队的板子。

**关键是这必须做在接收端,而不是界面里。** 我先按"只在 release 下不显示 id 和
IP"理解,但那样等于没做:广播的 Hello 里就有 MAC 派生的 id 和 IP,对手
tcpdump 一下全都知道,而数据本来也已经收下并解码了。隐藏一行文字不阻止任何事。

所以改为 `Receiver::restrict_to`:队伍端 release 且已绑定时,只重组、只解码、
只统计绑定的那一块板子。对手的板子即使把流推到了本机端口(IP 已泄露、端口是
公开常量),数据也不会进入任何管线。

四种情况不限制:裁判端(中立第三方,本来就要两块)、debug 构建(开发时需要
看到全场)、队伍端未绑定(选择界面需要列出候选)、命令行临时覆盖 camera_id。

`--list` 在 release 队伍端直接报错:它会打印场上所有板子的 id 和 IP,正是要防
的那件事。与其列出来再叮嘱"别看",不如不给。

`TEAM_DIAGNOSTICS = cfg!(debug_assertions)` 门控界面上的 fps / 丢帧 / 数据报数
等遥测和 stdout 的诊断日志。裁判端不受影响——它是中立方,双方遥测对判罚有用。

选择界面保留:现场换机器时还要用它重新绑定。数据侧的闸门已经足够。

顺带修一个设计上就错的判断:`is_online()` 看的是 `last_seen`,而它此前只由 Hello
更新,于是出现"画面每秒刷 22 帧、状态栏却显示离线(橙色)"的矛盾状态。正在推
流本身就是最可靠的存活证据,所以收到视频帧也刷新时间戳。
2026-10-05 21:27:36 +08:00
Horldsence 7b6593bb75 文档:按 UDP 多端架构重写根 README 与 client README
三份文档此前还是 UDP 之前的内容(旧 HTTP 架构、单路、无身份概念),与实现不符。

根 README 是总入口:拓扑(两块板不区分队伍,"哪一边"由客户端决定)、实测性
能对比、发现协议、身份派生理由、构建烧录、硬件坑、排查表。

client README 覆盖:两种身份对应的两个组件、命令行、绑定流程与配置格式、三个
线程的分工、Slint 的硬约束(Image/Weak 不是 Send、ModelRc 没有 From<Vec<T>>、
基础元素不能挂 callback)。

写的时候逐条核对了代码,发现并修正了几处"我以为是这样":

  - WIFI_AUTH 一直能用但从未进文档,.env.example 里也没有
  - 两端各有超时淘汰(原以为没有),值不同:固件 6 秒丢订阅者、客户端 60 秒
    才从界面列表移除。固件还有 MAX_PEERS=4 的上限,也补上了
  - 两处注释写死 app 分区在 0x40000,实际由 flash.sh 自动挑选
  - 实测数字重测:22 fps / 0 丢帧 / 平均帧 22 kB / 350 包每秒

也把两条踩过的坑写进去了,因为它们都属于"看起来是 A,实际是 B":帧率必须按
计数除以墙钟(累计值除窗口值会报出单调上升的假数字,这个错误出现过两次),
以及探到链路异常先怀疑测量工具。
2026-10-05 19:13:52 +08:00
Horldsence c7f42a1979 scripts: 修 udp_probe.py 的重组与 fps 两处测量错误
探测脚本是诊断工具,测错比不测更糟——它给出的数字会被当成结论。

**重组状态必须按 (sender_id, frame_id) 存。** 原先只按 sender_id 存,于是丢片
之后下一帧的分片会接着凑上一帧的计数,凑够了交出去一张混着两帧的 JPEG,表现为
"JPEG 不完整"。修完:14 秒 132 帧,0 张坏帧。

换帧时被放弃的半成品单独计为"被新帧取代",不并入"JPEG 不完整"——它们本来就
不完整,混在一起会把两种情形都算成链路有问题。

**分窗 fps 的分子分母必须同量纲。** 原先用累计帧数除以窗口时长,于是报出 39、
61、79 这种随时间单调上升的假数字,看起来像板子越跑越快。改成窗口增量除以窗
口时长后稳定在 22,也就是传感器上限。

顺带修正两处与代码不符的注释:

  partitions.csv / sdkconfig.defaults
    两处都写死 app 分区在 0x40000,实际是 scripts/flash.sh 自动挑选并写回,
    当前为 0x060000。补上 flash.sh 会重编译并复验的原因(重编译会让镜像位移,
    所以偏移无法是常量)。

  .env.example
    补 CAM_VSYNC_INVERT 和 WIFI_AUTH。WIFI_AUTH 一直能用但没进文档:通常不用
    设,扫描会报告 AP 的真实认证方式;只有扫不到时(隐藏 SSID)才需要指定,
    指定后只试这一种,不成功直接报错而不是挨个试 10 秒——挨个试等于在猜一个
    本该已知的值。
2026-10-05 19:13:37 +08:00
Horldsence 7be8d7d2ba protocol: 更正上一个提交的归因——坏帧不在这里
上一个提交(1c65eaa)说"34 张 JPEG 不完整是重复分片凑数导致的",并说修完从
34 降到 1。**这个结论是错的**,是我归因到了错误的地方。

实测复查:一到两个订阅者、20 秒 2000+ 个分片,重复投递数为 0(固定端口和
临时端口两种订阅方式都试过)。这条链路上根本不产生重复。

34 张坏帧的真实来源是探测脚本自己:scripts/udp_probe.py 的重组状态只按
sender_id 存,不按 frame_id 存。于是某帧丢了分片之后,下一帧的分片会接着往
同一个计数器里凑,凑够了就交出去一张混着两帧的 JPEG。

FrameAssembler 一直按 frame_id 隔离(换个 frame_id 就重置),所以它没这个
问题——脚本原先跟它不一致,而我又把脚本的输出当成了 crate 的行为。

本次改动:

  protocol/src/lib.rs
    位图去重保留,理由改为正确性而非正在发生的故障,并在注释里写明实测重复
    为 0、以及真正的坏帧来源。删掉一个死变量 `last`。行为不变。

  protocol/README.md
    "重复分片必须去重" 一节改写:说清防护的定位是"哪天真的重复了"。
    新增"按 frame_id 隔离,换帧即放弃"一节,讲清不隔离的具体后果。
    记下探到坏帧时先怀疑测量工具。

值得记的不是位图本身,是那个错误归因:34 张坏帧配上"帧率正常、无报错线索"
这个症状,太像一个真的丢包/重复 bug 了,于是一路写到了注释、文档和提交说明
里。以后探到链路异常,先确认测量工具和被测对象说的是同一件事。
2026-10-05 19:13:24 +08:00
Horldsence 1c65eaabc6 protocol: 修复重复分片能凑出一张坏帧
`seen >= expected` 判断分片是否收齐是错的:UDP 会重复投递,一个丢失的
分片可以被另一个包的副本凑数,于是计数达标而缓冲区里留着空洞,交出去
一张尾部是 0 的 JPEG。

这个 bug 很隐蔽——帧率完全正常,没有任何报错线索,只是偶尔几张图解不
出来。实测 10 秒内出现 34 张这样的坏帧。

改为按分片下标记录是否收到,重复的直接跳过且不计数;再显式确认最后一个
分片确实到过。实测坏帧降到 1(板子启动瞬间的残帧),完整帧率不变。

补两个测试覆盖它——原有测试没有一个会重复发分片,所以这个 bug 一直没被
发现:
  - a_duplicate_cannot_complete_a_frame_with_a_hole
  - a_gap_that_is_never_filled_never_yields_a_frame

同时更新 scripts/udp_probe.py:它有同一个 bug,所以测不出修复效果。它还
把 Subscribe 的 role 字节多填了 12 个零字节,固件按偏移严格读取因而拒收,
表现为"板子在线但 0 clients subscribed"。脚本的意义正是独立核对线路格式,
所以这些偏移是手写的,改协议时必须同步。

另删 scripts/pick-flash-pad.sh:链接期 padding 已被证明移不动
.flash.text 内部,flash.sh 现在会自动挑选避开坏字节的 app 偏移。
2026-10-05 18:54:35 +08:00
439 changed files with 43266 additions and 5639 deletions
+4 -11
View File
@@ -12,14 +12,7 @@ build-std = ["std", "panic_abort"]
[env]
MCU = "esp32s3"
# Use the ESP-IDF installation and toolchains that are already on the machine
# (eim's install under ~/.espressif) instead of downloading another copy into
# ./.embuild. Without this the first build re-downloads several hundred MB of
# Xtensa GCC/LLVM.
#
# `global` means ~/.espressif, which is where eim installs.
#
# Drop these two lines to let esp-idf-sys clone and fetch the ESP-IDF version it
# pins itself (v5.5.5 by default), into ~/.espressif.
IDF_PATH = { value = "/Users/peng/.espressif/esp-idf/v5.5.5", force = true }
ESP_IDF_TOOLS_INSTALL_DIR = { value = "global", force = true }
# esp-idf-sys downloads this version unless IDF_PATH points to an existing SDK.
# Keep machine-specific paths in the environment instead of this shared file.
ESP_IDF_VERSION = { value = "v5.5.5", force = false }
ESP_IDF_TOOLS_INSTALL_DIR = { value = "global", force = false }
+78 -1
View File
@@ -5,7 +5,84 @@ WIFI_SSID=your-ssid
WIFI_PASS=your-password
# Optional
# Armor plates (armor/). Which team this board belongs to: 0 = team one (red),
# 1 = team two (blue). Decides the LED colour and the `team` field in the health
# reports, so it is baked in at build time - reflash to switch teams.
#ARMOR_TEAM=0
# Armor plates: raw 12-bit ADC level above which a piezo sample counts as a hit
# (default 2000, same as the reference firmware). Calibrate from the `peak[...]`
# field in the serial status line: idle noise well below, a real tap well above.
#ARMOR_THRESHOLD=2000
# Capture resolution: qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga
#CAM_RES=vga
# JPEG quality 0-63, lower is better quality
#CAM_QUALITY=12
#CAM_QUALITY=12
# Optional adaptive compression ceiling, 0 = fixed (default), else QUALITY..63.
# Shed load on sender congestion, restore detail after 15 stable seconds.
#CAM_QUALITY_MAX=0
# Stream cap, 0 = uncapped (default), 1-60 = maximum fps.
# Paces frame retrieval and UDP delivery; sensor clocks remain unchanged.
#CAM_MAX_FPS=0
# OV5640 PLL profile: default (driver timing) | vga30 (VGA ~30.09 fps).
# vga30 preserves HTS/VTS: XCLK 20, VCO 610, SYSCLK 61, PCLK 15.25 MHz.
# Retune exposure-line limits when switching profiles; keep HTS at default.
#CAM_CLOCK_PROFILE=default
# Capture frame buffers in PSRAM: 3 (default) or 2 (lower capture queue depth).
# Prefer 3 for burst tolerance; 2 showed worse long-run peak frame loss here.
#CAM_FB_COUNT=3
# Sensor grayscale effect while retaining hardware JPEG, 0 = colour (default).
#CAM_GRAYSCALE=0
# OV5640 AE target bias: 0 (default), -1 (~-1.1 EV), -2 (~-1.9 EV).
# This target alone does not guarantee a shorter shutter.
#CAM_EXPOSURE_EV=0
# Optional OV5640 driver AE level -5..5; requires CAM_EXPOSURE_EV=0.
# Unset uses the EV setting. -1 lowers the target 55 -> 45 (~-0.29 EV),
# a finer brightness adjustment than CAM_EXPOSURE_EV=-1. AEC/AGC stay on.
#CAM_AE_LEVEL=-1
# OV5640 fixed shutter in sensor lines, 0 = automatic (default), AGC stays on.
# 220 lines is roughly 10 ms with default VGA HTS 2060; verify timing/logs.
#CAM_SHUTTER_LINES=0
# Optional automatic exposure cap in sensor lines, 0 = driver default.
# AEC/AGC stay automatic, night frame extension is disabled. Mutually exclusive
# with a nonzero fixed shutter. Default VGA: 440 lines is about 20 ms.
# With vga30 use 592 lines for about 20 ms, including two 50 Hz AEC bands.
#CAM_EXPOSURE_MAX_LINES=0
# OV5640 automatic gain ceiling, 0 = driver default, else 1..63 times.
# More gain can brighten short exposures but also amplifies noise.
#CAM_GAIN_CEILING=0
# OV5640 horizontal total, 0 = driver default. Even values only; hardware test
# required. Changing HTS also changes the time represented by one shutter line.
# HTS 1500 / 1800 caused green/magenta flicker on this module; keep 0.
#CAM_HTS=0
# Minimum gap between successful video datagrams, 0 = burst (default).
# Allowed 0..5000 us in FreeRTOS tick increments (1000 us in this build).
#UDP_PACKET_GAP_US=0
# Total yielding-retry allowance per JPEG, shared by all fragments/peers.
# 0 = fail on backpressure; default 8000 us, 0..25000 in tick increments.
# Frame deadlines remain 25 ms sending / 100 ms since capture start.
#UDP_RETRY_BUDGET_US=8000
# Internal ESP chip temperature load limit, Celsius; 0 disables, default 75.
# At the limit halve fps (rounded up); restore after cooling by 5 C.
# This is a proactive load threshold, not a specified silicon safety limit.
#CAM_THERMAL_LIMIT_C=75
# Set to 0 if your board inverts VSYNC in hardware.
#CAM_VSYNC_INVERT=1
# Pin the authentication mode instead of trusting the scan: none | wep | wpa |
# wpa2 | wpawpa2 | wpa3 | wpa2wpa3 | wapi. Only needed when the scan cannot see
# the AP (a hidden SSID, for instance) - otherwise the scan reports the real
# mode and this only makes it fail faster.
#WIFI_AUTH=wpa2
# Optional maximum Wi-Fi TX power, integer dBm (2..20). Unset keeps SDK default.
# Test on your own board/network; higher power does not always improve video.
# Current delivery requests 15 dBm; this ESP32-S3 reports a 15.00 dBm cap.
# Runtime load control does not lower it; reconnects reapply the same cap.
# WIFI_TX_POWER_DBM=15
# Diagnostic builds only: intentionally disconnect once, after N seconds of
# the streaming loop, hold offline 5 seconds, then exercise runtime recovery.
# Keep 0 (default) for normal firmware; does not change router/TUN settings.
#WIFI_TEST_DROP_AFTER_S=0
+16
View File
@@ -0,0 +1,16 @@
# actionlint 配置。
#
# 这个仓库的 CI 跑在 Gitea 上,但 actionlint 用的是 GitHub 的规则,所以有一条
# 政策性的误报要忽略掉:
#
# the runner of "actions/upload-artifact@v3" action is too old to run on GitHub Actions
#
# 它对 GitHub 是成立的(v3 声明的 node16 运行时 GitHub 已经不再执行),但我们是
# Gitea:Gitea 的 runner 用自己的 node 跑 JS action,v3 能正常把产物传上去;反过来
# v4 在 Gitea 上**根本跑不了** —— v4 里的 @actions/artifact 会拒绝非 github.com
# 的服务器(GHESNotSupportedError,原文见 .github/workflows/build-client.yml 文件头
# 第 3 条,那是 2026-10-15 第一次真实运行的日志里抄下来的)。
paths:
.github/workflows/*.yml:
ignore:
- 'the runner of "actions/upload-artifact@v3" action is too old to run on GitHub Actions'
+569
View File
@@ -0,0 +1,569 @@
# 构建桌面客户端(desktop/,Tauri v2 + React)。
#
# 产物都挂在这次运行下面(Actions 页面直接下载),保留 14 天:
# Linux x64 deb + AppImage RMIC-2026_<版本>_amd64.deb / .AppImage ← 默认每次编
# Windows x64 NSIS 安装包 RMIC-2026_<版本>_x64-setup.exe ← 默认每次编
# macOS 通用 .app + .dmg arm64 与 x86_64 合成一个包 ← 还没有机器,手动勾选才编
#
# ── 触发 ─────────────────────────────────────────────────────────────────────
# * push 到 main/master —— 只认 desktop/、protocol/ 与本文件的改动,改固件不会
# 白占 runner
# * 打 v* tag、开 PR
# * 手动 Run workflow:逐个平台勾选,不进 paths 过滤("就想编一次"走它)
# 平台开关一律看勾选框(理由见下面第 4 条)。
#
# ── 跑在 Gitea 上要先对上的两件事 ────────────────────────────────────────────
# 1. runner 标签是注册 runner 时自己起的名字,猜不到,所以三个 job 都写成
# "仓库变量优先、常见标签兜底":
# runs-on: ${{ vars.RUNNER_LINUX || 'ubuntu-latest' }}
# 标签对不上时 job 是**排队**而不是报错 —— 排队会一直占着这次运行不结束,
# 所以上线新平台前先看一眼 runner 页面上那几行标签。
#
# 2026-10-15 实测在线的(括号里是它们自己声明的标签):
# * simba-runner-01 (ubuntu-latest / ubuntu-24.04 / ubuntu-22.04)
# * windows-runner-01 (windows-latest / windows-2025 / windows-2022)
# * macOS:还没有
# 所以 Windows 默认就跑;macOS 的 job 写成"手动勾选才跑"。
#
# 为什么不能拿 Linux runner 交叉编出来:
# * Windows:Rust 侧用 mingw 交叉编还能出个裸 exe,但 tauri 的 NSIS 打包器
# 本身只能在 Windows 上跑,出不了安装包;
# * macOS:要 Apple SDK 与 codesign/lipo,只能在 macOS 上编。
# Windows runner 上要预装好 MSVC Build Tools(link.exe)、WebView2 运行时与
# rustup(dtolnay/rust-toolchain 靠它装工具链);macOS 上要 Xcode Command Line
# Tools + rustup。Linux 的 job 通常跑在容器里,没装 rustup 也没关系 —— 见兜底那步。
# 2. 这个文件放在 .github/workflows/ 下就够:Gitea 的 [actions] WORKFLOW_DIRS
# 默认值就是 [".gitea/workflows", ".github/workflows"](源码里的默认值,且
# .yml 与 .yaml 都认),所以两个目录都是原生支持的,不用挪。
# 3. 两处"GitHub 能跑、Gitea 上不行"的地方,都是 2026-10-15 第一次真实运行的
# 日志里钉出来的:
# * 传产物用 upload-artifact@v3,**不能用 v4**。v4 里的 @actions/artifact 会
# 拒绝非 github.com 的服务器,日志原文:
# GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
# and download-artifact@v4+ are not currently supported on GHES
# Gitea 实现的是 v3 那套接口(ACTIONS_RUNTIME_URL),所以 v3 能传上去。
# * 一个缓存都没开。这台实例的缓存服务在 job 容器里连不上,日志原文:
# Failed to restore: getCacheEntry failed: connect ETIMEDOUT 172.18.0.2:37315
# setup-node 的 cache 每次要干等 4 分 39 秒才放弃(两个 job 各一次)。
# 等缓存能从容器访问到了再加回来,写法就是:
# setup-node 的 with 里加:cache: pnpm / cache-dependency-path: desktop/pnpm-lock.yaml
# 各 job 里加:
# - uses: actions/cache@v4
# with:
# path: |
# ~/.cargo/registry/index
# ~/.cargo/registry/cache
# ~/.cargo/git/db
# key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }}
# restore-keys: ${{ runner.os }}-cargo-registry-
# 4. 平台开关只用 github.event.inputs,**不用仓库变量**。2026-10-15 那次 Windows# runner 明明在线、job 也确实派给了它(它自己的日志里写着
# "Runner labels: windows-latest, windows-2025, windows-2022"),产物却一直是
# skipped —— 卡在 job 级 if 的 `vars.ENABLE_WINDOWS == 'true'` 上:Gitea 的
# runner 在 job 级 if 里拿不到(或拿不对)vars 上下文,而这个条件写反的代价是
# "静悄悄不编",最难发现。同一份文件里 `github.event.inputs.linux != 'false'`
# 这个形式在 runner 日志里能看到正常求值(evaluating expression '...' evaluated
# to 'true'),所以平台开关统一成这种:
# * Linux / Windows:`github.event.inputs.<平台> != 'false'` → 默认跑,
# 手动触发时取消勾选就不跑
# * macOS:`github.event.inputs.macos == 'true'` → 默认不跑,等有机器了改成
# 上一种形式即可
# runs-on 里的 vars(RUNNER_*)不受影响,标签不匹配时用那个覆盖。
# 5. Windows runner 的 job 跑在**低完整性**上(实测 Mandatory Label\Low Mandatory
# Level, S-1-16-4096),而 Windows 不允许低完整性往中完整性写:用户目录
# (~/.rustup、~/.cargo、%TEMP%、%LOCALAPPDATA%)统统 Access denied (os error 5)
# —— ACL 是干净的(SYSTEM / Administrators / 本人 全 F,没有任何 Deny),拦它的是
# 完整性级别,所以"放宽权限"一点用都没有。表现是一串看着毫不相干的错误:
# * Git Bash 的 mktemp 写 /tmp 被拒(它的 /tmp 跟着 TEMP 走;TEMP 不可写时退回
# <Git 安装目录>\tmp,那个目录普通权限进不去);
# * rustup: could not create temp file C:\Users\admin\.rustup\tmp\xxx_file
# 处置:Windows job 把能重定向的都指到 runner 自己的工作目录
# (C:\Users\admin\runner\windows\cache,job 内写探测实测可写)——
# RUSTUP_HOME / CARGO_HOME / npm_config_cache / npm_config_store_dir /
# RUNNER_TOOL_CACHE / LOCALAPPDATA / APPDATA,加上 TEMP/TMP/TMPDIR、
# pnpm/action-setup 的 dest,以及 **USERPROFILE**。
# 最后这个是被 pnpm 逼出来的:它的 store 操作锁不在 store-dir 下面,也不看
# LOCALAPPDATA,而是按 <USERPROFILE>\AppData\Local\pnpm-store-operation-locks
# 算 —— 只把 LOCALAPPDATA 指走它照样去开原来的路径(报错原文见 run 8 的日志),
# 把 USERPROFILE 指走它才跟着走(本机实测:探针 profile 下立刻出现
# all-stores.lock)。
# 想从根上解决:让 gitea-runner 以**中完整性**启动(在普通终端里跑
# run-runner.ps1,而不是从被沙箱化的低完整性 shell 里拉起来),那样这些重定向
# 就都可以撤掉。这台机器上 runner 由计划任务 GiteaRunner-Windows(admin /
# Limited)拉起,但进程链上的完整性已经落到了 Low。
#
# ── 三个平台都显式写 --target,这不是啰嗦 ────────────────────────────────────
# desktop/.cargo/config.toml 里钉着 `target = "aarch64-apple-darwin"`(本机开发用;
# 它存在的理由是 desktop/ 嵌在固件目录树里,不然会继承根目录那份
# xtensa-esp32s3-espidf 的交叉编译配置)。CI 上不覆盖它,Linux 与 Windows 的 runner
# 会去编 macOS 的目标,然后在依赖深处以 libc 找不到 MAP_FAILED 这类形式失败。
# 所以每个 job 都把真正的目标交两遍:CARGO_BUILD_TARGET 给裸 cargo(测试)用,
# --target 给 tauri build 用(打包路径也跟着它走)。
#
# ── 还有一条:仓库根目录的 rust-toolchain.toml 钉的是 `esp` ─────────────────
# 那是给 ESP32 固件的定制工具链,CI 上没有、也不打算装(espup 要下几百 MB)。
# rustup 是按**当前目录**往上找 rust-toolchain.toml 的:
# * desktop/** 有自己的 rust-toolchain.toml(stable),所以那里的 cargo 直接可用;
# * protocol/ 没有,会一路找到根目录那个 esp —— 所以那条测试写成 `cargo +stable`,
# 显式越过它。
# 任何在仓库根目录直接跑 cargo 的步骤都会去要 esp 工具链,别加。
#
# ── 没做的事 ────────────────────────────────────────────────────────────────
# 不签名、不公证:Windows 的 exe 与 macOS 的 app 在别人机器上会被 SmartScreen /
# Gatekeeper 拦一次(右键打开即可)。要做的话给 macOS 那几个 job 加
# APPLE_CERTIFICATE / APPLE_SIGNING_IDENTITY / APPLE_ID / APPLE_PASSWORD /
# APPLE_TEAM_ID(tauri 认这些环境变量,构建步骤不用改)。也没有自动发 Release:
# 要发的话在最后加一个下载全部 artifact 再创建 release 的 job。
name: 构建客户端
on:
push:
branches: [main, master]
tags: ["v*"]
# GitHub 语义下 tag 推送不受 paths 过滤影响;如果你们的 Gitea 版本把 tag 也
# 一起过滤掉了(打 tag 不触发),把这三行 paths 删掉即可。
paths:
- "desktop/**"
- "protocol/**"
- ".github/workflows/build-client.yml"
pull_request:
paths:
- "desktop/**"
- "protocol/**"
- ".github/workflows/build-client.yml"
workflow_dispatch:
inputs:
# Linux 与 Windows 的 runner 都在线,默认勾上;macOS 还没有机器,默认不勾
# (勾了会一直排队)。开关为什么不用仓库变量见文件头第 4 条。
linux:
description: "构建 Linux(deb + AppImage)"
type: boolean
default: true
windows:
description: "构建 Windows(NSIS 安装包)"
type: boolean
default: true
macos:
description: "构建 macOS(通用 dmg)——还没有 macOS runner,勾了会排队"
type: boolean
default: false
# 同一分支连推两次时把上一次没跑完的取消掉:自建 runner 数量有限,让过期的构建
# 占着机器没有意义。老版本 runner 忽略这个键,不影响构建。
concurrency:
group: build-client-${{ github.ref }}
cancel-in-progress: true
env:
# 与本机开发对齐:node 24 / pnpm 12(pnpm-lock.yaml 就是 pnpm 12.8.1 写的,
# lockfileVersion 9.0)。改这两个数之前先确认 lock 文件还认。
NODE_VERSION: "24"
PNPM_VERSION: "12"
CARGO_TERM_COLOR: always
RUST_BACKTRACE: "1"
jobs:
# ───────────────────────────────────────────────────────────────────────────
# 守门:类型检查、前端构建、两个不依赖窗口的 crate 的单元测试。
# 下面每个构建 job 都 needs 它 —— 编到一半才发现 tsc 报错,是最浪费 runner 的
# 一种失败。src-tauri 的 30 个测试放在 Linux 那个 job 里跑(它本来就要装
# webkit 依赖,而且那几条录制测试要真的起 ffmpeg)。
# ───────────────────────────────────────────────────────────────────────────
checks:
name: 检查(类型 + 单元测试)
runs-on: ${{ vars.RUNNER_LINUX || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
# 版本号从标签算(git describe / rev-list --count)。默认的浅克隆没有标签、
# 提交数恒为 1,version.mjs --check 会误报「版本已过期」——必须先取全历史。
- uses: actions/checkout@v4
with:
fetch-depth: 0
# runner 镜像里多半没有 rustup,而 dtolnay/rust-toolchain 靠它装工具链。
# 这里只装一个最小的 rustup 本身,工具链交给下面那个 action —— 这样自建
# 镜像不用为了 CI 专门烤一个带 Rust 的镜像。
# 国内网络慢的话,在 runner 的环境变量里给 RUSTUP_DIST_SERVER 指一个镜像
# (如 https://mirrors.ustc.edu.cn/rust-static)会快很多。
- name: 确保有 rustup
run: |
if command -v rustup >/dev/null 2>&1; then
rustup --version
exit 0
fi
if command -v curl >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain none
elif command -v wget >/dev/null 2>&1; then
wget -qO- https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain none
else
echo "装 rustup 需要 curl 或 wget,这个镜像里两个都没有" >&2
exit 1
fi
if [ -n "$GITHUB_PATH" ]; then
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
# pnpm 装在 setup-node 之前:这一步只靠 runner 镜像里自带的 node(实测
# /opt/hostedtoolcache 里有 24.x),而下面的 pnpm install 要用它。
- name: 装 pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: 装 Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: 前端依赖(严格按 lock 装)
working-directory: desktop
run: pnpm install --frozen-lockfile
# 版本号与改动日志是从 git 历史算出来的(scripts/version.mjs)。这一步确认
# 仓库里的 version.json 没有被落下:有新提交却没重新生成时,装包里的版本会
# 悄悄停在旧值上,而那种错误要到发布之后才会被发现。
- name: 版本一致性(version.json 是否跟上了 git 历史)
run: node scripts/version.mjs --check
- name: 类型检查(tsc --noEmit)
working-directory: desktop
run: pnpm run check
- name: 前端构建(vite)
working-directory: desktop
run: pnpm build
- name: 装 Rust(stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
# protocol 没有自己的 rust-toolchain.toml,会被根目录那个 `esp` 接住,
# 所以这里必须 +stable 显式指定。
- name: protocol 单元测试(15)
working-directory: protocol
run: cargo +stable test --locked --target x86_64-unknown-linux-gnu
# core 在 desktop/ 下面,desktop/rust-toolchain.toml 已经是 stable,不用 +stable。
- name: cam-core 单元测试(80)
working-directory: desktop/core
run: cargo test --locked --target x86_64-unknown-linux-gnu
# ───────────────────────────────────────────────────────────────────────────
# Linux x64:deb + AppImage
# ───────────────────────────────────────────────────────────────────────────
linux:
name: Linux x64(deb + AppImage)
needs: checks
if: ${{ github.event.inputs.linux != 'false' }}
runs-on: ${{ vars.RUNNER_LINUX || 'ubuntu-latest' }}
timeout-minutes: 120
env:
TARGET: x86_64-unknown-linux-gnu
CARGO_BUILD_TARGET: x86_64-unknown-linux-gnu
steps:
# 版本号从标签算(git describe / rev-list --count)。默认的浅克隆没有标签、
# 提交数恒为 1,version.mjs --check 会误报「版本已过期」——必须先取全历史。
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 确保有 rustup
run: |
if command -v rustup >/dev/null 2>&1; then
rustup --version
exit 0
fi
if command -v curl >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain none
elif command -v wget >/dev/null 2>&1; then
wget -qO- https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain none
else
echo "装 rustup 需要 curl 或 wget,这个镜像里两个都没有" >&2
exit 1
fi
if [ -n "$GITHUB_PATH" ]; then
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
- name: 装系统依赖
run: |
# 容器里跑的 runner 通常是 root,没有 sudo;托管 runner 上有。
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO=sudo
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
libxdo-dev \
libssl-dev \
xdg-utils \
build-essential curl wget file \
ffmpeg
# AppImage 打包要运行 linuxdeploy(它本身是个 AppImage);24.04 起包名带 t64。
$SUDO apt-get install -y libfuse2 || $SUDO apt-get install -y libfuse2t64 || true
- name: 装 Rust(stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: 装 pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: 装 Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: 前端依赖(严格按 lock 装)
working-directory: desktop
run: pnpm install --frozen-lockfile
# 上面 apt 装了 ffmpeg,所以 recording.rs 里那几条"真的起 ffmpeg 并读回帧数"
# 的测试不会走 has_ffmpeg() 的跳过分支,是真的在验。
# 如果这台 runner 上它们长期不稳定,删掉这一步即可 —— 它们不在守门 job 里,
# 不影响另外两个平台的产物。
- name: Tauri 后端单元测试(30)
working-directory: desktop/src-tauri
run: cargo test --locked --target ${{ env.TARGET }}
# beforeBuildCommand(pnpm build)由 tauri 自己跑,这里不用再 build 一次。
- name: 打包(tauri build)
working-directory: desktop
run: pnpm tauri build --target ${{ env.TARGET }} --bundles deb,appimage
- name: 上传产物
uses: actions/upload-artifact@v3
with:
name: rmic-2026-linux-x64
# 一个都没找到就报错:产物路径写错却"构建成功"是最难发现的一种失败。
if-no-files-found: error
retention-days: 14
path: |
desktop/src-tauri/target/${{ env.TARGET }}/release/bundle/deb/*.deb
desktop/src-tauri/target/${{ env.TARGET }}/release/bundle/appimage/*.AppImage
# ───────────────────────────────────────────────────────────────────────────
# Windows x64:NSIS 安装包
# ───────────────────────────────────────────────────────────────────────────
windows:
name: Windows x64(NSIS 安装包)
needs: checks
# 默认就跑:windows-runner-01 在线(自报标签 windows-latest)。
# 开关用 inputs 而不是仓库变量的原因见文件头第 4 条 —— 这个坑正是它踩出来的。
if: ${{ github.event.inputs.windows != 'false' }}
# 这台机器上要预装:MSVC Build Tools(tauri 的 Rust 侧要 link.exe)、WebView2
# 运行时(装了包的应用要它才能起窗口;打包本身不需要)与 rustup。
runs-on: ${{ vars.RUNNER_WINDOWS || 'windows-latest' }}
timeout-minutes: 120
env:
TARGET: x86_64-pc-windows-msvc
CARGO_BUILD_TARGET: x86_64-pc-windows-msvc
# 见文件头第 5 条:这台 runner 的 job 跑在**低完整性**(Low Mandatory Level,
# S-1-16-4096)上,而 Windows 不允许低完整性往中完整性写 —— 用户目录全中招:
# ~/.rustup、~/.cargo、%TEMP%、%LOCALAPPDATA% 下新建任何东西都是
# "Access denied (os error 5)",ACL 再宽松也没用(没有 Deny ACE,是完整性
# 级别拦的)。所以 CI 用到的每个"家目录"都挪到 runner 自己的工作目录下,
# 那几处在 job 内的写探测里实测可写。换一台 Windows runner 时改这几行。
RUSTUP_HOME: C:\Users\admin\runner\windows\cache\ci-rustup
CARGO_HOME: C:\Users\admin\runner\windows\cache\ci-cargo
# LOCALAPPDATA/APPDATA 也得改:pnpm 12 的 store 锁与 cache/state 都挂在
# %LOCALAPPDATA% 下(报错原文:pnpm-store-operation-locks\all-stores.lock:
# 拒绝访问),tauri 打包时下载的 NSIS 也放这儿。本机实测:把 LOCALAPPDATA
# 指到可写目录后,pnpm 会在那里建 pnpm-cache / pnpm-state,install 正常。
LOCALAPPDATA: C:\Users\admin\runner\windows\cache\ci-localappdata
APPDATA: C:\Users\admin\runner\windows\cache\ci-appdata
npm_config_cache: C:\Users\admin\runner\windows\cache\ci-npm
RUNNER_TOOL_CACHE: C:\Users\admin\runner\windows\cache\ci-toolcache
steps:
# 版本号从标签算(git describe / rev-list --count)。默认的浅克隆没有标签、
# 提交数恒为 1,version.mjs --check 会误报「版本已过期」——必须先取全历史。
- uses: actions/checkout@v4
with:
fetch-depth: 0
# 宿主模式的 Windows runner 上,PATH 里的 `bash` 是 C:\Windows\System32\bash.exe
# —— WSL 的启动器,而 WSL 在 runner 账户下起不来,报 E_ACCESSDENIED(日志里是
# 一串 GBK 被当 UTF-8 解出来的乱码 + https://github.com/microsoft/WSL/issues)。
# 于是任何 `shell: bash` 的步骤、以及用 bash 写的 composite action
# (dtolnay/rust-toolchain 就是),都会在 0 秒内失败。
# Git for Windows 自带 bash,把它的 bin 前置到 PATH —— GITHUB_PATH 的语义就是
# **前置**插入,所以它会盖过 System32 那个 —— 后面这些步骤才跑得起来。
#
# 换掉 bash 之后还有第二个坑:这个 job 的环境里 TEMP/TMP 是空的,而 Git Bash 的
# /tmp 是从 TEMP 推出来的;没有 TEMP 它会退回 <Git 安装目录>\tmp
# (C:\Program Files\Git\tmp),那个目录普通权限写不进去,于是 dtolnay 里那句
# log=$(mktemp) 直接挂掉:
# mktemp: failed to create file via template '/tmp/tmp.XXXXXXXXXX': Permission denied
# 所以顺手给 job 指定一个自己建的可写目录(workspace 的同级 ci-tmp)。
# 两个坑都是 2026-10-15 真跑 Windows job 时踩出来的,本地按这样改完后
# mktemp、rustup toolchain install、rustup target add 全部正常。
- name: 让 bash 指向 Git for Windows,并给 job 一个可写的临时目录
shell: pwsh
run: |
if (-not $env:GITHUB_PATH) { throw '这个 runner 没提供 GITHUB_PATH,没法给后续步骤改 PATH' }
if (-not $env:GITHUB_ENV) { throw '这个 runner 没提供 GITHUB_ENV,没法给后续步骤加变量' }
if (-not $env:GITHUB_WORKSPACE) { throw '这个 runner 没提供 GITHUB_WORKSPACE' }
$gitBash = 'C:\Program Files\Git\bin'
if (-not (Test-Path (Join-Path $gitBash 'bash.exe'))) {
throw "找不到 $gitBash\bash.exe:这台机器上没装 Git for Windows,bash 会落到 WSL 上"
}
Add-Content -Path $env:GITHUB_PATH -Value $gitBash -Encoding ascii
$tmpRoot = Join-Path (Split-Path -Parent $env:GITHUB_WORKSPACE) 'ci-tmp'
New-Item -ItemType Directory -Force -Path $tmpRoot | Out-Null
Add-Content -Path $env:GITHUB_ENV -Value "TEMP=$tmpRoot" -Encoding ascii
Add-Content -Path $env:GITHUB_ENV -Value "TMP=$tmpRoot" -Encoding ascii
Add-Content -Path $env:GITHUB_ENV -Value "TMPDIR=$($tmpRoot -replace '\\', '/')" -Encoding ascii
# 第三个坑:pnpm 的 store 操作锁是按 <USERPROFILE>\AppData\Local\
# pnpm-store-operation-locks 算的 —— 实测只把 LOCALAPPDATA 指走它不跟,
# 只把 USERPROFILE 指走它就跟着走(换完 USERPROFILE 后探针目录里立刻出现
# all-stores.lock)。所以 USERPROFILE 也得指到能写的地方,并且先把
# AppData\Local、AppData\Roaming 建好。
$profileRoot = Join-Path (Split-Path -Parent $env:GITHUB_WORKSPACE) 'ci-profile'
New-Item -ItemType Directory -Force -Path (Join-Path $profileRoot 'AppData\Local'), (Join-Path $profileRoot 'AppData\Roaming') | Out-Null
Add-Content -Path $env:GITHUB_ENV -Value "USERPROFILE=$profileRoot" -Encoding ascii
"bash = $(Join-Path $gitBash 'bash.exe')"
"TEMP/TMP/TMPDIR = $tmpRoot"
"USERPROFILE = $profileRoot"
- name: 装 Rust(stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: 装 Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
# 这里**不用** pnpm/action-setup:它装的是 standalone 的 pnpm 二进制,而那个
# 二进制在这台 runner 的低完整性沙箱里走到"把包装进 virtual store"时必崩:
# thread '<unknown>' (6716) has overflowed its stack
# corepack 拉下来的是同一个二进制(也崩),所以改成用 npm 装 JS 版 pnpm ——
# 它跑在 node 上,是另一条代码路径。装到 node 自己的目录里(RUNNER_TOOL_CACHE
# 已经指到可写位置,见 job env)。
- name: 装 pnpm(npm 装 JS 版,绕开 standalone 二进制)
shell: pwsh
run: |
# 前面几次尝试在 node 目录里留下过 pnpm/pnpx 的影子文件,npm 会以
# EEXIST 拒绝覆盖,先清掉(node 目录在 RUNNER_TOOL_CACHE 下,可写)。
$nodeDir = Split-Path (Get-Command node).Source
foreach ($n in 'pnpm', 'pnpx', 'pnpm.cmd', 'pnpx.cmd', 'pnpm.ps1', 'pnpx.ps1') {
Remove-Item (Join-Path $nodeDir $n) -Force -ErrorAction SilentlyContinue
}
npm install -g pnpm@12.8.1
if ($LASTEXITCODE -ne 0) { throw "npm 装 pnpm 失败(exit $LASTEXITCODE)" }
"pnpm = $((Get-Command pnpm).Source)"
pnpm --version
- name: 前端依赖(严格按 lock 装)
working-directory: desktop
shell: pwsh
# 这台 runner 的 job 跑在低完整性沙箱里(见文件头第 5 条),standalone 的
# pnpm 到"把包装进 virtual store"那一步会直接栈溢出:
# thread '<unknown>' (6716) has overflowed its stack
# (store 已经在可写目录、也改成 copy 导入了,仍然复现;同样的命令在普通
# Medium 会话里跑得好好的。)所以先试 standalone,崩了就退回 corepack:
# 让 node 去跑 pnpm 的 JS 实现,绕开那个二进制。
run: |
$pnpmArgs = @('install', '--frozen-lockfile',
'--store-dir', 'C:\Users\admin\runner\windows\cache\ps',
'--package-import-method=copy', '--reporter=append-only')
pnpm @pnpmArgs
if ($LASTEXITCODE -eq 0) { exit 0 }
"::warning::standalone pnpm 失败(exit $LASTEXITCODE),改用 corepack + node 跑 pnpm"
corepack enable
corepack prepare pnpm@12.8.1 --activate
pnpm @pnpmArgs
exit $LASTEXITCODE
# nsis 是纯安装包,不需要 WiX;tauri 会自己把 NSIS 下到用户缓存里(这个缓存
# 也跟着 LOCALAPPDATA 走,见上面 job env),所以这台 runner 要能访问 GitHub
# (离线环境改成 --bundles msi 也一样要下 WiX)。
- name: 打包(tauri build)
working-directory: desktop
run: pnpm tauri build --target ${{ env.TARGET }} --bundles nsis
- name: 上传产物
uses: actions/upload-artifact@v3
with:
name: rmic-2026-windows-x64
if-no-files-found: error
retention-days: 14
path: |
desktop/src-tauri/target/${{ env.TARGET }}/release/bundle/nsis/*.exe
# ───────────────────────────────────────────────────────────────────────────
# macOS 通用包:.app + .dmg
# ───────────────────────────────────────────────────────────────────────────
macos:
name: macOS 通用(.app + .dmg)
needs: checks
# 还没有 macOS runner,所以只有"手动触发 + 勾上"才跑;等机器注册好,把这一行
# 换成 `github.event.inputs.macos != 'false'` 就变成默认跑(同 Windows)。
if: ${{ github.event.inputs.macos == 'true' }}
# 那台机器上要有 Xcode Command Line Tools(链接要它)与 rustup。
runs-on: ${{ vars.RUNNER_MACOS || 'macos-latest' }}
timeout-minutes: 150
env:
# 注意:这里**不能**设 CARGO_BUILD_TARGET —— universal-apple-darwin 是 tauri
# 自己的伪目标(编两遍再 lipo 合成),裸 cargo 不认它。这个 job 里也没有裸
# cargo 步骤,所有目标都由 tauri build 的 --target 交代。
TARGET: universal-apple-darwin
steps:
# 版本号从标签算(git describe / rev-list --count)。默认的浅克隆没有标签、
# 提交数恒为 1,version.mjs --check 会误报「版本已过期」——必须先取全历史。
- uses: actions/checkout@v4
with:
fetch-depth: 0
# 通用包要两个架构的 std 都在。
- name: 装 Rust(stable + 两个 mac 目标)
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: 装 pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: 装 Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: 前端依赖(严格按 lock 装)
working-directory: desktop
run: pnpm install --frozen-lockfile
# 只想要当前架构、想省一半编译时间的话,把 --target 换成 aarch64-apple-darwin
# (Apple Silicon 的 runner)并去掉上面 targets 里的 x86_64 即可。
- name: 打包(tauri build)
working-directory: desktop
run: pnpm tauri build --target ${{ env.TARGET }} --bundles app,dmg
# 第二行是兜底:不同 tauri-cli 版本给通用包放的目录略有差异,宁可多写一行,
# 也不要上传时静默少东西。(注意 path 是块标量,里面写 # 是路径不是注释。)
- name: 上传产物
uses: actions/upload-artifact@v3
with:
name: rmic-2026-macos-universal
if-no-files-found: error
retention-days: 14
path: |
desktop/src-tauri/target/${{ env.TARGET }}/release/bundle/dmg/*.dmg
desktop/src-tauri/target/*/release/bundle/dmg/*.dmg
+33 -4
View File
@@ -1,15 +1,44 @@
# 构建产物。
#
# 三条都必要:根目录是 firmware crate,protocol/ 和 client/ 各自也是独立的
# crate(各有自己的 .cargo/config.toml 与 target 目录),所以只写 `target/`
# 匹配不到它们。
# 每一行都必要:根目录是 firmware crate,protocol/ 也是独立的 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、三个 crate 的 target)。
/target/
/protocol/target/
/client/target/
/armor/target/
/.embuild/
/managed_components/
/sdkconfig
/sdkconfig.old
/sdkconfig.*
!/sdkconfig.defaults
!/sdkconfig.defaults.*
# 本机专属的 cargo 注册表缓存。
#
# 这个仓库所在的目录树在 DSH 的文件沙箱里可写,而 ~/.cargo 不可写,所以 desktop/ 的
# 构建把 CARGO_HOME 指到了这里(里面是从 ~/.cargo 搬过来的 crates 缓存)。它是几 GB 的
# 机器状态,不是项目的一部分。
/.cargo-home/
# WiFi 凭据,编译期由 env! 读取。不要提交。
.env
# pnpm 在本机把 store 落在了工作区里(沙箱下 HOME 里的 store 不可写)。
/.pnpm-store/
# macOS 的目录元数据。不锚定,子目录里的也一起忽略。
.DS_Store
# Python diagnostic helpers.
__pycache__/
# `docs/simba_docs`:工作区里另起的一个 Astro 文档站,与本仓库的目录结构无关,
# 也不属于本项目的产物。留着不跟踪,免得以后 `git add -A` 顺手把它带进来。
/docs/
+35
View File
@@ -0,0 +1,35 @@
{
"version": "0.2.1",
"major": 0,
"minor": 2,
"patch": 1,
"build": 35,
"protocol": 1,
"date": "2026-10-19",
"baseline": "v0.2.0",
"commits": 1,
"groups": {
"fix": [
{
"scope": "ci",
"text": "版本校验在浅克隆下必然误报",
"breaking": false
},
{
"scope": "ci",
"text": "checkout 改成 fetch-depth: 0——没有标签、提交数恒为 1 时,",
"breaking": false
},
{
"scope": "version",
"text": "CHANGELOG 按标签区间重算全部历史,不再只渲染当前版本,",
"breaking": false
},
{
"scope": "build",
"text": "固件包文件名用算出来的版本,不再用 BUILD-INFO.json 里那套旧编号",
"breaking": false
}
]
}
}
+98
View File
@@ -0,0 +1,98 @@
# 改动记录
> 本文件由 `scripts/version.mjs` 依据 git 历史生成,**请勿手改**。
> 提交信息遵循 Conventional Commits,一条一个条目;要求见该脚本的文件头。
> 建立这套机制之前的人工整理记录保存在 [`docs/CHANGELOG-legacy.md`](docs/CHANGELOG-legacy.md)。
## v0.2.1 — 2026-10-19
构建号 **35** · 线路协议 **1** · 1 个提交(v0.2.0 → v0.2.1)
### 修复
- **ci** · 版本校验在浅克隆下必然误报
- **ci** · checkout 改成 fetch-depth: 0——没有标签、提交数恒为 1 时,
- **version** · CHANGELOG 按标签区间重算全部历史,不再只渲染当前版本,
- **build** · 固件包文件名用算出来的版本,不再用 BUILD-INFO.json 里那套旧编号
## v0.2.0 — 2026-10-19
1 个提交
### 新增
- **client** · RMIC::2026 界面重做、可计算的版本机制与固件版本联动
- **client** · 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形
- **client** · 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退
- **client** · 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页
- **client** · 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路
- **client** · 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码
- **client** · 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔
- **version** · 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写
- **firmware** · Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容
- **client** · 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因
- **firmware** · 固件包版本与客户端同源,都从 version.json 读
### 修复
- **client** · getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载
- **client** · 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页
- **client** · 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系
- **protocol** · Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失
- **build** · Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径
- **build** · esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录
### 构建
- **client** · 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO
- **ci** · 守门 job 增加版本一致性检查,产物名改为 rmic-2026-*
### 文档
- 补充界面语言、版本机制与协议通告布局的说明
## v0.1.0 — 2026-10-19
33 个提交
### 新增
- 链路控制、延迟校准与长测工具,补全文档
### CI
- npm 装 pnpm 前先清掉残留影子文件(EEXIST),并去掉 npm 不认的 store-dir
- Windows 改用 npm 装的 JS 版 pnpm(standalone 二进制在低完整性沙箱里必崩)
- Windows 的 pnpm install 加 corepack 退路(standalone 在低完整性沙箱里栈溢出)
- Windows 的 pnpm 安装目录改成每次运行都干净的路径
- 删掉两段临时诊断(Windows 的坑已经查清并写进文件头第 5 条)
- Windows 的 pnpm 那步写死 shell: pwsh(免得 actionlint 拿 bash 规则挑反斜杠路径)
- Windows job 给 pnpm install 明确指定 store 与导入方式
- Windows job 加 USERPROFILE 重定向 —— pnpm 的操作锁按它算路径
- **diag** · 再探一次 —— pnpm 眼里的目录到底是哪个
- Windows job 再加 LOCALAPPDATA/APPDATA 重定向(pnpm 的 store 锁挂在这里)
- Windows job 低完整性适配 —— 把 CI 的"家目录"挪到 runner 自己的工作目录
- **diag** · 在 Windows job 里加一段临时诊断(查完删)
- 修 Windows job 第二个坑 —— TEMP/TMP 为空,Git Bash 的 mktemp 写不进 <Git>\tmp
- 修 Windows job 0 秒失败 —— 宿主 runner 上的 bash 是 WSL,不是 Git Bash
- Windows 产物默认就编(平台开关从 vars 换成 inputs)
- 修两处 Gitea 上跑不了的东西(upload-artifact v4 → v3、去掉缓存)
- 构建客户端 workflow(Windows / Linux / macOS)
### 其它
- 双方血条(角上平行四边形)、归零即击败、去掉受击抖动、裁判端可改队名
- 早先那版 C 写的装甲板固件(ESP-IDF 工程)
- ESP32-C3 装甲板固件(一主三从)+ 烧录脚本,之前一直没提交
- 修两处小问题(预览模式的命中节奏、设置面板里漏出来的 **)
- 用户端与裁判端两套页面(Tauri v2 + React),删掉 Slint 客户端
- Tauri v2 后端 + 共享网络层(用户端/裁判端双身份)
- 文档:写防作弊、缩放布局、槽位语义
- 槽位对应队伍,不再按 id 顺序对号入座
- 修裁判端两格不等宽,加滚轮缩放与拖动平移,启动即最大化
- 队伍端 release 只接收绑定的那一块板子
- 文档:按 UDP 多端架构重写根 README 与 client README
- 修 udp_probe.py 的重组与 fps 两处测量错误
- 更正上一个提交的归因——坏帧不在这里
- 修复重复分片能凑出一张坏帧
- ESP32-S3 摄像头图传:固件 + Slint 客户端 + 共享协议
+129
View File
@@ -0,0 +1,129 @@
# 开发与验证
编译入口见 [README.md](README.md),硬件限制见 [HARDWARE.md](HARDWARE.md),历史变化见 [CHANGELOG.md](CHANGELOG.md)。
## 镜像导出
以下使用 espflash 4.4.0 与固定的 `esp-1.97.0.0` 工具链。完成构建后,使用 `esp-idf-sys` 复制到 `release/` 的 Bootloader。Windows PowerShell:
```powershell
$targetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { 'target' }
New-Item -ItemType Directory -Force target/firmware | Out-Null
espflash save-image --chip esp32s3 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$targetDir/xtensa-esp32s3-espidf/release/bootloader.bin" --merge --skip-padding "$targetDir/xtensa-esp32s3-espidf/release/esp32" target/firmware/esp32s3-merged.bin
```
Linux / macOS(Bash):
```bash
target_dir="${CARGO_TARGET_DIR:-target}"
mkdir -p target/firmware
espflash save-image --chip esp32s3 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$target_dir/xtensa-esp32s3-espidf/release/bootloader.bin" --merge --skip-padding "$target_dir/xtensa-esp32s3-espidf/release/esp32" target/firmware/esp32s3-merged.bin
```
合并镜像包含 Bootloader、分区表、App 和必要地址填充,`--skip-padding` 避免将文件填满 16 MB。镜像从 `0x0` 写入:
```text
espflash write-bin --chip esp32s3 --port COM5 0x0 target/firmware/esp32s3-merged.bin
```
去掉导出命令的 `--merge --skip-padding` 可生成独立 App,它需写入 `partitions.csv` 声明的 Factory 偏移,并与板上 Bootloader 和分区表匹配。坏板重编可能改变该偏移;新板优先使用 ELF 或合并镜像。
## 主机测试
协议层不依赖 ESP-IDF。进入 `protocol/`,以 Windows MSVC 为例:
```powershell
cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
```
Linux 常见 host 为 `x86_64-unknown-linux-gnu`,Apple Silicon 为 `aarch64-apple-darwin`,可用 `rustc +stable -vV` 查看。`--target` 覆盖仓库里的其他平台配置,空 `build-std` 避免主机测试继承嵌入式标准库重编译。
装甲规则从 `armor/` 执行 `cargo +stable test -p armor-protocol`,桌面核心从 `desktop/core/` 执行 `cargo +stable test`,同样按需指定宿主 `--target` 和空 `build-std`。
限帧、温度回差和 JPEG 压缩回压策略可以独立在主机运行,无需连接板卡。Windows 在根目录执行:
```powershell
New-Item -ItemType Directory -Force target | Out-Null
rustc +stable --edition 2021 --test src/stream_control.rs -o target/stream-control-tests.exe
.\target\stream-control-tests.exe
```
实际持续运行的串口与客户端通道记录方法见 [STABILITY.md](STABILITY.md)。主机策略测试不等于已经触发板上过温减载。
## 清理构建缓存
改动 C 组件或 `sdkconfig.defaults` 后,增量构建若没有反映变化,可从根目录执行:
```text
cargo clean --release --target xtensa-esp32s3-espidf -p esp-idf-sys
```
随后用构建脚本重编,确认 CMake / Ninja 重新编译相应组件。仍有缓存问题时使用 `cargo clean` 完整清理。不要删除 `components/esp32-camera/target/`。
构建脚本设置 `CARGO_WORKSPACE_DIR`,并通过 `ESP_IDF_GLOB_BASE` / `ESP_IDF_GLOB_PARTITIONS` 将分区表复制到生成的 CMake 工程,因此支持外部 `CARGO_TARGET_DIR` 和不同 Cargo 输出层级。Windows 使用短目录,如 `C:\s3vt`;设置 `ESP_IDF_PATH_ISSUES=ignore` 或 `subst` 不能消除底层路径限制。
## 发现与协议
- 图传板每 2 秒广播 Hello;客户端每 800 ms 广播 Subscribe,并向已发现的在线板卡 IP 单播续订,固定绑定 UDP 5600。
- 全局广播可能被 VPN / 虚拟网卡路由到其它接口。收到 Hello 后按设备 IP 定向续订,可以使用通往实际局域网的路由;队伍端仅向绑定的板卡增加单播续订。
- 固件使用订阅包的源地址作为发送目标,6 秒不续订即移除,最多保留 4 个对端。
- 新客户端为每个接收器生成随机会话 ID;同一源 IP 下,同一会话换端口会更新推流目标。旧版公共固定 ID `0x0C110001` 保持按地址区分,避免合并同机旧客户端。
- MAC 派生的 `sender_id` 不随 DHCP 地址变化,适合客户端持久绑定。
- Hello 和视频都是设备在线证据;客户端列表清理与固件订阅使用不同超时。
- 数据报为 27 字节头 + 最多 1400 字节载荷,采用小端编码;字段及拒收条件见 [protocol/README.md](protocol/README.md)。
缺失分片不重传,不完整帧整体丢弃。JPEG 由传感器编码,桌面后端转交原始 JPEG,由 WebView 解码,避免接收线程进行耗时编解码。
## 性能测量
本次在保留 TUN 环境中的对照测试、最终参数与验证边界见 [PERFORMANCE.md](PERFORMANCE.md)。
下表为原 README 保存的**历史单次测试数据**,不是本次构建实测或性能保证。配置为 OV5640、VGA;原始日志、时长和完整网络条件未随这些数字保存。
| 指标 | 早期 HTTP MJPEG / TCP | 后续 UDP |
| --- | --- | --- |
| 传感器产出 | 约 22 fps | 约 22 fps |
| 客户端收到 | 约 1.9 fps | 约 22 fps |
| 吞吐 | 约 0.28 Mbit/s | 约 1.6 Mbit/s |
| 丢帧 | 未记录 | 当次报告 0% |
这些数据不足以单独证明性能差异的根因或无线链路容量。复测时记录板型、固件版本、分辨率、画质、信号、订阅者数量、时长和丢帧数。
FPS 应按**同一时间窗口的帧数增量 / 窗口秒数**计算。累计帧数与窗口时长混用会产生假数值,UI 轮询间隔也不代表相机出帧间隔。探测脚本的重组状态需按设备及帧隔离,避免测量错误。
可重复测量:`python scripts/perf_probe.py <板卡IP> --seconds 30 --json perf.json`。默认使用独立的临时端口和 1 MiB 接收缓冲,每 800 ms 定向续订。脚本只依赖 Python 标准库;`--save-frame frame.jpg` 可保存首张完整 JPEG。公平比较应保持订阅者数量、分辨率、画质与网络条件一致。
`frame_loss_percent` 包含不完整、超时及帧号缺口,不包含开始订阅前或测量结束后的帧。`assembly_p95_ms` 是首末分片到达的时间差,`inter_frame_p95_ms` 是完整帧到达间隔,均使用高精度计时;没有发送时间戳及两端时钟同步,不能称为端到端延迟。
固件日志将采集尝试与完整提交分开计数。`submitted` 只表示全部分片交给了网络,不代表无线确认或客户端显示;`send failures` 可能包含随后重试成功的调用,应结合 `peer frames dropped` 和接收端指标判断。
画面链路测量使用裁判端 `--latency-report <新文件>`,让相机拍摄校准页左侧标记,固定相机和窗口位置。标记经正常采集、网络与绘制路径回到客户端,源与回显共用主机单调时钟;不需要 ESP 时钟同步。测试期间只保留一个原生客户端,额外的本地 WebSocket 观察器不增加 UDP 订阅。方法、刷新估计边界及最新结果见 [LATENCY.md](LATENCY.md)。
按记录文件的半开行号区间复算延迟:
```text
python scripts/analyze_latency.py --report latency-run.ndjson --range fb3-range.json --range fb2-range.json --out latency-result.json
```
区间文件至少包含 `start_row`、`end_row` 和 `fb_count`,行号从 0 开始;报告关闭后再分析,避免读取写入中的半行。分析器拒绝重复源标记、无效时间边界与空样本,输出 P50/P95/P99、rAF 区间宽度和解码完成到绘制的耗时。JPEG 接收间隔仍是独立指标。
恢复策略是独立纯 Rust 模块,可在主机运行:
```text
rustc +stable --test src/link_control.rs -o link-control-tests
./link-control-tests
```
Windows 为输出文件加 `.exe`。实机故障注入仅在诊断构建设置 `WIFI_TEST_DROP_AFTER_S`:主循环运行相应秒数后断开一次并保持 5 秒,正式构建保持 `0`。重连后需观察重新取址、socket 重建和订阅恢复,不能仅凭 connect 调用成功判定恢复视频。
## 板上验证
阻塞与缓冲交接测试:`python scripts/test-latest-frame.py` 使用主机 clang/gcc 编译真实 `cam_take_latest` 函数及队列替身,验证持续生产下有限取帧、旧缓冲仅归还一次、无新帧保留有效旧帧,以及首次超时不泄漏。`rustc +stable --test src/stream_control.rs` 验证发送期限、共享重试预算和零重试模式;`desktop/` 的 `pnpm test:stream` 验证 WebView 积压、解码阻塞及过期帧清理。实机验证范围另见 [REALTIME.md](REALTIME.md)。
1. 相机识别、PSRAM 初始化及稳定采集。
2. Wi-Fi 完成关联和 DHCP,得到有效 IP。
3. 客户端启动后订阅者增加并持续显示画面。
4. `python scripts/udp_probe.py 10` 独立核对协议及帧统计。
5. 分别验证多端订阅、断网重连、不同分辨率与持续运行。
编译、镜像生成与主机测试通过均不等于烧录、板上运行或硬件稳定性已经验证。
+464
View File
@@ -0,0 +1,464 @@
# 烧录 / 维护客户端架构设计(ESP32-S3 图传板)
> 状态:**提议中(Proposed)**,等待第 9 节的问题清单答复后冻结。
> 输入约束:现场存在 SPI Flash 坏块;需要兼顾 OTA 与现场排障;**不改固件源码、不重新编译**;
> 优先“模板固件 + 动态 patch”。
> 本文所有结论都在仓库里逐条对过代码/脚本/分区表/文档,引用位置见各节链接。
---
## 0. 结论摘要(先看这五条)
1. **“不改固件源码”可以做到;但“真 OTA”做不到。** 当前固件没有 `ota_0/ota_1/otadata` 分区,
没有 `esp_ota` 调用,UDP 协议里也没有任何命令通道(只有 Hello/Subscribe/视频/血量)。
设备自己没有能力接收固件。所谓“兼顾 OTA”,在现约束下只能是**现场 USB 串口更新**,
或者先落一个 OTA-就绪的分区表(只改二进制,不改源码)作为未来选项。**这是必须先决的问题**。
2. **坏块规避其实根本不需要重编译。** `scripts/flash.sh` 之所以要重编,是因为它把新偏移写回
`partitions.csv`(构建输入)再触发重建;而 **App 镜像不包含自己的分区偏移**,偏移只存在于
`0x8000` 的 `partition-table.bin`(0xC00 字节 + 末尾 16 字节 MD5)。
→ 对同一份已构建镜像,**搜索偏移 + 重写分区表 + 原样写 App**,全程零编译。
这是本方案最大的收益点,也是“模板 + patch”的第一块拼图。
3. **SSID/密码不是 NVS,是 `.rodata` 里的字符串字面量**(`env!` 编译期注入)。
二进制里能直接 grep 到明文。所以“动态改 SSID”= 字节级 patch **App 镜像**,
且必须连带处理**字符串长度常量**与**镜像 checksum/SHA256**。
长度常量能否可靠定位是这个方案真正的技术风险(见 7.2)。
4. **坏块信息不能“读”出来,只能“写进去再读回来”差分出来。** `flash_id` 只给芯片型号与容量,
SFDP 只给参数,启动日志只给症状。仓库里 `0x10863` 的 bit6 挂在 1,就是整段回读比对发现的
(HARDWARE.md)。因此客户端必须自带「写入图案 → 回读 → 差分」与「写后整段校验差分」两条链路。
5. **推荐形态:纯逻辑核心库 `flash-core` + 优先 CLI + 桌面端加一页**。
把最危险的逻辑(镜像格式、分区表、偏移搜索、补丁)做成不碰 I/O 的纯函数,
在主机上用**真实 1.1 MB 镜像做往返测试**——这与仓库已有的 `protocol/`、`stream_control`、
`link_control`「能在主机跑测试的策略代码」风格一致。
---
## 1. 现状梳理(对着代码说)
### 1.1 工程与构建/烧录链
| 路径 | 角色 | 与本次需求的关系 |
| --- | --- | --- |
| [src/](src/) | S3 图传固件(根 Cargo 包,xtensa) | 烧录对象 |
| [partitions.csv](partitions.csv) | 自定义分区表源文件 | 坏字节规避的**构建输入**(现状),本方案改为产物 |
| [scripts/build.ps1](scripts/build.ps1) / [scripts/build.sh](scripts/build.sh) | 读 `.env` → `cargo build` | 现场不想跑 |
| [scripts/flash.sh](scripts/flash.sh) | build → 搜偏移 → 改 csv → **重建** → 校验 → esptool 写 | 只支持 macOS,必须重编 |
| [desktop/](desktop/) | Tauri v2 + React 客户端(视频/血量/录制) | **完全没有串口/烧录能力**(全仓库 `ssid|serial|espflash` 只命中 serde) |
| [target/tools/espflash/espflash.exe](target/tools/espflash/espflash.exe) | 已落地的 espflash 4.4.0 | 现成的协议实现,可直接当库或 sidecar |
| [target/firmware/](target/firmware/) | 已导出的 bootloader / partition-table / app / merged | 真实样本,用于离线验收测试 |
产物三元组(README「编译固件」表)是 **bootloader + partition-table + app**,
今天现场要么用 ELF(`espflash flash ... --partition-table partitions.csv`),
要么用 merged(从 `0x0` 全片写)。这两条路都**没有**坏字节规避能力。
### 1.2 固件侧:配置是编译期的,运行期没有任何入口
```rust
// src/main.rs:36-37
const SSID: &str = env!("WIFI_SSID");
const PASSWORD: &str = env!("WIFI_PASS");
```
- 每次启动都会用编译期值覆盖驱动配置:`wifi.set_configuration(&client_config(auth)?)`
([src/main.rs:502](src/main.rs#L502))。所以 **NVS 里即使存着旧配置也不会被采用**。
- `EspDefaultNvsPartition` 只是被交给 `EspWifi::new`([src/main.rs:178](src/main.rs#L178)),
用于驱动自身的存储,不参与凭据解析。
- 对比:[armor/armor-firmware/src/settings.rs](armor/armor-firmware/src/settings.rs) 的 C3 装甲板
**有** NVS 设置层(队伍/颜色)——说明“运行期配置”在本仓库是被认可的,只是 S3 固件没做。
- 构建脚本强制要求凭据([scripts/build.ps1:40-43](scripts/build.ps1#L40-L43)),
`.env` 里是 `WIFI_SSID='SR-602-2G4'`(10 字节)、`WIFI_PASS=12345678`(8 字节)。
- 已确认:该字面量就在构建产物里(对 [target/firmware/esp32s3-app.bin](target/firmware/esp32s3-app.bin)
检索 `SR-602-2G4` 有命中)。
### 1.3 OTA:三条路全断
| OTA 必要条件 | 现状 | 结论 |
| --- | --- | --- |
| `ota_0/ota_1/otadata` 分区 | 只有 `factory`([partitions.csv:16-18](partitions.csv#L16-L18)) | 可用**重写分区表**补上(不改源码) |
| 固件内有 `esp_ota_*` 接收端 | 无(全仓库无 `esp_ota`) | **必须改源码**,与本约束冲突 |
| 设备可达的控制/数据传输通道 | UDP 只有 Hello/Subscribe/视频/血量([protocol/README.md](protocol/README.md) 无线格式里无命令字) | **必须改源码** |
→ 结论:**“禁止重新编译” ⇒ 真 OTA 不可行**。这不是实现难度问题,是固件里没有任何代码能把
新镜像写进 flash。
### 1.4 坏 Flash 的真实现状(两个模组,两种缺陷)
| 模组 | 证据 | 缺陷 |
| --- | --- | --- |
| 健康板(MAC `90:70:69:f8:f2:14`) | [budget40-verify-all.log](target/camera-diagnostics/budget40-verify-all.log):App @ `0x10000`,`verify OK` | 无 |
| 故障板(开发板) | [HARDWARE.md:59-67](HARDWARE.md#L59-L67) | `0xFADE7` bit5–7 不可写(经验判据:`data[addr] & 0xE0 == 0`);`0x10863` bit6 读回为 1(期望 `0x2e` 实读 `0x6e`) |
关键推论(对架构有决定性影响):
- `0x10863` 位于**未分配空洞** `0x10000–0x70000`(`phy_init` 结束到 `factory` 开始之间,
380 KB 全是空闲 flash)。→ 这块空洞就是天然的**坏块探测试验场**,写坏也不要紧。
- `0xFADE7` 落在 App 镜像的**代码段**内(镜像布局见 1.5),所以“把那个字节改成别的安全值”
会改坏指令;**唯一安全的动作是挪分区**。
- 现行偏移已经漂过至少三次:`0x10000` → `0x50000`([LATENCY.md:88](LATENCY.md#L88))→ `0x70000`
(当前 [partitions.csv](partitions.csv))。每次重编都要重新核对,这正是要消灭的脆弱点。
- 缺陷有两种**方向相反**的形态:卡在 0(写不进 1)与卡在 1(写不进 0)。后者在“擦除后读回 0xFF”
的检查里是**隐形**的,必须写 0 才暴露。探测逻辑必须两种都打图案。
### 1.5 已实测出来的镜像布局(`fps-final-raw-serial.log` 逐段对齐算出)
```
0x000000 esp_image_header_t (24B) magic E9, segment_count, entry_addr, hash_appended
0x000018 segment 0 header (8B) → load 0x3c0d0020 ← DROM / .rodata(SSID 字面量在这里)
0x000020 segment 0 data (0x2a2a8) 开头 256B 是 esp_app_desc_t(version/project_name/idf_ver…)
0x02a2d0 segment 1 (0x05d48) → 0x3fc9e500 DRAM
0x030020 segment 2 (0xc60c0) → 0x42000020 IROM 代码(0xFADE7 落在这里)
0x0f60e8 segment 3 (0x021c0)
0x0f82b0 segment 4 (0x0163f8)
0x10e6b0 segment 5 (0x00020)
→ 段尾 16B 对齐填充 + 1B checksum(XOR, 种子 0xEF) + 16B 对齐 + 32B SHA256
0x10e700 = 1,107,712 B(与 budget40-verify-all.log 的 0x10e700 一致)
```
几个直接可用的副产品:
- `esp_app_desc_t` 在 **App 分区 + 0x20**,内含 `version` / `project_name` / `idf_ver` / 编译时间。
**不需要让板子跑起来**,读 256 字节就能回答“这块板上到底是哪版固件”。
- 分区表就是 0x8000 起的 0xC00 字节(`verify 0xc00 @ 0x8000` 佐证),末尾 16 字节 MD5。
改写偏移只要重算这 16 字节。
### 1.6 需求 vs 现状 gap
| 需求 | 现状 | Gap |
| --- | --- | --- |
| 插入即自动识别 | 无 | 串口枚举 + ROM 探测 + 读镜像描述符 |
| 选文件烧录 | 只能用脚本 + 手敲端口 | 文件分类(bundle / merged / app / ELF)+ 计划预览 |
| 自动跳过坏块 | 半自动:改 csv + 重编 + 手工核对 | 偏移搜索 + 分区表重写 + 写后差分 |
| 动态改 SSID | 改 `.env` + 重编 + 重烧 | 镜像内字面量 patch(含长度常量与哈希) |
| OTA | 无 | **不可行**(见 1.3) |
| 现场排障 | 串口日志靠人看 | 结构化阈值断言 + 回读差分 + 设备档案/回滚 |
---
## 2. 三个方案对比
### 方案 A:桌面端内嵌烧录模块(Tauri 单进程)
在 [desktop/](desktop/) 里加 `flash` 页面,Rust 侧用 `espflash` 库直接开串口。
- ✅ 现场只有一个程序;已有 NSIS 打包、CI、配置与密码闸门可复用。
- ✅ 视频端本来就要连板子,烧完顺手进监控很自然。
- ❌ WebView 里跑长任务 + 进度 + 日志尾随,需要额外的事件/取消/背压设计。
- ❌ 烧录是**系统级、不可中断、会变砖风险**的操作,和“看画面”共享一个进程的失败域。
- ❌ 无法在 CI / 无人值守 / 远程 SSH 场景复用。
### 方案 B(推荐):纯逻辑核心库 + 双前端(CLI 优先,GUI 复用)
`flash/core`(无 I/O,纯函数,主机可测)+ `flashctl` CLI + 桌面端后续加一页调用 Tauri 命令。
- ✅ 最容易错的部分(镜像格式、checksum/SHA256、分区表 MD5、偏移搜索、补丁)**全部可在主机测**,
且可以用仓库里真实的 1.1 MB 镜像做往返验收。
- ✅ CLI 先天可脚本化:CI 能跑、现场能远程跑、能出确定退出码与机器可读报告。
- ✅ GUI 后置,做完 CLI 后界面只是薄壳;失败域分开(GUI 崩了不影响烧录工具)。
- ❌ 多一个二进制要分发(可由桌面端作为 sidecar 一起打包解决)。
- ❌ 现场非技术用户需要 GUI(用 P2 的 Tauri 页面补上)。
### 方案 C:外部进程编排(客户端只做壳,调 `espflash.exe` / `esptool.py`)
- ✅ 落地最快,协议风险最低;`target/tools/espflash/espflash.exe` 已经在了。
- ✅ 与现有 `scripts/*.sh` 心智一致。
- ❌ **拿不到回读差分**(espflash 内部校验只给 pass/fail,不给字节列表)——而差分是本方案
坏块检测的核心,只能再调一次 `read-flash` 自己比。
- ❌ 解析人类可读输出、处理 `esptool.py` 的 Python 依赖,现场极易碎(现有 flash.sh 就是被
“macOS 串口名 + termios + 本地 IDF venv”绑死的)。
- ❌ 补丁/分区表重写仍要自己写,等于 B 的核心工作一样不少,只省了协议层。
### 决策矩阵(权重按当前项目处境给)
| 维度 | 权重 | A 内嵌 | **B 核心库+双前端** | C 外部编排 |
| --- | --- | --- | --- | --- |
| 可测试性(本方案最需要) | 30% | 6 | **9** | 4 |
| 坏块检测能力(要回读差分) | 25% | 8 | **9** | 5 |
| 现场可用性 | 20% | 9 | 7 | 6 |
| 线上/CI 可自动化 | 15% | 3 | **9** | 6 |
| 交付成本 | 10% | 6 | 6 | **9** |
| 加权 | | 6.45 | **8.10** | 5.65 |
**推荐 B**,并明确:GUI 不是不做,而是**放到 P2**——先用 CLI 把危险逻辑做对、做实测。
---
## 3. 推荐架构
### 3.1 分层(依赖方向单向,内层不认识外层的存在)
```
┌─────────────────────────────── 前端(可替换) ───────────────────────────────┐
│ flashctl CLI(P0/P1) desktop Tauri 命令 + 「维护」页(P2) │
└───────────────┬────────────────────────────────────┬────────────────────────┘
│ │
┌───────────────▼────────────────────────────────────▼────────────────────────┐
│ flash-device —— I/O 适配层(唯一碰硬件的地方) │
│ · 串口枚举/VID-PID 过滤 · espflash Connection/Flasher 包装 · 回读 │
│ · 监视器(日志尾随 + 断言)· 自动复位/进下载模式策略 │
└───────────────────────────────┬──────────────────────────────────────────────┘
│ 只传「字节 + 计划 + 观察值」
┌───────────────────────────────▼──────────────────────────────────────────────┐
│ flash-core —— 纯逻辑(无 I/O、无 serde-io、可在主机完整测试) │
│ image │ parttable │ placement │ badmap │ manifest │ patch │ verify │ session │
└──────────────────────────────────────────────────────────────────────────────┘
```
建议目录(沿用本仓库“每个目标一套 workspace”的既有做法):
```
flash/
core/ crate flash-core lib,纯函数 + 单测(含对真实镜像的往返测试)
cli/ crate flashctl bin,含 flash-device 适配(串口/espflash)
templates/ 模板生产脚本:从 ELF 生成 manifest(开发期一次性,不进现场)
```
`desktop/src-tauri` 之后以 path 依赖引 `flash-core` + `flash/cli` 的适配层。
### 3.2 模块职责
| 模块 | 职责 | 关键不变量 / 失败模式 |
| --- | --- | --- |
| `image` | 解析 ESP-IDF App 镜像:header、段表、`esp_app_desc_t`、checksum 位置、SHA256 位置;重算哈希;打字节补丁 | 往返测试:对未修改镜像重算出的 checksum/SHA256 必须与文件里已有的**逐字节相同** |
| `parttable` | 解析/序列化 0xC00 分区表;改某条的 offset;重算 MD5 | 输出必须能被 `esp-idf` 的 `esp_partition` 规则接受(4 KiB 对齐、不重叠、不越界) |
| `placement` | 给定(镜像字节, 坏点表, 约束)枚举合法 App 偏移,**并给出每个候选被拒的原因** | 纯函数;同一输入必须给同一结果;找不到时必须说清是哪条约束卡住 |
| `badmap` | 缺陷模型:`{addr, stuck0_mask, stuck1_mask, sector, confidence}`;提供 `byte_storable(addr, value)` | 两向谓词:`(v & stuck0)==0 && (v & stuck1)==stuck1` |
| `manifest` | 模板清单:镜像哈希绑定、补丁点(偏移/容量/长度字位置)、能力标志 | 清单与镜像哈希不匹配 → **拒绝**,不做“尽力而为” |
| `patch` | 应用凭据补丁(字符串 + 长度字)+ 哈希重算,输出变更日志 | 每次补丁都产出「改了什么、改在哪、前后值」,写进报告 |
| `verify` | 期望字节 vs 回读字节 → 缺陷列表(地址、期望、实读、推导出的 stuck mask) | 差分必须能表达“读出比期望多 1 位/少 1 位” |
| `session` | 状态机:`Enumerate → Identify → LoadBundle → Plan → Patch → Write → Verify → BootCheck → Report` | 每步可 dry-run;任一步失败可回滚到写前备份 |
| `report` | `FlashReport`(本次)与 `DeviceProfile`(按 MAC 累积的坏点表/上次好镜像/历史) | 档案只作**提示**,最终决策永远以本次回读为准 |
### 3.3 数据模型(三份 JSON,人可读、可 diff、可回滚)
```jsonc
// manifest.json —— 与模板镜像一起发布,模板生产线生成
{
"schema": 1,
"app_sha256": "…", // 本清单只对这一份 app.bin 有效
"chip": "esp32s3",
"image": { "size": 1107712, "segments": 6, "hash_appended": true },
"patch_points": [
{ "id": "ssid", "kind": "utf8", "str_offset": 123456, "capacity": 10,
"len_words": [{"offset": 210000, "width": 4}], "max_len": 32 },
{ "id": "password", "kind": "utf8", "str_offset": 123466, "capacity": 8,
"len_words": [], "max_len": 64 }
],
"capabilities": { "equal_length_only": ["password"] }
}
// device-<mac>.json —— 主机侧设备档案(累积)
{ "mac": "…", "chip": "esp32s3", "flash_jedec": "0x…", "flash_size": 16777216,
"defects": [ {"addr": 1027047, "stuck0": 224, "stuck1": 0, "confidence": "verified"} ],
"last_good": { "bundle": "…", "app_offset": "0x50000", "at": "…" },
"history": [ { "at": "…", "app_offset": "0x70000", "result": "ok" } ] }
// report-<timestamp>.json —— 每次烧录一份,可交给现场/复核
{ "device": "…", "bundle": "…", "plan": { "app_offset": "0x70000", "patches": ["ssid"], "erase": [...] },
"write": { "ok": true }, "verify": { "mismatches": [] },
"boot": { "signature": "Loaded app from partition at offset 0x70000", "ip": "…" } }
```
### 3.4 状态机(每步都能停、都能讲清为什么)
```
Enumerate ─▶ Identify ─▶ LoadBundle ─▶ Plan ──(人工确认)──▶ Patch ─▶ Write ─▶ Verify ─▶ BootCheck ─▶ Report
│ │ │ │ │ │ │ │
│ │ │ └─ 打印全部计划:偏移/补丁/擦除范围/预计耗时
│ │ └─ 分类:bundle | merged | app | ELF;类型必须**显示并由人确认**
│ └─ 芯片型号、MAC、Flash JEDEC/容量、0x8000 分区表、App 描述符(离线读)
└─ 端口枚举 + VID/PID 过滤;只有一个候选才自动选中
```
`Plan` 必须可 `--dry-run` 输出到 stdout(JSON),这是 CI 与现场复核的接缝。
---
## 4. 坏块屏蔽的落点(四层,各层归属明确)
### 4.1 检测(`flash-device::probe` + `flash-core::verify`)
| 手段 | 能发现什么 | 成本 | 用在哪 |
| --- | --- | --- | --- |
| `flash_id` / JEDEC + SFDP | 厂商、型号、容量、时序 —— **不含缺陷** | 毫秒 | 识别芯片、判断是否可疑批次 |
| **图案探测**:向空洞 `0x10000–0x70000` 写 `00 / 55 / AA / E0 / 1F` 等图案并回读差 | 卡 0、卡 1、整扇区失效 | 380 KB × N 图案,约十几秒 | 首次接管一块板 / 用户点「深度体检」 |
| **写后整段回读差** | 本次实际没写进去的字节(**权威**) | 1.1 MB 回读,秒级 | **每次烧录都做**,不可选 |
| 串口日志诊断 | 症状:`invalid header` / checksum 失败 / rst 原因 | 秒级 | 板子已经在现场、只能看现象时 |
要点:
- 图案探测必须**双向**:写 0 找卡 1,写 1 找卡 0。擦除态 `0xFF` 会让“卡 1”完全隐形——
`0x10863` 就是整段回读才发现就是这个原因。
- 探测区选空洞而不是 NVS:**不要为了体检去毁 NVS**。若怀疑 NVS 区有坏点,单独提供
“备份 NVS → 探测 → 还原”的危险模式,默认关闭。
- probe 结束后必须 `erase` 干净,并**复核空洞回到 0xFF**,避免留下疑似数据。
### 4.2 决策(`flash-core::placement`,纯函数)
```
输入:image(bytes), badmap, {flash_size, align(min 4KiB, 首选 64KiB), min_offset=0x20000,
app_size=0x120000, 保留区表}
对每个候选 off(升序,先 64 KiB 网格,再 4 KiB 网格兜底):
① off 4 KiB 对齐,off ≥ min_offset,off + image.len() ≤ flash_size
② off ≥ 0x20000 且分区不与 0x10863 所在扇区重叠(等价于“不覆盖已坏空洞”)
③ 对每个“已证坏点 b”:若 off ≤ b < off+image.len(),要求 byte_storable(b, image[b-off])
④ 分区 [off, off+0x120000) 不与任何 bad sector 相交(注意:**镜像尾部之外的 slack 不算**)
输出:第一个满足全部条件的 off;若都没有,逐个候选打印“被 ③ 卡住,b=0xFADE7 需要 image 字节
的 bit5..7 为 0,实际 0x6E”——这正是本仓库一贯的“失败要能解释”的风格。
```
三点必须写进设计:
- **对齐放宽到 4 KiB**:App 分区只要求 4 KiB 对齐,`flash.sh` 用 64 KiB 是自我限制。
候选从 ~230 个变成 ~3700 个,命中概率高一个数量级(单坏点 1/8,双坏点也能稳过)。
- **分区大小 ≠ 镜像大小**:`0x120000` 的分区里镜像只占 `0x10E700`,尾部 71 KiB 是空的。
坏扇区落在尾部 slack 里**无害**,别把它算成阻塞。
- **bootloader 与分区表固定偏移**:`0x0`(0x5160) 与 `0x8000`(0xC00) 挪不了。
这里只能做**字节级吸收**:例如坏点落在分区表某条 `label` 字段里,就换一个 label 让那一位合法
(label 是自由文本);落在 `magic/offset/size/MD5` 上则该板报废。这条路径要显式实现并明确其边界。
### 4.3 执行(`flash-core::parttable` + `image`)
- 改写 `partition-table.bin` 中 `factory` 的 offset(和必要时 size)→ 重算末尾 16 字节 MD5。
- **App 镜像原样写入新偏移**——镜像里没有任何“我在哪个偏移”的信息,改位置不改变它。
- 只有在“分区表/bootloader 必须字节吸收”这一层才动镜像,并且要重算 checksum/SHA256。
### 4.4 验证与回滚(`session` + `report`)
- 写前:把 `0x0–0x9000` + 分区表 + App 分区整段回读到 `rescue-<mac>-<ts>.bin`(约 1.2 MB)。
这既是回滚素材,也是一份“坏点快照”。
- 写后:回读 → 差分 → 若有 mismatch,**先更新 badmap,再重新规划**,而不是简单重试。
- 引导:抓串口,断言 `Loaded app from partition at offset 0x<off>` 与
`ESP32 camera is live on <ip>`;失败则按 rst 原因给出分类结论(镜像校验失败 / 找不到分区 /
Wi-Fi 没连上 / 相机没起来)。**写入成功 ≠ 能跑**,这是仓库文档反复强调的点。
**为什么这套不会变砖**:ROM 下载模式永远在,只要不是 eFuse 层面的破坏(secure boot / flash
encryption / 烧写保护),任何 App/分区表错误都只是“不启动”,可以用同一根 USB 线救回来。
前提是**确认这些 eFuse 特性没开**(见问题 Q6)。
---
## 5. 动态 patch 的落点
### 5.1 必须重算的两样东西(否则一定不开机)
App 镜像的完整性由 bootloader 校验:
```
checksum = 0xEF XOR (所有段的所有数据字节) // 1 字节,位于段尾 16B 对齐之后
sha256 = SHA256(镜像中它之前的全部字节) // 32 字节,仅当 header.hash_appended=1
```
任何字节补丁都会同时破坏这两者。`flash-core::image` 必须实现并在**真实镜像上做往返测试**
(重算结果与文件里已有的字节必须完全相同)——这是整个方案最便宜、最有效的验收手段,
在碰硬件之前就能证明实现是对的。
另外:`esp_app_desc_t` 里的 `app_elf_sha256` 是 ELF 的哈希,补丁后必然与内容不符。
它只用于 core dump 关联,**没有校验作用**,可以接受;但要在报告里写一句,避免以后有人拿它
当“镜像指纹”。真正的指纹用整镜像 SHA256。
### 5.2 SSID / 密码补丁的两种模式(这是本方案的真实技术风险)
事实基础:
- `const SSID: &str = env!("WIFI_SSID")` 是编译期常量,长度也是编译期常量。
- 字面量本身在 `.rodata`(segment 0,`0x3c0d0020` 附近),二进制里可 grep。
- 但**长度**在机器码里可能以三种形式存在:`.rodata` 里的 `(ptr,len)` 静态对
(`format_args!` 走 trait object 时几乎必然产生一份)、`movi` 立即数、或两者混用。
| 模式 | 做法 | 前提 | 风险 |
| --- | --- | --- | --- |
| **M1 等长补丁** | 只改写字符串字节,长度不动 | 新值**字节长度 == 模板槽位长度** | 低。仅当现场 SSID 长度固定时可用 |
| **M2 长度字补丁** | 改字节 + 改所有 `(ptr,len)` 长度字 | 模板生产期能**定位全部**长度字并验证 | 中高。混用立即数时会出现“日志显示新 SSID 但连接用的旧长度” |
工程对策(三条,缺一不可):
1. **模板生产线生成 manifest**(`flash/templates/`):构建出 ELF 后,用符号表 + 字面量地址
反查所有引用,产出 `str_offset / capacity / len_words[]`。这是一次性、离线、可审计的活动,
**不发生在现场**。
2. **清单里写清能力**:`capabilities.equal_length_only: ["password"]` 之类。M2 只在
“长度字全部定位成功”时启用;否则清单退化为 M1,客户端在长度不匹配时**拒绝并给出可执行的
建议**(“本模板 SSID 槽位 10 字节,请把路由器 SSID 设为 10 字节,或使用 N 字节模板”)。
3. **硬件验收一次,之后才允许现场用**:模板发布前跑一遍「patch 成另一个等长 SSID → 烧 → 看日志
是否连上」。连不上就把该模板降级为 M1。这一步可以脚本化,属于发布门禁。
> 注意 `format_args!` 会**把补丁后的 SSID 原样打进日志**(`"{SSID}" was not in the scan results`),
> 所以现场日志本身就能佐证补丁是否生效——这是免费的验收信号,也是排障的第一手材料。
### 5.3 模板固件的构建约定(一次性,发布期)
```text
WIFI_SSID = 32 字节占位(留满槽位,便于 M2);WIFI_PASS = 64 字节占位
其余 .env 参数按交付基线冻结
产物:bootloader.bin + partition-table.template + app.bin + manifest.json → 一个 bundle
```
这样**现场只选 SSID/密码,不再触碰 `.env`、不再跑 cargo**。
---
## 6. 关键风险
| # | 风险 | 影响 | 对策 |
| --- | --- | --- | --- |
| R1 | 长度字定位不全,M2 补丁“半生效” | 板子连不上 Wi-Fi,看起来像“密码错了” | 清单声明能力 + 发布门禁实测 + 不匹配就拒绝(不猜) |
| R2 | 补丁后未重算 checksum/SHA256 | 不启动(可救,但现场中断) | 真实镜像往返测试;写入前本地自校验 |
| R3 | 偏移漂移:新镜像可能**没有**合法偏移 | 该板无法升级 | 4 KiB 对齐搜索 + 尾部 slack 检查 + 找不到时明确报“这块板的 flash 太坏” |
| R4 | 坏点表串板/过期(缺陷随温度、擦写次数变化) | 写进去才发现 | 档案只作提示;**每次写后必做回读差分** |
| R5 | 坏点在 bootloader/分区表固定区 | 无法规避 | 仅 label 等自由字段可吸收;其余明确报废,别赌 |
| R6 | 自动复位/进下载模式不可靠 | “插入即烧”的第一步就失败 | 先实测;失败时降级为“按 BOOT 提示 + 重试”,并把步骤画在界面上 |
| R7 | 多程序抢串口 / 抢 UDP 5600 | 现场打不开或烧不了 | 串口独占提示;烧录模式与视频模式互斥提示(沿用现有 `startup-error.txt` 风格) |
| R8 | secure boot / flash encryption / eFuse 保护开启 | **整套方案失效**(镜像是加密/签名的) | 上线前确认关闭;客户端探测到加密镜像直接拒绝并说明 |
| R9 | 模板与清单版本错配 | 打到错误偏移 | 清单绑定 `app_sha256`,不匹配即拒绝 |
| R10 | OTA 需求最终被判定为硬需求 | 架构返工 | **第 9 节 Q2 必须先答**;先落 OTA-ready 分区表作为廉价期权 |
| R11 | `esp_app_desc_t.app_elf_sha256` 补丁后不符 | 误导后人 | 报告里注明;指纹改用整镜像 SHA256 |
| R12 | 破坏 NVS(改分区布局或误擦) | 丢绑定/校准,重连变慢 | 默认不擦 NVS;提供备份/还原;改布局前显式确认 |
---
## 7. 必须向用户确认的问题(决定架构是否成立)
| # | 问题 | 为什么现在必须答 | 我的默认建议 |
| --- | --- | --- | --- |
| **Q1** | 客户端宿主平台与技术栈?Windows-only 还是三平台?扩展 [desktop/](desktop/) 还是独立工具? | 决定打包、串口栈、UI 形态 | Rust 核心库 + CLI(P1)→ Tauri 页面(P2),Windows 优先 |
| **Q2** | **“OTA”到底指什么**:(a) USB 现场更新;(b) 设备自己从网上拉;(c) 局域网推 | (b)(c) 与“禁止改固件”**直接互斥**,架构会完全不同 | 先按 (a) 做;同时只改分区表落一个 OTA-ready 布局作期权 |
| **Q3** | SSID 修改是否接受**等长约束**?现场 SSID 长度是否固定?是否出现过 ≥16 字节或含中文/空格? | 决定 M1 够不够,还是必须做 M2 并承担风险 | 双模:M1 保底,M2 需模板验收通过才启用 |
| **Q4** | 是否允许**写入图案做坏块探测**(十几秒、会擦除未分配空洞)?坏板一共几块?坏点是否稳定复现? | 决定检测深度与是否值得做 probe | 首次接管一块板自动探测一次;之后只做写后差分 |
| **Q5** | 现有板子的**自动复位/进下载模式**可靠吗(是否必须手按 BOOT/EN)? | 决定“插入即烧录”能不能成立 | 实测;不可靠则做“半自动 + 引导” |
| **Q6** | 是否启用过 secure boot / flash encryption / eFuse 写保护? | 开了则本方案**整体失效** | 假设关闭,需你确认 |
| **Q7** | 现场更新时可否重写**分区表与 NVS**?丢了客户端绑定/设置能接受吗? | 有些修复必须动布局 | 默认不碰 NVS;改布局前备份并确认 |
| **Q8** | 是否需要同一个客户端也支持 **ESP32-C3 装甲板**([armor/](armor/))? | 影响芯片抽象与模板管理 | 预留 chip 抽象,P2 再接 C3 |
| **Q9** | 现场是否允许**任意固件文件**(用户挑一个 `.bin/.elf`),还是只允许“官方 bundle”? | 决定 manifest 从哪来:bundle 自带 vs 客户端现场解析 ELF | 两者都支持:任意文件走“无 patch 模式”(坏块规避仍然有效),bundle 才有 SSID patch |
| **Q10** | 坏块规避失败(该板无合法偏移)时的产品行为:拒绝升级 / 允许“尽力而为” / 提示换模组? | 决定失败语义 | **拒绝并给证据**,不做尽力而为 |
---
## 8. 分期建议
| 阶段 | 内容 | 验收标准 |
| --- | --- | --- |
| **P0**(1 周) | `flash-core`:`image` + `parttable` + 真实镜像往返测试;CLI 能“读懂一份 bundle + 打印计划(dry-run)” | 未修改镜像的 checksum/SHA256 重算与原文件逐字节一致 |
| **P1**(2 周) | `placement` + `verify` + 串口写入 + 回读差分 + 引导断言;**在健康板与故障板上各跑通一次** | 故障板零重编译完成升级;`0xFADE7` 落点字节被证明可写;日志断言通过 |
| **P2**(2 周) | manifest 生产线 + SSID/patch(M1 先上,M2 门禁通过再开)+ 设备档案 + 回滚 | 同一 bundle 换 SSID 后板子连上新 AP,无需任何编译 |
| **P3** | desktop Tauri「维护」页(复用 P1/P2 的一切) | 现场非技术用户可完成“插入 → 识别 → 选包 → 烧 → 看到连上” |
---
## 附录 A:两条 ADR
### ADR-001:坏字节规避从“重编译”改为“分区表重写”
- **状态**:提议中
- **背景**:`0xFADE7` 的 bit5–7 不可写;`scripts/flash.sh` 通过改 `partitions.csv` 并重建来避开它,
但重建会移动镜像内容,于是需要“第二次验证”,且只支持 macOS、必须带完整 IDF 工具链。
- **决策**:把 App 偏移从**构建输入**改成**烧录期的计划值**:搜索偏移 → 改写
`partition-table.bin`(重算 MD5)→ App 镜像原样写入。
- **备选**:① 继续重编译(现状);② 为每块板生成专用 merged 镜像;③ 用 `otadata` 之类间接重定向。
- **影响**:现场彻底摆脱工具链;同一份镜像可服务健康板与故障板;代价是客户端必须正确实现镜像与
分区表格式(用真实镜像的往返测试兜底)。
### ADR-002:核心逻辑与 I/O 分离,CLI 先于 GUI
- **状态**:提议中
- **背景**:本方案里最容易错、错了最难在现场发现的,是镜像格式、哈希重算、偏移搜索与差分;
而这些恰好都能在主机上测。仓库已有 `protocol/`、`stream_control`、`link_control` 的先例。
- **决策**:`flash-core` 纯函数、无 I/O;`flash-device` 单独承担串口与协议;GUI 最后接。
- **备选**:直接写进 Tauri 后端(失败域大、难测);用 Python 脚本 (现有风格) 但无法复用 Rust 类型。
- **影响**:多一个 crate 与一条编译链;换来可在 CI 上守护的不变量,以及将来 GUI/CLI/远程三处复用。
+77
View File
@@ -0,0 +1,77 @@
# 图传板硬件说明
对应 Freenove ESP32-S3-WROOM CAM、ESP32-S3-WROOM-1-N16R8 与 OV5640。构建和烧录入口见 [README.md](README.md)。
## 摄像头引脚
配置以 `src/camera.rs` 的 `pins` 模块为准。DATA 按 D0–D7(传感器 Y2–Y9)排列。
| 信号 | GPIO |
| --- | --- |
| RESET | 0 |
| XCLK | 15 |
| PCLK | 13 |
| VSYNC | 6 |
| HREF | 7 |
| SIOD / SDA | 4 |
| SIOC / SCL | 5 |
| D0–D7 | 11, 9, 8, 10, 12, 18, 17, 16 |
| PWDN | 未连接(`-1`) |
当前 XCLK 为 20 MHz。不要直接套用 AI-Thinker、XIAO 等其他板卡的相机引脚表。
本模组需避开的资源包括 Flash / PSRAM 总线(GPIO 26–37)、USB(19 / 20)、串口(43 / 44)及模组内部引脚,以原理图为最终依据。占用 Flash / PSRAM 引脚可能造成传输期间 CPU 挂死、串口静默。
## 内存与任务配置
参数位于根目录 `sdkconfig.defaults`:
| 参数 | 当前值 | 用途 |
| --- | --- | --- |
| `CONFIG_SPIRAM_MODE_OCT` | `y` | 8 MB Octal PSRAM |
| `CONFIG_SPIRAM_SPEED_80M` | `y` | PSRAM 时钟 |
| `CONFIG_CAMERA_PSRAM_DMA` | `n` | DMA 经内部 RAM 中转后复制到 PSRAM |
| `CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP` | `n` | Wi-Fi / lwIP 缓冲保留在内部 RAM |
| `CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240` | `y` | CPU 240 MHz |
| `CONFIG_CAMERA_CORE1` | `y` | 显式选择 CPU1,与 Wi-Fi 分开(只禁用 CORE0 不足以改变 choice 默认值) |
| `CONFIG_FREERTOS_HZ` | `1000` | 发送拥塞时以 1 ms tick 让出调度 |
| `CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM` | `64` | 发送缓冲数量,使用内部 RAM |
| `CONFIG_ESP_WIFI_TX_BA_WIN` | `32` | 保留 AMPDU 发送聚合 |
相机使用 3 个 PSRAM 帧缓冲及 `GRAB_LATEST`,优先取得较新的画面。JPEG 编码由传感器完成,S3 主要负责采集和发送。
原测试板启用 PSRAM DMA 后曾出现 `NO-EOI`:JPEG 结束标记位于驱动末尾 DMA 搜索窗口之外,随后停止交付帧。因此默认关闭该配置;更换驱动后应单独验证持续采集。
## Flash 与分区
默认 **16 MB、DIO、40 MHz**,分区来自 `partitions.csv`:
| 分区 | 偏移 | 大小 |
| --- | --- | --- |
| NVS | `0x9000` | `0x6000` |
| PHY init | `0xF000` | `0x1000` |
| Factory app | 以 `partitions.csv` 为准,坏板重编时可能调整 | `0x120000`(1152 KiB) |
分区表位于 `0x8000`,S3 Bootloader 从 `0x0` 开始。App 写入分区表声明的偏移,不能直接套用 `0x10000`。修改分区或 Flash 模式后需重新构建,成套更新 Bootloader、分区表和 App。
健康硬件可以评估恢复 `0x10000` App 偏移或更快 Flash 配置,但需重新验证。
## 损坏 Flash 模组的兼容处理
早期测试模组在绝对地址 **`0xFADE7`** 存在写入异常,bit 5–7 无法正确写入。这是特定模组的故障。当前 App 偏移来自该板的开发过程,不能保证新镜像仍避开坏字节。
2026-10-18 的整段回读还发现 **`0x10863`** 的 bit 6 异常读回为 1:期望 `0x2e`,读回 `0x6e`,该轮 App 只有这一处字节不同,尚未单独擦写复核是否为永久故障。该模组的 App 偏移需至少为 `0x20000`,并继续检查 `0xFADE7`;烧录工具的校验失败必须先排除,不能当作网络或相机故障。
`scripts/flash.sh` 保留的流程为:编译、导出 App、搜索 64 KiB 对齐的可写偏移、修改分区、重新构建和验证,再写入三段镜像。重新构建可能移动镜像内容,因此第二次验证不可省略。
脚本依赖 macOS 串口名、Unix `termios`、本地 ESP-IDF Python 目录及旧版工具参数,不适合作为通用烧录入口。坏板需核对工具版本及坏地址字节;普通板卡使用 README 流程。更换故障模组比长期依赖镜像位置更可靠。
## 本地相机组件
`components/esp32-camera/` 包含上游 2.1.7 及本地兼容补丁,`components/cam_wrapper/` 提供 Rust FFI 封装。
本地增加 `esp_camera_fb_get_latest` / `cam_take_latest`:等待第一张有效帧后,以有限次数零等待取出更新帧,并归还替换的缓冲。`cam_take(0)` 表示单次非阻塞尝试,不在空队列、坏 JPEG 或 DMA 复位哨兵上继续等待。升级驱动时需保留或移植这些补丁,并运行缓冲所有权测试;不能用“先检查有帧、再调用可能阻塞 4 秒的 get”替代。
S3 的 `ll_cam.c` 中有 `CAM_IO_MUX_REG()` 本地补丁,用于 SCCB / I2C 引脚连接。升级驱动后需检查补丁并验证传感器探测和采集。
`components/esp32-camera/target/` 是按芯片划分的**源代码**,清理缓存时不能删除,也不能忽略所有层级的 `target/`。
+88
View File
@@ -0,0 +1,88 @@
# 约 30 fps、Wi-Fi 重连与画面链路延迟验证(2026-10-18)
当前交付已增加阻塞恢复与新鲜度处理,见 [REALTIME.md](REALTIME.md)。本页保留此前减光确认和画面延迟对照;本页 SHA 与测量结果均属于当时构建,不是当前固件的重测结果。
## 先前三缓冲与小幅减光记录
根据用户长程运行反馈,双缓冲在峰值负载下丢帧明显,当前交付恢复 **`CAM_FB_COUNT=3`**,保留突发发送期间的采集余量。约 30 fps 完整 PLL 配置、VGA 彩色、正常白平衡、Wi-Fi 自动重连和自适应 JPEG 均保留。双缓冲保留为试验选项,下面的短时延迟对照属于历史记录,不能证明长程峰值稳定性。
针对现场轻微过亮,增加 `CAM_AE_LEVEL=-1`,将 OV5640 AE 目标从 55 微调到 45,目标比值约 −0.29 EV;`CAM_EXPOSURE_EV=0`,自动曝光和增益仍联动,曝光上限保持 592 行(约 20 ms)。这会小幅降低亮度目标,不保证实际快门必然缩短。当前 50 Hz 抗频闪步长约 10 ms,直接小减曝光上限可能跨越一整档,因此使用 AE 目标细调。该目标比值不等于经过照度标定的实际 EV。
本次已通过 COM8 烧录与 Bootloader、分区表、App 的独立校验。重新启动后串口观察 45.15 秒,复位和采集错误均为 0,订阅已恢复;末个窗口提交 29.7 fps。观察中仍有 UDP 发送回压,三缓冲不代表完全消除路由器拥塞。本次未重新测量原生完整接收损失率或画布延迟,也未重新进行长程峰值测试。
用户已确认当前三缓冲、小幅减光版本亮度合适、颜色稳定。
该阶段 App SHA-256:`05aff1f53519fbb52b89ca526c3be11cc78f37a3356f588986336cf7870924eb`。完整选择理由、当前参数与短测记录见制品 `BUFFER-DECISION.json`;下面的 90 秒/五分钟对照、CDF 和旧接收曲线保留为历史数据,不能套用到本次亮度微调后的镜像。
## 此前短时对照(历史)
此前试验使用 VGA 彩色、完整 PLL 配置和双缓冲。保留 Mihomo TUN,只有一个 UDP 订阅者,使用 Windows 生产客户端的真实视频绘制路径,非演示模式。用户已确认新时钟配置亮度够用、颜色正常,未再出现短 HTS 方案的绿紫闪烁。
| 指标 | 三缓冲基线 | 双缓冲试验配置 |
| --- | --- | --- |
| 原生接收窗口 | 90.008 s | 300.003 s |
| 完整接收帧 | 2659 | 8909 |
| 帧损失 | 32 / 1.189% | 61 / 0.680% |
| 完整接收帧率 | 29.54 fps | 29.70 fps |
| 本地 JPEG 通道 | 27.23 fps | 27.90 fps |
| 标记回显有效样本 | 767 | 2641 |
| 标记回显采样跨度 | 109.68 s | 295.08 s |
| 标记 → 首次可解码画布回显 P50 | 128.4 ms | 121.5 ms |
| 标记 → 首次可解码画布回显 P95 | 230.7 ms | 193.4 ms |
| 回显估计 P99 / 最大值 | 285.3 / 443.2 ms | 248.9 / 461.5 ms |
这次窗口的 P50 降低 6.8 ms、P95 降低 37.3 ms。双缓冲减少潜在采集排队,同时保住接近 30 fps 的实际交付。两个窗口的网络回压、JPEG 自适应档位和时长不同,属于连续实机对照,不能把全部改善归因于缓冲数,也不是所有路由器或场景的保证。此前约 22 fps 的测试保留在 [STABILITY.md](STABILITY.md)。
双缓冲试验五分钟全程在线,复位 0 次、采集错误 0 次,通道断开 0 次、计数重置 0 次。抽取 61 张 JPEG 均完整解码为 640×480 RGB;没有用灰度或降低色深换帧率。最小内部空闲堆 118771 B;实际 JPEG 档位 18–28,温度 58–60°C,未触发减载。
## 该测试阶段的配置与时钟
```dotenv
CAM_RES=vga
CAM_CLOCK_PROFILE=vga30
CAM_FB_COUNT=3
CAM_QUALITY=18
CAM_QUALITY_MAX=30
CAM_MAX_FPS=0
CAM_GRAYSCALE=0
CAM_EXPOSURE_EV=0
CAM_AE_LEVEL=-1
CAM_SHUTTER_LINES=0
CAM_EXPOSURE_MAX_LINES=592
CAM_GAIN_CEILING=32
CAM_HTS=0
CAM_THERMAL_LIMIT_C=75
WIFI_TX_POWER_DBM=14
UDP_PACKET_GAP_US=0
WIFI_TEST_DROP_AFTER_S=0
```
XCLK 保持 20 MHz,使用完整 PLL 分频组合:VCO 610 MHz、SYSCLK 61 MHz、DVP PCLK 15.25 MHz;保持默认 HTS 2060 / VTS 984 和读出窗口,名义节拍约 30.09 fps。此前缩短 HTS 至 1500 / 1800 的方案会闪色,未采用。驱动回调的参数顺序与私有 PLL 辅助函数不同,必须按 `sensor_t.set_pll` 的签名调用。
下列实际曝光、增益范围属于此前双缓冲试验。当前 AE 目标改为 45/55,自动增益保留,曝光上限 592 行约 19.99 ms;实际曝光 590–590 行,估算 19.92–19.92 ms,增益 8.00–13.50×。最大 50 Hz band 数保留为 2,避免提升 SYSCLK 后向下取整只剩一个 band 导致画面过暗。未强制负 EV 或固定低快门。行数与毫秒换算依赖当前时钟,不能直接套用其他配置。
参考:[OV5640 数据手册](https://files.waveshare.com/upload/d/da/OV5640_DataSheet.pdf)、[Espressif OV5640 驱动](https://github.com/espressif/esp32-camera/blob/master/sensors/ov5640.c)。新组合的 VCO 和 PCLK 在该手册列出的范围内;这不代替板上信号完整性和画面验证。
## Wi-Fi 运行时恢复
断开、失去有效 IP 或地址变化后停止 UDP 发送并清理订阅目标。关联和 DHCP 分别使用 15 秒期限;首次立即尝试,失败按 1、2、4、8、16、30 秒退避。事件回调只更新状态,主循环负责调用驱动;认证进行中不会反复 connect。取址后重建 socket、恢复无线参数、丢弃旧采集帧并重新广播,保留摄像头、曝光、增益、设备 ID 和帧序号。
实机诊断构建主动断开并保持 5 秒:检测断线后 9021 ms 恢复网络,其中重试发起到恢复日志为 4029 ms(含取址和恢复处理);无需重启,客户端随后自动续订恢复视频。该测试使用同一恢复逻辑、60 MHz SYSCLK 的预检构建;最终交付为 61 MHz,故障注入参数为 0。恢复到同一个 IP 已验证,路由器长时间关机、密码改变和 DHCP 换址尚未分别做硬件测试。启动阶段第一次联网仍使用原有扫描和认证模式探测逻辑。
处理方式遵循 [ESP-IDF 断线事件说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wifi-event-sta-disconnected):有效 IP 恢复后再重建上层 socket。4 项恢复策略测试覆盖认证不被重启、关联/DHCP 超时、指数退避和新断线重置状态。
## 延迟测量方法与边界
Windows 客户端使用裁判配置和 `--latency-report <新的NDJSON路径>` 启动。相机固定对准左侧四角标记,右侧沿正常 `VideoSurface` 路径绘制真实回显。源标记每 50 ms 更新,记录每个编号首次可解码的画布回显;四角透视校正、Gray 码和互补行拒绝无法可靠识别的曝光混合帧。最初逐刷新更新在 180 Hz 屏幕上会让一次曝光跨越多个编号,已在正式对照前修正。
源标记和回显均使用同一客户端 `performance.now()`,无需 ESP 时钟同步。绘制时刻以相邻 rAF 夹定,表中为上下界中点;此前双缓冲样本的区间宽度中位数为 11.4 ms。该范围包括相机取样等待、曝光、采集、无线传输、重组、本地 WebSocket、解码和画布绘制。它是**屏幕标记事件到首次可解码画布回显的估计**,没有测量源/输出面板扫描或像素响应;严格的物理光学端到端延迟需用高速相机复核。未识别帧不成为样本,首次可解码回显可能晚于较早的模糊回显,不能把有效样本分布当作每一帧的延迟。
此前双缓冲试验中,解码完成到画布绘制平均 2.72 ms;标记分析平均 2.02 ms。该分析只在校准模式运行,正常客户端不做画布回读。字段 `decodeToDrawMs` 从解码完成开始,不代表 JPEG 解码耗时。接收间隔、分片组装耗时及发送函数耗时各有独立口径,不能替代本测量。
`scripts/analyze_latency.py` 可按半开区间行号复算 NDJSON。完整结果、参数、行号范围和边界见制品 `LATENCY-RESULT.json`,分布图见 `LATENCY.png`。原始校准记录保存在本机 `target/latency/`,不把画面、私有配置或令牌日志打进制品。
## 交付与硬件限制
该阶段 App SHA-256:`05aff1f53519fbb52b89ca526c3be11cc78f37a3356f588986336cf7870924eb`。Bootloader、分区表和 App 已经 COM8 烧录并独立校验,ZIP 内含校验清单。
本模组除原有 `0xFADE7` 坏点,还发现 `0x10863` 的 bit 6 异常读回为 1,整段回读只差这一字节,是否永久故障尚未单独擦写复核;已把应用分区移至 `0x50000` 并验证 `0xFADE7` 对应字节可写。该兼容布局针对故障模组,每次重编仍需重新核对,详见 [HARDWARE.md](HARDWARE.md)。
+53
View File
@@ -0,0 +1,53 @@
# 图传性能排查与验证(2026-10-18)
本文保留早期短时对照;当前交付已回退到 15 dBm,功率、距离与发送拥塞分析见 [POWER.md](POWER.md),阻塞处理见 [REALTIME.md](REALTIME.md)。此前约 30 fps 与回显延迟数据见 [LATENCY.md](LATENCY.md),更早验证见 [STABILITY.md](STABILITY.md)。下列数字对应当时的传感器时序和网络状态。
## 环境与结论
测试使用当前 ESP32-S3-N16R8 / OV5640,VGA 640×480、8 MB PSRAM、CPU 240 MHz、ESP-IDF v5.5.5。板端连接 2.4 GHz Wi-Fi,实际协商 HT20 / CCMP,主要有效窗口的信号约 −18~−32 dBm;Windows 通过同一路由器通信,Mihomo TUN 始终保持启用。单客户端对照使用 1 MiB 接收缓冲,每 800 ms 单播续订。
传感器在 CPU0 和 CPU1 均可输出约 22.2 fps。低帧率期间持续出现 `sendto` 的 `ENOMEM`(errno 12),完整帧提交率低于采集率,客户端收到不完整分片。普通系统内存仍有约 100 KiB 以上可用,证据指向无线发送拥塞与驱动缓冲回压。核绑定和单独扩大 Windows 缓冲均未解决主要瓶颈。
降低最大发射功率、使用视频队列,并降低单帧大小后有所改善。功率切换对照中,20 dBm 的窗口明显恶化,10 dBm 的两次窗口约 12 fps;14 dBm 的结果仍有波动。仅凭这些数据无法确定供电、天线、射频或 AP 的具体物理原因。
## 接收端对照
| 配置 | 时长 | 完整帧 | 窗口 fps | 帧损失 | 完整帧间隔 P95 | 分片组装 P95 |
| --- | --- | --- | --- | --- | --- | --- |
| 问题复测:20 dBm / 普通队列 / quality 12 | 30 s | 165 | 5.49 | 74.69% | 672.0 ms | 109.0 ms |
| 14 dBm / 普通队列 / quality 12 | 30 s | 477 | 15.88 | 27.84% | 242.2 ms | 145.4 ms |
| 14 dBm / 视频队列 / quality 12 | 60 s | 1091 | 18.18 | 17.41% | 194.4 ms | 103.2 ms |
| 该阶段选择:14 dBm / 视频队列 / quality 18 | 60 s | 1177 | 19.62 | 11.30% | 192.8 ms | 94.2 ms |
表中 fps = 完整帧数 / 整个测量时长。帧损失包括被替换的不完整帧、超时及帧号缺口;末尾仍在接收的帧不计损失。丢帧率与旧客户端的口径不同,旧版会低估实际丢帧。
无线链路有明显时变,早期其它窗口也出现过约 5~15 fps,表格不是长期保证或所有板卡的通用最优参数。前两行使用 CPU0,后两行使用 CPU1;核绑定对照未消除发送拥塞,最终保留显式 CPU1 配置。关闭 AMPDU 的试验几乎无法得到完整帧,已撤回;忙等重试也已撤回。另一次 40 ms 重试预算试验期间,有新客户端加入导致两个订阅者,且信号和画面复杂度变化;该轮约 12.6 fps,不能用于证明重试预算的单项影响,未作为最终配置依据。
## 最终参数与改动
- 该测试阶段本机 `.env` 选择 `CAM_QUALITY=18`、`WIFI_TX_POWER_DBM=14`。分辨率保持 VGA;quality 数字增大表示压缩更强,细节会有所损失。仓库默认 quality 仍为 12,未设置发射功率时保留 SDK 默认上限;当前功率选择见 [POWER.md](POWER.md)。
- 视频 socket 设置 `IP_TOS=128`,使用 AC_VI,并保留 AMPDU;64 个动态 TX 缓冲、TX BA window 32、FreeRTOS 1000 Hz。JPEG 在 PSRAM 中原地读取,仅复用一个数据报缓冲;临时发送失败让出调度重试,等待预算为每帧 25 ms。
- 客户端按发现的设备 IP 定向续订,接收缓冲请求 1 MiB。随机会话 ID 让同一会话的端口变化更新目标地址;旧固定 ID 保留按地址区分。实测迁移后旧端口无新视频,新端口继续接收;两个旧版订阅仍各自收到数据。
- 接收器拒绝迟到旧帧、重复完成帧和矛盾分片,处理帧号回绕、设备重启及超时。视频 WebSocket 只保留各路最新帧;前端每路至多一次解码与一张最新待解码帧,避免持续积压。
## Windows 实际客户端验证
使用已构建的 release EXE 和独立裁判端测试配置,非演示模式。原用户配置、密码和绑定保留。
约 30 秒窗口中,后端完整帧增量 624、丢帧增量 38,窗口帧损失约 5.74%。界面连接存在,独立观察连接收到 619 张完整 JPEG,通道帧率 20.62 fps,帧间隔 P95 为 103.0 ms,无损坏 JPEG。
这项验证覆盖真实板卡、UDP 接收与重组、GUI 的 WebSocket 连接及二进制视频通道。另有 20 项协议测试、83 项客户端核心测试、9 项后端视频通道测试、5 项前端解码队列测试,以及 Windows 类型检查和生产打包通过。未将整个后端测试集或录制功能记为已验证。
**时间边界:**组装耗时是首末分片到达的差值,帧间隔是相邻完整帧到达的差值,均不是摄像头到屏幕的端到端延迟。未做两端时钟同步或实际屏幕的光学延迟测量,也未自动验证 WebView 最终绘制像素。
## 复测与原始记录
先退出其它图传接收进程,避免增加订阅负载:
```text
python scripts/perf_probe.py <板卡IP> --seconds 60 --json perf.json --save-frame frame.jpg
```
本机原始 JSON、抽帧和串口日志在 `target/camera-diagnostics/`,随构建缓存忽略;主要文件为 `fps-core0-raw.json`、`fps-tx14-raw.json`、`fps-quality12-raw.json`、`fps-final-raw.json`、`fps-final-client-result.json`、`session-migration.json`。固件与 Windows 制品的 `BUILD-INFO.json` 记录最终测量及验证范围,ZIP 不包含用户运行配置。
设计依据:[ESP-IDF 5.5.5 的 UDP 发送回压说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/lwip.html#limitations)、[Wi-Fi QoS 的 IP precedence / AC_VI 映射](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wi-fi-qos)。这些文档说明机制,性能数字来自上述本机测量。
+64
View File
@@ -0,0 +1,64 @@
# 发射功率、断连距离与拥塞分析(2026-10-18)
当前交付请求 `WIFI_TX_POWER_DBM=15`,实板驱动回读 **15.00 dBm**。根据用户的距离与拥塞反馈,从 17 dBm 回退;启动和 Wi-Fi 重连后应用同一设置,压缩与温控控制器不自动降低发射功率。三缓冲、VGA 彩色、AE 目标 45、约 20 ms 曝光上限和 PLL 时钟保持不变。
这里的数值是驱动配置的发射功率上限。API 回读不能代替功率计,不能认为所有 PHY 速率下、每个数据包的天线端输出都恰好相同。
## 先前 17 dBm 记录说明了什么
| 记录 | 时间 | 原生接收帧率 | 原生帧损失率 | 板端发送失败 / 应用重试 / 放弃目标帧 |
| --- | --- | --- | --- | --- |
| 17 dBm 第一轮 | 视频 30 s / 串口 75 s | 29.78 fps | 0.667% | 90 / 80 / 10(整段串口) |
| 17 dBm 第二轮,增加周期回读 | 视频 30 s / 串口 75 s | 27.92 fps | 6.889% | 498 / 446 / 52(整段串口) |
两轮的发射上限相同,曝光与缓冲参数相同,但无线状态、场景内容和时间并未锁定,且第二轮多了周期功率读数。因此这些记录不构成严格 A/B 对照,不能单凭它们断定“增加 3 dB 必然导致某个百分比的丢帧”。用户反馈是回退依据,日志用于定位失败发生在哪一层。
第二轮内部空闲堆最少 120039 字节、最大连续块最少 43008 字节,温度 57–58°C,无温控限帧、复位或采集错误,只有一个 UDP 订阅者。出现发送失败时伴有 `errno 12 / ENOMEM`,优先指向网络发送资源的瞬时耗尽。堆仍有余量不能排除受配额限制的 Wi-Fi TX 缓冲已满,也不能把这个错误直接当成内存泄漏。
ESP-IDF 明确说明,UDP 连续 `send/sendto` 可以因底层驱动 TX 缓冲全部占用而返回 `ENOMEM`。目前日志中的“446 次重试”是**应用调用 sendto 的重试**,不是已测得的 802.11 空口重传次数。来源:[ESP-IDF v5.5.5 lwIP 文档](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/lwip.html#limitations)。
## 为什么距离改善不明显
1. **功率差只有 3 dB。** 14→17 dBm 的配置上限约为两倍功率,不是两倍距离。按简化路径损耗模型 `距离倍率 = 10^(3/(10n))`,自由空间 `n=2` 时约 1.41 倍,假设室内 `n=3–4` 时约 1.19–1.26 倍。这只是模型估算,不是本次测得的距离。
2. **收发链路必须同时成立。** 增强板卡发射不能增强板卡接收;AP 信标、应答、订阅和数据回程仍依赖另一方向。ESP-IDF 的断连说明包含持续丢失信标等原因。当前记录没有最远点的断连 reason、AP 侧接收强度或重传统计,不能确定距离边界由哪一方向限制。来源:[ESP-IDF 断连事件说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wifi-event-sta-disconnected)。
3. **回读是上限,RSSI 有方向。** 板端日志里的 RSSI 是板卡接收到 AP 的信号,不是 AP 接收到板卡的信号。17 dBm 第二轮 RSSI 为 −25 至 −14 dBm,属于近距离记录,不能拿来证明远距离的上行链路已经改善。
## 为什么拥塞仍会加重
已确认的应用行为是:每约 33 ms 取得一张 JPEG,再把它拆成多个数据报连续提交,分片间隔为 0;成功返回只表示本地网络栈接受,不能证明 AP 或电脑已经收到。每帧 8 ms 重试预算和 25 ms 持有期限限制应用等待,但不能撤回已经进入 SDK 的旧分片。
提高发射上限不会增加信道带宽或路由器的排队能力。若空口竞争、速率回退或重传使驱动排空变慢,突发提交仍会填满 TX 缓冲。这是与日志一致的机制推断;本次没有 CCA 忙占比、MAC 重传、实际发送 MCS 或 AP 队列数据,尚不能把它们中的某一项确认为功率变化造成的根因。来源:[ESP-IDF TX 数据路径与缓冲说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#how-to-improve-wi-fi-performance)。
更高功率改变干扰覆盖、射频线性度或供电瞬态,也可能间接影响链路,但这里没有相应测量,不将“接收端过载”“功放饱和”或“电压跌落”写成已确认原因。无 Brownout 记录也不能证明射频供电完全没有瞬态变化。
电脑当前通过 **5 GHz / 信道 161 / 802.11ac** 连接,摄像头使用 2.4 GHz。因此不能直接声称视频在同一个 2.4 GHz 信道被路由器重复发送了两次;摄像头上行、AP 转发、电脑接收与 TUN 处理仍可能分别排队,TUN 保持原样。
## 还需区分本地跳帧
17 dBm 第二轮视频区间中,原生接收了 838 张完整 JPEG,而辅助本地 WebSocket 观察器收到 582 张。两者不是同一个指标:后端采用最新帧覆盖和帧龄期限,慢消费者可能主动跳过已完整收到的帧;少收到的本地消息不能全部算成无线丢帧,更不能直接等同于真实屏幕绘制帧率。
## 当前回退验证与后续定位
测试期间用户补充观察:用手握持开发板时丢包明显增多。测试没有逐秒标记握持、位置和朝向,五分钟记录应视作现场连续性观察,不能当作固定摆位的功率 A/B 测试。人体靠近 PCB 天线导致的吸收、失谐或遮挡成为更值得优先排查的线索。
本轮只改变功率上限到 15 dBm,保持其他图传参数与单 UDP 订阅,做五分钟收流观察;结果由交付包 `POWER-RESULT.json` 记录。之前的 17 dBm 记录保留为 `POWER-HISTORY-tx17.json`,14 dBm 的阻塞处理记录保留在 [REALTIME.md](REALTIME.md)。本轮不重新测量最远断连距离或物理屏幕端到端延迟。
下一项值得单独对照的是**分片突发整形**:固定 15 dBm、曝光和 JPEG 策略,按发送回压逐步比较小批次提交与短暂让出调度,并同时看原生完整帧率、网络丢帧、主动跳帧和帧龄。不能仅通过增大缓冲或延长重试,把丢帧转化为持续排队延迟。若要确认距离瓶颈,需要同步记录远点断连原因、AP 侧板卡 RSSI、MAC 重传和信道忙占比。
## 蛇形 PCB 天线的握持改善
手接近天线会改变其周围的电磁环境,人体和外壳可使 PCB 天线失谐;人体也会吸收、遮挡部分射频能量。这是对用户观察的机制解释,本板尚未测量 S11、效率或握持前后的辐射图。TI 的 2.4 GHz PCB 天线实测例子显示,加入外壳并手持后匹配状态明显变化,需要在实际使用条件下匹配。来源:[TI 天线阻抗测量与匹配,章节 6](https://www.ti.com/lit/an/swra726/swra726.pdf)。
建议按以下顺序改善:
1. **先改善握持和摆位。** 用塑料支架或绝缘延长握柄固定,让蛇形天线端伸出、远离手掌;握住远离天线的一端。可先尝试让手与天线相距约 3–5 cm,再按实测增加距离,这不是保证性能的固定阈值。
2. **留出天线净空。** Espressif 建议成品外壳中天线各方向至少 15 mm 净空;这不是“距离手 15 mm 就一定够”的保证。天线附近避开电池、金属支架、屏蔽片和紧贴的 USB 线或排线,外壳在天线区域使用非金属材料。来源:[ESP32-S3 PCB 天线布局指南](https://docs.espressif.com/projects/esp-hardware-design-guidelines/en/latest/esp32s3/pcb-layout-design.html#general-principles-of-pcb-layout-for-modules-positioning-a-module-on-a-base-board)。
3. **固定朝向再比较。** 在同一位置和画面下,保持开发板朝向固定,比较“不接触 → 握远端 → 手靠近天线 → 移开手”,每段 30–60 秒,同时记录完整帧损失和板端 RSSI。单看 RSSI 不能覆盖上行质量,但可帮助区分朝向、遮挡与队列负载变化。
4. **需要长期手持时,优先考虑外置天线版本。** `ESP32-S3-WROOM-1` 为 PCB 天线,`WROOM-1U` 为外置天线接口版本。选兼容摄像头接线、Flash/PSRAM 配置的板卡,将合适的 2.4 GHz 天线安放在握持区域之外;具体封装、接口和射频连接须按实际板卡核对。外置天线也应远离手和金属。来源:[Espressif 模组数据手册](https://documentation.espressif.com/esp32-s3-wroom-1_wroom-1u_datasheet_en.pdf)。
5. **必须贴近人体使用时,按最终结构调谐。** 外壳、支架、馈线和手持姿态都参与最终匹配,需要 VNA/射频测量后调整匹配网络。直接延长蛇形走线、随意焊线或并接两根天线不能代替调谐;在现板上先做可逆的机械隔离更容易验证。
## 15 dBm 本轮结果
当前 15 dBm 交付 App SHA-256:`a6092e4b0cf42819c04424e066651f285b8961312a73822d667532f800e46767`,应用分区 `0x70000`。COM8 烧录与三段独立校验通过;355.1 秒串口的 71 次功率回读均为 15.00 dBm。五分钟观察:原生完整接收 **29.68 fps**,丢帧 **1.045%**,本地 JPEG 通道 **28.23 fps**,31 张抽帧全部完整解码为 640×480 RGB,无无效 JPEG、设备离线、通道断开、复位或采集错误;仍有最长 1.646 秒到帧间隔,不能将此轮称为无卡顿。距离与拥塞分析、测试边界见 [POWER.md](POWER.md) 和制品 `POWER-RESULT.json`。
按时间近似对齐的板端五分钟区间有 731 次发送失败、653 次应用重试、78 次目标帧放弃;温度 56–58°C,RSSI -35 至 -11 dBm。板端区间由五秒采样的结束时间选择,边界并非逐包精确对齐。与此前短测时长、现场无线状态和 JPEG 内容不同,不能将差值归因于功率这一项。
+279 -365
View File
@@ -1,402 +1,316 @@
# ESP32-S3 摄像头图传(Rust / ESP-IDF / UDP)
# ESP32-S3 摄像头图传系统
**ESP32-S3-WROOM-1-N16R8** + OV2640 / OV3660 / OV5640(DVP 接口)实时图传,
客户端是 Rust + Slint 桌面程序。固件是跑在 ESP-IDF 上的 Rust,用官方
`esp32-camera` 组件做采集。
基于 **Rust + ESP-IDF** 的局域网实时图传系统。ESP32-S3 采集摄像头输出的 JPEG,通过 UDP 向桌面客户端推流;桌面端提供队伍端与裁判端界面,并接收 ESP32-C3 装甲板的血量广播。
```
protocol/ 线路格式 + 帧重组 (有单元测试,为宿主架构编译)
. 固件 -> ESP32-S3 (交叉编译,Xtensa)
client/ 桌面客户端 (Slint,macOS/Linux/Windows)
## 功能与架构
- 摄像头自动探测、JPEG 采集、UDP 分片发送与完整帧重组。
- 局域网广播发现与订阅,单块图传板最多向 4 个客户端发送画面。
- 运行中 Wi-Fi 断开后自动重连,重新获得 IP 后重建 UDP socket 并恢复订阅。
- 使用 MAC 派生的稳定设备 ID;两块图传板可烧录同一固件,在客户端绑定队伍。
- Tauri v2 + React 桌面端:视频显示、双方血量、设备绑定与录制。
- 独立 ESP32-C3 装甲板固件:四路受击检测、WS2812 灯带及血量上报。
```text
ESP32-S3 图传板 A ─┐ ┌─ 队伍客户端
├─ 同一局域网 / UDP ─┼─ 裁判客户端
ESP32-S3 图传板 B ─┤ └─ 队伍客户端
ESP32-C3 装甲主板 ─┘
```
`protocol/` 被两端共用,所以线路格式不可能各自漂移。
图传使用 UDP **5600**,装甲板血量使用 UDP **5601**。板卡与客户端需在可互相通信、允许广播的网络中;关闭 AP 客户端隔离,并允许客户端程序通过防火墙。
## 实测性能
## 项目结构
VGA 640x480,JPEG 质量 12,板子与 2.4GHz AP 相距约一间房:
| 路径 | 用途 |
| --- | --- |
| `src/` | ESP32-S3 图传固件,根目录 Cargo 项目 |
| `components/` | 本地相机驱动及 C/Rust 接口封装 |
| `protocol/` | 共享 UDP 协议与帧重组,支持主机测试 |
| `armor/` | 独立 ESP32-C3 装甲板 workspace |
| `desktop/` | Tauri + React 客户端及独立网络核心 `core/` |
| `scripts/` | 构建、烧录与探测脚本 |
| `reference/armor/` | 早期 C 版装甲板工程 |
| | TCP(HTTP MJPEG) | UDP |
|---|---|---|
| 帧率 | 1.9 fps | **20.8 fps** |
| 丢帧 | 大量超时 | **0.0%**(146 帧 / 1270 个数据报) |
| 帧间隔 | 测不出来 | 35 ms |
| 实际吞吐 | 0.28 Mbit/s | 1.72 Mbit/s |
| 传感器自身上限 | 22.2 fps | 22.2 fps |
各子项目的构建目标不同。以下编译、烧录流程针对**根目录的 ESP32-S3 图传固件**。
UDP 这条路已经跑到传感器的极限,网络不再是瓶颈——即便如此也只用掉链路的
6–9%,说明瓶颈从头到尾都不在带宽上。
(为什么用 UDP、以及 TCP 那组数字到底说明什么,见
[`protocol/README.md`](protocol/README.md)。)
## 硬件要求
---
| 项目 | 当前配置 |
| --- | --- |
| 开发板 | Freenove ESP32-S3-WROOM CAM |
| 模组 | ESP32-S3-WROOM-1-N16R8,16 MB Flash + 8 MB Octal PSRAM |
| 摄像头 | OV5640;驱动包含 OV2640 / OV3660 支持,接线需核对 |
| 网络 | 2.4 GHz Wi-Fi |
| 连接 | 支持数据传输的 USB 线、可识别的串口及稳定供电 |
# 固件
默认采集 VGA(640 × 480)、彩色 JPEG quality 12,按传感器节拍发送。相机引脚、Flash 分区和 PSRAM 配置见 [HARDWARE.md](HARDWARE.md)。换板型前必须核对接线,避免占用 Flash / PSRAM 引脚。
## 环境要求
## ESP Rust 工具链入门
- **Rust 的 `esp` 工具链。** 仓库里有 `rust-toolchain.toml`,所以
`rustup toolchain install esp` 就够;否则用 `espup install`。
- **ESP-IDF。** `.cargo/config.toml` 通过 `IDF_PATH` 指向已有的安装。删掉那两行
的话,`esp-idf-sys` 会自己 clone 并构建它固定的那个版本——能用,但要下载几百 MB。
- **Python 3**(ESP-IDF 构建系统要用)。
- **esptool** 或 **espflash**,用来烧录。
本项目使用 Rust `std` 和 ESP-IDF。ESP32-S3 属于 **Xtensa** 架构,需要通过 `espup` 安装 Espressif Rust 工具链;普通 `rustup target add` 不能替代这一步。根目录固定选择名为 `esp-1.97.0.0` 的工具链,避免浮动版本影响构建。
先确认本机已具备:
### 1. 安装基础依赖与 Rust 工具
```sh
rustc --version # 必须是 esp 工具链
先安装 [Rust / rustup](https://rustup.rs/)、[Git](https://git-scm.com/downloads)、[Python 3](https://www.python.org/downloads/)(建议 3.11)、[CMake](https://cmake.org/download/) 与 [Ninja](https://ninja-build.org/),确保命令在 `PATH` 中。
- **Windows**:使用 PowerShell 7.5 或更新版本,安装 Visual Studio Build Tools 的“使用 C++ 的桌面开发”和 Windows SDK,使用 MSVC Rust 主机工具链。仓库和工具链尽量使用较短、无空格和中文的路径。
- **Linux(Debian / Ubuntu)**:安装 `build-essential pkg-config libudev-dev libssl-dev python3 python3-venv cmake ninja-build git curl`。
- **macOS**:安装 Xcode Command Line Tools(`xcode-select --install`),以及 Python、CMake 和 Ninja。
以下命令显式使用 `+stable`,即使在仓库内执行,也不会要求事先已有 `esp`:
```text
rustup toolchain install stable
cargo +stable install espup --locked
cargo +stable install ldproxy --locked
cargo +stable install espflash --version 4.4.0 --locked
espup install --name esp-1.97.0.0 --toolchain-version 1.97.0.0 --targets esp32s3 --std
```
也可以从各工具的官方 releases 下载预编译程序。`espup --std` 安装本项目所需的 Rust / LLVM 工具,ESP-IDF 的 C 工具链由后续构建准备。
Linux / macOS 每次新开终端后执行:
```bash
. "$HOME/export-esp.sh"
```
Windows 安装后重新打开 PowerShell,让环境变量生效;若当前终端仍找不到 LLVM,可执行 `. "$HOME/export-esp.ps1"`。检查结果:
```text
rustup toolchain list
rustc +esp-1.97.0.0 --version
cargo +esp-1.97.0.0 --version
espflash --version
python3 --version
```
## 配置
官方入门资料:[The Rust on ESP Book](https://docs.espressif.com/projects/rust/book/getting-started/toolchain.html)、[espup](https://github.com/esp-rs/espup)、[espflash](https://github.com/esp-rs/espflash/tree/v4.4.0/espflash)。
WiFi 凭据在**编译期**由 `env!` 读取,所以放在 `.env` 里,不进源码。`.env` 已在
gitignore 中。
### 2. 准备 ESP-IDF
```sh
默认使用 **ESP-IDF v5.5.5**。首次构建时,`esp-idf-sys` 下载 SDK、对应 C 工具链及 Python 依赖,默认缓存到用户目录的 `.espressif/`;需要网络、磁盘空间及缓存目录写入权限。
已有匹配版本 SDK 时可在终端设置 `IDF_PATH`,不要提交个人绝对路径:
```powershell
# Windows:替换为实际 SDK 源码目录。
$env:IDF_PATH = 'C:\Espressif\frameworks\esp-idf-v5.5.5'
```
```bash
# Linux / macOS:替换为实际 SDK 源码目录。
export IDF_PATH="$HOME/esp/esp-idf-v5.5.5"
```
使用自动下载时,清除指向其他 SDK 的旧 `IDF_PATH`。缓存位置等高级设置见 [esp-idf-sys 构建选项](https://github.com/esp-rs/esp-idf-sys/blob/master/BUILD-OPTIONS.md)。
## 配置 Wi-Fi 与摄像头
在根目录复制 `.env.example` 为 `.env`,再用编辑器填写;已有 `.env` 时不要覆盖。
```powershell
# Windows PowerShell
Copy-Item .env.example .env
```
```bash
# Linux / macOS
cp .env.example .env
```
```sh
# .env
WIFI_SSID=你的2.4G频段ssid
WIFI_PASS=你的密码
```dotenv
WIFI_SSID='你的2.4G网络名称'
WIFI_PASS='你的Wi-Fi密码'
# 以下都有默认值:
#CAM_RES=vga # qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga
#CAM_QUALITY=12 # 0-63,数字越小画质越好、字节越少
#CAM_VSYNC_INVERT=1 # 若板子在硬件上反相了 VSYNC,填 0
# 可选,省略时使用固件默认值。
#CAM_RES=vga
#CAM_QUALITY=12
#CAM_QUALITY_MAX=0
#CAM_MAX_FPS=0
#CAM_CLOCK_PROFILE=default
#CAM_FB_COUNT=3
#CAM_GRAYSCALE=0
#CAM_EXPOSURE_EV=0
#CAM_AE_LEVEL=-1
#CAM_SHUTTER_LINES=0
#CAM_EXPOSURE_MAX_LINES=0
#CAM_GAIN_CEILING=0
#CAM_HTS=0
#UDP_PACKET_GAP_US=0
#UDP_RETRY_BUDGET_US=8000
#CAM_THERMAL_LIMIT_C=75
#CAM_VSYNC_INVERT=1
#WIFI_AUTH=wpa2
```
SSID **必须是 2.4GHz**。ESP32-S3 的射频不支持 5GHz,5GHz 的 SSID 对它完全不可见,
板子只会一直超时等待。固件在关联前会先扫描并打印可见网络,正是为了让这个问题
能被一眼看出:
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `WIFI_SSID` | 无,必填 | 2.4 GHz 网络名称,最多 32 字节 |
| `WIFI_PASS` | 空字符串 | Wi-Fi 密码;开放网络可留空 |
| `CAM_RES` | `vga` | `qqvga` / `qvga` / `cif` / `hvga` / `vga` / `svga` / `xga` / `sxga` / `uxga` |
| `CAM_QUALITY` | `12` | 0–63,越小通常画质越好、JPEG 越大 |
| `CAM_QUALITY_MAX` | `0` | `0` 固定画质;设为 `CAM_QUALITY`–63 时按发送回压在两者之间调节压缩,保留彩色和分辨率。拥塞时每 5 秒增大最多 3 档,连续稳定 15 秒才恢复 1 档 |
| `CAM_MAX_FPS` | `0` | 取帧与发送的最高节拍,1–60;`0` 不限帧。仅限制消费节拍,不修改传感器时钟 |
| `CAM_CLOCK_PROFILE` | `default` | `default` 保留驱动时钟;`vga30` 使用 OV5640 VGA 约 30.09 fps PLL 配置,保持 HTS/VTS 和读出窗口,SYSCLK 61 MHz / PCLK 15.25 MHz。切换后需重新核对曝光行数 |
| `CAM_FB_COUNT` | `3` | PSRAM 帧缓冲数,允许 2 或 3。当前交付选三缓冲,保留发送突发时的采集余量;双缓冲曾在短测降低部分回显延迟,但用户长程运行反馈峰值丢帧明显,保留为试验选项 |
| `CAM_GRAYSCALE` | `0` | `1` 使用传感器灰度效果,保留硬件 JPEG;`0` 彩色 |
| `CAM_EXPOSURE_EV` | `0` | OV5640 自动曝光目标偏置:`-1` / `-2` 约对应 −1.1 / −1.9 EV;不是标定后的绝对 EV,也不保证快门缩短 |
| `CAM_AE_LEVEL` | 未设置 | 可选 OV5640 驱动 AE 档位,−5–5;仅与 `CAM_EXPOSURE_EV=0` 同用。`-1` 将目标从 55 小幅降至 45,目标比值约 −0.29 EV,保留自动曝光/增益;未设置时按 EV 参数处理。实际快门和亮度变化取决于场景 |
| `CAM_SHUTTER_LINES` | `0` | OV5640 固定快门,单位为传感器曝光行;`0` 自动曝光,非零保持自动增益。默认 VGA / HTS 2060 下 220 行估算约 10 ms;数值不能超过当前 VTS |
| `CAM_EXPOSURE_MAX_LINES` | `0` | OV5640 自动曝光上限,`0` 保留驱动默认。非零保持 AEC/AGC 联动并禁用夜景延帧,不能与固定快门同时使用。默认 VGA 下 440 行约 20 ms;`vga30` 下 592 行约 20 ms。实际曝光由场景和抗频闪步长决定 |
| `CAM_GAIN_CEILING` | `0` | OV5640 自动增益上限,`0` 保留驱动默认;1–63,单位为倍,实际上限略低于整数档。短曝光遇到室内弱光时可提高,但暗部噪点和 JPEG 负载可能增加 |
| `CAM_HTS` | `0` | OV5640 水平总长度试验参数,`0` 使用驱动默认。缩短消隐会影响彩色稳定性,本机 1500 / 1800 出现绿紫闪烁,已撤回;几何检查通过不代表可用 |
| `UDP_PACKET_GAP_US` | `0` | 成功提交视频数据报之间的最小间隔;0–5000 μs,须为调度 tick 的整数倍(当前为 1000 μs)。配合有界帧等待,避免集中突发写入队列 |
| `UDP_RETRY_BUDGET_US` | `8000` | 每帧、所有分片及订阅者共用的让出调度重试预算,0–25000 μs,须为 tick 整数倍;`0` 为非阻塞失败即弃。总发送检查期限为 25 ms,采集帧龄期限为 100 ms;分片间隔也计入期限,不会额外延长 |
| `CAM_THERMAL_LIMIT_C` | `75` | 芯片内部温度达到该值时减半帧率(向上取整),下降 5°C 后恢复;40–95,`0` 禁用。原先不限帧时降至 8 fps |
| `CAM_VSYNC_INVERT` | `1` | 驱动反相 VSYNC;硬件已反相时填 `0` |
| `WIFI_AUTH` | 自动选择 | `none` / `wep` / `wpa` / `wpa2` / `wpawpa2` / `wpa3` / `wpa2wpa3` / `wapi` |
| `WIFI_TX_POWER_DBM` | SDK 默认上限 | 可选整数 2–20 dBm;当前交付请求 15 dBm,本板驱动回读 15.00 dBm;启动及重连时应用,拥塞和温控逻辑不降低此上限 |
```
I esp32: scanning for 2.4 GHz networks...
I esp32: SR-602-2G4 ch 6 -27 dBm Some(WPAWPA2Personal) <- target
E esp32: "SR-602-5G" was not in the scan results
`.env` 使用简单的 `KEY=value`;值含空格、`#` 或 `$` 时用单引号包围,不使用变量展开或命令替换。脚本会加载 `.env`,直接 `cargo build` 不会自动读取它。上述参数在编译时写入固件,修改后需要重新编译和烧录。`.env` 已被 Git 忽略;固件也包含凭据,分享前应重新配置。
运行时断线恢复不重置摄像头、曝光或增益。首次尝试立即发起,失败后按 1、2、4、8、16、30 秒退避;关联与 DHCP 分别有 15 秒期限。恢复 IP 后丢弃旧采集帧、重建 socket 并重新广播,由客户端自动续订。启动时首次联网仍使用扫描与认证模式探测流程。实现依据 [ESP-IDF 断线事件处理说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wifi-event-sta-disconnected)。
## 编译固件
在仓库根目录执行:
```powershell
# Windows PowerShell
$env:CARGO_TARGET_DIR = 'C:\s3vt'
.\scripts\build.ps1
```
## 编译
```sh
```bash
# Linux / macOS
./scripts/build.sh
```
这就是"读取 `.env` 后执行 `cargo build --release`"。产物在
`target/xtensa-esp32s3-espidf/release/esp32`(约 1.1 MB)。
脚本执行 `cargo build --release --locked`,目标为 `xtensa-esp32s3-espidf`。首次编译包含 Rust 标准库、ESP-IDF 和相机组件,耗时较长。
> **改了 C 文件?**
> `components/esp32-camera/**` 和 `components/cam_wrapper/**` 不在 `esp-idf-sys`
> 的依赖追踪里,所以改它们**不会**触发重新编译,会静默地用旧代码。先清缓存:
>
> ```sh
> rm -rf target/xtensa-esp32s3-espidf/release/build/esp-idf-sys-*
> ./scripts/build.sh
> ```
Windows 的 ESP-IDF 构建输出路径需不超过 88 字符,因此示例使用短目录 `C:\s3vt`(需可写,且不与其他项目共用)。`subst` 不能绕过该检查。下表的 `<TARGET_DIR>` 在 Windows 示例中为 `C:/s3vt`,Linux / macOS 默认是仓库的 `target`。每次新开 Windows 终端都要设置相同的 `CARGO_TARGET_DIR`。
## 烧录并看日志
| 产物 | 位置 |
| --- | --- |
| 固件 ELF | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/esp32`(没有 `.exe` 后缀) |
| Bootloader | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/bootloader.bin` |
| 分区表 BIN | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/partition-table.bin` |
| 分区表源文件 | 根目录 `partitions.csv` |
ELF 供 `espflash` 烧录与符号解析,不能当作 BIN 直接写入 Flash。Bootloader、分区表与 App 应来自同一次构建。
## 烧录与串口验证
### 1. 查找串口
```text
espflash list-ports
```
Windows 常见 `COM5`;Linux 为 `/dev/ttyACM0` 或 `/dev/ttyUSB0`;macOS 为 `/dev/cu.usbmodem...`。关闭其他串口监视器,Linux 需具备串口访问权限。
### 2. 烧录 ELF
本项目使用自定义分区表,显式传入 `partitions.csv` 和本次构建的 Bootloader。将示例端口替换为实际端口:
```powershell
$targetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { 'target' }
espflash flash --chip esp32s3 --port COM5 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$targetDir/xtensa-esp32s3-espidf/release/bootloader.bin" --monitor "$targetDir/xtensa-esp32s3-espidf/release/esp32"
```
```bash
target_dir="${CARGO_TARGET_DIR:-target}"
espflash flash --chip esp32s3 --port /dev/ttyACM0 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$target_dir/xtensa-esp32s3-espidf/release/bootloader.bin" --monitor "$target_dir/xtensa-esp32s3-espidf/release/esp32"
```
连接失败时按住 **BOOT**,点按 **RESET / EN**,松开 BOOT 后重试。正常启动应看到相机识别、Wi-Fi 获取 IP 和 `ESP32 camera is live` 日志。没有客户端时,`0 clients subscribed` 属于正常状态。
仅查看日志:
```text
espflash monitor --port COM5
```
### 3. 导出 BIN
`espflash save-image --merge` 可导出从 `0x0` 写入的合并镜像。完整命令、独立镜像偏移与测试方法见 [DEVELOPMENT.md](DEVELOPMENT.md)。
保留的 `scripts/flash.sh` 针对**特定坏 Flash 模组**,依赖 macOS 环境,会修改分区并重编译。普通板卡使用上述标准流程;坏板限制见 [HARDWARE.md](HARDWARE.md#损坏-flash-模组的兼容处理)。
## 客户端与装甲板
桌面端入口如下。安装依赖、主机 target 设置与功能说明见 [desktop/README.md](desktop/README.md):
```text
cd desktop
pnpm install
pnpm app
```
装甲板是独立的 **ESP32-C3 / RISC-V** 工程,不能使用 S3 的 ELF。`ARMOR_TEAM=0/1` 在编译期决定红 / 蓝队伍。接线、规则与构建烧录见 [armor/README.md](armor/README.md)。
## 常见问题
| 现象 | 检查方法 |
| --- | --- |
| 提示缺少 `esp-1.97.0.0` | 按上述命令安装指定名称的工具链,重新打开终端后检查版本 |
| `espup` 查询 GitHub 版本返回 403 | 对上述固定版本安装命令追加 `--skip-version-parse`,或稍后重试 |
| 找不到 `ldproxy` / `libclang` | 安装链接工具;Unix 加载 `export-esp.sh`,Windows 重开终端 |
| ESP-IDF 路径或版本错误 | 清除旧 `IDF_PATH` 或指向 v5.5.5 SDK |
| Windows 路径过长 | 缩短仓库和工具链路径,见开发说明 |
| Wi-Fi 连不上 | 核对 2.4 GHz、凭据和认证方式,再重新编译烧录 |
| 相机无输出 / 初始化失败 | 核对板型接线、供电与 Octal PSRAM |
| `NO-EOI`、几帧后停止 | 确认 `CONFIG_CAMERA_PSRAM_DMA=n` |
| 在线但没有画面 | 检查客户端订阅、UDP 5600、防火墙及 AP 隔离 |
| 开启 VPN / 虚拟网卡后设备在线但无画面 | 使用支持按已发现设备 IP 定向续订的客户端;仅发送 `255.255.255.255` 广播可能走错网卡 |
| 改 C 组件后无变化 | 清理 `esp-idf-sys` 构建缓存后重编,见开发说明 |
| 主机项目被编为 Xtensa | 显式指定宿主 target,见各子项目说明 |
用 `python scripts/udp_probe.py 10` 可独立检查视频协议;装甲板用 `python scripts/armor_probe.py 15`。这些脚本需要板卡在线,构建通过不代表已完成硬件验证。
测量帧率和丢帧可运行以下命令,将 IP 换成板卡实际地址。对照测试时先退出桌面客户端,避免额外订阅者增加无线负载:
```text
python scripts/perf_probe.py 192.168.1.139 --seconds 30 --json perf.json
```
脚本定向续订,支持保留 VPN / TUN 的环境。输出包含完整帧率、帧损失率、接收间隔和组装耗时;这些时间不等于摄像头到屏幕的端到端延迟。
运动图传保持 `CAM_GRAYSCALE=0`,优先使用自动曝光上限和自动增益协同控制拖影。室内强制 −1 EV / 固定短快门在本机过暗,已撤回;提高增益上限也会增加噪点。需要限流时可设置 `CAM_MAX_FPS=15`,追求帧率时使用 `0`;可启用有界 JPEG 压缩调节应对发送回压,分片间隔按网络实测选择。灰度效果仍保留硬件 JPEG,不是改为 RGB565 或降低 JPEG 采样位数。75°C 是主动减载阈值,温度来自 ESP 芯片内部,不能代表摄像头或环境温度,也不是芯片损坏温度规格。五分钟彩色对照与此前中止的灰度长测见 [STABILITY.md](STABILITY.md)。
## 版本号与改动日志
版本号和 `CHANGELOG.md` 不手写,从 git 历史算:
```sh
./scripts/flash.sh --monitor 30
node scripts/version.mjs --write # 计算并写回 version.json / CHANGELOG / 客户端版本
node scripts/version.mjs --check # 只校验(CI 与发布前用)
```
它会编译、烧录、打印串口日志。预期输出:
```
I (2790) cam_wrapper: fps probe: 20 frames in 900 ms -> 22.2 fps, avg 9246 bytes
I (9817) esp32::udp: UDP sender listening on port 5600, streaming on request
I (9817) esp32: ESP32 camera is live on 192.168.1.139
I (9817) esp32: point the client at 192.168.1.139:5600 (UDP)
```
`fps probe` 那一行是**传感器上限**,在 WiFi 启动之前测的。排查流速慢时要拿它做
对照:如果 probe 说 22fps 而实际流不到,问题就在两者之间。
### 手动烧录
`scripts/flash.sh` 存在的原因之一是有一块坏 flash 字节(见
[硬件相关](#硬件相关))。它用 `esptool` 写三段:
```sh
B=$(ls -d target/xtensa-esp32s3-espidf/release/build/esp-idf-sys-*/out/build)
espflash save-image --chip esp32s3 --format esp-idf \
target/xtensa-esp32s3-espidf/release/esp32 /tmp/app.bin
~/.espressif/tools/python/*/venv/bin/python -m esptool \
--chip esp32s3 --port /dev/cu.usbmodemXXX --baud 460800 write-flash \
0x0 "$B/bootloader/bootloader.bin" \
0x8000 "$B/partition_table/partition-table.bin" \
0x10000 /tmp/app.bin
```
注意 app 偏移必须和 `partitions.csv` 里的 `factory` 一致。这里不能用
`cargo espflash flash`:它只写 app 段,而且会在没有 `.env` 的情况下重新编译,
导致 `main.rs` 里的 `env!` 失败。
### 找串口
```sh
ls /dev/cu.usbmodem* # 这块板子显示为 /dev/cu.usbmodem111301
```
`scripts/flash.sh` 会取第一个匹配项,也可以 `ESP_PORT=/dev/cu.usbmodemXXX` 指定。
---
# 客户端
## 环境要求
- Rust(普通 stable 或 nightly,**不要**用固件那个 `esp` 工具链)
- 不需要额外的系统库:macOS 和 Linux 上 Slint 用 `femtovg` 渲染,也不需要 `skia`
> `client/.cargo/config.toml` 把 `target` 钉成 `aarch64-apple-darwin`,因为这个
> 目录位于固件源码树内部,否则会继承 Xtensa 交叉编译配置。**要按你的机器改**——
> Intel Mac 用 `x86_64-apple-darwin`,Linux 用 `rustc -vV | grep host` 的输出。
## 编译与运行
```sh
cd client
cargo build --release
./target/aarch64-apple-darwin/release/cam-client # 用下面的默认地址
./target/aarch64-apple-darwin/release/cam-client 192.168.1.139
```
```
用法: cam-client [CAMERA_IP] [PORT]
CAMERA_IP ESP32 摄像头地址(默认 192.168.1.139)
PORT UDP 视频端口 (默认 5600)
```
改 `ui/camera.slint` 时用一步到位的方式:
```sh
cd client && cargo run --release -- 192.168.1.139
```
## 界面读数
```
streaming 20.8 fps 10.1 kB per frame 35 ms between frames
640x480 shown 146 dropped 0 (0.0%) 1270 datagrams
```
**丢帧率和帧率同等重要**,这正是把统计放在界面上的原因。这条传输链路是**整帧丢弃
而不重传**,所以单看帧率是有歧义的:
| 帧率 | 丢帧 | 含义 |
|---|---|---|
| 达到传感器上限 | 0 | 链路没问题,瓶颈是传感器 |
| 远低于上限 | 有 | 瓶颈是网络 |
| 低 | 0,且显示 "no frames - camera?" | 板子没在推流 |
状态文字的颜色是同一信息的粗略版本:推流中绿色,否则琥珀色。
### 帧率是"计数"算出来的,不是"平均间隔"
不是对帧间隔取平均。一次 UI 轮询之间可能已完成好几帧,所以平均间隔量的是
**显示**的刷新快慢,而不是**相机**的出帧快慢——那曾把上限 22fps 的传感器报成
36fps。一个笃定的错误数字比没有数字更糟。
## 诊断
```sh
RUST_LOG=info ./target/aarch64-apple-darwin/release/cam-client 192.168.1.139
```
```
frames shown 146 | dropped 0 (0.0%) | datagrams 1270 | undecodable 0 | short 0
```
| 计数器 | 含义 |
|---|---|
| `frames shown` | 完整重组并交给界面的帧 |
| `dropped` | 因分片缺失而被放弃的帧 |
| `datagrams` | 能解析为分片的数据报 |
| `undecodable` | 收到了但解析不了的数据报 |
`undecodable` 非零意味着板子的数据报**到了但没看懂**——是协议或端口对不上,不是网络
问题。这两者很容易混淆,而这个计数器正是用来区分它们的。
## 排查
| 现象 | 原因 |
|---|---|
| 一直显示 `connecting to …` | 板子不在网上,或防火墙拦了入站 UDP 5600 |
| `no frames - camera?` | 板子停止推流:重启了,或掉线了 |
| 完全没有输出,也没有日志 | host triple 不对,见[目标架构](#目标架构) |
| 只有第一帧然后卡住 | 另一个客户端连在别的端口上;板子只推给最后宣告的那个 |
| 颜色错乱 | 通道顺序。当前构建用 `Rgba8Pixel`,另一个选项是 `Bgra8Pixel` |
| 编译在 `libc` / `memmap2` 报错 | 用的是 `esp` 工具链,或缺少 target triple 覆盖 |
---
# 工作原理
## 采集 → UDP
```
OV5640 ──DVP──> LCD_CAM/GDMA ──> PSRAM 帧缓冲
│
frame::next_frame() (阻塞)
│
切成 1400 字节一个的数据报
│
对每个 peer sendto()
```
`esp_camera_fb_get` 会阻塞到传感器出帧,所以帧率跟随传感器,中间没有任何可能被
积压 overrun 的队列。数据报一进内核,帧缓冲立刻还给驱动——多占着只会增加延迟。
## 对端
板子是服务端,客户端是对等端。客户端绑定 UDP 5600 并发一个宣告数据报;板子随后
就往它看到的源地址推流,当存活探测开始失败时移除该对端。没有握手、没有发现协议,
而且客户端晚连(或板子重启)都能自恢复——因为宣告每 500ms 重发一次。
最多并发 4 个对端。
## 为什么要 `cam_wrapper`
`esp_camera.h` 里的 `camera_config_t` 对已废弃的 SCCB 引脚名用了匿名 union,bindgen
会生成成 unsafe union 字段,从 Rust 构造很别扭。`components/cam_wrapper` 接收一个
纯 int 结构体,在 C 侧填 union,于是 Rust 侧的绑定简单到可以安全构造。
## 客户端线程模型
三个线程,且**没有任何 worker 线程碰 Slint 组件**:
- `udp-reader` —— 收包与重组
- `jpeg-decode` —— JPEG 解码成 RGBA
- 主线程 / 事件循环 —— 转成 `slint::Image` 并更新属性,每 30ms 一次
这是 Slint 的硬性约束而非风格偏好:组件只能在拥有它的线程上使用,
`Weak<App>` 是 `!Send` 就是为了强制这一点。worker 线程去 upgrade 一个 weak handle
会拿到 `None` 然后什么都不做——现象和"界面卡死"完全一样。
---
# 硬件相关
## 引脚表
默认匹配 **Freenove ESP32-S3-WROOM CAM** 的摄像头座(`src/camera.rs` 的 `pins`
模块):
| 信号 | GPIO | | 信号 | GPIO |
|---|---|---|---|---|
| RESET | 0 | | D0 (Y2) | 11 |
| XCLK | 15 | | D1 (Y3) | 9 |
| PCLK | 13 | | D2 (Y4) | 8 |
| VSYNC | 6 | | D3 (Y5) | 10 |
| HREF | 7 | | D4 (Y6) | 12 |
| SIOD | 4 | | D5 (Y7) | 18 |
| SIOC | 5 | | D6 (Y8) | 17 |
| | | | D7 (Y9) | 16 |
数据总线是传感器的 **Y2..Y9**,即 10 位像素字的低字节,所以 Y2 是 D0、Y9 是 D7。
### 千万不要套用别的板子的引脚表
上面每个引脚都是 ESP32-S3 空出来的。下面这些**不是**:
| GPIO | 被什么占用 | 拿来用会怎样 |
|---|---|---|
| 26–32 | 模组的 SPI flash | `PIN_FUNC_SELECT` 改掉 flash 的复用,CPU **在传输中途挂死** |
| 33–37 | octal PSRAM | 同上,对内存总线而言 |
| 19, 20 | 原生 USB | 串口 console 断掉 |
| 43, 44 | console UART | 日志断掉 |
| 45, 46 | 模组内部 | 保留 |
在 ESP32-S3 上套用 AI-Thinker ESP32-CAM 的引脚表是踩这个坑的经典方式:15 根信号里
有 7 根落在 flash、PSRAM 或 USB 上。现象是板子能正常枚举、摄像头也能配置成功,
然后在第一次 `PIN_FUNC_SELECT` 时锁死。
## Flash:DIO @ 40MHz
`sdkconfig.defaults` 用 DIO/40MHz 而不是这个片子标称的 QIO/80MHz,因为测试用的模组
有一块坏 flash 字节(见下),四线模式会把它推进启动循环。硬件正常的话可以换回
QIO + 80MHz——程序是从 flash 运行的,这会明显加快**代码**执行。
## 坏的那块 flash 字节
用于点亮的这块模组,其 SPI flash 在 **`0xFADE7`** 有一个字节的 bit5–7 写不进去。
镜像放在该地址上的任何值都会被破坏,校验和失败,bootloader 拒绝启动。
镜像内容不受它在 flash 中**位置**的影响,所以 `scripts/flash.sh` 会挑一个 64KB 对齐
的 app 偏移,使该地址上的字节是这块 flash 存得下的,相应改写 `partitions.csv`,并在
烧录前校验。
**这是为坏硬件准备的绕行方案,不是设计选择。** 换到正常模组时:删掉
`scripts/flash.sh` 里的 `BAD_FLASH_ADDR` 逻辑,把 `partitions.csv` 的 `factory` 改回
`0x10000`,之后就可以用 `cargo espflash flash`。
## `PSRAM DMA` 是故意关掉的
`sdkconfig.defaults` 里 `CONFIG_CAMERA_PSRAM_DMA=n`,原因就写在那个文件里。简要说:
开着时 `esp32-camera` 只在最后 1KB 的 DMA 节点里找 JPEG 结束标记(EOI)。标记一旦
落在窗口外,这一帧就被 `NO-EOI` 丢弃——在这块板子上是大部分帧,之后驱动彻底不再
交出任何帧且不会恢复。关掉后每帧多一次约 20KB 的 `memcpy`,但搜索范围是整个缓冲。
## CPU 频率
显式设置了 `CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y`,因为 **ESP-IDF v5.5 把 ESP32-S3
的默认值设成 160MHz**(`components/esp_system/port/soc/esp32s3/Kconfig.cpu`)。默认
构建会白白浪费一半 CPU,而采集、JPEG 搜索、lwIP、HTTP 全是 CPU 密集的。启动时确认:
```
I (891) cpu_start: cpu freq: 240000000 Hz
```
## WiFi 缓冲留在内部 RAM
`CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=n`。这里原本是 `y`,理由是"PSRAM 空间充足"——
恰好搞反了。WiFi 描述符和 TCP 段每个包都要碰,而内部 SRAM 比隔着内存总线的 PSRAM
快一个数量级。帧缓冲体积大、相对少碰,仍然留在 PSRAM。
运行时也关掉了 WiFi 省电模式:开着时空闲的信标间隔要花几百毫秒,表现为卡顿和
200–300ms 的 ping。
---
# 可调项
| 项目 | 位置 | 说明 |
|---|---|---|
| 分辨率 | `.env` 的 `CAM_RES` | QVGA..UXGA。CIF 以上需要 PSRAM。 |
| JPEG 质量 | `.env` 的 `CAM_QUALITY` | 0–63,越小越好。VGA 下 12 约 10–20KB/帧。 |
| 帧缓冲数 | `camera.rs` 的 `fb_count` | 3:一个在填、一个在发、一个备用。 |
| 指定传感器 | `main.rs` 的 `SensorHint` | 除非探测失败,否则用 `Auto`。 |
| XCLK | `camera.rs` 的 `xclk_freq_hz` | 20MHz;OV5640 的 PLL 只为此调校过。 |
| VSYNC 极性 | `.env` 的 `CAM_VSYNC_INVERT` | 仅当板子在硬件上反相时需要改。 |
| 采集核心 | `CONFIG_CAMERA_CORE0` | `n` 把采集放到 CPU1,避开 CPU0 上的 WiFi 任务。 |
| 分片大小 | `protocol/` 的 `MAX_FRAGMENT_PAYLOAD` | 1400 保持在 1500 MTU 以内。再大就有 IP 分片风险。 |
## 继续提高帧率
传感器在 VGA 下大约封顶 22fps,而链路现在已经跟得上,所以下一个杠杆在传感器侧:
- **SVGA(800x600)** 牺牲的是分辨率而非帧率——OV5640 的帧率基本由像素时钟决定。
- **调大 `CAM_QUALITY` 的数字** 会缩小帧体积、减少字节数,但瓶颈是每秒帧数而不是
带宽。
- 如果要的是低延迟而不是细节,用 **QQVGA/QVGA**:DVP 总线和 PSRAM 带宽都随像素数
线性变化。
## 协议测试
`protocol/` 是最容易出现细微错误、又无法在固件上验证的部分,所以它有真正的单元测试
并且为宿主架构编译:
```sh
cd protocol && cargo test
```
```
7 passed
```
- 版本号由提交类型决定:`BREAKING CHANGE:` → major,`feat` → minor,`fix`/`perf` → patch。
- 构建号 = `git rev-list --count HEAD`,单调、不撞车。
- 同一次计算还产出 `version.json`(固件 `build.rs` 读它)、`CHANGELOG.json`、
`desktop/src/lib/version.ts`。
**固件与客户端的版本是联动过的**:板子每 2 秒广播的 `Hello` 里带着语义化版本、构建号与
它编译进去的线路协议号;客户端读出来显示在设置与裁判端每一格下面,协议对不上时直接说明
原因("板子是协议 2,客户端是协议 1,画面会被丢弃,请重新烧录"),而不是只表现为没有画面。
细节见 [`desktop/README.md`](desktop/README.md#版本与改动日志算出来的不是写出来的) 与
[`protocol/README.md`](protocol/README.md#通告里的版本信息)。
## 文档索引
- [CHANGELOG.md](CHANGELOG.md):改动记录与历史修复。
- [HARDWARE.md](HARDWARE.md):接线、内存、Flash 与驱动限制。
- [DEVELOPMENT.md](DEVELOPMENT.md):镜像导出、测试、协议与性能测量。
- [PERFORMANCE.md](PERFORMANCE.md):保留 TUN 的图传排查、参数取舍与实际测试结果。
- [STABILITY.md](STABILITY.md):限帧、灰度与温度减载,长时间运行测试。
- [REALTIME.md](REALTIME.md):阻塞时取最新帧、超期丢弃和客户端积压处理。
- [POWER.md](POWER.md):15 dBm 回退、功率与距离的关系,以及发送拥塞证据。
- [LATENCY.md](LATENCY.md):当前约 30 fps 配置、Wi-Fi 重连及实际画面链路延迟对照。
- [protocol/README.md](protocol/README.md):UDP 线路格式及帧重组。
- [armor/README.md](armor/README.md):装甲板固件与规则。
- [desktop/README.md](desktop/README.md):桌面端配置、录制与排查。
- [docs/firmware-provisioning-evaluation.md](docs/firmware-provisioning-evaluation.md):自动烧录、坏区规避与 SSID 配置 patch 的工具评估、MVE 与测试矩阵。
- [docs/firmware-maintenance.md](docs/firmware-maintenance.md):已实现的固件维护页、官方 bundle 格式、坏块规避流程与实机验证记录。
+73
View File
@@ -0,0 +1,73 @@
# 阻塞恢复与最新帧处理(2026-10-18)
## 当前交付
当前 15 dBm 交付 App SHA-256:`a6092e4b0cf42819c04424e066651f285b8961312a73822d667532f800e46767`,应用分区 `0x70000`。COM8 烧录与三段独立校验通过;355.1 秒串口的 71 次功率回读均为 15.00 dBm。五分钟观察:原生完整接收 **29.68 fps**,丢帧 **1.045%**,本地 JPEG 通道 **28.23 fps**,31 张抽帧全部完整解码为 640×480 RGB,无无效 JPEG、设备离线、通道断开、复位或采集错误;仍有最长 1.646 秒到帧间隔,不能将此轮称为无卡顿。距离与拥塞分析、测试边界见 [POWER.md](POWER.md) 和制品 `POWER-RESULT.json`。
保留已确认可用的三块 PSRAM 采集缓冲、OV5640 VGA 彩色、约 30 fps PLL 时钟、AE 目标 45/55、约 20 ms 曝光上限及自动增益;Wi-Fi 自动重连和 TUN 兼容订阅继续保留。没有增加待发送 JPEG FIFO,也没有为了消除队列改成单块 DMA 缓冲。
发送程序只持有当前一张完整 JPEG;阻塞恢复后通过有限次数的零等待取帧选择最新有效帧,旧帧立即归还驱动。三块采集内存用于捕获余量,与必须按序播放的三帧队列不同。已有相机队列仍然有限,应用交接会跳过其旧帧。
完整 JPEG 取得后按 1400 字节载荷分片立即提交,复用一个数据报缓冲,直接读取 PSRAM。当前传感器驱动在 JPEG 结束后交出完整帧,本次没有修改为 DMA 尚未完成就读取其内容;不完整 JPEG 由接收端整帧丢弃,不进入绘制。
## 阻塞处理
| 阶段 | 处理方式 |
| --- | --- |
| 采集交接 | 首帧最多等待 100 ms,其后零等待、有限次数取更新的有效帧;归还被替代的旧缓冲 |
| 发送帧龄 | 用板内同一单调时钟检查从首个 DMA 缓冲开始的年龄,期限 100 ms |
| 当前帧发送 | 绝对检查期限 25 ms,成功发送分片不会续期,所有订阅者共享 |
| 拥塞重试 | 默认每帧最多 8 ms 让出调度重试;预算用尽或过期后放弃该目标剩余分片,转向新帧 |
| 原生本地通道 | 每路保留最新帧;不重放超过 100 ms 的缓存,写入超过 100 ms 时断开慢端以便重连 |
| WebView / 解码 | 原生端在 JPEG 前发送 `frameTiming`;JS 在解码前后拒绝本机滞留超过 100 ms 的帧并释放旧结果 |
```dotenv
CAM_FB_COUNT=3
CAM_AE_LEVEL=-1
CAM_EXPOSURE_EV=0
CAM_EXPOSURE_MAX_LINES=592
UDP_PACKET_GAP_US=0
UDP_RETRY_BUDGET_US=8000
WIFI_TX_POWER_DBM=15
```
当前交付根据用户反馈回退到 15 dBm,实板驱动回读 15.00 dBm。启动和重连应用同一功率上限,拥塞和温控逻辑不自动降功率;此前 17 dBm 的数据作为历史记录保留,见 [POWER.md](POWER.md)。
若要在发送回压时立即放弃本帧,可将 `UDP_RETRY_BUDGET_US=0` 后重编烧录。该模式仍保留三块采集内存和完整 JPEG 校验;含义是零让出重试,不是删除驱动和路由器中的所有缓冲。零重试策略已经主机测试,本次五分钟实机使用默认 8000 μs。
分片间隔也计入 25 ms 检查期限,不再额外延长帧的发送寿命;设置过大的间隔可能使整帧无法在期限内提交。分片在 SDK socket 中已经接受后无法由本策略撤回,底层调度和网络排队也无法由应用层强制抢占。以上为应用层新鲜度策略,不能保证每帧、无线链路或物理屏幕具有硬实时上限。
本地 JPEG 二进制头继续为 16 字节,旧消费者可忽略新增文本消息。新前端连接旧后端时仍可用,但没有原生发布时间,只能按 JS 收到时刻判断本地队列年龄。两端本机发布时间使用同机系统时间;这不是 ESP 与电脑的时钟同步,也不能据此计算无线单向延迟。JS 内部排队同时使用单调时钟检查。
## 此前 14 dBm 版本的实机五分钟检查
该次测试使用 14 dBm,App SHA-256:`9a7decd59572c3f280e7fd4c0a291645011b779025e56e99ea50087e92b5fa77`,应用分区 `0x60000`。以下五分钟数据属于该历史镜像,不是提高功率后的重测结果。该镜像曾经 COM8 烧录并独立三段校验,三段镜像必须成套使用。运行保留 TUN、只有一个 UDP 订阅者;串口和本地视频观察器不会产生额外 UDP 推流。
| 指标 | 本次结果 |
| --- | --- |
| 本地 JPEG 通道观察时长 | 300.005 s |
| 原生接收计数有效区间 | 180.937 s |
| 完整接收 / 丢帧 | 5390 / 26(仅有效区间) |
| 完整接收帧率 / 帧损失率 | 29.79 fps / 0.480%(仅有效区间) |
| 本地 JPEG 通道 | 29.69 fps |
| 本地 JPEG 通道到达间隔 P95 / 最大值 | 47.648 / 323.146 ms |
| 主动跳过旧采集帧 | 0 |
| 发送前过期帧 / 发送期限目标丢弃 | 0 / 0 |
| 提交时采集帧龄最大观测值 | 74.13 ms |
| 发送持有时间最大观测值 | 11.23 ms |
| 复位 / 采集错误 | 0 / 0 |
观察途中界面切到队伍端,按设计隐藏诊断计数,所以原生完整帧率和损失率只报告隐藏前的有效区间,不提供完整五分钟的原生丢帧率;本地 JPEG 通道则完整观察五分钟。保留用户当前界面选择,没有强制切回裁判端。两个最大值是板端自启动起的观测值,包含预热;帧龄从驱动的首个 DMA 缓冲时间戳开始,不是光子进入传感器到屏幕的时间。主动跳过的采集帧尚未分配网络帧号,不计入接收端丢帧率;两种计数不能混为一谈。另抽取 61 张 JPEG,均完整解码为 640×480 RGB。
部署前的 30 秒基线仅用于检查连通性,画面内容和无线状态有变化,不能据此证明本策略使丢帧率或光学延迟降低。本次未重新测量物理端到端延迟,也未证明所有长程拥塞峰值均已解决。较早的标记回显测量保留在 [LATENCY.md](LATENCY.md)。
## 阻塞恢复验证
真实原生视频通道上的辅助接收端主动阻塞事件循环 350 ms,生产 `StreamClient` 恢复后拒绝 8 张过期帧,帧号从 1886 前进至 1918。该测试没有新增 UDP 订阅者;JPEG 解码在此辅助测试中使用替身,验证的是本地积压年龄和恢复逻辑,实际 JPEG 完整解码另行检查。
- 12 项 Rust 调度策略测试:绝对帧期限、旧采集帧寿命、共享重试预算、零重试及原有调度策略。
- 4 项 C 驱动交接情景:有限次数追赶、旧缓冲仅归还一次、无有效新帧时保留已有帧、首次超时及单缓冲边界。
- 8 项前端测试:慢解码保留最新待处理帧、500 ms 本机积压拒绝、阻塞解码结果释放、待解码帧超期和原有关闭/重启处理。
- 10 项原生视频通道测试:时间标记先于对应 JPEG、重连不重放过期缓存及原有协议与通道行为。
使用 `python scripts/test-latest-frame.py` 运行驱动交接测试;`desktop/` 中运行 `pnpm test:stream`。实时通道故障测试为 `node desktop/scripts/test-live-freshness.mjs <本机私有调试日志> <结果JSON>`,日志内的通道令牌仅在程序内部读取,不应发布。完整测量与边界见制品 `REALTIME-RESULT.json`,不包含私有日志、用户配置或相机抽帧。
+135
View File
@@ -0,0 +1,135 @@
# 彩色图传调度与持续运行验证(2026-10-18)
本文保留此前约 22 fps 配置及更早长测的历史记录。后续约 30 fps PLL 配置、缓冲选择、运行中断线恢复与画布回显延迟测量见 [LATENCY.md](LATENCY.md);以下参数与 SHA 属于当时的测试阶段,当前交付以新报告和制品 `BUILD-INFO.json` 为准。
## 测试目标与配置
使用 ESP32-S3-N16R8 / OV5640、VGA、8 MB PSRAM、CPU 240 MHz、Windows 原生客户端,保留 Mihomo TUN。每轮只运行一个 UDP 订阅者,串口记录器与本地 WebSocket 观察器不会新增 UDP 推流目标。Windows 到板卡的实际路由走 WLAN;电脑连接 5 GHz、板卡连接 2.4 GHz。
优化目标为彩色、内容可辨、较短曝光和尽可能高的完整接收帧率,每轮有效测量为 5 分钟。下文保留了亮度与时序调整过程;不同轮次的场景、曝光、压缩和网络状态有变化,接收数字是描述性对照,不能作为严格单因素实验。
```dotenv
CAM_RES=vga
CAM_QUALITY=18
CAM_QUALITY_MAX=30
CAM_MAX_FPS=0
CAM_GRAYSCALE=0
CAM_EXPOSURE_EV=0
CAM_SHUTTER_LINES=0
CAM_EXPOSURE_MAX_LINES=440
CAM_GAIN_CEILING=32
CAM_HTS=0
CAM_THERMAL_LIMIT_C=75
WIFI_TX_POWER_DBM=14
UDP_PACKET_GAP_US=0
```
这些参数在编译时生效。该阶段配置的板上无网络采集探针为 22.20 fps,保留驱动默认 HTS 2060 / VTS 984 / XCLK 20 MHz;未采用颜色异常的短 HTS,也未采用此前导致采集下降至 8.1 fps 的 VTS 调整。
## 曝光与彩色
OV5640 驱动的 AE 档位不是 EV 数值:名义零档目标为 55,档位 −3 的目标为 25,目标比值约为 −1.14 EV;档位 −4 的目标为 15,约为 −1.87 EV。固件的 `CAM_EXPOSURE_EV=-1/-2` 使用这两个近似档位;这不是经过照度标定的绝对 EV。
早期仅降低 AE 目标后,实际曝光仍为 885 行;固定 220 行约为 10 ms,但用户反馈太暗,固定约 20 ms 也仍然太暗,均已撤回。其他时钟或分辨率下,行数对应的时间不同,不能直接套用行数。
该阶段采用正常亮度目标、自动 AEC / AGC、440 行曝光上限,关闭夜景延帧,并按现有 50/60 Hz band step 同步约束最大 band 数。自动增益上限由原来的 15.5× 扩至 31.94×,给短曝光补偿空间;增益会放大噪点。预检实际曝光为 295 行,按 SYSCLK 45 MHz / HTS 2060 估算约 13.5 ms,约为早期 885 行曝光的三分之一。440 行上限约 20.1 ms,实际值由场景与频闪步长决定。
用户已确认新配置**亮度够用、颜色稳定**。它仍使用传感器硬件彩色 JPEG,不通过降低色深来提帧。
彩色 JPEG 抽帧已解码为 640×480 RGB,仍使用传感器硬件 JPEG 编码。没有用灰度换取本轮帧率。曝光行数日志用于验证快门配置,未进行运动靶拖影或照度标定测量。驱动依据:[Espressif OV5640 源码](https://github.com/espressif/esp32-camera/blob/master/sensors/ov5640.c)。
## 最终配置的五分钟验证
本轮测量从 `2026-10-18T12:24:10.381357+00:00` 到 `2026-10-18T12:29:10.384000+00:00`,持续 300.004 秒。下表对照前一轮固定约 20 ms、quality 18 的完整五分钟记录;前一轮用户确认过暗,本轮用户确认亮度够用、颜色稳定。
| 指标 | 固定快门对照 | 自动曝光上限 + 压缩调节 |
| --- | --- | --- |
| 完整接收帧 | 5220 | 6520 |
| 丢帧 | 1430(21.50%) | 34(0.52%) |
| 完整接收帧率 | 17.40 fps | 21.73 fps |
| 本地 JPEG 通道帧率 | 13.45 fps | 20.99 fps |
| 通道帧间隔 P95 | 277.473 ms | 102.841 ms |
| 通道帧间隔最大值 | 1163.792 ms | 419.825 ms |
| 平均 JPEG 大小 | 12099 字节 | 16346 字节 |
本轮全程在线,无复位、采集错误、本地通道断开、计数重置或诊断计数缺失。视频包长度与首尾标记错误为 0;另抽取 148 张 JPEG 完整解码,均为 640×480 RGB。
测量窗口内曝光为 295–295 行;JPEG 档位为 18–22,实际增益 11.00–18.00×。内部堆最小 129455 字节,平均提交耗时 2.60 ms/帧,温度 54–59°C,未触发温度减载。
这次完整接收帧率接近当前可靠彩色时序的约 22.2 fps 上限。亮度、画面内容、JPEG 大小和网络负载在各轮之间变化,不能把全部差异归因于单一控制参数,也不能将五分钟结果作为长期或所有路由器的保证。帧间隔不是摄像头到屏幕的光学延迟。
本轮固件 SHA-256 为 `e297069e190dcccefbae6d95591703c6848f4e523f910e2c0cb44af91f698936`,当时与交付及板上镜像一致;最新交付见 LATENCY.md。配套 Bootloader、分区表及 App 均已独立读回校验。曲线和完整数据位于制品包内的 `STABILITY.png` / `SCHEDULING-RESULT.json`,不包含相机抽帧或令牌日志。
## 较早的五分钟彩色基线
以下为较早的完整 300.002 秒测量,HTS 保持驱动默认 2060,快门 220 行,UDP 分片间隔为 0。真实 Windows 客户端接收,非演示模式,单 UDP 订阅者。该场景 JPEG 约 9 KB,用户随后确认这一曝光方案过暗,因此不再作为最终交付配置。
| 指标 | 结果 |
| --- | --- |
| 完整接收帧 | 6647 |
| 丢帧 | 12,0.1802% |
| 完整接收帧率 | 22.1565 fps |
| 本地 JPEG 通道帧率 | 20.8365 fps,6251 帧 |
| 接收通道帧间隔 P95 / 最大值 | 102.459 / 408.082 ms |
| 平均 JPEG 大小 | 9113 字节 |
| 实际曝光 | 全程 220 行 |
| 采集错误 / 复位 / 本地连接断开 | 0 / 0 / 0 |
图传完整帧率已接近该采集配置的约 22.2 fps 上限;本地通道采用 latest 模式,突发到达的多帧可能被合并,不能用其帧数代表屏幕已绘制帧数。不同场景下 JPEG 大小和无线负载会变化。
## 间隔发送的中止轮次
1000 μs 最小分片间隔轮次在设备运行约 235 秒时出现串口 I/O 错误,随后系统不再枚举开发板串口,摄像头同时离线。观察器提前停止,未完成有效的五分钟对照,断连尾段不能归因于调度。
断连前 3 个完整在线窗口合计 181.192 秒,接收 21.58 fps、帧损失 1.96%,本地通道 17.25 fps,没有显示出逐包间隔的收益。它可能影响 AMPDU 聚合效率,但未抓取空口包,不能确认唯一原因。保留突发发送作为已验证基线,不采用这轮的完整汇总作性能结论。
## 已撤回的行时序提帧试验
新增可选 `CAM_HTS`,仅改变 OV5640 水平总时长,保留有效读出窗口及消隐余量,PLL、XCLK、VTS 不变。候选参数为 HTS 1500、快门 300 行,估算保持约 10 ms 曝光,采集节拍目标约 30.5 fps。
USB 恢复后,HTS 1500 的板上采集探针达到 30.49 fps,但白光环境出现明显绿紫闪烁,用户确认不可用。HTS 1800 的探针为 25.41 fps,仍出现明显偏紫,也已中止;两者均不作为有效五分钟彩色结果或交付配置。恢复默认 HTS 2060 后,用户确认颜色稳定。
1500 轮次开始时队伍 release 模式隐藏接收计数,因此该轮汇总中的接收 fps / 丢帧为无效值,不能当作零丢帧;观察脚本现已要求裁判诊断计数并记录诊断可用性。`0x380C/0x380D` 的水平总长定义见 [OV5640 数据手册](https://files.waveshare.com/upload/d/da/OV5640_DataSheet.pdf)。几何检查和 JPEG 成功解码都不足以证明彩色稳定。
## 室内亮度回归
用户确认 −1 EV / 固定 10 ms,以及恢复名义 0 EV / 固定约 20 ms 后仍然太暗。因此最终方案不再固定短快门,而是让 AEC / AGC 自动联动,并以曝光上限控制拖影。手册说明 AGC 通常在 AEC 到达上限后继续增加;仅保留 AGC 开关不能当作亮度已经补足的证据。需要结合实际曝光、增益和画面验证。
固定 20 ms 的完整五分钟轮次接收 5220 帧、丢帧 1430(21.50%),接收 17.40 fps,本地 JPEG 通道 13.45 fps,接收间隔 P95 277.473 ms、最大 1163.792 ms;全程在线、无采集错误。它保留为拥塞对照,未被选为最终配置。
## 发送调度
当前启用有界 JPEG 压缩调节,`CAM_QUALITY=18` 为画质恢复目标,`CAM_QUALITY_MAX=30` 为压缩上限。每 5 秒检查本机发送窗口:对端帧放弃比例达到 2%,或平均提交耗时达到 8 ms 时,压缩数字增加 3 档;连续 3 个无放弃且提交小于 4 ms 的窗口后,才降低 1 档恢复细节。无订阅或流量不足时不会当作健康窗口。
该反馈来自板端提交回压,不包含接收端 ACK;不能据此声称消除了路由器后续丢包。调节不改变彩色、分辨率或采集时钟,运行日志记录实际 JPEG 档位、曝光行数和增益。
视频数据报使用 WMM AC_VI、保留 AMPDU。`UDP_PACKET_GAP_US` 控制成功提交数据报的最小间隔,调度 tick 可能使实际等待更长。等待期间让出 CPU,避免在帧开始时集中向 Wi-Fi 队列灌入全部分片。
每帧预算为 25 ms 加计划分片间隔,总等待上限 40 ms;没有调度间隔时仍为 25 ms。超过预算则放弃该对端的剩余旧帧,不追赶补发。分片数量较多时需要重新选择间隔,不能把某个 VGA 配置的结果直接套到更大图像。
`submitted` 只表示分片全部交给了网络,不代表无线确认或屏幕显示。帧损失包含接收端未完成帧与帧号缺口;设置帧率上限后,latest 模式有意覆盖的旧采集帧不进入 UDP 帧编号,不计入该丢帧率。
## 此前中止的灰度长测
原计划 30 分钟的灰度 / 15 fps 长测按用户要求提前停止,保留了 **18 个完整分钟窗口**,合计 1080.15 秒:完整帧 15307、丢帧 806,平均 14.17 fps,帧损失 5.00%。板端记录温度约 48–58°C,无复位和采集错误。
早期丢帧上升时温度没有同步上升,温度下降期间也出现过明显发送失败。内存紧张后能够恢复;发送失败与队列回压更符合当前证据。这一轮不计为完成 30 分钟验证,且配置不同,不能与彩色不限帧试验直接作单因素对照。
温度控制仍可选:达到 75°C 时将已设置的帧率减半(向上取整),原来不限帧时降至 8 fps;降到 70°C 后恢复。阈值可设 40–95°C,`0` 禁用。75°C 是主动减载策略,不是芯片损坏温度规格。本轮未触发该策略。ESP 内部温度不能代表 OV5640 或环境温度:[ESP-IDF v5.5.5 温度传感器说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-reference/peripherals/temp_sensor.html)。
## 复测与记录
退出其他图传客户端,使用一个非演示模式的 Windows 裁判端。release 队伍模式隐藏技术计数,不能用于本脚本的接收 fps / 丢帧测量;用户在本机切换身份后可继续使用原进程,本次没有改写用户配置。`RUST_LOG=cam_desktop=debug,cam_core=info` 会记录本地 WebSocket 地址及令牌,原始日志仅供本机诊断,不应公开。
从仓库根目录分别运行,串口及设备 ID 换成实际值:
```text
python scripts/serial_soak.py --port COM7 --seconds 330 --out target/soak/device --stop-file target/soak/stop
node desktop/scripts/soak-stream.mjs --log target/soak/referee-stderr.log --out target/soak/client --seconds 300 --warmup 10 --stop-file target/soak/stop --sample-every 5
```
串口脚本依赖 `pyserial`;观察脚本使用 Node.js 22 内置 WebSocket。默认设备 ID 为 `0x3D4D8A9A`,换板用 `--camera 0x设备ID`。创建 stop 文件可提前结束并保存部分窗口;新一轮使用不存在的 stop 文件路径。
串口每 5 秒保存温度、曝光行数、增益、JPEG 档位、内部堆、采集错误、发送重试、订阅数与帧率。客户端每分钟保存完整帧率、帧损失、JPEG 通道帧率、间隔 P95 / 最大值与连接状态,并标记离线、诊断不可用与末尾无视频时长。`--sample-every` 可选,单位为秒,抽帧仅留本机。JSON 标记实际时长与是否提前停止,不能将部分窗口称为完成 5 分钟。原始记录位于本机 `target/soak/`,随构建缓存忽略。
JPEG 长度与首尾标记校验不等于每张图的完整解码验证;本地 WebSocket 通道不代表 WebView 最终绘制。接收间隔与分片组装耗时均不是摄像头到屏幕的光学延迟。
+31
View File
@@ -0,0 +1,31 @@
# 装甲板的目标是 ESP32-C3(RISC-V),与外层 esp32/.cargo/config.toml 的
# Xtensa 目标互不相干。这个文件离得近,所以会覆盖外层同名键(尤其是 MCU)。
[build]
target = "riscv32imc-esp-espidf"
[target.riscv32imc-esp-espidf]
linker = "ldproxy"
runner = "espflash flash --monitor"
rustflags = ["--cfg", "espidf_time64"]
[unstable]
# `esp` 工具链只带了 host 的预编译 std,没有 esp 目标(与图传板那套一样)。
# 所以必须自己编 std:键名是 `build-std`(连字符),写成 `build_std` 会被 cargo
# 当成未知键静默忽略,然后在链接阶段报找不到 `core`。
build-std = ["std", "panic_abort"]
[env]
MCU = "esp32c3"
# 本机装了两套 ESP-IDF:
#
# /Users/peng/.espressif/v6.0.1/esp-idf eim 装的,工具链齐全(含 RISC-V)
# /Users/peng/.espressif/esp-idf/v5.5.5 只有 Xtensa 工具链(图传板在用)
#
# C3 是 RISC-V,所以这里指向 v6.0.1。esp-idf-sys 0.38.1 支持 6.0–6.2。
#
# `ESP_IDF_TOOLS_INSTALL_DIR` 刻意 `force = false`:默认 `global`(即 ~/.espressif),
# 但如果环境里已经给了 `custom:<可写目录>`(沙箱、CI),就用环境里的那个。
# 形状必须与外层 esp32/.cargo/config.toml 一致(都是表),否则 cargo 合并配置会报错。
IDF_PATH = { value = "/Users/peng/.espressif/v6.0.1/esp-idf", force = true }
ESP_IDF_TOOLS_INSTALL_DIR = { value = "global", force = false }
+1718
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
# 装甲板固件(ESP32-C3):一主三从,主板一颗 MCU,四路压电片 + 四路 WS2812 都接在它上面。
#
# 独立 workspace,不参与外层 esp32/ crate 的构建:两者目标芯片不同
# (外层是 Xtensa 的 ESP32-S3 图传,这里是 RISC-V 的 ESP32-C3),各自的
# `.cargo/config.toml` 就是这条边界的体现。
[workspace]
resolver = "2"
members = ["armor-firmware", "armor-protocol"]
[profile.release]
opt-level = "s"
[profile.dev]
debug = true
opt-level = "z"
+352
View File
@@ -0,0 +1,352 @@
# 装甲板(ESP32-C3 / Rust / ESP-IDF)—— 一主三从,与裁判终端联动
一辆车四块装甲板(前/后/左/右),**只有主板一颗 MCU**:另外三块"从"板里没有单片机,
各自只有一路 WS2812 和一路压电片,直接接线到主板上。主板负责四路击打检测、四路灯带、
整车血量,并把血量广播给本项目裁判终端。
固件是跑在 ESP-IDF 上的 Rust,目标芯片 **ESP32-C3(RISC-V)**。
```text
armor/
├── Cargo.toml workspace:两个 crate,各自独立构建
├── armor-protocol/ 规则层 + 线路格式(纯计算,主机上 22 个单元测试)
├── armor-firmware/ 固件:ADC / RMT / NVS / 按键 / WiFi+UDP
│ ├── src/leds.rs 四路 WS2812(单 RMT 通道轮流切 GPIO)
│ ├── src/piezo.rs 四路压电片(40 kHz ADC 连续采样)
│ ├── src/net.rs UDP 广播 ArmorHealth
│ ├── src/settings.rs NVS:队伍与颜色
│ └── sdkconfig.defaults / partitions.csv
└── .cargo/config.toml riscv32imc-esp-espidf + MCU=esp32c3
```
**为什么拆成两个 crate。** 规则层(算不算一次受击、扣几滴血、这一刻灯该显示什么、
按一下键算短按还是长按)是最容易写错、又最不适合"烧上去看现象"的部分 —— 压电片的
尖峰在硬件上根本没法按需复现。放在 `armor-protocol` 里就能喂 `Duration` 假时间来测。
固件侧只做外设接线。
## 接线
| 功能 | GPIO | 说明 |
| --- | --- | --- |
| WS2812 前 | **IO10** | 每条灯带 3 颗灯珠,GRB、MSB first |
| WS2812 后 | **IO5** | |
| WS2812 左 | **IO6** | |
| WS2812 右 | **IO7** | |
| 压电片 前 | GPIO0(ADC1_CH0) | 0 dB 衰减、12 bit |
| 压电片 后 | GPIO4(ADC1_CH4) | |
| 压电片 左 | GPIO3(ADC1_CH3) | |
| 压电片 右 | GPIO1(ADC1_CH1) | |
| BOOT 键 | GPIO9 | 低有效,内部上拉 |
| 调试串口 UART0 TXD | GPIO21 | 115200 8N1,日志从这里出来(主控制台) |
| 调试串口 UART0 RXD | GPIO20 | 目前空闲,留给以后的命令通道 |
引脚与参考实现完全一致(灯:前 10 / 后 5 / 左 6 / 右 7;压电:前 0 / 后 4 / 左 3 /
右 1)。调试口用 UART0 而不是 USB Serial/JTAG,是为了让"RXD/TXD"这对丝印就是日志口。
**日志同时从板上原生 USB 口出来。** `sdkconfig.defaults` 里把 USB Serial/JTAG 配成了
**次**控制台(`CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y`):主控制台仍是 UART0,
所以"RXD/TXD 就是调试口"这条成立;而只用原生 USB 线烧录、没接 USB-TTL 的人也能看到日志,
否则那种接法下应用日志一个字符都看不到(只有 ROM 的启动信息)。
## 规则
| | 值 | 在哪 |
| --- | --- | --- |
| 整车血量 | 100 | `Rules::max_hp` |
| 每次受击扣血 | 3 | `Rules::damage_per_hit` |
| 受击后变暗 | **只有被打的那一路**全黑 50 ms,其余三路不动 | `Rules::hit_blink` + `Armor::strip` |
| 同一板位重复判定抑制 | 50 ms | `Rules::hit_cooldown` |
| 受击阈值 | 原始 12 bit ADC > 2000(`ARMOR_THRESHOLD` 可改) | `Rules::threshold` |
| 血量归零 | 四路全黑,直到复位 | `Armor::strip` |
| 正常颜色 | 队伍一红(`0xFF0000`)、队伍二蓝(`0x0000FF`) | `Rgb::for_team` |
| 复位(新一局) | 长按 BOOT ≥500 ms → 满血、清计数 | `Button` + `Armor::reset` |
| 打印状态 | 短按 BOOT → 立刻打一行状态并重发上报 | `ButtonEvent::Click` |
**整车一条血量,四格同值。** 板子算账、裁判端只显示:每条上报的 `hp` 相同,
`index` 分别是 0..3,所以裁判端已经画好的四格血量界面不用改。
**为什么要"每板独立冷却"。** 参考实现用全局 50 ms 冷却:一块板挨打后,别块板在这
50 ms 内的击打会被吃掉。四路各自独立才是"每面各自挨打"的正确语义,这里有单元测试
钉住(`cooldown_is_per_plate_not_global`)。
## 上报(UDP)
主板每 200 ms 广播四条 `protocol::ArmorHealth` 到 `255.255.255.255:5601`:
```text
magic "MVC1"(4) | type(1)=5 | version(2) | sender_id(4) | ... | team(1) | index(1) | hp(2)
```
- **血量一变立刻发**,没变就按 200 ms 心跳重发。裁判端靠心跳判断板子还在线,一次
丢包不该让界面退回"未接入"。
- `sender_id` 用 MAC 派生的板子 id(`protocol::id_from_mac`),不是 0 —— 0 是裁判
终端的保留 id,抓包时一块装甲板发的包不该长得像裁判端发的。
- 四条 `index` 的 `hp` 相同(整车一条血),裁判端按 `(team, index)` 收齐四条才显示。
- 广播而不是单播:裁判终端的 IP 是 DHCP 给的,会变。图传板用 Hello 广播绕开这件事,
装甲板沿用同一个约定。
### 裁判端怎么收
`client/src/armor.rs` 里的 `ArmorHealthStore`(裁判端角色才启动):
- 绑 `0.0.0.0:5601` 收广播,按 `(team, index)` 存最后一次读数。
- **四条都在 1.5 s 内收到**才交给界面显示;否则回退占位符。心跳 200 ms,1.5 s 是连丢
7 个包,偶发丢包不会让四个格子各自跳。
- 队伍端不用它(界面不显示对手血量),所以那台机器不会去占 5601。
- 血量变化会打日志(`armor: team 0 hp 100 -> 97`):窗口是给别人看的,抓日志的人
也得能看见掉血。
**裁判端界面原本看不到血量。** 协议与 `ArmorSource` 早已就位,但 Slint 里那 4 格只是
绿/灰方块 —— `hps` 的数值从没被渲染,于是"板子被打到 0"在界面上完全看不出来(现场
表现就是"敲死了裁判系统没掉血")。现在 `TeamHealth` 多了一个 `hp-text` 与 `critical`:
队伍名旁边直接显示血量数字,低于 30 或归零时数字与血条转红。归零**不写"已击毁"**——那一刻
图传中央会给出「胜利 / 击败」的结论(见 [`desktop/README.md`](../desktop/README.md))。
## 构建
凭据与队伍号走 `.env`(与图传固件共用同一个文件):
```bash
# .env
WIFI_SSID=your-ssid
WIFI_PASS=your-password
ARMOR_TEAM=0 # 0 = 队伍一(红),1 = 队伍二(蓝)
ARMOR_THRESHOLD=2000 # 受击阈值(原始 12 bit ADC),看日志里的 peak 来定
scripts/build_armor.sh # 编译
ARMOR_TEAM=1 scripts/build_armor.sh # 临时当队伍二编译
ARMOR_THRESHOLD=1200 scripts/build_armor.sh # 换受击阈值(看日志里的 peak 来定)
```
产物:`armor/target/riscv32imc-esp-espidf/release/armor`。
`ARMOR_TEAM` 是**编译期**注入的:换队伍重刷固件即可,不用先擦 NVS。NVS 里另外记着
`team` 与 `color`(命名空间 `armor`,与参考实现同名),两者不一致时以 `ARMOR_TEAM`
为准并覆盖,日志里会说一声。
本机已有的 ESP-IDF 与工具链通过 `armor/.cargo/config.toml` 指定(`IDF_PATH` /
`ESP_IDF_TOOLS_INSTALL_DIR`),不需要先 `activate` ESP-IDF。删掉那两行就交给
esp-idf-sys 自己下载它钉住的版本。
### 用的是 ESP-IDF v6.0.1,不是图传板那套 v5.5.5
这台机器上有两套 ESP-IDF:
| | 路径 | 工具链 |
| --- | --- | --- |
| v6.0.1(eim 装的) | `/Users/peng/.espressif/v6.0.1/esp-idf` | Xtensa + **RISC-V(`esp-15.2.0_20251204`)** |
| v5.5.5(图传板在用) | `/Users/peng/.espressif/esp-idf/v5.5.5` | 只有 Xtensa |
C3 是 **RISC-V**,而 v5.5.5 那套缺 `riscv32-esp-elf`(它要的是 `esp-14.2.0_20260121`,
本机没有,而 GitHub release 又下不动)。所以装甲板固件指向 v6.0.1 —— 它的工具链齐全,
且 `esp-idf-sys` 0.38.1 支持的版本范围是 4.4.4+、5.0–5.5、**6.0–6.2**。
两套 IDF 共存没有冲突:各自 `.cargo/config.toml` 里的 `IDF_PATH` 是每套 crate 自己钉的。
`ESP_IDF_TOOLS_INSTALL_DIR` 默认 `global`(即 `~/.espressif`,工具所在处),但**没有**加
`force`:沙箱或 CI 里可以用 `ESP_IDF_TOOLS_INSTALL_DIR=custom:<可写目录>` 覆盖(工具目录
不可写时把 `tools/` 软链到已装好的工具即可,Python venv 会建在那个可写目录里)。
### 主机侧测试
规则层在 PC 上跑,不需要板子:
```bash
cd armor/armor-protocol && cargo test # 22 个测试
cd protocol && cargo test # 线路格式往返(含 ArmorHealth)
```
(`armor-protocol/.cargo/config.toml` 把 target 覆盖回 host:它坐在 `armor/` 目录树
里,否则会继承 RISC-V 目标,链接必然失败。这与 `protocol/`、`client/` 的做法一致。)
## 烧录与实测
```bash
scripts/flash_armor.sh # 自动找串口,烧完看 20 秒日志
ESP_PORT=/dev/cu.usbmodem1101 scripts/flash_armor.sh
scripts/flash_armor.sh --monitor 60 # 看 60 秒
scripts/flash_armor.sh --no-monitor # 只烧不抓
```
烧的是 ELF(`espflash` 自己从 ELF 里读分区表与 bootloader),芯片 `esp32c3`。
不打开裁判端界面,也可以单独确认板子在广播、血量对不对:
```bash
python3 scripts/armor_probe.py 15
```
它手写协议常量(不依赖 Rust 侧代码),所以是线路格式的**独立**检查:如果固件编码写错,
这个脚本会立刻报不一致。
### 串口日志长什么样(实机抓的)
```text
I (321) armor: pins: ws2812 front=IO10 rear=IO5 left=IO6 right=IO7
I (321) armor: pins: piezo front=GPIO0 rear=GPIO4 left=GPIO3 right=GPIO1
I (4864) esp_netif_handlers: sta ip: 192.168.1.117, mask: 255.255.255.0, gw: 192.168.1.1
I (5034) armor: joined "SR-602-2G4" on 192.168.1.117
I (5034) armor::net: reporting to 255.255.255.255:5601 over UDP
I (5034) armor::net: this board's id is 0xA820D321, from MAC [98, A3, 16, 4C, 67, C8]
I (6044) armor: status: hp=100 hits=0 dead=false plates[front=0 rear=0 left=0 right=0]
peak[front=490 rear=395 left=456 right=475] thr=2000
strips[front=#FF0000 rear=#FF0000 left=#FF0000 right=#FF0000]
ap=SR-602-2G4 rssi=-36 ch=6 ip=192.168.1.117 reports=5 errs=0
I (63104) armor: hit plate=front raw=4095 hp=1 (hits=33)
I (63314) armor: hit plate=front raw=4095 hp=0 (hits=34)
W (63314) armor: hp reached 0: all four strips stay dark until the next reset
W (306504) armor: BOOT long press: hp reset to 100 (new round)
```
(日志里的长行是这里折行显示的,实机是一行。)
## 实测(2026-10-14,ESP32-C3 devkit,MAC `98:a3:16:4c:67:c8`)
| 项 | 结果 |
| --- | --- |
| 编译 | `cargo build --release` 通过;text 1,081,093 B,app 镜像 **1,082,848 B**,占 4 MB flash 的 26% |
| 启动 | 无 panic/重启;状态行每秒一条 |
| WiFi | 关联 `SR-602-2G4`(rssi −36,ch 6),DHCP 拿到 `192.168.1.117` |
| 上报 | `scripts/armor_probe.py` 收到队伍一四条、约 5 包/秒、`sender_id 0xA820D321`、四格同值 |
| 裁判端 | `cam-client --role referee` 日志 `team 0 plate 0..3 is online (hp 100) via 192.168.1.117`,界面显示血量数字 |
| 受击 | 敲前装甲板:`hit plate=front raw=2663..4095`,每次 −3(100→97→94…),只有 front 计数 |
| 变暗 | 敲击瞬间**只有前那一路**全黑 50 ms,其余不变(现场确认) |
| 死亡 | 34 击到 0;状态行 `dead=true strips[front=off rear=off left=off right=off]`,四路全黑 |
| 复位 | BOOT 长按 → 板子 `reset to 100`,裁判端日志 `armor: team 0 hp 0 -> 100` |
| 底噪 | 未敲击时四路 peak 约 380–510,阈值 2000 有明显余量 |
| 未接线 | 后/左/右三路压电片与灯带本次未接(用户说明),代码按四路齐全写 |
### 裁判端这边踩到的坑:界面根本不显示血量
协议、`ArmorSource`、`ArmorHealthStore` 都对,但改之前 Slint 里那 4 格只是绿/灰方块,
`hps` 的**数值从不渲染**——板子被打到 0,界面四格还是绿的,看起来像"没掉血"。现在
队伍名旁边直接显示数字(低于 30 转红,归零不写"已击毁",改由图传中央的胜负横幅表达),
并且客户端日志会打 `armor: team 0 hp 100 -> 97`。
## 实现要点(都是踩过的坑)
### 1. ESP32-C3 的 RMT 只有 96 个 symbol 的通道内存
C3 的 RMT 只有一个 block,总计 96 个 symbol,按 48 为单位分给两个 TX 通道。`mem_block_symbols = 64`
会被向上取整到 96,也就是一个通道吃光全部内存,第二个 `rmt_new_tx_channel()` 就会失败:
```text
E rmt: rmt_tx_register_to_group(168): no free tx channels
```
四条灯带不可能各占一个通道(只有 2 个 TX 通道)。所以这里只建**一个**通道、让它独占
96 个 symbol(一帧 = 3 珠 × 24 bit + reset = 73 个 symbol,装得下),再用
`rmt_tx_switch_gpio()` 依次切到四个 GPIO 上发送;每路约 90 µs。
**为什么不用 `esp-idf-hal` 的 `TxChannelDriver`。** 它把 `rmt_channel_handle_t` 藏在
结构体里(`as_ptr()` 是 `pub(crate)`),拿不到句柄就调不了 `rmt_tx_switch_gpio()`。
所以 `leds.rs` 直接用 sys 绑定:通道、官方 copy encoder、transmit 都是 IDF 原生的那
几个函数,用法与参考实现的 `led_strip_encoder.c` 一一对应。
**每路内容没变就不重发。** 参考实现里四条灯带永远同色,所以每 10 ms 无条件刷四遍;
这里四路颜色独立(被打的那一路要单独变暗),于是只在内容变化时才走
`disable → switch → enable → transmit`,另外每秒强制全刷一次(掉电、静电都可能让
某条灯带丢帧,缓存不能当真相)。
### 2. 压电片必须连续(DMA)采样
一次击打的波形只有几十微秒宽,oneshot 轮询几乎必然从尖峰旁边擦过去,表现就是"敲得响
才算"。这里与参考实现一致:四通道合计 40 kHz(每通道 10 kHz),一帧 10 ms = 400 个
采样点,一帧就是主循环的节拍。
`esp-idf-hal` 0.47 的 `adc::continuous` 多通道入口 `EmptyAdcChannels::chain` 的约束是
`A: Adc` 且 `O: AdcChannels<AdcUnit = A>`:`ADC1` 满足前者、`ADCU1` 满足后者,同一个
类型参数凑不齐,四路 ADC1 通道拼不出来。所以 `piezo.rs` 同样直接用 sys 绑定,与参考
实现逐行对应。
采样点的通道号由硬件结果自带(`type2.channel()`),映射不依赖"结果按 pattern 顺序
轮转"这个假设。
### 3. 一次击打会被算成几十次
40 kHz 下,压电片一次击打会连续几十个采样点过阈值。**去抖不在采样层做**,而是
`Armor::on_sample` 的冷却窗口把它收敛成一次受击 —— 这样"算不算受击"这条规则可以在
主机上测(`repeat_samples_inside_the_cooldown_are_one_hit`)。
### 4. 控制台放在 UART0
`sdkconfig.defaults` 里显式写了 `CONFIG_ESP_CONSOLE_UART_DEFAULT=y`:C3 的 ROM 默认
控制台就是 UART0,引脚正好是 RXD=GPIO20 / TXD=GPIO21,与要求一致。换成 USB
Serial/JTAG 输出只要把这一行换成 `CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG`。
### 5. 应用分区给 2 MB
默认的 1 MB 单 app 槽对带 WiFi 的 Rust 固件太紧,`partitions.csv` 按参考实现给
factory 划了 2 MB。
### 6. WiFi 省电关掉
省电模式下上报会晚一个信标间隔(上百毫秒),而"受击后 50 ms 变暗"本来就是要给人即时
反馈的。板子是车载供电,这点电流不值得换不确定性。另外主循环里每 5 s 检查一次连接,
断了就重连 —— 一块静默失联的装甲板在场上是最难查的故障。
### 7. 判断"连上没有"要问 IDF,不要信 esp-idf-svc 的事件缓存
`BlockingWifi::is_connected()/is_up()/wait_netif_up()` 读的是 esp-idf-svc 自己维护的
WiFi 事件状态。实测在 **IDF v6.0.1 + esp-idf-svc 0.53** 上它会停在"已连接":日志说
一切正常、`get_ip_info()` 却是 `0.0.0.0`,而 IDF 的 `esp_wifi_sta_get_ap_info()` 明明
说没关联。后果是 `join()` 假装连上了、看门狗永远不会重试,板子静默失联。
所以固件里一律用 IDF 的 API 判断:
- 关联:`esp_wifi_sta_get_ap_info()`
- 地址:`esp_netif_get_handle_from_ifkey("WIFI_STA_DEF")` + `esp_netif_get_ip_info()`
而且 **WiFi 是尽力而为**:`join()` 失败只警告、不退出。一块因为没网就不亮灯的装甲板,
比"裁判端看不到血量"糟糕得多。看门狗每 5 s 检查一次"关联 + 有 IP",不满足就断开重连
(重连时只等 3 s IP,别让主循环错过受击)。
### 8. 日志里"有没有连上"要说清楚
状态行长这样:
```text
status: hp=0 hits=37 dead=true plates[front=37 rear=0 left=0 right=0]
peak[front=504 rear=379 left=459 right=486] thr=2000
strips[front=off rear=off left=off right=off]
ap=SR-602-2G4 rssi=-29 ch=6 ip=192.168.1.117 reports=1745 errs=0
```
- `peak[...]` 是**这一秒内每路 ADC 的峰值**,用来校准阈值:没敲时的底噪要明显低于
阈值,正常敲击要明显高于它(实测底噪 ~400–500,敲击 2000–4095)。只报"有没有过阈值"
没法判断阈值该调到哪。
- `thr=` 是当前阈值,`ARMOR_THRESHOLD=1200 scripts/build_armor.sh` 可以改。
- `ap=` / `ip=` / `reports=` 一眼分清"板子没连上"和"包发出去了但对端没显示"。
### 9. USB 控制台看不到复位后的头两秒
主控制台是 UART0,另外把 USB Serial/JTAG 配成了次控制台,所以插原生 USB 也能看日志。
但复位时 USB 会重新枚举,**复位后最初约 1–2 秒的日志会丢**(我们实测错过过启动横幅)。
要看完整启动过程就用 UART0 那对脚接 USB-TTL——那才是设计中的调试口。
## 与参考实现(`reference/armor`)的差异
| | 参考实现 | 这里 | 为什么 |
| --- | --- | --- | --- |
| 主从链路 | ESP-NOW 配对,两块板对拷 | 无:三块从板只是直接接线的灯与传感器 | 按实际硬件(只有主板一颗 MCU) |
| DBUS 透传 | UART0 收发 10 字节 DBUS,USB CDC 打 CSV | 不做 | 这套硬件没有 DBUS 需求;UART0 让给日志 |
| 受击变暗 | 四条灯带一起全黑 50 ms | 只暗被打的那一路 | 每面独立灯带的意义就在这里 |
| 冷却窗口 | 全局 50 ms | 每板独立 50 ms | 别块板的击打不该被吃掉 |
| 灯带刷新 | 每 10 ms 无条件刷四遍 | 只在内容变化时刷 + 每秒强制全刷 | 四路独立控制后,省掉四轮 RMT 开销 |
| 血量 | 只有击打计数,无血量 | 整车 100 血、每击 −3、0 血全黑,按 `ArmorHealth` 上报 | 与裁判端已画好的四格血量对齐 |
| 颜色切换 | BOOT 短按切红/蓝,存 NVS | 颜色由 `ARMOR_TEAM` 决定(红/蓝),短按打状态 | 队别不该靠现场按键切错 |
| 按键 | 移植 OneButton 状态机(短按配对,长按切色) | 本地 FSM:长按复位血量、短按打状态 | 只有两种动作,且要能在主机上测 |
| 上报 sender_id | —(不走 UDP) | MAC 派生 id | 0 是裁判终端保留 id |
## 还没做的
- **另外三路(后/左/右)的压电片与灯带还没接**(2026-10-14 实机只有前一路)。
代码按四路齐全写,接线后无需改代码;但"四路同时受击"、"每路独立变暗"这两件事
还没在实板上验证过。
- **UART0 RX(GPIO20)上的命令通道**:日志方向已经能用;反向的 `status` / `reset`
命令还没接(当前复位走 BOOT 长按)。
- **从板加 MCU 的方案**:如果以后从板也装单片机(走 UART 或无线回传),
`armor-protocol` 里已经预留了板位枚举与上报格式,加一个 `slave` bin 即可。
- **双击 / 多击**:`Button` 只实现短按与长按,参考实现里未使用的 OneButton 功能没有移植。
- **下发血量**:当前板子本地算账(裁判端只显示)。若以后要裁判端权威(下发血量/复位),
需要新增一条下行消息并在 `client` 里发。
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "armor-firmware"
version = "0.1.0"
edition = "2021"
resolver = "2"
rust-version = "1.82"
# 一个 bin:主板固件。三块"从"装甲板里没有 MCU,只有一路 WS2812 + 一路压电片
# 直接接在主板 GPIO 上,所以没有第二份固件可烧(见 armor/README.md)。
[[bin]]
name = "armor"
path = "src/main.rs"
harness = false # 不在固件里跑主机测试框架;规则层的测试在 armor-protocol
[dependencies]
log = "0.4"
anyhow = "1"
embedded-svc = "0.29"
esp-idf-svc = "0.53"
# 规则层 + 线路格式:血量、去抖、按键、灯带输出,以及上报给裁判终端的
# `protocol::ArmorHealth`(armor-protocol 再导出)。规则层能在主机上单测。
armor-protocol = { path = "../armor-protocol" }
[build-dependencies]
embuild = "0.33.5"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
embuild::espidf::sysenv::output();
}
+10
View File
@@ -0,0 +1,10 @@
# 装甲板固件(4 MB flash)的自定义分区表。
#
# nvs : 颜色 / 队伍等应用设置,外加 WiFi 校准数据
# phy_init : 射频校准数据
# factory : 唯一的分区镜像(2 MB 余量;固件实测不到 1 MB,但默认的 1 MB 单 app
# 槽对带 WiFi 的固件太紧)
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 0x200000,
1 # 装甲板固件(4 MB flash)的自定义分区表。
2 #
3 # nvs : 颜色 / 队伍等应用设置,外加 WiFi 校准数据
4 # phy_init : 射频校准数据
5 # factory : 唯一的分区镜像(2 MB 余量;固件实测不到 1 MB,但默认的 1 MB 单 app
6 # 槽对带 WiFi 的固件太紧)
7 # Name, Type, SubType, Offset, Size, Flags
8 nvs, data, nvs, 0x9000, 0x6000,
9 phy_init, data, phy, 0xf000, 0x1000,
10 factory, app, factory, 0x10000, 0x200000,
+29
View File
@@ -0,0 +1,29 @@
# 装甲板固件(ESP32-C3)的默认配置。
#
# CONFIG_IDF_TARGET 必须显式写出来:本机装的是通用 ESP-IDF,默认目标是 ESP32,
# 不写这一行会按 ESP32 去配 ADC/RMT 通道,编译期就报"没有这个外设"。
#
# 串口调试走 UART0(RXD = GPIO20 / TXD = GPIO21,115200 8N1)——这是 C3 的 ROM
# 默认控制台引脚,和参考固件里给 DBUS 用的那两个脚是同一对。想改成 USB
# Serial/JTAG 输出,把 CONFIG_ESP_CONSOLE_UART_DEFAULT 换成
# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG 即可。
CONFIG_IDF_TARGET="esp32c3"
# 4 MB flash;应用分区放到 2 MB(见 partitions.csv),默认的 1 MB 单 app 槽对
# WiFi + 日志的 Rust 固件太紧。
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM=y
# 调试口 = UART0(GPIO20 RX / GPIO21 TX)。
#
# 同时打开 USB Serial/JTAG 作为**次**控制台:日志会同时从板上那个原生 USB 口出来。
# 这样现场两种接法都能看到日志 —— 接了 USB-TTL 到 RXD/TXD 的看 UART0,直接插板上
# USB 口的看 USB。主控制台仍是 UART0,所以"RXD/TXD 就是调试口"这条成立。
#
# 只用原生 USB 口烧录(不接 USB-TTL)时,这一条是能看见日志的唯一办法。
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
# Rust 的 main 与规则层在同一个任务栈上跑,另外 log 会用到一些栈;默认 3.5 KB
# 在带 WiFi 的固件里容易爆栈。
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
+27
View File
@@ -0,0 +1,27 @@
//! 把 IDF 的 `esp_err_t` 返回值变成可读的 `anyhow::Error`。
//!
//! 固件里到处都要判 `ESP_OK` 并把错误码翻译成人能看懂的名字;不翻译的话现场只会
//! 看到 `0x105`,而 IDF 的错误码表并不在脑子里。
use anyhow::{anyhow, Result};
use esp_idf_svc::sys as ffi;
/// `ESP_OK` 之外的返回值转成 `Err`,`what` 是出错的那一步的名字。
pub fn check(what: &str, rc: ffi::esp_err_t) -> Result<()> {
if rc == ffi::ESP_OK {
return Ok(());
}
Err(anyhow!("{what} failed: {} (0x{rc:x})", name(rc)))
}
/// 错误码的名字,例如 `ESP_ERR_INVALID_ARG`。
#[must_use]
pub fn name(rc: ffi::esp_err_t) -> String {
// SAFETY: esp_err_to_name 对任意 esp_err_t 都返回一个静态 C 字符串。
unsafe {
core::ffi::CStr::from_ptr(ffi::esp_err_to_name(rc))
.to_string_lossy()
.into_owned()
}
}
+312
View File
@@ -0,0 +1,312 @@
//! 四路 WS2812 灯带:一个 RMT TX 通道 + 官方 copy encoder,靠切 GPIO 轮流驱动。
//!
//! # 为什么不用 `esp-idf-hal` 的 `TxChannelDriver`
//!
//! 它把 `rmt_channel_handle_t` 藏在结构体里(`as_ptr()` 是 `pub(crate)`),而
//! "一个通道依次驱动四路灯带"必须用 `rmt_tx_switch_gpio()` 换脚。ESP32-C3 的 RMT
//! 只有 **2 个 TX 通道**,四路灯带不可能各占一个,所以这里直接用 `esp-idf-svc` 的
//! sys 绑定:通道、encoder、transmit 都是 IDF 原生的那几个函数,用法与参考实现的
//! `led_strip_encoder.c` 一一对应。
//!
//! # ESP32-C3 的 RMT 内存限制(照抄参考实现的踩坑记录)
//!
//! C3 的 RMT 只有一个 block,总计 **96 个 symbol 的通道内存**,按
//! `SOC_RMT_MEM_WORDS_PER_CHANNEL = 48` 为单位分配。`mem_block_symbols = 64` 会被
//! 向上取整到 96,也就是一个通道吃光全部内存,第二个 `rmt_new_tx_channel()` 会以
//! `no free tx channels` 失败:
//!
//! ```text
//! E rmt: rmt_tx_register_to_group(168): no free tx channels
//! ```
//!
//! 所以这里只建**一个**通道、让它独占 96 个 symbol(一帧 = 3 珠 × 24 bit + reset
//! = 73 个 symbol,装得下),再用 `rmt_tx_switch_gpio()` 依次切到四个 GPIO 上发送。
//! 每路约 90 µs,10 ms 周期里的开销可以忽略。
//!
//! # 与参考实现的差异
//!
//! 参考实现里四条灯带永远同色,所以每 10 ms 无条件刷四遍。这里四路颜色各自独立
//! (被打的那一路要单独变暗),于是:
//!
//! - 内容没变的路不发(`refresh` 里比较上一次的 [`Strip`]),省掉每次
//! `disable → switch → enable → transmit` 的四轮开销;
//! - 每秒仍强制全刷一次:灯带可能因为上电次序、掉电、静电而丢帧,缓存不能当真相。
use core::ffi::c_void;
use core::time::Duration;
use anyhow::Result;
use esp_idf_svc::hal::rmt::{PinState, Pulse, Symbol};
use esp_idf_svc::hal::units::Hertz;
use esp_idf_svc::sys as ffi;
use log::{info, warn};
use armor_protocol::{Plate, Rgb, Strip};
use crate::esp;
/// RMT 计时分辨率:10 MHz,即 1 tick = 0.1 µs。
const RMT_RESOLUTION_HZ: u32 = 10_000_000;
/// 每条灯带 3 颗灯珠(与参考实现一致)。
const LEDS_PER_STRIP: usize = 3;
/// 一帧的符号数:24 bit/珠 × 3 珠 + 1 个 reset 符号。
const FRAME_SYMBOLS: usize = LEDS_PER_STRIP * 24 + 1;
/// 独占 C3 全部 RMT 通道内存,只建一个通道(见模块文档)。
const MEM_BLOCK_SYMBOLS: usize = 96;
/// WS2812 的位时序:T0H / T0L / T1H / T1L。
const T0H_NS: u64 = 300;
const T0L_NS: u64 = 900;
const T1H_NS: u64 = 900;
const T1L_NS: u64 = 300;
/// 帧尾低电平时长;WS2812 要 >50 µs 才锁存。
const RESET_US: u64 = 50;
/// 灯带数据脚(GPIO 号,按 [`Plate`] 的 index 排列):前 10 / 后 5 / 左 6 / 右 7。
const STRIP_GPIO: [i32; 4] = [10, 5, 6, 7];
/// `rmt_tx_wait_all_done` 的超时(毫秒)。一帧只要 ~90 µs,1 s 是"卡死就报错"的量级。
const TX_TIMEOUT_MS: i32 = 1000;
/// 强制全刷的周期:缓存不可信(掉电、静电都可能让灯带丢帧)。
const FULL_REFRESH_MS: u64 = 1000;
/// 四路 WS2812。
pub struct Leds {
channel: ffi::rmt_channel_handle_t,
encoder: ffi::rmt_encoder_handle_t,
/// 通道当前是否处于 enable 状态;切 GPIO 只能在 INIT 状态下做。
enabled: bool,
bit0: Symbol,
bit1: Symbol,
reset: Symbol,
/// 每路的预编码帧。放在 `Box` 里:`rmt_transmit` 只借用指针,堆上的地址不会
/// 因为结构体搬动而失效。
frames: Box<[[ffi::rmt_symbol_word_t; FRAME_SYMBOLS]; 4]>,
/// 上一次真正发出去的内容,用来跳过没变化的刷新。
current: [Option<Strip>; 4],
last_full_refresh_ms: u64,
}
impl Leds {
/// 建通道与 encoder,把四路灯带全刷成黑色(上电先灭)。
pub fn new() -> Result<Self> {
let mut config = ffi::rmt_tx_channel_config_t::default();
// 先落在前装甲板那一路;其余三路靠 switch_gpio 换过去。
config.gpio_num = STRIP_GPIO[Plate::Front.index() as usize] as _;
config.clk_src = ffi::soc_periph_rmt_clk_src_t_RMT_CLK_SRC_DEFAULT;
config.resolution_hz = RMT_RESOLUTION_HZ;
config.mem_block_symbols = MEM_BLOCK_SYMBOLS;
config.trans_queue_depth = 4;
let mut channel: ffi::rmt_channel_handle_t = core::ptr::null_mut();
esp::check(
"rmt_new_tx_channel",
// SAFETY: config 与 channel 都是本函数的局部量,调用期间有效。
unsafe { ffi::rmt_new_tx_channel(&config, &mut channel) },
)?;
let mut encoder: ffi::rmt_encoder_handle_t = core::ptr::null_mut();
let copy_config = ffi::rmt_copy_encoder_config_t::default();
if let Err(err) = esp::check(
"rmt_new_copy_encoder",
// SAFETY: 同上,copy encoder 只是把用户给的 symbol 原样搬进硬件。
unsafe { ffi::rmt_new_copy_encoder(&copy_config, &mut encoder) },
) {
// SAFETY: 通道刚建好且未使用,可以直接删。
unsafe { ffi::rmt_del_channel(channel) };
return Err(err);
}
let resolution = Hertz(RMT_RESOLUTION_HZ);
let bit0 = Symbol::new_with(
resolution,
PinState::High,
Duration::from_nanos(T0H_NS),
PinState::Low,
Duration::from_nanos(T0L_NS),
)?;
let bit1 = Symbol::new_with(
resolution,
PinState::High,
Duration::from_nanos(T1H_NS),
PinState::Low,
Duration::from_nanos(T1L_NS),
)?;
let reset_low =
Pulse::new_with_duration(resolution, PinState::Low, Duration::from_micros(RESET_US))?;
// reset = 一整段(两半都是低电平)的符号。
let reset = Symbol::new(reset_low, reset_low);
// `rmt_symbol_word_t` 是 union,bindgen 不给它 `Default`,只能自己写一个全零符号。
let blank = ffi::rmt_symbol_word_t { val: 0 };
let mut leds = Self {
channel,
encoder,
enabled: false,
bit0,
bit1,
reset,
frames: Box::new([[blank; FRAME_SYMBOLS]; 4]),
current: [None; 4],
last_full_refresh_ms: 0,
};
info!(
"ws2812: 1 RMT channel x {MEM_BLOCK_SYMBOLS} symbols, {LEDS_PER_STRIP} leds/strip on GPIO {:?}",
STRIP_GPIO
);
// 上电先灭:不知道灯带上一次显示的是什么。
for plate in Plate::ALL {
leds.write(plate, Strip::Black)?;
}
Ok(leds)
}
/// 把预编码的帧发到指定那一路。
fn write(&mut self, plate: Plate, strip: Strip) -> Result<()> {
let slot = plate.index() as usize;
self.encode(slot, strip.color());
// 切 GPIO 只能在 INIT 状态下做,所以先 disable。
if self.enabled {
esp::check("rmt_disable", unsafe { ffi::rmt_disable(self.channel) })?;
self.enabled = false;
}
esp::check(
"rmt_tx_switch_gpio",
// SAFETY: 通道处于 INIT 状态;gpio 是 C3 上的合法输出脚。
unsafe { ffi::rmt_tx_switch_gpio(self.channel, STRIP_GPIO[slot] as _, false) },
)?;
esp::check("rmt_enable", unsafe { ffi::rmt_enable(self.channel) })?;
self.enabled = true;
let tx_config = ffi::rmt_transmit_config_t {
loop_count: 0, // 不循环
..Default::default()
};
esp::check(
"rmt_transmit",
// SAFETY: frames[slot] 在 Box 上,地址稳定;调用期间不会被移动。
unsafe {
ffi::rmt_transmit(
self.channel,
self.encoder,
self.frames[slot].as_ptr() as *const c_void,
core::mem::size_of_val(&self.frames[slot]),
&tx_config,
)
},
)?;
esp::check(
"rmt_tx_wait_all_done",
// SAFETY: 纯等待,超时给 1 s。
unsafe { ffi::rmt_tx_wait_all_done(self.channel, TX_TIMEOUT_MS) },
)?;
self.current[slot] = Some(strip);
Ok(())
}
/// 把 `rgb` 编成一条灯带的整帧(GRB、MSB first,3 珠同色)。
fn encode(&mut self, slot: usize, rgb: Rgb) {
let grb = rgb.to_grb();
let frame = &mut self.frames[slot];
// 一颗灯珠的 24 个符号只算一遍,再复制给另外两颗 —— 四条灯带都是 3 珠,
// 中间那两步没必要重复算。
let mut one_led = [ffi::rmt_symbol_word_t { val: 0 }; 24];
for (byte_index, byte) in grb.iter().enumerate() {
for bit in 0..8 {
let symbol = if (byte >> (7 - bit)) & 1 == 1 {
self.bit1
} else {
self.bit0
};
one_led[byte_index * 8 + bit] = symbol.into();
}
}
for led in 0..LEDS_PER_STRIP {
frame[led * 24..(led + 1) * 24].copy_from_slice(&one_led);
}
// 帧尾:>50 µs 低电平,灯带才会锁存这一帧。
frame[FRAME_SYMBOLS - 1] = self.reset.into();
}
/// 按 [`Plate`] 的 index 刷四路灯带。
///
/// 内容与上一次相同的路直接跳过(除非到了每秒一次的强制全刷),所以稳态下
/// 这个函数几乎不花时间。
pub fn refresh(&mut self, strips: [Strip; 4], now_ms: u64) -> Result<()> {
let full = now_ms.saturating_sub(self.last_full_refresh_ms) >= FULL_REFRESH_MS;
for plate in Plate::ALL {
let strip = strips[plate.index() as usize];
if !full && self.current[plate.index() as usize] == Some(strip) {
continue;
}
self.write(plate, strip)?;
}
if full {
self.last_full_refresh_ms = now_ms;
}
Ok(())
}
}
impl Drop for Leds {
fn drop(&mut self) {
// 固件里 main 永不返回,这段只在异常路径(比如 new() 之后的早期返回)跑到。
unsafe {
if self.enabled {
ffi::rmt_disable(self.channel);
}
if !self.encoder.is_null() {
ffi::rmt_del_encoder(self.encoder);
}
if !self.channel.is_null() {
ffi::rmt_del_channel(self.channel);
}
}
}
}
/// 供 main 打印一次用:把四路输出拼成一行,便于对照。
pub fn describe(strips: [Strip; 4]) -> String {
Plate::ALL
.iter()
.map(|plate| {
let strip = strips[plate.index() as usize];
let text = if strip.is_black() {
"off".to_string()
} else {
format!("#{:06X}", strip.color().to_u32())
};
format!("{}={text}", plate.name())
})
.collect::<Vec<_>>()
.join(" ")
}
/// 记一次刷新失败:诊断信息比重启有用,现场灯带接错线也不该把固件打挂。
pub fn log_failure(err: &anyhow::Error) {
warn!("ws2812 refresh failed: {err:#}");
}
+556
View File
@@ -0,0 +1,556 @@
//! 装甲板主板固件(ESP32-C3):一主三从,与裁判终端联动。
//!
//! # 这套硬件是什么
//!
//! 一辆车四块装甲板(前/后/左/右),但只有**主板一颗 MCU**:另外三块"从"板里没有
//! 单片机,各自只有一路 WS2812 和一路压电片,直接接线到主板上。所以这份固件同时管
//! 四路输入与四路输出:
//!
//! ```text
//! 压电片 前/后/左/右 ──ADC──┐
//! ├──► armor-protocol(血量 / 去抖 / 灯色)
//! BOOT 键 ──────── GPIO9 ──┘ │
//! ├──► WS2812 前/后/左/右
//! └──► UDP 广播 ──► 裁判终端 :5601
//! ```
//!
//! # 规则(见 `armor-protocol`,那里有单元测试)
//!
//! - 整车一条血量:100 起,每次受击 −3,减到 0 不变负;0 血四路全黑。
//! - 受击时**只有被打的那一路**灯带全黑 50 ms,其余三路不动。
//! - 颜色由队伍决定:队伍一红、队伍二蓝;`ARMOR_TEAM` 编译期注入。
//! - 血量账在板子上算,按 `protocol::ArmorHealth` 广播给本项目裁判终端。
//!
//! # 构建与烧录
//!
//! ```text
//! scripts/build_armor.sh # 用 .env 里的 WIFI_SSID/WIFI_PASS/ARMOR_TEAM
//! scripts/flash_armor.sh # 编译 + espflash + 串口日志
//! ```
//!
//! 串口调试走 UART0:RXD = GPIO20 / TXD = GPIO21,115200 8N1(日志从 TX 出来,
//! RX 目前空闲,留给以后加命令通道)。USB Serial/JTAG 配成了次控制台,所以插原生
//! USB 口也能看到同一份日志。完整接线表、协议与踩坑见 `armor/README.md`。
use std::time::Duration;
use anyhow::{anyhow, Result};
use embedded_svc::wifi::{AuthMethod, ClientConfiguration, Configuration, PmfConfiguration};
use esp_idf_svc::eventloop::EspSystemEventLoop;
use esp_idf_svc::hal::gpio::{PinDriver, Pull};
use esp_idf_svc::hal::peripherals::Peripherals;
use esp_idf_svc::nvs::EspDefaultNvsPartition;
use esp_idf_svc::wifi::{BlockingWifi, EspWifi};
use log::{error, info, warn};
use armor_protocol::{Armor, Button, ButtonEvent, Plate, ReportSchedule, Rules, TEAM_2};
mod esp;
mod leds;
mod net;
mod piezo;
mod settings;
/// WiFi 账号来自 .env(构建期注入,固件里不留密码)。
const SSID: &str = env!("WIFI_SSID");
const PASSWORD: &str = env!("WIFI_PASS");
/// 编译期注入的队伍号:`0` = 队伍一(红),`1` = 队伍二(蓝)。不写就是队伍一。
const TEAM_ENV: &str = match option_env!("ARMOR_TEAM") {
Some(value) => value,
None => "0",
};
/// 受击阈值(原始 12 bit ADC)。参考实现用 2000,但压电片的幅度取决于贴装、线长
/// 与有没有放大级,所以这里留一个编译期入口:`ARMOR_THRESHOLD=1200`。
///
/// 怎么定这个值:看串口 status 行里的 `peak[...]`。没敲时的底噪要明显低于阈值,
/// 正常敲击要明显高于它。
const THRESHOLD_ENV: &str = match option_env!("ARMOR_THRESHOLD") {
Some(value) => value,
None => "2000",
};
/// 血量不变时的重发周期。裁判端靠它判断板子还在线 —— 一次丢包不该让界面变回
/// "装甲板未接入"。
const REPORT_HEARTBEAT_MS: u64 = 200;
/// 状态行的打印周期(UART 调试口)。
const STATUS_INTERVAL_MS: u64 = 1000;
/// WiFi 断线检查周期。
const WIFI_CHECK_MS: u64 = 5000;
/// 毫秒时基。`esp_timer` 是微秒计数器,转毫秒后 u64 不会被绕回。
fn now_ms() -> u64 {
// SAFETY: 纯读计时器。
(unsafe { esp_idf_svc::sys::esp_timer_get_time() } as u64) / 1000
}
fn main() -> Result<()> {
// esp-idf-sys 的运行时补丁必须跑一次,日志系统也要先起来。
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
let team = armor_protocol::parse_team(TEAM_ENV)
.ok_or_else(|| anyhow!("ARMOR_TEAM must be 0 or 1, got {TEAM_ENV:?}"))?;
let peripherals = Peripherals::take()?;
let sys_loop = EspSystemEventLoop::take()?;
let nvs = EspDefaultNvsPartition::take()?;
// 队别与颜色:ARMOR_TEAM 是权威,NVS 只记住结果。
let color = settings::Settings::new(nvs.clone())?.resolve_color(team)?;
let threshold: u16 = THRESHOLD_ENV
.parse()
.map_err(|_| anyhow!("ARMOR_THRESHOLD must be a number, got {THRESHOLD_ENV:?}"))?;
let rules = Rules {
threshold,
..Rules::default()
};
let mut armor = Armor::new(rules, color);
info!("pins: ws2812 front=IO10 rear=IO5 left=IO6 right=IO7");
info!("pins: piezo front=GPIO0 rear=GPIO4 left=GPIO3 right=GPIO1");
info!("pins: boot=GPIO9 debug uart0 rxd=GPIO20 txd=GPIO21 115200 8N1");
info!(
"armor board: team={team} ({}), {} hp, -{} per hit, {} ms dark on the hit strip",
if team == TEAM_2 { "blue" } else { "red" },
armor.hp(),
rules.damage_per_hit,
rules.hit_blink.as_millis()
);
let mut leds = leds::Leds::new()?;
let mut piezo = piezo::Piezo::new()?;
// BOOT 键(GPIO9,低有效):内部上拉,按下读低。
let button = PinDriver::input(peripherals.pins.gpio9, Pull::Up)?;
let mut button_fsm = Button::default();
let mut wifi = BlockingWifi::wrap(
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
sys_loop,
)?;
// WiFi 是尽力而为:**连不上也绝不能挡住受击检测与灯效**。一块因为没网就不亮
// 灯的装甲板,在场上比"裁判端看不到血量"糟糕得多。所以这里只报警,主循环照跑,
// 由看门狗每 5 s 重试。
if let Err(err) = join(&mut wifi, Duration::from_secs(10)) {
warn!("no referee link yet ({err:#}); hits and LEDs still work, retrying every {WIFI_CHECK_MS} ms");
}
let reporter = net::Reporter::new()?;
let sender_id = net::local_id();
let mut hits: Vec<(Plate, u16)> = Vec::with_capacity(16);
let mut reports = ReportSchedule::new(Duration::from_millis(REPORT_HEARTBEAT_MS));
let mut last_status_ms = now_ms();
let mut last_wifi_check_ms = now_ms();
// 启动即发一次:裁判端不用等到第一次受击才知道板子在线。
let mut force_report = true;
// 上报统计,状态行里打出来:现场"裁判端没显示"时,第一件事是分清是链路问题
// 还是对端问题。计数在涨就说明包已经出去了。
let mut reports_sent: u64 = 0;
let mut report_errors: u64 = 0;
// 上一次状态行以来每路 ADC 的峰值(校准阈值用),打印后清零。
let mut peak = [0u16; 4];
info!("entering the main loop: long-press BOOT to reset hp, short-press to print status");
loop {
let now = Duration::from_millis(now_ms());
// 采样:一次读一帧(约 10 ms),这一帧就是主循环的节拍。
match piezo.read_frame(armor.rules().threshold, &mut hits) {
Ok(stats) => {
for (slot, value) in stats.peak.iter().enumerate() {
peak[slot] = peak[slot].max(*value);
}
for (plate, raw) in hits.iter().copied() {
if let Some(event) = armor.on_sample(plate, raw, now) {
info!(
"hit plate={} raw={} hp={} (hits={})",
event.plate.name(),
event.raw,
event.hp,
armor.hits()
);
if event.dead {
warn!("hp reached 0: all four strips stay dark until the next reset");
}
}
}
}
Err(err) => {
warn!("adc frame failed: {err:#}");
// 读失败时别把循环变成没有节拍的忙等。
std::thread::sleep(Duration::from_millis(10));
}
}
// 灯带:只有内容变化的那一路才真的发 RMT(每秒另有一次强制全刷)。
let strips = armor.strips(now);
if let Err(err) = leds.refresh(strips, now_ms()) {
leds::log_failure(&err);
}
// 按键。BOOT 低有效,所以"按下" = 读到低电平。
match button_fsm.tick(button.is_low(), now) {
ButtonEvent::LongPress => {
armor.reset();
warn!("BOOT long press: hp reset to {} (new round)", armor.hp());
force_report = true;
}
ButtonEvent::Click => {
info!(
"status: {}",
status_with_link(&armor, now, reports_sent, report_errors, peak)
);
force_report = true;
}
ButtonEvent::None => {}
}
// 上报:血量一变立刻发,其余按心跳重发。
let current_ms = now_ms();
if force_report || reports.due(armor.hp(), now) {
match broadcast_health(&reporter, &armor, team, sender_id) {
Ok(()) => {
reports.mark_sent(armor.hp(), now);
reports_sent = reports_sent.saturating_add(1);
}
Err(err) => {
report_errors = report_errors.saturating_add(1);
warn!("report failed: {err:#}");
}
}
force_report = false;
}
// 状态行:现场不看串口也能从日志确认板子活着、血量是多少、链路通不通。
if current_ms.saturating_sub(last_status_ms) >= STATUS_INTERVAL_MS {
last_status_ms = current_ms;
info!(
"status: {}",
status_with_link(&armor, now, reports_sent, report_errors, peak)
);
// 峰值是"上一秒"的量,打完就清零,这样现场能一眼看出没敲时的底噪。
peak = [0u16; 4];
}
// WiFi 看门狗:**关联上 + 拿到 IP** 才算链路好。
//
// 判断不用 `BlockingWifi::is_connected()/is_up()`:那两个读的是 esp-idf-svc
// 自己的事件缓存,实测在 IDF v6.0.1 上会停留在"已连接",而 IDF 的 API 明明
// 说没关联(现场表现就是日志说一切正常、裁判端一个包都收不到)。所以这里
// 直接问 IDF:`esp_wifi_sta_get_ap_info()` 与 `esp_netif_get_ip_info()`。
//
// 重连时只给 3 s 等 IP(启动时给的 10 s):这里是主循环,等太久会漏掉受击。
if current_ms.saturating_sub(last_wifi_check_ms) >= WIFI_CHECK_MS {
last_wifi_check_ms = current_ms;
let link = link_state();
let ip = sta_ip();
let healthy = link.associated() && ip.is_some_and(|ip| !ip.is_unspecified());
if !healthy {
warn!("link is not usable ({}); reconnecting", describe_link(&link, ip));
// 断开再连:关联掉了或 DHCP 没下来时,只有重新来一遍才会好。
if let Err(err) = wifi.disconnect() {
warn!("disconnect failed: {err}");
}
if let Err(err) = join(&mut wifi, Duration::from_secs(3)) {
warn!("reconnect failed: {err:#}");
}
}
}
}
}
/// 广播四块装甲板的血量给裁判终端。
///
/// 四条数据报的 `hp` 相同(整车一条血量),`index` 分别是 0..3 —— 裁判端已有的
/// 四格血量界面无需改动。`sender_id` 用 MAC 派生的板子 id,方便抓包时分辨是谁发的。
fn broadcast_health(
reporter: &net::Reporter,
armor: &Armor,
team: u8,
sender_id: u32,
) -> Result<()> {
for datagram in armor.datagrams_from(team, sender_id) {
reporter.broadcast(&datagram)?;
}
Ok(())
}
/// 状态行 + 当场问出来的链路状态。
///
/// 每次都现算而不是缓存:现场排查时最怕看到的是过期的好消息。
fn status_with_link(
armor: &Armor,
now: Duration,
reports_sent: u64,
report_errors: u64,
peak: [u16; 4],
) -> String {
status_line(
armor,
now,
&link_line(&link_state(), sta_ip(), reports_sent, report_errors),
peak,
)
}
/// 一行状态,给 UART 调试口用。
///
/// `peak` 是这一秒里每路 ADC 的峰值。它比"有没有受击"更有用:现场要判断阈值该调到
/// 多少,靠的就是"没敲时的底噪"和"敲击时的幅度"这两个数字。
fn status_line(armor: &Armor, now: Duration, link: &str, peak: [u16; 4]) -> String {
let counts = armor.plate_hits_all();
let per_plate = Plate::ALL
.iter()
.map(|plate| format!("{}={}", plate.name(), counts[plate.index() as usize]))
.collect::<Vec<_>>()
.join(" ");
let peaks = Plate::ALL
.iter()
.map(|plate| format!("{}={}", plate.name(), peak[plate.index() as usize]))
.collect::<Vec<_>>()
.join(" ");
format!(
"hp={} hits={} dead={} plates[{}] peak[{}] thr={} strips[{}] {link}",
armor.hp(),
armor.hits(),
armor.dead(),
per_plate,
peaks,
armor.rules().threshold,
leds::describe(armor.strips(now)),
)
}
/// 链路那半截状态:连的是哪个 AP、信号多强、有没有 IP、上报发了多少包。
///
/// 现场"裁判端没显示"时,第一件事是分清链路问题还是对端问题:`reports` 在涨就说明
/// 包已经出去了,该去查裁判端;一直不动就是这块板子没连上。而"没连上"还要再分
/// 关联与 DHCP —— 关联上但 IP 是 0.0.0.0 是最容易看错的一种。
fn link_line(link: &Link, ip: Option<std::net::Ipv4Addr>, reports_sent: u64, report_errors: u64) -> String {
format!(
"{} reports={reports_sent} errs={report_errors}",
describe_link(link, ip)
)
}
/// 当前关联状态。**来源是 IDF 的 API**,不是 esp-idf-svc 的事件缓存(见看门狗注释)。
#[derive(Debug, Clone)]
enum Link {
/// 关联上了:AP 名字、RSSI、信道。
Associated {
ssid: String,
rssi: i8,
channel: u8,
},
/// 没关联(或者 IDF 说 STA 没在跑)。
Idle { rc: i32 },
}
impl Link {
#[must_use]
fn associated(&self) -> bool {
matches!(self, Link::Associated { .. })
}
}
/// 问一次 IDF:STA 关联到哪个 AP 了。
fn link_state() -> Link {
let mut record = esp_idf_svc::sys::wifi_ap_record_t::default();
// SAFETY: record 是本函数的局部量,IDF 只往里写;未关联时返回非 OK 而不动它。
let rc = unsafe { esp_idf_svc::sys::esp_wifi_sta_get_ap_info(&mut record) };
if rc != esp_idf_svc::sys::ESP_OK {
return Link::Idle { rc };
}
// ssid 是定长 33 字节、以 0 结尾的缓冲区。
let ssid = unsafe {
core::ffi::CStr::from_ptr(record.ssid.as_ptr() as *const core::ffi::c_char)
.to_string_lossy()
.into_owned()
};
Link::Associated {
ssid,
rssi: record.rssi,
channel: record.primary,
}
}
/// STA 网卡当前的 IPv4 地址(直接从 `esp_netif` 读,绕开 esp-idf-svc 的封装)。
///
/// `None` 表示还没拿到 DHCP 地址。0.0.0.0 也按 `None` 处理 —— 那等于没有地址。
fn sta_ip() -> Option<std::net::Ipv4Addr> {
// 默认 STA 网卡的 interface key(`esp_netif_create_default_wifi_sta` 用的就是它)。
let handle = unsafe {
esp_idf_svc::sys::esp_netif_get_handle_from_ifkey(c"WIFI_STA_DEF".as_ptr())
};
if handle.is_null() {
return None;
}
let mut info = esp_idf_svc::sys::esp_netif_ip_info_t::default();
// SAFETY: info 是局部量,handle 来自上面的查询。
if unsafe { esp_idf_svc::sys::esp_netif_get_ip_info(handle, &mut info) }
!= esp_idf_svc::sys::ESP_OK
{
return None;
}
// lwIP 的 `ip4_addr_t.addr` 是网络字节序,所以最低字节是点分表示的第一段。
let raw = info.ip.addr;
let ip = std::net::Ipv4Addr::new(
(raw & 0xFF) as u8,
((raw >> 8) & 0xFF) as u8,
((raw >> 16) & 0xFF) as u8,
((raw >> 24) & 0xFF) as u8,
);
if ip.is_unspecified() {
None
} else {
Some(ip)
}
}
/// 链路状态的一行描述,日志与状态行共用。
fn describe_link(link: &Link, ip: Option<std::net::Ipv4Addr>) -> String {
let ip = match ip {
Some(ip) => ip.to_string(),
None => "none".to_string(),
};
match link {
Link::Associated {
ssid,
rssi,
channel,
} => format!("ap={ssid} rssi={rssi} ch={channel} ip={ip}"),
Link::Idle { rc } => format!("ap=none (rc=0x{rc:x}) ip={ip}"),
}
}
/// 等到 STA 真的拿到 IP(权威来源),最多等 `timeout`。
///
/// 不用 `BlockingWifi::wait_netif_up()`:它读的是 esp-idf-svc 的事件缓存,在
/// IDF v6.0.1 上会"立刻成功"而实际没有地址 —— 那样 join() 会假装连上了,看门狗
/// 也就永远不会重试。
fn wait_for_ip(timeout: Duration) -> Option<std::net::Ipv4Addr> {
let deadline = std::time::Instant::now() + timeout;
loop {
if let Some(ip) = sta_ip() {
return Some(ip);
}
if std::time::Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(200));
}
}
/// 连上 WiFi 并等一个 IP(幂等:已启动就不再 start 一次)。
///
/// 连不上时把扫到的网络打出来:这块模块只有 2.4 GHz,SSID 若在 5 GHz 上,现场看到
/// 一张空列表比看一个关联超时有用得多。**调用方不应该因为这个错误就退出** —— 没有
/// 网也要能打灯。
fn join(wifi: &mut BlockingWifi<EspWifi<'static>>, ip_timeout: Duration) -> Result<()> {
if !wifi.is_started()? {
wifi.start()?;
}
if !link_state().associated() {
let config = Configuration::Client(ClientConfiguration {
ssid: SSID
.try_into()
.map_err(|_| anyhow!("WIFI_SSID too long (max 32 bytes)"))?,
bssid: None,
auth_method: default_auth_method(),
password: PASSWORD
.try_into()
.map_err(|_| anyhow!("WIFI_PASS too long (max 64 bytes)"))?,
channel: None,
// 通告 PMF:WPA2/WPA3 混合模式的路由器否则会丢掉握手;`required: false`
// 仍然允许纯 WPA2 的 AP。
pmf_cfg: PmfConfiguration::Capable { required: false },
..Default::default()
});
wifi.set_configuration(&config)?;
if let Err(err) = wifi.connect() {
error!("could not join \"{SSID}\": {err}");
report_scan(wifi);
return Err(err.into());
}
}
// 省电模式换来的电流不值得:它会让上报晚一个信标间隔(上百毫秒),而"受击后
// 50 ms 变暗"这件事本来就是要给人即时反馈的。
let rc = unsafe {
esp_idf_svc::sys::esp_wifi_set_ps(esp_idf_svc::sys::wifi_ps_type_t_WIFI_PS_NONE)
};
if rc != esp_idf_svc::sys::ESP_OK {
warn!("could not disable wifi power save ({})", esp::name(rc));
}
let link = link_state();
let Some(ip) = wait_for_ip(ip_timeout) else {
report_scan(wifi);
return Err(anyhow!(
"no DHCP address within {} ms ({})",
ip_timeout.as_millis(),
describe_link(&link, None)
));
};
info!("joined \"{SSID}\" on {ip}");
Ok(())
}
/// 没写 `WIFI_PASS` 就是开放网络。
fn default_auth_method() -> AuthMethod {
if PASSWORD.is_empty() {
AuthMethod::None
} else {
AuthMethod::WPA2Personal
}
}
/// 把扫到的网络打出来,并标出我们要连的那个。连不上时的第一诊断信息。
fn report_scan(wifi: &mut BlockingWifi<EspWifi<'static>>) {
match wifi.scan() {
Ok(aps) if aps.is_empty() => {
error!("scan found no networks; check the antenna and that the AP is on 2.4 GHz");
}
Ok(aps) => {
for ap in aps {
info!(
" {:<32} ch {:>2} {:>4} dBm {:?}{}",
ap.ssid,
ap.channel,
ap.signal_strength,
ap.auth_method,
if ap.ssid.as_str() == SSID { " <- target" } else { "" }
);
}
}
Err(err) => warn!("scan failed: {err}"),
}
}
+142
View File
@@ -0,0 +1,142 @@
//! 上报通道:把 [`ArmorHealth`](armor_protocol::ArmorHealth) 广播给本项目裁判终端。
//!
//! # 为什么直接写 lwIP 的 socket
//!
//! 与同仓库的图传固件(`src/udp.rs`)一样:`std::net` 在 lwIP 上的行为要靠一层
//! VFS 转接,而这里只需要"建一个 UDP socket + 广播"这一件事,直接用 IDF 原生的
//! `lwip_*` 就能与图传板跑在同一条被验证过的代码路径上。
//!
//! # 为什么不 bind
//!
//! 装甲板只发不收:血量是板子算出来的,裁判端只显示。所以 socket 不绑定端口,
//! 由 lwIP 选一个临时端口即可 —— 绑定 5601 反而会和裁判端、以及同网段的另一块
//! 装甲板抢同一个端口。裁判端监听 5601,板子往 5601 发,方向是单向的。
//!
//! # 广播而不是单播
//!
//! 裁判终端的地址是 DHCP 变的(图传板的 Hello 广播就是为了绕开这件事)。装甲板
//! 沿用同一个约定:不知道也不关心裁判端在哪,反正同一个局域网里它会收到。
use core::ffi::c_void;
use std::net::SocketAddrV4;
use anyhow::{anyhow, Result};
use esp_idf_svc::sys as ffi;
use log::{info, warn};
use armor_protocol::{ARMOR_PORT, BROADCAST_ADDR};
/// 把 Rust 的 socket 地址转成 lwIP 期望的 C 结构。
///
/// 逐字段构造而不是 `transmute`:`SocketAddrV4` 是紧凑的 6 字节(端口在前、主机
/// 字节序),C 的 `sockaddr_in` 是 16 字节且地址是网络字节序 —— 指针强转会发到一个
/// 乱地址,而且不会报错。
fn to_sockaddr(addr: SocketAddrV4) -> ffi::sockaddr_in {
let octets = addr.ip().octets();
ffi::sockaddr_in {
sin_len: core::mem::size_of::<ffi::sockaddr_in>() as u8,
sin_family: ffi::AF_INET as ffi::sa_family_t,
// 用 lwIP 的 htons,字节序不必在 Rust 侧推理。
sin_port: unsafe { ffi::lwip_htons(addr.port()) },
sin_addr: ffi::in_addr {
s_addr: u32::from_le_bytes(octets),
},
sin_zero: [0; 8],
}
}
/// 本机 id:由 WiFi MAC 派生(FNV-1a),重启不变,且避开 0 —— 0 留给裁判终端。
pub fn local_id() -> u32 {
let mut mac = [0u8; 6];
// SAFETY: `mac` 有 6 字节空间,函数会把 6 字节写满。
let rc = unsafe { ffi::esp_read_mac(mac.as_mut_ptr(), ffi::esp_mac_type_t_ESP_MAC_WIFI_STA) };
if rc != ffi::ESP_OK {
warn!("could not read the WiFi MAC ({:#x}); using a fixed id", rc);
return 0xF1F0_0001;
}
let id = armor_protocol::id_from_mac(&mac);
info!("this board's id is 0x{id:08X}, from MAC {mac:02X?}");
id
}
/// 广播上报器。
pub struct Reporter {
socket: i32,
}
impl Reporter {
/// 建一个开了广播许可的 UDP socket。
pub fn new() -> Result<Self> {
// SAFETY: 建 socket 不涉及 Rust 内存。
let socket = unsafe { ffi::lwip_socket(ffi::AF_INET as i32, ffi::SOCK_DGRAM as i32, 0) };
if socket < 0 {
// 用 std 的 errno 读取:`__errno` 不是每个目标的绑定里都有,而 lwIP 的
// socket 错误码就是同一个 errno。
return Err(anyhow!(
"could not create UDP socket: {}",
std::io::Error::last_os_error()
));
}
// 目标地址是 255.255.255.255,不开这个选项 sendto 会返回 EACCES。默认就是
// 关的,而且失败不会自己冒出来 —— 只会表现为"裁判端一直显示未接入"。
let enable: i32 = 1;
// SAFETY: optval 指向栈上的 i32,optlen 与它一致。
let rc = unsafe {
ffi::lwip_setsockopt(
socket,
ffi::SOL_SOCKET as i32,
ffi::SO_BROADCAST as i32,
&enable as *const i32 as *const c_void,
core::mem::size_of::<i32>() as u32,
)
};
if rc != 0 {
let err = std::io::Error::last_os_error();
// SAFETY: 关闭本函数刚建的 socket。
unsafe { ffi::lwip_close(socket) };
return Err(anyhow!("could not enable SO_BROADCAST: {err}"));
}
info!("reporting to {BROADCAST_ADDR}:{ARMOR_PORT} over UDP");
Ok(Self { socket })
}
/// 把一个数据报广播到裁判终端。
pub fn broadcast(&self, payload: &[u8]) -> Result<()> {
let addr = to_sockaddr(SocketAddrV4::new(BROADCAST_ADDR, ARMOR_PORT));
// SAFETY: 缓冲区与地址在调用期间都有效;sendto 复制数据而不是持有指针。
let rc = unsafe {
ffi::lwip_sendto(
self.socket,
payload.as_ptr() as *const c_void,
payload.len(),
ffi::MSG_DONTWAIT as i32,
&addr as *const ffi::sockaddr_in as *const ffi::sockaddr,
core::mem::size_of::<ffi::sockaddr_in>() as ffi::socklen_t,
)
};
if rc < 0 {
return Err(anyhow!(
"broadcast failed: {}",
std::io::Error::last_os_error()
));
}
Ok(())
}
}
impl Drop for Reporter {
fn drop(&mut self) {
// SAFETY: 关闭本结构体自己建的 socket。
unsafe { ffi::lwip_close(self.socket) };
}
}
+213
View File
@@ -0,0 +1,213 @@
//! 四路压电片的 ADC 连续采样(ESP32-C3)。
//!
//! # 为什么要连续(DMA)采样
//!
//! 压电片被敲一下的波形只有几十微秒宽。用 oneshot 轮询(每圈读一次)几乎必然从
//! 尖峰旁边擦过去,表现就是"有时候敲得响才算";参考实现因此用 40 kHz 的连续采样,
//! 这里保持一致:四个通道合计 40 kHz(每通道 10 kHz),一帧 10 ms = 400 个采样点。
//!
//! # 为什么也走 sys 绑定
//!
//! `esp-idf-hal` 的 `adc::continuous` 要用 `AdcChannels` 组合通道,而多通道组合的
//! 入口 `EmptyAdcChannels::chain` 在 0.47 里的约束是 `A: Adc` 且
//! `O: AdcChannels<AdcUnit = A>` —— `ADC1` 满足前者、`ADCU1` 满足后者,同一个类型
//! 参数凑不齐,四路 ADC1 通道拼不出来。直接照着 IDF 的 C API 建驱动反而更短,也
//! 与参考实现逐行对应。
//!
//! 采样点的通道号由硬件结果自带(`type2.channel`),所以映射不依赖"结果按 pattern
//! 顺序轮转"这个假设。
use anyhow::Result;
use esp_idf_svc::sys as ffi;
use log::info;
use armor_protocol::Plate;
use crate::esp;
/// 四个通道合计的采样率;每通道 10 kHz。
const SAMPLE_FREQ_HZ: u32 = 40_000;
/// 一帧的采样点数:10 ms × 40 kHz。
pub const FRAME_MEASUREMENTS: usize = 400;
/// 一帧的字节数(每个结果 4 字节)。
const FRAME_BYTES: u32 = FRAME_MEASUREMENTS as u32 * ffi::SOC_ADC_DIGI_RESULT_BYTES;
/// 连续读的超时:正常情况 10 ms 就凑满一帧,200 ms 只用来发现"驱动卡住了"。
const READ_TIMEOUT_MS: u32 = 200;
/// 每个板位对应的 ADC 通道号(ESP32-C3:GPIO0/4/3/1 = ADC1_CH0/CH4/CH3/CH1)。
///
/// 顺序与 [`Plate`] 的 index 一致;硬件结果里的通道号就是这些值。
const PLATE_CHANNEL: [u8; 4] = [0, 4, 3, 1];
/// 一帧的统计:读到多少采样点,以及每个板位的峰值。
///
/// 峰值是给**现场校准阈值**用的:压电片的输出幅度取决于贴装、线长和有没有放大,
/// 只报"有没有过阈值"没法判断该把阈值调到哪里。status 行每秒打印一次。
#[derive(Debug, Clone, Copy, Default)]
pub struct FrameStats {
/// 这一帧实际读到的采样点数。
pub samples: usize,
/// 每个板位的最大原始 ADC 值,按 [`Plate`] 的 index 排列。
pub peak: [u16; 4],
}
/// 连续采样的压电片输入。
pub struct Piezo {
handle: ffi::adc_continuous_handle_t,
/// 一帧的原始结果。`adc_digi_output_data_t` 里有 union,bindgen 不给 `Default`,
/// 所以用零值显式初始化(这个类型是纯 POD,零值是合法状态)。
frame: Box<[ffi::adc_digi_output_data_t; FRAME_MEASUREMENTS]>,
}
impl Piezo {
/// 建驱动并开始采样。
pub fn new() -> Result<Self> {
let handle_config = ffi::adc_continuous_handle_cfg_t {
// 两帧的余量:这一帧还没读完时,下一帧有地方落。
max_store_buf_size: FRAME_BYTES * 2,
conv_frame_size: FRAME_BYTES,
..Default::default()
};
let mut handle: ffi::adc_continuous_handle_t = core::ptr::null_mut();
esp::check(
"adc_continuous_new_handle",
// SAFETY: 两个指针都指向本函数的局部量,调用期间有效。
unsafe { ffi::adc_continuous_new_handle(&handle_config, &mut handle) },
)?;
// 四路通道的采样参数:0 dB 衰减、12 bit、都在 ADC1。
let mut patterns = [ffi::adc_digi_pattern_config_t {
atten: ffi::adc_atten_t_ADC_ATTEN_DB_0 as u8,
channel: 0,
unit: ffi::adc_unit_t_ADC_UNIT_1 as u8,
bit_width: 12,
}; 4];
for (pattern, channel) in patterns.iter_mut().zip(PLATE_CHANNEL) {
pattern.channel = channel;
}
let config = ffi::adc_continuous_config_t {
pattern_num: patterns.len() as u32,
adc_pattern: patterns.as_mut_ptr(),
sample_freq_hz: SAMPLE_FREQ_HZ,
conv_mode: ffi::adc_digi_convert_mode_t_ADC_CONV_SINGLE_UNIT_1,
// C3 的连续采样只支持 12 bit 的 TYPE2 结果布局,也正好对应下面读的
// `type2.data`(参考实现依赖了默认值 0,这里写明确)。
format: ffi::adc_digi_output_format_t_ADC_DIGI_OUTPUT_FORMAT_TYPE2,
..Default::default()
};
if let Err(err) = esp::check(
"adc_continuous_config",
// SAFETY: IDF 会把 patterns 复制进驱动,调用后不再持有这个指针。
unsafe { ffi::adc_continuous_config(handle, &config) },
) {
// SAFETY: 句柄刚建好、还没 start,可以直接删。
unsafe { ffi::adc_continuous_deinit(handle) };
return Err(err);
}
esp::check("adc_continuous_start", unsafe {
ffi::adc_continuous_start(handle)
})?;
let zero = ffi::adc_digi_output_data_t {
__bindgen_anon_1: ffi::adc_digi_output_data_t__bindgen_ty_1 { val: 0 },
};
info!(
"piezo: {SAMPLE_FREQ_HZ} Hz total over {} channels, {} measurements/frame (10 ms)",
PLATE_CHANNEL.len(),
FRAME_MEASUREMENTS
);
Ok(Self {
handle,
frame: Box::new([zero; FRAME_MEASUREMENTS]),
})
}
/// 读一帧(约 10 ms),把过阈值的采样点收集进 `hits`。
///
/// `hits` 会被清空后填充 `(板位, 原始值)`;返回值里带这一帧的采样点数与每路峰值。
/// **去抖与扣血不在这里做** —— 那是
/// [`armor_protocol::Armor::on_sample`] 的职责,一次击打的多个过阈值采样点由它
/// 的冷却窗口收敛成一次受击。
pub fn read_frame(
&mut self,
threshold: u16,
hits: &mut Vec<(Plate, u16)>,
) -> Result<FrameStats> {
hits.clear();
let mut stats = FrameStats::default();
let mut out_length: u32 = 0;
let rc = unsafe {
ffi::adc_continuous_read(
self.handle,
self.frame.as_mut_ptr() as *mut u8,
FRAME_BYTES,
&mut out_length,
READ_TIMEOUT_MS,
)
};
if rc == ffi::ESP_ERR_TIMEOUT {
// 没有数据不算错误:上一帧还没凑满,下一圈再来。
return Ok(stats);
}
esp::check("adc_continuous_read", rc)?;
let count = (out_length as usize / ffi::SOC_ADC_DIGI_RESULT_BYTES as usize)
.min(FRAME_MEASUREMENTS);
stats.samples = count;
for index in 0..count {
// SAFETY: 配置的是 TYPE2(12 bit)格式,`type2` 是有效成员;每个结果
// 4 字节且已 4 字节对齐。
let result = unsafe { self.frame[index].__bindgen_anon_1.type2 };
let raw = result.data() as u16;
let Some(plate) = plate_of_channel(result.channel() as u8) else {
continue;
};
// 峰值取自**所有**采样点,不只是过阈值的:阈值调得对不对,要靠没敲时
// 的底噪和敲击时的幅度两个数字一起看。
let slot = plate.index() as usize;
if raw > stats.peak[slot] {
stats.peak[slot] = raw;
}
if raw > threshold {
hits.push((plate, raw));
}
}
Ok(stats)
}
}
impl Drop for Piezo {
fn drop(&mut self) {
// 固件里 main 永不返回;这段只在启动失败路径上跑到。
unsafe {
if !self.handle.is_null() {
ffi::adc_continuous_stop(self.handle);
ffi::adc_continuous_deinit(self.handle);
}
}
}
}
/// 硬件结果里的通道号 → 板位。
#[must_use]
fn plate_of_channel(channel: u8) -> Option<Plate> {
let slot = PLATE_CHANNEL.iter().position(|c| *c == channel)?;
Plate::ALL.get(slot).copied()
}
+65
View File
@@ -0,0 +1,65 @@
//! 板子自己的持久化设置(NVS):队别与颜色。
//!
//! 命名空间与键名都沿用参考实现(`armor` / `color`),所以从参考固件刷过来时,
//! 之前存过的颜色还能被读到。新增一个 `team` 键用来记住这块板子是哪一队。
use anyhow::Result;
use esp_idf_svc::nvs::{EspDefaultNvsPartition, EspNvs, NvsDefault};
use log::info;
use armor_protocol::Rgb;
/// NVS 命名空间(与参考实现一致)。
const NAMESPACE: &str = "armor";
/// 队别键名。
const KEY_TEAM: &str = "team";
/// 颜色键名,值是 `0x00RRGGBB`(与参考实现一致)。
const KEY_COLOR: &str = "color";
/// 打开着的 NVS 句柄。
pub struct Settings {
nvs: EspNvs<NvsDefault>,
}
impl Settings {
/// 打开(必要时创建)命名空间。
pub fn new(partition: EspDefaultNvsPartition) -> Result<Self> {
Ok(Self {
nvs: EspNvs::new(partition, NAMESPACE, true)?,
})
}
/// 定出这次该用哪个颜色,并把它写回 NVS。
///
/// 规则:**编译期注入的 `ARMOR_TEAM` 是权威**。换队伍只要重刷固件,不用先擦
/// NVS;NVS 里的值只用来记住当前状态(也给以后加"手动换色"留位置)。两者
/// 不一致时以 `ARMOR_TEAM` 为准并覆盖,日志里会说一声,免得现场纳闷为什么
/// 灯色变了。
pub fn resolve_color(&self, team: u8) -> Result<Rgb> {
let expected = Rgb::for_team(team);
let stored_team = self.nvs.get_u8(KEY_TEAM)?;
let stored_color = self.nvs.get_u32(KEY_COLOR)?;
let color = match (stored_team, stored_color) {
(Some(stored), Some(raw)) if stored == team => Rgb::from_u32(raw),
(Some(stored), Some(raw)) => {
info!(
"ARMOR_TEAM={team} overrides the stored team {stored} (colour #{raw:06X}); rewriting NVS"
);
expected
}
_ => {
info!(
"first boot as team {team}: storing colour #{:06X}",
expected.to_u32()
);
expected
}
};
self.nvs.set_u8(KEY_TEAM, team)?;
self.nvs.set_u32(KEY_COLOR, color.to_u32())?;
Ok(color)
}
}
+7
View File
@@ -0,0 +1,7 @@
# 这个 crate 在主机上跑单元测试,不交叉编译。
#
# 它坐在 armor/ 目录树里,cargo 会向上继承 armor/.cargo/config.toml 的 RISC-V
# 目标与 ldproxy linker —— 把主机测试编译成 ESP32-C3 固件会直接失败在链接上。
# 把 target 覆盖回 host triple 就够了,别的键对非 ESP 目标没有影响。
[build]
target = "aarch64-apple-darwin"
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "armor-protocol"
version = "0.1.0"
edition = "2021"
# 只依赖错误检查用的东西:规则层是纯计算(血量、去抖、按键状态机),
# 这样它能在 PC 上跑单元测试,不需要 ESP32-C3 硬件。
[dependencies]
protocol = { path = "../../protocol" }
+872
View File
@@ -0,0 +1,872 @@
//! 装甲板规则层:板位、整车血量、受击判定、按键状态机、上报帧。
//!
//! # 为什么和固件分开
//!
//! 这一层全是纯计算:一次采样算不算新受击、扣几滴血、灯带这一刻该显示什么颜色、
//! 按一下键算短按还是长按。这些恰好是最容易写错、又最不适合"烧上去看现象"的部分
//! —— 在 ESP32-C3 上,受击是几微秒的压电片尖峰,现场根本没法复现"冷却期内的第二次
//! 采样被误判"这类 bug。放在这里就能用 `Duration` 喂假时间来测。
//!
//! 固件侧(`armor-firmware`)只负责把外设接到这层:ADC 采样值、GPIO 电平、毫秒
//! 时基、UDP 发送。
//!
//! # 硬件形态(一主三从)
//!
//! **只有主板一颗 MCU**:三块"从"装甲板里没有单片机,只有一路 WS2812 和一路压电片,
//! 直接接到主板上。所以这一层同时管四路:
//!
//! | 板位 | WS2812 | 压电片 ADC |
//! | --- | --- | --- |
//! | 前 front | GPIO10 | ADC1_CH0 (GPIO0) |
//! | 后 rear | GPIO5 | ADC1_CH4 (GPIO4) |
//! | 左 left | GPIO6 | ADC1_CH3 (GPIO3) |
//! | 右 right | GPIO7 | ADC1_CH1 (GPIO1) |
//!
//! # 规则(已与本项目裁判端对齐)
//!
//! - 整车一条血量:初始 100,每次受击 −3,减到 0 为止**不变负**,血量 0 时四路灯带
//! 全黑("死了全黑")。
//! - 受击时**只有被打的那一路**灯带全黑 50 ms,其余三路不动;50 ms 同时是同一板位的
//! 重复判定冷却。
//! - 颜色由队伍决定:队伍一红、队伍二蓝(RoboMaster 惯例)。
//! - 血量账在板子上算,按 [`protocol::ArmorHealth`] 上报给本项目裁判终端(UDP 5601)。
//! 整车一条血量 → 四条上报的 `hp` 相同,`index` 分别是 0..3,裁判端已有的四格血量
//! 界面无需改动。
use std::time::Duration;
pub use protocol::{id_from_mac, ArmorHealth, ARMOR_PORT, BROADCAST_ADDR, MsgType};
/// 队伍一。
pub const TEAM_1: u8 = 0;
/// 队伍二。
pub const TEAM_2: u8 = 1;
/// 一块装甲板。
///
/// 判别值就是上报里的 `index`(0..3),所以 `Plate::Front.index() == 0` 与裁判端
/// 的四格顺序一一对应。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum Plate {
/// 前装甲板,WS2812 = GPIO10,压电片 = ADC1_CH0。
Front = 0,
/// 后装甲板,WS2812 = GPIO5,压电片 = ADC1_CH4。
Rear = 1,
/// 左装甲板,WS2812 = GPIO6,压电片 = ADC1_CH3。
Left = 2,
/// 右装甲板,WS2812 = GPIO7,压电片 = ADC1_CH1。
Right = 3,
}
impl Plate {
/// 按 index 顺序的四块板,也是灯带刷新和 ADC 通道表里用的顺序。
pub const ALL: [Plate; 4] = [Plate::Front, Plate::Rear, Plate::Left, Plate::Right];
/// 上报里的 `index`。
#[must_use]
pub const fn index(self) -> u8 {
self as u8
}
/// 日志里用的短名(英文,和本仓库其余固件的日志一致)。
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Plate::Front => "front",
Plate::Rear => "rear",
Plate::Left => "left",
Plate::Right => "right",
}
}
/// 从 `index` 还原,越界返回 `None`。
#[must_use]
pub const fn from_index(value: u8) -> Option<Plate> {
match value {
0 => Some(Plate::Front),
1 => Some(Plate::Rear),
2 => Some(Plate::Left),
3 => Some(Plate::Right),
_ => None,
}
}
/// 数组下标。
#[must_use]
const fn slot(self) -> usize {
self as usize
}
}
/// 颜色,`0x00RRGGBB`(与参考实现里 `Adafruit_NeoPixel::Color(r, g, b)` 一致)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb(pub u8, pub u8, pub u8);
impl Rgb {
/// 队伍一的颜色。
pub const RED: Rgb = Rgb(0xFF, 0x00, 0x00);
/// 队伍二的颜色。
pub const BLUE: Rgb = Rgb(0x00, 0x00, 0xFF);
/// 全黑,也就是熄灭。
pub const BLACK: Rgb = Rgb(0x00, 0x00, 0x00);
/// 队伍对应的颜色:队伍二蓝,其余(含误配的值)红。
#[must_use]
pub const fn for_team(team: u8) -> Rgb {
if team == TEAM_2 {
Rgb::BLUE
} else {
Rgb::RED
}
}
/// 打包成 `0x00RRGGBB`,用于写 NVS 或日志。
#[must_use]
pub const fn to_u32(self) -> u32 {
((self.0 as u32) << 16) | ((self.1 as u32) << 8) | (self.2 as u32)
}
/// 从 `0x00RRGGBB` 还原。
#[must_use]
pub const fn from_u32(value: u32) -> Rgb {
Rgb(
((value >> 16) & 0xFF) as u8,
((value >> 8) & 0xFF) as u8,
(value & 0xFF) as u8,
)
}
/// WS2812 的线上字节序:GRB,MSB first。
///
/// 交错顺序错了不会报错,只会显示成另一种颜色(红蓝互换),所以单独有测试钉住。
#[must_use]
pub const fn to_grb(self) -> [u8; 3] {
[self.1, self.0, self.2]
}
}
/// 一条灯带这一刻要显示的内容。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strip {
/// 正常颜色。
Color(Rgb),
/// 全黑:受击的 50 ms 窗口内,或者整车血量已经归零。
Black,
}
impl Strip {
/// 是否熄灭。
#[must_use]
pub const fn is_black(self) -> bool {
matches!(self, Strip::Black)
}
/// 实际颜色,`Black` 就是 [`Rgb::BLACK`]。
#[must_use]
pub const fn color(self) -> Rgb {
match self {
Strip::Color(rgb) => rgb,
Strip::Black => Rgb::BLACK,
}
}
}
/// 规则参数。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rules {
/// 整车初始血量。
pub max_hp: u16,
/// 每次受击扣多少血。
pub damage_per_hit: u16,
/// 受击后该路灯带全黑的时长。
pub hit_blink: Duration,
/// 同一块板重复受击的抑制窗口(压电片一次击打会在多个采样点上过阈值)。
pub hit_cooldown: Duration,
/// 原始 12 bit ADC 采样值超过它才算受击。
pub threshold: u16,
}
impl Default for Rules {
/// 现场取值:100 血、每击 −3、50 ms 变暗/冷却、阈值 2000(与参考实现一致)。
fn default() -> Self {
Self {
max_hp: 100,
damage_per_hit: 3,
hit_blink: Duration::from_millis(50),
hit_cooldown: Duration::from_millis(50),
threshold: 2000,
}
}
}
/// 一次被计入的受击。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HitEvent {
/// 哪块装甲板。
pub plate: Plate,
/// 触发这次判定的原始 ADC 值,用来现场校准阈值。
pub raw: u16,
/// 扣血之后的整车血量。
pub hp: u16,
/// 这一击是否把血量打到 0。
pub dead: bool,
/// 判定时刻。
pub at: Duration,
}
/// 装甲板状态机:血量、受击冷却、灯带输出。
#[derive(Debug, Clone)]
pub struct Armor {
rules: Rules,
color: Rgb,
hp: u16,
hits: u32,
plate_hits: [u32; 4],
last_hit: [Option<Duration>; 4],
}
impl Armor {
/// 满血、`color` 为正常色。
#[must_use]
pub fn new(rules: Rules, color: Rgb) -> Self {
Self {
rules,
color,
hp: rules.max_hp,
hits: 0,
plate_hits: [0; 4],
last_hit: [None; 4],
}
}
/// 规则参数。
#[must_use]
pub fn rules(&self) -> Rules {
self.rules
}
/// 当前整车血量。
#[must_use]
pub fn hp(&self) -> u16 {
self.hp
}
/// 血量是否归零。
#[must_use]
pub fn dead(&self) -> bool {
self.hp == 0
}
/// 累计计入的受击次数。
#[must_use]
pub fn hits(&self) -> u32 {
self.hits
}
/// 某块装甲板各自被计中的次数。
#[must_use]
pub fn plate_hits(&self, plate: Plate) -> u32 {
self.plate_hits[plate.slot()]
}
/// 四块板各自被计中的次数,按 index 排列。
#[must_use]
pub fn plate_hits_all(&self) -> [u32; 4] {
self.plate_hits
}
/// 正常显示颜色。
#[must_use]
pub fn color(&self) -> Rgb {
self.color
}
/// 换正常显示颜色(队伍变了、或从 NVS 读回)。
pub fn set_color(&mut self, color: Rgb) {
self.color = color;
}
/// 喂一个 ADC 采样值。
///
/// 只有"过阈值 + 该板不在冷却期内"才计入受击并扣血;其余返回 `None`。
/// 一次击打在 40 kHz 采样下会连续多个点过阈值,冷却窗口就是把这些点算成一次。
pub fn on_sample(&mut self, plate: Plate, raw: u16, now: Duration) -> Option<HitEvent> {
if raw <= self.rules.threshold {
return None;
}
let slot = plate.slot();
if let Some(last) = self.last_hit[slot] {
if now.saturating_sub(last) < self.rules.hit_cooldown {
return None;
}
}
self.last_hit[slot] = Some(now);
self.hits = self.hits.saturating_add(1);
self.plate_hits[slot] = self.plate_hits[slot].saturating_add(1);
let before = self.hp;
self.hp = self.hp.saturating_sub(self.rules.damage_per_hit);
Some(HitEvent {
plate,
raw,
hp: self.hp,
// 是"这一击打死了",不是"打完之后已经死了":已经 0 血以后继续挨打不该
// 每次都报一次死亡,否则现场日志会被刷屏、上报也会抖。
dead: self.hp == 0 && before > 0,
at: now,
})
}
/// 这块装甲板这一刻该显示什么。
///
/// 整车死亡优先于一切:死了四路全黑。否则只有被打的那一路在 50 ms 窗口内全黑。
#[must_use]
pub fn strip(&self, plate: Plate, now: Duration) -> Strip {
if self.dead() {
return Strip::Black;
}
if let Some(last) = self.last_hit[plate.slot()] {
if now.saturating_sub(last) < self.rules.hit_blink {
return Strip::Black;
}
}
Strip::Color(self.color)
}
/// 四路灯带这一刻的输出,按 index 排列 —— 固件照着它刷 RMT。
#[must_use]
pub fn strips(&self, now: Duration) -> [Strip; 4] {
Plate::ALL.map(|plate| self.strip(plate, now))
}
/// 复位成"新一局":满血、清计数、清冷却(颜色不动)。
pub fn reset(&mut self) {
self.hp = self.rules.max_hp;
self.hits = 0;
self.plate_hits = [0; 4];
self.last_hit = [None; 4];
}
/// 要上报给裁判端的四条记录。
///
/// 整车一条血量,所以四条 `hp` 相同、`index` 分别是 0..3;裁判端按
/// [`protocol::ArmorHealth::team`] 把这里的数据归到对应队伍。
#[must_use]
pub fn reports(&self, team: u8) -> [ArmorHealth; 4] {
Plate::ALL.map(|plate| ArmorHealth {
team,
index: plate.index(),
hp: self.hp,
})
}
/// 四条记录编码成可以直接 `sendto` 的字节串。
#[must_use]
pub fn datagrams(&self, team: u8) -> Vec<Vec<u8>> {
self.datagrams_from(team, 0)
}
/// 同 [`datagrams`](Self::datagrams),但帧头带上板子自己的 id(由 MAC 派生)。
///
/// 用 `sender_id` 而不是默认的 0:0 是裁判终端的保留 id,抓包时一块装甲板发的
/// 包不该长得像裁判端发的。
#[must_use]
pub fn datagrams_from(&self, team: u8, sender_id: u32) -> Vec<Vec<u8>> {
self.reports(team)
.iter()
.map(|report| report.encode_from(sender_id))
.collect()
}
}
/// 上报节奏:血量一变立刻发,没变就按心跳周期重发。
///
/// 裁判终端只看得到最后一条,"每 200 ms 重发"是它判断板子还在线的唯一依据 ——
/// 没有心跳,一次丢包就会让界面显示成"装甲板未接入"。
#[derive(Debug, Clone)]
pub struct ReportSchedule {
heartbeat: Duration,
last: Option<(u16, Duration)>,
}
impl ReportSchedule {
/// `heartbeat` 是血量不变时的重发周期。
#[must_use]
pub fn new(heartbeat: Duration) -> Self {
Self {
heartbeat,
last: None,
}
}
/// 这一刻该不该发。
#[must_use]
pub fn due(&self, hp: u16, now: Duration) -> bool {
match self.last {
None => true,
Some((last_hp, last_at)) => {
last_hp != hp || now.saturating_sub(last_at) >= self.heartbeat
}
}
}
/// 记下"这一刻用这个血量发过了"。
pub fn mark_sent(&mut self, hp: u16, now: Duration) {
self.last = Some((hp, now));
}
}
/// 按键事件。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ButtonEvent {
/// 什么都没发生。
None,
/// 短按(按下后很快松开):固件用它打印一次状态并立刻重发上报。
Click,
/// 长按(按住到阈值):固件用它复位血量,也就是"新一局"。
LongPress,
}
/// 按键状态机:去抖 + 短按 / 长按,事件只发一次。
///
/// 参考实现移植了 OneButton 2.6.1 的整套状态机(它还带双击、多击、idle 回调)。
/// 这里只有两种动作,所以按同样的语义重写了一个本地版本:电平去抖,长按在按住
/// 到阈值时立刻产生一次且不重复,松开时若在短按窗口内则产生一次短按。
#[derive(Debug, Clone)]
pub struct Button {
debounce: Duration,
click_max: Duration,
long_press: Duration,
stable_pressed: bool,
candidate: bool,
candidate_since: Duration,
pressed_since: Duration,
long_fired: bool,
}
impl Default for Button {
/// BOOT 键(GPIO9,低有效)的现场取值:去抖 20 ms、短按 <500 ms、长按 ≥500 ms。
fn default() -> Self {
Self::new(
Duration::from_millis(20),
Duration::from_millis(500),
Duration::from_millis(500),
)
}
}
impl Button {
/// 自定义时间参数,主要给测试用。
#[must_use]
pub fn new(debounce: Duration, click_max: Duration, long_press: Duration) -> Self {
Self {
debounce,
click_max,
long_press,
stable_pressed: false,
candidate: false,
candidate_since: Duration::ZERO,
pressed_since: Duration::ZERO,
long_fired: false,
}
}
/// 喂一次电平(已换算成"是否按下")与当前时刻,最多产生一个事件。
pub fn tick(&mut self, pressed: bool, now: Duration) -> ButtonEvent {
let mut event = ButtonEvent::None;
if pressed != self.stable_pressed {
if self.candidate != pressed {
// 抖动的新电平:重新开始计稳定时间。
self.candidate = pressed;
self.candidate_since = now;
} else if now.saturating_sub(self.candidate_since) >= self.debounce {
self.stable_pressed = pressed;
if pressed {
self.pressed_since = now;
self.long_fired = false;
} else if !self.long_fired
&& now.saturating_sub(self.pressed_since) < self.click_max
{
event = ButtonEvent::Click;
}
}
} else {
// 电平回到稳定值,候选作废。
self.candidate = pressed;
}
if event == ButtonEvent::None
&& self.stable_pressed
&& !self.long_fired
&& now.saturating_sub(self.pressed_since) >= self.long_press
{
self.long_fired = true;
event = ButtonEvent::LongPress;
}
event
}
}
/// 解析编译期注入的 `ARMOR_TEAM`。
///
/// 只接受 "0" 与 "1":把队伍认错会让裁判端把血记到对手头上,现场很难发现,
/// 所以宁可启动就报错。
#[must_use]
pub fn parse_team(value: &str) -> Option<u8> {
match value.trim() {
"0" => Some(TEAM_1),
"1" => Some(TEAM_2),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ms(v: u64) -> Duration {
Duration::from_millis(v)
}
fn armor() -> Armor {
Armor::new(Rules::default(), Rgb::for_team(TEAM_1))
}
// ---------------------------------------------------------------- 血量 --
#[test]
fn starts_at_full_hp_and_is_not_dead() {
let a = armor();
assert_eq!(a.hp(), 100);
assert!(!a.dead());
assert_eq!(a.hits(), 0);
}
#[test]
fn each_hit_costs_three_and_floors_at_zero() {
let mut a = armor();
let mut hits = 0;
// 100 / 3 = 33.33,所以第 34 击才归零。
for i in 0..34 {
let at = ms(i * 1000);
a.on_sample(Plate::Front, 2500, at).expect("hit should count");
hits += 1;
}
assert_eq!(hits, 34);
assert_eq!(a.hp(), 0);
assert!(a.dead());
// 已经归零之后继续打,不会变负,也不算"新的一击导致死亡"。
let event = a
.on_sample(Plate::Rear, 2500, ms(40_000))
.expect("still counts as a hit");
assert_eq!(event.hp, 0);
assert!(!event.dead, "0 -> 0 不是新的死亡事件");
assert_eq!(a.hp(), 0);
}
#[test]
fn sample_below_threshold_is_not_a_hit() {
let mut a = armor();
assert!(a.on_sample(Plate::Front, 2000, ms(0)).is_none(), "阈值是严格大于");
assert!(a.on_sample(Plate::Front, 1999, ms(0)).is_none());
assert!(a.on_sample(Plate::Front, 2001, ms(0)).is_some());
assert_eq!(a.hp(), 97);
}
#[test]
fn per_plate_hit_counts_are_separate() {
let mut a = armor();
a.on_sample(Plate::Front, 3000, ms(0));
a.on_sample(Plate::Left, 3000, ms(1000));
a.on_sample(Plate::Left, 3000, ms(2000));
assert_eq!(a.plate_hits(Plate::Front), 1);
assert_eq!(a.plate_hits(Plate::Left), 2);
assert_eq!(a.plate_hits(Plate::Rear), 0);
assert_eq!(a.plate_hits(Plate::Right), 0);
assert_eq!(a.hits(), 3);
// 整车一条血量:三击共扣 9。
assert_eq!(a.hp(), 91);
}
// ------------------------------------------------------------ 受击去抖 --
#[test]
fn repeat_samples_inside_the_cooldown_are_one_hit() {
let mut a = armor();
// 40 kHz 采样下,一次击打会连续几十个点过阈值。
for i in 0..49 {
a.on_sample(Plate::Left, 2600, ms(i));
}
assert_eq!(a.plate_hits(Plate::Left), 1, "49 ms 内的连续采样算一次");
assert_eq!(a.hp(), 97);
}
#[test]
fn a_sample_after_the_cooldown_is_a_new_hit() {
let mut a = armor();
a.on_sample(Plate::Left, 2600, ms(0));
assert!(a.on_sample(Plate::Left, 2600, ms(49)).is_none());
assert!(a.on_sample(Plate::Left, 2600, ms(50)).is_some(), "50 ms 是边界,算新受击");
assert_eq!(a.plate_hits(Plate::Left), 2);
assert_eq!(a.hp(), 94);
}
#[test]
fn cooldown_is_per_plate_not_global() {
let mut a = armor();
// 参考实现用全局冷却:一块板挨打后,别块板在这 50 ms 内的击打会被吃掉。
// 每板独立冷却才是"每面各自挨打"的正确语义。
a.on_sample(Plate::Front, 3000, ms(0));
assert!(a.on_sample(Plate::Left, 3000, ms(1)).is_some());
assert!(a.on_sample(Plate::Rear, 3000, ms(2)).is_some());
assert_eq!(a.hits(), 3);
assert_eq!(a.hp(), 91);
}
// ------------------------------------------------------------ 灯带输出 --
#[test]
fn hit_plate_goes_black_for_fifty_ms_only() {
let mut a = armor();
a.on_sample(Plate::Left, 3000, ms(1000));
assert!(a.strip(Plate::Left, ms(1000)).is_black(), "受击瞬间");
assert!(a.strip(Plate::Left, ms(1049)).is_black(), "窗口内");
assert_eq!(
a.strip(Plate::Left, ms(1050)),
Strip::Color(Rgb::RED),
"窗口结束恢复原色"
);
// 其余三路全程不受影响 —— 这正是每面独立灯带的意义。
for plate in [Plate::Front, Plate::Rear, Plate::Right] {
assert_eq!(a.strip(plate, ms(1000)), Strip::Color(Rgb::RED));
assert_eq!(a.strip(plate, ms(1049)), Strip::Color(Rgb::RED));
}
}
#[test]
fn dead_blacks_out_every_strip() {
let mut a = armor();
let last = 34 * 1000;
for i in 0..34 {
a.on_sample(Plate::Front, 3000, ms(i * 1000));
}
assert!(a.dead());
for plate in Plate::ALL {
assert!(a.strip(plate, ms(last)).is_black(), "{plate:?} 死了要全黑");
}
// 冷却窗口早就过了,仍然全黑:死优先于一切。
assert!(a.strip(Plate::Left, ms(last + 10_000)).is_black());
}
#[test]
fn strips_returns_all_four_in_index_order() {
let mut a = armor();
a.on_sample(Plate::Rear, 3000, ms(0));
let strips = a.strips(ms(0));
assert_eq!(strips.len(), 4);
assert!(strips[Plate::Rear.index() as usize].is_black());
assert_eq!(strips[Plate::Front.index() as usize].color(), Rgb::RED);
}
// ---------------------------------------------------------------- 复位 --
#[test]
fn reset_restores_full_hp_and_keeps_the_colour() {
let mut a = armor();
a.on_sample(Plate::Front, 3000, ms(0));
a.on_sample(Plate::Right, 3000, ms(100));
a.set_color(Rgb::BLUE);
a.reset();
assert_eq!(a.hp(), 100);
assert_eq!(a.hits(), 0);
assert_eq!(a.plate_hits_all(), [0; 4]);
assert_eq!(a.color(), Rgb::BLUE);
assert_eq!(a.strip(Plate::Front, ms(100)), Strip::Color(Rgb::BLUE));
}
// ---------------------------------------------------------------- 上报 --
#[test]
fn reports_carry_the_shared_hp_on_all_four_indices() {
let mut a = armor();
a.on_sample(Plate::Front, 3000, ms(0));
a.on_sample(Plate::Front, 3000, ms(1000));
let reports = a.reports(TEAM_2);
assert_eq!(reports.len(), 4);
for (i, report) in reports.iter().enumerate() {
assert_eq!(report.team, TEAM_2);
assert_eq!(report.index, i as u8, "四格按 index 排列");
assert_eq!(report.hp, 94, "整车一条血量,四格同值");
}
}
#[test]
fn datagrams_round_trip_through_the_shared_protocol() {
let mut a = armor();
a.on_sample(Plate::Left, 3000, ms(0));
let datagrams = a.datagrams(TEAM_1);
assert_eq!(datagrams.len(), 4);
for (i, bytes) in datagrams.iter().enumerate() {
// 裁判端就是用这个函数解回来的,编码错了这里立刻挂。
let decoded = ArmorHealth::decode(bytes).expect("裁判端要能解出来");
assert_eq!(decoded.team, TEAM_1);
assert_eq!(decoded.index, i as u8);
assert_eq!(decoded.hp, 97);
assert_eq!(bytes.len(), protocol::HEADER_LEN + 4);
assert_eq!(bytes[4], MsgType::ArmorHealth as u8);
}
}
#[test]
fn datagrams_can_carry_the_board_id() {
let a = armor();
let datagrams = a.datagrams_from(TEAM_2, 0xABCD_1234);
for bytes in &datagrams {
let header = protocol::decode_header(bytes).expect("帧头可解");
assert_eq!(header.sender_id, 0xABCD_1234);
assert_eq!(ArmorHealth::decode(bytes).map(|r| r.team), Some(TEAM_2));
}
// 默认入口仍然是裁判终端的保留 id 0。
let plain = a.datagrams(TEAM_2);
assert_eq!(
protocol::decode_header(&plain[0]).map(|h| h.sender_id),
Some(0)
);
}
#[test]
fn report_schedule_fires_immediately_then_heartbeats() {
let mut s = ReportSchedule::new(ms(200));
assert!(s.due(100, ms(0)), "第一次一定发");
s.mark_sent(100, ms(0));
assert!(!s.due(100, ms(199)));
assert!(s.due(100, ms(200)), "心跳到期");
// 血量变化不等心跳。
assert!(s.due(97, ms(201)));
s.mark_sent(97, ms(201));
assert!(!s.due(97, ms(250)));
assert!(s.due(94, ms(251)));
}
// ---------------------------------------------------------------- 按键 --
#[test]
fn button_needs_the_debounce_window() {
let mut b = Button::new(ms(20), ms(500), ms(500));
assert_eq!(b.tick(true, ms(0)), ButtonEvent::None);
assert_eq!(b.tick(false, ms(5)), ButtonEvent::None, "5 ms 的毛刺不算按下");
assert_eq!(b.tick(true, ms(10)), ButtonEvent::None);
// 从 10 ms 起连续按下到 30 ms 才稳定。
assert_eq!(b.tick(true, ms(29)), ButtonEvent::None);
assert_eq!(b.tick(true, ms(30)), ButtonEvent::None, "按下本身不产生事件");
}
#[test]
fn short_press_is_a_click() {
let mut b = Button::new(ms(20), ms(500), ms(500));
b.tick(true, ms(0));
b.tick(true, ms(20)); // 稳定按下
b.tick(false, ms(100));
assert_eq!(b.tick(false, ms(120)), ButtonEvent::Click);
// 只发一次。
assert_eq!(b.tick(false, ms(200)), ButtonEvent::None);
}
#[test]
fn holding_produces_one_long_press_and_no_click_on_release() {
let mut b = Button::new(ms(20), ms(500), ms(500));
b.tick(true, ms(0));
b.tick(true, ms(20)); // 稳定按下
assert_eq!(b.tick(true, ms(400)), ButtonEvent::None);
assert_eq!(b.tick(true, ms(520)), ButtonEvent::LongPress, "按住到阈值");
assert_eq!(b.tick(true, ms(900)), ButtonEvent::None, "不回弹重复长按");
b.tick(false, ms(1000));
assert_eq!(
b.tick(false, ms(1020)),
ButtonEvent::None,
"长按过的这一下不该再算短按"
);
}
// ---------------------------------------------------------------- 其它 --
#[test]
fn colours_match_the_robo_master_convention() {
assert_eq!(Rgb::for_team(TEAM_1), Rgb::RED);
assert_eq!(Rgb::for_team(TEAM_2), Rgb::BLUE);
// 存 NVS / 打印与 Adafruit_NeoPixel::Color 的打包一致。
assert_eq!(Rgb::RED.to_u32(), 0x00FF_0000);
assert_eq!(Rgb::BLUE.to_u32(), 0x0000_00FF);
assert_eq!(Rgb::from_u32(0x0012_3456), Rgb(0x12, 0x34, 0x56));
}
#[test]
fn ws2812_byte_order_is_grb() {
assert_eq!(Rgb::RED.to_grb(), [0x00, 0xFF, 0x00]);
assert_eq!(Rgb::BLUE.to_grb(), [0x00, 0x00, 0xFF]);
assert_eq!(Rgb(0x11, 0x22, 0x33).to_grb(), [0x22, 0x11, 0x33]);
}
#[test]
fn plate_indices_are_stable() {
assert_eq!(Plate::Front.index(), 0);
assert_eq!(Plate::Rear.index(), 1);
assert_eq!(Plate::Left.index(), 2);
assert_eq!(Plate::Right.index(), 3);
for plate in Plate::ALL {
assert_eq!(Plate::from_index(plate.index()), Some(plate));
}
assert_eq!(Plate::from_index(4), None);
}
#[test]
fn team_env_parsing_rejects_anything_but_zero_and_one() {
assert_eq!(parse_team("0"), Some(TEAM_1));
assert_eq!(parse_team("1"), Some(TEAM_2));
assert_eq!(parse_team(" 1\n"), Some(TEAM_2));
assert_eq!(parse_team("2"), None);
assert_eq!(parse_team("team1"), None);
assert_eq!(parse_team(""), None);
}
}
+77 -1
View File
@@ -1,3 +1,79 @@
// ESP-IDF 的环境导出 + 固件版本信息注入。
//
// # 版本号为什么在这里注入
//
// 固件的版本必须和客户端、和 `version.json` 是**同一个数**。手工在 `src/` 里写一个
// 常量,第一次忘了改就分叉了,而且没有任何东西会报错——现场只会表现为"界面说固件 0.2.0,
// 实际烧的是别的"。
//
// 所以这里在编译期把 `version.json` 读进来,导出成 `cargo:rustc-env`,源码里用
// `env!()` 取。`version.json` 由 `scripts/version.mjs` 从 git 历史算出来,于是
// 一条链是通的:git → version.json → 固件二进制 → Hello 报文 → 客户端界面。
use std::fs;
use std::path::Path;
fn main() {
embuild::espidf::sysenv::output();
}
emit_version_env();
}
/// 把 `version.json` 里的版本拆成几个编译期常量。
fn emit_version_env() {
let manifest = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
let path = Path::new(&manifest).join("version.json");
// 这个文件改了要重新编译,否则版本号会停在旧值上而二进制看起来是新的。
println!("cargo:rerun-if-changed={}", path.display());
// 没有 version.json 也要能编:直接 cargo build 的人可能还没跑过生成脚本。
// 这时退回 0.0.0+0 并留一条编译器警告——比编不过友好,比编出一个假的版本诚实。
let (major, minor, patch, build) = match fs::read_to_string(&path) {
Ok(text) => match parse(&text) {
Some(parsed) => parsed,
None => {
println!("cargo:warning=version.json 解析失败,固件版本回退为 0.0.0+0");
(0, 0, 0, 0)
}
},
Err(_) => {
println!(
"cargo:warning=找不到 version.json,固件版本回退为 0.0.0+0(先跑 node scripts/version.mjs --write)"
);
(0, 0, 0, 0)
}
};
let packed = ((major as u32) << 16) | (((minor as u32) & 0xff) << 8) | ((patch as u32) & 0xff);
println!("cargo:rustc-env=RMIC_VERSION={major}.{minor}.{patch}");
println!("cargo:rustc-env=RMIC_VERSION_PACKED={packed}");
println!("cargo:rustc-env=RMIC_BUILD={build}");
}
/// 从 `version.json` 里抠出四段数字。
///
/// 刻意不引入 `serde_json`:解析四个字段不值得多一个编译期依赖,而固件的构建时间本来
/// 就是这个项目里最贵的东西。
fn parse(text: &str) -> Option<(u64, u64, u64, u64)> {
let field = |name: &str| -> Option<u64> {
let key = format!("\"{name}\"");
let at = text.find(&key)?;
let rest = &text[at + key.len()..];
let colon = rest.find(':')?;
let rest = &rest[colon + 1..];
let start = rest.find(|c: char| c.is_ascii_digit())?;
let digits: String = rest[start..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
digits.parse().ok()
};
Some((
field("major")?,
field("minor")?,
field("patch")?,
field("build")?,
))
}
-13
View File
@@ -1,13 +0,0 @@
# This crate builds for the host, not the ESP32.
#
# It sits inside the firmware's directory tree, so cargo walks up and inherits
# ../.cargo/config.toml, which pins `target = "xtensa-esp32s3-espidf"` and an
# ldproxy linker that only work inside the ESP-IDF cross build. Without this file
# the desktop client is cross-compiled for Xtensa, which fails deep inside
# dependencies (libc not finding MAP_FAILED and friends, because it was being
# compiled for an embedded target that has no mmap).
#
# Overriding the target back to the host triple is the whole fix. Nothing else in
# the parent config applies to a non-ESP target.
[build]
target = "aarch64-apple-darwin"
-29
View File
@@ -1,29 +0,0 @@
[package]
name = "cam-client"
version = "0.1.0"
edition = "2021"
[build-dependencies]
# Compiles ui/camera.slint into Rust before the crate itself builds. Kept separate
# from [dependencies] because it only runs on the build host, never on the target.
slint-build = "1"
[dependencies]
# Shared wire format with the firmware. Kept as a path dependency rather than
# copied, so the two sides cannot drift apart silently.
protocol = { path = "../protocol" }
# Slint for the UI. The default renderer on macOS is femtovg, which needs no
# extra system libraries; `backend-winit` provides the window integration.
slint = "1"
# JPEG decoding for display. Slint's Image can take raw RGBA, so the JPEG has to
# be decoded somewhere - `image` does that, and nothing on this side re-encodes.
image = { version = "0.25", default-features = false, features = ["jpeg"] }
anyhow = "1"
log = "0.4"
env_logger = "0.11"
[profile.release]
opt-level = 2
-158
View File
@@ -1,158 +0,0 @@
# cam-client
[ESP32-S3 摄像头固件](../README.md) 的桌面查看器。接收 UDP 数据报形式的 JPEG 帧,
解码后显示实时画面,并给出帧率与丢帧统计。
Rust + [Slint](https://slint.dev),不需要额外的系统库。
## 环境要求
- Rust(普通工具链——**不要**用固件那个 `esp` 工具链)
- 已在运行本固件的摄像头,且能在 2.4GHz WiFi 下访问到
## 目标架构
**编译前先设好。** 本目录位于固件源码树内部,Cargo 否则会继承
`../.cargo/config.toml` 去交叉编译 `xtensa-esp32s3-espidf`——报错会出现在依赖
深处,而不是一眼可见的地方。
`client/.cargo/config.toml` 因此把目标钉成宿主架构:
```toml
[build]
target = "aarch64-apple-darwin" # Intel Mac:x86_64-apple-darwin
# Linux:rustc -vV | grep host 的输出
```
查看你的:
```sh
rustc -vV | grep host
```
## 编译
```sh
cargo build --release
```
因为上面的 target 覆盖,二进制落在带架构名的目录里:
```sh
./target/aarch64-apple-darwin/release/cam-client
```
## 运行
```sh
./target/aarch64-apple-darwin/release/cam-client # 用下面的默认值
./target/aarch64-apple-darwin/release/cam-client 192.168.1.139
./target/aarch64-apple-darwin/release/cam-client 192.168.1.139 5600
```
```
用法: cam-client [CAMERA_IP] [PORT]
CAMERA_IP ESP32 摄像头地址(默认 192.168.1.139)
PORT UDP 视频端口 (默认 5600)
```
改 UI 时:
```sh
cargo run --release -- 192.168.1.139
```
## 界面读数
```
streaming 20.8 fps 10.1 kB per frame 35 ms between frames
640x480 shown 146 dropped 0 (0.0%) 1270 datagrams
```
丢帧率的存在是有理由的。这条链路**整帧丢弃而不重试**,所以单看帧率是有歧义的:
| 帧率 | 丢帧 | 判断 |
|---|---|---|
| 达到传感器上限 | 0 | 链路正常,瓶颈是传感器 |
| 远低于上限 | 有 | 瓶颈是网络 |
| 低 | 0,且显示 "no frames - camera?" | 板子没在推流 |
状态文字的颜色是同一信息的粗略版本:推流中绿色,否则琥珀色。
### 帧率是"计数"算出来的,不是"平均间隔"
不是对帧间隔取平均。一次 UI 轮询之间可能已完成好几帧,所以平均间隔量的是
**显示**的刷新快慢,而不是**相机**的出帧快慢——那曾把上限 22fps 的传感器报成
36fps。一个笃定的错误数字比没有数字更糟。
## 诊断
```sh
RUST_LOG=info ./target/aarch64-apple-darwin/release/cam-client 192.168.1.139
```
```
frames shown 146 | dropped 0 (0.0%) | datagrams 1270 | undecodable 0 | short 0
```
| 计数器 | 含义 |
|---|---|
| `frames shown` | 完整重组并交给界面的帧 |
| `dropped` | 因分片缺失而被放弃的帧 |
| `datagrams` | 能解析为分片的数据报 |
| `undecodable` | 收到了但解析不了的数据报 |
`undecodable` 非零意味着板子的数据报**到了但没看懂**——是协议或端口对不上,不是网络
问题。这两者很容易混淆,而这个计数器正是用来区分它们的。
## 排查
| 现象 | 原因 |
|---|---|
| 一直显示 `connecting to …` | 板子不在网上,或防火墙拦了入站 UDP 5600 |
| `no frames - camera?` | 板子停止推流:重启了,或掉线了 |
| 完全没有输出,也没有日志 | host triple 不对,见[目标架构](#目标架构) |
| 只有第一帧然后卡住 | 另一个客户端连在别的端口上;板子只推给最后宣告的那个 |
| 颜色错乱 | 通道顺序。当前构建用 `Rgba8Pixel`,另一个选项是 `Bgra8Pixel` |
| 编译在 `libc` / `memmap2` 报错 | 用的是 `esp` 工具链,或缺少 target triple 覆盖 |
## 目录结构
```
client/
├── Cargo.toml
├── build.rs 编译 ui/camera.slint
├── ui/camera.slint 窗口定义
└── src/
├── main.rs 命令行、线程编排、统计
└── receiver.rs socket、重组、JPEG 解码
```
线路格式在 [`../protocol`](../protocol),与固件共用,两端不会漂移。
## 线程模型
三个线程,且**没有任何 worker 线程碰 Slint 组件**:
| 线程 | 职责 |
|---|---|
| `udp-reader` | `recv_from`、解析、重组 |
| `jpeg-decode` | JPEG → RGBA |
| 主线程(事件循环) | RGBA → `slint::Image`、更新属性,每 30ms 一次 |
这是 Slint 的硬性约束而非偏好:组件只能在拥有它的线程上使用,`Weak<App>` 是
`!Send` 就是为了强制这一点。worker 线程去 upgrade 一个 weak handle 会拿到 `None`
然后什么都不做——现象和"界面卡死"完全一样。
解码单独一个线程,是因为一张 VGA 的 JPEG 要花几毫秒,放在收包线程里会挤进数据报
之间,造成**链路上本不存在**的丢帧。
## 可调项
| 项目 | 位置 | 说明 |
|---|---|---|
| 轮询间隔 | `src/main.rs` 的 `UI_POLL` | 30ms。更快只是白费 CPU,不会多出帧。 |
| 帧率统计窗口 | `FPS_WINDOW` | 2 秒。在 ~22fps 下更短的窗口会量化失真。 |
| 帧超时 | `receiver.rs` 的 `FRAME_TIMEOUT` | 500ms。一帧约 40ms,更慢的就是丢了。 |
| 宣告间隔 | `ANNOUNCE_INTERVAL` | 500ms。同时兼作保活。 |
| 主题 | `build.rs` | 默认 `fluent`;可试 `fluent-dark`、`material`、`native`。 |
-10
View File
@@ -1,10 +0,0 @@
//! Compiles the Slint markup and writes the generated Rust.
//!
//! Without this, `slint::include_modules!` has nothing to expand to: it reads the
//! path to the generated file from an environment variable that only this build
//! script sets.
fn main() {
let config = slint_build::CompilerConfiguration::new().with_style("fluent".to_owned());
slint_build::compile_with_config("ui/camera.slint", config).expect("compiling ui/camera.slint");
}
-310
View File
@@ -1,310 +0,0 @@
//! 绑定配置:这个客户端是什么身份,以及看哪块板子。
//!
//! 队伍的"选定后不可更改"落在 [`Config::camera_id`] 上:首次运行时从发现列表里选,
//! 结果写进配置文件,之后直接读,不再询问。
//!
//! 用 TOML 而不是 JSON,因为这个文件是**给人看的**——现场排查时要能一眼看懂改了什么。
//! 而它只有十来个字段,不值得为它引入整个序列化框架。
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
/// 客户端身份。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
/// 队伍终端:一块画面 + 自己那一边的血量。
Team,
/// 裁判终端:两块画面 + 双方血量。
Referee,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Role::Team => "team",
Role::Referee => "referee",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"team" => Some(Role::Team),
"referee" => Some(Role::Referee),
_ => None,
}
}
pub fn to_protocol(self) -> protocol::SubscribeRole {
match self {
Role::Team => protocol::SubscribeRole::Team,
Role::Referee => protocol::SubscribeRole::Referee,
}
}
}
/// 磁盘上的配置。
#[derive(Debug, Clone)]
pub struct Config {
/// 客户端身份。
pub role: Role,
/// 队伍端:自己属于哪一边,0 = 队伍一,1 = 队伍二。
///
/// 决定血量栏显示哪一边。裁判端两个都要,所以忽略这个值。
pub team: u8,
/// 绑定的板子 id(MAC 派生,重启不变)。
///
/// `None` 表示还没选过,首次运行会在界面上询问。
pub camera_id: Option<u32>,
/// 板子的 IP。DHCP 可能变,所以这只是发现阶段的一个提示。
pub camera_addr: Option<std::net::Ipv4Addr>,
}
impl Default for Config {
/// 默认当裁判机:它不需要任何配置就能用,是现场最省事的起点。
fn default() -> Self {
Self {
role: Role::Referee,
team: 0,
camera_id: None,
camera_addr: None,
}
}
}
impl Config {
/// 从磁盘读取。
///
/// 文件不存在时返回默认配置,这是正常路径——首次运行本来就没有配置。
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
log::info!("no config at {}; starting with defaults", path.display());
return Ok(Self::default());
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading {}", path.display()))?;
Self::parse(&text).with_context(|| format!("parsing {}", path.display()))
}
/// 写入磁盘。
///
/// 手写序列化而不是引入 toml crate:这个文件只有十来个字段,且格式必须与
/// [`Config::parse`] 严格对称——两份代码比一个依赖更容易在 review 时看出不匹配。
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating {}", dir.display()))?;
}
let mut out = String::new();
out.push_str("# cam-client 配置。\n");
out.push_str("#\n");
out.push_str("# role : team | referee\n");
out.push_str("# team = 一路画面 + 自己那一边的血量,需要绑定一块板子\n");
out.push_str("# referee = 两路画面 + 双方血量,不需要绑定\n");
out.push_str("# team : 0 = 队伍一, 1 = 队伍二(仅 team 端用)\n");
out.push_str("#\n");
out.push_str("# 绑定之后不要再改 camera_id:队伍端按它选画面,改了就换了视角。\n");
out.push_str("# camera_id 由板子的 MAC 派生,重启后不变,所以这个绑定是稳的。\n\n");
out.push_str(&format!("role = \"{}\"\n", self.role.as_str()));
out.push_str(&format!("team = {}\n", self.team));
if let Some(id) = self.camera_id {
out.push_str(&format!("camera_id = \"0x{id:08X}\"\n"));
}
if let Some(addr) = self.camera_addr {
out.push_str(&format!("camera_addr = \"{addr}\"\n"));
}
std::fs::write(path, out).with_context(|| format!("writing {}", path.display()))?;
log::info!("saved config to {}", path.display());
Ok(())
}
/// 解析 TOML 的一个子集。
///
/// 只支持 `key = value`、字符串、`整数`、十六进制字符串和注释——恰好是这个文件
/// 需要的全部,遇到不认识的形式会明确报错而不是静默忽略,因为静默忽略会让一个
/// 打错的配置看起来像是生效了。
fn parse(text: &str) -> Result<Self> {
let mut config = Config::default();
let mut role_seen = false;
for (lineno, raw) in text.lines().enumerate() {
let line = strip_comment(raw).trim();
if line.is_empty() {
continue;
}
let Some((key, value)) = line.split_once('=') else {
anyhow::bail!("line {}: expected `key = value`, got {line:?}", lineno + 1);
};
let key = key.trim();
let value = value.trim().trim_matches('"');
match key {
"role" => {
config.role = Role::parse(value).ok_or_else(|| {
anyhow::anyhow!(
"line {}: role must be `team` or `referee`, got {value:?}",
lineno + 1
)
})?;
role_seen = true;
}
"team" => {
config.team = value
.parse::<u8>()
.with_context(|| format!("line {}: team must be 0 or 1", lineno + 1))?;
}
"camera_id" => {
config.camera_id = Some(parse_id(value).with_context(|| {
format!("line {}: camera_id must look like 0x1A2B3C4D", lineno + 1)
})?);
}
"camera_addr" => {
config.camera_addr = Some(value.parse().with_context(|| {
format!("line {}: camera_addr must be an IPv4 address", lineno + 1)
})?);
}
_ => {
anyhow::bail!(
"line {}: unknown key {key:?}; refusing to guess",
lineno + 1
);
}
}
}
if !role_seen {
// 缺 role 时按裁判端处理:它是唯一无需配置就能工作的模式。
config.role = Role::Referee;
}
Ok(config)
}
}
/// 去掉 `#` 之后的内容。
fn strip_comment(line: &str) -> &str {
match line.find('#') {
Some(at) => &line[..at],
None => line,
}
}
/// 解析 `0x1A2B3C4D` 或十进制形式的板子 id。
fn parse_id(text: &str) -> Result<u32> {
let text = text.trim();
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
u32::from_str_radix(hex, 16).with_context(|| format!("{text:?} is not hex"))
} else {
text.parse::<u32>()
.with_context(|| format!("{text:?} is not a number"))
}
}
/// 默认配置文件位置。
///
/// 优先 `$XDG_CONFIG_HOME` 或 `~/.config`,与 Linux 上其它工具一致;macOS 上用同一个
/// 路径而不是 `~/Library/Application Support`,是为了让两台机器(裁判机和队伍机可能
/// 是不同系统)的配置结构一致。
pub fn default_config_path() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| {
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| {
PathBuf::from(".")
});
home.join(".config")
});
base.join("cam-client").join("config.toml")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_team_config() {
let config = Config::parse(
r#"
# 队伍一的机器
role = "team"
team = 1
camera_id = "0x3D4D8A9A"
"#,
)
.expect("parses");
assert_eq!(config.role, Role::Team);
assert_eq!(config.team, 1);
assert_eq!(config.camera_id, Some(0x3D4D_8A9A));
assert!(config.is_bound());
}
#[test]
fn referee_needs_no_binding() {
let config = Config::parse("role = \"referee\"").expect("parses");
assert_eq!(config.role, Role::Referee);
assert!(config.is_bound(), "the referee watches everything");
}
#[test]
fn a_team_without_a_camera_is_unbound() {
let config = Config::parse("role = \"team\"\nteam = 0").expect("parses");
assert_eq!(config.role, Role::Team);
assert!(!config.is_bound(), "must ask which camera to watch");
}
#[test]
fn roundtrips_through_save_and_load() {
let mut dir = std::env::temp_dir();
dir.push(format!("cam-client-test-{}", std::process::id()));
let path = dir.join("nested").join("config.toml");
let config = Config {
role: Role::Team,
team: 1,
camera_id: Some(0x1234_5678),
camera_addr: Some("192.168.1.50".parse().unwrap()),
};
config.save(&path).expect("saves");
let loaded = Config::load(&path).expect("loads");
assert_eq!(loaded.role, config.role);
assert_eq!(loaded.team, config.team);
assert_eq!(loaded.camera_id, config.camera_id);
assert_eq!(loaded.camera_addr, config.camera_addr);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_file_is_the_default_not_an_error() {
let config = Config::load(Path::new("/nonexistent/cam-client/config.toml"))
.expect("a missing config is a normal first run");
assert_eq!(config.role, Role::Referee);
}
#[test]
fn refuses_garbage_instead_of_ignoring_it() {
// 静默忽略一个打错的 key,会让配置看起来生效了。
assert!(Config::parse("rol = \"team\"").is_err(), "unknown key");
assert!(Config::parse("role = \"camera\"").is_err(), "bad role value");
assert!(Config::parse("role = \"team\"\nteam = nine").is_err(), "bad team");
assert!(Config::parse("just some text").is_err(), "not key = value");
}
}
-628
View File
@@ -1,628 +0,0 @@
//! ESP32 图传查看器。
//!
//! 两种身份,由 [`Role`] 决定:
//!
//! - **队伍端** —— 一块画面 + 自己那一边的血量。需要绑定一块板子(选定后不可更改)。
//! - **裁判端** —— 两块画面左右并排 + 双方血量。不需要绑定。
//!
//! 用法:
//!
//! ```text
//! cam-client 默认裁判端
//! cam-client --role team --team 1 队伍一
//! cam-client --camera-id 0x3D4D8A9A 绑定到某块板子
//! ```
//!
//! 绑定与身份存在配置文件里(默认 `~/.config/cam-client/config.toml`)。首次运行
//! 队伍端会列出发现的摄像头,选定后写入,之后不再询问。
//!
//! # 为什么血量是占位符
//!
//! 装甲板接口已经预留([`receiver::ArmorSource`] 与 `protocol::ArmorHealth`),但
//! 现在没有实现也没有数据源。界面显示占位符而不是 0——0 看起来像真值,现场看到会
//! 以为对方已经没血了。
mod config;
mod receiver;
use std::error::Error;
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use config::{Config, Role};
use receiver::{
decode_frame, ArmorSource, CameraInfo, DecodedFrame, NoArmorYet, Receiver,
SourceStats, ARMOR_BOARDS,
};
slint::include_modules!();
const USAGE: &str = "\
用法: cam-client [选项]
--role <team|referee> 身份。默认 referee(无需配置即可看两路)
--team <0|1> 自己属于哪一边,仅 team 端用
--camera-id <0xID> 绑定到某块板子(MAC 派生,重启不变)
--camera <IP> 向哪块板子发订阅(默认 255.255.255.255 广播)
--config <路径> 配置文件位置
--list 列出发现的摄像头后退出
-h, --help 显示这段帮助
";
/// 事件循环轮询新数据的间隔。
///
/// 不是每帧一次:显示不需要每秒重绘 22 次,更快只是让事件循环和接收路径抢 CPU。
const UI_POLL: Duration = Duration::from_millis(33);
/// 帧率统计窗口。
///
/// 按计数除以墙钟时间,而不是对帧间隔取平均。一次轮询之间可能完成多帧,所以平均间隔
/// 量的是**显示**的刷新快慢而不是**相机**的出帧快慢——那会把上限 22fps 的传感器
/// 报成 36fps。一个笃定的错误数字比没有数字更糟。
const FPS_WINDOW: Duration = Duration::from_secs(2);
/// 某一路的显示状态。
struct View {
/// 显示哪块板子(`None` = 还没有画面)。
sender_id: Option<u32>,
fps: f32,
window_started: Instant,
window_frames: u64,
reported: bool,
}
impl View {
fn new() -> Self {
Self {
sender_id: None,
fps: 0.0,
window_started: Instant::now(),
window_frames: 0,
reported: false,
}
}
/// 累计窗口内的帧数,算出帧率。
fn update_fps(&mut self, stats: &SourceStats) {
let window = self.window_started.elapsed();
if window < FPS_WINDOW {
return;
}
self.fps = (stats.frames_shown - self.window_frames) as f32 / window.as_secs_f32();
self.window_frames = stats.frames_shown;
self.window_started = Instant::now();
}
}
/// 解码后的画面缓存,供事件循环取用。
///
/// 解码线程只处理纯数据(`Vec<u8>` + 尺寸),`slint::Image` 的构造留在事件循环,
/// 因为 `slint::Image` 不是 `Send`。
type DecodedSlots = Arc<Mutex<Vec<(u32, DecodedFrame)>>>;
fn main() -> Result<(), Box<dyn Error>> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
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);
// 命令行覆盖配置文件,这样现场可以临时当另一台机器用而不用改文件。
let mut config = Config::load(&config_path)?;
if let Some(role) = args.role {
config.role = role;
}
if let Some(team) = args.team {
config.team = team;
}
if let Some(id) = args.camera_id {
config.camera_id = Some(id);
}
log::info!(
"role={} team={} camera_id={:?} config={}",
config.role.as_str(),
config.team,
config.camera_id,
config_path.display()
);
let rx = Arc::new(Receiver::start(
args.camera,
config.role.to_protocol(),
)?);
let discovery = rx.discovery();
let frames = rx.frames();
if args.list {
// 等几个广播周期,让列表有内容。
let socket = bind_socket()?;
let for_list = Arc::clone(&rx);
let handle = std::thread::spawn(move || for_list.run(socket));
std::thread::sleep(Duration::from_secs(5));
let cameras = discovery.online_cameras();
if cameras.is_empty() {
println!("没有发现摄像头。板子每 2 秒广播一次,再等等或检查网络。");
} else {
println!("发现 {} 块板子:", cameras.len());
for c in cameras {
println!(" 0x{:08X} {} (id 与 MAC 派生,重启不变)", c.id, c.addr);
}
}
rx.shutdown();
let _ = handle.join();
return Ok(());
}
// 血量目前没有数据源。接口已经就位,装甲板接上后换掉这一行即可。
let armor: Arc<dyn ArmorSource> = Arc::new(NoArmorYet);
let decoded: DecodedSlots = Arc::new(Mutex::new(Vec::new()));
// 接收线程。
{
let rx = Arc::clone(&rx);
std::thread::Builder::new()
.name("udp-reader".into())
.spawn(move || {
let Ok(socket) = bind_socket() else {
log::error!("could not bind the socket for the receive loop");
return;
};
rx.run(socket);
})?;
}
// 解码线程:一张 VGA 的 JPEG 要几毫秒,放在收包线程里会挤进数据报之间,造成
// 链路上本不存在的丢帧。
{
let frames = Arc::clone(&frames);
let decoded = Arc::clone(&decoded);
std::thread::Builder::new()
.name("jpeg-decode".into())
.spawn(move || {
loop {
if frames.pending_senders().is_empty() {
std::thread::sleep(Duration::from_millis(5));
continue;
}
for sender_id in frames.pending_senders() {
let Some(frame) = frames.take(sender_id) else {
continue;
};
match decode_frame(&frame.data) {
Some(pixels) => {
let mut slot = decoded.lock().expect("decoded mutex poisoned");
// 同一路只保留最新一张,避免解码已经过时的画面。
slot.retain(|(id, _)| *id != sender_id);
slot.push((sender_id, pixels));
}
None => log::warn!(
"frame {} from 0x{:08X} is not a decodable JPEG ({} bytes)",
frame.frame_id,
frame.sender_id,
frame.data.len()
),
}
}
}
})?;
}
let timer = slint::Timer::default();
// 每个身份一个闭包,而不是一个闭包里再 match 身份。
//
// 两种身份是两个不同的组件类型(TeamApp / RefereeApp),没有共同的超类可以
// 在一个闭包里统一调用 set_frame/set_frame_a。写成两个分支后每边都拿到具体
// 类型,属性赋值直接调用,不再需要 match 身份——那种 match 会让编译器在每个
// 赋值点都检查一遍身份,而它本来就是编译期就定死的。
match config.role {
Role::Team => {
let ui = TeamApp::new()?;
ui.show()?;
let weak = ui.as_weak();
let discovery = Arc::clone(&discovery);
let decoded = Arc::clone(&decoded);
let armor = Arc::clone(&armor);
let config_path = config_path.clone();
let team = config.team;
let bound = config.camera_id;
let mut view = View::new();
// 绑定回调与刷新定时器都要读这份列表,所以用锁共享,而不是让其中一个
// 把 `choices` 移走。
let choices: Arc<Mutex<Vec<CameraChoice>>> = Arc::new(Mutex::new(Vec::new()));
// 绑定完成时写配置。"选定不可更改"就落在这里:此后配置里已有
// camera_id,界面不再出现选择页。
let choices_for_pick = Arc::clone(&choices);
let pick_handle = ui.on_pick(move |index| {
let Ok(index) = usize::try_from(index) else {
return;
};
let picked = choices_for_pick
.lock()
.expect("choices mutex poisoned")
.get(index)
.map(|c| c.id_text.to_string());
let Some(id_text) = picked else {
return;
};
let Some(id) = parse_id(&id_text).ok() else {
log::error!("could not read back the selected id {id_text:?}");
return;
};
let saved = Config {
role: Role::Team,
team,
camera_id: Some(id),
camera_addr: None,
};
if let Err(err) = saved.save(&config_path) {
log::error!("could not save the binding: {err}");
log::error!("画面仍会显示,但下次启动要重新选择");
} else {
log::info!("bound to camera 0x{id:08X}; saved in the config");
}
});
let choices_for_poll = Arc::clone(&choices);
timer.start(slint::TimerMode::Repeated, UI_POLL, move || {
let Some(ui) = weak.upgrade() else {
return;
};
let cameras = discovery.online_cameras();
// 未绑定:只显示选择界面。
let Some(bound) = bound else {
let updated: Vec<CameraChoice> = cameras
.iter()
.map(|c| CameraChoice {
id_text: format!("0x{:08X}", c.id).into(),
addr_text: format!("{} ({:?})", c.addr, c.silence()).into(),
online: c.is_online(),
})
.collect();
*choices_for_poll.lock().expect("choices mutex poisoned") = updated.clone();
ui.set_choices(std::rc::Rc::new(slint::VecModel::from(updated)).into());
ui.set_choosing(true);
return;
};
ui.set_choosing(false);
if view.sender_id != Some(bound) {
view = View::new();
view.sender_id = Some(bound);
}
if let Some(image) = take_decoded(&decoded, bound) {
ui.set_frame(image);
}
let stats = discovery.stats(bound);
view.update_fps(&stats);
if !view.reported && stats.frames_shown > 0 {
log_diagnostics(&stats, bound);
view.reported = true;
}
ui.set_stats(render_camera_stats(&cameras, bound, &stats, view.fps));
ui.set_health(render_health(team, armor.as_ref()));
});
// 绑定回调必须活到窗口关闭。CallbackHandle 是 Copy,但 Drop 会注销
// 回调,所以必须真的把它泄漏掉——之前用 `mem::forget` 编译器提示它对
// Copy 类型无效,那确实无效:第一次点击后回调就被注销了,第二次点击
// 什么也不会发生。
std::boxed::Box::leak(Box::new(pick_handle));
}
Role::Referee => {
let ui = RefereeApp::new()?;
ui.show()?;
let weak = ui.as_weak();
let discovery = Arc::clone(&discovery);
let decoded = Arc::clone(&decoded);
let armor = Arc::clone(&armor);
let mut views: Vec<View> = (0..2).map(|_| View::new()).collect();
timer.start(slint::TimerMode::Repeated, UI_POLL, move || {
let Some(ui) = weak.upgrade() else {
return;
};
let cameras = discovery.online_cameras();
// 两路按 id 顺序填槽。槽位保持稳定(不会因为某块板子临时离线就重新
// 洗牌),否则画面会左右跳动。
let ids: Vec<u32> = cameras.iter().map(|c| c.id).take(2).collect();
for (i, view) in views.iter_mut().enumerate() {
let wanted = ids.get(i).copied();
if view.sender_id != wanted {
*view = View::new();
view.sender_id = wanted;
}
}
for (i, view) in views.iter_mut().enumerate() {
let Some(sender_id) = view.sender_id else {
continue;
};
if let Some(image) = take_decoded(&decoded, sender_id) {
if i == 0 {
ui.set_frame_a(image);
} else {
ui.set_frame_b(image);
}
}
let stats = discovery.stats(sender_id);
view.update_fps(&stats);
if !view.reported && stats.frames_shown > 0 {
log_diagnostics(&stats, sender_id);
view.reported = true;
}
let view_stats = render_camera_stats(&cameras, sender_id, &stats, view.fps);
if i == 0 {
ui.set_stats_a(view_stats);
} else {
ui.set_stats_b(view_stats);
}
}
ui.set_health_a(render_health(0, armor.as_ref()));
ui.set_health_b(render_health(1, armor.as_ref()));
});
}
}
// Timer 一旦被 drop 就停止,所以要活到进程结束。
let _timer = Box::leak(Box::new(timer));
slint::run_event_loop()?;
rx.shutdown();
Ok(())
}
/// 从解码缓存里取出某一路的最新画面。
///
/// 解码线程只处理纯数据,`slint::Image` 的构造留在这里——`slint::Image` 不是
/// `Send`,跨不了线程。
fn take_decoded(
decoded: &DecodedSlots,
sender_id: u32,
) -> Option<slint::Image> {
let mut slot = decoded.lock().expect("decoded mutex poisoned");
let pos = slot.iter().position(|(id, _)| *id == sender_id)?;
let (_, pixels) = slot.remove(pos);
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
&pixels.rgba,
pixels.width,
pixels.height,
);
Some(slint::Image::from_rgba8(buffer))
}
/// 命令行参数。
struct Args {
role: Option<Role>,
team: Option<u8>,
camera_id: Option<u32>,
camera: Ipv4Addr,
config: Option<PathBuf>,
list: bool,
help: bool,
}
impl Args {
fn parse(argv: impl Iterator<Item = String>) -> Result<Self, Box<dyn Error>> {
let mut args = Args {
role: None,
team: None,
camera_id: None,
camera: protocol::BROADCAST_ADDR,
config: None,
list: false,
help: false,
};
let mut it = argv.peekable();
while let Some(arg) = it.next() {
match arg.as_str() {
"-h" | "--help" => args.help = true,
"--list" => args.list = true,
"--role" => {
let v = it.next().ok_or("--role needs a value")?;
args.role =
Some(Role::parse(&v).ok_or("role must be `team` or `referee`")?);
}
"--team" => {
let v = it.next().ok_or("--team needs a value")?;
args.team = Some(v.parse().map_err(|_| "--team must be 0 or 1")?);
}
"--camera-id" => {
let v = it.next().ok_or("--camera-id needs a value")?;
args.camera_id = Some(parse_id(&v)?);
}
"--camera" => {
let v = it.next().ok_or("--camera needs a value")?;
args.camera = v
.parse()
.map_err(|_| "--camera must be an IPv4 address")?;
}
"--config" => {
args.config = Some(PathBuf::from(it.next().ok_or("--config needs a path")?));
}
other => {
return Err(format!("unknown argument {other:?}\n\n{USAGE}").into());
}
}
}
Ok(args)
}
}
/// 绑定 UDP 接收 socket。
///
/// 固定端口(而非临时端口)是必须的:板子从订阅的**源端口**学到推流目标,而这个
/// socket 同时承担收包。
fn bind_socket() -> std::io::Result<std::net::UdpSocket> {
let socket = std::net::UdpSocket::bind(std::net::SocketAddrV4::new(
Ipv4Addr::UNSPECIFIED,
protocol::DEFAULT_PORT,
))?;
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
// 广播是发现的前提:板子靠它宣告存在,客户端靠它发起订阅。
let _ = socket.set_broadcast(true);
Ok(socket)
}
/// 解析 `0x1A2B3C4D` 或十进制形式的板子 id。
fn parse_id(text: &str) -> Result<u32, Box<dyn Error>> {
let text = text.trim();
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
Ok(u32::from_str_radix(hex, 16)?)
} else {
Ok(text.parse()?)
}
}
/// 一次性诊断,在某一路第一次出画面时记录。
///
/// 不再单列"无法解码"计数:那是链路正常但内容坏了的情形,会在解码线程里逐帧告警,
/// 汇总在这里反而会把它和普通的丢帧混在一起。
fn log_diagnostics(stats: &SourceStats, sender_id: u32) {
log::info!(
"camera 0x{sender_id:08X}: {} frames shown | {} dropped ({:.1}%) | \
{} datagrams | {}x{} {} bytes",
stats.frames_shown,
stats.frames_dropped,
stats.loss_percent(),
stats.datagrams,
stats.width,
stats.height,
stats.last_frame_len,
);
}
/// 把某一路的统计渲染成界面结构。
fn render_camera_stats(
cameras: &[CameraInfo],
sender_id: u32,
stats: &SourceStats,
fps: f32,
) -> CameraStats {
let online = cameras
.iter()
.find(|c| c.id == sender_id)
.map(CameraInfo::is_online)
.unwrap_or(false);
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
// 比让人以为板子挂了诚实。
let status = if stats.frames_shown == 0 {
if online {
"已连接,等待画面…".to_string()
} else {
"离线".to_string()
}
} else {
format!("0x{:08X}", sender_id)
};
CameraStats {
online,
status_text: status.into(),
fps: format!("{fps:.1}").into(),
resolution: format!("{}x{}", stats.width, stats.height).into(),
frame_bytes: format!("{:.1} kB", stats.last_frame_len as f32 / 1024.0).into(),
frames_shown: stats.frames_shown.to_string().into(),
frames_dropped: stats.frames_dropped.to_string().into(),
loss_percent: format!("{:.1}%", stats.loss_percent()).into(),
datagrams: stats.datagrams.to_string().into(),
gap_ms: stats
.last_interval
.map(|d| format!("{:.0} ms", d.as_secs_f32() * 1000.0))
.unwrap_or_else(|| "-".to_string())
.into(),
}
}
/// 渲染一边的血量。
///
/// 现在永远走 `available == false` 的分支,因为还没有数据源。装甲板接上后
/// [`ArmorSource::health`] 返回 `Some`,界面自动显示真实数值,这里不用改。
///
/// 没有数据时用占位符而不是 0:0 看起来像真值,现场看到会以为对方已经没血了。
fn render_health(team: u8, armor: &dyn ArmorSource) -> TeamHealth {
let name = match team {
0 => "队伍一",
_ => "队伍二",
};
match armor.health(team) {
Some(hps) => TeamHealth {
team_name: format!("{name} · 装甲板").into(),
available: true,
placeholder: String::new().into(),
hps: std::rc::Rc::new(slint::VecModel::from(
hps.iter().map(|hp| *hp as i32).collect::<Vec<i32>>(),
))
.into(),
},
None => TeamHealth {
team_name: name.into(),
available: false,
placeholder:
"装甲板未接入:血量接口已预留(protocol::ArmorHealth),当前无数据源".into(),
// 依然给出 4 个格子:界面用 `available` 决定颜色,所以空数组会让占位
// 框整个消失,看起来像布局出错,而不是像"还没有数据"。
hps: std::rc::Rc::new(slint::VecModel::from(vec![0i32; ARMOR_BOARDS])).into(),
},
}
}
-458
View File
@@ -1,458 +0,0 @@
//! UDP 接收、发现、多路帧重组。
//!
//! # 多路
//!
//! 裁判终端同时收两块图传板,队伍终端也会收到两块(只是界面上只显示一块)。
//! 所以重组状态**必须按发送方分开**——把两块板的帧塞进同一个重组器,会让它们
//! 的 `frame_id` 互相顶掉,两路都出不来。
//!
//! # 发现与订阅
//!
//! 两个方向各司其职:
//!
//! - 板子每 2 秒广播一次 [`protocol::MsgType::Hello`],客户端被动收到就知道有谁在线。
//! - 客户端广播 [`protocol::MsgType::Subscribe`],板子收到后开始定向推流。
//!
//! 板子靠**源地址**记住客户端,所以客户端只要周期性续订就能维持推流,不存在
//! 任何超时淘汰的竞态。
//!
//! # 丢帧策略
//!
//! 缺了分片的帧整个丢弃、下一帧顶上,不重传也不尝试补洞。对实时画面这是对的:
//! 等重传补齐时,那一帧早就不新鲜了。这个取舍的代价是丢帧率必须摆在界面上,否则
//! "画面卡"没法区分是链路问题还是传感器问题。
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
}
}
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;
}
}
/// 每一路的最新帧,等着被解码。
///
/// 每路只保留最新的一张:在 22fps 下把每一帧都排队意味着内存里压着几十张 JPEG,
/// 然后解码那些几毫秒后就被下一帧顶掉的画面——而这正是这条链路要消除的延迟。
#[derive(Default)]
pub struct LatestFrames {
inner: Mutex<HashMap<u32, protocol::Frame>>,
}
impl LatestFrames {
pub fn put(&self, frame: protocol::Frame) {
self.inner
.lock()
.expect("latest-frames mutex poisoned")
.insert(frame.sender_id, frame);
}
/// 取走某一路的待处理帧。
pub fn take(&self, sender_id: u32) -> Option<protocol::Frame> {
self.inner
.lock()
.expect("latest-frames mutex poisoned")
.remove(&sender_id)
}
/// 列出所有有待处理帧的板子 id。
pub fn pending_senders(&self) -> Vec<u32> {
self.inner
.lock()
.expect("latest-frames mutex poisoned")
.keys()
.copied()
.collect()
}
}
/// 所有板子的共享状态,跨线程只读。
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 note_hello(&self, id: u32, addr: SocketAddrV4, firmware: u16) {
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
cameras
.entry(id)
.and_modify(|c| {
c.addr = addr;
c.last_seen = Instant::now();
c.firmware = firmware;
})
.or_insert(CameraInfo {
id,
addr,
firmware,
last_seen: Instant::now(),
});
}
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;
/// 接收器。
///
/// socket 在这里创建而不是在接收线程里,这样"端口被占用"会立刻报错,而不是被线程
/// 吞掉、表现为一个永远不出画面的空窗口。
pub struct Receiver {
discovery: Arc<Discovery>,
frames: Arc<LatestFrames>,
role: SubscribeRole,
camera: Ipv4Addr,
stop: Arc<AtomicBool>,
}
impl Receiver {
/// 绑定视频端口并准备好发现。
///
/// 绑定固定端口(而非临时端口)是必须的:板子是从订阅的**源端口**学到推流目标的,
/// 临时端口也能工作,但固定端口让三台客户端行为一致,也方便抓包排查。
pub fn start(camera: Ipv4Addr, role: SubscribeRole) -> std::io::Result<Self> {
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 {
discovery: Arc::new(Discovery::new()),
frames: Arc::new(LatestFrames::default()),
role,
camera,
stop: Arc::new(AtomicBool::new(false)),
})
}
pub fn discovery(&self) -> Arc<Discovery> {
Arc::clone(&self.discovery)
}
pub fn frames(&self) -> Arc<LatestFrames> {
Arc::clone(&self.frames)
}
/// 运行接收循环,直到 [`Receiver::shutdown`]。
///
/// 阻塞,所以放在单独的线程上。
pub fn run(&self, socket: UdpSocket) {
let target = SocketAddrV4::new(self.camera, DEFAULT_PORT);
let subscribe = Subscribe {
role: self.role,
client_id: CLIENT_ID,
};
let subscribe_bytes = subscribe.encode();
// 每个发送方一个重组器:两块板的 frame_id 各自独立,混在一起会互相顶掉。
let mut assemblers: HashMap<u32, FrameAssembler> = HashMap::new();
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 秒收不到就认为我们走了。
if last_subscribe.elapsed() >= SUBSCRIBE_INTERVAL {
if let Err(err) = socket.send_to(&subscribe_bytes, target) {
log::warn!("could not subscribe at {target}: {err}");
}
last_subscribe = Instant::now();
}
match socket.recv_from(&mut buf) {
Ok((n, from)) => {
self.handle_datagram(&buf[..n], from, &mut assemblers);
}
Err(err)
if err.kind() == ErrorKind::WouldBlock
|| err.kind() == ErrorKind::TimedOut =>
{
// 帧之间的常态。读超时正是让退出能及时响应的原因。
}
Err(err) => {
log::warn!("recv failed: {err}");
thread::sleep(Duration::from_millis(100));
}
}
// 放弃等太久的半截帧。否则板子在帧中途死掉时,重组器会一直留着它。
for (id, asm) in assemblers.iter_mut() {
if asm.expire().is_some() {
self.discovery.count(*id, |s| s.frames_dropped += 1);
}
}
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");
}
/// 处理一个数据报。
fn handle_datagram(
&self,
bytes: &[u8],
from: std::net::SocketAddr,
assemblers: &mut HashMap<u32, FrameAssembler>,
) {
if bytes.len() < HEADER_LEN {
return;
}
// 板子的在线通告。注意这里不看来源端口:广播来的源端口没有意义,身份由
// 头里的 sender_id 决定。
if let Some(hello) = protocol::Hello::decode(bytes) {
if let SocketAddr::V4(v4) = from {
let is_new = !self.discovery.cameras().iter().any(|c| c.id == hello.sender_id);
self.discovery.note_hello(hello.sender_id, v4, hello.firmware);
if is_new {
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;
match assemblers
.entry(id)
.or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT))
.push(frag)
{
Ok(frame) => {
self.discovery.count(id, |s| s.datagrams += 1);
self.discovery
.note_frame(id, frame.width, frame.height, frame.data.len());
self.frames.put(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 shutdown(&self) {
self.stop.store(true, Ordering::Relaxed);
}
}
/// 解码后的画面。
pub struct DecodedFrame {
pub rgba: Vec<u8>,
pub width: u32,
pub height: u32,
}
/// 解码一张 JPEG。
///
/// 返回 `None` 表示字节不是可解码的图像——协议层挡不住这种:它只检查分片结构,
/// 不检查 JPEG 本身是否有效。
pub fn decode_frame(jpeg: &[u8]) -> Option<DecodedFrame> {
let img = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg).ok()?;
let rgba = img.to_rgba8();
Some(DecodedFrame {
width: rgba.width(),
height: rgba.height(),
rgba: rgba.into_raw(),
})
}
/// 装甲板血量的来源。
///
/// **预留,当前没有实现也没有数据源。** 血量显示为占位符。
///
/// 接口和端口都已就位([`protocol::ARMOR_PORT`]、[`protocol::ArmorHealth`]):装甲板
/// 接上以后往那个端口发消息即可,裁判界面与其它代码都不用改。
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
}
}
-416
View File
@@ -1,416 +0,0 @@
// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。
/* 一块画面的读数。裁判端有两块,队伍端一块。 */
export struct CameraStats {
// 在线状态
online: bool,
status-text: string,
// 实时测量
fps: string,
resolution: string,
frame-bytes: string,
// 累计
frames-shown: string,
frames-dropped: string,
loss-percent: string,
datagrams: string,
// 帧间隔,用于把"卡"和"帧率低"区分开
gap-ms: string,
}
/* 一边的血量(4 块装甲板)。
*
* `available` 为 false 时界面显示占位符而不是 0:0 是一个看起来像真值的假值,
* 现场看到 0 会以为对方已经没血了。 */
export struct TeamHealth {
team-name: string,
available: bool,
// 占位符文案
placeholder: string,
// 每块装甲板的血量;available 时有效
hps: [int],
}
/* 首次运行时的摄像头选择。
*
* 队伍端需要绑定一块板子(选定后不可更改),裁判端不需要。 */
export struct CameraChoice {
id-text: string,
addr-text: string,
online: bool,
}
// ---------------------------------------------------------------- 队伍端
/*
* 队伍终端:一块画面 + 自己那一边的血量。
*
* 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省
* 画面空间给视频,一边要均分),共用一套再用 visible 切换会让每处改动都要考虑两种
* 情况。 */
export component TeamApp inherits Window {
title: "cam-client — 队伍";
preferred-width: 1000px;
preferred-height: 720px;
in property <image> frame;
in property <CameraStats> stats;
in property <TeamHealth> health;
// 选择绑定时显示,其余时候隐藏
in property <[CameraChoice]> choices;
in property <bool> choosing;
// 选中了列表里的第 N 项。
//
// 绑定在这里一次完成:Rust 侧收到回调就写配置文件,所以"选定"当场生效,而不是
// "下次启动才生效"。索引而不是 id,是为了让 Rust 侧回到自己那份列表里取完整
// 信息——.slint 里的模型只是给界面看的。
callback pick(int);
background: #101014;
// 绑定完成前只显示选择界面:没有画面可显示,显示一个空窗口更让人困惑。
if !root.choosing: VerticalLayout {
spacing: 0;
HorizontalLayout {
padding: 0;
spacing: 0;
Rectangle {
background: #0a0a0d;
vertical-stretch: 1;
frame := Image {
source: root.frame;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 底部一条:左边自己这边的血量,右边这一路的统计。
Rectangle {
background: #1c1c22;
height: 72px;
HorizontalLayout {
padding: 14px;
spacing: 24px;
VerticalLayout {
padding: 0;
spacing: 2px;
horizontal-stretch: 1;
Text {
text: root.health.team-name;
color: #e8e8ec;
font-size: 14px;
font-weight: 600;
}
// 装甲板还没接上,所以是一排占位而不是四个数字。
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health.hps: Rectangle {
width: 34px;
height: 12px;
border-width: 1px;
border-color: root.health.available ? #4a4a55 : #33333c;
background: root.health.available ? #7ddc7d : #1a1a20;
}
}
}
VerticalLayout {
padding: 0;
spacing: 2px;
preferred-width: 320px;
Text {
text: root.stats.status-text + " " + root.stats.fps + " fps "
+ root.stats.resolution + " " + root.stats.frame-bytes;
color: root.stats.online ? #e8e8ec : #e0a060;
font-size: 13px;
}
Text {
text: "shown " + root.stats.frames-shown
+ " dropped " + root.stats.frames-dropped
+ " (" + root.stats.loss-percent + ")"
+ " gap " + root.stats.gap-ms;
color: root.stats.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
}
}
// 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。
Rectangle {
background: #14141a;
height: 26px;
Text {
text: root.health.placeholder;
color: #6a6a75;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 选择界面
if root.choosing: VerticalLayout {
padding: 32px;
spacing: 16px;
Text {
text: "选择要观看的摄像头";
color: #e8e8ec;
font-size: 20px;
font-weight: 600;
}
Text {
text: "选定后不能更改。这里的选择会写入配置文件,重启后仍然生效。";
color: #8a8a95;
font-size: 13px;
wrap: word-wrap;
}
Rectangle { height: 8px; background: transparent; }
// TouchArea 而不是给 Rectangle 挂 callback:Rectangle 是基础元素,没有
// 点击回调;TouchArea 才是带交互的元素。
for choice[index] in root.choices: Rectangle {
background: choice.online ? #1c1c22 : #16161b;
border-width: 1px;
border-color: #2a2a33;
height: 56px;
TouchArea {
clicked => {
root.pick(index);
}
}
HorizontalLayout {
padding: 14px;
spacing: 16px;
Text {
text: choice.id-text;
color: #e8e8ec;
font-size: 14px;
font-weight: 600;
vertical-alignment: center;
horizontal-alignment: left;
}
Text {
text: choice.addr-text;
color: #8a8a95;
font-size: 12px;
vertical-alignment: center;
horizontal-alignment: left;
horizontal-stretch: 1;
}
Text {
text: choice.online ? "在线" : "离线";
color: choice.online ? #7ddc7d : #8a8a95;
font-size: 12px;
vertical-alignment: center;
}
}
}
// 一块都没发现时给出可操作的提示,而不是一个空列表。
if root.choices.length == 0: Rectangle {
background: #1a1418;
border-width: 1px;
border-color: #4a2a35;
height: 64px;
Text {
text: "还没有发现任何摄像头。请确认板子已上电、已连上这个网络,\n然后稍候——板子每 2 秒广播一次在线状态。";
color: #e0a060;
font-size: 13px;
wrap: word-wrap;
vertical-alignment: center;
}
}
}
}
// ---------------------------------------------------------------- 裁判端
/* 裁判终端:两块画面左右并排 + 双方血量。
*
* 与队伍端分开:这里的核心诉求是"一眼比出两边的状态",所以两块画面等宽、血量
* 并排对称。 */
export component RefereeApp inherits Window {
title: "cam-client — 裁判系统";
preferred-width: 1400px;
preferred-height: 800px;
in property <image> frame-a;
in property <image> frame-b;
in property <CameraStats> stats-a;
in property <CameraStats> stats-b;
in property <TeamHealth> health-a;
in property <TeamHealth> health-b;
background: #101014;
VerticalLayout {
spacing: 0;
// 两路画面等宽并排,各占一半。
HorizontalLayout {
padding: 0;
spacing: 2px;
Rectangle {
background: #0a0a0d;
vertical-stretch: 1;
horizontal-stretch: 1;
frame-a := Image {
source: root.frame-a;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
background: #0a0a0d;
vertical-stretch: 1;
horizontal-stretch: 1;
frame-b := Image {
source: root.frame-b;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 每路画面下方的队伍条:名字、血量、统计。
HorizontalLayout {
padding: 0;
spacing: 2px;
Rectangle {
background: #1c1c22;
height: 78px;
horizontal-stretch: 1;
VerticalLayout {
padding: 10px;
spacing: 4px;
Text {
text: root.health-a.team-name + " " + root.stats-a.status-text
+ " " + root.stats-a.fps + " fps " + root.stats-a.resolution;
color: root.stats-a.online ? #e8e8ec : #e0a060;
font-size: 14px;
font-weight: 600;
}
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health-a.hps: Rectangle {
width: 40px;
height: 14px;
border-width: 1px;
border-color: root.health-a.available ? #4a4a55 : #33333c;
background: root.health-a.available ? #7ddc7d : #1a1a20;
}
}
Text {
text: "shown " + root.stats-a.frames-shown
+ " dropped " + root.stats-a.frames-dropped
+ " (" + root.stats-a.loss-percent + ")"
+ " gap " + root.stats-a.gap-ms;
color: root.stats-a.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
}
Rectangle {
background: #1c1c22;
height: 78px;
horizontal-stretch: 1;
VerticalLayout {
padding: 10px;
spacing: 4px;
Text {
text: root.health-b.team-name + " " + root.stats-b.status-text
+ " " + root.stats-b.fps + " fps " + root.stats-b.resolution;
color: root.stats-b.online ? #e8e8ec : #e0a060;
font-size: 14px;
font-weight: 600;
}
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health-b.hps: Rectangle {
width: 40px;
height: 14px;
border-width: 1px;
border-color: root.health-b.available ? #4a4a55 : #33333c;
background: root.health-b.available ? #7ddc7d : #1a1a20;
}
}
Text {
text: "shown " + root.stats-b.frames-shown
+ " dropped " + root.stats-b.frames-dropped
+ " (" + root.stats-b.loss-percent + ")"
+ " gap " + root.stats-b.gap-ms;
color: root.stats-b.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
}
}
// 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。
Rectangle {
background: #14141a;
height: 26px;
Text {
text: root.health-a.placeholder;
color: #6a6a75;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ idf_component_register(SRCS "cam_wrapper.c"
INCLUDE_DIRS "include"
# esp_timer for the frame-rate probe; the camera component
# pulls in the LCD_CAM driver and the FreeRTOS task API.
REQUIRES esp32-camera esp_timer)
REQUIRES esp32-camera esp_timer esp_driver_tsens)
+178 -4
View File
@@ -1,18 +1,20 @@
/* Implementation of the cam_wrapper shim around esp32-camera. See cam_wrapper.h. */
#include "cam_wrapper.h"
#include "driver/temperature_sensor.h"
#include <string.h>
#include "esp_log.h"
#include "esp_timer.h"
static void cam_wrapper_measure_fps(cam_wrapper_sensor_t sensor);
static float cam_wrapper_measure_fps(cam_wrapper_sensor_t sensor);
static const char *TAG = "cam_wrapper";
static sensor_t *s_sensor;
static char s_sensor_name[16] = "unknown";
static temperature_sensor_handle_t s_temperature;
static void fill_config(const cam_wrapper_config_t *in, camera_config_t *out)
{
@@ -134,7 +136,7 @@ esp_err_t cam_wrapper_init(const cam_wrapper_config_t *config, cam_wrapper_senso
* two is dropping frames. */
#define PROBE_FRAMES 20
static void cam_wrapper_measure_fps(cam_wrapper_sensor_t sensor)
static float cam_wrapper_measure_fps(cam_wrapper_sensor_t sensor)
{
uint32_t total = 0;
int64_t start = esp_timer_get_time();
@@ -158,7 +160,7 @@ static void cam_wrapper_measure_fps(cam_wrapper_sensor_t sensor)
if (taken == 0 || elapsed_us <= 0) {
ESP_LOGW(TAG, "fps probe: got no frames");
return;
return 0;
}
double fps = (double)taken * 1000000.0 / (double)elapsed_us;
@@ -167,6 +169,159 @@ static void cam_wrapper_measure_fps(cam_wrapper_sensor_t sensor)
"(sensor ceiling, no network involved)",
taken, (long long)(elapsed_us / 1000), fps,
(unsigned)(total / (uint32_t)taken));
return (float)fps;
}
esp_err_t cam_wrapper_set_grayscale(int enabled)
{
if (!s_sensor || !s_sensor->set_special_effect) return ESP_ERR_NOT_SUPPORTED;
int rc = s_sensor->set_special_effect(s_sensor, enabled ? 2 : 0);
if (rc != 0) return ESP_FAIL;
ESP_LOGI(TAG, "sensor JPEG colour: %s", enabled ? "grayscale" : "colour");
return ESP_OK;
}
esp_err_t cam_wrapper_set_quality(unsigned quality)
{
if (quality > 63) return ESP_ERR_INVALID_ARG;
if (!s_sensor || !s_sensor->set_quality) return ESP_ERR_NOT_SUPPORTED;
return s_sensor->set_quality(s_sensor, (int)quality) == 0 ? ESP_OK : ESP_FAIL;
}
esp_err_t cam_wrapper_set_exposure_bias(int ev)
{
if (ev < -2 || ev > 0) return ESP_ERR_INVALID_ARG;
if (!s_sensor || s_sensor->id.PID != OV5640_PID)
return ev == 0 ? ESP_OK : ESP_ERR_NOT_SUPPORTED;
/* Driver target = (level+5)*10+5, default 55. Targets 25 and 15
* are ~-1.14 and ~-1.87 EV, not literal driver levels -1 and -2. */
int level = ev == -1 ? -3 : ev == -2 ? -4 : 0;
if (s_sensor->set_exposure_ctrl(s_sensor, 1) != 0
|| s_sensor->set_gain_ctrl(s_sensor, 1) != 0
|| s_sensor->set_ae_level(s_sensor, level) != 0) return ESP_FAIL;
ESP_LOGI(TAG, "OV5640 auto exposure bias: requested %d EV, driver level %d, target %d / 55",
ev, level, (level + 5) * 10 + 5);
return ESP_OK;
}
int cam_wrapper_exposure_lines(void)
{
if (!s_sensor || s_sensor->id.PID != OV5640_PID) return -1;
int raw = s_sensor->get_reg(s_sensor, 0x3500, 0xfffff);
return raw < 0 ? -1 : raw >> 4;
}
int cam_wrapper_gain_raw(void)
{
if (!s_sensor || s_sensor->id.PID != OV5640_PID) return -1;
return s_sensor->get_reg(s_sensor, 0x350a, 0x03ff);
}
esp_err_t cam_wrapper_set_shutter_lines(unsigned lines)
{
if (!s_sensor || s_sensor->id.PID != OV5640_PID)
return lines == 0 ? ESP_OK : ESP_ERR_NOT_SUPPORTED;
int vts = s_sensor->get_reg(s_sensor, 0x380e, 0xffff);
if (vts <= 0 || lines > (unsigned)vts) return ESP_ERR_INVALID_ARG;
int previous = cam_wrapper_exposure_lines();
if (s_sensor->set_exposure_ctrl(s_sensor, lines == 0) != 0
|| s_sensor->set_gain_ctrl(s_sensor, 1) != 0) return ESP_FAIL;
if (lines && (s_sensor->set_aec_value(s_sensor, (int)lines) != 0
|| cam_wrapper_exposure_lines() != (int)lines)) return ESP_FAIL;
ESP_LOGI(TAG, "OV5640 shutter: previous %d lines, requested %u lines (0=auto), AGC enabled",
previous, lines);
return ESP_OK;
}
esp_err_t cam_wrapper_set_horizontal_timing(unsigned hts)
{
if (!hts) return ESP_OK;
if (!s_sensor || s_sensor->id.PID != OV5640_PID) return ESP_ERR_NOT_SUPPORTED;
int start = s_sensor->get_reg(s_sensor, 0x3800, 0x0fff);
int end = s_sensor->get_reg(s_sensor, 0x3804, 0x0fff);
int previous = s_sensor->get_reg(s_sensor, 0x380c, 0x0fff);
if (start < 0 || end < start || previous <= 0) return ESP_FAIL;
unsigned active = (unsigned)(end - start + 1) / (s_sensor->status.binning ? 2 : 1);
/* Preserve the active readout window and a conservative blanking margin.
* This is a guard against invalid geometry, not a stability guarantee. */
if ((hts & 1) || hts < active + 64 || hts > 4095) return ESP_ERR_INVALID_ARG;
/* Use the byte order that cannot transiently shorten the line below
* either valid endpoint while updating this live sensor. */
int rc;
if (hts < (unsigned)previous) {
rc = s_sensor->set_reg(s_sensor, 0x380d, 0xff, hts & 0xff)
|| s_sensor->set_reg(s_sensor, 0x380c, 0x0f, hts >> 8);
} else {
rc = s_sensor->set_reg(s_sensor, 0x380c, 0x0f, hts >> 8)
|| s_sensor->set_reg(s_sensor, 0x380d, 0xff, hts & 0xff);
}
if (rc || s_sensor->get_reg(s_sensor, 0x380c, 0x0fff) != (int)hts) return ESP_FAIL;
ESP_LOGI(TAG, "OV5640 horizontal timing: HTS %d -> %u, active %u, VTS %d (PLL/XCLK unchanged)",
previous, hts, active, s_sensor->get_reg(s_sensor, 0x380e, 0xffff));
return ESP_OK;
}
esp_err_t cam_wrapper_set_exposure_limit(unsigned lines)
{
if (!lines) return ESP_OK;
if (!s_sensor || s_sensor->id.PID != OV5640_PID) return ESP_ERR_NOT_SUPPORTED;
int vts = s_sensor->get_reg(s_sensor, 0x380e, 0xffff);
int band50 = s_sensor->get_reg(s_sensor, 0x3a08, 0x03ff);
int band60 = s_sensor->get_reg(s_sensor, 0x3a0a, 0x03ff);
if (vts <= 4 || lines > (unsigned)(vts - 4) || band50 <= 0 || band60 <= 0)
return ESP_ERR_INVALID_ARG;
unsigned max50 = lines / (unsigned)band50;
unsigned max60 = lines / (unsigned)band60;
if (!max50) max50 = 1;
if (!max60) max60 = 1;
if (max50 > 63 || max60 > 63) return ESP_ERR_INVALID_ARG;
int rc = s_sensor->set_exposure_ctrl(s_sensor, 0)
|| s_sensor->set_aec2(s_sensor, 0)
|| s_sensor->set_reg(s_sensor, 0x3a02, 0xff, lines >> 8)
|| s_sensor->set_reg(s_sensor, 0x3a03, 0xff, lines & 0xff)
|| s_sensor->set_reg(s_sensor, 0x3a14, 0x0f, lines >> 8)
|| s_sensor->set_reg(s_sensor, 0x3a15, 0xff, lines & 0xff)
|| s_sensor->set_reg(s_sensor, 0x3a0d, 0x3f, max60)
|| s_sensor->set_reg(s_sensor, 0x3a0e, 0x3f, max50)
|| s_sensor->set_gain_ctrl(s_sensor, 1)
|| s_sensor->set_exposure_ctrl(s_sensor, 1);
if (rc || s_sensor->get_reg(s_sensor, 0x3a02, 0xffff) != (int)lines
|| s_sensor->get_reg(s_sensor, 0x3a14, 0x0fff) != (int)lines)
return ESP_FAIL;
ESP_LOGI(TAG, "OV5640 auto exposure cap: %u lines, band maxima 50Hz=%u / 60Hz=%u, AEC/AGC auto, night mode off",
lines, max50, max60);
return ESP_OK;
}
float cam_wrapper_probe_frame_rate(void)
{
/* Drain the old three-buffer pipeline after changing the colour effect. */
for (int i = 0; i < 4; ++i) {
cam_wrapper_fb_t fb;
if (cam_wrapper_fb_get(&fb, CAM_WRAPPER_SENSOR_AUTO) == ESP_OK)
cam_wrapper_fb_release(&fb);
}
return cam_wrapper_measure_fps(CAM_WRAPPER_SENSOR_AUTO);
}
esp_err_t cam_wrapper_temperature_init(void)
{
if (s_temperature) return ESP_OK;
temperature_sensor_config_t config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(20, 100);
esp_err_t rc = temperature_sensor_install(&config, &s_temperature);
if (rc != ESP_OK) return rc;
rc = temperature_sensor_enable(s_temperature);
if (rc != ESP_OK) {
temperature_sensor_uninstall(s_temperature);
s_temperature = NULL;
}
return rc;
}
esp_err_t cam_wrapper_temperature_read(float *celsius)
{
if (!s_temperature) return ESP_ERR_INVALID_STATE;
return temperature_sensor_get_celsius(s_temperature, celsius);
}
esp_err_t cam_wrapper_fb_get(cam_wrapper_fb_t *out, cam_wrapper_sensor_t sensor)
@@ -190,10 +345,29 @@ esp_err_t cam_wrapper_fb_get(cam_wrapper_fb_t *out, cam_wrapper_sensor_t sensor)
out->height = fb->height;
out->format = (int)fb->format;
out->handle = fb;
out->capture_started_us = (int64_t)fb->timestamp.tv_sec * 1000000 + fb->timestamp.tv_usec;
return ESP_OK;
}
esp_err_t cam_wrapper_fb_get_latest(cam_wrapper_fb_t *out, uint32_t timeout_ms)
{
if (!out) return ESP_ERR_INVALID_ARG;
memset(out, 0, sizeof(*out));
uint32_t skipped = 0;
camera_fb_t *fb = esp_camera_fb_get_latest(timeout_ms, &skipped);
if (!fb) return ESP_FAIL;
out->data = fb->buf;
out->len = fb->len;
out->width = fb->width;
out->height = fb->height;
out->format = (int)fb->format;
out->handle = fb;
out->capture_started_us = (int64_t)fb->timestamp.tv_sec * 1000000 + fb->timestamp.tv_usec;
out->skipped_before = skipped;
return ESP_OK;
}
void cam_wrapper_fb_release(cam_wrapper_fb_t *fb)
{
if (fb == NULL || fb->handle == NULL) {
@@ -233,4 +407,4 @@ esp_err_t cam_wrapper_deinit(void)
const char *cam_wrapper_sensor_name(void)
{
return s_sensor_name;
}
}
+24 -1
View File
@@ -112,6 +112,8 @@ typedef struct {
size_t height;
int format;
void *handle; /* internal driver frame; do not touch */
int64_t capture_started_us; /* same monotonic clock as esp_timer_get_time */
uint32_t skipped_before; /* older completed frames released by latest get */
} cam_wrapper_fb_t;
esp_err_t cam_wrapper_init(const cam_wrapper_config_t *config, cam_wrapper_sensor_t sensor);
@@ -119,6 +121,7 @@ esp_err_t cam_wrapper_init(const cam_wrapper_config_t *config, cam_wrapper_senso
/* Acquire one frame into `out`. Returns ESP_OK on success, ESP_FAIL if the
* driver could not hand out a frame (e.g. timeout or buffer starvation). */
esp_err_t cam_wrapper_fb_get(cam_wrapper_fb_t *out, cam_wrapper_sensor_t sensor);
esp_err_t cam_wrapper_fb_get_latest(cam_wrapper_fb_t *out, uint32_t timeout_ms);
void cam_wrapper_fb_release(cam_wrapper_fb_t *fb);
@@ -129,6 +132,26 @@ esp_err_t cam_wrapper_deinit(void);
/* Name of the sensor the driver detected, or "unknown". Never NULL. */
const char *cam_wrapper_sensor_name(void);
/* Keep the sensor's JPEG encoder; effect 2 removes chroma in the sensor ISP. */
esp_err_t cam_wrapper_set_grayscale(int enabled);
/* Runtime JPEG compression, 0..63, without restarting the camera pipeline. */
esp_err_t cam_wrapper_set_quality(unsigned quality);
/* OV5640 target bias: 0 = default, -1 / -2 select approximately -1.1 / -1.9 EV. */
esp_err_t cam_wrapper_set_exposure_bias(int ev);
/* 0 = auto shutter; nonzero holds that exposure in sensor lines, AGC stays on. */
esp_err_t cam_wrapper_set_shutter_lines(unsigned lines);
/* Keep AEC/AGC automatic, cap exposure lines, and disable night frame extension. */
esp_err_t cam_wrapper_set_exposure_limit(unsigned lines);
/* 0 keeps the driver timing; nonzero changes OV5640 horizontal total only. */
esp_err_t cam_wrapper_set_horizontal_timing(unsigned hts);
int cam_wrapper_exposure_lines(void);
/* OV5640 gain in 1/16 units; -1 when unavailable. */
int cam_wrapper_gain_raw(void);
float cam_wrapper_probe_frame_rate(void);
/* Silicon temperature, not ambient or camera-sensor temperature. */
esp_err_t cam_wrapper_temperature_init(void);
esp_err_t cam_wrapper_temperature_read(float *celsius);
#ifdef __cplusplus
}
#endif
#endif
+24 -2
View File
@@ -733,13 +733,14 @@ camera_fb_t *cam_take(TickType_t timeout)
for (;;)
{
TickType_t elapsed = xTaskGetTickCount() - start; /* TickType_t is unsigned so rollover is safe */
if (elapsed >= timeout) {
if (timeout && elapsed >= timeout) {
ESP_LOGW(TAG, "Failed to get frame: timeout");
return NULL;
}
TickType_t remaining = timeout - elapsed;
TickType_t remaining = timeout ? timeout - elapsed : 0;
if (xQueueReceive(cam_obj->frame_buffer_queue, (void *)&dma_buffer, remaining) == pdFALSE) {
if (!timeout) return NULL;
continue;
}
@@ -750,6 +751,7 @@ camera_fb_t *cam_take(TickType_t timeout)
if (dma_reset_counter < MAX_GDMA_RESETS) {
ll_cam_dma_reset(cam_obj);
dma_reset_counter++;
if (!timeout) return NULL;
continue; /* retry with queue timeout */
}
if (dma_reset_counter == MAX_GDMA_RESETS) {
@@ -762,6 +764,7 @@ camera_fb_t *cam_take(TickType_t timeout)
CAM_WARN_THROTTLE(warn_null_cnt,
"Unexpected NULL frame on " CONFIG_IDF_TARGET);
#endif
if (!timeout) return NULL;
vTaskDelay(1); /* immediate yield once resets are done */
continue; /* go to top of loop */
}
@@ -802,6 +805,7 @@ skip_eoi_check:
CAM_WARN_THROTTLE(warn_eoi_miss_cnt,
"NO-EOI - JPEG end marker missing");
cam_give(dma_buffer);
if (!timeout) return NULL;
continue; /* wait for another frame */
} else if (cam_obj->psram_mode &&
cam_obj->in_bytes_per_pixel != cam_obj->fb_bytes_per_pixel) {
@@ -818,6 +822,24 @@ skip_eoi_check:
}
}
camera_fb_t *cam_take_latest(TickType_t timeout, uint32_t *skipped)
{
if (skipped) *skipped = 0;
camera_fb_t *latest = cam_take(timeout);
if (!latest) return NULL;
/* A bounded drain, not availability-check + blocking get: the producer can
* replace queued buffers on the other core. Retain the valid frame if a
* newer candidate is invalid or a GDMA-reset sentinel is encountered. */
for (size_t i = 1; i < cam_obj->frame_cnt; i++) {
camera_fb_t *next = cam_take(0);
if (!next) break;
cam_give(latest);
latest = next;
if (skipped) (*skipped)++;
}
return latest;
}
void cam_give(camera_fb_t *dma_buffer)
{
for (int x = 0; x < cam_obj->frame_cnt; x++) {
@@ -401,6 +401,19 @@ camera_fb_t *esp_camera_fb_get()
return fb;
}
camera_fb_t *esp_camera_fb_get_latest(uint32_t timeout_ms, uint32_t *skipped)
{
if (skipped) *skipped = 0;
if (s_state == NULL) return NULL;
camera_fb_t *fb = cam_take_latest(pdMS_TO_TICKS(timeout_ms), skipped);
if (fb) {
fb->width = resolution[s_state->sensor.status.framesize].width;
fb->height = resolution[s_state->sensor.status.framesize].height;
fb->format = s_state->sensor.pixformat;
}
return fb;
}
void esp_camera_fb_return(camera_fb_t *fb)
{
if (s_state == NULL) {
@@ -206,6 +206,9 @@ esp_err_t esp_camera_deinit(void);
* @return pointer to the frame buffer
*/
camera_fb_t* esp_camera_fb_get(void);
/* At most timeout_ms waiting for the first frame; pending frames are drained
* nonblocking, keeping the newest valid JPEG. skipped may be NULL. */
camera_fb_t* esp_camera_fb_get_latest(uint32_t timeout_ms, uint32_t *skipped);
/**
* @brief Return the frame buffer to be reused again.
@@ -52,6 +52,8 @@ void cam_stop(void);
void cam_start(void);
camera_fb_t *cam_take(TickType_t timeout);
/* Hold only the newest validated frame, returning superseded buffers. */
camera_fb_t *cam_take_latest(TickType_t timeout, uint32_t *skipped);
void cam_give(camera_fb_t *dma_buffer);
+15
View File
@@ -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"
+20
View File
@@ -0,0 +1,20 @@
# 前端依赖与产物。
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/
# 本机开发用的状态:dev 配置(里面有密码哈希)、演示/录制的输出、界面截图。
# 截图不是源码,它们随界面一起变;要看就去 `pnpm app` 跑起来,或者自己跑 .dev/shoot.js。
/.dev/
/screenshots/
# 测试运行时被 XDG_CONFIG_HOME 相对路径写出来的配置,不是仓库内容。
src-tauri/.config/
+684
View File
@@ -0,0 +1,684 @@
# RMIC::2026
黑龙江大学 602 实验室 · Simba Robotics 的**视野链路终端**。
桌面端。**两种身份,两套页面,一个进程。**
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|---|---|---|---|---|
| 用户端(`team`) | 一路 | **双方**(左上角自己 / 右上角对手) | 自己那块 | **要,改动需密码** |
| 裁判端(`referee`) | 两路并排 | 双方(各自贴画面角上,**队名可改**) | 发现列表 + 槽位 | 不需要 |
界面是 WebView 里的 React(画面画在 canvas 上),后端是 Rust。**它不是把旧客户端套进
一个窗口**:网络、重组、发现、配置、密码、录制全部重写成了可单测的模块。
> **名字与数据目录**:显示名是 `RMIC::2026`;安装包与可执行文件叫 `RMIC-2026`(Windows
> 文件名不允许 `:`)。配置、录制、日志仍落在旧的 `cam-desktop` 目录下——这是**故意的**,
> 换名字不该顺手把现场的密码哈希与绑定丢掉。见「配置」一节。
```text
desktop/
core/ cam-core:不含任何界面与 Tauri 的东西(83 个测试)
src-tauri/ Tauri 后端:推流、命令、录制(32 个测试)
src/ React 前端:两套页面 + HUD
src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化、准星绘制、界面小节拍
src/components/ 取景器、装甲条、命中、结算、装甲板表、队名、录制、设置抽屉、启动序列、门、提示
src/pages/ 用户端 / 裁判端 / 固件维护 / 延迟校准
src/styles/ tokens(令牌)、base(原子)、chrome(外壳)、stage(舞台)、console、settings、maintenance
public/fonts/ 自托管的 HarmonyOS Sans SC 与 JetBrains Mono
src-tauri/icons/ 队标(由 simba-robotics.com/logo.svg 生成,含光学尺寸分级)
```
## 界面:一套终端语言
这一栏不是装饰说明,而是"改界面之前该知道的几件事"。
### 配色:墨黑蓝打底,只有一支红
| 角色 | 值 | 用在哪 |
|---|---|---|
| 底色 | `--void #05070d` → `--panel-3 #172032` | 全部面积。五级低饱和冷蓝黑,靠 1px 线分层,不靠阴影 |
| 文字 | `--ink #e7ecf5` → `--ink-4 #3a4457` | 四档,第四档暗到不像真信息 |
| **强调** | `--primary #e8232b` | **只有**:焦点、选中、录制中、错误、临界血量、结算 |
| 状态 | `--error` / `--warn #f2b33d` / `--ok #3fc98a` | 各有语义,不与强调色混用 |
| 队伍 | `--team-0 #ff6b5e` / `--team-1 #4a9bff` | **硬件约束**:装甲板 LED 就是红与蓝 |
一条硬规则:**红只给必须被看见的东西**。开关合上是白的、滑杆填充是钢灰、分段控件的选中
记号是底沿一条 2px 线、分区编号是灰的、顶栏没有任何强调线。当红色出现时,它后面一定有一
件真事——这就是"只高亮真正重要的信息"落到代码里的样子。
队红刻意做得比强调红更暖更浅,否则两条血并列时会被误读成"系统在报警"。
### 形状:矩形,唯一的斜边给装甲条
全局是矩形:没有圆角、没有切角、没有药丸、没有阴影卡片。**唯一的斜边是装甲条那排平行四边形**
——它必须在一屏矩形里被一眼认成"这是血,不是别的东西"。别的元素一旦也开始斜切,那个形状
就不再携带信息了。
### 动效:严格照 Material 3 的曲线与时长
`styles/tokens.css` 里直接写着 M3 的七条缓动与 short/medium/long 四组时长:
| 令牌 | 曲线 | 语义 |
|---|---|---|
| `--m3-emphasized` | `cubic-bezier(0.2, 0, 0, 1)` | 需要存在感的转场 |
| `--m3-emphasized-decelerate` | `cubic-bezier(0.05, 0.7, 0.1, 1)` | **进入**(快起慢收) |
| `--m3-emphasized-accelerate` | `cubic-bezier(0.3, 0, 0.8, 0.15)` | **退出**(慢起快走) |
| `--m3-standard` / `-decelerate` / `-accelerate` | `(0.2,0,0,1)` / `(0,0,0,1)` / `(0.3,0,1,1)` | 常规位移 |
| `--m3-linear` | `(0,0,1,1)` | 恒速(仅用于跟踪与循环) |
旧名 `--e-out` / `--e-in` / `--d-1..5` 保留为**指向 M3 的别名**,所以没有一条规则落在自造
的贝塞尔上。两个例外是刻意的:`steps()` 用于录制红点与终端光标(离散闪烁),`linear` 用于
"没信号"那圈恒速转动的虚线环——M3 的曲线描述的是有始有终的转场,不是无限循环。
### 动效:每一页只有一个签名动作
重复使用同一种效果会让界面显得机械,所以只共享底层曲线与时长,**每页各有一个属于自己的
动作**:
| 界面 | 签名动作 | 它解决什么 |
|---|---|---|
| 启动序列 | 一行行亮起的自检 + 真实进度 | 让"还在等"与"卡住了"分得开 |
| 首启门 | 两张模块卡,悬停浮起并露出角括号 | 说明这件选择是要"装"进这台机器的 |
| 用户端 | 逐格熄灭的装甲条、四周泛红、六秒休眠 | 静止时屏幕上的东西越少越好 |
| 裁判端 | 中缝可拖、鼠标停在哪一路下方就看哪一路 | 不用再开第三个页面去"看某一边" |
| 设置 | 常驻实时预览 + 竖向分节导航 | 改准星/网格/装甲条时立刻看得见结果 |
| 固件维护 | 步骤轨 + 环形进度 + 终端日志 | 烧录的哪一步、到哪儿了,一眼可读 |
| 解锁 | 一次性的校验扫描 + 错误时的一次抖动 | 明确"现在这一步是在验证身份" |
`prefers-reduced-motion` 会把这些时长压到 0,不是关掉功能。
### 字体:中文一套、拉丁一套,逐字回退
| | 字体 | 承担什么 |
|---|---|---|
| 中文 | **HarmonyOS Sans SC**(400 / 500 / 700) | 叙述、标签、按钮文字。字面方正,11px 仍然立得住 |
| 拉丁与数字 | **JetBrains Mono**(400 / 500 / 700) | 全部读数:帧率、血量、地址、倍率、时间码。等宽,列里的数字跳动时不左右抖 |
两者都以 woff2 自托管在 `public/fonts/`,随 `frontendDist` 进安装包,**运行时不碰网络**
(CSP 里 `default-src 'self'` 也不允许)。中文由 `cn-font-split` 切成 82 个 unicode-range
分片 × 3 个字重,浏览器只下载页面真正用到的那几十个,而不是一上来吞 14 MB。
合到一句话里靠**逐字回退**,不做语言判断:`--font-ui` 把 JetBrains Mono 写在前面,它里面
没有汉字,浏览器就逐字往下找,落到 HarmonyOS。于是"血量 87"里数字是等宽的、汉字是
HarmonyOS 的,一条 `font-family` 同时满足两个要求。
启动序列会等 `document.fonts.ready`:先画成系统黑体、两百毫秒后再跳成 HarmonyOS,会让整块
布局重排一次——那一个抖动比多等一瞬难看得多。
### 队标
应用图标与启动页的标记是队标(`https://simba-robotics.com/logo.svg`),由它生成整套
`src-tauri/icons/`。原图是 1024×1024 的画布、标志只占中间一条,所以脚本把 viewBox 收到本体
上再居中;**小尺寸用更大的缩放比**(光学尺寸:256px 以上 82%、64–128px 88%、48px 以下 97%),
否则 16/24/32 上那些 1px 笔画会糊成一团。`icon.ico` 是手工拼的多尺寸容器——Pillow 的写入器
只能从一张主图缩出所有档位,而 Windows 任务栏恰恰只取最小的那几档。
笔画在 17px 以下就只剩 1px,所以顶栏只放字标 `RMIC::2026`(`::` 是界面里唯一被染红的两个
字符),完整队标留给启动页的 30px。
### 每页最顺手的那个操作
- **用户端不用点。** 操作手的手在遥控器上,所以这一页的交互全部收在画面上一个手势里:
滚轮缩放、拖动平移、双击复位。没有任何"瞄准再点"的按钮。六秒没有输入也没有挨打时,角上
的标签与代号自己淡下去;一有动静整块醒来。
- **裁判端让控制台跟着鼠标走。** 按下左格,下方列表就只显示那一边的装甲板(再按一次回到
全部,键盘 `1` / `2` / `0` 同样)。中间那条竖线可以拖——哪一路更重要就把它拉宽,双击回到
五五开。画面与下方控制台之间的横线也能拖:看回放的时候把播放器拉大。
- **设置是一块带预览的控制台。** 左侧竖向导航,右侧常驻一块实时预览;预览用的是画面里
**同一个**绘制函数(`lib/reticle.ts`),所以"预览里对、画面上偏"这种最坏情况不可能发生。
预览旁边那根血量滑杆是刻意加的:低血阈值与脉动只有真的把血拖下去才看得见。
## 版本与改动日志:算出来的,不是写出来的
版本号和改动日志都从 **git 历史**算,不靠人记着去改。
```sh
node scripts/version.mjs --write # 计算并写回所有派生文件
node scripts/version.mjs --check # 只校验:有未归档的提交就退 1(CI 里跑这个)
node scripts/version.mjs --print # 把算出来的版本打到 stdout
```
### 规则
提交标题用 Conventional Commits(`type(scope): 说明`):
| 这批提交里有 | 升哪一位 |
|---|---|
| `BREAKING CHANGE:` 或 `type!:` | major |
| `feat` | minor |
| `fix` / `perf` | patch |
| 其它 | 不动版本,只加构建号 |
构建号是 `git rev-list --count HEAD`:单调递增、不会撞车、不用手工维护。正文里以 `- `
开头且本身写成 `type(scope): 说明` 的行算**额外条目**——这样一次 squash 提交也能把几件事
分别归类,不必为了日志好看去拆提交。
### 派生出来的东西
| 文件 | 谁用 |
|---|---|
| `version.json`(仓库根) | 固件 `build.rs` 读它,编译期注入 `RMIC_VERSION*` |
| `CHANGELOG.md` / `CHANGELOG.json` | 人读的那份与机器读的那份,同一次计算产出 |
| `desktop/src/lib/version.ts` | 界面上的版本标签与协议号 |
| `desktop/package.json`、`tauri.conf.json` 的 `version` | 装包文件名与 Windows 的「应用和功能」 |
建这套机制之前的人工整理记录保存在 [`docs/CHANGELOG-legacy.md`](../docs/CHANGELOG-legacy.md)。
### 固件与客户端怎么对上
同一条链贯穿到底:`git → version.json → 固件二进制 → Hello 报文 → 客户端界面`。
板子每 2 秒广播的 `Hello` 里带着**语义化版本、构建号、以及它编译进去的线路协议号**
(布局见 `protocol/README.md`)。客户端读出来之后:
- 设置面板的绑定下拉里显示 `固件 v0.2.0 · 34`;
- 裁判端每一格下方显示这块板子的固件版本;
- 协议对不上时,用户端画面正中给出**具体原因**("它是协议 2,本客户端是协议 1"),
而不是只表现为没有画面——它的视频帧会被严格解析丢弃,但那句解释正是靠通告读出来的。
**为什么值得占这 10 个字节**:一块烧错固件的板子,原来只表现为"没画面",现场会去查网线、
AP、供电。现在同一个现象会直接指向"重新烧固件"。
## 快速开始
```sh
cd desktop
pnpm install
pnpm app # = tauri dev:起 vite + 编 Rust + 开窗口
pnpm build # 只构建前端(tsc --noEmit && vite build)
pnpm tauri build # 默认打包 macOS 的 .app 与 .dmg;Windows 见下文
```
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
```sh
cd desktop/core && cargo test # 83
cd desktop/src-tauri && cargo test # 32,其中几条真的会起 ffmpeg
```
### Windows x64 构建
需要 Node.js 22.12+(或 24)、pnpm 12、Rust stable MSVC,以及 Visual Studio Build Tools 的 C++ 桌面开发组件和 Windows SDK。应用窗口使用 WebView2 Runtime。
在 `desktop/` 中执行:
```powershell
pnpm install --frozen-lockfile
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --ci -- --locked --config 'unstable.build-std=[]'
```
`--target` 覆盖本目录原来的 macOS target,`--bundles nsis` 选择 Windows 安装包;末尾的 Cargo 参数清空从根固件项目继承的 `build-std`。
- 程序:`src-tauri/target/x86_64-pc-windows-msvc/release/RMIC-2026.exe`(名字由 `tauri.conf.json` 的 `productName` 决定;直接 `cargo build` 出来的原始产物仍叫 `cam-desktop.exe`,那是内部构建物,不是交付物)。
- 安装包:同一 release 目录下的 `bundle/nsis/*-setup.exe`。
- 核心测试:进入 `core/` 后运行 `cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'`。
视频录制另需可从 `PATH` 找到的 `ffmpeg`,也可以在配置的 `[recording] ffmpeg` 中指定路径;基础图传显示不依赖它。
设备发现使用板卡的 Hello 广播;客户端除广播订阅外,还会按已发现设备的 IP 定向续订。Windows 开启 VPN / 虚拟网卡时,全局广播可能走虚拟接口;定向续订可避免“能发现并绑定,但始终等待画面”的情况。队伍端的绑定和画面过滤仍然生效。
### 演示模式:没有板子也能把整条链路跑起来
```sh
cd desktop
CAM_DEMO=1 pnpm app
```
它**不伪造数据给界面**,而是合成**协议数据报**,从 `Receiver::datagram` 与
`ArmorHealthStore::datagram` 进去。也就是说演示模式跑的是同一条真实路径:
```text
合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈
```
分片、重组、发现、统计、掉血派生、两道闸门全在跑真代码,截图与录制也不需要特殊分支。
界面上会有一条 **演示模式** 标记,板子 id 也刻意取成 `0x0DE00001` 这种一眼能看出是人造
的形状——**别把它当成现场数据**。
### 命令行与环境变量
```text
--role <team|referee> 身份。默认看配置文件
--config <路径> 配置文件位置
--demo 演示模式
--list 打印发现的摄像头与装甲板后退出
-h, --help 帮助
CAM_DEMO=1 等价于 --demo
CAM_CONFIG=<路径> 等价于 --config <路径>
```
两个环境变量存在的理由很实际:`tauri dev` 要用两个 `--` 才能把参数递给应用
(`tauri dev -- -- --demo`),写在一行环境变量里不会有人记错。
**未知参数只记一条 debug 日志,不报错。** macOS 从访达双击 .app 时系统会塞进来一个
`-psn_0_123456`;把它当错误处理的表现是"双击图标什么都没发生"。
## 为什么换成 Tauri,代价是什么
旧客户端是 Rust + Slint 单二进制,能用。换掉它的理由是**界面这一侧的生产力**:血条、
瞄准器、可拖拽的偏移校准、下拉绑定、设置抽屉、录制列表与回放——这些用 CSS/HTML 写是
几十分钟的事,用即时模式 UI 工具写要自己排版、自己画控件、自己处理焦点。这个项目的重心
在链路(UDP、重组、发现、防作弊),界面应当越快改完越好。
代价说清楚:
- **多一层**。WebView + Node 工具链 + Tauri 的权限系统,比一个 Slint 二进制重得多。
- **要联网装依赖**(前端 npm、Rust crates)。
- **画面要过一条本地通道**才能到界面(见下一节)。Slint 那版是把 RGBA 直接交给渲染器,
路径更短。
换来的另一样东西是**可测性**:`core/` 不认识界面,所以重组、发现、帧率窗口、槽位分配、
配置与密码全都能用单元测试盯住——而它们恰好是最容易错、错了又最难在现场看出来的部分。
## 视频怎么进 WebView
**一个只监听回环地址的 WebSocket,推二进制 JPEG。** 界面从 `stream_info` 命令拿到
`ws://127.0.0.1:<随机端口>/?token=...`。
### 为什么不是 Tauri 的 IPC
两路 22fps、每帧约 22 kB,每秒约 1 MB 的裸 JPEG。走 IPC 要序列化成 JSON(base64 再涨
1/3),前端再解回来——CPU 花在没人需要的编解码上,延迟白涨一截。二进制 WebSocket 帧把
原始字节直接交给前端,浏览器用**原生解码器**解,**后端连 JPEG 都不用解**。
### 二进制帧的布局
```text
0 4 8 10 12 16
+--------------+--------------+------+------+--------------+---------+
| sender_id u32| frame_id u32 | w u16| h u16| jpeg_len u32 | JPEG... |
+--------------+--------------+------+------+--------------+---------+
```
小端,16 字节头。前后端对这段布局的约定由一个测试盯着(`server.rs` 与 `stream.ts`
两侧都有),长度不一致的帧宁可丢掉,也不要让前端拿到一段不完整的 JPEG。
### 前端侧的两个陷阱
**解码是异步的,所以帧号要看着用。** `createImageBitmap` 返回 Promise,两个相邻帧的解码
可能乱序完成。所以每路记住最后画过的帧号,只有更新的那帧才采用——没有这一步,画面会偶尔
往回跳一帧,看起来像"卡了一下",极难归因。
**`ImageBitmap` 必须显式 `close()`。** 它持有解码后的像素,不吃 GC 的账。每收到新帧就
替换旧的那张并不够,必须把旧的那张关掉,否则两路 22fps 跑几分钟就是几百 MB。
## 防作弊:两道闸门,不是一个
对抗赛里两队都用这套系统看自己小车的图传,要防止对手开第二个窗口连到另一队的板子。
**关键是这件事做在接收端,不是界面里。** 广播的 Hello 里就有 MAC 派生的 `sender_id` 与
IP,藏起来毫无意义;数据也可能已经收下并解码了,`.slint`/CSS 只控制显示。
| 闸门 | 管什么 | 什么时候开 |
|---|---|---|
| `only_sender` | 帧的重组与投递 | 用户端**始终**是绑定的那一块 |
| `discovery_all` | 是否记录**其它**板子的 Hello | 只在密码解锁后的设置界面里打开 |
旧客户端把两件事绑在同一道闸门上,于是只能在"能列出候选"和"挡住对手"之间二选一,改绑
只能靠手改配置文件。现在分开了:设置界面能列出候选摄像头(否则无从选起),但**画面不进
来**——对手的板子即使把流推到本机端口、即使被记进发现列表,它的帧也进不了重组器。
四种情况不限制:裁判端(中立方)、debug 构建、用户端还没绑定、`--camera-id` 显式覆盖。
### 遥测按编译模式门控
`TEAM_DIAGNOSTICS = cfg!(debug_assertions)`。用户端 release 下**不产出**帧率/丢帧这些
数字——不是"界面藏起来",是 DTO 里就不存在(`CameraStatsDto::build` 直接把它们置零)。
由编译模式决定而不是命令行开关:开关意味着二进制里带着那套东西,忘开一次就泄露了。
### 这挡不住什么
诚实地说:**IP 仍然通过 Hello 广播泄露。** 对手知道你的 IP 之后可以自己发一个
`Subscribe` 过来——固件会向任何订阅者推流,那道闸门在他的客户端上,不在你的。真要挡住
需要固件侧做配对 token(首次绑定发一个令牌,固件只接受带令牌的订阅),代价是固件重启就
失配。
回环通道上那个 token 也不是防对手的:它只让"本机另一个程序顺手连上来看画面"变成一件需要
先读到那个 token 的事。
## 密码:它挡的是谁
必须说清楚,否则会被当成一把它并不是的锁。
- **挡得住**:现场随手改绑定。选手想把自己的画面换到对手那块板子上,得先知道密码;围观
的人翻不到明文(只有 Argon2id 哈希),也不能靠改配置文件把绑定改掉——改了之后界面会
要求先解锁。**退出/关闭程序**同样要密码,因为"随手关掉"也是一种要挡的误操作。
- **挡不住**:一个愿意在自己机器上重编这个客户端的人。配置与二进制都在他手上,删掉哈希
或者直接改源码都行。真要封住得回固件加配对 token。
所以它的定位是**防误触与防随手改**,不是密码学意义上的对抗。
实现上:
- Argon2id(`argon2` crate,默认参数 19 MiB / t=2),存标准的 PHC 字符串,参数跟着哈希
一起躺在配置里,以后调参不需要迁移。用快哈希(SHA-256 之类)对 4 位数字密码等于没有
保护。
- 首次启动**强制**设置密码(`Config::needs_password_setup`),最短 4 位。长度下限只要
4 位是刻意的:一个 8 位强密码在这里换不来任何实际安全增益,却会让现场每次改绑都要掏
手机查一遍。
- 解锁一次管到进程结束(会话内)。用户端可以手动"上锁"。
- **裁判端不要密码**:它没有"绑定到自己那块板子"的动作可被滥用,而现场裁判要能最快地改
槽位、开录。
- **离开用户端要密码**:否则切到裁判端就能看两路画面、还能随便改配置,闸门形同虚设。
执行点在 Rust 侧:每个改变配置的命令都过 `AppState::require_unlocked`。界面上的解锁
对话框只是入口——即使有人从控制台直接调 `invoke("bind_camera")`,一样要过这一关。
**故意没有 `--camera-id` 这类命令行覆盖。** 旧客户端有一个,用来"临时把一台机器当另一台
用";在这里它等于一条不需要密码就能改绑的路,所以去掉。要临时换一块板子,就在设置里改
(要密码),或者用 `--config` 指一份另外的配置文件。
忘了密码:删掉配置文件里的 `password` 一行(或整个文件)。旧客户端的配置会在首次启动时
导入并备份成 `.bak`。
## 用户端 HUD
### 血条:两个上角,平行四边形,一条分 5 格
**画面左上角是自己,右上角是对手**——两条同时看得见。血量是这一屏上最需要对照的两个数,
放在画面之外意味着视线要离开取景。条本身是斜的(平行四边形),里面分成 **5 格**,一格
20 点血:格子少而大,隔着几米扫一眼也能数出"还剩几格",比读一条连续的长度或一个百分比快。
少了的那部分从**靠中心的一侧**开始灭,于是两条血一起减少时视觉上往两边退。
(条挂在绝对定位的一层上,`pointer-events: none`——那一层盖着画面,而画面要能滚轮缩放、
指针拖动。同理,倍率与复位徽标从画面**上**沿挪到了下沿,上沿两个角归血条。)
宽度、是否显示数字/队名、低血阈值与脉动都可配;阈值以内的格子提前染上警示色,所以"危险区
在哪"是一直看得见的,而不是等真的低血了才第一次出现。
**未接入时显示占位说明而不是 0**:0 是一个看起来像真值的假值,现场看到 0 会以为对方已经
没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
血量归零**不写"已击毁/损毁"这类话**:归零这件事由图传中央的胜负横幅表达(见下),条上只留
一个 0 与灭掉的格子。
### 胜负横幅
一方归零之后,**图传中央**显示结论:赢的一边是「胜利」,输的一边是「击败」,两边同时归零是
「平局」(那时候任何一边显示"胜利"都是错的)。用词带上对手的名字,裁判端两格因此也能直接
看出是哪一边赢了。
- 结论由 Rust 侧算(`snapshot::resolve_outcomes`,有单测):**输只需要看自己**——自己归零就是
输,哪怕对手那块板子掉线;**赢要看两边**——对手归零且自己还有血,否则一块从没上报过血量的
自家主板会让人"凭空胜利"。
- 它一直显示到血量被重置(长按 BOOT / 下一局开始),不做几秒淡出:淡出之后屏幕上就再没有
"这局结束了"的痕迹,截图、回放与现场的争议都无从对照。
- 这是唯一放在画面正中的东西。命中反馈那一套刻意避开正中(见下),因为**胜负不是那种信息**:
它是一局的结束,此刻不再需要瞄准。
### 瞄准器
画在 canvas 上(不是叠一层 DOM),因为**它必须跟着画面走**:它标的是相机光轴上的一个点。
用 DOM 摆一个 `<img>` 再叠一层 `transform` 的话,"图像落在哪、瞄准点落在哪"要算两遍,而
两份实现迟早会不一致——表现是放大之后瞄准器偏了,恰恰发生在这件事最要紧的时候。所以变换
只算一次,图像、网格、瞄准器、伤害数字全都用同一组数画出来。
尺寸与缩放的关系也是刻意分开的:
| | 跟着缩放走吗 | 为什么 |
|---|---|---|
| 位置 | **是** | 它指的是画面里的一个点 |
| 大小 | 否 | 像真瞄具一样,放大之后准星覆盖画面里更小的一块 |
| 网格 | 是 | 它标的是画面里的比例,用来估距离 |
**偏移校准存的是画面比例,不是像素。** 摄像头与枪管不同轴时要把瞄点挪到弹着点上,而像素
偏移在分辨率或窗口大小一变就全错了。校准可以拖着画面调,也可以直接填数值。
### 命中反馈
主板**不报**"被打了一下",只报"现在是多少血"。所以命中是靠相邻两次读数之差还原的
(`HealthTracker`):
- 血量减少 ⇒ 一次命中,伤害是两个读数之差;
- 血量增加 ⇒ 不是命中,是长按 BOOT 的重置;
- 第一次见到一块板子不产生命中——没有基线,此时 `hp = 0` 只是"已经没血了"。
按**板子**记基线而不是按 `(板子, index)`:主板一次广播四条、`hp` 相同,按四条各记一份
基线会让一次掉血被报成四次命中。
已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次
打击会被合并成一次——对"闪一下红"足够,对判定不够,判定看的是界面上的绝对血量。
反馈有**两样**:画面四周泛红(颜色只从四条边渗进来,中间保持原样)、准星旁的伤害数字。
**大号数字不放在画面正中**——第一版放了,结果它正好压在瞄准点上,而被打中的那一下恰恰是最
需要看着准星的时候。
**它不给方向。** 命中事件里的 `index` 是哪一条上报先发现了掉血,不是"从哪一侧打来的"(主板
一次广播四条、血值相同,谁先到达取决于 UDP)。把它画成方向箭头会是一条看起来很专业、实际上
完全错误的线索,比不画糟得多。
**没有"画面抖动"了。** 原来有第三样:被打中时整幅图(连瞄准器一起)晃一下。它去掉了,
理由有两条:
- 抖动恰好发生在最需要看清画面的那一下;
- 它把**画面**动了,而操作手看到画面动时的第一反应是"车在晃",不是"我被打中了"——一个
只有几百毫秒的信号,代价是让人怀疑一个不存在的问题。
`HitFeedbackConfig::shake` 这个字段留着但不再生效:配置文件是 `deny_unknown_fields` 的,
删字段会让所有写过 `shake = true` 的配置**解析失败**,而配置解析失败等于程序起不来。设置
面板里也没有这个开关了。
## 裁判端
### 队名可以直接改
两格画面各自下面有一个队名输入框(`TeamNameEditor`),回车或点到别处生效,Esc 取消。
名字是**这一场比赛**的属性,所以它放在裁判盯着的地方,而不是设置面板深处的某一节;改完
立刻反映到血条、装甲板表与"等待图传"的占位文字上。
规范化的三件事统一在 Rust 侧做(`config::sanitize_team_name`,有单测):合并空白、按**字符**
截断到 12 个字(按字节截断会切出半个字)、留空回落到「队伍一 / 队伍二」。超过长度在输入框里
就限制了,比提交后被悄悄截掉诚实。
命令是 `set_team_name(team, name)`——**一次只改一边**。界面本来可以传一整个两条的数组,但那样
每次改名都是"读两条、改一条、写两条",而读来的那两条来自最多 200 ms 前的快照,紧接着改另一边
就会把上一次的改动覆盖回去。**同样过密码闸门**:裁判端本来就没有密码(`Role::needs_password`
为假),所以对现场没有影响;而给一个不设闸门的"写配置"入口,等于在闸门上开一条旁路——今天
改的是名字,明天有人顺手把绑定也挪进来。
名字在快照里以 `teamNames`(两条,下标就是队伍号)推给界面,两边各自的 `TeamHealthDto.name`
也是从这里来的:界面各处自己拼 `team === 0 ? "队伍一" : "队伍二"` 会在改名之后留下一半旧名字,
而且不会报错。
### 两格等宽
`flex: 1 1 0` + `min-width: 0`,两格的宽度只由容器决定。
旧客户端在这里翻过车:`HorizontalLayout` 先按内容的**最大**需求分配,剩下的才按 stretch
分。一格写着"已连接,等待画面…"(8 字),另一格只有"未收到画面"(5 字),于是左边多要
十几个像素——实测 55% / 45%。现在占位文字绝对定位、两格需求相同。
### 槽位分配:修掉了一个真 bug
规则:
1. 还在线的槽位不换人;
2. **候选里排除已经被另一个槽位占着的 id**;
3. 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位;
4. 没有候选就不动——只有一块板在场时另一个槽位空着,空着比显示一份复制品诚实。
规则 2 是修 bug。旧实现只排除"这个槽位自己原来那个 id",于是**队伍一的板子一离线,它的
槽位就会去拿队伍二那块,两格显示同一块板子**——看起来像图传串了,现场的排查方向会是网线、
AP、板子,而不是二十行分配代码。现在它有一个直接对着这个场景写的测试。
有烧错固件的板子(`team` 字段是反的)时可以在设置里改成手动指定槽位。
### 装甲板发现列表
场上连上了几块板子、各自自报哪一队、现在多少血、多久没广播、四条上报齐不齐、分到了哪个
槽位——"裁判端没显示血量"时第一眼看的就是这张表。它能区分"板子根本没上线"、"上线了但队
伍号烧反了"和"只有一条上报在发"。
**这不需要改固件**:主板一直用 `encode_from(sender_id)` 把自己的 MAC 派生 id 打进帧头,
只是旧的解码路径把它丢了。现在 `ArmorHealth::decode_from` 把它交出来。
### 截图与录制
- 截图存 PNG(`shots/` 下),文件名带板子 id 与毫秒时间戳。
- 录制存 mp4,走 ffmpeg 子进程:`-f image2pipe -c:v mjpeg -framerate <fps> -i pipe:0`
喂帧,x264 编码,`+faststart`。
- **帧率要显式告诉 ffmpeg**。`image2pipe` 里的 JPEG 序列没有时间戳,一串帧在 ffmpeg 看来
就是"以固定帧率播放的图片"。所以这个数只能来自实测,它会写进日志与 DTO——回放快放/慢放
时第一个要看的就是它。
- 喂帧经由一条长度 64 的队列与一个写盘线程。**接收线程绝不能被写盘拖慢**:那一天的表现是
链路上本不存在的丢帧。写不进去就丢帧并计数,丢帧数会出现在界面上。
- 文件名会查重:同一秒停一次再开一次是很自然的操作("这局重录"),而重名意味着 ffmpeg 的
`-y` 把上一段**静默覆盖**掉。所以先收尾上一段再开新的,重名往后排 `-2`、`-3`。
界面内回放走 Tauri 的资源协议,只对 `tauri.conf.json` 里 `assetProtocol.scope` 白名单的
目录生效。默认录制目录在名单里;改到别处时 `<video>` 会加载失败,界面上给出"用系统播放器
打开"的退路——而不是让人以为录下来的文件坏了。
## 配置
`~/.config/cam-desktop/config.toml`,`--config` 可改。**目录名沿用旧的 `cam-desktop`**:改名不该顺手把现场的密码哈希与绑定丢掉。要干净重来就删掉这个目录。
```toml
role = "team"
roleChosen = true
team = 0
teamNames = ["队伍一", "队伍二"]
cameraId = "0x0DE00001"
armorId = "0x0A200001"
mirror = false
password = "$argon2id$v=19$m=19456,t=2,p=1$..."
[recording]
dir = "/Users/you/Movies/cam-desktop"
ffmpeg = "ffmpeg"
crf = 20
preset = "veryfast"
[hud.crosshair]
style = "cross-dot"
size = 0.05
offsetX = 0.0
```
几个决定:
- **手写解析换成 serde + toml。** 旧客户端的配置只有四个标量字段,手写换来零依赖是划算
的;现在有 HUD 的嵌套结构、密码哈希、录制参数,对称性成本超过了收益。
- **未知键名仍然报错**(`deny_unknown_fields`)。打错一个键名却"看起来生效了",在现场是
要浪费时间的。
- **字段名统一 camelCase**,磁盘、IPC、前端 TS 一个名字。TOML 里写 camelCase 不算最漂亮,
但换来的是不需要任何一层做名字转换——而那种转换漏掉一个字段时不会报错,只会让某一项设置
看起来"改了没生效"。
- **原子写**:先写同目录临时文件再 `rename`。配置文件里有密码哈希,写一半崩掉留下的截断
文件会让"解锁"永久失败。
- **id 写成十六进制字符串**(`"0x3D4D8A9A"`),与界面、日志、抓包里看到的完全一致。
- `teamNames` 在根上而不是 `[referee]` 里:用户端的血条同样要显示名字,而"这场比赛的两队
叫什么"是比赛本身的属性,不是裁判端这个身份的属性。
- `roleChosen` 单独一位:`role` 的默认值是裁判端,所以"role == referee"既可能是人选了裁判
端,也可能只是还没人碰过这个文件。界面需要这个判据来决定首启要不要先问一句。
旧客户端(Slint 版)的配置在首次启动时导入能对应的字段,然后改名成 `.bak`(不删:现场
出问题时"旧的那份到底写了什么"是第一个要看的东西)。只有用**默认**路径时才做这件事——
`--config` 指定了别的路径说明这次运行是有意分开的,那时去动常规配置是越界的。
## 浏览器预览(仅开发)
Tauri 的界面跑在 WKWebView 里,改一行 CSS 要走一次 cargo 构建。所以 dev 模式下、且页面
里没有 Tauri 注入时,同一套 React 组件可以在浏览器里跑起来:状态与画面由
`src/lib/dev-preview.ts` 合成。
```sh
pnpm dev # 用户端
open 'http://127.0.0.1:1420/?preview=referee' # 裁判端(密码 1234)
open 'http://127.0.0.1:1420/?preview=first-run' # 首启的身份选择
open 'http://127.0.0.1:1420/?preview=password' # 首启的设密码
```
后两个参数只是让对应的门禁强制显示出来,不用为了看一眼就去删配置文件。
两重门保证它进不了正式构建:`import.meta.env.DEV` 为真**且**没有 `__TAURI_INTERNALS__`,
并且调用点是 `await import(...)`——条件为假时整个模块不会被打进产物。
## 测试
```sh
cd desktop/core && cargo test # 83:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定
cd desktop/src-tauri && cargo test # 32:帧头布局、录制、截图、演示画面、命令行
cd protocol && cargo test # 20
```
前端解码队列测试:在 `desktop/` 运行 `pnpm test:stream`,5 项测试覆盖慢解码时保留最新帧、多路独立处理、关闭清理、坏帧恢复与设备重启。它不替代实际 WebView 的画面验证。
三条值得单独说的:
- **录制**那条真的起 ffmpeg、喂 20 帧、停,然后用 `ffprobe` 读回帧数与分辨率。只断言"文件
存在且非空"是不够的:ffmpeg 参数写错时也会留下一个空的容器,而那正是最难在现场发现的
一种失败。
- **截图**那条真写一个 PNG 再读回来对尺寸,另外验证坏输入会报错而不是留下一个空文件。
- **帧率窗口**那条把"对帧间隔取平均会给出什么"也钉住了(约 30fps),防止有人"顺手修一下"
改回错误算法。
CSP 的验证不靠"看一眼没报错",而是靠**前端能不能连上后端**:前端的第一步是先 `invoke`
拿通道地址,再连 WebSocket。所以快照里的 `clients` 从 0 变成 1 同时证明了四件事——JS 加载
成功、IPC 通、WebSocket 通、React 的 effect 跑到了。CSP 一旦挡住其中任何一环,它就会停在
0。手动核对的办法:
```sh
RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
# 拿那条 ws://... 地址,用任何 WebSocket 客户端连上去,看 clients 是不是 1
```
## 线程与数据流
| 线程 | 做什么 | 为什么单独 |
|---|---|---|
| `udp-reader` | 收包、解析、按发送方重组 | 不能让解码/写盘挤进数据报之间 |
| `armor-reader` | 收 5601 上的血量广播 | 与视频是两个端口、两种节奏 |
| `rec-writer-<id>` | 把队列里的帧写进 ffmpeg 的 stdin | 接收线程绝不能被写盘拖慢 |
| `demo-*`(仅演示) | 合成协议数据报 | 走同一条真实路径 |
| tokio 任务 | WebSocket 服务、5 Hz 快照 | 异步 IO |
帧在接收线程里编码一次(16 字节头 + JPEG),所有界面共用同一份 `Bytes`。
视频使用每路只保留最新帧的 watch 通道;状态与控制消息使用有序广播通道。
前端每路最多保留一次正在进行的解码和一张最新待解码帧。
每张本地 JPEG 前增加 `frameTiming` 文本消息,包含同机后端发布时刻;JPEG 二进制头仍为 16 字节。前端在解码前后检查本机队列年龄,超过 100 ms 的待解码帧或解码结果直接释放,恢复后优先显示最新画面。后端不重放超过 100 ms 的缓存帧,本地视频 socket 写入超时后重连。旧版没有时间标记时仍支持 JPEG,但只能按 JS 收到时刻判断本地年龄。这些时间不能用于测量 ESP 到电脑的无线传输延迟。
## 已知限制与没做的
- **同一队有两块装甲板在广播时**,`health(team)` 是"最后写入者优先",四个格子可能混着两块
板子的数。真实比赛一队只有一块,所以这只在开发(演示 + 真板子)或烧错固件时才会遇到;
日志里会警告一次,消歧办法是绑定具体的板子 id(`health_of_board`)。
- **录制帧率**是固定值,不是可变帧率。源帧率变化大时回放速度会有偏差;这个数会写进日志与
DTO,所以至少能解释清楚。
- **`csp` 是一条收紧过的白名单**,但它是"够用"而不是"最小":`style-src` 带
`'unsafe-inline'`(React 的内联 `style` 属性需要它),`connect-src` 放开了整个
`ws://127.0.0.1:*`(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端"
——见下面"测试"一节最后一条。
- **证书与公证**没做,`.dmg` 只在本机可用。
- 前端已有解码队列测试;布局、实际 WebView 解码和画面显示仍需浏览器预览及真机运行验证。
## 排查
需要测量相机到客户端的整条链路时,使用裁判配置启动校准模式:
```powershell
.\RMIC-2026.exe --config .\referee.toml --latency-report .\latency-run.ndjson
```
报告路径必须是新文件。让相机完整拍到左侧四个彩色角块,避开右侧回显,固定相机和窗口位置。标记每 50 ms 更新;程序沿正常接收、解码和 `VideoSurface` 绘制路径记录每个标记的首次有效回显,拒绝曝光混合而无法可靠解码的帧。NDJSON 包含相邻 rAF 夹定的时间、解码完成到画布绘制时间和标记分析耗时,不保存图像或配置。面板扫描和像素响应未测量,精确物理光学延迟需用高速相机复核。正常启动不运行标记识别;校准模式不会额外订阅一份 UDP 视频。
`pnpm test:latency` 验证标记边界、旋转/镜像、混合曝光拒绝及屏幕原色泛白情形。
| 现象 | 原因 |
|---|---|
| 起不来,提示绑不上 UDP 5600 | 同一端口上已经跑着另一个客户端(旧的 Slint 版或另一份本程序)。看 `~/.config/cam-desktop/startup-error.txt` |
| 窗口起来了但没画面 | 设置里看"摄像头"下拉:绑定的是哪一块、在不在线 |
| 状态栏一直"已连接,等待画面…" | 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次) |
| 队伍端下拉里只有一块板子 | 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选 |
| 右上角那条血显示"装甲板未接入" | 对手那块主板没在 5601 上报血量(或不在线)。显示的是说明而不是 0——0 会被当成"对手已经没血" |
| 改了队名,某一处还是旧名字 | 不该发生:名字只有一个来源(快照里的 `teamNames`,血条直接用 `name`)。看到的话说明有一处自己拼了"队伍一/队伍二",那是个 bug |
| 输了/赢了之后画面中央一直有字 | 刻意如此:它显示到血量被重置(长按 BOOT)为止,不做几秒淡出。想清掉就重置装甲板 |
| 被打中时画面不动,只有闪红 | 刻意如此:抖动去掉了(见"命中反馈")。旧配置里的 `shake = true` 读得进来但不生效 |
| 画面不动 | 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。`RUST_LOG=debug` 会打印完整通道地址,可以用 `wscat` 连上去看 |
| 回放失败 | 录制目录不在资源协议白名单里。用"用系统播放器打开" |
| 录不了 | ffmpeg 不在 PATH 里。设置里可以指定路径;不起 ffmpeg 时会报错而不是假装在录 |
| 两块画面串了 | 检查是不是手动指定了槽位,或者两块板子烧了同一个 `ARMOR_TEAM` |
| macOS 上 WebKit 报 `could not create directory .../WebKit/...` | 运行环境禁止写 `~/Library/WebKit`(沙箱)。WebView 会退回无缓存的临时目录,功能不受影响 |
| `libc` 找不到 `MAP_FAILED` | `desktop/.cargo/config.toml` 的 target 没改成宿主三元组 |
+462
View File
@@ -0,0 +1,462 @@
# 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",
"socket2",
"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 = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[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 = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[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"
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "cam-core"
version = "0.1.0"
edition = "2021"
description = "桌面端的非界面部分:UDP 接收/重组、装甲板发现、配置与密码"
[dependencies]
# 与固件共享的线路格式。用 path 而不是复制,两边不可能悄悄分叉。
protocol = { path = "../../protocol" }
anyhow = "1"
log = "0.4"
socket2 = "0.6"
# 配置的序列化。这里与旧客户端(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"
+641
View File
@@ -0,0 +1,641 @@
//! 装甲板:监听 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>,
/// 已经警告过"同一队有多块板子"的队伍,避免刷屏。
warned_multi_board: Vec<u8>,
}
/// 相邻两次读数之差还原出来的掉血事件。
///
/// 主板不报"被打了一下",只报"现在是 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 报一次。
//
// 上一次的读数取自**这块板子自己**的记录,而不是 `readings[team][0]`:那个
// 槽位是"这一队"的,同队有两块板子在广播时会被互相覆盖,于是日志会把另一块
// 板子的血量当成"这块板子之前的血量"——一条读起来像真事的假线索。
if index == 0 {
if let Some(previous) = inner.boards.get(&board).map(|entry| entry.hp) {
if previous != health.hp {
log::info!(
"armor: board 0x{board:08X} team {team} hp {previous} -> {}",
health.hp
);
}
}
}
// 同一队出现第二块板子:说清楚后果,并提醒用绑定/指定板子 id 来消歧。
let same_team = inner
.boards
.values()
.any(|other| other.id != board && other.team == health.team && other.is_online());
if same_team && !inner.warned_multi_board.contains(&health.team) {
inner.warned_multi_board.push(health.team);
log::warn!(
"armor: 队伍 {team} 上有不止一块装甲板在广播(现在这是 0x{board:08X})。\
`health(team)` 是**最后写入者优先**,四条上报可能来自不同的板子;\
要确定是谁的血,请在界面上绑定/指定具体的板子 id。"
);
}
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 two_boards_on_the_same_team_share_the_per_team_slot() {
// 这是 `health(team)` 的已知限制,不是意外:一个队里同时有两块板子广播时,那
// 四个格子是"最后写入者优先",可能混着两块板子的数。真实比赛里一队只有一块,
// 所以这条路径只在开发(演示 + 真板子)或烧错固件时才会走到。
//
// 消歧的办法是绑定具体的板子 id,走 `health_of_board`。
let store = ArmorHealthStore::new();
tick(&store, 0xAAAA_0001, 0, 100, [192, 168, 1, 50]);
tick(&store, 0xBBBB_0002, 0, 40, [192, 168, 1, 60]);
// 后写的那块板子赢了。
assert_eq!(store.health(0), Some([40, 40, 40, 40]));
// 按板子取就没这个歧义。
assert_eq!(store.health_of_board(0xAAAA_0001), Some(100));
assert_eq!(store.health_of_board(0xBBBB_0002), Some(40));
// 两块板子都还在发现列表里,界面才能让人选。
assert_eq!(store.boards().len(), 2);
}
#[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
+43
View File
@@ -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;
+161
View File
@@ -0,0 +1,161 @@
//! 密码:哈希、校验、强度下限。
//!
//! # 这把锁挡的是谁
//!
//! 必须说清楚,否则会被当成一把它并不是的锁:
//!
//! - **挡得住**:现场随手改绑定。选手想把自己的画面换到对手那块板子上,得先知道密码;
//! 围观的人翻不到明文密码(只有 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_known_phc_string_still_verifies() {
// 一条固定的 Argon2id PHC 串,密码是 1234。
//
// 作用是把"已经写进某个人的配置文件里的哈希,将来还能不能验证"钉下来:换库、
// 换默认参数、或者手滑把 salt 的编码改成别的形式时,这条会立刻失败——而它失败的
// 那一天,现场的表现会是"密码明明对,却一直说密码错"。
const PHC: &str = "$argon2id$v=19$m=19456,t=2,p=1$rBxlGFBYgghR/PPmKlx++A$B7J4JeKgDzv4JdNycjf7rb+5UPgO9JY+KhURaZtn5R0";
assert!(verify_password("1234", PHC).expect("verifies"));
assert!(!verify_password("1235", PHC).expect("the hash itself is fine"));
}
#[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());
}
}
+945
View File
@@ -0,0 +1,945 @@
//! UDP 接收、发现、多路帧重组。
//!
//! # 多路
//!
//! 裁判终端同时收两块图传板,队伍终端也会收到两块(只是界面上只显示绑定的那一块)。
//! 所以重组状态**必须按发送方分开**——把两块板的帧塞进同一个重组器,会让它们各自的
//! `frame_id` 互相顶掉,两路都出不来。
//!
//! # 发现与订阅
//!
//! 两个方向各司其职:
//!
//! - 板子每 2 秒广播一次 [`protocol::MsgType::Hello`],客户端被动收到就知道有谁在线。
//! - 客户端广播 [`protocol::MsgType::Subscribe`],并向已发现的板子单播续订。
//! 多网卡 / VPN 可能把全局广播路由到虚拟网卡;单播按板子 IP 选择局域网接口。
//!
//! 板子靠**源地址**记住客户端,所以客户端只要周期性续订就能维持推流,不存在任何
//! 超时淘汰的竞态。
//!
//! # 丢帧策略
//!
//! 缺了分片的帧整个丢弃、下一帧顶上,不重传也不尝试补洞。对实时画面这是对的:等
//! 重传补齐时,那一帧早就不新鲜了。代价是丢帧率必须摆在界面上,否则"画面卡"没法
//! 区分是链路问题还是传感器问题。
//!
//! # 两道闸门,不是一个
//!
//! 旧客户端只有一道 `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,
/// 板子自报的固件版本(来自它的 Hello)。
pub firmware: FirmwareInfo,
/// 最近一次收到它广播的时刻。
pub last_seen: Instant,
}
/// 板子自报的固件版本。
///
/// # 为什么不是一个 u16
///
/// 原来只有 `Hello.firmware: u16`,是个没有定义的数——显示出来也没人能解释。现在它是
/// 一组**能对得上**的数:语义化版本、构建号、以及板子编译进去的线路协议版本。
///
/// 最后一项是这套机制的重点:一块烧了别的协议版本的板子,视频帧会被严格解析挡掉,
/// 于是它在客户端看来等于不存在。但它的通告仍然读得出来,所以界面可以说
/// "板子是协议 2,客户端是协议 1",而不是只能报"没有画面"。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FirmwareInfo {
/// 打包的语义化版本(`major << 16 | minor << 8 | patch`)。0 = 固件太老,没带。
pub version: u32,
/// 自增构建号。0 = 未知。
pub build: u16,
/// 板子自报的线路协议版本。
pub protocol: u16,
/// 旧的两字节字段原值,只用于日志。
pub legacy: u16,
}
impl FirmwareInfo {
pub fn from_hello(hello: &protocol::Hello) -> Self {
Self {
version: hello.version,
build: hello.build,
protocol: hello.protocol,
legacy: hello.firmware,
}
}
/// `"0.2.0"`;固件没带版本时返回 `None`(而不是 "0.0.0")。
pub fn version_text(&self) -> Option<String> {
(self.version != 0).then(|| protocol::format_version(self.version))
}
/// 这块板子的协议和本客户端一致吗。
///
/// 老固件没有这个字段,`Hello::decode` 会把帧头里的版本号填进来——那个值已经被
/// 严格解析校验过,所以到这一步一定匹配。
pub fn is_compatible(&self) -> bool {
self.protocol == protocol::PROTOCOL_VERSION
}
/// 一行给人看的描述,日志与界面提示共用同一句。
pub fn describe(&self) -> String {
match (self.version_text(), self.build) {
(Some(version), 0) => format!("v{version}"),
(Some(version), build) => format!("v{version}.{build}"),
(None, 0) => "版本未知".to_string(),
(None, build) => format!("构建 {build}"),
}
}
}
impl CameraInfo {
/// 多久没有广播了。
pub fn silence(&self) -> Duration {
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();
}
}
/// 记下一次通告。
///
/// 返回值只在**新出现**或**版本/协议发生变化**时有意义:`Some(true)` 表示它是兼容的,
/// `Some(false)` 表示它自报的协议与本客户端不同。周期性的重复通告返回 `None`——
/// 它每 2 秒来一次,每次都打日志会把真正的事件淹掉。
fn note_hello(&self, id: u32, addr: SocketAddrV4, firmware: FirmwareInfo) -> Option<bool> {
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
match cameras.get_mut(&id) {
Some(info) => {
let changed = info.firmware != firmware;
info.addr = addr;
info.last_seen = Instant::now();
info.firmware = firmware;
changed.then(|| firmware.is_compatible())
}
None => {
cameras.insert(
id,
CameraInfo {
id,
addr,
firmware,
last_seen: Instant::now(),
},
);
Some(firmware.is_compatible())
}
}
}
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);
}
}
/// 客户端身份。
///
/// 旧客户端使用的固定标识,保留用于兼容判断。
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>,
client_id: u32,
camera: Ipv4Addr,
stop: Arc<AtomicBool>,
}
impl Receiver {
/// 建一个接收器,但先不绑端口。
///
/// `only_sender` 为 `Some` 时只接受那一块板子的帧。
pub fn new(camera: Ipv4Addr, role: SubscribeRole, only_sender: Option<u32>) -> Self {
let client_id = loop {
let id = rand::random::<u32>();
if id != 0 && id != CLIENT_ID { break id; }
};
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),
client_id,
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))?;
// Absorb short scheduler/NIC bursts without losing JPEG fragments. The
// reader still drains continuously; decoded video uses latest frames.
let socket_ref = socket2::SockRef::from(&socket);
if let Err(err) = socket_ref.set_recv_buffer_size(1024 * 1024) {
log::warn!("could not enlarge the video receive buffer: {err}");
}
if let Ok(bytes) = socket_ref.recv_buffer_size() {
log::info!("video UDP receive buffer: {bytes} bytes");
}
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;
}
}
let firmware = FirmwareInfo::from_hello(&hello);
if let Some(compatible) = self.discovery.note_hello(hello.sender_id, v4, firmware) {
if compatible {
log::info!(
"found camera 0x{:08X} at {v4} ({}, protocol {})",
hello.sender_id,
firmware.describe(),
firmware.protocol
);
} else {
// 这条日志值钱的地方在于:它把"没有画面"翻译成了一个具体的原因。
// 严格解析会把这些板子的视频帧全部丢掉,所以除了这里,没有任何地方
// 会留下"场上有一块协议不匹配的板子"的痕迹。
log::warn!(
"camera 0x{:08X} at {v4} speaks protocol {}, this client speaks {} - \
its video will be ignored; reflash the board with a matching firmware",
hello.sender_id,
firmware.protocol,
protocol::PROTOCOL_VERSION
);
}
}
return;
}
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));
let dropped_before = assembler.dropped_frames();
let result = assembler.push(frag);
let dropped = assembler.dropped_frames() - dropped_before;
drop(ingest);
self.discovery.count(id, |s| {
s.datagrams += 1;
s.frames_dropped += dropped;
});
match result {
Ok(frame) => {
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(_) => {}
}
}
/// 放弃等太久的半截帧。接收循环周期性调用。
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);
}
}
/// 全局广播用于发现;已知板子还需按 IP 续订,避免广播被 VPN / 虚拟网卡接走。
fn subscription_targets(&self) -> Vec<SocketAddrV4> {
let mut targets = vec![SocketAddrV4::new(self.camera, DEFAULT_PORT)];
let only_sender = self.sender_limit();
for camera in self.discovery.online_cameras() {
if only_sender.is_some_and(|id| camera.id != id) {
continue;
}
// Hello 的来源端口不一定是订阅监听端口;协议固定使用 DEFAULT_PORT。
let target = SocketAddrV4::new(*camera.addr.ip(), DEFAULT_PORT);
if !targets.contains(&target) {
targets.push(target);
}
}
targets
}
/// 运行接收循环,直到 [`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: self.client_id,
};
let encoded = subscribe.encode();
for target in self.subscription_targets() {
if let Err(err) = socket.send_to(&encoded, 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,
version: protocol::pack_version(0, 2, 0),
build: 57,
protocol: protocol::PROTOCOL_VERSION,
};
rx.datagram(&hello.encode(MsgType::Hello), from(from_ip));
}
#[test]
fn discovered_boards_are_subscribed_directly_when_broadcast_uses_another_interface() {
let rx = Receiver::new(Ipv4Addr::BROADCAST, SubscribeRole::Referee, None);
let hello = Hello {
sender_id: 1,
..Default::default()
};
// Hello 可从其它端口发来,订阅仍需发往板子的固定监听端口。
rx.datagram(&hello.encode(MsgType::Hello), "192.168.1.139:12345".parse().unwrap());
feed_hello(&rx, 2, [192, 168, 1, 140]);
assert_eq!(rx.subscription_targets(), vec![
"255.255.255.255:5600".parse().unwrap(),
"192.168.1.139:5600".parse().unwrap(),
"192.168.1.140:5600".parse().unwrap(),
]);
// DHCP 地址变化后只使用新地址。
feed_hello(&rx, 1, [192, 168, 1, 141]);
assert_eq!(rx.subscription_targets(), vec![
"255.255.255.255:5600".parse().unwrap(),
"192.168.1.141:5600".parse().unwrap(),
"192.168.1.140:5600".parse().unwrap(),
]);
}
#[test]
fn discovery_of_other_teams_does_not_directly_subscribe_to_their_video() {
let rx = Receiver::new(Ipv4Addr::BROADCAST, SubscribeRole::Team, Some(1));
rx.set_discovery_all(true);
feed_hello(&rx, 1, [192, 168, 1, 10]);
feed_hello(&rx, 2, [192, 168, 1, 20]);
assert_eq!(rx.discovery().cameras().len(), 2);
assert_eq!(rx.subscription_targets(), vec![
"255.255.255.255:5600".parse().unwrap(),
"192.168.1.10:5600".parse().unwrap(),
]);
rx.restrict_to(Some(2));
assert_eq!(rx.subscription_targets(), vec![
"255.255.255.255:5600".parse().unwrap(),
"192.168.1.20:5600".parse().unwrap(),
]);
}
#[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());
}
#[test]
fn loss_statistics_include_superseded_frames_and_entirely_missing_ids() {
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Referee, None);
feed_hello(&rx, 1, [192, 168, 1, 10]);
feed_frame(&rx, 1, 1, [192, 168, 1, 10]);
let partial = fragment_frame(1, 2, &jpeg(), 640, 480);
rx.datagram(&partial[0].encode(), from([192, 168, 1, 10]));
assert_eq!(rx.discovery().stats(1).frames_dropped, 0, "normal pending fragments are not drops");
feed_frame(&rx, 1, 4, [192, 168, 1, 10]);
let stats = rx.discovery().stats(1);
assert_eq!(stats.frames_shown, 2);
assert_eq!(stats.frames_dropped, 2, "partial 2 and entirely missing 3");
assert_eq!(stats.loss_percent(), 50.0);
for frag in partial { rx.datagram(&frag.encode(), from([192, 168, 1, 10])); }
assert_eq!(rx.discovery().stats(1).frames_dropped, 2);
}
}
+188
View File
@@ -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,界面才能说清在等谁"
);
}
}
+714
View File
@@ -0,0 +1,714 @@
//! 推给界面的快照类型。
//!
//! # 为什么单独一个模块
//!
//! 这些结构体的字段名就是**界面看到的 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,
/// 固件版本字符串(`"0.2.0"`)。固件太老、没带这个字段时为 `null`。
pub firmware_version: Option<String>,
/// 自增构建号。0 = 未知。
pub firmware_build: u16,
/// 板子自报的线路协议版本。
pub protocol: u16,
/// 本客户端能不能和它通信。
///
/// 为 `false` 时它不会有画面——帧被严格解析挡掉了——但界面仍然能说出原因,
/// 这就是这个字段存在的全部意义。
pub compatible: bool,
}
impl From<&CameraInfo> for CameraDto {
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_version: camera.firmware.version_text(),
firmware_build: camera.firmware.build,
protocol: camera.firmware.protocol,
compatible: camera.firmware.is_compatible(),
}
}
}
/// 一块装甲板主板。
#[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,
/// 这一边的胜负结论,由 [`resolve_outcomes`] 从**双方**的血量算出来。
///
/// 放在每一边的 DTO 上而不是单独一个全局字段:界面上一格画面就是一边,一个格子里
/// 要放的就是"从我这边看,现在是什么结果"。裁判端两格因此各看各的那份。
pub outcome: Option<MatchOutcome>,
}
/// 一边看到的比赛结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum MatchOutcome {
/// 对手归零,自己还活着。
Victory,
/// 自己归零,对手还活着。
Defeat,
/// 两边同时归零。不写成"平局"以外的任何一个字:同时打空时两边都看到"胜利"才是错的。
Draw,
}
impl TeamHealthDto {
/// 还没有数据源。
pub fn absent(team: u8, board: Option<u32>, names: &[String; 2]) -> Self {
Self {
team,
name: team_name(team, names),
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,
outcome: None,
}
}
/// 有数据。
pub fn present(
team: u8,
board: Option<u32>,
hps: [u16; crate::receiver::ARMOR_BOARDS],
low: u16,
names: &[String; 2],
) -> Self {
let hp = hps.first().copied().unwrap_or(0);
Self {
team,
name: team_name(team, names),
available: true,
placeholder: String::new(),
hp: Some(hp),
hps: hps.to_vec(),
board,
board_text: board.map(id_text),
critical: hp == 0 || hp <= low,
outcome: None,
}
}
}
/// 把双方的血量合起来算成各自看到的胜负结论,写回 `outcome`。
///
/// # 规则是有意不对称的
///
/// - **输只需要看自己**:自己归零就是输,哪怕对手那块板子的数据没收到。这一条要成立,
/// 否则"我被打死了但对手的主板掉线"会显示成什么都没发生。
/// - **赢要看两边**:必须看到对手归零**且**自己还有血。少了后一半,一块从没上报过血量的
/// 自己方主板会让人"凭空胜利"。
/// - 两边同时归零是平局:那种情况下任何一边显示"胜利"都是错的。
///
/// 变量不足(对不上的队伍、只有一份 DTO)时什么都不写。
pub fn resolve_outcomes(health: &mut [TeamHealthDto]) {
if health.len() != 2 {
return;
}
let dead = |index: usize| health[index].available && health[index].hp == Some(0);
let alive = |index: usize| matches!(health[index].hp, Some(hp) if hp > 0) && health[index].available;
let (a_dead, b_dead) = (dead(0), dead(1));
let (a_alive, b_alive) = (alive(0), alive(1));
health[0].outcome = if a_dead && b_dead {
Some(MatchOutcome::Draw)
} else if b_dead && a_alive {
Some(MatchOutcome::Victory)
} else if a_dead {
Some(MatchOutcome::Defeat)
} else {
None
};
health[1].outcome = if a_dead && b_dead {
Some(MatchOutcome::Draw)
} else if a_dead && b_alive {
Some(MatchOutcome::Victory)
} else if b_dead {
Some(MatchOutcome::Defeat)
} else {
None
};
}
/// 队伍名的兜底值(与 `Config::default().team_names` 一致)。
pub fn default_team_name(team: u8) -> String {
crate::config::DEFAULT_TEAM_NAMES[usize::from(team.min(1))].to_string()
}
/// 这一边在界面上叫什么。
///
/// 配置里的名字是裁判端可以改的,所以名字必须**从这里来**,不能让界面各处自己拼
/// "队伍一"/"队伍二"——那种拼法会在改了名字之后留下一半旧名字。
pub fn team_name(team: u8, names: &[String; 2]) -> String {
let name = names[usize::from(team.min(1))].trim();
if name.is_empty() {
default_team_name(team)
} else {
name.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,
/// **两端都是两条,顺序是队伍号 0、1**:用户端也要显示对手的血量(画面右上角)。
///
/// 界面按 `team` 取自己那一格,不靠数组下标——用户端绑的是队伍二时 `health[0]` 是别人。
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 team_names: [String; 2],
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,
team_names: config.team_names.clone(),
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, "离线");
}
/// 默认队名,测试里到处用。
fn names() -> [String; 2] {
["队伍一".to_string(), "队伍二".to_string()]
}
#[test]
fn absent_health_is_a_placeholder_with_four_cells() {
let dto = TeamHealthDto::absent(0, None, &names());
assert!(!dto.available);
assert_eq!(dto.hp, None);
assert_eq!(dto.hps.len(), crate::receiver::ARMOR_BOARDS);
assert!(!dto.placeholder.is_empty());
assert_eq!(dto.outcome, None, "没有数据时不下结论");
}
#[test]
fn critical_health_is_flagged_at_zero_and_at_the_threshold() {
let low = TeamHealthDto::present(0, Some(1), [30, 30, 30, 30], 30, &names());
assert!(low.critical, "at the threshold");
let ok = TeamHealthDto::present(0, Some(1), [31, 31, 31, 31], 30, &names());
assert!(!ok.critical);
let dead = TeamHealthDto::present(0, Some(1), [0, 0, 0, 0], 30, &names());
assert!(dead.critical);
assert_eq!(dead.hp, Some(0));
}
#[test]
fn the_team_name_comes_from_the_config_not_from_a_hard_coded_string() {
let custom = ["红队".to_string(), "蓝队".to_string()];
assert_eq!(TeamHealthDto::absent(1, None, &custom).name, "蓝队");
assert_eq!(
TeamHealthDto::present(0, Some(1), [50; 4], 30, &custom).name,
"红队"
);
// 空名字回落默认值,而不是显示一个没有名字的血条。
let blank = ["".to_string(), " ".to_string()];
assert_eq!(team_name(0, &blank), "队伍一");
assert_eq!(team_name(1, &blank), "队伍二");
assert_eq!(team_name(9, &blank), "队伍二", "越界的队伍号按第二队处理");
}
#[test]
fn killing_the_opponent_reads_as_a_victory_for_the_other_side() {
let mut health = vec![
TeamHealthDto::present(0, Some(1), [100; 4], 30, &names()),
TeamHealthDto::present(1, Some(2), [0; 4], 30, &names()),
];
resolve_outcomes(&mut health);
assert_eq!(health[0].outcome, Some(MatchOutcome::Victory));
assert_eq!(health[1].outcome, Some(MatchOutcome::Defeat));
}
#[test]
fn nothing_is_announced_while_both_sides_are_alive() {
let mut health = vec![
TeamHealthDto::present(0, Some(1), [100; 4], 30, &names()),
TeamHealthDto::present(1, Some(2), [40; 4], 30, &names()),
];
resolve_outcomes(&mut health);
assert_eq!(health[0].outcome, None);
assert_eq!(health[1].outcome, None);
}
#[test]
fn both_sides_at_zero_is_a_draw_rather_than_two_victories() {
let mut health = vec![
TeamHealthDto::present(0, Some(1), [0; 4], 30, &names()),
TeamHealthDto::present(1, Some(2), [0; 4], 30, &names()),
];
resolve_outcomes(&mut health);
assert_eq!(health[0].outcome, Some(MatchOutcome::Draw));
assert_eq!(health[1].outcome, Some(MatchOutcome::Draw));
}
#[test]
fn a_dead_team_loses_even_when_the_other_board_is_missing() {
// 输只看自己:对手的主板掉线不该让"我被击毁"变成什么都没发生。
let mut health = vec![
TeamHealthDto::present(0, Some(1), [0; 4], 30, &names()),
TeamHealthDto::absent(1, None, &names()),
];
resolve_outcomes(&mut health);
assert_eq!(health[0].outcome, Some(MatchOutcome::Defeat));
assert_eq!(health[1].outcome, None, "没有自己数据的这边不下结论");
}
#[test]
fn a_missing_own_board_does_not_produce_a_victory() {
// 赢要看两边:只看到对手归零、不知道自己还有没有血时不能宣布胜利。
let mut health = vec![
TeamHealthDto::absent(0, None, &names()),
TeamHealthDto::present(1, Some(2), [0; 4], 30, &names()),
];
resolve_outcomes(&mut health);
assert_eq!(health[0].outcome, None);
assert_eq!(health[1].outcome, Some(MatchOutcome::Defeat));
}
#[test]
fn the_snapshot_serialises_the_outcome_as_a_plain_word() {
let mut health = vec![
TeamHealthDto::present(0, Some(1), [0; 4], 30, &names()),
TeamHealthDto::present(1, Some(2), [50; 4], 30, &names()),
];
resolve_outcomes(&mut health);
let json = serde_json::to_string(&health[0]).unwrap();
assert!(json.contains(r#""outcome":"defeat""#), "got {json}");
let json = serde_json::to_string(&health[1]).unwrap();
assert!(json.contains(r#""outcome":"victory""#), "got {json}");
}
#[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");
}
}
+228
View File
@@ -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);
}
}
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>RMIC::2026</title>
<!-- 中文三字重。这些文件是 cn-font-split 生成的(每种 82 个 unicode-range 分片),
放在 public/fonts 里随产物一起走;不经过打包器,所以用绝对路径直接 link。
放在 <head> 而不是 CSS 里 @import:@import 会串行阻塞,而这几份互不依赖。 -->
<link rel="stylesheet" href="/fonts/harmonyos-sans-sc/Regular.css" />
<link rel="stylesheet" href="/fonts/harmonyos-sans-sc/Medium.css" />
<link rel="stylesheet" href="/fonts/harmonyos-sans-sc/Bold.css" />
<!-- 首屏所有数字都是拉丁字形,先把它预取下来;中文字体是按需分片,预取哪一个都是猜。 -->
<link
rel="preload"
href="/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2"
as="font"
type="font/woff2"
crossorigin
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{
"name": "rmic-2026",
"private": true,
"version": "0.2.1",
"type": "module",
"description": "RMIC::2026 —— ESP32 图传与装甲板系统的桌面端:用户端(装甲条 + 瞄准器)与裁判端(双路 + 装甲板 + 录制)",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri",
"app": "tauri dev",
"check": "tsc --noEmit",
"version:write": "node ../scripts/version.mjs --write",
"version:check": "node ../scripts/version.mjs --check",
"test:stream": "node --test scripts/test-stream.mjs",
"test:latency": "node --test scripts/test-latency-marker.mjs"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.8.3",
"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"
}
}
+1305
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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,680 @@
/* Generated By cn-font-split@6.0.3 https://www.npmjs.com/package/cn-font-split
CreateTime: Fri, 06 Jun 2025 12:25:03 GMT;
Origin File Name Table:
256: Clock Tabular
257: Clock Proportional
258: Calculator Operators
259: Alternate “l”
260: Full Width Math Symbols
261: Punctuation Change
262: Red Circle Fig
263: Vert Figures
copyright: Copyright 2021-2025 Huawei Device Co., Ltd. All Rights Reserved.
fontFamily: HarmonyOS Sans SC
fontSubfamily: Bold
uniqueID: HarmonyOS Sans SC Bold Version 2.400
fullName: HarmonyOS Sans SC Bold
version: Version 2.400
postScriptName: HarmonyOS_Sans_SC_Bold
manufacturer: Hanyi Fonts
designer: Huawei Device Co., Ltd & Hanyi Fonts
preferredFamily: HarmonyOS Sans SC
preferredSubfamily: Bold
*/
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_53d21e.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+251,U+259,U+261,U+292,U+2bb-2bc,U+2ca-2cb,U+2d8-2dd,U+2f3,U+301,U+303,U+306-30a,U+30f,U+323,U+326-327,U+335,U+338,U+1e00-1e01,U+1e3e-1e3f,U+1e80-1e85,U+1ea0-1ef9,U+2004-2006,U+2009-200a,U+2010-2011,U+2013-201e,U+2020-2022,U+2025-2029,U+2030,U+2032-2037,U+2039-203b,U+2044,U+2052,U+2060-2064,U+2066-2069,U+2070,U+2074-2079,U+207f-2089,U+20a3-20a4,U+20a6-20ac,U+20b1,U+20b9-20ba,U+20bc-20bd,U+2103,U+2105,U+2109,U+2113,U+2116,U+2121-2122,U+2126,U+212e,U+215b-215e,U+2160-216b,U+2170-2179,U+2190-2193,U+2196-2199,U+2202,U+2206,U+2208,U+220f,U+2211-2212,U+2215,U+221a,U+221d-2220,U+2223,U+2225,U+2227-222b,U+222e,U+2234-2237,U+223d,U+2248,U+224c,U+2252,U+2260-2261,U+2264-2267,U+226e-226f,U+2295,U+2299,U+22a5,U+22bf,U+2312,U+2460-2469,U+2474-249b,U+2500-254b,U+2550-2573,U+2581-258f,U+2593-2595,U+25a0-25a1,U+25b2-25b3,U+25bc-25bd,U+25c6-25c7,U+25ca-25cb,U+25ce-25cf,U+25e2-25e5,U+2605-2606,U+2609,U+2640,U+2642,U+9f42-9f43,U+9f45-9f46,U+9f48-9f49,U+9f4c-9f4d,U+9f53,U+9f55-9f58,U+9f5a-9f5b,U+9f5d-9f5e,U+9f62,U+9f64-9f65,U+9f68-9f69,U+9f6b,U+9f6d-9f71,U+9f73-9f75,U+9f78-9f7e,U+9f81-9f82,U+9f8e-9f8f,U+9f91-9f93,U+9f96-9f98,U+9f9d-9f9e,U+9fa1-9fff,U+f9f1,U+fa0e-fa0f,U+fa11,U+fa13-fa14,U+fa1f,U+fa21,U+fa23-fa24,U+fa27-fa29,U+fe49-fe52,U+fe54-fe57,U+fe59-fe66,U+fe68-fe6b,U+ff01-ff03,U+ff05-ff0a,U+ff0c-ff1b,U+ff1f-ff3d,U+ff3f,U+ff41-ff5b,U+ff5d,U+ff5f-ff60,U+ffe6;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_70874c.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+9db2-9db3,U+9db5-9dba,U+9dbd-9dbf,U+9dc1,U+9dc3-9dd2,U+9dd4-9dd6,U+9dd8,U+9ddb-9de4,U+9de7-9dee,U+9df0-9df1,U+9df4-9df7,U+9dfb-9e0b,U+9e0d-9e14,U+9e16-9e19,U+9e1c,U+9e24,U+9e27,U+9e2e,U+9e30,U+9e34,U+9e3b-9e3c,U+9e40,U+9e4d,U+9e50,U+9e52-9e54,U+9e56,U+9e59,U+9e5d,U+9e5f-9e62,U+9e65,U+9e6e-9e6f,U+9e72,U+9e74-9e78,U+9e7b,U+9e80-9e81,U+9e83-9e84,U+9e86,U+9e89-9e8a,U+9e8c-9e91,U+9e94-9e96,U+9e98-9e9c,U+9e9e,U+9ea0-9ea4,U+9ea7-9ea8,U+9eaa-9eb3,U+9eb5-9eb7,U+9eb9-9eba,U+9ebf-9ec2,U+9ec5-9ec8,U+9eca-9ecb,U+9ed0,U+9ed3,U+9ed5-9ed7,U+9ed9-9eda,U+9ee1,U+9ee3-9ee4,U+9ee6,U+9eeb-9eee,U+9ef0-9ef1,U+9ef3,U+9ef5-9ef6,U+9ef8,U+9efa,U+9f00-9f08,U+9f0a,U+9f0c,U+9f0f,U+9f11-9f12,U+9f14,U+9f16,U+9f18,U+9f1a-9f1f,U+9f23-9f2b,U+9f2d-9f2e,U+9f30-9f33,U+9f35-9f36,U+9f38,U+9f3a,U+9f3c,U+9f3f-9f41;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_d2da49.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+9c2f-9c30,U+9c34,U+9c36,U+9c38,U+9c3a,U+9c3c-9c3d,U+9c3f-9c44,U+9c46-9c47,U+9c4a-9c51,U+9c53,U+9c55,U+9c59-9c5c,U+9c5e,U+9c60-9c66,U+9c68-9c6c,U+9c6e-9c76,U+9c79,U+9c7b,U+9c7d-9c7e,U+9c80,U+9c83-9c84,U+9c89-9c8a,U+9c8c,U+9c8f,U+9c93,U+9c96-9c99,U+9c9d,U+9caa,U+9cac,U+9caf,U+9cb9,U+9cbe-9cc2,U+9cc8-9cc9,U+9cd1-9cd2,U+9cda-9cdb,U+9ce0-9ce1,U+9ce3-9ce4,U+9ce6,U+9ce8,U+9cea-9cf2,U+9cf5,U+9cf7-9d05,U+9d08,U+9d0a-9d14,U+9d16-9d1a,U+9d1c,U+9d1e,U+9d20-9d22,U+9d24-9d25,U+9d27,U+9d29-9d2e,U+9d31-9d3a,U+9d3c-9d3e,U+9d40-9d41,U+9d43-9d50,U+9d54-9d5b,U+9d5e-9d5f,U+9d62-9d69,U+9d6b,U+9d6d-9d6e,U+9d70-9d71,U+9d73-9d86,U+9d88,U+9d8a-9d92,U+9d94-9d97,U+9d99,U+9d9b-9da4,U+9da6-9da8,U+9daa-9dae,U+9db0-9db1;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_e8003a.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+9ac8-9aca,U+9acd-9ace,U+9ad0,U+9ad7,U+9ad9-9ade,U+9ae0,U+9ae2-9ae5,U+9ae7-9aea,U+9aec,U+9aee,U+9af0-9af8,U+9afa,U+9afc-9b02,U+9b04-9b05,U+9b07,U+9b09-9b0e,U+9b10-9b12,U+9b14-9b1e,U+9b20-9b21,U+9b24,U+9b26,U+9b2a-9b2d,U+9b30,U+9b33-9b3a,U+9b3d-9b40,U+9b46,U+9b4a-9b4c,U+9b50,U+9b52-9b53,U+9b55-9b57,U+9b59,U+9b5b-9b6e,U+9b70-9b73,U+9b75-9b76,U+9b78-9b80,U+9b82,U+9b84-9b8d,U+9b8f,U+9b93-9b99,U+9b9b-9b9d,U+9b9f-9ba9,U+9bac,U+9baf-9bbf,U+9bc2-9bc6,U+9bc8,U+9bcb-9bd3,U+9bd5,U+9bd8-9bda,U+9bdc,U+9bde-9be0,U+9be3,U+9be5-9be6,U+9be9,U+9bec-9bef,U+9bf1-9bf3,U+9bf5-9bfc,U+9bfe,U+9c00-9c07,U+9c0a-9c0c,U+9c0e-9c11,U+9c14-9c1f,U+9c21-9c22,U+9c24,U+9c26-9c27,U+9c2a-9c2c,U+9c2e;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_bba9bc.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+98c7,U+98c9-98cd,U+98cf-98d0,U+98d4,U+98d6-98d7,U+98dc-98dd,U+98e1,U+98e3-98e6,U+98ec,U+98ee,U+98f0,U+98f3,U+98f5-98fb,U+98ff-9902,U+9904,U+9906-9907,U+990b,U+990e-990f,U+9914-9917,U+9919,U+991c-991d,U+991f-9920,U+9922-9927,U+9929-992b,U+992d,U+992f-9930,U+9932,U+9934,U+9936,U+9938-993a,U+993d,U+9940-9942,U+9944,U+9946-9947,U+994d-9951,U+9953,U+9956,U+9958-995b,U+995d,U+995f-9961,U+9964,U+9966,U+9973,U+9978-9979,U+997b,U+997e,U+9982-9983,U+9989,U+998c,U+998e,U+999a-99a4,U+99a6-99a7,U+99a9-99ab,U+99af-99b0,U+99b2,U+99b5-99c0,U+99c2-99cf,U+99d3,U+99d6-99d7,U+99da,U+99dc,U+99de,U+99e0-99e1,U+99e3-99ec,U+99ee-99f0,U+99f2-99fe,U+9a00,U+9a02-9a04,U+9a06-9a0c,U+9a10-9a15,U+9a17-9a18,U+9a1a-9a2a,U+9a2c,U+9a2f,U+9a31-9a35,U+9a39-9a3d,U+9a3f,U+9a46-9a49,U+9a4b-9a4c,U+9a4e,U+9a50-9a54,U+9a56,U+9a58-9a59,U+9a5c-9a5e,U+9a60-9a61,U+9a63,U+9a66-9a69,U+9a6b,U+9a72,U+9a83,U+9a89,U+9a8d-9a8e,U+9a94-9a95,U+9a99,U+9aa6,U+9aa9-9aae,U+9ab2-9ab5,U+9ab9,U+9abb,U+9abd-9abf,U+9ac3-9ac4,U+9ac6-9ac7;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_0ca5a9.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+96e1,U+96e4-96e7,U+96eb-96ee,U+96f0-96f1,U+96f4-96f5,U+96f8,U+96fa,U+96fc-96fd,U+96ff,U+9702-9703,U+9705,U+970a-970c,U+9710,U+9712,U+9714-9715,U+9717-971b,U+971d,U+971f-9726,U+9728-9729,U+972b-972c,U+972e-972f,U+9731,U+9733-9737,U+973a-973c,U+973f-9741,U+9743,U+9745-9747,U+9749-9751,U+9754-9755,U+9757-9758,U+975d,U+975f,U+9763-9764,U+9767,U+976a-9772,U+9775,U+9777-977b,U+977d-9784,U+9786-978a,U+978c,U+978e,U+9790,U+9793,U+9795-9797,U+9799-979c,U+979e-979f,U+97a1-97a2,U+97a4-97a5,U+97a7-97aa,U+97ac,U+97ae,U+97b0-97b1,U+97b3,U+97b5-97bc,U+97be-97c2,U+97c4-97c5,U+97c7-97c8,U+97ca,U+97cd-97d2,U+97d4-97d8,U+97da-97db,U+97df-97e5,U+97e8,U+97ee-97f2,U+97f4,U+97f7-97fa,U+97fc-97fe,U+9800,U+9804,U+9809,U+980b,U+980d,U+9814-9816,U+9819-981b,U+981d-9820,U+9822-9823,U+9825,U+9827-982c,U+982e-982f,U+9831-9836,U+983a,U+983c-9845,U+9847-984b,U+9850-9852,U+9854-9855,U+9857,U+985a,U+985c-985d,U+985f-9861,U+9863-9864,U+9866,U+9868-986a,U+986d-986e,U+9872,U+988b,U+988e,U+9892,U+9895,U+9899,U+98a3,U+98a9-98ad,U+98b0-98b2,U+98b4-98b5,U+98b7-98bb,U+98bd-98c3,U+98c5;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_22048b.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+9213,U+9216-921d,U+921f-9222,U+9224,U+9228-922d,U+922f,U+9231-9232,U+9235-9236,U+923b-923c,U+9241-9244,U+9246-9247,U+924a-924c,U+924e-9250,U+9252-9254,U+9256,U+9258-9259,U+925c-925d,U+925f-9261,U+9263,U+9265,U+9267-926b,U+926e-9273,U+9275-9277,U+9279,U+927c-927e,U+9281-9282,U+9284,U+9286-928d,U+928f-9290,U+9292,U+9294-9295,U+9297,U+9299,U+929b,U+929d-929f,U+92a1-92a2,U+92a4,U+92a7,U+92ad,U+92af-92b0,U+92b2,U+92b4-92b6,U+92b8-92ba,U+92bd-92c0,U+92c2,U+92c4,U+92c6,U+92c9-92cb,U+92cd-92ce,U+92d0-92d1,U+92d3-92dc,U+92de,U+92e0-92e2,U+92e7,U+92e9,U+92eb-92ed,U+92f2-92f5,U+92f7,U+92f9-92fb,U+92fd-9300,U+9302-9303,U+9305,U+9309-930f,U+9311,U+9313-9314,U+9316-9317,U+931c-931e,U+9321,U+9323-9325,U+9327,U+9329-932a,U+932c-932d,U+9330-9332,U+9334-9335,U+9337,U+9339-933d,U+933f,U+9342,U+9344-9345,U+9348-934a,U+934c,U+934e-9353,U+9355-9357,U+9359-935a,U+935c-9363,U+9366-9368,U+936b,U+936d-936f,U+9371-9374,U+9377-9379,U+937b-937d,U+937f-9381,U+9383,U+9385-9386,U+9388-9389,U+938b-938e,U+9390-9395,U+9397,U+9399-93a1,U+93a4-93a5,U+93a8,U+93ab,U+93ad,U+93af,U+93b1-93b2,U+93b4,U+93b6-93be,U+93c0-93c2,U+93c4-93c6,U+93c9,U+93cb,U+93ce-93d0,U+93d2-93d5,U+93d9-93db,U+93e0,U+93e3,U+93e5;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_3c428d.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+93e6-93e7,U+93e9-93f4,U+93f6,U+93f8,U+93fa-93fc,U+93fe-9402,U+9404-940a,U+940c-940f,U+9411,U+9415-9418,U+941a-941f,U+9421-9425,U+9429-942a,U+942c-942d,U+942f-9431,U+9434,U+9436-9437,U+9439,U+943b-943d,U+9440-9443,U+9445-9449,U+944b,U+944d-9450,U+9452-9453,U+9455-945f,U+9461-9462,U+9464,U+9466-946a,U+946c,U+946e-946f,U+9471,U+9473-9476,U+9478,U+947a-947b,U+9480,U+9482-9484,U+9491,U+9496,U+9498,U+94c7,U+94cf,U+94d3-94d4,U+94da,U+94e6,U+94fb,U+951c,U+9520,U+9527,U+9533,U+953d,U+9543,U+9548,U+954b,U+9555,U+955a,U+9560,U+956e,U+9574-9575,U+9578-957e,U+9581,U+9584-9585,U+9587-9588,U+958a,U+958d,U+9590-9591,U+9595-9597,U+9599-95a0,U+95a2,U+95a6-95a7,U+95aa,U+95ae-95b0,U+95b2-95b5,U+95b7-95b8,U+95ba,U+95c0-95c2,U+95c4-95c5,U+95c9,U+95cd-95cf,U+95d1-95d3,U+95d7-95db,U+95dd,U+95df-95e0,U+95e3-95e4,U+95e6-95e7,U+95ec,U+95ff,U+9607,U+9613,U+9618,U+961b,U+961e,U+9620,U+9623-9629,U+962b-962d,U+962f-9630,U+9637-963a,U+963e,U+9641,U+9643,U+964a,U+964e-964f,U+9651-9653,U+9656-9657,U+9659-965a,U+965c,U+965e,U+9660,U+9665-9666,U+966b,U+966d-966f,U+9671,U+9679-967c,U+967e-9684,U+9687,U+968c,U+9691-9693,U+9696,U+969a,U+969d-96a6,U+96a9,U+96ab-96af,U+96b2,U+96b5,U+96b7,U+96ba,U+96bf,U+96c2-96c3,U+96ca,U+96d0-96d1,U+96d3-96d4,U+96d7-96d8,U+96da,U+96dd,U+96df;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_d9776c.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+8f2e,U+8f30-8f32,U+8f34-8f37,U+8f39-8f3a,U+8f3c-8f3d,U+8f40-8f41,U+8f43,U+8f47-8f48,U+8f4a-8f4c,U+8f4f-8f53,U+8f55-8f5e,U+8f60,U+8f63,U+8f65,U+8f6a,U+8f80,U+8f8c,U+8f92,U+8f9d,U+8fa0-8fa2,U+8fa4-8fa5,U+8fa7,U+8faa,U+8fac,U+8fb3-8fb5,U+8fb7-8fb8,U+8fba-8fbc,U+8fbf-8fc0,U+8fc3,U+8fc6,U+8fc9-8fcd,U+8fcf,U+8fd2,U+8fd6-8fd7,U+8fda,U+8fe0-8fe1,U+8fe3,U+8fe7,U+8fec,U+8fef,U+8ff1-8ff2,U+8ff5-8ff6,U+8ffa-8ffc,U+8ffe-8fff,U+9007-9008,U+900c,U+900e,U+9013,U+9018,U+901c,U+9024-9025,U+9027-902b,U+9030-9031,U+9033-9034,U+9037,U+9039-903a,U+903d,U+903f-9040,U+9043,U+9045-9046,U+9048-9049,U+904c,U+9056,U+905a,U+905f,U+9061,U+9064,U+9066-9067,U+906a-906c,U+906f-9071,U+9073,U+9076,U+9079,U+907b,U+907e,U+9085-9086,U+9089,U+908c-908e,U+9092,U+9094,U+9096,U+9098,U+909a,U+909c,U+909e-90a0,U+90a4-90a5,U+90a7-90a9,U+90ab,U+90ad,U+90b2,U+90b7,U+90bc-90bd,U+90bf-90c0,U+90c2-90c3,U+90c6,U+90c8-90c9,U+90cb-90cd,U+90d2,U+90d4-90d6,U+90d8-90da,U+90de,U+90e0,U+90e3,U+90e5,U+90e9-90ea,U+90ec,U+90ee,U+90f0-90f3,U+90f6-90f7,U+90f9-90fc,U+90ff-9101,U+9103,U+9105,U+9107-9108,U+910a-9111,U+9113,U+9115,U+9117-9118,U+911a-911d,U+911f-9121,U+9124-9126,U+9128-912c,U+912e,U+9133,U+9135,U+9137-9138,U+913b-9142,U+9144-9145,U+9147,U+9151,U+9153-9156,U+9158-9159,U+915b-915c,U+915f-9160,U+9166-9168,U+916b,U+916d,U+9173,U+917a-917c,U+9180-9182,U+9184,U+9186,U+9188,U+918a,U+918e-918f,U+9193-9199,U+919d,U+919f-91a1,U+91a4-91a9,U+91b0-91b3,U+91b6-91b9,U+91bb-91bf,U+91c2,U+91c4,U+91c6,U+91c8,U+91d6,U+91da-91db,U+91de-91e2,U+91e5,U+91e8,U+91ea-91f4,U+91f6,U+91f8,U+91fb-91ff,U+9202-9203,U+9205-9207,U+920a-920c,U+920e-920f,U+9212;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_09158a.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+8cdf,U+8ce5,U+8ce8-8ce9,U+8ceb,U+8cee-8cf3,U+8cf5-8cf9,U+8cff-8d03,U+8d06-8d07,U+8d09,U+8d0b-8d0c,U+8d0e,U+8d11-8d12,U+8d14-8d15,U+8d18-8d1a,U+8d1c,U+8d20,U+8d51-8d52,U+8d57,U+8d5f,U+8d65,U+8d68-8d6a,U+8d6c,U+8d6e-8d6f,U+8d71-8d72,U+8d78-8d80,U+8d82-8d83,U+8d86-8d89,U+8d8c-8d90,U+8d92-8d93,U+8d96-8d98,U+8d9a-8d9e,U+8da0-8da2,U+8da4-8da7,U+8da9-8db0,U+8db6-8db7,U+8db9,U+8dbb,U+8dbd,U+8dc0-8dc2,U+8dc5,U+8dc7-8dca,U+8dcd,U+8dd0,U+8dd2-8dd5,U+8dd8-8dd9,U+8ddc,U+8de0,U+8de2,U+8de5-8de7,U+8de9,U+8ded-8dee,U+8df0-8df2,U+8df4,U+8df6,U+8dfc,U+8dfe-8e04,U+8e06-8e08,U+8e0b,U+8e0d-8e0e,U+8e11-8e13,U+8e15-8e1c,U+8e20-8e21,U+8e24-8e28,U+8e2b,U+8e2d,U+8e32-8e33,U+8e36-8e38,U+8e3b-8e3c,U+8e3e-8e3f,U+8e43,U+8e45-8e46,U+8e4d-8e50,U+8e53-8e54,U+8e56-8e58,U+8e5a-8e5f,U+8e61-8e62,U+8e65,U+8e67-8e68,U+8e6a-8e6b,U+8e6e,U+8e71,U+8e73,U+8e75,U+8e77-8e79,U+8e7b,U+8e7d-8e7e,U+8e80,U+8e82-8e84,U+8e86,U+8e88,U+8e8c,U+8e8e,U+8e96-8e99,U+8e9b,U+8e9d,U+8e9f-8ea0,U+8ea2-8ea4,U+8ea7-8ea9,U+8ead-8eae,U+8eb0-8eb1,U+8eb3-8eb9,U+8ebb-8ebf,U+8ec1-8ec9,U+8ecf-8ed1,U+8ed3,U+8ed5-8eda,U+8edc-8ede,U+8ee0-8ee3,U+8ee5-8eea,U+8eec-8ef1,U+8ef3-8ef7,U+8efd,U+8eff-8f02,U+8f04,U+8f06,U+8f08,U+8f0b-8f11,U+8f16-8f1a,U+8f20-8f24,U+8f27-8f28,U+8f2b-8f2d;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_a0f240.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+8a4a-8a4d,U+8a4f,U+8a51,U+8a53,U+8a56-8a57,U+8a59-8a5a,U+8a5c-8a5d,U+8a5f,U+8a64-8a65,U+8a67-8a68,U+8a6a,U+8a6f,U+8a74,U+8a76-8a78,U+8a7a-8a7b,U+8a7d-8a7e,U+8a80-8a83,U+8a88,U+8a8b,U+8a8e-8a90,U+8a94,U+8a96-8a97,U+8a99,U+8a9b-8a9d,U+8a9f,U+8aa2,U+8aa7,U+8aa9,U+8aab-8aaf,U+8ab1,U+8ab3-8ab5,U+8ab7-8ab8,U+8aba-8abb,U+8abd-8abe,U+8ac0-8ac1,U+8ac3,U+8ac5-8ac6,U+8ac8,U+8aca,U+8acc,U+8ace,U+8ad0,U+8ad3-8ad5,U+8ad8-8ada,U+8add,U+8adf-8ae0,U+8ae3,U+8ae5,U+8ae8-8aea,U+8aec,U+8aef-8af0,U+8af2,U+8af4-8af5,U+8af9,U+8afb,U+8afd,U+8aff,U+8b03,U+8b06,U+8b08-8b09,U+8b0b-8b0d,U+8b0f,U+8b11-8b13,U+8b15,U+8b18,U+8b1a,U+8b1c,U+8b1e-8b1f,U+8b21-8b25,U+8b27,U+8b29-8b2a,U+8b2d-8b32,U+8b34-8b38,U+8b3a-8b3d,U+8b3f-8b40,U+8b42-8b48,U+8b4a-8b4d,U+8b50-8b55,U+8b57,U+8b5b,U+8b5d-8b5e,U+8b60-8b65,U+8b67-8b6a,U+8b6d-8b6e,U+8b71-8b73,U+8b75-8b76,U+8b78-8b7c,U+8b7f,U+8b81-8b89,U+8b8b-8b8d,U+8b8f-8b91,U+8b94,U+8b97-8b9b,U+8b9d,U+8b9f,U+8bac,U+8bb1,U+8bbb,U+8bc7,U+8bd0,U+8bea,U+8c09,U+8c1e,U+8c38-8c40,U+8c42-8c45,U+8c4a-8c4b,U+8c4d,U+8c4f,U+8c51-8c53,U+8c56-8c59,U+8c5b-8c60,U+8c63-8c69,U+8c6d-8c72,U+8c74-8c77,U+8c7b-8c81,U+8c83-8c84,U+8c86-8c88,U+8c8b,U+8c8e-8c92,U+8c95-8c97,U+8c99-8c9a,U+8c9c,U+8c9f,U+8ca3-8ca6,U+8cad-8cae,U+8cb1,U+8cb5,U+8cb9,U+8cbe,U+8cc6,U+8cc9,U+8ccb-8cd0,U+8cd4,U+8cd6-8cd9,U+8cdb,U+8cdd;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_e7152d.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+883f,U+8841-8843,U+8847-8849,U+884b,U+884e-8852,U+8856,U+8858,U+885a,U+885c,U+885e-8860,U+8866-8867,U+886a,U+886d,U+886f,U+8871,U+8873-8876,U+8878-887c,U+8880,U+8883,U+8886-8887,U+8889-888a,U+888c,U+888e-8891,U+8893-8895,U+8897-889b,U+889d,U+889f-88a1,U+88a3,U+88a5-88aa,U+88ac,U+88ae-88b0,U+88b2-88b6,U+88b8-88bb,U+88bd-88c0,U+88c3-88c4,U+88c7-88c8,U+88ca-88cd,U+88d0-88d1,U+88d3,U+88d6-88d7,U+88da-88db,U+88de,U+88e0-88e1,U+88e6-88e7,U+88e9-88ef,U+88f2,U+88f5-88f7,U+88fa-88fb,U+88ff-8901,U+8903-8909,U+890b-890f,U+8911,U+8914-8918,U+891c-8920,U+8922-8924,U+8926-8929,U+892c-892f,U+8931,U+8935,U+8937,U+8939-893a,U+893c-8940,U+8942-8943,U+8945,U+8947-8948,U+894a-8955,U+8957-895c,U+8961-8963,U+8965,U+8967-8969,U+896b-896e,U+8970-8971,U+8973-897a,U+897c-897e,U+8980,U+8982,U+8984-8985,U+8987,U+8989-898a,U+898c-898e,U+8990-8992,U+8994-8995,U+8997,U+8999-89a0,U+89a2-89a5,U+89a7-89a9,U+89ab,U+89ad-89ae,U+89b0-89b1,U+89b3-89b6,U+89b8-89b9,U+89bb-89bc,U+89be,U+89c3,U+89cd,U+89d3,U+89d5,U+89d7-89d9,U+89db,U+89dd,U+89df-89e2,U+89e4,U+89e7-89ea,U+89ec-89ee,U+89f0-89f2,U+89f5,U+89f7,U+89f9-89ff,U+8a04-8a06,U+8a09,U+8a0b,U+8a0d,U+8a0f,U+8a11-8a12,U+8a14,U+8a19-8a1a,U+8a1c,U+8a1e,U+8a20-8a22,U+8a24,U+8a26-8a29,U+8a2b-8a2c,U+8a2e-8a30,U+8a32-8a33,U+8a35,U+8a37-8a39,U+8a3c-8a3d,U+8a3f-8a40,U+8a42-8a45,U+8a47,U+8a49;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_d8e0ef.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+8641-864c,U+8652-8653,U+8656-8659,U+865d,U+8660-8661,U+8663-8666,U+8668-866a,U+866d,U+8670,U+8672-8678,U+8683-8689,U+868e-8692,U+8694,U+8696-869b,U+869e-86a2,U+86a5-86a6,U+86ab,U+86ad-86ae,U+86b2-86b3,U+86b7-86b9,U+86bb-86bf,U+86c1-86c3,U+86c5,U+86c8,U+86cc-86cd,U+86d2-86d3,U+86d5-86d7,U+86da,U+86dc-86dd,U+86e0-86e3,U+86e5-86e8,U+86ea-86ec,U+86ef,U+86f5-86f7,U+86fc-86fd,U+86ff,U+8701,U+8704-8705,U+870b-870c,U+870e-8711,U+8714,U+8719,U+871b,U+871d,U+871f-8720,U+8724,U+8726-8728,U+872a-872d,U+872f-8730,U+8732-8733,U+8735-8736,U+8738-873a,U+873c-873d,U+8740-8746,U+874a-874b,U+874d,U+874f-8752,U+8754,U+8756,U+8758,U+875a-875e,U+8761-8762,U+8767,U+8769-876d,U+876f,U+8771-8773,U+8775,U+8777,U+8779-877a,U+877f-8781,U+8786-8787,U+8789-878a,U+878c,U+878e-8792,U+8794-8796,U+8798-879d,U+87a0-87a1,U+87a3-87a7,U+87a9-87aa,U+87ae,U+87b0-87b2,U+87b4,U+87b6-87b9,U+87bc,U+87be-87bf,U+87c1-87c3,U+87c5,U+87c7,U+87c9,U+87cc-87cd,U+87cf-87d0,U+87d4-87da,U+87dc-87df,U+87e1-87e2,U+87e4,U+87e6-87e9,U+87eb,U+87ed,U+87f0-87f1,U+87f3-87f5,U+87f7-87f8,U+87fa,U+87fc-87fd,U+87ff-8802,U+8804,U+8807-8809,U+880b-880c,U+880e-880f,U+8812,U+8817-881a,U+881c-881e,U+8820,U+8824-8830,U+8833-8835,U+8837-8838,U+883a,U+883d-883e;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_0cdc77.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+848d,U+848f,U+8491-8493,U+8495-8496,U+8498,U+849a-849b,U+849d,U+849f-84a0,U+84a2-84ae,U+84b0-84b1,U+84b3,U+84b5-84b7,U+84bb,U+84be,U+84c2-84c3,U+84c5,U+84c7-84c8,U+84cc,U+84ce-84cf,U+84d2,U+84d4-84d5,U+84d7-84dc,U+84de,U+84e1-84e2,U+84e4,U+84e8-84eb,U+84ed,U+84f1-84f3,U+84f5-84fb,U+84fe,U+8500-850b,U+850d-8510,U+8512,U+8515-8516,U+8518-8519,U+851b-851d,U+8520,U+8522,U+8524,U+8527-852a,U+852e-8536,U+853e-8540,U+8542,U+8544-8545,U+8547,U+854b-854d,U+854f-8551,U+8554,U+8557,U+855a-855d,U+855f-8561,U+8563,U+8565-8567,U+856b-856c,U+856e-8571,U+8573,U+8575-8576,U+8578,U+857c-857d,U+857f-8583,U+8586,U+8589,U+858b,U+858d-858e,U+8590,U+8592-8593,U+8595-859a,U+859d-859e,U+85a0-85a3,U+85a5,U+85a7,U+85ab-85ad,U+85b1-85b6,U+85b8,U+85bb-85c0,U+85c2-85c8,U+85ca-85cc,U+85d1-85d2,U+85d4,U+85d6-85db,U+85de-85e3,U+85e6-85e8,U+85eb-85f5,U+85f7-85f8,U+85fc-85fe,U+8600-8603,U+8608-8609,U+860c-8610,U+8612-8615,U+8617-8619,U+861b-861d,U+861f-8621,U+8623-8626,U+8628,U+862a-862c,U+862e-8637,U+8639,U+863b,U+863d-863e,U+8640;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_2f7c7d.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+81ac-81b2,U+81b4-81b9,U+81bc,U+81c4-81c5,U+81c7-81c8,U+81cb,U+81ce,U+81d0-81d1,U+81d3-81d7,U+81d9,U+81db-81df,U+81e1-81e2,U+81e4,U+81e6,U+81e9,U+81eb,U+81ee-81f2,U+81f5-81fa,U+81fd,U+81ff,U+8203,U+820b,U+820e-820f,U+8211,U+8213,U+8215-821a,U+821d,U+8220,U+8224-8227,U+8229,U+822e,U+8232,U+823a,U+823c-823d,U+823f-8243,U+8245-8246,U+8248,U+824a,U+824c-824e,U+8250-8257,U+825b-825e,U+8260-8263,U+8265,U+8267,U+8269-826a,U+826c-826d,U+8275-8277,U+827b-827c,U+8280-8281,U+8283,U+8285-8287,U+8289,U+828c,U+8290,U+8293-8296,U+829a-829b,U+829e,U+82a0,U+82a2-82a3,U+82a7,U+82b2,U+82b5-82b6,U+82ba,U+82bc,U+82bf-82c0,U+82c2-82c3,U+82c5-82c6,U+82c9,U+82d0,U+82d6,U+82d9-82da,U+82dd,U+82e2,U+82e8-82ea,U+82ec-82ee,U+82f0,U+82f2-82f3,U+82f5-82f6,U+82f8,U+82fa,U+82fc-8300,U+830a-830b,U+830d,U+8310,U+8312-8313,U+8316,U+8318-8319,U+831d-8326,U+8329-832a,U+832e,U+8330,U+8337,U+833b,U+833d-833f,U+8341-8342,U+8344-8345,U+8348,U+834b-834e,U+8353,U+8355-8359,U+835d,U+8362,U+8370-8376,U+8379-837a,U+837e-8384,U+8387-8388,U+838b-838d,U+838f-8391,U+8394-8395,U+8397,U+8399-839a,U+839d,U+839f,U+83a1,U+83a3-83a6,U+83ac-83af,U+83b5,U+83bb,U+83be-83bf,U+83c2-83c4,U+83c6,U+83c8-83c9,U+83cb,U+83cd-83ce,U+83d0-83d2,U+83d5,U+83d7,U+83d9-83db,U+83de,U+83e2-83e4,U+83e6-83e8,U+83eb-83ee,U+83f3,U+83f5-83f7,U+83fa-83fc,U+83fe-8400,U+8402,U+8405,U+8408-8409,U+8410,U+8412-8417,U+8419-841b,U+841e-8423,U+8429-842b,U+842d-8430,U+8432-8434,U+8436-8437,U+8439-843b,U+843e-8445,U+8447-8448,U+844a-8450,U+8453-8456,U+8458,U+845d-8460,U+8462,U+8465,U+8467-8468,U+846a,U+846e-8470,U+8472,U+8474,U+8479,U+847b-8481,U+8483-8486,U+848a;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_138611.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+7e8b,U+7e8e,U+7e90-7e92,U+7e95,U+7e97,U+7e99-7e9a,U+7e9d-7e9e,U+7eae,U+7eb4,U+7ebb-7ebc,U+7ed6,U+7ee4,U+7eec,U+7ef9,U+7f0a,U+7f10,U+7f1e,U+7f37,U+7f39,U+7f3b-7f41,U+7f43,U+7f46-7f4b,U+7f4d-7f4f,U+7f52-7f53,U+7f56,U+7f59,U+7f5b-7f5e,U+7f60,U+7f63-7f67,U+7f6b-7f6d,U+7f6f,U+7f73,U+7f76,U+7f78,U+7f7a-7f7d,U+7f7f-7f80,U+7f82-7f84,U+7f87,U+7f89,U+7f8d,U+7f8f-7f93,U+7f95-7f99,U+7f9b-7f9c,U+7fa0,U+7fa2,U+7fa6,U+7faa-7fae,U+7fb1,U+7fb3-7fb5,U+7fb7,U+7fba-7fbb,U+7fbe,U+7fc0,U+7fc2-7fc4,U+7fc6-7fc9,U+7fcb,U+7fcd,U+7fcf-7fd1,U+7fd3,U+7fd6-7fd7,U+7fd9-7fde,U+7fe2-7fe4,U+7fe7-7fe8,U+7fea,U+7fec-7fed,U+7fef,U+7ff2,U+7ff4-7ff8,U+7ffd-7fff,U+8002,U+8007-800a,U+800e-800f,U+8011,U+8013,U+801a-801b,U+801d-801f,U+8021,U+8023-8024,U+802b,U+802d,U+802f-8030,U+8032,U+8034,U+8039-803a,U+803c,U+803e,U+8040-8041,U+8044-8045,U+8047-8049,U+804e-8051,U+8053,U+8055,U+8057,U+8059,U+805b-805d,U+805f-8068,U+806b-806e,U+8074,U+8078,U+807a-807c,U+8081-8082,U+8088,U+808a,U+808d-8092,U+8094-8095,U+8097,U+8099,U+809e,U+80a3,U+80a6-80a8,U+80ac,U+80b0,U+80b3,U+80b5-80b6,U+80b8-80b9,U+80bb,U+80c5,U+80c7-80cb,U+80cf-80d5,U+80d8,U+80df-80e0,U+80e2-80e3,U+80e6,U+80ee,U+80f5,U+80f7,U+80f9,U+80fb,U+80fe-8101,U+8103-8104,U+8107,U+810b-810c,U+8115,U+8117,U+8119,U+811c-811d,U+811f-8122,U+8124-8128,U+812a,U+812d-812e,U+8130,U+8133-8135,U+8137,U+813a-813d,U+813f-8140,U+8142-8145,U+8147,U+8149,U+814d,U+814f,U+8152,U+8157-8158,U+815b-815f,U+8162-8164,U+8168,U+816a,U+816c,U+816f,U+8172,U+8175-8177,U+8184-8187,U+8189,U+818b-818e,U+8190,U+8192-8197,U+8199,U+819e-819f,U+81a1-81a2,U+81a4-81a5,U+81a7,U+81ab;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_16a8ba.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+7cab-7cad,U+7caf-7cb0,U+7cb4,U+7cb6-7cb8,U+7cba-7cbb,U+7cbf-7cc0,U+7cc2-7cc4,U+7cc6,U+7ccb,U+7cce-7cd4,U+7cd8,U+7cda-7cdb,U+7ce1-7ce6,U+7ce9-7cee,U+7cf0-7cf1,U+7cf3,U+7cf5,U+7cf7,U+7cfa,U+7cfc-7cfd,U+7cff,U+7d01,U+7d03,U+7d0c,U+7d0e-7d0f,U+7d11-7d12,U+7d16,U+7d18,U+7d1d-7d1f,U+7d23-7d26,U+7d28-7d2a,U+7d2c-7d2d,U+7d34-7d38,U+7d3b,U+7d3d-7d3e,U+7d41,U+7d45,U+7d47-7d4d,U+7d4f,U+7d51-7d54,U+7d56-7d5a,U+7d5c,U+7d5f-7d60,U+7d63-7d65,U+7d67,U+7d69-7d6d,U+7d6f-7d70,U+7d74-7d76,U+7d78,U+7d7a-7d80,U+7d82,U+7d84-7d85,U+7d87,U+7d89-7d8e,U+7d90,U+7d92,U+7d94-7d9b,U+7d9d,U+7d9f,U+7da1,U+7da4-7da5,U+7da7-7daa,U+7daf,U+7db3,U+7db6-7db7,U+7dbc,U+7dc0-7dc3,U+7dc5-7dc6,U+7dc8-7dc9,U+7dcc-7dd1,U+7dd3-7dd6,U+7dda-7ddc,U+7ddf,U+7de2,U+7de4-7de5,U+7de7,U+7dea-7deb,U+7ded-7dee,U+7df0,U+7df3,U+7df5,U+7df7-7df8,U+7dfa,U+7dfc-7e07,U+7e0c-7e0f,U+7e12-7e1a,U+7e1c,U+7e20-7e22,U+7e24-7e2a,U+7e2c,U+7e2f-7e30,U+7e33-7e34,U+7e38,U+7e3a,U+7e3c,U+7e3f-7e40,U+7e42,U+7e44,U+7e49-7e51,U+7e53,U+7e56-7e59,U+7e5b-7e5d,U+7e5f-7e60,U+7e63-7e68,U+7e6b-7e6c,U+7e71-7e72,U+7e74-7e78,U+7e7a-7e7b,U+7e7f-7e81,U+7e83-7e87,U+7e89;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_2fefd6.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+7ab5,U+7ab7-7ab9,U+7abb-7abe,U+7ac0-7ac3,U+7ac6,U+7ac9,U+7acc-7ad5,U+7ad7-7ad8,U+7ada-7add,U+7ae1-7ae2,U+7ae4,U+7ae7-7aec,U+7aee,U+7af0-7af5,U+7af7-7af8,U+7afb-7afc,U+7afe,U+7b00-7b02,U+7b05,U+7b07,U+7b09,U+7b0c-7b0e,U+7b10,U+7b12-7b13,U+7b16-7b18,U+7b1a,U+7b1c-7b1d,U+7b1f,U+7b21-7b23,U+7b27,U+7b29,U+7b2d,U+7b2f-7b30,U+7b32,U+7b34-7b37,U+7b39,U+7b3b,U+7b3d,U+7b3f-7b44,U+7b48,U+7b4a,U+7b4e,U+7b53,U+7b55,U+7b57,U+7b59,U+7b5c,U+7b5e-7b5f,U+7b61,U+7b63-7b66,U+7b68-7b6d,U+7b6f-7b70,U+7b73-7b74,U+7b76,U+7b78,U+7b7a,U+7b7c-7b7d,U+7b7f,U+7b81-7b84,U+7b86-7b8a,U+7b8c,U+7b8e,U+7b91-7b93,U+7b96,U+7b98-7b9b,U+7b9e-7ba0,U+7ba3-7ba5,U+7bae-7bb0,U+7bb2-7bb3,U+7bb5-7bb7,U+7bb9-7bbf,U+7bc2-7bc3,U+7bc5,U+7bc8,U+7bca,U+7bcd-7bd0,U+7bd2,U+7bd4-7bd8,U+7bdb-7bdc,U+7bde-7be0,U+7be2-7be3,U+7be7-7be8,U+7beb-7bed,U+7bef-7bf0,U+7bf2,U+7bf4-7bf6,U+7bf8-7bfb,U+7bfd,U+7bff,U+7c01-7c06,U+7c08-7c0a,U+7c0e,U+7c10,U+7c12-7c15,U+7c17-7c1d,U+7c20,U+7c22,U+7c24-7c25,U+7c28-7c29,U+7c2c-7c36,U+7c39-7c3d,U+7c42,U+7c44-7c4b,U+7c4e-7c5b,U+7c5d-7c5e,U+7c61-7c63,U+7c65-7c68,U+7c6b,U+7c6d,U+7c6f-7c72,U+7c75-7c7a,U+7c7e-7c88,U+7c8a-7c90,U+7c93-7c94,U+7c96,U+7c99-7c9b,U+7ca0-7ca1,U+7ca3,U+7ca6-7ca9;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_c37197.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+7607-7609,U+760e-760f,U+7611-7612,U+7614,U+7616,U+761a,U+761c-761d,U+7623,U+7628,U+762c,U+762e-762f,U+7631-7632,U+7636-7637,U+7639-763a,U+763d,U+7641,U+7644-7645,U+7648,U+764a-764b,U+764e-7651,U+7653,U+7655,U+7657,U+7659-765b,U+765d,U+7660,U+7666,U+7668,U+766a,U+7673-7677,U+7679-767a,U+767f-7680,U+7683,U+7685,U+7689-768a,U+768c-768d,U+768f-7690,U+7692,U+7694-7695,U+7697-7698,U+769b-76a3,U+76a5-76ad,U+76af,U+76b3,U+76b5-76b7,U+76b9,U+76bb-76be,U+76c0-76c1,U+76c4,U+76c7,U+76c9,U+76cb-76cc,U+76d3,U+76d5,U+76d9-76da,U+76dd,U+76e0,U+76e2,U+76e6,U+76e8-76ed,U+76f0,U+76f3,U+76f5-76f7,U+76fa-76fb,U+76fd,U+76ff-7703,U+7705-7706,U+770a,U+770c,U+770e-7718,U+771b-771d,U+7721,U+7723-7724,U+7727,U+772a-772c,U+772e,U+7730-7734,U+7739,U+773b,U+773d-773f,U+7742,U+7744-7746,U+7748-774e,U+7752-7759,U+775d,U+775f-7760,U+7764,U+7767,U+7769-776a,U+776d-7778,U+777a-777c,U+7781-7783,U+7786,U+7788-778b,U+778f-7790,U+7793-7797,U+7799-779d,U+77a1,U+77a3-77a4,U+77a6,U+77a8,U+77ab,U+77ae-77af,U+77b1-77b2,U+77b4,U+77b6-77ba,U+77be,U+77c0-77cc,U+77ce-77d6,U+77d8-77d9,U+77dd-77e1,U+77e4,U+77e6,U+77e8,U+77ea,U+77f0-77f2,U+77f4-77f5,U+77f7,U+77f9-77fc,U+7803-7808,U+780a-780b,U+780e-7810,U+7813,U+7815,U+7819,U+781b,U+781e,U+7820-7822,U+7824,U+7828,U+782a-782b,U+782e-782f,U+7831-7833,U+7835-7836,U+783d,U+783f,U+7841-7842,U+7844,U+7846,U+7848-784b,U+784d,U+784f,U+7851,U+7853-7854,U+7858-785c,U+785e-7863,U+7865-7867,U+7869,U+7870-7876,U+7878-787b,U+787d-7886,U+7888,U+788a-788b,U+788f-7890,U+7892;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_eeb3fd.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+7894-7896,U+7899,U+789d-789e,U+78a0,U+78a2,U+78a4,U+78a6,U+78a8,U+78aa-78ac,U+78ae-78af,U+78b5-78b7,U+78bb,U+78bd,U+78bf-78c0,U+78c2-78c4,U+78c6-78c8,U+78cc-78cf,U+78d1-78d3,U+78d6-78d8,U+78db-78e2,U+78e4-78e6,U+78e9-78eb,U+78ed-78ee,U+78f0-78f1,U+78f3,U+78f5-78f6,U+78f8-78f9,U+78fb-78fc,U+78fe-7900,U+7902-7904,U+7907-790d,U+790f-7912,U+7914-7918,U+791a-791d,U+7920-7923,U+7925,U+7927-7929,U+792d-7930,U+7932-7933,U+7935-7939,U+793d,U+793f,U+7942-7945,U+7947,U+794a-7952,U+7954,U+7958-7959,U+7961,U+7963-7964,U+7966,U+7969-796c,U+796e,U+7970-7976,U+7979,U+797b-797e,U+7982-7983,U+7986-7989,U+798b-798c,U+7990-7999,U+799b-79a5,U+79a8-79a9,U+79ab-79ad,U+79af,U+79b2,U+79b4-79b8,U+79bc,U+79c2,U+79c4-79c5,U+79c7,U+79ca,U+79cc,U+79ce-79d0,U+79d3-79d4,U+79d6-79d7,U+79d9-79de,U+79e0-79e2,U+79e5,U+79e8,U+79ea,U+79ec,U+79ee,U+79f1-79f7,U+79f9-79fa,U+79fc,U+79fe-79ff,U+7a01,U+7a04,U+7a07,U+7a09-7a0a,U+7a0c,U+7a0f-7a13,U+7a15-7a16,U+7a18-7a19,U+7a1b,U+7a1d,U+7a21-7a22,U+7a24-7a2d,U+7a2f-7a30,U+7a32,U+7a34-7a36,U+7a38,U+7a3a,U+7a3e,U+7a41-7a45,U+7a47-7a4b,U+7a4f-7a50,U+7a52-7a56,U+7a58-7a60,U+7a63-7a68,U+7a6a-7a6c,U+7a6e-7a6f,U+7a71-7a73,U+7a75,U+7a7b-7a7e,U+7a82,U+7a85,U+7a87,U+7a89-7a8c,U+7a8e-7a90,U+7a93-7a94,U+7a99-7a9b,U+7a9e,U+7aa1-7aa4,U+7aa7,U+7aab,U+7ab0-7ab2,U+7ab4;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_76f2de.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+739e,U+73a0-73a1,U+73a3-73a7,U+73aa,U+73ac-73ad,U+73b1,U+73b4-73b6,U+73b8-73b9,U+73bc-73bf,U+73c1,U+73c3-73c7,U+73cb-73cc,U+73ce,U+73d2-73d8,U+73da-73dd,U+73df,U+73e1-73e4,U+73e6,U+73e8,U+73ea-73ec,U+73ee-73f1,U+73f3-73fd,U+73ff-7402,U+7404,U+7407-7408,U+740b-740e,U+7411-7419,U+741c-7421,U+7423-7424,U+7427,U+7429,U+742b,U+742d,U+742f,U+7431-7432,U+7437-7439,U+743b,U+743d-743e,U+7440,U+7442-7448,U+744a,U+744c-7454,U+7456,U+7458,U+745d,U+7460-7462,U+7465-7468,U+746b-746c,U+746e,U+7471-7475,U+7478-747d,U+747f,U+7482,U+7484-7486,U+7488,U+748a,U+748c-748d,U+748f,U+7491-749b,U+749d,U+749f-74a2,U+74a4-74a5,U+74aa-74af,U+74b1-74b9,U+74bb-74bc,U+74be,U+74c0-74c9,U+74cb-74ce,U+74d0-74d1,U+74d3,U+74d5-74d9,U+74db,U+74dd,U+74df,U+74e1,U+74e5,U+74e7-74ed,U+74f0-74f3,U+74f5,U+74f8-74fe,U+7500,U+7502-7503,U+7505-750b,U+750e,U+7510,U+7512,U+7514,U+7516-7517,U+751b,U+751d-751e,U+7520-7521,U+7523-7524,U+7526-7527,U+752a,U+752e,U+7534,U+7536,U+7539,U+753c-753d,U+753f,U+7541-7544,U+7546-7547,U+7549-754a,U+754d,U+7550-7553,U+7555-7558,U+755e-7561,U+7563-7564,U+7567-7569,U+756c-756f,U+7571,U+7573,U+7575,U+7577,U+757a-757e,U+7580-7582,U+7584-7585,U+7588-7589,U+758c-758e,U+7590,U+7593,U+7595,U+7598,U+759b-759c,U+759e,U+75a2,U+75a6-75aa,U+75ad,U+75b6-75b7,U+75ba-75bb,U+75bf-75c1,U+75c6,U+75cb-75cc,U+75ce-75d1,U+75d3,U+75d7,U+75da,U+75dc-75dd,U+75df,U+75e1,U+75e5,U+75e9,U+75ec-75ef,U+75f2-75f3,U+75f5-75f8,U+75fa-75fb,U+75fd,U+7604,U+7606;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_666915.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+716a-716b,U+716d,U+716f-7171,U+7174-7177,U+7179,U+717b-717c,U+717e-7183,U+7185-7189,U+718b-718e,U+7190-7191,U+7193,U+7195-7196,U+719a-719e,U+71a1-71a7,U+71a9-71ab,U+71ad-71b0,U+71b2,U+71b4,U+71b6-71b8,U+71ba-71bd,U+71bf-71c0,U+71c2,U+71c4-71c7,U+71ca-71cd,U+71cf-71d1,U+71d3,U+71d6-71d8,U+71da-71db,U+71dd-71de,U+71e1-71e4,U+71e8-71ec,U+71ef-71f3,U+71f5-71f8,U+71fa,U+71fd,U+71ff-7205,U+7207-720c,U+720e-720f,U+7211-721a,U+721c,U+721e-7227,U+7229,U+722b,U+722e-722f,U+7233-7234,U+723c,U+7241-7245,U+7249-724b,U+724e-7251,U+7253-7255,U+7257,U+725a,U+725c,U+725e,U+7263-7265,U+7268,U+726a-726d,U+7270-7271,U+7273,U+7276-7278,U+727b-727c,U+7282-7283,U+7285-7289,U+728c,U+728e,U+7290-7291,U+7293-7295,U+7297-729a,U+729c-729e,U+72a0-72a1,U+72a3-72a6,U+72a8-72ab,U+72ae,U+72b1-72b3,U+72b5,U+72ba-72bf,U+72c5-72c7,U+72c9-72cc,U+72cf,U+72d1,U+72d3-72d6,U+72d8,U+72da-72dd,U+72df,U+72e2-72e7,U+72ea-72eb,U+72f5-72f6,U+72fe-7300,U+7302,U+7304-7309,U+730b-730d,U+730f-7312,U+7314,U+7318,U+731a,U+731f-7320,U+7323-7324,U+7326-7328,U+732d,U+732f-7330,U+7332-7333,U+7335,U+733a,U+733c-733d,U+7340,U+7342,U+7346-734c,U+734f,U+7351,U+7353-7356,U+7358-735f,U+7361-7367,U+7369,U+736e,U+7371,U+7373-7374,U+7376,U+7379,U+737d,U+737f,U+7381-7383,U+7385-7386,U+7388,U+738a,U+738c-738d,U+738f-7390,U+7392-7395,U+7397-739a,U+739c-739d;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_6938ca.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+6f6a-6f6c,U+6f71,U+6f73,U+6f75-6f76,U+6f79,U+6f7b,U+6f7d-6f7e,U+6f81-6f83,U+6f85,U+6f8a-6f8b,U+6f8f-6f96,U+6f98-6f9b,U+6f9d-6f9f,U+6fa2-6fa3,U+6fa5-6fa6,U+6fa8,U+6faa-6fad,U+6faf-6fb0,U+6fb2,U+6fb4-6fb5,U+6fb7-6fb8,U+6fba-6fbf,U+6fc4-6fc8,U+6fca-6fd0,U+6fd3-6fd7,U+6fd9-6fdd,U+6fe2-6fe3,U+6fe5-6fea,U+6fec-6fed,U+6ff2-6ff9,U+6ffb,U+6ffd,U+6fff-7004,U+7007-7008,U+700a,U+700c-700e,U+7010,U+7012-7014,U+7016-7017,U+7019,U+701c,U+701e,U+7021-7022,U+7024-7025,U+7029-7031,U+7033-7034,U+7036-7038,U+703a-703d,U+703f-7042,U+7045-704b,U+704d-704e,U+7050,U+7052-7054,U+7056-7057,U+7059-705c,U+705f-7062,U+7065-7068,U+706a,U+706e,U+7071-7074,U+7077,U+7079-707b,U+7081-7084,U+7086-7088,U+708b-708d,U+708f-7091,U+7093,U+7097-7098,U+709a-709b,U+709e-70aa,U+70b0,U+70b2,U+70b4-70b6,U+70ba,U+70be-70bf,U+70c4-70c7,U+70c9,U+70cb-70ce,U+70d0-70d7,U+70da,U+70dc-70de,U+70e0-70e3,U+70e5,U+70ea,U+70ee,U+70f0-70f3,U+70f5-70f6,U+70f8,U+70fa-70fc,U+70fe-7108,U+710b-710f,U+7111-7112,U+7114,U+7117,U+711b-7120,U+7122-7125,U+7127-712e,U+7132-7135,U+7137-7144,U+7147-7148,U+714b,U+714d,U+714f-7151,U+7153-7158,U+715a-715b,U+715d,U+715f-7161,U+7163;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_cf29b0.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+6c45,U+6c48,U+6c4b-6c4f,U+6c51-6c53,U+6c56,U+6c58,U+6c5a,U+6c62-6c63,U+6c65-6c67,U+6c6b-6c6f,U+6c71,U+6c73,U+6c75,U+6c77-6c78,U+6c7b-6c7c,U+6c7f-6c80,U+6c84,U+6c87,U+6c8a-6c8b,U+6c8d-6c8e,U+6c91,U+6c95-6c98,U+6c9a,U+6c9c-6c9e,U+6ca0,U+6ca2,U+6ca8,U+6cac,U+6caf-6cb0,U+6cb4-6cb7,U+6cba,U+6cc0,U+6cc2-6cc3,U+6cc6-6cc8,U+6ccb,U+6ccd-6ccf,U+6cd1-6cd2,U+6cd8-6cda,U+6cdc,U+6cdf,U+6ce4,U+6ce6-6ce7,U+6ce9,U+6cec-6ced,U+6cf2,U+6cf4,U+6cf9,U+6cff-6d00,U+6d02-6d03,U+6d05-6d06,U+6d08-6d0a,U+6d0d,U+6d0f-6d11,U+6d13-6d16,U+6d18,U+6d1c-6d1d,U+6d1f-6d24,U+6d26,U+6d28,U+6d2c-6d2d,U+6d2f-6d30,U+6d34,U+6d37-6d38,U+6d3a,U+6d3f-6d40,U+6d42,U+6d44,U+6d49,U+6d4c,U+6d50,U+6d55-6d58,U+6d5b,U+6d5d,U+6d5f,U+6d61-6d62,U+6d64-6d65,U+6d67-6d68,U+6d6b-6d6d,U+6d70-6d73,U+6d75-6d76,U+6d7a-6d7b,U+6d7d-6d81,U+6d83-6d84,U+6d86,U+6d8a-6d8b,U+6d8d,U+6d8f-6d90,U+6d92,U+6d96-6d9a,U+6d9c,U+6da2,U+6da5,U+6dac-6dad,U+6db0-6db1,U+6db3-6db4,U+6db6-6db7,U+6db9-6dbb,U+6dbd-6dbe,U+6dc1-6dc3,U+6dc8-6dca,U+6dcd-6dd0,U+6dd2-6dd5,U+6dd7,U+6ddb-6ddc,U+6ddf,U+6de2-6de3,U+6de7,U+6ded,U+6def-6df0,U+6df2,U+6df4,U+6df8,U+6dfd-6e04,U+6e06-6e08,U+6e0b,U+6e0f,U+6e12-6e13,U+6e15,U+6e18,U+6e1c,U+6e1e-6e1f,U+6e22,U+6e27-6e28,U+6e2a,U+6e2e,U+6e30-6e31,U+6e33,U+6e35-6e37,U+6e39,U+6e3b-6e3d,U+6e3f-6e42,U+6e45-6e49,U+6e4b-6e4c,U+6e4f-6e52,U+6e55,U+6e57,U+6e59-6e5a,U+6e5c-6e5d,U+6e60-6e62,U+6e64-6e66,U+6e68-6e6a,U+6e6c-6e6d,U+6e70-6e7d,U+6e80-6e82,U+6e84,U+6e87-6e88,U+6e8a-6e8e,U+6e91-6e95,U+6e97,U+6e99-6e9b,U+6e9e,U+6ea0-6ea1,U+6ea3-6ea4,U+6ea6,U+6ea8-6ea9,U+6eac-6eae,U+6eb0,U+6eb3,U+6eb5,U+6eb8-6eb9,U+6ebe-6ec0,U+6ec3,U+6ec6,U+6ec8-6eca,U+6ecd,U+6ed0,U+6ed2,U+6ed6,U+6ed8-6ed9,U+6edb-6edd,U+6ee3,U+6ee7,U+6eea-6eeb,U+6eed-6eee,U+6ef0-6ef1,U+6ef3,U+6ef5-6ef6,U+6efa-6efd,U+6f00,U+6f03-6f05,U+6f07-6f08,U+6f0a-6f0e,U+6f10,U+6f12,U+6f16-6f19,U+6f1b-6f1f,U+6f21,U+6f25-6f28,U+6f2e,U+6f30,U+6f34,U+6f37,U+6f39-6f3d,U+6f40,U+6f42-6f45,U+6f48-6f4a,U+6f4c,U+6f4e-6f50,U+6f52-6f53,U+6f55-6f57,U+6f5a,U+6f5d,U+6f5f-6f61,U+6f63,U+6f65,U+6f67-6f69;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_b8977e.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+6a56-6a57,U+6a5a,U+6a5c-6a5e,U+6a60,U+6a63-6a64,U+6a66-6a6a,U+6a6c-6a70,U+6a72-6a78,U+6a7a-6a7b,U+6a7d-6a7f,U+6a82-6a83,U+6a85-6a88,U+6a8a-6a8d,U+6a8f,U+6a92-6a93,U+6a95-6a96,U+6a98-6a9b,U+6a9d-6a9f,U+6aa1,U+6aa4-6aa8,U+6aaa,U+6aad-6ab2,U+6ab4-6ab7,U+6ab9-6aba,U+6abc-6abd,U+6abf-6ac2,U+6ac4-6ad2,U+6ad4-6ad9,U+6adc,U+6ae0-6ae4,U+6ae6,U+6ae9,U+6aed-6af0,U+6af2,U+6af4-6af7,U+6af9-6afa,U+6afc-6b03,U+6b05-6b09,U+6b0b-6b0e,U+6b10-6b11,U+6b13-6b15,U+6b17-6b1d,U+6b1f,U+6b25-6b26,U+6b28-6b31,U+6b33-6b36,U+6b38,U+6b3b-6b3c,U+6b3f-6b42,U+6b44-6b45,U+6b48,U+6b4a-6b4b,U+6b4d-6b4f,U+6b51-6b58,U+6b5a-6b5e,U+6b60,U+6b68-6b69,U+6b6b-6b71,U+6b73-6b76,U+6b7a,U+6b7d-6b7e,U+6b80,U+6b85,U+6b88,U+6b8c,U+6b8e-6b91,U+6b94-6b95,U+6b97,U+6b99,U+6b9c-6b9d,U+6b9f-6ba0,U+6ba2-6ba3,U+6ba5-6ba9,U+6bac,U+6bb0-6bb1,U+6bb6,U+6bb8-6bb9,U+6bbb,U+6bbd-6bbe,U+6bc3-6bc4,U+6bc7-6bca,U+6bcc,U+6bce,U+6bd0-6bd1,U+6bd8,U+6bda,U+6bdc-6be0,U+6be2-6be9,U+6bec-6bee,U+6bf0-6bf2,U+6bf4,U+6bf6-6bf8,U+6bfa-6bfc,U+6bfe,U+6c00-6c04,U+6c09-6c0b,U+6c0e,U+6c12,U+6c17,U+6c1c-6c1e,U+6c20,U+6c25,U+6c2d,U+6c31,U+6c36-6c37,U+6c3a-6c3c,U+6c3e-6c3f,U+6c43-6c44;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_8ea677.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+681e-6820,U+6822-6828,U+682b-6831,U+6834-6836,U+683a-683b,U+683f,U+6847,U+684b,U+684d,U+684f,U+6852,U+6856-685f,U+686a,U+686c-6873,U+6875,U+6878-687e,U+6880,U+6882,U+6884,U+6887-688e,U+6890-6892,U+6895-6896,U+6899-689c,U+689e,U+68a0-68a1,U+68a3-68a5,U+68a9-68ac,U+68ae,U+68b1-68b2,U+68b4,U+68b6-68bf,U+68c1,U+68c3,U+68c5-68c8,U+68cc,U+68ce-68d1,U+68d3-68d4,U+68d9,U+68db-68de,U+68e1-68e2,U+68e4-68e6,U+68e8-68eb,U+68ed,U+68ef,U+68f3-68f4,U+68f6-68f8,U+68fb,U+68fd-6900,U+6902-6904,U+6906-690a,U+690c,U+6911,U+6913-691e,U+6921-6923,U+6925-692c,U+692e-692f,U+6931-6933,U+6935-6938,U+693a-693c,U+693e,U+6940-6941,U+6943-6949,U+694b-6952,U+6955-6956,U+6958-6959,U+695b-695c,U+695f,U+6961-6962,U+6964-6965,U+6967,U+6969-696a,U+696c,U+696f-6970,U+6972-6974,U+6976,U+697a-697b,U+697d-697f,U+6981,U+6983,U+6985,U+698a-698c,U+698e-6993,U+6996-6997,U+6999-699a,U+699d-69a6,U+69a9,U+69ac,U+69af-69b0,U+69b2-69b3,U+69b5-69b6,U+69b8-69ba,U+69bc-69be,U+69c0,U+69c2-69c9,U+69cf,U+69d1-69d2,U+69d5-69da,U+69dc-69de,U+69e1-69e6,U+69e9,U+69eb-69ec,U+69ee-69f1,U+69f4-69fc,U+69fe,U+6a00,U+6a03-6a04,U+6a06-6a09,U+6a0b-6a10,U+6a12,U+6a14-6a16,U+6a1a-6a1d,U+6a20,U+6a22,U+6a24-6a27,U+6a29,U+6a2b-6a2e,U+6a30,U+6a32-6a33,U+6a36-6a37,U+6a3b-6a3c,U+6a3f-6a43,U+6a45-6a46,U+6a49-6a4a,U+6a4c-6a4f,U+6a51-6a55;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_0d19f4.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+653a,U+653c-653d,U+6540-6544,U+6546-6547,U+654a-654b,U+654d,U+6550,U+6552-6554,U+655a,U+655c,U+655f-6561,U+6564-6565,U+6567-656a,U+656d-656f,U+6571,U+6573,U+6576,U+6579-6581,U+6584-6586,U+6588-658a,U+658d-658f,U+6592,U+6594,U+6596,U+6598,U+659a,U+659d-659e,U+65a0,U+65a2-65a3,U+65a6,U+65a8,U+65aa,U+65ae,U+65b1-65b6,U+65b8,U+65ba-65bb,U+65be-65c0,U+65c7-65ca,U+65cd,U+65d0-65d1,U+65d3-65d5,U+65d8-65df,U+65e1,U+65e4,U+65ea-65eb,U+65f2-65f5,U+65f8-65f9,U+65fb-65ff,U+6601,U+6604-6605,U+6607-6609,U+660b,U+660d,U+6610-6612,U+6616-6618,U+661a-661c,U+661e,U+6621-6624,U+6626,U+6629-662c,U+662e,U+6630,U+6632-6633,U+6637-663b,U+663d,U+663f-6640,U+6644-6648,U+664a,U+664d-664e,U+6650-6651,U+6658-6659,U+665b-665c,U+665e,U+6660,U+6662-6663,U+6665,U+6667,U+6669-666d,U+6671-6673,U+6675,U+6678-6679,U+667b-667d,U+667f-6681,U+6683,U+6685-6686,U+668a-668b,U+668d-6690,U+6692-6695,U+6698-669c,U+669e-66a1,U+66a3-66a6,U+66a9-66aa,U+66ac-66ad,U+66af-66b0,U+66b2-66b3,U+66b5-66b8,U+66ba-66bd,U+66bf-66c3,U+66c5-66c6,U+66c8,U+66ca-66d5,U+66d7-66d8,U+66da,U+66de-66df,U+66e1-66e5,U+66e7-66e8,U+66ea-66eb,U+66ed-66ef,U+66f1,U+66f5-66f6,U+66fa-66fb,U+66fd,U+6701-6702,U+6704-6707,U+670c,U+670e-670f,U+6711-6713,U+6716,U+6718-671a,U+671c,U+671e,U+6720-6725,U+6729,U+672e,U+6730,U+6732-6733,U+6737-6739,U+673b-673c,U+673e-673f,U+6741,U+6744-6745,U+6747,U+674a-674b,U+674d,U+6752,U+6754-6755,U+6757-675b,U+675d,U+6762-6764,U+6766-6767,U+676b-676c,U+676e,U+6774,U+6776,U+6778-677b,U+677d,U+6780,U+6782-6783,U+6785-6786,U+6788,U+678a,U+678c-678f,U+6791-6794,U+6796,U+6799,U+679b,U+679f-67a1,U+67a4,U+67a6,U+67a9,U+67ac,U+67ae,U+67b1-67b2,U+67b4,U+67b9-67c0,U+67c2,U+67c5-67ce,U+67d5-67d7,U+67db,U+67df,U+67e1,U+67e3-67e4,U+67e6-67e8,U+67ea-67eb,U+67ed-67ee,U+67f2,U+67f6-67fa,U+67fc,U+67fe,U+6801-6804,U+6806,U+680d,U+6810,U+6812,U+6814-6815,U+6818-681c;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_87c494.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+6235,U+6238-623c,U+6242,U+6244-6246,U+624a,U+624f-6250,U+6255-6257,U+6259-625a,U+625c-6260,U+6262,U+6264-6265,U+6268,U+6271-6272,U+6274-6275,U+6277-6278,U+627a-627b,U+627d,U+6281-6283,U+6285-6288,U+628b-6290,U+6294,U+6299,U+629c-629e,U+62a3,U+62a6-62a7,U+62a9-62aa,U+62ad-62b0,U+62b2-62b4,U+62b6-62b8,U+62ba,U+62be,U+62c0-62c1,U+62c3,U+62cf,U+62d1,U+62d5,U+62de,U+62e0-62e1,U+62e4,U+62ea-62eb,U+62f0,U+62f2,U+62f5,U+62f8-62fb,U+6300,U+6303-6306,U+630a-630d,U+630f-6310,U+6312-6315,U+6317-6319,U+631c,U+6326-6327,U+6329,U+632c-632e,U+6330,U+6333-6338,U+633b-633c,U+633f-6341,U+6344,U+6347-6348,U+634a,U+6351-6354,U+6356-635d,U+6360,U+6364-6366,U+636a,U+636c,U+636f-6370,U+6373-6375,U+6378-6379,U+637c-637f,U+6381,U+6385-6386,U+638b,U+638d,U+6391,U+6393-6395,U+6397,U+639a,U+639c-639f,U+63a1,U+63a4,U+63a6,U+63ab,U+63af,U+63b1-63b2,U+63b5-63b6,U+63b9,U+63bb,U+63bd,U+63bf,U+63c1-63c3,U+63c5,U+63c7-63c8,U+63ca-63cc,U+63d1,U+63d3-63d5,U+63d7-63d9,U+63dc-63dd,U+63df,U+63e2,U+63e4-63e8,U+63eb-63ec,U+63ef-63f1,U+63f3,U+63f5,U+63f7,U+63fa-63fc,U+63fe,U+6403-6404,U+6406-640a,U+640e,U+6411-6412,U+6415,U+6418-641a,U+641d,U+641f,U+6422-6424,U+6427-6429,U+642b,U+642e-6433,U+6435,U+6437-6439,U+643b-643c,U+643e,U+6442-6443,U+6449,U+644b-6450,U+6453,U+6455-6457,U+6459-645b,U+645d,U+6460-6462,U+6464-6466,U+6468,U+646a-646c,U+646e,U+6470-6472,U+6474-6475,U+6477,U+647c-6481,U+6483,U+6486,U+6489-648f,U+6494,U+6497-6498,U+649b-649d,U+64a0-64a2,U+64a6-64a8,U+64aa,U+64af,U+64b1,U+64b4,U+64b6,U+64b9,U+64bd,U+64c3,U+64c6,U+64c8-64c9,U+64cc,U+64cf,U+64d1,U+64d3,U+64d5-64d6,U+64d9,U+64db-64dd,U+64df,U+64e3,U+64e5,U+64e7-64eb,U+64ed-64ee,U+64f3,U+64f5-64f6,U+64f8-64f9,U+64fd,U+64ff,U+6501-6503,U+6505,U+6507-6508,U+650a-650e,U+6510-6511,U+6513,U+6515,U+6517,U+651a,U+651e-6521,U+6526-6529,U+652d,U+6530-6533,U+6537;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_3b743f.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+5f60,U+5f63,U+5f67-5f68,U+5f6b,U+5f6e-5f6f,U+5f72,U+5f74-5f76,U+5f78,U+5f7a,U+5f7d-5f7e,U+5f83,U+5f86,U+5f8d-5f8f,U+5f93-5f94,U+5f96,U+5f9a-5f9b,U+5f9d,U+5f9f,U+5fa2-5fa7,U+5fab-5fac,U+5faf-5fb4,U+5fb6,U+5fb8,U+5fba-5fbb,U+5fbe-5fc2,U+5fc7-5fc8,U+5fca-5fcb,U+5fce,U+5fd3-5fd5,U+5fda-5fdc,U+5fde-5fdf,U+5fe2-5fe3,U+5fe5-5fe6,U+5fe8-5fe9,U+5fec,U+5fef-5ff0,U+5ff2-5ff4,U+5ff6-5ff7,U+5ff9-5ffa,U+5ffc,U+6007-6009,U+600b-600c,U+6010-6011,U+6013,U+6017-6018,U+601a,U+601e-601f,U+6022-6024,U+602c-602e,U+6030-6034,U+6036-603a,U+603d-603e,U+6040,U+6044-6045,U+6047-604a,U+604c,U+604e-604f,U+6051,U+6053-6054,U+6056-6058,U+605b-605c,U+605e-6061,U+6066,U+606e,U+6071-6072,U+6074-6075,U+6077,U+607e,U+6080-6082,U+6086-6088,U+608a-608b,U+608e-6091,U+6093,U+6095,U+6097-6099,U+609c,U+609e,U+60a1-60a2,U+60a4-60a5,U+60a7,U+60a9-60aa,U+60ae,U+60b0,U+60b3,U+60b7,U+60b9-60ba,U+60be-60c4,U+60c7-60c9,U+60cc-60d0,U+60d2-60d4,U+60d6-60d7,U+60d9,U+60db,U+60de,U+60e2-60e5,U+60ea,U+60f5,U+60f7-60f8,U+60fc-60ff,U+6102-6105,U+6107,U+610a-610c,U+6110-6114,U+6116-6119,U+611d-611e,U+6121-6122,U+6125,U+6129-612a,U+612c-6133,U+6135-6136,U+6138-613d,U+6140-6141,U+6143,U+6145-6147,U+6149,U+614f-6150,U+6152-6154,U+6156-6157,U+6159,U+615b-615c,U+615e,U+6160-6161,U+6164-6166,U+6169,U+616c-616d,U+616f,U+6171-6172,U+6174,U+6178-617b,U+617d,U+617f-6181,U+6183-6189,U+618c-618d,U+618f,U+6193,U+6195-6199,U+619b-619c,U+619e-61a3,U+61a5-61a6,U+61aa,U+61ad,U+61af-61b1,U+61b3-61b5,U+61b8-61bd,U+61bf-61c1,U+61c3-61c6,U+61ce-61d0,U+61d3,U+61d5-61de,U+61e0-61e2,U+61e4-61e5,U+61e7,U+61e9-61f1,U+61f3-61f4,U+61f9,U+61fb,U+61fd,U+6201-6205,U+6209,U+6213,U+6219,U+621c-621e,U+6220,U+6223,U+6226,U+6228,U+622b,U+622d,U+622f,U+6231;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_5825df.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+5cde-5ce0,U+5ce2-5ce3,U+5ce7,U+5ceb-5cec,U+5cee,U+5cf1-5cf3,U+5cf5,U+5cf7-5cfa,U+5cfc,U+5cfe-5d01,U+5d04-5d05,U+5d08-5d0c,U+5d0f-5d10,U+5d12-5d13,U+5d15,U+5d18-5d1a,U+5d1c-5d1d,U+5d1f-5d21,U+5d23,U+5d25,U+5d28,U+5d2a-5d2b,U+5d2f-5d32,U+5d35-5d3c,U+5d3f-5d46,U+5d48-5d49,U+5d4d-5d4f,U+5d51-5d57,U+5d59-5d5a,U+5d5c,U+5d5e-5d68,U+5d6a,U+5d6d-5d6e,U+5d70-5d73,U+5d75-5d80,U+5d83,U+5d85-5d86,U+5d88-5d96,U+5d98,U+5d9a-5d9c,U+5d9e-5d9f,U+5da1-5da6,U+5da8-5db3,U+5db5-5db6,U+5db9,U+5dbb,U+5dbe-5dc4,U+5dc6-5dca,U+5dcc,U+5dce-5dd1,U+5dd3,U+5dd5,U+5dd7-5dda,U+5ddc,U+5ddf-5de0,U+5de4,U+5dea,U+5dec-5ded,U+5df5-5df6,U+5df8,U+5dfa-5dfc,U+5dff-5e00,U+5e04,U+5e07,U+5e09-5e0b,U+5e0d-5e0e,U+5e12-5e13,U+5e17,U+5e1e-5e24,U+5e28-5e2a,U+5e2c,U+5e2f-5e30,U+5e32,U+5e34-5e35,U+5e39-5e3a,U+5e3e-5e3f,U+5e41,U+5e46-5e4b,U+5e4d-5e53,U+5e56,U+5e59-5e5a,U+5e5c-5e5d,U+5e60,U+5e64-5e6a,U+5e6d-5e71,U+5e75,U+5e77,U+5e81-5e83,U+5e85,U+5e88-5e89,U+5e8c-5e8e,U+5e92,U+5e98,U+5e9b,U+5e9d,U+5ea1-5ea4,U+5ea8-5eaa,U+5eac,U+5eae-5eb2,U+5eb4,U+5eba-5ebd,U+5ebf-5ec0,U+5ec3,U+5ec5-5ec7,U+5ecb-5ed0,U+5ed4-5ed5,U+5ed7-5ed9,U+5edc,U+5ede,U+5ee0,U+5ee4-5ee7,U+5eeb,U+5eed-5ef2,U+5ef5,U+5ef8-5ef9,U+5efb-5efd,U+5f05-5f07,U+5f09,U+5f0c-5f0e,U+5f10,U+5f16,U+5f19-5f1a,U+5f1c-5f1e,U+5f21-5f24,U+5f28,U+5f2b-5f2c,U+5f2e,U+5f30,U+5f32,U+5f34,U+5f36,U+5f38,U+5f3b,U+5f3d-5f3f,U+5f41-5f47,U+5f49-5f4b,U+5f4d,U+5f4f,U+5f51,U+5f54,U+5f59-5f5c,U+5f5f;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_1c96d4.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+5a4b-5a54,U+5a56-5a59,U+5a5b-5a61,U+5a63-5a65,U+5a68-5a69,U+5a6b-5a6c,U+5a6e-5a73,U+5a78-5a79,U+5a7b-5a7e,U+5a80-5a91,U+5a93-5a99,U+5a9c-5aa6,U+5aa8-5aa9,U+5aab-5ab1,U+5ab4,U+5ab6-5ab7,U+5ab9-5abb,U+5abf-5ac0,U+5ac3-5ac8,U+5aca,U+5acd-5ad1,U+5ad3,U+5ad5,U+5ad9-5adb,U+5add-5adf,U+5ae2,U+5ae4-5ae5,U+5ae7-5ae8,U+5aea,U+5aec-5af0,U+5af2-5af4,U+5af6-5af9,U+5afb-5aff,U+5b01-5b07,U+5b0a,U+5b0d-5b15,U+5b18,U+5b1a-5b20,U+5b22-5b23,U+5b25-5b29,U+5b2b-5b2f,U+5b31,U+5b33,U+5b35-5b36,U+5b39-5b3f,U+5b41-5b42,U+5b44-5b4b,U+5b4d-5b4f,U+5b52,U+5b56,U+5b5e,U+5b60-5b61,U+5b67-5b68,U+5b6d-5b6f,U+5b72,U+5b74,U+5b77,U+5b79,U+5b7b-5b7c,U+5b7e,U+5b82,U+5b86,U+5b8a,U+5b8d-5b8e,U+5b90-5b92,U+5b94,U+5b96,U+5b9f,U+5ba7-5ba9,U+5bac-5bad,U+5baf,U+5bb1-5bb2,U+5bb7,U+5bba-5bbc,U+5bc0-5bc1,U+5bc3,U+5bc8-5bcb,U+5bcd-5bcf,U+5bd1,U+5bd4-5bdc,U+5be0,U+5be3,U+5bea,U+5bed,U+5bef,U+5bf1-5bf4,U+5bf7,U+5bfd-5bfe,U+5c00,U+5c02-5c03,U+5c05,U+5c0c,U+5c10,U+5c12-5c13,U+5c17,U+5c19,U+5c1b,U+5c1e-5c21,U+5c23,U+5c26,U+5c28-5c2b,U+5c2d-5c30,U+5c32-5c33,U+5c35-5c36,U+5c43-5c44,U+5c47,U+5c4c,U+5c52-5c54,U+5c56-5c58,U+5c5a,U+5c5d,U+5c5f,U+5c67,U+5c69-5c6b,U+5c6d,U+5c70,U+5c72-5c78,U+5c7b-5c7e,U+5c80,U+5c83-5c87,U+5c89-5c8b,U+5c8e-5c8f,U+5c92-5c93,U+5c95,U+5c9d-5ca0,U+5ca4-5ca8,U+5caa,U+5cae-5cb0,U+5cb2,U+5cb4,U+5cb6,U+5cb9-5cbc,U+5cbe,U+5cc0,U+5cc2-5cc3,U+5cc5-5cca,U+5ccc-5cd1,U+5cd3-5cd8,U+5cda-5cdd;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_eac724.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+57b9-57c1,U+57c4-57ca,U+57cc-57cd,U+57d0-57d1,U+57d3,U+57d6-57d7,U+57db-57dc,U+57de,U+57e2-57e3,U+57e5-57ec,U+57ee,U+57f0-57f3,U+57f5-57f6,U+57fb-57fc,U+57fe-57ff,U+5801,U+5803-5804,U+5808-5809,U+580c,U+580e-5810,U+5812-5814,U+5817-5818,U+581a-581c,U+581f,U+5822-5823,U+5825-5829,U+582b-582e,U+5832-5833,U+5836-5843,U+5845-5849,U+584e,U+5850,U+5853,U+5855-5857,U+5859,U+585b-585d,U+585f-5861,U+5863-5864,U+5866-586a,U+586d-5874,U+5876-5878,U+587a-587d,U+587f,U+5882,U+5884,U+5886-5888,U+588b-5891,U+5894-5898,U+589b,U+589d,U+58a0-58a7,U+58aa-58ad,U+58af-58b2,U+58b4-58bb,U+58bd,U+58bf-58c0,U+58c2-58c4,U+58c6,U+58c8-58cd,U+58cf-58d0,U+58d2,U+58d4,U+58d6-58d7,U+58db-58dd,U+58e1,U+58e3,U+58e5-58e8,U+58ea,U+58ed,U+58f1-58f2,U+58f4-58f5,U+58f7-58f8,U+58fb-58fc,U+58fe-5901,U+5903,U+5905-5906,U+5908-590c,U+590e,U+5910-5913,U+5917-5918,U+591b,U+591d-591e,U+5921,U+5923,U+5926,U+5928,U+592c,U+5930,U+5932-5933,U+5935-5936,U+593b,U+593d,U+593f-5940,U+5943,U+5945-5946,U+594a,U+594c-594d,U+5952-5953,U+5959,U+595b-595f,U+5961,U+5963-5964,U+5966,U+5968,U+596b-596d,U+596f-5972,U+5975,U+5977,U+597a-597b,U+597e-5980,U+5985,U+5989,U+598b-598c,U+598e-5991,U+5994-5995,U+5998,U+599a-599c,U+599f-59a2,U+59a6-59a7,U+59ac-59ad,U+59b0-59b1,U+59b4-59b8,U+59ba,U+59bc-59bd,U+59bf-59c5,U+59c7-59c9,U+59cc,U+59ce-59cf,U+59d5-59d6,U+59d9,U+59db,U+59de-59e2,U+59e4,U+59e7,U+59e9-59eb,U+59ed-59f8,U+59fa,U+59fc-59fe,U+5a00,U+5a02,U+5a0a-5a0b,U+5a0d-5a10,U+5a12,U+5a14-5a17,U+5a19-5a1a,U+5a1d-5a1e,U+5a21-5a22,U+5a24,U+5a26-5a28,U+5a2a-5a30,U+5a33,U+5a35,U+5a37-5a3b,U+5a3d-5a3f,U+5a42-5a45,U+5a47-5a48;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_5ae301.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+50c4,U+50c6-50c8,U+50ca-50ce,U+50d0,U+50d2-50d4,U+50d7-50d9,U+50db-50dd,U+50df-50e4,U+50e9-50eb,U+50ef-50f0,U+50f2,U+50f4,U+50f6-50f8,U+50fa,U+50fc-50ff,U+5103,U+5105,U+510a,U+510c,U+510e-510f,U+5111,U+5113,U+5116-511e,U+5120,U+5122-5129,U+512b-5131,U+5133-5136,U+5138-5139,U+513d-513e,U+5142,U+514a,U+514e-5150,U+5153,U+5158-5159,U+515b,U+515d-5161,U+5163-5164,U+5166,U+516f,U+5172,U+517a,U+517e-517f,U+5183-5184,U+5186-5187,U+518b,U+518e-5190,U+5193-5194,U+5198,U+519a,U+519d-519f,U+51a1,U+51a3,U+51a6-51a9,U+51ad-51ae,U+51b4,U+51b8-51ba,U+51be-51bf,U+51c1-51c3,U+51c5,U+51c8,U+51ca,U+51ce,U+51d0,U+51d2-51da,U+51de-51df,U+51e2-51e3,U+51e5-51ea,U+51ec,U+51ee,U+51f2,U+51f4,U+51f7,U+51fe,U+5204-5205,U+5209,U+520b-520c,U+520f-5210,U+5213-5215,U+521c,U+521e-521f,U+5221-5223,U+5226-5227,U+522c,U+522f,U+5231-5232,U+5234-5235,U+523c,U+523e,U+5245-5246,U+5248-5249,U+524f,U+5252-5253,U+5255,U+5257-525a,U+525f-5260,U+5262-5264,U+5266,U+5268,U+526b-526d,U+5271,U+5273,U+5276-527c,U+527e,U+5280,U+5285-5286,U+528b,U+528e-528f,U+5292,U+5294-529a,U+529c,U+52a4-52a7,U+52ae-52b0,U+52b4-52bd,U+52c0,U+52c2,U+52c4-52c6,U+52c8,U+52ca,U+52cc-52cf,U+52d1,U+52d3-52d4,U+52d7,U+52da-52dc,U+52e0-52e1,U+52e3,U+52e5-52ef,U+52f2,U+52f4,U+52f6-52f7,U+52fc-52fd,U+5301-5304,U+5307,U+5309-530c,U+530e,U+5311-5314,U+5318,U+531b-531c,U+531e-531f,U+5322,U+5324-5325,U+5327-5329,U+532b-532c,U+5330,U+5332-5338,U+533c-533d,U+5342,U+5344,U+5346,U+534b-534d,U+5350,U+5358-5359,U+535b,U+535d,U+5365,U+5368,U+536a,U+536c-536d,U+5372,U+5376,U+537c,U+537e,U+5380-5381,U+5383,U+5387-5388,U+538a,U+538e-5394,U+5396-5397,U+539b-539c,U+539e,U+53a0-53a1,U+53a4,U+53a7,U+53aa-53ac,U+53af-53b1,U+53b3,U+53b5,U+53b7-53ba,U+53bc-53be,U+53c0,U+53c5-53c7,U+53ce-53d0,U+53d2-53d3,U+53d5,U+53da,U+53dc-53de,U+53e1,U+53e7,U+53f4,U+53fa,U+53fe-5400,U+5402,U+5405,U+5407,U+540b,U+5414,U+5418-541a,U+541c,U+5422,U+5424-5425,U+542a,U+5430,U+5437,U+543a,U+543d,U+543f,U+5441,U+5444-5445,U+5447,U+5449,U+544c-544f,U+545a,U+545d-5461,U+5463,U+5465,U+5467,U+5469-546f;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_306bba.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+5470,U+5474,U+5479-547a,U+547e-547f,U+5481,U+5483,U+5485,U+5487-548a,U+548d,U+5491,U+5493,U+5497-5498,U+549c,U+549e-54a2,U+54a5,U+54ae,U+54b0,U+54b2,U+54b5-54b7,U+54b9-54ba,U+54be,U+54c3,U+54c5,U+54ca-54cb,U+54d6,U+54d8,U+54db,U+54e0,U+54e3-54e4,U+54eb-54ec,U+54ef-54f1,U+54f4-54f9,U+54fb,U+54fe,U+5500,U+5502-5503,U+5505,U+5508,U+550a-550e,U+5512-5513,U+5516-5519,U+551c-551f,U+5521,U+5525-5526,U+5528-5529,U+552b,U+552d,U+5532,U+5534-5536,U+5539-553b,U+553d,U+5540,U+5542,U+5545,U+5547-5548,U+554b-554e,U+5551-5552,U+5554,U+5557-555b,U+555d,U+555f-5560,U+5562-5563,U+5568-5569,U+556b,U+556f-5574,U+5579-557a,U+557d,U+557f,U+5585,U+558c-558e,U+5590,U+5592-5593,U+5595-5597,U+559b,U+55a0-55a6,U+55a8-55a9,U+55ad,U+55af-55b0,U+55b4,U+55b6,U+55b8,U+55ba,U+55bc,U+55bf-55c3,U+55c8,U+55ca-55cb,U+55cf-55d0,U+55d5,U+55d7-55d9,U+55db,U+55de,U+55e0,U+55e2,U+55e7,U+55ed-55ee,U+55f0-55f1,U+55f4,U+55f8-55fc,U+55ff,U+5602-5605,U+5607,U+560a-560b,U+5610-5613,U+5615,U+5619-561a,U+561d,U+5620-5622,U+5625-5626,U+5628-562b,U+5633,U+5637,U+563a,U+563c-563e,U+5640-5644,U+5646-564b,U+564f-5652,U+5655-5656,U+565a-565b,U+565e-565f,U+5661,U+5663,U+5667,U+566d-566e,U+5670,U+5673,U+5675,U+5677,U+5679-567a,U+567d-567f,U+5681-5684,U+5688-568b,U+568d,U+5690-5692,U+5696-56a2,U+56a4,U+56a7,U+56a9-56ae,U+56b0-56b2,U+56b5,U+56b8-56bb,U+56bd-56bf,U+56c3-56c4,U+56c6-56c7,U+56c9,U+56cb-56d0,U+56d2-56d3,U+56d5-56d6,U+56d8-56d9,U+56dc,U+56e3,U+56e5-56e9,U+56ec,U+56ee-56ef,U+56f2-56f3,U+56f6-56f8,U+56fb-56fc,U+5700-5702,U+5705,U+570c,U+570e,U+5710-5711,U+5714-5715,U+5717,U+5719-571b,U+571d-571e,U+5720-5722,U+5724-5727,U+572b,U+5731-5732,U+5734-5738,U+573c-573d,U+573f,U+5741,U+5743-5746,U+5748-5749,U+574b,U+5752-5756,U+5758-5759,U+5762-5763,U+5765,U+5767,U+576c,U+576e,U+5771-5772,U+5774-5775,U+5778-577a,U+577d-5781,U+5787-578a,U+578d-5791,U+5794-579a,U+579d-579f,U+57a5,U+57a8,U+57aa,U+57ac,U+57af-57b1,U+57b3,U+57b6-57b7;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_6d65eb.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4d67-4db5,U+4e02,U+4e04-4e06,U+4e0f,U+4e12,U+4e17,U+4e20-4e21,U+4e23,U+4e29,U+4e2e-4e2f,U+4e31,U+4e33,U+4e35,U+4e37,U+4e3c,U+4e40-4e42,U+4e44,U+4e46,U+4e4a,U+4e51,U+4e55,U+4e57,U+4e5a-4e5b,U+4e62-4e65,U+4e67-4e68,U+4e6a-4e6f,U+4e72,U+4e74-4e7d,U+4e7f-4e81,U+4e83-4e85,U+4e87,U+4e8a,U+4e90,U+4e96-4e97,U+4e9c-4e9d,U+4ea3,U+4eaa,U+4eaf-4eb1,U+4eb4,U+4eb6-4eb9,U+4ebc-4ebe,U+4ec8,U+4ecc,U+4ecf-4ed0,U+4ed2,U+4eda-4edc,U+4ee0,U+4ee2,U+4ee6-4ee7,U+4ee9,U+4eed-4eef,U+4ef1,U+4ef4,U+4ef8-4efa,U+4efc,U+4efe,U+4f00,U+4f02-4f08,U+4f0b-4f0c,U+4f12-4f14,U+4f16,U+4f1c-4f1d,U+4f21,U+4f23,U+4f28-4f29,U+4f2c-4f2e,U+4f31,U+4f33,U+4f35,U+4f37,U+4f39,U+4f3b,U+4f3e-4f42,U+4f44-4f45,U+4f49-4f4c,U+4f52,U+4f56,U+4f61-4f62,U+4f66,U+4f68,U+4f6a-4f6b,U+4f6d-4f6e,U+4f71-4f72,U+4f75,U+4f77-4f7a,U+4f7d,U+4f80-4f82,U+4f85,U+4f87,U+4f8a,U+4f8c,U+4f8e,U+4f90,U+4f92-4f93,U+4f95,U+4f98-4f9a,U+4f9c,U+4f9e-4f9f,U+4fa1-4fa2,U+4fa4,U+4fab,U+4fad,U+4fb0-4fb4,U+4fb8-4fbe,U+4fc0,U+4fc2,U+4fc6-4fc9,U+4fcb-4fcd,U+4fd2-4fd6,U+4fd9,U+4fdb,U+4fe2,U+4fe4-4fe5,U+4fe7,U+4feb,U+4ff0,U+4ff2,U+4ff4-4ff7,U+4ff9,U+4ffb-4ffd,U+4fff,U+5001-5005,U+5007-5008,U+500a,U+500e,U+5010,U+5013,U+5015,U+5017,U+501b,U+501d-501e,U+5020,U+5022-5024,U+5027,U+502f-5039,U+503b,U+503d,U+503f-5042,U+5044-5046,U+504a-504b,U+504d,U+5050-5054,U+5056-5059,U+505b,U+505d-5064,U+5066-506b,U+506d-5073,U+5079-507a,U+507c-507d,U+5081-5084,U+5086-5087,U+5089-508c,U+508e-5090,U+5092-5095,U+5097,U+509a-50a1,U+50a4,U+50a6,U+50aa-50ab,U+50ae,U+50b0-50b1,U+50b6,U+50b8-50b9,U+50bc-50bd,U+50bf-50c1,U+50c3,U+30d7c,U+30de5,U+310a8,U+310b1,U+3114b;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_0dbc27.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4c74-4d66;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_135790.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4b68-4c73,U+2f80f;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_813dd1.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4a4f-4b67,U+2e774,U+2e779,U+2e8f4,U+2e932,U+2e9f4-2e9f5,U+2ea5d;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_ed0d20.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4923-4a4e,U+2dc4a,U+2e0c4,U+2e267-2e268,U+2e26a-2e26b,U+2e428,U+2e64b;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_3149e0.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+47d8-4922,U+2ce7c,U+2ce88,U+2ce93,U+2d075,U+2d079,U+2d6a6,U+2d8c7;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_71407d.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+46e9-47d7,U+2c542,U+2c613,U+2c618,U+2c621,U+2c629,U+2c62b-2c62d,U+2c62f,U+2c642,U+2c64a-2c64b,U+2c72c,U+2c72f,U+2c79f,U+2c7c1,U+2c7fd,U+2c88a,U+2c8d9,U+2c8de,U+2c8e1,U+2c8f3,U+2c907,U+2c90a,U+2c91d,U+2c9c0,U+2ca02,U+2ca0e,U+2ca7d,U+2caa9,U+2cb29,U+2cb2b,U+2cb2d-2cb2e,U+2cb31,U+2cb38-2cb39,U+2cb3b,U+2cb3f,U+2cb41,U+2cb4a,U+2cb4e,U+2cb5a-2cb5b,U+2cb63-2cb64,U+2cb69,U+2cb6c,U+2cb6f,U+2cb73,U+2cb76,U+2cb78,U+2cb7c,U+2cba4,U+2cbb1,U+2cbbf-2cbc0,U+2cbce,U+2cc56,U+2cc5f,U+2ccf5-2ccf6,U+2ccfd,U+2ccff,U+2cd02-2cd03,U+2cd0a,U+2cd8b,U+2cd8d,U+2cd8f-2cd90,U+2cd9f-2cda0,U+2cda8,U+2cdad-2cdae,U+2cdd5,U+2ce05,U+2ce18,U+2ce1a,U+2ce23,U+2ce26,U+2ce2a;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_0e127a.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+45c4-46e8,U+2b9ef,U+2ba74,U+2bac7,U+2bb5f,U+2bb62,U+2bb7c,U+2bb83,U+2bc1b,U+2bc30,U+2bd58,U+2bd77,U+2bd87,U+2bdf7,U+2be29,U+2c029-2c02a,U+2c0a9,U+2c0ca,U+2c1ae,U+2c1d5,U+2c1d9,U+2c1f9,U+2c27c,U+2c288,U+2c2a4,U+2c317,U+2c35b,U+2c361,U+2c364,U+2c3df,U+2c488,U+2c494,U+2c497;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_b028be.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+44a2-45c3,U+2aed0,U+2afa2,U+2b124-2b125,U+2b127-2b128,U+2b137-2b138,U+2b1ed,U+2b300,U+2b328,U+2b363,U+2b36f,U+2b372,U+2b37d,U+2b404,U+2b410,U+2b413,U+2b461,U+2b4e7,U+2b4ef,U+2b4f6,U+2b4f9,U+2b50d-2b50e,U+2b536,U+2b5ae-2b5af,U+2b5b3,U+2b5e7,U+2b5f4,U+2b61c-2b61d,U+2b624,U+2b626-2b628,U+2b62a,U+2b62c,U+2b695-2b696,U+2b6ad,U+2b6de,U+2b6e9,U+2b6ed,U+2b6f6,U+2b728,U+2b7a9,U+2b7c5,U+2b7e6,U+2b7f7,U+2b7f9,U+2b7fc,U+2b806,U+2b80a,U+2b81c,U+2b892,U+2b8b8;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_35c17d.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+434d-44a1,U+2a3ad,U+2a7dd,U+2a8fb,U+2a917,U+2aa07,U+2aa17,U+2aa30,U+2aa36,U+2aa58;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_abac36.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4236-434c,U+29f7e,U+29f83,U+29f8c;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_d829f0.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+40e9-4235,U+289c0,U+28a0f,U+28b46,U+28b49,U+28b4e,U+28c47,U+28c4d,U+28c4f,U+28c51,U+28c54,U+28e99,U+28f7b,U+294d0,U+295d7;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_0f358f.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+3f8b-40e8,U+27c35,U+27ff9,U+280bf,U+2812e,U+28263,U+28408,U+284c8,U+28678,U+28695,U+287e0;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_782503.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e69,U+4f67,U+4f7e,U+4fdc,U+50e6,U+50ec,U+5196,U+51ab,U+51b1,U+5202,U+5233,U+5261,U+52ac,U+531a,U+5363,U+536e,U+5416,U+5432,U+5454,U+5472,U+5476,U+5478,U+54ad,U+54bb,U+54c2,U+54cf,U+54da,U+54de,U+5537,U+553f,U+5575,U+5581,U+55cc,U+55fe,U+5601,U+560c,U+561e,U+564d,U+566b,U+56dd,U+5709,U+572c,U+572e,U+576b,U+57cf,U+57d2,U+57f8,U+580d,U+581e,U+5844,U+5881,U+589a,U+5924,U+5981,U+5997,U+59a3,U+59b2,U+5ab8,U+5ae0,U+5b34,U+5b5b,U+5ba5,U+5c25,U+5c59,U+5c63,U+5d1e,U+5d24,U+5d6b,U+5e11,U+5e14,U+5e8b,U+5eb3,U+5ed1-5ed2,U+5ef4,U+5efe,U+5f40,U+5f58,U+5f73,U+5f8c,U+5fad,U+600d,U+6019,U+601b,U+602b,U+605a,U+6083,U+6092,U+6100,U+610e,U+623d,U+624c,U+62ca,U+634c,U+6369,U+638e,U+641b,U+6426,U+646d,U+6535,U+65c4,U+65d2,U+6661,U+66db,U+670a,U+6715,U+6753,U+6798,U+67c3,U+67f0,U+681d,U+6855,U+686b,U+68f9,U+6939,U+696e,U+6980,U+698d,U+69b1,U+69ca,U+69ed,U+69f2,U+69ff,U+6a18,U+6aab,U+6b39,U+6b82,U+6b84,U+6b9b,U+6bb3,U+6bf3,U+6bf9,U+6c05-6c06,U+6c0d,U+6c19-6c1a,U+6cb2,U+6cd6,U+6d07,U+6d1a,U+6d27,U+6d2b,U+6d33,U+6d91,U+6dab,U+6e53-6e54,U+6e8f,U+6ebb,U+6f36,U+6fb6,U+7035,U+7085,U+70bb,U+7178,U+71b5,U+7228,U+7256,U+725d,U+726e,U+727e,U+72b0,U+72b4,U+72ce,U+72f7,U+72fa-72fb,U+7313,U+7322,U+738e,U+739f,U+73e5,U+7481,U+74ba,U+74e0,U+750f,U+7540,U+754b,U+755a-755b,U+758b,U+7594,U+75b3,U+760c,U+7615,U+7630,U+7635,U+7640,U+7654,U+76a4,U+76cd,U+775a,U+7765,U+7768,U+77bd,U+77ec,U+781f,U+78a1,U+78d4,U+78d9,U+7905,U+7913,U+79b3,U+79d5,U+79eb,U+7a02,U+7a70,U+7a86,U+7b25,U+7b2a-7b2b,U+7b2e,U+7b31,U+7b38,U+7b47,U+7b62,U+7b6e,U+7b72,U+7b7b,U+7b85,U+7b9c,U+7bcc,U+7bea,U+7bfc,U+7c0b,U+7c0f,U+7c26,U+7c9e,U+7ce8,U+7dae,U+7e47,U+7e9b,U+7f36,U+7f7e,U+7fb0,U+7fbc,U+7fee,U+8016,U+8020,U+802a,U+8031,U+8043,U+8071,U+809f,U+80ab,U+80cd,U+80d7,U+8112,U+8118,U+8160,U+8169,U+8188,U+81a6,U+81c1,U+8234,U+823e,U+825f,U+8298,U+829f,U+82a4,U+82ca,U+82e1,U+82e4,U+82f4,U+82fb,U+8308,U+831a,U+8333,U+83c0,U+83d4,U+83df,U+83f8,U+8401,U+840f,U+841c,U+8451,U+845a,U+846d,U+8476,U+8478,U+84ba,U+84bd,U+851f,U+8548,U+8556,U+855e,U+8585,U+85a4,U+8627,U+8629,U+867a,U+868b,U+868d,U+86b5,U+86c4,U+86de,U+86e9,U+86f8,U+872e,U+8731,U+873e,U+8765,U+877b,U+877d,U+8785,U+87ab,U+87ea,U+87ee,U+880a,U+8813,U+8816,U+8832,U+8864,U+8872,U+887f,U+88a2,U+88b7,U+88c9,U+88ce,U+88f0,U+890a,U+892b,U+897b,U+89da,U+89f3,U+8a48,U+8c55,U+8c73,U+8c78,U+8d91,U+8dba,U+8dcf,U+8deb,U+8e14,U+8e3a,U+8e41,U+8e6f,U+8e90,U+8fb6,U+8fd3,U+8fe8,U+9011,U+902d,U+90be,U+915e,U+9161-9162,U+9179,U+9185,U+918c,U+9190,U+91a3,U+92c8,U+953a,U+961d,U+9654,U+96ce,U+96e9,U+972a,U+9730,U+9785,U+9954,U+9aba,U+9ac0-9ac1,U+9ae1,U+9aeb,U+9aed,U+9af9,U+9b1f,U+9b32,U+9b4d,U+9b51,U+9e87,U+9ee2,U+9f17,U+9f19,U+9f22,U+9f2c,U+9f2f;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_0d29b4.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+3e19-3f8a,U+26d4f,U+277c4;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_5a8449.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+3cc5-3e18,U+260b3,U+260c2,U+26220-26221,U+26246,U+26291,U+26351,U+26369,U+2640c,U+2648d,U+26548,U+26676,U+2677c,U+26b5c,U+26c0f,U+26c21;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_545620.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e47,U+4e5c,U+4e8d,U+4e93,U+4ebb,U+4ec4,U+4edf,U+4ef5,U+4f32,U+4f5e,U+4f76,U+4f7b,U+4f89,U+5028,U+502c,U+5048,U+5080,U+50ee,U+5121,U+51c7,U+5208,U+520e,U+52f0,U+530f,U+535f,U+53f5,U+5412,U+5443,U+5464,U+5466,U+54a3,U+54a6,U+54bf,U+54d0,U+5533,U+5541,U+5549,U+5550,U+5556,U+555c,U+5576-5577,U+558f,U+5591,U+55b1,U+55dd,U+55e5,U+55f2,U+55f5,U+560f,U+5627,U+562c,U+564c,U+5657-5658,U+5664,U+567b,U+5685,U+56d4,U+56e1,U+571c,U+5769,U+577c,U+578c,U+5793,U+57e4,U+5800,U+5807,U+5889,U+58c5,U+593c,U+59a4,U+59af,U+59d8,U+5a09,U+5a0c,U+5a4a,U+5a62,U+5a67,U+5ad8,U+5aeb,U+5b53,U+5b6c,U+5c3b,U+5c50,U+5c7a,U+5ca2-5ca3,U+5cac,U+5d26-5d27,U+5d6c,U+5d9d,U+5db7,U+5df3,U+5e19,U+5ea0,U+5eb9,U+5ebe,U+5edb,U+5f01,U+5f2d,U+5fb5,U+5fbc,U+5fdd,U+5fea,U+5ff8,U+6029,U+6041-6042,U+60b1,U+6175,U+6215,U+6221-6222,U+623e,U+6308,U+6339,U+638a,U+63e0,U+643d,U+6441,U+647a,U+6485,U+64e2,U+6555,U+659b,U+65ab,U+65c3,U+65c6,U+65f0,U+6600,U+6677,U+668c,U+669d,U+66be,U+66e9,U+66f7,U+6775,U+678b,U+67c1,U+67d2,U+67e2,U+6832,U+6840,U+6874,U+6877,U+68f0,U+68fc,U+690b,U+6910,U+6934,U+6966,U+6978,U+6998,U+69a7,U+69c1,U+69ce,U+6a17,U+6a3d-6a3e,U+6a50,U+6a5b,U+6a84,U+6a97,U+6b8d,U+6baa,U+6bd6,U+6bfd,U+6c10,U+6c16,U+6c18,U+6c3d,U+6c54,U+6cd4,U+6ceb,U+6cee,U+6d04,U+6d0c,U+6d60,U+6ddd,U+6de0,U+6e6b,U+6eb1,U+6ebd,U+6ecf,U+6fde,U+7023,U+70b7,U+70ca,U+7110,U+7168,U+71b3,U+71f9,U+7230,U+723b,U+7284,U+728d,U+7292,U+72d2,U+72d9,U+7301,U+731e,U+7352,U+7360,U+73a2,U+73b3,U+740a,U+7441,U+7457,U+74ff,U+7511,U+75a3,U+75a5,U+75d8,U+75e3-75e4,U+75e7,U+7610,U+7619,U+7622,U+7633,U+7688,U+76b4,U+76e5,U+771a,U+772d,U+779f,U+7812,U+7818,U+7826,U+7829,U+782c,U+784c,U+7850,U+7887,U+789a,U+78b2,U+78f4,U+7957,U+7a39,U+7aa8,U+7ae6,U+7b04,U+7b0f,U+7b1e,U+7b24,U+7bac,U+7bb8,U+7bc1,U+7bd9,U+7c1f,U+7ccc,U+7f45,U+7f5f,U+7f71,U+7fa7,U+7faf,U+7fbf,U+7fd5,U+7fe5-7fe6,U+7ff3,U+801c,U+8052,U+8084,U+8093,U+80b1,U+80bc,U+80db,U+80dd,U+80e4,U+80ef,U+80f4,U+8132,U+815a,U+8167,U+81ca,U+81cc,U+81ec,U+8202,U+8210,U+8228,U+8244,U+8249,U+824f,U+828a,U+828e,U+82a1,U+82a8-82a9,U+82ab,U+82b4,U+82e3,U+8306,U+833a,U+8351,U+8378,U+83a8,U+83aa,U+83d8,U+83e1,U+83ea,U+83f0,U+8497,U+84c1,U+84ca,U+84fc,U+8538,U+853b,U+857b,U+858f,U+859c,U+85ff,U+8605,U+8662,U+867b,U+86a8-86a9,U+86b1,U+86b4,U+86c9,U+86ed,U+870a,U+8722,U+8725,U+8734,U+874c,U+8763,U+876e,U+879f,U+87b3,U+87ca,U+87e5,U+887d-887e,U+8888,U+88df,U+88fe,U+8919,U+8921,U+89e5,U+8c47,U+8c7a,U+8d67,U+8db5,U+8dbf,U+8dd7,U+8dec,U+8dfd,U+8e05,U+8e1f,U+8e59,U+8e69,U+8e85,U+900b,U+9021,U+902f,U+9044,U+9062,U+908b,U+9099,U+909b,U+90b4,U+90c7,U+90eb,U+90fe,U+9123,U+9143,U+9146,U+9172,U+918d,U+91aa,U+91ae,U+91ba,U+93ca,U+96b9,U+96d2,U+9708,U+975b,U+977c,U+992e,U+9955,U+9ab0,U+9ab6-9ab7,U+9ac2,U+9afb,U+9b23,U+9b3b,U+9ecd,U+9ecf,U+9ee7;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_bc06aa.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e4d,U+4e5e,U+4ea8,U+4ed5,U+4f51,U+4f7c,U+4f83,U+4fae,U+4fd1,U+4fd8,U+4fef,U+4ffa,U+5018,U+5026,U+508d,U+50a3,U+50da,U+50e7,U+50fb,U+515c,U+5162,U+5189,U+5195,U+51a4,U+51f3,U+51f9,U+5203,U+5228,U+5254,U+5256,U+52ff,U+5319,U+5320,U+533e-533f,U+5351,U+5375,U+53db,U+53ed-53ee,U+541f,U+5435,U+543b,U+5475,U+5484,U+548b,U+5490,U+54c9,U+54e9,U+54fa,U+5543,U+5587,U+5589,U+5598,U+55a7,U+55d3,U+560e,U+576f,U+5777,U+57ae,U+57e0,U+5830,U+589f,U+58a9,U+5937,U+5962,U+5974,U+5984,U+59ae,U+59e8,U+59ec,U+5a1f,U+5a23,U+5a36,U+5a3c,U+5a49,U+5a9a,U+5ab3,U+5ac2,U+5ad6,U+5b5a,U+5b99,U+5b9b,U+5bb5,U+5bc5,U+5bc7,U+5bde,U+5be1,U+5be5,U+5c09,U+5c2c,U+5c51,U+5cb1,U+5cd9,U+5cea,U+5d0e,U+5d16,U+5d29,U+5d4c,U+5dcd,U+5deb,U+5e87,U+5e9a,U+5ed3,U+5f13,U+5f57,U+5f64,U+5f87,U+5f8a,U+5f98,U+5fe1,U+5ff1,U+6016,U+6021,U+606a,U+606d,U+6096,U+60bc,U+60d5,U+60df,U+60f9,U+611a,U+614c,U+6177,U+6241,U+6252,U+6254,U+625b,U+626f,U+627c,U+6292,U+6296,U+62b9,U+62c2,U+62cc,U+62ef,U+62f1,U+634f,U+63a0,U+63e3,U+6467,U+6495,U+64bc,U+64c2,U+6518,U+6577,U+6590,U+65a7,U+6627,U+6643,U+6674,U+6687,U+66ae,U+66d9,U+66dd,U+66f0,U+6714,U+673d,U+6789,U+6795,U+67d1,U+67ff,U+6813,U+6829,U+6893,U+68a7,U+68ad,U+68b3,U+68cd,U+68d5,U+68d8,U+68e0,U+690e,U+6930,U+6960,U+6977,U+6986,U+69bb,U+69d0,U+69fd,U+6a1f,U+6b47,U+6b49,U+6b6a,U+6bd7,U+6c13,U+6c1f,U+6c2e-6c2f,U+6c72,U+6c7e,U+6c81,U+6c90,U+6cbc,U+6ccc,U+6cd3,U+6cd7,U+6ce3,U+6d5a,U+6dcc,U+6de4,U+6df3,U+6e1d,U+6e23,U+6e2d,U+6e9c,U+6ea5,U+6ec7,U+6ed4-6ed5,U+6f2f,U+6f33,U+6f62,U+6fa1,U+7011,U+701a,U+707c,U+708a,U+70af,U+70d8,U+70f9,U+711a,U+7130,U+714e,U+716e,U+7194,U+71a0,U+71ac,U+71e5,U+7280-7281,U+72d0,U+72fc,U+7316,U+731c,U+7334,U+7357,U+7384,U+73ab,U+73ca,U+7422,U+7426,U+742a,U+745b-745c,U+745f,U+7470,U+7480,U+74a7,U+7529,U+7578,U+75d5,U+7696,U+76ce,U+7736-7737,U+77a7,U+77bb,U+77d7,U+77e2-77e3,U+77ee,U+780c,U+785d,U+788c,U+789f,U+78c5,U+78ca,U+7901,U+7941,U+796d,U+797a,U+79be,U+79e4,U+79f8,U+7a1a,U+7a3c,U+7a57,U+7a74,U+7a98,U+7a9f,U+7aff,U+7b1b,U+7bf7,U+7c07,U+7c3f,U+7c7d,U+7c9f,U+7ca5,U+7cb9,U+7cd9,U+7cdf,U+7e82,U+7f38,U+7f69,U+7f9e,U+7fdf,U+7fe9,U+800d,U+8018,U+803d,U+803f,U+80a2,U+80aa,U+80d6,U+8116,U+813e,U+8151,U+8155,U+817a,U+819d,U+81fb,U+8205,U+820c,U+821c,U+82ad,U+82af,U+82b9,U+82bd,U+82d1,U+82db,U+82df,U+82ef,U+8304,U+832c,U+8335,U+8354,U+839e,U+8403,U+840c,U+840e,U+8446,U+846b,U+849c,U+84b2,U+84c9,U+8517,U+851a,U+853d,U+8549,U+8587,U+85af,U+85fb,U+8611,U+8650,U+865e,U+868a,U+868c,U+86d9,U+8700,U+8774,U+8776,U+8862,U+88d4,U+88d8-88d9,U+88f8-88f9,U+8912,U+895f,U+8a79,U+8b6c,U+8c41,U+8c79,U+8d81,U+8d9f,U+8dcb,U+8dea,U+8e22,U+8e29,U+8e35,U+8e44,U+8e72,U+8e81,U+8eac,U+8eb2,U+8fe2,U+8fed,U+9017,U+901b,U+902e,U+9038,U+9050,U+906e,U+9091,U+90aa,U+90af,U+90dd,U+914b,U+916f,U+9175-9176,U+918b,U+946b,U+962e,U+9640,U+964c,U+9661,U+9672,U+968b,U+9699,U+96c0,U+9716,U+9756,U+9761,U+97a0,U+97f6,U+99a8,U+9ad3,U+9ae6,U+9e93,U+9ed4;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_cd2170.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+3b81-3cc4,U+2538a,U+2546a,U+25532,U+25562,U+255a8,U+255fd,U+256f1,U+2591a,U+25997,U+25bbe,U+25c7c,U+25cbb,U+25dd1,U+25ed7;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_fdc333.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+3a1a-3b80,U+246d8,U+247a4,U+249db,U+24a0f,U+24a7d,U+24a8c,U+24ac9,U+24b5b,U+24c1c,U+24c48,U+24cc8,U+24d21,U+24d50,U+24ea5,U+25128,U+251e2;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_f9818b.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e15,U+4e1e,U+4e2b,U+4eb3,U+4ec9,U+4f0e,U+4f3d,U+4f58,U+4f64,U+4f8f,U+4fd0,U+4fdf,U+501c,U+5043,U+5055,U+510b,U+5140,U+51bc-51bd,U+51cb,U+5243,U+527d,U+52be,U+531d,U+5323,U+535e,U+536f,U+53a5,U+53f1,U+541d,U+5429,U+542d-542e,U+5431,U+547b,U+5486,U+548e,U+5492,U+54aa-54ab,U+54af,U+54c6,U+54ce,U+54fd,U+5506,U+5509,U+5511,U+552c,U+553e,U+557b,U+55b3,U+55c5,U+55d2,U+55e1,U+55e6,U+55f7,U+55fd,U+5600,U+5608,U+561f,U+5636,U+5639,U+563b,U+563f,U+5659,U+5662,U+568e,U+5693,U+572a,U+573b,U+574d,U+5768,U+5773,U+577b,U+57a0,U+57b8,U+57c2,U+586c,U+587e,U+5892,U+58d5,U+58f9,U+598a,U+5992-5993,U+59e3,U+5a20,U+5a29,U+5a6a,U+5a7a,U+5ac9,U+5ae3,U+5b17,U+5b62,U+5b7d,U+5b95,U+5ba6,U+5bb8,U+5c15,U+5c4e,U+5c8c,U+5cab,U+5cb7,U+5d34,U+5d3d,U+5d4a,U+5d82,U+5e1a-5e1b,U+5e44,U+5e7a,U+5ea5,U+5eff,U+5f0b,U+5f29,U+5f77,U+5f89,U+5f99,U+5f9c,U+5fa8,U+5fd0-5fd1,U+5fd6,U+5fff,U+6026,U+6035,U+6043,U+6059,U+6063,U+60b4,U+60b8,U+60bb,U+60d8,U+60f0,U+6115,U+612b,U+618e,U+6194,U+61a9,U+61ca,U+61e6,U+61f5,U+620a,U+6248,U+62a8,U+62d7,U+62fd,U+630e,U+637a,U+6390,U+63a3,U+63ac,U+63b0,U+63e9,U+642a,U+64a9,U+64ae,U+6525,U+652b,U+6538,U+655d,U+65bc,U+65cc,U+65ce,U+65d6,U+65ee-65ef,U+6631,U+6636,U+664c,U+665f,U+6666,U+6684,U+66b9,U+66f3,U+6773,U+6777,U+677c,U+6787,U+67b8,U+67d8,U+67de,U+67e9,U+6845,U+684e,U+6886,U+688f,U+68b5,U+693d,U+6963,U+696b,U+6979,U+6994,U+69b7,U+6a35,U+6aac,U+6b59,U+6c26,U+6c50,U+6c69,U+6c74,U+6c76,U+6c8c,U+6cae,U+6ce0,U+6cef,U+6d5c,U+6d8e,U+6d95,U+6db8,U+6dc5,U+6dd6,U+6dde,U+6de6,U+6dfc,U+6e1a,U+6e25,U+6e6e,U+6e9f,U+6ea7,U+6eb4,U+6ef9,U+6f15,U+6f29-6f2a,U+6f78,U+6fa7,U+6fc2,U+6fe1,U+7131,U+714a,U+715c,U+7166,U+7172,U+71b9,U+7317,U+731d,U+7329,U+7337,U+733e,U+7396,U+73b7,U+73e9,U+7425,U+7428,U+742c,U+742e,U+7455,U+7490,U+74f4,U+7519,U+7525,U+753a,U+75b5,U+75b9,U+75d4,U+75de,U+75e2,U+75fc,U+7638,U+7656,U+765c,U+7678,U+768e,U+76bf,U+76c2,U+76d4,U+76f9,U+7728-7729,U+7762,U+776c,U+777d,U+77a0,U+77cd,U+77f8,U+77fd,U+7825,U+7837,U+787c,U+78b4,U+78d0,U+795a,U+795f,U+79ed,U+7a1e,U+7a37,U+7a79,U+7a92,U+7abf,U+7afa,U+7b06,U+7b20,U+7b4f,U+7b60,U+7b75,U+7b8d,U+7bb4,U+7bdd,U+7be1,U+7cb3,U+7cbc,U+7cdc,U+7cef,U+7f44,U+7f54,U+7f8c,U+7f94,U+7fb2,U+7fb9,U+7fce,U+7fe1,U+8004,U+800b,U+8019,U+808b,U+809b,U+80f1,U+8146,U+816e,U+81c0,U+81c3,U+81c6,U+81fc,U+81fe,U+822b,U+82a5,U+82aa,U+82de,U+8309,U+8317,U+832f,U+8340,U+8343,U+8392,U+83a0,U+8431,U+8469,U+84af,U+8543,U+854a,U+8559,U+8568,U+85b0,U+85b9,U+8638,U+869c,U+86c0,U+86e4,U+8713,U+8718,U+87c0,U+87cb,U+87fe,U+8821-8822,U+8882,U+8892,U+88e8,U+88f1,U+8902,U+892a,U+8983,U+8c5a,U+8c82,U+8d73,U+8dbe,U+8e74,U+8e7c,U+8e87,U+8fc2,U+8fe4,U+8fe6,U+900d,U+9035,U+9041,U+9052,U+905b,U+9068,U+9074,U+9095,U+90a1,U+90c5,U+911e,U+9122,U+9149,U+9157,U+916a,U+916e,U+919b,U+93d6,U+9621,U+9631,U+9642,U+968d,U+96c9,U+9706,U+970f,U+97ed,U+99a5,U+9abc,U+9b03,U+9e9d,U+9ebe,U+9edb,U+9edd,U+9f10;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_09a9b3.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+3886-3a19,U+235cb,U+2365c,U+237c4,U+237f4,U+23a3c,U+23b72,U+23c7c,U+23c97-23c98,U+23e23,U+23e8c,U+241fe,U+242ee,U+24350,U+243f6;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_6927bf.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4edd-4ede,U+4ee1,U+4ee8,U+4eeb,U+4f09,U+4f22,U+4f43,U+4f57,U+4f5a,U+4f5d,U+4f6f,U+4fce,U+4fda,U+4ff3,U+4ff8,U+4ffe,U+500c,U+500f,U+5025,U+502d,U+504e,U+5088,U+50f3,U+5106,U+5241,U+525c,U+52ad,U+530d,U+5310,U+5345,U+539d,U+53df,U+53fb-53fc,U+5420-5421,U+544b,U+5477,U+5482,U+54a7,U+54cc,U+54e7,U+550f,U+5514,U+5530,U+5544,U+5555,U+557e,U+558b,U+5594,U+5599,U+559f,U+55b9,U+55c4,U+55d1,U+55d4,U+55d6,U+55df,U+55e4,U+55e8,U+55ea,U+55ef,U+5623,U+562d,U+564e,U+5654,U+5671,U+56d7,U+56eb,U+56f9,U+5704,U+576d,U+57a1,U+57d5,U+57ed,U+584d,U+5880,U+58ec,U+5914,U+5958,U+599e,U+59be,U+59dd,U+5a11,U+5a40,U+5a55,U+5abe,U+5ae1,U+5ae6,U+5b40,U+5b51,U+5b71,U+5b93,U+5bee,U+5c9c,U+5cc1,U+5ccb,U+5cd2,U+5d06,U+5d2e,U+5d47,U+5d4b,U+5d6f,U+5d99,U+5dfd,U+5e54,U+5e5b,U+5e61,U+5e96,U+5fd2,U+600f,U+6014,U+606b,U+608c,U+609a,U+60c6,U+60da,U+60f4,U+60fa,U+61ff,U+621b,U+621f,U+622e,U+62bb,U+62bf,U+62c8,U+634b,U+636d,U+6371,U+6387,U+63ae,U+63c4,U+63c6,U+63cd,U+63d6,U+63f6,U+6410,U+6414,U+6421,U+64c0,U+64d8,U+661d,U+6641,U+6657,U+66dc,U+6710,U+6748,U+6772,U+67b0,U+67b7,U+6841,U+6849,U+6954,U+695d,U+699b,U+69ab,U+6a28,U+6a47,U+6a58,U+6b46,U+6c21,U+6c24,U+6c35,U+6c4a,U+6c68,U+6c86,U+6c8f,U+6c94,U+6cc5,U+6d2e,U+6d35,U+6d39,U+6d63,U+6d6f,U+6d94,U+6dd9,U+6dec,U+6e4e,U+6e98,U+6ec2,U+6ed3,U+6f09,U+6f66,U+6f8d,U+6fb9,U+6fc9,U+6fe0,U+6fef,U+705e,U+7094,U+7119,U+712f,U+71e7,U+723f,U+726f,U+729f,U+7325,U+7350,U+73c8,U+73d9,U+73de,U+741a,U+7430,U+74e4,U+753e,U+7548,U+7566,U+7579,U+757f,U+7583,U+759d,U+75b8,U+75bd,U+75c2,U+75cd,U+75f1,U+75ff,U+7634,U+7699,U+76c5,U+76f1,U+7708,U+7719,U+777e,U+778c,U+7791,U+77b3,U+77dc,U+781d,U+7823,U+7827,U+782d,U+7830,U+783c,U+7889,U+7893,U+78a3,U+78ec,U+7949,U+795c,U+79e3,U+7a14,U+7a17,U+7a88,U+7a95,U+7aa0,U+7afd,U+7b08,U+7b33,U+7b90,U+7b95,U+7be6,U+7bfe,U+7c0c,U+7c2a,U+7c91,U+7cb2,U+7cc5,U+7da6,U+7f58,U+7f61,U+7fb8,U+7fca,U+8006,U+8012,U+8026,U+8037,U+807f,U+80bd,U+80ed,U+814b,U+8153,U+8171,U+8174,U+8198,U+8200,U+8214,U+821b,U+8222,U+826e,U+828d,U+82be,U+82c4,U+82d2,U+82d5,U+82dc,U+82eb,U+82f7,U+830c,U+832d,U+8331,U+8334,U+834f,U+83c5,U+83d6,U+83fd,U+840b,U+8418,U+8438,U+843c,U+8473,U+847a,U+84a1,U+84d6,U+84ff,U+852b,U+8564,U+85d0,U+8693,U+86a3-86a4,U+86a7,U+86aa,U+86af,U+86b6,U+86c6,U+86d0,U+86f9,U+8703,U+8707-8709,U+870d,U+8737,U+873b,U+8759,U+8760,U+8782,U+87af,U+87c6,U+87d1-87d2,U+87e0,U+8815,U+8839,U+8869,U+8913,U+8936,U+8a07,U+8a3e,U+8b07,U+8c49,U+8c4c,U+8c62,U+8c89,U+8d6d,U+8d84,U+8d94,U+8dc6,U+8dce,U+8ddb,U+8dfa,U+8e09,U+8e1d,U+8e2e,U+8e31,U+8e39,U+8e3d,U+8e42,U+8e47,U+8e49,U+8e4b,U+8e6d,U+8e76,U+9004-9005,U+9016,U+9036,U+9051,U+907d,U+9082,U+9088,U+9097,U+90b0,U+90d7,U+90dc,U+90e2,U+90ef,U+9104,U+914a,U+9150,U+9169,U+919a,U+91b4,U+938f,U+965f,U+9697,U+96b0,U+96bc,U+970e,U+9739,U+973e,U+9798,U+97a3,U+9997,U+9aef,U+9e82,U+9edc,U+9edf-9ee0,U+9f3e;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_f7ca8a.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e0c,U+4e28,U+4e36,U+4e3f,U+4ea0,U+4ec2,U+4ef3,U+4f74,U+4f91,U+4f94,U+4fc5,U+502e,U+50ba,U+50d6,U+50ed,U+5107,U+5155,U+5182,U+51f5,U+5216,U+525e,U+5281-5282,U+5290,U+5293,U+52d6,U+52f9,U+5369,U+53b6,U+54a9,U+54ff,U+552a,U+553c,U+5588,U+55b5,U+55c9,U+55cd,U+5686,U+570a,U+572f,U+574c,U+5776,U+57a4,U+57f4,U+57fd,U+580b,U+5819,U+5820,U+5865,U+58bc,U+5902,U+5925,U+59d2,U+5ab5,U+5b16,U+5b32,U+5b65,U+5b80,U+5b84,U+5be4,U+5c1c,U+5c22,U+5c6e,U+5c88,U+5c8d,U+5cb5,U+5d3e,U+5ddb,U+5e80,U+5ee8,U+5f50,U+5f56,U+5f61,U+5f82,U+5fc4,U+5fc9,U+5fe4,U+5fed,U+600a,U+605d,U+6067,U+609b,U+609d,U+60dd,U+6106,U+614a,U+615d,U+619d,U+6224-6225,U+6243,U+62f6,U+6343,U+63ad,U+63be,U+63ce,U+63de,U+63f2,U+640b-640c,U+6420,U+6434,U+643f,U+6496,U+64d0,U+64d7,U+64e4,U+6509,U+652e,U+6534,U+6549,U+656b,U+6603,U+6634,U+674c,U+676a,U+67b5,U+67d9,U+67dd,U+6833,U+6844,U+6883,U+6957,U+6971,U+69d4,U+6a8e,U+6a91,U+6aa0,U+6b37,U+6b43,U+6bea,U+6c15,U+6c2a,U+6c46,U+6c5c,U+6cd0,U+6d0e,U+6d7c,U+6e2b,U+6eb2,U+6eb7,U+6f2d,U+6f72,U+6f89,U+6f8c,U+7039,U+706c,U+70b1,U+70c0,U+7113,U+71d4,U+71e0,U+721d,U+727f,U+728b,U+728f,U+72c1,U+72c3,U+72e8,U+72f3-72f4,U+730a,U+7331,U+7338-7339,U+734d,U+736c,U+736f,U+73e7,U+746d,U+74a9,U+74de,U+750d,U+7513,U+752f,U+754e,U+7592,U+75c3-75c4,U+75e6,U+760a,U+761b,U+7625,U+762d,U+763c,U+7643,U+764d,U+7704,U+7707,U+7722,U+7735,U+7743,U+7747,U+7780,U+778d,U+77a2,U+77b5,U+77e7,U+7809,U+7811,U+7839,U+7847,U+784e,U+786a,U+786d,U+78a5,U+78c9,U+78f2,U+791e,U+7924,U+793b,U+7946,U+7953,U+7967,U+798a,U+7a03,U+7a78,U+7a80,U+7aac,U+7ab3,U+7b0a,U+7b45,U+7b4c,U+7b58,U+7b9d,U+7ba2,U+7bda,U+7be5,U+7c40,U+7ca2,U+7cc8,U+7cd7,U+7cf8,U+7e3b,U+7f68,U+7f9d,U+8025,U+8028-8029,U+8035,U+8080,U+809c,U+80ad,U+80c2,U+80e9,U+80ec,U+80f2,U+810e,U+811e,U+812c,U+8182,U+8201,U+8204,U+8221,U+822d,U+822f,U+8233,U+824b,U+825a,U+8268,U+8274,U+827d,U+827f,U+8284,U+828f,U+8291,U+82b0,U+82e0,U+8307,U+831b,U+833c,U+8347,U+837d,U+839b,U+83a9,U+83b0,U+83dd,U+83e5,U+83f9,U+8406,U+8459,U+845c,U+8488,U+848e,U+84b4,U+84b9,U+84cd,U+84d0,U+84f0,U+850c,U+8579-857a,U+85a8,U+85b7,U+863c,U+864d,U+867c,U+86b0,U+86ba,U+86d1,U+86d8,U+871e,U+8723,U+8729,U+8753,U+8764,U+8770,U+8788,U+878b,U+8793,U+8797,U+87ac-87ad,U+87b5,U+87bd,U+87d3,U+87db,U+8803,U+881b,U+883c,U+8844,U+88bc,U+88d2,U+88fc,U+8930,U+895e,U+8966,U+89d6,U+89eb,U+8b26,U+8c85,U+8c8a,U+8c94,U+8c98,U+8dbc,U+8de3,U+8e23,U+8e40,U+8e94,U+8e9e,U+8ece,U+8fd5,U+9058,U+90b6,U+90db,U+914e-914f,U+9164,U+9174,U+9191,U+91a2,U+91ad,U+91af,U+91b5,U+928e,U+936a,U+943e,U+955f,U+9622,U+963c-963d,U+966c,U+9674,U+9688,U+96b3,U+9794,U+97ab,U+9998,U+9ab1,U+9ad1,U+9adf,U+9b08,U+9b0f,U+9b2f,U+9b43,U+9b48,U+9eb4,U+9ee5,U+9ef9,U+9efb-9efc,U+9f37,U+9f3d,U+9fa0;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_bf7217.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+3702-3885,U+2295e,U+2298f,U+22ad3,U+22aec,U+22b38,U+22ba5,U+22cdf,U+22d80,U+22dec,U+22f96,U+23519;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_5b1e0f.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e01,U+4e39,U+4e43,U+4e4f,U+4e58-4e59,U+4e95,U+4ea1,U+4ea6,U+4eab,U+4eae,U+4ec1,U+4ef2,U+4f0a,U+4f0f-4f11,U+4f2f-4f30,U+4f34,U+4f38,U+4f3c,U+4f69,U+4f73,U+4faf,U+4fb5,U+4fd7,U+4ff1,U+5012,U+5021,U+504f,U+5085,U+5141,U+5144,U+5146,U+5192,U+51a0,U+51b0,U+51b6-51b7,U+51dd,U+51e1,U+5200,U+5211,U+5237,U+523a,U+5272,U+52a3,U+52c3,U+52c7,U+52d2,U+52d8,U+52e4,U+5353,U+539a,U+53e5,U+53eb,U+5401,U+541b,U+5427,U+542b,U+5448,U+54e5,U+54ea,U+5510,U+552f,U+5531,U+554a,U+559d,U+5609,U+5740,U+5750,U+5761,U+5766,U+57c3,U+5802,U+5806,U+5821,U+5851,U+5854,U+585e,U+586b,U+58a8,U+58c1,U+5915,U+5949,U+594f,U+5954,U+5960,U+5976,U+5999,U+59a5,U+59b9,U+59bb,U+59c6,U+59d0-59d1,U+59d3,U+5a5a,U+5a92,U+5b54,U+5b5f,U+5b64,U+5b85,U+5b87-5b88,U+5b8b,U+5b97,U+5b9c,U+5bbf,U+5bc4,U+5bd2,U+5be8,U+5c01,U+5c04,U+5c0a,U+5c16,U+5c24,U+5c3e,U+5c4b,U+5c65,U+5cfb,U+5d07,U+5de1,U+5de7,U+5e1d,U+5e7c,U+5ec9,U+5ef7,U+5f04,U+5f1f,U+5f31,U+5f6d,U+5f70,U+5f79,U+5f92,U+5faa,U+5fbd,U+5fd8-5fd9,U+5fe0,U+5ffd,U+6015,U+602a,U+6050,U+6062,U+6069,U+6070,U+6084,U+6089,U+60a3,U+60a8,U+60b2,U+60dc,U+6162,U+6167,U+6170,U+61c2,U+622a,U+6234,U+626d,U+6291,U+62b1,U+62bc-62bd,U+62c6,U+62d2-62d4,U+62d6,U+62f3,U+6311,U+6316,U+6350,U+6355,U+6377,U+6389,U+638c,U+6398,U+63cf,U+63e1,U+63ed,U+63f4,U+640f,U+642c-642d,U+6458,U+6469,U+6478,U+64a4,U+64cd,U+6500,U+653b,U+654f,U+6562,U+6566,U+656c,U+658c,U+65c1,U+65e6,U+65e8,U+65ec,U+65f1,U+65fa,U+660c,U+6628,U+6668,U+667a,U+6696,U+66b4,U+66f9,U+66fc,U+66ff,U+670b,U+6717,U+672b,U+675c,U+676d,U+6790,U+679a,U+679d,U+67b6,U+67ec,U+67f1,U+67f3-67f4,U+683d,U+6842,U+684c,U+6851,U+6885,U+68af-68b0,U+68ee,U+695a,U+699c,U+6b20,U+6b23,U+6b8a,U+6b96,U+6bc5,U+6bcd,U+6bd2,U+6beb,U+6c0f,U+6c1b,U+6c57,U+6c60,U+6c6a,U+6c89,U+6cc9,U+6cdb,U+6ce5,U+6cf0,U+6cf3,U+6d17,U+6d1b,U+6d1e,U+6d3d,U+6d59,U+6d66,U+6d69-6d6a,U+6d6e,U+6d82,U+6db2,U+6db5,U+6de1,U+6dee,U+6df7,U+6dfb,U+6e20-6e21,U+6e58,U+6eaa,U+6ecb,U+6ed1,U+6f0f,U+6f20,U+6f2b,U+6f58,U+704c,U+7070,U+708e,U+70ad,U+70b8,U+7126,U+714c,U+718a,U+719f,U+71c3,U+71d5,U+7206,U+7236,U+7238,U+7259,U+725b,U+7262,U+7267,U+72e0,U+731b,U+73bb,U+73cd,U+73e0,U+741b,U+7434,U+745e,U+7483,U+74dc,U+74e6,U+74f7,U+7518,U+7532,U+755c,U+756a,U+7586,U+7591,U+75ab,U+75be,U+75db,U+764c,U+7687,U+76c8,U+76df,U+76f2,U+76fe,U+7761,U+77db,U+786c,U+7891,U+78c1,U+78e8,U+795d,U+7965,U+7981,U+79d2,U+79df,U+79e6,U+79e9,U+7a00,U+7a3b,U+7a3f,U+7a46,U+7a7f,U+7a97,U+7adf,U+7aef,U+7af9,U+7b11,U+7b26,U+7bb1,U+7bc7,U+7c4d,U+7c89,U+7c97,U+7f8a,U+7fbd,U+7ffb,U+8000,U+8010,U+8015,U+8017,U+8033,U+8058,U+805a,U+8089,U+8096,U+809d,U+80a5,U+80a9,U+80af,U+80de,U+80e1,U+8150,U+817f,U+81a8,U+81e3,U+8212,U+827e,U+82ac,U+82b3,U+82d7,U+82e5,U+8302,U+8336,U+8352,U+8377,U+83ab,U+83f2,U+8461,U+8463,U+8499,U+84c4,U+852c,U+8584,U+864e,U+86cb,U+8861,U+8863,U+8870,U+8877,U+8881,U+888b,U+88c1-88c2,U+88d5,U+8986,U+8c46,U+8c6a,U+8c8c,U+8d64,U+8d6b,U+8d74,U+8da3,U+8dd1,U+8ddd,U+8ddf,U+8de8,U+8df3,U+8e0f,U+8f9b,U+8fd4,U+8fea-8feb,U+8ff7,U+9003,U+900f,U+9014,U+906d,U+9075,U+907f-9080,U+90a6,U+90ca,U+90ce,U+90ed,U+9192,U+91ce,U+963b,U+9644,U+966a,U+9675-9677,U+9686,U+9694,U+96c5,U+96d5,U+96e8,U+96ea,U+96f6-96f7,U+9707,U+971e,U+9732,U+978b,U+9910,U+9aa8,U+9ebb,U+9ece,U+9ed8;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_971f00.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+3569-3701,U+21681,U+216df,U+21a18,U+21a25,U+21b36,U+21deb,U+21ed5,U+2231a;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_df3eef.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+3001-3002,U+3008-3011,U+3014-3017,U+4e00,U+4e03,U+4e09-4e0b,U+4e0d,U+4e14,U+4e16,U+4e2d,U+4e3b,U+4e45,U+4e4b,U+4e4e,U+4e5d,U+4e5f,U+4e88,U+4e8b-4e8c,U+4e92,U+4e94,U+4e9b,U+4ea4,U+4eac,U+4eba,U+4ec0,U+4eca-4ecb,U+4ecd,U+4ed6,U+4ed8,U+4ee3-4ee5,U+4ef6,U+4efb,U+4efd,U+4f01,U+4f0d,U+4f46,U+4f4d-4f4f,U+4f55,U+4f5c,U+4f7f,U+4f8b,U+4f9b,U+4f9d,U+4fbf,U+4fc3-4fc4,U+4fdd,U+4fe1,U+500d,U+5019,U+503c,U+5047,U+505a,U+505c,U+5065,U+50cf,U+5143,U+5145,U+5148-5149,U+514d,U+5165,U+5168,U+516b-516d,U+5171,U+5175-5178,U+517c,U+518d,U+5206-5207,U+520a,U+5217,U+521d,U+5224,U+5229,U+5230,U+5238,U+523b,U+524d,U+526f,U+529b,U+529f-52a0,U+52a9-52aa,U+5305,U+5316-5317,U+5341,U+5347-5348,U+534a,U+5357,U+535a,U+5361,U+5370-5371,U+5382,U+539f,U+53bb,U+53c8,U+53ca-53cb,U+53cd,U+53d6-53d7,U+53e3-53e4,U+53e6,U+53ec,U+53ef-53f0,U+53f2-53f3,U+53f8,U+5404,U+5409,U+540d,U+5411,U+5426,U+5438,U+544a,U+5462,U+5468,U+5473,U+547c-547d,U+548c,U+54c1,U+54c8,U+552e,U+5546,U+5584,U+559c,U+5668,U+56db,U+56e0,U+56fa,U+571f,U+5728,U+5730,U+5733,U+5747,U+578b,U+57ce,U+57df,U+57f9-57fa,U+5883,U+589e,U+58eb,U+590f,U+5916,U+591a,U+591c,U+5927,U+5929-592a,U+592e,U+5931,U+5947,U+5957,U+5973,U+5979,U+597d,U+5982,U+59cb,U+59d4,U+5a01,U+5b50,U+5b57-5b58,U+5b63,U+5b69,U+5b89,U+5b8c,U+5b8f,U+5b98,U+5b9a,U+5ba2-5ba4,U+5bb3,U+5bb9,U+5bc6,U+5bcc,U+5bdf,U+5c0f,U+5c11,U+5c1a,U+5c31,U+5c3c,U+5c45,U+5c55,U+5c71,U+5cb8,U+5ddd-5dde,U+5de5-5de6,U+5dee,U+5df1-5df2,U+5df4,U+5e02,U+5e0c,U+5e38,U+5e45,U+5e55,U+5e73-5e74,U+5e8f,U+5e95,U+5e97,U+5e9c,U+5ea6-5ea7,U+5ead,U+5eb7,U+5ef6,U+5efa,U+5f0f,U+5f15,U+5f62,U+5f71,U+5f80,U+5f85,U+5f88,U+5f8b,U+5f90,U+5f97,U+5fae,U+5fb7,U+5fc3,U+5fc5,U+5feb,U+600e,U+601d,U+6025,U+6027,U+606f,U+60c5,U+60e0,U+60f3,U+610f,U+611f,U+6210-6211,U+6216,U+623f-6240,U+624b,U+6253,U+6276,U+6279,U+627e-6280,U+628a,U+6293,U+6295,U+6297,U+62c9,U+62cd,U+62db,U+62ec,U+62ff,U+6301,U+6307,U+6309,U+632f,U+6388,U+6392,U+63a2,U+63a5,U+63a7-63a8,U+63aa,U+63d0,U+641e,U+64ad,U+652f,U+6536,U+6539,U+653e-653f,U+6545,U+6548,U+6551,U+6563,U+6574,U+6587,U+6599,U+65a4,U+65af-65b0,U+65b9,U+65bd,U+65c5,U+65cf,U+65e5,U+65e9,U+660e,U+6613,U+661f-6620,U+6625,U+662f,U+665a,U+666e-666f,U+66f2,U+66f4,U+66fe,U+6700,U+6708-6709,U+670d,U+671b,U+671d,U+671f,U+6728,U+672a,U+672c,U+674e,U+6750-6751,U+675f,U+6797,U+67d0,U+67d3,U+6821,U+6839,U+683c,U+6848,U+68c9,U+690d,U+6a21,U+6b21,U+6b3e,U+6b4c,U+6b62-6b66,U+6b7b,U+6bb5,U+6bcf,U+6bd4,U+6bdb,U+6c11,U+6c34,U+6c38,U+6c42,U+6c5f,U+6c7d,U+6c99,U+6cb3,U+6cb9,U+6cbb,U+6cbf,U+6cd5,U+6ce2,U+6d0b,U+6d25,U+6d2a,U+6d32,U+6d3b,U+6d3e,U+6d41,U+6d77,U+6d88,U+6df1,U+6e05,U+6e2f,U+6e56,U+6e90,U+6f14,U+6f6e,U+6fb3,U+6fc0,U+706b,U+70c8,U+7136,U+7164,U+7167,U+7247-7248,U+724c,U+7269,U+7279,U+72af,U+7387,U+7389,U+738b,U+73ed,U+7403,U+7406,U+751a,U+751f,U+7528,U+7530-7531,U+7533,U+7537,U+754c,U+7559,U+7565,U+75c5,U+767b,U+767d-767e,U+7684,U+76ae,U+76ca,U+76db,U+76ee,U+76f4,U+76f8,U+770b,U+773c,U+7763,U+77e5,U+77ed,U+77f3,U+7814,U+7834,U+793a,U+793e,U+7956,U+795e,U+7968,U+798f,U+79c0,U+79d1,U+79fb,U+7a0b,U+7a76,U+7a7a,U+7a81,U+7acb,U+7ad9,U+7ae0,U+7ae5,U+7b2c,U+7b49,U+7b54,U+7b56,U+7b97,U+7ba1,U+7c73,U+7cbe,U+7cfb,U+7d20,U+7d22,U+7e41,U+7f3a,U+7f6a,U+7f6e,U+7f72,U+7f8e,U+8001,U+8003,U+8005,U+800c,U+80a1,U+80b2,U+80fd,U+81ea,U+81f3,U+821e,U+822a,U+822c,U+8239,U+826f,U+8272,U+82b1,U+82e6,U+82f1,U+8349,U+83dc,U+843d,U+8457,U+85cf,U+878d,U+8840,U+884c,U+8857,U+88ab,U+897f,U+8981,U+89d2,U+89e3,U+8a00,U+8b66,U+8c61,U+8d70,U+8d77,U+8d85,U+8d8a,U+8db3,U+8def,U+8eab,U+8fc5,U+8fce,U+8fd1,U+8ff0,U+8ffd,U+9000-9001,U+9010,U+901a,U+901f-9020,U+9047,U+904d,U+9053,U+90a3,U+90e8,U+90fd,U+914d,U+9152,U+91c7,U+91cd,U+91cf,U+91d1,U+9632,U+963f,U+964d,U+9650,U+9662,U+9664,U+969c,U+96c4,U+96c6,U+9700,U+9752,U+975e,U+9760,U+9762,U+9769,U+97f3,U+98df,U+9996,U+9999,U+9ad8,U+9f13,U+fe10-fe19,U+fe30-fe31,U+fe33-fe44;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_8867bc.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+27e8-27e9,U+2e81,U+2e84,U+2e88,U+2e8b-2e8c,U+2e97,U+2ea7,U+2eaa,U+2eae,U+2eb3,U+2eb6-2eb7,U+2ebb,U+2eca,U+2ff0-2ffb,U+3000,U+3003,U+3005-3007,U+3012-3013,U+3018-301b,U+301d-301e,U+3021-3029,U+303e,U+3105-3129,U+31c0-31e3,U+3220-3229,U+3231,U+32a3,U+338e-338f,U+339c-339e,U+33a1,U+33c4,U+33ce,U+33d1-33d2,U+33d5,U+3400-3568,U+20087,U+20089,U+200cc,U+20164,U+201ee,U+20242,U+20302,U+20542,U+20545,U+20587,U+20676,U+207ad,U+207b0,U+20a44,U+20ab1,U+20b6f,U+20bbe,U+20cd0,U+20d1f,U+20dff,U+20ef3,U+20f1f,U+2139a,U+21413,U+2144d,U+215d7;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_fdb231.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+20-7e,U+a0-17f,U+18f,U+192,U+1a0-1a1,U+1af-1b0,U+1cd-1dc,U+1e5,U+1e7,U+1e9,U+1ef-1f0,U+1f9-1ff,U+218-21b,U+21f,U+237,U+2c6-2c7,U+2c9,U+300,U+302,U+304,U+30c,U+ff04,U+ff0b,U+ff1c-ff1e,U+ff3e,U+ff40,U+ff5c,U+ff5e,U+ffe0-ffe5;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_d95e9e.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+50b4,U+50e8,U+5110,U+51aa,U+5244,U+528c,U+52f1,U+532d,U+53b4,U+54bc,U+551a,U+5645,U+5666,U+5672,U+568c,U+56a6,U+56b3,U+56c5,U+5852,U+58da,U+5969,U+5abc,U+5b24,U+5b4c,U+5c68,U+5d2c,U+5d81,U+5df0,U+5df9,U+5e6c,U+5f12,U+5f33,U+6128,U+6192,U+61ae,U+61cd,U+61df,U+6207,U+6214,U+6227,U+6451,U+6463,U+649f,U+6504,U+68d6,U+68ec,U+69aa,U+69bf,U+69e7,U+6a48,U+6ae7,U+6aeb,U+6af3,U+6e5e,U+6e63,U+6f59,U+6f77,U+6fa9,U+6ffc,U+7027,U+7197,U+736a-736b,U+7380,U+7602,U+761e,U+7649,U+7667,U+76b8,U+7798,U+78fd,U+79b0,U+7a6d,U+7ab6,U+7c5c,U+7c69-7c6a,U+7cdd,U+7cf4,U+7cf6,U+7cf9,U+7d06-7d07,U+7d32,U+7d86,U+7d88,U+7dc4,U+7dd9,U+7de6,U+7df6,U+7df9,U+7e0b,U+7e11,U+7e2d,U+7e32,U+7e36,U+7e52,U+7e62,U+7e6f,U+7e8a,U+7f86,U+7f8b,U+802e,U+8079,U+8156,U+8161,U+8181,U+81da,U+81e0,U+8264,U+826b,U+8452,U+851e,U+8526,U+8546,U+8552,U+8558,U+8562,U+8577,U+859f,U+85f6,U+861e,U+863a,U+86fa,U+87e3,U+87ef,U+8806,U+8810,U+8933,U+8946,U+8949,U+895d,U+89a1,U+89af,U+89bf,U+89f6,U+8a01,U+8a75,U+8a84,U+8ad1,U+8ad7,U+8b6b,U+8b7e,U+8b9e,U+8d10,U+8db2,U+8e60,U+8e92,U+8ee4,U+8ef9-8efa,U+8f05,U+8f07,U+8f0a,U+8f61-8f62,U+91c3,U+91d9,U+9201,U+9225,U+9233,U+923d,U+9255,U+929a,U+92a9,U+92b1,U+92cf,U+92dd,U+92df,U+92e3,U+92e6,U+92f6,U+9301,U+9306-9308,U+9312,U+931f,U+9343,U+9346,U+9364,U+9369,U+9370,U+9384,U+9387,U+93bf,U+93dd,U+93f7,U+93f9,U+9412,U+9414,U+9420,U+9426,U+9428,U+944a,U+9465,U+9479,U+958c,U+95ab,U+95bd,U+95e5,U+9689,U+96c8,U+979d,U+97bd,U+97dd,U+980a,U+980f,U+9856,U+9859,U+9862,U+98eb,U+98f1,U+9931,U+9933,U+9937,U+993c,U+994a,U+995c,U+99d4,U+99d8,U+9a0d,U+9a36,U+9a42,U+9a4f,U+9b83,U+9b92,U+9b9a,U+9b9e,U+9baa,U+9bc1,U+9bdd,U+9bea,U+9c08,U+9c12,U+9c28-9c29,U+9c35,U+9c39,U+9c3e,U+9c5d,U+9c5f,U+9c67,U+9c6d,U+9c7a,U+9d1f,U+9d2f-9d30,U+9d42,U+9d53,U+9d5c,U+9d6f,U+9d87,U+9d93,U+9d98,U+9da5,U+9dbc,U+9dd9-9dda,U+9de5-9de6,U+9def,U+9e0c,U+9e7a,U+9ecc,U+9ef2,U+9ef7,U+9f09,U+9f34,U+9f4e-9f4f,U+9f54,U+9f59;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_8a2aad.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e08,U+4e18-4e19,U+4e32,U+4e38,U+4e52-4e53,U+4e73,U+4e7e,U+4e9f,U+4ead,U+4ed4,U+4ed7,U+4ed9,U+4ef0,U+4f50,U+4fcf,U+502a,U+5076,U+50ac,U+50b2,U+5112,U+5154,U+5180,U+51f0,U+51fd,U+524a,U+526a,U+52ab,U+52c9,U+52df,U+52fe,U+5306,U+5308,U+532a,U+5339,U+5349,U+5378,U+537f,U+5384,U+53c9,U+53d4,U+5410,U+5439,U+543e,U+5440,U+5496,U+54ac,U+54b1,U+54c0,U+54e8,U+54ed,U+5561,U+5564-5566,U+5580,U+558a,U+55bb,U+561b,U+5634,U+56ca,U+572d,U+573e,U+574a,U+574e,U+5751,U+5764,U+576a,U+5782-5783,U+57cb,U+57d4,U+5824,U+582a,U+5835,U+584c,U+5858,U+5885,U+5893,U+58e4,U+5948,U+594e,U+5951,U+59a8,U+59da,U+59fb,U+59ff,U+5a03,U+5a1c,U+5a46,U+5ac1,U+5acc,U+5ae9,U+5b55,U+5b5c-5b5d,U+5bb0,U+5bb4,U+5bc2,U+5bd3,U+5bf8,U+5bfa,U+5c39-5c3a,U+5c3f,U+5c48,U+5c60,U+5c6f,U+5c90,U+5d14,U+5d1b,U+5df7,U+5dfe,U+5e06,U+5e15,U+5e37,U+5e3d,U+5e62,U+5e7b,U+5e7d,U+5eb8,U+5eca,U+5ed6,U+5f0a,U+5f17-5f18,U+5f6a,U+5f6c,U+5f7c,U+5fcc-5fcd,U+6012,U+6028,U+6068,U+6094,U+609f-60a0,U+60d1,U+6101,U+6109,U+6127,U+6148,U+614e,U+6155,U+6168,U+61be,U+61c8,U+6208,U+6212,U+6247,U+626e,U+6284,U+62ab,U+62d0,U+62d8,U+62da,U+62fc,U+62fe,U+632a-632b,U+633a,U+6349,U+6367,U+6380,U+638f,U+63a9,U+63d2,U+6454,U+6492,U+649e,U+64b0,U+64c5,U+64e6,U+655e,U+6572,U+6591,U+659c,U+65a5,U+65ed,U+6602,U+660f,U+6614,U+662d,U+6664,U+6670,U+6676,U+6691,U+66a8,U+6749,U+674f,U+67af,U+67cf,U+67d4,U+67ef,U+682a,U+6843,U+6846,U+6850,U+6854,U+6876,U+68a8,U+68d2,U+68da,U+68f5,U+6905,U+6912,U+6a0a,U+6a61,U+6b3a,U+6b67,U+6b79,U+6bb7,U+6bbf,U+6bef,U+6c27-6c28,U+6c41,U+6c55,U+6c5b,U+6c5d,U+6c70,U+6c82-6c83,U+6c9b,U+6cab,U+6cb8,U+6cca,U+6ce1,U+6cf5,U+6d74,U+6d78,U+6daf,U+6dc4,U+6dcb,U+6dd1,U+6dd8,U+6deb,U+6df9,U+6e24,U+6e34,U+6e5b,U+6ea2,U+6eb6,U+6ef4,U+6f02,U+6f06,U+6f6d,U+6f84,U+7092,U+70ac,U+70b3,U+70e4,U+70ef,U+710a,U+7199,U+722c,U+723d,U+725f,U+7261,U+7272,U+72ac,U+72c2,U+72d7,U+73b2,U+7433,U+751c,U+752b,U+7538,U+754f,U+7554,U+758f,U+75b2,U+75bc,U+7624,U+7626,U+7686,U+76c6,U+76d2,U+76ef,U+76fc,U+7709,U+7720,U+775b,U+7766,U+7779,U+7784,U+77ac,U+7802,U+780d,U+7838,U+786b,U+788e,U+7897-7898,U+78a7,U+78b0,U+78b3,U+78cb,U+78f7,U+79b9,U+79bd,U+79c9,U+79e7,U+7a0d,U+7a3d,U+7a84,U+7ae3,U+7aed,U+7b52,U+7bad,U+7c92,U+7c98,U+7cca,U+7d2b,U+7f50,U+7f55,U+7fc1,U+7fc5,U+7fd4,U+7fe0,U+7ff0,U+7ffc,U+8036,U+804a,U+8086-8087,U+808c,U+809a,U+80ba,U+80c3,U+80ce,U+80f8,U+8102,U+8106,U+810a,U+8154,U+8170,U+8179,U+8180,U+818f,U+819c,U+81c2,U+81ed,U+821f,U+8236,U+8247,U+8258,U+8292,U+829d,U+8305,U+8328,U+832b,U+8389,U+838e,U+83c7,U+83ca,U+83cc,U+83f1,U+8404,U+840d,U+845b,U+846c,U+8482,U+84b8,U+84ec,U+8513,U+8521,U+857e,U+859b,U+85aa,U+85e4,U+8679,U+86c7,U+8702,U+871c,U+87ba,U+87f9,U+884d,U+886b,U+8896,U+88b1,U+8944,U+8a93,U+8c6b,U+8dcc,U+8e48,U+8eba,U+8f9c,U+8fa3,U+8fa8,U+8fb0-8fb1,U+8fc4,U+9006,U+901d,U+9022,U+903c,U+9042,U+904f,U+9063,U+90a2,U+90b1,U+90b5,U+9102,U+916c,U+9177,U+9187,U+9189,U+961c,U+964b,U+96a7,U+96c1,U+970d,U+971c,U+9738,U+978d,U+97ad,U+9b3c,U+9b41-9b42,U+9b44-9b45,U+9b4f,U+9b54,U+9e7f,U+9e9f,U+9f0e,U+9f3b;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_82438c.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4fc1,U+5000,U+5096,U+50c9,U+5114-5115,U+526e,U+5274,U+5399,U+53c4,U+55f6,U+5635,U+5638,U+565d,U+5665,U+566f,U+56c0,U+56c8,U+57b5,U+5816,U+581d,U+584f,U+58d9,U+58e2,U+5b00,U+5b19,U+5b21,U+5b2a,U+5d0d,U+5d33,U+5e43,U+5ee1,U+5ee9,U+614d,U+616a,U+6173,U+61e8,U+6261,U+6331,U+645c,U+6476,U+64fc,U+651b,U+6894,U+6898,U+6a05,U+6a34,U+6a81,U+6a89,U+6abe,U+6add-6ade,U+6ae8,U+6aec,U+6af8,U+6b0f,U+6b5f,U+6b7f,U+6ba4,U+6c0c,U+6de5,U+6f35,U+6f7f,U+6fae,U+7026,U+7032,U+7162,U+716c,U+733b,U+7341,U+743a,U+74a6,U+74d4,U+74da,U+7646,U+7658,U+7669,U+766d,U+7670,U+7725,U+7864,U+7868,U+78b8,U+78e7,U+7906,U+7a61,U+7b67,U+7bcb,U+7bf3,U+7c00,U+7c1e,U+7cf2,U+7d08,U+7d3c,U+7d3f,U+7d4e,U+7d5b,U+7d5d,U+7d83,U+7d9e,U+7da3,U+7db0,U+7db9,U+7dc7,U+7de1,U+7df1,U+7e0a,U+7e1e-7e1f,U+7e35,U+7e7e,U+7e98,U+7fb6,U+802c,U+8141,U+8278,U+8407,U+8435,U+8494,U+84e7,U+84ef,U+84f4,U+84fd,U+8541,U+8555,U+85ce,U+85ea,U+87bb,U+87f6,U+889e,U+8a0c,U+8a10,U+8a4e,U+8a58,U+8a6c,U+8a7f,U+8a86,U+8a91,U+8a9a,U+8ab6,U+8ac2,U+8acf,U+8adb,U+8ade,U+8ae1-8ae2,U+8ae4,U+8afc,U+8b04-8b05,U+8b59,U+8b92,U+8b95-8b96,U+8b9c,U+8c9b,U+8cb2-8cb3,U+8cba,U+8cfb,U+8cfe,U+8d04,U+8e55,U+8e91,U+8e95,U+8edb,U+8eeb,U+8f1c,U+8f1e,U+8f26,U+8f33,U+8f54,U+9015,U+9090,U+90df,U+90e4,U+9134,U+9136,U+91c5,U+91d3-91d5,U+91e4,U+91e7,U+91f7,U+9204,U+9227,U+9230,U+9239,U+923f,U+9248,U+9266,U+926d,U+927a,U+927f,U+9283,U+92a0,U+92a3,U+92a6,U+92aa-92ab,U+92e8,U+92ee,U+92f1,U+9319,U+931b,U+9338,U+9340,U+93a6,U+93a9,U+93c3,U+93cc-93cd,U+93e8,U+9403,U+9413,U+9419,U+943f,U+944c,U+9454,U+946d,U+9477,U+9582,U+958e,U+95b6,U+95b9,U+95bc,U+95be,U+95c3,U+95c8,U+95cb,U+95d0,U+9768,U+97c9,U+97d9,U+9807,U+980e,U+9826,U+984e,U+9853,U+9873-9874,U+98ae,U+98bc,U+98e0,U+98ed,U+9908,U+9949,U+99d1,U+99d9,U+99e2,U+9a05,U+9a43-9a44,U+9ad5,U+9b29,U+9b2e,U+9b4e,U+9b58,U+9b74,U+9b8e,U+9b90,U+9bab,U+9bc0,U+9bc7,U+9bd6-9bd7,U+9be1-9be2,U+9bff,U+9c09,U+9c13,U+9c23,U+9c33,U+9c45,U+9ce7,U+9d07,U+9d52,U+9dbb,U+9dc0,U+9dc2,U+9df3,U+9df8,U+9e15,U+9e1d,U+9efd,U+9eff,U+9f5c,U+9f66,U+9f6a,U+9f76-9f77;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_2c89c1.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+37e,U+384-38a,U+38c,U+38e-3a1,U+3a3-3ce,U+3d1-3d2,U+3d6,U+400-45f,U+462-463,U+46b,U+472-475,U+490-493,U+497,U+49a-49d,U+4a2-4a3,U+4ae-4b3,U+4b8-4bb,U+4ca,U+4d8-4d9,U+4e8-4e9,U+1f45,U+54f3,U+5515,U+5adc,U+5e58,U+5fee,U+6499,U+6516,U+6bff,U+6d5e,U+7020,U+7044,U+75b0,U+7603,U+799a,U+7e70,U+8014,U+80d9,U+8159,U+8183,U+81a3,U+81aa,U+8464,U+8811,U+89dc,U+8a92,U+8b56,U+8cb0,U+8cd5,U+8ce7,U+8e93,U+8ea6,U+8fee,U+9139,U+93aa,U+940b,U+95bf,U+986c,U+9a2d,U+9a38,U+9beb,U+9bf4,U+9c20,U+9c37,U+9d1d,U+9e88,U+9f47,U+9f60;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_bee5fd.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+50af,U+50c2,U+5108,U+5137,U+513b,U+528a,U+5504,U+559e,U+560d,U+561c,U+5660,U+5680,U+5694-5695,U+56a8,U+56b6,U+56c1,U+5707,U+5770,U+57e1,U+580a,U+584b,U+58ce,U+58e0,U+5950,U+597c,U+5aa7,U+5ad7,U+5b08,U+5b0b,U+5b76,U+5b7f,U+5cf4,U+5da0,U+5da7,U+5db4,U+5dcb,U+5ec4,U+60f2,U+60fb,U+6134,U+613e,U+6142,U+615f,U+616b,U+61cc,U+61e3,U+61fa,U+6229,U+636b,U+64a3,U+64b3,U+64bb,U+64c4,U+64ef,U+6523,U+66b1,U+66c7,U+66d6,U+689f,U+690f,U+69e8,U+6a9c,U+6aa3,U+6ad3,U+6adf,U+6aea,U+6af1,U+6b1e,U+6b9e,U+6bae,U+6c2c,U+6c33,U+6c39,U+6df6,U+6f1a,U+6fa0,U+7005,U+7028,U+7043,U+7069,U+7146,U+71dc,U+71f4,U+7296,U+72fd,U+7319,U+7370,U+7449,U+74a3,U+75d9,U+75fe,U+763b,U+765f,U+7664,U+76b0,U+77bc,U+78ad,U+78e3,U+7931,U+7a1f,U+7a4c,U+7c11,U+7c23,U+7c5f,U+7d02,U+7d13,U+7d15,U+7d31,U+7d3a,U+7d40,U+7dbe,U+7dcb,U+7dd7-7dd8,U+7df2,U+7e09,U+7e39,U+7e48,U+7e88,U+7fa5,U+8075,U+81cf,U+81d2,U+82bb,U+83a2,U+83a7,U+858c,U+8594,U+85ba,U+8604,U+861a,U+866f,U+8706,U+8716,U+875f,U+8768,U+8784,U+87c4,U+87c8,U+87ce,U+8814,U+8823,U+8831,U+8938,U+893b,U+8960,U+8964,U+8998,U+89a6,U+89ac,U+89b2,U+89f4,U+8a03,U+8a15-8a16,U+8a36,U+8a41,U+8a46,U+8a52,U+8a54,U+8a5b,U+8a61,U+8a70,U+8a85,U+8aa5,U+8acd,U+8aeb,U+8aed,U+8af6,U+8b14,U+8b16,U+8b2b,U+8b3e,U+8b4e,U+8cc5,U+8cda,U+8e4c,U+8e89,U+8e9a,U+8ea1,U+8ea5,U+8eaa,U+8ed4,U+8ef2,U+8efb,U+8f42,U+8f64,U+9106,U+9148,U+91d2,U+91f5,U+91f9,U+9200,U+9210-9211,U+922e,U+9238,U+924d,U+925e,U+9278,U+92bc,U+92c3,U+92c7,U+92e5,U+92ef,U+9315,U+9341,U+9347,U+9354,U+9358,U+9398,U+93a3,U+93a7,U+93b0,U+93b5,U+93d1,U+93dc,U+93e4,U+93f5,U+9410,U+9427,U+943a,U+9463,U+9481,U+95ac-95ad,U+95de,U+9658,U+973d,U+9742,U+9744,U+97c3,U+97de,U+981c,U+9821,U+9837,U+9865,U+9870,U+98af,U+98b6,U+98c6,U+98c8,U+98e9,U+98f4,U+9909,U+9911,U+991b,U+991e,U+993f,U+9948,U+994c,U+9962,U+9a0f,U+9a16,U+9a2e,U+9a41,U+9a64,U+9acf,U+9ad6,U+9bad,U+9bca,U+9bdb,U+9be4,U+9be7,U+9bf0,U+9bfd,U+9c0d,U+9c25,U+9c2d,U+9c31,U+9c48,U+9c52,U+9c56,U+9c78,U+9ce9,U+9cf6,U+9d06,U+9d23,U+9d61,U+9d6a,U+9d89,U+9d9a,U+9dd3,U+9df2,U+9e1a-9e1b,U+9e85,U+9ea9,U+9f5f,U+9f67,U+9f6c,U+9f95;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_9ad5e8.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e99,U+4f47,U+50e5,U+5102,U+513a,U+513c,U+5157,U+5191,U+5284,U+54e2,U+55b2,U+55c6-55c7,U+55da,U+55e9,U+5616,U+562e,U+5630,U+5653,U+56ea,U+579c,U+585a,U+5879,U+58ae,U+59cd,U+5acb,U+5af5,U+5afa,U+5b38,U+5c5c,U+5d22,U+5d87,U+5d97,U+5db8,U+5dd2,U+5dd4,U+5e40,U+5edd,U+60b5,U+611c,U+6137,U+615a,U+619a,U+61ab,U+633e,U+6384,U+6425,U+6440,U+645f,U+6473,U+649a,U+64f0,U+64f7,U+64fb,U+6506,U+6519,U+6582,U+6595,U+66c4,U+67f5,U+68e7,U+69f3,U+6a62,U+6ab3,U+6ab8,U+6abb,U+6ada-6adb,U+6b12,U+6bab,U+6c08,U+6d79,U+6e19,U+6ece,U+6ef7-6ef8,U+6f2c,U+6f41,U+6f6f,U+6f97,U+6fd8,U+6ffa,U+705d,U+70f4,U+71c9,U+71fc,U+71fe,U+7258,U+729b,U+72a2,U+7377,U+737a,U+737c,U+73a8,U+743f,U+744b,U+7489,U+74bd,U+74bf,U+74cf,U+750c,U+7515,U+760d,U+7627,U+7647,U+7662,U+766c,U+7672,U+769a,U+7787,U+792a-792c,U+798e,U+79b1,U+79c8,U+7b8b,U+7be4,U+7c0d,U+7c2b,U+7c37,U+7c6c,U+7c6e,U+7cc9,U+7d1c,U+7d33,U+7d46,U+7d73,U+7dac,U+7dde,U+7e08,U+7e10,U+7e1d,U+7e43,U+7e45,U+7e6e,U+7e93,U+7f4c,U+7f88,U+7ffa,U+811b,U+81be-81bf,U+81cd,U+82e7,U+8396,U+83f4,U+8477,U+849e,U+84c0,U+854e,U+858a,U+85f9-85fa,U+8622,U+865c,U+86fb,U+8778,U+87a2,U+880d,U+884a,U+8956,U+89b7,U+8a1b,U+8a25,U+8a6b,U+8a6d-8a6e,U+8a7c,U+8ab9,U+8ac9,U+8adc,U+8ae6,U+8af3,U+8afa,U+8b01,U+8b10,U+8b17,U+8b28,U+8b2c,U+8b33,U+8b4f,U+8cc1,U+8cd2,U+8ce1,U+8d05,U+8d0d,U+8d16-8d17,U+8e63,U+8e7a,U+8e8a,U+8efc,U+8efe,U+8f12,U+8f25,U+8f45-8f46,U+8fae,U+902c,U+9114,U+9116,U+913a,U+9183,U+91c1,U+91e9,U+91fa,U+9208,U+920d,U+9226,U+9237,U+923e,U+9249,U+9251,U+9257,U+925a,U+9262,U+926c,U+927b,U+9291,U+9293,U+9296,U+92a5,U+92ac,U+92bb,U+92cc,U+92e4,U+92f0,U+9310,U+932e,U+9333,U+9365,U+936c,U+9376,U+937a,U+93a2,U+93ac,U+93b3,U+93d7-93d8,U+93de,U+93e2,U+942b,U+942e,U+9432,U+9460,U+947e,U+9586,U+958f,U+95a1,U+95a8,U+95cc,U+95d4-95d5,U+975a,U+9766,U+9830,U+9839,U+990c,U+9912,U+9921,U+993e,U+9943,U+9957,U+995e,U+99b1,U+99b4,U+99df,U+99ed,U+9a2b,U+9a3e,U+9a40,U+9a4d,U+9a5b,U+9a65,U+9a6a,U+9aaf,U+9b22,U+9b77,U+9b81,U+9bc9,U+9be8,U+9c54,U+9c58,U+9d15,U+9d1b,U+9d26,U+9d51,U+9d60,U+9d72,U+9da9,U+9daf,U+9dd7,U+9dfa,U+9e1e,U+9f72;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_c2b336.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4f96,U+4fb6,U+4fe0,U+5098,U+50ad,U+50d5,U+510d,U+516a,U+51dc,U+522a,U+52fb,U+5331,U+5379,U+53ad,U+5436,U+555e,U+5614,U+562f,U+5687,U+56a5,U+56c2,U+56d1,U+582f,U+5862,U+589c,U+58b3,U+58fa,U+59e6,U+5a41,U+5a6d,U+5b0c,U+5be2,U+5bf5,U+5c37,U+5c4d,U+5ce9,U+5dbc,U+5de3,U+5e57,U+5eec,U+5f5e,U+5fa0,U+6065,U+60b6,U+60bd,U+60f1,U+6144,U+618a,U+6190,U+61f6,U+61fc,U+61fe,U+63c0,U+6417,U+646f,U+647b,U+6493,U+64be-64bf,U+64f1-64f2,U+650f,U+6522,U+652a,U+6583,U+65ac,U+665d,U+6688-6689,U+66e0,U+66ec,U+6727,U+68f2,U+6953,U+6968,U+6a3a,U+6ae5,U+6afb,U+6b16,U+6bad,U+6baf,U+6bb2,U+6bc6,U+6c2b,U+6cdd,U+6d36,U+6d87,U+6dea,U+6df5,U+6e26,U+6e4a,U+6f23,U+6f70,U+6f80,U+6fc1,U+6ff0,U+6ffe,U+7006,U+7009,U+700f,U+7018,U+701d,U+7055,U+7064,U+7152,U+7192,U+71be,U+71c1,U+71d9,U+71ed,U+71fb,U+720d,U+7463,U+7469,U+746f,U+7587,U+758a,U+760b,U+7613,U+7621,U+7661,U+766e,U+7671,U+7681,U+76ba,U+76de,U+775c,U+779e,U+77ef,U+786f,U+78ef,U+79aa,U+79bf,U+7a08,U+7a1c,U+7a62,U+7aaa,U+7aba,U+7ac4-7ac5,U+7ac7,U+7b4d,U+7b8f,U+7be9,U+7c3e,U+7cde,U+7d09,U+7d43,U+7d5e,U+7d62,U+7d79,U+7d81,U+7d8f,U+7d91,U+7db4,U+7db8,U+7dba-7dbb,U+7dbd,U+7ddd,U+7def,U+7e37,U+7e46,U+7e55,U+7e5a,U+7e6d,U+7e79,U+7e7d,U+7e8f,U+7f75,U+7fa8,U+7ff9,U+8073,U+807e,U+8123,U+814e,U+81a9,U+834a,U+8466,U+8525,U+852d,U+8553,U+856a,U+8588,U+863f,U+879e,U+87ec,U+87fb,U+8805,U+881f,U+883b,U+896a,U+8993,U+8a1d,U+8a23,U+8a60,U+8a63,U+8aa1,U+8aa3,U+8aa6,U+8aa8,U+8ac4,U+8ad2,U+8af1,U+8af7,U+8b0a,U+8b0e,U+8b20,U+8b41,U+8b74,U+8b8e,U+8c4e,U+8c8d,U+8c9e,U+8caf,U+8cb6,U+8cbd,U+8cca,U+8cd1,U+8cdc,U+8ce4,U+8cec,U+8d13,U+8e34,U+8ec0,U+8ed2,U+8f1f,U+8f3e,U+8f4d,U+905c-905d,U+9087,U+9132,U+919e,U+91ac,U+91d7-91d8,U+9209,U+9215,U+921e,U+9223,U+923a,U+9240,U+92a8,U+92c5,U+92f8,U+9318,U+931a,U+9320,U+9328,U+934d,U+9382,U+938a,U+93df,U+93fd,U+9433,U+9438,U+9470,U+9472,U+947c,U+947f,U+9594,U+95a5,U+9695,U+96b4,U+96b8,U+96cb,U+96db,U+9711,U+97cc,U+97dc,U+9824,U+985b,U+986b,U+9871,U+98e2,U+98ea,U+9903,U+9913,U+991a,U+9935,U+993b,U+9945,U+99ad,U+99c1,U+99d2,U+99dd,U+99f1,U+99ff,U+9a01,U+9a4a,U+9a62,U+9bd4,U+9c32,U+9c3b,U+9c49,U+9c57,U+9c77,U+9d09,U+9d3f,U+9ef4,U+9f4b,U+9f9c;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_aacef2.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e1f,U+5006,U+5009,U+5074-5075,U+5078,U+50f1,U+5109,U+5147,U+514c,U+51cd,U+51f1,U+524e,U+525d,U+5270,U+528d,U+52f3,U+52f8,U+53b2,U+53e2,U+5442,U+5451,U+5586,U+559a,U+55aa,U+5606,U+5617,U+5674,U+5875,U+588a,U+58be,U+58d8,U+58df,U+58e9,U+593e,U+599d,U+5b30,U+5c5b,U+5c62,U+5ca1,U+5d50,U+5d84,U+5dba,U+5dbd,U+5dd6,U+5e25,U+5e5f,U+5ec1-5ec2,U+5eda,U+5edf,U+5f14,U+5f4c,U+5f4e,U+5f65,U+6046,U+6085,U+6158,U+617c,U+61a4,U+61b6,U+61c7,U+61f2,U+61f8,U+6200,U+62cb,U+62dd,U+6399,U+6488,U+6490,U+64ab,U+64b2,U+64cb,U+64e0-64e1,U+64ec,U+64fe,U+6514,U+651c,U+652c,U+6558,U+6575,U+6736,U+687f,U+68d7,U+68df,U+69d3,U+6a01,U+6a1e,U+6a38,U+6ac3,U+6b04,U+6b3d,U+6bbc,U+6bc0,U+6d29,U+6dbc,U+6de9,U+6dfa,U+6e3e,U+6e6f,U+6ebc,U+6ec4,U+6ecc,U+6eec,U+6eef,U+6ef2,U+6f11,U+6f3f,U+6f51,U+6f86-6f87,U+6fb1,U+6feb,U+7015,U+701f,U+703e,U+7051,U+7058,U+7165,U+7169,U+71e6,U+721b,U+723a,U+727d,U+72a7,U+72f9,U+7336,U+7343-7345,U+7375,U+7378,U+7464,U+746a,U+74ca,U+7501,U+775e,U+77da,U+78a9,U+78da,U+797f,U+798d,U+79a6,U+7a4e,U+7aa9,U+7aaf,U+7ac8,U+7aca,U+7c60,U+7cb5,U+7d0b,U+7d17,U+7d68,U+7da2,U+7db1,U+7dbf,U+7dd2,U+7de0,U+7de3,U+7dec,U+7e1b,U+7e2b,U+7e61,U+7e69,U+7e96,U+7e9c,U+7f77,U+8070,U+8076,U+8105,U+8108,U+816b,U+8178,U+819a,U+81d8,U+81e5,U+8259,U+8266,U+8332,U+8490,U+84bc,U+84ee,U+8514,U+8523,U+8569,U+856d,U+8591,U+85a6,U+8606,U+860a-860b,U+8755,U+8766,U+8836,U+8932,U+896f,U+8972,U+89d4,U+8a1f,U+8a50,U+8a73,U+8a87,U+8a98,U+8ae7,U+8b19,U+8b39,U+8b5a,U+8b5c,U+8b5f,U+8b6f,U+8c48,U+8c54,U+8c93,U+8caa,U+8cc2-8cc4,U+8cc8,U+8ce2,U+8ce6,U+8ced,U+8cf4,U+8d1b,U+8e30,U+8e64,U+8e8b,U+8ecb,U+8ef8,U+8f13-8f14,U+8f29,U+8f3b,U+8f3f,U+8f5f,U+8faf,U+9059,U+908f,U+9112,U+919c,U+91c0,U+91d0,U+91e3,U+9214,U+9234,U+925b,U+9264,U+9298,U+929c,U+92b3,U+92c1,U+934b,U+935b,U+93c7-93c8,U+9444,U+9583,U+9592,U+9598,U+95a3,U+95a9,U+95b1,U+95bb,U+95c7,U+95d6,U+95e1,U+9670,U+96b1,U+9727,U+978f,U+97cb,U+97fb,U+9801,U+9803,U+980c,U+9811,U+9838,U+9846,U+984f,U+98b3,U+98c4,U+98fc,U+9905,U+992c,U+994b,U+9952,U+99ae,U+99b3,U+99d5,U+9a0e,U+9a37,U+9a45,U+9a55,U+9a5f,U+9ad2,U+9b27-9b28,U+9b31,U+9b91,U+9ce5,U+9cf4,U+9d28,U+9d3b,U+9d5d,U+9db4,U+9df9,U+9e79,U+9e7c,U+9f21,U+9f52,U+9f90,U+9f94;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_0ff13d.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e82,U+5016,U+502b,U+5049,U+5091,U+50b5,U+50b7,U+50be,U+50d1,U+50de,U+5100-5101,U+511f,U+5132,U+518a,U+525b,U+5291,U+52c1,U+52f5,U+5433,U+5553,U+55ac,U+55ce,U+570f,U+5713,U+584a,U+58c7,U+58d3,U+58de,U+58ef,U+58fd,U+5920,U+5922,U+5967,U+596a,U+596e,U+5a1b,U+5a66,U+5abd,U+5b43,U+5b6b,U+5bae,U+5bec,U+5c0b,U+5cef,U+5cf6,U+5cfd,U+5d11,U+5d17,U+5e33,U+5e63,U+5e6b,U+5eab,U+5ec8,U+5ee2,U+5ef3,U+5f48,U+5f7f,U+5f91,U+5fb9,U+60e1,U+6163,U+616e,U+6176,U+617e,U+6182,U+6191,U+61b2,U+61f7,U+6232,U+6368,U+6372,U+6383,U+639b,U+63da,U+63f9,U+6416,U+6436,U+64a5,U+64c1,U+64c7,U+64fa,U+651d,U+6524,U+6557,U+65c2,U+6649,U+66a2,U+66ab,U+66c9,U+68c4,U+68ca,U+694a,U+69cd,U+69ea,U+6a11,U+6a13,U+6a4b,U+6a6b,U+6a94,U+6b61,U+6b78,U+6b98,U+6bba,U+6c59,U+6dda,U+6de8,U+6e09,U+6e67,U+6e9d,U+6eab,U+6ec5,U+6efe,U+6f01,U+6f22,U+6f32,U+6f38,U+6f54,U+6f5b,U+6f64,U+6fc3,U+6fe4,U+6ff1,U+700b,U+7063,U+707d,U+70cf,U+7149,U+7159,U+71c8,U+71d2,U+7210,U+7240,U+7246,U+7274,U+755d,U+7562,U+7570,U+75e0,U+7642,U+7652,U+7665,U+76c3,U+76dc,U+76e4,U+76e7,U+7843,U+78bc,U+7919,U+791f,U+7955,U+79ae,U+7a40,U+7aae,U+7b46,U+7bc9,U+7c21,U+7c43,U+7c4c,U+7c64,U+7cfe,U+7d10,U+7d14,U+7d19,U+7d1b,U+7d21,U+7d2e,U+7d30,U+7d39,U+7d55,U+7d61,U+7d72,U+7da0,U+7db5,U+7de9,U+7df4,U+7e2e,U+7e31,U+7e3e,U+7e5e,U+7e6a,U+7e73,U+7e8d,U+7f70,U+7feb,U+8056,U+8085,U+812b,U+8139,U+8166,U+8173,U+81a0,U+81bd,U+81c9,U+820a,U+8271,U+838a,U+840a,U+8449,U+84cb,U+85a9,U+85cd,U+865b,U+8667,U+87f2,U+885d,U+89ba,U+89bd,U+89f8,U+8a02,U+8a34,U+8a3a,U+8a5e,U+8a62,U+8a69,U+8a95,U+8a9e,U+8aa0,U+8aa4,U+8ab0,U+8ab2,U+8abc,U+8aee,U+8af8,U+8afe,U+8b00,U+8b02,U+8b1d,U+8b7d,U+8c6c,U+8c9d,U+8ca2,U+8ca9,U+8cab,U+8cb4,U+8cbc,U+8cc0,U+8cd3,U+8cde,U+8ce0,U+8cfa,U+8d08,U+8d0a,U+8d0f,U+8d95,U+8d99,U+8da8,U+8de1,U+8e10,U+8e8d,U+8ecc,U+8edf,U+8f09,U+8f1b,U+8f1d,U+8f2a,U+8f2f,U+8f44,U+8f4e,U+8fad,U+9055,U+905e,U+9072,U+9077,U+907a,U+907c,U+9081,U+90f5,U+9127,U+912d,U+9130,U+91cb,U+91e6,U+9285,U+92d2,U+92ea,U+9304,U+9326,U+932b,U+932f,U+9375,U+937e,U+9396,U+93e1,U+9451,U+947d,U+9589,U+95c6,U+95ca,U+95e2,U+965d,U+9663,U+9678,U+96d6,U+96dc,U+96de,U+9748,U+975c,U+97a6,U+97d3,U+9802,U+9806,U+9812-9813,U+9817,U+983b,U+98ef,U+98f2,U+98fd-98fe,U+99d0,U+99db,U+9a19,U+9a30,U+9a5a,U+9b06,U+9b5a,U+9b6f,U+9bae,U+9cf3,U+9d6c,U+9e7d,U+9e97,U+9ea5,U+9f15,U+9f4a,U+9f61;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_00397b.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e26,U+4e9e,U+4f15,U+4f48,U+4f54,U+4f86,U+4fb7,U+4fec,U+500b,U+5011,U+5099,U+50a2,U+50b3,U+50c5,U+50f9,U+5104,U+512a,U+5152,U+5167,U+5169,U+5225,U+5247,U+524b,U+5275,U+5283,U+5287,U+5289,U+52d5,U+52d9,U+52dd-52de,U+52e2,U+532f,U+5340,U+5354,U+537b,U+537d,U+53c3,U+54e1,U+5538,U+554f,U+55ab,U+55ae,U+5678,U+56b4,U+570b,U+570d,U+5712,U+5716,U+5718,U+57f7,U+5805,U+5831,U+5834,U+59b3,U+5b78,U+5be6-5be7,U+5be9,U+5beb,U+5bf6,U+5c07-5c08,U+5c0d-5c0e,U+5c46,U+5c64,U+5c6c,U+5e2b,U+5e36,U+5e79,U+5e7e,U+5ee3,U+5f35,U+5f37,U+5f9e,U+5fa9,U+611b,U+614b,U+61c9,U+6230,U+6236,U+63db,U+63ee,U+640d,U+64ca,U+64d4,U+64da,U+64f4,U+654e,U+6578,U+65b7,U+65e3,U+6642,U+66f8,U+6703,U+6771,U+67fb,U+689d,U+696d,U+6975,U+69ae,U+69cb,U+6a02,U+6a19,U+6a23,U+6a39,U+6a5f,U+6aa2,U+6b0a,U+6b50,U+6b72,U+6b77,U+6c23,U+6c7a,U+6c92,U+6cc1,U+6e1b,U+6e2c,U+6e96,U+6eff,U+6fa4,U+6fdf,U+7121,U+71b1,U+71df,U+722d,U+7232,U+723e,U+7260,U+72c0,U+734e,U+7368,U+7372,U+737b,U+73fe,U+74b0,U+7522,U+756b,U+7576,U+767c,U+76e1,U+76e3,U+771e,U+774f,U+77ad,U+78ba,U+790e,U+7926,U+7a05,U+7a2e,U+7a31,U+7a4d,U+7a69,U+7af6,U+7bc0,U+7bc4,U+7ce7,U+7d00,U+7d04-7d05,U+7d0d,U+7d1a,U+7d42,U+7d44,U+7d50,U+7d66,U+7d71,U+7d93,U+7d9c,U+7dab,U+7dad,U+7db2,U+7dca,U+7de8,U+7dfb,U+7e23,U+7e3d,U+7e54,U+7e7c,U+7e8c,U+7e94,U+7f85,U+7fa3,U+7fa9,U+7fd2,U+805e,U+806f,U+8072,U+8077,U+807d,U+8129,U+81e8,U+8207-8209,U+83d3,U+83ef,U+842c,U+84c6,U+85dd,U+85e5,U+8607,U+862d,U+8655,U+865f,U+8846,U+8853,U+8855,U+885b,U+88cf,U+88dc-88dd,U+88fd,U+8988,U+898b,U+898f,U+8996,U+89aa,U+89c0,U+8a08,U+8a0a,U+8a0e,U+8a13,U+8a17-8a18,U+8a2a,U+8a2d,U+8a31,U+8a3b,U+8a55,U+8a66,U+8a71-8a72,U+8a8c-8a8d,U+8aaa,U+8abf,U+8ac7,U+8acb,U+8ad6,U+8b1b,U+8b49,U+8b58,U+8b70,U+8b77,U+8b80,U+8b8a,U+8b93,U+8c50,U+8ca0-8ca1,U+8ca7-8ca8,U+8cac,U+8cb7-8cb8,U+8cbb,U+8cbf,U+8cc7,U+8ce3,U+8cea,U+8cfc-8cfd,U+8eca,U+8ecd,U+8f03,U+8f15,U+8f38,U+8f49,U+8fa6,U+8fb2,U+8ff4,U+9019,U+9023,U+9032,U+904a-904b,U+904e,U+9054,U+9060,U+9069,U+9078,U+9084,U+908a,U+9109,U+91ab,U+91dd,U+9245,U+9280,U+92b7,U+92fc,U+9322,U+9336,U+93ae,U+9435,U+9577,U+9580,U+958b,U+9593,U+95a4,U+95dc,U+9673,U+967d,U+968a,U+968e,U+969b,U+96a8,U+96aa,U+96bb,U+96d9,U+96e2-96e3,U+96f2,U+96fb,U+97c6,U+97ff,U+9805,U+9808,U+9810,U+9818,U+982d,U+984c-984d,U+9858,U+985e,U+9867,U+986f,U+98a8,U+98db,U+990a,U+9918,U+9928,U+99ac,U+9a57,U+9ad4,U+9b25,U+9ebc,U+9ec3,U+9ed2,U+9ede,U+9ee8,U+9f63,U+9f8d;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_e7daea.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4f25,U+4f27,U+4f65,U+4fa9-4faa,U+4fe3,U+4fe6,U+50a7,U+51eb,U+51fc,U+523d,U+5240,U+5250,U+538d,U+53c1,U+5452-5453,U+5456,U+549d,U+54d3-54d4,U+54dd,U+5527,U+556d-556e,U+55eb,U+55f3,U+5624,U+568f,U+56af,U+5739,U+575c,U+57b2,U+57b4,U+57da,U+57ef,U+5942,U+59ab,U+5aaa,U+5ad2,U+5ad4,U+5af1,U+5c98,U+5d03,U+5d5b,U+5d5d,U+5def,U+5e0f,U+5e91,U+5eea,U+5ffe,U+6004,U+6079,U+60ab,U+60ad,U+6120,U+61b7,U+6217,U+622c,U+6266,U+629f,U+6332,U+63b8,U+63bc,U+64b8,U+64ba,U+66a7,U+679e,U+67a7,U+67b3,U+67fd,U+6800,U+680c,U+6860,U+68c2,U+691f,U+6924,U+6987,U+6989,U+6a2f,U+6a79,U+6a7c,U+6aa9,U+6b24,U+6b81,U+6b87,U+6b92-6b93,U+6bc2,U+6c07,U+6c29,U+6ca9,U+6cf6,U+6d4d,U+6da0,U+6e0c,U+6e86,U+6edf,U+6f4b,U+6f74,U+6fd1,U+7080,U+7145,U+7198,U+72b8,U+72f2,U+732c,U+737e,U+73d0,U+7477,U+748e,U+74d2,U+7596,U+75a0,U+75c8,U+75e8,U+763f,U+765e,U+7726,U+7817,U+781c,U+7856,U+7877,U+789b,U+7a51,U+7b15,U+7b5a,U+7ba6-7ba7,U+7baa,U+7c74,U+7c9c-7c9d,U+7ccd,U+7e9f,U+7ea8,U+7ec0,U+7ecb,U+7ed0,U+7ed4,U+7ed7,U+7ee1,U+7ee6,U+7ee8,U+7ef1,U+7efa-7efb,U+7efe,U+7f01,U+7f0d,U+7f11,U+7f17,U+7f1b,U+7f1f,U+7f22,U+7f26,U+7f31,U+7f35,U+8027,U+80fc,U+816d,U+81bb,U+8279,U+82c1,U+82cc,U+82d8,U+8315,U+835c,U+8366,U+8368-8369,U+839c,U+83b3-83b4,U+83b8,U+83bc,U+84d1,U+84e0,U+85ae,U+85c1,U+85d3,U+86cf,U+86d4,U+86f3-86f4,U+8748,U+877c,U+886e,U+88e2-88e3,U+89c7,U+89de,U+8a8a,U+8ba6-8ba7,U+8bb5,U+8bcc,U+8bce,U+8bd3,U+8bd6,U+8bdf,U+8be8,U+8bee,U+8bf3,U+8bf9,U+8c00,U+8c04,U+8c07,U+8c14,U+8c16-8c17,U+8c1d,U+8c20-8c21,U+8c25,U+8c2f-8c30,U+8c36,U+8d30,U+8d36,U+8d3d,U+8d40,U+8d59,U+8d5c,U+8dde,U+8df8,U+8e2f,U+8e51,U+8e70,U+8f6b,U+8f6d,U+8f73,U+8f78,U+8f87,U+8f8b,U+8f8e-8f8f,U+8f94,U+8f9a,U+8ff3,U+9026,U+90ba,U+90c4,U+90cf-90d0,U+917d,U+933e,U+9486-9487,U+948c-948d,U+948f-9490,U+94a3,U+94aa-94ab,U+94ad,U+94af,U+94b2,U+94ba,U+94bd,U+94bf,U+94c8,U+94ca,U+94cc-94cd,U+94d1-94d2,U+94d5-94d6,U+94d9,U+94df,U+94e9-94ea,U+94ef-94f0,U+94f3,U+94f7,U+94fc-94fd,U+9507,U+9515,U+951b,U+951d,U+9531,U+9546,U+954a,U+954f,U+9554,U+9558-9559,U+955d-955e,U+9566-9567,U+956b,U+9571-9572,U+95e9,U+95f1,U+95f3,U+9604,U+9608-960b,U+960f,U+9612,U+9615,U+9617,U+9765,U+97af,U+97ea,U+9878,U+987c,U+9880,U+988f,U+989a-989b,U+989e,U+98a2,U+98a7,U+98d1,U+98d5,U+9963,U+996c,U+9980,U+998a,U+9991,U+9994-9995,U+9a78,U+9a7d,U+9a88,U+9a93,U+9a9b,U+9aa0,U+9aa2,U+9ac5,U+9acc,U+9b47,U+9b49,U+9c82,U+9c87,U+9c90-9c91,U+9c9b,U+9c9e,U+9ca5,U+9ca7,U+9ca9,U+9cad-9cae,U+9cb1,U+9cb5,U+9cc3,U+9cc7,U+9cca,U+9cce-9ccf,U+9cd3,U+9cd5,U+9cd9,U+9e28-9e29,U+9e2c,U+9e37,U+9e41-9e42,U+9e46-9e47,U+9e58,U+9e5a,U+9e5e,U+9e6c,U+9efe,U+9f0b,U+9f39,U+9f83,U+9f85,U+9f87-9f8a,U+9f8c;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_269445.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e2c,U+4f1b,U+507e,U+5181,U+522d,U+523f,U+52a2,U+5326,U+537a,U+53a3,U+5423,U+5459,U+54b4,U+54d5,U+54d9,U+54dc,U+5523,U+55be,U+56df,U+5786,U+57d8,U+5941,U+5a08,U+5b37,U+5c66,U+5cbd,U+5d74,U+5e31,U+5e3b,U+5e42,U+5e5e,U+5f11,U+5f2a,U+6003,U+603c,U+610d,U+6126,U+61d4,U+6206,U+620b,U+6322,U+63b4,U+63f8,U+6445,U+6484,U+6769,U+67a8,U+680a,U+684a,U+6861,U+6864,U+6920,U+69e0,U+6a65,U+6bf5,U+6cf7,U+6cfa,U+6d48,U+6e16,U+6ed7,U+6ee0,U+6f24,U+6f46,U+709d,U+7173,U+717a,U+72ad,U+72ef,U+7303,U+7321,U+75ac,U+75d6,U+7605,U+7617,U+76b2,U+770d,U+7857,U+78b9,U+7962,U+7a06,U+7aad,U+7b3e,U+7ba8,U+7c16,U+7cc1,U+7cc7,U+7d77,U+7ea1,U+7ea5,U+7ea9,U+7ec1,U+7ee0,U+7ef2,U+7f02,U+7f07,U+7f0b-7f0c,U+7f0f,U+7f12,U+7f21,U+7f23,U+7f27,U+7f2f,U+7f32-7f33,U+7f74,U+8022,U+804d,U+80b7,U+80e8,U+80ea,U+8114,U+8136,U+817d,U+8223,U+823b,U+8288,U+82c8,U+8311,U+835b,U+836c-836e,U+83b6,U+8411,U+8487,U+8489,U+848c,U+84e3,U+8539,U+866e,U+867f,U+86f1-86f2,U+877e,U+88e5,U+89cb-89cc,U+89cf,U+89ef,U+8ba0,U+8bd4,U+8bdc,U+8bf6,U+8bfc,U+8c02,U+8c18,U+8c2b,U+8c2e,U+8c33,U+8c35,U+8d33,U+8d46-8d47,U+8d4d,U+8d55,U+8db1,U+8dd6,U+8e2c,U+8e9c,U+8f75,U+8f77,U+8f79-8f7a,U+8f7e,U+8f81-8f82,U+917e,U+948b,U+9494,U+94ac,U+94b6-94b8,U+94d7-94d8,U+94de,U+94e5,U+94eb,U+94f4,U+94f9,U+950a,U+950d-950e,U+9513-9514,U+9516,U+9518,U+951e,U+9529-952c,U+9538,U+953c,U+953e-953f,U+9544-9545,U+954e,U+9561,U+9564-9565,U+9568-956a,U+956c,U+95f6,U+95fc,U+9603,U+960c-960d,U+9667,U+96e0,U+9792,U+97b2,U+97b4,U+9883,U+989f,U+98a1,U+98a5,U+98e7,U+990d,U+9967,U+9969,U+996b,U+9987,U+9993,U+9a75,U+9a7a,U+9a80,U+9a92,U+9a96,U+9a98,U+9a9f,U+9aa3,U+9c86,U+9c8b,U+9c8e,U+9c92,U+9c94-9c95,U+9c9a,U+9ca0-9ca1,U+9ca3,U+9ca6,U+9cb0,U+9cb4,U+9cba,U+9cbc-9cbd,U+9cc6,U+9ccb,U+9cd0,U+9cd4,U+9cd8,U+9ce2,U+9e2b,U+9e31-9e32,U+9e36,U+9e38-9e3a,U+9e48,U+9e4b,U+9e4e,U+9e55,U+9e5b,U+9e63,U+9e68-9e6a,U+9e71,U+9e7e,U+9ebd,U+9ec9,U+9ee9-9eea,U+9f0d,U+9f44,U+9f51,U+9f80,U+9f86;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_36ad1b.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4eb5,U+4f2b,U+4fac,U+4fea,U+506c,U+507b,U+50a5,U+50a9,U+5156,U+51a2,U+520d,U+52d0,U+53a9,U+53ae,U+53fd,U+5457,U+545b,U+5494,U+5499,U+549b,U+54a4,U+54d2,U+551b,U+5520,U+5522,U+556c,U+55bd,U+55ec,U+565c,U+567c,U+56f5,U+5785,U+579b,U+57a7,U+57a9,U+57ad,U+57d9,U+57dd,U+5811,U+59a9-59aa,U+59f9,U+5a06,U+5a32,U+5a75-5a76,U+5b6a,U+5b73,U+5c49,U+5c99,U+5cbf,U+5cc4,U+5ce4-5ce5,U+5d02,U+5d58,U+5fcf,U+6002,U+6005-6006,U+603f,U+6078,U+607b,U+607d,U+607f,U+60af,U+60ec-60ee,U+61d1,U+626a,U+62a1,U+631b,U+631e,U+6376,U+637b,U+63b3,U+63ff,U+6448,U+64b5,U+64b7,U+64de,U+6619,U+6635,U+6654,U+67a5,U+67ad,U+680e,U+683e,U+6867,U+6901,U+692d,U+6988,U+69df,U+6b9a,U+6c32,U+6ca3-6ca4,U+6d43,U+6d52,U+6d54,U+6d9e,U+6e11,U+6ee2,U+704f,U+7096,U+70c3,U+70e9,U+70ec,U+7116,U+7118,U+724d,U+7266,U+728a,U+72c8,U+72cd,U+72de,U+72f0,U+736d,U+7391,U+73c9,U+73cf,U+740f,U+74ee-74ef,U+7572,U+75b1,U+75b4,U+75c9,U+7618,U+762a,U+7663,U+766f,U+7691,U+7751,U+77fe,U+7800,U+783b,U+783e,U+789c,U+7980,U+7a23,U+7b3a,U+7ba9,U+7bab,U+7bd1,U+7bd3,U+7c41,U+7c7c,U+7cbd,U+7ea3,U+7eb0,U+7ebe,U+7ec2,U+7ec9,U+7ecc,U+7edb,U+7eeb,U+7eef,U+7ef6,U+7f03-7f04,U+7f08,U+7f19,U+7f1c,U+7f25,U+7f2c,U+7f30,U+7f42,U+7f9f,U+7ff1,U+8069,U+80ae,U+80c4,U+810d,U+8113,U+817c,U+8191,U+8297,U+82cb,U+830f,U+8314,U+835a,U+835e,U+8360,U+8364-8365,U+836a,U+8385,U+8424,U+84df,U+84e5,U+8537,U+853a,U+8572,U+8616,U+864f,U+866c,U+8671,U+869d,U+86ac,U+86ca,U+86ce,U+86f0,U+8715,U+8717,U+87a8,U+8845,U+8884-8885,U+88c6,U+891b,U+8934,U+8941,U+89ca,U+89ce,U+89d0-89d1,U+8ba3,U+8baa-8bab,U+8bb9,U+8bc2-8bc3,U+8bc5,U+8bcb,U+8bcf,U+8bd2,U+8bd8,U+8bdb,U+8be0-8be1,U+8be4,U+8be9,U+8bf0,U+8c0c,U+8c0f,U+8c11-8c12,U+8c15,U+8c1f,U+8c27,U+8c29-8c2a,U+8c32,U+8d32,U+8d45,U+8d49,U+8d53,U+8d58,U+8d5d,U+8db8,U+8dc4,U+8df7,U+8df9,U+8e0c,U+8e7f,U+8e8f,U+8f71-8f72,U+8f7c,U+8f95,U+8f98,U+8fab,U+909d,U+90d3,U+90e6-90e7,U+92ae,U+9485,U+948e,U+9492,U+9495,U+9497,U+949a,U+949c-949d,U+94a1,U+94a4,U+94b4-94b5,U+94b9,U+94bc,U+94c2,U+94c4,U+94c6,U+94c9,U+94cb,U+94db,U+94e0-94e1,U+94e3-94e4,U+94e7,U+94f1,U+9502-9503,U+9506,U+9509,U+950f,U+9511-9512,U+9517,U+951f,U+9528,U+9534,U+9536-9537,U+9542,U+9549,U+9552-9553,U+9556-9557,U+9562-9563,U+956f-9570,U+9573,U+95eb,U+95f0,U+95fe,U+9602,U+9606,U+9611,U+9616,U+9619-961a,U+9649,U+96f3,U+9701,U+972d,U+9753,U+9791,U+97eb,U+9889-988a,U+988c,U+9893-9894,U+98a6,U+98d2-98d3,U+98d9-98da,U+98e8,U+9968,U+996f,U+9974,U+9977,U+997d,U+9984,U+998b,U+9990,U+9a6e,U+9a77,U+9a81,U+9a87,U+9a8a,U+9a90,U+9a9c-9a9e,U+9aa1,U+9aa7,U+9acb,U+9c85,U+9c88,U+9ca2,U+9ca8,U+9cab,U+9cb2-9cb3,U+9cb6-9cb7,U+9cc5,U+9ccd,U+9cdc-9cdd,U+9cdf,U+9e20,U+9e22,U+9e2a,U+9e2f,U+9e33,U+9e3e,U+9e44,U+9e49,U+9e4c,U+9e51,U+9e57,U+9e5c,U+9e66-9e67,U+9e6b,U+9e6d,U+9e73,U+9eb8,U+9f8b,U+9f9b;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_a04e21.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e98,U+4ec7,U+4ed1,U+4f1e,U+4f63,U+4fa0,U+4fa3,U+4fa5,U+4fe8,U+50bb,U+50f5,U+51c4,U+51db,U+51ff,U+5220,U+5300,U+532e,U+5364,U+538c,U+5413,U+5450,U+5455,U+545c,U+548f,U+54bd,U+54d1,U+54d7,U+54df,U+5507,U+5567,U+5578,U+5618,U+56a3,U+56f1,U+5742,U+575e-5760,U+5815,U+5978,U+59d7,U+5a04-5a05,U+5a07,U+5a34,U+5ba0,U+5bdd,U+5c27,U+5c34,U+5c7f,U+5c96,U+5ce6,U+5ce8,U+5dc5,U+5e18,U+5e27,U+5e3c,U+5eb5,U+5f26,U+5f5d,U+5f95,U+601c,U+6064,U+607a,U+60e7,U+60eb,U+6123,U+6151,U+61d2,U+62a0,U+62e2-62e3,U+62e7,U+631a,U+631d,U+631f-6320,U+6342,U+6346,U+6361,U+6363,U+63b7,U+63ba,U+6400-6402,U+6405,U+6512,U+655b,U+658b,U+6593,U+65a9,U+65f7,U+663c,U+6652,U+6655-6656,U+672d,U+67ab,U+67e0,U+6805,U+6808-6809,U+6816-6817,U+6862,U+6866,U+6868,U+68f1,U+6984,U+69db,U+6a31,U+6a71,U+6a90,U+6b7c,U+6ba1,U+6bb4,U+6bd9,U+6be1,U+6c22,U+6c79,U+6c93,U+6ca5-6ca6,U+6cbe,U+6cde,U+6cf8,U+6cfb,U+6cfe,U+6d3c,U+6d4a,U+6d4f,U+6d9f,U+6da1,U+6da3,U+6da7,U+6da9,U+6e0d-6e0e,U+6e83,U+6e85,U+6eaf,U+6ee4,U+6ee6,U+6f13,U+6f4d,U+709c,U+70bd,U+70c1,U+70db,U+70e8,U+70eb,U+718f,U+72b7,U+72f8,U+7315,U+73ae,U+73ba,U+73d1,U+73f2,U+7405,U+7410,U+7487,U+7574,U+759f,U+75a1,U+75ae-75af,U+75d2,U+75ea-75eb,U+75f4,U+762b,U+763e,U+766b,U+7682,U+76b1,U+76cf,U+772f,U+7741,U+7792,U+77e9,U+77eb,U+77f6,U+781a,U+783a,U+796f,U+7977,U+7985,U+79c3,U+79c6,U+79fd,U+7a8d,U+7a9c,U+7aa5-7aa6,U+7ad6,U+7b03,U+7b5b,U+7b5d,U+7bf1,U+7caa,U+7cd5,U+7eab-7ead,U+7eb6,U+7ec5,U+7eca,U+7ece,U+7ed1,U+7eda,U+7ede,U+7ee2,U+7ee5,U+7eee,U+7ef0,U+7ef7,U+7efd,U+7f00,U+7f09,U+7f0e,U+7f15,U+7f20,U+7f24,U+7f28,U+7f2a-7f2b,U+7f2d-7f2e,U+7f81,U+7fa1,U+7fd8,U+8038,U+803b,U+804b,U+80b4,U+80be,U+80e7,U+80eb,U+8110,U+814c,U+817b,U+82b8,U+82c7,U+82ce,U+82d4,U+830e,U+8327,U+835f,U+8367,U+836b,U+83b9-83ba,U+841d,U+8426,U+8471,U+84e6,U+8511,U+853c,U+8681-8682,U+86ee,U+8721,U+8747,U+8749,U+874e,U+889c,U+89c5,U+8ba5,U+8bb3-8bb4,U+8bb6-8bb7,U+8bbd,U+8bc0,U+8bd9,U+8be3,U+8be7,U+8beb-8bec,U+8bf2,U+8bf5,U+8bfd,U+8bff,U+8c06,U+8c0d-8c0e,U+8c19-8c1c,U+8c23-8c24,U+8c2c,U+8c34,U+8d1e,U+8d26,U+8d31,U+8d3b-8d3c,U+8d48,U+8d4a,U+8d4e,U+8d50,U+8d61,U+8e0a,U+8e52,U+8eaf,U+8f76,U+8f84,U+8f8a,U+8f8d,U+8f97,U+8f99,U+8fe9,U+8ff8,U+90ac,U+90f8,U+915d,U+9171,U+9489-948a,U+9499,U+949b,U+94a0,U+94a5,U+94a8,U+94ae,U+94b0,U+94b3,U+94be,U+94c0,U+94ce,U+94d0,U+94e2,U+94e8,U+94ec,U+94ee,U+94f2,U+94f5,U+94ff,U+9504,U+9508,U+950c,U+951a,U+9522,U+9524-9525,U+952d,U+952f-9530,U+9532,U+9535,U+9539,U+9540-9541,U+954c-954d,U+9550-9551,U+955b,U+956d,U+9576,U+95f5,U+95f7,U+95fa,U+9600,U+9647,U+9668,U+96b6,U+96bd,U+96cf,U+97e7,U+97ec,U+9885,U+988d,U+9890,U+98a0,U+98a4,U+996a,U+9975,U+997a,U+997f,U+9981,U+9985,U+998d,U+998f,U+9992,U+9a6d,U+9a6f,U+9a73-9a74,U+9a79,U+9a7c,U+9a7f,U+9a85-9a86,U+9a8b,U+9a8f,U+9aa5,U+9b13,U+9c7f,U+9c9f,U+9ca4,U+9cb8,U+9cbb,U+9cc4,U+9ccc,U+9cd6-9cd7,U+9cde,U+9e25-9e26,U+9e35,U+9e3d,U+9e43,U+9e4a,U+9f9f;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_b1143d.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e11,U+4e1b,U+4e22,U+4e27,U+4ec6,U+4ed3,U+4eff,U+4f26,U+4f2a,U+4f5b,U+4fa6-4fa8,U+4fca,U+4fde,U+4fe9,U+4fed,U+5077,U+5151,U+5179,U+517d,U+5188,U+51af,U+51bb,U+51c0,U+51c9,U+51cc,U+51d1,U+51e4,U+51ed,U+51ef,U+51f6,U+522e,U+5239,U+5251,U+5265,U+5269,U+529d,U+52b2,U+52cb,U+535c,U+5362,U+5367,U+5389,U+5395,U+5398,U+53a2,U+53a8,U+53d9,U+53e0,U+53f9,U+540a,U+5415,U+541e,U+5446,U+54a8,U+54b8,U+54c4,U+54f2,U+5524,U+5582,U+55b7,U+5631,U+566a,U+5706,U+5708,U+5723,U+575b,U+575d,U+5784,U+5792,U+57a6,U+57ab,U+5899,U+58f3,U+58f6,U+5938-5939,U+5986,U+5988,U+59dc,U+5a18,U+5a31,U+5a74,U+5baa-5bab,U+5bff,U+5c18,U+5c1d,U+5c38,U+5c4f,U+5c61,U+5c82,U+5c9a,U+5ca9,U+5cad,U+5cb3,U+5d2d,U+5de2,U+5de9,U+5e05,U+5e1c,U+5e78,U+5e90,U+5e99,U+5e9e,U+5f03,U+5f25,U+5f2f,U+5f39,U+5f66,U+5fa1,U+5fc6,U+5fe7,U+604b,U+6052,U+6073,U+6076,U+607c,U+60a6,U+60ac,U+60e8-60e9,U+60ef,U+6108,U+6124,U+621a,U+6251,U+6263,U+626b,U+6270,U+629a-629b,U+62ac,U+62dc,U+62df,U+62e6,U+62e8,U+6321,U+6323-6324,U+6328,U+633d,U+635e,U+63fd,U+641c,U+643a,U+6447,U+644a,U+6491,U+654c,U+65cb,U+6606,U+664b,U+6682,U+6697,U+6734-6735,U+6740,U+6746,U+6760,U+67a2-67a3,U+67aa,U+67dc,U+680b,U+680f,U+6869,U+68a6,U+68cb,U+6982,U+6a2a,U+6b32,U+6bc1,U+6c64,U+6c9f,U+6ca7,U+6caa,U+6cc4,U+6cea,U+6cfc,U+6d12,U+6d45-6d47,U+6d51,U+6d53,U+6d8c,U+6d9b,U+6d9d,U+6da4,U+6dc0,U+6e0a,U+6e14,U+6e17,U+6e7f,U+6e89,U+6eda,U+6ede,U+6ee5,U+6ee8-6ee9,U+6f47,U+6f9c,U+6fd2,U+706d,U+7076,U+707f,U+7089,U+70ae,U+70bc,U+70c2,U+70e6-70e7,U+7115,U+7237,U+7275,U+727a,U+72b9,U+72ed-72ee,U+72f1,U+730e,U+732b,U+739b,U+73a9,U+743c,U+7476,U+74f6,U+7545,U+75c7,U+76d7,U+7750,U+77a9,U+7801,U+7816,U+7845,U+7855,U+788d,U+78b1,U+7978,U+7984,U+7a83,U+7a91,U+7a9d,U+7b0b,U+7b3c,U+7b4b,U+7bee,U+7ca4,U+7cd6,U+7ea0,U+7ea4,U+7eb1-7eb2,U+7eb5,U+7eb9,U+7ebd,U+7ed2,U+7ed8,U+7ee3,U+7eea,U+7ef3,U+7ef5,U+7ef8,U+7f05-7f06,U+7f14,U+7f18,U+7f1a,U+7f1d,U+7f34,U+7f62,U+8042,U+806a,U+80a0,U+80a4,U+80bf-80c1,U+80f6,U+8109,U+810f,U+8138,U+814a,U+817e,U+8206,U+820d,U+8230-8231,U+8273,U+829c,U+82a6,U+82cd,U+82f9,U+8346,U+8350,U+8361,U+83b1-83b2,U+8427,U+848b,U+84dd,U+8574,U+865a,U+866b,U+867e,U+8680,U+8695,U+8854,U+886c,U+88ad,U+88e4,U+89e6,U+8bbc,U+8bc8,U+8bca,U+8bd1,U+8bde,U+8be6,U+8bf1,U+8bf8,U+8bfa,U+8c05,U+8c0a,U+8c10,U+8c13,U+8c26,U+8c28,U+8c2d,U+8c31,U+8c37,U+8d1d,U+8d24,U+8d29-8d2a,U+8d2c,U+8d2e,U+8d3e-8d3f,U+8d41-8d43,U+8d4b-8d4c,U+8d4f,U+8d54,U+8d56,U+8d5a,U+8d60,U+8d62-8d63,U+8dfb,U+8e2a,U+8f67,U+8f69,U+8f70,U+8f74,U+8f7f,U+8f85,U+8f88,U+8f90-8f91,U+8f96,U+8f9e-8f9f,U+8fa9,U+8fc1,U+8fc8,U+8fdf,U+900a,U+9012,U+903b,U+903e,U+9065,U+90b9,U+90bb,U+90c1,U+9178,U+917f,U+91ca,U+9493,U+949e,U+94a6-94a7,U+94a9,U+94bb,U+94c3,U+94c5,U+94dc-94dd,U+94ed,U+94f8,U+94fa,U+94fe,U+9501,U+9505,U+950b,U+9510,U+9521,U+9523,U+953b,U+955c,U+95ea,U+95ef,U+95f2,U+95f8-95f9,U+95fd,U+9601,U+9605,U+960e,U+9610,U+9634,U+9655,U+9690,U+96c7,U+96fe,U+9709,U+97e6,U+97f5,U+9875-9877,U+987d,U+9882,U+9887-9888,U+9891,U+9896-9897,U+989c,U+98d8,U+9965,U+9971-9972,U+9976,U+997c,U+9988,U+9a70-9a71,U+9a76,U+9a7e,U+9a82,U+9a84,U+9a91,U+9a97,U+9a9a,U+9aa4,U+9c8d,U+9e1f,U+9e23,U+9e2d,U+9e3f,U+9e45,U+9e64,U+9e70,U+9f20,U+9f7f,U+9f9a;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_c728d8.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e10,U+4e56,U+4ea2,U+4ea5,U+4ec3,U+4f36,U+4f3a,U+4f5f,U+4f6c,U+4f70,U+4f84,U+4f88,U+4f8d,U+4f97,U+5014,U+501a,U+5029,U+504c,U+516e,U+5197,U+51a5,U+51f8,U+5201,U+524c,U+527f,U+5288,U+52fa,U+5315,U+5321,U+5352,U+5366,U+53e8-53e9,U+5406,U+540f,U+543c,U+5471,U+5480,U+5495,U+549a,U+54b3,U+54c7,U+54e6,U+54ee,U+54fc,U+5501,U+556a,U+557c,U+5583,U+55dc,U+55e3,U+5632,U+5669,U+566c,U+5676,U+56b7,U+56bc,U+56da,U+56e4,U+56ff,U+5703,U+5729,U+57a2-57a3,U+58d1,U+5919,U+592d,U+592f,U+5944,U+5955,U+595a,U+5983,U+598d,U+5996,U+59ca,U+59e5,U+5a13,U+5a25,U+5a77,U+5a7f,U+5a9b,U+5ab2,U+5b09,U+5b70,U+5b75,U+5b7a,U+5bd0,U+5bf0,U+5c41,U+5c79,U+5c91,U+5c94,U+5ced,U+5d69,U+5e16,U+5e4c,U+5eb6,U+5f08,U+5f1b,U+5f27,U+5f3c,U+5ffb,U+6020,U+602f,U+604d,U+6055,U+606c,U+608d,U+60cb,U+60e6,U+60f6,U+618b,U+61a7-61a8,U+61ac,U+61cb,U+620c-620e,U+6233,U+6249,U+6273,U+6289,U+62c4,U+62c7,U+62ce,U+62d9,U+62ed-62ee,U+62f4,U+62f7,U+6345,U+634d-634e,U+6382,U+6396,U+63c9,U+63ea,U+6413,U+6452,U+645e,U+6479,U+6482,U+6487,U+64ac,U+64ce,U+64d2,U+6556,U+659f,U+65a1,U+660a,U+6615,U+664f,U+667e,U+66e6,U+6726,U+6756,U+675e,U+67c4,U+67da,U+6853,U+6897,U+68a2,U+68e3,U+68fa,U+693f,U+6942,U+695e,U+6995,U+69a8,U+69ad,U+69b4,U+69cc,U+6a44,U+6a59,U+6a80,U+6b83,U+6b86,U+6b89,U+6bcb,U+6bd3,U+6c30,U+6c40,U+6c5e,U+6c85,U+6cad,U+6cb1,U+6cbd,U+6cf1,U+6d19,U+6d31,U+6d85,U+6d93,U+6daa,U+6dae,U+6dbf,U+6dc6-6dc7,U+6e32,U+6e3a,U+6e43-6e44,U+6e4d,U+6e5f,U+6eba,U+6ec1,U+6f31,U+6f3e,U+6f5e,U+6f7a,U+6f7c,U+6f88,U+6f8e,U+6fee,U+701b,U+7078,U+7095,U+7099,U+70ab,U+70d9,U+70f7,U+70fd,U+7109,U+715e,U+717d,U+7184,U+71a8,U+71ce,U+71ee,U+722a,U+7235,U+7239,U+7252,U+72c4,U+72e1,U+72e9,U+733f,U+73c0,U+73c2,U+7409,U+7435-7436,U+7459-745a,U+747e,U+748b,U+749c,U+749e,U+74a8,U+74e2-74e3,U+7504,U+752c-752d,U+7599-759a,U+75a4,U+75ca,U+75f0,U+75f9,U+7600-7601,U+761f-7620,U+7629,U+768b,U+7693,U+7738,U+773a,U+776b,U+777f,U+7785,U+778e,U+77a5,U+77aa,U+77b0,U+77bf,U+7852,U+78be,U+78d5,U+78fa,U+7934,U+7940,U+7948,U+795b,U+7960,U+79a7,U+79ba,U+7a20,U+7a96,U+7b19,U+7b28,U+7b50,U+7b71,U+7b77,U+7b94,U+7bc6,U+7c27,U+7c38,U+7c95,U+7cb1,U+7ce0,U+7d0a,U+7d6e,U+7f79,U+7f9a,U+7fcc,U+8046,U+8098,U+80da,U+80e5,U+80f0,U+80f3,U+80fa,U+812f,U+8148,U+8165,U+818a,U+819b,U+81b3,U+81ba,U+81e7,U+8235,U+8237-8238,U+828b,U+8299,U+82ae,U+82b7,U+82d3,U+8301,U+831c,U+8338-8339,U+837b-837c,U+8386,U+8393,U+8398,U+83bd,U+83c1,U+83cf,U+83e0,U+83e9,U+8475,U+84bf,U+84d3,U+85c9,U+85d5,U+85dc,U+85e9,U+8654,U+86db,U+86df,U+86fe,U+8712,U+871a,U+873f,U+8757,U+8783,U+8859,U+888d,U+88a4,U+88f3-88f4,U+8910,U+891a,U+8925,U+8d66,U+8db4,U+8dda,U+8de4,U+8e1e,U+8e4a,U+8e66,U+8e6c,U+8fe5,U+901e,U+9083,U+90b3,U+90b8,U+90e1,U+90f4,U+9119,U+912f,U+9131,U+914c,U+915a,U+9163,U+9165,U+9170,U+91c9,U+91dc,U+962a,U+965b,U+9685,U+9698,U+96cc-96cd,U+96ef,U+96f9,U+9704,U+9713,U+9773-9774,U+9776,U+9ab8,U+9e8b,U+9e92,U+9eef;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_8ea124.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+4e07,U+4e0e,U+4e13,U+4e1a,U+4e1c-4e1d,U+4e24-4e25,U+4e2a,U+4e30,U+4e34,U+4e3a,U+4e3d-4e3e,U+4e48-4e49,U+4e4c,U+4e50,U+4e54,U+4e60-4e61,U+4e66,U+4e70-4e71,U+4e86,U+4e89,U+4e8e-4e8f,U+4e91,U+4e9a,U+4ea7,U+4ea9,U+4eb2,U+4ebf,U+4ec5,U+4ece,U+4eea,U+4eec,U+4ef7,U+4f17-4f1a,U+4f1f-4f20,U+4f24,U+4f53,U+4f59,U+4f60,U+4fee,U+501f,U+503a,U+503e,U+507f,U+50a8,U+513f,U+514b,U+515a,U+5170,U+5173-5174,U+517b,U+5185,U+518c,U+5199,U+519b-519c,U+51ac,U+51b2-51b3,U+51b5,U+51c6,U+51cf,U+51e0,U+51fa-51fb,U+5212,U+5218-521b,U+522b,U+5236,U+5242,U+5267,U+529e,U+52a1,U+52a8,U+52b1,U+52b3,U+52bf,U+533a-533b,U+5343,U+534e-534f,U+5355-5356,U+5360,U+536b,U+5373-5374,U+5377,U+5385-5386,U+538b,U+53a6,U+53bf,U+53c2,U+53cc,U+53d1,U+53d8,U+53ea,U+53f6-53f7,U+5403,U+5408,U+540c,U+540e,U+5417,U+5428,U+542c,U+542f,U+5434,U+5458,U+54cd,U+56de,U+56e2,U+56ed,U+56f0,U+56f4,U+56fd-56fe,U+573a,U+574f,U+5757,U+575a,U+58ee,U+58f0,U+5904,U+5907,U+590d,U+591f,U+592b,U+5934,U+593a,U+594b,U+5956,U+5965,U+5987,U+5b59,U+5b66,U+5b81,U+5b83,U+5b9d-5b9e,U+5ba1,U+5bb6,U+5bbd-5bbe,U+5bf9,U+5bfb-5bfc,U+5c06,U+5c14,U+5c3d,U+5c40,U+5c42,U+5c4a,U+5c5e,U+5c81,U+5c97,U+5c9b,U+5ce1,U+5cf0,U+5de8,U+5e01,U+5e03,U+5e08,U+5e10,U+5e26,U+5e2d-5e2e,U+5e72,U+5e76,U+5e7f,U+5e84,U+5e86,U+5e8a,U+5e93-5e94,U+5e9f,U+5f00,U+5f02,U+5f20,U+5f3a,U+5f52-5f53,U+5f55,U+5f69,U+5f7b,U+5f81,U+5f84,U+5fd7,U+5ff5,U+6000-6001,U+603b,U+60ca,U+613f,U+620f,U+6218,U+6237,U+624d-624e,U+6258,U+6267,U+6269,U+626c,U+6298,U+62a2,U+62a4-62a5,U+62b5,U+62c5,U+62e5,U+62e9,U+6302,U+6325,U+635f,U+6362,U+636e,U+6444,U+6446,U+6559,U+6570,U+6597,U+65ad,U+65d7,U+65e0,U+65e2,U+65e7,U+65f6,U+663e,U+6653,U+672f,U+6731,U+673a,U+6742-6743,U+6761,U+6765,U+6768,U+676f-6770,U+677e-677f,U+6781,U+6784,U+679c,U+67e5,U+6807,U+6811,U+6837-6838,U+6863,U+6865,U+6881,U+68c0,U+697c,U+6b22,U+6b27,U+6b8b,U+6bd5,U+6c14,U+6c47,U+6c49,U+6c61,U+6c88,U+6ca1,U+6ce8,U+6cfd,U+6d01,U+6d4b,U+6d4e,U+6d89,U+6da6,U+6da8,U+6e10,U+6e29,U+6e38,U+6e7e,U+6ee1,U+6f5c,U+706f,U+7075,U+707e,U+70b9,U+70df,U+70ed,U+7231,U+72b6,U+72ec,U+732a,U+732e,U+73af-73b0,U+7535,U+753b,U+7597,U+76d0-76d1,U+76d6,U+76d8,U+771f,U+7740,U+77ff,U+7840,U+786e,U+793c,U+79bb,U+79c1,U+79cb,U+79cd,U+79d8,U+79ef-79f0,U+7a0e,U+7a33,U+7a77,U+7ade,U+7b14,U+7b51,U+7b79,U+7b7e,U+7b80,U+7c7b,U+7cae,U+7d27,U+7d2f,U+7ea2,U+7ea6-7ea7,U+7eaa,U+7eaf,U+7eb3,U+7eb7-7eb8,U+7eba,U+7ebf,U+7ec3-7ec4,U+7ec6-7ec8,U+7ecd,U+7ecf,U+7ed3,U+7ed5,U+7ed9,U+7edc-7edd,U+7edf,U+7ee7,U+7ee9,U+7eed,U+7ef4,U+7efc,U+7eff,U+7f13,U+7f16,U+7f29,U+7f51,U+7f57,U+7f5a,U+7fa4,U+804c,U+8054,U+8083,U+80c6,U+80cc,U+80dc,U+8111,U+811a,U+8131,U+81f4,U+8270,U+827a,U+8282,U+82cf,U+8303,U+8363,U+836f,U+83b7,U+8425,U+8428,U+8651,U+867d,U+8865,U+8868,U+88c5,U+89c1-89c2,U+89c4,U+89c6,U+89c8-89c9,U+8a89,U+8ba1-8ba2,U+8ba4,U+8ba8-8ba9,U+8bad-8bb0,U+8bb2,U+8bb8,U+8bba,U+8bbe-8bbf,U+8bc1,U+8bc4,U+8bc6,U+8bc9,U+8bcd,U+8bd5,U+8bd7,U+8bda,U+8bdd,U+8be2,U+8be5,U+8bed,U+8bef,U+8bf4,U+8bf7,U+8bfb,U+8bfe,U+8c01,U+8c03,U+8c08,U+8c0b,U+8c22,U+8d1f,U+8d21-8d23,U+8d25,U+8d27-8d28,U+8d2b,U+8d2d,U+8d2f,U+8d34-8d35,U+8d37-8d3a,U+8d44,U+8d5b,U+8d5e,U+8d75-8d76,U+8d8b,U+8dc3,U+8df5,U+8f66,U+8f68,U+8f6c,U+8f6e-8f6f,U+8f7b,U+8f7d,U+8f83,U+8f86,U+8f89,U+8f93,U+8fb9,U+8fbd-8fbe,U+8fc7,U+8fd0,U+8fd8-8fd9,U+8fdb-8fde,U+8ff9,U+9002,U+9009,U+9057,U+9093,U+90ae,U+90d1,U+91cc,U+9274,U+9488,U+949f,U+94a2,U+94b1,U+94c1,U+94f6,U+9500,U+9519,U+9526,U+952e,U+9547,U+957f,U+95e8,U+95ed-95ee,U+95f4,U+95fb,U+9614,U+961f,U+9633,U+9635-9636,U+9645-9646,U+9648,U+9669,U+968f,U+96be,U+9759,U+97e9,U+9879-987b,U+987e-987f,U+9881,U+9884,U+9886,U+9898,U+989d,U+98ce,U+98de,U+996d-996e,U+9970,U+9986,U+9a6c,U+9a7b,U+9a8c,U+9c7c,U+9c81,U+9c9c,U+9e21,U+9e4f,U+9ea6,U+9ec4,U+9ed1,U+9f50,U+9f84,U+9f99;
}
@font-face {
font-family:"HarmonyOS Sans SC";
src:local("HarmonyOS Sans SC Bold"),local("HarmonyOS Sans SC"),url("./Bold_c614c1.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
unicode-range:U+3041-3094,U+309b-309e,U+30a1-30fe;
}

Some files were not shown because too many files have changed in this diff Show More