Files
ESP32S3_vt/scripts/udp_probe.py
T
Horldsence c7f42a1979 scripts: 修 udp_probe.py 的重组与 fps 两处测量错误
探测脚本是诊断工具,测错比不测更糟——它给出的数字会被当成结论。

**重组状态必须按 (sender_id, frame_id) 存。** 原先只按 sender_id 存,于是丢片
之后下一帧的分片会接着凑上一帧的计数,凑够了交出去一张混着两帧的 JPEG,表现为
"JPEG 不完整"。修完:14 秒 132 帧,0 张坏帧。

换帧时被放弃的半成品单独计为"被新帧取代",不并入"JPEG 不完整"——它们本来就
不完整,混在一起会把两种情形都算成链路有问题。

**分窗 fps 的分子分母必须同量纲。** 原先用累计帧数除以窗口时长,于是报出 39、
61、79 这种随时间单调上升的假数字,看起来像板子越跑越快。改成窗口增量除以窗
口时长后稳定在 22,也就是传感器上限。

顺带修正两处与代码不符的注释:

  partitions.csv / sdkconfig.defaults
    两处都写死 app 分区在 0x40000,实际是 scripts/flash.sh 自动挑选并写回,
    当前为 0x060000。补上 flash.sh 会重编译并复验的原因(重编译会让镜像位移,
    所以偏移无法是常量)。

  .env.example
    补 CAM_VSYNC_INVERT 和 WIFI_AUTH。WIFI_AUTH 一直能用但没进文档:通常不用
    设,扫描会报告 AP 的真实认证方式;只有扫不到时(隐藏 SSID)才需要指定,
    指定后只试这一种,不成功直接报错而不是挨个试 10 秒——挨个试等于在猜一个
    本该已知的值。
2026-10-05 19:13:37 +08:00

226 lines
8.6 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
prev_frames = 0
prev_pkts = 0
prev_drops = 0
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
# 重组状态必须按 (sender_id, frame_id) 存,缺一不可。
#
# 之前这里只按 sender_id 存,于是某一帧丢了分片之后,下一帧的分片会接着往
# 同一个计数器里凑,凑够了就交出去一张混着两帧的坏 JPEG——报成 34 张
# "JPEG 不完整",看起来像重复分片或丢包的证据,其实是这个键选错了。
# protocol crate 的 FrameAssembler 一直按 frame_id 区分(换个 frame_id
# 就重置),这里原先跟它不一致。
key = (sid, h["frame_id"])
state = pending.get(key)
if state is None:
# 新的一帧到了:丢掉之前那帧的半成品。实时画面宁可少一帧,也不等它。
# 这些帧算"被新帧取代",不是"JPEG 不完整"——它们本来就不完整,
# 混在一起会把两种情形都算成链路有问题。
counters["被新帧取代"] += len(pending)
pending = {
key: {
"buf": bytearray(h["total_len"]),
"have": set(),
"expect": h["frag_count"],
"total": h["total_len"],
"w": h["width"],
"h": h["height"],
"started": time.time(),
}
}
state = pending[key]
# 同一帧内的重复分片直接跳过且不计数:否则 seen 达标而缓冲区仍有空洞。
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
tail = (h["total_len"] - 1) // FRAG
if len(state["have"]) >= 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[key]
now = time.time()
if now - last_print >= 2.0:
# 分子分母必须都是本窗口的增量。早先用累计帧数除以窗口时长,于是报出
# 39、61、79 这种随时间单调上升的假 fps——看起来像板子越跑越快。
elapsed = now - last_print
window_frames = counters["完整帧"] - prev_frames
window_pkts = counters["type:video"] - prev_pkts
window_drops = counters["JPEG 不完整"] - prev_drops
loss = 100.0 * window_drops / max(window_frames + window_drops, 1)
print(
f" {window_frames / elapsed:5.1f} fps "
f"包/s={window_pkts / elapsed:5.0f} "
f"完整帧={counters['完整帧']:4d} "
f"丢弃={window_drops:3d} ({loss:.1f}%)"
)
prev_frames, prev_pkts, prev_drops = (
counters["完整帧"],
counters["type:video"],
counters["JPEG 不完整"],
)
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()