diff --git a/README.md b/README.md index eda2809..295a53c 100644 --- a/README.md +++ b/README.md @@ -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` + 尺寸),`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` 是 `!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 -``` \ No newline at end of file +它手写了每一个协议常量,**不依赖 `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` 即可,界面和其余代 +码都不用改。 diff --git a/client/README.md b/client/README.md index d573445..2300e1d 100644 --- a/client/README.md +++ b/client/README.md @@ -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 身份。默认 referee(无需配置即可看两路) + --team <0|1> 自己属于哪一边,仅 team 端用 + --camera-id <0xID> 绑定到某块板子(MAC 派生,重启不变) + --camera 向哪块板子发订阅(默认 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` 和 `slint::Image` 都不是 `Send`。** + +`slint::Image` 尤其致命:JPEG 必须在别的线程解码(一张 VGA 的 JPEG 要几毫秒,放在收包 +线程里会挤进数据报之间,造成链路上本不存在的丢帧),但解码出来的 RGBA 无法跨线程交给 +UI。 + +解法是分两段:worker 线程只处理纯数据(`Vec` + 尺寸),`slint::Image` 的构造留在 +事件循环里。`main.rs` 的 `decoded` 槽位就是这个用途,`take_decoded()` 是那段跨界。 + +**`ModelRc` 没有 `From>`。** + +只有 `From>`、`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 = 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` / `Arc`,内部 `Mutex` +- 解码线程 → 事件循环:`Arc>>` +- 事件循环 → 界面:直接调用(同一个线程) +- 绝不跨线程:`Weak`、`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` 是 -`!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`。 | \ No newline at end of file +这一侧**没有任何东西重新编码**。板子出的就是传感器里的 JPEG,客户端只解码。