client: 队伍端 release 只接收绑定的那一块板子

对抗赛里两队都用这套系统看自己小车的图传。需求是防止对手开第二个窗口连到另一
队的板子。

**关键是这必须做在接收端,而不是界面里。** 我先按"只在 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 帧、状态栏却显示离线(橙色)"的矛盾状态。正在推
流本身就是最可靠的存活证据,所以收到视频帧也刷新时间戳。
This commit is contained in:
Horldsence
2026-10-05 21:27:36 +08:00
parent 7b6593bb75
commit a16ad8595e
2 changed files with 181 additions and 12 deletions
+108 -11
View File
@@ -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<dyn Error>> {
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<dyn Error>> {
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<dyn 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);
@@ -331,12 +383,20 @@ fn main() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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(),
}
}
+73 -1
View File
@@ -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<LatestFrames>,
role: SubscribeRole,
camera: Ipv4Addr,
/// 只处理这一块板子的数据,`None` 表示全都要(裁判端)。
///
/// 队伍端在 release 下设成 `Some(bound)`,这是一道**接收端**的闸门而不只是界面
/// 的:对手的板子即使把流推到了本机端口,也不会被重组、不会进解码队列、不会
/// 出现在任何统计里。
///
/// 为什么必须在这一层而不是靠界面隐藏:`.slint` 只控制显示,而数据已经收下并
/// 解码了。隐藏一行文字不阻止任何事。
///
/// 用 `Mutex` 而不是 `Cell`/`AtomicU32`:接收线程要读它,而绑定回调在主线程上
/// 写它。订阅循环本身已经是串行的,所以锁的持有时间极短。
only_sender: Mutex<Option<u32>>,
stop: Arc<AtomicBool>,
}
@@ -247,7 +270,13 @@ impl Receiver {
///
/// 绑定固定端口(而非临时端口)是必须的:板子是从订阅的**源端口**学到推流目标的,
/// 临时端口也能工作,但固定端口让三台客户端行为一致,也方便抓包排查。
pub fn start(camera: Ipv4Addr, role: SubscribeRole) -> std::io::Result<Self> {
///
/// `only_sender` 为 `Some` 时只接受那一块板子的数据,见 [`Receiver`] 的字段说明。
pub fn start(
camera: Ipv4Addr,
role: SubscribeRole,
only_sender: Option<u32>,
) -> std::io::Result<Self> {
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<u32>) {
*self.only_sender.lock().expect("only_sender mutex poisoned") = only_sender;
}
/// 当前限制到哪一块板子。
fn sender_limit(&self) -> Option<u32> {
*self.only_sender.lock().expect("only_sender mutex poisoned")
}
pub fn discovery(&self) -> Arc<Discovery> {
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))