两块 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/ 是上游的按芯片分目录,里面是 源码,必须保留。
416 lines
14 KiB
Plaintext
416 lines
14 KiB
Plaintext
// 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;
|
||
}
|
||
}
|
||
}
|
||
} |