//! 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 身份。默认 referee(无需配置即可看两路) --team <0|1> 自己属于哪一边,仅 team 端用 --camera-id <0xID> 绑定到某块板子(MAC 派生,重启不变) --camera 向哪块板子发订阅(默认 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, 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` + 尺寸),`slint::Image` 的构造留在事件循环, /// 因为 `slint::Image` 不是 `Send`。 type DecodedSlots = Arc>>; fn main() -> Result<(), Box> { 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 = 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>> = 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 = 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 = (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 { 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::::clone_from_slice( &pixels.rgba, pixels.width, pixels.height, ); Some(slint::Image::from_rgba8(buffer)) } /// 命令行参数。 struct Args { role: Option, team: Option, camera_id: Option, camera: Ipv4Addr, config: Option, list: bool, help: bool, } impl Args { fn parse(argv: impl Iterator) -> Result> { 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 { 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> { 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::>(), )) .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(), }, } }