Horldsence 7fdebe3ec7 desktop: 修两处小问题(预览模式的命中节奏、设置面板里漏出来的 **)
- 浏览器预览的掉血脚本写成"打空就重置,然后按 to >= from 判断"——两块板子都打空之后
  每一次都是重置,命中再也不出现,盯着界面等闪红会等到超时。现在打空那一拍只重置、
  其余每一拍都出命中。
- 设置面板里"偏移存的是**画面比例**"直接写进了 JSX,`**` 原样显示出来了。JSX 不渲染
  Markdown,改成 <span class="em">。

顺带把界面截图落到 desktop/screenshots/(不进仓库):这个环境里 Page.captureScreenshot
不可用(空白页也超时),改用 Page.printToPDF + sips 转 PNG;打印路径不渲染 box-shadow
的模糊半径(会变成实心块),所以 .dev/shoot.js 在拍之前把它去掉。
2026-10-14 19:37:59 +08:00

ESP32-S3 摄像头图传(Rust / ESP-IDF / UDP)

两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV5640),向队伍客户端与裁判终端推 UDP 图传。

固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;桌面端是 Tauri v2 + React,用户端与裁判端两套页面(见 desktop/README.md)。同一套 系统里还有一队一块的装甲板(ESP32-C3,一主三从),负责四路击打检测、灯带与整车血量, 并把血量广播给裁判终端。

protocol/   线路格式 + 帧重组    三端共用,无 ESP-IDF 依赖,14 个单元测试
.           图传固件  -> ESP32-S3  交叉编译,Xtensa
armor/      装甲板固件 -> ESP32-C3  独立 workspace,RISC-V;规则层 22 个单元测试
desktop/    桌面端               Tauri v2 + React;用户端(血条 + 瞄准器)/ 裁判端
            core/               不含界面与 Tauri 的网络层、配置、密码(可单测)
            src-tauri/          Tauri 后端:推流、命令、录制
            src/                React 前端:两套页面 + HUD

五份产物各自独立构建,各有自己的 .cargo/config.toml。

拓扑

                    ┌──────────────────────────────┐
                    │  裁判终端(PC,仅显示)      │
                    │  队伍一 + 队伍二 + 双方血量   │
                    └───────────────┬──────────────┘
                                    │  同一个局域网,广播可用
               ┌────────────────────┼────────────────────┐
               │                    │                    │
       ┌───────┴───────┐    ┌───────┴───────┐    ┌───────┴───────┐
       │  图传板 A     │    │  图传板 B     │    │  装甲板主板   │
       │  (队伍一)     │    │  (队伍二)     │    │  ×2(每队一块)│
       └───────────────┘    └───────────────┘    └───────────────┘

图传板不区分队伍。 它们只知道自己是谁(MAC 派生的 sender_id),"哪一边"由 客户端决定。烧同一份固件即可,配置全在客户端一侧。

装甲板反过来:队伍是编译期定的(ARMOR_TEAM=0/1,同时决定灯色红/蓝),因为血量 上报里必须带 team 字段,而且灯色现场不该靠按键切错。见 armor/README.md。

裁判终端同时收两块图传板,也收两块装甲板主板在 5601 上广播的血量。队伍客户端也收 全部板子的视频,然后在界面层过滤到绑定的那一块。

实测性能

VGA 640x480,传感器产出上限 22 fps(OV5640 在此配置下的实测值)。

TCP(HTTP MJPEG) UDP(现在)
传感器实际产出 22 fps 22 fps
客户端实际收到 1.9 fps 22 fps
实际吞吐 0.28 Mbit/s 1.6 Mbit/s
丢帧 — 0%

TCP 那一栏只用了链路能力的 1% 左右(ESP32-S3 在良好 2.4GHz 链路上 TCP 实测上限约 20–30 Mbit/s)。链路是空的,帧却堵在队头阻塞后面等一个实时画面等不起的重传计时 器。

UDP 没有重传与确认:缺了分片的帧整个丢弃,下一帧顶上。丢帧率实测 0%,因为带宽从来 不是瓶颈——即便如此也只用掉 6–9%。

其余数字:平均帧 22 kB,约 350 包/秒,每帧约 17 个分片,客户端观测的帧间隔 7 ms。

为什么视频走 UDP 而不是 TCP

延迟优先。 丢失 5% 的画面远好于等待 500 ms 的重传。

没有队头阻塞。 一帧的任一分片卡住,后面所有帧都跟着卡——对实时画面这正好是错 的方向。

带宽不是约束。 见上表,TCP 那栏证明了链路是空的,所以 TCP 的优势在这里换不来 任何东西。

代价是丢包丢整帧。这被接受了:撕裂的画面比过期的画面更糟,所以接收端不做补洞, 只等下一张完整帧。

帧率必须按计数除以墙钟

"22 fps" 这个数字不是对帧间隔取平均得来的,是每个窗口的帧数增量除以窗口时长。

一次 UI 轮询(33ms)之间可能完成多帧,平均间隔量的是显示的刷新快慢而不是相机 的出帧快慢——那会把上限 22fps 的传感器报成 36fps。一个笃定的错误数字比没有数字更 糟,现场你会以为还有性能余量,然后花时间去找一个不存在的问题。

这个错误在本项目里出现过两次(客户端的 View::update_fps、探测脚本的分窗打印),两 次都是同一个形状:分子用累计值,分母用窗口值,于是报出随时间单调上升的假数字。

快速开始

1. 固件

cp .env.example .env      # 填 WIFI_SSID / WIFI_PASS
./scripts/flash.sh --monitor 20

flash.sh 会编译、挑选避开坏字节的 app 偏移、烧录三段(bootloader / 分区表 / app)、打印串口日志。ESP-IDF 位置从 .cargo/config.toml 读,不用先 activate。

.env:

WIFI_SSID=你的2.4G网络
WIFI_PASS=密码

# 装甲板(armor/)用这两个里的第一个;队伍号决定灯色与上报里的 team 字段:
#ARMOR_TEAM=0        # 0 = 队伍一(红),1 = 队伍二(蓝)

# 以下都有默认值:
#CAM_RES=vga          # qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga
#CAM_QUALITY=12       # 0-63,数字越小画质越好、字节越少
#CAM_VSYNC_INVERT=1   # 若板子在硬件上反相了 VSYNC,填 0
#WIFI_AUTH=wpa2       # 固定认证方式,仅当扫描看不到 AP 时需要(如隐藏 SSID)

凭据由 env! 在编译期读取,所以没有运行时解析路径,也不会因为忘了读 .env 而 在板子上带着别人的密码。ARMOR_TEAM 同理:换队伍重刷装甲板固件即可,不用擦 NVS。

WIFI_AUTH 一般不用设:固件先扫描 AP,用它报告的真实认证方式去连,只有扫不到时才 需要人工指定。指定之后只试这一种方式——不成功就直接报错,而不是挨个试 10 秒。 挨个试等于在猜一个本该已知的值。

2. 装甲板固件(ESP32-C3)

scripts/flash_armor.sh            # 编译 + 烧录 + 20 秒串口日志
python3 scripts/armor_probe.py 15 # 不开界面,单看板子在广播什么血量

接线表、血量规则、C3 的 RMT/ADC 踩坑都在 armor/README.md。

3. 桌面端

cd desktop
pnpm install
pnpm app          # = tauri dev

没有板子也能把整条链路跑起来:CAM_DEMO=1 pnpm app 会合成协议数据报喂进真实的接收 路径(分片、重组、发现、掉血派生、两道闸门全在跑真代码),界面上标着"演示模式"。

首次启动会问这台机器是哪一端:用户端要绑一块图传板与一块装甲板(改动需密码), 裁判端不需要任何配置就能看两路。

desktop/.cargo/config.toml 里的 target = "aarch64-apple-darwin" 换机器必须改。 desktop/ 嵌在固件的目录树里,会继承父目录那个 xtensa-esp32s3-espidf 的交叉编译配置, 然后在依赖深处以 libc 找不到 MAP_FAILED 的形式失败。

界面、视频通道、密码模型、录制与排查都在 desktop/README.md。

发现是双向的

单靠一个方向建立不了"谁要看我":

  • 板子每 2 秒广播一次 Hello —— "我在线,我是谁"
  • 客户端每 800 ms广播一次 Subscribe —— "我是这个角色,请给我画面"

板子从订阅的源地址学到推流目标(所以客户端的 socket 必须固定绑 5600 端口,不能 用临时端口)。

对端靠源地址记忆,不发探测包。续订本来就每 800 ms 一次,所以"还活着吗"这个问题 由续订本身回答了——超时即代表它离开了,多一份心跳状态就多一个能出错的地方。

两端各有自己的超时淘汰,阈值不同:

超时 为什么是这个量级
固件丢弃订阅者 6 s 续订间隔 800 ms,7 倍余量,偶发丢包不会把人踢掉
客户端移除板子 60 s 只影响界面列表,不影响推流

客户端的 60 s 刻意很长:界面列表里留着一块刚重启还没广播的板子,比它短暂消失更可接 受。而它每 5 秒才扫一次,不值得为这个上一份状态。

固件同时最多向 4 个订阅者推流(MAX_PEERS)。每个对端每帧要一次 sendto,这是帧 CPU 成本的倍数;三台客户端(两队 + 裁判)够用,且不让一个陈旧的地址条目拖慢每一帧。

身份:为什么用 MAC 派生

protocol::id_from_mac() 用 FNV-1a 从 MAC 派生 sender_id,避开 0(裁判终端的保留 值)。

用 MAC 而不是 IP,因为客户端要把绑定存进配置文件并在重启后继续有效——DHCP 换地 址是常态,MAC 不会变。用 MAC 也意味着烧同一份固件即可,不需要任何"我是谁"的配置。

线路格式

27 字节头 + 至多 1400 字节载荷,全部小端。细节在 protocol/README.md,那里有完整 的字节图和每个拒收条件。

三处关键决定:

  • 27 字节不凑整。 头只占每个 1427 字节数据报的 1.9%,而为对齐在两端各写一段 手写偏移常量,是引入静默错位的更划算的代价。
  • 每个数据报 1427 字节,低于 1500 MTU。 超 MTU 的数据报要么被 AP 丢、要么被 IP 分片,两种情况的丢包都记在应用账上而不是网络账上——后者本来更容易排查。
  • version 不匹配就拒收,而不是按错误布局继续解析。后者表现为随机崩溃或画面错 乱,比拒绝连接难查得多。

硬件

Freenove ESP32-S3-WROOM CAM,ESP32-S3-WROOM-1-N16R8(16 MB flash + 8 MB octal PSRAM),OV5640。

引脚

src/camera.rs 的 pins 模块:

GPIO
RESET 0
XCLK 15
PCLK 13
VSYNC 6
HREF 7
SIOD (SDA) 4
SIOC (SCL) 5
DATA[0..8] 11, 9, 8, 10, 12, 18, 17, 16

千万不要套用别的板子的引脚表

这块板子的 GPIO 分配是死的:

范围 占用
26–32 SPI flash
33–37 octal PSRAM
19 / 20 USB
43 / 44 console
45 / 46 模组内部

直接套用 AI-Thinker ESP32-CAM 的引脚表会有 7 根冲突。冲突的表现不是"某根线不 通",而是 PIN_FUNC_SELECT 把某个外设接到了 flash 或 PSRAM 上,CPU 在传输中途挂 死——没有报错、没有回溯、串口直接静默。

改引脚前先对照上面这张表。

那块坏掉的 flash 字节

测试用的这块模组在 0xFADE7 有个字节的 bit 5–7 写不进去。镜像放在哪里会决定哪 个镜像字节落到这个地址,落上去就校验失败、bootloader 拒绝启动。

镜像本身不因位置而变,所以移动 app 分区就够了。scripts/flash.sh 会挑最低的 64 KB 对齐、且该地址处字节 flash 真的能存的偏移,写回 partitions.csv,然后重新编译 (重编译会让镜像位移,所以编译后再验证一次)。

换到健康硬件:把 factory 分区放回 0x10000,删掉 flash.sh 里的 BAD_FLASH_ADDR,别的什么都不用改。

Flash 跑 DIO @ 40 MHz 而不是这颗片子的标称 QIO/80 MHz:测试模组的坏字节在 quad 模式 下会被推到 boot loop。QIO 对代码执行速度的提升是明显的(程序从 flash 跑,cache miss 直接打在这条总线上),健康硬件上值得换回去试。

PSRAM DMA 是故意关掉的

开着的话,esp32-camera 让 GDMA 直接把帧缓冲写进 PSRAM,然后只在最后一个 DMA 节点 (1 KB)里找 JPEG 结束标记。标记落在窗口外就丢帧并报 NO-EOI——在这块板子上是大部 分的帧,几帧之后驱动干脆不再交出任何帧,而且不恢复。

关掉之后驱动 DMA 到内部 RAM 的中转缓冲,再 memcpy 进 PSRAM。每帧多一次约 23 KB 的 memcpy,但搜索整个缓冲找标记。对一条能用的图传链路,这是对的取舍。

CPU 频率

CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y 是 sdkconfig.defaults 里最值钱的一行, 而且免费。

ESP-IDF v5.5 的 Kconfig 把 ESP32-S3 默认成 160 MHz(见 components/esp_system/port/soc/esp32s3/Kconfig.cpu),所以默认编译悄悄浪费了一半 CPU。相机采集、esp32-camera 里的 JPEG 搜索、lwIP、发送路径全是 CPU 密集的,这一行一 次性把它们全部搬上去。

WiFi 缓冲留在内部 RAM

CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=n。

这个值曾经按"PSRAM 很空闲"的直觉写成 y,方向恰好相反:WiFi 描述符和 TCP 段每个 包都要碰,内部 SRAM 比隔着内存总线的 PSRAM 快一个数量级。把它们放进 PSRAM 的表现是 高延迟和差吞吐,无论 PSRAM 还剩多少。

帧缓冲相反——它大、相对少碰,留在 PSRAM。

相机任务钉在 CPU1

CONFIG_CAMERA_CORE0=n(即跑在 CPU1)。WiFi 任务钉在 CPU0。esp32-camera 的采集任务 时间是短促且延迟敏感的(DMA 完成、JPEG 标记搜索、拷进帧缓冲),在共享核上会被抢占, 每一帧都被拉长。双核芯片上没有理由共享。

vendored esp32-camera

components/esp32-camera/ 是上游 2.1.7 的副本,因为组件注册表的版本求解器找不到它。

它打了一处补丁:target/esp32s3/ll_cam.c 里的 CAM_IO_MUX_REG()。这个宏负责把引脚 接到 I2C(OV5640 的 SCCB 走 I2C),不接的话传感器根本不会被发现。

注意这个目录叫 target/,但它不是构建目录。 它是上游组件的按芯片分目录 (esp32/、esp32s2/、esp32s3/),里面是 ll_cam.c 等源码,包含上述补丁。所以 .gitignore 里三条 target/ 规则全部锚定在根目录——这是有意的。

改过 C 文件之后

rm -rf target/xtensa-esp32s3-espidf/release/build/esp-idf-sys-*

不删不会重新编译,你的补丁看起来像完全没生效。

摄像头端不编码,客户端不编码

OV5640 的 JPEG 编码器在传感器内部。DVP 接口出来的一帧要么是 JPEG,要么是 YUV 裸 数据。

不编码的话,单帧从约 21 KB 涨到 450 KB(21 倍),而 ESP32-S3 没有硬件 JPEG 编码器(整个 ESP32 系列只有 P4 有,esp_driver_jpeg 里 CONFIG_IDF_TARGET_ESP32P4 是写死的)。

同理,客户端也不编码:板子出的就是传感器里的 JPEG,客户端只解码。ESP32-S3 也没有硬 件 JPEG 解码器。

为什么收包和解码要分开线程

一张 VGA 的 JPEG 解码要几毫秒。放在收包线程里会挤进数据报之间,造成链路上本不存在 的丢帧——不是真的丢了网络包,是应用来不及取走 socket 缓冲。

现在桌面端把 JPEG 原样交给 WebView,由浏览器的原生解码器解——后端连解都不解, 接收线程只做一次 16 字节头的拼接与一次广播。这一条比上面那条更彻底:省掉的不是几毫秒, 而是一整个线程与一次内存拷贝。细节在 desktop/README.md。

排查

现象 原因
板子无输出、串口静默 GPIO 冲突。CPU 在传输中途挂了,见上面那张占用表
几帧后停止出图,报 NO-EOI PSRAM DMA 被打开了
板子在线但 0 clients subscribed 订阅包格式不对。Subscribe 的 role 字节紧跟 27 字节头,在偏移 27
一直接到但画面偶尔解不出来 帧重组的问题。先怀疑你的测量工具:本项目的探测脚本就因为重组状态没按 frame_id 隔离,误报过 34 张坏帧
libc 找不到 MAP_FAILED desktop/.cargo/config.toml 的 target 没改成宿主三元组
串口看不到任何输出 非 TTY 下 espflash monitor 不可用,用 /tmp/rd2.py 裸读
WiFi 连不上 SSID 必须在 2.4GHz。5G 频段看不见
裁判端两块画面宽窄不一 两格必须是 flex: 1 1 0 且 min-width: 0:按内容分宽度时,占位文字长的那一格会多占十几个像素
裁判端两格显示同一块板子 旧版槽位分配的真 bug。现在候选里要排除另一个槽位占着的 id
有画面但状态栏说"离线" 视频帧也必须算存活证据(Discovery::touch),不能只看 Hello
改了绑定却"没生效" 改动需要先解锁;后端每个改动命令都会校验,界面上的入口只是入口

独立核对线路格式:

python3 scripts/udp_probe.py 10

它手写了每一个协议常量,不依赖 protocol crate——正因为如此,如果 crate 的编码 有错,脚本会立刻报出不一致。

四份文档

根 README.md 你在这里。拓扑、性能、构建烧录、硬件坑
protocol/README.md 线路格式、帧重组的取舍、每个拒收条件
armor/README.md 装甲板接线表、血量规则、上报、C3 的 RMT/ADC 踩坑
desktop/README.md 两套页面、视频通道路径、两道闸门、密码模型、录制与排查

装甲板(已实现)

armor/ 是一套独立的 Rust workspace(目标 ESP32-C3,RISC-V):

  • armor-protocol —— 规则层:血量(100 起、每击 −3)、每板独立的 50 ms 受击冷却、 受击后只有那一路灯带全黑 50 ms、按键 FSM、上报编码。纯计算,主机上 22 个测试。
  • armor-firmware —— 固件:四路压电片 40 kHz ADC 连续采样、四路 WS2812(C3 只有一个 RMT 通道可用,靠 rmt_tx_switch_gpio 轮流驱动)、NVS、BOOT 键、WiFi + UDP。

硬件是"一主三从":三块从板里没有 MCU,各自只有一路灯带和一路压电片,直接接线到主板。 接线表与实现细节见 armor/README.md。

scripts/build_armor.sh        # 编译(WIFI_SSID/WIFI_PASS/ARMOR_TEAM 取自 .env)
scripts/flash_armor.sh        # 编译 + 烧录 + 串口日志
python3 scripts/armor_probe.py 15   # 不开界面,单看板子在广播什么血量

桌面端侧:desktop/core/src/armor.rs 的 ArmorHealthStore 监听 5601,按 (team, index) 存最后一次读数,四条都在 1.5 s 内收到才显示——没有数据时显示占位符而不是 0, 0 看起来像真值,现场看到会以为对方已经没血了。界面上直接显示血量数字(整车一条血, 归零显示 0 · 已击毁 并转红);数字变化也会打进客户端日志。

同一块板子还会被单独记下来(帧头里的 MAC 派生 id),所以两端都能列出"场上有哪几块装甲板" 并选一块绑定——命中反馈也是从这个 id 上归位的。这不需要改固件:主板一直在用 encode_from(sender_id) 把自己的 id 打进帧头。

S
Description
笑赛图传方案及裁判系统
Readme
1.2 MiB
Languages
C 54.2%
Rust 27.5%
TypeScript 9.5%
C++ 5.1%
CSS 1.7%
Other 2%