ESP32-S3 摄像头图传:固件 + Slint 客户端 + 共享协议

两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV2640/OV3660/OV5640),
向队伍客户端与裁判终端推 UDP 图传。固件是跑在 ESP-IDF 上的 Rust,用官方
esp32-camera 组件采集;客户端是 Rust + Slint 桌面程序。

三个 crate 各自独立构建:
  .            firmware,交叉编译到 Xtensa
  protocol/    线路格式与帧重组,两端共用,无 ESP-IDF 依赖,有单元测试
  client/      Slint 桌面客户端

视频走 UDP 而非 TCP:实测传感器产出 22 fps,TCP MJPEG 只送出 1.9 fps,
且只占用链路能力的 1%——链路是空的,帧堵在队头阻塞后面等一个实时画面
等不起的重传计时器。改为独立数据报、不重传、缺片丢整帧后达到 20.8 fps。

发现是双向的:板子每 2 秒广播 Hello,客户端广播 Subscribe,板子据此定向
推流。板子身份由 MAC 派生,重启不变,所以客户端可以把绑定存进配置。

裁判端同时收两块板子(每个发送方一个重组器),队伍端绑定其中一块并只显示
自己那一边的血量。装甲板接口已预留(ArmorSource trait + ARMOR_PORT),
当前无数据源,界面显示占位符。

vendor 了 esp32-camera 2.1.7:组件注册表的版本求解器找不到它,且需要打一处
补丁(见 components/esp32-camera/target/esp32s3/ll_cam.c)。

.gitignore 三条 target/ 都锚定在根目录,因为 protocol/ 与 client/ 各自也是
独立 crate;而 components/esp32-camera/target/ 是上游的按芯片分目录,里面是
源码,必须保留。
This commit is contained in:
Horldsence
2026-10-05 18:22:19 +08:00
commit 4f893ce140
134 changed files with 41128 additions and 0 deletions
+416
View File
@@ -0,0 +1,416 @@
// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。
/* 一块画面的读数。裁判端有两块,队伍端一块。 */
export struct CameraStats {
// 在线状态
online: bool,
status-text: string,
// 实时测量
fps: string,
resolution: string,
frame-bytes: string,
// 累计
frames-shown: string,
frames-dropped: string,
loss-percent: string,
datagrams: string,
// 帧间隔,用于把"卡"和"帧率低"区分开
gap-ms: string,
}
/* 一边的血量(4 块装甲板)。
*
* `available` 为 false 时界面显示占位符而不是 0:0 是一个看起来像真值的假值,
* 现场看到 0 会以为对方已经没血了。 */
export struct TeamHealth {
team-name: string,
available: bool,
// 占位符文案
placeholder: string,
// 每块装甲板的血量;available 时有效
hps: [int],
}
/* 首次运行时的摄像头选择。
*
* 队伍端需要绑定一块板子(选定后不可更改),裁判端不需要。 */
export struct CameraChoice {
id-text: string,
addr-text: string,
online: bool,
}
// ---------------------------------------------------------------- 队伍端
/*
* 队伍终端:一块画面 + 自己那一边的血量。
*
* 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省
* 画面空间给视频,一边要均分),共用一套再用 visible 切换会让每处改动都要考虑两种
* 情况。 */
export component TeamApp inherits Window {
title: "cam-client — 队伍";
preferred-width: 1000px;
preferred-height: 720px;
in property <image> frame;
in property <CameraStats> stats;
in property <TeamHealth> health;
// 选择绑定时显示,其余时候隐藏
in property <[CameraChoice]> choices;
in property <bool> choosing;
// 选中了列表里的第 N 项。
//
// 绑定在这里一次完成:Rust 侧收到回调就写配置文件,所以"选定"当场生效,而不是
// "下次启动才生效"。索引而不是 id,是为了让 Rust 侧回到自己那份列表里取完整
// 信息——.slint 里的模型只是给界面看的。
callback pick(int);
background: #101014;
// 绑定完成前只显示选择界面:没有画面可显示,显示一个空窗口更让人困惑。
if !root.choosing: VerticalLayout {
spacing: 0;
HorizontalLayout {
padding: 0;
spacing: 0;
Rectangle {
background: #0a0a0d;
vertical-stretch: 1;
frame := Image {
source: root.frame;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 底部一条:左边自己这边的血量,右边这一路的统计。
Rectangle {
background: #1c1c22;
height: 72px;
HorizontalLayout {
padding: 14px;
spacing: 24px;
VerticalLayout {
padding: 0;
spacing: 2px;
horizontal-stretch: 1;
Text {
text: root.health.team-name;
color: #e8e8ec;
font-size: 14px;
font-weight: 600;
}
// 装甲板还没接上,所以是一排占位而不是四个数字。
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health.hps: Rectangle {
width: 34px;
height: 12px;
border-width: 1px;
border-color: root.health.available ? #4a4a55 : #33333c;
background: root.health.available ? #7ddc7d : #1a1a20;
}
}
}
VerticalLayout {
padding: 0;
spacing: 2px;
preferred-width: 320px;
Text {
text: root.stats.status-text + " " + root.stats.fps + " fps "
+ root.stats.resolution + " " + root.stats.frame-bytes;
color: root.stats.online ? #e8e8ec : #e0a060;
font-size: 13px;
}
Text {
text: "shown " + root.stats.frames-shown
+ " dropped " + root.stats.frames-dropped
+ " (" + root.stats.loss-percent + ")"
+ " gap " + root.stats.gap-ms;
color: root.stats.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
}
}
// 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。
Rectangle {
background: #14141a;
height: 26px;
Text {
text: root.health.placeholder;
color: #6a6a75;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 选择界面
if root.choosing: VerticalLayout {
padding: 32px;
spacing: 16px;
Text {
text: "选择要观看的摄像头";
color: #e8e8ec;
font-size: 20px;
font-weight: 600;
}
Text {
text: "选定后不能更改。这里的选择会写入配置文件,重启后仍然生效。";
color: #8a8a95;
font-size: 13px;
wrap: word-wrap;
}
Rectangle { height: 8px; background: transparent; }
// TouchArea 而不是给 Rectangle 挂 callback:Rectangle 是基础元素,没有
// 点击回调;TouchArea 才是带交互的元素。
for choice[index] in root.choices: Rectangle {
background: choice.online ? #1c1c22 : #16161b;
border-width: 1px;
border-color: #2a2a33;
height: 56px;
TouchArea {
clicked => {
root.pick(index);
}
}
HorizontalLayout {
padding: 14px;
spacing: 16px;
Text {
text: choice.id-text;
color: #e8e8ec;
font-size: 14px;
font-weight: 600;
vertical-alignment: center;
horizontal-alignment: left;
}
Text {
text: choice.addr-text;
color: #8a8a95;
font-size: 12px;
vertical-alignment: center;
horizontal-alignment: left;
horizontal-stretch: 1;
}
Text {
text: choice.online ? "在线" : "离线";
color: choice.online ? #7ddc7d : #8a8a95;
font-size: 12px;
vertical-alignment: center;
}
}
}
// 一块都没发现时给出可操作的提示,而不是一个空列表。
if root.choices.length == 0: Rectangle {
background: #1a1418;
border-width: 1px;
border-color: #4a2a35;
height: 64px;
Text {
text: "还没有发现任何摄像头。请确认板子已上电、已连上这个网络,\n然后稍候——板子每 2 秒广播一次在线状态。";
color: #e0a060;
font-size: 13px;
wrap: word-wrap;
vertical-alignment: center;
}
}
}
}
// ---------------------------------------------------------------- 裁判端
/* 裁判终端:两块画面左右并排 + 双方血量。
*
* 与队伍端分开:这里的核心诉求是"一眼比出两边的状态",所以两块画面等宽、血量
* 并排对称。 */
export component RefereeApp inherits Window {
title: "cam-client — 裁判系统";
preferred-width: 1400px;
preferred-height: 800px;
in property <image> frame-a;
in property <image> frame-b;
in property <CameraStats> stats-a;
in property <CameraStats> stats-b;
in property <TeamHealth> health-a;
in property <TeamHealth> health-b;
background: #101014;
VerticalLayout {
spacing: 0;
// 两路画面等宽并排,各占一半。
HorizontalLayout {
padding: 0;
spacing: 2px;
Rectangle {
background: #0a0a0d;
vertical-stretch: 1;
horizontal-stretch: 1;
frame-a := Image {
source: root.frame-a;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
background: #0a0a0d;
vertical-stretch: 1;
horizontal-stretch: 1;
frame-b := Image {
source: root.frame-b;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 每路画面下方的队伍条:名字、血量、统计。
HorizontalLayout {
padding: 0;
spacing: 2px;
Rectangle {
background: #1c1c22;
height: 78px;
horizontal-stretch: 1;
VerticalLayout {
padding: 10px;
spacing: 4px;
Text {
text: root.health-a.team-name + " " + root.stats-a.status-text
+ " " + root.stats-a.fps + " fps " + root.stats-a.resolution;
color: root.stats-a.online ? #e8e8ec : #e0a060;
font-size: 14px;
font-weight: 600;
}
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health-a.hps: Rectangle {
width: 40px;
height: 14px;
border-width: 1px;
border-color: root.health-a.available ? #4a4a55 : #33333c;
background: root.health-a.available ? #7ddc7d : #1a1a20;
}
}
Text {
text: "shown " + root.stats-a.frames-shown
+ " dropped " + root.stats-a.frames-dropped
+ " (" + root.stats-a.loss-percent + ")"
+ " gap " + root.stats-a.gap-ms;
color: root.stats-a.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
}
Rectangle {
background: #1c1c22;
height: 78px;
horizontal-stretch: 1;
VerticalLayout {
padding: 10px;
spacing: 4px;
Text {
text: root.health-b.team-name + " " + root.stats-b.status-text
+ " " + root.stats-b.fps + " fps " + root.stats-b.resolution;
color: root.stats-b.online ? #e8e8ec : #e0a060;
font-size: 14px;
font-weight: 600;
}
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health-b.hps: Rectangle {
width: 40px;
height: 14px;
border-width: 1px;
border-color: root.health-b.available ? #4a4a55 : #33333c;
background: root.health-b.available ? #7ddc7d : #1a1a20;
}
}
Text {
text: "shown " + root.stats-b.frames-shown
+ " dropped " + root.stats-b.frames-dropped
+ " (" + root.stats-b.loss-percent + ")"
+ " gap " + root.stats-b.gap-ms;
color: root.stats-b.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
}
}
// 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。
Rectangle {
background: #14141a;
height: 26px;
Text {
text: root.health-a.placeholder;
color: #6a6a75;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}