From cc6b82d7ec42b5ec732ca35aca107ee261a59172 Mon Sep 17 00:00:00 2001 From: sloray Date: Mon, 19 Oct 2026 00:19:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=93=BE=E8=B7=AF=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E3=80=81=E5=BB=B6=E8=BF=9F=E6=A0=A1=E5=87=86=E4=B8=8E=E9=95=BF?= =?UTF-8?q?=E6=B5=8B=E5=B7=A5=E5=85=B7=EF=BC=8C=E8=A1=A5=E5=85=A8=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 固件:新增 link_control / stream_control,覆盖运行中 Wi-Fi 断线恢复、取帧节拍、 温控与质量/帧率减载;UDP 发送增加有界重试预算与帧期限,PSRAM JPEG 直发减少拷贝。 - 协议:重组器严格校验分片布局,统计未收齐、超时与完全缺失的帧,处理帧号回绕与设备重启。 - 桌面端:视频改 latest-value 通道并前置帧时间标记,拒绝积压和慢解码的过期画面;新增 延迟标记页与 --latency-report 校准,完善录制、服务端与退出流程。 - 文档与脚本:新增硬件、开发、延迟、功耗、实时性、稳定性文档和改动记录,重写根 README; 新增构建、性能、延迟与串口长测脚本,以及长测客户端脚本。 - 构建:固定 esp-1.97.0.0 工具链,分区表随构建复制,更新 sdkconfig 与忽略规则。 --- .cargo/config.toml | 15 +- .env.example | 58 ++ .gitignore | 10 + CHANGELOG.md | 102 +++ DEVELOPMENT.md | 129 ++++ HARDWARE.md | 77 +++ LATENCY.md | 88 +++ PERFORMANCE.md | 53 ++ POWER.md | 64 ++ README.md | 611 ++++++++---------- REALTIME.md | 73 +++ STABILITY.md | 135 ++++ components/cam_wrapper/CMakeLists.txt | 2 +- components/cam_wrapper/cam_wrapper.c | 182 +++++- components/cam_wrapper/include/cam_wrapper.h | 25 +- components/esp32-camera/driver/cam_hal.c | 26 +- components/esp32-camera/driver/esp_camera.c | 13 + .../esp32-camera/driver/include/esp_camera.h | 3 + .../driver/private_include/cam_hal.h | 2 + desktop/README.md | 59 +- desktop/core/Cargo.lock | 26 + desktop/core/Cargo.toml | 1 + desktop/core/src/receiver.rs | 136 +++- desktop/package.json | 4 +- desktop/scripts/soak-stream.mjs | 146 +++++ desktop/scripts/test-latency-marker.mjs | 50 ++ desktop/scripts/test-live-freshness.mjs | 41 ++ desktop/scripts/test-stream.mjs | 143 ++++ desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/src/commands.rs | 6 +- desktop/src-tauri/src/demo.rs | 69 +- desktop/src-tauri/src/latency.rs | 77 +++ desktop/src-tauri/src/main.rs | 21 +- desktop/src-tauri/src/recording.rs | 58 +- desktop/src-tauri/src/server.rs | 194 +++++- desktop/src-tauri/src/state.rs | 95 +-- desktop/src/components/VideoSurface.tsx | 10 +- desktop/src/lib/latency-marker.ts | 101 +++ desktop/src/lib/store.ts | 2 + desktop/src/lib/stream.ts | 102 ++- desktop/src/lib/types.ts | 1 + desktop/src/main.tsx | 7 +- desktop/src/pages/LatencyPage.tsx | 101 +++ partitions.csv | 11 +- protocol/src/lib.rs | 164 ++++- rust-toolchain.toml | 5 +- scripts/analyze_latency.py | 80 +++ scripts/build.ps1 | 56 ++ scripts/build.sh | 14 +- scripts/flash.sh | 7 +- scripts/perf_probe.py | 174 +++++ scripts/serial_soak.py | 124 ++++ scripts/test-latest-frame.py | 65 ++ scripts/udp_probe.py | 4 + sdkconfig.defaults | 20 +- src/camera.rs | 143 +++- src/link_control.rs | 102 +++ src/main.rs | 351 +++++++++- src/stream_control.rs | 287 ++++++++ src/udp.rs | 276 ++++++-- 60 files changed, 4347 insertions(+), 655 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 DEVELOPMENT.md create mode 100644 HARDWARE.md create mode 100644 LATENCY.md create mode 100644 PERFORMANCE.md create mode 100644 POWER.md create mode 100644 REALTIME.md create mode 100644 STABILITY.md create mode 100644 desktop/scripts/soak-stream.mjs create mode 100644 desktop/scripts/test-latency-marker.mjs create mode 100644 desktop/scripts/test-live-freshness.mjs create mode 100644 desktop/scripts/test-stream.mjs create mode 100644 desktop/src-tauri/src/latency.rs create mode 100644 desktop/src/lib/latency-marker.ts create mode 100644 desktop/src/pages/LatencyPage.tsx create mode 100644 scripts/analyze_latency.py create mode 100644 scripts/build.ps1 create mode 100644 scripts/perf_probe.py create mode 100644 scripts/serial_soak.py create mode 100644 scripts/test-latest-frame.py create mode 100644 src/link_control.rs create mode 100644 src/stream_control.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index d4fa5b0..a3f8952 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -12,14 +12,7 @@ build-std = ["std", "panic_abort"] [env] MCU = "esp32s3" -# Use the ESP-IDF installation and toolchains that are already on the machine -# (eim's install under ~/.espressif) instead of downloading another copy into -# ./.embuild. Without this the first build re-downloads several hundred MB of -# Xtensa GCC/LLVM. -# -# `global` means ~/.espressif, which is where eim installs. -# -# Drop these two lines to let esp-idf-sys clone and fetch the ESP-IDF version it -# pins itself (v5.5.5 by default), into ~/.espressif. -IDF_PATH = { value = "/Users/peng/.espressif/esp-idf/v5.5.5", force = true } -ESP_IDF_TOOLS_INSTALL_DIR = { value = "global", force = true } \ No newline at end of file +# esp-idf-sys downloads this version unless IDF_PATH points to an existing SDK. +# Keep machine-specific paths in the environment instead of this shared file. +ESP_IDF_VERSION = { value = "v5.5.5", force = false } +ESP_IDF_TOOLS_INSTALL_DIR = { value = "global", force = false } diff --git a/.env.example b/.env.example index 84aa21b..6b0e912 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,54 @@ WIFI_PASS=your-password #CAM_RES=vga # JPEG quality 0-63, lower is better quality #CAM_QUALITY=12 +# Optional adaptive compression ceiling, 0 = fixed (default), else QUALITY..63. +# Shed load on sender congestion, restore detail after 15 stable seconds. +#CAM_QUALITY_MAX=0 +# Stream cap, 0 = uncapped (default), 1-60 = maximum fps. +# Paces frame retrieval and UDP delivery; sensor clocks remain unchanged. +#CAM_MAX_FPS=0 +# OV5640 PLL profile: default (driver timing) | vga30 (VGA ~30.09 fps). +# vga30 preserves HTS/VTS: XCLK 20, VCO 610, SYSCLK 61, PCLK 15.25 MHz. +# Retune exposure-line limits when switching profiles; keep HTS at default. +#CAM_CLOCK_PROFILE=default +# Capture frame buffers in PSRAM: 3 (default) or 2 (lower capture queue depth). +# Prefer 3 for burst tolerance; 2 showed worse long-run peak frame loss here. +#CAM_FB_COUNT=3 +# Sensor grayscale effect while retaining hardware JPEG, 0 = colour (default). +#CAM_GRAYSCALE=0 +# OV5640 AE target bias: 0 (default), -1 (~-1.1 EV), -2 (~-1.9 EV). +# This target alone does not guarantee a shorter shutter. +#CAM_EXPOSURE_EV=0 +# Optional OV5640 driver AE level -5..5; requires CAM_EXPOSURE_EV=0. +# Unset uses the EV setting. -1 lowers the target 55 -> 45 (~-0.29 EV), +# a finer brightness adjustment than CAM_EXPOSURE_EV=-1. AEC/AGC stay on. +#CAM_AE_LEVEL=-1 +# OV5640 fixed shutter in sensor lines, 0 = automatic (default), AGC stays on. +# 220 lines is roughly 10 ms with default VGA HTS 2060; verify timing/logs. +#CAM_SHUTTER_LINES=0 +# Optional automatic exposure cap in sensor lines, 0 = driver default. +# AEC/AGC stay automatic, night frame extension is disabled. Mutually exclusive +# with a nonzero fixed shutter. Default VGA: 440 lines is about 20 ms. +# With vga30 use 592 lines for about 20 ms, including two 50 Hz AEC bands. +#CAM_EXPOSURE_MAX_LINES=0 +# OV5640 automatic gain ceiling, 0 = driver default, else 1..63 times. +# More gain can brighten short exposures but also amplifies noise. +#CAM_GAIN_CEILING=0 +# OV5640 horizontal total, 0 = driver default. Even values only; hardware test +# required. Changing HTS also changes the time represented by one shutter line. +# HTS 1500 / 1800 caused green/magenta flicker on this module; keep 0. +#CAM_HTS=0 +# Minimum gap between successful video datagrams, 0 = burst (default). +# Allowed 0..5000 us in FreeRTOS tick increments (1000 us in this build). +#UDP_PACKET_GAP_US=0 +# Total yielding-retry allowance per JPEG, shared by all fragments/peers. +# 0 = fail on backpressure; default 8000 us, 0..25000 in tick increments. +# Frame deadlines remain 25 ms sending / 100 ms since capture start. +#UDP_RETRY_BUDGET_US=8000 +# Internal ESP chip temperature load limit, Celsius; 0 disables, default 75. +# At the limit halve fps (rounded up); restore after cooling by 5 C. +# This is a proactive load threshold, not a specified silicon safety limit. +#CAM_THERMAL_LIMIT_C=75 # Set to 0 if your board inverts VSYNC in hardware. #CAM_VSYNC_INVERT=1 @@ -28,3 +76,13 @@ WIFI_PASS=your-password # the AP (a hidden SSID, for instance) - otherwise the scan reports the real # mode and this only makes it fail faster. #WIFI_AUTH=wpa2 +# Optional maximum Wi-Fi TX power, integer dBm (2..20). Unset keeps SDK default. +# Test on your own board/network; higher power does not always improve video. +# Current delivery requests 15 dBm; this ESP32-S3 reports a 15.00 dBm cap. +# Runtime load control does not lower it; reconnects reapply the same cap. +# WIFI_TX_POWER_DBM=15 + +# Diagnostic builds only: intentionally disconnect once, after N seconds of +# the streaming loop, hold offline 5 seconds, then exercise runtime recovery. +# Keep 0 (default) for normal firmware; does not change router/TUN settings. +#WIFI_TEST_DROP_AFTER_S=0 diff --git a/.gitignore b/.gitignore index 3e8ecc1..ffe0403 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,13 @@ /target/ /protocol/target/ /armor/target/ +/.embuild/ +/managed_components/ +/sdkconfig +/sdkconfig.old +/sdkconfig.* +!/sdkconfig.defaults +!/sdkconfig.defaults.* # 本机专属的 cargo 注册表缓存。 # @@ -29,6 +36,9 @@ # macOS 的目录元数据。不锚定,子目录里的也一起忽略。 .DS_Store +# Python diagnostic helpers. +__pycache__/ + # `docs/simba_docs`:工作区里另起的一个 Astro 文档站,与本仓库的目录结构无关, # 也不属于本项目的产物。留着不跟踪,免得以后 `git add -A` 顺手把它带进来。 /docs/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9e5116a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,102 @@ +# 改动记录 + +记录面向使用者和维护者的主要变化。历史条目依据 Git 提交整理,日期为提交日期,尚未对应正式发布版本;详细历史见 `git log`。 + +## 未发布 + +### 文档 + +- 重组根 README,补充 ESP Rust 入门、跨平台编译与标准烧录指南。 +- 将硬件限制、开发说明与改动记录分别整理为独立文档。 +- 修正 JPEG 质量与数据大小的说明,区分历史性能记录和当前验证。 +- 补充桌面端 Windows x64 / NSIS 构建、测试与产物路径说明。 + +### 构建 + +- 固定使用命名工具链 `esp-1.97.0.0`,避免较新预览版标准库的 ESP-IDF 兼容问题。 +- 移除 S3 构建配置中的个人 macOS SDK 路径,默认 ESP-IDF v5.5.5,支持环境变量覆盖。 +- 新增从 `.env` 读取配置的 PowerShell 构建脚本。 +- 图传构建脚本使用 `--locked`,忽略 SDK 缓存和生成配置。 +- 分区表复制到生成的 CMake 工程,不再依赖 Cargo 输出层级;支持 Windows 使用较短的外部构建目录。 + +### 修复 + +- 增加运行中 Wi-Fi 断线恢复、关联和 DHCP 超时退避;取址后重建 UDP socket,清理订阅目标与旧帧,保留相机状态和帧序号。 +- 桌面端向已发现的图传板 IP 定向续订,修复 VPN / 多网卡把全局广播发往虚拟接口后“设备在线但无画面”的问题。 +- 修正丢帧统计:计入未收齐就被替换、超时及帧号缺口,重复包和等待分片不计作丢帧;拒绝迟到旧帧覆盖新帧。 +- 校验分片数量、长度与同帧元数据,处理帧号回绕及设备重启。 + +### 图传性能 + +- 根据距离收益有限、拥塞增加的实测反馈,将交付 Wi-Fi 最大发射功率上限从 17 dBm 回退到 15 dBm,实板驱动回读 15.00 dBm;启动、重连均应用同一设置,拥塞和温控逻辑不自动降功率。保留此前 14/17 dBm 的原始记录,新增功率与拥塞分析。 +- 阻塞恢复时非阻塞取最新完整 JPEG,返回被替换的旧采集缓冲;保留三块采集内存,不增加待发送 FIFO。 +- 每帧增加采集帧龄和绝对发送期限,重试预算默认缩短至 8 ms;`UDP_RETRY_BUDGET_US=0` 可在回压时立即放弃剩余分片。 +- 客户端通过本机帧时间标记拒绝 WebView 积压和慢解码的过期画面;本地 JPEG 二进制头保持 16 字节兼容。 +- 根据长程运行的峰值丢帧反馈,当前交付恢复三缓冲;双缓冲的短测数据保留为历史对照。 +- 新增可选 OV5640 `CAM_AE_LEVEL` 细调:`-1` 将亮度目标从 55 调至 45,保留 AEC/AGC 与曝光上限,供轻微过亮时调整。 +- 增加完整 OV5640 `vga30` PLL 配置与可选双缓冲;保持默认行、帧时序与彩色读出窗口,配套调整自动曝光行数上限。 +- Windows 客户端增加裁判端 `--latency-report` 校准模式,通过屏幕标记和实际视频画布测量首次可解码回显时间,保存本地 NDJSON;区分画布刷新估计与物理面板响应。 +- 固件直接读取 PSRAM 中的 JPEG,复用单个数据报缓冲,减少每帧分片复制与分配。 +- 视频 socket 使用 IP precedence 4,映射至保留 AMPDU 的 Wi-Fi 视频队列 AC_VI。 +- 对临时 UDP 发送拥塞进行有时限的让出调度重试,统计实际提交帧、失败次数与丢弃帧。 +- Windows UDP 接收缓冲请求增至 1 MiB;界面视频通道和解码队列保留最新待处理帧,避免慢端持续积压。 +- 新客户端使用随机会话 ID;固件更新同一会话的源端口,避免 NAT / TUN 端口变化产生重复推流目标,旧固定 ID 客户端继续按地址区分。 +- 新增可选编译参数 `WIFI_TX_POWER_DBM`(2–20 dBm);未设置时保留 SDK 默认上限,按实际板卡和网络测量调节。 +- 新增 `scripts/perf_probe.py`,测量完整帧率、帧损失、接收间隔和分片组装耗时。 +- 新增 `CAM_MAX_FPS`(默认 0,不限帧),可限制取帧和发送节拍,过载后不追赶补发。 +- 新增 `CAM_GRAYSCALE`,通过传感器灰度效果保留硬件 JPEG;增加可配置的芯片温度减载和 5°C 恢复回差。 +- 增加芯片内部温度、采集错误与复位原因日志,以及串口和原生客户端视频通道的长测记录脚本。 +- 新增 OV5640 曝光目标偏置与固定快门行数配置,保留自动增益;记录实际曝光行数,避免将降低亮度等同于缩短快门。 +- 新增 OV5640 水平总长试验参数 `CAM_HTS`;过短消隐会出现绿紫闪烁,几何约束通过不代表画质稳定,必须在板上验证。 +- 新增可配置 UDP 分片间隔及有界帧发送时间,支持按五分钟窗口比较突发发送与让出调度发送。 +- 新增可选 JPEG 压缩回压调节 `CAM_QUALITY_MAX`,拥塞时快速减小彩色帧,连续稳定后缓慢恢复画质;记录实际压缩档位和传感器增益。 +- 新增 `CAM_EXPOSURE_MAX_LINES`,保留自动曝光和增益联动,以曝光上限控制拖影;固定短快门不再作为室内亮度优化的默认方案。 +- 新增 OV5640 自动增益上限 `CAM_GAIN_CEILING`,为室内短曝光提供增益余量,保留运行时增益读数供验证。 + +### 硬件配置 + +- 显式设置 `CONFIG_CAMERA_CORE1=y`;仅关闭 `CAMERA_CORE0` 时,Kconfig 的 choice 仍会默认选择 CPU0。 +- 按当前 Wi-Fi 配置的镜像核对 Factory 偏移与原测试模组 `0xFADE7` 坏字节的可写性;后续重编译仍需重新检查,以 `partitions.csv` 为准。 + +## 2026-10-15 + +### 持续集成 + +- 新增 Windows、Linux、macOS 客户端构建工作流。 +- 调整制品上传版本及缓存行为,适配 Gitea runner。 +- 修复 Windows runner 的 shell、临时目录和低完整性环境适配。 +- 使用 npm 安装的 JS 版 pnpm,处理安装目录残留与 `EEXIST`。 + +对应提交:`98dc87e` 至 `4794bed`。 + +## 2026-10-14 + +### 新增 + +- 引入 Tauri v2 + React 队伍端和裁判端,网络逻辑拆分到 `desktop/core`。 +- 加入 ESP32-C3 一主三从装甲板:四路受击检测、灯带及血量广播。 +- 保存早期 C 版装甲板工程到 `reference/armor/`。 +- 桌面端加入双方血条、归零胜负提示与裁判端队名编辑。 + +### 调整与修复 + +- 移除旧 Slint 客户端。 +- 修复演示模式命中节奏与设置文字显示,移除受击画面抖动。 + +对应提交:`a89d7ae`、`4f7a142`、`7fdebe3`、`a30eb32`、`fd71b83`、`a93ae06`。 + +## 2026-10-05 + +### 新增 + +- 建立摄像头固件、共享 UDP 协议与初代桌面客户端。 +- 支持多端发现、订阅、设备绑定及队伍端 / 裁判端视图。 +- 加入滚轮缩放、拖动平移与启动最大化。 + +### 修复 + +- 修复探测脚本重组状态未按帧隔离导致的坏帧误报,以及 FPS 窗口计算错误。 +- 修复裁判端两格宽度不一致,槽位按队伍语义分配。 +- 队伍端 release 接收路径限制到已绑定图传板。 + +早期对重复分片的协议归因已被后续提交更正,不能将当时的坏帧直接归为协议缺陷。对应提交:`4f893ce`、`1c65eaa`、`7be8d7d`、`c7f42a1`、`a16ad85`、`cd8344c`、`3631a03`。 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..a8f544d --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,129 @@ +# 开发与验证 + +编译入口见 [README.md](README.md),硬件限制见 [HARDWARE.md](HARDWARE.md),历史变化见 [CHANGELOG.md](CHANGELOG.md)。 + +## 镜像导出 + +以下使用 espflash 4.4.0 与固定的 `esp-1.97.0.0` 工具链。完成构建后,使用 `esp-idf-sys` 复制到 `release/` 的 Bootloader。Windows PowerShell: + +```powershell +$targetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { 'target' } +New-Item -ItemType Directory -Force target/firmware | Out-Null +espflash save-image --chip esp32s3 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$targetDir/xtensa-esp32s3-espidf/release/bootloader.bin" --merge --skip-padding "$targetDir/xtensa-esp32s3-espidf/release/esp32" target/firmware/esp32s3-merged.bin +``` + +Linux / macOS(Bash): + +```bash +target_dir="${CARGO_TARGET_DIR:-target}" +mkdir -p target/firmware +espflash save-image --chip esp32s3 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$target_dir/xtensa-esp32s3-espidf/release/bootloader.bin" --merge --skip-padding "$target_dir/xtensa-esp32s3-espidf/release/esp32" target/firmware/esp32s3-merged.bin +``` + +合并镜像包含 Bootloader、分区表、App 和必要地址填充,`--skip-padding` 避免将文件填满 16 MB。镜像从 `0x0` 写入: + +```text +espflash write-bin --chip esp32s3 --port COM5 0x0 target/firmware/esp32s3-merged.bin +``` + +去掉导出命令的 `--merge --skip-padding` 可生成独立 App,它需写入 `partitions.csv` 声明的 Factory 偏移,并与板上 Bootloader 和分区表匹配。坏板重编可能改变该偏移;新板优先使用 ELF 或合并镜像。 + +## 主机测试 + +协议层不依赖 ESP-IDF。进入 `protocol/`,以 Windows MSVC 为例: + +```powershell +cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]' +``` + +Linux 常见 host 为 `x86_64-unknown-linux-gnu`,Apple Silicon 为 `aarch64-apple-darwin`,可用 `rustc +stable -vV` 查看。`--target` 覆盖仓库里的其他平台配置,空 `build-std` 避免主机测试继承嵌入式标准库重编译。 + +装甲规则从 `armor/` 执行 `cargo +stable test -p armor-protocol`,桌面核心从 `desktop/core/` 执行 `cargo +stable test`,同样按需指定宿主 `--target` 和空 `build-std`。 + +限帧、温度回差和 JPEG 压缩回压策略可以独立在主机运行,无需连接板卡。Windows 在根目录执行: + +```powershell +New-Item -ItemType Directory -Force target | Out-Null +rustc +stable --edition 2021 --test src/stream_control.rs -o target/stream-control-tests.exe +.\target\stream-control-tests.exe +``` + +实际持续运行的串口与客户端通道记录方法见 [STABILITY.md](STABILITY.md)。主机策略测试不等于已经触发板上过温减载。 + +## 清理构建缓存 + +改动 C 组件或 `sdkconfig.defaults` 后,增量构建若没有反映变化,可从根目录执行: + +```text +cargo clean --release --target xtensa-esp32s3-espidf -p esp-idf-sys +``` + +随后用构建脚本重编,确认 CMake / Ninja 重新编译相应组件。仍有缓存问题时使用 `cargo clean` 完整清理。不要删除 `components/esp32-camera/target/`。 + +构建脚本设置 `CARGO_WORKSPACE_DIR`,并通过 `ESP_IDF_GLOB_BASE` / `ESP_IDF_GLOB_PARTITIONS` 将分区表复制到生成的 CMake 工程,因此支持外部 `CARGO_TARGET_DIR` 和不同 Cargo 输出层级。Windows 使用短目录,如 `C:\s3vt`;设置 `ESP_IDF_PATH_ISSUES=ignore` 或 `subst` 不能消除底层路径限制。 + +## 发现与协议 + +- 图传板每 2 秒广播 Hello;客户端每 800 ms 广播 Subscribe,并向已发现的在线板卡 IP 单播续订,固定绑定 UDP 5600。 +- 全局广播可能被 VPN / 虚拟网卡路由到其它接口。收到 Hello 后按设备 IP 定向续订,可以使用通往实际局域网的路由;队伍端仅向绑定的板卡增加单播续订。 +- 固件使用订阅包的源地址作为发送目标,6 秒不续订即移除,最多保留 4 个对端。 +- 新客户端为每个接收器生成随机会话 ID;同一源 IP 下,同一会话换端口会更新推流目标。旧版公共固定 ID `0x0C110001` 保持按地址区分,避免合并同机旧客户端。 +- MAC 派生的 `sender_id` 不随 DHCP 地址变化,适合客户端持久绑定。 +- Hello 和视频都是设备在线证据;客户端列表清理与固件订阅使用不同超时。 +- 数据报为 27 字节头 + 最多 1400 字节载荷,采用小端编码;字段及拒收条件见 [protocol/README.md](protocol/README.md)。 + +缺失分片不重传,不完整帧整体丢弃。JPEG 由传感器编码,桌面后端转交原始 JPEG,由 WebView 解码,避免接收线程进行耗时编解码。 + +## 性能测量 + +本次在保留 TUN 环境中的对照测试、最终参数与验证边界见 [PERFORMANCE.md](PERFORMANCE.md)。 + +下表为原 README 保存的**历史单次测试数据**,不是本次构建实测或性能保证。配置为 OV5640、VGA;原始日志、时长和完整网络条件未随这些数字保存。 + +| 指标 | 早期 HTTP MJPEG / TCP | 后续 UDP | +| --- | --- | --- | +| 传感器产出 | 约 22 fps | 约 22 fps | +| 客户端收到 | 约 1.9 fps | 约 22 fps | +| 吞吐 | 约 0.28 Mbit/s | 约 1.6 Mbit/s | +| 丢帧 | 未记录 | 当次报告 0% | + +这些数据不足以单独证明性能差异的根因或无线链路容量。复测时记录板型、固件版本、分辨率、画质、信号、订阅者数量、时长和丢帧数。 + +FPS 应按**同一时间窗口的帧数增量 / 窗口秒数**计算。累计帧数与窗口时长混用会产生假数值,UI 轮询间隔也不代表相机出帧间隔。探测脚本的重组状态需按设备及帧隔离,避免测量错误。 + +可重复测量:`python scripts/perf_probe.py <板卡IP> --seconds 30 --json perf.json`。默认使用独立的临时端口和 1 MiB 接收缓冲,每 800 ms 定向续订。脚本只依赖 Python 标准库;`--save-frame frame.jpg` 可保存首张完整 JPEG。公平比较应保持订阅者数量、分辨率、画质与网络条件一致。 + +`frame_loss_percent` 包含不完整、超时及帧号缺口,不包含开始订阅前或测量结束后的帧。`assembly_p95_ms` 是首末分片到达的时间差,`inter_frame_p95_ms` 是完整帧到达间隔,均使用高精度计时;没有发送时间戳及两端时钟同步,不能称为端到端延迟。 + +固件日志将采集尝试与完整提交分开计数。`submitted` 只表示全部分片交给了网络,不代表无线确认或客户端显示;`send failures` 可能包含随后重试成功的调用,应结合 `peer frames dropped` 和接收端指标判断。 + +画面链路测量使用裁判端 `--latency-report <新文件>`,让相机拍摄校准页左侧标记,固定相机和窗口位置。标记经正常采集、网络与绘制路径回到客户端,源与回显共用主机单调时钟;不需要 ESP 时钟同步。测试期间只保留一个原生客户端,额外的本地 WebSocket 观察器不增加 UDP 订阅。方法、刷新估计边界及最新结果见 [LATENCY.md](LATENCY.md)。 + +按记录文件的半开行号区间复算延迟: + +```text +python scripts/analyze_latency.py --report latency-run.ndjson --range fb3-range.json --range fb2-range.json --out latency-result.json +``` + +区间文件至少包含 `start_row`、`end_row` 和 `fb_count`,行号从 0 开始;报告关闭后再分析,避免读取写入中的半行。分析器拒绝重复源标记、无效时间边界与空样本,输出 P50/P95/P99、rAF 区间宽度和解码完成到绘制的耗时。JPEG 接收间隔仍是独立指标。 + +恢复策略是独立纯 Rust 模块,可在主机运行: + +```text +rustc +stable --test src/link_control.rs -o link-control-tests +./link-control-tests +``` + +Windows 为输出文件加 `.exe`。实机故障注入仅在诊断构建设置 `WIFI_TEST_DROP_AFTER_S`:主循环运行相应秒数后断开一次并保持 5 秒,正式构建保持 `0`。重连后需观察重新取址、socket 重建和订阅恢复,不能仅凭 connect 调用成功判定恢复视频。 + +## 板上验证 + +阻塞与缓冲交接测试:`python scripts/test-latest-frame.py` 使用主机 clang/gcc 编译真实 `cam_take_latest` 函数及队列替身,验证持续生产下有限取帧、旧缓冲仅归还一次、无新帧保留有效旧帧,以及首次超时不泄漏。`rustc +stable --test src/stream_control.rs` 验证发送期限、共享重试预算和零重试模式;`desktop/` 的 `pnpm test:stream` 验证 WebView 积压、解码阻塞及过期帧清理。实机验证范围另见 [REALTIME.md](REALTIME.md)。 + +1. 相机识别、PSRAM 初始化及稳定采集。 +2. Wi-Fi 完成关联和 DHCP,得到有效 IP。 +3. 客户端启动后订阅者增加并持续显示画面。 +4. `python scripts/udp_probe.py 10` 独立核对协议及帧统计。 +5. 分别验证多端订阅、断网重连、不同分辨率与持续运行。 + +编译、镜像生成与主机测试通过均不等于烧录、板上运行或硬件稳定性已经验证。 diff --git a/HARDWARE.md b/HARDWARE.md new file mode 100644 index 0000000..b497cf5 --- /dev/null +++ b/HARDWARE.md @@ -0,0 +1,77 @@ +# 图传板硬件说明 + +对应 Freenove ESP32-S3-WROOM CAM、ESP32-S3-WROOM-1-N16R8 与 OV5640。构建和烧录入口见 [README.md](README.md)。 + +## 摄像头引脚 + +配置以 `src/camera.rs` 的 `pins` 模块为准。DATA 按 D0–D7(传感器 Y2–Y9)排列。 + +| 信号 | GPIO | +| --- | --- | +| RESET | 0 | +| XCLK | 15 | +| PCLK | 13 | +| VSYNC | 6 | +| HREF | 7 | +| SIOD / SDA | 4 | +| SIOC / SCL | 5 | +| D0–D7 | 11, 9, 8, 10, 12, 18, 17, 16 | +| PWDN | 未连接(`-1`) | + +当前 XCLK 为 20 MHz。不要直接套用 AI-Thinker、XIAO 等其他板卡的相机引脚表。 + +本模组需避开的资源包括 Flash / PSRAM 总线(GPIO 26–37)、USB(19 / 20)、串口(43 / 44)及模组内部引脚,以原理图为最终依据。占用 Flash / PSRAM 引脚可能造成传输期间 CPU 挂死、串口静默。 + +## 内存与任务配置 + +参数位于根目录 `sdkconfig.defaults`: + +| 参数 | 当前值 | 用途 | +| --- | --- | --- | +| `CONFIG_SPIRAM_MODE_OCT` | `y` | 8 MB Octal PSRAM | +| `CONFIG_SPIRAM_SPEED_80M` | `y` | PSRAM 时钟 | +| `CONFIG_CAMERA_PSRAM_DMA` | `n` | DMA 经内部 RAM 中转后复制到 PSRAM | +| `CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP` | `n` | Wi-Fi / lwIP 缓冲保留在内部 RAM | +| `CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240` | `y` | CPU 240 MHz | +| `CONFIG_CAMERA_CORE1` | `y` | 显式选择 CPU1,与 Wi-Fi 分开(只禁用 CORE0 不足以改变 choice 默认值) | +| `CONFIG_FREERTOS_HZ` | `1000` | 发送拥塞时以 1 ms tick 让出调度 | +| `CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM` | `64` | 发送缓冲数量,使用内部 RAM | +| `CONFIG_ESP_WIFI_TX_BA_WIN` | `32` | 保留 AMPDU 发送聚合 | + +相机使用 3 个 PSRAM 帧缓冲及 `GRAB_LATEST`,优先取得较新的画面。JPEG 编码由传感器完成,S3 主要负责采集和发送。 + +原测试板启用 PSRAM DMA 后曾出现 `NO-EOI`:JPEG 结束标记位于驱动末尾 DMA 搜索窗口之外,随后停止交付帧。因此默认关闭该配置;更换驱动后应单独验证持续采集。 + +## Flash 与分区 + +默认 **16 MB、DIO、40 MHz**,分区来自 `partitions.csv`: + +| 分区 | 偏移 | 大小 | +| --- | --- | --- | +| NVS | `0x9000` | `0x6000` | +| PHY init | `0xF000` | `0x1000` | +| Factory app | 以 `partitions.csv` 为准,坏板重编时可能调整 | `0x120000`(1152 KiB) | + +分区表位于 `0x8000`,S3 Bootloader 从 `0x0` 开始。App 写入分区表声明的偏移,不能直接套用 `0x10000`。修改分区或 Flash 模式后需重新构建,成套更新 Bootloader、分区表和 App。 + +健康硬件可以评估恢复 `0x10000` App 偏移或更快 Flash 配置,但需重新验证。 + +## 损坏 Flash 模组的兼容处理 + +早期测试模组在绝对地址 **`0xFADE7`** 存在写入异常,bit 5–7 无法正确写入。这是特定模组的故障。当前 App 偏移来自该板的开发过程,不能保证新镜像仍避开坏字节。 + +2026-10-18 的整段回读还发现 **`0x10863`** 的 bit 6 异常读回为 1:期望 `0x2e`,读回 `0x6e`,该轮 App 只有这一处字节不同,尚未单独擦写复核是否为永久故障。该模组的 App 偏移需至少为 `0x20000`,并继续检查 `0xFADE7`;烧录工具的校验失败必须先排除,不能当作网络或相机故障。 + +`scripts/flash.sh` 保留的流程为:编译、导出 App、搜索 64 KiB 对齐的可写偏移、修改分区、重新构建和验证,再写入三段镜像。重新构建可能移动镜像内容,因此第二次验证不可省略。 + +脚本依赖 macOS 串口名、Unix `termios`、本地 ESP-IDF Python 目录及旧版工具参数,不适合作为通用烧录入口。坏板需核对工具版本及坏地址字节;普通板卡使用 README 流程。更换故障模组比长期依赖镜像位置更可靠。 + +## 本地相机组件 + +`components/esp32-camera/` 包含上游 2.1.7 及本地兼容补丁,`components/cam_wrapper/` 提供 Rust FFI 封装。 + +本地增加 `esp_camera_fb_get_latest` / `cam_take_latest`:等待第一张有效帧后,以有限次数零等待取出更新帧,并归还替换的缓冲。`cam_take(0)` 表示单次非阻塞尝试,不在空队列、坏 JPEG 或 DMA 复位哨兵上继续等待。升级驱动时需保留或移植这些补丁,并运行缓冲所有权测试;不能用“先检查有帧、再调用可能阻塞 4 秒的 get”替代。 + +S3 的 `ll_cam.c` 中有 `CAM_IO_MUX_REG()` 本地补丁,用于 SCCB / I2C 引脚连接。升级驱动后需检查补丁并验证传感器探测和采集。 + +`components/esp32-camera/target/` 是按芯片划分的**源代码**,清理缓存时不能删除,也不能忽略所有层级的 `target/`。 diff --git a/LATENCY.md b/LATENCY.md new file mode 100644 index 0000000..5ce0421 --- /dev/null +++ b/LATENCY.md @@ -0,0 +1,88 @@ +# 约 30 fps、Wi-Fi 重连与画面链路延迟验证(2026-10-18) + +当前交付已增加阻塞恢复与新鲜度处理,见 [REALTIME.md](REALTIME.md)。本页保留此前减光确认和画面延迟对照;本页 SHA 与测量结果均属于当时构建,不是当前固件的重测结果。 + +## 先前三缓冲与小幅减光记录 + +根据用户长程运行反馈,双缓冲在峰值负载下丢帧明显,当前交付恢复 **`CAM_FB_COUNT=3`**,保留突发发送期间的采集余量。约 30 fps 完整 PLL 配置、VGA 彩色、正常白平衡、Wi-Fi 自动重连和自适应 JPEG 均保留。双缓冲保留为试验选项,下面的短时延迟对照属于历史记录,不能证明长程峰值稳定性。 + +针对现场轻微过亮,增加 `CAM_AE_LEVEL=-1`,将 OV5640 AE 目标从 55 微调到 45,目标比值约 −0.29 EV;`CAM_EXPOSURE_EV=0`,自动曝光和增益仍联动,曝光上限保持 592 行(约 20 ms)。这会小幅降低亮度目标,不保证实际快门必然缩短。当前 50 Hz 抗频闪步长约 10 ms,直接小减曝光上限可能跨越一整档,因此使用 AE 目标细调。该目标比值不等于经过照度标定的实际 EV。 + +本次已通过 COM8 烧录与 Bootloader、分区表、App 的独立校验。重新启动后串口观察 45.15 秒,复位和采集错误均为 0,订阅已恢复;末个窗口提交 29.7 fps。观察中仍有 UDP 发送回压,三缓冲不代表完全消除路由器拥塞。本次未重新测量原生完整接收损失率或画布延迟,也未重新进行长程峰值测试。 + +用户已确认当前三缓冲、小幅减光版本亮度合适、颜色稳定。 + +该阶段 App SHA-256:`05aff1f53519fbb52b89ca526c3be11cc78f37a3356f588986336cf7870924eb`。完整选择理由、当前参数与短测记录见制品 `BUFFER-DECISION.json`;下面的 90 秒/五分钟对照、CDF 和旧接收曲线保留为历史数据,不能套用到本次亮度微调后的镜像。 + +## 此前短时对照(历史) + +此前试验使用 VGA 彩色、完整 PLL 配置和双缓冲。保留 Mihomo TUN,只有一个 UDP 订阅者,使用 Windows 生产客户端的真实视频绘制路径,非演示模式。用户已确认新时钟配置亮度够用、颜色正常,未再出现短 HTS 方案的绿紫闪烁。 + +| 指标 | 三缓冲基线 | 双缓冲试验配置 | +| --- | --- | --- | +| 原生接收窗口 | 90.008 s | 300.003 s | +| 完整接收帧 | 2659 | 8909 | +| 帧损失 | 32 / 1.189% | 61 / 0.680% | +| 完整接收帧率 | 29.54 fps | 29.70 fps | +| 本地 JPEG 通道 | 27.23 fps | 27.90 fps | +| 标记回显有效样本 | 767 | 2641 | +| 标记回显采样跨度 | 109.68 s | 295.08 s | +| 标记 → 首次可解码画布回显 P50 | 128.4 ms | 121.5 ms | +| 标记 → 首次可解码画布回显 P95 | 230.7 ms | 193.4 ms | +| 回显估计 P99 / 最大值 | 285.3 / 443.2 ms | 248.9 / 461.5 ms | + +这次窗口的 P50 降低 6.8 ms、P95 降低 37.3 ms。双缓冲减少潜在采集排队,同时保住接近 30 fps 的实际交付。两个窗口的网络回压、JPEG 自适应档位和时长不同,属于连续实机对照,不能把全部改善归因于缓冲数,也不是所有路由器或场景的保证。此前约 22 fps 的测试保留在 [STABILITY.md](STABILITY.md)。 + +双缓冲试验五分钟全程在线,复位 0 次、采集错误 0 次,通道断开 0 次、计数重置 0 次。抽取 61 张 JPEG 均完整解码为 640×480 RGB;没有用灰度或降低色深换帧率。最小内部空闲堆 118771 B;实际 JPEG 档位 18–28,温度 58–60°C,未触发减载。 + +## 该测试阶段的配置与时钟 + +```dotenv +CAM_RES=vga +CAM_CLOCK_PROFILE=vga30 +CAM_FB_COUNT=3 +CAM_QUALITY=18 +CAM_QUALITY_MAX=30 +CAM_MAX_FPS=0 +CAM_GRAYSCALE=0 +CAM_EXPOSURE_EV=0 +CAM_AE_LEVEL=-1 +CAM_SHUTTER_LINES=0 +CAM_EXPOSURE_MAX_LINES=592 +CAM_GAIN_CEILING=32 +CAM_HTS=0 +CAM_THERMAL_LIMIT_C=75 +WIFI_TX_POWER_DBM=14 +UDP_PACKET_GAP_US=0 +WIFI_TEST_DROP_AFTER_S=0 +``` + +XCLK 保持 20 MHz,使用完整 PLL 分频组合:VCO 610 MHz、SYSCLK 61 MHz、DVP PCLK 15.25 MHz;保持默认 HTS 2060 / VTS 984 和读出窗口,名义节拍约 30.09 fps。此前缩短 HTS 至 1500 / 1800 的方案会闪色,未采用。驱动回调的参数顺序与私有 PLL 辅助函数不同,必须按 `sensor_t.set_pll` 的签名调用。 + +下列实际曝光、增益范围属于此前双缓冲试验。当前 AE 目标改为 45/55,自动增益保留,曝光上限 592 行约 19.99 ms;实际曝光 590–590 行,估算 19.92–19.92 ms,增益 8.00–13.50×。最大 50 Hz band 数保留为 2,避免提升 SYSCLK 后向下取整只剩一个 band 导致画面过暗。未强制负 EV 或固定低快门。行数与毫秒换算依赖当前时钟,不能直接套用其他配置。 + +参考:[OV5640 数据手册](https://files.waveshare.com/upload/d/da/OV5640_DataSheet.pdf)、[Espressif OV5640 驱动](https://github.com/espressif/esp32-camera/blob/master/sensors/ov5640.c)。新组合的 VCO 和 PCLK 在该手册列出的范围内;这不代替板上信号完整性和画面验证。 + +## Wi-Fi 运行时恢复 + +断开、失去有效 IP 或地址变化后停止 UDP 发送并清理订阅目标。关联和 DHCP 分别使用 15 秒期限;首次立即尝试,失败按 1、2、4、8、16、30 秒退避。事件回调只更新状态,主循环负责调用驱动;认证进行中不会反复 connect。取址后重建 socket、恢复无线参数、丢弃旧采集帧并重新广播,保留摄像头、曝光、增益、设备 ID 和帧序号。 + +实机诊断构建主动断开并保持 5 秒:检测断线后 9021 ms 恢复网络,其中重试发起到恢复日志为 4029 ms(含取址和恢复处理);无需重启,客户端随后自动续订恢复视频。该测试使用同一恢复逻辑、60 MHz SYSCLK 的预检构建;最终交付为 61 MHz,故障注入参数为 0。恢复到同一个 IP 已验证,路由器长时间关机、密码改变和 DHCP 换址尚未分别做硬件测试。启动阶段第一次联网仍使用原有扫描和认证模式探测逻辑。 + +处理方式遵循 [ESP-IDF 断线事件说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wifi-event-sta-disconnected):有效 IP 恢复后再重建上层 socket。4 项恢复策略测试覆盖认证不被重启、关联/DHCP 超时、指数退避和新断线重置状态。 + +## 延迟测量方法与边界 + +Windows 客户端使用裁判配置和 `--latency-report <新的NDJSON路径>` 启动。相机固定对准左侧四角标记,右侧沿正常 `VideoSurface` 路径绘制真实回显。源标记每 50 ms 更新,记录每个编号首次可解码的画布回显;四角透视校正、Gray 码和互补行拒绝无法可靠识别的曝光混合帧。最初逐刷新更新在 180 Hz 屏幕上会让一次曝光跨越多个编号,已在正式对照前修正。 + +源标记和回显均使用同一客户端 `performance.now()`,无需 ESP 时钟同步。绘制时刻以相邻 rAF 夹定,表中为上下界中点;此前双缓冲样本的区间宽度中位数为 11.4 ms。该范围包括相机取样等待、曝光、采集、无线传输、重组、本地 WebSocket、解码和画布绘制。它是**屏幕标记事件到首次可解码画布回显的估计**,没有测量源/输出面板扫描或像素响应;严格的物理光学端到端延迟需用高速相机复核。未识别帧不成为样本,首次可解码回显可能晚于较早的模糊回显,不能把有效样本分布当作每一帧的延迟。 + +此前双缓冲试验中,解码完成到画布绘制平均 2.72 ms;标记分析平均 2.02 ms。该分析只在校准模式运行,正常客户端不做画布回读。字段 `decodeToDrawMs` 从解码完成开始,不代表 JPEG 解码耗时。接收间隔、分片组装耗时及发送函数耗时各有独立口径,不能替代本测量。 + +`scripts/analyze_latency.py` 可按半开区间行号复算 NDJSON。完整结果、参数、行号范围和边界见制品 `LATENCY-RESULT.json`,分布图见 `LATENCY.png`。原始校准记录保存在本机 `target/latency/`,不把画面、私有配置或令牌日志打进制品。 + +## 交付与硬件限制 + +该阶段 App SHA-256:`05aff1f53519fbb52b89ca526c3be11cc78f37a3356f588986336cf7870924eb`。Bootloader、分区表和 App 已经 COM8 烧录并独立校验,ZIP 内含校验清单。 + +本模组除原有 `0xFADE7` 坏点,还发现 `0x10863` 的 bit 6 异常读回为 1,整段回读只差这一字节,是否永久故障尚未单独擦写复核;已把应用分区移至 `0x50000` 并验证 `0xFADE7` 对应字节可写。该兼容布局针对故障模组,每次重编仍需重新核对,详见 [HARDWARE.md](HARDWARE.md)。 diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 0000000..05bac65 --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,53 @@ +# 图传性能排查与验证(2026-10-18) + +本文保留早期短时对照;当前交付已回退到 15 dBm,功率、距离与发送拥塞分析见 [POWER.md](POWER.md),阻塞处理见 [REALTIME.md](REALTIME.md)。此前约 30 fps 与回显延迟数据见 [LATENCY.md](LATENCY.md),更早验证见 [STABILITY.md](STABILITY.md)。下列数字对应当时的传感器时序和网络状态。 + +## 环境与结论 + +测试使用当前 ESP32-S3-N16R8 / OV5640,VGA 640×480、8 MB PSRAM、CPU 240 MHz、ESP-IDF v5.5.5。板端连接 2.4 GHz Wi-Fi,实际协商 HT20 / CCMP,主要有效窗口的信号约 −18~−32 dBm;Windows 通过同一路由器通信,Mihomo TUN 始终保持启用。单客户端对照使用 1 MiB 接收缓冲,每 800 ms 单播续订。 + +传感器在 CPU0 和 CPU1 均可输出约 22.2 fps。低帧率期间持续出现 `sendto` 的 `ENOMEM`(errno 12),完整帧提交率低于采集率,客户端收到不完整分片。普通系统内存仍有约 100 KiB 以上可用,证据指向无线发送拥塞与驱动缓冲回压。核绑定和单独扩大 Windows 缓冲均未解决主要瓶颈。 + +降低最大发射功率、使用视频队列,并降低单帧大小后有所改善。功率切换对照中,20 dBm 的窗口明显恶化,10 dBm 的两次窗口约 12 fps;14 dBm 的结果仍有波动。仅凭这些数据无法确定供电、天线、射频或 AP 的具体物理原因。 + +## 接收端对照 + +| 配置 | 时长 | 完整帧 | 窗口 fps | 帧损失 | 完整帧间隔 P95 | 分片组装 P95 | +| --- | --- | --- | --- | --- | --- | --- | +| 问题复测:20 dBm / 普通队列 / quality 12 | 30 s | 165 | 5.49 | 74.69% | 672.0 ms | 109.0 ms | +| 14 dBm / 普通队列 / quality 12 | 30 s | 477 | 15.88 | 27.84% | 242.2 ms | 145.4 ms | +| 14 dBm / 视频队列 / quality 12 | 60 s | 1091 | 18.18 | 17.41% | 194.4 ms | 103.2 ms | +| 该阶段选择:14 dBm / 视频队列 / quality 18 | 60 s | 1177 | 19.62 | 11.30% | 192.8 ms | 94.2 ms | + +表中 fps = 完整帧数 / 整个测量时长。帧损失包括被替换的不完整帧、超时及帧号缺口;末尾仍在接收的帧不计损失。丢帧率与旧客户端的口径不同,旧版会低估实际丢帧。 + +无线链路有明显时变,早期其它窗口也出现过约 5~15 fps,表格不是长期保证或所有板卡的通用最优参数。前两行使用 CPU0,后两行使用 CPU1;核绑定对照未消除发送拥塞,最终保留显式 CPU1 配置。关闭 AMPDU 的试验几乎无法得到完整帧,已撤回;忙等重试也已撤回。另一次 40 ms 重试预算试验期间,有新客户端加入导致两个订阅者,且信号和画面复杂度变化;该轮约 12.6 fps,不能用于证明重试预算的单项影响,未作为最终配置依据。 + +## 最终参数与改动 + +- 该测试阶段本机 `.env` 选择 `CAM_QUALITY=18`、`WIFI_TX_POWER_DBM=14`。分辨率保持 VGA;quality 数字增大表示压缩更强,细节会有所损失。仓库默认 quality 仍为 12,未设置发射功率时保留 SDK 默认上限;当前功率选择见 [POWER.md](POWER.md)。 +- 视频 socket 设置 `IP_TOS=128`,使用 AC_VI,并保留 AMPDU;64 个动态 TX 缓冲、TX BA window 32、FreeRTOS 1000 Hz。JPEG 在 PSRAM 中原地读取,仅复用一个数据报缓冲;临时发送失败让出调度重试,等待预算为每帧 25 ms。 +- 客户端按发现的设备 IP 定向续订,接收缓冲请求 1 MiB。随机会话 ID 让同一会话的端口变化更新目标地址;旧固定 ID 保留按地址区分。实测迁移后旧端口无新视频,新端口继续接收;两个旧版订阅仍各自收到数据。 +- 接收器拒绝迟到旧帧、重复完成帧和矛盾分片,处理帧号回绕、设备重启及超时。视频 WebSocket 只保留各路最新帧;前端每路至多一次解码与一张最新待解码帧,避免持续积压。 + +## Windows 实际客户端验证 + +使用已构建的 release EXE 和独立裁判端测试配置,非演示模式。原用户配置、密码和绑定保留。 + +约 30 秒窗口中,后端完整帧增量 624、丢帧增量 38,窗口帧损失约 5.74%。界面连接存在,独立观察连接收到 619 张完整 JPEG,通道帧率 20.62 fps,帧间隔 P95 为 103.0 ms,无损坏 JPEG。 + +这项验证覆盖真实板卡、UDP 接收与重组、GUI 的 WebSocket 连接及二进制视频通道。另有 20 项协议测试、83 项客户端核心测试、9 项后端视频通道测试、5 项前端解码队列测试,以及 Windows 类型检查和生产打包通过。未将整个后端测试集或录制功能记为已验证。 + +**时间边界:**组装耗时是首末分片到达的差值,帧间隔是相邻完整帧到达的差值,均不是摄像头到屏幕的端到端延迟。未做两端时钟同步或实际屏幕的光学延迟测量,也未自动验证 WebView 最终绘制像素。 + +## 复测与原始记录 + +先退出其它图传接收进程,避免增加订阅负载: + +```text +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 不包含用户运行配置。 + +设计依据:[ESP-IDF 5.5.5 的 UDP 发送回压说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/lwip.html#limitations)、[Wi-Fi QoS 的 IP precedence / AC_VI 映射](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wi-fi-qos)。这些文档说明机制,性能数字来自上述本机测量。 diff --git a/POWER.md b/POWER.md new file mode 100644 index 0000000..2d5bb36 --- /dev/null +++ b/POWER.md @@ -0,0 +1,64 @@ +# 发射功率、断连距离与拥塞分析(2026-10-18) + +当前交付请求 `WIFI_TX_POWER_DBM=15`,实板驱动回读 **15.00 dBm**。根据用户的距离与拥塞反馈,从 17 dBm 回退;启动和 Wi-Fi 重连后应用同一设置,压缩与温控控制器不自动降低发射功率。三缓冲、VGA 彩色、AE 目标 45、约 20 ms 曝光上限和 PLL 时钟保持不变。 + +这里的数值是驱动配置的发射功率上限。API 回读不能代替功率计,不能认为所有 PHY 速率下、每个数据包的天线端输出都恰好相同。 + +## 先前 17 dBm 记录说明了什么 + +| 记录 | 时间 | 原生接收帧率 | 原生帧损失率 | 板端发送失败 / 应用重试 / 放弃目标帧 | +| --- | --- | --- | --- | --- | +| 17 dBm 第一轮 | 视频 30 s / 串口 75 s | 29.78 fps | 0.667% | 90 / 80 / 10(整段串口) | +| 17 dBm 第二轮,增加周期回读 | 视频 30 s / 串口 75 s | 27.92 fps | 6.889% | 498 / 446 / 52(整段串口) | + +两轮的发射上限相同,曝光与缓冲参数相同,但无线状态、场景内容和时间并未锁定,且第二轮多了周期功率读数。因此这些记录不构成严格 A/B 对照,不能单凭它们断定“增加 3 dB 必然导致某个百分比的丢帧”。用户反馈是回退依据,日志用于定位失败发生在哪一层。 + +第二轮内部空闲堆最少 120039 字节、最大连续块最少 43008 字节,温度 57–58°C,无温控限帧、复位或采集错误,只有一个 UDP 订阅者。出现发送失败时伴有 `errno 12 / ENOMEM`,优先指向网络发送资源的瞬时耗尽。堆仍有余量不能排除受配额限制的 Wi-Fi TX 缓冲已满,也不能把这个错误直接当成内存泄漏。 + +ESP-IDF 明确说明,UDP 连续 `send/sendto` 可以因底层驱动 TX 缓冲全部占用而返回 `ENOMEM`。目前日志中的“446 次重试”是**应用调用 sendto 的重试**,不是已测得的 802.11 空口重传次数。来源:[ESP-IDF v5.5.5 lwIP 文档](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/lwip.html#limitations)。 + +## 为什么距离改善不明显 + +1. **功率差只有 3 dB。** 14→17 dBm 的配置上限约为两倍功率,不是两倍距离。按简化路径损耗模型 `距离倍率 = 10^(3/(10n))`,自由空间 `n=2` 时约 1.41 倍,假设室内 `n=3–4` 时约 1.19–1.26 倍。这只是模型估算,不是本次测得的距离。 +2. **收发链路必须同时成立。** 增强板卡发射不能增强板卡接收;AP 信标、应答、订阅和数据回程仍依赖另一方向。ESP-IDF 的断连说明包含持续丢失信标等原因。当前记录没有最远点的断连 reason、AP 侧接收强度或重传统计,不能确定距离边界由哪一方向限制。来源:[ESP-IDF 断连事件说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wifi-event-sta-disconnected)。 +3. **回读是上限,RSSI 有方向。** 板端日志里的 RSSI 是板卡接收到 AP 的信号,不是 AP 接收到板卡的信号。17 dBm 第二轮 RSSI 为 −25 至 −14 dBm,属于近距离记录,不能拿来证明远距离的上行链路已经改善。 + +## 为什么拥塞仍会加重 + +已确认的应用行为是:每约 33 ms 取得一张 JPEG,再把它拆成多个数据报连续提交,分片间隔为 0;成功返回只表示本地网络栈接受,不能证明 AP 或电脑已经收到。每帧 8 ms 重试预算和 25 ms 持有期限限制应用等待,但不能撤回已经进入 SDK 的旧分片。 + +提高发射上限不会增加信道带宽或路由器的排队能力。若空口竞争、速率回退或重传使驱动排空变慢,突发提交仍会填满 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 记录也不能证明射频供电完全没有瞬态变化。 + +电脑当前通过 **5 GHz / 信道 161 / 802.11ac** 连接,摄像头使用 2.4 GHz。因此不能直接声称视频在同一个 2.4 GHz 信道被路由器重复发送了两次;摄像头上行、AP 转发、电脑接收与 TUN 处理仍可能分别排队,TUN 保持原样。 + +## 还需区分本地跳帧 + +17 dBm 第二轮视频区间中,原生接收了 838 张完整 JPEG,而辅助本地 WebSocket 观察器收到 582 张。两者不是同一个指标:后端采用最新帧覆盖和帧龄期限,慢消费者可能主动跳过已完整收到的帧;少收到的本地消息不能全部算成无线丢帧,更不能直接等同于真实屏幕绘制帧率。 + +## 当前回退验证与后续定位 + +测试期间用户补充观察:用手握持开发板时丢包明显增多。测试没有逐秒标记握持、位置和朝向,五分钟记录应视作现场连续性观察,不能当作固定摆位的功率 A/B 测试。人体靠近 PCB 天线导致的吸收、失谐或遮挡成为更值得优先排查的线索。 + +本轮只改变功率上限到 15 dBm,保持其他图传参数与单 UDP 订阅,做五分钟收流观察;结果由交付包 `POWER-RESULT.json` 记录。之前的 17 dBm 记录保留为 `POWER-HISTORY-tx17.json`,14 dBm 的阻塞处理记录保留在 [REALTIME.md](REALTIME.md)。本轮不重新测量最远断连距离或物理屏幕端到端延迟。 + +下一项值得单独对照的是**分片突发整形**:固定 15 dBm、曝光和 JPEG 策略,按发送回压逐步比较小批次提交与短暂让出调度,并同时看原生完整帧率、网络丢帧、主动跳帧和帧龄。不能仅通过增大缓冲或延长重试,把丢帧转化为持续排队延迟。若要确认距离瓶颈,需要同步记录远点断连原因、AP 侧板卡 RSSI、MAC 重传和信道忙占比。 + +## 蛇形 PCB 天线的握持改善 + +手接近天线会改变其周围的电磁环境,人体和外壳可使 PCB 天线失谐;人体也会吸收、遮挡部分射频能量。这是对用户观察的机制解释,本板尚未测量 S11、效率或握持前后的辐射图。TI 的 2.4 GHz PCB 天线实测例子显示,加入外壳并手持后匹配状态明显变化,需要在实际使用条件下匹配。来源:[TI 天线阻抗测量与匹配,章节 6](https://www.ti.com/lit/an/swra726/swra726.pdf)。 + +建议按以下顺序改善: + +1. **先改善握持和摆位。** 用塑料支架或绝缘延长握柄固定,让蛇形天线端伸出、远离手掌;握住远离天线的一端。可先尝试让手与天线相距约 3–5 cm,再按实测增加距离,这不是保证性能的固定阈值。 +2. **留出天线净空。** Espressif 建议成品外壳中天线各方向至少 15 mm 净空;这不是“距离手 15 mm 就一定够”的保证。天线附近避开电池、金属支架、屏蔽片和紧贴的 USB 线或排线,外壳在天线区域使用非金属材料。来源:[ESP32-S3 PCB 天线布局指南](https://docs.espressif.com/projects/esp-hardware-design-guidelines/en/latest/esp32s3/pcb-layout-design.html#general-principles-of-pcb-layout-for-modules-positioning-a-module-on-a-base-board)。 +3. **固定朝向再比较。** 在同一位置和画面下,保持开发板朝向固定,比较“不接触 → 握远端 → 手靠近天线 → 移开手”,每段 30–60 秒,同时记录完整帧损失和板端 RSSI。单看 RSSI 不能覆盖上行质量,但可帮助区分朝向、遮挡与队列负载变化。 +4. **需要长期手持时,优先考虑外置天线版本。** `ESP32-S3-WROOM-1` 为 PCB 天线,`WROOM-1U` 为外置天线接口版本。选兼容摄像头接线、Flash/PSRAM 配置的板卡,将合适的 2.4 GHz 天线安放在握持区域之外;具体封装、接口和射频连接须按实际板卡核对。外置天线也应远离手和金属。来源:[Espressif 模组数据手册](https://documentation.espressif.com/esp32-s3-wroom-1_wroom-1u_datasheet_en.pdf)。 +5. **必须贴近人体使用时,按最终结构调谐。** 外壳、支架、馈线和手持姿态都参与最终匹配,需要 VNA/射频测量后调整匹配网络。直接延长蛇形走线、随意焊线或并接两根天线不能代替调谐;在现板上先做可逆的机械隔离更容易验证。 + +## 15 dBm 本轮结果 + +当前 15 dBm 交付 App SHA-256:`a6092e4b0cf42819c04424e066651f285b8961312a73822d667532f800e46767`,应用分区 `0x70000`。COM8 烧录与三段独立校验通过;355.1 秒串口的 71 次功率回读均为 15.00 dBm。五分钟观察:原生完整接收 **29.68 fps**,丢帧 **1.045%**,本地 JPEG 通道 **28.23 fps**,31 张抽帧全部完整解码为 640×480 RGB,无无效 JPEG、设备离线、通道断开、复位或采集错误;仍有最长 1.646 秒到帧间隔,不能将此轮称为无卡顿。距离与拥塞分析、测试边界见 [POWER.md](POWER.md) 和制品 `POWER-RESULT.json`。 + +按时间近似对齐的板端五分钟区间有 731 次发送失败、653 次应用重试、78 次目标帧放弃;温度 56–58°C,RSSI -35 至 -11 dBm。板端区间由五秒采样的结束时间选择,边界并非逐包精确对齐。与此前短测时长、现场无线状态和 JPEG 内容不同,不能将差值归因于功率这一项。 diff --git a/README.md b/README.md index 4224d18..a1e41f0 100644 --- a/README.md +++ b/README.md @@ -1,395 +1,294 @@ -# ESP32-S3 摄像头图传(Rust / ESP-IDF / UDP) +# ESP32-S3 摄像头图传系统 -两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV5640),向队伍客户端与裁判终端推 -UDP 图传。 +基于 **Rust + ESP-IDF** 的局域网实时图传系统。ESP32-S3 采集摄像头输出的 JPEG,通过 UDP 向桌面客户端推流;桌面端提供队伍端与裁判端界面,并接收 ESP32-C3 装甲板的血量广播。 -固件是跑在 ESP-IDF 上的 Rust,用官方 esp32-camera 组件采集;桌面端是 **Tauri v2 + -React**,用户端与裁判端两套页面(见 [`desktop/README.md`](desktop/README.md))。同一套 -系统里还有一队一块的**装甲板**(ESP32-C3,一主三从),负责四路击打检测、灯带与整车血量, -并把血量广播给局域网里的两端。 +## 功能与架构 + +- 摄像头自动探测、JPEG 采集、UDP 分片发送与完整帧重组。 +- 局域网广播发现与订阅,单块图传板最多向 4 个客户端发送画面。 +- 运行中 Wi-Fi 断开后自动重连,重新获得 IP 后重建 UDP socket 并恢复订阅。 +- 使用 MAC 派生的稳定设备 ID;两块图传板可烧录同一固件,在客户端绑定队伍。 +- Tauri v2 + React 桌面端:视频显示、双方血量、设备绑定与录制。 +- 独立 ESP32-C3 装甲板固件:四路受击检测、WS2812 灯带及血量上报。 ```text -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 +ESP32-S3 图传板 A ─┐ ┌─ 队伍客户端 + ├─ 同一局域网 / UDP ─┼─ 裁判客户端 +ESP32-S3 图传板 B ─┤ └─ 队伍客户端 +ESP32-C3 装甲主板 ─┘ ``` -五份产物各自独立构建,各有自己的 `.cargo/config.toml`。 +图传使用 UDP **5600**,装甲板血量使用 UDP **5601**。板卡与客户端需在可互相通信、允许广播的网络中;关闭 AP 客户端隔离,并允许客户端程序通过防火墙。 -## 拓扑 +## 项目结构 + +| 路径 | 用途 | +| --- | --- | +| `src/` | ESP32-S3 图传固件,根目录 Cargo 项目 | +| `components/` | 本地相机驱动及 C/Rust 接口封装 | +| `protocol/` | 共享 UDP 协议与帧重组,支持主机测试 | +| `armor/` | 独立 ESP32-C3 装甲板 workspace | +| `desktop/` | Tauri + React 客户端及独立网络核心 `core/` | +| `scripts/` | 构建、烧录与探测脚本 | +| `reference/armor/` | 早期 C 版装甲板工程 | + +各子项目的构建目标不同。以下编译、烧录流程针对**根目录的 ESP32-S3 图传固件**。 + +## 硬件要求 + +| 项目 | 当前配置 | +| --- | --- | +| 开发板 | Freenove ESP32-S3-WROOM CAM | +| 模组 | ESP32-S3-WROOM-1-N16R8,16 MB Flash + 8 MB Octal PSRAM | +| 摄像头 | OV5640;驱动包含 OV2640 / OV3660 支持,接线需核对 | +| 网络 | 2.4 GHz Wi-Fi | +| 连接 | 支持数据传输的 USB 线、可识别的串口及稳定供电 | + +默认采集 VGA(640 × 480)、彩色 JPEG quality 12,按传感器节拍发送。相机引脚、Flash 分区和 PSRAM 配置见 [HARDWARE.md](HARDWARE.md)。换板型前必须核对接线,避免占用 Flash / PSRAM 引脚。 + +## ESP Rust 工具链入门 + +本项目使用 Rust `std` 和 ESP-IDF。ESP32-S3 属于 **Xtensa** 架构,需要通过 `espup` 安装 Espressif Rust 工具链;普通 `rustup target add` 不能替代这一步。根目录固定选择名为 `esp-1.97.0.0` 的工具链,避免浮动版本影响构建。 + +### 1. 安装基础依赖与 Rust 工具 + +先安装 [Rust / rustup](https://rustup.rs/)、[Git](https://git-scm.com/downloads)、[Python 3](https://www.python.org/downloads/)(建议 3.11)、[CMake](https://cmake.org/download/) 与 [Ninja](https://ninja-build.org/),确保命令在 `PATH` 中。 + +- **Windows**:使用 PowerShell 7.5 或更新版本,安装 Visual Studio Build Tools 的“使用 C++ 的桌面开发”和 Windows SDK,使用 MSVC Rust 主机工具链。仓库和工具链尽量使用较短、无空格和中文的路径。 +- **Linux(Debian / Ubuntu)**:安装 `build-essential pkg-config libudev-dev libssl-dev python3 python3-venv cmake ninja-build git curl`。 +- **macOS**:安装 Xcode Command Line Tools(`xcode-select --install`),以及 Python、CMake 和 Ninja。 + +以下命令显式使用 `+stable`,即使在仓库内执行,也不会要求事先已有 `esp`: ```text - ┌──────────────────────────────┐ - │ 裁判终端(PC,仅显示) │ - │ 队伍一 + 队伍二 + 双方血量 │ - └───────────────┬──────────────┘ - │ 同一个局域网,广播可用 - ┌────────────────────┼────────────────────┐ - │ │ │ - ┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐ - │ 图传板 A │ │ 图传板 B │ │ 装甲板主板 │ - │ (队伍一) │ │ (队伍二) │ │ ×2(每队一块)│ - └───────────────┘ └───────────────┘ └───────────────┘ +rustup toolchain install stable +cargo +stable install espup --locked +cargo +stable install ldproxy --locked +cargo +stable install espflash --version 4.4.0 --locked +espup install --name esp-1.97.0.0 --toolchain-version 1.97.0.0 --targets esp32s3 --std ``` -**图传板不区分队伍。** 它们只知道自己是谁(MAC 派生的 `sender_id`),"哪一边"由 -客户端决定。烧同一份固件即可,配置全在客户端一侧。 +也可以从各工具的官方 releases 下载预编译程序。`espup --std` 安装本项目所需的 Rust / LLVM 工具,ESP-IDF 的 C 工具链由后续构建准备。 -**装甲板反过来:队伍是编译期定的**(`ARMOR_TEAM=0/1`,同时决定灯色红/蓝),因为血量 -上报里必须带 `team` 字段,而且灯色现场不该靠按键切错。见 `armor/README.md`。 +Linux / macOS 每次新开终端后执行: -裁判终端同时收两块图传板,也收两块装甲板主板在 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. 固件 - -```sh -cp .env.example .env # 填 WIFI_SSID / WIFI_PASS -./scripts/flash.sh --monitor 20 +```bash +. "$HOME/export-esp.sh" ``` -`flash.sh` 会编译、挑选避开坏字节的 app 偏移、烧录三段(bootloader / 分区表 / -app)、打印串口日志。ESP-IDF 位置从 `.cargo/config.toml` 读,不用先 `activate`。 +Windows 安装后重新打开 PowerShell,让环境变量生效;若当前终端仍找不到 LLVM,可执行 `. "$HOME/export-esp.ps1"`。检查结果: -`.env`: - -```sh -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) +```text +rustup toolchain list +rustc +esp-1.97.0.0 --version +cargo +esp-1.97.0.0 --version +espflash --version ``` -凭据由 `env!` 在**编译期**读取,所以没有运行时解析路径,也不会因为忘了读 `.env` 而 -在板子上带着别人的密码。`ARMOR_TEAM` 同理:换队伍重刷装甲板固件即可,不用擦 NVS。 +官方入门资料:[The Rust on ESP Book](https://docs.espressif.com/projects/rust/book/getting-started/toolchain.html)、[espup](https://github.com/esp-rs/espup)、[espflash](https://github.com/esp-rs/espflash/tree/v4.4.0/espflash)。 -`WIFI_AUTH` 一般不用设:固件先扫描 AP,用它报告的真实认证方式去连,只有扫不到时才 -需要人工指定。指定之后**只**试这一种方式——不成功就直接报错,而不是挨个试 10 秒。 -挨个试等于在猜一个本该已知的值。 +### 2. 准备 ESP-IDF -### 2. 装甲板固件(ESP32-C3) +默认使用 **ESP-IDF v5.5.5**。首次构建时,`esp-idf-sys` 下载 SDK、对应 C 工具链及 Python 依赖,默认缓存到用户目录的 `.espressif/`;需要网络、磁盘空间及缓存目录写入权限。 -```sh -scripts/flash_armor.sh # 编译 + 烧录 + 20 秒串口日志 -python3 scripts/armor_probe.py 15 # 不开界面,单看板子在广播什么血量 +已有匹配版本 SDK 时可在终端设置 `IDF_PATH`,不要提交个人绝对路径: + +```powershell +# Windows:替换为实际 SDK 源码目录。 +$env:IDF_PATH = 'C:\Espressif\frameworks\esp-idf-v5.5.5' ``` -接线表、血量规则、C3 的 RMT/ADC 踩坑都在 [`armor/README.md`](armor/README.md)。 +```bash +# Linux / macOS:替换为实际 SDK 源码目录。 +export IDF_PATH="$HOME/esp/esp-idf-v5.5.5" +``` -### 3. 桌面端 +使用自动下载时,清除指向其他 SDK 的旧 `IDF_PATH`。缓存位置等高级设置见 [esp-idf-sys 构建选项](https://github.com/esp-rs/esp-idf-sys/blob/master/BUILD-OPTIONS.md)。 -```sh +## 配置 Wi-Fi 与摄像头 + +在根目录复制 `.env.example` 为 `.env`,再用编辑器填写;已有 `.env` 时不要覆盖。 + +```powershell +# Windows PowerShell +Copy-Item .env.example .env +``` + +```bash +# Linux / macOS +cp .env.example .env +``` + +```dotenv +WIFI_SSID='你的2.4G网络名称' +WIFI_PASS='你的Wi-Fi密码' + +# 可选,省略时使用固件默认值。 +#CAM_RES=vga +#CAM_QUALITY=12 +#CAM_QUALITY_MAX=0 +#CAM_MAX_FPS=0 +#CAM_CLOCK_PROFILE=default +#CAM_FB_COUNT=3 +#CAM_GRAYSCALE=0 +#CAM_EXPOSURE_EV=0 +#CAM_AE_LEVEL=-1 +#CAM_SHUTTER_LINES=0 +#CAM_EXPOSURE_MAX_LINES=0 +#CAM_GAIN_CEILING=0 +#CAM_HTS=0 +#UDP_PACKET_GAP_US=0 +#UDP_RETRY_BUDGET_US=8000 +#CAM_THERMAL_LIMIT_C=75 +#CAM_VSYNC_INVERT=1 +#WIFI_AUTH=wpa2 +``` + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `WIFI_SSID` | 无,必填 | 2.4 GHz 网络名称,最多 32 字节 | +| `WIFI_PASS` | 空字符串 | Wi-Fi 密码;开放网络可留空 | +| `CAM_RES` | `vga` | `qqvga` / `qvga` / `cif` / `hvga` / `vga` / `svga` / `xga` / `sxga` / `uxga` | +| `CAM_QUALITY` | `12` | 0–63,越小通常画质越好、JPEG 越大 | +| `CAM_QUALITY_MAX` | `0` | `0` 固定画质;设为 `CAM_QUALITY`–63 时按发送回压在两者之间调节压缩,保留彩色和分辨率。拥塞时每 5 秒增大最多 3 档,连续稳定 15 秒才恢复 1 档 | +| `CAM_MAX_FPS` | `0` | 取帧与发送的最高节拍,1–60;`0` 不限帧。仅限制消费节拍,不修改传感器时钟 | +| `CAM_CLOCK_PROFILE` | `default` | `default` 保留驱动时钟;`vga30` 使用 OV5640 VGA 约 30.09 fps PLL 配置,保持 HTS/VTS 和读出窗口,SYSCLK 61 MHz / PCLK 15.25 MHz。切换后需重新核对曝光行数 | +| `CAM_FB_COUNT` | `3` | PSRAM 帧缓冲数,允许 2 或 3。当前交付选三缓冲,保留发送突发时的采集余量;双缓冲曾在短测降低部分回显延迟,但用户长程运行反馈峰值丢帧明显,保留为试验选项 | +| `CAM_GRAYSCALE` | `0` | `1` 使用传感器灰度效果,保留硬件 JPEG;`0` 彩色 | +| `CAM_EXPOSURE_EV` | `0` | OV5640 自动曝光目标偏置:`-1` / `-2` 约对应 −1.1 / −1.9 EV;不是标定后的绝对 EV,也不保证快门缩短 | +| `CAM_AE_LEVEL` | 未设置 | 可选 OV5640 驱动 AE 档位,−5–5;仅与 `CAM_EXPOSURE_EV=0` 同用。`-1` 将目标从 55 小幅降至 45,目标比值约 −0.29 EV,保留自动曝光/增益;未设置时按 EV 参数处理。实际快门和亮度变化取决于场景 | +| `CAM_SHUTTER_LINES` | `0` | OV5640 固定快门,单位为传感器曝光行;`0` 自动曝光,非零保持自动增益。默认 VGA / HTS 2060 下 220 行估算约 10 ms;数值不能超过当前 VTS | +| `CAM_EXPOSURE_MAX_LINES` | `0` | OV5640 自动曝光上限,`0` 保留驱动默认。非零保持 AEC/AGC 联动并禁用夜景延帧,不能与固定快门同时使用。默认 VGA 下 440 行约 20 ms;`vga30` 下 592 行约 20 ms。实际曝光由场景和抗频闪步长决定 | +| `CAM_GAIN_CEILING` | `0` | OV5640 自动增益上限,`0` 保留驱动默认;1–63,单位为倍,实际上限略低于整数档。短曝光遇到室内弱光时可提高,但暗部噪点和 JPEG 负载可能增加 | +| `CAM_HTS` | `0` | OV5640 水平总长度试验参数,`0` 使用驱动默认。缩短消隐会影响彩色稳定性,本机 1500 / 1800 出现绿紫闪烁,已撤回;几何检查通过不代表可用 | +| `UDP_PACKET_GAP_US` | `0` | 成功提交视频数据报之间的最小间隔;0–5000 μs,须为调度 tick 的整数倍(当前为 1000 μs)。配合有界帧等待,避免集中突发写入队列 | +| `UDP_RETRY_BUDGET_US` | `8000` | 每帧、所有分片及订阅者共用的让出调度重试预算,0–25000 μs,须为 tick 整数倍;`0` 为非阻塞失败即弃。总发送检查期限为 25 ms,采集帧龄期限为 100 ms;分片间隔也计入期限,不会额外延长 | +| `CAM_THERMAL_LIMIT_C` | `75` | 芯片内部温度达到该值时减半帧率(向上取整),下降 5°C 后恢复;40–95,`0` 禁用。原先不限帧时降至 8 fps | +| `CAM_VSYNC_INVERT` | `1` | 驱动反相 VSYNC;硬件已反相时填 `0` | +| `WIFI_AUTH` | 自动选择 | `none` / `wep` / `wpa` / `wpa2` / `wpawpa2` / `wpa3` / `wpa2wpa3` / `wapi` | +| `WIFI_TX_POWER_DBM` | SDK 默认上限 | 可选整数 2–20 dBm;当前交付请求 15 dBm,本板驱动回读 15.00 dBm;启动及重连时应用,拥塞和温控逻辑不降低此上限 | + +`.env` 使用简单的 `KEY=value`;值含空格、`#` 或 `$` 时用单引号包围,不使用变量展开或命令替换。脚本会加载 `.env`,直接 `cargo build` 不会自动读取它。上述参数在编译时写入固件,修改后需要重新编译和烧录。`.env` 已被 Git 忽略;固件也包含凭据,分享前应重新配置。 + +运行时断线恢复不重置摄像头、曝光或增益。首次尝试立即发起,失败后按 1、2、4、8、16、30 秒退避;关联与 DHCP 分别有 15 秒期限。恢复 IP 后丢弃旧采集帧、重建 socket 并重新广播,由客户端自动续订。启动时首次联网仍使用扫描与认证模式探测流程。实现依据 [ESP-IDF 断线事件处理说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.5/esp32s3/api-guides/wifi.html#wifi-event-sta-disconnected)。 + +## 编译固件 + +在仓库根目录执行: + +```powershell +# Windows PowerShell +$env:CARGO_TARGET_DIR = 'C:\s3vt' +.\scripts\build.ps1 +``` + +```bash +# Linux / macOS +./scripts/build.sh +``` + +脚本执行 `cargo build --release --locked`,目标为 `xtensa-esp32s3-espidf`。首次编译包含 Rust 标准库、ESP-IDF 和相机组件,耗时较长。 + +Windows 的 ESP-IDF 构建输出路径需不超过 88 字符,因此示例使用短目录 `C:\s3vt`(需可写,且不与其他项目共用)。`subst` 不能绕过该检查。下表的 `` 在 Windows 示例中为 `C:/s3vt`,Linux / macOS 默认是仓库的 `target`。每次新开 Windows 终端都要设置相同的 `CARGO_TARGET_DIR`。 + +| 产物 | 位置 | +| --- | --- | +| 固件 ELF | `/xtensa-esp32s3-espidf/release/esp32`(没有 `.exe` 后缀) | +| Bootloader | `/xtensa-esp32s3-espidf/release/bootloader.bin` | +| 分区表 BIN | `/xtensa-esp32s3-espidf/release/partition-table.bin` | +| 分区表源文件 | 根目录 `partitions.csv` | + +ELF 供 `espflash` 烧录与符号解析,不能当作 BIN 直接写入 Flash。Bootloader、分区表与 App 应来自同一次构建。 + +## 烧录与串口验证 + +### 1. 查找串口 + +```text +espflash list-ports +``` + +Windows 常见 `COM5`;Linux 为 `/dev/ttyACM0` 或 `/dev/ttyUSB0`;macOS 为 `/dev/cu.usbmodem...`。关闭其他串口监视器,Linux 需具备串口访问权限。 + +### 2. 烧录 ELF + +本项目使用自定义分区表,显式传入 `partitions.csv` 和本次构建的 Bootloader。将示例端口替换为实际端口: + +```powershell +$targetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { 'target' } +espflash flash --chip esp32s3 --port COM5 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$targetDir/xtensa-esp32s3-espidf/release/bootloader.bin" --monitor "$targetDir/xtensa-esp32s3-espidf/release/esp32" +``` + +```bash +target_dir="${CARGO_TARGET_DIR:-target}" +espflash flash --chip esp32s3 --port /dev/ttyACM0 --flash-size 16mb --flash-mode dio --flash-freq 40mhz --partition-table partitions.csv --bootloader "$target_dir/xtensa-esp32s3-espidf/release/bootloader.bin" --monitor "$target_dir/xtensa-esp32s3-espidf/release/esp32" +``` + +连接失败时按住 **BOOT**,点按 **RESET / EN**,松开 BOOT 后重试。正常启动应看到相机识别、Wi-Fi 获取 IP 和 `ESP32 camera is live` 日志。没有客户端时,`0 clients subscribed` 属于正常状态。 + +仅查看日志: + +```text +espflash monitor --port COM5 +``` + +### 3. 导出 BIN + +`espflash save-image --merge` 可导出从 `0x0` 写入的合并镜像。完整命令、独立镜像偏移与测试方法见 [DEVELOPMENT.md](DEVELOPMENT.md)。 + +保留的 `scripts/flash.sh` 针对**特定坏 Flash 模组**,依赖 macOS 环境,会修改分区并重编译。普通板卡使用上述标准流程;坏板限制见 [HARDWARE.md](HARDWARE.md#损坏-flash-模组的兼容处理)。 + +## 客户端与装甲板 + +桌面端入口如下。安装依赖、主机 target 设置与功能说明见 [desktop/README.md](desktop/README.md): + +```text cd desktop pnpm install -pnpm app # = tauri dev +pnpm app ``` -没有板子也能把整条链路跑起来:`CAM_DEMO=1 pnpm app` 会合成**协议数据报**喂进真实的接收 -路径(分片、重组、发现、掉血派生、两道闸门全在跑真代码),界面上标着"演示模式"。 +装甲板是独立的 **ESP32-C3 / RISC-V** 工程,不能使用 S3 的 ELF。`ARMOR_TEAM=0/1` 在编译期决定红 / 蓝队伍。接线、规则与构建烧录见 [armor/README.md](armor/README.md)。 -首次启动会问这台机器是哪一端:**用户端**要绑一块图传板与一块装甲板(改动需密码), -**裁判端**不需要任何配置就能看两路。 +## 常见问题 -**`desktop/.cargo/config.toml` 里的 `target = "aarch64-apple-darwin"` 换机器必须改。** -desktop/ 嵌在固件的目录树里,会继承父目录那个 `xtensa-esp32s3-espidf` 的交叉编译配置, -然后在依赖深处以 `libc` 找不到 `MAP_FAILED` 的形式失败。 +| 现象 | 检查方法 | +| --- | --- | +| 提示缺少 `esp-1.97.0.0` | 按上述命令安装指定名称的工具链,重新打开终端后检查版本 | +| `espup` 查询 GitHub 版本返回 403 | 对上述固定版本安装命令追加 `--skip-version-parse`,或稍后重试 | +| 找不到 `ldproxy` / `libclang` | 安装链接工具;Unix 加载 `export-esp.sh`,Windows 重开终端 | +| ESP-IDF 路径或版本错误 | 清除旧 `IDF_PATH` 或指向 v5.5.5 SDK | +| Windows 路径过长 | 缩短仓库和工具链路径,见开发说明 | +| Wi-Fi 连不上 | 核对 2.4 GHz、凭据和认证方式,再重新编译烧录 | +| 相机无输出 / 初始化失败 | 核对板型接线、供电与 Octal PSRAM | +| `NO-EOI`、几帧后停止 | 确认 `CONFIG_CAMERA_PSRAM_DMA=n` | +| 在线但没有画面 | 检查客户端订阅、UDP 5600、防火墙及 AP 隔离 | +| 开启 VPN / 虚拟网卡后设备在线但无画面 | 使用支持按已发现设备 IP 定向续订的客户端;仅发送 `255.255.255.255` 广播可能走错网卡 | +| 改 C 组件后无变化 | 清理 `esp-idf-sys` 构建缓存后重编,见开发说明 | +| 主机项目被编为 Xtensa | 显式指定宿主 target,见各子项目说明 | -界面、视频通道、密码模型、录制与排查都在 [`desktop/README.md`](desktop/README.md)。 +用 `python scripts/udp_probe.py 10` 可独立检查视频协议;装甲板用 `python scripts/armor_probe.py 15`。这些脚本需要板卡在线,构建通过不代表已完成硬件验证。 -## 发现是双向的 +测量帧率和丢帧可运行以下命令,将 IP 换成板卡实际地址。对照测试时先退出桌面客户端,避免额外订阅者增加无线负载: -单靠一个方向建立不了"谁要看我": - -- 板子每 **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 文件之后 - -```sh -rm -rf target/xtensa-esp32s3-espidf/release/build/esp-idf-sys-* +```text +python scripts/perf_probe.py 192.168.1.139 --seconds 30 --json perf.json ``` -不删不会重新编译,你的补丁看起来像完全没生效。 +脚本定向续订,支持保留 VPN / TUN 的环境。输出包含完整帧率、帧损失率、接收间隔和组装耗时;这些时间不等于摄像头到屏幕的端到端延迟。 -## 摄像头端不编码,客户端不编码 +运动图传保持 `CAM_GRAYSCALE=0`,优先使用自动曝光上限和自动增益协同控制拖影。室内强制 −1 EV / 固定短快门在本机过暗,已撤回;提高增益上限也会增加噪点。需要限流时可设置 `CAM_MAX_FPS=15`,追求帧率时使用 `0`;可启用有界 JPEG 压缩调节应对发送回压,分片间隔按网络实测选择。灰度效果仍保留硬件 JPEG,不是改为 RGB565 或降低 JPEG 采样位数。75°C 是主动减载阈值,温度来自 ESP 芯片内部,不能代表摄像头或环境温度,也不是芯片损坏温度规格。五分钟彩色对照与此前中止的灰度长测见 [STABILITY.md](STABILITY.md)。 -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`](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 | -| 改了绑定却"没生效" | 改动需要先解锁;后端每个改动命令都会校验,界面上的入口只是入口 | - -独立核对线路格式: - -```sh -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`](armor/README.md)。 - -```sh -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 看起来像真值,现场看到会以为对方已经没血了。界面上的血量画在画面角上(一条分 5 格的 -平行四边形,20 点血一格),归零不写"已击毁/损毁",而是由图传中央的「胜利 / 击败」横幅表达 -(见 [`desktop/README.md`](desktop/README.md));数字变化也会打进客户端日志。 - -同一块板子还会被单独记下来(帧头里的 MAC 派生 id),所以两端都能列出"场上有哪几块装甲板" -并选一块绑定——命中反馈也是从这个 id 上归位的。**这不需要改固件**:主板一直在用 -`encode_from(sender_id)` 把自己的 id 打进帧头。 +- [CHANGELOG.md](CHANGELOG.md):改动记录与历史修复。 +- [HARDWARE.md](HARDWARE.md):接线、内存、Flash 与驱动限制。 +- [DEVELOPMENT.md](DEVELOPMENT.md):镜像导出、测试、协议与性能测量。 +- [PERFORMANCE.md](PERFORMANCE.md):保留 TUN 的图传排查、参数取舍与实际测试结果。 +- [STABILITY.md](STABILITY.md):限帧、灰度与温度减载,长时间运行测试。 +- [REALTIME.md](REALTIME.md):阻塞时取最新帧、超期丢弃和客户端积压处理。 +- [POWER.md](POWER.md):15 dBm 回退、功率与距离的关系,以及发送拥塞证据。 +- [LATENCY.md](LATENCY.md):当前约 30 fps 配置、Wi-Fi 重连及实际画面链路延迟对照。 +- [protocol/README.md](protocol/README.md):UDP 线路格式及帧重组。 +- [armor/README.md](armor/README.md):装甲板固件与规则。 +- [desktop/README.md](desktop/README.md):桌面端配置、录制与排查。 diff --git a/REALTIME.md b/REALTIME.md new file mode 100644 index 0000000..7ab0e50 --- /dev/null +++ b/REALTIME.md @@ -0,0 +1,73 @@ +# 阻塞恢复与最新帧处理(2026-10-18) + +## 当前交付 + +当前 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.md) 和制品 `POWER-RESULT.json`。 + +保留已确认可用的三块 PSRAM 采集缓冲、OV5640 VGA 彩色、约 30 fps PLL 时钟、AE 目标 45/55、约 20 ms 曝光上限及自动增益;Wi-Fi 自动重连和 TUN 兼容订阅继续保留。没有增加待发送 JPEG FIFO,也没有为了消除队列改成单块 DMA 缓冲。 + +发送程序只持有当前一张完整 JPEG;阻塞恢复后通过有限次数的零等待取帧选择最新有效帧,旧帧立即归还驱动。三块采集内存用于捕获余量,与必须按序播放的三帧队列不同。已有相机队列仍然有限,应用交接会跳过其旧帧。 + +完整 JPEG 取得后按 1400 字节载荷分片立即提交,复用一个数据报缓冲,直接读取 PSRAM。当前传感器驱动在 JPEG 结束后交出完整帧,本次没有修改为 DMA 尚未完成就读取其内容;不完整 JPEG 由接收端整帧丢弃,不进入绘制。 + +## 阻塞处理 + +| 阶段 | 处理方式 | +| --- | --- | +| 采集交接 | 首帧最多等待 100 ms,其后零等待、有限次数取更新的有效帧;归还被替代的旧缓冲 | +| 发送帧龄 | 用板内同一单调时钟检查从首个 DMA 缓冲开始的年龄,期限 100 ms | +| 当前帧发送 | 绝对检查期限 25 ms,成功发送分片不会续期,所有订阅者共享 | +| 拥塞重试 | 默认每帧最多 8 ms 让出调度重试;预算用尽或过期后放弃该目标剩余分片,转向新帧 | +| 原生本地通道 | 每路保留最新帧;不重放超过 100 ms 的缓存,写入超过 100 ms 时断开慢端以便重连 | +| WebView / 解码 | 原生端在 JPEG 前发送 `frameTiming`;JS 在解码前后拒绝本机滞留超过 100 ms 的帧并释放旧结果 | + +```dotenv +CAM_FB_COUNT=3 +CAM_AE_LEVEL=-1 +CAM_EXPOSURE_EV=0 +CAM_EXPOSURE_MAX_LINES=592 +UDP_PACKET_GAP_US=0 +UDP_RETRY_BUDGET_US=8000 +WIFI_TX_POWER_DBM=15 +``` + +当前交付根据用户反馈回退到 15 dBm,实板驱动回读 15.00 dBm。启动和重连应用同一功率上限,拥塞和温控逻辑不自动降功率;此前 17 dBm 的数据作为历史记录保留,见 [POWER.md](POWER.md)。 + +若要在发送回压时立即放弃本帧,可将 `UDP_RETRY_BUDGET_US=0` 后重编烧录。该模式仍保留三块采集内存和完整 JPEG 校验;含义是零让出重试,不是删除驱动和路由器中的所有缓冲。零重试策略已经主机测试,本次五分钟实机使用默认 8000 μs。 + +分片间隔也计入 25 ms 检查期限,不再额外延长帧的发送寿命;设置过大的间隔可能使整帧无法在期限内提交。分片在 SDK socket 中已经接受后无法由本策略撤回,底层调度和网络排队也无法由应用层强制抢占。以上为应用层新鲜度策略,不能保证每帧、无线链路或物理屏幕具有硬实时上限。 + +本地 JPEG 二进制头继续为 16 字节,旧消费者可忽略新增文本消息。新前端连接旧后端时仍可用,但没有原生发布时间,只能按 JS 收到时刻判断本地队列年龄。两端本机发布时间使用同机系统时间;这不是 ESP 与电脑的时钟同步,也不能据此计算无线单向延迟。JS 内部排队同时使用单调时钟检查。 + +## 此前 14 dBm 版本的实机五分钟检查 + +该次测试使用 14 dBm,App SHA-256:`9a7decd59572c3f280e7fd4c0a291645011b779025e56e99ea50087e92b5fa77`,应用分区 `0x60000`。以下五分钟数据属于该历史镜像,不是提高功率后的重测结果。该镜像曾经 COM8 烧录并独立三段校验,三段镜像必须成套使用。运行保留 TUN、只有一个 UDP 订阅者;串口和本地视频观察器不会产生额外 UDP 推流。 + +| 指标 | 本次结果 | +| --- | --- | +| 本地 JPEG 通道观察时长 | 300.005 s | +| 原生接收计数有效区间 | 180.937 s | +| 完整接收 / 丢帧 | 5390 / 26(仅有效区间) | +| 完整接收帧率 / 帧损失率 | 29.79 fps / 0.480%(仅有效区间) | +| 本地 JPEG 通道 | 29.69 fps | +| 本地 JPEG 通道到达间隔 P95 / 最大值 | 47.648 / 323.146 ms | +| 主动跳过旧采集帧 | 0 | +| 发送前过期帧 / 发送期限目标丢弃 | 0 / 0 | +| 提交时采集帧龄最大观测值 | 74.13 ms | +| 发送持有时间最大观测值 | 11.23 ms | +| 复位 / 采集错误 | 0 / 0 | + +观察途中界面切到队伍端,按设计隐藏诊断计数,所以原生完整帧率和损失率只报告隐藏前的有效区间,不提供完整五分钟的原生丢帧率;本地 JPEG 通道则完整观察五分钟。保留用户当前界面选择,没有强制切回裁判端。两个最大值是板端自启动起的观测值,包含预热;帧龄从驱动的首个 DMA 缓冲时间戳开始,不是光子进入传感器到屏幕的时间。主动跳过的采集帧尚未分配网络帧号,不计入接收端丢帧率;两种计数不能混为一谈。另抽取 61 张 JPEG,均完整解码为 640×480 RGB。 + +部署前的 30 秒基线仅用于检查连通性,画面内容和无线状态有变化,不能据此证明本策略使丢帧率或光学延迟降低。本次未重新测量物理端到端延迟,也未证明所有长程拥塞峰值均已解决。较早的标记回显测量保留在 [LATENCY.md](LATENCY.md)。 + +## 阻塞恢复验证 + +真实原生视频通道上的辅助接收端主动阻塞事件循环 350 ms,生产 `StreamClient` 恢复后拒绝 8 张过期帧,帧号从 1886 前进至 1918。该测试没有新增 UDP 订阅者;JPEG 解码在此辅助测试中使用替身,验证的是本地积压年龄和恢复逻辑,实际 JPEG 完整解码另行检查。 + +- 12 项 Rust 调度策略测试:绝对帧期限、旧采集帧寿命、共享重试预算、零重试及原有调度策略。 +- 4 项 C 驱动交接情景:有限次数追赶、旧缓冲仅归还一次、无有效新帧时保留已有帧、首次超时及单缓冲边界。 +- 8 项前端测试:慢解码保留最新待处理帧、500 ms 本机积压拒绝、阻塞解码结果释放、待解码帧超期和原有关闭/重启处理。 +- 10 项原生视频通道测试:时间标记先于对应 JPEG、重连不重放过期缓存及原有协议与通道行为。 + +使用 `python scripts/test-latest-frame.py` 运行驱动交接测试;`desktop/` 中运行 `pnpm test:stream`。实时通道故障测试为 `node desktop/scripts/test-live-freshness.mjs <本机私有调试日志> <结果JSON>`,日志内的通道令牌仅在程序内部读取,不应发布。完整测量与边界见制品 `REALTIME-RESULT.json`,不包含私有日志、用户配置或相机抽帧。 diff --git a/STABILITY.md b/STABILITY.md new file mode 100644 index 0000000..566977d --- /dev/null +++ b/STABILITY.md @@ -0,0 +1,135 @@ +# 彩色图传调度与持续运行验证(2026-10-18) + +本文保留此前约 22 fps 配置及更早长测的历史记录。后续约 30 fps PLL 配置、缓冲选择、运行中断线恢复与画布回显延迟测量见 [LATENCY.md](LATENCY.md);以下参数与 SHA 属于当时的测试阶段,当前交付以新报告和制品 `BUILD-INFO.json` 为准。 + +## 测试目标与配置 + +使用 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 分钟。下文保留了亮度与时序调整过程;不同轮次的场景、曝光、压缩和网络状态有变化,接收数字是描述性对照,不能作为严格单因素实验。 + +```dotenv +CAM_RES=vga +CAM_QUALITY=18 +CAM_QUALITY_MAX=30 +CAM_MAX_FPS=0 +CAM_GRAYSCALE=0 +CAM_EXPOSURE_EV=0 +CAM_SHUTTER_LINES=0 +CAM_EXPOSURE_MAX_LINES=440 +CAM_GAIN_CEILING=32 +CAM_HTS=0 +CAM_THERMAL_LIMIT_C=75 +WIFI_TX_POWER_DBM=14 +UDP_PACKET_GAP_US=0 +``` + +这些参数在编译时生效。该阶段配置的板上无网络采集探针为 22.20 fps,保留驱动默认 HTS 2060 / VTS 984 / XCLK 20 MHz;未采用颜色异常的短 HTS,也未采用此前导致采集下降至 8.1 fps 的 VTS 调整。 + +## 曝光与彩色 + +OV5640 驱动的 AE 档位不是 EV 数值:名义零档目标为 55,档位 −3 的目标为 25,目标比值约为 −1.14 EV;档位 −4 的目标为 15,约为 −1.87 EV。固件的 `CAM_EXPOSURE_EV=-1/-2` 使用这两个近似档位;这不是经过照度标定的绝对 EV。 + +早期仅降低 AE 目标后,实际曝光仍为 885 行;固定 220 行约为 10 ms,但用户反馈太暗,固定约 20 ms 也仍然太暗,均已撤回。其他时钟或分辨率下,行数对应的时间不同,不能直接套用行数。 + +该阶段采用正常亮度目标、自动 AEC / AGC、440 行曝光上限,关闭夜景延帧,并按现有 50/60 Hz band step 同步约束最大 band 数。自动增益上限由原来的 15.5× 扩至 31.94×,给短曝光补偿空间;增益会放大噪点。预检实际曝光为 295 行,按 SYSCLK 45 MHz / HTS 2060 估算约 13.5 ms,约为早期 885 行曝光的三分之一。440 行上限约 20.1 ms,实际值由场景与频闪步长决定。 + +用户已确认新配置**亮度够用、颜色稳定**。它仍使用传感器硬件彩色 JPEG,不通过降低色深来提帧。 + +彩色 JPEG 抽帧已解码为 640×480 RGB,仍使用传感器硬件 JPEG 编码。没有用灰度换取本轮帧率。曝光行数日志用于验证快门配置,未进行运动靶拖影或照度标定测量。驱动依据:[Espressif OV5640 源码](https://github.com/espressif/esp32-camera/blob/master/sensors/ov5640.c)。 + +## 最终配置的五分钟验证 + +本轮测量从 `2026-10-18T12:24:10.381357+00:00` 到 `2026-10-18T12:29:10.384000+00:00`,持续 300.004 秒。下表对照前一轮固定约 20 ms、quality 18 的完整五分钟记录;前一轮用户确认过暗,本轮用户确认亮度够用、颜色稳定。 + +| 指标 | 固定快门对照 | 自动曝光上限 + 压缩调节 | +| --- | --- | --- | +| 完整接收帧 | 5220 | 6520 | +| 丢帧 | 1430(21.50%) | 34(0.52%) | +| 完整接收帧率 | 17.40 fps | 21.73 fps | +| 本地 JPEG 通道帧率 | 13.45 fps | 20.99 fps | +| 通道帧间隔 P95 | 277.473 ms | 102.841 ms | +| 通道帧间隔最大值 | 1163.792 ms | 419.825 ms | +| 平均 JPEG 大小 | 12099 字节 | 16346 字节 | + +本轮全程在线,无复位、采集错误、本地通道断开、计数重置或诊断计数缺失。视频包长度与首尾标记错误为 0;另抽取 148 张 JPEG 完整解码,均为 640×480 RGB。 + +测量窗口内曝光为 295–295 行;JPEG 档位为 18–22,实际增益 11.00–18.00×。内部堆最小 129455 字节,平均提交耗时 2.60 ms/帧,温度 54–59°C,未触发温度减载。 + +这次完整接收帧率接近当前可靠彩色时序的约 22.2 fps 上限。亮度、画面内容、JPEG 大小和网络负载在各轮之间变化,不能把全部差异归因于单一控制参数,也不能将五分钟结果作为长期或所有路由器的保证。帧间隔不是摄像头到屏幕的光学延迟。 + +本轮固件 SHA-256 为 `e297069e190dcccefbae6d95591703c6848f4e523f910e2c0cb44af91f698936`,当时与交付及板上镜像一致;最新交付见 LATENCY.md。配套 Bootloader、分区表及 App 均已独立读回校验。曲线和完整数据位于制品包内的 `STABILITY.png` / `SCHEDULING-RESULT.json`,不包含相机抽帧或令牌日志。 + +## 较早的五分钟彩色基线 + +以下为较早的完整 300.002 秒测量,HTS 保持驱动默认 2060,快门 220 行,UDP 分片间隔为 0。真实 Windows 客户端接收,非演示模式,单 UDP 订阅者。该场景 JPEG 约 9 KB,用户随后确认这一曝光方案过暗,因此不再作为最终交付配置。 + +| 指标 | 结果 | +| --- | --- | +| 完整接收帧 | 6647 | +| 丢帧 | 12,0.1802% | +| 完整接收帧率 | 22.1565 fps | +| 本地 JPEG 通道帧率 | 20.8365 fps,6251 帧 | +| 接收通道帧间隔 P95 / 最大值 | 102.459 / 408.082 ms | +| 平均 JPEG 大小 | 9113 字节 | +| 实际曝光 | 全程 220 行 | +| 采集错误 / 复位 / 本地连接断开 | 0 / 0 / 0 | + +图传完整帧率已接近该采集配置的约 22.2 fps 上限;本地通道采用 latest 模式,突发到达的多帧可能被合并,不能用其帧数代表屏幕已绘制帧数。不同场景下 JPEG 大小和无线负载会变化。 + +## 间隔发送的中止轮次 + +1000 μs 最小分片间隔轮次在设备运行约 235 秒时出现串口 I/O 错误,随后系统不再枚举开发板串口,摄像头同时离线。观察器提前停止,未完成有效的五分钟对照,断连尾段不能归因于调度。 + +断连前 3 个完整在线窗口合计 181.192 秒,接收 21.58 fps、帧损失 1.96%,本地通道 17.25 fps,没有显示出逐包间隔的收益。它可能影响 AMPDU 聚合效率,但未抓取空口包,不能确认唯一原因。保留突发发送作为已验证基线,不采用这轮的完整汇总作性能结论。 + +## 已撤回的行时序提帧试验 + +新增可选 `CAM_HTS`,仅改变 OV5640 水平总时长,保留有效读出窗口及消隐余量,PLL、XCLK、VTS 不变。候选参数为 HTS 1500、快门 300 行,估算保持约 10 ms 曝光,采集节拍目标约 30.5 fps。 + +USB 恢复后,HTS 1500 的板上采集探针达到 30.49 fps,但白光环境出现明显绿紫闪烁,用户确认不可用。HTS 1800 的探针为 25.41 fps,仍出现明显偏紫,也已中止;两者均不作为有效五分钟彩色结果或交付配置。恢复默认 HTS 2060 后,用户确认颜色稳定。 + +1500 轮次开始时队伍 release 模式隐藏接收计数,因此该轮汇总中的接收 fps / 丢帧为无效值,不能当作零丢帧;观察脚本现已要求裁判诊断计数并记录诊断可用性。`0x380C/0x380D` 的水平总长定义见 [OV5640 数据手册](https://files.waveshare.com/upload/d/da/OV5640_DataSheet.pdf)。几何检查和 JPEG 成功解码都不足以证明彩色稳定。 + +## 室内亮度回归 + +用户确认 −1 EV / 固定 10 ms,以及恢复名义 0 EV / 固定约 20 ms 后仍然太暗。因此最终方案不再固定短快门,而是让 AEC / AGC 自动联动,并以曝光上限控制拖影。手册说明 AGC 通常在 AEC 到达上限后继续增加;仅保留 AGC 开关不能当作亮度已经补足的证据。需要结合实际曝光、增益和画面验证。 + +固定 20 ms 的完整五分钟轮次接收 5220 帧、丢帧 1430(21.50%),接收 17.40 fps,本地 JPEG 通道 13.45 fps,接收间隔 P95 277.473 ms、最大 1163.792 ms;全程在线、无采集错误。它保留为拥塞对照,未被选为最终配置。 + +## 发送调度 + +当前启用有界 JPEG 压缩调节,`CAM_QUALITY=18` 为画质恢复目标,`CAM_QUALITY_MAX=30` 为压缩上限。每 5 秒检查本机发送窗口:对端帧放弃比例达到 2%,或平均提交耗时达到 8 ms 时,压缩数字增加 3 档;连续 3 个无放弃且提交小于 4 ms 的窗口后,才降低 1 档恢复细节。无订阅或流量不足时不会当作健康窗口。 + +该反馈来自板端提交回压,不包含接收端 ACK;不能据此声称消除了路由器后续丢包。调节不改变彩色、分辨率或采集时钟,运行日志记录实际 JPEG 档位、曝光行数和增益。 + +视频数据报使用 WMM AC_VI、保留 AMPDU。`UDP_PACKET_GAP_US` 控制成功提交数据报的最小间隔,调度 tick 可能使实际等待更长。等待期间让出 CPU,避免在帧开始时集中向 Wi-Fi 队列灌入全部分片。 + +每帧预算为 25 ms 加计划分片间隔,总等待上限 40 ms;没有调度间隔时仍为 25 ms。超过预算则放弃该对端的剩余旧帧,不追赶补发。分片数量较多时需要重新选择间隔,不能把某个 VGA 配置的结果直接套到更大图像。 + +`submitted` 只表示分片全部交给了网络,不代表无线确认或屏幕显示。帧损失包含接收端未完成帧与帧号缺口;设置帧率上限后,latest 模式有意覆盖的旧采集帧不进入 UDP 帧编号,不计入该丢帧率。 + +## 此前中止的灰度长测 + +原计划 30 分钟的灰度 / 15 fps 长测按用户要求提前停止,保留了 **18 个完整分钟窗口**,合计 1080.15 秒:完整帧 15307、丢帧 806,平均 14.17 fps,帧损失 5.00%。板端记录温度约 48–58°C,无复位和采集错误。 + +早期丢帧上升时温度没有同步上升,温度下降期间也出现过明显发送失败。内存紧张后能够恢复;发送失败与队列回压更符合当前证据。这一轮不计为完成 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)。 + +## 复测与记录 + +退出其他图传客户端,使用一个非演示模式的 Windows 裁判端。release 队伍模式隐藏技术计数,不能用于本脚本的接收 fps / 丢帧测量;用户在本机切换身份后可继续使用原进程,本次没有改写用户配置。`RUST_LOG=cam_desktop=debug,cam_core=info` 会记录本地 WebSocket 地址及令牌,原始日志仅供本机诊断,不应公开。 + +从仓库根目录分别运行,串口及设备 ID 换成实际值: + +```text +python scripts/serial_soak.py --port COM7 --seconds 330 --out target/soak/device --stop-file target/soak/stop +node desktop/scripts/soak-stream.mjs --log target/soak/referee-stderr.log --out target/soak/client --seconds 300 --warmup 10 --stop-file target/soak/stop --sample-every 5 +``` + +串口脚本依赖 `pyserial`;观察脚本使用 Node.js 22 内置 WebSocket。默认设备 ID 为 `0x3D4D8A9A`,换板用 `--camera 0x设备ID`。创建 stop 文件可提前结束并保存部分窗口;新一轮使用不存在的 stop 文件路径。 + +串口每 5 秒保存温度、曝光行数、增益、JPEG 档位、内部堆、采集错误、发送重试、订阅数与帧率。客户端每分钟保存完整帧率、帧损失、JPEG 通道帧率、间隔 P95 / 最大值与连接状态,并标记离线、诊断不可用与末尾无视频时长。`--sample-every` 可选,单位为秒,抽帧仅留本机。JSON 标记实际时长与是否提前停止,不能将部分窗口称为完成 5 分钟。原始记录位于本机 `target/soak/`,随构建缓存忽略。 + +JPEG 长度与首尾标记校验不等于每张图的完整解码验证;本地 WebSocket 通道不代表 WebView 最终绘制。接收间隔与分片组装耗时均不是摄像头到屏幕的光学延迟。 diff --git a/components/cam_wrapper/CMakeLists.txt b/components/cam_wrapper/CMakeLists.txt index 5abe4ba..c49f4e3 100644 --- a/components/cam_wrapper/CMakeLists.txt +++ b/components/cam_wrapper/CMakeLists.txt @@ -2,4 +2,4 @@ idf_component_register(SRCS "cam_wrapper.c" INCLUDE_DIRS "include" # esp_timer for the frame-rate probe; the camera component # pulls in the LCD_CAM driver and the FreeRTOS task API. - REQUIRES esp32-camera esp_timer) + REQUIRES esp32-camera esp_timer esp_driver_tsens) diff --git a/components/cam_wrapper/cam_wrapper.c b/components/cam_wrapper/cam_wrapper.c index f47931c..68e8eed 100644 --- a/components/cam_wrapper/cam_wrapper.c +++ b/components/cam_wrapper/cam_wrapper.c @@ -1,18 +1,20 @@ /* Implementation of the cam_wrapper shim around esp32-camera. See cam_wrapper.h. */ #include "cam_wrapper.h" +#include "driver/temperature_sensor.h" #include #include "esp_log.h" #include "esp_timer.h" -static void cam_wrapper_measure_fps(cam_wrapper_sensor_t sensor); +static float cam_wrapper_measure_fps(cam_wrapper_sensor_t sensor); static const char *TAG = "cam_wrapper"; static sensor_t *s_sensor; static char s_sensor_name[16] = "unknown"; +static temperature_sensor_handle_t s_temperature; static void fill_config(const cam_wrapper_config_t *in, camera_config_t *out) { @@ -134,7 +136,7 @@ esp_err_t cam_wrapper_init(const cam_wrapper_config_t *config, cam_wrapper_senso * two is dropping frames. */ #define PROBE_FRAMES 20 -static void cam_wrapper_measure_fps(cam_wrapper_sensor_t sensor) +static float cam_wrapper_measure_fps(cam_wrapper_sensor_t sensor) { uint32_t total = 0; int64_t start = esp_timer_get_time(); @@ -158,7 +160,7 @@ static void cam_wrapper_measure_fps(cam_wrapper_sensor_t sensor) if (taken == 0 || elapsed_us <= 0) { ESP_LOGW(TAG, "fps probe: got no frames"); - return; + return 0; } double fps = (double)taken * 1000000.0 / (double)elapsed_us; @@ -167,6 +169,159 @@ static void cam_wrapper_measure_fps(cam_wrapper_sensor_t sensor) "(sensor ceiling, no network involved)", taken, (long long)(elapsed_us / 1000), fps, (unsigned)(total / (uint32_t)taken)); + return (float)fps; +} + +esp_err_t cam_wrapper_set_grayscale(int enabled) +{ + if (!s_sensor || !s_sensor->set_special_effect) return ESP_ERR_NOT_SUPPORTED; + int rc = s_sensor->set_special_effect(s_sensor, enabled ? 2 : 0); + if (rc != 0) return ESP_FAIL; + ESP_LOGI(TAG, "sensor JPEG colour: %s", enabled ? "grayscale" : "colour"); + return ESP_OK; +} + +esp_err_t cam_wrapper_set_quality(unsigned quality) +{ + if (quality > 63) return ESP_ERR_INVALID_ARG; + if (!s_sensor || !s_sensor->set_quality) return ESP_ERR_NOT_SUPPORTED; + return s_sensor->set_quality(s_sensor, (int)quality) == 0 ? ESP_OK : ESP_FAIL; +} + +esp_err_t cam_wrapper_set_exposure_bias(int ev) +{ + if (ev < -2 || ev > 0) return ESP_ERR_INVALID_ARG; + if (!s_sensor || s_sensor->id.PID != OV5640_PID) + return ev == 0 ? ESP_OK : ESP_ERR_NOT_SUPPORTED; + /* Driver target = (level+5)*10+5, default 55. Targets 25 and 15 + * are ~-1.14 and ~-1.87 EV, not literal driver levels -1 and -2. */ + int level = ev == -1 ? -3 : ev == -2 ? -4 : 0; + if (s_sensor->set_exposure_ctrl(s_sensor, 1) != 0 + || s_sensor->set_gain_ctrl(s_sensor, 1) != 0 + || s_sensor->set_ae_level(s_sensor, level) != 0) return ESP_FAIL; + ESP_LOGI(TAG, "OV5640 auto exposure bias: requested %d EV, driver level %d, target %d / 55", + ev, level, (level + 5) * 10 + 5); + return ESP_OK; +} + +int cam_wrapper_exposure_lines(void) +{ + if (!s_sensor || s_sensor->id.PID != OV5640_PID) return -1; + int raw = s_sensor->get_reg(s_sensor, 0x3500, 0xfffff); + return raw < 0 ? -1 : raw >> 4; +} + +int cam_wrapper_gain_raw(void) +{ + if (!s_sensor || s_sensor->id.PID != OV5640_PID) return -1; + return s_sensor->get_reg(s_sensor, 0x350a, 0x03ff); +} + +esp_err_t cam_wrapper_set_shutter_lines(unsigned lines) +{ + if (!s_sensor || s_sensor->id.PID != OV5640_PID) + return lines == 0 ? ESP_OK : ESP_ERR_NOT_SUPPORTED; + int vts = s_sensor->get_reg(s_sensor, 0x380e, 0xffff); + if (vts <= 0 || lines > (unsigned)vts) return ESP_ERR_INVALID_ARG; + int previous = cam_wrapper_exposure_lines(); + if (s_sensor->set_exposure_ctrl(s_sensor, lines == 0) != 0 + || s_sensor->set_gain_ctrl(s_sensor, 1) != 0) return ESP_FAIL; + if (lines && (s_sensor->set_aec_value(s_sensor, (int)lines) != 0 + || cam_wrapper_exposure_lines() != (int)lines)) return ESP_FAIL; + ESP_LOGI(TAG, "OV5640 shutter: previous %d lines, requested %u lines (0=auto), AGC enabled", + previous, lines); + return ESP_OK; +} + +esp_err_t cam_wrapper_set_horizontal_timing(unsigned hts) +{ + if (!hts) return ESP_OK; + if (!s_sensor || s_sensor->id.PID != OV5640_PID) return ESP_ERR_NOT_SUPPORTED; + int start = s_sensor->get_reg(s_sensor, 0x3800, 0x0fff); + int end = s_sensor->get_reg(s_sensor, 0x3804, 0x0fff); + int previous = s_sensor->get_reg(s_sensor, 0x380c, 0x0fff); + if (start < 0 || end < start || previous <= 0) return ESP_FAIL; + unsigned active = (unsigned)(end - start + 1) / (s_sensor->status.binning ? 2 : 1); + /* Preserve the active readout window and a conservative blanking margin. + * This is a guard against invalid geometry, not a stability guarantee. */ + if ((hts & 1) || hts < active + 64 || hts > 4095) return ESP_ERR_INVALID_ARG; + /* Use the byte order that cannot transiently shorten the line below + * either valid endpoint while updating this live sensor. */ + int rc; + if (hts < (unsigned)previous) { + rc = s_sensor->set_reg(s_sensor, 0x380d, 0xff, hts & 0xff) + || s_sensor->set_reg(s_sensor, 0x380c, 0x0f, hts >> 8); + } else { + rc = s_sensor->set_reg(s_sensor, 0x380c, 0x0f, hts >> 8) + || s_sensor->set_reg(s_sensor, 0x380d, 0xff, hts & 0xff); + } + if (rc || s_sensor->get_reg(s_sensor, 0x380c, 0x0fff) != (int)hts) return ESP_FAIL; + ESP_LOGI(TAG, "OV5640 horizontal timing: HTS %d -> %u, active %u, VTS %d (PLL/XCLK unchanged)", + previous, hts, active, s_sensor->get_reg(s_sensor, 0x380e, 0xffff)); + return ESP_OK; +} + +esp_err_t cam_wrapper_set_exposure_limit(unsigned lines) +{ + if (!lines) return ESP_OK; + if (!s_sensor || s_sensor->id.PID != OV5640_PID) return ESP_ERR_NOT_SUPPORTED; + int vts = s_sensor->get_reg(s_sensor, 0x380e, 0xffff); + int band50 = s_sensor->get_reg(s_sensor, 0x3a08, 0x03ff); + int band60 = s_sensor->get_reg(s_sensor, 0x3a0a, 0x03ff); + if (vts <= 4 || lines > (unsigned)(vts - 4) || band50 <= 0 || band60 <= 0) + return ESP_ERR_INVALID_ARG; + unsigned max50 = lines / (unsigned)band50; + unsigned max60 = lines / (unsigned)band60; + if (!max50) max50 = 1; + if (!max60) max60 = 1; + if (max50 > 63 || max60 > 63) return ESP_ERR_INVALID_ARG; + int rc = s_sensor->set_exposure_ctrl(s_sensor, 0) + || s_sensor->set_aec2(s_sensor, 0) + || s_sensor->set_reg(s_sensor, 0x3a02, 0xff, lines >> 8) + || s_sensor->set_reg(s_sensor, 0x3a03, 0xff, lines & 0xff) + || s_sensor->set_reg(s_sensor, 0x3a14, 0x0f, lines >> 8) + || s_sensor->set_reg(s_sensor, 0x3a15, 0xff, lines & 0xff) + || s_sensor->set_reg(s_sensor, 0x3a0d, 0x3f, max60) + || s_sensor->set_reg(s_sensor, 0x3a0e, 0x3f, max50) + || s_sensor->set_gain_ctrl(s_sensor, 1) + || s_sensor->set_exposure_ctrl(s_sensor, 1); + if (rc || s_sensor->get_reg(s_sensor, 0x3a02, 0xffff) != (int)lines + || s_sensor->get_reg(s_sensor, 0x3a14, 0x0fff) != (int)lines) + return ESP_FAIL; + ESP_LOGI(TAG, "OV5640 auto exposure cap: %u lines, band maxima 50Hz=%u / 60Hz=%u, AEC/AGC auto, night mode off", + lines, max50, max60); + return ESP_OK; +} + +float cam_wrapper_probe_frame_rate(void) +{ + /* Drain the old three-buffer pipeline after changing the colour effect. */ + for (int i = 0; i < 4; ++i) { + cam_wrapper_fb_t fb; + if (cam_wrapper_fb_get(&fb, CAM_WRAPPER_SENSOR_AUTO) == ESP_OK) + cam_wrapper_fb_release(&fb); + } + return cam_wrapper_measure_fps(CAM_WRAPPER_SENSOR_AUTO); +} + +esp_err_t cam_wrapper_temperature_init(void) +{ + if (s_temperature) return ESP_OK; + temperature_sensor_config_t config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(20, 100); + esp_err_t rc = temperature_sensor_install(&config, &s_temperature); + if (rc != ESP_OK) return rc; + rc = temperature_sensor_enable(s_temperature); + if (rc != ESP_OK) { + temperature_sensor_uninstall(s_temperature); + s_temperature = NULL; + } + return rc; +} + +esp_err_t cam_wrapper_temperature_read(float *celsius) +{ + if (!s_temperature) return ESP_ERR_INVALID_STATE; + return temperature_sensor_get_celsius(s_temperature, celsius); } esp_err_t cam_wrapper_fb_get(cam_wrapper_fb_t *out, cam_wrapper_sensor_t sensor) @@ -190,10 +345,29 @@ esp_err_t cam_wrapper_fb_get(cam_wrapper_fb_t *out, cam_wrapper_sensor_t sensor) out->height = fb->height; out->format = (int)fb->format; out->handle = fb; + out->capture_started_us = (int64_t)fb->timestamp.tv_sec * 1000000 + fb->timestamp.tv_usec; return ESP_OK; } +esp_err_t cam_wrapper_fb_get_latest(cam_wrapper_fb_t *out, uint32_t timeout_ms) +{ + if (!out) return ESP_ERR_INVALID_ARG; + memset(out, 0, sizeof(*out)); + uint32_t skipped = 0; + camera_fb_t *fb = esp_camera_fb_get_latest(timeout_ms, &skipped); + if (!fb) return ESP_FAIL; + out->data = fb->buf; + out->len = fb->len; + out->width = fb->width; + out->height = fb->height; + out->format = (int)fb->format; + out->handle = fb; + out->capture_started_us = (int64_t)fb->timestamp.tv_sec * 1000000 + fb->timestamp.tv_usec; + out->skipped_before = skipped; + return ESP_OK; +} + void cam_wrapper_fb_release(cam_wrapper_fb_t *fb) { if (fb == NULL || fb->handle == NULL) { @@ -233,4 +407,4 @@ esp_err_t cam_wrapper_deinit(void) const char *cam_wrapper_sensor_name(void) { return s_sensor_name; -} \ No newline at end of file +} diff --git a/components/cam_wrapper/include/cam_wrapper.h b/components/cam_wrapper/include/cam_wrapper.h index 7229148..8631dd4 100644 --- a/components/cam_wrapper/include/cam_wrapper.h +++ b/components/cam_wrapper/include/cam_wrapper.h @@ -112,6 +112,8 @@ typedef struct { size_t height; int format; void *handle; /* internal driver frame; do not touch */ + int64_t capture_started_us; /* same monotonic clock as esp_timer_get_time */ + uint32_t skipped_before; /* older completed frames released by latest get */ } cam_wrapper_fb_t; esp_err_t cam_wrapper_init(const cam_wrapper_config_t *config, cam_wrapper_sensor_t sensor); @@ -119,6 +121,7 @@ esp_err_t cam_wrapper_init(const cam_wrapper_config_t *config, cam_wrapper_senso /* Acquire one frame into `out`. Returns ESP_OK on success, ESP_FAIL if the * driver could not hand out a frame (e.g. timeout or buffer starvation). */ esp_err_t cam_wrapper_fb_get(cam_wrapper_fb_t *out, cam_wrapper_sensor_t sensor); +esp_err_t cam_wrapper_fb_get_latest(cam_wrapper_fb_t *out, uint32_t timeout_ms); void cam_wrapper_fb_release(cam_wrapper_fb_t *fb); @@ -129,6 +132,26 @@ esp_err_t cam_wrapper_deinit(void); /* Name of the sensor the driver detected, or "unknown". Never NULL. */ const char *cam_wrapper_sensor_name(void); +/* Keep the sensor's JPEG encoder; effect 2 removes chroma in the sensor ISP. */ +esp_err_t cam_wrapper_set_grayscale(int enabled); +/* Runtime JPEG compression, 0..63, without restarting the camera pipeline. */ +esp_err_t cam_wrapper_set_quality(unsigned quality); +/* OV5640 target bias: 0 = default, -1 / -2 select approximately -1.1 / -1.9 EV. */ +esp_err_t cam_wrapper_set_exposure_bias(int ev); +/* 0 = auto shutter; nonzero holds that exposure in sensor lines, AGC stays on. */ +esp_err_t cam_wrapper_set_shutter_lines(unsigned lines); +/* Keep AEC/AGC automatic, cap exposure lines, and disable night frame extension. */ +esp_err_t cam_wrapper_set_exposure_limit(unsigned lines); +/* 0 keeps the driver timing; nonzero changes OV5640 horizontal total only. */ +esp_err_t cam_wrapper_set_horizontal_timing(unsigned hts); +int cam_wrapper_exposure_lines(void); +/* OV5640 gain in 1/16 units; -1 when unavailable. */ +int cam_wrapper_gain_raw(void); +float cam_wrapper_probe_frame_rate(void); +/* Silicon temperature, not ambient or camera-sensor temperature. */ +esp_err_t cam_wrapper_temperature_init(void); +esp_err_t cam_wrapper_temperature_read(float *celsius); + #ifdef __cplusplus } -#endif \ No newline at end of file +#endif diff --git a/components/esp32-camera/driver/cam_hal.c b/components/esp32-camera/driver/cam_hal.c index 4f7e9fc..580aaa5 100644 --- a/components/esp32-camera/driver/cam_hal.c +++ b/components/esp32-camera/driver/cam_hal.c @@ -733,13 +733,14 @@ camera_fb_t *cam_take(TickType_t timeout) for (;;) { TickType_t elapsed = xTaskGetTickCount() - start; /* TickType_t is unsigned so rollover is safe */ - if (elapsed >= timeout) { + if (timeout && elapsed >= timeout) { ESP_LOGW(TAG, "Failed to get frame: timeout"); return NULL; } - TickType_t remaining = timeout - elapsed; + TickType_t remaining = timeout ? timeout - elapsed : 0; if (xQueueReceive(cam_obj->frame_buffer_queue, (void *)&dma_buffer, remaining) == pdFALSE) { + if (!timeout) return NULL; continue; } @@ -750,6 +751,7 @@ camera_fb_t *cam_take(TickType_t timeout) if (dma_reset_counter < MAX_GDMA_RESETS) { ll_cam_dma_reset(cam_obj); dma_reset_counter++; + if (!timeout) return NULL; continue; /* retry with queue timeout */ } if (dma_reset_counter == MAX_GDMA_RESETS) { @@ -762,6 +764,7 @@ camera_fb_t *cam_take(TickType_t timeout) CAM_WARN_THROTTLE(warn_null_cnt, "Unexpected NULL frame on " CONFIG_IDF_TARGET); #endif + if (!timeout) return NULL; vTaskDelay(1); /* immediate yield once resets are done */ continue; /* go to top of loop */ } @@ -802,6 +805,7 @@ skip_eoi_check: CAM_WARN_THROTTLE(warn_eoi_miss_cnt, "NO-EOI - JPEG end marker missing"); cam_give(dma_buffer); + if (!timeout) return NULL; continue; /* wait for another frame */ } else if (cam_obj->psram_mode && cam_obj->in_bytes_per_pixel != cam_obj->fb_bytes_per_pixel) { @@ -818,6 +822,24 @@ skip_eoi_check: } } +camera_fb_t *cam_take_latest(TickType_t timeout, uint32_t *skipped) +{ + if (skipped) *skipped = 0; + camera_fb_t *latest = cam_take(timeout); + if (!latest) return NULL; + /* A bounded drain, not availability-check + blocking get: the producer can + * replace queued buffers on the other core. Retain the valid frame if a + * newer candidate is invalid or a GDMA-reset sentinel is encountered. */ + for (size_t i = 1; i < cam_obj->frame_cnt; i++) { + camera_fb_t *next = cam_take(0); + if (!next) break; + cam_give(latest); + latest = next; + if (skipped) (*skipped)++; + } + return latest; +} + void cam_give(camera_fb_t *dma_buffer) { for (int x = 0; x < cam_obj->frame_cnt; x++) { diff --git a/components/esp32-camera/driver/esp_camera.c b/components/esp32-camera/driver/esp_camera.c index b6e3bbe..f0b499c 100644 --- a/components/esp32-camera/driver/esp_camera.c +++ b/components/esp32-camera/driver/esp_camera.c @@ -401,6 +401,19 @@ camera_fb_t *esp_camera_fb_get() return fb; } +camera_fb_t *esp_camera_fb_get_latest(uint32_t timeout_ms, uint32_t *skipped) +{ + if (skipped) *skipped = 0; + if (s_state == NULL) return NULL; + camera_fb_t *fb = cam_take_latest(pdMS_TO_TICKS(timeout_ms), skipped); + if (fb) { + fb->width = resolution[s_state->sensor.status.framesize].width; + fb->height = resolution[s_state->sensor.status.framesize].height; + fb->format = s_state->sensor.pixformat; + } + return fb; +} + void esp_camera_fb_return(camera_fb_t *fb) { if (s_state == NULL) { diff --git a/components/esp32-camera/driver/include/esp_camera.h b/components/esp32-camera/driver/include/esp_camera.h index fa2f24c..5de6b98 100644 --- a/components/esp32-camera/driver/include/esp_camera.h +++ b/components/esp32-camera/driver/include/esp_camera.h @@ -206,6 +206,9 @@ esp_err_t esp_camera_deinit(void); * @return pointer to the frame buffer */ camera_fb_t* esp_camera_fb_get(void); +/* At most timeout_ms waiting for the first frame; pending frames are drained + * nonblocking, keeping the newest valid JPEG. skipped may be NULL. */ +camera_fb_t* esp_camera_fb_get_latest(uint32_t timeout_ms, uint32_t *skipped); /** * @brief Return the frame buffer to be reused again. diff --git a/components/esp32-camera/driver/private_include/cam_hal.h b/components/esp32-camera/driver/private_include/cam_hal.h index e89ad57..97b338e 100644 --- a/components/esp32-camera/driver/private_include/cam_hal.h +++ b/components/esp32-camera/driver/private_include/cam_hal.h @@ -52,6 +52,8 @@ void cam_stop(void); void cam_start(void); camera_fb_t *cam_take(TickType_t timeout); +/* Hold only the newest validated frame, returning superseded buffers. */ +camera_fb_t *cam_take_latest(TickType_t timeout, uint32_t *skipped); void cam_give(camera_fb_t *dma_buffer); diff --git a/desktop/README.md b/desktop/README.md index 3de1d40..e7a9f79 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -12,8 +12,8 @@ ```text desktop/ - core/ cam-core:不含任何界面与 Tauri 的东西(80 个测试) - src-tauri/ Tauri 后端:推流、命令、录制(30 个测试) + core/ cam-core:不含任何界面与 Tauri 的东西(83 个测试) + src-tauri/ Tauri 后端:推流、命令、录制(32 个测试) src/ React 前端:两套页面 + HUD src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化 src/components/ 视频窗格、血条、胜负横幅、瞄准器、装甲板表、队名编辑、录制面板、设置抽屉 @@ -27,16 +27,37 @@ cd desktop pnpm install pnpm app # = tauri dev:起 vite + 编 Rust + 开窗口 pnpm build # 只构建前端(tsc --noEmit && vite build) -pnpm tauri build # 出 .app 与 .dmg +pnpm tauri build # 默认打包 macOS 的 .app 与 .dmg;Windows 见下文 ``` 下面两条是 Rust 侧,用它验证不依赖窗口的部分: ```sh -cd desktop/core && cargo test # 80 -cd desktop/src-tauri && cargo test # 30,其中几条真的会起 ffmpeg +cd desktop/core && cargo test # 83 +cd desktop/src-tauri && cargo test # 32,其中几条真的会起 ffmpeg ``` +### Windows x64 构建 + +需要 Node.js 22.12+(或 24)、pnpm 12、Rust stable MSVC,以及 Visual Studio Build Tools 的 C++ 桌面开发组件和 Windows SDK。应用窗口使用 WebView2 Runtime。 + +在 `desktop/` 中执行: + +```powershell +pnpm install --frozen-lockfile +pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --ci -- --locked --config 'unstable.build-std=[]' +``` + +`--target` 覆盖本目录原来的 macOS target,`--bundles nsis` 选择 Windows 安装包;末尾的 Cargo 参数清空从根固件项目继承的 `build-std`。 + +- 程序:`src-tauri/target/x86_64-pc-windows-msvc/release/cam-desktop.exe`。 +- 安装包:同一 release 目录下的 `bundle/nsis/*-setup.exe`。 +- 核心测试:进入 `core/` 后运行 `cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'`。 + +视频录制另需可从 `PATH` 找到的 `ffmpeg`,也可以在配置的 `[recording] ffmpeg` 中指定路径;基础图传显示不依赖它。 + +设备发现使用板卡的 Hello 广播;客户端除广播订阅外,还会按已发现设备的 IP 定向续订。Windows 开启 VPN / 虚拟网卡时,全局广播可能走虚拟接口;定向续订可避免“能发现并绑定,但始终等待画面”的情况。队伍端的绑定和画面过滤仍然生效。 + ### 演示模式:没有板子也能把整条链路跑起来 ```sh @@ -410,11 +431,13 @@ open 'http://127.0.0.1:1420/?preview=referee' # 裁判端(密码 1234) ## 测试 ```sh -cd desktop/core && cargo test # 80:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定 -cd desktop/src-tauri && cargo test # 30:帧头布局、录制、截图、演示画面、命令行 -cd protocol && cargo test # 15 +cd desktop/core && cargo test # 83:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定 +cd desktop/src-tauri && cargo test # 32:帧头布局、录制、截图、演示画面、命令行 +cd protocol && cargo test # 20 ``` +前端解码队列测试:在 `desktop/` 运行 `pnpm test:stream`,5 项测试覆盖慢解码时保留最新帧、多路独立处理、关闭清理、坏帧恢复与设备重启。它不替代实际 WebView 的画面验证。 + 三条值得单独说的: - **录制**那条真的起 ffmpeg、喂 20 帧、停,然后用 `ffprobe` 读回帧数与分辨率。只断言"文件 @@ -444,8 +467,11 @@ RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:' | `demo-*`(仅演示) | 合成协议数据报 | 走同一条真实路径 | | tokio 任务 | WebSocket 服务、5 Hz 快照 | 异步 IO | -跨线程只有两样东西:`Arc` 与广播通道。帧在接收线程里编码一次(16 字节头 + JPEG), -所有界面共用同一份 `Bytes`。 +帧在接收线程里编码一次(16 字节头 + JPEG),所有界面共用同一份 `Bytes`。 +视频使用每路只保留最新帧的 watch 通道;状态与控制消息使用有序广播通道。 +前端每路最多保留一次正在进行的解码和一张最新待解码帧。 + +每张本地 JPEG 前增加 `frameTiming` 文本消息,包含同机后端发布时刻;JPEG 二进制头仍为 16 字节。前端在解码前后检查本机队列年龄,超过 100 ms 的待解码帧或解码结果直接释放,恢复后优先显示最新画面。后端不重放超过 100 ms 的缓存帧,本地视频 socket 写入超时后重连。旧版没有时间标记时仍支持 JPEG,但只能按 JS 收到时刻判断本地年龄。这些时间不能用于测量 ESP 到电脑的无线传输延迟。 ## 已知限制与没做的 @@ -459,11 +485,20 @@ RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:' `ws://127.0.0.1:*`(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端" ——见下面"测试"一节最后一条。 - **证书与公证**没做,`.dmg` 只在本机可用。 -- 前端没有自动化测试。界面这一侧的验证手段是浏览器预览 + 真机运行;逻辑都在 `core/` 里 - 有测试。 +- 前端已有解码队列测试;布局、实际 WebView 解码和画面显示仍需浏览器预览及真机运行验证。 ## 排查 +需要测量相机到客户端的整条链路时,使用裁判配置启动校准模式: + +```powershell +.\cam-desktop.exe --config .\referee.toml --latency-report .\latency-run.ndjson +``` + +报告路径必须是新文件。让相机完整拍到左侧四个彩色角块,避开右侧回显,固定相机和窗口位置。标记每 50 ms 更新;程序沿正常接收、解码和 `VideoSurface` 绘制路径记录每个标记的首次有效回显,拒绝曝光混合而无法可靠解码的帧。NDJSON 包含相邻 rAF 夹定的时间、解码完成到画布绘制时间和标记分析耗时,不保存图像或配置。面板扫描和像素响应未测量,精确物理光学延迟需用高速相机复核。正常启动不运行标记识别;校准模式不会额外订阅一份 UDP 视频。 + +`pnpm test:latency` 验证标记边界、旋转/镜像、混合曝光拒绝及屏幕原色泛白情形。 + | 现象 | 原因 | |---|---| | 起不来,提示绑不上 UDP 5600 | 同一端口上已经跑着另一个客户端(旧的 Slint 版或另一份本程序)。看 `~/.config/cam-desktop/startup-error.txt` | diff --git a/desktop/core/Cargo.lock b/desktop/core/Cargo.lock index ba02378..b77163d 100644 --- a/desktop/core/Cargo.lock +++ b/desktop/core/Cargo.lock @@ -55,6 +55,7 @@ dependencies = [ "rand", "serde", "serde_json", + "socket2", "toml", ] @@ -297,6 +298,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "subtle" version = "2.6.1" @@ -391,6 +402,21 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "0.7.15" diff --git a/desktop/core/Cargo.toml b/desktop/core/Cargo.toml index b7eeec6..6624c51 100644 --- a/desktop/core/Cargo.toml +++ b/desktop/core/Cargo.toml @@ -10,6 +10,7 @@ protocol = { path = "../../protocol" } anyhow = "1" log = "0.4" +socket2 = "0.6" # 配置的序列化。这里与旧客户端(client/)的做法相反:那一版的配置只有十来个标量 # 字段,手写 TOML 换来零依赖是划算的。现在配置里有 HUD 的嵌套结构和密码哈希,手写 diff --git a/desktop/core/src/receiver.rs b/desktop/core/src/receiver.rs index a14f443..a776d64 100644 --- a/desktop/core/src/receiver.rs +++ b/desktop/core/src/receiver.rs @@ -11,7 +11,8 @@ //! 两个方向各司其职: //! //! - 板子每 2 秒广播一次 [`protocol::MsgType::Hello`],客户端被动收到就知道有谁在线。 -//! - 客户端广播 [`protocol::MsgType::Subscribe`],板子收到后开始定向推流。 +//! - 客户端广播 [`protocol::MsgType::Subscribe`],并向已发现的板子单播续订。 +//! 多网卡 / VPN 可能把全局广播路由到虚拟网卡;单播按板子 IP 选择局域网接口。 //! //! 板子靠**源地址**记住客户端,所以客户端只要周期性续订就能维持推流,不存在任何 //! 超时淘汰的竞态。 @@ -59,7 +60,7 @@ use protocol::{ /// 来自网络,而不是来自过小的缓冲。 const RECV_BUFFER: usize = 2048; -/// 重新广播订阅的间隔。 +/// 广播及定向续订的间隔。 /// /// 比板子判定"客户端掉线"的超时(6 秒)短得多,所以偶发丢一两个包不会中断推流。 const SUBSCRIBE_INTERVAL: Duration = Duration::from_millis(800); @@ -291,8 +292,7 @@ impl Discovery { /// 客户端身份。 /// -/// 常量而非 MAC 派生:队伍客户端和裁判终端各自唯一,进程内也不需要区分自己,而一个 -/// 确定的常量让日志更好读。 +/// 旧客户端使用的固定标识,保留用于兼容判断。 pub const CLIENT_ID: u32 = 0x0C11_0001; /// 接收循环的可变状态:只由接收线程在跑循环时访问。 @@ -318,6 +318,7 @@ pub struct Receiver { /// 身份可以运行时切换(同一个进程里从裁判端切到用户端),所以订阅里声明的角色 /// 也得能改——它决定板子要不要给这个客户端推流。 role: Mutex, + client_id: u32, camera: Ipv4Addr, stop: Arc, } @@ -327,6 +328,10 @@ impl Receiver { /// /// `only_sender` 为 `Some` 时只接受那一块板子的帧。 pub fn new(camera: Ipv4Addr, role: SubscribeRole, only_sender: Option) -> Self { + let client_id = loop { + let id = rand::random::(); + if id != 0 && id != CLIENT_ID { break id; } + }; Self { discovery: Arc::new(Discovery::new()), latest: Arc::new(LatestFrames::default()), @@ -337,6 +342,7 @@ impl Receiver { discovery_all: false, }), role: Mutex::new(role), + client_id, camera, stop: Arc::new(AtomicBool::new(false)), } @@ -354,6 +360,15 @@ impl Receiver { ) -> std::io::Result<(Self, UdpSocket)> { let socket = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?; + // Absorb short scheduler/NIC bursts without losing JPEG fragments. The + // reader still drains continuously; decoded video uses latest frames. + let socket_ref = socket2::SockRef::from(&socket); + if let Err(err) = socket_ref.set_recv_buffer_size(1024 * 1024) { + log::warn!("could not enlarge the video receive buffer: {err}"); + } + if let Ok(bytes) = socket_ref.recv_buffer_size() { + log::info!("video UDP receive buffer: {bytes} bytes"); + } socket.set_read_timeout(Some(Duration::from_millis(100)))?; let broadcast = socket.set_broadcast(true).is_ok(); @@ -473,11 +488,17 @@ impl Receiver { .entry(id) .or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT)); - match assembler.push(frag) { - Ok(frame) => { - drop(ingest); + let dropped_before = assembler.dropped_frames(); + let result = assembler.push(frag); + let dropped = assembler.dropped_frames() - dropped_before; + drop(ingest); + self.discovery.count(id, |s| { + s.datagrams += 1; + s.frames_dropped += dropped; + }); - self.discovery.count(id, |s| s.datagrams += 1); + match result { + Ok(frame) => { self.discovery .note_frame(id, frame.width, frame.height, frame.data.len()); @@ -489,17 +510,7 @@ impl Receiver { sink(frame); } } - Err(protocol::DropReason::Superseded) => { - // 被更新的帧追上了。这不是丢帧:半截的那张只是被超越,在健康链路上 - // 这是帧被放弃的**正常**方式。 - self.discovery.count(id, |s| s.datagrams += 1); - } - Err(protocol::DropReason::TimedOut) => { - self.discovery.count(id, |s| { - s.datagrams += 1; - s.frames_dropped += 1; - }); - } + Err(_) => {} } } @@ -521,6 +532,25 @@ impl Receiver { } } + /// 全局广播用于发现;已知板子还需按 IP 续订,避免广播被 VPN / 虚拟网卡接走。 + fn subscription_targets(&self) -> Vec { + let mut targets = vec![SocketAddrV4::new(self.camera, DEFAULT_PORT)]; + let only_sender = self.sender_limit(); + + for camera in self.discovery.online_cameras() { + if only_sender.is_some_and(|id| camera.id != id) { + continue; + } + // Hello 的来源端口不一定是订阅监听端口;协议固定使用 DEFAULT_PORT。 + let target = SocketAddrV4::new(*camera.addr.ip(), DEFAULT_PORT); + if !targets.contains(&target) { + targets.push(target); + } + } + + targets + } + /// 运行接收循环,直到 [`Receiver::shutdown`]。 /// /// 阻塞,所以放在单独的线程上。 @@ -543,11 +573,14 @@ impl Receiver { if last_subscribe.elapsed() >= SUBSCRIBE_INTERVAL { let subscribe = Subscribe { role: self.role(), - client_id: CLIENT_ID, + client_id: self.client_id, }; - if let Err(err) = socket.send_to(&subscribe.encode(), target) { - log::warn!("could not subscribe at {target}: {err}"); + let encoded = subscribe.encode(); + for target in self.subscription_targets() { + if let Err(err) = socket.send_to(&encoded, target) { + log::warn!("could not subscribe at {target}: {err}"); + } } last_subscribe = Instant::now(); } @@ -639,6 +672,48 @@ mod tests { rx.datagram(&hello.encode(MsgType::Hello), from(from_ip)); } + #[test] + fn discovered_boards_are_subscribed_directly_when_broadcast_uses_another_interface() { + let rx = Receiver::new(Ipv4Addr::BROADCAST, SubscribeRole::Referee, None); + let hello = Hello { sender_id: 1, firmware: 7 }; + // Hello 可从其它端口发来,订阅仍需发往板子的固定监听端口。 + rx.datagram(&hello.encode(MsgType::Hello), "192.168.1.139:12345".parse().unwrap()); + feed_hello(&rx, 2, [192, 168, 1, 140]); + + assert_eq!(rx.subscription_targets(), vec![ + "255.255.255.255:5600".parse().unwrap(), + "192.168.1.139:5600".parse().unwrap(), + "192.168.1.140:5600".parse().unwrap(), + ]); + + // DHCP 地址变化后只使用新地址。 + feed_hello(&rx, 1, [192, 168, 1, 141]); + assert_eq!(rx.subscription_targets(), vec![ + "255.255.255.255:5600".parse().unwrap(), + "192.168.1.141:5600".parse().unwrap(), + "192.168.1.140:5600".parse().unwrap(), + ]); + } + + #[test] + fn discovery_of_other_teams_does_not_directly_subscribe_to_their_video() { + let rx = Receiver::new(Ipv4Addr::BROADCAST, SubscribeRole::Team, Some(1)); + rx.set_discovery_all(true); + feed_hello(&rx, 1, [192, 168, 1, 10]); + feed_hello(&rx, 2, [192, 168, 1, 20]); + + assert_eq!(rx.discovery().cameras().len(), 2); + assert_eq!(rx.subscription_targets(), vec![ + "255.255.255.255:5600".parse().unwrap(), + "192.168.1.10:5600".parse().unwrap(), + ]); + rx.restrict_to(Some(2)); + assert_eq!(rx.subscription_targets(), vec![ + "255.255.255.255:5600".parse().unwrap(), + "192.168.1.20:5600".parse().unwrap(), + ]); + } + #[test] fn delivers_the_latest_frame_of_the_bound_sender_only() { let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Team, Some(1)); @@ -761,4 +836,21 @@ mod tests { assert!(rx.discovery().cameras().is_empty()); } + + #[test] + fn loss_statistics_include_superseded_frames_and_entirely_missing_ids() { + let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Referee, None); + feed_hello(&rx, 1, [192, 168, 1, 10]); + feed_frame(&rx, 1, 1, [192, 168, 1, 10]); + let partial = fragment_frame(1, 2, &jpeg(), 640, 480); + rx.datagram(&partial[0].encode(), from([192, 168, 1, 10])); + assert_eq!(rx.discovery().stats(1).frames_dropped, 0, "normal pending fragments are not drops"); + feed_frame(&rx, 1, 4, [192, 168, 1, 10]); + let stats = rx.discovery().stats(1); + assert_eq!(stats.frames_shown, 2); + assert_eq!(stats.frames_dropped, 2, "partial 2 and entirely missing 3"); + assert_eq!(stats.loss_percent(), 50.0); + for frag in partial { rx.datagram(&frag.encode(), from([192, 168, 1, 10])); } + assert_eq!(rx.discovery().stats(1).frames_dropped, 2); + } } diff --git a/desktop/package.json b/desktop/package.json index 51a3e85..9b61d27 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -10,7 +10,9 @@ "preview": "vite preview", "tauri": "tauri", "app": "tauri dev", - "check": "tsc --noEmit" + "check": "tsc --noEmit", + "test:stream": "node --test scripts/test-stream.mjs", + "test:latency": "node --test scripts/test-latency-marker.mjs" }, "dependencies": { "@tauri-apps/api": "^2", diff --git a/desktop/scripts/soak-stream.mjs b/desktop/scripts/soak-stream.mjs new file mode 100644 index 0000000..9dd64fe --- /dev/null +++ b/desktop/scripts/soak-stream.mjs @@ -0,0 +1,146 @@ +// Observe the native client's local video channel without adding a UDP peer. +// Read the token internally from a deliberately enabled debug log; never print it. +import { readFile, writeFile, appendFile, mkdir, access } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +const args = Object.fromEntries(process.argv.slice(2).reduce((pairs, value, i, all) => { + if (i % 2 === 0) pairs.push([value.replace(/^--/, ''), all[i+1]]); + return pairs; +}, [])); +if (!args.log || !args.out) throw new Error('Use --log --out [--seconds 1800] [--warmup 30]'); +const seconds = Number(args.seconds ?? 300); +const warmup = Number(args.warmup ?? 10); +const sampleEveryMs = 1000*Number(args['sample-every'] ?? 0); +const camera = Number(args.camera ?? '0x3D4D8A9A'); +await mkdir(dirname(args.out), { recursive: true }); +const log = await readFile(args.log, 'utf8'); +const url = [...log.matchAll(/ws:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+/g)].at(-1)?.[0]; +if (!url) throw new Error('No local video-channel URL in the debug log'); +const ws = new WebSocket(url); +ws.binaryType = 'arraybuffer'; +let latest = null, demo = true, diagnostics = false, clients = 0, connected = false; +let binary = 0, invalid = 0, lastArrival = null, gaps = [], bytes = 0; +let firstFrame = null; +let sampleCount = 0, nextSampleAt = 0; +const allGaps = [], windows = []; +let measurementStarted = null, previousAt = null, previousStats = null; +let windowBinary = 0, windowInvalid = 0, windowBytes = 0; +let totalFrames = 0, totalDropped = 0, counterResets = 0, socketCloses = 0; +let offlineSnapshots = 0; +let diagnosticsUnavailableSnapshots = 0; +let finished = false; +ws.addEventListener('close', () => { connected = false; if (!finished) socketCloses++; }); +ws.addEventListener('message', event => { + if (typeof event.data === 'string') { + const msg = JSON.parse(event.data); + if (msg.t === 'snapshot') { + latest = msg.stats.find(stat => stat.camera === camera) ?? null; + demo = msg.demo; + diagnostics = msg.diagnostics; + clients = msg.clients; + if (measurementStarted !== null && (!latest || !latest.online)) offlineSnapshots++; + if (measurementStarted !== null && !diagnostics) diagnosticsUnavailableSnapshots++; + } + return; + } + const data = Buffer.from(event.data); + if (data.length < 16 || data.readUInt32LE(0) !== camera) return; + if (measurementStarted === null) return; + const valid = data.length === data.readUInt32LE(12)+16 && data[16] === 255 && data[17] === 216 && data.at(-2) === 255 && data.at(-1) === 217; + if (!valid) { invalid++; windowInvalid++; return; } + const now = performance.now(); + if (lastArrival !== null) { gaps.push(now-lastArrival); allGaps.push(now-lastArrival); } + lastArrival = now; + binary++; windowBinary++; + bytes += data.length-16; windowBytes += data.length-16; + if (firstFrame === null) { + firstFrame = Buffer.from(data.subarray(16)); + void writeFile(`${args.out}.first.jpg`, firstFrame).catch(error => console.error('Could not save sample JPEG:', error.message)); + } + if (sampleEveryMs > 0 && now >= nextSampleAt) { + nextSampleAt = now+sampleEveryMs; + const path = `${args.out}.sample-${String(++sampleCount).padStart(4,'0')}.jpg`; + void writeFile(path, data.subarray(16)).catch(error => console.error('Could not save diagnostic JPEG:', error.message)); + } +}); +await Promise.race([ + new Promise((resolve, reject) => { ws.addEventListener('open', () => { connected = true; resolve(); }, { once:true }); ws.addEventListener('error', reject, { once:true }); }), + new Promise((_, reject) => setTimeout(() => reject(new Error('Video channel open timeout')), 15_000)), +]); +await new Promise(resolve => setTimeout(resolve, warmup*1000)); +if (demo || !latest?.online || !latest?.hasFrames || clients < 2) throw new Error('Native GUI and online real hardware video are required before starting the soak'); +if (!diagnostics) throw new Error('Receiver counters require referee diagnostics; team release mode hides them'); +measurementStarted = previousAt = performance.now(); +previousStats = latest; +await writeFile(`${args.out}.windows.ndjson`, ''); + +function percentile(values, p) { + if (!values.length) return null; + const sorted = [...values].sort((a,b) => a-b); + return Number(sorted[Math.floor((sorted.length-1)*p)].toFixed(3)); +} +async function sample() { + const now = performance.now(); + const elapsed = (now-previousAt)/1000; + let complete = 0, dropped = 0; + if (latest && previousStats) { + if (latest.framesShown < previousStats.framesShown || latest.framesDropped < previousStats.framesDropped) counterResets++; + else { complete = latest.framesShown-previousStats.framesShown; dropped = latest.framesDropped-previousStats.framesDropped; } + } + totalFrames += complete; totalDropped += dropped; + const row = { + utc:new Date().toISOString(), elapsedSeconds:(now-measurementStarted)/1000, windowSeconds:elapsed, + completeFrames:complete, framesDropped:dropped, receiverFps:complete/elapsed, + frameLossPercent:100*dropped/Math.max(1,complete+dropped), + binaryFrames:windowBinary, channelFps:windowBinary/elapsed, invalidJpegs:windowInvalid, + meanJpegBytes:windowBinary ? windowBytes/windowBinary : 0, + gapP95Ms:percentile(gaps,.95), gapMaxMs:Math.max(0,...gaps, lastArrival===null ? (now-measurementStarted) : now-lastArrival), + cameraOnline:latest?.online ?? false, frontendConnected:clients>=2, observerConnected:connected, + }; + windows.push(row); + await appendFile(`${args.out}.windows.ndjson`, JSON.stringify(row)+'\n'); + await writeFile(`${args.out}.latest.json`, JSON.stringify(row,null,2)+'\n'); + console.log(JSON.stringify(row)); + previousAt = now; + previousStats = latest; + windowBinary = windowInvalid = windowBytes = 0; + gaps = []; +} +let stoppedByRequest = false; +while ((performance.now()-measurementStarted)/1000 < seconds) { + if (args['stop-file'] && await access(args['stop-file']).then(() => true, () => false)) { + stoppedByRequest = true; + break; + } + const remaining = seconds-(performance.now()-measurementStarted)/1000; + await new Promise(resolve => setTimeout(resolve, Math.min(1,remaining)*1000)); + if (performance.now()-previousAt >= 60_000 || (performance.now()-measurementStarted)/1000 >= seconds) await sample(); +} +if (stoppedByRequest && performance.now()-previousAt > 1_000) await sample(); +finished = true; +ws.close(); +const actualSeconds = (performance.now()-measurementStarted)/1000; +const receiverMetricsValid = counterResets === 0 && diagnosticsUnavailableSnapshots === 0; +const summary = { + actualSeconds, requestedSeconds:seconds, stoppedByRequest, warmupSeconds:warmup, camera, demo, windows, binaryFrames:binary, + invalidJpegs:invalid, receiverMetricsValid, + completeFrames:receiverMetricsValid ? totalFrames : null, framesDropped:receiverMetricsValid ? totalDropped : null, + rawWindowCountedCompleteFrames:totalFrames, rawWindowCountedDroppedFrames:totalDropped, + receiverFps:receiverMetricsValid ? totalFrames/actualSeconds : null, channelFps:binary/actualSeconds, + frameLossPercent:receiverMetricsValid ? 100*totalDropped/Math.max(1,totalFrames+totalDropped) : null, + gapP95Ms:percentile(allGaps,.95), gapMaxMs:Math.max(0,...allGaps), + meanJpegBytes:binary ? bytes/binary : 0, counterResets, socketCloses, + offlineSnapshots, + diagnosticsUnavailableSnapshots, + diagnosticSamplesSaved:sampleCount, + allWindowsOnline:windows.every(window => window.cameraOnline), + zeroVideoWindows:windows.filter(window => window.binaryFrames === 0).length, + noVideoSecondsAtEnd:lastArrival === null ? actualSeconds : (performance.now()-lastArrival)/1000, + maxObservedSilenceMs:Math.max(0,...windows.map(window => window.gapMaxMs)), + frontendConnected:clients>=2, + measurementScope:'Native receiver and local JPEG WebSocket channel; not optical display latency', +}; +await writeFile(`${args.out}.summary.json`, JSON.stringify(summary,null,2)+'\n'); +if (firstFrame) await writeFile(`${args.out}.first.jpg`, firstFrame); +console.log(JSON.stringify({ ...summary, windows:windows.length })); +if (demo || !binary || invalid || socketCloses || counterResets || offlineSnapshots || diagnosticsUnavailableSnapshots) process.exitCode = 1; diff --git a/desktop/scripts/test-latency-marker.mjs b/desktop/scripts/test-latency-marker.mjs new file mode 100644 index 0000000..02b227c --- /dev/null +++ b/desktop/scripts/test-latency-marker.mjs @@ -0,0 +1,50 @@ +import {readFileSync} from 'node:fs'; +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import ts from 'typescript'; +const source=readFileSync(new URL('../src/lib/latency-marker.ts',import.meta.url),'utf8'); +const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; +const exports={};new Function('exports',code)(exports); +const {drawMarker,decodeMarker}=exports; +function image(counter,w=320,h=240) { + const pixels=new Uint8ClampedArray(w*h*4); + const ctx={canvas:{width:w,height:h},fillStyle:'',fillRect(x,y,width,height){ + const c=this.fillStyle.startsWith('rgb(')?this.fillStyle.slice(4,-1).split(',').map(Number): + this.fillStyle==='#fff'?[255,255,255]:this.fillStyle==='#000'?[0,0,0]:[60,60,60]; + for(let yy=Math.max(0,Math.ceil(y));yy{ + for(const id of [0,1,2,3,63,127,1023,2047,2048,4094,4095,4096]) { + assert.equal(decodeMarker(image(id),320,240)?.counter,id&4095); + } +}); +test('camera rotation and reflection preserve the observed ID',()=>{ + const original=image(1371),rotated=new Uint8ClampedArray(original.length),flipped=new Uint8ClampedArray(original.length); + for(let y=0;y<240;y++)for(let x=0;x<320;x++){ + rotated.set(original.subarray((y*320+x)*4,(y*320+x)*4+4),(x*240+239-y)*4); + flipped.set(original.subarray((y*320+x)*4,(y*320+x)*4+4),(y*320+319-x)*4); + } + assert.equal(decodeMarker(rotated,240,320)?.counter,1371); + assert.equal(decodeMarker(flipped,320,240)?.counter,1371); +}); +test('missing fiducials and ambiguous rolling transitions do not invent latency samples',()=>{ + assert.equal(decodeMarker(new Uint8ClampedArray(320*240*4),320,240),null); + const pixels=image(123); + for(let y=90;y<152;y++)for(let x=44;x<276;x++)pixels.set([128,128,128,255],(y*320+x)*4); + assert.equal(decodeMarker(pixels,320,240),null); +}); + +test('washed screen primaries survive without mistaking a blue-tinted background',()=>{ + const pixels=image(1371); + for(let i=0;i '); +const log=readFileSync(logPath,'utf8'); +const url=[...log.matchAll(/ws:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+/g)].at(-1)?.[0]; +if(!url)throw new Error('No local video URL in the private debug log'); +const source=readFileSync(new URL('../src/lib/stream.ts',import.meta.url),'utf8'); +const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; +const exports={};new Function('require','exports',compiled)(()=>({FRAME_HEADER_LEN:16}),exports); +globalThis.window={setTimeout,clearTimeout}; +globalThis.createImageBitmap=async()=>({close(){}}); +const rx=new exports.StreamClient({onMessage(){},onConnection(){}}); +const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +try { + rx.connect(url); + const deadline=performance.now()+10_000; + while(rx.counters.frames<10&&performance.now()=10,'Real native JPEG video did not arrive'); + const camera=[...rx.frames.keys()][0]; + const before=rx.latest(camera)?.frameId; + const staleBefore=rx.counters.staleFrames; + const blockedAt=performance.now(); + while(performance.now()-blockedAt<350) { /* synthetic consumer stall */ } + await delay(700); + const latest=rx.latest(camera); + assert.ok(rx.counters.staleFrames>staleBefore,'Stale native-timestamped frames were not rejected'); + assert.ok(latest&&latest.frameId!==before,'The consumer did not recover to new video'); + assert.ok(performance.now()-latest.receivedAt<150,'Recovered image is still stale locally'); + const result={passed:true,stall_ms:350,stale_frames_rejected:rx.counters.staleFrames-staleBefore, + frame_before:before,frame_after:latest.frameId,frames_received:rx.counters.frames, + additional_udp_subscribers:0,jpeg_decoder_stubbed:true, + scope:'Production StreamClient over real native WebSocket; tests queued-frame age and recovery, not physical display latency'}; + writeFileSync(outPath,JSON.stringify(result,null,2)+'\n'); + console.log(JSON.stringify(result)); +} finally {rx.close();} diff --git a/desktop/scripts/test-stream.mjs b/desktop/scripts/test-stream.mjs new file mode 100644 index 0000000..5ddca02 --- /dev/null +++ b/desktop/scripts/test-stream.mjs @@ -0,0 +1,143 @@ +import { readFileSync } from 'node:fs'; +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import ts from 'typescript'; + +const source = readFileSync(new URL('../src/lib/stream.ts', import.meta.url), 'utf8'); +const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText; +const exports = {}; +new Function('require', 'exports', compiled)(name => { + assert.equal(name, './types'); + return { FRAME_HEADER_LEN: 16 }; +}, exports); +const { StreamClient } = exports; +globalThis.window = { clearTimeout, setTimeout }; +const tick = () => new Promise(resolve => setImmediate(resolve)); +const bitmap = () => ({ closed: false, close() { this.closed = true; } }); +function frame(id, camera = 42) { + const bytes = new ArrayBuffer(20); + const header = new DataView(bytes); + header.setUint32(0, camera, true); + header.setUint32(4, id, true); + header.setUint16(8, 640, true); + header.setUint16(10, 480, true); + header.setUint32(12, 4, true); + return bytes; +} +function client(now, wallNow) { return new StreamClient({ onMessage() {}, onConnection() {} },now,wallNow); } +function deferredDecoder() { + const requests = []; + globalThis.createImageBitmap = () => new Promise((resolve, reject) => requests.push({ resolve, reject })); + return requests; +} + +test('a slow decoder keeps only the latest pending frame for each camera', async () => { + const requests = deferredDecoder(); + const rx = client(); + const finished = rx.onBinary(frame(1)); + for (let id = 2; id <= 100; id++) await rx.onBinary(frame(id)); + assert.equal(requests.length, 1); + assert.equal(rx.pending.size, 1); + const first = bitmap(); + requests[0].resolve(first); + await tick(); + assert.equal(requests.length, 2); + requests[1].resolve(bitmap()); + await finished; + assert.equal(rx.latest(42).frameId, 100); + assert.equal(rx.counters.skippedDecodes, 98); + assert.equal(first.closed, true); + rx.close(); +}); + +test('two cameras decode independently without queues growing behind either', async () => { + const requests = deferredDecoder(); + const rx = client(); + const a = rx.onBinary(frame(1, 1)); + const b = rx.onBinary(frame(2, 2)); + assert.equal(requests.length, 2); + requests[1].resolve(bitmap()); + await b; + assert.equal(rx.latest(2).frameId, 2); + assert.equal(rx.latest(1), undefined); + requests[0].resolve(bitmap()); + await a; + rx.close(); +}); + +test('closing during decode releases the late bitmap and discards pending work', async () => { + const requests = deferredDecoder(); + const rx = client(); + const work = rx.onBinary(frame(1)); + await rx.onBinary(frame(2)); + rx.close(); + const late = bitmap(); + requests[0].resolve(late); + await work; + assert.equal(late.closed, true); + assert.equal(requests.length, 1); + assert.equal(rx.latest(42), undefined); +}); + +test('a corrupt JPEG does not stop the next frame from being decoded', async () => { + const requests = deferredDecoder(); + const rx = client(); + const work = rx.onBinary(frame(1)); + await rx.onBinary(frame(2)); + requests[0].reject(new Error('bad JPEG')); + await tick(); + requests[1].resolve(bitmap()); + await work; + assert.equal(rx.counters.decodeFailures, 1); + assert.equal(rx.latest(42).frameId, 2); + rx.close(); +}); + +test('late frames are rejected but a reboot after silence can restart the counter', () => { + const rx = client(); + rx.injectFrame(42, 100, 640, 480, bitmap()); + const old = bitmap(); + rx.injectFrame(42, 99, 640, 480, old); + assert.equal(old.closed, true); + assert.equal(rx.latest(42).frameId, 100); + rx.latest(42).receivedAt -= 2000; + rx.injectFrame(42, 1, 640, 480, bitmap()); + assert.equal(rx.latest(42).frameId, 1); + rx.close(); +}); + +test('a WebView backlog is discarded before JPEG decoding using native host timing', async () => { + const requests = deferredDecoder(); + const rx = client(() => 0, () => 2000); + rx.onTiming({camera:42,frameId:1,publishedUnixMs:1500}); + await rx.onBinary(frame(1)); + assert.equal(requests.length,0); + assert.equal(rx.counters.staleFrames,1); + rx.onTiming({camera:42,frameId:2,publishedUnixMs:2000}); + const work = rx.onBinary(frame(2)); + requests[0].resolve(bitmap());await work; + assert.equal(rx.latest(42).frameId,2); + rx.close(); +}); + +test('a blocked decoder releases its old result and resumes with the fresh pending frame', async () => { + let clock=0; + const requests=deferredDecoder(),rx=client(()=>clock); + const work=rx.onBinary(frame(1)); + clock=110;await rx.onBinary(frame(2)); + const old=bitmap();requests[0].resolve(old);await tick(); + assert.equal(old.closed,true); + assert.equal(rx.latest(42),undefined); + requests[1].resolve(bitmap());await work; + assert.equal(rx.latest(42).frameId,2); + assert.equal(rx.counters.staleFrames,1);rx.close(); +}); + +test('pending frames that expired during a decoder stall never reach the decoder', async () => { + let clock=0; + const requests=deferredDecoder(),rx=client(()=>clock); + const work=rx.onBinary(frame(1));await rx.onBinary(frame(2)); + clock=200;const old=bitmap();requests[0].resolve(old);await work; + assert.equal(old.closed,true);assert.equal(requests.length,1); + assert.equal(rx.counters.staleFrames,2);assert.equal(rx.latest(42),undefined);rx.close(); +}); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 01937c9..d6d49a8 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -476,6 +476,7 @@ dependencies = [ "protocol", "rand 0.9.5", "serde", + "socket2", "toml 0.9.12+spec-1.1.0", ] diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs index 417de5b..5bec3d4 100644 --- a/desktop/src-tauri/src/commands.rs +++ b/desktop/src-tauri/src/commands.rs @@ -485,11 +485,7 @@ pub fn clear_config(password: Option, state: Shared<'_>) -> Result, - app: AppHandle, - state: Shared<'_>, -) -> Result<(), String> { +pub fn quit_app(password: Option, app: AppHandle, state: Shared<'_>) -> Result<(), String> { state.require_unlocked(password.as_deref())?; // 先把录制收尾,否则 ffmpeg 会留下一个没有索引的 mp4。 diff --git a/desktop/src-tauri/src/demo.rs b/desktop/src-tauri/src/demo.rs index f6796e8..f34dadf 100644 --- a/desktop/src-tauri/src/demo.rs +++ b/desktop/src-tauri/src/demo.rs @@ -31,9 +31,7 @@ use std::time::Duration; use protocol::{fragment_frame, ArmorHealth, MsgType}; -use crate::state::{ - AppState, DEMO_ARMOR_A, DEMO_ARMOR_B, DEMO_CAMERA_A, DEMO_CAMERA_B, -}; +use crate::state::{AppState, DEMO_ARMOR_A, DEMO_ARMOR_B, DEMO_CAMERA_A, DEMO_CAMERA_B}; /// 合成画面的尺寸。与实机一致(VGA),这样界面的缩放/比例在演示下也说得通。 const WIDTH: u32 = 640; @@ -83,9 +81,7 @@ fn spawn_camera(state: Arc, camera: u32, team: u8) { // 板子每 2 秒广播一次在线状态,与固件一致。 if last_hello.elapsed() >= Duration::from_secs(2) { - state - .receiver - .datagram(&hello.encode(MsgType::Hello), from); + state.receiver.datagram(&hello.encode(MsgType::Hello), from); last_hello = std::time::Instant::now(); } @@ -140,10 +136,8 @@ fn spawn_armor(state: Arc) { hp[slot] = hp[slot].saturating_sub(damage); } - let from = SocketAddr::V4(SocketAddrV4::new( - Ipv4Addr::new(127, 0, 0, 1), - *port, - )); + let from = + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), *port)); // 主板一次广播四条,整车一条血所以 hp 相同、index 0..3。 for index in 0..4u8 { @@ -215,10 +209,38 @@ fn render(camera: u32, team: u8, frame_id: u32) -> Vec { // 中心的靶心:两个同心圆 + 四臂。瞄准器偏移校准时对着它看。 ring(&mut img, cx, cy, 60, image::Rgb([200, 200, 210])); ring(&mut img, cx, cy, 28, image::Rgb([150, 150, 160])); - arm(&mut img, cx - 90, cy, cx - 40, cy, image::Rgb([120, 120, 130])); - arm(&mut img, cx + 40, cy, cx + 90, cy, image::Rgb([120, 120, 130])); - arm(&mut img, cx, cy - 90, cx, cy - 40, image::Rgb([120, 120, 130])); - arm(&mut img, cx, cy + 40, cx, cy + 90, image::Rgb([120, 120, 130])); + arm( + &mut img, + cx - 90, + cy, + cx - 40, + cy, + image::Rgb([120, 120, 130]), + ); + arm( + &mut img, + cx + 40, + cy, + cx + 90, + cy, + image::Rgb([120, 120, 130]), + ); + arm( + &mut img, + cx, + cy - 90, + cx, + cy - 40, + image::Rgb([120, 120, 130]), + ); + arm( + &mut img, + cx, + cy + 40, + cx, + cy + 90, + image::Rgb([120, 120, 130]), + ); // 二进制帧号:16 个方块,最左边是低位。 for bit in 0..16i32 { @@ -241,7 +263,14 @@ fn render(camera: u32, team: u8, frame_id: u32) -> Vec { // 底部随时间右移的指示条:截图里能判断这一帧是不是较新的。 let bar = ((t * 60.0) as i32).rem_euclid(WIDTH as i32 - 80); - fill_rect(&mut img, bar, HEIGHT as i32 - 24, 60, 8, image::Rgb([90, 200, 255])); + fill_rect( + &mut img, + bar, + HEIGHT as i32 - 24, + 60, + 8, + image::Rgb([90, 200, 255]), + ); // 摄像头 id 的最低位用一个角标表示,进一步区分两路。 let corner = if camera == DEMO_CAMERA_A { 0 } else { 1 }; @@ -341,8 +370,8 @@ mod tests { assert!(jpeg.starts_with(&[0xFF, 0xD8]), "JPEG SOI"); assert!(jpeg.ends_with(&[0xFF, 0xD9]), "JPEG EOI"); - let decoded = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg) - .expect("decodes"); + let decoded = + image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg).expect("decodes"); assert_eq!((decoded.width(), decoded.height()), (WIDTH, HEIGHT)); } @@ -366,7 +395,11 @@ mod tests { fn a_frame_is_big_enough_to_be_a_realistic_load() { // 太小说明画的内容退化了(比如全是纯色),那分片/带宽的验证就失去意义。 let jpeg = render(DEMO_CAMERA_A, 0, 1); - assert!(jpeg.len() > 3_000, "suspiciously small: {} bytes", jpeg.len()); + assert!( + jpeg.len() > 3_000, + "suspiciously small: {} bytes", + jpeg.len() + ); assert!( jpeg.len() < 200_000, "suspiciously large: {} bytes", diff --git a/desktop/src-tauri/src/latency.rs b/desktop/src-tauri/src/latency.rs new file mode 100644 index 0000000..e8d48d3 --- /dev/null +++ b/desktop/src-tauri/src/latency.rs @@ -0,0 +1,77 @@ +//! Opt-in, local-only calibration reports. No images, tokens or user config. +use serde::Deserialize; +use std::{ + fs::{File, OpenOptions}, + io::Write, + path::PathBuf, + sync::Mutex, +}; + +pub struct Recorder { + output: Mutex>, +} +impl Recorder { + pub fn new(path: Option) -> std::io::Result { + let output = if let Some(path) = path { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + Some(OpenOptions::new().write(true).create_new(true).open(path)?) + } else { + None + }; + Ok(Self { + output: Mutex::new(output), + }) + } +} + +#[derive(Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Sample { + frame_id: u32, + counter: u16, + source_draw_ms: f64, + draw_ms: f64, + next_raf_ms: f64, + lower_ms: f64, + upper_ms: f64, + decode_to_draw_ms: f64, + analysis_ms: f64, +} + +#[tauri::command] +pub fn latency_enabled(recorder: tauri::State<'_, Recorder>) -> bool { + recorder + .output + .lock() + .expect("latency output mutex") + .is_some() +} + +#[tauri::command] +pub fn latency_sample(sample: Sample, recorder: tauri::State<'_, Recorder>) -> Result<(), String> { + if ![ + sample.source_draw_ms, + sample.draw_ms, + sample.next_raf_ms, + sample.lower_ms, + sample.upper_ms, + sample.decode_to_draw_ms, + sample.analysis_ms, + ] + .iter() + .all(|v| v.is_finite()) + || sample.lower_ms < 0.0 + || sample.upper_ms < sample.lower_ms + || sample.upper_ms > 5000.0 + { + return Err("Invalid latency sample".into()); + } + let mut guard = recorder.output.lock().map_err(|_| "latency output mutex")?; + let out = guard.as_mut().ok_or("Latency calibration is disabled")?; + serde_json::to_writer(&mut *out, &sample).map_err(|e| e.to_string())?; + out.write_all(b"\n") + .and_then(|_| out.flush()) + .map_err(|e| e.to_string()) +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 27f3739..de43055 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -23,6 +23,7 @@ mod commands; mod demo; +mod latency; mod recording; mod server; mod state; @@ -42,6 +43,7 @@ const USAGE: &str = "\ --role 身份。默认看配置文件;没有配置文件时是 referee --config <路径> 配置文件位置 + --latency-report <路径> 打开裁判端屏幕标记校准,写入新的本地 NDJSON 报告 --demo 演示模式:画面与血量都是本地合成的 --list 打印发现的摄像头与装甲板后退出 -h, --help 显示这段帮助 @@ -78,6 +80,7 @@ fn main() { struct Args { role: Option, config: Option, + latency_report: Option, demo: bool, list: bool, help: bool, @@ -104,6 +107,9 @@ impl Args { "--config" => { args.config = it.next().map(PathBuf::from); } + "--latency-report" => { + args.latency_report = it.next().map(PathBuf::from); + } other => { // 见模块文档:macOS 的 .app 启动参数与 shell 里的笔误都可能出现在这里, // 但报错的代价(窗口起不来)远大于收益。 @@ -124,7 +130,10 @@ fn run() -> Result<(), Box> { return Ok(()); } - let config_path = args.config.clone().unwrap_or_else(config::default_config_path); + let config_path = args + .config + .clone() + .unwrap_or_else(config::default_config_path); // 首次启动时导入旧客户端的配置,并把旧文件改名成 .bak。 let (mut config, notes) = config::load_with_migration(&config_path)?; @@ -134,6 +143,10 @@ fn run() -> Result<(), Box> { if let Some(role) = args.role { config.role = role; } + if args.latency_report.is_some() && config.role != Role::Referee { + return Err("Latency calibration requires a referee test configuration".into()); + } + let latency_recorder = latency::Recorder::new(args.latency_report.clone())?; for note in ¬es { log::info!("{note}"); @@ -150,8 +163,7 @@ fn run() -> Result<(), Box> { config_path.display() ); - let (state, video_socket, armor_socket) = - AppState::new(config_path, config, notes, args.demo)?; + let (state, video_socket, armor_socket) = AppState::new(config_path, config, notes, args.demo)?; // 视频端口绑不上时没有 socket,接收线程也就没有意义——界面会显示那条说明。 if let Some(socket) = video_socket { @@ -219,7 +231,10 @@ fn run() -> Result<(), Box> { let app = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .manage(Arc::clone(&state)) + .manage(latency_recorder) .invoke_handler(tauri::generate_handler![ + latency::latency_enabled, + latency::latency_sample, commands::stream_info, commands::get_snapshot, commands::get_config, diff --git a/desktop/src-tauri/src/recording.rs b/desktop/src-tauri/src/recording.rs index 29fbd27..6ab9a01 100644 --- a/desktop/src-tauri/src/recording.rs +++ b/desktop/src-tauri/src/recording.rs @@ -99,9 +99,7 @@ impl Recorder { .stderr(Stdio::piped()) .spawn() .with_context(|| { - format!( - "could not start ffmpeg ({ffmpeg}); 用 [recording] ffmpeg 指定它的路径" - ) + format!("could not start ffmpeg ({ffmpeg}); 用 [recording] ffmpeg 指定它的路径") })?; let stdin = child @@ -124,8 +122,7 @@ impl Recorder { .spawn(move || { if let Err(err) = pump(&rx, stdin, &mut child, &frames_written) { log::error!("recording {} stopped: {err:#}", path.display()); - *error.lock().expect("rec error mutex poisoned") = - Some(format!("{err:#}")); + *error.lock().expect("rec error mutex poisoned") = Some(format!("{err:#}")); } })? }; @@ -191,7 +188,10 @@ impl Recorder { dto.active = false; if let Some(error) = dto.error.as_deref() { - log::warn!("recording 0x{:08X} finished with an error: {error}", self.camera); + log::warn!( + "recording 0x{:08X} finished with an error: {error}", + self.camera + ); } else { log::info!( "recording 0x{:08X} stopped: {} frames -> {}", @@ -291,7 +291,9 @@ fn pump( let reason = child_stderr(child); return Err(anyhow!( "writing to ffmpeg failed: {err}{}", - reason.map(|r| format!(";ffmpeg 说:{r}")).unwrap_or_default() + reason + .map(|r| format!(";ffmpeg 说:{r}")) + .unwrap_or_default() )); } @@ -356,7 +358,11 @@ impl RecordingSet { // 文件是它启动之后才创建的。先开新的话,新的一段会挑到与上一段相同的名字,然后被 // 它的 `-y` 覆盖掉——而上一段刚要收尾。先停就能保证上一个文件已经落地,`exists()` // 于是是可信的。 - let previous = self.inner.lock().expect("recording mutex poisoned").remove(&camera); + let previous = self + .inner + .lock() + .expect("recording mutex poisoned") + .remove(&camera); if let Some(previous) = previous { log::warn!("0x{camera:08X} was already recording; the previous take is being closed"); @@ -399,7 +405,12 @@ impl RecordingSet { /// 把一帧喂给对应那一路的录制器。在 UDP 接收线程里被调用,所以必须快。 pub fn feed(&self, frame: &Arc) { // 先快速判断有没有在录,避免每帧都抢锁。 - if self.inner.lock().expect("recording mutex poisoned").is_empty() { + if self + .inner + .lock() + .expect("recording mutex poisoned") + .is_empty() + { return; } @@ -498,8 +509,7 @@ mod tests { } let mut out = Vec::new(); - let mut encoder = - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80); + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80); encoder.encode_image(&img).expect("encodes"); out } @@ -525,10 +535,8 @@ mod tests { } fn temp_dir(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "cam-desktop-rec-{tag}-{}", - std::process::id() - )); + let dir = + std::env::temp_dir().join(format!("cam-desktop-rec-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("creates the temp dir"); dir @@ -585,7 +593,10 @@ mod tests { let path = dto.path.expect("has a path"); let meta = std::fs::metadata(&path).expect("the file exists"); - assert!(meta.len() > 0, "an empty container is a failure, not a take"); + assert!( + meta.len() > 0, + "an empty container is a failure, not a take" + ); if let Some((frames, width, height)) = ffprobe_frames(&path) { assert_eq!(frames, 20, "ffmpeg wrote every frame we fed it"); @@ -704,7 +715,10 @@ mod tests { let decoded = image::open(&path).expect("the PNG is readable"); assert_eq!((decoded.width(), decoded.height()), (64, 48)); assert!( - path.file_name().unwrap().to_string_lossy().starts_with("shot-3D4D8A9A-"), + path.file_name() + .unwrap() + .to_string_lossy() + .starts_with("shot-3D4D8A9A-"), "文件名要能看出是哪一路截的:{path:?}" ); @@ -727,7 +741,10 @@ mod tests { let forced = unique_path(&dir, "shot-00000001-fixed", "png"); std::fs::write(&forced, b"").unwrap(); let forced2 = unique_path(&dir, "shot-00000001-fixed", "png"); - assert!(forced2.to_string_lossy().ends_with("-2.png"), "got {forced2:?}"); + assert!( + forced2.to_string_lossy().ends_with("-2.png"), + "got {forced2:?}" + ); let _ = std::fs::remove_dir_all(&dir); } @@ -755,7 +772,10 @@ mod tests { assert_eq!(names.len(), 2, "got {names:?}"); assert!(names.contains(&"a.mp4")); - assert!(names.contains(&"b.PNG"), "后缀大小写不该决定它是不是一张截图"); + assert!( + names.contains(&"b.PNG"), + "后缀大小写不该决定它是不是一张截图" + ); assert_eq!(files[0].kind, "video"); let _ = std::fs::remove_dir_all(&dir); diff --git a/desktop/src-tauri/src/server.rs b/desktop/src-tauri/src/server.rs index 6d1c1a1..fb000d1 100644 --- a/desktop/src-tauri/src/server.rs +++ b/desktop/src-tauri/src/server.rs @@ -29,14 +29,15 @@ //! 小端。16 字节头,前端切掉它就能拿到一段完整的 JPEG。`sender_id` 让前端知道这一帧是 //! 哪块板子的——用户端只画绑定的那一路,裁判端按槽位分配画两路。 +use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use futures_util::{SinkExt, StreamExt}; use serde::Serialize; use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, watch}; use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response}; use tokio_tungstenite::tungstenite::http::StatusCode; use tokio_tungstenite::tungstenite::{Bytes, Message}; @@ -46,24 +47,43 @@ use cam_core::snapshot::{HitDto, Snapshot}; /// 二进制帧头长度。 pub const FRAME_HEADER_LEN: usize = 16; -/// 广播通道的容量。 -/// -/// 满了之后 `send` 会挤掉最老的(接收慢的连接会拿到 `Lagged`)——对实时画面这正是想要 -/// 的:落后 200 帧的客户端需要的是最新的那一帧,不是补齐历史。按每帧 16+22 kB 算, -/// 256 个槽位约 5.6 MB 上限。 +/// Ordered control-message capacity. JPEGs use a separate latest-value channel. pub const CHANNEL_CAPACITY: usize = 256; +const LOCAL_VIDEO_MAX_AGE: Duration = Duration::from_millis(100); /// 向界面推送的消息。 pub enum ServerMsg { - /// 一帧原始 JPEG(已经拼好 16 字节头)。 - /// - /// 载荷用 `Bytes` 而不是 `Arc>`:它本身就是引用计数的缓冲,克隆是加一次 - /// 引用计数,而且正好是 tungstenite 要的类型——每个连接一次多余的拷贝就此消失。 - Binary(Bytes), /// JSON 文本。 Text(String), } +#[derive(Clone)] +pub struct VideoPacket { + frame_id: u32, + bytes: Bytes, + published_at: Instant, + published_unix_ms: u64, +} + +pub type VideoFrames = HashMap; +pub type VideoSender = watch::Sender; + +/// Video uses latest-value semantics; control messages keep ordered delivery. +pub fn publish_video(tx: &VideoSender, frame: &protocol::Frame) { + let packet = VideoPacket { + frame_id: frame.frame_id, + bytes: encode_frame(frame).into(), + published_at: Instant::now(), + published_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + }; + tx.send_modify(|latest| { + latest.insert(frame.sender_id, packet); + }); +} + /// JSON 文本消息的信封。 /// /// 用内部标签 `t` 而不是两个字段:前端一个 `switch (msg.t)` 就能分发,且不需要为每种 @@ -82,6 +102,14 @@ pub enum WireMsg { /// 写成带字段的结构体而不是 `Note(String)`:serde 的**内部标签**(`#[serde(tag)]`) /// 只支持载荷是 map 的变体,newtype 里放一个字符串会在运行时报错,而不是编译期。 Note { message: String }, + /// Timestamp precedes its JPEG so a stalled WebView can reject queued data. + FrameTiming { + camera: u32, + #[serde(rename = "frameId")] + frame_id: u32, + #[serde(rename = "publishedUnixMs")] + published_unix_ms: u64, + }, } /// 前端连接这条通道需要的信息。 @@ -144,6 +172,7 @@ pub async fn serve( listener: TcpListener, token: String, tx: broadcast::Sender>, + video: VideoSender, clients: Arc, ) { loop { @@ -159,9 +188,10 @@ pub async fn serve( let tx = tx.clone(); let token = token.clone(); let clients = Arc::clone(&clients); + let video = video.subscribe(); tauri::async_runtime::spawn(async move { - if let Err(err) = handle_client(stream, peer, &token, tx, clients).await { + if let Err(err) = handle_client(stream, peer, &token, tx, video, clients).await { log::debug!("stream: client {peer} ended: {err}"); } }); @@ -173,6 +203,7 @@ async fn handle_client( peer: std::net::SocketAddr, token: &str, tx: broadcast::Sender>, + mut video: watch::Receiver, clients: Arc, ) -> anyhow::Result<()> { stream.set_nodelay(true).ok(); @@ -201,6 +232,8 @@ async fn handle_client( let (mut sink, mut incoming) = ws.split(); let mut rx = tx.subscribe(); + video.mark_changed(); + let mut last_sent = HashMap::new(); let mut keepalive = tokio::time::interval(Duration::from_secs(20)); let count = clients.fetch_add(1, Ordering::Relaxed) + 1; log::info!("stream: interface connected from {peer} ({count} connected)"); @@ -212,12 +245,35 @@ async fn handle_client( .unwrap_or_else(|_| "{}".into()); let _ = sink.send(Message::text(hello)).await; - let result = loop { + let result = 'messages: loop { tokio::select! { + changed = video.changed() => { + if changed.is_err() { break Ok(()); } + // Release the watch read lock before awaiting socket writes. + let latest: Vec<_> = video.borrow_and_update().iter() + .map(|(&camera, packet)| (camera, packet.clone())).collect(); + for (camera, packet) in latest { + if last_sent.get(&camera) == Some(&packet.frame_id) { continue; } + if packet.published_at.elapsed() >= LOCAL_VIDEO_MAX_AGE { continue; } + let timing = serde_json::to_string(&WireMsg::FrameTiming { + camera, frame_id: packet.frame_id, published_unix_ms: packet.published_unix_ms, + })?; + // Keep the 16-byte JPEG header compatible. This small text + // message carries host queue age through TCP/WebView queues. + let send = async { + sink.send(Message::text(timing)).await?; + sink.send(Message::Binary(packet.bytes)).await + }; + match tokio::time::timeout(LOCAL_VIDEO_MAX_AGE, send).await { + Ok(Ok(())) => { last_sent.insert(camera, packet.frame_id); } + Ok(Err(err)) => break 'messages Err(anyhow::anyhow!("video send failed: {err}")), + Err(_) => break 'messages Err(anyhow::anyhow!("video client stalled")), + } + } + }, message = rx.recv() => match message { Ok(msg) => { let out = match &*msg { - ServerMsg::Binary(bytes) => Message::Binary(bytes.clone()), ServerMsg::Text(text) => Message::text(text.clone()), }; @@ -334,6 +390,114 @@ mod tests { assert_eq!(random_token().len(), 48); } + #[test] + fn a_slow_video_consumer_keeps_only_the_latest_frame_of_each_camera() { + let (video, mut reader) = watch::channel(VideoFrames::new()); + for id in 1..=1000 { + publish_video(&video, &frame(1, id, 100)); + publish_video(&video, &frame(2, id + 10, 100)); + } + let latest = reader.borrow_and_update(); + assert_eq!(latest.len(), 2); + assert_eq!(latest[&1].frame_id, 1000); + assert_eq!(latest[&2].frame_id, 1010); + } + + #[tokio::test] + async fn a_reconnecting_interface_receives_cached_latest_frames_without_waiting_for_capture() { + let (listener, port) = bind().await.unwrap(); + let (control, _) = broadcast::channel(CHANNEL_CAPACITY); + let (video, _) = watch::channel(VideoFrames::new()); + for id in 1..=100 { + publish_video(&video, &frame(1, id, 100)); + publish_video(&video, &frame(2, id + 10, 100)); + } + let task = tokio::spawn(serve( + listener, + "test-token".into(), + control, + video, + Arc::new(AtomicUsize::new(0)), + )); + let (mut client, _) = + tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/?token=test-token")) + .await + .unwrap(); + let mut received = Vec::new(); + let mut timings = HashMap::new(); + while received.len() < 2 { + let message = tokio::time::timeout(Duration::from_secs(2), client.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + if let Message::Text(text) = &message { + let value: serde_json::Value = serde_json::from_str(text).unwrap(); + if value["t"] == "frameTiming" { + assert!(value["publishedUnixMs"].as_u64().unwrap() > 0); + timings.insert( + value["camera"].as_u64().unwrap() as u32, + value["frameId"].as_u64().unwrap() as u32, + ); + } + } + if let Message::Binary(bytes) = message { + let (camera, frame_id, _, _, _) = decode_frame_header(&bytes).unwrap(); + assert_eq!(timings.get(&camera), Some(&frame_id)); + received.push((camera, frame_id)); + } + } + received.sort_unstable(); + assert_eq!(received, vec![(1, 100), (2, 110)]); + client.close(None).await.unwrap(); + task.abort(); + } + + #[tokio::test] + async fn reconnect_does_not_replay_expired_cached_video() { + let (listener, port) = bind().await.unwrap(); + let (control, _) = broadcast::channel(CHANNEL_CAPACITY); + let (video, _) = watch::channel(VideoFrames::new()); + publish_video(&video, &frame(1, 1, 100)); + video.send_modify(|latest| { + latest.get_mut(&1).unwrap().published_at = Instant::now() - Duration::from_secs(1) + }); + let task = tokio::spawn(serve( + listener, + "test-token".into(), + control, + video.clone(), + Arc::new(AtomicUsize::new(0)), + )); + let (mut client, _) = + tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/?token=test-token")) + .await + .unwrap(); + let stale = tokio::time::timeout(Duration::from_millis(100), async { + while let Some(message) = client.next().await { + if matches!(message.unwrap(), Message::Binary(_)) { + return; + } + } + }) + .await; + assert!(stale.is_err()); + publish_video(&video, &frame(1, 2, 100)); + let fresh = tokio::time::timeout(Duration::from_secs(2), async { + while let Some(message) = client.next().await { + if let Message::Binary(bytes) = message.unwrap() { + return decode_frame_header(&bytes).unwrap().1; + } + } + panic!("video connection closed"); + }) + .await + .unwrap(); + assert_eq!(fresh, 2); + client.close(None).await.unwrap(); + task.abort(); + } + #[test] fn the_wire_envelope_carries_the_message_kind() { let json = serde_json::to_string(&WireMsg::Hit(HitDto { diff --git a/desktop/src-tauri/src/state.rs b/desktop/src-tauri/src/state.rs index 22a209d..5c4a57d 100644 --- a/desktop/src-tauri/src/state.rs +++ b/desktop/src-tauri/src/state.rs @@ -24,14 +24,14 @@ use cam_core::armor::{ArmorHealthStore, HealthTracker}; use cam_core::config::{Config, Role}; use cam_core::password; use cam_core::receiver::{Discovery, LatestFrames, Receiver}; -use cam_core::ArmorSource; use cam_core::slots::SlotAllocator; use cam_core::snapshot::{ - resolve_outcomes, ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, Snapshot, - SlotsDto, TeamHealthDto, + resolve_outcomes, ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, SlotsDto, + Snapshot, TeamHealthDto, }; use cam_core::stats::{FpsWindow, DEFAULT_WINDOW}; -use tokio::sync::broadcast; +use cam_core::ArmorSource; +use tokio::sync::{broadcast, watch}; use crate::recording::{now_ms, RecordingSet}; use crate::server::{self, ServerMsg, WireMsg}; @@ -67,6 +67,7 @@ pub struct AppState { pub recordings: Arc, tx: broadcast::Sender>, + video: server::VideoSender, /// 回环上已经绑好的 WebSocket listener,等 `spawn_background` 取走。 /// /// 先绑再交给后台任务,是为了让"端口绑不上"在启动阶段就报错,而不是变成一个永远 @@ -92,38 +93,43 @@ impl AppState { config: Config, mut notes: Vec, demo: bool, - ) -> Result<(Arc, Option, Option)> { + ) -> Result<( + Arc, + Option, + Option, + )> { let role = config.role; - let bound = if role == Role::Team { config.camera_id } else { None }; + let bound = if role == Role::Team { + config.camera_id + } else { + None + }; // 视频端口绑不上**不**致命:界面照常起来,并把原因写在最显眼的地方。 // // 一开始这里是 `?`(直接退出)。但那样一来,现场看到的是一次闪退加上一句只出现 // 在 stderr 的话——从访达双击启动时连 stderr 都没有。让窗口起来并说明原因,比 // 静默退出有用得多;而且"另一个客户端已经在跑"恰好是最常见的原因。 - let (receiver, video_socket) = match Receiver::start( - protocol::BROADCAST_ADDR, - role.to_protocol(), - bound, - ) { - Ok((receiver, socket)) => (Arc::new(receiver), Some(socket)), - Err(err) => { - log::error!("could not bind UDP {}: {err}", protocol::DEFAULT_PORT); - notes.push(format!( - "绑不上 UDP {}:{err}。多半是同一个端口上已经跑着另一个客户端 \ + let (receiver, video_socket) = + match Receiver::start(protocol::BROADCAST_ADDR, role.to_protocol(), bound) { + Ok((receiver, socket)) => (Arc::new(receiver), Some(socket)), + Err(err) => { + log::error!("could not bind UDP {}: {err}", protocol::DEFAULT_PORT); + notes.push(format!( + "绑不上 UDP {}:{err}。多半是同一个端口上已经跑着另一个客户端 \ (旧的 Slint 版或另一份本程序)。画面收不到,其它功能照常。", - protocol::DEFAULT_PORT - )); - ( - Arc::new(Receiver::new( - protocol::BROADCAST_ADDR, - role.to_protocol(), - bound, - )), - None, - ) - } - }; + protocol::DEFAULT_PORT + )); + ( + Arc::new(Receiver::new( + protocol::BROADCAST_ADDR, + role.to_protocol(), + bound, + )), + None, + ) + } + }; let discovery = receiver.discovery(); let latest = receiver.latest_frames(); @@ -141,6 +147,7 @@ impl AppState { }; let (tx, _) = broadcast::channel(server::CHANNEL_CAPACITY); + let (video, _) = watch::channel(server::VideoFrames::new()); let (listener, port) = tauri::async_runtime::block_on(server::bind()) .context("绑不上本地 WebSocket 端口(回环地址)")?; @@ -157,6 +164,7 @@ impl AppState { slots: Mutex::new(SlotAllocator::new()), recordings: RecordingSet::new(), tx, + video, listener: Mutex::new(Some(listener)), port, token: server::random_token(), @@ -175,15 +183,14 @@ impl AppState { /// /// 必须在 `Arc` 建好之后调用(sink 里要持有 state 的通道)。 pub fn install_sinks(self: &Arc) { - let tx = self.tx.clone(); + let video = self.video.clone(); let recordings = Arc::clone(&self.recordings); // 帧的 sink:在接收线程里跑,只做"编码一次 + 广播"。这一份字节所有界面共用。 self.receiver .set_frame_sink(Arc::new(move |frame: Arc| { recordings.feed(&frame); - let bytes = server::encode_frame(&frame); - let _ = tx.send(Arc::new(ServerMsg::Binary(bytes.into()))); + server::publish_video(&video, &frame); })); let tx = self.tx.clone(); @@ -240,9 +247,10 @@ impl AppState { let token = self.token.clone(); let tx = self.tx.clone(); let clients = Arc::clone(&self.clients); + let video = self.video.clone(); tauri::async_runtime::spawn(async move { - server::serve(listener, token, tx, clients).await; + server::serve(listener, token, tx, video, clients).await; }); let state_for_loop = Arc::clone(&state); @@ -306,7 +314,9 @@ impl AppState { // 本来就要看到全场。 let open = match role { Role::Referee => true, - Role::Team => self.discovery_open.load(Ordering::Relaxed) && self.unlocked.load(Ordering::Relaxed), + Role::Team => { + self.discovery_open.load(Ordering::Relaxed) && self.unlocked.load(Ordering::Relaxed) + } }; self.receiver.set_discovery_all(open); @@ -457,7 +467,12 @@ impl AppState { /// 推进帧率窗口与槽位分配。 pub fn tick(&self) { - let online: Vec = self.discovery.online_cameras().iter().map(|c| c.id).collect(); + let online: Vec = self + .discovery + .online_cameras() + .iter() + .map(|c| c.id) + .collect(); let online_set: HashSet = online.iter().copied().collect(); { @@ -539,8 +554,12 @@ impl AppState { }) .collect(); - let armor_boards: Vec = - self.armor.boards().iter().map(ArmorBoardDto::from).collect(); + let armor_boards: Vec = self + .armor + .boards() + .iter() + .map(ArmorBoardDto::from) + .collect(); let slots = self.slots.lock().expect("slots mutex poisoned").slots(); @@ -627,7 +646,9 @@ impl AppState { if let Some(board) = board { return match self.armor.health_of_board(board) { - Some(hp) => TeamHealthDto::present(team, Some(board), [hp; 4], low, &config.team_names), + Some(hp) => { + TeamHealthDto::present(team, Some(board), [hp; 4], low, &config.team_names) + } None => TeamHealthDto::absent(team, Some(board), &config.team_names), }; } diff --git a/desktop/src/components/VideoSurface.tsx b/desktop/src/components/VideoSurface.tsx index 13b95bb..98ea18c 100644 --- a/desktop/src/components/VideoSurface.tsx +++ b/desktop/src/components/VideoSurface.tsx @@ -23,6 +23,7 @@ import { useEffect, useRef, type ReactNode } from "react"; import { streamClient } from "../lib/store"; import { withAlpha } from "../lib/format"; import type { CrosshairConfig, GridConfig, HitFeedbackConfig } from "../lib/types"; +import type { FrameInfo } from "../lib/stream"; export interface ViewTransform { zoom: number; @@ -61,6 +62,8 @@ interface Props { hitFeedback: HitFeedbackConfig; showZoom: boolean; children?: ReactNode; + /** Only used by the opt-in screen/camera calibration page. */ + onFramePainted?: (canvas:HTMLCanvasElement,frame:FrameInfo,drawMs:number)=>void; } export function VideoSurface({ @@ -77,12 +80,13 @@ export function VideoSurface({ hitFeedback, showZoom, children, + onFramePainted, }: Props) { const canvasRef = useRef(null); // 绘制循环不随 props 重建,所以最新一份 props 放在 ref 里给它读。 - const props = useRef({ camera, mirrored, crosshair, grid, view, hit, hitFeedback }); - props.current = { camera, mirrored, crosshair, grid, view, hit, hitFeedback }; + const props = useRef({ camera, mirrored, crosshair, grid, view, hit, hitFeedback,onFramePainted }); + props.current = { camera, mirrored, crosshair, grid, view, hit, hitFeedback,onFramePainted }; const drag = useRef<{ x: number; y: number; originX: number; originY: number } | null>(null); const dirty = useRef(true); @@ -127,6 +131,7 @@ export function VideoSurface({ const current = props.current; const frame = streamClient().latest(current.camera); + const newFrame=frame && frame.frameId!==lastFrameId.current; if (frame && frame.frameId !== lastFrameId.current) { lastFrameId.current = frame.frameId; @@ -145,6 +150,7 @@ export function VideoSurface({ dirty.current = false; draw(canvas, current, frame, timestamp); + if(newFrame && current.onFramePainted) current.onFramePainted(canvas,frame,performance.now()); }; raf = requestAnimationFrame(loop); diff --git a/desktop/src/lib/latency-marker.ts b/desktop/src/lib/latency-marker.ts new file mode 100644 index 0000000..f8abb0a --- /dev/null +++ b/desktop/src/lib/latency-marker.ts @@ -0,0 +1,101 @@ +/** A screen marker observed through the real camera. No device clock sync. */ +export type Point = { x: number; y: number }; +export const CORNERS = [[255,32,32],[32,255,32],[32,96,255],[255,32,255]] as const; +export const MARKER_BITS = 16; + +export function markerBits(counter: number): number[] { + const gray=(counter&4095)^((counter&4095)>>>1); + return [1,0,1,0,...Array.from({length:12},(_,i)=>(gray>>>(11-i))&1)]; +} + +export function drawMarker(ctx: CanvasRenderingContext2D, counter:number) { + const w=ctx.canvas.width,h=ctx.canvas.height; + ctx.fillStyle='#30363e';ctx.fillRect(0,0,w,h); + // Static coloured detail keeps the target from being an unrealistically tiny JPEG. + for(let y=0;y<8;y++) for(let x=0;x<12;x++) { + ctx.fillStyle=`hsl(${(x*31+y*47)%360} 10% ${20+(x+y)%3*7}%)`; + ctx.fillRect(x*w/12,y*h/8,w/12,h/8); + } + [[.06,.08],[.94,.08],[.94,.92],[.06,.92]].forEach(([x,y],i)=>{ + ctx.fillStyle=`rgb(${CORNERS[i].join(',')})`; + ctx.fillRect(x*w-.035*w,y*h-.045*h,.07*w,.09*h); + }); + const bits=markerBits(counter); + ctx.fillStyle='#222';ctx.fillRect(.12*w,.34*h,.76*w,.32*h); + bits.forEach((bit,i)=>{ + const x=(.14+.72*i/MARKER_BITS)*w,cw=.72*w/MARKER_BITS; + ctx.fillStyle=bit?'#fff':'#000';ctx.fillRect(x,.38*h,cw,.10*h); + ctx.fillStyle=bit?'#000':'#fff';ctx.fillRect(x,.52*h,cw,.10*h); + }); +} + +function colour(r:number,g:number,b:number):number { + // Screen primaries can clip and bloom through exposure/white balance. + // Absolute channel separation admits pastel green/cyan while excluding + // the low-saturation background (which can otherwise look more blue). + if(r>150 && b>150 && r-g>40 && b-g>40 && Math.abs(r-b)<100) return 4; + if(r>150 && r-g>40 && r-b>40) return 1; + if(g>150 && g-r>35 && g-b>30) return 2; + if(b>150 && b>r*1.7 && b-g>20) return 3; + return 0; +} + +function project(corners:Point[],u:number,v:number):Point|null { + const [p0,p1,p2,p3]=corners; + const dx1=p1.x-p2.x, dx2=p3.x-p2.x, dx3=p0.x-p1.x+p2.x-p3.x; + const dy1=p1.y-p2.y, dy2=p3.y-p2.y, dy3=p0.y-p1.y+p2.y-p3.y; + const det=dx1*dy2-dx2*dy1; + if(Math.abs(det)<1e-6) return null; + const g=(dx3*dy2-dx2*dy3)/det, h=(dx1*dy3-dx3*dy1)/det; + const den=g*u+h*v+1; + if(Math.abs(den)<.01) return null; + return {x:((p1.x-p0.x+g*p1.x)*u+(p3.x-p0.x+h*p3.x)*v+p0.x)/den, + y:((p1.y-p0.y+g*p1.y)*u+(p3.y-p0.y+h*p3.y)*v+p0.y)/den}; +} + +export function decodeMarker(pixels:Uint8ClampedArray,w:number,h:number):{counter:number;corners:Point[]}|null { + const labels=new Uint8Array(w*h); + for(let i=0;i({count:0,x:0,y:0})); + const queue=new Int32Array(w*h); + for(let start=0;start0?i-1:-1,x0?i-w:-1,y=0 && labels[n]===cls) { labels[n]=0;queue[tail++]=n; } + } + } + if(count>=6 && countbest[cls-1].count) best[cls-1]={count,x:sx/count,y:sy/count}; + } + if(best.some(b=>!b.count)) return null; + const corners=best.map(({x,y})=>({x,y})); + const area=Math.abs(corners.reduce((a,p,i)=>a+p.x*corners[(i+1)%4].y-p.y*corners[(i+1)%4].x,0))/2; + if(area<1500) return null; + const luma=(x:number,y:number)=>{ + let total=0,count=0; + for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++) { + const xx=Math.round(x)+dx,yy=Math.round(y)+dy; + if(xx<0||xx>=w||yy<0||yy>=h) continue; + const i=(yy*w+xx)*4;total+=(pixels[i]+pixels[i+1]+pixels[i+2])/3;count++; + } + return count?total/count:NaN; + }; + const bits:number[]=[]; + for(let i=0;i0?1:0); + } + if(bits.slice(0,4).join('')!=='1010') return null; + let gray=bits.slice(4).reduce((n,b)=>(n<<1)|b,0),counter=gray; + while(gray>>>=1) counter^=gray; + return {counter,corners}; +} diff --git a/desktop/src/lib/store.ts b/desktop/src/lib/store.ts index 393459b..2706901 100644 --- a/desktop/src/lib/store.ts +++ b/desktop/src/lib/store.ts @@ -76,6 +76,8 @@ export function currentStore(): AppStore { function handleMessage(msg: WireMsg) { switch (msg.t) { + case "frameTiming": + return; // Consumed by StreamClient before reaching application state. case "snapshot": { const { t: _tag, ...snapshot } = msg; emit({ snapshot: snapshot as Snapshot }); diff --git a/desktop/src/lib/stream.ts b/desktop/src/lib/stream.ts index c337632..6b797da 100644 --- a/desktop/src/lib/stream.ts +++ b/desktop/src/lib/stream.ts @@ -28,7 +28,7 @@ export interface FrameInfo { width: number; height: number; bitmap: ImageBitmap; - /** 收到的时刻(`performance.now()`),界面用来显示端到端延迟。 */ + /** 本地完成解码的时刻(`performance.now()`),用于判断帧的新旧。 */ receivedAt: number; } @@ -39,6 +39,13 @@ interface Handlers { /** 断线重连的退避上限。 */ const MAX_BACKOFF_MS = 3000; +const MAX_LOCAL_VIDEO_AGE_MS = 100; + +interface PendingFrame { + bytes: ArrayBuffer; + arrivedAt: number; + publishedUnixMs?: number; +} export class StreamClient { private ws: WebSocket | null = null; @@ -48,14 +55,19 @@ export class StreamClient { private retryTimer: number | null = null; private backoff = 250; private closed = false; + private generation = 0; + private readonly pending = new Map(); + private readonly frameTimes = new Map(); + private readonly decoding = new Map(); /** 统计:收到多少帧、解码失败多少张。界面用它区分"链路没数据"和"数据坏了"。 */ - readonly counters = { frames: 0, decodeFailures: 0, reconnects: 0 }; + readonly counters = { frames: 0, decodeFailures: 0, reconnects: 0, skippedDecodes: 0, staleFrames: 0 }; - constructor(handlers: Handlers) { + constructor(handlers: Handlers, private readonly now = () => performance.now(), private readonly wallNow = () => Date.now()) { this.handlers = handlers; } connect(url: string) { + this.close(); this.url = url; this.closed = false; this.open(); @@ -76,11 +88,17 @@ export class StreamClient { this.ws = socket; socket.onopen = () => { + if (this.ws !== socket) return; this.backoff = 250; this.handlers.onConnection(true); }; socket.onclose = () => { + if (this.ws !== socket) return; + this.generation += 1; + this.pending.clear(); + this.frameTimes.clear(); + this.decoding.clear(); this.handlers.onConnection(false); this.scheduleReconnect(); }; @@ -90,9 +108,12 @@ export class StreamClient { }; socket.onmessage = (event) => { + if (this.ws !== socket) return; if (typeof event.data === "string") { try { - this.handlers.onMessage(JSON.parse(event.data) as WireMsg); + const message = JSON.parse(event.data) as WireMsg; + if (message.t === "frameTiming") this.onTiming(message); + else this.handlers.onMessage(message); } catch { // 前端解析不了的消息只可能是版本不匹配,忽略比崩掉好。 } @@ -103,31 +124,66 @@ export class StreamClient { }; } + private onTiming(message: { camera: number; frameId: number; publishedUnixMs: number }) { + if (Number.isFinite(message.publishedUnixMs) && message.publishedUnixMs > 0) { + this.frameTimes.set(message.camera, message); + } + } + + private expired(frame: PendingFrame): boolean { + return this.now() - frame.arrivedAt >= MAX_LOCAL_VIDEO_AGE_MS || + (frame.publishedUnixMs !== undefined && this.wallNow() - frame.publishedUnixMs >= MAX_LOCAL_VIDEO_AGE_MS); + } + private async onBinary(buffer: ArrayBuffer) { - if (buffer.byteLength < FRAME_HEADER_LEN) return; + if (this.closed || buffer.byteLength < FRAME_HEADER_LEN) return; const view = new DataView(buffer); const camera = view.getUint32(0, true); - const frameId = view.getUint32(4, true); - const width = view.getUint16(8, true); - const height = view.getUint16(10, true); const length = view.getUint32(12, true); - if (buffer.byteLength < FRAME_HEADER_LEN + length) return; + if (buffer.byteLength !== FRAME_HEADER_LEN + length) return; this.counters.frames += 1; + const timing = this.frameTimes.get(camera); + const received: PendingFrame = { bytes: buffer, arrivedAt: this.now(), + publishedUnixMs: timing?.frameId === view.getUint32(4, true) ? timing.publishedUnixMs : undefined }; + if (this.expired(received)) { this.counters.staleFrames += 1; return; } + if (this.pending.has(camera)) this.counters.skippedDecodes += 1; + this.pending.set(camera, received); + if (this.decoding.has(camera)) return; - const jpeg = new Uint8Array(buffer, FRAME_HEADER_LEN, length); - let bitmap: ImageBitmap; - + const generation = this.generation; + this.decoding.set(camera, generation); try { - bitmap = await createImageBitmap(new Blob([jpeg], { type: "image/jpeg" })); - } catch { - this.counters.decodeFailures += 1; - return; + while (!this.closed && this.generation === generation) { + const next = this.pending.get(camera); + if (!next) break; + this.pending.delete(camera); + if (this.expired(next)) { this.counters.staleFrames += 1; continue; } + const header = new DataView(next.bytes); + const jpeg = new Uint8Array(next.bytes, FRAME_HEADER_LEN); + let bitmap: ImageBitmap; + try { + bitmap = await createImageBitmap(new Blob([jpeg], { type: "image/jpeg" })); + } catch { + this.counters.decodeFailures += 1; + continue; + } + if (this.closed || this.generation !== generation) { + bitmap.close(); + break; + } + if (this.expired(next)) { + bitmap.close(); + this.counters.staleFrames += 1; + continue; + } + this.store(camera, header.getUint32(4, true), header.getUint16(8, true), header.getUint16(10, true), bitmap); + } + } finally { + if (this.decoding.get(camera) === generation) this.decoding.delete(camera); } - - this.store(camera, frameId, width, height, bitmap); } /** @@ -149,7 +205,9 @@ export class StreamClient { const current = this.frames.get(camera); // 带符号差处理 u32 回绕。 - if (current && ((frameId - current.frameId) | 0) <= 0) { + // After a quiet interval the board may have rebooted and restarted its + // frame counter. An older in-flight decode cannot hold the display forever. + if (current && ((frameId - current.frameId) | 0) <= 0 && this.now() - current.receivedAt < 1000) { bitmap.close(); return; } @@ -162,7 +220,7 @@ export class StreamClient { width, height, bitmap, - receivedAt: performance.now(), + receivedAt: this.now(), }); } @@ -197,6 +255,10 @@ export class StreamClient { close() { this.closed = true; + this.generation += 1; + this.pending.clear(); + this.frameTimes.clear(); + this.decoding.clear(); if (this.retryTimer !== null) { window.clearTimeout(this.retryTimer); diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index 79425c4..496688c 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -233,6 +233,7 @@ export type WireMsg = | ({ t: "snapshot" } & Snapshot) | ({ t: "hit" } & HitEvent) | { t: "note"; message: string } + | { t: "frameTiming"; camera: number; frameId: number; publishedUnixMs: number } | { t: "confirmQuit" }; /** 二进制视频帧的头部长度,与 `server::FRAME_HEADER_LEN` 一致。 */ diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 9aca81a..5ad5676 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -1,6 +1,8 @@ import { createRoot } from "react-dom/client"; import App from "./App"; +import { LatencyPage } from './pages/LatencyPage'; +import { invoke } from '@tauri-apps/api/core'; import "./styles/global.css"; const container = document.getElementById("root"); @@ -12,4 +14,7 @@ if (!container) { // 不用 StrictMode:这个界面的关键路径是命令式的(WebSocket、requestAnimationFrame 绘制 // 循环、canvas 尺寸)。StrictMode 在开发下会把每个 effect 跑两遍,于是连接与绘制循环会 // 短暂地建两份——排查起来的噪音大于它带来的收益。生产构建的行为与这里一致。 -createRoot(container).render(); +const root=createRoot(container); +if('__TAURI_INTERNALS__' in window) { + void invoke('latency_enabled').then(enabled=>root.render(enabled?:),()=>root.render()); +} else root.render(); diff --git a/desktop/src/pages/LatencyPage.tsx b/desktop/src/pages/LatencyPage.tsx new file mode 100644 index 0000000..d86f69a --- /dev/null +++ b/desktop/src/pages/LatencyPage.tsx @@ -0,0 +1,101 @@ +import { useEffect,useRef,useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { api } from '../lib/ipc'; +import { startStream,useAppStore } from '../lib/store'; +import { decodeMarker,drawMarker } from '../lib/latency-marker'; +import { VideoSurface,IDENTITY_VIEW } from '../components/VideoSurface'; +import type { FrameInfo } from '../lib/stream'; + +type Stamp={draw:number;next:number|null;sampled:boolean}; +const feedback={flash:false,shake:false,marker:false,intensity:0}; + +export function LatencyPage() { + const {snapshot,connected}=useAppStore(); + const [camera,setCamera]=useState(null); + const [status,setStatus]=useState('等待摄像头与屏幕标记'); + const [summary,setSummary]=useState({samples:0,p50:0,p95:0,refresh:0}); + const target=useRef(null); + const history=useRef(new Map()); + const scratch=useRef(null); + const values=useRef([]); + const lastSample=useRef(-Infinity); + const refresh=useRef(16.7); + const active=useRef(true); + useEffect(()=>{ + let client:ReturnType|null=null,disposed=false; + void api.streamInfo().then(info=>{if(!disposed)client=startStream(info.url);}).catch(e=>setStatus(String(e))); + return()=>{disposed=true;client?.close();}; + },[]); + useEffect(()=>{ + if(camera===null && snapshot) { + const found=snapshot.cameras.find(c=>c.online); + if(found)setCamera(found.id); + } + },[snapshot,camera]); + useEffect(()=>{ + active.current=true; + const canvas=target.current;if(!canvas)return; + const ctx=canvas.getContext('2d');if(!ctx)return; + let raf=0,counter=0,previous:Stamp|null=null,last=0,lastMarker=-Infinity; + const loop=(now:number)=>{ + if(previous){previous.next=now;previous=null;} + if(last && now-last<50)refresh.current=refresh.current*.9+(now-last)*.1; + last=now; + // A 180 Hz display can change several IDs within one camera exposure. + // Hold each event for 50 ms, and record only its first decodable echo. + if(now-lastMarker>=50){ + lastMarker=now;counter=(counter+1)&4095; + drawMarker(ctx,counter); + const stamp={draw:performance.now(),next:null,sampled:false}; + history.current.set(counter,stamp);previous=stamp; + } + // Only the most recent five seconds count, including counter wrap. + for(const [id,value] of history.current) if(now-value.draw>5000)history.current.delete(id); + raf=requestAnimationFrame(loop); + }; + raf=requestAnimationFrame(loop); + return()=>{active.current=false;cancelAnimationFrame(raf);}; + },[]); + const painted=(canvas:HTMLCanvasElement,frame:FrameInfo,drawMs:number)=>{ + if(drawMs-lastSample.current<25)return; + lastSample.current=drawMs; + const started=performance.now(); + const small=scratch.current??(scratch.current=document.createElement('canvas')); + small.width=320;small.height=240; + const ctx=small.getContext('2d',{willReadFrequently:true});if(!ctx)return; + // Read the canvas after the production renderer has painted the frame. + ctx.drawImage(canvas,0,0,320,240); + const marker=decodeMarker(ctx.getImageData(0,0,320,240).data,320,240); + const analysisMs=performance.now()-started; + if(!marker){setStatus('未识别到四角标记:请让左侧标记完整入镜并尽量拍大');return;} + const source=history.current.get(marker.counter); + if(!source||source.next===null||source.draw>drawMs){setStatus('等待有效的同一会话标记');return;} + if(source.sampled)return; + source.sampled=true; + requestAnimationFrame(nextRafMs=>{ + if(!active.current)return; + const lowerMs=Math.max(0,drawMs-source.next!); + const upperMs=nextRafMs-source.draw; + if(upperMs5000)return; + const value=(lowerMs+upperMs)/2; + values.current.push(value); + const sorted=[...values.current].sort((a,b)=>a-b); + setStatus('标记已锁定,正在记录真实相机 → 客户端画布链路'); + setSummary({samples:sorted.length,p50:sorted[Math.floor((sorted.length-1)*.5)],p95:sorted[Math.floor((sorted.length-1)*.95)],refresh:refresh.current}); + void invoke('latency_sample',{sample:{frameId:frame.frameId,counter:marker.counter,sourceDrawMs:source.draw, + drawMs,nextRafMs,lowerMs,upperMs,decodeToDrawMs:drawMs-frame.receivedAt,analysisMs}}).catch(e=>setStatus(`报告写入失败:${String(e)}`)); + }); + }; + return
+

屏幕 → 摄像头 → 客户端绘制延迟校准

+

将摄像头固定对准左侧标记,完整拍到四个彩色角块。不要拍右侧回显区域;保持窗口位置不动。

+
+ +
{}} hitFeedback={feedback} showZoom={false} onFramePainted={painted}/>
+
+

{connected?'已连接':'连接中'} · {status}

+

样本 {summary.samples} · P50 {summary.p50.toFixed(1)} ms · P95 {summary.p95.toFixed(1)} ms

+

每 50 ms 更新标记,记录首次可解码回显,包含相机取样等待、曝光、采集、传输和绘制;混合曝光帧不计入。相邻 rAF 夹定绘制时间,刷新间隔约 {summary.refresh.toFixed(1)} ms;面板像素响应需高速相机复核。

+
; +} diff --git a/partitions.csv b/partitions.csv index e0cda0d..b380623 100644 --- a/partitions.csv +++ b/partitions.csv @@ -1,17 +1,18 @@ # Name, Type, SubType, Offset, Size, Flags # -# 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 +# The app partition offset must be validated against each rebuilt image. 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 +# because a rebuild can shift the image again, keeping an unchecked offset # 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 # BAD_FLASH_ADDR from scripts/flash.sh; nothing else depends on this. +# The same module also showed bit 6 read back high at 0x10863; keep factory >=0x20000. nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, -factory, app, factory, 0x060000, 0x120000, \ No newline at end of file +factory, app, factory, 0x070000, 0x120000, diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs index 8334d9b..8a4d902 100644 --- a/protocol/src/lib.rs +++ b/protocol/src/lib.rs @@ -305,16 +305,8 @@ impl Fragment { return None; } - // 一帧至少一个分片,且分片下标必须落在范围内。 - if header.frag_count == 0 || header.frag_index >= header.frag_count { - return None; - } - let payload = &bytes[HEADER_LEN..]; - - // JPEG 不可能比承载它的分片还短。检查很便宜,却能在造成一次大额分配之前 - // 挡掉损坏的头。 - if payload.len() as u64 > header.total_len as u64 { + if !valid_fragment_layout(&header, payload.len()) { return None; } @@ -325,6 +317,17 @@ impl Fragment { } } +fn valid_fragment_layout(header: &Header, payload_len: usize) -> bool { + let size = header.total_len as u64; + let chunk = MAX_FRAGMENT_PAYLOAD as u64; + let count = ((size + chunk - 1) / chunk).max(1); + let offset = header.frag_index as u64 * chunk; + header.frag_count as u64 == count + && header.frag_index < header.frag_count + && offset <= size + && payload_len as u64 == (size - offset).min(chunk) +} + /// 一张完整重组好的 JPEG。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct Frame { @@ -572,6 +575,10 @@ pub struct FrameAssembler { /// 存在的原因是 `seen` 会被重复包灌水:UDP 会重复投递,一个丢失的分片可以 /// 被另一个包的副本凑数,于是"收够了"却留着空洞,交出去一张尾部是 0 的坏图。 have: Vec, + total_len: u32, + latest_id: Option, + last_fragment_at: Option, + dropped_frames: u64, } impl FrameAssembler { @@ -588,6 +595,10 @@ impl FrameAssembler { timeout, seen: 0, have: Vec::new(), + total_len: 0, + latest_id: None, + last_fragment_at: None, + dropped_frames: 0, } } @@ -596,31 +607,52 @@ impl FrameAssembler { self.sender_id } + /// 未收齐、超时以及完全缺失的帧累计数;等待分片和重复包不增加此值。 + pub fn dropped_frames(&self) -> u64 { + self.dropped_frames + } + /// 收到一个分片。 /// /// 这张分片补齐一张帧时返回 [`Frame`]。 /// - /// `Err(Superseded)` 表示这个分片属于一张**已被放弃**的帧。多路复用时(裁判 - /// 终端同时收两块板子)必须每个发送方一个重组器——混在一起会让两块板子的分片 - /// 互相顶掉。这里不做检查,是调用方的责任。 + /// 为兼容已有调用,等待更多分片也返回 `Err(Superseded)`;丢帧统计应读取 + /// `dropped_frames()` 的增量,不能把每次 Err 当作丢帧或全部忽略。 pub fn push(&mut self, frag: Fragment) -> Result { + if !valid_fragment_layout(&frag.header, frag.payload.len()) { + return Err(DropReason::Superseded); + } let sender_id = frag.header.sender_id; let frame_id = frag.header.frame_id; // 换了发送方:上一张属于别人的帧就此作废。 if self.sender_id != 0 && self.sender_id != sender_id { self.reset(); + self.latest_id = None; + self.last_fragment_at = None; } self.sender_id = sender_id; - let superseded = match self.current_id { - None => false, - Some(id) => id != frame_id, - }; - - if superseded { - self.reset(); + if let Some(latest) = self.latest_id { + let distance = frame_id.wrapping_sub(latest) as i32; + let restarted = distance < 0 && self.last_fragment_at.is_some_and(|at| { + at.elapsed() >= self.timeout.max(std::time::Duration::from_secs(1)) + }); + if restarted { + self.reset(); + self.latest_id = None; + } else if distance < 0 || (distance == 0 && self.current_id.is_none()) { + // A late packet cannot replace a newer frame or republish one + // already completed/expired. Signed subtraction handles u32 wrap. + return Err(DropReason::Superseded); + } else if distance > 0 { + self.dropped_frames += u64::from(self.current_id.is_some()) + + distance.saturating_sub(1) as u64; + self.reset(); + } } + self.latest_id = Some(frame_id); + self.last_fragment_at = Some(std::time::Instant::now()); match self.current_id { None => { @@ -629,6 +661,7 @@ impl FrameAssembler { self.buffer.clear(); // 按 total_len 预留,最后拼接时不再重新分配。 self.buffer.reserve(frag.header.total_len as usize); + self.total_len = frag.header.total_len; self.width = frag.header.width; self.height = frag.header.height; self.since = Some(std::time::Instant::now()); @@ -636,7 +669,14 @@ impl FrameAssembler { self.have.clear(); self.have.resize(self.expected, false); } - Some(id) if id == frame_id => {} + Some(id) if id == frame_id => { + if frag.header.total_len != self.total_len + || frag.header.frag_count as usize != self.expected + || frag.header.width != self.width || frag.header.height != self.height + { + return Err(DropReason::Superseded); + } + } _ => unreachable!("current_id was just reconciled with frame_id"), } @@ -697,6 +737,7 @@ impl FrameAssembler { pub fn expire(&mut self) -> Option { match self.since { Some(started) if started.elapsed() > self.timeout => { + self.dropped_frames += 1; self.reset(); Some(DropReason::TimedOut) } @@ -712,11 +753,10 @@ impl FrameAssembler { fn reset(&mut self) { self.current_id = None; self.expected = 0; - self.buffer = Vec::new(); - self.buffer.shrink_to_fit(); + self.buffer.clear(); self.since = None; self.seen = 0; - self.have = Vec::new(); + self.have.clear(); } } @@ -1013,4 +1053,80 @@ mod tests { assert!(f.encode().len() < 1500, "must fit an Ethernet MTU"); } } -} \ No newline at end of file + + #[test] + fn counts_partial_and_wholly_missing_frames_without_counting_pending_packets() { + let mut asm = FrameAssembler::new(std::time::Duration::from_secs(1)); + let partial = fragment_frame(1, 10, &[1; 3000], 640, 480); + let _ = asm.push(partial[0].clone()); + let _ = asm.push(partial[0].clone()); + assert_eq!(asm.dropped_frames(), 0); + let next = fragment_frame(1, 13, &[2; 3000], 640, 480); + let _ = asm.push(next[0].clone()); + assert_eq!(asm.dropped_frames(), 3, "partial 10 plus missing 11 and 12"); + let _ = asm.push(next[1].clone()); + assert!(asm.push(next[2].clone()).is_ok()); + assert_eq!(asm.dropped_frames(), 3); + } + + #[test] + fn late_packets_cannot_replace_newer_frames_or_publish_a_frame_twice() { + let mut asm = FrameAssembler::new(std::time::Duration::from_secs(1)); + let old = fragment_frame(1, 1, &[1; 3000], 640, 480); + let new = fragment_frame(1, 2, &[2; 3000], 640, 480); + let _ = asm.push(old[0].clone()); + let _ = asm.push(new[0].clone()); + let _ = asm.push(old[1].clone()); + let _ = asm.push(new[2].clone()); + let completed = asm.push(new[1].clone()).unwrap(); + assert_eq!(completed.frame_id, 2); + assert_eq!(completed.data, vec![2; 3000]); + for frag in new { assert!(asm.push(frag).is_err()); } + assert!(!asm.is_in_progress()); + assert_eq!(asm.dropped_frames(), 1); + } + + #[test] + fn frame_ids_wrap_and_restart_after_a_quiet_period() { + let mut asm = FrameAssembler::new(std::time::Duration::from_millis(100)); + for id in [u32::MAX, 0, 1, 100] { + assert_eq!(asm.push(fragment_frame(1, id, &[1], 1, 1).remove(0)).unwrap().frame_id, id); + } + let before_restart = asm.dropped_frames(); + asm.last_fragment_at = Some(std::time::Instant::now() - std::time::Duration::from_secs(2)); + assert!(asm.push(fragment_frame(1, 1, &[1], 1, 1).remove(0)).is_ok()); + assert_eq!(asm.dropped_frames(), before_restart, "a reboot is not a huge frame gap"); + } + + #[test] + fn an_expired_frame_is_counted_once_and_cannot_be_resurrected() { + let mut asm = FrameAssembler::new(std::time::Duration::from_millis(100)); + let frags = fragment_frame(1, 1, &[1; 3000], 640, 480); + let _ = asm.push(frags[0].clone()); + asm.since = Some(std::time::Instant::now() - std::time::Duration::from_secs(1)); + assert_eq!(asm.expire(), Some(DropReason::TimedOut)); + assert_eq!(asm.expire(), None); + for frag in frags { assert!(asm.push(frag).is_err()); } + assert!(!asm.is_in_progress()); + assert_eq!(asm.dropped_frames(), 1); + } + + #[test] + fn contradictory_fragment_lengths_and_metadata_are_ignored() { + let mut asm = FrameAssembler::new(std::time::Duration::from_secs(1)); + let frags = fragment_frame(1, 1, &[1; 3000], 640, 480); + let mut bad = frags[0].clone(); + bad.header.frag_count += 1; + assert!(Fragment::decode(&bad.encode()).is_none()); + let mut short = frags[0].clone(); + short.payload.pop(); + assert!(Fragment::decode(&short.encode()).is_none()); + let _ = asm.push(frags[0].clone()); + let mut changed = frags[1].clone(); + changed.header.width = 320; + assert!(asm.push(changed).is_err()); + let _ = asm.push(frags[1].clone()); + assert_eq!(asm.push(frags[2].clone()).unwrap().width, 640); + assert_eq!(asm.dropped_frames(), 0); + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 4c3b310..5f44147 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,4 @@ [toolchain] -channel = "esp" -components = ["rust-src"] \ No newline at end of file +# Installed with: espup install --name esp-1.97.0.0 --toolchain-version 1.97.0.0 --targets esp32s3 --std +channel = "esp-1.97.0.0" +components = ["rust-src"] diff --git a/scripts/analyze_latency.py b/scripts/analyze_latency.py new file mode 100644 index 0000000..41d46fe --- /dev/null +++ b/scripts/analyze_latency.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Summarize opt-in client marker reports using explicit, half-open row ranges.""" +import argparse +import json +import math +from pathlib import Path +import statistics + + +def describe(values): + ordered = sorted(values) + if not ordered: + raise ValueError('No valid marker samples in the selected range') + return { + 'count': len(ordered), + 'mean': statistics.mean(ordered), + 'p50': statistics.median(ordered), + 'p95': ordered[int((len(ordered)-1)*.95)], + 'p99': ordered[int((len(ordered)-1)*.99)], + 'minimum': ordered[0], + 'maximum': ordered[-1], + } + + +def summarize(rows, selection): + start, end = selection['start_row'], selection['end_row'] + if not 0 <= start < end <= len(rows): + raise ValueError('Range must select complete rows from the report') + samples = rows[start:end] + seen = set() + for sample in samples: + if not all(isinstance(v, (int, float)) and math.isfinite(v) for v in sample.values()): + raise ValueError('Sample contains a nonfinite value') + if not 0 <= sample['lowerMs'] <= sample['upperMs'] <= 5000: + raise ValueError('Invalid refresh bracket') + key = (sample['counter'], sample['sourceDrawMs']) + if key in seen: + raise ValueError('A source marker was counted more than once') + seen.add(key) + return { + **selection, + 'samples': len(samples), + 'sample_span_seconds': (samples[-1]['drawMs']-samples[0]['drawMs'])/1000, + 'marker_event_to_paint_ms': describe([(s['lowerMs']+s['upperMs'])/2 for s in samples]), + 'lower_bound_ms': describe([s['lowerMs'] for s in samples]), + 'upper_bound_ms': describe([s['upperMs'] for s in samples]), + 'refresh_bracket_width_ms': describe([s['upperMs']-s['lowerMs'] for s in samples]), + 'decoded_to_draw_ms': describe([s['decodeToDrawMs'] for s in samples]), + 'marker_analysis_ms': describe([s['analysisMs'] for s in samples]), + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--range', type=Path, action='append', required=True, dest='ranges') + parser.add_argument('--out', type=Path, required=True) + args = parser.parse_args() + rows = [json.loads(line) for line in args.report.read_text().splitlines() if line.strip()] + trials = [summarize(rows, json.loads(path.read_text())) for path in args.ranges] + result = { + 'method': '50 ms source marker events; first decodable echo on production VideoSurface; midpoint of adjacent-rAF brackets', + 'scope': 'Source-screen marker to camera exposure/capture, Wi-Fi/UDP, native reassembly, local WebSocket, JPEG decode and canvas draw', + 'limitations': [ + 'rAF brackets estimate display scheduling; panel scanout and pixel response are not measured', + 'Mixed-exposure or unrecognized markers are excluded; first decodable echo can follow an earlier ambiguous image', + 'Not every displayed frame is a sample; accepted samples describe this calibration scene and network', + 'decodeToDrawMs starts after JPEG decoding, not at UDP or WebSocket arrival', + ], + 'physical_panel_response_measured': False, + 'trials': trials, + } + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') + for trial in trials: + print(json.dumps({k: trial[k] for k in ('fb_count', 'samples', 'marker_event_to_paint_ms')})) + + +if __name__ == '__main__': + main() diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..09662e4 --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,56 @@ +# Build the ESP32-S3 firmware from PowerShell without executing .env as code. +[CmdletBinding()] +param( + [string]$EnvFile = '.env', + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$CargoArgs = @() +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Push-Location (Split-Path -Parent $PSScriptRoot) +$buildEnvironment = @{} +foreach ($name in @('CARGO_WORKSPACE_DIR', 'ESP_IDF_GLOB_BASE', 'ESP_IDF_GLOB_PARTITIONS')) { + $buildEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') +} +try { + if (Test-Path -LiteralPath $EnvFile) { + $lineNumber = 0 + foreach ($line in Get-Content -LiteralPath $EnvFile -Encoding UTF8) { + $lineNumber++ + if ($line -match '^\s*(#|$)') { continue } + if ($line -notmatch '^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$') { + throw "Invalid assignment in ${EnvFile}:${lineNumber}" + } + $key = $Matches[1] + $value = $Matches[2].Trim() + if ($value.StartsWith("'") -or $value.StartsWith('"')) { + $quote = $value.Substring(0, 1) + if ($value.Length -lt 2 -or -not $value.EndsWith($quote)) { + throw "Unclosed quote in ${EnvFile}:${lineNumber}" + } + $value = $value.Substring(1, $value.Length - 2) + } else { + $value = ($value -replace '\s+#.*$', '').TrimEnd() + } + [Environment]::SetEnvironmentVariable($key, $value, 'Process') + } + } + + if ([string]::IsNullOrWhiteSpace($env:WIFI_SSID) -or $env:WIFI_SSID -eq 'your-ssid') { + throw 'Set WIFI_SSID in .env (copy .env.example) or the environment before building.' + } + if ($null -eq $env:WIFI_PASS) { $env:WIFI_PASS = '' } + + $env:CARGO_WORKSPACE_DIR = (Get-Location).Path + $env:ESP_IDF_GLOB_BASE = (Get-Location).Path + $env:ESP_IDF_GLOB_PARTITIONS = 'partitions.csv' + + & cargo build --release --locked @CargoArgs + if ($LASTEXITCODE -ne 0) { throw "cargo build failed (exit $LASTEXITCODE)." } +} finally { + foreach ($name in $buildEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($name, $buildEnvironment[$name], 'Process') + } + Pop-Location +} diff --git a/scripts/build.sh b/scripts/build.sh index a1fb672..2004548 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,14 +1,18 @@ #!/usr/bin/env bash # Builds the firmware with the credentials from .env (see .env.example). # -# ESP-IDF location comes from .cargo/config.toml (IDF_PATH / -# ESP_IDF_TOOLS_INSTALL_DIR), so no ESP-IDF activation is needed here as long as -# those point at an installation you already have. Drop them to let esp-idf-sys -# fetch its own pinned ESP-IDF. +# esp-idf-sys downloads the pinned ESP-IDF automatically. Set IDF_PATH in the +# environment to reuse an existing installation (see README.md). set -euo pipefail cd "$(dirname "$0")/.." +# Resolve the SDK defaults/components from this repository even when the build +# cache is elsewhere, and copy the partition table into the CMake project. +export CARGO_WORKSPACE_DIR="$PWD" +export ESP_IDF_GLOB_BASE="$PWD" +export ESP_IDF_GLOB_PARTITIONS="partitions.csv" + if [ -f .env ]; then set -a # shellcheck disable=SC1091 @@ -19,4 +23,4 @@ fi : "${WIFI_SSID:?set WIFI_SSID in .env (or export it) before building}" export WIFI_PASS="${WIFI_PASS:-}" -cargo build --release "$@" \ No newline at end of file +cargo build --release --locked "$@" diff --git a/scripts/flash.sh b/scripts/flash.sh index 5e62c53..ab1f39b 100755 --- a/scripts/flash.sh +++ b/scripts/flash.sh @@ -16,6 +16,9 @@ cd "$(dirname "$0")/.." readonly BAD_FLASH_ADDR="${BAD_FLASH_ADDR:-0xFADE7}" readonly ALIGN="${ALIGN:-0x10000}" +# The same module also showed a high-bit write mismatch at 0x10863. Keep the application +# above its sector; this does not remove the separate 0xFADE7 constraint. +readonly MIN_APP_OFFSET="${MIN_APP_OFFSET:-0x20000}" readonly MAX_APP_SIZE="${MAX_APP_SIZE:-0x120000}" MONITOR_SECS=25 @@ -47,7 +50,7 @@ data = open("/tmp/esp32-cam-app.bin", "rb").read() bad = $BAD_FLASH_ADDR align = $ALIGN flash_size = 16 * 1024 * 1024 -for off in range(align, flash_size - len(data), align): +for off in range(max(align, $MIN_APP_OFFSET), flash_size - len(data), align): at = bad - off if at < 0 or at >= len(data): continue @@ -125,4 +128,4 @@ while time.time() < end: sys.stdout.write(d.decode("utf-8", "replace")) sys.stdout.flush() PY -fi \ No newline at end of file +fi diff --git a/scripts/perf_probe.py b/scripts/perf_probe.py new file mode 100644 index 0000000..bdae984 --- /dev/null +++ b/scripts/perf_probe.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Measure real UDP video throughput, complete-frame loss and receive jitter. + +Uses an ephemeral port by default so it can run beside the desktop client. +The timings are receive-side metrics, not camera-to-display latency. +""" +import argparse +from collections import Counter +import json +from pathlib import Path +import socket +import secrets +import struct +import time + +from udp_probe import decode + +PORT = 5600 +FRAGMENT = 1400 + + +def percentile(values, p): + if not values: + return None + values = sorted(values) + return round(values[int((len(values) - 1) * p)], 3) + + +def summary(source, duration): + counts = source['counts'] + frames = counts['complete_frames'] + dropped = counts['superseded_frames'] + counts['missing_frames'] + counts['invalid_jpeg'] + counts['timed_out_frames'] + span = source['last_complete'] - source['first_complete'] if frames > 1 else 0 + return dict( + address=source['address'], counts=dict(counts), + window_fps=round(frames / duration, 3) if duration else 0, + active_fps=round((frames - 1) / span, 3) if span else 0, + frame_loss_percent=round(100 * dropped / max(1, frames + dropped), 3), + resolution=source.get('resolution'), + mean_jpeg_bytes=round(counts['jpeg_bytes'] / frames) if frames else 0, + inter_frame_p50_ms=percentile(source['intervals'], .5), + inter_frame_p95_ms=percentile(source['intervals'], .95), + inter_frame_max_ms=round(max(source['intervals']), 3) if source['intervals'] else None, + assembly_p95_ms=percentile(source['assembly'], .95), + pending_frames=int(source['pending'] is not None), + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('host', help='Board IPv4 address; avoids VPN limited-broadcast routing') + parser.add_argument('--seconds', type=float, default=30) + parser.add_argument('--local-port', type=int, default=0) + parser.add_argument('--local-ip', default='') + parser.add_argument('--receive-buffer', type=int, default=1024 * 1024, help='0 keeps the OS default') + parser.add_argument('--json', type=Path) + parser.add_argument('--save-frame', type=Path) + args = parser.parse_args() + client_id = secrets.randbelow(0xFFFFFFFF) + 1 + if client_id == 0x0C110001: + client_id += 1 + subscribe = struct.pack(' .5: + source['counts']['timed_out_frames'] += 1 + source['pending'] = None + if now >= renew: + sock.sendto(subscribe, (args.host, PORT)) + renew = now + .8 + if now >= report_at: + frames = sum(s['counts']['complete_frames'] for s in sources.values()) + print(f'{now-started:5.1f}s {(frames-previous_frames)/(now-previous_report):5.1f} fps complete={frames}', flush=True) + previous_frames, previous_report, report_at = frames, now, now + 5 + try: + data, address = sock.recvfrom(2048) + except socket.timeout: + continue + now = time.perf_counter() + parsed = decode(data) + if parsed is None: + invalid += 1 + continue + kind, h, payload = parsed + if kind != 1: + continue + size = h['total_len'] + index, count = h['frag_index'], h['frag_count'] + offset = index * FRAGMENT + if h['version'] != 1 or size > 4 * 1024 * 1024 or count != max(1, (size + FRAGMENT - 1) // FRAGMENT) or index >= count or offset > size or len(payload) != min(FRAGMENT, size-offset): + invalid += 1 + continue + sender, frame = h['sender_id'], h['frame_id'] + if sender not in sources: + sources[sender] = dict(address=address[0], counts=Counter(), latest=None, last_seen=now, pending=None, intervals=[], assembly=[], first_complete=None, last_complete=None) + s = sources[sender] + counts = s['counts'] + counts['video_datagrams'] += 1 + latest = s['latest'] + if latest is not None: + distance = (frame - latest) & 0xFFFFFFFF + if distance >= 0x80000000 and now-s['last_seen'] >= 1: + counts['restarts'] += 1 + latest = s['latest'] = None + s['pending'] = None + elif distance >= 0x80000000 or (distance == 0 and s['pending'] is None): + counts['late_or_repeated_datagrams'] += 1 + continue + elif distance > 0: + counts['missing_frames'] += distance - 1 + counts['superseded_frames'] += int(s['pending'] is not None) + s['pending'] = None + s['latest'], s['last_seen'] = frame, now + if s['pending'] is None: + s['pending'] = dict(buffer=bytearray(size), seen=set(), count=count, size=size, width=h['width'], height=h['height'], started=now) + pending = s['pending'] + if (size, count, h['width'], h['height']) != (pending['size'], pending['count'], pending['width'], pending['height']): + counts['inconsistent_metadata'] += 1 + continue + if index in pending['seen']: + counts['duplicate_fragments'] += 1 + continue + pending['seen'].add(index) + pending['buffer'][offset:offset+len(payload)] = payload + if len(pending['seen']) != count: + continue + jpeg = bytes(pending['buffer']) + if jpeg[:2] == b'\xff\xd8' and jpeg[-2:] == b'\xff\xd9': + completed = time.perf_counter() + counts['complete_frames'] += 1 + counts['jpeg_bytes'] += size + if s['last_complete'] is not None: + s['intervals'].append((completed-s['last_complete'])*1000) + s['last_complete'] = completed + if s['first_complete'] is None: + s['first_complete'] = completed + s['assembly'].append((completed-pending['started'])*1000) + s['resolution'] = [h['width'], h['height']] + if args.save_frame and not saved: + args.save_frame.write_bytes(jpeg) + saved = True + else: + counts['invalid_jpeg'] += 1 + s['pending'] = None + finally: + sock.close() + elapsed = time.perf_counter()-started + result = dict(seconds=round(elapsed, 3), host=args.host, local_port=local_port, receive_buffer_bytes=buffer_bytes, invalid_datagrams=invalid, sources={f'0x{sender:08X}': summary(s, elapsed) for sender, s in sources.items()}, timing_scope='receiver inter-frame spacing and first-to-last fragment assembly; not camera-to-display latency') + if args.json: + args.json.write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if any(s['counts']['complete_frames'] for s in sources.values()) else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/serial_soak.py b/scripts/serial_soak.py new file mode 100644 index 0000000..9107a42 --- /dev/null +++ b/scripts/serial_soak.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Record firmware telemetry continuously (requires pyserial; no UDP subscriber).""" +import argparse +from datetime import datetime, timezone +import json +import math +from pathlib import Path +import re +import sys +import time + +import serial + + +FIELDS = { + 'submitted_fps': (r'([\d.]+) fps submitted', float), + 'attempted_fps': (r'([\d.]+) fps attempted', float), + 'kilobytes_per_second': (r'([\d.]+) kB/s', float), + 'send_failures': (r'(\d+) send failures', int), + 'retries': (r'(\d+) retries', int), + 'peer_frames_dropped': (r'(\d+) peer frames dropped', int), + 'send_ms': (r'([\d.]+) ms/send', float), + 'subscribers': (r'(\d+) clients subscribed', int), + 'heap_free': (r'internal heap (\d+) free', int), + 'heap_largest': (r'free / (\d+) largest bytes', int), + 'rssi_dbm': (r'RSSI (-?\d+) dBm', int), + 'max_tx_power_dbm': (r'max TX power ([\w.+-]+) dBm', float), + 'chip_temperature_c': (r'chip temp ([\w.+-]+) C', float), + 'target_fps': (r'target (\d+) fps', int), + 'capture_errors': (r'capture errors (\d+)', int), + 'exposure_lines': (r'exposure (-?\d+) lines', int), + 'jpeg_quality': (r'jpeg quality (\d+)', int), + 'gain_multiplier': (r'gain ([\d.]+)x', float), + 'capture_queue_skipped': (r'capture queue skipped (\d+)', int), + 'stale_frames_dropped': (r'stale frames dropped (\d+)', int), + 'deadline_peer_drops': (r'deadline peer drops (\d+)', int), + 'max_submitted_capture_age_ms': (r'max submitted capture age ([\d.]+) ms', float), + 'max_send_hold_ms': (r'max send hold ([\d.]+) ms', float), +} + + +def parse_sample(line): + if 'fps submitted' not in line: + return None + sample = {} + match = re.search(r'\((\d+)\)', line) + if match: + sample['uptime_ms'] = int(match[1]) + for name, (pattern, convert) in FIELDS.items(): + match = re.search(pattern, line) + if match: + value = convert(match[1]) + sample[name] = value if not isinstance(value, float) or math.isfinite(value) else None + sample['thermal_limited'] = 'thermal_limited true' in line + return sample + + +def write_json(path, data): + temporary = path.with_suffix(path.suffix+'.tmp') + temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') + temporary.replace(path) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--port', default='COM7') + parser.add_argument('--seconds', type=float, default=1860) + parser.add_argument('--out', type=Path, required=True, help='Output path prefix') + parser.add_argument('--reset', action='store_true') + parser.add_argument('--stop-file', type=Path, help='Finish cleanly when this file exists') + args = parser.parse_args() + args.out.parent.mkdir(parents=True, exist_ok=True) + port = serial.Serial() + port.port, port.baudrate, port.timeout = args.port, 115200, .2 + port.dtr = port.rts = False + records, events = [], [] + started = time.perf_counter() + last_uptime, reboot_count, last_sample_at = None, 0, None + serial_error = None + try: + port.open() + if args.reset: + port.rts = True + time.sleep(.15) + port.rts = False + with args.out.with_suffix('.serial.log').open('w', encoding='utf-8') as raw, args.out.with_suffix('.telemetry.ndjson').open('w', encoding='utf-8') as telemetry: + while time.perf_counter()-started < args.seconds: + if args.stop_file and args.stop_file.exists(): + break + line = port.readline().decode('utf-8', errors='replace') + if not line: + continue + raw.write(line) + raw.flush() + elapsed = time.perf_counter()-started + sample = parse_sample(line) + if sample: + sample.update(elapsed_seconds=round(elapsed, 3), utc=datetime.now(timezone.utc).isoformat()) + uptime = sample.get('uptime_ms') + if uptime is not None and last_uptime is not None and uptime+1000 < last_uptime: + reboot_count += 1 + last_uptime = uptime + last_sample_at = elapsed + records.append(sample) + telemetry.write(json.dumps(sample)+'\n') + telemetry.flush() + write_json(args.out.with_suffix('.latest.json'), dict(elapsed_seconds=round(elapsed, 3), samples=len(records), reboot_count=reboot_count, latest=sample)) + if len(records) % 12 == 0: + print(json.dumps(dict(minutes=round(elapsed/60, 1), reboot_count=reboot_count, latest=sample)), flush=True) + elif re.search(r'panic|Guru Meditation|Brownout|watchdog|NO-EOI|OVF|thermal load limit|reset reason|capture cap|configured capture', line, re.I): + events.append(dict(elapsed_seconds=round(elapsed, 3), text=line.strip())) + except (serial.SerialException, OSError) as error: + serial_error = str(error) + finally: + port.close() + result = dict(elapsed_seconds=round(time.perf_counter()-started, 3), requested_seconds=args.seconds, stopped_by_request=bool(args.stop_file and args.stop_file.exists()), serial_error=serial_error, samples=len(records), reboot_count=reboot_count, last_sample_at=last_sample_at, events=events, records=records) + write_json(args.out.with_suffix('.summary.json'), result) + print(json.dumps({k:v for k,v in result.items() if k not in ('records', 'events')})) + if serial_error: + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/scripts/test-latest-frame.py b/scripts/test-latest-frame.py new file mode 100644 index 0000000..6255d06 --- /dev/null +++ b/scripts/test-latest-frame.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Compile the actual driver handoff function with deterministic queue stubs.""" +from pathlib import Path +import shutil +import subprocess +import sys + +root = Path(__file__).resolve().parents[1] +source = (root/'components/esp32-camera/driver/cam_hal.c').read_text() +start = source.index('camera_fb_t *cam_take_latest(') +end = source.index('\nvoid cam_give(', start) +function = source[start:end] +out = root/'target/latest-frame-test' +out.mkdir(parents=True, exist_ok=True) +test = r''' +#include +#include +#include +#include +typedef unsigned TickType_t; +typedef struct { int id; } camera_fb_t; +static struct { size_t frame_cnt; } object = {3}, *cam_obj = &object; +static camera_fb_t frames[] = {{0},{1},{2},{3},{4},{5}}; +static int calls, releases[6], mode; +static camera_fb_t *cam_take(TickType_t wait) { + assert(wait == (calls == 0 ? 100 : 0)); + ++calls; + if (mode == 1 || (mode == 2 && calls == 2)) return NULL; + assert(calls < 6); + return &frames[calls]; +} +static void cam_give(camera_fb_t *frame) { releases[frame->id]++; } +'''+function+r''' +static void reset(int scenario) { + mode=scenario; calls=0; + for(int i=0;i<6;i++) releases[i]=0; +} +int main(void) { + uint32_t skipped=99; + // A producer that stays ahead cannot make the drain loop run forever. + reset(0); + camera_fb_t *latest=cam_take_latest(100,&skipped); + assert(calls==3 && latest->id==3 && skipped==2); + assert(releases[1]==1 && releases[2]==1 && releases[3]==0); + // An unavailable/invalid newer candidate must not release our valid frame. + reset(2); + latest=cam_take_latest(100,&skipped); + assert(calls==2 && latest->id==1 && skipped==0 && releases[1]==0); + // A first-frame timeout has no handle to return, and resets the skip count. + reset(1); + assert(cam_take_latest(100,&skipped)==NULL && skipped==0 && calls==1); + // A single capture buffer performs no second, nonblocking acquisition. + reset(0); object.frame_cnt=1; + latest=cam_take_latest(100,NULL); + assert(calls==1 && latest->id==1 && releases[1]==0); + puts("4 latest-frame ownership and bounded-drain scenarios passed"); +} +''' +(out/'latest-frame.c').write_text(test) +compiler = shutil.which('clang') or shutil.which('gcc') +if not compiler: + sys.exit('A host clang or gcc is required for this optional driver test') +binary = out/('latest-frame.exe' if sys.platform=='win32' else 'latest-frame') +subprocess.run([compiler,'-std=c11','-Wall','-Wextra','-Werror',str(out/'latest-frame.c'),'-o',str(binary)],check=True) +subprocess.run([str(binary)],check=True) diff --git a/scripts/udp_probe.py b/scripts/udp_probe.py index 9ddd8ae..a691f4e 100644 --- a/scripts/udp_probe.py +++ b/scripts/udp_probe.py @@ -106,8 +106,12 @@ def main(): prev_frames = 0 prev_pkts = 0 prev_drops = 0 + last_subscribe = start while time.time() - start < duration: + if time.time() - last_subscribe >= 0.8: + sock.sendto(sub, (host, PORT)) + last_subscribe = time.time() try: data, addr = sock.recvfrom(2048) except socket.timeout: diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 8f8bded..51aadfc 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -4,6 +4,8 @@ CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=4096 CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=4096 +# A retry yields one tick; use 1 ms ticks for bounded UDP backpressure waits. +CONFIG_FREERTOS_HZ=1000 # Run the CPU at 240 MHz. # @@ -39,7 +41,8 @@ CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB=y # 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" +# The build scripts copy this file into the generated CMake project. +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" CONFIG_PARTITION_TABLE_OFFSET=0x8000 # Octal PSRAM is mandatory for the N16R8 module (R8 == 8 MB OPI PSRAM). The @@ -63,6 +66,11 @@ CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=n CONFIG_ESP_WIFI_IRAM_OPT=y CONFIG_ESP_WIFI_RX_IRAM_OPT=y +# Enough TX slots for a JPEG burst and multiple subscribers. JPEG data remains +# in PSRAM; Wi-Fi DMA buffers stay in internal memory. +CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=64 +CONFIG_ESP_WIFI_TX_BA_WIN=32 +CONFIG_ESP_WIFI_AMPDU_TX_ENABLED=y # Socket buffers sized for a video frame rather than for a text request. # @@ -77,11 +85,11 @@ CONFIG_CAMERA_TASK_STACK_SIZE=4096 # Run the camera task on CPU1. # -# The WiFi task is pinned to CPU0. esp32-camera's capture task spends its time -# in short, latency-sensitive bursts (DMA completion, the JPEG marker search, -# the copy into the frame buffer) and gets preempted on a shared core, which -# lengthens every frame. On a two-core part there is no reason to share. +# Explicitly select the choice: disabling CORE0 alone leaves Kconfig free to +# select it again. CPU0/CPU1 A/B tests both reach the sensor's ~22 fps ceiling; +# the observed transport bottleneck is not resolved by changing this affinity. CONFIG_CAMERA_CORE0=n +CONFIG_CAMERA_CORE1=y # PSRAM DMA is left off deliberately. # @@ -102,4 +110,4 @@ CONFIG_CAMERA_DMA_BUFFER_SIZE_MAX=32768 # driver, and refuses to run both in one image. esp32-camera picks one of them # for the SCCB bus; whichever it picks, the other has to stay out of the image, # so the conflict check is skipped here. -CONFIG_I2C_SKIP_LEGACY_CONFLICT_CHECK=y \ No newline at end of file +CONFIG_I2C_SKIP_LEGACY_CONFLICT_CHECK=y diff --git a/src/camera.rs b/src/camera.rs index 5e5fd8b..a42afa5 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -112,6 +112,14 @@ pub struct Frame { } impl Frame { + pub fn captured_at_us(&self) -> u64 { + self.fb.capture_started_us.max(0) as u64 + } + + pub fn skipped_before(&self) -> u32 { + self.fb.skipped_before + } + pub fn data(&self) -> &[u8] { if self.fb.data.is_null() || self.fb.len == 0 { return &[]; @@ -206,12 +214,17 @@ impl Camera { pixel_format: PIXFORMAT_JPEG, frame_size: resolution.framesize(), jpeg_quality: jpeg_quality as i32, - // Three buffers, not two: esp32-camera needs a free buffer at every - // VSYNC, and with two a client that is still draining the previous - // frame costs a whole capture period. One is spent filling, one is - // being written to the socket, and one is spare for the moment the - // write blocks. - fb_count: 3, + // Three buffers tolerate socket stalls. Two reduce capture queue + // depth, but may miss a VSYNC while the caller holds a buffer. + // Keep this selectable so latency and delivered FPS can be measured + // together instead of assuming the smallest pool always wins. + fb_count: { + let count: u32 = option_env!("CAM_FB_COUNT").unwrap_or("3").parse()?; + if !(2..=3).contains(&count) { + return Err(anyhow!("CAM_FB_COUNT must be 2 or 3")); + } + count as _ + }, fb_location: FB_IN_PSRAM, // LATEST keeps the newest frame in the queue instead of handing out // whatever was captured first, which matters once a client is slow @@ -254,6 +267,122 @@ impl Camera { } } + pub fn set_grayscale(&self, enabled: bool) -> Result<()> { + let rc = unsafe { sys::camera::cam_wrapper_set_grayscale(i32::from(enabled)) }; + if rc != 0 { + return Err(anyhow!("sensor grayscale failed (esp_err 0x{rc:x})")); + } + Ok(()) + } + + /// A complete PLL profile; preserve HTS/VTS and the active readout window. + /// 610 MHz VCO, 61 MHz system clock, 15.25 MHz DVP PCLK at XCLK 20 MHz. + pub fn set_clock_profile(&self, profile: &str) -> Result<()> { + if profile == "default" { + return Ok(()); + } + if profile != "vga30" { + return Err(anyhow!("CAM_CLOCK_PROFILE must be default or vga30")); + } + unsafe { + let sensor = sys::camera::esp_camera_sensor_get(); + if sensor.is_null() || (*sensor).id.PID != 0x5640 { + return Err(anyhow!("vga30 requires OV5640")); + } + let set = (*sensor) + .set_pll + .ok_or_else(|| anyhow!("sensor PLL control unavailable"))?; + // sensor_t callback order differs from the driver's private helper: + // bypass, multiplier, sys_div, root_2x, pre_div, pclk_root_div, + // manual_pclk, pclk_div. + if set(sensor, 0, 61, 1, 0, 2, 2, 1, 4) != 0 { + return Err(anyhow!("OV5640 clock profile failed")); + } + std::thread::sleep(std::time::Duration::from_millis(300)); + info!("OV5640 vga30 clock profile: VCO 610 MHz, SYSCLK 61 MHz, PCLK 15.25 MHz; HTS/VTS unchanged"); + } + Ok(()) + } + + /// Fine AE target adjustment without changing the exposure ceiling or AWB. + pub fn set_ae_level(&self, level: i32) -> Result<()> { + if !(-5..=5).contains(&level) { + return Err(anyhow!("CAM_AE_LEVEL must be between -5 and 5")); + } + unsafe { + let sensor = sys::camera::esp_camera_sensor_get(); + if sensor.is_null() || (*sensor).id.PID != 0x5640 { + return Err(anyhow!("CAM_AE_LEVEL requires OV5640")); + } + let set = (*sensor) + .set_ae_level + .ok_or_else(|| anyhow!("sensor AE target control unavailable"))?; + if set(sensor, level) != 0 { + return Err(anyhow!("OV5640 AE target adjustment failed")); + } + } + info!( + "OV5640 AE target override: driver level {level}, target {}/55; AEC/AGC retained", + (level + 5) * 10 + 5 + ); + Ok(()) + } + + pub fn probe_frame_rate(&self) -> f32 { + unsafe { sys::camera::cam_wrapper_probe_frame_rate() } + } + + pub fn set_quality(&self, quality: u8) -> Result<()> { + let rc = unsafe { sys::camera::cam_wrapper_set_quality(u32::from(quality)) }; + if rc != 0 { + return Err(anyhow!("sensor JPEG quality failed (esp_err 0x{rc:x})")); + } + Ok(()) + } + + pub fn set_gain_ceiling(&self, gain: u8) -> Result<()> { + if gain == 0 { + return Ok(()); + } + if gain > 63 { + return Err(anyhow!("CAM_GAIN_CEILING must be 0 or 1-63")); + } + // OV5640 gain is a 6.4 fixed-point value. Keep AEC/AGC automatic; + // change its limit, rather than forcing the live analogue gain. + unsafe { + let sensor = sys::camera::esp_camera_sensor_get(); + if sensor.is_null() || (*sensor).id.PID != 0x5640 { + return Err(anyhow!("CAM_GAIN_CEILING requires OV5640")); + } + let write = (*sensor) + .set_reg + .ok_or_else(|| anyhow!("sensor register writes unavailable"))?; + let read = (*sensor) + .get_reg + .ok_or_else(|| anyhow!("sensor register reads unavailable"))?; + let raw = i32::from(gain) * 16 - 1; + if write(sensor, 0x3a18, 0x03, raw >> 8) != 0 + || write(sensor, 0x3a19, 0xff, raw & 0xff) != 0 + || read(sensor, 0x3a18, 0x03ff) != raw + { + return Err(anyhow!("could not set OV5640 gain ceiling")); + } + info!("OV5640 automatic gain ceiling: {:.2}x", raw as f32 / 16.0); + } + Ok(()) + } + + /// Keep capture memory at three buffers, but never send a FIFO backlog. + pub fn latest_frame(&self) -> Result { + let mut fb: sys::camera::cam_wrapper_fb_t = unsafe { std::mem::zeroed() }; + let rc = unsafe { sys::camera::cam_wrapper_fb_get_latest(&mut fb, 100) }; + if rc != 0 || fb.data.is_null() || fb.len == 0 { + unsafe { sys::camera::cam_wrapper_fb_release(&mut fb) }; + return Err(FrameError); + } + Ok(Frame { fb }) + } + /// Grabs the next frame. /// /// This blocks until the sensor has one, which is what paces the stream. @@ -297,4 +426,4 @@ mod pins { /// D0..D7 == Y2..Y9, in the order `camera_config_t` expects. pub const DATA: [i32; 8] = [11, 9, 8, 10, 12, 18, 17, 16]; -} \ No newline at end of file +} diff --git a/src/link_control.rs b/src/link_control.rs new file mode 100644 index 0000000..1da161e --- /dev/null +++ b/src/link_control.rs @@ -0,0 +1,102 @@ +//! Nonblocking Wi-Fi recovery. Association and DHCP have separate deadlines. +#[derive(Debug, PartialEq)] +pub enum Action { + Connect, + AbortAttempt, +} + +pub struct Recovery { + next_attempt_ms: u64, + attempt_deadline_ms: Option, + lease_started_ms: Option, + backoff_ms: u64, +} + +impl Recovery { + pub fn new(now_ms: u64) -> Self { + Self { + next_attempt_ms: now_ms, + attempt_deadline_ms: None, + lease_started_ms: None, + backoff_ms: 1000, + } + } + fn failed(&mut self, now_ms: u64) { + self.attempt_deadline_ms = None; + self.lease_started_ms = None; + self.next_attempt_ms = now_ms.saturating_add(self.backoff_ms); + self.backoff_ms = (self.backoff_ms * 2).min(30_000); + } + pub fn disconnected(&mut self, now_ms: u64) { + if self.attempt_deadline_ms.is_some() { + self.failed(now_ms); + } + } + pub fn poll(&mut self, now_ms: u64, associated: bool) -> Option { + if associated { + let start = *self.lease_started_ms.get_or_insert(now_ms); + if now_ms.saturating_sub(start) >= 15_000 { + self.failed(now_ms); + return Some(Action::AbortAttempt); + } + return None; + } + self.lease_started_ms = None; + if self + .attempt_deadline_ms + .is_some_and(|deadline| now_ms >= deadline) + { + self.failed(now_ms); + return Some(Action::AbortAttempt); + } + if self.attempt_deadline_ms.is_none() && now_ms >= self.next_attempt_ms { + self.attempt_deadline_ms = Some(now_ms.saturating_add(15_000)); + return Some(Action::Connect); + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn first_attempt_is_immediate_but_does_not_restart_active_authentication() { + let mut r = Recovery::new(100); + assert_eq!(r.poll(100, false), Some(Action::Connect)); + assert_eq!(r.poll(14_999, false), None); + assert_eq!(r.poll(15_100, false), Some(Action::AbortAttempt)); + assert_eq!(r.poll(16_099, false), None); + assert_eq!(r.poll(16_100, false), Some(Action::Connect)); + } + #[test] + fn failure_events_back_off_with_a_thirty_second_limit() { + let mut r = Recovery::new(0); + let mut now = 0; + for delay in [1000, 2000, 4000, 8000, 16000, 30000, 30000] { + assert_eq!(r.poll(now, false), Some(Action::Connect)); + r.disconnected(now + 100); + assert_eq!(r.poll(now + 99 + delay, false), None); + now += 100 + delay; + } + } + #[test] + fn dhcp_is_allowed_time_but_cannot_hang_forever() { + let mut r = Recovery::new(0); + assert_eq!(r.poll(0, false), Some(Action::Connect)); + assert_eq!(r.poll(500, true), None); + assert_eq!(r.poll(15_499, true), None); + assert_eq!(r.poll(15_500, true), Some(Action::AbortAttempt)); + // The expected disconnect after abort must not double the backoff. + r.disconnected(15_501); + assert_eq!(r.poll(16_500, false), Some(Action::Connect)); + } + #[test] + fn a_new_outage_resets_backoff_and_deadlines() { + let mut r = Recovery::new(0); + r.poll(0, false); + r.disconnected(100); + let mut next = Recovery::new(200); + assert_eq!(next.poll(200, false), Some(Action::Connect)); + } +} diff --git a/src/main.rs b/src/main.rs index 8c8578e..fab6614 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,10 @@ //! WiFi credentials come from the `WIFI_SSID` / `WIFI_PASS` environment variables //! at build time, so no secrets end up in the source. +use std::sync::{ + atomic::{AtomicU32, Ordering}, + Arc, +}; use std::time::{Duration, Instant}; use anyhow::{anyhow, Result}; @@ -16,14 +20,17 @@ use embedded_svc::wifi::{ AccessPointInfo, AuthMethod, ClientConfiguration, Configuration, PmfConfiguration, }; use esp_idf_svc::hal::peripherals::Peripherals; -use esp_idf_svc::wifi::{BlockingWifi, EspWifi}; +use esp_idf_svc::wifi::{BlockingWifi, EspWifi, WifiEvent}; use esp_idf_svc::{eventloop::EspSystemEventLoop, nvs::EspDefaultNvsPartition}; use log::{error, info, warn}; mod camera; +mod link_control; +mod stream_control; mod udp; use camera::{Camera, Resolution, SensorHint}; +use stream_control::{Pacer, QualityController, ThermalLimiter}; use udp::UdpSender; const SSID: &str = env!("WIFI_SSID"); @@ -84,14 +91,88 @@ fn main() -> Result<()> { let resolution: Resolution = RESOLUTION.parse()?; let quality: u8 = QUALITY.parse()?; + let quality_ceiling: u8 = option_env!("CAM_QUALITY_MAX").unwrap_or("0").parse()?; + if quality_ceiling != 0 && !(quality..=63).contains(&quality_ceiling) { + return Err(anyhow!( + "CAM_QUALITY_MAX must be 0 (fixed quality) or CAM_QUALITY..63" + )); + } + let max_fps: u32 = option_env!("CAM_MAX_FPS").unwrap_or("0").parse()?; + if max_fps > 60 { + return Err(anyhow!("CAM_MAX_FPS must be 0 (uncapped) or 1-60")); + } + let grayscale = match option_env!("CAM_GRAYSCALE").unwrap_or("0") { + "0" => false, + "1" => true, + _ => return Err(anyhow!("CAM_GRAYSCALE must be 0 or 1")), + }; + let thermal_limit: f32 = option_env!("CAM_THERMAL_LIMIT_C").unwrap_or("75").parse()?; + if !thermal_limit.is_finite() + || (thermal_limit != 0.0 && !(40.0..=95.0).contains(&thermal_limit)) + { + return Err(anyhow!("CAM_THERMAL_LIMIT_C must be 0 (disabled) or 40-95")); + } + info!("reset reason: {}", unsafe { + esp_idf_svc::sys::esp_reset_reason() + }); info!("vsync invert: {}", vsync_invert()); let camera = Camera::init(SensorHint::Auto, resolution, quality, vsync_invert())?; + camera.set_clock_profile(option_env!("CAM_CLOCK_PROFILE").unwrap_or("default"))?; + camera.set_grayscale(grayscale)?; + let exposure_ev: i32 = option_env!("CAM_EXPOSURE_EV").unwrap_or("0").parse()?; + esp_idf_svc::sys::esp!(unsafe { + esp_idf_svc::sys::camera::cam_wrapper_set_exposure_bias(exposure_ev) + })?; + if let Some(level) = option_env!("CAM_AE_LEVEL") { + if exposure_ev != 0 { + return Err(anyhow!( + "Use CAM_AE_LEVEL or a nonzero CAM_EXPOSURE_EV, not both" + )); + } + camera.set_ae_level(level.parse()?)?; + } + let horizontal_total: u32 = option_env!("CAM_HTS").unwrap_or("0").parse()?; + esp_idf_svc::sys::esp!(unsafe { + esp_idf_svc::sys::camera::cam_wrapper_set_horizontal_timing(horizontal_total) + })?; + let shutter_lines: u32 = option_env!("CAM_SHUTTER_LINES").unwrap_or("0").parse()?; + let exposure_limit: u32 = option_env!("CAM_EXPOSURE_MAX_LINES") + .unwrap_or("0") + .parse()?; + if shutter_lines != 0 && exposure_limit != 0 { + return Err(anyhow!( + "Use CAM_SHUTTER_LINES or CAM_EXPOSURE_MAX_LINES, not both" + )); + } + esp_idf_svc::sys::esp!(unsafe { + esp_idf_svc::sys::camera::cam_wrapper_set_shutter_lines(shutter_lines) + })?; + esp_idf_svc::sys::esp!(unsafe { + esp_idf_svc::sys::camera::cam_wrapper_set_exposure_limit(exposure_limit) + })?; + camera.set_gain_ceiling(option_env!("CAM_GAIN_CEILING").unwrap_or("0").parse()?)?; + info!("stream profile: target {max_fps} fps, grayscale {grayscale}, quality {quality}, thermal limit {thermal_limit:.1} C"); + info!( + "configured capture probe: {:.2} fps", + camera.probe_frame_rate() + ); + let temperature_available = unsafe { esp_idf_svc::sys::camera::cam_wrapper_temperature_init() }; + if temperature_available != 0 { + warn!("chip temperature unavailable (esp_err 0x{temperature_available:x}); fixed frame cap remains active"); + } let peripherals = Peripherals::take()?; let sys_loop = EspSystemEventLoop::take()?; let nvs = EspDefaultNvsPartition::take()?; + let network_epoch = Arc::new(AtomicU32::new(0)); + let event_epoch = network_epoch.clone(); + let _wifi_events = sys_loop.subscribe::(move |event| { + if matches!(event, WifiEvent::StaDisconnected(_)) { + event_epoch.fetch_add(1, Ordering::Relaxed); + } + })?; let mut wifi = BlockingWifi::wrap( EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?, @@ -109,21 +190,7 @@ fn main() -> Result<()> { let ip = wifi.wifi().sta_netif().get_ip_info()?.ip; - // Modem power save trades latency for current, and for a video stream that - // trade is backwards: with PS on, an idle beacon interval costs hundreds of - // milliseconds, which shows up as stutter and a ping of 200-300 ms on an - // otherwise good link. The board is mains-powered anyway. - // - // This one binds as a bare `esp_err_t` rather than a `Result`. - let ps_rc = unsafe { - esp_idf_svc::sys::esp_wifi_set_ps(esp_idf_svc::sys::wifi_ps_type_t_WIFI_PS_NONE) - }; - - if ps_rc == 0 { - info!("WiFi power save disabled"); - } else { - warn!("could not disable WiFi power save (esp_err 0x{ps_rc:x})"); - } + configure_radio()?; // The board identifies itself by an id derived from its MAC, not its IP: DHCP // hands out a different address across reboots, and a client that saved its @@ -136,8 +203,114 @@ fn main() -> Result<()> { let mut last_report = Instant::now(); let mut last_stats = sender.stats(); + let mut pacer = Pacer::new(max_fps); + let mut thermal = ThermalLimiter::new(max_fps, thermal_limit); + let mut current_quality = quality; + let mut quality_control = QualityController::new( + quality, + if quality_ceiling == 0 { + quality + } else { + quality_ceiling + }, + ); + let loop_start = Instant::now(); + let mut recovery = link_control::Recovery::new(0); + let mut seen_epoch = network_epoch.load(Ordering::Relaxed); + let mut link_up = true; + let mut active_ip = ip; + let mut next_link_check = 0u64; + let mut outage_started = 0u64; + let test_drop_ms = option_env!("WIFI_TEST_DROP_AFTER_S") + .unwrap_or("0") + .parse::()? + .saturating_mul(1000); + let mut test_injected = false; + let mut test_hold_until = 0u64; loop { + let now_ms = loop_start.elapsed().as_millis() as u64; + if test_drop_ms > 0 && !test_injected && now_ms >= test_drop_ms { + test_injected = true; + test_hold_until = now_ms + 5000; + warn!("Wi-Fi recovery self-test: intentional disconnect for 5000 ms"); + wifi.wifi_mut().disconnect().ok(); + next_link_check = 0; + } + if now_ms >= next_link_check { + next_link_check = now_ms + 100; + let epoch = network_epoch.load(Ordering::Relaxed); + let associated = wifi.wifi().is_connected().unwrap_or(false); + let lease = wifi + .wifi() + .sta_netif() + .get_ip_info() + .ok() + .map(|v| v.ip) + .filter(|ip| !ip.is_unspecified()); + let ready = associated + && wifi.wifi().is_up().unwrap_or(false) + && lease.is_some() + && now_ms >= test_hold_until; + let address_changed = ready && lease.is_some_and(|address| address != active_ip); + if link_up && (!ready || epoch != seen_epoch || address_changed) { + link_up = false; + outage_started = now_ms; + recovery = link_control::Recovery::new(now_ms); + sender.network_lost(); + warn!("Wi-Fi link unavailable or changed; UDP suspended, waiting for association and DHCP"); + } else if epoch != seen_epoch { + recovery.disconnected(now_ms); + } + seen_epoch = epoch; + if !link_up && ready { + match sender.restore_network() { + Ok(()) => { + active_ip = lease.unwrap(); + if let Err(err) = configure_radio() { + warn!("radio profile restore: {err}"); + } + // Discard any queued image from before recovery. + for _ in 0..2 { + if let Ok(frame) = camera.next_frame() { + drop(frame); + } + } + link_up = true; + last_report = Instant::now(); + last_stats = sender.stats(); + info!("Wi-Fi recovery complete after {} ms; IP {active_ip}; UDP recreated, camera state preserved",now_ms-outage_started); + } + Err(err) => warn!("UDP restore failed, will retry: {err}"), + } + } else if !link_up && now_ms >= test_hold_until { + match recovery.poll(now_ms, associated) { + Some(link_control::Action::Connect) => { + info!("Wi-Fi reconnect attempt"); + if let Err(err) = wifi.wifi_mut().connect() { + warn!("Wi-Fi reconnect rejected: {err}"); + recovery.disconnected(now_ms); + } + } + Some(link_control::Action::AbortAttempt) => { + warn!("Wi-Fi association/DHCP deadline elapsed; cancelling attempt before backoff"); + wifi.wifi_mut().disconnect().ok(); + } + None => {} + } + } + } + if !link_up { + std::thread::sleep(Duration::from_millis(50)); + continue; + } + sender.housekeeping(); + let now_us = unsafe { esp_idf_svc::sys::esp_timer_get_time() } as u64; + let wait_us = pacer.delay_us(now_us); + if wait_us > 0 { + std::thread::sleep(Duration::from_micros(wait_us)); + } + pacer.started(unsafe { esp_idf_svc::sys::esp_timer_get_time() } as u64); udp::pump(&camera, &mut sender)?; let now = Instant::now(); @@ -148,16 +321,90 @@ fn main() -> Result<()> { // distinction that matters when the frame rate is wrong. let s = sender.stats(); let elapsed = now.duration_since(last_report).as_secs_f32(); + let attempted = s.frames_attempted - last_stats.frames_attempted; + let dropped = s.peer_frames_dropped - last_stats.peer_frames_dropped; + let send_ms = (s.send_time_us - last_stats.send_time_us) as f32 + / attempted.max(1) as f32 + / 1000.0; + if let Some(next) = quality_control.observe( + u64::from(attempted) * sender.peer_count() as u64, + dropped, + send_ms, + ) { + match camera.set_quality(next) { + Ok(()) => { + info!("JPEG load control: quality {current_quality} -> {next}, {dropped} peer drops / {attempted} attempts, {send_ms:.2} ms/send"); + current_quality = next; + } + Err(err) => { + warn!("JPEG load control disabled after sensor error: {err}"); + quality_control = QualityController::new(current_quality, current_quality); + } + } + } + let mut ap: esp_idf_svc::sys::wifi_ap_record_t = unsafe { std::mem::zeroed() }; + let ap_ok = unsafe { esp_idf_svc::sys::esp_wifi_sta_get_ap_info(&mut ap) } == 0; + let mut tx_power = 0; + let tx_power_ok = unsafe { + esp_idf_svc::sys::esp_wifi_get_max_tx_power(&mut tx_power) + } == 0; + let mut celsius = f32::NAN; + let temp_ok = temperature_available == 0 + && unsafe { esp_idf_svc::sys::camera::cam_wrapper_temperature_read(&mut celsius) } + == 0; + let previous_fps = thermal.fps(); + let target_fps = thermal.observe(if temp_ok { Some(celsius) } else { None }); + if previous_fps != target_fps { + pacer.set_fps( + target_fps, + unsafe { esp_idf_svc::sys::esp_timer_get_time() } as u64, + ); + info!("thermal load limit changed: {previous_fps} -> {target_fps} fps at {celsius:.1} C"); + } info!( - "{:.1} fps, {} datagrams/s, {:.1} kB/s, {} send errors, \ - {} clients subscribed, {} frames skipped (no client)", + "{:.1} fps submitted, {:.1} fps attempted, {:.1} datagrams/s, {:.1} kB/s, \ + {} send failures, {} retries, errno {}, {} peer frames dropped, \ + {:.2} ms/send, {} clients subscribed, {} frames skipped (no client), \ + internal heap {} free / {} largest bytes, RSSI {} dBm channel {}, \ + chip temp {:.1} C, target {} fps, thermal_limited {}, capture errors {}, exposure {} lines, jpeg quality {}, gain {:.2}x, \ + capture queue skipped {}, stale frames dropped {}, deadline peer drops {}, max submitted capture age {:.2} ms, max send hold {:.2} ms, max TX power {:.2} dBm", (s.frames_sent - last_stats.frames_sent) as f32 / elapsed, + (s.frames_attempted - last_stats.frames_attempted) as f32 / elapsed, (s.fragments_sent - last_stats.fragments_sent) as f32 / elapsed, (s.bytes_sent - last_stats.bytes_sent) as f32 / elapsed / 1024.0, s.errors - last_stats.errors, + s.retries - last_stats.retries, + s.last_errno, + s.peer_frames_dropped - last_stats.peer_frames_dropped, + (s.send_time_us - last_stats.send_time_us) as f32 + / (s.frames_attempted - last_stats.frames_attempted).max(1) as f32 + / 1000.0, sender.peer_count(), s.skipped_no_peer - last_stats.skipped_no_peer, + unsafe { + esp_idf_svc::sys::heap_caps_get_free_size(esp_idf_svc::sys::MALLOC_CAP_INTERNAL) + }, + unsafe { + esp_idf_svc::sys::heap_caps_get_largest_free_block( + esp_idf_svc::sys::MALLOC_CAP_INTERNAL, + ) + }, + if ap_ok { ap.rssi } else { 0 }, + if ap_ok { ap.primary } else { 0 }, + if temp_ok { celsius } else { f32::NAN }, + target_fps, + thermal.limited(), + s.capture_errors - last_stats.capture_errors, + unsafe { esp_idf_svc::sys::camera::cam_wrapper_exposure_lines() }, + current_quality, + unsafe { esp_idf_svc::sys::camera::cam_wrapper_gain_raw() } as f32 / 16.0, + s.capture_queue_skipped - last_stats.capture_queue_skipped, + s.stale_frames_dropped - last_stats.stale_frames_dropped, + s.deadline_peer_drops - last_stats.deadline_peer_drops, + s.max_submitted_age_us as f32 / 1000.0, + s.max_send_hold_us as f32 / 1000.0, + if tx_power_ok { tx_power as f32 / 4.0 } else { f32::NAN }, ); last_stats = s; @@ -166,6 +413,56 @@ fn main() -> Result<()> { } } +fn configure_radio() -> Result<()> { + // Modem power save trades latency for current, and for a video stream that + // trade is backwards: with PS on, an idle beacon interval costs hundreds of + // milliseconds, which shows up as stutter and a ping of 200-300 ms on an + // otherwise good link. The board is mains-powered anyway. + // + // This one binds as a bare `esp_err_t` rather than a `Result`. + let ps_rc = + unsafe { esp_idf_svc::sys::esp_wifi_set_ps(esp_idf_svc::sys::wifi_ps_type_t_WIFI_PS_NONE) }; + + if ps_rc == 0 { + info!("WiFi power save disabled"); + } else { + warn!("could not disable WiFi power save (esp_err 0x{ps_rc:x})"); + } + // Apply the selected power cap both at startup and after reconnecting. + // Congestion/thermal controllers must not lower it: they adjust JPEG + // quality or frame delivery instead. The SDK quantizes this setting. + if let Some(value) = option_env!("WIFI_TX_POWER_DBM") { + let dbm: u8 = value + .parse() + .map_err(|_| anyhow!("WIFI_TX_POWER_DBM must be an integer from 2 to 20"))?; + if !(2..=20).contains(&dbm) { + return Err(anyhow!("WIFI_TX_POWER_DBM must be an integer from 2 to 20")); + } + esp_idf_svc::sys::esp!(unsafe { + esp_idf_svc::sys::esp_wifi_set_max_tx_power((dbm * 4) as i8) + })?; + info!("WiFi requested max TX power: {dbm} dBm"); + } + + let mut ap: esp_idf_svc::sys::wifi_ap_record_t = unsafe { std::mem::zeroed() }; + let mut phy = 0; + let mut tx_power = 0; + unsafe { + esp_idf_svc::sys::esp_wifi_sta_get_ap_info(&mut ap); + esp_idf_svc::sys::esp_wifi_sta_get_negotiated_phymode(&mut phy); + } + esp_idf_svc::sys::esp!(unsafe { + esp_idf_svc::sys::esp_wifi_get_max_tx_power(&mut tx_power) + })?; + info!( + "WiFi link: negotiated PHY {phy}, pairwise cipher {}, max TX power {:.2} dBm", + ap.pairwise_cipher, + tx_power as f32 / 4.0 + ); + + Ok(()) +} + /// Joins `SSID`, scanning first and retrying the plausible authentication modes. /// /// The scan is not decoration. This radio is 2.4 GHz only, so a 5 GHz SSID never @@ -181,7 +478,9 @@ fn connect(wifi: &mut BlockingWifi>) -> Result<()> { // A configuration has to exist before start(), which is what decides // station vs AP mode; the real one is picked after the scan. - wifi.set_configuration(&client_config(pinned_auth.unwrap_or_else(default_auth_method))?)?; + wifi.set_configuration(&client_config( + pinned_auth.unwrap_or_else(default_auth_method), + )?)?; wifi.start()?; info!("wifi started"); @@ -232,7 +531,11 @@ fn connect(wifi: &mut BlockingWifi>) -> Result<()> { warn!( "{auth:?} did not complete the handshake ({})", - if associated { "associated but no DHCP address" } else { "no association" } + if associated { + "associated but no DHCP address" + } else { + "no association" + } ); wifi.wifi_mut().disconnect().ok(); } @@ -329,7 +632,11 @@ fn report_scan(aps: &[AccessPointInfo]) { ap.channel, ap.signal_strength, ap.auth_method, - if ap.ssid.as_str() == SSID { " <- target" } else { "" } + if ap.ssid.as_str() == SSID { + " <- target" + } else { + "" + } ); } @@ -337,4 +644,4 @@ fn report_scan(aps: &[AccessPointInfo]) { error!("\"{SSID}\" was not in the scan results"); error!("this radio is 2.4 GHz only, so the AP is likely 5 GHz or on a forbidden channel"); } -} \ No newline at end of file +} diff --git a/src/stream_control.rs b/src/stream_control.rs new file mode 100644 index 0000000..80b9f89 --- /dev/null +++ b/src/stream_control.rs @@ -0,0 +1,287 @@ +//! Frame pacing and a hysteretic, configurable thermal load limit. +//! Times are monotonic microseconds so the scheduling policy is host-testable. + +pub struct Pacer { + period_us: u64, + next_due_us: u64, +} + +impl Pacer { + pub fn new(fps: u32) -> Self { + Self::with_gap_us(period(fps)) + } + + pub fn with_gap_us(period_us: u64) -> Self { + Self { + period_us, + next_due_us: 0, + } + } + + pub fn delay_us(&self, now_us: u64) -> u64 { + self.next_due_us.saturating_sub(now_us) + } + + pub fn started(&mut self, now_us: u64) { + // An overrun starts a new period; never burst to repay missed slots. + self.next_due_us = now_us.saturating_add(self.period_us); + } + + pub fn set_fps(&mut self, fps: u32, now_us: u64) { + self.period_us = period(fps); + self.started(now_us); + } +} + +pub const MAX_SEND_HOLD_US: u64 = 25_000; +pub const MAX_CAPTURE_AGE_US: u64 = 100_000; +pub const DEFAULT_RETRY_BUDGET_US: u64 = 8_000; + +/// One current frame, no pending-send FIFO. A short retry budget is shared by +/// all fragments/peers; success never renews the frame's absolute deadline. +pub struct FrameBudget { + deadline_us: u64, + retry_left_us: u64, +} + +impl FrameBudget { + pub fn new(now_us: u64, captured_us: u64, retry_us: u64) -> Self { + Self { + deadline_us: now_us + .saturating_add(MAX_SEND_HOLD_US) + .min(captured_us.min(now_us).saturating_add(MAX_CAPTURE_AGE_US)), + retry_left_us: retry_us.min(MAX_SEND_HOLD_US), + } + } + + pub fn expired(&self, now_us: u64) -> bool { + now_us >= self.deadline_us + } + + pub fn allow_retry(&mut self, now_us: u64, wait_us: u64) -> bool { + if wait_us == 0 + || wait_us > self.retry_left_us + || now_us.saturating_add(wait_us) >= self.deadline_us + { + return false; + } + self.retry_left_us -= wait_us; + true + } +} + +fn period(fps: u32) -> u64 { + if fps == 0 { + 0 + } else { + 1_000_000u64.div_ceil(fps as u64) + } +} + +/// Adjust JPEG compression from five-second submission windows. The sensor's +/// quality number increases with compression; restore detail more slowly than +/// we shed load, and do not interpret absent subscribers as a healthy link. +pub struct QualityController { + base: u8, + ceiling: u8, + current: u8, + healthy_windows: u8, +} + +impl QualityController { + pub fn new(base: u8, ceiling: u8) -> Self { + Self { + base, + ceiling, + current: base, + healthy_windows: 0, + } + } + + pub fn observe(&mut self, peer_frames: u64, dropped: u32, send_ms: f32) -> Option { + if peer_frames < 5 || !send_ms.is_finite() { + self.healthy_windows = 0; + return None; + } + let previous = self.current; + if u64::from(dropped) * 100 >= peer_frames.saturating_mul(2) || send_ms >= 8.0 { + self.current = self.current.saturating_add(3).min(self.ceiling); + self.healthy_windows = 0; + } else if dropped == 0 && send_ms < 4.0 { + self.healthy_windows += 1; + if self.healthy_windows >= 3 { + self.current = self.current.saturating_sub(1).max(self.base); + self.healthy_windows = 0; + } + } else { + self.healthy_windows = 0; + } + (self.current != previous).then_some(self.current) + } +} + +pub struct ThermalLimiter { + normal_fps: u32, + limit_c: f32, + limited: bool, +} + +impl ThermalLimiter { + pub fn new(normal_fps: u32, limit_c: f32) -> Self { + Self { + normal_fps, + limit_c, + limited: false, + } + } + + pub fn observe(&mut self, temperature: Option) -> u32 { + if self.limit_c > 0.0 { + if let Some(t) = temperature.filter(|t| t.is_finite()) { + if t >= self.limit_c { + self.limited = true; + } else if t <= self.limit_c - 5.0 { + self.limited = false; + } + } + } + self.fps() + } + + pub fn fps(&self) -> u32 { + if !self.limited { + self.normal_fps + } else if self.normal_fps == 0 { + 8 + } else { + self.normal_fps.div_ceil(2) + } + } + + pub fn limited(&self) -> bool { + self.limited + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pacing_caps_rate_without_catching_up_after_an_overrun() { + let mut pacer = Pacer::new(15); + assert_eq!(pacer.delay_us(100), 0); + pacer.started(100); + assert_eq!(pacer.delay_us(101), 66_666); + assert_eq!(pacer.delay_us(300_000), 0); + pacer.started(300_000); + assert_eq!(pacer.delay_us(300_001), 66_666); + } + + #[test] + fn unlimited_and_runtime_rate_changes_do_not_burst() { + let mut pacer = Pacer::new(0); + pacer.started(1_000); + assert_eq!(pacer.delay_us(1_001), 0); + pacer.set_fps(10, 1_001); + assert_eq!(pacer.delay_us(1_002), 99_999); + } + + #[test] + fn thermal_limit_has_five_degree_hysteresis() { + let mut limit = ThermalLimiter::new(15, 75.0); + assert_eq!(limit.observe(Some(74.9)), 15); + assert_eq!(limit.observe(Some(75.0)), 8); + assert_eq!(limit.observe(Some(72.0)), 8); + assert_eq!(limit.observe(None), 8); + assert_eq!(limit.observe(Some(f32::NAN)), 8); + assert_eq!(limit.observe(Some(70.0)), 15); + } + + #[test] + fn thermal_guard_can_be_disabled_or_used_with_uncapped_streams() { + assert_eq!(ThermalLimiter::new(15, 0.0).observe(Some(90.0)), 15); + assert_eq!(ThermalLimiter::new(0, 75.0).observe(Some(80.0)), 8); + assert_eq!(ThermalLimiter::new(1, 75.0).observe(Some(80.0)), 1); + } + + #[test] + fn packet_spacing_starts_from_actual_submission_without_catch_up() { + let mut pacer = Pacer::with_gap_us(1000); + pacer.started(100); + assert_eq!(pacer.delay_us(900), 200); + assert_eq!(pacer.delay_us(5000), 0); + pacer.started(5000); + assert_eq!(pacer.delay_us(5001), 999); + } + + #[test] + fn frame_deadline_is_not_renewed_by_successful_fragments_or_other_peers() { + let frame = FrameBudget::new(50_000, 20_000, 8_000); + assert!(!frame.expired(74_999)); + assert!(frame.expired(75_000)); + } + + #[test] + fn an_old_capture_cannot_gain_a_fresh_send_lifetime() { + let frame = FrameBudget::new(99_000, 0, 8_000); + assert!(!frame.expired(99_999)); + assert!(frame.expired(100_000)); + assert!(FrameBudget::new(100_001, 0, 8_000).expired(100_001)); + } + + #[test] + fn retry_allowance_is_shared_by_all_fragments_and_stops_before_deadline() { + let mut frame = FrameBudget::new(50_000, 20_000, 2_000); + assert!(frame.allow_retry(51_000, 1000)); + assert!(frame.allow_retry(52_000, 1000)); + assert!(!frame.allow_retry(53_000, 1000)); + let mut late = FrameBudget::new(50_000, 20_000, 8_000); + assert!(!late.allow_retry(74_000, 1000)); + } + + #[test] + fn zero_retry_mode_still_allows_immediate_sends_and_abandons_backpressure() { + let mut frame = FrameBudget::new(50_000, 20_000, 0); + assert!(!frame.expired(50_001)); + assert!(!frame.allow_retry(50_001, 1000)); + } + + #[test] + fn compression_sheds_congestion_load_quickly_and_stays_in_bounds() { + let mut control = QualityController::new(18, 24); + assert_eq!(control.observe(100, 2, 3.0), Some(21)); + assert_eq!(control.observe(100, 0, 8.0), Some(24)); + assert_eq!(control.observe(100, 20, 20.0), None); + let mut disabled = QualityController::new(18, 18); + assert_eq!(disabled.observe(100, 50, 20.0), None); + } + + #[test] + fn compression_recovers_only_after_three_consecutive_healthy_windows() { + let mut control = QualityController::new(18, 24); + control.observe(100, 10, 10.0); + assert_eq!(control.observe(100, 0, 2.0), None); + assert_eq!(control.observe(100, 0, 2.0), None); + assert_eq!(control.observe(100, 1, 5.0), None); + assert_eq!(control.observe(100, 0, 2.0), None); + assert_eq!(control.observe(100, 0, 2.0), None); + assert_eq!(control.observe(100, 0, 2.0), Some(20)); + for _ in 0..20 { + control.observe(100, 0, 2.0); + } + assert_eq!(control.current, 18); + } + + #[test] + fn missing_traffic_or_invalid_timing_does_not_restore_quality() { + let mut control = QualityController::new(18, 24); + control.observe(100, 10, 10.0); + control.observe(100, 0, 2.0); + control.observe(100, 0, 2.0); + assert_eq!(control.observe(0, 0, 0.0), None); + assert_eq!(control.observe(100, 0, f32::NAN), None); + assert_eq!(control.observe(100, 0, 2.0), None); + assert_eq!(control.current, 21); + } +} diff --git a/src/udp.rs b/src/udp.rs index 65cc470..6ff6c30 100644 --- a/src/udp.rs +++ b/src/udp.rs @@ -32,6 +32,9 @@ use esp_idf_svc::sys as ffi; use log::{debug, info, warn}; use crate::camera::{Camera, Frame}; +use crate::stream_control::{ + FrameBudget, Pacer, DEFAULT_RETRY_BUDGET_US, MAX_CAPTURE_AGE_US, MAX_SEND_HOLD_US, +}; /// 单个数据报的最大字节数。 /// @@ -51,6 +54,10 @@ const HELLO_INTERVAL_MS: i64 = 2_000; /// 够用,且不让一个陈旧的地址条目拖慢每一帧。 const MAX_PEERS: usize = 4; +// A frame must not spend a capture period waiting for network buffers. Retry +// transient driver backpressure briefly, then abandon that peer's old frame. +const TX_RETRY_WAIT_US: i64 = 1_000_000 / ffi::CONFIG_FREERTOS_HZ as i64; + /// 把 Rust 的 socket 地址转成 lwIP 期望的 C 结构。 /// /// 必须逐字段构造而不是 transmute:Rust 的 `SocketAddrV4` 是紧凑的 6 字节(端口在前、 @@ -75,6 +82,7 @@ fn to_sockaddr(addr: SocketAddrV4) -> ffi::sockaddr_in { #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct Peer { addr: SocketAddrV4, + client_id: u32, /// 最近一次收到它订阅的时刻。 last_seen_us: i64, } @@ -84,20 +92,32 @@ struct Peer { /// 与"传感器产出了多少"分开计数:两者的差就是这条链路的全部故事。 #[derive(Debug, Clone, Copy, Default)] pub struct SendStats { - /// 已交给网络的帧。 + pub capture_errors: u32, + /// 所有分片均交给网络的帧(至少一个对端;不代表无线接收确认)。 pub frames_sent: u32, + pub frames_attempted: u32, + pub peer_frames_sent: u32, + pub peer_frames_dropped: u32, /// 已发出的数据报。 pub fragments_sent: u32, /// 已发出的字节,含头。 pub bytes_sent: u64, /// `sendto` 失败次数。 pub errors: u32, + pub retries: u32, + pub last_errno: i32, + pub send_time_us: u64, /// 因没有客户端而未发送的帧。 pub skipped_no_peer: u32, /// 收到的订阅数。 pub subscriptions: u32, /// 已广播的 Hello 数。 pub hellos_sent: u32, + pub capture_queue_skipped: u32, + pub stale_frames_dropped: u32, + pub deadline_peer_drops: u32, + pub max_submitted_age_us: u64, + pub max_send_hold_us: u64, } /// 向已订阅的客户端推流,并广播自身存在。 @@ -111,6 +131,9 @@ pub struct UdpSender { /// 读客户端订阅的暂存。内容无关紧要——只有源地址有用——所以刻意很小。 announce_buf: [u8; 64], last_hello_us: i64, + datagram: Vec, + packet_pacer: Pacer, + retry_budget_us: u64, } /// 本机标识:由 MAC 派生,重启后不变。 @@ -118,12 +141,7 @@ pub fn local_sender_id() -> u32 { let mut mac = [0u8; 6]; // SAFETY: `mac` 有 6 字节空间,函数会把 6 字节写满。 - let rc = unsafe { - ffi::esp_read_mac( - mac.as_mut_ptr(), - ffi::esp_mac_type_t_ESP_MAC_WIFI_STA, - ) - }; + let rc = unsafe { ffi::esp_read_mac(mac.as_mut_ptr(), ffi::esp_mac_type_t_ESP_MAC_WIFI_STA) }; if rc != 0 { warn!("could not read the WiFi MAC (esp_err {rc}); using a fixed id"); @@ -142,6 +160,22 @@ impl UdpSender { /// /// socket 既收也发:客户端广播订阅进来,板子往它看到的源地址推流。 pub fn new(sender_id: u32) -> Result { + let packet_gap_us: u64 = option_env!("UDP_PACKET_GAP_US").unwrap_or("0").parse()?; + if packet_gap_us > 5_000 || packet_gap_us % TX_RETRY_WAIT_US as u64 != 0 { + return Err(anyhow!( + "UDP_PACKET_GAP_US must be 0..5000 in scheduler-tick increments" + )); + } + let retry_budget_us: u64 = option_env!("UDP_RETRY_BUDGET_US") + .map(str::parse) + .transpose()? + .unwrap_or(DEFAULT_RETRY_BUDGET_US); + if retry_budget_us > MAX_SEND_HOLD_US || retry_budget_us % TX_RETRY_WAIT_US as u64 != 0 { + return Err(anyhow!( + "UDP_RETRY_BUDGET_US must be 0..25000 in scheduler-tick increments" + )); + } + info!("UDP packet gap: {packet_gap_us} us; retries {retry_budget_us} us/frame (0=fail fast), hold limit {MAX_SEND_HOLD_US} us, capture age limit {MAX_CAPTURE_AGE_US} us; newest completed frame only"); // SAFETY: 纯 socket 设置,没有指向 Rust 内存的指针。 let socket = unsafe { let fd = ffi::lwip_socket(ffi::AF_INET as i32, ffi::SOCK_DGRAM as i32, 0); @@ -163,6 +197,23 @@ impl UdpSender { std::mem::size_of::() as u32, ); + // ESP-IDF maps IP precedence 4 to WMM AC_VI. Video should use the + // video queue, which retains AMPDU, rather than best-effort bursts. + let video_priority: i32 = 4 << 5; + if ffi::lwip_setsockopt( + fd, + ffi::IPPROTO_IP as i32, + ffi::IP_TOS as i32, + &video_priority as *const i32 as *const std::ffi::c_void, + std::mem::size_of::() as u32, + ) < 0 + { + warn!( + "could not set video Wi-Fi priority: errno {}", + *ffi::__errno() + ); + } + // 绑定到视频端口的所有接口。地址留 0,lwIP 视作 INADDR_ANY。 let addr = ffi::sockaddr_in { sin_len: std::mem::size_of::() as u8, @@ -188,8 +239,7 @@ impl UdpSender { let flags = ffi::lwip_fcntl(fd, ffi::F_GETFL as i32, 0); if flags < 0 - || ffi::lwip_fcntl(fd, ffi::F_SETFL as i32, flags | ffi::O_NONBLOCK as i32) - < 0 + || ffi::lwip_fcntl(fd, ffi::F_SETFL as i32, flags | ffi::O_NONBLOCK as i32) < 0 { let err = *ffi::__errno(); ffi::lwip_close(fd); @@ -224,6 +274,9 @@ impl UdpSender { stats: SendStats::default(), announce_buf: [0u8; 64], last_hello_us: 0, + datagram: Vec::with_capacity(SEND_BUFFER_SIZE), + packet_pacer: Pacer::with_gap_us(packet_gap_us), + retry_budget_us, }) } @@ -231,6 +284,29 @@ impl UdpSender { self.stats } + pub fn network_lost(&mut self) { + if self.socket >= 0 { + unsafe { + ffi::lwip_close(self.socket); + } + self.socket = -1; + } + self.peers.clear(); + } + + pub fn restore_network(&mut self) -> Result<()> { + // Keep frame numbering and telemetry across a short radio outage. + let next_frame_id = self.next_frame_id; + let stats = self.stats; + let fresh = Self::new(self.sender_id)?; + *self = fresh; + self.next_frame_id = next_frame_id; + self.stats = stats; + self.broadcast_hello(); + self.last_hello_us = now_us(); + Ok(()) + } + /// 广播一次自身存在。 /// /// 客户端靠它发现有谁在线,所以即使当前没有订阅者也照发——这正是"板子主动 @@ -244,7 +320,10 @@ impl UdpSender { let buf = hello.encode(protocol::MsgType::Hello); // 广播地址 255.255.255.255。 - let sa = to_sockaddr(SocketAddrV4::new(protocol::BROADCAST_ADDR, protocol::DEFAULT_PORT)); + let sa = to_sockaddr(SocketAddrV4::new( + protocol::BROADCAST_ADDR, + protocol::DEFAULT_PORT, + )); // SAFETY: 缓冲区与地址在调用期间都有效,sendto 会复制而不是持有指针。 let rc = unsafe { @@ -261,7 +340,9 @@ impl UdpSender { if rc >= 0 { self.stats.hellos_sent += 1; } else { - warn!("could not broadcast Hello: errno {}", unsafe { *ffi::__errno() }); + warn!("could not broadcast Hello: errno {}", unsafe { + *ffi::__errno() + }); } } @@ -297,7 +378,9 @@ impl UdpSender { // 没有待处理的数据,这是常态。 let err = unsafe { *ffi::__errno() }; - if err != 11 /* EWOULDBLOCK */ { + if err != 11 + /* EWOULDBLOCK */ + { warn!("recvfrom on the subscribe socket failed: errno {err}"); } @@ -308,11 +391,10 @@ impl UdpSender { continue; } - let Some(_sub) = protocol::Subscribe::decode(&self.announce_buf[..rc as usize]) - else { + let Some(sub) = protocol::Subscribe::decode(&self.announce_buf[..rc as usize]) else { // 不是订阅:可能是另一块板子的 Hello,或者将来的其它消息。安静跳过, // 每包都打日志没有意义。客户端的身份由它的源地址决定,订阅里带的 - // id 只是给它自己用的,所以这里不需要读。 + // id 用于识别新客户端的一次接收会话。 continue; }; @@ -323,15 +405,29 @@ impl UdpSender { ); self.stats.subscriptions += 1; - self.upsert_peer(addr); + self.upsert_peer(addr, sub.client_id); } } /// 加入对端,或刷新已知地址的时间戳。 - fn upsert_peer(&mut self, addr: SocketAddrV4) { + fn upsert_peer(&mut self, addr: SocketAddrV4, client_id: u32) { let now = now_us(); - if let Some(peer) = self.peers.iter_mut().find(|p| p.addr == addr) { + // New clients use a random session ID. NAT/TUN source-port changes must + // move that session, not create duplicate video destinations. Legacy + // clients all used 0x0C110001, so retain address-based behavior for them. + let session_id = client_id != 0 && client_id != 0x0C11_0001; + if let Some(peer) = self.peers.iter_mut().find(|p| { + p.addr == addr || (session_id && p.client_id == client_id && p.addr.ip() == addr.ip()) + }) { + if peer.addr != addr { + info!( + "client session 0x{client_id:08X} moved from {} to {addr}", + peer.addr + ); + } + peer.addr = addr; + peer.client_id = client_id; peer.last_seen_us = now; return; } @@ -344,6 +440,7 @@ impl UdpSender { info!("client at {addr} subscribed; streaming to it"); self.peers.push(Peer { addr, + client_id, last_seen_us: now, }); } @@ -372,55 +469,125 @@ impl UdpSender { /// /// 帧缓冲在返回时就交还驱动——多占着只会增加延迟。 pub fn send(&mut self, frame: &Frame) -> Result<()> { + if frame.data().is_empty() { + return Ok(()); + } if self.peers.is_empty() { self.stats.skipped_no_peer += 1; return Ok(()); } + let started = now_us() as u64; + let mut budget = FrameBudget::new(started, frame.captured_at_us(), self.retry_budget_us); + if budget.expired(started) { + self.stats.stale_frames_dropped += 1; + return Ok(()); + } + let frame_id = self.next_frame_id; self.next_frame_id = self.next_frame_id.wrapping_add(1); + self.stats.frames_attempted += 1; + let mut active = [true; MAX_PEERS]; - let fragments = protocol::fragment_frame( - self.sender_id, - frame_id, - frame.data(), - frame.width() as u16, - frame.height() as u16, - ); + // Borrow the PSRAM JPEG in place. fragment_frame() copies every payload + // into small internal-RAM allocations, competing with Wi-Fi TX buffers. + let jpeg = frame.data(); + let frag_count = jpeg.len().div_ceil(protocol::MAX_FRAGMENT_PAYLOAD); + for (frag_index, payload) in jpeg.chunks(protocol::MAX_FRAGMENT_PAYLOAD).enumerate() { + if !active[..self.peers.len()].iter().any(|&peer| peer) { + break; + } + let header = protocol::Header { + sender_id: self.sender_id, + frame_id, + frag_index: frag_index as u16, + frag_count: frag_count as u16, + total_len: jpeg.len() as u32, + width: frame.width() as u16, + height: frame.height() as u16, + ..Default::default() + }; + self.datagram.clear(); + protocol::encode_header_into(&header, &mut self.datagram); + self.datagram.extend_from_slice(payload); - // 每帧一个可复用缓冲:头每个分片重写,但分配每帧只发生一次。在 22fps 下这是 - // 每秒 22 次与约 250 次分配的区别。 - let mut datagram = Vec::with_capacity(SEND_BUFFER_SIZE); - - for frag in &fragments { - datagram.clear(); - frag.encode_into(&mut datagram); - - for peer in &self.peers { + for (index, peer) in self.peers.iter().enumerate() { + if !active[index] { + continue; + } let sa = to_sockaddr(peer.addr); - // SAFETY: 两个缓冲区在调用期间都有效,sendto 会复制。 - let rc = unsafe { - ffi::lwip_sendto( - self.socket, - datagram.as_ptr() as *const std::ffi::c_void, - datagram.len(), - ffi::MSG_DONTWAIT as i32, - &sa as *const ffi::sockaddr_in as *const ffi::sockaddr, - std::mem::size_of::() as ffi::socklen_t, - ) - }; + loop { + if budget.expired(now_us() as u64) { + active[index] = false; + self.stats.peer_frames_dropped += 1; + self.stats.deadline_peer_drops += 1; + break; + } + let wait_us = self.packet_pacer.delay_us(now_us() as u64); + if wait_us > 0 { + unsafe { + ffi::vTaskDelay( + wait_us.div_ceil(TX_RETRY_WAIT_US as u64) as ffi::TickType_t + ) + }; + // vTaskDelay wakes on a tick boundary and can return + // before this microsecond deadline; recheck it. + continue; + } + // SAFETY: buffers remain valid until sendto has copied them. + let rc = unsafe { + ffi::lwip_sendto( + self.socket, + self.datagram.as_ptr() as *const std::ffi::c_void, + self.datagram.len(), + ffi::MSG_DONTWAIT as i32, + &sa as *const ffi::sockaddr_in as *const ffi::sockaddr, + std::mem::size_of::() as ffi::socklen_t, + ) + }; + + if rc == self.datagram.len() as isize { + self.packet_pacer.started(now_us() as u64); + self.stats.fragments_sent += 1; + self.stats.bytes_sent += rc as u64; + break; + } - if rc < 0 { self.stats.errors += 1; - } else { - self.stats.fragments_sent += 1; - self.stats.bytes_sent += rc as u64; + let errno = unsafe { *ffi::__errno() }; + self.stats.last_errno = errno; + if matches!(errno, 11 | 12 | 105) + && budget.allow_retry(now_us() as u64, TX_RETRY_WAIT_US as u64) + { + self.stats.retries += 1; + // Give the network tasks and TX completion callbacks a + // scheduler tick instead of repeatedly allocating pbufs. + unsafe { ffi::vTaskDelay(1) }; + } else { + active[index] = false; + self.stats.peer_frames_dropped += 1; + break; + } } } } - self.stats.frames_sent += 1; + let delivered = active[..self.peers.len()].iter().filter(|&&ok| ok).count(); + self.stats.peer_frames_sent += delivered as u32; + self.stats.frames_sent += u32::from(delivered > 0); + let ended = now_us() as u64; + self.stats.send_time_us += ended.saturating_sub(started); + self.stats.max_send_hold_us = self + .stats + .max_send_hold_us + .max(ended.saturating_sub(started)); + if delivered > 0 { + self.stats.max_submitted_age_us = self + .stats + .max_submitted_age_us + .max(ended.saturating_sub(frame.captured_at_us())); + } Ok(()) } @@ -452,7 +619,9 @@ impl Drop for UdpSender { fn drop(&mut self) { // SAFETY: socket 由本值拥有,且只关闭一次。 unsafe { - ffi::lwip_close(self.socket); + if self.socket >= 0 { + ffi::lwip_close(self.socket); + } } } } @@ -469,13 +638,16 @@ fn now_us() -> i64 { pub fn pump(camera: &Camera, sender: &mut UdpSender) -> Result<()> { sender.housekeeping(); - let frame = match camera.next_frame() { + let frame = match camera.latest_frame() { Ok(frame) => frame, Err(_) => { + sender.stats.capture_errors += 1; // 丢帧是可恢复的——驱动会重新同步——这里没什么可做的。 return Ok(()); } }; + sender.stats.capture_queue_skipped += frame.skipped_before(); + sender.send(&frame) }