From 009ea92e5f99001685698bf14b055d295d605201 Mon Sep 17 00:00:00 2001 From: chuan Date: Tue, 16 Jun 2026 10:07:15 +0800 Subject: [PATCH] feat: init --- .gitignore | 9 + .python-version | 1 + README.md | 78 ++++++ config.toml | 17 ++ docs/design.md | 60 +++++ pyproject.toml | 39 +++ src/line_laser_hmi/__init__.py | 10 + src/line_laser_hmi/__main__.py | 6 + src/line_laser_hmi/app.py | 51 ++++ src/line_laser_hmi/backend.py | 81 ++++++ src/line_laser_hmi/main_window.py | 232 ++++++++++++++++ src/line_laser_hmi/panels/__init__.py | 22 ++ src/line_laser_hmi/panels/chart_panel.py | 67 +++++ src/line_laser_hmi/panels/connection_panel.py | 152 +++++++++++ src/line_laser_hmi/panels/log_panel.py | 83 ++++++ src/line_laser_hmi/panels/manual_panel.py | 98 +++++++ src/line_laser_hmi/panels/mode_panel.py | 76 ++++++ src/line_laser_hmi/panels/polling_panel.py | 109 ++++++++ src/line_laser_hmi/panels/pose_panel.py | 54 ++++ src/line_laser_hmi/panels/status_panel.py | 63 +++++ src/line_laser_hmi/py.typed | 0 src/line_laser_hmi/recorder.py | 90 +++++++ src/line_laser_hmi/settings.py | 89 +++++++ src/line_laser_hmi/worker.py | 192 ++++++++++++++ tests/test_backend.py | 41 +++ tests/test_recorder.py | 46 ++++ tests/test_settings.py | 34 +++ uv.lock | 251 ++++++++++++++++++ 28 files changed, 2051 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 config.toml create mode 100644 docs/design.md create mode 100644 pyproject.toml create mode 100644 src/line_laser_hmi/__init__.py create mode 100644 src/line_laser_hmi/__main__.py create mode 100644 src/line_laser_hmi/app.py create mode 100644 src/line_laser_hmi/backend.py create mode 100644 src/line_laser_hmi/main_window.py create mode 100644 src/line_laser_hmi/panels/__init__.py create mode 100644 src/line_laser_hmi/panels/chart_panel.py create mode 100644 src/line_laser_hmi/panels/connection_panel.py create mode 100644 src/line_laser_hmi/panels/log_panel.py create mode 100644 src/line_laser_hmi/panels/manual_panel.py create mode 100644 src/line_laser_hmi/panels/mode_panel.py create mode 100644 src/line_laser_hmi/panels/polling_panel.py create mode 100644 src/line_laser_hmi/panels/pose_panel.py create mode 100644 src/line_laser_hmi/panels/status_panel.py create mode 100644 src/line_laser_hmi/py.typed create mode 100644 src/line_laser_hmi/recorder.py create mode 100644 src/line_laser_hmi/settings.py create mode 100644 src/line_laser_hmi/worker.py create mode 100644 tests/test_backend.py create mode 100644 tests/test_recorder.py create mode 100644 tests/test_settings.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2338f4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +dist/ +build/ +*.egg-info/ +records/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2164e44 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..bc3c1b3 --- /dev/null +++ b/config.toml @@ -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" diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..295d396 --- /dev/null +++ b/docs/design.md @@ -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`。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2325584 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/line_laser_hmi/__init__.py b/src/line_laser_hmi/__init__.py new file mode 100644 index 0000000..f465b69 --- /dev/null +++ b/src/line_laser_hmi/__init__.py @@ -0,0 +1,10 @@ +"""线激光 Modbus 上位机 GUI 包""" + +from line_laser_hmi.settings import UiConfig, load_config, save_config + +# 公开 GUI 之外最常复用的配置入口。 +__all__ = [ + "UiConfig", + "load_config", + "save_config", +] diff --git a/src/line_laser_hmi/__main__.py b/src/line_laser_hmi/__main__.py new file mode 100644 index 0000000..4fceb0c --- /dev/null +++ b/src/line_laser_hmi/__main__.py @@ -0,0 +1,6 @@ +"""支持 python -m line_laser_hmi 启动""" + +from line_laser_hmi.app import main + +if __name__ == "__main__": + main() diff --git a/src/line_laser_hmi/app.py b/src/line_laser_hmi/app.py new file mode 100644 index 0000000..821a854 --- /dev/null +++ b/src/line_laser_hmi/app.py @@ -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() diff --git a/src/line_laser_hmi/backend.py b/src/line_laser_hmi/backend.py new file mode 100644 index 0000000..40aa621 --- /dev/null +++ b/src/line_laser_hmi/backend.py @@ -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) diff --git a/src/line_laser_hmi/main_window.py b/src/line_laser_hmi/main_window.py new file mode 100644 index 0000000..e4f4c04 --- /dev/null +++ b/src/line_laser_hmi/main_window.py @@ -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) diff --git a/src/line_laser_hmi/panels/__init__.py b/src/line_laser_hmi/panels/__init__.py new file mode 100644 index 0000000..83f9e3f --- /dev/null +++ b/src/line_laser_hmi/panels/__init__.py @@ -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", +] diff --git a/src/line_laser_hmi/panels/chart_panel.py b/src/line_laser_hmi/panels/chart_panel.py new file mode 100644 index 0000000..ac822c0 --- /dev/null +++ b/src/line_laser_hmi/panels/chart_panel.py @@ -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 diff --git a/src/line_laser_hmi/panels/connection_panel.py b/src/line_laser_hmi/panels/connection_panel.py new file mode 100644 index 0000000..24095ff --- /dev/null +++ b/src/line_laser_hmi/panels/connection_panel.py @@ -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) diff --git a/src/line_laser_hmi/panels/log_panel.py b/src/line_laser_hmi/panels/log_panel.py new file mode 100644 index 0000000..8b6f2b9 --- /dev/null +++ b/src/line_laser_hmi/panels/log_panel.py @@ -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() diff --git a/src/line_laser_hmi/panels/manual_panel.py b/src/line_laser_hmi/panels/manual_panel.py new file mode 100644 index 0000000..8bf298c --- /dev/null +++ b/src/line_laser_hmi/panels/manual_panel.py @@ -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()) diff --git a/src/line_laser_hmi/panels/mode_panel.py b/src/line_laser_hmi/panels/mode_panel.py new file mode 100644 index 0000000..ef8ed0d --- /dev/null +++ b/src/line_laser_hmi/panels/mode_panel.py @@ -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) diff --git a/src/line_laser_hmi/panels/polling_panel.py b/src/line_laser_hmi/panels/polling_panel.py new file mode 100644 index 0000000..9aa611f --- /dev/null +++ b/src/line_laser_hmi/panels/polling_panel.py @@ -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() diff --git a/src/line_laser_hmi/panels/pose_panel.py b/src/line_laser_hmi/panels/pose_panel.py new file mode 100644 index 0000000..1c5e095 --- /dev/null +++ b/src/line_laser_hmi/panels/pose_panel.py @@ -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}") diff --git a/src/line_laser_hmi/panels/status_panel.py b/src/line_laser_hmi/panels/status_panel.py new file mode 100644 index 0000000..603af42 --- /dev/null +++ b/src/line_laser_hmi/panels/status_panel.py @@ -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;" + ) diff --git a/src/line_laser_hmi/py.typed b/src/line_laser_hmi/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/line_laser_hmi/recorder.py b/src/line_laser_hmi/recorder.py new file mode 100644 index 0000000..3c64724 --- /dev/null +++ b/src/line_laser_hmi/recorder.py @@ -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 diff --git a/src/line_laser_hmi/settings.py b/src/line_laser_hmi/settings.py new file mode 100644 index 0000000..54b86ca --- /dev/null +++ b/src/line_laser_hmi/settings.py @@ -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" diff --git a/src/line_laser_hmi/worker.py b/src/line_laser_hmi/worker.py new file mode 100644 index 0000000..34d4891 --- /dev/null +++ b/src/line_laser_hmi/worker.py @@ -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 diff --git a/tests/test_backend.py b/tests/test_backend.py new file mode 100644 index 0000000..42386b1 --- /dev/null +++ b/tests/test_backend.py @@ -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() diff --git a/tests/test_recorder.py b/tests/test_recorder.py new file mode 100644 index 0000000..ed7e130 --- /dev/null +++ b/tests/test_recorder.py @@ -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 diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..1a1a80a --- /dev/null +++ b/tests/test_settings.py @@ -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" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..53cdef1 --- /dev/null +++ b/uv.lock @@ -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" }, +]