裁判端两个槽位的语义应该是"队伍一 / 队伍二",而不是"第 N 块发现的板子"。之前 两者混为一谈,出现两个都会被现场误读成故障的现象: 1. **两块槽位显示同一块板。** 每轮都从 `online_cameras()` 取前两个 id 填满两 个槽位,于是只有一块板在场时两边一模一样。裁判会以为两队的图传串了。 2. **板子掉线时画面左右互换。** 低 id 的板子一离线,候选列表缩短,后面那块就 跳到前面的槽位——同样像串线,而实际上只是排序。 规则改成:每轮最多消费一个候选 id;已填且仍在线的槽位不动;掉线的槽位保留它的 id 并显示"离线",等它回来时还在原位。 空槽的提示文案也改准:左槽"等待队伍一的图传"、右槽"等待队伍二的图传",而不是笼 统的"未收到画面"。 顺带修两个假数字:`frames_shown == 0` 时 `stats.width`/`height` 还是默认值 0,界 面上显示成 `0x0`——一个看起来像分辨率的假分辨率;fps 显示 `0.0` 读起来像"板子 卡死了",而真相是"还没收到"。状态行已经写了"已连接,等待画面…",不该再补一个矛 盾的数字。 关于"别把刚掉线的板子又立刻选回同一个槽位"那条过滤:它只在槽位掉线时起作用, 且会跳过当前 id 去找下一个候选,槽位仍然有机会回来。实测唯一那块在推流的板子正 常占用一个槽位,另一槽保留着在线但不推流的真板子——符合预期。
758 lines
28 KiB
Rust
758 lines
28 KiB
Rust
//! 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);
|
|
|
|
/// 队伍端在 release 下是否显示技术诊断信息。
|
|
///
|
|
/// 对抗赛里两队都用这套系统看自己小车的图传,所以 release 给现场用、debug 给开发
|
|
/// 用。这个开关由**编译模式**决定而不是命令行参数——命令行开关意味着二进制里带着
|
|
/// 那套东西,忘开一次就泄露了。
|
|
///
|
|
/// 裁判端不受它影响:裁判是中立方,双方遥测对判罚有用。
|
|
const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
|
|
|
|
/// 帧率统计窗口。
|
|
///
|
|
/// 按计数除以墙钟时间,而不是对帧间隔取平均。一次轮询之间可能完成多帧,所以平均间隔
|
|
/// 量的是**显示**的刷新快慢而不是**相机**的出帧快慢——那会把上限 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()
|
|
);
|
|
|
|
// 队伍端在 release 下已经绑定时,把接收端限制到那一块。
|
|
//
|
|
// 这是本次改动里唯一有实际防护的一处:`.slint` 里不显示 id 只是藏起文字,而
|
|
// 广播的 Hello 早就把 MAC 派生的 id 和 IP 送出去了(对手 tcpdump 一下就有)。
|
|
// 在**接收端**拒绝才是真的:不重组、不解码、不进 discovery。
|
|
//
|
|
// 四种情况不限制:
|
|
// - 裁判端(中立第三方,本来就要两块板子)
|
|
// - debug 构建(开发时要能看到全场)
|
|
// - 队伍端还没绑定(选择界面需要列出候选)
|
|
// - 命令行临时用别的板子跑(--camera-id 覆盖配置)
|
|
let restrict = if config.role == Role::Team && !TEAM_DIAGNOSTICS {
|
|
config.camera_id.filter(|_| args.camera_id.is_none())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let rx = Arc::new(Receiver::start(
|
|
args.camera,
|
|
config.role.to_protocol(),
|
|
restrict,
|
|
)?);
|
|
let discovery = rx.discovery();
|
|
let frames = rx.frames();
|
|
|
|
if args.list {
|
|
// 队伍端 release 下 `--list` 会列出场上所有板子(包括对手的 id 和 IP),
|
|
// 正是要防的那件事。所以这里直接拒绝,而不是列出来再让人别看。
|
|
if config.role == Role::Team && !TEAM_DIAGNOSTICS {
|
|
return Err(
|
|
"--list 需要 debug 构建:它会列出场上所有板子的 id 和 IP。\
|
|
release 的队伍端请直接用已绑定的配置运行。"
|
|
.into(),
|
|
);
|
|
}
|
|
// 等几个广播周期,让列表有内容。
|
|
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()?;
|
|
|
|
// 启动即最大化。必须在 show() 之前:之后调用会被后端当成"用户点了最大化
|
|
// 按钮"。取 set_maximized 而不是 set_fullscreen,因为全屏会盖掉菜单栏和
|
|
// Dock,裁判终端要切去别的窗口核对数据。
|
|
ui.window().set_maximized(true);
|
|
ui.show()?;
|
|
|
|
// 接收端的限制在构造 Receiver 时已经按配置设好了(见上面 `restrict`),
|
|
// 这里不重复设:只有选择界面上新选定的那一次需要改它。
|
|
let rx_for_pick = Arc::clone(&rx);
|
|
|
|
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");
|
|
}
|
|
|
|
// 选定之后把接收端也限制到这一块。此后场上别人的板子即使把流推到
|
|
// 本机端口,也不会被重组或解码。
|
|
//
|
|
// 解除限制(传 None)要等到重启:配置里有了 camera_id 就会走绑定
|
|
// 分支,限制随之恢复,两者不会不一致。
|
|
rx_for_pick.restrict_to(Some(id));
|
|
});
|
|
|
|
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);
|
|
|
|
// 遥测日志同样按编译模式门控:release 的队伍端不往 stdout 打帧率/丢帧这些
|
|
// 能被对手读到的数字。
|
|
if TEAM_DIAGNOSTICS && !view.reported && stats.frames_shown > 0 {
|
|
log_diagnostics(&stats, bound);
|
|
view.reported = true;
|
|
}
|
|
|
|
ui.set_stats(render_camera_stats(
|
|
&cameras,
|
|
bound,
|
|
&stats,
|
|
view.fps,
|
|
TEAM_DIAGNOSTICS,
|
|
));
|
|
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.window().set_maximized(true);
|
|
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();
|
|
|
|
// 槽位对应**队伍**,不是"第 N 块发现的板子"。
|
|
//
|
|
// 两条规则:
|
|
//
|
|
// 1. 一次只填一个空槽,按 id 顺序。之前两块都空时每轮都从
|
|
// `online_cameras()` 取前两个,于是只有一块板在场时两块槽位显示
|
|
// 同一块画面——右半边不再是"队伍二",而是队伍一的复制。裁判看到
|
|
// 两边一模一样,会以为两队的图传串了。
|
|
//
|
|
// 2. 已填的槽位不换人。原先是 `ids.take(2)` 后按下标对号入座,低 id
|
|
// 的板子一离线,后面那块就跳到前面,画面左右互换,看起来同样
|
|
// 像串线。掉线的槽位保留它的 id 并显示"离线",等它回来。
|
|
//
|
|
// 所以每轮最多消费一个候选 id。
|
|
let mut next_candidate = cameras.iter().map(|c| c.id);
|
|
|
|
for view in views.iter_mut() {
|
|
let still_here = view
|
|
.sender_id
|
|
.is_some_and(|id| cameras.iter().any(|c| c.id == id));
|
|
|
|
if still_here {
|
|
continue;
|
|
}
|
|
|
|
// 槽位已占且板子还在场上 → 保留(上面已 continue)。到这里说明
|
|
// 槽位是空的,或板子掉线了。
|
|
let candidate = next_candidate
|
|
.next()
|
|
// 别把刚掉线的这块又立刻选回同一个槽位:它短暂离线又回来时,
|
|
// 画面会原地卡一下才恢复。
|
|
.filter(|id| Some(*id) != view.sender_id);
|
|
|
|
match candidate {
|
|
Some(id) => {
|
|
*view = View::new();
|
|
view.sender_id = Some(id);
|
|
}
|
|
// 没有可分配的了。掉线的槽位**保留 id**(显示"离线"而不是
|
|
// 变回空槽),这样它回来时还在原位。
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
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, true);
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
/// 把某一路的统计渲染成界面结构。
|
|
///
|
|
/// `diagnostics` 为 false 时不产出技术字段——它们留空字符串,界面也不显示。留空而
|
|
/// 不是填 0 或假值:0 会被读成"丢帧 0"这种看起来像真值的东西。
|
|
fn render_camera_stats(
|
|
cameras: &[CameraInfo],
|
|
sender_id: u32,
|
|
stats: &SourceStats,
|
|
fps: f32,
|
|
diagnostics: bool,
|
|
) -> CameraStats {
|
|
let online = cameras
|
|
.iter()
|
|
.find(|c| c.id == sender_id)
|
|
.map(CameraInfo::is_online)
|
|
.unwrap_or(false);
|
|
|
|
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
|
|
// 比让人以为板子挂了诚实。
|
|
//
|
|
// 有画面时显示 id —— 这对调试有用。裁判端和队伍端 debug 都显示;队伍端 release
|
|
// 下这一路只有自己绑定的板子,显示它不构成泄露(界面上根本没有别人的板子可选,
|
|
// 见 Receiver::restrict_to)。
|
|
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 是 0.0,读起来像"板子卡死了",而真相是"还没收到"。
|
|
// 状态行已经写了"已连接,等待画面…",这里不该再补一个矛盾的数字。
|
|
fps: (diagnostics && stats.frames_shown > 0)
|
|
.then(|| format!("{fps:.1}"))
|
|
.unwrap_or_default()
|
|
.into(),
|
|
// 没出过一个字节的源,`stats.width`/`height` 还是默认值 0,于是显示成
|
|
// "0x0"——一个看起来像分辨率的假分辨率。用 frames_shown 把它挡掉。
|
|
resolution: (diagnostics && stats.frames_shown > 0)
|
|
.then(|| format!("{}x{}", stats.width, stats.height))
|
|
.unwrap_or_default()
|
|
.into(),
|
|
frame_bytes: (diagnostics && stats.frames_shown > 0)
|
|
.then(|| format!("{:.1} kB", stats.last_frame_len as f32 / 1024.0))
|
|
.unwrap_or_default()
|
|
.into(),
|
|
frames_shown: diagnostics
|
|
.then(|| stats.frames_shown.to_string())
|
|
.unwrap_or_default()
|
|
.into(),
|
|
frames_dropped: diagnostics
|
|
.then(|| stats.frames_dropped.to_string())
|
|
.unwrap_or_default()
|
|
.into(),
|
|
loss_percent: diagnostics
|
|
.then(|| format!("{:.1}%", stats.loss_percent()))
|
|
.unwrap_or_default()
|
|
.into(),
|
|
datagrams: diagnostics
|
|
.then(|| stats.datagrams.to_string())
|
|
.unwrap_or_default()
|
|
.into(),
|
|
gap_ms: stats
|
|
.last_interval
|
|
.filter(|_| diagnostics)
|
|
.map(|d| format!("{:.0} ms", d.as_secs_f32() * 1000.0))
|
|
.unwrap_or_default()
|
|
.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(),
|
|
},
|
|
}
|
|
}
|