Compare commits
34
Commits
4f893ce140
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9a27b69f3
|
||
|
|
896b21fe44
|
||
|
|
cc6b82d7ec
|
||
|
|
4794bed2cf | ||
|
|
e6070f91ee | ||
|
|
26731958ea | ||
|
|
de7263569c | ||
|
|
c9ce7b64ef | ||
|
|
f3b09ca9a6 | ||
|
|
1cccc1aa00 | ||
|
|
f0538fa233 | ||
|
|
47ef7c63dd | ||
|
|
b74c6654e5 | ||
|
|
ac3d639d6b | ||
|
|
2ed16ff88d | ||
|
|
6f83ebbe3c | ||
|
|
4667d94c45 | ||
|
|
76b96cc374 | ||
|
|
03f7279b53 | ||
|
|
98dc87ec6a | ||
|
|
a93ae065cf | ||
|
|
fd71b83e3a | ||
|
|
a30eb3200a | ||
|
|
7fdebe3ec7 | ||
|
|
4f7a142a39 | ||
|
|
a89d7ae5fb | ||
|
|
eafa112959 | ||
|
|
3631a037d8 | ||
|
|
cd8344c698 | ||
|
|
a16ad8595e | ||
|
|
7b6593bb75 | ||
|
|
c7f42a1979 | ||
|
|
7be8d7d2ba | ||
|
|
1c65eaabc6 |
+4
-11
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
# 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
|
||||
|
||||
@@ -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'
|
||||
@@ -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
@@ -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/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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. 分别验证多端订阅、断网重连、不同分辨率与持续运行。
|
||||
|
||||
编译、镜像生成与主机测试通过均不等于烧录、板上运行或硬件稳定性已经验证。
|
||||
@@ -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
@@ -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
@@ -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)。
|
||||
@@ -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)。这些文档说明机制,性能数字来自上述本机测量。
|
||||
@@ -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 内容不同,不能将差值归因于功率这一项。
|
||||
@@ -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
@@ -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
@@ -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 最终绘制。接收间隔与分片组装耗时均不是摄像头到屏幕的光学延迟。
|
||||
@@ -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 }
|
||||
Generated
+1718
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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` 里发。
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
embuild::espidf::sysenv::output();
|
||||
}
|
||||
@@ -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,
|
||||
|
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(©_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:#}");
|
||||
}
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "armor-protocol"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# 只依赖错误检查用的东西:规则层是纯计算(血量、去抖、按键状态机),
|
||||
# 这样它能在 PC 上跑单元测试,不需要 ESP32-C3 硬件。
|
||||
[dependencies]
|
||||
protocol = { path = "../../protocol" }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")?,
|
||||
))
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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`。 |
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# 桌面端构建为宿主机目标,不是 ESP32。
|
||||
#
|
||||
# desktop/ 嵌在固件的目录树里,cargo 会向上找到 ../../.cargo/config.toml,那里面
|
||||
# `target = "xtensa-esp32s3-espidf"` 加一个 ldproxy linker,只有在 ESP-IDF 交叉编译
|
||||
# 里才成立。没有这个覆盖,桌面端会被交叉编译到 Xtensa,然后在依赖深处失败——
|
||||
# `libc` 找不到 `MAP_FAILED` 之类的东西,因为它正在为一个没有 `mmap` 的嵌入式目标
|
||||
# 编译。
|
||||
#
|
||||
# 覆盖 target 一项就够了:父配置里 `cfg(target_os = "espidf")` 那一段对 macOS 不
|
||||
# 生效,`[env]` 里两个 ESP-IDF 变量与宿主构建无关。
|
||||
#
|
||||
# 这一行是**换机器时必须改的**:换到 Intel Mac 是 x86_64-apple-darwin,换到 Linux
|
||||
# 是 x86_64-unknown-linux-gnu。
|
||||
[build]
|
||||
target = "aarch64-apple-darwin"
|
||||
@@ -0,0 +1,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/
|
||||
@@ -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 没改成宿主三元组 |
|
||||
Generated
+462
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -0,0 +1,43 @@
|
||||
//! cam-core —— 桌面端的非界面部分。
|
||||
//!
|
||||
//! 这一层不认识 Tauri,也不认识任何界面:它只负责
|
||||
//!
|
||||
//! - [`receiver`]:收 UDP、发现摄像头、按发送方重组 JPEG 帧;
|
||||
//! - [`armor`]:收装甲板主板广播的血量,并列出场上有哪几块板子;
|
||||
//! - [`stats`]:帧率统计(按帧数除以墙钟,不是对帧间隔取平均);
|
||||
//! - [`config`]:磁盘上的配置(身份、绑定、HUD、密码哈希);
|
||||
//! - [`password`]:密码的哈希与校验。
|
||||
//!
|
||||
//! # 为什么单独一层
|
||||
//!
|
||||
//! 旧的 Slint 客户端把这几件事和界面耦合在一个 crate 里,于是**它们只能通过跑起
|
||||
//! 整个界面来验证**。抽出来之后,重组、发现、帧率窗口、血量新鲜度都能用单元测试
|
||||
//! 直接盯住——而它们恰好是最容易错、错了又最难在现场看出来的部分。
|
||||
//!
|
||||
//! # 界面怎么拿数据
|
||||
//!
|
||||
//! 两条路,都是**推**而不是轮询:
|
||||
//!
|
||||
//! - 视频帧走 [`receiver::Receiver::set_frame_sink`]:接收线程重组好一张 JPEG 就
|
||||
//! 交给 sink,由上层转成 WebSocket 的二进制消息。sink 必须是非阻塞的。
|
||||
//! - 血量走 [`armor::ArmorHealthStore::set_sink`]:每收到一条上报就交给 sink。
|
||||
//!
|
||||
//! 同时两边的**最新值**都留在内存里([`receiver::LatestFrames`] 与
|
||||
//! [`armor::ArmorHealthStore::boards`]),给"截图"和"设置界面里的下拉列表"用——
|
||||
//! 那两处要的是当前状态,不是每一帧。
|
||||
|
||||
pub mod armor;
|
||||
pub mod config;
|
||||
pub mod password;
|
||||
pub mod receiver;
|
||||
pub mod slots;
|
||||
pub mod snapshot;
|
||||
pub mod stats;
|
||||
|
||||
pub use config::{Config, HudConfig, Role};
|
||||
pub use receiver::{
|
||||
ArmorSource, CameraInfo, FrameSink, LatestFrames, NoArmorYet, Receiver, SourceStats,
|
||||
ARMOR_BOARDS,
|
||||
};
|
||||
pub use slots::SlotAllocator;
|
||||
pub use stats::FpsWindow;
|
||||
@@ -0,0 +1,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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! 裁判端两个槽位对应哪两块摄像头。
|
||||
//!
|
||||
//! # 规则
|
||||
//!
|
||||
//! 1. **还在线的槽位不换人。** 板子短暂掉线又回来时,画面留在原位。
|
||||
//! 2. **没人用的板子才拿来填。** 候选里排除已经被另一个槽位占着的 id——这一条是
|
||||
//! 修一个真 bug:以前的实现只排除"这个槽位自己原来那个 id",于是队伍一那块板子
|
||||
//! 一离线,它的槽位就会去拿队伍二那块,**两格显示同一块板子**,看起来像图传串了。
|
||||
//! 3. **掉线的槽位保留它的 id。** 显示"离线"而不是变回空槽,这样它回来时还在原位,
|
||||
//! 也不会把画面顺序重排。
|
||||
//! 4. **没有候选就不动。** 只有一块板在场时,另一个槽位空着——空着比显示一份复制品
|
||||
//! 诚实。
|
||||
//!
|
||||
//! # 为什么值得单独一个模块加测试
|
||||
//!
|
||||
//! 这个逻辑出错的**表现**是"两路画面看起来串了"。现场的排查方向会是网线、AP、
|
||||
//! 板子,而不是二十行分配代码。旧客户端在 README 里为它写了整整一节,但仍然漏掉了
|
||||
//! 规则 2。所以现在它有了直接对着场景写的测试。
|
||||
|
||||
/// 两个槽位。索引 0 是左(队伍一),1 是右(队伍二)。
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SlotAllocator {
|
||||
slots: [Option<u32>; 2],
|
||||
}
|
||||
|
||||
impl SlotAllocator {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 当前分配。
|
||||
pub fn slots(&self) -> [Option<u32>; 2] {
|
||||
self.slots
|
||||
}
|
||||
|
||||
pub fn slot(&self, index: usize) -> Option<u32> {
|
||||
self.slots.get(index).copied().flatten()
|
||||
}
|
||||
|
||||
/// 手动指定一个槽位(裁判端的"允许手动覆盖")。
|
||||
pub fn force(&mut self, index: usize, id: Option<u32>) {
|
||||
if let Some(slot) = self.slots.get_mut(index) {
|
||||
*slot = id;
|
||||
}
|
||||
}
|
||||
|
||||
/// 按当前在线的摄像头更新分配,返回是否发生了变化。
|
||||
///
|
||||
/// `online` 必须按 id 排序,调用方用 [`crate::receiver::Discovery::online_cameras`]
|
||||
/// 得到的就是排好序的——顺序稳定,分配才不会每轮抖动。
|
||||
pub fn assign(&mut self, online: &[u32]) -> bool {
|
||||
let before = self.slots;
|
||||
|
||||
for index in 0..self.slots.len() {
|
||||
// 规则 1:还在场就不动。
|
||||
if self.slots[index].is_some_and(|id| online.contains(&id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 规则 2:另一个槽位占着的 id 不能拿。
|
||||
let taken: Vec<u32> = (0..self.slots.len())
|
||||
.filter(|other| *other != index)
|
||||
.filter_map(|other| self.slots[other])
|
||||
.collect();
|
||||
|
||||
let candidate = online
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|id| !taken.contains(id) && self.slots[index] != Some(*id));
|
||||
|
||||
// 规则 4:没有候选就保留原样(规则 3:掉线的 id 留着)。
|
||||
if let Some(id) = candidate {
|
||||
self.slots[index] = Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
self.slots != before
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn two_boards_fill_both_slots_left_to_right() {
|
||||
let mut slots = SlotAllocator::new();
|
||||
|
||||
assert!(slots.assign(&[0xAAAA, 0xBBBB]));
|
||||
assert_eq!(slots.slots(), [Some(0xAAAA), Some(0xBBBB)]);
|
||||
assert!(!slots.assign(&[0xAAAA, 0xBBBB]), "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_board_never_fills_both_slots() {
|
||||
// 旧实现会在两块都空时每轮取前两个,只有一块板在场时两格显示同一块画面。
|
||||
// 裁判看到两边一模一样,会以为两队的图传串了。
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA]);
|
||||
|
||||
assert_eq!(slots.slots(), [Some(0xAAAA), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_online_slot_keeps_its_board_when_the_other_goes_offline() {
|
||||
// 这是本项目里真实出现过的 bug:队伍一的板子一离线,它的槽位去拿了队伍二
|
||||
// 那块,于是两格都显示队伍二——而 B 的槽位本身没动。
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
|
||||
slots.assign(&[0xBBBB]);
|
||||
|
||||
assert_eq!(
|
||||
slots.slots(),
|
||||
[Some(0xAAAA), Some(0xBBBB)],
|
||||
"A 掉线的槽位保留它的 id(显示离线),B 原地不动,绝不能出现两块 B"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_returning_board_lands_back_in_its_own_slot() {
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
slots.assign(&[0xBBBB]);
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
|
||||
assert_eq!(slots.slots(), [Some(0xAAAA), Some(0xBBBB)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spare_board_does_not_land_twice() {
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
|
||||
// 两块都掉线,然后来了一块新的 C:只填一个槽位。
|
||||
slots.assign(&[0xCCCC]);
|
||||
|
||||
assert_eq!(slots.slots(), [Some(0xCCCC), Some(0xBBBB)]);
|
||||
|
||||
// C 填了左边,右边仍是掉线的 B;再来一块 D 才填右边。
|
||||
slots.assign(&[0xCCCC, 0xDDDD]);
|
||||
assert_eq!(slots.slots(), [Some(0xCCCC), Some(0xDDDD)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_board_coming_back_does_not_thrash_the_slot_it_left() {
|
||||
// "板子短暂掉线又回来,槽位原地打转":掉线的槽位不该立刻把自己换掉。
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA]);
|
||||
|
||||
// A 掉线、B 上线:A 的槽位改拿 B。
|
||||
slots.assign(&[0xBBBB]);
|
||||
assert_eq!(slots.slots(), [Some(0xBBBB), None]);
|
||||
|
||||
// A 回来,两块都在:B 已经在槽位里且在线,A 只能等另一个槽位——它空着,于是
|
||||
// A 落到右边。这是有意的:已经显示出来的画面不该因为一块板子回来就跳到别处。
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
assert_eq!(slots.slots(), [Some(0xBBBB), Some(0xAAAA)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forcing_a_slot_overrides_the_automatic_choice() {
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
|
||||
slots.force(0, Some(0xBBBB));
|
||||
slots.force(1, Some(0xAAAA));
|
||||
|
||||
assert_eq!(slots.slots(), [Some(0xBBBB), Some(0xAAAA)]);
|
||||
|
||||
// 手动模式下依然是手动值:assign 只对"不在线"的槽位动手。
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
assert_eq!(slots.slots(), [Some(0xBBBB), Some(0xAAAA)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_everything_leaves_the_last_known_ids() {
|
||||
let mut slots = SlotAllocator::new();
|
||||
slots.assign(&[0xAAAA, 0xBBBB]);
|
||||
|
||||
assert!(!slots.assign(&[]), "nothing to reassign");
|
||||
assert_eq!(
|
||||
slots.slots(),
|
||||
[Some(0xAAAA), Some(0xBBBB)],
|
||||
"掉线的槽位保留 id,界面才能说清在等谁"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! 帧率统计。
|
||||
//!
|
||||
//! # 必须按"帧数除以墙钟",不是"帧间隔的平均"
|
||||
//!
|
||||
//! 这两种算法在理想链路上给出同一个数,所以错误的那一版能一路跑到现场才暴露。
|
||||
//!
|
||||
//! 界面每 33ms 轮询一次,而相机出帧间隔是 45ms。也就是说**一次轮询之间可能完成零帧
|
||||
//! 或多帧**。对帧间隔取平均,量到的是**显示**的刷新节奏而不是**相机**的出帧节奏——
|
||||
//! 于是上限 22fps 的传感器被报成 36fps。**一个笃定的错误数字比没有数字更糟**:现场
|
||||
//! 会以为还有性能余量,然后花时间去查一个不存在的问题。
|
||||
//!
|
||||
//! 这个错误在本项目里出现过两次(旧客户端的 `View::update_fps`、探测脚本的分窗打印),
|
||||
//! 两次都是同一个形状:分子用累计值,分母用窗口值,于是报出随时间单调上升的假数字。
|
||||
//! 现在它的正确性由一个直接对着数字写的测试盯着。
|
||||
//!
|
||||
//! # 时钟是可注入的
|
||||
//!
|
||||
//! [`FpsWindow::observe`] 收 `now` 而不是自己取 `Instant::now()`,这样测试可以用
|
||||
//! 明确的时刻验证窗口边界——否则只能 sleep,而 sleep 出来的测试既慢又不稳。
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 帧率统计窗口。默认 2 秒。
|
||||
pub const DEFAULT_WINDOW: Duration = Duration::from_secs(2);
|
||||
|
||||
/// 某一路的帧率窗口。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FpsWindow {
|
||||
window: Duration,
|
||||
started: Instant,
|
||||
/// 上一次结算时的累计帧数。
|
||||
frames: u64,
|
||||
/// 已经结算过几个窗口。
|
||||
///
|
||||
/// 不能拿"累计帧数非零"来代替它:窗口的基线是**播种**进去的(见 [`FpsWindow::seeded`]),
|
||||
/// 一块掉线又回来的板子一建立窗口就带着一个很大的累计值。用帧数判断的话,会在一个
|
||||
/// 窗口都还没走完时就宣称"有读数了",界面于是先显示 0.0,再跳到真值。
|
||||
completed: u32,
|
||||
fps: f32,
|
||||
}
|
||||
|
||||
impl Default for FpsWindow {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_WINDOW)
|
||||
}
|
||||
}
|
||||
|
||||
impl FpsWindow {
|
||||
pub fn new(window: Duration) -> Self {
|
||||
Self::starting_at(window, Instant::now())
|
||||
}
|
||||
|
||||
/// 窗口从指定的时刻开始,并把累计帧数的基线设成给定值。
|
||||
///
|
||||
/// 基线不能总是 0:`frames_shown` 是**自进程启动以来**的累计值,而一块掉线又回来的
|
||||
/// 板子会带着一个已经涨了很大的数字回来。基线设错(比如设成 0)时,下一个窗口会
|
||||
/// 报出几百 fps 的假数字。
|
||||
pub fn seeded(window: Duration, started: Instant, frames_shown: u64) -> Self {
|
||||
Self {
|
||||
window,
|
||||
started,
|
||||
frames: frames_shown,
|
||||
completed: 0,
|
||||
fps: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 窗口从指定的时刻开始,基线为 0。测试用。
|
||||
pub fn starting_at(window: Duration, started: Instant) -> Self {
|
||||
Self::seeded(window, started, 0)
|
||||
}
|
||||
|
||||
/// 最近一次结算出的帧率。窗口还没满时是 0。
|
||||
pub fn fps(&self) -> f32 {
|
||||
self.fps
|
||||
}
|
||||
|
||||
/// 窗口是否已经结算过至少一次。
|
||||
///
|
||||
/// 界面用它在"还没收到帧"和"帧率真的是 0"之间区分——前者显示 0.0 会被读成"板子
|
||||
/// 卡死了"。
|
||||
pub fn has_reading(&self) -> bool {
|
||||
self.completed > 0
|
||||
}
|
||||
|
||||
/// 喂进当前的累计帧数,返回本窗口的帧率(窗口未满时返回 `None`)。
|
||||
///
|
||||
/// `frames_shown` 是**自进程启动以来**的累计值,不是窗口内的增量——这正是重点:
|
||||
/// 窗口内的增量由这里相减得到,调用方不需要(也就不会弄错)维护它。
|
||||
pub fn observe(&mut self, frames_shown: u64, now: Instant) -> Option<f32> {
|
||||
let elapsed = now.saturating_duration_since(self.started);
|
||||
|
||||
if elapsed < self.window {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.fps = (frames_shown.saturating_sub(self.frames)) as f32 / elapsed.as_secs_f32();
|
||||
self.frames = frames_shown;
|
||||
self.started = now;
|
||||
self.completed += 1;
|
||||
|
||||
Some(self.fps)
|
||||
}
|
||||
|
||||
/// 换了一路画面:窗口重开。
|
||||
///
|
||||
/// 不重开的话,新画面的第一段统计里带着上一块板子的累计帧数,会报出一个凭空多出来
|
||||
/// 的数字。旧客户端对此的处理是"`view.sender_id` 变了就整体重建 `View`",那条
|
||||
/// 规则现在收在这里。
|
||||
pub fn reset(&mut self, now: Instant) {
|
||||
self.started = now;
|
||||
self.frames = 0;
|
||||
self.completed = 0;
|
||||
self.fps = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rate_is_frames_over_wallclock() {
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||||
|
||||
// 2 秒里到了 44 帧 = 22 fps。
|
||||
let fps = window
|
||||
.observe(44, t0 + Duration::from_secs(2))
|
||||
.expect("the window is full");
|
||||
|
||||
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_half_full_window_reports_nothing() {
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||||
|
||||
assert_eq!(window.observe(10, t0 + Duration::from_millis(1999)), None);
|
||||
assert_eq!(window.fps(), 0.0);
|
||||
assert!(!window.has_reading());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stalled_stream_reports_zero_not_a_stale_number() {
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||||
|
||||
window.observe(44, t0 + Duration::from_secs(2));
|
||||
// 板子不出帧了:累计值不变,下一个窗口必须报 0。
|
||||
let fps = window
|
||||
.observe(44, t0 + Duration::from_secs(4))
|
||||
.expect("second window");
|
||||
|
||||
assert_eq!(fps, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn averaging_frame_gaps_is_the_wrong_answer() {
|
||||
// 这条测试把"错误算法会给出什么"钉下来,避免有人"顺手修一下"。
|
||||
//
|
||||
// 场景:界面每 33ms 轮询一次,相机 45ms 出一帧。一次轮询里轮空、下一次里
|
||||
// 完成一帧,还有一次轮询之间完成两帧。对帧间隔取平均会算成约 33ms 一帧
|
||||
// (30fps 上下),而墙钟给出的才是真实的 22fps。
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||||
|
||||
// 2 秒内累计 44 帧,但最后一次轮询与上一帧之间只隔了 33ms。
|
||||
let fps = window
|
||||
.observe(44, t0 + Duration::from_secs(2))
|
||||
.expect("window full");
|
||||
|
||||
assert!((fps - 22.0).abs() < 0.001, "wallclock rate: {fps}");
|
||||
assert!(
|
||||
fps < 25.0,
|
||||
"a frame-gap average would have reported ~30fps here"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_completed_window_is_not_a_reading_yet_until_it_is_full() {
|
||||
// 建立窗口的那一轮不该产出数字:它连一个完整窗口都还没过。
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::seeded(Duration::from_secs(2), t0, 9_000);
|
||||
|
||||
assert_eq!(window.observe(9_010, t0 + Duration::from_millis(10)), None);
|
||||
assert!(!window.has_reading());
|
||||
|
||||
// 2 秒后:这 2 秒里到了 44 帧。
|
||||
let fps = window
|
||||
.observe(9_044, t0 + Duration::from_secs(2))
|
||||
.expect("window full");
|
||||
|
||||
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resetting_clears_the_cumulative_count() {
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||||
|
||||
window.observe(1000, t0 + Duration::from_secs(2));
|
||||
// 换了一路画面:累计值从 0 重新开始。
|
||||
window.reset(t0 + Duration::from_secs(2));
|
||||
|
||||
let fps = window
|
||||
.observe(44, t0 + Duration::from_secs(4))
|
||||
.expect("window full");
|
||||
|
||||
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_counter_that_goes_backwards_does_not_underflow() {
|
||||
// 累计值只应该单调增。真出现回退(换了板子但没 reset),宁可报 0 也不要用
|
||||
// u64 回绕算出一个天文数字。
|
||||
let t0 = Instant::now();
|
||||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||||
|
||||
window.observe(500, t0 + Duration::from_secs(2));
|
||||
let fps = window
|
||||
.observe(10, t0 + Duration::from_secs(4))
|
||||
.expect("window full");
|
||||
|
||||
assert_eq!(fps, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1305
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
# pnpm 11 不再读取 package.json 里的 `pnpm` 字段,设置搬到了这里。
|
||||
# 这个文件是 pnpm 自己在遇到被忽略的构建脚本时生成的。
|
||||
#
|
||||
# esbuild 的 postinstall 是它把自己平台的二进制接好的那一步。不允许它跑,`pnpm exec`
|
||||
# 每次都会以 ERR_PNPM_IGNORED_BUILDS 退出 1 —— 即使 vite 本身还能跑,这个非零退出码
|
||||
# 也会让 `tauri dev` 的 beforeDevCommand 失败。所以这里显式放行。
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user