Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc6b82d7ec
|
||
|
|
4794bed2cf | ||
|
|
e6070f91ee | ||
|
|
26731958ea | ||
|
|
de7263569c | ||
|
|
c9ce7b64ef | ||
|
|
f3b09ca9a6 | ||
|
|
1cccc1aa00 | ||
|
|
f0538fa233 | ||
|
|
47ef7c63dd | ||
|
|
b74c6654e5 | ||
|
|
ac3d639d6b | ||
|
|
2ed16ff88d | ||
|
|
6f83ebbe3c | ||
|
|
4667d94c45 | ||
|
|
76b96cc374 | ||
|
|
03f7279b53 |
+4
-11
@@ -12,14 +12,7 @@ build-std = ["std", "panic_abort"]
|
|||||||
[env]
|
[env]
|
||||||
MCU = "esp32s3"
|
MCU = "esp32s3"
|
||||||
|
|
||||||
# Use the ESP-IDF installation and toolchains that are already on the machine
|
# esp-idf-sys downloads this version unless IDF_PATH points to an existing SDK.
|
||||||
# (eim's install under ~/.espressif) instead of downloading another copy into
|
# Keep machine-specific paths in the environment instead of this shared file.
|
||||||
# ./.embuild. Without this the first build re-downloads several hundred MB of
|
ESP_IDF_VERSION = { value = "v5.5.5", force = false }
|
||||||
# Xtensa GCC/LLVM.
|
ESP_IDF_TOOLS_INSTALL_DIR = { value = "global", force = false }
|
||||||
#
|
|
||||||
# `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 }
|
|
||||||
|
|||||||
@@ -20,6 +20,54 @@ WIFI_PASS=your-password
|
|||||||
#CAM_RES=vga
|
#CAM_RES=vga
|
||||||
# JPEG quality 0-63, lower is better quality
|
# JPEG quality 0-63, lower is better quality
|
||||||
#CAM_QUALITY=12
|
#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.
|
# Set to 0 if your board inverts VSYNC in hardware.
|
||||||
#CAM_VSYNC_INVERT=1
|
#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
|
# the AP (a hidden SSID, for instance) - otherwise the scan reports the real
|
||||||
# mode and this only makes it fail faster.
|
# mode and this only makes it fail faster.
|
||||||
#WIFI_AUTH=wpa2
|
#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
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# actionlint 配置。
|
||||||
|
#
|
||||||
|
# 这个仓库的 CI 跑在 Gitea 上,但 actionlint 用的是 GitHub 的规则,所以有一条
|
||||||
|
# 政策性的误报要忽略掉:
|
||||||
|
#
|
||||||
|
# the runner of "actions/upload-artifact@v3" action is too old to run on GitHub Actions
|
||||||
|
#
|
||||||
|
# 它对 GitHub 是成立的(v3 声明的 node16 运行时 GitHub 已经不再执行),但我们是
|
||||||
|
# Gitea:Gitea 的 runner 用自己的 node 跑 JS action,v3 能正常把产物传上去;反过来
|
||||||
|
# v4 在 Gitea 上**根本跑不了** —— v4 里的 @actions/artifact 会拒绝非 github.com
|
||||||
|
# 的服务器(GHESNotSupportedError,原文见 .github/workflows/build-client.yml 文件头
|
||||||
|
# 第 3 条,那是 2026-10-15 第一次真实运行的日志里抄下来的)。
|
||||||
|
paths:
|
||||||
|
.github/workflows/*.yml:
|
||||||
|
ignore:
|
||||||
|
- 'the runner of "actions/upload-artifact@v3" action is too old to run on GitHub Actions'
|
||||||
+191
-108
@@ -2,41 +2,94 @@
|
|||||||
#
|
#
|
||||||
# 产物都挂在这次运行下面(Actions 页面直接下载),保留 14 天:
|
# 产物都挂在这次运行下面(Actions 页面直接下载),保留 14 天:
|
||||||
# Linux x64 deb + AppImage cam-desktop_<版本>_amd64.deb / .AppImage ← 默认每次编
|
# Linux x64 deb + AppImage cam-desktop_<版本>_amd64.deb / .AppImage ← 默认每次编
|
||||||
# Windows x64 NSIS 安装包 cam-desktop_<版本>_x64-setup.exe ← 有 runner 才编
|
# Windows x64 NSIS 安装包 cam-desktop_<版本>_x64-setup.exe ← 默认每次编
|
||||||
# macOS 通用 .app + .dmg arm64 与 x86_64 合成一个包 ← 有 runner 才编
|
# macOS 通用 .app + .dmg arm64 与 x86_64 合成一个包 ← 还没有机器,手动勾选才编
|
||||||
#
|
#
|
||||||
# ── 触发 ─────────────────────────────────────────────────────────────────────
|
# ── 触发 ─────────────────────────────────────────────────────────────────────
|
||||||
# * push 到 main/master —— 只认 desktop/、protocol/ 与本文件的改动,改固件不会
|
# * push 到 main/master —— 只认 desktop/、protocol/ 与本文件的改动,改固件不会
|
||||||
# 白占 runner
|
# 白占 runner
|
||||||
# * 打 v* tag、开 PR
|
# * 打 v* tag、开 PR
|
||||||
# * 手动 Run workflow:逐个平台勾选,不进 paths 过滤("就想编一次"走它)
|
# * 手动 Run workflow:逐个平台勾选,不进 paths 过滤("就想编一次"走它)
|
||||||
# Windows 与 macOS 两个 job 目前默认不跑,原因与打开方式见下面第 1 条。
|
# 平台开关一律看勾选框(理由见下面第 4 条)。
|
||||||
#
|
#
|
||||||
# ── 跑在 Gitea 上要先对上的两件事 ────────────────────────────────────────────
|
# ── 跑在 Gitea 上要先对上的两件事 ────────────────────────────────────────────
|
||||||
# 1. runner 标签是注册 runner 时自己起的名字,猜不到,所以三个 job 都写成
|
# 1. runner 标签是注册 runner 时自己起的名字,猜不到,所以三个 job 都写成
|
||||||
# "仓库变量优先、常见标签兜底":
|
# "仓库变量优先、常见标签兜底":
|
||||||
# runs-on: ${{ vars.RUNNER_LINUX || 'ubuntu-latest' }}
|
# runs-on: ${{ vars.RUNNER_LINUX || 'ubuntu-latest' }}
|
||||||
# 标签对不上时 job 是**排队**而不是报错 —— 排队会一直占着这次运行不结束,
|
# 标签对不上时 job 是**排队**而不是报错 —— 排队会一直占着这次运行不结束,
|
||||||
# 所以第一次跑之前先看一眼 runner 页面上那几行标签。
|
# 所以上线新平台前先看一眼 runner 页面上那几行标签。
|
||||||
#
|
#
|
||||||
# 本仓库目前只有 Linux 的两个 runner 标签(ubuntu-latest / ubuntu-24.04 /
|
# 2026-10-15 实测在线的(括号里是它们自己声明的标签):
|
||||||
# ubuntu-22.04),所以**默认只跑 Linux**:Windows 与 macOS 两个 job 要等
|
# * simba-runner-01 (ubuntu-latest / ubuntu-24.04 / ubuntu-22.04)
|
||||||
# 仓库变量 ENABLE_WINDOWS / ENABLE_MACOS 设成 true 才会跑(手动 Run workflow
|
# * windows-runner-01 (windows-latest / windows-2025 / windows-2022)
|
||||||
# 时另看勾选框),机器的标签则用 RUNNER_WINDOWS / RUNNER_MACOS 指过去。
|
# * macOS:还没有
|
||||||
# 等那两台机器上的 runner 注册好再打开,否则每次 push 都会多出两个永远排队的
|
# 所以 Windows 默认就跑;macOS 的 job 写成"手动勾选才跑"。
|
||||||
# job。
|
|
||||||
#
|
#
|
||||||
# 为什么不能拿 Linux runner 交叉编出来:
|
# 为什么不能拿 Linux runner 交叉编出来:
|
||||||
# * Windows:Rust 侧用 mingw 交叉编还能出个裸 exe,但 tauri 的 NSIS 打包器
|
# * Windows:Rust 侧用 mingw 交叉编还能出个裸 exe,但 tauri 的 NSIS 打包器
|
||||||
# 本身只能在 Windows 上跑,出不了安装包;
|
# 本身只能在 Windows 上跑,出不了安装包;
|
||||||
# * macOS:要 Apple SDK 与 codesign/lipo,只能在 macOS 上编。
|
# * macOS:要 Apple SDK 与 codesign/lipo,只能在 macOS 上编。
|
||||||
# 所以这两个平台的 job 留着是为了"机器到位那天直接开",不是摆设。
|
# Windows runner 上要预装好 MSVC Build Tools(link.exe)、WebView2 运行时与
|
||||||
# 那两台机器上要预装好:Windows 的 MSVC Build Tools(link.exe)、macOS 的
|
# rustup(dtolnay/rust-toolchain 靠它装工具链);macOS 上要 Xcode Command Line
|
||||||
# Xcode Command Line Tools,以及 rustup(dtolnay/rust-toolchain 靠它装工具链)。
|
# Tools + rustup。Linux 的 job 通常跑在容器里,没装 rustup 也没关系 —— 见兜底那步。
|
||||||
# Linux 的 job 通常跑在容器里,所以没装 rustup 也没关系 —— 见下面那步兜底。
|
|
||||||
# 2. 这个文件放在 .github/workflows/ 下就够:Gitea 的 [actions] WORKFLOW_DIRS
|
# 2. 这个文件放在 .github/workflows/ 下就够:Gitea 的 [actions] WORKFLOW_DIRS
|
||||||
# 默认值就是 [".gitea/workflows", ".github/workflows"](源码里的默认值,且
|
# 默认值就是 [".gitea/workflows", ".github/workflows"](源码里的默认值,且
|
||||||
# .yml 与 .yaml 都认),所以两个目录都是原生支持的,不用挪。
|
# .yml 与 .yaml 都认),所以两个目录都是原生支持的,不用挪。
|
||||||
|
# 3. 两处"GitHub 能跑、Gitea 上不行"的地方,都是 2026-10-15 第一次真实运行的
|
||||||
|
# 日志里钉出来的:
|
||||||
|
# * 传产物用 upload-artifact@v3,**不能用 v4**。v4 里的 @actions/artifact 会
|
||||||
|
# 拒绝非 github.com 的服务器,日志原文:
|
||||||
|
# GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
|
||||||
|
# and download-artifact@v4+ are not currently supported on GHES
|
||||||
|
# Gitea 实现的是 v3 那套接口(ACTIONS_RUNTIME_URL),所以 v3 能传上去。
|
||||||
|
# * 一个缓存都没开。这台实例的缓存服务在 job 容器里连不上,日志原文:
|
||||||
|
# Failed to restore: getCacheEntry failed: connect ETIMEDOUT 172.18.0.2:37315
|
||||||
|
# setup-node 的 cache 每次要干等 4 分 39 秒才放弃(两个 job 各一次)。
|
||||||
|
# 等缓存能从容器访问到了再加回来,写法就是:
|
||||||
|
# setup-node 的 with 里加:cache: pnpm / cache-dependency-path: desktop/pnpm-lock.yaml
|
||||||
|
# 各 job 里加:
|
||||||
|
# - uses: actions/cache@v4
|
||||||
|
# with:
|
||||||
|
# path: |
|
||||||
|
# ~/.cargo/registry/index
|
||||||
|
# ~/.cargo/registry/cache
|
||||||
|
# ~/.cargo/git/db
|
||||||
|
# key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }}
|
||||||
|
# restore-keys: ${{ runner.os }}-cargo-registry-
|
||||||
|
# 4. 平台开关只用 github.event.inputs,**不用仓库变量**。2026-10-15 那次 Windows# runner 明明在线、job 也确实派给了它(它自己的日志里写着
|
||||||
|
# "Runner labels: windows-latest, windows-2025, windows-2022"),产物却一直是
|
||||||
|
# skipped —— 卡在 job 级 if 的 `vars.ENABLE_WINDOWS == 'true'` 上:Gitea 的
|
||||||
|
# runner 在 job 级 if 里拿不到(或拿不对)vars 上下文,而这个条件写反的代价是
|
||||||
|
# "静悄悄不编",最难发现。同一份文件里 `github.event.inputs.linux != 'false'`
|
||||||
|
# 这个形式在 runner 日志里能看到正常求值(evaluating expression '...' evaluated
|
||||||
|
# to 'true'),所以平台开关统一成这种:
|
||||||
|
# * Linux / Windows:`github.event.inputs.<平台> != 'false'` → 默认跑,
|
||||||
|
# 手动触发时取消勾选就不跑
|
||||||
|
# * macOS:`github.event.inputs.macos == 'true'` → 默认不跑,等有机器了改成
|
||||||
|
# 上一种形式即可
|
||||||
|
# runs-on 里的 vars(RUNNER_*)不受影响,标签不匹配时用那个覆盖。
|
||||||
|
# 5. Windows runner 的 job 跑在**低完整性**上(实测 Mandatory Label\Low Mandatory
|
||||||
|
# Level, S-1-16-4096),而 Windows 不允许低完整性往中完整性写:用户目录
|
||||||
|
# (~/.rustup、~/.cargo、%TEMP%、%LOCALAPPDATA%)统统 Access denied (os error 5)
|
||||||
|
# —— ACL 是干净的(SYSTEM / Administrators / 本人 全 F,没有任何 Deny),拦它的是
|
||||||
|
# 完整性级别,所以"放宽权限"一点用都没有。表现是一串看着毫不相干的错误:
|
||||||
|
# * Git Bash 的 mktemp 写 /tmp 被拒(它的 /tmp 跟着 TEMP 走;TEMP 不可写时退回
|
||||||
|
# <Git 安装目录>\tmp,那个目录普通权限进不去);
|
||||||
|
# * rustup: could not create temp file C:\Users\admin\.rustup\tmp\xxx_file
|
||||||
|
# 处置:Windows job 把能重定向的都指到 runner 自己的工作目录
|
||||||
|
# (C:\Users\admin\runner\windows\cache,job 内写探测实测可写)——
|
||||||
|
# RUSTUP_HOME / CARGO_HOME / npm_config_cache / npm_config_store_dir /
|
||||||
|
# RUNNER_TOOL_CACHE / LOCALAPPDATA / APPDATA,加上 TEMP/TMP/TMPDIR、
|
||||||
|
# pnpm/action-setup 的 dest,以及 **USERPROFILE**。
|
||||||
|
# 最后这个是被 pnpm 逼出来的:它的 store 操作锁不在 store-dir 下面,也不看
|
||||||
|
# LOCALAPPDATA,而是按 <USERPROFILE>\AppData\Local\pnpm-store-operation-locks
|
||||||
|
# 算 —— 只把 LOCALAPPDATA 指走它照样去开原来的路径(报错原文见 run 8 的日志),
|
||||||
|
# 把 USERPROFILE 指走它才跟着走(本机实测:探针 profile 下立刻出现
|
||||||
|
# all-stores.lock)。
|
||||||
|
# 想从根上解决:让 gitea-runner 以**中完整性**启动(在普通终端里跑
|
||||||
|
# run-runner.ps1,而不是从被沙箱化的低完整性 shell 里拉起来),那样这些重定向
|
||||||
|
# 就都可以撤掉。这台机器上 runner 由计划任务 GiteaRunner-Windows(admin /
|
||||||
|
# Limited)拉起,但进程链上的完整性已经落到了 Low。
|
||||||
#
|
#
|
||||||
# ── 三个平台都显式写 --target,这不是啰嗦 ────────────────────────────────────
|
# ── 三个平台都显式写 --target,这不是啰嗦 ────────────────────────────────────
|
||||||
# desktop/.cargo/config.toml 里钉着 `target = "aarch64-apple-darwin"`(本机开发用;
|
# desktop/.cargo/config.toml 里钉着 `target = "aarch64-apple-darwin"`(本机开发用;
|
||||||
@@ -80,18 +133,18 @@ on:
|
|||||||
- ".github/workflows/build-client.yml"
|
- ".github/workflows/build-client.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
# Linux 的 runner 有,默认勾上;另两个平台目前没有 runner,默认不勾 ——
|
# Linux 与 Windows 的 runner 都在线,默认勾上;macOS 还没有机器,默认不勾
|
||||||
# 勾了而没有对应 runner,那个 job 会一直排队。
|
# (勾了会一直排队)。开关为什么不用仓库变量见文件头第 4 条。
|
||||||
linux:
|
linux:
|
||||||
description: "构建 Linux(deb + AppImage)"
|
description: "构建 Linux(deb + AppImage)"
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
windows:
|
windows:
|
||||||
description: "构建 Windows(NSIS 安装包)——需要 Windows runner"
|
description: "构建 Windows(NSIS 安装包)"
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: true
|
||||||
macos:
|
macos:
|
||||||
description: "构建 macOS(通用 dmg)——需要 macOS runner"
|
description: "构建 macOS(通用 dmg)——还没有 macOS runner,勾了会排队"
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
@@ -148,7 +201,8 @@ jobs:
|
|||||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# pnpm 必须在 setup-node 之前:setup-node 的 cache: pnpm 要用它算 store 路径。
|
# pnpm 装在 setup-node 之前:这一步只靠 runner 镜像里自带的 node(实测
|
||||||
|
# /opt/hostedtoolcache 里有 24.x),而下面的 pnpm install 要用它。
|
||||||
- name: 装 pnpm
|
- name: 装 pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
@@ -158,8 +212,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: 前端依赖(严格按 lock 装)
|
- name: 前端依赖(严格按 lock 装)
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
@@ -178,21 +230,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
targets: x86_64-unknown-linux-gnu
|
targets: x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
# 只缓存 registry(crate 源码),不缓存 target/:自建 runner 的容器每次都是
|
|
||||||
# 干净的,几 GB 的 target 缓存拉下来的时间常常比重新编译还长,磁盘也扛不住。
|
|
||||||
# 缓存服务在不同 Gitea 版本上差异较大,所以失败不让它拖垮构建。
|
|
||||||
- name: cargo 缓存
|
|
||||||
uses: actions/cache@v4
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry/index
|
|
||||||
~/.cargo/registry/cache
|
|
||||||
~/.cargo/git/db
|
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-registry-
|
|
||||||
|
|
||||||
# protocol 没有自己的 rust-toolchain.toml,会被根目录那个 `esp` 接住,
|
# protocol 没有自己的 rust-toolchain.toml,会被根目录那个 `esp` 接住,
|
||||||
# 所以这里必须 +stable 显式指定。
|
# 所以这里必须 +stable 显式指定。
|
||||||
- name: protocol 单元测试(15)
|
- name: protocol 单元测试(15)
|
||||||
@@ -272,25 +309,11 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: 前端依赖(严格按 lock 装)
|
- name: 前端依赖(严格按 lock 装)
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: cargo 缓存
|
|
||||||
uses: actions/cache@v4
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry/index
|
|
||||||
~/.cargo/registry/cache
|
|
||||||
~/.cargo/git/db
|
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-registry-
|
|
||||||
|
|
||||||
# 上面 apt 装了 ffmpeg,所以 recording.rs 里那几条"真的起 ffmpeg 并读回帧数"
|
# 上面 apt 装了 ffmpeg,所以 recording.rs 里那几条"真的起 ffmpeg 并读回帧数"
|
||||||
# 的测试不会走 has_ffmpeg() 的跳过分支,是真的在验。
|
# 的测试不会走 has_ffmpeg() 的跳过分支,是真的在验。
|
||||||
# 如果这台 runner 上它们长期不稳定,删掉这一步即可 —— 它们不在守门 job 里,
|
# 如果这台 runner 上它们长期不稳定,删掉这一步即可 —— 它们不在守门 job 里,
|
||||||
@@ -305,7 +328,7 @@ jobs:
|
|||||||
run: pnpm tauri build --target ${{ env.TARGET }} --bundles deb,appimage
|
run: pnpm tauri build --target ${{ env.TARGET }} --bundles deb,appimage
|
||||||
|
|
||||||
- name: 上传产物
|
- name: 上传产物
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: cam-desktop-linux-x64
|
name: cam-desktop-linux-x64
|
||||||
# 一个都没找到就报错:产物路径写错却"构建成功"是最难发现的一种失败。
|
# 一个都没找到就报错:产物路径写错却"构建成功"是最难发现的一种失败。
|
||||||
@@ -321,65 +344,143 @@ jobs:
|
|||||||
windows:
|
windows:
|
||||||
name: Windows x64(NSIS 安装包)
|
name: Windows x64(NSIS 安装包)
|
||||||
needs: checks
|
needs: checks
|
||||||
# 默认不跑:本仓库目前没有 Windows runner,跑了只会一直排队。有了机器之后
|
# 默认就跑:windows-runner-01 在线(自报标签 windows-latest)。
|
||||||
# 在仓库变量里加 ENABLE_WINDOWS=true 就会跟着每次 push 自动跑;手动触发时
|
# 开关用 inputs 而不是仓库变量的原因见文件头第 4 条 —— 这个坑正是它踩出来的。
|
||||||
# 勾 "构建 Windows" 也能单独跑一次。标签用 RUNNER_WINDOWS 指。
|
if: ${{ github.event.inputs.windows != 'false' }}
|
||||||
if: >-
|
# 这台机器上要预装:MSVC Build Tools(tauri 的 Rust 侧要 link.exe)、WebView2
|
||||||
${{
|
# 运行时(装了包的应用要它才能起窗口;打包本身不需要)与 rustup。
|
||||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.windows == 'true')
|
|
||||||
|| (github.event_name != 'workflow_dispatch' && vars.ENABLE_WINDOWS == 'true')
|
|
||||||
}}
|
|
||||||
# 自建 Windows runner 上要有 MSVC Build Tools(tauri 的 Rust 侧要 link.exe)、
|
|
||||||
# WebView2 运行时(装了包的应用要它才能起窗口;打包本身不需要)与 rustup。
|
|
||||||
runs-on: ${{ vars.RUNNER_WINDOWS || 'windows-latest' }}
|
runs-on: ${{ vars.RUNNER_WINDOWS || 'windows-latest' }}
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
env:
|
env:
|
||||||
TARGET: x86_64-pc-windows-msvc
|
TARGET: x86_64-pc-windows-msvc
|
||||||
CARGO_BUILD_TARGET: x86_64-pc-windows-msvc
|
CARGO_BUILD_TARGET: x86_64-pc-windows-msvc
|
||||||
|
# 见文件头第 5 条:这台 runner 的 job 跑在**低完整性**(Low Mandatory Level,
|
||||||
|
# S-1-16-4096)上,而 Windows 不允许低完整性往中完整性写 —— 用户目录全中招:
|
||||||
|
# ~/.rustup、~/.cargo、%TEMP%、%LOCALAPPDATA% 下新建任何东西都是
|
||||||
|
# "Access denied (os error 5)",ACL 再宽松也没用(没有 Deny ACE,是完整性
|
||||||
|
# 级别拦的)。所以 CI 用到的每个"家目录"都挪到 runner 自己的工作目录下,
|
||||||
|
# 那几处在 job 内的写探测里实测可写。换一台 Windows runner 时改这几行。
|
||||||
|
RUSTUP_HOME: C:\Users\admin\runner\windows\cache\ci-rustup
|
||||||
|
CARGO_HOME: C:\Users\admin\runner\windows\cache\ci-cargo
|
||||||
|
# LOCALAPPDATA/APPDATA 也得改:pnpm 12 的 store 锁与 cache/state 都挂在
|
||||||
|
# %LOCALAPPDATA% 下(报错原文:pnpm-store-operation-locks\all-stores.lock:
|
||||||
|
# 拒绝访问),tauri 打包时下载的 NSIS 也放这儿。本机实测:把 LOCALAPPDATA
|
||||||
|
# 指到可写目录后,pnpm 会在那里建 pnpm-cache / pnpm-state,install 正常。
|
||||||
|
LOCALAPPDATA: C:\Users\admin\runner\windows\cache\ci-localappdata
|
||||||
|
APPDATA: C:\Users\admin\runner\windows\cache\ci-appdata
|
||||||
|
npm_config_cache: C:\Users\admin\runner\windows\cache\ci-npm
|
||||||
|
RUNNER_TOOL_CACHE: C:\Users\admin\runner\windows\cache\ci-toolcache
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# 宿主模式的 Windows runner 上,PATH 里的 `bash` 是 C:\Windows\System32\bash.exe
|
||||||
|
# —— WSL 的启动器,而 WSL 在 runner 账户下起不来,报 E_ACCESSDENIED(日志里是
|
||||||
|
# 一串 GBK 被当 UTF-8 解出来的乱码 + https://github.com/microsoft/WSL/issues)。
|
||||||
|
# 于是任何 `shell: bash` 的步骤、以及用 bash 写的 composite action
|
||||||
|
# (dtolnay/rust-toolchain 就是),都会在 0 秒内失败。
|
||||||
|
# Git for Windows 自带 bash,把它的 bin 前置到 PATH —— GITHUB_PATH 的语义就是
|
||||||
|
# **前置**插入,所以它会盖过 System32 那个 —— 后面这些步骤才跑得起来。
|
||||||
|
#
|
||||||
|
# 换掉 bash 之后还有第二个坑:这个 job 的环境里 TEMP/TMP 是空的,而 Git Bash 的
|
||||||
|
# /tmp 是从 TEMP 推出来的;没有 TEMP 它会退回 <Git 安装目录>\tmp
|
||||||
|
# (C:\Program Files\Git\tmp),那个目录普通权限写不进去,于是 dtolnay 里那句
|
||||||
|
# log=$(mktemp) 直接挂掉:
|
||||||
|
# mktemp: failed to create file via template '/tmp/tmp.XXXXXXXXXX': Permission denied
|
||||||
|
# 所以顺手给 job 指定一个自己建的可写目录(workspace 的同级 ci-tmp)。
|
||||||
|
# 两个坑都是 2026-10-15 真跑 Windows job 时踩出来的,本地按这样改完后
|
||||||
|
# mktemp、rustup toolchain install、rustup target add 全部正常。
|
||||||
|
- name: 让 bash 指向 Git for Windows,并给 job 一个可写的临时目录
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
if (-not $env:GITHUB_PATH) { throw '这个 runner 没提供 GITHUB_PATH,没法给后续步骤改 PATH' }
|
||||||
|
if (-not $env:GITHUB_ENV) { throw '这个 runner 没提供 GITHUB_ENV,没法给后续步骤加变量' }
|
||||||
|
if (-not $env:GITHUB_WORKSPACE) { throw '这个 runner 没提供 GITHUB_WORKSPACE' }
|
||||||
|
|
||||||
|
$gitBash = 'C:\Program Files\Git\bin'
|
||||||
|
if (-not (Test-Path (Join-Path $gitBash 'bash.exe'))) {
|
||||||
|
throw "找不到 $gitBash\bash.exe:这台机器上没装 Git for Windows,bash 会落到 WSL 上"
|
||||||
|
}
|
||||||
|
Add-Content -Path $env:GITHUB_PATH -Value $gitBash -Encoding ascii
|
||||||
|
|
||||||
|
$tmpRoot = Join-Path (Split-Path -Parent $env:GITHUB_WORKSPACE) 'ci-tmp'
|
||||||
|
New-Item -ItemType Directory -Force -Path $tmpRoot | Out-Null
|
||||||
|
Add-Content -Path $env:GITHUB_ENV -Value "TEMP=$tmpRoot" -Encoding ascii
|
||||||
|
Add-Content -Path $env:GITHUB_ENV -Value "TMP=$tmpRoot" -Encoding ascii
|
||||||
|
Add-Content -Path $env:GITHUB_ENV -Value "TMPDIR=$($tmpRoot -replace '\\', '/')" -Encoding ascii
|
||||||
|
|
||||||
|
# 第三个坑:pnpm 的 store 操作锁是按 <USERPROFILE>\AppData\Local\
|
||||||
|
# pnpm-store-operation-locks 算的 —— 实测只把 LOCALAPPDATA 指走它不跟,
|
||||||
|
# 只把 USERPROFILE 指走它就跟着走(换完 USERPROFILE 后探针目录里立刻出现
|
||||||
|
# all-stores.lock)。所以 USERPROFILE 也得指到能写的地方,并且先把
|
||||||
|
# AppData\Local、AppData\Roaming 建好。
|
||||||
|
$profileRoot = Join-Path (Split-Path -Parent $env:GITHUB_WORKSPACE) 'ci-profile'
|
||||||
|
New-Item -ItemType Directory -Force -Path (Join-Path $profileRoot 'AppData\Local'), (Join-Path $profileRoot 'AppData\Roaming') | Out-Null
|
||||||
|
Add-Content -Path $env:GITHUB_ENV -Value "USERPROFILE=$profileRoot" -Encoding ascii
|
||||||
|
|
||||||
|
"bash = $(Join-Path $gitBash 'bash.exe')"
|
||||||
|
"TEMP/TMP/TMPDIR = $tmpRoot"
|
||||||
|
"USERPROFILE = $profileRoot"
|
||||||
|
|
||||||
- name: 装 Rust(stable)
|
- name: 装 Rust(stable)
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
targets: x86_64-pc-windows-msvc
|
targets: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
- name: 装 pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: ${{ env.PNPM_VERSION }}
|
|
||||||
|
|
||||||
- name: 装 Node
|
- name: 装 Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
# 这里**不用** pnpm/action-setup:它装的是 standalone 的 pnpm 二进制,而那个
|
||||||
|
# 二进制在这台 runner 的低完整性沙箱里走到"把包装进 virtual store"时必崩:
|
||||||
|
# thread '<unknown>' (6716) has overflowed its stack
|
||||||
|
# corepack 拉下来的是同一个二进制(也崩),所以改成用 npm 装 JS 版 pnpm ——
|
||||||
|
# 它跑在 node 上,是另一条代码路径。装到 node 自己的目录里(RUNNER_TOOL_CACHE
|
||||||
|
# 已经指到可写位置,见 job env)。
|
||||||
|
- name: 装 pnpm(npm 装 JS 版,绕开 standalone 二进制)
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
# 前面几次尝试在 node 目录里留下过 pnpm/pnpx 的影子文件,npm 会以
|
||||||
|
# EEXIST 拒绝覆盖,先清掉(node 目录在 RUNNER_TOOL_CACHE 下,可写)。
|
||||||
|
$nodeDir = Split-Path (Get-Command node).Source
|
||||||
|
foreach ($n in 'pnpm', 'pnpx', 'pnpm.cmd', 'pnpx.cmd', 'pnpm.ps1', 'pnpx.ps1') {
|
||||||
|
Remove-Item (Join-Path $nodeDir $n) -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
npm install -g pnpm@12.8.1
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "npm 装 pnpm 失败(exit $LASTEXITCODE)" }
|
||||||
|
"pnpm = $((Get-Command pnpm).Source)"
|
||||||
|
pnpm --version
|
||||||
|
|
||||||
- name: 前端依赖(严格按 lock 装)
|
- name: 前端依赖(严格按 lock 装)
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
run: pnpm install --frozen-lockfile
|
shell: pwsh
|
||||||
|
# 这台 runner 的 job 跑在低完整性沙箱里(见文件头第 5 条),standalone 的
|
||||||
|
# pnpm 到"把包装进 virtual store"那一步会直接栈溢出:
|
||||||
|
# thread '<unknown>' (6716) has overflowed its stack
|
||||||
|
# (store 已经在可写目录、也改成 copy 导入了,仍然复现;同样的命令在普通
|
||||||
|
# Medium 会话里跑得好好的。)所以先试 standalone,崩了就退回 corepack:
|
||||||
|
# 让 node 去跑 pnpm 的 JS 实现,绕开那个二进制。
|
||||||
|
run: |
|
||||||
|
$pnpmArgs = @('install', '--frozen-lockfile',
|
||||||
|
'--store-dir', 'C:\Users\admin\runner\windows\cache\ps',
|
||||||
|
'--package-import-method=copy', '--reporter=append-only')
|
||||||
|
pnpm @pnpmArgs
|
||||||
|
if ($LASTEXITCODE -eq 0) { exit 0 }
|
||||||
|
"::warning::standalone pnpm 失败(exit $LASTEXITCODE),改用 corepack + node 跑 pnpm"
|
||||||
|
corepack enable
|
||||||
|
corepack prepare pnpm@12.8.1 --activate
|
||||||
|
pnpm @pnpmArgs
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
|
||||||
- name: cargo 缓存
|
# nsis 是纯安装包,不需要 WiX;tauri 会自己把 NSIS 下到用户缓存里(这个缓存
|
||||||
uses: actions/cache@v4
|
# 也跟着 LOCALAPPDATA 走,见上面 job env),所以这台 runner 要能访问 GitHub
|
||||||
continue-on-error: true
|
# (离线环境改成 --bundles msi 也一样要下 WiX)。
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry/index
|
|
||||||
~/.cargo/registry/cache
|
|
||||||
~/.cargo/git/db
|
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-registry-
|
|
||||||
|
|
||||||
# nsis 是纯安装包,不需要 WiX;tauri 会自己把 NSIS 下到用户缓存里,所以
|
|
||||||
# 这台 runner 要能访问 GitHub(离线环境改成 --bundles msi 也一样要下 WiX)。
|
|
||||||
- name: 打包(tauri build)
|
- name: 打包(tauri build)
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
run: pnpm tauri build --target ${{ env.TARGET }} --bundles nsis
|
run: pnpm tauri build --target ${{ env.TARGET }} --bundles nsis
|
||||||
|
|
||||||
- name: 上传产物
|
- name: 上传产物
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: cam-desktop-windows-x64
|
name: cam-desktop-windows-x64
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
@@ -393,14 +494,10 @@ jobs:
|
|||||||
macos:
|
macos:
|
||||||
name: macOS 通用(.app + .dmg)
|
name: macOS 通用(.app + .dmg)
|
||||||
needs: checks
|
needs: checks
|
||||||
# 同 Windows:默认不跑,有机器之后设仓库变量 ENABLE_MACOS=true,标签用
|
# 还没有 macOS runner,所以只有"手动触发 + 勾上"才跑;等机器注册好,把这一行
|
||||||
# RUNNER_MACOS 指。
|
# 换成 `github.event.inputs.macos != 'false'` 就变成默认跑(同 Windows)。
|
||||||
if: >-
|
if: ${{ github.event.inputs.macos == 'true' }}
|
||||||
${{
|
# 那台机器上要有 Xcode Command Line Tools(链接要它)与 rustup。
|
||||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.macos == 'true')
|
|
||||||
|| (github.event_name != 'workflow_dispatch' && vars.ENABLE_MACOS == 'true')
|
|
||||||
}}
|
|
||||||
# 自建 macOS runner 上要有 Xcode Command Line Tools(链接要它)与 rustup。
|
|
||||||
runs-on: ${{ vars.RUNNER_MACOS || 'macos-latest' }}
|
runs-on: ${{ vars.RUNNER_MACOS || 'macos-latest' }}
|
||||||
timeout-minutes: 150
|
timeout-minutes: 150
|
||||||
env:
|
env:
|
||||||
@@ -426,25 +523,11 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: desktop/pnpm-lock.yaml
|
|
||||||
|
|
||||||
- name: 前端依赖(严格按 lock 装)
|
- name: 前端依赖(严格按 lock 装)
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: cargo 缓存
|
|
||||||
uses: actions/cache@v4
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry/index
|
|
||||||
~/.cargo/registry/cache
|
|
||||||
~/.cargo/git/db
|
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-registry-
|
|
||||||
|
|
||||||
# 只想要当前架构、想省一半编译时间的话,把 --target 换成 aarch64-apple-darwin
|
# 只想要当前架构、想省一半编译时间的话,把 --target 换成 aarch64-apple-darwin
|
||||||
# (Apple Silicon 的 runner)并去掉上面 targets 里的 x86_64 即可。
|
# (Apple Silicon 的 runner)并去掉上面 targets 里的 x86_64 即可。
|
||||||
- name: 打包(tauri build)
|
- name: 打包(tauri build)
|
||||||
@@ -454,7 +537,7 @@ jobs:
|
|||||||
# 第二行是兜底:不同 tauri-cli 版本给通用包放的目录略有差异,宁可多写一行,
|
# 第二行是兜底:不同 tauri-cli 版本给通用包放的目录略有差异,宁可多写一行,
|
||||||
# 也不要上传时静默少东西。(注意 path 是块标量,里面写 # 是路径不是注释。)
|
# 也不要上传时静默少东西。(注意 path 是块标量,里面写 # 是路径不是注释。)
|
||||||
- name: 上传产物
|
- name: 上传产物
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: cam-desktop-macos-universal
|
name: cam-desktop-macos-universal
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|||||||
+10
@@ -12,6 +12,13 @@
|
|||||||
/target/
|
/target/
|
||||||
/protocol/target/
|
/protocol/target/
|
||||||
/armor/target/
|
/armor/target/
|
||||||
|
/.embuild/
|
||||||
|
/managed_components/
|
||||||
|
/sdkconfig
|
||||||
|
/sdkconfig.old
|
||||||
|
/sdkconfig.*
|
||||||
|
!/sdkconfig.defaults
|
||||||
|
!/sdkconfig.defaults.*
|
||||||
|
|
||||||
# 本机专属的 cargo 注册表缓存。
|
# 本机专属的 cargo 注册表缓存。
|
||||||
#
|
#
|
||||||
@@ -29,6 +36,9 @@
|
|||||||
# macOS 的目录元数据。不锚定,子目录里的也一起忽略。
|
# macOS 的目录元数据。不锚定,子目录里的也一起忽略。
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Python diagnostic helpers.
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
# `docs/simba_docs`:工作区里另起的一个 Astro 文档站,与本仓库的目录结构无关,
|
# `docs/simba_docs`:工作区里另起的一个 Astro 文档站,与本仓库的目录结构无关,
|
||||||
# 也不属于本项目的产物。留着不跟踪,免得以后 `git add -A` 顺手把它带进来。
|
# 也不属于本项目的产物。留着不跟踪,免得以后 `git add -A` 顺手把它带进来。
|
||||||
/docs/
|
/docs/
|
||||||
|
|||||||
+102
@@ -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`。
|
||||||
+129
@@ -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. 分别验证多端订阅、断网重连、不同分辨率与持续运行。
|
||||||
|
|
||||||
|
编译、镜像生成与主机测试通过均不等于烧录、板上运行或硬件稳定性已经验证。
|
||||||
+77
@@ -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/`。
|
||||||
+88
@@ -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)。
|
||||||
@@ -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)。这些文档说明机制,性能数字来自上述本机测量。
|
||||||
@@ -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 内容不同,不能将差值归因于功率这一项。
|
||||||
@@ -1,395 +1,294 @@
|
|||||||
# ESP32-S3 摄像头图传(Rust / ESP-IDF / UDP)
|
# ESP32-S3 摄像头图传系统
|
||||||
|
|
||||||
两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV5640),向队伍客户端与裁判终端推
|
基于 **Rust + ESP-IDF** 的局域网实时图传系统。ESP32-S3 采集摄像头输出的 JPEG,通过 UDP 向桌面客户端推流;桌面端提供队伍端与裁判端界面,并接收 ESP32-C3 装甲板的血量广播。
|
||||||
UDP 图传。
|
|
||||||
|
|
||||||
固件是跑在 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
|
```text
|
||||||
protocol/ 线路格式 + 帧重组 三端共用,无 ESP-IDF 依赖,14 个单元测试
|
ESP32-S3 图传板 A ─┐ ┌─ 队伍客户端
|
||||||
. 图传固件 -> ESP32-S3 交叉编译,Xtensa
|
├─ 同一局域网 / UDP ─┼─ 裁判客户端
|
||||||
armor/ 装甲板固件 -> ESP32-C3 独立 workspace,RISC-V;规则层 22 个单元测试
|
ESP32-S3 图传板 B ─┤ └─ 队伍客户端
|
||||||
desktop/ 桌面端 Tauri v2 + React;用户端(双方血条 + 瞄准器)/ 裁判端(可改队名)
|
ESP32-C3 装甲主板 ─┘
|
||||||
core/ 不含界面与 Tauri 的网络层、配置、密码(可单测)
|
|
||||||
src-tauri/ Tauri 后端:推流、命令、录制
|
|
||||||
src/ React 前端:两套页面 + HUD
|
|
||||||
```
|
```
|
||||||
|
|
||||||
五份产物各自独立构建,各有自己的 `.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
|
```text
|
||||||
┌──────────────────────────────┐
|
rustup toolchain install stable
|
||||||
│ 裁判终端(PC,仅显示) │
|
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
|
||||||
┌────────────────────┼────────────────────┐
|
|
||||||
│ │ │
|
|
||||||
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
|
|
||||||
│ 图传板 A │ │ 图传板 B │ │ 装甲板主板 │
|
|
||||||
│ (队伍一) │ │ (队伍二) │ │ ×2(每队一块)│
|
|
||||||
└───────────────┘ └───────────────┘ └───────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**图传板不区分队伍。** 它们只知道自己是谁(MAC 派生的 `sender_id`),"哪一边"由
|
也可以从各工具的官方 releases 下载预编译程序。`espup --std` 安装本项目所需的 Rust / LLVM 工具,ESP-IDF 的 C 工具链由后续构建准备。
|
||||||
客户端决定。烧同一份固件即可,配置全在客户端一侧。
|
|
||||||
|
|
||||||
**装甲板反过来:队伍是编译期定的**(`ARMOR_TEAM=0/1`,同时决定灯色红/蓝),因为血量
|
Linux / macOS 每次新开终端后执行:
|
||||||
上报里必须带 `team` 字段,而且灯色现场不该靠按键切错。见 `armor/README.md`。
|
|
||||||
|
|
||||||
裁判终端同时收两块图传板,也收两块装甲板主板在 5601 上广播的血量。队伍客户端也收
|
```bash
|
||||||
全部板子的视频,然后在**界面层**过滤到绑定的那一块;**血量相反,两边都显示**——用户端的
|
. "$HOME/export-esp.sh"
|
||||||
画面左上角是自己、右上角是对手。血量本来就是明文广播的(裁判端与用户端收的是同一份),而
|
|
||||||
"自己的血与对手的血一起看"是这项运动里最基本的对照,藏起来只会让操作手看不见对手的血。
|
|
||||||
|
|
||||||
## 实测性能
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`flash.sh` 会编译、挑选避开坏字节的 app 偏移、烧录三段(bootloader / 分区表 /
|
Windows 安装后重新打开 PowerShell,让环境变量生效;若当前终端仍找不到 LLVM,可执行 `. "$HOME/export-esp.ps1"`。检查结果:
|
||||||
app)、打印串口日志。ESP-IDF 位置从 `.cargo/config.toml` 读,不用先 `activate`。
|
|
||||||
|
|
||||||
`.env`:
|
```text
|
||||||
|
rustup toolchain list
|
||||||
```sh
|
rustc +esp-1.97.0.0 --version
|
||||||
WIFI_SSID=你的2.4G网络
|
cargo +esp-1.97.0.0 --version
|
||||||
WIFI_PASS=密码
|
espflash --version
|
||||||
|
|
||||||
# 装甲板(armor/)用这两个里的第一个;队伍号决定灯色与上报里的 team 字段:
|
|
||||||
#ARMOR_TEAM=0 # 0 = 队伍一(红),1 = 队伍二(蓝)
|
|
||||||
|
|
||||||
# 以下都有默认值:
|
|
||||||
#CAM_RES=vga # qqvga | qvga | cif | hvga | vga | svga | xga | sxga | uxga
|
|
||||||
#CAM_QUALITY=12 # 0-63,数字越小画质越好、字节越少
|
|
||||||
#CAM_VSYNC_INVERT=1 # 若板子在硬件上反相了 VSYNC,填 0
|
|
||||||
#WIFI_AUTH=wpa2 # 固定认证方式,仅当扫描看不到 AP 时需要(如隐藏 SSID)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
凭据由 `env!` 在**编译期**读取,所以没有运行时解析路径,也不会因为忘了读 `.env` 而
|
官方入门资料:[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)。
|
||||||
在板子上带着别人的密码。`ARMOR_TEAM` 同理:换队伍重刷装甲板固件即可,不用擦 NVS。
|
|
||||||
|
|
||||||
`WIFI_AUTH` 一般不用设:固件先扫描 AP,用它报告的真实认证方式去连,只有扫不到时才
|
### 2. 准备 ESP-IDF
|
||||||
需要人工指定。指定之后**只**试这一种方式——不成功就直接报错,而不是挨个试 10 秒。
|
|
||||||
挨个试等于在猜一个本该已知的值。
|
|
||||||
|
|
||||||
### 2. 装甲板固件(ESP32-C3)
|
默认使用 **ESP-IDF v5.5.5**。首次构建时,`esp-idf-sys` 下载 SDK、对应 C 工具链及 Python 依赖,默认缓存到用户目录的 `.espressif/`;需要网络、磁盘空间及缓存目录写入权限。
|
||||||
|
|
||||||
```sh
|
已有匹配版本 SDK 时可在终端设置 `IDF_PATH`,不要提交个人绝对路径:
|
||||||
scripts/flash_armor.sh # 编译 + 烧录 + 20 秒串口日志
|
|
||||||
python3 scripts/armor_probe.py 15 # 不开界面,单看板子在广播什么血量
|
```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` 不能绕过该检查。下表的 `<TARGET_DIR>` 在 Windows 示例中为 `C:/s3vt`,Linux / macOS 默认是仓库的 `target`。每次新开 Windows 终端都要设置相同的 `CARGO_TARGET_DIR`。
|
||||||
|
|
||||||
|
| 产物 | 位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 固件 ELF | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/esp32`(没有 `.exe` 后缀) |
|
||||||
|
| Bootloader | `<TARGET_DIR>/xtensa-esp32s3-espidf/release/bootloader.bin` |
|
||||||
|
| 分区表 BIN | `<TARGET_DIR>/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
|
cd desktop
|
||||||
pnpm install
|
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 换成板卡实际地址。对照测试时先退出桌面客户端,避免额外订阅者增加无线负载:
|
||||||
|
|
||||||
单靠一个方向建立不了"谁要看我":
|
```text
|
||||||
|
python scripts/perf_probe.py 192.168.1.139 --seconds 30 --json perf.json
|
||||||
- 板子每 **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-*
|
|
||||||
```
|
```
|
||||||
|
|
||||||
不删不会重新编译,你的补丁看起来像完全没生效。
|
脚本定向续订,支持保留 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
|
- [CHANGELOG.md](CHANGELOG.md):改动记录与历史修复。
|
||||||
编码器**(整个 ESP32 系列只有 P4 有,`esp_driver_jpeg` 里
|
- [HARDWARE.md](HARDWARE.md):接线、内存、Flash 与驱动限制。
|
||||||
`CONFIG_IDF_TARGET_ESP32P4` 是写死的)。
|
- [DEVELOPMENT.md](DEVELOPMENT.md):镜像导出、测试、协议与性能测量。
|
||||||
|
- [PERFORMANCE.md](PERFORMANCE.md):保留 TUN 的图传排查、参数取舍与实际测试结果。
|
||||||
同理,客户端也不编码:板子出的就是传感器里的 JPEG,客户端只解码。ESP32-S3 也没有硬
|
- [STABILITY.md](STABILITY.md):限帧、灰度与温度减载,长时间运行测试。
|
||||||
件 JPEG 解码器。
|
- [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 线路格式及帧重组。
|
||||||
一张 VGA 的 JPEG 解码要几毫秒。放在收包线程里会挤进数据报之间,造成**链路上本不存在
|
- [armor/README.md](armor/README.md):装甲板固件与规则。
|
||||||
的丢帧**——不是真的丢了网络包,是应用来不及取走 socket 缓冲。
|
- [desktop/README.md](desktop/README.md):桌面端配置、录制与排查。
|
||||||
|
|
||||||
现在桌面端把 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 打进帧头。
|
|
||||||
|
|||||||
+73
@@ -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`,不包含私有日志、用户配置或相机抽帧。
|
||||||
+135
@@ -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 最终绘制。接收间隔与分片组装耗时均不是摄像头到屏幕的光学延迟。
|
||||||
@@ -2,4 +2,4 @@ idf_component_register(SRCS "cam_wrapper.c"
|
|||||||
INCLUDE_DIRS "include"
|
INCLUDE_DIRS "include"
|
||||||
# esp_timer for the frame-rate probe; the camera component
|
# esp_timer for the frame-rate probe; the camera component
|
||||||
# pulls in the LCD_CAM driver and the FreeRTOS task API.
|
# pulls in the LCD_CAM driver and the FreeRTOS task API.
|
||||||
REQUIRES esp32-camera esp_timer)
|
REQUIRES esp32-camera esp_timer esp_driver_tsens)
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
/* Implementation of the cam_wrapper shim around esp32-camera. See cam_wrapper.h. */
|
/* Implementation of the cam_wrapper shim around esp32-camera. See cam_wrapper.h. */
|
||||||
|
|
||||||
#include "cam_wrapper.h"
|
#include "cam_wrapper.h"
|
||||||
|
#include "driver/temperature_sensor.h"
|
||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "esp_timer.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 const char *TAG = "cam_wrapper";
|
||||||
|
|
||||||
static sensor_t *s_sensor;
|
static sensor_t *s_sensor;
|
||||||
static char s_sensor_name[16] = "unknown";
|
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)
|
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. */
|
* two is dropping frames. */
|
||||||
#define PROBE_FRAMES 20
|
#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;
|
uint32_t total = 0;
|
||||||
int64_t start = esp_timer_get_time();
|
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) {
|
if (taken == 0 || elapsed_us <= 0) {
|
||||||
ESP_LOGW(TAG, "fps probe: got no frames");
|
ESP_LOGW(TAG, "fps probe: got no frames");
|
||||||
return;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
double fps = (double)taken * 1000000.0 / (double)elapsed_us;
|
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)",
|
"(sensor ceiling, no network involved)",
|
||||||
taken, (long long)(elapsed_us / 1000), fps,
|
taken, (long long)(elapsed_us / 1000), fps,
|
||||||
(unsigned)(total / (uint32_t)taken));
|
(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)
|
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->height = fb->height;
|
||||||
out->format = (int)fb->format;
|
out->format = (int)fb->format;
|
||||||
out->handle = fb;
|
out->handle = fb;
|
||||||
|
out->capture_started_us = (int64_t)fb->timestamp.tv_sec * 1000000 + fb->timestamp.tv_usec;
|
||||||
|
|
||||||
return ESP_OK;
|
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)
|
void cam_wrapper_fb_release(cam_wrapper_fb_t *fb)
|
||||||
{
|
{
|
||||||
if (fb == NULL || fb->handle == NULL) {
|
if (fb == NULL || fb->handle == NULL) {
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ typedef struct {
|
|||||||
size_t height;
|
size_t height;
|
||||||
int format;
|
int format;
|
||||||
void *handle; /* internal driver frame; do not touch */
|
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;
|
} cam_wrapper_fb_t;
|
||||||
|
|
||||||
esp_err_t cam_wrapper_init(const cam_wrapper_config_t *config, cam_wrapper_sensor_t sensor);
|
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
|
/* 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). */
|
* 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(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);
|
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. */
|
/* Name of the sensor the driver detected, or "unknown". Never NULL. */
|
||||||
const char *cam_wrapper_sensor_name(void);
|
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
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -733,13 +733,14 @@ camera_fb_t *cam_take(TickType_t timeout)
|
|||||||
for (;;)
|
for (;;)
|
||||||
{
|
{
|
||||||
TickType_t elapsed = xTaskGetTickCount() - start; /* TickType_t is unsigned so rollover is safe */
|
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");
|
ESP_LOGW(TAG, "Failed to get frame: timeout");
|
||||||
return NULL;
|
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 (xQueueReceive(cam_obj->frame_buffer_queue, (void *)&dma_buffer, remaining) == pdFALSE) {
|
||||||
|
if (!timeout) return NULL;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,6 +751,7 @@ camera_fb_t *cam_take(TickType_t timeout)
|
|||||||
if (dma_reset_counter < MAX_GDMA_RESETS) {
|
if (dma_reset_counter < MAX_GDMA_RESETS) {
|
||||||
ll_cam_dma_reset(cam_obj);
|
ll_cam_dma_reset(cam_obj);
|
||||||
dma_reset_counter++;
|
dma_reset_counter++;
|
||||||
|
if (!timeout) return NULL;
|
||||||
continue; /* retry with queue timeout */
|
continue; /* retry with queue timeout */
|
||||||
}
|
}
|
||||||
if (dma_reset_counter == MAX_GDMA_RESETS) {
|
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,
|
CAM_WARN_THROTTLE(warn_null_cnt,
|
||||||
"Unexpected NULL frame on " CONFIG_IDF_TARGET);
|
"Unexpected NULL frame on " CONFIG_IDF_TARGET);
|
||||||
#endif
|
#endif
|
||||||
|
if (!timeout) return NULL;
|
||||||
vTaskDelay(1); /* immediate yield once resets are done */
|
vTaskDelay(1); /* immediate yield once resets are done */
|
||||||
continue; /* go to top of loop */
|
continue; /* go to top of loop */
|
||||||
}
|
}
|
||||||
@@ -802,6 +805,7 @@ skip_eoi_check:
|
|||||||
CAM_WARN_THROTTLE(warn_eoi_miss_cnt,
|
CAM_WARN_THROTTLE(warn_eoi_miss_cnt,
|
||||||
"NO-EOI - JPEG end marker missing");
|
"NO-EOI - JPEG end marker missing");
|
||||||
cam_give(dma_buffer);
|
cam_give(dma_buffer);
|
||||||
|
if (!timeout) return NULL;
|
||||||
continue; /* wait for another frame */
|
continue; /* wait for another frame */
|
||||||
} else if (cam_obj->psram_mode &&
|
} else if (cam_obj->psram_mode &&
|
||||||
cam_obj->in_bytes_per_pixel != cam_obj->fb_bytes_per_pixel) {
|
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)
|
void cam_give(camera_fb_t *dma_buffer)
|
||||||
{
|
{
|
||||||
for (int x = 0; x < cam_obj->frame_cnt; x++) {
|
for (int x = 0; x < cam_obj->frame_cnt; x++) {
|
||||||
|
|||||||
@@ -401,6 +401,19 @@ camera_fb_t *esp_camera_fb_get()
|
|||||||
return fb;
|
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)
|
void esp_camera_fb_return(camera_fb_t *fb)
|
||||||
{
|
{
|
||||||
if (s_state == NULL) {
|
if (s_state == NULL) {
|
||||||
|
|||||||
@@ -206,6 +206,9 @@ esp_err_t esp_camera_deinit(void);
|
|||||||
* @return pointer to the frame buffer
|
* @return pointer to the frame buffer
|
||||||
*/
|
*/
|
||||||
camera_fb_t* esp_camera_fb_get(void);
|
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.
|
* @brief Return the frame buffer to be reused again.
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ void cam_stop(void);
|
|||||||
void cam_start(void);
|
void cam_start(void);
|
||||||
|
|
||||||
camera_fb_t *cam_take(TickType_t timeout);
|
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);
|
void cam_give(camera_fb_t *dma_buffer);
|
||||||
|
|
||||||
|
|||||||
+47
-12
@@ -12,8 +12,8 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
desktop/
|
desktop/
|
||||||
core/ cam-core:不含任何界面与 Tauri 的东西(80 个测试)
|
core/ cam-core:不含任何界面与 Tauri 的东西(83 个测试)
|
||||||
src-tauri/ Tauri 后端:推流、命令、录制(30 个测试)
|
src-tauri/ Tauri 后端:推流、命令、录制(32 个测试)
|
||||||
src/ React 前端:两套页面 + HUD
|
src/ React 前端:两套页面 + HUD
|
||||||
src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化
|
src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化
|
||||||
src/components/ 视频窗格、血条、胜负横幅、瞄准器、装甲板表、队名编辑、录制面板、设置抽屉
|
src/components/ 视频窗格、血条、胜负横幅、瞄准器、装甲板表、队名编辑、录制面板、设置抽屉
|
||||||
@@ -27,16 +27,37 @@ cd desktop
|
|||||||
pnpm install
|
pnpm install
|
||||||
pnpm app # = tauri dev:起 vite + 编 Rust + 开窗口
|
pnpm app # = tauri dev:起 vite + 编 Rust + 开窗口
|
||||||
pnpm build # 只构建前端(tsc --noEmit && vite build)
|
pnpm build # 只构建前端(tsc --noEmit && vite build)
|
||||||
pnpm tauri build # 出 .app 与 .dmg
|
pnpm tauri build # 默认打包 macOS 的 .app 与 .dmg;Windows 见下文
|
||||||
```
|
```
|
||||||
|
|
||||||
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
|
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd desktop/core && cargo test # 80
|
cd desktop/core && cargo test # 83
|
||||||
cd desktop/src-tauri && cargo test # 30,其中几条真的会起 ffmpeg
|
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
|
```sh
|
||||||
@@ -410,11 +431,13 @@ open 'http://127.0.0.1:1420/?preview=referee' # 裁判端(密码 1234)
|
|||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd desktop/core && cargo test # 80:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定
|
cd desktop/core && cargo test # 83:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定
|
||||||
cd desktop/src-tauri && cargo test # 30:帧头布局、录制、截图、演示画面、命令行
|
cd desktop/src-tauri && cargo test # 32:帧头布局、录制、截图、演示画面、命令行
|
||||||
cd protocol && cargo test # 15
|
cd protocol && cargo test # 20
|
||||||
```
|
```
|
||||||
|
|
||||||
|
前端解码队列测试:在 `desktop/` 运行 `pnpm test:stream`,5 项测试覆盖慢解码时保留最新帧、多路独立处理、关闭清理、坏帧恢复与设备重启。它不替代实际 WebView 的画面验证。
|
||||||
|
|
||||||
三条值得单独说的:
|
三条值得单独说的:
|
||||||
|
|
||||||
- **录制**那条真的起 ffmpeg、喂 20 帧、停,然后用 `ffprobe` 读回帧数与分辨率。只断言"文件
|
- **录制**那条真的起 ffmpeg、喂 20 帧、停,然后用 `ffprobe` 读回帧数与分辨率。只断言"文件
|
||||||
@@ -444,8 +467,11 @@ RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
|
|||||||
| `demo-*`(仅演示) | 合成协议数据报 | 走同一条真实路径 |
|
| `demo-*`(仅演示) | 合成协议数据报 | 走同一条真实路径 |
|
||||||
| tokio 任务 | WebSocket 服务、5 Hz 快照 | 异步 IO |
|
| tokio 任务 | WebSocket 服务、5 Hz 快照 | 异步 IO |
|
||||||
|
|
||||||
跨线程只有两样东西:`Arc` 与广播通道。帧在接收线程里编码一次(16 字节头 + JPEG),
|
帧在接收线程里编码一次(16 字节头 + JPEG),所有界面共用同一份 `Bytes`。
|
||||||
所有界面共用同一份 `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:*`(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端"
|
`ws://127.0.0.1:*`(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端"
|
||||||
——见下面"测试"一节最后一条。
|
——见下面"测试"一节最后一条。
|
||||||
- **证书与公证**没做,`.dmg` 只在本机可用。
|
- **证书与公证**没做,`.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` |
|
| 起不来,提示绑不上 UDP 5600 | 同一端口上已经跑着另一个客户端(旧的 Slint 版或另一份本程序)。看 `~/.config/cam-desktop/startup-error.txt` |
|
||||||
|
|||||||
Generated
+26
@@ -55,6 +55,7 @@ dependencies = [
|
|||||||
"rand",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"socket2",
|
||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -297,6 +298,16 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -391,6 +402,21 @@ dependencies = [
|
|||||||
"wit-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.7.15"
|
version = "0.7.15"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ protocol = { path = "../../protocol" }
|
|||||||
|
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
socket2 = "0.6"
|
||||||
|
|
||||||
# 配置的序列化。这里与旧客户端(client/)的做法相反:那一版的配置只有十来个标量
|
# 配置的序列化。这里与旧客户端(client/)的做法相反:那一版的配置只有十来个标量
|
||||||
# 字段,手写 TOML 换来零依赖是划算的。现在配置里有 HUD 的嵌套结构和密码哈希,手写
|
# 字段,手写 TOML 换来零依赖是划算的。现在配置里有 HUD 的嵌套结构和密码哈希,手写
|
||||||
|
|||||||
+114
-22
@@ -11,7 +11,8 @@
|
|||||||
//! 两个方向各司其职:
|
//! 两个方向各司其职:
|
||||||
//!
|
//!
|
||||||
//! - 板子每 2 秒广播一次 [`protocol::MsgType::Hello`],客户端被动收到就知道有谁在线。
|
//! - 板子每 2 秒广播一次 [`protocol::MsgType::Hello`],客户端被动收到就知道有谁在线。
|
||||||
//! - 客户端广播 [`protocol::MsgType::Subscribe`],板子收到后开始定向推流。
|
//! - 客户端广播 [`protocol::MsgType::Subscribe`],并向已发现的板子单播续订。
|
||||||
|
//! 多网卡 / VPN 可能把全局广播路由到虚拟网卡;单播按板子 IP 选择局域网接口。
|
||||||
//!
|
//!
|
||||||
//! 板子靠**源地址**记住客户端,所以客户端只要周期性续订就能维持推流,不存在任何
|
//! 板子靠**源地址**记住客户端,所以客户端只要周期性续订就能维持推流,不存在任何
|
||||||
//! 超时淘汰的竞态。
|
//! 超时淘汰的竞态。
|
||||||
@@ -59,7 +60,7 @@ use protocol::{
|
|||||||
/// 来自网络,而不是来自过小的缓冲。
|
/// 来自网络,而不是来自过小的缓冲。
|
||||||
const RECV_BUFFER: usize = 2048;
|
const RECV_BUFFER: usize = 2048;
|
||||||
|
|
||||||
/// 重新广播订阅的间隔。
|
/// 广播及定向续订的间隔。
|
||||||
///
|
///
|
||||||
/// 比板子判定"客户端掉线"的超时(6 秒)短得多,所以偶发丢一两个包不会中断推流。
|
/// 比板子判定"客户端掉线"的超时(6 秒)短得多,所以偶发丢一两个包不会中断推流。
|
||||||
const SUBSCRIBE_INTERVAL: Duration = Duration::from_millis(800);
|
const SUBSCRIBE_INTERVAL: Duration = Duration::from_millis(800);
|
||||||
@@ -291,8 +292,7 @@ impl Discovery {
|
|||||||
|
|
||||||
/// 客户端身份。
|
/// 客户端身份。
|
||||||
///
|
///
|
||||||
/// 常量而非 MAC 派生:队伍客户端和裁判终端各自唯一,进程内也不需要区分自己,而一个
|
/// 旧客户端使用的固定标识,保留用于兼容判断。
|
||||||
/// 确定的常量让日志更好读。
|
|
||||||
pub const CLIENT_ID: u32 = 0x0C11_0001;
|
pub const CLIENT_ID: u32 = 0x0C11_0001;
|
||||||
|
|
||||||
/// 接收循环的可变状态:只由接收线程在跑循环时访问。
|
/// 接收循环的可变状态:只由接收线程在跑循环时访问。
|
||||||
@@ -318,6 +318,7 @@ pub struct Receiver {
|
|||||||
/// 身份可以运行时切换(同一个进程里从裁判端切到用户端),所以订阅里声明的角色
|
/// 身份可以运行时切换(同一个进程里从裁判端切到用户端),所以订阅里声明的角色
|
||||||
/// 也得能改——它决定板子要不要给这个客户端推流。
|
/// 也得能改——它决定板子要不要给这个客户端推流。
|
||||||
role: Mutex<SubscribeRole>,
|
role: Mutex<SubscribeRole>,
|
||||||
|
client_id: u32,
|
||||||
camera: Ipv4Addr,
|
camera: Ipv4Addr,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
@@ -327,6 +328,10 @@ impl Receiver {
|
|||||||
///
|
///
|
||||||
/// `only_sender` 为 `Some` 时只接受那一块板子的帧。
|
/// `only_sender` 为 `Some` 时只接受那一块板子的帧。
|
||||||
pub fn new(camera: Ipv4Addr, role: SubscribeRole, only_sender: Option<u32>) -> Self {
|
pub fn new(camera: Ipv4Addr, role: SubscribeRole, only_sender: Option<u32>) -> Self {
|
||||||
|
let client_id = loop {
|
||||||
|
let id = rand::random::<u32>();
|
||||||
|
if id != 0 && id != CLIENT_ID { break id; }
|
||||||
|
};
|
||||||
Self {
|
Self {
|
||||||
discovery: Arc::new(Discovery::new()),
|
discovery: Arc::new(Discovery::new()),
|
||||||
latest: Arc::new(LatestFrames::default()),
|
latest: Arc::new(LatestFrames::default()),
|
||||||
@@ -337,6 +342,7 @@ impl Receiver {
|
|||||||
discovery_all: false,
|
discovery_all: false,
|
||||||
}),
|
}),
|
||||||
role: Mutex::new(role),
|
role: Mutex::new(role),
|
||||||
|
client_id,
|
||||||
camera,
|
camera,
|
||||||
stop: Arc::new(AtomicBool::new(false)),
|
stop: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
@@ -354,6 +360,15 @@ impl Receiver {
|
|||||||
) -> std::io::Result<(Self, UdpSocket)> {
|
) -> std::io::Result<(Self, UdpSocket)> {
|
||||||
let socket =
|
let socket =
|
||||||
UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?;
|
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)))?;
|
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
|
||||||
|
|
||||||
let broadcast = socket.set_broadcast(true).is_ok();
|
let broadcast = socket.set_broadcast(true).is_ok();
|
||||||
@@ -473,11 +488,17 @@ impl Receiver {
|
|||||||
.entry(id)
|
.entry(id)
|
||||||
.or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT));
|
.or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT));
|
||||||
|
|
||||||
match assembler.push(frag) {
|
let dropped_before = assembler.dropped_frames();
|
||||||
Ok(frame) => {
|
let result = assembler.push(frag);
|
||||||
drop(ingest);
|
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
|
self.discovery
|
||||||
.note_frame(id, frame.width, frame.height, frame.data.len());
|
.note_frame(id, frame.width, frame.height, frame.data.len());
|
||||||
|
|
||||||
@@ -489,17 +510,7 @@ impl Receiver {
|
|||||||
sink(frame);
|
sink(frame);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(protocol::DropReason::Superseded) => {
|
Err(_) => {}
|
||||||
// 被更新的帧追上了。这不是丢帧:半截的那张只是被超越,在健康链路上
|
|
||||||
// 这是帧被放弃的**正常**方式。
|
|
||||||
self.discovery.count(id, |s| s.datagrams += 1);
|
|
||||||
}
|
|
||||||
Err(protocol::DropReason::TimedOut) => {
|
|
||||||
self.discovery.count(id, |s| {
|
|
||||||
s.datagrams += 1;
|
|
||||||
s.frames_dropped += 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,6 +532,25 @@ impl Receiver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 全局广播用于发现;已知板子还需按 IP 续订,避免广播被 VPN / 虚拟网卡接走。
|
||||||
|
fn subscription_targets(&self) -> Vec<SocketAddrV4> {
|
||||||
|
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`]。
|
/// 运行接收循环,直到 [`Receiver::shutdown`]。
|
||||||
///
|
///
|
||||||
/// 阻塞,所以放在单独的线程上。
|
/// 阻塞,所以放在单独的线程上。
|
||||||
@@ -543,11 +573,14 @@ impl Receiver {
|
|||||||
if last_subscribe.elapsed() >= SUBSCRIBE_INTERVAL {
|
if last_subscribe.elapsed() >= SUBSCRIBE_INTERVAL {
|
||||||
let subscribe = Subscribe {
|
let subscribe = Subscribe {
|
||||||
role: self.role(),
|
role: self.role(),
|
||||||
client_id: CLIENT_ID,
|
client_id: self.client_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(err) = socket.send_to(&subscribe.encode(), target) {
|
let encoded = subscribe.encode();
|
||||||
log::warn!("could not subscribe at {target}: {err}");
|
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();
|
last_subscribe = Instant::now();
|
||||||
}
|
}
|
||||||
@@ -639,6 +672,48 @@ mod tests {
|
|||||||
rx.datagram(&hello.encode(MsgType::Hello), from(from_ip));
|
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]
|
#[test]
|
||||||
fn delivers_the_latest_frame_of_the_bound_sender_only() {
|
fn delivers_the_latest_frame_of_the_bound_sender_only() {
|
||||||
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Team, Some(1));
|
let rx = Receiver::new(Ipv4Addr::UNSPECIFIED, SubscribeRole::Team, Some(1));
|
||||||
@@ -761,4 +836,21 @@ mod tests {
|
|||||||
|
|
||||||
assert!(rx.discovery().cameras().is_empty());
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"app": "tauri dev",
|
"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": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
|||||||
@@ -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 <backend-debug-log> --out <output-prefix> [--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;
|
||||||
@@ -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<Math.min(h,y+height);yy++)
|
||||||
|
for(let xx=Math.max(0,Math.ceil(x));xx<Math.min(w,x+width);xx++)pixels.set([...c,255],(yy*w+xx)*4);
|
||||||
|
}};
|
||||||
|
drawMarker(ctx,counter);return pixels;
|
||||||
|
}
|
||||||
|
test('screen IDs decode at Gray-code boundaries and across counter wrap',()=>{
|
||||||
|
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<pixels.length;i+=4){
|
||||||
|
const r=pixels[i],g=pixels[i+1],b=pixels[i+2];
|
||||||
|
if(r===32&&g===255&&b===32)pixels.set([190,255,208,255],i);
|
||||||
|
else if(r===32&&g===96&&b===255)pixels.set([78,205,255,255],i);
|
||||||
|
else if(r===255&&g===32&&b===255)pixels.set([255,112,255,255],i);
|
||||||
|
else if(r===60&&g===60&&b===60)pixels.set([165,181,225,255],i);
|
||||||
|
}
|
||||||
|
assert.equal(decodeMarker(pixels,320,240)?.counter,1371);
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Exercise the production StreamClient against native hardware video, while
|
||||||
|
// deliberately stalling only this auxiliary local consumer. Adds no UDP peer.
|
||||||
|
// Bitmap decoding is stubbed here; actual JPEG decoding is checked separately.
|
||||||
|
import {readFileSync,writeFileSync} from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
const [logPath,outPath]=process.argv.slice(2);
|
||||||
|
if(!logPath||!outPath)throw new Error('Use <private native debug log> <result JSON>');
|
||||||
|
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()<deadline)await delay(30);
|
||||||
|
assert.ok(rx.counters.frames>=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();}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
Generated
+1
@@ -476,6 +476,7 @@ dependencies = [
|
|||||||
"protocol",
|
"protocol",
|
||||||
"rand 0.9.5",
|
"rand 0.9.5",
|
||||||
"serde",
|
"serde",
|
||||||
|
"socket2",
|
||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -485,11 +485,7 @@ pub fn clear_config(password: Option<String>, state: Shared<'_>) -> Result<Strin
|
|||||||
|
|
||||||
/// 退出程序。用户端要密码——现场"随手关掉"也是一种需要挡住的误操作。
|
/// 退出程序。用户端要密码——现场"随手关掉"也是一种需要挡住的误操作。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn quit_app(
|
pub fn quit_app(password: Option<String>, app: AppHandle, state: Shared<'_>) -> Result<(), String> {
|
||||||
password: Option<String>,
|
|
||||||
app: AppHandle,
|
|
||||||
state: Shared<'_>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.require_unlocked(password.as_deref())?;
|
state.require_unlocked(password.as_deref())?;
|
||||||
|
|
||||||
// 先把录制收尾,否则 ffmpeg 会留下一个没有索引的 mp4。
|
// 先把录制收尾,否则 ffmpeg 会留下一个没有索引的 mp4。
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use protocol::{fragment_frame, ArmorHealth, MsgType};
|
use protocol::{fragment_frame, ArmorHealth, MsgType};
|
||||||
|
|
||||||
use crate::state::{
|
use crate::state::{AppState, DEMO_ARMOR_A, DEMO_ARMOR_B, DEMO_CAMERA_A, DEMO_CAMERA_B};
|
||||||
AppState, DEMO_ARMOR_A, DEMO_ARMOR_B, DEMO_CAMERA_A, DEMO_CAMERA_B,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// 合成画面的尺寸。与实机一致(VGA),这样界面的缩放/比例在演示下也说得通。
|
/// 合成画面的尺寸。与实机一致(VGA),这样界面的缩放/比例在演示下也说得通。
|
||||||
const WIDTH: u32 = 640;
|
const WIDTH: u32 = 640;
|
||||||
@@ -83,9 +81,7 @@ fn spawn_camera(state: Arc<AppState>, camera: u32, team: u8) {
|
|||||||
|
|
||||||
// 板子每 2 秒广播一次在线状态,与固件一致。
|
// 板子每 2 秒广播一次在线状态,与固件一致。
|
||||||
if last_hello.elapsed() >= Duration::from_secs(2) {
|
if last_hello.elapsed() >= Duration::from_secs(2) {
|
||||||
state
|
state.receiver.datagram(&hello.encode(MsgType::Hello), from);
|
||||||
.receiver
|
|
||||||
.datagram(&hello.encode(MsgType::Hello), from);
|
|
||||||
last_hello = std::time::Instant::now();
|
last_hello = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,10 +136,8 @@ fn spawn_armor(state: Arc<AppState>) {
|
|||||||
hp[slot] = hp[slot].saturating_sub(damage);
|
hp[slot] = hp[slot].saturating_sub(damage);
|
||||||
}
|
}
|
||||||
|
|
||||||
let from = SocketAddr::V4(SocketAddrV4::new(
|
let from =
|
||||||
Ipv4Addr::new(127, 0, 0, 1),
|
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), *port));
|
||||||
*port,
|
|
||||||
));
|
|
||||||
|
|
||||||
// 主板一次广播四条,整车一条血所以 hp 相同、index 0..3。
|
// 主板一次广播四条,整车一条血所以 hp 相同、index 0..3。
|
||||||
for index in 0..4u8 {
|
for index in 0..4u8 {
|
||||||
@@ -215,10 +209,38 @@ fn render(camera: u32, team: u8, frame_id: u32) -> Vec<u8> {
|
|||||||
// 中心的靶心:两个同心圆 + 四臂。瞄准器偏移校准时对着它看。
|
// 中心的靶心:两个同心圆 + 四臂。瞄准器偏移校准时对着它看。
|
||||||
ring(&mut img, cx, cy, 60, image::Rgb([200, 200, 210]));
|
ring(&mut img, cx, cy, 60, image::Rgb([200, 200, 210]));
|
||||||
ring(&mut img, cx, cy, 28, image::Rgb([150, 150, 160]));
|
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(
|
||||||
arm(&mut img, cx + 40, cy, cx + 90, cy, image::Rgb([120, 120, 130]));
|
&mut img,
|
||||||
arm(&mut img, cx, cy - 90, cx, cy - 40, image::Rgb([120, 120, 130]));
|
cx - 90,
|
||||||
arm(&mut img, cx, cy + 40, cx, cy + 90, image::Rgb([120, 120, 130]));
|
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 个方块,最左边是低位。
|
// 二进制帧号:16 个方块,最左边是低位。
|
||||||
for bit in 0..16i32 {
|
for bit in 0..16i32 {
|
||||||
@@ -241,7 +263,14 @@ fn render(camera: u32, team: u8, frame_id: u32) -> Vec<u8> {
|
|||||||
|
|
||||||
// 底部随时间右移的指示条:截图里能判断这一帧是不是较新的。
|
// 底部随时间右移的指示条:截图里能判断这一帧是不是较新的。
|
||||||
let bar = ((t * 60.0) as i32).rem_euclid(WIDTH as i32 - 80);
|
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 的最低位用一个角标表示,进一步区分两路。
|
// 摄像头 id 的最低位用一个角标表示,进一步区分两路。
|
||||||
let corner = if camera == DEMO_CAMERA_A { 0 } else { 1 };
|
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.starts_with(&[0xFF, 0xD8]), "JPEG SOI");
|
||||||
assert!(jpeg.ends_with(&[0xFF, 0xD9]), "JPEG EOI");
|
assert!(jpeg.ends_with(&[0xFF, 0xD9]), "JPEG EOI");
|
||||||
|
|
||||||
let decoded = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg)
|
let decoded =
|
||||||
.expect("decodes");
|
image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg).expect("decodes");
|
||||||
|
|
||||||
assert_eq!((decoded.width(), decoded.height()), (WIDTH, HEIGHT));
|
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() {
|
fn a_frame_is_big_enough_to_be_a_realistic_load() {
|
||||||
// 太小说明画的内容退化了(比如全是纯色),那分片/带宽的验证就失去意义。
|
// 太小说明画的内容退化了(比如全是纯色),那分片/带宽的验证就失去意义。
|
||||||
let jpeg = render(DEMO_CAMERA_A, 0, 1);
|
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!(
|
assert!(
|
||||||
jpeg.len() < 200_000,
|
jpeg.len() < 200_000,
|
||||||
"suspiciously large: {} bytes",
|
"suspiciously large: {} bytes",
|
||||||
|
|||||||
@@ -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<Option<File>>,
|
||||||
|
}
|
||||||
|
impl Recorder {
|
||||||
|
pub fn new(path: Option<PathBuf>) -> std::io::Result<Self> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
mod commands;
|
mod commands;
|
||||||
mod demo;
|
mod demo;
|
||||||
|
mod latency;
|
||||||
mod recording;
|
mod recording;
|
||||||
mod server;
|
mod server;
|
||||||
mod state;
|
mod state;
|
||||||
@@ -42,6 +43,7 @@ const USAGE: &str = "\
|
|||||||
|
|
||||||
--role <team|referee> 身份。默认看配置文件;没有配置文件时是 referee
|
--role <team|referee> 身份。默认看配置文件;没有配置文件时是 referee
|
||||||
--config <路径> 配置文件位置
|
--config <路径> 配置文件位置
|
||||||
|
--latency-report <路径> 打开裁判端屏幕标记校准,写入新的本地 NDJSON 报告
|
||||||
--demo 演示模式:画面与血量都是本地合成的
|
--demo 演示模式:画面与血量都是本地合成的
|
||||||
--list 打印发现的摄像头与装甲板后退出
|
--list 打印发现的摄像头与装甲板后退出
|
||||||
-h, --help 显示这段帮助
|
-h, --help 显示这段帮助
|
||||||
@@ -78,6 +80,7 @@ fn main() {
|
|||||||
struct Args {
|
struct Args {
|
||||||
role: Option<Role>,
|
role: Option<Role>,
|
||||||
config: Option<PathBuf>,
|
config: Option<PathBuf>,
|
||||||
|
latency_report: Option<PathBuf>,
|
||||||
demo: bool,
|
demo: bool,
|
||||||
list: bool,
|
list: bool,
|
||||||
help: bool,
|
help: bool,
|
||||||
@@ -104,6 +107,9 @@ impl Args {
|
|||||||
"--config" => {
|
"--config" => {
|
||||||
args.config = it.next().map(PathBuf::from);
|
args.config = it.next().map(PathBuf::from);
|
||||||
}
|
}
|
||||||
|
"--latency-report" => {
|
||||||
|
args.latency_report = it.next().map(PathBuf::from);
|
||||||
|
}
|
||||||
other => {
|
other => {
|
||||||
// 见模块文档:macOS 的 .app 启动参数与 shell 里的笔误都可能出现在这里,
|
// 见模块文档:macOS 的 .app 启动参数与 shell 里的笔误都可能出现在这里,
|
||||||
// 但报错的代价(窗口起不来)远大于收益。
|
// 但报错的代价(窗口起不来)远大于收益。
|
||||||
@@ -124,7 +130,10 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
return Ok(());
|
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。
|
// 首次启动时导入旧客户端的配置,并把旧文件改名成 .bak。
|
||||||
let (mut config, notes) = config::load_with_migration(&config_path)?;
|
let (mut config, notes) = config::load_with_migration(&config_path)?;
|
||||||
@@ -134,6 +143,10 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
if let Some(role) = args.role {
|
if let Some(role) = args.role {
|
||||||
config.role = 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 {
|
for note in ¬es {
|
||||||
log::info!("{note}");
|
log::info!("{note}");
|
||||||
@@ -150,8 +163,7 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
config_path.display()
|
config_path.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
let (state, video_socket, armor_socket) =
|
let (state, video_socket, armor_socket) = AppState::new(config_path, config, notes, args.demo)?;
|
||||||
AppState::new(config_path, config, notes, args.demo)?;
|
|
||||||
|
|
||||||
// 视频端口绑不上时没有 socket,接收线程也就没有意义——界面会显示那条说明。
|
// 视频端口绑不上时没有 socket,接收线程也就没有意义——界面会显示那条说明。
|
||||||
if let Some(socket) = video_socket {
|
if let Some(socket) = video_socket {
|
||||||
@@ -219,7 +231,10 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
let app = tauri::Builder::default()
|
let app = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.manage(Arc::clone(&state))
|
.manage(Arc::clone(&state))
|
||||||
|
.manage(latency_recorder)
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
latency::latency_enabled,
|
||||||
|
latency::latency_sample,
|
||||||
commands::stream_info,
|
commands::stream_info,
|
||||||
commands::get_snapshot,
|
commands::get_snapshot,
|
||||||
commands::get_config,
|
commands::get_config,
|
||||||
|
|||||||
@@ -99,9 +99,7 @@ impl Recorder {
|
|||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!(
|
format!("could not start ffmpeg ({ffmpeg}); 用 [recording] ffmpeg 指定它的路径")
|
||||||
"could not start ffmpeg ({ffmpeg}); 用 [recording] ffmpeg 指定它的路径"
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let stdin = child
|
let stdin = child
|
||||||
@@ -124,8 +122,7 @@ impl Recorder {
|
|||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(err) = pump(&rx, stdin, &mut child, &frames_written) {
|
if let Err(err) = pump(&rx, stdin, &mut child, &frames_written) {
|
||||||
log::error!("recording {} stopped: {err:#}", path.display());
|
log::error!("recording {} stopped: {err:#}", path.display());
|
||||||
*error.lock().expect("rec error mutex poisoned") =
|
*error.lock().expect("rec error mutex poisoned") = Some(format!("{err:#}"));
|
||||||
Some(format!("{err:#}"));
|
|
||||||
}
|
}
|
||||||
})?
|
})?
|
||||||
};
|
};
|
||||||
@@ -191,7 +188,10 @@ impl Recorder {
|
|||||||
dto.active = false;
|
dto.active = false;
|
||||||
|
|
||||||
if let Some(error) = dto.error.as_deref() {
|
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 {
|
} else {
|
||||||
log::info!(
|
log::info!(
|
||||||
"recording 0x{:08X} stopped: {} frames -> {}",
|
"recording 0x{:08X} stopped: {} frames -> {}",
|
||||||
@@ -291,7 +291,9 @@ fn pump(
|
|||||||
let reason = child_stderr(child);
|
let reason = child_stderr(child);
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"writing to ffmpeg failed: {err}{}",
|
"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()`
|
// 它的 `-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 {
|
if let Some(previous) = previous {
|
||||||
log::warn!("0x{camera:08X} was already recording; the previous take is being closed");
|
log::warn!("0x{camera:08X} was already recording; the previous take is being closed");
|
||||||
@@ -399,7 +405,12 @@ impl RecordingSet {
|
|||||||
/// 把一帧喂给对应那一路的录制器。在 UDP 接收线程里被调用,所以必须快。
|
/// 把一帧喂给对应那一路的录制器。在 UDP 接收线程里被调用,所以必须快。
|
||||||
pub fn feed(&self, frame: &Arc<Frame>) {
|
pub fn feed(&self, frame: &Arc<Frame>) {
|
||||||
// 先快速判断有没有在录,避免每帧都抢锁。
|
// 先快速判断有没有在录,避免每帧都抢锁。
|
||||||
if self.inner.lock().expect("recording mutex poisoned").is_empty() {
|
if self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("recording mutex poisoned")
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,8 +509,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut encoder =
|
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80);
|
||||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80);
|
|
||||||
encoder.encode_image(&img).expect("encodes");
|
encoder.encode_image(&img).expect("encodes");
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -525,10 +535,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn temp_dir(tag: &str) -> PathBuf {
|
fn temp_dir(tag: &str) -> PathBuf {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir =
|
||||||
"cam-desktop-rec-{tag}-{}",
|
std::env::temp_dir().join(format!("cam-desktop-rec-{tag}-{}", std::process::id()));
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).expect("creates the temp dir");
|
std::fs::create_dir_all(&dir).expect("creates the temp dir");
|
||||||
dir
|
dir
|
||||||
@@ -585,7 +593,10 @@ mod tests {
|
|||||||
|
|
||||||
let path = dto.path.expect("has a path");
|
let path = dto.path.expect("has a path");
|
||||||
let meta = std::fs::metadata(&path).expect("the file exists");
|
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) {
|
if let Some((frames, width, height)) = ffprobe_frames(&path) {
|
||||||
assert_eq!(frames, 20, "ffmpeg wrote every frame we fed it");
|
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");
|
let decoded = image::open(&path).expect("the PNG is readable");
|
||||||
assert_eq!((decoded.width(), decoded.height()), (64, 48));
|
assert_eq!((decoded.width(), decoded.height()), (64, 48));
|
||||||
assert!(
|
assert!(
|
||||||
path.file_name().unwrap().to_string_lossy().starts_with("shot-3D4D8A9A-"),
|
path.file_name()
|
||||||
|
.unwrap()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with("shot-3D4D8A9A-"),
|
||||||
"文件名要能看出是哪一路截的:{path:?}"
|
"文件名要能看出是哪一路截的:{path:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -727,7 +741,10 @@ mod tests {
|
|||||||
let forced = unique_path(&dir, "shot-00000001-fixed", "png");
|
let forced = unique_path(&dir, "shot-00000001-fixed", "png");
|
||||||
std::fs::write(&forced, b"").unwrap();
|
std::fs::write(&forced, b"").unwrap();
|
||||||
let forced2 = unique_path(&dir, "shot-00000001-fixed", "png");
|
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);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
@@ -755,7 +772,10 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(names.len(), 2, "got {names:?}");
|
assert_eq!(names.len(), 2, "got {names:?}");
|
||||||
assert!(names.contains(&"a.mp4"));
|
assert!(names.contains(&"a.mp4"));
|
||||||
assert!(names.contains(&"b.PNG"), "后缀大小写不该决定它是不是一张截图");
|
assert!(
|
||||||
|
names.contains(&"b.PNG"),
|
||||||
|
"后缀大小写不该决定它是不是一张截图"
|
||||||
|
);
|
||||||
assert_eq!(files[0].kind, "video");
|
assert_eq!(files[0].kind, "video");
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
|||||||
+179
-15
@@ -29,14 +29,15 @@
|
|||||||
//! 小端。16 字节头,前端切掉它就能拿到一段完整的 JPEG。`sender_id` 让前端知道这一帧是
|
//! 小端。16 字节头,前端切掉它就能拿到一段完整的 JPEG。`sender_id` 让前端知道这一帧是
|
||||||
//! 哪块板子的——用户端只画绑定的那一路,裁判端按槽位分配画两路。
|
//! 哪块板子的——用户端只画绑定的那一路,裁判端按槽位分配画两路。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
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::handshake::server::{ErrorResponse, Request, Response};
|
||||||
use tokio_tungstenite::tungstenite::http::StatusCode;
|
use tokio_tungstenite::tungstenite::http::StatusCode;
|
||||||
use tokio_tungstenite::tungstenite::{Bytes, Message};
|
use tokio_tungstenite::tungstenite::{Bytes, Message};
|
||||||
@@ -46,24 +47,43 @@ use cam_core::snapshot::{HitDto, Snapshot};
|
|||||||
/// 二进制帧头长度。
|
/// 二进制帧头长度。
|
||||||
pub const FRAME_HEADER_LEN: usize = 16;
|
pub const FRAME_HEADER_LEN: usize = 16;
|
||||||
|
|
||||||
/// 广播通道的容量。
|
/// Ordered control-message capacity. JPEGs use a separate latest-value channel.
|
||||||
///
|
|
||||||
/// 满了之后 `send` 会挤掉最老的(接收慢的连接会拿到 `Lagged`)——对实时画面这正是想要
|
|
||||||
/// 的:落后 200 帧的客户端需要的是最新的那一帧,不是补齐历史。按每帧 16+22 kB 算,
|
|
||||||
/// 256 个槽位约 5.6 MB 上限。
|
|
||||||
pub const CHANNEL_CAPACITY: usize = 256;
|
pub const CHANNEL_CAPACITY: usize = 256;
|
||||||
|
const LOCAL_VIDEO_MAX_AGE: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
/// 向界面推送的消息。
|
/// 向界面推送的消息。
|
||||||
pub enum ServerMsg {
|
pub enum ServerMsg {
|
||||||
/// 一帧原始 JPEG(已经拼好 16 字节头)。
|
|
||||||
///
|
|
||||||
/// 载荷用 `Bytes` 而不是 `Arc<Vec<u8>>`:它本身就是引用计数的缓冲,克隆是加一次
|
|
||||||
/// 引用计数,而且正好是 tungstenite 要的类型——每个连接一次多余的拷贝就此消失。
|
|
||||||
Binary(Bytes),
|
|
||||||
/// JSON 文本。
|
/// JSON 文本。
|
||||||
Text(String),
|
Text(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct VideoPacket {
|
||||||
|
frame_id: u32,
|
||||||
|
bytes: Bytes,
|
||||||
|
published_at: Instant,
|
||||||
|
published_unix_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type VideoFrames = HashMap<u32, VideoPacket>;
|
||||||
|
pub type VideoSender = watch::Sender<VideoFrames>;
|
||||||
|
|
||||||
|
/// 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 文本消息的信封。
|
/// JSON 文本消息的信封。
|
||||||
///
|
///
|
||||||
/// 用内部标签 `t` 而不是两个字段:前端一个 `switch (msg.t)` 就能分发,且不需要为每种
|
/// 用内部标签 `t` 而不是两个字段:前端一个 `switch (msg.t)` 就能分发,且不需要为每种
|
||||||
@@ -82,6 +102,14 @@ pub enum WireMsg {
|
|||||||
/// 写成带字段的结构体而不是 `Note(String)`:serde 的**内部标签**(`#[serde(tag)]`)
|
/// 写成带字段的结构体而不是 `Note(String)`:serde 的**内部标签**(`#[serde(tag)]`)
|
||||||
/// 只支持载荷是 map 的变体,newtype 里放一个字符串会在运行时报错,而不是编译期。
|
/// 只支持载荷是 map 的变体,newtype 里放一个字符串会在运行时报错,而不是编译期。
|
||||||
Note { message: String },
|
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,
|
listener: TcpListener,
|
||||||
token: String,
|
token: String,
|
||||||
tx: broadcast::Sender<Arc<ServerMsg>>,
|
tx: broadcast::Sender<Arc<ServerMsg>>,
|
||||||
|
video: VideoSender,
|
||||||
clients: Arc<AtomicUsize>,
|
clients: Arc<AtomicUsize>,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
@@ -159,9 +188,10 @@ pub async fn serve(
|
|||||||
let tx = tx.clone();
|
let tx = tx.clone();
|
||||||
let token = token.clone();
|
let token = token.clone();
|
||||||
let clients = Arc::clone(&clients);
|
let clients = Arc::clone(&clients);
|
||||||
|
let video = video.subscribe();
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
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}");
|
log::debug!("stream: client {peer} ended: {err}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -173,6 +203,7 @@ async fn handle_client(
|
|||||||
peer: std::net::SocketAddr,
|
peer: std::net::SocketAddr,
|
||||||
token: &str,
|
token: &str,
|
||||||
tx: broadcast::Sender<Arc<ServerMsg>>,
|
tx: broadcast::Sender<Arc<ServerMsg>>,
|
||||||
|
mut video: watch::Receiver<VideoFrames>,
|
||||||
clients: Arc<AtomicUsize>,
|
clients: Arc<AtomicUsize>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
stream.set_nodelay(true).ok();
|
stream.set_nodelay(true).ok();
|
||||||
@@ -201,6 +232,8 @@ async fn handle_client(
|
|||||||
let (mut sink, mut incoming) = ws.split();
|
let (mut sink, mut incoming) = ws.split();
|
||||||
|
|
||||||
let mut rx = tx.subscribe();
|
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 mut keepalive = tokio::time::interval(Duration::from_secs(20));
|
||||||
let count = clients.fetch_add(1, Ordering::Relaxed) + 1;
|
let count = clients.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
log::info!("stream: interface connected from {peer} ({count} connected)");
|
log::info!("stream: interface connected from {peer} ({count} connected)");
|
||||||
@@ -212,12 +245,35 @@ async fn handle_client(
|
|||||||
.unwrap_or_else(|_| "{}".into());
|
.unwrap_or_else(|_| "{}".into());
|
||||||
let _ = sink.send(Message::text(hello)).await;
|
let _ = sink.send(Message::text(hello)).await;
|
||||||
|
|
||||||
let result = loop {
|
let result = 'messages: loop {
|
||||||
tokio::select! {
|
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 {
|
message = rx.recv() => match message {
|
||||||
Ok(msg) => {
|
Ok(msg) => {
|
||||||
let out = match &*msg {
|
let out = match &*msg {
|
||||||
ServerMsg::Binary(bytes) => Message::Binary(bytes.clone()),
|
|
||||||
ServerMsg::Text(text) => Message::text(text.clone()),
|
ServerMsg::Text(text) => Message::text(text.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -334,6 +390,114 @@ mod tests {
|
|||||||
assert_eq!(random_token().len(), 48);
|
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]
|
#[test]
|
||||||
fn the_wire_envelope_carries_the_message_kind() {
|
fn the_wire_envelope_carries_the_message_kind() {
|
||||||
let json = serde_json::to_string(&WireMsg::Hit(HitDto {
|
let json = serde_json::to_string(&WireMsg::Hit(HitDto {
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ use cam_core::armor::{ArmorHealthStore, HealthTracker};
|
|||||||
use cam_core::config::{Config, Role};
|
use cam_core::config::{Config, Role};
|
||||||
use cam_core::password;
|
use cam_core::password;
|
||||||
use cam_core::receiver::{Discovery, LatestFrames, Receiver};
|
use cam_core::receiver::{Discovery, LatestFrames, Receiver};
|
||||||
use cam_core::ArmorSource;
|
|
||||||
use cam_core::slots::SlotAllocator;
|
use cam_core::slots::SlotAllocator;
|
||||||
use cam_core::snapshot::{
|
use cam_core::snapshot::{
|
||||||
resolve_outcomes, ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, Snapshot,
|
resolve_outcomes, ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, SlotsDto,
|
||||||
SlotsDto, TeamHealthDto,
|
Snapshot, TeamHealthDto,
|
||||||
};
|
};
|
||||||
use cam_core::stats::{FpsWindow, DEFAULT_WINDOW};
|
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::recording::{now_ms, RecordingSet};
|
||||||
use crate::server::{self, ServerMsg, WireMsg};
|
use crate::server::{self, ServerMsg, WireMsg};
|
||||||
@@ -67,6 +67,7 @@ pub struct AppState {
|
|||||||
pub recordings: Arc<RecordingSet>,
|
pub recordings: Arc<RecordingSet>,
|
||||||
|
|
||||||
tx: broadcast::Sender<Arc<ServerMsg>>,
|
tx: broadcast::Sender<Arc<ServerMsg>>,
|
||||||
|
video: server::VideoSender,
|
||||||
/// 回环上已经绑好的 WebSocket listener,等 `spawn_background` 取走。
|
/// 回环上已经绑好的 WebSocket listener,等 `spawn_background` 取走。
|
||||||
///
|
///
|
||||||
/// 先绑再交给后台任务,是为了让"端口绑不上"在启动阶段就报错,而不是变成一个永远
|
/// 先绑再交给后台任务,是为了让"端口绑不上"在启动阶段就报错,而不是变成一个永远
|
||||||
@@ -92,38 +93,43 @@ impl AppState {
|
|||||||
config: Config,
|
config: Config,
|
||||||
mut notes: Vec<String>,
|
mut notes: Vec<String>,
|
||||||
demo: bool,
|
demo: bool,
|
||||||
) -> Result<(Arc<Self>, Option<std::net::UdpSocket>, Option<std::net::UdpSocket>)> {
|
) -> Result<(
|
||||||
|
Arc<Self>,
|
||||||
|
Option<std::net::UdpSocket>,
|
||||||
|
Option<std::net::UdpSocket>,
|
||||||
|
)> {
|
||||||
let role = config.role;
|
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 都没有。让窗口起来并说明原因,比
|
// 在 stderr 的话——从访达双击启动时连 stderr 都没有。让窗口起来并说明原因,比
|
||||||
// 静默退出有用得多;而且"另一个客户端已经在跑"恰好是最常见的原因。
|
// 静默退出有用得多;而且"另一个客户端已经在跑"恰好是最常见的原因。
|
||||||
let (receiver, video_socket) = match Receiver::start(
|
let (receiver, video_socket) =
|
||||||
protocol::BROADCAST_ADDR,
|
match Receiver::start(protocol::BROADCAST_ADDR, role.to_protocol(), bound) {
|
||||||
role.to_protocol(),
|
Ok((receiver, socket)) => (Arc::new(receiver), Some(socket)),
|
||||||
bound,
|
Err(err) => {
|
||||||
) {
|
log::error!("could not bind UDP {}: {err}", protocol::DEFAULT_PORT);
|
||||||
Ok((receiver, socket)) => (Arc::new(receiver), Some(socket)),
|
notes.push(format!(
|
||||||
Err(err) => {
|
"绑不上 UDP {}:{err}。多半是同一个端口上已经跑着另一个客户端 \
|
||||||
log::error!("could not bind UDP {}: {err}", protocol::DEFAULT_PORT);
|
|
||||||
notes.push(format!(
|
|
||||||
"绑不上 UDP {}:{err}。多半是同一个端口上已经跑着另一个客户端 \
|
|
||||||
(旧的 Slint 版或另一份本程序)。画面收不到,其它功能照常。",
|
(旧的 Slint 版或另一份本程序)。画面收不到,其它功能照常。",
|
||||||
protocol::DEFAULT_PORT
|
protocol::DEFAULT_PORT
|
||||||
));
|
));
|
||||||
(
|
(
|
||||||
Arc::new(Receiver::new(
|
Arc::new(Receiver::new(
|
||||||
protocol::BROADCAST_ADDR,
|
protocol::BROADCAST_ADDR,
|
||||||
role.to_protocol(),
|
role.to_protocol(),
|
||||||
bound,
|
bound,
|
||||||
)),
|
)),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let discovery = receiver.discovery();
|
let discovery = receiver.discovery();
|
||||||
let latest = receiver.latest_frames();
|
let latest = receiver.latest_frames();
|
||||||
@@ -141,6 +147,7 @@ impl AppState {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (tx, _) = broadcast::channel(server::CHANNEL_CAPACITY);
|
let (tx, _) = broadcast::channel(server::CHANNEL_CAPACITY);
|
||||||
|
let (video, _) = watch::channel(server::VideoFrames::new());
|
||||||
let (listener, port) = tauri::async_runtime::block_on(server::bind())
|
let (listener, port) = tauri::async_runtime::block_on(server::bind())
|
||||||
.context("绑不上本地 WebSocket 端口(回环地址)")?;
|
.context("绑不上本地 WebSocket 端口(回环地址)")?;
|
||||||
|
|
||||||
@@ -157,6 +164,7 @@ impl AppState {
|
|||||||
slots: Mutex::new(SlotAllocator::new()),
|
slots: Mutex::new(SlotAllocator::new()),
|
||||||
recordings: RecordingSet::new(),
|
recordings: RecordingSet::new(),
|
||||||
tx,
|
tx,
|
||||||
|
video,
|
||||||
listener: Mutex::new(Some(listener)),
|
listener: Mutex::new(Some(listener)),
|
||||||
port,
|
port,
|
||||||
token: server::random_token(),
|
token: server::random_token(),
|
||||||
@@ -175,15 +183,14 @@ impl AppState {
|
|||||||
///
|
///
|
||||||
/// 必须在 `Arc<AppState>` 建好之后调用(sink 里要持有 state 的通道)。
|
/// 必须在 `Arc<AppState>` 建好之后调用(sink 里要持有 state 的通道)。
|
||||||
pub fn install_sinks(self: &Arc<Self>) {
|
pub fn install_sinks(self: &Arc<Self>) {
|
||||||
let tx = self.tx.clone();
|
let video = self.video.clone();
|
||||||
let recordings = Arc::clone(&self.recordings);
|
let recordings = Arc::clone(&self.recordings);
|
||||||
|
|
||||||
// 帧的 sink:在接收线程里跑,只做"编码一次 + 广播"。这一份字节所有界面共用。
|
// 帧的 sink:在接收线程里跑,只做"编码一次 + 广播"。这一份字节所有界面共用。
|
||||||
self.receiver
|
self.receiver
|
||||||
.set_frame_sink(Arc::new(move |frame: Arc<protocol::Frame>| {
|
.set_frame_sink(Arc::new(move |frame: Arc<protocol::Frame>| {
|
||||||
recordings.feed(&frame);
|
recordings.feed(&frame);
|
||||||
let bytes = server::encode_frame(&frame);
|
server::publish_video(&video, &frame);
|
||||||
let _ = tx.send(Arc::new(ServerMsg::Binary(bytes.into())));
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let tx = self.tx.clone();
|
let tx = self.tx.clone();
|
||||||
@@ -240,9 +247,10 @@ impl AppState {
|
|||||||
let token = self.token.clone();
|
let token = self.token.clone();
|
||||||
let tx = self.tx.clone();
|
let tx = self.tx.clone();
|
||||||
let clients = Arc::clone(&self.clients);
|
let clients = Arc::clone(&self.clients);
|
||||||
|
let video = self.video.clone();
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
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);
|
let state_for_loop = Arc::clone(&state);
|
||||||
@@ -306,7 +314,9 @@ impl AppState {
|
|||||||
// 本来就要看到全场。
|
// 本来就要看到全场。
|
||||||
let open = match role {
|
let open = match role {
|
||||||
Role::Referee => true,
|
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);
|
self.receiver.set_discovery_all(open);
|
||||||
|
|
||||||
@@ -457,7 +467,12 @@ impl AppState {
|
|||||||
|
|
||||||
/// 推进帧率窗口与槽位分配。
|
/// 推进帧率窗口与槽位分配。
|
||||||
pub fn tick(&self) {
|
pub fn tick(&self) {
|
||||||
let online: Vec<u32> = self.discovery.online_cameras().iter().map(|c| c.id).collect();
|
let online: Vec<u32> = self
|
||||||
|
.discovery
|
||||||
|
.online_cameras()
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.id)
|
||||||
|
.collect();
|
||||||
let online_set: HashSet<u32> = online.iter().copied().collect();
|
let online_set: HashSet<u32> = online.iter().copied().collect();
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -539,8 +554,12 @@ impl AppState {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let armor_boards: Vec<ArmorBoardDto> =
|
let armor_boards: Vec<ArmorBoardDto> = self
|
||||||
self.armor.boards().iter().map(ArmorBoardDto::from).collect();
|
.armor
|
||||||
|
.boards()
|
||||||
|
.iter()
|
||||||
|
.map(ArmorBoardDto::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
let slots = self.slots.lock().expect("slots mutex poisoned").slots();
|
let slots = self.slots.lock().expect("slots mutex poisoned").slots();
|
||||||
|
|
||||||
@@ -627,7 +646,9 @@ impl AppState {
|
|||||||
|
|
||||||
if let Some(board) = board {
|
if let Some(board) = board {
|
||||||
return match self.armor.health_of_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),
|
None => TeamHealthDto::absent(team, Some(board), &config.team_names),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useEffect, useRef, type ReactNode } from "react";
|
|||||||
import { streamClient } from "../lib/store";
|
import { streamClient } from "../lib/store";
|
||||||
import { withAlpha } from "../lib/format";
|
import { withAlpha } from "../lib/format";
|
||||||
import type { CrosshairConfig, GridConfig, HitFeedbackConfig } from "../lib/types";
|
import type { CrosshairConfig, GridConfig, HitFeedbackConfig } from "../lib/types";
|
||||||
|
import type { FrameInfo } from "../lib/stream";
|
||||||
|
|
||||||
export interface ViewTransform {
|
export interface ViewTransform {
|
||||||
zoom: number;
|
zoom: number;
|
||||||
@@ -61,6 +62,8 @@ interface Props {
|
|||||||
hitFeedback: HitFeedbackConfig;
|
hitFeedback: HitFeedbackConfig;
|
||||||
showZoom: boolean;
|
showZoom: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
/** Only used by the opt-in screen/camera calibration page. */
|
||||||
|
onFramePainted?: (canvas:HTMLCanvasElement,frame:FrameInfo,drawMs:number)=>void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoSurface({
|
export function VideoSurface({
|
||||||
@@ -77,12 +80,13 @@ export function VideoSurface({
|
|||||||
hitFeedback,
|
hitFeedback,
|
||||||
showZoom,
|
showZoom,
|
||||||
children,
|
children,
|
||||||
|
onFramePainted,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
// 绘制循环不随 props 重建,所以最新一份 props 放在 ref 里给它读。
|
// 绘制循环不随 props 重建,所以最新一份 props 放在 ref 里给它读。
|
||||||
const props = useRef({ 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 };
|
props.current = { camera, mirrored, crosshair, grid, view, hit, hitFeedback,onFramePainted };
|
||||||
|
|
||||||
const drag = useRef<{ x: number; y: number; originX: number; originY: number } | null>(null);
|
const drag = useRef<{ x: number; y: number; originX: number; originY: number } | null>(null);
|
||||||
const dirty = useRef(true);
|
const dirty = useRef(true);
|
||||||
@@ -127,6 +131,7 @@ export function VideoSurface({
|
|||||||
|
|
||||||
const current = props.current;
|
const current = props.current;
|
||||||
const frame = streamClient().latest(current.camera);
|
const frame = streamClient().latest(current.camera);
|
||||||
|
const newFrame=frame && frame.frameId!==lastFrameId.current;
|
||||||
|
|
||||||
if (frame && frame.frameId !== lastFrameId.current) {
|
if (frame && frame.frameId !== lastFrameId.current) {
|
||||||
lastFrameId.current = frame.frameId;
|
lastFrameId.current = frame.frameId;
|
||||||
@@ -145,6 +150,7 @@ export function VideoSurface({
|
|||||||
dirty.current = false;
|
dirty.current = false;
|
||||||
|
|
||||||
draw(canvas, current, frame, timestamp);
|
draw(canvas, current, frame, timestamp);
|
||||||
|
if(newFrame && current.onFramePainted) current.onFramePainted(canvas,frame,performance.now());
|
||||||
};
|
};
|
||||||
|
|
||||||
raf = requestAnimationFrame(loop);
|
raf = requestAnimationFrame(loop);
|
||||||
|
|||||||
@@ -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<labels.length;i++) labels[i]=colour(pixels[i*4],pixels[i*4+1],pixels[i*4+2]);
|
||||||
|
const best=Array.from({length:4},()=>({count:0,x:0,y:0}));
|
||||||
|
const queue=new Int32Array(w*h);
|
||||||
|
for(let start=0;start<labels.length;start++) {
|
||||||
|
const cls=labels[start]; if(!cls) continue;
|
||||||
|
let head=0,tail=1,count=0,sx=0,sy=0;
|
||||||
|
queue[0]=start;labels[start]=0;
|
||||||
|
while(head<tail) {
|
||||||
|
const i=queue[head++],x=i%w,y=Math.floor(i/w);
|
||||||
|
count++;sx+=x;sy+=y;
|
||||||
|
for(const n of [x>0?i-1:-1,x<w-1?i+1:-1,y>0?i-w:-1,y<h-1?i+w:-1]) {
|
||||||
|
if(n>=0 && labels[n]===cls) { labels[n]=0;queue[tail++]=n; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(count>=6 && count<w*h/6 && count>best[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;i<MARKER_BITS;i++) {
|
||||||
|
const x=.14+.72*(i+.5)/MARKER_BITS;
|
||||||
|
const top=project(corners,(x-.06)/.88,(.43-.08)/.84);
|
||||||
|
const bottom=project(corners,(x-.06)/.88,(.57-.08)/.84);
|
||||||
|
if(!top||!bottom) return null;
|
||||||
|
const delta=luma(top.x,top.y)-luma(bottom.x,bottom.y);
|
||||||
|
if(!Number.isFinite(delta)||Math.abs(delta)<60) return null;
|
||||||
|
bits.push(delta>0?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};
|
||||||
|
}
|
||||||
@@ -76,6 +76,8 @@ export function currentStore(): AppStore {
|
|||||||
|
|
||||||
function handleMessage(msg: WireMsg) {
|
function handleMessage(msg: WireMsg) {
|
||||||
switch (msg.t) {
|
switch (msg.t) {
|
||||||
|
case "frameTiming":
|
||||||
|
return; // Consumed by StreamClient before reaching application state.
|
||||||
case "snapshot": {
|
case "snapshot": {
|
||||||
const { t: _tag, ...snapshot } = msg;
|
const { t: _tag, ...snapshot } = msg;
|
||||||
emit({ snapshot: snapshot as Snapshot });
|
emit({ snapshot: snapshot as Snapshot });
|
||||||
|
|||||||
+82
-20
@@ -28,7 +28,7 @@ export interface FrameInfo {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
bitmap: ImageBitmap;
|
bitmap: ImageBitmap;
|
||||||
/** 收到的时刻(`performance.now()`),界面用来显示端到端延迟。 */
|
/** 本地完成解码的时刻(`performance.now()`),用于判断帧的新旧。 */
|
||||||
receivedAt: number;
|
receivedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +39,13 @@ interface Handlers {
|
|||||||
|
|
||||||
/** 断线重连的退避上限。 */
|
/** 断线重连的退避上限。 */
|
||||||
const MAX_BACKOFF_MS = 3000;
|
const MAX_BACKOFF_MS = 3000;
|
||||||
|
const MAX_LOCAL_VIDEO_AGE_MS = 100;
|
||||||
|
|
||||||
|
interface PendingFrame {
|
||||||
|
bytes: ArrayBuffer;
|
||||||
|
arrivedAt: number;
|
||||||
|
publishedUnixMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class StreamClient {
|
export class StreamClient {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
@@ -48,14 +55,19 @@ export class StreamClient {
|
|||||||
private retryTimer: number | null = null;
|
private retryTimer: number | null = null;
|
||||||
private backoff = 250;
|
private backoff = 250;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
|
private generation = 0;
|
||||||
|
private readonly pending = new Map<number, PendingFrame>();
|
||||||
|
private readonly frameTimes = new Map<number, { frameId: number; publishedUnixMs: number }>();
|
||||||
|
private readonly decoding = new Map<number, number>();
|
||||||
/** 统计:收到多少帧、解码失败多少张。界面用它区分"链路没数据"和"数据坏了"。 */
|
/** 统计:收到多少帧、解码失败多少张。界面用它区分"链路没数据"和"数据坏了"。 */
|
||||||
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;
|
this.handlers = handlers;
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(url: string) {
|
connect(url: string) {
|
||||||
|
this.close();
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.closed = false;
|
this.closed = false;
|
||||||
this.open();
|
this.open();
|
||||||
@@ -76,11 +88,17 @@ export class StreamClient {
|
|||||||
this.ws = socket;
|
this.ws = socket;
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
|
if (this.ws !== socket) return;
|
||||||
this.backoff = 250;
|
this.backoff = 250;
|
||||||
this.handlers.onConnection(true);
|
this.handlers.onConnection(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
|
if (this.ws !== socket) return;
|
||||||
|
this.generation += 1;
|
||||||
|
this.pending.clear();
|
||||||
|
this.frameTimes.clear();
|
||||||
|
this.decoding.clear();
|
||||||
this.handlers.onConnection(false);
|
this.handlers.onConnection(false);
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
};
|
};
|
||||||
@@ -90,9 +108,12 @@ export class StreamClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
|
if (this.ws !== socket) return;
|
||||||
if (typeof event.data === "string") {
|
if (typeof event.data === "string") {
|
||||||
try {
|
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 {
|
} 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) {
|
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 view = new DataView(buffer);
|
||||||
const camera = view.getUint32(0, true);
|
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);
|
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;
|
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);
|
const generation = this.generation;
|
||||||
let bitmap: ImageBitmap;
|
this.decoding.set(camera, generation);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bitmap = await createImageBitmap(new Blob([jpeg], { type: "image/jpeg" }));
|
while (!this.closed && this.generation === generation) {
|
||||||
} catch {
|
const next = this.pending.get(camera);
|
||||||
this.counters.decodeFailures += 1;
|
if (!next) break;
|
||||||
return;
|
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);
|
const current = this.frames.get(camera);
|
||||||
|
|
||||||
// 带符号差处理 u32 回绕。
|
// 带符号差处理 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();
|
bitmap.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -162,7 +220,7 @@ export class StreamClient {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
bitmap,
|
bitmap,
|
||||||
receivedAt: performance.now(),
|
receivedAt: this.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +255,10 @@ export class StreamClient {
|
|||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
|
this.generation += 1;
|
||||||
|
this.pending.clear();
|
||||||
|
this.frameTimes.clear();
|
||||||
|
this.decoding.clear();
|
||||||
|
|
||||||
if (this.retryTimer !== null) {
|
if (this.retryTimer !== null) {
|
||||||
window.clearTimeout(this.retryTimer);
|
window.clearTimeout(this.retryTimer);
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export type WireMsg =
|
|||||||
| ({ t: "snapshot" } & Snapshot)
|
| ({ t: "snapshot" } & Snapshot)
|
||||||
| ({ t: "hit" } & HitEvent)
|
| ({ t: "hit" } & HitEvent)
|
||||||
| { t: "note"; message: string }
|
| { t: "note"; message: string }
|
||||||
|
| { t: "frameTiming"; camera: number; frameId: number; publishedUnixMs: number }
|
||||||
| { t: "confirmQuit" };
|
| { t: "confirmQuit" };
|
||||||
|
|
||||||
/** 二进制视频帧的头部长度,与 `server::FRAME_HEADER_LEN` 一致。 */
|
/** 二进制视频帧的头部长度,与 `server::FRAME_HEADER_LEN` 一致。 */
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { LatencyPage } from './pages/LatencyPage';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import "./styles/global.css";
|
import "./styles/global.css";
|
||||||
|
|
||||||
const container = document.getElementById("root");
|
const container = document.getElementById("root");
|
||||||
@@ -12,4 +14,7 @@ if (!container) {
|
|||||||
// 不用 StrictMode:这个界面的关键路径是命令式的(WebSocket、requestAnimationFrame 绘制
|
// 不用 StrictMode:这个界面的关键路径是命令式的(WebSocket、requestAnimationFrame 绘制
|
||||||
// 循环、canvas 尺寸)。StrictMode 在开发下会把每个 effect 跑两遍,于是连接与绘制循环会
|
// 循环、canvas 尺寸)。StrictMode 在开发下会把每个 effect 跑两遍,于是连接与绘制循环会
|
||||||
// 短暂地建两份——排查起来的噪音大于它带来的收益。生产构建的行为与这里一致。
|
// 短暂地建两份——排查起来的噪音大于它带来的收益。生产构建的行为与这里一致。
|
||||||
createRoot(container).render(<App />);
|
const root=createRoot(container);
|
||||||
|
if('__TAURI_INTERNALS__' in window) {
|
||||||
|
void invoke<boolean>('latency_enabled').then(enabled=>root.render(enabled?<LatencyPage/>:<App/>),()=>root.render(<App/>));
|
||||||
|
} else root.render(<App/>);
|
||||||
|
|||||||
@@ -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<number|null>(null);
|
||||||
|
const [status,setStatus]=useState('等待摄像头与屏幕标记');
|
||||||
|
const [summary,setSummary]=useState({samples:0,p50:0,p95:0,refresh:0});
|
||||||
|
const target=useRef<HTMLCanvasElement>(null);
|
||||||
|
const history=useRef(new Map<number,Stamp>());
|
||||||
|
const scratch=useRef<HTMLCanvasElement|null>(null);
|
||||||
|
const values=useRef<number[]>([]);
|
||||||
|
const lastSample=useRef(-Infinity);
|
||||||
|
const refresh=useRef(16.7);
|
||||||
|
const active=useRef(true);
|
||||||
|
useEffect(()=>{
|
||||||
|
let client:ReturnType<typeof startStream>|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(upperMs<lowerMs||upperMs>5000)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 <main style={{height:'100vh',display:'flex',flexDirection:'column',padding:20,gap:12,background:'#111820',color:'#f3f6fa'}}>
|
||||||
|
<h1 style={{fontSize:24}}>屏幕 → 摄像头 → 客户端绘制延迟校准</h1>
|
||||||
|
<p>将摄像头固定对准左侧标记,完整拍到四个彩色角块。不要拍右侧回显区域;保持窗口位置不动。</p>
|
||||||
|
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:16,flex:1,minHeight:0}}>
|
||||||
|
<canvas ref={target} width={800} height={600} style={{width:'100%',height:'100%',objectFit:'contain'}}/>
|
||||||
|
<div style={{position:'relative',minHeight:0}}><VideoSurface camera={camera} emptyText="等待真实摄像头" mirrored={false}
|
||||||
|
crosshair={null} grid={null} view={IDENTITY_VIEW} onView={()=>{}} hitFeedback={feedback} showZoom={false} onFramePainted={painted}/></div>
|
||||||
|
</div>
|
||||||
|
<p>{connected?'已连接':'连接中'} · {status}</p>
|
||||||
|
<p style={{fontSize:22}}>样本 {summary.samples} · P50 {summary.p50.toFixed(1)} ms · P95 {summary.p95.toFixed(1)} ms</p>
|
||||||
|
<p>每 50 ms 更新标记,记录首次可解码回显,包含相机取样等待、曝光、采集、传输和绘制;混合曝光帧不计入。相邻 rAF 夹定绘制时间,刷新间隔约 {summary.refresh.toFixed(1)} ms;面板像素响应需高速相机复核。</p>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
+6
-5
@@ -1,17 +1,18 @@
|
|||||||
# Name, Type, SubType, Offset, Size, Flags
|
# Name, Type, SubType, Offset, Size, Flags
|
||||||
#
|
#
|
||||||
# The app partition offset here is chosen by scripts/flash.sh, not by hand. It
|
# The app partition offset must be validated against each rebuilt image. The
|
||||||
# sits well above the usual 0x10000 because the module this firmware was first
|
# module this firmware was first brought up on has a defective byte in its SPI
|
||||||
# brought up on has a defective byte in its SPI flash at 0xFADE7 (bits 5..7
|
# flash at 0xFADE7 (bits 5..7
|
||||||
# cannot be programmed). The app image is unchanged by where it is placed, so
|
# 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
|
# 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
|
# 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,
|
# whose byte at that address the flash can actually store, writes it back here,
|
||||||
# and rebuilds.
|
# and rebuilds.
|
||||||
#
|
#
|
||||||
# If you move to healthy hardware, put `factory` back at 0x10000 and delete
|
# 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.
|
# 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,
|
nvs, data, nvs, 0x9000, 0x6000,
|
||||||
phy_init, data, phy, 0xf000, 0x1000,
|
phy_init, data, phy, 0xf000, 0x1000,
|
||||||
factory, app, factory, 0x060000, 0x120000,
|
factory, app, factory, 0x070000, 0x120000,
|
||||||
|
|||||||
|
+139
-23
@@ -305,16 +305,8 @@ impl Fragment {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 一帧至少一个分片,且分片下标必须落在范围内。
|
|
||||||
if header.frag_count == 0 || header.frag_index >= header.frag_count {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload = &bytes[HEADER_LEN..];
|
let payload = &bytes[HEADER_LEN..];
|
||||||
|
if !valid_fragment_layout(&header, payload.len()) {
|
||||||
// JPEG 不可能比承载它的分片还短。检查很便宜,却能在造成一次大额分配之前
|
|
||||||
// 挡掉损坏的头。
|
|
||||||
if payload.len() as u64 > header.total_len as u64 {
|
|
||||||
return None;
|
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。
|
/// 一张完整重组好的 JPEG。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Frame {
|
pub struct Frame {
|
||||||
@@ -572,6 +575,10 @@ pub struct FrameAssembler {
|
|||||||
/// 存在的原因是 `seen` 会被重复包灌水:UDP 会重复投递,一个丢失的分片可以
|
/// 存在的原因是 `seen` 会被重复包灌水:UDP 会重复投递,一个丢失的分片可以
|
||||||
/// 被另一个包的副本凑数,于是"收够了"却留着空洞,交出去一张尾部是 0 的坏图。
|
/// 被另一个包的副本凑数,于是"收够了"却留着空洞,交出去一张尾部是 0 的坏图。
|
||||||
have: Vec<bool>,
|
have: Vec<bool>,
|
||||||
|
total_len: u32,
|
||||||
|
latest_id: Option<u32>,
|
||||||
|
last_fragment_at: Option<std::time::Instant>,
|
||||||
|
dropped_frames: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FrameAssembler {
|
impl FrameAssembler {
|
||||||
@@ -588,6 +595,10 @@ impl FrameAssembler {
|
|||||||
timeout,
|
timeout,
|
||||||
seen: 0,
|
seen: 0,
|
||||||
have: Vec::new(),
|
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
|
self.sender_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 未收齐、超时以及完全缺失的帧累计数;等待分片和重复包不增加此值。
|
||||||
|
pub fn dropped_frames(&self) -> u64 {
|
||||||
|
self.dropped_frames
|
||||||
|
}
|
||||||
|
|
||||||
/// 收到一个分片。
|
/// 收到一个分片。
|
||||||
///
|
///
|
||||||
/// 这张分片补齐一张帧时返回 [`Frame`]。
|
/// 这张分片补齐一张帧时返回 [`Frame`]。
|
||||||
///
|
///
|
||||||
/// `Err(Superseded)` 表示这个分片属于一张**已被放弃**的帧。多路复用时(裁判
|
/// 为兼容已有调用,等待更多分片也返回 `Err(Superseded)`;丢帧统计应读取
|
||||||
/// 终端同时收两块板子)必须每个发送方一个重组器——混在一起会让两块板子的分片
|
/// `dropped_frames()` 的增量,不能把每次 Err 当作丢帧或全部忽略。
|
||||||
/// 互相顶掉。这里不做检查,是调用方的责任。
|
|
||||||
pub fn push(&mut self, frag: Fragment) -> Result<Frame, DropReason> {
|
pub fn push(&mut self, frag: Fragment) -> Result<Frame, DropReason> {
|
||||||
|
if !valid_fragment_layout(&frag.header, frag.payload.len()) {
|
||||||
|
return Err(DropReason::Superseded);
|
||||||
|
}
|
||||||
let sender_id = frag.header.sender_id;
|
let sender_id = frag.header.sender_id;
|
||||||
let frame_id = frag.header.frame_id;
|
let frame_id = frag.header.frame_id;
|
||||||
|
|
||||||
// 换了发送方:上一张属于别人的帧就此作废。
|
// 换了发送方:上一张属于别人的帧就此作废。
|
||||||
if self.sender_id != 0 && self.sender_id != sender_id {
|
if self.sender_id != 0 && self.sender_id != sender_id {
|
||||||
self.reset();
|
self.reset();
|
||||||
|
self.latest_id = None;
|
||||||
|
self.last_fragment_at = None;
|
||||||
}
|
}
|
||||||
self.sender_id = sender_id;
|
self.sender_id = sender_id;
|
||||||
|
|
||||||
let superseded = match self.current_id {
|
if let Some(latest) = self.latest_id {
|
||||||
None => false,
|
let distance = frame_id.wrapping_sub(latest) as i32;
|
||||||
Some(id) => id != frame_id,
|
let restarted = distance < 0 && self.last_fragment_at.is_some_and(|at| {
|
||||||
};
|
at.elapsed() >= self.timeout.max(std::time::Duration::from_secs(1))
|
||||||
|
});
|
||||||
if superseded {
|
if restarted {
|
||||||
self.reset();
|
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 {
|
match self.current_id {
|
||||||
None => {
|
None => {
|
||||||
@@ -629,6 +661,7 @@ impl FrameAssembler {
|
|||||||
self.buffer.clear();
|
self.buffer.clear();
|
||||||
// 按 total_len 预留,最后拼接时不再重新分配。
|
// 按 total_len 预留,最后拼接时不再重新分配。
|
||||||
self.buffer.reserve(frag.header.total_len as usize);
|
self.buffer.reserve(frag.header.total_len as usize);
|
||||||
|
self.total_len = frag.header.total_len;
|
||||||
self.width = frag.header.width;
|
self.width = frag.header.width;
|
||||||
self.height = frag.header.height;
|
self.height = frag.header.height;
|
||||||
self.since = Some(std::time::Instant::now());
|
self.since = Some(std::time::Instant::now());
|
||||||
@@ -636,7 +669,14 @@ impl FrameAssembler {
|
|||||||
self.have.clear();
|
self.have.clear();
|
||||||
self.have.resize(self.expected, false);
|
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"),
|
_ => unreachable!("current_id was just reconciled with frame_id"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,6 +737,7 @@ impl FrameAssembler {
|
|||||||
pub fn expire(&mut self) -> Option<DropReason> {
|
pub fn expire(&mut self) -> Option<DropReason> {
|
||||||
match self.since {
|
match self.since {
|
||||||
Some(started) if started.elapsed() > self.timeout => {
|
Some(started) if started.elapsed() > self.timeout => {
|
||||||
|
self.dropped_frames += 1;
|
||||||
self.reset();
|
self.reset();
|
||||||
Some(DropReason::TimedOut)
|
Some(DropReason::TimedOut)
|
||||||
}
|
}
|
||||||
@@ -712,11 +753,10 @@ impl FrameAssembler {
|
|||||||
fn reset(&mut self) {
|
fn reset(&mut self) {
|
||||||
self.current_id = None;
|
self.current_id = None;
|
||||||
self.expected = 0;
|
self.expected = 0;
|
||||||
self.buffer = Vec::new();
|
self.buffer.clear();
|
||||||
self.buffer.shrink_to_fit();
|
|
||||||
self.since = None;
|
self.since = None;
|
||||||
self.seen = 0;
|
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");
|
assert!(f.encode().len() < 1500, "must fit an Ethernet MTU");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "esp"
|
# 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"]
|
components = ["rust-src"]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+9
-5
@@ -1,14 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Builds the firmware with the credentials from .env (see .env.example).
|
# Builds the firmware with the credentials from .env (see .env.example).
|
||||||
#
|
#
|
||||||
# ESP-IDF location comes from .cargo/config.toml (IDF_PATH /
|
# esp-idf-sys downloads the pinned ESP-IDF automatically. Set IDF_PATH in the
|
||||||
# ESP_IDF_TOOLS_INSTALL_DIR), so no ESP-IDF activation is needed here as long as
|
# environment to reuse an existing installation (see README.md).
|
||||||
# those point at an installation you already have. Drop them to let esp-idf-sys
|
|
||||||
# fetch its own pinned ESP-IDF.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
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
|
if [ -f .env ]; then
|
||||||
set -a
|
set -a
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
@@ -19,4 +23,4 @@ fi
|
|||||||
: "${WIFI_SSID:?set WIFI_SSID in .env (or export it) before building}"
|
: "${WIFI_SSID:?set WIFI_SSID in .env (or export it) before building}"
|
||||||
export WIFI_PASS="${WIFI_PASS:-}"
|
export WIFI_PASS="${WIFI_PASS:-}"
|
||||||
|
|
||||||
cargo build --release "$@"
|
cargo build --release --locked "$@"
|
||||||
|
|||||||
+4
-1
@@ -16,6 +16,9 @@ cd "$(dirname "$0")/.."
|
|||||||
|
|
||||||
readonly BAD_FLASH_ADDR="${BAD_FLASH_ADDR:-0xFADE7}"
|
readonly BAD_FLASH_ADDR="${BAD_FLASH_ADDR:-0xFADE7}"
|
||||||
readonly ALIGN="${ALIGN:-0x10000}"
|
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}"
|
readonly MAX_APP_SIZE="${MAX_APP_SIZE:-0x120000}"
|
||||||
|
|
||||||
MONITOR_SECS=25
|
MONITOR_SECS=25
|
||||||
@@ -47,7 +50,7 @@ data = open("/tmp/esp32-cam-app.bin", "rb").read()
|
|||||||
bad = $BAD_FLASH_ADDR
|
bad = $BAD_FLASH_ADDR
|
||||||
align = $ALIGN
|
align = $ALIGN
|
||||||
flash_size = 16 * 1024 * 1024
|
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
|
at = bad - off
|
||||||
if at < 0 or at >= len(data):
|
if at < 0 or at >= len(data):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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('<IBHIIHHIHHB', 0x4D564331, 2, 1, client_id, 0, 0, 0, 0, 0, 0, 2)
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
|
if args.receive_buffer:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, args.receive_buffer)
|
||||||
|
sock.bind((args.local_ip, args.local_port))
|
||||||
|
local_port = sock.getsockname()[1]
|
||||||
|
buffer_bytes = sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)
|
||||||
|
sock.settimeout(.05)
|
||||||
|
sources = {}
|
||||||
|
invalid = 0
|
||||||
|
saved = False
|
||||||
|
started = time.perf_counter()
|
||||||
|
renew = started
|
||||||
|
report_at = started + 5
|
||||||
|
previous_frames = 0
|
||||||
|
previous_report = started
|
||||||
|
try:
|
||||||
|
while time.perf_counter() - started < args.seconds:
|
||||||
|
now = time.perf_counter()
|
||||||
|
for source in sources.values():
|
||||||
|
if source['pending'] is not None and now-source['pending']['started'] > .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())
|
||||||
@@ -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()
|
||||||
@@ -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 <assert.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
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)
|
||||||
@@ -106,8 +106,12 @@ def main():
|
|||||||
prev_frames = 0
|
prev_frames = 0
|
||||||
prev_pkts = 0
|
prev_pkts = 0
|
||||||
prev_drops = 0
|
prev_drops = 0
|
||||||
|
last_subscribe = start
|
||||||
|
|
||||||
while time.time() - start < duration:
|
while time.time() - start < duration:
|
||||||
|
if time.time() - last_subscribe >= 0.8:
|
||||||
|
sock.sendto(sub, (host, PORT))
|
||||||
|
last_subscribe = time.time()
|
||||||
try:
|
try:
|
||||||
data, addr = sock.recvfrom(2048)
|
data, addr = sock.recvfrom(2048)
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
|
|||||||
+13
-5
@@ -4,6 +4,8 @@ CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
|||||||
CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096
|
CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096
|
||||||
CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=4096
|
CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=4096
|
||||||
CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=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.
|
# 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
|
# 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.
|
# fixed here; see partitions.csv for why it cannot be a constant.
|
||||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
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
|
CONFIG_PARTITION_TABLE_OFFSET=0x8000
|
||||||
|
|
||||||
# Octal PSRAM is mandatory for the N16R8 module (R8 == 8 MB OPI PSRAM). The
|
# 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_IRAM_OPT=y
|
||||||
CONFIG_ESP_WIFI_RX_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.
|
# 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.
|
# Run the camera task on CPU1.
|
||||||
#
|
#
|
||||||
# The WiFi task is pinned to CPU0. esp32-camera's capture task spends its time
|
# Explicitly select the choice: disabling CORE0 alone leaves Kconfig free to
|
||||||
# in short, latency-sensitive bursts (DMA completion, the JPEG marker search,
|
# select it again. CPU0/CPU1 A/B tests both reach the sensor's ~22 fps ceiling;
|
||||||
# the copy into the frame buffer) and gets preempted on a shared core, which
|
# the observed transport bottleneck is not resolved by changing this affinity.
|
||||||
# lengthens every frame. On a two-core part there is no reason to share.
|
|
||||||
CONFIG_CAMERA_CORE0=n
|
CONFIG_CAMERA_CORE0=n
|
||||||
|
CONFIG_CAMERA_CORE1=y
|
||||||
|
|
||||||
# PSRAM DMA is left off deliberately.
|
# PSRAM DMA is left off deliberately.
|
||||||
#
|
#
|
||||||
|
|||||||
+135
-6
@@ -112,6 +112,14 @@ pub struct Frame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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] {
|
pub fn data(&self) -> &[u8] {
|
||||||
if self.fb.data.is_null() || self.fb.len == 0 {
|
if self.fb.data.is_null() || self.fb.len == 0 {
|
||||||
return &[];
|
return &[];
|
||||||
@@ -206,12 +214,17 @@ impl Camera {
|
|||||||
pixel_format: PIXFORMAT_JPEG,
|
pixel_format: PIXFORMAT_JPEG,
|
||||||
frame_size: resolution.framesize(),
|
frame_size: resolution.framesize(),
|
||||||
jpeg_quality: jpeg_quality as i32,
|
jpeg_quality: jpeg_quality as i32,
|
||||||
// Three buffers, not two: esp32-camera needs a free buffer at every
|
// Three buffers tolerate socket stalls. Two reduce capture queue
|
||||||
// VSYNC, and with two a client that is still draining the previous
|
// depth, but may miss a VSYNC while the caller holds a buffer.
|
||||||
// frame costs a whole capture period. One is spent filling, one is
|
// Keep this selectable so latency and delivered FPS can be measured
|
||||||
// being written to the socket, and one is spare for the moment the
|
// together instead of assuming the smallest pool always wins.
|
||||||
// write blocks.
|
fb_count: {
|
||||||
fb_count: 3,
|
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,
|
fb_location: FB_IN_PSRAM,
|
||||||
// LATEST keeps the newest frame in the queue instead of handing out
|
// LATEST keeps the newest frame in the queue instead of handing out
|
||||||
// whatever was captured first, which matters once a client is slow
|
// 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<Frame, FrameError> {
|
||||||
|
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.
|
/// Grabs the next frame.
|
||||||
///
|
///
|
||||||
/// This blocks until the sensor has one, which is what paces the stream.
|
/// This blocks until the sensor has one, which is what paces the stream.
|
||||||
|
|||||||
@@ -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<u64>,
|
||||||
|
lease_started_ms: Option<u64>,
|
||||||
|
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<Action> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+328
-21
@@ -9,6 +9,10 @@
|
|||||||
//! WiFi credentials come from the `WIFI_SSID` / `WIFI_PASS` environment variables
|
//! WiFi credentials come from the `WIFI_SSID` / `WIFI_PASS` environment variables
|
||||||
//! at build time, so no secrets end up in the source.
|
//! at build time, so no secrets end up in the source.
|
||||||
|
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicU32, Ordering},
|
||||||
|
Arc,
|
||||||
|
};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
@@ -16,14 +20,17 @@ use embedded_svc::wifi::{
|
|||||||
AccessPointInfo, AuthMethod, ClientConfiguration, Configuration, PmfConfiguration,
|
AccessPointInfo, AuthMethod, ClientConfiguration, Configuration, PmfConfiguration,
|
||||||
};
|
};
|
||||||
use esp_idf_svc::hal::peripherals::Peripherals;
|
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 esp_idf_svc::{eventloop::EspSystemEventLoop, nvs::EspDefaultNvsPartition};
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
|
|
||||||
mod camera;
|
mod camera;
|
||||||
|
mod link_control;
|
||||||
|
mod stream_control;
|
||||||
mod udp;
|
mod udp;
|
||||||
|
|
||||||
use camera::{Camera, Resolution, SensorHint};
|
use camera::{Camera, Resolution, SensorHint};
|
||||||
|
use stream_control::{Pacer, QualityController, ThermalLimiter};
|
||||||
use udp::UdpSender;
|
use udp::UdpSender;
|
||||||
|
|
||||||
const SSID: &str = env!("WIFI_SSID");
|
const SSID: &str = env!("WIFI_SSID");
|
||||||
@@ -84,14 +91,88 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
let resolution: Resolution = RESOLUTION.parse()?;
|
let resolution: Resolution = RESOLUTION.parse()?;
|
||||||
let quality: u8 = QUALITY.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());
|
info!("vsync invert: {}", vsync_invert());
|
||||||
|
|
||||||
let camera = Camera::init(SensorHint::Auto, resolution, quality, 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 peripherals = Peripherals::take()?;
|
||||||
let sys_loop = EspSystemEventLoop::take()?;
|
let sys_loop = EspSystemEventLoop::take()?;
|
||||||
let nvs = EspDefaultNvsPartition::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::<WifiEvent, _>(move |event| {
|
||||||
|
if matches!(event, WifiEvent::StaDisconnected(_)) {
|
||||||
|
event_epoch.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
let mut wifi = BlockingWifi::wrap(
|
let mut wifi = BlockingWifi::wrap(
|
||||||
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
|
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;
|
let ip = wifi.wifi().sta_netif().get_ip_info()?.ip;
|
||||||
|
|
||||||
// Modem power save trades latency for current, and for a video stream that
|
configure_radio()?;
|
||||||
// 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})");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The board identifies itself by an id derived from its MAC, not its IP: DHCP
|
// 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
|
// 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_report = Instant::now();
|
||||||
let mut last_stats = sender.stats();
|
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::<u64>()?
|
||||||
|
.saturating_mul(1000);
|
||||||
|
let mut test_injected = false;
|
||||||
|
let mut test_hold_until = 0u64;
|
||||||
|
|
||||||
loop {
|
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)?;
|
udp::pump(&camera, &mut sender)?;
|
||||||
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
@@ -148,16 +321,90 @@ fn main() -> Result<()> {
|
|||||||
// distinction that matters when the frame rate is wrong.
|
// distinction that matters when the frame rate is wrong.
|
||||||
let s = sender.stats();
|
let s = sender.stats();
|
||||||
let elapsed = now.duration_since(last_report).as_secs_f32();
|
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!(
|
info!(
|
||||||
"{:.1} fps, {} datagrams/s, {:.1} kB/s, {} send errors, \
|
"{:.1} fps submitted, {:.1} fps attempted, {:.1} datagrams/s, {:.1} kB/s, \
|
||||||
{} clients subscribed, {} frames skipped (no client)",
|
{} 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_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.fragments_sent - last_stats.fragments_sent) as f32 / elapsed,
|
||||||
(s.bytes_sent - last_stats.bytes_sent) as f32 / elapsed / 1024.0,
|
(s.bytes_sent - last_stats.bytes_sent) as f32 / elapsed / 1024.0,
|
||||||
s.errors - last_stats.errors,
|
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(),
|
sender.peer_count(),
|
||||||
s.skipped_no_peer - last_stats.skipped_no_peer,
|
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;
|
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.
|
/// 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
|
/// 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<EspWifi<'static>>) -> Result<()> {
|
|||||||
|
|
||||||
// A configuration has to exist before start(), which is what decides
|
// A configuration has to exist before start(), which is what decides
|
||||||
// station vs AP mode; the real one is picked after the scan.
|
// 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()?;
|
wifi.start()?;
|
||||||
info!("wifi started");
|
info!("wifi started");
|
||||||
|
|
||||||
@@ -232,7 +531,11 @@ fn connect(wifi: &mut BlockingWifi<EspWifi<'static>>) -> Result<()> {
|
|||||||
|
|
||||||
warn!(
|
warn!(
|
||||||
"{auth:?} did not complete the handshake ({})",
|
"{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();
|
wifi.wifi_mut().disconnect().ok();
|
||||||
}
|
}
|
||||||
@@ -329,7 +632,11 @@ fn report_scan(aps: &[AccessPointInfo]) {
|
|||||||
ap.channel,
|
ap.channel,
|
||||||
ap.signal_strength,
|
ap.signal_strength,
|
||||||
ap.auth_method,
|
ap.auth_method,
|
||||||
if ap.ssid.as_str() == SSID { " <- target" } else { "" }
|
if ap.ssid.as_str() == SSID {
|
||||||
|
" <- target"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<u8> {
|
||||||
|
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<f32>) -> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+224
-52
@@ -32,6 +32,9 @@ use esp_idf_svc::sys as ffi;
|
|||||||
use log::{debug, info, warn};
|
use log::{debug, info, warn};
|
||||||
|
|
||||||
use crate::camera::{Camera, Frame};
|
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;
|
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 结构。
|
/// 把 Rust 的 socket 地址转成 lwIP 期望的 C 结构。
|
||||||
///
|
///
|
||||||
/// 必须逐字段构造而不是 transmute:Rust 的 `SocketAddrV4` 是紧凑的 6 字节(端口在前、
|
/// 必须逐字段构造而不是 transmute:Rust 的 `SocketAddrV4` 是紧凑的 6 字节(端口在前、
|
||||||
@@ -75,6 +82,7 @@ fn to_sockaddr(addr: SocketAddrV4) -> ffi::sockaddr_in {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
struct Peer {
|
struct Peer {
|
||||||
addr: SocketAddrV4,
|
addr: SocketAddrV4,
|
||||||
|
client_id: u32,
|
||||||
/// 最近一次收到它订阅的时刻。
|
/// 最近一次收到它订阅的时刻。
|
||||||
last_seen_us: i64,
|
last_seen_us: i64,
|
||||||
}
|
}
|
||||||
@@ -84,20 +92,32 @@ struct Peer {
|
|||||||
/// 与"传感器产出了多少"分开计数:两者的差就是这条链路的全部故事。
|
/// 与"传感器产出了多少"分开计数:两者的差就是这条链路的全部故事。
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
pub struct SendStats {
|
pub struct SendStats {
|
||||||
/// 已交给网络的帧。
|
pub capture_errors: u32,
|
||||||
|
/// 所有分片均交给网络的帧(至少一个对端;不代表无线接收确认)。
|
||||||
pub frames_sent: u32,
|
pub frames_sent: u32,
|
||||||
|
pub frames_attempted: u32,
|
||||||
|
pub peer_frames_sent: u32,
|
||||||
|
pub peer_frames_dropped: u32,
|
||||||
/// 已发出的数据报。
|
/// 已发出的数据报。
|
||||||
pub fragments_sent: u32,
|
pub fragments_sent: u32,
|
||||||
/// 已发出的字节,含头。
|
/// 已发出的字节,含头。
|
||||||
pub bytes_sent: u64,
|
pub bytes_sent: u64,
|
||||||
/// `sendto` 失败次数。
|
/// `sendto` 失败次数。
|
||||||
pub errors: u32,
|
pub errors: u32,
|
||||||
|
pub retries: u32,
|
||||||
|
pub last_errno: i32,
|
||||||
|
pub send_time_us: u64,
|
||||||
/// 因没有客户端而未发送的帧。
|
/// 因没有客户端而未发送的帧。
|
||||||
pub skipped_no_peer: u32,
|
pub skipped_no_peer: u32,
|
||||||
/// 收到的订阅数。
|
/// 收到的订阅数。
|
||||||
pub subscriptions: u32,
|
pub subscriptions: u32,
|
||||||
/// 已广播的 Hello 数。
|
/// 已广播的 Hello 数。
|
||||||
pub hellos_sent: u32,
|
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],
|
announce_buf: [u8; 64],
|
||||||
last_hello_us: i64,
|
last_hello_us: i64,
|
||||||
|
datagram: Vec<u8>,
|
||||||
|
packet_pacer: Pacer,
|
||||||
|
retry_budget_us: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 本机标识:由 MAC 派生,重启后不变。
|
/// 本机标识:由 MAC 派生,重启后不变。
|
||||||
@@ -118,12 +141,7 @@ pub fn local_sender_id() -> u32 {
|
|||||||
let mut mac = [0u8; 6];
|
let mut mac = [0u8; 6];
|
||||||
|
|
||||||
// SAFETY: `mac` 有 6 字节空间,函数会把 6 字节写满。
|
// SAFETY: `mac` 有 6 字节空间,函数会把 6 字节写满。
|
||||||
let rc = unsafe {
|
let rc = unsafe { ffi::esp_read_mac(mac.as_mut_ptr(), ffi::esp_mac_type_t_ESP_MAC_WIFI_STA) };
|
||||||
ffi::esp_read_mac(
|
|
||||||
mac.as_mut_ptr(),
|
|
||||||
ffi::esp_mac_type_t_ESP_MAC_WIFI_STA,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
if rc != 0 {
|
if rc != 0 {
|
||||||
warn!("could not read the WiFi MAC (esp_err {rc}); using a fixed id");
|
warn!("could not read the WiFi MAC (esp_err {rc}); using a fixed id");
|
||||||
@@ -142,6 +160,22 @@ impl UdpSender {
|
|||||||
///
|
///
|
||||||
/// socket 既收也发:客户端广播订阅进来,板子往它看到的源地址推流。
|
/// socket 既收也发:客户端广播订阅进来,板子往它看到的源地址推流。
|
||||||
pub fn new(sender_id: u32) -> Result<Self> {
|
pub fn new(sender_id: u32) -> Result<Self> {
|
||||||
|
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 内存的指针。
|
// SAFETY: 纯 socket 设置,没有指向 Rust 内存的指针。
|
||||||
let socket = unsafe {
|
let socket = unsafe {
|
||||||
let fd = ffi::lwip_socket(ffi::AF_INET as i32, ffi::SOCK_DGRAM as i32, 0);
|
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::<i32>() as u32,
|
std::mem::size_of::<i32>() 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::<i32>() as u32,
|
||||||
|
) < 0
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
"could not set video Wi-Fi priority: errno {}",
|
||||||
|
*ffi::__errno()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 绑定到视频端口的所有接口。地址留 0,lwIP 视作 INADDR_ANY。
|
// 绑定到视频端口的所有接口。地址留 0,lwIP 视作 INADDR_ANY。
|
||||||
let addr = ffi::sockaddr_in {
|
let addr = ffi::sockaddr_in {
|
||||||
sin_len: std::mem::size_of::<ffi::sockaddr_in>() as u8,
|
sin_len: std::mem::size_of::<ffi::sockaddr_in>() as u8,
|
||||||
@@ -188,8 +239,7 @@ impl UdpSender {
|
|||||||
let flags = ffi::lwip_fcntl(fd, ffi::F_GETFL as i32, 0);
|
let flags = ffi::lwip_fcntl(fd, ffi::F_GETFL as i32, 0);
|
||||||
|
|
||||||
if flags < 0
|
if flags < 0
|
||||||
|| ffi::lwip_fcntl(fd, ffi::F_SETFL as i32, flags | ffi::O_NONBLOCK as i32)
|
|| ffi::lwip_fcntl(fd, ffi::F_SETFL as i32, flags | ffi::O_NONBLOCK as i32) < 0
|
||||||
< 0
|
|
||||||
{
|
{
|
||||||
let err = *ffi::__errno();
|
let err = *ffi::__errno();
|
||||||
ffi::lwip_close(fd);
|
ffi::lwip_close(fd);
|
||||||
@@ -224,6 +274,9 @@ impl UdpSender {
|
|||||||
stats: SendStats::default(),
|
stats: SendStats::default(),
|
||||||
announce_buf: [0u8; 64],
|
announce_buf: [0u8; 64],
|
||||||
last_hello_us: 0,
|
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
|
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);
|
let buf = hello.encode(protocol::MsgType::Hello);
|
||||||
|
|
||||||
// 广播地址 255.255.255.255。
|
// 广播地址 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 会复制而不是持有指针。
|
// SAFETY: 缓冲区与地址在调用期间都有效,sendto 会复制而不是持有指针。
|
||||||
let rc = unsafe {
|
let rc = unsafe {
|
||||||
@@ -261,7 +340,9 @@ impl UdpSender {
|
|||||||
if rc >= 0 {
|
if rc >= 0 {
|
||||||
self.stats.hellos_sent += 1;
|
self.stats.hellos_sent += 1;
|
||||||
} else {
|
} 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() };
|
let err = unsafe { *ffi::__errno() };
|
||||||
|
|
||||||
if err != 11 /* EWOULDBLOCK */ {
|
if err != 11
|
||||||
|
/* EWOULDBLOCK */
|
||||||
|
{
|
||||||
warn!("recvfrom on the subscribe socket failed: errno {err}");
|
warn!("recvfrom on the subscribe socket failed: errno {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,11 +391,10 @@ impl UdpSender {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(_sub) = protocol::Subscribe::decode(&self.announce_buf[..rc as usize])
|
let Some(sub) = protocol::Subscribe::decode(&self.announce_buf[..rc as usize]) else {
|
||||||
else {
|
|
||||||
// 不是订阅:可能是另一块板子的 Hello,或者将来的其它消息。安静跳过,
|
// 不是订阅:可能是另一块板子的 Hello,或者将来的其它消息。安静跳过,
|
||||||
// 每包都打日志没有意义。客户端的身份由它的源地址决定,订阅里带的
|
// 每包都打日志没有意义。客户端的身份由它的源地址决定,订阅里带的
|
||||||
// id 只是给它自己用的,所以这里不需要读。
|
// id 用于识别新客户端的一次接收会话。
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -323,15 +405,29 @@ impl UdpSender {
|
|||||||
);
|
);
|
||||||
|
|
||||||
self.stats.subscriptions += 1;
|
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();
|
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;
|
peer.last_seen_us = now;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -344,6 +440,7 @@ impl UdpSender {
|
|||||||
info!("client at {addr} subscribed; streaming to it");
|
info!("client at {addr} subscribed; streaming to it");
|
||||||
self.peers.push(Peer {
|
self.peers.push(Peer {
|
||||||
addr,
|
addr,
|
||||||
|
client_id,
|
||||||
last_seen_us: now,
|
last_seen_us: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -372,55 +469,125 @@ impl UdpSender {
|
|||||||
///
|
///
|
||||||
/// 帧缓冲在返回时就交还驱动——多占着只会增加延迟。
|
/// 帧缓冲在返回时就交还驱动——多占着只会增加延迟。
|
||||||
pub fn send(&mut self, frame: &Frame) -> Result<()> {
|
pub fn send(&mut self, frame: &Frame) -> Result<()> {
|
||||||
|
if frame.data().is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if self.peers.is_empty() {
|
if self.peers.is_empty() {
|
||||||
self.stats.skipped_no_peer += 1;
|
self.stats.skipped_no_peer += 1;
|
||||||
return Ok(());
|
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;
|
let frame_id = self.next_frame_id;
|
||||||
self.next_frame_id = self.next_frame_id.wrapping_add(1);
|
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(
|
// Borrow the PSRAM JPEG in place. fragment_frame() copies every payload
|
||||||
self.sender_id,
|
// into small internal-RAM allocations, competing with Wi-Fi TX buffers.
|
||||||
frame_id,
|
let jpeg = frame.data();
|
||||||
frame.data(),
|
let frag_count = jpeg.len().div_ceil(protocol::MAX_FRAGMENT_PAYLOAD);
|
||||||
frame.width() as u16,
|
for (frag_index, payload) in jpeg.chunks(protocol::MAX_FRAGMENT_PAYLOAD).enumerate() {
|
||||||
frame.height() as u16,
|
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 下这是
|
for (index, peer) in self.peers.iter().enumerate() {
|
||||||
// 每秒 22 次与约 250 次分配的区别。
|
if !active[index] {
|
||||||
let mut datagram = Vec::with_capacity(SEND_BUFFER_SIZE);
|
continue;
|
||||||
|
}
|
||||||
for frag in &fragments {
|
|
||||||
datagram.clear();
|
|
||||||
frag.encode_into(&mut datagram);
|
|
||||||
|
|
||||||
for peer in &self.peers {
|
|
||||||
let sa = to_sockaddr(peer.addr);
|
let sa = to_sockaddr(peer.addr);
|
||||||
|
|
||||||
// SAFETY: 两个缓冲区在调用期间都有效,sendto 会复制。
|
loop {
|
||||||
let rc = unsafe {
|
if budget.expired(now_us() as u64) {
|
||||||
ffi::lwip_sendto(
|
active[index] = false;
|
||||||
self.socket,
|
self.stats.peer_frames_dropped += 1;
|
||||||
datagram.as_ptr() as *const std::ffi::c_void,
|
self.stats.deadline_peer_drops += 1;
|
||||||
datagram.len(),
|
break;
|
||||||
ffi::MSG_DONTWAIT as i32,
|
}
|
||||||
&sa as *const ffi::sockaddr_in as *const ffi::sockaddr,
|
let wait_us = self.packet_pacer.delay_us(now_us() as u64);
|
||||||
std::mem::size_of::<ffi::sockaddr_in>() as ffi::socklen_t,
|
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::<ffi::sockaddr_in>() 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;
|
self.stats.errors += 1;
|
||||||
} else {
|
let errno = unsafe { *ffi::__errno() };
|
||||||
self.stats.fragments_sent += 1;
|
self.stats.last_errno = errno;
|
||||||
self.stats.bytes_sent += rc as u64;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +619,9 @@ impl Drop for UdpSender {
|
|||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// SAFETY: socket 由本值拥有,且只关闭一次。
|
// SAFETY: socket 由本值拥有,且只关闭一次。
|
||||||
unsafe {
|
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<()> {
|
pub fn pump(camera: &Camera, sender: &mut UdpSender) -> Result<()> {
|
||||||
sender.housekeeping();
|
sender.housekeeping();
|
||||||
|
|
||||||
let frame = match camera.next_frame() {
|
let frame = match camera.latest_frame() {
|
||||||
Ok(frame) => frame,
|
Ok(frame) => frame,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
sender.stats.capture_errors += 1;
|
||||||
// 丢帧是可恢复的——驱动会重新同步——这里没什么可做的。
|
// 丢帧是可恢复的——驱动会重新同步——这里没什么可做的。
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
sender.stats.capture_queue_skipped += frame.skipped_before();
|
||||||
|
|
||||||
sender.send(&frame)
|
sender.send(&frame)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user