Compare commits

...
4 Commits
Author SHA1 Message Date
Horldsence 7b6593bb75 文档:按 UDP 多端架构重写根 README 与 client README
三份文档此前还是 UDP 之前的内容(旧 HTTP 架构、单路、无身份概念),与实现不符。

根 README 是总入口:拓扑(两块板不区分队伍,"哪一边"由客户端决定)、实测性
能对比、发现协议、身份派生理由、构建烧录、硬件坑、排查表。

client README 覆盖:两种身份对应的两个组件、命令行、绑定流程与配置格式、三个
线程的分工、Slint 的硬约束(Image/Weak 不是 Send、ModelRc 没有 From<Vec<T>>、
基础元素不能挂 callback)。

写的时候逐条核对了代码,发现并修正了几处"我以为是这样":

  - WIFI_AUTH 一直能用但从未进文档,.env.example 里也没有
  - 两端各有超时淘汰(原以为没有),值不同:固件 6 秒丢订阅者、客户端 60 秒
    才从界面列表移除。固件还有 MAX_PEERS=4 的上限,也补上了
  - 两处注释写死 app 分区在 0x40000,实际由 flash.sh 自动挑选
  - 实测数字重测:22 fps / 0 丢帧 / 平均帧 22 kB / 350 包每秒

也把两条踩过的坑写进去了,因为它们都属于"看起来是 A,实际是 B":帧率必须按
计数除以墙钟(累计值除窗口值会报出单调上升的假数字,这个错误出现过两次),
以及探到链路异常先怀疑测量工具。
2026-10-05 19:13:52 +08:00
Horldsence c7f42a1979 scripts: 修 udp_probe.py 的重组与 fps 两处测量错误
探测脚本是诊断工具,测错比不测更糟——它给出的数字会被当成结论。

**重组状态必须按 (sender_id, frame_id) 存。** 原先只按 sender_id 存,于是丢片
之后下一帧的分片会接着凑上一帧的计数,凑够了交出去一张混着两帧的 JPEG,表现为
"JPEG 不完整"。修完:14 秒 132 帧,0 张坏帧。

换帧时被放弃的半成品单独计为"被新帧取代",不并入"JPEG 不完整"——它们本来就
不完整,混在一起会把两种情形都算成链路有问题。

**分窗 fps 的分子分母必须同量纲。** 原先用累计帧数除以窗口时长,于是报出 39、
61、79 这种随时间单调上升的假数字,看起来像板子越跑越快。改成窗口增量除以窗
口时长后稳定在 22,也就是传感器上限。

顺带修正两处与代码不符的注释:

  partitions.csv / sdkconfig.defaults
    两处都写死 app 分区在 0x40000,实际是 scripts/flash.sh 自动挑选并写回,
    当前为 0x060000。补上 flash.sh 会重编译并复验的原因(重编译会让镜像位移,
    所以偏移无法是常量)。

  .env.example
    补 CAM_VSYNC_INVERT 和 WIFI_AUTH。WIFI_AUTH 一直能用但没进文档:通常不用
    设,扫描会报告 AP 的真实认证方式;只有扫不到时(隐藏 SSID)才需要指定,
    指定后只试这一种,不成功直接报错而不是挨个试 10 秒——挨个试等于在猜一个
    本该已知的值。
2026-10-05 19:13:37 +08:00
Horldsence 7be8d7d2ba protocol: 更正上一个提交的归因——坏帧不在这里
上一个提交(1c65eaa)说"34 张 JPEG 不完整是重复分片凑数导致的",并说修完从
34 降到 1。**这个结论是错的**,是我归因到了错误的地方。

实测复查:一到两个订阅者、20 秒 2000+ 个分片,重复投递数为 0(固定端口和
临时端口两种订阅方式都试过)。这条链路上根本不产生重复。

34 张坏帧的真实来源是探测脚本自己:scripts/udp_probe.py 的重组状态只按
sender_id 存,不按 frame_id 存。于是某帧丢了分片之后,下一帧的分片会接着往
同一个计数器里凑,凑够了就交出去一张混着两帧的 JPEG。

FrameAssembler 一直按 frame_id 隔离(换个 frame_id 就重置),所以它没这个
问题——脚本原先跟它不一致,而我又把脚本的输出当成了 crate 的行为。

本次改动:

  protocol/src/lib.rs
    位图去重保留,理由改为正确性而非正在发生的故障,并在注释里写明实测重复
    为 0、以及真正的坏帧来源。删掉一个死变量 `last`。行为不变。

  protocol/README.md
    "重复分片必须去重" 一节改写:说清防护的定位是"哪天真的重复了"。
    新增"按 frame_id 隔离,换帧即放弃"一节,讲清不隔离的具体后果。
    记下探到坏帧时先怀疑测量工具。

值得记的不是位图本身,是那个错误归因:34 张坏帧配上"帧率正常、无报错线索"
这个症状,太像一个真的丢包/重复 bug 了,于是一路写到了注释、文档和提交说明
里。以后探到链路异常,先确认测量工具和被测对象说的是同一件事。
2026-10-05 19:13:24 +08:00
Horldsence 1c65eaabc6 protocol: 修复重复分片能凑出一张坏帧
`seen >= expected` 判断分片是否收齐是错的:UDP 会重复投递,一个丢失的
分片可以被另一个包的副本凑数,于是计数达标而缓冲区里留着空洞,交出去
一张尾部是 0 的 JPEG。

这个 bug 很隐蔽——帧率完全正常,没有任何报错线索,只是偶尔几张图解不
出来。实测 10 秒内出现 34 张这样的坏帧。

改为按分片下标记录是否收到,重复的直接跳过且不计数;再显式确认最后一个
分片确实到过。实测坏帧降到 1(板子启动瞬间的残帧),完整帧率不变。

补两个测试覆盖它——原有测试没有一个会重复发分片,所以这个 bug 一直没被
发现:
  - a_duplicate_cannot_complete_a_frame_with_a_hole
  - a_gap_that_is_never_filled_never_yields_a_frame

同时更新 scripts/udp_probe.py:它有同一个 bug,所以测不出修复效果。它还
把 Subscribe 的 role 字节多填了 12 个零字节,固件按偏移严格读取因而拒收,
表现为"板子在线但 0 clients subscribed"。脚本的意义正是独立核对线路格式,
所以这些偏移是手写的,改协议时必须同步。

另删 scripts/pick-flash-pad.sh:链接期 padding 已被证明移不动
.flash.text 内部,flash.sh 现在会自动挑选避开坏字节的 app 偏移。
2026-10-05 18:54:35 +08:00
9 changed files with 931 additions and 666 deletions
+10 -1
View File
@@ -5,7 +5,16 @@ WIFI_SSID=your-ssid
WIFI_PASS=your-password
# Optional
# Capture resolution: qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga
#CAM_RES=vga
# JPEG quality 0-63, lower is better quality
#CAM_QUALITY=12
#CAM_QUALITY=12
# Set to 0 if your board inverts VSYNC in hardware.
#CAM_VSYNC_INVERT=1
# Pin the authentication mode instead of trusting the scan: none | wep | wpa |
# wpa2 | wpawpa2 | wpa3 | wpa2wpa3 | wapi. Only needed when the scan cannot see
# the AP (a hidden SSID, for instance) - otherwise the scan reports the real
# mode and this only makes it fail faster.
#WIFI_AUTH=wpa2
+279 -334
View File
@@ -1,402 +1,347 @@
# ESP32-S3 摄像头图传(Rust / ESP-IDF / UDP)
**ESP32-S3-WROOM-1-N16R8** + OV2640 / OV3660 / OV5640(DVP 接口)实时图传,
客户端是 Rust + Slint 桌面程序。固件是跑在 ESP-IDF 上的 Rust,用官方
`esp32-camera` 组件做采集。
两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV5640),向队伍客户端与裁判终端推
UDP 图传。
```
protocol/ 线路格式 + 帧重组 (有单元测试,为宿主架构编译)
. 固件 -> ESP32-S3 (交叉编译,Xtensa)
client/ 桌面客户端 (Slint,macOS/Linux/Windows)
固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;客户端是 Rust + Slint
桌面程序。
```text
protocol/ 线路格式 + 帧重组 两端共用,无 ESP-IDF 依赖,13 个单元测试
. 固件 -> ESP32-S3 交叉编译,Xtensa
client/ 桌面客户端 Slint,队伍端 / 裁判端两种身份
```
`protocol/` 被两端共用,所以线路格式不可能各自漂移。
三个 crate 各自独立构建,各有自己的 `.cargo/config.toml`。
## 拓扑
```text
┌──────────────────────────────┐
│ 裁判终端(PC,仅显示) │
│ 队伍一 + 队伍二 + 双方血量 │
└───────────────┬──────────────┘
│ 同一个局域网,广播可用
┌────────────────────┼────────────────────┐
│ │ │
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
│ 图传板 A │ │ 图传板 B │ │ 装甲板 ×4 │
│ (队伍一) │ │ (队伍二) │ │ (预留,未实现) │
└───────────────┘ └───────────────┘ └───────────────┘
```
**两块板子不区分队伍。** 它们只知道自己是谁(MAC 派生的 `sender_id`),"哪一边"由
客户端决定。烧同一份固件即可,配置全在客户端一侧。
裁判终端同时收两块板子。队伍客户端也收全部板子的视频,然后在**界面层**过滤到绑定的
那一块。
## 实测性能
VGA 640x480,JPEG 质量 12,板子与 2.4GHz AP 相距约一间房:
VGA 640x480,传感器产出上限 22 fps(OV5640 在此配置下的实测值)。
| | TCP(HTTP MJPEG) | UDP |
| | TCP(HTTP MJPEG) | UDP(现在) |
|---|---|---|
| 帧率 | 1.9 fps | **20.8 fps** |
| 丢帧 | 大量超时 | **0.0%**(146 帧 / 1270 个数据报) |
| 帧间隔 | 测不出来 | 35 ms |
| 实际吞吐 | 0.28 Mbit/s | 1.72 Mbit/s |
| 传感器自身上限 | 22.2 fps | 22.2 fps |
| 传感器实际产出 | 22 fps | 22 fps |
| 客户端实际收到 | **1.9 fps** | **22 fps** |
| 实际吞吐 | 0.28 Mbit/s | 1.6 Mbit/s |
| 丢帧 | — | 0% |
UDP 这条路已经跑到传感器的极限,网络不再是瓶颈——即便如此也只用掉链路的
6–9%,说明瓶颈从头到尾都不在带宽上。
(为什么用 UDP、以及 TCP 那组数字到底说明什么,见
[`protocol/README.md`](protocol/README.md)。)
TCP 那一栏只用了链路能力的 1% 左右(ESP32-S3 在良好 2.4GHz 链路上 TCP 实测上限约
20–30 Mbit/s)。**链路是空的**,帧却堵在队头阻塞后面等一个实时画面等不起的重传计时
器。
---
UDP 没有重传与确认:缺了分片的帧整个丢弃,下一帧顶上。丢帧率实测 0%,因为带宽从来
不是瓶颈——即便如此也只用掉 6–9%。
# 固件
其余数字:平均帧 22 kB,约 350 包/秒,每帧约 17 个分片,客户端观测的帧间隔 7 ms。
## 环境要求
## 为什么视频走 UDP 而不是 TCP
- **Rust 的 `esp` 工具链。** 仓库里有 `rust-toolchain.toml`,所以
`rustup toolchain install esp` 就够;否则用 `espup install`。
- **ESP-IDF。** `.cargo/config.toml` 通过 `IDF_PATH` 指向已有的安装。删掉那两行
的话,`esp-idf-sys` 会自己 clone 并构建它固定的那个版本——能用,但要下载几百 MB。
- **Python 3**(ESP-IDF 构建系统要用)。
- **esptool** 或 **espflash**,用来烧录。
**延迟优先。** 丢失 5% 的画面远好于等待 500 ms 的重传。
先确认本机已具备:
**没有队头阻塞。** 一帧的任一分片卡住,后面所有帧都跟着卡——对实时画面这正好是错
的方向。
**带宽不是约束。** 见上表,TCP 那栏证明了链路是空的,所以 TCP 的优势在这里换不来
任何东西。
代价是丢包丢整帧。这被接受了:**撕裂的画面比过期的画面更糟**,所以接收端不做补洞,
只等下一张完整帧。
## 帧率必须按计数除以墙钟
"22 fps" 这个数字不是对帧间隔取平均得来的,是每个窗口的**帧数增量除以窗口时长**。
一次 UI 轮询(33ms)之间可能完成多帧,平均间隔量的是**显示**的刷新快慢而不是**相机**
的出帧快慢——那会把上限 22fps 的传感器报成 36fps。**一个笃定的错误数字比没有数字更
糟**,现场你会以为还有性能余量,然后花时间去找一个不存在的问题。
这个错误在本项目里出现过两次(客户端的 `View::update_fps`、探测脚本的分窗打印),两
次都是同一个形状:分子用累计值,分母用窗口值,于是报出随时间单调上升的假数字。
## 快速开始
### 1. 固件
```sh
rustc --version # 必须是 esp 工具链
espflash --version
python3 --version
cp .env.example .env # 填 WIFI_SSID / WIFI_PASS
./scripts/flash.sh --monitor 20
```
## 配置
`flash.sh` 会编译、挑选避开坏字节的 app 偏移、烧录三段(bootloader / 分区表 /
app)、打印串口日志。ESP-IDF 位置从 `.cargo/config.toml` 读,不用先 `activate`。
WiFi 凭据在**编译期**由 `env!` 读取,所以放在 `.env` 里,不进源码。`.env` 已在
gitignore 中。
`.env`:
```sh
cp .env.example .env
```
```sh
# .env
WIFI_SSID=你的2.4G频段ssid
WIFI_PASS=你的密码
WIFI_SSID=你的2.4G网络
WIFI_PASS=密码
# 以下都有默认值:
#CAM_RES=vga # qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga
#CAM_QUALITY=12 # 0-63,数字越小画质越好、字节越少
#CAM_VSYNC_INVERT=1 # 若板子在硬件上反相了 VSYNC,填 0
#WIFI_AUTH=wpa2 # 固定认证方式,仅当扫描看不到 AP 时需要(如隐藏 SSID)
```
SSID **必须是 2.4GHz**。ESP32-S3 的射频不支持 5GHz,5GHz 的 SSID 对它完全不可见,
板子只会一直超时等待。固件在关联前会先扫描并打印可见网络,正是为了让这个问题
能被一眼看出:
凭据由 `env!` 在**编译期**读取,所以没有运行时解析路径,也不会因为忘了读 `.env` 而
在板子上带着别人的密码。
```
I esp32: scanning for 2.4 GHz networks...
I esp32: SR-602-2G4 ch 6 -27 dBm Some(WPAWPA2Personal) <- target
E esp32: "SR-602-5G" was not in the scan results
```
`WIFI_AUTH` 一般不用设:固件先扫描 AP,用它报告的真实认证方式去连,只有扫不到时才
需要人工指定。指定之后**只**试这一种方式——不成功就直接报错,而不是挨个试 10 秒。
挨个试等于在猜一个本该已知的值。
## 编译
### 2. 客户端
```sh
./scripts/build.sh
cd client && cargo build --release
./target/aarch64-apple-darwin/release/cam-client --list
```
这就是"读取 `.env` 后执行 `cargo build --release`"。产物在
`target/xtensa-esp32s3-espidf/release/esp32`(约 1.1 MB)。
> **改了 C 文件?**
> `components/esp32-camera/**` 和 `components/cam_wrapper/**` 不在 `esp-idf-sys`
> 的依赖追踪里,所以改它们**不会**触发重新编译,会静默地用旧代码。先清缓存:
>
> ```sh
> rm -rf target/xtensa-esp32s3-espidf/release/build/esp-idf-sys-*
> ./scripts/build.sh
> ```
## 烧录并看日志
`--list` 列出发现的板子。然后:
```sh
./scripts/flash.sh --monitor 30
cam-client # 裁判端,无需配置
cam-client --role team --team 1 # 队伍端,首次运行问绑定
```
它会编译、烧录、打印串口日志。预期输出:
**`client/.cargo/config.toml` 里的 `target = "aarch64-apple-darwin"` 换机器必须改。**
client/ 嵌在固件的目录树里,会继承父目录那个 `xtensa-esp32s3-espidf` 的交叉编译配
置,然后在依赖深处以 `libc` 找不到 `MAP_FAILED` 的形式失败。
```
I (2790) cam_wrapper: fps probe: 20 frames in 900 ms -> 22.2 fps, avg 9246 bytes
I (9817) esp32::udp: UDP sender listening on port 5600, streaming on request
I (9817) esp32: ESP32 camera is live on 192.168.1.139
I (9817) esp32: point the client at 192.168.1.139:5600 (UDP)
```
## 发现是双向的
`fps probe` 那一行是**传感器上限**,在 WiFi 启动之前测的。排查流速慢时要拿它做
对照:如果 probe 说 22fps 而实际流不到,问题就在两者之间。
单靠一个方向建立不了"谁要看我":
### 手动烧录
- 板子每 **2 秒**广播一次 Hello —— "我在线,我是谁"
- 客户端每 **800 ms**广播一次 Subscribe —— "我是这个角色,请给我画面"
`scripts/flash.sh` 存在的原因之一是有一块坏 flash 字节(见
[硬件相关](#硬件相关))。它用 `esptool` 写三段:
板子从订阅的**源地址**学到推流目标(所以客户端的 socket 必须固定绑 5600 端口,不能
用临时端口)。
```sh
B=$(ls -d target/xtensa-esp32s3-espidf/release/build/esp-idf-sys-*/out/build)
espflash save-image --chip esp32s3 --format esp-idf \
target/xtensa-esp32s3-espidf/release/esp32 /tmp/app.bin
对端靠**源地址**记忆,不发探测包。续订本来就每 800 ms 一次,所以"还活着吗"这个问题
由续订本身回答了——超时即代表它离开了,多一份心跳状态就多一个能出错的地方。
~/.espressif/tools/python/*/venv/bin/python -m esptool \
--chip esp32s3 --port /dev/cu.usbmodemXXX --baud 460800 write-flash \
0x0 "$B/bootloader/bootloader.bin" \
0x8000 "$B/partition_table/partition-table.bin" \
0x10000 /tmp/app.bin
```
两端各有自己的超时淘汰,阈值不同:
注意 app 偏移必须和 `partitions.csv` 里的 `factory` 一致。这里不能用
`cargo espflash flash`:它只写 app 段,而且会在没有 `.env` 的情况下重新编译,
导致 `main.rs` 里的 `env!` 失败。
### 找串口
```sh
ls /dev/cu.usbmodem* # 这块板子显示为 /dev/cu.usbmodem111301
```
`scripts/flash.sh` 会取第一个匹配项,也可以 `ESP_PORT=/dev/cu.usbmodemXXX` 指定。
---
# 客户端
## 环境要求
- Rust(普通 stable 或 nightly,**不要**用固件那个 `esp` 工具链)
- 不需要额外的系统库:macOS 和 Linux 上 Slint 用 `femtovg` 渲染,也不需要 `skia`
> `client/.cargo/config.toml` 把 `target` 钉成 `aarch64-apple-darwin`,因为这个
> 目录位于固件源码树内部,否则会继承 Xtensa 交叉编译配置。**要按你的机器改**——
> Intel Mac 用 `x86_64-apple-darwin`,Linux 用 `rustc -vV | grep host` 的输出。
## 编译与运行
```sh
cd client
cargo build --release
./target/aarch64-apple-darwin/release/cam-client # 用下面的默认地址
./target/aarch64-apple-darwin/release/cam-client 192.168.1.139
```
```
用法: cam-client [CAMERA_IP] [PORT]
CAMERA_IP ESP32 摄像头地址(默认 192.168.1.139)
PORT UDP 视频端口 (默认 5600)
```
改 `ui/camera.slint` 时用一步到位的方式:
```sh
cd client && cargo run --release -- 192.168.1.139
```
## 界面读数
```
streaming 20.8 fps 10.1 kB per frame 35 ms between frames
640x480 shown 146 dropped 0 (0.0%) 1270 datagrams
```
**丢帧率和帧率同等重要**,这正是把统计放在界面上的原因。这条传输链路是**整帧丢弃
而不重传**,所以单看帧率是有歧义的:
| 帧率 | 丢帧 | 含义 |
| | 超时 | 为什么是这个量级 |
|---|---|---|
| 达到传感器上限 | 0 | 链路没问题,瓶颈是传感器 |
| 远低于上限 | 有 | 瓶颈是网络 |
| 低 | 0,且显示 "no frames - camera?" | 板子没在推流 |
| 固件丢弃订阅者 | 6 s | 续订间隔 800 ms,7 倍余量,偶发丢包不会把人踢掉 |
| 客户端移除板子 | 60 s | 只影响界面列表,不影响推流 |
状态文字的颜色是同一信息的粗略版本:推流中绿色,否则琥珀色。
客户端的 60 s 刻意很长:界面列表里留着一块刚重启还没广播的板子,比它短暂消失更可接
受。而它每 5 秒才扫一次,不值得为这个上一份状态。
### 帧率是"计数"算出来的,不是"平均间隔"
固件同时最多向 **4 个**订阅者推流(`MAX_PEERS`)。每个对端每帧要一次 `sendto`,这是帧
CPU 成本的倍数;三台客户端(两队 + 裁判)够用,且不让一个陈旧的地址条目拖慢每一帧。
不是对帧间隔取平均。一次 UI 轮询之间可能已完成好几帧,所以平均间隔量的是
**显示**的刷新快慢,而不是**相机**的出帧快慢——那曾把上限 22fps 的传感器报成
36fps。一个笃定的错误数字比没有数字更糟。
## 身份:为什么用 MAC 派生
## 诊断
`protocol::id_from_mac()` 用 FNV-1a 从 MAC 派生 `sender_id`,避开 0(裁判终端的保留
值)。
**用 MAC 而不是 IP**,因为客户端要把绑定存进配置文件并在重启后继续有效——DHCP 换地
址是常态,MAC 不会变。用 MAC 也意味着烧同一份固件即可,不需要任何"我是谁"的配置。
## 线路格式
27 字节头 + 至多 1400 字节载荷,全部小端。细节在 `protocol/README.md`,那里有完整
的字节图和每个拒收条件。
三处关键决定:
- **27 字节不凑整。** 头只占每个 1427 字节数据报的 1.9%,而为对齐在两端各写一段
手写偏移常量,是引入静默错位的更划算的代价。
- **每个数据报 1427 字节,低于 1500 MTU。** 超 MTU 的数据报要么被 AP 丢、要么被 IP
分片,两种情况的丢包都记在应用账上而不是网络账上——后者本来更容易排查。
- **`version` 不匹配就拒收**,而不是按错误布局继续解析。后者表现为随机崩溃或画面错
乱,比拒绝连接难查得多。
## 硬件
Freenove ESP32-S3-WROOM CAM,ESP32-S3-WROOM-1-N16R8(16 MB flash + 8 MB octal
PSRAM),OV5640。
### 引脚
`src/camera.rs` 的 `pins` 模块:
| | GPIO |
|---|---|
| RESET | 0 |
| XCLK | 15 |
| PCLK | 13 |
| VSYNC | 6 |
| HREF | 7 |
| SIOD (SDA) | 4 |
| SIOC (SCL) | 5 |
| DATA[0..8] | 11, 9, 8, 10, 12, 18, 17, 16 |
### 千万不要套用别的板子的引脚表
这块板子的 GPIO 分配是死的:
| 范围 | 占用 |
|---|---|
| 26–32 | SPI flash |
| 33–37 | octal PSRAM |
| 19 / 20 | USB |
| 43 / 44 | console |
| 45 / 46 | 模组内部 |
直接套用 AI-Thinker ESP32-CAM 的引脚表会有 **7 根冲突**。冲突的表现不是"某根线不
通",而是 `PIN_FUNC_SELECT` 把某个外设接到了 flash 或 PSRAM 上,**CPU 在传输中途挂
死**——没有报错、没有回溯、串口直接静默。
改引脚前先对照上面这张表。
### 那块坏掉的 flash 字节
测试用的这块模组在 `0xFADE7` 有个字节的 bit 5–7 **写不进去**。镜像放在哪里会决定哪
个镜像字节落到这个地址,落上去就校验失败、bootloader 拒绝启动。
镜像本身不因位置而变,所以移动 app 分区就够了。`scripts/flash.sh` 会挑最低的 64 KB
对齐、且该地址处字节 flash 真的能存的偏移,写回 `partitions.csv`,然后重新编译
(重编译会让镜像位移,所以编译后再验证一次)。
**换到健康硬件**:把 `factory` 分区放回 `0x10000`,删掉 `flash.sh` 里的
`BAD_FLASH_ADDR`,别的什么都不用改。
Flash 跑 DIO @ 40 MHz 而不是这颗片子的标称 QIO/80 MHz:测试模组的坏字节在 quad 模式
下会被推到 boot loop。QIO 对**代码执行**速度的提升是明显的(程序从 flash 跑,cache
miss 直接打在这条总线上),健康硬件上值得换回去试。
### `PSRAM DMA` 是故意关掉的
开着的话,esp32-camera 让 GDMA 直接把帧缓冲写进 PSRAM,然后**只在最后一个 DMA 节点
(1 KB)里**找 JPEG 结束标记。标记落在窗口外就丢帧并报 `NO-EOI`——在这块板子上是大部
分的帧,几帧之后驱动干脆不再交出任何帧,而且不恢复。
关掉之后驱动 DMA 到内部 RAM 的中转缓冲,再 memcpy 进 PSRAM。每帧多一次约 23 KB 的
memcpy,但搜索整个缓冲找标记。对一条能用的图传链路,这是对的取舍。
### CPU 频率
**`CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y`** 是 `sdkconfig.defaults` 里最值钱的一行,
而且免费。
ESP-IDF v5.5 的 Kconfig 把 ESP32-S3 默认成 **160** MHz(见
`components/esp_system/port/soc/esp32s3/Kconfig.cpu`),所以默认编译悄悄浪费了一半
CPU。相机采集、esp32-camera 里的 JPEG 搜索、lwIP、发送路径全是 CPU 密集的,这一行一
次性把它们全部搬上去。
### WiFi 缓冲留在内部 RAM
`CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=n`。
这个值曾经按"PSRAM 很空闲"的直觉写成 `y`,方向恰好相反:WiFi 描述符和 TCP 段**每个
包都要碰**,内部 SRAM 比隔着内存总线的 PSRAM 快一个数量级。把它们放进 PSRAM 的表现是
高延迟和差吞吐,无论 PSRAM 还剩多少。
帧缓冲相反——它大、相对少碰,留在 PSRAM。
### 相机任务钉在 CPU1
`CONFIG_CAMERA_CORE0=n`(即跑在 CPU1)。WiFi 任务钉在 CPU0。esp32-camera 的采集任务
时间是短促且延迟敏感的(DMA 完成、JPEG 标记搜索、拷进帧缓冲),在共享核上会被抢占,
每一帧都被拉长。双核芯片上没有理由共享。
## vendored esp32-camera
`components/esp32-camera/` 是上游 2.1.7 的副本,因为组件注册表的版本求解器找不到它。
它打了一处补丁:`target/esp32s3/ll_cam.c` 里的 `CAM_IO_MUX_REG()`。这个宏负责把引脚
接到 I2C(OV5640 的 SCCB 走 I2C),不接的话传感器根本不会被发现。
**注意这个目录叫 `target/`,但它不是构建目录。** 它是上游组件的按芯片分目录
(`esp32/`、`esp32s2/`、`esp32s3/`),里面是 `ll_cam.c` 等源码,包含上述补丁。所以
`.gitignore` 里三条 `target/` 规则全部锚定在根目录——这是有意的。
## 改过 C 文件之后
```sh
RUST_LOG=info ./target/aarch64-apple-darwin/release/cam-client 192.168.1.139
rm -rf target/xtensa-esp32s3-espidf/release/build/esp-idf-sys-*
```
```
frames shown 146 | dropped 0 (0.0%) | datagrams 1270 | undecodable 0 | short 0
```
不删不会重新编译,你的补丁看起来像完全没生效。
| 计数器 | 含义 |
|---|---|
| `frames shown` | 完整重组并交给界面的帧 |
| `dropped` | 因分片缺失而被放弃的帧 |
| `datagrams` | 能解析为分片的数据报 |
| `undecodable` | 收到了但解析不了的数据报 |
## 摄像头端不编码,客户端不编码
`undecodable` 非零意味着板子的数据报**到了但没看懂**——是协议或端口对不上,不是网络
问题。这两者很容易混淆,而这个计数器正是用来区分它们的。
OV5640 的 JPEG 编码器**在传感器内部**。DVP 接口出来的一帧要么是 JPEG,要么是 YUV 裸
数据。
不编码的话,单帧从约 21 KB 涨到 **450 KB**(21 倍),而 ESP32-S3 **没有硬件 JPEG
编码器**(整个 ESP32 系列只有 P4 有,`esp_driver_jpeg` 里
`CONFIG_IDF_TARGET_ESP32P4` 是写死的)。
同理,客户端也不编码:板子出的就是传感器里的 JPEG,客户端只解码。ESP32-S3 也没有硬
件 JPEG 解码器。
## 为什么收包和解码要分开线程
一张 VGA 的 JPEG 解码要几毫秒。放在收包线程里会挤进数据报之间,造成**链路上本不存在
的丢帧**——不是真的丢了网络包,是应用来不及取走 socket 缓冲。
`slint::Image` 不是 `Send`,所以 worker 线程只能处理纯数据(`Vec<u8>` + 尺寸),`Ima
ge` 的构造必须留在事件循环。这条约束决定了整个客户端的线程结构,细节在
`client/README.md`。
## 排查
| 现象 | 原因 |
|---|---|
| 一直显示 `connecting to …` | 板子不在网上,或防火墙拦了入站 UDP 5600 |
| `no frames - camera?` | 板子停止推流:重启了,或掉线了 |
| 完全没有输出,也没有日志 | host triple 不对,见[目标架构](#目标架构) |
| 只有第一帧然后卡住 | 另一个客户端连在别的端口上;板子只推给最后宣告的那个 |
| 颜色错乱 | 通道顺序。当前构建用 `Rgba8Pixel`,另一个选项是 `Bgra8Pixel` |
| 编译在 `libc` / `memmap2` 报错 | 用的是 `esp` 工具链,或缺少 target triple 覆盖 |
| 板子无输出、串口静默 | GPIO 冲突。CPU 在传输中途挂了,见上面那张占用表 |
| 几帧后停止出图,报 `NO-EOI` | `PSRAM DMA` 被打开了 |
| 板子在线但 `0 clients subscribed` | 订阅包格式不对。`Subscribe` 的 `role` 字节紧跟 27 字节头,在偏移 27 |
| 一直接到但画面偶尔解不出来 | 帧重组的问题。**先怀疑你的测量工具**:本项目的探测脚本就因为重组状态没按 `frame_id` 隔离,误报过 34 张坏帧 |
| `libc` 找不到 `MAP_FAILED` | `client/.cargo/config.toml` 的 target 没改成宿主三元组 |
| 串口看不到任何输出 | 非 TTY 下 `espflash monitor` 不可用,用 `/tmp/rd2.py` 裸读 |
| WiFi 连不上 | SSID 必须在 2.4GHz。5G 频段看不见 |
---
# 工作原理
## 采集 → UDP
```
OV5640 ──DVP──> LCD_CAM/GDMA ──> PSRAM 帧缓冲
│
frame::next_frame() (阻塞)
│
切成 1400 字节一个的数据报
│
对每个 peer sendto()
```
`esp_camera_fb_get` 会阻塞到传感器出帧,所以帧率跟随传感器,中间没有任何可能被
积压 overrun 的队列。数据报一进内核,帧缓冲立刻还给驱动——多占着只会增加延迟。
## 对端
板子是服务端,客户端是对等端。客户端绑定 UDP 5600 并发一个宣告数据报;板子随后
就往它看到的源地址推流,当存活探测开始失败时移除该对端。没有握手、没有发现协议,
而且客户端晚连(或板子重启)都能自恢复——因为宣告每 500ms 重发一次。
最多并发 4 个对端。
## 为什么要 `cam_wrapper`
`esp_camera.h` 里的 `camera_config_t` 对已废弃的 SCCB 引脚名用了匿名 union,bindgen
会生成成 unsafe union 字段,从 Rust 构造很别扭。`components/cam_wrapper` 接收一个
纯 int 结构体,在 C 侧填 union,于是 Rust 侧的绑定简单到可以安全构造。
## 客户端线程模型
三个线程,且**没有任何 worker 线程碰 Slint 组件**:
- `udp-reader` —— 收包与重组
- `jpeg-decode` —— JPEG 解码成 RGBA
- 主线程 / 事件循环 —— 转成 `slint::Image` 并更新属性,每 30ms 一次
这是 Slint 的硬性约束而非风格偏好:组件只能在拥有它的线程上使用,
`Weak<App>` 是 `!Send` 就是为了强制这一点。worker 线程去 upgrade 一个 weak handle
会拿到 `None` 然后什么都不做——现象和"界面卡死"完全一样。
---
# 硬件相关
## 引脚表
默认匹配 **Freenove ESP32-S3-WROOM CAM** 的摄像头座(`src/camera.rs` 的 `pins`
模块):
| 信号 | GPIO | | 信号 | GPIO |
|---|---|---|---|---|
| RESET | 0 | | D0 (Y2) | 11 |
| XCLK | 15 | | D1 (Y3) | 9 |
| PCLK | 13 | | D2 (Y4) | 8 |
| VSYNC | 6 | | D3 (Y5) | 10 |
| HREF | 7 | | D4 (Y6) | 12 |
| SIOD | 4 | | D5 (Y7) | 18 |
| SIOC | 5 | | D6 (Y8) | 17 |
| | | | D7 (Y9) | 16 |
数据总线是传感器的 **Y2..Y9**,即 10 位像素字的低字节,所以 Y2 是 D0、Y9 是 D7。
### 千万不要套用别的板子的引脚表
上面每个引脚都是 ESP32-S3 空出来的。下面这些**不是**:
| GPIO | 被什么占用 | 拿来用会怎样 |
|---|---|---|
| 26–32 | 模组的 SPI flash | `PIN_FUNC_SELECT` 改掉 flash 的复用,CPU **在传输中途挂死** |
| 33–37 | octal PSRAM | 同上,对内存总线而言 |
| 19, 20 | 原生 USB | 串口 console 断掉 |
| 43, 44 | console UART | 日志断掉 |
| 45, 46 | 模组内部 | 保留 |
在 ESP32-S3 上套用 AI-Thinker ESP32-CAM 的引脚表是踩这个坑的经典方式:15 根信号里
有 7 根落在 flash、PSRAM 或 USB 上。现象是板子能正常枚举、摄像头也能配置成功,
然后在第一次 `PIN_FUNC_SELECT` 时锁死。
## Flash:DIO @ 40MHz
`sdkconfig.defaults` 用 DIO/40MHz 而不是这个片子标称的 QIO/80MHz,因为测试用的模组
有一块坏 flash 字节(见下),四线模式会把它推进启动循环。硬件正常的话可以换回
QIO + 80MHz——程序是从 flash 运行的,这会明显加快**代码**执行。
## 坏的那块 flash 字节
用于点亮的这块模组,其 SPI flash 在 **`0xFADE7`** 有一个字节的 bit5–7 写不进去。
镜像放在该地址上的任何值都会被破坏,校验和失败,bootloader 拒绝启动。
镜像内容不受它在 flash 中**位置**的影响,所以 `scripts/flash.sh` 会挑一个 64KB 对齐
的 app 偏移,使该地址上的字节是这块 flash 存得下的,相应改写 `partitions.csv`,并在
烧录前校验。
**这是为坏硬件准备的绕行方案,不是设计选择。** 换到正常模组时:删掉
`scripts/flash.sh` 里的 `BAD_FLASH_ADDR` 逻辑,把 `partitions.csv` 的 `factory` 改回
`0x10000`,之后就可以用 `cargo espflash flash`。
## `PSRAM DMA` 是故意关掉的
`sdkconfig.defaults` 里 `CONFIG_CAMERA_PSRAM_DMA=n`,原因就写在那个文件里。简要说:
开着时 `esp32-camera` 只在最后 1KB 的 DMA 节点里找 JPEG 结束标记(EOI)。标记一旦
落在窗口外,这一帧就被 `NO-EOI` 丢弃——在这块板子上是大部分帧,之后驱动彻底不再
交出任何帧且不会恢复。关掉后每帧多一次约 20KB 的 `memcpy`,但搜索范围是整个缓冲。
## CPU 频率
显式设置了 `CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y`,因为 **ESP-IDF v5.5 把 ESP32-S3
的默认值设成 160MHz**(`components/esp_system/port/soc/esp32s3/Kconfig.cpu`)。默认
构建会白白浪费一半 CPU,而采集、JPEG 搜索、lwIP、HTTP 全是 CPU 密集的。启动时确认:
```
I (891) cpu_start: cpu freq: 240000000 Hz
```
## WiFi 缓冲留在内部 RAM
`CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=n`。这里原本是 `y`,理由是"PSRAM 空间充足"——
恰好搞反了。WiFi 描述符和 TCP 段每个包都要碰,而内部 SRAM 比隔着内存总线的 PSRAM
快一个数量级。帧缓冲体积大、相对少碰,仍然留在 PSRAM。
运行时也关掉了 WiFi 省电模式:开着时空闲的信标间隔要花几百毫秒,表现为卡顿和
200–300ms 的 ping。
---
# 可调项
| 项目 | 位置 | 说明 |
|---|---|---|
| 分辨率 | `.env` 的 `CAM_RES` | QVGA..UXGA。CIF 以上需要 PSRAM。 |
| JPEG 质量 | `.env` 的 `CAM_QUALITY` | 0–63,越小越好。VGA 下 12 约 10–20KB/帧。 |
| 帧缓冲数 | `camera.rs` 的 `fb_count` | 3:一个在填、一个在发、一个备用。 |
| 指定传感器 | `main.rs` 的 `SensorHint` | 除非探测失败,否则用 `Auto`。 |
| XCLK | `camera.rs` 的 `xclk_freq_hz` | 20MHz;OV5640 的 PLL 只为此调校过。 |
| VSYNC 极性 | `.env` 的 `CAM_VSYNC_INVERT` | 仅当板子在硬件上反相时需要改。 |
| 采集核心 | `CONFIG_CAMERA_CORE0` | `n` 把采集放到 CPU1,避开 CPU0 上的 WiFi 任务。 |
| 分片大小 | `protocol/` 的 `MAX_FRAGMENT_PAYLOAD` | 1400 保持在 1500 MTU 以内。再大就有 IP 分片风险。 |
## 继续提高帧率
传感器在 VGA 下大约封顶 22fps,而链路现在已经跟得上,所以下一个杠杆在传感器侧:
- **SVGA(800x600)** 牺牲的是分辨率而非帧率——OV5640 的帧率基本由像素时钟决定。
- **调大 `CAM_QUALITY` 的数字** 会缩小帧体积、减少字节数,但瓶颈是每秒帧数而不是
带宽。
- 如果要的是低延迟而不是细节,用 **QQVGA/QVGA**:DVP 总线和 PSRAM 带宽都随像素数
线性变化。
## 协议测试
`protocol/` 是最容易出现细微错误、又无法在固件上验证的部分,所以它有真正的单元测试
并且为宿主架构编译:
独立核对线路格式:
```sh
cd protocol && cargo test
python3 scripts/udp_probe.py 10
```
```
7 passed
```
它手写了每一个协议常量,**不依赖 `protocol` crate**——正因为如此,如果 crate 的编码
有错,脚本会立刻报出不一致。
## 三份文档
| | |
|---|---|
| 根 `README.md` | 你在这里。拓扑、性能、构建烧录、硬件坑 |
| `protocol/README.md` | 线路格式、帧重组的取舍、每个拒收条件 |
| `client/README.md` | 命令行、绑定流程、Slint 的硬约束、线程模型 |
## 装甲板(预留)
`protocol::ArmorHealth`、`ARMOR_PORT`(5601)、`MsgType` 5 号都已就位。客户端有真实的
`ArmorSource` trait,当前实现是 `NoArmorYet`。
**界面显示占位符而不是 0。** 0 看起来像真值——现场看到两个 0 会以为对方已经没血了。
接上以后板子向 `ARMOR_PORT` 发这个结构,客户端实现 `ArmorSource` 即可,界面和其余代
码都不用改。
+182 -126
View File
@@ -1,158 +1,214 @@
# cam-client
# client
[ESP32-S3 摄像头固件](../README.md) 的桌面查看器。接收 UDP 数据报形式的 JPEG 帧,
解码后显示实时画面,并给出帧率与丢帧统计。
Slint 桌面客户端。**两种身份,界面对应两个不同的组件。**
Rust + [Slint](https://slint.dev),不需要额外的系统库。
## 环境要求
- Rust(普通工具链——**不要**用固件那个 `esp` 工具链)
- 已在运行本固件的摄像头,且能在 2.4GHz WiFi 下访问到
## 目标架构
**编译前先设好。** 本目录位于固件源码树内部,Cargo 否则会继承
`../.cargo/config.toml` 去交叉编译 `xtensa-esp32s3-espidf`——报错会出现在依赖
深处,而不是一眼可见的地方。
`client/.cargo/config.toml` 因此把目标钉成宿主架构:
```toml
[build]
target = "aarch64-apple-darwin" # Intel Mac:x86_64-apple-darwin
# Linux:rustc -vV | grep host 的输出
```
查看你的:
```sh
rustc -vV | grep host
```
## 编译
```sh
cargo build --release
```
因为上面的 target 覆盖,二进制落在带架构名的目录里:
| 身份 | 画面 | 血量 | 需要绑定 |
|---|---|---|---|
| `--role referee`(默认) | 两块板左右并排 | 双方 | 否 |
| `--role team` | 一块画面 | 自己那一边 | **是,选定后不可更改** |
```sh
cd client && cargo build --release
./target/aarch64-apple-darwin/release/cam-client
```
## 运行
产物路径带三重名(`target/aarch64-apple-darwin/release/`)是因为下面那节解释了原因。
## 命令行
```text
--role <team|referee> 身份。默认 referee(无需配置即可看两路)
--team <0|1> 自己属于哪一边,仅 team 端用
--camera-id <0xID> 绑定到某块板子(MAC 派生,重启不变)
--camera <IP> 向哪块板子发订阅(默认 255.255.255.255 广播)
--config <路径> 配置文件位置
--list 列出发现的摄像头后退出
-h, --help 显示这段帮助
```
常用:
```sh
./target/aarch64-apple-darwin/release/cam-client # 用下面的默认值
./target/aarch64-apple-darwin/release/cam-client 192.168.1.139
./target/aarch64-apple-darwin/release/cam-client 192.168.1.139 5600
cam-client --list # 先看看场上有几块板子
cam-client --role team --team 1 # 队伍一(首次运行会问绑定)
cam-client --role team --team 1 --camera-id 0x3D4D8A9A # 跳过选择直接绑
```
```
用法: cam-client [CAMERA_IP] [PORT]
CAMERA_IP ESP32 摄像头地址(默认 192.168.1.139)
PORT UDP 视频端口 (默认 5600)
`--list` 会等 5 秒收广播周期再打印,所以它不是即时的。
## 配置文件
默认 `~/.config/cam-client/config.toml`,`--config` 可改。
```toml
role = "team"
team = 1
camera_id = 0x3D4D8A9A
```
改 UI 时:
**没有配置文件也能跑**——裁判端就是无配置启动的,直接显示两路。队伍端缺配置时进入
摄像头选择界面。
```sh
cargo run --release -- 192.168.1.139
命令行覆盖配置文件,反过来不成立(只在内存里改)。这样现场可以把一台机器临时当另一
台用,不动文件。
TOML 的序列化是手写的(`config.rs`),没有引 `toml` crate。配置只有四个标量字段,
引一个解析器不值得;而手写解析换来的是零依赖,以及 `save` 与 `parse` 严格对称这一点
可以被一个测试覆盖。**忘记同步这两个方向的改动是这类代码最典型的 bug**,所以它们挨着
放,并且有 `parses_a_team_config` 盯着。
## 绑定流程
队伍端首次运行:
1. 接收线程开始收广播,板子每 2 秒发一次 Hello
2. 界面列出发现的所有板子(id + IP + 是否在线)
3. 点一个 → 写进配置文件
4. 此后配置里已有 `camera_id`,界面**不再出现选择页**
"选定不可更改"就落在这条路径上:没有 unbind 命令,`--camera-id` 是命令行覆盖而不是改
文件。
改绑的办法只有两种:手改配置文件,或 `rm ~/.config/cam-client/config.toml`。
### `Box::leak` 不是疏忽
绑定回调拿到的 `CallbackHandle` **必须活到窗口关闭**。它是 `Copy`,但 `Drop` 会注销回
调。
这里踩过一个坑:一开始用 `mem::forget`,编译器提示它对 `Copy` 类型无效——这个提示是
对的,`mem::forget` 传的是值,句柄被复制进闭包捕获后原值立刻 drop,回调在第一次点击
后就注销了,**第二次点击什么也不会发生**。`Box::leak` 让所有权真的离开作用域。
窗口里的 `Timer` 同理:被 drop 即停止,也泄漏掉。
## Slint 的三条硬约束
这三条都不是文档里写的,是撞出来的。
**`Weak<App>` 和 `slint::Image` 都不是 `Send`。**
`slint::Image` 尤其致命:JPEG 必须在别的线程解码(一张 VGA 的 JPEG 要几毫秒,放在收包
线程里会挤进数据报之间,造成链路上本不存在的丢帧),但解码出来的 RGBA 无法跨线程交给
UI。
解法是分两段:worker 线程只处理纯数据(`Vec<u8>` + 尺寸),`slint::Image` 的构造留在
事件循环里。`main.rs` 的 `decoded` 槽位就是这个用途,`take_decoded()` 是那段跨界。
**`ModelRc` 没有 `From<Vec<T>>`。**
只有 `From<Rc<M>>`、`From<&[T]>`、`From<[T; N]>`。所以要写:
```rust
std::rc::Rc::new(slint::VecModel::from(v)).into()
```
## 界面读数
而不是 `v.into()`。这个编译错误会在每个第一次用它的人身上重现一次。
```
streaming 20.8 fps 10.1 kB per frame 35 ms between frames
640x480 shown 146 dropped 0 (0.0%) 1270 datagrams
```
**`.slint` 的语法限制。**
丢帧率的存在是有理由的。这条链路**整帧丢弃而不重试**,所以单看帧率是有歧义的:
- `Rectangle` 这类基础元素**不能**挂 `callback`,要用 `TouchArea`
- `in` 属性不能从内部赋值
- 没有 `RowLayout`,用 `HorizontalLayout`
- 窗口不能同时给 `width`/`height` 和 `min-*`
| 帧率 | 丢帧 | 判断 |
## 线程
三个,加上 Slint 自己的:
| 线程 | 做什么 | 为什么单独 |
|---|---|---|
| 达到传感器上限 | 0 | 链路正常,瓶颈是传感器 |
| 远低于上限 | 有 | 瓶颈是网络 |
| 低 | 0,且显示 "no frames - camera?" | 板子没在推流 |
| `udp-reader` | 收包、解析、重组 | 不能让解码挤进数据报之间 |
| `jpeg-decode` | JPEG → RGBA | 几毫秒的活 |
| 事件循环 | 取 RGBA、造 `Image`、刷新界面 | `Image` 只能在拥有它的线程构造 |
| `slint::Timer` 回调(33ms) | 推进上面那条流水线 | 显示不需要 22fps 重绘 |
状态文字的颜色是同一信息的粗略版本:推流中绿色,否则琥珀色。
33ms 的轮询间隔不是每帧一次:更快只是让事件循环和接收路径抢 CPU。
### 帧率是"计数"算出来的,不是"平均间隔"
### 槽位不会左右跳动
不是对帧间隔取平均。一次 UI 轮询之间可能已完成好几帧,所以平均间隔量的是
**显示**的刷新快慢,而不是**相机**的出帧快慢——那曾把上限 22fps 的传感器报成
36fps。一个笃定的错误数字比没有数字更糟。
裁判端按 sender_id 排序填两个槽位,**槽位保持稳定**——某块板子临时离线时不会重新
洗牌。如果每次都重新排版,一块板子掉线会让剩下的画面从左边跳到右边,现场看非常像
"两路串了"。
## 诊断
`view.sender_id != wanted` 才重建 `View`(清零帧率窗口),避免每 33ms 重置一次统计。
```sh
RUST_LOG=info ./target/aarch64-apple-darwin/release/cam-client 192.168.1.139
## 帧率:按计数除以墙钟
```rust
self.fps = (stats.frames_shown - self.window_frames) as f32 / window.as_secs_f32();
```
```
frames shown 146 | dropped 0 (0.0%) | datagrams 1270 | undecodable 0 | short 0
不是对帧间隔取平均。
一次 UI 轮询(33ms)之间可能完成多帧,平均间隔量的是**显示**的刷新快慢而不是**相机**
的出帧快慢——那会把上限 22fps 的传感器报成 36fps。**一个笃定的错误数字比没有数字更糟**,
现场你会以为还有性能余量。
## 血量:接口就位,数据源没有
```rust
let armor: Arc<dyn ArmorSource> = Arc::new(NoArmorYet);
```
| 计数器 | 含义 |
`ArmorSource` 是真的 trait,`protocol::ArmorHealth` 和 `ARMOR_PORT` 都已就位。装甲板接上
后只需换掉这一行,`render_health()` 会自动走 `Some(hps)` 分支,界面和其余代码都不用改。
现在没有数据源,所以**显示占位符而不是 0**。0 看起来像真值——现场看到两个 0 会以为
对方已经没血了,然后开始找裁判理论。
占位分支依然给出 4 个血格(值全 0),因为界面用 `available` 决定颜色:空数组会让整个
占位框消失,看起来像布局出错,而不是像"还没有数据"。
## 内存:解码只留最新一张
解码线程在写入前先 `retain` 掉同一路的旧图(`main.rs`)。
一张 VGA 的 RGBA 是 1.2 MB。两路各留两张就是 5 MB,而延迟换清晰度在这里没有意义——
观战的人要的是最新的画面,不是最完整的那张已经过时的。
## 为什么客户端收全部板子的视频
队伍端也订阅了所有板子,然后在**界面层**过滤到自己那一个。
反过来的做法(板子按订阅者的 role 决定推给谁)会引出一个不对称:板子得维护"谁是队伍
一谁是队伍二"的认知,但那块知识本来就在客户端。裁判端同时要两路的事实说明这个
认知不能放在板子上。
代价是队伍端浪费一份几乎相同的图像数据——实测 264 kB/s,占链路 6–9%,可以接受。
## 换机器要改 `client/.cargo/config.toml`
```toml
[build]
target = "aarch64-apple-darwin"
```
**这行必须改**,它是本文件唯一的存在理由。
client/ 嵌在固件的目录树里,cargo 会向上找到 `../.cargo/config.toml`,那里面
`target = "xtensa-esp32s3-espidf"` 加一个 ldproxy linker,只有在 ESP-IDF 交叉编译里才成立。
没有这个覆盖,桌面客户端会被交叉编译到 Xtensa,然后在依赖深处失败——`libc` 找不到
`MAP_FAILED` 之类的东西,因为它正在为一个没有 `mmap` 的嵌入式目标编译。
父配置的其它设置对非 ESP 目标都不适用,所以只需覆盖 target 一项。
## 线程安全清单
- 接收线程 → 其它:`Arc<Discovery>` / `Arc<LatestFrames>`,内部 `Mutex`
- 解码线程 → 事件循环:`Arc<Mutex<Vec<(u32, DecodedFrame)>>>`
- 事件循环 → 界面:直接调用(同一个线程)
- 绝不跨线程:`Weak<App>`、`slint::Image`、`ModelRc`
## 依赖
| crate | 为什么 |
|---|---|
| `frames shown` | 完整重组并交给界面的帧 |
| `dropped` | 因分片缺失而被放弃的帧 |
| `datagrams` | 能解析为分片的数据报 |
| `undecodable` | 收到了但解析不了的数据报 |
| `slint` / `slint-build` | 界面。macOS 默认渲染器是 femtovg,不需要额外系统库 |
| `image` | 只为 `jpeg` feature,解码用 |
| `protocol`(path) | 共享线路格式。用 path 而不是复制,两边不可能悄悄分叉 |
`undecodable` 非零意味着板子的数据报**到了但没看懂**——是协议或端口对不上,不是网络
问题。这两者很容易混淆,而这个计数器正是用来区分它们的。
`image` 关掉了 default features 只开 jpeg:这个客户端不读 PNG/TIFF/webp 以外的任何
东西,也不写任何文件。
## 排查
| 现象 | 原因 |
|---|---|
| 一直显示 `connecting to …` | 板子不在网上,或防火墙拦了入站 UDP 5600 |
| `no frames - camera?` | 板子停止推流:重启了,或掉线了 |
| 完全没有输出,也没有日志 | host triple 不对,见[目标架构](#目标架构) |
| 只有第一帧然后卡住 | 另一个客户端连在别的端口上;板子只推给最后宣告的那个 |
| 颜色错乱 | 通道顺序。当前构建用 `Rgba8Pixel`,另一个选项是 `Bgra8Pixel` |
| 编译在 `libc` / `memmap2` 报错 | 用的是 `esp` 工具链,或缺少 target triple 覆盖 |
## 目录结构
```
client/
├── Cargo.toml
├── build.rs 编译 ui/camera.slint
├── ui/camera.slint 窗口定义
└── src/
├── main.rs 命令行、线程编排、统计
└── receiver.rs socket、重组、JPEG 解码
```
线路格式在 [`../protocol`](../protocol),与固件共用,两端不会漂移。
## 线程模型
三个线程,且**没有任何 worker 线程碰 Slint 组件**:
| 线程 | 职责 |
|---|---|
| `udp-reader` | `recv_from`、解析、重组 |
| `jpeg-decode` | JPEG → RGBA |
| 主线程(事件循环) | RGBA → `slint::Image`、更新属性,每 30ms 一次 |
这是 Slint 的硬性约束而非偏好:组件只能在拥有它的线程上使用,`Weak<App>` 是
`!Send` 就是为了强制这一点。worker 线程去 upgrade 一个 weak handle 会拿到 `None`
然后什么都不做——现象和"界面卡死"完全一样。
解码单独一个线程,是因为一张 VGA 的 JPEG 要花几毫秒,放在收包线程里会挤进数据报
之间,造成**链路上本不存在**的丢帧。
## 可调项
| 项目 | 位置 | 说明 |
|---|---|---|
| 轮询间隔 | `src/main.rs` 的 `UI_POLL` | 30ms。更快只是白费 CPU,不会多出帧。 |
| 帧率统计窗口 | `FPS_WINDOW` | 2 秒。在 ~22fps 下更短的窗口会量化失真。 |
| 帧超时 | `receiver.rs` 的 `FRAME_TIMEOUT` | 500ms。一帧约 40ms,更慢的就是丢了。 |
| 宣告间隔 | `ANNOUNCE_INTERVAL` | 500ms。同时兼作保活。 |
| 主题 | `build.rs` | 默认 `fluent`;可试 `fluent-dark`、`material`、`native`。 |
这一侧**没有任何东西重新编码**。板子出的就是传感器里的 JPEG,客户端只解码。
+11 -8
View File
@@ -1,14 +1,17 @@
# Name, Type, SubType, Offset, Size, Flags
#
# The app partition starts at 0x40000 rather than the usual 0x10000 on purpose:
# the module this firmware was first brought up on has a defective byte in its
# SPI flash at 0xFADE7 (bits 5..7 cannot be programmed). The app image is
# unchanged by where it is placed, so moving the partition moves which image byte
# lands on that address - and at 0x40000 the byte there is 0x1A, which the flash
# accepts.
# The app partition offset here is chosen by scripts/flash.sh, not by hand. It
# sits well above the usual 0x10000 because the module this firmware was first
# brought up on has a defective byte in its SPI flash at 0xFADE7 (bits 5..7
# cannot be programmed). The app image is unchanged by where it is placed, so
# moving the partition moves which image byte lands on that address - and
# because a rebuild can shift the image again, re-deriving the offset by hand
# would break on the next build. flash.sh picks the lowest 64 KB-aligned offset
# whose byte at that address the flash can actually store, writes it back here,
# and rebuilds.
#
# If you move to healthy hardware, put `factory` back at 0x10000 and delete the
# note in sdkconfig.defaults; nothing else depends on this.
# If you move to healthy hardware, put `factory` back at 0x10000 and delete
# BAD_FLASH_ADDR from scripts/flash.sh; nothing else depends on this.
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x060000, 0x120000,
1 # Name, Type, SubType, Offset, Size, Flags
2 #
3 # The app partition starts at 0x40000 rather than the usual 0x10000 on purpose: # The app partition offset here is chosen by scripts/flash.sh, not by hand. It
4 # the module this firmware was first brought up on has a defective byte in its # sits well above the usual 0x10000 because the module this firmware was first
5 # SPI flash at 0xFADE7 (bits 5..7 cannot be programmed). The app image is # brought up on has a defective byte in its SPI flash at 0xFADE7 (bits 5..7
6 # unchanged by where it is placed, so moving the partition moves which image byte # cannot be programmed). The app image is unchanged by where it is placed, so
7 # lands on that address - and at 0x40000 the byte there is 0x1A, which the flash # moving the partition moves which image byte lands on that address - and
8 # accepts. # because a rebuild can shift the image again, re-deriving the offset by hand
9 # would break on the next build. flash.sh picks the lowest 64 KB-aligned offset
10 # whose byte at that address the flash can actually store, writes it back here,
11 # and rebuilds.
12 #
13 # If you move to healthy hardware, put `factory` back at 0x10000 and delete the # If you move to healthy hardware, put `factory` back at 0x10000 and delete
14 # note in sdkconfig.defaults; nothing else depends on this. # BAD_FLASH_ADDR from scripts/flash.sh; nothing else depends on this.
15 nvs, data, nvs, 0x9000, 0x6000,
16 phy_init, data, phy, 0xf000, 0x1000,
17 factory, app, factory, 0x060000, 0x120000,
+161 -46
View File
@@ -1,49 +1,95 @@
# protocol
摄像头 UDP 图传的线路格式,**固件与客户端共用**。
摄像头 UDP 图传的线路格式,**固件与两端客户端共用**。
```
protocol/ 线路格式 + 帧重组 (13 个单元测试,无 ESP-IDF 依赖)
. 固件 -> ESP32-S3 (交叉编译,Xtensa)
client/ 桌面客户端 (Slint,两种身份)
```
这是本仓库里最该被测试的部分:帧重组和编码都极易出微妙错误,而这两件事都无法
在固件上验证——你只会看到"不出画面"。
因此它没有 ESP-IDF 依赖,会为宿主架构编译:
因此它为宿主架构编译,与 ESP-IDF 完全无关:
```sh
cargo test
cd protocol && cargo test
```
```
7 passed
13 passed
```
## 拓扑
```text
┌──────────────────────────────┐
│ 裁判终端(PC,仅显示) │
│ 队伍一 + 队伍二 + 双方血量 │
└───────────────┬──────────────┘
│ 同一个局域网,广播可用
┌────────────────────┼────────────────────┐
│ │ │
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
│ 图传板 A │ │ 图传板 B │ │ 装甲板 ×4 │
│ (队伍一) │ │ (队伍二) │ │ (预留,未实现) │
└───────────────┘ └───────────────┘ └───────────────┘
```
两块板子不区分队伍——它们只知道自己是谁(MAC 派生的 id),"哪一边"由客户端决定。
烧同一份固件即可,配置在客户端一侧。
## 线路格式
每个数据报 = 20 字节头 + 至多 1400 字节 JPEG 数据:
每个数据报 = **27 字节头** + 至多 **1400 字节**载荷:
```
```text
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-------------------------------+-------------------------------+
| magic (u32) | frame_id (u32) |
+-------------------------------+-------------------------------+
| frag_index (u16) | frag_count (u16) |
+-------------------------------+-------------------------------+
+-------------------------------+-------+-----------------------+
| magic (u32) | type | version (u16) |
+-------------------------------+-------+-----------------------+
| sender_id (u32) | frame_id (u32) |
+-------------------------------+-------+-----------------------+
| frag_index (u16) | frag_count (u16) |
+-------------------------------+-------+-----------------------+
| total_len (u32) | width (u16) | height (u16) |
+-------------------------------+---------------+---------------+
| payload (0..=1400) |
| payload (0..=1400) |
+---------------------------------------------------------------+
```
全部整数为**小端**——ESP32-S3(Xtensa)和所有桌面架构都原生如此,编解码就是
全部整数**小端**——ESP32-S3(Xtensa)和所有桌面架构都原生如此,编解码就是
`to_le_bytes` / `from_le_bytes`,没有字节序转换步骤。
- `magic` 固定值,用于把同端口上的无关 UDP 包挡掉。
- `total_len` 是整张 JPEG 的长度,接收端据此预分配缓冲并判断帧是否完整。
- `width`/`height` 每个分片都重复携带(不只是第 0 个),这样每个数据报都能独立
解析,中途加入的接收端也能立刻知道分辨率。
`msg_type` 决定同一端口上的字节如何解释,这是视频、发现、装甲板能共用一个端口的
前提:
## 为什么是 UDP 而不是 TCP
| type | 方向 | 用途 |
|---|---|---|
| 1 `Video` | 板子 → 客户端 | 一帧 JPEG 的第 N 个分片 |
| 2 `Subscribe` | 客户端 → 板子 | "我是这个角色,请给我画面" |
| 3 `Hello` | 板子 → 客户端 | "我在线,我是谁" |
| 4 `Heartbeat` | 板子 → 客户端 | 同上,心跳形式 |
| 5 `ArmorHealth` | 装甲板 → 客户端 | 血量(**预留,当前无实现**) |
这是本 crate 存在的原因,也是实测数据:
### 三个设计决定
**27 字节不凑整。** 头只占每个 1427 字节数据报的 1.9%,而为对齐在固件和客户端两侧
各写一段手写偏移常量,是引入静默错位的更划算的代价。
**`sender_id` 是多路的前提。** 裁判终端同时收两块板子,靠它在同一端口上区分来源。
**`width`/`height` 每个分片都重复携带**(不只是第 0 个),这样每个数据报都能独立
解析,中途加入的接收端也能立刻知道分辨率。
**`version` 不匹配就拒收**,而不是按错误布局继续解析——后者会表现为随机崩溃或画面
错乱,比拒绝连接难查得多。
## 为什么视频走 UDP 而不是 TCP
实测(VGA 640x480,传感器产出 22 fps):
| | TCP(HTTP MJPEG) | UDP |
|---|---|---|
@@ -51,45 +97,114 @@ cargo test
| 客户端实际收到 | **1.9 fps** | **20.8 fps** |
| 实际吞吐 | 0.28 Mbit/s | 1.72 Mbit/s |
关键在于**链路是空的**:TCP 那一栏只用了链路能力的 1% 左右(ESP32-S3 在良好的
2.4GHz 链路上,TCP 实测上限约 20–30 Mbit/s),帧却堵在队头阻塞后面等一个实时画面
等不起的重传计时器。
TCP 那一栏只用了链路能力的 1% 左右(ESP32-S3 在良好 2.4GHz 链路上 TCP 实测上限
约 20–30 Mbit/s)。**链路是空的**,帧却堵在队头阻塞后面等一个实时画面等不起的
重传计时器。
UDP 没有任何重传与确认:缺了分片的帧被整个丢弃,下一帧顶上。这换来低延迟,也把
本来就有的带宽真正用起来了——但即便如此也只用掉 6–9%,说明瓶颈从头到尾都不在
带宽上。
UDP 没有重传与确认:缺了分片的帧整个丢弃,下一帧顶上。这换来低延迟,也把本来就
有的带宽真正用起来了——但即便如此也只用掉 6–9%,说明瓶颈从头到尾都不在带宽上。
## 接收端的取舍
`FrameAssembler` 的策略就是全部要点:**只有分片全部到齐才显示该帧,且最新的帧永远
优先。**
`FrameAssembler` 的策略就是全部要点:**一张帧只有所有分片都到齐才交付,且最新的
帧永远优先。**
UDP 不保证跨数据报的顺序或到达,所以试图"补洞"的接收端做的功比"等下一张完整帧"
UDP 不保证跨数据报的顺序与到达,所以试图"补洞"的接收端做的功比"等下一张完整帧"
更多——而且补的过程中会显示撕裂的画面。
另外有一个有界超时,用来堵住另一种失败模式:发送方在帧中途死掉时,半截缓冲会
永远留在这里。
### 按发送方分开
| 字段 | 含义 |
|---|---|
| `frag_count == 0` | 非法,立即拒绝 |
| `frag_index >= frag_count` | 自相矛盾,立即拒绝 |
| `payload_len > total_len` | 损坏,立即拒绝(在造成大额分配之前就挡住) |
**每个发送方必须有自己的重组器。** 两块板子的 `frame_id` 各自独立计数,塞进同一个
重组器会让它们互相顶掉,两路都出不来。
## 发送端
### 重复分片必须去重
`fragment_frame()` 从 JPEG 长度推出分片数,因此调用方不可能产出与自身矛盾的
分片集合。
`FrameAssembler` 按分片下标记录是否收到,重复的直接跳过且不计数,并显式确认最后一个
分片确实到过。
用单一计数 `seen >= expected` 判断收齐不够健壮:一个丢失的分片若能被某个分片的副本
凑数,计数就达标了,而缓冲区里留着空洞,交出去一张尾部是 0 的 JPEG。两个测试专门覆盖
它(`a_duplicate_cannot_complete_a_frame_with_a_hole`、
`a_gap_that_is_never_filled_never_yields_a_frame`)。
**不过要说清楚:在实测链路上一次重复投递都没出现过。** 一到两个订阅者、20 秒 2000+
个分片,重复数为 0(固定端口与临时端口两种订阅方式都试过)。所以这层防护是为了正确
性,不是为了修一个正在发生的故障。
它防的是"哪天真的重复了"——那时症状是**帧率完全正常、没有任何报错线索**,只有偶尔
几张图解不出来,比一次崩溃难查得多。
### 按 frame_id 隔离,换帧即放弃
每个 `FrameAssembler` **按 `frame_id` 隔离**状态:`frame_id` 一变就重置,之前那帧的半
成品直接丢掉。
不隔离的后果很具体——某帧丢了分片之后,下一帧的分片会接着往同一个计数器里凑,凑够
了就交出去一张**混着两帧**的 JPEG。
这个坑我踩过:`scripts/udp_probe.py` 最初只按 `sender_id` 存重组状态,于是 10 秒里报出
34 张"JPEG 不完整"。那个数字看起来像丢包或重复分片的证据,我据此给 `protocol` 写了一
个"修复重复分片"的提交和一段因果说明——**结论是错的**,那段归因比它揭示的 bug 更值得
记下来。修掉之后同样 14 秒内是 0 张。
探到坏帧时,先怀疑测量工具。
### 有界超时
发送方在帧中途死掉时,半截缓冲会永远留在重组器里。`expire()` 堵住这个。
### 发送端的复用
`Fragment::encode_into()` 追加写入而不分配:发送端对每帧的每个分片复用同一个缓冲,
在 22fps 下这是每秒约 250 次分配与不分配的区别——在 WiFi 栈也要抢内部 RAM 的芯片
上,这个差别很实在。
在 22fps 下这是每秒约 250 次分配与不节省别的差别——在这块同时要抢内部 RAM 的芯片
上很实在。
`fragment_frame()` 从 JPEG 长度推出分片数,因此调用方不可能产出与自身矛盾的集合。
## 拒收条件
`Fragment::decode` 会挡掉这些,`FrameAssembler` 不必再操心:
| 条件 | 为什么 |
|---|---|
| `magic` 不符 | 同端口上可能有别的协议 |
| `version` 不符 | 按错误布局解析比拒收难查 |
| 长度不足 27 字节 | 可能是存活探测包(1 字节) |
| `frag_count == 0` | 自相矛盾 |
| `frag_index >= frag_count` | 自相矛盾 |
| 载荷比 `total_len` 还长 | 损坏,且在造成大额分配之前就该挡住 |
## 不变量
- 一个数据报不超过 `HEADER_LEN + MAX_FRAGMENT_PAYLOAD` = 1420 字节,低于典型以太网
的 1500 MTU。
这一点比看起来重要:超过 MTU 的数据报要么被 AP 丢掉,要么被 IP 分片,而两种情况
的丢包都会**记在应用账上**而不是网络账上——后者本来更容易排查。
- VGA 下 `MAX_FRAGMENT_PAYLOAD` 对应每帧 8–10 个数据报。
- `DEFAULT_PORT` = 5600,两端共用同一个常量。
- 一个数据报不超过 `HEADER_LEN + MAX_FRAGMENT_PAYLOAD` = **1427 字节**,低于典型
以太网的 1500 MTU。
这一点比看起来重要:超 MTU 的数据报要么被 AP 丢弃,要么被 IP 分片,两种情况的
丢包都**记在应用账上**而不是网络账上——后者本来更容易排查。
- VGA 下每帧约 17 个分片。
- `DEFAULT_PORT` = 5600,两端共用同一个常量。
- `ARMOR_PORT` = 5601,装甲板预留,当前无实现。
## 身份
`id_from_mac()` 用固定长度输入的 FNV-1a 从 MAC 派生 `sender_id`。
**用 MAC 而不是 IP**,因为客户端要把绑定存进配置文件并在重启后继续有效——DHCP 换
地址是常态,MAC 不会变。用 MAC 也意味着烧同一份固件即可,不需要任何"我是谁"的配置。
返回值避开 0,那是裁判终端的保留值。哈希有测试保证稳定性与不撞车。
## 独立核对
`scripts/udp_probe.py` 手写了这里的每一个常量,**不依赖本 crate**。
这正是它的价值:如果本 crate 的编码有错,脚本仍按文档解析,会立刻报出不一致。反面
例子也有——它的 `role` 字节曾多填 12 个零字节,固件按偏移严格读取因而拒收,表现为
"板子在线但 0 clients subscribed"。改协议时记得同步它。
## 装甲板(预留)
`ArmorHealth` 与 `ARMOR_PORT` 已就位,`MsgType` 也留了 5 号。现在没有实现也没有
数据源,裁判界面显示占位符而不是 0——**0 看起来像真值,现场看到会以为对方已经没
血了**。
接上以后板子向 `ARMOR_PORT` 发这个结构,客户端监听即可,界面与其它代码都不用改。
+79 -3
View File
@@ -540,8 +540,13 @@ pub struct FrameAssembler {
height: u16,
since: Option<std::time::Instant>,
timeout: std::time::Duration,
/// 已收到的分片数,含重复。用来统计网络丢了多少。
/// 已收到的分片数(不含重复)。用来统计网络丢了多少。
seen: usize,
/// 每个分片是否已收到。
///
/// 存在的原因是 `seen` 会被重复包灌水:UDP 会重复投递,一个丢失的分片可以
/// 被另一个包的副本凑数,于是"收够了"却留着空洞,交出去一张尾部是 0 的坏图。
have: Vec<bool>,
}
impl FrameAssembler {
@@ -557,6 +562,7 @@ impl FrameAssembler {
since: None,
timeout,
seen: 0,
have: Vec::new(),
}
}
@@ -602,12 +608,32 @@ impl FrameAssembler {
self.height = frag.header.height;
self.since = Some(std::time::Instant::now());
self.seen = 0;
self.have.clear();
self.have.resize(self.expected, false);
}
Some(id) if id == frame_id => {}
_ => unreachable!("current_id was just reconciled with frame_id"),
}
let start = frag.header.frag_index as usize * MAX_FRAGMENT_PAYLOAD;
let index = frag.header.frag_index as usize;
// 重复分片直接跳过,且**不**增加计数。
//
// 用 `seen` 一个计数判断收齐是不够健壮的:一个丢失的分片若能被某个分片的
// 副本凑数,计数就达标了,而缓冲区里留着空洞,交出去一张尾部是 0 的 JPEG。
// 位图让"收到过"这件事是显式的,不用靠计数的算术去推断。
//
// 要说明的是:在本项目实测的链路上(2.4GHz AP,一到两个订阅者,20 秒 2000+
// 个分片)**一次重复投递都没出现过**,所以这层防护是为了正确性而不是为了
// 修一个正在发生的故障。之前观察到的 34 张坏 JPEG 另有原因:探测脚本自己
// 的重组状态只按 sender_id 存、不按 frame_id 存,于是丢片之后下一帧的分片
// 会接着凑上一帧的计数。那个脚本已修,见 scripts/udp_probe.py。
if self.have[index] {
return Err(DropReason::Superseded);
}
self.have[index] = true;
let start = index * MAX_FRAGMENT_PAYLOAD;
let end = start + frag.payload.len();
if end > self.buffer.len() {
@@ -616,7 +642,12 @@ impl FrameAssembler {
self.buffer[start..end].copy_from_slice(&frag.payload);
self.seen += 1;
if self.seen >= self.expected {
// 计数达标还不够:最后一个分片必须真的到过,否则中间可能缺一块。
// 显式检查尾块,比依赖计数的算术更直接,也更容易看出意图。
let tail_index =
(frag.header.total_len as usize).saturating_sub(1) / MAX_FRAGMENT_PAYLOAD;
if self.seen >= self.expected && self.have[tail_index] {
let total = frag.header.total_len as usize;
self.buffer.truncate(total.min(self.buffer.len()));
@@ -660,6 +691,7 @@ impl FrameAssembler {
self.buffer.shrink_to_fit();
self.since = None;
self.seen = 0;
self.have = Vec::new();
}
}
@@ -852,6 +884,50 @@ mod tests {
assert_ne!(a, id_from_mac(&[1, 2, 3, 4, 5, 6]));
}
#[test]
fn a_duplicate_cannot_complete_a_frame_with_a_hole() {
// UDP 会重复投递。一个丢失的分片若能被另一片的副本凑数,接收端就会以为
// 收齐了,交出一张中间是 0 的坏 JPEG——而且帧率正常、没有任何报错线索。
let jpeg: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
let frags = fragment_frame(1, 1, &jpeg, 640, 480);
let mut asm = FrameAssembler::new(std::time::Duration::from_secs(2));
// 丢掉第 2 片,然后把第 0 片连送三次来灌 seen。
for f in frags.iter().filter(|f| f.header.frag_index != 2) {
let _ = asm.push(f.clone());
}
for _ in 0..3 {
assert!(
asm.push(frags[0].clone()).is_err(),
"a duplicate must not be allowed to complete the frame"
);
}
assert!(asm.is_in_progress(), "the hole must keep it incomplete");
// 补上真正的第 2 片,才在这一片上收齐——所以结果要从这一次的返回值拿。
let completed = asm.push(frags[2].clone());
assert_eq!(
completed.expect("completes once the hole is filled").data,
jpeg,
);
}
#[test]
fn a_gap_that_is_never_filled_never_yields_a_frame() {
let jpeg = vec![0xAAu8; 5000];
let frags = fragment_frame(1, 1, &jpeg, 640, 480);
let mut asm = FrameAssembler::new(std::time::Duration::from_secs(2));
// 反复送同一片,永远凑不齐——接收端必须一直等着,而不是交出半张图。
for _ in 0..50 {
assert!(asm.push(frags[0].clone()).is_err());
}
assert!(asm.is_in_progress(), "50 duplicates must not complete a frame");
assert_eq!(asm.sender_id(), 1, "and it must still be the same camera");
}
#[test]
fn empty_jpeg_still_produces_one_fragment() {
let frags = fragment_frame(1, 1, &[], 64, 48);
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bash
# Finds a FLASH_PAD value that moves the firmware off a defective SPI flash byte.
#
# The module this was developed against has one byte at flash address 0xFADE7
# whose bits 5..7 cannot be programmed, so whatever lands there gets corrupted
# and the image checksum (and therefore the app) fails to boot. Growing
# FLASH_PAD shifts .flash.text, which moves a different byte onto that address.
# A byte is safe when its top three bits are zero, i.e. value <= 0x1F.
#
# Usage: scripts/pick-flash-pad.sh [max_attempts]
# Prints the FLASH_PAD value to use, or exits non-zero if none was found.
set -euo pipefail
cd "$(dirname "$0")/.."
# Flash address of the defective cell, and the offset of the app partition.
readonly BAD_FLASH_ADDR="${BAD_FLASH_ADDR:-0xFADE7}"
readonly APP_OFFSET="${APP_OFFSET:-0x10000}"
readonly MAX_ATTEMPTS="${1:-40}"
image=$(mktemp -t esp32-app.XXXXXX.bin)
trap 'rm -f "$image"' EXIT
bad_offset=$(python3 -c "print($BAD_FLASH_ADDR - $APP_OFFSET)")
echo "bad cell: flash $BAD_FLASH_ADDR (image offset $bad_offset)"
for ((pad = 0; pad < MAX_ATTEMPTS; pad++)); do
FLASH_PAD="$pad" ./scripts/build.sh >/dev/null 2>&1 || {
echo "build failed for FLASH_PAD=$pad" >&2
exit 1
}
espflash save-image --chip esp32s3 --format esp-idf \
target/xtensa-esp32s3-espidf/release/esp32 "$image" >/dev/null 2>&1
byte=$(python3 - "$image" "$bad_offset" <<'PY'
import sys
data = open(sys.argv[1], "rb").read()
off = int(sys.argv[2])
print(data[off] if off < len(data) else -1)
PY
)
if (( byte >= 0 && (byte & 0xE0) == 0 )); then
echo "FLASH_PAD=$pad works: byte at $BAD_FLASH_ADDR would be 0x$(printf '%02X' "$byte")"
echo "set FLASH_PAD to $pad (in src/main.rs, or export it at build time)"
exit 0
fi
echo " FLASH_PAD=$pad -> 0x$(printf '%02X' "$byte" 2>/dev/null || echo '??') (unsafe)"
done
echo "no safe FLASH_PAD found in 0..$((MAX_ATTEMPTS - 1)); this module's flash is too broken" >&2
exit 1
+206 -91
View File
@@ -1,110 +1,225 @@
#!/usr/bin/env python3
"""Minimal UDP listener for the camera stream, used to verify the firmware.
"""独立验证相机 UDP 图传线路的诊断脚本。
Not the real client - just enough to confirm datagrams arrive, that they
reassemble into a valid JPEG, and to measure the frame rate independently of
the Slint app.
不依赖 Rust,也不依赖 protocol crate——正因为如此,它能当作线路格式的
**独立**检查:如果 protocol crate 的编码有错,这个脚本(手写的常量)仍然
按文档解析,会立刻报出不一致。
用法:
python3 scripts/udp_probe.py [秒数]
它做三件事:
1. 广播一次 Subscribe,触发板子开始推流
2. 解析收到的数据报,按 sender_id 重组
3. 报告帧率、丢帧、以及是否发现了板子的 Hello
常量是手写的,与 protocol/src/lib.rs 保持一致。改了协议记得改这里——
这正是它作为交叉检查的价值所在。
"""
import socket, sys, time, collections
import socket
import sys
import time
from collections import defaultdict
HOST = "192.168.1.139"
PORT = 5600
DURATION = float(sys.argv[1]) if len(sys.argv) > 1 else 10.0
# ---- 与 protocol/src/lib.rs 一致 ----
HEADER = 27 # magic4 + type1 + ver2 + sender4 + frame4 + idx2 + cnt2 + len4 + w2 + h2
MAGIC = 0x4D564331 # "MVC1"
FRAG = 1400 # MAX_FRAGMENT_PAYLOAD
PORT = 5600 # DEFAULT_PORT
BROADCAST = "255.255.255.255"
# Must match protocol/src/lib.rs: MAGIC, HEADER_LEN, MAX_FRAGMENT_PAYLOAD,
# DEFAULT_PORT. Duplicated here on purpose - this is a throwaway diagnostic, and
# making it depend on the Rust crate would defeat the point of it being an
# independent check of the wire format.
HEADER = 20
MAGIC = 0x4D564331
FRAG = 1400
MSG_VIDEO = 1
MSG_SUBSCRIBE = 2
MSG_HELLO = 3
MSG_HEARTBEAT = 4
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("", PORT))
TYPE_NAMES = {
MSG_VIDEO: "video",
MSG_SUBSCRIBE: "subscribe",
MSG_HELLO: "hello",
MSG_HEARTBEAT: "heartbeat",
}
# Announce ourselves, exactly as the Slint client will.
sock.sendto(b"\x00", (HOST, PORT))
sock.settimeout(0.5)
frag_bytes = FRAG
frames = {}
stats = collections.Counter()
sizes = []
start = time.time()
last_print = start
while time.time() - start < DURATION:
try:
data, _ = sock.recvfrom(2048)
except socket.timeout:
continue
stats["datagrams"] += 1
def decode(data):
"""把一个数据报解析成 (msg_type, header_dict, payload)。"""
if len(data) < HEADER:
stats["short"] += 1
continue
return None
magic = int.from_bytes(data[0:4], "little")
if magic != MAGIC:
stats["bad_magic"] += 1
continue
return None
frame_id = int.from_bytes(data[4:8], "little")
frag_index = int.from_bytes(data[8:10], "little")
frag_count = int.from_bytes(data[10:12], "little")
total_len = int.from_bytes(data[12:16], "little")
width = int.from_bytes(data[16:18], "little")
height = int.from_bytes(data[18:20], "little")
msg_type = data[4]
header = {
"version": int.from_bytes(data[5:7], "little"),
"sender_id": int.from_bytes(data[7:11], "little"),
"frame_id": int.from_bytes(data[11:15], "little"),
"frag_index": int.from_bytes(data[15:17], "little"),
"frag_count": int.from_bytes(data[17:19], "little"),
"total_len": int.from_bytes(data[19:23], "little"),
"width": int.from_bytes(data[23:25], "little"),
"height": int.from_bytes(data[25:27], "little"),
}
return msg_type, header, data[HEADER:]
payload = data[HEADER:]
if frame_id not in frames:
if frames:
stats["superseded"] += 1
frames[frame_id] = {
"buf": bytearray(total_len),
"seen": 0,
"count": frag_count,
"total": total_len,
"w": width,
"h": height,
"t0": time.time(),
}
def main():
duration = float(sys.argv[1]) if len(sys.argv) > 1 else 10.0
host = sys.argv[2] if len(sys.argv) > 2 else BROADCAST
f = frames[frame_id]
off = frag_index * frag_bytes
if off + len(payload) <= total_len:
f["buf"][off:off + len(payload)] = payload
f["seen"] += 1
else:
stats["overflow"] += 1
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.bind(("", PORT))
sock.settimeout(0.5)
if f["seen"] >= f["count"]:
jpeg = bytes(f["buf"])
if jpeg[:2] == b"\xff\xd8" and jpeg[-2:] == b"\xff\xd9":
stats["good"] += 1
sizes.append((f["total"], f["w"], f["h"]))
else:
stats["corrupt"] += 1
del frames[frame_id]
# 订阅:板子看到它就开始往本机推流。
#
# role 字节紧跟在 HEADER 之后,也就是偏移 HEADER 处。之前这里多填了 12 个
# 零字节,role 落到了偏移 39——板子的 Subscribe::decode 严格按偏移读,于是
# 认不出 role 而拒收,表现为"板子在线但 0 clients subscribed"。
# 这里刻意手写而不是复用 protocol crate:这个脚本的意义就在于独立验证,
# role 的位置写错时它会给出和固件一致的结论,而不是把错误藏起来。
header = (
MAGIC.to_bytes(4, "little")
+ bytes([MSG_SUBSCRIBE])
+ (1).to_bytes(2, "little") # version
+ (0x0C110001).to_bytes(4, "little") # client_id
+ b"\x00" * 4 # frame_id
+ b"\x00" * 4 # frag_index + frag_count
+ b"\x00" * 4 # total_len
+ b"\x00" * 4 # width + height
)
sub = header + bytes([2]) # role = referee,紧跟 27 字节头
sock.sendto(sub, (host, PORT))
print(f"已向 {host}:{PORT} 广播订阅(role=referee),监听 {duration:.0f} 秒…")
now = time.time()
if now - last_print >= 2.0:
elapsed = now - last_print
print(
f" {stats['good']/elapsed:5.1f} fps "
f"datagrams={stats['datagrams']:5d} "
f"superseded={stats['superseded']:4d} corrupt={stats['corrupt']:3d} "
f"good={stats['good']:4d}"
)
last_print = now
# 每个发送方一份重组状态:两块板的 frame_id 各自独立,混在一起会互相顶掉。
pending = {}
boards = {}
counters = defaultdict(int)
sizes = []
start = time.time()
last_print = start
prev_frames = 0
prev_pkts = 0
prev_drops = 0
sock.close()
print()
print("totals:", dict(stats))
if sizes:
avg = sum(s[0] for s in sizes) / len(sizes)
print(f"avg frame: {avg:.0f} bytes, {sizes[0][1]}x{sizes[0][2]}")
print(f"throughput: {sum(s[0] for s in sizes)/DURATION/1024:.1f} kB/s")
while time.time() - start < duration:
try:
data, addr = sock.recvfrom(2048)
except socket.timeout:
continue
parsed = decode(data)
if parsed is None:
counters["无法解析"] += 1
continue
msg_type, h, payload = parsed
counters[f"type:{TYPE_NAMES.get(msg_type, msg_type)}"] += 1
if msg_type in (MSG_HELLO, MSG_HEARTBEAT):
boards[h["sender_id"]] = addr
continue
if msg_type != MSG_VIDEO:
continue
sid = h["sender_id"]
# 与 Fragment::decode 相同的自洽性检查。
if h["frag_count"] == 0 or h["frag_index"] >= h["frag_count"]:
counters["分片索引非法"] += 1
continue
if len(payload) > h["total_len"]:
counters["载荷超过总长"] += 1
continue
# 重组状态必须按 (sender_id, frame_id) 存,缺一不可。
#
# 之前这里只按 sender_id 存,于是某一帧丢了分片之后,下一帧的分片会接着往
# 同一个计数器里凑,凑够了就交出去一张混着两帧的坏 JPEG——报成 34 张
# "JPEG 不完整",看起来像重复分片或丢包的证据,其实是这个键选错了。
# protocol crate 的 FrameAssembler 一直按 frame_id 区分(换个 frame_id
# 就重置),这里原先跟它不一致。
key = (sid, h["frame_id"])
state = pending.get(key)
if state is None:
# 新的一帧到了:丢掉之前那帧的半成品。实时画面宁可少一帧,也不等它。
# 这些帧算"被新帧取代",不是"JPEG 不完整"——它们本来就不完整,
# 混在一起会把两种情形都算成链路有问题。
counters["被新帧取代"] += len(pending)
pending = {
key: {
"buf": bytearray(h["total_len"]),
"have": set(),
"expect": h["frag_count"],
"total": h["total_len"],
"w": h["width"],
"h": h["height"],
"started": time.time(),
}
}
state = pending[key]
# 同一帧内的重复分片直接跳过且不计数:否则 seen 达标而缓冲区仍有空洞。
if h["frag_index"] in state["have"]:
counters["重复分片"] += 1
continue
state["have"].add(h["frag_index"])
off = h["frag_index"] * FRAG
state["buf"][off:off + len(payload)] = payload
tail = (h["total_len"] - 1) // FRAG
if len(state["have"]) >= state["expect"] and tail in state["have"]:
jpeg = bytes(state["buf"][:state["total"]])
if jpeg[:2] == b"\xff\xd8" and jpeg[-2:] == b"\xff\xd9":
counters["完整帧"] += 1
sizes.append((state["total"], state["w"], state["h"]))
else:
counters["JPEG 不完整"] += 1
del pending[key]
now = time.time()
if now - last_print >= 2.0:
# 分子分母必须都是本窗口的增量。早先用累计帧数除以窗口时长,于是报出
# 39、61、79 这种随时间单调上升的假 fps——看起来像板子越跑越快。
elapsed = now - last_print
window_frames = counters["完整帧"] - prev_frames
window_pkts = counters["type:video"] - prev_pkts
window_drops = counters["JPEG 不完整"] - prev_drops
loss = 100.0 * window_drops / max(window_frames + window_drops, 1)
print(
f" {window_frames / elapsed:5.1f} fps "
f"包/s={window_pkts / elapsed:5.0f} "
f"完整帧={counters['完整帧']:4d} "
f"丢弃={window_drops:3d} ({loss:.1f}%)"
)
prev_frames, prev_pkts, prev_drops = (
counters["完整帧"],
counters["type:video"],
counters["JPEG 不完整"],
)
last_print = now
sock.close()
total_time = time.time() - start
print()
print("板子:", ", ".join(f"0x{k:08X} @ {v[0]}" for k, v in boards.items()) or "(未发现)")
print("完整帧:", counters["完整帧"], " 累计视频包:", counters["type:video"])
if counters["完整帧"]:
avg = sum(s[0] for s in sizes) / len(sizes)
print(f"平均帧: {avg:.0f} 字节 {sizes[0][1]}x{sizes[0][2]}")
print(f"吞吐: {sum(s[0] for s in sizes) / total_time / 1024:.1f} kB/s")
bad = {k: v for k, v in counters.items() if k.startswith(("无法", "JPEG", "分片", "载荷"))}
print("异常:", bad or "无")
if __name__ == "__main__":
main()
+3 -2
View File
@@ -35,8 +35,9 @@ CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB=y
# only 1 MB - smaller than the ~1.15 MB this firmware produces, so the image
# overflows the partition.
#
# The custom table also moves the app to 0x40000, which is what keeps the image
# off the module's defective flash byte; see partitions.csv for why.
# The custom table also carries an app offset that keeps the image off the
# module's defective flash byte. That offset is written by scripts/flash.sh, not
# fixed here; see partitions.csv for why it cannot be a constant.
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="../../../../../../partitions.csv"
CONFIG_PARTITION_TABLE_OFFSET=0x8000