☰
USB可编程直流电源SCPI自动化测试与上电时序实践
2026/11/6 6:06:30 网站建设 项目流程

简介:ControlItech 示例工程面向需要通过 USB 接口远程控制艾德克斯(ITECH)直流电源的测试开发人员与实验室工程师,围绕电压电流设定、输出状态读取、CV/CC 模式切换及自动化测试流程搭建等需求,提供一套可参考的 C# 实现思路,适合具备基础编程能力、希望快速验证 USB 通讯方案的读者。压缩包共 14 个文件,约 19KB,以 7 个 cs 源码文件为核心,配合 resx 界面资源、settings 与 config 配置文件、csproj 项目文件和 sln 解决方案,构成一个可用 Visual Studio 打开的 WinForms 小项目,suo 仅记录本地用户选项。资源已有 3951 人学习,说明其在电源控制入门场景中具有一定参考价值。读者可从中了解 USB 驱动调用、命令收发、界面参数设置与测量数据回传的组织方式,并据此替换量程、保护阈值或测试脚本,减少从零搭建通讯程序的时间。

1. 台架上那台电源,不该再用手拧旋钮

测试台架上给一块板子做三段上电:9V 起,200ms 后切 12V,再 200ms 落到 5V,全程盯电流。手工拧旋钮加秒表,一轮两分钟,一天五十轮。后来把电源的 USB 口接上,用 SCPI 指令把这套动作写成一串序列,二十几行代码跑完,顺手把每次的电压电流落成 CSV。这篇笔记讲的就是把「USB 通讯控制可编程直流电源」做成一个能跑、能改的小工程:通信层怎么选、指令怎么封、上电时序怎么排、翻车了先看哪。适合手上有一台带 USB 口的可编程直流电源、想把它塞进自动化测试流程的开发者,也适合刚接手台架脚本、被超时和权限来回折磨过的人。

2. 通信层选型与工程骨架:USBTMC 和虚拟串口不是一回事

动手写代码之前最该确认的一件事,是这台电源的 USB 口到底吐出来的是什么。很多人插上线、装个驱动就开写,写到一半发现设备管理器里多出来的是「USB 串行设备(COM3)」,而代码里却在死磕 VISA 资源串,来回折腾一整天。这两条路从驱动到 API 到出问题时的排查方向都不一样,先分清能省掉大量无用时间。

2.1 两条路的区别:USBTMC 与 USB 虚拟串口

原生支持 USBTMC(USB Test & Measurement Class)的设备,在系统里不会出现 COM 口,而是被 VISA 运行时识别成一个仪器资源,资源串长这样:USB0::0x1234::0x0001::8001234567890::INSTR,中间两段是厂商号和产品号,后面是序列号。这种设备走的是 Bulk 端点,报文里带 USBTMC 头,带宽比串口高得多,做高速采样或者频繁回读的时候优势明显。

另一类是电源的 USB 口后面其实焊了一颗 USB 转串口芯片(FTDI、CH340、或者芯片自带的 CDC),系统里就是一个虚拟 COM 口。走这条路的代码简单到极致:pyserial 打开端口,把 SCPI 字符串按行写进去、按行读出来,跟控制一台老式串口仪器没有任何区别。代价是波特率和延迟,115200 到 921600 是常见档位,高频率回读会喘。

维度USBTMCUSB 虚拟串口
系统识别VISA 资源串,无 COM 口COM3 //dev/ttyACM0
依赖VISA 运行时(NI-VISA 或 pyvisa-py + libusb)系统自带串口驱动
传输方式Bulk 端点,带 USBTMC 报文头原始字节流
吞吐高,适合连续回读受波特率限制
常见坑权限、后端选择、设备被占用波特率、DTR、换行符

判断方法很土但好用:Linux 上lsusb看设备描述,如果显示的是USB Test and Measurement Device,那就是 USBTMC;如果显示FT232 Serial或者CDC ACM,那就是串口。Windows 上打开设备管理器,看「端口(COM 和 LPT)」下面有没有新冒出来的 COM 号,有就是串口路线。

串口路线的最小可运行代码如下,注意结尾的换行符,这条最容易翻车:

import serial # pyserial # 串口路线:端口、波特率按实际改,超时别设成 0 ser = serial.Serial("/dev/ttyACM0", baudrate=115200, timeout=2) ser.write(b"*IDN?\n") # 多数机型以 \n 作为命令结束 print(ser.readline().decode().strip()) ser.write(b"VOLT 12.000\n") ser.write(b"OUTP ON\n") ser.close()

timeout是读操作的最长等待时间,设成 0 会立刻返回空,设成None会一直挂住,2 秒是个能忍的值。baudrate必须和电源手册里写的一致,设错了不是报错而是收到乱码或者干脆没反应。

2.2 依赖清单与后端选择

USBTMC 路线在 Python 里的标准组合是 pyvisa 加一个后端。pyvisa 本身只是统一 API,真正的传输由后端完成:装了 NI-VISA 就用它,图省事;没装(或者不想在 CI 机器上装几百兆的运行时)就用pyvisa-py,纯 Python 实现,配合pyusb走 libusb。

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install pyvisa pyvisa-py pyusb pyserial pyyaml

装完之后第一件事不是写业务代码,是确认能不能看见设备:

import pyvisa # "@py" 表示强制使用 pyvisa-py 后端;装了 NI-VISA 时去掉这个参数 rm = pyvisa.ResourceManager("@py") resources = rm.list_resources() print(resources) # 期望看到 ('USB0::0x1234::0x0001::...::INSTR',)

返回空元组是新手最常见的第一个坑,原因基本集中在三处:后端不对、驱动没装、Linux 权限不够。如果list_resources()里只有ASRL开头的串口资源却没有 USB 资源,说明系统层面根本没把设备当仪器识别,先去查驱动,别在代码里改参数。

提示:后端一旦选定就写进配置,不要靠环境变量隐式切换。同一份代码在两台机器上表现不一致,八成是这台装了 NI-VISA、那台没装。

2.3 目录结构:把传输层和设备层切开

这类小工具最常见的死法是写成一个大文件,main.py里既有串口读写又有电压换算又有 CSV 落盘,三个月后换一台不同型号的电源,整个文件重写一遍。我一般从第一天就按四层切:传输层只认字符串,设备层只认物理量,序列层只认步骤,记录层只认数据。

psu-usb-ctl/ ├── app/ │ ├── __main__.py # 入口,解析参数 │ ├── cli.py # 命令行:list / idn / run / monitor │ ├── transport/ │ │ ├── base.py # Transport 抽象基类 │ │ ├── visa_tmc.py # USBTMC 实现 │ │ └── serial_scpi.py # 虚拟串口实现 │ ├── device/ │ │ ├── scpi.py # 指令常量与响应解析 │ │ └── psu.py # 电源对象:设压、设流、回读 │ ├── sequence/ │ │ ├── steps.py # 步骤校验 │ │ └── runner.py # 执行器,含异常回滚 │ ├── record/ │ │ └── recorder.py # 采样与 CSV 输出 │ └── config/ │ ├── devices.yaml # 机型 profile:VID/PID、量程、终止符 │ └── sequence_board_a.yaml ├── tests/ │ ├── fake_transport.py # 假设备,CI 里跑 │ └── test_scpi.py ├── requirements.txt └── README.md

这样切的好处是替换成本可控:换接口只动transport/,换机型只动config/devices.yaml加一个 profile,换测试流程只动 yaml,业务代码一行不改。tests/fake_transport.py是后面要讲的重点,有了它,没插硬件也能跑通整条链路。

3. 核心模块拆解:一条 SCPI 指令从进入到返回

把VOLT 12.000这句字符串从 Python 送到电源,再等着它回一个12.003,中间经过的东西比看上去多:命令终止符的拼接、写入缓冲、Bulk 传输、设备端解析、回读采样、响应缓冲、读终止符匹配。任何一环没对齐,表现出来都是同一个症状——超时。所以封装的思路是:把不确定的部分收窄到两层里,其余地方只处理干净的数字和布尔值。

3.1 transport 层:三个方法就够

抽象基类只定义四个方法,保持极窄的接口,是为了让假设备实现起来毫无负担:

# app/transport/base.py from abc import ABC, abstractmethod class Transport(ABC): """传输层只做一件事:把字符串送出去,把字符串收回来。""" @abstractmethod def open(self) -> None: ... @abstractmethod def write(self, cmd: str) -> None: ... @abstractmethod def query(self, cmd: str) -> str: ... @abstractmethod def close(self) -> None: ...

USBTMC 实现里几个参数值得单独说。timeout是单条指令的等待上限,单位毫秒,设太小会在第一次回读时准时超时,设太大出问题时要干等。read_termination和write_termination必须显式设置,很多机型回的是\r\n,如果不设,read()会一直读到超时才返回,然后下一次query拿到的就是上一条指令的残留响应——这种错位是最难查的一类。

# app/transport/visa_tmc.py import pyvisa from .base import Transport class VisaTmcTransport(Transport): def __init__(self, resource: str, timeout_ms: int = 3000, read_term: str = "\n", write_term: str = "\n"): self.resource = resource self.timeout_ms = timeout_ms self.read_term = read_term self.write_term = write_term self._rm = None self._inst = None def open(self) -> None: self._rm = pyvisa.ResourceManager("@py") self._inst = self._rm.open_resource(self.resource) self._inst.timeout = self.timeout_ms # 单条指令等待上限 self._inst.read_termination = self.read_term # 不设会读到超时 self._inst.write_termination = self.write_term self._inst.clear() # 清掉上电残留的缓冲 idn = self._inst.query("*IDN?").strip() if not idn: raise RuntimeError(f"设备无应答: {self.resource}") def write(self, cmd: str) -> None: self._inst.write(cmd) def query(self, cmd: str) -> str: return self._inst.query(cmd).strip() def close(self) -> None: if self._inst: self._inst.close() if self._rm: self._rm.close()

open()里先clear()再*IDN?是一个很划算的习惯:设备刚上电或者上一个脚本异常退出时,输入缓冲里可能躺着半条没被读走的响应,不清掉的话第一条查询必然错位。*IDN?顺便当握手用,能回来说明链路是通的,回不来就别往下走了。

串口实现只有几行差异,本质是把write补上终止符、把readline的返回值解码:

# app/transport/serial_scpi.py import serial from .base import Transport class SerialScpiTransport(Transport): def __init__(self, port: str, baudrate: int = 115200, timeout_s: float = 2.0): self.port = port self.baudrate = baudrate self.timeout_s = timeout_s self._ser = None def open(self) -> None: self._ser = serial.Serial(self.port, self.baudrate, timeout=self.timeout_s) def write(self, cmd: str) -> None: self._ser.write((cmd + "\n").encode("ascii")) def query(self, cmd: str) -> str: self.write(cmd) return self._ser.readline().decode("ascii", errors="replace").strip() def close(self) -> None: if self._ser: self._ser.close()

注意errors="replace"。串口上偶尔会收到被截断的字节,直接decode()会抛异常,整条序列就断在这里,而实际上设备只是回了个半截。用 replace 至少能拿到部分内容,配合日志更容易定位。

3.2 device 层:把VOLT 12这种字符串关进笼子

device 层的价值在于:一旦出了这层,代码里就再也看不到 SCPI 字符串,只有伏特、安培和布尔值。量程校验也放在这里,因为这是唯一知道设备能力边界的地方。

# app/device/psu.py from dataclasses import dataclass @dataclass class Limits: max_volt: float = 30.0 max_curr: float = 5.0 max_ovp: float = 33.0 class Psu: def __init__(self, transport, limits: Limits): self.t = transport self.lim = limits def reset(self) -> None: self.t.write("*RST") self.t.write("*CLS") def set_voltage(self, v: float) -> None: if not 0 <= v <= self.lim.max_volt: raise ValueError(f"电压 {v}V 超出量程 {self.lim.max_volt}V") self.t.write(f"VOLT {v:.3f}") def set_current(self, i: float) -> None: if not 0 <= i <= self.lim.max_curr: raise ValueError(f"电流 {i}A 超出量程 {self.lim.max_curr}A") self.t.write(f"CURR {i:.3f}") def set_ovp(self, v: float) -> None: # 过压保护建议略高于最高工作电压,别贴着设 if not 0 < v <= self.lim.max_ovp: raise ValueError(f"过压保护 {v}V 不合理") self.t.write(f"VOLT:PROT {v:.3f}") def output(self, on: bool) -> None: self.t.write(f"OUTP {'ON' if on else 'OFF'}") def read_voltage(self) -> float: return self._to_float(self.t.query("MEAS:VOLT?")) def read_current(self) -> float: return self._to_float(self.t.query("MEAS:CURR?")) def read_output(self) -> bool: return self.t.query("OUTP?").strip() in ("1", "ON") @staticmethod def _to_float(raw: str) -> float: try: return float(raw) except ValueError as exc: raise RuntimeError(f"回读值无法解析: {raw!r}") from exc

:.3f这个格式化不是装饰。有些固件对小数位数敏感,发VOLT 12和发VOLT 12.000表现不同,统一三位小数能避开这类玄学差异。_to_float单独抽出来,是因为回读解析失败时会带上原始字符串,日志里能直接看到设备到底回了什么,省去重新复现的功夫。

3.3 指令表与解析:先查手册,再写代码

不同厂商、不同系列的指令集大同小异但不完全一致,下面这些是最通用的一组,实际接入前拿*IDN?确认型号再对一遍手册。

功能写入指令回读指令回读示例
设备识别无*IDN?VENDOR,MODEL,SN,1.23
复位 / 清状态*RST/*CLS无无
设电压VOLT 12.000VOLT?12.000
设限流CURR 1.500CURR?1.500
过压保护VOLT:PROT 13.200VOLT:PROT?13.200
输出开关OUTP ON/OUTP OFFOUTP?1/0
回读实际电压无MEAS:VOLT?12.003
回读实际电流无MEAS:CURR?0.421
错误队列无SYST:ERR?0,"No error"

SYST:ERR?值得写进排查流程:设备收到不认识的头、参数超范围、内部保护动作,都会往错误队列里塞一条。序列跑完最后统一查一遍错误队列,比事后猜要高效得多。注意每查一条错误队列就会弹出一条,所以要循环读到0,"No error"为止。

4. 上电时序与数据记录:把一次性脚本变成能复跑的序列

能手动跑通一次和能反复跑一千次,中间隔着一整套工程约束:步骤要可配置、失败要能回滚、数据要能落地、日志要能复现现场。上电时序这种东西尤其容易写成time.sleep(0.2)硬编码,改一个板子就要复制一份脚本,最后目录里躺着test_v1.py、test_v2_final.py、test_v2_final_改.py。

4.1 steps 定义:用 YAML 描述时序

把步骤从代码里抽出来,一个板子一份 yaml,改时序不用碰 Python:

# app/config/sequence_board_a.yaml name: board_a_power_on sample_interval_ms: 100 steps: - {action: set_ovp, value: 13.2} # 先设保护,再上电 - {action: set_current, value: 2.0} - {action: set_voltage, value: 9.0} - {action: output, value: true} - {action: wait, value: 200} - {action: set_voltage, value: 12.0} - {action: wait, value: 200} - {action: set_voltage, value: 5.0} - {action: wait, value: 500} - {action: output, value: false}

顺序上有个细节:过压保护和限流要放在使能输出之前,先设压再设保护,中间那几十毫秒里输出是不受保护的。wait的数值单位统一用毫秒,纯数字不带单位,避免 yaml 里混进200ms这种字符串还要额外解析。

4.2 runner:带异常回滚的执行器

执行器最忌讳写成一长串 if-elif,加个动作就要改三处。用字典把动作名映射到处理函数,新增动作只是多一行注册:

# app/sequence/runner.py import logging import time log = logging.getLogger(__name__) class Runner: def __init__(self, psu, recorder=None): self.psu = psu self.recorder = recorder self._handlers = { "set_voltage": lambda v: self.psu.set_voltage(float(v)), "set_current": lambda v: self.psu.set_current(float(v)), "set_ovp": lambda v: self.psu.set_ovp(float(v)), "output": lambda v: self.psu.output(bool(v)), "wait": lambda v: time.sleep(float(v) / 1000.0), } def run(self, seq: dict) -> None: steps = seq.get("steps") or [] if not steps: raise ValueError("序列为空") try: for idx, step in enumerate(steps, 1): action = step.get("action") handler = self._handlers.get(action) if handler is None: raise KeyError(f"第 {idx} 步动作未知: {action}") log.info("step %02d %s=%s", idx, action, step.get("value")) handler(step.get("value")) if self.recorder: self.recorder.sample() except Exception: log.exception("序列执行中断,强制关输出") # 留下现场 self.psu.output(False) raise finally: self.psu.output(False) # 正常结束也关输出

finally里那句关输出是整份代码里最值钱的一行。台架上出现过执行器崩在wait中间、输出还开着、板子在 12V 上烤了半小时的情况,从那以后不管正常结束还是异常退出,输出一律关掉。bool(v)转换也要留意,yaml 里的true会被解析成 Python 的True,但如果写成字符串"false",bool("false")是True,这是一个安静的致命错误。

4.3 recorder:采样与落盘

采样和序列执行是两条时间线,序列里有wait的间隔,采样要在这个间隔里按固定周期取点,否则一段 500ms 的等待只能采到一个点,看不出上电瞬间的电流尖峰。

# app/record/recorder.py import csv import time class Recorder: def __init__(self, psu, path: str = "run.csv", interval_ms: int = 100): self.psu = psu self.path = path self.interval_s = interval_ms / 1000.0 self.rows = [] self.t0 = time.time() self._last = 0.0 def sample(self) -> None: now = time.time() if now - self._last < self.interval_s: # 节流,别让回读挤爆链路 return self._last = now self.rows.append({ "t": round(now - self.t0, 3), "v_read": self.psu.read_voltage(), "i_read": self.psu.read_current(), }) def flush(self) -> None: if not self.rows: return with open(self.path, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=list(self.rows[0].keys())) writer.writeheader() writer.writerows(self.rows)

节流那一句的必要性来自实测:USBTMC 上连续两条MEAS:VOLT?加MEAS:CURR?大约要几十毫秒,如果不节流,sample()的调用频率完全由序列决定,一个 200ms 的等待里可能只有一次采样。把间隔设成 100ms,配合更细的wait切分,才能画出像样的上电曲线。反过来,采样间隔也不能比单次回读耗时还短,否则队列会越积越长。

5. 避坑与排查:五个让我加班到凌晨的细节

5.1list_resources()返回空

现象:代码跑起来不报错,resources打印出来是(),怎么改超时都没用。

原因:系统层面没把设备当仪器识别。常见三种情况——Windows 上没装 VISA 运行时,设备在设备管理器里带黄色感叹号;Linux 上没有对应的 udev 规则,普通用户没权限打开 USB 设备;后端选错,装了 NI-VISA 却强制指定了@py,或者反过来。

解决:先脱离 Python 确认。Windows 用厂商自带的设备管理器工具或者 NI MAX 看能不能识别;Linux 用lsusb找厂商号和产品号,然后写一条规则:

# /etc/udev/rules.d/99-psu.rules # idVendor/idProduct 按 lsusb 输出替换,MODE 0666 让普通用户可读写 SUBSYSTEM=="usb", ATTR{idVendor}=="1234", ATTR{idProduct}=="0001", MODE="0666"
sudo udevadm control --reload-rules sudo udevadm trigger # 拔插一次 USB 线,再跑 list_resources

5.2 第一条 query 超时,之后全乱

现象:*IDN?报VisaIOError: Timeout expired,把 timeout 调到 10 秒也不行;偶尔能通,但之后每条查询拿到的都是上一条的答案。

原因:终止符没对齐。设备回的是\r\n,代码设的是\n,read()一直等不到结束符,直到超时;超时后缓冲里残留的内容会在下一次query时被读走,于是所有响应整体错位一条。

解决:显式设置read_termination,不确定就三种都试一遍(\n、\r\n、\r)。开open()时先clear()清缓冲,再发*IDN?做握手。如果还是超时,用串口工具或者厂商上位机抓一下原始报文,确认设备确实回了东西。

5.3 设定 12V,回读只有 11.2V

现象:MEAS:VOLT?回来 11.2,偏差 0.8V,换个量程又变了,怀疑设备坏了。

原因:八成不是设备问题。线损(细线大电流)、表笔接在电源端而不是负载端、输出其实没使能还在 CV/CC 切换的瞬态里、或者回读的是设备内部采样点而非输出端子电压。有些机型出厂默认回读的是设定值而不是实测值,需要用MEAS:前缀才取实测。

解决:先查OUTP?确认真的开了;把万用表接到负载端对比,如果负载端和电源端差得多,那就是线损,换粗线或者改四线接法;用SYST:ERR?看有没有保护动作记录。偏差稳定且可复现,多半是接线和采样点的问题,别急着返修。

5.4 昨天能跑,重启后要 sudo

现象:改完 udev 规则当场生效,第二天开机又变成权限不足。

原因:规则只在当前会话生效过,文件没放对位置,或者写完之后没重载。也有的是因为设备换了 USB 口,某些主机的不同端口对应不同的设备路径。

解决:确认规则文件在/etc/udev/rules.d/下且后缀是.rules,执行udevadm control --reload-rules后重新插拔设备。如果 VID/PID 不是设备唯一标识(同型号多台),加ATTRS{serial}=="..."精确定位。别用chmod 666 /dev/bus/usb/...这种临时方案,重启就没了。

5.5 连续 write 后OUTP ON没生效

现象:发出一串指令,电压电流都设上了,输出就是不打开;或者SYST:ERR?报-113 Undefined header。

原因:SCPI 对大小写不敏感但把指令拆错了,比如把VOLT 12;CURR 2用分号拼在一起发——分号在 SCPI 里表示同一级路径下的并联,CURR前面需要补全路径才能正确解析。另一种是固件根本不支持某条指令,比如老机型没有VOLT:PROT,静默忽略或者报错。

解决:一条write只发一条指令,别图省事拼串;确实要拼就用换行分隔。逐条发完之后用*OPC?同步,确认前面的操作真的执行完了再发下一条。遇到-113就对着手册核对拼写,或者先发SYST:ERR?把错误队列清空再重试。

6. 二次开发:加一台新电源和加一道新工序怎么改

这套结构真正省事的地方在扩展的时候。加一台不同型号的电源,改动集中在两处:config/devices.yaml里加一个 profile,记录 VID/PID、量程上限、终止符;如果指令集有差异,就继承Psu覆盖对应方法,其余代码一行不动。

# app/device/psu_model_b.py from .psu import Psu class PsuModelB(Psu): """某个机型的差异:过压保护指令是 VOLT:PROT:LEV,回读要加 :SCAL""" def set_ovp(self, v: float) -> None: if not 0 < v <= self.lim.max_ovp: raise ValueError(f"过压保护 {v}V 不合理") self.t.write(f"VOLT:PROT:LEV {v:.3f}") def read_voltage(self) -> float: return self._to_float(self.t.query("MEAS:VOLT:SCAL?"))

加一道新工序(比如「循环拉偏」)只需要往_handlers里注册一个动作,再在 yaml 里写出来:

# 在 Runner.__init__ 的 _handlers 字典里追加 "ramp_voltage": lambda v: [ self.psu.set_voltage(float(v) * k / 10.0) or time.sleep(0.05) for k in range(1, 11) ],

改造点的分布大致是这样:

需求改动文件影响范围
换一台同指令集的电源config/devices.yaml无代码改动
换指令集有差异的型号新增device/psu_xxx.py,继承Psu只影响该机型
从 USB 切到串口transport/serial_scpi.py+ 配置业务代码不动
加一道时序工序sequence/runner.py注册动作 + yaml单点扩展
加一路温度采样record/recorder.py输出多一列

验证方法我踩过坑之后才固定下来:先跑假设备,再插真机。FakeTransport实现同样的四个方法,内部维护一个字典当寄存器,query时返回预设值,顺便可以注入超时和乱码来模拟故障:

# tests/fake_transport.py from app.transport.base import Transport class FakeTransport(Transport): def __init__(self, fail_on: str | None = None): self.state = {"VOLT": 0.0, "CURR": 0.0, "OUTP": "0"} self.fail_on = fail_on def open(self) -> None: pass def write(self, cmd: str) -> None: if self.fail_on and self.fail_on in cmd: raise TimeoutError(f"模拟超时: {cmd}") head, _, arg = cmd.partition(" ") if head in self.state and arg: self.state[head] = arg def query(self, cmd: str) -> str: if cmd == "*IDN?": return "FAKE,MODEL-X,0001,1.00" if cmd == "MEAS:VOLT?": return f"{float(self.state['VOLT']):.3f}" if cmd == "MEAS:CURR?": return f"{float(self.state['CURR']):.3f}" if cmd == "OUTP?": return self.state["OUTP"] return "0" def close(self) -> None: pass

有了它,pytest里不插硬件就能把「序列跑完输出必为 OFF」「中途异常必须回滚」「电压超量程必须抛错」这几条断言全部跑一遍,CI 机器上也毫无压力。我那台真机每周只用来做一次回归,剩下时间全靠假设备兜着。

从那以后我每次加新机型,都强制先跑一遍FakeTransport的回环测试,确认异常路径和回滚逻辑没问题,再插真机验证时序。真机验证也只看三件事:*IDN?对不对、SYST:ERR?队列是不是空的、CSV 里第一条和最后一条记录的电压是否落在预期区间。这套顺序帮我省掉了好几次把板子烤糊的机会。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询