fix(packaging): 修复 Windows 安装与固件打包并完善客户端文案
- 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。未执行实机烧录。
@@ -86,10 +86,12 @@
|
|||||||
# 算 —— 只把 LOCALAPPDATA 指走它照样去开原来的路径(报错原文见 run 8 的日志),
|
# 算 —— 只把 LOCALAPPDATA 指走它照样去开原来的路径(报错原文见 run 8 的日志),
|
||||||
# 把 USERPROFILE 指走它才跟着走(本机实测:探针 profile 下立刻出现
|
# 把 USERPROFILE 指走它才跟着走(本机实测:探针 profile 下立刻出现
|
||||||
# all-stores.lock)。
|
# all-stores.lock)。
|
||||||
# 想从根上解决:让 gitea-runner 以**中完整性**启动(在普通终端里跑
|
# 现在 Windows 打包要求 gitea-runner 以**中完整性或更高**启动(在普通终端里跑
|
||||||
# run-runner.ps1,而不是从被沙箱化的低完整性 shell 里拉起来),那样这些重定向
|
# run-runner.ps1,而不是从被沙箱化的低完整性 shell 里拉起来),那样这些重定向
|
||||||
# 就都可以撤掉。这台机器上 runner 由计划任务 GiteaRunner-Windows(admin /
|
# 就都可以撤掉。这台机器上 runner 由计划任务 GiteaRunner-Windows(admin /
|
||||||
# Limited)拉起,但进程链上的完整性已经落到了 Low。
|
# Limited)拉起,但进程链上的完整性已经落到了 Low。仅重定向目录仍会产生带 Low
|
||||||
|
# 标签的 exe;用户运行它时也会变成 Low,NSIS 在执行 .onInit 前就无法写 TEMP。
|
||||||
|
# job 开始时检查进程级别;打包前修正输出目录与 exe 标签;上传前只读复查。
|
||||||
#
|
#
|
||||||
# ── 三个平台都显式写 --target,这不是啰嗦 ────────────────────────────────────
|
# ── 三个平台都显式写 --target,这不是啰嗦 ────────────────────────────────────
|
||||||
# desktop/.cargo/config.toml 里钉着 `target = "aarch64-apple-darwin"`(本机开发用;
|
# desktop/.cargo/config.toml 里钉着 `target = "aarch64-apple-darwin"`(本机开发用;
|
||||||
@@ -368,12 +370,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TARGET: x86_64-pc-windows-msvc
|
TARGET: x86_64-pc-windows-msvc
|
||||||
CARGO_BUILD_TARGET: x86_64-pc-windows-msvc
|
CARGO_BUILD_TARGET: x86_64-pc-windows-msvc
|
||||||
# 见文件头第 5 条:这台 runner 的 job 跑在**低完整性**(Low Mandatory Level,
|
# 这些隔离缓存路径沿用原 runner 配置。见文件头第 5 条:此前通过重定向
|
||||||
# S-1-16-4096)上,而 Windows 不允许低完整性往中完整性写 —— 用户目录全中招:
|
# 目录绕过 Low 进程的写入限制,但产物也会带 Low 标签。现在 job 开始时
|
||||||
# ~/.rustup、~/.cargo、%TEMP%、%LOCALAPPDATA% 下新建任何东西都是
|
# 要求进程达到 Medium;目录重定向仅用于缓存隔离,不能替代完整性检查。
|
||||||
# "Access denied (os error 5)",ACL 再宽松也没用(没有 Deny ACE,是完整性
|
|
||||||
# 级别拦的)。所以 CI 用到的每个"家目录"都挪到 runner 自己的工作目录下,
|
|
||||||
# 那几处在 job 内的写探测里实测可写。换一台 Windows runner 时改这几行。
|
|
||||||
RUSTUP_HOME: C:\Users\admin\runner\windows\cache\ci-rustup
|
RUSTUP_HOME: C:\Users\admin\runner\windows\cache\ci-rustup
|
||||||
CARGO_HOME: C:\Users\admin\runner\windows\cache\ci-cargo
|
CARGO_HOME: C:\Users\admin\runner\windows\cache\ci-cargo
|
||||||
# LOCALAPPDATA/APPDATA 也得改:pnpm 12 的 store 锁与 cache/state 都挂在
|
# LOCALAPPDATA/APPDATA 也得改:pnpm 12 的 store 锁与 cache/state 都挂在
|
||||||
@@ -391,6 +390,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 检查 Windows 打包进程完整性
|
||||||
|
shell: pwsh
|
||||||
|
run: ./desktop/scripts/prepare-windows-bundle.ps1 -CheckProcessOnly
|
||||||
|
|
||||||
# 宿主模式的 Windows runner 上,PATH 里的 `bash` 是 C:\Windows\System32\bash.exe
|
# 宿主模式的 Windows runner 上,PATH 里的 `bash` 是 C:\Windows\System32\bash.exe
|
||||||
# —— WSL 的启动器,而 WSL 在 runner 账户下起不来,报 E_ACCESSDENIED(日志里是
|
# —— WSL 的启动器,而 WSL 在 runner 账户下起不来,报 E_ACCESSDENIED(日志里是
|
||||||
# 一串 GBK 被当 UTF-8 解出来的乱码 + https://github.com/microsoft/WSL/issues)。
|
# 一串 GBK 被当 UTF-8 解出来的乱码 + https://github.com/microsoft/WSL/issues)。
|
||||||
@@ -497,6 +500,16 @@ jobs:
|
|||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
run: pnpm tauri build --target ${{ env.TARGET }} --bundles nsis
|
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: 上传产物
|
- name: 上传产物
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
**黑龙江大学 602 实验室 · Simba Robotics**
|
**黑龙江大学 602 实验室 · Simba Robotics**
|
||||||
|
|
||||||
面向机器人对抗赛的局域网图传与装甲板系统。ESP32-S3 采集 OV5640 的硬件 JPEG,经 UDP 发送到桌面端;ESP32-C3 上报装甲板血量,桌面端提供操作手视野、裁判双路监看、截图录制和 USB 固件维护。
|
本项目面向机器人对抗赛,提供局域网图传与装甲板系统。ESP32-S3 采集 OV5640 的硬件 JPEG,经 UDP 发送到桌面端;ESP32-C3 上报装甲板血量。桌面端提供操作手视野、裁判双路监看、截图录制和 USB 固件维护。
|
||||||
|
|
||||||
[](docs/setup/firmware.md)
|
[](docs/setup/firmware.md)
|
||||||
[](docs/hardware/armor.md)
|
[](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 `5600` | Hello、Subscribe 与 JPEG 分片 |
|
||||||
| 装甲板上报 | UDP `5601` | 每 200 ms 广播四路血量 |
|
| 装甲板上报 | UDP `5601` | 每 200 ms 广播四路血量 |
|
||||||
| 本地界面通道 | 回环 WebSocket + Tauri IPC | JPEG 由 WebView 解码,网络接收线程不做视频解码 |
|
| 本地界面通道 | 回环 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;图传槽位可手动指定 |
|
| 裁判端 | 双路画面、队名编辑、装甲板发现、槽位分配、截图、录制与回放 | 录制需要 ffmpeg;图传槽位可手动指定 |
|
||||||
| 装甲板 | 四路受击检测与灯带、整车血量、UDP 上报 | 初始 100 血,每次受击扣 3 点;队伍号在编译期选择 |
|
| 装甲板 | 四路受击检测与灯带、整车血量、UDP 上报 | 初始 100 血,每次受击扣 3 点;队伍号在编译期选择 |
|
||||||
| 固件维护 | USB 读取设备、检查固件包、选择候选槽位、备份和写后回读 | App 写入与回读已有实机记录;完整分区切换尚未验证 |
|
| 固件维护 | USB 读取设备、检查固件包、选择候选槽位、备份和写后回读 | App 写入与回读已有实机记录;完整分区切换尚未验证 |
|
||||||
|
|
||||||
发送端在有限预算内提交当前帧,超时后转向新帧;本地视频通道与 WebView 会放弃滞留超过 100 ms 的旧帧。这个期限约束的是本机积压,**不代表相机到屏幕的端到端延迟**。
|
发送端在限定时间内提交当前帧,超时后转向新帧;本地视频通道与 WebView 会放弃滞留超过 100 ms 的旧帧。这个期限只约束本机积压,**不代表相机到屏幕的端到端延迟**。
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> 固件维护会写入设备 Flash。使用前核对芯片、分区和备份,并阅读[已验证与未验证范围](docs/maintenance/firmware.md#已验证--未验证)。ESP32-S3 与 ESP32-C3 的固件不能混用。
|
> 固件维护会写入设备 Flash。使用前核对芯片、分区和备份,并阅读[已验证与未验证范围](docs/maintenance/firmware.md#已验证--未验证)。ESP32-S3 与 ESP32-C3 的固件不能混用。
|
||||||
@@ -86,7 +86,7 @@ flowchart LR
|
|||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 无板卡:先看界面
|
### 1. 无裁判系统:先看界面
|
||||||
|
|
||||||
安装 Node.js 和 pnpm 后,在仓库根目录执行:
|
安装 Node.js 和 pnpm 后,在仓库根目录执行:
|
||||||
|
|
||||||
@@ -96,12 +96,12 @@ pnpm install --frozen-lockfile
|
|||||||
pnpm dev
|
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]
|
> [!TIP]
|
||||||
> 检查首次启动流程时,可使用 `?preview=first-run` 和 `?preview=password`。需要测试实际接收、重组和录制路径时,使用原生客户端的 `--demo` 模式;两种方式显示的都是合成数据。
|
> 检查首次启动流程时,可使用 `?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。
|
先按[客户端说明](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>
|
</details>
|
||||||
|
|
||||||
首次启动选择用户端或裁判端。用户端需要设置现场密码并绑定本车板卡;裁判端自动分配图传槽位,装甲板按上报队伍分配。对应关系不符时,在设置中手动指定。
|
首次启动时选择用户端或裁判端。用户端需要设置现场密码并绑定本车裁判系统;裁判端自动分配图传槽位,并按装甲板上报的队伍分配装甲槽位。对应关系不符时,可在设置中手动指定。
|
||||||
|
|
||||||
### 3. 编译图传或装甲固件
|
### 3. 编译图传或装甲固件
|
||||||
|
|
||||||
@@ -162,9 +162,12 @@ if (-not (Test-Path -LiteralPath .env)) {
|
|||||||
Copy-Item -LiteralPath .env.example -Destination .env
|
Copy-Item -LiteralPath .env.example -Destination .env
|
||||||
}
|
}
|
||||||
# 用编辑器填写 .env 中的 Wi-Fi 配置后再构建。
|
# 用编辑器填写 .env 中的 Wi-Fi 配置后再构建。
|
||||||
|
$env:CARGO_TARGET_DIR = 'C:\s3vt'
|
||||||
.\scripts\build.ps1 -Package
|
.\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):
|
Linux / macOS(Bash):
|
||||||
|
|
||||||
```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=[]'
|
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>
|
</details>
|
||||||
|
|
||||||
@@ -261,7 +264,7 @@ node scripts/version.mjs --check
|
|||||||
|
|
||||||
## 文档与后续工作
|
## 文档与后续工作
|
||||||
|
|
||||||
文档沿用 Firefox 仓库的分类与索引思路:根目录保留项目入口和协作约定,详细说明集中到 [`docs/index.md`](docs/index.md)。
|
文档参照 Firefox 仓库的分类与索引方式组织。根目录保留项目入口和协作约定,详细说明从 [`docs/index.md`](docs/index.md) 查阅。
|
||||||
|
|
||||||
| 要做什么 | 从哪里开始 |
|
| 要做什么 | 从哪里开始 |
|
||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
|
|||||||
@@ -18,3 +18,6 @@ dist/
|
|||||||
|
|
||||||
# 测试运行时被 XDG_CONFIG_HOME 相对路径写出来的配置,不是仓库内容。
|
# 测试运行时被 XDG_CONFIG_HOME 相对路径写出来的配置,不是仓库内容。
|
||||||
src-tauri/.config/
|
src-tauri/.config/
|
||||||
|
|
||||||
|
# Installer hotspot guidance is local configuration.
|
||||||
|
/src-tauri/installer/network.local.nsh
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)."
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 917 B |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,9 +1,9 @@
|
|||||||
<!-- RMIC::2026 应用图标(矢量源)。
|
<!-- RMIC::2026 应用图标(矢量源)。
|
||||||
由 https://simba-robotics.com/logo.svg 生成:把原图 1024 画布的 viewBox 收窄、标志居中,
|
由 https://simba-robotics.com/logo.svg 生成:把原图 1024 画布的 viewBox 收窄、标志居中,
|
||||||
底色用界面的 --deep。位图(PNG / ICO / ICNS)由这一份光栅化得到;
|
底色沿用界面的深色背景,圆角之外透明。位图(PNG / ICO / ICNS)由这一份光栅化得到;
|
||||||
小尺寸另有一版更大的缩放比(见 docs/desktop/client.md 的「队标」一节)。 -->
|
小尺寸另有一版更大的缩放比(见 docs/desktop/client.md 的「队标」一节)。 -->
|
||||||
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93333" xmlns="http://www.w3.org/2000/svg">
|
<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" fill="#080b12"/>
|
<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 transform="translate(135.4667 135.4667) scale(0.82) translate(-135.28 -136.22)">
|
||||||
<g
|
<g
|
||||||
inkscape:label="图层 1"
|
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} "同时删除应用数据"
|
||||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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 |
@@ -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
|
||||||
@@ -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 "现场热点密码"
|
||||||
|
After Width: | Height: | Size: 650 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -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 |
@@ -40,8 +40,8 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"shortDescription": "RMIC::2026 —— 视野链路终端",
|
"shortDescription": "RMIC::2026 裁判系统客户端",
|
||||||
"longDescription": "RMIC::2026 视野链路终端。用户端:一路画面 + 装甲条与瞄准器。裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。",
|
"longDescription": "RMIC::2026 裁判系统客户端。用户端:一路画面 + 装甲条与瞄准器。裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。",
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ export function Boot({ milestones, progress, fatal, leaving = false }: Props) {
|
|||||||
<span className="boot__name">
|
<span className="boot__name">
|
||||||
RMIC<span className="boot__slash">::</span>2026
|
RMIC<span className="boot__slash">::</span>2026
|
||||||
</span>
|
</span>
|
||||||
<span className="boot__sub label">视野链路终端 · SIMBA ROBOTICS</span>
|
<span className="boot__sub label">裁判系统客户端 · SIMBA ROBOTICS</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ export function SettingsPanel({
|
|||||||
|
|
||||||
{cameraOptions.length === 0 && (
|
{cameraOptions.length === 0 && (
|
||||||
<p className="sect__warn">
|
<p className="sect__warn">
|
||||||
尚未发现图传板。板卡每 2 秒广播一次;请检查供电,确认与本机处于
|
尚未发现图传板。裁判系统每 2 秒广播一次;请检查供电,确认与本机处于
|
||||||
同一网络后稍候。
|
同一网络后稍候。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function TopBar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tag tag--warn tag--button"
|
className="tag tag--warn tag--button"
|
||||||
title="画面与血量由本机合成,不代表现场板卡状态。"
|
title="画面与血量由本机合成,不代表现场裁判系统状态。"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
pushNote(
|
pushNote(
|
||||||
"演示模式:画面与血量由本机合成。截图和验证时请注明为演示数据。",
|
"演示模式:画面与血量由本机合成。截图和验证时请注明为演示数据。",
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ export const BUILD = {
|
|||||||
minor: 2,
|
minor: 2,
|
||||||
patch: 2,
|
patch: 2,
|
||||||
/** git 提交数,单调递增。 */
|
/** git 提交数,单调递增。 */
|
||||||
build: 38,
|
build: 39,
|
||||||
/** 线路协议版本。与固件自报的那个数直接比对。 */
|
/** 线路协议版本。与固件自报的那个数直接比对。 */
|
||||||
protocol: 1,
|
protocol: 1,
|
||||||
/** 生成时的提交日期(`YYYY-MM-DD`)。 */
|
/** 生成时的提交日期(`YYYY-MM-DD`)。 */
|
||||||
date: "2026-10-19",
|
date: "2026-10-21",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** `"0.2.0+57"` —— 界面上放得下的那一行。 */
|
/** `"0.2.0+57"` —— 界面上放得下的那一行。 */
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
|
|||||||
camera={camera}
|
camera={camera}
|
||||||
label={`CAM · ${cameraText}`}
|
label={`CAM · ${cameraText}`}
|
||||||
emptyText={
|
emptyText={
|
||||||
stats?.hasFrames ? "" : stats?.online ? "已连接,等待画面…" : "离线:尚未收到板卡广播"
|
stats?.hasFrames ? "" : stats?.online ? "已连接,等待画面…" : "离线:尚未收到裁判系统广播"
|
||||||
}
|
}
|
||||||
mirrored={snapshot.mirrored}
|
mirrored={snapshot.mirrored}
|
||||||
crosshair={hud.crosshair}
|
crosshair={hud.crosshair}
|
||||||
@@ -179,7 +179,7 @@ export function TeamPage({ snapshot, hud, onOpenSettings, calibrating, onCalibra
|
|||||||
<div className="fwfault" role="alert">
|
<div className="fwfault" role="alert">
|
||||||
<span className="fwfault__tag label">固件不匹配</span>
|
<span className="fwfault__tag label">固件不匹配</span>
|
||||||
<span className="fwfault__text">
|
<span className="fwfault__text">
|
||||||
板卡固件版本为 <b>{firmwareLabel(boundCamera)}</b>、线路协议{" "}
|
裁判系统固件版本为 <b>{firmwareLabel(boundCamera)}</b>、线路协议{" "}
|
||||||
<b>{boundCamera.protocol}</b>;本客户端使用协议 <b>{BUILD.protocol}</b>。
|
<b>{boundCamera.protocol}</b>;本客户端使用协议 <b>{BUILD.protocol}</b>。
|
||||||
画面无法接收。请烧录协议匹配的固件,或使用对应版本的客户端。
|
画面无法接收。请烧录协议匹配的固件,或使用对应版本的客户端。
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
| `immersive` | F11 | chrome 隐藏;指针移到上沿 8px 时 TopBar 150ms 淡入,3 s 后淡出;指针静止 2 s 隐藏光标 |
|
| `immersive` | F11 | chrome 隐藏;指针移到上沿 8px 时 TopBar 150ms 淡入,3 s 后淡出;指针静止 2 s 隐藏光标 |
|
||||||
| `broadcast` | 视图切换 `Ctrl+Shift+B` | 只渲染 `BroadcastStage`;禁用设置/维护/发现列表;不渲染任何 IP/token |
|
| `broadcast` | 视图切换 `Ctrl+Shift+B` | 只渲染 `BroadcastStage`;禁用设置/维护/发现列表;不渲染任何 IP/token |
|
||||||
|
|
||||||
结构落点:`App.tsx` 里现有的门禁分支(致命错误 / 等待快照 / 身份选择 / 设密码)不动,只在最后一段把 `TeamPage | RefereePage` 包进 `AppShell`,并新增 `BroadcastPage` 分支。
|
在 `App.tsx` 中保留现有门禁分支(致命错误 / 等待快照 / 身份选择 / 设密码),只在最后一段将 `TeamPage | RefereePage` 放入 `AppShell`,并新增 `BroadcastPage` 分支。
|
||||||
|
|
||||||
### 用户端 `TeamPage`(操作手视角)
|
### 用户端 `TeamPage`(操作手视角)
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
### 裁判端 `RefereePage`
|
### 裁判端 `RefereePage`
|
||||||
|
|
||||||
三区 + 一条比赛条:
|
裁判端分为三区,另设一条比赛条:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌ TopBar ────────────────────────────────────────────────────────────────┐
|
┌ TopBar ────────────────────────────────────────────────────────────────┐
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
2. `referee-stage` 保持 `flex: 1`,新增的 `MatchBar` 占固定高度;下方控制台采用可拖拽高度(提议默认 240px,最小 120,最大 45vh);加一行比赛条后,900px 高的窗口约剩 560px 用于画面。
|
2. `referee-stage` 保持 `flex: 1`,新增的 `MatchBar` 占固定高度;下方控制台采用可拖拽高度(提议默认 240px,最小 120,最大 45vh);加一行比赛条后,900px 高的窗口约剩 560px 用于画面。
|
||||||
3. `ArmorTable` 改为两队并列(左红右蓝),每行左侧加 5 格迷你血条(与 HUD 同源,便于核对);主装甲板加 `主` 徽标;离线行 `opacity .55`(保留);`reportsAllPlates=false` 时显示"仅报 2 块"——规则允许 2 或 4 块装甲板。
|
3. `ArmorTable` 改为两队并列(左红右蓝),每行左侧加 5 格迷你血条(与 HUD 同源,便于核对);主装甲板加 `主` 徽标;离线行 `opacity .55`(保留);`reportsAllPlates=false` 时显示"仅报 2 块"——规则允许 2 或 4 块装甲板。
|
||||||
4. 新增 `EventPanel`(Dock 第 3 个页签):命中/暂停/判负/录制开始/结束的时间轴列表,可导出。
|
4. 新增 `EventPanel`(Dock 第 3 个页签):命中/暂停/判负/录制开始/结束的时间轴列表,可导出。
|
||||||
5. `MediaPanel` 保留全部功能,新增两个东西:按局分组(`matchId` 目录)与 "回放本局"(双路同步 + 事件定位)。
|
5. `MediaPanel` 保留全部功能,新增按局分组(`matchId` 目录)与“回放本局”(双路同步 + 事件定位)两项功能。
|
||||||
6. 队名编辑保留(现在是内联输入),但改成"点击铅笔 → 行内编辑 → Enter 保存 / Esc 取消",避免一直占一行高。
|
6. 队名编辑保留(现在是内联输入),但改成"点击铅笔 → 行内编辑 → Enter 保存 / Esc 取消",避免一直占一行高。
|
||||||
|
|
||||||
### 直播 `BroadcastPage`(新)
|
### 直播 `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}` 时可用。
|
入口:`MatchBar` 右侧 `⚑ 判负`,或 `Ctrl/Cmd+Shift+F`。只在裁判端、`phase ∈ {running, paused}` 时可用。
|
||||||
|
|
||||||
三步,不可合并
|
以下三步不可合并:
|
||||||
|
|
||||||
```
|
```
|
||||||
① 点按 → ForfeitDialog
|
① 点按 → ForfeitDialog
|
||||||
@@ -144,7 +144,7 @@ match_end { reason: Forfeit { loser: 0|1, kind: ForfeitKind, note: Option<String
|
|||||||
|
|
||||||
- 状态 → `Ended { by: Referee, reason, at, actor }`,比分按规则 +1 给胜方。
|
- 状态 → `Ended { by: Referee, reason, at, actor }`,比分按规则 +1 给胜方。
|
||||||
- 用户端若已同步(见 R2):显示 `JudgedOverlay`「本局判负」;未同步则只影响裁判端与直播。
|
- 用户端若已同步(见 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`。
|
- 审计:事件 `{ 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 }`,在历史里留下"更正"两条记录(不删除原记录)。
|
- 撤销:`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 像素 | 内容 |
|
| 区域 | 距边距 | 1080p 像素 | 内容 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
@@ -328,7 +328,7 @@ z6 GuideLayer 安全区参考线(仅预览,输出时 display:none
|
|||||||
|
|
||||||
- `BroadcastStage` 用 1920×1080 的绝对坐标系 + `transform: scale(min(vw/1920, vh/1080))`,`transform-origin: top left`。这样所有设计稿坐标直接写进 CSS,无需换算为百分比。
|
- `BroadcastStage` 用 1920×1080 的绝对坐标系 + `transform: scale(min(vw/1920, vh/1080))`,`transform-origin: top left`。这样所有设计稿坐标直接写进 CSS,无需换算为百分比。
|
||||||
- 参考线由 `GuideLayer` 画(`outline: 1px dashed`),受 `props.guides` 控制;全屏/输出时自动关闭。
|
- 参考线由 `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` 铺满,不做透明。
|
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% 噪点。
|
3. 纯黑编码:`#000` 在 H.264 里容易 banding,用 `#07080a` + 2% 噪点。
|
||||||
4. OBS 窗口采集会采到鼠标:全屏时 `cursor: none`(`immersive` 状态负责)。
|
4. OBS 窗口采集会采到鼠标:全屏时 `cursor: none`(`immersive` 状态负责)。
|
||||||
5. 窗口标题:直播窗口标题里不要出现队名/端口等内部信息(`setTitle` 现在会写身份;broadcast 视图下改成 `cam-desktop — 直播`)。
|
5. 窗口标题:直播窗口标题里不要出现队名/端口等内部信息(`setTitle` 现在会写身份;broadcast 视图下改成 `cam-desktop — 直播`)。
|
||||||
@@ -350,9 +350,9 @@ z6 GuideLayer 安全区参考线(仅预览,输出时 display:none
|
|||||||
### 直播视图的安全边界(必须做)
|
### 直播视图的安全边界(必须做)
|
||||||
|
|
||||||
- `broadcast` 视图不渲染:设置按钮、维护入口、`cameras[].addr`(IP)、`stream_info.url`(token)、密码相关 UI、装甲板 id 全文(只显示队名)。
|
- `broadcast` 视图不渲染:设置按钮、维护入口、`cameras[].addr`(IP)、`stream_info.url`(token)、密码相关 UI、装甲板 id 全文(只显示队名)。
|
||||||
- 所有管理命令在广播态禁用(不是隐藏,是 `disabled` + `aria-hidden`,防止误触与 Tab 聚焦)。
|
- 广播态下需禁用所有管理命令,使用 `disabled` + `aria-hidden` 防止误触与 Tab 聚焦,不能只隐藏入口。
|
||||||
- 演示模式(`demo`)在直播里显示红色角标"演示画面",与现有 `tag--demo` 同一语义(现在只在 TopBar,直播态必须另有一处)。
|
- 演示模式(`demo`)在直播里显示红色角标"演示画面",与现有 `tag--demo` 同一语义(现在只在 TopBar,直播态必须另有一处)。
|
||||||
- 断流时 `StandbyCard` 明确写"信号中断 · 正在重连",不显示上一帧冻结画面冒充直播(冻结画面会被观众当成"画面卡住")。
|
- 断流时,`StandbyCard` 显示“信号中断 · 正在重连”,不显示上一帧冻结画面,以免观众将其误认为直播卡顿。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -377,11 +377,11 @@ z6 GuideLayer 安全区参考线(仅预览,输出时 display:none
|
|||||||
|
|
||||||
### 对客户端的影响
|
### 对客户端的影响
|
||||||
|
|
||||||
1. 血量必须同时有数字和格子。100 点 / 5 格 = 20 点/格,而单发只扣 3 点——一格的粒度相当于 6.7 发。只给格子,操作手无法判断"再中两发就死"。→ 保留 `showNumbers` 默认开启,且数字 ≥ 18px。
|
1. 血量需同时显示数字和格子。100 点 / 5 格 = 20 点/格,单发只扣 3 点,一格相当于 6.7 发;仅凭格子,操作手无法判断再中两发是否会归零。因此保留 `showNumbers` 默认开启,且数字 ≥ 18px。
|
||||||
2. 100 点 = 34 发(33 发后剩 1 点)。裁判端可以显示"已中 33 发"这类辅助读数(`100 - hp) / 3`),但不要把它当成权威值——静默期内的连击会被合并。
|
2. 100 点 = 34 发(33 发后剩 1 点)。裁判端可显示“已中 33 发”等辅助读数(`100 - hp) / 3`),但静默期内的连击会合并,不能将该读数作为权威值。
|
||||||
3. 100 ms 静默期决定命中动效下限:同一次攻击可能在 4 块板上各上报一次,`HealthTracker` 按血量差派生,已经能合并;UI 侧不要为每个 board 事件各弹一次伤害数字(否则一次命中会闪 4 下)。
|
3. 100 ms 静默期决定命中动效下限:同一次攻击可能在 4 块板上各上报一次,`HealthTracker` 按血量差派生,已经能合并;UI 侧不要为每个 board 事件各弹一次伤害数字(否则一次命中会闪 4 下)。
|
||||||
4. 装甲板 2 块也要能显示:`ArmorBoardDto.reportsAllPlates` / `platesSeen` 已存在,界面在 `platesSeen < 4` 时显示"仅报 N 块",不要显示 4 个空槽让人以为板子坏了。
|
4. 装甲板 2 块也要能显示:`ArmorBoardDto.reportsAllPlates` / `platesSeen` 已存在,界面在 `platesSeen < 4` 时显示"仅报 N 块",不要显示 4 个空槽让人以为板子坏了。
|
||||||
5. "最快开始移动"没有数据源。图传只给画面,协议里没有位移/速度;因此这条规则只能人工判定(见“计时 / 比分”)。不要试图从画面里推算。
|
5. 目前没有“最快开始移动”的数据源。图传只提供画面,协议不包含位移或速度,因此只能人工判定(见“计时 / 比分”),不从画面推算。
|
||||||
6. 赛前 3 分钟是"确认客户端连上了"的窗口,所以用户端需要一个准备检查卡:图传在线、装甲板在线、帧率、最近一帧年龄、镜像是否正确。这张卡在 `Ready` 阶段自动出现,`Running` 后消失。
|
6. 赛前 3 分钟是"确认客户端连上了"的窗口,所以用户端需要一个准备检查卡:图传在线、装甲板在线、帧率、最近一帧年龄、镜像是否正确。这张卡在 `Ready` 阶段自动出现,`Running` 后消失。
|
||||||
7. 红蓝配色固定:队号 0 = 红、1 = 蓝,与部署点/装甲板灯色一致(现有 `teamColor` 已是这个语义,只改色值)。
|
7. 红蓝配色固定:队号 0 = 红、1 = 蓝,与部署点/装甲板灯色一致(现有 `teamColor` 已是这个语义,只改色值)。
|
||||||
|
|
||||||
@@ -439,7 +439,7 @@ z6 GuideLayer 安全区参考线(仅预览,输出时 display:none
|
|||||||
| 设备故障(图传中断)导致无法比赛 | 人工裁定:技术暂停 / 判负 / 重赛 | 主裁判 |
|
| 设备故障(图传中断)导致无法比赛 | 人工裁定:技术暂停 / 判负 / 重赛 | 主裁判 |
|
||||||
| 技术暂停超时(>5 分钟) | 提示超时,不自动判负 | 主裁判 |
|
| 技术暂停超时(>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` 分支(不改相机订阅逻辑)。
|
- 裁判端以 `REFEREE_ID` 广播到 `5601`;用户端在装甲板监听里增加一个 `MsgType::MatchControl` 分支(不改相机订阅逻辑)。
|
||||||
- 安全:局域网里任何人都能发 UDP,所以必须带共享码派生 MAC(检录时裁判端生成 6 位码,双方在客户端输入),否则队伍可以伪造"对方判负"。
|
- 安全:局域网内任何人都能发送 UDP,因此消息必须携带共享码派生的 MAC。检录时由裁判端生成 6 位码,双方在客户端输入;否则队伍可以伪造对方判负消息。
|
||||||
- 固件兼容性待验证:装甲板固件只认 `MsgType::ArmorHealth`,未知类型按现有 `decode_header` 逻辑返回 `None` 被忽略;但上线前必须在实机上验证"未知 MsgType 不会让板子异常"。
|
- 固件兼容性待验证:装甲板固件只认 `MsgType::ArmorHealth`,未知类型按现有 `decode_header` 逻辑返回 `None` 被忽略;但上线前必须在实机上验证"未知 MsgType 不会让板子异常"。
|
||||||
|
|
||||||
### 需要用户确认的问题
|
### 需要用户确认的问题
|
||||||
@@ -527,7 +527,7 @@ pub enum MsgType { Video = 1, Subscribe = 2, Hello = 3, Heartbeat = 4, ArmorHeal
|
|||||||
## 附录 C:范围
|
## 附录 C:范围
|
||||||
|
|
||||||
1. 不做网络 OTA / 设备自升级(与[固件维护](../maintenance/firmware.md) 的边界一致)。
|
1. 不做网络 OTA / 设备自升级(与[固件维护](../maintenance/firmware.md) 的边界一致)。
|
||||||
2. 不做修改固件源码;打包只组装已有产物。
|
2. 不修改固件源码,打包只组装已有产物。
|
||||||
3. 不做从画面推算"移动先后"、命中位置、弹道等规则数据。
|
3. 不做从画面推算"移动先后"、命中位置、弹道等规则数据。
|
||||||
4. 不做直播的透明背景/虚拟摄像头/NDI。
|
4. 不做直播的透明背景/虚拟摄像头/NDI。
|
||||||
5. 不做多语言(中文界面为唯一语言,符合约束)。
|
5. 不做多语言(中文界面为唯一语言,符合约束)。
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# RMIC::2026
|
# RMIC::2026
|
||||||
|
|
||||||
黑龙江大学 602 实验室 · Simba Robotics 的视野链路桌面端。同一进程提供用户端与裁判端两种界面。
|
黑龙江大学 602 实验室 · Simba Robotics 的裁判系统客户端。同一进程提供用户端与裁判端两种界面。
|
||||||
|
|
||||||
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 用户端(`team`) | 一路 | **双方**(左上角自己 / 右上角对手) | 自己那块 | **要,改动需密码** |
|
| 用户端(`team`) | 一路 | **双方**(左上角自己 / 右上角对手) | 自己那块 | **要,改动需密码** |
|
||||||
| 裁判端(`referee`) | 两路并排 | 双方(各自贴画面角上,**队名可改**) | 发现列表 + 槽位 | 不需要 |
|
| 裁判端(`referee`) | 两路并排 | 双方(各自贴画面角上,**队名可改**) | 发现列表 + 槽位 | 不需要 |
|
||||||
|
|
||||||
前端使用 WebView 中的 React,以 canvas 绘制画面;后端使用 Rust。网络接收、重组、发现、配置、密码和录制均已重写为可单测的模块。
|
前端在 WebView 中运行 React,以 canvas 绘制画面;后端使用 Rust。网络接收、重组、发现、配置、密码和录制均已重写为独立模块,可分别运行单元测试。
|
||||||
|
|
||||||
> 显示名是 `RMIC::2026`;安装包和可执行文件使用 `RMIC-2026`,因为 Windows 文件名不允许 `:`。配置、录制与日志沿用 `cam-desktop` 目录,以保留已有密码哈希与绑定,见“配置”。
|
> 显示名是 `RMIC::2026`;安装包和可执行文件使用 `RMIC-2026`,因为 Windows 文件名不允许 `:`。配置、录制与日志沿用 `cam-desktop` 目录,以保留已有密码哈希与绑定,见“配置”。
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ desktop/
|
|||||||
|
|
||||||
红色用于需要注意的状态。开启的开关为白色,滑杆填充为钢灰色,分段控件以底部 2px 线标记选中,分区编号为灰色,顶栏不加装饰强调线。
|
红色用于需要注意的状态。开启的开关为白色,滑杆填充为钢灰色,分段控件以底部 2px 线标记选中,分区编号为灰色,顶栏不加装饰强调线。
|
||||||
|
|
||||||
队红刻意做得比强调红更暖更浅,否则两条血并列时会被误读成"系统在报警"。
|
队伍红色比强调红更暖、更浅,以免两条血量并列时看起来像系统在报警。
|
||||||
|
|
||||||
### 形状:矩形,唯一的斜边给装甲条
|
### 形状:矩形,唯一的斜边给装甲条
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ desktop/
|
|||||||
|
|
||||||
### 动效:严格照 Material 3 的曲线与时长
|
### 动效:严格照 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-standard` / `-decelerate` / `-accelerate` | `(0.2,0,0,1)` / `(0,0,0,1)` / `(0.3,0,1,1)` | 常规位移 |
|
||||||
| `--m3-linear` | `(0,0,1,1)` | 恒速(仅用于跟踪与循环) |
|
| `--m3-linear` | `(0,0,1,1)` | 恒速(仅用于跟踪与循环) |
|
||||||
|
|
||||||
旧名 `--e-out` / `--e-in` / `--d-1..5` 保留为**指向 M3 的别名**,所以没有一条规则落在自造
|
旧名 `--e-out` / `--e-in` / `--d-1..5` 保留为指向 M3 的别名,相关规则均引用这些曲线,没有自定义贝塞尔曲线。两种循环效果另行处理:`steps()` 用于录制红点与终端光标的离散闪烁,`linear` 用于“没信号”状态下恒速转动的虚线环。M3 曲线描述有起止的转场,不适用于这些无限循环。
|
||||||
的贝塞尔上。两个例外是刻意的:`steps()` 用于录制红点与终端光标(离散闪烁),`linear` 用于
|
|
||||||
"没信号"那圈恒速转动的虚线环——M3 的曲线描述的是有始有终的转场,不是无限循环。
|
|
||||||
|
|
||||||
### 动效:每一页只有一个签名动作
|
### 动效:每一页只有一个签名动作
|
||||||
|
|
||||||
@@ -85,35 +83,32 @@ desktop/
|
|||||||
| 中文 | **HarmonyOS Sans SC**(400 / 500 / 700) | 叙述、标签、按钮文字。字面方正,11px 仍然立得住 |
|
| 中文 | **HarmonyOS Sans SC**(400 / 500 / 700) | 叙述、标签、按钮文字。字面方正,11px 仍然立得住 |
|
||||||
| 拉丁与数字 | **JetBrains Mono**(400 / 500 / 700) | 全部读数:帧率、血量、地址、倍率、时间码。等宽,列里的数字跳动时不左右抖 |
|
| 拉丁与数字 | **JetBrains Mono**(400 / 500 / 700) | 全部读数:帧率、血量、地址、倍率、时间码。等宽,列里的数字跳动时不左右抖 |
|
||||||
|
|
||||||
两者都以 woff2 自托管在 `public/fonts/`,随 `frontendDist` 进安装包,**运行时不碰网络**
|
两种字体都以 woff2 格式保存在 `public/fonts/`,随 `frontendDist` 打入安装包,运行时无需联网加载,CSP 的 `default-src 'self'` 也不允许从外部加载。中文字体由 `cn-font-split` 切成 82 个 unicode-range 分片 × 3 个字重,浏览器按需加载,避免启动时读取完整的 14 MB 字体。
|
||||||
(CSP 里 `default-src 'self'` 也不允许)。中文由 `cn-font-split` 切成 82 个 unicode-range
|
|
||||||
分片 × 3 个字重,浏览器按需加载,避免启动时加载完整的 14 MB 字体。
|
|
||||||
|
|
||||||
合到一句话里靠**逐字回退**,不做语言判断:`--font-ui` 把 JetBrains Mono 写在前面,它里面
|
混排依靠逐字回退,无需判断语言:`--font-ui` 将 JetBrains Mono 放在前面,遇到其中没有的汉字时,浏览器回退到 HarmonyOS。因此,“血量 87”中的数字使用等宽字体,汉字使用 HarmonyOS,同一条 `font-family` 即可处理两者。
|
||||||
没有汉字,浏览器就逐字往下找,落到 HarmonyOS。于是"血量 87"里数字是等宽的、汉字是
|
|
||||||
HarmonyOS 的,一条 `font-family` 同时满足两个要求。
|
|
||||||
|
|
||||||
启动页等待 `document.fonts.ready`,避免先显示系统字体、两百毫秒后切换到 HarmonyOS 引起布局重排。
|
启动页等待 `document.fonts.ready`,避免先显示系统字体、两百毫秒后切换到 HarmonyOS 引起布局重排。
|
||||||
|
|
||||||
### 队标
|
### 队标
|
||||||
|
|
||||||
应用图标与启动页的标记是队标(`https://simba-robotics.com/logo.svg`),由它生成整套
|
应用图标和启动页标记均来自队标(`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 任务栏只使用其中最小的几个尺寸。
|
||||||
`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`(`::` 是界面里唯一被染红的两个
|
图标小于 17px 时,笔画只剩 1px,因此顶栏只放字标 `RMIC::2026`(其中 `::` 是界面里唯一标红的两个字符),完整队标在启动页以 30px 显示。
|
||||||
字符),完整队标留给启动页的 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` 也可切换。拖动中间的竖线可调整两路画面的宽度,双击恢复等宽。拖动画面与控制台之间的横线可调整回放区域高度。
|
||||||
全部,键盘 `1` / `2` / `0` 同样)。中间那条竖线可以拖——哪一路更重要就把它拉宽,双击回到
|
- 设置页左侧为竖向导航,右侧常驻实时预览。预览与实际画面共用绘制函数(`lib/reticle.ts`),保持绘制逻辑一致;预览血量滑杆用于查看低血阈值与脉动效果。
|
||||||
等宽。画面与下方控制台之间的横线也可拖动,用来调整回放区域的高度。
|
|
||||||
- **设置是一块带预览的控制台。** 左侧竖向导航,右侧常驻一块实时预览;预览用的是画面里
|
|
||||||
同一个绘制函数(`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`。
|
- 安装包:同一 release 目录下的 `bundle/nsis/*-setup.exe`。
|
||||||
- 核心测试:进入 `core/` 后运行 `cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'`。
|
- 核心测试:进入 `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` 中指定路径;基础图传显示不依赖它。
|
视频录制另需可从 `PATH` 找到的 `ffmpeg`,也可以在配置的 `[recording] ffmpeg` 中指定路径;基础图传显示不依赖它。
|
||||||
|
|
||||||
设备发现使用板卡的 Hello 广播;客户端除广播订阅外,还会按已发现设备的 IP 定向续订。Windows 开启 VPN / 虚拟网卡时,全局广播可能走虚拟接口;定向续订可避免"能发现并绑定,但始终等待画面"的情况。队伍端的绑定和画面过滤仍然生效。
|
设备发现使用裁判系统的 Hello 广播。客户端除广播订阅外,还会按已发现设备的 IP 定向续订。Windows 开启 VPN / 虚拟网卡时,全局广播可能走虚拟接口,定向续订可避免“能发现并绑定,却始终等待画面”的情况。队伍端的绑定和画面过滤仍然生效。
|
||||||
|
|
||||||
### 演示模式:没有板子也能把整条链路跑起来
|
### 演示模式:没有板子也能把整条链路跑起来
|
||||||
|
|
||||||
@@ -188,8 +206,7 @@ CAM_CONFIG=<路径> 等价于 --config <路径>
|
|||||||
|
|
||||||
使用 `tauri dev` 时,应用参数需要通过两个 `--` 分隔符传递,如 `tauri dev -- -- --demo`。也可通过上面的环境变量设置。
|
使用 `tauri dev` 时,应用参数需要通过两个 `--` 分隔符传递,如 `tauri dev -- -- --demo`。也可通过上面的环境变量设置。
|
||||||
|
|
||||||
**未知参数只记一条 debug 日志,不报错。** macOS 从访达双击 .app 时系统会塞进来一个
|
未知参数只记录一条 debug 日志,不报错。macOS 从访达双击 .app 时,系统会附加 `-psn_0_123456`;若将它视为错误,用户看到的现象就是双击图标后程序没有启动。
|
||||||
`-psn_0_123456`;把它当错误处理的表现是"双击图标什么都没发生"。
|
|
||||||
|
|
||||||
## 为什么换成 Tauri,代价是什么
|
## 为什么换成 Tauri,代价是什么
|
||||||
|
|
||||||
@@ -232,7 +249,7 @@ CAM_CONFIG=<路径> 等价于 --config <路径>
|
|||||||
|
|
||||||
## 防作弊:两道闸门,不是一个
|
## 防作弊:两道闸门,不是一个
|
||||||
|
|
||||||
对抗赛里两队都用这套系统看自己小车的图传,要防止对手开第二个窗口连到另一队的板子。
|
对抗赛中,两队各用这套系统查看本车图传,接收限制用于防止对手另开窗口连接另一队的板子。
|
||||||
|
|
||||||
限制在接收端执行。Hello 广播已包含由 MAC 派生的 `sender_id` 与 IP;CSS 只能隐藏显示,无法阻止接收与解码。
|
限制在接收端执行。Hello 广播已包含由 MAC 派生的 `sender_id` 与 IP;CSS 只能隐藏显示,无法阻止接收与解码。
|
||||||
|
|
||||||
@@ -241,14 +258,13 @@ CAM_CONFIG=<路径> 等价于 --config <路径>
|
|||||||
| `only_sender` | 帧的重组与投递 | 用户端**始终**是绑定的那一块 |
|
| `only_sender` | 帧的重组与投递 | 用户端**始终**是绑定的那一块 |
|
||||||
| `discovery_all` | 是否记录**其它**板子的 Hello | 只在密码解锁后的设置界面里打开 |
|
| `discovery_all` | 是否记录**其它**板子的 Hello | 只在密码解锁后的设置界面里打开 |
|
||||||
|
|
||||||
旧客户端共用一个开关控制发现和接收,改绑需要手动修改配置文件。现在设置界面可以列出候选摄像头,同时保持接收限制:其他板卡即使向本机端口推流或进入发现列表,其视频帧也不会进入重组器。
|
旧客户端共用一个开关控制发现和接收,改绑需要手动修改配置文件。现在设置界面可以列出候选摄像头,同时保持接收限制:其他裁判系统即使向本机端口推流或进入发现列表,其视频帧也不会进入重组器。
|
||||||
|
|
||||||
`only_sender` 只在两种情况下放开:裁判端(中立方),以及用户端还没绑定摄像头。debug 构建不会放开它。
|
`only_sender` 只在两种情况下放开:裁判端(中立方),以及用户端还没绑定摄像头。debug 构建不会放开它。
|
||||||
|
|
||||||
### 遥测按编译模式门控
|
### 遥测按编译模式门控
|
||||||
|
|
||||||
`TEAM_DIAGNOSTICS = cfg!(debug_assertions)`。用户端 release 下**不产出**帧率/丢帧这些
|
`TEAM_DIAGNOSTICS = cfg!(debug_assertions)`。用户端 release 构建不产出帧率和丢帧数据,`CameraStatsDto::build` 将相应字段置零或置空。是否提供这些数据由编译模式决定,不依赖现场命令行开关。
|
||||||
数字,`CameraStatsDto::build` 将相应字段置零或置空。门控由编译模式决定,避免依赖现场命令行开关。
|
|
||||||
|
|
||||||
### 这挡不住什么
|
### 这挡不住什么
|
||||||
|
|
||||||
@@ -261,7 +277,7 @@ Hello 广播仍包含 IP。固件会向任何发送 `Subscribe` 的订阅者推
|
|||||||
- 密码用于限制现场改绑和退出程序。配置只保存 Argon2id 哈希;修改配置中的绑定后,界面仍要求解锁。
|
- 密码用于限制现场改绑和退出程序。配置只保存 Argon2id 哈希;修改配置中的绑定后,界面仍要求解锁。
|
||||||
- 拥有本机配置和源码的人可以删除哈希或修改客户端,因此密码无法约束这种操作。限制无线订阅仍需固件侧配对机制。
|
- 拥有本机配置和源码的人可以删除哈希或修改客户端,因此密码无法约束这种操作。限制无线订阅仍需固件侧配对机制。
|
||||||
|
|
||||||
所以它的定位是**防误触与防随手改**,不是密码学意义上的对抗。
|
密码的作用是防止误触和现场随手修改,不能提供密码学意义上的对抗保护。
|
||||||
|
|
||||||
实现上:
|
实现上:
|
||||||
|
|
||||||
@@ -274,10 +290,9 @@ Hello 广播仍包含 IP。固件会向任何发送 `Subscribe` 的订阅者推
|
|||||||
|
|
||||||
Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从控制台调用 `invoke("bind_camera")`,也需要通过这项检查。
|
Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从控制台调用 `invoke("bind_camera")`,也需要通过这项检查。
|
||||||
|
|
||||||
客户端移除了旧版的 `--camera-id` 命令行覆盖,避免绕过密码改绑。临时更换板卡时,在设置中解锁后修改,或用 `--config` 指定另一份配置文件。
|
客户端移除了旧版的 `--camera-id` 命令行覆盖,避免绕过密码改绑。临时更换裁判系统时,在设置中解锁后修改,或用 `--config` 指定另一份配置文件。
|
||||||
|
|
||||||
忘了密码:删掉配置文件里的 `password` 一行(或整个文件)。旧客户端的配置会在首次启动时
|
忘记密码时,可删除配置文件中的 `password` 一行,或删除整个文件。旧客户端配置会在首次启动时导入,并备份为 `.bak`。
|
||||||
导入并备份成 `.bak`。
|
|
||||||
|
|
||||||
## 用户端 HUD
|
## 用户端 HUD
|
||||||
|
|
||||||
@@ -295,8 +310,7 @@ Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从
|
|||||||
|
|
||||||
### 胜负横幅
|
### 胜负横幅
|
||||||
|
|
||||||
一方归零后,图传中央显示结果:获胜方为“胜利”,落败方为“落败”,双方同时归零为“平局”。用词带上对手的名字,裁判端两格因此也能直接
|
一方血量归零后,图传中央显示结果:获胜方为“胜利”,落败方为“落败”,双方同时归零为“平局”。提示带上对手名字,便于在裁判端两路画面中判断胜负。
|
||||||
看出是哪一边赢了。
|
|
||||||
|
|
||||||
- 结果由 Rust 侧计算(`snapshot::resolve_outcomes`,有单测)。自己归零即可判负,即使对手掉线;判胜则要求对手归零且自己仍有血,避免在本队从未上报时误判获胜。
|
- 结果由 Rust 侧计算(`snapshot::resolve_outcomes`,有单测)。自己归零即可判负,即使对手掉线;判胜则要求对手归零且自己仍有血,避免在本队从未上报时误判获胜。
|
||||||
- 横幅持续显示到血量重置(长按 BOOT / 下一局开始),供截图、回放和现场核对。
|
- 横幅持续显示到血量重置(长按 BOOT / 下一局开始),供截图、回放和现场核对。
|
||||||
@@ -306,7 +320,7 @@ Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从
|
|||||||
|
|
||||||
瞄准器标记相机画面中的一个点,直接绘制在 canvas 上。图像、网格、瞄准器和伤害数字共用同一组变换,避免 DOM 与 canvas 分别计算位置而产生偏移。
|
瞄准器标记相机画面中的一个点,直接绘制在 canvas 上。图像、网格、瞄准器和伤害数字共用同一组变换,避免 DOM 与 canvas 分别计算位置而产生偏移。
|
||||||
|
|
||||||
尺寸与缩放的关系也是刻意分开的:
|
瞄准器的位置与大小采用不同的缩放规则:
|
||||||
|
|
||||||
| | 跟着缩放走吗 | 为什么 |
|
| | 跟着缩放走吗 | 为什么 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -314,8 +328,7 @@ Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从
|
|||||||
| 大小 | 否 | 像真瞄具一样,放大之后准星覆盖画面里更小的一块 |
|
| 大小 | 否 | 像真瞄具一样,放大之后准星覆盖画面里更小的一块 |
|
||||||
| 网格 | 是 | 它标的是画面里的比例,用来估距离 |
|
| 网格 | 是 | 它标的是画面里的比例,用来估距离 |
|
||||||
|
|
||||||
**偏移校准存的是画面比例,不是像素。** 摄像头与枪管不同轴时要把瞄点挪到弹着点上,而像素
|
偏移校准保存画面比例,不保存像素偏移。摄像头与枪管不同轴时,需要将瞄点调整到弹着点;若保存像素偏移,分辨率或窗口尺寸变化后就会失准。校准既可通过拖动画面完成,也可直接输入数值。
|
||||||
偏移在分辨率或窗口大小一变就全错了。校准可以拖着画面调,也可以直接填数值。
|
|
||||||
|
|
||||||
### 命中反馈
|
### 命中反馈
|
||||||
|
|
||||||
@@ -325,11 +338,9 @@ Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从
|
|||||||
- 血量增加 ⇒ 不是命中,是长按 BOOT 的重置;
|
- 血量增加 ⇒ 不是命中,是长按 BOOT 的重置;
|
||||||
- 第一次见到一块板子不产生命中——没有基线,此时 `hp = 0` 只是"已经没血了"。
|
- 第一次见到一块板子不产生命中——没有基线,此时 `hp = 0` 只是"已经没血了"。
|
||||||
|
|
||||||
按**板子**记基线而不是按 `(板子, index)`:主板一次广播四条、`hp` 相同,按四条各记一份
|
基线按板子记录,而非按 `(板子, index)` 记录。主板一次广播四条上报,`hp` 相同;若为每条分别记录基线,一次掉血就会产生四次命中反馈。
|
||||||
基线会让一次掉血被报成四次命中。
|
|
||||||
|
|
||||||
已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次
|
命中反馈只能反映两次读数之间的血量净差。心跳间隔为 200 ms,这段时间内的两次打击会合并为一次反馈,足以触发红色提示,但不足以单独用于判定;判定需看界面上的绝对血量。
|
||||||
打击会被合并成一次——对"闪一下红"足够,对判定不够,判定看的是界面上的绝对血量。
|
|
||||||
|
|
||||||
反馈包括画面四周泛红和准星旁的伤害数字,中央画面保持原样。早期版本的数字曾遮住瞄点,因此移到了准星旁。
|
反馈包括画面四周泛红和准星旁的伤害数字,中央画面保持原样。早期版本的数字曾遮住瞄点,因此移到了准星旁。
|
||||||
|
|
||||||
@@ -337,25 +348,19 @@ Rust 侧的配置修改命令会调用 `AppState::require_unlocked`。即使从
|
|||||||
|
|
||||||
早期版本会在命中时晃动画面和瞄准器。该效果会干扰观察,也容易被误认为车体晃动,因此已移除。
|
早期版本会在命中时晃动画面和瞄准器。该效果会干扰观察,也容易被误认为车体晃动,因此已移除。
|
||||||
|
|
||||||
`HitFeedbackConfig::shake` 这个字段留着但不再生效:配置文件是 `deny_unknown_fields` 的,
|
`HitFeedbackConfig::shake` 字段保留,但已不再生效。配置解析使用 `deny_unknown_fields`,删除该字段会让所有写有 `shake = true` 的配置解析失败,导致程序无法启动。设置面板也已移除这个开关。
|
||||||
删字段会让所有写过 `shake = true` 的配置**解析失败**,而配置解析失败等于程序起不来。设置
|
|
||||||
面板里也没有这个开关了。
|
|
||||||
|
|
||||||
## 裁判端
|
## 裁判端
|
||||||
|
|
||||||
### 队名可以直接改
|
### 队名可以直接改
|
||||||
|
|
||||||
两格画面各自下面有一个队名输入框(`TeamNameEditor`),回车或点到别处生效,Esc 取消。
|
两路画面下方各有一个队名输入框(`TeamNameEditor`),按回车或点击其他位置生效,Esc 取消。队名随比赛变化,因此直接放在裁判查看画面的区域。修改会立即同步到血条、装甲板表和“等待图传”的占位文字中。
|
||||||
名字是**这一场比赛**的属性,所以它放在裁判盯着的地方,而不是设置面板深处的某一节;改完
|
|
||||||
立刻反映到血条、装甲板表与"等待图传"的占位文字上。
|
|
||||||
|
|
||||||
Rust 侧统一处理队名(`config::sanitize_team_name`,有单测):合并空白,按字符截断到 12 个字,留空时使用「队伍一 / 队伍二」。输入框也限制长度,避免提交后才截断;按字符处理可避免截断中文的字节序列。
|
Rust 侧统一处理队名(`config::sanitize_team_name`,有单测):合并空白,按字符截断到 12 个字,留空时使用「队伍一 / 队伍二」。输入框也限制长度,避免提交后才截断;按字符处理可避免截断中文的字节序列。
|
||||||
|
|
||||||
`set_team_name(team, name)` 每次只修改一队。若提交整个队名数组,最多 200 ms 前的旧快照可能覆盖刚修改的另一队名称。命令仍经过配置解锁检查,避免新增不受检查的写配置入口;裁判端的 `Role::needs_password` 为假,现场编辑无需密码。
|
`set_team_name(team, name)` 每次只修改一队。若提交整个队名数组,最多 200 ms 前的旧快照可能覆盖刚修改的另一队名称。命令仍经过配置解锁检查,避免新增不受检查的写配置入口;裁判端的 `Role::needs_password` 为假,现场编辑无需密码。
|
||||||
|
|
||||||
名字在快照里以 `teamNames`(两条,下标就是队伍号)推给界面,两边各自的 `TeamHealthDto.name`
|
快照通过 `teamNames` 向界面传递两条队名,下标对应队伍号;两侧的 `TeamHealthDto.name` 也来自这里。若界面各处自行拼接 `team === 0 ? "队伍一" : "队伍二"`,改名后部分位置仍会保留旧名字,且不会报错。
|
||||||
也是从这里来的:界面各处自己拼 `team === 0 ? "队伍一" : "队伍二"` 会在改名之后留下一半旧名字,
|
|
||||||
而且不会报错。
|
|
||||||
|
|
||||||
### 两格等宽
|
### 两格等宽
|
||||||
|
|
||||||
@@ -372,16 +377,15 @@ Rust 侧统一处理队名(`config::sanitize_team_name`,有单测):合
|
|||||||
3. 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位;
|
3. 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位;
|
||||||
4. 没有候选时保持槽位为空,避免两格重复显示同一块板。
|
4. 没有候选时保持槽位为空,避免两格重复显示同一块板。
|
||||||
|
|
||||||
旧实现只排除当前槽位原有的 id。一队掉线后,其槽位可能选中另一队仍在线的板卡,导致两格重复显示。规则 2 修复了这一问题,并有对应测试。
|
旧实现只排除当前槽位原有的 id。一队掉线后,其槽位可能选中另一队仍在线的裁判系统,导致两格重复显示。规则 2 修复了这一问题,并有对应测试。
|
||||||
|
|
||||||
图传板按在线候选自动分配,装甲板按上报的 `team` 分配。若画面对应关系不符,或装甲板固件的队伍号烧反,可在设置中手动指定槽位。
|
图传板按在线候选自动分配,装甲板按上报的 `team` 分配。若画面对应关系不符,或装甲板固件的队伍号烧反,可在设置中手动指定槽位。
|
||||||
|
|
||||||
### 装甲板发现列表
|
### 装甲板发现列表
|
||||||
|
|
||||||
列表显示板卡数量、自报队伍、血量、距上次广播的时间、四路上报情况和槽位。裁判端未显示血量时,可据此区分板卡未上线、队伍号错误和上报不完整。
|
列表显示裁判系统数量、自报队伍、血量、距上次广播的时间、四路上报情况和槽位。裁判端未显示血量时,可据此区分裁判系统未上线、队伍号错误和上报不完整。
|
||||||
|
|
||||||
**这不需要改固件**:主板一直用 `encode_from(sender_id)` 把自己的 MAC 派生 id 打进帧头,
|
该功能不需要修改固件:主板原本就通过 `encode_from(sender_id)` 将 MAC 派生 id 写入帧头,但旧解码路径丢弃了这一字段。现在由 `ArmorHealth::decode_from` 将它交给上层。
|
||||||
只是旧的解码路径把它丢了。现在 `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
|
```toml
|
||||||
role = "team"
|
role = "team"
|
||||||
@@ -488,15 +492,9 @@ RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
|
|||||||
|
|
||||||
## 已知限制与没做的
|
## 已知限制与没做的
|
||||||
|
|
||||||
- **同一队有两块装甲板在广播时**,`health(team)` 是"最后写入者优先",四个格子可能混着两块
|
- 同一队有两块装甲板广播时,`health(team)` 采用“最后写入者优先”,四格数据可能来自不同板子。真实比赛每队只有一块,因此该情况只在开发(演示 + 真板子)或烧错固件时出现。日志会警告一次,可通过绑定具体板子 id(`health_of_board`)消除歧义。
|
||||||
板子的数。真实比赛一队只有一块,所以这只在开发(演示 + 真板子)或烧错固件时才会遇到;
|
- 录制使用固定帧率,不使用可变帧率。源帧率变化较大时,回放速度会有偏差;采用的帧率写入日志与 DTO,便于排查。
|
||||||
日志里会警告一次,消歧办法是绑定具体的板子 id(`health_of_board`)。
|
- `csp` 使用收紧后的白名单,但还不是最小范围:`style-src` 带有 `'unsafe-inline'`,供 React 内联 `style` 属性使用;`connect-src` 允许整个 `ws://127.0.0.1:*`,因为端口在启动时随机分配,无法写死。通过前后端连接检查验证它是否可用,方法见“测试”一节最后一条。
|
||||||
- **录制帧率**是固定值,不是可变帧率。源帧率变化大时回放速度会有偏差;这个数会写进日志与
|
|
||||||
DTO,所以至少能解释清楚。
|
|
||||||
- **`csp` 是一条收紧过的白名单**,但它是"够用"而不是"最小":`style-src` 带
|
|
||||||
`'unsafe-inline'`(React 的内联 `style` 属性需要它),`connect-src` 放开了整个
|
|
||||||
`ws://127.0.0.1:*`(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端",
|
|
||||||
见"测试"一节最后一条。
|
|
||||||
- **证书与公证**没做,`.dmg` 只在本机可用。
|
- **证书与公证**没做,`.dmg` 只在本机可用。
|
||||||
- 前端已有解码队列测试;布局、实际 WebView 解码和画面显示仍需浏览器预览及真机运行验证。
|
- 前端已有解码队列测试;布局、实际 WebView 解码和画面显示仍需浏览器预览及真机运行验证。
|
||||||
|
|
||||||
|
|||||||
@@ -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 或合并镜像。
|
去掉导出命令的 `--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 为例:
|
协议层不依赖 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`。
|
装甲规则从 `armor/` 执行 `cargo +stable test -p armor-protocol`,桌面核心从 `desktop/core/` 执行 `cargo +stable test`,同样按需指定宿主 `--target` 和空的 `build-std`。
|
||||||
|
|
||||||
限帧、温度回差和 JPEG 压缩回压策略可以独立在主机运行,无需连接板卡。Windows 在根目录执行:
|
限帧、温度回差和 JPEG 压缩回压策略可在主机上独立测试,无需连接裁判系统。Windows 下在根目录执行:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
New-Item -ItemType Directory -Force target | Out-Null
|
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。
|
- 图传板每 2 秒广播 Hello;客户端每 800 ms 广播 Subscribe,并向已发现的在线裁判系统 IP 单播续订,固定绑定 UDP 5600。
|
||||||
- 全局广播可能被 VPN / 虚拟网卡路由到其它接口。收到 Hello 后按设备 IP 定向续订,可以使用通往实际局域网的路由;队伍端仅向绑定的板卡增加单播续订。
|
- 全局广播可能被 VPN / 虚拟网卡路由到其它接口。收到 Hello 后按设备 IP 定向续订,可以使用通往实际局域网的路由;队伍端仅向绑定的裁判系统增加单播续订。
|
||||||
- 固件使用订阅包的源地址作为发送目标,6 秒不续订即移除,最多保留 4 个对端。
|
- 固件使用订阅包的源地址作为发送目标,6 秒不续订即移除,最多保留 4 个对端。
|
||||||
- 新客户端为每个接收器生成随机会话 ID;同一源 IP 下,同一会话换端口会更新推流目标。旧版公共固定 ID `0x0C110001` 保持按地址区分,避免合并同机旧客户端。
|
- 新客户端为每个接收器生成随机会话 ID;同一源 IP 下,同一会话换端口会更新推流目标。旧版公共固定 ID `0x0C110001` 保持按地址区分,避免合并同机旧客户端。
|
||||||
- MAC 派生的 `sender_id` 不随 DHCP 地址变化,适合客户端持久绑定。
|
- MAC 派生的 `sender_id` 不随 DHCP 地址变化,适合客户端持久绑定。
|
||||||
@@ -78,7 +80,7 @@ cargo clean --release --target xtensa-esp32s3-espidf -p esp-idf-sys
|
|||||||
|
|
||||||
本次对照测试在保留 TUN 的环境中进行,最终参数与验证边界见 [性能排查](../testing/performance.md)。
|
本次对照测试在保留 TUN 的环境中进行,最终参数与验证边界见 [性能排查](../testing/performance.md)。
|
||||||
|
|
||||||
下表为原 README 保存的**历史单次测试数据**,不是本次构建实测或性能保证。配置为 OV5640、VGA;原始日志、时长和完整网络条件未随这些数字保存。
|
下表保留原 README 中的历史单次测试数据,不代表本次构建的实测结果,也不构成性能保证。测试使用 OV5640、VGA;这些数字未附原始日志、时长和完整网络条件。
|
||||||
|
|
||||||
| 指标 | 早期 HTTP MJPEG / TCP | 后续 UDP |
|
| 指标 | 早期 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` 是完整帧到达间隔,均使用高精度计时;没有发送时间戳及两端时钟同步,不能称为端到端延迟。
|
`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)。
|
画面链路测量使用裁判端 `--latency-report <新文件>`,让相机拍摄校准页左侧标记,固定相机和窗口位置。标记经正常采集、网络与绘制路径回到客户端,源与回显共用主机单调时钟;不需要 ESP 时钟同步。测试期间只保留一个原生客户端,额外的本地 WebSocket 观察器不增加 UDP 订阅。方法、刷新估计边界及最新结果见 [延迟测量](../testing/latency.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ armor/
|
|||||||
| 复位(新一局) | 长按 BOOT ≥500 ms → 满血、清计数 | `Button` + `Armor::reset` |
|
| 复位(新一局) | 长按 BOOT ≥500 ms → 满血、清计数 | `Button` + `Armor::reset` |
|
||||||
| 打印状态 | 短按 BOOT → 立刻打一行状态并重发上报 | `ButtonEvent::Click` |
|
| 打印状态 | 短按 BOOT → 立刻打一行状态并重发上报 | `ButtonEvent::Click` |
|
||||||
|
|
||||||
**整车一条血量。** 板子算账、裁判端只显示:四条上报的 `hp` 相同,`index` 分别是 0..3,
|
整车共用一条血量,由板子计算,裁判端只负责显示。四条上报的 `hp` 相同,`index` 分别为 0..3;界面按 `(team, index)` 收齐四条后显示同一个数值。
|
||||||
界面按 `(team, index)` 收齐四条后显示同一个数值。
|
|
||||||
|
|
||||||
参考实现使用全局 50 ms 冷却,一路受击会抑制其余路在该窗口内的击打。当前四路分别冷却,由 `cooldown_is_per_plate_not_global` 测试验证。
|
参考实现使用全局 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)
|
magic "MVC1"(4) | type(1)=5 | version(2) | sender_id(4) | ... | team(1) | index(1) | hp(2)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **血量一变立刻发**,没变就按 200 ms 心跳重发。裁判端靠心跳判断板子还在线,一次
|
- 血量变化时立即发送,未变化时按 200 ms 心跳重发。裁判端通过心跳判断板子是否在线,避免一次丢包就将界面切回“未接入”。
|
||||||
丢包不该让界面退回"未接入"。
|
- `sender_id` 使用 MAC 派生的板子 id(`protocol::id_from_mac`),不使用 0。0 是裁判终端的保留 id,需避免将装甲板的数据包误认为裁判端数据。
|
||||||
- `sender_id` 用 MAC 派生的板子 id(`protocol::id_from_mac`),不是 0 —— 0 是裁判
|
|
||||||
终端的保留 id,抓包时一块装甲板发的包不该长得像裁判端发的。
|
|
||||||
- 四条 `index` 的 `hp` 相同(整车一条血),裁判端按 `(team, index)` 收齐四条才显示。
|
- 四条 `index` 的 `hp` 相同(整车一条血),裁判端按 `(team, index)` 收齐四条才显示。
|
||||||
- 广播而不是单播:桌面端电脑的 IP 是 DHCP 给的,会变。图传板用 Hello 广播绕开这件事,
|
- 上报使用广播而非单播,因为桌面电脑通过 DHCP 获取的 IP 可能变化。图传板使用 Hello 广播处理这一问题,装甲板沿用相同约定。
|
||||||
装甲板沿用同一个约定。
|
|
||||||
|
|
||||||
### 桌面端怎么收
|
### 桌面端怎么收
|
||||||
|
|
||||||
`desktop/core/src/armor.rs` 里的 `ArmorHealthStore`(两种身份都启动):
|
`desktop/core/src/armor.rs` 里的 `ArmorHealthStore`(两种身份都启动):
|
||||||
|
|
||||||
- 绑 `0.0.0.0:5601` 收广播,按 `(team, index)` 存最后一次读数。
|
- 绑 `0.0.0.0:5601` 收广播,按 `(team, index)` 存最后一次读数。
|
||||||
- **四条都在 1.5 s 内收到**才交给界面显示;否则回退占位符。心跳 200 ms,1.5 s 是连丢
|
- 四条上报都在 1.5 s 内收到时才交给界面显示,否则显示占位符。心跳间隔为 200 ms,1.5 s 对应连丢 7 个包;偶发丢包不会让读数反复切换。
|
||||||
7 个包,偶发丢包不会让读数来回跳。
|
|
||||||
- 用户端也要显示双方血条,所以两端都会占 5601。
|
- 用户端也要显示双方血条,所以两端都会占 5601。
|
||||||
- 血量变化会打日志(`armor: board 0x… team 0 hp 100 -> 97`):窗口是给别人看的,
|
- 血量变化会写入日志(`armor: board 0x… team 0 hp 100 -> 97`),便于不查看窗口时也能核对扣血。
|
||||||
抓日志的人也得能看见掉血。
|
|
||||||
|
|
||||||
**界面显示血量数字。** `TeamHealthDto` 带 `hp` 与 `critical`:数值就在队伍名旁边,低于
|
界面在队伍名旁显示血量数字。`TeamHealthDto` 提供 `hp` 与 `critical`,血量低于 30 或归零时,数字与血条一起转红。归零时不显示“已击毁”,图传中央会显示“胜利 / 落败”(见 [桌面客户端](../desktop/client.md))。
|
||||||
30 或归零时它与血条一起转红。归零**不写"已击毁"**——那一刻图传中央会给出
|
|
||||||
“胜利 / 落败”的结论(见 [桌面客户端](../desktop/client.md))。
|
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
@@ -107,9 +99,7 @@ ARMOR_THRESHOLD=1200 scripts/build_armor.sh # 换受击阈值(看日志里
|
|||||||
|
|
||||||
产物:`armor/target/riscv32imc-esp-espidf/release/armor`。
|
产物:`armor/target/riscv32imc-esp-espidf/release/armor`。
|
||||||
|
|
||||||
`ARMOR_TEAM` 是**编译期**注入的:换队伍重刷固件即可,不用先擦 NVS。NVS 里另外记着
|
`ARMOR_TEAM` 在编译期注入,更换队伍后重新烧录固件即可,无需先擦除 NVS。NVS 另存有 `team` 与 `color`,命名空间为 `armor`,与参考实现同名。两者不一致时,以 `ARMOR_TEAM` 为准覆盖 NVS,并记录日志。
|
||||||
`team` 与 `color`(命名空间 `armor`,与参考实现同名),两者不一致时以 `ARMOR_TEAM`
|
|
||||||
为准并覆盖,日志里会说一声。
|
|
||||||
|
|
||||||
本机已有的 ESP-IDF 与工具链通过 `armor/.cargo/config.toml` 指定(`IDF_PATH` /
|
本机已有的 ESP-IDF 与工具链通过 `armor/.cargo/config.toml` 指定(`IDF_PATH` /
|
||||||
`ESP_IDF_TOOLS_INSTALL_DIR`),不需要先 `activate` ESP-IDF。删掉那两行就交给
|
`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`)** |
|
| 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 |
|
| 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`,
|
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。
|
||||||
本机没有,而 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 自己钉的。
|
两套 IDF 共存没有冲突:各自 `.cargo/config.toml` 里的 `IDF_PATH` 是每套 crate 自己钉的。
|
||||||
|
|
||||||
@@ -143,10 +131,7 @@ cd armor/armor-protocol && cargo test # 22 个测试
|
|||||||
cd protocol && cargo test # 线路格式往返(含 ArmorHealth)
|
cd protocol && cargo test # 线路格式往返(含 ArmorHealth)
|
||||||
```
|
```
|
||||||
|
|
||||||
(`armor-protocol/.cargo/config.toml` 把 target 覆盖回 host:它坐在 `armor/` 目录树
|
`armor-protocol/.cargo/config.toml` 将 target 覆盖为 host,否则它会从上层 `armor/` 继承 RISC-V 目标,导致链接失败。这与 `protocol/`、`desktop/` 的做法一致。更换宿主平台时,固定的 `aarch64-apple-darwin` 不再适用,需按[开发与验证](../development/firmware.md)用 `--target <宿主三元组> --config 'unstable.build-std=[]'` 覆盖。
|
||||||
里,否则会继承 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`。
|
烧的是 ELF(`espflash` 自己从 ELF 里读分区表与 bootloader),芯片 `esp32c3`。
|
||||||
|
|
||||||
不打开裁判端界面,也可以单独确认板子在广播、血量对不对:
|
不打开裁判端界面,也可单独检查板子的广播和血量:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/armor_probe.py 15
|
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 的通道内存
|
### 1. ESP32-C3 的 RMT 只有 96 个 symbol 的通道内存
|
||||||
|
|
||||||
C3 的 RMT 只有一个 block,总计 96 个 symbol,按 48 为单位分给两个 TX 通道。`mem_block_symbols = 64`
|
C3 的 RMT 只有一个 block,共 96 个 symbol,按 48 为单位分配给两个 TX 通道。`mem_block_symbols = 64` 会向上取整到 96,使一个通道占满全部内存,第二个 `rmt_new_tx_channel()` 因此失败:
|
||||||
会被向上取整到 96,也就是一个通道吃光全部内存,第二个 `rmt_new_tx_channel()` 就会失败:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
E rmt: rmt_tx_register_to_group(168): no free tx channels
|
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。
|
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` 藏在
|
未使用 `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` 一一对应。
|
||||||
结构体里(`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`;每秒另做一次全量刷新,避免灯带因掉电、静电等丢帧后一直保持错误状态。
|
参考实现每 10 ms 刷新四路同色灯带。这里四路颜色独立,只有内容变化时才执行 `disable → switch → enable → transmit`;每秒另做一次全量刷新,避免灯带因掉电、静电等丢帧后一直保持错误状态。
|
||||||
|
|
||||||
@@ -232,10 +213,7 @@ C3 只有 2 个 TX 通道,四路灯带无法各占一个。因此只创建一
|
|||||||
|
|
||||||
一次击打的波形只有几十微秒宽,oneshot 轮询容易错过尖峰,造成轻敲漏判。采样方式与参考实现一致:四通道合计 40 kHz,每通道 10 kHz;每帧为 10 ms、共 400 个采样点,主循环按帧处理。
|
一次击打的波形只有几十微秒宽,oneshot 轮询容易错过尖峰,造成轻敲漏判。采样方式与参考实现一致:四通道合计 40 kHz,每通道 10 kHz;每帧为 10 ms、共 400 个采样点,主循环按帧处理。
|
||||||
|
|
||||||
`esp-idf-hal` 0.47 的 `adc::continuous` 多通道入口 `EmptyAdcChannels::chain` 的约束是
|
`esp-idf-hal` 0.47 的 `adc::continuous` 多通道入口 `EmptyAdcChannels::chain` 要求同时满足 `A: Adc` 和 `O: AdcChannels<AdcUnit = A>`。`ADC1` 满足前者,`ADCU1` 满足后者,但同一个类型参数无法同时满足,因而不能组合四路 ADC1 通道。`piezo.rs` 因此也直接使用 sys 绑定,与参考实现逐行对应。
|
||||||
`A: Adc` 且 `O: AdcChannels<AdcUnit = A>`:`ADC1` 满足前者、`ADCU1` 满足后者,同一个
|
|
||||||
类型参数凑不齐,四路 ADC1 通道拼不出来。所以 `piezo.rs` 同样直接用 sys 绑定,与参考
|
|
||||||
实现逐行对应。
|
|
||||||
|
|
||||||
采样点的通道号由硬件结果自带(`type2.channel()`),映射不依赖"结果按 pattern 顺序
|
采样点的通道号由硬件结果自带(`type2.channel()`),映射不依赖"结果按 pattern 顺序
|
||||||
轮转"这个假设。
|
轮转"这个假设。
|
||||||
@@ -261,12 +239,9 @@ factory 划了 2 MB。
|
|||||||
|
|
||||||
### 7. 判断"连上没有"要问 IDF,不要信 esp-idf-svc 的事件缓存
|
### 7. 判断"连上没有"要问 IDF,不要信 esp-idf-svc 的事件缓存
|
||||||
|
|
||||||
`BlockingWifi::is_connected()/is_up()/wait_netif_up()` 读的是 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()` 误判连接成功,看门狗不再重试,板子失联却没有相应提示。
|
||||||
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_wifi_sta_get_ap_info()`
|
||||||
- 地址:`esp_netif_get_handle_from_ifkey("WIFI_STA_DEF")` + `esp_netif_get_ip_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
|
ap=SR-602-2G4 rssi=-29 ch=6 ip=192.168.1.117 reports=1745 errs=0
|
||||||
```
|
```
|
||||||
|
|
||||||
- `peak[...]` 是**这一秒内每路 ADC 的峰值**,用来校准阈值:没敲时的底噪要明显低于
|
- `peak[...]` 记录这一秒内每路 ADC 的峰值,用于校准阈值:未敲击时的底噪应明显低于阈值,正常敲击应明显高于阈值(实测底噪 ~400–500,敲击 2000–4095)。仅记录是否超过阈值,无法判断应如何调整。
|
||||||
阈值,正常敲击要明显高于它(实测底噪 ~400–500,敲击 2000–4095)。只报"有没有过阈值"
|
|
||||||
没法判断阈值该调到哪。
|
|
||||||
- `thr=` 是当前阈值,`ARMOR_THRESHOLD=1200 scripts/build_armor.sh` 可以改。
|
- `thr=` 是当前阈值,`ARMOR_THRESHOLD=1200 scripts/build_armor.sh` 可以改。
|
||||||
- `ap=` / `ip=` / `reports=` 用于区分未联网和已发送但对端未显示的情况。
|
- `ap=` / `ip=` / `reports=` 用于区分未联网和已发送但对端未显示的情况。
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
| D0–D7 | 11, 9, 8, 10, 12, 18, 17, 16 |
|
| D0–D7 | 11, 9, 8, 10, 12, 18, 17, 16 |
|
||||||
| PWDN | 未连接(`-1`) |
|
| 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 对齐的可写偏移、修改分区、重新构建和验证,再写入三段镜像。重新构建可能移动镜像内容,因此第二次验证不可省略。
|
`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 封装。
|
`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 引脚连接。升级驱动后需检查补丁并验证传感器探测和采集。
|
S3 的 `ll_cam.c` 中有 `CAM_IO_MUX_REG()` 本地补丁,用于 SCCB / I2C 引脚连接。升级驱动后需检查补丁并验证传感器探测和采集。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# RMIC::2026 文档
|
# RMIC::2026 文档
|
||||||
|
|
||||||
从使用步骤进入,再按问题查阅硬件、实现和测试记录。代码命令默认从仓库根目录执行;需要切换目录的地方会注明。
|
首次使用可从入门文档开始,遇到问题时再查阅硬件、实现说明和测试记录。命令默认从仓库根目录执行;需要切换目录时会另行注明。
|
||||||
|
|
||||||
项目概览与架构图见 [README](../README.md),工具链、验证命令和仓库协作约定见 [AGENTS](../AGENTS.md)。
|
项目概览与架构图见 [README](../README.md),工具链、验证命令和仓库协作约定见 [AGENTS](../AGENTS.md)。
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
## 测试与排查记录
|
## 测试与排查记录
|
||||||
|
|
||||||
以下是特定配置、板卡和时间窗口的记录;不同轮次的数字不能直接视为同一配置的结果。
|
以下记录各自对应特定配置、裁判系统和观察时段,不能把不同轮次的数字当作同一配置下的结果。
|
||||||
|
|
||||||
- [最新帧与阻塞恢复](testing/realtime.md):发送期限、本地积压清理及五分钟观察。
|
- [最新帧与阻塞恢复](testing/realtime.md):发送期限、本地积压清理及五分钟观察。
|
||||||
- [功率、距离与拥塞](testing/power.md):15 dBm 回退及先前功率对照。
|
- [功率、距离与拥塞](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、许可证、构建文件、运行时配置和测试数据保留各自位置。
|
除 `README.md` 和 `AGENTS.md`(或 `Agents.md`)外,项目说明集中放在 `docs/`。根目录 README 介绍项目并链接到文档;子项目 README 保留简短入口。第三方组件的 README、许可证、构建文件、运行时配置和测试数据保留各自位置。
|
||||||
|
|
||||||
新增文档时先选择主题目录,再补充本页链接。迁移时同步修改相对链接、标题锚点与生成脚本。已被实现取代或已放弃的方案可以删除,历史仍可从 Git 查阅;仍需使用的实验记录保留条件、日期与验证范围,提案须明确标注状态。
|
新增文档时,先选择对应的主题目录,再补充本页链接。迁移文档时,同步修改相对链接、标题锚点与生成脚本。已被实现取代或已放弃的方案可以删除,历史仍可从 Git 查阅。保留实验记录时需写明条件、日期与验证范围,提案需标注状态。
|
||||||
|
|||||||
@@ -13,8 +13,7 @@
|
|||||||
|
|
||||||
## 为什么坏块规避不需要重编译
|
## 为什么坏块规避不需要重编译
|
||||||
|
|
||||||
App 镜像不包含自己的分区偏移;偏移只在 `0x8000` 的分区表里。所以同一份 App 可以
|
App 镜像不包含自身的分区偏移,偏移保存在 `0x8000` 的分区表中。因此,同一份 App 可写入旧 App 之后的空闲槽位,只需修改分区表中的 factory 偏移,无需重新构建。
|
||||||
写到旧 App 之后的空闲槽位,只需要改分区表里的 factory 偏移,不需要重新构建。
|
|
||||||
|
|
||||||
旧 `scripts/flash.sh` 之所以重编,是因为它把新偏移写回 `partitions.csv` 再重建。
|
旧 `scripts/flash.sh` 之所以重编,是因为它把新偏移写回 `partitions.csv` 再重建。
|
||||||
客户端直接修改从设备读取的当前分区表;包内的 `partition-table.bin` 只作参考模板与校验:
|
客户端直接修改从设备读取的当前分区表;包内的 `partition-table.bin` 只作参考模板与校验:
|
||||||
@@ -50,8 +49,7 @@ App 镜像不包含自己的分区偏移;偏移只在 `0x8000` 的分区表里
|
|||||||
- `0xFADE7`:bit 5–7 无法正确编程(已确认);
|
- `0xFADE7`:bit 5–7 无法正确编程(已确认);
|
||||||
- `0x10863`:bit 6 读回为 1,尚未确认是否为永久故障,见[硬件说明](../hardware/camera.md#损坏-flash-模组的兼容处理)。
|
- `0x10863`:bit 6 读回为 1,尚未确认是否为永久故障,见[硬件说明](../hardware/camera.md#损坏-flash-模组的兼容处理)。
|
||||||
|
|
||||||
两个地址都在 `0x100000` 以下;`0x200000` 槽位天然避开它们。客户端仍会对写入的
|
两个地址均低于 `0x100000`,因此 `0x200000` 槽位避开了它们。客户端仍会整段回读写入的 App,不依赖“已知坏点表”判断是否成功。
|
||||||
App 整段回读,不依赖“已知坏点表”。
|
|
||||||
|
|
||||||
## 官方 .zip 固件包
|
## 官方 .zip 固件包
|
||||||
|
|
||||||
@@ -104,7 +102,9 @@ BUILD-INFO.json # 可选;展示版本/构建信息
|
|||||||
编译后打包:
|
编译后打包:
|
||||||
|
|
||||||
```powershell
|
```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
|
python scripts/package_firmware.py --firmware target/firmware
|
||||||
|
|
||||||
# 构建完成后自动打包
|
# 构建完成后自动打包
|
||||||
@@ -115,13 +115,13 @@ scripts/build.sh --package
|
|||||||
产物:
|
产物:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
target/firmware/camfw-<chip>-<variant>-<rev>-<yyyymmdd>.zip
|
target/firmware/camfw-<chip>-v<version>-<build>.zip
|
||||||
target/firmware/camfw-...zip.sha256
|
target/firmware/camfw-...zip.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
zip 使用固定条目顺序、固定 mtime 与 deflate level 9,同一个输入应得到同一个整包
|
zip 使用固定条目顺序、固定 mtime 和 deflate level 9。同一输入应生成相同的整包 SHA256,便于核对分发的固件包是否一致。`package_firmware.py` 复用 `make_bundle.py`,无需另维护一套 manifest 逻辑。
|
||||||
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` 是参考模板;客户端实际写的是设备当前
|
> 注意:bundle 里的 `partition-table.bin` 是参考模板;客户端实际写的是设备当前
|
||||||
> 分区表的 patch 版本,以保留设备的 NVS/PHY 布局。bundle 的分区表 SHA 仍会校验。
|
> 分区表的 patch 版本,以保留设备的 NVS/PHY 布局。bundle 的分区表 SHA 仍会校验。
|
||||||
@@ -219,10 +219,7 @@ Secure Boot / Flash Encryption 均未启用)上验证:
|
|||||||
- 离线单元测试:分区表解析、MD5 重算、候选槽位、尺寸解析、zip 路径白名单,共 7 个;
|
- 离线单元测试:分区表解析、MD5 重算、候选槽位、尺寸解析、zip 路径白名单,共 7 个;
|
||||||
- 前端 `tsc --noEmit`、`vite build`,后端 `cargo check` 通过。
|
- 前端 `tsc --noEmit`、`vite build`,后端 `cargo check` 通过。
|
||||||
|
|
||||||
**尚未执行**完整实机切换(写 patch 后分区表 → 复位 → 启动日志)。App 写入/回读
|
完整实机切换(写 patch 后分区表 → 复位 → 启动日志)尚未执行;App 写入和回读已在同一块板上验证。切换风险集中在 0x8000 处的 3 KiB 分区表写入,写入后会回读校验,失败时尝试恢复旧表。若新 App 启动失败,页面会显示备份目录和 `恢复旧分区表` 按钮。
|
||||||
已经在同一块板上验证;切换步骤的风险集中在 0x8000 的 3 KiB 分区表写入,写入后会
|
|
||||||
回读,失败会尝试恢复旧表。如果新 App 启动失败,页面会给出
|
|
||||||
备份目录和 `恢复旧分区表` 按钮。
|
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ protocol/ 线路格式 + 帧重组 (23 个单元测试,无 ESP-IDF 依
|
|||||||
desktop/ 桌面客户端 (RMIC::2026,Tauri v2 + React + Rust,两种身份)
|
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` 是多路的前提。** 裁判终端同时收两块板子,靠它在同一端口上区分来源。
|
**`sender_id` 是多路的前提。** 裁判终端同时收两块板子,靠它在同一端口上区分来源。
|
||||||
|
|
||||||
**`width`/`height` 每个分片都重复携带**(不只是第 0 个),这样每个数据报都能独立
|
**`width`/`height` 每个分片都重复携带**(不只是第 0 个),这样每个数据报都能独立
|
||||||
解析,中途加入的接收端也能立刻知道分辨率。
|
解析,中途加入的接收端也能立刻知道分辨率。
|
||||||
|
|
||||||
**`version` 不匹配就拒收**,而不是按错误布局继续解析——后者会表现为随机崩溃或画面
|
`version` 不匹配时直接拒收。继续按错误布局解析会导致随机崩溃或画面错乱,比拒绝连接更难排查。
|
||||||
错乱,比拒绝连接难查得多。
|
|
||||||
|
|
||||||
## 通告里的版本信息
|
## 通告里的版本信息
|
||||||
|
|
||||||
`Hello` / `Heartbeat` 的负载是**向后兼容的一截**,现在 10 字节:
|
`Hello` / `Heartbeat` 的负载保留向后兼容的字段,目前共 10 字节:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
相对 HEADER_LEN 的偏移
|
相对 HEADER_LEN 的偏移
|
||||||
@@ -102,15 +99,13 @@ cd protocol && cargo test
|
|||||||
|
|
||||||
三个决定:
|
三个决定:
|
||||||
|
|
||||||
- **旧的 2 字节 `fw` 保留不动**。老客户端只读它,多余的字节不看,所以一次固件更新
|
- 保留旧的 2 字节 `fw` 字段。老客户端只读取该字段并忽略后续字节,因此更新固件不会使旧客户端因新增字段而断连。
|
||||||
不会把还在用旧客户端的机器踢下线。
|
- 新客户端接受仅含 2 字节负载的老固件通告:版本与构建号设为 0(“未知”),协议号使用帧头中的值。界面显示“版本未知”,避免将 `0.0.0` 当作实际版本。
|
||||||
- **新客户端接受只有 2 字节的老固件**:版本与构建号回到 0("未知"),协议号退回帧头里
|
|
||||||
的那个。界面显示"版本未知",而不是一个看起来像真值的 `0.0.0`。
|
|
||||||
- **通告用宽松的帧头解析,视频帧不用**。见下一条。
|
- **通告用宽松的帧头解析,视频帧不用**。见下一条。
|
||||||
|
|
||||||
### 为什么通告允许协议不匹配
|
### 为什么通告允许协议不匹配
|
||||||
|
|
||||||
严格解析会丢弃 `version != PROTOCOL_VERSION` 的数据报,以避免按错误布局解析视频。若通告也被丢弃,客户端便无法发现协议不同的板卡,也无法解释没有画面的原因。
|
严格解析会丢弃 `version != PROTOCOL_VERSION` 的数据报,以避免按错误布局解析视频。若通告也被丢弃,客户端便无法发现协议不同的裁判系统,也无法解释没有画面的原因。
|
||||||
|
|
||||||
`Hello::decode` 使用 `decode_header_lenient`,保留魔数、长度和字段校验,允许版本不匹配。读出 `proto` 后,客户端可以显示双方协议号,并提示重新烧录。
|
`Hello::decode` 使用 `decode_header_lenient`,保留魔数、长度和字段校验,允许版本不匹配。读出 `proto` 后,客户端可以显示双方协议号,并提示重新烧录。
|
||||||
|
|
||||||
@@ -138,18 +133,14 @@ UDP 不保证数据报的顺序和到达。接收端等待下一张完整帧,
|
|||||||
|
|
||||||
### 按发送方分开
|
### 按发送方分开
|
||||||
|
|
||||||
**每个发送方必须有自己的重组器。** 两块板子的 `frame_id` 各自独立计数,塞进同一个
|
每个发送方必须使用独立的重组器。两块板子的 `frame_id` 分别计数,若共用重组器,两路数据会互相覆盖,导致两路都无法交付画面。
|
||||||
重组器会让它们互相顶掉,两路都出不来。
|
|
||||||
|
|
||||||
### 重复分片必须去重
|
### 重复分片必须去重
|
||||||
|
|
||||||
`FrameAssembler` 按分片下标记录是否收到,重复的直接跳过且不计数,并显式确认最后一个
|
`FrameAssembler` 按分片下标记录是否收到,重复的直接跳过且不计数,并显式确认最后一个
|
||||||
分片确实到过。
|
分片确实到过。
|
||||||
|
|
||||||
用单一计数 `seen >= expected` 判断收齐不够健壮:一个丢失的分片若能被某个分片的副本
|
仅用计数 `seen >= expected` 判断是否收齐并不可靠:重复分片可能补足计数,但丢失位置仍为空,最终交付的 JPEG 尾部会留下 0。两个测试覆盖这一情况(`a_duplicate_cannot_complete_a_frame_with_a_hole`、`a_gap_that_is_never_filled_never_yields_a_frame`)。
|
||||||
凑数,计数就达标了,而缓冲区里留着空洞,交出去一张尾部是 0 的 JPEG。两个测试专门覆盖
|
|
||||||
它(`a_duplicate_cannot_complete_a_frame_with_a_hole`、
|
|
||||||
`a_gap_that_is_never_filled_never_yields_a_frame`)。
|
|
||||||
|
|
||||||
一次历史测试使用一到两个订阅者,20 秒收到 2000 多个分片;固定端口与临时端口两种方式均未观察到重复投递。去重用于保证重组正确性。重复片若被误计数,可能在帧率正常、没有报错时偶发地产生无法解码的 JPEG。
|
一次历史测试使用一到两个订阅者,20 秒收到 2000 多个分片;固定端口与临时端口两种方式均未观察到重复投递。去重用于保证重组正确性。重复片若被误计数,可能在帧率正常、没有报错时偶发地产生无法解码的 JPEG。
|
||||||
|
|
||||||
@@ -199,8 +190,7 @@ UDP 不保证数据报的顺序和到达。接收端等待下一张完整帧,
|
|||||||
|
|
||||||
`id_from_mac()` 用固定长度输入的 FNV-1a 从 MAC 派生 `sender_id`。
|
`id_from_mac()` 用固定长度输入的 FNV-1a 从 MAC 派生 `sender_id`。
|
||||||
|
|
||||||
**用 MAC 而不是 IP**,因为客户端要把绑定存进配置文件并在重启后继续有效——DHCP 换
|
使用 MAC 而非 IP,是为了让保存在配置文件中的绑定在重启后继续有效:DHCP 地址会变,MAC 不变。两块板子因此可以烧录同一份固件,无需另配设备身份。
|
||||||
地址是常态,MAC 不会变。用 MAC 也意味着烧同一份固件即可,不需要任何"我是谁"的配置。
|
|
||||||
|
|
||||||
返回值避开裁判终端保留值 0。测试检查结果稳定性与样例间是否碰撞,但不能证明哈希永不碰撞。
|
返回值避开裁判终端保留值 0。测试检查结果稳定性与样例间是否碰撞,但不能证明哈希永不碰撞。
|
||||||
|
|
||||||
@@ -208,9 +198,7 @@ UDP 不保证数据报的顺序和到达。接收端等待下一张完整帧,
|
|||||||
|
|
||||||
`scripts/udp_probe.py` 手写了这里的每一个常量,**不依赖本 crate**。
|
`scripts/udp_probe.py` 手写了这里的每一个常量,**不依赖本 crate**。
|
||||||
|
|
||||||
这正是它的价值:如果本 crate 的编码有错,脚本仍按文档解析,会立刻报出不一致。反面
|
如果本 crate 编码有误,脚本仍按文档独立解析,可立即报出不一致。探测脚本本身也曾出错:`role` 字节曾多填 12 个零字节,固件按严格偏移读取后拒收,表现为“板子在线但 0 clients subscribed”。修改协议时需同步核对脚本。
|
||||||
例子也有——它的 `role` 字节曾多填 12 个零字节,固件按偏移严格读取因而拒收,表现为
|
|
||||||
"板子在线但 0 clients subscribed"。改协议时记得同步它。
|
|
||||||
|
|
||||||
## 装甲板
|
## 装甲板
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
- 对临时 UDP 发送拥塞进行有时限的让出调度重试,统计实际提交帧、失败次数与丢弃帧。
|
- 对临时 UDP 发送拥塞进行有时限的让出调度重试,统计实际提交帧、失败次数与丢弃帧。
|
||||||
- Windows UDP 接收缓冲请求增至 1 MiB;界面视频通道和解码队列保留最新待处理帧,避免慢端持续积压。
|
- Windows UDP 接收缓冲请求增至 1 MiB;界面视频通道和解码队列保留最新待处理帧,避免慢端持续积压。
|
||||||
- 新客户端使用随机会话 ID;固件更新同一会话的源端口,避免 NAT / TUN 端口变化产生重复推流目标,旧固定 ID 客户端继续按地址区分。
|
- 新客户端使用随机会话 ID;固件更新同一会话的源端口,避免 NAT / TUN 端口变化产生重复推流目标,旧固定 ID 客户端继续按地址区分。
|
||||||
- 新增可选编译参数 `WIFI_TX_POWER_DBM`(2–20 dBm);未设置时保留 SDK 默认上限,按实际板卡和网络测量调节。
|
- 新增可选编译参数 `WIFI_TX_POWER_DBM`(2–20 dBm);未设置时保留 SDK 默认上限,按实际裁判系统和网络测量调节。
|
||||||
- 新增 `scripts/perf_probe.py`,测量完整帧率、帧损失、接收间隔和分片组装耗时。
|
- 新增 `scripts/perf_probe.py`,测量完整帧率、帧损失、接收间隔和分片组装耗时。
|
||||||
- 新增 `CAM_MAX_FPS`(默认 0,不限帧),可限制取帧和发送节拍,过载后不追赶补发。
|
- 新增 `CAM_MAX_FPS`(默认 0,不限帧),可限制取帧和发送节拍,过载后不追赶补发。
|
||||||
- 新增 `CAM_GRAYSCALE`,通过传感器灰度效果保留硬件 JPEG;增加可配置的芯片温度减载和 5°C 恢复回差。
|
- 新增 `CAM_GRAYSCALE`,通过传感器灰度效果保留硬件 JPEG;增加可配置的芯片温度减载和 5°C 恢复回差。
|
||||||
|
|||||||
@@ -3,13 +3,38 @@
|
|||||||
"major": 0,
|
"major": 0,
|
||||||
"minor": 2,
|
"minor": 2,
|
||||||
"patch": 2,
|
"patch": 2,
|
||||||
"build": 38,
|
"build": 39,
|
||||||
"protocol": 1,
|
"protocol": 1,
|
||||||
"date": "2026-10-19",
|
"date": "2026-10-21",
|
||||||
"baseline": "v0.2.1",
|
"baseline": "v0.2.1",
|
||||||
"commits": 3,
|
"commits": 4,
|
||||||
"groups": {
|
"groups": {
|
||||||
"fix": [
|
"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",
|
"scope": "client",
|
||||||
"text": "统一状态与操作文案,录制和截图成功后再显示完成提示",
|
"text": "统一状态与操作文案,录制和截图成功后再显示完成提示",
|
||||||
@@ -17,6 +42,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"docs": [
|
"docs": [
|
||||||
|
{
|
||||||
|
"scope": null,
|
||||||
|
"text": "润色项目说明并统一裁判系统术语",
|
||||||
|
"breaking": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"scope": null,
|
"scope": null,
|
||||||
"text": "整理项目文档并统一界面表述",
|
"text": "整理项目文档并统一界面表述",
|
||||||
@@ -34,11 +64,23 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"test": [
|
"test": [
|
||||||
|
{
|
||||||
|
"scope": "packaging",
|
||||||
|
"text": "补充 Windows 完整性和固件打包回归,覆盖外部输出目录、旧镜像与重复生成",
|
||||||
|
"breaking": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"scope": "client",
|
"scope": "client",
|
||||||
"text": "按文件名核对媒体类型,避免依赖时间戳排序",
|
"text": "按文件名核对媒体类型,避免依赖时间戳排序",
|
||||||
"breaking": false
|
"breaking": false
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"style": [
|
||||||
|
{
|
||||||
|
"scope": "client",
|
||||||
|
"text": "应用图标增加圆角,统一“裁判系统客户端”命名",
|
||||||
|
"breaking": false
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,35 @@
|
|||||||
> 提交采用 Conventional Commits,规则见[版本维护](../development/versioning.md)。
|
> 提交采用 Conventional Commits,规则见[版本维护](../development/versioning.md)。
|
||||||
> 自动版本机制建立前的说明见[早期人工记录](changelog-legacy.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** · 统一状态与操作文案,录制和截图成功后再显示完成提示
|
- **client** · 统一状态与操作文案,录制和截图成功后再显示完成提示
|
||||||
|
|
||||||
### 文档
|
### 文档
|
||||||
|
|
||||||
|
- 润色项目说明并统一裁判系统术语
|
||||||
- 整理项目文档并统一界面表述
|
- 整理项目文档并统一界面表述
|
||||||
- README 不写死构建号,改由 version.json 提供
|
- README 不写死构建号,改由 version.json 提供
|
||||||
- 按 humanizer 重写项目文档并同步 RMIC::2026 现状
|
- 按 humanizer 重写项目文档并同步 RMIC::2026 现状
|
||||||
|
|
||||||
### 测试
|
### 测试
|
||||||
|
|
||||||
|
- **packaging** · 补充 Windows 完整性和固件打包回归,覆盖外部输出目录、旧镜像与重复生成
|
||||||
- **client** · 按文件名核对媒体类型,避免依赖时间戳排序
|
- **client** · 按文件名核对媒体类型,避免依赖时间戳排序
|
||||||
|
|
||||||
|
### 样式
|
||||||
|
|
||||||
|
- **client** · 应用图标增加圆角,统一“裁判系统客户端”命名
|
||||||
|
|
||||||
## v0.2.1 — 2026-10-19
|
## v0.2.1 — 2026-10-19
|
||||||
|
|
||||||
1 个提交
|
1 个提交
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
| 网络 | 2.4 GHz Wi-Fi |
|
| 网络 | 2.4 GHz Wi-Fi |
|
||||||
| 连接 | 支持数据传输的 USB 线、可识别的串口及稳定供电 |
|
| 连接 | 支持数据传输的 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
|
# Windows PowerShell
|
||||||
$env:CARGO_TARGET_DIR = 'C:\s3vt'
|
$env:CARGO_TARGET_DIR = 'C:\s3vt'
|
||||||
.\scripts\build.ps1
|
.\scripts\build.ps1
|
||||||
|
# 同时导出并打包:.\scripts\build.ps1 -Package
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -158,9 +159,9 @@ $env:CARGO_TARGET_DIR = 'C:\s3vt'
|
|||||||
./scripts/build.sh
|
./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)。
|
`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-模组的兼容处理)。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 约 30 fps、Wi-Fi 重连与画面链路延迟验证(2026-10-18)
|
# 约 30 fps、Wi-Fi 重连与画面链路延迟验证(2026-10-18)
|
||||||
|
|
||||||
当前交付已增加阻塞恢复与新鲜度处理,见 [最新帧处理](realtime.md)。本页保留此前减光确认和画面延迟对照;所列 SHA 与测量结果均属于当时构建,不是当前固件的重测结果。
|
当前交付已增加阻塞恢复与新鲜度处理,见 [最新帧处理](realtime.md)。本页保留此前的亮度确认与画面延迟对照;所列 SHA 和测量结果均对应当时的构建,不代表当前固件重新测量后的结果。
|
||||||
|
|
||||||
## 先前三缓冲与小幅减光记录
|
## 先前三缓冲与小幅减光记录
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
| 标记 → 首次可解码画布回显 P95 | 230.7 ms | 193.4 ms |
|
| 标记 → 首次可解码画布回显 P95 | 230.7 ms | 193.4 ms |
|
||||||
| 回显估计 P99 / 最大值 | 285.3 / 443.2 ms | 248.9 / 461.5 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,未触发减载。
|
双缓冲试验五分钟全程在线,复位、采集错误、通道断开和计数重置均为 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、解码和画布绘制。它是**屏幕标记事件到首次可解码画布回显的估计**,没有测量源/输出面板扫描或像素响应;严格的物理光学端到端延迟需用高速相机复核。未识别帧不成为样本,首次可解码回显可能晚于较早的模糊回显,不能把有效样本分布当作每一帧的延迟。
|
源标记和回显均使用同一客户端 `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/`,不把画面、私有配置或令牌日志打进制品。
|
`scripts/analyze_latency.py` 可按半开区间行号复算 NDJSON。完整结果、参数、行号范围和边界见制品 `LATENCY-RESULT.json`,分布图见 `LATENCY.png`。原始校准记录保存在本机 `target/latency/`,不把画面、私有配置或令牌日志打进制品。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 图传性能排查与验证(2026-10-18)
|
# 图传性能排查与验证(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 = 完整帧数 / 整个测量时长。帧损失包括被替换的不完整帧、超时及帧号缺口;末尾仍在接收的帧不计损失。丢帧率与旧客户端的口径不同,旧版会低估实际丢帧。
|
表中 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。
|
约 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
|
```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 不包含用户运行配置。
|
本机原始 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 不包含用户运行配置。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
当前交付请求 `WIFI_TX_POWER_DBM=15`,实板驱动回读 **15.00 dBm**。根据用户的距离与拥塞反馈,从 17 dBm 回退;启动和 Wi-Fi 重连后应用同一设置,压缩与温控控制器不自动降低发射功率。三缓冲、VGA 彩色、AE 目标 45、约 20 ms 曝光上限和 PLL 时钟保持不变。
|
当前交付请求 `WIFI_TX_POWER_DBM=15`,实板驱动回读 **15.00 dBm**。根据用户的距离与拥塞反馈,从 17 dBm 回退;启动和 Wi-Fi 重连后应用同一设置,压缩与温控控制器不自动降低发射功率。三缓冲、VGA 彩色、AE 目标 45、约 20 ms 曝光上限和 PLL 时钟保持不变。
|
||||||
|
|
||||||
这里的数值是驱动配置的发射功率上限。API 回读不能代替功率计,也不能据此认为所有 PHY 速率下、每个数据包的天线端输出都恰好相同。
|
这里的数值表示驱动配置的发射功率上限。API 回读不能代替功率计测量,也不能证明所有 PHY 速率下、每个数据包的天线端输出都相同。
|
||||||
|
|
||||||
## 先前 17 dBm 记录说明了什么
|
## 先前 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 倍。这只是模型估算,不是本次测得的距离。
|
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)。
|
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,属于近距离记录,不能拿来证明远距离的上行链路已经改善。
|
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)。
|
提高发射上限不会增加信道带宽或路由器的排队能力。若空口竞争、速率回退或重传使驱动排空变慢,突发提交仍会填满 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 保持原样。
|
电脑当前通过 **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,其他图传参数与单 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 天线的握持改善
|
## 蛇形 PCB 天线的握持改善
|
||||||
|
|
||||||
@@ -54,11 +54,13 @@ ESP-IDF 明确说明,UDP 连续 `send/sendto` 可以因底层驱动 TX 缓冲
|
|||||||
1. **先改善握持和摆位。** 用塑料支架或绝缘延长握柄固定,让蛇形天线端伸出、远离手掌;握住远离天线的一端。可先尝试让手与天线相距约 3–5 cm,再按实测增加距离,这不是保证性能的固定阈值。
|
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)。
|
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 不能覆盖上行质量,但可帮助区分朝向、遮挡与队列负载变化。
|
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/射频测量后调整匹配网络。直接延长蛇形走线、随意焊线或并接两根天线不能代替调谐;在现板上先做可逆的机械隔离更容易验证。
|
5. **必须贴近人体使用时,按最终结构调谐。** 外壳、支架、馈线和手持姿态都参与最终匹配,需要 VNA/射频测量后调整匹配网络。直接延长蛇形走线、随意焊线或并接两根天线不能代替调谐;在现板上先做可逆的机械隔离更容易验证。
|
||||||
|
|
||||||
## 15 dBm 本轮结果
|
## 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 内容不同,不能将差值归因于功率这一项。
|
按时间近似对齐的板端五分钟区间有 731 次发送失败、653 次应用重试、78 次目标帧放弃;温度 56–58°C,RSSI -35 至 -11 dBm。板端区间由五秒采样的结束时间选择,边界并非逐包精确对齐。与此前短测时长、现场无线状态和 JPEG 内容不同,不能将差值归因于功率这一项。
|
||||||
|
|||||||
@@ -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 缓冲。
|
沿用已确认可用的三块 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 由接收端整帧丢弃,不进入绘制。
|
完整 JPEG 取得后按 1400 字节载荷分片立即提交,复用一个数据报缓冲,直接读取 PSRAM。当前传感器驱动在 JPEG 结束后交出完整帧,本次没有改成在 DMA 尚未完成时就读取其内容;不完整 JPEG 由接收端整帧丢弃,不进入绘制。
|
||||||
|
|
||||||
@@ -35,9 +37,9 @@ WIFI_TX_POWER_DBM=15
|
|||||||
|
|
||||||
若要在发送回压时立即放弃本帧,可将 `UDP_RETRY_BUDGET_US=0` 后重编烧录。该模式仍保留三块采集内存和完整 JPEG 校验;这里的“零”指不让出调度重试,不是删除驱动和路由器中的所有缓冲。零重试策略已经主机测试,本次五分钟实机使用默认 8000 μs。
|
若要在发送回压时立即放弃本帧,可将 `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 版本的实机五分钟检查
|
## 此前 14 dBm 版本的实机五分钟检查
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
```dotenv
|
||||||
CAM_RES=vga
|
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,未触发温度减载。
|
测量窗口内曝光为 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`,不包含相机抽帧或令牌日志。
|
本轮固件 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 分钟的灰度 / 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)。
|
温度控制仍可选:达到 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)。
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 提示缺少 `esp-1.97.0.0` | 按上述命令安装指定名称的工具链,重新打开终端后检查版本 |
|
| 提示缺少 `esp-1.97.0.0` | 按上述命令安装指定名称的工具链,重新打开终端后检查版本 |
|
||||||
| `espup` 查询 GitHub 版本返回 403 | 对上述固定版本安装命令追加 `--skip-version-parse`,或稍后重试 |
|
| `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 |
|
| 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、凭据和认证方式,再重新编译烧录 |
|
| Wi-Fi 连不上 | 核对 2.4 GHz、凭据和认证方式,再重新编译烧录 |
|
||||||
| 相机无输出 / 初始化失败 | 核对板型接线、供电与 Octal PSRAM |
|
| 相机无输出 / 初始化失败 | 核对板型接线、供电与 Octal PSRAM |
|
||||||
| `NO-EOI`、几帧后停止 | 确认 `CONFIG_CAMERA_PSRAM_DMA=n` |
|
| `NO-EOI`、几帧后停止 | 确认 `CONFIG_CAMERA_PSRAM_DMA=n` |
|
||||||
@@ -20,9 +20,11 @@
|
|||||||
| 改 C 组件后无变化 | 清理 `esp-idf-sys` 构建缓存后重编,见开发说明 |
|
| 改 C 组件后无变化 | 清理 `esp-idf-sys` 构建缓存后重编,见开发说明 |
|
||||||
| 主机项目被编为 Xtensa | 显式指定宿主 target,见各子项目说明 |
|
| 主机项目被编为 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
|
```text
|
||||||
python scripts/perf_probe.py 192.168.1.139 --seconds 30 --json perf.json
|
python scripts/perf_probe.py 192.168.1.139 --seconds 30 --json perf.json
|
||||||
|
|||||||
@@ -51,19 +51,33 @@ try {
|
|||||||
$espClang = Join-Path $env:USERPROFILE '.rustup\toolchains\esp-1.97.0.0\xtensa-esp32-elf-clang\esp-clang\bin'
|
$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')) {
|
if (Test-Path -LiteralPath (Join-Path $espClang 'libclang.dll')) {
|
||||||
$env:LIBCLANG_PATH = $espClang
|
$env:LIBCLANG_PATH = $espClang
|
||||||
if (-not ($env:PATH -split ';' | Where-Object { $_ -eq $espClang })) {
|
# An existing entry can be behind another LLVM installation. Move it
|
||||||
$env:PATH = $espClang + ';' + $env:PATH
|
# 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:CARGO_WORKSPACE_DIR = (Get-Location).Path
|
||||||
$env:ESP_IDF_GLOB_BASE = (Get-Location).Path
|
$env:ESP_IDF_GLOB_BASE = (Get-Location).Path
|
||||||
$env:ESP_IDF_GLOB_PARTITIONS = 'partitions.csv'
|
$env:ESP_IDF_GLOB_PARTITIONS = 'partitions.csv'
|
||||||
|
|
||||||
|
$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
|
& cargo build --release --locked @CargoArgs
|
||||||
|
}
|
||||||
if ($LASTEXITCODE -ne 0) { throw "cargo build failed (exit $LASTEXITCODE)." }
|
if ($LASTEXITCODE -ne 0) { throw "cargo build failed (exit $LASTEXITCODE)." }
|
||||||
|
|
||||||
if ($Package) {
|
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
|
$python = Get-Command python -ErrorAction SilentlyContinue
|
||||||
if ($null -eq $python) {
|
if ($null -eq $python) {
|
||||||
throw '-Package requires Python 3 on PATH (used by scripts/package_firmware.py).'
|
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
|
$info = Get-Content -LiteralPath 'version.json' -Raw | ConvertFrom-Json
|
||||||
$versionArgs = @('--version', "v$($info.version)+$($info.build)")
|
$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)." }
|
if ($LASTEXITCODE -ne 0) { throw "package_firmware.py failed (exit $LASTEXITCODE)." }
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
|
|
||||||
流程(在 cargo build 之后):
|
流程(在 cargo build 之后):
|
||||||
|
|
||||||
make_bundle.py → bundle.json(沿用现有清单)
|
|
||||||
package_firmware.py
|
package_firmware.py
|
||||||
├─ 读 bundle.json / BUILD-INFO.json
|
├─ 从 Cargo 产物导出 App,同步 Bootloader / 分区表
|
||||||
|
├─ 刷新 BUILD-INFO.json,并调用 make_bundle.py 生成 bundle.json
|
||||||
├─ 固定条目顺序与 mtime,生成可复现的 zip
|
├─ 固定条目顺序与 mtime,生成可复现的 zip
|
||||||
└─ 写 <zip>.sha256
|
└─ 写 <zip>.sha256
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -59,19 +59,31 @@ def parse_epoch(build: dict) -> int:
|
|||||||
return int(time.time())
|
return int(time.time())
|
||||||
|
|
||||||
|
|
||||||
def export_app_if_stale(repo: Path, firmware: Path, allow_stale: bool) -> None:
|
def resolve_elf(repo: Path, explicit: str) -> Path:
|
||||||
"""编译后 App 镜像可能还没导出;优先用 espflash 从最新 ELF 重新导出。"""
|
if explicit:
|
||||||
elf = repo / "target" / "xtensa-esp32s3-espidf" / "release" / "esp32"
|
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"
|
app = firmware / "esp32s3-app.bin"
|
||||||
if not elf.is_file():
|
if not elf.is_file():
|
||||||
|
if allow_stale and app.is_file():
|
||||||
|
print(f"警告:缺少 {elf};继续打包现有镜像", file=sys.stderr)
|
||||||
return
|
return
|
||||||
if app.is_file() and app.stat().st_mtime >= elf.stat().st_mtime:
|
raise SystemExit(f"固件 ELF 不存在:{elf}。请先构建,或用 --elf 指定本次构建的 ELF。")
|
||||||
return
|
|
||||||
|
|
||||||
if app.is_file():
|
companions = [elf.parent / name for name in ("bootloader.bin", "partition-table.bin")]
|
||||||
print(f"提示:{app} 早于 {elf},将尝试从最新 ELF 重新导出", file=sys.stderr)
|
for source in companions:
|
||||||
else:
|
if not source.is_file():
|
||||||
print(f"提示:缺少 {app},将尝试从最新 ELF 导出", file=sys.stderr)
|
raise SystemExit(f"缺少同次构建的配套镜像:{source}")
|
||||||
|
|
||||||
espflash = repo / "target" / "tools" / "espflash" / "espflash.exe"
|
espflash = repo / "target" / "tools" / "espflash" / "espflash.exe"
|
||||||
if not espflash.is_file():
|
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:
|
if allow_stale:
|
||||||
print(f"警告:{message};继续打包现有镜像", file=sys.stderr)
|
print(f"警告:{message};继续打包现有镜像", file=sys.stderr)
|
||||||
return
|
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)
|
firmware.mkdir(parents=True, exist_ok=True)
|
||||||
print(f"导出 App:{elf} -> {app}")
|
print(f"导出 App:{elf} -> {app}")
|
||||||
@@ -94,6 +106,13 @@ def export_app_if_stale(repo: Path, firmware: Path, allow_stale: bool) -> None:
|
|||||||
"esp32s3",
|
"esp32s3",
|
||||||
"--format",
|
"--format",
|
||||||
"esp-idf",
|
"esp-idf",
|
||||||
|
"--flash-size",
|
||||||
|
"16mb",
|
||||||
|
"--flash-mode",
|
||||||
|
"dio",
|
||||||
|
"--flash-freq",
|
||||||
|
"40mhz",
|
||||||
|
"--skip-update-check",
|
||||||
str(elf),
|
str(elf),
|
||||||
str(app),
|
str(app),
|
||||||
]
|
]
|
||||||
@@ -101,28 +120,50 @@ def export_app_if_stale(repo: Path, firmware: Path, allow_stale: bool) -> None:
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise SystemExit(f"espflash save-image 失败(exit {result.returncode})")
|
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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="生成官方 .zip 固件包")
|
parser = argparse.ArgumentParser(description="生成官方 .zip 固件包")
|
||||||
parser.add_argument("--firmware", default="target/firmware", help="固件产物目录")
|
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("--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("--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")
|
parser.add_argument("--output", default="", help="输出 zip 路径;默认 target/firmware/camfw-*.zip")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
repo = Path(__file__).resolve().parent.parent
|
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)
|
firmware = Path(args.firmware)
|
||||||
if not firmware.is_absolute():
|
if not firmware.is_absolute():
|
||||||
firmware = (Path.cwd() / firmware).resolve()
|
firmware = (Path.cwd() / firmware).resolve()
|
||||||
if not firmware.is_dir():
|
|
||||||
print(f"固件目录不存在:{firmware}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
export_app_if_stale(repo, firmware, args.allow_stale)
|
export_app(repo, firmware, resolve_elf(repo, args.elf), args.allow_stale)
|
||||||
except SystemExit as error:
|
except (SystemExit, OSError, subprocess.CalledProcessError, ValueError) as error:
|
||||||
print(error, file=sys.stderr)
|
print(error, file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
"major": 0,
|
"major": 0,
|
||||||
"minor": 2,
|
"minor": 2,
|
||||||
"patch": 2,
|
"patch": 2,
|
||||||
"build": 38,
|
"build": 39,
|
||||||
"protocol": 1,
|
"protocol": 1,
|
||||||
"date": "2026-10-19",
|
"date": "2026-10-21",
|
||||||
"baseline": "v0.2.1",
|
"baseline": "v0.2.1",
|
||||||
"note": "由 scripts/version.mjs 从 git 历史算出,请勿手改。"
|
"note": "由 scripts/version.mjs 从 git 历史算出,请勿手改。"
|
||||||
}
|
}
|
||||||
|
|||||||