UI
UI 面板详细功能说明
本文档深入讲解各顶层视图下的子面板功能、控件、完整工作流、数据流、线程模型和实现细节。

一、DUT Test 视图
DUT Test 视图包含 4 个 Tab 页:Run Test、Product Management、TX Power Cal、RX Calibration。
1.1 Run Test — DUT 测试执行面板
文件: ui/panels/dut_test_view.py — DUTRunTestPanel(QWidget)
功能概述
提供 DUT 启动和测试执行的完整 UI 配置界面。用户选择产品、启动模式、测试组,配置资源路径,然后一键启动后台 BootupRunner 线程执行 DUT 测试流程。
UI 控件一览
| 区域 | 控件 | 说明 |
|---|---|---|
| 产品选择 | product_combo |
从 products.json 加载的产品列表 |
board_type_label / power_type_label |
只读,显示产品的接口板类型和电源类型 | |
| 启动配置 | boot_mode_combo |
INTERNAL / EXTERNAL |
restart_mode_combo |
RestartMode 枚举(NOT_RESTART / REPLACE_RADIOAPP_SWDB / REPLACE_DC 等) | |
slot_spin |
Slot 编号(外部启动时禁用并显示 "N/A") | |
branch_edit |
Branch 输入(默认 "0") | |
test_group_combo |
TestCaseGroup 枚举(BOOTUP / ALL / pa_cal / rx_cal 等) | |
| 资源路径 | elf_path_edit + Browse |
ELF 固件文件路径 |
swdb_paths_edit + Browse |
SWDB 路径(逗号分隔,支持多选追加) | |
sqlite_path_edit + Browse |
SQLite 数据库路径 | |
pis_path_edit + Browse |
PIS 路径(仅外部启动) | |
dc_path_edit + Browse |
DC 路径(仅内部启动) | |
libetsw_path_edit + Browse |
Libetsw 路径 | |
ppar_path_edit + checkbox |
PPAR 加载(仅外部启动) | |
proddb_path_edit + checkbox |
ProdDB 加载(仅外部启动) | |
| 远程文件 | fetch_files_button |
从 E2C 通过 SSH/SFTP 下载文件到本地 |
| 执行控制 | enable_powercycle_checkbox |
启用电源循环(restart_mode != NOT_RESTART 时可用) |
repeat_enabled_checkbox + repeat_count_spin |
循环测试(1-9999 次) | |
start_button / stop_button |
开始/停止测试 | |
power_on_btn / power_off_btn |
手动电源控制(绿/红色按钮) | |
| 日志 | log_text_edit |
只读日志输出(彩色显示 INFO/WARNING/ERROR) |
完整工作流(从用户操作到后台执行)
用户操作 系统行为
─────────────────────────────────────────────────────────────────
1. 选择产品 → _on_product_changed(name)
│ ├── 从 products.json 加载配置
│ ├── 填充 board_type_label / power_type_label
│ └── 自动填充 resource_config 所有路径字段
│
2. 选择启动模式 → _on_boot_mode_changed(mode)
│ ├── EXTERNAL: 禁用 slot_spin, 启用 PIS/PPAR/ProdDB, 禁用 DC
│ └── INTERNAL: 启用 slot_spin, 启用 DC, 禁用 PIS/PPAR/ProdDB
│
3. 选择 Restart Mode → _on_restart_mode_changed()
│ ├── 根据 _restart_mode_visible_fields 映射表 显示/隐藏字段
│ │ REPLACE_RADIOAPP_SWDB → 显示 ELF + SWDB
│ │ REPLACE_DC → 显示 DC
│ │ REPLACE_ET_LIB → 显示 Libetsw
│ └── NOT_RESTART → 禁用 Power Cycle checkbox
│
4. (可选) 点击 Fetch Remote → _fetch_and_refresh_files()
│ ├── 验证: product 已选? loading_dir 已配置? SSH 信息已填?
│ ├── 解码 base64 SSH 密码
│ ├── 收集 ssh_elf_path / ssh_swdb_paths / ssh_libetsw_path
│ ├── 创建 _DownloadWorker(QThread)
│ │ └── run(): paramiko SSH → SFTP get 逐文件下载
│ ├── 每 10% 发射进度 log
│ └── 完成后 _auto_detect_local_files(loading_dir)
│ └── glob 模式匹配: *.elf, swDbPrdType_*.bin, *.so.0.0.0 等
│
5. 点击 "Start" → _on_start_clicked()
│ ├── 清理之前的 log handlers (防止累积)
│ ├── build_namespace() 构建 argparse.Namespace
│ ├── validate_resource_config() 校验必填文件
│ │ └── 校验失败 → QMessageBox 提示, return
│ ├── 创建 BootupRunner(QThread)
│ │ 参数: namespace, repeat_count, repeat_mode
│ ├── 连接 signals:
│ │ ├── log_message → _on_runner_log (彩色输出)
│ │ ├── finished_ok → _on_runner_finished
│ │ ├── error_occurred → _on_runner_error
│ │ ├── iteration_started → _on_iteration_started
│ │ └── iteration_complete → _on_iteration_complete
│ ├── runner.start()
│ └── 禁用 Start, 启用 Stop
│
6. 点击 "Stop" → _on_stop_clicked()
├── runner.request_stop() — 设置 _stop_event
├── _cleanup_adapter() — 关闭电源/VISA/Telnet
├── runner.wait(2000) — 等待 2 秒优雅退出
├── 若超时 → runner.terminate() + wait(1000)
└── 启用 Start, 禁用 Stop
数据流图
┌─────────────────────────────────────────────────────────────────────────────┐
│ DUTRunTestPanel (UI Thread) │
│ │
│ product_combo ─→ _on_product_changed() ─→ products.json ─→ 填充路径字段 │
│ │
│ [Start] ─→ build_namespace() ─→ validate_resource_config() │
│ │ │
│ ▼ │
│ BootupRunner(QThread) │
└──────────────────│──────────────────────────────────────────────────────────┘
│ (后台线程)
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ BootupRunner.run() — _run_full_flow_repeat() │
│ │
│ ① ProductConfigManager.get_product_config(product) │
│ ② setup_logging() + _SignalLogHandler → 日志转发到 UI │
│ ③ 构建 LoadedFiles(elf, swdb, sqlite, dc, pis, libetsw) │
│ ④ AdapterFactory.create(config) → 创建硬件 adapter │
│ ⑤ adapter.create_control_connection() → Telnet/SSH │
│ ⑥ adapter.create_power_supply() → GPIB/TCPIP 电源控制 │
│ ⑦ _maybe_load_profile() → gRPC ConfigurationClient (TCPE3 only) │
│ ⑧ BootModeHandler.execute() → 按 boot_mode 执行启动流程 │
│ ⑨ TestRunner.run(radio, args) → 执行测试用例 │
│ ⑩ TestRunner.cleanup() → 清理串口连接 │
│ │
│ Signals: log_message(str, int), finished_ok(), error_occurred(str) │
│ iteration_started(int, int), iteration_complete(int, int) │
└─────────────────────────────────────────────────────────────────────────────┘
BootupRunner 线程模型
文件: ui/workers/bootup_runner.py — BootupRunner(QThread)
class BootupRunner(QThread):
"""关键信号"""
log_message = Signal(str, int) # (格式化消息, logging 级别)
finished_ok = Signal() # 正常完成
error_occurred = Signal(str) # 异常错误
iteration_started = Signal(int, int) # (当前迭代, 总迭代)
iteration_complete = Signal(int, int)
def __init__(self, namespace, repeat_count=1, repeat_mode="full_flow"):
self._args = namespace
self._repeat_count = repeat_count
self._repeat_mode = repeat_mode
self._stop_event = threading.Event() # 协作式停止标志
两种重复模式:
| 模式 | 方法 | 行为 |
|---|---|---|
full_flow |
_run_full_flow_repeat() |
每次迭代独立创建连接、执行完整流程、清理 |
test_only |
_run_test_only_repeat() |
连接只建立一次,仅重复 TestRunner.run() |
停止机制:
request_stop()→ 设置threading.Event, 立即清理硬件- Runner 在每个步骤之间检查
is_stop_requested() - UI 端
_on_stop_clicked()先等待 2s 优雅退出,超时后terminate()
持久化机制
def save_settings(self) -> None:
"""使用 QSettings 保存所有表单值"""
# 键名格式: "dut_test/{field_name}"
# 包括: boot_mode, slot, branch, test_group, restart_mode,
# 所有路径字段, repeat 设置, ppar/proddb 选项
def restore_settings(self) -> None:
"""从 QSettings 恢复表单值 (程序启动时调用)"""
# 使用 findData/findText 恢复 combo box 选择
# 使用 setValue/setText 恢复 spin/edit 值
注意事项/常见坑
- 路径 sys.path 注入: 面板头部显式将
dut_test/加入sys.path,因为dut_test包内部使用from common.xxx相对导入 - Log Handler 泄漏:
_on_start_clicked()开头必须清理旧的_SignalLogHandler,否则多次 Start 后日志会重复 - PPAR/ProdDB 联动: 这些路径字段仅在 EXTERNAL boot mode 时启用,且需要 checkbox 勾选
- Fetch Remote Files 阻塞保护: 使用独立
_DownloadWorker(QThread)避免阻塞 UI,下载完后在 UI 线程回调中执行_auto_detect_local_files() - Slot 值特殊处理: External 模式下 slot_spin 显示 "N/A"(使用
setSpecialValueText),实际传递 minimum 值 - Power 控制独立线程:
_on_power_on/off创建_PowerWorker(QThread)避免 GPIB 通信阻塞 UI
1.2 Product Management — 产品管理面板
文件: ui/panels/product_management_panel.py — ProductManagementPanel(QWidget)
功能概述
完整的产品配置 CRUD 界面。所有产品存储在 configuration/products.json 中,支持增删改查、拖拽排序、搜索过滤、SSH 远程文件浏览。
数据模型
// configuration/products.json — 单个产品示例
{
"name": "AIR3286",
"family": "Radio",
"production_number": "KRC161702/1",
"r_state": "R1A",
"db_suffix": "AIR3286_R1A",
"interface_board_type": "TCPE3",
"profile_path": "/path/to/profile.json",
"interface_board_position": 1,
"power_supply_type": "DC",
"power_supply_com_mode": "GPIB",
"power_supply_addr": "GPIB0::5::INSTR",
"power_supply_vol": 54,
"power_supply_current": 30,
"resource_config": {
"loading_dir": "C:/loading/AIR3286",
"elf_path": "C:/loading/AIR3286/firmware.elf",
"swdb_paths": ["C:/loading/AIR3286/swDbPrdType_AIR3286.bin"],
"sqlite_path": "C:/loading/AIR3286/config.sqlite",
"pis_path": "",
"dc_path": "C:/loading/AIR3286/dc_file.dc",
"libetsw_path": "",
"ssh_host": "10.0.0.100",
"ssh_username": "root",
"ssh_password": "base64_encoded_password",
"ssh_elf_path": "/opt/firmware/latest.elf",
"ssh_swdb_paths": ["/opt/swdb/swDbPrdType_AIR3286.bin"],
"ssh_libetsw_path": ""
}
}
UI 布局

_ProductDialog — 编辑对话框完整字段
class _ProductDialog(QDialog):
"""产品编辑对话框,包含:
1. 基本字段 (name, family, production_number, r_state, db_suffix,
interface_board_type, profile_path, interface_board_position,
power_supply_type/com_mode/addr/vol/current)
2. Resource Configuration 组:
├── Loading Directory (Browse → 自动检测文件)
├── Local Paths (ELF, SWDB, SQLite, PIS, DC, Libetsw)
├── SSH Connection (host, username, password + Connect 按钮)
└── SSH Remote Paths (ELF, SWDB, Libetsw — 仅用于远程下载)
"""
_auto_detect_files() 自动检测算法
当用户选择 Loading Directory 后自动扫描并填充路径字段:
规则 (如果匹配到多个文件,清空该字段并弹出警告):
1. ELF: glob("*.elf") → 唯一匹配
2. SWDB: 精确匹配 "swDbPrdType_{db_suffix}.bin"
+ "swDbPrdType_{prefix}.bin" (prefix = db_suffix 第一个 _ 前的部分)
3. Libetsw: glob("*.so.0.0.0") → 唯一匹配
4. SQLite: glob("*.sqlite") → 唯一匹配
5. DC: glob("*.dc") → 唯一匹配
6. PIS: 查找 pis/ 子文件夹 → glob("pis/*.dc") → 唯一匹配
SSH 远程浏览 (_on_ssh_connect)
@Slot()
def _on_ssh_connect(self) -> None:
"""建立 SSH 连接用于远程文件浏览"""
# 1. 验证 host + username 非空
# 2. 使用 paramiko.SSHClient 连接 (timeout=10s)
# 3. 成功 → self._ssh_client = client, 状态显示 "Connected ✓"
# 4. 失败 → 状态显示红色错误信息
# 5. 连接后可使用 _RemoteFileSelectDialog 浏览远程文件
_RemoteFileSelectDialog: 基于 paramiko exec_command("ls -la") 实现的远程文件树浏览器,支持单选/多选模式。
拖拽排序实现
# QTableWidget 配置:
self._table.setDragEnabled(True)
self._table.setAcceptDrops(True)
self._table.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
self._table.verticalHeader().setSectionsMovable(True)
self._table.verticalHeader().sectionMoved.connect(self._on_row_moved)
@Slot(int, int, int)
def _on_row_moved(self, logical, old_visual, new_visual):
"""拖拽后同步内部产品列表顺序"""
# 1. 从 verticalHeader 获取当前视觉顺序
# 2. 根据 logicalIndex 映射重建 self._products 列表
# 3. _save_products() 持久化
# 4. _refresh_table() 重建表格 (reset logical==visual)
# 5. products_changed.emit() 通知其他面板刷新
数据流图
┌─── UI 操作 ──────────────────────────────────────────────────────────────┐
│ │
│ [Add/Edit/Clone] → _ProductDialog → get_data() → dict │
│ │ │
│ ▼ │
│ self._products.append(data) / self._products[row] = data │
│ │ │
│ ▼ │
│ _save_products() → json.dump(self._products, products.json) │
│ │ │
│ ▼ │
│ _refresh_table() → 重建表格行 │
│ │ │
│ ▼ │
│ products_changed.emit() → DUTRunTestPanel.refresh_product_list() │
│ │
└──────────────────────────────────────────────────────────────────────────┘
注意事项/常见坑
- 密码 Base64 编码: SSH 密码使用
encode_password()编码存储,读取时需decode_password()解码 - _NoScrollComboBox / _NoScrollSpinBox: 防止用户滚动时意外修改值(覆盖
wheelEvent为event.ignore()) - Legacy 路径兼容: 首次加载时检查
custom_products.json遗留文件,合并到products.json - 表格排序禁用: 刻意
setSortingEnabled(False)保持拖拽顺序稳定 - 去重验证:
_is_duplicate_name()在 Add/Edit/Clone 时防止产品名重复
1.3 TX Power Cal — TX 功率校准
文件: ui/panels/tx_power_cal_view.py — TxPowerCalView(QWidget)
结构与资源管理
三个 Sub-Tab,共享同一个 NRP 功率计资源:
TxPowerCalView (QWidget)
├── Tab 1: TxPowerCalServicePanel — gRPC 服务管理 + Trace/Pulse 图表
├── Tab 2: DutSimulatorPanel — DUT 模拟器 + 校准流程
└── Tab 3: PowerViewerPanel — 实时功率查看器
资源优先级: Service > Viewer
┌──────────────────────────────────────────────────┐
│ Service 启动时: │
│ server_state_changed("starting"/"running") │
│ │ │
│ ▼ │
│ TxPowerCalView._on_service_state_changed() │
│ │ │
│ ▼ │
│ PowerViewerPanel.stop() ← 强制停止 Viewer │
│ │
│ Viewer 启动时: │
│ _service_is_running() 检查 │
│ │ │
│ └── True → 拒绝启动,显示错误提示 │
└──────────────────────────────────────────────────┘
Tab 1: TX Power Cal Service

文件: ui/panels/tx_power_cal_service_panel.py — TxPowerCalServicePanel(QWidget)
完整工作流
用户操作 系统行为
───────────────────────────────────────────────────────────────────
1. 填写 VISA Resource / Cal Dir / Port
2. 点击 "Start Server" → start_server()
│ ├── 验证 VISA 非空
│ ├── _update_ui_state("starting")
│ │ └── 禁用 Start/Stop, 发射 server_state_changed
│ └── self._worker.start_server(port, visa, cal_dir)
│ └── TxPowerCalWorker 后台线程启动 gRPC 服务器
│
3. Worker 报告启动成功 → _on_server_started()
│ ├── _update_ui_state("running")
│ └── 日志: "Server started successfully"
│
4. DUT 调用 ExecutePulseMeas RPC → Worker 内 gRPC servicer 处理
│ ├── 功率计采集 trace
│ ├── worker.log_message 发射日志
│ └── 日志中含 "trace saved to PATH (N points)"
│
5. 日志正则检测 trace 文件 → _on_worker_log() 中:
│ re.search(r'trace saved to (.+?) \((\d+) points\)', msg)
│ └── _load_and_plot_trace(csv_path)
│ ├── 读取 CSV (time, power 两列)
│ ├── 转换: time→ms, watts→dBm
│ └── self._trace_plot.plot(x_ms, y_dbm)
│
6. 点击 "Stop Server" → stop_server()
├── _update_ui_state("stopping")
└── worker.stop_server()
图表更新机制
def _on_worker_log(self, message: str, level: int) -> None:
"""Worker 日志消息处理 — 正则匹配自动加载 CSV 到图表"""
self._append_log(message, level)
# 正则: "trace saved to /path/to/file.csv (1000 points)"
trace_match = re.search(r'trace saved to (.+?) \((\d+) points\)', message)
if trace_match:
csv_path = trace_match.group(1)
self._load_and_plot_trace(csv_path)
def update_trace_plot(self, x_values, y_values):
"""Trace 图表: 时间(s)→ms, 功率(W)→dBm"""
x_ms = [t * 1000.0 for t in x_values]
y_dbm = [10.0 * math.log10(w) + 30.0 if w > 0 else -200.0 for w in y_values]
self._trace_plot.plot(x_ms, y_dbm, pen=pg.mkPen('c', width=1))
def update_pulse_plot(self, pulse_data):
"""Pulse 图表: 每个脉冲画水平线段 (绿=present, 红=absent)"""
for pulse in pulse_data:
color = 'g' if pulse.get('present', True) else 'r'
self._pulse_plot.plot([start_ms, stop_ms], [power, power], pen=color)
TxPowerCalWorker 生命周期
┌─────────────────────────────────────────────────────┐
│ TxPowerCalWorker(QThread) │
│ │
│ Signals: │
│ server_started() │
│ server_stopped() │
│ server_error(str) │
│ log_message(str, int) │
│ │
│ start_server(port, visa, cal_dir): │
│ → 创建 gRPC server + TxOutputPowerCalServicer │
│ → 初始化 NRPPowerMeter(visa) │
│ → server.add_insecure_port(f"[::]:{port}") │
│ → server.start() │
│ → server_started.emit() │
│ │
│ stop_server(): │
│ → server.stop(grace=5) │
│ → 关闭功率计连接 │
│ → server_stopped.emit() │
└─────────────────────────────────────────────────────┘
Tab 2: DUT Simulator

文件: ui/panels/dut_simulator_panel.py — DutSimulatorPanel(QWidget)
connect_and_run() 完整流程
def connect_and_run(self) -> None:
"""收集 UI 参数 → 构建 ClientConfig → 提交到 Worker 执行"""
config = ClientConfig(
server_address=self._server_address_edit.text().strip(),
start_hz=self._start_hz_spin.value() * 1e9, # GHz → Hz
stop_hz=self._stop_hz_spin.value() * 1e9,
targeted_number_of_pulses=self._pulse_count_spin.value(),
min_configurable_pulse_len_s=self._pulse_len_spin.value() * 1e-6, # μs → s
min_configurable_pulse_sep_s=self._pulse_sep_spin.value() * 1e-6,
rf_port_no=[self._rf_port_spin.value()],
)
self._worker.run_calibration(config)
DutSimulatorWorker 后台执行逻辑
DutSimulatorWorker(QThread)
│
├── Signals: flow_started, flow_completed(report), flow_error(str),
│ step_progress(str), log_message(str,int), result_ready(object)
│
├── run_calibration(config):
│ └── 提交完整校准流程到内部队列
│
├── submit(name, callable):
│ └── 提交单独 RPC 调用到队列
│
└── 内部执行流程:
① gRPC channel connect
② ExecutePulseMeas RPC → 获取 calibration_id
③ 等待功率计采集 (capture_time 秒)
④ GetPulseMeasResults RPC → 获取脉冲测量结果
⑤ 生成 CalibrationReport
⑥ flow_completed.emit(report)
单独 RPC 调用 vs 完整流程调用
| 按钮 | 方法 | 行为 |
|---|---|---|
| Connect & Run | connect_and_run() |
Worker 执行完整校准流程(全自动) |
| Execute Measurement | execute_measurement() |
Worker.submit → _do_execute_measurement() |
| Get Results | get_results() |
Worker.submit → _do_get_results() (需要 calibration_id) |
| Query RF Interface | query_rf_interface() |
Worker.submit → _do_query_rf_interface() |
每个单独 RPC 调用都会创建临时 gRPC channel,调用后立即关闭:
def _do_execute_measurement(self) -> object:
channel = grpc.insecure_channel(target)
try:
stub = TxOutputPowerCalServiceStub(channel)
response = stub.ExecutePulseMeas(request, timeout=60)
return ("execute_measurement", response)
finally:
channel.close()
参数自动保存 Debounce 机制
# 初始化 debounce timer
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.setInterval(500) # 500ms 去抖
self._save_timer.timeout.connect(self._do_save_params)
# 每个输入控件变化时触发
def _auto_save_params(self) -> None:
self._save_timer.start() # 重置 timer, 500ms 内无新变化才执行保存
def _do_save_params(self) -> None:
data = self.save_config()
yaml.dump(data, f) # 保存到 configuration/dut_simulator_cache.yaml
持久化格式: YAML (configuration/dut_simulator_cache.yaml)
connection.server_address: 服务地址pulse_config: start_hz/stop_hz/pulse_count/pulse_len_s/pulse_sep_s/rf_port_no (原始单位)pulse_sequence: 脉冲表格数据列表
Tab 3: Power Viewer

文件: ui/panels/power_viewer_panel.py — PowerViewerPanel(QWidget)
_PowerMeterWorker 命令队列模式
┌─────────────────────────────────────────────────────────────────┐
│ _PowerMeterWorker(QThread) │
│ │
│ 架构: 命令队列 + 连续读取循环 │
│ │
│ self._cmd_queue = deque() ← 主线程提交命令 (线程安全: deque) │
│ self._continuous = False ← 连续读取模式标志 │
│ │
│ run() 主循环: │
│ while not interrupted: │
│ ① 处理所有待命令 (connect/disconnect/update_settings/trace) │
│ ② if _continuous and _meter: │
│ power = self._meter.read_power() ← 硬件节拍, 无 sleep │
│ reading_ready.emit(power) │
│ else: │
│ self.msleep(50) ← 空闲时避免 busy-wait │
│ │
│ 命令类型: │
│ ("connect", visa, freq_hz, aperture_s, auto_avg, avg_count) │
│ ("disconnect",) │
│ ("update_settings", aperture_s, auto_avg, avg_count) │
│ ("trace", sweep_time_s, points) │
└─────────────────────────────────────────────────────────────────┘
Continuous vs Trace 两种模式
| 模式 | 数据采集 | 图表显示 | 统计 |
|---|---|---|---|
| Continuous | _meter.read_power() 循环 (硬件 aperture 节拍) |
滚动折线图 (最近 200 点) | Min/Max/Avg/Count 实时更新 |
| Trace | _meter.capture_trace(sweep_time, points) 一次性采集 |
时间域波形 | Peak/Min/Avg 从 trace 数据计算 |
UI 刷新解耦机制 (QTimer 33fps)
# 核心设计: 读取速率 ≠ 显示速率
# 读取: 硬件 aperture 决定 (可能 50+ Hz)
# 显示: QTimer(30ms) ≈ 33fps
self._ui_timer = QTimer(self)
self._ui_timer.setInterval(30)
self._ui_timer.timeout.connect(self._refresh_ui)
@Slot(float)
def _on_reading(self, power_dbm: float) -> None:
"""Worker 信号 → 仅更新缓冲区, 不触发 UI 更新"""
self._samples.append(power_dbm) # deque(maxlen=200)
self._stats_count += 1
self._stats_sum += power_dbm
self._latest_power = power_dbm
self._ui_dirty = True # 标记需要刷新
def _refresh_ui(self) -> None:
"""Timer 回调 → 从缓冲区读取数据更新 UI (每 30ms 最多一次)"""
if not self._ui_dirty:
return
self._ui_dirty = False
# 更新数字显示、统计、滚动图表
资源竞争处理 (Service > Viewer)
def start(self) -> None:
"""启动前检查 Service 状态"""
if self._service_is_running(): # 由 TxPowerCalView 注入的 callable
self._log("Cannot start: TX Power Cal Service is running.")
return
self._do_start()
# TxPowerCalView 中:
self._power_viewer_panel._service_is_running = self._is_service_running
self._service_panel.server_state_changed.connect(self._on_service_state_changed)
def _on_service_state_changed(self, state):
if state in ("starting", "running"):
self._power_viewer_panel.stop() # 强制停止 Viewer
注意事项/常见坑
- deque 线程安全: Python
deque.append()是原子操作,Worker 线程 emit → UI 线程 append 无需额外锁 - 硬件节拍: Continuous 模式下
read_power()的速率完全由硬件 aperture 时间决定,无软件 sleep - NRPPowerMeter 导入路径: 需要将
tx_output_power_cal_service/加入sys.path - 连续 Trace 模式: Trace 采集完成后使用
QTimer.singleShot(0, self._do_trace_capture)立即排队下次采集 - Settings 实时推送:
_on_settings_changed()在运行中实时向 Worker 队列发送update_settings命令


二、InterfaceBoard Test 视图
InterfaceBoard Test 是多 Tab 面板,包含 Connection、Configuration、GPIO、Firmware Update、DZ Loading、DCDC FW、TSP UART、Test Execution。
2.1 Connection — 设备连接面板

文件: ui/panels/connection_panel.py — ConnectionPanel(QWidget)
功能概述
管理与接口板的 gRPC 连接:设备扫描 → 板卡类型识别 → 自动连接 → 创建 9 个服务客户端 → 配置文件管理。
完整连接流程
┌─────────────────────────────────────────────────────────────────────────┐
│ 程序启动 │
│ │ │
│ ▼ QTimer.singleShot(0, _on_scan_clicked) ← auto_scan=True 默认 │
│ │
│ ① _on_scan_clicked(): │
│ ├── 禁用 Scan 按钮, 显示 Cancel + ProgressBar │
│ ├── 清空 device_combo (保留空白首项) │
│ ├── 创建 ScanWorker(QThread) │
│ │ └── 扫描子网内 gRPC 端口 (50050/50051) │
│ ├── 连接 signals: scan_progress, scan_complete, finished │
│ └── worker.start() │
│ │
│ ② _on_scan_complete(online_devices): │
│ ├── 隐藏 ProgressBar + Cancel │
│ ├── 去重 (by IP) │
│ ├── 为每个设备生成 display 文本: │
│ │ _build_device_display("192.168.2.61:50050") │
│ │ → resolve_board_type(ip) → {"board_type": "TIB3", "dut_position": 1}│
│ │ → "192.168.2.61:50050 TIB3#1" │
│ ├── 填充 device_combo │
│ └── QTimer.singleShot(100, combo.showPopup) ← 延迟弹出让布局稳定 │
│ │
│ ③ 用户选择设备 → _on_device_selected(text): │
│ ├── 取消正在进行的连接 (如有) │
│ ├── 解析 IP + board_type + dut_position │
│ ├── 更新 board_type_label / dut_label │
│ ├── if "NIB": │
│ │ ├── 从 CredentialStore 加载凭证 (先尝试 port 2222, 回退 22) │
│ │ ├── nib_selected.emit(ip, username, password) │
│ │ └── return (NIB 无 gRPC) │
│ └── else: _connect_to_device(address, dut_position) │
│ │
│ ④ _connect_to_device(address, dut_position): │
│ ├── 显示 indeterminate ProgressBar │
│ ├── 创建 _ConnectWorker(QThread, timeout=5s) │
│ │ └── run(): │
│ │ channel = grpc.insecure_channel(address) │
│ │ grpc.channel_ready_future(channel).result(timeout=5) │
│ │ connect_success.emit(channel) │
│ └── 连接 signals + worker.start() │
│ │
│ ⑤ _on_connect_success(channel): │
│ ├── 检查 worker 是否被取消 │
│ ├── self._channel = channel │
│ ├── 创建 9 个服务客户端 (顺序): │
│ │ ConfigurationClient → GPIOClient → ModeSelectClient → │
│ │ TriggerClient → StatusClient → DutConnectionClient → │
│ │ PMBusClient → TspUartClient → ExternalAlarmClient │
│ │ 每个: Client(channel, logger, dut_position).connect() │
│ ├── 更新 StatusBar │
│ └── connected.emit(channel, dut_position) │
│ │
│ ⑥ 断开连接 _disconnect(): │
│ ├── channel.close() │
│ ├── 清空所有 client 引用 = None │
│ ├── 更新 StatusBar │
│ └── disconnected.emit() │
└─────────────────────────────────────────────────────────────────────────┘
_ConnectWorker 异步连接
class _ConnectWorker(QThread):
connect_success = Signal(object) # grpc.Channel
connect_failed = Signal(str)
def __init__(self, address: str, timeout: float = 5):
self._address = address
self._timeout = timeout
self._cancelled = False
def cancel(self) -> None:
"""取消连接 — 设置标志 + requestInterruption"""
self._cancelled = True
self.requestInterruption()
def run(self) -> None:
channel = grpc.insecure_channel(self._address)
grpc.channel_ready_future(channel).result(timeout=self._timeout)
if self._cancelled:
channel.close() # 取消时关闭
return
self.connect_success.emit(channel)
9 个 gRPC 客户端
| # | Client | 用途 |
|---|---|---|
| 1 | ConfigurationClient | 配置加载/设置 (Profile, DZ) |
| 2 | GPIOClient | GPIO 引脚控制 |
| 3 | ModeSelectClient | 模式选择 |
| 4 | TriggerClient | 触发控制 |
| 5 | StatusClient | 状态查询 |
| 6 | DutConnectionClient | DUT 连接管理 |
| 7 | PMBusClient | PMBus DC/DC 控制 |
| 8 | TspUartClient | TSP UART 配置 |
| 9 | ExternalAlarmClient | 外部告警 |
NIB 特殊处理
NIB (Network Interface Board) 不提供 gRPC 服务,通过 SSH 凭证管理连接:
if new_board_type == "NIB":
username, password = self._cred_store.load_credential(ip_part, 2222)
if not username:
username, password = self._cred_store.load_credential(ip_part, 22)
self._nib_username = username or "pi" # 默认值
self._nib_password = password or "nib"
self.nib_selected.emit(ip, username, password)
return # 不进行 gRPC 连接
Config Files 管理
[Config 下拉框] [New] [Save] [Save As] [Load]
_scan_config_files(): 扫描 products/*.yaml 填充下拉框
_on_cfg_new(): QInputDialog 输入文件名 → 创建空文件
_on_cfg_save(): 保存当前所有面板状态到选中的 yaml
_on_cfg_save_as(): SaveAs 对话框 → 新文件
_on_cfg_load(): 加载 yaml → 恢复所有面板状态
信号表
| Signal | 参数 | 触发时机 |
|---|---|---|
connected |
(channel, dut_position) | gRPC 连接建立后 |
disconnected |
— | 断开连接后 |
board_type_changed |
str | 板卡类型变化时 |
nib_selected |
(ip, username, password) | 选择 NIB 设备时 |
注意事项/常见坑
- 连接取消竞态:
_on_device_selected先cancel()旧 worker +wait(2000)再启动新连接 - Worker 生命周期: 使用
finished.connect(_on_connect_worker_finished)确保线程停止后才清理引用 - 去重策略: 扫描结果按 IP 部分去重 (
addr.split(":")[0]),同一 IP 不同端口只保留第一个 - Combo Popup 延迟:
QTimer.singleShot(100, showPopup)避免 ProgressBar 隐藏时布局抖动 - DutSpinCompat:
_DutSpinCompat类模拟 QSpinBox API,让外部代码无需关心 UI 细节变化


2.2 DCDC FW — DC/DC 固件管理面板

文件: ui/panels/dcdc_fw_panel.py — DCDC_FW_Panel(QWidget)
功能概述
通过 PMBus 协议控制 DC/DC 电源模块的模式切换、时钟频率配置和固件加载。所有操作通过 GrpcWorker 后台执行。
GrpcWorker 的 submit/result/error 模式
┌─────────────────────────────────────────────────────────────────┐
│ GrpcWorker(QThread) │
│ │
│ 核心架构: Queue + 后台循环 │
│ │
│ submit(operation_name, callable, *args, **kwargs): │
│ → self._queue.put((name, callable, args, kwargs)) │
│ │
│ run() 主循环: │
│ while not _stop_flag: │
│ item = self._queue.get(timeout=0.1) │
│ if item is _STOP_SENTINEL: break │
│ operation_started.emit(name) │
│ try: │
│ result = callable(*args, **kwargs) │
│ if not is_cancelled(): │
│ result_ready.emit(result) │
│ except Exception as exc: │
│ error_occurred.emit(f"{name}: {exc}") │
│ │
│ Signals: │
│ result_ready(object) → 操作成功 │
│ error_occurred(str) → 操作失败 │
│ operation_started(str) → 开始执行 │
│ │
│ cancel(): 设置 _cancel_flag, 结果将被丢弃 │
│ stop(): 设置 _stop_flag + 放入 SENTINEL 让循环退出 │
└─────────────────────────────────────────────────────────────────┘
三个操作调用
def _on_set_mode(self, mode_value: str) -> None:
"""PMBus 控制模式 (NORMAL/PROGRAMMING)"""
client = self._connection_panel.pmbus_client
self._show_busy()
self._worker.submit("Set PMBus Control Mode", client.set_control_mode, mode_value)
def _on_set_freq(self, freq_value: str) -> None:
"""PMBus 时钟频率 (100KHZ/400KHZ)"""
client = self._connection_panel.pmbus_client
self._show_busy()
self._worker.submit("Set PMBus Clock Frequency", client.set_clock_frequency, freq_value)
def _on_load_firmware(self) -> None:
"""加载 DC/DC 固件"""
client = self._connection_panel.pmbus_client
fw_folder = self._fw_path.text().strip()
self._show_busy()
self._worker.submit("Load DC/DC Firmware", client.load_dcdc_firmware, fw_folder)
Busy Indicator 管理
def _show_busy(self):
self._busy_bar.setVisible(True) # Indeterminate progress bar (range 0,0)
self._cancel_btn.setVisible(True)
def _hide_busy(self):
self._busy_bar.setVisible(False)
self._cancel_btn.setVisible(False)
def _on_cancel(self):
self._worker.cancel() # 设置 cancel flag, 结果被丢弃
self._hide_busy()
数据流图
┌── UI Thread ──────────────────────────────────────────────────────────┐
│ │
│ [Normal] click → _on_set_mode("NORMAL") → worker.submit(...) │
│ [Programming] click → _on_set_mode("PROGRAMMING") → worker.submit() │
│ [100KHz] click → _on_set_freq("100KHZ") → worker.submit(...) │
│ [400KHz] click → _on_set_freq("400KHZ") → worker.submit(...) │
│ [Load FW] click → _on_load_firmware() → worker.submit(...) │
│ │
│ result_ready → _on_result() → _hide_busy() + _log(success) │
│ error_occurred → _on_error() → _hide_busy() + _log(error) │
│ │
└───────────────────────│────────────────────────────────────────────────┘
│ Queue
▼
┌── GrpcWorker Thread ──────────────────────────────────────────────────┐
│ │
│ callable(*args) → pmbus_client.set_control_mode("NORMAL") │
│ → pmbus_client.set_clock_frequency("100KHZ") │
│ → pmbus_client.load_dcdc_firmware(fw_folder) │
│ │
│ (实际发送 gRPC protobuf 请求到接口板) │
└────────────────────────────────────────────────────────────────────────┘
2.3 TSP UART — UART 配置面板

文件: ui/panels/tsp_uart_panel.py — TspUartPanel(QWidget)
参数值到 Protobuf 枚举的映射
# Port Name
"DB_UART" → data value "DB_UART"
"OPB_UART" → data value "OPB_UART"
# Baudrate — QComboBox data 值直接映射 protobuf 枚举字符串
300 → "UART_BAUDRATE_BAUD_300"
600 → "UART_BAUDRATE_BAUD_600"
...
460800 → "UART_BAUDRATE_BAUD_460800" (默认)
921600 → "UART_BAUDRATE_BAUD_921600"
# Parity
"None" → "UART_PARITY_NONE"
"Even" → "UART_PARITY_EVEN"
"Odd" → "UART_PARITY_ODD"
"Mark" → "UART_PARITY_MARK"
"Space" → "UART_PARITY_SPACE"
# Databits
"5" → "UART_DATABITS_FIVE"
"6" → "UART_DATABITS_SIX"
"7" → "UART_DATABITS_SEVEN"
"8" → "UART_DATABITS_EIGHT" (默认)
# Stopbits
"1" → "UART_STOPBITS_ONE"
"2" → "UART_STOPBITS_TWO"
GrpcWorker.submit() 调用方式
def _on_send(self) -> None:
client = self._connection_panel.tsp_uart_client
if client is None:
self._log("Not connected.", logging.WARNING)
return
# 从 combo 的 currentData() 获取 protobuf 枚举字符串
name = self._name_combo.currentData() # "OPB_UART"
baudrate = self._baudrate_combo.currentData() # "UART_BAUDRATE_BAUD_460800"
parity = self._parity_combo.currentData() # "UART_PARITY_NONE"
databits = self._databits_combo.currentData() # "UART_DATABITS_EIGHT"
stopbits = self._stopbits_combo.currentData() # "UART_STOPBITS_ONE"
self._show_busy()
self._worker.submit(
"TspUartConfiguration",
client.tsp_uart_configuration,
name, baudrate, parity, databits, stopbits,
)
结果处理
@Slot(object)
def _on_result(self, result):
self._hide_busy()
if hasattr(result, 'status'):
if result.status.code == 0:
self._log(f"OK: {result.status.message}")
else:
self._log(f"Failed: code={result.status.code}", logging.ERROR)
三、Terminal 视图
文件: ui/views/terminal_view.py — TerminalView(QWidget)
功能概述
MobaXterm 风格的多协议终端管理器,支持 SSH、Telnet、Serial、GPIB、SFTP、WSL、LocalShell 7 种协议。提供会话树、命令矩阵、多标签管理、Multi-Exec 广播、区域折叠等高级功能。
四区域布局

┌─────────────────────────────────────────────────────────────────────┐
│ [TopToolbar: New|Duplicate|Split V|Split H|MultiExec] │
│ [QuickConnectBar: Protocol|Host|Port|User|Pass|Connect] [Search] │
├────┬────────────────────────────────────────────────────────────────┤
│ ◀ │ ┌─ StyledTabBar ────────────────────────────────────────────┐│
│ │ │ ●1.SSH ●2.Telnet ●3.Serial ●4.GPIB ││
│ 会 │ ├──────────────────────────────────────────────────────────┤│
│ 话 │ │ ││
│ 树 │ │ VT100 Terminal Output Area ││
│ │ │ (带 ANSI 颜色、滚动、搜索高亮) ││
│ Q │ │ ││
│ u │ └──────────────────────────────────────────────────────────┘│
│ i ├────────────────────────────────────────────────────────────────┤
│ c │ ▼ [CommandMatrix: 多组 4×7 命令按钮 + Save/Load/New] │
│ k │ [cmd1] [cmd2] [cmd3] [cmd4] [cmd5] [cmd6] [cmd7] │
│ │ [cmd8] ... │
└────┴────────────────────────────────────────────────────────────────┘
Tab 创建流程
# 统一入口: _create_tab_from_config(config: dict)
# config 示例: {"protocol": "ssh", "host": "192.168.2.61", "port": 22, ...}
def _create_tab_from_config(self, config: dict) -> None:
protocol = config.get("protocol", "ssh")
if protocol == "ssh":
tab = self.create_ssh_tab(default_ip=host)
tab._ip.setText(host)
tab._port.setText(str(port))
tab._user.setText(username)
tab._pw.setText(password)
tab._connect() # 自动连接
elif protocol == "telnet":
tab = self.create_telnet_tab(...)
elif protocol == "serial":
tab = self.create_serial_tab(...)
elif protocol == "gpib":
tab = self.create_gpib_tab(...)
elif protocol == "sftp":
tab = self.create_sftp_tab(...)
elif protocol == "wsl":
self.create_wsl_tab(shell=f"wsl -d {distro}")
elif protocol in ("bash", "cmd", "powershell"):
self.create_local_shell_tab(shell=cmd, shell_type=protocol)
每种 Tab 创建的标准流程:
create_xxx_tab(name, default_ip):
① self._tab_counter += 1
② tab = _XXXTab(connection_panel, ...)
③ idx = self._tab_widget.addTab(tab, name)
④ self._tab_widget.setCurrentIndex(idx)
⑤ self._styled_tab_bar.set_tab_protocol_color(idx, PROTOCOL_COLORS["xxx"])
⑥ self._styled_tab_bar.set_tab_connected(idx, False)
⑦ self._connect_tab_search(tab) # 连接终端搜索信号
⑧ return tab
支持的协议
| 协议 | Tab 类 | Worker | 通信方式 | 依赖 |
|---|---|---|---|---|
| SSH | _SSHTab |
ssh_worker.py |
paramiko TCP 22 | paramiko |
| Telnet | _TelnetTab |
telnet_worker.py |
telnetlib TCP 23/3000 | — |
| Serial | _SerialTab |
serial_worker.py |
pyserial COM 端口 | pyserial (可选) |
| GPIB | _GPIBTab |
gpib_worker.py |
pyvisa SCPI | pyvisa (可选) |
| SFTP | _SFTPTab |
sftp_worker.py |
paramiko SFTP | paramiko |
| WSL | _LocalShellTab |
local_shell_worker.py |
subprocess (wsl) | — |
| LocalShell | _LocalShellTab |
local_shell_worker.py |
subprocess | — |
MultiExecController 广播机制
class MultiExecController:
"""多标签命令广播控制器"""
def toggle(self, active: bool):
"""开启/关闭广播模式"""
# active=True: 后续命令发送到所有已连接的 Tab (除 SFTP)
# active=False: 仅发送到当前 Tab
# 广播逻辑:
# 1. 源 Tab 输入命令
# 2. Controller 遍历所有 Tab
# 3. 跳过: 未连接的 Tab, SFTP Tab, 源 Tab 自身
# 4. 对每个目标 Tab 调用 execute_commands()
# 5. 失败时: broadcast_failed.emit(tab_index, error_msg)
# → StyledTabBar 标红该 Tab 的状态点
# UI 反馈:
_on_multi_exec_mode_changed(active):
for each tab (except source):
if connected and not SFTP:
_styled_tab_bar.set_broadcast_overlay(i, True) # 蓝色广播指示
Session 持久化/加载
文件: configuration/terminal/sessions.json
def _save_sessions(self) -> None:
"""保存所有 Tab 的连接设置"""
tabs = []
for i in range(self._tab_widget.count()):
tab = self._tab_widget.widget(i)
d = tab.get_settings() # 每个 Tab 类实现此方法
# 使用 StyledTabBar 的 base_name 避免保存 "1. SSH #1" 这样的编号前缀
d["name"] = base_name or tab_text
tabs.append(d)
json.dump({"tabs": tabs}, f)
def _load_sessions(self) -> None:
"""程序启动时恢复 Tab"""
data = json.load(f)
for td in data["tabs"]:
kind = td.get("type", "ssh")
tab = self.create_xxx_tab(name=td["name"])
tab.load_settings(td) # 恢复连接参数 (不自动连接)
CommandMatrix 命令执行
_on_matrix_exec(cmds: list):
→ 获取当前 Tab
→ tab.execute_commands(cmds)
→ (MultiExec 模式下) 同时广播到其他 Tab
命令矩阵持久化:
Save: json.dump({"matrix": matrix.get_settings()}, "commands.json")
Load: matrix.load_settings(data["matrix"])
New: 创建空配置 [{"name": "Default", "cells": []}]
SaveAs: 自定义路径保存
StyledTabBar 协议颜色和状态管理
PROTOCOL_COLORS = {
"ssh": "#22C55E", # 绿色
"telnet": "#3B82F6", # 蓝色
"serial": "#F59E0B", # 琥珀色
"gpib": "#8B5CF6", # 紫色
"sftp": "#EC4899", # 粉色
"wsl": "#F97316", # 橙色
"localshell": "#6B7280", # 灰色
}
# 每个 Tab 有:
# - 协议颜色条 (左侧 3px 竖线)
# - 状态点 (●绿=连接, ●红=断开/错误)
# - 广播覆盖层 (MultiExec 模式下的蓝色指示)
# - 编号前缀 ("1. SSH #1")
区域折叠
def _toggle_left_sidebar(self):
"""左侧会话树折叠/展开"""
self._left_visible = not self._left_visible
if self._left_visible:
self._left_sidebar_widget.show()
sizes[0] = self._left_saved_size # 恢复之前宽度
else:
self._left_saved_size = sizes[0] # 保存当前宽度
self._left_sidebar_widget.hide()
sizes[0] = 0
def _toggle_bottom_panel(self):
"""底部命令矩阵折叠/展开 (同理)"""
注意事项/常见坑
- 可选依赖检测:
HAS_PYSERIAL/HAS_PYVISA在模块加载时检测,缺失时禁用对应按钮 - SFTP Transport 复用:
_find_active_ssh_transport(host, port)遍历已连接的 SSH Tab 复用 transport - Tab 关闭顺序: 先断开 worker (disconnect_ssh/telnet/serial/gpib/sftp/stop),再 removeTab
- Tab 重命名持久化: rename 后立即
_save_sessions()确保不丢失 - 搜索功能: Ctrl+F 打开搜索栏,支持 prev/next 导航,高亮 VT100 终端中的匹配文本
- Quick Copy: 全局开关,启用后选中文本自动复制到剪贴板
- Split Pane: 最多 4 个窗格,达到上限时禁用 Split 按钮并更新 tooltip

四、Tools 视图
文件: ui/views/tools_view.py — ToolsView(QWidget)
包含 2 个 Tab 页:DHCP Server 和 IP Scanner。
4.1 DHCP Server — DHCP 服务器面板

文件: ui/panels/dhcp_server_panel.py — DHCPServerPanel(QWidget)
功能概述
基于 scapy 的静态 DHCP 服务器,使用 MAC → IP 映射表为设备分配固定 IP。用于生产环境中 DUT 的网络初始化。
完整工作流
用户操作 系统行为
───────────────────────────────────────────────────────────────────
1. 配置服务器参数 ← 程序启动时从 dhcp_server.json 加载
2. 管理 MAC→IP 映射表 → _add_table_row() / _on_remove_row()
3. 点击 "保存配置" → _on_save_config()
│ ├── _collect_config_from_ui() → DHCPServerConfig
│ └── config.save(DEFAULT_CONFIG_PATH)
│ → json.dump 到 configuration/dhcp_server.json
│
4. 点击 "启动 DHCP Server" → _on_start()
│ ├── _collect_config_from_ui() + save
│ ├── 验证 leases 非空
│ ├── 创建 DHCPServer(config, on_event=callback)
│ │ └── DHCPServer 内部:
│ │ - threading.Thread 启动 scapy sniff
│ │ - 监听 DHCP DISCOVER/REQUEST
│ │ - 按 MAC→IP 映射回复 OFFER/ACK
│ │ - 租约时间: 0xFFFFFFFF (永久)
│ └── 更新 UI: 禁用 Start, 启用 Stop
│
5. DUT 发送 DHCP DISCOVER → Server 线程处理:
│ ├── 查找 MAC 在映射表中的 IP
│ ├── 发送 DHCP OFFER
│ └── on_event("DISCOVER from aa:bb:cc:dd:ee:ff")
│ → _log_signal.emit(msg)
│ → QueuedConnection → _append_log(msg)
│
6. 点击 "停止" → _on_stop()
├── server.stop() → 停止 sniff 线程
└── 更新 UI: 启用 Start, 禁用 Stop
线程安全日志更新机制
class DHCPServerPanel(QWidget):
# 自定义 Signal 用于跨线程日志
_log_signal = Signal(str)
def __init__(self):
# 使用 QueuedConnection 确保跨线程安全
self._log_signal.connect(self._append_log, Qt.ConnectionType.QueuedConnection)
def _on_server_event(self, msg: str) -> None:
"""DHCPServer 后台线程回调 — 不能直接操作 GUI"""
self._log_signal.emit(msg) # emit Signal → Qt 事件循环排队
@Slot(str)
def _append_log(self, msg: str) -> None:
"""在 GUI 线程中安全更新日志 (通过 QueuedConnection 保证)"""
self._log_edit.append(msg)
DHCPServer 启动参数
class DHCPServerConfig:
server_ip: str # "192.168.1.1"
subnet_mask: str # "255.255.255.0"
interface: str # 网络接口名称 (get_available_interfaces())
leases: dict[str, str] # {"aa:bb:cc:dd:ee:ff": "192.168.1.100", ...}
self._server = DHCPServer(
config=self._config,
on_event=self._on_server_event, # 跨线程回调
)
self._server.start() # 启动后台 sniff 线程
配置保存/加载
# 保存: DHCPServerConfig.save(DEFAULT_CONFIG_PATH)
# 加载: DHCPServerConfig.load(DEFAULT_CONFIG_PATH)
# 格式: JSON (configuration/dhcp_server.json)
{
"server_ip": "192.168.1.1",
"subnet_mask": "255.255.255.0",
"interface": "以太网",
"leases": {
"aa:bb:cc:dd:ee:ff": "192.168.1.100",
"11:22:33:44:55:66": "192.168.1.101"
}
}
注意事项/常见坑
- scapy 权限: 需要管理员/root 权限运行 raw socket sniff
- 永久租约:
lease_time = 0xFFFFFFFF表示永不过期,适合固定设备 - closeEvent 清理: 面板关闭时自动停止 server
- 接口检测:
get_available_interfaces()列举系统网络接口供选择 - 线程安全关键: 绝对不能在
_on_server_event回调中直接操作 Qt 控件
4.2 IP Scanner — 网络扫描器

文件: ui/widgets/ip_scanner_widget.py — IPScannerWidget(QWidget)
功能概述
扫描指定 IP 范围内的在线设备,检测开放端口和协议,支持 Deep Scan 和 CSV 导出。
输入验证逻辑(红色边框错误提示)
def _start_scan(self) -> None:
error_style = "border: 2px solid red;"
ok_style = ""
# 重置所有字段样式
for field in (octet1, octet2, octet3, start, end):
field.setStyleSheet(ok_style)
# 逐字段验证:
# - 空字段 → 红色边框
# - 超范围值 → 红色边框 (octets: 0-255, host: 1-254)
# - start > end → 两个字段都标红
# - 任何验证失败 → status_label 显示错误, return
IPScanWorker 的两阶段扫描
┌─────────────────────────────────────────────────────────────────────┐
│ IPScanWorker(QThread) │
│ │
│ 输入: ip_list = ["192.168.2.1", "192.168.2.2", ..., "192.168.2.254"]│
│ 并发: ThreadPoolExecutor(max_workers=512) │
│ │
│ 对每个 IP 执行: │
│ │
│ 阶段 1: TCP 端口探测 (快速路径) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 并行探测 11 个端口 (100ms 超时): │ │
│ │ FTP(21), SSH(22), Telnet(23), SMTP(25), DNS(53), │ │
│ │ HTTP(80), HTTPS(443), RDP(3389), VNC(5900), │ │
│ │ MySQL(3306), Redis(6379) │ │
│ │ │ │
│ │ 任何端口响应 → Online! (最快 ~100ms) │ │
│ │ 收集所有开放端口对应的协议名 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 阶段 2: ICMP Ping 回退 (仅当 TCP 无响应时) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ping(ip, timeout=150ms) │ │
│ │ 响应 → Online (无协议信息) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 阶段 3: DNS 反向解析 (仅在线主机) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ socket.gethostbyaddr(ip) │ │
│ │ daemon thread, 200ms 硬超时 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ Signals: │
│ scan_progress(current, total) → 更新 ProgressBar │
│ device_found(dict) → 实时添加表格行 │
│ scan_complete(devices_list) → 最终排序 + 统计 │
│ │
│ 总扫描时间: ~2.5 秒 (254 个 IP, 512 并发) │
└─────────────────────────────────────────────────────────────────────┘
结果排序算法(数值 IP 排序)
def _on_scan_complete(self, devices):
"""扫描完成后按 IP 数值排序 (非字符串排序)"""
rows = [...] # 从表格中提取所有行数据
def _ip_sort_key(row_data):
try:
parts = row_data[0].split(".") # IP 在第 0 列
return tuple(int(p) for p in parts) # (192, 168, 2, 1)
except (ValueError, IndexError):
return (999, 999, 999, 999) # 无效 IP 排最后
rows.sort(key=_ip_sort_key)
# 重建表格行...
# 注意: 不启用 Qt 自动排序 (字符串排序会把 "192.168.2.9" 排在 "192.168.2.10" 后面)
self._result_table.horizontalHeader().setSortIndicator(0, Qt.AscendingOrder)
DeepScanDialog 端口扫描
class DeepScanDialog(QDialog):
"""对单个 IP 深度扫描指定端口范围"""
def _start_scan(self):
parsed = parse_port_range(self._port_range_input.text()) # "1-1024" → (1, 1024)
# 验证失败 → QMessageBox.warning
self._worker = DeepScanWorker(self._ip, start_port, end_port)
self._worker.deep_scan_progress.connect(self._on_progress)
self._worker.deep_scan_complete.connect(self._on_complete)
self._worker.start()
def _on_complete(self, ip, results):
for result in results:
if result.get("open"):
# 显示: Port | Protocol | Status(Open)
CSV 导出
def _export_csv(self):
path, _ = QFileDialog.getSaveFileName(self, "Export to CSV", "", "CSV (*.csv)")
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["IP Address", "Hostname", "Protocols", "Status"])
for row in range(table.rowCount()):
writer.writerow([table.item(row, col).text() for col in range(4)])
数据流图
┌── UI Thread ──────────────────────────────────────────────────────┐
│ │
│ [Scan] → 验证输入 → build_ip_list() → IPScanWorker.start() │
│ │
│ device_found(dict) → _on_device_found() → 追加表格行 (实时) │
│ scan_progress(n, total) → ProgressBar 更新 │
│ scan_complete(devices) → IP 数值排序 → 统计信息 │
│ │
│ [Double-click] → ip_selected.emit(ip) │
│ [Right-click > Deep Scan] → DeepScanDialog │
│ [Right-click > Export CSV] → _export_csv() │
│ │
└───────────────────────│────────────────────────────────────────────┘
│
▼
┌── IPScanWorker Thread ────────────────────────────────────────────┐
│ ThreadPoolExecutor(512 workers) │
│ ├── TCP probe × 11 ports (100ms timeout each) │
│ ├── ICMP ping fallback (150ms timeout) │
│ └── DNS reverse lookup (200ms timeout, daemon thread) │
└────────────────────────────────────────────────────────────────────┘
注意事项/常见坑
- QIntValidator: 每个 octet 输入框使用
QIntValidator(0, 255),host 使用(1, 254) - 隐藏兼容 shim:
self._ip_range_input是一个隐藏的 QLineEdit,为测试代码提供兼容接口 - 不启用表格排序:
setSortingEnabled(False)防止 Qt 字符串排序破坏数值 IP 顺序 - Worker 取消:
requestInterruption()设置中断标志,Worker 在循环中检查isInterruptionRequested() - Worker 引用清理:
finished.connect(_on_worker_finished)中self._scan_worker = None防止内存泄漏 - 512 并发: 使用 Python ThreadPoolExecutor 而非 QThread,适合大量 I/O 密集型任务

五、通用设计模式总结
5.1 线程模型
本项目使用三种线程模式:
| 模式 | 使用场景 | 代表类 |
|---|---|---|
| 命令队列 Worker | 需要串行执行多种操作 | GrpcWorker, _PowerMeterWorker |
| 单次任务 Worker | 一次性异步操作 | _ConnectWorker, _DownloadWorker, ScanWorker |
| 长驻服务 Worker | 持续运行的后台服务 | TxPowerCalWorker, BootupRunner |
5.2 Signal/Slot 连接模式
# 标准模式: Worker Signal → UI Slot
worker.result_ready.connect(self._on_result)
worker.error_occurred.connect(self._on_error)
# 跨线程安全模式 (显式 QueuedConnection):
self._log_signal.connect(self._append_log, Qt.ConnectionType.QueuedConnection)
# Debounce 模式 (QTimer.singleShot):
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.setInterval(500)
input.valueChanged.connect(lambda: self._save_timer.start())
5.3 配置持久化策略
| 面板 | 存储方式 | 文件路径 |
|---|---|---|
| DUT Run Test | QSettings | Windows Registry / ini |
| Product Management | JSON | configuration/products.json |
| DUT Simulator | YAML | configuration/dut_simulator_cache.yaml |
| DHCP Server | JSON | configuration/dhcp_server.json |
| Terminal Sessions | JSON | configuration/terminal/sessions.json |
| Command Matrix | JSON | configuration/terminal/commands.json |
| Connection Config | YAML | products/*.yaml |
5.4 错误处理模式
# 模式 1: GrpcWorker 统一捕获
try:
result = callable(*args)
result_ready.emit(result)
except Exception as exc:
error_occurred.emit(f"{op_name}: {exc}")
# 模式 2: 连接超时 + 取消
channel_ready_future(channel).result(timeout=5)
if self._cancelled:
channel.close()
return
# 模式 3: 协作式停止
if self.is_stop_requested():
return # 在每个步骤间检查
# 模式 4: 强制终止回退
if not runner.wait(2000): # 等 2 秒
runner.terminate() # 强制杀死
runner.wait(1000) # 等清理
5.5 UI 状态管理模式
# Busy indicator 模式 (DCDC FW / TSP UART):
_show_busy() → 操作前显示 indeterminate ProgressBar + Cancel
_hide_busy() → result/error 回调中隐藏
# 按钮互斥模式 (Start/Stop):
_on_start(): start_btn.setEnabled(False); stop_btn.setEnabled(True)
_on_stop(): start_btn.setEnabled(True); stop_btn.setEnabled(False)
# 动态字段可见性模式 (Run Test):
_restart_mode_visible_fields = {
RestartMode.REPLACE_RADIOAPP_SWDB.value: {"elf", "swdb"},
RestartMode.REPLACE_DC.value: {"dc"},
...
}
# 根据映射表动态 setVisible() 相关控件
六、文件索引
| 视图/Tab | 文件路径 | 行数 | 主要类 |
|---|---|---|---|
| DUT Test / Run Test | ui/panels/dut_test_view.py |
~1500 | DUTRunTestPanel |
| DUT Test / Product Mgmt | ui/panels/product_management_panel.py |
~1350 | ProductManagementPanel, _ProductDialog |
| TX Power Cal / Service | ui/panels/tx_power_cal_service_panel.py |
~350 | TxPowerCalServicePanel |
| TX Power Cal / Simulator | ui/panels/dut_simulator_panel.py |
~500 | DutSimulatorPanel |
| TX Power Cal / Viewer | ui/panels/power_viewer_panel.py |
~500 | PowerViewerPanel, _PowerMeterWorker |
| TX Power Cal View | ui/panels/tx_power_cal_view.py |
~100 | TxPowerCalView |
| Connection | ui/panels/connection_panel.py |
~600 | ConnectionPanel, _ConnectWorker |
| DCDC FW | ui/panels/dcdc_fw_panel.py |
~250 | DCDC_FW_Panel |
| TSP UART | ui/panels/tsp_uart_panel.py |
~230 | TspUartPanel |
| Terminal | ui/views/terminal_view.py |
~1200 | TerminalView |
| DHCP Server | ui/panels/dhcp_server_panel.py |
~250 | DHCPServerPanel |
| IP Scanner | ui/widgets/ip_scanner_widget.py |
~500 | IPScannerWidget, DeepScanDialog |
| GrpcWorker (通用) | ui/workers/grpc_worker.py |
~120 | GrpcWorker |
| BootupRunner | ui/workers/bootup_runner.py |
~350 | BootupRunner |

浙公网安备 33010602011771号