Compare commits
29
Commits
eafa112959
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a0eb14b28
|
||
|
|
ee22a82ecd
|
||
|
|
e3acb37d32
|
||
|
|
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 |
+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 }
|
||||
|
||||
@@ -6,10 +6,68 @@ 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
|
||||
|
||||
@@ -18,3 +76,13 @@ WIFI_PASS=your-password
|
||||
# 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 文档站,与本仓库的目录结构无关,
|
||||
# 也不属于本项目的产物。只忽略它本身;docs/ 下的项目文档要跟踪。
|
||||
/docs/simba_docs/
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# 仓库协作约定
|
||||
|
||||
本文件适用于整个仓库,供开发者和代码助手使用。先阅读 [README.md](README.md) 与 [文档首页](docs/index.md),再按任务进入对应子项目。子目录若新增更具体的 `AGENTS.md`,按其适用范围执行。
|
||||
|
||||
## 项目边界
|
||||
|
||||
RMIC::2026 包含 ESP32-S3 图传固件、ESP32-C3 装甲板固件和 Tauri 桌面端。它们共享部分协议代码,但不共享同一个构建目标。
|
||||
|
||||
| 路径 | 修改内容 | 需要注意 |
|
||||
| --- | --- | --- |
|
||||
| `src/`、根 `build.rs` | S3 采集、发送、重连及构建参数 | Rust `std` + ESP-IDF,默认目标为 Xtensa |
|
||||
| `components/cam_wrapper/` | 相机 C/Rust FFI | 同步核对 C 结构体、绑定与缓冲所有权 |
|
||||
| `components/esp32-camera/` | 本地驱动及补丁 | 保留上游结构和本地补丁说明 |
|
||||
| `protocol/` | 线路编解码与帧重组 | 固件和桌面共用,保持协议兼容性 |
|
||||
| `desktop/core/` | 网络、配置、密码、装甲板与槽位 | 保持独立于 UI 与 Tauri,可单独测试 |
|
||||
| `desktop/src-tauri/` | 本地通道、命令、录制与维护 | 涉及文件、子进程、串口及 WebView 边界 |
|
||||
| `desktop/src/` | React、TypeScript、canvas 与样式 | 复用已有组件、绘制函数和样式令牌 |
|
||||
| `armor/` | C3 固件与受击规则 | 独立 workspace,目标为 RISC-V |
|
||||
| `scripts/` | 构建、打包、版本和诊断 | 修改入口或输出路径时同步文档与调用方 |
|
||||
| `reference/armor/` | 早期 C 版参考工程 | 默认用于核对,不作为当前实现入口 |
|
||||
|
||||
## 工具链与执行目录
|
||||
|
||||
- 根 `rust-toolchain.toml` 固定为 `esp-1.97.0.0`,根 `.cargo/config.toml` 默认目标是 `xtensa-esp32s3-espidf`,并启用 `build-std`。
|
||||
- `desktop/` 使用 Rust stable,但默认 Cargo 目标仍为 `aarch64-apple-darwin`;`protocol/` 和 `armor/armor-protocol/` 也有宿主目标覆盖。换平台时显式传入实际目标。
|
||||
- Windows x64 的宿主目标是 `x86_64-pc-windows-msvc`。主机检查显式使用 `+stable`、`--target` 和 `--config 'unstable.build-std=[]'`,避免继承固件配置。
|
||||
- S3 默认 ESP-IDF 为 v5.5.5;装甲板原环境使用 v6.0.1。`armor/.cargo/config.toml` 含原开发机的 `IDF_PATH`,构建前核对,不假设该路径在其他机器存在。
|
||||
- Cargo 根据执行目录查找 `.cargo/config.toml`;仅传 `--manifest-path` 不会自动切换到对应子项目的配置。需要依赖子项目配置时,先进入该目录。
|
||||
- 固件通过 `scripts/build.ps1` 或 `scripts/build.sh` 构建,以加载 `.env` 并设置组件、分区表路径。直接执行 `cargo build` 不会自动加载 `.env`。
|
||||
- 桌面前端使用 pnpm 和 `desktop/pnpm-lock.yaml`,安装时优先 `pnpm install --frozen-lockfile`。不要同时维护 npm / Yarn 锁文件。
|
||||
|
||||
环境准备和平台细节见[固件入门](docs/setup/firmware.md)、[客户端说明](docs/desktop/client.md)和[装甲板说明](docs/hardware/armor.md)。
|
||||
|
||||
## 修改方式
|
||||
|
||||
1. 开始前检查工作区差异,保留用户已有的修改;先定位行为所在模块,再修改对应层。
|
||||
2. 沿用现有命名、格式和模块划分。代码助手只处理任务相关变更,避免顺带重排全仓库、升级依赖或改动其他平台配置。
|
||||
3. 修改行为时补充能覆盖该行为的验证;纯文档或文案调整检查链接、命令、类型和显示效果即可。
|
||||
4. 完成后检查差异与空白问题,说明改了什么、做了哪些验证,以及仍未验证的范围。
|
||||
|
||||
### 协议与实时图传
|
||||
|
||||
- 线路定义以 `protocol/` 为准。修改字段、头长、消息类型或版本时,同时核对固件、桌面、`scripts/udp_probe.py` 等独立探测器和[协议文档](docs/protocol/udp.md)。
|
||||
- 视频帧必须按发送方和帧号隔离,重复分片不重复计数,不完整 JPEG 不进入解码和绘制。
|
||||
- 保留最新帧优先、有界等待和过期清理。不要让接收线程等待视频解码、写盘或 UI 消费,也不要以无限队列掩盖拥塞。
|
||||
- 修改 C 相机缓冲交接时,检查获取、归还、超时和提前返回路径,避免重复归还或泄漏。
|
||||
- 用户端绑定过滤、发现列表和裁判槽位有不同职责,不要为了显示候选设备而放开用户端视频接收限制。
|
||||
|
||||
### 桌面端与文案
|
||||
|
||||
- 业务规则放在 Rust 核心,前端使用既有快照与 IPC 接口;新增设置需核对配置序列化、默认值和旧配置兼容性。
|
||||
- 画面、准星、网格和命中反馈复用现有变换与绘制函数。改文案或布局后,按影响范围检查用户端、裁判端、设置和首次启动页面。
|
||||
- 中文表达清楚、具体,写明状态、条件和下一步。保留术语、错误原因、数字与验证边界,避免比喻式按钮和含糊的成功提示。
|
||||
- 密码限制用于防止现场误改;包内哈希校验用于检查完整性。文案不能将它们描述为不可绕过的访问控制或固件来源认证。
|
||||
- 预览和演示数据始终标明为合成数据,不用演示结果替代实际网络或硬件验证。
|
||||
|
||||
### 硬件、配置与源码保护
|
||||
|
||||
- 不提交 Wi-Fi 凭据、密码哈希、个人 SDK 绝对路径或本机缓存;不要在日志和交付说明中输出 `.env` 内容。保留已有 `.env`,示例使用占位值。
|
||||
- 仅做软件、文档或文案任务时,不执行烧录、擦除、写分区表或修改设备安全配置。涉及硬件任务时,按用户授权核对芯片、端口、分区与备份。
|
||||
- `components/esp32-camera/target/` 是驱动源码目录,不能按构建缓存删除或忽略。清理缓存时使用明确的构建目录,相关方法见[开发与验证](docs/development/firmware.md#清理构建缓存)。
|
||||
- 第三方组件的 README、许可证和本地补丁记录保留原有位置。不要把参考工程的旧实现误写成当前功能。
|
||||
|
||||
## 按变更选择验证
|
||||
|
||||
以下命令默认从仓库根目录执行,标明其他目录的除外。示例宿主为 Windows x64;其他平台用 `rustc +stable -vV` 核对 host 后替换 `--target`。
|
||||
|
||||
| 变更 | 至少检查 |
|
||||
| --- | --- |
|
||||
| 文档、链接、导航 | 文件与锚点有效,代码块闭合,命令路径正确;Mermaid 语法可解析 |
|
||||
| React / TypeScript / UI 文案 | `desktop/` 下 `pnpm build`,并预览受影响页面 |
|
||||
| 前端解码队列 | `desktop/` 下 `pnpm test:stream` |
|
||||
| 延迟校准标记 | `desktop/` 下 `pnpm test:latency` |
|
||||
| 线路编解码、帧重组 | `protocol/` 主机测试,必要时独立协议探测 |
|
||||
| 桌面业务规则 | `desktop/core/` 主机测试 |
|
||||
| Tauri 命令、录制、维护 | 后端编译检查和相关单元测试;录制测试需 ffmpeg / ffprobe |
|
||||
| 装甲板受击规则 | `armor/armor-protocol/` 主机测试 |
|
||||
| 固件发送、重连、缓冲交接 | 对应主机策略测试或 `scripts/test-latest-frame.py`;硬件表现另行实测 |
|
||||
| 版本生成脚本或生成路径 | 隔离样例检查输出位置与重复生成,再运行版本一致性检查 |
|
||||
|
||||
### 常用主机命令
|
||||
|
||||
```powershell
|
||||
cargo +stable test --locked --manifest-path protocol/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
cargo +stable test --locked --manifest-path desktop/core/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
cargo +stable test --locked --manifest-path armor/armor-protocol/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
cargo +stable check --locked --manifest-path desktop/src-tauri/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
前端命令在 `desktop/` 执行:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
pnpm test:stream
|
||||
pnpm test:latency
|
||||
```
|
||||
|
||||
浏览器预览:`pnpm dev`,打开 `http://127.0.0.1:1420/`;可用 `?preview=referee`、`?preview=first-run`、`?preview=password` 检查不同页面。预览不能验证 Tauri 原生能力。
|
||||
|
||||
测试通过后无需重复运行无关的大范围检查。依赖、工具链或设备条件不足时,明确记录实际执行结果和未验证项。**编译、主机测试、镜像生成不等于烧录、板上运行或硬件稳定性已验证。**
|
||||
|
||||
## 文档组织
|
||||
|
||||
- 根目录 `README.md` 介绍项目、展示架构并链接到详细文档;本文件保存仓库协作约定。子项目 README 保留简短入口。
|
||||
- 除 README 和 AGENTS 外,项目说明集中到 `docs/`,按 `setup/`、`development/`、`hardware/`、`desktop/`、`protocol/`、`maintenance/`、`testing/`、`releases/`、`design/` 归类。
|
||||
- 新增或迁移文档时,同步更新 `docs/index.md`、相对链接、标题锚点、脚本和代码中的引用。可复用既有主题时,优先修改原文。
|
||||
- 已被实现替代或明确放弃的方案可以删除,历史从 Git 查阅;保留仍有价值的实验条件、原始数字和限定。未实现需求写入 `docs/design/` 并标明状态。
|
||||
- 改写中文时保留事实、数字、命令、否定和确定程度,不将计划写成已完成,不将接收间隔写成端到端延迟。
|
||||
- README 使用 GitHub 风味 Markdown:表格用于比较,折叠块放可选步骤,提示块强调实际限制,Mermaid 解释结构和流程。使用稳定基础语法,不在图表中加入脚本或外部服务依赖。
|
||||
|
||||
## 版本与提交
|
||||
|
||||
提交标题采用 Conventional Commits:`type(scope): 说明`。破坏性改动升 major,`feat` 升 minor,`fix` / `perf` 升 patch;其他类型保持语义版本,构建号由 Git 提交数计算。
|
||||
|
||||
版本和日志由脚本生成,在根目录执行:
|
||||
|
||||
```sh
|
||||
node scripts/version.mjs --write
|
||||
node scripts/version.mjs --check
|
||||
```
|
||||
|
||||
不要手动修改 `version.json`、`desktop/src/lib/version.ts` 或 `docs/releases/changelog.md` / `.json`。`desktop/package.json` 和 `desktop/src-tauri/tauri.conf.json` 的版本字段也由同一脚本同步;其他字段按任务正常维护。提交或标签改变后,按发布流程重新生成;文档路径变更不得使日志重新出现在根目录。
|
||||
|
||||
完整规则见[版本维护](docs/development/versioning.md)。
|
||||
@@ -1,349 +1,292 @@
|
||||
# ESP32-S3 摄像头图传(Rust / ESP-IDF / UDP)
|
||||
<p align="center">
|
||||
<img src="desktop/src-tauri/icons/128x128.png" width="88" height="88" alt="Simba Robotics 队标">
|
||||
</p>
|
||||
|
||||
两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV5640),向队伍客户端与裁判终端推
|
||||
UDP 图传。
|
||||
# RMIC::2026 · 视野链路系统
|
||||
|
||||
固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;客户端是 Rust + Slint
|
||||
桌面程序。
|
||||
**黑龙江大学 602 实验室 · Simba Robotics**
|
||||
|
||||
```text
|
||||
protocol/ 线路格式 + 帧重组 两端共用,无 ESP-IDF 依赖,13 个单元测试
|
||||
. 固件 -> ESP32-S3 交叉编译,Xtensa
|
||||
client/ 桌面客户端 Slint,队伍端 / 裁判端两种身份
|
||||
面向机器人对抗赛的局域网图传与装甲板系统。ESP32-S3 采集 OV5640 的硬件 JPEG,经 UDP 发送到桌面端;ESP32-C3 上报装甲板血量,桌面端提供操作手视野、裁判双路监看、截图录制和 USB 固件维护。
|
||||
|
||||
[](docs/setup/firmware.md)
|
||||
[](docs/hardware/armor.md)
|
||||
[](docs/desktop/client.md)
|
||||
[](docs/protocol/udp.md)
|
||||
|
||||
[文档首页](docs/index.md) · [硬件接线](docs/hardware/camera.md) · [故障排查](docs/testing/troubleshooting.md) · [改动记录](docs/releases/changelog.md) · [开发约定](AGENTS.md)
|
||||
|
||||
> [!NOTE]
|
||||
> 版本、构建号与线路协议号以 [`version.json`](version.json) 为准。改动记录从 Git 历史生成,维护方式见[版本说明](docs/development/versioning.md)。
|
||||
|
||||
## 导航
|
||||
|
||||
- [系统架构](#系统架构)
|
||||
- [功能与验证范围](#功能与验证范围)
|
||||
- [快速开始](#快速开始)
|
||||
- [发现与收流时序](#发现与收流时序)
|
||||
- [仓库结构](#仓库结构)
|
||||
- [开发与验证](#开发与验证)
|
||||
- [文档与后续工作](#文档与后续工作)
|
||||
|
||||
## 系统架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Camera["图传板 · ESP32-S3"]
|
||||
Sensor["OV5640<br/>硬件 JPEG"] --> Firmware["采集与分片<br/>最新帧发送"]
|
||||
end
|
||||
|
||||
subgraph Armor["装甲板 · ESP32-C3"]
|
||||
Piezo["四路压电片"] --> Rules["受击规则与整车血量"]
|
||||
Rules --> LEDs["四路 WS2812"]
|
||||
end
|
||||
|
||||
subgraph Desktop["RMIC::2026 · 桌面端"]
|
||||
Core["Rust 核心<br/>发现、重组与血量状态"]
|
||||
Backend["Tauri 后端<br/>本地通道、截图与录制"]
|
||||
UI["React / WebView<br/>用户端或裁判端"]
|
||||
Core --> Backend
|
||||
Backend -->|"本地 WebSocket:JPEG 与状态"| UI
|
||||
UI -->|"IPC:配置与操作"| Backend
|
||||
end
|
||||
|
||||
Firmware -->|"UDP 5600:Hello 与视频分片"| Core
|
||||
Core -->|"Subscribe:广播与定向续订"| Firmware
|
||||
Rules -->|"UDP 5601:血量广播"| Core
|
||||
```
|
||||
|
||||
三个 crate 各自独立构建,各有自己的 `.cargo/config.toml`。
|
||||
两块图传板各自发送,裁判端同时接收两路;用户端只接收绑定图传板的画面。装甲板主板连接四路传感器和灯带,其余从板不含 MCU。
|
||||
|
||||
## 拓扑
|
||||
| 网络项 | 配置 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| 图传板 Wi-Fi | 2.4 GHz | 板卡与客户端需处于可互通的局域网 |
|
||||
| 视频与发现 | UDP `5600` | Hello、Subscribe 与 JPEG 分片 |
|
||||
| 装甲板上报 | UDP `5601` | 每 200 ms 广播四路血量 |
|
||||
| 本地界面通道 | 回环 WebSocket + Tauri IPC | JPEG 由 WebView 解码,网络接收线程不做视频解码 |
|
||||
|
||||
```text
|
||||
┌──────────────────────────────┐
|
||||
│ 裁判终端(PC,仅显示) │
|
||||
│ 队伍一 + 队伍二 + 双方血量 │
|
||||
└───────────────┬──────────────┘
|
||||
│ 同一个局域网,广播可用
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
|
||||
│ 图传板 A │ │ 图传板 B │ │ 装甲板 ×4 │
|
||||
│ (队伍一) │ │ (队伍二) │ │ (预留,未实现) │
|
||||
└───────────────┘ └───────────────┘ └───────────────┘
|
||||
```
|
||||
> [!IMPORTANT]
|
||||
> 网络需要允许广播和设备间互访。请关闭 AP 客户端隔离,并允许客户端通过防火墙。VPN / 虚拟网卡可能影响广播路由,排查方法见[客户端说明](docs/desktop/client.md#windows-x64-构建)。
|
||||
|
||||
**两块板子不区分队伍。** 它们只知道自己是谁(MAC 派生的 `sender_id`),"哪一边"由
|
||||
客户端决定。烧同一份固件即可,配置全在客户端一侧。
|
||||
## 功能与验证范围
|
||||
|
||||
裁判终端同时收两块板子。队伍客户端也收全部板子的视频,然后在**界面层**过滤到绑定的
|
||||
那一块。
|
||||
| 模块 | 已有功能 | 使用条件与边界 |
|
||||
| :--- | :--- | :--- |
|
||||
| 图传固件 | VGA 彩色 JPEG、UDP 分片、多端订阅、Wi-Fi 重连、过期帧放弃 | 当前记录约 30 fps;不同网络、板卡与画质下需复测[^performance] |
|
||||
| 用户端 | 本队画面、双方装甲条、瞄准器、偏移校准、命中反馈 | 先绑定图传板与装甲板;改绑、切换模式和退出受密码限制 |
|
||||
| 裁判端 | 双路画面、队名编辑、装甲板发现、槽位分配、截图、录制与回放 | 录制需要 ffmpeg;图传槽位可手动指定 |
|
||||
| 装甲板 | 四路受击检测与灯带、整车血量、UDP 上报 | 初始 100 血,每次受击扣 3 点;队伍号在编译期选择 |
|
||||
| 固件维护 | USB 读取设备、检查固件包、选择候选槽位、备份和写后回读 | App 写入与回读已有实机记录;完整分区切换尚未验证 |
|
||||
|
||||
## 实测性能
|
||||
发送端在有限预算内提交当前帧,超时后转向新帧;本地视频通道与 WebView 会放弃滞留超过 100 ms 的旧帧。这个期限约束的是本机积压,**不代表相机到屏幕的端到端延迟**。
|
||||
|
||||
VGA 640x480,传感器产出上限 22 fps(OV5640 在此配置下的实测值)。
|
||||
> [!WARNING]
|
||||
> 固件维护会写入设备 Flash。使用前核对芯片、分区和备份,并阅读[已验证与未验证范围](docs/maintenance/firmware.md#已验证--未验证)。ESP32-S3 与 ESP32-C3 的固件不能混用。
|
||||
|
||||
| | TCP(HTTP MJPEG) | UDP(现在) |
|
||||
|---|---|---|
|
||||
| 传感器实际产出 | 22 fps | 22 fps |
|
||||
| 客户端实际收到 | **1.9 fps** | **22 fps** |
|
||||
| 实际吞吐 | 0.28 Mbit/s | 1.6 Mbit/s |
|
||||
| 丢帧 | — | 0% |
|
||||
|
||||
TCP 那一栏只用了链路能力的 1% 左右(ESP32-S3 在良好 2.4GHz 链路上 TCP 实测上限约
|
||||
20–30 Mbit/s)。**链路是空的**,帧却堵在队头阻塞后面等一个实时画面等不起的重传计时
|
||||
器。
|
||||
|
||||
UDP 没有重传与确认:缺了分片的帧整个丢弃,下一帧顶上。丢帧率实测 0%,因为带宽从来
|
||||
不是瓶颈——即便如此也只用掉 6–9%。
|
||||
|
||||
其余数字:平均帧 22 kB,约 350 包/秒,每帧约 17 个分片,客户端观测的帧间隔 7 ms。
|
||||
|
||||
## 为什么视频走 UDP 而不是 TCP
|
||||
|
||||
**延迟优先。** 丢失 5% 的画面远好于等待 500 ms 的重传。
|
||||
|
||||
**没有队头阻塞。** 一帧的任一分片卡住,后面所有帧都跟着卡——对实时画面这正好是错
|
||||
的方向。
|
||||
|
||||
**带宽不是约束。** 见上表,TCP 那栏证明了链路是空的,所以 TCP 的优势在这里换不来
|
||||
任何东西。
|
||||
|
||||
代价是丢包丢整帧。这被接受了:**撕裂的画面比过期的画面更糟**,所以接收端不做补洞,
|
||||
只等下一张完整帧。
|
||||
|
||||
## 帧率必须按计数除以墙钟
|
||||
|
||||
"22 fps" 这个数字不是对帧间隔取平均得来的,是每个窗口的**帧数增量除以窗口时长**。
|
||||
|
||||
一次 UI 轮询(33ms)之间可能完成多帧,平均间隔量的是**显示**的刷新快慢而不是**相机**
|
||||
的出帧快慢——那会把上限 22fps 的传感器报成 36fps。**一个笃定的错误数字比没有数字更
|
||||
糟**,现场你会以为还有性能余量,然后花时间去找一个不存在的问题。
|
||||
|
||||
这个错误在本项目里出现过两次(客户端的 `View::update_fps`、探测脚本的分窗打印),两
|
||||
次都是同一个形状:分子用累计值,分母用窗口值,于是报出随时间单调上升的假数字。
|
||||
[^performance]: [最新帧处理记录](docs/testing/realtime.md)、[功率与拥塞记录](docs/testing/power.md)和[延迟测量](docs/testing/latency.md)保留了配置、观察窗口与测量限制。历史数字不是所有部署环境的性能保证。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 固件
|
||||
### 1. 无板卡:先看界面
|
||||
|
||||
安装 Node.js 和 pnpm 后,在仓库根目录执行:
|
||||
|
||||
```sh
|
||||
cp .env.example .env # 填 WIFI_SSID / WIFI_PASS
|
||||
./scripts/flash.sh --monitor 20
|
||||
cd desktop
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`flash.sh` 会编译、挑选避开坏字节的 app 偏移、烧录三段(bootloader / 分区表 /
|
||||
app)、打印串口日志。ESP-IDF 位置从 `.cargo/config.toml` 读,不用先 `activate`。
|
||||
打开 [用户端预览](http://127.0.0.1:1420/) 或 [裁判端预览](http://127.0.0.1:1420/?preview=referee)。预览由浏览器合成状态和画面,无需 Rust 后端或板卡。
|
||||
|
||||
`.env`:
|
||||
> [!TIP]
|
||||
> 检查首次启动流程时,可使用 `?preview=first-run` 和 `?preview=password`。需要测试实际接收、重组和录制路径时,使用原生客户端的 `--demo` 模式;两种方式显示的都是合成数据。
|
||||
|
||||
### 2. 连接板卡:运行桌面端
|
||||
|
||||
先按[客户端说明](docs/desktop/client.md#快速开始)安装宿主平台依赖。Windows x64 需要 Rust stable MSVC、Visual Studio C++ Build Tools、Windows SDK 和 WebView2;录制另需 ffmpeg。
|
||||
|
||||
<details>
|
||||
<summary><strong>Windows x64:开发运行与安装包</strong></summary>
|
||||
|
||||
在 `desktop/` 执行。显式指定目标,并清空从根固件项目继承的 `build-std`:
|
||||
|
||||
```powershell
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm app --target x86_64-pc-windows-msvc -- --locked --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
生成 NSIS 安装包:
|
||||
|
||||
```powershell
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --ci -- --locked --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
安装包位于 `desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/`。显示名为 **RMIC::2026**,Windows 可执行文件和安装包使用 **RMIC-2026**。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS / Linux:目标选择与演示模式</strong></summary>
|
||||
|
||||
`desktop/.cargo/config.toml` 默认目标为 `aarch64-apple-darwin`。Intel Mac 使用 `x86_64-apple-darwin`,常见 Linux x64 使用 `x86_64-unknown-linux-gnu`;其他架构先用 `rustc +stable -vV` 核对 host。
|
||||
|
||||
以 Linux x64 为例,在 `desktop/` 执行:
|
||||
|
||||
```sh
|
||||
WIFI_SSID=你的2.4G网络
|
||||
WIFI_PASS=密码
|
||||
|
||||
# 以下都有默认值:
|
||||
#CAM_RES=vga # qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga
|
||||
#CAM_QUALITY=12 # 0-63,数字越小画质越好、字节越少
|
||||
#CAM_VSYNC_INVERT=1 # 若板子在硬件上反相了 VSYNC,填 0
|
||||
#WIFI_AUTH=wpa2 # 固定认证方式,仅当扫描看不到 AP 时需要(如隐藏 SSID)
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm app --target x86_64-unknown-linux-gnu -- --locked --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
凭据由 `env!` 在**编译期**读取,所以没有运行时解析路径,也不会因为忘了读 `.env` 而
|
||||
在板子上带着别人的密码。
|
||||
同一命令末尾追加 `-- --demo` 可启动原生演示模式。它将合成数据报送入正常接收与重组路径,适合检查截图、录制和命中反馈;预览与演示的区别见[客户端说明](docs/desktop/client.md#演示模式没有板子也能把整条链路跑起来)。
|
||||
|
||||
`WIFI_AUTH` 一般不用设:固件先扫描 AP,用它报告的真实认证方式去连,只有扫不到时才
|
||||
需要人工指定。指定之后**只**试这一种方式——不成功就直接报错,而不是挨个试 10 秒。
|
||||
挨个试等于在猜一个本该已知的值。
|
||||
</details>
|
||||
|
||||
### 2. 客户端
|
||||
首次启动选择用户端或裁判端。用户端需要设置现场密码并绑定本车板卡;裁判端自动分配图传槽位,装甲板按上报队伍分配。对应关系不符时,在设置中手动指定。
|
||||
|
||||
### 3. 编译图传或装甲固件
|
||||
|
||||
| 硬件 | 构建目标 | 入门文档 |
|
||||
| :--- | :--- | :--- |
|
||||
| Freenove ESP32-S3-WROOM CAM + OV5640 | `xtensa-esp32s3-espidf` | [环境、参数与烧录](docs/setup/firmware.md) |
|
||||
| ESP32-C3 装甲板主板 | `riscv32imc-esp-espidf` | [接线、规则与构建](docs/hardware/armor.md) |
|
||||
|
||||
<details>
|
||||
<summary><strong>已装好图传工具链:构建命令</strong></summary>
|
||||
|
||||
先按[固件入门](docs/setup/firmware.md)安装固定的 `esp-1.97.0.0` 工具链与 ESP-IDF 依赖。在仓库根目录准备 `.env`;已有文件时不要覆盖。
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
if (-not (Test-Path -LiteralPath .env)) {
|
||||
Copy-Item -LiteralPath .env.example -Destination .env
|
||||
}
|
||||
# 用编辑器填写 .env 中的 Wi-Fi 配置后再构建。
|
||||
.\scripts\build.ps1 -Package
|
||||
```
|
||||
|
||||
Linux / macOS(Bash):
|
||||
|
||||
```bash
|
||||
test -f .env || cp .env.example .env
|
||||
# 用编辑器填写 .env 中的 Wi-Fi 配置后再构建。
|
||||
. "$HOME/export-esp.sh"
|
||||
scripts/build.sh --package
|
||||
```
|
||||
|
||||
上述命令只构建和打包。烧录步骤、引脚与分区偏移请按[固件入门](docs/setup/firmware.md)核对;不要将 ELF 当作 BIN 写入。
|
||||
|
||||
</details>
|
||||
|
||||
Wi-Fi 凭据会编入固件。`.env` 已被 Git 忽略,分享固件前也需检查其中的凭据。
|
||||
|
||||
## 发现与收流时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Board as ESP32-S3 图传板
|
||||
participant Client as 桌面 Rust 核心
|
||||
participant View as WebView 画面
|
||||
|
||||
Board-->>Client: Hello 广播(每 2 秒,含版本与协议号)
|
||||
Client->>Board: Subscribe(每 800 ms 续订)
|
||||
Note over Board,Client: 按订阅包源地址推流;6 秒未续订则移除
|
||||
|
||||
loop 采集与发送
|
||||
Board-->>Client: JPEG 分片(27 字节头 + 最多 1400 字节载荷)
|
||||
alt 分片完整且通过接收检查
|
||||
Client->>View: 本地通道投递完整 JPEG
|
||||
View->>View: 解码并绘制最新有效帧
|
||||
else 缺片或超时
|
||||
Client->>Client: 丢弃未完成帧,等待后续帧
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
分片按 `sender_id` 和 `frame_id` 隔离,不重传缺失分片。线路格式、去重和拒收条件见 [UDP 协议](docs/protocol/udp.md)。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
| 路径 | 职责 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| [`src/`](src/) | ESP32-S3 固件 | 采集、分片、发现订阅、发送与重连 |
|
||||
| [`components/`](components/) | C 组件与 FFI | 本地相机驱动、封装及补丁 |
|
||||
| [`protocol/`](protocol/) | 共享线路协议 | 编解码与帧重组,可在主机测试 |
|
||||
| [`desktop/core/`](desktop/core/) | 桌面核心 | 网络、装甲板、配置、密码与槽位 |
|
||||
| [`desktop/src-tauri/`](desktop/src-tauri/) | Tauri 后端 | 本地通道、命令、录制与固件维护 |
|
||||
| [`desktop/src/`](desktop/src/) | React 前端 | 用户端、裁判端、维护与延迟校准 |
|
||||
| [`armor/`](armor/) | ESP32-C3 独立 workspace | 受击规则、灯带与血量广播 |
|
||||
| [`scripts/`](scripts/) | 构建与诊断工具 | 烧录、打包、版本、协议与性能探测 |
|
||||
| [`reference/armor/`](reference/armor/) | 早期 C 版参考工程 | 用于核对硬件与实现差异 |
|
||||
| [`docs/`](docs/index.md) | 项目文档 | 入门、开发、硬件、测试、发布与提案 |
|
||||
|
||||
> [!CAUTION]
|
||||
> `components/esp32-camera/target/` 是按芯片分类的**驱动源码**,包含本地补丁。它不是构建缓存,不能随其他 `target/` 目录一起清理。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
按修改范围运行对应检查。主机 Rust 测试需显式选择宿主目标,避免继承 ESP32 的构建配置;各平台说明见[开发与验证](docs/development/firmware.md)。
|
||||
|
||||
<details>
|
||||
<summary><strong>前端检查与 Windows 主机 Rust 检查</strong></summary>
|
||||
|
||||
前端命令在 `desktop/` 执行:
|
||||
|
||||
```sh
|
||||
cd client && cargo build --release
|
||||
./target/aarch64-apple-darwin/release/cam-client --list
|
||||
pnpm build
|
||||
pnpm test:stream
|
||||
pnpm test:latency
|
||||
```
|
||||
|
||||
`--list` 列出发现的板子。然后:
|
||||
以下 Rust 命令在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
cargo +stable test --locked --manifest-path protocol/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
cargo +stable test --locked --manifest-path desktop/core/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
cargo +stable check --locked --manifest-path desktop/src-tauri/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||
```
|
||||
|
||||
录制相关测试会调用 ffmpeg / ffprobe。主机测试、前端构建通过后,仍需按变更范围检查真实 WebView、板卡与网络行为。
|
||||
|
||||
</details>
|
||||
|
||||
提交使用 Conventional Commits,如 `docs(readme): 完善项目入口`。版本由 Git 历史计算,在根目录执行:
|
||||
|
||||
```sh
|
||||
cam-client # 裁判端,无需配置
|
||||
cam-client --role team --team 1 # 队伍端,首次运行问绑定
|
||||
node scripts/version.mjs --write
|
||||
node scripts/version.mjs --check
|
||||
```
|
||||
|
||||
**`client/.cargo/config.toml` 里的 `target = "aarch64-apple-darwin"` 换机器必须改。**
|
||||
client/ 嵌在固件的目录树里,会继承父目录那个 `xtensa-esp32s3-espidf` 的交叉编译配
|
||||
置,然后在依赖深处以 `libc` 找不到 `MAP_FAILED` 的形式失败。
|
||||
生成的改动记录位于 `docs/releases/`;派生文件清单见[版本维护](docs/development/versioning.md)。
|
||||
|
||||
## 发现是双向的
|
||||
## 文档与后续工作
|
||||
|
||||
单靠一个方向建立不了"谁要看我":
|
||||
文档沿用 Firefox 仓库的分类与索引思路:根目录保留项目入口和协作约定,详细说明集中到 [`docs/index.md`](docs/index.md)。
|
||||
|
||||
- 板子每 **2 秒**广播一次 Hello —— "我在线,我是谁"
|
||||
- 客户端每 **800 ms**广播一次 Subscribe —— "我是这个角色,请给我画面"
|
||||
| 要做什么 | 从哪里开始 |
|
||||
| :--- | :--- |
|
||||
| 首次准备图传板 | [固件入门](docs/setup/firmware.md) → [硬件接线](docs/hardware/camera.md) |
|
||||
| 使用或调试桌面端 | [客户端说明](docs/desktop/client.md) → [故障排查](docs/testing/troubleshooting.md) |
|
||||
| 更新设备固件 | [固件维护](docs/maintenance/firmware.md) |
|
||||
| 修改受击规则或灯带 | [装甲板说明](docs/hardware/armor.md) |
|
||||
| 复核帧率和延迟 | [最新帧记录](docs/testing/realtime.md) → [延迟测量](docs/testing/latency.md) |
|
||||
| 参与开发 | [AGENTS.md](AGENTS.md) → [开发与验证](docs/development/firmware.md) |
|
||||
|
||||
板子从订阅的**源地址**学到推流目标(所以客户端的 socket 必须固定绑 5600 端口,不能
|
||||
用临时端口)。
|
||||
实现状态与待验证事项分开记录:
|
||||
|
||||
对端靠**源地址**记忆,不发探测包。续订本来就每 800 ms 一次,所以"还活着吗"这个问题
|
||||
由续订本身回答了——超时即代表它离开了,多一份心跳状态就多一个能出错的地方。
|
||||
- [x] UDP 图传、发现订阅与桌面双模式
|
||||
- [x] 装甲板血量上报、HUD、命中反馈与录制
|
||||
- [x] USB 固件包校验、App 写入与回读
|
||||
- [ ] 完成固件维护的完整实机分区切换验证
|
||||
- [ ] 确认并实现[比赛控制与直播提案](docs/design/match-and-broadcast.md)
|
||||
|
||||
两端各有自己的超时淘汰,阈值不同:
|
||||
<details>
|
||||
<summary><strong>仓库地址</strong></summary>
|
||||
|
||||
| | 超时 | 为什么是这个量级 |
|
||||
|---|---|---|
|
||||
| 固件丢弃订阅者 | 6 s | 续订间隔 800 ms,7 倍余量,偶发丢包不会把人踢掉 |
|
||||
| 客户端移除板子 | 60 s | 只影响界面列表,不影响推流 |
|
||||
仓库已由 `ESP32S3_vt` 改名为 `simba-rmic-2026`,旧地址会重定向。SSH 地址:
|
||||
|
||||
客户端的 60 s 刻意很长:界面列表里留着一块刚重启还没广播的板子,比它短暂消失更可接
|
||||
受。而它每 5 秒才扫一次,不值得为这个上一份状态。
|
||||
|
||||
固件同时最多向 **4 个**订阅者推流(`MAX_PEERS`)。每个对端每帧要一次 `sendto`,这是帧
|
||||
CPU 成本的倍数;三台客户端(两队 + 裁判)够用,且不让一个陈旧的地址条目拖慢每一帧。
|
||||
|
||||
## 身份:为什么用 MAC 派生
|
||||
|
||||
`protocol::id_from_mac()` 用 FNV-1a 从 MAC 派生 `sender_id`,避开 0(裁判终端的保留
|
||||
值)。
|
||||
|
||||
**用 MAC 而不是 IP**,因为客户端要把绑定存进配置文件并在重启后继续有效——DHCP 换地
|
||||
址是常态,MAC 不会变。用 MAC 也意味着烧同一份固件即可,不需要任何"我是谁"的配置。
|
||||
|
||||
## 线路格式
|
||||
|
||||
27 字节头 + 至多 1400 字节载荷,全部小端。细节在 `protocol/README.md`,那里有完整
|
||||
的字节图和每个拒收条件。
|
||||
|
||||
三处关键决定:
|
||||
|
||||
- **27 字节不凑整。** 头只占每个 1427 字节数据报的 1.9%,而为对齐在两端各写一段
|
||||
手写偏移常量,是引入静默错位的更划算的代价。
|
||||
- **每个数据报 1427 字节,低于 1500 MTU。** 超 MTU 的数据报要么被 AP 丢、要么被 IP
|
||||
分片,两种情况的丢包都记在应用账上而不是网络账上——后者本来更容易排查。
|
||||
- **`version` 不匹配就拒收**,而不是按错误布局继续解析。后者表现为随机崩溃或画面错
|
||||
乱,比拒绝连接难查得多。
|
||||
|
||||
## 硬件
|
||||
|
||||
Freenove ESP32-S3-WROOM CAM,ESP32-S3-WROOM-1-N16R8(16 MB flash + 8 MB octal
|
||||
PSRAM),OV5640。
|
||||
|
||||
### 引脚
|
||||
|
||||
`src/camera.rs` 的 `pins` 模块:
|
||||
|
||||
| | GPIO |
|
||||
|---|---|
|
||||
| RESET | 0 |
|
||||
| XCLK | 15 |
|
||||
| PCLK | 13 |
|
||||
| VSYNC | 6 |
|
||||
| HREF | 7 |
|
||||
| SIOD (SDA) | 4 |
|
||||
| SIOC (SCL) | 5 |
|
||||
| DATA[0..8] | 11, 9, 8, 10, 12, 18, 17, 16 |
|
||||
|
||||
### 千万不要套用别的板子的引脚表
|
||||
|
||||
这块板子的 GPIO 分配是死的:
|
||||
|
||||
| 范围 | 占用 |
|
||||
|---|---|
|
||||
| 26–32 | SPI flash |
|
||||
| 33–37 | octal PSRAM |
|
||||
| 19 / 20 | USB |
|
||||
| 43 / 44 | console |
|
||||
| 45 / 46 | 模组内部 |
|
||||
|
||||
直接套用 AI-Thinker ESP32-CAM 的引脚表会有 **7 根冲突**。冲突的表现不是"某根线不
|
||||
通",而是 `PIN_FUNC_SELECT` 把某个外设接到了 flash 或 PSRAM 上,**CPU 在传输中途挂
|
||||
死**——没有报错、没有回溯、串口直接静默。
|
||||
|
||||
改引脚前先对照上面这张表。
|
||||
|
||||
### 那块坏掉的 flash 字节
|
||||
|
||||
测试用的这块模组在 `0xFADE7` 有个字节的 bit 5–7 **写不进去**。镜像放在哪里会决定哪
|
||||
个镜像字节落到这个地址,落上去就校验失败、bootloader 拒绝启动。
|
||||
|
||||
镜像本身不因位置而变,所以移动 app 分区就够了。`scripts/flash.sh` 会挑最低的 64 KB
|
||||
对齐、且该地址处字节 flash 真的能存的偏移,写回 `partitions.csv`,然后重新编译
|
||||
(重编译会让镜像位移,所以编译后再验证一次)。
|
||||
|
||||
**换到健康硬件**:把 `factory` 分区放回 `0x10000`,删掉 `flash.sh` 里的
|
||||
`BAD_FLASH_ADDR`,别的什么都不用改。
|
||||
|
||||
Flash 跑 DIO @ 40 MHz 而不是这颗片子的标称 QIO/80 MHz:测试模组的坏字节在 quad 模式
|
||||
下会被推到 boot loop。QIO 对**代码执行**速度的提升是明显的(程序从 flash 跑,cache
|
||||
miss 直接打在这条总线上),健康硬件上值得换回去试。
|
||||
|
||||
### `PSRAM DMA` 是故意关掉的
|
||||
|
||||
开着的话,esp32-camera 让 GDMA 直接把帧缓冲写进 PSRAM,然后**只在最后一个 DMA 节点
|
||||
(1 KB)里**找 JPEG 结束标记。标记落在窗口外就丢帧并报 `NO-EOI`——在这块板子上是大部
|
||||
分的帧,几帧之后驱动干脆不再交出任何帧,而且不恢复。
|
||||
|
||||
关掉之后驱动 DMA 到内部 RAM 的中转缓冲,再 memcpy 进 PSRAM。每帧多一次约 23 KB 的
|
||||
memcpy,但搜索整个缓冲找标记。对一条能用的图传链路,这是对的取舍。
|
||||
|
||||
### CPU 频率
|
||||
|
||||
**`CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y`** 是 `sdkconfig.defaults` 里最值钱的一行,
|
||||
而且免费。
|
||||
|
||||
ESP-IDF v5.5 的 Kconfig 把 ESP32-S3 默认成 **160** MHz(见
|
||||
`components/esp_system/port/soc/esp32s3/Kconfig.cpu`),所以默认编译悄悄浪费了一半
|
||||
CPU。相机采集、esp32-camera 里的 JPEG 搜索、lwIP、发送路径全是 CPU 密集的,这一行一
|
||||
次性把它们全部搬上去。
|
||||
|
||||
### WiFi 缓冲留在内部 RAM
|
||||
|
||||
`CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=n`。
|
||||
|
||||
这个值曾经按"PSRAM 很空闲"的直觉写成 `y`,方向恰好相反:WiFi 描述符和 TCP 段**每个
|
||||
包都要碰**,内部 SRAM 比隔着内存总线的 PSRAM 快一个数量级。把它们放进 PSRAM 的表现是
|
||||
高延迟和差吞吐,无论 PSRAM 还剩多少。
|
||||
|
||||
帧缓冲相反——它大、相对少碰,留在 PSRAM。
|
||||
|
||||
### 相机任务钉在 CPU1
|
||||
|
||||
`CONFIG_CAMERA_CORE0=n`(即跑在 CPU1)。WiFi 任务钉在 CPU0。esp32-camera 的采集任务
|
||||
时间是短促且延迟敏感的(DMA 完成、JPEG 标记搜索、拷进帧缓冲),在共享核上会被抢占,
|
||||
每一帧都被拉长。双核芯片上没有理由共享。
|
||||
|
||||
## vendored esp32-camera
|
||||
|
||||
`components/esp32-camera/` 是上游 2.1.7 的副本,因为组件注册表的版本求解器找不到它。
|
||||
|
||||
它打了一处补丁:`target/esp32s3/ll_cam.c` 里的 `CAM_IO_MUX_REG()`。这个宏负责把引脚
|
||||
接到 I2C(OV5640 的 SCCB 走 I2C),不接的话传感器根本不会被发现。
|
||||
|
||||
**注意这个目录叫 `target/`,但它不是构建目录。** 它是上游组件的按芯片分目录
|
||||
(`esp32/`、`esp32s2/`、`esp32s3/`),里面是 `ll_cam.c` 等源码,包含上述补丁。所以
|
||||
`.gitignore` 里三条 `target/` 规则全部锚定在根目录——这是有意的。
|
||||
|
||||
## 改过 C 文件之后
|
||||
|
||||
```sh
|
||||
rm -rf target/xtensa-esp32s3-espidf/release/build/esp-idf-sys-*
|
||||
```text
|
||||
git@git.simba-robotics.com:PRAM/simba-rmic-2026.git
|
||||
```
|
||||
|
||||
不删不会重新编译,你的补丁看起来像完全没生效。
|
||||
|
||||
## 摄像头端不编码,客户端不编码
|
||||
|
||||
OV5640 的 JPEG 编码器**在传感器内部**。DVP 接口出来的一帧要么是 JPEG,要么是 YUV 裸
|
||||
数据。
|
||||
|
||||
不编码的话,单帧从约 21 KB 涨到 **450 KB**(21 倍),而 ESP32-S3 **没有硬件 JPEG
|
||||
编码器**(整个 ESP32 系列只有 P4 有,`esp_driver_jpeg` 里
|
||||
`CONFIG_IDF_TARGET_ESP32P4` 是写死的)。
|
||||
|
||||
同理,客户端也不编码:板子出的就是传感器里的 JPEG,客户端只解码。ESP32-S3 也没有硬
|
||||
件 JPEG 解码器。
|
||||
|
||||
## 为什么收包和解码要分开线程
|
||||
|
||||
一张 VGA 的 JPEG 解码要几毫秒。放在收包线程里会挤进数据报之间,造成**链路上本不存在
|
||||
的丢帧**——不是真的丢了网络包,是应用来不及取走 socket 缓冲。
|
||||
|
||||
`slint::Image` 不是 `Send`,所以 worker 线程只能处理纯数据(`Vec<u8>` + 尺寸),`Ima
|
||||
ge` 的构造必须留在事件循环。这条约束决定了整个客户端的线程结构,细节在
|
||||
`client/README.md`。
|
||||
|
||||
## 排查
|
||||
|
||||
| 现象 | 原因 |
|
||||
|---|---|
|
||||
| 板子无输出、串口静默 | GPIO 冲突。CPU 在传输中途挂了,见上面那张占用表 |
|
||||
| 几帧后停止出图,报 `NO-EOI` | `PSRAM DMA` 被打开了 |
|
||||
| 板子在线但 `0 clients subscribed` | 订阅包格式不对。`Subscribe` 的 `role` 字节紧跟 27 字节头,在偏移 27 |
|
||||
| 一直接到但画面偶尔解不出来 | 帧重组的问题。**先怀疑你的测量工具**:本项目的探测脚本就因为重组状态没按 `frame_id` 隔离,误报过 34 张坏帧 |
|
||||
| `libc` 找不到 `MAP_FAILED` | `client/.cargo/config.toml` 的 target 没改成宿主三元组 |
|
||||
| 串口看不到任何输出 | 非 TTY 下 `espflash monitor` 不可用,用 `/tmp/rd2.py` 裸读 |
|
||||
| WiFi 连不上 | SSID 必须在 2.4GHz。5G 频段看不见 |
|
||||
| 裁判端两块画面宽窄不一 | 检查那两格是否都写了相同的 `preferred-width`,且占位文字是绝对定位(`x`/`y`)而不是普通布局 |
|
||||
| 放大后画面没变清晰 | `image-fit` 必须跟着 zoom 在 `contain`/`fill` 之间切。只用 `contain` 的话画面不会跟着放大 |
|
||||
|
||||
独立核对线路格式:
|
||||
|
||||
```sh
|
||||
python3 scripts/udp_probe.py 10
|
||||
```
|
||||
|
||||
它手写了每一个协议常量,**不依赖 `protocol` crate**——正因为如此,如果 crate 的编码
|
||||
有错,脚本会立刻报出不一致。
|
||||
|
||||
## 三份文档
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 根 `README.md` | 你在这里。拓扑、性能、构建烧录、硬件坑 |
|
||||
| `protocol/README.md` | 线路格式、帧重组的取舍、每个拒收条件 |
|
||||
| `client/README.md` | 命令行、绑定流程、Slint 的硬约束、线程模型 |
|
||||
|
||||
## 装甲板(预留)
|
||||
|
||||
`protocol::ArmorHealth`、`ARMOR_PORT`(5601)、`MsgType` 5 号都已就位。客户端有真实的
|
||||
`ArmorSource` trait,当前实现是 `NoArmorYet`。
|
||||
|
||||
**界面显示占位符而不是 0。** 0 看起来像真值——现场看到两个 0 会以为对方已经没血了。
|
||||
|
||||
接上以后板子向 `ARMOR_PORT` 发这个结构,客户端实现 `ArmorSource` 即可,界面和其余代
|
||||
码都不用改。
|
||||
</details>
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,13 @@
|
||||
# 装甲板(ESP32-C3)
|
||||
|
||||
独立的 Rust + ESP-IDF workspace。主板处理四路压电片、四路 WS2812 灯带和整车血量,并经 UDP 向 RMIC::2026 广播。三块从板直接接线到主板,不含 MCU。
|
||||
|
||||
从仓库根目录执行:
|
||||
|
||||
```bash
|
||||
scripts/build_armor.sh
|
||||
scripts/flash_armor.sh
|
||||
python3 scripts/armor_probe.py 15
|
||||
```
|
||||
|
||||
接线、规则、工具链版本、测试记录与实现限制见[装甲板说明](../docs/hardware/armor.md)。它使用 ESP32-C3 / RISC-V 目标,不能烧录 S3 图传固件。其他主题见[文档首页](../docs/index.md)。
|
||||
@@ -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,365 +0,0 @@
|
||||
# client
|
||||
|
||||
Slint 桌面客户端。**两种身份,界面对应两个不同的组件。**
|
||||
|
||||
| 身份 | 画面 | 血量 | 需要绑定 |
|
||||
|---|---|---|---|
|
||||
| `--role referee`(默认) | 两块板左右并排 | 双方 | 否 |
|
||||
| `--role team` | 一块画面 | 自己那一边 | **是,选定后不可更改** |
|
||||
|
||||
```sh
|
||||
cd client && cargo build --release
|
||||
./target/aarch64-apple-darwin/release/cam-client
|
||||
```
|
||||
|
||||
产物路径带三重名(`target/aarch64-apple-darwin/release/`)是因为下面那节解释了原因。
|
||||
|
||||
## 命令行
|
||||
|
||||
```text
|
||||
--role <team|referee> 身份。默认 referee(无需配置即可看两路)
|
||||
--team <0|1> 自己属于哪一边,仅 team 端用
|
||||
--camera-id <0xID> 绑定到某块板子(MAC 派生,重启不变)
|
||||
--camera <IP> 向哪块板子发订阅(默认 255.255.255.255 广播)
|
||||
--config <路径> 配置文件位置
|
||||
--list 列出发现的摄像头后退出
|
||||
-h, --help 显示这段帮助
|
||||
```
|
||||
|
||||
常用:
|
||||
|
||||
```sh
|
||||
cam-client --list # 先看看场上有几块板子
|
||||
cam-client --role team --team 1 # 队伍一(首次运行会问绑定)
|
||||
cam-client --role team --team 1 --camera-id 0x3D4D8A9A # 跳过选择直接绑
|
||||
```
|
||||
|
||||
`--list` 会等 5 秒收广播周期再打印,所以它不是即时的。
|
||||
|
||||
## 配置文件
|
||||
|
||||
默认 `~/.config/cam-client/config.toml`,`--config` 可改。
|
||||
|
||||
```toml
|
||||
role = "team"
|
||||
team = 1
|
||||
camera_id = 0x3D4D8A9A
|
||||
```
|
||||
|
||||
**没有配置文件也能跑**——裁判端就是无配置启动的,直接显示两路。队伍端缺配置时进入
|
||||
摄像头选择界面。
|
||||
|
||||
命令行覆盖配置文件,反过来不成立(只在内存里改)。这样现场可以把一台机器临时当另一
|
||||
台用,不动文件。
|
||||
|
||||
TOML 的序列化是手写的(`config.rs`),没有引 `toml` crate。配置只有四个标量字段,
|
||||
引一个解析器不值得;而手写解析换来的是零依赖,以及 `save` 与 `parse` 严格对称这一点
|
||||
可以被一个测试覆盖。**忘记同步这两个方向的改动是这类代码最典型的 bug**,所以它们挨着
|
||||
放,并且有 `parses_a_team_config` 盯着。
|
||||
|
||||
## 绑定流程
|
||||
|
||||
队伍端首次运行:
|
||||
|
||||
1. 接收线程开始收广播,板子每 2 秒发一次 Hello
|
||||
2. 界面列出发现的所有板子(id + IP + 是否在线)
|
||||
3. 点一个 → 写进配置文件
|
||||
4. 此后配置里已有 `camera_id`,界面**不再出现选择页**
|
||||
|
||||
"选定不可更改"就落在这条路径上:没有 unbind 命令,`--camera-id` 是命令行覆盖而不是改
|
||||
文件。
|
||||
|
||||
改绑的办法只有两种:手改配置文件,或 `rm ~/.config/cam-client/config.toml`。
|
||||
|
||||
### `Box::leak` 不是疏忽
|
||||
|
||||
绑定回调拿到的 `CallbackHandle` **必须活到窗口关闭**。它是 `Copy`,但 `Drop` 会注销回
|
||||
调。
|
||||
|
||||
这里踩过一个坑:一开始用 `mem::forget`,编译器提示它对 `Copy` 类型无效——这个提示是
|
||||
对的,`mem::forget` 传的是值,句柄被复制进闭包捕获后原值立刻 drop,回调在第一次点击
|
||||
后就注销了,**第二次点击什么也不会发生**。`Box::leak` 让所有权真的离开作用域。
|
||||
|
||||
窗口里的 `Timer` 同理:被 drop 即停止,也泄漏掉。
|
||||
|
||||
## Slint 的三条硬约束
|
||||
|
||||
这三条都不是文档里写的,是撞出来的。
|
||||
|
||||
**`Weak<App>` 和 `slint::Image` 都不是 `Send`。**
|
||||
|
||||
`slint::Image` 尤其致命:JPEG 必须在别的线程解码(一张 VGA 的 JPEG 要几毫秒,放在收包
|
||||
线程里会挤进数据报之间,造成链路上本不存在的丢帧),但解码出来的 RGBA 无法跨线程交给
|
||||
UI。
|
||||
|
||||
解法是分两段:worker 线程只处理纯数据(`Vec<u8>` + 尺寸),`slint::Image` 的构造留在
|
||||
事件循环里。`main.rs` 的 `decoded` 槽位就是这个用途,`take_decoded()` 是那段跨界。
|
||||
|
||||
**`ModelRc` 没有 `From<Vec<T>>`。**
|
||||
|
||||
只有 `From<Rc<M>>`、`From<&[T]>`、`From<[T; N]>`。所以要写:
|
||||
|
||||
```rust
|
||||
std::rc::Rc::new(slint::VecModel::from(v)).into()
|
||||
```
|
||||
|
||||
而不是 `v.into()`。这个编译错误会在每个第一次用它的人身上重现一次。
|
||||
|
||||
**`.slint` 的语法限制。**
|
||||
|
||||
- `Rectangle` 这类基础元素**不能**挂 `callback`,要用 `TouchArea`
|
||||
- `in` 属性不能从内部赋值
|
||||
- 没有 `RowLayout`,用 `HorizontalLayout`
|
||||
- 窗口不能同时给 `width`/`height` 和 `min-*`
|
||||
|
||||
## 线程
|
||||
|
||||
三个,加上 Slint 自己的:
|
||||
|
||||
| 线程 | 做什么 | 为什么单独 |
|
||||
|---|---|---|
|
||||
| `udp-reader` | 收包、解析、重组 | 不能让解码挤进数据报之间 |
|
||||
| `jpeg-decode` | JPEG → RGBA | 几毫秒的活 |
|
||||
| 事件循环 | 取 RGBA、造 `Image`、刷新界面 | `Image` 只能在拥有它的线程构造 |
|
||||
| `slint::Timer` 回调(33ms) | 推进上面那条流水线 | 显示不需要 22fps 重绘 |
|
||||
|
||||
33ms 的轮询间隔不是每帧一次:更快只是让事件循环和接收路径抢 CPU。
|
||||
|
||||
### 槽位一旦占定就不换人
|
||||
|
||||
裁判端有两个槽位,但**不要按 id 顺序重新对号入座**。
|
||||
|
||||
原来那版是:
|
||||
|
||||
```rust
|
||||
let ids: Vec<u32> = cameras.iter().map(|c| c.id).take(2).collect();
|
||||
let wanted = ids.get(i).copied();
|
||||
```
|
||||
|
||||
看起来稳定,其实不是:低 id 的板子一离线,`ids` 就缩短成 `[B]`,B 落到**左**槽 ——
|
||||
画面左右互换。现场看非常像"两路串了",而实际上只是排序。
|
||||
|
||||
现在的规则:还**在线**的槽位保持不动;空槽或已掉线的槽位才从还没被占用的板子里按 id
|
||||
顺序取第一个。
|
||||
|
||||
```rust
|
||||
let still_here = view.sender_id.is_some_and(|id| cameras.iter().any(|c| c.id == id));
|
||||
if still_here { continue; }
|
||||
```
|
||||
|
||||
换人时优先挑一个和当前不同的板子,否则会出现"板子短暂掉线又回来,槽位原地打转",
|
||||
看起来像卡住。
|
||||
|
||||
`view.sender_id` 变了才重建 `View`(清零帧率窗口),否则每 33ms 重置一次统计,帧率
|
||||
永远算不出来。
|
||||
|
||||
## 帧率:按计数除以墙钟
|
||||
|
||||
```rust
|
||||
self.fps = (stats.frames_shown - self.window_frames) as f32 / window.as_secs_f32();
|
||||
```
|
||||
|
||||
不是对帧间隔取平均。
|
||||
|
||||
一次 UI 轮询(33ms)之间可能完成多帧,平均间隔量的是**显示**的刷新快慢而不是**相机**
|
||||
的出帧快慢——那会把上限 22fps 的传感器报成 36fps。**一个笃定的错误数字比没有数字更糟**,
|
||||
现场你会以为还有性能余量。
|
||||
|
||||
## 血量:接口就位,数据源没有
|
||||
|
||||
```rust
|
||||
let armor: Arc<dyn ArmorSource> = Arc::new(NoArmorYet);
|
||||
```
|
||||
|
||||
`ArmorSource` 是真的 trait,`protocol::ArmorHealth` 和 `ARMOR_PORT` 都已就位。装甲板接上
|
||||
后只需换掉这一行,`render_health()` 会自动走 `Some(hps)` 分支,界面和其余代码都不用改。
|
||||
|
||||
现在没有数据源,所以**显示占位符而不是 0**。0 看起来像真值——现场看到两个 0 会以为
|
||||
对方已经没血了,然后开始找裁判理论。
|
||||
|
||||
占位分支依然给出 4 个血格(值全 0),因为界面用 `available` 决定颜色:空数组会让整个
|
||||
占位框消失,看起来像布局出错,而不是像"还没有数据"。
|
||||
|
||||
## 防作弊:release 队伍端只接收绑定的那一块
|
||||
|
||||
对抗赛里两队都用这套系统看自己小车的图传,所以要防止对手开第二个窗口连到另一队
|
||||
的板子。
|
||||
|
||||
**关键是这件事必须做在接收端,而不是界面里。**
|
||||
|
||||
一开始我按"release 下不显示 id 和 IP"理解,那样等于没做:
|
||||
|
||||
- 广播的 Hello 里就有 MAC 派生的 `sender_id` 和 IP。对手 `tcpdump` 一下全都知道,
|
||||
界面藏不藏毫无关系。
|
||||
- 数据本来已经收下并解码了。`.slint` 只控制显示,隐藏一行文字不阻止任何事。
|
||||
|
||||
所以是 `Receiver::restrict_to`:队伍端 release 且已绑定时,只重组、只解码、只统计
|
||||
绑定的那一块。对手的板子即使把流推到了本机端口(IP 已泄露,`DEFAULT_PORT` 是公开
|
||||
常量),数据也不进入任何管线。
|
||||
|
||||
```rust
|
||||
let restrict = if config.role == Role::Team && !TEAM_DIAGNOSTICS {
|
||||
config.camera_id.filter(|_| args.camera_id.is_none())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
```
|
||||
|
||||
四种情况不限制,各有理由:
|
||||
|
||||
| 情况 | 为什么不限制 |
|
||||
|---|---|
|
||||
| 裁判端 | 中立第三方,本来就要同时看两块板子 |
|
||||
| debug 构建 | 开发时需要看到全场 |
|
||||
| 队伍端未绑定 | 选择界面需要列出候选,否则无从选起 |
|
||||
| `--camera-id` 覆盖了配置 | 显式指定了要看哪块,是有意的 |
|
||||
|
||||
### 遥测按编译模式门控
|
||||
|
||||
```rust
|
||||
const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
|
||||
```
|
||||
|
||||
fps / 丢帧率 / 数据报数 / 帧间隔这些在队伍端 release 下**不产出**(空字符串,界面
|
||||
也不显示),stdout 的诊断日志同样门控。
|
||||
|
||||
由编译模式决定而不是命令行参数:命令行开关意味着二进制里带着那套东西,忘开一次
|
||||
就泄露了。
|
||||
|
||||
裁判端不受影响——双方遥测对判罚有用。
|
||||
|
||||
### `--list` 在 release 队伍端直接拒绝
|
||||
|
||||
它会打印场上所有板子的 id 和 IP,正是要防的那件事。与其列出来再叮嘱"别看",不如
|
||||
不给:
|
||||
|
||||
```
|
||||
Error: "--list 需要 debug 构建:它会列出场上所有板子的 id 和 IP。"
|
||||
```
|
||||
|
||||
### 选择界面保留
|
||||
|
||||
现场换机器时还要用它重新绑定。数据侧的闸门已经足够,界面列出候选不构成额外泄露
|
||||
(对手需要的是实时画面,不是 id 字符串)。
|
||||
|
||||
### 这挡不住什么
|
||||
|
||||
诚实地说:**IP 仍然通过 Hello 广播泄露。** 对手知道你的 IP 之后,可以自己发一个
|
||||
`Subscribe` 过来——固件会向任何订阅者推流,那道闸门在他的客户端上,不在你的。
|
||||
|
||||
真要挡住需要固件侧做配对(首次绑定发一个 token,固件只接受带 token 的订阅),
|
||||
代价是固件重启就失配。这次没做,因为需求定的是"避免一个队伍通过开其他窗口连接到
|
||||
其他队伍的图传",而这需要对手主动在自己机器上装这套客户端——纯 UI 或客户端侧的
|
||||
措施挡不住它。**这一条如果要真正封住,得回固件加 token。**
|
||||
|
||||
## 缩放与自适应布局
|
||||
|
||||
### 滚轮缩放 + 拖动平移
|
||||
|
||||
Slint 的 `Image` 没有缩放因子属性,所以实现是:把 `Image` 的 `width`/`height` 按
|
||||
`zoom` 放大后放进 `clip: true` 的容器,靠 `offset-x`/`offset-y` 平移。
|
||||
|
||||
- 滚轮改 `zoom`(每次 ×1.25,上限 8 倍)
|
||||
- 拖动改 `offset`(只在 `zoom > 1` 时生效——此时才有可移动的空间)
|
||||
- 双击复位
|
||||
- 缩回 1.0 时自动归位
|
||||
|
||||
最后一条容易漏:缩回 1.0 后若保留偏移,下一次放大会从偏移位置开始,看起来像
|
||||
"跳了一下"。
|
||||
|
||||
`image-fit` 必须跟着 `zoom` 切:
|
||||
|
||||
```
|
||||
image-fit: root.zoom <= 1.0 ? contain : fill;
|
||||
```
|
||||
|
||||
`contain` 在 `zoom = 1` 时保证完整可见且不变形,`fill` 才让画面跟着放大。**只用
|
||||
`contain` 的话放大不会有任何视觉变化**——这个 bug 很容易写出来,因为界面上确实
|
||||
变大了,只是画面没跟上。
|
||||
|
||||
裁判端两路的缩放**互相独立**:滚轮只作用于鼠标所在的那一路。
|
||||
|
||||
### 为什么两格必须等宽
|
||||
|
||||
裁判端曾经放大时左边占 55%、右边 45%;空的那一侧更极端时会被压成一条缝。
|
||||
|
||||
原因不是缺 `stretch`。`HorizontalLayout` 先按内容的**最大**需求分配,剩下的才按
|
||||
stretch 分。左格写"已连接,等待画面…"(8 字),右格"未收到画面"(5 字),左格就
|
||||
多要十几个像素。而没有画面的一格最小宽度接近 0,可以被压没。
|
||||
|
||||
三处一起改:
|
||||
|
||||
1. **占位文字改成绝对定位**(给出 `x`/`y`),不参与父级尺寸计算。文字长短本不该
|
||||
决定画面大小。
|
||||
2. **两格统一 `preferred-width` + `horizontal-stretch: 1`**,需求变成相同,于是精确
|
||||
平分。`min-width: 320px` 兜底,保证任何一侧至少有一个能看画的宽度。
|
||||
3. **缩放指示与复位按钮也绝对定位**。否则它们一出现就改变这一格的尺寸需求,把不
|
||||
等宽重新引进来——而且是在用户放大之后才出现,最难排查。
|
||||
|
||||
`preferred-width` 只接受长度或字面量 `100%`,不接受 `50%`。
|
||||
|
||||
### 启动即最大化
|
||||
|
||||
```rust
|
||||
ui.window().set_maximized(true);
|
||||
ui.show()?;
|
||||
```
|
||||
|
||||
**必须在 `show()` 之前**——之后调用会被后端当成"用户点了最大化按钮",而不是初始
|
||||
状态。
|
||||
|
||||
取 `set_maximized` 而不是 `set_fullscreen`:全屏会盖掉菜单栏和 Dock,切出去要按快
|
||||
捷键。裁判终端偶尔要切到别的窗口核对数据。
|
||||
|
||||
## 内存:解码只留最新一张
|
||||
|
||||
解码线程在写入前先 `retain` 掉同一路的旧图(`main.rs`)。
|
||||
|
||||
一张 VGA 的 RGBA 是 1.2 MB。两路各留两张就是 5 MB,而延迟换清晰度在这里没有意义——
|
||||
观战的人要的是最新的画面,不是最完整的那张已经过时的。
|
||||
|
||||
## 为什么客户端收全部板子的视频
|
||||
|
||||
队伍端也订阅了所有板子,然后在**界面层**过滤到自己那一个。
|
||||
|
||||
反过来的做法(板子按订阅者的 role 决定推给谁)会引出一个不对称:板子得维护"谁是队伍
|
||||
一谁是队伍二"的认知,但那块知识本来就在客户端。裁判端同时要两路的事实说明这个
|
||||
认知不能放在板子上。
|
||||
|
||||
代价是队伍端浪费一份几乎相同的图像数据——实测 264 kB/s,占链路 6–9%,可以接受。
|
||||
|
||||
## 换机器要改 `client/.cargo/config.toml`
|
||||
|
||||
```toml
|
||||
[build]
|
||||
target = "aarch64-apple-darwin"
|
||||
```
|
||||
|
||||
**这行必须改**,它是本文件唯一的存在理由。
|
||||
|
||||
client/ 嵌在固件的目录树里,cargo 会向上找到 `../.cargo/config.toml`,那里面
|
||||
`target = "xtensa-esp32s3-espidf"` 加一个 ldproxy linker,只有在 ESP-IDF 交叉编译里才成立。
|
||||
没有这个覆盖,桌面客户端会被交叉编译到 Xtensa,然后在依赖深处失败——`libc` 找不到
|
||||
`MAP_FAILED` 之类的东西,因为它正在为一个没有 `mmap` 的嵌入式目标编译。
|
||||
|
||||
父配置的其它设置对非 ESP 目标都不适用,所以只需覆盖 target 一项。
|
||||
|
||||
## 线程安全清单
|
||||
|
||||
- 接收线程 → 其它:`Arc<Discovery>` / `Arc<LatestFrames>`,内部 `Mutex`
|
||||
- 解码线程 → 事件循环:`Arc<Mutex<Vec<(u32, DecodedFrame)>>>`
|
||||
- 事件循环 → 界面:直接调用(同一个线程)
|
||||
- 绝不跨线程:`Weak<App>`、`slint::Image`、`ModelRc`
|
||||
|
||||
## 依赖
|
||||
|
||||
| crate | 为什么 |
|
||||
|---|---|
|
||||
| `slint` / `slint-build` | 界面。macOS 默认渲染器是 femtovg,不需要额外系统库 |
|
||||
| `image` | 只为 `jpeg` feature,解码用 |
|
||||
| `protocol`(path) | 共享线路格式。用 path 而不是复制,两边不可能悄悄分叉 |
|
||||
|
||||
`image` 关掉了 default features 只开 jpeg:这个客户端不读 PNG/TIFF/webp 以外的任何
|
||||
东西,也不写任何文件。
|
||||
|
||||
这一侧**没有任何东西重新编码**。板子出的就是传感器里的 JPEG,客户端只解码。
|
||||
@@ -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,757 +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);
|
||||
|
||||
/// 队伍端在 release 下是否显示技术诊断信息。
|
||||
///
|
||||
/// 对抗赛里两队都用这套系统看自己小车的图传,所以 release 给现场用、debug 给开发
|
||||
/// 用。这个开关由**编译模式**决定而不是命令行参数——命令行开关意味着二进制里带着
|
||||
/// 那套东西,忘开一次就泄露了。
|
||||
///
|
||||
/// 裁判端不受它影响:裁判是中立方,双方遥测对判罚有用。
|
||||
const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
|
||||
|
||||
/// 帧率统计窗口。
|
||||
///
|
||||
/// 按计数除以墙钟时间,而不是对帧间隔取平均。一次轮询之间可能完成多帧,所以平均间隔
|
||||
/// 量的是**显示**的刷新快慢而不是**相机**的出帧快慢——那会把上限 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()
|
||||
);
|
||||
|
||||
// 队伍端在 release 下已经绑定时,把接收端限制到那一块。
|
||||
//
|
||||
// 这是本次改动里唯一有实际防护的一处:`.slint` 里不显示 id 只是藏起文字,而
|
||||
// 广播的 Hello 早就把 MAC 派生的 id 和 IP 送出去了(对手 tcpdump 一下就有)。
|
||||
// 在**接收端**拒绝才是真的:不重组、不解码、不进 discovery。
|
||||
//
|
||||
// 四种情况不限制:
|
||||
// - 裁判端(中立第三方,本来就要两块板子)
|
||||
// - debug 构建(开发时要能看到全场)
|
||||
// - 队伍端还没绑定(选择界面需要列出候选)
|
||||
// - 命令行临时用别的板子跑(--camera-id 覆盖配置)
|
||||
let restrict = if config.role == Role::Team && !TEAM_DIAGNOSTICS {
|
||||
config.camera_id.filter(|_| args.camera_id.is_none())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rx = Arc::new(Receiver::start(
|
||||
args.camera,
|
||||
config.role.to_protocol(),
|
||||
restrict,
|
||||
)?);
|
||||
let discovery = rx.discovery();
|
||||
let frames = rx.frames();
|
||||
|
||||
if args.list {
|
||||
// 队伍端 release 下 `--list` 会列出场上所有板子(包括对手的 id 和 IP),
|
||||
// 正是要防的那件事。所以这里直接拒绝,而不是列出来再让人别看。
|
||||
if config.role == Role::Team && !TEAM_DIAGNOSTICS {
|
||||
return Err(
|
||||
"--list 需要 debug 构建:它会列出场上所有板子的 id 和 IP。\
|
||||
release 的队伍端请直接用已绑定的配置运行。"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
// 等几个广播周期,让列表有内容。
|
||||
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()?;
|
||||
|
||||
// 启动即最大化。必须在 show() 之前:之后调用会被后端当成"用户点了最大化
|
||||
// 按钮"。取 set_maximized 而不是 set_fullscreen,因为全屏会盖掉菜单栏和
|
||||
// Dock,裁判终端要切去别的窗口核对数据。
|
||||
ui.window().set_maximized(true);
|
||||
ui.show()?;
|
||||
|
||||
// 接收端的限制在构造 Receiver 时已经按配置设好了(见上面 `restrict`),
|
||||
// 这里不重复设:只有选择界面上新选定的那一次需要改它。
|
||||
let rx_for_pick = Arc::clone(&rx);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// 选定之后把接收端也限制到这一块。此后场上别人的板子即使把流推到
|
||||
// 本机端口,也不会被重组或解码。
|
||||
//
|
||||
// 解除限制(传 None)要等到重启:配置里有了 camera_id 就会走绑定
|
||||
// 分支,限制随之恢复,两者不会不一致。
|
||||
rx_for_pick.restrict_to(Some(id));
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// 遥测日志同样按编译模式门控:release 的队伍端不往 stdout 打帧率/丢帧这些
|
||||
// 能被对手读到的数字。
|
||||
if TEAM_DIAGNOSTICS && !view.reported && stats.frames_shown > 0 {
|
||||
log_diagnostics(&stats, bound);
|
||||
view.reported = true;
|
||||
}
|
||||
|
||||
ui.set_stats(render_camera_stats(
|
||||
&cameras,
|
||||
bound,
|
||||
&stats,
|
||||
view.fps,
|
||||
TEAM_DIAGNOSTICS,
|
||||
));
|
||||
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.window().set_maximized(true);
|
||||
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();
|
||||
|
||||
// 槽位对应**队伍**,不是"第 N 块发现的板子"。
|
||||
//
|
||||
// 两条规则:
|
||||
//
|
||||
// 1. 一次只填一个空槽,按 id 顺序。之前两块都空时每轮都从
|
||||
// `online_cameras()` 取前两个,于是只有一块板在场时两块槽位显示
|
||||
// 同一块画面——右半边不再是"队伍二",而是队伍一的复制。裁判看到
|
||||
// 两边一模一样,会以为两队的图传串了。
|
||||
//
|
||||
// 2. 已填的槽位不换人。原先是 `ids.take(2)` 后按下标对号入座,低 id
|
||||
// 的板子一离线,后面那块就跳到前面,画面左右互换,看起来同样
|
||||
// 像串线。掉线的槽位保留它的 id 并显示"离线",等它回来。
|
||||
//
|
||||
// 所以每轮最多消费一个候选 id。
|
||||
let mut next_candidate = cameras.iter().map(|c| c.id);
|
||||
|
||||
for view in views.iter_mut() {
|
||||
let still_here = view
|
||||
.sender_id
|
||||
.is_some_and(|id| cameras.iter().any(|c| c.id == id));
|
||||
|
||||
if still_here {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 槽位已占且板子还在场上 → 保留(上面已 continue)。到这里说明
|
||||
// 槽位是空的,或板子掉线了。
|
||||
let candidate = next_candidate
|
||||
.next()
|
||||
// 别把刚掉线的这块又立刻选回同一个槽位:它短暂离线又回来时,
|
||||
// 画面会原地卡一下才恢复。
|
||||
.filter(|id| Some(*id) != view.sender_id);
|
||||
|
||||
match candidate {
|
||||
Some(id) => {
|
||||
*view = View::new();
|
||||
view.sender_id = Some(id);
|
||||
}
|
||||
// 没有可分配的了。掉线的槽位**保留 id**(显示"离线"而不是
|
||||
// 变回空槽),这样它回来时还在原位。
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
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, true);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/// 把某一路的统计渲染成界面结构。
|
||||
///
|
||||
/// `diagnostics` 为 false 时不产出技术字段——它们留空字符串,界面也不显示。留空而
|
||||
/// 不是填 0 或假值:0 会被读成"丢帧 0"这种看起来像真值的东西。
|
||||
fn render_camera_stats(
|
||||
cameras: &[CameraInfo],
|
||||
sender_id: u32,
|
||||
stats: &SourceStats,
|
||||
fps: f32,
|
||||
diagnostics: bool,
|
||||
) -> CameraStats {
|
||||
let online = cameras
|
||||
.iter()
|
||||
.find(|c| c.id == sender_id)
|
||||
.map(CameraInfo::is_online)
|
||||
.unwrap_or(false);
|
||||
|
||||
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
|
||||
// 比让人以为板子挂了诚实。
|
||||
//
|
||||
// 有画面时显示 id —— 这对调试有用。裁判端和队伍端 debug 都显示;队伍端 release
|
||||
// 下这一路只有自己绑定的板子,显示它不构成泄露(界面上根本没有别人的板子可选,
|
||||
// 见 Receiver::restrict_to)。
|
||||
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 是 0.0,读起来像"板子卡死了",而真相是"还没收到"。
|
||||
// 状态行已经写了"已连接,等待画面…",这里不该再补一个矛盾的数字。
|
||||
fps: (diagnostics && stats.frames_shown > 0)
|
||||
.then(|| format!("{fps:.1}"))
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
// 没出过一个字节的源,`stats.width`/`height` 还是默认值 0,于是显示成
|
||||
// "0x0"——一个看起来像分辨率的假分辨率。用 frames_shown 把它挡掉。
|
||||
resolution: (diagnostics && stats.frames_shown > 0)
|
||||
.then(|| format!("{}x{}", stats.width, stats.height))
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
frame_bytes: (diagnostics && stats.frames_shown > 0)
|
||||
.then(|| format!("{:.1} kB", stats.last_frame_len as f32 / 1024.0))
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
frames_shown: diagnostics
|
||||
.then(|| stats.frames_shown.to_string())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
frames_dropped: diagnostics
|
||||
.then(|| stats.frames_dropped.to_string())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
loss_percent: diagnostics
|
||||
.then(|| format!("{:.1}%", stats.loss_percent()))
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
datagrams: diagnostics
|
||||
.then(|| stats.datagrams.to_string())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
gap_ms: stats
|
||||
.last_interval
|
||||
.filter(|_| diagnostics)
|
||||
.map(|d| format!("{:.0} ms", d.as_secs_f32() * 1000.0))
|
||||
.unwrap_or_default()
|
||||
.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,530 +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 touch(&self, id: u32) {
|
||||
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
|
||||
|
||||
if let Some(info) = cameras.get_mut(&id) {
|
||||
info.last_seen = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn note_hello(&self, id: u32, addr: SocketAddrV4, firmware: u16) {
|
||||
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,
|
||||
/// 只处理这一块板子的数据,`None` 表示全都要(裁判端)。
|
||||
///
|
||||
/// 队伍端在 release 下设成 `Some(bound)`,这是一道**接收端**的闸门而不只是界面
|
||||
/// 的:对手的板子即使把流推到了本机端口,也不会被重组、不会进解码队列、不会
|
||||
/// 出现在任何统计里。
|
||||
///
|
||||
/// 为什么必须在这一层而不是靠界面隐藏:`.slint` 只控制显示,而数据已经收下并
|
||||
/// 解码了。隐藏一行文字不阻止任何事。
|
||||
///
|
||||
/// 用 `Mutex` 而不是 `Cell`/`AtomicU32`:接收线程要读它,而绑定回调在主线程上
|
||||
/// 写它。订阅循环本身已经是串行的,所以锁的持有时间极短。
|
||||
only_sender: Mutex<Option<u32>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Receiver {
|
||||
/// 绑定视频端口并准备好发现。
|
||||
///
|
||||
/// 绑定固定端口(而非临时端口)是必须的:板子是从订阅的**源端口**学到推流目标的,
|
||||
/// 临时端口也能工作,但固定端口让三台客户端行为一致,也方便抓包排查。
|
||||
///
|
||||
/// `only_sender` 为 `Some` 时只接受那一块板子的数据,见 [`Receiver`] 的字段说明。
|
||||
pub fn start(
|
||||
camera: Ipv4Addr,
|
||||
role: SubscribeRole,
|
||||
only_sender: Option<u32>,
|
||||
) -> 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,
|
||||
only_sender: Mutex::new(only_sender),
|
||||
stop: Arc::new(AtomicBool::new(false)),
|
||||
})
|
||||
}
|
||||
|
||||
/// 绑定之后切换限制目标,或解除限制。
|
||||
///
|
||||
/// 队伍端在选择界面里点定一块板子时调用:此前要能看到所有候选(否则无从选
|
||||
/// 起),此后只看这一块。
|
||||
pub fn restrict_to(&self, only_sender: Option<u32>) {
|
||||
*self.only_sender.lock().expect("only_sender mutex poisoned") = only_sender;
|
||||
}
|
||||
|
||||
/// 当前限制到哪一块板子。
|
||||
fn sender_limit(&self) -> Option<u32> {
|
||||
*self.only_sender.lock().expect("only_sender mutex poisoned")
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let limit = self.sender_limit();
|
||||
|
||||
// 板子的在线通告。注意这里不看来源端口:广播来的源端口没有意义,身份由
|
||||
// 头里的 sender_id 决定。
|
||||
if let Some(hello) = protocol::Hello::decode(bytes) {
|
||||
// 限制生效时不记录其他板子。理由不只是"不显示":如果把它们记进
|
||||
// discovery,`--list` 式的输出、槽位排序、界面上任何一处遍历 cameras()
|
||||
// 的地方都会间接泄露场上还有谁。
|
||||
if let (Some(only), SocketAddr::V4(v4)) = (limit, from) {
|
||||
if hello.sender_id != only {
|
||||
return;
|
||||
}
|
||||
self.discovery.note_hello(hello.sender_id, v4, hello.firmware);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 视频同样要过闸门。对手的板子一旦把流推到这个端口(IP 已被广播泄露,
|
||||
// 端口是公开常量),它的数据不会进任何重组器。
|
||||
if let Some(only) = limit {
|
||||
if id != only {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 视频帧也算存活证据。
|
||||
//
|
||||
// `is_online` 看的是 `last_seen`,而它此前只由 Hello 更新。于是出现一个
|
||||
// 荒唐的状态:画面在每秒刷 22 帧,状态栏却显示"离线"(橙色)。真板子每 2
|
||||
// 秒广播一次所以不易撞上,但它本身就是设计错误——正在推流的板子比广播更
|
||||
// 可靠地证明自己活着。
|
||||
self.discovery.touch(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,550 +0,0 @@
|
||||
// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。
|
||||
|
||||
/* 一块画面的读数。
|
||||
*
|
||||
* 技术字段(fps / 丢帧 / 数据报数等)在队伍端 release 下**不产出**:Rust 侧
|
||||
* `render_camera_stats(..., TEAM_DIAGNOSTICS)` 传 false 时这些字段是空字符串,
|
||||
* 界面也不显示它们。裁判端始终显示。 */
|
||||
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,
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 视频窗格
|
||||
*
|
||||
* 一路画面 + 滚轮缩放 + 拖动平移。两种身份共用这个组件。
|
||||
*
|
||||
* 缩放的实现方式:把 `Image` 的 `width`/`height` 按 `zoom` 放大后放进一个
|
||||
* `clip: true` 的容器里,靠 `offset-x`/`offset-y` 平移。Slint 的 `Image` 没有
|
||||
* 缩放因子属性,所以只能用这个办法——它也顺带让"放大到超出窗口"成为可能。
|
||||
*
|
||||
* `image-fit: contain` 让缩放到 1.0 时画面完整可见且不变形;放大后
|
||||
* `image-fit: fill` 才会跟着缩放,否则画面尺寸固定在 contain 算出的尺寸上,
|
||||
* 放大看起来毫无反应。
|
||||
*/
|
||||
component VideoPane inherits Rectangle {
|
||||
// 画面
|
||||
in property <image> frame;
|
||||
// 没有画面时显示的提示(例如槽位空着)
|
||||
in property <string> empty-text;
|
||||
|
||||
background: #000000;
|
||||
// 关键:不 clip 的话放大后的画面会溢出到界面其它地方去。
|
||||
clip: true;
|
||||
|
||||
// 缩放与平移。zoom = 1.0 是"完整可见"。
|
||||
in-out property <float> zoom: 1.0;
|
||||
in-out property <length> offset-x: 0px;
|
||||
in-out property <length> offset-y: 0px;
|
||||
|
||||
// 放大到几倍为止。再多也看不清了,只是把像素拉大。
|
||||
in property <float> max-zoom: 8.0;
|
||||
in property <float> step: 1.25;
|
||||
|
||||
// 拖动时的起点,由 TouchArea 记录。
|
||||
private property <length> drag-start-x: 0px;
|
||||
private property <length> drag-start-y: 0px;
|
||||
private property <length> drag-origin-x: 0px;
|
||||
private property <length> drag-origin-y: 0px;
|
||||
|
||||
public function reset-view() {
|
||||
root.zoom = 1.0;
|
||||
root.offset-x = 0px;
|
||||
root.offset-y = 0px;
|
||||
}
|
||||
|
||||
Image {
|
||||
source: root.frame;
|
||||
// contain:zoom=1 时完整可见且不变形。
|
||||
// fill:放大后跟着容器缩放——只用 contain 的话放大不会有任何视觉变化。
|
||||
image-fit: root.zoom <= 1.0 ? contain : fill;
|
||||
width: parent.width * root.zoom;
|
||||
height: parent.height * root.zoom;
|
||||
x: (parent.width - self.width) / 2 + root.offset-x;
|
||||
y: (parent.height - self.height) / 2 + root.offset-y;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// 没有画面时的占位。裁判端两块板子通常只到一块,空槽位要能说清原因,
|
||||
// 而不是留一块黑。
|
||||
//
|
||||
// `absolute` 布局:它不参与父级的尺寸需求计算。之前它不是,于是"已连接,
|
||||
// 等待画面…"这行字的长度决定了这一格要多少宽度,裁判端两格就永远不等宽
|
||||
// (实测 55% / 45%)——文字长短本不该影响画面大小。
|
||||
if root.empty-text != "": Text {
|
||||
text: root.empty-text;
|
||||
color: #5a5a64;
|
||||
font-size: 14px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
// 占满整个窗格,文字居中,但不参与布局。
|
||||
x: 0;
|
||||
y: parent.height / 2 - self.height / 2;
|
||||
width: parent.width;
|
||||
}
|
||||
|
||||
// 缩放指示:只在放大时出现,否则一直占着位置。
|
||||
//
|
||||
// 同样是绝对定位(给出 x/y),否则它一出现就会改变这一格的尺寸需求,把两路
|
||||
// 不等宽的问题重新引进来——而且是在用户放大之后才出现,最难排查。
|
||||
if root.zoom > 1.05: Rectangle {
|
||||
background: #000000aa;
|
||||
border-radius: 4px;
|
||||
width: 92px;
|
||||
height: 26px;
|
||||
x: parent.width - self.width - 10px;
|
||||
y: 10px;
|
||||
|
||||
Text {
|
||||
text: "x" + root.zoom;
|
||||
color: #d0d0d8;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
// reset-view 按钮:只在放大/平移后出现,给一个回到全景的出口。
|
||||
if root.zoom > 1.05 || root.offset-x != 0px || root.offset-y != 0px: Rectangle {
|
||||
background: #000000aa;
|
||||
border-radius: 4px;
|
||||
width: 76px;
|
||||
height: 26px;
|
||||
x: 10px;
|
||||
y: 10px;
|
||||
|
||||
TouchArea {
|
||||
clicked => {
|
||||
root.reset-view();
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "复位";
|
||||
color: #d0d0d8;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
// 滚轮与拖动都在最上层。
|
||||
TouchArea {
|
||||
scroll-event(event) => {
|
||||
// delta-y 为正表示向上滚(放大)。
|
||||
if event.delta-y > 0px {
|
||||
root.zoom = Math.min(root.zoom * root.step, root.max-zoom);
|
||||
} else if event.delta-y < 0px {
|
||||
root.zoom = Math.max(root.zoom / root.step, 1.0);
|
||||
}
|
||||
// 缩回 1.0 时顺便归位:此时画面完整可见,平移没有意义,
|
||||
// 留着会让下一次放大从偏移位置开始,看起来像"跳了一下"。
|
||||
if root.zoom <= 1.0 {
|
||||
root.offset-x = 0px;
|
||||
root.offset-y = 0px;
|
||||
}
|
||||
return EventResult.accept;
|
||||
}
|
||||
|
||||
// 拖动平移。
|
||||
//
|
||||
// 用 `pointer-event` 而不是 `pressed` / `moved`:TouchArea 的 `pressed` 是
|
||||
// **属性**(可以读 `self.pressed`)而不是回调,写成 `pressed => {...}` 编译
|
||||
// 不过。按下(Down)时记起点和当前偏移,移动(Move)时按增量更新。
|
||||
pointer-event(event) => {
|
||||
if event.kind == PointerEventKind.down {
|
||||
root.drag-start-x = self.mouse-x;
|
||||
root.drag-start-y = self.mouse-y;
|
||||
root.drag-origin-x = root.offset-x;
|
||||
root.drag-origin-y = root.offset-y;
|
||||
} else if event.kind == PointerEventKind.move && self.pressed {
|
||||
// 只在放大后生效——zoom=1 时画面完整可见,没有可移动的空间。
|
||||
if root.zoom > 1.0 {
|
||||
root.offset-x = root.drag-origin-x
|
||||
+ (self.mouse-x - root.drag-start-x);
|
||||
root.offset-y = root.drag-origin-y
|
||||
+ (self.mouse-y - root.drag-start-y);
|
||||
}
|
||||
}
|
||||
return EventResult.accept;
|
||||
}
|
||||
|
||||
double-clicked => {
|
||||
root.reset-view();
|
||||
}
|
||||
|
||||
// 放大后用抓取光标提示可拖动。
|
||||
mouse-cursor: root.zoom > 1.0 ? move : default;
|
||||
}
|
||||
}
|
||||
|
||||
/* 一路画面的下方信息条。两种身份共用。
|
||||
*
|
||||
* 技术读数(fps / shown / dropped)在队伍端 release 下为空字符串,`visible`
|
||||
* 让整块消失而不是留一条空行。 */
|
||||
component StatusBar inherits Rectangle {
|
||||
in property <CameraStats> stats;
|
||||
in property <TeamHealth> health;
|
||||
// 血格子比状态栏里的字宽,裁判端并排时用
|
||||
in property <length> cell-width: 34px;
|
||||
in property <length> cell-height: 12px;
|
||||
// 队伍端那一条更高一些,因为它独占一行血量
|
||||
in property <bool> wide: false;
|
||||
|
||||
background: #1a1a20;
|
||||
height: wide ? 72px : 78px;
|
||||
|
||||
HorizontalLayout {
|
||||
padding: 12px;
|
||||
spacing: 16px;
|
||||
|
||||
// 左:队伍名 + 血量格子
|
||||
VerticalLayout {
|
||||
padding: 0;
|
||||
spacing: 5px;
|
||||
horizontal-stretch: 1;
|
||||
|
||||
Text {
|
||||
text: root.health.team-name;
|
||||
color: #e8e8ec;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding: 0;
|
||||
spacing: 5px;
|
||||
|
||||
for hp[index] in root.health.hps: Rectangle {
|
||||
width: root.cell-width;
|
||||
height: root.cell-height;
|
||||
border-width: 1px;
|
||||
border-color: root.health.available ? #4a4a55 : #2f2f38;
|
||||
background: root.health.available ? #6cc96c : #17171c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右:状态 + 技术读数
|
||||
//
|
||||
// VerticalLayout 没有 horizontal-alignment 属性(右对齐要落在里面的 Text
|
||||
// 上)。给它 preferred-width 而不是 stretch,让它按内容宽度占位。
|
||||
VerticalLayout {
|
||||
padding: 0;
|
||||
spacing: 3px;
|
||||
preferred-width: root.wide ? 340px : 300px;
|
||||
|
||||
Text {
|
||||
text: root.stats.status-text
|
||||
+ (root.stats.fps != "" ? " " + root.stats.fps + " fps" : "")
|
||||
+ (root.stats.resolution != "" ? " " + root.stats.resolution : "");
|
||||
color: root.stats.online ? #e8e8ec : #e0a060;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: right;
|
||||
}
|
||||
|
||||
// 技术读数整行在 release 下消失,不留空行。
|
||||
if root.stats.frames-shown != "": Text {
|
||||
text: "shown " + root.stats.frames-shown
|
||||
+ (root.stats.frames-dropped != ""
|
||||
? " dropped " + root.stats.frames-dropped
|
||||
+ " (" + root.stats.loss-percent + ")"
|
||||
: "")
|
||||
+ (root.stats.gap-ms != "" ? " gap " + root.stats.gap-ms : "")
|
||||
+ (root.stats.frame-bytes != "" ? " " + root.stats.frame-bytes : "");
|
||||
color: root.stats.frames-dropped == "0" ? #7e7e89 : #e0a060;
|
||||
font-size: 12px;
|
||||
horizontal-alignment: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 队伍端
|
||||
*
|
||||
* 队伍终端:一块画面 + 自己那一边的血量。
|
||||
*
|
||||
* 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省
|
||||
* 画面空间给视频,一边要均分),共用一套再用 visible 切换会让每处改动都要考虑两种
|
||||
* 情况。 */
|
||||
export component TeamApp inherits Window {
|
||||
title: "cam-client — 队伍";
|
||||
// 初始尺寸只作为"用户什么都不做时"的兜底;Rust 侧在 show() 之前按屏幕工作区
|
||||
// 覆盖它,所以这里的数字只是无屏幕信息时的退路。
|
||||
preferred-width: 1280px;
|
||||
preferred-height: 800px;
|
||||
// min-* 保证拖到很小时仍然可用:两路并排时没有下限,空的那格会被压没。
|
||||
min-width: 900px;
|
||||
min-height: 600px;
|
||||
|
||||
in property <image> frame;
|
||||
in property <CameraStats> stats;
|
||||
in property <TeamHealth> health;
|
||||
|
||||
// 选择绑定时显示,其余时候隐藏
|
||||
in property <[CameraChoice]> choices;
|
||||
in property <bool> choosing;
|
||||
|
||||
callback pick(int);
|
||||
|
||||
background: #101014;
|
||||
|
||||
if !root.choosing: VerticalLayout {
|
||||
spacing: 0;
|
||||
|
||||
// 画面占满其余全部空间。0 的 padding/spacing 是"贴边",让画面尽可能大——
|
||||
// 视频终端里每一像素都可能是有用信息。
|
||||
VideoPane {
|
||||
frame: root.frame;
|
||||
empty-text: root.stats.online ? "已连接,等待画面…" : "离线";
|
||||
vertical-stretch: 1;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
StatusBar {
|
||||
stats: root.stats;
|
||||
health: root.health;
|
||||
wide: true;
|
||||
}
|
||||
|
||||
// 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。
|
||||
// release 下这行也保留——它不泄露任何对手信息。
|
||||
Rectangle {
|
||||
background: #14141a;
|
||||
height: 24px;
|
||||
|
||||
Text {
|
||||
text: root.health.placeholder;
|
||||
color: #6a6a75;
|
||||
font-size: 12px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 选择界面
|
||||
if root.choosing: VerticalLayout {
|
||||
padding: 32px;
|
||||
spacing: 14px;
|
||||
|
||||
Text {
|
||||
text: "选择要观看的摄像头";
|
||||
color: #e8e8ec;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "选定后不能更改。这里的选择会写入配置文件,重启后仍然生效。";
|
||||
color: #8a8a95;
|
||||
font-size: 13px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
Rectangle { height: 6px; 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: 1600px;
|
||||
preferred-height: 900px;
|
||||
min-width: 1000px;
|
||||
min-height: 640px;
|
||||
|
||||
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;
|
||||
|
||||
// 两路的缩放互相独立:滚轮只作用于鼠标所在的一路(见各自窗格上的 TouchArea)。
|
||||
// 裁判要并排比出两边,所以默认都是 1.0,且不提供联动。
|
||||
|
||||
background: #101014;
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0;
|
||||
|
||||
// 两路画面等宽并排。
|
||||
//
|
||||
// 之前只写 `horizontal-stretch: 1`,于是**内容多的一格会占更多宽度**:
|
||||
// HorizontalLayout 先按内容的最大需求分配,剩下的才按 stretch 分。队伍一
|
||||
// 那格写着"已连接,等待画面…",队伍二只有"未收到画面",于是左边多要了
|
||||
// 十几个像素,右边就少十几个——实测 55% / 45%。
|
||||
//
|
||||
// 空的那一侧被压成一条缝是更极端的同一类问题:没有图 ⇒ 最小宽度接近 0。
|
||||
//
|
||||
// 解法是让两格的需求**只由画面决定**,与文字无关:
|
||||
// preferred-width: 50% 声明"我想要一半",配合 stretch 1 精确平分
|
||||
// min-width: 320px 兜底,保证任何一侧至少有一个能看画的宽度
|
||||
// 文字的溢出由 VideoPane 的 clip 裁掉,不参与布局。
|
||||
HorizontalLayout {
|
||||
padding: 0;
|
||||
spacing: 2px;
|
||||
|
||||
VideoPane {
|
||||
frame: root.frame-a;
|
||||
empty-text: root.stats-a.online ? "已连接,等待画面…" : "等待队伍一的图传";
|
||||
vertical-stretch: 1;
|
||||
horizontal-stretch: 1;
|
||||
preferred-width: 480px;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
VideoPane {
|
||||
frame: root.frame-b;
|
||||
// 槽位对应队伍,所以空槽要说清在等谁,而不是笼统的"未收到画面"。
|
||||
empty-text: root.stats-b.online ? "已连接,等待画面…" : "等待队伍二的图传";
|
||||
vertical-stretch: 1;
|
||||
horizontal-stretch: 1;
|
||||
preferred-width: 480px;
|
||||
min-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
// 每路画面下方的队伍条:名字、血量、统计。
|
||||
//
|
||||
// 用与画面列完全相同的宽度策略,所以拖动窗口时文字块不会跟着画面块错位。
|
||||
HorizontalLayout {
|
||||
padding: 0;
|
||||
spacing: 2px;
|
||||
|
||||
StatusBar {
|
||||
stats: root.stats-a;
|
||||
health: root.health-a;
|
||||
horizontal-stretch: 1;
|
||||
preferred-width: 480px;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
StatusBar {
|
||||
stats: root.stats-b;
|
||||
health: root.health-b;
|
||||
horizontal-stretch: 1;
|
||||
preferred-width: 480px;
|
||||
min-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
// 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。
|
||||
Rectangle {
|
||||
background: #14141a;
|
||||
height: 24px;
|
||||
|
||||
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) {
|
||||
@@ -233,4 +407,4 @@ esp_err_t cam_wrapper_deinit(void)
|
||||
const char *cam_wrapper_sensor_name(void)
|
||||
{
|
||||
return s_sensor_name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ typedef struct {
|
||||
size_t height;
|
||||
int format;
|
||||
void *handle; /* internal driver frame; do not touch */
|
||||
int64_t capture_started_us; /* same monotonic clock as esp_timer_get_time */
|
||||
uint32_t skipped_before; /* older completed frames released by latest get */
|
||||
} cam_wrapper_fb_t;
|
||||
|
||||
esp_err_t cam_wrapper_init(const cam_wrapper_config_t *config, cam_wrapper_sensor_t sensor);
|
||||
@@ -119,6 +121,7 @@ esp_err_t cam_wrapper_init(const cam_wrapper_config_t *config, cam_wrapper_senso
|
||||
/* Acquire one frame into `out`. Returns ESP_OK on success, ESP_FAIL if the
|
||||
* driver could not hand out a frame (e.g. timeout or buffer starvation). */
|
||||
esp_err_t cam_wrapper_fb_get(cam_wrapper_fb_t *out, cam_wrapper_sensor_t sensor);
|
||||
esp_err_t cam_wrapper_fb_get_latest(cam_wrapper_fb_t *out, uint32_t timeout_ms);
|
||||
|
||||
void cam_wrapper_fb_release(cam_wrapper_fb_t *fb);
|
||||
|
||||
@@ -129,6 +132,26 @@ esp_err_t cam_wrapper_deinit(void);
|
||||
/* Name of the sensor the driver detected, or "unknown". Never NULL. */
|
||||
const char *cam_wrapper_sensor_name(void);
|
||||
|
||||
/* Keep the sensor's JPEG encoder; effect 2 removes chroma in the sensor ISP. */
|
||||
esp_err_t cam_wrapper_set_grayscale(int enabled);
|
||||
/* Runtime JPEG compression, 0..63, without restarting the camera pipeline. */
|
||||
esp_err_t cam_wrapper_set_quality(unsigned quality);
|
||||
/* OV5640 target bias: 0 = default, -1 / -2 select approximately -1.1 / -1.9 EV. */
|
||||
esp_err_t cam_wrapper_set_exposure_bias(int ev);
|
||||
/* 0 = auto shutter; nonzero holds that exposure in sensor lines, AGC stays on. */
|
||||
esp_err_t cam_wrapper_set_shutter_lines(unsigned lines);
|
||||
/* Keep AEC/AGC automatic, cap exposure lines, and disable night frame extension. */
|
||||
esp_err_t cam_wrapper_set_exposure_limit(unsigned lines);
|
||||
/* 0 keeps the driver timing; nonzero changes OV5640 horizontal total only. */
|
||||
esp_err_t cam_wrapper_set_horizontal_timing(unsigned hts);
|
||||
int cam_wrapper_exposure_lines(void);
|
||||
/* OV5640 gain in 1/16 units; -1 when unavailable. */
|
||||
int cam_wrapper_gain_raw(void);
|
||||
float cam_wrapper_probe_frame_rate(void);
|
||||
/* Silicon temperature, not ambient or camera-sensor temperature. */
|
||||
esp_err_t cam_wrapper_temperature_init(void);
|
||||
esp_err_t cam_wrapper_temperature_read(float *celsius);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -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,17 @@
|
||||
# RMIC::2026 桌面端
|
||||
|
||||
Tauri v2 + React + Rust 客户端。用户端显示本队图传、双方装甲条、瞄准器与命中反馈;裁判端显示双路画面、装甲板列表,并支持队名编辑、槽位分配、截图与录制回放。
|
||||
|
||||
在本目录执行:
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm app
|
||||
```
|
||||
|
||||
`pnpm build` 构建前端,`pnpm tauri build` 打包应用。默认 Rust target 是 macOS ARM;Windows 和 Linux 需显式指定宿主 target。
|
||||
|
||||
- [客户端说明](../docs/desktop/client.md):构建、配置、界面行为、网络通道、权限与排查。
|
||||
- [固件维护](../docs/maintenance/firmware.md):`.zip` 固件包、坏块规避、备份与回读校验。
|
||||
- [版本维护](../docs/development/versioning.md):版本计算与派生文件。
|
||||
- [文档首页](../docs/index.md):仓库的其他说明。
|
||||
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.2",
|
||||
"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.
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