Compare commits
28
Commits
7b6593bb75
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc6b82d7ec
|
||
|
|
4794bed2cf | ||
|
|
e6070f91ee | ||
|
|
26731958ea | ||
|
|
de7263569c | ||
|
|
c9ce7b64ef | ||
|
|
f3b09ca9a6 | ||
|
|
1cccc1aa00 | ||
|
|
f0538fa233 | ||
|
|
47ef7c63dd | ||
|
|
b74c6654e5 | ||
|
|
ac3d639d6b | ||
|
|
2ed16ff88d | ||
|
|
6f83ebbe3c | ||
|
|
4667d94c45 | ||
|
|
76b96cc374 | ||
|
|
03f7279b53 | ||
|
|
98dc87ec6a | ||
|
|
a93ae065cf | ||
|
|
fd71b83e3a | ||
|
|
a30eb3200a | ||
|
|
7fdebe3ec7 | ||
|
|
4f7a142a39 | ||
|
|
a89d7ae5fb | ||
|
|
eafa112959 | ||
|
|
3631a037d8 | ||
|
|
cd8344c698 | ||
|
|
a16ad8595e |
+4
-11
@@ -12,14 +12,7 @@ build-std = ["std", "panic_abort"]
|
|||||||
[env]
|
[env]
|
||||||
MCU = "esp32s3"
|
MCU = "esp32s3"
|
||||||
|
|
||||||
# Use the ESP-IDF installation and toolchains that are already on the machine
|
# esp-idf-sys downloads this version unless IDF_PATH points to an existing SDK.
|
||||||
# (eim's install under ~/.espressif) instead of downloading another copy into
|
# Keep machine-specific paths in the environment instead of this shared file.
|
||||||
# ./.embuild. Without this the first build re-downloads several hundred MB of
|
ESP_IDF_VERSION = { value = "v5.5.5", force = false }
|
||||||
# Xtensa GCC/LLVM.
|
ESP_IDF_TOOLS_INSTALL_DIR = { value = "global", force = false }
|
||||||
#
|
|
||||||
# `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 }
|
|
||||||
|
|||||||
@@ -6,10 +6,68 @@ WIFI_PASS=your-password
|
|||||||
|
|
||||||
# Optional
|
# 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
|
# Capture resolution: qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga
|
||||||
#CAM_RES=vga
|
#CAM_RES=vga
|
||||||
# JPEG quality 0-63, lower is better quality
|
# JPEG quality 0-63, lower is better quality
|
||||||
#CAM_QUALITY=12
|
#CAM_QUALITY=12
|
||||||
|
# Optional adaptive compression ceiling, 0 = fixed (default), else QUALITY..63.
|
||||||
|
# Shed load on sender congestion, restore detail after 15 stable seconds.
|
||||||
|
#CAM_QUALITY_MAX=0
|
||||||
|
# Stream cap, 0 = uncapped (default), 1-60 = maximum fps.
|
||||||
|
# Paces frame retrieval and UDP delivery; sensor clocks remain unchanged.
|
||||||
|
#CAM_MAX_FPS=0
|
||||||
|
# OV5640 PLL profile: default (driver timing) | vga30 (VGA ~30.09 fps).
|
||||||
|
# vga30 preserves HTS/VTS: XCLK 20, VCO 610, SYSCLK 61, PCLK 15.25 MHz.
|
||||||
|
# Retune exposure-line limits when switching profiles; keep HTS at default.
|
||||||
|
#CAM_CLOCK_PROFILE=default
|
||||||
|
# Capture frame buffers in PSRAM: 3 (default) or 2 (lower capture queue depth).
|
||||||
|
# Prefer 3 for burst tolerance; 2 showed worse long-run peak frame loss here.
|
||||||
|
#CAM_FB_COUNT=3
|
||||||
|
# Sensor grayscale effect while retaining hardware JPEG, 0 = colour (default).
|
||||||
|
#CAM_GRAYSCALE=0
|
||||||
|
# OV5640 AE target bias: 0 (default), -1 (~-1.1 EV), -2 (~-1.9 EV).
|
||||||
|
# This target alone does not guarantee a shorter shutter.
|
||||||
|
#CAM_EXPOSURE_EV=0
|
||||||
|
# Optional OV5640 driver AE level -5..5; requires CAM_EXPOSURE_EV=0.
|
||||||
|
# Unset uses the EV setting. -1 lowers the target 55 -> 45 (~-0.29 EV),
|
||||||
|
# a finer brightness adjustment than CAM_EXPOSURE_EV=-1. AEC/AGC stay on.
|
||||||
|
#CAM_AE_LEVEL=-1
|
||||||
|
# OV5640 fixed shutter in sensor lines, 0 = automatic (default), AGC stays on.
|
||||||
|
# 220 lines is roughly 10 ms with default VGA HTS 2060; verify timing/logs.
|
||||||
|
#CAM_SHUTTER_LINES=0
|
||||||
|
# Optional automatic exposure cap in sensor lines, 0 = driver default.
|
||||||
|
# AEC/AGC stay automatic, night frame extension is disabled. Mutually exclusive
|
||||||
|
# with a nonzero fixed shutter. Default VGA: 440 lines is about 20 ms.
|
||||||
|
# With vga30 use 592 lines for about 20 ms, including two 50 Hz AEC bands.
|
||||||
|
#CAM_EXPOSURE_MAX_LINES=0
|
||||||
|
# OV5640 automatic gain ceiling, 0 = driver default, else 1..63 times.
|
||||||
|
# More gain can brighten short exposures but also amplifies noise.
|
||||||
|
#CAM_GAIN_CEILING=0
|
||||||
|
# OV5640 horizontal total, 0 = driver default. Even values only; hardware test
|
||||||
|
# required. Changing HTS also changes the time represented by one shutter line.
|
||||||
|
# HTS 1500 / 1800 caused green/magenta flicker on this module; keep 0.
|
||||||
|
#CAM_HTS=0
|
||||||
|
# Minimum gap between successful video datagrams, 0 = burst (default).
|
||||||
|
# Allowed 0..5000 us in FreeRTOS tick increments (1000 us in this build).
|
||||||
|
#UDP_PACKET_GAP_US=0
|
||||||
|
# Total yielding-retry allowance per JPEG, shared by all fragments/peers.
|
||||||
|
# 0 = fail on backpressure; default 8000 us, 0..25000 in tick increments.
|
||||||
|
# Frame deadlines remain 25 ms sending / 100 ms since capture start.
|
||||||
|
#UDP_RETRY_BUDGET_US=8000
|
||||||
|
# Internal ESP chip temperature load limit, Celsius; 0 disables, default 75.
|
||||||
|
# At the limit halve fps (rounded up); restore after cooling by 5 C.
|
||||||
|
# This is a proactive load threshold, not a specified silicon safety limit.
|
||||||
|
#CAM_THERMAL_LIMIT_C=75
|
||||||
# Set to 0 if your board inverts VSYNC in hardware.
|
# Set to 0 if your board inverts VSYNC in hardware.
|
||||||
#CAM_VSYNC_INVERT=1
|
#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
|
# the AP (a hidden SSID, for instance) - otherwise the scan reports the real
|
||||||
# mode and this only makes it fail faster.
|
# mode and this only makes it fail faster.
|
||||||
#WIFI_AUTH=wpa2
|
#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,547 @@
|
|||||||
|
# 构建桌面客户端(desktop/,Tauri v2 + React)。
|
||||||
|
#
|
||||||
|
# 产物都挂在这次运行下面(Actions 页面直接下载),保留 14 天:
|
||||||
|
# Linux x64 deb + AppImage cam-desktop_<版本>_amd64.deb / .AppImage ← 默认每次编
|
||||||
|
# Windows x64 NSIS 安装包 cam-desktop_<版本>_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:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
- 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:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- 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: cam-desktop-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:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# 宿主模式的 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: cam-desktop-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:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# 通用包要两个架构的 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: cam-desktop-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/ 各自也是独立的
|
# 每一行都必要:根目录是 firmware crate,protocol/ 也是独立的 crate(有自己的
|
||||||
# crate(各有自己的 .cargo/config.toml 与 target 目录),所以只写 `target/`
|
# .cargo/config.toml 与 target 目录),armor/ 是一套独立 workspace(协议层 +
|
||||||
# 匹配不到它们。
|
# ESP32-C3 固件),同理。所以只写 `target/` 匹配不到它们。
|
||||||
#
|
#
|
||||||
# 全部锚定在根目录(前置 /),这是有意的:components/esp32-camera/target/ 看着
|
# 全部锚定在根目录(前置 /),这是有意的:components/esp32-camera/target/ 看着
|
||||||
# 像构建产物,其实是上游组件的**按芯片分目录**(esp32/ esp32s2/ esp32s3/ …),
|
# 像构建产物,其实是上游组件的**按芯片分目录**(esp32/ esp32s2/ esp32s3/ …),
|
||||||
# 里面是 ll_cam.c 等源码,包含本项目打的补丁。不要把它一起忽略掉。
|
# 里面是 ll_cam.c 等源码,包含本项目打的补丁。不要把它一起忽略掉。
|
||||||
|
#
|
||||||
|
# desktop/ 自己还有一份 .gitignore(node_modules、dist、三个 crate 的 target)。
|
||||||
/target/
|
/target/
|
||||||
/protocol/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! 读取。不要提交。
|
# WiFi 凭据,编译期由 env! 读取。不要提交。
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# pnpm 在本机把 store 落在了工作区里(沙箱下 HOME 里的 store 不可写)。
|
||||||
|
/.pnpm-store/
|
||||||
|
|
||||||
|
# macOS 的目录元数据。不锚定,子目录里的也一起忽略。
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Python diagnostic helpers.
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
# `docs/simba_docs`:工作区里另起的一个 Astro 文档站,与本仓库的目录结构无关,
|
||||||
|
# 也不属于本项目的产物。留着不跟踪,免得以后 `git add -A` 顺手把它带进来。
|
||||||
|
/docs/
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
# 改动记录
|
||||||
|
|
||||||
|
记录面向使用者和维护者的主要变化。历史条目依据 Git 提交整理,日期为提交日期,尚未对应正式发布版本;详细历史见 `git log`。
|
||||||
|
|
||||||
|
## 未发布
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
|
||||||
|
- 重组根 README,补充 ESP Rust 入门、跨平台编译与标准烧录指南。
|
||||||
|
- 将硬件限制、开发说明与改动记录分别整理为独立文档。
|
||||||
|
- 修正 JPEG 质量与数据大小的说明,区分历史性能记录和当前验证。
|
||||||
|
- 补充桌面端 Windows x64 / NSIS 构建、测试与产物路径说明。
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
- 固定使用命名工具链 `esp-1.97.0.0`,避免较新预览版标准库的 ESP-IDF 兼容问题。
|
||||||
|
- 移除 S3 构建配置中的个人 macOS SDK 路径,默认 ESP-IDF v5.5.5,支持环境变量覆盖。
|
||||||
|
- 新增从 `.env` 读取配置的 PowerShell 构建脚本。
|
||||||
|
- 图传构建脚本使用 `--locked`,忽略 SDK 缓存和生成配置。
|
||||||
|
- 分区表复制到生成的 CMake 工程,不再依赖 Cargo 输出层级;支持 Windows 使用较短的外部构建目录。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 增加运行中 Wi-Fi 断线恢复、关联和 DHCP 超时退避;取址后重建 UDP socket,清理订阅目标与旧帧,保留相机状态和帧序号。
|
||||||
|
- 桌面端向已发现的图传板 IP 定向续订,修复 VPN / 多网卡把全局广播发往虚拟接口后“设备在线但无画面”的问题。
|
||||||
|
- 修正丢帧统计:计入未收齐就被替换、超时及帧号缺口,重复包和等待分片不计作丢帧;拒绝迟到旧帧覆盖新帧。
|
||||||
|
- 校验分片数量、长度与同帧元数据,处理帧号回绕及设备重启。
|
||||||
|
|
||||||
|
### 图传性能
|
||||||
|
|
||||||
|
- 根据距离收益有限、拥塞增加的实测反馈,将交付 Wi-Fi 最大发射功率上限从 17 dBm 回退到 15 dBm,实板驱动回读 15.00 dBm;启动、重连均应用同一设置,拥塞和温控逻辑不自动降功率。保留此前 14/17 dBm 的原始记录,新增功率与拥塞分析。
|
||||||
|
- 阻塞恢复时非阻塞取最新完整 JPEG,返回被替换的旧采集缓冲;保留三块采集内存,不增加待发送 FIFO。
|
||||||
|
- 每帧增加采集帧龄和绝对发送期限,重试预算默认缩短至 8 ms;`UDP_RETRY_BUDGET_US=0` 可在回压时立即放弃剩余分片。
|
||||||
|
- 客户端通过本机帧时间标记拒绝 WebView 积压和慢解码的过期画面;本地 JPEG 二进制头保持 16 字节兼容。
|
||||||
|
- 根据长程运行的峰值丢帧反馈,当前交付恢复三缓冲;双缓冲的短测数据保留为历史对照。
|
||||||
|
- 新增可选 OV5640 `CAM_AE_LEVEL` 细调:`-1` 将亮度目标从 55 调至 45,保留 AEC/AGC 与曝光上限,供轻微过亮时调整。
|
||||||
|
- 增加完整 OV5640 `vga30` PLL 配置与可选双缓冲;保持默认行、帧时序与彩色读出窗口,配套调整自动曝光行数上限。
|
||||||
|
- Windows 客户端增加裁判端 `--latency-report` 校准模式,通过屏幕标记和实际视频画布测量首次可解码回显时间,保存本地 NDJSON;区分画布刷新估计与物理面板响应。
|
||||||
|
- 固件直接读取 PSRAM 中的 JPEG,复用单个数据报缓冲,减少每帧分片复制与分配。
|
||||||
|
- 视频 socket 使用 IP precedence 4,映射至保留 AMPDU 的 Wi-Fi 视频队列 AC_VI。
|
||||||
|
- 对临时 UDP 发送拥塞进行有时限的让出调度重试,统计实际提交帧、失败次数与丢弃帧。
|
||||||
|
- Windows UDP 接收缓冲请求增至 1 MiB;界面视频通道和解码队列保留最新待处理帧,避免慢端持续积压。
|
||||||
|
- 新客户端使用随机会话 ID;固件更新同一会话的源端口,避免 NAT / TUN 端口变化产生重复推流目标,旧固定 ID 客户端继续按地址区分。
|
||||||
|
- 新增可选编译参数 `WIFI_TX_POWER_DBM`(2–20 dBm);未设置时保留 SDK 默认上限,按实际板卡和网络测量调节。
|
||||||
|
- 新增 `scripts/perf_probe.py`,测量完整帧率、帧损失、接收间隔和分片组装耗时。
|
||||||
|
- 新增 `CAM_MAX_FPS`(默认 0,不限帧),可限制取帧和发送节拍,过载后不追赶补发。
|
||||||
|
- 新增 `CAM_GRAYSCALE`,通过传感器灰度效果保留硬件 JPEG;增加可配置的芯片温度减载和 5°C 恢复回差。
|
||||||
|
- 增加芯片内部温度、采集错误与复位原因日志,以及串口和原生客户端视频通道的长测记录脚本。
|
||||||
|
- 新增 OV5640 曝光目标偏置与固定快门行数配置,保留自动增益;记录实际曝光行数,避免将降低亮度等同于缩短快门。
|
||||||
|
- 新增 OV5640 水平总长试验参数 `CAM_HTS`;过短消隐会出现绿紫闪烁,几何约束通过不代表画质稳定,必须在板上验证。
|
||||||
|
- 新增可配置 UDP 分片间隔及有界帧发送时间,支持按五分钟窗口比较突发发送与让出调度发送。
|
||||||
|
- 新增可选 JPEG 压缩回压调节 `CAM_QUALITY_MAX`,拥塞时快速减小彩色帧,连续稳定后缓慢恢复画质;记录实际压缩档位和传感器增益。
|
||||||
|
- 新增 `CAM_EXPOSURE_MAX_LINES`,保留自动曝光和增益联动,以曝光上限控制拖影;固定短快门不再作为室内亮度优化的默认方案。
|
||||||
|
- 新增 OV5640 自动增益上限 `CAM_GAIN_CEILING`,为室内短曝光提供增益余量,保留运行时增益读数供验证。
|
||||||
|
|
||||||
|
### 硬件配置
|
||||||
|
|
||||||
|
- 显式设置 `CONFIG_CAMERA_CORE1=y`;仅关闭 `CAMERA_CORE0` 时,Kconfig 的 choice 仍会默认选择 CPU0。
|
||||||
|
- 按当前 Wi-Fi 配置的镜像核对 Factory 偏移与原测试模组 `0xFADE7` 坏字节的可写性;后续重编译仍需重新检查,以 `partitions.csv` 为准。
|
||||||
|
|
||||||
|
## 2026-10-15
|
||||||
|
|
||||||
|
### 持续集成
|
||||||
|
|
||||||
|
- 新增 Windows、Linux、macOS 客户端构建工作流。
|
||||||
|
- 调整制品上传版本及缓存行为,适配 Gitea runner。
|
||||||
|
- 修复 Windows runner 的 shell、临时目录和低完整性环境适配。
|
||||||
|
- 使用 npm 安装的 JS 版 pnpm,处理安装目录残留与 `EEXIST`。
|
||||||
|
|
||||||
|
对应提交:`98dc87e` 至 `4794bed`。
|
||||||
|
|
||||||
|
## 2026-10-14
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 引入 Tauri v2 + React 队伍端和裁判端,网络逻辑拆分到 `desktop/core`。
|
||||||
|
- 加入 ESP32-C3 一主三从装甲板:四路受击检测、灯带及血量广播。
|
||||||
|
- 保存早期 C 版装甲板工程到 `reference/armor/`。
|
||||||
|
- 桌面端加入双方血条、归零胜负提示与裁判端队名编辑。
|
||||||
|
|
||||||
|
### 调整与修复
|
||||||
|
|
||||||
|
- 移除旧 Slint 客户端。
|
||||||
|
- 修复演示模式命中节奏与设置文字显示,移除受击画面抖动。
|
||||||
|
|
||||||
|
对应提交:`a89d7ae`、`4f7a142`、`7fdebe3`、`a30eb32`、`fd71b83`、`a93ae06`。
|
||||||
|
|
||||||
|
## 2026-10-05
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 建立摄像头固件、共享 UDP 协议与初代桌面客户端。
|
||||||
|
- 支持多端发现、订阅、设备绑定及队伍端 / 裁判端视图。
|
||||||
|
- 加入滚轮缩放、拖动平移与启动最大化。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复探测脚本重组状态未按帧隔离导致的坏帧误报,以及 FPS 窗口计算错误。
|
||||||
|
- 修复裁判端两格宽度不一致,槽位按队伍语义分配。
|
||||||
|
- 队伍端 release 接收路径限制到已绑定图传板。
|
||||||
|
|
||||||
|
早期对重复分片的协议归因已被后续提交更正,不能将当时的坏帧直接归为协议缺陷。对应提交:`4f893ce`、`1c65eaa`、`7be8d7d`、`c7f42a1`、`a16ad85`、`cd8344c`、`3631a03`。
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
# 开发与验证
|
||||||
|
|
||||||
|
编译入口见 [README.md](README.md),硬件限制见 [HARDWARE.md](HARDWARE.md),历史变化见 [CHANGELOG.md](CHANGELOG.md)。
|
||||||
|
|
||||||
|
## 镜像导出
|
||||||
|
|
||||||
|
以下使用 espflash 4.4.0 与固定的 `esp-1.97.0.0` 工具链。完成构建后,使用 `esp-idf-sys` 复制到 `release/` 的 Bootloader。Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$targetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { 'target' }
|
||||||
|
New-Item -ItemType Directory -Force target/firmware | Out-Null
|
||||||
|
espflash save-image --chip esp32s3 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$targetDir/xtensa-esp32s3-espidf/release/bootloader.bin" --merge --skip-padding "$targetDir/xtensa-esp32s3-espidf/release/esp32" target/firmware/esp32s3-merged.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux / macOS(Bash):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
target_dir="${CARGO_TARGET_DIR:-target}"
|
||||||
|
mkdir -p target/firmware
|
||||||
|
espflash save-image --chip esp32s3 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$target_dir/xtensa-esp32s3-espidf/release/bootloader.bin" --merge --skip-padding "$target_dir/xtensa-esp32s3-espidf/release/esp32" target/firmware/esp32s3-merged.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
合并镜像包含 Bootloader、分区表、App 和必要地址填充,`--skip-padding` 避免将文件填满 16 MB。镜像从 `0x0` 写入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
espflash write-bin --chip esp32s3 --port COM5 0x0 target/firmware/esp32s3-merged.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
去掉导出命令的 `--merge --skip-padding` 可生成独立 App,它需写入 `partitions.csv` 声明的 Factory 偏移,并与板上 Bootloader 和分区表匹配。坏板重编可能改变该偏移;新板优先使用 ELF 或合并镜像。
|
||||||
|
|
||||||
|
## 主机测试
|
||||||
|
|
||||||
|
协议层不依赖 ESP-IDF。进入 `protocol/`,以 Windows MSVC 为例:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux 常见 host 为 `x86_64-unknown-linux-gnu`,Apple Silicon 为 `aarch64-apple-darwin`,可用 `rustc +stable -vV` 查看。`--target` 覆盖仓库里的其他平台配置,空 `build-std` 避免主机测试继承嵌入式标准库重编译。
|
||||||
|
|
||||||
|
装甲规则从 `armor/` 执行 `cargo +stable test -p armor-protocol`,桌面核心从 `desktop/core/` 执行 `cargo +stable test`,同样按需指定宿主 `--target` 和空 `build-std`。
|
||||||
|
|
||||||
|
限帧、温度回差和 JPEG 压缩回压策略可以独立在主机运行,无需连接板卡。Windows 在根目录执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-Item -ItemType Directory -Force target | Out-Null
|
||||||
|
rustc +stable --edition 2021 --test src/stream_control.rs -o target/stream-control-tests.exe
|
||||||
|
.\target\stream-control-tests.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
实际持续运行的串口与客户端通道记录方法见 [STABILITY.md](STABILITY.md)。主机策略测试不等于已经触发板上过温减载。
|
||||||
|
|
||||||
|
## 清理构建缓存
|
||||||
|
|
||||||
|
改动 C 组件或 `sdkconfig.defaults` 后,增量构建若没有反映变化,可从根目录执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo clean --release --target xtensa-esp32s3-espidf -p esp-idf-sys
|
||||||
|
```
|
||||||
|
|
||||||
|
随后用构建脚本重编,确认 CMake / Ninja 重新编译相应组件。仍有缓存问题时使用 `cargo clean` 完整清理。不要删除 `components/esp32-camera/target/`。
|
||||||
|
|
||||||
|
构建脚本设置 `CARGO_WORKSPACE_DIR`,并通过 `ESP_IDF_GLOB_BASE` / `ESP_IDF_GLOB_PARTITIONS` 将分区表复制到生成的 CMake 工程,因此支持外部 `CARGO_TARGET_DIR` 和不同 Cargo 输出层级。Windows 使用短目录,如 `C:\s3vt`;设置 `ESP_IDF_PATH_ISSUES=ignore` 或 `subst` 不能消除底层路径限制。
|
||||||
|
|
||||||
|
## 发现与协议
|
||||||
|
|
||||||
|
- 图传板每 2 秒广播 Hello;客户端每 800 ms 广播 Subscribe,并向已发现的在线板卡 IP 单播续订,固定绑定 UDP 5600。
|
||||||
|
- 全局广播可能被 VPN / 虚拟网卡路由到其它接口。收到 Hello 后按设备 IP 定向续订,可以使用通往实际局域网的路由;队伍端仅向绑定的板卡增加单播续订。
|
||||||
|
- 固件使用订阅包的源地址作为发送目标,6 秒不续订即移除,最多保留 4 个对端。
|
||||||
|
- 新客户端为每个接收器生成随机会话 ID;同一源 IP 下,同一会话换端口会更新推流目标。旧版公共固定 ID `0x0C110001` 保持按地址区分,避免合并同机旧客户端。
|
||||||
|
- MAC 派生的 `sender_id` 不随 DHCP 地址变化,适合客户端持久绑定。
|
||||||
|
- Hello 和视频都是设备在线证据;客户端列表清理与固件订阅使用不同超时。
|
||||||
|
- 数据报为 27 字节头 + 最多 1400 字节载荷,采用小端编码;字段及拒收条件见 [protocol/README.md](protocol/README.md)。
|
||||||
|
|
||||||
|
缺失分片不重传,不完整帧整体丢弃。JPEG 由传感器编码,桌面后端转交原始 JPEG,由 WebView 解码,避免接收线程进行耗时编解码。
|
||||||
|
|
||||||
|
## 性能测量
|
||||||
|
|
||||||
|
本次在保留 TUN 环境中的对照测试、最终参数与验证边界见 [PERFORMANCE.md](PERFORMANCE.md)。
|
||||||
|
|
||||||
|
下表为原 README 保存的**历史单次测试数据**,不是本次构建实测或性能保证。配置为 OV5640、VGA;原始日志、时长和完整网络条件未随这些数字保存。
|
||||||
|
|
||||||
|
| 指标 | 早期 HTTP MJPEG / TCP | 后续 UDP |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 传感器产出 | 约 22 fps | 约 22 fps |
|
||||||
|
| 客户端收到 | 约 1.9 fps | 约 22 fps |
|
||||||
|
| 吞吐 | 约 0.28 Mbit/s | 约 1.6 Mbit/s |
|
||||||
|
| 丢帧 | 未记录 | 当次报告 0% |
|
||||||
|
|
||||||
|
这些数据不足以单独证明性能差异的根因或无线链路容量。复测时记录板型、固件版本、分辨率、画质、信号、订阅者数量、时长和丢帧数。
|
||||||
|
|
||||||
|
FPS 应按**同一时间窗口的帧数增量 / 窗口秒数**计算。累计帧数与窗口时长混用会产生假数值,UI 轮询间隔也不代表相机出帧间隔。探测脚本的重组状态需按设备及帧隔离,避免测量错误。
|
||||||
|
|
||||||
|
可重复测量:`python scripts/perf_probe.py <板卡IP> --seconds 30 --json perf.json`。默认使用独立的临时端口和 1 MiB 接收缓冲,每 800 ms 定向续订。脚本只依赖 Python 标准库;`--save-frame frame.jpg` 可保存首张完整 JPEG。公平比较应保持订阅者数量、分辨率、画质与网络条件一致。
|
||||||
|
|
||||||
|
`frame_loss_percent` 包含不完整、超时及帧号缺口,不包含开始订阅前或测量结束后的帧。`assembly_p95_ms` 是首末分片到达的时间差,`inter_frame_p95_ms` 是完整帧到达间隔,均使用高精度计时;没有发送时间戳及两端时钟同步,不能称为端到端延迟。
|
||||||
|
|
||||||
|
固件日志将采集尝试与完整提交分开计数。`submitted` 只表示全部分片交给了网络,不代表无线确认或客户端显示;`send failures` 可能包含随后重试成功的调用,应结合 `peer frames dropped` 和接收端指标判断。
|
||||||
|
|
||||||
|
画面链路测量使用裁判端 `--latency-report <新文件>`,让相机拍摄校准页左侧标记,固定相机和窗口位置。标记经正常采集、网络与绘制路径回到客户端,源与回显共用主机单调时钟;不需要 ESP 时钟同步。测试期间只保留一个原生客户端,额外的本地 WebSocket 观察器不增加 UDP 订阅。方法、刷新估计边界及最新结果见 [LATENCY.md](LATENCY.md)。
|
||||||
|
|
||||||
|
按记录文件的半开行号区间复算延迟:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python scripts/analyze_latency.py --report latency-run.ndjson --range fb3-range.json --range fb2-range.json --out latency-result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
区间文件至少包含 `start_row`、`end_row` 和 `fb_count`,行号从 0 开始;报告关闭后再分析,避免读取写入中的半行。分析器拒绝重复源标记、无效时间边界与空样本,输出 P50/P95/P99、rAF 区间宽度和解码完成到绘制的耗时。JPEG 接收间隔仍是独立指标。
|
||||||
|
|
||||||
|
恢复策略是独立纯 Rust 模块,可在主机运行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rustc +stable --test src/link_control.rs -o link-control-tests
|
||||||
|
./link-control-tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 为输出文件加 `.exe`。实机故障注入仅在诊断构建设置 `WIFI_TEST_DROP_AFTER_S`:主循环运行相应秒数后断开一次并保持 5 秒,正式构建保持 `0`。重连后需观察重新取址、socket 重建和订阅恢复,不能仅凭 connect 调用成功判定恢复视频。
|
||||||
|
|
||||||
|
## 板上验证
|
||||||
|
|
||||||
|
阻塞与缓冲交接测试:`python scripts/test-latest-frame.py` 使用主机 clang/gcc 编译真实 `cam_take_latest` 函数及队列替身,验证持续生产下有限取帧、旧缓冲仅归还一次、无新帧保留有效旧帧,以及首次超时不泄漏。`rustc +stable --test src/stream_control.rs` 验证发送期限、共享重试预算和零重试模式;`desktop/` 的 `pnpm test:stream` 验证 WebView 积压、解码阻塞及过期帧清理。实机验证范围另见 [REALTIME.md](REALTIME.md)。
|
||||||
|
|
||||||
|
1. 相机识别、PSRAM 初始化及稳定采集。
|
||||||
|
2. Wi-Fi 完成关联和 DHCP,得到有效 IP。
|
||||||
|
3. 客户端启动后订阅者增加并持续显示画面。
|
||||||
|
4. `python scripts/udp_probe.py 10` 独立核对协议及帧统计。
|
||||||
|
5. 分别验证多端订阅、断网重连、不同分辨率与持续运行。
|
||||||
|
|
||||||
|
编译、镜像生成与主机测试通过均不等于烧录、板上运行或硬件稳定性已经验证。
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
# 图传板硬件说明
|
||||||
|
|
||||||
|
对应 Freenove ESP32-S3-WROOM CAM、ESP32-S3-WROOM-1-N16R8 与 OV5640。构建和烧录入口见 [README.md](README.md)。
|
||||||
|
|
||||||
|
## 摄像头引脚
|
||||||
|
|
||||||
|
配置以 `src/camera.rs` 的 `pins` 模块为准。DATA 按 D0–D7(传感器 Y2–Y9)排列。
|
||||||
|
|
||||||
|
| 信号 | GPIO |
|
||||||
|
| --- | --- |
|
||||||
|
| RESET | 0 |
|
||||||
|
| XCLK | 15 |
|
||||||
|
| PCLK | 13 |
|
||||||
|
| VSYNC | 6 |
|
||||||
|
| HREF | 7 |
|
||||||
|
| SIOD / SDA | 4 |
|
||||||
|
| SIOC / SCL | 5 |
|
||||||
|
| D0–D7 | 11, 9, 8, 10, 12, 18, 17, 16 |
|
||||||
|
| PWDN | 未连接(`-1`) |
|
||||||
|
|
||||||
|
当前 XCLK 为 20 MHz。不要直接套用 AI-Thinker、XIAO 等其他板卡的相机引脚表。
|
||||||
|
|
||||||
|
本模组需避开的资源包括 Flash / PSRAM 总线(GPIO 26–37)、USB(19 / 20)、串口(43 / 44)及模组内部引脚,以原理图为最终依据。占用 Flash / PSRAM 引脚可能造成传输期间 CPU 挂死、串口静默。
|
||||||
|
|
||||||
|
## 内存与任务配置
|
||||||
|
|
||||||
|
参数位于根目录 `sdkconfig.defaults`:
|
||||||
|
|
||||||
|
| 参数 | 当前值 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `CONFIG_SPIRAM_MODE_OCT` | `y` | 8 MB Octal PSRAM |
|
||||||
|
| `CONFIG_SPIRAM_SPEED_80M` | `y` | PSRAM 时钟 |
|
||||||
|
| `CONFIG_CAMERA_PSRAM_DMA` | `n` | DMA 经内部 RAM 中转后复制到 PSRAM |
|
||||||
|
| `CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP` | `n` | Wi-Fi / lwIP 缓冲保留在内部 RAM |
|
||||||
|
| `CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240` | `y` | CPU 240 MHz |
|
||||||
|
| `CONFIG_CAMERA_CORE1` | `y` | 显式选择 CPU1,与 Wi-Fi 分开(只禁用 CORE0 不足以改变 choice 默认值) |
|
||||||
|
| `CONFIG_FREERTOS_HZ` | `1000` | 发送拥塞时以 1 ms tick 让出调度 |
|
||||||
|
| `CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM` | `64` | 发送缓冲数量,使用内部 RAM |
|
||||||
|
| `CONFIG_ESP_WIFI_TX_BA_WIN` | `32` | 保留 AMPDU 发送聚合 |
|
||||||
|
|
||||||
|
相机使用 3 个 PSRAM 帧缓冲及 `GRAB_LATEST`,优先取得较新的画面。JPEG 编码由传感器完成,S3 主要负责采集和发送。
|
||||||
|
|
||||||
|
原测试板启用 PSRAM DMA 后曾出现 `NO-EOI`:JPEG 结束标记位于驱动末尾 DMA 搜索窗口之外,随后停止交付帧。因此默认关闭该配置;更换驱动后应单独验证持续采集。
|
||||||
|
|
||||||
|
## Flash 与分区
|
||||||
|
|
||||||
|
默认 **16 MB、DIO、40 MHz**,分区来自 `partitions.csv`:
|
||||||
|
|
||||||
|
| 分区 | 偏移 | 大小 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| NVS | `0x9000` | `0x6000` |
|
||||||
|
| PHY init | `0xF000` | `0x1000` |
|
||||||
|
| Factory app | 以 `partitions.csv` 为准,坏板重编时可能调整 | `0x120000`(1152 KiB) |
|
||||||
|
|
||||||
|
分区表位于 `0x8000`,S3 Bootloader 从 `0x0` 开始。App 写入分区表声明的偏移,不能直接套用 `0x10000`。修改分区或 Flash 模式后需重新构建,成套更新 Bootloader、分区表和 App。
|
||||||
|
|
||||||
|
健康硬件可以评估恢复 `0x10000` App 偏移或更快 Flash 配置,但需重新验证。
|
||||||
|
|
||||||
|
## 损坏 Flash 模组的兼容处理
|
||||||
|
|
||||||
|
早期测试模组在绝对地址 **`0xFADE7`** 存在写入异常,bit 5–7 无法正确写入。这是特定模组的故障。当前 App 偏移来自该板的开发过程,不能保证新镜像仍避开坏字节。
|
||||||
|
|
||||||
|
2026-10-18 的整段回读还发现 **`0x10863`** 的 bit 6 异常读回为 1:期望 `0x2e`,读回 `0x6e`,该轮 App 只有这一处字节不同,尚未单独擦写复核是否为永久故障。该模组的 App 偏移需至少为 `0x20000`,并继续检查 `0xFADE7`;烧录工具的校验失败必须先排除,不能当作网络或相机故障。
|
||||||
|
|
||||||
|
`scripts/flash.sh` 保留的流程为:编译、导出 App、搜索 64 KiB 对齐的可写偏移、修改分区、重新构建和验证,再写入三段镜像。重新构建可能移动镜像内容,因此第二次验证不可省略。
|
||||||
|
|
||||||
|
脚本依赖 macOS 串口名、Unix `termios`、本地 ESP-IDF Python 目录及旧版工具参数,不适合作为通用烧录入口。坏板需核对工具版本及坏地址字节;普通板卡使用 README 流程。更换故障模组比长期依赖镜像位置更可靠。
|
||||||
|
|
||||||
|
## 本地相机组件
|
||||||
|
|
||||||
|
`components/esp32-camera/` 包含上游 2.1.7 及本地兼容补丁,`components/cam_wrapper/` 提供 Rust FFI 封装。
|
||||||
|
|
||||||
|
本地增加 `esp_camera_fb_get_latest` / `cam_take_latest`:等待第一张有效帧后,以有限次数零等待取出更新帧,并归还替换的缓冲。`cam_take(0)` 表示单次非阻塞尝试,不在空队列、坏 JPEG 或 DMA 复位哨兵上继续等待。升级驱动时需保留或移植这些补丁,并运行缓冲所有权测试;不能用“先检查有帧、再调用可能阻塞 4 秒的 get”替代。
|
||||||
|
|
||||||
|
S3 的 `ll_cam.c` 中有 `CAM_IO_MUX_REG()` 本地补丁,用于 SCCB / I2C 引脚连接。升级驱动后需检查补丁并验证传感器探测和采集。
|
||||||
|
|
||||||
|
`components/esp32-camera/target/` 是按芯片划分的**源代码**,清理缓存时不能删除,也不能忽略所有层级的 `target/`。
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
# 约 30 fps、Wi-Fi 重连与画面链路延迟验证(2026-10-18)
|
||||||
|
|
||||||
|
当前交付已增加阻塞恢复与新鲜度处理,见 [REALTIME.md](REALTIME.md)。本页保留此前减光确认和画面延迟对照;本页 SHA 与测量结果均属于当时构建,不是当前固件的重测结果。
|
||||||
|
|
||||||
|
## 先前三缓冲与小幅减光记录
|
||||||
|
|
||||||
|
根据用户长程运行反馈,双缓冲在峰值负载下丢帧明显,当前交付恢复 **`CAM_FB_COUNT=3`**,保留突发发送期间的采集余量。约 30 fps 完整 PLL 配置、VGA 彩色、正常白平衡、Wi-Fi 自动重连和自适应 JPEG 均保留。双缓冲保留为试验选项,下面的短时延迟对照属于历史记录,不能证明长程峰值稳定性。
|
||||||
|
|
||||||
|
针对现场轻微过亮,增加 `CAM_AE_LEVEL=-1`,将 OV5640 AE 目标从 55 微调到 45,目标比值约 −0.29 EV;`CAM_EXPOSURE_EV=0`,自动曝光和增益仍联动,曝光上限保持 592 行(约 20 ms)。这会小幅降低亮度目标,不保证实际快门必然缩短。当前 50 Hz 抗频闪步长约 10 ms,直接小减曝光上限可能跨越一整档,因此使用 AE 目标细调。该目标比值不等于经过照度标定的实际 EV。
|
||||||
|
|
||||||
|
本次已通过 COM8 烧录与 Bootloader、分区表、App 的独立校验。重新启动后串口观察 45.15 秒,复位和采集错误均为 0,订阅已恢复;末个窗口提交 29.7 fps。观察中仍有 UDP 发送回压,三缓冲不代表完全消除路由器拥塞。本次未重新测量原生完整接收损失率或画布延迟,也未重新进行长程峰值测试。
|
||||||
|
|
||||||
|
用户已确认当前三缓冲、小幅减光版本亮度合适、颜色稳定。
|
||||||
|
|
||||||
|
该阶段 App SHA-256:`05aff1f53519fbb52b89ca526c3be11cc78f37a3356f588986336cf7870924eb`。完整选择理由、当前参数与短测记录见制品 `BUFFER-DECISION.json`;下面的 90 秒/五分钟对照、CDF 和旧接收曲线保留为历史数据,不能套用到本次亮度微调后的镜像。
|
||||||
|
|
||||||
|
## 此前短时对照(历史)
|
||||||
|
|
||||||
|
此前试验使用 VGA 彩色、完整 PLL 配置和双缓冲。保留 Mihomo TUN,只有一个 UDP 订阅者,使用 Windows 生产客户端的真实视频绘制路径,非演示模式。用户已确认新时钟配置亮度够用、颜色正常,未再出现短 HTS 方案的绿紫闪烁。
|
||||||
|
|
||||||
|
| 指标 | 三缓冲基线 | 双缓冲试验配置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 原生接收窗口 | 90.008 s | 300.003 s |
|
||||||
|
| 完整接收帧 | 2659 | 8909 |
|
||||||
|
| 帧损失 | 32 / 1.189% | 61 / 0.680% |
|
||||||
|
| 完整接收帧率 | 29.54 fps | 29.70 fps |
|
||||||
|
| 本地 JPEG 通道 | 27.23 fps | 27.90 fps |
|
||||||
|
| 标记回显有效样本 | 767 | 2641 |
|
||||||
|
| 标记回显采样跨度 | 109.68 s | 295.08 s |
|
||||||
|
| 标记 → 首次可解码画布回显 P50 | 128.4 ms | 121.5 ms |
|
||||||
|
| 标记 → 首次可解码画布回显 P95 | 230.7 ms | 193.4 ms |
|
||||||
|
| 回显估计 P99 / 最大值 | 285.3 / 443.2 ms | 248.9 / 461.5 ms |
|
||||||
|
|
||||||
|
这次窗口的 P50 降低 6.8 ms、P95 降低 37.3 ms。双缓冲减少潜在采集排队,同时保住接近 30 fps 的实际交付。两个窗口的网络回压、JPEG 自适应档位和时长不同,属于连续实机对照,不能把全部改善归因于缓冲数,也不是所有路由器或场景的保证。此前约 22 fps 的测试保留在 [STABILITY.md](STABILITY.md)。
|
||||||
|
|
||||||
|
双缓冲试验五分钟全程在线,复位 0 次、采集错误 0 次,通道断开 0 次、计数重置 0 次。抽取 61 张 JPEG 均完整解码为 640×480 RGB;没有用灰度或降低色深换帧率。最小内部空闲堆 118771 B;实际 JPEG 档位 18–28,温度 58–60°C,未触发减载。
|
||||||
|
|
||||||
|
## 该测试阶段的配置与时钟
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
CAM_RES=vga
|
||||||
|
CAM_CLOCK_PROFILE=vga30
|
||||||
|
CAM_FB_COUNT=3
|
||||||
|
CAM_QUALITY=18
|
||||||
|
CAM_QUALITY_MAX=30
|
||||||
|
CAM_MAX_FPS=0
|
||||||
|
CAM_GRAYSCALE=0
|
||||||
|
CAM_EXPOSURE_EV=0
|
||||||
|
CAM_AE_LEVEL=-1
|
||||||
|
CAM_SHUTTER_LINES=0
|
||||||
|
CAM_EXPOSURE_MAX_LINES=592
|
||||||
|
CAM_GAIN_CEILING=32
|
||||||
|
CAM_HTS=0
|
||||||
|
CAM_THERMAL_LIMIT_C=75
|
||||||
|
WIFI_TX_POWER_DBM=14
|
||||||
|
UDP_PACKET_GAP_US=0
|
||||||
|
WIFI_TEST_DROP_AFTER_S=0
|
||||||
|
```
|
||||||
|
|
||||||
|
XCLK 保持 20 MHz,使用完整 PLL 分频组合:VCO 610 MHz、SYSCLK 61 MHz、DVP PCLK 15.25 MHz;保持默认 HTS 2060 / VTS 984 和读出窗口,名义节拍约 30.09 fps。此前缩短 HTS 至 1500 / 1800 的方案会闪色,未采用。驱动回调的参数顺序与私有 PLL 辅助函数不同,必须按 `sensor_t.set_pll` 的签名调用。
|
||||||
|
|
||||||
|
下列实际曝光、增益范围属于此前双缓冲试验。当前 AE 目标改为 45/55,自动增益保留,曝光上限 592 行约 19.99 ms;实际曝光 590–590 行,估算 19.92–19.92 ms,增益 8.00–13.50×。最大 50 Hz band 数保留为 2,避免提升 SYSCLK 后向下取整只剩一个 band 导致画面过暗。未强制负 EV 或固定低快门。行数与毫秒换算依赖当前时钟,不能直接套用其他配置。
|
||||||
|
|
||||||
|
参考:[OV5640 数据手册](https://files.waveshare.com/upload/d/da/OV5640_DataSheet.pdf)、[Espressif OV5640 驱动](https://github.com/espressif/esp32-camera/blob/master/sensors/ov5640.c)。新组合的 VCO 和 PCLK 在该手册列出的范围内;这不代替板上信号完整性和画面验证。
|
||||||
|
|
||||||
|
## Wi-Fi 运行时恢复
|
||||||
|
|
||||||
|
断开、失去有效 IP 或地址变化后停止 UDP 发送并清理订阅目标。关联和 DHCP 分别使用 15 秒期限;首次立即尝试,失败按 1、2、4、8、16、30 秒退避。事件回调只更新状态,主循环负责调用驱动;认证进行中不会反复 connect。取址后重建 socket、恢复无线参数、丢弃旧采集帧并重新广播,保留摄像头、曝光、增益、设备 ID 和帧序号。
|
||||||
|
|
||||||
|
实机诊断构建主动断开并保持 5 秒:检测断线后 9021 ms 恢复网络,其中重试发起到恢复日志为 4029 ms(含取址和恢复处理);无需重启,客户端随后自动续订恢复视频。该测试使用同一恢复逻辑、60 MHz SYSCLK 的预检构建;最终交付为 61 MHz,故障注入参数为 0。恢复到同一个 IP 已验证,路由器长时间关机、密码改变和 DHCP 换址尚未分别做硬件测试。启动阶段第一次联网仍使用原有扫描和认证模式探测逻辑。
|
||||||
|
|
||||||
|
处理方式遵循 [ESP-IDF 断线事件说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wifi-event-sta-disconnected):有效 IP 恢复后再重建上层 socket。4 项恢复策略测试覆盖认证不被重启、关联/DHCP 超时、指数退避和新断线重置状态。
|
||||||
|
|
||||||
|
## 延迟测量方法与边界
|
||||||
|
|
||||||
|
Windows 客户端使用裁判配置和 `--latency-report <新的NDJSON路径>` 启动。相机固定对准左侧四角标记,右侧沿正常 `VideoSurface` 路径绘制真实回显。源标记每 50 ms 更新,记录每个编号首次可解码的画布回显;四角透视校正、Gray 码和互补行拒绝无法可靠识别的曝光混合帧。最初逐刷新更新在 180 Hz 屏幕上会让一次曝光跨越多个编号,已在正式对照前修正。
|
||||||
|
|
||||||
|
源标记和回显均使用同一客户端 `performance.now()`,无需 ESP 时钟同步。绘制时刻以相邻 rAF 夹定,表中为上下界中点;此前双缓冲样本的区间宽度中位数为 11.4 ms。该范围包括相机取样等待、曝光、采集、无线传输、重组、本地 WebSocket、解码和画布绘制。它是**屏幕标记事件到首次可解码画布回显的估计**,没有测量源/输出面板扫描或像素响应;严格的物理光学端到端延迟需用高速相机复核。未识别帧不成为样本,首次可解码回显可能晚于较早的模糊回显,不能把有效样本分布当作每一帧的延迟。
|
||||||
|
|
||||||
|
此前双缓冲试验中,解码完成到画布绘制平均 2.72 ms;标记分析平均 2.02 ms。该分析只在校准模式运行,正常客户端不做画布回读。字段 `decodeToDrawMs` 从解码完成开始,不代表 JPEG 解码耗时。接收间隔、分片组装耗时及发送函数耗时各有独立口径,不能替代本测量。
|
||||||
|
|
||||||
|
`scripts/analyze_latency.py` 可按半开区间行号复算 NDJSON。完整结果、参数、行号范围和边界见制品 `LATENCY-RESULT.json`,分布图见 `LATENCY.png`。原始校准记录保存在本机 `target/latency/`,不把画面、私有配置或令牌日志打进制品。
|
||||||
|
|
||||||
|
## 交付与硬件限制
|
||||||
|
|
||||||
|
该阶段 App SHA-256:`05aff1f53519fbb52b89ca526c3be11cc78f37a3356f588986336cf7870924eb`。Bootloader、分区表和 App 已经 COM8 烧录并独立校验,ZIP 内含校验清单。
|
||||||
|
|
||||||
|
本模组除原有 `0xFADE7` 坏点,还发现 `0x10863` 的 bit 6 异常读回为 1,整段回读只差这一字节,是否永久故障尚未单独擦写复核;已把应用分区移至 `0x50000` 并验证 `0xFADE7` 对应字节可写。该兼容布局针对故障模组,每次重编仍需重新核对,详见 [HARDWARE.md](HARDWARE.md)。
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 图传性能排查与验证(2026-10-18)
|
||||||
|
|
||||||
|
本文保留早期短时对照;当前交付已回退到 15 dBm,功率、距离与发送拥塞分析见 [POWER.md](POWER.md),阻塞处理见 [REALTIME.md](REALTIME.md)。此前约 30 fps 与回显延迟数据见 [LATENCY.md](LATENCY.md),更早验证见 [STABILITY.md](STABILITY.md)。下列数字对应当时的传感器时序和网络状态。
|
||||||
|
|
||||||
|
## 环境与结论
|
||||||
|
|
||||||
|
测试使用当前 ESP32-S3-N16R8 / OV5640,VGA 640×480、8 MB PSRAM、CPU 240 MHz、ESP-IDF v5.5.5。板端连接 2.4 GHz Wi-Fi,实际协商 HT20 / CCMP,主要有效窗口的信号约 −18~−32 dBm;Windows 通过同一路由器通信,Mihomo TUN 始终保持启用。单客户端对照使用 1 MiB 接收缓冲,每 800 ms 单播续订。
|
||||||
|
|
||||||
|
传感器在 CPU0 和 CPU1 均可输出约 22.2 fps。低帧率期间持续出现 `sendto` 的 `ENOMEM`(errno 12),完整帧提交率低于采集率,客户端收到不完整分片。普通系统内存仍有约 100 KiB 以上可用,证据指向无线发送拥塞与驱动缓冲回压。核绑定和单独扩大 Windows 缓冲均未解决主要瓶颈。
|
||||||
|
|
||||||
|
降低最大发射功率、使用视频队列,并降低单帧大小后有所改善。功率切换对照中,20 dBm 的窗口明显恶化,10 dBm 的两次窗口约 12 fps;14 dBm 的结果仍有波动。仅凭这些数据无法确定供电、天线、射频或 AP 的具体物理原因。
|
||||||
|
|
||||||
|
## 接收端对照
|
||||||
|
|
||||||
|
| 配置 | 时长 | 完整帧 | 窗口 fps | 帧损失 | 完整帧间隔 P95 | 分片组装 P95 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 问题复测:20 dBm / 普通队列 / quality 12 | 30 s | 165 | 5.49 | 74.69% | 672.0 ms | 109.0 ms |
|
||||||
|
| 14 dBm / 普通队列 / quality 12 | 30 s | 477 | 15.88 | 27.84% | 242.2 ms | 145.4 ms |
|
||||||
|
| 14 dBm / 视频队列 / quality 12 | 60 s | 1091 | 18.18 | 17.41% | 194.4 ms | 103.2 ms |
|
||||||
|
| 该阶段选择:14 dBm / 视频队列 / quality 18 | 60 s | 1177 | 19.62 | 11.30% | 192.8 ms | 94.2 ms |
|
||||||
|
|
||||||
|
表中 fps = 完整帧数 / 整个测量时长。帧损失包括被替换的不完整帧、超时及帧号缺口;末尾仍在接收的帧不计损失。丢帧率与旧客户端的口径不同,旧版会低估实际丢帧。
|
||||||
|
|
||||||
|
无线链路有明显时变,早期其它窗口也出现过约 5~15 fps,表格不是长期保证或所有板卡的通用最优参数。前两行使用 CPU0,后两行使用 CPU1;核绑定对照未消除发送拥塞,最终保留显式 CPU1 配置。关闭 AMPDU 的试验几乎无法得到完整帧,已撤回;忙等重试也已撤回。另一次 40 ms 重试预算试验期间,有新客户端加入导致两个订阅者,且信号和画面复杂度变化;该轮约 12.6 fps,不能用于证明重试预算的单项影响,未作为最终配置依据。
|
||||||
|
|
||||||
|
## 最终参数与改动
|
||||||
|
|
||||||
|
- 该测试阶段本机 `.env` 选择 `CAM_QUALITY=18`、`WIFI_TX_POWER_DBM=14`。分辨率保持 VGA;quality 数字增大表示压缩更强,细节会有所损失。仓库默认 quality 仍为 12,未设置发射功率时保留 SDK 默认上限;当前功率选择见 [POWER.md](POWER.md)。
|
||||||
|
- 视频 socket 设置 `IP_TOS=128`,使用 AC_VI,并保留 AMPDU;64 个动态 TX 缓冲、TX BA window 32、FreeRTOS 1000 Hz。JPEG 在 PSRAM 中原地读取,仅复用一个数据报缓冲;临时发送失败让出调度重试,等待预算为每帧 25 ms。
|
||||||
|
- 客户端按发现的设备 IP 定向续订,接收缓冲请求 1 MiB。随机会话 ID 让同一会话的端口变化更新目标地址;旧固定 ID 保留按地址区分。实测迁移后旧端口无新视频,新端口继续接收;两个旧版订阅仍各自收到数据。
|
||||||
|
- 接收器拒绝迟到旧帧、重复完成帧和矛盾分片,处理帧号回绕、设备重启及超时。视频 WebSocket 只保留各路最新帧;前端每路至多一次解码与一张最新待解码帧,避免持续积压。
|
||||||
|
|
||||||
|
## Windows 实际客户端验证
|
||||||
|
|
||||||
|
使用已构建的 release EXE 和独立裁判端测试配置,非演示模式。原用户配置、密码和绑定保留。
|
||||||
|
|
||||||
|
约 30 秒窗口中,后端完整帧增量 624、丢帧增量 38,窗口帧损失约 5.74%。界面连接存在,独立观察连接收到 619 张完整 JPEG,通道帧率 20.62 fps,帧间隔 P95 为 103.0 ms,无损坏 JPEG。
|
||||||
|
|
||||||
|
这项验证覆盖真实板卡、UDP 接收与重组、GUI 的 WebSocket 连接及二进制视频通道。另有 20 项协议测试、83 项客户端核心测试、9 项后端视频通道测试、5 项前端解码队列测试,以及 Windows 类型检查和生产打包通过。未将整个后端测试集或录制功能记为已验证。
|
||||||
|
|
||||||
|
**时间边界:**组装耗时是首末分片到达的差值,帧间隔是相邻完整帧到达的差值,均不是摄像头到屏幕的端到端延迟。未做两端时钟同步或实际屏幕的光学延迟测量,也未自动验证 WebView 最终绘制像素。
|
||||||
|
|
||||||
|
## 复测与原始记录
|
||||||
|
|
||||||
|
先退出其它图传接收进程,避免增加订阅负载:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python scripts/perf_probe.py <板卡IP> --seconds 60 --json perf.json --save-frame frame.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
本机原始 JSON、抽帧和串口日志在 `target/camera-diagnostics/`,随构建缓存忽略;主要文件为 `fps-core0-raw.json`、`fps-tx14-raw.json`、`fps-quality12-raw.json`、`fps-final-raw.json`、`fps-final-client-result.json`、`session-migration.json`。固件与 Windows 制品的 `BUILD-INFO.json` 记录最终测量及验证范围,ZIP 不包含用户运行配置。
|
||||||
|
|
||||||
|
设计依据:[ESP-IDF 5.5.5 的 UDP 发送回压说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/lwip.html#limitations)、[Wi-Fi QoS 的 IP precedence / AC_VI 映射](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wi-fi-qos)。这些文档说明机制,性能数字来自上述本机测量。
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 发射功率、断连距离与拥塞分析(2026-10-18)
|
||||||
|
|
||||||
|
当前交付请求 `WIFI_TX_POWER_DBM=15`,实板驱动回读 **15.00 dBm**。根据用户的距离与拥塞反馈,从 17 dBm 回退;启动和 Wi-Fi 重连后应用同一设置,压缩与温控控制器不自动降低发射功率。三缓冲、VGA 彩色、AE 目标 45、约 20 ms 曝光上限和 PLL 时钟保持不变。
|
||||||
|
|
||||||
|
这里的数值是驱动配置的发射功率上限。API 回读不能代替功率计,不能认为所有 PHY 速率下、每个数据包的天线端输出都恰好相同。
|
||||||
|
|
||||||
|
## 先前 17 dBm 记录说明了什么
|
||||||
|
|
||||||
|
| 记录 | 时间 | 原生接收帧率 | 原生帧损失率 | 板端发送失败 / 应用重试 / 放弃目标帧 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 17 dBm 第一轮 | 视频 30 s / 串口 75 s | 29.78 fps | 0.667% | 90 / 80 / 10(整段串口) |
|
||||||
|
| 17 dBm 第二轮,增加周期回读 | 视频 30 s / 串口 75 s | 27.92 fps | 6.889% | 498 / 446 / 52(整段串口) |
|
||||||
|
|
||||||
|
两轮的发射上限相同,曝光与缓冲参数相同,但无线状态、场景内容和时间并未锁定,且第二轮多了周期功率读数。因此这些记录不构成严格 A/B 对照,不能单凭它们断定“增加 3 dB 必然导致某个百分比的丢帧”。用户反馈是回退依据,日志用于定位失败发生在哪一层。
|
||||||
|
|
||||||
|
第二轮内部空闲堆最少 120039 字节、最大连续块最少 43008 字节,温度 57–58°C,无温控限帧、复位或采集错误,只有一个 UDP 订阅者。出现发送失败时伴有 `errno 12 / ENOMEM`,优先指向网络发送资源的瞬时耗尽。堆仍有余量不能排除受配额限制的 Wi-Fi TX 缓冲已满,也不能把这个错误直接当成内存泄漏。
|
||||||
|
|
||||||
|
ESP-IDF 明确说明,UDP 连续 `send/sendto` 可以因底层驱动 TX 缓冲全部占用而返回 `ENOMEM`。目前日志中的“446 次重试”是**应用调用 sendto 的重试**,不是已测得的 802.11 空口重传次数。来源:[ESP-IDF v5.5.5 lwIP 文档](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/lwip.html#limitations)。
|
||||||
|
|
||||||
|
## 为什么距离改善不明显
|
||||||
|
|
||||||
|
1. **功率差只有 3 dB。** 14→17 dBm 的配置上限约为两倍功率,不是两倍距离。按简化路径损耗模型 `距离倍率 = 10^(3/(10n))`,自由空间 `n=2` 时约 1.41 倍,假设室内 `n=3–4` 时约 1.19–1.26 倍。这只是模型估算,不是本次测得的距离。
|
||||||
|
2. **收发链路必须同时成立。** 增强板卡发射不能增强板卡接收;AP 信标、应答、订阅和数据回程仍依赖另一方向。ESP-IDF 的断连说明包含持续丢失信标等原因。当前记录没有最远点的断连 reason、AP 侧接收强度或重传统计,不能确定距离边界由哪一方向限制。来源:[ESP-IDF 断连事件说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wifi-event-sta-disconnected)。
|
||||||
|
3. **回读是上限,RSSI 有方向。** 板端日志里的 RSSI 是板卡接收到 AP 的信号,不是 AP 接收到板卡的信号。17 dBm 第二轮 RSSI 为 −25 至 −14 dBm,属于近距离记录,不能拿来证明远距离的上行链路已经改善。
|
||||||
|
|
||||||
|
## 为什么拥塞仍会加重
|
||||||
|
|
||||||
|
已确认的应用行为是:每约 33 ms 取得一张 JPEG,再把它拆成多个数据报连续提交,分片间隔为 0;成功返回只表示本地网络栈接受,不能证明 AP 或电脑已经收到。每帧 8 ms 重试预算和 25 ms 持有期限限制应用等待,但不能撤回已经进入 SDK 的旧分片。
|
||||||
|
|
||||||
|
提高发射上限不会增加信道带宽或路由器的排队能力。若空口竞争、速率回退或重传使驱动排空变慢,突发提交仍会填满 TX 缓冲。这是与日志一致的机制推断;本次没有 CCA 忙占比、MAC 重传、实际发送 MCS 或 AP 队列数据,尚不能把它们中的某一项确认为功率变化造成的根因。来源:[ESP-IDF TX 数据路径与缓冲说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#how-to-improve-wi-fi-performance)。
|
||||||
|
|
||||||
|
更高功率改变干扰覆盖、射频线性度或供电瞬态,也可能间接影响链路,但这里没有相应测量,不将“接收端过载”“功放饱和”或“电压跌落”写成已确认原因。无 Brownout 记录也不能证明射频供电完全没有瞬态变化。
|
||||||
|
|
||||||
|
电脑当前通过 **5 GHz / 信道 161 / 802.11ac** 连接,摄像头使用 2.4 GHz。因此不能直接声称视频在同一个 2.4 GHz 信道被路由器重复发送了两次;摄像头上行、AP 转发、电脑接收与 TUN 处理仍可能分别排队,TUN 保持原样。
|
||||||
|
|
||||||
|
## 还需区分本地跳帧
|
||||||
|
|
||||||
|
17 dBm 第二轮视频区间中,原生接收了 838 张完整 JPEG,而辅助本地 WebSocket 观察器收到 582 张。两者不是同一个指标:后端采用最新帧覆盖和帧龄期限,慢消费者可能主动跳过已完整收到的帧;少收到的本地消息不能全部算成无线丢帧,更不能直接等同于真实屏幕绘制帧率。
|
||||||
|
|
||||||
|
## 当前回退验证与后续定位
|
||||||
|
|
||||||
|
测试期间用户补充观察:用手握持开发板时丢包明显增多。测试没有逐秒标记握持、位置和朝向,五分钟记录应视作现场连续性观察,不能当作固定摆位的功率 A/B 测试。人体靠近 PCB 天线导致的吸收、失谐或遮挡成为更值得优先排查的线索。
|
||||||
|
|
||||||
|
本轮只改变功率上限到 15 dBm,保持其他图传参数与单 UDP 订阅,做五分钟收流观察;结果由交付包 `POWER-RESULT.json` 记录。之前的 17 dBm 记录保留为 `POWER-HISTORY-tx17.json`,14 dBm 的阻塞处理记录保留在 [REALTIME.md](REALTIME.md)。本轮不重新测量最远断连距离或物理屏幕端到端延迟。
|
||||||
|
|
||||||
|
下一项值得单独对照的是**分片突发整形**:固定 15 dBm、曝光和 JPEG 策略,按发送回压逐步比较小批次提交与短暂让出调度,并同时看原生完整帧率、网络丢帧、主动跳帧和帧龄。不能仅通过增大缓冲或延长重试,把丢帧转化为持续排队延迟。若要确认距离瓶颈,需要同步记录远点断连原因、AP 侧板卡 RSSI、MAC 重传和信道忙占比。
|
||||||
|
|
||||||
|
## 蛇形 PCB 天线的握持改善
|
||||||
|
|
||||||
|
手接近天线会改变其周围的电磁环境,人体和外壳可使 PCB 天线失谐;人体也会吸收、遮挡部分射频能量。这是对用户观察的机制解释,本板尚未测量 S11、效率或握持前后的辐射图。TI 的 2.4 GHz PCB 天线实测例子显示,加入外壳并手持后匹配状态明显变化,需要在实际使用条件下匹配。来源:[TI 天线阻抗测量与匹配,章节 6](https://www.ti.com/lit/an/swra726/swra726.pdf)。
|
||||||
|
|
||||||
|
建议按以下顺序改善:
|
||||||
|
|
||||||
|
1. **先改善握持和摆位。** 用塑料支架或绝缘延长握柄固定,让蛇形天线端伸出、远离手掌;握住远离天线的一端。可先尝试让手与天线相距约 3–5 cm,再按实测增加距离,这不是保证性能的固定阈值。
|
||||||
|
2. **留出天线净空。** Espressif 建议成品外壳中天线各方向至少 15 mm 净空;这不是“距离手 15 mm 就一定够”的保证。天线附近避开电池、金属支架、屏蔽片和紧贴的 USB 线或排线,外壳在天线区域使用非金属材料。来源:[ESP32-S3 PCB 天线布局指南](https://docs.espressif.com/projects/esp-hardware-design-guidelines/en/latest/esp32s3/pcb-layout-design.html#general-principles-of-pcb-layout-for-modules-positioning-a-module-on-a-base-board)。
|
||||||
|
3. **固定朝向再比较。** 在同一位置和画面下,保持开发板朝向固定,比较“不接触 → 握远端 → 手靠近天线 → 移开手”,每段 30–60 秒,同时记录完整帧损失和板端 RSSI。单看 RSSI 不能覆盖上行质量,但可帮助区分朝向、遮挡与队列负载变化。
|
||||||
|
4. **需要长期手持时,优先考虑外置天线版本。** `ESP32-S3-WROOM-1` 为 PCB 天线,`WROOM-1U` 为外置天线接口版本。选兼容摄像头接线、Flash/PSRAM 配置的板卡,将合适的 2.4 GHz 天线安放在握持区域之外;具体封装、接口和射频连接须按实际板卡核对。外置天线也应远离手和金属。来源:[Espressif 模组数据手册](https://documentation.espressif.com/esp32-s3-wroom-1_wroom-1u_datasheet_en.pdf)。
|
||||||
|
5. **必须贴近人体使用时,按最终结构调谐。** 外壳、支架、馈线和手持姿态都参与最终匹配,需要 VNA/射频测量后调整匹配网络。直接延长蛇形走线、随意焊线或并接两根天线不能代替调谐;在现板上先做可逆的机械隔离更容易验证。
|
||||||
|
|
||||||
|
## 15 dBm 本轮结果
|
||||||
|
|
||||||
|
当前 15 dBm 交付 App SHA-256:`a6092e4b0cf42819c04424e066651f285b8961312a73822d667532f800e46767`,应用分区 `0x70000`。COM8 烧录与三段独立校验通过;355.1 秒串口的 71 次功率回读均为 15.00 dBm。五分钟观察:原生完整接收 **29.68 fps**,丢帧 **1.045%**,本地 JPEG 通道 **28.23 fps**,31 张抽帧全部完整解码为 640×480 RGB,无无效 JPEG、设备离线、通道断开、复位或采集错误;仍有最长 1.646 秒到帧间隔,不能将此轮称为无卡顿。距离与拥塞分析、测试边界见 [POWER.md](POWER.md) 和制品 `POWER-RESULT.json`。
|
||||||
|
|
||||||
|
按时间近似对齐的板端五分钟区间有 731 次发送失败、653 次应用重试、78 次目标帧放弃;温度 56–58°C,RSSI -35 至 -11 dBm。板端区间由五秒采样的结束时间选择,边界并非逐包精确对齐。与此前短测时长、现场无线状态和 JPEG 内容不同,不能将差值归因于功率这一项。
|
||||||
@@ -1,347 +1,294 @@
|
|||||||
# ESP32-S3 摄像头图传(Rust / ESP-IDF / UDP)
|
# ESP32-S3 摄像头图传系统
|
||||||
|
|
||||||
两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV5640),向队伍客户端与裁判终端推
|
基于 **Rust + ESP-IDF** 的局域网实时图传系统。ESP32-S3 采集摄像头输出的 JPEG,通过 UDP 向桌面客户端推流;桌面端提供队伍端与裁判端界面,并接收 ESP32-C3 装甲板的血量广播。
|
||||||
UDP 图传。
|
|
||||||
|
|
||||||
固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;客户端是 Rust + Slint
|
## 功能与架构
|
||||||
桌面程序。
|
|
||||||
|
- 摄像头自动探测、JPEG 采集、UDP 分片发送与完整帧重组。
|
||||||
|
- 局域网广播发现与订阅,单块图传板最多向 4 个客户端发送画面。
|
||||||
|
- 运行中 Wi-Fi 断开后自动重连,重新获得 IP 后重建 UDP socket 并恢复订阅。
|
||||||
|
- 使用 MAC 派生的稳定设备 ID;两块图传板可烧录同一固件,在客户端绑定队伍。
|
||||||
|
- Tauri v2 + React 桌面端:视频显示、双方血量、设备绑定与录制。
|
||||||
|
- 独立 ESP32-C3 装甲板固件:四路受击检测、WS2812 灯带及血量上报。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
protocol/ 线路格式 + 帧重组 两端共用,无 ESP-IDF 依赖,13 个单元测试
|
ESP32-S3 图传板 A ─┐ ┌─ 队伍客户端
|
||||||
. 固件 -> ESP32-S3 交叉编译,Xtensa
|
├─ 同一局域网 / UDP ─┼─ 裁判客户端
|
||||||
client/ 桌面客户端 Slint,队伍端 / 裁判端两种身份
|
ESP32-S3 图传板 B ─┤ └─ 队伍客户端
|
||||||
|
ESP32-C3 装甲主板 ─┘
|
||||||
```
|
```
|
||||||
|
|
||||||
三个 crate 各自独立构建,各有自己的 `.cargo/config.toml`。
|
图传使用 UDP **5600**,装甲板血量使用 UDP **5601**。板卡与客户端需在可互相通信、允许广播的网络中;关闭 AP 客户端隔离,并允许客户端程序通过防火墙。
|
||||||
|
|
||||||
## 拓扑
|
## 项目结构
|
||||||
|
|
||||||
|
| 路径 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/` | ESP32-S3 图传固件,根目录 Cargo 项目 |
|
||||||
|
| `components/` | 本地相机驱动及 C/Rust 接口封装 |
|
||||||
|
| `protocol/` | 共享 UDP 协议与帧重组,支持主机测试 |
|
||||||
|
| `armor/` | 独立 ESP32-C3 装甲板 workspace |
|
||||||
|
| `desktop/` | Tauri + React 客户端及独立网络核心 `core/` |
|
||||||
|
| `scripts/` | 构建、烧录与探测脚本 |
|
||||||
|
| `reference/armor/` | 早期 C 版装甲板工程 |
|
||||||
|
|
||||||
|
各子项目的构建目标不同。以下编译、烧录流程针对**根目录的 ESP32-S3 图传固件**。
|
||||||
|
|
||||||
|
## 硬件要求
|
||||||
|
|
||||||
|
| 项目 | 当前配置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 开发板 | Freenove ESP32-S3-WROOM CAM |
|
||||||
|
| 模组 | ESP32-S3-WROOM-1-N16R8,16 MB Flash + 8 MB Octal PSRAM |
|
||||||
|
| 摄像头 | OV5640;驱动包含 OV2640 / OV3660 支持,接线需核对 |
|
||||||
|
| 网络 | 2.4 GHz Wi-Fi |
|
||||||
|
| 连接 | 支持数据传输的 USB 线、可识别的串口及稳定供电 |
|
||||||
|
|
||||||
|
默认采集 VGA(640 × 480)、彩色 JPEG quality 12,按传感器节拍发送。相机引脚、Flash 分区和 PSRAM 配置见 [HARDWARE.md](HARDWARE.md)。换板型前必须核对接线,避免占用 Flash / PSRAM 引脚。
|
||||||
|
|
||||||
|
## ESP Rust 工具链入门
|
||||||
|
|
||||||
|
本项目使用 Rust `std` 和 ESP-IDF。ESP32-S3 属于 **Xtensa** 架构,需要通过 `espup` 安装 Espressif Rust 工具链;普通 `rustup target add` 不能替代这一步。根目录固定选择名为 `esp-1.97.0.0` 的工具链,避免浮动版本影响构建。
|
||||||
|
|
||||||
|
### 1. 安装基础依赖与 Rust 工具
|
||||||
|
|
||||||
|
先安装 [Rust / rustup](https://rustup.rs/)、[Git](https://git-scm.com/downloads)、[Python 3](https://www.python.org/downloads/)(建议 3.11)、[CMake](https://cmake.org/download/) 与 [Ninja](https://ninja-build.org/),确保命令在 `PATH` 中。
|
||||||
|
|
||||||
|
- **Windows**:使用 PowerShell 7.5 或更新版本,安装 Visual Studio Build Tools 的“使用 C++ 的桌面开发”和 Windows SDK,使用 MSVC Rust 主机工具链。仓库和工具链尽量使用较短、无空格和中文的路径。
|
||||||
|
- **Linux(Debian / Ubuntu)**:安装 `build-essential pkg-config libudev-dev libssl-dev python3 python3-venv cmake ninja-build git curl`。
|
||||||
|
- **macOS**:安装 Xcode Command Line Tools(`xcode-select --install`),以及 Python、CMake 和 Ninja。
|
||||||
|
|
||||||
|
以下命令显式使用 `+stable`,即使在仓库内执行,也不会要求事先已有 `esp`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
┌──────────────────────────────┐
|
rustup toolchain install stable
|
||||||
│ 裁判终端(PC,仅显示) │
|
cargo +stable install espup --locked
|
||||||
│ 队伍一 + 队伍二 + 双方血量 │
|
cargo +stable install ldproxy --locked
|
||||||
└───────────────┬──────────────┘
|
cargo +stable install espflash --version 4.4.0 --locked
|
||||||
│ 同一个局域网,广播可用
|
espup install --name esp-1.97.0.0 --toolchain-version 1.97.0.0 --targets esp32s3 --std
|
||||||
┌────────────────────┼────────────────────┐
|
|
||||||
│ │ │
|
|
||||||
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
|
|
||||||
│ 图传板 A │ │ 图传板 B │ │ 装甲板 ×4 │
|
|
||||||
│ (队伍一) │ │ (队伍二) │ │ (预留,未实现) │
|
|
||||||
└───────────────┘ └───────────────┘ └───────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**两块板子不区分队伍。** 它们只知道自己是谁(MAC 派生的 `sender_id`),"哪一边"由
|
也可以从各工具的官方 releases 下载预编译程序。`espup --std` 安装本项目所需的 Rust / LLVM 工具,ESP-IDF 的 C 工具链由后续构建准备。
|
||||||
客户端决定。烧同一份固件即可,配置全在客户端一侧。
|
|
||||||
|
|
||||||
裁判终端同时收两块板子。队伍客户端也收全部板子的视频,然后在**界面层**过滤到绑定的
|
Linux / macOS 每次新开终端后执行:
|
||||||
那一块。
|
|
||||||
|
|
||||||
## 实测性能
|
```bash
|
||||||
|
. "$HOME/export-esp.sh"
|
||||||
VGA 640x480,传感器产出上限 22 fps(OV5640 在此配置下的实测值)。
|
|
||||||
|
|
||||||
| | 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`、探测脚本的分窗打印),两
|
|
||||||
次都是同一个形状:分子用累计值,分母用窗口值,于是报出随时间单调上升的假数字。
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 1. 固件
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp .env.example .env # 填 WIFI_SSID / WIFI_PASS
|
|
||||||
./scripts/flash.sh --monitor 20
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`flash.sh` 会编译、挑选避开坏字节的 app 偏移、烧录三段(bootloader / 分区表 /
|
Windows 安装后重新打开 PowerShell,让环境变量生效;若当前终端仍找不到 LLVM,可执行 `. "$HOME/export-esp.ps1"`。检查结果:
|
||||||
app)、打印串口日志。ESP-IDF 位置从 `.cargo/config.toml` 读,不用先 `activate`。
|
|
||||||
|
|
||||||
`.env`:
|
```text
|
||||||
|
rustup toolchain list
|
||||||
```sh
|
rustc +esp-1.97.0.0 --version
|
||||||
WIFI_SSID=你的2.4G网络
|
cargo +esp-1.97.0.0 --version
|
||||||
WIFI_PASS=密码
|
espflash --version
|
||||||
|
|
||||||
# 以下都有默认值:
|
|
||||||
#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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
凭据由 `env!` 在**编译期**读取,所以没有运行时解析路径,也不会因为忘了读 `.env` 而
|
官方入门资料:[The Rust on ESP Book](https://docs.espressif.com/projects/rust/book/getting-started/toolchain.html)、[espup](https://github.com/esp-rs/espup)、[espflash](https://github.com/esp-rs/espflash/tree/v4.4.0/espflash)。
|
||||||
在板子上带着别人的密码。
|
|
||||||
|
|
||||||
`WIFI_AUTH` 一般不用设:固件先扫描 AP,用它报告的真实认证方式去连,只有扫不到时才
|
### 2. 准备 ESP-IDF
|
||||||
需要人工指定。指定之后**只**试这一种方式——不成功就直接报错,而不是挨个试 10 秒。
|
|
||||||
挨个试等于在猜一个本该已知的值。
|
|
||||||
|
|
||||||
### 2. 客户端
|
默认使用 **ESP-IDF v5.5.5**。首次构建时,`esp-idf-sys` 下载 SDK、对应 C 工具链及 Python 依赖,默认缓存到用户目录的 `.espressif/`;需要网络、磁盘空间及缓存目录写入权限。
|
||||||
|
|
||||||
```sh
|
已有匹配版本 SDK 时可在终端设置 `IDF_PATH`,不要提交个人绝对路径:
|
||||||
cd client && cargo build --release
|
|
||||||
./target/aarch64-apple-darwin/release/cam-client --list
|
```powershell
|
||||||
|
# Windows:替换为实际 SDK 源码目录。
|
||||||
|
$env:IDF_PATH = 'C:\Espressif\frameworks\esp-idf-v5.5.5'
|
||||||
```
|
```
|
||||||
|
|
||||||
`--list` 列出发现的板子。然后:
|
```bash
|
||||||
|
# Linux / macOS:替换为实际 SDK 源码目录。
|
||||||
```sh
|
export IDF_PATH="$HOME/esp/esp-idf-v5.5.5"
|
||||||
cam-client # 裁判端,无需配置
|
|
||||||
cam-client --role team --team 1 # 队伍端,首次运行问绑定
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**`client/.cargo/config.toml` 里的 `target = "aarch64-apple-darwin"` 换机器必须改。**
|
使用自动下载时,清除指向其他 SDK 的旧 `IDF_PATH`。缓存位置等高级设置见 [esp-idf-sys 构建选项](https://github.com/esp-rs/esp-idf-sys/blob/master/BUILD-OPTIONS.md)。
|
||||||
client/ 嵌在固件的目录树里,会继承父目录那个 `xtensa-esp32s3-espidf` 的交叉编译配
|
|
||||||
置,然后在依赖深处以 `libc` 找不到 `MAP_FAILED` 的形式失败。
|
|
||||||
|
|
||||||
## 发现是双向的
|
## 配置 Wi-Fi 与摄像头
|
||||||
|
|
||||||
单靠一个方向建立不了"谁要看我":
|
在根目录复制 `.env.example` 为 `.env`,再用编辑器填写;已有 `.env` 时不要覆盖。
|
||||||
|
|
||||||
- 板子每 **2 秒**广播一次 Hello —— "我在线,我是谁"
|
```powershell
|
||||||
- 客户端每 **800 ms**广播一次 Subscribe —— "我是这个角色,请给我画面"
|
# Windows PowerShell
|
||||||
|
Copy-Item .env.example .env
|
||||||
板子从订阅的**源地址**学到推流目标(所以客户端的 socket 必须固定绑 5600 端口,不能
|
|
||||||
用临时端口)。
|
|
||||||
|
|
||||||
对端靠**源地址**记忆,不发探测包。续订本来就每 800 ms 一次,所以"还活着吗"这个问题
|
|
||||||
由续订本身回答了——超时即代表它离开了,多一份心跳状态就多一个能出错的地方。
|
|
||||||
|
|
||||||
两端各有自己的超时淘汰,阈值不同:
|
|
||||||
|
|
||||||
| | 超时 | 为什么是这个量级 |
|
|
||||||
|---|---|---|
|
|
||||||
| 固件丢弃订阅者 | 6 s | 续订间隔 800 ms,7 倍余量,偶发丢包不会把人踢掉 |
|
|
||||||
| 客户端移除板子 | 60 s | 只影响界面列表,不影响推流 |
|
|
||||||
|
|
||||||
客户端的 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-*
|
|
||||||
```
|
```
|
||||||
|
|
||||||
不删不会重新编译,你的补丁看起来像完全没生效。
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
## 摄像头端不编码,客户端不编码
|
cp .env.example .env
|
||||||
|
|
||||||
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 频段看不见 |
|
|
||||||
|
|
||||||
独立核对线路格式:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 scripts/udp_probe.py 10
|
|
||||||
```
|
```
|
||||||
|
|
||||||
它手写了每一个协议常量,**不依赖 `protocol` crate**——正因为如此,如果 crate 的编码
|
```dotenv
|
||||||
有错,脚本会立刻报出不一致。
|
WIFI_SSID='你的2.4G网络名称'
|
||||||
|
WIFI_PASS='你的Wi-Fi密码'
|
||||||
|
|
||||||
## 三份文档
|
# 可选,省略时使用固件默认值。
|
||||||
|
#CAM_RES=vga
|
||||||
|
#CAM_QUALITY=12
|
||||||
|
#CAM_QUALITY_MAX=0
|
||||||
|
#CAM_MAX_FPS=0
|
||||||
|
#CAM_CLOCK_PROFILE=default
|
||||||
|
#CAM_FB_COUNT=3
|
||||||
|
#CAM_GRAYSCALE=0
|
||||||
|
#CAM_EXPOSURE_EV=0
|
||||||
|
#CAM_AE_LEVEL=-1
|
||||||
|
#CAM_SHUTTER_LINES=0
|
||||||
|
#CAM_EXPOSURE_MAX_LINES=0
|
||||||
|
#CAM_GAIN_CEILING=0
|
||||||
|
#CAM_HTS=0
|
||||||
|
#UDP_PACKET_GAP_US=0
|
||||||
|
#UDP_RETRY_BUDGET_US=8000
|
||||||
|
#CAM_THERMAL_LIMIT_C=75
|
||||||
|
#CAM_VSYNC_INVERT=1
|
||||||
|
#WIFI_AUTH=wpa2
|
||||||
|
```
|
||||||
|
|
||||||
| | |
|
| 变量 | 默认值 | 说明 |
|
||||||
|---|---|
|
| --- | --- | --- |
|
||||||
| 根 `README.md` | 你在这里。拓扑、性能、构建烧录、硬件坑 |
|
| `WIFI_SSID` | 无,必填 | 2.4 GHz 网络名称,最多 32 字节 |
|
||||||
| `protocol/README.md` | 线路格式、帧重组的取舍、每个拒收条件 |
|
| `WIFI_PASS` | 空字符串 | Wi-Fi 密码;开放网络可留空 |
|
||||||
| `client/README.md` | 命令行、绑定流程、Slint 的硬约束、线程模型 |
|
| `CAM_RES` | `vga` | `qqvga` / `qvga` / `cif` / `hvga` / `vga` / `svga` / `xga` / `sxga` / `uxga` |
|
||||||
|
| `CAM_QUALITY` | `12` | 0–63,越小通常画质越好、JPEG 越大 |
|
||||||
|
| `CAM_QUALITY_MAX` | `0` | `0` 固定画质;设为 `CAM_QUALITY`–63 时按发送回压在两者之间调节压缩,保留彩色和分辨率。拥塞时每 5 秒增大最多 3 档,连续稳定 15 秒才恢复 1 档 |
|
||||||
|
| `CAM_MAX_FPS` | `0` | 取帧与发送的最高节拍,1–60;`0` 不限帧。仅限制消费节拍,不修改传感器时钟 |
|
||||||
|
| `CAM_CLOCK_PROFILE` | `default` | `default` 保留驱动时钟;`vga30` 使用 OV5640 VGA 约 30.09 fps PLL 配置,保持 HTS/VTS 和读出窗口,SYSCLK 61 MHz / PCLK 15.25 MHz。切换后需重新核对曝光行数 |
|
||||||
|
| `CAM_FB_COUNT` | `3` | PSRAM 帧缓冲数,允许 2 或 3。当前交付选三缓冲,保留发送突发时的采集余量;双缓冲曾在短测降低部分回显延迟,但用户长程运行反馈峰值丢帧明显,保留为试验选项 |
|
||||||
|
| `CAM_GRAYSCALE` | `0` | `1` 使用传感器灰度效果,保留硬件 JPEG;`0` 彩色 |
|
||||||
|
| `CAM_EXPOSURE_EV` | `0` | OV5640 自动曝光目标偏置:`-1` / `-2` 约对应 −1.1 / −1.9 EV;不是标定后的绝对 EV,也不保证快门缩短 |
|
||||||
|
| `CAM_AE_LEVEL` | 未设置 | 可选 OV5640 驱动 AE 档位,−5–5;仅与 `CAM_EXPOSURE_EV=0` 同用。`-1` 将目标从 55 小幅降至 45,目标比值约 −0.29 EV,保留自动曝光/增益;未设置时按 EV 参数处理。实际快门和亮度变化取决于场景 |
|
||||||
|
| `CAM_SHUTTER_LINES` | `0` | OV5640 固定快门,单位为传感器曝光行;`0` 自动曝光,非零保持自动增益。默认 VGA / HTS 2060 下 220 行估算约 10 ms;数值不能超过当前 VTS |
|
||||||
|
| `CAM_EXPOSURE_MAX_LINES` | `0` | OV5640 自动曝光上限,`0` 保留驱动默认。非零保持 AEC/AGC 联动并禁用夜景延帧,不能与固定快门同时使用。默认 VGA 下 440 行约 20 ms;`vga30` 下 592 行约 20 ms。实际曝光由场景和抗频闪步长决定 |
|
||||||
|
| `CAM_GAIN_CEILING` | `0` | OV5640 自动增益上限,`0` 保留驱动默认;1–63,单位为倍,实际上限略低于整数档。短曝光遇到室内弱光时可提高,但暗部噪点和 JPEG 负载可能增加 |
|
||||||
|
| `CAM_HTS` | `0` | OV5640 水平总长度试验参数,`0` 使用驱动默认。缩短消隐会影响彩色稳定性,本机 1500 / 1800 出现绿紫闪烁,已撤回;几何检查通过不代表可用 |
|
||||||
|
| `UDP_PACKET_GAP_US` | `0` | 成功提交视频数据报之间的最小间隔;0–5000 μs,须为调度 tick 的整数倍(当前为 1000 μs)。配合有界帧等待,避免集中突发写入队列 |
|
||||||
|
| `UDP_RETRY_BUDGET_US` | `8000` | 每帧、所有分片及订阅者共用的让出调度重试预算,0–25000 μs,须为 tick 整数倍;`0` 为非阻塞失败即弃。总发送检查期限为 25 ms,采集帧龄期限为 100 ms;分片间隔也计入期限,不会额外延长 |
|
||||||
|
| `CAM_THERMAL_LIMIT_C` | `75` | 芯片内部温度达到该值时减半帧率(向上取整),下降 5°C 后恢复;40–95,`0` 禁用。原先不限帧时降至 8 fps |
|
||||||
|
| `CAM_VSYNC_INVERT` | `1` | 驱动反相 VSYNC;硬件已反相时填 `0` |
|
||||||
|
| `WIFI_AUTH` | 自动选择 | `none` / `wep` / `wpa` / `wpa2` / `wpawpa2` / `wpa3` / `wpa2wpa3` / `wapi` |
|
||||||
|
| `WIFI_TX_POWER_DBM` | SDK 默认上限 | 可选整数 2–20 dBm;当前交付请求 15 dBm,本板驱动回读 15.00 dBm;启动及重连时应用,拥塞和温控逻辑不降低此上限 |
|
||||||
|
|
||||||
## 装甲板(预留)
|
`.env` 使用简单的 `KEY=value`;值含空格、`#` 或 `$` 时用单引号包围,不使用变量展开或命令替换。脚本会加载 `.env`,直接 `cargo build` 不会自动读取它。上述参数在编译时写入固件,修改后需要重新编译和烧录。`.env` 已被 Git 忽略;固件也包含凭据,分享前应重新配置。
|
||||||
|
|
||||||
`protocol::ArmorHealth`、`ARMOR_PORT`(5601)、`MsgType` 5 号都已就位。客户端有真实的
|
运行时断线恢复不重置摄像头、曝光或增益。首次尝试立即发起,失败后按 1、2、4、8、16、30 秒退避;关联与 DHCP 分别有 15 秒期限。恢复 IP 后丢弃旧采集帧、重建 socket 并重新广播,由客户端自动续订。启动时首次联网仍使用扫描与认证模式探测流程。实现依据 [ESP-IDF 断线事件处理说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wifi-event-sta-disconnected)。
|
||||||
`ArmorSource` trait,当前实现是 `NoArmorYet`。
|
|
||||||
|
|
||||||
**界面显示占位符而不是 0。** 0 看起来像真值——现场看到两个 0 会以为对方已经没血了。
|
## 编译固件
|
||||||
|
|
||||||
接上以后板子向 `ARMOR_PORT` 发这个结构,客户端实现 `ArmorSource` 即可,界面和其余代
|
在仓库根目录执行:
|
||||||
码都不用改。
|
|
||||||
|
```powershell
|
||||||
|
# Windows PowerShell
|
||||||
|
$env:CARGO_TARGET_DIR = 'C:\s3vt'
|
||||||
|
.\scripts\build.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
|
./scripts/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本执行 `cargo build --release --locked`,目标为 `xtensa-esp32s3-espidf`。首次编译包含 Rust 标准库、ESP-IDF 和相机组件,耗时较长。
|
||||||
|
|
||||||
|
Windows 的 ESP-IDF 构建输出路径需不超过 88 字符,因此示例使用短目录 `C:\s3vt`(需可写,且不与其他项目共用)。`subst` 不能绕过该检查。下表的 `<TARGET_DIR>` 在 Windows 示例中为 `C:/s3vt`,Linux / macOS 默认是仓库的 `target`。每次新开 Windows 终端都要设置相同的 `CARGO_TARGET_DIR`。
|
||||||
|
|
||||||
|
| 产物 | 位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 固件 ELF | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/esp32`(没有 `.exe` 后缀) |
|
||||||
|
| Bootloader | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/bootloader.bin` |
|
||||||
|
| 分区表 BIN | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/partition-table.bin` |
|
||||||
|
| 分区表源文件 | 根目录 `partitions.csv` |
|
||||||
|
|
||||||
|
ELF 供 `espflash` 烧录与符号解析,不能当作 BIN 直接写入 Flash。Bootloader、分区表与 App 应来自同一次构建。
|
||||||
|
|
||||||
|
## 烧录与串口验证
|
||||||
|
|
||||||
|
### 1. 查找串口
|
||||||
|
|
||||||
|
```text
|
||||||
|
espflash list-ports
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 常见 `COM5`;Linux 为 `/dev/ttyACM0` 或 `/dev/ttyUSB0`;macOS 为 `/dev/cu.usbmodem...`。关闭其他串口监视器,Linux 需具备串口访问权限。
|
||||||
|
|
||||||
|
### 2. 烧录 ELF
|
||||||
|
|
||||||
|
本项目使用自定义分区表,显式传入 `partitions.csv` 和本次构建的 Bootloader。将示例端口替换为实际端口:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$targetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { 'target' }
|
||||||
|
espflash flash --chip esp32s3 --port COM5 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$targetDir/xtensa-esp32s3-espidf/release/bootloader.bin" --monitor "$targetDir/xtensa-esp32s3-espidf/release/esp32"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
target_dir="${CARGO_TARGET_DIR:-target}"
|
||||||
|
espflash flash --chip esp32s3 --port /dev/ttyACM0 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$target_dir/xtensa-esp32s3-espidf/release/bootloader.bin" --monitor "$target_dir/xtensa-esp32s3-espidf/release/esp32"
|
||||||
|
```
|
||||||
|
|
||||||
|
连接失败时按住 **BOOT**,点按 **RESET / EN**,松开 BOOT 后重试。正常启动应看到相机识别、Wi-Fi 获取 IP 和 `ESP32 camera is live` 日志。没有客户端时,`0 clients subscribed` 属于正常状态。
|
||||||
|
|
||||||
|
仅查看日志:
|
||||||
|
|
||||||
|
```text
|
||||||
|
espflash monitor --port COM5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 导出 BIN
|
||||||
|
|
||||||
|
`espflash save-image --merge` 可导出从 `0x0` 写入的合并镜像。完整命令、独立镜像偏移与测试方法见 [DEVELOPMENT.md](DEVELOPMENT.md)。
|
||||||
|
|
||||||
|
保留的 `scripts/flash.sh` 针对**特定坏 Flash 模组**,依赖 macOS 环境,会修改分区并重编译。普通板卡使用上述标准流程;坏板限制见 [HARDWARE.md](HARDWARE.md#损坏-flash-模组的兼容处理)。
|
||||||
|
|
||||||
|
## 客户端与装甲板
|
||||||
|
|
||||||
|
桌面端入口如下。安装依赖、主机 target 设置与功能说明见 [desktop/README.md](desktop/README.md):
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd desktop
|
||||||
|
pnpm install
|
||||||
|
pnpm app
|
||||||
|
```
|
||||||
|
|
||||||
|
装甲板是独立的 **ESP32-C3 / RISC-V** 工程,不能使用 S3 的 ELF。`ARMOR_TEAM=0/1` 在编译期决定红 / 蓝队伍。接线、规则与构建烧录见 [armor/README.md](armor/README.md)。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
| 现象 | 检查方法 |
|
||||||
|
| --- | --- |
|
||||||
|
| 提示缺少 `esp-1.97.0.0` | 按上述命令安装指定名称的工具链,重新打开终端后检查版本 |
|
||||||
|
| `espup` 查询 GitHub 版本返回 403 | 对上述固定版本安装命令追加 `--skip-version-parse`,或稍后重试 |
|
||||||
|
| 找不到 `ldproxy` / `libclang` | 安装链接工具;Unix 加载 `export-esp.sh`,Windows 重开终端 |
|
||||||
|
| ESP-IDF 路径或版本错误 | 清除旧 `IDF_PATH` 或指向 v5.5.5 SDK |
|
||||||
|
| Windows 路径过长 | 缩短仓库和工具链路径,见开发说明 |
|
||||||
|
| Wi-Fi 连不上 | 核对 2.4 GHz、凭据和认证方式,再重新编译烧录 |
|
||||||
|
| 相机无输出 / 初始化失败 | 核对板型接线、供电与 Octal PSRAM |
|
||||||
|
| `NO-EOI`、几帧后停止 | 确认 `CONFIG_CAMERA_PSRAM_DMA=n` |
|
||||||
|
| 在线但没有画面 | 检查客户端订阅、UDP 5600、防火墙及 AP 隔离 |
|
||||||
|
| 开启 VPN / 虚拟网卡后设备在线但无画面 | 使用支持按已发现设备 IP 定向续订的客户端;仅发送 `255.255.255.255` 广播可能走错网卡 |
|
||||||
|
| 改 C 组件后无变化 | 清理 `esp-idf-sys` 构建缓存后重编,见开发说明 |
|
||||||
|
| 主机项目被编为 Xtensa | 显式指定宿主 target,见各子项目说明 |
|
||||||
|
|
||||||
|
用 `python scripts/udp_probe.py 10` 可独立检查视频协议;装甲板用 `python scripts/armor_probe.py 15`。这些脚本需要板卡在线,构建通过不代表已完成硬件验证。
|
||||||
|
|
||||||
|
测量帧率和丢帧可运行以下命令,将 IP 换成板卡实际地址。对照测试时先退出桌面客户端,避免额外订阅者增加无线负载:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python scripts/perf_probe.py 192.168.1.139 --seconds 30 --json perf.json
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本定向续订,支持保留 VPN / TUN 的环境。输出包含完整帧率、帧损失率、接收间隔和组装耗时;这些时间不等于摄像头到屏幕的端到端延迟。
|
||||||
|
|
||||||
|
运动图传保持 `CAM_GRAYSCALE=0`,优先使用自动曝光上限和自动增益协同控制拖影。室内强制 −1 EV / 固定短快门在本机过暗,已撤回;提高增益上限也会增加噪点。需要限流时可设置 `CAM_MAX_FPS=15`,追求帧率时使用 `0`;可启用有界 JPEG 压缩调节应对发送回压,分片间隔按网络实测选择。灰度效果仍保留硬件 JPEG,不是改为 RGB565 或降低 JPEG 采样位数。75°C 是主动减载阈值,温度来自 ESP 芯片内部,不能代表摄像头或环境温度,也不是芯片损坏温度规格。五分钟彩色对照与此前中止的灰度长测见 [STABILITY.md](STABILITY.md)。
|
||||||
|
|
||||||
|
## 文档索引
|
||||||
|
|
||||||
|
- [CHANGELOG.md](CHANGELOG.md):改动记录与历史修复。
|
||||||
|
- [HARDWARE.md](HARDWARE.md):接线、内存、Flash 与驱动限制。
|
||||||
|
- [DEVELOPMENT.md](DEVELOPMENT.md):镜像导出、测试、协议与性能测量。
|
||||||
|
- [PERFORMANCE.md](PERFORMANCE.md):保留 TUN 的图传排查、参数取舍与实际测试结果。
|
||||||
|
- [STABILITY.md](STABILITY.md):限帧、灰度与温度减载,长时间运行测试。
|
||||||
|
- [REALTIME.md](REALTIME.md):阻塞时取最新帧、超期丢弃和客户端积压处理。
|
||||||
|
- [POWER.md](POWER.md):15 dBm 回退、功率与距离的关系,以及发送拥塞证据。
|
||||||
|
- [LATENCY.md](LATENCY.md):当前约 30 fps 配置、Wi-Fi 重连及实际画面链路延迟对照。
|
||||||
|
- [protocol/README.md](protocol/README.md):UDP 线路格式及帧重组。
|
||||||
|
- [armor/README.md](armor/README.md):装甲板固件与规则。
|
||||||
|
- [desktop/README.md](desktop/README.md):桌面端配置、录制与排查。
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
# 阻塞恢复与最新帧处理(2026-10-18)
|
||||||
|
|
||||||
|
## 当前交付
|
||||||
|
|
||||||
|
当前 15 dBm 交付 App SHA-256:`a6092e4b0cf42819c04424e066651f285b8961312a73822d667532f800e46767`,应用分区 `0x70000`。COM8 烧录与三段独立校验通过;355.1 秒串口的 71 次功率回读均为 15.00 dBm。五分钟观察:原生完整接收 **29.68 fps**,丢帧 **1.045%**,本地 JPEG 通道 **28.23 fps**,31 张抽帧全部完整解码为 640×480 RGB,无无效 JPEG、设备离线、通道断开、复位或采集错误;仍有最长 1.646 秒到帧间隔,不能将此轮称为无卡顿。距离与拥塞分析、测试边界见 [POWER.md](POWER.md) 和制品 `POWER-RESULT.json`。
|
||||||
|
|
||||||
|
保留已确认可用的三块 PSRAM 采集缓冲、OV5640 VGA 彩色、约 30 fps PLL 时钟、AE 目标 45/55、约 20 ms 曝光上限及自动增益;Wi-Fi 自动重连和 TUN 兼容订阅继续保留。没有增加待发送 JPEG FIFO,也没有为了消除队列改成单块 DMA 缓冲。
|
||||||
|
|
||||||
|
发送程序只持有当前一张完整 JPEG;阻塞恢复后通过有限次数的零等待取帧选择最新有效帧,旧帧立即归还驱动。三块采集内存用于捕获余量,与必须按序播放的三帧队列不同。已有相机队列仍然有限,应用交接会跳过其旧帧。
|
||||||
|
|
||||||
|
完整 JPEG 取得后按 1400 字节载荷分片立即提交,复用一个数据报缓冲,直接读取 PSRAM。当前传感器驱动在 JPEG 结束后交出完整帧,本次没有修改为 DMA 尚未完成就读取其内容;不完整 JPEG 由接收端整帧丢弃,不进入绘制。
|
||||||
|
|
||||||
|
## 阻塞处理
|
||||||
|
|
||||||
|
| 阶段 | 处理方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| 采集交接 | 首帧最多等待 100 ms,其后零等待、有限次数取更新的有效帧;归还被替代的旧缓冲 |
|
||||||
|
| 发送帧龄 | 用板内同一单调时钟检查从首个 DMA 缓冲开始的年龄,期限 100 ms |
|
||||||
|
| 当前帧发送 | 绝对检查期限 25 ms,成功发送分片不会续期,所有订阅者共享 |
|
||||||
|
| 拥塞重试 | 默认每帧最多 8 ms 让出调度重试;预算用尽或过期后放弃该目标剩余分片,转向新帧 |
|
||||||
|
| 原生本地通道 | 每路保留最新帧;不重放超过 100 ms 的缓存,写入超过 100 ms 时断开慢端以便重连 |
|
||||||
|
| WebView / 解码 | 原生端在 JPEG 前发送 `frameTiming`;JS 在解码前后拒绝本机滞留超过 100 ms 的帧并释放旧结果 |
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
CAM_FB_COUNT=3
|
||||||
|
CAM_AE_LEVEL=-1
|
||||||
|
CAM_EXPOSURE_EV=0
|
||||||
|
CAM_EXPOSURE_MAX_LINES=592
|
||||||
|
UDP_PACKET_GAP_US=0
|
||||||
|
UDP_RETRY_BUDGET_US=8000
|
||||||
|
WIFI_TX_POWER_DBM=15
|
||||||
|
```
|
||||||
|
|
||||||
|
当前交付根据用户反馈回退到 15 dBm,实板驱动回读 15.00 dBm。启动和重连应用同一功率上限,拥塞和温控逻辑不自动降功率;此前 17 dBm 的数据作为历史记录保留,见 [POWER.md](POWER.md)。
|
||||||
|
|
||||||
|
若要在发送回压时立即放弃本帧,可将 `UDP_RETRY_BUDGET_US=0` 后重编烧录。该模式仍保留三块采集内存和完整 JPEG 校验;含义是零让出重试,不是删除驱动和路由器中的所有缓冲。零重试策略已经主机测试,本次五分钟实机使用默认 8000 μs。
|
||||||
|
|
||||||
|
分片间隔也计入 25 ms 检查期限,不再额外延长帧的发送寿命;设置过大的间隔可能使整帧无法在期限内提交。分片在 SDK socket 中已经接受后无法由本策略撤回,底层调度和网络排队也无法由应用层强制抢占。以上为应用层新鲜度策略,不能保证每帧、无线链路或物理屏幕具有硬实时上限。
|
||||||
|
|
||||||
|
本地 JPEG 二进制头继续为 16 字节,旧消费者可忽略新增文本消息。新前端连接旧后端时仍可用,但没有原生发布时间,只能按 JS 收到时刻判断本地队列年龄。两端本机发布时间使用同机系统时间;这不是 ESP 与电脑的时钟同步,也不能据此计算无线单向延迟。JS 内部排队同时使用单调时钟检查。
|
||||||
|
|
||||||
|
## 此前 14 dBm 版本的实机五分钟检查
|
||||||
|
|
||||||
|
该次测试使用 14 dBm,App SHA-256:`9a7decd59572c3f280e7fd4c0a291645011b779025e56e99ea50087e92b5fa77`,应用分区 `0x60000`。以下五分钟数据属于该历史镜像,不是提高功率后的重测结果。该镜像曾经 COM8 烧录并独立三段校验,三段镜像必须成套使用。运行保留 TUN、只有一个 UDP 订阅者;串口和本地视频观察器不会产生额外 UDP 推流。
|
||||||
|
|
||||||
|
| 指标 | 本次结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 本地 JPEG 通道观察时长 | 300.005 s |
|
||||||
|
| 原生接收计数有效区间 | 180.937 s |
|
||||||
|
| 完整接收 / 丢帧 | 5390 / 26(仅有效区间) |
|
||||||
|
| 完整接收帧率 / 帧损失率 | 29.79 fps / 0.480%(仅有效区间) |
|
||||||
|
| 本地 JPEG 通道 | 29.69 fps |
|
||||||
|
| 本地 JPEG 通道到达间隔 P95 / 最大值 | 47.648 / 323.146 ms |
|
||||||
|
| 主动跳过旧采集帧 | 0 |
|
||||||
|
| 发送前过期帧 / 发送期限目标丢弃 | 0 / 0 |
|
||||||
|
| 提交时采集帧龄最大观测值 | 74.13 ms |
|
||||||
|
| 发送持有时间最大观测值 | 11.23 ms |
|
||||||
|
| 复位 / 采集错误 | 0 / 0 |
|
||||||
|
|
||||||
|
观察途中界面切到队伍端,按设计隐藏诊断计数,所以原生完整帧率和损失率只报告隐藏前的有效区间,不提供完整五分钟的原生丢帧率;本地 JPEG 通道则完整观察五分钟。保留用户当前界面选择,没有强制切回裁判端。两个最大值是板端自启动起的观测值,包含预热;帧龄从驱动的首个 DMA 缓冲时间戳开始,不是光子进入传感器到屏幕的时间。主动跳过的采集帧尚未分配网络帧号,不计入接收端丢帧率;两种计数不能混为一谈。另抽取 61 张 JPEG,均完整解码为 640×480 RGB。
|
||||||
|
|
||||||
|
部署前的 30 秒基线仅用于检查连通性,画面内容和无线状态有变化,不能据此证明本策略使丢帧率或光学延迟降低。本次未重新测量物理端到端延迟,也未证明所有长程拥塞峰值均已解决。较早的标记回显测量保留在 [LATENCY.md](LATENCY.md)。
|
||||||
|
|
||||||
|
## 阻塞恢复验证
|
||||||
|
|
||||||
|
真实原生视频通道上的辅助接收端主动阻塞事件循环 350 ms,生产 `StreamClient` 恢复后拒绝 8 张过期帧,帧号从 1886 前进至 1918。该测试没有新增 UDP 订阅者;JPEG 解码在此辅助测试中使用替身,验证的是本地积压年龄和恢复逻辑,实际 JPEG 完整解码另行检查。
|
||||||
|
|
||||||
|
- 12 项 Rust 调度策略测试:绝对帧期限、旧采集帧寿命、共享重试预算、零重试及原有调度策略。
|
||||||
|
- 4 项 C 驱动交接情景:有限次数追赶、旧缓冲仅归还一次、无有效新帧时保留已有帧、首次超时及单缓冲边界。
|
||||||
|
- 8 项前端测试:慢解码保留最新待处理帧、500 ms 本机积压拒绝、阻塞解码结果释放、待解码帧超期和原有关闭/重启处理。
|
||||||
|
- 10 项原生视频通道测试:时间标记先于对应 JPEG、重连不重放过期缓存及原有协议与通道行为。
|
||||||
|
|
||||||
|
使用 `python scripts/test-latest-frame.py` 运行驱动交接测试;`desktop/` 中运行 `pnpm test:stream`。实时通道故障测试为 `node desktop/scripts/test-live-freshness.mjs <本机私有调试日志> <结果JSON>`,日志内的通道令牌仅在程序内部读取,不应发布。完整测量与边界见制品 `REALTIME-RESULT.json`,不包含私有日志、用户配置或相机抽帧。
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
# 彩色图传调度与持续运行验证(2026-10-18)
|
||||||
|
|
||||||
|
本文保留此前约 22 fps 配置及更早长测的历史记录。后续约 30 fps PLL 配置、缓冲选择、运行中断线恢复与画布回显延迟测量见 [LATENCY.md](LATENCY.md);以下参数与 SHA 属于当时的测试阶段,当前交付以新报告和制品 `BUILD-INFO.json` 为准。
|
||||||
|
|
||||||
|
## 测试目标与配置
|
||||||
|
|
||||||
|
使用 ESP32-S3-N16R8 / OV5640、VGA、8 MB PSRAM、CPU 240 MHz、Windows 原生客户端,保留 Mihomo TUN。每轮只运行一个 UDP 订阅者,串口记录器与本地 WebSocket 观察器不会新增 UDP 推流目标。Windows 到板卡的实际路由走 WLAN;电脑连接 5 GHz、板卡连接 2.4 GHz。
|
||||||
|
|
||||||
|
优化目标为彩色、内容可辨、较短曝光和尽可能高的完整接收帧率,每轮有效测量为 5 分钟。下文保留了亮度与时序调整过程;不同轮次的场景、曝光、压缩和网络状态有变化,接收数字是描述性对照,不能作为严格单因素实验。
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
CAM_RES=vga
|
||||||
|
CAM_QUALITY=18
|
||||||
|
CAM_QUALITY_MAX=30
|
||||||
|
CAM_MAX_FPS=0
|
||||||
|
CAM_GRAYSCALE=0
|
||||||
|
CAM_EXPOSURE_EV=0
|
||||||
|
CAM_SHUTTER_LINES=0
|
||||||
|
CAM_EXPOSURE_MAX_LINES=440
|
||||||
|
CAM_GAIN_CEILING=32
|
||||||
|
CAM_HTS=0
|
||||||
|
CAM_THERMAL_LIMIT_C=75
|
||||||
|
WIFI_TX_POWER_DBM=14
|
||||||
|
UDP_PACKET_GAP_US=0
|
||||||
|
```
|
||||||
|
|
||||||
|
这些参数在编译时生效。该阶段配置的板上无网络采集探针为 22.20 fps,保留驱动默认 HTS 2060 / VTS 984 / XCLK 20 MHz;未采用颜色异常的短 HTS,也未采用此前导致采集下降至 8.1 fps 的 VTS 调整。
|
||||||
|
|
||||||
|
## 曝光与彩色
|
||||||
|
|
||||||
|
OV5640 驱动的 AE 档位不是 EV 数值:名义零档目标为 55,档位 −3 的目标为 25,目标比值约为 −1.14 EV;档位 −4 的目标为 15,约为 −1.87 EV。固件的 `CAM_EXPOSURE_EV=-1/-2` 使用这两个近似档位;这不是经过照度标定的绝对 EV。
|
||||||
|
|
||||||
|
早期仅降低 AE 目标后,实际曝光仍为 885 行;固定 220 行约为 10 ms,但用户反馈太暗,固定约 20 ms 也仍然太暗,均已撤回。其他时钟或分辨率下,行数对应的时间不同,不能直接套用行数。
|
||||||
|
|
||||||
|
该阶段采用正常亮度目标、自动 AEC / AGC、440 行曝光上限,关闭夜景延帧,并按现有 50/60 Hz band step 同步约束最大 band 数。自动增益上限由原来的 15.5× 扩至 31.94×,给短曝光补偿空间;增益会放大噪点。预检实际曝光为 295 行,按 SYSCLK 45 MHz / HTS 2060 估算约 13.5 ms,约为早期 885 行曝光的三分之一。440 行上限约 20.1 ms,实际值由场景与频闪步长决定。
|
||||||
|
|
||||||
|
用户已确认新配置**亮度够用、颜色稳定**。它仍使用传感器硬件彩色 JPEG,不通过降低色深来提帧。
|
||||||
|
|
||||||
|
彩色 JPEG 抽帧已解码为 640×480 RGB,仍使用传感器硬件 JPEG 编码。没有用灰度换取本轮帧率。曝光行数日志用于验证快门配置,未进行运动靶拖影或照度标定测量。驱动依据:[Espressif OV5640 源码](https://github.com/espressif/esp32-camera/blob/master/sensors/ov5640.c)。
|
||||||
|
|
||||||
|
## 最终配置的五分钟验证
|
||||||
|
|
||||||
|
本轮测量从 `2026-10-18T12:24:10.381357+00:00` 到 `2026-10-18T12:29:10.384000+00:00`,持续 300.004 秒。下表对照前一轮固定约 20 ms、quality 18 的完整五分钟记录;前一轮用户确认过暗,本轮用户确认亮度够用、颜色稳定。
|
||||||
|
|
||||||
|
| 指标 | 固定快门对照 | 自动曝光上限 + 压缩调节 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 完整接收帧 | 5220 | 6520 |
|
||||||
|
| 丢帧 | 1430(21.50%) | 34(0.52%) |
|
||||||
|
| 完整接收帧率 | 17.40 fps | 21.73 fps |
|
||||||
|
| 本地 JPEG 通道帧率 | 13.45 fps | 20.99 fps |
|
||||||
|
| 通道帧间隔 P95 | 277.473 ms | 102.841 ms |
|
||||||
|
| 通道帧间隔最大值 | 1163.792 ms | 419.825 ms |
|
||||||
|
| 平均 JPEG 大小 | 12099 字节 | 16346 字节 |
|
||||||
|
|
||||||
|
本轮全程在线,无复位、采集错误、本地通道断开、计数重置或诊断计数缺失。视频包长度与首尾标记错误为 0;另抽取 148 张 JPEG 完整解码,均为 640×480 RGB。
|
||||||
|
|
||||||
|
测量窗口内曝光为 295–295 行;JPEG 档位为 18–22,实际增益 11.00–18.00×。内部堆最小 129455 字节,平均提交耗时 2.60 ms/帧,温度 54–59°C,未触发温度减载。
|
||||||
|
|
||||||
|
这次完整接收帧率接近当前可靠彩色时序的约 22.2 fps 上限。亮度、画面内容、JPEG 大小和网络负载在各轮之间变化,不能把全部差异归因于单一控制参数,也不能将五分钟结果作为长期或所有路由器的保证。帧间隔不是摄像头到屏幕的光学延迟。
|
||||||
|
|
||||||
|
本轮固件 SHA-256 为 `e297069e190dcccefbae6d95591703c6848f4e523f910e2c0cb44af91f698936`,当时与交付及板上镜像一致;最新交付见 LATENCY.md。配套 Bootloader、分区表及 App 均已独立读回校验。曲线和完整数据位于制品包内的 `STABILITY.png` / `SCHEDULING-RESULT.json`,不包含相机抽帧或令牌日志。
|
||||||
|
|
||||||
|
## 较早的五分钟彩色基线
|
||||||
|
|
||||||
|
以下为较早的完整 300.002 秒测量,HTS 保持驱动默认 2060,快门 220 行,UDP 分片间隔为 0。真实 Windows 客户端接收,非演示模式,单 UDP 订阅者。该场景 JPEG 约 9 KB,用户随后确认这一曝光方案过暗,因此不再作为最终交付配置。
|
||||||
|
|
||||||
|
| 指标 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 完整接收帧 | 6647 |
|
||||||
|
| 丢帧 | 12,0.1802% |
|
||||||
|
| 完整接收帧率 | 22.1565 fps |
|
||||||
|
| 本地 JPEG 通道帧率 | 20.8365 fps,6251 帧 |
|
||||||
|
| 接收通道帧间隔 P95 / 最大值 | 102.459 / 408.082 ms |
|
||||||
|
| 平均 JPEG 大小 | 9113 字节 |
|
||||||
|
| 实际曝光 | 全程 220 行 |
|
||||||
|
| 采集错误 / 复位 / 本地连接断开 | 0 / 0 / 0 |
|
||||||
|
|
||||||
|
图传完整帧率已接近该采集配置的约 22.2 fps 上限;本地通道采用 latest 模式,突发到达的多帧可能被合并,不能用其帧数代表屏幕已绘制帧数。不同场景下 JPEG 大小和无线负载会变化。
|
||||||
|
|
||||||
|
## 间隔发送的中止轮次
|
||||||
|
|
||||||
|
1000 μs 最小分片间隔轮次在设备运行约 235 秒时出现串口 I/O 错误,随后系统不再枚举开发板串口,摄像头同时离线。观察器提前停止,未完成有效的五分钟对照,断连尾段不能归因于调度。
|
||||||
|
|
||||||
|
断连前 3 个完整在线窗口合计 181.192 秒,接收 21.58 fps、帧损失 1.96%,本地通道 17.25 fps,没有显示出逐包间隔的收益。它可能影响 AMPDU 聚合效率,但未抓取空口包,不能确认唯一原因。保留突发发送作为已验证基线,不采用这轮的完整汇总作性能结论。
|
||||||
|
|
||||||
|
## 已撤回的行时序提帧试验
|
||||||
|
|
||||||
|
新增可选 `CAM_HTS`,仅改变 OV5640 水平总时长,保留有效读出窗口及消隐余量,PLL、XCLK、VTS 不变。候选参数为 HTS 1500、快门 300 行,估算保持约 10 ms 曝光,采集节拍目标约 30.5 fps。
|
||||||
|
|
||||||
|
USB 恢复后,HTS 1500 的板上采集探针达到 30.49 fps,但白光环境出现明显绿紫闪烁,用户确认不可用。HTS 1800 的探针为 25.41 fps,仍出现明显偏紫,也已中止;两者均不作为有效五分钟彩色结果或交付配置。恢复默认 HTS 2060 后,用户确认颜色稳定。
|
||||||
|
|
||||||
|
1500 轮次开始时队伍 release 模式隐藏接收计数,因此该轮汇总中的接收 fps / 丢帧为无效值,不能当作零丢帧;观察脚本现已要求裁判诊断计数并记录诊断可用性。`0x380C/0x380D` 的水平总长定义见 [OV5640 数据手册](https://files.waveshare.com/upload/d/da/OV5640_DataSheet.pdf)。几何检查和 JPEG 成功解码都不足以证明彩色稳定。
|
||||||
|
|
||||||
|
## 室内亮度回归
|
||||||
|
|
||||||
|
用户确认 −1 EV / 固定 10 ms,以及恢复名义 0 EV / 固定约 20 ms 后仍然太暗。因此最终方案不再固定短快门,而是让 AEC / AGC 自动联动,并以曝光上限控制拖影。手册说明 AGC 通常在 AEC 到达上限后继续增加;仅保留 AGC 开关不能当作亮度已经补足的证据。需要结合实际曝光、增益和画面验证。
|
||||||
|
|
||||||
|
固定 20 ms 的完整五分钟轮次接收 5220 帧、丢帧 1430(21.50%),接收 17.40 fps,本地 JPEG 通道 13.45 fps,接收间隔 P95 277.473 ms、最大 1163.792 ms;全程在线、无采集错误。它保留为拥塞对照,未被选为最终配置。
|
||||||
|
|
||||||
|
## 发送调度
|
||||||
|
|
||||||
|
当前启用有界 JPEG 压缩调节,`CAM_QUALITY=18` 为画质恢复目标,`CAM_QUALITY_MAX=30` 为压缩上限。每 5 秒检查本机发送窗口:对端帧放弃比例达到 2%,或平均提交耗时达到 8 ms 时,压缩数字增加 3 档;连续 3 个无放弃且提交小于 4 ms 的窗口后,才降低 1 档恢复细节。无订阅或流量不足时不会当作健康窗口。
|
||||||
|
|
||||||
|
该反馈来自板端提交回压,不包含接收端 ACK;不能据此声称消除了路由器后续丢包。调节不改变彩色、分辨率或采集时钟,运行日志记录实际 JPEG 档位、曝光行数和增益。
|
||||||
|
|
||||||
|
视频数据报使用 WMM AC_VI、保留 AMPDU。`UDP_PACKET_GAP_US` 控制成功提交数据报的最小间隔,调度 tick 可能使实际等待更长。等待期间让出 CPU,避免在帧开始时集中向 Wi-Fi 队列灌入全部分片。
|
||||||
|
|
||||||
|
每帧预算为 25 ms 加计划分片间隔,总等待上限 40 ms;没有调度间隔时仍为 25 ms。超过预算则放弃该对端的剩余旧帧,不追赶补发。分片数量较多时需要重新选择间隔,不能把某个 VGA 配置的结果直接套到更大图像。
|
||||||
|
|
||||||
|
`submitted` 只表示分片全部交给了网络,不代表无线确认或屏幕显示。帧损失包含接收端未完成帧与帧号缺口;设置帧率上限后,latest 模式有意覆盖的旧采集帧不进入 UDP 帧编号,不计入该丢帧率。
|
||||||
|
|
||||||
|
## 此前中止的灰度长测
|
||||||
|
|
||||||
|
原计划 30 分钟的灰度 / 15 fps 长测按用户要求提前停止,保留了 **18 个完整分钟窗口**,合计 1080.15 秒:完整帧 15307、丢帧 806,平均 14.17 fps,帧损失 5.00%。板端记录温度约 48–58°C,无复位和采集错误。
|
||||||
|
|
||||||
|
早期丢帧上升时温度没有同步上升,温度下降期间也出现过明显发送失败。内存紧张后能够恢复;发送失败与队列回压更符合当前证据。这一轮不计为完成 30 分钟验证,且配置不同,不能与彩色不限帧试验直接作单因素对照。
|
||||||
|
|
||||||
|
温度控制仍可选:达到 75°C 时将已设置的帧率减半(向上取整),原来不限帧时降至 8 fps;降到 70°C 后恢复。阈值可设 40–95°C,`0` 禁用。75°C 是主动减载策略,不是芯片损坏温度规格。本轮未触发该策略。ESP 内部温度不能代表 OV5640 或环境温度:[ESP-IDF v5.5.5 温度传感器说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-reference/peripherals/temp_sensor.html)。
|
||||||
|
|
||||||
|
## 复测与记录
|
||||||
|
|
||||||
|
退出其他图传客户端,使用一个非演示模式的 Windows 裁判端。release 队伍模式隐藏技术计数,不能用于本脚本的接收 fps / 丢帧测量;用户在本机切换身份后可继续使用原进程,本次没有改写用户配置。`RUST_LOG=cam_desktop=debug,cam_core=info` 会记录本地 WebSocket 地址及令牌,原始日志仅供本机诊断,不应公开。
|
||||||
|
|
||||||
|
从仓库根目录分别运行,串口及设备 ID 换成实际值:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python scripts/serial_soak.py --port COM7 --seconds 330 --out target/soak/device --stop-file target/soak/stop
|
||||||
|
node desktop/scripts/soak-stream.mjs --log target/soak/referee-stderr.log --out target/soak/client --seconds 300 --warmup 10 --stop-file target/soak/stop --sample-every 5
|
||||||
|
```
|
||||||
|
|
||||||
|
串口脚本依赖 `pyserial`;观察脚本使用 Node.js 22 内置 WebSocket。默认设备 ID 为 `0x3D4D8A9A`,换板用 `--camera 0x设备ID`。创建 stop 文件可提前结束并保存部分窗口;新一轮使用不存在的 stop 文件路径。
|
||||||
|
|
||||||
|
串口每 5 秒保存温度、曝光行数、增益、JPEG 档位、内部堆、采集错误、发送重试、订阅数与帧率。客户端每分钟保存完整帧率、帧损失、JPEG 通道帧率、间隔 P95 / 最大值与连接状态,并标记离线、诊断不可用与末尾无视频时长。`--sample-every` 可选,单位为秒,抽帧仅留本机。JSON 标记实际时长与是否提前停止,不能将部分窗口称为完成 5 分钟。原始记录位于本机 `target/soak/`,随构建缓存忽略。
|
||||||
|
|
||||||
|
JPEG 长度与首尾标记校验不等于每张图的完整解码验证;本地 WebSocket 通道不代表 WebView 最终绘制。接收间隔与分片组装耗时均不是摄像头到屏幕的光学延迟。
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 装甲板的目标是 ESP32-C3(RISC-V),与外层 esp32/.cargo/config.toml 的
|
||||||
|
# Xtensa 目标互不相干。这个文件离得近,所以会覆盖外层同名键(尤其是 MCU)。
|
||||||
|
[build]
|
||||||
|
target = "riscv32imc-esp-espidf"
|
||||||
|
|
||||||
|
[target.riscv32imc-esp-espidf]
|
||||||
|
linker = "ldproxy"
|
||||||
|
runner = "espflash flash --monitor"
|
||||||
|
rustflags = ["--cfg", "espidf_time64"]
|
||||||
|
|
||||||
|
[unstable]
|
||||||
|
# `esp` 工具链只带了 host 的预编译 std,没有 esp 目标(与图传板那套一样)。
|
||||||
|
# 所以必须自己编 std:键名是 `build-std`(连字符),写成 `build_std` 会被 cargo
|
||||||
|
# 当成未知键静默忽略,然后在链接阶段报找不到 `core`。
|
||||||
|
build-std = ["std", "panic_abort"]
|
||||||
|
|
||||||
|
[env]
|
||||||
|
MCU = "esp32c3"
|
||||||
|
|
||||||
|
# 本机装了两套 ESP-IDF:
|
||||||
|
#
|
||||||
|
# /Users/peng/.espressif/v6.0.1/esp-idf eim 装的,工具链齐全(含 RISC-V)
|
||||||
|
# /Users/peng/.espressif/esp-idf/v5.5.5 只有 Xtensa 工具链(图传板在用)
|
||||||
|
#
|
||||||
|
# C3 是 RISC-V,所以这里指向 v6.0.1。esp-idf-sys 0.38.1 支持 6.0–6.2。
|
||||||
|
#
|
||||||
|
# `ESP_IDF_TOOLS_INSTALL_DIR` 刻意 `force = false`:默认 `global`(即 ~/.espressif),
|
||||||
|
# 但如果环境里已经给了 `custom:<可写目录>`(沙箱、CI),就用环境里的那个。
|
||||||
|
# 形状必须与外层 esp32/.cargo/config.toml 一致(都是表),否则 cargo 合并配置会报错。
|
||||||
|
IDF_PATH = { value = "/Users/peng/.espressif/v6.0.1/esp-idf", force = true }
|
||||||
|
ESP_IDF_TOOLS_INSTALL_DIR = { value = "global", force = false }
|
||||||
Generated
+1718
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
# 装甲板固件(ESP32-C3):一主三从,主板一颗 MCU,四路压电片 + 四路 WS2812 都接在它上面。
|
||||||
|
#
|
||||||
|
# 独立 workspace,不参与外层 esp32/ crate 的构建:两者目标芯片不同
|
||||||
|
# (外层是 Xtensa 的 ESP32-S3 图传,这里是 RISC-V 的 ESP32-C3),各自的
|
||||||
|
# `.cargo/config.toml` 就是这条边界的体现。
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["armor-firmware", "armor-protocol"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
debug = true
|
||||||
|
opt-level = "z"
|
||||||
+352
@@ -0,0 +1,352 @@
|
|||||||
|
# 装甲板(ESP32-C3 / Rust / ESP-IDF)—— 一主三从,与裁判终端联动
|
||||||
|
|
||||||
|
一辆车四块装甲板(前/后/左/右),**只有主板一颗 MCU**:另外三块"从"板里没有单片机,
|
||||||
|
各自只有一路 WS2812 和一路压电片,直接接线到主板上。主板负责四路击打检测、四路灯带、
|
||||||
|
整车血量,并把血量广播给本项目裁判终端。
|
||||||
|
|
||||||
|
固件是跑在 ESP-IDF 上的 Rust,目标芯片 **ESP32-C3(RISC-V)**。
|
||||||
|
|
||||||
|
```text
|
||||||
|
armor/
|
||||||
|
├── Cargo.toml workspace:两个 crate,各自独立构建
|
||||||
|
├── armor-protocol/ 规则层 + 线路格式(纯计算,主机上 22 个单元测试)
|
||||||
|
├── armor-firmware/ 固件:ADC / RMT / NVS / 按键 / WiFi+UDP
|
||||||
|
│ ├── src/leds.rs 四路 WS2812(单 RMT 通道轮流切 GPIO)
|
||||||
|
│ ├── src/piezo.rs 四路压电片(40 kHz ADC 连续采样)
|
||||||
|
│ ├── src/net.rs UDP 广播 ArmorHealth
|
||||||
|
│ ├── src/settings.rs NVS:队伍与颜色
|
||||||
|
│ └── sdkconfig.defaults / partitions.csv
|
||||||
|
└── .cargo/config.toml riscv32imc-esp-espidf + MCU=esp32c3
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么拆成两个 crate。** 规则层(算不算一次受击、扣几滴血、这一刻灯该显示什么、
|
||||||
|
按一下键算短按还是长按)是最容易写错、又最不适合"烧上去看现象"的部分 —— 压电片的
|
||||||
|
尖峰在硬件上根本没法按需复现。放在 `armor-protocol` 里就能喂 `Duration` 假时间来测。
|
||||||
|
固件侧只做外设接线。
|
||||||
|
|
||||||
|
## 接线
|
||||||
|
|
||||||
|
| 功能 | GPIO | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| WS2812 前 | **IO10** | 每条灯带 3 颗灯珠,GRB、MSB first |
|
||||||
|
| WS2812 后 | **IO5** | |
|
||||||
|
| WS2812 左 | **IO6** | |
|
||||||
|
| WS2812 右 | **IO7** | |
|
||||||
|
| 压电片 前 | GPIO0(ADC1_CH0) | 0 dB 衰减、12 bit |
|
||||||
|
| 压电片 后 | GPIO4(ADC1_CH4) | |
|
||||||
|
| 压电片 左 | GPIO3(ADC1_CH3) | |
|
||||||
|
| 压电片 右 | GPIO1(ADC1_CH1) | |
|
||||||
|
| BOOT 键 | GPIO9 | 低有效,内部上拉 |
|
||||||
|
| 调试串口 UART0 TXD | GPIO21 | 115200 8N1,日志从这里出来(主控制台) |
|
||||||
|
| 调试串口 UART0 RXD | GPIO20 | 目前空闲,留给以后的命令通道 |
|
||||||
|
|
||||||
|
引脚与参考实现完全一致(灯:前 10 / 后 5 / 左 6 / 右 7;压电:前 0 / 后 4 / 左 3 /
|
||||||
|
右 1)。调试口用 UART0 而不是 USB Serial/JTAG,是为了让"RXD/TXD"这对丝印就是日志口。
|
||||||
|
|
||||||
|
**日志同时从板上原生 USB 口出来。** `sdkconfig.defaults` 里把 USB Serial/JTAG 配成了
|
||||||
|
**次**控制台(`CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y`):主控制台仍是 UART0,
|
||||||
|
所以"RXD/TXD 就是调试口"这条成立;而只用原生 USB 线烧录、没接 USB-TTL 的人也能看到日志,
|
||||||
|
否则那种接法下应用日志一个字符都看不到(只有 ROM 的启动信息)。
|
||||||
|
|
||||||
|
## 规则
|
||||||
|
|
||||||
|
| | 值 | 在哪 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 整车血量 | 100 | `Rules::max_hp` |
|
||||||
|
| 每次受击扣血 | 3 | `Rules::damage_per_hit` |
|
||||||
|
| 受击后变暗 | **只有被打的那一路**全黑 50 ms,其余三路不动 | `Rules::hit_blink` + `Armor::strip` |
|
||||||
|
| 同一板位重复判定抑制 | 50 ms | `Rules::hit_cooldown` |
|
||||||
|
| 受击阈值 | 原始 12 bit ADC > 2000(`ARMOR_THRESHOLD` 可改) | `Rules::threshold` |
|
||||||
|
| 血量归零 | 四路全黑,直到复位 | `Armor::strip` |
|
||||||
|
| 正常颜色 | 队伍一红(`0xFF0000`)、队伍二蓝(`0x0000FF`) | `Rgb::for_team` |
|
||||||
|
| 复位(新一局) | 长按 BOOT ≥500 ms → 满血、清计数 | `Button` + `Armor::reset` |
|
||||||
|
| 打印状态 | 短按 BOOT → 立刻打一行状态并重发上报 | `ButtonEvent::Click` |
|
||||||
|
|
||||||
|
**整车一条血量,四格同值。** 板子算账、裁判端只显示:每条上报的 `hp` 相同,
|
||||||
|
`index` 分别是 0..3,所以裁判端已经画好的四格血量界面不用改。
|
||||||
|
|
||||||
|
**为什么要"每板独立冷却"。** 参考实现用全局 50 ms 冷却:一块板挨打后,别块板在这
|
||||||
|
50 ms 内的击打会被吃掉。四路各自独立才是"每面各自挨打"的正确语义,这里有单元测试
|
||||||
|
钉住(`cooldown_is_per_plate_not_global`)。
|
||||||
|
|
||||||
|
## 上报(UDP)
|
||||||
|
|
||||||
|
主板每 200 ms 广播四条 `protocol::ArmorHealth` 到 `255.255.255.255:5601`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
magic "MVC1"(4) | type(1)=5 | version(2) | sender_id(4) | ... | team(1) | index(1) | hp(2)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **血量一变立刻发**,没变就按 200 ms 心跳重发。裁判端靠心跳判断板子还在线,一次
|
||||||
|
丢包不该让界面退回"未接入"。
|
||||||
|
- `sender_id` 用 MAC 派生的板子 id(`protocol::id_from_mac`),不是 0 —— 0 是裁判
|
||||||
|
终端的保留 id,抓包时一块装甲板发的包不该长得像裁判端发的。
|
||||||
|
- 四条 `index` 的 `hp` 相同(整车一条血),裁判端按 `(team, index)` 收齐四条才显示。
|
||||||
|
- 广播而不是单播:裁判终端的 IP 是 DHCP 给的,会变。图传板用 Hello 广播绕开这件事,
|
||||||
|
装甲板沿用同一个约定。
|
||||||
|
|
||||||
|
### 裁判端怎么收
|
||||||
|
|
||||||
|
`client/src/armor.rs` 里的 `ArmorHealthStore`(裁判端角色才启动):
|
||||||
|
|
||||||
|
- 绑 `0.0.0.0:5601` 收广播,按 `(team, index)` 存最后一次读数。
|
||||||
|
- **四条都在 1.5 s 内收到**才交给界面显示;否则回退占位符。心跳 200 ms,1.5 s 是连丢
|
||||||
|
7 个包,偶发丢包不会让四个格子各自跳。
|
||||||
|
- 队伍端不用它(界面不显示对手血量),所以那台机器不会去占 5601。
|
||||||
|
- 血量变化会打日志(`armor: team 0 hp 100 -> 97`):窗口是给别人看的,抓日志的人
|
||||||
|
也得能看见掉血。
|
||||||
|
|
||||||
|
**裁判端界面原本看不到血量。** 协议与 `ArmorSource` 早已就位,但 Slint 里那 4 格只是
|
||||||
|
绿/灰方块 —— `hps` 的数值从没被渲染,于是"板子被打到 0"在界面上完全看不出来(现场
|
||||||
|
表现就是"敲死了裁判系统没掉血")。现在 `TeamHealth` 多了一个 `hp-text` 与 `critical`:
|
||||||
|
队伍名旁边直接显示血量数字,低于 30 或归零时数字与血条转红。归零**不写"已击毁"**——那一刻
|
||||||
|
图传中央会给出「胜利 / 击败」的结论(见 [`desktop/README.md`](../desktop/README.md))。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
凭据与队伍号走 `.env`(与图传固件共用同一个文件):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
WIFI_SSID=your-ssid
|
||||||
|
WIFI_PASS=your-password
|
||||||
|
ARMOR_TEAM=0 # 0 = 队伍一(红),1 = 队伍二(蓝)
|
||||||
|
ARMOR_THRESHOLD=2000 # 受击阈值(原始 12 bit ADC),看日志里的 peak 来定
|
||||||
|
|
||||||
|
scripts/build_armor.sh # 编译
|
||||||
|
ARMOR_TEAM=1 scripts/build_armor.sh # 临时当队伍二编译
|
||||||
|
ARMOR_THRESHOLD=1200 scripts/build_armor.sh # 换受击阈值(看日志里的 peak 来定)
|
||||||
|
```
|
||||||
|
|
||||||
|
产物:`armor/target/riscv32imc-esp-espidf/release/armor`。
|
||||||
|
|
||||||
|
`ARMOR_TEAM` 是**编译期**注入的:换队伍重刷固件即可,不用先擦 NVS。NVS 里另外记着
|
||||||
|
`team` 与 `color`(命名空间 `armor`,与参考实现同名),两者不一致时以 `ARMOR_TEAM`
|
||||||
|
为准并覆盖,日志里会说一声。
|
||||||
|
|
||||||
|
本机已有的 ESP-IDF 与工具链通过 `armor/.cargo/config.toml` 指定(`IDF_PATH` /
|
||||||
|
`ESP_IDF_TOOLS_INSTALL_DIR`),不需要先 `activate` ESP-IDF。删掉那两行就交给
|
||||||
|
esp-idf-sys 自己下载它钉住的版本。
|
||||||
|
|
||||||
|
### 用的是 ESP-IDF v6.0.1,不是图传板那套 v5.5.5
|
||||||
|
|
||||||
|
这台机器上有两套 ESP-IDF:
|
||||||
|
|
||||||
|
| | 路径 | 工具链 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| v6.0.1(eim 装的) | `/Users/peng/.espressif/v6.0.1/esp-idf` | Xtensa + **RISC-V(`esp-15.2.0_20251204`)** |
|
||||||
|
| v5.5.5(图传板在用) | `/Users/peng/.espressif/esp-idf/v5.5.5` | 只有 Xtensa |
|
||||||
|
|
||||||
|
C3 是 **RISC-V**,而 v5.5.5 那套缺 `riscv32-esp-elf`(它要的是 `esp-14.2.0_20260121`,
|
||||||
|
本机没有,而 GitHub release 又下不动)。所以装甲板固件指向 v6.0.1 —— 它的工具链齐全,
|
||||||
|
且 `esp-idf-sys` 0.38.1 支持的版本范围是 4.4.4+、5.0–5.5、**6.0–6.2**。
|
||||||
|
|
||||||
|
两套 IDF 共存没有冲突:各自 `.cargo/config.toml` 里的 `IDF_PATH` 是每套 crate 自己钉的。
|
||||||
|
|
||||||
|
`ESP_IDF_TOOLS_INSTALL_DIR` 默认 `global`(即 `~/.espressif`,工具所在处),但**没有**加
|
||||||
|
`force`:沙箱或 CI 里可以用 `ESP_IDF_TOOLS_INSTALL_DIR=custom:<可写目录>` 覆盖(工具目录
|
||||||
|
不可写时把 `tools/` 软链到已装好的工具即可,Python venv 会建在那个可写目录里)。
|
||||||
|
|
||||||
|
### 主机侧测试
|
||||||
|
|
||||||
|
规则层在 PC 上跑,不需要板子:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd armor/armor-protocol && cargo test # 22 个测试
|
||||||
|
cd protocol && cargo test # 线路格式往返(含 ArmorHealth)
|
||||||
|
```
|
||||||
|
|
||||||
|
(`armor-protocol/.cargo/config.toml` 把 target 覆盖回 host:它坐在 `armor/` 目录树
|
||||||
|
里,否则会继承 RISC-V 目标,链接必然失败。这与 `protocol/`、`client/` 的做法一致。)
|
||||||
|
|
||||||
|
## 烧录与实测
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/flash_armor.sh # 自动找串口,烧完看 20 秒日志
|
||||||
|
ESP_PORT=/dev/cu.usbmodem1101 scripts/flash_armor.sh
|
||||||
|
scripts/flash_armor.sh --monitor 60 # 看 60 秒
|
||||||
|
scripts/flash_armor.sh --no-monitor # 只烧不抓
|
||||||
|
```
|
||||||
|
|
||||||
|
烧的是 ELF(`espflash` 自己从 ELF 里读分区表与 bootloader),芯片 `esp32c3`。
|
||||||
|
|
||||||
|
不打开裁判端界面,也可以单独确认板子在广播、血量对不对:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/armor_probe.py 15
|
||||||
|
```
|
||||||
|
|
||||||
|
它手写协议常量(不依赖 Rust 侧代码),所以是线路格式的**独立**检查:如果固件编码写错,
|
||||||
|
这个脚本会立刻报不一致。
|
||||||
|
|
||||||
|
### 串口日志长什么样(实机抓的)
|
||||||
|
|
||||||
|
```text
|
||||||
|
I (321) armor: pins: ws2812 front=IO10 rear=IO5 left=IO6 right=IO7
|
||||||
|
I (321) armor: pins: piezo front=GPIO0 rear=GPIO4 left=GPIO3 right=GPIO1
|
||||||
|
I (4864) esp_netif_handlers: sta ip: 192.168.1.117, mask: 255.255.255.0, gw: 192.168.1.1
|
||||||
|
I (5034) armor: joined "SR-602-2G4" on 192.168.1.117
|
||||||
|
I (5034) armor::net: reporting to 255.255.255.255:5601 over UDP
|
||||||
|
I (5034) armor::net: this board's id is 0xA820D321, from MAC [98, A3, 16, 4C, 67, C8]
|
||||||
|
I (6044) armor: status: hp=100 hits=0 dead=false plates[front=0 rear=0 left=0 right=0]
|
||||||
|
peak[front=490 rear=395 left=456 right=475] thr=2000
|
||||||
|
strips[front=#FF0000 rear=#FF0000 left=#FF0000 right=#FF0000]
|
||||||
|
ap=SR-602-2G4 rssi=-36 ch=6 ip=192.168.1.117 reports=5 errs=0
|
||||||
|
I (63104) armor: hit plate=front raw=4095 hp=1 (hits=33)
|
||||||
|
I (63314) armor: hit plate=front raw=4095 hp=0 (hits=34)
|
||||||
|
W (63314) armor: hp reached 0: all four strips stay dark until the next reset
|
||||||
|
W (306504) armor: BOOT long press: hp reset to 100 (new round)
|
||||||
|
```
|
||||||
|
|
||||||
|
(日志里的长行是这里折行显示的,实机是一行。)
|
||||||
|
|
||||||
|
## 实测(2026-10-14,ESP32-C3 devkit,MAC `98:a3:16:4c:67:c8`)
|
||||||
|
|
||||||
|
| 项 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 编译 | `cargo build --release` 通过;text 1,081,093 B,app 镜像 **1,082,848 B**,占 4 MB flash 的 26% |
|
||||||
|
| 启动 | 无 panic/重启;状态行每秒一条 |
|
||||||
|
| WiFi | 关联 `SR-602-2G4`(rssi −36,ch 6),DHCP 拿到 `192.168.1.117` |
|
||||||
|
| 上报 | `scripts/armor_probe.py` 收到队伍一四条、约 5 包/秒、`sender_id 0xA820D321`、四格同值 |
|
||||||
|
| 裁判端 | `cam-client --role referee` 日志 `team 0 plate 0..3 is online (hp 100) via 192.168.1.117`,界面显示血量数字 |
|
||||||
|
| 受击 | 敲前装甲板:`hit plate=front raw=2663..4095`,每次 −3(100→97→94…),只有 front 计数 |
|
||||||
|
| 变暗 | 敲击瞬间**只有前那一路**全黑 50 ms,其余不变(现场确认) |
|
||||||
|
| 死亡 | 34 击到 0;状态行 `dead=true strips[front=off rear=off left=off right=off]`,四路全黑 |
|
||||||
|
| 复位 | BOOT 长按 → 板子 `reset to 100`,裁判端日志 `armor: team 0 hp 0 -> 100` |
|
||||||
|
| 底噪 | 未敲击时四路 peak 约 380–510,阈值 2000 有明显余量 |
|
||||||
|
| 未接线 | 后/左/右三路压电片与灯带本次未接(用户说明),代码按四路齐全写 |
|
||||||
|
|
||||||
|
### 裁判端这边踩到的坑:界面根本不显示血量
|
||||||
|
|
||||||
|
协议、`ArmorSource`、`ArmorHealthStore` 都对,但改之前 Slint 里那 4 格只是绿/灰方块,
|
||||||
|
`hps` 的**数值从不渲染**——板子被打到 0,界面四格还是绿的,看起来像"没掉血"。现在
|
||||||
|
队伍名旁边直接显示数字(低于 30 转红,归零不写"已击毁",改由图传中央的胜负横幅表达),
|
||||||
|
并且客户端日志会打 `armor: team 0 hp 100 -> 97`。
|
||||||
|
|
||||||
|
## 实现要点(都是踩过的坑)
|
||||||
|
|
||||||
|
### 1. ESP32-C3 的 RMT 只有 96 个 symbol 的通道内存
|
||||||
|
|
||||||
|
C3 的 RMT 只有一个 block,总计 96 个 symbol,按 48 为单位分给两个 TX 通道。`mem_block_symbols = 64`
|
||||||
|
会被向上取整到 96,也就是一个通道吃光全部内存,第二个 `rmt_new_tx_channel()` 就会失败:
|
||||||
|
|
||||||
|
```text
|
||||||
|
E rmt: rmt_tx_register_to_group(168): no free tx channels
|
||||||
|
```
|
||||||
|
|
||||||
|
四条灯带不可能各占一个通道(只有 2 个 TX 通道)。所以这里只建**一个**通道、让它独占
|
||||||
|
96 个 symbol(一帧 = 3 珠 × 24 bit + reset = 73 个 symbol,装得下),再用
|
||||||
|
`rmt_tx_switch_gpio()` 依次切到四个 GPIO 上发送;每路约 90 µs。
|
||||||
|
|
||||||
|
**为什么不用 `esp-idf-hal` 的 `TxChannelDriver`。** 它把 `rmt_channel_handle_t` 藏在
|
||||||
|
结构体里(`as_ptr()` 是 `pub(crate)`),拿不到句柄就调不了 `rmt_tx_switch_gpio()`。
|
||||||
|
所以 `leds.rs` 直接用 sys 绑定:通道、官方 copy encoder、transmit 都是 IDF 原生的那
|
||||||
|
几个函数,用法与参考实现的 `led_strip_encoder.c` 一一对应。
|
||||||
|
|
||||||
|
**每路内容没变就不重发。** 参考实现里四条灯带永远同色,所以每 10 ms 无条件刷四遍;
|
||||||
|
这里四路颜色独立(被打的那一路要单独变暗),于是只在内容变化时才走
|
||||||
|
`disable → switch → enable → transmit`,另外每秒强制全刷一次(掉电、静电都可能让
|
||||||
|
某条灯带丢帧,缓存不能当真相)。
|
||||||
|
|
||||||
|
### 2. 压电片必须连续(DMA)采样
|
||||||
|
|
||||||
|
一次击打的波形只有几十微秒宽,oneshot 轮询几乎必然从尖峰旁边擦过去,表现就是"敲得响
|
||||||
|
才算"。这里与参考实现一致:四通道合计 40 kHz(每通道 10 kHz),一帧 10 ms = 400 个
|
||||||
|
采样点,一帧就是主循环的节拍。
|
||||||
|
|
||||||
|
`esp-idf-hal` 0.47 的 `adc::continuous` 多通道入口 `EmptyAdcChannels::chain` 的约束是
|
||||||
|
`A: Adc` 且 `O: AdcChannels<AdcUnit = A>`:`ADC1` 满足前者、`ADCU1` 满足后者,同一个
|
||||||
|
类型参数凑不齐,四路 ADC1 通道拼不出来。所以 `piezo.rs` 同样直接用 sys 绑定,与参考
|
||||||
|
实现逐行对应。
|
||||||
|
|
||||||
|
采样点的通道号由硬件结果自带(`type2.channel()`),映射不依赖"结果按 pattern 顺序
|
||||||
|
轮转"这个假设。
|
||||||
|
|
||||||
|
### 3. 一次击打会被算成几十次
|
||||||
|
|
||||||
|
40 kHz 下,压电片一次击打会连续几十个采样点过阈值。**去抖不在采样层做**,而是
|
||||||
|
`Armor::on_sample` 的冷却窗口把它收敛成一次受击 —— 这样"算不算受击"这条规则可以在
|
||||||
|
主机上测(`repeat_samples_inside_the_cooldown_are_one_hit`)。
|
||||||
|
|
||||||
|
### 4. 控制台放在 UART0
|
||||||
|
|
||||||
|
`sdkconfig.defaults` 里显式写了 `CONFIG_ESP_CONSOLE_UART_DEFAULT=y`:C3 的 ROM 默认
|
||||||
|
控制台就是 UART0,引脚正好是 RXD=GPIO20 / TXD=GPIO21,与要求一致。换成 USB
|
||||||
|
Serial/JTAG 输出只要把这一行换成 `CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG`。
|
||||||
|
|
||||||
|
### 5. 应用分区给 2 MB
|
||||||
|
|
||||||
|
默认的 1 MB 单 app 槽对带 WiFi 的 Rust 固件太紧,`partitions.csv` 按参考实现给
|
||||||
|
factory 划了 2 MB。
|
||||||
|
|
||||||
|
### 6. WiFi 省电关掉
|
||||||
|
|
||||||
|
省电模式下上报会晚一个信标间隔(上百毫秒),而"受击后 50 ms 变暗"本来就是要给人即时
|
||||||
|
反馈的。板子是车载供电,这点电流不值得换不确定性。另外主循环里每 5 s 检查一次连接,
|
||||||
|
断了就重连 —— 一块静默失联的装甲板在场上是最难查的故障。
|
||||||
|
|
||||||
|
### 7. 判断"连上没有"要问 IDF,不要信 esp-idf-svc 的事件缓存
|
||||||
|
|
||||||
|
`BlockingWifi::is_connected()/is_up()/wait_netif_up()` 读的是 esp-idf-svc 自己维护的
|
||||||
|
WiFi 事件状态。实测在 **IDF v6.0.1 + esp-idf-svc 0.53** 上它会停在"已连接":日志说
|
||||||
|
一切正常、`get_ip_info()` 却是 `0.0.0.0`,而 IDF 的 `esp_wifi_sta_get_ap_info()` 明明
|
||||||
|
说没关联。后果是 `join()` 假装连上了、看门狗永远不会重试,板子静默失联。
|
||||||
|
|
||||||
|
所以固件里一律用 IDF 的 API 判断:
|
||||||
|
|
||||||
|
- 关联:`esp_wifi_sta_get_ap_info()`
|
||||||
|
- 地址:`esp_netif_get_handle_from_ifkey("WIFI_STA_DEF")` + `esp_netif_get_ip_info()`
|
||||||
|
|
||||||
|
而且 **WiFi 是尽力而为**:`join()` 失败只警告、不退出。一块因为没网就不亮灯的装甲板,
|
||||||
|
比"裁判端看不到血量"糟糕得多。看门狗每 5 s 检查一次"关联 + 有 IP",不满足就断开重连
|
||||||
|
(重连时只等 3 s IP,别让主循环错过受击)。
|
||||||
|
|
||||||
|
### 8. 日志里"有没有连上"要说清楚
|
||||||
|
|
||||||
|
状态行长这样:
|
||||||
|
|
||||||
|
```text
|
||||||
|
status: hp=0 hits=37 dead=true plates[front=37 rear=0 left=0 right=0]
|
||||||
|
peak[front=504 rear=379 left=459 right=486] thr=2000
|
||||||
|
strips[front=off rear=off left=off right=off]
|
||||||
|
ap=SR-602-2G4 rssi=-29 ch=6 ip=192.168.1.117 reports=1745 errs=0
|
||||||
|
```
|
||||||
|
|
||||||
|
- `peak[...]` 是**这一秒内每路 ADC 的峰值**,用来校准阈值:没敲时的底噪要明显低于
|
||||||
|
阈值,正常敲击要明显高于它(实测底噪 ~400–500,敲击 2000–4095)。只报"有没有过阈值"
|
||||||
|
没法判断阈值该调到哪。
|
||||||
|
- `thr=` 是当前阈值,`ARMOR_THRESHOLD=1200 scripts/build_armor.sh` 可以改。
|
||||||
|
- `ap=` / `ip=` / `reports=` 一眼分清"板子没连上"和"包发出去了但对端没显示"。
|
||||||
|
|
||||||
|
### 9. USB 控制台看不到复位后的头两秒
|
||||||
|
|
||||||
|
主控制台是 UART0,另外把 USB Serial/JTAG 配成了次控制台,所以插原生 USB 也能看日志。
|
||||||
|
但复位时 USB 会重新枚举,**复位后最初约 1–2 秒的日志会丢**(我们实测错过过启动横幅)。
|
||||||
|
要看完整启动过程就用 UART0 那对脚接 USB-TTL——那才是设计中的调试口。
|
||||||
|
|
||||||
|
## 与参考实现(`reference/armor`)的差异
|
||||||
|
|
||||||
|
| | 参考实现 | 这里 | 为什么 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 主从链路 | ESP-NOW 配对,两块板对拷 | 无:三块从板只是直接接线的灯与传感器 | 按实际硬件(只有主板一颗 MCU) |
|
||||||
|
| DBUS 透传 | UART0 收发 10 字节 DBUS,USB CDC 打 CSV | 不做 | 这套硬件没有 DBUS 需求;UART0 让给日志 |
|
||||||
|
| 受击变暗 | 四条灯带一起全黑 50 ms | 只暗被打的那一路 | 每面独立灯带的意义就在这里 |
|
||||||
|
| 冷却窗口 | 全局 50 ms | 每板独立 50 ms | 别块板的击打不该被吃掉 |
|
||||||
|
| 灯带刷新 | 每 10 ms 无条件刷四遍 | 只在内容变化时刷 + 每秒强制全刷 | 四路独立控制后,省掉四轮 RMT 开销 |
|
||||||
|
| 血量 | 只有击打计数,无血量 | 整车 100 血、每击 −3、0 血全黑,按 `ArmorHealth` 上报 | 与裁判端已画好的四格血量对齐 |
|
||||||
|
| 颜色切换 | BOOT 短按切红/蓝,存 NVS | 颜色由 `ARMOR_TEAM` 决定(红/蓝),短按打状态 | 队别不该靠现场按键切错 |
|
||||||
|
| 按键 | 移植 OneButton 状态机(短按配对,长按切色) | 本地 FSM:长按复位血量、短按打状态 | 只有两种动作,且要能在主机上测 |
|
||||||
|
| 上报 sender_id | —(不走 UDP) | MAC 派生 id | 0 是裁判终端保留 id |
|
||||||
|
|
||||||
|
## 还没做的
|
||||||
|
|
||||||
|
- **另外三路(后/左/右)的压电片与灯带还没接**(2026-10-14 实机只有前一路)。
|
||||||
|
代码按四路齐全写,接线后无需改代码;但"四路同时受击"、"每路独立变暗"这两件事
|
||||||
|
还没在实板上验证过。
|
||||||
|
- **UART0 RX(GPIO20)上的命令通道**:日志方向已经能用;反向的 `status` / `reset`
|
||||||
|
命令还没接(当前复位走 BOOT 长按)。
|
||||||
|
- **从板加 MCU 的方案**:如果以后从板也装单片机(走 UART 或无线回传),
|
||||||
|
`armor-protocol` 里已经预留了板位枚举与上报格式,加一个 `slave` bin 即可。
|
||||||
|
- **双击 / 多击**:`Button` 只实现短按与长按,参考实现里未使用的 OneButton 功能没有移植。
|
||||||
|
- **下发血量**:当前板子本地算账(裁判端只显示)。若以后要裁判端权威(下发血量/复位),
|
||||||
|
需要新增一条下行消息并在 `client` 里发。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "armor-firmware"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
resolver = "2"
|
||||||
|
rust-version = "1.82"
|
||||||
|
|
||||||
|
# 一个 bin:主板固件。三块"从"装甲板里没有 MCU,只有一路 WS2812 + 一路压电片
|
||||||
|
# 直接接在主板 GPIO 上,所以没有第二份固件可烧(见 armor/README.md)。
|
||||||
|
[[bin]]
|
||||||
|
name = "armor"
|
||||||
|
path = "src/main.rs"
|
||||||
|
harness = false # 不在固件里跑主机测试框架;规则层的测试在 armor-protocol
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
log = "0.4"
|
||||||
|
anyhow = "1"
|
||||||
|
embedded-svc = "0.29"
|
||||||
|
esp-idf-svc = "0.53"
|
||||||
|
|
||||||
|
# 规则层 + 线路格式:血量、去抖、按键、灯带输出,以及上报给裁判终端的
|
||||||
|
# `protocol::ArmorHealth`(armor-protocol 再导出)。规则层能在主机上单测。
|
||||||
|
armor-protocol = { path = "../armor-protocol" }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
embuild = "0.33.5"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
embuild::espidf::sysenv::output();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 装甲板固件(4 MB flash)的自定义分区表。
|
||||||
|
#
|
||||||
|
# nvs : 颜色 / 队伍等应用设置,外加 WiFi 校准数据
|
||||||
|
# phy_init : 射频校准数据
|
||||||
|
# factory : 唯一的分区镜像(2 MB 余量;固件实测不到 1 MB,但默认的 1 MB 单 app
|
||||||
|
# 槽对带 WiFi 的固件太紧)
|
||||||
|
# Name, Type, SubType, Offset, Size, Flags
|
||||||
|
nvs, data, nvs, 0x9000, 0x6000,
|
||||||
|
phy_init, data, phy, 0xf000, 0x1000,
|
||||||
|
factory, app, factory, 0x10000, 0x200000,
|
||||||
|
@@ -0,0 +1,29 @@
|
|||||||
|
# 装甲板固件(ESP32-C3)的默认配置。
|
||||||
|
#
|
||||||
|
# CONFIG_IDF_TARGET 必须显式写出来:本机装的是通用 ESP-IDF,默认目标是 ESP32,
|
||||||
|
# 不写这一行会按 ESP32 去配 ADC/RMT 通道,编译期就报"没有这个外设"。
|
||||||
|
#
|
||||||
|
# 串口调试走 UART0(RXD = GPIO20 / TXD = GPIO21,115200 8N1)——这是 C3 的 ROM
|
||||||
|
# 默认控制台引脚,和参考固件里给 DBUS 用的那两个脚是同一对。想改成 USB
|
||||||
|
# Serial/JTAG 输出,把 CONFIG_ESP_CONSOLE_UART_DEFAULT 换成
|
||||||
|
# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG 即可。
|
||||||
|
CONFIG_IDF_TARGET="esp32c3"
|
||||||
|
|
||||||
|
# 4 MB flash;应用分区放到 2 MB(见 partitions.csv),默认的 1 MB 单 app 槽对
|
||||||
|
# WiFi + 日志的 Rust 固件太紧。
|
||||||
|
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||||
|
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||||
|
|
||||||
|
# 调试口 = UART0(GPIO20 RX / GPIO21 TX)。
|
||||||
|
#
|
||||||
|
# 同时打开 USB Serial/JTAG 作为**次**控制台:日志会同时从板上那个原生 USB 口出来。
|
||||||
|
# 这样现场两种接法都能看到日志 —— 接了 USB-TTL 到 RXD/TXD 的看 UART0,直接插板上
|
||||||
|
# USB 口的看 USB。主控制台仍是 UART0,所以"RXD/TXD 就是调试口"这条成立。
|
||||||
|
#
|
||||||
|
# 只用原生 USB 口烧录(不接 USB-TTL)时,这一条是能看见日志的唯一办法。
|
||||||
|
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
|
||||||
|
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
|
||||||
|
|
||||||
|
# Rust 的 main 与规则层在同一个任务栈上跑,另外 log 会用到一些栈;默认 3.5 KB
|
||||||
|
# 在带 WiFi 的固件里容易爆栈。
|
||||||
|
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//! 把 IDF 的 `esp_err_t` 返回值变成可读的 `anyhow::Error`。
|
||||||
|
//!
|
||||||
|
//! 固件里到处都要判 `ESP_OK` 并把错误码翻译成人能看懂的名字;不翻译的话现场只会
|
||||||
|
//! 看到 `0x105`,而 IDF 的错误码表并不在脑子里。
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use esp_idf_svc::sys as ffi;
|
||||||
|
|
||||||
|
/// `ESP_OK` 之外的返回值转成 `Err`,`what` 是出错的那一步的名字。
|
||||||
|
pub fn check(what: &str, rc: ffi::esp_err_t) -> Result<()> {
|
||||||
|
if rc == ffi::ESP_OK {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!("{what} failed: {} (0x{rc:x})", name(rc)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错误码的名字,例如 `ESP_ERR_INVALID_ARG`。
|
||||||
|
#[must_use]
|
||||||
|
pub fn name(rc: ffi::esp_err_t) -> String {
|
||||||
|
// SAFETY: esp_err_to_name 对任意 esp_err_t 都返回一个静态 C 字符串。
|
||||||
|
unsafe {
|
||||||
|
core::ffi::CStr::from_ptr(ffi::esp_err_to_name(rc))
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
//! 四路 WS2812 灯带:一个 RMT TX 通道 + 官方 copy encoder,靠切 GPIO 轮流驱动。
|
||||||
|
//!
|
||||||
|
//! # 为什么不用 `esp-idf-hal` 的 `TxChannelDriver`
|
||||||
|
//!
|
||||||
|
//! 它把 `rmt_channel_handle_t` 藏在结构体里(`as_ptr()` 是 `pub(crate)`),而
|
||||||
|
//! "一个通道依次驱动四路灯带"必须用 `rmt_tx_switch_gpio()` 换脚。ESP32-C3 的 RMT
|
||||||
|
//! 只有 **2 个 TX 通道**,四路灯带不可能各占一个,所以这里直接用 `esp-idf-svc` 的
|
||||||
|
//! sys 绑定:通道、encoder、transmit 都是 IDF 原生的那几个函数,用法与参考实现的
|
||||||
|
//! `led_strip_encoder.c` 一一对应。
|
||||||
|
//!
|
||||||
|
//! # ESP32-C3 的 RMT 内存限制(照抄参考实现的踩坑记录)
|
||||||
|
//!
|
||||||
|
//! C3 的 RMT 只有一个 block,总计 **96 个 symbol 的通道内存**,按
|
||||||
|
//! `SOC_RMT_MEM_WORDS_PER_CHANNEL = 48` 为单位分配。`mem_block_symbols = 64` 会被
|
||||||
|
//! 向上取整到 96,也就是一个通道吃光全部内存,第二个 `rmt_new_tx_channel()` 会以
|
||||||
|
//! `no free tx channels` 失败:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! E rmt: rmt_tx_register_to_group(168): no free tx channels
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 所以这里只建**一个**通道、让它独占 96 个 symbol(一帧 = 3 珠 × 24 bit + reset
|
||||||
|
//! = 73 个 symbol,装得下),再用 `rmt_tx_switch_gpio()` 依次切到四个 GPIO 上发送。
|
||||||
|
//! 每路约 90 µs,10 ms 周期里的开销可以忽略。
|
||||||
|
//!
|
||||||
|
//! # 与参考实现的差异
|
||||||
|
//!
|
||||||
|
//! 参考实现里四条灯带永远同色,所以每 10 ms 无条件刷四遍。这里四路颜色各自独立
|
||||||
|
//! (被打的那一路要单独变暗),于是:
|
||||||
|
//!
|
||||||
|
//! - 内容没变的路不发(`refresh` 里比较上一次的 [`Strip`]),省掉每次
|
||||||
|
//! `disable → switch → enable → transmit` 的四轮开销;
|
||||||
|
//! - 每秒仍强制全刷一次:灯带可能因为上电次序、掉电、静电而丢帧,缓存不能当真相。
|
||||||
|
|
||||||
|
use core::ffi::c_void;
|
||||||
|
use core::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use esp_idf_svc::hal::rmt::{PinState, Pulse, Symbol};
|
||||||
|
use esp_idf_svc::hal::units::Hertz;
|
||||||
|
use esp_idf_svc::sys as ffi;
|
||||||
|
use log::{info, warn};
|
||||||
|
|
||||||
|
use armor_protocol::{Plate, Rgb, Strip};
|
||||||
|
|
||||||
|
use crate::esp;
|
||||||
|
|
||||||
|
/// RMT 计时分辨率:10 MHz,即 1 tick = 0.1 µs。
|
||||||
|
const RMT_RESOLUTION_HZ: u32 = 10_000_000;
|
||||||
|
|
||||||
|
/// 每条灯带 3 颗灯珠(与参考实现一致)。
|
||||||
|
const LEDS_PER_STRIP: usize = 3;
|
||||||
|
|
||||||
|
/// 一帧的符号数:24 bit/珠 × 3 珠 + 1 个 reset 符号。
|
||||||
|
const FRAME_SYMBOLS: usize = LEDS_PER_STRIP * 24 + 1;
|
||||||
|
|
||||||
|
/// 独占 C3 全部 RMT 通道内存,只建一个通道(见模块文档)。
|
||||||
|
const MEM_BLOCK_SYMBOLS: usize = 96;
|
||||||
|
|
||||||
|
/// WS2812 的位时序:T0H / T0L / T1H / T1L。
|
||||||
|
const T0H_NS: u64 = 300;
|
||||||
|
const T0L_NS: u64 = 900;
|
||||||
|
const T1H_NS: u64 = 900;
|
||||||
|
const T1L_NS: u64 = 300;
|
||||||
|
|
||||||
|
/// 帧尾低电平时长;WS2812 要 >50 µs 才锁存。
|
||||||
|
const RESET_US: u64 = 50;
|
||||||
|
|
||||||
|
/// 灯带数据脚(GPIO 号,按 [`Plate`] 的 index 排列):前 10 / 后 5 / 左 6 / 右 7。
|
||||||
|
const STRIP_GPIO: [i32; 4] = [10, 5, 6, 7];
|
||||||
|
|
||||||
|
/// `rmt_tx_wait_all_done` 的超时(毫秒)。一帧只要 ~90 µs,1 s 是"卡死就报错"的量级。
|
||||||
|
const TX_TIMEOUT_MS: i32 = 1000;
|
||||||
|
|
||||||
|
/// 强制全刷的周期:缓存不可信(掉电、静电都可能让灯带丢帧)。
|
||||||
|
const FULL_REFRESH_MS: u64 = 1000;
|
||||||
|
|
||||||
|
/// 四路 WS2812。
|
||||||
|
pub struct Leds {
|
||||||
|
channel: ffi::rmt_channel_handle_t,
|
||||||
|
encoder: ffi::rmt_encoder_handle_t,
|
||||||
|
/// 通道当前是否处于 enable 状态;切 GPIO 只能在 INIT 状态下做。
|
||||||
|
enabled: bool,
|
||||||
|
bit0: Symbol,
|
||||||
|
bit1: Symbol,
|
||||||
|
reset: Symbol,
|
||||||
|
/// 每路的预编码帧。放在 `Box` 里:`rmt_transmit` 只借用指针,堆上的地址不会
|
||||||
|
/// 因为结构体搬动而失效。
|
||||||
|
frames: Box<[[ffi::rmt_symbol_word_t; FRAME_SYMBOLS]; 4]>,
|
||||||
|
/// 上一次真正发出去的内容,用来跳过没变化的刷新。
|
||||||
|
current: [Option<Strip>; 4],
|
||||||
|
last_full_refresh_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Leds {
|
||||||
|
/// 建通道与 encoder,把四路灯带全刷成黑色(上电先灭)。
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let mut config = ffi::rmt_tx_channel_config_t::default();
|
||||||
|
// 先落在前装甲板那一路;其余三路靠 switch_gpio 换过去。
|
||||||
|
config.gpio_num = STRIP_GPIO[Plate::Front.index() as usize] as _;
|
||||||
|
config.clk_src = ffi::soc_periph_rmt_clk_src_t_RMT_CLK_SRC_DEFAULT;
|
||||||
|
config.resolution_hz = RMT_RESOLUTION_HZ;
|
||||||
|
config.mem_block_symbols = MEM_BLOCK_SYMBOLS;
|
||||||
|
config.trans_queue_depth = 4;
|
||||||
|
|
||||||
|
let mut channel: ffi::rmt_channel_handle_t = core::ptr::null_mut();
|
||||||
|
esp::check(
|
||||||
|
"rmt_new_tx_channel",
|
||||||
|
// SAFETY: config 与 channel 都是本函数的局部量,调用期间有效。
|
||||||
|
unsafe { ffi::rmt_new_tx_channel(&config, &mut channel) },
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut encoder: ffi::rmt_encoder_handle_t = core::ptr::null_mut();
|
||||||
|
let copy_config = ffi::rmt_copy_encoder_config_t::default();
|
||||||
|
if let Err(err) = esp::check(
|
||||||
|
"rmt_new_copy_encoder",
|
||||||
|
// SAFETY: 同上,copy encoder 只是把用户给的 symbol 原样搬进硬件。
|
||||||
|
unsafe { ffi::rmt_new_copy_encoder(©_config, &mut encoder) },
|
||||||
|
) {
|
||||||
|
// SAFETY: 通道刚建好且未使用,可以直接删。
|
||||||
|
unsafe { ffi::rmt_del_channel(channel) };
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolution = Hertz(RMT_RESOLUTION_HZ);
|
||||||
|
let bit0 = Symbol::new_with(
|
||||||
|
resolution,
|
||||||
|
PinState::High,
|
||||||
|
Duration::from_nanos(T0H_NS),
|
||||||
|
PinState::Low,
|
||||||
|
Duration::from_nanos(T0L_NS),
|
||||||
|
)?;
|
||||||
|
let bit1 = Symbol::new_with(
|
||||||
|
resolution,
|
||||||
|
PinState::High,
|
||||||
|
Duration::from_nanos(T1H_NS),
|
||||||
|
PinState::Low,
|
||||||
|
Duration::from_nanos(T1L_NS),
|
||||||
|
)?;
|
||||||
|
let reset_low =
|
||||||
|
Pulse::new_with_duration(resolution, PinState::Low, Duration::from_micros(RESET_US))?;
|
||||||
|
// reset = 一整段(两半都是低电平)的符号。
|
||||||
|
let reset = Symbol::new(reset_low, reset_low);
|
||||||
|
|
||||||
|
// `rmt_symbol_word_t` 是 union,bindgen 不给它 `Default`,只能自己写一个全零符号。
|
||||||
|
let blank = ffi::rmt_symbol_word_t { val: 0 };
|
||||||
|
|
||||||
|
let mut leds = Self {
|
||||||
|
channel,
|
||||||
|
encoder,
|
||||||
|
enabled: false,
|
||||||
|
bit0,
|
||||||
|
bit1,
|
||||||
|
reset,
|
||||||
|
frames: Box::new([[blank; FRAME_SYMBOLS]; 4]),
|
||||||
|
current: [None; 4],
|
||||||
|
last_full_refresh_ms: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"ws2812: 1 RMT channel x {MEM_BLOCK_SYMBOLS} symbols, {LEDS_PER_STRIP} leds/strip on GPIO {:?}",
|
||||||
|
STRIP_GPIO
|
||||||
|
);
|
||||||
|
|
||||||
|
// 上电先灭:不知道灯带上一次显示的是什么。
|
||||||
|
for plate in Plate::ALL {
|
||||||
|
leds.write(plate, Strip::Black)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(leds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把预编码的帧发到指定那一路。
|
||||||
|
fn write(&mut self, plate: Plate, strip: Strip) -> Result<()> {
|
||||||
|
let slot = plate.index() as usize;
|
||||||
|
|
||||||
|
self.encode(slot, strip.color());
|
||||||
|
|
||||||
|
// 切 GPIO 只能在 INIT 状态下做,所以先 disable。
|
||||||
|
if self.enabled {
|
||||||
|
esp::check("rmt_disable", unsafe { ffi::rmt_disable(self.channel) })?;
|
||||||
|
self.enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp::check(
|
||||||
|
"rmt_tx_switch_gpio",
|
||||||
|
// SAFETY: 通道处于 INIT 状态;gpio 是 C3 上的合法输出脚。
|
||||||
|
unsafe { ffi::rmt_tx_switch_gpio(self.channel, STRIP_GPIO[slot] as _, false) },
|
||||||
|
)?;
|
||||||
|
|
||||||
|
esp::check("rmt_enable", unsafe { ffi::rmt_enable(self.channel) })?;
|
||||||
|
self.enabled = true;
|
||||||
|
|
||||||
|
let tx_config = ffi::rmt_transmit_config_t {
|
||||||
|
loop_count: 0, // 不循环
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
esp::check(
|
||||||
|
"rmt_transmit",
|
||||||
|
// SAFETY: frames[slot] 在 Box 上,地址稳定;调用期间不会被移动。
|
||||||
|
unsafe {
|
||||||
|
ffi::rmt_transmit(
|
||||||
|
self.channel,
|
||||||
|
self.encoder,
|
||||||
|
self.frames[slot].as_ptr() as *const c_void,
|
||||||
|
core::mem::size_of_val(&self.frames[slot]),
|
||||||
|
&tx_config,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
esp::check(
|
||||||
|
"rmt_tx_wait_all_done",
|
||||||
|
// SAFETY: 纯等待,超时给 1 s。
|
||||||
|
unsafe { ffi::rmt_tx_wait_all_done(self.channel, TX_TIMEOUT_MS) },
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.current[slot] = Some(strip);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 `rgb` 编成一条灯带的整帧(GRB、MSB first,3 珠同色)。
|
||||||
|
fn encode(&mut self, slot: usize, rgb: Rgb) {
|
||||||
|
let grb = rgb.to_grb();
|
||||||
|
let frame = &mut self.frames[slot];
|
||||||
|
|
||||||
|
// 一颗灯珠的 24 个符号只算一遍,再复制给另外两颗 —— 四条灯带都是 3 珠,
|
||||||
|
// 中间那两步没必要重复算。
|
||||||
|
let mut one_led = [ffi::rmt_symbol_word_t { val: 0 }; 24];
|
||||||
|
for (byte_index, byte) in grb.iter().enumerate() {
|
||||||
|
for bit in 0..8 {
|
||||||
|
let symbol = if (byte >> (7 - bit)) & 1 == 1 {
|
||||||
|
self.bit1
|
||||||
|
} else {
|
||||||
|
self.bit0
|
||||||
|
};
|
||||||
|
one_led[byte_index * 8 + bit] = symbol.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for led in 0..LEDS_PER_STRIP {
|
||||||
|
frame[led * 24..(led + 1) * 24].copy_from_slice(&one_led);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 帧尾:>50 µs 低电平,灯带才会锁存这一帧。
|
||||||
|
frame[FRAME_SYMBOLS - 1] = self.reset.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 [`Plate`] 的 index 刷四路灯带。
|
||||||
|
///
|
||||||
|
/// 内容与上一次相同的路直接跳过(除非到了每秒一次的强制全刷),所以稳态下
|
||||||
|
/// 这个函数几乎不花时间。
|
||||||
|
pub fn refresh(&mut self, strips: [Strip; 4], now_ms: u64) -> Result<()> {
|
||||||
|
let full = now_ms.saturating_sub(self.last_full_refresh_ms) >= FULL_REFRESH_MS;
|
||||||
|
|
||||||
|
for plate in Plate::ALL {
|
||||||
|
let strip = strips[plate.index() as usize];
|
||||||
|
|
||||||
|
if !full && self.current[plate.index() as usize] == Some(strip) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.write(plate, strip)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if full {
|
||||||
|
self.last_full_refresh_ms = now_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Leds {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// 固件里 main 永不返回,这段只在异常路径(比如 new() 之后的早期返回)跑到。
|
||||||
|
unsafe {
|
||||||
|
if self.enabled {
|
||||||
|
ffi::rmt_disable(self.channel);
|
||||||
|
}
|
||||||
|
if !self.encoder.is_null() {
|
||||||
|
ffi::rmt_del_encoder(self.encoder);
|
||||||
|
}
|
||||||
|
if !self.channel.is_null() {
|
||||||
|
ffi::rmt_del_channel(self.channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 供 main 打印一次用:把四路输出拼成一行,便于对照。
|
||||||
|
pub fn describe(strips: [Strip; 4]) -> String {
|
||||||
|
Plate::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|plate| {
|
||||||
|
let strip = strips[plate.index() as usize];
|
||||||
|
let text = if strip.is_black() {
|
||||||
|
"off".to_string()
|
||||||
|
} else {
|
||||||
|
format!("#{:06X}", strip.color().to_u32())
|
||||||
|
};
|
||||||
|
format!("{}={text}", plate.name())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记一次刷新失败:诊断信息比重启有用,现场灯带接错线也不该把固件打挂。
|
||||||
|
pub fn log_failure(err: &anyhow::Error) {
|
||||||
|
warn!("ws2812 refresh failed: {err:#}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
//! 装甲板主板固件(ESP32-C3):一主三从,与裁判终端联动。
|
||||||
|
//!
|
||||||
|
//! # 这套硬件是什么
|
||||||
|
//!
|
||||||
|
//! 一辆车四块装甲板(前/后/左/右),但只有**主板一颗 MCU**:另外三块"从"板里没有
|
||||||
|
//! 单片机,各自只有一路 WS2812 和一路压电片,直接接线到主板上。所以这份固件同时管
|
||||||
|
//! 四路输入与四路输出:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! 压电片 前/后/左/右 ──ADC──┐
|
||||||
|
//! ├──► armor-protocol(血量 / 去抖 / 灯色)
|
||||||
|
//! BOOT 键 ──────── GPIO9 ──┘ │
|
||||||
|
//! ├──► WS2812 前/后/左/右
|
||||||
|
//! └──► UDP 广播 ──► 裁判终端 :5601
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # 规则(见 `armor-protocol`,那里有单元测试)
|
||||||
|
//!
|
||||||
|
//! - 整车一条血量:100 起,每次受击 −3,减到 0 不变负;0 血四路全黑。
|
||||||
|
//! - 受击时**只有被打的那一路**灯带全黑 50 ms,其余三路不动。
|
||||||
|
//! - 颜色由队伍决定:队伍一红、队伍二蓝;`ARMOR_TEAM` 编译期注入。
|
||||||
|
//! - 血量账在板子上算,按 `protocol::ArmorHealth` 广播给本项目裁判终端。
|
||||||
|
//!
|
||||||
|
//! # 构建与烧录
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! scripts/build_armor.sh # 用 .env 里的 WIFI_SSID/WIFI_PASS/ARMOR_TEAM
|
||||||
|
//! scripts/flash_armor.sh # 编译 + espflash + 串口日志
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 串口调试走 UART0:RXD = GPIO20 / TXD = GPIO21,115200 8N1(日志从 TX 出来,
|
||||||
|
//! RX 目前空闲,留给以后加命令通道)。USB Serial/JTAG 配成了次控制台,所以插原生
|
||||||
|
//! USB 口也能看到同一份日志。完整接线表、协议与踩坑见 `armor/README.md`。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use embedded_svc::wifi::{AuthMethod, ClientConfiguration, Configuration, PmfConfiguration};
|
||||||
|
use esp_idf_svc::eventloop::EspSystemEventLoop;
|
||||||
|
use esp_idf_svc::hal::gpio::{PinDriver, Pull};
|
||||||
|
use esp_idf_svc::hal::peripherals::Peripherals;
|
||||||
|
use esp_idf_svc::nvs::EspDefaultNvsPartition;
|
||||||
|
use esp_idf_svc::wifi::{BlockingWifi, EspWifi};
|
||||||
|
use log::{error, info, warn};
|
||||||
|
|
||||||
|
use armor_protocol::{Armor, Button, ButtonEvent, Plate, ReportSchedule, Rules, TEAM_2};
|
||||||
|
|
||||||
|
mod esp;
|
||||||
|
mod leds;
|
||||||
|
mod net;
|
||||||
|
mod piezo;
|
||||||
|
mod settings;
|
||||||
|
|
||||||
|
/// WiFi 账号来自 .env(构建期注入,固件里不留密码)。
|
||||||
|
const SSID: &str = env!("WIFI_SSID");
|
||||||
|
const PASSWORD: &str = env!("WIFI_PASS");
|
||||||
|
|
||||||
|
/// 编译期注入的队伍号:`0` = 队伍一(红),`1` = 队伍二(蓝)。不写就是队伍一。
|
||||||
|
const TEAM_ENV: &str = match option_env!("ARMOR_TEAM") {
|
||||||
|
Some(value) => value,
|
||||||
|
None => "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 受击阈值(原始 12 bit ADC)。参考实现用 2000,但压电片的幅度取决于贴装、线长
|
||||||
|
/// 与有没有放大级,所以这里留一个编译期入口:`ARMOR_THRESHOLD=1200`。
|
||||||
|
///
|
||||||
|
/// 怎么定这个值:看串口 status 行里的 `peak[...]`。没敲时的底噪要明显低于阈值,
|
||||||
|
/// 正常敲击要明显高于它。
|
||||||
|
const THRESHOLD_ENV: &str = match option_env!("ARMOR_THRESHOLD") {
|
||||||
|
Some(value) => value,
|
||||||
|
None => "2000",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 血量不变时的重发周期。裁判端靠它判断板子还在线 —— 一次丢包不该让界面变回
|
||||||
|
/// "装甲板未接入"。
|
||||||
|
const REPORT_HEARTBEAT_MS: u64 = 200;
|
||||||
|
|
||||||
|
/// 状态行的打印周期(UART 调试口)。
|
||||||
|
const STATUS_INTERVAL_MS: u64 = 1000;
|
||||||
|
|
||||||
|
/// WiFi 断线检查周期。
|
||||||
|
const WIFI_CHECK_MS: u64 = 5000;
|
||||||
|
|
||||||
|
/// 毫秒时基。`esp_timer` 是微秒计数器,转毫秒后 u64 不会被绕回。
|
||||||
|
fn now_ms() -> u64 {
|
||||||
|
// SAFETY: 纯读计时器。
|
||||||
|
(unsafe { esp_idf_svc::sys::esp_timer_get_time() } as u64) / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
// esp-idf-sys 的运行时补丁必须跑一次,日志系统也要先起来。
|
||||||
|
esp_idf_svc::sys::link_patches();
|
||||||
|
esp_idf_svc::log::EspLogger::initialize_default();
|
||||||
|
|
||||||
|
let team = armor_protocol::parse_team(TEAM_ENV)
|
||||||
|
.ok_or_else(|| anyhow!("ARMOR_TEAM must be 0 or 1, got {TEAM_ENV:?}"))?;
|
||||||
|
|
||||||
|
let peripherals = Peripherals::take()?;
|
||||||
|
let sys_loop = EspSystemEventLoop::take()?;
|
||||||
|
let nvs = EspDefaultNvsPartition::take()?;
|
||||||
|
|
||||||
|
// 队别与颜色:ARMOR_TEAM 是权威,NVS 只记住结果。
|
||||||
|
let color = settings::Settings::new(nvs.clone())?.resolve_color(team)?;
|
||||||
|
|
||||||
|
let threshold: u16 = THRESHOLD_ENV
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| anyhow!("ARMOR_THRESHOLD must be a number, got {THRESHOLD_ENV:?}"))?;
|
||||||
|
let rules = Rules {
|
||||||
|
threshold,
|
||||||
|
..Rules::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut armor = Armor::new(rules, color);
|
||||||
|
|
||||||
|
info!("pins: ws2812 front=IO10 rear=IO5 left=IO6 right=IO7");
|
||||||
|
info!("pins: piezo front=GPIO0 rear=GPIO4 left=GPIO3 right=GPIO1");
|
||||||
|
info!("pins: boot=GPIO9 debug uart0 rxd=GPIO20 txd=GPIO21 115200 8N1");
|
||||||
|
info!(
|
||||||
|
"armor board: team={team} ({}), {} hp, -{} per hit, {} ms dark on the hit strip",
|
||||||
|
if team == TEAM_2 { "blue" } else { "red" },
|
||||||
|
armor.hp(),
|
||||||
|
rules.damage_per_hit,
|
||||||
|
rules.hit_blink.as_millis()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut leds = leds::Leds::new()?;
|
||||||
|
let mut piezo = piezo::Piezo::new()?;
|
||||||
|
|
||||||
|
// BOOT 键(GPIO9,低有效):内部上拉,按下读低。
|
||||||
|
let button = PinDriver::input(peripherals.pins.gpio9, Pull::Up)?;
|
||||||
|
let mut button_fsm = Button::default();
|
||||||
|
|
||||||
|
let mut wifi = BlockingWifi::wrap(
|
||||||
|
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
|
||||||
|
sys_loop,
|
||||||
|
)?;
|
||||||
|
// WiFi 是尽力而为:**连不上也绝不能挡住受击检测与灯效**。一块因为没网就不亮
|
||||||
|
// 灯的装甲板,在场上比"裁判端看不到血量"糟糕得多。所以这里只报警,主循环照跑,
|
||||||
|
// 由看门狗每 5 s 重试。
|
||||||
|
if let Err(err) = join(&mut wifi, Duration::from_secs(10)) {
|
||||||
|
warn!("no referee link yet ({err:#}); hits and LEDs still work, retrying every {WIFI_CHECK_MS} ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
let reporter = net::Reporter::new()?;
|
||||||
|
let sender_id = net::local_id();
|
||||||
|
|
||||||
|
let mut hits: Vec<(Plate, u16)> = Vec::with_capacity(16);
|
||||||
|
let mut reports = ReportSchedule::new(Duration::from_millis(REPORT_HEARTBEAT_MS));
|
||||||
|
let mut last_status_ms = now_ms();
|
||||||
|
let mut last_wifi_check_ms = now_ms();
|
||||||
|
// 启动即发一次:裁判端不用等到第一次受击才知道板子在线。
|
||||||
|
let mut force_report = true;
|
||||||
|
// 上报统计,状态行里打出来:现场"裁判端没显示"时,第一件事是分清是链路问题
|
||||||
|
// 还是对端问题。计数在涨就说明包已经出去了。
|
||||||
|
let mut reports_sent: u64 = 0;
|
||||||
|
let mut report_errors: u64 = 0;
|
||||||
|
// 上一次状态行以来每路 ADC 的峰值(校准阈值用),打印后清零。
|
||||||
|
let mut peak = [0u16; 4];
|
||||||
|
|
||||||
|
info!("entering the main loop: long-press BOOT to reset hp, short-press to print status");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let now = Duration::from_millis(now_ms());
|
||||||
|
|
||||||
|
// 采样:一次读一帧(约 10 ms),这一帧就是主循环的节拍。
|
||||||
|
match piezo.read_frame(armor.rules().threshold, &mut hits) {
|
||||||
|
Ok(stats) => {
|
||||||
|
for (slot, value) in stats.peak.iter().enumerate() {
|
||||||
|
peak[slot] = peak[slot].max(*value);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (plate, raw) in hits.iter().copied() {
|
||||||
|
if let Some(event) = armor.on_sample(plate, raw, now) {
|
||||||
|
info!(
|
||||||
|
"hit plate={} raw={} hp={} (hits={})",
|
||||||
|
event.plate.name(),
|
||||||
|
event.raw,
|
||||||
|
event.hp,
|
||||||
|
armor.hits()
|
||||||
|
);
|
||||||
|
if event.dead {
|
||||||
|
warn!("hp reached 0: all four strips stay dark until the next reset");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("adc frame failed: {err:#}");
|
||||||
|
// 读失败时别把循环变成没有节拍的忙等。
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 灯带:只有内容变化的那一路才真的发 RMT(每秒另有一次强制全刷)。
|
||||||
|
let strips = armor.strips(now);
|
||||||
|
if let Err(err) = leds.refresh(strips, now_ms()) {
|
||||||
|
leds::log_failure(&err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按键。BOOT 低有效,所以"按下" = 读到低电平。
|
||||||
|
match button_fsm.tick(button.is_low(), now) {
|
||||||
|
ButtonEvent::LongPress => {
|
||||||
|
armor.reset();
|
||||||
|
warn!("BOOT long press: hp reset to {} (new round)", armor.hp());
|
||||||
|
force_report = true;
|
||||||
|
}
|
||||||
|
ButtonEvent::Click => {
|
||||||
|
info!(
|
||||||
|
"status: {}",
|
||||||
|
status_with_link(&armor, now, reports_sent, report_errors, peak)
|
||||||
|
);
|
||||||
|
force_report = true;
|
||||||
|
}
|
||||||
|
ButtonEvent::None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上报:血量一变立刻发,其余按心跳重发。
|
||||||
|
let current_ms = now_ms();
|
||||||
|
if force_report || reports.due(armor.hp(), now) {
|
||||||
|
match broadcast_health(&reporter, &armor, team, sender_id) {
|
||||||
|
Ok(()) => {
|
||||||
|
reports.mark_sent(armor.hp(), now);
|
||||||
|
reports_sent = reports_sent.saturating_add(1);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
report_errors = report_errors.saturating_add(1);
|
||||||
|
warn!("report failed: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
force_report = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态行:现场不看串口也能从日志确认板子活着、血量是多少、链路通不通。
|
||||||
|
if current_ms.saturating_sub(last_status_ms) >= STATUS_INTERVAL_MS {
|
||||||
|
last_status_ms = current_ms;
|
||||||
|
info!(
|
||||||
|
"status: {}",
|
||||||
|
status_with_link(&armor, now, reports_sent, report_errors, peak)
|
||||||
|
);
|
||||||
|
// 峰值是"上一秒"的量,打完就清零,这样现场能一眼看出没敲时的底噪。
|
||||||
|
peak = [0u16; 4];
|
||||||
|
}
|
||||||
|
|
||||||
|
// WiFi 看门狗:**关联上 + 拿到 IP** 才算链路好。
|
||||||
|
//
|
||||||
|
// 判断不用 `BlockingWifi::is_connected()/is_up()`:那两个读的是 esp-idf-svc
|
||||||
|
// 自己的事件缓存,实测在 IDF v6.0.1 上会停留在"已连接",而 IDF 的 API 明明
|
||||||
|
// 说没关联(现场表现就是日志说一切正常、裁判端一个包都收不到)。所以这里
|
||||||
|
// 直接问 IDF:`esp_wifi_sta_get_ap_info()` 与 `esp_netif_get_ip_info()`。
|
||||||
|
//
|
||||||
|
// 重连时只给 3 s 等 IP(启动时给的 10 s):这里是主循环,等太久会漏掉受击。
|
||||||
|
if current_ms.saturating_sub(last_wifi_check_ms) >= WIFI_CHECK_MS {
|
||||||
|
last_wifi_check_ms = current_ms;
|
||||||
|
|
||||||
|
let link = link_state();
|
||||||
|
let ip = sta_ip();
|
||||||
|
let healthy = link.associated() && ip.is_some_and(|ip| !ip.is_unspecified());
|
||||||
|
|
||||||
|
if !healthy {
|
||||||
|
warn!("link is not usable ({}); reconnecting", describe_link(&link, ip));
|
||||||
|
|
||||||
|
// 断开再连:关联掉了或 DHCP 没下来时,只有重新来一遍才会好。
|
||||||
|
if let Err(err) = wifi.disconnect() {
|
||||||
|
warn!("disconnect failed: {err}");
|
||||||
|
}
|
||||||
|
if let Err(err) = join(&mut wifi, Duration::from_secs(3)) {
|
||||||
|
warn!("reconnect failed: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 广播四块装甲板的血量给裁判终端。
|
||||||
|
///
|
||||||
|
/// 四条数据报的 `hp` 相同(整车一条血量),`index` 分别是 0..3 —— 裁判端已有的
|
||||||
|
/// 四格血量界面无需改动。`sender_id` 用 MAC 派生的板子 id,方便抓包时分辨是谁发的。
|
||||||
|
fn broadcast_health(
|
||||||
|
reporter: &net::Reporter,
|
||||||
|
armor: &Armor,
|
||||||
|
team: u8,
|
||||||
|
sender_id: u32,
|
||||||
|
) -> Result<()> {
|
||||||
|
for datagram in armor.datagrams_from(team, sender_id) {
|
||||||
|
reporter.broadcast(&datagram)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 状态行 + 当场问出来的链路状态。
|
||||||
|
///
|
||||||
|
/// 每次都现算而不是缓存:现场排查时最怕看到的是过期的好消息。
|
||||||
|
fn status_with_link(
|
||||||
|
armor: &Armor,
|
||||||
|
now: Duration,
|
||||||
|
reports_sent: u64,
|
||||||
|
report_errors: u64,
|
||||||
|
peak: [u16; 4],
|
||||||
|
) -> String {
|
||||||
|
status_line(
|
||||||
|
armor,
|
||||||
|
now,
|
||||||
|
&link_line(&link_state(), sta_ip(), reports_sent, report_errors),
|
||||||
|
peak,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一行状态,给 UART 调试口用。
|
||||||
|
///
|
||||||
|
/// `peak` 是这一秒里每路 ADC 的峰值。它比"有没有受击"更有用:现场要判断阈值该调到
|
||||||
|
/// 多少,靠的就是"没敲时的底噪"和"敲击时的幅度"这两个数字。
|
||||||
|
fn status_line(armor: &Armor, now: Duration, link: &str, peak: [u16; 4]) -> String {
|
||||||
|
let counts = armor.plate_hits_all();
|
||||||
|
let per_plate = Plate::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|plate| format!("{}={}", plate.name(), counts[plate.index() as usize]))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
let peaks = Plate::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|plate| format!("{}={}", plate.name(), peak[plate.index() as usize]))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"hp={} hits={} dead={} plates[{}] peak[{}] thr={} strips[{}] {link}",
|
||||||
|
armor.hp(),
|
||||||
|
armor.hits(),
|
||||||
|
armor.dead(),
|
||||||
|
per_plate,
|
||||||
|
peaks,
|
||||||
|
armor.rules().threshold,
|
||||||
|
leds::describe(armor.strips(now)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 链路那半截状态:连的是哪个 AP、信号多强、有没有 IP、上报发了多少包。
|
||||||
|
///
|
||||||
|
/// 现场"裁判端没显示"时,第一件事是分清链路问题还是对端问题:`reports` 在涨就说明
|
||||||
|
/// 包已经出去了,该去查裁判端;一直不动就是这块板子没连上。而"没连上"还要再分
|
||||||
|
/// 关联与 DHCP —— 关联上但 IP 是 0.0.0.0 是最容易看错的一种。
|
||||||
|
fn link_line(link: &Link, ip: Option<std::net::Ipv4Addr>, reports_sent: u64, report_errors: u64) -> String {
|
||||||
|
format!(
|
||||||
|
"{} reports={reports_sent} errs={report_errors}",
|
||||||
|
describe_link(link, ip)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前关联状态。**来源是 IDF 的 API**,不是 esp-idf-svc 的事件缓存(见看门狗注释)。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Link {
|
||||||
|
/// 关联上了:AP 名字、RSSI、信道。
|
||||||
|
Associated {
|
||||||
|
ssid: String,
|
||||||
|
rssi: i8,
|
||||||
|
channel: u8,
|
||||||
|
},
|
||||||
|
/// 没关联(或者 IDF 说 STA 没在跑)。
|
||||||
|
Idle { rc: i32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Link {
|
||||||
|
#[must_use]
|
||||||
|
fn associated(&self) -> bool {
|
||||||
|
matches!(self, Link::Associated { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 问一次 IDF:STA 关联到哪个 AP 了。
|
||||||
|
fn link_state() -> Link {
|
||||||
|
let mut record = esp_idf_svc::sys::wifi_ap_record_t::default();
|
||||||
|
|
||||||
|
// SAFETY: record 是本函数的局部量,IDF 只往里写;未关联时返回非 OK 而不动它。
|
||||||
|
let rc = unsafe { esp_idf_svc::sys::esp_wifi_sta_get_ap_info(&mut record) };
|
||||||
|
if rc != esp_idf_svc::sys::ESP_OK {
|
||||||
|
return Link::Idle { rc };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssid 是定长 33 字节、以 0 结尾的缓冲区。
|
||||||
|
let ssid = unsafe {
|
||||||
|
core::ffi::CStr::from_ptr(record.ssid.as_ptr() as *const core::ffi::c_char)
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
};
|
||||||
|
|
||||||
|
Link::Associated {
|
||||||
|
ssid,
|
||||||
|
rssi: record.rssi,
|
||||||
|
channel: record.primary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// STA 网卡当前的 IPv4 地址(直接从 `esp_netif` 读,绕开 esp-idf-svc 的封装)。
|
||||||
|
///
|
||||||
|
/// `None` 表示还没拿到 DHCP 地址。0.0.0.0 也按 `None` 处理 —— 那等于没有地址。
|
||||||
|
fn sta_ip() -> Option<std::net::Ipv4Addr> {
|
||||||
|
// 默认 STA 网卡的 interface key(`esp_netif_create_default_wifi_sta` 用的就是它)。
|
||||||
|
let handle = unsafe {
|
||||||
|
esp_idf_svc::sys::esp_netif_get_handle_from_ifkey(c"WIFI_STA_DEF".as_ptr())
|
||||||
|
};
|
||||||
|
if handle.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut info = esp_idf_svc::sys::esp_netif_ip_info_t::default();
|
||||||
|
// SAFETY: info 是局部量,handle 来自上面的查询。
|
||||||
|
if unsafe { esp_idf_svc::sys::esp_netif_get_ip_info(handle, &mut info) }
|
||||||
|
!= esp_idf_svc::sys::ESP_OK
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// lwIP 的 `ip4_addr_t.addr` 是网络字节序,所以最低字节是点分表示的第一段。
|
||||||
|
let raw = info.ip.addr;
|
||||||
|
let ip = std::net::Ipv4Addr::new(
|
||||||
|
(raw & 0xFF) as u8,
|
||||||
|
((raw >> 8) & 0xFF) as u8,
|
||||||
|
((raw >> 16) & 0xFF) as u8,
|
||||||
|
((raw >> 24) & 0xFF) as u8,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ip.is_unspecified() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 链路状态的一行描述,日志与状态行共用。
|
||||||
|
fn describe_link(link: &Link, ip: Option<std::net::Ipv4Addr>) -> String {
|
||||||
|
let ip = match ip {
|
||||||
|
Some(ip) => ip.to_string(),
|
||||||
|
None => "none".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match link {
|
||||||
|
Link::Associated {
|
||||||
|
ssid,
|
||||||
|
rssi,
|
||||||
|
channel,
|
||||||
|
} => format!("ap={ssid} rssi={rssi} ch={channel} ip={ip}"),
|
||||||
|
Link::Idle { rc } => format!("ap=none (rc=0x{rc:x}) ip={ip}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等到 STA 真的拿到 IP(权威来源),最多等 `timeout`。
|
||||||
|
///
|
||||||
|
/// 不用 `BlockingWifi::wait_netif_up()`:它读的是 esp-idf-svc 的事件缓存,在
|
||||||
|
/// IDF v6.0.1 上会"立刻成功"而实际没有地址 —— 那样 join() 会假装连上了,看门狗
|
||||||
|
/// 也就永远不会重试。
|
||||||
|
fn wait_for_ip(timeout: Duration) -> Option<std::net::Ipv4Addr> {
|
||||||
|
let deadline = std::time::Instant::now() + timeout;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Some(ip) = sta_ip() {
|
||||||
|
return Some(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 连上 WiFi 并等一个 IP(幂等:已启动就不再 start 一次)。
|
||||||
|
///
|
||||||
|
/// 连不上时把扫到的网络打出来:这块模块只有 2.4 GHz,SSID 若在 5 GHz 上,现场看到
|
||||||
|
/// 一张空列表比看一个关联超时有用得多。**调用方不应该因为这个错误就退出** —— 没有
|
||||||
|
/// 网也要能打灯。
|
||||||
|
fn join(wifi: &mut BlockingWifi<EspWifi<'static>>, ip_timeout: Duration) -> Result<()> {
|
||||||
|
if !wifi.is_started()? {
|
||||||
|
wifi.start()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !link_state().associated() {
|
||||||
|
let config = Configuration::Client(ClientConfiguration {
|
||||||
|
ssid: SSID
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow!("WIFI_SSID too long (max 32 bytes)"))?,
|
||||||
|
bssid: None,
|
||||||
|
auth_method: default_auth_method(),
|
||||||
|
password: PASSWORD
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow!("WIFI_PASS too long (max 64 bytes)"))?,
|
||||||
|
channel: None,
|
||||||
|
// 通告 PMF:WPA2/WPA3 混合模式的路由器否则会丢掉握手;`required: false`
|
||||||
|
// 仍然允许纯 WPA2 的 AP。
|
||||||
|
pmf_cfg: PmfConfiguration::Capable { required: false },
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
wifi.set_configuration(&config)?;
|
||||||
|
|
||||||
|
if let Err(err) = wifi.connect() {
|
||||||
|
error!("could not join \"{SSID}\": {err}");
|
||||||
|
report_scan(wifi);
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 省电模式换来的电流不值得:它会让上报晚一个信标间隔(上百毫秒),而"受击后
|
||||||
|
// 50 ms 变暗"这件事本来就是要给人即时反馈的。
|
||||||
|
let rc = unsafe {
|
||||||
|
esp_idf_svc::sys::esp_wifi_set_ps(esp_idf_svc::sys::wifi_ps_type_t_WIFI_PS_NONE)
|
||||||
|
};
|
||||||
|
if rc != esp_idf_svc::sys::ESP_OK {
|
||||||
|
warn!("could not disable wifi power save ({})", esp::name(rc));
|
||||||
|
}
|
||||||
|
|
||||||
|
let link = link_state();
|
||||||
|
|
||||||
|
let Some(ip) = wait_for_ip(ip_timeout) else {
|
||||||
|
report_scan(wifi);
|
||||||
|
return Err(anyhow!(
|
||||||
|
"no DHCP address within {} ms ({})",
|
||||||
|
ip_timeout.as_millis(),
|
||||||
|
describe_link(&link, None)
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("joined \"{SSID}\" on {ip}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 没写 `WIFI_PASS` 就是开放网络。
|
||||||
|
fn default_auth_method() -> AuthMethod {
|
||||||
|
if PASSWORD.is_empty() {
|
||||||
|
AuthMethod::None
|
||||||
|
} else {
|
||||||
|
AuthMethod::WPA2Personal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把扫到的网络打出来,并标出我们要连的那个。连不上时的第一诊断信息。
|
||||||
|
fn report_scan(wifi: &mut BlockingWifi<EspWifi<'static>>) {
|
||||||
|
match wifi.scan() {
|
||||||
|
Ok(aps) if aps.is_empty() => {
|
||||||
|
error!("scan found no networks; check the antenna and that the AP is on 2.4 GHz");
|
||||||
|
}
|
||||||
|
Ok(aps) => {
|
||||||
|
for ap in aps {
|
||||||
|
info!(
|
||||||
|
" {:<32} ch {:>2} {:>4} dBm {:?}{}",
|
||||||
|
ap.ssid,
|
||||||
|
ap.channel,
|
||||||
|
ap.signal_strength,
|
||||||
|
ap.auth_method,
|
||||||
|
if ap.ssid.as_str() == SSID { " <- target" } else { "" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => warn!("scan failed: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! 上报通道:把 [`ArmorHealth`](armor_protocol::ArmorHealth) 广播给本项目裁判终端。
|
||||||
|
//!
|
||||||
|
//! # 为什么直接写 lwIP 的 socket
|
||||||
|
//!
|
||||||
|
//! 与同仓库的图传固件(`src/udp.rs`)一样:`std::net` 在 lwIP 上的行为要靠一层
|
||||||
|
//! VFS 转接,而这里只需要"建一个 UDP socket + 广播"这一件事,直接用 IDF 原生的
|
||||||
|
//! `lwip_*` 就能与图传板跑在同一条被验证过的代码路径上。
|
||||||
|
//!
|
||||||
|
//! # 为什么不 bind
|
||||||
|
//!
|
||||||
|
//! 装甲板只发不收:血量是板子算出来的,裁判端只显示。所以 socket 不绑定端口,
|
||||||
|
//! 由 lwIP 选一个临时端口即可 —— 绑定 5601 反而会和裁判端、以及同网段的另一块
|
||||||
|
//! 装甲板抢同一个端口。裁判端监听 5601,板子往 5601 发,方向是单向的。
|
||||||
|
//!
|
||||||
|
//! # 广播而不是单播
|
||||||
|
//!
|
||||||
|
//! 裁判终端的地址是 DHCP 变的(图传板的 Hello 广播就是为了绕开这件事)。装甲板
|
||||||
|
//! 沿用同一个约定:不知道也不关心裁判端在哪,反正同一个局域网里它会收到。
|
||||||
|
|
||||||
|
use core::ffi::c_void;
|
||||||
|
use std::net::SocketAddrV4;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use esp_idf_svc::sys as ffi;
|
||||||
|
use log::{info, warn};
|
||||||
|
|
||||||
|
use armor_protocol::{ARMOR_PORT, BROADCAST_ADDR};
|
||||||
|
|
||||||
|
/// 把 Rust 的 socket 地址转成 lwIP 期望的 C 结构。
|
||||||
|
///
|
||||||
|
/// 逐字段构造而不是 `transmute`:`SocketAddrV4` 是紧凑的 6 字节(端口在前、主机
|
||||||
|
/// 字节序),C 的 `sockaddr_in` 是 16 字节且地址是网络字节序 —— 指针强转会发到一个
|
||||||
|
/// 乱地址,而且不会报错。
|
||||||
|
fn to_sockaddr(addr: SocketAddrV4) -> ffi::sockaddr_in {
|
||||||
|
let octets = addr.ip().octets();
|
||||||
|
|
||||||
|
ffi::sockaddr_in {
|
||||||
|
sin_len: core::mem::size_of::<ffi::sockaddr_in>() as u8,
|
||||||
|
sin_family: ffi::AF_INET as ffi::sa_family_t,
|
||||||
|
// 用 lwIP 的 htons,字节序不必在 Rust 侧推理。
|
||||||
|
sin_port: unsafe { ffi::lwip_htons(addr.port()) },
|
||||||
|
sin_addr: ffi::in_addr {
|
||||||
|
s_addr: u32::from_le_bytes(octets),
|
||||||
|
},
|
||||||
|
sin_zero: [0; 8],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本机 id:由 WiFi MAC 派生(FNV-1a),重启不变,且避开 0 —— 0 留给裁判终端。
|
||||||
|
pub fn local_id() -> u32 {
|
||||||
|
let mut mac = [0u8; 6];
|
||||||
|
|
||||||
|
// SAFETY: `mac` 有 6 字节空间,函数会把 6 字节写满。
|
||||||
|
let rc = unsafe { ffi::esp_read_mac(mac.as_mut_ptr(), ffi::esp_mac_type_t_ESP_MAC_WIFI_STA) };
|
||||||
|
|
||||||
|
if rc != ffi::ESP_OK {
|
||||||
|
warn!("could not read the WiFi MAC ({:#x}); using a fixed id", rc);
|
||||||
|
return 0xF1F0_0001;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = armor_protocol::id_from_mac(&mac);
|
||||||
|
info!("this board's id is 0x{id:08X}, from MAC {mac:02X?}");
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 广播上报器。
|
||||||
|
pub struct Reporter {
|
||||||
|
socket: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reporter {
|
||||||
|
/// 建一个开了广播许可的 UDP socket。
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
// SAFETY: 建 socket 不涉及 Rust 内存。
|
||||||
|
let socket = unsafe { ffi::lwip_socket(ffi::AF_INET as i32, ffi::SOCK_DGRAM as i32, 0) };
|
||||||
|
if socket < 0 {
|
||||||
|
// 用 std 的 errno 读取:`__errno` 不是每个目标的绑定里都有,而 lwIP 的
|
||||||
|
// socket 错误码就是同一个 errno。
|
||||||
|
return Err(anyhow!(
|
||||||
|
"could not create UDP socket: {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目标地址是 255.255.255.255,不开这个选项 sendto 会返回 EACCES。默认就是
|
||||||
|
// 关的,而且失败不会自己冒出来 —— 只会表现为"裁判端一直显示未接入"。
|
||||||
|
let enable: i32 = 1;
|
||||||
|
// SAFETY: optval 指向栈上的 i32,optlen 与它一致。
|
||||||
|
let rc = unsafe {
|
||||||
|
ffi::lwip_setsockopt(
|
||||||
|
socket,
|
||||||
|
ffi::SOL_SOCKET as i32,
|
||||||
|
ffi::SO_BROADCAST as i32,
|
||||||
|
&enable as *const i32 as *const c_void,
|
||||||
|
core::mem::size_of::<i32>() as u32,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
// SAFETY: 关闭本函数刚建的 socket。
|
||||||
|
unsafe { ffi::lwip_close(socket) };
|
||||||
|
return Err(anyhow!("could not enable SO_BROADCAST: {err}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("reporting to {BROADCAST_ADDR}:{ARMOR_PORT} over UDP");
|
||||||
|
|
||||||
|
Ok(Self { socket })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把一个数据报广播到裁判终端。
|
||||||
|
pub fn broadcast(&self, payload: &[u8]) -> Result<()> {
|
||||||
|
let addr = to_sockaddr(SocketAddrV4::new(BROADCAST_ADDR, ARMOR_PORT));
|
||||||
|
|
||||||
|
// SAFETY: 缓冲区与地址在调用期间都有效;sendto 复制数据而不是持有指针。
|
||||||
|
let rc = unsafe {
|
||||||
|
ffi::lwip_sendto(
|
||||||
|
self.socket,
|
||||||
|
payload.as_ptr() as *const c_void,
|
||||||
|
payload.len(),
|
||||||
|
ffi::MSG_DONTWAIT as i32,
|
||||||
|
&addr as *const ffi::sockaddr_in as *const ffi::sockaddr,
|
||||||
|
core::mem::size_of::<ffi::sockaddr_in>() as ffi::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if rc < 0 {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"broadcast failed: {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Reporter {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: 关闭本结构体自己建的 socket。
|
||||||
|
unsafe { ffi::lwip_close(self.socket) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
//! 四路压电片的 ADC 连续采样(ESP32-C3)。
|
||||||
|
//!
|
||||||
|
//! # 为什么要连续(DMA)采样
|
||||||
|
//!
|
||||||
|
//! 压电片被敲一下的波形只有几十微秒宽。用 oneshot 轮询(每圈读一次)几乎必然从
|
||||||
|
//! 尖峰旁边擦过去,表现就是"有时候敲得响才算";参考实现因此用 40 kHz 的连续采样,
|
||||||
|
//! 这里保持一致:四个通道合计 40 kHz(每通道 10 kHz),一帧 10 ms = 400 个采样点。
|
||||||
|
//!
|
||||||
|
//! # 为什么也走 sys 绑定
|
||||||
|
//!
|
||||||
|
//! `esp-idf-hal` 的 `adc::continuous` 要用 `AdcChannels` 组合通道,而多通道组合的
|
||||||
|
//! 入口 `EmptyAdcChannels::chain` 在 0.47 里的约束是 `A: Adc` 且
|
||||||
|
//! `O: AdcChannels<AdcUnit = A>` —— `ADC1` 满足前者、`ADCU1` 满足后者,同一个类型
|
||||||
|
//! 参数凑不齐,四路 ADC1 通道拼不出来。直接照着 IDF 的 C API 建驱动反而更短,也
|
||||||
|
//! 与参考实现逐行对应。
|
||||||
|
//!
|
||||||
|
//! 采样点的通道号由硬件结果自带(`type2.channel`),所以映射不依赖"结果按 pattern
|
||||||
|
//! 顺序轮转"这个假设。
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use esp_idf_svc::sys as ffi;
|
||||||
|
use log::info;
|
||||||
|
|
||||||
|
use armor_protocol::Plate;
|
||||||
|
|
||||||
|
use crate::esp;
|
||||||
|
|
||||||
|
/// 四个通道合计的采样率;每通道 10 kHz。
|
||||||
|
const SAMPLE_FREQ_HZ: u32 = 40_000;
|
||||||
|
|
||||||
|
/// 一帧的采样点数:10 ms × 40 kHz。
|
||||||
|
pub const FRAME_MEASUREMENTS: usize = 400;
|
||||||
|
|
||||||
|
/// 一帧的字节数(每个结果 4 字节)。
|
||||||
|
const FRAME_BYTES: u32 = FRAME_MEASUREMENTS as u32 * ffi::SOC_ADC_DIGI_RESULT_BYTES;
|
||||||
|
|
||||||
|
/// 连续读的超时:正常情况 10 ms 就凑满一帧,200 ms 只用来发现"驱动卡住了"。
|
||||||
|
const READ_TIMEOUT_MS: u32 = 200;
|
||||||
|
|
||||||
|
/// 每个板位对应的 ADC 通道号(ESP32-C3:GPIO0/4/3/1 = ADC1_CH0/CH4/CH3/CH1)。
|
||||||
|
///
|
||||||
|
/// 顺序与 [`Plate`] 的 index 一致;硬件结果里的通道号就是这些值。
|
||||||
|
const PLATE_CHANNEL: [u8; 4] = [0, 4, 3, 1];
|
||||||
|
|
||||||
|
/// 一帧的统计:读到多少采样点,以及每个板位的峰值。
|
||||||
|
///
|
||||||
|
/// 峰值是给**现场校准阈值**用的:压电片的输出幅度取决于贴装、线长和有没有放大,
|
||||||
|
/// 只报"有没有过阈值"没法判断该把阈值调到哪里。status 行每秒打印一次。
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct FrameStats {
|
||||||
|
/// 这一帧实际读到的采样点数。
|
||||||
|
pub samples: usize,
|
||||||
|
/// 每个板位的最大原始 ADC 值,按 [`Plate`] 的 index 排列。
|
||||||
|
pub peak: [u16; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 连续采样的压电片输入。
|
||||||
|
pub struct Piezo {
|
||||||
|
handle: ffi::adc_continuous_handle_t,
|
||||||
|
/// 一帧的原始结果。`adc_digi_output_data_t` 里有 union,bindgen 不给 `Default`,
|
||||||
|
/// 所以用零值显式初始化(这个类型是纯 POD,零值是合法状态)。
|
||||||
|
frame: Box<[ffi::adc_digi_output_data_t; FRAME_MEASUREMENTS]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Piezo {
|
||||||
|
/// 建驱动并开始采样。
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let handle_config = ffi::adc_continuous_handle_cfg_t {
|
||||||
|
// 两帧的余量:这一帧还没读完时,下一帧有地方落。
|
||||||
|
max_store_buf_size: FRAME_BYTES * 2,
|
||||||
|
conv_frame_size: FRAME_BYTES,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut handle: ffi::adc_continuous_handle_t = core::ptr::null_mut();
|
||||||
|
esp::check(
|
||||||
|
"adc_continuous_new_handle",
|
||||||
|
// SAFETY: 两个指针都指向本函数的局部量,调用期间有效。
|
||||||
|
unsafe { ffi::adc_continuous_new_handle(&handle_config, &mut handle) },
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 四路通道的采样参数:0 dB 衰减、12 bit、都在 ADC1。
|
||||||
|
let mut patterns = [ffi::adc_digi_pattern_config_t {
|
||||||
|
atten: ffi::adc_atten_t_ADC_ATTEN_DB_0 as u8,
|
||||||
|
channel: 0,
|
||||||
|
unit: ffi::adc_unit_t_ADC_UNIT_1 as u8,
|
||||||
|
bit_width: 12,
|
||||||
|
}; 4];
|
||||||
|
|
||||||
|
for (pattern, channel) in patterns.iter_mut().zip(PLATE_CHANNEL) {
|
||||||
|
pattern.channel = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = ffi::adc_continuous_config_t {
|
||||||
|
pattern_num: patterns.len() as u32,
|
||||||
|
adc_pattern: patterns.as_mut_ptr(),
|
||||||
|
sample_freq_hz: SAMPLE_FREQ_HZ,
|
||||||
|
conv_mode: ffi::adc_digi_convert_mode_t_ADC_CONV_SINGLE_UNIT_1,
|
||||||
|
// C3 的连续采样只支持 12 bit 的 TYPE2 结果布局,也正好对应下面读的
|
||||||
|
// `type2.data`(参考实现依赖了默认值 0,这里写明确)。
|
||||||
|
format: ffi::adc_digi_output_format_t_ADC_DIGI_OUTPUT_FORMAT_TYPE2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = esp::check(
|
||||||
|
"adc_continuous_config",
|
||||||
|
// SAFETY: IDF 会把 patterns 复制进驱动,调用后不再持有这个指针。
|
||||||
|
unsafe { ffi::adc_continuous_config(handle, &config) },
|
||||||
|
) {
|
||||||
|
// SAFETY: 句柄刚建好、还没 start,可以直接删。
|
||||||
|
unsafe { ffi::adc_continuous_deinit(handle) };
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
esp::check("adc_continuous_start", unsafe {
|
||||||
|
ffi::adc_continuous_start(handle)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let zero = ffi::adc_digi_output_data_t {
|
||||||
|
__bindgen_anon_1: ffi::adc_digi_output_data_t__bindgen_ty_1 { val: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"piezo: {SAMPLE_FREQ_HZ} Hz total over {} channels, {} measurements/frame (10 ms)",
|
||||||
|
PLATE_CHANNEL.len(),
|
||||||
|
FRAME_MEASUREMENTS
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
handle,
|
||||||
|
frame: Box::new([zero; FRAME_MEASUREMENTS]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读一帧(约 10 ms),把过阈值的采样点收集进 `hits`。
|
||||||
|
///
|
||||||
|
/// `hits` 会被清空后填充 `(板位, 原始值)`;返回值里带这一帧的采样点数与每路峰值。
|
||||||
|
/// **去抖与扣血不在这里做** —— 那是
|
||||||
|
/// [`armor_protocol::Armor::on_sample`] 的职责,一次击打的多个过阈值采样点由它
|
||||||
|
/// 的冷却窗口收敛成一次受击。
|
||||||
|
pub fn read_frame(
|
||||||
|
&mut self,
|
||||||
|
threshold: u16,
|
||||||
|
hits: &mut Vec<(Plate, u16)>,
|
||||||
|
) -> Result<FrameStats> {
|
||||||
|
hits.clear();
|
||||||
|
let mut stats = FrameStats::default();
|
||||||
|
|
||||||
|
let mut out_length: u32 = 0;
|
||||||
|
let rc = unsafe {
|
||||||
|
ffi::adc_continuous_read(
|
||||||
|
self.handle,
|
||||||
|
self.frame.as_mut_ptr() as *mut u8,
|
||||||
|
FRAME_BYTES,
|
||||||
|
&mut out_length,
|
||||||
|
READ_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if rc == ffi::ESP_ERR_TIMEOUT {
|
||||||
|
// 没有数据不算错误:上一帧还没凑满,下一圈再来。
|
||||||
|
return Ok(stats);
|
||||||
|
}
|
||||||
|
esp::check("adc_continuous_read", rc)?;
|
||||||
|
|
||||||
|
let count = (out_length as usize / ffi::SOC_ADC_DIGI_RESULT_BYTES as usize)
|
||||||
|
.min(FRAME_MEASUREMENTS);
|
||||||
|
stats.samples = count;
|
||||||
|
|
||||||
|
for index in 0..count {
|
||||||
|
// SAFETY: 配置的是 TYPE2(12 bit)格式,`type2` 是有效成员;每个结果
|
||||||
|
// 4 字节且已 4 字节对齐。
|
||||||
|
let result = unsafe { self.frame[index].__bindgen_anon_1.type2 };
|
||||||
|
let raw = result.data() as u16;
|
||||||
|
|
||||||
|
let Some(plate) = plate_of_channel(result.channel() as u8) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 峰值取自**所有**采样点,不只是过阈值的:阈值调得对不对,要靠没敲时
|
||||||
|
// 的底噪和敲击时的幅度两个数字一起看。
|
||||||
|
let slot = plate.index() as usize;
|
||||||
|
if raw > stats.peak[slot] {
|
||||||
|
stats.peak[slot] = raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw > threshold {
|
||||||
|
hits.push((plate, raw));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Piezo {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// 固件里 main 永不返回;这段只在启动失败路径上跑到。
|
||||||
|
unsafe {
|
||||||
|
if !self.handle.is_null() {
|
||||||
|
ffi::adc_continuous_stop(self.handle);
|
||||||
|
ffi::adc_continuous_deinit(self.handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 硬件结果里的通道号 → 板位。
|
||||||
|
#[must_use]
|
||||||
|
fn plate_of_channel(channel: u8) -> Option<Plate> {
|
||||||
|
let slot = PLATE_CHANNEL.iter().position(|c| *c == channel)?;
|
||||||
|
Plate::ALL.get(slot).copied()
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! 板子自己的持久化设置(NVS):队别与颜色。
|
||||||
|
//!
|
||||||
|
//! 命名空间与键名都沿用参考实现(`armor` / `color`),所以从参考固件刷过来时,
|
||||||
|
//! 之前存过的颜色还能被读到。新增一个 `team` 键用来记住这块板子是哪一队。
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use esp_idf_svc::nvs::{EspDefaultNvsPartition, EspNvs, NvsDefault};
|
||||||
|
use log::info;
|
||||||
|
|
||||||
|
use armor_protocol::Rgb;
|
||||||
|
|
||||||
|
/// NVS 命名空间(与参考实现一致)。
|
||||||
|
const NAMESPACE: &str = "armor";
|
||||||
|
/// 队别键名。
|
||||||
|
const KEY_TEAM: &str = "team";
|
||||||
|
/// 颜色键名,值是 `0x00RRGGBB`(与参考实现一致)。
|
||||||
|
const KEY_COLOR: &str = "color";
|
||||||
|
|
||||||
|
/// 打开着的 NVS 句柄。
|
||||||
|
pub struct Settings {
|
||||||
|
nvs: EspNvs<NvsDefault>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
/// 打开(必要时创建)命名空间。
|
||||||
|
pub fn new(partition: EspDefaultNvsPartition) -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
nvs: EspNvs::new(partition, NAMESPACE, true)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 定出这次该用哪个颜色,并把它写回 NVS。
|
||||||
|
///
|
||||||
|
/// 规则:**编译期注入的 `ARMOR_TEAM` 是权威**。换队伍只要重刷固件,不用先擦
|
||||||
|
/// NVS;NVS 里的值只用来记住当前状态(也给以后加"手动换色"留位置)。两者
|
||||||
|
/// 不一致时以 `ARMOR_TEAM` 为准并覆盖,日志里会说一声,免得现场纳闷为什么
|
||||||
|
/// 灯色变了。
|
||||||
|
pub fn resolve_color(&self, team: u8) -> Result<Rgb> {
|
||||||
|
let expected = Rgb::for_team(team);
|
||||||
|
let stored_team = self.nvs.get_u8(KEY_TEAM)?;
|
||||||
|
let stored_color = self.nvs.get_u32(KEY_COLOR)?;
|
||||||
|
|
||||||
|
let color = match (stored_team, stored_color) {
|
||||||
|
(Some(stored), Some(raw)) if stored == team => Rgb::from_u32(raw),
|
||||||
|
(Some(stored), Some(raw)) => {
|
||||||
|
info!(
|
||||||
|
"ARMOR_TEAM={team} overrides the stored team {stored} (colour #{raw:06X}); rewriting NVS"
|
||||||
|
);
|
||||||
|
expected
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
info!(
|
||||||
|
"first boot as team {team}: storing colour #{:06X}",
|
||||||
|
expected.to_u32()
|
||||||
|
);
|
||||||
|
expected
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.nvs.set_u8(KEY_TEAM, team)?;
|
||||||
|
self.nvs.set_u32(KEY_COLOR, color.to_u32())?;
|
||||||
|
|
||||||
|
Ok(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 这个 crate 在主机上跑单元测试,不交叉编译。
|
||||||
|
#
|
||||||
|
# 它坐在 armor/ 目录树里,cargo 会向上继承 armor/.cargo/config.toml 的 RISC-V
|
||||||
|
# 目标与 ldproxy linker —— 把主机测试编译成 ESP32-C3 固件会直接失败在链接上。
|
||||||
|
# 把 target 覆盖回 host triple 就够了,别的键对非 ESP 目标没有影响。
|
||||||
|
[build]
|
||||||
|
target = "aarch64-apple-darwin"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "armor-protocol"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# 只依赖错误检查用的东西:规则层是纯计算(血量、去抖、按键状态机),
|
||||||
|
# 这样它能在 PC 上跑单元测试,不需要 ESP32-C3 硬件。
|
||||||
|
[dependencies]
|
||||||
|
protocol = { path = "../../protocol" }
|
||||||
@@ -0,0 +1,872 @@
|
|||||||
|
//! 装甲板规则层:板位、整车血量、受击判定、按键状态机、上报帧。
|
||||||
|
//!
|
||||||
|
//! # 为什么和固件分开
|
||||||
|
//!
|
||||||
|
//! 这一层全是纯计算:一次采样算不算新受击、扣几滴血、灯带这一刻该显示什么颜色、
|
||||||
|
//! 按一下键算短按还是长按。这些恰好是最容易写错、又最不适合"烧上去看现象"的部分
|
||||||
|
//! —— 在 ESP32-C3 上,受击是几微秒的压电片尖峰,现场根本没法复现"冷却期内的第二次
|
||||||
|
//! 采样被误判"这类 bug。放在这里就能用 `Duration` 喂假时间来测。
|
||||||
|
//!
|
||||||
|
//! 固件侧(`armor-firmware`)只负责把外设接到这层:ADC 采样值、GPIO 电平、毫秒
|
||||||
|
//! 时基、UDP 发送。
|
||||||
|
//!
|
||||||
|
//! # 硬件形态(一主三从)
|
||||||
|
//!
|
||||||
|
//! **只有主板一颗 MCU**:三块"从"装甲板里没有单片机,只有一路 WS2812 和一路压电片,
|
||||||
|
//! 直接接到主板上。所以这一层同时管四路:
|
||||||
|
//!
|
||||||
|
//! | 板位 | WS2812 | 压电片 ADC |
|
||||||
|
//! | --- | --- | --- |
|
||||||
|
//! | 前 front | GPIO10 | ADC1_CH0 (GPIO0) |
|
||||||
|
//! | 后 rear | GPIO5 | ADC1_CH4 (GPIO4) |
|
||||||
|
//! | 左 left | GPIO6 | ADC1_CH3 (GPIO3) |
|
||||||
|
//! | 右 right | GPIO7 | ADC1_CH1 (GPIO1) |
|
||||||
|
//!
|
||||||
|
//! # 规则(已与本项目裁判端对齐)
|
||||||
|
//!
|
||||||
|
//! - 整车一条血量:初始 100,每次受击 −3,减到 0 为止**不变负**,血量 0 时四路灯带
|
||||||
|
//! 全黑("死了全黑")。
|
||||||
|
//! - 受击时**只有被打的那一路**灯带全黑 50 ms,其余三路不动;50 ms 同时是同一板位的
|
||||||
|
//! 重复判定冷却。
|
||||||
|
//! - 颜色由队伍决定:队伍一红、队伍二蓝(RoboMaster 惯例)。
|
||||||
|
//! - 血量账在板子上算,按 [`protocol::ArmorHealth`] 上报给本项目裁判终端(UDP 5601)。
|
||||||
|
//! 整车一条血量 → 四条上报的 `hp` 相同,`index` 分别是 0..3,裁判端已有的四格血量
|
||||||
|
//! 界面无需改动。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub use protocol::{id_from_mac, ArmorHealth, ARMOR_PORT, BROADCAST_ADDR, MsgType};
|
||||||
|
|
||||||
|
/// 队伍一。
|
||||||
|
pub const TEAM_1: u8 = 0;
|
||||||
|
/// 队伍二。
|
||||||
|
pub const TEAM_2: u8 = 1;
|
||||||
|
|
||||||
|
/// 一块装甲板。
|
||||||
|
///
|
||||||
|
/// 判别值就是上报里的 `index`(0..3),所以 `Plate::Front.index() == 0` 与裁判端
|
||||||
|
/// 的四格顺序一一对应。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum Plate {
|
||||||
|
/// 前装甲板,WS2812 = GPIO10,压电片 = ADC1_CH0。
|
||||||
|
Front = 0,
|
||||||
|
/// 后装甲板,WS2812 = GPIO5,压电片 = ADC1_CH4。
|
||||||
|
Rear = 1,
|
||||||
|
/// 左装甲板,WS2812 = GPIO6,压电片 = ADC1_CH3。
|
||||||
|
Left = 2,
|
||||||
|
/// 右装甲板,WS2812 = GPIO7,压电片 = ADC1_CH1。
|
||||||
|
Right = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plate {
|
||||||
|
/// 按 index 顺序的四块板,也是灯带刷新和 ADC 通道表里用的顺序。
|
||||||
|
pub const ALL: [Plate; 4] = [Plate::Front, Plate::Rear, Plate::Left, Plate::Right];
|
||||||
|
|
||||||
|
/// 上报里的 `index`。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn index(self) -> u8 {
|
||||||
|
self as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日志里用的短名(英文,和本仓库其余固件的日志一致)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Plate::Front => "front",
|
||||||
|
Plate::Rear => "rear",
|
||||||
|
Plate::Left => "left",
|
||||||
|
Plate::Right => "right",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 `index` 还原,越界返回 `None`。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn from_index(value: u8) -> Option<Plate> {
|
||||||
|
match value {
|
||||||
|
0 => Some(Plate::Front),
|
||||||
|
1 => Some(Plate::Rear),
|
||||||
|
2 => Some(Plate::Left),
|
||||||
|
3 => Some(Plate::Right),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 数组下标。
|
||||||
|
#[must_use]
|
||||||
|
const fn slot(self) -> usize {
|
||||||
|
self as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 颜色,`0x00RRGGBB`(与参考实现里 `Adafruit_NeoPixel::Color(r, g, b)` 一致)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Rgb(pub u8, pub u8, pub u8);
|
||||||
|
|
||||||
|
impl Rgb {
|
||||||
|
/// 队伍一的颜色。
|
||||||
|
pub const RED: Rgb = Rgb(0xFF, 0x00, 0x00);
|
||||||
|
/// 队伍二的颜色。
|
||||||
|
pub const BLUE: Rgb = Rgb(0x00, 0x00, 0xFF);
|
||||||
|
/// 全黑,也就是熄灭。
|
||||||
|
pub const BLACK: Rgb = Rgb(0x00, 0x00, 0x00);
|
||||||
|
|
||||||
|
/// 队伍对应的颜色:队伍二蓝,其余(含误配的值)红。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn for_team(team: u8) -> Rgb {
|
||||||
|
if team == TEAM_2 {
|
||||||
|
Rgb::BLUE
|
||||||
|
} else {
|
||||||
|
Rgb::RED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打包成 `0x00RRGGBB`,用于写 NVS 或日志。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn to_u32(self) -> u32 {
|
||||||
|
((self.0 as u32) << 16) | ((self.1 as u32) << 8) | (self.2 as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 `0x00RRGGBB` 还原。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn from_u32(value: u32) -> Rgb {
|
||||||
|
Rgb(
|
||||||
|
((value >> 16) & 0xFF) as u8,
|
||||||
|
((value >> 8) & 0xFF) as u8,
|
||||||
|
(value & 0xFF) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WS2812 的线上字节序:GRB,MSB first。
|
||||||
|
///
|
||||||
|
/// 交错顺序错了不会报错,只会显示成另一种颜色(红蓝互换),所以单独有测试钉住。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn to_grb(self) -> [u8; 3] {
|
||||||
|
[self.1, self.0, self.2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条灯带这一刻要显示的内容。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Strip {
|
||||||
|
/// 正常颜色。
|
||||||
|
Color(Rgb),
|
||||||
|
/// 全黑:受击的 50 ms 窗口内,或者整车血量已经归零。
|
||||||
|
Black,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Strip {
|
||||||
|
/// 是否熄灭。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_black(self) -> bool {
|
||||||
|
matches!(self, Strip::Black)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实际颜色,`Black` 就是 [`Rgb::BLACK`]。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn color(self) -> Rgb {
|
||||||
|
match self {
|
||||||
|
Strip::Color(rgb) => rgb,
|
||||||
|
Strip::Black => Rgb::BLACK,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 规则参数。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Rules {
|
||||||
|
/// 整车初始血量。
|
||||||
|
pub max_hp: u16,
|
||||||
|
/// 每次受击扣多少血。
|
||||||
|
pub damage_per_hit: u16,
|
||||||
|
/// 受击后该路灯带全黑的时长。
|
||||||
|
pub hit_blink: Duration,
|
||||||
|
/// 同一块板重复受击的抑制窗口(压电片一次击打会在多个采样点上过阈值)。
|
||||||
|
pub hit_cooldown: Duration,
|
||||||
|
/// 原始 12 bit ADC 采样值超过它才算受击。
|
||||||
|
pub threshold: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Rules {
|
||||||
|
/// 现场取值:100 血、每击 −3、50 ms 变暗/冷却、阈值 2000(与参考实现一致)。
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_hp: 100,
|
||||||
|
damage_per_hit: 3,
|
||||||
|
hit_blink: Duration::from_millis(50),
|
||||||
|
hit_cooldown: Duration::from_millis(50),
|
||||||
|
threshold: 2000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次被计入的受击。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct HitEvent {
|
||||||
|
/// 哪块装甲板。
|
||||||
|
pub plate: Plate,
|
||||||
|
/// 触发这次判定的原始 ADC 值,用来现场校准阈值。
|
||||||
|
pub raw: u16,
|
||||||
|
/// 扣血之后的整车血量。
|
||||||
|
pub hp: u16,
|
||||||
|
/// 这一击是否把血量打到 0。
|
||||||
|
pub dead: bool,
|
||||||
|
/// 判定时刻。
|
||||||
|
pub at: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 装甲板状态机:血量、受击冷却、灯带输出。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Armor {
|
||||||
|
rules: Rules,
|
||||||
|
color: Rgb,
|
||||||
|
hp: u16,
|
||||||
|
hits: u32,
|
||||||
|
plate_hits: [u32; 4],
|
||||||
|
last_hit: [Option<Duration>; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Armor {
|
||||||
|
/// 满血、`color` 为正常色。
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(rules: Rules, color: Rgb) -> Self {
|
||||||
|
Self {
|
||||||
|
rules,
|
||||||
|
color,
|
||||||
|
hp: rules.max_hp,
|
||||||
|
hits: 0,
|
||||||
|
plate_hits: [0; 4],
|
||||||
|
last_hit: [None; 4],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 规则参数。
|
||||||
|
#[must_use]
|
||||||
|
pub fn rules(&self) -> Rules {
|
||||||
|
self.rules
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前整车血量。
|
||||||
|
#[must_use]
|
||||||
|
pub fn hp(&self) -> u16 {
|
||||||
|
self.hp
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 血量是否归零。
|
||||||
|
#[must_use]
|
||||||
|
pub fn dead(&self) -> bool {
|
||||||
|
self.hp == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 累计计入的受击次数。
|
||||||
|
#[must_use]
|
||||||
|
pub fn hits(&self) -> u32 {
|
||||||
|
self.hits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某块装甲板各自被计中的次数。
|
||||||
|
#[must_use]
|
||||||
|
pub fn plate_hits(&self, plate: Plate) -> u32 {
|
||||||
|
self.plate_hits[plate.slot()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 四块板各自被计中的次数,按 index 排列。
|
||||||
|
#[must_use]
|
||||||
|
pub fn plate_hits_all(&self) -> [u32; 4] {
|
||||||
|
self.plate_hits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 正常显示颜色。
|
||||||
|
#[must_use]
|
||||||
|
pub fn color(&self) -> Rgb {
|
||||||
|
self.color
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 换正常显示颜色(队伍变了、或从 NVS 读回)。
|
||||||
|
pub fn set_color(&mut self, color: Rgb) {
|
||||||
|
self.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 喂一个 ADC 采样值。
|
||||||
|
///
|
||||||
|
/// 只有"过阈值 + 该板不在冷却期内"才计入受击并扣血;其余返回 `None`。
|
||||||
|
/// 一次击打在 40 kHz 采样下会连续多个点过阈值,冷却窗口就是把这些点算成一次。
|
||||||
|
pub fn on_sample(&mut self, plate: Plate, raw: u16, now: Duration) -> Option<HitEvent> {
|
||||||
|
if raw <= self.rules.threshold {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let slot = plate.slot();
|
||||||
|
|
||||||
|
if let Some(last) = self.last_hit[slot] {
|
||||||
|
if now.saturating_sub(last) < self.rules.hit_cooldown {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.last_hit[slot] = Some(now);
|
||||||
|
self.hits = self.hits.saturating_add(1);
|
||||||
|
self.plate_hits[slot] = self.plate_hits[slot].saturating_add(1);
|
||||||
|
|
||||||
|
let before = self.hp;
|
||||||
|
self.hp = self.hp.saturating_sub(self.rules.damage_per_hit);
|
||||||
|
|
||||||
|
Some(HitEvent {
|
||||||
|
plate,
|
||||||
|
raw,
|
||||||
|
hp: self.hp,
|
||||||
|
// 是"这一击打死了",不是"打完之后已经死了":已经 0 血以后继续挨打不该
|
||||||
|
// 每次都报一次死亡,否则现场日志会被刷屏、上报也会抖。
|
||||||
|
dead: self.hp == 0 && before > 0,
|
||||||
|
at: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这块装甲板这一刻该显示什么。
|
||||||
|
///
|
||||||
|
/// 整车死亡优先于一切:死了四路全黑。否则只有被打的那一路在 50 ms 窗口内全黑。
|
||||||
|
#[must_use]
|
||||||
|
pub fn strip(&self, plate: Plate, now: Duration) -> Strip {
|
||||||
|
if self.dead() {
|
||||||
|
return Strip::Black;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(last) = self.last_hit[plate.slot()] {
|
||||||
|
if now.saturating_sub(last) < self.rules.hit_blink {
|
||||||
|
return Strip::Black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Strip::Color(self.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 四路灯带这一刻的输出,按 index 排列 —— 固件照着它刷 RMT。
|
||||||
|
#[must_use]
|
||||||
|
pub fn strips(&self, now: Duration) -> [Strip; 4] {
|
||||||
|
Plate::ALL.map(|plate| self.strip(plate, now))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 复位成"新一局":满血、清计数、清冷却(颜色不动)。
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.hp = self.rules.max_hp;
|
||||||
|
self.hits = 0;
|
||||||
|
self.plate_hits = [0; 4];
|
||||||
|
self.last_hit = [None; 4];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 要上报给裁判端的四条记录。
|
||||||
|
///
|
||||||
|
/// 整车一条血量,所以四条 `hp` 相同、`index` 分别是 0..3;裁判端按
|
||||||
|
/// [`protocol::ArmorHealth::team`] 把这里的数据归到对应队伍。
|
||||||
|
#[must_use]
|
||||||
|
pub fn reports(&self, team: u8) -> [ArmorHealth; 4] {
|
||||||
|
Plate::ALL.map(|plate| ArmorHealth {
|
||||||
|
team,
|
||||||
|
index: plate.index(),
|
||||||
|
hp: self.hp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 四条记录编码成可以直接 `sendto` 的字节串。
|
||||||
|
#[must_use]
|
||||||
|
pub fn datagrams(&self, team: u8) -> Vec<Vec<u8>> {
|
||||||
|
self.datagrams_from(team, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同 [`datagrams`](Self::datagrams),但帧头带上板子自己的 id(由 MAC 派生)。
|
||||||
|
///
|
||||||
|
/// 用 `sender_id` 而不是默认的 0:0 是裁判终端的保留 id,抓包时一块装甲板发的
|
||||||
|
/// 包不该长得像裁判端发的。
|
||||||
|
#[must_use]
|
||||||
|
pub fn datagrams_from(&self, team: u8, sender_id: u32) -> Vec<Vec<u8>> {
|
||||||
|
self.reports(team)
|
||||||
|
.iter()
|
||||||
|
.map(|report| report.encode_from(sender_id))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上报节奏:血量一变立刻发,没变就按心跳周期重发。
|
||||||
|
///
|
||||||
|
/// 裁判终端只看得到最后一条,"每 200 ms 重发"是它判断板子还在线的唯一依据 ——
|
||||||
|
/// 没有心跳,一次丢包就会让界面显示成"装甲板未接入"。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ReportSchedule {
|
||||||
|
heartbeat: Duration,
|
||||||
|
last: Option<(u16, Duration)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportSchedule {
|
||||||
|
/// `heartbeat` 是血量不变时的重发周期。
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(heartbeat: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
heartbeat,
|
||||||
|
last: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一刻该不该发。
|
||||||
|
#[must_use]
|
||||||
|
pub fn due(&self, hp: u16, now: Duration) -> bool {
|
||||||
|
match self.last {
|
||||||
|
None => true,
|
||||||
|
Some((last_hp, last_at)) => {
|
||||||
|
last_hp != hp || now.saturating_sub(last_at) >= self.heartbeat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记下"这一刻用这个血量发过了"。
|
||||||
|
pub fn mark_sent(&mut self, hp: u16, now: Duration) {
|
||||||
|
self.last = Some((hp, now));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按键事件。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ButtonEvent {
|
||||||
|
/// 什么都没发生。
|
||||||
|
None,
|
||||||
|
/// 短按(按下后很快松开):固件用它打印一次状态并立刻重发上报。
|
||||||
|
Click,
|
||||||
|
/// 长按(按住到阈值):固件用它复位血量,也就是"新一局"。
|
||||||
|
LongPress,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按键状态机:去抖 + 短按 / 长按,事件只发一次。
|
||||||
|
///
|
||||||
|
/// 参考实现移植了 OneButton 2.6.1 的整套状态机(它还带双击、多击、idle 回调)。
|
||||||
|
/// 这里只有两种动作,所以按同样的语义重写了一个本地版本:电平去抖,长按在按住
|
||||||
|
/// 到阈值时立刻产生一次且不重复,松开时若在短按窗口内则产生一次短按。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Button {
|
||||||
|
debounce: Duration,
|
||||||
|
click_max: Duration,
|
||||||
|
long_press: Duration,
|
||||||
|
stable_pressed: bool,
|
||||||
|
candidate: bool,
|
||||||
|
candidate_since: Duration,
|
||||||
|
pressed_since: Duration,
|
||||||
|
long_fired: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Button {
|
||||||
|
/// BOOT 键(GPIO9,低有效)的现场取值:去抖 20 ms、短按 <500 ms、长按 ≥500 ms。
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(
|
||||||
|
Duration::from_millis(20),
|
||||||
|
Duration::from_millis(500),
|
||||||
|
Duration::from_millis(500),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Button {
|
||||||
|
/// 自定义时间参数,主要给测试用。
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(debounce: Duration, click_max: Duration, long_press: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
debounce,
|
||||||
|
click_max,
|
||||||
|
long_press,
|
||||||
|
stable_pressed: false,
|
||||||
|
candidate: false,
|
||||||
|
candidate_since: Duration::ZERO,
|
||||||
|
pressed_since: Duration::ZERO,
|
||||||
|
long_fired: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 喂一次电平(已换算成"是否按下")与当前时刻,最多产生一个事件。
|
||||||
|
pub fn tick(&mut self, pressed: bool, now: Duration) -> ButtonEvent {
|
||||||
|
let mut event = ButtonEvent::None;
|
||||||
|
|
||||||
|
if pressed != self.stable_pressed {
|
||||||
|
if self.candidate != pressed {
|
||||||
|
// 抖动的新电平:重新开始计稳定时间。
|
||||||
|
self.candidate = pressed;
|
||||||
|
self.candidate_since = now;
|
||||||
|
} else if now.saturating_sub(self.candidate_since) >= self.debounce {
|
||||||
|
self.stable_pressed = pressed;
|
||||||
|
|
||||||
|
if pressed {
|
||||||
|
self.pressed_since = now;
|
||||||
|
self.long_fired = false;
|
||||||
|
} else if !self.long_fired
|
||||||
|
&& now.saturating_sub(self.pressed_since) < self.click_max
|
||||||
|
{
|
||||||
|
event = ButtonEvent::Click;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 电平回到稳定值,候选作废。
|
||||||
|
self.candidate = pressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event == ButtonEvent::None
|
||||||
|
&& self.stable_pressed
|
||||||
|
&& !self.long_fired
|
||||||
|
&& now.saturating_sub(self.pressed_since) >= self.long_press
|
||||||
|
{
|
||||||
|
self.long_fired = true;
|
||||||
|
event = ButtonEvent::LongPress;
|
||||||
|
}
|
||||||
|
|
||||||
|
event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析编译期注入的 `ARMOR_TEAM`。
|
||||||
|
///
|
||||||
|
/// 只接受 "0" 与 "1":把队伍认错会让裁判端把血记到对手头上,现场很难发现,
|
||||||
|
/// 所以宁可启动就报错。
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse_team(value: &str) -> Option<u8> {
|
||||||
|
match value.trim() {
|
||||||
|
"0" => Some(TEAM_1),
|
||||||
|
"1" => Some(TEAM_2),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn ms(v: u64) -> Duration {
|
||||||
|
Duration::from_millis(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn armor() -> Armor {
|
||||||
|
Armor::new(Rules::default(), Rgb::for_team(TEAM_1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 血量 --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn starts_at_full_hp_and_is_not_dead() {
|
||||||
|
let a = armor();
|
||||||
|
assert_eq!(a.hp(), 100);
|
||||||
|
assert!(!a.dead());
|
||||||
|
assert_eq!(a.hits(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_hit_costs_three_and_floors_at_zero() {
|
||||||
|
let mut a = armor();
|
||||||
|
let mut hits = 0;
|
||||||
|
|
||||||
|
// 100 / 3 = 33.33,所以第 34 击才归零。
|
||||||
|
for i in 0..34 {
|
||||||
|
let at = ms(i * 1000);
|
||||||
|
a.on_sample(Plate::Front, 2500, at).expect("hit should count");
|
||||||
|
hits += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(hits, 34);
|
||||||
|
assert_eq!(a.hp(), 0);
|
||||||
|
assert!(a.dead());
|
||||||
|
|
||||||
|
// 已经归零之后继续打,不会变负,也不算"新的一击导致死亡"。
|
||||||
|
let event = a
|
||||||
|
.on_sample(Plate::Rear, 2500, ms(40_000))
|
||||||
|
.expect("still counts as a hit");
|
||||||
|
assert_eq!(event.hp, 0);
|
||||||
|
assert!(!event.dead, "0 -> 0 不是新的死亡事件");
|
||||||
|
assert_eq!(a.hp(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_below_threshold_is_not_a_hit() {
|
||||||
|
let mut a = armor();
|
||||||
|
assert!(a.on_sample(Plate::Front, 2000, ms(0)).is_none(), "阈值是严格大于");
|
||||||
|
assert!(a.on_sample(Plate::Front, 1999, ms(0)).is_none());
|
||||||
|
assert!(a.on_sample(Plate::Front, 2001, ms(0)).is_some());
|
||||||
|
assert_eq!(a.hp(), 97);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn per_plate_hit_counts_are_separate() {
|
||||||
|
let mut a = armor();
|
||||||
|
|
||||||
|
a.on_sample(Plate::Front, 3000, ms(0));
|
||||||
|
a.on_sample(Plate::Left, 3000, ms(1000));
|
||||||
|
a.on_sample(Plate::Left, 3000, ms(2000));
|
||||||
|
|
||||||
|
assert_eq!(a.plate_hits(Plate::Front), 1);
|
||||||
|
assert_eq!(a.plate_hits(Plate::Left), 2);
|
||||||
|
assert_eq!(a.plate_hits(Plate::Rear), 0);
|
||||||
|
assert_eq!(a.plate_hits(Plate::Right), 0);
|
||||||
|
assert_eq!(a.hits(), 3);
|
||||||
|
// 整车一条血量:三击共扣 9。
|
||||||
|
assert_eq!(a.hp(), 91);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 受击去抖 --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeat_samples_inside_the_cooldown_are_one_hit() {
|
||||||
|
let mut a = armor();
|
||||||
|
|
||||||
|
// 40 kHz 采样下,一次击打会连续几十个点过阈值。
|
||||||
|
for i in 0..49 {
|
||||||
|
a.on_sample(Plate::Left, 2600, ms(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(a.plate_hits(Plate::Left), 1, "49 ms 内的连续采样算一次");
|
||||||
|
assert_eq!(a.hp(), 97);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sample_after_the_cooldown_is_a_new_hit() {
|
||||||
|
let mut a = armor();
|
||||||
|
|
||||||
|
a.on_sample(Plate::Left, 2600, ms(0));
|
||||||
|
assert!(a.on_sample(Plate::Left, 2600, ms(49)).is_none());
|
||||||
|
assert!(a.on_sample(Plate::Left, 2600, ms(50)).is_some(), "50 ms 是边界,算新受击");
|
||||||
|
assert_eq!(a.plate_hits(Plate::Left), 2);
|
||||||
|
assert_eq!(a.hp(), 94);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cooldown_is_per_plate_not_global() {
|
||||||
|
let mut a = armor();
|
||||||
|
|
||||||
|
// 参考实现用全局冷却:一块板挨打后,别块板在这 50 ms 内的击打会被吃掉。
|
||||||
|
// 每板独立冷却才是"每面各自挨打"的正确语义。
|
||||||
|
a.on_sample(Plate::Front, 3000, ms(0));
|
||||||
|
assert!(a.on_sample(Plate::Left, 3000, ms(1)).is_some());
|
||||||
|
assert!(a.on_sample(Plate::Rear, 3000, ms(2)).is_some());
|
||||||
|
assert_eq!(a.hits(), 3);
|
||||||
|
assert_eq!(a.hp(), 91);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 灯带输出 --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hit_plate_goes_black_for_fifty_ms_only() {
|
||||||
|
let mut a = armor();
|
||||||
|
a.on_sample(Plate::Left, 3000, ms(1000));
|
||||||
|
|
||||||
|
assert!(a.strip(Plate::Left, ms(1000)).is_black(), "受击瞬间");
|
||||||
|
assert!(a.strip(Plate::Left, ms(1049)).is_black(), "窗口内");
|
||||||
|
assert_eq!(
|
||||||
|
a.strip(Plate::Left, ms(1050)),
|
||||||
|
Strip::Color(Rgb::RED),
|
||||||
|
"窗口结束恢复原色"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 其余三路全程不受影响 —— 这正是每面独立灯带的意义。
|
||||||
|
for plate in [Plate::Front, Plate::Rear, Plate::Right] {
|
||||||
|
assert_eq!(a.strip(plate, ms(1000)), Strip::Color(Rgb::RED));
|
||||||
|
assert_eq!(a.strip(plate, ms(1049)), Strip::Color(Rgb::RED));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dead_blacks_out_every_strip() {
|
||||||
|
let mut a = armor();
|
||||||
|
let last = 34 * 1000;
|
||||||
|
|
||||||
|
for i in 0..34 {
|
||||||
|
a.on_sample(Plate::Front, 3000, ms(i * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(a.dead());
|
||||||
|
for plate in Plate::ALL {
|
||||||
|
assert!(a.strip(plate, ms(last)).is_black(), "{plate:?} 死了要全黑");
|
||||||
|
}
|
||||||
|
// 冷却窗口早就过了,仍然全黑:死优先于一切。
|
||||||
|
assert!(a.strip(Plate::Left, ms(last + 10_000)).is_black());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_returns_all_four_in_index_order() {
|
||||||
|
let mut a = armor();
|
||||||
|
a.on_sample(Plate::Rear, 3000, ms(0));
|
||||||
|
let strips = a.strips(ms(0));
|
||||||
|
|
||||||
|
assert_eq!(strips.len(), 4);
|
||||||
|
assert!(strips[Plate::Rear.index() as usize].is_black());
|
||||||
|
assert_eq!(strips[Plate::Front.index() as usize].color(), Rgb::RED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 复位 --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_restores_full_hp_and_keeps_the_colour() {
|
||||||
|
let mut a = armor();
|
||||||
|
a.on_sample(Plate::Front, 3000, ms(0));
|
||||||
|
a.on_sample(Plate::Right, 3000, ms(100));
|
||||||
|
a.set_color(Rgb::BLUE);
|
||||||
|
|
||||||
|
a.reset();
|
||||||
|
|
||||||
|
assert_eq!(a.hp(), 100);
|
||||||
|
assert_eq!(a.hits(), 0);
|
||||||
|
assert_eq!(a.plate_hits_all(), [0; 4]);
|
||||||
|
assert_eq!(a.color(), Rgb::BLUE);
|
||||||
|
assert_eq!(a.strip(Plate::Front, ms(100)), Strip::Color(Rgb::BLUE));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 上报 --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reports_carry_the_shared_hp_on_all_four_indices() {
|
||||||
|
let mut a = armor();
|
||||||
|
a.on_sample(Plate::Front, 3000, ms(0));
|
||||||
|
a.on_sample(Plate::Front, 3000, ms(1000));
|
||||||
|
|
||||||
|
let reports = a.reports(TEAM_2);
|
||||||
|
assert_eq!(reports.len(), 4);
|
||||||
|
|
||||||
|
for (i, report) in reports.iter().enumerate() {
|
||||||
|
assert_eq!(report.team, TEAM_2);
|
||||||
|
assert_eq!(report.index, i as u8, "四格按 index 排列");
|
||||||
|
assert_eq!(report.hp, 94, "整车一条血量,四格同值");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn datagrams_round_trip_through_the_shared_protocol() {
|
||||||
|
let mut a = armor();
|
||||||
|
a.on_sample(Plate::Left, 3000, ms(0));
|
||||||
|
|
||||||
|
let datagrams = a.datagrams(TEAM_1);
|
||||||
|
assert_eq!(datagrams.len(), 4);
|
||||||
|
|
||||||
|
for (i, bytes) in datagrams.iter().enumerate() {
|
||||||
|
// 裁判端就是用这个函数解回来的,编码错了这里立刻挂。
|
||||||
|
let decoded = ArmorHealth::decode(bytes).expect("裁判端要能解出来");
|
||||||
|
assert_eq!(decoded.team, TEAM_1);
|
||||||
|
assert_eq!(decoded.index, i as u8);
|
||||||
|
assert_eq!(decoded.hp, 97);
|
||||||
|
assert_eq!(bytes.len(), protocol::HEADER_LEN + 4);
|
||||||
|
assert_eq!(bytes[4], MsgType::ArmorHealth as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn datagrams_can_carry_the_board_id() {
|
||||||
|
let a = armor();
|
||||||
|
let datagrams = a.datagrams_from(TEAM_2, 0xABCD_1234);
|
||||||
|
|
||||||
|
for bytes in &datagrams {
|
||||||
|
let header = protocol::decode_header(bytes).expect("帧头可解");
|
||||||
|
assert_eq!(header.sender_id, 0xABCD_1234);
|
||||||
|
assert_eq!(ArmorHealth::decode(bytes).map(|r| r.team), Some(TEAM_2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认入口仍然是裁判终端的保留 id 0。
|
||||||
|
let plain = a.datagrams(TEAM_2);
|
||||||
|
assert_eq!(
|
||||||
|
protocol::decode_header(&plain[0]).map(|h| h.sender_id),
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_schedule_fires_immediately_then_heartbeats() {
|
||||||
|
let mut s = ReportSchedule::new(ms(200));
|
||||||
|
|
||||||
|
assert!(s.due(100, ms(0)), "第一次一定发");
|
||||||
|
s.mark_sent(100, ms(0));
|
||||||
|
|
||||||
|
assert!(!s.due(100, ms(199)));
|
||||||
|
assert!(s.due(100, ms(200)), "心跳到期");
|
||||||
|
|
||||||
|
// 血量变化不等心跳。
|
||||||
|
assert!(s.due(97, ms(201)));
|
||||||
|
s.mark_sent(97, ms(201));
|
||||||
|
assert!(!s.due(97, ms(250)));
|
||||||
|
assert!(s.due(94, ms(251)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 按键 --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn button_needs_the_debounce_window() {
|
||||||
|
let mut b = Button::new(ms(20), ms(500), ms(500));
|
||||||
|
|
||||||
|
assert_eq!(b.tick(true, ms(0)), ButtonEvent::None);
|
||||||
|
assert_eq!(b.tick(false, ms(5)), ButtonEvent::None, "5 ms 的毛刺不算按下");
|
||||||
|
assert_eq!(b.tick(true, ms(10)), ButtonEvent::None);
|
||||||
|
// 从 10 ms 起连续按下到 30 ms 才稳定。
|
||||||
|
assert_eq!(b.tick(true, ms(29)), ButtonEvent::None);
|
||||||
|
assert_eq!(b.tick(true, ms(30)), ButtonEvent::None, "按下本身不产生事件");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_press_is_a_click() {
|
||||||
|
let mut b = Button::new(ms(20), ms(500), ms(500));
|
||||||
|
|
||||||
|
b.tick(true, ms(0));
|
||||||
|
b.tick(true, ms(20)); // 稳定按下
|
||||||
|
b.tick(false, ms(100));
|
||||||
|
assert_eq!(b.tick(false, ms(120)), ButtonEvent::Click);
|
||||||
|
// 只发一次。
|
||||||
|
assert_eq!(b.tick(false, ms(200)), ButtonEvent::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn holding_produces_one_long_press_and_no_click_on_release() {
|
||||||
|
let mut b = Button::new(ms(20), ms(500), ms(500));
|
||||||
|
|
||||||
|
b.tick(true, ms(0));
|
||||||
|
b.tick(true, ms(20)); // 稳定按下
|
||||||
|
assert_eq!(b.tick(true, ms(400)), ButtonEvent::None);
|
||||||
|
assert_eq!(b.tick(true, ms(520)), ButtonEvent::LongPress, "按住到阈值");
|
||||||
|
assert_eq!(b.tick(true, ms(900)), ButtonEvent::None, "不回弹重复长按");
|
||||||
|
|
||||||
|
b.tick(false, ms(1000));
|
||||||
|
assert_eq!(
|
||||||
|
b.tick(false, ms(1020)),
|
||||||
|
ButtonEvent::None,
|
||||||
|
"长按过的这一下不该再算短按"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 其它 --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colours_match_the_robo_master_convention() {
|
||||||
|
assert_eq!(Rgb::for_team(TEAM_1), Rgb::RED);
|
||||||
|
assert_eq!(Rgb::for_team(TEAM_2), Rgb::BLUE);
|
||||||
|
|
||||||
|
// 存 NVS / 打印与 Adafruit_NeoPixel::Color 的打包一致。
|
||||||
|
assert_eq!(Rgb::RED.to_u32(), 0x00FF_0000);
|
||||||
|
assert_eq!(Rgb::BLUE.to_u32(), 0x0000_00FF);
|
||||||
|
assert_eq!(Rgb::from_u32(0x0012_3456), Rgb(0x12, 0x34, 0x56));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ws2812_byte_order_is_grb() {
|
||||||
|
assert_eq!(Rgb::RED.to_grb(), [0x00, 0xFF, 0x00]);
|
||||||
|
assert_eq!(Rgb::BLUE.to_grb(), [0x00, 0x00, 0xFF]);
|
||||||
|
assert_eq!(Rgb(0x11, 0x22, 0x33).to_grb(), [0x22, 0x11, 0x33]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plate_indices_are_stable() {
|
||||||
|
assert_eq!(Plate::Front.index(), 0);
|
||||||
|
assert_eq!(Plate::Rear.index(), 1);
|
||||||
|
assert_eq!(Plate::Left.index(), 2);
|
||||||
|
assert_eq!(Plate::Right.index(), 3);
|
||||||
|
|
||||||
|
for plate in Plate::ALL {
|
||||||
|
assert_eq!(Plate::from_index(plate.index()), Some(plate));
|
||||||
|
}
|
||||||
|
assert_eq!(Plate::from_index(4), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn team_env_parsing_rejects_anything_but_zero_and_one() {
|
||||||
|
assert_eq!(parse_team("0"), Some(TEAM_1));
|
||||||
|
assert_eq!(parse_team("1"), Some(TEAM_2));
|
||||||
|
assert_eq!(parse_team(" 1\n"), Some(TEAM_2));
|
||||||
|
assert_eq!(parse_team("2"), None);
|
||||||
|
assert_eq!(parse_team("team1"), None);
|
||||||
|
assert_eq!(parse_team(""), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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,214 +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。
|
|
||||||
|
|
||||||
### 槽位不会左右跳动
|
|
||||||
|
|
||||||
裁判端按 sender_id 排序填两个槽位,**槽位保持稳定**——某块板子临时离线时不会重新
|
|
||||||
洗牌。如果每次都重新排版,一块板子掉线会让剩下的画面从左边跳到右边,现场看非常像
|
|
||||||
"两路串了"。
|
|
||||||
|
|
||||||
`view.sender_id != wanted` 才重建 `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` 决定颜色:空数组会让整个
|
|
||||||
占位框消失,看起来像布局出错,而不是像"还没有数据"。
|
|
||||||
|
|
||||||
## 内存:解码只留最新一张
|
|
||||||
|
|
||||||
解码线程在写入前先 `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,628 +0,0 @@
|
|||||||
//! ESP32 图传查看器。
|
|
||||||
//!
|
|
||||||
//! 两种身份,由 [`Role`] 决定:
|
|
||||||
//!
|
|
||||||
//! - **队伍端** —— 一块画面 + 自己那一边的血量。需要绑定一块板子(选定后不可更改)。
|
|
||||||
//! - **裁判端** —— 两块画面左右并排 + 双方血量。不需要绑定。
|
|
||||||
//!
|
|
||||||
//! 用法:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! cam-client 默认裁判端
|
|
||||||
//! cam-client --role team --team 1 队伍一
|
|
||||||
//! cam-client --camera-id 0x3D4D8A9A 绑定到某块板子
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! 绑定与身份存在配置文件里(默认 `~/.config/cam-client/config.toml`)。首次运行
|
|
||||||
//! 队伍端会列出发现的摄像头,选定后写入,之后不再询问。
|
|
||||||
//!
|
|
||||||
//! # 为什么血量是占位符
|
|
||||||
//!
|
|
||||||
//! 装甲板接口已经预留([`receiver::ArmorSource`] 与 `protocol::ArmorHealth`),但
|
|
||||||
//! 现在没有实现也没有数据源。界面显示占位符而不是 0——0 看起来像真值,现场看到会
|
|
||||||
//! 以为对方已经没血了。
|
|
||||||
|
|
||||||
mod config;
|
|
||||||
mod receiver;
|
|
||||||
|
|
||||||
use std::error::Error;
|
|
||||||
use std::net::Ipv4Addr;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use config::{Config, Role};
|
|
||||||
use receiver::{
|
|
||||||
decode_frame, ArmorSource, CameraInfo, DecodedFrame, NoArmorYet, Receiver,
|
|
||||||
SourceStats, ARMOR_BOARDS,
|
|
||||||
};
|
|
||||||
|
|
||||||
slint::include_modules!();
|
|
||||||
|
|
||||||
const USAGE: &str = "\
|
|
||||||
用法: cam-client [选项]
|
|
||||||
|
|
||||||
--role <team|referee> 身份。默认 referee(无需配置即可看两路)
|
|
||||||
--team <0|1> 自己属于哪一边,仅 team 端用
|
|
||||||
--camera-id <0xID> 绑定到某块板子(MAC 派生,重启不变)
|
|
||||||
--camera <IP> 向哪块板子发订阅(默认 255.255.255.255 广播)
|
|
||||||
--config <路径> 配置文件位置
|
|
||||||
--list 列出发现的摄像头后退出
|
|
||||||
-h, --help 显示这段帮助
|
|
||||||
";
|
|
||||||
|
|
||||||
/// 事件循环轮询新数据的间隔。
|
|
||||||
///
|
|
||||||
/// 不是每帧一次:显示不需要每秒重绘 22 次,更快只是让事件循环和接收路径抢 CPU。
|
|
||||||
const UI_POLL: Duration = Duration::from_millis(33);
|
|
||||||
|
|
||||||
/// 帧率统计窗口。
|
|
||||||
///
|
|
||||||
/// 按计数除以墙钟时间,而不是对帧间隔取平均。一次轮询之间可能完成多帧,所以平均间隔
|
|
||||||
/// 量的是**显示**的刷新快慢而不是**相机**的出帧快慢——那会把上限 22fps 的传感器
|
|
||||||
/// 报成 36fps。一个笃定的错误数字比没有数字更糟。
|
|
||||||
const FPS_WINDOW: Duration = Duration::from_secs(2);
|
|
||||||
|
|
||||||
/// 某一路的显示状态。
|
|
||||||
struct View {
|
|
||||||
/// 显示哪块板子(`None` = 还没有画面)。
|
|
||||||
sender_id: Option<u32>,
|
|
||||||
fps: f32,
|
|
||||||
window_started: Instant,
|
|
||||||
window_frames: u64,
|
|
||||||
reported: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl View {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
sender_id: None,
|
|
||||||
fps: 0.0,
|
|
||||||
window_started: Instant::now(),
|
|
||||||
window_frames: 0,
|
|
||||||
reported: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 累计窗口内的帧数,算出帧率。
|
|
||||||
fn update_fps(&mut self, stats: &SourceStats) {
|
|
||||||
let window = self.window_started.elapsed();
|
|
||||||
if window < FPS_WINDOW {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.fps = (stats.frames_shown - self.window_frames) as f32 / window.as_secs_f32();
|
|
||||||
self.window_frames = stats.frames_shown;
|
|
||||||
self.window_started = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解码后的画面缓存,供事件循环取用。
|
|
||||||
///
|
|
||||||
/// 解码线程只处理纯数据(`Vec<u8>` + 尺寸),`slint::Image` 的构造留在事件循环,
|
|
||||||
/// 因为 `slint::Image` 不是 `Send`。
|
|
||||||
type DecodedSlots = Arc<Mutex<Vec<(u32, DecodedFrame)>>>;
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
|
||||||
|
|
||||||
let args = Args::parse(std::env::args().skip(1))?;
|
|
||||||
|
|
||||||
if args.help {
|
|
||||||
print!("{USAGE}");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let config_path = args.config.clone().unwrap_or_else(config::default_config_path);
|
|
||||||
|
|
||||||
// 命令行覆盖配置文件,这样现场可以临时当另一台机器用而不用改文件。
|
|
||||||
let mut config = Config::load(&config_path)?;
|
|
||||||
|
|
||||||
if let Some(role) = args.role {
|
|
||||||
config.role = role;
|
|
||||||
}
|
|
||||||
if let Some(team) = args.team {
|
|
||||||
config.team = team;
|
|
||||||
}
|
|
||||||
if let Some(id) = args.camera_id {
|
|
||||||
config.camera_id = Some(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"role={} team={} camera_id={:?} config={}",
|
|
||||||
config.role.as_str(),
|
|
||||||
config.team,
|
|
||||||
config.camera_id,
|
|
||||||
config_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let rx = Arc::new(Receiver::start(
|
|
||||||
args.camera,
|
|
||||||
config.role.to_protocol(),
|
|
||||||
)?);
|
|
||||||
let discovery = rx.discovery();
|
|
||||||
let frames = rx.frames();
|
|
||||||
|
|
||||||
if args.list {
|
|
||||||
// 等几个广播周期,让列表有内容。
|
|
||||||
let socket = bind_socket()?;
|
|
||||||
let for_list = Arc::clone(&rx);
|
|
||||||
let handle = std::thread::spawn(move || for_list.run(socket));
|
|
||||||
|
|
||||||
std::thread::sleep(Duration::from_secs(5));
|
|
||||||
|
|
||||||
let cameras = discovery.online_cameras();
|
|
||||||
if cameras.is_empty() {
|
|
||||||
println!("没有发现摄像头。板子每 2 秒广播一次,再等等或检查网络。");
|
|
||||||
} else {
|
|
||||||
println!("发现 {} 块板子:", cameras.len());
|
|
||||||
for c in cameras {
|
|
||||||
println!(" 0x{:08X} {} (id 与 MAC 派生,重启不变)", c.id, c.addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rx.shutdown();
|
|
||||||
let _ = handle.join();
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 血量目前没有数据源。接口已经就位,装甲板接上后换掉这一行即可。
|
|
||||||
let armor: Arc<dyn ArmorSource> = Arc::new(NoArmorYet);
|
|
||||||
|
|
||||||
let decoded: DecodedSlots = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
|
|
||||||
// 接收线程。
|
|
||||||
{
|
|
||||||
let rx = Arc::clone(&rx);
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("udp-reader".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let Ok(socket) = bind_socket() else {
|
|
||||||
log::error!("could not bind the socket for the receive loop");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
rx.run(socket);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解码线程:一张 VGA 的 JPEG 要几毫秒,放在收包线程里会挤进数据报之间,造成
|
|
||||||
// 链路上本不存在的丢帧。
|
|
||||||
{
|
|
||||||
let frames = Arc::clone(&frames);
|
|
||||||
let decoded = Arc::clone(&decoded);
|
|
||||||
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("jpeg-decode".into())
|
|
||||||
.spawn(move || {
|
|
||||||
loop {
|
|
||||||
if frames.pending_senders().is_empty() {
|
|
||||||
std::thread::sleep(Duration::from_millis(5));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for sender_id in frames.pending_senders() {
|
|
||||||
let Some(frame) = frames.take(sender_id) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
match decode_frame(&frame.data) {
|
|
||||||
Some(pixels) => {
|
|
||||||
let mut slot = decoded.lock().expect("decoded mutex poisoned");
|
|
||||||
|
|
||||||
// 同一路只保留最新一张,避免解码已经过时的画面。
|
|
||||||
slot.retain(|(id, _)| *id != sender_id);
|
|
||||||
slot.push((sender_id, pixels));
|
|
||||||
}
|
|
||||||
None => log::warn!(
|
|
||||||
"frame {} from 0x{:08X} is not a decodable JPEG ({} bytes)",
|
|
||||||
frame.frame_id,
|
|
||||||
frame.sender_id,
|
|
||||||
frame.data.len()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timer = slint::Timer::default();
|
|
||||||
|
|
||||||
// 每个身份一个闭包,而不是一个闭包里再 match 身份。
|
|
||||||
//
|
|
||||||
// 两种身份是两个不同的组件类型(TeamApp / RefereeApp),没有共同的超类可以
|
|
||||||
// 在一个闭包里统一调用 set_frame/set_frame_a。写成两个分支后每边都拿到具体
|
|
||||||
// 类型,属性赋值直接调用,不再需要 match 身份——那种 match 会让编译器在每个
|
|
||||||
// 赋值点都检查一遍身份,而它本来就是编译期就定死的。
|
|
||||||
match config.role {
|
|
||||||
Role::Team => {
|
|
||||||
let ui = TeamApp::new()?;
|
|
||||||
ui.show()?;
|
|
||||||
|
|
||||||
let weak = ui.as_weak();
|
|
||||||
let discovery = Arc::clone(&discovery);
|
|
||||||
let decoded = Arc::clone(&decoded);
|
|
||||||
let armor = Arc::clone(&armor);
|
|
||||||
let config_path = config_path.clone();
|
|
||||||
let team = config.team;
|
|
||||||
let bound = config.camera_id;
|
|
||||||
|
|
||||||
let mut view = View::new();
|
|
||||||
|
|
||||||
// 绑定回调与刷新定时器都要读这份列表,所以用锁共享,而不是让其中一个
|
|
||||||
// 把 `choices` 移走。
|
|
||||||
let choices: Arc<Mutex<Vec<CameraChoice>>> = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
|
|
||||||
// 绑定完成时写配置。"选定不可更改"就落在这里:此后配置里已有
|
|
||||||
// camera_id,界面不再出现选择页。
|
|
||||||
let choices_for_pick = Arc::clone(&choices);
|
|
||||||
|
|
||||||
let pick_handle = ui.on_pick(move |index| {
|
|
||||||
let Ok(index) = usize::try_from(index) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let picked = choices_for_pick
|
|
||||||
.lock()
|
|
||||||
.expect("choices mutex poisoned")
|
|
||||||
.get(index)
|
|
||||||
.map(|c| c.id_text.to_string());
|
|
||||||
|
|
||||||
let Some(id_text) = picked else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(id) = parse_id(&id_text).ok() else {
|
|
||||||
log::error!("could not read back the selected id {id_text:?}");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let saved = Config {
|
|
||||||
role: Role::Team,
|
|
||||||
team,
|
|
||||||
camera_id: Some(id),
|
|
||||||
camera_addr: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = saved.save(&config_path) {
|
|
||||||
log::error!("could not save the binding: {err}");
|
|
||||||
log::error!("画面仍会显示,但下次启动要重新选择");
|
|
||||||
} else {
|
|
||||||
log::info!("bound to camera 0x{id:08X}; saved in the config");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let choices_for_poll = Arc::clone(&choices);
|
|
||||||
|
|
||||||
timer.start(slint::TimerMode::Repeated, UI_POLL, move || {
|
|
||||||
let Some(ui) = weak.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let cameras = discovery.online_cameras();
|
|
||||||
|
|
||||||
// 未绑定:只显示选择界面。
|
|
||||||
let Some(bound) = bound else {
|
|
||||||
let updated: Vec<CameraChoice> = cameras
|
|
||||||
.iter()
|
|
||||||
.map(|c| CameraChoice {
|
|
||||||
id_text: format!("0x{:08X}", c.id).into(),
|
|
||||||
addr_text: format!("{} ({:?})", c.addr, c.silence()).into(),
|
|
||||||
online: c.is_online(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
*choices_for_poll.lock().expect("choices mutex poisoned") = updated.clone();
|
|
||||||
|
|
||||||
ui.set_choices(std::rc::Rc::new(slint::VecModel::from(updated)).into());
|
|
||||||
ui.set_choosing(true);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
ui.set_choosing(false);
|
|
||||||
|
|
||||||
if view.sender_id != Some(bound) {
|
|
||||||
view = View::new();
|
|
||||||
view.sender_id = Some(bound);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(image) = take_decoded(&decoded, bound) {
|
|
||||||
ui.set_frame(image);
|
|
||||||
}
|
|
||||||
|
|
||||||
let stats = discovery.stats(bound);
|
|
||||||
view.update_fps(&stats);
|
|
||||||
|
|
||||||
if !view.reported && stats.frames_shown > 0 {
|
|
||||||
log_diagnostics(&stats, bound);
|
|
||||||
view.reported = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.set_stats(render_camera_stats(&cameras, bound, &stats, view.fps));
|
|
||||||
ui.set_health(render_health(team, armor.as_ref()));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 绑定回调必须活到窗口关闭。CallbackHandle 是 Copy,但 Drop 会注销
|
|
||||||
// 回调,所以必须真的把它泄漏掉——之前用 `mem::forget` 编译器提示它对
|
|
||||||
// Copy 类型无效,那确实无效:第一次点击后回调就被注销了,第二次点击
|
|
||||||
// 什么也不会发生。
|
|
||||||
std::boxed::Box::leak(Box::new(pick_handle));
|
|
||||||
}
|
|
||||||
|
|
||||||
Role::Referee => {
|
|
||||||
let ui = RefereeApp::new()?;
|
|
||||||
ui.show()?;
|
|
||||||
|
|
||||||
let weak = ui.as_weak();
|
|
||||||
let discovery = Arc::clone(&discovery);
|
|
||||||
let decoded = Arc::clone(&decoded);
|
|
||||||
let armor = Arc::clone(&armor);
|
|
||||||
|
|
||||||
let mut views: Vec<View> = (0..2).map(|_| View::new()).collect();
|
|
||||||
|
|
||||||
timer.start(slint::TimerMode::Repeated, UI_POLL, move || {
|
|
||||||
let Some(ui) = weak.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let cameras = discovery.online_cameras();
|
|
||||||
|
|
||||||
// 两路按 id 顺序填槽。槽位保持稳定(不会因为某块板子临时离线就重新
|
|
||||||
// 洗牌),否则画面会左右跳动。
|
|
||||||
let ids: Vec<u32> = cameras.iter().map(|c| c.id).take(2).collect();
|
|
||||||
|
|
||||||
for (i, view) in views.iter_mut().enumerate() {
|
|
||||||
let wanted = ids.get(i).copied();
|
|
||||||
if view.sender_id != wanted {
|
|
||||||
*view = View::new();
|
|
||||||
view.sender_id = wanted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i, view) in views.iter_mut().enumerate() {
|
|
||||||
let Some(sender_id) = view.sender_id else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(image) = take_decoded(&decoded, sender_id) {
|
|
||||||
if i == 0 {
|
|
||||||
ui.set_frame_a(image);
|
|
||||||
} else {
|
|
||||||
ui.set_frame_b(image);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let stats = discovery.stats(sender_id);
|
|
||||||
view.update_fps(&stats);
|
|
||||||
|
|
||||||
if !view.reported && stats.frames_shown > 0 {
|
|
||||||
log_diagnostics(&stats, sender_id);
|
|
||||||
view.reported = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let view_stats = render_camera_stats(&cameras, sender_id, &stats, view.fps);
|
|
||||||
|
|
||||||
if i == 0 {
|
|
||||||
ui.set_stats_a(view_stats);
|
|
||||||
} else {
|
|
||||||
ui.set_stats_b(view_stats);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.set_health_a(render_health(0, armor.as_ref()));
|
|
||||||
ui.set_health_b(render_health(1, armor.as_ref()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timer 一旦被 drop 就停止,所以要活到进程结束。
|
|
||||||
let _timer = Box::leak(Box::new(timer));
|
|
||||||
|
|
||||||
slint::run_event_loop()?;
|
|
||||||
|
|
||||||
rx.shutdown();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从解码缓存里取出某一路的最新画面。
|
|
||||||
///
|
|
||||||
/// 解码线程只处理纯数据,`slint::Image` 的构造留在这里——`slint::Image` 不是
|
|
||||||
/// `Send`,跨不了线程。
|
|
||||||
fn take_decoded(
|
|
||||||
decoded: &DecodedSlots,
|
|
||||||
sender_id: u32,
|
|
||||||
) -> Option<slint::Image> {
|
|
||||||
let mut slot = decoded.lock().expect("decoded mutex poisoned");
|
|
||||||
|
|
||||||
let pos = slot.iter().position(|(id, _)| *id == sender_id)?;
|
|
||||||
let (_, pixels) = slot.remove(pos);
|
|
||||||
|
|
||||||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
|
|
||||||
&pixels.rgba,
|
|
||||||
pixels.width,
|
|
||||||
pixels.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
Some(slint::Image::from_rgba8(buffer))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 命令行参数。
|
|
||||||
struct Args {
|
|
||||||
role: Option<Role>,
|
|
||||||
team: Option<u8>,
|
|
||||||
camera_id: Option<u32>,
|
|
||||||
camera: Ipv4Addr,
|
|
||||||
config: Option<PathBuf>,
|
|
||||||
list: bool,
|
|
||||||
help: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Args {
|
|
||||||
fn parse(argv: impl Iterator<Item = String>) -> Result<Self, Box<dyn Error>> {
|
|
||||||
let mut args = Args {
|
|
||||||
role: None,
|
|
||||||
team: None,
|
|
||||||
camera_id: None,
|
|
||||||
camera: protocol::BROADCAST_ADDR,
|
|
||||||
config: None,
|
|
||||||
list: false,
|
|
||||||
help: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut it = argv.peekable();
|
|
||||||
|
|
||||||
while let Some(arg) = it.next() {
|
|
||||||
match arg.as_str() {
|
|
||||||
"-h" | "--help" => args.help = true,
|
|
||||||
"--list" => args.list = true,
|
|
||||||
"--role" => {
|
|
||||||
let v = it.next().ok_or("--role needs a value")?;
|
|
||||||
args.role =
|
|
||||||
Some(Role::parse(&v).ok_or("role must be `team` or `referee`")?);
|
|
||||||
}
|
|
||||||
"--team" => {
|
|
||||||
let v = it.next().ok_or("--team needs a value")?;
|
|
||||||
args.team = Some(v.parse().map_err(|_| "--team must be 0 or 1")?);
|
|
||||||
}
|
|
||||||
"--camera-id" => {
|
|
||||||
let v = it.next().ok_or("--camera-id needs a value")?;
|
|
||||||
args.camera_id = Some(parse_id(&v)?);
|
|
||||||
}
|
|
||||||
"--camera" => {
|
|
||||||
let v = it.next().ok_or("--camera needs a value")?;
|
|
||||||
args.camera = v
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| "--camera must be an IPv4 address")?;
|
|
||||||
}
|
|
||||||
"--config" => {
|
|
||||||
args.config = Some(PathBuf::from(it.next().ok_or("--config needs a path")?));
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
return Err(format!("unknown argument {other:?}\n\n{USAGE}").into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 绑定 UDP 接收 socket。
|
|
||||||
///
|
|
||||||
/// 固定端口(而非临时端口)是必须的:板子从订阅的**源端口**学到推流目标,而这个
|
|
||||||
/// socket 同时承担收包。
|
|
||||||
fn bind_socket() -> std::io::Result<std::net::UdpSocket> {
|
|
||||||
let socket = std::net::UdpSocket::bind(std::net::SocketAddrV4::new(
|
|
||||||
Ipv4Addr::UNSPECIFIED,
|
|
||||||
protocol::DEFAULT_PORT,
|
|
||||||
))?;
|
|
||||||
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
|
|
||||||
|
|
||||||
// 广播是发现的前提:板子靠它宣告存在,客户端靠它发起订阅。
|
|
||||||
let _ = socket.set_broadcast(true);
|
|
||||||
Ok(socket)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 `0x1A2B3C4D` 或十进制形式的板子 id。
|
|
||||||
fn parse_id(text: &str) -> Result<u32, Box<dyn Error>> {
|
|
||||||
let text = text.trim();
|
|
||||||
|
|
||||||
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
|
|
||||||
Ok(u32::from_str_radix(hex, 16)?)
|
|
||||||
} else {
|
|
||||||
Ok(text.parse()?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一次性诊断,在某一路第一次出画面时记录。
|
|
||||||
///
|
|
||||||
/// 不再单列"无法解码"计数:那是链路正常但内容坏了的情形,会在解码线程里逐帧告警,
|
|
||||||
/// 汇总在这里反而会把它和普通的丢帧混在一起。
|
|
||||||
fn log_diagnostics(stats: &SourceStats, sender_id: u32) {
|
|
||||||
log::info!(
|
|
||||||
"camera 0x{sender_id:08X}: {} frames shown | {} dropped ({:.1}%) | \
|
|
||||||
{} datagrams | {}x{} {} bytes",
|
|
||||||
stats.frames_shown,
|
|
||||||
stats.frames_dropped,
|
|
||||||
stats.loss_percent(),
|
|
||||||
stats.datagrams,
|
|
||||||
stats.width,
|
|
||||||
stats.height,
|
|
||||||
stats.last_frame_len,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 把某一路的统计渲染成界面结构。
|
|
||||||
fn render_camera_stats(
|
|
||||||
cameras: &[CameraInfo],
|
|
||||||
sender_id: u32,
|
|
||||||
stats: &SourceStats,
|
|
||||||
fps: f32,
|
|
||||||
) -> CameraStats {
|
|
||||||
let online = cameras
|
|
||||||
.iter()
|
|
||||||
.find(|c| c.id == sender_id)
|
|
||||||
.map(CameraInfo::is_online)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
|
|
||||||
// 比让人以为板子挂了诚实。
|
|
||||||
let status = if stats.frames_shown == 0 {
|
|
||||||
if online {
|
|
||||||
"已连接,等待画面…".to_string()
|
|
||||||
} else {
|
|
||||||
"离线".to_string()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
format!("0x{:08X}", sender_id)
|
|
||||||
};
|
|
||||||
|
|
||||||
CameraStats {
|
|
||||||
online,
|
|
||||||
status_text: status.into(),
|
|
||||||
fps: format!("{fps:.1}").into(),
|
|
||||||
resolution: format!("{}x{}", stats.width, stats.height).into(),
|
|
||||||
frame_bytes: format!("{:.1} kB", stats.last_frame_len as f32 / 1024.0).into(),
|
|
||||||
frames_shown: stats.frames_shown.to_string().into(),
|
|
||||||
frames_dropped: stats.frames_dropped.to_string().into(),
|
|
||||||
loss_percent: format!("{:.1}%", stats.loss_percent()).into(),
|
|
||||||
datagrams: stats.datagrams.to_string().into(),
|
|
||||||
gap_ms: stats
|
|
||||||
.last_interval
|
|
||||||
.map(|d| format!("{:.0} ms", d.as_secs_f32() * 1000.0))
|
|
||||||
.unwrap_or_else(|| "-".to_string())
|
|
||||||
.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 渲染一边的血量。
|
|
||||||
///
|
|
||||||
/// 现在永远走 `available == false` 的分支,因为还没有数据源。装甲板接上后
|
|
||||||
/// [`ArmorSource::health`] 返回 `Some`,界面自动显示真实数值,这里不用改。
|
|
||||||
///
|
|
||||||
/// 没有数据时用占位符而不是 0:0 看起来像真值,现场看到会以为对方已经没血了。
|
|
||||||
fn render_health(team: u8, armor: &dyn ArmorSource) -> TeamHealth {
|
|
||||||
let name = match team {
|
|
||||||
0 => "队伍一",
|
|
||||||
_ => "队伍二",
|
|
||||||
};
|
|
||||||
|
|
||||||
match armor.health(team) {
|
|
||||||
Some(hps) => TeamHealth {
|
|
||||||
team_name: format!("{name} · 装甲板").into(),
|
|
||||||
available: true,
|
|
||||||
placeholder: String::new().into(),
|
|
||||||
hps: std::rc::Rc::new(slint::VecModel::from(
|
|
||||||
hps.iter().map(|hp| *hp as i32).collect::<Vec<i32>>(),
|
|
||||||
))
|
|
||||||
.into(),
|
|
||||||
},
|
|
||||||
None => TeamHealth {
|
|
||||||
team_name: name.into(),
|
|
||||||
available: false,
|
|
||||||
placeholder:
|
|
||||||
"装甲板未接入:血量接口已预留(protocol::ArmorHealth),当前无数据源".into(),
|
|
||||||
// 依然给出 4 个格子:界面用 `available` 决定颜色,所以空数组会让占位
|
|
||||||
// 框整个消失,看起来像布局出错,而不是像"还没有数据"。
|
|
||||||
hps: std::rc::Rc::new(slint::VecModel::from(vec![0i32; ARMOR_BOARDS])).into(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,458 +0,0 @@
|
|||||||
//! UDP 接收、发现、多路帧重组。
|
|
||||||
//!
|
|
||||||
//! # 多路
|
|
||||||
//!
|
|
||||||
//! 裁判终端同时收两块图传板,队伍终端也会收到两块(只是界面上只显示一块)。
|
|
||||||
//! 所以重组状态**必须按发送方分开**——把两块板的帧塞进同一个重组器,会让它们
|
|
||||||
//! 的 `frame_id` 互相顶掉,两路都出不来。
|
|
||||||
//!
|
|
||||||
//! # 发现与订阅
|
|
||||||
//!
|
|
||||||
//! 两个方向各司其职:
|
|
||||||
//!
|
|
||||||
//! - 板子每 2 秒广播一次 [`protocol::MsgType::Hello`],客户端被动收到就知道有谁在线。
|
|
||||||
//! - 客户端广播 [`protocol::MsgType::Subscribe`],板子收到后开始定向推流。
|
|
||||||
//!
|
|
||||||
//! 板子靠**源地址**记住客户端,所以客户端只要周期性续订就能维持推流,不存在
|
|
||||||
//! 任何超时淘汰的竞态。
|
|
||||||
//!
|
|
||||||
//! # 丢帧策略
|
|
||||||
//!
|
|
||||||
//! 缺了分片的帧整个丢弃、下一帧顶上,不重传也不尝试补洞。对实时画面这是对的:
|
|
||||||
//! 等重传补齐时,那一帧早就不新鲜了。这个取舍的代价是丢帧率必须摆在界面上,否则
|
|
||||||
//! "画面卡"没法区分是链路问题还是传感器问题。
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::io::ErrorKind;
|
|
||||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::thread;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use protocol::{Fragment, FrameAssembler, Subscribe, SubscribeRole, DEFAULT_PORT, HEADER_LEN};
|
|
||||||
|
|
||||||
/// 接受的最大数据报。高于协议的理论上限(1427),留出余量,这样一个被截断的读只可能
|
|
||||||
/// 来自网络,而不是来自过小的缓冲。
|
|
||||||
const RECV_BUFFER: usize = 2048;
|
|
||||||
|
|
||||||
/// 重新广播订阅的间隔。
|
|
||||||
///
|
|
||||||
/// 比板子判定"客户端掉线"的超时(6 秒)短得多,所以偶发丢一两个包不会中断推流。
|
|
||||||
const SUBSCRIBE_INTERVAL: Duration = Duration::from_millis(800);
|
|
||||||
|
|
||||||
/// 一张帧没收齐就放弃的时限。
|
|
||||||
///
|
|
||||||
/// 一帧在 22fps 下约 45ms 到,所以几百毫秒还没齐的永远不会齐了。
|
|
||||||
const FRAME_TIMEOUT: Duration = Duration::from_millis(500);
|
|
||||||
|
|
||||||
/// 一块已知的图传板。
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct CameraInfo {
|
|
||||||
pub id: u32,
|
|
||||||
pub addr: SocketAddrV4,
|
|
||||||
pub firmware: u16,
|
|
||||||
/// 最近一次收到它广播的时刻。
|
|
||||||
pub last_seen: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CameraInfo {
|
|
||||||
/// 多久没有广播了。
|
|
||||||
pub fn silence(&self) -> Duration {
|
|
||||||
self.last_seen.elapsed()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 板子在线吗?
|
|
||||||
///
|
|
||||||
/// 阈值是广播间隔的数倍,所以偶发丢包不会让界面上的在线状态闪��。
|
|
||||||
pub fn is_online(&self) -> bool {
|
|
||||||
self.silence() < Duration::from_secs(7)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一块板的运行时统计。
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
pub struct SourceStats {
|
|
||||||
pub frames_shown: u64,
|
|
||||||
/// 因分片缺失被放弃的帧。
|
|
||||||
pub frames_dropped: u64,
|
|
||||||
pub datagrams: u64,
|
|
||||||
pub last_frame_len: usize,
|
|
||||||
pub width: u16,
|
|
||||||
pub height: u16,
|
|
||||||
/// 上一帧到这一帧之间的间隔。
|
|
||||||
pub last_interval: Option<Duration>,
|
|
||||||
last_shown_at: Option<Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SourceStats {
|
|
||||||
/// 丢弃的帧占已收到帧的比例。
|
|
||||||
pub fn loss_percent(&self) -> f32 {
|
|
||||||
let total = self.frames_shown + self.frames_dropped;
|
|
||||||
if total == 0 {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
self.frames_dropped as f32 * 100.0 / total as f32
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_frame(&mut self, width: u16, height: u16, len: usize) {
|
|
||||||
self.last_interval = self.last_shown_at.map(|t| t.elapsed());
|
|
||||||
self.last_shown_at = Some(Instant::now());
|
|
||||||
self.frames_shown += 1;
|
|
||||||
self.width = width;
|
|
||||||
self.height = height;
|
|
||||||
self.last_frame_len = len;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 每一路的最新帧,等着被解码。
|
|
||||||
///
|
|
||||||
/// 每路只保留最新的一张:在 22fps 下把每一帧都排队意味着内存里压着几十张 JPEG,
|
|
||||||
/// 然后解码那些几毫秒后就被下一帧顶掉的画面——而这正是这条链路要消除的延迟。
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct LatestFrames {
|
|
||||||
inner: Mutex<HashMap<u32, protocol::Frame>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LatestFrames {
|
|
||||||
pub fn put(&self, frame: protocol::Frame) {
|
|
||||||
self.inner
|
|
||||||
.lock()
|
|
||||||
.expect("latest-frames mutex poisoned")
|
|
||||||
.insert(frame.sender_id, frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 取走某一路的待处理帧。
|
|
||||||
pub fn take(&self, sender_id: u32) -> Option<protocol::Frame> {
|
|
||||||
self.inner
|
|
||||||
.lock()
|
|
||||||
.expect("latest-frames mutex poisoned")
|
|
||||||
.remove(&sender_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列出所有有待处理帧的板子 id。
|
|
||||||
pub fn pending_senders(&self) -> Vec<u32> {
|
|
||||||
self.inner
|
|
||||||
.lock()
|
|
||||||
.expect("latest-frames mutex poisoned")
|
|
||||||
.keys()
|
|
||||||
.copied()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 所有板子的共享状态,跨线程只读。
|
|
||||||
pub struct Discovery {
|
|
||||||
cameras: Mutex<HashMap<u32, CameraInfo>>,
|
|
||||||
stats: Mutex<HashMap<u32, SourceStats>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Discovery {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
cameras: Mutex::new(HashMap::new()),
|
|
||||||
stats: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 当前已知的板子,按 id 排序,让界面上���列表顺序稳定。
|
|
||||||
pub fn cameras(&self) -> Vec<CameraInfo> {
|
|
||||||
let mut list: Vec<CameraInfo> = self
|
|
||||||
.cameras
|
|
||||||
.lock()
|
|
||||||
.expect("cameras mutex poisoned")
|
|
||||||
.values()
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
list.sort_by_key(|c| c.id);
|
|
||||||
list
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在线且刚刚广播过的板子。
|
|
||||||
pub fn online_cameras(&self) -> Vec<CameraInfo> {
|
|
||||||
self.cameras().into_iter().filter(CameraInfo::is_online).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 某一路的统计。
|
|
||||||
pub fn stats(&self, sender_id: u32) -> SourceStats {
|
|
||||||
self.stats
|
|
||||||
.lock()
|
|
||||||
.expect("stats mutex poisoned")
|
|
||||||
.get(&sender_id)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn note_hello(&self, id: u32, addr: SocketAddrV4, firmware: u16) {
|
|
||||||
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
|
|
||||||
|
|
||||||
cameras
|
|
||||||
.entry(id)
|
|
||||||
.and_modify(|c| {
|
|
||||||
c.addr = addr;
|
|
||||||
c.last_seen = Instant::now();
|
|
||||||
c.firmware = firmware;
|
|
||||||
})
|
|
||||||
.or_insert(CameraInfo {
|
|
||||||
id,
|
|
||||||
addr,
|
|
||||||
firmware,
|
|
||||||
last_seen: Instant::now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn note_frame(&self, id: u32, width: u16, height: u16, len: usize) {
|
|
||||||
let mut stats = self.stats.lock().expect("stats mutex poisoned");
|
|
||||||
stats.entry(id).or_default().on_frame(width, height, len);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn count(&self, id: u32, f: impl FnOnce(&mut SourceStats)) {
|
|
||||||
let mut stats = self.stats.lock().expect("stats mutex poisoned");
|
|
||||||
f(stats.entry(id).or_default());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 超过 `max_idle` 没有广播的板子从列表里移除。
|
|
||||||
///
|
|
||||||
/// 界面上不会留下一堆永远离线的板子。
|
|
||||||
pub fn forget_silent_cameras(&self, max_idle: Duration) {
|
|
||||||
self.cameras
|
|
||||||
.lock()
|
|
||||||
.expect("cameras mutex poisoned")
|
|
||||||
.retain(|_, c| c.silence() < max_idle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 客户端身份。
|
|
||||||
///
|
|
||||||
/// 常量而非 MAC 派生:队伍客户端和裁判终端各自唯一,进程内也不需要区分自己,
|
|
||||||
/// 而一个确定的常量让日志更好读。
|
|
||||||
pub const CLIENT_ID: u32 = 0x0C11_0001;
|
|
||||||
|
|
||||||
/// 接收器。
|
|
||||||
///
|
|
||||||
/// socket 在这里创建而不是在接收线程里,这样"端口被占用"会立刻报错,而不是被线程
|
|
||||||
/// 吞掉、表现为一个永远不出画面的空窗口。
|
|
||||||
pub struct Receiver {
|
|
||||||
discovery: Arc<Discovery>,
|
|
||||||
frames: Arc<LatestFrames>,
|
|
||||||
role: SubscribeRole,
|
|
||||||
camera: Ipv4Addr,
|
|
||||||
stop: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Receiver {
|
|
||||||
/// 绑定视频端口并准备好发现。
|
|
||||||
///
|
|
||||||
/// 绑定固定端口(而非临时端口)是必须的:板子是从订阅的**源端口**学到推流目标的,
|
|
||||||
/// 临时端口也能工作,但固定端口让三台客户端行为一致,也方便抓包排查。
|
|
||||||
pub fn start(camera: Ipv4Addr, role: SubscribeRole) -> std::io::Result<Self> {
|
|
||||||
let socket =
|
|
||||||
UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?;
|
|
||||||
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
|
|
||||||
|
|
||||||
let broadcast = socket.set_broadcast(true).is_ok();
|
|
||||||
if !broadcast {
|
|
||||||
log::warn!("could not enable broadcast on the socket");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
discovery: Arc::new(Discovery::new()),
|
|
||||||
frames: Arc::new(LatestFrames::default()),
|
|
||||||
role,
|
|
||||||
camera,
|
|
||||||
stop: Arc::new(AtomicBool::new(false)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn discovery(&self) -> Arc<Discovery> {
|
|
||||||
Arc::clone(&self.discovery)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn frames(&self) -> Arc<LatestFrames> {
|
|
||||||
Arc::clone(&self.frames)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 运行接收循环,直到 [`Receiver::shutdown`]。
|
|
||||||
///
|
|
||||||
/// 阻塞,所以放在单独的线程上。
|
|
||||||
pub fn run(&self, socket: UdpSocket) {
|
|
||||||
let target = SocketAddrV4::new(self.camera, DEFAULT_PORT);
|
|
||||||
let subscribe = Subscribe {
|
|
||||||
role: self.role,
|
|
||||||
client_id: CLIENT_ID,
|
|
||||||
};
|
|
||||||
let subscribe_bytes = subscribe.encode();
|
|
||||||
|
|
||||||
// 每个发送方一个重组器:两块板的 frame_id 各自独立,混在一起会互相顶掉。
|
|
||||||
let mut assemblers: HashMap<u32, FrameAssembler> = HashMap::new();
|
|
||||||
let mut buf = [0u8; RECV_BUFFER];
|
|
||||||
let mut last_subscribe = Instant::now() - SUBSCRIBE_INTERVAL;
|
|
||||||
let mut last_sweep = Instant::now();
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"listening on UDP {DEFAULT_PORT}, announcing {:?} to {target}",
|
|
||||||
self.role
|
|
||||||
);
|
|
||||||
|
|
||||||
while !self.stop.load(Ordering::Relaxed) {
|
|
||||||
// 周期性续订就是保活:板子在 6 秒收不到就认为我们走了。
|
|
||||||
if last_subscribe.elapsed() >= SUBSCRIBE_INTERVAL {
|
|
||||||
if let Err(err) = socket.send_to(&subscribe_bytes, target) {
|
|
||||||
log::warn!("could not subscribe at {target}: {err}");
|
|
||||||
}
|
|
||||||
last_subscribe = Instant::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
match socket.recv_from(&mut buf) {
|
|
||||||
Ok((n, from)) => {
|
|
||||||
self.handle_datagram(&buf[..n], from, &mut assemblers);
|
|
||||||
}
|
|
||||||
Err(err)
|
|
||||||
if err.kind() == ErrorKind::WouldBlock
|
|
||||||
|| err.kind() == ErrorKind::TimedOut =>
|
|
||||||
{
|
|
||||||
// 帧之间的常态。读超时正是让退出能及时响应的原因。
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
log::warn!("recv failed: {err}");
|
|
||||||
thread::sleep(Duration::from_millis(100));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 放弃等太久的半截帧。否则板子在帧中途死掉时,重组器会一直留着它。
|
|
||||||
for (id, asm) in assemblers.iter_mut() {
|
|
||||||
if asm.expire().is_some() {
|
|
||||||
self.discovery.count(*id, |s| s.frames_dropped += 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if last_sweep.elapsed() >= Duration::from_secs(5) {
|
|
||||||
self.discovery.forget_silent_cameras(Duration::from_secs(60));
|
|
||||||
last_sweep = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("receive loop stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 处理一个数据报。
|
|
||||||
fn handle_datagram(
|
|
||||||
&self,
|
|
||||||
bytes: &[u8],
|
|
||||||
from: std::net::SocketAddr,
|
|
||||||
assemblers: &mut HashMap<u32, FrameAssembler>,
|
|
||||||
) {
|
|
||||||
if bytes.len() < HEADER_LEN {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 板子的在线通告。注意这里不看来源端口:广播来的源端口没有意义,身份由
|
|
||||||
// 头里的 sender_id 决定。
|
|
||||||
if let Some(hello) = protocol::Hello::decode(bytes) {
|
|
||||||
if let SocketAddr::V4(v4) = from {
|
|
||||||
let is_new = !self.discovery.cameras().iter().any(|c| c.id == hello.sender_id);
|
|
||||||
self.discovery.note_hello(hello.sender_id, v4, hello.firmware);
|
|
||||||
|
|
||||||
if is_new {
|
|
||||||
log::info!(
|
|
||||||
"found camera 0x{:08X} at {v4}",
|
|
||||||
hello.sender_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(frag) = Fragment::decode(bytes) else {
|
|
||||||
// 既不是 Hello 也不是合法分片:将来的其它消息,或一次损坏。
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let id = frag.header.sender_id;
|
|
||||||
|
|
||||||
match assemblers
|
|
||||||
.entry(id)
|
|
||||||
.or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT))
|
|
||||||
.push(frag)
|
|
||||||
{
|
|
||||||
Ok(frame) => {
|
|
||||||
self.discovery.count(id, |s| s.datagrams += 1);
|
|
||||||
self.discovery
|
|
||||||
.note_frame(id, frame.width, frame.height, frame.data.len());
|
|
||||||
self.frames.put(frame);
|
|
||||||
}
|
|
||||||
Err(protocol::DropReason::Superseded) => {
|
|
||||||
// 被更新的帧追上了。这不是丢帧:半截的那张只是被超越,在健康链路上
|
|
||||||
// 这是帧被放弃的**正常**方式。
|
|
||||||
self.discovery.count(id, |s| s.datagrams += 1);
|
|
||||||
}
|
|
||||||
Err(protocol::DropReason::TimedOut) => {
|
|
||||||
self.discovery.count(id, |s| {
|
|
||||||
s.datagrams += 1;
|
|
||||||
s.frames_dropped += 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn shutdown(&self) {
|
|
||||||
self.stop.store(true, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解码后的画面。
|
|
||||||
pub struct DecodedFrame {
|
|
||||||
pub rgba: Vec<u8>,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解码一张 JPEG。
|
|
||||||
///
|
|
||||||
/// 返回 `None` 表示字节不是可解码的图像——协议层挡不住这种:它只检查分片结构,
|
|
||||||
/// 不检查 JPEG 本身是否有效。
|
|
||||||
pub fn decode_frame(jpeg: &[u8]) -> Option<DecodedFrame> {
|
|
||||||
let img = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg).ok()?;
|
|
||||||
let rgba = img.to_rgba8();
|
|
||||||
|
|
||||||
Some(DecodedFrame {
|
|
||||||
width: rgba.width(),
|
|
||||||
height: rgba.height(),
|
|
||||||
rgba: rgba.into_raw(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 装甲板血量的来源。
|
|
||||||
///
|
|
||||||
/// **预留,当前没有实现也没有数据源。** 血量显示为占位符。
|
|
||||||
///
|
|
||||||
/// 接口和端口都已就位([`protocol::ARMOR_PORT`]、[`protocol::ArmorHealth`]):装甲板
|
|
||||||
/// 接上以后往那个端口发消息即可,裁判界面与其它代码都不用改。
|
|
||||||
pub trait ArmorSource: Send + Sync {
|
|
||||||
/// 某一边的血量。
|
|
||||||
///
|
|
||||||
/// 返回 4 个装甲板的当前血量,按 index 排列。没有数据时返回 `None`,界面据此
|
|
||||||
/// 显示占位符而不是 0——0 是一个看起来像真值的假值。
|
|
||||||
fn health(&self, team: u8) -> Option<[u16; 4]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一边的装甲板数量。
|
|
||||||
///
|
|
||||||
/// RoboMaster 规则是每队 4 块。界面按这个数量画格子,所以数据还没接上时也能看到
|
|
||||||
/// 位置——空着比画 0 个格子更让人以为布局坏了。
|
|
||||||
pub const ARMOR_BOARDS: usize = 4;
|
|
||||||
|
|
||||||
/// 目前唯一的实现:什么都不提供。
|
|
||||||
///
|
|
||||||
/// 存在的意义是让界面代码从一开始就对着一个真实接口开发,而不是等装甲板就绪后再改
|
|
||||||
/// 一次结构。
|
|
||||||
pub struct NoArmorYet;
|
|
||||||
|
|
||||||
impl ArmorSource for NoArmorYet {
|
|
||||||
fn health(&self, _team: u8) -> Option<[u16; 4]> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。
|
|
||||||
|
|
||||||
/* 一块画面的读数。裁判端有两块,队伍端一块。 */
|
|
||||||
export struct CameraStats {
|
|
||||||
// 在线状态
|
|
||||||
online: bool,
|
|
||||||
status-text: string,
|
|
||||||
|
|
||||||
// 实时测量
|
|
||||||
fps: string,
|
|
||||||
resolution: string,
|
|
||||||
frame-bytes: string,
|
|
||||||
|
|
||||||
// 累计
|
|
||||||
frames-shown: string,
|
|
||||||
frames-dropped: string,
|
|
||||||
loss-percent: string,
|
|
||||||
datagrams: string,
|
|
||||||
|
|
||||||
// 帧间隔,用于把"卡"和"帧率低"区分开
|
|
||||||
gap-ms: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 一边的血量(4 块装甲板)。
|
|
||||||
*
|
|
||||||
* `available` 为 false 时界面显示占位符而不是 0:0 是一个看起来像真值的假值,
|
|
||||||
* 现场看到 0 会以为对方已经没血了。 */
|
|
||||||
export struct TeamHealth {
|
|
||||||
team-name: string,
|
|
||||||
available: bool,
|
|
||||||
// 占位符文案
|
|
||||||
placeholder: string,
|
|
||||||
// 每块装甲板的血量;available 时有效
|
|
||||||
hps: [int],
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 首次运行时的摄像头选择。
|
|
||||||
*
|
|
||||||
* 队伍端需要绑定一块板子(选定后不可更改),裁判端不需要。 */
|
|
||||||
export struct CameraChoice {
|
|
||||||
id-text: string,
|
|
||||||
addr-text: string,
|
|
||||||
online: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 队伍端
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 队伍终端:一块画面 + 自己那一边的血量。
|
|
||||||
*
|
|
||||||
* 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省
|
|
||||||
* 画面空间给视频,一边要均分),共用一套再用 visible 切换会让每处改动都要考虑两种
|
|
||||||
* 情况。 */
|
|
||||||
export component TeamApp inherits Window {
|
|
||||||
title: "cam-client — 队伍";
|
|
||||||
preferred-width: 1000px;
|
|
||||||
preferred-height: 720px;
|
|
||||||
|
|
||||||
in property <image> frame;
|
|
||||||
in property <CameraStats> stats;
|
|
||||||
in property <TeamHealth> health;
|
|
||||||
|
|
||||||
// 选择绑定时显示,其余时候隐藏
|
|
||||||
in property <[CameraChoice]> choices;
|
|
||||||
in property <bool> choosing;
|
|
||||||
|
|
||||||
// 选中了列表里的第 N 项。
|
|
||||||
//
|
|
||||||
// 绑定在这里一次完成:Rust 侧收到回调就写配置文件,所以"选定"当场生效,而不是
|
|
||||||
// "下次启动才生效"。索引而不是 id,是为了让 Rust 侧回到自己那份列表里取完整
|
|
||||||
// 信息——.slint 里的模型只是给界面看的。
|
|
||||||
callback pick(int);
|
|
||||||
|
|
||||||
background: #101014;
|
|
||||||
|
|
||||||
// 绑定完成前只显示选择界面:没有画面可显示,显示一个空窗口更让人困惑。
|
|
||||||
if !root.choosing: VerticalLayout {
|
|
||||||
spacing: 0;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 0;
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
background: #0a0a0d;
|
|
||||||
vertical-stretch: 1;
|
|
||||||
|
|
||||||
frame := Image {
|
|
||||||
source: root.frame;
|
|
||||||
image-fit: contain;
|
|
||||||
width: parent.width;
|
|
||||||
height: parent.height;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 底部一条:左边自己这边的血量,右边这一路的统计。
|
|
||||||
Rectangle {
|
|
||||||
background: #1c1c22;
|
|
||||||
height: 72px;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 14px;
|
|
||||||
spacing: 24px;
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 2px;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.health.team-name;
|
|
||||||
color: #e8e8ec;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 装甲板还没接上,所以是一排占位而不是四个数字。
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 6px;
|
|
||||||
|
|
||||||
for hp in root.health.hps: Rectangle {
|
|
||||||
width: 34px;
|
|
||||||
height: 12px;
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: root.health.available ? #4a4a55 : #33333c;
|
|
||||||
background: root.health.available ? #7ddc7d : #1a1a20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 2px;
|
|
||||||
preferred-width: 320px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.stats.status-text + " " + root.stats.fps + " fps "
|
|
||||||
+ root.stats.resolution + " " + root.stats.frame-bytes;
|
|
||||||
color: root.stats.online ? #e8e8ec : #e0a060;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: "shown " + root.stats.frames-shown
|
|
||||||
+ " dropped " + root.stats.frames-dropped
|
|
||||||
+ " (" + root.stats.loss-percent + ")"
|
|
||||||
+ " gap " + root.stats.gap-ms;
|
|
||||||
color: root.stats.frames-dropped == "0" ? #8a8a95 : #e0a060;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。
|
|
||||||
Rectangle {
|
|
||||||
background: #14141a;
|
|
||||||
height: 26px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.health.placeholder;
|
|
||||||
color: #6a6a75;
|
|
||||||
font-size: 12px;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择界面
|
|
||||||
if root.choosing: VerticalLayout {
|
|
||||||
padding: 32px;
|
|
||||||
spacing: 16px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "选择要观看的摄像头";
|
|
||||||
color: #e8e8ec;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "选定后不能更改。这里的选择会写入配置文件,重启后仍然生效。";
|
|
||||||
color: #8a8a95;
|
|
||||||
font-size: 13px;
|
|
||||||
wrap: word-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle { height: 8px; background: transparent; }
|
|
||||||
|
|
||||||
// TouchArea 而不是给 Rectangle 挂 callback:Rectangle 是基础元素,没有
|
|
||||||
// 点击回调;TouchArea 才是带交互的元素。
|
|
||||||
for choice[index] in root.choices: Rectangle {
|
|
||||||
background: choice.online ? #1c1c22 : #16161b;
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: #2a2a33;
|
|
||||||
height: 56px;
|
|
||||||
|
|
||||||
TouchArea {
|
|
||||||
clicked => {
|
|
||||||
root.pick(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 14px;
|
|
||||||
spacing: 16px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: choice.id-text;
|
|
||||||
color: #e8e8ec;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
vertical-alignment: center;
|
|
||||||
horizontal-alignment: left;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: choice.addr-text;
|
|
||||||
color: #8a8a95;
|
|
||||||
font-size: 12px;
|
|
||||||
vertical-alignment: center;
|
|
||||||
horizontal-alignment: left;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: choice.online ? "在线" : "离线";
|
|
||||||
color: choice.online ? #7ddc7d : #8a8a95;
|
|
||||||
font-size: 12px;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 一块都没发现时给出可操作的提示,而不是一个空列表。
|
|
||||||
if root.choices.length == 0: Rectangle {
|
|
||||||
background: #1a1418;
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: #4a2a35;
|
|
||||||
height: 64px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "还没有发现任何摄像头。请确认板子已上电、已连上这个网络,\n然后稍候——板子每 2 秒广播一次在线状态。";
|
|
||||||
color: #e0a060;
|
|
||||||
font-size: 13px;
|
|
||||||
wrap: word-wrap;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 裁判端
|
|
||||||
|
|
||||||
/* 裁判终端:两块画面左右并排 + 双方血量。
|
|
||||||
*
|
|
||||||
* 与队伍端分开:这里的核心诉求是"一眼比出两边的状态",所以两块画面等宽、血量
|
|
||||||
* 并排对称。 */
|
|
||||||
export component RefereeApp inherits Window {
|
|
||||||
title: "cam-client — 裁判系统";
|
|
||||||
preferred-width: 1400px;
|
|
||||||
preferred-height: 800px;
|
|
||||||
|
|
||||||
in property <image> frame-a;
|
|
||||||
in property <image> frame-b;
|
|
||||||
in property <CameraStats> stats-a;
|
|
||||||
in property <CameraStats> stats-b;
|
|
||||||
in property <TeamHealth> health-a;
|
|
||||||
in property <TeamHealth> health-b;
|
|
||||||
|
|
||||||
background: #101014;
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
spacing: 0;
|
|
||||||
|
|
||||||
// 两路画面等宽并排,各占一半。
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 2px;
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
background: #0a0a0d;
|
|
||||||
vertical-stretch: 1;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
|
|
||||||
frame-a := Image {
|
|
||||||
source: root.frame-a;
|
|
||||||
image-fit: contain;
|
|
||||||
width: parent.width;
|
|
||||||
height: parent.height;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
background: #0a0a0d;
|
|
||||||
vertical-stretch: 1;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
|
|
||||||
frame-b := Image {
|
|
||||||
source: root.frame-b;
|
|
||||||
image-fit: contain;
|
|
||||||
width: parent.width;
|
|
||||||
height: parent.height;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每路画面下方的队伍条:名字、血量、统计。
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 2px;
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
background: #1c1c22;
|
|
||||||
height: 78px;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding: 10px;
|
|
||||||
spacing: 4px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.health-a.team-name + " " + root.stats-a.status-text
|
|
||||||
+ " " + root.stats-a.fps + " fps " + root.stats-a.resolution;
|
|
||||||
color: root.stats-a.online ? #e8e8ec : #e0a060;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 6px;
|
|
||||||
|
|
||||||
for hp in root.health-a.hps: Rectangle {
|
|
||||||
width: 40px;
|
|
||||||
height: 14px;
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: root.health-a.available ? #4a4a55 : #33333c;
|
|
||||||
background: root.health-a.available ? #7ddc7d : #1a1a20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "shown " + root.stats-a.frames-shown
|
|
||||||
+ " dropped " + root.stats-a.frames-dropped
|
|
||||||
+ " (" + root.stats-a.loss-percent + ")"
|
|
||||||
+ " gap " + root.stats-a.gap-ms;
|
|
||||||
color: root.stats-a.frames-dropped == "0" ? #8a8a95 : #e0a060;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
background: #1c1c22;
|
|
||||||
height: 78px;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding: 10px;
|
|
||||||
spacing: 4px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.health-b.team-name + " " + root.stats-b.status-text
|
|
||||||
+ " " + root.stats-b.fps + " fps " + root.stats-b.resolution;
|
|
||||||
color: root.stats-b.online ? #e8e8ec : #e0a060;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
padding: 0;
|
|
||||||
spacing: 6px;
|
|
||||||
|
|
||||||
for hp in root.health-b.hps: Rectangle {
|
|
||||||
width: 40px;
|
|
||||||
height: 14px;
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: root.health-b.available ? #4a4a55 : #33333c;
|
|
||||||
background: root.health-b.available ? #7ddc7d : #1a1a20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "shown " + root.stats-b.frames-shown
|
|
||||||
+ " dropped " + root.stats-b.frames-dropped
|
|
||||||
+ " (" + root.stats-b.loss-percent + ")"
|
|
||||||
+ " gap " + root.stats-b.gap-ms;
|
|
||||||
color: root.stats-b.frames-dropped == "0" ? #8a8a95 : #e0a060;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。
|
|
||||||
Rectangle {
|
|
||||||
background: #14141a;
|
|
||||||
height: 26px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.health-a.placeholder;
|
|
||||||
color: #6a6a75;
|
|
||||||
font-size: 12px;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,4 +2,4 @@ idf_component_register(SRCS "cam_wrapper.c"
|
|||||||
INCLUDE_DIRS "include"
|
INCLUDE_DIRS "include"
|
||||||
# esp_timer for the frame-rate probe; the camera component
|
# esp_timer for the frame-rate probe; the camera component
|
||||||
# pulls in the LCD_CAM driver and the FreeRTOS task API.
|
# 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. */
|
/* Implementation of the cam_wrapper shim around esp32-camera. See cam_wrapper.h. */
|
||||||
|
|
||||||
#include "cam_wrapper.h"
|
#include "cam_wrapper.h"
|
||||||
|
#include "driver/temperature_sensor.h"
|
||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "esp_timer.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 const char *TAG = "cam_wrapper";
|
||||||
|
|
||||||
static sensor_t *s_sensor;
|
static sensor_t *s_sensor;
|
||||||
static char s_sensor_name[16] = "unknown";
|
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)
|
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. */
|
* two is dropping frames. */
|
||||||
#define PROBE_FRAMES 20
|
#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;
|
uint32_t total = 0;
|
||||||
int64_t start = esp_timer_get_time();
|
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) {
|
if (taken == 0 || elapsed_us <= 0) {
|
||||||
ESP_LOGW(TAG, "fps probe: got no frames");
|
ESP_LOGW(TAG, "fps probe: got no frames");
|
||||||
return;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
double fps = (double)taken * 1000000.0 / (double)elapsed_us;
|
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)",
|
"(sensor ceiling, no network involved)",
|
||||||
taken, (long long)(elapsed_us / 1000), fps,
|
taken, (long long)(elapsed_us / 1000), fps,
|
||||||
(unsigned)(total / (uint32_t)taken));
|
(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)
|
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->height = fb->height;
|
||||||
out->format = (int)fb->format;
|
out->format = (int)fb->format;
|
||||||
out->handle = fb;
|
out->handle = fb;
|
||||||
|
out->capture_started_us = (int64_t)fb->timestamp.tv_sec * 1000000 + fb->timestamp.tv_usec;
|
||||||
|
|
||||||
return ESP_OK;
|
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)
|
void cam_wrapper_fb_release(cam_wrapper_fb_t *fb)
|
||||||
{
|
{
|
||||||
if (fb == NULL || fb->handle == NULL) {
|
if (fb == NULL || fb->handle == NULL) {
|
||||||
@@ -233,4 +407,4 @@ esp_err_t cam_wrapper_deinit(void)
|
|||||||
const char *cam_wrapper_sensor_name(void)
|
const char *cam_wrapper_sensor_name(void)
|
||||||
{
|
{
|
||||||
return s_sensor_name;
|
return s_sensor_name;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ typedef struct {
|
|||||||
size_t height;
|
size_t height;
|
||||||
int format;
|
int format;
|
||||||
void *handle; /* internal driver frame; do not touch */
|
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;
|
} cam_wrapper_fb_t;
|
||||||
|
|
||||||
esp_err_t cam_wrapper_init(const cam_wrapper_config_t *config, cam_wrapper_sensor_t sensor);
|
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
|
/* 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). */
|
* 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(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);
|
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. */
|
/* Name of the sensor the driver detected, or "unknown". Never NULL. */
|
||||||
const char *cam_wrapper_sensor_name(void);
|
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
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -733,13 +733,14 @@ camera_fb_t *cam_take(TickType_t timeout)
|
|||||||
for (;;)
|
for (;;)
|
||||||
{
|
{
|
||||||
TickType_t elapsed = xTaskGetTickCount() - start; /* TickType_t is unsigned so rollover is safe */
|
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");
|
ESP_LOGW(TAG, "Failed to get frame: timeout");
|
||||||
return NULL;
|
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 (xQueueReceive(cam_obj->frame_buffer_queue, (void *)&dma_buffer, remaining) == pdFALSE) {
|
||||||
|
if (!timeout) return NULL;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,6 +751,7 @@ camera_fb_t *cam_take(TickType_t timeout)
|
|||||||
if (dma_reset_counter < MAX_GDMA_RESETS) {
|
if (dma_reset_counter < MAX_GDMA_RESETS) {
|
||||||
ll_cam_dma_reset(cam_obj);
|
ll_cam_dma_reset(cam_obj);
|
||||||
dma_reset_counter++;
|
dma_reset_counter++;
|
||||||
|
if (!timeout) return NULL;
|
||||||
continue; /* retry with queue timeout */
|
continue; /* retry with queue timeout */
|
||||||
}
|
}
|
||||||
if (dma_reset_counter == MAX_GDMA_RESETS) {
|
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,
|
CAM_WARN_THROTTLE(warn_null_cnt,
|
||||||
"Unexpected NULL frame on " CONFIG_IDF_TARGET);
|
"Unexpected NULL frame on " CONFIG_IDF_TARGET);
|
||||||
#endif
|
#endif
|
||||||
|
if (!timeout) return NULL;
|
||||||
vTaskDelay(1); /* immediate yield once resets are done */
|
vTaskDelay(1); /* immediate yield once resets are done */
|
||||||
continue; /* go to top of loop */
|
continue; /* go to top of loop */
|
||||||
}
|
}
|
||||||
@@ -802,6 +805,7 @@ skip_eoi_check:
|
|||||||
CAM_WARN_THROTTLE(warn_eoi_miss_cnt,
|
CAM_WARN_THROTTLE(warn_eoi_miss_cnt,
|
||||||
"NO-EOI - JPEG end marker missing");
|
"NO-EOI - JPEG end marker missing");
|
||||||
cam_give(dma_buffer);
|
cam_give(dma_buffer);
|
||||||
|
if (!timeout) return NULL;
|
||||||
continue; /* wait for another frame */
|
continue; /* wait for another frame */
|
||||||
} else if (cam_obj->psram_mode &&
|
} else if (cam_obj->psram_mode &&
|
||||||
cam_obj->in_bytes_per_pixel != cam_obj->fb_bytes_per_pixel) {
|
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)
|
void cam_give(camera_fb_t *dma_buffer)
|
||||||
{
|
{
|
||||||
for (int x = 0; x < cam_obj->frame_cnt; x++) {
|
for (int x = 0; x < cam_obj->frame_cnt; x++) {
|
||||||
|
|||||||
@@ -401,6 +401,19 @@ camera_fb_t *esp_camera_fb_get()
|
|||||||
return fb;
|
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)
|
void esp_camera_fb_return(camera_fb_t *fb)
|
||||||
{
|
{
|
||||||
if (s_state == NULL) {
|
if (s_state == NULL) {
|
||||||
|
|||||||
@@ -206,6 +206,9 @@ esp_err_t esp_camera_deinit(void);
|
|||||||
* @return pointer to the frame buffer
|
* @return pointer to the frame buffer
|
||||||
*/
|
*/
|
||||||
camera_fb_t* esp_camera_fb_get(void);
|
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.
|
* @brief Return the frame buffer to be reused again.
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ void cam_stop(void);
|
|||||||
void cam_start(void);
|
void cam_start(void);
|
||||||
|
|
||||||
camera_fb_t *cam_take(TickType_t timeout);
|
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);
|
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,17 @@
|
|||||||
|
# 前端依赖与产物。
|
||||||
|
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/
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
# desktop
|
||||||
|
|
||||||
|
桌面端。**两种身份,两套页面,一个进程。**
|
||||||
|
|
||||||
|
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 用户端(`team`) | 一路 | **双方**(左上角自己 / 右上角对手) | 自己那块 | **要,改动需密码** |
|
||||||
|
| 裁判端(`referee`) | 两路并排 | 双方(各自贴画面角上,**队名可改**) | 发现列表 + 槽位 | 不需要 |
|
||||||
|
|
||||||
|
界面是 WebView 里的 React(画面画在 canvas 上),后端是 Rust。**它不是把旧客户端套进
|
||||||
|
一个窗口**:网络、重组、发现、配置、密码、录制全部重写成了可单测的模块。
|
||||||
|
|
||||||
|
```text
|
||||||
|
desktop/
|
||||||
|
core/ cam-core:不含任何界面与 Tauri 的东西(83 个测试)
|
||||||
|
src-tauri/ Tauri 后端:推流、命令、录制(32 个测试)
|
||||||
|
src/ React 前端:两套页面 + HUD
|
||||||
|
src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化
|
||||||
|
src/components/ 视频窗格、血条、胜负横幅、瞄准器、装甲板表、队名编辑、录制面板、设置抽屉
|
||||||
|
src/pages/ 用户端 / 裁判端
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd desktop
|
||||||
|
pnpm install
|
||||||
|
pnpm app # = tauri dev:起 vite + 编 Rust + 开窗口
|
||||||
|
pnpm build # 只构建前端(tsc --noEmit && vite build)
|
||||||
|
pnpm tauri build # 默认打包 macOS 的 .app 与 .dmg;Windows 见下文
|
||||||
|
```
|
||||||
|
|
||||||
|
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd desktop/core && cargo test # 83
|
||||||
|
cd desktop/src-tauri && cargo test # 32,其中几条真的会起 ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows x64 构建
|
||||||
|
|
||||||
|
需要 Node.js 22.12+(或 24)、pnpm 12、Rust stable MSVC,以及 Visual Studio Build Tools 的 C++ 桌面开发组件和 Windows SDK。应用窗口使用 WebView2 Runtime。
|
||||||
|
|
||||||
|
在 `desktop/` 中执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --ci -- --locked --config 'unstable.build-std=[]'
|
||||||
|
```
|
||||||
|
|
||||||
|
`--target` 覆盖本目录原来的 macOS target,`--bundles nsis` 选择 Windows 安装包;末尾的 Cargo 参数清空从根固件项目继承的 `build-std`。
|
||||||
|
|
||||||
|
- 程序:`src-tauri/target/x86_64-pc-windows-msvc/release/cam-desktop.exe`。
|
||||||
|
- 安装包:同一 release 目录下的 `bundle/nsis/*-setup.exe`。
|
||||||
|
- 核心测试:进入 `core/` 后运行 `cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'`。
|
||||||
|
|
||||||
|
视频录制另需可从 `PATH` 找到的 `ffmpeg`,也可以在配置的 `[recording] ffmpeg` 中指定路径;基础图传显示不依赖它。
|
||||||
|
|
||||||
|
设备发现使用板卡的 Hello 广播;客户端除广播订阅外,还会按已发现设备的 IP 定向续订。Windows 开启 VPN / 虚拟网卡时,全局广播可能走虚拟接口;定向续订可避免“能发现并绑定,但始终等待画面”的情况。队伍端的绑定和画面过滤仍然生效。
|
||||||
|
|
||||||
|
### 演示模式:没有板子也能把整条链路跑起来
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd desktop
|
||||||
|
CAM_DEMO=1 pnpm app
|
||||||
|
```
|
||||||
|
|
||||||
|
它**不伪造数据给界面**,而是合成**协议数据报**,从 `Receiver::datagram` 与
|
||||||
|
`ArmorHealthStore::datagram` 进去。也就是说演示模式跑的是同一条真实路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
|
||||||
|
合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈
|
||||||
|
```
|
||||||
|
|
||||||
|
分片、重组、发现、统计、掉血派生、两道闸门全在跑真代码,截图与录制也不需要特殊分支。
|
||||||
|
界面上会有一条 **演示模式** 标记,板子 id 也刻意取成 `0x0DE00001` 这种一眼能看出是人造
|
||||||
|
的形状——**别把它当成现场数据**。
|
||||||
|
|
||||||
|
### 命令行与环境变量
|
||||||
|
|
||||||
|
```text
|
||||||
|
--role <team|referee> 身份。默认看配置文件
|
||||||
|
--config <路径> 配置文件位置
|
||||||
|
--demo 演示模式
|
||||||
|
--list 打印发现的摄像头与装甲板后退出
|
||||||
|
-h, --help 帮助
|
||||||
|
|
||||||
|
CAM_DEMO=1 等价于 --demo
|
||||||
|
CAM_CONFIG=<路径> 等价于 --config <路径>
|
||||||
|
```
|
||||||
|
|
||||||
|
两个环境变量存在的理由很实际:`tauri dev` 要用两个 `--` 才能把参数递给应用
|
||||||
|
(`tauri dev -- -- --demo`),写在一行环境变量里不会有人记错。
|
||||||
|
|
||||||
|
**未知参数只记一条 debug 日志,不报错。** macOS 从访达双击 .app 时系统会塞进来一个
|
||||||
|
`-psn_0_123456`;把它当错误处理的表现是"双击图标什么都没发生"。
|
||||||
|
|
||||||
|
## 为什么换成 Tauri,代价是什么
|
||||||
|
|
||||||
|
旧客户端是 Rust + Slint 单二进制,能用。换掉它的理由是**界面这一侧的生产力**:血条、
|
||||||
|
瞄准器、可拖拽的偏移校准、下拉绑定、设置抽屉、录制列表与回放——这些用 CSS/HTML 写是
|
||||||
|
几十分钟的事,用即时模式 UI 工具写要自己排版、自己画控件、自己处理焦点。这个项目的重心
|
||||||
|
在链路(UDP、重组、发现、防作弊),界面应当越快改完越好。
|
||||||
|
|
||||||
|
代价说清楚:
|
||||||
|
|
||||||
|
- **多一层**。WebView + Node 工具链 + Tauri 的权限系统,比一个 Slint 二进制重得多。
|
||||||
|
- **要联网装依赖**(前端 npm、Rust crates)。
|
||||||
|
- **画面要过一条本地通道**才能到界面(见下一节)。Slint 那版是把 RGBA 直接交给渲染器,
|
||||||
|
路径更短。
|
||||||
|
|
||||||
|
换来的另一样东西是**可测性**:`core/` 不认识界面,所以重组、发现、帧率窗口、槽位分配、
|
||||||
|
配置与密码全都能用单元测试盯住——而它们恰好是最容易错、错了又最难在现场看出来的部分。
|
||||||
|
|
||||||
|
## 视频怎么进 WebView
|
||||||
|
|
||||||
|
**一个只监听回环地址的 WebSocket,推二进制 JPEG。** 界面从 `stream_info` 命令拿到
|
||||||
|
`ws://127.0.0.1:<随机端口>/?token=...`。
|
||||||
|
|
||||||
|
### 为什么不是 Tauri 的 IPC
|
||||||
|
|
||||||
|
两路 22fps、每帧约 22 kB,每秒约 1 MB 的裸 JPEG。走 IPC 要序列化成 JSON(base64 再涨
|
||||||
|
1/3),前端再解回来——CPU 花在没人需要的编解码上,延迟白涨一截。二进制 WebSocket 帧把
|
||||||
|
原始字节直接交给前端,浏览器用**原生解码器**解,**后端连 JPEG 都不用解**。
|
||||||
|
|
||||||
|
### 二进制帧的布局
|
||||||
|
|
||||||
|
```text
|
||||||
|
0 4 8 10 12 16
|
||||||
|
+--------------+--------------+------+------+--------------+---------+
|
||||||
|
| sender_id u32| frame_id u32 | w u16| h u16| jpeg_len u32 | JPEG... |
|
||||||
|
+--------------+--------------+------+------+--------------+---------+
|
||||||
|
```
|
||||||
|
|
||||||
|
小端,16 字节头。前后端对这段布局的约定由一个测试盯着(`server.rs` 与 `stream.ts`
|
||||||
|
两侧都有),长度不一致的帧宁可丢掉,也不要让前端拿到一段不完整的 JPEG。
|
||||||
|
|
||||||
|
### 前端侧的两个陷阱
|
||||||
|
|
||||||
|
**解码是异步的,所以帧号要看着用。** `createImageBitmap` 返回 Promise,两个相邻帧的解码
|
||||||
|
可能乱序完成。所以每路记住最后画过的帧号,只有更新的那帧才采用——没有这一步,画面会偶尔
|
||||||
|
往回跳一帧,看起来像"卡了一下",极难归因。
|
||||||
|
|
||||||
|
**`ImageBitmap` 必须显式 `close()`。** 它持有解码后的像素,不吃 GC 的账。每收到新帧就
|
||||||
|
替换旧的那张并不够,必须把旧的那张关掉,否则两路 22fps 跑几分钟就是几百 MB。
|
||||||
|
|
||||||
|
## 防作弊:两道闸门,不是一个
|
||||||
|
|
||||||
|
对抗赛里两队都用这套系统看自己小车的图传,要防止对手开第二个窗口连到另一队的板子。
|
||||||
|
|
||||||
|
**关键是这件事做在接收端,不是界面里。** 广播的 Hello 里就有 MAC 派生的 `sender_id` 与
|
||||||
|
IP,藏起来毫无意义;数据也可能已经收下并解码了,`.slint`/CSS 只控制显示。
|
||||||
|
|
||||||
|
| 闸门 | 管什么 | 什么时候开 |
|
||||||
|
|---|---|---|
|
||||||
|
| `only_sender` | 帧的重组与投递 | 用户端**始终**是绑定的那一块 |
|
||||||
|
| `discovery_all` | 是否记录**其它**板子的 Hello | 只在密码解锁后的设置界面里打开 |
|
||||||
|
|
||||||
|
旧客户端把两件事绑在同一道闸门上,于是只能在"能列出候选"和"挡住对手"之间二选一,改绑
|
||||||
|
只能靠手改配置文件。现在分开了:设置界面能列出候选摄像头(否则无从选起),但**画面不进
|
||||||
|
来**——对手的板子即使把流推到本机端口、即使被记进发现列表,它的帧也进不了重组器。
|
||||||
|
|
||||||
|
四种情况不限制:裁判端(中立方)、debug 构建、用户端还没绑定、`--camera-id` 显式覆盖。
|
||||||
|
|
||||||
|
### 遥测按编译模式门控
|
||||||
|
|
||||||
|
`TEAM_DIAGNOSTICS = cfg!(debug_assertions)`。用户端 release 下**不产出**帧率/丢帧这些
|
||||||
|
数字——不是"界面藏起来",是 DTO 里就不存在(`CameraStatsDto::build` 直接把它们置零)。
|
||||||
|
由编译模式决定而不是命令行开关:开关意味着二进制里带着那套东西,忘开一次就泄露了。
|
||||||
|
|
||||||
|
### 这挡不住什么
|
||||||
|
|
||||||
|
诚实地说:**IP 仍然通过 Hello 广播泄露。** 对手知道你的 IP 之后可以自己发一个
|
||||||
|
`Subscribe` 过来——固件会向任何订阅者推流,那道闸门在他的客户端上,不在你的。真要挡住
|
||||||
|
需要固件侧做配对 token(首次绑定发一个令牌,固件只接受带令牌的订阅),代价是固件重启就
|
||||||
|
失配。
|
||||||
|
|
||||||
|
回环通道上那个 token 也不是防对手的:它只让"本机另一个程序顺手连上来看画面"变成一件需要
|
||||||
|
先读到那个 token 的事。
|
||||||
|
|
||||||
|
## 密码:它挡的是谁
|
||||||
|
|
||||||
|
必须说清楚,否则会被当成一把它并不是的锁。
|
||||||
|
|
||||||
|
- **挡得住**:现场随手改绑定。选手想把自己的画面换到对手那块板子上,得先知道密码;围观
|
||||||
|
的人翻不到明文(只有 Argon2id 哈希),也不能靠改配置文件把绑定改掉——改了之后界面会
|
||||||
|
要求先解锁。**退出/关闭程序**同样要密码,因为"随手关掉"也是一种要挡的误操作。
|
||||||
|
- **挡不住**:一个愿意在自己机器上重编这个客户端的人。配置与二进制都在他手上,删掉哈希
|
||||||
|
或者直接改源码都行。真要封住得回固件加配对 token。
|
||||||
|
|
||||||
|
所以它的定位是**防误触与防随手改**,不是密码学意义上的对抗。
|
||||||
|
|
||||||
|
实现上:
|
||||||
|
|
||||||
|
- Argon2id(`argon2` crate,默认参数 19 MiB / t=2),存标准的 PHC 字符串,参数跟着哈希
|
||||||
|
一起躺在配置里,以后调参不需要迁移。用快哈希(SHA-256 之类)对 4 位数字密码等于没有
|
||||||
|
保护。
|
||||||
|
- 首次启动**强制**设置密码(`Config::needs_password_setup`),最短 4 位。长度下限只要
|
||||||
|
4 位是刻意的:一个 8 位强密码在这里换不来任何实际安全增益,却会让现场每次改绑都要掏
|
||||||
|
手机查一遍。
|
||||||
|
- 解锁一次管到进程结束(会话内)。用户端可以手动"上锁"。
|
||||||
|
- **裁判端不要密码**:它没有"绑定到自己那块板子"的动作可被滥用,而现场裁判要能最快地改
|
||||||
|
槽位、开录。
|
||||||
|
- **离开用户端要密码**:否则切到裁判端就能看两路画面、还能随便改配置,闸门形同虚设。
|
||||||
|
|
||||||
|
执行点在 Rust 侧:每个改变配置的命令都过 `AppState::require_unlocked`。界面上的解锁
|
||||||
|
对话框只是入口——即使有人从控制台直接调 `invoke("bind_camera")`,一样要过这一关。
|
||||||
|
|
||||||
|
**故意没有 `--camera-id` 这类命令行覆盖。** 旧客户端有一个,用来"临时把一台机器当另一台
|
||||||
|
用";在这里它等于一条不需要密码就能改绑的路,所以去掉。要临时换一块板子,就在设置里改
|
||||||
|
(要密码),或者用 `--config` 指一份另外的配置文件。
|
||||||
|
|
||||||
|
忘了密码:删掉配置文件里的 `password` 一行(或整个文件)。旧客户端的配置会在首次启动时
|
||||||
|
导入并备份成 `.bak`。
|
||||||
|
|
||||||
|
## 用户端 HUD
|
||||||
|
|
||||||
|
### 血条:两个上角,平行四边形,一条分 5 格
|
||||||
|
|
||||||
|
**画面左上角是自己,右上角是对手**——两条同时看得见。血量是这一屏上最需要对照的两个数,
|
||||||
|
放在画面之外意味着视线要离开取景。条本身是斜的(平行四边形),里面分成 **5 格**,一格
|
||||||
|
20 点血:格子少而大,隔着几米扫一眼也能数出"还剩几格",比读一条连续的长度或一个百分比快。
|
||||||
|
少了的那部分从**靠中心的一侧**开始灭,于是两条血一起减少时视觉上往两边退。
|
||||||
|
|
||||||
|
(条挂在绝对定位的一层上,`pointer-events: none`——那一层盖着画面,而画面要能滚轮缩放、
|
||||||
|
指针拖动。同理,倍率与复位徽标从画面**上**沿挪到了下沿,上沿两个角归血条。)
|
||||||
|
|
||||||
|
宽度、是否显示数字/队名、低血阈值与脉动都可配;阈值以内的格子提前染上警示色,所以"危险区
|
||||||
|
在哪"是一直看得见的,而不是等真的低血了才第一次出现。
|
||||||
|
|
||||||
|
**未接入时显示占位说明而不是 0**:0 是一个看起来像真值的假值,现场看到 0 会以为对方已经
|
||||||
|
没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
|
||||||
|
|
||||||
|
血量归零**不写"已击毁/损毁"这类话**:归零这件事由图传中央的胜负横幅表达(见下),条上只留
|
||||||
|
一个 0 与灭掉的格子。
|
||||||
|
|
||||||
|
### 胜负横幅
|
||||||
|
|
||||||
|
一方归零之后,**图传中央**显示结论:赢的一边是「胜利」,输的一边是「击败」,两边同时归零是
|
||||||
|
「平局」(那时候任何一边显示"胜利"都是错的)。用词带上对手的名字,裁判端两格因此也能直接
|
||||||
|
看出是哪一边赢了。
|
||||||
|
|
||||||
|
- 结论由 Rust 侧算(`snapshot::resolve_outcomes`,有单测):**输只需要看自己**——自己归零就是
|
||||||
|
输,哪怕对手那块板子掉线;**赢要看两边**——对手归零且自己还有血,否则一块从没上报过血量的
|
||||||
|
自家主板会让人"凭空胜利"。
|
||||||
|
- 它一直显示到血量被重置(长按 BOOT / 下一局开始),不做几秒淡出:淡出之后屏幕上就再没有
|
||||||
|
"这局结束了"的痕迹,截图、回放与现场的争议都无从对照。
|
||||||
|
- 这是唯一放在画面正中的东西。命中反馈那一套刻意避开正中(见下),因为**胜负不是那种信息**:
|
||||||
|
它是一局的结束,此刻不再需要瞄准。
|
||||||
|
|
||||||
|
### 瞄准器
|
||||||
|
|
||||||
|
画在 canvas 上(不是叠一层 DOM),因为**它必须跟着画面走**:它标的是相机光轴上的一个点。
|
||||||
|
用 DOM 摆一个 `<img>` 再叠一层 `transform` 的话,"图像落在哪、瞄准点落在哪"要算两遍,而
|
||||||
|
两份实现迟早会不一致——表现是放大之后瞄准器偏了,恰恰发生在这件事最要紧的时候。所以变换
|
||||||
|
只算一次,图像、网格、瞄准器、伤害数字全都用同一组数画出来。
|
||||||
|
|
||||||
|
尺寸与缩放的关系也是刻意分开的:
|
||||||
|
|
||||||
|
| | 跟着缩放走吗 | 为什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| 位置 | **是** | 它指的是画面里的一个点 |
|
||||||
|
| 大小 | 否 | 像真瞄具一样,放大之后准星覆盖画面里更小的一块 |
|
||||||
|
| 网格 | 是 | 它标的是画面里的比例,用来估距离 |
|
||||||
|
|
||||||
|
**偏移校准存的是画面比例,不是像素。** 摄像头与枪管不同轴时要把瞄点挪到弹着点上,而像素
|
||||||
|
偏移在分辨率或窗口大小一变就全错了。校准可以拖着画面调,也可以直接填数值。
|
||||||
|
|
||||||
|
### 命中反馈
|
||||||
|
|
||||||
|
主板**不报**"被打了一下",只报"现在是多少血"。所以命中是靠相邻两次读数之差还原的
|
||||||
|
(`HealthTracker`):
|
||||||
|
|
||||||
|
- 血量减少 ⇒ 一次命中,伤害是两个读数之差;
|
||||||
|
- 血量增加 ⇒ 不是命中,是长按 BOOT 的重置;
|
||||||
|
- 第一次见到一块板子不产生命中——没有基线,此时 `hp = 0` 只是"已经没血了"。
|
||||||
|
|
||||||
|
按**板子**记基线而不是按 `(板子, index)`:主板一次广播四条、`hp` 相同,按四条各记一份
|
||||||
|
基线会让一次掉血被报成四次命中。
|
||||||
|
|
||||||
|
已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次
|
||||||
|
打击会被合并成一次——对"闪一下红"足够,对判定不够,判定看的是界面上的绝对血量。
|
||||||
|
|
||||||
|
反馈有**两样**:整屏闪红、准星旁的伤害数字。**大号数字不放在画面正中**——第一版放了,
|
||||||
|
结果它正好压在瞄准点上,而被打中的那一下恰恰是最需要看着准星的时候。
|
||||||
|
|
||||||
|
**没有"画面抖动"了。** 原来有第三样:被打中时整幅图(连瞄准器一起)晃一下。它去掉了,
|
||||||
|
理由有两条:
|
||||||
|
|
||||||
|
- 抖动恰好发生在最需要看清画面的那一下;
|
||||||
|
- 它把**画面**动了,而操作手看到画面动时的第一反应是"车在晃",不是"我被打中了"——一个
|
||||||
|
只有几百毫秒的信号,代价是让人怀疑一个不存在的问题。
|
||||||
|
|
||||||
|
`HitFeedbackConfig::shake` 这个字段留着但不再生效:配置文件是 `deny_unknown_fields` 的,
|
||||||
|
删字段会让所有写过 `shake = true` 的配置**解析失败**,而配置解析失败等于程序起不来。设置
|
||||||
|
面板里也没有这个开关了。
|
||||||
|
|
||||||
|
## 裁判端
|
||||||
|
|
||||||
|
### 队名可以直接改
|
||||||
|
|
||||||
|
两格画面各自下面有一个队名输入框(`TeamNameEditor`),回车或点到别处生效,Esc 取消。
|
||||||
|
名字是**这一场比赛**的属性,所以它放在裁判盯着的地方,而不是设置面板深处的某一节;改完
|
||||||
|
立刻反映到血条、装甲板表与"等待图传"的占位文字上。
|
||||||
|
|
||||||
|
规范化的三件事统一在 Rust 侧做(`config::sanitize_team_name`,有单测):合并空白、按**字符**
|
||||||
|
截断到 12 个字(按字节截断会切出半个字)、留空回落到「队伍一 / 队伍二」。超过长度在输入框里
|
||||||
|
就限制了,比提交后被悄悄截掉诚实。
|
||||||
|
|
||||||
|
命令是 `set_team_name(team, name)`——**一次只改一边**。界面本来可以传一整个两条的数组,但那样
|
||||||
|
每次改名都是"读两条、改一条、写两条",而读来的那两条来自最多 200 ms 前的快照,紧接着改另一边
|
||||||
|
就会把上一次的改动覆盖回去。**同样过密码闸门**:裁判端本来就没有密码(`Role::needs_password`
|
||||||
|
为假),所以对现场没有影响;而给一个不设闸门的"写配置"入口,等于在闸门上开一条旁路——今天
|
||||||
|
改的是名字,明天有人顺手把绑定也挪进来。
|
||||||
|
|
||||||
|
名字在快照里以 `teamNames`(两条,下标就是队伍号)推给界面,两边各自的 `TeamHealthDto.name`
|
||||||
|
也是从这里来的:界面各处自己拼 `team === 0 ? "队伍一" : "队伍二"` 会在改名之后留下一半旧名字,
|
||||||
|
而且不会报错。
|
||||||
|
|
||||||
|
### 两格等宽
|
||||||
|
|
||||||
|
`flex: 1 1 0` + `min-width: 0`,两格的宽度只由容器决定。
|
||||||
|
|
||||||
|
旧客户端在这里翻过车:`HorizontalLayout` 先按内容的**最大**需求分配,剩下的才按 stretch
|
||||||
|
分。一格写着"已连接,等待画面…"(8 字),另一格只有"未收到画面"(5 字),于是左边多要
|
||||||
|
十几个像素——实测 55% / 45%。现在占位文字绝对定位、两格需求相同。
|
||||||
|
|
||||||
|
### 槽位分配:修掉了一个真 bug
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
1. 还在线的槽位不换人;
|
||||||
|
2. **候选里排除已经被另一个槽位占着的 id**;
|
||||||
|
3. 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位;
|
||||||
|
4. 没有候选就不动——只有一块板在场时另一个槽位空着,空着比显示一份复制品诚实。
|
||||||
|
|
||||||
|
规则 2 是修 bug。旧实现只排除"这个槽位自己原来那个 id",于是**队伍一的板子一离线,它的
|
||||||
|
槽位就会去拿队伍二那块,两格显示同一块板子**——看起来像图传串了,现场的排查方向会是网线、
|
||||||
|
AP、板子,而不是二十行分配代码。现在它有一个直接对着这个场景写的测试。
|
||||||
|
|
||||||
|
有烧错固件的板子(`team` 字段是反的)时可以在设置里改成手动指定槽位。
|
||||||
|
|
||||||
|
### 装甲板发现列表
|
||||||
|
|
||||||
|
场上连上了几块板子、各自自报哪一队、现在多少血、多久没广播、四条上报齐不齐、分到了哪个
|
||||||
|
槽位——"裁判端没显示血量"时第一眼看的就是这张表。它能区分"板子根本没上线"、"上线了但队
|
||||||
|
伍号烧反了"和"只有一条上报在发"。
|
||||||
|
|
||||||
|
**这不需要改固件**:主板一直用 `encode_from(sender_id)` 把自己的 MAC 派生 id 打进帧头,
|
||||||
|
只是旧的解码路径把它丢了。现在 `ArmorHealth::decode_from` 把它交出来。
|
||||||
|
|
||||||
|
### 截图与录制
|
||||||
|
|
||||||
|
- 截图存 PNG(`shots/` 下),文件名带板子 id 与毫秒时间戳。
|
||||||
|
- 录制存 mp4,走 ffmpeg 子进程:`-f image2pipe -c:v mjpeg -framerate <fps> -i pipe:0`
|
||||||
|
喂帧,x264 编码,`+faststart`。
|
||||||
|
- **帧率要显式告诉 ffmpeg**。`image2pipe` 里的 JPEG 序列没有时间戳,一串帧在 ffmpeg 看来
|
||||||
|
就是"以固定帧率播放的图片"。所以这个数只能来自实测,它会写进日志与 DTO——回放快放/慢放
|
||||||
|
时第一个要看的就是它。
|
||||||
|
- 喂帧经由一条长度 64 的队列与一个写盘线程。**接收线程绝不能被写盘拖慢**:那一天的表现是
|
||||||
|
链路上本不存在的丢帧。写不进去就丢帧并计数,丢帧数会出现在界面上。
|
||||||
|
- 文件名会查重:同一秒停一次再开一次是很自然的操作("这局重录"),而重名意味着 ffmpeg 的
|
||||||
|
`-y` 把上一段**静默覆盖**掉。所以先收尾上一段再开新的,重名往后排 `-2`、`-3`。
|
||||||
|
|
||||||
|
界面内回放走 Tauri 的资源协议,只对 `tauri.conf.json` 里 `assetProtocol.scope` 白名单的
|
||||||
|
目录生效。默认录制目录在名单里;改到别处时 `<video>` 会加载失败,界面上给出"用系统播放器
|
||||||
|
打开"的退路——而不是让人以为录下来的文件坏了。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
`~/.config/cam-desktop/config.toml`,`--config` 可改。
|
||||||
|
|
||||||
|
```toml
|
||||||
|
role = "team"
|
||||||
|
roleChosen = true
|
||||||
|
team = 0
|
||||||
|
teamNames = ["队伍一", "队伍二"]
|
||||||
|
cameraId = "0x0DE00001"
|
||||||
|
armorId = "0x0A200001"
|
||||||
|
mirror = false
|
||||||
|
password = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||||
|
|
||||||
|
[recording]
|
||||||
|
dir = "/Users/you/Movies/cam-desktop"
|
||||||
|
ffmpeg = "ffmpeg"
|
||||||
|
crf = 20
|
||||||
|
preset = "veryfast"
|
||||||
|
|
||||||
|
[hud.crosshair]
|
||||||
|
style = "cross-dot"
|
||||||
|
size = 0.05
|
||||||
|
offsetX = 0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
几个决定:
|
||||||
|
|
||||||
|
- **手写解析换成 serde + toml。** 旧客户端的配置只有四个标量字段,手写换来零依赖是划算
|
||||||
|
的;现在有 HUD 的嵌套结构、密码哈希、录制参数,对称性成本超过了收益。
|
||||||
|
- **未知键名仍然报错**(`deny_unknown_fields`)。打错一个键名却"看起来生效了",在现场是
|
||||||
|
要浪费时间的。
|
||||||
|
- **字段名统一 camelCase**,磁盘、IPC、前端 TS 一个名字。TOML 里写 camelCase 不算最漂亮,
|
||||||
|
但换来的是不需要任何一层做名字转换——而那种转换漏掉一个字段时不会报错,只会让某一项设置
|
||||||
|
看起来"改了没生效"。
|
||||||
|
- **原子写**:先写同目录临时文件再 `rename`。配置文件里有密码哈希,写一半崩掉留下的截断
|
||||||
|
文件会让"解锁"永久失败。
|
||||||
|
- **id 写成十六进制字符串**(`"0x3D4D8A9A"`),与界面、日志、抓包里看到的完全一致。
|
||||||
|
- `teamNames` 在根上而不是 `[referee]` 里:用户端的血条同样要显示名字,而"这场比赛的两队
|
||||||
|
叫什么"是比赛本身的属性,不是裁判端这个身份的属性。
|
||||||
|
- `roleChosen` 单独一位:`role` 的默认值是裁判端,所以"role == referee"既可能是人选了裁判
|
||||||
|
端,也可能只是还没人碰过这个文件。界面需要这个判据来决定首启要不要先问一句。
|
||||||
|
|
||||||
|
旧客户端(Slint 版)的配置在首次启动时导入能对应的字段,然后改名成 `.bak`(不删:现场
|
||||||
|
出问题时"旧的那份到底写了什么"是第一个要看的东西)。只有用**默认**路径时才做这件事——
|
||||||
|
`--config` 指定了别的路径说明这次运行是有意分开的,那时去动常规配置是越界的。
|
||||||
|
|
||||||
|
## 浏览器预览(仅开发)
|
||||||
|
|
||||||
|
Tauri 的界面跑在 WKWebView 里,改一行 CSS 要走一次 cargo 构建。所以 dev 模式下、且页面
|
||||||
|
里没有 Tauri 注入时,同一套 React 组件可以在浏览器里跑起来:状态与画面由
|
||||||
|
`src/lib/dev-preview.ts` 合成。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm dev # 用户端
|
||||||
|
open 'http://127.0.0.1:1420/?preview=referee' # 裁判端(密码 1234)
|
||||||
|
```
|
||||||
|
|
||||||
|
两重门保证它进不了正式构建:`import.meta.env.DEV` 为真**且**没有 `__TAURI_INTERNALS__`,
|
||||||
|
并且调用点是 `await import(...)`——条件为假时整个模块不会被打进产物。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd desktop/core && cargo test # 83:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定
|
||||||
|
cd desktop/src-tauri && cargo test # 32:帧头布局、录制、截图、演示画面、命令行
|
||||||
|
cd protocol && cargo test # 20
|
||||||
|
```
|
||||||
|
|
||||||
|
前端解码队列测试:在 `desktop/` 运行 `pnpm test:stream`,5 项测试覆盖慢解码时保留最新帧、多路独立处理、关闭清理、坏帧恢复与设备重启。它不替代实际 WebView 的画面验证。
|
||||||
|
|
||||||
|
三条值得单独说的:
|
||||||
|
|
||||||
|
- **录制**那条真的起 ffmpeg、喂 20 帧、停,然后用 `ffprobe` 读回帧数与分辨率。只断言"文件
|
||||||
|
存在且非空"是不够的:ffmpeg 参数写错时也会留下一个空的容器,而那正是最难在现场发现的
|
||||||
|
一种失败。
|
||||||
|
- **截图**那条真写一个 PNG 再读回来对尺寸,另外验证坏输入会报错而不是留下一个空文件。
|
||||||
|
- **帧率窗口**那条把"对帧间隔取平均会给出什么"也钉住了(约 30fps),防止有人"顺手修一下"
|
||||||
|
改回错误算法。
|
||||||
|
|
||||||
|
CSP 的验证不靠"看一眼没报错",而是靠**前端能不能连上后端**:前端的第一步是先 `invoke`
|
||||||
|
拿通道地址,再连 WebSocket。所以快照里的 `clients` 从 0 变成 1 同时证明了四件事——JS 加载
|
||||||
|
成功、IPC 通、WebSocket 通、React 的 effect 跑到了。CSP 一旦挡住其中任何一环,它就会停在
|
||||||
|
0。手动核对的办法:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
|
||||||
|
# 拿那条 ws://... 地址,用任何 WebSocket 客户端连上去,看 clients 是不是 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 线程与数据流
|
||||||
|
|
||||||
|
| 线程 | 做什么 | 为什么单独 |
|
||||||
|
|---|---|---|
|
||||||
|
| `udp-reader` | 收包、解析、按发送方重组 | 不能让解码/写盘挤进数据报之间 |
|
||||||
|
| `armor-reader` | 收 5601 上的血量广播 | 与视频是两个端口、两种节奏 |
|
||||||
|
| `rec-writer-<id>` | 把队列里的帧写进 ffmpeg 的 stdin | 接收线程绝不能被写盘拖慢 |
|
||||||
|
| `demo-*`(仅演示) | 合成协议数据报 | 走同一条真实路径 |
|
||||||
|
| tokio 任务 | WebSocket 服务、5 Hz 快照 | 异步 IO |
|
||||||
|
|
||||||
|
帧在接收线程里编码一次(16 字节头 + JPEG),所有界面共用同一份 `Bytes`。
|
||||||
|
视频使用每路只保留最新帧的 watch 通道;状态与控制消息使用有序广播通道。
|
||||||
|
前端每路最多保留一次正在进行的解码和一张最新待解码帧。
|
||||||
|
|
||||||
|
每张本地 JPEG 前增加 `frameTiming` 文本消息,包含同机后端发布时刻;JPEG 二进制头仍为 16 字节。前端在解码前后检查本机队列年龄,超过 100 ms 的待解码帧或解码结果直接释放,恢复后优先显示最新画面。后端不重放超过 100 ms 的缓存帧,本地视频 socket 写入超时后重连。旧版没有时间标记时仍支持 JPEG,但只能按 JS 收到时刻判断本地年龄。这些时间不能用于测量 ESP 到电脑的无线传输延迟。
|
||||||
|
|
||||||
|
## 已知限制与没做的
|
||||||
|
|
||||||
|
- **同一队有两块装甲板在广播时**,`health(team)` 是"最后写入者优先",四个格子可能混着两块
|
||||||
|
板子的数。真实比赛一队只有一块,所以这只在开发(演示 + 真板子)或烧错固件时才会遇到;
|
||||||
|
日志里会警告一次,消歧办法是绑定具体的板子 id(`health_of_board`)。
|
||||||
|
- **录制帧率**是固定值,不是可变帧率。源帧率变化大时回放速度会有偏差;这个数会写进日志与
|
||||||
|
DTO,所以至少能解释清楚。
|
||||||
|
- **`csp` 是一条收紧过的白名单**,但它是"够用"而不是"最小":`style-src` 带
|
||||||
|
`'unsafe-inline'`(React 的内联 `style` 属性需要它),`connect-src` 放开了整个
|
||||||
|
`ws://127.0.0.1:*`(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端"
|
||||||
|
——见下面"测试"一节最后一条。
|
||||||
|
- **证书与公证**没做,`.dmg` 只在本机可用。
|
||||||
|
- 前端已有解码队列测试;布局、实际 WebView 解码和画面显示仍需浏览器预览及真机运行验证。
|
||||||
|
|
||||||
|
## 排查
|
||||||
|
|
||||||
|
需要测量相机到客户端的整条链路时,使用裁判配置启动校准模式:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\cam-desktop.exe --config .\referee.toml --latency-report .\latency-run.ndjson
|
||||||
|
```
|
||||||
|
|
||||||
|
报告路径必须是新文件。让相机完整拍到左侧四个彩色角块,避开右侧回显,固定相机和窗口位置。标记每 50 ms 更新;程序沿正常接收、解码和 `VideoSurface` 绘制路径记录每个标记的首次有效回显,拒绝曝光混合而无法可靠解码的帧。NDJSON 包含相邻 rAF 夹定的时间、解码完成到画布绘制时间和标记分析耗时,不保存图像或配置。面板扫描和像素响应未测量,精确物理光学延迟需用高速相机复核。正常启动不运行标记识别;校准模式不会额外订阅一份 UDP 视频。
|
||||||
|
|
||||||
|
`pnpm test:latency` 验证标记边界、旋转/镜像、混合曝光拒绝及屏幕原色泛白情形。
|
||||||
|
|
||||||
|
| 现象 | 原因 |
|
||||||
|
|---|---|
|
||||||
|
| 起不来,提示绑不上 UDP 5600 | 同一端口上已经跑着另一个客户端(旧的 Slint 版或另一份本程序)。看 `~/.config/cam-desktop/startup-error.txt` |
|
||||||
|
| 窗口起来了但没画面 | 设置里看"摄像头"下拉:绑定的是哪一块、在不在线 |
|
||||||
|
| 状态栏一直"已连接,等待画面…" | 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次) |
|
||||||
|
| 队伍端下拉里只有一块板子 | 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选 |
|
||||||
|
| 右上角那条血显示"装甲板未接入" | 对手那块主板没在 5601 上报血量(或不在线)。显示的是说明而不是 0——0 会被当成"对手已经没血" |
|
||||||
|
| 改了队名,某一处还是旧名字 | 不该发生:名字只有一个来源(快照里的 `teamNames`,血条直接用 `name`)。看到的话说明有一处自己拼了"队伍一/队伍二",那是个 bug |
|
||||||
|
| 输了/赢了之后画面中央一直有字 | 刻意如此:它显示到血量被重置(长按 BOOT)为止,不做几秒淡出。想清掉就重置装甲板 |
|
||||||
|
| 被打中时画面不动,只有闪红 | 刻意如此:抖动去掉了(见"命中反馈")。旧配置里的 `shake = true` 读得进来但不生效 |
|
||||||
|
| 画面不动 | 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。`RUST_LOG=debug` 会打印完整通道地址,可以用 `wscat` 连上去看 |
|
||||||
|
| 回放失败 | 录制目录不在资源协议白名单里。用"用系统播放器打开" |
|
||||||
|
| 录不了 | ffmpeg 不在 PATH 里。设置里可以指定路径;不起 ffmpeg 时会报错而不是假装在录 |
|
||||||
|
| 两块画面串了 | 检查是不是手动指定了槽位,或者两块板子烧了同一个 `ARMOR_TEAM` |
|
||||||
|
| macOS 上 WebKit 报 `could not create directory .../WebKit/...` | 运行环境禁止写 `~/Library/WebKit`(沙箱)。WebView 会退回无缓存的临时目录,功能不受影响 |
|
||||||
|
| `libc` 找不到 `MAP_FAILED` | `desktop/.cargo/config.toml` 的 target 没改成宿主三元组 |
|
||||||
Generated
+462
@@ -0,0 +1,462 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.104"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cam-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"argon2",
|
||||||
|
"log",
|
||||||
|
"protocol",
|
||||||
|
"rand",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"socket2",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"wasip2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.190"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "protocol"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.154"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.9.12+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow 0.7.15",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.7.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.1.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||||
|
dependencies = [
|
||||||
|
"winnow 1.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.3+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip2"
|
||||||
|
version = "1.0.4+wasi-0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.57.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.63"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.63"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "cam-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "桌面端的非界面部分:UDP 接收/重组、装甲板发现、配置与密码"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# 与固件共享的线路格式。用 path 而不是复制,两边不可能悄悄分叉。
|
||||||
|
protocol = { path = "../../protocol" }
|
||||||
|
|
||||||
|
anyhow = "1"
|
||||||
|
log = "0.4"
|
||||||
|
socket2 = "0.6"
|
||||||
|
|
||||||
|
# 配置的序列化。这里与旧客户端(client/)的做法相反:那一版的配置只有十来个标量
|
||||||
|
# 字段,手写 TOML 换来零依赖是划算的。现在配置里有 HUD 的嵌套结构和密码哈希,手写
|
||||||
|
# 解析器的对称性成本超过了它的收益,所以换成真正的 TOML。
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = "0.9"
|
||||||
|
|
||||||
|
# 密码哈希。Argon2id 是目前的推荐选择(内存硬),而 SHA-256 之类的快哈希对
|
||||||
|
# 一个 4 位数字密码来说等于没有保护。
|
||||||
|
argon2 = "0.5"
|
||||||
|
rand = "0.9"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# 只用来验证喂给界面的 JSON:断言"密码哈希不在里面"这件事只能对着序列化结果做。
|
||||||
|
serde_json = "1"
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
//! 装甲板:监听 5601,把主板广播的 `ArmorHealth` 变成界面要的东西。
|
||||||
|
//!
|
||||||
|
//! 两件事,各自服务一种需求:
|
||||||
|
//!
|
||||||
|
//! - **血量**([`ArmorSource::health`]):某一边四条上报都在新鲜期内才交给界面。
|
||||||
|
//! - **发现**([`ArmorHealthStore::boards`]):场上有哪几块装甲板主板,各自的 id、IP、
|
||||||
|
//! 自报的队伍、最后一次血量。
|
||||||
|
//!
|
||||||
|
//! 第二种以前没有,因为队伍端不能选装甲板。现在能选了(要密码),于是"列出候选"
|
||||||
|
//! 成为必需。**这里不需要改固件**:主板一直用 `encode_from(sender_id)` 把自己的
|
||||||
|
//! MAC 派生 id 打进帧头,只是旧的解码路径把它丢了。
|
||||||
|
//!
|
||||||
|
//! # 为什么按 `(team, index)` 存最后一次读数
|
||||||
|
//!
|
||||||
|
//! 主板每 200 ms 广播四条(`index` 0..3,整车一条血所以 `hp` 相同)。UDP 会丢包,
|
||||||
|
//! 界面不能"收到哪条显示哪条"——那样四个格子会各自跳。所以**四条都在新鲜期内**才交给
|
||||||
|
//! 界面;否则退回占位符:显示一个可能已经过期的血量,比显示"未接入"更危险(现场会以为
|
||||||
|
//! 对方还有血)。
|
||||||
|
//!
|
||||||
|
//! # 为什么绑 0.0.0.0
|
||||||
|
//!
|
||||||
|
//! 板子往 `255.255.255.255:5601` 广播,只有绑到 `INADDR_ANY` 的 socket 才收得到。
|
||||||
|
//! 绑具体网卡地址的话,换个网络就得改配置。
|
||||||
|
//!
|
||||||
|
//! # 两端都开
|
||||||
|
//!
|
||||||
|
//! 旧客户端只在裁判端绑 5601(队伍端不显示血量)。现在队伍端要显示自己的血条,所以
|
||||||
|
//! 两端都要收。这反而去掉了同一台机器上"队伍端与裁判端抢 5601"的争用点——两套界面
|
||||||
|
//! 现在在同一个进程里。
|
||||||
|
//!
|
||||||
|
//! # 命中检测
|
||||||
|
//!
|
||||||
|
//! 掉血事件([`HealthTracker`])由这里派生:主板只上报"现在是多少血",不报"刚刚被
|
||||||
|
//! 打了一下"。所以命中反馈是靠**相邻两次读数之差**还原的,见那个结构体的文档。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use protocol::{ArmorHealth, ARMOR_PORT};
|
||||||
|
|
||||||
|
use crate::receiver::ARMOR_BOARDS;
|
||||||
|
|
||||||
|
/// 多久没收到就当作板子掉线。
|
||||||
|
///
|
||||||
|
/// 心跳 200 ms,所以这是连续丢了 7 个包:偶发丢一两个不会让界面闪回占位符。
|
||||||
|
pub const STALE_AFTER: Duration = Duration::from_millis(1500);
|
||||||
|
|
||||||
|
/// 一边的装甲板数量(0/1 两支队伍)。
|
||||||
|
const TEAMS: usize = 2;
|
||||||
|
|
||||||
|
/// 某个 `(team, index)` 的最后一次读数。
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Reading {
|
||||||
|
hp: u16,
|
||||||
|
at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一块装甲板主板。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ArmorBoard {
|
||||||
|
/// MAC 派生的板子 id,重启不变,所以绑定它不会失效。
|
||||||
|
pub id: u32,
|
||||||
|
/// 最后一次收到它广播的来源地址。
|
||||||
|
pub addr: SocketAddrV4,
|
||||||
|
/// 板子自报的队伍(编译期定的 `ARMOR_TEAM`)。
|
||||||
|
pub team: u8,
|
||||||
|
/// 最后一次读数里的血量。整车一条血,所以取任意一条都一样。
|
||||||
|
pub hp: u16,
|
||||||
|
/// 见过的 `index` 位图:低 4 位分别对应 0..3。
|
||||||
|
///
|
||||||
|
/// 用来区分"这块板子只报了第 0 条"和"四条都报了"——前者通常意味着配置或固件不对,
|
||||||
|
/// 现场值得看一眼。
|
||||||
|
pub plates_seen: u8,
|
||||||
|
pub last_seen: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArmorBoard {
|
||||||
|
/// 多久没有广播了。
|
||||||
|
pub fn silence(&self) -> Duration {
|
||||||
|
self.last_seen.elapsed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 板子在线吗?
|
||||||
|
pub fn is_online(&self) -> bool {
|
||||||
|
self.silence() < STALE_AFTER
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 四条装甲板都上报过吗。
|
||||||
|
pub fn reports_all_plates(&self) -> bool {
|
||||||
|
self.plates_seen == 0b1111
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收到的一条装甲板上报。
|
||||||
|
///
|
||||||
|
/// 比 [`protocol::ArmorHealth`] 多一个"谁发的"——那正是能列出候选、能绑定、能把命中
|
||||||
|
/// 归到具体某块板子上的前提。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ArmorReport {
|
||||||
|
pub board: u32,
|
||||||
|
pub addr: SocketAddrV4,
|
||||||
|
pub team: u8,
|
||||||
|
pub index: u8,
|
||||||
|
pub hp: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 每次收到上报就调一次。
|
||||||
|
///
|
||||||
|
/// 在接收线程里被调用,所以必须是非阻塞的。Tauri 层在这里做掉血检测并把结果推给界面。
|
||||||
|
pub type ArmorSink = Arc<dyn Fn(ArmorReport) + Send + Sync>;
|
||||||
|
|
||||||
|
/// 已收到的最新血量。
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Inner {
|
||||||
|
/// `readings[team][index]`。
|
||||||
|
readings: [[Option<Reading>; ARMOR_BOARDS]; TEAMS],
|
||||||
|
/// 按板子 id 索引的发现状态。
|
||||||
|
boards: HashMap<u32, ArmorBoard>,
|
||||||
|
/// 已经打过"这块板子在线"的板子 id,避免刷屏。
|
||||||
|
announced: Vec<u32>,
|
||||||
|
/// 已经警告过"同一队有多块板子"的队伍,避免刷屏。
|
||||||
|
warned_multi_board: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相邻两次读数之差还原出来的掉血事件。
|
||||||
|
///
|
||||||
|
/// 主板不报"被打了一下",只报"现在是 97"。所以命中反馈只能是派生的:
|
||||||
|
///
|
||||||
|
/// - 血量**减少** ⇒ 一次命中,伤害是两个读数之差。
|
||||||
|
/// - 血量**增加** ⇒ 不是命中,是长按 BOOT 的重置。忽略,但值得记一条日志。
|
||||||
|
/// - **第一次**见到一块板子不产生命中:没有基线,此时 `hp = 0` 只是"已经没血了",
|
||||||
|
/// 不是"刚刚被打死"。
|
||||||
|
///
|
||||||
|
/// 已知的精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段
|
||||||
|
/// 窗口里的两次打击会被合并成一次——对"闪一下红"的用途足够,对判定不够,判定看的是
|
||||||
|
/// 界面上的绝对血量。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Hit {
|
||||||
|
pub board: u32,
|
||||||
|
pub team: u8,
|
||||||
|
pub index: u8,
|
||||||
|
pub from: u16,
|
||||||
|
pub to: u16,
|
||||||
|
pub damage: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从连续的读数里派生命中。
|
||||||
|
///
|
||||||
|
/// 按**板子**而不是按 `(板子, index)` 记基线:主板一次广播四条、`hp` 相同,按四条各记
|
||||||
|
/// 一份基线会让一次掉血被报成四次命中。
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct HealthTracker {
|
||||||
|
last: HashMap<u32, u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HealthTracker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 喂进一条上报,返回派生的命中(如果有)。
|
||||||
|
pub fn observe(&mut self, report: &ArmorReport) -> Option<Hit> {
|
||||||
|
let previous = self.last.insert(report.board, report.hp);
|
||||||
|
|
||||||
|
let Some(from) = previous else {
|
||||||
|
// 第一次见到这块板子,没有基线。
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
if report.hp >= from {
|
||||||
|
// 持平(同一次广播的另外三条)或回升(重置)。
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Hit {
|
||||||
|
board: report.board,
|
||||||
|
team: report.team,
|
||||||
|
index: report.index,
|
||||||
|
from,
|
||||||
|
to: report.hp,
|
||||||
|
damage: from - report.hp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 忘掉一块板子的基线(掉线重连后重新建)。
|
||||||
|
pub fn forget(&mut self, board: u32) {
|
||||||
|
self.last.remove(&board);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 监听装甲板广播的血量源。
|
||||||
|
pub struct ArmorHealthStore {
|
||||||
|
inner: Mutex<Inner>,
|
||||||
|
sink: Mutex<Option<ArmorSink>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ArmorHealthStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArmorHealthStore {
|
||||||
|
/// 建一个 store,但先不绑端口。测试从这里喂数据报。
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Mutex::new(Inner::default()),
|
||||||
|
sink: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 绑定 5601 并起一个接收线程。
|
||||||
|
///
|
||||||
|
/// 绑定失败(端口被占、权限问题)返回 `Err`,调用方退回 [`crate::NoArmorYet`] 即可
|
||||||
|
/// ——血量显示不了不该让整个客户端起不来。
|
||||||
|
pub fn start() -> std::io::Result<(Arc<Self>, UdpSocket)> {
|
||||||
|
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, ARMOR_PORT))?;
|
||||||
|
socket.set_read_timeout(Some(Duration::from_millis(200)))?;
|
||||||
|
|
||||||
|
log::info!("armor: listening for health broadcasts on UDP {ARMOR_PORT}");
|
||||||
|
Ok((Arc::new(Self::new()), socket))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在单独的线程上跑接收循环。
|
||||||
|
pub fn spawn(self: &Arc<Self>, socket: UdpSocket) {
|
||||||
|
let store = Arc::clone(self);
|
||||||
|
|
||||||
|
// 线程名带上前缀,日志里一眼能看出是装甲板那条链路。
|
||||||
|
let _ = thread::Builder::new()
|
||||||
|
.name("armor-reader".into())
|
||||||
|
.spawn(move || store.run(socket));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 接收循环。这个线程活到进程结束,不需要退出条件。
|
||||||
|
pub fn run(&self, socket: UdpSocket) {
|
||||||
|
let mut buf = [0u8; 256];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (len, from) = match socket.recv_from(&mut buf) {
|
||||||
|
Ok(pair) => pair,
|
||||||
|
Err(err)
|
||||||
|
if err.kind() == ErrorKind::WouldBlock
|
||||||
|
|| err.kind() == ErrorKind::TimedOut =>
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("armor: receive failed: {err}");
|
||||||
|
// 出错后别做没有间隔的忙等(比如网卡热插拔时的 ENOBUFS)。
|
||||||
|
thread::sleep(Duration::from_millis(200));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.datagram(&buf[..len], from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理一条上报。测试从这里喂字节。
|
||||||
|
pub fn datagram(&self, bytes: &[u8], from: SocketAddr) {
|
||||||
|
let SocketAddr::V4(addr) = from else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 同一个端口上可能有别的东西(图传板的心跳、别的协议),解不出来就当不是给
|
||||||
|
// 我的,安静跳过。
|
||||||
|
let Some((board, health)) = ArmorHealth::decode_from(bytes) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (team, index) = (health.team as usize, health.index as usize);
|
||||||
|
|
||||||
|
if team >= TEAMS || index >= ARMOR_BOARDS {
|
||||||
|
log::warn!(
|
||||||
|
"armor: ignoring out-of-range report team={} index={}",
|
||||||
|
health.team,
|
||||||
|
health.index
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = ArmorReport {
|
||||||
|
board,
|
||||||
|
addr,
|
||||||
|
team: health.team,
|
||||||
|
index: health.index,
|
||||||
|
hp: health.hp,
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut inner = self.inner.lock().expect("armor mutex poisoned");
|
||||||
|
|
||||||
|
// 第一次见到某块板子就报一声:现场判断"到底连上没有"最快的线索。
|
||||||
|
if !inner.announced.contains(&board) {
|
||||||
|
inner.announced.push(board);
|
||||||
|
log::info!(
|
||||||
|
"armor: board 0x{board:08X} is online (team {}, hp {}) via {addr}",
|
||||||
|
health.team,
|
||||||
|
health.hp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 血量变化也要能看见:窗口是给别人看的,抓日志的人得知道掉血没有。整车一条
|
||||||
|
// 血、四条同值,所以只对 index 0 报一次。
|
||||||
|
//
|
||||||
|
// 上一次的读数取自**这块板子自己**的记录,而不是 `readings[team][0]`:那个
|
||||||
|
// 槽位是"这一队"的,同队有两块板子在广播时会被互相覆盖,于是日志会把另一块
|
||||||
|
// 板子的血量当成"这块板子之前的血量"——一条读起来像真事的假线索。
|
||||||
|
if index == 0 {
|
||||||
|
if let Some(previous) = inner.boards.get(&board).map(|entry| entry.hp) {
|
||||||
|
if previous != health.hp {
|
||||||
|
log::info!(
|
||||||
|
"armor: board 0x{board:08X} team {team} hp {previous} -> {}",
|
||||||
|
health.hp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一队出现第二块板子:说清楚后果,并提醒用绑定/指定板子 id 来消歧。
|
||||||
|
let same_team = inner
|
||||||
|
.boards
|
||||||
|
.values()
|
||||||
|
.any(|other| other.id != board && other.team == health.team && other.is_online());
|
||||||
|
|
||||||
|
if same_team && !inner.warned_multi_board.contains(&health.team) {
|
||||||
|
inner.warned_multi_board.push(health.team);
|
||||||
|
log::warn!(
|
||||||
|
"armor: 队伍 {team} 上有不止一块装甲板在广播(现在这是 0x{board:08X})。\
|
||||||
|
`health(team)` 是**最后写入者优先**,四条上报可能来自不同的板子;\
|
||||||
|
要确定是谁的血,请在界面上绑定/指定具体的板子 id。"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
inner.readings[team][index] = Some(Reading {
|
||||||
|
hp: health.hp,
|
||||||
|
at: Instant::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let entry = inner.boards.entry(board).or_insert(ArmorBoard {
|
||||||
|
id: board,
|
||||||
|
addr,
|
||||||
|
team: health.team,
|
||||||
|
hp: health.hp,
|
||||||
|
plates_seen: 0,
|
||||||
|
last_seen: Instant::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 队伍是编译期定的,但同队两块板子都自报同一个 team 是正常的;这里只在
|
||||||
|
// 板子换了 team(重刷了固件)时跟着更新。
|
||||||
|
entry.addr = addr;
|
||||||
|
entry.team = health.team;
|
||||||
|
entry.hp = health.hp;
|
||||||
|
entry.plates_seen |= 1 << index;
|
||||||
|
entry.last_seen = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
let sink = self.sink.lock().expect("armor sink mutex poisoned").clone();
|
||||||
|
if let Some(sink) = sink {
|
||||||
|
sink(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注入上报的消费者。
|
||||||
|
pub fn set_sink(&self, sink: ArmorSink) {
|
||||||
|
*self.sink.lock().expect("armor sink mutex poisoned") = Some(sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 场上的装甲板主板,按 id 排序。
|
||||||
|
///
|
||||||
|
/// 包含已经掉线的(界面要能把"掉线"显示出来),是否在线由 [`ArmorBoard::is_online`]
|
||||||
|
/// 判断。
|
||||||
|
pub fn boards(&self) -> Vec<ArmorBoard> {
|
||||||
|
let inner = self.inner.lock().expect("armor mutex poisoned");
|
||||||
|
let mut list: Vec<ArmorBoard> = inner.boards.values().cloned().collect();
|
||||||
|
|
||||||
|
list.sort_by_key(|b| b.id);
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某一块板子的最新血量,太旧就返回 `None`。
|
||||||
|
pub fn health_of_board(&self, board: u32) -> Option<u16> {
|
||||||
|
let inner = self.inner.lock().expect("armor mutex poisoned");
|
||||||
|
let entry = inner.boards.get(&board)?;
|
||||||
|
|
||||||
|
if entry.silence() > STALE_AFTER {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(entry.hp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 四个格子的当前血量;只要有一格不新鲜就返回 `None`。
|
||||||
|
fn fresh_hp(&self, team: u8) -> Option<[u16; ARMOR_BOARDS]> {
|
||||||
|
let team = usize::from(team);
|
||||||
|
|
||||||
|
if team >= TEAMS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let inner = self.inner.lock().expect("armor mutex poisoned");
|
||||||
|
let mut out = [0u16; ARMOR_BOARDS];
|
||||||
|
|
||||||
|
for (index, reading) in inner.readings[team].iter().enumerate() {
|
||||||
|
let reading = reading.as_ref()?;
|
||||||
|
|
||||||
|
if now.duration_since(reading.at) > STALE_AFTER {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
out[index] = reading.hp;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::receiver::ArmorSource for ArmorHealthStore {
|
||||||
|
fn health(&self, team: u8) -> Option<[u16; ARMOR_BOARDS]> {
|
||||||
|
self.fresh_hp(team)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::receiver::ArmorSource;
|
||||||
|
|
||||||
|
fn from(ip: [u8; 4]) -> SocketAddr {
|
||||||
|
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::from(ip), 5601))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 主板一次广播四条,`hp` 相同。
|
||||||
|
fn tick(store: &ArmorHealthStore, board: u32, team: u8, hp: u16, ip: [u8; 4]) {
|
||||||
|
for index in 0..ARMOR_BOARDS as u8 {
|
||||||
|
let health = ArmorHealth { team, index, hp };
|
||||||
|
store.datagram(&health.encode_from(board), from(ip));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_broadcast_yields_four_plates_of_health() {
|
||||||
|
let store = ArmorHealthStore::new();
|
||||||
|
tick(&store, 0xAAAA_0001, 0, 100, [192, 168, 1, 50]);
|
||||||
|
|
||||||
|
let health = store.health(0).expect("all four plates are fresh");
|
||||||
|
assert_eq!(health, [100, 100, 100, 100]);
|
||||||
|
assert_eq!(store.health(1), None, "the other team sent nothing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_missing_plate_falls_back_to_the_placeholder() {
|
||||||
|
let store = ArmorHealthStore::new();
|
||||||
|
|
||||||
|
// 只来三条:界面宁可说"未接入",也不要显示一个可能过期的血量。
|
||||||
|
for index in 0..3u8 {
|
||||||
|
let health = ArmorHealth {
|
||||||
|
team: 0,
|
||||||
|
index,
|
||||||
|
hp: 88,
|
||||||
|
};
|
||||||
|
store.datagram(&health.encode_from(0xAAAA_0001), from([192, 168, 1, 50]));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(store.health(0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boards_are_listed_by_their_mac_derived_id() {
|
||||||
|
let store = ArmorHealthStore::new();
|
||||||
|
tick(&store, 0xBBBB_0002, 1, 100, [192, 168, 1, 60]);
|
||||||
|
tick(&store, 0xAAAA_0001, 0, 100, [192, 168, 1, 50]);
|
||||||
|
|
||||||
|
let boards = store.boards();
|
||||||
|
let ids: Vec<u32> = boards.iter().map(|b| b.id).collect();
|
||||||
|
|
||||||
|
assert_eq!(ids, vec![0xAAAA_0001, 0xBBBB_0002], "sorted, stable order");
|
||||||
|
assert_eq!(boards[0].team, 0);
|
||||||
|
assert_eq!(boards[1].team, 1);
|
||||||
|
assert!(boards[0].reports_all_plates());
|
||||||
|
assert!(boards[0].is_online());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn health_of_board_ignores_out_of_range_and_garbage() {
|
||||||
|
let store = ArmorHealthStore::new();
|
||||||
|
|
||||||
|
// team/index 越界:丢掉并留一条警告,而不是写进 readings 把数组撑爆。
|
||||||
|
let bad = ArmorHealth {
|
||||||
|
team: 9,
|
||||||
|
index: 0,
|
||||||
|
hp: 100,
|
||||||
|
};
|
||||||
|
store.datagram(&bad.encode_from(1), from([192, 168, 1, 1]));
|
||||||
|
assert!(store.boards().is_empty());
|
||||||
|
|
||||||
|
// 不是装甲板上报的字节:安静跳过。
|
||||||
|
store.datagram(&[0u8; 64], from([192, 168, 1, 1]));
|
||||||
|
assert!(store.boards().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_sink_sees_every_report_with_its_board_id() {
|
||||||
|
let store = ArmorHealthStore::new();
|
||||||
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
|
let tap = Arc::clone(&seen);
|
||||||
|
store.set_sink(Arc::new(move |report: ArmorReport| {
|
||||||
|
tap.lock().unwrap().push((report.board, report.index, report.hp));
|
||||||
|
}));
|
||||||
|
|
||||||
|
tick(&store, 7, 0, 100, [192, 168, 1, 50]);
|
||||||
|
|
||||||
|
assert_eq!(seen.lock().unwrap().len(), ARMOR_BOARDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_boards_on_the_same_team_share_the_per_team_slot() {
|
||||||
|
// 这是 `health(team)` 的已知限制,不是意外:一个队里同时有两块板子广播时,那
|
||||||
|
// 四个格子是"最后写入者优先",可能混着两块板子的数。真实比赛里一队只有一块,
|
||||||
|
// 所以这条路径只在开发(演示 + 真板子)或烧错固件时才会走到。
|
||||||
|
//
|
||||||
|
// 消歧的办法是绑定具体的板子 id,走 `health_of_board`。
|
||||||
|
let store = ArmorHealthStore::new();
|
||||||
|
tick(&store, 0xAAAA_0001, 0, 100, [192, 168, 1, 50]);
|
||||||
|
tick(&store, 0xBBBB_0002, 0, 40, [192, 168, 1, 60]);
|
||||||
|
|
||||||
|
// 后写的那块板子赢了。
|
||||||
|
assert_eq!(store.health(0), Some([40, 40, 40, 40]));
|
||||||
|
|
||||||
|
// 按板子取就没这个歧义。
|
||||||
|
assert_eq!(store.health_of_board(0xAAAA_0001), Some(100));
|
||||||
|
assert_eq!(store.health_of_board(0xBBBB_0002), Some(40));
|
||||||
|
|
||||||
|
// 两块板子都还在发现列表里,界面才能让人选。
|
||||||
|
assert_eq!(store.boards().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_falling_hp_is_exactly_one_hit() {
|
||||||
|
let mut tracker = HealthTracker::new();
|
||||||
|
|
||||||
|
let report = |hp: u16, index: u8| ArmorReport {
|
||||||
|
board: 1,
|
||||||
|
addr: "192.168.1.50:5601".parse().unwrap(),
|
||||||
|
team: 0,
|
||||||
|
index,
|
||||||
|
hp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 第一次上报只是建立基线,不是"刚刚被打死"。
|
||||||
|
for index in 0..4u8 {
|
||||||
|
assert_eq!(tracker.observe(&report(100, index)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 掉血:四条同值的上报里只有第一条该产生命中。
|
||||||
|
let hits: Vec<Option<Hit>> = (0..4u8).map(|i| tracker.observe(&report(97, i))).collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
hits[0],
|
||||||
|
Some(Hit {
|
||||||
|
board: 1,
|
||||||
|
team: 0,
|
||||||
|
index: 0,
|
||||||
|
from: 100,
|
||||||
|
to: 97,
|
||||||
|
damage: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
hits[1..].iter().all(Option::is_none),
|
||||||
|
"the other three datagrams carry the same hp and must not be hits"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reset_is_not_a_hit() {
|
||||||
|
let mut tracker = HealthTracker::new();
|
||||||
|
let addr = "192.168.1.50:5601".parse().unwrap();
|
||||||
|
let report = |hp: u16| ArmorReport {
|
||||||
|
board: 1,
|
||||||
|
addr,
|
||||||
|
team: 0,
|
||||||
|
index: 0,
|
||||||
|
hp,
|
||||||
|
};
|
||||||
|
|
||||||
|
tracker.observe(&report(100));
|
||||||
|
assert!(tracker.observe(&report(0)).is_some(), "death is a hit");
|
||||||
|
assert_eq!(
|
||||||
|
tracker.observe(&report(100)),
|
||||||
|
None,
|
||||||
|
"长按 BOOT 重置成满血不是一次命中"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_board_first_seen_at_zero_hp_produces_no_hit() {
|
||||||
|
let mut tracker = HealthTracker::new();
|
||||||
|
let report = ArmorReport {
|
||||||
|
board: 1,
|
||||||
|
addr: "192.168.1.50:5601".parse().unwrap(),
|
||||||
|
team: 0,
|
||||||
|
index: 0,
|
||||||
|
hp: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(tracker.observe(&report), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_boards_keep_separate_baselines() {
|
||||||
|
let mut tracker = HealthTracker::new();
|
||||||
|
let addr = "192.168.1.50:5601".parse().unwrap();
|
||||||
|
let report = |board: u32, hp: u16| ArmorReport {
|
||||||
|
board,
|
||||||
|
addr,
|
||||||
|
team: 0,
|
||||||
|
index: 0,
|
||||||
|
hp,
|
||||||
|
};
|
||||||
|
|
||||||
|
tracker.observe(&report(1, 100));
|
||||||
|
tracker.observe(&report(2, 100));
|
||||||
|
|
||||||
|
let hit = tracker.observe(&report(2, 90)).expect("board 2 was hit");
|
||||||
|
assert_eq!(hit.board, 2);
|
||||||
|
assert_eq!(hit.damage, 10);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tracker.observe(&report(1, 100)),
|
||||||
|
None,
|
||||||
|
"board 1 is untouched, not a 10-point heal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
//! cam-core —— 桌面端的非界面部分。
|
||||||
|
//!
|
||||||
|
//! 这一层不认识 Tauri,也不认识任何界面:它只负责
|
||||||
|
//!
|
||||||
|
//! - [`receiver`]:收 UDP、发现摄像头、按发送方重组 JPEG 帧;
|
||||||
|
//! - [`armor`]:收装甲板主板广播的血量,并列出场上有哪几块板子;
|
||||||
|
//! - [`stats`]:帧率统计(按帧数除以墙钟,不是对帧间隔取平均);
|
||||||
|
//! - [`config`]:磁盘上的配置(身份、绑定、HUD、密码哈希);
|
||||||
|
//! - [`password`]:密码的哈希与校验。
|
||||||
|
//!
|
||||||
|
//! # 为什么单独一层
|
||||||
|
//!
|
||||||
|
//! 旧的 Slint 客户端把这几件事和界面耦合在一个 crate 里,于是**它们只能通过跑起
|
||||||
|
//! 整个界面来验证**。抽出来之后,重组、发现、帧率窗口、血量新鲜度都能用单元测试
|
||||||
|
//! 直接盯住——而它们恰好是最容易错、错了又最难在现场看出来的部分。
|
||||||
|
//!
|
||||||
|
//! # 界面怎么拿数据
|
||||||
|
//!
|
||||||
|
//! 两条路,都是**推**而不是轮询:
|
||||||
|
//!
|
||||||
|
//! - 视频帧走 [`receiver::Receiver::set_frame_sink`]:接收线程重组好一张 JPEG 就
|
||||||
|
//! 交给 sink,由上层转成 WebSocket 的二进制消息。sink 必须是非阻塞的。
|
||||||
|
//! - 血量走 [`armor::ArmorHealthStore::set_sink`]:每收到一条上报就交给 sink。
|
||||||
|
//!
|
||||||
|
//! 同时两边的**最新值**都留在内存里([`receiver::LatestFrames`] 与
|
||||||
|
//! [`armor::ArmorHealthStore::boards`]),给"截图"和"设置界面里的下拉列表"用——
|
||||||
|
//! 那两处要的是当前状态,不是每一帧。
|
||||||
|
|
||||||
|
pub mod armor;
|
||||||
|
pub mod config;
|
||||||
|
pub mod password;
|
||||||
|
pub mod receiver;
|
||||||
|
pub mod slots;
|
||||||
|
pub mod snapshot;
|
||||||
|
pub mod stats;
|
||||||
|
|
||||||
|
pub use config::{Config, HudConfig, Role};
|
||||||
|
pub use receiver::{
|
||||||
|
ArmorSource, CameraInfo, FrameSink, LatestFrames, NoArmorYet, Receiver, SourceStats,
|
||||||
|
ARMOR_BOARDS,
|
||||||
|
};
|
||||||
|
pub use slots::SlotAllocator;
|
||||||
|
pub use stats::FpsWindow;
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
//! 密码:哈希、校验、强度下限。
|
||||||
|
//!
|
||||||
|
//! # 这把锁挡的是谁
|
||||||
|
//!
|
||||||
|
//! 必须说清楚,否则会被当成一把它并不是的锁:
|
||||||
|
//!
|
||||||
|
//! - **挡得住**:现场随手改绑定。选手想把自己的画面换到对手那块板子上,得先知道密码;
|
||||||
|
//! 围观的人翻不到明文密码(只有 Argon2id 哈希),也不能靠改配置文件把绑定改掉——
|
||||||
|
//! 改了之后界面会要求先解锁。
|
||||||
|
//! - **挡不住**:一个愿意在自己机器上重编这个客户端的人。配置文件与二进制都在他手上,
|
||||||
|
//! 删掉哈希、或者直接改源码都行。真正封住这件事要靠固件侧的配对 token(见根 README
|
||||||
|
//! 里"这挡不住什么"那一节)。
|
||||||
|
//!
|
||||||
|
//! 所以它的定位是**防误触与防随手改**,不是密码学意义上的对抗。这也是为什么强度下限
|
||||||
|
//! 只要 4 位:一个 8 位强密码在这里换不来任何实际的安全增益,却会让现场每次改绑都要
|
||||||
|
//! 掏手机查一遍。
|
||||||
|
//!
|
||||||
|
//! # 为什么是 Argon2 而不是 SHA-256
|
||||||
|
//!
|
||||||
|
//! 密码短(4 位数字只有 1 万种),任何**快**哈希都能在一秒内被穷举完。Argon2id 是
|
||||||
|
//! 内存硬的,把每次尝试的代价抬到几十毫秒——即使密码很短,"改配置文件暴力试密码"
|
||||||
|
//! 这条路也变得不划算。存的是标准的 PHC 字符串(`$argon2id$v=19$m=...`),参数跟着
|
||||||
|
//! 哈希一起躺在配置里,所以以后调参数不需要做迁移。
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||||
|
use argon2::Argon2;
|
||||||
|
use rand::RngCore;
|
||||||
|
|
||||||
|
/// 密码的最短长度。
|
||||||
|
///
|
||||||
|
/// 见模块文档:这不是安全边界,长度下限只用来挡住"密码是 1 个字符"这种显然是随手打的
|
||||||
|
/// 情况。
|
||||||
|
pub const MIN_PASSWORD_LEN: usize = 4;
|
||||||
|
|
||||||
|
/// 算一个密码的 PHC 字符串。
|
||||||
|
///
|
||||||
|
/// 每次调用都用新的随机盐,所以同一个密码两次得到的结果不同——这样哈希不能当查找表用。
|
||||||
|
pub fn hash_password(password: &str) -> Result<String> {
|
||||||
|
check_strength(password)?;
|
||||||
|
|
||||||
|
let mut salt = [0u8; 16];
|
||||||
|
rand::rng().fill_bytes(&mut salt);
|
||||||
|
|
||||||
|
let salt = SaltString::encode_b64(&salt)
|
||||||
|
.map_err(|err| anyhow!("could not encode the salt: {err}"))?;
|
||||||
|
|
||||||
|
// Argon2::default() 就是 Argon2id、v19、Params::DEFAULT(19 MiB,t=2)。
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|err| anyhow!("could not hash the password: {err}"))?;
|
||||||
|
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验一个密码。
|
||||||
|
///
|
||||||
|
/// 返回 `Ok(false)` 表示"密码不对",返回 `Err` 表示"存下来的哈希本身坏了"——这两种
|
||||||
|
/// 必须区分开:前者是用户输错了,后者是配置文件被改坏了,界面上要给完全不同的提示。
|
||||||
|
pub fn verify_password(password: &str, phc: &str) -> Result<bool> {
|
||||||
|
let parsed = PasswordHash::new(phc)
|
||||||
|
.map_err(|err| anyhow!("the stored password hash is not a valid PHC string: {err}"))?;
|
||||||
|
|
||||||
|
// `password-hash` 的解析器很宽松:`$argon2id$broken` 这种**截断**的字符串也能解析
|
||||||
|
// 成功,只是解析出来的对象里没有盐、没有摘要。于是所有校验都失败,被 `is_ok()`
|
||||||
|
// 读成"密码错了"——现场表现是"密码明明是对的,却一直说密码错",然后所有人开始
|
||||||
|
// 怀疑键盘。所以这里显式要求盐与摘要都在。
|
||||||
|
if parsed.salt.is_none() || parsed.hash.is_none() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"the stored password hash is incomplete (no salt or no digest): {phc:?}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查长度下限。
|
||||||
|
pub fn check_strength(password: &str) -> Result<()> {
|
||||||
|
if password.chars().count() < MIN_PASSWORD_LEN {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"密码至少 {MIN_PASSWORD_LEN} 位(现在是 {} 位)",
|
||||||
|
password.chars().count()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_password_verifies_against_its_own_hash() {
|
||||||
|
let phc = hash_password("1234").expect("hashes");
|
||||||
|
assert!(verify_password("1234", &phc).expect("verifies"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_wrong_password_is_false_not_an_error() {
|
||||||
|
let phc = hash_password("1234").expect("hashes");
|
||||||
|
|
||||||
|
assert!(!verify_password("1235", &phc).expect("the hash itself is fine"));
|
||||||
|
assert!(!verify_password("", &phc).expect("the hash itself is fine"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_same_password_hashes_differently_every_time() {
|
||||||
|
// 随机盐:否则配置文件里的哈希就是一个可以拿去查表的指纹。
|
||||||
|
let a = hash_password("1234").expect("hashes");
|
||||||
|
let b = hash_password("1234").expect("hashes");
|
||||||
|
|
||||||
|
assert_ne!(a, b, "a fresh salt each time");
|
||||||
|
assert!(verify_password("1234", &a).unwrap());
|
||||||
|
assert!(verify_password("1234", &b).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_hash_is_argon2id_and_carries_its_parameters() {
|
||||||
|
// 参数写在 PHC 字符串里,所以以后调参数不需要迁移旧配置。
|
||||||
|
let phc = hash_password("1234").expect("hashes");
|
||||||
|
assert!(phc.starts_with("$argon2id$"), "got {phc}");
|
||||||
|
assert!(phc.contains("$v=19$"), "got {phc}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_known_phc_string_still_verifies() {
|
||||||
|
// 一条固定的 Argon2id PHC 串,密码是 1234。
|
||||||
|
//
|
||||||
|
// 作用是把"已经写进某个人的配置文件里的哈希,将来还能不能验证"钉下来:换库、
|
||||||
|
// 换默认参数、或者手滑把 salt 的编码改成别的形式时,这条会立刻失败——而它失败的
|
||||||
|
// 那一天,现场的表现会是"密码明明对,却一直说密码错"。
|
||||||
|
const PHC: &str = "$argon2id$v=19$m=19456,t=2,p=1$rBxlGFBYgghR/PPmKlx++A$B7J4JeKgDzv4JdNycjf7rb+5UPgO9JY+KhURaZtn5R0";
|
||||||
|
|
||||||
|
assert!(verify_password("1234", PHC).expect("verifies"));
|
||||||
|
assert!(!verify_password("1235", PHC).expect("the hash itself is fine"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_corrupt_stored_hash_is_an_error_not_a_wrong_password() {
|
||||||
|
// 这两种情况必须能分开:一个是输错了,一个是配置文件坏了。
|
||||||
|
assert!(verify_password("1234", "not a phc string").is_err());
|
||||||
|
assert!(verify_password("1234", "$argon2id$broken").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_too_short_password_is_refused_at_set_time() {
|
||||||
|
assert!(hash_password("123").is_err());
|
||||||
|
assert!(hash_password("").is_err());
|
||||||
|
assert!(hash_password("1234").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_ascii_password_works() {
|
||||||
|
let phc = hash_password("口令甲乙").expect("hashes");
|
||||||
|
assert!(verify_password("口令甲乙", &phc).unwrap());
|
||||||
|
assert!(!verify_password("口令甲丙", &phc).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,856 @@
|
|||||||
|
//! 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,
|
||||||
|
pub firmware: u16,
|
||||||
|
/// 最近一次收到它广播的时刻。
|
||||||
|
pub last_seen: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CameraInfo {
|
||||||
|
/// 多久没有广播了。
|
||||||
|
pub fn silence(&self) -> Duration {
|
||||||
|
self.last_seen.elapsed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 板子在线吗?
|
||||||
|
///
|
||||||
|
/// 阈值是广播间隔的数倍,所以偶发丢包不会让界面上的在线状态闪烁。
|
||||||
|
pub fn is_online(&self) -> bool {
|
||||||
|
self.silence() < Duration::from_secs(7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一块板的运行时统计。
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct SourceStats {
|
||||||
|
pub frames_shown: u64,
|
||||||
|
/// 因分片缺失被放弃的帧。
|
||||||
|
pub frames_dropped: u64,
|
||||||
|
pub datagrams: u64,
|
||||||
|
pub last_frame_len: usize,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
/// 上一帧到这一帧之间的间隔。
|
||||||
|
pub last_interval: Option<Duration>,
|
||||||
|
last_shown_at: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceStats {
|
||||||
|
/// 丢弃的帧占已收到帧的比例。
|
||||||
|
pub fn loss_percent(&self) -> f32 {
|
||||||
|
let total = self.frames_shown + self.frames_dropped;
|
||||||
|
if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.frames_dropped as f32 * 100.0 / total as f32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上一帧到这一帧的间隔,毫秒。
|
||||||
|
pub fn gap_ms(&self) -> Option<f32> {
|
||||||
|
self.last_interval.map(|d| d.as_secs_f32() * 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_frame(&mut self, width: u16, height: u16, len: usize) {
|
||||||
|
self.last_interval = self.last_shown_at.map(|t| t.elapsed());
|
||||||
|
self.last_shown_at = Some(Instant::now());
|
||||||
|
self.frames_shown += 1;
|
||||||
|
self.width = width;
|
||||||
|
self.height = height;
|
||||||
|
self.last_frame_len = len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 每一路的**最新**一帧。
|
||||||
|
///
|
||||||
|
/// 每路只保留最新的一张:这张图有两个用途,都不需要历史——截图(存盘的那一张应该
|
||||||
|
/// 是按下按钮时看到的画面)和慢速客户端的补帧。真正的实时画面走
|
||||||
|
/// [`Receiver::set_frame_sink`] 的推送路径,不从这里取。
|
||||||
|
///
|
||||||
|
/// 用 `Arc` 而不是 `Vec<u8>`:同一张帧要同时给 sink 和这个缓存,克隆一个 22 kB 的
|
||||||
|
/// 缓冲区是每秒 22 次不必要的分配。
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct LatestFrames {
|
||||||
|
inner: Mutex<HashMap<u32, Arc<protocol::Frame>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LatestFrames {
|
||||||
|
pub fn put(&self, frame: Arc<protocol::Frame>) {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.expect("latest-frames mutex poisoned")
|
||||||
|
.insert(frame.sender_id, frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取某一路的最新帧(克隆的是 `Arc`,不是 JPEG)。
|
||||||
|
pub fn latest(&self, sender_id: u32) -> Option<Arc<protocol::Frame>> {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.expect("latest-frames mutex poisoned")
|
||||||
|
.get(&sender_id)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 所有已知来源的最新帧,按 id 排序,让界面上的顺序稳定。
|
||||||
|
pub fn latest_all(&self) -> Vec<Arc<protocol::Frame>> {
|
||||||
|
let mut list: Vec<Arc<protocol::Frame>> = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("latest-frames mutex poisoned")
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
list.sort_by_key(|f| f.sender_id);
|
||||||
|
list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帧的消费者。
|
||||||
|
///
|
||||||
|
/// 在接收线程里被调用,所以**必须是非阻塞的**:一次 `send` 到一个有界广播通道、
|
||||||
|
/// 一次 `try_send`,都可以;写文件、等锁、发网络请求都不行——那会把接收循环拖慢,
|
||||||
|
/// 表现为链路上本不存在的丢帧。
|
||||||
|
pub type FrameSink = Arc<dyn Fn(Arc<protocol::Frame>) + Send + Sync>;
|
||||||
|
|
||||||
|
/// 所有板子的共享状态,跨线程只读。
|
||||||
|
pub struct Discovery {
|
||||||
|
cameras: Mutex<HashMap<u32, CameraInfo>>,
|
||||||
|
stats: Mutex<HashMap<u32, SourceStats>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Discovery {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
cameras: Mutex::new(HashMap::new()),
|
||||||
|
stats: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前已知的板子,按 id 排序。
|
||||||
|
pub fn cameras(&self) -> Vec<CameraInfo> {
|
||||||
|
let mut list: Vec<CameraInfo> = self
|
||||||
|
.cameras
|
||||||
|
.lock()
|
||||||
|
.expect("cameras mutex poisoned")
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
list.sort_by_key(|c| c.id);
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在线且刚刚广播过的板子。
|
||||||
|
pub fn online_cameras(&self) -> Vec<CameraInfo> {
|
||||||
|
self.cameras()
|
||||||
|
.into_iter()
|
||||||
|
.filter(CameraInfo::is_online)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某一路的统计。
|
||||||
|
pub fn stats(&self, sender_id: u32) -> SourceStats {
|
||||||
|
self.stats
|
||||||
|
.lock()
|
||||||
|
.expect("stats mutex poisoned")
|
||||||
|
.get(&sender_id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只刷新存活时间戳,不改地址或固件版本。
|
||||||
|
///
|
||||||
|
/// 收到视频帧时调用:正在推流本身就是最可靠的存活证据。
|
||||||
|
fn touch(&self, id: u32) {
|
||||||
|
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
|
||||||
|
|
||||||
|
if let Some(info) = cameras.get_mut(&id) {
|
||||||
|
info.last_seen = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_hello(&self, id: u32, addr: SocketAddrV4, firmware: u16) -> bool {
|
||||||
|
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
|
||||||
|
|
||||||
|
match cameras.get_mut(&id) {
|
||||||
|
Some(info) => {
|
||||||
|
info.addr = addr;
|
||||||
|
info.last_seen = Instant::now();
|
||||||
|
info.firmware = firmware;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
cameras.insert(
|
||||||
|
id,
|
||||||
|
CameraInfo {
|
||||||
|
id,
|
||||||
|
addr,
|
||||||
|
firmware,
|
||||||
|
last_seen: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_frame(&self, id: u32, width: u16, height: u16, len: usize) {
|
||||||
|
let mut stats = self.stats.lock().expect("stats mutex poisoned");
|
||||||
|
stats.entry(id).or_default().on_frame(width, height, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(&self, id: u32, f: impl FnOnce(&mut SourceStats)) {
|
||||||
|
let mut stats = self.stats.lock().expect("stats mutex poisoned");
|
||||||
|
f(stats.entry(id).or_default());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 超过 `max_idle` 没有广播的板子从列表里移除。
|
||||||
|
///
|
||||||
|
/// 界面上不会留下一堆永远离线的板子。
|
||||||
|
pub fn forget_silent_cameras(&self, max_idle: Duration) {
|
||||||
|
self.cameras
|
||||||
|
.lock()
|
||||||
|
.expect("cameras mutex poisoned")
|
||||||
|
.retain(|_, c| c.silence() < max_idle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 客户端身份。
|
||||||
|
///
|
||||||
|
/// 旧客户端使用的固定标识,保留用于兼容判断。
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.discovery.note_hello(hello.sender_id, v4, hello.firmware) {
|
||||||
|
log::info!("found camera 0x{:08X} at {v4}", hello.sender_id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(frag) = Fragment::decode(bytes) else {
|
||||||
|
// 既不是 Hello 也不是合法分片:将来的其它消息,或一次损坏。
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = frag.header.sender_id;
|
||||||
|
|
||||||
|
// 帧的重组与投递过的是**另一道**闸门:发现列表里能看到对手的板子,不等于
|
||||||
|
// 它的画面会进来。
|
||||||
|
if let Some(only) = limit {
|
||||||
|
if id != only {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频帧也算存活证据。
|
||||||
|
//
|
||||||
|
// `is_online` 看的是 `last_seen`,而它此前只由 Hello 更新。于是会出现一个
|
||||||
|
// 荒唐的状态:画面在每秒刷 22 帧,状态栏却显示"离线"(橙色)。正在推流的板子
|
||||||
|
// 比广播更可靠地证明自己活着。
|
||||||
|
self.discovery.touch(id);
|
||||||
|
|
||||||
|
let mut ingest = self.ingest.lock().expect("ingest mutex poisoned");
|
||||||
|
let assembler = ingest
|
||||||
|
.assemblers
|
||||||
|
.entry(id)
|
||||||
|
.or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT));
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
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, firmware: 7 };
|
||||||
|
// 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,701 @@
|
|||||||
|
//! 推给界面的快照类型。
|
||||||
|
//!
|
||||||
|
//! # 为什么单独一个模块
|
||||||
|
//!
|
||||||
|
//! 这些结构体的字段名就是**界面看到的 JSON 字段名**,所以它们是接口的一部分,值得
|
||||||
|
//! 集中放、集中改。放在这里而不是 Tauri 层,是因为其中几个的构造规则(比如"关掉
|
||||||
|
//! 诊断时不留数字")需要单元测试。
|
||||||
|
//!
|
||||||
|
//! # 为什么 id 同时给数字和文本
|
||||||
|
//!
|
||||||
|
//! 界面显示、日志、配置文件里用的都是 `0x3D4D8A9A` 这种十六进制形式。让 Rust 侧拼好
|
||||||
|
//! (`id_text`) 而不是让前端各处自己 `toString(16)`,可以避免"某处少补了前导零"这种
|
||||||
|
//! 只能靠肉眼比对才发现的不一致。
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::armor::ArmorBoard;
|
||||||
|
use crate::config::{Config, CrosshairConfig, GridConfig, HealthConfig, HitFeedbackConfig, Role};
|
||||||
|
use crate::receiver::{CameraInfo, SourceStats};
|
||||||
|
|
||||||
|
/// 把 id 写成界面上、配置文件里、抓包时都一致的形式。
|
||||||
|
pub fn id_text(id: u32) -> String {
|
||||||
|
format!("0x{id:08X}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一块图传板。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CameraDto {
|
||||||
|
pub id: u32,
|
||||||
|
pub id_text: String,
|
||||||
|
pub addr: String,
|
||||||
|
pub online: bool,
|
||||||
|
pub silence_ms: u64,
|
||||||
|
pub firmware: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&CameraInfo> for CameraDto {
|
||||||
|
fn from(camera: &CameraInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
id: camera.id,
|
||||||
|
id_text: id_text(camera.id),
|
||||||
|
addr: camera.addr.ip().to_string(),
|
||||||
|
online: camera.is_online(),
|
||||||
|
silence_ms: camera.silence().as_millis() as u64,
|
||||||
|
firmware: camera.firmware,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一块装甲板主板。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ArmorBoardDto {
|
||||||
|
pub id: u32,
|
||||||
|
pub id_text: String,
|
||||||
|
pub addr: String,
|
||||||
|
pub online: bool,
|
||||||
|
/// 板子自报的队伍(编译期定的 `ARMOR_TEAM`)。
|
||||||
|
pub team: u8,
|
||||||
|
/// 最后一次读数。整车一条血,所以这里一个数。
|
||||||
|
pub hp: u16,
|
||||||
|
pub plates_seen: u8,
|
||||||
|
pub reports_all_plates: bool,
|
||||||
|
pub silence_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ArmorBoard> for ArmorBoardDto {
|
||||||
|
fn from(board: &ArmorBoard) -> Self {
|
||||||
|
Self {
|
||||||
|
id: board.id,
|
||||||
|
id_text: id_text(board.id),
|
||||||
|
addr: board.addr.ip().to_string(),
|
||||||
|
online: board.is_online(),
|
||||||
|
team: board.team,
|
||||||
|
hp: board.hp,
|
||||||
|
plates_seen: board.plates_seen.count_ones() as u8,
|
||||||
|
reports_all_plates: board.reports_all_plates(),
|
||||||
|
silence_ms: board.silence().as_millis() as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一路画面的读数。
|
||||||
|
///
|
||||||
|
/// # 关掉诊断时这些数字**不存在**,而不是被界面藏起来
|
||||||
|
///
|
||||||
|
/// 旧客户端在队伍端 release 下把这些字段填成空字符串。现在换成数字,规则不变:非诊断
|
||||||
|
/// 模式下它们被置零/置空。由编译模式决定而不是命令行参数——命令行开关意味着二进制里
|
||||||
|
/// 带着那套东西,忘开一次就泄露了。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CameraStatsDto {
|
||||||
|
pub camera: u32,
|
||||||
|
pub camera_text: String,
|
||||||
|
pub online: bool,
|
||||||
|
/// 界面直接显示这一行,所以"在线但还没画面"与"离线"的区别由这里给出,而不是让
|
||||||
|
/// 前端从 online + hasFrames 猜。
|
||||||
|
pub status: String,
|
||||||
|
pub has_frames: bool,
|
||||||
|
pub fps: f32,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
pub frames_shown: u64,
|
||||||
|
pub frames_dropped: u64,
|
||||||
|
pub loss_percent: f32,
|
||||||
|
pub datagrams: u64,
|
||||||
|
pub last_frame_bytes: usize,
|
||||||
|
pub gap_ms: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CameraStatsDto {
|
||||||
|
/// `diagnostics` 为 false 时不产出技术数字(见类型文档)。
|
||||||
|
pub fn build(
|
||||||
|
diagnostics: bool,
|
||||||
|
camera: u32,
|
||||||
|
online: bool,
|
||||||
|
stats: &SourceStats,
|
||||||
|
fps: f32,
|
||||||
|
) -> Self {
|
||||||
|
let has_frames = stats.frames_shown > 0;
|
||||||
|
|
||||||
|
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
|
||||||
|
// 比让人以为板子挂了诚实。
|
||||||
|
//
|
||||||
|
// 有画面时显示 id —— 这对调试有用。队伍端 release 下这一路只有自己绑定的板子,
|
||||||
|
// 显示它不构成泄露(界面上根本没有别人的板子可选)。
|
||||||
|
let status = if !has_frames {
|
||||||
|
if online {
|
||||||
|
"已连接,等待画面…".to_string()
|
||||||
|
} else {
|
||||||
|
"离线".to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
id_text(camera)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !diagnostics {
|
||||||
|
return Self {
|
||||||
|
camera,
|
||||||
|
camera_text: id_text(camera),
|
||||||
|
online,
|
||||||
|
status,
|
||||||
|
has_frames,
|
||||||
|
fps: 0.0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
frames_shown: 0,
|
||||||
|
frames_dropped: 0,
|
||||||
|
loss_percent: 0.0,
|
||||||
|
datagrams: 0,
|
||||||
|
last_frame_bytes: 0,
|
||||||
|
gap_ms: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没出过一个字节的源,`stats.width`/`height` 还是默认值 0,于是界面会显示成
|
||||||
|
// "0x0"——一个看起来像分辨率的假分辨率。用 has_frames 把它挡掉。
|
||||||
|
Self {
|
||||||
|
camera,
|
||||||
|
camera_text: id_text(camera),
|
||||||
|
online,
|
||||||
|
status,
|
||||||
|
has_frames,
|
||||||
|
fps: if has_frames { fps } else { 0.0 },
|
||||||
|
width: if has_frames { stats.width } else { 0 },
|
||||||
|
height: if has_frames { stats.height } else { 0 },
|
||||||
|
frames_shown: stats.frames_shown,
|
||||||
|
frames_dropped: stats.frames_dropped,
|
||||||
|
loss_percent: stats.loss_percent(),
|
||||||
|
datagrams: stats.datagrams,
|
||||||
|
last_frame_bytes: stats.last_frame_len,
|
||||||
|
gap_ms: stats.gap_ms(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一边的血量。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TeamHealthDto {
|
||||||
|
pub team: u8,
|
||||||
|
pub name: String,
|
||||||
|
/// 有没有可信的数据。false 时界面显示 `placeholder` 而不是 0——0 是一个看起来像
|
||||||
|
/// 真值的假值,现场看到 0 会以为对方已经没血了。
|
||||||
|
pub available: bool,
|
||||||
|
pub placeholder: String,
|
||||||
|
/// 整车血量(四条装甲板同值,所以取第一条)。
|
||||||
|
pub hp: Option<u16>,
|
||||||
|
/// 四条装甲板各自的读数;`available` 为 true 时有效。
|
||||||
|
pub hps: Vec<u16>,
|
||||||
|
/// 绑定的装甲板主板 id(用户端)/ 分配到的(裁判端)。
|
||||||
|
pub board: Option<u32>,
|
||||||
|
pub board_text: Option<String>,
|
||||||
|
/// 血量偏低或已归零。
|
||||||
|
pub critical: bool,
|
||||||
|
/// 这一边的胜负结论,由 [`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,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>cam-desktop</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "cam-desktop",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "ESP32 图传与装甲板系统的桌面端:用户端(血条 + 瞄准器)与裁判端(双路 + 装甲板 + 录制)",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"app": "tauri dev",
|
||||||
|
"check": "tsc --noEmit",
|
||||||
|
"test:stream": "node --test scripts/test-stream.mjs",
|
||||||
|
"test:latency": "node --test scripts/test-latency-marker.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"react": "^19",
|
||||||
|
"react-dom": "^19"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^5",
|
||||||
|
"typescript": "^5.9",
|
||||||
|
"vite": "^7"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1295
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
|||||||
|
# pnpm 11 不再读取 package.json 里的 `pnpm` 字段,设置搬到了这里。
|
||||||
|
# 这个文件是 pnpm 自己在遇到被忽略的构建脚本时生成的。
|
||||||
|
#
|
||||||
|
# esbuild 的 postinstall 是它把自己平台的二进制接好的那一步。不允许它跑,`pnpm exec`
|
||||||
|
# 每次都会以 ERR_PNPM_IGNORED_BUILDS 退出 1 —— 即使 vite 本身还能跑,这个非零退出码
|
||||||
|
# 也会让 `tauri dev` 的 beforeDevCommand 失败。所以这里显式放行。
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 这个目录里的一切都用宿主机的稳定版工具链构建。
|
||||||
|
#
|
||||||
|
# 仓库根目录的 rust-toolchain.toml 钉的是 `esp`(带 Xtensa 支持的定制工具链),
|
||||||
|
# 那是给图传板与装甲板固件用的。桌面端不需要它,而且 Tauri 依赖里的若干 crate
|
||||||
|
# 要求较新的稳定版。rustup 取**最近的**那一个 rust-toolchain.toml,所以这个文件
|
||||||
|
# 就是 desktop/ 下面所有 cargo 调用的实际选择。
|
||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Observe the native client's local video channel without adding a UDP peer.
|
||||||
|
// Read the token internally from a deliberately enabled debug log; never print it.
|
||||||
|
import { readFile, writeFile, appendFile, mkdir, access } from 'node:fs/promises';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
|
||||||
|
const args = Object.fromEntries(process.argv.slice(2).reduce((pairs, value, i, all) => {
|
||||||
|
if (i % 2 === 0) pairs.push([value.replace(/^--/, ''), all[i+1]]);
|
||||||
|
return pairs;
|
||||||
|
}, []));
|
||||||
|
if (!args.log || !args.out) throw new Error('Use --log <backend-debug-log> --out <output-prefix> [--seconds 1800] [--warmup 30]');
|
||||||
|
const seconds = Number(args.seconds ?? 300);
|
||||||
|
const warmup = Number(args.warmup ?? 10);
|
||||||
|
const sampleEveryMs = 1000*Number(args['sample-every'] ?? 0);
|
||||||
|
const camera = Number(args.camera ?? '0x3D4D8A9A');
|
||||||
|
await mkdir(dirname(args.out), { recursive: true });
|
||||||
|
const log = await readFile(args.log, 'utf8');
|
||||||
|
const url = [...log.matchAll(/ws:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+/g)].at(-1)?.[0];
|
||||||
|
if (!url) throw new Error('No local video-channel URL in the debug log');
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
let latest = null, demo = true, diagnostics = false, clients = 0, connected = false;
|
||||||
|
let binary = 0, invalid = 0, lastArrival = null, gaps = [], bytes = 0;
|
||||||
|
let firstFrame = null;
|
||||||
|
let sampleCount = 0, nextSampleAt = 0;
|
||||||
|
const allGaps = [], windows = [];
|
||||||
|
let measurementStarted = null, previousAt = null, previousStats = null;
|
||||||
|
let windowBinary = 0, windowInvalid = 0, windowBytes = 0;
|
||||||
|
let totalFrames = 0, totalDropped = 0, counterResets = 0, socketCloses = 0;
|
||||||
|
let offlineSnapshots = 0;
|
||||||
|
let diagnosticsUnavailableSnapshots = 0;
|
||||||
|
let finished = false;
|
||||||
|
ws.addEventListener('close', () => { connected = false; if (!finished) socketCloses++; });
|
||||||
|
ws.addEventListener('message', event => {
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.t === 'snapshot') {
|
||||||
|
latest = msg.stats.find(stat => stat.camera === camera) ?? null;
|
||||||
|
demo = msg.demo;
|
||||||
|
diagnostics = msg.diagnostics;
|
||||||
|
clients = msg.clients;
|
||||||
|
if (measurementStarted !== null && (!latest || !latest.online)) offlineSnapshots++;
|
||||||
|
if (measurementStarted !== null && !diagnostics) diagnosticsUnavailableSnapshots++;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = Buffer.from(event.data);
|
||||||
|
if (data.length < 16 || data.readUInt32LE(0) !== camera) return;
|
||||||
|
if (measurementStarted === null) return;
|
||||||
|
const valid = data.length === data.readUInt32LE(12)+16 && data[16] === 255 && data[17] === 216 && data.at(-2) === 255 && data.at(-1) === 217;
|
||||||
|
if (!valid) { invalid++; windowInvalid++; return; }
|
||||||
|
const now = performance.now();
|
||||||
|
if (lastArrival !== null) { gaps.push(now-lastArrival); allGaps.push(now-lastArrival); }
|
||||||
|
lastArrival = now;
|
||||||
|
binary++; windowBinary++;
|
||||||
|
bytes += data.length-16; windowBytes += data.length-16;
|
||||||
|
if (firstFrame === null) {
|
||||||
|
firstFrame = Buffer.from(data.subarray(16));
|
||||||
|
void writeFile(`${args.out}.first.jpg`, firstFrame).catch(error => console.error('Could not save sample JPEG:', error.message));
|
||||||
|
}
|
||||||
|
if (sampleEveryMs > 0 && now >= nextSampleAt) {
|
||||||
|
nextSampleAt = now+sampleEveryMs;
|
||||||
|
const path = `${args.out}.sample-${String(++sampleCount).padStart(4,'0')}.jpg`;
|
||||||
|
void writeFile(path, data.subarray(16)).catch(error => console.error('Could not save diagnostic JPEG:', error.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.race([
|
||||||
|
new Promise((resolve, reject) => { ws.addEventListener('open', () => { connected = true; resolve(); }, { once:true }); ws.addEventListener('error', reject, { once:true }); }),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Video channel open timeout')), 15_000)),
|
||||||
|
]);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, warmup*1000));
|
||||||
|
if (demo || !latest?.online || !latest?.hasFrames || clients < 2) throw new Error('Native GUI and online real hardware video are required before starting the soak');
|
||||||
|
if (!diagnostics) throw new Error('Receiver counters require referee diagnostics; team release mode hides them');
|
||||||
|
measurementStarted = previousAt = performance.now();
|
||||||
|
previousStats = latest;
|
||||||
|
await writeFile(`${args.out}.windows.ndjson`, '');
|
||||||
|
|
||||||
|
function percentile(values, p) {
|
||||||
|
if (!values.length) return null;
|
||||||
|
const sorted = [...values].sort((a,b) => a-b);
|
||||||
|
return Number(sorted[Math.floor((sorted.length-1)*p)].toFixed(3));
|
||||||
|
}
|
||||||
|
async function sample() {
|
||||||
|
const now = performance.now();
|
||||||
|
const elapsed = (now-previousAt)/1000;
|
||||||
|
let complete = 0, dropped = 0;
|
||||||
|
if (latest && previousStats) {
|
||||||
|
if (latest.framesShown < previousStats.framesShown || latest.framesDropped < previousStats.framesDropped) counterResets++;
|
||||||
|
else { complete = latest.framesShown-previousStats.framesShown; dropped = latest.framesDropped-previousStats.framesDropped; }
|
||||||
|
}
|
||||||
|
totalFrames += complete; totalDropped += dropped;
|
||||||
|
const row = {
|
||||||
|
utc:new Date().toISOString(), elapsedSeconds:(now-measurementStarted)/1000, windowSeconds:elapsed,
|
||||||
|
completeFrames:complete, framesDropped:dropped, receiverFps:complete/elapsed,
|
||||||
|
frameLossPercent:100*dropped/Math.max(1,complete+dropped),
|
||||||
|
binaryFrames:windowBinary, channelFps:windowBinary/elapsed, invalidJpegs:windowInvalid,
|
||||||
|
meanJpegBytes:windowBinary ? windowBytes/windowBinary : 0,
|
||||||
|
gapP95Ms:percentile(gaps,.95), gapMaxMs:Math.max(0,...gaps, lastArrival===null ? (now-measurementStarted) : now-lastArrival),
|
||||||
|
cameraOnline:latest?.online ?? false, frontendConnected:clients>=2, observerConnected:connected,
|
||||||
|
};
|
||||||
|
windows.push(row);
|
||||||
|
await appendFile(`${args.out}.windows.ndjson`, JSON.stringify(row)+'\n');
|
||||||
|
await writeFile(`${args.out}.latest.json`, JSON.stringify(row,null,2)+'\n');
|
||||||
|
console.log(JSON.stringify(row));
|
||||||
|
previousAt = now;
|
||||||
|
previousStats = latest;
|
||||||
|
windowBinary = windowInvalid = windowBytes = 0;
|
||||||
|
gaps = [];
|
||||||
|
}
|
||||||
|
let stoppedByRequest = false;
|
||||||
|
while ((performance.now()-measurementStarted)/1000 < seconds) {
|
||||||
|
if (args['stop-file'] && await access(args['stop-file']).then(() => true, () => false)) {
|
||||||
|
stoppedByRequest = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const remaining = seconds-(performance.now()-measurementStarted)/1000;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, Math.min(1,remaining)*1000));
|
||||||
|
if (performance.now()-previousAt >= 60_000 || (performance.now()-measurementStarted)/1000 >= seconds) await sample();
|
||||||
|
}
|
||||||
|
if (stoppedByRequest && performance.now()-previousAt > 1_000) await sample();
|
||||||
|
finished = true;
|
||||||
|
ws.close();
|
||||||
|
const actualSeconds = (performance.now()-measurementStarted)/1000;
|
||||||
|
const receiverMetricsValid = counterResets === 0 && diagnosticsUnavailableSnapshots === 0;
|
||||||
|
const summary = {
|
||||||
|
actualSeconds, requestedSeconds:seconds, stoppedByRequest, warmupSeconds:warmup, camera, demo, windows, binaryFrames:binary,
|
||||||
|
invalidJpegs:invalid, receiverMetricsValid,
|
||||||
|
completeFrames:receiverMetricsValid ? totalFrames : null, framesDropped:receiverMetricsValid ? totalDropped : null,
|
||||||
|
rawWindowCountedCompleteFrames:totalFrames, rawWindowCountedDroppedFrames:totalDropped,
|
||||||
|
receiverFps:receiverMetricsValid ? totalFrames/actualSeconds : null, channelFps:binary/actualSeconds,
|
||||||
|
frameLossPercent:receiverMetricsValid ? 100*totalDropped/Math.max(1,totalFrames+totalDropped) : null,
|
||||||
|
gapP95Ms:percentile(allGaps,.95), gapMaxMs:Math.max(0,...allGaps),
|
||||||
|
meanJpegBytes:binary ? bytes/binary : 0, counterResets, socketCloses,
|
||||||
|
offlineSnapshots,
|
||||||
|
diagnosticsUnavailableSnapshots,
|
||||||
|
diagnosticSamplesSaved:sampleCount,
|
||||||
|
allWindowsOnline:windows.every(window => window.cameraOnline),
|
||||||
|
zeroVideoWindows:windows.filter(window => window.binaryFrames === 0).length,
|
||||||
|
noVideoSecondsAtEnd:lastArrival === null ? actualSeconds : (performance.now()-lastArrival)/1000,
|
||||||
|
maxObservedSilenceMs:Math.max(0,...windows.map(window => window.gapMaxMs)),
|
||||||
|
frontendConnected:clients>=2,
|
||||||
|
measurementScope:'Native receiver and local JPEG WebSocket channel; not optical display latency',
|
||||||
|
};
|
||||||
|
await writeFile(`${args.out}.summary.json`, JSON.stringify(summary,null,2)+'\n');
|
||||||
|
if (firstFrame) await writeFile(`${args.out}.first.jpg`, firstFrame);
|
||||||
|
console.log(JSON.stringify({ ...summary, windows:windows.length }));
|
||||||
|
if (demo || !binary || invalid || socketCloses || counterResets || offlineSnapshots || diagnosticsUnavailableSnapshots) process.exitCode = 1;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {readFileSync} from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {test} from 'node:test';
|
||||||
|
import ts from 'typescript';
|
||||||
|
const source=readFileSync(new URL('../src/lib/latency-marker.ts',import.meta.url),'utf8');
|
||||||
|
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||||
|
const exports={};new Function('exports',code)(exports);
|
||||||
|
const {drawMarker,decodeMarker}=exports;
|
||||||
|
function image(counter,w=320,h=240) {
|
||||||
|
const pixels=new Uint8ClampedArray(w*h*4);
|
||||||
|
const ctx={canvas:{width:w,height:h},fillStyle:'',fillRect(x,y,width,height){
|
||||||
|
const c=this.fillStyle.startsWith('rgb(')?this.fillStyle.slice(4,-1).split(',').map(Number):
|
||||||
|
this.fillStyle==='#fff'?[255,255,255]:this.fillStyle==='#000'?[0,0,0]:[60,60,60];
|
||||||
|
for(let yy=Math.max(0,Math.ceil(y));yy<Math.min(h,y+height);yy++)
|
||||||
|
for(let xx=Math.max(0,Math.ceil(x));xx<Math.min(w,x+width);xx++)pixels.set([...c,255],(yy*w+xx)*4);
|
||||||
|
}};
|
||||||
|
drawMarker(ctx,counter);return pixels;
|
||||||
|
}
|
||||||
|
test('screen IDs decode at Gray-code boundaries and across counter wrap',()=>{
|
||||||
|
for(const id of [0,1,2,3,63,127,1023,2047,2048,4094,4095,4096]) {
|
||||||
|
assert.equal(decodeMarker(image(id),320,240)?.counter,id&4095);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('camera rotation and reflection preserve the observed ID',()=>{
|
||||||
|
const original=image(1371),rotated=new Uint8ClampedArray(original.length),flipped=new Uint8ClampedArray(original.length);
|
||||||
|
for(let y=0;y<240;y++)for(let x=0;x<320;x++){
|
||||||
|
rotated.set(original.subarray((y*320+x)*4,(y*320+x)*4+4),(x*240+239-y)*4);
|
||||||
|
flipped.set(original.subarray((y*320+x)*4,(y*320+x)*4+4),(y*320+319-x)*4);
|
||||||
|
}
|
||||||
|
assert.equal(decodeMarker(rotated,240,320)?.counter,1371);
|
||||||
|
assert.equal(decodeMarker(flipped,320,240)?.counter,1371);
|
||||||
|
});
|
||||||
|
test('missing fiducials and ambiguous rolling transitions do not invent latency samples',()=>{
|
||||||
|
assert.equal(decodeMarker(new Uint8ClampedArray(320*240*4),320,240),null);
|
||||||
|
const pixels=image(123);
|
||||||
|
for(let y=90;y<152;y++)for(let x=44;x<276;x++)pixels.set([128,128,128,255],(y*320+x)*4);
|
||||||
|
assert.equal(decodeMarker(pixels,320,240),null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('washed screen primaries survive without mistaking a blue-tinted background',()=>{
|
||||||
|
const pixels=image(1371);
|
||||||
|
for(let i=0;i<pixels.length;i+=4){
|
||||||
|
const r=pixels[i],g=pixels[i+1],b=pixels[i+2];
|
||||||
|
if(r===32&&g===255&&b===32)pixels.set([190,255,208,255],i);
|
||||||
|
else if(r===32&&g===96&&b===255)pixels.set([78,205,255,255],i);
|
||||||
|
else if(r===255&&g===32&&b===255)pixels.set([255,112,255,255],i);
|
||||||
|
else if(r===60&&g===60&&b===60)pixels.set([165,181,225,255],i);
|
||||||
|
}
|
||||||
|
assert.equal(decodeMarker(pixels,320,240)?.counter,1371);
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Exercise the production StreamClient against native hardware video, while
|
||||||
|
// deliberately stalling only this auxiliary local consumer. Adds no UDP peer.
|
||||||
|
// Bitmap decoding is stubbed here; actual JPEG decoding is checked separately.
|
||||||
|
import {readFileSync,writeFileSync} from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
const [logPath,outPath]=process.argv.slice(2);
|
||||||
|
if(!logPath||!outPath)throw new Error('Use <private native debug log> <result JSON>');
|
||||||
|
const log=readFileSync(logPath,'utf8');
|
||||||
|
const url=[...log.matchAll(/ws:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+/g)].at(-1)?.[0];
|
||||||
|
if(!url)throw new Error('No local video URL in the private debug log');
|
||||||
|
const source=readFileSync(new URL('../src/lib/stream.ts',import.meta.url),'utf8');
|
||||||
|
const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||||
|
const exports={};new Function('require','exports',compiled)(()=>({FRAME_HEADER_LEN:16}),exports);
|
||||||
|
globalThis.window={setTimeout,clearTimeout};
|
||||||
|
globalThis.createImageBitmap=async()=>({close(){}});
|
||||||
|
const rx=new exports.StreamClient({onMessage(){},onConnection(){}});
|
||||||
|
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||||
|
try {
|
||||||
|
rx.connect(url);
|
||||||
|
const deadline=performance.now()+10_000;
|
||||||
|
while(rx.counters.frames<10&&performance.now()<deadline)await delay(30);
|
||||||
|
assert.ok(rx.counters.frames>=10,'Real native JPEG video did not arrive');
|
||||||
|
const camera=[...rx.frames.keys()][0];
|
||||||
|
const before=rx.latest(camera)?.frameId;
|
||||||
|
const staleBefore=rx.counters.staleFrames;
|
||||||
|
const blockedAt=performance.now();
|
||||||
|
while(performance.now()-blockedAt<350) { /* synthetic consumer stall */ }
|
||||||
|
await delay(700);
|
||||||
|
const latest=rx.latest(camera);
|
||||||
|
assert.ok(rx.counters.staleFrames>staleBefore,'Stale native-timestamped frames were not rejected');
|
||||||
|
assert.ok(latest&&latest.frameId!==before,'The consumer did not recover to new video');
|
||||||
|
assert.ok(performance.now()-latest.receivedAt<150,'Recovered image is still stale locally');
|
||||||
|
const result={passed:true,stall_ms:350,stale_frames_rejected:rx.counters.staleFrames-staleBefore,
|
||||||
|
frame_before:before,frame_after:latest.frameId,frames_received:rx.counters.frames,
|
||||||
|
additional_udp_subscribers:0,jpeg_decoder_stubbed:true,
|
||||||
|
scope:'Production StreamClient over real native WebSocket; tests queued-frame age and recovery, not physical display latency'};
|
||||||
|
writeFileSync(outPath,JSON.stringify(result,null,2)+'\n');
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
} finally {rx.close();}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('../src/lib/stream.ts', import.meta.url), 'utf8');
|
||||||
|
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||||
|
const exports = {};
|
||||||
|
new Function('require', 'exports', compiled)(name => {
|
||||||
|
assert.equal(name, './types');
|
||||||
|
return { FRAME_HEADER_LEN: 16 };
|
||||||
|
}, exports);
|
||||||
|
const { StreamClient } = exports;
|
||||||
|
globalThis.window = { clearTimeout, setTimeout };
|
||||||
|
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
const bitmap = () => ({ closed: false, close() { this.closed = true; } });
|
||||||
|
function frame(id, camera = 42) {
|
||||||
|
const bytes = new ArrayBuffer(20);
|
||||||
|
const header = new DataView(bytes);
|
||||||
|
header.setUint32(0, camera, true);
|
||||||
|
header.setUint32(4, id, true);
|
||||||
|
header.setUint16(8, 640, true);
|
||||||
|
header.setUint16(10, 480, true);
|
||||||
|
header.setUint32(12, 4, true);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
function client(now, wallNow) { return new StreamClient({ onMessage() {}, onConnection() {} },now,wallNow); }
|
||||||
|
function deferredDecoder() {
|
||||||
|
const requests = [];
|
||||||
|
globalThis.createImageBitmap = () => new Promise((resolve, reject) => requests.push({ resolve, reject }));
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a slow decoder keeps only the latest pending frame for each camera', async () => {
|
||||||
|
const requests = deferredDecoder();
|
||||||
|
const rx = client();
|
||||||
|
const finished = rx.onBinary(frame(1));
|
||||||
|
for (let id = 2; id <= 100; id++) await rx.onBinary(frame(id));
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
assert.equal(rx.pending.size, 1);
|
||||||
|
const first = bitmap();
|
||||||
|
requests[0].resolve(first);
|
||||||
|
await tick();
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
requests[1].resolve(bitmap());
|
||||||
|
await finished;
|
||||||
|
assert.equal(rx.latest(42).frameId, 100);
|
||||||
|
assert.equal(rx.counters.skippedDecodes, 98);
|
||||||
|
assert.equal(first.closed, true);
|
||||||
|
rx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two cameras decode independently without queues growing behind either', async () => {
|
||||||
|
const requests = deferredDecoder();
|
||||||
|
const rx = client();
|
||||||
|
const a = rx.onBinary(frame(1, 1));
|
||||||
|
const b = rx.onBinary(frame(2, 2));
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
requests[1].resolve(bitmap());
|
||||||
|
await b;
|
||||||
|
assert.equal(rx.latest(2).frameId, 2);
|
||||||
|
assert.equal(rx.latest(1), undefined);
|
||||||
|
requests[0].resolve(bitmap());
|
||||||
|
await a;
|
||||||
|
rx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing during decode releases the late bitmap and discards pending work', async () => {
|
||||||
|
const requests = deferredDecoder();
|
||||||
|
const rx = client();
|
||||||
|
const work = rx.onBinary(frame(1));
|
||||||
|
await rx.onBinary(frame(2));
|
||||||
|
rx.close();
|
||||||
|
const late = bitmap();
|
||||||
|
requests[0].resolve(late);
|
||||||
|
await work;
|
||||||
|
assert.equal(late.closed, true);
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
assert.equal(rx.latest(42), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a corrupt JPEG does not stop the next frame from being decoded', async () => {
|
||||||
|
const requests = deferredDecoder();
|
||||||
|
const rx = client();
|
||||||
|
const work = rx.onBinary(frame(1));
|
||||||
|
await rx.onBinary(frame(2));
|
||||||
|
requests[0].reject(new Error('bad JPEG'));
|
||||||
|
await tick();
|
||||||
|
requests[1].resolve(bitmap());
|
||||||
|
await work;
|
||||||
|
assert.equal(rx.counters.decodeFailures, 1);
|
||||||
|
assert.equal(rx.latest(42).frameId, 2);
|
||||||
|
rx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('late frames are rejected but a reboot after silence can restart the counter', () => {
|
||||||
|
const rx = client();
|
||||||
|
rx.injectFrame(42, 100, 640, 480, bitmap());
|
||||||
|
const old = bitmap();
|
||||||
|
rx.injectFrame(42, 99, 640, 480, old);
|
||||||
|
assert.equal(old.closed, true);
|
||||||
|
assert.equal(rx.latest(42).frameId, 100);
|
||||||
|
rx.latest(42).receivedAt -= 2000;
|
||||||
|
rx.injectFrame(42, 1, 640, 480, bitmap());
|
||||||
|
assert.equal(rx.latest(42).frameId, 1);
|
||||||
|
rx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a WebView backlog is discarded before JPEG decoding using native host timing', async () => {
|
||||||
|
const requests = deferredDecoder();
|
||||||
|
const rx = client(() => 0, () => 2000);
|
||||||
|
rx.onTiming({camera:42,frameId:1,publishedUnixMs:1500});
|
||||||
|
await rx.onBinary(frame(1));
|
||||||
|
assert.equal(requests.length,0);
|
||||||
|
assert.equal(rx.counters.staleFrames,1);
|
||||||
|
rx.onTiming({camera:42,frameId:2,publishedUnixMs:2000});
|
||||||
|
const work = rx.onBinary(frame(2));
|
||||||
|
requests[0].resolve(bitmap());await work;
|
||||||
|
assert.equal(rx.latest(42).frameId,2);
|
||||||
|
rx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a blocked decoder releases its old result and resumes with the fresh pending frame', async () => {
|
||||||
|
let clock=0;
|
||||||
|
const requests=deferredDecoder(),rx=client(()=>clock);
|
||||||
|
const work=rx.onBinary(frame(1));
|
||||||
|
clock=110;await rx.onBinary(frame(2));
|
||||||
|
const old=bitmap();requests[0].resolve(old);await tick();
|
||||||
|
assert.equal(old.closed,true);
|
||||||
|
assert.equal(rx.latest(42),undefined);
|
||||||
|
requests[1].resolve(bitmap());await work;
|
||||||
|
assert.equal(rx.latest(42).frameId,2);
|
||||||
|
assert.equal(rx.counters.staleFrames,1);rx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pending frames that expired during a decoder stall never reach the decoder', async () => {
|
||||||
|
let clock=0;
|
||||||
|
const requests=deferredDecoder(),rx=client(()=>clock);
|
||||||
|
const work=rx.onBinary(frame(1));await rx.onBinary(frame(2));
|
||||||
|
clock=200;const old=bitmap();requests[0].resolve(old);await work;
|
||||||
|
assert.equal(old.closed,true);assert.equal(requests.length,1);
|
||||||
|
assert.equal(rx.counters.staleFrames,2);assert.equal(rx.latest(42),undefined);rx.close();
|
||||||
|
});
|
||||||
+2339
-3152
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
[package]
|
||||||
|
name = "cam-desktop"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "ESP32 图传与装甲板系统的桌面端"
|
||||||
|
rust-version = "1.77"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# `protocol-asset` 打开自定义资源协议,录制出来的 mp4 才能在界面里直接回放
|
||||||
|
# (前端用 convertFileSrc 把它变成一个 <video> 能播的 URL)。
|
||||||
|
tauri = { version = "2", features = ["protocol-asset"] }
|
||||||
|
|
||||||
|
# 只用来"在访达里显示"与"用系统播放器打开"。所有调用都走我们自己的 Rust 命令,
|
||||||
|
# 所以前端不需要装 @tauri-apps/plugin-opener,也不需要给它开权限。
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
|
||||||
|
# 非界面部分:UDP 接收/重组、装甲板、配置、密码、槽位分配。它自己带 64 个测试。
|
||||||
|
cam-core = { path = "../core" }
|
||||||
|
|
||||||
|
# 线路格式与固件共享。
|
||||||
|
protocol = { path = "../../protocol" }
|
||||||
|
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = "0.11"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
# 回环通道的随机 token。
|
||||||
|
rand = "0.9"
|
||||||
|
|
||||||
|
# 本地 WebSocket 视频通道。用 tokio-tungstenite 而不是 Tauri 的自定义协议:
|
||||||
|
# 二进制消息不经 JSON/IPC 序列化,前端拿到的就是原始 JPEG 字节,可以直接交给
|
||||||
|
# 浏览器的原生 JPEG 解码器(createImageBitmap)。
|
||||||
|
tokio = { version = "1", features = [
|
||||||
|
"rt-multi-thread",
|
||||||
|
"net",
|
||||||
|
"sync",
|
||||||
|
"io-util",
|
||||||
|
"macros",
|
||||||
|
"time",
|
||||||
|
] }
|
||||||
|
tokio-tungstenite = "0.30"
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
||||||
|
# 截图要 PNG(相机出的 JPEG 不带 alpha、也可能被播放器当照片处理),所以要解码再编码。
|
||||||
|
# demo 模式也用它合成假画面。
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||||
|
|
||||||
|
# 文件名里的时间戳:现场看目录时要能一眼看出是哪一局录的。
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 2
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//! Tauri 的构建脚本。
|
||||||
|
//!
|
||||||
|
//! 它做两件事:把 `tauri.conf.json` 读进来生成上下文,以及(在 Windows 上)挂上清单。
|
||||||
|
//! 桌面端不需要自定义资源编译,所以这里没有 `tauri_build::Attributes`。
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "窗口与事件这些核心能力。业务命令(绑定、解锁、截图、录制)都是本应用自己的 #[tauri::command],不在权限系统里,所以这里没有它们的条目。",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,547 @@
|
|||||||
|
//! 界面能调用的命令。
|
||||||
|
//!
|
||||||
|
//! # 每个会改东西的命令都过密码闸门
|
||||||
|
//!
|
||||||
|
//! 界面里有一个解锁对话框,但那只是入口:这些函数才是执行点。所以每个改变配置的命令
|
||||||
|
//! 都显式调用 [`AppState::require_unlocked`],而 `presented` 允许在同一次调用里带上
|
||||||
|
//! 密码(前端在解锁后立刻改绑定时就是这样)。
|
||||||
|
//!
|
||||||
|
//! # 为什么错误是 `String`
|
||||||
|
//!
|
||||||
|
//! Tauri 把 `Err` 序列化给前端,而 `anyhow::Error` 不实现 `Serialize`。这些错误是给
|
||||||
|
//! **现场的人**看的("密码不对"、"还没有收到这一路的画面"),本来就该是给人读的句子,
|
||||||
|
//! 所以直接传字符串,同时用 `log::warn!` 留下带上下文的版本。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
|
use cam_core::config::Role;
|
||||||
|
use cam_core::snapshot::{ConfigView, Snapshot};
|
||||||
|
use cam_core::HudConfig;
|
||||||
|
|
||||||
|
use crate::recording::{list_media, MediaFileDto};
|
||||||
|
use crate::server::StreamInfo;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// 还没有测到帧率时,告诉 ffmpeg 的默认输入帧率。
|
||||||
|
///
|
||||||
|
/// 传感器在这个配置下实测 22 fps,但写死 22 是在撒谎——真正的问题不是"猜得准不准",
|
||||||
|
/// 而是这个数会决定回放速度,所以它一律写进日志与 [`cam_core::snapshot::RecordingDto`]。
|
||||||
|
const FALLBACK_INPUT_FPS: f32 = 20.0;
|
||||||
|
|
||||||
|
type Shared<'a> = State<'a, Arc<AppState>>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 只读
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 视频通道的地址与 token。前端启动时拉一次。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn stream_info(state: Shared<'_>) -> StreamInfo {
|
||||||
|
state.stream_info()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前完整状态。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_snapshot(state: Shared<'_>) -> Snapshot {
|
||||||
|
state.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 配置里可以给界面看的那部分(不含密码哈希)。用户端第一次启动用它判断"要不要设密码"。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_config(state: Shared<'_>) -> ConfigView {
|
||||||
|
ConfigView::from(&state.config())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 录制目录里的 mp4 与 png,新的在前。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_media_files(state: Shared<'_>) -> Vec<MediaFileDto> {
|
||||||
|
list_media(&state.config().recordings_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 配置文件的路径。现场排查时要能告诉人"去看这个文件"。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn config_path(state: Shared<'_>) -> String {
|
||||||
|
state.config_path.display().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 密码
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 校验密码并把本次运行标记为已解锁。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn unlock(password: String, state: Shared<'_>) -> Result<bool, String> {
|
||||||
|
state
|
||||||
|
.unlock(&password)
|
||||||
|
.map_err(|err| format!("存下来的密码哈希读不出来:{err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动重新上锁(并顺手把发现列表关掉)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn lock(state: Shared<'_>) {
|
||||||
|
state.lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次设置密码,或修改密码。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_password(
|
||||||
|
current: Option<String>,
|
||||||
|
fresh: String,
|
||||||
|
state: Shared<'_>,
|
||||||
|
) -> Result<ConfigView, String> {
|
||||||
|
state
|
||||||
|
.set_password(current.as_deref(), &fresh)
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
|
||||||
|
Ok(ConfigView::from(&state.config()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置面板打开/关闭带来的发现列表。打开需要已解锁。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_discovery_open(open: bool, state: Shared<'_>) -> Result<(), String> {
|
||||||
|
state.set_discovery_open(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 绑定与身份
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 绑定(或解除绑定)一块图传板。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn bind_camera(
|
||||||
|
camera_id: Option<u32>,
|
||||||
|
password: Option<String>,
|
||||||
|
state: Shared<'_>,
|
||||||
|
) -> Result<ConfigView, String> {
|
||||||
|
state.require_unlocked(password.as_deref())?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.update_config(move |config| config.camera_id = camera_id)
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
|
||||||
|
Ok(ConfigView::from(&state.config()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 绑定(或解除绑定)一块装甲板主板。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn bind_armor(
|
||||||
|
armor_id: Option<u32>,
|
||||||
|
password: Option<String>,
|
||||||
|
state: Shared<'_>,
|
||||||
|
) -> Result<ConfigView, String> {
|
||||||
|
state.require_unlocked(password.as_deref())?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.update_config(move |config| config.armor_id = armor_id)
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
|
||||||
|
Ok(ConfigView::from(&state.config()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自己属于哪一边(0/1)。改错队号会把对手的血条放在左上角(当成自己的),所以也要密码。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_team(
|
||||||
|
team: u8,
|
||||||
|
password: Option<String>,
|
||||||
|
state: Shared<'_>,
|
||||||
|
) -> Result<ConfigView, String> {
|
||||||
|
state.require_unlocked(password.as_deref())?;
|
||||||
|
|
||||||
|
if team > 1 {
|
||||||
|
return Err("队伍号只能是 0(队伍一)或 1(队伍二)".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.update_config(move |config| config.team = team)
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
|
||||||
|
Ok(ConfigView::from(&state.config()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换身份。
|
||||||
|
///
|
||||||
|
/// **离开用户端要密码**:否则解锁界面就形同虚设——切到裁判端就能看两路画面、还能随便
|
||||||
|
/// 改配置。反过来(裁判端 → 用户端)不需要:裁判端本来就没有密码。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_role(
|
||||||
|
role: Role,
|
||||||
|
password: Option<String>,
|
||||||
|
state: Shared<'_>,
|
||||||
|
) -> Result<ConfigView, String> {
|
||||||
|
let current = state.config().role;
|
||||||
|
|
||||||
|
if current.needs_password() && current != role {
|
||||||
|
state.require_unlocked(password.as_deref())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.update_config(move |config| {
|
||||||
|
config.role = role;
|
||||||
|
// 记下"人选过了":下次启动直接进这个身份,不再问。
|
||||||
|
config.role_chosen = true;
|
||||||
|
})
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
|
||||||
|
Ok(ConfigView::from(&state.config()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 左右镜像。摄像头装反了才需要,不是判罚相关的东西,所以不设密码。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_mirror(mirror: bool, state: Shared<'_>) -> Result<ConfigView, String> {
|
||||||
|
state
|
||||||
|
.update_config(move |config| config.mirror = mirror)
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
|
||||||
|
Ok(ConfigView::from(&state.config()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存 HUD 设置(血条、瞄准器、网格、命中反馈)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_hud(hud: HudConfig, state: Shared<'_>) -> Result<ConfigView, String> {
|
||||||
|
state
|
||||||
|
.update_config(move |config| config.hud = hud)
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
|
||||||
|
Ok(ConfigView::from(&state.config()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 裁判端两个槽位的手动指定。
|
||||||
|
#[tauri::command]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn set_referee_slots(
|
||||||
|
camera_a: Option<u32>,
|
||||||
|
camera_b: Option<u32>,
|
||||||
|
armor_a: Option<u32>,
|
||||||
|
armor_b: Option<u32>,
|
||||||
|
manual: bool,
|
||||||
|
state: Shared<'_>,
|
||||||
|
) -> Result<ConfigView, String> {
|
||||||
|
state
|
||||||
|
.update_config(move |config| {
|
||||||
|
config.referee.slot_a = camera_a;
|
||||||
|
config.referee.slot_b = camera_b;
|
||||||
|
config.referee.armor_a = armor_a;
|
||||||
|
config.referee.armor_b = armor_b;
|
||||||
|
config.referee.manual_slots = manual;
|
||||||
|
})
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
|
||||||
|
Ok(ConfigView::from(&state.config()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 改一边在界面上显示的名字(0 = 队伍一,1 = 队伍二)。
|
||||||
|
///
|
||||||
|
/// # 为什么一次只改一边
|
||||||
|
///
|
||||||
|
/// 界面本来可以传一整个 `[String; 2]`,但那意味着每次改名都是"读两条、改一条、写两条",
|
||||||
|
/// 而读来的那两条来自最多 200 ms 前的快照:紧接着改另一边就会把上一次的改动覆盖回去。
|
||||||
|
/// 一次只改一边,这个窗口不存在。
|
||||||
|
///
|
||||||
|
/// # 为什么这个命令也过密码闸门
|
||||||
|
///
|
||||||
|
/// 名字本身不是判罚数据,但它写在**配置文件**里。给一个不设闸门的"改配置"入口,等于在
|
||||||
|
/// 密码闸门上开一条旁路——今天改的是名字,明天有人顺手把绑定也挪进来。裁判端本来就没有
|
||||||
|
/// 密码(`Role::needs_password` 为假),所以对裁判端这个闸门是空的,界面上的入口也在裁判端。
|
||||||
|
///
|
||||||
|
/// 规范化(去空白、截断、留空回落默认名)由 [`cam_core::config::Config::normalized`] 统一
|
||||||
|
/// 做,这里只管队伍号合不合法。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_team_name(
|
||||||
|
team: u8,
|
||||||
|
name: String,
|
||||||
|
password: Option<String>,
|
||||||
|
state: Shared<'_>,
|
||||||
|
) -> Result<ConfigView, String> {
|
||||||
|
state.require_unlocked(password.as_deref())?;
|
||||||
|
|
||||||
|
if team > 1 {
|
||||||
|
return Err("队伍号只能是 0(队伍一)或 1(队伍二)".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.update_config(move |config| config.team_names[usize::from(team)] = name)
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
|
||||||
|
Ok(ConfigView::from(&state.config()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 录制目录与 ffmpeg 路径。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_recording_options(
|
||||||
|
dir: Option<String>,
|
||||||
|
ffmpeg: Option<String>,
|
||||||
|
crf: Option<u8>,
|
||||||
|
preset: Option<String>,
|
||||||
|
state: Shared<'_>,
|
||||||
|
) -> Result<ConfigView, String> {
|
||||||
|
state
|
||||||
|
.update_config(move |config| {
|
||||||
|
config.recording.dir = dir.filter(|d| !d.trim().is_empty());
|
||||||
|
if let Some(ffmpeg) = ffmpeg.filter(|f| !f.trim().is_empty()) {
|
||||||
|
config.recording.ffmpeg = ffmpeg;
|
||||||
|
}
|
||||||
|
if let Some(crf) = crf {
|
||||||
|
config.recording.crf = crf;
|
||||||
|
}
|
||||||
|
if let Some(preset) = preset.filter(|p| !p.trim().is_empty()) {
|
||||||
|
config.recording.preset = preset;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
|
||||||
|
Ok(ConfigView::from(&state.config()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 截图与录制
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 把某一路最新的一帧存成 PNG(存在录制目录的 `shots/` 下)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn screenshot(camera: u32, state: Shared<'_>) -> Result<String, String> {
|
||||||
|
let frame = state
|
||||||
|
.latest_frames()
|
||||||
|
.latest(camera)
|
||||||
|
.ok_or_else(|| format!("还没有收到 0x{camera:08X} 的画面,暂时截不了"))?;
|
||||||
|
|
||||||
|
let config = state.config();
|
||||||
|
let dir = config.recordings_dir().join("shots");
|
||||||
|
|
||||||
|
let path = crate::recording::save_screenshot(&frame.data, &dir, camera)
|
||||||
|
.map_err(|err| format!("{err:#}"))?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"screenshot of 0x{camera:08X} (frame {}) -> {}",
|
||||||
|
frame.frame_id,
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(path.display().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开始录制若干路。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn start_recording(
|
||||||
|
cameras: Vec<u32>,
|
||||||
|
state: Shared<'_>,
|
||||||
|
) -> Result<Vec<cam_core::snapshot::RecordingDto>, String> {
|
||||||
|
if cameras.is_empty() {
|
||||||
|
return Err("没有指定要录哪一路".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = state.config();
|
||||||
|
let dir = config.recordings_dir();
|
||||||
|
|
||||||
|
let mut started = Vec::new();
|
||||||
|
|
||||||
|
for camera in cameras {
|
||||||
|
// 帧率:配置里指定了就用它,否则用实测值。两者都没有时用保守默认值并记日志
|
||||||
|
// ——猜错的表现是回放速度不对,而那个数会写进 DTO 与日志。
|
||||||
|
let fps = if config.recording.fps > 0.0 {
|
||||||
|
config.recording.fps
|
||||||
|
} else {
|
||||||
|
match state.fps_of(camera) {
|
||||||
|
Some(measured) if measured > 1.0 => measured,
|
||||||
|
_ => {
|
||||||
|
log::warn!(
|
||||||
|
"0x{camera:08X} 还没有测到帧率,录制先按 {FALLBACK_INPUT_FPS} fps 走;\
|
||||||
|
回放速度可能不准,可以改用 [recording] fps 明确指定"
|
||||||
|
);
|
||||||
|
FALLBACK_INPUT_FPS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let dto = state
|
||||||
|
.recordings
|
||||||
|
.start(
|
||||||
|
camera,
|
||||||
|
&dir,
|
||||||
|
&config.recording.ffmpeg,
|
||||||
|
fps,
|
||||||
|
config.recording.crf,
|
||||||
|
&config.recording.preset,
|
||||||
|
)
|
||||||
|
.map_err(|err| format!("起不了录制:{err:#}"))?;
|
||||||
|
|
||||||
|
started.push(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(started)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止录制。`cameras` 为空表示全部停止。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn stop_recording(
|
||||||
|
cameras: Vec<u32>,
|
||||||
|
state: Shared<'_>,
|
||||||
|
) -> Result<Vec<cam_core::snapshot::RecordingDto>, String> {
|
||||||
|
let stopped = if cameras.is_empty() {
|
||||||
|
state.recordings.stop_all()
|
||||||
|
} else {
|
||||||
|
cameras
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|camera| state.recordings.stop(camera))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(stopped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在访达/资源管理器里显示一个文件。
|
||||||
|
///
|
||||||
|
/// 只允许指向录制目录里的东西:这是一个"用系统程序打开路径"的命令,把它做成任意路径
|
||||||
|
/// 都能传的通用工具没有任何好处。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn reveal(path: String, state: Shared<'_>) -> Result<(), String> {
|
||||||
|
let dir = state.config().recordings_dir();
|
||||||
|
let target = PathBuf::from(&path);
|
||||||
|
|
||||||
|
if !is_within(&dir, &target) {
|
||||||
|
return Err(format!("只能打开录制目录({})里的文件", dir.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !target.exists() {
|
||||||
|
return Err(format!("{} 不存在了", target.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
tauri_plugin_opener::reveal_item_in_dir(&target)
|
||||||
|
.map_err(|err| format!("打开 {path} 失败:{err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用系统默认程序打开一个文件。
|
||||||
|
///
|
||||||
|
/// 界面内回放走的是 Tauri 的资源协议,而那条协议只对白名单目录生效(见
|
||||||
|
/// `tauri.conf.json` 里的 `assetProtocol.scope`)。录制目录被改到白名单之外时,`<video>`
|
||||||
|
/// 会加载失败——那时这个命令是退路,而不是让现场以为"录下来的文件坏了"。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_file(path: String, state: Shared<'_>) -> Result<(), String> {
|
||||||
|
let dir = state.config().recordings_dir();
|
||||||
|
let target = PathBuf::from(&path);
|
||||||
|
|
||||||
|
if !is_within(&dir, &target) {
|
||||||
|
return Err(format!("只能打开录制目录({})里的文件", dir.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !target.exists() {
|
||||||
|
return Err(format!("{} 不存在了", target.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
tauri_plugin_opener::open_path(&target, None::<&str>)
|
||||||
|
.map_err(|err| format!("打开 {path} 失败:{err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `child` 在 `parent` 里面吗。
|
||||||
|
///
|
||||||
|
/// 逐段比较而不是字符串前缀:`/a/bc` 以 `/a/b` 为前缀,但它不在 `/a/b` 里面。
|
||||||
|
fn is_within(parent: &Path, child: &Path) -> bool {
|
||||||
|
let Ok(parent) = parent.canonicalize() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
match child.canonicalize() {
|
||||||
|
Ok(child) => child.starts_with(parent),
|
||||||
|
// 还不存在(比如刚被删掉)时退回文本比较,但仍然要求是父目录下的相对路径。
|
||||||
|
Err(_) => child.parent().is_some_and(|p| {
|
||||||
|
p.canonicalize()
|
||||||
|
.map(|p| p.starts_with(&parent))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 退出与清空
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 清空配置:把文件挪到 `.bak`,然后回到"第一次启动"的状态。
|
||||||
|
///
|
||||||
|
/// 不直接删的原因是现场排查:一个人说"我重置过了还是这样"时,能看一眼旧文件是最快的
|
||||||
|
/// 结论。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn clear_config(password: Option<String>, state: Shared<'_>) -> Result<String, String> {
|
||||||
|
state.require_unlocked(password.as_deref())?;
|
||||||
|
|
||||||
|
let path = state.config_path.clone();
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
let backup = PathBuf::from(format!("{}.bak", path.display()));
|
||||||
|
std::fs::rename(&path, &backup)
|
||||||
|
.map_err(|err| format!("挪走 {} 失败:{err}", path.display()))?;
|
||||||
|
|
||||||
|
log::warn!("配置已重置,旧文件留在 {}", backup.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lock();
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"配置已重置(旧文件备份在 {})。请重启程序。",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 退出程序。用户端要密码——现场"随手关掉"也是一种需要挡住的误操作。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quit_app(password: Option<String>, app: AppHandle, state: Shared<'_>) -> Result<(), String> {
|
||||||
|
state.require_unlocked(password.as_deref())?;
|
||||||
|
|
||||||
|
// 先把录制收尾,否则 ffmpeg 会留下一个没有索引的 mp4。
|
||||||
|
let stopped = state.recordings.stop_all();
|
||||||
|
for dto in stopped {
|
||||||
|
log::info!(
|
||||||
|
"退出前收尾录制 0x{:08X}:{} 帧 -> {:?}",
|
||||||
|
dto.camera,
|
||||||
|
dto.frames_written,
|
||||||
|
dto.path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.exit(0);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 主动推一次快照。绑定改动之后前端不必等 200 ms。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn refresh(state: Shared<'_>) {
|
||||||
|
state.push_snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::is_within;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sibling_directory_with_a_shared_prefix_is_not_inside() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("cam-reveal-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(dir.join("rec")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("rec-evil")).unwrap();
|
||||||
|
|
||||||
|
let inside = dir.join("rec").join("a.mp4");
|
||||||
|
std::fs::write(&inside, b"x").unwrap();
|
||||||
|
|
||||||
|
let outside = dir.join("rec-evil").join("b.mp4");
|
||||||
|
std::fs::write(&outside, b"x").unwrap();
|
||||||
|
|
||||||
|
assert!(is_within(&dir.join("rec"), &inside));
|
||||||
|
assert!(
|
||||||
|
!is_within(&dir.join("rec"), &outside),
|
||||||
|
"`/a/rec-evil` 以 `/a/rec` 为前缀,但它不在里面"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_absolute_path_outside_is_refused() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("cam-reveal2-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
assert!(!is_within(&dir, &PathBuf::from("/etc/hosts")));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
//! 演示模式:本地合成画面与血量,喂进**真实的**接收路径。
|
||||||
|
//!
|
||||||
|
//! # 为什么不是"假界面"
|
||||||
|
//!
|
||||||
|
//! 演示模式不伪造数据给界面看,而是合成**协议数据报**,从
|
||||||
|
//! [`cam_core::receiver::Receiver::datagram`] 与
|
||||||
|
//! [`cam_core::armor::ArmorHealthStore::datagram`] 进去。也就是说它跑的是同一条真实链路:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! 合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
|
||||||
|
//! 合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 于是演示模式顺带成了这条链路的端到端验证:分片、重组、发现、统计、掉血派生、
|
||||||
|
//! 两道闸门(用户端只收自己那一块)全都在跑真代码。截图、录制也因此**不需要特殊分支**。
|
||||||
|
//!
|
||||||
|
//! # 它绝不能被误读成真实链路
|
||||||
|
//!
|
||||||
|
//! 只在 `--demo` / `CAM_DEMO=1` 下启动,而且 [`cam_core::snapshot::Snapshot::demo`] 为
|
||||||
|
//! true,界面必须显示"演示模式"标记。板子 id 也刻意取成 `0x0DE00001` 这种一眼能看出
|
||||||
|
//! 是人造的形状。
|
||||||
|
//!
|
||||||
|
//! # 线程
|
||||||
|
//!
|
||||||
|
//! 三个:两路画面各一个(各 15fps 合成 JPEG),加一个广播装甲板血量的。它们活到进程
|
||||||
|
//! 结束、不需要退出条件——和真实的接收线程一样。
|
||||||
|
|
||||||
|
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use protocol::{fragment_frame, ArmorHealth, MsgType};
|
||||||
|
|
||||||
|
use crate::state::{AppState, DEMO_ARMOR_A, DEMO_ARMOR_B, DEMO_CAMERA_A, DEMO_CAMERA_B};
|
||||||
|
|
||||||
|
/// 合成画面的尺寸。与实机一致(VGA),这样界面的缩放/比例在演示下也说得通。
|
||||||
|
const WIDTH: u32 = 640;
|
||||||
|
const HEIGHT: u32 = 480;
|
||||||
|
|
||||||
|
/// 合成帧率。实机是 22fps;15 够看出流畅度,又把 CPU 留给真正要验证的界面。
|
||||||
|
const FPS: u64 = 15;
|
||||||
|
|
||||||
|
/// 装甲板广播周期,与固件一致(200 ms)。
|
||||||
|
const ARMOR_PERIOD: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
/// 演示里一次"命中"打掉多少血。故意取一组差异大的值,好看出闪红强度是不是跟着伤害走。
|
||||||
|
const DAMAGE_CYCLE: [u16; 4] = [3, 7, 12, 25];
|
||||||
|
|
||||||
|
pub fn spawn(state: Arc<AppState>) {
|
||||||
|
log::warn!("演示模式:画面与血量都是本地合成的,不是场上的板子");
|
||||||
|
|
||||||
|
for (camera, team) in [(DEMO_CAMERA_A, 0u8), (DEMO_CAMERA_B, 1u8)] {
|
||||||
|
spawn_camera(Arc::clone(&state), camera, team);
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_armor(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一路合成画面:周期广播 Hello(让发现列表有东西),并按 15fps 推分片。
|
||||||
|
fn spawn_camera(state: Arc<AppState>, camera: u32, team: u8) {
|
||||||
|
let _ = std::thread::Builder::new()
|
||||||
|
.name(format!("demo-cam-{camera:08X}"))
|
||||||
|
.spawn(move || {
|
||||||
|
let from = SocketAddr::V4(SocketAddrV4::new(
|
||||||
|
Ipv4Addr::new(127, 0, 0, 1),
|
||||||
|
// 端口只是让日志/发现里的地址看起来像回事。
|
||||||
|
41000 + team as u16,
|
||||||
|
));
|
||||||
|
|
||||||
|
let hello = protocol::Hello {
|
||||||
|
sender_id: camera,
|
||||||
|
firmware: 0x0DE0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let frame_interval = Duration::from_micros(1_000_000 / FPS);
|
||||||
|
let mut frame_id: u32 = 0;
|
||||||
|
let mut last_hello = std::time::Instant::now() - Duration::from_secs(10);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
|
||||||
|
// 板子每 2 秒广播一次在线状态,与固件一致。
|
||||||
|
if last_hello.elapsed() >= Duration::from_secs(2) {
|
||||||
|
state.receiver.datagram(&hello.encode(MsgType::Hello), from);
|
||||||
|
last_hello = std::time::Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
let jpeg = render(camera, team, frame_id);
|
||||||
|
|
||||||
|
for fragment in fragment_frame(camera, frame_id, &jpeg, WIDTH as u16, HEIGHT as u16)
|
||||||
|
{
|
||||||
|
state.receiver.datagram(&fragment.encode(), from);
|
||||||
|
}
|
||||||
|
|
||||||
|
frame_id = frame_id.wrapping_add(1);
|
||||||
|
|
||||||
|
// 按目标周期睡,而不是固定 sleep:合成一张 640x480 的 JPEG 要几毫秒,
|
||||||
|
// 不扣掉这段时间的话实际帧率会明显低于 15,演示看起来像在卡。
|
||||||
|
if let Some(rest) = frame_interval.checked_sub(started.elapsed()) {
|
||||||
|
std::thread::sleep(rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两块装甲板主板:各自按脚本掉血、打空、重置。
|
||||||
|
fn spawn_armor(state: Arc<AppState>) {
|
||||||
|
let _ = std::thread::Builder::new()
|
||||||
|
.name("demo-armor".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let boards = [(DEMO_ARMOR_A, 0u8, 41050u16), (DEMO_ARMOR_B, 1u8, 41051)];
|
||||||
|
|
||||||
|
let mut hp = [100u16, 100u16];
|
||||||
|
// 两块板子的掉血节奏错开,界面上两边的血量不会同时变。
|
||||||
|
let mut ticks = [0u32, 7];
|
||||||
|
let mut hits = [0usize, 1];
|
||||||
|
let mut dead_for = [0u32, 0];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
for (slot, (board, team, port)) in boards.iter().enumerate() {
|
||||||
|
ticks[slot] += 1;
|
||||||
|
|
||||||
|
if hp[slot] == 0 {
|
||||||
|
// 打空之后停一会儿再重置:让界面上的胜负横幅真的能被看到。
|
||||||
|
dead_for[slot] += 1;
|
||||||
|
if dead_for[slot] >= 10 {
|
||||||
|
hp[slot] = 100;
|
||||||
|
dead_for[slot] = 0;
|
||||||
|
ticks[slot] = 0;
|
||||||
|
log::info!("demo: 0x{board:08X} 重置为满血(长按 BOOT 的等价动作)");
|
||||||
|
}
|
||||||
|
} else if ticks[slot] % 15 == 0 {
|
||||||
|
// 约 3 秒一次。
|
||||||
|
let damage = DAMAGE_CYCLE[hits[slot] % DAMAGE_CYCLE.len()];
|
||||||
|
hits[slot] += 1;
|
||||||
|
hp[slot] = hp[slot].saturating_sub(damage);
|
||||||
|
}
|
||||||
|
|
||||||
|
let from =
|
||||||
|
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), *port));
|
||||||
|
|
||||||
|
// 主板一次广播四条,整车一条血所以 hp 相同、index 0..3。
|
||||||
|
for index in 0..4u8 {
|
||||||
|
let health = ArmorHealth {
|
||||||
|
team: *team,
|
||||||
|
index,
|
||||||
|
hp: hp[slot],
|
||||||
|
};
|
||||||
|
|
||||||
|
state.armor.datagram(&health.encode_from(*board), from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::thread::sleep(ARMOR_PERIOD);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合成一张 PNG/JPEG 用的画面。
|
||||||
|
///
|
||||||
|
/// 内容刻意是为**验收**挑的:
|
||||||
|
///
|
||||||
|
/// - 两路的底色不同(青 / 橙),一眼能分辨左右两格有没有串;
|
||||||
|
/// - 正中有一组同心圆 + 十字,用来核对瞄准器的偏移校准(偏移为 0 时应该正好套住圆心);
|
||||||
|
/// - 每 40 px 一条淡网格,用来核对缩放与平移;
|
||||||
|
/// - 左上角 16 个方块是二进制帧号,截图里能看出画面在推进;
|
||||||
|
/// - 一个随时间走 Lissajous 的实心圆,用来判断两路是不是同一时刻的画面。
|
||||||
|
fn render(camera: u32, team: u8, frame_id: u32) -> Vec<u8> {
|
||||||
|
// 底色:竖直渐变。队伍一偏青、队伍二偏橙。
|
||||||
|
//
|
||||||
|
// 直接铺一块裸缓冲再交给 `from_raw`,而不是逐像素 `put_pixel`:一帧 30 万像素,
|
||||||
|
// 每像素一次带边界检查的写入在 15fps 下是白白烧掉的 CPU。
|
||||||
|
let tint: [i16; 3] = if team == 0 { [0, 46, 40] } else { [58, 30, 0] };
|
||||||
|
let mut raw = vec![0u8; (WIDTH * HEIGHT * 3) as usize];
|
||||||
|
|
||||||
|
for y in 0..HEIGHT {
|
||||||
|
let shade = (y as i16 * 60 / HEIGHT as i16) as i16;
|
||||||
|
let row = &mut raw[(y * WIDTH * 3) as usize..((y + 1) * WIDTH * 3) as usize];
|
||||||
|
|
||||||
|
for pixel in row.chunks_exact_mut(3) {
|
||||||
|
pixel[0] = (18 + tint[0] / 3 + shade) as u8;
|
||||||
|
pixel[1] = (18 + tint[1] / 3 + shade) as u8;
|
||||||
|
pixel[2] = (22 + tint[2] / 3 + shade) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut img = image::RgbImage::from_raw(WIDTH, HEIGHT, raw)
|
||||||
|
.expect("the buffer is exactly WIDTH * HEIGHT * 3 bytes");
|
||||||
|
|
||||||
|
let (cx, cy) = (WIDTH as i32 / 2, HEIGHT as i32 / 2);
|
||||||
|
let t = frame_id as f32 / FPS as f32;
|
||||||
|
|
||||||
|
// 淡网格:每 40 px。缩放/平移时这组线是最直观的参照。
|
||||||
|
for x in (0..WIDTH as i32).step_by(40) {
|
||||||
|
for y in 0..HEIGHT as i32 {
|
||||||
|
if let Some(pixel) = img.get_pixel_mut_checked(x as u32, y as u32) {
|
||||||
|
*pixel = brighten(*pixel, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for y in (0..HEIGHT as i32).step_by(40) {
|
||||||
|
for x in 0..WIDTH as i32 {
|
||||||
|
if let Some(pixel) = img.get_pixel_mut_checked(x as u32, y as u32) {
|
||||||
|
*pixel = brighten(*pixel, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中心的靶心:两个同心圆 + 四臂。瞄准器偏移校准时对着它看。
|
||||||
|
ring(&mut img, cx, cy, 60, image::Rgb([200, 200, 210]));
|
||||||
|
ring(&mut img, cx, cy, 28, image::Rgb([150, 150, 160]));
|
||||||
|
arm(
|
||||||
|
&mut img,
|
||||||
|
cx - 90,
|
||||||
|
cy,
|
||||||
|
cx - 40,
|
||||||
|
cy,
|
||||||
|
image::Rgb([120, 120, 130]),
|
||||||
|
);
|
||||||
|
arm(
|
||||||
|
&mut img,
|
||||||
|
cx + 40,
|
||||||
|
cy,
|
||||||
|
cx + 90,
|
||||||
|
cy,
|
||||||
|
image::Rgb([120, 120, 130]),
|
||||||
|
);
|
||||||
|
arm(
|
||||||
|
&mut img,
|
||||||
|
cx,
|
||||||
|
cy - 90,
|
||||||
|
cx,
|
||||||
|
cy - 40,
|
||||||
|
image::Rgb([120, 120, 130]),
|
||||||
|
);
|
||||||
|
arm(
|
||||||
|
&mut img,
|
||||||
|
cx,
|
||||||
|
cy + 40,
|
||||||
|
cx,
|
||||||
|
cy + 90,
|
||||||
|
image::Rgb([120, 120, 130]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 二进制帧号:16 个方块,最左边是低位。
|
||||||
|
for bit in 0..16i32 {
|
||||||
|
let on = (frame_id >> bit) & 1 == 1;
|
||||||
|
let x = 12 + bit * 14;
|
||||||
|
let color = if on {
|
||||||
|
image::Rgb([120, 255, 160])
|
||||||
|
} else {
|
||||||
|
image::Rgb([52, 52, 60])
|
||||||
|
};
|
||||||
|
|
||||||
|
fill_rect(&mut img, x, 12, 10, 10, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lissajous 圆点:两路同一个 t 下位置不同,用来分辨"这两格是不是同一块板子"。
|
||||||
|
let phase = if team == 0 { 0.0 } else { 1.7 };
|
||||||
|
let dot_x = cx + (250.0 * (t * 0.9 + phase).sin()) as i32;
|
||||||
|
let dot_y = cy + (150.0 * (t * 1.3 + phase).cos()) as i32;
|
||||||
|
disc(&mut img, dot_x, dot_y, 26, image::Rgb([255, 210, 90]));
|
||||||
|
|
||||||
|
// 底部随时间右移的指示条:截图里能判断这一帧是不是较新的。
|
||||||
|
let bar = ((t * 60.0) as i32).rem_euclid(WIDTH as i32 - 80);
|
||||||
|
fill_rect(
|
||||||
|
&mut img,
|
||||||
|
bar,
|
||||||
|
HEIGHT as i32 - 24,
|
||||||
|
60,
|
||||||
|
8,
|
||||||
|
image::Rgb([90, 200, 255]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 摄像头 id 的最低位用一个角标表示,进一步区分两路。
|
||||||
|
let corner = if camera == DEMO_CAMERA_A { 0 } else { 1 };
|
||||||
|
fill_rect(
|
||||||
|
&mut img,
|
||||||
|
WIDTH as i32 - 40 - corner * 24,
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
18,
|
||||||
|
if corner == 0 {
|
||||||
|
image::Rgb([120, 255, 160])
|
||||||
|
} else {
|
||||||
|
image::Rgb([255, 140, 120])
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 78);
|
||||||
|
encoder
|
||||||
|
.encode_image(&img)
|
||||||
|
.expect("a synthetic RGB image always encodes");
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn brighten(pixel: image::Rgb<u8>, by: u8) -> image::Rgb<u8> {
|
||||||
|
image::Rgb([
|
||||||
|
pixel[0].saturating_add(by),
|
||||||
|
pixel[1].saturating_add(by),
|
||||||
|
pixel[2].saturating_add(by),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put(img: &mut image::RgbImage, x: i32, y: i32, color: image::Rgb<u8>) {
|
||||||
|
if x < 0 || y < 0 || x >= WIDTH as i32 || y >= HEIGHT as i32 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.put_pixel(x as u32, y as u32, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_rect(img: &mut image::RgbImage, x: i32, y: i32, w: i32, h: i32, color: image::Rgb<u8>) {
|
||||||
|
for dy in 0..h {
|
||||||
|
for dx in 0..w {
|
||||||
|
put(img, x + dx, y + dy, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disc(img: &mut image::RgbImage, cx: i32, cy: i32, radius: i32, color: image::Rgb<u8>) {
|
||||||
|
for dy in -radius..=radius {
|
||||||
|
for dx in -radius..=radius {
|
||||||
|
if dx * dx + dy * dy <= radius * radius {
|
||||||
|
put(img, cx + dx, cy + dy, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 细圆环(只画一圈,不填充)。
|
||||||
|
fn ring(img: &mut image::RgbImage, cx: i32, cy: i32, radius: i32, color: image::Rgb<u8>) {
|
||||||
|
let steps = 720;
|
||||||
|
for step in 0..steps {
|
||||||
|
let angle = step as f32 * std::f32::consts::TAU / steps as f32;
|
||||||
|
put(
|
||||||
|
img,
|
||||||
|
cx + (radius as f32 * angle.cos()) as i32,
|
||||||
|
cy + (radius as f32 * angle.sin()) as i32,
|
||||||
|
color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一像素宽的线段(只用来画靶心的四臂,够用就行)。
|
||||||
|
fn arm(img: &mut image::RgbImage, x0: i32, y0: i32, x1: i32, y1: i32, color: image::Rgb<u8>) {
|
||||||
|
let steps = (x1 - x0).abs().max((y1 - y0).abs()).max(1);
|
||||||
|
|
||||||
|
for step in 0..=steps {
|
||||||
|
let f = step as f32 / steps as f32;
|
||||||
|
put(
|
||||||
|
img,
|
||||||
|
x0 + ((x1 - x0) as f32 * f) as i32,
|
||||||
|
y0 + ((y1 - y0) as f32 * f) as i32,
|
||||||
|
color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_synthetic_frame_is_a_decodable_jpeg_of_the_right_size() {
|
||||||
|
let jpeg = render(DEMO_CAMERA_A, 0, 42);
|
||||||
|
|
||||||
|
assert!(jpeg.starts_with(&[0xFF, 0xD8]), "JPEG SOI");
|
||||||
|
assert!(jpeg.ends_with(&[0xFF, 0xD9]), "JPEG EOI");
|
||||||
|
|
||||||
|
let decoded =
|
||||||
|
image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg).expect("decodes");
|
||||||
|
|
||||||
|
assert_eq!((decoded.width(), decoded.height()), (WIDTH, HEIGHT));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_two_cameras_look_different() {
|
||||||
|
// 两路画面必须能被一眼分辨,否则"左右两格是不是同一块板子"就看不出来。
|
||||||
|
let a = render(DEMO_CAMERA_A, 0, 10);
|
||||||
|
let b = render(DEMO_CAMERA_B, 1, 10);
|
||||||
|
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consecutive_frames_differ() {
|
||||||
|
// 画面必须真的在动,否则截图看不出链路是活的。
|
||||||
|
assert_ne!(render(DEMO_CAMERA_A, 0, 1), render(DEMO_CAMERA_A, 0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frame_is_big_enough_to_be_a_realistic_load() {
|
||||||
|
// 太小说明画的内容退化了(比如全是纯色),那分片/带宽的验证就失去意义。
|
||||||
|
let jpeg = render(DEMO_CAMERA_A, 0, 1);
|
||||||
|
assert!(
|
||||||
|
jpeg.len() > 3_000,
|
||||||
|
"suspiciously small: {} bytes",
|
||||||
|
jpeg.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
jpeg.len() < 200_000,
|
||||||
|
"suspiciously large: {} bytes",
|
||||||
|
jpeg.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//! Opt-in, local-only calibration reports. No images, tokens or user config.
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::{
|
||||||
|
fs::{File, OpenOptions},
|
||||||
|
io::Write,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::Mutex,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Recorder {
|
||||||
|
output: Mutex<Option<File>>,
|
||||||
|
}
|
||||||
|
impl Recorder {
|
||||||
|
pub fn new(path: Option<PathBuf>) -> std::io::Result<Self> {
|
||||||
|
let output = if let Some(path) = path {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
Some(OpenOptions::new().write(true).create_new(true).open(path)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
output: Mutex::new(output),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct Sample {
|
||||||
|
frame_id: u32,
|
||||||
|
counter: u16,
|
||||||
|
source_draw_ms: f64,
|
||||||
|
draw_ms: f64,
|
||||||
|
next_raf_ms: f64,
|
||||||
|
lower_ms: f64,
|
||||||
|
upper_ms: f64,
|
||||||
|
decode_to_draw_ms: f64,
|
||||||
|
analysis_ms: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn latency_enabled(recorder: tauri::State<'_, Recorder>) -> bool {
|
||||||
|
recorder
|
||||||
|
.output
|
||||||
|
.lock()
|
||||||
|
.expect("latency output mutex")
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn latency_sample(sample: Sample, recorder: tauri::State<'_, Recorder>) -> Result<(), String> {
|
||||||
|
if ![
|
||||||
|
sample.source_draw_ms,
|
||||||
|
sample.draw_ms,
|
||||||
|
sample.next_raf_ms,
|
||||||
|
sample.lower_ms,
|
||||||
|
sample.upper_ms,
|
||||||
|
sample.decode_to_draw_ms,
|
||||||
|
sample.analysis_ms,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.all(|v| v.is_finite())
|
||||||
|
|| sample.lower_ms < 0.0
|
||||||
|
|| sample.upper_ms < sample.lower_ms
|
||||||
|
|| sample.upper_ms > 5000.0
|
||||||
|
{
|
||||||
|
return Err("Invalid latency sample".into());
|
||||||
|
}
|
||||||
|
let mut guard = recorder.output.lock().map_err(|_| "latency output mutex")?;
|
||||||
|
let out = guard.as_mut().ok_or("Latency calibration is disabled")?;
|
||||||
|
serde_json::to_writer(&mut *out, &sample).map_err(|e| e.to_string())?;
|
||||||
|
out.write_all(b"\n")
|
||||||
|
.and_then(|_| out.flush())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
//! cam-desktop —— ESP32 图传与装甲板系统的桌面端。
|
||||||
|
//!
|
||||||
|
//! 一个进程、两套页面:
|
||||||
|
//!
|
||||||
|
//! - **用户端**(`--role team`):一路画面 + 血条 + 瞄准器,绑定一块图传板与一块装甲板。
|
||||||
|
//! - **裁判端**(`--role referee`,默认):两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。
|
||||||
|
//!
|
||||||
|
//! 界面是 WebView(React 渲染到 canvas),后端是这里的 Rust:UDP 收流、发现、装甲板、
|
||||||
|
//! 配置与密码、录制。视频不经 IPC,而是走一个只监听回环的 WebSocket(见 [`server`])。
|
||||||
|
//!
|
||||||
|
//! # 命令行
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! --role <team|referee> 身份。默认看配置文件,没有配置文件时是 referee
|
||||||
|
//! --config <路径> 配置文件位置
|
||||||
|
//! --demo 演示模式:本地合成画面与血量(标了"演示模式")
|
||||||
|
//! --list 打印发现的摄像头与装甲板后退出
|
||||||
|
//! -h, --help 显示这段帮助
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! **未知参数只记一条日志,不报错**:macOS 从访达启动 .app 时会塞进来一个
|
||||||
|
//! `-psn_0_123456`,把它当错误处理的表现是"双击图标什么都没发生"。
|
||||||
|
|
||||||
|
mod commands;
|
||||||
|
mod demo;
|
||||||
|
mod latency;
|
||||||
|
mod recording;
|
||||||
|
mod server;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
use std::error::Error;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use cam_core::config::{self, Config, Role};
|
||||||
|
use tauri::{Manager, RunEvent, WindowEvent};
|
||||||
|
|
||||||
|
use state::AppState;
|
||||||
|
|
||||||
|
const USAGE: &str = "\
|
||||||
|
用法: cam-desktop [选项]
|
||||||
|
|
||||||
|
--role <team|referee> 身份。默认看配置文件;没有配置文件时是 referee
|
||||||
|
--config <路径> 配置文件位置
|
||||||
|
--latency-report <路径> 打开裁判端屏幕标记校准,写入新的本地 NDJSON 报告
|
||||||
|
--demo 演示模式:画面与血量都是本地合成的
|
||||||
|
--list 打印发现的摄像头与装甲板后退出
|
||||||
|
-h, --help 显示这段帮助
|
||||||
|
|
||||||
|
身份由配置文件记住(~/.config/cam-desktop/config.toml),首次启动会在界面上问。
|
||||||
|
用户端的绑定改动需要密码;裁判端不需要。
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
CAM_DEMO=1 等价于 --demo
|
||||||
|
CAM_CONFIG=<路径> 等价于 --config <路径>
|
||||||
|
|
||||||
|
两个环境变量存在的理由很实际:`tauri dev` 要用两个 `--` 才能把参数递给应用
|
||||||
|
(`tauri dev -- -- --demo`),而写在一行环境变量里不会有人记错。
|
||||||
|
";
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
|
if let Err(err) = run() {
|
||||||
|
log::error!("{err}");
|
||||||
|
|
||||||
|
// 走到这里说明连窗口都没起来(配置坏了、回环端口绑不上)。GUI 程序从访达启动时
|
||||||
|
// 没有终端可看,所以把原因也写进一个旁边的文件——现场排查时这是唯一线索。
|
||||||
|
let path = config::default_config_path().with_extension("startup-error.txt");
|
||||||
|
let _ = std::fs::write(&path, format!("{err}\n"));
|
||||||
|
eprintln!("{USAGE}");
|
||||||
|
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 命令行参数。全部可选——什么都不给时按配置文件走。
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Args {
|
||||||
|
role: Option<Role>,
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
latency_report: Option<PathBuf>,
|
||||||
|
demo: bool,
|
||||||
|
list: bool,
|
||||||
|
help: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Args {
|
||||||
|
fn parse(argv: impl Iterator<Item = String>) -> Self {
|
||||||
|
let mut args = Args {
|
||||||
|
demo: std::env::var("CAM_DEMO").is_ok_and(|v| v == "1" || v == "true"),
|
||||||
|
config: std::env::var_os("CAM_CONFIG").map(PathBuf::from),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut it = argv.peekable();
|
||||||
|
|
||||||
|
while let Some(arg) = it.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"-h" | "--help" => args.help = true,
|
||||||
|
"--demo" => args.demo = true,
|
||||||
|
"--list" => args.list = true,
|
||||||
|
"--role" => {
|
||||||
|
args.role = it.next().and_then(|v| Role::parse(&v));
|
||||||
|
}
|
||||||
|
"--config" => {
|
||||||
|
args.config = it.next().map(PathBuf::from);
|
||||||
|
}
|
||||||
|
"--latency-report" => {
|
||||||
|
args.latency_report = it.next().map(PathBuf::from);
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
// 见模块文档:macOS 的 .app 启动参数与 shell 里的笔误都可能出现在这里,
|
||||||
|
// 但报错的代价(窗口起不来)远大于收益。
|
||||||
|
log::debug!("ignoring unknown argument {other:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<(), Box<dyn Error>> {
|
||||||
|
let args = Args::parse(std::env::args().skip(1));
|
||||||
|
|
||||||
|
if args.help {
|
||||||
|
print!("{USAGE}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_path = args
|
||||||
|
.config
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(config::default_config_path);
|
||||||
|
|
||||||
|
// 首次启动时导入旧客户端的配置,并把旧文件改名成 .bak。
|
||||||
|
let (mut config, notes) = config::load_with_migration(&config_path)?;
|
||||||
|
|
||||||
|
// 命令行覆盖配置文件,反过来不成立(只在内存里改)。这样现场可以把一台机器临时当
|
||||||
|
// 另一台用,而不动文件。
|
||||||
|
if let Some(role) = args.role {
|
||||||
|
config.role = role;
|
||||||
|
}
|
||||||
|
if args.latency_report.is_some() && config.role != Role::Referee {
|
||||||
|
return Err("Latency calibration requires a referee test configuration".into());
|
||||||
|
}
|
||||||
|
let latency_recorder = latency::Recorder::new(args.latency_report.clone())?;
|
||||||
|
|
||||||
|
for note in ¬es {
|
||||||
|
log::info!("{note}");
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"role={} team={} camera={:?} armor={:?} mirror={} demo={} config={}",
|
||||||
|
config.role.as_str(),
|
||||||
|
config.team,
|
||||||
|
config.camera_id,
|
||||||
|
config.armor_id,
|
||||||
|
config.mirror,
|
||||||
|
args.demo,
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let (state, video_socket, armor_socket) = AppState::new(config_path, config, notes, args.demo)?;
|
||||||
|
|
||||||
|
// 视频端口绑不上时没有 socket,接收线程也就没有意义——界面会显示那条说明。
|
||||||
|
if let Some(socket) = video_socket {
|
||||||
|
let receiver = Arc::clone(&state.receiver);
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("udp-reader".into())
|
||||||
|
.spawn(move || receiver.run(socket))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(socket) = armor_socket {
|
||||||
|
state.armor.spawn(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顺序有讲究:sink 必须先装好,后台任务才开始推——否则头几帧会掉在空处。
|
||||||
|
state.install_sinks();
|
||||||
|
|
||||||
|
// 视频通道的地址。
|
||||||
|
//
|
||||||
|
// 带 token 的那一版只在 debug 级:它是给本机开发者用 `wscat` 连上去看这条通道用的
|
||||||
|
// ("画面不动"时能立刻分清是后端没推还是前端没接)。info 级只报端口。
|
||||||
|
log::info!("stream: listening on ws://127.0.0.1:{}", state.port);
|
||||||
|
log::debug!("stream: {}", state.stream_info().url);
|
||||||
|
|
||||||
|
if args.demo {
|
||||||
|
state.note("演示模式已打开:画面与血量由本机合成,不是场上的板子。");
|
||||||
|
demo::spawn(Arc::clone(&state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --list:等一个广播周期,把发现到的东西打出来。
|
||||||
|
//
|
||||||
|
// 旧的 Slint 客户端在 release 的用户端直接拒绝这个选项(它会列出场上所有板子的 id
|
||||||
|
// 与 IP)。现在这条限制落在别处:用户端的发现列表只在**解锁后的设置面板**里放开,
|
||||||
|
// 而这里的输出只进日志,不进界面。
|
||||||
|
if args.list {
|
||||||
|
std::thread::sleep(Duration::from_secs(5));
|
||||||
|
|
||||||
|
println!("摄像头:");
|
||||||
|
for camera in state.snapshot().cameras {
|
||||||
|
println!(
|
||||||
|
" {} {} {}",
|
||||||
|
camera.id_text,
|
||||||
|
camera.addr,
|
||||||
|
if camera.online { "在线" } else { "离线" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("装甲板:");
|
||||||
|
for board in state.snapshot().armor_boards {
|
||||||
|
println!(
|
||||||
|
" {} {} 队伍{} {} 血",
|
||||||
|
board.id_text,
|
||||||
|
board.addr,
|
||||||
|
board.team + 1,
|
||||||
|
board.hp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.recordings.stop_all();
|
||||||
|
_ = state;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener_state = Arc::clone(&state);
|
||||||
|
|
||||||
|
let app = tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.manage(Arc::clone(&state))
|
||||||
|
.manage(latency_recorder)
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
latency::latency_enabled,
|
||||||
|
latency::latency_sample,
|
||||||
|
commands::stream_info,
|
||||||
|
commands::get_snapshot,
|
||||||
|
commands::get_config,
|
||||||
|
commands::list_media_files,
|
||||||
|
commands::config_path,
|
||||||
|
commands::unlock,
|
||||||
|
commands::lock,
|
||||||
|
commands::set_password,
|
||||||
|
commands::set_discovery_open,
|
||||||
|
commands::bind_camera,
|
||||||
|
commands::bind_armor,
|
||||||
|
commands::set_team,
|
||||||
|
commands::set_role,
|
||||||
|
commands::set_mirror,
|
||||||
|
commands::save_hud,
|
||||||
|
commands::set_team_name,
|
||||||
|
commands::set_referee_slots,
|
||||||
|
commands::set_recording_options,
|
||||||
|
commands::screenshot,
|
||||||
|
commands::start_recording,
|
||||||
|
commands::stop_recording,
|
||||||
|
commands::reveal,
|
||||||
|
commands::open_file,
|
||||||
|
commands::clear_config,
|
||||||
|
commands::quit_app,
|
||||||
|
commands::refresh,
|
||||||
|
])
|
||||||
|
.setup(move |_app| {
|
||||||
|
listener_state.spawn_background();
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
// 用户端未解锁时拦住"关窗口"与"退出"。
|
||||||
|
//
|
||||||
|
// 这是需求里明确要的(退出/关闭也要密码),所以这里把 close 拦下来并让前端弹
|
||||||
|
// 解锁框。拦不住的路径仍然存在——`kill -9`、强退、拔电源——所以它同样是"防
|
||||||
|
// 随手误操作",不是安全边界。
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
let state = window.state::<Arc<AppState>>();
|
||||||
|
|
||||||
|
if state.config().role.needs_password() && !state.is_unlocked() {
|
||||||
|
api.prevent_close();
|
||||||
|
state.push(server::WireMsg::ConfirmQuit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(tauri::generate_context!())?;
|
||||||
|
|
||||||
|
app.run(|app, event| {
|
||||||
|
if let RunEvent::ExitRequested { api, .. } = &event {
|
||||||
|
let state = app.state::<Arc<AppState>>();
|
||||||
|
|
||||||
|
if state.config().role.needs_password() && !state.is_unlocked() {
|
||||||
|
api.prevent_exit();
|
||||||
|
state.push(server::WireMsg::ConfirmQuit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let RunEvent::Exit = event {
|
||||||
|
// 收尾录制:ffmpeg 需要把索引写完,否则留下一个放不了的 mp4。
|
||||||
|
let state = app.state::<Arc<AppState>>();
|
||||||
|
let stopped = state.recordings.stop_all();
|
||||||
|
|
||||||
|
for dto in stopped {
|
||||||
|
log::info!(
|
||||||
|
"退出:0x{:08X} 收了 {} 帧 -> {:?}",
|
||||||
|
dto.camera,
|
||||||
|
dto.frames_written,
|
||||||
|
dto.path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 让 [`Config`] 在日志里可读。
|
||||||
|
///
|
||||||
|
/// 实现成自由函数而不是给 `Config` 加 `Display`:那是 cam-core 的公共类型,而"日志里长
|
||||||
|
/// 什么样"是这个二进制自己的事。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn describe(config: &Config) -> String {
|
||||||
|
format!(
|
||||||
|
"role={} team={} camera={:?} armor={:?}",
|
||||||
|
config.role.as_str(),
|
||||||
|
config.team,
|
||||||
|
config.camera_id,
|
||||||
|
config.armor_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::Args;
|
||||||
|
|
||||||
|
fn args(list: &[&str]) -> Args {
|
||||||
|
Args::parse(list.iter().map(|s| s.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_are_all_optional() {
|
||||||
|
let parsed = args(&[]);
|
||||||
|
assert!(parsed.role.is_none(), "the config file decides");
|
||||||
|
assert!(!parsed.demo);
|
||||||
|
assert!(!parsed.help);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_and_config_are_read() {
|
||||||
|
let parsed = args(&["--role", "team", "--config", "/tmp/x.toml"]);
|
||||||
|
assert_eq!(parsed.role, Some(cam_core::Role::Team));
|
||||||
|
assert_eq!(parsed.config.unwrap().to_string_lossy(), "/tmp/x.toml");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mac_app_launch_argument_does_not_break_startup() {
|
||||||
|
// 从访达双击 .app 时系统会塞进来这个。把它当错误处理的表现是"双击没反应"。
|
||||||
|
let parsed = args(&["-psn_0_123456"]);
|
||||||
|
assert!(parsed.role.is_none());
|
||||||
|
assert!(!parsed.help);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_role_value_falls_back_to_the_config_rather_than_dying() {
|
||||||
|
let parsed = args(&["--role", "teem"]);
|
||||||
|
assert!(parsed.role.is_none(), "typo must not kill the window");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demo_can_be_turned_on_from_the_command_line() {
|
||||||
|
assert!(args(&["--demo"]).demo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,846 @@
|
|||||||
|
//! 录制为 mp4,以及录制文件的清点。
|
||||||
|
//!
|
||||||
|
//! # 为什么用 ffmpeg 子进程而不是 Rust 编码器
|
||||||
|
//!
|
||||||
|
//! 两路 640x480@22fps 的 H.264 编码在 Rust 侧要引一个大而重的编码器绑定;而现场机器上
|
||||||
|
//! 已经有 ffmpeg(`/opt/homebrew/bin/ffmpeg`),它是硬编码/多线程都调好的。代价是多一个
|
||||||
|
//! **外部依赖**——所以 [Recorder::start] 在 ffmpeg 起不来时返回一个明确的错误,而不是
|
||||||
|
//! 让界面看起来"在录",其实什么都没写。
|
||||||
|
//!
|
||||||
|
//! # 为什么帧率要显式告诉 ffmpeg
|
||||||
|
//!
|
||||||
|
//! `image2pipe` 里的 JPEG 序列**没有时间戳**:一串帧在 ffmpeg 看来就是"以固定帧率播放
|
||||||
|
//! 的图片"。所以必须由我们给出这个数,而它只能来自实测([`crate::state`] 里的帧率窗口)。
|
||||||
|
//! 猜错的表现是回放快放或慢放——而且看不出来是哪里错了,所以在 DTO 与文档里都写明了。
|
||||||
|
//!
|
||||||
|
//! # 为什么经由一个线程和一条有界队列
|
||||||
|
//!
|
||||||
|
//! 喂帧发生在 UDP 接收线程里,而**接收线程绝不能被写盘拖慢**:那一天的表现是链路上
|
||||||
|
//! 本不存在的丢帧。所以中间隔一条长度 64 的队列:写不进去就把帧丢掉并计数,丢帧数会
|
||||||
|
//! 出现在界面上——少了半秒却不说,会看起来像相机的问题。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{ErrorKind, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use cam_core::snapshot::{id_text, RecordingDto};
|
||||||
|
use protocol::Frame;
|
||||||
|
|
||||||
|
/// 写入线程的队列长度。
|
||||||
|
///
|
||||||
|
/// 22fps 下这是约 3 秒的缓冲:够吸收一次写盘的抖动,又不至于在 ffmpeg 卡死时把内存
|
||||||
|
/// 堆起来。
|
||||||
|
const QUEUE: usize = 64;
|
||||||
|
|
||||||
|
/// Unix 毫秒。
|
||||||
|
pub fn now_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次录制。
|
||||||
|
pub struct Recorder {
|
||||||
|
camera: u32,
|
||||||
|
path: PathBuf,
|
||||||
|
started_ms: u64,
|
||||||
|
input_fps: f32,
|
||||||
|
/// `None` 表示已经停止喂帧(队列那头的线程会随之收尾)。
|
||||||
|
tx: Option<SyncSender<Arc<Frame>>>,
|
||||||
|
frames_written: Arc<AtomicU64>,
|
||||||
|
frames_dropped: Arc<AtomicU64>,
|
||||||
|
error: Arc<Mutex<Option<String>>>,
|
||||||
|
finish: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Recorder {
|
||||||
|
/// 启动一路录制。
|
||||||
|
///
|
||||||
|
/// `fps` 是喂给 ffmpeg 的输入帧率,调用方负责给出实测值。
|
||||||
|
pub fn start(
|
||||||
|
camera: u32,
|
||||||
|
dir: &Path,
|
||||||
|
ffmpeg: &str,
|
||||||
|
fps: f32,
|
||||||
|
crf: u8,
|
||||||
|
preset: &str,
|
||||||
|
) -> Result<Self> {
|
||||||
|
std::fs::create_dir_all(dir)
|
||||||
|
.with_context(|| format!("creating the recording directory {}", dir.display()))?;
|
||||||
|
|
||||||
|
// 文件名里带秒级时间戳,但**必须**再查一次有没有同名——同一秒里停一次再开一次
|
||||||
|
// 是很自然的操作("这局重录"),而重名意味着 ffmpeg 的 `-y` 会把上一段直接覆盖掉,
|
||||||
|
// 没有任何提示。现场丢掉的那段往往正好是要复盘的那一段。
|
||||||
|
let path = unique_take_path(dir, camera);
|
||||||
|
|
||||||
|
let mut child = Command::new(ffmpeg)
|
||||||
|
.args(["-hide_banner", "-loglevel", "error", "-y"])
|
||||||
|
// 输入:一串 JPEG。-framerate 必须在 -i 之前,它是 image2 解复用器的选项。
|
||||||
|
.args(["-f", "image2pipe", "-c:v", "mjpeg"])
|
||||||
|
.args(["-framerate", &format!("{fps:.4}")])
|
||||||
|
.args(["-i", "pipe:0"])
|
||||||
|
.args(["-an"])
|
||||||
|
.args(["-c:v", "libx264", "-preset", preset])
|
||||||
|
.args(["-crf", &crf.to_string()])
|
||||||
|
// yuv420p + faststart:前者让文件在任何播放器里都能放,后者把索引挪到文件头,
|
||||||
|
// 于是分享出去的 mp4 可以边下边播。
|
||||||
|
.args(["-pix_fmt", "yuv420p", "-movflags", "+faststart"])
|
||||||
|
.arg(&path)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| {
|
||||||
|
format!("could not start ffmpeg ({ffmpeg}); 用 [recording] ffmpeg 指定它的路径")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let stdin = child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| anyhow!("ffmpeg was started without a stdin pipe"))?;
|
||||||
|
|
||||||
|
let (tx, rx) = sync_channel::<Arc<Frame>>(QUEUE);
|
||||||
|
let frames_written = Arc::new(AtomicU64::new(0));
|
||||||
|
let frames_dropped = Arc::new(AtomicU64::new(0));
|
||||||
|
let error = Arc::new(Mutex::new(None));
|
||||||
|
|
||||||
|
let finish = {
|
||||||
|
let frames_written = Arc::clone(&frames_written);
|
||||||
|
let error = Arc::clone(&error);
|
||||||
|
let path = path.clone();
|
||||||
|
|
||||||
|
thread::Builder::new()
|
||||||
|
.name(format!("rec-writer-{}", id_text(camera)))
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(err) = pump(&rx, stdin, &mut child, &frames_written) {
|
||||||
|
log::error!("recording {} stopped: {err:#}", path.display());
|
||||||
|
*error.lock().expect("rec error mutex poisoned") = Some(format!("{err:#}"));
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"recording 0x{camera:08X} -> {} ({fps:.1} fps in, crf {crf}, {preset})",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
camera,
|
||||||
|
path,
|
||||||
|
started_ms: now_ms(),
|
||||||
|
input_fps: fps,
|
||||||
|
tx: Some(tx),
|
||||||
|
frames_written,
|
||||||
|
frames_dropped,
|
||||||
|
error,
|
||||||
|
finish: Some(finish),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 喂一帧。写不进去就计一次丢帧,不阻塞。
|
||||||
|
pub fn feed(&self, frame: &Arc<Frame>) {
|
||||||
|
let Some(tx) = self.tx.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match tx.try_send(Arc::clone(frame)) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(TrySendError::Full(_)) => {
|
||||||
|
self.frames_dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
// 写入线程已经结束(ffmpeg 退出):由 `error` 记录原因,这里不再重复报。
|
||||||
|
Err(TrySendError::Disconnected(_)) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn frames_written(&self) -> u64 {
|
||||||
|
self.frames_written.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn frames_dropped(&self) -> u64 {
|
||||||
|
self.frames_dropped.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(&self) -> Option<String> {
|
||||||
|
self.error.lock().expect("rec error mutex poisoned").clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止并等 ffmpeg 把文件收尾(`+faststart` 要重写一遍索引,所以这一步不是瞬时的)。
|
||||||
|
pub fn stop(mut self) -> RecordingDto {
|
||||||
|
// 丢掉发送端 ⇒ 写入线程的 recv 返回 Err ⇒ 它关掉 stdin 并 wait 子进程。
|
||||||
|
drop(self.tx.take());
|
||||||
|
|
||||||
|
if let Some(handle) = self.finish.take() {
|
||||||
|
if handle.join().is_err() {
|
||||||
|
log::warn!("the recording writer thread panicked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut dto = self.dto();
|
||||||
|
dto.active = false;
|
||||||
|
|
||||||
|
if let Some(error) = dto.error.as_deref() {
|
||||||
|
log::warn!(
|
||||||
|
"recording 0x{:08X} finished with an error: {error}",
|
||||||
|
self.camera
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"recording 0x{:08X} stopped: {} frames -> {}",
|
||||||
|
self.camera,
|
||||||
|
dto.frames_written,
|
||||||
|
self.path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dto
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dto(&self) -> RecordingDto {
|
||||||
|
RecordingDto {
|
||||||
|
camera: self.camera,
|
||||||
|
camera_text: id_text(self.camera),
|
||||||
|
active: self.tx.is_some(),
|
||||||
|
path: Some(self.path.display().to_string()),
|
||||||
|
input_fps: self.input_fps,
|
||||||
|
frames_written: self.frames_written(),
|
||||||
|
frames_dropped: self.frames_dropped(),
|
||||||
|
started_ms: Some(self.started_ms),
|
||||||
|
error: self.error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 给一段新录制挑一个不会被占用的文件名。
|
||||||
|
///
|
||||||
|
/// `rec-00000001-2026-10-14_19-12-05.mp4`,同秒重名时往后排 `-2`、`-3`。
|
||||||
|
fn unique_take_path(dir: &Path, camera: u32) -> PathBuf {
|
||||||
|
let base = format!(
|
||||||
|
"rec-{}-{}",
|
||||||
|
id_text(camera).trim_start_matches("0x"),
|
||||||
|
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S")
|
||||||
|
);
|
||||||
|
|
||||||
|
unique_path(dir, &base, "mp4")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 挑一个还没被占用的路径:`<base>.<ext>` → `<base>-2.<ext>` → `<base>-3.<ext>`。
|
||||||
|
///
|
||||||
|
/// 录制与截图共用它。时间戳只到秒(截图到毫秒),所以"同一秒停一次再开一次"这种事
|
||||||
|
/// 会挑到同一个名字——而 ffmpeg 的 `-y` 与图片写入都会**静默覆盖**。现场丢掉的那一段
|
||||||
|
/// 往往正好是要复盘的那一段。
|
||||||
|
fn unique_path(dir: &Path, base: &str, ext: &str) -> PathBuf {
|
||||||
|
let mut candidate = dir.join(format!("{base}.{ext}"));
|
||||||
|
|
||||||
|
for n in 2..1000 {
|
||||||
|
if !candidate.exists() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
candidate = dir.join(format!("{base}-{n}.{ext}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把一张 JPEG 存成 PNG。
|
||||||
|
///
|
||||||
|
/// 从命令里抽出来是为了能测:这条路径里唯一可能出错的是"JPEG 解不开"与"PNG 写不出去",
|
||||||
|
/// 两者都值得有一个直接对着它们写的测试——现场截图失败却不报错,等于证据没留下来。
|
||||||
|
pub fn save_screenshot(jpeg: &[u8], dir: &Path, camera: u32) -> Result<PathBuf> {
|
||||||
|
std::fs::create_dir_all(dir)
|
||||||
|
.with_context(|| format!("creating the screenshot directory {}", dir.display()))?;
|
||||||
|
|
||||||
|
let path = unique_path(
|
||||||
|
dir,
|
||||||
|
&format!(
|
||||||
|
"shot-{camera:08X}-{}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S-%3f")
|
||||||
|
),
|
||||||
|
"png",
|
||||||
|
);
|
||||||
|
|
||||||
|
let image = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg)
|
||||||
|
.map_err(|err| anyhow!("这一帧不是能解码的 JPEG:{err}"))?;
|
||||||
|
|
||||||
|
image
|
||||||
|
.save_with_format(&path, image::ImageFormat::Png)
|
||||||
|
.map_err(|err| anyhow!("写不了 {}:{err}", path.display()))?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入线程的主体:把队列里的帧写进 ffmpeg 的 stdin,队列关闭后收尾。
|
||||||
|
fn pump(
|
||||||
|
rx: &std::sync::mpsc::Receiver<Arc<Frame>>,
|
||||||
|
mut stdin: ChildStdin,
|
||||||
|
child: &mut Child,
|
||||||
|
frames_written: &AtomicU64,
|
||||||
|
) -> Result<()> {
|
||||||
|
while let Ok(frame) = rx.recv() {
|
||||||
|
if let Err(err) = stdin.write_all(&frame.data) {
|
||||||
|
// 管道断了通常意味着 ffmpeg 已经退出(参数不对、磁盘满)。把它的 stderr
|
||||||
|
// 读出来拼进错误里:只说"Broken pipe"对现场毫无帮助。
|
||||||
|
let reason = child_stderr(child);
|
||||||
|
return Err(anyhow!(
|
||||||
|
"writing to ffmpeg failed: {err}{}",
|
||||||
|
reason
|
||||||
|
.map(|r| format!(";ffmpeg 说:{r}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
frames_written.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关掉 stdin 是让 ffmpeg 知道"没有更多帧了"的唯一方式。
|
||||||
|
drop(stdin);
|
||||||
|
|
||||||
|
match child.wait() {
|
||||||
|
Ok(status) if status.success() => Ok(()),
|
||||||
|
Ok(status) => {
|
||||||
|
let reason = child_stderr(child);
|
||||||
|
Err(anyhow!(
|
||||||
|
"ffmpeg exited with {status}{}",
|
||||||
|
reason.map(|r| format!(":{r}")).unwrap_or_default()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(err) => Err(anyhow!("could not wait for ffmpeg: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读一下子进程的 stderr(已经读完则返回 `None`)。
|
||||||
|
fn child_stderr(child: &mut Child) -> Option<String> {
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
let mut buf = String::new();
|
||||||
|
let stderr = child.stderr.as_mut()?;
|
||||||
|
|
||||||
|
// 管道是非阻塞读的话这里会直接返回;否则 ffmpeg 已经退出,读得到 EOF。
|
||||||
|
match stderr.read_to_string(&mut buf) {
|
||||||
|
Ok(0) => None,
|
||||||
|
Ok(_) => Some(buf.trim().to_string()),
|
||||||
|
Err(err) if err.kind() == ErrorKind::WouldBlock => None,
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 所有正在进行的录制。
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct RecordingSet {
|
||||||
|
inner: Mutex<HashMap<u32, Recorder>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingSet {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(
|
||||||
|
&self,
|
||||||
|
camera: u32,
|
||||||
|
dir: &Path,
|
||||||
|
ffmpeg: &str,
|
||||||
|
fps: f32,
|
||||||
|
crf: u8,
|
||||||
|
preset: &str,
|
||||||
|
) -> Result<RecordingDto> {
|
||||||
|
// **先停掉同一路已有的那次录制,再开新的。**
|
||||||
|
//
|
||||||
|
// 顺序不是随便定的:文件名唯一性靠"这个路径已经被占用了"来判断,而 ffmpeg 的输出
|
||||||
|
// 文件是它启动之后才创建的。先开新的话,新的一段会挑到与上一段相同的名字,然后被
|
||||||
|
// 它的 `-y` 覆盖掉——而上一段刚要收尾。先停就能保证上一个文件已经落地,`exists()`
|
||||||
|
// 于是是可信的。
|
||||||
|
let previous = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("recording mutex poisoned")
|
||||||
|
.remove(&camera);
|
||||||
|
|
||||||
|
if let Some(previous) = previous {
|
||||||
|
log::warn!("0x{camera:08X} was already recording; the previous take is being closed");
|
||||||
|
drop(previous.stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
let recorder = Recorder::start(camera, dir, ffmpeg, fps, crf, preset)?;
|
||||||
|
let dto = recorder.dto();
|
||||||
|
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.expect("recording mutex poisoned")
|
||||||
|
.insert(camera, recorder);
|
||||||
|
|
||||||
|
Ok(dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self, camera: u32) -> Option<RecordingDto> {
|
||||||
|
let recorder = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("recording mutex poisoned")
|
||||||
|
.remove(&camera)?;
|
||||||
|
|
||||||
|
Some(recorder.stop())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_all(&self) -> Vec<RecordingDto> {
|
||||||
|
let recorders: Vec<Recorder> = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("recording mutex poisoned")
|
||||||
|
.drain()
|
||||||
|
.map(|(_, recorder)| recorder)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
recorders.into_iter().map(Recorder::stop).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把一帧喂给对应那一路的录制器。在 UDP 接收线程里被调用,所以必须快。
|
||||||
|
pub fn feed(&self, frame: &Arc<Frame>) {
|
||||||
|
// 先快速判断有没有在录,避免每帧都抢锁。
|
||||||
|
if self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("recording mutex poisoned")
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner = self.inner.lock().expect("recording mutex poisoned");
|
||||||
|
if let Some(recorder) = inner.get(&frame.sender_id) {
|
||||||
|
recorder.feed(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active(&self) -> Vec<RecordingDto> {
|
||||||
|
let inner = self.inner.lock().expect("recording mutex poisoned");
|
||||||
|
let mut list: Vec<RecordingDto> = inner.values().map(Recorder::dto).collect();
|
||||||
|
|
||||||
|
list.sort_by_key(|dto| dto.camera);
|
||||||
|
list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一段已经录好的文件或截图。
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MediaFileDto {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub bytes: u64,
|
||||||
|
pub modified_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出目录里的 mp4 与 png,新的在前。
|
||||||
|
///
|
||||||
|
/// 只认这两种后缀:目录是给人看的,不该因为里面放了个 `.DS_Store` 就报错或显示出来。
|
||||||
|
pub fn list_media(dir: &Path) -> Vec<MediaFileDto> {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let kind = match ext.to_ascii_lowercase().as_str() {
|
||||||
|
"mp4" => "video",
|
||||||
|
"png" => "image",
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(meta) = entry.metadata() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
out.push(MediaFileDto {
|
||||||
|
name: path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
path: path.display().to_string(),
|
||||||
|
kind: kind.to_string(),
|
||||||
|
bytes: meta.len(),
|
||||||
|
modified_ms: meta
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use protocol::fragment_frame;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// 合成一张能被 ffmpeg 解出来的 JPEG。
|
||||||
|
fn jpeg(width: u32, height: u32, seed: u8) -> Vec<u8> {
|
||||||
|
let mut img = image::RgbImage::new(width, height);
|
||||||
|
for (x, y, pixel) in img.enumerate_pixels_mut() {
|
||||||
|
*pixel = image::Rgb([
|
||||||
|
(x as u8).wrapping_add(seed),
|
||||||
|
(y as u8).wrapping_add(seed),
|
||||||
|
seed,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80);
|
||||||
|
encoder.encode_image(&img).expect("encodes");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(camera: u32, id: u32) -> Arc<Frame> {
|
||||||
|
Arc::new(Frame {
|
||||||
|
sender_id: camera,
|
||||||
|
frame_id: id,
|
||||||
|
width: 64,
|
||||||
|
height: 48,
|
||||||
|
data: jpeg(64, 48, id as u8),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_ffmpeg() -> bool {
|
||||||
|
Command::new("ffmpeg")
|
||||||
|
.arg("-version")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_dir(tag: &str) -> PathBuf {
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("cam-desktop-rec-{tag}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).expect("creates the temp dir");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_ffmpeg_is_an_error_not_a_silent_no_op() {
|
||||||
|
let dir = temp_dir("no-ffmpeg");
|
||||||
|
// 不用 expect_err:那要求 Ok 那一侧实现 Debug,而 Recorder 持有着管道与线程,
|
||||||
|
// 给它加 Debug 只是为了写测试,不划算。
|
||||||
|
let err = match Recorder::start(1, &dir, "definitely-not-ffmpeg", 20.0, 20, "veryfast") {
|
||||||
|
Ok(_) => panic!("must refuse to pretend it is recording"),
|
||||||
|
Err(err) => err,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("ffmpeg"),
|
||||||
|
"the message must name the missing tool: {err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recording_produces_a_playable_mp4() {
|
||||||
|
// 这一条真的会起 ffmpeg 并写出一个文件,然后用 ffprobe 读回它的帧数与分辨率。
|
||||||
|
// 只用"文件存在且非空"来断言是不够的:ffmpeg 参数写错时也会留下一个空的
|
||||||
|
// 容器,而那正是最难在现场发现的一种失败。
|
||||||
|
if !has_ffmpeg() {
|
||||||
|
eprintln!("skipping: no ffmpeg on this machine");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = temp_dir("mp4");
|
||||||
|
let set = RecordingSet::new();
|
||||||
|
|
||||||
|
set.start(0xAAAA, &dir, "ffmpeg", 20.0, 23, "veryfast")
|
||||||
|
.expect("starts");
|
||||||
|
for i in 0..20 {
|
||||||
|
set.feed(&frame(0xAAAA, i));
|
||||||
|
}
|
||||||
|
// 让写入线程把队列排空再停。
|
||||||
|
for _ in 0..50 {
|
||||||
|
thread::sleep(Duration::from_millis(10));
|
||||||
|
if set.active()[0].frames_written >= 20 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dto = set.stop(0xAAAA).expect("was recording");
|
||||||
|
assert_eq!(dto.frames_written, 20, "all 20 frames reached ffmpeg");
|
||||||
|
assert_eq!(dto.frames_dropped, 0);
|
||||||
|
assert!(dto.error.is_none(), "ffmpeg said: {:?}", dto.error);
|
||||||
|
|
||||||
|
let path = dto.path.expect("has a path");
|
||||||
|
let meta = std::fs::metadata(&path).expect("the file exists");
|
||||||
|
assert!(
|
||||||
|
meta.len() > 0,
|
||||||
|
"an empty container is a failure, not a take"
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some((frames, width, height)) = ffprobe_frames(&path) {
|
||||||
|
assert_eq!(frames, 20, "ffmpeg wrote every frame we fed it");
|
||||||
|
assert_eq!((width, height), (64, 48));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn feeding_after_stop_does_not_panic() {
|
||||||
|
if !has_ffmpeg() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = temp_dir("after-stop");
|
||||||
|
let set = RecordingSet::new();
|
||||||
|
set.start(0xBBBB, &dir, "ffmpeg", 20.0, 23, "veryfast")
|
||||||
|
.unwrap();
|
||||||
|
let held = frame(0xBBBB, 0);
|
||||||
|
set.stop(0xBBBB);
|
||||||
|
|
||||||
|
// 停止之后接收线程可能还有一帧在手。
|
||||||
|
set.feed(&held);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn starting_twice_closes_the_first_take_instead_of_leaking_it() {
|
||||||
|
if !has_ffmpeg() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = temp_dir("twice");
|
||||||
|
let set = RecordingSet::new();
|
||||||
|
|
||||||
|
// 第一段:喂几帧并等它们真的落到 ffmpeg。写盘是异步的,不等到位的话这次录制会是
|
||||||
|
// 零帧,而零帧时 ffmpeg 连输出文件都不会创建——那测的就不是同一件事了。
|
||||||
|
set.start(1, &dir, "ffmpeg", 20.0, 23, "veryfast").unwrap();
|
||||||
|
for i in 0..8 {
|
||||||
|
set.feed(&frame(1, i));
|
||||||
|
}
|
||||||
|
wait_for_frames(&set, 1, 8);
|
||||||
|
|
||||||
|
// 第二段:这里会把第一段收尾,并另取一个文件名。
|
||||||
|
let second = set.start(1, &dir, "ffmpeg", 20.0, 23, "veryfast").unwrap();
|
||||||
|
for i in 10..18 {
|
||||||
|
set.feed(&frame(1, i));
|
||||||
|
}
|
||||||
|
wait_for_frames(&set, 1, 8);
|
||||||
|
|
||||||
|
assert_eq!(set.active().len(), 1, "只有一路在录");
|
||||||
|
assert_eq!(set.stop_all().len(), 1);
|
||||||
|
|
||||||
|
let files = list_media(&dir);
|
||||||
|
assert_eq!(files.len(), 2, "两段各自留下一个文件:{files:?}");
|
||||||
|
assert!(
|
||||||
|
files.iter().all(|file| file.bytes > 0),
|
||||||
|
"两段都得有内容,空容器不算一次录制:{files:?}"
|
||||||
|
);
|
||||||
|
assert!(second.path.is_some(), "第二段也应该有自己的路径");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等到某一路写进了至少 `want` 帧,或者超时。
|
||||||
|
fn wait_for_frames(set: &RecordingSet, camera: u32, want: u64) {
|
||||||
|
for _ in 0..100 {
|
||||||
|
let written = set
|
||||||
|
.active()
|
||||||
|
.iter()
|
||||||
|
.find(|dto| dto.camera == camera)
|
||||||
|
.map(|dto| dto.frames_written)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if written >= want {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
panic!("0x{camera:08X} 没有把 {want} 帧写进 ffmpeg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_takes_in_the_same_second_do_not_overwrite_each_other() {
|
||||||
|
// 同一秒里停一次再开一次是现场很自然的操作("这局重录")。重名的后果是 ffmpeg 的
|
||||||
|
// `-y` 静默覆盖上一段——而丢掉的那段往往正好是要复盘的那段。
|
||||||
|
let dir = temp_dir("unique");
|
||||||
|
let first = unique_take_path(&dir, 0xAAAA);
|
||||||
|
std::fs::write(&first, b"").unwrap();
|
||||||
|
|
||||||
|
let second = unique_take_path(&dir, 0xAAAA);
|
||||||
|
|
||||||
|
assert_ne!(first, second);
|
||||||
|
assert!(
|
||||||
|
second.to_string_lossy().ends_with("-2.mp4"),
|
||||||
|
"got {}",
|
||||||
|
second.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::write(&second, b"").unwrap();
|
||||||
|
let third = unique_take_path(&dir, 0xAAAA);
|
||||||
|
assert!(third.to_string_lossy().ends_with("-3.mp4"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_screenshot_is_a_readable_png_of_the_same_size() {
|
||||||
|
let dir = temp_dir("shots");
|
||||||
|
let path = save_screenshot(&jpeg(64, 48, 7), &dir, 0x3D4D_8A9A).expect("saves");
|
||||||
|
|
||||||
|
let decoded = image::open(&path).expect("the PNG is readable");
|
||||||
|
assert_eq!((decoded.width(), decoded.height()), (64, 48));
|
||||||
|
assert!(
|
||||||
|
path.file_name()
|
||||||
|
.unwrap()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with("shot-3D4D8A9A-"),
|
||||||
|
"文件名要能看出是哪一路截的:{path:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_screenshots_never_land_on_the_same_file() {
|
||||||
|
let dir = temp_dir("shots-twice");
|
||||||
|
|
||||||
|
let first = save_screenshot(&jpeg(32, 32, 1), &dir, 1).unwrap();
|
||||||
|
let second = save_screenshot(&jpeg(32, 32, 2), &dir, 1).unwrap();
|
||||||
|
|
||||||
|
assert_ne!(first, second, "两次截图不能落在同一个文件上");
|
||||||
|
assert!(first.to_string_lossy().contains("shot-00000001-"));
|
||||||
|
assert!(second.to_string_lossy().contains("shot-00000001-"));
|
||||||
|
|
||||||
|
// 时间戳到毫秒,所以这两次通常落在不同的毫秒上,各自拿到自己的名字。真正要挡的
|
||||||
|
// 是**同一毫秒**内连按两次,那由 `unique_path` 的后缀兜住——下面直接验证它。
|
||||||
|
let forced = unique_path(&dir, "shot-00000001-fixed", "png");
|
||||||
|
std::fs::write(&forced, b"").unwrap();
|
||||||
|
let forced2 = unique_path(&dir, "shot-00000001-fixed", "png");
|
||||||
|
assert!(
|
||||||
|
forced2.to_string_lossy().ends_with("-2.png"),
|
||||||
|
"got {forced2:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_broken_frame_is_an_error_not_an_empty_png() {
|
||||||
|
let dir = temp_dir("shots-bad");
|
||||||
|
let err = save_screenshot(b"not a jpeg at all", &dir, 1).unwrap_err();
|
||||||
|
|
||||||
|
assert!(err.to_string().contains("JPEG"), "got {err}");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_listing_ignores_anything_that_is_not_a_take() {
|
||||||
|
let dir = temp_dir("listing");
|
||||||
|
std::fs::write(dir.join("a.mp4"), b"x").unwrap();
|
||||||
|
std::fs::write(dir.join("b.PNG"), b"x").unwrap();
|
||||||
|
std::fs::write(dir.join("notes.txt"), b"x").unwrap();
|
||||||
|
std::fs::write(dir.join(".DS_Store"), b"x").unwrap();
|
||||||
|
|
||||||
|
let files = list_media(&dir);
|
||||||
|
let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
|
||||||
|
|
||||||
|
assert_eq!(names.len(), 2, "got {names:?}");
|
||||||
|
assert!(names.contains(&"a.mp4"));
|
||||||
|
assert!(
|
||||||
|
names.contains(&"b.PNG"),
|
||||||
|
"后缀大小写不该决定它是不是一张截图"
|
||||||
|
);
|
||||||
|
assert_eq!(files[0].kind, "video");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn listing_a_missing_directory_is_empty_not_an_error() {
|
||||||
|
assert!(list_media(Path::new("/nonexistent/recordings")).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_file_name_says_which_camera_and_when() {
|
||||||
|
// 现场翻目录时要能一眼看出是哪一路、哪一局。
|
||||||
|
assert!(id_text(0x3D4D_8A9A).ends_with("3D4D8A9A"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用 ffprobe 读回帧数与分辨率。ffprobe 不在就返回 `None`(跳过那几条断言)。
|
||||||
|
fn ffprobe_frames(path: &str) -> Option<(i64, u32, u32)> {
|
||||||
|
let out = Command::new("ffprobe")
|
||||||
|
.args([
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
"-count_frames",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=nb_read_frames,width,height",
|
||||||
|
"-of",
|
||||||
|
"default=noprint_wrappers=1",
|
||||||
|
path,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let mut frames = None;
|
||||||
|
let mut width = None;
|
||||||
|
let mut height = None;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
match key {
|
||||||
|
"nb_read_frames" => frames = value.parse().ok(),
|
||||||
|
"width" => width = value.parse().ok(),
|
||||||
|
"height" => height = value.parse().ok(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((frames?, width?, height?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fragmented_frames_are_not_what_the_recorder_wants() {
|
||||||
|
// 录制器收的是**重组之后**的整帧(sink 传进来的就是),不是分片。这里把这个
|
||||||
|
// 约定钉下来:如果哪天有人在接收路径上提前喂分片,测试会立刻说明原因。
|
||||||
|
let frags = fragment_frame(1, 1, &jpeg(64, 48, 1), 64, 48);
|
||||||
|
assert!(frags.len() == 1, "one small JPEG is one fragment");
|
||||||
|
|
||||||
|
let whole = frame(1, 1);
|
||||||
|
assert!(whole.data.len() > 2, "a real JPEG, not a fragment header");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
//! 界面与后端之间的视频通道:一个只监听回环地址的 WebSocket。
|
||||||
|
//!
|
||||||
|
//! # 为什么不是 Tauri 的 IPC
|
||||||
|
//!
|
||||||
|
//! 两路 22fps、每帧约 22 kB,也就是每秒约 1 MB 的裸 JPEG。走 IPC 的话它要被序列化成
|
||||||
|
//! JSON(base64 再涨 1/3),再在 JS 侧解回来——CPU 花在没人需要的编解码上,延迟也白
|
||||||
|
//! 涨一截。WebSocket 的二进制帧把原始 JPEG 字节直接交给前端,浏览器用原生解码器
|
||||||
|
//! (`createImageBitmap`)解,**后端连 JPEG 都不用解**。
|
||||||
|
//!
|
||||||
|
//! # 为什么要有 token
|
||||||
|
//!
|
||||||
|
//! 端口是随机分配的,但同机器上任何进程都能连回环地址。这条通道里有实时画面,给它一
|
||||||
|
//! 个每次启动随机生成的 token(前端从 `stream_info` 命令拿到),至少让"另一个本地程序
|
||||||
|
//! 顺手连上来看画面"变成一件需要先读到那个 token 的事。
|
||||||
|
//!
|
||||||
|
//! 说清楚它的边界:这**不是**防对手的措施——对手要看画面得先在自己的机器上跑这套客户
|
||||||
|
//! 端,那道闸门在 [`cam_core::receiver::Receiver`] 里。这里的 token 只防止本机上的顺手
|
||||||
|
//! 连接。
|
||||||
|
//!
|
||||||
|
//! # 二进制帧的布局
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! 0 4 8 10 12 16
|
||||||
|
//! +--------------+--------------+------+------+--------------+---------+
|
||||||
|
//! | sender_id u32| frame_id u32 | w u16| h u16| jpeg_len u32 | JPEG... |
|
||||||
|
//! +--------------+--------------+------+------+--------------+---------+
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 小端。16 字节头,前端切掉它就能拿到一段完整的 JPEG。`sender_id` 让前端知道这一帧是
|
||||||
|
//! 哪块板子的——用户端只画绑定的那一路,裁判端按槽位分配画两路。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::sync::{broadcast, watch};
|
||||||
|
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
|
||||||
|
use tokio_tungstenite::tungstenite::http::StatusCode;
|
||||||
|
use tokio_tungstenite::tungstenite::{Bytes, Message};
|
||||||
|
|
||||||
|
use cam_core::snapshot::{HitDto, Snapshot};
|
||||||
|
|
||||||
|
/// 二进制帧头长度。
|
||||||
|
pub const FRAME_HEADER_LEN: usize = 16;
|
||||||
|
|
||||||
|
/// Ordered control-message capacity. JPEGs use a separate latest-value channel.
|
||||||
|
pub const CHANNEL_CAPACITY: usize = 256;
|
||||||
|
const LOCAL_VIDEO_MAX_AGE: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
|
/// 向界面推送的消息。
|
||||||
|
pub enum ServerMsg {
|
||||||
|
/// JSON 文本。
|
||||||
|
Text(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct VideoPacket {
|
||||||
|
frame_id: u32,
|
||||||
|
bytes: Bytes,
|
||||||
|
published_at: Instant,
|
||||||
|
published_unix_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type VideoFrames = HashMap<u32, VideoPacket>;
|
||||||
|
pub type VideoSender = watch::Sender<VideoFrames>;
|
||||||
|
|
||||||
|
/// Video uses latest-value semantics; control messages keep ordered delivery.
|
||||||
|
pub fn publish_video(tx: &VideoSender, frame: &protocol::Frame) {
|
||||||
|
let packet = VideoPacket {
|
||||||
|
frame_id: frame.frame_id,
|
||||||
|
bytes: encode_frame(frame).into(),
|
||||||
|
published_at: Instant::now(),
|
||||||
|
published_unix_ms: SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64,
|
||||||
|
};
|
||||||
|
tx.send_modify(|latest| {
|
||||||
|
latest.insert(frame.sender_id, packet);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON 文本消息的信封。
|
||||||
|
///
|
||||||
|
/// 用内部标签 `t` 而不是两个字段:前端一个 `switch (msg.t)` 就能分发,且不需要为每种
|
||||||
|
/// 消息各写一次 `JSON.parse` 的错误处理。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "t", rename_all = "camelCase")]
|
||||||
|
pub enum WireMsg {
|
||||||
|
/// 完整状态快照,5 Hz。
|
||||||
|
Snapshot(Box<Snapshot>),
|
||||||
|
/// 一次掉血,立即推。
|
||||||
|
Hit(HitDto),
|
||||||
|
/// 后端拦截了一次"关闭/退出"(用户端还没解锁)。前端据此弹出解锁对话框。
|
||||||
|
ConfirmQuit,
|
||||||
|
/// 一条说明(迁移、录制报错等),前端显示成一行提示。
|
||||||
|
///
|
||||||
|
/// 写成带字段的结构体而不是 `Note(String)`:serde 的**内部标签**(`#[serde(tag)]`)
|
||||||
|
/// 只支持载荷是 map 的变体,newtype 里放一个字符串会在运行时报错,而不是编译期。
|
||||||
|
Note { message: String },
|
||||||
|
/// Timestamp precedes its JPEG so a stalled WebView can reject queued data.
|
||||||
|
FrameTiming {
|
||||||
|
camera: u32,
|
||||||
|
#[serde(rename = "frameId")]
|
||||||
|
frame_id: u32,
|
||||||
|
#[serde(rename = "publishedUnixMs")]
|
||||||
|
published_unix_ms: u64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前端连接这条通道需要的信息。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StreamInfo {
|
||||||
|
/// `ws://127.0.0.1:<port>/?token=...`
|
||||||
|
pub url: String,
|
||||||
|
pub port: u16,
|
||||||
|
/// 当前连着的界面数。用于诊断"画面不动"到底是后端没推还是前端没连。
|
||||||
|
pub clients: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把一张帧编码成二进制消息。
|
||||||
|
pub fn encode_frame(frame: &protocol::Frame) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(FRAME_HEADER_LEN + frame.data.len());
|
||||||
|
|
||||||
|
out.extend_from_slice(&frame.sender_id.to_le_bytes());
|
||||||
|
out.extend_from_slice(&frame.frame_id.to_le_bytes());
|
||||||
|
out.extend_from_slice(&frame.width.to_le_bytes());
|
||||||
|
out.extend_from_slice(&frame.height.to_le_bytes());
|
||||||
|
out.extend_from_slice(&(frame.data.len() as u32).to_le_bytes());
|
||||||
|
out.extend_from_slice(&frame.data);
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从二进制消息里解出帧头。前后端对这段字节布局的约定由测试盯着。
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn decode_frame_header(message: &[u8]) -> Option<(u32, u32, u16, u16, usize)> {
|
||||||
|
if message.len() < FRAME_HEADER_LEN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sender_id = u32::from_le_bytes(message[0..4].try_into().ok()?);
|
||||||
|
let frame_id = u32::from_le_bytes(message[4..8].try_into().ok()?);
|
||||||
|
let width = u16::from_le_bytes(message[8..10].try_into().ok()?);
|
||||||
|
let height = u16::from_le_bytes(message[10..12].try_into().ok()?);
|
||||||
|
let len = u32::from_le_bytes(message[12..16].try_into().ok()?) as usize;
|
||||||
|
|
||||||
|
if message.len() < FRAME_HEADER_LEN + len {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((sender_id, frame_id, width, height, len))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 绑一个随机端口的回环 listener。
|
||||||
|
///
|
||||||
|
/// 随机端口而不是固定端口:同一台机器上同时开用户端和裁判端时不会撞车。
|
||||||
|
pub async fn bind() -> std::io::Result<(TcpListener, u16)> {
|
||||||
|
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
|
||||||
|
let port = listener.local_addr()?.port();
|
||||||
|
|
||||||
|
Ok((listener, port))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 接受循环。每个连接一个任务,活到连接断开。
|
||||||
|
pub async fn serve(
|
||||||
|
listener: TcpListener,
|
||||||
|
token: String,
|
||||||
|
tx: broadcast::Sender<Arc<ServerMsg>>,
|
||||||
|
video: VideoSender,
|
||||||
|
clients: Arc<AtomicUsize>,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
let (stream, peer) = match listener.accept().await {
|
||||||
|
Ok(pair) => pair,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("stream: accept failed: {err}");
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tx = tx.clone();
|
||||||
|
let token = token.clone();
|
||||||
|
let clients = Arc::clone(&clients);
|
||||||
|
let video = video.subscribe();
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(err) = handle_client(stream, peer, &token, tx, video, clients).await {
|
||||||
|
log::debug!("stream: client {peer} ended: {err}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_client(
|
||||||
|
stream: TcpStream,
|
||||||
|
peer: std::net::SocketAddr,
|
||||||
|
token: &str,
|
||||||
|
tx: broadcast::Sender<Arc<ServerMsg>>,
|
||||||
|
mut video: watch::Receiver<VideoFrames>,
|
||||||
|
clients: Arc<AtomicUsize>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
stream.set_nodelay(true).ok();
|
||||||
|
|
||||||
|
let expected = token.to_string();
|
||||||
|
let callback = move |req: &Request, res: Response| -> Result<Response, ErrorResponse> {
|
||||||
|
let query = req.uri().query().unwrap_or("");
|
||||||
|
let presented = query
|
||||||
|
.split('&')
|
||||||
|
.filter_map(|pair| pair.split_once('='))
|
||||||
|
.find(|(key, _)| *key == "token")
|
||||||
|
.map(|(_, value)| value.to_string());
|
||||||
|
|
||||||
|
match presented {
|
||||||
|
Some(value) if constant_time_eq(&value, &expected) => Ok(res),
|
||||||
|
_ => {
|
||||||
|
log::warn!("stream: refusing {peer}: wrong or missing token");
|
||||||
|
let mut response = ErrorResponse::new(Some("bad token".into()));
|
||||||
|
*response.status_mut() = StatusCode::FORBIDDEN;
|
||||||
|
Err(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ws = tokio_tungstenite::accept_hdr_async(stream, callback).await?;
|
||||||
|
let (mut sink, mut incoming) = ws.split();
|
||||||
|
|
||||||
|
let mut rx = tx.subscribe();
|
||||||
|
video.mark_changed();
|
||||||
|
let mut last_sent = HashMap::new();
|
||||||
|
let mut keepalive = tokio::time::interval(Duration::from_secs(20));
|
||||||
|
let count = clients.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
log::info!("stream: interface connected from {peer} ({count} connected)");
|
||||||
|
|
||||||
|
// 连上就先推一条提示,前端能据此在界面上显示"通道已接通"。
|
||||||
|
let hello = serde_json::to_string(&WireMsg::Note {
|
||||||
|
message: "video channel ready".into(),
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| "{}".into());
|
||||||
|
let _ = sink.send(Message::text(hello)).await;
|
||||||
|
|
||||||
|
let result = 'messages: loop {
|
||||||
|
tokio::select! {
|
||||||
|
changed = video.changed() => {
|
||||||
|
if changed.is_err() { break Ok(()); }
|
||||||
|
// Release the watch read lock before awaiting socket writes.
|
||||||
|
let latest: Vec<_> = video.borrow_and_update().iter()
|
||||||
|
.map(|(&camera, packet)| (camera, packet.clone())).collect();
|
||||||
|
for (camera, packet) in latest {
|
||||||
|
if last_sent.get(&camera) == Some(&packet.frame_id) { continue; }
|
||||||
|
if packet.published_at.elapsed() >= LOCAL_VIDEO_MAX_AGE { continue; }
|
||||||
|
let timing = serde_json::to_string(&WireMsg::FrameTiming {
|
||||||
|
camera, frame_id: packet.frame_id, published_unix_ms: packet.published_unix_ms,
|
||||||
|
})?;
|
||||||
|
// Keep the 16-byte JPEG header compatible. This small text
|
||||||
|
// message carries host queue age through TCP/WebView queues.
|
||||||
|
let send = async {
|
||||||
|
sink.send(Message::text(timing)).await?;
|
||||||
|
sink.send(Message::Binary(packet.bytes)).await
|
||||||
|
};
|
||||||
|
match tokio::time::timeout(LOCAL_VIDEO_MAX_AGE, send).await {
|
||||||
|
Ok(Ok(())) => { last_sent.insert(camera, packet.frame_id); }
|
||||||
|
Ok(Err(err)) => break 'messages Err(anyhow::anyhow!("video send failed: {err}")),
|
||||||
|
Err(_) => break 'messages Err(anyhow::anyhow!("video client stalled")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message = rx.recv() => match message {
|
||||||
|
Ok(msg) => {
|
||||||
|
let out = match &*msg {
|
||||||
|
ServerMsg::Text(text) => Message::text(text.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = sink.send(out).await {
|
||||||
|
break Err(anyhow::anyhow!("send failed: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 接收慢,积压被挤掉了。对视频这是正常且期望的:跳过旧帧,继续送新的。
|
||||||
|
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
|
log::debug!("stream: {peer} lagged {skipped} messages; skipping ahead");
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => {
|
||||||
|
break Ok(());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_ = keepalive.tick() => {
|
||||||
|
// WKWebView 侧的连接可能在休眠后静默失效。ping 让这种情况变成一次明确的
|
||||||
|
// 发送失败,而不是一个永远不刷新的窗口。
|
||||||
|
if let Err(err) = sink.send(Message::Ping(Bytes::new())).await {
|
||||||
|
break Err(anyhow::anyhow!("keepalive failed: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming_msg = incoming.next() => match incoming_msg {
|
||||||
|
None => break Ok(()),
|
||||||
|
Some(Err(err)) => break Err(anyhow::anyhow!("receive failed: {err}")),
|
||||||
|
// 这条通道是单向的:前端发来的东西(pong 除外)一律忽略。
|
||||||
|
Some(Ok(_)) => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let count = clients.fetch_sub(1, Ordering::Relaxed) - 1;
|
||||||
|
log::info!("stream: interface disconnected ({count} connected)");
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 定长比较,避免 token 校验泄露时间信息。
|
||||||
|
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||||
|
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||||
|
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut diff = 0u8;
|
||||||
|
for (x, y) in a.iter().zip(b) {
|
||||||
|
diff |= x ^ y;
|
||||||
|
}
|
||||||
|
|
||||||
|
diff == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成一个随机 token。
|
||||||
|
pub fn random_token() -> String {
|
||||||
|
use rand::RngCore;
|
||||||
|
|
||||||
|
let mut bytes = [0u8; 24];
|
||||||
|
rand::rng().fill_bytes(&mut bytes);
|
||||||
|
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn frame(sender: u32, id: u32, len: usize) -> protocol::Frame {
|
||||||
|
protocol::Frame {
|
||||||
|
sender_id: sender,
|
||||||
|
frame_id: id,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
data: vec![0xAB; len],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_binary_header_roundtrips() {
|
||||||
|
let encoded = encode_frame(&frame(0x3D4D_8A9A, 4242, 22_000));
|
||||||
|
|
||||||
|
assert_eq!(encoded.len(), FRAME_HEADER_LEN + 22_000);
|
||||||
|
assert_eq!(
|
||||||
|
decode_frame_header(&encoded),
|
||||||
|
Some((0x3D4D_8A9A, 4242, 640, 480, 22_000))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_truncated_message_is_refused_rather_than_read_past_the_end() {
|
||||||
|
let mut encoded = encode_frame(&frame(1, 1, 100));
|
||||||
|
encoded.truncate(FRAME_HEADER_LEN + 40);
|
||||||
|
|
||||||
|
// 头里声明 100 字节,实际只有 40:宁可返回 None,也不要让前端拿到一段不完整的
|
||||||
|
// JPEG(那会解码失败,或者更糟——解出一张半截的图)。
|
||||||
|
assert_eq!(decode_frame_header(&encoded), None);
|
||||||
|
assert_eq!(decode_frame_header(&[0u8; 4]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_token_comparison_is_exact() {
|
||||||
|
assert!(constant_time_eq("abc", "abc"));
|
||||||
|
assert!(!constant_time_eq("abc", "abd"));
|
||||||
|
assert!(!constant_time_eq("abc", "abcd"));
|
||||||
|
assert!(!constant_time_eq("", "a"));
|
||||||
|
assert!(constant_time_eq("", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tokens_differ_between_runs() {
|
||||||
|
assert_ne!(random_token(), random_token());
|
||||||
|
assert_eq!(random_token().len(), 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_slow_video_consumer_keeps_only_the_latest_frame_of_each_camera() {
|
||||||
|
let (video, mut reader) = watch::channel(VideoFrames::new());
|
||||||
|
for id in 1..=1000 {
|
||||||
|
publish_video(&video, &frame(1, id, 100));
|
||||||
|
publish_video(&video, &frame(2, id + 10, 100));
|
||||||
|
}
|
||||||
|
let latest = reader.borrow_and_update();
|
||||||
|
assert_eq!(latest.len(), 2);
|
||||||
|
assert_eq!(latest[&1].frame_id, 1000);
|
||||||
|
assert_eq!(latest[&2].frame_id, 1010);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_reconnecting_interface_receives_cached_latest_frames_without_waiting_for_capture() {
|
||||||
|
let (listener, port) = bind().await.unwrap();
|
||||||
|
let (control, _) = broadcast::channel(CHANNEL_CAPACITY);
|
||||||
|
let (video, _) = watch::channel(VideoFrames::new());
|
||||||
|
for id in 1..=100 {
|
||||||
|
publish_video(&video, &frame(1, id, 100));
|
||||||
|
publish_video(&video, &frame(2, id + 10, 100));
|
||||||
|
}
|
||||||
|
let task = tokio::spawn(serve(
|
||||||
|
listener,
|
||||||
|
"test-token".into(),
|
||||||
|
control,
|
||||||
|
video,
|
||||||
|
Arc::new(AtomicUsize::new(0)),
|
||||||
|
));
|
||||||
|
let (mut client, _) =
|
||||||
|
tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/?token=test-token"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut received = Vec::new();
|
||||||
|
let mut timings = HashMap::new();
|
||||||
|
while received.len() < 2 {
|
||||||
|
let message = tokio::time::timeout(Duration::from_secs(2), client.next())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
if let Message::Text(text) = &message {
|
||||||
|
let value: serde_json::Value = serde_json::from_str(text).unwrap();
|
||||||
|
if value["t"] == "frameTiming" {
|
||||||
|
assert!(value["publishedUnixMs"].as_u64().unwrap() > 0);
|
||||||
|
timings.insert(
|
||||||
|
value["camera"].as_u64().unwrap() as u32,
|
||||||
|
value["frameId"].as_u64().unwrap() as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Message::Binary(bytes) = message {
|
||||||
|
let (camera, frame_id, _, _, _) = decode_frame_header(&bytes).unwrap();
|
||||||
|
assert_eq!(timings.get(&camera), Some(&frame_id));
|
||||||
|
received.push((camera, frame_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
received.sort_unstable();
|
||||||
|
assert_eq!(received, vec![(1, 100), (2, 110)]);
|
||||||
|
client.close(None).await.unwrap();
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconnect_does_not_replay_expired_cached_video() {
|
||||||
|
let (listener, port) = bind().await.unwrap();
|
||||||
|
let (control, _) = broadcast::channel(CHANNEL_CAPACITY);
|
||||||
|
let (video, _) = watch::channel(VideoFrames::new());
|
||||||
|
publish_video(&video, &frame(1, 1, 100));
|
||||||
|
video.send_modify(|latest| {
|
||||||
|
latest.get_mut(&1).unwrap().published_at = Instant::now() - Duration::from_secs(1)
|
||||||
|
});
|
||||||
|
let task = tokio::spawn(serve(
|
||||||
|
listener,
|
||||||
|
"test-token".into(),
|
||||||
|
control,
|
||||||
|
video.clone(),
|
||||||
|
Arc::new(AtomicUsize::new(0)),
|
||||||
|
));
|
||||||
|
let (mut client, _) =
|
||||||
|
tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/?token=test-token"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let stale = tokio::time::timeout(Duration::from_millis(100), async {
|
||||||
|
while let Some(message) = client.next().await {
|
||||||
|
if matches!(message.unwrap(), Message::Binary(_)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(stale.is_err());
|
||||||
|
publish_video(&video, &frame(1, 2, 100));
|
||||||
|
let fresh = tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
while let Some(message) = client.next().await {
|
||||||
|
if let Message::Binary(bytes) = message.unwrap() {
|
||||||
|
return decode_frame_header(&bytes).unwrap().1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("video connection closed");
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fresh, 2);
|
||||||
|
client.close(None).await.unwrap();
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_wire_envelope_carries_the_message_kind() {
|
||||||
|
let json = serde_json::to_string(&WireMsg::Hit(HitDto {
|
||||||
|
board: 1,
|
||||||
|
team: 0,
|
||||||
|
index: 2,
|
||||||
|
from: 100,
|
||||||
|
to: 97,
|
||||||
|
damage: 3,
|
||||||
|
at_ms: 1,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(json.contains(r#""t":"hit""#), "got {json}");
|
||||||
|
assert!(json.contains(r#""damage":3"#), "got {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_unit_variant_is_just_a_tag() {
|
||||||
|
let json = serde_json::to_string(&WireMsg::ConfirmQuit).unwrap();
|
||||||
|
assert_eq!(json, r#"{"t":"confirmQuit"}"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_note_is_tagged_too() {
|
||||||
|
let json = serde_json::to_string(&WireMsg::Note {
|
||||||
|
message: "hi".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(json, r#"{"t":"note","message":"hi"}"#);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,669 @@
|
|||||||
|
//! 进程内的共享状态。
|
||||||
|
//!
|
||||||
|
//! 这里是"网络层的推"与"界面的拉"交汇的地方:
|
||||||
|
//!
|
||||||
|
//! - **推**:接收线程拿到帧就交给 sink,sink 直接发进广播通道(不阻塞、不排队);
|
||||||
|
//! 装甲板上报推进一个 [`HealthTracker`],派生出掉血事件就立刻推一条。
|
||||||
|
//! - **拉**:一个 5 Hz 的循环把当前状态拼成一份 [`Snapshot`] 推出去。界面不需要任何
|
||||||
|
//! 轮询命令,它只是渲染收到的那份状态。
|
||||||
|
//!
|
||||||
|
//! # 密码闸门的执行点在这里,不在界面里
|
||||||
|
//!
|
||||||
|
//! 所有会改绑定的命令都过 [`AppState::require_unlocked`]。界面上有一个解锁对话框,但那
|
||||||
|
//! **只是入口**:即使有人从控制台直接调 `invoke("bind_camera")`,一样要过这一关。这和
|
||||||
|
//! 旧客户端把防作弊放在**接收端**而不是界面里是同一条原则。
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use cam_core::armor::{ArmorHealthStore, HealthTracker};
|
||||||
|
use cam_core::config::{Config, Role};
|
||||||
|
use cam_core::password;
|
||||||
|
use cam_core::receiver::{Discovery, LatestFrames, Receiver};
|
||||||
|
use cam_core::slots::SlotAllocator;
|
||||||
|
use cam_core::snapshot::{
|
||||||
|
resolve_outcomes, ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, SlotsDto,
|
||||||
|
Snapshot, TeamHealthDto,
|
||||||
|
};
|
||||||
|
use cam_core::stats::{FpsWindow, DEFAULT_WINDOW};
|
||||||
|
use cam_core::ArmorSource;
|
||||||
|
use tokio::sync::{broadcast, watch};
|
||||||
|
|
||||||
|
use crate::recording::{now_ms, RecordingSet};
|
||||||
|
use crate::server::{self, ServerMsg, WireMsg};
|
||||||
|
|
||||||
|
/// 状态快照的推送频率。
|
||||||
|
///
|
||||||
|
/// 5 Hz:血量心跳是 200 ms,所以这个频率刚好不漏掉一次掉血;而再快也只是让界面做
|
||||||
|
/// 无意义的重绘。
|
||||||
|
pub const SNAPSHOT_INTERVAL: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
/// 界面端是否产出技术读数。
|
||||||
|
///
|
||||||
|
/// 由**编译模式**决定而不是命令行参数:命令行开关意味着二进制里带着那套东西,忘开
|
||||||
|
/// 一次就泄露了。裁判端不受影响——它是中立方,双方遥测对判罚有用。
|
||||||
|
pub const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub config_path: PathBuf,
|
||||||
|
config: Mutex<Config>,
|
||||||
|
/// 会话内解锁(见模块文档)。
|
||||||
|
unlocked: AtomicBool,
|
||||||
|
/// 设置面板是否打开,决定发现列表放不放开。
|
||||||
|
discovery_open: AtomicBool,
|
||||||
|
|
||||||
|
pub receiver: Arc<Receiver>,
|
||||||
|
pub armor: Arc<ArmorHealthStore>,
|
||||||
|
discovery: Arc<Discovery>,
|
||||||
|
latest: Arc<LatestFrames>,
|
||||||
|
|
||||||
|
/// 每一路的帧率窗口。掉线的摄像头会被清掉,回来时重新建。
|
||||||
|
fps: Mutex<HashMap<u32, FpsWindow>>,
|
||||||
|
slots: Mutex<SlotAllocator>,
|
||||||
|
pub recordings: Arc<RecordingSet>,
|
||||||
|
|
||||||
|
tx: broadcast::Sender<Arc<ServerMsg>>,
|
||||||
|
video: server::VideoSender,
|
||||||
|
/// 回环上已经绑好的 WebSocket listener,等 `spawn_background` 取走。
|
||||||
|
///
|
||||||
|
/// 先绑再交给后台任务,是为了让"端口绑不上"在启动阶段就报错,而不是变成一个永远
|
||||||
|
/// 连不上的界面。
|
||||||
|
listener: Mutex<Option<tokio::net::TcpListener>>,
|
||||||
|
pub port: u16,
|
||||||
|
token: String,
|
||||||
|
clients: Arc<AtomicUsize>,
|
||||||
|
|
||||||
|
pub demo: bool,
|
||||||
|
notes: Mutex<Vec<String>>,
|
||||||
|
started: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// 建好状态,并绑好两个 UDP 端口。
|
||||||
|
///
|
||||||
|
/// 视频端口(5600)绑不上是**致命**的:整个程序的存在意义就是收画面,绑不上应该
|
||||||
|
/// 立刻报错,而不是开一个永远不出画面的窗口。装甲板端口(5601)绑不上只是没有
|
||||||
|
/// 血量,退回占位符即可——血量显示不了不该让整个客户端起不来。
|
||||||
|
pub fn new(
|
||||||
|
config_path: PathBuf,
|
||||||
|
config: Config,
|
||||||
|
mut notes: Vec<String>,
|
||||||
|
demo: bool,
|
||||||
|
) -> Result<(
|
||||||
|
Arc<Self>,
|
||||||
|
Option<std::net::UdpSocket>,
|
||||||
|
Option<std::net::UdpSocket>,
|
||||||
|
)> {
|
||||||
|
let role = config.role;
|
||||||
|
let bound = if role == Role::Team {
|
||||||
|
config.camera_id
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频端口绑不上**不**致命:界面照常起来,并把原因写在最显眼的地方。
|
||||||
|
//
|
||||||
|
// 一开始这里是 `?`(直接退出)。但那样一来,现场看到的是一次闪退加上一句只出现
|
||||||
|
// 在 stderr 的话——从访达双击启动时连 stderr 都没有。让窗口起来并说明原因,比
|
||||||
|
// 静默退出有用得多;而且"另一个客户端已经在跑"恰好是最常见的原因。
|
||||||
|
let (receiver, video_socket) =
|
||||||
|
match Receiver::start(protocol::BROADCAST_ADDR, role.to_protocol(), bound) {
|
||||||
|
Ok((receiver, socket)) => (Arc::new(receiver), Some(socket)),
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("could not bind UDP {}: {err}", protocol::DEFAULT_PORT);
|
||||||
|
notes.push(format!(
|
||||||
|
"绑不上 UDP {}:{err}。多半是同一个端口上已经跑着另一个客户端 \
|
||||||
|
(旧的 Slint 版或另一份本程序)。画面收不到,其它功能照常。",
|
||||||
|
protocol::DEFAULT_PORT
|
||||||
|
));
|
||||||
|
(
|
||||||
|
Arc::new(Receiver::new(
|
||||||
|
protocol::BROADCAST_ADDR,
|
||||||
|
role.to_protocol(),
|
||||||
|
bound,
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let discovery = receiver.discovery();
|
||||||
|
let latest = receiver.latest_frames();
|
||||||
|
|
||||||
|
// 装甲板:两端都要收。用户端要显示**双方**的血条(左上自己、右上对手),裁判端也是双方。
|
||||||
|
let (armor, armor_socket) = match ArmorHealthStore::start() {
|
||||||
|
Ok((store, socket)) => (store, Some(socket)),
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"armor: could not listen on UDP {}: {err}; health stays a placeholder",
|
||||||
|
protocol::ARMOR_PORT
|
||||||
|
);
|
||||||
|
(Arc::new(ArmorHealthStore::new()), None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (tx, _) = broadcast::channel(server::CHANNEL_CAPACITY);
|
||||||
|
let (video, _) = watch::channel(server::VideoFrames::new());
|
||||||
|
let (listener, port) = tauri::async_runtime::block_on(server::bind())
|
||||||
|
.context("绑不上本地 WebSocket 端口(回环地址)")?;
|
||||||
|
|
||||||
|
let state = Arc::new(Self {
|
||||||
|
config_path,
|
||||||
|
config: Mutex::new(config),
|
||||||
|
unlocked: AtomicBool::new(false),
|
||||||
|
discovery_open: AtomicBool::new(false),
|
||||||
|
receiver,
|
||||||
|
armor,
|
||||||
|
discovery,
|
||||||
|
latest,
|
||||||
|
fps: Mutex::new(HashMap::new()),
|
||||||
|
slots: Mutex::new(SlotAllocator::new()),
|
||||||
|
recordings: RecordingSet::new(),
|
||||||
|
tx,
|
||||||
|
video,
|
||||||
|
listener: Mutex::new(Some(listener)),
|
||||||
|
port,
|
||||||
|
token: server::random_token(),
|
||||||
|
clients: Arc::new(AtomicUsize::new(0)),
|
||||||
|
demo,
|
||||||
|
notes: Mutex::new(notes),
|
||||||
|
started: Instant::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
state.apply_gates();
|
||||||
|
|
||||||
|
Ok((state, video_socket, armor_socket))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 让视频通道开始工作:把帧与装甲板上报接到广播通道上。
|
||||||
|
///
|
||||||
|
/// 必须在 `Arc<AppState>` 建好之后调用(sink 里要持有 state 的通道)。
|
||||||
|
pub fn install_sinks(self: &Arc<Self>) {
|
||||||
|
let video = self.video.clone();
|
||||||
|
let recordings = Arc::clone(&self.recordings);
|
||||||
|
|
||||||
|
// 帧的 sink:在接收线程里跑,只做"编码一次 + 广播"。这一份字节所有界面共用。
|
||||||
|
self.receiver
|
||||||
|
.set_frame_sink(Arc::new(move |frame: Arc<protocol::Frame>| {
|
||||||
|
recordings.feed(&frame);
|
||||||
|
server::publish_video(&video, &frame);
|
||||||
|
}));
|
||||||
|
|
||||||
|
let tx = self.tx.clone();
|
||||||
|
let tracker = Arc::new(Mutex::new(HealthTracker::new()));
|
||||||
|
|
||||||
|
// 装甲板的 sink:把"现在多少血"派生为"刚刚掉了几点",立刻推给界面。
|
||||||
|
self.armor.set_sink(Arc::new(move |report| {
|
||||||
|
let hit = tracker
|
||||||
|
.lock()
|
||||||
|
.expect("health tracker mutex poisoned")
|
||||||
|
.observe(&report);
|
||||||
|
|
||||||
|
let Some(hit) = hit else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"armor: board 0x{:08X} took {} damage ({} -> {})",
|
||||||
|
hit.board,
|
||||||
|
hit.damage,
|
||||||
|
hit.from,
|
||||||
|
hit.to
|
||||||
|
);
|
||||||
|
|
||||||
|
let dto = HitDto {
|
||||||
|
board: hit.board,
|
||||||
|
team: hit.team,
|
||||||
|
index: hit.index,
|
||||||
|
from: hit.from,
|
||||||
|
to: hit.to,
|
||||||
|
damage: hit.damage,
|
||||||
|
at_ms: now_ms(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = tx.send(Arc::new(ServerMsg::Text(
|
||||||
|
serde_json::to_string(&WireMsg::Hit(dto)).unwrap_or_default(),
|
||||||
|
)));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 起 WebSocket 服务与快照循环。
|
||||||
|
pub fn spawn_background(self: &Arc<Self>) {
|
||||||
|
let Some(listener) = self
|
||||||
|
.listener
|
||||||
|
.lock()
|
||||||
|
.expect("listener mutex poisoned")
|
||||||
|
.take()
|
||||||
|
else {
|
||||||
|
log::warn!("spawn_background called twice; the stream server is already running");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = Arc::clone(self);
|
||||||
|
let token = self.token.clone();
|
||||||
|
let tx = self.tx.clone();
|
||||||
|
let clients = Arc::clone(&self.clients);
|
||||||
|
let video = self.video.clone();
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
server::serve(listener, token, tx, video, clients).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let state_for_loop = Arc::clone(&state);
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(SNAPSHOT_INTERVAL);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
state_for_loop.tick();
|
||||||
|
state_for_loop.push_snapshot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 配置
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn config(&self) -> Config {
|
||||||
|
self.config.lock().expect("config mutex poisoned").clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 改配置、落盘、重新套用闸门。
|
||||||
|
pub fn update_config(&self, change: impl FnOnce(&mut Config)) -> Result<Config> {
|
||||||
|
let updated = {
|
||||||
|
let mut config = self.config.lock().expect("config mutex poisoned");
|
||||||
|
change(&mut config);
|
||||||
|
// Guard 上不能直接调 `normalized()`(它取走 self)。克隆一份再归一化:配置
|
||||||
|
// 只有几百字节,而这里每次都是人点一下才发生。
|
||||||
|
config.clone().normalized()
|
||||||
|
};
|
||||||
|
|
||||||
|
updated
|
||||||
|
.save(&self.config_path)
|
||||||
|
.context("写入配置文件失败;改动只在本次运行内有效")?;
|
||||||
|
|
||||||
|
*self.config.lock().expect("config mutex poisoned") = updated.clone();
|
||||||
|
self.apply_gates();
|
||||||
|
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按当前配置重新设置两道闸门。
|
||||||
|
///
|
||||||
|
/// 每一次可能改变身份、绑定或发现开合的改动之后都要调用它——把这件事收在一个函数
|
||||||
|
/// 里,是为了不让"某个命令忘了重新套用闸门"这种漏洞有时间长出来。
|
||||||
|
pub fn apply_gates(&self) {
|
||||||
|
let config = self.config();
|
||||||
|
let role = config.role;
|
||||||
|
|
||||||
|
self.receiver.set_role(role.to_protocol());
|
||||||
|
|
||||||
|
// 第一道闸门:帧。用户端只看绑定的那一块;裁判端看全部。
|
||||||
|
let limit = match role {
|
||||||
|
Role::Team => config.camera_id,
|
||||||
|
Role::Referee => None,
|
||||||
|
};
|
||||||
|
self.receiver.restrict_to(limit);
|
||||||
|
|
||||||
|
// 第二道闸门:发现列表。只有用户端在**解锁后**打开设置面板时才放开;裁判端
|
||||||
|
// 本来就要看到全场。
|
||||||
|
let open = match role {
|
||||||
|
Role::Referee => true,
|
||||||
|
Role::Team => {
|
||||||
|
self.discovery_open.load(Ordering::Relaxed) && self.unlocked.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.receiver.set_discovery_all(open);
|
||||||
|
|
||||||
|
if !open {
|
||||||
|
self.discovery_open.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 密码闸门
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn is_unlocked(&self) -> bool {
|
||||||
|
// 裁判端没有密码(见 Role::needs_password),所以它永远是"已解锁"。
|
||||||
|
!self.config().role.needs_password() || self.unlocked.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验一次密码,成功就把会话标记为已解锁。
|
||||||
|
pub fn unlock(&self, presented: &str) -> Result<bool> {
|
||||||
|
let stored = self.config().password;
|
||||||
|
|
||||||
|
let Some(stored) = stored else {
|
||||||
|
// 还没设过密码:任何输入都不该被当成"通过"。
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let ok = password::verify_password(presented, &stored)?;
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
self.unlocked.store(true, Ordering::Relaxed);
|
||||||
|
log::info!("密码校验通过;本次运行内不再询问");
|
||||||
|
} else {
|
||||||
|
log::warn!("密码校验失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动重新上锁。
|
||||||
|
pub fn lock(&self) {
|
||||||
|
self.unlocked.store(false, Ordering::Relaxed);
|
||||||
|
self.discovery_open.store(false, Ordering::Relaxed);
|
||||||
|
self.apply_gates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 改绑之前的守门人。
|
||||||
|
///
|
||||||
|
/// `presented` 允许调用方在同一次调用里带上密码(界面上就是这样:解锁对话框收起后
|
||||||
|
/// 用户立刻做改动),但会话已经解锁时不需要它。
|
||||||
|
pub fn require_unlocked(&self, presented: Option<&str>) -> Result<(), String> {
|
||||||
|
if self.is_unlocked() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(presented) = presented {
|
||||||
|
match self.unlock(presented) {
|
||||||
|
Ok(true) => return Ok(()),
|
||||||
|
Ok(false) => {
|
||||||
|
return Err("密码不对。改摄像头/装甲板绑定、切换队伍都需要密码。".into())
|
||||||
|
}
|
||||||
|
Err(err) => return Err(format!("存下来的密码哈希读不出来:{err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("这个改动需要密码。先解锁(密码只是防止现场随手改绑,见 README 的说明)。".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开/关闭设置面板带来的发现列表。
|
||||||
|
pub fn set_discovery_open(&self, open: bool) -> Result<(), String> {
|
||||||
|
if open {
|
||||||
|
self.require_unlocked(None)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.discovery_open.store(open, Ordering::Relaxed);
|
||||||
|
self.apply_gates();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置或修改密码。用户端第一次启动必须设。
|
||||||
|
pub fn set_password(&self, current: Option<&str>, fresh: &str) -> Result<()> {
|
||||||
|
let existing = self.config().password;
|
||||||
|
|
||||||
|
if let Some(existing) = existing {
|
||||||
|
// 已有密码:必须能证明知道旧的(或者在本次运行里已经解锁过)。
|
||||||
|
let trusted = self.unlocked.load(Ordering::Relaxed)
|
||||||
|
|| current
|
||||||
|
.map(|current| password::verify_password(current, &existing).unwrap_or(false))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !trusted {
|
||||||
|
anyhow::bail!("修改密码需要先输入当前密码");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = password::hash_password(fresh)?;
|
||||||
|
self.update_config(move |config| config.password = Some(hash))?;
|
||||||
|
self.unlocked.store(true, Ordering::Relaxed);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 推送
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn push(&self, msg: WireMsg) {
|
||||||
|
let text = match serde_json::to_string(&msg) {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("could not serialise a message for the interface: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 没有连接时发送会失败,这是正常情况(界面还没起来,或者刚刷新)。
|
||||||
|
let _ = self.tx.send(Arc::new(ServerMsg::Text(text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_snapshot(&self) {
|
||||||
|
self.push(WireMsg::Snapshot(Box::new(self.snapshot())));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn note(&self, message: impl Into<String>) {
|
||||||
|
let message = message.into();
|
||||||
|
log::info!("{message}");
|
||||||
|
self.notes
|
||||||
|
.lock()
|
||||||
|
.expect("notes mutex poisoned")
|
||||||
|
.push(message.clone());
|
||||||
|
self.push(WireMsg::Note { message });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stream_info(&self) -> server::StreamInfo {
|
||||||
|
server::StreamInfo {
|
||||||
|
url: format!("ws://127.0.0.1:{}/?token={}", self.port, self.token),
|
||||||
|
port: self.port,
|
||||||
|
clients: self.clients.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn latest_frames(&self) -> Arc<LatestFrames> {
|
||||||
|
Arc::clone(&self.latest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 每 200ms 推进一次
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 推进帧率窗口与槽位分配。
|
||||||
|
pub fn tick(&self) {
|
||||||
|
let online: Vec<u32> = self
|
||||||
|
.discovery
|
||||||
|
.online_cameras()
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.id)
|
||||||
|
.collect();
|
||||||
|
let online_set: HashSet<u32> = online.iter().copied().collect();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut fps = self.fps.lock().expect("fps mutex poisoned");
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
// 掉线的摄像头把窗口丢掉。留着的话,它回来时累计帧数已经涨了一大截,下一
|
||||||
|
// 次结算会报出一个几百 fps 的假数字。
|
||||||
|
fps.retain(|id, _| online_set.contains(id));
|
||||||
|
|
||||||
|
for id in &online {
|
||||||
|
let stats = self.discovery.stats(*id);
|
||||||
|
fps.entry(*id)
|
||||||
|
.or_insert_with(|| FpsWindow::seeded(DEFAULT_WINDOW, now, stats.frames_shown))
|
||||||
|
.observe(stats.frames_shown, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = self.config();
|
||||||
|
let mut slots = self.slots.lock().expect("slots mutex poisoned");
|
||||||
|
|
||||||
|
match config.role {
|
||||||
|
Role::Referee => {
|
||||||
|
if config.referee.manual_slots {
|
||||||
|
// 手动:以配置为准。现场有烧错固件的板子时,人需要能压过自动分配。
|
||||||
|
slots.force(0, config.referee.slot_a);
|
||||||
|
slots.force(1, config.referee.slot_b);
|
||||||
|
} else {
|
||||||
|
slots.assign(&online);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 用户端只有一个"槽位":它绑定的那一块。
|
||||||
|
Role::Team => {
|
||||||
|
slots.force(0, config.camera_id);
|
||||||
|
slots.force(1, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnostics(&self) -> bool {
|
||||||
|
self.config().role == Role::Referee || TEAM_DIAGNOSTICS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某块摄像头的帧率(用于录制时告诉 ffmpeg 输入帧率)。
|
||||||
|
pub fn fps_of(&self, camera: u32) -> Option<f32> {
|
||||||
|
let fps = self.fps.lock().expect("fps mutex poisoned");
|
||||||
|
let window = fps.get(&camera)?;
|
||||||
|
|
||||||
|
if window.has_reading() {
|
||||||
|
Some(window.fps())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 快照
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> Snapshot {
|
||||||
|
let config = self.config();
|
||||||
|
let diagnostics = self.diagnostics();
|
||||||
|
|
||||||
|
let cameras: Vec<CameraDto> = self
|
||||||
|
.discovery
|
||||||
|
.cameras()
|
||||||
|
.iter()
|
||||||
|
.map(CameraDto::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let stats: Vec<CameraStatsDto> = self
|
||||||
|
.discovery
|
||||||
|
.cameras()
|
||||||
|
.iter()
|
||||||
|
.map(|camera| {
|
||||||
|
let stats = self.discovery.stats(camera.id);
|
||||||
|
let fps = self.fps_of(camera.id).unwrap_or(0.0);
|
||||||
|
CameraStatsDto::build(diagnostics, camera.id, camera.is_online(), &stats, fps)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let armor_boards: Vec<ArmorBoardDto> = self
|
||||||
|
.armor
|
||||||
|
.boards()
|
||||||
|
.iter()
|
||||||
|
.map(ArmorBoardDto::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let slots = self.slots.lock().expect("slots mutex poisoned").slots();
|
||||||
|
|
||||||
|
// 两边都给:用户端的画面右上角要显示对手的血量,裁判端本来就要双方。
|
||||||
|
//
|
||||||
|
// 顺序固定为队伍号 0、1,界面按 `team` 取那一格——**不靠数组下标**判断"哪个是我",
|
||||||
|
// 因为下标在"用户端绑的是队伍二"时就不再等于自己的队伍号了。
|
||||||
|
let mut health = vec![self.health_for(0), self.health_for(1)];
|
||||||
|
|
||||||
|
// 胜负是**两边合起来**才能算出来的东西,所以在这里补一步。
|
||||||
|
resolve_outcomes(&mut health);
|
||||||
|
|
||||||
|
Snapshot {
|
||||||
|
role: config.role,
|
||||||
|
team: config.team,
|
||||||
|
mirrored: config.mirror,
|
||||||
|
demo: self.demo,
|
||||||
|
diagnostics,
|
||||||
|
unlocked: self.is_unlocked(),
|
||||||
|
discovery_open: self.discovery_open.load(Ordering::Relaxed),
|
||||||
|
uptime_ms: self.started.elapsed().as_millis() as u64,
|
||||||
|
clients: self.clients.load(Ordering::Relaxed),
|
||||||
|
config: ConfigView::from(&config),
|
||||||
|
cameras,
|
||||||
|
armor_boards,
|
||||||
|
stats,
|
||||||
|
slots: SlotsDto {
|
||||||
|
camera_a: slots[0],
|
||||||
|
camera_b: slots[1],
|
||||||
|
armor_a: self.assigned_armor(&config, 0),
|
||||||
|
armor_b: self.assigned_armor(&config, 1),
|
||||||
|
manual: config.referee.manual_slots,
|
||||||
|
},
|
||||||
|
health,
|
||||||
|
recordings: self.recordings.active(),
|
||||||
|
notes: self.notes.lock().expect("notes mutex poisoned").clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一边的装甲板主板:手动指定优先,否则按板子自报的 `team` 自动找。
|
||||||
|
///
|
||||||
|
/// 槽位指定只有**裁判端**有:`referee.armor_a/b` 是裁判端界面上的"左格/右格",用户端
|
||||||
|
/// 拿它当自己的板子会让绑定被一份不相干的配置盖掉。
|
||||||
|
fn assigned_armor(&self, config: &Config, team: u8) -> Option<u32> {
|
||||||
|
let explicit = if config.role == Role::Referee {
|
||||||
|
if team == 0 {
|
||||||
|
config.referee.armor_a
|
||||||
|
} else {
|
||||||
|
config.referee.armor_b
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if config.role == Role::Referee && config.referee.manual_slots {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
explicit.or_else(|| {
|
||||||
|
self.armor
|
||||||
|
.boards()
|
||||||
|
.iter()
|
||||||
|
.find(|board| board.team == team && board.is_online())
|
||||||
|
.map(|board| board.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一边的血量。
|
||||||
|
///
|
||||||
|
/// 顺序是有意的:先看绑定的那块板子,再看"这一边有没有人报血量"。绑定了却在掉线
|
||||||
|
/// 状态时要显示**未接入**而不是拿别人的数据顶上——现场会以为对手还有血。
|
||||||
|
///
|
||||||
|
/// # 对手那一块绝不能用 `armor_id`
|
||||||
|
///
|
||||||
|
/// `armor_id` 是**我自己**绑定的主板。拿它去填对手那一格,右上角显示的就是自己的血——
|
||||||
|
/// 比不显示更糟:现场会照着一个假的对手血量做决策。
|
||||||
|
fn health_for(&self, team: u8) -> TeamHealthDto {
|
||||||
|
let config = self.config();
|
||||||
|
let low = config.hud.health.low_threshold;
|
||||||
|
|
||||||
|
let own = config.role == Role::Team && team == config.team;
|
||||||
|
let bound = if own { config.armor_id } else { None };
|
||||||
|
let board = bound.or_else(|| self.assigned_armor(&config, team));
|
||||||
|
|
||||||
|
if let Some(board) = board {
|
||||||
|
return match self.armor.health_of_board(board) {
|
||||||
|
Some(hp) => {
|
||||||
|
TeamHealthDto::present(team, Some(board), [hp; 4], low, &config.team_names)
|
||||||
|
}
|
||||||
|
None => TeamHealthDto::absent(team, Some(board), &config.team_names),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.armor.health(team) {
|
||||||
|
Some(hps) => TeamHealthDto::present(team, None, hps, low, &config.team_names),
|
||||||
|
None => TeamHealthDto::absent(team, None, &config.team_names),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 演示模式下合成的板子 id。
|
||||||
|
///
|
||||||
|
/// 取成合法十六进制、又不与真实 MAC 派生值相像的形状:截图里一眼能看出这是演示。
|
||||||
|
pub const DEMO_CAMERA_A: u32 = 0x0DE0_0001;
|
||||||
|
pub const DEMO_CAMERA_B: u32 = 0x0DE0_0002;
|
||||||
|
pub const DEMO_ARMOR_A: u32 = 0x0A20_0001;
|
||||||
|
pub const DEMO_ARMOR_B: u32 = 0x0A20_0002;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "cam-desktop",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "org.camdesktop.client",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
"devUrl": "http://127.0.0.1:1420",
|
||||||
|
"beforeBuildCommand": "pnpm build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "cam-desktop",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 900,
|
||||||
|
"minHeight": 600,
|
||||||
|
"maximized": true,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' asset: http://asset.localhost blob:; connect-src 'self' ipc: http://ipc.localhost ws://127.0.0.1:*; object-src 'none'; base-uri 'self'; form-action 'none'",
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": [
|
||||||
|
"$HOME/Movies/cam-desktop/**",
|
||||||
|
"$HOME/Videos/cam-desktop/**",
|
||||||
|
"$APPDATA/**",
|
||||||
|
"$TEMP/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["app", "dmg"],
|
||||||
|
"category": "Utility",
|
||||||
|
"shortDescription": "ESP32 图传与装甲板桌面端",
|
||||||
|
"longDescription": "用户端:一路画面 + 血条与瞄准器。裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* 应用外壳:连通道、拿状态、按身份分流、管住解锁流程。
|
||||||
|
*
|
||||||
|
* # 状态来自哪里
|
||||||
|
*
|
||||||
|
* 后端每 200 ms 推一份完整快照,前端不轮询。唯一在本地维护的是 **HUD 草稿**:滑杆要即时
|
||||||
|
* 反馈,等 300 ms 的后端往返会很黏手。草稿改动后节流保存,保存的结果就是后端的配置。
|
||||||
|
*
|
||||||
|
* # 解锁流程
|
||||||
|
*
|
||||||
|
* 需要密码的动作有三处入口:打开设置面板、点关闭窗口(后端会推 `confirmQuit`)、以及
|
||||||
|
* 任何一次被后端拒绝的改动。三条路径都汇到同一个 `UnlockDialog`,解锁成功后执行挂起的
|
||||||
|
* 那个动作。后端才是真正的闸门,这里只负责把话说清楚。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { SettingsPanel } from "./components/SettingsPanel";
|
||||||
|
import { SetupPassword, RolePicker } from "./components/Gate";
|
||||||
|
import { Toast } from "./components/TopBar";
|
||||||
|
import { UnlockDialog } from "./components/UnlockDialog";
|
||||||
|
import { api } from "./lib/ipc";
|
||||||
|
import { startStream, streamClient, useAppStore } from "./lib/store";
|
||||||
|
import type { ConfigView, HudView, Snapshot } from "./lib/types";
|
||||||
|
import { RefereePage } from "./pages/RefereePage";
|
||||||
|
import { TeamPage } from "./pages/TeamPage";
|
||||||
|
|
||||||
|
/** 解锁之后要做的那件事。 */
|
||||||
|
type Pending = { kind: "settings" } | { kind: "quit" } | { kind: "retry"; run: () => void };
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { snapshot, connected, confirmQuit } = useAppStore();
|
||||||
|
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [fatal, setFatal] = useState<string | null>(null);
|
||||||
|
const [hud, setHud] = useState<HudView | null>(null);
|
||||||
|
const [drawer, setDrawer] = useState(false);
|
||||||
|
const [calibrating, setCalibrating] = useState(false);
|
||||||
|
const [unlockReason, setUnlockReason] = useState<string | null>(null);
|
||||||
|
const [pending, setPending] = useState<Pending | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [info, setInfo] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const hudInitialised = useRef(false);
|
||||||
|
|
||||||
|
// ---- 通道 ----
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void api.streamInfo().then(
|
||||||
|
(stream) => setUrl(stream.url),
|
||||||
|
(err) => setFatal(err instanceof Error ? err.message : String(err)),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
const client = startStream(url);
|
||||||
|
return () => client.close();
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
// ---- HUD 草稿 ----
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!snapshot || hudInitialised.current) return;
|
||||||
|
hudInitialised.current = true;
|
||||||
|
setHud(snapshot.config.hud);
|
||||||
|
}, [snapshot]);
|
||||||
|
|
||||||
|
const onConfig = useCallback((config: ConfigView) => {
|
||||||
|
// 绑定/身份改了:HUD 之外的东西都由快照带回来,这里只需要让 HUD 跟上(例如换了身份)。
|
||||||
|
setHud((current) => current ?? config.hud);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 校准时拖动画面:偏移是按比例的,直接累加。 */
|
||||||
|
const onCalibrate = useCallback(
|
||||||
|
(dx: number, dy: number) => {
|
||||||
|
setHud((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
|
||||||
|
const clamp = (value: number) => Math.max(-0.5, Math.min(0.5, value));
|
||||||
|
const next: HudView = {
|
||||||
|
...current,
|
||||||
|
crosshair: {
|
||||||
|
...current.crosshair,
|
||||||
|
offsetX: clamp(current.crosshair.offsetX + dx),
|
||||||
|
offsetY: clamp(current.crosshair.offsetY + dy),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 校准改动也要落盘。和设置面板里的滑杆共用同一条节流路径。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hud || !calibrating) return;
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void api.saveHud(hud).catch(() => undefined);
|
||||||
|
}, 600);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [hud, calibrating]);
|
||||||
|
|
||||||
|
// ---- 解锁 ----
|
||||||
|
|
||||||
|
const needsUnlock = useCallback(
|
||||||
|
(reason: string, next: Pending) => {
|
||||||
|
setUnlockReason(reason);
|
||||||
|
setPending(next);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openSettings = useCallback(() => {
|
||||||
|
if (!snapshot) return;
|
||||||
|
|
||||||
|
if (snapshot.role !== "referee" && !snapshot.unlocked) {
|
||||||
|
needsUnlock("改摄像头/装甲板绑定、切换队伍号都要密码。", { kind: "settings" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDrawer(true);
|
||||||
|
}, [snapshot, needsUnlock]);
|
||||||
|
|
||||||
|
// 后端拦下了一次"关窗口/退出"。
|
||||||
|
useEffect(() => {
|
||||||
|
if (confirmQuit === 0) return;
|
||||||
|
needsUnlock("退出程序需要密码。", { kind: "quit" });
|
||||||
|
}, [confirmQuit, needsUnlock]);
|
||||||
|
|
||||||
|
const finishUnlock = useCallback(() => {
|
||||||
|
setUnlockReason(null);
|
||||||
|
|
||||||
|
const action = pending;
|
||||||
|
setPending(null);
|
||||||
|
|
||||||
|
switch (action?.kind) {
|
||||||
|
case "settings":
|
||||||
|
setDrawer(true);
|
||||||
|
break;
|
||||||
|
case "quit":
|
||||||
|
void api.quit().catch((err) =>
|
||||||
|
setError(err instanceof Error ? err.message : String(err)),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "retry":
|
||||||
|
action.run();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
void api.refresh();
|
||||||
|
}, [pending]);
|
||||||
|
|
||||||
|
// ---- 窗口标题 ----
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!snapshot) return;
|
||||||
|
|
||||||
|
const suffix =
|
||||||
|
snapshot.role === "team" ? "用户端" : "裁判端";
|
||||||
|
const extra = snapshot.demo ? "(演示模式)" : "";
|
||||||
|
|
||||||
|
void import("@tauri-apps/api/window")
|
||||||
|
.then(({ getCurrentWindow }) => getCurrentWindow().setTitle(`cam-desktop — ${suffix}${extra}`))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [snapshot?.role, snapshot?.demo]);
|
||||||
|
|
||||||
|
// ---- 分支 ----
|
||||||
|
|
||||||
|
if (fatal) {
|
||||||
|
return (
|
||||||
|
<div className="gate">
|
||||||
|
<h1 className="gate__title">起不来</h1>
|
||||||
|
<p className="gate__error">{fatal}</p>
|
||||||
|
<p className="gate__note">
|
||||||
|
端口可能被另一个客户端占着(旧的 Slint 版或另一份本程序)。关掉那一份再试。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return (
|
||||||
|
<div className="gate">
|
||||||
|
<h1 className="gate__title">cam-desktop</h1>
|
||||||
|
<p className="gate__hint">
|
||||||
|
{connected ? "正在等待后端的第一份状态…" : "正在连接后端…"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot.config.roleChosen) {
|
||||||
|
return <RolePicker />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.role === "team" && !snapshot.config.passwordSet) {
|
||||||
|
return <SetupPassword />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
{snapshot.role === "team" ? (
|
||||||
|
<TeamPage
|
||||||
|
snapshot={snapshot}
|
||||||
|
hud={hud ?? snapshot.config.hud}
|
||||||
|
onOpenSettings={openSettings}
|
||||||
|
calibrating={calibrating}
|
||||||
|
onCalibrate={onCalibrate}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RefereePage
|
||||||
|
snapshot={snapshot}
|
||||||
|
hud={hud ?? snapshot.config.hud}
|
||||||
|
onOpenSettings={openSettings}
|
||||||
|
onError={setError}
|
||||||
|
onInfo={setInfo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{drawer && (
|
||||||
|
<SettingsPanel
|
||||||
|
snapshot={snapshot}
|
||||||
|
hud={hud ?? snapshot.config.hud}
|
||||||
|
onHud={setHud}
|
||||||
|
calibrating={calibrating}
|
||||||
|
onCalibrating={(active) => {
|
||||||
|
setCalibrating(active);
|
||||||
|
if (active) setDrawer(false);
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setCalibrating(false);
|
||||||
|
setDrawer(false);
|
||||||
|
}}
|
||||||
|
onNeedsUnlock={(reason) => needsUnlock(reason, { kind: "settings" })}
|
||||||
|
onError={setError}
|
||||||
|
onInfo={setInfo}
|
||||||
|
onConfig={onConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{unlockReason && (
|
||||||
|
<UnlockDialog
|
||||||
|
reason={unlockReason}
|
||||||
|
onUnlocked={finishUnlock}
|
||||||
|
onCancel={() => {
|
||||||
|
setUnlockReason(null);
|
||||||
|
setPending(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <Toast kind="error" message={error} onClose={() => setError(null)} />}
|
||||||
|
{info && !error && <Toast kind="info" message={info} onClose={() => setInfo(null)} />}
|
||||||
|
|
||||||
|
{/* 校准提示:视频区域在页面里,离开设置面板之后还能看到它。 */}
|
||||||
|
{calibrating && (
|
||||||
|
<div className="calibrate-banner">
|
||||||
|
校准中:拖动画面挪动瞄准点,调好后回到设置里关掉这个开关。
|
||||||
|
<button onClick={() => setCalibrating(false)}>结束校准</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChannelWatchdog snapshot={snapshot} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通道看门狗:后端在推、前端没连上时给一句明确的话。
|
||||||
|
*
|
||||||
|
* 这一条存在的理由很实际:画面不动时,第一件要分清的事是"后端没推"还是"前端没接上"。
|
||||||
|
* 后端快照里有 `clients`,所以这两件事可以分开说。
|
||||||
|
*/
|
||||||
|
function ChannelWatchdog({ snapshot }: { snapshot: Snapshot }) {
|
||||||
|
const { connected } = useAppStore();
|
||||||
|
|
||||||
|
if (connected) return null;
|
||||||
|
|
||||||
|
let detail = "正在重连后端的视频通道…";
|
||||||
|
|
||||||
|
try {
|
||||||
|
detail += `(已收到 ${streamClient().counters.frames} 帧,重连 ${streamClient().counters.reconnects} 次)`;
|
||||||
|
} catch {
|
||||||
|
// 通道还没建起来。
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.clients === 0 && snapshot.uptimeMs > 5000) {
|
||||||
|
detail += " 后端报告没有任何界面连着,这一条是前端侧的问题。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="channel-warning">{detail}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* 裁判端的装甲板发现列表。
|
||||||
|
*
|
||||||
|
* 场上到底连上了几块板子、分别自报哪一队、现在多少血、多久没广播了——现场"裁判端没显示
|
||||||
|
* 血量"时,这一张表就是第一眼看的地方:它能区分"板子根本没上线"、"上线了但队伍号烧反了"
|
||||||
|
* 和"只有一条上报在发"。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { elapsed, teamColor, teamName } from "../lib/format";
|
||||||
|
import type { ArmorBoardDto, SlotsDto } from "../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
boards: ArmorBoardDto[];
|
||||||
|
slots: SlotsDto;
|
||||||
|
/** 两边显示的名字(裁判端可改)。缺省时回落到「队伍一 / 队伍二」。 */
|
||||||
|
names?: [string, string];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArmorTable({ boards, slots, names }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="armor-table">
|
||||||
|
<div className="armor-table__head">
|
||||||
|
<span>装甲板</span>
|
||||||
|
<span>地址</span>
|
||||||
|
<span>自报队伍</span>
|
||||||
|
<span>血量</span>
|
||||||
|
<span>四条</span>
|
||||||
|
<span>静默</span>
|
||||||
|
<span>槽位</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{boards.length === 0 && (
|
||||||
|
<div className="armor-table__empty">
|
||||||
|
还没有收到任何装甲板上报。主板每 200 ms 广播一次,确认它已上电、和这台机器在同一个
|
||||||
|
网络里。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{boards.map((board) => {
|
||||||
|
const assigned =
|
||||||
|
slots.armorA === board.id
|
||||||
|
? `左(${teamName(0, names)})`
|
||||||
|
: slots.armorB === board.id
|
||||||
|
? `右(${teamName(1, names)})`
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={board.id}
|
||||||
|
className={`armor-table__row${board.online ? "" : " armor-table__row--offline"}`}
|
||||||
|
>
|
||||||
|
<span className="mono">{board.idText}</span>
|
||||||
|
<span className="mono dim">{board.addr}</span>
|
||||||
|
<span style={{ color: teamColor(board.team) }}>{teamName(board.team, names)}</span>
|
||||||
|
<span className={`mono${board.hp === 0 ? " danger" : ""}`}>{board.hp}</span>
|
||||||
|
<span className={board.reportsAllPlates ? "dim" : "warn"}>
|
||||||
|
{board.platesSeen}/4
|
||||||
|
{!board.reportsAllPlates && " ⚠"}
|
||||||
|
</span>
|
||||||
|
<span className="dim">{board.online ? `${elapsed(board.silenceMs)}前` : "离线"}</span>
|
||||||
|
<span className="dim">{assigned}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行只读读数,裁判端每格下面用。 */
|
||||||
|
export function StatsLine({
|
||||||
|
stats,
|
||||||
|
diagnostics,
|
||||||
|
}: {
|
||||||
|
stats:
|
||||||
|
| {
|
||||||
|
status: string;
|
||||||
|
online: boolean;
|
||||||
|
hasFrames: boolean;
|
||||||
|
fps: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
framesShown: number;
|
||||||
|
framesDropped: number;
|
||||||
|
lossPercent: number;
|
||||||
|
datagrams: number;
|
||||||
|
lastFrameBytes: number;
|
||||||
|
gapMs: number | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
diagnostics: boolean;
|
||||||
|
}) {
|
||||||
|
if (!stats) {
|
||||||
|
return <div className="stats-line stats-line--empty">还没有这一路的数据</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (diagnostics) {
|
||||||
|
if (stats.hasFrames) {
|
||||||
|
parts.push(`${stats.fps.toFixed(1)} fps`);
|
||||||
|
parts.push(`${stats.width}×${stats.height}`);
|
||||||
|
parts.push(`${(stats.lastFrameBytes / 1024).toFixed(1)} kB/帧`);
|
||||||
|
parts.push(`丢 ${stats.framesDropped}(${stats.lossPercent.toFixed(1)}%)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stats-line">
|
||||||
|
<span className={stats.online ? "stats-line__status" : "stats-line__status warn"}>
|
||||||
|
{stats.status}
|
||||||
|
</span>
|
||||||
|
{parts.length > 0 && <span className="dim">{parts.join(" · ")}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* 首次启动的两道门:先选身份,再设密码。
|
||||||
|
*
|
||||||
|
* 身份必须问一次,因为 `role` 的默认值(裁判端)既可能是人选了裁判端,也可能只是还没人
|
||||||
|
* 碰过这个文件——后者让用户端的人第一次打开会直接进裁判端,然后以为这个程序只有那一种
|
||||||
|
* 界面。所以后端单独记了一个 `roleChosen`。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "../lib/ipc";
|
||||||
|
import { pushNote } from "../lib/store";
|
||||||
|
import type { Role } from "../lib/types";
|
||||||
|
|
||||||
|
export function RolePicker() {
|
||||||
|
const [busy, setBusy] = useState<Role | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const choose = async (role: Role) => {
|
||||||
|
setBusy(role);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.setRole(role);
|
||||||
|
await api.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gate">
|
||||||
|
<h1 className="gate__title">这台机器是哪一端?</h1>
|
||||||
|
<p className="gate__hint">选定之后记进配置,下次启动直接进这一端。之后可以在设置里改。</p>
|
||||||
|
|
||||||
|
<div className="gate__choices">
|
||||||
|
<button className="choice" disabled={busy !== null} onClick={() => void choose("team")}>
|
||||||
|
<span className="choice__name">用户端</span>
|
||||||
|
<span className="choice__desc">
|
||||||
|
一路画面 + 血条 + 瞄准器。需要绑定一块图传板与一块装甲板,绑定改动要密码。
|
||||||
|
</span>
|
||||||
|
{busy === "team" && <span className="choice__busy">正在切换…</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="choice" disabled={busy !== null} onClick={() => void choose("referee")}>
|
||||||
|
<span className="choice__name">裁判端</span>
|
||||||
|
<span className="choice__desc">
|
||||||
|
两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。不需要绑定,也不需要密码。
|
||||||
|
</span>
|
||||||
|
{busy === "referee" && <span className="choice__busy">正在切换…</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="gate__error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupPassword() {
|
||||||
|
const [first, setFirst] = useState("");
|
||||||
|
const [again, setAgain] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const input = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
input.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
|
||||||
|
if (first !== again) {
|
||||||
|
setError("两次输入不一致");
|
||||||
|
setAgain("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.setPassword(first);
|
||||||
|
await api.refresh();
|
||||||
|
pushNote("密码已设置。改摄像头/装甲板绑定、切换队伍号、退出都需要它。");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gate">
|
||||||
|
<h1 className="gate__title">先设一个密码</h1>
|
||||||
|
<p className="gate__hint">
|
||||||
|
用户端改摄像头与装甲板绑定、切换队伍号、退出都要用它。至少 4 位,本机存的是
|
||||||
|
Argon2id 哈希,不是明文。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="gate__form">
|
||||||
|
<input
|
||||||
|
ref={input}
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={first}
|
||||||
|
onChange={(event) => setFirst(event.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
placeholder="再输一次"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={again}
|
||||||
|
onChange={(event) => setAgain(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") void submit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{error && <p className="gate__error">{error}</p>}
|
||||||
|
<button className="button button--primary" onClick={() => void submit()} disabled={busy}>
|
||||||
|
{busy ? "保存中…" : "设置密码并继续"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="gate__note">
|
||||||
|
忘了密码的处理:删掉配置文件里的 password 一行,或者删掉整个配置文件。旧客户端
|
||||||
|
(Slint 版)的配置会在首次启动时自动导入并备份成 .bak。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* 画面角上的分段血条(平行四边形,一条分 5 格)。
|
||||||
|
*
|
||||||
|
* # 为什么改到角上、改成斜的
|
||||||
|
*
|
||||||
|
* 原来它在画面**下面**一条窄带里。改成画面左上角/右上角之后:
|
||||||
|
*
|
||||||
|
* - 画面不再是"被一条横带压住的一块",整块视频区域给到取景;
|
||||||
|
* - 两边各占一角,**同时**看到自己与对手的血量,视线不用离开画面;
|
||||||
|
* - 斜切的形状不会在第一时间被误认成字幕条或进度条,扫一眼就知道是血量。
|
||||||
|
*
|
||||||
|
* 左上角给自己、右上角给对手(见 `TeamPage`)。裁判端两格各自一个角,与用户端同一套
|
||||||
|
* 视觉语言。
|
||||||
|
*
|
||||||
|
* # 一条分 5 格 = 一格 20 点
|
||||||
|
*
|
||||||
|
* 整条是一条,里面分成 **5 格**:格子少而大,隔着几米扫一眼就能数出"还剩三格",
|
||||||
|
* 比读一条连续的长度或一个百分比快。血量归零就是 0 格,那时图传中央会给出胜负。
|
||||||
|
*
|
||||||
|
* 血少了,亮着的格子从**中心那一侧**开始灭(左条从右往左灭,右条从左往右灭),所以两条
|
||||||
|
* 血一起减少时,视觉上是往两侧退——不会看成"条变长了"。
|
||||||
|
*
|
||||||
|
* # 未接入时不显示 0
|
||||||
|
*
|
||||||
|
* `available` 为 false 时显示占位说明而不是 0:0 是一个看起来像真值的假值,现场看到 0
|
||||||
|
* 会以为对方已经没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { teamColor, withAlpha } from "../lib/format";
|
||||||
|
import type { HealthConfig, TeamHealthDto } from "../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
health: TeamHealthDto;
|
||||||
|
config: HealthConfig;
|
||||||
|
/** 挂在画面哪一角。`right` 会把条整个镜像过来(含缺血的退向)。 */
|
||||||
|
align: "left" | "right";
|
||||||
|
/** 这一格是谁:用户端用「我方 / 对手」把两条同色的血区分开。 */
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 整条分成几格。 */
|
||||||
|
const CELLS = 5;
|
||||||
|
/** 一格多少血。整车一条血 100 点。 */
|
||||||
|
const CELL_HP = 100 / CELLS;
|
||||||
|
|
||||||
|
/** 脉动的周期。与 CSS 动画保持一致。 */
|
||||||
|
const PULSE_MS = 900;
|
||||||
|
|
||||||
|
interface Cell {
|
||||||
|
/** 这一格是不是亮着(这一格的血还在)。 */
|
||||||
|
on: boolean;
|
||||||
|
/** 落在低血阈值以内:这是"危险区",即使还亮着也提前给个警示色。 */
|
||||||
|
danger: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HealthBar({ health, config, align, tag }: Props) {
|
||||||
|
const [pulseOn, setPulseOn] = useState(true);
|
||||||
|
|
||||||
|
// 低血脉动:只有真的低血时才起定时器,否则一个空转的 setInterval 会让整个界面
|
||||||
|
// 每秒重渲染一次。
|
||||||
|
const pulsing = config.pulse && health.available && health.critical;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pulsing) {
|
||||||
|
setPulseOn(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => setPulseOn((on) => !on), PULSE_MS / 2);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [pulsing]);
|
||||||
|
|
||||||
|
const color = health.critical ? "#ff5f5f" : teamColor(health.team);
|
||||||
|
|
||||||
|
const hp = health.available ? Math.max(0, Math.min(100, health.hp ?? 0)) : 0;
|
||||||
|
// 向上取整:还剩 1 点血的时候必须还剩一格亮着。向下取整会让"还有 10 点血"显示成空条,
|
||||||
|
// 而那正是最需要它准确的时候。
|
||||||
|
const lit = Math.ceil(hp / CELL_HP);
|
||||||
|
const dangerCells =
|
||||||
|
config.lowThreshold > 0 && config.lowThreshold < 100
|
||||||
|
? Math.ceil(config.lowThreshold / CELL_HP)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 逻辑顺序:0 号格是"满血时最靠外的那一格"。
|
||||||
|
//
|
||||||
|
// **不要在这里按 `align` 反转数组**:右角那条的翻转让 CSS 的 `scaleX(-1)` 做
|
||||||
|
// (`segbars--right .segbars__track`),两边都做等于翻两次、又翻回来了——表现是右条
|
||||||
|
// 从外侧开始灭,与左条相反。
|
||||||
|
const cells: Cell[] = Array.from({ length: CELLS }, (_, index) => ({
|
||||||
|
on: index < lit,
|
||||||
|
danger: index >= CELLS - dangerCells,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 宽度按配置收窄。它挂在画面角上,太宽会盖住画面本身。
|
||||||
|
const width = `${Math.round(config.width * 100)}%`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`segbars segbars--${align}`}
|
||||||
|
style={{ width }}
|
||||||
|
title={health.available ? undefined : health.placeholder}
|
||||||
|
>
|
||||||
|
<div className="segbars__labels">
|
||||||
|
{tag && <span className="segbars__tag">{tag}</span>}
|
||||||
|
{config.showTeamName && (
|
||||||
|
<span className="segbars__team" style={{ color }}>
|
||||||
|
{health.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{health.available ? (
|
||||||
|
config.showNumbers && (
|
||||||
|
<span className="segbars__value" style={{ color }}>
|
||||||
|
{hp}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="segbars__missing">装甲板未接入</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="segbars__track"
|
||||||
|
style={{ opacity: pulsing && !pulseOn ? 0.35 : 1 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{cells.map((cell, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={[
|
||||||
|
"segbars__cell",
|
||||||
|
cell.on ? "segbars__cell--on" : "",
|
||||||
|
cell.danger ? "segbars__cell--danger" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
style={
|
||||||
|
cell.on
|
||||||
|
? {
|
||||||
|
background: color,
|
||||||
|
boxShadow: `0 0 8px ${withAlpha(color, 0.45)}`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这一格是谁。
|
||||||
|
*
|
||||||
|
* 用户端两条血分别属于两边,而形状是对称的——没有这个标签,"左边这条是我的"要靠位置猜。
|
||||||
|
*/
|
||||||
|
export function healthTag(health: TeamHealthDto, myTeam: number): string {
|
||||||
|
return health.team === myTeam ? "我方" : "对手";
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* 命中反馈的整屏层:闪红 + 顶部一条文字。
|
||||||
|
*
|
||||||
|
* # 大号数字刻意不放在画面正中
|
||||||
|
*
|
||||||
|
* 第一版把 `-7 / 100 → 93` 放在正中,结果它正好压在瞄准点上——被打中的那一下恰恰是最
|
||||||
|
* 需要看着准星的时候。现在数字由 `VideoSurface` 画在**准星旁边**(跟着瞄准点走,不挡
|
||||||
|
* 目标),这里只留闪红与顶部一条信息。
|
||||||
|
*
|
||||||
|
* `key={hit.seq}` 是让 CSS 动画每次都从头开始的关键:同一个元素上换属性不会重启动画,
|
||||||
|
* 于是第二下命中就再也闪不出来了——一个"第一次有效"的 bug。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { HitPulseRecord } from "../lib/store";
|
||||||
|
import type { HitFeedbackConfig } from "../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hit: HitPulseRecord | null;
|
||||||
|
config: HitFeedbackConfig;
|
||||||
|
/** 这一下是不是打在我这一边(裁判端两边都显示,但只有相关那一路闪)。 */
|
||||||
|
relevant: boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HitFlash({ hit, config, relevant, label }: Props) {
|
||||||
|
if (!hit || !relevant || !config.flash) return null;
|
||||||
|
|
||||||
|
const strength = Math.min(0.55, 0.16 * config.intensity);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={hit.seq} className="hit-flash" style={{ ["--flash" as string]: strength }}>
|
||||||
|
<div className="hit-flash__banner">
|
||||||
|
{label && <span className="hit-flash__label">{label}</span>}
|
||||||
|
<span className="hit-flash__value">-{hit.damage}</span>
|
||||||
|
<span className="hit-flash__sub">
|
||||||
|
{hit.from} → {hit.to}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 图传中央的胜负横幅:一边归零之后,画面正中显示「胜利」或「击败」。
|
||||||
|
*
|
||||||
|
* # 为什么这次放在正中
|
||||||
|
*
|
||||||
|
* 命中反馈那一套刻意避开正中(伤害数字放在准星旁),因为被打中的那一下恰恰最需要看着
|
||||||
|
* 准星。**胜负不是那种信息**:它是一局的结束,此刻不再需要瞄准;反过来说,它是这一屏
|
||||||
|
* 上唯一还重要的事,放在角上会让人错过整场比赛的结论。
|
||||||
|
*
|
||||||
|
* # 它一直显示到血量被重置
|
||||||
|
*
|
||||||
|
* 不做 3 秒淡出:淡出之后屏幕上就再没有"这局结束了"的痕迹,截图、回放、现场的争议都
|
||||||
|
* 无从对照。血量被重置(长按 BOOT / 下一局开始)时它自己消失——那个动作本来就是
|
||||||
|
* "这局翻篇了"。
|
||||||
|
*
|
||||||
|
* 用词是两边各说各的:赢的一边看到「胜利」,输的一边看到「击败」。同时归零显示「平局」,
|
||||||
|
* 因为那时候任何一边显示"胜利"都是错的。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MatchOutcome } from "../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 从这一边看的结论。两边都活着时是 null,什么都不显示。 */
|
||||||
|
outcome: MatchOutcome | null;
|
||||||
|
/** 我这一边的名字。 */
|
||||||
|
mine: string;
|
||||||
|
/** 对手的名字。 */
|
||||||
|
theirs: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORD: Record<MatchOutcome, string> = {
|
||||||
|
victory: "胜利",
|
||||||
|
defeat: "击败",
|
||||||
|
draw: "平局",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MatchBanner({ outcome, mine, theirs }: Props) {
|
||||||
|
if (!outcome) return null;
|
||||||
|
|
||||||
|
const caption =
|
||||||
|
outcome === "victory"
|
||||||
|
? `击败 ${theirs}`
|
||||||
|
: outcome === "defeat"
|
||||||
|
? `被 ${theirs} 击败`
|
||||||
|
: "双方装甲板同时归零";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`match-banner match-banner--${outcome}`} role="status">
|
||||||
|
<div className="match-banner__card">
|
||||||
|
<span className="match-banner__team">{mine}</span>
|
||||||
|
<span className="match-banner__word">{WORD[outcome]}</span>
|
||||||
|
<span className="match-banner__caption">{caption}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* 录制、截图与录下来的东西。
|
||||||
|
*
|
||||||
|
* # 回放为什么可能失败
|
||||||
|
*
|
||||||
|
* 界面内回放走 Tauri 的资源协议,而那条协议只对 `tauri.conf.json` 里
|
||||||
|
* `assetProtocol.scope` 白名单的目录生效。默认录制目录在白名单里;如果现场把输出目录
|
||||||
|
* 改到别处,`<video>` 会加载失败——那时退回到"用系统播放器打开",而不是让人以为文件坏了。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import { api } from "../lib/ipc";
|
||||||
|
import { bytes, since } from "../lib/format";
|
||||||
|
import type { MediaFileDto, Snapshot } from "../lib/types";
|
||||||
|
import { Button } from "./controls";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
snapshot: Snapshot;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
onInfo: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaPanel({ snapshot, onError, onInfo }: Props) {
|
||||||
|
const [files, setFiles] = useState<MediaFileDto[]>([]);
|
||||||
|
const [playing, setPlaying] = useState<MediaFileDto | null>(null);
|
||||||
|
const [playbackFailed, setPlaybackFailed] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
void api.mediaFiles().then(setFiles, () => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
// 录制中每 2 秒刷一次列表:刚录完的那一段应该自己出现。
|
||||||
|
const timer = window.setInterval(refresh, 2000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const active = snapshot.recordings.filter((recording) => recording.active);
|
||||||
|
const cameras = snapshot.cameras.filter((camera) => camera.online).map((camera) => camera.id);
|
||||||
|
|
||||||
|
const guard = (action: Promise<unknown>) => {
|
||||||
|
setBusy(true);
|
||||||
|
void action
|
||||||
|
.then(() => api.refresh())
|
||||||
|
.catch((err) => onError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setBusy(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="media-panel">
|
||||||
|
<div className="media-panel__actions">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={busy || cameras.length === 0}
|
||||||
|
title={cameras.length === 0 ? "还没有在线的摄像头" : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
guard(api.startRecording(cameras));
|
||||||
|
onInfo("开始录制。停止之前请留在这台机器上——退出会先收尾再走。");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
录制全部在线画面
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={busy || active.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
guard(api.stopRecording([]));
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
停止全部录制
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{[snapshot.slots.cameraA, snapshot.slots.cameraB]
|
||||||
|
.filter((id): id is number => id !== null)
|
||||||
|
.map((camera) => (
|
||||||
|
<Button
|
||||||
|
key={camera}
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
guard(api.screenshot(camera));
|
||||||
|
onInfo(`已截图 0x${camera.toString(16).toUpperCase().padStart(8, "0")}(存在录制目录的 shots/ 下)。`);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
截图 {`0x${camera.toString(16).toUpperCase().padStart(8, "0")}`}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
void api
|
||||||
|
.reveal(snapshot.config.recordingDir)
|
||||||
|
.catch((err) => onError(err instanceof Error ? err.message : String(err)));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
打开录制目录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active.length > 0 && (
|
||||||
|
<div className="media-panel__active">
|
||||||
|
{active.map((recording) => (
|
||||||
|
<div key={recording.camera} className="recording">
|
||||||
|
<span className="recording__dot" />
|
||||||
|
<span className="mono">{recording.cameraText}</span>
|
||||||
|
<span>
|
||||||
|
{recording.framesWritten} 帧 · {since(recording.startedMs)} · {recording.inputFps.toFixed(1)} fps
|
||||||
|
输入
|
||||||
|
</span>
|
||||||
|
{recording.framesDropped > 0 && (
|
||||||
|
<span className="warn" title="写盘跟不上时丢的帧。丢帧不报出来,回放里少半秒会被当成相机的问题。">
|
||||||
|
丢 {recording.framesDropped} 帧
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{recording.error && <span className="danger">ffmpeg:{recording.error}</span>}
|
||||||
|
<Button variant="ghost" onClick={() => { guard(api.stopRecording([recording.camera])); refresh(); }}>
|
||||||
|
停止
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="media-panel__list">
|
||||||
|
{files.length === 0 && <p className="dim">录制目录里还没有文件。</p>}
|
||||||
|
|
||||||
|
{files.map((file) => (
|
||||||
|
<div key={file.path} className="media-row">
|
||||||
|
<span className={`media-row__kind media-row__kind--${file.kind}`}>
|
||||||
|
{file.kind === "video" ? "MP4" : "PNG"}
|
||||||
|
</span>
|
||||||
|
<span className="media-row__name" title={file.path}>
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
<span className="dim">{bytes(file.bytes)}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setPlaybackFailed(false);
|
||||||
|
setPlaying(file.kind === "video" ? file : null);
|
||||||
|
if (file.kind === "image") {
|
||||||
|
void api.openFile(file.path).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file.kind === "video" ? "播放" : "打开"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
void api
|
||||||
|
.reveal(file.path)
|
||||||
|
.catch((err) => onError(err instanceof Error ? err.message : String(err)));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
在访达里显示
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{playing && (
|
||||||
|
<div className="playback">
|
||||||
|
<div className="playback__head">
|
||||||
|
<span className="mono">{playing.name}</span>
|
||||||
|
<Button variant="ghost" onClick={() => setPlaying(null)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{playbackFailed ? (
|
||||||
|
<div className="playback__fallback">
|
||||||
|
<p className="warn">
|
||||||
|
界面内回放失败——多半是这个目录不在资源协议的白名单里(改成默认的录制目录,或者
|
||||||
|
直接看下面这一条)。
|
||||||
|
</p>
|
||||||
|
<Button variant="primary" onClick={() => void api.openFile(playing.path).catch(() => undefined)}>
|
||||||
|
用系统播放器打开
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
className="playback__video"
|
||||||
|
src={convertFileSrc(playing.path)}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
onError={() => setPlaybackFailed(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
/**
|
||||||
|
* 设置抽屉。两端的"改东西"都收在这里,所以密码闸门也只有一个入口。
|
||||||
|
*
|
||||||
|
* # 打开面板 ≠ 解锁
|
||||||
|
*
|
||||||
|
* 面板本身由 `App` 在**解锁之后**才挂载,但面板里的每个改动仍然各自过一次后端闸门
|
||||||
|
* (`bind_camera` 等都会校验)。所以即使有人把这里改成"不需要密码",配置也改不动。
|
||||||
|
*
|
||||||
|
* # 发现列表按需放开
|
||||||
|
*
|
||||||
|
* 挂载时调 `set_discovery_open(true)`,卸载时关掉。这是用户端唯一会列出别的图传板的
|
||||||
|
* 时候——**画面不会**因此进来,接收端那道闸门只管帧,不管发现(见 `cam_core::receiver`)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "../lib/ipc";
|
||||||
|
import { teamName } from "../lib/format";
|
||||||
|
import type {
|
||||||
|
ConfigView,
|
||||||
|
CrosshairConfig,
|
||||||
|
HudView,
|
||||||
|
Snapshot,
|
||||||
|
} from "../lib/types";
|
||||||
|
import { Button, Field, Section, Select, Slider, Switch } from "./controls";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
snapshot: Snapshot;
|
||||||
|
hud: HudView;
|
||||||
|
onHud: (hud: HudView) => void;
|
||||||
|
calibrating: boolean;
|
||||||
|
onCalibrating: (active: boolean) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onNeedsUnlock: (reason: string) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
onInfo: (message: string) => void;
|
||||||
|
/** 配置变了(身份/绑定)。App 用它立刻刷新顶层状态。 */
|
||||||
|
onConfig: (config: ConfigView) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HUD 的滑杆每动一下都会触发保存,节流一下,免得写盘几十次。 */
|
||||||
|
const SAVE_DEBOUNCE_MS = 350;
|
||||||
|
|
||||||
|
export function SettingsPanel({
|
||||||
|
snapshot,
|
||||||
|
hud,
|
||||||
|
onHud,
|
||||||
|
calibrating,
|
||||||
|
onCalibrating,
|
||||||
|
onClose,
|
||||||
|
onNeedsUnlock,
|
||||||
|
onError,
|
||||||
|
onInfo,
|
||||||
|
onConfig,
|
||||||
|
}: Props) {
|
||||||
|
const config = snapshot.config;
|
||||||
|
const locked = !snapshot.unlocked && config.role !== "referee";
|
||||||
|
|
||||||
|
// 打开面板时放开发现列表(后端会校验是否已解锁)。
|
||||||
|
useEffect(() => {
|
||||||
|
if (locked) return;
|
||||||
|
|
||||||
|
void api.setDiscoveryOpen(true).catch((err) => {
|
||||||
|
onNeedsUnlock(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
void api.setDiscoveryOpen(false).catch(() => undefined);
|
||||||
|
};
|
||||||
|
}, [locked, onNeedsUnlock]);
|
||||||
|
|
||||||
|
// HUD 的保存节流。滑杆一停就写。
|
||||||
|
const [pending, setPending] = useState<HudView | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pending) return;
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setPending(null);
|
||||||
|
void api.saveHud(pending).then(onConfig).catch((err) => {
|
||||||
|
onError(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [pending, onConfig, onError]);
|
||||||
|
|
||||||
|
const update = useCallback(
|
||||||
|
(change: (draft: HudView) => void) => {
|
||||||
|
const next = structuredClone(hud);
|
||||||
|
change(next);
|
||||||
|
onHud(next);
|
||||||
|
setPending(next);
|
||||||
|
},
|
||||||
|
[hud, onHud],
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = async (action: () => Promise<ConfigView>) => {
|
||||||
|
try {
|
||||||
|
onConfig(await action());
|
||||||
|
await api.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
if (message.includes("密码")) {
|
||||||
|
onNeedsUnlock(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cameraOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
snapshot.cameras.map((camera) => ({
|
||||||
|
value: camera.id,
|
||||||
|
label: camera.idText,
|
||||||
|
hint: `${camera.addr} · ${camera.online ? "在线" : "离线"}${
|
||||||
|
snapshot.stats.find((s) => s.camera === camera.id)?.hasFrames ? "" : " · 无画面"
|
||||||
|
}`,
|
||||||
|
})),
|
||||||
|
[snapshot.cameras, snapshot.stats],
|
||||||
|
);
|
||||||
|
|
||||||
|
const armorOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
snapshot.armorBoards.map((board) => ({
|
||||||
|
value: board.id,
|
||||||
|
label: board.idText,
|
||||||
|
hint: `${board.addr} · ${teamName(board.team, snapshot.config.teamNames)} · ${board.hp} 血 · ${
|
||||||
|
board.online ? "在线" : "离线"
|
||||||
|
}`,
|
||||||
|
})),
|
||||||
|
[snapshot.armorBoards, snapshot.config.teamNames],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="drawer">
|
||||||
|
<header className="drawer__head">
|
||||||
|
<h2 className="drawer__title">设置</h2>
|
||||||
|
<div className="drawer__head-actions">
|
||||||
|
{config.role === "team" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
void api.lock().then(() => {
|
||||||
|
onInfo("已重新上锁。");
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
上锁
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{locked && (
|
||||||
|
<p className="drawer__locked">
|
||||||
|
这个面板的改动需要密码。<button onClick={() => onNeedsUnlock("改动设置需要密码")}>立即解锁</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="drawer__body">
|
||||||
|
<Section title="身份">
|
||||||
|
<Field label="这台机器是哪一端">
|
||||||
|
<Select
|
||||||
|
value={config.role}
|
||||||
|
options={[
|
||||||
|
{ value: "team", label: "用户端", hint: "一路画面 + 血条 + 瞄准器" },
|
||||||
|
{ value: "referee", label: "裁判端", hint: "两路 + 双方血量 + 录制" },
|
||||||
|
]}
|
||||||
|
onChange={(role) => void run(() => api.setRole(role))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="section__note">
|
||||||
|
离开用户端要密码:否则切到裁判端就能看两路画面、也能随便改配置。
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{config.role === "team" && (
|
||||||
|
<>
|
||||||
|
<Section
|
||||||
|
title="绑定"
|
||||||
|
hint="图传板与装甲板都用 MAC 派生的 id 标识,重启不变,所以绑定是稳的。改动需要密码。"
|
||||||
|
>
|
||||||
|
<Field label="摄像头(图传板)">
|
||||||
|
<Select
|
||||||
|
value={config.cameraId}
|
||||||
|
options={cameraOptions}
|
||||||
|
onChange={(id) => void run(() => api.bindCamera(id))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="装甲板主板" hint="决定血条读谁的血量。下拉里是场上正在广播的板子。">
|
||||||
|
<Select
|
||||||
|
value={config.armorId}
|
||||||
|
options={armorOptions}
|
||||||
|
onChange={(id) => void run(() => api.bindArmor(id))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="自己属于哪一边"
|
||||||
|
hint="改错队号会把对手的血条当成自己的:画面左上角那一条就会是对手的血。"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={config.team}
|
||||||
|
options={[
|
||||||
|
{ value: 0, label: `${teamName(0, config.teamNames)}(红)` },
|
||||||
|
{ value: 1, label: `${teamName(1, config.teamNames)}(蓝)` },
|
||||||
|
]}
|
||||||
|
onChange={(team) => void run(() => api.setTeam(team))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
checked={config.mirror}
|
||||||
|
label="左右镜像画面(摄像头装反了)"
|
||||||
|
onChange={(mirror) => void run(() => api.setMirror(mirror))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{cameraOptions.length === 0 && (
|
||||||
|
<p className="section__note section__note--warn">
|
||||||
|
现在没有发现任何图传板。板子每 2 秒广播一次,确认它已上电、和这台机器在同一个
|
||||||
|
网络里,然后稍等。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="瞄准器" hint="位置跟着画面走,大小不跟着缩放走——放大后准星更精确。">
|
||||||
|
<Switch
|
||||||
|
checked={hud.crosshair.visible}
|
||||||
|
label="显示瞄准器"
|
||||||
|
onChange={(visible) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.crosshair.visible = visible;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field label="样式">
|
||||||
|
<Select
|
||||||
|
value={hud.crosshair.style}
|
||||||
|
options={[
|
||||||
|
{ value: "cross", label: "四臂十字" },
|
||||||
|
{ value: "cross-dot", label: "十字 + 中心点" },
|
||||||
|
{ value: "circle-dot", label: "圆环 + 中心点" },
|
||||||
|
{ value: "dot", label: "只有点" },
|
||||||
|
]}
|
||||||
|
onChange={(style) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.crosshair.style = style;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="大小" hint="画面短边的比例">
|
||||||
|
<Slider
|
||||||
|
value={hud.crosshair.size}
|
||||||
|
min={0.01}
|
||||||
|
max={0.2}
|
||||||
|
step={0.005}
|
||||||
|
format={(value) => `${(value * 100).toFixed(1)}%`}
|
||||||
|
onChange={(size) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.crosshair.size = size;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="线宽">
|
||||||
|
<Slider
|
||||||
|
value={hud.crosshair.thickness}
|
||||||
|
min={0.001}
|
||||||
|
max={0.02}
|
||||||
|
step={0.0005}
|
||||||
|
format={(value) => value.toFixed(4)}
|
||||||
|
onChange={(thickness) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.crosshair.thickness = thickness;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="中心空隙">
|
||||||
|
<Slider
|
||||||
|
value={hud.crosshair.gap}
|
||||||
|
min={0}
|
||||||
|
max={0.1}
|
||||||
|
step={0.002}
|
||||||
|
format={(value) => `${(value * 100).toFixed(1)}%`}
|
||||||
|
onChange={(gap) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.crosshair.gap = gap;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="颜色">
|
||||||
|
<input
|
||||||
|
className="color"
|
||||||
|
type="color"
|
||||||
|
value={hud.crosshair.color}
|
||||||
|
onChange={(event) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.crosshair.color = event.target.value;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Calibration
|
||||||
|
crosshair={hud.crosshair}
|
||||||
|
calibrating={calibrating}
|
||||||
|
onCalibrating={onCalibrating}
|
||||||
|
onChange={(offsetX, offsetY) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.crosshair.offsetX = offsetX;
|
||||||
|
draft.crosshair.offsetY = offsetY;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="叠加与提示">
|
||||||
|
<Switch
|
||||||
|
checked={hud.grid.visible}
|
||||||
|
label="显示网格"
|
||||||
|
onChange={(visible) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.grid.visible = visible;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hud.grid.visible && (
|
||||||
|
<>
|
||||||
|
<Field label="网格样式">
|
||||||
|
<Select
|
||||||
|
value={hud.grid.style}
|
||||||
|
options={[
|
||||||
|
{ value: "thirds", label: "三分线" },
|
||||||
|
{ value: "squares", label: "等分方格" },
|
||||||
|
{ value: "cross", label: "中心十字" },
|
||||||
|
]}
|
||||||
|
onChange={(style) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.grid.style = style;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{hud.grid.style === "squares" && (
|
||||||
|
<Field label="等分数">
|
||||||
|
<Slider
|
||||||
|
value={hud.grid.divisions}
|
||||||
|
min={2}
|
||||||
|
max={16}
|
||||||
|
step={1}
|
||||||
|
format={(value) => `${value} 格`}
|
||||||
|
onChange={(divisions) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.grid.divisions = divisions;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="网格明显程度">
|
||||||
|
<Slider
|
||||||
|
value={hud.grid.opacity}
|
||||||
|
min={0.05}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
format={(value) => value.toFixed(2)}
|
||||||
|
onChange={(opacity) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.grid.opacity = opacity;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
checked={hud.showZoom}
|
||||||
|
label="显示放大倍率"
|
||||||
|
onChange={(showZoom) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.showZoom = showZoom;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
checked={hud.hitFeedback.flash}
|
||||||
|
label="被打中时闪红"
|
||||||
|
onChange={(flash) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.hitFeedback.flash = flash;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
checked={hud.hitFeedback.marker}
|
||||||
|
label="在瞄点旁显示伤害数字"
|
||||||
|
onChange={(marker) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.hitFeedback.marker = marker;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field label="闪红强度">
|
||||||
|
<Slider
|
||||||
|
value={hud.hitFeedback.intensity}
|
||||||
|
min={0.2}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
format={(value) => `${value.toFixed(1)}×`}
|
||||||
|
onChange={(intensity) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.hitFeedback.intensity = intensity;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="血条"
|
||||||
|
hint="条挂在画面两个上角:左上角是自己、右上角是对手。一条分 5 格,一格 20 点血。"
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={hud.health.visible}
|
||||||
|
label="显示血条"
|
||||||
|
onChange={(visible) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.health.visible = visible;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checked={hud.health.showNumbers}
|
||||||
|
label="显示血量数字"
|
||||||
|
onChange={(showNumbers) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.health.showNumbers = showNumbers;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checked={hud.health.showTeamName}
|
||||||
|
label="显示队伍名"
|
||||||
|
onChange={(showTeamName) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.health.showTeamName = showTeamName;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checked={hud.health.pulse}
|
||||||
|
label="低血时脉动"
|
||||||
|
onChange={(pulse) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.health.pulse = pulse;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field label="低血阈值">
|
||||||
|
<Slider
|
||||||
|
value={hud.health.lowThreshold}
|
||||||
|
min={5}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
format={(value) => `${value} 血`}
|
||||||
|
onChange={(lowThreshold) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.health.lowThreshold = lowThreshold;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="血条宽度" hint="占画面宽度的比例">
|
||||||
|
<Slider
|
||||||
|
value={hud.health.width}
|
||||||
|
min={0.15}
|
||||||
|
max={0.9}
|
||||||
|
step={0.01}
|
||||||
|
format={(value) => `${(value * 100).toFixed(0)}%`}
|
||||||
|
onChange={(width) =>
|
||||||
|
update((draft) => {
|
||||||
|
draft.health.width = width;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.role === "referee" && (
|
||||||
|
<Section
|
||||||
|
title="裁判端槽位"
|
||||||
|
hint="默认按装甲板自报的队伍自动分左右。有烧错固件的板子时打开手动指定。"
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={snapshot.slots.manual}
|
||||||
|
label="手动指定槽位"
|
||||||
|
onChange={(manual) =>
|
||||||
|
void run(() =>
|
||||||
|
api.setRefereeSlots({
|
||||||
|
cameraA: snapshot.slots.cameraA,
|
||||||
|
cameraB: snapshot.slots.cameraB,
|
||||||
|
armorA: snapshot.slots.armorA,
|
||||||
|
armorB: snapshot.slots.armorB,
|
||||||
|
manual,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{snapshot.slots.manual && (
|
||||||
|
<>
|
||||||
|
<Field label={`左格:${teamName(0, config.teamNames)}摄像头`}>
|
||||||
|
<Select
|
||||||
|
value={snapshot.slots.cameraA}
|
||||||
|
options={cameraOptions}
|
||||||
|
onChange={(cameraA) =>
|
||||||
|
void run(() =>
|
||||||
|
api.setRefereeSlots({ ...snapshot.slots, cameraA, manual: true }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={`左格:${teamName(0, config.teamNames)}装甲板`}>
|
||||||
|
<Select
|
||||||
|
value={snapshot.slots.armorA}
|
||||||
|
options={armorOptions}
|
||||||
|
onChange={(armorA) =>
|
||||||
|
void run(() =>
|
||||||
|
api.setRefereeSlots({ ...snapshot.slots, armorA, manual: true }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={`右格:${teamName(1, config.teamNames)}摄像头`}>
|
||||||
|
<Select
|
||||||
|
value={snapshot.slots.cameraB}
|
||||||
|
options={cameraOptions}
|
||||||
|
onChange={(cameraB) =>
|
||||||
|
void run(() =>
|
||||||
|
api.setRefereeSlots({ ...snapshot.slots, cameraB, manual: true }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={`右格:${teamName(1, config.teamNames)}装甲板`}>
|
||||||
|
<Select
|
||||||
|
value={snapshot.slots.armorB}
|
||||||
|
options={armorOptions}
|
||||||
|
onChange={(armorB) =>
|
||||||
|
void run(() =>
|
||||||
|
api.setRefereeSlots({ ...snapshot.slots, armorB, manual: true }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="录制与截图" hint="截图存 PNG,录制存 mp4(需要 ffmpeg)。">
|
||||||
|
<Field label="输出目录">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
defaultValue={config.recordingDir}
|
||||||
|
onBlur={(event) => {
|
||||||
|
const value = event.target.value.trim();
|
||||||
|
if (value !== config.recordingDir) {
|
||||||
|
void run(() => api.setRecordingOptions({ dir: value, ffmpeg: null, crf: null, preset: null }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="ffmpeg 路径" hint="默认从 PATH 找。找不到就录不了,但界面照常用。">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
defaultValue={config.ffmpeg}
|
||||||
|
onBlur={(event) => {
|
||||||
|
const value = event.target.value.trim();
|
||||||
|
if (value && value !== config.ffmpeg) {
|
||||||
|
void run(() => api.setRecordingOptions({ dir: null, ffmpeg: value, crf: null, preset: null }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="section__note">
|
||||||
|
界面内回放要目录在资源协议的白名单里(默认的 ~/Movies/cam-desktop 就在)。
|
||||||
|
改到别处时回放会失败,界面上会给出"用系统播放器打开"的退路。
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="安全" hint="密码只挡现场随手改绑;配置文件与程序都在本机,愿意改的人绕得过去。">
|
||||||
|
<ChangePassword onDone={onInfo} onError={onError} />
|
||||||
|
<div className="row">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm("清空配置?旧文件会被改名成 .bak,然后需要重启程序。")) return;
|
||||||
|
void api.clearConfig().then(
|
||||||
|
(message) => onInfo(message),
|
||||||
|
(err) => {
|
||||||
|
const text = err instanceof Error ? err.message : String(err);
|
||||||
|
if (text.includes("密码")) onNeedsUnlock(text);
|
||||||
|
else onError(text);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空配置
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm("退出程序?正在进行的录制会先收尾。")) return;
|
||||||
|
void api.quit().catch((err) => {
|
||||||
|
const text = err instanceof Error ? err.message : String(err);
|
||||||
|
if (text.includes("密码")) onNeedsUnlock(text);
|
||||||
|
else onError(text);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
退出程序
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 瞄准器偏移:拖画面校准 + 数值微调 + 归零。 */
|
||||||
|
function Calibration({
|
||||||
|
crosshair,
|
||||||
|
calibrating,
|
||||||
|
onCalibrating,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
crosshair: CrosshairConfig;
|
||||||
|
calibrating: boolean;
|
||||||
|
onCalibrating: (active: boolean) => void;
|
||||||
|
onChange: (offsetX: number, offsetY: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="calibration">
|
||||||
|
<Switch
|
||||||
|
checked={calibrating}
|
||||||
|
label="拖动画面校准瞄准点"
|
||||||
|
onChange={onCalibrating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="section__note">
|
||||||
|
摄像头与枪管不同轴时用这里把瞄点挪到弹着点上。偏移存的是<span className="em">画面比例</span>
|
||||||
|
,所以换分辨率、换窗口大小都不影响它。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Field label="水平偏移" hint={`${(crosshair.offsetX * 100).toFixed(2)}%`}>
|
||||||
|
<Slider
|
||||||
|
value={crosshair.offsetX}
|
||||||
|
min={-0.3}
|
||||||
|
max={0.3}
|
||||||
|
step={0.001}
|
||||||
|
format={(value) => `${(value * 100).toFixed(2)}%`}
|
||||||
|
onChange={(value) => onChange(value, crosshair.offsetY)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="垂直偏移" hint={`${(crosshair.offsetY * 100).toFixed(2)}%`}>
|
||||||
|
<Slider
|
||||||
|
value={crosshair.offsetY}
|
||||||
|
min={-0.3}
|
||||||
|
max={0.3}
|
||||||
|
step={0.001}
|
||||||
|
format={(value) => `${(value * 100).toFixed(2)}%`}
|
||||||
|
onChange={(value) => onChange(crosshair.offsetX, value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button variant="ghost" onClick={() => onChange(0, 0)}>
|
||||||
|
偏移归零
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改密码。 */
|
||||||
|
function ChangePassword({
|
||||||
|
onDone,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onDone: (message: string) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [fresh, setFresh] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="password-change">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
placeholder="当前密码"
|
||||||
|
autoComplete="off"
|
||||||
|
value={current}
|
||||||
|
onChange={(event) => setCurrent(event.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
placeholder="新密码(≥4 位)"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={fresh}
|
||||||
|
onChange={(event) => setFresh(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={busy || fresh.length < 4}
|
||||||
|
onClick={() => {
|
||||||
|
setBusy(true);
|
||||||
|
void api
|
||||||
|
.setPassword(fresh, current)
|
||||||
|
.then(() => {
|
||||||
|
onDone("密码已更新。");
|
||||||
|
setCurrent("");
|
||||||
|
setFresh("");
|
||||||
|
})
|
||||||
|
.catch((err) => onError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setBusy(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "保存中…" : "修改密码"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 裁判端就地改队伍名。
|
||||||
|
*
|
||||||
|
* # 为什么是输入框而不是设置面板里的两项
|
||||||
|
*
|
||||||
|
* 现场改名字的时机是"比赛开始前,两队报上名来"。那时候裁判盯着的是两格画面,让他在
|
||||||
|
* 设置面板的某一节里找到对应项、改完再关掉,是一次没有必要的往返。名字就放在两格画面
|
||||||
|
* 各自下面,改完直接看到血条上的名字变了。
|
||||||
|
*
|
||||||
|
* # 回车/失焦才提交
|
||||||
|
*
|
||||||
|
* 逐字提交会在每次按键后写一次配置文件(还带一次 Argon2 无关的原子替换),而且中间态
|
||||||
|
* ("红"、"红队")会被后端截断/回落规则改写一次,光标跟着跳。失焦提交是唯一不会跟
|
||||||
|
* 打字打架的时机。
|
||||||
|
*
|
||||||
|
* 超过上限的字数由后端统一截断(`TEAM_NAME_MAX_CHARS`),这里也限一下长度——输入框
|
||||||
|
* 里就能看到"打不进去了",比提交后被悄悄截掉诚实。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { DEFAULT_TEAM_NAMES } from "../lib/format";
|
||||||
|
|
||||||
|
/** 与 Rust 侧的 `config::TEAM_NAME_MAX_CHARS` 一致。 */
|
||||||
|
const MAX_CHARS = 12;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
team: number;
|
||||||
|
/** 当前生效的名字(来自快照,可能已经被后端规范化过)。 */
|
||||||
|
name: string;
|
||||||
|
/** 提交。后端会规范化,成功之后快照会带新名字回来。 */
|
||||||
|
onSave: (name: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamNameEditor({ team, name, onSave, disabled }: Props) {
|
||||||
|
const [draft, setDraft] = useState(name);
|
||||||
|
const focused = useRef(false);
|
||||||
|
/** Esc 已经改回去了,紧接着的那次失焦不该再提交。 */
|
||||||
|
const reverted = useRef(false);
|
||||||
|
|
||||||
|
// 焦点不在输入框里时跟着快照走:改完之后后端返回的规范名要能显示出来,而正在打字时
|
||||||
|
// 不能被一份恰好到达的快照顶掉。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focused.current) setDraft(name);
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
focused.current = false;
|
||||||
|
|
||||||
|
if (reverted.current) {
|
||||||
|
reverted.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft === name) return;
|
||||||
|
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
setDraft(trimmed);
|
||||||
|
onSave(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="team-name">
|
||||||
|
<span className="team-name__label" style={{ color: team === 0 ? "#ff5f5f" : "#5aa9ff" }}>
|
||||||
|
队名
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input team-name__input"
|
||||||
|
value={draft}
|
||||||
|
maxLength={MAX_CHARS}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={DEFAULT_TEAM_NAMES[team] ?? "队伍"}
|
||||||
|
title="改完按回车或点到别处生效;留空回落到默认名"
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onFocus={() => {
|
||||||
|
focused.current = true;
|
||||||
|
}}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
// 只走失焦这一条提交路径,避免回车与失焦各提交一次。
|
||||||
|
event.currentTarget.blur();
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
// 注意:不能只 `setDraft(name)` 再 blur——blur 是同一次事件里同步发生的,
|
||||||
|
// `commit` 读到的还是这一帧的旧 draft,于是"取消"反而把改动存了下去。
|
||||||
|
reverted.current = true;
|
||||||
|
setDraft(name);
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* 顶部状态条:身份、连接、演示模式、说明、以及进入设置的入口。
|
||||||
|
*
|
||||||
|
* 演示模式的标记**必须显眼**:那一整屏画面与血量都是本机合成的,截图或验收时把它当成
|
||||||
|
* 真实链路会得出完全错误的结论。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { pushNote, useAppStore } from "../lib/store";
|
||||||
|
import type { Snapshot } from "../lib/types";
|
||||||
|
import { Button } from "./controls";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
snapshot: Snapshot;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
onOpenMedia?: () => void;
|
||||||
|
extra?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({ snapshot, onOpenSettings, onOpenMedia, extra }: Props) {
|
||||||
|
const { connected, notes } = useAppStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar__left">
|
||||||
|
<span className="topbar__brand">cam-desktop</span>
|
||||||
|
<span className={`tag tag--${snapshot.role}`}>
|
||||||
|
{snapshot.role === "team" ? "用户端" : "裁判端"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{snapshot.demo && (
|
||||||
|
<button
|
||||||
|
className="tag tag--demo tag--button"
|
||||||
|
title="所有画面与血量都是本机合成的假数据,不代表场上的任何一块板子。"
|
||||||
|
onClick={() =>
|
||||||
|
pushNote("演示模式:画面与血量由本机合成,不是真实链路。截图或验收时别把它当成现场数据。")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
演示模式 · 合成画面
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!connected && <span className="tag tag--warn">通道断开,重连中</span>}
|
||||||
|
|
||||||
|
{snapshot.recordings.length > 0 && (
|
||||||
|
<span className="tag tag--recording">
|
||||||
|
<span className="recording__dot" /> 录制中 ×{snapshot.recordings.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="topbar__right">
|
||||||
|
{notes.slice(-1).map((note, index) => (
|
||||||
|
<span key={index} className="topbar__note">
|
||||||
|
{note}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<span className="dim mono" title="连着这个后端的界面数">
|
||||||
|
界面 {snapshot.clients}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{onOpenMedia && (
|
||||||
|
<Button variant="ghost" onClick={onOpenMedia}>
|
||||||
|
录制与回放
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="ghost" onClick={onOpenSettings}>
|
||||||
|
设置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 浮动提示(错误/信息)。 */
|
||||||
|
export function Toast({
|
||||||
|
message,
|
||||||
|
kind,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
kind: "info" | "error";
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`toast toast--${kind}`} onClick={onClose} role="alert">
|
||||||
|
<span>{message}</span>
|
||||||
|
<span className="toast__close">✕</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 解锁对话框。
|
||||||
|
*
|
||||||
|
* 它只是**入口**,不是闸门:真正拦住改绑的是后端的 `require_unlocked`。所以这里即使
|
||||||
|
* 被人绕过、甚至被改成永远返回成功,配置也改不动。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "../lib/ipc";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 为什么需要密码:显示在标题下。 */
|
||||||
|
reason: string;
|
||||||
|
onUnlocked: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnlockDialog({ reason, onUnlocked, onCancel }: Props) {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const input = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
input.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ok = await api.unlock(password);
|
||||||
|
if (ok) {
|
||||||
|
onUnlocked();
|
||||||
|
} else {
|
||||||
|
setError("密码不对");
|
||||||
|
setPassword("");
|
||||||
|
input.current?.focus();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="dialog">
|
||||||
|
<h2 className="dialog__title">需要密码</h2>
|
||||||
|
<p className="dialog__hint">{reason}</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={input}
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
placeholder="密码"
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") void submit();
|
||||||
|
if (event.key === "Escape") onCancel();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="dialog__error">{error}</p>}
|
||||||
|
|
||||||
|
<p className="dialog__note">
|
||||||
|
密码只挡现场随手改绑,不是安全边界:配置文件与程序都在本机,愿意改的人绕得过去。
|
||||||
|
忘了密码就删掉配置里的 password 一行。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="dialog__actions">
|
||||||
|
<button className="button button--ghost" onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="button button--primary" onClick={() => void submit()} disabled={busy}>
|
||||||
|
{busy ? "校验中…" : "解锁"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
/**
|
||||||
|
* 一路画面:canvas 绘制 + 缩放/平移 + 网格 + 瞄准器 + 命中反馈。
|
||||||
|
*
|
||||||
|
* # 为什么画在 canvas 上而不是用 CSS 变换 `<img>`
|
||||||
|
*
|
||||||
|
* 瞄准器必须跟着**画面**走:它标的是相机光轴上的一个点。用 DOM 摆一个 `<img>` 再叠一层
|
||||||
|
* `transform` 的话,"图像落在哪、瞄准点落在哪"要算两遍,而两份实现迟早会不一致——表现
|
||||||
|
* 是放大之后瞄准器偏了,恰恰发生在这件事最要紧的时候。
|
||||||
|
*
|
||||||
|
* 所以变换只算一次(`layout()`),图像、网格、瞄准器、伤害数字全都用同一组数画出来。
|
||||||
|
*
|
||||||
|
* # 尺寸与缩放的关系
|
||||||
|
*
|
||||||
|
* - 图像按 `contain` 放进窗格,再乘 `zoom`。
|
||||||
|
* - **网格**画在图像矩形里,跟着缩放走:它标的是画面里的比例,用来估距离。
|
||||||
|
* - **瞄准器**的**位置**跟着图像走,**大小**不跟着:像真瞄具一样,放大之后准星不会
|
||||||
|
* 跟着变粗,而是覆盖画面里更小的一块——那正是放大有用武之地的原因。
|
||||||
|
* - 镜像靠 `scale(-1, 1)`,同时把拖动方向也翻过来,否则向右拖画面会往左走。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { streamClient } from "../lib/store";
|
||||||
|
import { withAlpha } from "../lib/format";
|
||||||
|
import type { CrosshairConfig, GridConfig, HitFeedbackConfig } from "../lib/types";
|
||||||
|
import type { FrameInfo } from "../lib/stream";
|
||||||
|
|
||||||
|
export interface ViewTransform {
|
||||||
|
zoom: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IDENTITY_VIEW: ViewTransform = { zoom: 1, x: 0, y: 0 };
|
||||||
|
|
||||||
|
/** 缩放上下限。再多只是把像素拉大。 */
|
||||||
|
export const MIN_ZOOM = 1;
|
||||||
|
export const MAX_ZOOM = 8;
|
||||||
|
export const ZOOM_STEP = 1.25;
|
||||||
|
|
||||||
|
export interface HitPulse {
|
||||||
|
/** 每次命中都不同,用来重启动画。 */
|
||||||
|
seq: number;
|
||||||
|
damage: number;
|
||||||
|
/** `performance.now()` 时刻。 */
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
camera: number | null;
|
||||||
|
emptyText: string;
|
||||||
|
mirrored: boolean;
|
||||||
|
crosshair: CrosshairConfig | null;
|
||||||
|
grid: GridConfig | null;
|
||||||
|
view: ViewTransform;
|
||||||
|
onView: (view: ViewTransform) => void;
|
||||||
|
/** 校准模式:拖动画面来挪瞄准点。 */
|
||||||
|
calibrating?: boolean;
|
||||||
|
onCalibrate?: (dxFraction: number, dyFraction: number) => void;
|
||||||
|
/** 命中脉冲。只有属于这一路的命中才该传进来。 */
|
||||||
|
hit?: HitPulse | null;
|
||||||
|
hitFeedback: HitFeedbackConfig;
|
||||||
|
showZoom: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
/** Only used by the opt-in screen/camera calibration page. */
|
||||||
|
onFramePainted?: (canvas:HTMLCanvasElement,frame:FrameInfo,drawMs:number)=>void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoSurface({
|
||||||
|
camera,
|
||||||
|
emptyText,
|
||||||
|
mirrored,
|
||||||
|
crosshair,
|
||||||
|
grid,
|
||||||
|
view,
|
||||||
|
onView,
|
||||||
|
calibrating = false,
|
||||||
|
onCalibrate,
|
||||||
|
hit = null,
|
||||||
|
hitFeedback,
|
||||||
|
showZoom,
|
||||||
|
children,
|
||||||
|
onFramePainted,
|
||||||
|
}: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
// 绘制循环不随 props 重建,所以最新一份 props 放在 ref 里给它读。
|
||||||
|
const props = useRef({ camera, mirrored, crosshair, grid, view, hit, hitFeedback,onFramePainted });
|
||||||
|
props.current = { camera, mirrored, crosshair, grid, view, hit, hitFeedback,onFramePainted };
|
||||||
|
|
||||||
|
const drag = useRef<{ x: number; y: number; originX: number; originY: number } | null>(null);
|
||||||
|
const dirty = useRef(true);
|
||||||
|
const lastFrameId = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dirty.current = true;
|
||||||
|
}, [camera, mirrored, crosshair, grid, view, hit, hitFeedback]);
|
||||||
|
|
||||||
|
// canvas 的像素尺寸跟着 CSS 尺寸与 DPR 走。不这么做的话在 Retina 上会糊。
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
|
||||||
|
const height = Math.max(1, Math.round(canvas.clientHeight * dpr));
|
||||||
|
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
dirty.current = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(resize);
|
||||||
|
observer.observe(canvas);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
|
const loop = (timestamp: number) => {
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
const current = props.current;
|
||||||
|
const frame = streamClient().latest(current.camera);
|
||||||
|
const newFrame=frame && frame.frameId!==lastFrameId.current;
|
||||||
|
|
||||||
|
if (frame && frame.frameId !== lastFrameId.current) {
|
||||||
|
lastFrameId.current = frame.frameId;
|
||||||
|
dirty.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命中动画期间必须持续重绘,否则闪红/伤害数字只画了一帧就停住。
|
||||||
|
//
|
||||||
|
// **抖动没有了**(见 `HitFeedbackConfig::shake` 的文档):不再有"整幅图跟着晃"的
|
||||||
|
// 那一路,所以这里也不再需要因为抖动而复绘。
|
||||||
|
const pulseAge = current.hit ? timestamp - current.hit.at : Number.POSITIVE_INFINITY;
|
||||||
|
const animating =
|
||||||
|
pulseAge < 700 && (current.hitFeedback.marker || current.hitFeedback.flash);
|
||||||
|
|
||||||
|
if (!dirty.current && !animating) return;
|
||||||
|
dirty.current = false;
|
||||||
|
|
||||||
|
draw(canvas, current, frame, timestamp);
|
||||||
|
if(newFrame && current.onFramePainted) current.onFramePainted(canvas,frame,performance.now());
|
||||||
|
};
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- 输入 ----
|
||||||
|
|
||||||
|
const onWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
|
const factor = event.deltaY > 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
|
||||||
|
const zoom = clamp(view.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||||
|
|
||||||
|
// 缩回 1 时顺便归位:此时画面完整可见,平移没有意义,留着会让下一次放大从偏移位置
|
||||||
|
// 开始,看起来像"跳了一下"。
|
||||||
|
onView(zoom <= MIN_ZOOM ? IDENTITY_VIEW : { ...view, zoom });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
drag.current = { x: event.clientX, y: event.clientY, originX: view.x, originY: view.y };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const start = drag.current;
|
||||||
|
if (!start) return;
|
||||||
|
|
||||||
|
const dx = event.clientX - start.x;
|
||||||
|
const dy = event.clientY - start.y;
|
||||||
|
|
||||||
|
if (calibrating) {
|
||||||
|
// 校准:把屏幕上的位移换算成画面里的比例位移。用当前显示出来的图像尺寸做分母,
|
||||||
|
// 所以放大之后校准更精细——这正是需要的。
|
||||||
|
const box = canvasRef.current;
|
||||||
|
if (!box || !onCalibrate) return;
|
||||||
|
|
||||||
|
const frame = streamClient().latest(camera);
|
||||||
|
if (!frame) return;
|
||||||
|
|
||||||
|
const { drawWidth, drawHeight } = layout(box, frame.width, frame.height, view);
|
||||||
|
if (drawWidth <= 0 || drawHeight <= 0) return;
|
||||||
|
|
||||||
|
// 一次移动一小步,然后把起点挪过来。
|
||||||
|
drag.current = { ...start, x: event.clientX, y: event.clientY };
|
||||||
|
const sign = mirrored ? -1 : 1;
|
||||||
|
onCalibrate((sign * dx) / drawWidth, dy / drawHeight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.zoom <= MIN_ZOOM) return;
|
||||||
|
|
||||||
|
// 镜像时把横向拖动也翻过来:否则向右拖,画面往左走,看起来像拖动坏了。
|
||||||
|
const sign = mirrored ? -1 : 1;
|
||||||
|
onView({ ...view, x: start.originX + sign * dx, y: start.originY + dy });
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDrag = () => {
|
||||||
|
drag.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cursor = calibrating ? "crosshair" : view.zoom > MIN_ZOOM ? "grab" : "default";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="video-surface"
|
||||||
|
style={{ cursor }}
|
||||||
|
onWheel={onWheel}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
onDoubleClick={() => onView(IDENTITY_VIEW)}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} className="video-canvas" />
|
||||||
|
|
||||||
|
{emptyText !== "" && <div className="video-empty">{emptyText}</div>}
|
||||||
|
|
||||||
|
{showZoom && view.zoom > MIN_ZOOM + 0.05 && (
|
||||||
|
<div className="video-badge video-badge--zoom">×{view.zoom.toFixed(2)}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(view.zoom > MIN_ZOOM + 0.05 || view.x !== 0 || view.y !== 0) && (
|
||||||
|
<button className="video-badge video-badge--reset" onClick={() => onView(IDENTITY_VIEW)}>
|
||||||
|
复位
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{calibrating && <div className="video-calibrating">校准中:拖动画面挪动瞄准点</div>}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 绘制
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CanvasProps {
|
||||||
|
camera: number | null;
|
||||||
|
mirrored: boolean;
|
||||||
|
crosshair: CrosshairConfig | null;
|
||||||
|
grid: GridConfig | null;
|
||||||
|
view: ViewTransform;
|
||||||
|
hit: HitPulse | null;
|
||||||
|
hitFeedback: HitFeedbackConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Box {
|
||||||
|
clientWidth: number;
|
||||||
|
clientHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图像在窗格里的落点与大小(CSS 像素)。 */
|
||||||
|
function layout(box: Box, frameWidth: number, frameHeight: number, view: ViewTransform) {
|
||||||
|
const width = box.clientWidth;
|
||||||
|
const height = box.clientHeight;
|
||||||
|
|
||||||
|
if (frameWidth <= 0 || frameHeight <= 0) {
|
||||||
|
return { left: 0, top: 0, drawWidth: 0, drawHeight: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fit = Math.min(width / frameWidth, height / frameHeight);
|
||||||
|
const drawWidth = frameWidth * fit * view.zoom;
|
||||||
|
const drawHeight = frameHeight * fit * view.zoom;
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: (width - drawWidth) / 2 + view.x,
|
||||||
|
top: (height - drawHeight) / 2 + view.y,
|
||||||
|
drawWidth,
|
||||||
|
drawHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
current: CanvasProps,
|
||||||
|
frame: { bitmap: ImageBitmap; width: number; height: number } | undefined,
|
||||||
|
timestamp: number,
|
||||||
|
) {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const width = canvas.clientWidth;
|
||||||
|
const height = canvas.clientHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
const box = { clientWidth: width, clientHeight: height };
|
||||||
|
|
||||||
|
// 没有画面时把瞄准器留在窗格中心:录像断了,准星不该跟着消失。
|
||||||
|
const fallback = { left: 0, top: 0, drawWidth: 0, drawHeight: 0 };
|
||||||
|
const geometry = frame
|
||||||
|
? layout(box, frame.width, frame.height, current.view)
|
||||||
|
: fallback;
|
||||||
|
|
||||||
|
if (frame) {
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
if (current.mirrored) {
|
||||||
|
ctx.translate(width, 0);
|
||||||
|
ctx.scale(-1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 放大超过 3 倍时关掉插值:这时候要看的是像素本身,双线性插值只会把它糊掉。
|
||||||
|
ctx.imageSmoothingEnabled = current.view.zoom < 3;
|
||||||
|
ctx.drawImage(frame.bitmap, geometry.left, geometry.top, geometry.drawWidth, geometry.drawHeight);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
if (current.grid?.visible) {
|
||||||
|
drawGrid(ctx, geometry, current.grid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 瞄准点的屏幕位置:图像坐标系里的 (0.5 + offsetX, 0.5 + offsetY)。
|
||||||
|
const crosshair = current.crosshair;
|
||||||
|
let aimX = width / 2;
|
||||||
|
let aimY = height / 2;
|
||||||
|
|
||||||
|
if (crosshair && frame && geometry.drawWidth > 0) {
|
||||||
|
const rawX = geometry.left + (0.5 + crosshair.offsetX) * geometry.drawWidth;
|
||||||
|
aimX = current.mirrored ? width - rawX : rawX;
|
||||||
|
aimY = geometry.top + (0.5 + crosshair.offsetY) * geometry.drawHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (crosshair?.visible) {
|
||||||
|
drawCrosshair(ctx, aimX, aimY, Math.min(width, height), crosshair);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 伤害数字:画在屏幕坐标里,不跟着缩放走。
|
||||||
|
if (current.hit && current.hitFeedback.marker) {
|
||||||
|
const age = timestamp - current.hit.at;
|
||||||
|
if (age >= 0 && age < 700) {
|
||||||
|
const progress = age / 700;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = 1 - progress;
|
||||||
|
ctx.fillStyle = "#ff8a8a";
|
||||||
|
ctx.font = `600 ${Math.round(Math.max(14, Math.min(width, height) * 0.05))}px ui-sans-serif, system-ui, sans-serif`;
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(`-${current.hit.damage}`, aimX + 24, aimY - 26 - progress * 26);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
geometry: { left: number; top: number; drawWidth: number; drawHeight: number },
|
||||||
|
grid: GridConfig,
|
||||||
|
) {
|
||||||
|
if (geometry.drawWidth <= 0 || geometry.drawHeight <= 0) return;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = `rgba(255, 255, 255, ${grid.opacity})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
const x0 = geometry.left;
|
||||||
|
const y0 = geometry.top;
|
||||||
|
const x1 = x0 + geometry.drawWidth;
|
||||||
|
const y1 = y0 + geometry.drawHeight;
|
||||||
|
|
||||||
|
if (grid.style === "thirds") {
|
||||||
|
for (const fraction of [1 / 3, 2 / 3]) {
|
||||||
|
line(ctx, x0 + geometry.drawWidth * fraction, y0, x0 + geometry.drawWidth * fraction, y1);
|
||||||
|
line(ctx, x0, y0 + geometry.drawHeight * fraction, x1, y0 + geometry.drawHeight * fraction);
|
||||||
|
}
|
||||||
|
} else if (grid.style === "squares") {
|
||||||
|
const divisions = Math.max(2, grid.divisions);
|
||||||
|
for (let i = 1; i < divisions; i += 1) {
|
||||||
|
const fraction = i / divisions;
|
||||||
|
line(ctx, x0 + geometry.drawWidth * fraction, y0, x0 + geometry.drawWidth * fraction, y1);
|
||||||
|
line(ctx, x0, y0 + geometry.drawHeight * fraction, x1, y0 + geometry.drawHeight * fraction);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 中心十字。
|
||||||
|
line(ctx, x0 + geometry.drawWidth / 2, y0, x0 + geometry.drawWidth / 2, y1);
|
||||||
|
line(ctx, x0, y0 + geometry.drawHeight / 2, x1, y0 + geometry.drawHeight / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number) {
|
||||||
|
// 半像素偏移,让 1px 的线落在像素中心而不是跨两个像素发虚。
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(Math.round(x0) + 0.5, Math.round(y0) + 0.5);
|
||||||
|
ctx.lineTo(Math.round(x1) + 0.5, Math.round(y1) + 0.5);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCrosshair(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
shortSide: number,
|
||||||
|
config: CrosshairConfig,
|
||||||
|
) {
|
||||||
|
const arm = Math.max(3, config.size * shortSide);
|
||||||
|
const gap = config.gap * shortSide;
|
||||||
|
const thickness = Math.max(1, config.thickness * shortSide);
|
||||||
|
|
||||||
|
// 外描一圈深色:浅色背景下亮绿准星会消失,这一圈让它始终看得见。
|
||||||
|
const halo = withAlpha("#000000", 0.55);
|
||||||
|
|
||||||
|
const stroke = (color: string, width: number, radius: number) => {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = width;
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
if (config.style === "circle-dot") {
|
||||||
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||||
|
} else {
|
||||||
|
// 四臂,中心留 gap 的空隙。
|
||||||
|
ctx.moveTo(cx - radius, cy);
|
||||||
|
ctx.lineTo(cx - gap, cy);
|
||||||
|
ctx.moveTo(cx + gap, cy);
|
||||||
|
ctx.lineTo(cx + radius, cy);
|
||||||
|
ctx.moveTo(cx, cy - radius);
|
||||||
|
ctx.lineTo(cx, cy - gap);
|
||||||
|
ctx.moveTo(cx, cy + gap);
|
||||||
|
ctx.lineTo(cx, cy + radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
};
|
||||||
|
|
||||||
|
stroke(halo, thickness + 2, arm + 1);
|
||||||
|
stroke(config.color, thickness, arm);
|
||||||
|
|
||||||
|
// 中心点:`cross` 之外都画。
|
||||||
|
if (config.style !== "cross") {
|
||||||
|
const dot = Math.max(1.2, thickness * 0.9);
|
||||||
|
ctx.fillStyle = halo;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, dot + 1, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = config.color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, dot, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/** 设置面板里复用的几个小控件。分成单独文件只是为了不把面板本身撑成一千行。 */
|
||||||
|
|
||||||
|
import type { ChangeEvent, ReactNode } from "react";
|
||||||
|
|
||||||
|
export function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<h3 className="section__title">{title}</h3>
|
||||||
|
{hint && <p className="section__hint">{hint}</p>}
|
||||||
|
<div className="section__body">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="field">
|
||||||
|
<span className="field__label">{label}</span>
|
||||||
|
<span className="field__control">{children}</span>
|
||||||
|
{hint && <span className="field__hint">{hint}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Switch({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className={`switch${disabled ? " switch--disabled" : ""}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => onChange(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="switch__track">
|
||||||
|
<span className="switch__knob" />
|
||||||
|
</span>
|
||||||
|
<span className="switch__label">{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Slider({
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
onChange,
|
||||||
|
format,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
format?: (value: number) => string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="slider">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="slider__value">{format ? format(value) : value.toFixed(2)}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select<T extends string | number>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
value: T | null;
|
||||||
|
options: { value: T; label: string; hint?: string }[];
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={value === null ? "" : String(value)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
const raw = event.target.value;
|
||||||
|
const match = options.find((option) => String(option.value) === raw);
|
||||||
|
if (match) onChange(match.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value === null && <option value="">(未选择)</option>}
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={String(option.value)} value={String(option.value)}>
|
||||||
|
{option.hint ? `${option.label} — ${option.hint}` : option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
variant = "default",
|
||||||
|
disabled = false,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
variant?: "default" | "primary" | "danger" | "ghost";
|
||||||
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button className={`button button--${variant}`} onClick={onClick} disabled={disabled} title={title}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行只读信息:左边标题、右边值。 */
|
||||||
|
export function Readout({ label, value }: { label: string; value: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="readout">
|
||||||
|
<span className="readout__label">{label}</span>
|
||||||
|
<span className="readout__value">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
/**
|
||||||
|
* 仅开发用的浏览器预览。
|
||||||
|
*
|
||||||
|
* # 它解决什么问题
|
||||||
|
*
|
||||||
|
* Tauri 的界面跑在 WKWebView 里,改一行 CSS 要走一次 cargo 构建;而且截图要靠 macOS 的
|
||||||
|
* 屏幕录制权限。预览模式让同一套 React 组件在浏览器里跑起来:状态由本文件合成,画面用
|
||||||
|
* `createImageBitmap` 从一块 canvas 上取——`VideoSurface`、血条、瞄准器、命中反馈全都是
|
||||||
|
* 真代码,只有"后端"是假的。
|
||||||
|
*
|
||||||
|
* # 它绝不可能进到正式构建里
|
||||||
|
*
|
||||||
|
* 两重门:
|
||||||
|
*
|
||||||
|
* 1. `import.meta.env.DEV` —— 只在 `vite dev` 下为真;`vite build` 时是 false,
|
||||||
|
* 2. 并且页面里没有 Tauri 注入的 `__TAURI_INTERNALS__`。
|
||||||
|
*
|
||||||
|
* 调用点还用 `await import(...)`:条件为假时整个模块不会被打进产物,而不是打进去再靠
|
||||||
|
* 运行时不走。
|
||||||
|
*
|
||||||
|
* # 用法
|
||||||
|
*
|
||||||
|
* ```sh
|
||||||
|
* pnpm dev # 用户端
|
||||||
|
* open 'http://127.0.0.1:1420/?preview=referee' # 裁判端
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_TEAM_NAMES } from "./format";
|
||||||
|
import type { StreamClient } from "./stream";
|
||||||
|
import type {
|
||||||
|
ConfigView,
|
||||||
|
HudView,
|
||||||
|
Snapshot,
|
||||||
|
WireMsg,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/** 页面里有没有 Tauri 注入。 */
|
||||||
|
function hasTauri(): boolean {
|
||||||
|
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 现在是不是在预览模式。 */
|
||||||
|
export function isPreview(): boolean {
|
||||||
|
return import.meta.env.DEV && !hasTauri();
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(typeof location === "undefined" ? "" : location.search);
|
||||||
|
const startAsReferee = params.get("preview") === "referee";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 可变状态:设置面板里的改动会落到这里,界面因此是可交互的
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEFAULT_HUD: HudView = {
|
||||||
|
health: {
|
||||||
|
visible: true,
|
||||||
|
showNumbers: true,
|
||||||
|
showTeamName: true,
|
||||||
|
lowThreshold: 30,
|
||||||
|
pulse: true,
|
||||||
|
width: 0.42,
|
||||||
|
},
|
||||||
|
crosshair: {
|
||||||
|
visible: true,
|
||||||
|
style: "cross-dot",
|
||||||
|
size: 0.045,
|
||||||
|
thickness: 0.004,
|
||||||
|
gap: 0.012,
|
||||||
|
color: "#3dff88",
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
},
|
||||||
|
grid: { visible: false, style: "thirds", divisions: 6, opacity: 0.22 },
|
||||||
|
hitFeedback: { flash: true, shake: false, marker: true, intensity: 1 },
|
||||||
|
showZoom: true,
|
||||||
|
showStats: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CAMERA_A = 0x0de0_0001;
|
||||||
|
const CAMERA_B = 0x0de0_0002;
|
||||||
|
const ARMOR_A = 0x0a20_0001;
|
||||||
|
const ARMOR_B = 0x0a20_0002;
|
||||||
|
|
||||||
|
interface PreviewState {
|
||||||
|
role: "team" | "referee";
|
||||||
|
hud: HudView;
|
||||||
|
cameraId: number | null;
|
||||||
|
armorId: number | null;
|
||||||
|
team: number;
|
||||||
|
/** 两边显示的名字,与后端配置里的 `teamNames` 对应。 */
|
||||||
|
teamNames: [string, string];
|
||||||
|
mirror: boolean;
|
||||||
|
unlocked: boolean;
|
||||||
|
password: string | null;
|
||||||
|
calibrating: boolean;
|
||||||
|
recordings: { camera: number; frames: number; startedMs: number }[];
|
||||||
|
hp: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: PreviewState = {
|
||||||
|
role: startAsReferee ? "referee" : "team",
|
||||||
|
hud: structuredClone(DEFAULT_HUD),
|
||||||
|
cameraId: startAsReferee ? null : CAMERA_A,
|
||||||
|
armorId: startAsReferee ? null : ARMOR_A,
|
||||||
|
team: 0,
|
||||||
|
teamNames: ["队伍一", "队伍二"],
|
||||||
|
mirror: true,
|
||||||
|
// 预览里直接是已解锁状态,免得每次刷新都要输密码。
|
||||||
|
unlocked: false,
|
||||||
|
password: "1234",
|
||||||
|
calibrating: false,
|
||||||
|
recordings: [],
|
||||||
|
hp: [100, 100],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 假的命令实现
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 供 `ipc.ts` 在有 Tauri 时不会走到的分支上用。 */
|
||||||
|
export async function previewInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
const value = handle(command, args ?? {});
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(command: string, args: Record<string, unknown>): unknown {
|
||||||
|
switch (command) {
|
||||||
|
case "stream_info":
|
||||||
|
return { url: "preview://", port: 0, clients: 1 };
|
||||||
|
case "get_snapshot":
|
||||||
|
return snapshot();
|
||||||
|
case "get_config":
|
||||||
|
return configView();
|
||||||
|
case "list_media_files":
|
||||||
|
return [];
|
||||||
|
case "config_path":
|
||||||
|
return "/预览模式/没有真实配置文件";
|
||||||
|
case "unlock":
|
||||||
|
state.unlocked = String(args.password) === state.password;
|
||||||
|
return state.unlocked;
|
||||||
|
case "lock":
|
||||||
|
state.unlocked = false;
|
||||||
|
return undefined;
|
||||||
|
case "set_password":
|
||||||
|
state.password = String(args.fresh);
|
||||||
|
state.unlocked = true;
|
||||||
|
return configView();
|
||||||
|
case "set_discovery_open":
|
||||||
|
return undefined;
|
||||||
|
case "bind_camera":
|
||||||
|
guard();
|
||||||
|
state.cameraId = (args.cameraId as number | null) ?? null;
|
||||||
|
return configView();
|
||||||
|
case "bind_armor":
|
||||||
|
guard();
|
||||||
|
state.armorId = (args.armorId as number | null) ?? null;
|
||||||
|
return configView();
|
||||||
|
case "set_team":
|
||||||
|
guard();
|
||||||
|
state.team = Number(args.team);
|
||||||
|
return configView();
|
||||||
|
case "set_team_name": {
|
||||||
|
guard();
|
||||||
|
const team = Number(args.team);
|
||||||
|
if (team !== 0 && team !== 1) throw new Error("队伍号只能是 0 或 1");
|
||||||
|
// 与后端一样:合并空白、按字符截断、留空回落默认名。
|
||||||
|
state.teamNames[team] = sanitizeName(String(args.name ?? ""), team);
|
||||||
|
return configView();
|
||||||
|
}
|
||||||
|
case "set_role":
|
||||||
|
state.role = args.role as "team" | "referee";
|
||||||
|
return configView();
|
||||||
|
case "set_mirror":
|
||||||
|
state.mirror = Boolean(args.mirror);
|
||||||
|
return configView();
|
||||||
|
case "save_hud":
|
||||||
|
state.hud = args.hud as HudView;
|
||||||
|
return configView();
|
||||||
|
case "set_referee_slots":
|
||||||
|
return configView();
|
||||||
|
case "set_recording_options":
|
||||||
|
return configView();
|
||||||
|
case "screenshot":
|
||||||
|
return "/预览模式/没有截图";
|
||||||
|
case "start_recording": {
|
||||||
|
const cameras = (args.cameras as number[]) ?? [];
|
||||||
|
const startedMs = Date.now();
|
||||||
|
for (const camera of cameras) {
|
||||||
|
if (!state.recordings.some((r) => r.camera === camera)) {
|
||||||
|
state.recordings.push({ camera, frames: 0, startedMs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recordings();
|
||||||
|
}
|
||||||
|
case "stop_recording": {
|
||||||
|
const cameras = (args.cameras as number[]) ?? [];
|
||||||
|
state.recordings = cameras.length
|
||||||
|
? state.recordings.filter((r) => !cameras.includes(r.camera))
|
||||||
|
: [];
|
||||||
|
return recordings();
|
||||||
|
}
|
||||||
|
case "refresh":
|
||||||
|
case "reveal":
|
||||||
|
case "open_file":
|
||||||
|
return undefined;
|
||||||
|
case "clear_config":
|
||||||
|
return "预览模式:没有真实配置可清空";
|
||||||
|
case "quit":
|
||||||
|
return undefined;
|
||||||
|
default:
|
||||||
|
throw new Error(`预览模式没有实现命令 ${command}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览里模拟后端的密码闸门,好让解锁流程也能被看到。 */
|
||||||
|
function guard() {
|
||||||
|
if (state.role === "team" && state.password !== null && !state.unlocked) {
|
||||||
|
throw new Error("这个改动需要密码。先解锁(预览模式里密码是 1234)。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function configView(): ConfigView {
|
||||||
|
return {
|
||||||
|
role: state.role,
|
||||||
|
roleChosen: true,
|
||||||
|
team: state.team,
|
||||||
|
teamNames: [...state.teamNames] as [string, string],
|
||||||
|
cameraId: state.cameraId,
|
||||||
|
cameraIdText: state.cameraId === null ? null : hex(state.cameraId),
|
||||||
|
armorId: state.armorId,
|
||||||
|
armorIdText: state.armorId === null ? null : hex(state.armorId),
|
||||||
|
mirror: state.mirror,
|
||||||
|
passwordSet: state.password !== null,
|
||||||
|
hud: structuredClone(state.hud),
|
||||||
|
recordingDir: "/预览模式/recordings",
|
||||||
|
ffmpeg: "ffmpeg",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordings() {
|
||||||
|
return state.recordings.map((recording) => ({
|
||||||
|
camera: recording.camera,
|
||||||
|
cameraText: hex(recording.camera),
|
||||||
|
active: true,
|
||||||
|
path: `/预览模式/rec-${recording.camera.toString(16)}.mp4`,
|
||||||
|
inputFps: 20,
|
||||||
|
framesWritten: recording.frames,
|
||||||
|
framesDropped: 0,
|
||||||
|
startedMs: recording.startedMs,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 `config::sanitize_team_name` 一致:合并空白、按字符截断、留空回落默认名。 */
|
||||||
|
function sanitizeName(name: string | undefined, team: number): string {
|
||||||
|
const collapsed = (name ?? "").split(/\s+/).filter(Boolean).join(" ");
|
||||||
|
const text = collapsed.length > 0 ? collapsed : DEFAULT_TEAM_NAMES[team];
|
||||||
|
return [...text].slice(0, 12).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hex(id: number): string {
|
||||||
|
return `0x${id.toString(16).toUpperCase().padStart(8, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(): Snapshot {
|
||||||
|
const cameras = [
|
||||||
|
{ id: CAMERA_A, team: 0 },
|
||||||
|
{ id: CAMERA_B, team: 1 },
|
||||||
|
].map(({ id, team }) => ({
|
||||||
|
id,
|
||||||
|
idText: hex(id),
|
||||||
|
addr: team === 0 ? "192.168.1.41" : "192.168.1.42",
|
||||||
|
online: true,
|
||||||
|
silenceMs: 120,
|
||||||
|
firmware: 3,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const boards = [
|
||||||
|
{ id: ARMOR_A, team: 0 },
|
||||||
|
{ id: ARMOR_B, team: 1 },
|
||||||
|
].map(({ id, team }, index) => ({
|
||||||
|
id,
|
||||||
|
idText: hex(id),
|
||||||
|
addr: team === 0 ? "192.168.1.51" : "192.168.1.52",
|
||||||
|
online: true,
|
||||||
|
team,
|
||||||
|
hp: state.hp[index],
|
||||||
|
platesSeen: 4,
|
||||||
|
reportsAllPlates: true,
|
||||||
|
silenceMs: 90,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const stats = cameras.map((camera) => ({
|
||||||
|
camera: camera.id,
|
||||||
|
cameraText: camera.idText,
|
||||||
|
online: true,
|
||||||
|
status: camera.idText,
|
||||||
|
hasFrames: true,
|
||||||
|
fps: 20,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
framesShown: 1000,
|
||||||
|
framesDropped: 3,
|
||||||
|
lossPercent: 0.3,
|
||||||
|
datagrams: 17000,
|
||||||
|
lastFrameBytes: 22000,
|
||||||
|
gapMs: 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 与后端 `cam_core::snapshot::resolve_outcomes` 同一套规则,包括那条不对称:
|
||||||
|
// 输只看自己,赢要看对手归零**且**自己还活着。
|
||||||
|
const outcomeFor = (team: number) => {
|
||||||
|
const mine = state.hp[team];
|
||||||
|
const theirs = state.hp[team === 0 ? 1 : 0];
|
||||||
|
|
||||||
|
if (mine === 0 && theirs === 0) return "draw" as const;
|
||||||
|
if (theirs === 0 && mine > 0) return "victory" as const;
|
||||||
|
if (mine === 0) return "defeat" as const;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const healthFor = (team: number) => {
|
||||||
|
const hp = state.hp[team];
|
||||||
|
const board = team === 0 ? ARMOR_A : ARMOR_B;
|
||||||
|
|
||||||
|
return {
|
||||||
|
team,
|
||||||
|
name: state.teamNames[team],
|
||||||
|
available: true,
|
||||||
|
placeholder: "",
|
||||||
|
hp,
|
||||||
|
hps: [hp, hp, hp, hp],
|
||||||
|
board,
|
||||||
|
boardText: hex(board),
|
||||||
|
critical: hp === 0 || hp <= state.hud.health.lowThreshold,
|
||||||
|
outcome: outcomeFor(team),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: state.role,
|
||||||
|
team: state.team,
|
||||||
|
mirrored: state.mirror,
|
||||||
|
demo: true,
|
||||||
|
diagnostics: true,
|
||||||
|
unlocked: state.unlocked || state.role === "referee",
|
||||||
|
discoveryOpen: true,
|
||||||
|
uptimeMs: performance.now(),
|
||||||
|
clients: 1,
|
||||||
|
config: configView(),
|
||||||
|
cameras,
|
||||||
|
armorBoards: boards,
|
||||||
|
stats,
|
||||||
|
slots: {
|
||||||
|
cameraA: state.role === "referee" ? CAMERA_A : state.cameraId,
|
||||||
|
cameraB: state.role === "referee" ? CAMERA_B : null,
|
||||||
|
armorA: state.role === "referee" ? ARMOR_A : state.armorId,
|
||||||
|
armorB: state.role === "referee" ? ARMOR_B : null,
|
||||||
|
manual: false,
|
||||||
|
},
|
||||||
|
// 两边都给:用户端也要显示对手的血量(画面右上角那一条)。
|
||||||
|
health: [healthFor(0), healthFor(1)],
|
||||||
|
recordings: recordings(),
|
||||||
|
notes: ["这是浏览器预览:状态与画面都是本文件合成的,不是真实链路。"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 假的画面与心跳
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 启动预览的数据源:每 200ms 一份快照,每 50ms 一帧画面。 */
|
||||||
|
export function startPreview(client: StreamClient, emit: (msg: WireMsg) => void) {
|
||||||
|
let frameId = 0;
|
||||||
|
const started = performance.now();
|
||||||
|
|
||||||
|
// 快照。
|
||||||
|
const snapshotTimer = window.setInterval(() => {
|
||||||
|
emit({ t: "snapshot", ...snapshot() } as WireMsg);
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
// 两路画面,各 20fps。
|
||||||
|
const canvases = new Map<number, HTMLCanvasElement>();
|
||||||
|
const render = async (camera: number, team: number) => {
|
||||||
|
const canvas = canvases.get(camera) ?? makeCanvas();
|
||||||
|
canvases.set(camera, canvas);
|
||||||
|
draw(canvas, team, frameId);
|
||||||
|
|
||||||
|
const bitmap = await createImageBitmap(canvas);
|
||||||
|
client.injectFrame(camera, frameId, canvas.width, canvas.height, bitmap);
|
||||||
|
};
|
||||||
|
|
||||||
|
const frameTimer = window.setInterval(() => {
|
||||||
|
frameId += 1;
|
||||||
|
|
||||||
|
const cameras =
|
||||||
|
state.role === "referee"
|
||||||
|
? [
|
||||||
|
[CAMERA_A, 0],
|
||||||
|
[CAMERA_B, 1],
|
||||||
|
]
|
||||||
|
: [[state.cameraId ?? CAMERA_A, state.team]];
|
||||||
|
|
||||||
|
for (const [camera, team] of cameras as [number, number][]) {
|
||||||
|
void render(camera, team);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const recording of state.recordings) recording.frames += 1;
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
// 掉血:每 3 秒一次,轮流打两块板子,顺便把命中反馈也验证了。
|
||||||
|
//
|
||||||
|
// 打空的那一拍只做重置、不出命中(胜负横幅要真的能看到),其余每一拍都出一次
|
||||||
|
// 命中。早先的写法是 `to = from === 0 ? 100 : ...` 然后按 `to >= from` 判断——那样一旦
|
||||||
|
// 两块都打空,之后每次都是重置,命中就再也不会出现,盯着界面等闪红会等到超时。
|
||||||
|
let tick = 0;
|
||||||
|
const hitTimer = window.setInterval(() => {
|
||||||
|
tick += 1;
|
||||||
|
const team = tick % 2;
|
||||||
|
|
||||||
|
if (state.hp[team] === 0) {
|
||||||
|
state.hp[team] = 100;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = state.hp[team];
|
||||||
|
const damage = 3 + ((tick * 7) % 20);
|
||||||
|
const to = Math.max(0, from - damage);
|
||||||
|
state.hp[team] = to;
|
||||||
|
|
||||||
|
emit({
|
||||||
|
t: "hit",
|
||||||
|
board: team === 0 ? ARMOR_A : ARMOR_B,
|
||||||
|
team,
|
||||||
|
index: 0,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
damage: from - to,
|
||||||
|
atMs: Date.now(),
|
||||||
|
} as WireMsg);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
// 第一帧不必等 200ms。
|
||||||
|
emit({ t: "snapshot", ...snapshot() } as WireMsg);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(snapshotTimer);
|
||||||
|
window.clearInterval(frameTimer);
|
||||||
|
window.clearInterval(hitTimer);
|
||||||
|
void started;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCanvas(): HTMLCanvasElement {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 640;
|
||||||
|
canvas.height = 480;
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与 `src-tauri/src/demo.rs` 画的是同一类东西:底色区分两路、中心靶心、二进制帧号。 */
|
||||||
|
function draw(canvas: HTMLCanvasElement, team: number, frameId: number) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const { width, height } = canvas;
|
||||||
|
const tint = team === 0 ? [0, 46, 40] : [58, 30, 0];
|
||||||
|
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||||
|
gradient.addColorStop(0, `rgb(${18 + tint[0] / 3}, ${18 + tint[1] / 3}, ${22 + tint[2] / 3})`);
|
||||||
|
gradient.addColorStop(1, `rgb(${78 + tint[0] / 3}, ${78 + tint[1] / 3}, ${82 + tint[2] / 3})`);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.07)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let x = 0; x < width; x += 40) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + 0.5, 0);
|
||||||
|
ctx.lineTo(x + 0.5, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 0; y < height; y += 40) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y + 0.5);
|
||||||
|
ctx.lineTo(width, y + 0.5);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
ctx.strokeStyle = "#c8c8d2";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, 60, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = "#96969f";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, 28, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.strokeStyle = "#78788a";
|
||||||
|
for (const [dx, dy] of [
|
||||||
|
[1, 0],
|
||||||
|
[-1, 0],
|
||||||
|
[0, 1],
|
||||||
|
[0, -1],
|
||||||
|
]) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + dx * 40, cy + dy * 40);
|
||||||
|
ctx.lineTo(cx + dx * 90, cy + dy * 90);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let bit = 0; bit < 16; bit += 1) {
|
||||||
|
const on = ((frameId >> bit) & 1) === 1;
|
||||||
|
ctx.fillStyle = on ? "#78ffa0" : "#34343c";
|
||||||
|
ctx.fillRect(12 + bit * 14, 12, 10, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = frameId / 20;
|
||||||
|
const phase = team === 0 ? 0 : 1.7;
|
||||||
|
ctx.fillStyle = "#ffd25a";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx + 250 * Math.sin(t * 0.9 + phase), cy + 150 * Math.cos(t * 1.3 + phase), 26, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = "#5ac8ff";
|
||||||
|
ctx.fillRect(((t * 60) % (width - 80)) | 0, height - 24, 60, 8);
|
||||||
|
|
||||||
|
ctx.font = "600 20px ui-monospace, monospace";
|
||||||
|
ctx.fillStyle = "#e8e8ec";
|
||||||
|
ctx.fillText(team === 0 ? "PREVIEW · TEAM 1" : "PREVIEW · TEAM 2", 14, height - 40);
|
||||||
|
ctx.font = "500 16px ui-monospace, monospace";
|
||||||
|
ctx.fillStyle = "#8a8a95";
|
||||||
|
ctx.fillText(`frame ${frameId}`, 14, height - 16);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user