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:
@@ -0,0 +1,628 @@
|
||||
//! ESP32 图传查看器。
|
||||
//!
|
||||
//! 两种身份,由 [`Role`] 决定:
|
||||
//!
|
||||
//! - **队伍端** —— 一块画面 + 自己那一边的血量。需要绑定一块板子(选定后不可更改)。
|
||||
//! - **裁判端** —— 两块画面左右并排 + 双方血量。不需要绑定。
|
||||
//!
|
||||
//! 用法:
|
||||
//!
|
||||
//! ```text
|
||||
//! cam-client 默认裁判端
|
||||
//! cam-client --role team --team 1 队伍一
|
||||
//! cam-client --camera-id 0x3D4D8A9A 绑定到某块板子
|
||||
//! ```
|
||||
//!
|
||||
//! 绑定与身份存在配置文件里(默认 `~/.config/cam-client/config.toml`)。首次运行
|
||||
//! 队伍端会列出发现的摄像头,选定后写入,之后不再询问。
|
||||
//!
|
||||
//! # 为什么血量是占位符
|
||||
//!
|
||||
//! 装甲板接口已经预留([`receiver::ArmorSource`] 与 `protocol::ArmorHealth`),但
|
||||
//! 现在没有实现也没有数据源。界面显示占位符而不是 0——0 看起来像真值,现场看到会
|
||||
//! 以为对方已经没血了。
|
||||
|
||||
mod config;
|
||||
mod receiver;
|
||||
|
||||
use std::error::Error;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use config::{Config, Role};
|
||||
use receiver::{
|
||||
decode_frame, ArmorSource, CameraInfo, DecodedFrame, NoArmorYet, Receiver,
|
||||
SourceStats, ARMOR_BOARDS,
|
||||
};
|
||||
|
||||
slint::include_modules!();
|
||||
|
||||
const USAGE: &str = "\
|
||||
用法: cam-client [选项]
|
||||
|
||||
--role <team|referee> 身份。默认 referee(无需配置即可看两路)
|
||||
--team <0|1> 自己属于哪一边,仅 team 端用
|
||||
--camera-id <0xID> 绑定到某块板子(MAC 派生,重启不变)
|
||||
--camera <IP> 向哪块板子发订阅(默认 255.255.255.255 广播)
|
||||
--config <路径> 配置文件位置
|
||||
--list 列出发现的摄像头后退出
|
||||
-h, --help 显示这段帮助
|
||||
";
|
||||
|
||||
/// 事件循环轮询新数据的间隔。
|
||||
///
|
||||
/// 不是每帧一次:显示不需要每秒重绘 22 次,更快只是让事件循环和接收路径抢 CPU。
|
||||
const UI_POLL: Duration = Duration::from_millis(33);
|
||||
|
||||
/// 帧率统计窗口。
|
||||
///
|
||||
/// 按计数除以墙钟时间,而不是对帧间隔取平均。一次轮询之间可能完成多帧,所以平均间隔
|
||||
/// 量的是**显示**的刷新快慢而不是**相机**的出帧快慢——那会把上限 22fps 的传感器
|
||||
/// 报成 36fps。一个笃定的错误数字比没有数字更糟。
|
||||
const FPS_WINDOW: Duration = Duration::from_secs(2);
|
||||
|
||||
/// 某一路的显示状态。
|
||||
struct View {
|
||||
/// 显示哪块板子(`None` = 还没有画面)。
|
||||
sender_id: Option<u32>,
|
||||
fps: f32,
|
||||
window_started: Instant,
|
||||
window_frames: u64,
|
||||
reported: bool,
|
||||
}
|
||||
|
||||
impl View {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
sender_id: None,
|
||||
fps: 0.0,
|
||||
window_started: Instant::now(),
|
||||
window_frames: 0,
|
||||
reported: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 累计窗口内的帧数,算出帧率。
|
||||
fn update_fps(&mut self, stats: &SourceStats) {
|
||||
let window = self.window_started.elapsed();
|
||||
if window < FPS_WINDOW {
|
||||
return;
|
||||
}
|
||||
|
||||
self.fps = (stats.frames_shown - self.window_frames) as f32 / window.as_secs_f32();
|
||||
self.window_frames = stats.frames_shown;
|
||||
self.window_started = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 解码后的画面缓存,供事件循环取用。
|
||||
///
|
||||
/// 解码线程只处理纯数据(`Vec<u8>` + 尺寸),`slint::Image` 的构造留在事件循环,
|
||||
/// 因为 `slint::Image` 不是 `Send`。
|
||||
type DecodedSlots = Arc<Mutex<Vec<(u32, DecodedFrame)>>>;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
let args = Args::parse(std::env::args().skip(1))?;
|
||||
|
||||
if args.help {
|
||||
print!("{USAGE}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_path = args.config.clone().unwrap_or_else(config::default_config_path);
|
||||
|
||||
// 命令行覆盖配置文件,这样现场可以临时当另一台机器用而不用改文件。
|
||||
let mut config = Config::load(&config_path)?;
|
||||
|
||||
if let Some(role) = args.role {
|
||||
config.role = role;
|
||||
}
|
||||
if let Some(team) = args.team {
|
||||
config.team = team;
|
||||
}
|
||||
if let Some(id) = args.camera_id {
|
||||
config.camera_id = Some(id);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"role={} team={} camera_id={:?} config={}",
|
||||
config.role.as_str(),
|
||||
config.team,
|
||||
config.camera_id,
|
||||
config_path.display()
|
||||
);
|
||||
|
||||
let rx = Arc::new(Receiver::start(
|
||||
args.camera,
|
||||
config.role.to_protocol(),
|
||||
)?);
|
||||
let discovery = rx.discovery();
|
||||
let frames = rx.frames();
|
||||
|
||||
if args.list {
|
||||
// 等几个广播周期,让列表有内容。
|
||||
let socket = bind_socket()?;
|
||||
let for_list = Arc::clone(&rx);
|
||||
let handle = std::thread::spawn(move || for_list.run(socket));
|
||||
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
|
||||
let cameras = discovery.online_cameras();
|
||||
if cameras.is_empty() {
|
||||
println!("没有发现摄像头。板子每 2 秒广播一次,再等等或检查网络。");
|
||||
} else {
|
||||
println!("发现 {} 块板子:", cameras.len());
|
||||
for c in cameras {
|
||||
println!(" 0x{:08X} {} (id 与 MAC 派生,重启不变)", c.id, c.addr);
|
||||
}
|
||||
}
|
||||
|
||||
rx.shutdown();
|
||||
let _ = handle.join();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 血量目前没有数据源。接口已经就位,装甲板接上后换掉这一行即可。
|
||||
let armor: Arc<dyn ArmorSource> = Arc::new(NoArmorYet);
|
||||
|
||||
let decoded: DecodedSlots = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
// 接收线程。
|
||||
{
|
||||
let rx = Arc::clone(&rx);
|
||||
std::thread::Builder::new()
|
||||
.name("udp-reader".into())
|
||||
.spawn(move || {
|
||||
let Ok(socket) = bind_socket() else {
|
||||
log::error!("could not bind the socket for the receive loop");
|
||||
return;
|
||||
};
|
||||
rx.run(socket);
|
||||
})?;
|
||||
}
|
||||
|
||||
// 解码线程:一张 VGA 的 JPEG 要几毫秒,放在收包线程里会挤进数据报之间,造成
|
||||
// 链路上本不存在的丢帧。
|
||||
{
|
||||
let frames = Arc::clone(&frames);
|
||||
let decoded = Arc::clone(&decoded);
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("jpeg-decode".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
if frames.pending_senders().is_empty() {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
continue;
|
||||
}
|
||||
|
||||
for sender_id in frames.pending_senders() {
|
||||
let Some(frame) = frames.take(sender_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match decode_frame(&frame.data) {
|
||||
Some(pixels) => {
|
||||
let mut slot = decoded.lock().expect("decoded mutex poisoned");
|
||||
|
||||
// 同一路只保留最新一张,避免解码已经过时的画面。
|
||||
slot.retain(|(id, _)| *id != sender_id);
|
||||
slot.push((sender_id, pixels));
|
||||
}
|
||||
None => log::warn!(
|
||||
"frame {} from 0x{:08X} is not a decodable JPEG ({} bytes)",
|
||||
frame.frame_id,
|
||||
frame.sender_id,
|
||||
frame.data.len()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
|
||||
// 每个身份一个闭包,而不是一个闭包里再 match 身份。
|
||||
//
|
||||
// 两种身份是两个不同的组件类型(TeamApp / RefereeApp),没有共同的超类可以
|
||||
// 在一个闭包里统一调用 set_frame/set_frame_a。写成两个分支后每边都拿到具体
|
||||
// 类型,属性赋值直接调用,不再需要 match 身份——那种 match 会让编译器在每个
|
||||
// 赋值点都检查一遍身份,而它本来就是编译期就定死的。
|
||||
match config.role {
|
||||
Role::Team => {
|
||||
let ui = TeamApp::new()?;
|
||||
ui.show()?;
|
||||
|
||||
let weak = ui.as_weak();
|
||||
let discovery = Arc::clone(&discovery);
|
||||
let decoded = Arc::clone(&decoded);
|
||||
let armor = Arc::clone(&armor);
|
||||
let config_path = config_path.clone();
|
||||
let team = config.team;
|
||||
let bound = config.camera_id;
|
||||
|
||||
let mut view = View::new();
|
||||
|
||||
// 绑定回调与刷新定时器都要读这份列表,所以用锁共享,而不是让其中一个
|
||||
// 把 `choices` 移走。
|
||||
let choices: Arc<Mutex<Vec<CameraChoice>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
// 绑定完成时写配置。"选定不可更改"就落在这里:此后配置里已有
|
||||
// camera_id,界面不再出现选择页。
|
||||
let choices_for_pick = Arc::clone(&choices);
|
||||
|
||||
let pick_handle = ui.on_pick(move |index| {
|
||||
let Ok(index) = usize::try_from(index) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let picked = choices_for_pick
|
||||
.lock()
|
||||
.expect("choices mutex poisoned")
|
||||
.get(index)
|
||||
.map(|c| c.id_text.to_string());
|
||||
|
||||
let Some(id_text) = picked else {
|
||||
return;
|
||||
};
|
||||
let Some(id) = parse_id(&id_text).ok() else {
|
||||
log::error!("could not read back the selected id {id_text:?}");
|
||||
return;
|
||||
};
|
||||
|
||||
let saved = Config {
|
||||
role: Role::Team,
|
||||
team,
|
||||
camera_id: Some(id),
|
||||
camera_addr: None,
|
||||
};
|
||||
|
||||
if let Err(err) = saved.save(&config_path) {
|
||||
log::error!("could not save the binding: {err}");
|
||||
log::error!("画面仍会显示,但下次启动要重新选择");
|
||||
} else {
|
||||
log::info!("bound to camera 0x{id:08X}; saved in the config");
|
||||
}
|
||||
});
|
||||
|
||||
let choices_for_poll = Arc::clone(&choices);
|
||||
|
||||
timer.start(slint::TimerMode::Repeated, UI_POLL, move || {
|
||||
let Some(ui) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let cameras = discovery.online_cameras();
|
||||
|
||||
// 未绑定:只显示选择界面。
|
||||
let Some(bound) = bound else {
|
||||
let updated: Vec<CameraChoice> = cameras
|
||||
.iter()
|
||||
.map(|c| CameraChoice {
|
||||
id_text: format!("0x{:08X}", c.id).into(),
|
||||
addr_text: format!("{} ({:?})", c.addr, c.silence()).into(),
|
||||
online: c.is_online(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
*choices_for_poll.lock().expect("choices mutex poisoned") = updated.clone();
|
||||
|
||||
ui.set_choices(std::rc::Rc::new(slint::VecModel::from(updated)).into());
|
||||
ui.set_choosing(true);
|
||||
return;
|
||||
};
|
||||
|
||||
ui.set_choosing(false);
|
||||
|
||||
if view.sender_id != Some(bound) {
|
||||
view = View::new();
|
||||
view.sender_id = Some(bound);
|
||||
}
|
||||
|
||||
if let Some(image) = take_decoded(&decoded, bound) {
|
||||
ui.set_frame(image);
|
||||
}
|
||||
|
||||
let stats = discovery.stats(bound);
|
||||
view.update_fps(&stats);
|
||||
|
||||
if !view.reported && stats.frames_shown > 0 {
|
||||
log_diagnostics(&stats, bound);
|
||||
view.reported = true;
|
||||
}
|
||||
|
||||
ui.set_stats(render_camera_stats(&cameras, bound, &stats, view.fps));
|
||||
ui.set_health(render_health(team, armor.as_ref()));
|
||||
});
|
||||
|
||||
// 绑定回调必须活到窗口关闭。CallbackHandle 是 Copy,但 Drop 会注销
|
||||
// 回调,所以必须真的把它泄漏掉——之前用 `mem::forget` 编译器提示它对
|
||||
// Copy 类型无效,那确实无效:第一次点击后回调就被注销了,第二次点击
|
||||
// 什么也不会发生。
|
||||
std::boxed::Box::leak(Box::new(pick_handle));
|
||||
}
|
||||
|
||||
Role::Referee => {
|
||||
let ui = RefereeApp::new()?;
|
||||
ui.show()?;
|
||||
|
||||
let weak = ui.as_weak();
|
||||
let discovery = Arc::clone(&discovery);
|
||||
let decoded = Arc::clone(&decoded);
|
||||
let armor = Arc::clone(&armor);
|
||||
|
||||
let mut views: Vec<View> = (0..2).map(|_| View::new()).collect();
|
||||
|
||||
timer.start(slint::TimerMode::Repeated, UI_POLL, move || {
|
||||
let Some(ui) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let cameras = discovery.online_cameras();
|
||||
|
||||
// 两路按 id 顺序填槽。槽位保持稳定(不会因为某块板子临时离线就重新
|
||||
// 洗牌),否则画面会左右跳动。
|
||||
let ids: Vec<u32> = cameras.iter().map(|c| c.id).take(2).collect();
|
||||
|
||||
for (i, view) in views.iter_mut().enumerate() {
|
||||
let wanted = ids.get(i).copied();
|
||||
if view.sender_id != wanted {
|
||||
*view = View::new();
|
||||
view.sender_id = wanted;
|
||||
}
|
||||
}
|
||||
|
||||
for (i, view) in views.iter_mut().enumerate() {
|
||||
let Some(sender_id) = view.sender_id else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(image) = take_decoded(&decoded, sender_id) {
|
||||
if i == 0 {
|
||||
ui.set_frame_a(image);
|
||||
} else {
|
||||
ui.set_frame_b(image);
|
||||
}
|
||||
}
|
||||
|
||||
let stats = discovery.stats(sender_id);
|
||||
view.update_fps(&stats);
|
||||
|
||||
if !view.reported && stats.frames_shown > 0 {
|
||||
log_diagnostics(&stats, sender_id);
|
||||
view.reported = true;
|
||||
}
|
||||
|
||||
let view_stats = render_camera_stats(&cameras, sender_id, &stats, view.fps);
|
||||
|
||||
if i == 0 {
|
||||
ui.set_stats_a(view_stats);
|
||||
} else {
|
||||
ui.set_stats_b(view_stats);
|
||||
}
|
||||
}
|
||||
|
||||
ui.set_health_a(render_health(0, armor.as_ref()));
|
||||
ui.set_health_b(render_health(1, armor.as_ref()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Timer 一旦被 drop 就停止,所以要活到进程结束。
|
||||
let _timer = Box::leak(Box::new(timer));
|
||||
|
||||
slint::run_event_loop()?;
|
||||
|
||||
rx.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从解码缓存里取出某一路的最新画面。
|
||||
///
|
||||
/// 解码线程只处理纯数据,`slint::Image` 的构造留在这里——`slint::Image` 不是
|
||||
/// `Send`,跨不了线程。
|
||||
fn take_decoded(
|
||||
decoded: &DecodedSlots,
|
||||
sender_id: u32,
|
||||
) -> Option<slint::Image> {
|
||||
let mut slot = decoded.lock().expect("decoded mutex poisoned");
|
||||
|
||||
let pos = slot.iter().position(|(id, _)| *id == sender_id)?;
|
||||
let (_, pixels) = slot.remove(pos);
|
||||
|
||||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
|
||||
&pixels.rgba,
|
||||
pixels.width,
|
||||
pixels.height,
|
||||
);
|
||||
|
||||
Some(slint::Image::from_rgba8(buffer))
|
||||
}
|
||||
|
||||
/// 命令行参数。
|
||||
struct Args {
|
||||
role: Option<Role>,
|
||||
team: Option<u8>,
|
||||
camera_id: Option<u32>,
|
||||
camera: Ipv4Addr,
|
||||
config: Option<PathBuf>,
|
||||
list: bool,
|
||||
help: bool,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse(argv: impl Iterator<Item = String>) -> Result<Self, Box<dyn Error>> {
|
||||
let mut args = Args {
|
||||
role: None,
|
||||
team: None,
|
||||
camera_id: None,
|
||||
camera: protocol::BROADCAST_ADDR,
|
||||
config: None,
|
||||
list: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
let mut it = argv.peekable();
|
||||
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"-h" | "--help" => args.help = true,
|
||||
"--list" => args.list = true,
|
||||
"--role" => {
|
||||
let v = it.next().ok_or("--role needs a value")?;
|
||||
args.role =
|
||||
Some(Role::parse(&v).ok_or("role must be `team` or `referee`")?);
|
||||
}
|
||||
"--team" => {
|
||||
let v = it.next().ok_or("--team needs a value")?;
|
||||
args.team = Some(v.parse().map_err(|_| "--team must be 0 or 1")?);
|
||||
}
|
||||
"--camera-id" => {
|
||||
let v = it.next().ok_or("--camera-id needs a value")?;
|
||||
args.camera_id = Some(parse_id(&v)?);
|
||||
}
|
||||
"--camera" => {
|
||||
let v = it.next().ok_or("--camera needs a value")?;
|
||||
args.camera = v
|
||||
.parse()
|
||||
.map_err(|_| "--camera must be an IPv4 address")?;
|
||||
}
|
||||
"--config" => {
|
||||
args.config = Some(PathBuf::from(it.next().ok_or("--config needs a path")?));
|
||||
}
|
||||
other => {
|
||||
return Err(format!("unknown argument {other:?}\n\n{USAGE}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定 UDP 接收 socket。
|
||||
///
|
||||
/// 固定端口(而非临时端口)是必须的:板子从订阅的**源端口**学到推流目标,而这个
|
||||
/// socket 同时承担收包。
|
||||
fn bind_socket() -> std::io::Result<std::net::UdpSocket> {
|
||||
let socket = std::net::UdpSocket::bind(std::net::SocketAddrV4::new(
|
||||
Ipv4Addr::UNSPECIFIED,
|
||||
protocol::DEFAULT_PORT,
|
||||
))?;
|
||||
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
|
||||
|
||||
// 广播是发现的前提:板子靠它宣告存在,客户端靠它发起订阅。
|
||||
let _ = socket.set_broadcast(true);
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
/// 解析 `0x1A2B3C4D` 或十进制形式的板子 id。
|
||||
fn parse_id(text: &str) -> Result<u32, Box<dyn Error>> {
|
||||
let text = text.trim();
|
||||
|
||||
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
|
||||
Ok(u32::from_str_radix(hex, 16)?)
|
||||
} else {
|
||||
Ok(text.parse()?)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次性诊断,在某一路第一次出画面时记录。
|
||||
///
|
||||
/// 不再单列"无法解码"计数:那是链路正常但内容坏了的情形,会在解码线程里逐帧告警,
|
||||
/// 汇总在这里反而会把它和普通的丢帧混在一起。
|
||||
fn log_diagnostics(stats: &SourceStats, sender_id: u32) {
|
||||
log::info!(
|
||||
"camera 0x{sender_id:08X}: {} frames shown | {} dropped ({:.1}%) | \
|
||||
{} datagrams | {}x{} {} bytes",
|
||||
stats.frames_shown,
|
||||
stats.frames_dropped,
|
||||
stats.loss_percent(),
|
||||
stats.datagrams,
|
||||
stats.width,
|
||||
stats.height,
|
||||
stats.last_frame_len,
|
||||
);
|
||||
}
|
||||
|
||||
/// 把某一路的统计渲染成界面结构。
|
||||
fn render_camera_stats(
|
||||
cameras: &[CameraInfo],
|
||||
sender_id: u32,
|
||||
stats: &SourceStats,
|
||||
fps: f32,
|
||||
) -> CameraStats {
|
||||
let online = cameras
|
||||
.iter()
|
||||
.find(|c| c.id == sender_id)
|
||||
.map(CameraInfo::is_online)
|
||||
.unwrap_or(false);
|
||||
|
||||
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
|
||||
// 比让人以为板子挂了诚实。
|
||||
let status = if stats.frames_shown == 0 {
|
||||
if online {
|
||||
"已连接,等待画面…".to_string()
|
||||
} else {
|
||||
"离线".to_string()
|
||||
}
|
||||
} else {
|
||||
format!("0x{:08X}", sender_id)
|
||||
};
|
||||
|
||||
CameraStats {
|
||||
online,
|
||||
status_text: status.into(),
|
||||
fps: format!("{fps:.1}").into(),
|
||||
resolution: format!("{}x{}", stats.width, stats.height).into(),
|
||||
frame_bytes: format!("{:.1} kB", stats.last_frame_len as f32 / 1024.0).into(),
|
||||
frames_shown: stats.frames_shown.to_string().into(),
|
||||
frames_dropped: stats.frames_dropped.to_string().into(),
|
||||
loss_percent: format!("{:.1}%", stats.loss_percent()).into(),
|
||||
datagrams: stats.datagrams.to_string().into(),
|
||||
gap_ms: stats
|
||||
.last_interval
|
||||
.map(|d| format!("{:.0} ms", d.as_secs_f32() * 1000.0))
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 渲染一边的血量。
|
||||
///
|
||||
/// 现在永远走 `available == false` 的分支,因为还没有数据源。装甲板接上后
|
||||
/// [`ArmorSource::health`] 返回 `Some`,界面自动显示真实数值,这里不用改。
|
||||
///
|
||||
/// 没有数据时用占位符而不是 0:0 看起来像真值,现场看到会以为对方已经没血了。
|
||||
fn render_health(team: u8, armor: &dyn ArmorSource) -> TeamHealth {
|
||||
let name = match team {
|
||||
0 => "队伍一",
|
||||
_ => "队伍二",
|
||||
};
|
||||
|
||||
match armor.health(team) {
|
||||
Some(hps) => TeamHealth {
|
||||
team_name: format!("{name} · 装甲板").into(),
|
||||
available: true,
|
||||
placeholder: String::new().into(),
|
||||
hps: std::rc::Rc::new(slint::VecModel::from(
|
||||
hps.iter().map(|hp| *hp as i32).collect::<Vec<i32>>(),
|
||||
))
|
||||
.into(),
|
||||
},
|
||||
None => TeamHealth {
|
||||
team_name: name.into(),
|
||||
available: false,
|
||||
placeholder:
|
||||
"装甲板未接入:血量接口已预留(protocol::ArmorHealth),当前无数据源".into(),
|
||||
// 依然给出 4 个格子:界面用 `available` 决定颜色,所以空数组会让占位
|
||||
// 框整个消失,看起来像布局出错,而不是像"还没有数据"。
|
||||
hps: std::rc::Rc::new(slint::VecModel::from(vec![0i32; ARMOR_BOARDS])).into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user