From a16ad8595edb49947a4cf83095e84552ae7e9962 Mon Sep 17 00:00:00 2001 From: Horldsence Date: Mon, 5 Oct 2026 21:27:36 +0800 Subject: [PATCH] =?UTF-8?q?client:=20=E9=98=9F=E4=BC=8D=E7=AB=AF=20release?= =?UTF-8?q?=20=E5=8F=AA=E6=8E=A5=E6=94=B6=E7=BB=91=E5=AE=9A=E7=9A=84?= =?UTF-8?q?=E9=82=A3=E4=B8=80=E5=9D=97=E6=9D=BF=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对抗赛里两队都用这套系统看自己小车的图传。需求是防止对手开第二个窗口连到另一 队的板子。 **关键是这必须做在接收端,而不是界面里。** 我先按"只在 release 下不显示 id 和 IP"理解,但那样等于没做:广播的 Hello 里就有 MAC 派生的 id 和 IP,对手 tcpdump 一下全都知道,而数据本来也已经收下并解码了。隐藏一行文字不阻止任何事。 所以改为 `Receiver::restrict_to`:队伍端 release 且已绑定时,只重组、只解码、 只统计绑定的那一块板子。对手的板子即使把流推到了本机端口(IP 已泄露、端口是 公开常量),数据也不会进入任何管线。 四种情况不限制:裁判端(中立第三方,本来就要两块)、debug 构建(开发时需要 看到全场)、队伍端未绑定(选择界面需要列出候选)、命令行临时覆盖 camera_id。 `--list` 在 release 队伍端直接报错:它会打印场上所有板子的 id 和 IP,正是要防 的那件事。与其列出来再叮嘱"别看",不如不给。 `TEAM_DIAGNOSTICS = cfg!(debug_assertions)` 门控界面上的 fps / 丢帧 / 数据报数 等遥测和 stdout 的诊断日志。裁判端不受影响——它是中立方,双方遥测对判罚有用。 选择界面保留:现场换机器时还要用它重新绑定。数据侧的闸门已经足够。 顺带修一个设计上就错的判断:`is_online()` 看的是 `last_seen`,而它此前只由 Hello 更新,于是出现"画面每秒刷 22 帧、状态栏却显示离线(橙色)"的矛盾状态。正在推 流本身就是最可靠的存活证据,所以收到视频帧也刷新时间戳。 --- client/src/main.rs | 119 +++++++++++++++++++++++++++++++++++++---- client/src/receiver.rs | 74 ++++++++++++++++++++++++- 2 files changed, 181 insertions(+), 12 deletions(-) diff --git a/client/src/main.rs b/client/src/main.rs index aeec3fb..9ca09aa 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -56,6 +56,15 @@ const USAGE: &str = "\ /// 不是每帧一次:显示不需要每秒重绘 22 次,更快只是让事件循环和接收路径抢 CPU。 const UI_POLL: Duration = Duration::from_millis(33); +/// 队伍端在 release 下是否显示技术诊断信息。 +/// +/// 对抗赛里两队都用这套系统看自己小车的图传,所以 release 给现场用、debug 给开发 +/// 用。这个开关由**编译模式**决定而不是命令行参数——命令行开关意味着二进制里带着 +/// 那套东西,忘开一次就泄露了。 +/// +/// 裁判端不受它影响:裁判是中立方,双方遥测对判罚有用。 +const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions); + /// 帧率统计窗口。 /// /// 按计数除以墙钟时间,而不是对帧间隔取平均。一次轮询之间可能完成多帧,所以平均间隔 @@ -136,14 +145,41 @@ fn main() -> Result<(), Box> { 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); @@ -236,8 +272,17 @@ fn main() -> Result<(), Box> { 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); @@ -288,6 +333,13 @@ fn main() -> Result<(), Box> { } 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); @@ -331,12 +383,20 @@ fn main() -> Result<(), Box> { let stats = discovery.stats(bound); view.update_fps(&stats); - if !view.reported && stats.frames_shown > 0 { + // 遥测日志同样按编译模式门控: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)); + ui.set_stats(render_camera_stats( + &cameras, + bound, + &stats, + view.fps, + TEAM_DIAGNOSTICS, + )); ui.set_health(render_health(team, armor.as_ref())); }); @@ -349,6 +409,7 @@ fn main() -> Result<(), Box> { Role::Referee => { let ui = RefereeApp::new()?; + ui.window().set_maximized(true); ui.show()?; let weak = ui.as_weak(); @@ -398,7 +459,9 @@ fn main() -> Result<(), Box> { view.reported = true; } - let view_stats = render_camera_stats(&cameras, sender_id, &stats, view.fps); + // 裁判端始终显示诊断信息:它是中立方,双方遥测对判罚有用。 + let view_stats = + render_camera_stats(&cameras, sender_id, &stats, view.fps, true); if i == 0 { ui.set_stats_a(view_stats); @@ -551,11 +614,15 @@ fn log_diagnostics(stats: &SourceStats, sender_id: u32) { } /// 把某一路的统计渲染成界面结构。 +/// +/// `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() @@ -565,6 +632,10 @@ fn render_camera_stats( // 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面" // 比让人以为板子挂了诚实。 + // + // 有画面时显示 id —— 这对调试有用。裁判端和队伍端 debug 都显示;队伍端 release + // 下这一路只有自己绑定的板子,显示它不构成泄露(界面上根本没有别人的板子可选, + // 见 Receiver::restrict_to)。 let status = if stats.frames_shown == 0 { if online { "已连接,等待画面…".to_string() @@ -578,17 +649,43 @@ fn render_camera_stats( 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(), + // 同理:没出过帧时 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_else(|| "-".to_string()) + .unwrap_or_default() .into(), } } diff --git a/client/src/receiver.rs b/client/src/receiver.rs index 5c2b920..9d4cd6d 100644 --- a/client/src/receiver.rs +++ b/client/src/receiver.rs @@ -185,6 +185,17 @@ impl Discovery { .unwrap_or_default() } + /// 只刷新存活时间戳,不改地址或固件版本。 + /// + /// 收到视频帧时调用:正在推流本身就是最可靠的存活证据。 + fn touch(&self, id: u32) { + let mut cameras = self.cameras.lock().expect("cameras mutex poisoned"); + + if let Some(info) = cameras.get_mut(&id) { + info.last_seen = Instant::now(); + } + } + fn note_hello(&self, id: u32, addr: SocketAddrV4, firmware: u16) { let mut cameras = self.cameras.lock().expect("cameras mutex poisoned"); @@ -239,6 +250,18 @@ pub struct Receiver { frames: Arc, role: SubscribeRole, camera: Ipv4Addr, + /// 只处理这一块板子的数据,`None` 表示全都要(裁判端)。 + /// + /// 队伍端在 release 下设成 `Some(bound)`,这是一道**接收端**的闸门而不只是界面 + /// 的:对手的板子即使把流推到了本机端口,也不会被重组、不会进解码队列、不会 + /// 出现在任何统计里。 + /// + /// 为什么必须在这一层而不是靠界面隐藏:`.slint` 只控制显示,而数据已经收下并 + /// 解码了。隐藏一行文字不阻止任何事。 + /// + /// 用 `Mutex` 而不是 `Cell`/`AtomicU32`:接收线程要读它,而绑定回调在主线程上 + /// 写它。订阅循环本身已经是串行的,所以锁的持有时间极短。 + only_sender: Mutex>, stop: Arc, } @@ -247,7 +270,13 @@ impl Receiver { /// /// 绑定固定端口(而非临时端口)是必须的:板子是从订阅的**源端口**学到推流目标的, /// 临时端口也能工作,但固定端口让三台客户端行为一致,也方便抓包排查。 - pub fn start(camera: Ipv4Addr, role: SubscribeRole) -> std::io::Result { + /// + /// `only_sender` 为 `Some` 时只接受那一块板子的数据,见 [`Receiver`] 的字段说明。 + pub fn start( + camera: Ipv4Addr, + role: SubscribeRole, + only_sender: Option, + ) -> std::io::Result { let socket = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?; socket.set_read_timeout(Some(Duration::from_millis(100)))?; @@ -262,10 +291,24 @@ impl Receiver { frames: Arc::new(LatestFrames::default()), role, camera, + only_sender: Mutex::new(only_sender), stop: Arc::new(AtomicBool::new(false)), }) } + /// 绑定之后切换限制目标,或解除限制。 + /// + /// 队伍端在选择界面里点定一块板子时调用:此前要能看到所有候选(否则无从选 + /// 起),此后只看这一块。 + pub fn restrict_to(&self, only_sender: Option) { + *self.only_sender.lock().expect("only_sender mutex poisoned") = only_sender; + } + + /// 当前限制到哪一块板子。 + fn sender_limit(&self) -> Option { + *self.only_sender.lock().expect("only_sender mutex poisoned") + } + pub fn discovery(&self) -> Arc { Arc::clone(&self.discovery) } @@ -348,9 +391,22 @@ impl Receiver { return; } + let limit = self.sender_limit(); + // 板子的在线通告。注意这里不看来源端口:广播来的源端口没有意义,身份由 // 头里的 sender_id 决定。 if let Some(hello) = protocol::Hello::decode(bytes) { + // 限制生效时不记录其他板子。理由不只是"不显示":如果把它们记进 + // discovery,`--list` 式的输出、槽位排序、界面上任何一处遍历 cameras() + // 的地方都会间接泄露场上还有谁。 + if let (Some(only), SocketAddr::V4(v4)) = (limit, from) { + if hello.sender_id != only { + return; + } + self.discovery.note_hello(hello.sender_id, v4, hello.firmware); + return; + } + if let SocketAddr::V4(v4) = from { let is_new = !self.discovery.cameras().iter().any(|c| c.id == hello.sender_id); self.discovery.note_hello(hello.sender_id, v4, hello.firmware); @@ -372,6 +428,22 @@ impl Receiver { let id = frag.header.sender_id; + // 视频同样要过闸门。对手的板子一旦把流推到这个端口(IP 已被广播泄露, + // 端口是公开常量),它的数据不会进任何重组器。 + if let Some(only) = limit { + if id != only { + return; + } + } + + // 视频帧也算存活证据。 + // + // `is_online` 看的是 `last_seen`,而它此前只由 Hello 更新。于是出现一个 + // 荒唐的状态:画面在每秒刷 22 帧,状态栏却显示"离线"(橙色)。真板子每 2 + // 秒广播一次所以不易撞上,但它本身就是设计错误——正在推流的板子比广播更 + // 可靠地证明自己活着。 + self.discovery.touch(id); + match assemblers .entry(id) .or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT))