fix(packaging): 修复 Windows 安装与固件打包并完善客户端文案
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s

- fix(installer): 修复低完整性产物无法写入临时目录的问题,并在 CI 打包前后检查完整性标签
- fix(firmware): 从本次 Cargo ELF 打包并同步配套镜像,修正短目录说明与 LLVM 搜索顺序
- fix(installer): 修复中文完成页和页眉遮挡,侧栏使用等比缩放的原始 Logo
- style(client): 应用图标增加圆角,统一“裁判系统客户端”命名
- fix(config): 安装提示改用可选本地热点配置,排除 Wi-Fi 凭据
- docs: 润色项目说明并统一裁判系统术语
- test(packaging): 补充 Windows 完整性和固件打包回归,覆盖外部输出目录、旧镜像与重复生成

验证:Windows NSIS 构建及原生页面预览;通用与本地安装配置编译;固件 release 构建、重复打包和客户端离线校验;6 项固件打包回归与 Windows 权限回归;git diff --check。未执行实机烧录。
This commit is contained in:
2026-10-21 09:25:17 +08:00
parent 6a0eb14b28
commit 054379dc64
52 changed files with 1070 additions and 280 deletions
+21 -8
View File
@@ -86,10 +86,12 @@
# 算 —— 只把 LOCALAPPDATA 指走它照样去开原来的路径(报错原文见 run 8 的日志),
# 把 USERPROFILE 指走它才跟着走(本机实测:探针 profile 下立刻出现
# all-stores.lock)。
# 想从根上解决:让 gitea-runner 以**中完整性**启动(在普通终端里跑
# 现在 Windows 打包要求 gitea-runner 以**中完整性或更高**启动(在普通终端里跑
# run-runner.ps1,而不是从被沙箱化的低完整性 shell 里拉起来),那样这些重定向
# 就都可以撤掉。这台机器上 runner 由计划任务 GiteaRunner-Windows(admin /
# Limited)拉起,但进程链上的完整性已经落到了 Low。
# Limited)拉起,但进程链上的完整性已经落到了 Low。仅重定向目录仍会产生带 Low
# 标签的 exe;用户运行它时也会变成 Low,NSIS 在执行 .onInit 前就无法写 TEMP。
# job 开始时检查进程级别;打包前修正输出目录与 exe 标签;上传前只读复查。
#
# ── 三个平台都显式写 --target,这不是啰嗦 ────────────────────────────────────
# desktop/.cargo/config.toml 里钉着 `target = "aarch64-apple-darwin"`(本机开发用;
@@ -368,12 +370,9 @@ jobs:
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 时改这几行。
# 这些隔离缓存路径沿用原 runner 配置。见文件头第 5 条:此前通过重定向
# 目录绕过 Low 进程的写入限制,但产物也会带 Low 标签。现在 job 开始时
# 要求进程达到 Medium;目录重定向仅用于缓存隔离,不能替代完整性检查。
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 都挂在
@@ -391,6 +390,10 @@ jobs:
with:
fetch-depth: 0
- name: 检查 Windows 打包进程完整性
shell: pwsh
run: ./desktop/scripts/prepare-windows-bundle.ps1 -CheckProcessOnly
# 宿主模式的 Windows runner 上,PATH 里的 `bash` 是 C:\Windows\System32\bash.exe
# —— WSL 的启动器,而 WSL 在 runner 账户下起不来,报 E_ACCESSDENIED(日志里是
# 一串 GBK 被当 UTF-8 解出来的乱码 + https://github.com/microsoft/WSL/issues)。
@@ -497,6 +500,16 @@ jobs:
working-directory: desktop
run: pnpm tauri build --target ${{ env.TARGET }} --bundles nsis
- name: 检查安装包与主程序完整性标签
working-directory: desktop
shell: pwsh
run: ./scripts/prepare-windows-bundle.ps1 -CheckOnly
- name: Windows 安装包权限回归(不安装应用)
working-directory: desktop
shell: pwsh
run: ./scripts/test-windows-bundle.ps1
- name: 上传产物
uses: actions/upload-artifact@v3
with:
+13 -10
View File
@@ -6,7 +6,7 @@
**黑龙江大学 602 实验室 · Simba Robotics**
面向机器人对抗赛的局域网图传与装甲板系统。ESP32-S3 采集 OV5640 的硬件 JPEG,经 UDP 发送到桌面端;ESP32-C3 上报装甲板血量,桌面端提供操作手视野、裁判双路监看、截图录制和 USB 固件维护。
本项目面向机器人对抗赛,提供局域网图传与装甲板系统。ESP32-S3 采集 OV5640 的硬件 JPEG,经 UDP 发送到桌面端;ESP32-C3 上报装甲板血量。桌面端提供操作手视野、裁判双路监看、截图录制和 USB 固件维护。
[![Firmware: ESP32-S3](https://img.shields.io/badge/Firmware-ESP32--S3-00979D)](docs/setup/firmware.md)
[![Armor: ESP32-C3](https://img.shields.io/badge/Armor-ESP32--C3-00979D)](docs/hardware/armor.md)
@@ -59,7 +59,7 @@ flowchart LR
| 网络项 | 配置 | 说明 |
| :--- | :--- | :--- |
| 图传板 Wi-Fi | 2.4 GHz | 板卡与客户端需处于可互通的局域网 |
| 图传板 Wi-Fi | 2.4 GHz | 裁判系统与客户端需处于可互通的局域网 |
| 视频与发现 | UDP `5600` | Hello、Subscribe 与 JPEG 分片 |
| 装甲板上报 | UDP `5601` | 每 200 ms 广播四路血量 |
| 本地界面通道 | 回环 WebSocket + Tauri IPC | JPEG 由 WebView 解码,网络接收线程不做视频解码 |
@@ -71,13 +71,13 @@ flowchart LR
| 模块 | 已有功能 | 使用条件与边界 |
| :--- | :--- | :--- |
| 图传固件 | VGA 彩色 JPEG、UDP 分片、多端订阅、Wi-Fi 重连、过期帧放弃 | 当前记录约 30 fps;不同网络、板卡与画质下需复测[^performance] |
| 图传固件 | VGA 彩色 JPEG、UDP 分片、多端订阅、Wi-Fi 重连、过期帧放弃 | 当前记录约 30 fps;不同网络、裁判系统与画质下需复测[^performance] |
| 用户端 | 本队画面、双方装甲条、瞄准器、偏移校准、命中反馈 | 先绑定图传板与装甲板;改绑、切换模式和退出受密码限制 |
| 裁判端 | 双路画面、队名编辑、装甲板发现、槽位分配、截图、录制与回放 | 录制需要 ffmpeg;图传槽位可手动指定 |
| 装甲板 | 四路受击检测与灯带、整车血量、UDP 上报 | 初始 100 血,每次受击扣 3 点;队伍号在编译期选择 |
| 固件维护 | USB 读取设备、检查固件包、选择候选槽位、备份和写后回读 | App 写入与回读已有实机记录;完整分区切换尚未验证 |
发送端在有限预算内提交当前帧,超时后转向新帧;本地视频通道与 WebView 会放弃滞留超过 100 ms 的旧帧。这个期限约束的是本机积压,**不代表相机到屏幕的端到端延迟**。
发送端在限定时间内提交当前帧,超时后转向新帧;本地视频通道与 WebView 会放弃滞留超过 100 ms 的旧帧。这个期限只约束本机积压,**不代表相机到屏幕的端到端延迟**。
> [!WARNING]
> 固件维护会写入设备 Flash。使用前核对芯片、分区和备份,并阅读[已验证与未验证范围](docs/maintenance/firmware.md#已验证--未验证)。ESP32-S3 与 ESP32-C3 的固件不能混用。
@@ -86,7 +86,7 @@ flowchart LR
## 快速开始
### 1. 无板卡:先看界面
### 1. 无裁判系统:先看界面
安装 Node.js 和 pnpm 后,在仓库根目录执行:
@@ -96,12 +96,12 @@ pnpm install --frozen-lockfile
pnpm dev
```
打开 [用户端预览](http://127.0.0.1:1420/) 或 [裁判端预览](http://127.0.0.1:1420/?preview=referee)。预览由浏览器合成状态和画面,无需 Rust 后端或板卡。
打开 [用户端预览](http://127.0.0.1:1420/) 或 [裁判端预览](http://127.0.0.1:1420/?preview=referee)。预览由浏览器合成状态和画面,无需 Rust 后端或裁判系统。
> [!TIP]
> 检查首次启动流程时,可使用 `?preview=first-run` 和 `?preview=password`。需要测试实际接收、重组和录制路径时,使用原生客户端的 `--demo` 模式;两种方式显示的都是合成数据。
### 2. 连接板卡:运行桌面端
### 2. 连接裁判系统:运行桌面端
先按[客户端说明](docs/desktop/client.md#快速开始)安装宿主平台依赖。Windows x64 需要 Rust stable MSVC、Visual Studio C++ Build Tools、Windows SDK 和 WebView2;录制另需 ffmpeg。
@@ -141,7 +141,7 @@ pnpm app --target x86_64-unknown-linux-gnu -- --locked --config 'unstable.build-
</details>
首次启动选择用户端或裁判端。用户端需要设置现场密码并绑定本车板卡;裁判端自动分配图传槽位,装甲板按上报队伍分配。对应关系不符时,在设置中手动指定。
首次启动时选择用户端或裁判端。用户端需要设置现场密码并绑定本车裁判系统;裁判端自动分配图传槽位,并按装甲板上报的队伍分配装甲槽位。对应关系不符时,可在设置中手动指定。
### 3. 编译图传或装甲固件
@@ -162,9 +162,12 @@ if (-not (Test-Path -LiteralPath .env)) {
Copy-Item -LiteralPath .env.example -Destination .env
}
# 用编辑器填写 .env 中的 Wi-Fi 配置后再构建。
$env:CARGO_TARGET_DIR = 'C:\s3vt'
.\scripts\build.ps1 -Package
```
Windows 必须使用可写的短输出目录,且不要与其他项目共用。`esp-idf-sys` 检查完整的构建输出路径,超过 88 字符会报 `Too long output directory`;仓库位于用户目录时,默认的 `target/` 通常会超限。这里将构建缓存放到 `C:\s3vt`,无需移动源码;`subst` 不能绕过该限制。固件 ZIP 仍输出到仓库的 `target/firmware/`。该环境变量仅供固件构建使用,切回桌面端前执行 `Remove-Item Env:CARGO_TARGET_DIR`。
Linux / macOS(Bash):
```bash
@@ -246,7 +249,7 @@ cargo +stable test --locked --manifest-path desktop/core/Cargo.toml --target x86
cargo +stable check --locked --manifest-path desktop/src-tauri/Cargo.toml --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'
```
录制相关测试会调用 ffmpeg / ffprobe。主机测试、前端构建通过后,仍需按变更范围检查真实 WebView、板卡与网络行为。
录制相关测试会调用 ffmpeg / ffprobe。主机测试、前端构建通过后,仍需按变更范围检查真实 WebView、裁判系统与网络行为。
</details>
@@ -261,7 +264,7 @@ node scripts/version.mjs --check
## 文档与后续工作
文档沿用 Firefox 仓库的分类与索引思路:根目录保留项目入口和协作约定,详细说明集中到 [`docs/index.md`](docs/index.md)。
文档参照 Firefox 仓库的分类与索引方式组织。根目录保留项目入口和协作约定,详细说明从 [`docs/index.md`](docs/index.md) 查阅。
| 要做什么 | 从哪里开始 |
| :--- | :--- |
+3
View File
@@ -18,3 +18,6 @@ dist/
# 测试运行时被 XDG_CONFIG_HOME 相对路径写出来的配置,不是仓库内容。
src-tauri/.config/
# Installer hotspot guidance is local configuration.
/src-tauri/installer/network.local.nsh
+144
View File
@@ -0,0 +1,144 @@
"""Render the existing SVG brand sources with Tauri, then export PNG/ICO/ICNS/BMP.
Requires the installed desktop Node dependencies and Python's Pillow package.
Text is rasterized with Pillow so exports do not depend on the SVG renderer's
font discovery. No dependency is added to the application or its build hooks.
"""
import argparse
import copy
import io
import os
from pathlib import Path
import shutil
import struct
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from PIL import Image, ImageDraw, ImageFont
DESKTOP = Path(__file__).resolve().parents[1]
ICONS = DESKTOP / "src-tauri/icons"
INSTALLER = DESKTOP / "src-tauri/installer"
SVG = "{http://www.w3.org/2000/svg}"
ET.register_namespace("", SVG[1:-1])
def system_font(monospace=False, bold=False):
fonts = Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts"
candidates = (
[fonts / ("consolab.ttf" if bold else "consola.ttf"),
Path("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf")]
if monospace else
[fonts / ("msyhbd.ttc" if bold else "msyh.ttc"),
Path("/System/Library/Fonts/PingFang.ttc"),
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc")]
)
for path in candidates:
if path.exists():
return path
raise RuntimeError("Set --sans-font and --mono-font to installed fonts supporting the artwork's text.")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sans-font", type=Path)
parser.add_argument("--mono-font", type=Path)
args = parser.parse_args()
node = shutil.which("node")
cli = DESKTOP / "node_modules/@tauri-apps/cli/tauri.js"
if not node or not cli.exists():
raise RuntimeError("Install Node and run pnpm install --frozen-lockfile in desktop/ first.")
with tempfile.TemporaryDirectory(prefix="rmic-brand-") as temporary:
scratch = Path(temporary)
def render(root, size):
source = scratch / "source.svg"
ET.ElementTree(root).write(source, encoding="utf-8", xml_declaration=True)
subprocess.run([node, str(cli), "icon", str(source), "--output", str(scratch),
"--png", str(size)], cwd=DESKTOP, check=True, capture_output=True)
with Image.open(scratch / f"{size}x{size}.png") as image:
return image.convert("RGBA")
original = ET.parse(ICONS / "icon.svg").getroot()
images = {}
for size in (16, 20, 24, 32, 48, 64, 128, 256, 512, 1024):
root = copy.deepcopy(original)
scale = 0.97 if size <= 48 else 0.88 if size <= 128 else 0.82
group = root.find(f"{SVG}g")
group.set("transform", group.get("transform").replace("scale(0.82)", f"scale({scale})"))
images[size] = render(root, size)
for filename, size in (("32x32.png", 32), ("128x128.png", 128),
("128x128@2x.png", 256), ("icon.png", 512)):
images[size].save(ICONS / filename)
def png(size):
stream = io.BytesIO()
images[size].save(stream, format="PNG")
return stream.getvalue()
# Supply every ICO size separately, preserving optical scaling at 16–48px.
entries, payloads = [], []
sizes = (16, 20, 24, 32, 48, 64, 128, 256)
offset = 6 + 16 * len(sizes)
for size in sizes:
data = png(size)
entries.append(struct.pack("<BBBBHHII", size % 256, size % 256, 0, 0, 1, 32, len(data), offset))
payloads.append(data)
offset += len(data)
(ICONS / "icon.ico").write_bytes(struct.pack("<HHH", 0, 1, len(sizes)) + b"".join(entries + payloads))
chunks = []
for kind, size in ((b"icp4", 16), (b"icp5", 32), (b"icp6", 64), (b"ic07", 128),
(b"ic08", 256), (b"ic09", 512), (b"ic10", 1024),
(b"ic11", 32), (b"ic12", 64), (b"ic13", 256), (b"ic14", 512)):
data = png(size)
chunks.append(kind + struct.pack(">I", 8 + len(data)) + data)
body = b"".join(chunks)
(ICONS / "icon.icns").write_bytes(b"icns" + struct.pack(">I", 8 + len(body)) + body)
for name in ("header", "sidebar"):
root = ET.parse(INSTALLER / f"{name}.svg").getroot()
width, height = int(root.get("width")), int(root.get("height"))
text = []
for element in list(root):
if element.tag == f"{SVG}text":
text.append(element)
root.remove(element)
elif element.tag == f"{SVG}image":
# Installer art may reference the unframed original mark.
# Preserve its own viewBox instead of replacing every image
# with the square application icon.
source = (INSTALLER / element.get("href")).resolve()
logo = ET.parse(source).getroot()
for attr in ("x", "y", "width", "height"):
logo.set(attr, element.get(attr))
logo.set("preserveAspectRatio", "xMidYMid meet")
index = list(root).index(element)
root.remove(element)
root.insert(index, logo)
edge = max(width, height)
root.set("width", str(edge))
root.set("height", str(edge))
root.set("viewBox", f"0 0 {edge} {edge}")
factor = 4
image = render(root, edge * factor)
draw = ImageDraw.Draw(image)
for element in text:
mono = element.get("font-family") == "monospace"
bold = element.get("font-weight") == "700"
font_path = (args.mono_font if mono else args.sans_font) or system_font(mono, bold)
font = ImageFont.truetype(str(font_path), round(float(element.get("font-size")) * factor))
draw.text((float(element.get("x")) * factor, float(element.get("y")) * factor),
element.text, font=font, fill=element.get("fill"), anchor="ls")
image = image.crop((0, 0, width * factor, height * factor)).convert("RGB")
image.resize((width, height), Image.Resampling.LANCZOS).save(INSTALLER / f"{name}.png")
# MUI scales these to the dialog's font/DPI. Supply 2x bitmaps so
# Windows does not have to enlarge the raster text on modern screens.
image.resize((width * 2, height * 2), Image.Resampling.LANCZOS).save(INSTALLER / f"{name}.bmp")
print("Generated rounded PNG/ICO/ICNS icons and 24-bit NSIS header/sidebar artwork.")
if __name__ == "__main__":
main()
+137
View File
@@ -0,0 +1,137 @@
# Windows uses the executable's integrity label when creating its process. A Low
# label inherited from a sandboxed workspace prevents NSIS from writing to TEMP,
# before .onInit or any installer hook can run.
[CmdletBinding()]
param(
[string]$OutputDir,
[switch]$CheckProcessOnly,
[switch]$CheckOnly
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# whoami's group names are localized; the mandatory-label SID is stable.
$groups = (& whoami.exe /groups) -join "`n"
if ($LASTEXITCODE -ne 0 -or $groups -notmatch 'S-1-16-(\d+)') {
throw 'Cannot determine the Windows process integrity level.'
}
if ([int]$Matches[1] -lt 8192) {
throw 'Windows packaging requires Medium integrity or higher. Start the terminal / CI runner outside the Low integrity sandbox. Redirecting TEMP or changing directory write permissions does not fix executable integrity labels.'
}
if ($CheckProcessOnly) { return }
if (-not ('RmicBundleIntegrity' -as [type])) {
$compilerOptions = @{}
if ($PSVersionTable.PSEdition -eq 'Desktop') {
# Framework CodeDOM otherwise tries to label its private temp directory
# High when an admin token has been capped at Medium by an exe label.
$parameters = New-Object System.CodeDom.Compiler.CompilerParameters
$parameters.ReferencedAssemblies.Add('System.dll') | Out-Null
$parameters.TempFiles = New-Object System.CodeDom.Compiler.TempFileCollection([IO.Path]::GetTempPath(), $false)
$compilerOptions.CompilerParameters = $parameters
}
Add-Type @compilerOptions -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class RmicBundleIntegrity {
[DllImport("advapi32.dll", CharSet = CharSet.Unicode)]
private static extern uint GetNamedSecurityInfoW(string name, int type, uint info,
out IntPtr owner, out IntPtr group, out IntPtr dacl, out IntPtr sacl, out IntPtr sd);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool ConvertSecurityDescriptorToStringSecurityDescriptorW(
IntPtr sd, uint revision, uint info, out IntPtr text, out uint length);
[DllImport("kernel32.dll")]
private static extern IntPtr LocalFree(IntPtr memory);
public static string ReadLabel(string path) {
// LABEL_SECURITY_INFORMATION reads only the label, without requiring the
// SeSecurityPrivilege needed to read an entire SACL (Get-Acl -Audit).
IntPtr owner, group, dacl, sacl, sd, text = IntPtr.Zero;
uint error = GetNamedSecurityInfoW(path, 1, 0x10, out owner, out group,
out dacl, out sacl, out sd);
if (error != 0) throw new Win32Exception((int)error, path);
try {
uint length;
if (!ConvertSecurityDescriptorToStringSecurityDescriptorW(sd, 1, 0x10,
out text, out length)) throw new Win32Exception(Marshal.GetLastWin32Error());
return Marshal.PtrToStringUni(text);
} finally {
if (text != IntPtr.Zero) LocalFree(text);
LocalFree(sd);
}
}
}
'@
}
function Assert-MediumIntegrity([string]$Path) {
$label = [RmicBundleIntegrity]::ReadLabel($Path)
# An absent label is Medium. Windows may render known SIDs as SDDL aliases.
if ($label -match ';(LW|UN|S-1-16-(\d+))\)') {
if ($Matches[1] -in @('LW', 'UN') -or [int]$Matches[2] -lt 8192) {
throw "Low / Untrusted integrity label on $Path. Run prepare-windows-bundle.ps1 from a normal Windows terminal before distributing this artifact."
}
}
}
$hostOutputDir = $null
if (-not $OutputDir) {
# Ask Cargo so CARGO_TARGET_DIR and Cargo's target-dir setting are respected.
$manifest = Join-Path $PSScriptRoot '../src-tauri/Cargo.toml'
$metadata = & cargo +stable metadata --no-deps --format-version 1 --manifest-path $manifest
if ($LASTEXITCODE -ne 0) { throw 'Cannot resolve the Cargo output directory.' }
$target = $env:TAURI_ENV_TARGET_TRIPLE
if (-not $target) { $target = 'x86_64-pc-windows-msvc' }
$profile = if ($env:TAURI_ENV_DEBUG -eq 'true') { 'debug' } else { 'release' }
$targetDir = ($metadata | ConvertFrom-Json).target_directory
$OutputDir = Join-Path $targetDir "$target/$profile"
# Cross-target Cargo builds also run host build scripts from target/<profile>.
# Old Low executables there can fail before the bundling hook is reached.
$hostOutputDir = Join-Path $targetDir $profile
}
$OutputDir = [IO.Path]::GetFullPath($OutputDir)
# Limit changes to build outputs. Do not relabel the workspace, TEMP, user data,
# or DACLs, and do not request elevation for the installer.
$directories = @(
$OutputDir
(Join-Path $OutputDir 'bundle')
(Join-Path $OutputDir 'bundle/nsis')
(Join-Path $OutputDir 'nsis')
)
if ($hostOutputDir) { $directories = @($hostOutputDir) + $directories }
if (Test-Path -LiteralPath (Join-Path $OutputDir 'nsis')) {
$directories += @(Get-ChildItem -LiteralPath (Join-Path $OutputDir 'nsis') -Directory -Recurse |
Select-Object -ExpandProperty FullName)
}
foreach ($directory in $directories) {
if (-not $CheckOnly) {
New-Item -ItemType Directory -Force -Path $directory | Out-Null
& icacls.exe $directory /setintegritylevel '(OI)(CI)M' | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Cannot set Medium integrity on $directory" }
}
Assert-MediumIntegrity $directory
}
$executables = @(Get-ChildItem -LiteralPath $OutputDir -Filter '*.exe' -File)
foreach ($directory in @('nsis', 'bundle/nsis')) {
$executables += @(Get-ChildItem -LiteralPath (Join-Path $OutputDir $directory) -Filter '*.exe' -File -Recurse)
}
if ($hostOutputDir -and (Test-Path -LiteralPath (Join-Path $hostOutputDir 'build'))) {
$executables += @(Get-ChildItem -LiteralPath (Join-Path $hostOutputDir 'build') -Filter '*.exe' -File -Recurse)
}
foreach ($executable in $executables) {
if (-not $CheckOnly) {
# Also replace explicit Low labels left by an earlier Low integrity build.
& icacls.exe $executable.FullName /setintegritylevel M | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Cannot set Medium integrity on $($executable.FullName)" }
}
Assert-MediumIntegrity $executable.FullName
}
if ($CheckOnly -and -not (Get-ChildItem -LiteralPath (Join-Path $OutputDir 'bundle/nsis') -Filter '*.exe' -File)) {
throw "No NSIS installer found in $OutputDir/bundle/nsis."
}
Write-Host "Windows bundle integrity verified: $OutputDir ($($executables.Count) executables)."
+158
View File
@@ -0,0 +1,158 @@
# Integration regression: actual Windows labels, child process tokens, and an
# NSIS bootstrap that writes to TEMP and exits without installing anything.
[CmdletBinding()]
param(
[string]$NsisCompiler = (Join-Path $env:LOCALAPPDATA 'tauri/NSIS/makensis.exe')
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$prepare = Join-Path $PSScriptRoot 'prepare-windows-bundle.ps1'
& $prepare -CheckProcessOnly
if (-not (Test-Path -LiteralPath $NsisCompiler)) { throw "NSIS compiler not found: $NsisCompiler" }
function Assert([bool]$Condition, [string]$Message) {
if (-not $Condition) { throw $Message }
}
$testId = [Guid]::NewGuid().ToString('N')
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$fixture = Join-Path $tempRoot "rmic bundle test $testId"
$probeFile = Join-Path $tempRoot "rmic-nsis-temp-$testId.txt"
$installer = Join-Path $fixture 'bundle/nsis/probe-setup.exe'
$main = Join-Path $fixture 'cam-desktop.exe'
try {
New-Item -ItemType Directory -Force -Path (Join-Path $fixture 'bundle/nsis'), (Join-Path $fixture 'nsis/x64') | Out-Null
Copy-Item -LiteralPath (Join-Path $env:WINDIR 'System32/whoami.exe') -Destination $main
$accessBefore = (Get-Acl -LiteralPath $main).Sddl
# A small real NSIS executable exercises its early TEMP validation and
# plugin extraction without touching app files, registry, or shortcuts.
$source = Join-Path $fixture 'probe.nsi'
@"
Unicode true
Name "RMIC temporary directory probe"
OutFile "$($installer.Replace('$', '$$'))"
RequestExecutionLevel user
SilentInstall silent
Section
InitPluginsDir
FileOpen `$0 "`$TEMP\rmic-nsis-temp-$testId.txt" w
IfErrors failed
FileWrite `$0 "NSIS TEMP writable"
FileClose `$0
SetErrorLevel 0
Quit
failed:
SetErrorLevel 3
SectionEnd
"@ | Set-Content -LiteralPath $source -Encoding UTF8
& $NsisCompiler /V2 $source
Assert ($LASTEXITCODE -eq 0) 'Cannot compile the NSIS TEMP probe.'
& icacls.exe $fixture /setintegritylevel '(OI)(CI)L' | Out-Null
Assert ($LASTEXITCODE -eq 0) 'Cannot create a Low integrity fixture.'
& icacls.exe $main /setintegritylevel L | Out-Null
Assert ($LASTEXITCODE -eq 0) 'Cannot create an explicit Low executable label.'
$before = (& $main /groups) -join "`n"
Assert ($LASTEXITCODE -eq 0 -and $before -match 'S-1-16-4096') 'Low executable did not start with a Low token.'
$rejected = $false
try { & $prepare -OutputDir $fixture -CheckOnly } catch {
if ($_.Exception.Message -notmatch 'Low / Untrusted') { throw }
$rejected = $true
}
Assert $rejected 'Read-only artifact verification accepted a Low label.'
Assert ([RmicBundleIntegrity]::ReadLabel($installer) -match ';LW\)') 'Read-only verification changed the installer.'
# A Low process must fail before it tries to modify any output labels.
$lowShell = Join-Path $fixture 'low-shell.exe'
Copy-Item -LiteralPath (Join-Path $env:WINDIR 'System32/WindowsPowerShell/v1.0/powershell.exe') -Destination $lowShell
# Windows PowerShell 5 treats redirected native stderr as ErrorRecord objects.
# This command is expected to fail, so collect its diagnostic before asserting.
try {
$ErrorActionPreference = 'Continue'
$lowOutput = (& $lowShell -NoProfile -ExecutionPolicy Bypass -File $prepare -CheckProcessOnly 2>&1) -join "`n"
} finally {
$ErrorActionPreference = 'Stop'
}
Assert ($LASTEXITCODE -ne 0 -and $lowOutput -match 'requires Medium integrity') 'Low packaging process was not rejected.'
& $prepare -OutputDir $fixture
& $prepare -OutputDir $fixture -CheckOnly
& $prepare -OutputDir $fixture
$after = (& $main /groups) -join "`n"
Assert ($LASTEXITCODE -eq 0 -and $after -match 'S-1-16-(\d+)' -and [int]$Matches[1] -ge 8192) 'Repaired executable still starts below Medium integrity.'
Assert ((Get-Acl -LiteralPath $main).Sddl -eq $accessBefore) 'Repair changed the executable DACL.'
# The repaired shell now starts at Medium even from an elevated test runner.
# Verify that repairing owned Low outputs does not require elevation.
$mediumFixture = Join-Path $fixture 'medium repair'
New-Item -ItemType Directory -Path $mediumFixture | Out-Null
& icacls.exe $mediumFixture /setintegritylevel '(OI)(CI)L' | Out-Null
Assert ($LASTEXITCODE -eq 0) 'Cannot prepare the Medium-process repair fixture.'
$mediumOutput = (& $lowShell -NoProfile -ExecutionPolicy Bypass -File $prepare -OutputDir $mediumFixture 2>&1) -join "`n"
Assert ($LASTEXITCODE -eq 0) "Repair failed at Medium integrity: $mediumOutput"
Assert ([RmicBundleIntegrity]::ReadLabel($mediumFixture) -match ';ME\)') 'Medium process did not repair the output label.'
$probe = Start-Process -FilePath $installer -ArgumentList '/S' -PassThru -WindowStyle Hidden
try {
Assert ($probe.WaitForExit(15000)) 'NSIS TEMP probe timed out.'
Assert ($probe.ExitCode -eq 0) "NSIS TEMP probe failed: $($probe.ExitCode)"
Assert ((Get-Content -LiteralPath $probeFile -Raw) -eq 'NSIS TEMP writable') 'NSIS could not write to the normal TEMP directory.'
} finally {
if (-not $probe.HasExited) { $probe.Kill(); $probe.WaitForExit() }
$probe.Dispose()
}
$emptyOutput = Join-Path $fixture 'empty'
& $prepare -OutputDir $emptyOutput
$rejected = $false
try { & $prepare -OutputDir $emptyOutput -CheckOnly } catch {
if ($_.Exception.Message -notmatch 'No NSIS installer') { throw }
$rejected = $true
}
Assert $rejected 'Artifact verification accepted missing installers.'
# Exercise automatic Cargo output discovery, debug target selection and the
# separate host build-script directory, without invoking a real Rust build.
$savedEnvironment = @{}
foreach ($name in @('CARGO_TARGET_DIR', 'TAURI_ENV_TARGET_TRIPLE', 'TAURI_ENV_DEBUG')) {
$savedEnvironment[$name] = @{
Exists = Test-Path -LiteralPath "Env:$name"
Value = [Environment]::GetEnvironmentVariable($name, 'Process')
}
}
try {
$env:CARGO_TARGET_DIR = Join-Path $fixture 'cargo cache'
$env:TAURI_ENV_TARGET_TRIPLE = 'x86_64-pc-windows-msvc'
$env:TAURI_ENV_DEBUG = 'true'
$hostBuildDir = Join-Path $env:CARGO_TARGET_DIR 'debug/build/probe'
New-Item -ItemType Directory -Force -Path $hostBuildDir | Out-Null
$hostScript = Join-Path $hostBuildDir 'build-script-build.exe'
Copy-Item -LiteralPath $main -Destination $hostScript
& icacls.exe $env:CARGO_TARGET_DIR /setintegritylevel '(OI)(CI)L' | Out-Null
Assert ($LASTEXITCODE -eq 0) 'Cannot create Low host build outputs.'
& $prepare
Assert ([RmicBundleIntegrity]::ReadLabel($hostScript) -match ';ME\)') 'Host build script was not repaired.'
$targetOutput = Join-Path $env:CARGO_TARGET_DIR 'x86_64-pc-windows-msvc/debug/bundle/nsis'
Assert (Test-Path -LiteralPath $targetOutput) 'Cargo target directory or debug profile was ignored.'
} finally {
foreach ($name in $savedEnvironment.Keys) {
if ($savedEnvironment[$name].Exists) {
Set-Item -LiteralPath "Env:$name" -Value $savedEnvironment[$name].Value
} else {
Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue
}
}
}
Write-Host 'PASS: Low token reproduction, process guard, read-only rejection, label repair, unchanged DACL, repeat repair, NSIS TEMP bootstrap, missing artifacts, custom Cargo output and host build scripts.'
} finally {
# Only delete this test's uniquely named fixture, after checking its absolute path.
$resolvedFixture = [IO.Path]::GetFullPath($fixture)
if ($resolvedFixture.StartsWith($tempRoot.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase) -and
(Split-Path -Leaf $resolvedFixture) -eq "rmic bundle test $testId") {
if (Test-Path -LiteralPath $resolvedFixture) { Remove-Item -LiteralPath $resolvedFixture -Recurse -Force }
}
if (Test-Path -LiteralPath $probeFile) { Remove-Item -LiteralPath $probeFile -Force }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+3 -3
View File
@@ -1,9 +1,9 @@
<!-- RMIC::2026 应用图标(矢量源)。
由 https://simba-robotics.com/logo.svg 生成:把原图 1024 画布的 viewBox 收窄、标志居中,
底色用界面的 --deep。位图(PNG / ICO / ICNS)由这一份光栅化得到;
底色沿用界面的深色背景,圆角之外透明。位图(PNG / ICO / ICNS)由这一份光栅化得到;
小尺寸另有一版更大的缩放比(见 docs/desktop/client.md 的「队标」一节)。 -->
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93333" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="270.93333" height="270.93333" fill="#080b12"/>
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93333" xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
<rect x="0" y="0" width="270.93333" height="270.93333" rx="56.44" fill="#080b12"/>
<g transform="translate(135.4667 135.4667) scale(0.82) translate(-135.28 -136.22)">
<g
inkscape:label="图层 1"

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,32 @@
; Tauri-specific messages. Standard buttons, directory/install pages and
; uninstaller controls use NSIS's bundled SimpChinese language.
; This file is included after MUI_LANGUAGE, so it can override the NLF's font.
SetFont /LANG=${LANG_SIMPCHINESE} "Microsoft YaHei UI" 9
LangString addOrReinstall ${LANG_SIMPCHINESE} "重新安装或修复程序"
LangString alreadyInstalled ${LANG_SIMPCHINESE} "已安装此版本"
LangString alreadyInstalledLong ${LANG_SIMPCHINESE} "此电脑已安装客户端版本 ${VERSION}。请选择重新安装或卸载,然后点击“下一步”。"
LangString appRunning ${LANG_SIMPCHINESE} "RMIC::2026 正在运行。请先关闭软件,再重试。"
LangString appRunningOkKill ${LANG_SIMPCHINESE} "RMIC::2026 正在运行。$\n点击“确定”将关闭程序。"
LangString chooseMaintenanceOption ${LANG_SIMPCHINESE} "选择你所需的安装维护操作。"
LangString choowHowToInstall ${LANG_SIMPCHINESE} "请选择本软件安装范围。"
LangString createDesktop ${LANG_SIMPCHINESE} "创建桌面快捷方式"
LangString dontUninstall ${LANG_SIMPCHINESE} "保留现有版本并继续"
LangString dontUninstallDowngrade ${LANG_SIMPCHINESE} "保留现有版本"
LangString failedToKillApp ${LANG_SIMPCHINESE} "无法结束 RMIC::2026。请手动关闭软件后重试。"
LangString installingWebview2 ${LANG_SIMPCHINESE} "正在安装 WebView2 运行时……"
LangString newerVersionInstalled ${LANG_SIMPCHINESE} "此电脑已安装较新的 RMIC::2026。继续安装将降级,建议先卸载当前版本。请选择操作,然后点击“下一步”。"
LangString older ${LANG_SIMPCHINESE} "较旧"
LangString olderOrUnknownVersionInstalled ${LANG_SIMPCHINESE} "此电脑已安装 RMIC::2026,版本:$R4。建议先卸载现有程序,再安装新版本。请选择操作,然后点击“下一步”。"
LangString silentDowngrades ${LANG_SIMPCHINESE} "本安装包不允许静默降级。请打开安装界面选择维护操作。$\n"
LangString unableToUninstall ${LANG_SIMPCHINESE} "无法卸载现有程序。请关闭正在运行的 RMIC::2026 后重试。"
LangString uninstallApp ${LANG_SIMPCHINESE} "卸载 RMIC::2026"
LangString uninstallBeforeInstalling ${LANG_SIMPCHINESE} "先卸载现有程序,再继续安装"
LangString unknown ${LANG_SIMPCHINESE} "未知"
LangString webview2AbortError ${LANG_SIMPCHINESE} "WebView2 安装未完成,RMIC::2026 无法启动。请检查网络连接后重新运行安装程序。"
LangString webview2DownloadError ${LANG_SIMPCHINESE} "WebView2 下载失败。错误信息:$0"
LangString webview2DownloadSuccess ${LANG_SIMPCHINESE} "WebView2 安装引导程序已下载。"
LangString webview2Downloading ${LANG_SIMPCHINESE} "正在下载 WebView2 安装引导程序……"
LangString webview2InstallError ${LANG_SIMPCHINESE} "WebView2 安装失败,退出代码:$1"
LangString webview2InstallSuccess ${LANG_SIMPCHINESE} "WebView2 运行时已安装。"
LangString deleteAppData ${LANG_SIMPCHINESE} "同时删除应用数据"
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="132" height="57" viewBox="0 0 132 57">
<rect width="132" height="57" fill="#ffffff"/>
<image href="../icons/icon.svg" x="8" y="7" width="43" height="43"/>
<text x="61" y="26" font-family="monospace" font-size="15" font-weight="700" fill="#35475f">RMIC</text>
<text x="60" y="44" font-family="monospace" font-size="17" font-weight="700" fill="#e8232b">::2026</text>
</svg>

After

Width:  |  Height:  |  Size: 427 B

+47
View File
@@ -0,0 +1,47 @@
; Included by Tauri before its Modern UI pages. Keep the upstream installer
; template, upgrade handling and current-user installation behavior.
!ifndef RMIC_INSTALLER_THEME
!define RMIC_INSTALLER_THEME
; CJK dialog units have a different width/height ratio from the bitmap.
!define MUI_WELCOMEFINISHPAGE_BITMAP_STRETCH AspectFitHeight
; Header art is narrower than the standard 150px canvas so height-based
; scaling stays within the image area of the 9pt Chinese dialog.
!define MUI_HEADERIMAGE_BITMAP_STRETCH AspectFitHeight
!define MUI_WELCOMEPAGE_TITLE "欢迎安装$\r$\n裁判系统客户端"
!define MUI_WELCOMEPAGE_TEXT "本程序运行内存需求约为 400MB。$\r$\n$\r$\n点击“下一步”选择安装位置。"
!define MUI_DIRECTORYPAGE_TEXT_TOP "软件仅为当前 Windows 用户安装。请选择有写入权限的文件夹。"
!define MUI_FINISHPAGE_TITLE "安装完成"
; MUI reserves 60 dialog units above the two checkboxes: keep six short lines.
; Optional local guidance is excluded from version control.
!if /FileExists "${__FILEDIR__}/network.local.nsh"
!include "${__FILEDIR__}/network.local.nsh"
!endif
!ifdef RMIC_INSTALLER_WIFI_SSID & RMIC_INSTALLER_WIFI_PASS
!define MUI_FINISHPAGE_TEXT "裁判系统客户端已安装完成。$\r$\n首次启动请选择模式并绑定裁判系统。$\r$\n裁判系统会自动连接以下热点;请将$\r$\n本机也连接到同一网络。$\r$\n名称(SSID):${RMIC_INSTALLER_WIFI_SSID}$\r$\n密码:${RMIC_INSTALLER_WIFI_PASS}"
!else
!define MUI_FINISHPAGE_TEXT "裁判系统客户端已安装完成。$\r$\n首次启动请选择模式并绑定裁判系统。$\r$\n请让本机与裁判系统连接到同一网络。$\r$\n网络名称和密码请向现场工作人员确认。"
!endif
!define MUI_FINISHPAGE_TEXT_LARGE
!define MUI_FINISHPAGE_RUN_TEXT "启动裁判系统客户端"
!define MUI_INSTFILESPAGE_COLORS "E7ECF5 080B13"
!macro NSIS_HOOK_PREINSTALL
DetailPrint "正在安装 RMIC::2026 ${VERSION}。"
DetailPrint "安装位置:$INSTDIR"
!macroend
!macro NSIS_HOOK_POSTINSTALL
DetailPrint "程序文件与快捷方式已创建。请点击“下一步”完成安装。"
!macroend
!macro NSIS_HOOK_PREUNINSTALL
DetailPrint "正在移除 RMIC::2026 的程序文件与快捷方式。"
!macroend
!macro NSIS_HOOK_POSTUNINSTALL
DetailPrint "RMIC::2026 卸载步骤已完成。"
!macroend
!endif
+8
View File
@@ -0,0 +1,8 @@
<?xml version='1.0' encoding='utf-8'?>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 270.93333 270.93333">
<g id="layer1">
<path d="m 120.18387,65.065937 -8.00358,13.862771 h 27.77765 l 0.0541,0.09409 30.76276,53.283672 -35.41234,61.33599 -0.008,0.0143 -7.99546,13.84855 -0.008,0.0143 h 16.00718 44.01701 l -5.80572,10.05592 h -66.94744 l -0.007,0.0105 h -6.48395 l 5.81384,-10.07013 8.00358,-13.86278 28.83631,-49.94524 8.0036,-13.86276 H 142.78056 114.57375 107.01421 83.392663 67.385498 l -8.004262,13.86276 -42.562363,73.72043 -8.0035837,13.86278 H 24.822458 84.211033 l -0.0914,0.15771 h 16.007157 11.36098 l 0.007,-0.0105 h 62.07111 16.00784 l 0.0105,-0.0175 h 41.16526 15.05748 16.00718 l -8.00358,-13.86278 -5.81181,-10.06608 -8.00358,-13.86276 h -16.00718 -12.5848 -16.00784 l -0.0156,0.0278 h -1.6347 -0.80551 -12.33705 l 27.39928,-47.45699 8.00358,-13.86276 -5.11392,-8.85783 -11.76441,-20.37651 h 31.64205 l 5.06519,-8.773893 2.9384,-5.088885 h -15.13465 -48.55152 l 8.00358,13.862778 h 0.0297 l 16.87765,29.23434 -35.40286,61.31975 h -13.23258 l 27.41688,-47.48812 8.00358,-13.8621 -8.00358,-13.86276 -22.75918,-39.420912 h 60.26447 l -8.00332,-13.862894 h -32.42792 v -0.09409 z M 75.389113,143.70806 h 23.62222 7.558857 28.2075 l -28.83633,49.94523 -8.003587,13.86278 -5.11257,8.85512 -5.1119,-8.85512 -8.00427,-13.86278 H 63.701872 46.553462 l 8.574205,-14.85103 8.003585,-13.86277 z" style="fill:#ffffff;stroke-width:0.904925;paint-order:stroke fill markers" id="path24" />
<path d="m 134.72691,41.313757 -8.00358,13.862771 h 62.89352 16.00718 31.56419 l -8.00358,-13.862771 h -31.5642 -16.00717 z" style="fill:#ffffff;stroke-width:1.17159;paint-order:stroke fill markers" id="path26" />
<path d="M 243.26673,60.098895 A 11.644196,11.644196 0 0 0 231.62281,71.742811 11.644196,11.644196 0 0 0 243.26673,83.386727 11.644196,11.644196 0 0 0 254.91131,71.742811 11.644196,11.644196 0 0 0 243.26673,60.098895 Z" style="fill:#ff0000;stroke-width:0.513476;paint-order:stroke fill markers" id="path25" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,4 @@
; Copy to network.local.nsh and fill in the event's hotspot information.
; network.local.nsh is ignored by Git and must not be committed.
!define RMIC_INSTALLER_WIFI_SSID "现场热点名称"
!define RMIC_INSTALLER_WIFI_PASS "现场热点密码"
Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="164" height="338" viewBox="0 0 164 338">
<rect width="164" height="338" fill="#080b13"/>
<path d="M0 126H164M0 150H164M0 174H164M0 198H164M0 222H164M22 110V246M46 110V246M70 110V246M94 110V246M118 110V246M142 110V246" stroke="#141d2c" fill="none"/>
<text x="18" y="27" font-family="monospace" font-size="9" fill="#94a1b8">SIMBA ROBOTICS</text>
<text x="17" y="62" font-family="monospace" font-size="29" font-weight="700" fill="#e7ecf5">RMIC</text>
<text x="17" y="92" font-family="monospace" font-size="26" font-weight="700" fill="#e8232b">::</text>
<text x="48" y="92" font-family="monospace" font-size="26" font-weight="700" fill="#e7ecf5">2026</text>
<path d="M18 103H146" stroke="#26344a"/>
<path d="M18 103H48" stroke="#e8232b"/>
<image href="logo.svg" x="18" y="117" width="128" height="128" preserveAspectRatio="xMidYMid meet"/>
<text x="18" y="290" font-family="sans-serif" font-size="13" font-weight="700" fill="#e7ecf5">裁判系统客户端</text>
<text x="18" y="314" font-family="sans-serif" font-size="9" fill="#94a1b8">仅供第九届机甲大师校内赛使用</text>
<text x="18" y="330" font-family="sans-serif" font-size="7.5" fill="#616e86">黑龙江大学 Simba 战队</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+2 -2
View File
@@ -40,8 +40,8 @@
"active": true,
"targets": "all",
"category": "Utility",
"shortDescription": "RMIC::2026 —— 视野链路终端",
"longDescription": "RMIC::2026 视野链路终端。用户端:一路画面 + 装甲条与瞄准器。裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。",
"shortDescription": "RMIC::2026 裁判系统客户端",
"longDescription": "RMIC::2026 裁判系统客户端。用户端:一路画面 + 装甲条与瞄准器。裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://schema.tauri.app/config/2",
"build": {
"beforeBuildCommand": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/prepare-windows-bundle.ps1 && pnpm build",
"beforeBundleCommand": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/prepare-windows-bundle.ps1"
},
"bundle": {
"windows": {
"nsis": {
"installMode": "currentUser",
"languages": ["SimpChinese"],
"displayLanguageSelector": false,
"customLanguageFiles": {
"SimpChinese": "installer/SimpChinese.nsh"
},
"installerHooks": "installer/hooks.nsh",
"installerIcon": "icons/icon.ico",
"uninstallerIcon": "icons/icon.ico",
"headerImage": "installer/header.bmp",
"sidebarImage": "installer/sidebar.bmp",
"uninstallerHeaderImage": "installer/header.bmp"
}
}
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ export function Boot({ milestones, progress, fatal, leaving = false }: Props) {
<span className="boot__name">
RMIC<span className="boot__slash">::</span>2026
</span>
<span className="boot__sub label">视野链路终端 · SIMBA ROBOTICS</span>
<span className="boot__sub label">裁判系统客户端 · SIMBA ROBOTICS</span>
</div>
</header>
+1 -1
View File
@@ -350,7 +350,7 @@ export function SettingsPanel({
{cameraOptions.length === 0 && (
<p className="sect__warn">
尚未发现图传板。板卡每 2 秒广播一次;请检查供电,确认与本机处于
尚未发现图传板。裁判系统每 2 秒广播一次;请检查供电,确认与本机处于
同一网络后稍候。
</p>
)}
+1 -1
View File
@@ -63,7 +63,7 @@ export function TopBar({
<button
type="button"
className="tag tag--warn tag--button"
title="画面与血量由本机合成,不代表现场板卡状态。"
title="画面与血量由本机合成,不代表现场裁判系统状态。"
onClick={() =>
pushNote(
"演示模式:画面与血量由本机合成。截图和验证时请注明为演示数据。",
+2 -2
View File
@@ -11,11 +11,11 @@ export const BUILD = {
minor: 2,
patch: 2,
/** git 提交数,单调递增。 */
build: 38,
build: 39,
/** 线路协议版本。与固件自报的那个数直接比对。 */
protocol: 1,
/** 生成时的提交日期(`YYYY-MM-DD`)。 */
date: "2026-10-19",
date: "2026-10-21",
} as const;
/** `"0.2.0+57"` —— 界面上放得下的那一行。 */
+2 -2
View File
@@ -119,7 +119,7 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
camera={camera}
label={`CAM · ${cameraText}`}
emptyText={
stats?.hasFrames ? "" : stats?.online ? "已连接,等待画面…" : "离线:尚未收到板卡广播"
stats?.hasFrames ? "" : stats?.online ? "已连接,等待画面…" : "离线:尚未收到裁判系统广播"
}
mirrored={snapshot.mirrored}
crosshair={hud.crosshair}
@@ -179,7 +179,7 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
<div className="fwfault" role="alert">
<span className="fwfault__tag label">固件不匹配</span>
<span className="fwfault__text">
板卡固件版本为 <b>{firmwareLabel(boundCamera)}</b>、线路协议{" "}
裁判系统固件版本为 <b>{firmwareLabel(boundCamera)}</b>、线路协议{" "}
<b>{boundCamera.protocol}</b>;本客户端使用协议 <b>{BUILD.protocol}</b>。
画面无法接收。请烧录协议匹配的固件,或使用对应版本的客户端。
</span>
+19 -19
View File
@@ -29,7 +29,7 @@
| `immersive` | F11 | chrome 隐藏;指针移到上沿 8px 时 TopBar 150ms 淡入,3 s 后淡出;指针静止 2 s 隐藏光标 |
| `broadcast` | 视图切换 `Ctrl+Shift+B` | 只渲染 `BroadcastStage`;禁用设置/维护/发现列表;不渲染任何 IP/token |
结构落点:`App.tsx` 里现有的门禁分支(致命错误 / 等待快照 / 身份选择 / 设密码)不动,只在最后一段把 `TeamPage | RefereePage` 包进 `AppShell`,并新增 `BroadcastPage` 分支。
在 `App.tsx` 中保留现有门禁分支(致命错误 / 等待快照 / 身份选择 / 设密码),只在最后一段将 `TeamPage | RefereePage` 放入 `AppShell`,并新增 `BroadcastPage` 分支。
### 用户端 `TeamPage`(操作手视角)
@@ -60,7 +60,7 @@
### 裁判端 `RefereePage`
三区 + 一条比赛条:
裁判端分为三区,另设一条比赛条:
```
┌ TopBar ────────────────────────────────────────────────────────────────┐
@@ -84,7 +84,7 @@
2. `referee-stage` 保持 `flex: 1`,新增的 `MatchBar` 占固定高度;下方控制台采用可拖拽高度(提议默认 240px,最小 120,最大 45vh);加一行比赛条后,900px 高的窗口约剩 560px 用于画面。
3. `ArmorTable` 改为两队并列(左红右蓝),每行左侧加 5 格迷你血条(与 HUD 同源,便于核对);主装甲板加 `主` 徽标;离线行 `opacity .55`(保留);`reportsAllPlates=false` 时显示"仅报 2 块"——规则允许 2 或 4 块装甲板。
4. 新增 `EventPanel`(Dock 第 3 个页签):命中/暂停/判负/录制开始/结束的时间轴列表,可导出。
5. `MediaPanel` 保留全部功能,新增两个东西:按局分组(`matchId` 目录)与 "回放本局"(双路同步 + 事件定位)。
5. `MediaPanel` 保留全部功能,新增按局分组(`matchId` 目录)与“回放本局”(双路同步 + 事件定位)两项功能。
6. 队名编辑保留(现在是内联输入),但改成"点击铅笔 → 行内编辑 → Enter 保存 / Esc 取消",避免一直占一行高。
### 直播 `BroadcastPage`(新)
@@ -110,15 +110,15 @@ interface AppStore {
}
```
事件传输:比赛事件走独立推送,与现有的 `hit` 同一条路(`server::WireMsg` 新增 `MatchEvent`),前端累积;`Snapshot.match` 只带最近 12 条用于重连后补齐。理由:快照 5 Hz × 200 条事件会让每份快照增加到几十 KB,事件单独推送可避免重复传输。
比赛事件采用独立推送,与现有 `hit` 使用相同的传输方式:在 `server::WireMsg` 中新增 `MatchEvent`,由前端累积。`Snapshot.match` 仅携带最近 12 条,供重连后补齐。若按快照 5 Hz × 200 条事件传输,每份快照会增加到几十 KB;单独推送可避免重复传输。
计时以后端为准:`Snapshot` 增加 `nowMs: u64`(后端 Unix 毫秒),前端用 `useServerClock` 每帧算 `serverNow = localNow + clockSkewMs`,再用 `match.remainingMs - (serverNow - snapshotAt)` 插值。`setInterval` 在窗口失焦时会被节流,不能作为比赛计时依据。
计时以后端为准。在 `Snapshot` 中新增 `nowMs: u64`,表示后端 Unix 毫秒;前端通过 `useServerClock` 每帧计算 `serverNow = localNow + clockSkewMs`,再用 `match.remainingMs - (serverNow - snapshotAt)` 插值。`setInterval` 在窗口失焦时会被节流,不能作为比赛计时依据。
### 一键判负
入口:`MatchBar` 右侧 `⚑ 判负`,或 `Ctrl/Cmd+Shift+F`。只在裁判端、`phase ∈ {running, paused}` 时可用。
三步,不可合并
以下三步不可合并:
```
① 点按 → ForfeitDialog
@@ -144,7 +144,7 @@ match_end { reason: Forfeit { loser: 0|1, kind: ForfeitKind, note: Option<String
- 状态 → `Ended { by: Referee, reason, at, actor }`,比分按规则 +1 给胜方。
- 用户端若已同步(见 R2):显示 `JudgedOverlay`「本局判负」;未同步则只影响裁判端与直播。
- 不改硬件血量:`ArmorHealthStore` 只接收上报,没有任何写入口(见 R1)。界面必须显式写明这一点,否则现场会以为"判负之后对方血量归零了"。
- 不改硬件血量:`ArmorHealthStore` 只接收上报,没有写入口(见 R1)。界面必须写明这一点,避免现场误以为判负会使对方硬件血量归零。
- 审计:事件 `{ at, actor, action:"forfeit", loser, reason, note, hp:[76,100], remainingMs:161000 }`,追加到 `matches/<yyyy-mm-dd>/<matchId>/events.ndjson`,同时进 `snapshot.match.events`。
- 撤销:`match_undo { eventId }`,5 s 内可直接撤销;之后只能 `match_reopen_round { reason }`,在历史里留下"更正"两条记录(不删除原记录)。
@@ -177,7 +177,7 @@ end: { by: "auto" | "referee"; reason: "knockout" | "timeout" | "forfeit" | "equ
### 计时 / 比分
两个不同的 3 分钟,界面必须分开写
赛前准备与单局比赛各为 3 分钟,界面必须分别标注:
| 阶段 | 时长 | 文案 |
| --- | --- | --- |
@@ -253,7 +253,7 @@ impl MatchClock {
### 基准画布与安全区
基准 1920×1080(16:9)。相机是 640×480(4:3),所以直播层必须自己决定是加信箱还是裁切,不能指望画面填满。
基准画布为 1920×1080(16:9),相机画面为 640×480(4:3)。两者比例不同,直播层需明确选择留边或裁切,不能默认画面会填满画布。
| 区域 | 距边距 | 1080p 像素 | 内容 |
| --- | --- | --- | --- |
@@ -328,7 +328,7 @@ z6 GuideLayer 安全区参考线(仅预览,输出时 display:none
- `BroadcastStage` 用 1920×1080 的绝对坐标系 + `transform: scale(min(vw/1920, vh/1080))`,`transform-origin: top left`。这样所有设计稿坐标直接写进 CSS,无需换算为百分比。
- 参考线由 `GuideLayer` 画(`outline: 1px dashed`),受 `props.guides` 控制;全屏/输出时自动关闭。
- 视频源仍用 `VideoSurface`(`ViewTransform` 固定 `IDENTITY_VIEW`),只是容器换成 800×600 的固定框——不要为直播再写一套 canvas 绘制。
- 视频源继续使用 `VideoSurface`(`ViewTransform` 固定 `IDENTITY_VIEW`),将容器改为 800×600 的固定框,复用现有 canvas 绘制逻辑。
### 输出方式
@@ -341,7 +341,7 @@ z6 GuideLayer 安全区参考线(仅预览,输出时 display:none
实施时需检查:
1. WebView 背景:`transparent: true` 在 Windows WebView2 与 macOS WKWebView 行为不一致,直播场景下透明边缘会出黑边。结论:用不透明底色 `--bg-void` 铺满,不做透明。
2. 低帧画面 + 高帧包装:视频是 JPEG 序列,帧率取决于固件配置(`commands.rs` 的注释里记着"The sensor 实测 22 fps",`target/firmware/BUILD-INFO.json` 里 vga30 档实测 29.7 fps);无论哪个值都远低于包装层的 60 fps,动效会"飘"在画面上。所以包装层只做状态迁移动画,不做连续动画。
2. 视频与包装层帧率不同:视频是 JPEG 序列,帧率取决于固件配置(`commands.rs` 的注释记录“The sensor 实测 22 fps”,`target/firmware/BUILD-INFO.json` 中 vga30 档实测 29.7 fps),均远低于包装层的 60 fps。连续动效会显得与视频节奏不一致,因此包装层只使用状态迁移动画,不使用连续动画。
3. 纯黑编码:`#000` 在 H.264 里容易 banding,用 `#07080a` + 2% 噪点。
4. OBS 窗口采集会采到鼠标:全屏时 `cursor: none`(`immersive` 状态负责)。
5. 窗口标题:直播窗口标题里不要出现队名/端口等内部信息(`setTitle` 现在会写身份;broadcast 视图下改成 `cam-desktop — 直播`)。
@@ -350,9 +350,9 @@ z6 GuideLayer 安全区参考线(仅预览,输出时 display:none
### 直播视图的安全边界(必须做)
- `broadcast` 视图不渲染:设置按钮、维护入口、`cameras[].addr`(IP)、`stream_info.url`(token)、密码相关 UI、装甲板 id 全文(只显示队名)。
- 所有管理命令在广播态禁用(不是隐藏,是 `disabled` + `aria-hidden`,防止误触与 Tab 聚焦)。
- 广播态下需禁用所有管理命令,使用 `disabled` + `aria-hidden` 防止误触与 Tab 聚焦,不能只隐藏入口。
- 演示模式(`demo`)在直播里显示红色角标"演示画面",与现有 `tag--demo` 同一语义(现在只在 TopBar,直播态必须另有一处)。
- 断流时 `StandbyCard` 明确写"信号中断 · 正在重连",不显示上一帧冻结画面冒充直播(冻结画面会被观众当成"画面卡住")。
- 断流时,`StandbyCard` 显示“信号中断 · 正在重连”,不显示上一帧冻结画面,以免观众将其误认为直播卡顿。
---
@@ -377,11 +377,11 @@ z6 GuideLayer 安全区参考线(仅预览,输出时 display:none
### 对客户端的影响
1. 血量必须同时有数字和格子。100 点 / 5 格 = 20 点/格,而单发只扣 3 点——一格的粒度相当于 6.7 发。只给格子,操作手无法判断"再中两发就死"。→ 保留 `showNumbers` 默认开启,且数字 ≥ 18px。
2. 100 点 = 34 发(33 发后剩 1 点)。裁判端可以显示"已中 33 发"这类辅助读数(`100 - hp) / 3`),但不要把它当成权威值——静默期内的连击会被合并。
1. 血量需同时显示数字和格子。100 点 / 5 格 = 20 点/格,单发只扣 3 点,一格相当于 6.7 发;仅凭格子,操作手无法判断再中两发是否会归零。因此保留 `showNumbers` 默认开启,且数字 ≥ 18px。
2. 100 点 = 34 发(33 发后剩 1 点)。裁判端可显示“已中 33 发”等辅助读数(`100 - hp) / 3`),但静默期内的连击会合并,不能将该读数作为权威值。
3. 100 ms 静默期决定命中动效下限:同一次攻击可能在 4 块板上各上报一次,`HealthTracker` 按血量差派生,已经能合并;UI 侧不要为每个 board 事件各弹一次伤害数字(否则一次命中会闪 4 下)。
4. 装甲板 2 块也要能显示:`ArmorBoardDto.reportsAllPlates` / `platesSeen` 已存在,界面在 `platesSeen < 4` 时显示"仅报 N 块",不要显示 4 个空槽让人以为板子坏了。
5. "最快开始移动"没有数据源。图传只给画面,协议里没有位移/速度;因此这条规则只能人工判定(见“计时 / 比分”)。不要试图从画面里推算。
5. 目前没有“最快开始移动”的数据源。图传只提供画面,协议不包含位移或速度,因此只能人工判定(见“计时 / 比分”),不从画面推算。
6. 赛前 3 分钟是"确认客户端连上了"的窗口,所以用户端需要一个准备检查卡:图传在线、装甲板在线、帧率、最近一帧年龄、镜像是否正确。这张卡在 `Ready` 阶段自动出现,`Running` 后消失。
7. 红蓝配色固定:队号 0 = 红、1 = 蓝,与部署点/装甲板灯色一致(现有 `teamColor` 已是这个语义,只改色值)。
@@ -439,7 +439,7 @@ z6 GuideLayer 安全区参考线(仅预览,输出时 display:none
| 设备故障(图传中断)导致无法比赛 | 人工裁定:技术暂停 / 判负 / 重赛 | 主裁判 |
| 技术暂停超时(>5 分钟) | 提示超时,不自动判负 | 主裁判 |
> 手册 1.0.0 的更新日志提到"添加了详细的扣血、判罚规则与单局时间",但当前公开页面只给出了 上方规则表列出的内容(扣血 3 点、技术暂停 5 分钟、四条胜负条件)。如果组委会另有一份判罚细则(红黄牌、犯规等级),需要提供原文,否则界面不写死任何罚则。
> 手册 1.0.0 的更新日志提到“添加了详细的扣血、判罚规则与单局时间”,但当前公开页面只提供上表所列内容(扣血 3 点、技术暂停 5 分钟、四条胜负条件)。若组委会另有红黄牌、犯规等级等判罚细则,需提供原文;在此之前,界面不写死罚则。
---
@@ -469,7 +469,7 @@ pub enum MsgType { Video = 1, Subscribe = 2, Hello = 3, Heartbeat = 4, ArmorHeal
```
- 裁判端以 `REFEREE_ID` 广播到 `5601`;用户端在装甲板监听里增加一个 `MsgType::MatchControl` 分支(不改相机订阅逻辑)。
- 安全:局域网里任何人都能发 UDP,所以必须带共享码派生 MAC(检录时裁判端生成 6 位码,双方在客户端输入),否则队伍可以伪造"对方判负"。
- 安全:局域网内任何人都能发送 UDP,因此消息必须携带共享码派生的 MAC。检录时由裁判端生成 6 位码,双方在客户端输入;否则队伍可以伪造对方判负消息。
- 固件兼容性待验证:装甲板固件只认 `MsgType::ArmorHealth`,未知类型按现有 `decode_header` 逻辑返回 `None` 被忽略;但上线前必须在实机上验证"未知 MsgType 不会让板子异常"。
### 需要用户确认的问题
@@ -527,7 +527,7 @@ pub enum MsgType { Video = 1, Subscribe = 2, Hello = 3, Heartbeat = 4, ArmorHeal
## 附录 C:范围
1. 不做网络 OTA / 设备自升级(与[固件维护](../maintenance/firmware.md) 的边界一致)。
2. 不做修改固件源码;打包只组装已有产物。
2. 不修改固件源码,打包只组装已有产物。
3. 不做从画面推算"移动先后"、命中位置、弹道等规则数据。
4. 不做直播的透明背景/虚拟摄像头/NDI。
5. 不做多语言(中文界面为唯一语言,符合约束)。
+66 -68
View File
@@ -1,13 +1,13 @@
# RMIC::2026
黑龙江大学 602 实验室 · Simba Robotics 的视野链路桌面端。同一进程提供用户端与裁判端两种界面。
黑龙江大学 602 实验室 · Simba Robotics 的裁判系统客户端。同一进程提供用户端与裁判端两种界面。
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|---|---|---|---|---|
| 用户端(`team`) | 一路 | **双方**(左上角自己 / 右上角对手) | 自己那块 | **要,改动需密码** |
| 裁判端(`referee`) | 两路并排 | 双方(各自贴画面角上,**队名可改**) | 发现列表 + 槽位 | 不需要 |
前端使用 WebView 中的 React,以 canvas 绘制画面;后端使用 Rust。网络接收、重组、发现、配置、密码和录制均已重写为可单测的模块。
前端在 WebView 中运行 React,以 canvas 绘制画面;后端使用 Rust。网络接收、重组、发现、配置、密码和录制均已重写为独立模块,可分别运行单元测试。
> 显示名是 `RMIC::2026`;安装包和可执行文件使用 `RMIC-2026`,因为 Windows 文件名不允许 `:`。配置、录制与日志沿用 `cam-desktop` 目录,以保留已有密码哈希与绑定,见“配置”。
@@ -40,7 +40,7 @@ desktop/
红色用于需要注意的状态。开启的开关为白色,滑杆填充为钢灰色,分段控件以底部 2px 线标记选中,分区编号为灰色,顶栏不加装饰强调线。
队红刻意做得比强调红更暖更浅,否则两条血并列时会被误读成"系统在报警"。
队伍红色比强调红更暖、更浅,以免两条血量并列时看起来像系统在报警。
### 形状:矩形,唯一的斜边给装甲条
@@ -48,7 +48,7 @@ desktop/
### 动效:严格照 Material 3 的曲线与时长
`styles/tokens.css` 里直接写着 M3 的七条缓动与 short/medium/long 四组时长:
`styles/tokens.css` 定义了 M3 的七条缓动曲线与 short/medium/long 四组时长:
| 令牌 | 曲线 | 语义 |
|---|---|---|
@@ -58,9 +58,7 @@ desktop/
| `--m3-standard` / `-decelerate` / `-accelerate` | `(0.2,0,0,1)` / `(0,0,0,1)` / `(0.3,0,1,1)` | 常规位移 |
| `--m3-linear` | `(0,0,1,1)` | 恒速(仅用于跟踪与循环) |
旧名 `--e-out` / `--e-in` / `--d-1..5` 保留为**指向 M3 的别名**,所以没有一条规则落在自造
的贝塞尔上。两个例外是刻意的:`steps()` 用于录制红点与终端光标(离散闪烁),`linear` 用于
"没信号"那圈恒速转动的虚线环——M3 的曲线描述的是有始有终的转场,不是无限循环。
旧名 `--e-out` / `--e-in` / `--d-1..5` 保留为指向 M3 的别名,相关规则均引用这些曲线,没有自定义贝塞尔曲线。两种循环效果另行处理:`steps()` 用于录制红点与终端光标的离散闪烁,`linear` 用于“没信号”状态下恒速转动的虚线环。M3 曲线描述有起止的转场,不适用于这些无限循环。
### 动效:每一页只有一个签名动作
@@ -85,35 +83,32 @@ desktop/
| 中文 | **HarmonyOS Sans SC**(400 / 500 / 700) | 叙述、标签、按钮文字。字面方正,11px 仍然立得住 |
| 拉丁与数字 | **JetBrains Mono**(400 / 500 / 700) | 全部读数:帧率、血量、地址、倍率、时间码。等宽,列里的数字跳动时不左右抖 |
两者都以 woff2 自托管在 `public/fonts/`,随 `frontendDist` 进安装包,**运行时不碰网络**
(CSP 里 `default-src 'self'` 也不允许)。中文由 `cn-font-split` 切成 82 个 unicode-range
分片 × 3 个字重,浏览器按需加载,避免启动时加载完整的 14 MB 字体。
两种字体都以 woff2 格式保存在 `public/fonts/`,随 `frontendDist` 打入安装包,运行时无需联网加载,CSP 的 `default-src 'self'` 也不允许从外部加载。中文字体由 `cn-font-split` 切成 82 个 unicode-range 分片 × 3 个字重,浏览器按需加载,避免启动时读取完整的 14 MB 字体。
合到一句话里靠**逐字回退**,不做语言判断:`--font-ui` 把 JetBrains Mono 写在前面,它里面
没有汉字,浏览器就逐字往下找,落到 HarmonyOS。于是"血量 87"里数字是等宽的、汉字是
HarmonyOS 的,一条 `font-family` 同时满足两个要求。
混排依靠逐字回退,无需判断语言:`--font-ui` 将 JetBrains Mono 放在前面,遇到其中没有的汉字时,浏览器回退到 HarmonyOS。因此,“血量 87”中的数字使用等宽字体,汉字使用 HarmonyOS,同一条 `font-family` 即可处理两者。
启动页等待 `document.fonts.ready`,避免先显示系统字体、两百毫秒后切换到 HarmonyOS 引起布局重排。
### 队标
应用图标与启动页的标记是队标(`https://simba-robotics.com/logo.svg`),由它生成整套
`src-tauri/icons/`。原图是 1024×1024 的画布、标志只占中间一条,所以脚本把 viewBox 收到本体
上再居中;**小尺寸用更大的缩放比**(光学尺寸:256px 以上 82%、64–128px 88%、48px 以下 97%),
否则 16/24/32 上那些 1px 笔画会糊成一团。`icon.ico` 是手工拼的多尺寸容器——Pillow 的写入器
只能从一张主图缩出所有档位,而 Windows 任务栏恰恰只取最小的那几档。
应用图标和启动页标记均来自队标(`https://simba-robotics.com/logo.svg`),整套 `src-tauri/icons/` 由此生成。原图画布为 1024×1024,标志只占中间一条,因此脚本先将 viewBox 收到图形本体,再居中放置。深色底板采用圆角,四角透明。小尺寸使用更大的缩放比(光学尺寸:256px 以上 82%、64–128px 88%、48px 以下 97%),避免 16/24/32 图标中的 1px 笔画模糊。`icon.ico` 手工封装各尺寸图像;Pillow 的写入器只能从一张主图缩出所有档位,而 Windows 任务栏只使用其中最小的几个尺寸。
笔画在 17px 以下就只剩 1px,所以顶栏只放字标 `RMIC::2026`(`::` 是界面里唯一被染红的两个
字符),完整队标留给启动页的 30px。
图标小于 17px 时,笔画只剩 1px,因此顶栏只放字标 `RMIC::2026`(其中 `::` 是界面里唯一标红的两个字符),完整队标在启动页以 30px 显示。
图标的 PNG、ICO 与 ICNS,以及 Windows 安装程序的侧图和页眉图,由同一脚本生成。先在 `desktop/` 安装前端依赖,再准备 Python 3 与 Pillow:
```sh
python -m pip install Pillow
python scripts/generate-brand-assets.py
```
以上命令在 `desktop/` 执行。脚本使用已有 Tauri CLI 渲染 SVG,不新增应用依赖。图标源文件为 `src-tauri/icons/icon.svg`;安装图源文件为 `src-tauri/installer/header.svg` 和 `sidebar.svg`,采用 132×57 和 164×338 的布局,导出两倍分辨率的 24 位 BMP(264×114、328×676),另保留原尺寸 PNG 便于预览。侧栏使用 `installer/logo.svg` 中不带底板的原始队标;生成脚本按图片引用读取对应 SVG,不把它替换为应用图标。安装图采用 `AspectFitHeight` 等比缩放,侧栏比例适配简体中文字体的对话框单位,避免 Logo 被水平压缩。页眉画布收窄,为标题区域保留间隔,防止图片等比放大后遮住标题首字。安装图片沿用墨黑蓝底、白色队标和红色强调色。默认使用 Windows 系统字体;其他平台可用 `--sans-font` 指定支持中文的字体文件、`--mono-font` 指定等宽字体。
### 每页最顺手的那个操作
- 用户端通过滚轮缩放、拖动平移、双击复位,无需额外点击按钮。六秒没有输入或受击时,角落标签与代号淡出;出现输入或受击后恢复。
- **裁判端让控制台跟着鼠标走。** 按下左格,下方列表就只显示那一边的装甲板(再按一次回到
全部,键盘 `1` / `2` / `0` 同样)。中间那条竖线可以拖——哪一路更重要就把它拉宽,双击回到
等宽。画面与下方控制台之间的横线也可拖动,用来调整回放区域的高度。
- **设置是一块带预览的控制台。** 左侧竖向导航,右侧常驻一块实时预览;预览用的是画面里
同一个绘制函数(`lib/reticle.ts`),使预览与实际画面的绘制逻辑一致。预览血量滑杆用于查看低血阈值与脉动效果。
- 裁判端点击左侧画面后,下方列表只显示该侧的装甲板;再次点击恢复显示全部,键盘 `1` / `2` / `0` 也可切换。拖动中间的竖线可调整两路画面的宽度,双击恢复等宽。拖动画面与控制台之间的横线可调整回放区域高度。
- 设置页左侧为竖向导航,右侧常驻实时预览。预览与实际画面共用绘制函数(`lib/reticle.ts`),保持绘制逻辑一致;预览血量滑杆用于查看低血阈值与脉动效果。
## 版本与改动日志:算出来的,不是写出来的
@@ -153,9 +148,32 @@ pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --ci -- --locked
- 安装包:同一 release 目录下的 `bundle/nsis/*-setup.exe`。
- 核心测试:进入 `core/` 后运行 `cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'`。
Windows 打包需从普通终端或中完整性及以上的 CI runner 启动。`tauri.windows.conf.json` 在构建开始和打包前运行 `scripts/prepare-windows-bundle.ps1`,将输出目录、Cargo 宿主构建脚本、主程序及已有 NSIS 安装包的完整性标签设为 Medium。低完整性进程会被明确拒绝;CI 上传前还会只读检查产物标签。
如果已有安装包启动时报 `Error writing temporary file. Make sure your temp folder is valid.`,可先在普通 PowerShell 中检查标签:
```powershell
# 在 desktop/ 中执行。低完整性标签的 SID 为 S-1-16-4096。
icacls .\src-tauri\target\x86_64-pc-windows-msvc\release\bundle\nsis\*-setup.exe
# 修复本机已有主程序、安装包与打包输出目录,无需重新编译。
.\scripts\prepare-windows-bundle.ps1
# 只读复查,要求安装包存在。
.\scripts\prepare-windows-bundle.ps1 -CheckOnly
```
自定义 Cargo 输出路径时,脚本通过 `cargo metadata` 获取目录。单独修复其他目录可传 `-OutputDir <目标的 release 目录>`;调试产物则传相应的 `debug` 目录。
[Windows 创建进程时会取用户与 exe 文件完整性级别的较低值](https://learn.microsoft.com/en-us/windows/win32/secauthz/mandatory-integrity-control#process-creation),所以带 Low 标签的 exe 无法写入普通 `%TEMP%`。NSIS 在执行安装钩子前就会检查临时文件目录,仅重定向构建机的 `TEMP` 或放宽目录 DACL 不能解决分发后的问题。修复脚本只修改构建输出的完整性标签,不修改用户临时目录权限;安装包仍按当前用户安装。修复标签后若仍报错,再检查实际临时目录是否存在且可写。
权限回归检查:打包下载过 NSIS 工具后,在 `desktop/` 执行 `./scripts/test-windows-bundle.ps1`。它在独立样例目录验证 Low 进程拒绝、文件标签修复及重复执行,并编译一个仅写临时文件后退出的 NSIS 程序;不安装应用。工具不在默认缓存时可传 `-NsisCompiler <makensis.exe 路径>`。
NSIS 安装流程使用简体中文,包括欢迎、安装位置、安装完成、覆盖安装、卸载与 WebView2 错误提示。`src-tauri/installer/hooks.nsh` 定义页面提示和安装日志,`SimpChinese.nsh` 定义 Tauri 安装器的补充翻译;`tauri.windows.conf.json` 引用这些脚本和主题图片。沿用 Tauri 默认安装模板和当前用户安装方式;首次启动的模式选择与裁判系统绑定仍在客户端内完成。
默认完成页提示客户端与裁判系统连接同一网络,不内置 Wi-Fi 凭据。现场安装包如需显示热点信息,可将 `src-tauri/installer/network.example.nsh` 复制为同目录的 `network.local.nsh`,填写后重新打包。该本地文件已被 Git 忽略;不要提交它,分发安装包时也需确认其中的热点信息允许分享。
视频录制另需可从 `PATH` 找到的 `ffmpeg`,也可以在配置的 `[recording] ffmpeg` 中指定路径;基础图传显示不依赖它。
设备发现使用板卡的 Hello 广播;客户端除广播订阅外,还会按已发现设备的 IP 定向续订。Windows 开启 VPN / 虚拟网卡时,全局广播可能走虚拟接口;定向续订可避免"能发现并绑定,但始终等待画面"的情况。队伍端的绑定和画面过滤仍然生效。
设备发现使用裁判系统的 Hello 广播。客户端除广播订阅外,还会按已发现设备的 IP 定向续订。Windows 开启 VPN / 虚拟网卡时,全局广播可能走虚拟接口,定向续订可避免“能发现并绑定,却始终等待画面”的情况。队伍端的绑定和画面过滤仍然生效。
### 演示模式:没有板子也能把整条链路跑起来
@@ -188,8 +206,7 @@ CAM_CONFIG=<路径> 等价于 --config <路径>
使用 `tauri dev` 时,应用参数需要通过两个 `--` 分隔符传递,如 `tauri dev -- -- --demo`。也可通过上面的环境变量设置。
**未知参数只记一条 debug 日志,不报错。** macOS 从访达双击 .app 时系统会塞进来一个
`-psn_0_123456`;把它当错误处理的表现是"双击图标什么都没发生"。
未知参数只记录一条 debug 日志,不报错。macOS 从访达双击 .app 时,系统会附加 `-psn_0_123456`;若将它视为错误,用户看到的现象就是双击图标后程序没有启动。
## 为什么换成 Tauri,代价是什么
@@ -232,7 +249,7 @@ CAM_CONFIG=<路径> 等价于 --config <路径>
## 防作弊:两道闸门,不是一个
对抗赛里两队都用这套系统看自己小车的图传,要防止对手开第二个窗口连到另一队的板子。
对抗赛中,两队各用这套系统查看本车图传,接收限制用于防止对手另开窗口连接另一队的板子。
限制在接收端执行。Hello 广播已包含由 MAC 派生的 `sender_id` 与 IP;CSS 只能隐藏显示,无法阻止接收与解码。
@@ -241,14 +258,13 @@ CAM_CONFIG=<路径> 等价于 --config <路径>
| `only_sender` | 帧的重组与投递 | 用户端**始终**是绑定的那一块 |
| `discovery_all` | 是否记录**其它**板子的 Hello | 只在密码解锁后的设置界面里打开 |
旧客户端共用一个开关控制发现和接收,改绑需要手动修改配置文件。现在设置界面可以列出候选摄像头,同时保持接收限制:其他板卡即使向本机端口推流或进入发现列表,其视频帧也不会进入重组器。
旧客户端共用一个开关控制发现和接收,改绑需要手动修改配置文件。现在设置界面可以列出候选摄像头,同时保持接收限制:其他裁判系统即使向本机端口推流或进入发现列表,其视频帧也不会进入重组器。
`only_sender` 只在两种情况下放开:裁判端(中立方),以及用户端还没绑定摄像头。debug 构建不会放开它。
### 遥测按编译模式门控
`TEAM_DIAGNOSTICS = cfg!(debug_assertions)`。用户端 release 下**不产出**帧率/丢帧这些
数字,`CameraStatsDto::build` 将相应字段置零或置空。门控由编译模式决定,避免依赖现场命令行开关。
`TEAM_DIAGNOSTICS = cfg!(debug_assertions)`。用户端 release 构建不产出帧率和丢帧数据,`CameraStatsDto::build` 将相应字段置零或置空。是否提供这些数据由编译模式决定,不依赖现场命令行开关。
### 这挡不住什么
@@ -261,7 +277,7 @@ Hello 广播仍包含 IP。固件会向任何发送 `Subscribe` 的订阅者推
- 密码用于限制现场改绑和退出程序。配置只保存 Argon2id 哈希;修改配置中的绑定后,界面仍要求解锁。
- 拥有本机配置和源码的人可以删除哈希或修改客户端,因此密码无法约束这种操作。限制无线订阅仍需固件侧配对机制。
所以它的定位是**防误触与防随手改**,不是密码学意义上的对抗。
密码的作用是防止误触和现场随手修改,不能提供密码学意义上的对抗保护。
实现上:
@@ -274,10 +290,9 @@ Hello 广播仍包含 IP。固件会向任何发送 `Subscribe` 的订阅者推
Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从控制台调用 `invoke("bind_camera")`,也需要通过这项检查。
客户端移除了旧版的 `--camera-id` 命令行覆盖,避免绕过密码改绑。临时更换板卡时,在设置中解锁后修改,或用 `--config` 指定另一份配置文件。
客户端移除了旧版的 `--camera-id` 命令行覆盖,避免绕过密码改绑。临时更换裁判系统时,在设置中解锁后修改,或用 `--config` 指定另一份配置文件。
忘了密码:删掉配置文件里的 `password` 一行(或整个文件)。旧客户端的配置会在首次启动时
导入并备份成 `.bak`。
忘记密码时,可删除配置文件中的 `password` 一行,或删除整个文件。旧客户端配置会在首次启动时导入,并备份为 `.bak`。
## 用户端 HUD
@@ -295,8 +310,7 @@ Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从
### 胜负横幅
一方归零后,图传中央显示结果:获胜方为“胜利”,落败方为“落败”,双方同时归零为“平局”。用词带上对手的名字,裁判端两格因此也能直接
看出是哪一边赢了。
一方血量归零后,图传中央显示结果:获胜方为“胜利”,落败方为“落败”,双方同时归零为“平局”。提示带上对手名字,便于在裁判端两路画面中判断胜负。
- 结果由 Rust 侧计算(`snapshot::resolve_outcomes`,有单测)。自己归零即可判负,即使对手掉线;判胜则要求对手归零且自己仍有血,避免在本队从未上报时误判获胜。
- 横幅持续显示到血量重置(长按 BOOT / 下一局开始),供截图、回放和现场核对。
@@ -306,7 +320,7 @@ Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从
瞄准器标记相机画面中的一个点,直接绘制在 canvas 上。图像、网格、瞄准器和伤害数字共用同一组变换,避免 DOM 与 canvas 分别计算位置而产生偏移。
尺寸与缩放的关系也是刻意分开的:
瞄准器的位置与大小采用不同的缩放规则:
| | 跟着缩放走吗 | 为什么 |
|---|---|---|
@@ -314,8 +328,7 @@ Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从
| 大小 | 否 | 像真瞄具一样,放大之后准星覆盖画面里更小的一块 |
| 网格 | 是 | 它标的是画面里的比例,用来估距离 |
**偏移校准存的是画面比例,不是像素。** 摄像头与枪管不同轴时要把瞄点挪到弹着点上,而像素
偏移在分辨率或窗口大小一变就全错了。校准可以拖着画面调,也可以直接填数值。
偏移校准保存画面比例,不保存像素偏移。摄像头与枪管不同轴时,需要将瞄点调整到弹着点;若保存像素偏移,分辨率或窗口尺寸变化后就会失准。校准既可通过拖动画面完成,也可直接输入数值。
### 命中反馈
@@ -325,11 +338,9 @@ Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从
- 血量增加 ⇒ 不是命中,是长按 BOOT 的重置;
- 第一次见到一块板子不产生命中——没有基线,此时 `hp = 0` 只是"已经没血了"。
按**板子**记基线而不是按 `(板子, index)`:主板一次广播四条、`hp` 相同,按四条各记一份
基线会让一次掉血被报成四次命中。
基线按板子记录,而非按 `(板子, index)` 记录。主板一次广播四条上报,`hp` 相同;若为每条分别记录基线,一次掉血就会产生四次命中反馈。
已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次
打击会被合并成一次——对"闪一下红"足够,对判定不够,判定看的是界面上的绝对血量。
命中反馈只能反映两次读数之间的血量净差。心跳间隔为 200 ms,这段时间内的两次打击会合并为一次反馈,足以触发红色提示,但不足以单独用于判定;判定需看界面上的绝对血量。
反馈包括画面四周泛红和准星旁的伤害数字,中央画面保持原样。早期版本的数字曾遮住瞄点,因此移到了准星旁。
@@ -337,25 +348,19 @@ Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从
早期版本会在命中时晃动画面和瞄准器。该效果会干扰观察,也容易被误认为车体晃动,因此已移除。
`HitFeedbackConfig::shake` 这个字段留着但不再生效:配置文件是 `deny_unknown_fields` 的,
删字段会让所有写过 `shake = true` 的配置**解析失败**,而配置解析失败等于程序起不来。设置
面板里也没有这个开关了。
`HitFeedbackConfig::shake` 字段保留,但已不再生效。配置解析使用 `deny_unknown_fields`,删除该字段会让所有写有 `shake = true` 的配置解析失败,导致程序无法启动。设置面板也已移除这个开关。
## 裁判端
### 队名可以直接改
两格画面各自下面有一个队名输入框(`TeamNameEditor`),回车或点到别处生效,Esc 取消。
名字是**这一场比赛**的属性,所以它放在裁判盯着的地方,而不是设置面板深处的某一节;改完
立刻反映到血条、装甲板表与"等待图传"的占位文字上。
两路画面下方各有一个队名输入框(`TeamNameEditor`),按回车或点击其他位置生效,Esc 取消。队名随比赛变化,因此直接放在裁判查看画面的区域。修改会立即同步到血条、装甲板表和“等待图传”的占位文字中。
Rust 侧统一处理队名(`config::sanitize_team_name`,有单测):合并空白,按字符截断到 12 个字,留空时使用「队伍一 / 队伍二」。输入框也限制长度,避免提交后才截断;按字符处理可避免截断中文的字节序列。
`set_team_name(team, name)` 每次只修改一队。若提交整个队名数组,最多 200 ms 前的旧快照可能覆盖刚修改的另一队名称。命令仍经过配置解锁检查,避免新增不受检查的写配置入口;裁判端的 `Role::needs_password` 为假,现场编辑无需密码。
名字在快照里以 `teamNames`(两条,下标就是队伍号)推给界面,两边各自的 `TeamHealthDto.name`
也是从这里来的:界面各处自己拼 `team === 0 ? "队伍一" : "队伍二"` 会在改名之后留下一半旧名字,
而且不会报错。
快照通过 `teamNames` 向界面传递两条队名,下标对应队伍号;两侧的 `TeamHealthDto.name` 也来自这里。若界面各处自行拼接 `team === 0 ? "队伍一" : "队伍二"`,改名后部分位置仍会保留旧名字,且不会报错。
### 两格等宽
@@ -372,16 +377,15 @@ Rust 侧统一处理队名(`config::sanitize_team_name`,有单测):合
3. 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位;
4. 没有候选时保持槽位为空,避免两格重复显示同一块板。
旧实现只排除当前槽位原有的 id。一队掉线后,其槽位可能选中另一队仍在线的板卡,导致两格重复显示。规则 2 修复了这一问题,并有对应测试。
旧实现只排除当前槽位原有的 id。一队掉线后,其槽位可能选中另一队仍在线的裁判系统,导致两格重复显示。规则 2 修复了这一问题,并有对应测试。
图传板按在线候选自动分配,装甲板按上报的 `team` 分配。若画面对应关系不符,或装甲板固件的队伍号烧反,可在设置中手动指定槽位。
### 装甲板发现列表
列表显示板卡数量、自报队伍、血量、距上次广播的时间、四路上报情况和槽位。裁判端未显示血量时,可据此区分板卡未上线、队伍号错误和上报不完整。
列表显示裁判系统数量、自报队伍、血量、距上次广播的时间、四路上报情况和槽位。裁判端未显示血量时,可据此区分裁判系统未上线、队伍号错误和上报不完整。
**这不需要改固件**:主板一直用 `encode_from(sender_id)` 把自己的 MAC 派生 id 打进帧头,
只是旧的解码路径把它丢了。现在 `ArmorHealth::decode_from` 把它交出来。
该功能不需要修改固件:主板原本就通过 `encode_from(sender_id)` 将 MAC 派生 id 写入帧头,但旧解码路径丢弃了这一字段。现在由 `ArmorHealth::decode_from` 将它交给上层。
### 截图与录制
@@ -396,7 +400,7 @@ Rust 侧统一处理队名(`config::sanitize_team_name`,有单测):合
## 配置
`~/.config/cam-desktop/config.toml`,`--config` 可改。**目录名沿用旧的 `cam-desktop`**(原因见开头)。要干净重来就删掉这个目录。
默认配置文件为 `~/.config/cam-desktop/config.toml`,可用 `--config` 指定其他位置。目录名沿用旧的 `cam-desktop`,原因见开头;删除整个目录可重置配置。
```toml
role = "team"
@@ -488,15 +492,9 @@ RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
## 已知限制与没做的
- **同一队有两块装甲板在广播时**,`health(team)` 是"最后写入者优先",四个格子可能混着两块
板子的数。真实比赛一队只有一块,所以这只在开发(演示 + 真板子)或烧错固件时才会遇到;
日志里会警告一次,消歧办法是绑定具体的板子 id(`health_of_board`)。
- **录制帧率**是固定值,不是可变帧率。源帧率变化大时回放速度会有偏差;这个数会写进日志与
DTO,所以至少能解释清楚。
- **`csp` 是一条收紧过的白名单**,但它是"够用"而不是"最小":`style-src` 带
`'unsafe-inline'`(React 的内联 `style` 属性需要它),`connect-src` 放开了整个
`ws://127.0.0.1:*`(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端",
见"测试"一节最后一条。
- 同一队有两块装甲板广播时,`health(team)` 采用“最后写入者优先”,四格数据可能来自不同板子。真实比赛每队只有一块,因此该情况只在开发(演示 + 真板子)或烧错固件时出现。日志会警告一次,可通过绑定具体板子 id(`health_of_board`)消除歧义。
- 录制使用固定帧率,不使用可变帧率。源帧率变化较大时,回放速度会有偏差;采用的帧率写入日志与 DTO,便于排查。
- `csp` 使用收紧后的白名单,但还不是最小范围:`style-src` 带有 `'unsafe-inline'`,供 React 内联 `style` 属性使用;`connect-src` 允许整个 `ws://127.0.0.1:*`,因为端口在启动时随机分配,无法写死。通过前后端连接检查验证它是否可用,方法见“测试”一节最后一条。
- **证书与公证**没做,`.dmg` 只在本机可用。
- 前端已有解码队列测试;布局、实际 WebView 解码和画面显示仍需浏览器预览及真机运行验证。
+9 -7
View File
@@ -28,6 +28,8 @@ espflash write-bin --chip esp32s3 --port COM5 0x0 target/firmware/esp32s3-merged
去掉导出命令的 `--merge --skip-padding` 可生成独立 App,它需写入 `partitions.csv` 声明的 Factory 偏移,并与板上 Bootloader 和分区表匹配。坏板重编可能改变该偏移;新板优先使用 ELF 或合并镜像。
需要交给桌面维护页的 ZIP 时,使用 `scripts/build.ps1 -Package`,或在构建后运行 `python scripts/package_firmware.py --firmware target/firmware`。Windows 仍需保留构建时的短目录 `CARGO_TARGET_DIR`。打包路径与旧镜像隔离的回归检查为 `python scripts/test-package-firmware.py`;它使用临时样例和模拟导出,不连接设备。
## 主机测试
协议层不依赖 ESP-IDF。进入 `protocol/`,以 Windows MSVC 为例:
@@ -40,7 +42,7 @@ Linux 常见 host 为 `x86_64-unknown-linux-gnu`,Apple Silicon 为 `aarch64-ap
装甲规则从 `armor/` 执行 `cargo +stable test -p armor-protocol`,桌面核心从 `desktop/core/` 执行 `cargo +stable test`,同样按需指定宿主 `--target` 和空的 `build-std`。
限帧、温度回差和 JPEG 压缩回压策略可以独立在主机运行,无需连接板卡。Windows 在根目录执行:
限帧、温度回差和 JPEG 压缩回压策略可在主机上独立测试,无需连接裁判系统。Windows 下在根目录执行:
```powershell
New-Item -ItemType Directory -Force target | Out-Null
@@ -64,8 +66,8 @@ cargo clean --release --target xtensa-esp32s3-espidf -p esp-idf-sys
## 发现与协议
- 图传板每 2 秒广播 Hello;客户端每 800 ms 广播 Subscribe,并向已发现的在线板卡 IP 单播续订,固定绑定 UDP 5600。
- 全局广播可能被 VPN / 虚拟网卡路由到其它接口。收到 Hello 后按设备 IP 定向续订,可以使用通往实际局域网的路由;队伍端仅向绑定的板卡增加单播续订。
- 图传板每 2 秒广播 Hello;客户端每 800 ms 广播 Subscribe,并向已发现的在线裁判系统 IP 单播续订,固定绑定 UDP 5600。
- 全局广播可能被 VPN / 虚拟网卡路由到其它接口。收到 Hello 后按设备 IP 定向续订,可以使用通往实际局域网的路由;队伍端仅向绑定的裁判系统增加单播续订。
- 固件使用订阅包的源地址作为发送目标,6 秒不续订即移除,最多保留 4 个对端。
- 新客户端为每个接收器生成随机会话 ID;同一源 IP 下,同一会话换端口会更新推流目标。旧版公共固定 ID `0x0C110001` 保持按地址区分,避免合并同机旧客户端。
- MAC 派生的 `sender_id` 不随 DHCP 地址变化,适合客户端持久绑定。
@@ -78,7 +80,7 @@ cargo clean --release --target xtensa-esp32s3-espidf -p esp-idf-sys
本次对照测试在保留 TUN 的环境中进行,最终参数与验证边界见 [性能排查](../testing/performance.md)。
下表为原 README 保存的**历史单次测试数据**,不是本次构建实测或性能保证。配置为 OV5640、VGA;原始日志、时长和完整网络条件未随这些数字保存。
下表保留原 README 中的历史单次测试数据,不代表本次构建的实测结果,也不构成性能保证。测试使用 OV5640、VGA;这些数字未附原始日志、时长和完整网络条件。
| 指标 | 早期 HTTP MJPEG / TCP | 后续 UDP |
| --- | --- | --- |
@@ -89,13 +91,13 @@ cargo clean --release --target xtensa-esp32s3-espidf -p esp-idf-sys
这些数据不足以单独证明性能差异的根因或无线链路容量。复测时记录板型、固件版本、分辨率、画质、信号、订阅者数量、时长和丢帧数。
FPS 应按**同一时间窗口的帧数增量 / 窗口秒数**计算。累计帧数与窗口时长混用会产生假数值,UI 轮询间隔也不代表相机出帧间隔。探测脚本的重组状态需按设备及帧隔离,避免测量错误。
FPS 应按同一时间窗口的帧数增量 / 窗口秒数计算。混用累计帧数与窗口时长会使结果失真,UI 轮询间隔也不代表相机出帧间隔。探测脚本需按设备和帧隔离重组状态,避免测量错误。
可重复测量:`python scripts/perf_probe.py <板卡IP> --seconds 30 --json perf.json`。默认使用独立的临时端口和 1 MiB 接收缓冲,每 800 ms 定向续订。脚本只依赖 Python 标准库;`--save-frame frame.jpg` 可保存首张完整 JPEG。公平比较应保持订阅者数量、分辨率、画质与网络条件一致。
可重复测量:`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` 和接收端指标判断。
固件日志分别统计采集尝试和完整提交。`submitted` 只表示全部分片已交给网络,不代表收到无线确认或已在客户端显示。`send failures` 可能包含随后重试成功的调用,需结合 `peer frames dropped` 和接收端指标判断。
画面链路测量使用裁判端 `--latency-report <新文件>`,让相机拍摄校准页左侧标记,固定相机和窗口位置。标记经正常采集、网络与绘制路径回到客户端,源与回显共用主机单调时钟;不需要 ESP 时钟同步。测试期间只保留一个原生客户端,额外的本地 WebSocket 观察器不增加 UDP 订阅。方法、刷新估计边界及最新结果见 [延迟测量](../testing/latency.md)。
+17 -44
View File
@@ -53,8 +53,7 @@ armor/
| 复位(新一局) | 长按 BOOT ≥500 ms → 满血、清计数 | `Button` + `Armor::reset` |
| 打印状态 | 短按 BOOT → 立刻打一行状态并重发上报 | `ButtonEvent::Click` |
**整车一条血量。** 板子算账、裁判端只显示:四条上报的 `hp` 相同,`index` 分别是 0..3,
界面按 `(team, index)` 收齐四条后显示同一个数值。
整车共用一条血量,由板子计算,裁判端只负责显示。四条上报的 `hp` 相同,`index` 分别为 0..3;界面按 `(team, index)` 收齐四条后显示同一个数值。
参考实现使用全局 50 ms 冷却,一路受击会抑制其余路在该窗口内的击打。当前四路分别冷却,由 `cooldown_is_per_plate_not_global` 测试验证。
@@ -66,28 +65,21 @@ armor/
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,抓包时一块装甲板发的包不该长得像裁判端发的。
- 血量变化时立即发送,未变化时按 200 ms 心跳重发。裁判端通过心跳判断板子是否在线,避免一次丢包就将界面切回“未接入”。
- `sender_id` 使用 MAC 派生的板子 id(`protocol::id_from_mac`),不使用 0。0 是裁判终端的保留 id,需避免将装甲板的数据包误认为裁判端数据。
- 四条 `index` 的 `hp` 相同(整车一条血),裁判端按 `(team, index)` 收齐四条才显示。
- 广播而不是单播:桌面端电脑的 IP 是 DHCP 给的,会变。图传板用 Hello 广播绕开这件事,
装甲板沿用同一个约定。
- 上报使用广播而非单播,因为桌面电脑通过 DHCP 获取的 IP 可能变化。图传板使用 Hello 广播处理这一问题,装甲板沿用相同约定。
### 桌面端怎么收
`desktop/core/src/armor.rs` 里的 `ArmorHealthStore`(两种身份都启动):
- 绑 `0.0.0.0:5601` 收广播,按 `(team, index)` 存最后一次读数。
- **四条都在 1.5 s 内收到**才交给界面显示;否则回退占位符。心跳 200 ms,1.5 s 是连丢
7 个包,偶发丢包不会让读数来回跳。
- 四条上报都在 1.5 s 内收到时才交给界面显示,否则显示占位符。心跳间隔为 200 ms,1.5 s 对应连丢 7 个包;偶发丢包不会让读数反复切换。
- 用户端也要显示双方血条,所以两端都会占 5601。
- 血量变化会打日志(`armor: board 0x… team 0 hp 100 -> 97`):窗口是给别人看的,
抓日志的人也得能看见掉血。
- 血量变化会写入日志(`armor: board 0x… team 0 hp 100 -> 97`),便于不查看窗口时也能核对扣血。
**界面显示血量数字。** `TeamHealthDto` 带 `hp` 与 `critical`:数值就在队伍名旁边,低于
30 或归零时它与血条一起转红。归零**不写"已击毁"**——那一刻图传中央会给出
“胜利 / 落败”的结论(见 [桌面客户端](../desktop/client.md))。
界面在队伍名旁显示血量数字。`TeamHealthDto` 提供 `hp` 与 `critical`,血量低于 30 或归零时,数字与血条一起转红。归零时不显示“已击毁”,图传中央会显示“胜利 / 落败”(见 [桌面客户端](../desktop/client.md))。
## 构建
@@ -107,9 +99,7 @@ ARMOR_THRESHOLD=1200 scripts/build_armor.sh # 换受击阈值(看日志里
产物:`armor/target/riscv32imc-esp-espidf/release/armor`。
`ARMOR_TEAM` 是**编译期**注入的:换队伍重刷固件即可,不用先擦 NVS。NVS 里另外记着
`team` 与 `color`(命名空间 `armor`,与参考实现同名),两者不一致时以 `ARMOR_TEAM`
为准并覆盖,日志里会说一声。
`ARMOR_TEAM` 在编译期注入,更换队伍后重新烧录固件即可,无需先擦除 NVS。NVS 另存有 `team` 与 `color`,命名空间为 `armor`,与参考实现同名。两者不一致时,以 `ARMOR_TEAM` 为准覆盖 NVS,并记录日志。
本机已有的 ESP-IDF 与工具链通过 `armor/.cargo/config.toml` 指定(`IDF_PATH` /
`ESP_IDF_TOOLS_INSTALL_DIR`),不需要先 `activate` ESP-IDF。删掉那两行就交给
@@ -124,9 +114,7 @@ esp-idf-sys 自己下载它钉住的版本。
| 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**。
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 自己钉的。
@@ -143,10 +131,7 @@ cd armor/armor-protocol && cargo test # 22 个测试
cd protocol && cargo test # 线路格式往返(含 ArmorHealth)
```
(`armor-protocol/.cargo/config.toml` 把 target 覆盖回 host:它坐在 `armor/` 目录树
里,否则会继承 RISC-V 目标,链接必然失败。这与 `protocol/`、`desktop/` 的做法一致。
换到其他宿主平台时,配置里钉住的 `aarch64-apple-darwin` 不适用,按
[开发与验证](../development/firmware.md) 用 `--target <宿主三元组> --config 'unstable.build-std=[]'` 覆盖。)
`armor-protocol/.cargo/config.toml` 将 target 覆盖为 host,否则它会从上层 `armor/` 继承 RISC-V 目标,导致链接失败。这与 `protocol/`、`desktop/` 的做法一致。更换宿主平台时,固定的 `aarch64-apple-darwin` 不再适用,需按[开发与验证](../development/firmware.md)用 `--target <宿主三元组> --config 'unstable.build-std=[]'` 覆盖。
## 烧录与实测
@@ -159,7 +144,7 @@ scripts/flash_armor.sh --no-monitor # 只烧不抓
烧的是 ELF(`espflash` 自己从 ELF 里读分区表与 bootloader),芯片 `esp32c3`。
不打开裁判端界面,也可以单独确认板子在广播、血量对不对:
不打开裁判端界面,也可单独检查板子的广播和血量:
```bash
python3 scripts/armor_probe.py 15
@@ -212,8 +197,7 @@ W (306504) armor: BOOT long press: hp reset to 100 (new round)
### 1. ESP32-C3 的 RMT 只有 96 个 symbol 的通道内存
C3 的 RMT 只有一个 block,总计 96 个 symbol,按 48 为单位分给两个 TX 通道。`mem_block_symbols = 64`
会被向上取整到 96,也就是一个通道吃光全部内存,第二个 `rmt_new_tx_channel()` 就会失败:
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
@@ -221,10 +205,7 @@ E rmt: rmt_tx_register_to_group(168): no free tx channels
C3 只有 2 个 TX 通道,四路灯带无法各占一个。因此只创建一个通道,使用全部 96 个 symbol,再通过 `rmt_tx_switch_gpio()` 依次切换四个 GPIO。每帧为 3 珠 × 24 bit + reset,共 73 个 symbol,每路发送约 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` 一一对应。
未使用 `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`;每秒另做一次全量刷新,避免灯带因掉电、静电等丢帧后一直保持错误状态。
@@ -232,10 +213,7 @@ C3 只有 2 个 TX 通道,四路灯带无法各占一个。因此只创建一
一次击打的波形只有几十微秒宽,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 绑定,与参考
实现逐行对应。
`esp-idf-hal` 0.47 的 `adc::continuous` 多通道入口 `EmptyAdcChannels::chain` 要求同时满足 `A: Adc` 和 `O: AdcChannels<AdcUnit = A>`。`ADC1` 满足前者,`ADCU1` 满足后者,但同一个类型参数无法同时满足,因而不能组合四路 ADC1 通道。`piezo.rs` 因此也直接使用 sys 绑定,与参考实现逐行对应。
采样点的通道号由硬件结果自带(`type2.channel()`),映射不依赖"结果按 pattern 顺序
轮转"这个假设。
@@ -261,12 +239,9 @@ factory 划了 2 MB。
### 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()` 假装连上了、看门狗永远不会重试,板子静默失联。
`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 判断:
因此,固件统一通过 IDF API 判断连接状态:
- 关联:`esp_wifi_sta_get_ap_info()`
- 地址:`esp_netif_get_handle_from_ifkey("WIFI_STA_DEF")` + `esp_netif_get_ip_info()`
@@ -284,9 +259,7 @@ status: hp=0 hits=37 dead=true plates[front=37 rear=0 left=0 right=0]
ap=SR-602-2G4 rssi=-29 ch=6 ip=192.168.1.117 reports=1745 errs=0
```
- `peak[...]` 是**这一秒内每路 ADC 的峰值**,用来校准阈值:没敲时的底噪要明显低于
阈值,正常敲击要明显高于它(实测底噪 ~400–500,敲击 2000–4095)。只报"有没有过阈值"
没法判断阈值该调到哪。
- `peak[...]` 记录这一秒内每路 ADC 的峰值,用于校准阈值:未敲击时的底噪应明显低于阈值,正常敲击应明显高于阈值(实测底噪 ~400–500,敲击 2000–4095)。仅记录是否超过阈值,无法判断应如何调整。
- `thr=` 是当前阈值,`ARMOR_THRESHOLD=1200 scripts/build_armor.sh` 可以改。
- `ap=` / `ip=` / `reports=` 用于区分未联网和已发送但对端未显示的情况。
+4 -4
View File
@@ -18,9 +18,9 @@
| D0–D7 | 11, 9, 8, 10, 12, 18, 17, 16 |
| PWDN | 未连接(`-1`) |
当前 XCLK 为 20 MHz。不要直接套用 AI-Thinker、XIAO 等其他板卡的相机引脚表。
当前 XCLK 为 20 MHz。AI-Thinker、XIAO 等其他裁判系统的相机引脚表不能直接套用。
本模组需避开的资源包括 Flash / PSRAM 总线(GPIO 26–37)、USB(19 / 20)、串口(43 / 44)及模组内部引脚;具体以原理图为最终依据。占用 Flash / PSRAM 引脚可能造成传输期间 CPU 挂死、串口静默。
接线时需避开本模组的 Flash / PSRAM 总线(GPIO 26–37)、USB(19 / 20)、串口(43 / 44)及模组内部引脚,具体以原理图为准。占用 Flash / PSRAM 引脚可能导致传输期间 CPU 挂死、串口无输出。
## 内存与任务配置
@@ -64,13 +64,13 @@
`scripts/flash.sh` 保留的流程是:编译、导出 App、搜索 64 KiB 对齐的可写偏移、修改分区、重新构建和验证,再写入三段镜像。重新构建可能移动镜像内容,因此第二次验证不可省略。
脚本依赖 macOS 串口名、Unix `termios`、本地 ESP-IDF Python 目录及旧版工具参数,不适合作为通用烧录入口。坏板需核对工具版本及坏地址字节;普通板卡使用 README 流程。更换故障模组比长期依赖镜像位置更可靠。
脚本依赖 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”替代。
本地新增 `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 引脚连接。升级驱动后需检查补丁并验证传感器探测和采集。
+4 -4
View File
@@ -1,6 +1,6 @@
# RMIC::2026 文档
从使用步骤进入,再按问题查阅硬件、实现和测试记录。代码命令默认从仓库根目录执行;需要切换目录的地方会注明。
首次使用可从入门文档开始,遇到问题时再查阅硬件、实现说明和测试记录。命令默认从仓库根目录执行;需要切换目录时会另行注明。
项目概览与架构图见 [README](../README.md),工具链、验证命令和仓库协作约定见 [AGENTS](../AGENTS.md)。
@@ -21,7 +21,7 @@
## 测试与排查记录
以下是特定配置、板卡和时间窗口的记录;不同轮次的数字不能直接视为同一配置的结果。
以下记录各自对应特定配置、裁判系统和观察时段,不能把不同轮次的数字当作同一配置下的结果。
- [最新帧与阻塞恢复](testing/realtime.md):发送期限、本地积压清理及五分钟观察。
- [功率、距离与拥塞](testing/power.md):15 dBm 回退及先前功率对照。
@@ -37,8 +37,8 @@
## 文档维护
本仓库参考 [Firefox 文档首页](https://github.com/mozilla-firefox/firefox/blob/main/docs/index.md)按入门、开发、源码、测试和发布分组,并用索引串联主题。这里统一使用 Markdown,不引入 Firefox 的整套文档构建工具。
本仓库参考 [Firefox 文档首页](https://github.com/mozilla-firefox/firefox/blob/main/docs/index.md),按入门、开发、源码、测试和发布分类,通过索引连接各主题。文档统一使用 Markdown,不引入 Firefox 的整套文档构建工具。
除 `README.md` 和 `AGENTS.md`(或 `Agents.md`)外,项目说明集中放在 `docs/`。根目录 README 介绍项目并链接到文档;子项目 README 保留简短入口。第三方组件的 README、许可证、构建文件、运行时配置和测试数据保留各自位置。
新增文档时先选择主题目录,再补充本页链接。迁移时同步修改相对链接、标题锚点与生成脚本。已被实现取代或已放弃的方案可以删除,历史仍可从 Git 查阅;仍需使用的实验记录保留条件、日期与验证范围,提案须明确标注状态。
新增文档时,先选择对应的主题目录,再补充本页链接。迁移文档时,同步修改相对链接、标题锚点与生成脚本。已被实现取代或已放弃的方案可以删除,历史仍可从 Git 查阅。保留实验记录时需写明条件、日期与验证范围,提案需标注状态。
+10 -13
View File
@@ -13,8 +13,7 @@
## 为什么坏块规避不需要重编译
App 镜像不包含自己的分区偏移;偏移只在 `0x8000` 的分区表里。所以同一份 App 可以
写到旧 App 之后的空闲槽位,只需要改分区表里的 factory 偏移,不需要重新构建。
App 镜像不包含自身的分区偏移,偏移保存在 `0x8000` 的分区表中。因此,同一份 App 可写入旧 App 之后的空闲槽位,只需修改分区表中的 factory 偏移,无需重新构建。
旧 `scripts/flash.sh` 之所以重编,是因为它把新偏移写回 `partitions.csv` 再重建。
客户端直接修改从设备读取的当前分区表;包内的 `partition-table.bin` 只作参考模板与校验:
@@ -50,8 +49,7 @@ App 镜像不包含自己的分区偏移;偏移只在 `0x8000` 的分区表里
- `0xFADE7`:bit 5–7 无法正确编程(已确认);
- `0x10863`:bit 6 读回为 1,尚未确认是否为永久故障,见[硬件说明](../hardware/camera.md#损坏-flash-模组的兼容处理)。
两个地址都在 `0x100000` 以下;`0x200000` 槽位天然避开它们。客户端仍会对写入的
App 整段回读,不依赖“已知坏点表”。
两个地址均低于 `0x100000`,因此 `0x200000` 槽位避开了它们。客户端仍会整段回读写入的 App,不依赖“已知坏点表”判断是否成功。
## 官方 .zip 固件包
@@ -104,7 +102,9 @@ BUILD-INFO.json # 可选;展示版本/构建信息
编译后打包:
```powershell
# 只打包(会先调用 make_bundle.py 刷新 bundle.json)
# Windows:使用与构建时相同的短目录。
$env:CARGO_TARGET_DIR = 'C:\s3vt'
# 只打包(从 ELF 导出镜像,再调用 make_bundle.py 刷新 bundle.json)
python scripts/package_firmware.py --firmware target/firmware
# 构建完成后自动打包
@@ -115,13 +115,13 @@ scripts/build.sh --package
产物:
```text
target/firmware/camfw-<chip>-<variant>-<rev>-<yyyymmdd>.zip
target/firmware/camfw-<chip>-v<version>-<build>.zip
target/firmware/camfw-...zip.sha256
```
zip 使用固定条目顺序、固定 mtime 与 deflate level 9,同一个输入应得到同一个整包
SHA256,便于核对分发的固件包是否一致。`package_firmware.py` 会复用
`make_bundle.py`,不维护第二套 manifest 逻辑。
zip 使用固定条目顺序、固定 mtime 和 deflate level 9。同一输入应生成相同的整包 SHA256,便于核对分发的固件包是否一致。`package_firmware.py` 复用 `make_bundle.py`,无需另维护一套 manifest 逻辑。
PowerShell 构建入口通过 Cargo 的产物报告传递 ELF 路径;单独运行 Python 打包时,通过 `cargo metadata` 读取输出目录,支持 `CARGO_TARGET_DIR` 和 Cargo 的 `build.target-dir`。也可用 `--elf <路径>` 显式指定 ELF。脚本从该 ELF 导出 App,复制同目录的 Bootloader 和分区表,并重写构建信息,避免沿用旧镜像或旧实机测试结论。缺少 ELF、配套镜像或导出工具时默认停止;仅在明确需要复用现有目录包时使用 `--allow-stale`。版本默认读取根目录 `version.json`。切回桌面端构建前执行 `Remove-Item Env:CARGO_TARGET_DIR`。
> 注意:bundle 里的 `partition-table.bin` 是参考模板;客户端实际写的是设备当前
> 分区表的 patch 版本,以保留设备的 NVS/PHY 布局。bundle 的分区表 SHA 仍会校验。
@@ -219,10 +219,7 @@ Secure Boot / Flash Encryption 均未启用)上验证:
- 离线单元测试:分区表解析、MD5 重算、候选槽位、尺寸解析、zip 路径白名单,共 7 个;
- 前端 `tsc --noEmit`、`vite build`,后端 `cargo check` 通过。
**尚未执行**完整实机切换(写 patch 后分区表 → 复位 → 启动日志)。App 写入/回读
已经在同一块板上验证;切换步骤的风险集中在 0x8000 的 3 KiB 分区表写入,写入后会
回读,失败会尝试恢复旧表。如果新 App 启动失败,页面会给出
备份目录和 `恢复旧分区表` 按钮。
完整实机切换(写 patch 后分区表 → 复位 → 启动日志)尚未执行;App 写入和回读已在同一块板上验证。切换风险集中在 0x8000 处的 3 KiB 分区表写入,写入后会回读校验,失败时尝试恢复旧表。若新 App 启动失败,页面会显示备份目录和 `恢复旧分区表` 按钮。
## 边界
+12 -24
View File
@@ -8,7 +8,7 @@ protocol/ 线路格式 + 帧重组 (23 个单元测试,无 ESP-IDF 依
desktop/ 桌面客户端 (RMIC::2026,Tauri v2 + React + Rust,两种身份)
```
编码与帧重组出错时,板上通常只表现为没有画面,难以定位。因此这里提供不依赖板卡的主机测试。
编码与帧重组出错时,板上通常只表现为没有画面,难以定位。因此这里提供不依赖裁判系统的主机测试。
在宿主架构上运行测试:
@@ -38,8 +38,7 @@ cd protocol && cargo test
└───────────────┘ └───────────────┘ └───────────────┘
```
两块板子不区分队伍——它们只知道自己是谁(MAC 派生的 id),"哪一边"由客户端决定。
烧同一份固件即可,配置在客户端一侧。
两块板子不区分队伍,只通过 MAC 派生的 id 标识自身,所属队伍由客户端配置决定。因此,两块板子可以烧录同一份固件。
## 线路格式
@@ -75,20 +74,18 @@ cd protocol && cargo test
### 四个设计决定
**27 字节不凑整。** 头只占每个 1427 字节数据报的 1.9%,而为对齐在固件和客户端两侧
各写一段手写偏移常量,更容易引入静默错位。
头长保留为 27 字节,不额外补齐。它只占每个 1427 字节数据报的 1.9%;为对齐而在固件和客户端分别维护手写偏移常量,反而更容易引入不易察觉的错位。
**`sender_id` 是多路的前提。** 裁判终端同时收两块板子,靠它在同一端口上区分来源。
**`width`/`height` 每个分片都重复携带**(不只是第 0 个),这样每个数据报都能独立
解析,中途加入的接收端也能立刻知道分辨率。
**`version` 不匹配就拒收**,而不是按错误布局继续解析——后者会表现为随机崩溃或画面
错乱,比拒绝连接难查得多。
`version` 不匹配时直接拒收。继续按错误布局解析会导致随机崩溃或画面错乱,比拒绝连接更难排查。
## 通告里的版本信息
`Hello` / `Heartbeat` 的负载是**向后兼容的一截**,现在 10 字节:
`Hello` / `Heartbeat` 的负载保留向后兼容的字段,目前共 10 字节:
```text
相对 HEADER_LEN 的偏移
@@ -102,15 +99,13 @@ cd protocol && cargo test
三个决定:
- **旧的 2 字节 `fw` 保留不动**。老客户端只读它,多余的字节不看,所以一次固件更新
不会把还在用旧客户端的机器踢下线。
- **新客户端接受只有 2 字节的老固件**:版本与构建号回到 0("未知"),协议号退回帧头里
的那个。界面显示"版本未知",而不是一个看起来像真值的 `0.0.0`。
- 保留旧的 2 字节 `fw` 字段。老客户端只读取该字段并忽略后续字节,因此更新固件不会使旧客户端因新增字段而断连。
- 新客户端接受仅含 2 字节负载的老固件通告:版本与构建号设为 0(“未知”),协议号使用帧头中的值。界面显示“版本未知”,避免将 `0.0.0` 当作实际版本。
- **通告用宽松的帧头解析,视频帧不用**。见下一条。
### 为什么通告允许协议不匹配
严格解析会丢弃 `version != PROTOCOL_VERSION` 的数据报,以避免按错误布局解析视频。若通告也被丢弃,客户端便无法发现协议不同的板卡,也无法解释没有画面的原因。
严格解析会丢弃 `version != PROTOCOL_VERSION` 的数据报,以避免按错误布局解析视频。若通告也被丢弃,客户端便无法发现协议不同的裁判系统,也无法解释没有画面的原因。
`Hello::decode` 使用 `decode_header_lenient`,保留魔数、长度和字段校验,允许版本不匹配。读出 `proto` 后,客户端可以显示双方协议号,并提示重新烧录。
@@ -138,18 +133,14 @@ UDP 不保证数据报的顺序和到达。接收端等待下一张完整帧,
### 按发送方分开
**每个发送方必须有自己的重组器。** 两块板子的 `frame_id` 各自独立计数,塞进同一个
重组器会让它们互相顶掉,两路都出不来。
每个发送方必须使用独立的重组器。两块板子的 `frame_id` 分别计数,若共用重组器,两路数据会互相覆盖,导致两路都无法交付画面。
### 重复分片必须去重
`FrameAssembler` 按分片下标记录是否收到,重复的直接跳过且不计数,并显式确认最后一个
分片确实到过。
用单一计数 `seen >= expected` 判断收齐不够健壮:一个丢失的分片若能被某个分片的副本
凑数,计数就达标了,而缓冲区里留着空洞,交出去一张尾部是 0 的 JPEG。两个测试专门覆盖
它(`a_duplicate_cannot_complete_a_frame_with_a_hole`、
`a_gap_that_is_never_filled_never_yields_a_frame`)。
仅用计数 `seen >= expected` 判断是否收齐并不可靠:重复分片可能补足计数,但丢失位置仍为空,最终交付的 JPEG 尾部会留下 0。两个测试覆盖这一情况(`a_duplicate_cannot_complete_a_frame_with_a_hole`、`a_gap_that_is_never_filled_never_yields_a_frame`)。
一次历史测试使用一到两个订阅者,20 秒收到 2000 多个分片;固定端口与临时端口两种方式均未观察到重复投递。去重用于保证重组正确性。重复片若被误计数,可能在帧率正常、没有报错时偶发地产生无法解码的 JPEG。
@@ -199,8 +190,7 @@ UDP 不保证数据报的顺序和到达。接收端等待下一张完整帧,
`id_from_mac()` 用固定长度输入的 FNV-1a 从 MAC 派生 `sender_id`。
**用 MAC 而不是 IP**,因为客户端要把绑定存进配置文件并在重启后继续有效——DHCP 换
地址是常态,MAC 不会变。用 MAC 也意味着烧同一份固件即可,不需要任何"我是谁"的配置。
使用 MAC 而非 IP,是为了让保存在配置文件中的绑定在重启后继续有效:DHCP 地址会变,MAC 不变。两块板子因此可以烧录同一份固件,无需另配设备身份。
返回值避开裁判终端保留值 0。测试检查结果稳定性与样例间是否碰撞,但不能证明哈希永不碰撞。
@@ -208,9 +198,7 @@ UDP 不保证数据报的顺序和到达。接收端等待下一张完整帧,
`scripts/udp_probe.py` 手写了这里的每一个常量,**不依赖本 crate**。
这正是它的价值:如果本 crate 的编码有错,脚本仍按文档解析,会立刻报出不一致。反面
例子也有——它的 `role` 字节曾多填 12 个零字节,固件按偏移严格读取因而拒收,表现为
"板子在线但 0 clients subscribed"。改协议时记得同步它。
如果本 crate 编码有误,脚本仍按文档独立解析,可立即报出不一致。探测脚本本身也曾出错:`role` 字节曾多填 12 个零字节,固件按严格偏移读取后拒收,表现为“板子在线但 0 clients subscribed”。修改协议时需同步核对脚本。
## 装甲板
+1 -1
View File
@@ -41,7 +41,7 @@
- 对临时 UDP 发送拥塞进行有时限的让出调度重试,统计实际提交帧、失败次数与丢弃帧。
- Windows UDP 接收缓冲请求增至 1 MiB;界面视频通道和解码队列保留最新待处理帧,避免慢端持续积压。
- 新客户端使用随机会话 ID;固件更新同一会话的源端口,避免 NAT / TUN 端口变化产生重复推流目标,旧固定 ID 客户端继续按地址区分。
- 新增可选编译参数 `WIFI_TX_POWER_DBM`(2–20 dBm);未设置时保留 SDK 默认上限,按实际板卡和网络测量调节。
- 新增可选编译参数 `WIFI_TX_POWER_DBM`(2–20 dBm);未设置时保留 SDK 默认上限,按实际裁判系统和网络测量调节。
- 新增 `scripts/perf_probe.py`,测量完整帧率、帧损失、接收间隔和分片组装耗时。
- 新增 `CAM_MAX_FPS`(默认 0,不限帧),可限制取帧和发送节拍,过载后不追赶补发。
- 新增 `CAM_GRAYSCALE`,通过传感器灰度效果保留硬件 JPEG;增加可配置的芯片温度减载和 5°C 恢复回差。
+45 -3
View File
@@ -3,13 +3,38 @@
"major": 0,
"minor": 2,
"patch": 2,
"build": 38,
"build": 39,
"protocol": 1,
"date": "2026-10-19",
"date": "2026-10-21",
"baseline": "v0.2.1",
"commits": 3,
"commits": 4,
"groups": {
"fix": [
{
"scope": "packaging",
"text": "修复 Windows 安装与固件打包并完善客户端文案",
"breaking": false
},
{
"scope": "installer",
"text": "修复低完整性产物无法写入临时目录的问题,并在 CI 打包前后检查完整性标签",
"breaking": false
},
{
"scope": "firmware",
"text": "从本次 Cargo ELF 打包并同步配套镜像,修正短目录说明与 LLVM 搜索顺序",
"breaking": false
},
{
"scope": "installer",
"text": "修复中文完成页和页眉遮挡,侧栏使用等比缩放的原始 Logo",
"breaking": false
},
{
"scope": "config",
"text": "安装提示改用可选本地热点配置,排除 Wi-Fi 凭据",
"breaking": false
},
{
"scope": "client",
"text": "统一状态与操作文案,录制和截图成功后再显示完成提示",
@@ -17,6 +42,11 @@
}
],
"docs": [
{
"scope": null,
"text": "润色项目说明并统一裁判系统术语",
"breaking": false
},
{
"scope": null,
"text": "整理项目文档并统一界面表述",
@@ -34,11 +64,23 @@
}
],
"test": [
{
"scope": "packaging",
"text": "补充 Windows 完整性和固件打包回归,覆盖外部输出目录、旧镜像与重复生成",
"breaking": false
},
{
"scope": "client",
"text": "按文件名核对媒体类型,避免依赖时间戳排序",
"breaking": false
}
],
"style": [
{
"scope": "client",
"text": "应用图标增加圆角,统一“裁判系统客户端”命名",
"breaking": false
}
]
}
}
+13 -2
View File
@@ -4,24 +4,35 @@
> 提交采用 Conventional Commits,规则见[版本维护](../development/versioning.md)。
> 自动版本机制建立前的说明见[早期人工记录](changelog-legacy.md)。
## v0.2.2 — 2026-10-19
## v0.2.2 — 2026-10-21
构建号 **38** · 线路协议 **1** · 3 个提交(v0.2.1 → v0.2.2)
构建号 **39** · 线路协议 **1** · 4 个提交(v0.2.1 → v0.2.2)
### 修复
- **packaging** · 修复 Windows 安装与固件打包并完善客户端文案
- **installer** · 修复低完整性产物无法写入临时目录的问题,并在 CI 打包前后检查完整性标签
- **firmware** · 从本次 Cargo ELF 打包并同步配套镜像,修正短目录说明与 LLVM 搜索顺序
- **installer** · 修复中文完成页和页眉遮挡,侧栏使用等比缩放的原始 Logo
- **config** · 安装提示改用可选本地热点配置,排除 Wi-Fi 凭据
- **client** · 统一状态与操作文案,录制和截图成功后再显示完成提示
### 文档
- 润色项目说明并统一裁判系统术语
- 整理项目文档并统一界面表述
- README 不写死构建号,改由 version.json 提供
- 按 humanizer 重写项目文档并同步 RMIC::2026 现状
### 测试
- **packaging** · 补充 Windows 完整性和固件打包回归,覆盖外部输出目录、旧镜像与重复生成
- **client** · 按文件名核对媒体类型,避免依赖时间戳排序
### 样式
- **client** · 应用图标增加圆角,统一“裁判系统客户端”命名
## v0.2.1 — 2026-10-19
1 个提交
+5 -4
View File
@@ -12,7 +12,7 @@
| 网络 | 2.4 GHz Wi-Fi |
| 连接 | 支持数据传输的 USB 线、可识别的串口及稳定供电 |
默认采集 VGA(640 × 480)、彩色 JPEG quality 12,按传感器节拍发送。相机引脚、Flash 分区和 PSRAM 配置见 [图传板硬件](../hardware/camera.md)。换板型前必须核对接线,避免占用 Flash / PSRAM 引脚。
默认采集 VGA(640 × 480)彩色 JPEG,quality 为 12,按传感器节拍发送。相机引脚、Flash 分区和 PSRAM 配置见 [图传板硬件](../hardware/camera.md)。更换板型前必须核对接线,避免占用 Flash / PSRAM 引脚。
## 固件开发环境
@@ -151,6 +151,7 @@ WIFI_PASS='你的Wi-Fi密码'
# Windows PowerShell
$env:CARGO_TARGET_DIR = 'C:\s3vt'
.\scripts\build.ps1
# 同时导出并打包:.\scripts\build.ps1 -Package
```
```bash
@@ -158,9 +159,9 @@ $env:CARGO_TARGET_DIR = 'C:\s3vt'
./scripts/build.sh
```
脚本执行 `cargo build --release --locked`,目标为 `xtensa-esp32s3-espidf`。首次编译包含 Rust 标准库、ESP-IDF 和相机组件,耗时较长。构建完成后可用 `-Package` / `--package` 再打一个 `.zip` 固件包,供桌面端维护页使用。
脚本执行 `cargo build --release --locked`,目标为 `xtensa-esp32s3-espidf`。首次编译包含 Rust 标准库、ESP-IDF 和相机组件,耗时较长。构建完成后,可用 `-Package` / `--package` 生成 `.zip` 固件包,供桌面端维护页使用。
Windows 的 ESP-IDF 构建输出路径需不超过 88 字符,因此示例使用短目录 `C:\s3vt`(需可写,且不与其他项目共用)。`subst` 不能绕过该检查。下表的 `<TARGET_DIR>` 在 Windows 示例中为 `C:/s3vt`,Linux / macOS 默认是仓库的 `target`。每次新开 Windows 终端都要设置相同的 `CARGO_TARGET_DIR`。
Windows 的 ESP-IDF 构建输出路径需不超过 88 字符,因此示例使用短目录 `C:\s3vt`(需可写,且不与其他项目共用)。`subst` 不能绕过该检查。下表的 `<TARGET_DIR>` 在 Windows 示例中为 `C:/s3vt`,Linux / macOS 默认是仓库的 `target`。每次新开 Windows 终端都要设置相同的 `CARGO_TARGET_DIR`;切回桌面端构建前执行 `Remove-Item Env:CARGO_TARGET_DIR`。`-Package` 从 Cargo 本次报告的 ELF 路径导出 App,并复制同目录的 Bootloader 和分区表,ZIP 仍写入仓库的 `target/firmware/`。
| 产物 | 位置 |
| --- | --- |
@@ -207,4 +208,4 @@ espflash monitor --port COM5
`espflash save-image --merge` 可导出从 `0x0` 写入的合并镜像。完整命令、独立镜像偏移与测试方法见 [开发与验证](../development/firmware.md)。
保留的 `scripts/flash.sh` 针对特定坏 Flash 模组,依赖 macOS 环境,会修改分区并重编译。普通板卡使用上述标准流程;坏板限制见 [图传板硬件](../hardware/camera.md#损坏-flash-模组的兼容处理)。
保留的 `scripts/flash.sh` 针对特定坏 Flash 模组,依赖 macOS 环境,会修改分区并重编译。普通裁判系统使用上述标准流程;坏板限制见 [图传板硬件](../hardware/camera.md#损坏-flash-模组的兼容处理)。
+3 -3
View File
@@ -1,6 +1,6 @@
# 约 30 fps、Wi-Fi 重连与画面链路延迟验证(2026-10-18)
当前交付已增加阻塞恢复与新鲜度处理,见 [最新帧处理](realtime.md)。本页保留此前减光确认和画面延迟对照;所列 SHA 与测量结果均属于当时构建,不是当前固件的重测结果。
当前交付已增加阻塞恢复与新鲜度处理,见 [最新帧处理](realtime.md)。本页保留此前的亮度确认与画面延迟对照;所列 SHA 和测量结果均对应当时的构建,不代表当前固件重新测量后的结果。
## 先前三缓冲与小幅减光记录
@@ -31,7 +31,7 @@
| 标记 → 首次可解码画布回显 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)。
这次对照窗口中,P50 降低 6.8 ms,P95 降低 37.3 ms。双缓冲减少了潜在的采集排队,实际交付仍接近 30 fps。不过,两个窗口的网络回压、JPEG 自适应档位和时长不同,只能作为连续实机对照,不能把全部改善归因于缓冲数,也不能据此保证所有路由器或场景的表现。此前约 22 fps 的测试见 [持续运行记录](stability.md)。
双缓冲试验五分钟全程在线,复位、采集错误、通道断开和计数重置均为 0 次。抽取 61 张 JPEG 均完整解码为 640×480 RGB;没有用灰度或降低色深换帧率。最小内部空闲堆 118771 B;实际 JPEG 档位 18–28,温度 58–60°C,未触发减载。
@@ -77,7 +77,7 @@ Windows 客户端使用裁判配置和 `--latency-report <新的NDJSON路径>`
源标记和回显均使用同一客户端 `performance.now()`,无需 ESP 时钟同步。绘制时刻以相邻 rAF 夹定,表中为上下界中点;此前双缓冲样本的区间宽度中位数为 11.4 ms。该范围包括相机取样等待、曝光、采集、无线传输、重组、本地 WebSocket、解码和画布绘制。它是**屏幕标记事件到首次可解码画布回显的估计**,没有测量源/输出面板扫描或像素响应;严格的物理光学端到端延迟需用高速相机复核。未识别帧不成为样本,首次可解码回显可能晚于较早的模糊回显,不能把有效样本分布当作每一帧的延迟。
此前双缓冲试验中,解码完成到画布绘制平均 2.72 ms;标记分析平均 2.02 ms。该分析只在校准模式运行,正常客户端不做画布回读。字段 `decodeToDrawMs` 从解码完成开始,不代表 JPEG 解码耗时。接收间隔、分片组装耗时及发送函数耗时各有独立口径,不能替代本测量。
此前双缓冲试验中,从解码完成到画布绘制平均耗时 2.72 ms,标记分析平均耗时 2.02 ms。标记分析仅在校准模式运行,正常客户端不回读画布。字段 `decodeToDrawMs` 从解码完成时开始计时,不代表 JPEG 解码耗时。接收间隔、分片组装耗时和发送函数耗时各有独立口径,均不能替代本测量。
`scripts/analyze_latency.py` 可按半开区间行号复算 NDJSON。完整结果、参数、行号范围和边界见制品 `LATENCY-RESULT.json`,分布图见 `LATENCY.png`。原始校准记录保存在本机 `target/latency/`,不把画面、私有配置或令牌日志打进制品。
+7 -5
View File
@@ -1,6 +1,6 @@
# 图传性能排查与验证(2026-10-18)
本文保留早期短时对照;当前交付已回退到 15 dBm,功率、距离与发送拥塞分析见 [功率与拥塞记录](power.md),阻塞处理见 [最新帧处理](realtime.md),此前约 30 fps 与回显延迟数据见 [延迟测量](latency.md),更早的验证见 [持续运行记录](stability.md)。下列数字对应当时的传感器时序和网络状态。
本文保留早期短时对照,数字对应当时的传感器时序和网络状态。当前交付已回退到 15 dBm,功率、距离与发送拥塞分析见 [功率与拥塞记录](power.md),阻塞处理见 [最新帧处理](realtime.md)。此前约 30 fps 与回显延迟数据见 [延迟测量](latency.md),更早的验证见 [持续运行记录](stability.md)。
## 环境与结论
@@ -21,7 +21,9 @@
表中 fps = 完整帧数 / 整个测量时长。帧损失包括被替换的不完整帧、超时及帧号缺口;末尾仍在接收的帧不计损失。丢帧率与旧客户端的口径不同,旧版会低估实际丢帧。
无线链路有明显时变,早期其它窗口也出现过约 5~15 fps。表中的数字不构成长期保证,这些配置也不是所有板卡的通用最优参数。前两行使用 CPU0,后两行使用 CPU1;核绑定对照未消除发送拥塞,最终保留显式 CPU1 配置。关闭 AMPDU 的试验几乎无法得到完整帧,已撤回;忙等重试也已撤回。另一次 40 ms 重试预算试验期间,有新客户端加入导致两个订阅者,且信号和画面复杂度变化;该轮约 12.6 fps,不能用于证明重试预算的单项影响,未作为最终配置依据。
无线链路随时间变化明显,早期其他窗口也出现过约 5~15 fps。表中数字不构成长期保证,这些配置也不是所有裁判系统的通用最优参数。前两行使用 CPU0,后两行使用 CPU1;核绑定对照未消除发送拥塞,最终保留显式 CPU1 配置。
关闭 AMPDU 的试验几乎无法得到完整帧,已撤回;忙等重试也已撤回。另一次 40 ms 重试预算试验中,新客户端加入后订阅者增至两个,信号和画面复杂度也有变化。该轮约 12.6 fps,无法证明重试预算的单项影响,因此未作为最终配置依据。
## 最终参数与改动
@@ -36,16 +38,16 @@
约 30 秒窗口中,后端完整帧增量 624、丢帧增量 38,窗口帧损失约 5.74%。界面连接存在,独立观察连接收到 619 张完整 JPEG,通道帧率 20.62 fps,帧间隔 P95 为 103.0 ms,无损坏 JPEG。
这项验证覆盖真实板卡、UDP 接收与重组、GUI 的 WebSocket 连接及二进制视频通道。当时另有 20 项协议测试、83 项客户端核心测试、9 项后端视频通道测试、5 项前端解码队列测试;Windows 类型检查和生产打包也通过。未将整个后端测试集或录制功能记为已验证。
这项验证覆盖真实裁判系统、UDP 接收与重组、GUI 的 WebSocket 连接及二进制视频通道。当时另有 20 项协议测试、83 项客户端核心测试、9 项后端视频通道测试、5 项前端解码队列测试;Windows 类型检查和生产打包也通过。未将整个后端测试集或录制功能记为已验证。
**时间边界:**组装耗时是首末分片到达的差值,帧间隔是相邻完整帧到达的差值,均不是摄像头到屏幕的端到端延迟。未做两端时钟同步或实际屏幕的光学延迟测量,也未自动验证 WebView 最终绘制像素。
组装耗时是首末分片到达的时间差,帧间隔是相邻完整帧到达的时间差,两者均不是摄像头到屏幕的端到端延迟。本次未进行两端时钟同步或实际屏幕的光学延迟测量,也未自动验证 WebView 最终绘制的像素。
## 复测与原始记录
先退出其它图传接收进程,避免增加订阅负载:
```text
python scripts/perf_probe.py <板卡IP> --seconds 60 --json perf.json --save-frame frame.jpg
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 不包含用户运行配置。
+10 -8
View File
@@ -2,7 +2,7 @@
当前交付请求 `WIFI_TX_POWER_DBM=15`,实板驱动回读 **15.00 dBm**。根据用户的距离与拥塞反馈,从 17 dBm 回退;启动和 Wi-Fi 重连后应用同一设置,压缩与温控控制器不自动降低发射功率。三缓冲、VGA 彩色、AE 目标 45、约 20 ms 曝光上限和 PLL 时钟保持不变。
这里的数值是驱动配置的发射功率上限。API 回读不能代替功率计,也不能据此认为所有 PHY 速率下、每个数据包的天线端输出都恰好相同。
这里的数值表示驱动配置的发射功率上限。API 回读不能代替功率计测量,也不能证明所有 PHY 速率下、每个数据包的天线端输出都相同。
## 先前 17 dBm 记录说明了什么
@@ -20,8 +20,8 @@ ESP-IDF 明确说明,UDP 连续 `send/sendto` 可以因底层驱动 TX 缓冲
## 为什么距离改善不明显
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,属于近距离记录,不能拿来证明远距离的上行链路已经改善。
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,属于近距离记录,不能拿来证明远距离的上行链路已经改善。
## 为什么拥塞仍会加重
@@ -29,13 +29,13 @@ ESP-IDF 明确说明,UDP 连续 `send/sendto` 可以因底层驱动 TX 缓冲
提高发射上限不会增加信道带宽或路由器的排队能力。若空口竞争、速率回退或重传使驱动排空变慢,突发提交仍会填满 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 记录也不能证明射频供电完全没有瞬态变化。
提高功率可能通过改变干扰覆盖、射频线性度或供电瞬态,间接影响链路,但本次没有相应测量,尚不能确认“接收端过载”“功放饱和”或“电压跌落”。没有 Brownout 记录,也不能证明射频供电完全没有瞬态变化。
电脑当前通过 **5 GHz / 信道 161 / 802.11ac** 连接,摄像头使用 2.4 GHz。因此不能直接声称视频在同一个 2.4 GHz 信道被路由器重复发送了两次;摄像头上行、AP 转发、电脑接收与 TUN 处理仍可能分别排队,TUN 保持原样。
## 还需区分本地跳帧
17 dBm 第二轮视频区间中,原生接收了 838 张完整 JPEG,而辅助本地 WebSocket 观察器收到 582 张。两者不是同一个指标:后端采用最新帧覆盖和帧龄期限,慢消费者可能主动跳过已完整收到的帧;少收到的本地消息不能全部算成无线丢帧,更不能直接等同于真实屏幕绘制帧率。
17 dBm 第二轮视频区间中,原生端接收了 838 张完整 JPEG,辅助本地 WebSocket 观察器收到 582 张。两者口径不同:后端采用最新帧覆盖和帧龄期限,慢消费者可能主动跳过已完整收到的帧。因此,本地消息少收到的部分不能全部算作无线丢帧,本地接收帧率也不能直接等同于实际屏幕绘制帧率。
## 当前回退验证与后续定位
@@ -43,7 +43,7 @@ ESP-IDF 明确说明,UDP 连续 `send/sendto` 可以因底层驱动 TX 缓冲
本轮只把功率上限改到 15 dBm,其他图传参数与单 UDP 订阅保持不变,做五分钟收流观察;结果由交付包 `POWER-RESULT.json` 记录。之前的 17 dBm 记录保留为 `POWER-HISTORY-tx17.json`,14 dBm 的阻塞处理记录保留在 [最新帧处理](realtime.md)。本轮不重新测量最远断连距离或物理屏幕端到端延迟。
下一项值得单独对照的是**分片突发整形**:固定 15 dBm、曝光和 JPEG 策略,按发送回压逐步比较小批次提交与短暂让出调度,并同时看原生完整帧率、网络丢帧、主动跳帧和帧龄。不能仅通过增大缓冲或延长重试,把丢帧转化为持续排队延迟。若要确认距离瓶颈,需要同步记录远点断连原因、AP 侧板卡 RSSI、MAC 重传和信道忙占比。
下一项值得单独对照的是**分片突发整形**:固定 15 dBm、曝光和 JPEG 策略,按发送回压逐步比较小批次提交与短暂让出调度,并同时看原生完整帧率、网络丢帧、主动跳帧和帧龄。不能仅通过增大缓冲或延长重试,把丢帧转化为持续排队延迟。若要确认距离瓶颈,需要同步记录远点断连原因、AP 侧裁判系统 RSSI、MAC 重传和信道忙占比。
## 蛇形 PCB 天线的握持改善
@@ -54,11 +54,13 @@ ESP-IDF 明确说明,UDP 连续 `send/sendto` 可以因底层驱动 TX 缓冲
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)。
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-RESULT.json`。
当前 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-RESULT.json`。
按时间近似对齐的板端五分钟区间有 731 次发送失败、653 次应用重试、78 次目标帧放弃;温度 56–58°C,RSSI -35 至 -11 dBm。板端区间由五秒采样的结束时间选择,边界并非逐包精确对齐。与此前短测时长、现场无线状态和 JPEG 内容不同,不能将差值归因于功率这一项。
+6 -4
View File
@@ -2,11 +2,13 @@
## 当前交付
当前 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-RESULT.json`。
当前 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-RESULT.json`。
沿用已确认可用的三块 PSRAM 采集缓冲、OV5640 VGA 彩色、约 30 fps PLL 时钟、AE 目标 45/55、约 20 ms 曝光上限及自动增益;Wi-Fi 自动重连和 TUN 兼容订阅保持不变。没有增加待发送 JPEG FIFO,也没有为了消除队列改成单块 DMA 缓冲。
发送程序只持有当前一张完整 JPEG;阻塞恢复后通过有限次数的零等待取帧选择最新有效帧,旧帧立即归还驱动。三块采集内存用于捕获余量,与必须按序播放的三帧队列不同。已有相机队列仍然有限,应用交接会跳过其旧帧。
发送程序一次只持有一张完整 JPEG。阻塞恢复后,通过有限次数的零等待取帧选出最新有效帧,并立即将被替换的旧帧归还驱动。三块采集内存提供捕获余量,并非必须按序播放的三帧队列。相机队列仍有容量上限,应用交接时会跳过其中的旧帧。
完整 JPEG 取得后按 1400 字节载荷分片立即提交,复用一个数据报缓冲,直接读取 PSRAM。当前传感器驱动在 JPEG 结束后交出完整帧,本次没有改成在 DMA 尚未完成时就读取其内容;不完整 JPEG 由接收端整帧丢弃,不进入绘制。
@@ -35,9 +37,9 @@ WIFI_TX_POWER_DBM=15
若要在发送回压时立即放弃本帧,可将 `UDP_RETRY_BUDGET_US=0` 后重编烧录。该模式仍保留三块采集内存和完整 JPEG 校验;这里的“零”指不让出调度重试,不是删除驱动和路由器中的所有缓冲。零重试策略已经主机测试,本次五分钟实机使用默认 8000 μs。
分片间隔也计入 25 ms 检查期限,不再额外延长帧的发送寿命;设置过大的间隔可能使整帧无法在期限内提交。分片在 SDK socket 中已经接受后无法由本策略撤回,底层调度和网络排队也无法由应用层强制抢占。以上为应用层新鲜度策略,不能保证每帧、无线链路或物理屏幕具有硬实时上限。
分片间隔计入 25 ms 检查期限,不额外延长帧的发送寿命;间隔过大可能使整帧无法按期提交。SDK socket 已接受的分片无法由本策略撤回,应用层也无法强制抢占底层调度和网络排队。因此,这些新鲜度策略只能约束应用层,不能为每帧、无线链路或物理屏幕保证硬实时上限。
本地 JPEG 二进制头继续为 16 字节,旧消费者可忽略新增文本消息。新前端连接旧后端时仍可用,但没有原生发布时间,只能按 JS 收到时刻判断本地队列年龄。两端本机发布时间使用同机系统时间;这不是 ESP 与电脑的时钟同步,也不能据此计算无线单向延迟。JS 内部排队同时使用单调时钟检查。
本地 JPEG 二进制头仍为 16 字节,旧消费者可忽略新增的文本消息。新前端也可连接旧后端,但由于没有原生发布时间,只能按 JS 收到消息的时刻判断本地队列年龄。本地通道两端使用同机系统时间记录发布时间;这不涉及 ESP 与电脑的时钟同步,也不能用于计算无线单向延迟。JS 内部排队同时使用单调时钟检查。
## 此前 14 dBm 版本的实机五分钟检查
+4 -4
View File
@@ -4,9 +4,9 @@
## 测试目标与配置
使用 ESP32-S3-N16R8 / OV5640、VGA、8 MB PSRAM、CPU 240 MHz、Windows 原生客户端,保留 Mihomo TUN。每轮只运行一个 UDP 订阅者,串口记录器与本地 WebSocket 观察器不会新增 UDP 推流目标。Windows 到板卡的实际路由走 WLAN;电脑连接 5 GHz、板卡连接 2.4 GHz。
使用 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 分钟。下文保留了亮度与时序调整过程;不同轮次的场景、曝光、压缩和网络状态有变化,接收数字是描述性对照,不能作为严格单因素实验。
优化目标是在保持彩色、内容可辨和较短曝光的同时,尽可能提高完整接收帧率;每轮有效测量为 5 分钟。下文保留亮度与时序调整过程。各轮场景、曝光、压缩和网络状态有所变化,因此接收数据仅供描述性对照,不属于严格的单因素实验。
```dotenv
CAM_RES=vga
@@ -56,7 +56,7 @@ OV5640 驱动的 AE 档位不是 EV 数值:名义零档目标为 55,档位
测量窗口内曝光为 295–295 行;JPEG 档位为 18–22,实际增益 11.00–18.00×。内部堆最小 129455 字节,平均提交耗时 2.60 ms/帧,温度 54–59°C,未触发温度减载。
这次完整接收帧率接近当前可靠彩色时序的约 22.2 fps 上限。亮度、画面内容、JPEG 大小和网络负载在各轮之间变化,不能把全部差异归因于单一控制参数,也不能将五分钟结果作为长期或所有路由器的保证。帧间隔不是摄像头到屏幕的光学延迟。
本轮完整接收帧率接近当前可靠彩色时序的约 22.2 fps 上限。各轮亮度、画面内容、JPEG 大小和网络负载不同,不能把全部差异归因于单一控制参数,也不能用五分钟结果保证长期运行或所有路由器下的表现。帧间隔不是摄像头到屏幕的光学延迟。
本轮固件 SHA-256 为 `e297069e190dcccefbae6d95591703c6848f4e523f910e2c0cb44af91f698936`,当时与交付及板上镜像一致;当前交付以 [项目说明](../../README.md) 和交付包的 `BUILD-INFO.json` 为准。配套 Bootloader、分区表及 App 均已独立读回校验。曲线和完整数据位于制品包内的 `STABILITY.png` / `SCHEDULING-RESULT.json`,不包含相机抽帧或令牌日志。
@@ -113,7 +113,7 @@ USB 恢复后,HTS 1500 的板上采集探针达到 30.49 fps,但白光环境
原计划 30 分钟的灰度 / 15 fps 长测按用户要求提前停止,保留了 **18 个完整分钟窗口**,合计 1080.15 秒:完整帧 15307、丢帧 806,平均 14.17 fps,帧损失 5.00%。板端记录温度约 48–58°C,无复位和采集错误。
早期丢帧上升时温度没有同步上升,温度下降期间也出现过明显发送失败。内存紧张后能够恢复;发送失败与队列回压更符合当前证据。这一轮不计为完成 30 分钟验证,且配置不同,不能与彩色不限帧试验直接作单因素对照。
早期丢帧上升时,温度没有同步上升;温度下降期间也出现过明显发送失败,内存紧张后则能够恢复。因此,现有证据更支持发送失败与队列回压的解释。本轮不能计作完成 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)。
+6 -4
View File
@@ -8,9 +8,9 @@
| --- | --- |
| 提示缺少 `esp-1.97.0.0` | 按上述命令安装指定名称的工具链,重新打开终端后检查版本 |
| `espup` 查询 GitHub 版本返回 403 | 对上述固定版本安装命令追加 `--skip-version-parse`,或稍后重试 |
| 找不到 `ldproxy` / `libclang` | 安装链接工具;Unix 加载 `export-esp.sh`,Windows 重开终端 |
| 找不到 `ldproxy` / `libclang` | 安装链接工具;Unix 加载 `export-esp.sh`;Windows 使用 `scripts/build.ps1`,由脚本将 esp-clang 及其 DLL 依赖目录放到 PATH 前面 |
| ESP-IDF 路径或版本错误 | 清除旧 `IDF_PATH` 或指向 v5.5.5 SDK |
| Windows 路径过长 | 缩短仓库和工具链路径,见开发说明 |
| Windows 报 `Too long output directory` | 构建前设置 `$env:CARGO_TARGET_DIR = 'C:\s3vt'`,再运行 `scripts/build.ps1 -Package`;完整输出路径不得超过 88 字符,见[固件入门](../setup/firmware.md#编译固件) |
| Wi-Fi 连不上 | 核对 2.4 GHz、凭据和认证方式,再重新编译烧录 |
| 相机无输出 / 初始化失败 | 核对板型接线、供电与 Octal PSRAM |
| `NO-EOI`、几帧后停止 | 确认 `CONFIG_CAMERA_PSRAM_DMA=n` |
@@ -20,9 +20,11 @@
| 改 C 组件后无变化 | 清理 `esp-idf-sys` 构建缓存后重编,见开发说明 |
| 主机项目被编为 Xtensa | 显式指定宿主 target,见各子项目说明 |
用 `python scripts/udp_probe.py 10` 可独立检查视频协议;装甲板用 `python scripts/armor_probe.py 15`。这些脚本需要板卡在线,构建通过不代表已完成硬件验证。
`libclang.dll` 文件存在却报 `LoadLibraryExW failed` 时,还需检查它的依赖是否来自同一套 LLVM。仅将目录追加到 PATH 末尾不够;若前面已有其他 LLVM,可能加载到不兼容的同名 DLL。PowerShell 构建入口会将本项目工具链的 esp-clang 目录移到 PATH 前面。
测量帧率和丢帧可运行以下命令,将 IP 换成板卡实际地址。对照测试时先退出桌面客户端,避免额外订阅者增加无线负载:
用 `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
+19 -5
View File
@@ -51,19 +51,33 @@ try {
$espClang = Join-Path $env:USERPROFILE '.rustup\toolchains\esp-1.97.0.0\xtensa-esp32-elf-clang\esp-clang\bin'
if (Test-Path -LiteralPath (Join-Path $espClang 'libclang.dll')) {
$env:LIBCLANG_PATH = $espClang
if (-not ($env:PATH -split ';' | Where-Object { $_ -eq $espClang })) {
$env:PATH = $espClang + ';' + $env:PATH
}
# An existing entry can be behind another LLVM installation. Move it
# to the front so libclang-cpp.dll and the runtime match libclang.dll.
$otherPaths = @($env:PATH -split ';' | Where-Object { $_ -ne $espClang })
$env:PATH = (@($espClang) + $otherPaths) -join ';'
}
$env:CARGO_WORKSPACE_DIR = (Get-Location).Path
$env:ESP_IDF_GLOB_BASE = (Get-Location).Path
$env:ESP_IDF_GLOB_PARTITIONS = 'partitions.csv'
& cargo build --release --locked @CargoArgs
$firmwareElf = $null
if ($Package) {
# Use Cargo's actual artifact path, including --target-dir / --config
# overrides, rather than guessing where this build wrote its ELF.
& cargo build --release --locked --message-format=json-render-diagnostics @CargoArgs | ForEach-Object {
$message = $_ | ConvertFrom-Json
if ($message.reason -eq 'compiler-artifact' -and $message.target.name -eq 'esp32' -and $message.executable) {
$firmwareElf = $message.executable
}
}
} else {
& cargo build --release --locked @CargoArgs
}
if ($LASTEXITCODE -ne 0) { throw "cargo build failed (exit $LASTEXITCODE)." }
if ($Package) {
if (-not $firmwareElf) { throw 'Cargo did not report the esp32 firmware ELF; refusing to package old images.' }
$python = Get-Command python -ErrorAction SilentlyContinue
if ($null -eq $python) {
throw '-Package requires Python 3 on PATH (used by scripts/package_firmware.py).'
@@ -76,7 +90,7 @@ try {
$info = Get-Content -LiteralPath 'version.json' -Raw | ConvertFrom-Json
$versionArgs = @('--version', "v$($info.version)+$($info.build)")
}
& python scripts/package_firmware.py --firmware target/firmware --name 'RMIC 图传固件' @versionArgs
& python scripts/package_firmware.py --firmware target/firmware --elf $firmwareElf --name 'RMIC 图传固件' @versionArgs
if ($LASTEXITCODE -ne 0) { throw "package_firmware.py failed (exit $LASTEXITCODE)." }
}
} finally {
+63 -22
View File
@@ -3,9 +3,9 @@
流程(在 cargo build 之后):
make_bundle.py → bundle.json(沿用现有清单)
package_firmware.py
├─ 读 bundle.json / BUILD-INFO.json
├─ 从 Cargo 产物导出 App,同步 Bootloader / 分区表
├─ 刷新 BUILD-INFO.json,并调用 make_bundle.py 生成 bundle.json
├─ 固定条目顺序与 mtime,生成可复现的 zip
└─ 写 <zip>.sha256
@@ -32,7 +32,7 @@ import subprocess
import sys
import time
import zipfile
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
@@ -59,19 +59,31 @@ def parse_epoch(build: dict) -> int:
return int(time.time())
def export_app_if_stale(repo: Path, firmware: Path, allow_stale: bool) -> None:
"""编译后 App 镜像可能还没导出;优先用 espflash 从最新 ELF 重新导出。"""
elf = repo / "target" / "xtensa-esp32s3-espidf" / "release" / "esp32"
def resolve_elf(repo: Path, explicit: str) -> Path:
if explicit:
return Path(explicit).resolve()
# Cargo metadata also respects build.target-dir in Cargo configuration.
result = subprocess.run(
["cargo", "metadata", "--no-deps", "--format-version", "1"],
cwd=repo, capture_output=True, text=True, check=True,
)
target = Path(json.loads(result.stdout)["target_directory"])
return target / "xtensa-esp32s3-espidf" / "release" / "esp32"
def export_app(repo: Path, firmware: Path, elf: Path, allow_stale: bool) -> None:
"""从选中的 ELF 导出 App,并同步同次构建的配套镜像。"""
app = firmware / "esp32s3-app.bin"
if not elf.is_file():
return
if app.is_file() and app.stat().st_mtime >= elf.stat().st_mtime:
return
if allow_stale and app.is_file():
print(f"警告:缺少 {elf};继续打包现有镜像", file=sys.stderr)
return
raise SystemExit(f"固件 ELF 不存在:{elf}。请先构建,或用 --elf 指定本次构建的 ELF。")
if app.is_file():
print(f"提示:{app} 早于 {elf},将尝试从最新 ELF 重新导出", file=sys.stderr)
else:
print(f"提示:缺少 {app},将尝试从最新 ELF 导出", file=sys.stderr)
companions = [elf.parent / name for name in ("bootloader.bin", "partition-table.bin")]
for source in companions:
if not source.is_file():
raise SystemExit(f"缺少同次构建的配套镜像:{source}")
espflash = repo / "target" / "tools" / "espflash" / "espflash.exe"
if not espflash.is_file():
@@ -82,7 +94,7 @@ def export_app_if_stale(repo: Path, firmware: Path, allow_stale: bool) -> None:
if allow_stale:
print(f"警告:{message};继续打包现有镜像", file=sys.stderr)
return
raise SystemExit(f"{message}。请先导出 target/firmware/esp32s3-app.bin,或加 --allow-stale。")
raise SystemExit(f"{message}。请安装 espflash,或加 --allow-stale 打包现有镜像。")
firmware.mkdir(parents=True, exist_ok=True)
print(f"导出 App:{elf} -> {app}")
@@ -94,6 +106,13 @@ def export_app_if_stale(repo: Path, firmware: Path, allow_stale: bool) -> None:
"esp32s3",
"--format",
"esp-idf",
"--flash-size",
"16mb",
"--flash-mode",
"dio",
"--flash-freq",
"40mhz",
"--skip-update-check",
str(elf),
str(app),
]
@@ -101,28 +120,50 @@ def export_app_if_stale(repo: Path, firmware: Path, allow_stale: bool) -> None:
if result.returncode != 0:
raise SystemExit(f"espflash save-image 失败(exit {result.returncode})")
for source in companions:
destination = firmware / source.name
if source.resolve() != destination.resolve():
shutil.copy2(source, destination)
# Do not attach a previous build's hardware/performance claims to new images.
revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
dirty = subprocess.check_output(
["git", "status", "--porcelain", "--untracked-files=normal"], cwd=repo, text=True,
).strip()
build = {
"packaged_at": datetime.fromtimestamp(elf.stat().st_mtime, timezone.utc).isoformat(),
"source_revision": revision,
"includes_local_changes": bool(dirty),
"target": "xtensa-esp32s3-espidf",
"app_sha256": hashlib.sha256(app.read_bytes()).hexdigest(),
"verification": {"hardware_flashed_and_tested": False},
}
(firmware / "BUILD-INFO.json").write_text(
json.dumps(build, ensure_ascii=False, indent=2) + "\n", encoding="utf-8",
)
def main() -> int:
parser = argparse.ArgumentParser(description="生成官方 .zip 固件包")
parser.add_argument("--firmware", default="target/firmware", help="固件产物目录")
parser.add_argument("--elf", default="", help="本次构建的 ELF;默认通过 cargo metadata 查找 release 产物")
parser.add_argument("--name", default="", help="传递给 make_bundle.py 的名称")
parser.add_argument("--version", default="", help="传递给 make_bundle.py 的版本")
parser.add_argument("--version", default="", help="传递给 make_bundle.py 的版本;默认读取 version.json")
parser.add_argument("--skip-manifest", action="store_true", help="不重新生成 bundle.json")
parser.add_argument("--allow-stale", action="store_true", help="允许打包早于 ELF 的 App 镜像")
parser.add_argument("--allow-stale", action="store_true", help="缺少 ELF 或 espflash 时,允许打包目录中的现有镜像")
parser.add_argument("--output", default="", help="输出 zip 路径;默认 target/firmware/camfw-*.zip")
args = parser.parse_args()
repo = Path(__file__).resolve().parent.parent
if not args.version and (repo / "version.json").is_file():
version = json.loads((repo / "version.json").read_text(encoding="utf-8"))
args.version = f"v{version['version']}+{version['build']}"
firmware = Path(args.firmware)
if not firmware.is_absolute():
firmware = (Path.cwd() / firmware).resolve()
if not firmware.is_dir():
print(f"固件目录不存在:{firmware}", file=sys.stderr)
return 1
try:
export_app_if_stale(repo, firmware, args.allow_stale)
except SystemExit as error:
export_app(repo, firmware, resolve_elf(repo, args.elf), args.allow_stale)
except (SystemExit, OSError, subprocess.CalledProcessError, ValueError) as error:
print(error, file=sys.stderr)
return 1
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""固件打包回归:外部 Cargo 目录、旧镜像隔离、配套文件及 ZIP 完整性。"""
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import zipfile
REPO = Path(__file__).resolve().parent.parent
spec = importlib.util.spec_from_file_location("package_firmware", REPO / "scripts/package_firmware.py")
package = importlib.util.module_from_spec(spec)
spec.loader.exec_module(package)
class PackageFirmwareTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="rmic-package-")
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.release = self.root / "external-target/xtensa-esp32s3-espidf/release"
self.release.mkdir(parents=True)
self.elf = self.release / "esp32"
self.elf.write_bytes(b"current-elf")
(self.release / "bootloader.bin").write_bytes(b"current-bootloader")
(self.release / "partition-table.bin").write_bytes(b"\xff" * 0xC00)
self.output = self.root / "new-firmware"
def test_cargo_metadata_respects_external_target_directory(self):
with patch.dict(os.environ, {"CARGO_TARGET_DIR": str(self.root / "external-target")}):
self.assertEqual(package.resolve_elf(REPO, "").resolve(), self.elf.resolve())
def test_cargo_config_target_directory(self):
# Run real metadata in an isolated crate, without compiling dependencies.
crate = self.root / "crate"
(crate / ".cargo").mkdir(parents=True)
(crate / "src").mkdir()
(crate / "src/main.rs").write_text("fn main() {}\n")
(crate / "Cargo.toml").write_text('[package]\nname="fixture"\nversion="0.1.0"\n')
(crate / ".cargo/config.toml").write_text(
'[build]\ntarget-dir=' + json.dumps(str(self.root / "external-target")) + "\n"
)
with patch.dict(os.environ):
os.environ.pop("CARGO_TARGET_DIR", None)
self.assertEqual(package.resolve_elf(crate, "").resolve(), self.elf.resolve())
def test_missing_elf_does_not_reuse_old_app(self):
self.output.mkdir()
app = self.output / "esp32s3-app.bin"
app.write_bytes(b"old-app")
with self.assertRaisesRegex(SystemExit, "ELF 不存在"):
package.export_app(REPO, self.output, self.release / "missing", False)
self.assertEqual(app.read_bytes(), b"old-app")
def test_missing_companion_stops_before_export(self):
(self.release / "bootloader.bin").unlink()
with patch.object(package.subprocess, "run") as run:
with self.assertRaisesRegex(SystemExit, "配套镜像"):
package.export_app(REPO, self.output, self.elf, False)
run.assert_not_called()
def test_export_failure_does_not_create_archive(self):
with patch.object(package.shutil, "which", return_value=sys.executable):
with patch.object(package.subprocess, "run", return_value=subprocess.CompletedProcess([], 1)):
with self.assertRaisesRegex(SystemExit, "save-image 失败"):
package.export_app(REPO, self.output, self.elf, False)
self.assertEqual(list(self.root.rglob("*.zip")), [])
def test_package_replaces_newer_old_images_and_is_reproducible(self):
self.output.mkdir()
old_app = self.output / "esp32s3-app.bin"
old_app.write_bytes(b"old-app")
os.utime(old_app, (self.elf.stat().st_mtime + 100, self.elf.stat().st_mtime + 100))
(self.output / "BUILD-INFO.json").write_text(
'{"verification":{"hardware_flashed_and_tested":true},"old_test_results":true}'
)
original_run = subprocess.run
def run(command, *args, **kwargs):
if "save-image" in command:
self.assertEqual(Path(command[-2]).resolve(), self.elf.resolve())
Path(command[-1]).write_bytes(b"app-from-current-elf")
return subprocess.CompletedProcess(command, 0)
return original_run(command, *args, **kwargs)
arguments = ["package_firmware.py", "--elf", str(self.elf), "--firmware", str(self.output)]
with patch.object(sys, "argv", arguments), patch.object(package.subprocess, "run", side_effect=run):
with patch.object(package.shutil, "which", return_value=sys.executable):
self.assertEqual(package.main(), 0)
archive = next(self.output.glob("*.zip"))
first = archive.read_bytes()
self.assertEqual(package.main(), 0)
self.assertEqual(archive.read_bytes(), first)
with zipfile.ZipFile(archive) as bundle:
self.assertIsNone(bundle.testzip())
self.assertEqual(len(bundle.namelist()), 5)
self.assertEqual(bundle.read("esp32s3-app.bin"), b"app-from-current-elf")
self.assertEqual(bundle.read("bootloader.bin"), b"current-bootloader")
self.assertEqual(bundle.read("partition-table.bin"), b"\xff" * 0xC00)
manifest = json.loads(bundle.read("bundle.json"))
for filename, key in (("esp32s3-app.bin", "appSha256"),
("partition-table.bin", "partitionTableSha256"),
("bootloader.bin", "bootloaderSha256")):
self.assertEqual(hashlib.sha256(bundle.read(filename)).hexdigest(), manifest[key])
metadata = json.loads(bundle.read("BUILD-INFO.json"))
self.assertFalse(metadata["verification"]["hardware_flashed_and_tested"])
self.assertNotIn("old_test_results", metadata)
digest = hashlib.sha256(first).hexdigest()
self.assertEqual(archive.with_name(archive.name + ".sha256").read_text().split()[0], digest)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -3,9 +3,9 @@
"major": 0,
"minor": 2,
"patch": 2,
"build": 38,
"build": 39,
"protocol": 1,
"date": "2026-10-19",
"date": "2026-10-21",
"baseline": "v0.2.1",
"note": "由 scripts/version.mjs 从 git 历史算出,请勿手改。"
}