Files
ESP32S3_vt/scripts/udp_probe.py
T
Horldsence 1c65eaabc6 protocol: 修复重复分片能凑出一张坏帧
`seen >= expected` 判断分片是否收齐是错的:UDP 会重复投递,一个丢失的
分片可以被另一个包的副本凑数,于是计数达标而缓冲区里留着空洞,交出去
一张尾部是 0 的 JPEG。

这个 bug 很隐蔽——帧率完全正常,没有任何报错线索,只是偶尔几张图解不
出来。实测 10 秒内出现 34 张这样的坏帧。

改为按分片下标记录是否收到,重复的直接跳过且不计数;再显式确认最后一个
分片确实到过。实测坏帧降到 1(板子启动瞬间的残帧),完整帧率不变。

补两个测试覆盖它——原有测试没有一个会重复发分片,所以这个 bug 一直没被
发现:
  - a_duplicate_cannot_complete_a_frame_with_a_hole
  - a_gap_that_is_never_filled_never_yields_a_frame

同时更新 scripts/udp_probe.py:它有同一个 bug,所以测不出修复效果。它还
把 Subscribe 的 role 字节多填了 12 个零字节,固件按偏移严格读取因而拒收,
表现为"板子在线但 0 clients subscribed"。脚本的意义正是独立核对线路格式,
所以这些偏移是手写的,改协议时必须同步。

另删 scripts/pick-flash-pad.sh:链接期 padding 已被证明移不动
.flash.text 内部,flash.sh 现在会自动挑选避开坏字节的 app 偏移。
2026-10-05 18:54:35 +08:00

193 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""独立验证相机 UDP 图传线路的诊断脚本。
不依赖 Rust,也不依赖 protocol crate——正因为如此,它能当作线路格式的
**独立**检查:如果 protocol crate 的编码有错,这个脚本(手写的常量)仍然
按文档解析,会立刻报出不一致。
用法:
python3 scripts/udp_probe.py [秒数]
它做三件事:
1. 广播一次 Subscribe,触发板子开始推流
2. 解析收到的数据报,按 sender_id 重组
3. 报告帧率、丢帧、以及是否发现了板子的 Hello
常量是手写的,与 protocol/src/lib.rs 保持一致。改了协议记得改这里——
这正是它作为交叉检查的价值所在。
"""
import socket
import sys
import time
from collections import defaultdict
# ---- 与 protocol/src/lib.rs 一致 ----
HEADER = 27 # magic4 + type1 + ver2 + sender4 + frame4 + idx2 + cnt2 + len4 + w2 + h2
MAGIC = 0x4D564331 # "MVC1"
FRAG = 1400 # MAX_FRAGMENT_PAYLOAD
PORT = 5600 # DEFAULT_PORT
BROADCAST = "255.255.255.255"
MSG_VIDEO = 1
MSG_SUBSCRIBE = 2
MSG_HELLO = 3
MSG_HEARTBEAT = 4
TYPE_NAMES = {
MSG_VIDEO: "video",
MSG_SUBSCRIBE: "subscribe",
MSG_HELLO: "hello",
MSG_HEARTBEAT: "heartbeat",
}
def decode(data):
"""把一个数据报解析成 (msg_type, header_dict, payload)。"""
if len(data) < HEADER:
return None
magic = int.from_bytes(data[0:4], "little")
if magic != MAGIC:
return None
msg_type = data[4]
header = {
"version": int.from_bytes(data[5:7], "little"),
"sender_id": int.from_bytes(data[7:11], "little"),
"frame_id": int.from_bytes(data[11:15], "little"),
"frag_index": int.from_bytes(data[15:17], "little"),
"frag_count": int.from_bytes(data[17:19], "little"),
"total_len": int.from_bytes(data[19:23], "little"),
"width": int.from_bytes(data[23:25], "little"),
"height": int.from_bytes(data[25:27], "little"),
}
return msg_type, header, data[HEADER:]
def main():
duration = float(sys.argv[1]) if len(sys.argv) > 1 else 10.0
host = sys.argv[2] if len(sys.argv) > 2 else BROADCAST
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.bind(("", PORT))
sock.settimeout(0.5)
# 订阅:板子看到它就开始往本机推流。
#
# role 字节紧跟在 HEADER 之后,也就是偏移 HEADER 处。之前这里多填了 12 个
# 零字节,role 落到了偏移 39——板子的 Subscribe::decode 严格按偏移读,于是
# 认不出 role 而拒收,表现为"板子在线但 0 clients subscribed"。
# 这里刻意手写而不是复用 protocol crate:这个脚本的意义就在于独立验证,
# role 的位置写错时它会给出和固件一致的结论,而不是把错误藏起来。
header = (
MAGIC.to_bytes(4, "little")
+ bytes([MSG_SUBSCRIBE])
+ (1).to_bytes(2, "little") # version
+ (0x0C110001).to_bytes(4, "little") # client_id
+ b"\x00" * 4 # frame_id
+ b"\x00" * 4 # frag_index + frag_count
+ b"\x00" * 4 # total_len
+ b"\x00" * 4 # width + height
)
sub = header + bytes([2]) # role = referee,紧跟 27 字节头
sock.sendto(sub, (host, PORT))
print(f"已向 {host}:{PORT} 广播订阅(role=referee),监听 {duration:.0f} 秒…")
# 每个发送方一份重组状态:两块板的 frame_id 各自独立,混在一起会互相顶掉。
pending = {}
boards = {}
counters = defaultdict(int)
sizes = []
start = time.time()
last_print = start
while time.time() - start < duration:
try:
data, addr = sock.recvfrom(2048)
except socket.timeout:
continue
parsed = decode(data)
if parsed is None:
counters["无法解析"] += 1
continue
msg_type, h, payload = parsed
counters[f"type:{TYPE_NAMES.get(msg_type, msg_type)}"] += 1
if msg_type in (MSG_HELLO, MSG_HEARTBEAT):
boards[h["sender_id"]] = addr
continue
if msg_type != MSG_VIDEO:
continue
sid = h["sender_id"]
# 与 Fragment::decode 相同的自洽性检查。
if h["frag_count"] == 0 or h["frag_index"] >= h["frag_count"]:
counters["分片索引非法"] += 1
continue
if len(payload) > h["total_len"]:
counters["载荷超过总长"] += 1
continue
state = pending.setdefault(
sid,
{"buf": bytearray(h["total_len"]), "seen": 0, "expect": h["frag_count"],
"have": set(), "total": h["total_len"], "w": h["width"], "h": h["height"],
"started": time.time()},
)
# 重复分片直接跳过且不计数:否则一个丢失的分片会被副本凑数,
# seen 达标而缓冲区留空洞,交出去一张尾部是 0 的坏图。
if h["frag_index"] in state["have"]:
counters["重复分片"] += 1
continue
state["have"].add(h["frag_index"])
off = h["frag_index"] * FRAG
state["buf"][off:off + len(payload)] = payload
state["seen"] += 1
tail = (h["total_len"] - 1) // FRAG
if state["seen"] >= state["expect"] and tail in state["have"]:
jpeg = bytes(state["buf"][:state["total"]])
if jpeg[:2] == b"\xff\xd8" and jpeg[-2:] == b"\xff\xd9":
counters["完整帧"] += 1
sizes.append((state["total"], state["w"], state["h"]))
else:
counters["JPEG 不完整"] += 1
del pending[sid]
now = time.time()
if now - last_print >= 2.0:
elapsed = now - last_print
good = counters["完整帧"]
print(
f" {good / elapsed:5.1f} fps "
f"视频包={counters['type:video']:5d} "
f"完整帧={good:4d} 丢弃={counters['JPEG 不完整']:3d}"
)
last_print = now
sock.close()
total_time = time.time() - start
print()
print("板子:", ", ".join(f"0x{k:08X} @ {v[0]}" for k, v in boards.items()) or "(未发现)")
print("完整帧:", counters["完整帧"], " 累计视频包:", counters["type:video"])
if counters["完整帧"]:
avg = sum(s[0] for s in sizes) / len(sizes)
print(f"平均帧: {avg:.0f} 字节 {sizes[0][1]}x{sizes[0][2]}")
print(f"吞吐: {sum(s[0] for s in sizes) / total_time / 1024:.1f} kB/s")
bad = {k: v for k, v in counters.items() if k.startswith(("无法", "JPEG", "分片", "载荷"))}
print("异常:", bad or "无")
if __name__ == "__main__":
main()