Pelco-D云台模拟器:用pytest与虚拟串口做三层集成测试
2026/9/8 17:39:02 网站建设 项目流程

做Pelco KBD300A模拟器做到第19个版本,我最大的感受是:单体单测写得再漂亮,只要serial、protocol、macro这三层没有在同一套测试里真正跑通过一次,你就不敢说自己做好了“集成”。这一版我干脆把测试体系切到pytest,用虚拟串口把三层链路串起来做集成测试。这样做的直接收益是,以前只能在真机上手工验证的“按键宏回放→Pelco-D帧编码→串口字节发送”整条链路,现在一条命令就能在CI里反复跑。

这篇内容适合正在做串口工具、PTZ云台控制、模拟键盘、以及任何需要把“协议编解码”和“真实字节流”绑在一起测试的朋友参考。你会看到我是怎么搭虚拟串口、怎么做Pelco-D参数化、怎么让宏播放可控,以及最后三层合测时踩过的一堆坑。如果你现在还在用unittest硬撑,强烈建议看看pytest在这类场景下的组织方式。

1. 为什么这个阶段才真正需要三层集成测试

1.1 模拟器三层结构是怎么长出来的

这个模拟器刚起步时其实很简单:打开串口、读写字节,能做回显就算成功。但随着键盘功能越来越接近真实KBD300A,逻辑自然分成了三层。

第一层是serial,负责真实串口的打开、关闭、读写、超时控制,对上层只暴露最朴素的“字节进、字节出”。第二层是protocol,专门处理Pelco-D的编码和解码,把“云台向左转、速度0x20”这类动作变成7字节帧,再把收到的帧解析回动作。第三层是macro,它是键盘业务的体现,录制一串操作、定时回放,让摄像机按预设路径巡检。

三层拆分清楚之后,每一层都可以独立测试。serial层测试读写,protocol层测试编解码,macro层测试动作序列。问题在于,真实联调时bug往往出在三层之间的接缝上:宏引擎编出的动作,经协议层编码后发到串口,对端收到的帧到底是不是我们想要的?这种问题单测永远覆盖不到。

1.2 单体测试全绿,串起来仍然会翻车的三类典型问题

我这里踩过的坑大致能归成三类。

第一类是编码器与串口write之间的不匹配。protocol层单测里,send往往是一个假的回调函数,测试只关心编码后的字节内容对不对。可一旦换成真实串口写,就会遇到缓冲区、写入时序、甚至丢字节的问题。明明编码结果是正确的,串口另一端就是收不齐一帧。

第二类是宏层与协议层的停止动作映射不一致。宏引擎认为“结束一个动作”就是发一个cmd2为0x00的停止帧,但protocol层的动作表里如果对停止的定义不统一,就可能在宏回放结束时漏发或错发停止指令。单测里宏层用的是mock发送器,protocol层只认编码结果,两边各自都对,合在一起就错。

第三类是协议解码器对粘包半包的处理。单测里经常直接喂一个完整7字节帧给解码器,而真实串口是字节流,哪有什么帧边界。一次读操作可能只读到半个帧,也可能一次读到两三个帧粘在一起。解析逻辑稍不小心,宏回放过程中稍微有点回码,整个解析就乱了。

这三类问题指向同一个结论:必须把serial、protocol、macro放进同一个pytest进程,用同一个虚拟串口对,跑真正的端到端链路。

1.3 pytest在这里赢在哪

最早我也用过unittest,但在这个项目里,pytest的几个特性确实更顺手。

fixture很适合管理虚拟串口的生命周期。每个测试函数用vserial_pair这个fixture,测试前自动创建伪终端对,测试后自动关闭fd,比unittest里手写setUp和tearDown干净得多,还能按module、session粒度控制创建和销毁。

parametrize非常契合Pelco-D这种“字段多、枚举值多、边界要覆盖全”的协议。一个动作组合一个测试用例的写法会让文件爆炸,用参数化直接把有效地址、非法地址、速度边界全部铺开。断言失败时,pytest能同时打印参数和实际字节,排查效率高很多。

再加上pytest-timeout这类插件,可以对整个集成测试设一个总超时,防止宏线程或者串口读阻塞导致测试进程永远卡死。对串口类测试来说,超时兜底不是可选项,是必选项。

2. 串口测试前置:先把虚拟串口对搭稳

2.1 为什么不能用真实串口直接测

如果测试直接往真实串口写,首先你得有硬件,其次你得有对端设备。更麻烦的是,像我这种控制云台的模拟器,一个集成测试跑起来很可能真的把摄像机转走位了。测试本身应该是可重复、无副作用的,真实串口没法满足这些要求。

所以集成测试要用虚拟串口。在Linux和macOS上,最方便的手段是pty伪终端对。一个终端端被模拟器当串口打开,另一个终端端由测试进程持有,模拟器写进来的数据从另一端原样读出,完全不经过物理线路。

提示:如果只想在开发环境临时看效果,也可以用socat快速拉一对pseudo终端,比如socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1。但放到pytest里,直接用pty模块动态创建更干净,不依赖外部命令。

2.2 用pty模块封装一个vserial_pair fixture

我通常把虚拟串口对写在conftest.py里。原因很简单:多个测试模块要共用,而且它非常需要稳定的创建和销毁流程。

import os import pty import tty import pytest from pelco_kbd.serial_io import SerialIO @pytest.fixture def vserial_pair(): master_fd, slave_fd = pty.openpty() tty.setraw(master_fd) slave_name = os.ttyname(slave_fd) serial_io = SerialIO(port=slave_name, baudrate=9600, timeout=0.1) serial_io.open() try: yield serial_io, master_fd finally: serial_io.close() try: os.close(master_fd) except OSError: pass try: os.close(slave_fd) except OSError: pass

几个细节值得说一下。

tty.setraw(master_fd)是必须的。伪终端默认带行规则,会对输入做回显、换行转换之类的处理,这跟真实串口的裸字节流不一样。不关掉raw模式,你写进去的字节很可能被加工过,测试结果完全没意义。

master_fd和slave_fd必须都关闭。测试里经常出现跑完一轮之后fd耗尽、下一次用例打不开串口的问题,绝大多数是上一个测试没清理干净。用yield加finally的写法,可以保证即使断言失败文件描述符也会被释放。

SerialIO的timeout建议设小一点,比如0.1秒。这样如果对端没有数据可读,read会在100毫秒左右超时返回,不会把整个测试卡死。真实模拟器里这个值可以大一些,但测试环境里越小越稳。

2.3 serial基础用例:写入、读取与超时行为

fixture搭好之后,第一个集成用例一定是“写入内容能被另一端原样读到”。这个用例看起来简单,但它能一次性验证串口打开、写入、pty连通性、读超时这些基础链路。

def test_serial_write_to_virtual_port(vserial_pair): serial_io, master_fd = vserial_pair frame = bytes([0xFF, 0x01, 0x00, 0x04, 0x20, 0x00, 0x24]) written = serial_io.write(frame) assert written == len(frame) readable, _, _ = select.select([master_fd], [], [], 0.5) assert readable, "对端没有读到数据" data = os.read(master_fd, 4096) assert data == frame

read这步要注意,os.read在fd上没有数据时会阻塞。这里用select先等0.5秒,确保了读操作不会让pytest挂住。0.5秒也不是随便选的,后面会专门讲这个时间怎么估算。

注意:虚拟串口的另一端读到的数据,务必用os.reados.fdopen去读,不要试图用serial.Serial同时打开master端。伪终端对里的master端和slave端语义并不完全等同于两台物理串口设备互联。我自己调试时就在这个问题上浪费过不少时间。

3. 协议层参数化验证:Pelco-D编解码不能靠“手测几个包”

3.1 Pelco-D帧结构与编解码接口约定

Pelco-D本身是安防行业非常常见的云台控制协议,帧结构不复杂,但字段位置一旦记错,设备根本不会有反应。

我用的帧结构是7字节:

|

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询