feat: init
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
records/
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -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
@@ -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"
|
||||
@@ -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`。
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,10 @@
|
||||
"""线激光 Modbus 上位机 GUI 包"""
|
||||
|
||||
from line_laser_hmi.settings import UiConfig, load_config, save_config
|
||||
|
||||
# 公开 GUI 之外最常复用的配置入口。
|
||||
__all__ = [
|
||||
"UiConfig",
|
||||
"load_config",
|
||||
"save_config",
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""支持 python -m line_laser_hmi 启动"""
|
||||
|
||||
from line_laser_hmi.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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;"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user