feat: init

This commit is contained in:
2026-06-16 10:07:15 +08:00
Unverified
commit 009ea92e5f
28 changed files with 2051 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
dist/
build/
*.egg-info/
records/
+1
View File
@@ -0,0 +1 @@
3.12
+78
View File
@@ -0,0 +1,78 @@
# line-laser-hmi
线激光视觉设备与运动控制器的 **Qt 上位机(HMI**,基于 `line-laser-modbus` 协议库,提供连接管理、模式控制、状态与位姿监控、实时曲线、手动下发和数据录制。
设计文档见 [docs/design.md](docs/design.md),协议文档见 [line-laser-modbus/docs/proto.md](https://git.pchuan.top/SuperLaser/line-laser-modbus/src/branch/main/docs/proto.md)。
## 功能
- 串口连接配置与自动枚举 COM 口,**模拟模式**无需硬件即可运行
- 6 种工作模式切换(按协议状态机校验)与独立急停按钮
- 设备状态字、时间戳、通信超时实时监控
- 当前 6 轴位姿数码显示与实时趋势曲线
- 手动下发目标示教位姿 / 纠偏量,设置在线跟踪自动纠偏目标
- 50ms 后台轮询(独立线程,不阻塞界面),连接成功后自动启动
- 快照录制为 CSV,通信日志独立窗口(菜单「视图 → 日志窗口」,Ctrl+L)可导出
## 安装与运行
```powershell
python -m uv sync --default-index https://mirrors.ustc.edu.cn/pypi/simple
python -m uv run line-laser-hmi
```
或:
```powershell
python -m uv run python -m line_laser_hmi
```
首次启动默认勾选「模拟模式」,直接点击「连接」即可(连接成功后自动开始轮询),位姿与曲线立即动起来。
> `--default-index` 仅用于把 `PySide6`、`pymodbus` 等公网依赖切到国内镜像加速,协议库走本地路径,不经过镜像。
## 协议库依赖(line-laser-modbus
本项目**不从 PyPI 安装**协议库,而是以 **uv 路径依赖** 方式引用同仓库 `line-laser-modbus``pyproject.toml` 中两段配合:
```toml
[project]
dependencies = [
"line-laser-modbus", # 声明依赖(包名取自 $dir/pyproject.toml 的 [project].name
"PySide6>=6.7",
"pyqtgraph>=0.13.7",
"pyserial>=3.5",
]
[tool.uv.sources]
line-laser-modbus = { path = "../xxx" } # 指明从本地 ../xxx 目录安装,而非 PyPI
```
`python -m uv sync` 时 uv 会把 `../xxx` 作为本地包(可编辑安装)装入 `.venv`
## 连接真实设备
取消「模拟模式」,在连接面板选择串口、确认波特率/从站地址,或先修改根目录 `config.toml`
```toml
[serial]
port = "COM3"
slave_id = 8
baudrate = 115200
[polling]
interval_seconds = 0.05
[ui]
simulate = false
record_dir = "records"
```
## 测试
非 GUI 逻辑(配置、录制、模拟后端)可无显示运行:
```powershell
python -m uv run pytest
python -m uv run ruff check
```
+17
View File
@@ -0,0 +1,17 @@
[serial]
port = "COM1"
slave_id = 8
baudrate = 115200
bytesize = 8
parity = "N"
stopbits = 1
timeout = 0.15
retries = 3
[polling]
interval_seconds = 0.05
max_timeouts = 3
[ui]
simulate = true
record_dir = "records"
+60
View File
@@ -0,0 +1,60 @@
# 线激光 Modbus 上位机 GUI 设计
本程序是 [`line-laser-modbus`](../../py) 协议库的 Qt 上位机(HMI),用于操作与监控运动控制器。协议细节见库内 [`docs/proto.md`](../../py/docs/proto.md)。
## 1. 技术选型
| 项目 | 选择 | 说明 |
| ---- | ---- | ---- |
| GUI 框架 | PySide6 | Qt 官方绑定,LGPL,兼容 Python 3.12 |
| 实时曲线 | pyqtgraph | 高刷新率的科学绘图 |
| 协议层 | line-laser-modbus | 以路径依赖方式引用同仓库 `py` 包 |
| 串口枚举 | pyserial `list_ports` | 自动检测本机 COM 口 |
## 2. 线程模型
Modbus 客户端是阻塞式同步调用,绝不能在 GUI 线程执行,否则界面卡死。
- `ModbusWorker(QObject)` 持有 `LineLaserClient`,被 `moveToThread` 移入独立 `QThread`
- GUI 线程通过主窗口的请求信号(`requestConnect``requestSwitchMode` 等)以队列连接方式投递到 worker 槽。
- worker 通过结果信号(`connected``snapshotReady``errorOccurred` 等)回传,GUI 线程刷新界面。
- 轮询用 worker 线程内的 `QTimer` 周期触发 `PollingRunner.run_once`,避免无限循环阻塞线程。
```
GUI 线程 --请求信号(队列)--> ModbusWorker(子线程) --结果信号(队列)--> GUI 线程
|
+-- LineLaserClient / PollingRunner / QTimer
```
## 3. 功能面板
| 面板 | 模块 | 对应协议库能力 |
| ---- | ---- | -------------- |
| 连接 | `panels/connection_panel.py` | `SerialConfig`、模拟后端开关 |
| 模式控制 | `panels/mode_panel.py` | `switch_mode`(状态机校验)、急停 `write_mode(5)` |
| 状态监控 | `panels/status_panel.py` | `read_status`、时间戳、超时计数 |
| 当前位姿 | `panels/pose_panel.py` | `read_current_pose` |
| 轮询与录制 | `panels/polling_panel.py` | `PollingRunner`、CSV 录制 |
| 手动下发 | `panels/manual_panel.py` | `write_target_pose``write_correction`、跟踪目标 |
| 实时曲线 | `panels/chart_panel.py` | 6 轴位姿趋势 |
| 日志窗口 | `panels/log_panel.py``LogWindow` | 通信/错误记录、导出;由菜单「视图 → 日志窗口」(Ctrl+L) 打开 |
界面使用浅色 Fusion 主题(`app.apply_light_theme`),不跟随系统暗色。连接成功后会**自动启动轮询**,位姿数码显示与曲线立即刷新,无需手动点击。
## 4. 模拟模式
为满足无硬件演示,`backend.py` 提供 `DynamicSimulatedBackend`
- 读取当前位姿时按正弦规律围绕基准位姿摆动,使数码显示与曲线动起来;
- 写入模式命令时联动刷新设备状态字(如进入在线跟踪后状态变为 `TRACKING_OK`)。
勾选连接面板的「模拟模式」即可在没有串口和控制器的机器上完整体验所有功能。
## 5. 状态机与安全
- 普通模式切换走 `switch_mode`,非法切换由协议库抛错并在日志提示。
- 急停按钮走 `force_mode``write_mode`,不做状态机校验),保证任意状态下可立即下发急停。
## 6. 数据录制
`recorder.py``SnapshotRecorder` 将每帧快照追加为 CSV`wall_time, device_timestamp, mode, status, x..c`,文件按时间戳命名保存在 `record_dir`
+39
View File
@@ -0,0 +1,39 @@
[project]
name = "line-laser-hmi"
version = "0.1.0"
description = "线激光视觉设备运动控制器的 Qt 上位机 GUI,基于 line-laser-modbus 协议库"
readme = "README.md"
requires-python = ">=3.12,<3.13"
dependencies = [
"line-laser-modbus",
"PySide6>=6.7",
"pyqtgraph>=0.13.7",
"pyserial>=3.5",
]
[project.scripts]
line-laser-hmi = "line_laser_hmi.app:main"
[tool.uv.sources]
line-laser-modbus = { path = "../py" }
[dependency-groups]
dev = [
"pytest>=9.0.1",
"ruff>=0.14.6",
]
[build-system]
requires = ["uv_build>=0.11.14,<0.12.0"]
build-backend = "uv_build"
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.pytest.ini_options]
addopts = "-q"
testpaths = ["tests"]
+10
View File
@@ -0,0 +1,10 @@
"""线激光 Modbus 上位机 GUI 包"""
from line_laser_hmi.settings import UiConfig, load_config, save_config
# 公开 GUI 之外最常复用的配置入口。
__all__ = [
"UiConfig",
"load_config",
"save_config",
]
+6
View File
@@ -0,0 +1,6 @@
"""支持 python -m line_laser_hmi 启动"""
from line_laser_hmi.app import main
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
"""上位机 GUI 程序入口"""
from __future__ import annotations
import sys
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication
from line_laser_hmi.main_window import MainWindow
from line_laser_hmi.settings import load_config
def apply_light_theme(app: QApplication) -> None:
"""强制使用浅色 Fusion 主题,避免跟随系统暗色"""
app.setStyle("Fusion")
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor("#f0f0f0"))
palette.setColor(QPalette.ColorRole.WindowText, QColor("#202020"))
palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff"))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#f0f0f0"))
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffff"))
palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#202020"))
palette.setColor(QPalette.ColorRole.Text, QColor("#202020"))
palette.setColor(QPalette.ColorRole.Button, QColor("#e6e6e6"))
palette.setColor(QPalette.ColorRole.ButtonText, QColor("#202020"))
palette.setColor(QPalette.ColorRole.Highlight, QColor("#1565c0"))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
palette.setColor(QPalette.ColorRole.PlaceholderText, QColor("#808080"))
disabled = QColor("#a0a0a0")
palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled)
palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled)
palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled)
app.setPalette(palette)
def main() -> None:
"""启动 Qt 应用并显示主窗口"""
QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontUseNativeMenuBar, False)
app = QApplication(sys.argv)
apply_light_theme(app)
window = MainWindow(load_config())
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
"""客户端与模拟后端的构建工具"""
from __future__ import annotations
import math
import time
from line_laser_modbus.client import LineLaserClient, ModbusBackend
from line_laser_modbus.codec import encode_timed_pose
from line_laser_modbus.config import SerialConfig
from line_laser_modbus.constants import ADDR_CURRENT_POSE, ADDR_DEVICE_STATUS, ADDR_MODE_COMMAND
from line_laser_modbus.models import DeviceStatus, ModeCommand, Pose6D, TimedPose6D
from line_laser_modbus.simulator import SimulatedModbusBackend
# 模式写入后模拟控制器反馈的状态映射,仅用于无硬件演示。
_MODE_TO_STATUS = {
ModeCommand.STANDBY_RESET: DeviceStatus.STANDBY_READY,
ModeCommand.CALIBRATION: DeviceStatus.RUNNING,
ModeCommand.PRE_WELD_TEACHING: DeviceStatus.RUNNING,
ModeCommand.ONLINE_TRACKING: DeviceStatus.TRACKING_OK,
ModeCommand.TRAJECTORY_REPLAY: DeviceStatus.RUNNING,
ModeCommand.EMERGENCY_STOP: DeviceStatus.EMERGENCY_TRIGGERED,
}
class DynamicSimulatedBackend(SimulatedModbusBackend):
"""会随时间缓慢摆动当前位姿并响应模式切换的模拟后端"""
def __init__(self, *, center: Pose6D | None = None) -> None:
"""以一个基准位姿为中心创建动态模拟后端"""
super().__init__(current_pose=center or Pose6D(100.0, 50.0, 30.0, 0.0, 5.0, 10.0))
self._center = center or Pose6D(100.0, 50.0, 30.0, 0.0, 5.0, 10.0)
self._origin = time.monotonic()
def read_holding_registers(self, address: int, *, count: int, device_id: int):
"""读取前先刷新当前位姿寄存器,让位姿和曲线动起来"""
if address == ADDR_CURRENT_POSE:
self._refresh_current_pose()
return super().read_holding_registers(address, count=count, device_id=device_id)
def write_registers(self, address: int, values: list[int], *, device_id: int):
"""写入模式命令时联动刷新模拟设备状态字"""
response = super().write_registers(address, values, device_id=device_id)
if address == ADDR_MODE_COMMAND and values:
try:
status = _MODE_TO_STATUS[ModeCommand(values[0])]
except (ValueError, KeyError):
status = DeviceStatus.ALARM
self.registers[ADDR_DEVICE_STATUS] = status.value
return response
def _refresh_current_pose(self) -> None:
"""按时间生成围绕基准位姿摆动的当前位姿"""
elapsed = time.monotonic() - self._origin
wave = math.sin(elapsed)
base = self._center.as_tuple()
amplitudes = (5.0, 3.0, 2.0, 1.0, 0.5, 0.8)
moved = Pose6D.from_iterable(
[value + amplitude * wave for value, amplitude in zip(base, amplitudes, strict=True)]
)
timestamp = int(elapsed * 1000) & 0xFFFFFFFF
registers = encode_timed_pose(TimedPose6D(timestamp, moved))
for offset, register in enumerate(registers):
self.registers[ADDR_CURRENT_POSE + offset] = register
def build_client(
serial: SerialConfig,
*,
simulate: bool,
backend: ModbusBackend | None = None,
) -> LineLaserClient:
"""按是否模拟构建协议客户端"""
if backend is None and simulate:
backend = DynamicSimulatedBackend()
return LineLaserClient(serial, backend=backend)
+232
View File
@@ -0,0 +1,232 @@
"""主窗口:装配各面板并连接后台工作线程"""
from __future__ import annotations
from line_laser_modbus.models import DeviceSnapshot, ModeCommand, Pose6D
from PySide6.QtCore import QThread, Signal
from PySide6.QtWidgets import (
QGridLayout,
QMainWindow,
QVBoxLayout,
QWidget,
)
from line_laser_hmi.panels import (
ChartPanel,
ConnectionPanel,
LogWindow,
ManualPanel,
ModePanel,
PollingPanel,
PosePanel,
StatusPanel,
)
from line_laser_hmi.recorder import SnapshotRecorder
from line_laser_hmi.settings import HmiConfig, load_config
from line_laser_hmi.worker import ModbusWorker
class MainWindow(QMainWindow):
"""线激光 Modbus 上位机主窗口"""
requestConnect = Signal(object, bool)
requestDisconnect = Signal()
requestStartPolling = Signal(int)
requestStopPolling = Signal()
requestSwitchMode = Signal(int)
requestForceMode = Signal(int)
requestWriteTarget = Signal(object, int)
requestWriteCorrection = Signal(object, int)
requestSetTarget = Signal(object)
def __init__(self, config: HmiConfig | None = None) -> None:
"""构建窗口、面板和后台线程"""
super().__init__()
self._config = config or load_config()
self._recorder = SnapshotRecorder(self._config.ui.record_dir)
self.setWindowTitle("线激光视觉 - 运动控制器上位机")
self.resize(1180, 760)
self._build_panels()
self._build_layout()
self._build_menu()
self._start_worker()
self._wire_panels()
self._wire_worker()
self._connection.apply_config(self._config)
self._set_controls_enabled(False)
def _build_panels(self) -> None:
"""实例化所有界面面板"""
self._connection = ConnectionPanel()
self._mode = ModePanel()
self._polling = PollingPanel()
self._manual = ManualPanel()
self._status = StatusPanel()
self._pose = PosePanel()
self._chart = ChartPanel()
self._log = LogWindow(self)
def _build_layout(self) -> None:
"""按左控制、右监控、底部日志布局窗口"""
left = QVBoxLayout()
left.addWidget(self._connection)
left.addWidget(self._mode)
left.addWidget(self._polling)
left.addStretch(1)
right = QVBoxLayout()
right.addWidget(self._status)
right.addWidget(self._pose)
right.addWidget(self._chart, 1)
top = QGridLayout()
top.addLayout(left, 0, 0)
top.addLayout(right, 0, 1)
top.addWidget(self._manual, 1, 0, 1, 2)
top.setColumnStretch(0, 0)
top.setColumnStretch(1, 1)
root = QVBoxLayout()
root.addLayout(top, 1)
container = QWidget()
container.setLayout(root)
self.setCentralWidget(container)
def _build_menu(self) -> None:
"""构建菜单栏,提供打开日志窗口入口"""
view_menu = self.menuBar().addMenu("视图")
log_action = view_menu.addAction("日志窗口")
log_action.setShortcut("Ctrl+L")
log_action.triggered.connect(self._log.show_window)
def _start_worker(self) -> None:
"""创建后台线程并把工作对象移入其中"""
self._thread = QThread(self)
self._worker = ModbusWorker()
self._worker.moveToThread(self._thread)
self._thread.start()
def _wire_panels(self) -> None:
"""把面板的用户操作连接到主窗口请求信号"""
self._connection.connectRequested.connect(self.requestConnect)
self._connection.disconnectRequested.connect(self.requestDisconnect)
self._mode.modeRequested.connect(self.requestSwitchMode)
self._mode.emergencyRequested.connect(self._on_emergency)
self._polling.startPolling.connect(self.requestStartPolling)
self._polling.stopPolling.connect(self.requestStopPolling)
self._polling.startRecord.connect(self._on_start_record)
self._polling.stopRecord.connect(self._on_stop_record)
self._manual.sendTarget.connect(self.requestWriteTarget)
self._manual.sendCorrection.connect(self.requestWriteCorrection)
self._manual.setTrackingTarget.connect(self._on_set_target)
# 请求信号跨线程队列投递到工作对象的槽。
self.requestConnect.connect(self._worker.connect_device)
self.requestDisconnect.connect(self._worker.disconnect_device)
self.requestStartPolling.connect(self._worker.start_polling)
self.requestStopPolling.connect(self._worker.stop_polling)
self.requestSwitchMode.connect(self._worker.switch_mode)
self.requestForceMode.connect(self._worker.force_mode)
self.requestWriteTarget.connect(self._worker.write_target)
self.requestWriteCorrection.connect(self._worker.write_correction)
self.requestSetTarget.connect(self._worker.set_correction_target)
def _wire_worker(self) -> None:
"""把工作对象的结果信号连接到界面刷新"""
self._worker.connected.connect(self._on_connected)
self._worker.disconnected.connect(self._on_disconnected)
self._worker.snapshotReady.connect(self._on_snapshot)
self._worker.writeAck.connect(lambda msg: self._log.append(msg))
self._worker.errorOccurred.connect(lambda msg: self._log.append(msg, "错误"))
self._worker.timeoutCount.connect(self._status.set_timeout)
def _on_connected(self, success: bool, message: str) -> None:
"""处理连接结果"""
self._connection.set_connected(success, message)
self._status.set_connection(success, message)
self._set_controls_enabled(success)
self._log.append(message, "信息" if success else "错误")
if success:
# 连接成功后自动启动轮询,位姿与曲线立即开始刷新
self.requestStartPolling.emit(self._polling.interval())
self._polling.set_polling(True)
self._log.append("已自动启动轮询")
def _on_disconnected(self) -> None:
"""处理断开连接"""
self._connection.set_connected(False, "未连接")
self._status.set_connection(False, "未连接")
self._set_controls_enabled(False)
self._on_stop_record()
self._log.append("已断开连接")
def _on_snapshot(self, snapshot: DeviceSnapshot) -> None:
"""处理一次设备状态快照"""
self._status.update_snapshot(snapshot)
self._pose.update_pose(snapshot.pose)
self._mode.set_current_mode(snapshot.mode)
self._chart.append(snapshot.pose)
if self._recorder.is_recording:
self._recorder.write(snapshot)
self._polling.set_record_status_rows(self._recorder.rows)
def _on_emergency(self) -> None:
"""急停按钮强制写入急停模式"""
self.requestForceMode.emit(ModeCommand.EMERGENCY_STOP.value)
self._log.append("已触发紧急停止", "警告")
def _on_set_target(self, pose: Pose6D) -> None:
"""设置在线跟踪自动纠偏目标"""
self.requestSetTarget.emit(pose)
self._log.append(f"已设置跟踪目标 {pose.as_tuple()}")
def _on_start_record(self) -> None:
"""开始 CSV 录制"""
path = self._recorder.start()
self._polling.set_recording(True, f"录制中:{path.name}")
self._log.append(f"开始录制到 {path}")
def _on_stop_record(self) -> None:
"""停止 CSV 录制"""
if not self._recorder.is_recording:
return
path = self._recorder.stop()
self._polling.set_recording(False, f"已保存:{path.name if path else ''}")
self._log.append(f"录制结束 {path}")
def _set_controls_enabled(self, enabled: bool) -> None:
"""根据连接状态启用或禁用操作面板"""
self._mode.set_controls_enabled(enabled)
self._polling.set_controls_enabled(enabled)
self._manual.set_controls_enabled(enabled)
def closeEvent(self, event) -> None: # noqa: N802 - Qt 事件命名
"""关闭窗口时停止录制并安全退出后台线程"""
self._on_stop_record()
self.requestStopPolling.emit()
self.requestDisconnect.emit()
self._thread.quit()
self._thread.wait(2000)
super().closeEvent(event)
+22
View File
@@ -0,0 +1,22 @@
"""上位机界面面板集合"""
from line_laser_hmi.panels.chart_panel import ChartPanel
from line_laser_hmi.panels.connection_panel import ConnectionPanel
from line_laser_hmi.panels.log_panel import LogPanel, LogWindow
from line_laser_hmi.panels.manual_panel import ManualPanel
from line_laser_hmi.panels.mode_panel import ModePanel
from line_laser_hmi.panels.polling_panel import PollingPanel
from line_laser_hmi.panels.pose_panel import PosePanel
from line_laser_hmi.panels.status_panel import StatusPanel
__all__ = [
"ChartPanel",
"ConnectionPanel",
"LogPanel",
"LogWindow",
"ManualPanel",
"ModePanel",
"PollingPanel",
"PosePanel",
"StatusPanel",
]
+67
View File
@@ -0,0 +1,67 @@
"""实时曲线面板"""
from __future__ import annotations
from collections import deque
import pyqtgraph as pg
from line_laser_modbus.models import Pose6D
from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QWidget
# 曲线缓冲长度,按 50ms 周期约保留最近 30 秒数据。
_BUFFER = 600
# 位置三轴和姿态三轴的曲线颜色。
_POSITION = (("X", "#e53935"), ("Y", "#43a047"), ("Z", "#1e88e5"))
_ATTITUDE = (("A", "#fb8c00"), ("B", "#8e24aa"), ("C", "#00acc1"))
class ChartPanel(QGroupBox):
"""绘制 6 轴位姿随时间变化的实时曲线"""
def __init__(self, parent: QWidget | None = None) -> None:
"""构建位置与姿态两个曲线图"""
super().__init__("实时曲线", parent)
pg.setConfigOptions(antialias=True)
self._samples = 0
self._x: deque[int] = deque(maxlen=_BUFFER)
self._curves: list[tuple[deque[float], pg.PlotDataItem]] = []
position_plot = self._make_plot("位置 (mm)", _POSITION)
attitude_plot = self._make_plot("姿态 (°)", _ATTITUDE)
layout = QVBoxLayout(self)
layout.addWidget(position_plot)
layout.addWidget(attitude_plot)
def append(self, pose: Pose6D) -> None:
"""追加一帧位姿并刷新曲线"""
self._x.append(self._samples)
self._samples += 1
x = list(self._x)
for value, (buffer, curve) in zip(pose.as_tuple(), self._curves, strict=True):
buffer.append(value)
curve.setData(x, list(buffer))
def clear(self) -> None:
"""清空所有曲线缓冲"""
self._samples = 0
self._x.clear()
for buffer, curve in self._curves:
buffer.clear()
curve.setData([], [])
def _make_plot(self, title: str, axes: tuple[tuple[str, str], ...]) -> pg.PlotWidget:
"""创建一个带图例的曲线图并登记其曲线"""
plot = pg.PlotWidget(title=title)
plot.addLegend()
plot.showGrid(x=True, y=True, alpha=0.3)
plot.setMinimumHeight(160)
for name, color in axes:
curve = plot.plot(pen=pg.mkPen(color, width=2), name=name)
self._curves.append((deque(maxlen=_BUFFER), curve))
return plot
@@ -0,0 +1,152 @@
"""连接配置面板"""
from __future__ import annotations
from line_laser_modbus.config import SerialConfig
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
from line_laser_hmi.settings import HmiConfig
# 常用波特率选项,覆盖协议默认的 115200。
_BAUD_RATES = ("9600", "19200", "38400", "57600", "115200", "230400")
class ConnectionPanel(QGroupBox):
"""串口与模拟后端的连接配置面板"""
connectRequested = Signal(object, bool)
disconnectRequested = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
"""构建连接面板控件"""
super().__init__("连接", parent)
self._connected = False
self._port = QComboBox()
self._port.setEditable(True)
self._refresh = QPushButton("刷新")
self._refresh.clicked.connect(self._reload_ports)
self._baudrate = QComboBox()
self._baudrate.addItems(_BAUD_RATES)
self._baudrate.setCurrentText("115200")
self._slave_id = QSpinBox()
self._slave_id.setRange(1, 247)
self._slave_id.setValue(8)
self._timeout = QDoubleSpinBox()
self._timeout.setRange(0.01, 5.0)
self._timeout.setSingleStep(0.05)
self._timeout.setDecimals(2)
self._timeout.setValue(0.15)
self._retries = QSpinBox()
self._retries.setRange(0, 10)
self._retries.setValue(3)
self._simulate = QCheckBox("模拟模式(无硬件)")
self._simulate.setChecked(True)
self._toggle = QPushButton("连接")
self._toggle.clicked.connect(self._on_toggle)
self._indicator = QLabel("未连接")
self._indicator.setStyleSheet("color: white; background: #b03030; padding: 4px;")
port_row = QHBoxLayout()
port_row.addWidget(self._port, 1)
port_row.addWidget(self._refresh)
form = QFormLayout()
form.addRow("串口", port_row)
form.addRow("波特率", self._baudrate)
form.addRow("从站地址", self._slave_id)
form.addRow("超时(s)", self._timeout)
form.addRow("重试", self._retries)
form.addRow(self._simulate)
layout = QVBoxLayout(self)
layout.addLayout(form)
layout.addWidget(self._toggle)
layout.addWidget(self._indicator)
self._reload_ports()
def serial_config(self) -> SerialConfig:
"""从控件读取串口配置"""
return SerialConfig(
port=self._port.currentText().strip() or "COM1",
slave_id=self._slave_id.value(),
baudrate=int(self._baudrate.currentText()),
timeout=self._timeout.value(),
retries=self._retries.value(),
)
def is_simulate(self) -> bool:
"""返回是否选择了模拟模式"""
return self._simulate.isChecked()
def apply_config(self, config: HmiConfig) -> None:
"""用配置文件中的值初始化控件"""
serial = config.serial
if serial.port and self._port.findText(serial.port) < 0:
self._port.insertItem(0, serial.port)
self._port.setCurrentText(serial.port)
self._baudrate.setCurrentText(str(serial.baudrate))
self._slave_id.setValue(serial.slave_id)
self._timeout.setValue(serial.timeout)
self._retries.setValue(serial.retries)
self._simulate.setChecked(config.ui.simulate)
def set_connected(self, connected: bool, message: str) -> None:
"""根据连接结果刷新指示灯和按钮"""
self._connected = connected
self._toggle.setText("断开" if connected else "连接")
color = "#2e7d32" if connected else "#b03030"
self._indicator.setText(message)
self._indicator.setStyleSheet(f"color: white; background: {color}; padding: 4px;")
for widget in (self._port, self._refresh, self._baudrate, self._slave_id,
self._timeout, self._retries, self._simulate):
widget.setEnabled(not connected)
def _on_toggle(self) -> None:
"""连接按钮点击时发出连接或断开请求"""
if self._connected:
self.disconnectRequested.emit()
else:
self.connectRequested.emit(self.serial_config(), self.is_simulate())
def _reload_ports(self) -> None:
"""枚举本机可用串口填入下拉框"""
current = self._port.currentText()
self._port.clear()
try:
from serial.tools import list_ports
ports = [port.device for port in list_ports.comports()]
except Exception: # noqa: BLE001 - 无串口环境下保持下拉可手填
ports = []
self._port.addItems(ports)
if current:
self._port.setCurrentText(current)
+83
View File
@@ -0,0 +1,83 @@
"""通信日志面板"""
from __future__ import annotations
from datetime import datetime
from PySide6.QtWidgets import (
QDialog,
QFileDialog,
QGroupBox,
QHBoxLayout,
QPlainTextEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
class LogPanel(QGroupBox):
"""带时间戳的滚动日志,可清空和导出"""
def __init__(self, parent: QWidget | None = None) -> None:
"""构建日志文本框和操作按钮"""
super().__init__("日志", parent)
self._view = QPlainTextEdit()
self._view.setReadOnly(True)
self._view.setMaximumBlockCount(5000)
self._clear = QPushButton("清空")
self._clear.clicked.connect(self._view.clear)
self._export = QPushButton("导出")
self._export.clicked.connect(self._on_export)
buttons = QHBoxLayout()
buttons.addStretch(1)
buttons.addWidget(self._clear)
buttons.addWidget(self._export)
layout = QVBoxLayout(self)
layout.addWidget(self._view)
layout.addLayout(buttons)
def append(self, message: str, level: str = "信息") -> None:
"""追加一条带时间戳和等级的日志"""
stamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
self._view.appendPlainText(f"[{stamp}] [{level}] {message}")
def _on_export(self) -> None:
"""导出当前日志文本到文件"""
path, _ = QFileDialog.getSaveFileName(self, "导出日志", "hmi_log.txt", "文本文件 (*.txt)")
if path:
with open(path, "w", encoding="utf-8") as file:
file.write(self._view.toPlainText())
class LogWindow(QDialog):
"""承载日志面板的独立窗口,可由菜单按需打开"""
def __init__(self, parent: QWidget | None = None) -> None:
"""构建日志窗口并内嵌日志面板"""
super().__init__(parent)
self.setWindowTitle("通信日志")
self.resize(720, 420)
self._panel = LogPanel()
layout = QVBoxLayout(self)
layout.addWidget(self._panel)
def append(self, message: str, level: str = "信息") -> None:
"""转发到内嵌日志面板追加日志"""
self._panel.append(message, level)
def show_window(self) -> None:
"""显示并激活日志窗口"""
self.show()
self.raise_()
self.activateWindow()
+98
View File
@@ -0,0 +1,98 @@
"""手动下发面板"""
from __future__ import annotations
from line_laser_modbus.models import Pose6D
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QDoubleSpinBox,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
# 六轴名称与单位,顺序固定为 X Y Z A B C。
_AXES = (("X", "mm"), ("Y", "mm"), ("Z", "mm"), ("A", "°"), ("B", "°"), ("C", "°"))
class ManualPanel(QGroupBox):
"""手动填写并下发目标示教位姿、纠偏量或跟踪目标"""
sendTarget = Signal(object, int)
sendCorrection = Signal(object, int)
setTrackingTarget = Signal(object)
def __init__(self, parent: QWidget | None = None) -> None:
"""构建六轴输入框和下发按钮"""
super().__init__("手动下发", parent)
self._inputs: list[QDoubleSpinBox] = []
grid = QGridLayout()
for index, (name, unit) in enumerate(_AXES):
spin = QDoubleSpinBox()
spin.setRange(-100000.0, 100000.0)
spin.setDecimals(3)
spin.setSingleStep(0.1)
grid.addWidget(QLabel(f"{name} ({unit})"), index // 3, (index % 3) * 2)
grid.addWidget(spin, index // 3, (index % 3) * 2 + 1)
self._inputs.append(spin)
self._timestamp = QSpinBox()
self._timestamp.setRange(0, 2_000_000_000)
self._timestamp.setValue(0)
ts_row = QHBoxLayout()
ts_row.addWidget(QLabel("时间戳(ms)"))
ts_row.addWidget(self._timestamp, 1)
self._target_button = QPushButton("下发示教位姿")
self._target_button.clicked.connect(self._emit_target)
self._correction_button = QPushButton("下发纠偏量")
self._correction_button.clicked.connect(self._emit_correction)
self._track_button = QPushButton("设为跟踪目标")
self._track_button.clicked.connect(self._emit_tracking_target)
buttons = QHBoxLayout()
buttons.addWidget(self._target_button)
buttons.addWidget(self._correction_button)
buttons.addWidget(self._track_button)
layout = QVBoxLayout(self)
layout.addLayout(grid)
layout.addLayout(ts_row)
layout.addLayout(buttons)
self.set_controls_enabled(False)
def pose(self) -> Pose6D:
"""读取六个输入框组成 Pose6D"""
return Pose6D.from_iterable([spin.value() for spin in self._inputs])
def set_controls_enabled(self, enabled: bool) -> None:
"""连接状态变化时启用或禁用下发按钮"""
self._target_button.setEnabled(enabled)
self._correction_button.setEnabled(enabled)
self._track_button.setEnabled(enabled)
def _emit_target(self) -> None:
"""发出目标示教位姿下发请求"""
self.sendTarget.emit(self.pose(), self._timestamp.value())
def _emit_correction(self) -> None:
"""发出纠偏量下发请求"""
self.sendCorrection.emit(self.pose(), self._timestamp.value())
def _emit_tracking_target(self) -> None:
"""发出设置自动跟踪目标请求"""
self.setTrackingTarget.emit(self.pose())
+76
View File
@@ -0,0 +1,76 @@
"""模式控制面板"""
from __future__ import annotations
from line_laser_modbus.models import ModeCommand
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QGridLayout,
QGroupBox,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
# 可由界面主动切换的运行模式(急停单独用醒目按钮处理)。
_MODE_BUTTONS = (
(ModeCommand.STANDBY_RESET, "待机复位"),
(ModeCommand.CALIBRATION, "系统标定"),
(ModeCommand.PRE_WELD_TEACHING, "焊前示教"),
(ModeCommand.ONLINE_TRACKING, "在线跟踪"),
(ModeCommand.TRAJECTORY_REPLAY, "轨迹复现"),
)
class ModePanel(QGroupBox):
"""按状态机切换工作模式并提供急停按钮"""
modeRequested = Signal(int)
emergencyRequested = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
"""构建模式按钮与急停按钮"""
super().__init__("模式控制", parent)
self._buttons: dict[ModeCommand, QPushButton] = {}
grid = QGridLayout()
for index, (mode, text) in enumerate(_MODE_BUTTONS):
button = QPushButton(text)
button.setMinimumHeight(36)
button.clicked.connect(lambda _=False, value=mode.value: self.modeRequested.emit(value))
grid.addWidget(button, index // 2, index % 2)
self._buttons[mode] = button
self._current = QLabel("当前模式:—")
self._current.setStyleSheet("font-weight: bold;")
self._estop = QPushButton("紧 急 停 止")
self._estop.setMinimumHeight(56)
self._estop.setStyleSheet(
"background: #c62828; color: white; font-size: 18px; font-weight: bold;"
)
self._estop.clicked.connect(self.emergencyRequested.emit)
layout = QVBoxLayout(self)
layout.addLayout(grid)
layout.addWidget(self._current)
layout.addWidget(self._estop)
def set_current_mode(self, mode: ModeCommand) -> None:
"""高亮当前模式按钮并更新文字"""
self._current.setText(f"当前模式:{mode.name}")
for value, button in self._buttons.items():
highlight = value is mode
button.setStyleSheet(
"background: #1565c0; color: white; font-weight: bold;" if highlight else ""
)
def set_controls_enabled(self, enabled: bool) -> None:
"""连接状态变化时启用或禁用所有模式按钮"""
for button in self._buttons.values():
button.setEnabled(enabled)
self._estop.setEnabled(enabled)
+109
View File
@@ -0,0 +1,109 @@
"""轮询与录制控制面板"""
from __future__ import annotations
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
class PollingPanel(QGroupBox):
"""控制后台轮询周期与 CSV 录制"""
startPolling = Signal(int)
stopPolling = Signal()
startRecord = Signal()
stopRecord = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
"""构建轮询与录制控件"""
super().__init__("轮询与录制", parent)
self._polling = False
self._recording = False
self._interval = QSpinBox()
self._interval.setRange(10, 5000)
self._interval.setSingleStep(10)
self._interval.setValue(50)
self._interval.setSuffix(" ms")
self._poll_button = QPushButton("开始轮询")
self._poll_button.clicked.connect(self._on_poll)
self._record_button = QPushButton("开始录制")
self._record_button.clicked.connect(self._on_record)
self._record_status = QLabel("未录制")
form = QFormLayout()
form.addRow("轮询周期", self._interval)
buttons = QHBoxLayout()
buttons.addWidget(self._poll_button)
buttons.addWidget(self._record_button)
layout = QVBoxLayout(self)
layout.addLayout(form)
layout.addLayout(buttons)
layout.addWidget(self._record_status)
self.set_controls_enabled(False)
def interval(self) -> int:
"""返回当前设置的轮询周期(毫秒)"""
return self._interval.value()
def set_controls_enabled(self, enabled: bool) -> None:
"""连接状态变化时启用或禁用轮询控件"""
self._poll_button.setEnabled(enabled)
self._record_button.setEnabled(enabled)
if not enabled:
self.set_polling(False)
self.set_recording(False, "未录制")
def set_polling(self, polling: bool) -> None:
"""更新轮询按钮状态文字"""
self._polling = polling
self._poll_button.setText("停止轮询" if polling else "开始轮询")
self._interval.setEnabled(not polling)
def set_recording(self, recording: bool, message: str) -> None:
"""更新录制按钮和状态文字"""
self._recording = recording
self._record_button.setText("停止录制" if recording else "开始录制")
self._record_status.setText(message)
def set_record_status_rows(self, rows: int) -> None:
"""录制过程中刷新已写入行数"""
if self._recording:
self._record_status.setText(f"录制中:已写入 {rows}")
def _on_poll(self) -> None:
"""轮询按钮点击时发出开始或停止请求"""
if self._polling:
self.stopPolling.emit()
else:
self.startPolling.emit(self._interval.value())
def _on_record(self) -> None:
"""录制按钮点击时发出开始或停止请求"""
if self._recording:
self.stopRecord.emit()
else:
self.startRecord.emit()
+54
View File
@@ -0,0 +1,54 @@
"""当前位姿显示面板"""
from __future__ import annotations
from line_laser_modbus.models import Pose6D
from PySide6.QtWidgets import (
QGridLayout,
QGroupBox,
QLabel,
QLCDNumber,
QVBoxLayout,
QWidget,
)
# 六轴名称与单位,顺序固定为 X Y Z A B C。
_AXES = (
("X", "mm"),
("Y", "mm"),
("Z", "mm"),
("A", "°"),
("B", "°"),
("C", "°"),
)
class PosePanel(QGroupBox):
"""以大号数码管显示当前 6 轴位姿"""
def __init__(self, title: str = "当前位姿", parent: QWidget | None = None) -> None:
"""构建六个轴的数码显示"""
super().__init__(title, parent)
self._displays: list[QLCDNumber] = []
grid = QGridLayout(self)
for index, (name, unit) in enumerate(_AXES):
cell = QVBoxLayout()
caption = QLabel(f"{name} ({unit})")
caption.setStyleSheet("font-weight: bold;")
lcd = QLCDNumber()
lcd.setDigitCount(9)
lcd.setSegmentStyle(QLCDNumber.SegmentStyle.Flat)
lcd.setMinimumHeight(48)
lcd.display(0.0)
cell.addWidget(caption)
cell.addWidget(lcd)
grid.addLayout(cell, index // 3, index % 3)
self._displays.append(lcd)
def update_pose(self, pose: Pose6D) -> None:
"""刷新六个数码管的数值"""
for lcd, value in zip(self._displays, pose.as_tuple(), strict=True):
lcd.display(f"{value:.3f}")
+63
View File
@@ -0,0 +1,63 @@
"""设备状态监控面板"""
from __future__ import annotations
from line_laser_modbus.models import DeviceSnapshot, DeviceStatus
from PySide6.QtWidgets import QFormLayout, QGroupBox, QLabel, QWidget
# 异常类状态用红色,正常运行类用绿色,其余用中性色。
_STATUS_COLORS = {
DeviceStatus.STANDBY_READY: "#455a64",
DeviceStatus.RUNNING: "#2e7d32",
DeviceStatus.TEACHING_DONE: "#1565c0",
DeviceStatus.TRACKING_OK: "#2e7d32",
DeviceStatus.ALARM: "#c62828",
DeviceStatus.CALIBRATION_DONE: "#1565c0",
DeviceStatus.EMERGENCY_TRIGGERED: "#c62828",
}
class StatusPanel(QGroupBox):
"""显示模式、设备状态字、时间戳和通信超时计数"""
def __init__(self, parent: QWidget | None = None) -> None:
"""构建状态监控标签"""
super().__init__("状态监控", parent)
self._mode = QLabel("")
self._status = QLabel("")
self._status.setStyleSheet("color: white; background: #455a64; padding: 4px;")
self._timestamp = QLabel("")
self._timeout = QLabel("0")
self._connection = QLabel("未连接")
form = QFormLayout(self)
form.addRow("模式", self._mode)
form.addRow("设备状态", self._status)
form.addRow("时间戳(ms)", self._timestamp)
form.addRow("连续超时", self._timeout)
form.addRow("连接", self._connection)
def update_snapshot(self, snapshot: DeviceSnapshot) -> None:
"""用一次快照刷新模式、状态和时间戳"""
self._mode.setText(snapshot.mode.name)
color = _STATUS_COLORS.get(snapshot.status, "#455a64")
self._status.setText(snapshot.status.name)
self._status.setStyleSheet(f"color: white; background: {color}; padding: 4px;")
self._timestamp.setText(str(snapshot.timestamp))
def set_timeout(self, count: int) -> None:
"""刷新连续超时计数,非零时标红"""
self._timeout.setText(str(count))
self._timeout.setStyleSheet("color: #c62828; font-weight: bold;" if count else "")
def set_connection(self, connected: bool, message: str) -> None:
"""刷新连接状态文字"""
self._connection.setText(message)
self._connection.setStyleSheet(
"color: #2e7d32;" if connected else "color: #c62828;"
)
View File
+90
View File
@@ -0,0 +1,90 @@
"""位姿快照的 CSV 录制工具"""
from __future__ import annotations
import csv
from datetime import datetime
from pathlib import Path
from line_laser_modbus.models import DeviceSnapshot
CSV_HEADER = ("wall_time", "device_timestamp", "mode", "status", "x", "y", "z", "a", "b", "c")
class SnapshotRecorder:
"""将设备状态快照按行追加写入 CSV 文件"""
def __init__(self, record_dir: str | Path = "records") -> None:
"""记录录制目录但不立即创建文件"""
self._record_dir = Path(record_dir)
self._file = None
self._writer: csv.writer | None = None
self._path: Path | None = None
self._rows = 0
@property
def is_recording(self) -> bool:
"""返回当前是否处于录制状态"""
return self._file is not None
@property
def path(self) -> Path | None:
"""返回当前录制文件路径"""
return self._path
@property
def rows(self) -> int:
"""返回已写入的数据行数"""
return self._rows
def start(self) -> Path:
"""新建带时间戳命名的 CSV 文件并写入表头"""
if self.is_recording:
return self._path # type: ignore[return-value]
self._record_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self._path = self._record_dir / f"pose_{stamp}.csv"
self._file = self._path.open("w", newline="", encoding="utf-8")
self._writer = csv.writer(self._file)
self._writer.writerow(CSV_HEADER)
self._rows = 0
return self._path
def write(self, snapshot: DeviceSnapshot) -> None:
"""追加一条快照记录,未录制时直接忽略"""
if self._writer is None or self._file is None:
return
pose = snapshot.pose
self._writer.writerow(
(
datetime.now().isoformat(timespec="milliseconds"),
snapshot.timestamp,
snapshot.mode.name,
snapshot.status.name,
pose.x,
pose.y,
pose.z,
pose.a,
pose.b,
pose.c,
)
)
self._rows += 1
self._file.flush()
def stop(self) -> Path | None:
"""关闭当前录制文件并返回其路径"""
path = self._path
if self._file is not None:
self._file.close()
self._file = None
self._writer = None
self._path = None
return path
+89
View File
@@ -0,0 +1,89 @@
"""上位机运行配置的读取与保存"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from pathlib import Path
from line_laser_modbus.config import PollingConfig, SerialConfig
DEFAULT_CONFIG_PATH = "config.toml"
@dataclass(frozen=True, slots=True)
class UiConfig:
"""仅 GUI 关心的额外配置项"""
simulate: bool = True
record_dir: str = "records"
@dataclass(frozen=True, slots=True)
class HmiConfig:
"""上位机完整配置:串口、轮询和界面"""
serial: SerialConfig = SerialConfig()
polling: PollingConfig = PollingConfig()
ui: UiConfig = UiConfig()
def load_config(path: str | Path = DEFAULT_CONFIG_PATH) -> HmiConfig:
"""从 TOML 文件读取上位机配置,文件缺失时返回默认值"""
config_path = Path(path)
if not config_path.exists():
return HmiConfig()
with config_path.open("rb") as file:
data = tomllib.load(file)
# 配置文件只暴露运行需要的字段,未知键忽略避免与协议常量耦合
return HmiConfig(
serial=SerialConfig(**data.get("serial", {})),
polling=PollingConfig(**data.get("polling", {})),
ui=UiConfig(**data.get("ui", {})),
)
def save_config(config: HmiConfig, path: str | Path = DEFAULT_CONFIG_PATH) -> None:
"""将上位机配置写回 TOML 文件"""
text = (
_serial_section(config.serial)
+ "\n"
+ _polling_section(config.polling)
+ "\n"
+ _ui_section(config.ui)
)
Path(path).write_text(text, encoding="utf-8")
def _serial_section(serial: SerialConfig) -> str:
"""序列化 [serial] 段"""
return (
"[serial]\n"
f'port = "{serial.port}"\n'
f"slave_id = {serial.slave_id}\n"
f"baudrate = {serial.baudrate}\n"
f"bytesize = {serial.bytesize}\n"
f'parity = "{serial.parity}"\n'
f"stopbits = {serial.stopbits}\n"
f"timeout = {serial.timeout}\n"
f"retries = {serial.retries}\n"
)
def _polling_section(polling: PollingConfig) -> str:
"""序列化 [polling] 段"""
return (
"[polling]\n"
f"interval_seconds = {polling.interval_seconds}\n"
f"max_timeouts = {polling.max_timeouts}\n"
)
def _ui_section(ui: UiConfig) -> str:
"""序列化 [ui] 段"""
return f"[ui]\nsimulate = {str(ui.simulate).lower()}\nrecord_dir = \"{ui.record_dir}\"\n"
+192
View File
@@ -0,0 +1,192 @@
"""在后台线程中执行 Modbus 读写的工作对象"""
from __future__ import annotations
import contextlib
from line_laser_modbus.client import LineLaserClient
from line_laser_modbus.config import SerialConfig
from line_laser_modbus.models import ModeCommand, Pose6D, validate_mode_switch
from line_laser_modbus.runner import PollingRunner, pose_delta
from PySide6.QtCore import QObject, QTimer, Signal, Slot
from line_laser_hmi.backend import build_client
class ModbusWorker(QObject):
"""运行在独立 QThread 中的协议读写器
GUI 线程通过队列连接的信号调用这里的槽,结果再通过信号回传,
保证所有阻塞式 Modbus IO 都不发生在界面线程。
"""
connected = Signal(bool, str)
disconnected = Signal()
snapshotReady = Signal(object)
writeAck = Signal(str)
errorOccurred = Signal(str)
timeoutCount = Signal(int)
def __init__(self) -> None:
"""创建未连接状态的工作对象"""
super().__init__()
self._client: LineLaserClient | None = None
self._timer: QTimer | None = None
self._runner: PollingRunner | None = None
self._target: Pose6D | None = None
self._timeouts = 0
@Slot(object, bool)
def connect_device(self, serial: SerialConfig, simulate: bool) -> None:
"""连接真实串口或模拟后端"""
self._teardown()
try:
client = build_client(serial, simulate=simulate)
client.connect()
except Exception as exc: # noqa: BLE001 - 连接异常统一回传给界面
self._client = None
self.connected.emit(False, str(exc))
return
self._client = client
self._runner = PollingRunner(
client,
correction_provider=self._provide_correction,
snapshot_handler=self.snapshotReady.emit,
)
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
kind = "模拟后端" if simulate else serial.port
self.connected.emit(True, f"已连接 {kind}")
@Slot()
def disconnect_device(self) -> None:
"""停止轮询并断开当前连接"""
self._teardown()
self.disconnected.emit()
@Slot(int)
def start_polling(self, interval_ms: int) -> None:
"""按指定周期启动后台轮询"""
if self._timer is None or self._client is None:
self.errorOccurred.emit("未连接,无法启动轮询")
return
self._timeouts = 0
self._timer.setInterval(max(1, interval_ms))
self._timer.start()
@Slot()
def stop_polling(self) -> None:
"""停止后台轮询"""
if self._timer is not None:
self._timer.stop()
@Slot(int)
def switch_mode(self, mode_value: int) -> None:
"""按状态机校验后切换模式"""
if self._client is None:
self.errorOccurred.emit("未连接,无法切换模式")
return
try:
current = self._client.read_mode()
status = self._client.read_status()
target = validate_mode_switch(current, mode_value, status)
self._client.write_mode(target)
except Exception as exc: # noqa: BLE001 - 切换失败回传给界面提示
self.errorOccurred.emit(f"模式切换失败:{exc}")
return
self.writeAck.emit(f"切换模式 -> {target.name}")
@Slot(int)
def force_mode(self, mode_value: int) -> None:
"""不做状态机校验直接写模式,用于急停等强制场景"""
if self._client is None:
self.errorOccurred.emit("未连接,无法下发模式")
return
try:
self._client.write_mode(mode_value)
except Exception as exc: # noqa: BLE001 - 写入失败回传给界面提示
self.errorOccurred.emit(f"模式下发失败:{exc}")
return
self.writeAck.emit(f"强制模式 -> {ModeCommand(mode_value).name}")
@Slot(object, int)
def write_target(self, pose: Pose6D, timestamp: int) -> None:
"""下发目标示教位姿"""
if self._client is None:
self.errorOccurred.emit("未连接,无法下发示教位姿")
return
try:
self._client.write_target_pose(pose, timestamp=timestamp)
except Exception as exc: # noqa: BLE001 - 写入失败回传给界面提示
self.errorOccurred.emit(f"示教位姿下发失败:{exc}")
return
self.writeAck.emit("已下发目标示教位姿")
@Slot(object, int)
def write_correction(self, pose: Pose6D, timestamp: int) -> None:
"""下发实时纠偏量"""
if self._client is None:
self.errorOccurred.emit("未连接,无法下发纠偏量")
return
try:
self._client.write_correction(pose, timestamp=timestamp)
except Exception as exc: # noqa: BLE001 - 写入失败回传给界面提示
self.errorOccurred.emit(f"纠偏量下发失败:{exc}")
return
self.writeAck.emit("已下发纠偏量")
@Slot(object)
def set_correction_target(self, pose: Pose6D | None) -> None:
"""设置在线跟踪模式下自动纠偏使用的目标位姿"""
self._target = pose
def _provide_correction(self, snapshot) -> Pose6D:
"""根据目标位姿计算自动纠偏量,未设目标时输出零纠偏"""
if self._target is None:
return Pose6D.zeros()
return pose_delta(self._target)(snapshot)
def _tick(self) -> None:
"""单个轮询周期:读取快照并在跟踪模式下写入纠偏量"""
if self._runner is None:
return
try:
self._runner.run_once()
except TimeoutError:
self._timeouts += 1
self.timeoutCount.emit(self._timeouts)
self.errorOccurred.emit("通信超时")
return
except Exception as exc: # noqa: BLE001 - 周期异常回传但不中断定时器
self.errorOccurred.emit(f"轮询异常:{exc}")
return
if self._timeouts:
self._timeouts = 0
self.timeoutCount.emit(0)
def _teardown(self) -> None:
"""停止定时器并关闭客户端"""
if self._timer is not None:
self._timer.stop()
self._timer.deleteLater()
self._timer = None
if self._client is not None:
# 关闭异常忽略不影响断开流程
with contextlib.suppress(Exception):
self._client.close()
self._client = None
self._runner = None
self._timeouts = 0
+41
View File
@@ -0,0 +1,41 @@
"""动态模拟后端测试"""
from __future__ import annotations
import time
from line_laser_modbus.config import SerialConfig
from line_laser_modbus.models import DeviceStatus, ModeCommand
from line_laser_hmi.backend import DynamicSimulatedBackend, build_client
def test_build_client_uses_dynamic_simulator():
"""模拟模式应注入动态模拟后端"""
client = build_client(SerialConfig(port="SIM"), simulate=True)
assert isinstance(client._backend, DynamicSimulatedBackend)
def test_current_pose_changes_over_time():
"""连续读取当前位姿应随时间变化"""
client = build_client(SerialConfig(port="SIM"), simulate=True)
client.connect()
first = client.read_current_pose()
time.sleep(0.05)
second = client.read_current_pose()
client.close()
assert first.as_tuple() != second.as_tuple()
def test_write_mode_updates_status():
"""写入模式命令应联动刷新模拟设备状态字"""
client = build_client(SerialConfig(port="SIM"), simulate=True)
client.connect()
client.write_mode(ModeCommand.ONLINE_TRACKING)
assert client.read_status() is DeviceStatus.TRACKING_OK
client.write_mode(ModeCommand.EMERGENCY_STOP)
assert client.read_status() is DeviceStatus.EMERGENCY_TRIGGERED
client.close()
+46
View File
@@ -0,0 +1,46 @@
"""CSV 录制测试"""
from __future__ import annotations
import csv
from line_laser_modbus.models import DeviceSnapshot, DeviceStatus, ModeCommand, Pose6D
from line_laser_hmi.recorder import CSV_HEADER, SnapshotRecorder
def _snapshot() -> DeviceSnapshot:
"""构造一个测试用快照"""
return DeviceSnapshot(
ModeCommand.ONLINE_TRACKING,
DeviceStatus.TRACKING_OK,
Pose6D(1.0, 2.0, 3.0, 4.0, 5.0, 6.0),
timestamp=1234,
)
def test_record_writes_header_and_rows(tmp_path):
"""录制应写入表头并按快照追加数据行"""
recorder = SnapshotRecorder(tmp_path)
path = recorder.start()
recorder.write(_snapshot())
recorder.write(_snapshot())
assert recorder.rows == 2
recorder.stop()
with open(path, newline="", encoding="utf-8") as file:
rows = list(csv.reader(file))
assert tuple(rows[0]) == CSV_HEADER
assert len(rows) == 3
assert rows[1][2] == "ONLINE_TRACKING"
def test_write_without_start_is_ignored(tmp_path):
"""未开始录制时写入应被忽略"""
recorder = SnapshotRecorder(tmp_path)
recorder.write(_snapshot())
assert recorder.rows == 0
assert recorder.is_recording is False
+34
View File
@@ -0,0 +1,34 @@
"""配置读写测试"""
from __future__ import annotations
from line_laser_modbus.config import PollingConfig, SerialConfig
from line_laser_hmi.settings import HmiConfig, UiConfig, load_config, save_config
def test_load_missing_returns_default(tmp_path):
"""缺失配置文件时返回默认配置"""
config = load_config(tmp_path / "missing.toml")
assert config.serial.port == "COM1"
assert config.ui.simulate is True
def test_save_then_load_roundtrip(tmp_path):
"""保存后再读取应得到一致的配置"""
path = tmp_path / "config.toml"
original = HmiConfig(
serial=SerialConfig(port="COM7", slave_id=8, baudrate=57600, timeout=0.2, retries=2),
polling=PollingConfig(interval_seconds=0.1, max_timeouts=5),
ui=UiConfig(simulate=False, record_dir="out"),
)
save_config(original, path)
loaded = load_config(path)
assert loaded.serial.port == "COM7"
assert loaded.serial.baudrate == 57600
assert loaded.polling.interval_seconds == 0.1
assert loaded.ui.simulate is False
assert loaded.ui.record_dir == "out"
Generated
+251
View File
@@ -0,0 +1,251 @@
version = 1
revision = 3
requires-python = "==3.12.*"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "line-laser-hmi"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "line-laser-modbus" },
{ name = "pyqtgraph" },
{ name = "pyserial" },
{ name = "pyside6" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "line-laser-modbus", directory = "../py" },
{ name = "pyqtgraph", specifier = ">=0.13.7" },
{ name = "pyserial", specifier = ">=3.5" },
{ name = "pyside6", specifier = ">=6.7" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.0.1" },
{ name = "ruff", specifier = ">=0.14.6" },
]
[[package]]
name = "line-laser-modbus"
version = "0.1.0"
source = { directory = "../py" }
dependencies = [
{ name = "pymodbus", extra = ["serial"] },
]
[package.metadata]
requires-dist = [{ name = "pymodbus", extras = ["serial"], specifier = ">=3.11.3" }]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.0.1" },
{ name = "ruff", specifier = ">=0.14.6" },
]
[[package]]
name = "numpy"
version = "2.4.6"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pymodbus"
version = "3.13.1"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/c2/e4/68aa328d8c07583b1fe382f231d614bc1ebf3a86aa09db1a9c045c33c1df/pymodbus-3.13.1.tar.gz", hash = "sha256:7a74ea0a4eb4895f518b34de32915ba4fde216576e09deaf735a279a9281af4f", size = 166178, upload-time = "2026-06-13T17:02:09.885Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/df/84/cdd3f6af834dac9e3bfaeca1f37c4dbfd305943305670c48499950e4e1cd/pymodbus-3.13.1-py3-none-any.whl", hash = "sha256:820167a9c6a13d698d7ff49e8420f8fbbdd8fec3f75aacd46a35f4ab9a000144", size = 166529, upload-time = "2026-06-13T17:02:08.374Z" },
]
[package.optional-dependencies]
serial = [
{ name = "pyserial" },
]
[[package]]
name = "pyqtgraph"
version = "0.14.0"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
dependencies = [
{ name = "colorama" },
{ name = "numpy" },
]
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl", hash = "sha256:7abb7c3e17362add64f8711b474dffac5e7b0e9245abdf992e9a44119b7aa4f5", size = 1924755, upload-time = "2025-11-16T19:43:22.251Z" },
]
[[package]]
name = "pyserial"
version = "3.5"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
]
[[package]]
name = "pyside6"
version = "6.11.1"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
dependencies = [
{ name = "pyside6-addons" },
{ name = "pyside6-essentials" },
{ name = "shiboken6" },
]
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/d8/de/af89d71410c83b10654d86ff9aff2a4f87c30163658f1cc145242e222526/pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2", size = 572102, upload-time = "2026-05-13T09:47:38.249Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/b6/0e/d583bd3f7bf5046a4497b36f3902cfb64aa29554489a5a25c18e6b4ac0ac/pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84", size = 572098, upload-time = "2026-05-13T09:47:39.44Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/57/f2/d9d8ce1373dabb37e5919f63cd18446556079631d3f2eea3ada03c29f6b8/pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6", size = 578377, upload-time = "2026-05-13T09:47:40.76Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/96/02/a6057d8bd2bdb1940820fff2d627fdf4013148c9c57adf69fa40d3452ac3/pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805", size = 561765, upload-time = "2026-05-13T09:47:41.9Z" },
]
[[package]]
name = "pyside6-addons"
version = "6.11.1"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
dependencies = [
{ name = "pyside6-essentials" },
{ name = "shiboken6" },
]
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/dd/62/fb1428a523b2a4541e232aab50d9e789e6b4526f37fd9593452a7ea5b6b3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f", size = 175063224, upload-time = "2026-05-13T09:39:34.185Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ee/9b/2ccd52f66db55c06de65d0501170a1935d04d64d0a230c0d892284a02ce3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993", size = 170553429, upload-time = "2026-05-13T09:39:54.424Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/9a/bd/8adc4d350b3b363f3dfc8fccdcf5bfed25f7e36c2fff30c64e106f4f1572/pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923", size = 168816308, upload-time = "2026-05-13T09:40:13.541Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/65/b7/9a840d97f0f0f04e372a87e205dd30ee285b4e3b021b188459a917c9dc76/pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37", size = 35759347, upload-time = "2026-05-13T09:40:21.199Z" },
]
[[package]]
name = "pyside6-essentials"
version = "6.11.1"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
dependencies = [
{ name = "shiboken6" },
]
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" },
]
[[package]]
name = "pytest"
version = "9.1.0"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" },
]
[[package]]
name = "ruff"
version = "0.15.17"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" },
]
[[package]]
name = "shiboken6"
version = "6.11.1"
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
wheels = [
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" },
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" },
]