UI

UI 面板详细功能说明

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


一、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.pyDUTRunTestPanel(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.pyBootupRunner(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 值

注意事项/常见坑

  1. 路径 sys.path 注入: 面板头部显式将 dut_test/ 加入 sys.path,因为 dut_test 包内部使用 from common.xxx 相对导入
  2. Log Handler 泄漏: _on_start_clicked() 开头必须清理旧的 _SignalLogHandler,否则多次 Start 后日志会重复
  3. PPAR/ProdDB 联动: 这些路径字段仅在 EXTERNAL boot mode 时启用,且需要 checkbox 勾选
  4. Fetch Remote Files 阻塞保护: 使用独立 _DownloadWorker(QThread) 避免阻塞 UI,下载完后在 UI 线程回调中执行 _auto_detect_local_files()
  5. Slot 值特殊处理: External 模式下 slot_spin 显示 "N/A"(使用 setSpecialValueText),实际传递 minimum 值
  6. Power 控制独立线程: _on_power_on/off 创建 _PowerWorker(QThread) 避免 GPIB 通信阻塞 UI

1.2 Product Management — 产品管理面板

文件: ui/panels/product_management_panel.pyProductManagementPanel(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 布局

image

_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()      │
│                                                                          │
└──────────────────────────────────────────────────────────────────────────┘

注意事项/常见坑

  1. 密码 Base64 编码: SSH 密码使用 encode_password() 编码存储,读取时需 decode_password() 解码
  2. _NoScrollComboBox / _NoScrollSpinBox: 防止用户滚动时意外修改值(覆盖 wheelEventevent.ignore()
  3. Legacy 路径兼容: 首次加载时检查 custom_products.json 遗留文件,合并到 products.json
  4. 表格排序禁用: 刻意 setSortingEnabled(False) 保持拖拽顺序稳定
  5. 去重验证: _is_duplicate_name() 在 Add/Edit/Clone 时防止产品名重复

1.3 TX Power Cal — TX 功率校准

文件: ui/panels/tx_power_cal_view.pyTxPowerCalView(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

image

文件: ui/panels/tx_power_cal_service_panel.pyTxPowerCalServicePanel(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

image

文件: ui/panels/dut_simulator_panel.pyDutSimulatorPanel(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

image

文件: ui/panels/power_viewer_panel.pyPowerViewerPanel(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
注意事项/常见坑
  1. deque 线程安全: Python deque.append() 是原子操作,Worker 线程 emit → UI 线程 append 无需额外锁
  2. 硬件节拍: Continuous 模式下 read_power() 的速率完全由硬件 aperture 时间决定,无软件 sleep
  3. NRPPowerMeter 导入路径: 需要将 tx_output_power_cal_service/ 加入 sys.path
  4. 连续 Trace 模式: Trace 采集完成后使用 QTimer.singleShot(0, self._do_trace_capture) 立即排队下次采集
  5. Settings 实时推送: _on_settings_changed() 在运行中实时向 Worker 队列发送 update_settings 命令

image
image

二、InterfaceBoard Test 视图

InterfaceBoard Test 是多 Tab 面板,包含 Connection、Configuration、GPIO、Firmware Update、DZ Loading、DCDC FW、TSP UART、Test Execution。


2.1 Connection — 设备连接面板

image

文件: ui/panels/connection_panel.pyConnectionPanel(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 设备时

注意事项/常见坑

  1. 连接取消竞态: _on_device_selectedcancel() 旧 worker + wait(2000) 再启动新连接
  2. Worker 生命周期: 使用 finished.connect(_on_connect_worker_finished) 确保线程停止后才清理引用
  3. 去重策略: 扫描结果按 IP 部分去重 (addr.split(":")[0]),同一 IP 不同端口只保留第一个
  4. Combo Popup 延迟: QTimer.singleShot(100, showPopup) 避免 ProgressBar 隐藏时布局抖动
  5. DutSpinCompat: _DutSpinCompat 类模拟 QSpinBox API,让外部代码无需关心 UI 细节变化

image
image

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

image

文件: ui/panels/dcdc_fw_panel.pyDCDC_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 配置面板

image

文件: ui/panels/tsp_uart_panel.pyTspUartPanel(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.pyTerminalView(QWidget)

功能概述

MobaXterm 风格的多协议终端管理器,支持 SSH、Telnet、Serial、GPIB、SFTP、WSL、LocalShell 7 种协议。提供会话树、命令矩阵、多标签管理、Multi-Exec 广播、区域折叠等高级功能。

四区域布局

image

┌─────────────────────────────────────────────────────────────────────┐
│  [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):
    """底部命令矩阵折叠/展开 (同理)"""

注意事项/常见坑

  1. 可选依赖检测: HAS_PYSERIAL / HAS_PYVISA 在模块加载时检测,缺失时禁用对应按钮
  2. SFTP Transport 复用: _find_active_ssh_transport(host, port) 遍历已连接的 SSH Tab 复用 transport
  3. Tab 关闭顺序: 先断开 worker (disconnect_ssh/telnet/serial/gpib/sftp/stop),再 removeTab
  4. Tab 重命名持久化: rename 后立即 _save_sessions() 确保不丢失
  5. 搜索功能: Ctrl+F 打开搜索栏,支持 prev/next 导航,高亮 VT100 终端中的匹配文本
  6. Quick Copy: 全局开关,启用后选中文本自动复制到剪贴板
  7. Split Pane: 最多 4 个窗格,达到上限时禁用 Split 按钮并更新 tooltip

image

四、Tools 视图

文件: ui/views/tools_view.pyToolsView(QWidget)

包含 2 个 Tab 页:DHCP Server 和 IP Scanner。


4.1 DHCP Server — DHCP 服务器面板

image

文件: ui/panels/dhcp_server_panel.pyDHCPServerPanel(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"
    }
}

注意事项/常见坑

  1. scapy 权限: 需要管理员/root 权限运行 raw socket sniff
  2. 永久租约: lease_time = 0xFFFFFFFF 表示永不过期,适合固定设备
  3. closeEvent 清理: 面板关闭时自动停止 server
  4. 接口检测: get_available_interfaces() 列举系统网络接口供选择
  5. 线程安全关键: 绝对不能在 _on_server_event 回调中直接操作 Qt 控件

4.2 IP Scanner — 网络扫描器

image

文件: ui/widgets/ip_scanner_widget.pyIPScannerWidget(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)           │
└────────────────────────────────────────────────────────────────────┘

注意事项/常见坑

  1. QIntValidator: 每个 octet 输入框使用 QIntValidator(0, 255),host 使用 (1, 254)
  2. 隐藏兼容 shim: self._ip_range_input 是一个隐藏的 QLineEdit,为测试代码提供兼容接口
  3. 不启用表格排序: setSortingEnabled(False) 防止 Qt 字符串排序破坏数值 IP 顺序
  4. Worker 取消: requestInterruption() 设置中断标志,Worker 在循环中检查 isInterruptionRequested()
  5. Worker 引用清理: finished.connect(_on_worker_finished)self._scan_worker = None 防止内存泄漏
  6. 512 并发: 使用 Python ThreadPoolExecutor 而非 QThread,适合大量 I/O 密集型任务

image

五、通用设计模式总结

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
posted @ 2026-07-17 08:11  mo686  阅读(6)  评论(0)    收藏  举报