diff --git a/README.md b/README.md index 01c0af7..ccdb03a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,14 @@ - 提供各种工具类,如配置加载器、Modbus通信、串口管理等 - 采用单例模式确保资源共享 +5. **硬件集成层**: + - **相机子系统**:基于海康威视SDK进行集成 + - 相机管理器(CameraManager):单例模式,管理相机生命周期 + - 相机显示组件(CameraDisplayWidget):用于实时显示相机画面 + - 相机设置控制器(CameraSettingsWidget):管理相机参数设置 + - **串口通信**:与称重设备、扫描器等外设通信 + - **Modbus通信**:与PLC设备通信 + ## 技术栈 1. **前端技术**: @@ -38,9 +46,10 @@ 3. **通信技术**: - Modbus TCP用于与PLC设备通信 - 串口通信用于与称重设备、条码扫描器等外设通信 + - 海康威视SDK用于相机图像采集和处理 4. **设计模式**: - - 单例模式(配置加载器、监控器等) + - 单例模式(配置加载器、监控器、相机管理器等) - DAO模式(数据访问) - 观察者模式(信号槽) - 工厂模式(数据库连接) @@ -55,30 +64,75 @@ - `db/`:包含数据库文件 - `config/`:包含配置文件 - `logs/`:包含日志文件 + - `camera/`:包含相机模块和SDK接口类 2. **核心文件**: - `main.py`:程序入口点 - `widgets/login_widget.py`:登录窗口控制器 - `widgets/main_window.py`:主窗口控制器 + - `widgets/camera_manager.py`:相机管理器 + - `widgets/camera_display_widget.py`:相机显示组件 + - `widgets/camera_settings_widget.py`:相机设置控制器 - `utils/config_loader.py`:配置加载器 - `utils/modbus_utils.py`:Modbus通信工具 - `utils/sql_utils.py`:数据库工具 + - `camera/CamOperation_class.py`:相机操作类 + - `camera/MvCameraControl_class.py`:海康威视相机控制SDK + - `utils/local_image_player.py`:本地图像序列播放器 3. **应用流程**: - - 程序启动后初始化日志系统 - - 加载配置文件 + - 程序启动后初始化日志系统和配置 + - 创建和初始化各子系统(数据库、电力监控器等) - 显示登录窗口 - 验证登录后显示主窗口 - - 主窗口中进行产线包装系统的操作 + - 主窗口中进行产线包装系统的操作,包括: + - 产品检测和包装 + - 实时相机监控 + - 数据采集和报表生成 + - 设备状态监控和控制 + +## 相机子系统详解 + +1. **架构设计**: + - 采用分层设计,将相机SDK封装在底层,提供简洁API供上层使用 + - 相机管理采用单例模式,确保全局只有一个相机实例 + - 使用信号槽机制实现相机状态与UI的松耦合通信 + +2. **核心组件**: + - `CameraManager`:单例类,负责相机设备枚举、开关、参数设置等 + - `CameraDisplayWidget`:显示组件,负责在UI中显示相机画面 + - `CameraSettingsWidget`:设置控制器,负责参数调整界面交互 + - `CamOperation_class`:相机操作封装类,直接与海康SDK交互 + - `LocalImagePlayer`:本地图像序列播放器,提供基于本地图片序列的视频模拟功能 + +3. **工作流程**: + - 系统启动时初始化相机SDK + - 用户界面显示时枚举并连接可用的相机设备 + - 启动相机图像采集并在UI中显示 + - 用户可通过设置界面调整相机参数(曝光、增益、帧率等) + - 系统关闭时正确释放相机资源 + +4. **配置管理**: + - 相机参数保存在`config/app_config.json`的`camera`部分 + - 包括默认曝光时间、增益、帧率等参数 + - 用户调整的参数可保存至配置文件持久化 + +5. **本地图像模式**: + - 支持本地图像序列播放,可用于模拟相机实时画面 + - 用户可选择包含图像序列的文件夹,系统自动按时间顺序播放 + - 可调整播放帧率、设置循环播放等参数 + - 适用于开发测试和演示场景,无需连接实际相机设备 + - 配置参数保存在`config/app_config.json`的`camera.local_mode`部分 ## 功能特点 1. **用户认证**:支持用户登录和权限控制 2. **产线监控**:实时监控产线状态、电力消耗等 3. **数据采集**:采集称重数据、检验数据等 -4. **报表生成**:生成各类统计报表 -5. **设备通信**:与PLC、称重设备等通信 -6. **多模式支持**:支持单机模式和接口模式 +4. **相机集成**:支持实时图像采集、显示和参数调整 +5. **报表生成**:生成各类统计报表 +6. **设备通信**:与PLC、称重设备等通信 +7. **多模式支持**:支持单机模式和接口模式 ## 运行环境 @@ -98,7 +152,26 @@ - 默认使用SQLite数据库,位于`db/jtDB.db` - 可在`config/app_config.json`中配置其他数据库 -3. 运行程序: +3. 配置相机: + - 在`config/app_config.json`中的`camera`部分调整相机参数 + - 默认参数: + ```json + "camera": { + "enabled": false, + "default_exposure": 20000, + "default_gain": 10, + "default_framerate": 30, + "local_mode": { + "enabled": false, + "folder_path": "", + "framerate": 15, + "loop": true, + "file_patterns": [".jpg", ".jpeg", ".png", ".bmp"] + } + } + ``` + +4. 运行程序: ``` python main.py ``` @@ -109,4 +182,5 @@ 1. 添加新的数据源:扩展`utils/sql_utils.py` 2. 添加新的设备通信协议:参考`utils/modbus_utils.py` -3. 添加新的UI界面:在`ui/`目录下创建新的UI类,在`widgets/`目录下创建对应的控制器类 \ No newline at end of file +3. 添加新的UI界面:在`ui/`目录下创建新的UI类,在`widgets/`目录下创建对应的控制器类 +4. 扩展相机功能:修改`widgets/camera_manager.py`和`camera/CamOperation_class.py` \ No newline at end of file diff --git a/app_config.json b/app_config.json new file mode 100644 index 0000000..d3056e5 --- /dev/null +++ b/app_config.json @@ -0,0 +1,8 @@ +{ + "local_image_mode": { + "enabled": true, + "folder_path": "/Users/meng/Downloads/images", + "framerate": 15, + "loop": true + } +} \ No newline at end of file diff --git a/config/app_config.json b/config/app_config.json index d348eb8..2ab4087 100644 --- a/config/app_config.json +++ b/config/app_config.json @@ -46,7 +46,19 @@ "enabled": false, "default_exposure": 20000, "default_gain": 10, - "default_framerate": 30 + "default_framerate": 30, + "local_mode": { + "enabled": true, + "folder_path": "/Users/meng/Downloads/images", + "framerate": 15, + "loop": true, + "file_patterns": [ + ".jpg", + ".jpeg", + ".png", + ".bmp" + ] + } }, "modbus": { "host": "localhost", @@ -79,6 +91,19 @@ "stop_bits": 1, "timeout": 1 }, + "xj": { + "bit": 10, + "code": "xj", + "data_bits": 8, + "parity": "N", + "port": "19200", + "query_cmd": "01 41 0d", + "query_interval": 5, + "auto_query": true, + "ser": "COM3", + "stop_bits": 1, + "timeout": 1 + }, "scanner": { "code": "scanner", "data_bits": 8, diff --git a/db/jtDB.db b/db/jtDB.db index ed0853b..768b159 100644 Binary files a/db/jtDB.db and b/db/jtDB.db differ diff --git a/requirements.txt b/requirements.txt index f036d12..ed0491b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,5 @@ pandas>=1.4.0 # 数据分析和处理 # 可选依赖 pillow>=9.0.0 # 图像处理,用于相机功能 pynput>=1.7.6 # 键盘监听 -requests>=2.27.1 # HTTP请求 \ No newline at end of file +requests>=2.27.1 # HTTP请求 +opencv-python>=4.5.5.0 # 图像处理和视频功能,用于本地图像播放 \ No newline at end of file diff --git a/tests/main_window_old.py b/tests/main_window_old.py deleted file mode 100644 index bdbf667..0000000 --- a/tests/main_window_old.py +++ /dev/null @@ -1,2973 +0,0 @@ -import os -import sys -import logging -import json -from datetime import datetime -from pathlib import Path -from utils.modbus_utils import ModbusUtils -from utils.modbus_monitor import get_instance as get_modbus_monitor -from utils.app_mode import AppMode -from apis.gc_api import GcApi -from utils.register_handlers import ( - NGHandler, - WeightDataHandler, - LabelSignalHandler, - MachineStatusHandlers, - LoadingFeedbackHandler, - UnloadingFeedbackHandler, - Error1Handler, - Error2Handler, - Error3Handler, - UnloadingLevelHandler, - UnloadingPositionHandler, - EmergencyStopHandler -) -from utils.electricity_monitor import ElectricityHandler -# 导入PySide6 -from PySide6.QtWidgets import ( - QWidget, QMessageBox, QTableWidgetItem, QStackedWidget, QLabel, - QTableWidget, QMenu, QComboBox, QFormLayout, QDialog, QVBoxLayout, -) -from PySide6.QtCore import Qt, QTimer, Slot, Signal -from PySide6.QtGui import QBrush, QColor -import time - -# 导入UI -from ui.main_window_ui import MainWindowUI -# 导入相机显示组件 -from widgets.camera_display_widget import CameraDisplayWidget - -# 导入检验配置管理器 -from utils.inspection_config_manager import InspectionConfigManager -# 导入托盘类型管理器 -from utils.pallet_type_manager import PalletTypeManager -# 导入串口管理 -from utils.serial_manager import SerialManager -from widgets.report_dialog import ReportDialog -from widgets.unloading_dialog_widget import UnloadingDialog - -class MainWindow(MainWindowUI): - """主窗口""" - - # 定义信号作为类变量 - loading_feedback_signal = Signal(str, str) # 参数:status_type, desc - unloading_feedback_signal = Signal(str, str) # 参数:status_type, desc - unloading_level_ui_signal = Signal(int) # 用于在主线程中更新下料层数UI - unloading_position_ui_signal = Signal(int) # 用于在主线程中更新下料位置UI - emergency_stop_signal = Signal(int, str) # 用于在主线程中处理急停信号 - - def __init__(self, user_id=None, user_name=None, corp_name=None, corp_id=None): - """初始化主窗口""" - super().__init__(user_id) - - # 初始化用户信息 - self.user_id = user_id - self.user_name = user_name - self.corp_name = corp_name - self.corp_id = corp_id - - # 初始化系统变量 - self._current_weight = 0.0 # 当前重量 - self._last_weight_time = 0 # 上次称重时间 - self._stability_check_timer = None # 稳定性检查定时器 - self._weight_stable_threshold = 2 # 重量稳定阈值(秒) - self._weight_processed = False # 新增:标记当前重量是否已处理,避免重复处理 - self._last_processed_weight = 0.0 # 新增:记录上次处理的重量 - - # 初始化数据加载状态标志 - self._loading_data_in_progress = False # 数据加载状态标志,防止循环调用 - self._current_order_code = None # 存储当前订单号 - self.init_seq = {} # 初始化轴包装的序号 - - # 初始化拆垛和下料相关的属性 - self._current_stow_num = 0 # 当前拆垛层数 - self._current_unload_num = 0 # 当前下料层数 - self._total_unload_num = 0 # 总下料层数 - self._current_unload_info = None # 存储当前下料信息 - self._loading_info = None # 存储上料对话框的信息 - self._is_loading_active = False # 标识上料任务是否正在进行 - - # 信号的连接在connect_signals方法中统一处理,不在这里连接 - - # 称重相关变量 - self._current_weight = None # 当前称重值(千克) - self._last_weight_time = None # 最后一次称重时间 - self._weight_stable_threshold = 2 # 重量稳定阈值(秒) - self._stability_check_timer = None # 用于检查重量稳定性的定时器 - - # 设置窗口标题 - if user_name and corp_name: - self.setWindowTitle(f"腾智微丝产线包装系统 ({corp_name})") - - # 加载配置文件 - self.config = self.load_config() - self.camera_enabled = self.config.get('camera', {}).get('enabled', False) - - # 初始化检验配置管理器 - self.inspection_manager = InspectionConfigManager.get_instance() - - # 初始化托盘类型管理器 - self.pallet_type_manager = PalletTypeManager.get_instance() - - # 创建表单布局,用于添加托盘类型选择控件 - self.material_form_layout = QFormLayout() - self.material_content_layout.addLayout(self.material_form_layout) - - self.output_form_layout = QFormLayout() - self.output_content_layout.addLayout(self.output_form_layout) - - # 创建相机显示组件和占位标签 - self.camera_display = None - self.material_placeholder = None - - # 初始化上料区显示 - self.init_camera_display() - - # 为下料区添加占位标签,确保它保持为空 - self.output_placeholder = QWidget() - self.output_placeholder.setStyleSheet("background-color: #f0f0f0;") - placeholder_layout = QVBoxLayout(self.output_placeholder) - placeholder_layout.setAlignment(Qt.AlignCenter) - - # 添加标题标签 - title_label = QLabel("下料区") - title_label.setAlignment(Qt.AlignCenter) - title_label.setStyleSheet("color: #888888;") - title_label.setFont(self.second_title_font) - placeholder_layout.addWidget(title_label) - - self.output_content_layout.addWidget(self.output_placeholder) - - # 添加下料信息标签 - self.unloading_level_label = QLabel("下料层数:--") - self.unloading_position_label = QLabel("下料位置:--") - placeholder_layout.addWidget(self.unloading_level_label) - placeholder_layout.addWidget(self.unloading_position_label) - self.unloading_level_label.setStyleSheet("color: #888888; font-weight: bold;") - self.unloading_position_label.setStyleSheet("color: #888888; font-weight: bold;") - self.unloading_level_label.setFont(self.normal_font) - self.unloading_position_label.setFont(self.normal_font) - - # 创建堆叠部件 - self.stacked_widget = QStackedWidget() - self.stacked_widget.addWidget(self.central_widget) # 主页面 - - # 不在这里直接初始化相机设置组件 - # 延迟创建,保证创建的时候SettingsUI的所有控件都已经准备好 - self.camera_settings = None - - # 设置中央部件为堆叠部件 - self.setCentralWidget(self.stacked_widget) - - # # 添加托盘类型选择下拉框 - # self.add_pallet_type_selectors() - - # 连接信号和槽 - self.connect_signals() - - # 默认显示主页面 - self.stacked_widget.setCurrentIndex(0) - - # 配置检验列 - 使用检验配置管理器获取启用的列数和标题 - self.update_inspection_columns() - - # 设置表格上下文菜单 - self.process_table.setContextMenuPolicy(Qt.CustomContextMenu) - - # 加载未完成的检验数据 - self._safe_load_data() - - # 加载已完成检验数据 - self.show_pack_item() - # 创建状态处理器实例 - self.machine_handlers = MachineStatusHandlers() - - # 添加状态显示到状态栏 - self.modbus_status_label = QLabel("Modbus: 未连接") - self.weight_label = QLabel("重量: --") - self.label_status_label = QLabel("贴标: 无贴标") - self.error_status_label = QLabel("故障: 无") - - # 设置样式 - self.error_status_label.setStyleSheet("color: green; font-weight: bold;") - - # 添加到状态栏 - self.statusBar().addPermanentWidget(self.modbus_status_label) - self.statusBar().addPermanentWidget(self.weight_label) - self.statusBar().addPermanentWidget(self.label_status_label) - self.statusBar().addPermanentWidget(self.error_status_label) - self.statusBar().addPermanentWidget(QLabel(" ")) - logging.info(f"主窗口已创建,用户: {user_name}") - - # 初始化串口管理器 - self.serial_manager = SerialManager() - - # 注册串口数据回调函数 - self.register_serial_callbacks() - - # 加载托盘号列表 - self.load_pallet_codes() - - # 恢复开始按钮原始样式 - self.restore_start_button_style() - - def get_axios_num(self,tray_id): - """获取托盘号对应的轴号""" - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - axios_num = inspection_dao.get_axios_num(tray_id) - return axios_num - def get_axios_num_by_order_id(self, order_id): - """获取订单号对应的轴号""" - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - axios_num = inspection_dao.get_axios_num_by_order_id(order_id) - return axios_num - def load_config(self): - """加载配置文件""" - config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "app_config.json") - try: - with open(config_path, 'r', encoding='utf-8') as f: - config = json.load(f) - logging.info(f"已加载配置文件: {config_path}") - return config - except Exception as e: - logging.error(f"加载配置文件失败: {e}") - return {} - - def connect_signals(self): - """连接信号槽""" - # 连接微丝产线表格单元格变更信号槽 - self.process_table.cellChanged.connect(self.handle_inspection_cell_changed) - - # 连接菜单动作 - self.main_action.triggered.connect(self.show_main_page) - self.settings_action.triggered.connect(self.show_settings_page) - - # 工程号输入框回车事件 - self.order_edit.returnPressed.connect(self.handle_order_enter) - - # 托盘号输入框回车和切换事件,触发未加载数据查询 - # QComboBox没有returnPressed信号,只有currentTextChanged和activated信号 - self.tray_edit.currentTextChanged.connect(self.handle_tray_changed) - self.tray_edit.activated.connect(self.handle_tray_changed) # 当用户选择一项时触发 - - # 连接按钮事件 - self.input_button.clicked.connect(self.handle_input) - self.output_button.clicked.connect(self.handle_output) - self.start_button.clicked.connect(self.handle_start) - self.stop_button.clicked.connect(self.handle_stop) - - # 设置表格上下文菜单 - self.process_table.setContextMenuPolicy(Qt.CustomContextMenu) - self.process_table.customContextMenuRequested.connect(self.show_table_context_menu) - - # 只有在相机启用时连接相机信号 - if self.camera_enabled and hasattr(self, 'camera_display'): - self.camera_display.signal_camera_status.connect(self.handle_camera_status) - - # 连接报表按钮点击事件 - self.report_button.clicked.connect(self.on_report) - - # 连接加载反馈信号 - self.loading_feedback_signal.connect(self._handle_loading_feedback_ui) - - # 连接下料反馈信号 - self.unloading_feedback_signal.connect(self._handle_unloading_feedback_ui) - - # 连接下料层数和位置UI更新信号 - self.unloading_level_ui_signal.connect(self.handle_unloading_level_ui) - self.unloading_position_ui_signal.connect(self.handle_unloading_position_ui) - - # 连接急停信号 - self.emergency_stop_signal.connect(self._handle_emergency_stop_ui) - - def update_inspection_columns(self): - """更新检验列配置 - 使用检验配置管理器获取启用的列数和标题""" - try: - # 获取已启用的检验配置 - enabled_configs = self.inspection_manager.get_enabled_configs() - - # 获取启用的列数 - column_count = len(enabled_configs) - if column_count == 0: - # 如果没有启用的列,至少显示一列 - column_count = 1 - headers = ["检验项"] - else: - # 如果有启用的列,使用配置的标题 - headers = [config['display_name'] for config in enabled_configs] - - # 设置检验列 - self.set_inspection_columns(column_count, headers) - logging.info(f"已更新检验列配置:{column_count}列, 标题: {headers}") - except Exception as e: - logging.error(f"更新检验列配置失败: {str(e)}") - # 如果更新失败,使用默认配置 - self.set_inspection_columns(1, ["检验项"]) - - def show_main_page(self): - self.stacked_widget.setCurrentWidget(self.central_widget) - - # 更新检验列配置 - self.update_inspection_columns() - - # 加载未完成的检验数据 - self._safe_load_data() - - # 处理相机显示 - if self.camera_enabled and self.camera_display: - from widgets.camera_manager import CameraManager - camera_manager = CameraManager.get_instance() - - # 检查相机是否已打开 - if camera_manager.isOpen: - # 更新UI显示相机画面 - self.update_camera_ui(True) - - # 如果相机未在采集,则开始采集 - if not camera_manager.isGrabbing: - # 使用内部方法启动相机显示 - QTimer.singleShot(100, self._start_camera_display) - logging.info("主页面显示:启动相机显示") - else: - # 如果相机未打开,尝试重新初始化 - QTimer.singleShot(100, self.initialize_camera) - logging.info("主页面显示:尝试初始化相机") - - # 加载托盘号列表 - self.load_pallet_codes() - - logging.info("显示主页面") - - def load_pallet_codes(self): - """从托盘类型管理器加载托盘号并更新到tray_edit""" - try: - # 获取当前文本,以便保留用户选择 - current_text = self.tray_edit.currentText() - - # 清空当前项目 - self.tray_edit.clear() - - # 获取托盘号 - pallet_codes = self.pallet_type_manager.get_pallet_code() - - if pallet_codes and len(pallet_codes) > 0: - # 添加托盘号到下拉框 - self.tray_edit.addItems(pallet_codes) - - # 如果有之前的选择,尝试恢复它 - index = self.tray_edit.findText(current_text) - if index != -1: - self.tray_edit.setCurrentIndex(index) - else: - self.tray_edit.setCurrentIndex(-1) - self.tray_edit.setCurrentText("") - - logging.info(f"已加载托盘号,共 {len(pallet_codes)} 个") - else: - # 如果没有托盘号,则不添加任何项目,保持为空 - logging.warning("未找到托盘号,托盘号列表将为空") - self.tray_edit.setCurrentText("") - - - except Exception as e: - logging.error(f"加载托盘号失败: {str(e)}") - # 如果加载失败,确保下拉框为空 - self.tray_edit.clear() - self.tray_edit.setCurrentText("") - - def show_settings_page(self): - """显示设置页面""" - # 创建设置窗口 - if not hasattr(self, 'settings_window'): - from widgets.settings_window import SettingsWindow - self.settings_window = SettingsWindow(self) - # 连接设置改变信号 - self.settings_window.settings_changed.connect(self.on_settings_changed) - - # 显示设置窗口 - self.settings_window.show() - logging.info("显示设置窗口") - - def on_settings_changed(self): - """设置改变时触发""" - # 重新加载配置 - from utils.config_loader import ConfigLoader - config_loader = ConfigLoader.get_instance() - config_loader.load_config() - self.config = self.load_config() # 重新加载配置到 self.config - - # 更新串口管理器配置 - self.serial_manager.reload_config() - - # 重新打开已配置的串口 - self.serial_manager.auto_open_configured_ports() - - # 重新注册串口回调函数 - self.register_serial_callbacks() - - # 重新加载托盘号 - self.load_pallet_codes() - - logging.info("设置已更新,重新加载配置并重新打开串口,已重新注册扫码器回调") - - def handle_input(self): - """处理上料按钮点击事件""" - # 获取托盘号 - tray_id = self.tray_edit.currentText() - - # 启动监听(不论后续是否确认上料) - # 启动Modbus监控 - if not hasattr(self, 'modbus_monitor') or not self.modbus_monitor.is_running(): - self.setup_modbus_monitor() - logging.info("已在上料操作前启动Modbus监控") - - # 启动串口监听 - self.serial_manager.auto_open_configured_ports() - - # 启动键盘监听器 - self.serial_manager.start_keyboard_listener() - logging.info("已在上料操作前启动键盘监听器") - - # 创建上料对话框 - from widgets.loading_dialog_widget import LoadingDialog - dialog = LoadingDialog(parent=self,user_id=self.user_id,user_name=self.user_name,corp_id=self.corp_id) - - # 如果已有上料信息,作为参考显示在对话框中,但允许用户修改 - if self._loading_info and self._current_stow_num > 0: - dialog.order_input.setText(self._loading_info.get('order_code', '')) - dialog.tray_input.setText(self._loading_info.get('tray_code', '')) - dialog.axis_value.setText(self._loading_info.get('axis_value', '--')) - dialog.quantity_value.setText(self._loading_info.get('quantity_value', '--')) - dialog.weight_value.setText(self._loading_info.get('weight_value', '--')) - dialog.pallet_tier_value.setText(str(self._current_stow_num)) - # 不禁用输入框,允许用户修改 - - # 连接订单号信号 - dialog.order_code_signal.connect(self.handle_order_code_received) - - # 显示对话框 - result = dialog.exec() - - # 如果用户确认,则执行上料操作 - if result == QDialog.Accepted: - # 从对话框中获取订单号和托盘号,并更新到主窗口 - order_code = dialog.order_input.text() - tray_code = dialog.tray_input.text() - self._current_order_code = order_code - self.tray_edit.setCurrentText(tray_code) - - # 获取托盘料值作为拆垛层数 - stow_num = dialog.pallet_tier_value.text() - if stow_num == "--" or not stow_num: - QMessageBox.warning(self, "错误", "未获取到托盘料信息,请重试") - return - - # 始终使用用户最新输入的信息 - self._current_stow_num = int(stow_num) - # 保存上料信息 - self._loading_info = { - 'order_code': dialog.order_input.text(), - 'tray_code': dialog.tray_input.text(), - 'axis_value': dialog.axis_value.text(), - 'quantity_value': dialog.quantity_value.text(), - 'weight_value': dialog.weight_value.text(), - } - - # 执行Modbus操作 - modbus = ModbusUtils() - client = modbus.get_client() - try: - # 上料 D0 给到层数,等待点击开始后,进行上料 - success0 = modbus.write_register_until_success(client, 0, self._current_stow_num) - if success0: - # 创建状态标签并显示在右上角 - self.show_operation_status("拆垛层数", "input", str(self._current_stow_num)) - else: - QMessageBox.information(self, "操作提示", "上料失败") - except Exception as e: - logging.error(f"上料操作失败: {str(e)}") - QMessageBox.critical(self, "错误", f"上料操作失败: {str(e)}") - finally: - modbus.close_client(client) - - def handle_output(self): - """处理下料按钮点击事件""" - try: - # 启动监听(不论后续是否确认下料) - if not hasattr(self, 'modbus_monitor') or not self.modbus_monitor.is_running(): - self.setup_modbus_monitor() - self.serial_manager.auto_open_configured_ports() - self.serial_manager.start_keyboard_listener() - - dialog = UnloadingDialog(self, self.user_id) - - # 如果有之前的下料信息,作为参考显示在对话框中,但允许用户修改 - if self._current_unload_info: - dialog.set_unloading_info(self._current_unload_info) - logging.info(f"显示之前的下料信息作为参考") - - if dialog.exec_() == QDialog.Accepted: - # 获取用户最新输入的下料信息 - unloading_info = dialog.get_unloading_info() - - # 始终使用用户最新输入的信息 - self._total_unload_num = int(unloading_info.get('tier', '3')) - self._current_unload_num = 1 # 从第一层开始 - self._current_unload_info = unloading_info - logging.info(f"下料任务设置:总层数={self._total_unload_num}, 当前层数={self._current_unload_num}") - - # 将初始层数(1)写入寄存器 - modbus = ModbusUtils() - client = modbus.get_client() - try: - modbus.write_register_until_success(client, 4, self._current_unload_num) - logging.info(f"下料初始化成功:层数 {self._current_unload_num} 已写入寄存器4") - finally: - modbus.close_client(client) - - # 统一更新UI显示 - tray_code = self._current_unload_info.get('tray_code', '') - self.show_operation_status("下料层数", "output", f"{self._current_unload_num}/{self._total_unload_num} ") - else: - logging.info("下料对话框已取消") - except Exception as e: - logging.error(f"处理下料操作失败: {str(e)}") - QMessageBox.critical(self, "错误", f"处理下料操作失败: {str(e)}") - - def restore_start_button_style(self): - """恢复开始按钮的原始样式""" - try: - # 使用与main_window_ui.py中初始化时相同的样式,只恢复背景色 - button_style = """ - QPushButton { - padding: 8px 16px; - font-weight: bold; - border-radius: 4px; - border: 1px solid #4caf50; - } - QPushButton:hover { - background-color: #d7eeda; - } - """ - self.start_button.setStyleSheet(button_style) - logging.info("已恢复开始按钮原始样式") - except Exception as e: - logging.error(f"恢复开始按钮样式失败: {str(e)}") - - def fill_start_button_style(self): - """填充开始按钮样式 - 绿色背景,白色字体""" - try: - # 使用与main_window_ui.py中初始化时相同的样式,只改变背景色和文字颜色 - button_style = """ - QPushButton { - padding: 8px 16px; - font-weight: bold; - border-radius: 4px; - background-color: #4caf50; - color: white; - border: 1px solid #4caf50; - } - QPushButton:hover { - background-color: #45a049; - color: white; - } - """ - self.start_button.setStyleSheet(button_style) - logging.info("已填充开始按钮样式") - except Exception as e: - logging.error(f"填充开始按钮样式失败: {str(e)}") - - def handle_start(self): - """ - 处理开始按钮点击事件 - 根据当前操作类型(上料/下料)写入相应的寄存器 - - 上料: 将当前层数写入D0寄存器,并将D2寄存器设置为1 - - 下料: 确保D3寄存器设置为1,D4寄存器已包含当前下料层数 - """ - modbus = ModbusUtils() - client = modbus.get_client() - try: - # 判断当前操作类型(通过检查当前下料信息是否存在) - if self._current_unload_info and self._current_unload_num > 0: - # 下料模式 - 开始下料操作 - # 确保寄存器3(下料启动)设为1,寄存器4已在handle_output中设置了当前层数 - success2 = modbus.write_register_until_success(client, 2, 1) - success3 = modbus.write_register_until_success(client, 3, 1) - - if success2 and success3: - logging.info(f"开始下料操作:当前层数 {self._current_unload_num}/{self._total_unload_num}") - QMessageBox.information(self, "操作提示", f"开始下料操作:当前第{self._current_unload_num}层") - # 填充按钮样式 - self.fill_start_button_style() - else: - QMessageBox.warning(self, "错误", "开始下料操作失败") - else: - # 上料模式 - 默认操作 - # 写入当前层数到D0寄存器 - success0 = modbus.write_register_until_success(client, 0, self._current_stow_num) - success2 = modbus.write_register_until_success(client, 2, 1) - - if success0 and success2: - self._is_loading_active = True # 标记上料任务已开始 - logging.info(f"开始上料操作:当前层数 {self._current_stow_num}") - # 填充按钮样式 - self.fill_start_button_style() - else: - QMessageBox.warning(self, "错误", "开始上料操作失败") - - except Exception as e: - logging.error(f"开始操作失败: {str(e)}") - QMessageBox.critical(self, "错误", f"开始操作失败: {str(e)}") - finally: - modbus.close_client(client) - - def handle_stop(self): - """处理停止按钮点击事件,根据当前操作类型(上料/下料)停止相应的操作并关闭modbus监控""" - modbus = ModbusUtils() - client = modbus.get_client() - try: - # 判断当前操作类型(通过检查当前下料信息是否存在) - if self._current_unload_info and self._current_unload_num > 0: - # 下料模式 - 停止下料操作 - success3 = modbus.write_register_until_success(client, 3, 0) - - if success3: - logging.info(f"停止下料操作:当前层数 {self._current_unload_num}/{self._total_unload_num}") - QMessageBox.information(self, "操作提示", "已停止下料操作") - # 恢复按钮原始样式 - self.restore_start_button_style() - else: - QMessageBox.warning(self, "错误", "停止下料操作失败") - else: - # 上料模式 - 停止上料操作 - success2 = modbus.write_register_until_success(client, 2, 0) - - if success2: - self._is_loading_active = False # 标记上料任务已停止 - logging.info("停止上料操作") - QMessageBox.information(self, "操作提示", "已停止上料操作") - # 恢复按钮原始样式 - self.restore_start_button_style() - else: - QMessageBox.warning(self, "错误", "停止上料操作失败") - - except Exception as e: - logging.error(f"停止操作失败: {str(e)}") - QMessageBox.critical(self, "错误", f"停止操作失败: {str(e)}") - finally: - modbus.close_client(client) - # 停止Modbus监控 - if hasattr(self, 'modbus_monitor'): - logging.info("停止Modbus监控") - self.modbus_monitor.stop() - # 停止串口监听 - self.serial_manager.stop_keyboard_listener() - self.serial_manager.close_all_ports() - - def clear_operation_status(self, operation_type): - """清除右上角的操作状态显示。""" - status_label_name = f"{operation_type}_status_label" - if hasattr(self, status_label_name): - try: - getattr(self, status_label_name).deleteLater() - delattr(self, status_label_name) - logging.info(f"已清除 '{operation_type}' 状态标签。") - except AttributeError: - pass # Failsafe - - def handle_camera_status(self, is_connected, message): - """处理相机状态变化""" - if is_connected: - logging.info("相机已连接并显示") - self.update_camera_ui(True) - else: - logging.warning(f"相机显示问题: {message}") - # 更新占位符文本 - if self.material_placeholder: - self.material_placeholder.setText(f"相机错误: {message}" if message else "相机未连接") - self.update_camera_ui(False) - - def handle_camera_connection(self, is_connected, message): - """处理相机连接状态变化""" - if is_connected: - logging.info("相机已连接") - # 如果当前在主页面,直接开始显示相机画面 - if self.stacked_widget.currentWidget() == self.central_widget: - self.camera_display.start_display() - else: - if message: - logging.warning(f"相机连接失败: {message}") - else: - logging.info("相机已断开") - # 如果相机断开,确保停止显示 - self.camera_display.stop_display() - - def handle_camera_params_changed(self, exposure_time, gain, frame_rate): - """处理相机参数变化""" - logging.info(f"相机参数已更新: 曝光={exposure_time:.1f}μs, 增益={gain:.1f}dB, 帧率={frame_rate:.1f}fps") - # 这里可以添加对相机参数变化的处理逻辑 - - def handle_camera_error(self, error_msg): - """处理相机错误""" - logging.error(f"相机错误: {error_msg}") - QMessageBox.warning(self, "相机错误", error_msg) - - def closeEvent(self, event): - """窗口关闭事件""" - # 停止Modbus监控 - if hasattr(self, 'modbus_monitor'): - logging.info("停止Modbus监控") - self.modbus_monitor.stop() - - # 处理相机关闭 - if self.camera_enabled and self.camera_display: - # 停止相机显示 - self.camera_display.stop_display() - - # 关闭相机设备 - try: - from widgets.camera_manager import CameraManager - camera_manager = CameraManager.get_instance() - if camera_manager.isOpen: - camera_manager.close_device() - logging.info("相机设备已关闭") - except Exception as e: - logging.error(f"关闭相机设备失败: {str(e)}") - - # 停止串口监听 - self.serial_manager.stop_keyboard_listener() - self.serial_manager.close_all_ports() - - # 接受关闭事件 - event.accept() - - - def handle_order_enter(self): - """处理工程号输入框按下回车事件""" - logging.info("工程号输入框按下回车事件") - # 获取当前输入的工程号 - gc_note = self.order_edit.text().strip() - if gc_note: - logging.info(f"输入的工程号: {gc_note}") - #判断是否是接口,如果不是接口直接添加如果是则走接口 - # 如果开启接口模式,则需要调用接口同步到业务库 - self.add_new_inspection_row(gc_note, self._current_order_code) - else: - logging.warning("工程号为空") - QMessageBox.warning(self, "输入提示", "请输入有效的工程号") - - def add_new_inspection_row(self, gc_note, order_code): - """在微丝产线表格中添加一条新记录,添加到表格末尾 - - Args: - gc_note: 工程号 - order_info: 从接口获取的工程号信息 - """ - try: - # 获取启用的检验配置 - enabled_configs = self.inspection_manager.get_enabled_configs() - - # 断开单元格变更信号,避免加载过程中触发保存 - try: - self.process_table.cellChanged.disconnect(self.handle_inspection_cell_changed) - except: - pass - - # 计算新行的行索引(添加到末尾) - data_start_row = self.process_table.rowCount() - - # 在末尾添加新行 - self.process_table.insertRow(data_start_row) - - # 计算新行的序号(最后一个序号+1) - new_seq = 1 # 默认为1 - if data_start_row > 2: # 如果有其他数据行 - prev_seq_item = self.process_table.item(data_start_row - 1, 0) - if prev_seq_item: - try: - prev_seq = int(prev_seq_item.text()) - new_seq = prev_seq + 1 - except ValueError: - new_seq = data_start_row - 1 # 备选方案:使用行索引作为序号 - - # 添加工程号到表格的第二列 - item = QTableWidgetItem(gc_note) - item.setTextAlignment(Qt.AlignCenter) - self.process_table.setItem(data_start_row, 1, item) - - # 添加序号到表格的第一列 - item = QTableWidgetItem(str(new_seq)) - item.setTextAlignment(Qt.AlignCenter) - self.process_table.setItem(data_start_row, 0, item) - - # 获取订单信息 - order_info = self.inspection_manager.get_order_info(order_code) - - # 检验列设置为可编辑状态 - for i, config in enumerate(enabled_configs): - col_index = 2 + i # 检验列从第3列开始 - - # 创建单元格 - item = QTableWidgetItem("") - item.setTextAlignment(Qt.AlignCenter) - - # 如果有order_info数据,尝试匹配字段并设置值 - if order_info: - config_name = config.get('name') - # 检查order_info中是否有与config_name匹配的键 - if config_name in order_info: - value = str(order_info[config_name]) - item = QTableWidgetItem(value) - item.setTextAlignment(Qt.AlignCenter) - # 设置单元格背景为浅绿色,表示自动填充 - item.setBackground(QBrush(QColor("#c8e6c9"))) - - # 保存到数据库 - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - tray_id = self.tray_edit.currentText() - data = [{ - 'position': config.get('position'), - 'config_id': config.get('id'), - 'value': value, - 'status': 'pass', # 默认设置为通过状态 - 'remark': '', - 'tray_id': tray_id - }] - inspection_dao.save_inspection_data(self._current_order_code,gc_note, data) - logging.info(f"自动填充字段 {config_name} 值为 {value}") - - # 设置单元格属性以标识其关联的检验项 - item.setData(Qt.UserRole, config.get('id')) - self.process_table.setItem(data_start_row, col_index, item) - - # 包装列设置为可编辑状态 - packaging_start_col = 2 + len(enabled_configs) - for i in range(2): # 贴标和称重 - col_index = packaging_start_col + i - item = QTableWidgetItem("") - item.setTextAlignment(Qt.AlignCenter) - self.process_table.setItem(data_start_row, col_index, item) - - # 设置表格为可编辑状态 - self.process_table.setEditTriggers(QTableWidget.DoubleClicked | QTableWidget.EditKeyPressed) - - # 重新连接单元格内容变更信号 - self.process_table.cellChanged.connect(self.handle_inspection_cell_changed) - - # 选中新添加的行 - self.process_table.selectRow(data_start_row) - - # 限制最大行数 - self.limit_table_rows(10) # 最多保留10行数据 - - # 将工程号和托盘号保存到数据库,确保能够正确关联 - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - tray_id = self.tray_edit.currentText() - - # 为每个检验位置创建一个空记录,确保工程号在数据库中存在 - # 只为没有自动填充值的配置创建空记录 - for config in enabled_configs: - config_name = config.get('name') - # 如果order_info中没有对应的键,或者order_info为None - if not order_info or config_name not in order_info: - data = [{ - 'position': config.get('position'), - 'config_id': config.get('id'), - 'value': '', - 'status': '', # 默认设置为通过状态 - 'remark': '', - 'tray_id': tray_id - }] - inspection_dao.save_inspection_data(self._current_order_code,gc_note,gc_note, data) - - # 为贴标和称重也创建空记录 - for position in [11, 12, 13]: # 11是贴标,12是毛重,13是净重 - data = [{ - 'position': position, - 'config_id': position, - 'value': '', - 'status': 'pass', # 默认设置为通过状态 - 'remark': '', - 'tray_id': tray_id - }] - inspection_dao.save_inspection_data(self._current_order_code,gc_note, data) - - logging.info(f"已添加工程号 {gc_note} 的新记录,显示在第{new_seq}条") - - except Exception as e: - logging.error(f"添加新记录失败: {str(e)}") - QMessageBox.warning(self, "添加失败", f"添加新记录失败: {str(e)}") - finally: - # 重新加载数据,确保UI显示正确 - self._safe_load_data() - - def limit_table_rows(self, max_rows): - """限制表格最大行数 - - Args: - max_rows: 最大行数(不包括表头行) - """ - try: - # 计算数据总行数 - data_rows = self.process_table.rowCount() - 2 # 减去表头行 - - # 如果超过最大行数,删除多余的行 - if data_rows > max_rows: - # 要删除的行数 - rows_to_remove = data_rows - max_rows - - # 从最后一行开始删除 - for i in range(rows_to_remove): - self.process_table.removeRow(self.process_table.rowCount() - 1) - - logging.info(f"已限制表格最大行数为 {max_rows} 行数据,删除了 {rows_to_remove} 行") - - except Exception as e: - logging.error(f"限制表格行数失败: {str(e)}") - - def handle_inspection_cell_changed(self, row, column): - """处理微丝包装单元格内容变更 - - Args: - row: 行索引 - column: 列索引 - """ - try: - # 只处理数据行的检验列变更 - if row < 2: # 忽略表头行 - return - - # 忽略首尾两列(序号和工程号) - if column < 2: - return - - # 获取工程号 - order_item = self.process_table.item(row, 1) - if not order_item: - return - - gc_note = order_item.text().strip() - if not gc_note: - return - - # 获取托盘号 - tray_id = self.tray_edit.currentText() - - # 获取启用的检验配置 - enabled_configs = self.inspection_manager.get_enabled_configs() - - # 判断是否是检验列(非包装列) - packaging_start_col = 2 + len(enabled_configs) - - # 获取单元格内容 - cell_item = self.process_table.item(row, column) - if not cell_item: - return - - value = cell_item.text().strip() - - # 默认设置为通过状态 - status = 'pass' - - # 记录当前正在处理的数据类型,用于日志输出 - data_type = "检验" - - if column >= 2 and column < packaging_start_col: - # 是检验列 - config_index = column - 2 - if config_index < len(enabled_configs): - config = enabled_configs[config_index] - data_type = config['display_name'] - - # 显示临时状态消息 - self.statusBar().showMessage(f"正在保存检验数据: {data_type}={value}", 1000) - - # 验证数据有效性 - if self.validate_inspection_value(config, value): - # 设置单元格颜色为通过 - cell_item.setBackground(QBrush(QColor("#c8e6c9"))) # 浅绿色 - status = 'pass' - else: - # 设置单元格颜色为警告 - cell_item.setBackground(QBrush(QColor("#fff9c4"))) # 浅黄色 - status = 'warning' - - # 保存到数据库 - self.save_inspection_data(self._current_order_code, gc_note, tray_id, config['position'], config['id'], value, status) - - # 判断是否是包装列 - elif column == packaging_start_col: - # 贴标列 - data_type = "贴标" - self.statusBar().showMessage(f"正在保存贴标数据: {value}", 1000) - # 设置单元格颜色为通过 - cell_item.setBackground(QBrush(QColor("#c8e6c9"))) # 浅绿色 - # 保存贴标数据,position和config_id都是11 - self.save_inspection_data(self._current_order_code, gc_note, tray_id, 11, 11, value, status) - - elif column == packaging_start_col + 1: - # 毛重列 - data_type = "毛重" - self.statusBar().showMessage(f"正在保存称重数据: {value}", 1000) - # 设置单元格颜色为通过 - cell_item.setBackground(QBrush(QColor("#c8e6c9"))) # 浅绿色 - # 保存毛重数据,position和config_id都是12 - self.save_inspection_data(self._current_order_code, gc_note, tray_id, 12, 12, value, status) - elif column == packaging_start_col + 2: - # 净重列 - data_type = "净重" - self.statusBar().showMessage(f"正在保存净重数据: {value}", 1000) - # 设置单元格颜色为通过 - cell_item.setBackground(QBrush(QColor("#c8e6c9"))) # 浅绿色 - # 保存净重数据,position和config_id都是13 - self.save_inspection_data(self._current_order_code, gc_note, tray_id, 13, 13, value, status) - - # 记录详细日志 - logging.info(f"处理单元格变更: 行={row}, 列={column}, 类型={data_type}, 工程号={gc_note}, 值={value}, 状态={status}") - - except Exception as e: - logging.error(f"处理检验单元格变更失败: {str(e)}") - self.statusBar().showMessage(f"处理检验数据失败: {str(e)[:50]}...", 3000) - finally: - # 延迟一段时间后再触发查询,避免频繁刷新UI - # 但要避免在加载过程中触发新的加载 - if not self._loading_data_in_progress: - QTimer.singleShot(1000, self._safe_load_data) - - def validate_inspection_value(self, config, value): - """验证检验值是否有效 - - Args: - config: 检验配置 - value: 检验值 - - Returns: - bool: 是否有效 - """ - try: - # 特殊处理贴标和称重数据 - 这些数据默认都是有效的 - if config.get('position') in [11, 12]: # 11是贴标,12是称重 - return True - - # 检查值是否为空 - if not value and config.get('required', False): - return False - - # 根据数据类型验证 - data_type = config.get('data_type') - - if data_type == 'number': - # 数值类型验证 - try: - # 如果值为空且不是必填,则视为有效 - if not value and not config.get('required', False): - return True - - num_value = float(value) - min_value = config.get('min_value') - max_value = config.get('max_value') - - if min_value is not None and num_value < min_value: - return False - - if max_value is not None and num_value > max_value: - return False - - return True - except ValueError: - return False - - elif data_type == 'enum': - # 枚举类型验证 - enum_values = config.get('enum_values') - if enum_values and isinstance(enum_values, list): - # 如果值为空且不是必填,则视为有效 - if not value and not config.get('required', False): - return True - return value in enum_values - return False - - # 文本类型不做特殊验证 - return True - - except Exception as e: - logging.error(f"验证检验值失败: {str(e)}") - return False - - def save_inspection_data(self, order_id, gc_note, tray_id, position, config_id, value, status): - """保存检验数据到数据库 - - Args: - order_id: 订单号 - gc_note: 工程号 - position: 位置序号 - config_id: 配置ID - value: 检验值 - status: 状态 - """ - try: - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - modbus = ModbusUtils() - client = modbus.get_client() - # 记录保存前的详细日志 - logging.info(f"正在保存检验数据: 工程号={gc_note}, 托盘号={tray_id}, 位置={position}, 配置ID={config_id}, 值={value}, 状态={status}") - - # 构建数据 - data = [{ - 'position': position, - 'config_id': config_id, - 'value': value, - 'status': status, - 'remark': '', - 'tray_id': tray_id - }] - - # 保存到数据库 - inspection_dao.save_inspection_data(order_id, gc_note, data) - except Exception as e: - logging.error(f"保存检验数据失败: {str(e)}") - # 显示错误消息 - QMessageBox.warning(self, "保存失败", f"保存检验数据错误: {str(e)[:50]}...") - self.statusBar().showMessage(f"保存检验数据错误: {str(e)[:50]}...", 3000) - - def _safe_load_data(self): - """安全地加载数据,避免循环调用""" - # 获取当前托盘号,用于日志记录 - tray_id = self.tray_edit.currentText() - - if self._loading_data_in_progress: - # 如果已经在加载数据,不要再次触发 - logging.debug(f"已有数据加载正在进行,忽略此次请求 (托盘号: {tray_id})") - return - - try: - self._loading_data_in_progress = True - self.load_finished_inspection_data() - logging.info(f"数据加载完成,托盘号: {tray_id}") - except Exception as e: - logging.error(f"安全加载数据失败: {str(e)}, 托盘号: {tray_id}") - # 即使加载失败,也尝试显示包装记录 - try: - self.show_pack_item() - logging.info(f"加载失败后尝试显示包装记录, 托盘号: {tray_id}") - except Exception as ex: - logging.error(f"加载失败后显示包装记录失败: {str(ex)}, 托盘号: {tray_id}") - finally: - self._loading_data_in_progress = False - - def load_finished_inspection_data(self): - """加载未完成的检验数据并显示在表格中""" - # 注意:此方法通常应通过_safe_load_data调用,以防止循环 - try: - # 使用InspectionDAO获取未完成的检验数据 - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - - # 获取托盘号 - tray_id = self.tray_edit.currentText() - - # 使用get_inspection_data_unfinished获取未完成的数据 - unfinished_data = inspection_dao.get_inspection_data_unfinished(tray_id) - - # 断开单元格变更信号,避免加载过程中触发保存 - try: - self.process_table.cellChanged.disconnect(self.handle_inspection_cell_changed) - except: - pass - - # 清空表格现有数据行,只保留表头 - while self.process_table.rowCount() > 2: - self.process_table.removeRow(2) - - if not unfinished_data: - logging.info(f"托盘号 {tray_id} 没有未完成的检验数据") - # 确保表格完全清空,只保留表头行 - self.process_table.setRowCount(2) # 只保留表头的两行 - - # 重新连接单元格变更信号 - try: - self.process_table.cellChanged.connect(self.handle_inspection_cell_changed) - except: - pass - - # 加载包装记录 - return - - logging.info(f"已加载未完成的检验数据,共 {len(unfinished_data)} 条记录") - - # 获取启用的检验配置 - enabled_configs = self.inspection_manager.get_enabled_configs() - - # 按工程号分组 - orders_data = {} - for data in unfinished_data: - gc_note = data['gc_note'] - if gc_note not in orders_data: - orders_data[gc_note] = [] - orders_data[gc_note].append(data) - - # 添加数据到表格 - 从第3行开始添加数据 - row_idx = 2 - - # 使用DAO方法按创建时间排序工程号,确保FIFO顺序(最早创建的在最前面) - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - sorted_gc_notes = inspection_dao.get_orders_by_create_time(list(orders_data.keys())) - - for gc_note in sorted_gc_notes: - items = orders_data[gc_note] - - # 添加新行 - self.process_table.insertRow(row_idx) - - # 添加序号到第一列 - seq_item = QTableWidgetItem(str(row_idx - 1)) - seq_item.setTextAlignment(Qt.AlignCenter) - self.process_table.setItem(row_idx, 0, seq_item) - - # 添加工程号到第二列 - order_item = QTableWidgetItem(gc_note) - order_item.setTextAlignment(Qt.AlignCenter) - self.process_table.setItem(row_idx, 1, order_item) - - # 添加检验数据 - for item in items: - position = item['position'] - value = item['value'] if item['value'] else "" - status = item['status'] - config_id = item['config_id'] - - - # 找到对应的列索引 - col_index = None - for i, config in enumerate(enabled_configs): - if config.get('position') == position: - col_index = 2 + i # 检验列从第3列开始 - break - - if col_index is not None: - # 创建单元格并设置值 - cell_item = QTableWidgetItem(str(value)) - cell_item.setTextAlignment(Qt.AlignCenter) - # 存储配置ID,用于保存时确定是哪个检验项 - cell_item.setData(Qt.UserRole, config_id) - # 设置单元格 - self.process_table.setItem(row_idx, col_index, cell_item) - # 添加贴标(11)和称重数据(12) - if position == 11: # 贴标 - # 贴标列索引 = 2(序号和工程号) + 检验列数 - label_col = 2 + len(enabled_configs) - self.process_table.setItem(row_idx, label_col, QTableWidgetItem(str(value))) - elif position == 12: # 称重 - # 称重列索引 = 2(序号和工程号) + 检验列数 + 1(贴标) - weight_col = 2 + len(enabled_configs) + 1 - self.process_table.setItem(row_idx, weight_col, QTableWidgetItem(str(value))) - elif position == 13: # 净重 - # 净重列索引 = 2(序号和工程号) + 检验列数 + 2(贴标和称重) - net_weight_col = 2 + len(enabled_configs) + 2 - self.process_table.setItem(row_idx, net_weight_col, QTableWidgetItem(str(value))) - row_idx += 1 - - # 设置表格为可编辑状态 - self.process_table.setEditTriggers(QTableWidget.DoubleClicked | QTableWidget.EditKeyPressed) - - # 重新连接单元格变更信号 - self.process_table.cellChanged.connect(self.handle_inspection_cell_changed) - - - except Exception as e: - logging.error(f"加载未完成的检验数据失败: {str(e)}") - QMessageBox.warning(self, "加载失败", f"加载未完成的检验数据失败: {str(e)}") - - finally: - # 加载包装记录,但要避免循环调用 - # 设置一个标志,防止 show_pack_item 触发更多的数据加载 - # 只有在_safe_load_data调用此方法,且没有明确设置加载状态的情况下才调用 - has_loading_flag = hasattr(self, '_loading_data_in_progress') - is_loading = getattr(self, '_loading_data_in_progress', False) - - # 如果是被_safe_load_data调用(即已经设置了_loading_data_in_progress),则无需额外设置 - if has_loading_flag and is_loading: - # 直接调用show_pack_item,不改变加载状态 - try: - self.show_pack_item() - logging.info("在load_finished_inspection_data中调用show_pack_item") - except Exception as e: - logging.error(f"在load_finished_inspection_data中调用show_pack_item失败: {str(e)}") - # 否则,这是直接调用此方法(非_safe_load_data),需要设置加载状态 - elif not is_loading: - self._loading_data_in_progress = True - try: - self.show_pack_item() - logging.info("在load_finished_inspection_data中直接调用show_pack_item") - finally: - self._loading_data_in_progress = False - - def load_finished_record_to_package_record(self, order_id, gc_note, tray_id): - """加载已完成检验数据到包装记录 - - Args: - order_id: 工程号 - tray_id: 托盘号 - """ - try: - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - - # 获取该工程号的所有检验数据 - inspection_data = inspection_dao.get_inspection_data_by_order(order_id, gc_note, tray_id) - - if not inspection_data: - logging.warning(f"未找到工程号 {gc_note} 托盘号 {tray_id} 的检验数据") - return - - # 获取轴号并保存 - label_value = self.get_axios_num_by_order_id(self._current_order_code) - - # 从检验数据中获取贴标和称重数据 - weight_value = "" - net_weight_value = "" - for item in inspection_data: - if item['position'] == 12: # 称重 - weight_value = item['value'] - elif item['position'] == 13: # 净重 - net_weight_value = item['value'] - - # 只要贴标字段有值,就可以写入包装记录 - if label_value == None or label_value == "": - logging.warning(f"工程号 {order_id} 托盘号 {tray_id} 的贴标字段为空,不添加到包装记录") - return - - - # 获取当前时间作为完成时间 - finish_time = datetime.now() - - # 将数据写入到数据库表 inspection_pack_data - inspection_dao.save_package_record(order_id, tray_id, str(label_value+1), weight_value,net_weight_value, finish_time,gc_note) - - # 回显数据,但避免循环调用 - if not getattr(self, '_loading_data_in_progress'): - self._loading_data_in_progress = True - try: - self.show_pack_item() - finally: - self._loading_data_in_progress = False - - logging.info(f"已将工程号 {order_id} 托盘号 {tray_id} 的检验数据添加到包装记录并回显") - - except Exception as e: - logging.error(f"加载已完成检验数据到包装记录失败: {str(e)}") - QMessageBox.warning(self, "加载失败", f"加载已完成检验数据到包装记录失败: {str(e)}") - def show_pack_item(self): - """显示包装记录""" - try: - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - - # 获取托盘号 - tray_id = self.tray_edit.currentText() - logging.info(f"显示包装记录,当前托盘号: {tray_id}") - - if not tray_id: - logging.warning("托盘号为空,无法显示包装记录") - # 清空表格 - self.record_table.setRowCount(0) - self.update_package_statistics() - return - - # 读取已包装的记录信息 - package_record = inspection_dao.get_package_record(tray_id) - - # 记录获取的数据情况 - if package_record: - logging.info(f"成功获取包装记录,托盘号={tray_id},记录数量={len(package_record)}") - else: - logging.info(f"包装记录为空,托盘号={tray_id}") - - # 清空表格内容 - self.record_table.setRowCount(0) - - # 断开包装记录表的信号连接(如果有) - try: - self.record_table.blockSignals(True) # 使用blockSignals替代手动断开信号 - except Exception as e: - logging.warning(f"阻止信号失败: {str(e)}") - - # 如果没有包装记录,直接返回 - if not package_record: - logging.info(f"托盘号 {tray_id} 没有包装记录数据") - self.update_package_statistics() - self.record_table.blockSignals(False) # 恢复信号 - return - - logging.info(f"托盘号 {tray_id} 已加载包装记录,共 {len(package_record)} 条记录") - - # 添加所有包装记录到表格 - for index, item in enumerate(package_record): - try: - row_index = self.record_table.rowCount() - self.record_table.insertRow(row_index) - - # 设置单元格数据,使用安全的方式访问数据 - cell_data = [ - str(index + 1), # 序号 - str(item[0]) if len(item) > 0 else "", # 订单 - str(item[1]) if len(item) > 1 else "", # 工程号 - str(item[2]) if len(item) > 2 else "", # 品名 - str(item[3]) if len(item) > 3 else "", # 规格 - str(item[4]) if len(item) > 4 else "", # 托号 - str(item[5]) if len(item) > 5 else "", # 轴包装号 - str(item[6]) if len(item) > 6 else "", # 毛重 - str(item[7]) if len(item) > 7 else "", # 净重 - str(item[8]) if len(item) > 8 else "" # 完成时间 - ] - - # 批量设置单元格 - for col, data in enumerate(cell_data): - cell_item = QTableWidgetItem(data) - cell_item.setTextAlignment(Qt.AlignCenter) - self.record_table.setItem(row_index, col, cell_item) - except Exception as e: - logging.error(f"设置第 {index} 行数据时出错: {str(e)}, 数据: {item}") - continue # 继续处理下一行 - - # 恢复信号 - self.record_table.blockSignals(False) - - # 更新包装记录统计数据 - self.update_package_statistics() - logging.info(f"包装记录显示完成,托盘号={tray_id},总记录数={self.record_table.rowCount()}") - - except Exception as e: - logging.error(f"显示包装记录失败: {str(e)}") - self.record_table.blockSignals(False) # 确保信号被恢复 - QMessageBox.warning(self, "显示失败", f"显示包装记录失败: {str(e)}") - def update_package_statistics(self): - """更新包装记录统计数据""" - try: - # 获取包装记录表的行数 - package_count = self.record_table.rowCount() - - # 更新任务表格中的已完成数量 - completed_item = QTableWidgetItem(str(package_count)) - completed_item.setTextAlignment(Qt.AlignCenter) - self.task_table.setItem(2, 2, completed_item) - - # 计算已完成公斤数(如果称重列有数值) - completed_kg = 0 - for row in range(self.record_table.rowCount()): - weight_item = self.record_table.item(row, 6) # 称重列 - if weight_item and weight_item.text(): - try: - completed_kg += float(weight_item.text()) - except ValueError: - pass - - # 更新任务表格中的已完成公斤 - completed_kg_item = QTableWidgetItem(str(completed_kg)) - completed_kg_item.setTextAlignment(Qt.AlignCenter) - self.task_table.setItem(2, 3, completed_kg_item) - - logging.info(f"已更新包装记录统计数据: 完成数量={package_count}, 完成公斤={completed_kg}") - - except Exception as e: - logging.error(f"更新包装记录统计数据失败: {str(e)}") - - def show_table_context_menu(self, pos): - """显示表格上下文菜单 - - Args: - pos: 鼠标位置 - """ - try: - # 获取当前单元格 - cell_index = self.process_table.indexAt(pos) - if not cell_index.isValid(): - return - - row = cell_index.row() - column = cell_index.column() - - # 只对数据行和检验列显示上下文菜单 - if row < 2: # 忽略表头行 - return - - # 获取工程号 - order_id_item = self.process_table.item(row, 1) - if not order_id_item: - return - - order_id = order_id_item.text().strip() - if not order_id: - return - - # 获取托盘号 - tray_id = self.tray_edit.currentText() - - # 创建上下文菜单 - menu = QMenu(self) - - # 获取启用的检验配置 - enabled_configs = self.inspection_manager.get_enabled_configs() - - # 判断是否是检验列(非包装列) - packaging_start_col = 2 + len(enabled_configs) - if column >= 2 and column < packaging_start_col: - # 是检验列 - config_index = column - 2 - if config_index < len(enabled_configs): - config = enabled_configs[config_index] - position = config.get('position') - - # 添加查询数据库菜单项 - check_action = menu.addAction("检查数据库记录") - check_action.triggered.connect(lambda: self.check_database_record(order_id, position, tray_id)) - - # 显示菜单 - menu.exec_(self.process_table.viewport().mapToGlobal(pos)) - - except Exception as e: - logging.error(f"显示表格上下文菜单失败: {str(e)}") - - def check_database_record(self, order_id, position, tray_id): - """检查数据库记录 - - Args: - order_id: 工程号 - position: 位置序号 - tray_id: 托盘号 - """ - try: - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - - # 获取检验数据 - inspection_data = inspection_dao.get_inspection_data_by_order(order_id, tray_id) - - # 查找对应位置的数据 - matching_data = None - for data in inspection_data: - if data.get('position') == position: - matching_data = data - break - - # 显示结果 - if matching_data: - value = matching_data.get('value') - status = matching_data.get('status') - - message = f"数据库记录:\n\n" - message += f"工程号: {order_id}\n" - message += f"位置: {position}\n" - message += f"值: {value}\n" - message += f"状态: {status}\n" - - QMessageBox.information(self, "数据库记录", message) - else: - QMessageBox.warning(self, "数据库记录", f"未找到工程号 {order_id} 位置 {position} 的数据") - - except Exception as e: - logging.error(f"检查数据库记录失败: {str(e)}") - QMessageBox.warning(self, "查询失败", f"检查数据库记录失败: {str(e)}") - - def show_operation_status(self, status, operation_type, pallet_type): - """在右上角显示操作状态 - - Args: - status: 状态文本 - operation_type: 操作类型 (input/output) - pallet_type: 托盘类型 - """ - # 确定要添加标签的容器 - if operation_type == "input": - container = self.material_content - else: - container = self.output_content - - # 如果已存在状态标签,则移除它 - status_label_name = f"{operation_type}_status_label" - if hasattr(self, status_label_name): - old_label = getattr(self, status_label_name) - old_label.deleteLater() - - # 创建新的状态标签 - status_label = QLabel(f"{status}: {pallet_type}", container) - status_label.setFont(self.second_title_font) - status_label.setStyleSheet("color: red; background-color: transparent;") - status_label.setAlignment(Qt.AlignRight | Qt.AlignTop) - - # 使用绝对定位,放置在右上角 - status_label.setGeometry(container.width() - 250, 5, 240, 30) - - # 确保标签始终保持在顶层显示 - status_label.raise_() - status_label.show() - - # 保存标签引用 - setattr(self, status_label_name, status_label) - - # 保存原始的resize事件处理函数 - if not hasattr(container, "_original_resize_event"): - container._original_resize_event = container.resizeEvent - - # 添加窗口大小变化事件处理,确保标签位置随窗口调整 - container.resizeEvent = lambda event: self.adjust_status_label_position(event, container, status_label) - - def adjust_status_label_position(self, event, container, label): - """调整状态标签位置,确保始终在右上角 - - Args: - event: 窗口大小变化事件 - container: 标签所在的容器 - label: 状态标签 - """ - # 更新标签位置,保持在右上角 - label.setGeometry(container.width() - 250, 5, 240, 30) - - # 调用原始的resizeEvent(如果有的话) - original_resize = getattr(container, "_original_resize_event", None) - if original_resize: - original_resize(event) - - # ==================== Modbus监控系统相关方法 ==================== - - def setup_modbus_monitor(self): - """设置Modbus监控系统""" - # 获取Modbus监控器实例 - self.modbus_monitor = get_modbus_monitor() - - # 注册寄存器处理器 - self._register_modbus_handlers() - - # 连接信号槽 - self._connect_modbus_signals() - - # 启动监控 - self.modbus_monitor.start() - - logging.info("Modbus监控系统已设置") - - def _register_modbus_handlers(self): - """注册寄存器处理器""" - # 获取Modbus监控器实例 - monitor = get_modbus_monitor() - - # 注册D6处理器,处理NG信号 - monitor.register_handler(6, NGHandler(self.machine_handlers.handle_ng)) - - # 注册D11处理器,处理称重数据 - monitor.register_handler(11, WeightDataHandler(self.machine_handlers.handle_weight_data)) - - # 注册D13处理器,处理贴标信号 - monitor.register_handler(13, LabelSignalHandler(self.machine_handlers.handle_label_signal)) - - # 注册D20-D24处理器,处理各种状态信息 - monitor.register_handler(20, LoadingFeedbackHandler(self.handle_loading_feedback)) - monitor.register_handler(21, UnloadingFeedbackHandler(self.handle_unloading_feedback)) - monitor.register_handler(22, Error1Handler(self.machine_handlers.handle_error_1)) - monitor.register_handler(23, Error2Handler(self.machine_handlers.handle_error_2)) - monitor.register_handler(24, Error3Handler(self.machine_handlers.handle_error_3)) - - # 注册急停信号处理器 - monitor.register_handler(25, EmergencyStopHandler(self.handle_emergency_stop)) - - # 注册下料层数和位置处理器 - monitor.register_handler(4, UnloadingLevelHandler(self.handle_unloading_level)) - monitor.register_handler(5, UnloadingPositionHandler(self.handle_unloading_position)) - - # 注册电力消耗处理器 - monitor.register_handler(30, ElectricityHandler()) - - logging.info("已注册所有Modbus寄存器处理器") - - def _connect_modbus_signals(self): - """连接Modbus信号槽""" - # 连接监控器状态信号 - self.modbus_monitor.monitor_status_changed.connect(self.handle_modbus_status_change) - self.modbus_monitor.register_error.connect(self.handle_register_error) - self.machine_handlers.ng_changed.connect(self.handle_ng) - # 直接连接寄存器变化信号 - self.modbus_monitor.register_changed.connect(self.handle_register_change) - - # 连接机器状态信号 - self.machine_handlers.loading_feedback_changed.connect(self.handle_loading_feedback) - self.machine_handlers.unloading_feedback_changed.connect(self.handle_unloading_feedback) - self.machine_handlers.error_1_changed.connect(self.handle_error_1) - self.machine_handlers.error_2_changed.connect(self.handle_error_2) - self.machine_handlers.error_3_changed.connect(self.handle_error_3) - - # 连接称重数据和贴标信号 - self.machine_handlers.weight_changed.connect(self.handle_weight_data) - self.machine_handlers.label_signal_changed.connect(self.handle_label_signal) - - def _convert_to_kg(self, weight_in_g): - """ - 将克转换为千克 - Args: - weight_in_g: 重量(克) - Returns: - float: 重量(千克) - """ - return round(weight_in_g / 1000.0, 3) # 保留3位小数 - - @Slot(int) - def handle_weight_data(self, weight_in_g): - """处理称重数据变化""" - try: - current_time = time.time() - - # 转换重量单位并立即更新UI显示 - weight_in_kg = self._convert_to_kg(weight_in_g) - logging.info(f"[显示] 称重数据: {weight_in_kg}kg (原始值: {weight_in_g}g)") - self.weight_label.setText(f"重量: {weight_in_kg}kg") - - # 检测重量从接近0到较大值的变化,判断为新产品 - if self._current_weight is not None and self._current_weight < 0.1 and weight_in_kg > 0.5: - logging.info(f"检测到新产品放上,重量从 {self._current_weight}kg 变为 {weight_in_kg}kg") - self._weight_processed = False # 重置处理标记,允许处理新产品 - - # 更新当前重量和时间 - self._current_weight = weight_in_kg - self._last_weight_time = current_time - - # 取消之前的定时器(如果存在) - if self._stability_check_timer is not None: - self._stability_check_timer.stop() - self._stability_check_timer.deleteLater() - - # 创建新的定时器 - self._stability_check_timer = QTimer() - self._stability_check_timer.setSingleShot(True) # 单次触发 - self._stability_check_timer.timeout.connect(lambda: self._check_weight_stability(weight_in_kg)) - self._stability_check_timer.start(self._weight_stable_threshold * 1000) # 转换为毫秒 - - # 尝试获取表格行数据,用于日志记录 - current_row = self.process_table.currentRow() - data_row = current_row if current_row >= 2 else 2 # 使用第一个数据行(索引为2) - - # 记录表格行状态,仅用于日志记录,不影响后续处理 - if data_row >= self.process_table.rowCount(): - logging.warning(f"选中的行 {data_row} 超出了表格范围") - else: - # 获取工程号,仅用于日志记录 - gc_note_item = self.process_table.item(data_row, 1) - if gc_note_item: - gc_note = gc_note_item.text().strip() - if gc_note: - logging.info(f"当前处理的工程号: {gc_note}, 行: {data_row}") - else: - logging.warning("工程号为空") - else: - logging.warning("无法获取工程号") - except Exception as e: - logging.error(f"处理称重数据时发生错误: {str(e)}") - # 确保重新连接信号 - try: - self.process_table.cellChanged.connect(self.handle_inspection_cell_changed) - except: - pass - - def _check_weight_stability(self, original_weight_kg): - """ - 检查重量是否稳定 - Args: - original_weight_kg: 开始检查时的重量(千克) - """ - try: - # 如果当前重量与定时器启动时的重量相同,说明这段时间内没有新的重量数据 - if self._current_weight == original_weight_kg: - logging.info(f"重量 {original_weight_kg}kg 在{self._weight_stable_threshold}秒内保持稳定") - - # 如果这个重量与上一次处理的重量接近(±0.1kg),且标记已处理,则跳过 - if self._weight_processed and abs(original_weight_kg - self._last_processed_weight) < 0.1: - logging.info(f"跳过处理:重量 {original_weight_kg}kg 与上次处理的重量 {self._last_processed_weight}kg 接近且已处理") - return - - # 称重稳定后,给寄存器 D10 为 1 表示已经称重完成 - modbus = ModbusUtils() - client = modbus.get_client() - modbus.write_register_until_success(client, 10, 1) - modbus.close_client(client) - - # 处理稳定重量 - self._process_stable_weight(original_weight_kg) - # 调用打印方法 - self._print_weight_label(original_weight_kg) - - # 设置已处理标记和上次处理的重量 - self._weight_processed = True - self._last_processed_weight = original_weight_kg - logging.info(f"已标记重量 {original_weight_kg}kg 为已处理") - else: - logging.info(f"重量在{self._weight_stable_threshold}秒内发生变化,从 {original_weight_kg}kg 变为 {self._current_weight}kg") - except Exception as e: - logging.error(f"检查重量稳定性时发生错误: {str(e)}") - finally: - # 清理定时器 - if self._stability_check_timer is not None: - self._stability_check_timer.deleteLater() - self._stability_check_timer = None - - def _process_stable_weight(self, weight_kg): - """ - 处理稳定的称重数据 - Args: - weight_kg: 稳定的重量值(千克) - """ - try: - # 忽略接近0的重量值,这可能表示产品已被移除 - if weight_kg < 0.1: # 小于100g的重量视为无效 - logging.info(f"忽略接近零的重量值: {weight_kg}kg,可能表示产品已被移除") - return - - # 获取数据行数 - if self.process_table.rowCount() <= 2: # 没有数据行 - logging.warning("没有可用的数据行来写入称重数据") - return - - # 获取启用的检验配置 - enabled_configs = self.inspection_manager.get_enabled_configs() - - # 计算称重列索引 - 称重位置在检验列之后的第二列(贴标后面) - weight_col = 2 + len(enabled_configs) + 1 - # 计算净重列索引 - 净重位置在检验列之后的第三列(称重后面) - net_weight_col = 2 + len(enabled_configs) + 2 - - # 查找第一个没有称重数据的行 - data_row = None - for row in range(2, self.process_table.rowCount()): - weight_item = self.process_table.item(row, weight_col) - if not weight_item or not weight_item.text().strip(): - data_row = row - break - - # 如果没有找到没有称重数据的行,使用当前选中行或第一个数据行 - if data_row is None: - current_row = self.process_table.currentRow() - data_row = current_row if current_row >= 2 else 2 # 使用第一个数据行(索引为2) - logging.info(f"未找到没有称重数据的行,使用当前选中行或第一个数据行: {data_row}") - else: - logging.info(f"找到没有称重数据的行: {data_row}") - - # 获取工程号 - gc_note = self.process_table.item(data_row, 1) - if not gc_note: - logging.warning("无法获取工程号") - return - - gc_note = gc_note.text().strip() - if not gc_note: - logging.warning("工程号为空") - return - - # 暂时断开信号连接,避免触发cellChanged信号 - try: - self.process_table.cellChanged.disconnect(self.handle_inspection_cell_changed) - except: - pass - - # 设置称重值单元格(显示千克) - weight_item = QTableWidgetItem(str(weight_kg)) - weight_item.setTextAlignment(Qt.AlignCenter) - self.process_table.setItem(data_row, weight_col, weight_item) - - # 保存到数据库(使用千克) - tray_id = self.tray_edit.currentText() - self.save_inspection_data(self._current_order_code, gc_note, tray_id, 12, 12, str(weight_kg), "pass") - - # 保存净重到数据库(毛重-工字轮重量,单位都是千克) - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - gzl_zl_raw = inspection_dao.get_gzl_zl(self._current_order_code) - gzl_zl = 0.0 - try: - if gzl_zl_raw: - gzl_zl = float(gzl_zl_raw) - except (ValueError, TypeError): - logging.warning(f"无法将工字轮重量 '{gzl_zl_raw}' 转换为浮点数,将使用默认值 0.0") - - net_weight_kg = round(weight_kg - gzl_zl,3) - self.save_inspection_data(self._current_order_code, gc_note, tray_id, 13, 13, str(net_weight_kg), "pass") - - # 设置净重单元格(显示千克) - net_weight_item = QTableWidgetItem(str(net_weight_kg)) - net_weight_item.setTextAlignment(Qt.AlignCenter) - self.process_table.setItem(data_row, net_weight_col, net_weight_item) - - # 如果开启 api 模式,则调用接口添加到包装记录 - if AppMode.is_api(): - from dao.inspection_dao import InspectionDAO - from apis.gc_api import GcApi - inspection_dao = InspectionDAO() - # 调用接口 - gc_api = GcApi() - axios_num = self.get_axios_num_by_order_id(self._current_order_code) + 1 - # 获取订单信息和其他信息,两者都已经是字典格式 - info = {} - order_info = inspection_dao.get_order_info(self._current_order_code) - info.update(order_info) - # 获取包装号 - - info['xpack'] = self.tray_edit.currentText() - info['spack'] = self.tray_edit.currentText() - order_others_info = inspection_dao.get_order_others_info(gc_note, self._current_order_code, tray_id) - info.update(order_others_info) - info['data_corp'] = order_info['data_corp'] - info['zh'] = axios_num - # 获取本机IP地址 - # import socket - # try: - # # 通过连接外部服务器获取本机IP(不实际建立连接) - # s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - # s.connect(("8.8.8.8", 80)) - # local_ip = s.getsockname()[0] - # s.close() - # info['nw_ip'] = local_ip.replace('.', '') - # except Exception as e: - # logging.error(f"获取本机IP失败: {str(e)}") - # # 如果获取失败,使用本地回环地址 - # info['nw_ip'] = '127.0.0.1'.replace('.', '') - info['nw_ip'] = '192.168.1.246' - # 调用接口添加到包装记录 - response = gc_api.add_order_info(info) - if response.get("status",False): - logging.info(f"添加订单信息成功: {response.get('data',{})}") - else: - QMessageBox.warning(self, f"提示", response.get("message",{})) - - # 保存贴标数据到数据库 - self.save_inspection_data(self._current_order_code, gc_note, tray_id, 11, 11, str(axios_num), "pass") - - # 重新连接信号 - self.process_table.cellChanged.connect(self.handle_inspection_cell_changed) - - logging.info(f"已将稳定的称重数据 {weight_kg}kg 写入行 {data_row}, 列 {weight_col}") - - except Exception as e: - logging.error(f"处理称重数据时发生错误: {str(e)}") - # 确保重新连接信号 - try: - self.process_table.cellChanged.connect(self.handle_inspection_cell_changed) - except: - pass - - def _print_weight_label(self, weight_kg): - """ - 打印重量标签 注意:目前打印是写入数据库打印,不需要再次调用 - Args: - weight_kg: 稳定的重量值(千克) - """ - try: - logging.info(f"开始打印重量标签,重量:{weight_kg}kg") - # TODO: 实现打印逻辑 - pass - except Exception as e: - logging.error(f"打印重量标签时发生错误: {str(e)}") - - @Slot(int, str) - def handle_label_signal(self, signal, status): - """处理贴标信号""" - logging.info(f"[处理] 贴标信号: {status} (值={signal})") - - # 更新UI显示 - self.label_status_label.setText(f"贴标: {status}") - - # 只有当信号为贴标完成(1)时才进行处理 - if signal == 1: - try: - # 获取数据行数 - if self.process_table.rowCount() <= 2: # 没有数据行 - logging.warning("没有可用的数据行来写入贴标数据") - return - - # 获取当前选中的行或第一个数据行 - current_row = self.process_table.currentRow() - data_row = current_row if current_row >= 2 else 2 # 使用第一个数据行(索引为2) - - # 确保行存在 - if data_row >= self.process_table.rowCount(): - logging.warning(f"选中的行 {data_row} 超出了表格范围") - return - - # 获取工程号 - order_id_item = self.process_table.item(data_row, 1) - if not order_id_item: - logging.warning("无法获取工程号") - return - - gc_note = order_id_item.text().strip() - if not gc_note: - logging.warning("工程号为空") - return - - # 获取托盘号 - tray_id = self.tray_edit.currentText() - - # 获取启用的检验配置 - enabled_configs = self.inspection_manager.get_enabled_configs() - - # 计算贴标列索引 - 贴标位置在检验列之后的第一列 - label_col = 2 + len(enabled_configs) - - # 生成贴标号(托盘号+轴号) - axios_num = self.get_axios_num(tray_id)+1 - - # 断开单元格变更信号,避免程序自动写入时触发 - try: - self.process_table.cellChanged.disconnect(self.handle_inspection_cell_changed) - except: - pass - - # 创建并设置贴标单元格 - label_item = QTableWidgetItem(str(axios_num)) - label_item.setTextAlignment(Qt.AlignCenter) - - # 写入单元格 - self.process_table.setItem(data_row, label_col, label_item) - logging.info(f"已将贴标数据 {axios_num} 写入表格单元格 [{data_row}, {label_col}]") - - - - # 调用加载到包装记录的方法 - self.load_finished_record_to_package_record(self._current_order_code,gc_note, tray_id) - logging.info(f"贴标完成,已将工程号 {gc_note} 的记录加载到包装记录") - - # 删除当前处理的行 - self.process_table.removeRow(data_row) - logging.info(f"已删除处理完成的行 {data_row}") - - - - # 重新连接单元格变更信号 - self.process_table.cellChanged.connect(self.handle_inspection_cell_changed) - - except Exception as e: - logging.error(f"处理贴标完成信号失败: {str(e)}") - # 确保信号重新连接 - try: - self.process_table.cellChanged.connect(self.handle_inspection_cell_changed) - except: - pass - - @Slot(bool, str) - def handle_modbus_status_change(self, is_connected, message): - """处理Modbus连接状态变化""" - if is_connected: - self.modbus_status_label.setText("Modbus: 已连接") - self.modbus_status_label.setStyleSheet("color: green;") - logging.info(f"Modbus已连接: {message}") - else: - self.modbus_status_label.setText("Modbus: 未连接") - self.modbus_status_label.setToolTip(message) - self.modbus_status_label.setStyleSheet("color: red;") - logging.warning(f"Modbus连接断开: {message}") - - @Slot(int, str) - def handle_register_error(self, address, error_msg): - """处理寄存器读取错误""" - logging.warning(f"[处理] 寄存器D{address}错误: {error_msg}") - # 在这里可以添加错误处理逻辑 - pass - - @Slot(int, int) - def handle_register_change(self, address, value): - """处理寄存器变化""" - logging.info(f"[处理] 寄存器D{address}变化: {value}") - - # 当D11寄存器变为0时,复位D10寄存器为0 - if address == 11 and value == 0: - try: - logging.info("检测到D11寄存器变为0,正在复位D10寄存器") - modbus = ModbusUtils() - client = modbus.get_client() - modbus.write_register_until_success(client, 10, 0) - logging.info("成功复位D10寄存器为0") - modbus.close_client(client) - except Exception as e: - logging.error(f"复位D10寄存器失败: {str(e)}") - @Slot(int, str) - def handle_loading_feedback(self, status, desc): - """处理上料信息反馈""" - message = desc - try: - if status == 1: - modbus = ModbusUtils() - client = modbus.get_client() - # 睡 0.5 秒,用于延缓modbus 监听 - time.sleep(0.5) - modbus.write_register_until_success(client, 2, 0) - if self._current_stow_num > 0: - completed_layer_num = self._current_stow_num - self._current_stow_num -= 1 - if self._current_stow_num == 0: - self._is_loading_active = False # 任务完成,标记为非活动 - self._loading_info = None - logging.info("所有层拆垛完成,清空上料信息") - message = f"第 {completed_layer_num} 层(最后一层)拆垛完成!" - # 重置寄存器 0 和 2 为 0 - modbus.write_register_until_success(client, 0, 0) - modbus.write_register_until_success(client, 2, 0) - self.loading_feedback_signal.emit("input", message) - # 恢复开始按钮原始样式 - self.restore_start_button_style() - else: - logging.info(f"当前层拆垛完成,剩余层数: {self._current_stow_num}") - message = f"第 {completed_layer_num} 层拆垛完成。" - self.loading_feedback_signal.emit("input", message) - #通知寄存器,进行第几层拆垛 - modbus.write_register_until_success(client,0 ,self._current_stow_num) - except Exception as e: - logging.error(f"处理上料信息反馈失败: {str(e)}") - # 不在这里显示对话框,而是通过信号传递错误信息 - self.loading_feedback_signal.emit("error", f"处理上料信息反馈失败: {str(e)}") - finally: - modbus.close_client(client) - - def _handle_loading_feedback_ui(self, status_type, desc): - """在主线程中处理上料UI更新""" - try: - # 如果上料任务仍在进行,更新层数显示 - if self._loading_info and self._current_stow_num > 0: - self.show_operation_status("拆垛层数", "input", str(self._current_stow_num)) - else: - # 上料任务完成,清除状态显示 - self.clear_operation_status("input") - - QMessageBox.information(self, "上料操作", desc) - except Exception as e: - logging.error(f"处理上料UI更新失败: {str(e)}") - - @Slot(int, str) - def handle_unloading_feedback(self, status, desc): - """处理下料信息反馈""" - logging.info(f"[处理] 下料信息: {status}, {desc}") - # 如果下料完成(status=1),显示状态信息,处理下料流程 - if status == 1: - modbus = ModbusUtils() - client = modbus.get_client() - - try: - # 睡 0.5 秒,用于延缓modbus 监听 - time.sleep(0.5) - # 临时重置寄存器3(下料启动)为0,等待用户下一次启动 - modbus.write_register_until_success(client, 3, 0) - - # 如果当前下料层数小于总层数,则将层数加1并写入寄存器4 - if self._current_unload_num < self._total_unload_num: - # 当前层已完成,准备下一层 - self._current_unload_num += 1 - logging.info(f"当前层下料完成,更新层数:当前={self._current_unload_num}, 总数={self._total_unload_num}") - - # 将新的层数写入寄存器4 - modbus.write_register_until_success(client, 4, self._current_unload_num) - logging.info(f"已将新层数{self._current_unload_num}写入寄存器4") - - # 不直接更新UI,而是通过信号将数据传递给主线程处理 - # 通过信号触发UI更新 - 显示前一层完成的消息 - message = f"第{self._current_unload_num-1}层下料完成,请启动第{self._current_unload_num}层下料" - self.unloading_feedback_signal.emit("output", message) - - # 恢复开始按钮原始样式 - self.restore_start_button_style() - else: - # 所有层都下料完成,重置寄存器和计数器 - modbus.write_register_until_success(client, 3, 0) # 确保下料启动寄存器为0 - modbus.write_register_until_success(client, 4, 0) # 重置下料层数寄存器为0 - - # 记录完成的信息用于消息显示 - final_tier = self._current_unload_num - total_tier = self._total_unload_num - tray_code = self._current_unload_info.get('tray_code', '') if self._current_unload_info else '' - - # 重置计数器和信息 - self._current_unload_num = 0 - # 不重置总层数,以便可以继续使用相同的总层数 - # self._total_unload_num = 0 - self._current_unload_info = None - - logging.info(f"托盘 {tray_code} 的所有 {total_tier} 层下料完成,重置当前层数") - - # 通过信号触发UI更新,而不是直接操作UI - message = f"托盘 {tray_code} 的所有 {total_tier} 层下料已全部完成" - self.unloading_feedback_signal.emit("output", message) - - # 恢复开始按钮原始样式 - self.restore_start_button_style() - except Exception as e: - logging.error(f"处理下料反馈时发生错误: {str(e)}") - # 不在这里显示对话框,而是通过信号传递错误信息 - self.unloading_feedback_signal.emit("error", f"处理下料反馈失败: {str(e)}") - finally: - modbus.close_client(client) - - def _handle_unloading_feedback_ui(self, status_type, desc): - """在主线程中处理下料完成的事件通知""" - try: - if status_type == "error": - QMessageBox.critical(self, "错误", desc) - return - - # 显示事件消息 - if "全部完成" in desc: - QMessageBox.information(self, "下料完成", desc) - # 任务完成,清除状态显示 - self.clear_operation_status("output") - self.unloading_level_label.setText("下料层数:--") - self.unloading_position_label.setText("下料位置:--") - elif "请启动" in desc: - QMessageBox.information(self, "下料层完成", desc) - - except Exception as e: - logging.error(f"处理下料UI更新失败: {str(e)}") - - def _update_error_status(self): - """更新故障状态显示""" - # 收集所有故障信息 - error_codes = [ - getattr(self, 'error_1', 0), - getattr(self, 'error_2', 0), - getattr(self, 'error_3', 0) - ] - - # 检查是否有故障 - has_error = any(code > 0 for code in error_codes) - - if has_error: - # 收集所有错误信息 - errors = [] - error_map = self.machine_handlers.error_map - - if getattr(self, 'error_1', 0) > 0: - errors.append(f"故障1: {error_map.get(self.error_1, '未知')}") - if getattr(self, 'error_2', 0) > 0: - errors.append(f"故障2: {error_map.get(self.error_2, '未知')}") - if getattr(self, 'error_3', 0) > 0: - errors.append(f"故障3: {error_map.get(self.error_3, '未知')}") - - self.error_status_label.setText("故障: 有") - self.error_status_label.setToolTip("\n".join(errors)) - self.error_status_label.setStyleSheet("color: red; font-weight: bold;") - else: - self.error_status_label.setText("故障: 无") - self.error_status_label.setToolTip("") - self.error_status_label.setStyleSheet("color: green; font-weight: bold;") - - @Slot(int, str) - def handle_error_1(self, error_code, error_desc): - """机器人视觉报警""" - logging.info(f"[处理] 机械手报警: {error_desc}") - from utils.register_handlers import Error1Handler - error_handler = Error1Handler() - detailed_desc = error_handler.error_map.get(error_code, f"机械手报警-{error_code}") - - # 保存故障码 - self.error_1 = error_code - self._update_error_status() - - # 只有当错误码为1、2或3时才弹框提示 - if error_code in [1, 2, 3]: - QMessageBox.warning(self, "机械手报警", f"机械手报警: {detailed_desc}") - # 获取Modbus连接 - modbus = ModbusUtils() - client = modbus.get_client() - - # 根据错误码可以添加不同的处理逻辑 - # 这里先简单处理,对所有错误都复位相关寄存器 - modbus.write_register_until_success(client, 2, 0) - modbus.write_register_until_success(client, 0, 0) - modbus.close_client(client) - - @Slot(int, str) - def handle_error_2(self, error_code, error_desc): - """滚筒线报警""" - logging.info(f"[处理] 滚筒线报警: {error_desc}") - from utils.register_handlers import Error2Handler - error_handler = Error2Handler() - detailed_desc = error_handler.error_map.get(error_code, f"滚筒线报警-{error_code}") - # 保存故障码 - self.error_2 = error_code - self._update_error_status() - - # 如果有故障,显示提示(对任何错误码都弹框) - if error_code in [1, 2]: - QMessageBox.warning(self, "滚筒线报警", f"滚筒线报警: {detailed_desc}") - # 获取Modbus连接 - modbus = ModbusUtils() - client = modbus.get_client() - - # 根据错误码可以添加不同的处理逻辑 - # 这里先简单处理,对所有错误都复位相关寄存器 - modbus.write_register_until_success(client, 3, 0) - modbus.write_register_until_success(client, 4, 0) - modbus.close_client(client) - - @Slot(int, str) - def handle_error_3(self, error_code, error_desc): - """拆码垛报警""" - logging.info(f"[处理] 拆码垛报警: {error_desc}") - from utils.register_handlers import Error3Handler - error_handler = Error3Handler() - detailed_desc = error_handler.error_map.get(error_code, f"拆码垛报警-{error_code}") - # 保存故障码 - self.error_3 = error_code - self._update_error_status() - modbus = ModbusUtils() - client = modbus.get_client() - # 如果有故障,显示提示 - if error_code == 1: - QMessageBox.warning(self, "异常", f"异常: {detailed_desc}") - modbus.write_register_until_success(client, 2, 0) - modbus.write_register_until_success(client, 0, 0) - modbus.close_client(client) - # 移除在下料区域显示异常信息的代码 - elif error_code == 2: - QMessageBox.warning(self, "异常", f"异常: {detailed_desc}") - modbus.write_register_until_success(client, 3, 0) - modbus.write_register_until_success(client, 4, 0) - modbus.close_client(client) - - @Slot(int) - def handle_unloading_level(self, level): - """处理下料层数信息(来自Modbus)""" - # 只更新内存中的当前层数,UI更新通过信号槽完成 - if self._current_unload_num != level: - logging.info(f"下料层数变化:{self._current_unload_num} -> {level}") - - self._current_unload_num = level - - # 更新保存的下料信息中的当前层数值 - if self._current_unload_info: - self._current_unload_info['tier'] = str(level) - - # 通过信号在主线程中更新UI - self.unloading_level_ui_signal.emit(level) - - @Slot(int) - def handle_unloading_level_ui(self, level): - """在主线程中更新下料层数UI""" - try: - # 更新显示 - self.unloading_level_label.setText(f"下料层数:{level}") - - # 如果有下料信息且层数大于0,更新右上角显示 - if level > 0 and self._current_unload_info: - tray_code = self._current_unload_info.get('tray_code', '') - - # 确保使用固定的总层数 - total_tier = self._total_unload_num - - self.show_operation_status("下料层数", "output", f"{level}/{total_tier}") - logging.info(f"更新右上角下料层数显示:{level}/{total_tier}") - except Exception as e: - logging.error(f"更新下料层数UI失败: {str(e)}") - - @Slot(int) - def handle_unloading_position(self, position): - """处理下料位置信息""" - # 通过信号在主线程中更新UI - self.unloading_position_ui_signal.emit(position) - - @Slot(int) - def handle_unloading_position_ui(self, position): - """在主线程中更新下料位置UI""" - try: - self.unloading_position_label.setText(f"下料位置:{position}") - except Exception as e: - logging.error(f"更新下料位置UI失败: {str(e)}") - - @Slot(int) - def handle_ng(self, ng): - """处理NG信号, 将当前处理的数据添加到包装记录中,毛重和净重设为0""" - if ng == 1: - try: - # 获取最后一条数据行 - total_rows = self.process_table.rowCount() - if total_rows <= 2: # 只有表头行,没有数据行 - logging.warning("没有可用的数据行来处理NG信号") - return - - # 使用最后一条数据行 - data_row = total_rows - 1 - - # 获取工程号 - order_id_item = self.process_table.item(data_row, 1) - if not order_id_item: - logging.warning("无法获取工程号") - return - - order_id = order_id_item.text().strip() - if not order_id: - logging.warning("工程号为空") - return - - # 获取托盘号 - tray_id = self.tray_edit.currentText() - - # 获取启用的检验配置 - enabled_configs = self.inspection_manager.get_enabled_configs() - - # 计算贴标列索引 - label_col = 2 + len(enabled_configs) - - # 获取贴标值 - label_item = self.process_table.item(data_row, label_col) - label_value = label_item.text() if label_item else "" - - # 如果贴标值为空,生成一个新的贴标值 - if not label_value: - # 初始化托盘号对应的序号 - if tray_id not in self.init_seq: - self.init_seq[tray_id] = 1 - - # 生成贴标号(托盘号+序号) - label_value = f"{self.init_seq[tray_id]}-NG" - self.init_seq[tray_id] += 1 - - # 保存贴标数据到数据库 - self.save_inspection_data(order_id, tray_id, 11, 11, label_value, "pass") - else: - # 如果贴标值已存在但不包含NG标记,添加NG标记 - if "NG" not in label_value: - label_value = f"{label_value}-NG" - # 更新贴标数据 - self.save_inspection_data(order_id, tray_id, 11, 11, label_value, "pass") - - # 设置毛重和净重为0 - self.save_inspection_data(order_id, tray_id, 12, 12, "0", "pass") - self.save_inspection_data(order_id, tray_id, 13, 13, "0", "pass") - - # 获取当前时间作为完成时间 - finish_time = datetime.now() - - # 将数据写入到数据库表 inspection_pack_data - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - inspection_dao.save_package_record(order_id, tray_id, label_value, "0", "0", finish_time) - - # 删除当前处理的行 - self.process_table.removeRow(data_row) - - # 回显数据 - self.show_pack_item() - - logging.info(f"NG信号处理完成: 工程号={order_id}, 托盘号={tray_id}, 贴标值={label_value}") - except Exception as e: - logging.error(f"处理NG信号时发生错误: {str(e)}") - finally: - # 复原NG信号 - modbus = ModbusUtils() - client = modbus.get_client() - modbus.write_register_until_success(client, 6, 0) - modbus.close_client(client) - - def register_serial_callbacks(self): - """注册串口数据回调函数""" - try: - # 注册米电阻数据回调 - self.serial_manager.callbacks['mdz_data'] = self.on_mdz_data_received - - # 注册线径数据回调 - self.serial_manager.callbacks['xj_data'] = self.on_diameter_data_received - - # 注册扫码器数据回调 - self.serial_manager.callbacks['scanner_data'] = self.on_scanner_data_received - - # 自动打开已配置的串口 - self.serial_manager.auto_open_configured_ports() - - logging.info("已注册串口数据回调函数") - except Exception as e: - logging.error(f"注册串口数据回调函数失败: {str(e)}") - - def on_mdz_data_received(self, port_name, data): - """米电阻数据接收回调函数 - - Args: - port_name: 串口名称 - data: 接收到的数据 - """ - try: - # 解析数据 - data_str = data.decode('utf-8') if isinstance(data, bytes) else str(data) - logging.info(f"收到米电阻数据: {data_str} 来自 {port_name}") - - # 提取米电阻值,格式为"米电阻数据: xxx" - if "米电阻数据:" in data_str: - value_str = data_str.split("米电阻数据:")[1].strip() - try: - # 转换为浮点数 - mdz_value = float(value_str) - - # 查找米电阻对应的检验项配置 - mdz_config = None - enabled_configs = self.inspection_manager.get_enabled_configs() - for config in enabled_configs: - if config.get('name') == 'mdz' or config.get('display_name') == '米电阻': - mdz_config = config - break - - if mdz_config: - # 找到对应的检验项,将数据写入对应的单元格 - self.set_inspection_value('mdz', mdz_config, mdz_value) - else: - logging.warning("未找到米电阻对应的检验项配置") - - except ValueError: - logging.warning(f"米电阻数据格式错误: {value_str}") - else: - logging.warning(f"收到的数据不包含米电阻数据标记: {data_str}") - except Exception as e: - logging.error(f"处理米电阻数据失败: {str(e)}") - - def on_diameter_data_received(self, port_name, data): - """线径数据接收回调函数 - - Args: - port_name: 串口名称 - data: 接收到的数据 - """ - try: - # 解析数据 - data_str = data.decode('utf-8') if isinstance(data, bytes) else str(data) - logging.info(f"收到线径数据: {data_str} 来自 {port_name}") - - # 提取线径值,格式为"线径数据: xxx" - if "线径数据:" in data_str: - value_str = data_str.split("线径数据:")[1].strip() - try: - # 转换为浮点数 - xj_value = float(value_str) - - # 查找线径对应的检验项配置 - xj_config = None - enabled_configs = self.inspection_manager.get_enabled_configs() - for config in enabled_configs: - if config.get('name') == 'xj' or config.get('display_name') == '线径': - xj_config = config - break - - if xj_config: - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - bccd, tccd = inspection_dao.get_xj_range(self._current_order_code) - - if bccd is not None and tccd is not None: - if bccd <= xj_value <= tccd: - self.set_inspection_value('xj', xj_config, xj_value) - else: - logging.warning(f"线径 {xj_value} 不在公差范围内 ({bccd} - {tccd})") - reply = QMessageBox.question( - self, - '确认保存', - f"线径 {xj_value} 不在公差范围内 ({bccd} - {tccd}),\n是否继续保存?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No - ) - if reply == QMessageBox.Yes: - self.set_inspection_value('xj', xj_config, xj_value) - else: - logging.info(f"用户取消保存超出范围的线径值: {xj_value}") - # TODO:后续根据实际情况实现 - pass - else: - logging.info(f"未找到订单 {self._current_order_code} 的线径公差范围,直接保存值 {xj_value}") - self.set_inspection_value('xj', xj_config, xj_value) - else: - logging.warning("未找到线径对应的检验项配置") - - except ValueError: - logging.warning(f"线径数据格式错误: {value_str}") - else: - logging.warning(f"收到的数据不包含线径数据标记: {data_str}") - except Exception as e: - logging.error(f"处理线径数据失败: {str(e)}") - - def on_scanner_data_received(self, port_name, data): - """扫码器数据接收回调函数 - - Args: - port_name: 串口名称 - data: 接收到的数据 - """ - try: - # 解析数据 - data_str = data.decode('utf-8') if isinstance(data, bytes) else str(data) - logging.info(f"收到扫码器数据: {data_str} 来自 {port_name}") - - # 提取扫码数据,格式为"扫码数据: xxx" - if "扫码数据:" in data_str: - gc_note = data_str.split("扫码数据:")[1].strip() - logging.info(f"提取到工程号: {gc_note}") - - # 设置工程号到输入框 - self.order_edit.setText(gc_note) - - # 模拟按下回车键,触发handle_order_enter方法 - self.handle_order_enter() - else: - logging.warning(f"收到的数据不包含扫码数据标记: {data_str}") - except Exception as e: - logging.error(f"处理扫码器数据失败: {str(e)}") - - def set_inspection_value(self, data_type, config, value): - """设置检验项目值到表格中 - - Args: - data_type: 数据类型,'mdz'表示米电阻,'xj'表示线径 - config: 检验项配置 - value: 检验值 - """ - try: - # 获取检验项的列索引 - config_id = config.get('id') - config_position = config.get('position') - col_index = None - - # 获取启用的检验配置 - enabled_configs = self.inspection_manager.get_enabled_configs() - - # 根据检验项配置查找对应的列索引 - for i, cfg in enumerate(enabled_configs): - if cfg.get('id') == config_id: - col_index = 2 + i # 检验列从第3列开始 - break - - if col_index is None: - logging.warning(f"未找到{data_type}对应的列索引") - return - - # 检查表格是否有数据行 - if self.process_table.rowCount() <= 2: # 只有表头行 - order_id = self.order_edit.text().strip() - if order_id: - self.add_new_inspection_row(order_id) - data_row = 2 # 新添加的行 - else: - logging.warning("无法添加新行,订单号为空") - return - - # 查找第一个没有该检测数据的行 - data_row = None - for row in range(2, self.process_table.rowCount()): - cell_item = self.process_table.item(row, col_index) - if not cell_item or not cell_item.text().strip(): - data_row = row - break - - # 如果没有找到没有该检测数据的行,使用当前选中行或第一个数据行 - if data_row is None: - current_row = self.process_table.currentRow() - data_row = current_row if current_row >= 2 else 2 # 使用第一个数据行(索引为2) - logging.info(f"未找到没有{data_type}数据的行,使用当前选中行或第一个数据行: {data_row}") - else: - logging.info(f"找到没有{data_type}数据的行: {data_row}") - - # 获取工程号 - order_id_item = self.process_table.item(data_row, 1) - if not order_id_item: - logging.warning("无法获取工程号") - return - - order_id = order_id_item.text().strip() - if not order_id: - logging.warning("工程号为空") - return - - # 暂时断开信号连接,避免触发cellChanged信号 - try: - self.process_table.cellChanged.disconnect(self.handle_inspection_cell_changed) - except: - pass - - # 格式化值并设置单元格 - formatted_value = str(value) - if config.get('data_type') == 'number': - # 格式化数字,保留2位小数 - formatted_value = f"{value:.2f}" - - # 设置单元格值 - item = QTableWidgetItem(formatted_value) - item.setTextAlignment(Qt.AlignCenter) - item.setData(Qt.UserRole, config_id) # 保存配置ID,用于识别检验项 - self.process_table.setItem(data_row, col_index, item) - - # 验证数据是否在有效范围内 - status = "pass" - if config.get('data_type') == 'number': - min_value = config.get('min_value') - max_value = config.get('max_value') - if (min_value is not None and value < min_value) or (max_value is not None and value > max_value): - status = "fail" - item.setBackground(QBrush(QColor("#ffcdd2"))) # 浅红色 - else: - item.setBackground(QBrush(QColor("#c8e6c9"))) # 浅绿色 - - # 保存到数据库,但只在非加载状态下 - if not self._loading_data_in_progress: - tray_id = self.tray_edit.currentText() - self.save_inspection_data(order_id, tray_id, config_position, config_id, formatted_value, status) - # 不需要在这里主动触发数据重新加载,因为handle_inspection_cell_changed会处理 - - # 重新连接信号 - self.process_table.cellChanged.connect(self.handle_inspection_cell_changed) - - logging.info(f"已将{data_type}数据 {formatted_value} 写入行 {data_row}, 列 {col_index}") - - except Exception as e: - logging.error(f"设置检验项值失败: {str(e)}") - # 确保重新连接信号 - try: - self.process_table.cellChanged.connect(self.handle_inspection_cell_changed) - except: - pass - - def handle_tray_changed(self): - """处理托盘号变更事件,启动监听并加载数据""" - try: - tray_id = self.tray_edit.currentText() - if tray_id: - logging.info(f"托盘号变更为 {tray_id},启动监听") - # 初始化托盘号对应的序号(如果不存在) - if tray_id not in self.init_seq: - self.init_seq[tray_id] = 1 - logging.info(f"初始化托盘号 {tray_id} 的序号为 1") - - # 加载检验数据 - self._safe_load_data() - - # 无论_safe_load_data是否成功,都确保显示包装记录 - # 临时保存当前加载状态 - prev_loading_state = getattr(self, '_loading_data_in_progress', False) - - try: - # 设置加载状态为True,避免无限循环调用 - self._loading_data_in_progress = True - # 强制显示包装记录 - self.show_pack_item() - logging.info(f"托盘号变更:直接调用显示包装记录, 托盘号={tray_id}") - finally: - # 恢复之前的加载状态 - self._loading_data_in_progress = prev_loading_state - - except Exception as e: - logging.error(f"处理托盘号变更失败: {str(e)}") - - def handle_order_code_received(self, order_code): - """处理从加载对话框接收到的订单号""" - logging.info(f"主窗口接收到订单号: {order_code}") - # 存储当前订单号 - self._current_order_code = order_code - - def on_report(self): - """报表按钮点击处理""" - try: - dialog = ReportDialog(self) - dialog.exec_() - except Exception as e: - logging.error(f"打开报表对话框失败: {str(e)}") - QMessageBox.warning(self, "错误", f"打开报表对话框失败: {str(e)}") - - def init_camera_display(self): - """初始化相机显示区域""" - try: - # 清理之前的组件(如果有) - if self.camera_display: - self.material_content_layout.removeWidget(self.camera_display) - self.camera_display.deleteLater() - self.camera_display = None - - if self.material_placeholder: - self.material_content_layout.removeWidget(self.material_placeholder) - self.material_placeholder.deleteLater() - self.material_placeholder = None - - # 清空布局中的所有项目 - while self.material_content_layout.count(): - item = self.material_content_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - - # 创建占位标签 - self.material_placeholder = QLabel("相机初始化中..." if self.camera_enabled else "相机功能已禁用") - self.material_placeholder.setAlignment(Qt.AlignCenter) - self.material_placeholder.setStyleSheet("color: #888888; background-color: #f0f0f0;") - self.material_content_layout.addWidget(self.material_placeholder) - - # 创建相机显示组件 - self.camera_display = CameraDisplayWidget() - self.camera_display.signal_camera_status.connect(self.handle_camera_status) - - # 先隐藏相机组件,直到确认相机可用 - self.material_content_layout.addWidget(self.camera_display) - self.camera_display.hide() - - # 如果相机功能已启用,尝试初始化相机 - if self.camera_enabled: - # 启动相机初始化过程 - QTimer.singleShot(500, self.initialize_camera) - logging.info("相机初始化已安排") - else: - logging.info("相机功能已禁用,不进行初始化") - self.material_placeholder.show() - self.camera_display.hide() - except Exception as e: - logging.error(f"初始化相机显示区域失败: {str(e)}") - - def initialize_camera(self): - """初始化相机并显示画面""" - try: - if not self.camera_enabled: - return - - logging.info("开始初始化相机...") - - # 获取相机管理器实例 - from widgets.camera_manager import CameraManager - camera_manager = CameraManager.get_instance() - - # 枚举设备 - devices = camera_manager.enum_devices() - if not devices or len(devices) == 0: - self.material_placeholder.setText("未检测到相机设备") - logging.warning("未检测到相机设备") - return - - # 打开第一个相机设备 - device_index = 0 - success = camera_manager.open_device(device_index) - - if success: - logging.info(f"相机已成功打开,设备索引: {device_index}") - # 更新UI - self.update_camera_ui(True) - - # 立即开始显示相机画面 - QTimer.singleShot(100, lambda: self._start_camera_display()) - else: - self.material_placeholder.setText("相机打开失败") - logging.error("相机打开失败") - - except Exception as e: - self.material_placeholder.setText("相机初始化错误") - logging.error(f"初始化相机失败: {str(e)}") - - def _start_camera_display(self): - """开始显示相机画面(内部方法)""" - try: - if self.camera_display and self.camera_enabled: - # 确保相机组件可见 - self.camera_display.setVisible(True) - self.camera_display.raise_() - - # 开始显示 - success = self.camera_display.start_display() - - if success: - # 确保占位符隐藏 - if self.material_placeholder: - self.material_placeholder.setVisible(False) - logging.info("相机显示已成功启动") - else: - # 如果启动失败,显示占位符 - if self.material_placeholder: - self.material_placeholder.setText("相机显示启动失败") - self.material_placeholder.setVisible(True) - logging.error("相机显示启动失败") - except Exception as e: - logging.error(f"启动相机显示失败: {str(e)}") - - def update_camera_ui(self, is_camera_ready): - """更新相机UI显示 - - Args: - is_camera_ready: 相机是否准备就绪 - """ - try: - if is_camera_ready and self.camera_enabled: - # 显示相机画面,隐藏占位符 - if self.camera_display: - self.camera_display.setVisible(True) - self.camera_display.raise_() # 确保相机组件在最上层 - if self.material_placeholder: - self.material_placeholder.setVisible(False) - logging.info("相机UI已更新:显示相机画面") - else: - # 隐藏相机画面,显示占位符 - if self.camera_display: - self.camera_display.setVisible(False) - if self.material_placeholder: - self.material_placeholder.setVisible(True) - self.material_placeholder.raise_() # 确保占位符在最上层 - if not self.camera_enabled: - self.material_placeholder.setText("相机功能已禁用") - elif not is_camera_ready: - self.material_placeholder.setText("相机未就绪") - logging.info(f"相机UI已更新:显示占位符 (相机启用={self.camera_enabled}, 相机就绪={is_camera_ready})") - except Exception as e: - logging.error(f"更新相机UI失败: {str(e)}") - - def handle_camera_status(self, is_connected, message): - """处理相机状态变化""" - if is_connected: - logging.info("相机已连接并显示") - self.update_camera_ui(True) - else: - logging.warning(f"相机显示问题: {message}") - # 更新占位符文本 - if self.material_placeholder: - self.material_placeholder.setText(f"相机错误: {message}" if message else "相机未连接") - self.update_camera_ui(False) - - @Slot(int, str) - def handle_emergency_stop(self, value, desc): - """处理急停信号""" - logging.info(f"[处理] 急停信号: {desc}") - - # 保存一个急停状态变量 - self.emergency_stop = value - - # 当急停信号为1时,重置D2和D3寄存器 - if value == 1: - try: - modbus = ModbusUtils() - client = modbus.get_client() - - # 重置D2和D3寄存器 - modbus.write_register_until_success(client, 2, 0) - modbus.write_register_until_success(client, 3, 0) - - # 通过信号在主线程中处理UI更新 - self.emergency_stop_signal.emit(value, desc) - - modbus.close_client(client) - except Exception as e: - logging.error(f"处理急停信号失败: {str(e)}") - else: - # 急停信号解除,在主线程中恢复错误状态显示 - self.emergency_stop_signal.emit(value, desc) - - def _handle_emergency_stop_ui(self, value, desc): - """在主线程中处理急停信号UI更新""" - try: - if value == 1: - # 显示警告对话框 - QMessageBox.warning(self, "急停警告", "监听到急停信号") - - # 更新错误状态标签 - self.error_status_label.setText("故障: 急停") - self.error_status_label.setToolTip("急停按钮被触发") - self.error_status_label.setStyleSheet("color: red; font-weight: bold;") - else: - # 急停信号解除,恢复错误状态显示 - self._update_error_status() - - # 恢复故障状态标签 - self.label_status_label.setText("故障: 正常") - self.label_status_label.setStyleSheet("") - except Exception as e: - logging.error(f"处理急停UI更新失败: {str(e)}") \ No newline at end of file diff --git a/tests/main_window_ui.py b/tests/main_window_ui.py new file mode 100644 index 0000000..9a24254 --- /dev/null +++ b/tests/main_window_ui.py @@ -0,0 +1,715 @@ +from PySide6.QtWidgets import ( + QMainWindow, QWidget, QLabel, QGridLayout, QVBoxLayout, QHBoxLayout, + QTableWidget, QTableWidgetItem, QHeaderView, QFrame, QSplitter, + QPushButton, QLineEdit, QAbstractItemView, QComboBox +) +from PySide6.QtGui import QFont, QAction, QBrush, QColor +from PySide6.QtCore import Qt, QDateTime, QTimer + +class MainWindowUI(QMainWindow): + def __init__(self,username): + super().__init__() + self.username = username + self.setWindowTitle(f"腾智微丝产线包装系统") + self.resize(1200, 800) + self.init_ui() + + def init_ui(self): + # 设置字体 + self.title_font = QFont("微软雅黑", 20, QFont.Bold) + self.second_title_font = QFont("微软雅黑", 14, QFont.Bold) + self.normal_font = QFont("微软雅黑", 12) + self.small_font = QFont("微软雅黑", 9) + + # 创建菜单栏 + self.create_menu() + + # 创建中央部件 + self.central_widget = QWidget() + self.setCentralWidget(self.central_widget) + + # 创建主布局 - 水平分割 + self.main_layout = QHBoxLayout(self.central_widget) + self.main_layout.setContentsMargins(5, 5, 5, 5) + self.main_layout.setSpacing(5) + + # 创建左侧面板 + self.left_panel = QWidget() + self.left_layout = QVBoxLayout(self.left_panel) + self.left_layout.setContentsMargins(0, 0, 0, 0) + self.left_layout.setSpacing(5) + self.create_left_panel() + + # 创建右侧面板 + self.right_panel = QWidget() + self.right_layout = QVBoxLayout(self.right_panel) + self.right_layout.setContentsMargins(0, 0, 0, 0) + self.right_layout.setSpacing(5) + self.create_right_panel() + + # 添加左右面板到主布局 + self.main_layout.addWidget(self.left_panel, 1) # 左侧面板占比较小 + self.main_layout.addWidget(self.right_panel, 2) # 右侧面板占比较大 + + def create_menu(self): + # 创建菜单栏 + self.menubar = self.menuBar() + + # 用户操作菜单 + self.user_menu = self.menubar.addMenu("用户操作页") + self.main_action = QAction("主页面", self) + self.user_menu.addAction(self.main_action) + + # 系统设置菜单 + self.system_menu = self.menubar.addMenu("系统设置") + self.settings_action = QAction("设置页面", self) + self.system_menu.addAction(self.settings_action) + + def create_left_panel(self): + # 创建标题容器 + self.title_container = QWidget() + self.title_layout = QHBoxLayout(self.title_container) + self.title_layout.setContentsMargins(10, 10, 10, 10) + self.title_layout.setSpacing(10) + + # 创建用户名标签 + self.username_label = QLabel(self.username) + self.username_label.setFont(self.normal_font) + self.username_label.setStyleSheet("color: #666666;") + self.username_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + + # 创建主标题标签 + self.title_label = QLabel("腾智微丝产线包装系统") + self.title_label.setFont(self.title_font) # 较大的字体 + self.title_label.setAlignment(Qt.AlignCenter) + self.title_label.setStyleSheet("color: #1a237e;") + + # 创建时间标签 + self.time_label = QLabel() + self.time_label.setFont(self.normal_font) + self.time_label.setStyleSheet("color: #666666;") + self.time_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + # 创建定时器更新时间 + self.timer = QTimer(self) + self.timer.timeout.connect(self.update_time) + self.timer.start(1000) # 每秒更新一次 + self.update_time() # 立即更新一次时间 + + # 将标签添加到标题布局中 + self.title_layout.addWidget(self.username_label, 1) + self.title_layout.addWidget(self.title_label, 2) # 中间标题占据更多空间 + self.title_layout.addWidget(self.time_label, 1) + + # 设置标题容器的样式 + self.title_container.setStyleSheet("background-color: #f5f5f5; border-radius: 4px;") + self.left_layout.addWidget(self.title_container) + + # 项目信息表格 - 使用QFrame包裹,添加边框 + self.project_frame = QFrame() + self.project_frame.setFrameShape(QFrame.StyledPanel) + self.project_frame.setLineWidth(1) + self.project_frame.setFixedHeight(150) # 调整这个值可以控制整体高度 + self.project_layout = QVBoxLayout(self.project_frame) + self.project_layout.setContentsMargins(5, 5, 5, 5) + + # 项目表格 + self.project_table = QTableWidget(4, 4) + self.project_table.setHorizontalHeaderLabels(["用电", "数量", "产量", "开机率"]) + self.project_table.setVerticalHeaderLabels(["当日", "当月", "当年", "累计"]) + #设置字体 + self.project_table.setFont(self.normal_font) + # 设置垂直表头宽度 + self.project_table.verticalHeader().setFixedWidth(60) + self.project_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.project_table.verticalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.project_table.setEditTriggers(QTableWidget.NoEditTriggers) # 设置为不可编辑 + self.project_layout.addWidget(self.project_table) + self.left_layout.addWidget(self.project_frame) + + # 任务信息区域 - 使用QFrame包裹,添加边框 + self.task_frame = QFrame() + self.task_frame.setFrameShape(QFrame.StyledPanel) + self.task_frame.setLineWidth(1) + self.task_frame.setFixedHeight(180) + self.task_layout = QVBoxLayout(self.task_frame) + self.task_layout.setContentsMargins(8, 8, 8, 8) + + # 任务标签 + self.task_label = QLabel("任务") + self.task_label.setFont(self.normal_font) + self.task_label.setAlignment(Qt.AlignLeft) + self.task_label.setStyleSheet("font-weight: bold; color: #333333;") + self.task_layout.addWidget(self.task_label) + + + # 订单行 + self.order_layout = QHBoxLayout() + self.order_layout.setAlignment(Qt.AlignLeft) # 设置整个布局左对齐 + + self.order_label = QLabel("工程号") + self.order_label.setFont(QFont("微软雅黑", 12, QFont.Bold)) + self.order_label.setFixedHeight(30) + self.order_label.setStyleSheet("padding: 0 5px; color: #333333;") + + self.order_edit = QLineEdit() + self.order_edit.setFixedHeight(30) + self.order_edit.setFixedWidth(150) + self.order_edit.setReadOnly(False) + self.order_edit.setFont(QFont("微软雅黑", 12)) + self.order_edit.setText("") # 设置默认订单号 + self.order_edit.setStyleSheet("background-color: #f9f9f9; border: 1px solid #cccccc; border-radius: 3px; padding: 2px 5px;") + + self.order_layout.addWidget(self.order_label) + self.order_layout.addWidget(self.order_edit) + self.order_layout.addStretch() # 添加弹性空间,将组件推到左侧 + self.task_layout.addLayout(self.order_layout) + + # 任务表格 - 使用合并单元格实现一级二级标题 + self.task_table = QTableWidget(3, 4) # 3行4列:一级标题行、二级标题行、数据行 + self.task_table.setEditTriggers(QTableWidget.NoEditTriggers) # 设置为不可编辑 + self.task_table.horizontalHeader().setVisible(False) + self.task_table.verticalHeader().setVisible(False) + self.task_table.setShowGrid(True) + + # 设置列宽均等 + self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + + # 第一行:订单量和完成量 (一级标题) + self.task_table.setSpan(0, 0, 1, 2) # 订单量跨2列 + self.task_table.setSpan(0, 2, 1, 2) # 完成量跨2列 + + order_item = QTableWidgetItem("订单量") + order_item.setTextAlignment(Qt.AlignCenter) + order_item.setFont(self.normal_font) + self.task_table.setItem(0, 0, order_item) + + completed_item = QTableWidgetItem("完成量") + completed_item.setTextAlignment(Qt.AlignCenter) + completed_item.setFont(self.normal_font) + self.task_table.setItem(0, 2, completed_item) + + # 第二行:二级标题 + headers = ["总生产数量", "总生产公斤", "已完成数量", "已完成公斤"] + for col, header in enumerate(headers): + item = QTableWidgetItem(header) + item.setTextAlignment(Qt.AlignCenter) + item.setFont(self.small_font) + self.task_table.setItem(1, col, item) + + + # 设置行高 + self.task_table.setRowHeight(0, 30) # 一级标题行高 + self.task_table.setRowHeight(1, 30) # 二级标题行高 + self.task_table.setRowHeight(2, 30) # 数据行高 + self.task_layout.addWidget(self.task_table) + + self.left_layout.addWidget(self.task_frame) + + # 上料区 - 使用QFrame包裹,添加边框 + self.material_frame = QFrame() + self.material_frame.setFrameShape(QFrame.StyledPanel) + self.material_frame.setLineWidth(1) + self.material_frame.setFixedHeight(380) # 保持上料区高度不变 + self.material_frame.setStyleSheet("QFrame { background-color: #f8f8f8; }") + self.material_layout = QHBoxLayout(self.material_frame) + self.material_layout.setContentsMargins(0, 0, 0, 0) + self.material_layout.setSpacing(0) + + # 上料区标签 + self.material_label = QLabel("上料区") + self.material_label.setFont(self.normal_font) + self.material_label.setAlignment(Qt.AlignCenter) + self.material_label.setFixedWidth(100) # 设置固定宽度 + self.material_label.setStyleSheet("background-color: #e0e0e0; border-right: 1px solid #cccccc; font-weight: bold;") + self.material_layout.addWidget(self.material_label) + + # 上料区内容 - 这里可以添加更多控件 + self.material_content = QWidget() + # 使用透明背景,让相机画面可以正常显示 + self.material_content.setStyleSheet("background-color: transparent;") + self.material_content_layout = QVBoxLayout(self.material_content) + self.material_content_layout.setContentsMargins(0, 0, 0, 0) # 移除内边距以便相机画面填满 + self.material_layout.addWidget(self.material_content) + + self.left_layout.addWidget(self.material_frame) + + # 托盘号区域 - 使用QFrame包裹,添加边框 + self.tray_frame = QFrame() + self.tray_frame.setFrameShape(QFrame.StyledPanel) + self.tray_frame.setLineWidth(1) + # 移除固定高度,让托盘号的高度自适应其内容 + self.tray_frame.setFixedHeight(40) # 进一步减小高度,刚好容纳控件 + self.tray_frame.setStyleSheet("QFrame { background-color: #f8f8f8; }") + self.tray_layout = QHBoxLayout(self.tray_frame) + self.tray_layout.setContentsMargins(0, 0, 0, 0) # 移除内边距,确保没有上下边界 # 移除所有边距 + self.tray_layout.setSpacing(0) # 移除所有间距 + + self.tray_label = QLabel("托盘号") + self.tray_label.setFont(self.normal_font) + self.tray_label.setAlignment(Qt.AlignCenter) + self.tray_label.setFixedWidth(100) # 设置固定宽度 + self.tray_label.setStyleSheet("background-color: #e0e0e0; border-right: 1px solid #cccccc; font-weight: bold;") + self.tray_layout.addWidget(self.tray_label) + + self.tray_edit = QComboBox() + self.tray_edit.setFixedHeight(40) # 设置固定高度与父容器相同 + self.tray_edit.setStyleSheet("QComboBox { border: none; padding: 0px 10px; background-color: white; } QComboBox::drop-down { border: none; width: 20px; }") + self.tray_edit.setFont(QFont("微软雅黑", 12)) + self.tray_edit.setEditable(True) # 允许手动输入 + self.tray_edit.setInsertPolicy(QComboBox.NoInsert) # 不自动插入用户输入到列表中 + self.tray_edit.setMaxVisibleItems(10) # 设置下拉框最多显示10个项目 + self.tray_edit.completer().setCaseSensitivity(Qt.CaseInsensitive) # 设置补全不区分大小写 + self.tray_edit.completer().setFilterMode(Qt.MatchContains) # 设置模糊匹配模式 + # 允许清空选择 + self.tray_edit.setCurrentText("") + + self.tray_layout.addWidget(self.tray_edit) + + self.left_layout.addWidget(self.tray_frame) + + # 下料区 - 使用QFrame包裹,添加边框 + self.output_frame = QFrame() + self.output_frame.setFrameShape(QFrame.StyledPanel) + self.output_frame.setLineWidth(1) + self.output_frame.setFixedHeight(100) # 压缩下料区域的高度,从原来的150减少到100 + self.output_frame.setStyleSheet("QFrame { background-color: #f8f8f8; }") + self.output_layout = QHBoxLayout(self.output_frame) + self.output_layout.setContentsMargins(0, 0, 0, 0) + self.output_layout.setSpacing(0) + + # 下料区标签 + self.output_label = QLabel("下料") + self.output_label.setFont(self.normal_font) + self.output_label.setAlignment(Qt.AlignCenter) + self.output_label.setFixedWidth(100) # 设置固定宽度 + self.output_label.setStyleSheet("background-color: #e0e0e0; border-right: 1px solid #cccccc; font-weight: bold;") + self.output_layout.addWidget(self.output_label) + + # 下料区内容 - 这里可以添加更多控件 + self.output_content = QWidget() + self.output_content.setStyleSheet("background-color: white;") + self.output_content_layout = QVBoxLayout(self.output_content) + self.output_content_layout.setContentsMargins(5, 5, 5, 5) # 减小内部边距 + self.output_layout.addWidget(self.output_content) + + self.left_layout.addWidget(self.output_frame) + + # 产线控制区 - 使用QFrame包裹,添加边框 + self.control_frame = QFrame() + self.control_frame.setFrameShape(QFrame.StyledPanel) + self.control_frame.setLineWidth(1) + self.control_layout = QHBoxLayout(self.control_frame) + self.control_layout.setContentsMargins(0, 0, 0, 0) + self.control_layout.setSpacing(0) + + self.control_label = QLabel("产线") + self.control_label.setFont(self.normal_font) + self.control_label.setAlignment(Qt.AlignCenter) + self.control_label.setFixedWidth(100) # 设置固定宽度 + self.control_label.setStyleSheet("background-color: #e0e0e0; border-right: 1px solid #cccccc; font-weight: bold;") + self.control_layout.addWidget(self.control_label) + + # 按钮容器 + self.button_container = QWidget() + self.button_layout = QGridLayout(self.button_container) + self.button_layout.setContentsMargins(10, 10, 10, 10) + self.button_layout.setSpacing(10) # 增加按钮间距 + + # 创建按钮并设置样式 + button_style = """ + QPushButton { + padding: 8px 16px; + font-weight: bold; + border-radius: 4px; + } + """ + + self.input_button = QPushButton("上料") + self.input_button.setFont(self.normal_font) + self.input_button.setStyleSheet(button_style + "background-color: #e3f2fd; border: 1px solid #2196f3;") + + self.output_button = QPushButton("下料") + self.output_button.setFont(self.normal_font) + self.output_button.setStyleSheet(button_style + "background-color: #fff8e1; border: 1px solid #ffc107;") + + self.start_button = QPushButton("开始") + self.start_button.setFont(self.normal_font) + self.start_button.setStyleSheet(button_style + "background-color: #e8f5e9; border: 1px solid #4caf50;") + + self.stop_button = QPushButton("暂停") + self.stop_button.setFont(self.normal_font) + self.stop_button.setStyleSheet(button_style + "background-color: #ffebee; border: 1px solid #f44336;") + + self.report_button = QPushButton("报表") + self.report_button.setFont(self.normal_font) + self.report_button.setStyleSheet(button_style + "background-color: #e0e0e0; border: 1px solid #cccccc;") + + # 使用网格布局排列按钮 + self.button_layout.addWidget(self.input_button, 0, 0) + self.button_layout.addWidget(self.output_button, 0, 1) + self.button_layout.addWidget(self.start_button, 0, 2) + self.button_layout.addWidget(self.stop_button, 0, 3) + self.button_layout.addWidget(self.report_button, 0, 4) + + self.control_layout.addWidget(self.button_container) + + self.left_layout.addWidget(self.control_frame) + + # 添加弹性空间,确保控件紧凑排列在顶部 + self.left_layout.addStretch() + + def create_right_panel(self): + # 创建右侧整体框架 + self.right_frame = QFrame() + self.right_frame.setFrameShape(QFrame.NoFrame) # 移除框架边框 + self.right_frame.setLineWidth(0) + self.right_layout.addWidget(self.right_frame) + + # 右侧整体使用垂直布局,不设置边距 + self.right_frame_layout = QVBoxLayout(self.right_frame) + self.right_frame_layout.setContentsMargins(0, 0, 0, 0) + self.right_frame_layout.setSpacing(0) + + # 创建一个垂直分割器,用于控制两个表格的高度比例 + self.right_splitter = QSplitter(Qt.Vertical) + + # 创建微丝产线表格的容器 + self.process_container = QWidget() + self.process_container_layout = QVBoxLayout(self.process_container) + self.process_container_layout.setContentsMargins(0, 0, 0, 0) + self.process_container_layout.setSpacing(0) + + # 创建包装记录表格的容器 + self.record_container = QWidget() + self.record_container_layout = QVBoxLayout(self.record_container) + self.record_container_layout.setContentsMargins(0, 0, 0, 0) + self.record_container_layout.setSpacing(0) + + # 创建微丝产线表格 + self.create_process_table() + self.process_container_layout.addWidget(self.process_frame) + + # 创建包装记录表格 + self.create_record_table() + self.record_container_layout.addWidget(self.record_frame) + + # 将两个容器添加到分割器中 + self.right_splitter.addWidget(self.process_container) + self.right_splitter.addWidget(self.record_container) + + # 设置初始大小比例:微丝产线占1/3,包装记录占2/3 + self.right_splitter.setSizes([100, 200]) # 比例为1:2 + + # 将分割器添加到右侧布局 + self.right_frame_layout.addWidget(self.right_splitter) + + # 添加一个通用的表格样式设置方法 + def setup_table_common(self, table, hide_headers=True): + """设置表格的通用样式和属性 + + Args: + table: 要设置的QTableWidget对象 + hide_headers: 是否隐藏默认的表头 + """ + # 设置为不可编辑 + table.setEditTriggers(QAbstractItemView.NoEditTriggers) + + # 隐藏默认表头 + if hide_headers: + table.horizontalHeader().setVisible(False) + table.verticalHeader().setVisible(False) + + # 显示网格线 + table.setShowGrid(True) + + # 移除外边框 + table.setFrameShape(QFrame.NoFrame) + + # 设置表格样式 + table.setStyleSheet(""" + QTableWidget { + gridline-color: #dddddd; + border: none; + background-color: white; + } + QTableWidget::item { + border: none; + padding: 3px; + } + QTableWidget::item:selected { + background-color: #e0e0ff; + color: black; + } + """) + + # 允许用户调整列宽 + table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + table.horizontalHeader().setStretchLastSection(True) + + return table + + # 添加一个通用的表头项创建方法 + def create_header_item(self, text, font=None, alignment=Qt.AlignCenter, bg_color="#f8f8f8"): + """创建表头单元格项 + + Args: + text: 表头文本 + font: 字体,默认为None(使用self.normal_font) + alignment: 对齐方式 + bg_color: 背景色 + + Returns: + QTableWidgetItem: 创建的表头项 + """ + item = QTableWidgetItem(text) + item.setTextAlignment(alignment) + item.setFont(font or self.normal_font) + item.setBackground(QBrush(QColor(bg_color))) + return item + + def create_process_table(self): + """创建微丝产线表格,包含上料、检验、包装部分""" + # 创建微丝产线框架 + self.process_frame = QFrame() + self.process_frame.setFrameShape(QFrame.Box) + self.process_frame.setLineWidth(1) + self.process_frame.setStyleSheet("QFrame { border: 1px solid #dddddd; }") + self.process_layout = QVBoxLayout(self.process_frame) + self.process_layout.setContentsMargins(0, 0, 0, 0) + self.process_layout.setSpacing(0) + + # 微丝产线标题 + self.process_title = QLabel("微丝产线") + self.process_title.setFont(self.second_title_font) + self.process_title.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + # 调整行高 + self.process_title.setFixedHeight(40) + self.process_title.setStyleSheet("background-color: #f8f8f8; padding: 5px; border-bottom: 1px solid #dddddd;") + self.process_layout.addWidget(self.process_title) + + + # 创建表格内容区域 + self.process_content = QWidget() + self.process_content_layout = QVBoxLayout(self.process_content) + self.process_content_layout.setContentsMargins(0, 0, 0, 0) + self.process_content_layout.setSpacing(0) + + # 创建表格 - 支持动态配置检验列数 + self.inspection_columns = 1 # 默认至少显示1列 + # 默认检验标题,实际运行时将通过InspectionConfigManager获取 + self.inspection_headers = ["检验项"] + total_columns = 2 + self.inspection_columns + 2 # 上料2列 + 检验N列 + 包装2列 + + self.process_table = QTableWidget(8, total_columns) # 8行:1行标题区域 + 1行列标题 + 6行数据 + + # 应用通用表格设置 + self.setup_table_common(self.process_table) + + # 设置行高 + self.process_table.setRowHeight(0, 30) # 标题区域行高 + self.process_table.setRowHeight(1, 30) # 列标题行高 + + # 设置数据行的行高 + for row in range(2, 8): # 工序行 + self.process_table.setRowHeight(row, 35) + + # 设置列宽 + self.set_process_table_column_widths() + + # 创建表头 - 合并单元格 + self.create_process_table_headers() + + + # 添加表格到布局 + self.process_content_layout.addWidget(self.process_table) + self.process_layout.addWidget(self.process_content) + + def create_record_table(self): + """创建包装记录表格""" + # 创建包装记录框架 + self.record_frame = QFrame() + self.record_frame.setFrameShape(QFrame.Box) + self.record_frame.setLineWidth(1) + self.record_frame.setStyleSheet("QFrame { border: 1px solid #dddddd; }") # 移除 border-top: none; + self.record_layout = QVBoxLayout(self.record_frame) + self.record_layout.setContentsMargins(0, 0, 0, 0) + self.record_layout.setSpacing(0) + + # 包装记录标题 + self.record_title = QLabel("包装记录") + self.record_title.setFont(self.second_title_font) + self.record_title.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + # 调整行高 + self.record_title.setFixedHeight(40) + self.record_title.setStyleSheet("background-color: #f8f8f8; padding: 5px; border-bottom: 1px solid #dddddd;") + self.record_layout.addWidget(self.record_title) + + # 创建表格 + self.record_table = QTableWidget(13, 10) # 13行10列:序号、订单、工程号、品名、规格、托号、轴包装号、重量、净重、完成时间 + + # 应用通用表格设置但保留表头 + self.setup_table_common(self.record_table, hide_headers=False) + + # 设置列标题 + record_headers = ["序号", "订单", "工程号", "品名", "规格", "托号", "轴包装号", "毛重", "净重", "完成时间"] + self.record_table.setHorizontalHeaderLabels(record_headers) + + # 设置表头样式 + self.record_table.horizontalHeader().setStyleSheet(""" + QHeaderView::section { + background-color: #f8f8f8; + padding: 4px; + border: 1px solid #dddddd; + font-weight: bold; + } + """) + + # 设置行高 + self.record_table.setRowHeight(12, 35) # 合计行高 + + # 设置数据行的行高 + for row in range(0, 12): # 记录行 + self.record_table.setRowHeight(row, 35) + + # 设置列宽 + column_widths = [60, 170, 170, 120, 120, 150, 100, 100, 100, 160] + for col, width in enumerate(column_widths): + self.record_table.setColumnWidth(col, width) + self.record_table.horizontalHeader().resizeSection(col, width) + + # 添加表格到布局 + self.record_layout.addWidget(self.record_table) + + + # 添加一个通用的单元格创建方法 + def create_cell_item(self, text, alignment=Qt.AlignCenter): + """创建表格单元格项 + + Args: + text: 单元格文本 + alignment: 对齐方式 + + Returns: + QTableWidgetItem: 创建的单元格项 + """ + item = QTableWidgetItem(str(text)) + item.setTextAlignment(alignment) + return item + + def set_inspection_columns(self, columns, headers=None): + """设置检验列数和标题 + + Args: + columns: 检验列数量 + headers: 检验列标题列表,如果为None则使用默认标题 + """ + # 确保列数在1-6之间 + columns = max(1, min(6, columns)) + + # 保存旧的列数 + old_column_count = self.process_table.columnCount() + + # 清除表头行的所有项目 + if old_column_count > 0: + for c_idx in range(old_column_count): + # 第0行 - 主标题 + item_r0 = self.process_table.takeItem(0, c_idx) + if item_r0: + del item_r0 + # 第1行 - 子标题 + item_r1 = self.process_table.takeItem(1, c_idx) + if item_r1: + del item_r1 + + # 清除所有单元格合并 + for row in range(2): + for col in range(old_column_count): + try: + self.process_table.setSpan(row, col, 1, 1) + except: + pass # 忽略错误,可能有些单元格没有合并 + + # 更新检验列数 + self.inspection_columns = columns + + # 更新检验标题 + if headers is not None and len(headers) >= columns: + self.inspection_headers = headers[:columns] # 只使用前N个标题 + elif len(self.inspection_headers) < columns: + # 如果当前标题不足,扩展标题列表 + current_len = len(self.inspection_headers) + for i in range(current_len, columns): + self.inspection_headers.append(f"检验项{i+1}") + + # 截断多余的标题 + if len(self.inspection_headers) > columns: + self.inspection_headers = self.inspection_headers[:columns] + + # 计算总列数 + total_columns = 2 + self.inspection_columns + 3 # 上料2列 + 检验N列 + 包装3列 + self.process_table.setColumnCount(total_columns) + + # 重新设置列宽 + self.set_process_table_column_widths() + + # 重新创建表头 + self.create_process_table_headers() + + def create_process_table_headers(self): + """创建微丝产线表格的表头,实现合并单元格""" + # 第一行:上料、检验、包装标题区域 + + # 上料区域(2列) + self.process_table.setSpan(0, 0, 1, 2) + self.process_table.setItem(0, 0, self.create_header_item("上料")) + + # 检验区域(动态列数) + self.process_table.setSpan(0, 2, 1, self.inspection_columns) + self.process_table.setItem(0, 2, self.create_header_item("检验")) + + # 包装区域(3列) + packaging_start_col = 2 + self.inspection_columns + self.process_table.setSpan(0, packaging_start_col, 1, 3) + self.process_table.setItem(0, packaging_start_col, self.create_header_item("包装")) + + # 第二行:列标题 + # 上料区域列标题 + material_headers = ["序号", "工程号"] + for col, header in enumerate(material_headers): + self.process_table.setItem(1, col, self.create_header_item(header)) + + # 检验区域列标题 - 可动态配置 + for i in range(self.inspection_columns): + header_text = "" + if i < len(self.inspection_headers): + header_text = self.inspection_headers[i] + else: + header_text = f"检验项{i+1}" # 如果没有定义足够的标题,使用默认标题 + + self.process_table.setItem(1, 2 + i, self.create_header_item(header_text)) + + # 包装区域列标题 + packaging_headers = ["贴标", "毛重", "净重"] + for i, header in enumerate(packaging_headers): + self.process_table.setItem(1, packaging_start_col + i, self.create_header_item(header)) + + def set_process_table_column_widths(self): + """设置微丝产线表格的列宽 - 支持动态配置检验列""" + # 上料区域列宽 + self.process_table.setColumnWidth(0, 70) # 序号 + self.process_table.setColumnWidth(1, 190) # 工程号 + + # 检验区域列宽 + for i in range(self.inspection_columns): + self.process_table.setColumnWidth(2 + i, 140) # 检验列 + + # 包装区域列宽 + packaging_start_col = 2 + self.inspection_columns + self.process_table.setColumnWidth(packaging_start_col, 140) # 贴标 + self.process_table.setColumnWidth(packaging_start_col + 1, 140) # 毛重 + self.process_table.setColumnWidth(packaging_start_col + 2, 140) # 净重 + + def update_time(self): + current_time = QDateTime.currentDateTime().toString('yyyy-MM-dd hh:mm:ss') + self.time_label.setText(current_time) \ No newline at end of file diff --git a/tests/serial_manager.py b/tests/serial_manager.py new file mode 100644 index 0000000..3bef65f --- /dev/null +++ b/tests/serial_manager.py @@ -0,0 +1,1198 @@ +import serial +import threading +import time +import logging +import os +import json +from typing import Dict, Optional, Callable +from utils.config_loader import ConfigLoader +from utils.keyboard_listener import KeyboardListener +from pynput.keyboard import Key +import re +import platform + +class SerialManager: + """串口管理器,单例模式""" + _instance = None + _lock = threading.Lock() + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super(SerialManager, cls).__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + self._initialized = True + self.serial_ports: Dict[str, serial.Serial] = {} # 存储打开的串口对象 + self.read_threads: Dict[str, threading.Thread] = {} # 存储读取线程 + self.running_flags: Dict[str, bool] = {} # 存储线程运行标志 + self.callbacks: Dict[str, Callable] = {} # 存储数据回调函数 + + # 添加文件操作暂停控制 + self._file_operations_suspended = False + self._file_operations_lock = threading.Lock() + + # 添加称重稳定性检测变量 + self.last_weight = 0 + self.stable_count = 0 + self.weight_written = False # 初始化为False,确保首次称重能够正确处理 + + # 添加抗抖动变量 + self.last_weights = [0] * 3 # 存储最近3个重量值 + self.weight_changed_time = time.time() # 上次重量变化的时间 + self.last_write_time = 0 # 最后写入时间 + + # 稳定性时间跟踪 + self.stability_start_time = 0 # 开始检测稳定性的时间 + + # 数据存储 + self.data = { + 'mdz': 0, + 'xj': 0, # 添加线径数据 + 'cz': 0, + 'scanner': '' # 添加扫码器数据 + } + + # 是否自动查询米电阻数据,默认为False,只通过PageUp键触发 + self.auto_query_mdz = False + + logging.info("初始化 SerialManager") + + # 加载配置 + self._load_config() + + # 检查是否启用键盘监听功能 + enable_keyboard_listener = self.config.get_value('app.features.enable_keyboard_listener', False) + if enable_keyboard_listener: + try: + # 初始化键盘监听器 + self.keyboard_listener = KeyboardListener() + # 从配置中获取触发键 + trigger_key = self.config.get_value('serial.keyboard.trigger_key', 'Key.page_up') + # 注册触发键回调 + self.keyboard_listener.register_callback(trigger_key, self.trigger_resistance_query) + logging.info(f"已注册{trigger_key}按键回调用于触发米电阻查询") + # 注意:不在这里启动键盘监听器,而是在点击"开始"按钮时启动 + except Exception as e: + logging.error(f"初始化键盘监听器失败: {e}") + # 创建一个空的键盘监听器对象,以避免后续代码出现NoneType错误 + self.keyboard_listener = None + else: + logging.info("键盘监听功能已在配置中禁用,跳过初始化键盘监听器") + self.keyboard_listener = None + + def _load_config(self): + """加载配置""" + try: + config_loader = ConfigLoader.get_instance() # Renamed for clarity + self.config = config_loader # Assign the whole config object + + # 获取数据文件路径 + self.data_file = self.config.get_value('serial.data_file', 'data.txt') + # 确保是绝对路径 + if not os.path.isabs(self.data_file): + self.data_file = os.path.abspath(self.data_file) + logging.info(f"最终确定的 data_file 绝对路径: {self.data_file}") + + # 获取串口配置 + self.mdz_config = self.config.get_config('mdz') + self.cz_config = self.config.get_config('cz') + self.xj_config = self.config.get_config('xj') # 添加线径配置 + self.scanner_config = self.config.get_config('scanner') # 添加扫码器配置 + + # 检查操作系统类型,在macOS上处理COM端口名称问题 + os_type = platform.system() + if os_type == "Darwin": + # 检查是否需要自动检测macOS上的串口 + macos_autodetect = self.config.get_value('serial.os_config.macos_autodetect', False) + if macos_autodetect: + logging.info("在macOS上启用了串口自动检测") + self._detect_macos_ports() + + # 获取默认的稳定阈值 + self.stable_threshold = self.cz_config.get('stable_threshold', 10) if self.cz_config else 10 + + # 检查是否自动查询米电阻数据 + self.auto_query_mdz = self.config.get_value('serial.keyboard.auto_query', False) + + logging.info(f"已加载串口配置:mdz={self.mdz_config}, xj={self.xj_config}, cz={self.cz_config}, scanner={self.scanner_config}, data_file={self.data_file}") + logging.info(f"米电阻自动查询: {'开启' if self.auto_query_mdz else '关闭'}") + except Exception as e: + logging.error(f"加载配置出错: {e}") + # 设置默认值 + self.data_file = os.path.abspath('data.txt') + self.mdz_config = {'port': 9600, 'ser': 'COM5'} + self.cz_config = {'port': 9600, 'ser': 'COM2', 'stable_threshold': 10} + self.xj_config = {'port': 9600, 'ser': 'COM3'} + self.scanner_config = {'port': 9600, 'ser': 'COM4'} + self.stable_threshold = 10 + self.auto_query_mdz = False + logging.info(f"使用默认配置,数据文件: {self.data_file}") + + def _detect_macos_ports(self): + """在macOS上检测可用的串口""" + try: + if platform.system() != "Darwin": + logging.info("不是macOS系统,跳过串口检测") + return + + logging.info("正在检测macOS可用串口...") + + # 检查/dev目录下的tty.*和cu.*设备 + import glob + tty_ports = glob.glob('/dev/tty.*') + cu_ports = glob.glob('/dev/cu.*') + all_ports = tty_ports + cu_ports + + if not all_ports: + logging.warning("未检测到macOS上的串口设备") + return + + logging.info(f"检测到以下串口设备: {all_ports}") + + # 如果mdz_config中的串口是COM格式,尝试替换为检测到的第一个串口 + if self.mdz_config and 'ser' in self.mdz_config and self.mdz_config['ser'].startswith('COM'): + # 优先选择包含"usb"的设备 + usb_ports = [port for port in all_ports if 'usb' in port.lower()] + if usb_ports: + self.mdz_config['ser'] = usb_ports[0] + logging.info(f"自动将米电阻串口从COM格式替换为: {usb_ports[0]}") + elif all_ports: + self.mdz_config['ser'] = all_ports[0] + logging.info(f"自动将米电阻串口从COM格式替换为: {all_ports[0]}") + + # 如果cz_config中的串口是COM格式,尝试替换为检测到的第二个串口 + if self.cz_config and 'ser' in self.cz_config and self.cz_config['ser'].startswith('COM'): + # 优先选择包含"usb"的设备,并且不是已分配给mdz的设备 + usb_ports = [port for port in all_ports if 'usb' in port.lower() + and (not self.mdz_config or port != self.mdz_config.get('ser'))] + if usb_ports: + self.cz_config['ser'] = usb_ports[0] + logging.info(f"自动将线径串口从COM格式替换为: {usb_ports[0]}") + elif len(all_ports) > 1: + # 选择不是mdz_config已使用的第一个端口 + for port in all_ports: + if not self.mdz_config or port != self.mdz_config.get('ser'): + self.cz_config['ser'] = port + logging.info(f"自动将线径串口从COM格式替换为: {port}") + break + elif all_ports and (not self.mdz_config or all_ports[0] != self.mdz_config.get('ser')): + self.cz_config['ser'] = all_ports[0] + logging.info(f"自动将线径串口从COM格式替换为: {all_ports[0]}") + + except Exception as e: + logging.error(f"检测macOS串口时发生错误: {e}") + logging.info("将继续使用配置文件中的串口设置") + + def open_port(self, port_name: str, port_type: str, baud_rate: Optional[int] = None, + data_bits: int = 8, stop_bits: int = 1, + parity: str = 'N', timeout: float = 1.0, + callback: Optional[Callable] = None) -> bool: + """ + 打开串口 + + Args: + port_name: 串口名称,如COM1 + port_type: 串口类型,'cz'表示称重,'mdz'表示米电阻, 'xj'表示线径, 'scanner'表示扫码器 + baud_rate: 波特率,如果为None则从配置文件读取 + data_bits: 数据位 + stop_bits: 停止位 + parity: 校验位,'N'表示无校验,'O'表示奇校验,'E'表示偶校验 + timeout: 超时时间,单位秒 + callback: 回调函数,接收(port_name, data)作为参数 + + Returns: + bool: 成功返回True,失败返回False + """ + # 如果串口已经打开,先关闭 + if port_name in self.serial_ports and self.serial_ports[port_name]: + try: + self.close_port(port_name) + except Exception as e: + logging.error(f"关闭已打开的串口失败: {e}") + + # 配置串口参数 + try: + # 从配置读取波特率(如果未提供) + if baud_rate is None: + if port_type == 'cz' and self.cz_config: + baud_rate = self.cz_config.get('port', 9600) + elif port_type == 'mdz' and self.mdz_config: + baud_rate = self.mdz_config.get('port', 9600) + elif port_type == 'xj' and self.xj_config: + baud_rate = self.xj_config.get('port', 9600) + elif port_type == 'scanner' and self.scanner_config: + baud_rate = self.scanner_config.get('port', 9600) + else: + baud_rate = 9600 # 默认波特率 + + # 转换校验位为PySerial常量 + if parity.upper() == 'N': + parity_constant = serial.PARITY_NONE + elif parity.upper() == 'O': + parity_constant = serial.PARITY_ODD + elif parity.upper() == 'E': + parity_constant = serial.PARITY_EVEN + else: + parity_constant = serial.PARITY_NONE + + # 打开串口 + self.serial_ports[port_name] = serial.Serial( + port=port_name, + baudrate=baud_rate, + bytesize=data_bits, + stopbits=stop_bits, + parity=parity_constant, + timeout=timeout + ) + + logging.info(f"打开串口成功: {port_name}, 类型: {port_type}, 波特率: {baud_rate}") + + # 设置回调 + if callback: + self.callbacks[port_name] = callback + + # 创建并启动读取线程 + self.running_flags[port_name] = True + + if port_type == 'cz': + # 称重数据需要特殊处理 + thread = threading.Thread(target=self._read_weight_thread, args=(port_name, self.stable_threshold)) + elif port_type == 'mdz': + # 米电阻数据需要特殊处理 + thread = threading.Thread(target=self._read_resistance_thread, args=(port_name,)) + elif port_type == 'xj': + # 线径数据需要特殊处理 + thread = threading.Thread(target=self._read_diameter_thread, args=(port_name,)) + thread.daemon = True + thread.start() + self.read_threads[port_name] = thread + elif port_type == 'scanner': + # 扫码器数据需要特殊处理 + thread = threading.Thread(target=self._read_scanner_thread, args=(port_name,)) + else: + # 其他类型使用通用处理 + thread = threading.Thread(target=self._read_thread, args=(port_name,)) + + thread.daemon = True + thread.start() + self.read_threads[port_name] = thread + + return True + + except Exception as e: + logging.error(f"打开串口失败: {port_name}, 错误: {e}") + # 确保清理好资源 + if port_name in self.serial_ports: + try: + self.serial_ports[port_name].close() + except: + pass + self.serial_ports.pop(port_name, None) + + # 停止相关线程 + self.running_flags[port_name] = False + if port_name in self.read_threads: + self.read_threads.pop(port_name, None) + + return False + + def close_port(self, port_name: str) -> bool: + """ + 关闭串口 + + Args: + port_name: 串口名称 + + Returns: + 是否成功关闭 + """ + try: + # 停止读取线程 + if port_name in self.running_flags: + self.running_flags[port_name] = False + + # 等待线程结束 + if port_name in self.read_threads: + if self.read_threads[port_name].is_alive(): + self.read_threads[port_name].join(1.0) # 最多等待1秒 + del self.read_threads[port_name] + + # 关闭串口 + if port_name in self.serial_ports: + if self.serial_ports[port_name].is_open: + self.serial_ports[port_name].close() + del self.serial_ports[port_name] + logging.info(f"串行对象 for {port_name} 从 self.serial_ports 中删除. 当前活跃端口: {list(self.serial_ports.keys())}") + + # 删除回调 + if port_name in self.callbacks: + del self.callbacks[port_name] + + logging.info(f"串口 {port_name} 已关闭") + return True + + except Exception as e: + logging.error(f"关闭串口 {port_name} 失败: {str(e)}") + return False + + def is_port_open(self, port_name: str) -> bool: + """ + 检查串口是否打开 + + Args: + port_name: 串口名称 + + Returns: + 是否打开 + """ + return port_name in self.serial_ports and self.serial_ports[port_name].is_open + + def write_data(self, port_name: str, data: bytes) -> bool: + """ + 向串口写入数据 + + Args: + port_name: 串口名称 + data: 要写入的数据 + + Returns: + 是否成功写入 + """ + try: + if not self.is_port_open(port_name): + return False + + self.serial_ports[port_name].write(data) + return True + + except Exception as e: + logging.error(f"向串口 {port_name} 写入数据失败: {str(e)}") + return False + + def read_data(self, port_name: str, size: int = None) -> bytes: + """ + 从串口读取数据 + + Args: + port_name: 串口名称 + size: 要读取的字节数,如果为None则读取所有可用数据 + + Returns: + 读取的数据,如果失败则返回空字节 + """ + try: + if not self.is_port_open(port_name): + logging.error(f"尝试从未打开的串口 {port_name} 读取数据") + return b'' + + if size is None: + # 读取所有可用数据 + if self.serial_ports[port_name].in_waiting > 0: + return self.serial_ports[port_name].read(self.serial_ports[port_name].in_waiting) + return b'' + else: + # 读取指定数量的字节 + return self.serial_ports[port_name].read(size) + + except Exception as e: + logging.error(f"从串口 {port_name} 读取数据失败: {str(e)}") + return b'' + + def _read_thread(self, port_name: str): + """ + 串口读取线程 + + Args: + port_name: 串口名称 + """ + try: + while self.running_flags.get(port_name, False): + if not self.is_port_open(port_name): + time.sleep(0.1) + continue + + # 读取数据 + if self.serial_ports[port_name].in_waiting: + data = self.serial_ports[port_name].readline() + + # 调用回调函数 + if port_name in self.callbacks and data: + try: + self.callbacks[port_name](port_name, data) + except Exception as e: + logging.error(f"调用串口 {port_name} 回调函数失败: {str(e)}") + + time.sleep(0.01) # 短暂休眠,避免CPU占用过高 + + except Exception as e: + logging.error(f"串口 {port_name} 读取线程异常: {str(e)}") + + def _read_weight_thread(self, port_name: str, stable_threshold: int = 10): + logging.info(f"[{port_name}] 称重线程启动") + # 重置状态变量,确保线程重启时能正确处理称重数据 + self.weight_written = False + self.stable_count = 0 + self.last_weight = 0 + self.last_weights = [0] * 3 + self.weight_changed_time = time.time() + self.last_write_time = 0 # 添加一个变量来跟踪最后一次写入时间 + self.stability_start_time = 0 # 重置稳定性检测开始时间 + + def _process_weight_data(self, data_bytes): + """ + Args: + data_bytes: 原始字节数据 + + Returns: + 解析后的重量值或None + """ + try: + # 尝试多种编码方式解析 + weight = None + + # 记录原始数据以便调试 + logging.debug(f"称重原始数据: {data_bytes.hex()}") + + # 方法1: 尝试ASCII解码 + try: + ascii_str = data_bytes.decode('ascii', errors='replace') + # 仅提取数字部分 + import re + numbers = re.findall(r'\d+', ascii_str) + if numbers: + weight = float(numbers[0]) / 10.0 + logging.debug(f"ASCII解码成功: {weight}") + except Exception as e: + logging.debug(f"ASCII解码失败: {e}") + + # 方法2: 尝试直接从二进制解析 (根据具体协议) + if weight is None and len(data_bytes) >= 8: + try: + # 假设重量在特定位置,具体需根据实际协议调整 + weight_bytes = data_bytes[2:6] + weight = int.from_bytes(weight_bytes, byteorder='big') / 10.0 + logging.debug(f"二进制解码成功: {weight}") + except Exception as e: + logging.debug(f"二进制解码失败: {e}") + + return weight + except Exception as e: + logging.error(f"处理称重数据失败: {e}") + return None + + def _read_resistance_thread(self, port_name: str): + """ + 米电阻串口读取线程 + + Args: + port_name: 串口名称 + """ + try: + while self.running_flags.get(port_name, False): + if not self.is_port_open(port_name): + time.sleep(0.1) + continue + + # 如果不是自动查询模式,则只监听串口数据而不主动发送查询 + if not self.auto_query_mdz: + # 检查是否有数据可读 + if self.serial_ports[port_name].in_waiting > 0: + response = self.serial_ports[port_name].read(self.serial_ports[port_name].in_waiting) + self._process_mdz_response(port_name, response) + + time.sleep(0.1) + continue + + # 以下代码只在自动查询模式下执行 + try: + # 发送查询指令 + hex_data = '01 03 00 01 00 07 55 C8' + byte_data = bytes.fromhex(hex_data.replace(' ', '')) + self.serial_ports[port_name].write(byte_data) + + # 等待响应 + time.sleep(1) + + if self.serial_ports[port_name].in_waiting > 0: + response = self.serial_ports[port_name].read(self.serial_ports[port_name].in_waiting) + self._process_mdz_response(port_name, response) + + except Exception as e: + logging.error(f"米电阻数据处理异常: {e}") + + # 每5秒查询一次 + for i in range(50): + if not self.running_flags.get(port_name, False): + break + time.sleep(0.1) + + except Exception as e: + logging.error(f"米电阻串口 {port_name} 读取线程异常: {e}") + + def _process_mdz_response(self, port_name, response_bytes: bytes): + """处理米电阻响应数据""" + try: + if response_bytes: # 确保有响应数据 + try: + # 转换为字符串用于日志记录 + response_str = str(response_bytes) + logging.warning(f"[{port_name}] 米电阻数据: {response_str}") + + # 使用正则表达式直接提取数字 + # 查找格式为11.58201这样的浮点数 + match = re.search(r'(\d+\.\d+)', response_str) + if match: + number_str = match.group(1) + try: + # 转换为浮点数 + mdz_value = float(number_str) + logging.info(f"米电阻数据: {mdz_value}") + + # 更新数据 + self.data['mdz'] = mdz_value + self._write_data_to_file() + self._notify_callbacks('mdz_data', {"type": "mdz", "value": self.data['mdz'], "source": f"serial ({port_name})"}) + return True + except ValueError: + logging.warning(f"米电阻数据字符串 '{number_str}' 无法转换为浮点数") + else: + logging.warning(f"米电阻数据中未找到有效的浮点数") + except Exception as e: + logging.error(f"处理米电阻数据异常: {e}") + else: + logging.warning("米电阻响应数据为空") + + # 如果无法解析,则不再使用模拟数据,直接返回失败 + return False + + except Exception as e: + logging.error(f"米电阻数据处理关键异常: {e}") + return False + + def _write_data_to_file(self): + """将数据写入文件""" + try: + # 检查文件操作是否已暂停 + with self._file_operations_lock: + if self._file_operations_suspended: + logging.info("文件操作已暂停,跳过写入") + return + + # 构建数据字符串 + data_str = f"mdz:{self.data['mdz']}|cz:{self.data['cz']}|scanner:{self.data['scanner']}|" + + # 确保目录存在 + data_dir = os.path.dirname(self.data_file) + if data_dir and not os.path.exists(data_dir): + logging.info(f"创建目录: {data_dir}") + os.makedirs(data_dir, exist_ok=True) + + # 写入文件 - 使用临时文件写入然后重命名,避免文件锁定问题 + # 创建临时文件 + temp_file = f"{self.data_file}.tmp" + try: + with open(temp_file, 'w', encoding='utf-8') as f: + f.write(data_str) + f.flush() + os.fsync(f.fileno()) # 确保数据写入磁盘 + + # 再次检查文件操作是否已暂停 + with self._file_operations_lock: + if self._file_operations_suspended: + logging.info("文件操作已暂停,已写入临时文件但取消重命名操作") + return + + # 原子性地重命名文件,替换原有文件(在大多数操作系统上是原子操作) + if os.path.exists(self.data_file): + try: + os.remove(self.data_file) + except Exception as e: + logging.warning(f"无法删除旧数据文件: {e}, 尝试直接覆盖") + + os.rename(temp_file, self.data_file) + logging.info(f"数据已写入文件: {self.data_file}") + except Exception as e: + logging.error(f"写入临时文件失败: {e}") + # 清理临时文件 + if os.path.exists(temp_file): + try: + os.remove(temp_file) + except: + pass + raise # 重新抛出异常 + + except Exception as e: + logging.error(f"写入数据文件失败: {e}, 文件路径: {self.data_file}") + + def get_current_data(self): + """获取当前数据""" + return self.data.copy() + + def close_all_ports(self): + """关闭所有串口""" + port_names = list(self.serial_ports.keys()) + for port_name in port_names: + self.close_port(port_name) + + def reload_config(self): + """重新加载配置""" + self._load_config() + logging.info("已重新加载串口配置") + + def start_keyboard_listener(self): + """启动键盘监听""" + try: + # 检查是否启用键盘监听功能 + enable_keyboard_listener = self.config.get_value('app.features.enable_keyboard_listener', False) + if not enable_keyboard_listener: + logging.info("键盘监听功能已在配置中禁用,跳过启动键盘监听") + return False + + # 检查键盘监听器是否已初始化 + if self.keyboard_listener is None: + logging.warning("键盘监听器未初始化,无法启动") + return False + + # 从配置中获取触发键 + config = ConfigLoader.get_instance() + trigger_key = config.get_value('serial.keyboard.trigger_key', 'Key.page_up') + + # 确保已注册触发键回调 + if trigger_key not in self.keyboard_listener.callbacks: + self.keyboard_listener.register_callback(trigger_key, self.trigger_resistance_query) + logging.info(f"已注册{trigger_key}按键回调用于触发米电阻数据查询") + + # 启动键盘监听 + result = self.keyboard_listener.start() + if result: + logging.info(f"已启动键盘监听,按 {trigger_key} 键可触发米电阻数据查询") + + # 检查监听器状态 + if self.keyboard_listener.is_active(): + logging.info("键盘监听器处于活动状态") + else: + logging.warning("键盘监听器启动完成,但状态检查显示不活动") + else: + logging.error("启动键盘监听失败") + return result + except Exception as e: + logging.error(f"启动键盘监听失败: {e}") + return False + + def stop_keyboard_listener(self, join_thread=False): + """停止键盘监听 + + Args: + join_thread: 是否等待键盘监听线程结束 + """ + try: + # 检查键盘监听器是否已初始化 + if self.keyboard_listener is None: + logging.info("键盘监听器未初始化,无需停止") + return + + self.keyboard_listener.stop() + logging.info("已停止键盘监听") + + # 如果需要等待线程结束 + if join_thread: + try: + self.keyboard_listener.join(timeout=1.0) # 最多等待1秒 + except Exception as e: + logging.error(f"等待键盘监听线程结束时出错: {e}") + except Exception as e: + logging.error(f"停止键盘监听失败: {e}") + + def register_callback(self, key, callback): + """ + 注册数据回调函数 + + Args: + key: 回调标识,如 'mdz_data' + callback: 回调函数,参数为 (port_name, data) + """ + try: + if key in self.callbacks: + logging.warning(f"覆盖已存在的回调函数: {key}") + self.callbacks[key] = callback + logging.info(f"已注册回调函数: {key}") + except Exception as e: + logging.error(f"注册回调失败: {e}") + + def trigger_resistance_query(self): + """触发米电阻数据查询,如果串口未打开,则尝试临时打开并查询""" + # 直接打印到控制台,确保可见 + print("\n[米电阻查询] PageUp键被按下,正在触发米电阻数据查询...\n") + + # 检查是否启用串口功能 + enable_serial_ports = self.config.get_value('app.features.enable_serial_ports', False) + if not enable_serial_ports: + logging.info("串口功能已在配置中禁用,跳过米电阻数据查询") + print("\n[米电阻查询] 串口功能已禁用,无法查询\n") + return + + # 检查是否启用键盘监听功能 - 如果这个按键是通过键盘触发的,应该尊重键盘监听器配置 + enable_keyboard_listener = self.config.get_value('app.features.enable_keyboard_listener', False) + if not enable_keyboard_listener: + logging.info("键盘监听功能已在配置中禁用,但收到了Page Up触发,检查是否为其他来源的调用") + print("\n[米电阻查询] 键盘监听功能已禁用,但收到了触发\n") + # 这里我们仍然继续执行,因为该方法可能由其他非键盘源调用 + + # 从配置中获取触发键 + config = ConfigLoader.get_instance() + trigger_key = config.get_value('serial.keyboard.trigger_key', 'Key.page_up') + logging.info(f"[SerialManager] {trigger_key}键按下,正在触发米电阻数据查询...") + print(f"\n[米电阻查询] {trigger_key}键按下,正在处理...\n") + + if not self.mdz_config: + logging.error("[SerialManager] 米电阻配置 (mdz_config) 未加载,无法查询。") + print("\n[米电阻查询] 配置未加载,无法查询\n") + return + + mdz_port_name = self.mdz_config.get('ser') + query_cmd_hex = self.mdz_config.get('query_cmd', '01030001000755C8') + baud_rate = self.mdz_config.get('port', 9600) + timeout = self.mdz_config.get('timeout', 1.0) + data_bits = self.mdz_config.get('data_bits', 8) + stop_bits = self.mdz_config.get('stop_bits', 1) + parity_char = self.mdz_config.get('parity', 'N') + parity = parity_char # Use the character directly ('N', 'E', or 'O') + logging.info(f"[SerialManager] 使用校验位设置: {parity}") + print(f"\n[米电阻查询] 串口配置: {mdz_port_name}, {baud_rate}, {data_bits}, {stop_bits}, {parity}\n") + + temp_ser = None + try: + byte_data = bytes.fromhex(query_cmd_hex.replace(' ', '')) + logging.info(f"[SerialManager] 准备发送米电阻查询指令: {byte_data.hex(' ').upper()} 到端口 {mdz_port_name}") + print(f"\n[米电阻查询] 准备发送查询指令: {byte_data.hex(' ').upper()}\n") + + # 检查 SerialManager 是否已管理此端口且已打开 + if self.is_port_open(mdz_port_name): + logging.info(f"[SerialManager] 米电阻串口 {mdz_port_name} 已由 SerialManager 管理并打开,直接发送指令。") + print(f"\n[米电阻查询] 串口 {mdz_port_name} 已打开,直接发送指令\n") + if self.write_data(mdz_port_name, byte_data): + logging.info(f"[SerialManager] 指令已发送到 {mdz_port_name} (通过已打开的串口)。响应将由读取线程处理。") + print("\n[米电阻查询] 指令发送成功,等待响应\n") + # 当串口已打开时,指令发送后,响应会由 _read_resistance_thread 捕获并处理。 + # _read_resistance_thread 内部的 _process_mdz_response 会负责更新 self.data, + # 调用 _write_data_to_file 和 _notify_callbacks。 + # 因此,这里不需要再显式地 sleep 后调用 _write_data_to_file 和 _notify_callbacks, + # 以避免数据竞争或重复通知。 + return # 指令已发送,等待线程处理 + else: + logging.warning(f"[SerialManager] 向已打开的串口 {mdz_port_name} 发送指令失败。将尝试临时打开。") + print("\n[米电阻查询] 指令发送失败,尝试临时打开串口\n") + + # 如果串口未被 SerialManager 管理或发送失败,则尝试临时打开 + logging.info(f"[SerialManager] 米电阻串口 {mdz_port_name} 未打开或发送失败。尝试临时打开并查询...") + print(f"\n[米电阻查询] 串口 {mdz_port_name} 未打开,尝试临时打开\n") + temp_ser = serial.Serial( + port=mdz_port_name, + baudrate=baud_rate, + bytesize=data_bits, + stopbits=stop_bits, + parity=parity, + timeout=timeout + ) + if not temp_ser.is_open: + temp_ser.open() + + temp_ser.write(byte_data) + logging.info(f"[SerialManager] 指令已通过临时串口发送到 {mdz_port_name}。等待响应...") + print("\n[米电阻查询] 指令已通过临时串口发送,等待响应\n") + time.sleep(0.1) # 等待设备响应 + response_bytes = b'' + if temp_ser.in_waiting > 0: + response_bytes = temp_ser.read(temp_ser.in_waiting) + + if response_bytes: + logging.info(f"[SerialManager] 收到来自 {mdz_port_name} (临时串口) 的响应: {response_bytes.hex(' ').upper()}") + print(f"\n[米电阻查询] 收到响应: {response_bytes.hex(' ').upper()}\n") + + # 将响应交给标准的处理函数 + parse_success = self._process_mdz_response(mdz_port_name, response_bytes) + if not parse_success: + logging.warning(f"[SerialManager] _process_mdz_response未能成功处理来自临时串口{mdz_port_name}的响应。将依赖其内部的mock/old data逻辑。") + print("\n[米电阻查询] 响应解析失败\n") + # _process_mdz_response 内部在失败时会处理 mock/old data 及文件写入和通知,这里无需额外操作。 + else: + logging.warning(f"[SerialManager] 未收到来自 {mdz_port_name} (临时串口) 的响应。") + print("\n[米电阻查询] 未收到响应\n") + except serial.SerialException as se: + logging.error(f"[SerialManager] 临时打开或操作串口 {mdz_port_name} 失败: {se}") + print(f"\n[米电阻查询] 串口操作失败: {se}\n") + except ValueError as ve: + logging.error(f"[SerialManager] 指令转换错误或响应解析错误 (临时查询): {ve}") + print(f"\n[米电阻查询] 指令转换或响应解析错误: {ve}\n") + except Exception as e: + logging.error(f"[SerialManager] 触发米电阻查询时发生未知错误 (临时查询): {e}", exc_info=True) + print(f"\n[米电阻查询] 未知错误: {e}\n") + finally: + if temp_ser and temp_ser.is_open: + temp_ser.close() + logging.info("[SerialManager] 米电阻数据查询流程结束。") + print("\n[米电阻查询] 查询流程结束\n") + + def _notify_callbacks(self, port_name, value): + """通知所有相关回调函数""" + try: + # 端口特定回调 (通常用于原始串口数据) + if port_name in self.callbacks and port_name not in ['mdz_data', 'xj_data', 'scanner_data']: # 避免重复处理 + try: + # 假设这种回调期望原始的 value (可能是字节串,也可能是其他类型) + self.callbacks[port_name](port_name, value) + logging.debug(f"Notified port-specific callback for {port_name}") + except Exception as e: + logging.error(f"调用端口回调 {port_name} 失败: {e}") + + # 全局回调, 特别处理 'mdz_data' + if 'mdz_data' in self.callbacks and port_name == 'mdz_data': + actual_mdz_numeric_value = None + source_info = "unknown" + + if isinstance(value, dict): + actual_mdz_numeric_value = value.get('value') + source_info = value.get('source', source_info) + elif isinstance(value, (str, float, int)): + # 如果直接传递了数值 (例如来自旧的 _use_mock_data) + actual_mdz_numeric_value = str(value) + else: + # 尝试从可能是字节串的value中解码 (不太可能走到这里了,因为上游会处理好) + try: + decoded_value = value.decode('utf-8') + if "米电阻数据:" in decoded_value: + actual_mdz_numeric_value = decoded_value.split("米电阻数据:")[1].strip() + else: + actual_mdz_numeric_value = decoded_value # best guess + except: # noqa + pass # 无法解码或解析,保持 None + + if actual_mdz_numeric_value is not None: + # 构建 PackageInboundDialog.on_mdz_data_received 期望的格式 + callback_data_str = f"米电阻数据: {actual_mdz_numeric_value}" + try: + # port_name 对于 mdz_data 回调,可以传递触发源的串口名,或者一个通用标识 + # trigger_resistance_query 知道 mdz_port_name,它应该作为 port_name 传给 _notify_callbacks + # 如果是从模拟数据来,port_name 可能是 'mdz' 或 'mock' + triggering_port = port_name if port_name not in ['mdz_data', 'mdz'] else self.mdz_config.get('ser', 'N/A') if self.mdz_config else 'N/A' + if source_info.startswith("mock"): # 如果源是模拟数据 + triggering_port = f"mock_{port_name}" # e.g. mock_mdz + + self.callbacks['mdz_data'](triggering_port, callback_data_str.encode('utf-8')) + logging.info(f"通知 'mdz_data' 回调. 值: {actual_mdz_numeric_value}, 源: {source_info}, 触发源端口: {triggering_port}") + except Exception as e: + logging.error(f"调用全局回调 'mdz_data' 失败: {e}", exc_info=True) + else: + logging.warning(f"回调失败: mdz_data 中 实际值为None. 初始 value: {value}") + + # 全局回调, 特别处理 'xj_data' + if 'xj_data' in self.callbacks and port_name == 'xj_data': + actual_xj_numeric_value = None + source_info = "unknown" + + if isinstance(value, dict): + actual_xj_numeric_value = value.get('value') + source_info = value.get('source', source_info) + elif isinstance(value, (str, float, int)): + actual_xj_numeric_value = str(value) + else: + try: + decoded_value = value.decode('utf-8') + if "线径数据:" in decoded_value: + actual_xj_numeric_value = decoded_value.split("线径数据:")[1].strip() + else: + actual_xj_numeric_value = decoded_value # best guess + except: # noqa + pass # 无法解码或解析,保持 None + + if actual_xj_numeric_value is not None: + callback_data_str = f"线径数据: {actual_xj_numeric_value}" + try: + triggering_port = port_name if port_name not in ['xj_data', 'xj'] else self.xj_config.get('ser', 'N/A') if self.xj_config else 'N/A' + if source_info.startswith("mock"): + triggering_port = f"mock_{port_name}" + + self.callbacks['xj_data'](triggering_port, callback_data_str.encode('utf-8')) + logging.info(f"通知 'xj_data' 回调. 值: {actual_xj_numeric_value}, 源: {source_info}, 触发源端口: {triggering_port}") + except Exception as e: + logging.error(f"调用全局回调 'xj_data' 失败: {e}", exc_info=True) + else: + logging.warning(f"回调失败: xj_data 中实际值为None. 初始 value: {value}") + + # 全局回调, 特别处理 'scanner_data' + if 'scanner_data' in self.callbacks and port_name == 'scanner_data': + actual_scanner_value = None + source_info = "unknown" + + if isinstance(value, dict): + actual_scanner_value = value.get('value') + source_info = value.get('source', source_info) + elif isinstance(value, (str, bytes)): + if isinstance(value, bytes): + try: + actual_scanner_value = value.decode('utf-8').strip() + except: + actual_scanner_value = str(value) + else: + actual_scanner_value = value + + if actual_scanner_value is not None: + callback_data_str = f"扫码数据: {actual_scanner_value}" + try: + triggering_port = port_name if port_name not in ['scanner_data', 'scanner'] else self.scanner_config.get('ser', 'N/A') if self.scanner_config else 'N/A' + if source_info.startswith("mock"): + triggering_port = f"mock_{port_name}" + + self.callbacks['scanner_data'](triggering_port, callback_data_str.encode('utf-8')) + logging.info(f"通知 'scanner_data' 回调. 值: {actual_scanner_value}, 源: {source_info}, 触发源端口: {triggering_port}") + except Exception as e: + logging.error(f"调用全局回调 'scanner_data' 失败: {e}", exc_info=True) + else: + logging.warning(f"回调失败: scanner_data 中实际值为None. 初始 value: {value}") + + except Exception as e: + logging.error(f"通知回调失败: {e}", exc_info=True) + + def auto_open_configured_ports(self): + """自动打开已配置的串口""" + logging.info("尝试自动打开已配置的串口...") + + # 首先检查是否启用串口功能 + enable_serial_ports = self.config.get_value('app.features.enable_serial_ports', False) + if not enable_serial_ports: + logging.info("串口功能已在配置中禁用,跳过自动打开串口") + return False + + success = True + + # 检查操作系统类型并提供合适的警告 + os_type = platform.system() + if os_type == "Darwin" and ( + (self.mdz_config and 'ser' in self.mdz_config and self.mdz_config['ser'] and self.mdz_config['ser'].startswith('COM')) or + (self.cz_config and 'ser' in self.cz_config and self.cz_config['ser'] and self.cz_config['ser'].startswith('COM')) or + (self.scanner_config and 'ser' in self.scanner_config and self.scanner_config['ser'] and self.scanner_config['ser'].startswith('COM')) + ): + logging.warning("检测到在macOS系统上配置了Windows格式的COM端口,这些端口将无法正常打开") + logging.warning("macOS上的串口通常是/dev/tty.*或/dev/cu.*格式") + # 继续尝试打开,但不影响程序流程 + + # 尝试打开线径串口 + if self.cz_config and 'ser' in self.cz_config and self.cz_config['ser'] and self.cz_config['ser'].strip(): + port_name = self.cz_config['ser'] + baud_rate = self.cz_config.get('port', 2400) + + if not self.is_port_open(port_name): + try: + if self.open_port(port_name, 'cz', baud_rate): + logging.info(f"自动打开线径串口 {port_name} 成功") + else: + logging.error(f"自动打开线径串口 {port_name} 失败") + success = False + except Exception as e: + logging.error(f"自动打开线径串口 {port_name} 时发生异常: {e}") + success = False + else: + logging.info(f"线径串口 {port_name} 已经打开,无需重新打开") + else: + logging.warning("线径串口未配置或设置为不使用,跳过自动打开") + + # 尝试打开米电阻串口 + if self.mdz_config and 'ser' in self.mdz_config and self.mdz_config['ser'] and self.mdz_config['ser'].strip(): + port_name = self.mdz_config['ser'] + baud_rate = self.mdz_config.get('port', 9600) + + if not self.is_port_open(port_name): + try: + if self.open_port(port_name, 'mdz', baud_rate): + logging.info(f"自动打开米电阻串口 {port_name} 成功") + else: + logging.error(f"自动打开米电阻串口 {port_name} 失败") + success = False + except Exception as e: + logging.error(f"自动打开米电阻串口 {port_name} 时发生异常: {e}") + success = False + else: + logging.info(f"米电阻串口 {port_name} 已经打开,无需重新打开") + else: + logging.warning("米电阻串口未配置或设置为不使用,跳过自动打开") + + # 尝试打开扫码器串口 + if self.scanner_config and 'ser' in self.scanner_config and self.scanner_config['ser'] and self.scanner_config['ser'].strip(): + port_name = self.scanner_config['ser'] + baud_rate = self.scanner_config.get('port', 9600) + + if not self.is_port_open(port_name): + try: + if self.open_port(port_name, 'scanner', baud_rate): + logging.info(f"自动打开扫码器串口 {port_name} 成功") + else: + logging.error(f"自动打开扫码器串口 {port_name} 失败") + success = False + except Exception as e: + logging.error(f"自动打开扫码器串口 {port_name} 时发生异常: {e}") + success = False + else: + logging.info(f"扫码器串口 {port_name} 已经打开,无需重新打开") + else: + logging.warning("扫码器串口未配置或设置为不使用,跳过自动打开") + + # 注意:不在这里启动键盘监听器,而是在MainWindow的handle_start方法中显式调用start_keyboard_listener + + if not success: + logging.warning("部分串口自动打开失败,请检查设备连接或在参数配置中手动打开") + + return True # 总是返回True,防止应用程序因串口问题而终止 + + def suspend_file_operations(self, suspend: bool): + """暂停或恢复文件操作 + + Args: + suspend: True表示暂停,False表示恢复 + """ + with self._file_operations_lock: + old_state = self._file_operations_suspended + self._file_operations_suspended = suspend + + if old_state != suspend: + if suspend: + logging.info("已暂停SerialManager文件操作") + else: + logging.info("已恢复SerialManager文件操作") + + def _read_diameter_thread(self, port_name: str): + """ + 线径串口读取线程 + + Args: + port_name: 串口名称 + """ + try: + while self.running_flags.get(port_name, False): + if not self.is_port_open(port_name): + time.sleep(0.1) + continue + + # 检查是否有数据可读 + if self.serial_ports[port_name].in_waiting > 0: + response = self.serial_ports[port_name].read(self.serial_ports[port_name].in_waiting) + self._process_diameter_response(port_name, response) + + time.sleep(0.1) + + except Exception as e: + logging.error(f"线径串口 {port_name} 读取线程异常: {e}") + + def _process_diameter_response(self, port_name, response_bytes: bytes): + """处理线径响应数据""" + try: + if response_bytes: # 确保有响应数据 + try: + # 转换为字符串用于日志记录 + response_str = str(response_bytes) + logging.warning(f"[{port_name}] 线径数据: {response_str}") + + # 使用正则表达式直接提取数字 + # 查找格式为20.15这样的浮点数 + match = re.search(r'(\d+\.?\d*)', response_str) + if match: + number_str = match.group(1) + try: + # 转换为浮点数 + xj_value = float(number_str) + logging.info(f"线径数据: {xj_value}") + + # 更新数据 + self.data['xj'] = xj_value + self._write_data_to_file() + self._notify_callbacks('xj_data', {"type": "xj", "value": self.data['xj'], "source": f"serial ({port_name})"}) + return True + except ValueError: + logging.warning(f"线径数据字符串 '{number_str}' 无法转换为浮点数") + else: + logging.warning(f"线径数据中未找到有效的浮点数") + except Exception as e: + logging.error(f"处理线径数据异常: {e}") + else: + logging.warning("线径响应数据为空") + + # 如果无法解析,则直接返回失败 + return False + except Exception as e: + logging.error(f"处理线径数据总体异常: {e}") + return False + + def _read_scanner_thread(self, port_name: str): + """ + 扫码器串口读取线程 + + Args: + port_name: 串口名称 + """ + try: + while self.running_flags.get(port_name, False): + if not self.is_port_open(port_name): + time.sleep(0.1) + continue + + # 检查是否有数据可读 + if self.serial_ports[port_name].in_waiting > 0: + response = self.serial_ports[port_name].read(self.serial_ports[port_name].in_waiting) + self._process_scanner_response(port_name, response) + + time.sleep(0.1) + + except Exception as e: + logging.error(f"扫码器串口 {port_name} 读取线程异常: {e}") + + def _process_scanner_response(self, port_name, response_bytes: bytes): + """处理扫码器响应数据""" + try: + if response_bytes: # 确保有响应数据 + try: + # 尝试解码为字符串 + scanner_value = response_bytes.decode('utf-8').strip() + + # 记录日志 + logging.info(f"[{port_name}] 扫码数据: {scanner_value}") + + # 更新数据 + self.data['scanner'] = scanner_value + + # 写入文件并通知回调 + self._write_data_to_file() + # 使用"扫码数据: xxx"格式通知回调 + callback_data = f"扫码数据: {scanner_value}".encode('utf-8') + if 'scanner_data' in self.callbacks: + self.callbacks['scanner_data'](port_name, callback_data) + return True + except Exception as e: + logging.error(f"处理扫码数据异常: {e}") + # 解码失败,尝试直接使用字节数据 + # 记录日志(十六进制字符串) + hex_str = ' '.join(f'{b:02X}' for b in response_bytes) + logging.warning(f"[{port_name}] 扫码数据(十六进制): {hex_str}") + + # 更新数据(使用十六进制字符串) + self.data['scanner'] = hex_str + + # 写入文件并通知回调 + self._write_data_to_file() + # 使用"扫码数据: xxx"格式通知回调 + callback_data = f"扫码数据: {hex_str}".encode('utf-8') + if 'scanner_data' in self.callbacks: + self.callbacks['scanner_data'](port_name, callback_data) + return True + else: + logging.warning("扫码响应数据为空") + + # 如果无法解析,则直接返回失败 + return False + except Exception as e: + logging.error(f"处理扫码数据总体异常: {e}") + return False \ No newline at end of file diff --git a/tests/serial_manager.py.fixed b/tests/serial_manager.py.fixed new file mode 100644 index 0000000..d5289cc --- /dev/null +++ b/tests/serial_manager.py.fixed @@ -0,0 +1,1325 @@ +import serial +import threading +import time +import logging +import os +import json +from typing import Dict, Optional, Callable +from utils.config_loader import ConfigLoader +from utils.keyboard_listener import KeyboardListener +from pynput.keyboard import Key +import re +import platform + +class SerialManager: + """串口管理器,单例模式""" + _instance = None + _lock = threading.Lock() + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super(SerialManager, cls).__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + self._initialized = True + self.serial_ports: Dict[str, serial.Serial] = {} # 存储打开的串口对象 + self.read_threads: Dict[str, threading.Thread] = {} # 存储读取线程 + self.running_flags: Dict[str, bool] = {} # 存储线程运行标志 + self.callbacks: Dict[str, Callable] = {} # 存储数据回调函数 + self.port_types: Dict[str, str] = {} # 存储端口类型,用于线程重启 + + # 添加文件操作暂停控制 + self._file_operations_suspended = False + self._file_operations_lock = threading.Lock() + + # 添加称重稳定性检测变量 + self.last_weight = 0 + self.stable_count = 0 + self.weight_written = False # 初始化为False,确保首次称重能够正确处理 + + # 添加抗抖动变量 + self.last_weights = [0] * 3 # 存储最近3个重量值 + self.weight_changed_time = time.time() # 上次重量变化的时间 + self.last_write_time = 0 # 最后写入时间 + + # 稳定性时间跟踪 + self.stability_start_time = 0 # 开始检测稳定性的时间 + + # 数据存储 + self.data = { + 'mdz': 0, + 'xj': 0, # 添加线径数据 + 'cz': 0, + 'scanner': '' # 添加扫码器数据 + } + + # 是否自动查询米电阻数据,默认为False,只通过PageUp键触发 + self.auto_query_mdz = False + + # 是否自动查询线径数据,默认为True,开启自动查询 + self.auto_query_xj = True + + logging.info("初始化 SerialManager") + + # 加载配置 + self._load_config() + + # 检查是否启用键盘监听功能 + enable_keyboard_listener = self.config.get_value('app.features.enable_keyboard_listener', False) + if enable_keyboard_listener: + try: + # 初始化键盘监听器 + self.keyboard_listener = KeyboardListener() + + # 从配置中获取米电阻触发键 + trigger_key = self.config.get_value('serial.keyboard.trigger_key', 'Key.page_up') + # 注册米电阻触发键回调 + self.keyboard_listener.register_callback(trigger_key, self.trigger_resistance_query) + logging.info(f"已注册{trigger_key}按键回调用于触发米电阻查询") + + # 注意:不在这里启动键盘监听器,而是在点击"开始"按钮时启动 + except Exception as e: + logging.error(f"初始化键盘监听器失败: {e}") + # 创建一个空的键盘监听器对象,以避免后续代码出现NoneType错误 + self.keyboard_listener = None + else: + logging.info("键盘监听功能已在配置中禁用,跳过初始化键盘监听器") + self.keyboard_listener = None + + # 启动线程监控 + self._start_thread_monitor() + + def _load_config(self): + """加载配置""" + try: + config_loader = ConfigLoader.get_instance() # Renamed for clarity + self.config = config_loader # Assign the whole config object + + # 获取数据文件路径 + self.data_file = self.config.get_value('serial.data_file', 'data.txt') + # 确保是绝对路径 + if not os.path.isabs(self.data_file): + self.data_file = os.path.abspath(self.data_file) + logging.info(f"最终确定的 data_file 绝对路径: {self.data_file}") + + # 获取串口配置 + self.mdz_config = self.config.get_config('mdz') + self.cz_config = self.config.get_config('cz') + self.xj_config = self.config.get_config('xj') # 添加线径配置 + self.scanner_config = self.config.get_config('scanner') # 添加扫码器配置 + + # 检查操作系统类型,在macOS上处理COM端口名称问题 + os_type = platform.system() + if os_type == "Darwin": + # 检查是否需要自动检测macOS上的串口 + macos_autodetect = self.config.get_value('serial.os_config.macos_autodetect', False) + if macos_autodetect: + logging.info("在macOS上启用了串口自动检测") + self._detect_macos_ports() + + # 获取默认的稳定阈值 + self.stable_threshold = self.cz_config.get('stable_threshold', 10) if self.cz_config else 10 + + # 检查是否自动查询米电阻数据 + self.auto_query_mdz = self.config.get_value('serial.mdz.auto_query', False) + + # 检查是否自动查询线径数据 + self.auto_query_xj = self.config.get_value('serial.xj.auto_query', True) # 默认为True,确保线径自动查询开启 + + logging.info(f"已加载串口配置:mdz={self.mdz_config}, xj={self.xj_config}, cz={self.cz_config}, scanner={self.scanner_config}, data_file={self.data_file}") + logging.info(f"米电阻自动查询: {'开启' if self.auto_query_mdz else '关闭'}") + logging.info(f"线径自动查询: {'开启' if self.auto_query_xj else '关闭'}") + except Exception as e: + logging.error(f"加载配置出错: {e}") + # 设置默认值 + self.data_file = os.path.abspath('data.txt') + self.mdz_config = {'port': 9600, 'ser': 'COM5'} + self.cz_config = {'port': 9600, 'ser': 'COM2', 'stable_threshold': 10} + self.xj_config = {'port': 9600, 'ser': 'COM3'} + self.scanner_config = {'port': 9600, 'ser': 'COM4'} + self.stable_threshold = 10 + self.auto_query_mdz = False + self.auto_query_xj = True # 默认为True,确保线径自动查询开启 + logging.info(f"使用默认配置,数据文件: {self.data_file}") + + def _detect_macos_ports(self): + """在macOS上检测可用的串口""" + try: + if platform.system() != "Darwin": + logging.info("不是macOS系统,跳过串口检测") + return + + logging.info("正在检测macOS可用串口...") + + # 检查/dev目录下的tty.*和cu.*设备 + import glob + tty_ports = glob.glob('/dev/tty.*') + cu_ports = glob.glob('/dev/cu.*') + all_ports = tty_ports + cu_ports + + if not all_ports: + logging.warning("未检测到macOS上的串口设备") + return + + logging.info(f"检测到以下串口设备: {all_ports}") + + # 如果mdz_config中的串口是COM格式,尝试替换为检测到的第一个串口 + if self.mdz_config and 'ser' in self.mdz_config and self.mdz_config['ser'].startswith('COM'): + # 优先选择包含"usb"的设备 + usb_ports = [port for port in all_ports if 'usb' in port.lower()] + if usb_ports: + self.mdz_config['ser'] = usb_ports[0] + logging.info(f"自动将米电阻串口从COM格式替换为: {usb_ports[0]}") + elif all_ports: + self.mdz_config['ser'] = all_ports[0] + logging.info(f"自动将米电阻串口从COM格式替换为: {all_ports[0]}") + + # 如果cz_config中的串口是COM格式,尝试替换为检测到的第二个串口 + if self.cz_config and 'ser' in self.cz_config and self.cz_config['ser'].startswith('COM'): + # 优先选择包含"usb"的设备,并且不是已分配给mdz的设备 + usb_ports = [port for port in all_ports if 'usb' in port.lower() + and (not self.mdz_config or port != self.mdz_config.get('ser'))] + if usb_ports: + self.cz_config['ser'] = usb_ports[0] + logging.info(f"自动将线径串口从COM格式替换为: {usb_ports[0]}") + elif len(all_ports) > 1: + # 选择不是mdz_config已使用的第一个端口 + for port in all_ports: + if not self.mdz_config or port != self.mdz_config.get('ser'): + self.cz_config['ser'] = port + logging.info(f"自动将线径串口从COM格式替换为: {port}") + break + elif all_ports and (not self.mdz_config or all_ports[0] != self.mdz_config.get('ser')): + self.cz_config['ser'] = all_ports[0] + logging.info(f"自动将线径串口从COM格式替换为: {all_ports[0]}") + + except Exception as e: + logging.error(f"检测macOS串口时发生错误: {e}") + logging.info("将继续使用配置文件中的串口设置") + + def open_port(self, port_name: str, port_type: str, baud_rate: Optional[int] = None, + data_bits: int = 8, stop_bits: int = 1, + parity: str = 'N', timeout: float = 1.0, + callback: Optional[Callable] = None) -> bool: + """ + 打开串口 + + Args: + port_name: 串口名称,如COM1 + port_type: 串口类型,'cz'表示称重,'mdz'表示米电阻, 'xj'表示线径, 'scanner'表示扫码器 + baud_rate: 波特率,如果为None则从配置文件读取 + data_bits: 数据位 + stop_bits: 停止位 + parity: 校验位,'N'表示无校验,'O'表示奇校验,'E'表示偶校验 + timeout: 超时时间,单位秒 + callback: 回调函数,接收(port_name, data)作为参数 + + Returns: + bool: 成功返回True,失败返回False + """ + # 如果串口已经打开,先关闭 + if port_name in self.serial_ports and self.serial_ports[port_name]: + try: + self.close_port(port_name) + except Exception as e: + logging.error(f"关闭已打开的串口失败: {e}") + + # 配置串口参数 + try: + # 从配置读取波特率(如果未提供) + if baud_rate is None: + if port_type == 'cz' and self.cz_config: + baud_rate = self.cz_config.get('port', 9600) + elif port_type == 'mdz' and self.mdz_config: + baud_rate = self.mdz_config.get('port', 9600) + elif port_type == 'xj' and self.xj_config: + baud_rate = self.xj_config.get('port', 9600) + elif port_type == 'scanner' and self.scanner_config: + baud_rate = self.scanner_config.get('port', 9600) + else: + baud_rate = 9600 # 默认波特率 + + # 转换校验位为PySerial常量 + if parity.upper() == 'N': + parity_constant = serial.PARITY_NONE + elif parity.upper() == 'O': + parity_constant = serial.PARITY_ODD + elif parity.upper() == 'E': + parity_constant = serial.PARITY_EVEN + else: + parity_constant = serial.PARITY_NONE + + # 打开串口 + self.serial_ports[port_name] = serial.Serial( + port=port_name, + baudrate=baud_rate, + bytesize=data_bits, + stopbits=stop_bits, + parity=parity_constant, + timeout=timeout + ) + + logging.info(f"打开串口成功: {port_name}, 类型: {port_type}, 波特率: {baud_rate}") + + # 设置回调 + if callback: + self.callbacks[port_name] = callback + + # 记录端口类型,用于线程重启 + self.port_types[port_name] = port_type + + # 创建并启动读取线程 + self.running_flags[port_name] = True + + # 统一线程创建和管理方式 + if port_type == 'cz': + # 称重数据需要特殊处理 + thread = threading.Thread( + target=self._read_weight_thread, + args=(port_name, self.stable_threshold), + daemon=True, + name=f"Thread-{port_type}-{port_name}" + ) + elif port_type == 'mdz': + # 米电阻数据需要特殊处理 + thread = threading.Thread( + target=self._read_resistance_thread, + args=(port_name,), + daemon=True, + name=f"Thread-{port_type}-{port_name}" + ) + elif port_type == 'xj': + # 线径数据需要特殊处理 + thread = threading.Thread( + target=self._read_diameter_thread, + args=(port_name,), + daemon=True, + name=f"Thread-{port_type}-{port_name}" + ) + elif port_type == 'scanner': + # 扫码器数据需要特殊处理 + thread = threading.Thread( + target=self._read_scanner_thread, + args=(port_name,), + daemon=True, + name=f"Thread-{port_type}-{port_name}" + ) + else: + # 其他类型使用通用处理 + thread = threading.Thread( + target=self._read_thread, + args=(port_name,), + daemon=True, + name=f"Thread-{port_type}-{port_name}" + ) + + # 统一启动线程 + thread.start() + self.read_threads[port_name] = thread + logging.info(f"已启动串口读取线程: {thread.name}") + + return True + + except Exception as e: + logging.error(f"打开串口失败: {port_name}, 错误: {e}") + # 确保清理好资源 + if port_name in self.serial_ports: + try: + self.serial_ports[port_name].close() + except: + pass + self.serial_ports.pop(port_name, None) + + # 停止相关线程 + self.running_flags[port_name] = False + if port_name in self.read_threads: + self.read_threads.pop(port_name, None) + + return False + + def close_port(self, port_name: str) -> bool: + """ + 关闭串口 + + Args: + port_name: 串口名称 + + Returns: + 是否成功关闭 + """ + try: + # 停止读取线程 + if port_name in self.running_flags: + self.running_flags[port_name] = False + + # 等待线程结束 + if port_name in self.read_threads: + if self.read_threads[port_name].is_alive(): + self.read_threads[port_name].join(1.0) # 最多等待1秒 + del self.read_threads[port_name] + + # 关闭串口 + if port_name in self.serial_ports: + if self.serial_ports[port_name].is_open: + self.serial_ports[port_name].close() + del self.serial_ports[port_name] + logging.info(f"串行对象 for {port_name} 从 self.serial_ports 中删除. 当前活跃端口: {list(self.serial_ports.keys())}") + + # 删除回调和端口类型记录 + if port_name in self.callbacks: + del self.callbacks[port_name] + + if port_name in self.port_types: + del self.port_types[port_name] + + logging.info(f"串口 {port_name} 已关闭") + return True + + except Exception as e: + logging.error(f"关闭串口 {port_name} 失败: {str(e)}") + return False + + def is_port_open(self, port_name: str) -> bool: + """ + 检查串口是否打开 + + Args: + port_name: 串口名称 + + Returns: + 是否打开 + """ + return port_name in self.serial_ports and self.serial_ports[port_name].is_open + + def write_data(self, port_name: str, data: bytes) -> bool: + """ + 向串口写入数据 + + Args: + port_name: 串口名称 + data: 要写入的数据 + + Returns: + 是否成功写入 + """ + try: + if not self.is_port_open(port_name): + return False + + self.serial_ports[port_name].write(data) + return True + + except Exception as e: + logging.error(f"向串口 {port_name} 写入数据失败: {str(e)}") + return False + + def read_data(self, port_name: str, size: int = None) -> bytes: + """ + 从串口读取数据 + + Args: + port_name: 串口名称 + size: 要读取的字节数,如果为None则读取所有可用数据 + + Returns: + 读取的数据,如果失败则返回空字节 + """ + try: + if not self.is_port_open(port_name): + logging.error(f"尝试从未打开的串口 {port_name} 读取数据") + return b'' + + if size is None: + # 读取所有可用数据 + if self.serial_ports[port_name].in_waiting > 0: + return self.serial_ports[port_name].read(self.serial_ports[port_name].in_waiting) + return b'' + else: + # 读取指定数量的字节 + return self.serial_ports[port_name].read(size) + + except Exception as e: + logging.error(f"从串口 {port_name} 读取数据失败: {str(e)}") + return b'' + + def _read_thread(self, port_name: str): + """ + 串口读取线程 + + Args: + port_name: 串口名称 + """ + try: + while self.running_flags.get(port_name, False): + if not self.is_port_open(port_name): + time.sleep(0.1) + continue + + # 读取数据 + if self.serial_ports[port_name].in_waiting: + data = self.serial_ports[port_name].readline() + + # 调用回调函数 + if port_name in self.callbacks and data: + try: + self.callbacks[port_name](port_name, data) + except Exception as e: + logging.error(f"调用串口 {port_name} 回调函数失败: {str(e)}") + + time.sleep(0.01) # 短暂休眠,避免CPU占用过高 + + except Exception as e: + logging.error(f"串口 {port_name} 读取线程异常: {str(e)}") + + def _read_weight_thread(self, port_name: str, stable_threshold: int = 10): + """ + 称重串口读取线程 + + Args: + port_name: 串口名称 + stable_threshold: 稳定阈值 + """ + try: + logging.info(f"[{port_name}] 称重线程启动") + # 重置状态变量,确保线程重启时能正确处理称重数据 + self.weight_written = False + self.stable_count = 0 + self.last_weight = 0 + self.last_weights = [0] * 3 + self.weight_changed_time = time.time() + self.last_write_time = 0 # 添加一个变量来跟踪最后一次写入时间 + self.stability_start_time = 0 # 重置稳定性检测开始时间 + + # 添加实际的读取逻辑 + while self.running_flags.get(port_name, False): + if not self.is_port_open(port_name): + time.sleep(0.1) + continue + + # 检查是否有数据可读 + if self.serial_ports[port_name].in_waiting > 0: + data = self.serial_ports[port_name].read(self.serial_ports[port_name].in_waiting) + weight = self._process_weight_data(data) + if weight is not None: + # 更新数据 + self.data['cz'] = weight + self._write_data_to_file() + + time.sleep(0.1) + + except Exception as e: + logging.error(f"称重串口 {port_name} 读取线程异常: {e}") + # 线程异常时,尝试重置状态 + self.running_flags[port_name] = False + + def _process_weight_data(self, data_bytes): + """ + TODO: 需要将线径数据写入文件,这个方法需要修改成线径的串口数据获取 + + Args: + data_bytes: 原始字节数据 + + Returns: + 解析后的重量值或None + """ + try: + # 尝试多种编码方式解析 + weight = None + + # 记录原始数据以便调试 + logging.debug(f"称重原始数据: {data_bytes.hex()}") + + # 方法1: 尝试ASCII解码 + try: + ascii_str = data_bytes.decode('ascii', errors='replace') + # 仅提取数字部分 + import re + numbers = re.findall(r'\d+', ascii_str) + if numbers: + weight = float(numbers[0]) / 10.0 + logging.debug(f"ASCII解码成功: {weight}") + except Exception as e: + logging.debug(f"ASCII解码失败: {e}") + + # 方法2: 尝试直接从二进制解析 (根据具体协议) + if weight is None and len(data_bytes) >= 8: + try: + # 假设重量在特定位置,具体需根据实际协议调整 + weight_bytes = data_bytes[2:6] + weight = int.from_bytes(weight_bytes, byteorder='big') / 10.0 + logging.debug(f"二进制解码成功: {weight}") + except Exception as e: + logging.debug(f"二进制解码失败: {e}") + + return weight + except Exception as e: + logging.error(f"处理称重数据失败: {e}") + return None + + def _read_resistance_thread(self, port_name: str): + """ + 米电阻串口读取线程 + + Args: + port_name: 串口名称 + """ + try: + logging.info(f"[{port_name}] 米电阻线程启动") + while self.running_flags.get(port_name, False): + if not self.is_port_open(port_name): + time.sleep(0.1) + continue + + # 如果不是自动查询模式,则只监听串口数据而不主动发送查询 + if not self.auto_query_mdz: + # 检查是否有数据可读 + if self.serial_ports[port_name].in_waiting > 0: + response = self.serial_ports[port_name].read(self.serial_ports[port_name].in_waiting) + self._process_mdz_response(port_name, response) + + time.sleep(0.1) + continue + + # 以下代码只在自动查询模式下执行 + try: + # 发送查询指令 + hex_data = '01 03 00 01 00 07 55 C8' + byte_data = bytes.fromhex(hex_data.replace(' ', '')) + self.serial_ports[port_name].write(byte_data) + + # 等待响应 + time.sleep(1) + + if self.serial_ports[port_name].in_waiting > 0: + response = self.serial_ports[port_name].read(self.serial_ports[port_name].in_waiting) + self._process_mdz_response(port_name, response) + + except Exception as e: + logging.error(f"米电阻数据处理异常: {e}") + + # 每5秒查询一次 + for i in range(50): + if not self.running_flags.get(port_name, False): + break + time.sleep(0.1) + + except Exception as e: + logging.error(f"米电阻串口 {port_name} 读取线程异常: {e}") + # 线程异常时,尝试重置状态 + self.running_flags[port_name] = False + + def _process_mdz_response(self, port_name, response_bytes: bytes): + """处理米电阻响应数据""" + try: + if response_bytes: # 确保有响应数据 + try: + # 转换为字符串用于日志记录 + response_str = str(response_bytes) + logging.warning(f"[{port_name}] 米电阻数据: {response_str}") + + # 使用正则表达式直接提取数字 + # 查找格式为11.58201这样的浮点数 + match = re.search(r'(\d+\.\d+)', response_str) + if match: + number_str = match.group(1) + try: + # 转换为浮点数 + mdz_value = float(number_str) + logging.info(f"米电阻数据: {mdz_value}") + + # 更新数据 + self.data['mdz'] = mdz_value + self._write_data_to_file() + self._notify_callbacks('mdz_data', {"type": "mdz", "value": self.data['mdz'], "source": f"serial ({port_name})"}) + return True + except ValueError: + logging.warning(f"米电阻数据字符串 '{number_str}' 无法转换为浮点数") + else: + logging.warning(f"米电阻数据中未找到有效的浮点数") + except Exception as e: + logging.error(f"处理米电阻数据异常: {e}") + else: + logging.warning("米电阻响应数据为空") + + # 如果无法解析,则不再使用模拟数据,直接返回失败 + return False + + except Exception as e: + logging.error(f"米电阻数据处理关键异常: {e}") + return False + + def _write_data_to_file(self): + """将数据写入文件""" + try: + # 检查文件操作是否已暂停 + with self._file_operations_lock: + if self._file_operations_suspended: + logging.info("文件操作已暂停,跳过写入") + return + + # 构建数据字符串 + data_str = f"mdz:{self.data['mdz']}|cz:{self.data['cz']}|scanner:{self.data['scanner']}|" + + # 确保目录存在 + data_dir = os.path.dirname(self.data_file) + if data_dir and not os.path.exists(data_dir): + logging.info(f"创建目录: {data_dir}") + os.makedirs(data_dir, exist_ok=True) + + # 写入文件 - 使用临时文件写入然后重命名,避免文件锁定问题 + # 创建临时文件 + temp_file = f"{self.data_file}.tmp" + try: + with open(temp_file, 'w', encoding='utf-8') as f: + f.write(data_str) + f.flush() + os.fsync(f.fileno()) # 确保数据写入磁盘 + + # 再次检查文件操作是否已暂停 + with self._file_operations_lock: + if self._file_operations_suspended: + logging.info("文件操作已暂停,已写入临时文件但取消重命名操作") + return + + # 原子性地重命名文件,替换原有文件(在大多数操作系统上是原子操作) + if os.path.exists(self.data_file): + try: + os.remove(self.data_file) + except Exception as e: + logging.warning(f"无法删除旧数据文件: {e}, 尝试直接覆盖") + + os.rename(temp_file, self.data_file) + logging.info(f"数据已写入文件: {self.data_file}") + except Exception as e: + logging.error(f"写入临时文件失败: {e}") + # 清理临时文件 + if os.path.exists(temp_file): + try: + os.remove(temp_file) + except: + pass + raise # 重新抛出异常 + + except Exception as e: + logging.error(f"写入数据文件失败: {e}, 文件路径: {self.data_file}") + + def get_current_data(self): + """获取当前数据""" + return self.data.copy() + + def close_all_ports(self): + """关闭所有串口""" + port_names = list(self.serial_ports.keys()) + for port_name in port_names: + self.close_port(port_name) + + def reload_config(self): + """重新加载配置""" + self._load_config() + logging.info("已重新加载串口配置") + + def start_keyboard_listener(self): + """启动键盘监听""" + try: + # 检查键盘监听器是否已初始化 + if self.keyboard_listener is None: + logging.warning("键盘监听器未初始化,无法启动") + return False + + # 从配置中获取米电阻触发键 + trigger_key = self.config.get_value('serial.keyboard.trigger_key', 'Key.page_up') + # 不再获取线径触发键 + + # 确保已注册米电阻触发键回调 + if trigger_key not in self.keyboard_listener.callbacks: + self.keyboard_listener.register_callback(trigger_key, self.trigger_resistance_query) + logging.info(f"已注册{trigger_key}按键回调用于触发米电阻数据查询") + + # 移除线径触发键回调注册代码 + + # 启动键盘监听 + result = self.keyboard_listener.start() + if result: + logging.info(f"已启动键盘监听,按 {trigger_key} 键可触发米电阻数据查询") + + # 检查监听器状态 + if self.keyboard_listener.is_active(): + logging.info("键盘监听器处于活动状态") + else: + logging.warning("键盘监听器启动完成,但状态检查显示不活动") + else: + logging.error("启动键盘监听失败") + return result + except Exception as e: + logging.error(f"启动键盘监听失败: {e}") + return False + + def stop_keyboard_listener(self, join_thread=False): + """停止键盘监听 + + Args: + join_thread: 是否等待键盘监听线程结束 + """ + try: + # 检查键盘监听器是否已初始化 + if self.keyboard_listener is None: + logging.info("键盘监听器未初始化,无需停止") + return + + self.keyboard_listener.stop() + logging.info("已停止键盘监听") + + # 如果需要等待线程结束 + if join_thread: + try: + self.keyboard_listener.join(timeout=1.0) # 最多等待1秒 + except Exception as e: + logging.error(f"等待键盘监听线程结束时出错: {e}") + except Exception as e: + logging.error(f"停止键盘监听失败: {e}") + + def register_callback(self, key, callback): + """ + 注册数据回调函数 + + Args: + key: 回调标识,如 'mdz_data' + callback: 回调函数,参数为 (port_name, data) + """ + try: + if key in self.callbacks: + logging.warning(f"覆盖已存在的回调函数: {key}") + self.callbacks[key] = callback + logging.info(f"已注册回调函数: {key}, 回调对象类型: {callback.__self__.__class__.__name__}") + except Exception as e: + logging.error(f"注册回调失败: {e}") + + def trigger_resistance_query(self): + """触发米电阻查询""" + try: + # 检查米电阻配置是否存在 + if not self.mdz_config: + logging.warning("未找到米电阻配置,无法触发查询") + return False + + # 检查米电阻串口是否已打开 + ser_name = self.mdz_config.get('ser') + if not ser_name or not self.is_port_open(ser_name): + logging.warning(f"米电阻串口 {ser_name} 未打开,无法触发查询") + return False + + # 从配置获取查询命令,如果没有则使用默认命令 + query_cmd = self.mdz_config.get('query_cmd', '01 03 00 01 00 07 55 C8') + + # 转换为字节数据 + try: + byte_data = bytes.fromhex(query_cmd.replace(' ', '')) + except ValueError as e: + logging.error(f"米电阻查询命令格式错误: {query_cmd}, 错误: {e}") + return False + + # 发送查询命令 + result = self.write_data(ser_name, byte_data) + if result: + logging.info(f"已向米电阻串口 {ser_name} 发送查询命令: {query_cmd}") + + # 特殊情况:如果触发了查询,但串口没有响应,尝试模拟一个合理的响应 + # 这里我们先等待一段时间,让设备有机会响应 + time.sleep(0.5) + + # 如果没有数据可读,或者读取的数据不包含有效的米电阻值 + if self.serial_ports[ser_name].in_waiting == 0: + logging.warning("米电阻串口未返回数据,检查设备连接") + + # 在这里我们不再自动提供模拟数据,而是由用户决定是否重试 + return True + else: + logging.error(f"向米电阻串口 {ser_name} 发送查询命令失败") + return False + + except Exception as e: + logging.error(f"触发米电阻查询时出错: {e}") + return False + + def _notify_callbacks(self, port_name, value): + """通知所有相关回调函数""" + try: + # 端口特定回调 (通常用于原始串口数据) + if port_name in self.callbacks and port_name not in ['mdz_data', 'xj_data', 'scanner_data']: # 避免重复处理 + try: + # 假设这种回调期望原始的 value (可能是字节串,也可能是其他类型) + self.callbacks[port_name](port_name, value) + logging.debug(f"Notified port-specific callback for {port_name}") + except Exception as e: + logging.error(f"调用端口回调 {port_name} 失败: {e}") + + # 全局回调, 特别处理 'mdz_data' + if 'mdz_data' in self.callbacks and port_name == 'mdz_data': + actual_mdz_numeric_value = None + source_info = "unknown" + + if isinstance(value, dict): + actual_mdz_numeric_value = value.get('value') + source_info = value.get('source', source_info) + elif isinstance(value, (str, float, int)): + # 如果直接传递了数值 (例如来自旧的 _use_mock_data) + actual_mdz_numeric_value = str(value) + else: + # 尝试从可能是字节串的value中解码 (不太可能走到这里了,因为上游会处理好) + try: + decoded_value = value.decode('utf-8') + if "米电阻数据:" in decoded_value: + actual_mdz_numeric_value = decoded_value.split("米电阻数据:")[1].strip() + else: + actual_mdz_numeric_value = decoded_value # best guess + except: # noqa + pass # 无法解码或解析,保持 None + + if actual_mdz_numeric_value is not None: + # 构建 PackageInboundDialog.on_mdz_data_received 期望的格式 + callback_data_str = f"米电阻数据: {actual_mdz_numeric_value}" + try: + # port_name 对于 mdz_data 回调,可以传递触发源的串口名,或者一个通用标识 + # trigger_resistance_query 知道 mdz_port_name,它应该作为 port_name 传给 _notify_callbacks + # 如果是从模拟数据来,port_name 可能是 'mdz' 或 'mock' + triggering_port = port_name if port_name not in ['mdz_data', 'mdz'] else self.mdz_config.get('ser', 'N/A') if self.mdz_config else 'N/A' + if source_info.startswith("mock"): # 如果源是模拟数据 + triggering_port = f"mock_{port_name}" # e.g. mock_mdz + + self.callbacks['mdz_data'](triggering_port, callback_data_str.encode('utf-8')) + logging.info(f"通知 'mdz_data' 回调. 值: {actual_mdz_numeric_value}, 源: {source_info}, 触发源端口: {triggering_port}") + except Exception as e: + logging.error(f"调用全局回调 'mdz_data' 失败: {e}", exc_info=True) + else: + logging.warning(f"回调失败: mdz_data 中 实际值为None. 初始 value: {value}") + + # 全局回调, 特别处理 'xj_data' + if 'xj_data' in self.callbacks and port_name == 'xj_data': + actual_xj_numeric_value = None + source_info = "unknown" + + if isinstance(value, dict): + actual_xj_numeric_value = value.get('value') + source_info = value.get('source', source_info) + elif isinstance(value, (str, float, int)): + actual_xj_numeric_value = str(value) + else: + try: + decoded_value = value.decode('utf-8') + if "线径数据:" in decoded_value: + actual_xj_numeric_value = decoded_value.split("线径数据:")[1].strip() + else: + actual_xj_numeric_value = decoded_value # best guess + except: # noqa + pass # 无法解码或解析,保持 None + + if actual_xj_numeric_value is not None: + callback_data_str = f"线径数据: {actual_xj_numeric_value}" + try: + triggering_port = port_name if port_name not in ['xj_data', 'xj'] else self.xj_config.get('ser', 'N/A') if self.xj_config else 'N/A' + if source_info.startswith("mock"): + triggering_port = f"mock_{port_name}" + + self.callbacks['xj_data'](triggering_port, callback_data_str.encode('utf-8')) + logging.info(f"通知 'xj_data' 回调. 值: {actual_xj_numeric_value}, 源: {source_info}, 触发源端口: {triggering_port}") + except Exception as e: + logging.error(f"调用全局回调 'xj_data' 失败: {e}", exc_info=True) + else: + logging.warning(f"回调失败: xj_data 中实际值为None. 初始 value: {value}") + + # 全局回调, 特别处理 'scanner_data' + if 'scanner_data' in self.callbacks and port_name == 'scanner_data': + actual_scanner_value = None + source_info = "unknown" + + if isinstance(value, dict): + actual_scanner_value = value.get('value') + source_info = value.get('source', source_info) + elif isinstance(value, (str, bytes)): + if isinstance(value, bytes): + try: + actual_scanner_value = value.decode('utf-8').strip() + except: + actual_scanner_value = str(value) + else: + actual_scanner_value = value + + if actual_scanner_value is not None: + callback_data_str = f"扫码数据: {actual_scanner_value}" + try: + triggering_port = port_name if port_name not in ['scanner_data', 'scanner'] else self.scanner_config.get('ser', 'N/A') if self.scanner_config else 'N/A' + if source_info.startswith("mock"): + triggering_port = f"mock_{port_name}" + + self.callbacks['scanner_data'](triggering_port, callback_data_str.encode('utf-8')) + logging.info(f"通知 'scanner_data' 回调. 值: {actual_scanner_value}, 源: {source_info}, 触发源端口: {triggering_port}") + except Exception as e: + logging.error(f"调用全局回调 'scanner_data' 失败: {e}", exc_info=True) + else: + logging.warning(f"回调失败: scanner_data 中实际值为None. 初始 value: {value}") + + except Exception as e: + logging.error(f"通知回调失败: {e}", exc_info=True) + + def auto_open_configured_ports(self): + """自动打开已配置的串口""" + logging.info("尝试自动打开已配置的串口...") + + # 首先检查是否启用串口功能 + enable_serial_ports = self.config.get_value('app.features.enable_serial_ports', False) + if not enable_serial_ports: + logging.info("串口功能已在配置中禁用,跳过自动打开串口") + return False + + success = True + + # 检查操作系统类型并提供合适的警告 + os_type = platform.system() + if os_type == "Darwin" and ( + (self.mdz_config and 'ser' in self.mdz_config and self.mdz_config['ser'] and self.mdz_config['ser'].startswith('COM')) or + (self.cz_config and 'ser' in self.cz_config and self.cz_config['ser'] and self.cz_config['ser'].startswith('COM')) or + (self.xj_config and 'ser' in self.xj_config and self.xj_config['ser'] and self.xj_config['ser'].startswith('COM')) or + (self.scanner_config and 'ser' in self.scanner_config and self.scanner_config['ser'] and self.scanner_config['ser'].startswith('COM')) + ): + logging.warning("检测到在macOS系统上配置了Windows格式的COM端口,这些端口将无法正常打开") + logging.warning("macOS上的串口通常是/dev/tty.*或/dev/cu.*格式") + # 继续尝试打开,但不影响程序流程 + + # 尝试打开称重串口 + if self.cz_config and 'ser' in self.cz_config and self.cz_config['ser'] and self.cz_config['ser'].strip(): + port_name = self.cz_config['ser'] + baud_rate = self.cz_config.get('port', 9600) + + if not self.is_port_open(port_name): + try: + if self.open_port(port_name, 'cz', baud_rate): + logging.info(f"自动打开称重串口 {port_name} 成功") + else: + logging.error(f"自动打开称重串口 {port_name} 失败") + success = False + except Exception as e: + logging.error(f"自动打开称重串口 {port_name} 时发生异常: {e}") + success = False + else: + logging.info(f"称重串口 {port_name} 已经打开,无需重新打开") + else: + logging.warning("称重串口未配置或设置为不使用,跳过自动打开") + + # 尝试打开线径串口 + if self.xj_config and 'ser' in self.xj_config and self.xj_config['ser'] and self.xj_config['ser'].strip(): + port_name = self.xj_config['ser'] + baud_rate = self.xj_config.get('port', 19200) + + if not self.is_port_open(port_name): + try: + if self.open_port(port_name, 'xj', baud_rate): + logging.info(f"自动打开线径串口 {port_name} 成功") + else: + logging.error(f"自动打开线径串口 {port_name} 失败") + success = False + except Exception as e: + logging.error(f"自动打开线径串口 {port_name} 时发生异常: {e}") + success = False + else: + logging.info(f"线径串口 {port_name} 已经打开,无需重新打开") + else: + logging.warning("线径串口未配置或设置为不使用,跳过自动打开") + + # 尝试打开米电阻串口 + if self.mdz_config and 'ser' in self.mdz_config and self.mdz_config['ser'] and self.mdz_config['ser'].strip(): + port_name = self.mdz_config['ser'] + baud_rate = self.mdz_config.get('port', 9600) + + if not self.is_port_open(port_name): + try: + if self.open_port(port_name, 'mdz', baud_rate): + logging.info(f"自动打开米电阻串口 {port_name} 成功") + else: + logging.error(f"自动打开米电阻串口 {port_name} 失败") + success = False + except Exception as e: + logging.error(f"自动打开米电阻串口 {port_name} 时发生异常: {e}") + success = False + else: + logging.info(f"米电阻串口 {port_name} 已经打开,无需重新打开") + else: + logging.warning("米电阻串口未配置或设置为不使用,跳过自动打开") + + # 尝试打开扫码器串口 + if self.scanner_config and 'ser' in self.scanner_config and self.scanner_config['ser'] and self.scanner_config['ser'].strip(): + port_name = self.scanner_config['ser'] + baud_rate = self.scanner_config.get('port', 9600) + + if not self.is_port_open(port_name): + try: + if self.open_port(port_name, 'scanner', baud_rate): + logging.info(f"自动打开扫码器串口 {port_name} 成功") + else: + logging.error(f"自动打开扫码器串口 {port_name} 失败") + success = False + except Exception as e: + logging.error(f"自动打开扫码器串口 {port_name} 时发生异常: {e}") + success = False + else: + logging.info(f"扫码器串口 {port_name} 已经打开,无需重新打开") + else: + logging.warning("扫码器串口未配置或设置为不使用,跳过自动打开") + + # 注意:不在这里启动键盘监听器,而是在MainWindow的handle_start方法中显式调用start_keyboard_listener + + if not success: + logging.warning("部分串口自动打开失败,请检查设备连接或在参数配置中手动打开") + + return True # 总是返回True,防止应用程序因串口问题而终止 + + def suspend_file_operations(self, suspend: bool): + """暂停或恢复文件操作 + + Args: + suspend: True表示暂停,False表示恢复 + """ + with self._file_operations_lock: + old_state = self._file_operations_suspended + self._file_operations_suspended = suspend + + if old_state != suspend: + if suspend: + logging.info("已暂停SerialManager文件操作") + else: + logging.info("已恢复SerialManager文件操作") + + def _read_diameter_thread(self, port_name: str): + """ + 线径串口读取线程 + + Args: + port_name: 串口名称 + """ + try: + logging.info(f"[{port_name}] 线径线程启动") + logging.info(f"线径自动查询已开启,将持续发送查询指令获取数据") + + while self.running_flags.get(port_name, False): + if not self.is_port_open(port_name): + time.sleep(0.1) + continue + + try: + # 从配置获取查询命令,如果没有则使用默认命令 + query_cmd = self.xj_config.get('query_cmd', '01 41 0d') + # 发送查询指令 + byte_data = bytes.fromhex(query_cmd.replace(' ', '')) + self.serial_ports[port_name].write(byte_data) + + # 等待响应 + time.sleep(0.5) + + if self.serial_ports[port_name].in_waiting > 0: + response = self.serial_ports[port_name].read(self.serial_ports[port_name].in_waiting) + self._process_diameter_response(port_name, response) + + except Exception as e: + logging.error(f"线径数据处理异常: {e}") + + # 查询间隔,从配置中获取或使用默认值 + query_interval = self.xj_config.get('query_interval', 5) if self.xj_config else 5 + wait_cycles = int(query_interval * 10) # 转换为0.1秒的周期数 + + # 每隔query_interval秒查询一次 + for i in range(wait_cycles): + if not self.running_flags.get(port_name, False): + break + time.sleep(0.1) + + except Exception as e: + logging.error(f"线径串口 {port_name} 读取线程异常: {e}") + # 线程异常时,尝试重置状态 + self.running_flags[port_name] = False + + def _process_diameter_response(self, port_name, response_bytes: bytes): + """处理线径响应数据""" + try: + if response_bytes: # 确保有响应数据 + try: + # 转换为字符串用于日志记录 + response_str = str(response_bytes) + logging.warning(f"[{port_name}] 线径数据: {response_str}") + + # 使用正则表达式直接提取数字 + # 查找格式为20.15这样的浮点数 + match = re.search(r'(\d+\.?\d*)', response_str) + if match: + number_str = match.group(1) + try: + # 转换为浮点数 + xj_value = float(number_str) + logging.info(f"线径数据: {xj_value}") + + # 更新数据 + self.data['xj'] = xj_value + self._write_data_to_file() + + # 构建MainWindow.on_diameter_data_received期望的格式 + callback_data_str = f"线径数据: {xj_value}" + if 'xj_data' in self.callbacks: + try: + # 与米电阻类似,传递实际的串口名称 + logging.info(f"线径回调开始调用,回调对象: {self.callbacks['xj_data'].__self__.__class__.__name__}, 数据: {xj_value}") + self.callbacks['xj_data'](port_name, callback_data_str.encode('utf-8')) + logging.info(f"通知 'xj_data' 回调成功. 值: {xj_value}, 串口: {port_name}") + except Exception as e: + logging.error(f"调用 'xj_data' 回调失败: {e}") + else: + # 如果未注册回调,仍然使用通用方法通知 + logging.warning(f"未找到xj_data回调,使用通用_notify_callbacks方法") + self._notify_callbacks('xj_data', {"type": "xj", "value": self.data['xj'], "source": f"serial ({port_name})"}) + + return True + except ValueError: + logging.warning(f"线径数据字符串 '{number_str}' 无法转换为浮点数") + else: + logging.warning(f"线径数据中未找到有效的浮点数") + except Exception as e: + logging.error(f"处理线径数据异常: {e}") + else: + logging.warning("线径响应数据为空") + + # 如果无法解析,则直接返回失败 + return False + except Exception as e: + logging.error(f"处理线径数据总体异常: {e}") + return False + + def _read_scanner_thread(self, port_name: str): + """ + 扫码器串口读取线程 + + Args: + port_name: 串口名称 + """ + try: + logging.info(f"[{port_name}] 扫码器线程启动") + while self.running_flags.get(port_name, False): + if not self.is_port_open(port_name): + time.sleep(0.1) + continue + + # 检查是否有数据可读 + if self.serial_ports[port_name].in_waiting > 0: + response = self.serial_ports[port_name].read(self.serial_ports[port_name].in_waiting) + self._process_scanner_response(port_name, response) + + time.sleep(0.1) + + except Exception as e: + logging.error(f"扫码器串口 {port_name} 读取线程异常: {e}") + # 线程异常时,尝试重置状态 + self.running_flags[port_name] = False + + def _process_scanner_response(self, port_name, response_bytes: bytes): + """处理扫码器响应数据""" + try: + if response_bytes: # 确保有响应数据 + try: + # 尝试解码为字符串 + scanner_value = response_bytes.decode('utf-8').strip() + + # 记录日志 + logging.info(f"[{port_name}] 扫码数据: {scanner_value}") + + # 更新数据 + self.data['scanner'] = scanner_value + + # 写入文件并通知回调 + self._write_data_to_file() + # 使用"扫码数据: xxx"格式通知回调 + callback_data = f"扫码数据: {scanner_value}".encode('utf-8') + if 'scanner_data' in self.callbacks: + self.callbacks['scanner_data'](port_name, callback_data) + return True + except Exception as e: + logging.error(f"处理扫码数据异常: {e}") + # 解码失败,尝试直接使用字节数据 + # 记录日志(十六进制字符串) + hex_str = ' '.join(f'{b:02X}' for b in response_bytes) + logging.warning(f"[{port_name}] 扫码数据(十六进制): {hex_str}") + + # 更新数据(使用十六进制字符串) + self.data['scanner'] = hex_str + + # 写入文件并通知回调 + self._write_data_to_file() + # 使用"扫码数据: xxx"格式通知回调 + callback_data = f"扫码数据: {hex_str}".encode('utf-8') + if 'scanner_data' in self.callbacks: + self.callbacks['scanner_data'](port_name, callback_data) + return True + else: + logging.warning("扫码响应数据为空") + + # 如果无法解析,则直接返回失败 + return False + except Exception as e: + logging.error(f"处理扫码数据总体异常: {e}") + return False + + def _start_thread_monitor(self): + """启动线程监控""" + threading.Thread(target=self._monitor_threads, daemon=True).start() + + def _monitor_threads(self): + """监控线程状态""" + try: + logging.info("线程监控已启动") + while True: + try: + # 创建当前线程的副本,避免在迭代过程中修改字典 + thread_items = list(self.read_threads.items()) + for port_name, thread in thread_items: + # 检查线程是否存活 + if not thread.is_alive(): + # 检查串口是否仍然打开 + if port_name in self.serial_ports and self.is_port_open(port_name): + port_type = self.port_types.get(port_name) + callback = self.callbacks.get(port_name) + logging.warning(f"线程 {thread.name} 已终止但串口仍然打开,尝试重新启动线程") + + # 重置线程状态 + self.running_flags[port_name] = False + if port_name in self.read_threads: + self.read_threads.pop(port_name, None) + + # 如果有端口类型记录,尝试重新启动线程 + if port_type: + # 短暂等待,确保资源释放 + time.sleep(0.5) + try: + # 重新打开串口 + self.open_port(port_name, port_type, callback=callback) + logging.info(f"已重新启动线程: {port_name} ({port_type})") + except Exception as restart_error: + logging.error(f"重新启动线程失败: {port_name}, 错误: {restart_error}") + else: + logging.warning(f"无法重启线程: {port_name}, 未找到端口类型记录") + else: + # 串口已关闭,清理线程记录 + if port_name in self.read_threads: + self.read_threads.pop(port_name, None) + logging.info(f"线程 {thread.name} 已终止,串口已关闭,清理线程记录") + + # 每隔5秒检查一次 + time.sleep(5) + except Exception as loop_error: + logging.error(f"线程监控循环异常: {loop_error}") + time.sleep(5) # 出错后等待一段时间再继续 + except Exception as e: + logging.error(f"线程监控主循环异常: {e}") + # 尝试重新启动监控 + time.sleep(10) + self._start_thread_monitor() \ No newline at end of file diff --git a/tests/serial_settings_widget.py b/tests/serial_settings_widget.py new file mode 100644 index 0000000..0ba1fd5 --- /dev/null +++ b/tests/serial_settings_widget.py @@ -0,0 +1,483 @@ +import os +import json +import logging +import serial.tools.list_ports +import time +from PySide6.QtWidgets import QMessageBox +from ui.serial_settings_ui import SerialSettingsUI +from utils.config_loader import ConfigLoader +from utils.serial_manager import SerialManager +from PySide6.QtWidgets import QApplication + +class SerialSettingsWidget(SerialSettingsUI): + """串口设置组件""" + + def __init__(self, parent=None): + super().__init__(parent) + self.config = ConfigLoader.get_instance() + self.serial_manager = SerialManager() + + # 连接信号 + self.init_connections() + + # 加载设置 + self.load_settings() + + def init_connections(self): + """初始化信号连接""" + # 米电阻串口 + self.mdz_refresh_btn.clicked.connect(self.refresh_ports) + self.test_mdz_btn.clicked.connect(self.test_mdz_port) + + # 线径串口 + self.xj_refresh_btn.clicked.connect(self.refresh_ports) + self.test_xj_btn.clicked.connect(self.test_xj_port) + + # 扫码器串口 + self.scanner_refresh_btn.clicked.connect(self.refresh_ports) + self.test_scanner_btn.clicked.connect(self.test_scanner_port) + + # 保存按钮 + self.save_btn.clicked.connect(self.save_settings) + + # 初始化时刷新串口列表 + self.refresh_ports() + + # 加载设置 + self.load_settings() + + def refresh_ports(self): + """刷新串口列表""" + try: + # 保存当前选择 + current_mdz_port = self.mdz_port_combo.currentData() + current_xj_port = self.xj_port_combo.currentData() + current_scanner_port = self.scanner_port_combo.currentData() + + # 清空列表 + self.mdz_port_combo.clear() + self.xj_port_combo.clear() + self.scanner_port_combo.clear() + + # 添加"不使用"选项 + self.mdz_port_combo.addItem("不使用", "") + self.xj_port_combo.addItem("不使用", "") + self.scanner_port_combo.addItem("不使用", "") + + # 获取可用串口 + ports = list(serial.tools.list_ports.comports()) + + for port in ports: + port_name = port.device + port_desc = f"{port_name} ({port.description})" + + # 添加到米电阻下拉框 + self.mdz_port_combo.addItem(port_desc, port_name) + + # 添加到线径下拉框 + self.xj_port_combo.addItem(port_desc, port_name) + + # 添加到扫码器下拉框 + self.scanner_port_combo.addItem(port_desc, port_name) + + # 恢复之前的选择 + if current_mdz_port: + index = self.mdz_port_combo.findData(current_mdz_port) + if index >= 0: + self.mdz_port_combo.setCurrentIndex(index) + else: + # 如果之前没有选择,则设为"不使用" + self.mdz_port_combo.setCurrentIndex(0) + + if current_xj_port: + index = self.xj_port_combo.findData(current_xj_port) + if index >= 0: + self.xj_port_combo.setCurrentIndex(index) + else: + # 如果之前没有选择,则设为"不使用" + self.xj_port_combo.setCurrentIndex(0) + + if current_scanner_port: + index = self.scanner_port_combo.findData(current_scanner_port) + if index >= 0: + self.scanner_port_combo.setCurrentIndex(index) + else: + # 如果之前没有选择,则设为"不使用" + self.scanner_port_combo.setCurrentIndex(0) + + logging.info(f"已刷新串口列表,找到 {len(ports)} 个串口") + except Exception as e: + logging.error(f"刷新串口列表失败: {e}") + QMessageBox.warning(self, "刷新失败", f"刷新串口列表失败: {e}") + + def load_settings(self): + """加载设置""" + try: + # 加载全局设置 + enable_serial = self.config.get_value('app.features.enable_serial_ports', False) + enable_keyboard = self.config.get_value('app.features.enable_keyboard_listener', False) + + self.enable_serial_checkbox.setChecked(enable_serial) + self.enable_keyboard_checkbox.setChecked(enable_keyboard) + + # 加载米电阻设置 + mdz_config = self.config.get_config('mdz') + if mdz_config: + # 设置串口 + mdz_port = mdz_config.get('ser', '') + index = self.mdz_port_combo.findData(mdz_port) + if index >= 0: + self.mdz_port_combo.setCurrentIndex(index) + + # 设置波特率 + mdz_baud = str(mdz_config.get('port', '9600')) + index = self.mdz_baud_combo.findText(mdz_baud) + if index >= 0: + self.mdz_baud_combo.setCurrentIndex(index) + + # 设置数据位 + mdz_data_bits = str(mdz_config.get('data_bits', '8')) + index = self.mdz_data_bits_combo.findText(mdz_data_bits) + if index >= 0: + self.mdz_data_bits_combo.setCurrentIndex(index) + + # 设置停止位 + mdz_stop_bits = str(mdz_config.get('stop_bits', '1')) + index = self.mdz_stop_bits_combo.findText(mdz_stop_bits) + if index >= 0: + self.mdz_stop_bits_combo.setCurrentIndex(index) + + # 设置校验位 + mdz_parity = mdz_config.get('parity', 'N') + index = self.mdz_parity_combo.findData(mdz_parity) + if index >= 0: + self.mdz_parity_combo.setCurrentIndex(index) + + # 设置查询指令 + mdz_query_cmd = mdz_config.get('query_cmd', '01 03 00 01 00 07 55 C8') + self.mdz_query_cmd.setText(mdz_query_cmd) + + # 设置查询间隔 + mdz_query_interval = mdz_config.get('query_interval', 5) + self.mdz_query_interval.setValue(mdz_query_interval) + + # 加载线径设置 + xj_config = self.config.get_config('xj') + if xj_config: + # 设置串口 + xj_port = xj_config.get('ser', '') + index = self.xj_port_combo.findData(xj_port) + if index >= 0: + self.xj_port_combo.setCurrentIndex(index) + + # 设置波特率 + xj_baud = str(xj_config.get('port', '9600')) + index = self.xj_baud_combo.findText(xj_baud) + if index >= 0: + self.xj_baud_combo.setCurrentIndex(index) + + # 设置数据位 + xj_data_bits = str(xj_config.get('data_bits', '8')) + index = self.xj_data_bits_combo.findText(xj_data_bits) + if index >= 0: + self.xj_data_bits_combo.setCurrentIndex(index) + + # 设置停止位 + xj_stop_bits = str(xj_config.get('stop_bits', '1')) + index = self.xj_stop_bits_combo.findText(xj_stop_bits) + if index >= 0: + self.xj_stop_bits_combo.setCurrentIndex(index) + + # 设置校验位 + xj_parity = xj_config.get('parity', 'N') + index = self.xj_parity_combo.findData(xj_parity) + if index >= 0: + self.xj_parity_combo.setCurrentIndex(index) + + # 加载扫码器设置 + scanner_config = self.config.get_config('scanner') + if scanner_config: + # 设置串口 + scanner_port = scanner_config.get('ser', '') + index = self.scanner_port_combo.findData(scanner_port) + if index >= 0: + self.scanner_port_combo.setCurrentIndex(index) + + # 设置波特率 + scanner_baud = str(scanner_config.get('port', '9600')) + index = self.scanner_baud_combo.findText(scanner_baud) + if index >= 0: + self.scanner_baud_combo.setCurrentIndex(index) + + # 设置数据位 + scanner_data_bits = str(scanner_config.get('data_bits', '8')) + index = self.scanner_data_bits_combo.findText(scanner_data_bits) + if index >= 0: + self.scanner_data_bits_combo.setCurrentIndex(index) + + # 设置停止位 + scanner_stop_bits = str(scanner_config.get('stop_bits', '1')) + index = self.scanner_stop_bits_combo.findText(scanner_stop_bits) + if index >= 0: + self.scanner_stop_bits_combo.setCurrentIndex(index) + + # 设置校验位 + scanner_parity = scanner_config.get('parity', 'N') + index = self.scanner_parity_combo.findData(scanner_parity) + if index >= 0: + self.scanner_parity_combo.setCurrentIndex(index) + + logging.info("已加载串口设置") + except Exception as e: + logging.error(f"加载串口设置失败: {e}") + QMessageBox.warning(self, "加载失败", f"加载串口设置失败: {e}") + + def save_settings(self): + """保存设置""" + try: + # 保存全局设置 + enable_serial = self.enable_serial_checkbox.isChecked() + enable_keyboard = self.enable_keyboard_checkbox.isChecked() + + self.config.set_value('app.features.enable_serial_ports', enable_serial) + self.config.set_value('app.features.enable_keyboard_listener', enable_keyboard) + + # 保存米电阻设置 + mdz_port = self.mdz_port_combo.currentData() + mdz_baud = int(self.mdz_baud_combo.currentText()) + mdz_data_bits = int(self.mdz_data_bits_combo.currentText()) + mdz_stop_bits = float(self.mdz_stop_bits_combo.currentText()) + mdz_parity = self.mdz_parity_combo.currentData() + mdz_query_cmd = self.mdz_query_cmd.text().strip() + mdz_query_interval = self.mdz_query_interval.value() + + mdz_config = { + 'port': mdz_baud, + 'data_bits': mdz_data_bits, + 'stop_bits': mdz_stop_bits, + 'parity': mdz_parity, + 'query_cmd': mdz_query_cmd, + 'query_interval': mdz_query_interval + } + + # 只有当用户选择了串口时才保存串口配置 + if mdz_port: + mdz_config['ser'] = mdz_port + + self.config.set_config('mdz', mdz_config) + + # 保存线径设置 + xj_port = self.xj_port_combo.currentData() + xj_baud = int(self.xj_baud_combo.currentText()) + xj_data_bits = int(self.xj_data_bits_combo.currentText()) + xj_stop_bits = float(self.xj_stop_bits_combo.currentText()) + xj_parity = self.xj_parity_combo.currentData() + + xj_config = { + 'port': xj_baud, + 'data_bits': xj_data_bits, + 'stop_bits': xj_stop_bits, + 'parity': xj_parity + } + + # 只有当用户选择了串口时才保存串口配置 + if xj_port: + xj_config['ser'] = xj_port + + self.config.set_config('xj', xj_config) + + # 保存扫码器设置 + scanner_port = self.scanner_port_combo.currentData() + scanner_baud = int(self.scanner_baud_combo.currentText()) + scanner_data_bits = int(self.scanner_data_bits_combo.currentText()) + scanner_stop_bits = float(self.scanner_stop_bits_combo.currentText()) + scanner_parity = self.scanner_parity_combo.currentData() + + scanner_config = { + 'port': scanner_baud, + 'data_bits': scanner_data_bits, + 'stop_bits': scanner_stop_bits, + 'parity': scanner_parity + } + + # 只有当用户选择了串口时才保存串口配置 + if scanner_port: + scanner_config['ser'] = scanner_port + + self.config.set_config('scanner', scanner_config) + + # 发送设置变更信号 + self.settings_changed.emit() + + QMessageBox.information(self, "保存成功", "串口设置已保存") + + except Exception as e: + logging.error(f"保存串口设置失败: {e}") + QMessageBox.critical(self, "保存失败", f"保存串口设置失败: {e}") + + def test_mdz_port(self): + """测试米电阻串口""" + try: + port = self.mdz_port_combo.currentData() + + if not port: + QMessageBox.warning(self, "测试失败", "请先选择串口,当前设置为\"不使用\"") + return + + baud = int(self.mdz_baud_combo.currentText()) + data_bits = int(self.mdz_data_bits_combo.currentText()) + stop_bits = float(self.mdz_stop_bits_combo.currentText()) + parity = self.mdz_parity_combo.currentData() + + # 关闭可能已经打开的串口 + if self.serial_manager.is_port_open(port): + self.serial_manager.close_port(port) + + # 尝试打开串口 + success = self.serial_manager.open_port( + port, 'mdz', baud, data_bits, stop_bits, parity, 1.0 + ) + + if success: + # 尝试发送查询指令 + query_cmd = self.mdz_query_cmd.text() + if query_cmd: + try: + # 转换查询指令为字节 + cmd_bytes = bytes.fromhex(query_cmd.replace(' ', '')) + self.serial_manager.write_data(port, cmd_bytes) + time.sleep(0.1) # 等待响应 + + # 读取响应 + response = self.serial_manager.read_data(port) + if response: + # 将字节转换为十六进制字符串 + hex_str = ' '.join(f'{b:02X}' for b in response) + QMessageBox.information(self, "测试成功", f"串口打开成功,收到响应:\n{hex_str}") + else: + QMessageBox.information(self, "测试成功", "串口打开成功,但未收到响应") + except Exception as e: + QMessageBox.warning(self, "测试结果", f"串口打开成功,但发送指令失败: {e}") + else: + QMessageBox.information(self, "测试成功", "串口打开成功") + + # 关闭串口 + self.serial_manager.close_port(port) + else: + QMessageBox.critical(self, "测试失败", f"无法打开串口 {port}") + + except Exception as e: + logging.error(f"测试米电阻串口失败: {e}") + QMessageBox.critical(self, "测试失败", f"测试米电阻串口失败: {e}") + + def test_xj_port(self): + """测试线径串口""" + try: + port = self.xj_port_combo.currentData() + + if not port: + QMessageBox.warning(self, "测试失败", "请先选择串口,当前设置为\"不使用\"") + return + + baud = int(self.xj_baud_combo.currentText()) + data_bits = int(self.xj_data_bits_combo.currentText()) + stop_bits = float(self.xj_stop_bits_combo.currentText()) + parity = self.xj_parity_combo.currentData() + + # 关闭可能已经打开的串口 + if self.serial_manager.is_port_open(port): + self.serial_manager.close_port(port) + + # 尝试打开串口 + success = self.serial_manager.open_port( + port, 'xj', baud, data_bits, stop_bits, parity, 1.0 + ) + + if success: + QMessageBox.information(self, "测试成功", f"串口 {port} 打开成功") + + # 关闭串口 + self.serial_manager.close_port(port) + else: + QMessageBox.critical(self, "测试失败", f"无法打开串口 {port}") + + except Exception as e: + logging.error(f"测试线径串口失败: {e}") + QMessageBox.critical(self, "测试失败", f"测试线径串口失败: {e}") + + def test_scanner_port(self): + """测试扫码器串口""" + try: + port = self.scanner_port_combo.currentData() + + if not port: + QMessageBox.warning(self, "测试失败", "请先选择串口,当前设置为\"不使用\"") + return + + baud = int(self.scanner_baud_combo.currentText()) + data_bits = int(self.scanner_data_bits_combo.currentText()) + stop_bits = float(self.scanner_stop_bits_combo.currentText()) + parity = self.scanner_parity_combo.currentData() + + # 关闭可能已经打开的串口 + if self.serial_manager.is_port_open(port): + self.serial_manager.close_port(port) + + # 创建临时回调函数,用于测试期间接收扫码器数据 + def scanner_callback(port_name, data): + try: + # 尝试将字节解码为字符串 + try: + text = data.decode('utf-8').strip() + # 如果数据以"扫码数据: "开头,提取实际数据部分 + if text.startswith("扫码数据: "): + text = text[6:].strip() + QMessageBox.information(self, "测试成功", f"收到扫码数据:\n{text}") + except: + # 如果解码失败,显示十六进制 + hex_str = ' '.join(f'{b:02X}' for b in data) + QMessageBox.information(self, "测试成功", f"收到扫码数据 (十六进制):\n{hex_str}") + except Exception as e: + logging.error(f"处理扫码回调数据失败: {e}") + + # 保存原始回调 + original_callback = self.serial_manager.callbacks.get('scanner_data', None) + + # 设置临时回调 + self.serial_manager.callbacks['scanner_data'] = scanner_callback + + # 尝试打开串口 + success = self.serial_manager.open_port( + port, 'scanner', baud, data_bits, stop_bits, parity, 1.0 + ) + + if success: + QMessageBox.information(self, "测试成功", f"串口 {port} 打开成功\n请触发扫码器进行扫描测试") + + # 等待用户操作扫码器(最多等待10秒) + start_time = time.time() + timeout = 10.0 # 10秒超时 + + while time.time() - start_time < timeout: + # 使用QApplication处理事件,保持UI响应 + QApplication.processEvents() + time.sleep(0.1) # 短暂休眠,减少CPU占用 + + # 关闭串口 + self.serial_manager.close_port(port) + + # 恢复原始回调 + if original_callback: + self.serial_manager.callbacks['scanner_data'] = original_callback + else: + # 如果之前没有回调,则删除临时回调 + if 'scanner_data' in self.serial_manager.callbacks: + del self.serial_manager.callbacks['scanner_data'] + + QMessageBox.information(self, "测试完成", f"串口 {port} 已关闭") + else: + QMessageBox.critical(self, "测试失败", f"无法打开串口 {port}") + + except Exception as e: + logging.error(f"测试扫码器串口失败: {e}") + QMessageBox.critical(self, "测试失败", f"测试扫码器串口失败: {e}") \ No newline at end of file diff --git a/ui/serial_settings_ui.py b/ui/serial_settings_ui.py index efb6a21..b54d466 100644 --- a/ui/serial_settings_ui.py +++ b/ui/serial_settings_ui.py @@ -20,12 +20,27 @@ class SerialSettingsUI(QWidget): main_layout = QVBoxLayout(self) # 创建全局启用选项 - enable_layout = QHBoxLayout() + enable_layout = QVBoxLayout() + + # 第一行:串口功能和键盘监听 + enable_row1 = QHBoxLayout() self.enable_serial_checkbox = QCheckBox("启用串口功能") - self.enable_keyboard_checkbox = QCheckBox("启用键盘监听 (PageUp 触发米电阻查询)") - enable_layout.addWidget(self.enable_serial_checkbox) - enable_layout.addWidget(self.enable_keyboard_checkbox) - enable_layout.addStretch() + self.enable_keyboard_checkbox = QCheckBox("启用键盘监听") + enable_row1.addWidget(self.enable_serial_checkbox) + enable_row1.addWidget(self.enable_keyboard_checkbox) + enable_row1.addStretch() + + # 第二行:键盘快捷键说明 + enable_row2 = QHBoxLayout() + key_info_label = QLabel("键盘快捷键: PageUp 触发米电阻查询") + key_info_label.setStyleSheet("color: #666; font-style: italic;") + enable_row2.addWidget(key_info_label) + enable_row2.addStretch() + + # 添加到垂直布局 + enable_layout.addLayout(enable_row1) + enable_layout.addLayout(enable_row2) + main_layout.addLayout(enable_layout) # # 创建串口设置组 @@ -116,6 +131,20 @@ class SerialSettingsUI(QWidget): self.xj_parity_combo.addItem(parity[0], parity[1]) xj_layout.addRow("校验位:", self.xj_parity_combo) + # 查询指令 + self.xj_query_cmd = QLineEdit() + xj_layout.addRow("查询指令:", self.xj_query_cmd) + + # 查询间隔 + self.xj_query_interval = QSpinBox() + self.xj_query_interval.setRange(1, 60) + self.xj_query_interval.setSuffix(" 秒") + xj_layout.addRow("查询间隔:", self.xj_query_interval) + + # 自动查询 + self.xj_auto_query = QCheckBox("启用自动查询") + xj_layout.addRow("", self.xj_auto_query) + # 扫码器串口设置 scanner_group = QGroupBox("扫码器串口") scanner_layout = QFormLayout(scanner_group) diff --git a/ui/settings_ui.py b/ui/settings_ui.py index c946665..8d8084a 100644 --- a/ui/settings_ui.py +++ b/ui/settings_ui.py @@ -1,7 +1,7 @@ from PySide6.QtWidgets import ( QWidget, QLabel, QLineEdit, QPushButton, QComboBox, QGridLayout, QHBoxLayout, QVBoxLayout, QTabWidget, QFrame, QFormLayout, QGroupBox, QRadioButton, QSpacerItem, QSizePolicy, - QTableWidget, QTableWidgetItem, QHeaderView, QSlider, QCheckBox, QButtonGroup + QTableWidget, QTableWidgetItem, QHeaderView, QSlider, QCheckBox, QButtonGroup, QFileDialog ) from PySide6.QtGui import QFont, QBrush, QColor, QIntValidator from PySide6.QtCore import Qt, Signal, QSize @@ -159,6 +159,72 @@ class SettingsUI(QWidget): self.camera_params_group.setLayout(self.camera_params_layout) self.camera_layout.addWidget(self.camera_params_group) + # 本地图像模式区域 (新增) + self.local_mode_group = QGroupBox("本地图像模式") + self.local_mode_group.setFont(self.normal_font) + self.local_mode_layout = QGridLayout() + + # 启用本地模式选项 + self.local_mode_check = QCheckBox("启用本地图像模式") + self.local_mode_check.setFont(self.normal_font) + + # 文件夹路径 + self.folder_path_label = QLabel("图像文件夹:") + self.folder_path_label.setFont(self.normal_font) + self.folder_path_edit = QLineEdit() + self.folder_path_edit.setReadOnly(True) + self.folder_path_edit.setMinimumWidth(300) + self.folder_path_button = QPushButton("选择文件夹") + self.folder_path_button.setFont(self.normal_font) + + # 本地模式帧率 + self.local_framerate_label = QLabel("播放帧率:") + self.local_framerate_label.setFont(self.normal_font) + self.local_framerate_slider = QSlider(Qt.Horizontal) + self.local_framerate_slider.setMinimum(1) + self.local_framerate_slider.setMaximum(30) + self.local_framerate_slider.setValue(15) + self.local_framerate_slider.setTickPosition(QSlider.TicksBelow) + self.local_framerate_slider.setTickInterval(5) + self.local_framerate_value = QLabel("15 fps") + self.local_framerate_value.setFont(self.normal_font) + self.local_framerate_value.setMinimumWidth(80) + + # 循环播放选项 + self.loop_playback_check = QCheckBox("循环播放") + self.loop_playback_check.setFont(self.normal_font) + self.loop_playback_check.setChecked(True) + + # 播放控制按钮 + self.play_button = QPushButton("播放") + self.play_button.setFont(self.normal_font) + self.stop_button = QPushButton("停止") + self.stop_button.setFont(self.normal_font) + + # 添加到布局 + self.local_mode_layout.addWidget(self.local_mode_check, 0, 0, 1, 3) + + self.local_mode_layout.addWidget(self.folder_path_label, 1, 0) + folder_path_layout = QHBoxLayout() + folder_path_layout.addWidget(self.folder_path_edit) + folder_path_layout.addWidget(self.folder_path_button) + self.local_mode_layout.addLayout(folder_path_layout, 1, 1, 1, 2) + + self.local_mode_layout.addWidget(self.local_framerate_label, 2, 0) + self.local_mode_layout.addWidget(self.local_framerate_slider, 2, 1) + self.local_mode_layout.addWidget(self.local_framerate_value, 2, 2) + + self.local_mode_layout.addWidget(self.loop_playback_check, 3, 0) + + playback_layout = QHBoxLayout() + playback_layout.addWidget(self.play_button) + playback_layout.addWidget(self.stop_button) + playback_layout.addStretch(1) + self.local_mode_layout.addLayout(playback_layout, 4, 1, 1, 2) + + self.local_mode_group.setLayout(self.local_mode_layout) + self.camera_layout.addWidget(self.local_mode_group) + # 相机设置按钮 self.camera_buttons_layout = QHBoxLayout() @@ -192,7 +258,14 @@ class SettingsUI(QWidget): self.preview_frame.setMinimumHeight(200) self.preview_frame.setStyleSheet("background-color: black;") + # 添加预览状态标签 + self.preview_status = QLabel("就绪") + self.preview_status.setAlignment(Qt.AlignCenter) + self.preview_status.setFont(self.small_font) + self.preview_status.setStyleSheet("color: #888888;") + self.preview_layout.addWidget(self.preview_frame) + self.preview_layout.addWidget(self.preview_status) self.preview_group.setLayout(self.preview_layout) self.camera_layout.addWidget(self.preview_group) diff --git a/utils/local_image_player.py b/utils/local_image_player.py new file mode 100644 index 0000000..0aa8f3b --- /dev/null +++ b/utils/local_image_player.py @@ -0,0 +1,265 @@ +import os +import cv2 +import time +import threading +import logging +import numpy as np +from glob import glob +from datetime import datetime +from PySide6.QtCore import QObject, Signal + +class LocalImagePlayer(QObject): + """本地图像播放器,用于读取和播放本地图像序列 + + 特点: + 1. 异步加载图像,不阻塞UI线程 + 2. 可调整帧率 + 3. 可循环播放 + 4. 支持暂停和恢复 + 5. 自动排序图像按时间序列播放 + """ + + # 信号定义 + signal_frame_ready = Signal(object) # 帧准备好信号 (frame) + signal_status = Signal(str) # 状态信号 (message) + signal_progress = Signal(int, int) # 进度信号 (current, total) + signal_error = Signal(str) # 错误信号 (message) + signal_completed = Signal() # 播放完成信号 + + def __init__(self): + super().__init__() + + # 初始化状态变量 + self.folder_path = "" # 图像文件夹路径 + self.file_patterns = [".jpg", ".jpeg", ".png", ".bmp"] # 支持的图像格式 + self.framerate = 15 # 播放帧率 + self.loop = True # 是否循环播放 + self.images = [] # 图像文件列表 + self.current_index = 0 # 当前播放索引 + + # 播放控制 + self.is_playing = False # 是否正在播放 + self.is_paused = False # 是否暂停 + self.stop_event = threading.Event() # 停止事件 + self.playback_thread = None # 播放线程 + + # 加载线程 + self.loading_thread = None + self.is_loading = False + + def set_folder(self, folder_path): + """设置图像文件夹路径 + + Args: + folder_path: 图像文件夹路径 + + Returns: + bool: 是否成功设置 + """ + if not os.path.exists(folder_path) or not os.path.isdir(folder_path): + self.signal_error.emit(f"文件夹不存在: {folder_path}") + return False + + self.folder_path = folder_path + + # 异步加载图像列表 + self.load_images_async() + + return True + + def set_file_patterns(self, patterns): + """设置图像文件格式 + + Args: + patterns: 支持的图像后缀列表,如['.jpg', '.png'] + """ + if patterns and isinstance(patterns, list): + self.file_patterns = patterns + + def set_framerate(self, framerate): + """设置播放帧率 + + Args: + framerate: 帧率,范围1-60 + """ + if 1 <= framerate <= 60: + self.framerate = framerate + + def set_loop(self, loop): + """设置是否循环播放 + + Args: + loop: 是否循环播放 + """ + self.loop = loop + + def load_images_async(self): + """异步加载图像列表""" + if self.is_loading: + return + + self.is_loading = True + self.loading_thread = threading.Thread(target=self._load_images_thread) + self.loading_thread.daemon = True + self.loading_thread.start() + + def _load_images_thread(self): + """加载图像列表的线程函数""" + try: + self.signal_status.emit("正在加载图像...") + + # 获取所有支持格式的图像文件 + image_files = [] + for pattern in self.file_patterns: + # 确保pattern是以.开头的扩展名 + if not pattern.startswith('.'): + pattern = '.' + pattern + # 查找所有匹配的文件 + pattern_files = glob(os.path.join(self.folder_path, f"*{pattern}")) + image_files.extend(pattern_files) + + # 按文件名排序(通常包含时间信息) + image_files.sort() + + if not image_files: + self.signal_error.emit(f"未找到图像文件,支持的格式: {', '.join(self.file_patterns)}") + self.is_loading = False + return + + # 更新图像列表 + self.images = image_files + self.current_index = 0 + + # 发出加载完成信号 + self.signal_status.emit(f"已加载 {len(self.images)} 张图像") + self.signal_progress.emit(0, len(self.images)) + + logging.info(f"已加载 {len(self.images)} 张图像,从 {self.folder_path}") + + except Exception as e: + logging.error(f"加载图像时发生错误: {e}") + self.signal_error.emit(f"加载图像失败: {str(e)}") + + finally: + self.is_loading = False + + def start_playback(self): + """开始播放图像序列""" + if not self.images: + self.signal_error.emit("没有可播放的图像") + return False + + if self.is_playing: + return True + + # 重置停止事件 + self.stop_event.clear() + self.is_playing = True + self.is_paused = False + + # 创建播放线程 + self.playback_thread = threading.Thread(target=self._playback_thread) + self.playback_thread.daemon = True + self.playback_thread.start() + + logging.info("开始播放本地图像序列") + return True + + def pause_playback(self): + """暂停播放""" + self.is_paused = True + logging.info("暂停播放本地图像序列") + + def resume_playback(self): + """恢复播放""" + self.is_paused = False + logging.info("恢复播放本地图像序列") + + def stop_playback(self): + """停止播放""" + if not self.is_playing: + return + + # 设置停止事件 + self.stop_event.set() + + # 等待线程结束 + if self.playback_thread and self.playback_thread.is_alive(): + self.playback_thread.join(timeout=1.0) + + self.is_playing = False + self.is_paused = False + logging.info("停止播放本地图像序列") + + def _playback_thread(self): + """播放线程函数""" + try: + frame_interval = 1.0 / self.framerate + logging.warning(f"====> 播放线程开始,帧率: {self.framerate} fps,帧间隔: {frame_interval} 秒") + + while not self.stop_event.is_set(): + if self.is_paused: + time.sleep(0.1) # 暂停时降低CPU使用率 + continue + + # 记录帧开始时间 + start_time = time.time() + + # 获取当前帧 + if 0 <= self.current_index < len(self.images): + image_path = self.images[self.current_index] + frame = cv2.imread(image_path) + + if frame is not None: + # OpenCV以BGR格式读取,需要转换为RGB才适合大多数显示 + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + # 输出调试信息 + logging.warning(f"====> 读取图像: {image_path}, 尺寸: {frame_rgb.shape}") + + # 保存最后一帧,以便重新连接时使用 + self.last_frame = frame_rgb + + # 发送帧准备好信号 + self.signal_frame_ready.emit(frame_rgb) + + # 确保不使用cv2.imshow + # 禁用任何可能的cv2.imshow调用 + + # 更新进度 + self.signal_progress.emit(self.current_index + 1, len(self.images)) + else: + logging.error(f"无法读取图像文件: {image_path}") + self.signal_error.emit(f"无法读取图像: {os.path.basename(image_path)}") + + # 更新索引 + self.current_index += 1 + + # 检查是否播放结束 + if self.current_index >= len(self.images): + if self.loop: + self.current_index = 0 + logging.debug("本地图像序列播放完成,循环播放") + else: + logging.info("本地图像序列播放完成") + self.signal_completed.emit() + break + + # 计算需要等待的时间 + elapsed = time.time() - start_time + sleep_time = max(0, frame_interval - elapsed) + + # 输出调试信息 + logging.debug(f"帧处理时间: {elapsed:.4f}秒,等待时间: {sleep_time:.4f}秒") + + # 等待直到下一帧时间或停止事件被设置 + if sleep_time > 0: + self.stop_event.wait(sleep_time) + + except Exception as e: + logging.error(f"播放图像序列时发生错误: {e}") + self.signal_error.emit(f"播放错误: {str(e)}") + + finally: + self.is_playing = False + logging.warning("====> 播放线程结束") \ No newline at end of file diff --git a/utils/serial_manager.py b/utils/serial_manager.py index 70f81bf..d5289cc 100644 --- a/utils/serial_manager.py +++ b/utils/serial_manager.py @@ -62,6 +62,9 @@ class SerialManager: # 是否自动查询米电阻数据,默认为False,只通过PageUp键触发 self.auto_query_mdz = False + # 是否自动查询线径数据,默认为True,开启自动查询 + self.auto_query_xj = True + logging.info("初始化 SerialManager") # 加载配置 @@ -73,11 +76,13 @@ class SerialManager: try: # 初始化键盘监听器 self.keyboard_listener = KeyboardListener() - # 从配置中获取触发键 + + # 从配置中获取米电阻触发键 trigger_key = self.config.get_value('serial.keyboard.trigger_key', 'Key.page_up') - # 注册触发键回调 + # 注册米电阻触发键回调 self.keyboard_listener.register_callback(trigger_key, self.trigger_resistance_query) logging.info(f"已注册{trigger_key}按键回调用于触发米电阻查询") + # 注意:不在这里启动键盘监听器,而是在点击"开始"按钮时启动 except Exception as e: logging.error(f"初始化键盘监听器失败: {e}") @@ -122,10 +127,14 @@ class SerialManager: self.stable_threshold = self.cz_config.get('stable_threshold', 10) if self.cz_config else 10 # 检查是否自动查询米电阻数据 - self.auto_query_mdz = self.config.get_value('serial.keyboard.auto_query', False) + self.auto_query_mdz = self.config.get_value('serial.mdz.auto_query', False) + + # 检查是否自动查询线径数据 + self.auto_query_xj = self.config.get_value('serial.xj.auto_query', True) # 默认为True,确保线径自动查询开启 logging.info(f"已加载串口配置:mdz={self.mdz_config}, xj={self.xj_config}, cz={self.cz_config}, scanner={self.scanner_config}, data_file={self.data_file}") logging.info(f"米电阻自动查询: {'开启' if self.auto_query_mdz else '关闭'}") + logging.info(f"线径自动查询: {'开启' if self.auto_query_xj else '关闭'}") except Exception as e: logging.error(f"加载配置出错: {e}") # 设置默认值 @@ -136,6 +145,7 @@ class SerialManager: self.scanner_config = {'port': 9600, 'ser': 'COM4'} self.stable_threshold = 10 self.auto_query_mdz = False + self.auto_query_xj = True # 默认为True,确保线径自动查询开启 logging.info(f"使用默认配置,数据文件: {self.data_file}") def _detect_macos_ports(self): @@ -717,26 +727,22 @@ class SerialManager: def start_keyboard_listener(self): """启动键盘监听""" try: - # 检查是否启用键盘监听功能 - enable_keyboard_listener = self.config.get_value('app.features.enable_keyboard_listener', False) - if not enable_keyboard_listener: - logging.info("键盘监听功能已在配置中禁用,跳过启动键盘监听") - return False - # 检查键盘监听器是否已初始化 if self.keyboard_listener is None: logging.warning("键盘监听器未初始化,无法启动") return False - # 从配置中获取触发键 - config = ConfigLoader.get_instance() - trigger_key = config.get_value('serial.keyboard.trigger_key', 'Key.page_up') + # 从配置中获取米电阻触发键 + trigger_key = self.config.get_value('serial.keyboard.trigger_key', 'Key.page_up') + # 不再获取线径触发键 - # 确保已注册触发键回调 + # 确保已注册米电阻触发键回调 if trigger_key not in self.keyboard_listener.callbacks: self.keyboard_listener.register_callback(trigger_key, self.trigger_resistance_query) logging.info(f"已注册{trigger_key}按键回调用于触发米电阻数据查询") + # 移除线径触发键回调注册代码 + # 启动键盘监听 result = self.keyboard_listener.start() if result: @@ -790,123 +796,56 @@ class SerialManager: if key in self.callbacks: logging.warning(f"覆盖已存在的回调函数: {key}") self.callbacks[key] = callback - logging.info(f"已注册回调函数: {key}") + logging.info(f"已注册回调函数: {key}, 回调对象类型: {callback.__self__.__class__.__name__}") except Exception as e: logging.error(f"注册回调失败: {e}") def trigger_resistance_query(self): - """触发米电阻数据查询,如果串口未打开,则尝试临时打开并查询""" - # 直接打印到控制台,确保可见 - print("\n[米电阻查询] PageUp键被按下,正在触发米电阻数据查询...\n") - - # 检查是否启用串口功能 - enable_serial_ports = self.config.get_value('app.features.enable_serial_ports', False) - if not enable_serial_ports: - logging.info("串口功能已在配置中禁用,跳过米电阻数据查询") - print("\n[米电阻查询] 串口功能已禁用,无法查询\n") - return - - # 检查是否启用键盘监听功能 - 如果这个按键是通过键盘触发的,应该尊重键盘监听器配置 - enable_keyboard_listener = self.config.get_value('app.features.enable_keyboard_listener', False) - if not enable_keyboard_listener: - logging.info("键盘监听功能已在配置中禁用,但收到了Page Up触发,检查是否为其他来源的调用") - print("\n[米电阻查询] 键盘监听功能已禁用,但收到了触发\n") - # 这里我们仍然继续执行,因为该方法可能由其他非键盘源调用 - - # 从配置中获取触发键 - config = ConfigLoader.get_instance() - trigger_key = config.get_value('serial.keyboard.trigger_key', 'Key.page_up') - logging.info(f"[SerialManager] {trigger_key}键按下,正在触发米电阻数据查询...") - print(f"\n[米电阻查询] {trigger_key}键按下,正在处理...\n") - - if not self.mdz_config: - logging.error("[SerialManager] 米电阻配置 (mdz_config) 未加载,无法查询。") - print("\n[米电阻查询] 配置未加载,无法查询\n") - return - - mdz_port_name = self.mdz_config.get('ser') - query_cmd_hex = self.mdz_config.get('query_cmd', '01030001000755C8') - baud_rate = self.mdz_config.get('port', 9600) - timeout = self.mdz_config.get('timeout', 1.0) - data_bits = self.mdz_config.get('data_bits', 8) - stop_bits = self.mdz_config.get('stop_bits', 1) - parity_char = self.mdz_config.get('parity', 'N') - parity = parity_char # Use the character directly ('N', 'E', or 'O') - logging.info(f"[SerialManager] 使用校验位设置: {parity}") - print(f"\n[米电阻查询] 串口配置: {mdz_port_name}, {baud_rate}, {data_bits}, {stop_bits}, {parity}\n") - - temp_ser = None + """触发米电阻查询""" try: - byte_data = bytes.fromhex(query_cmd_hex.replace(' ', '')) - logging.info(f"[SerialManager] 准备发送米电阻查询指令: {byte_data.hex(' ').upper()} 到端口 {mdz_port_name}") - print(f"\n[米电阻查询] 准备发送查询指令: {byte_data.hex(' ').upper()}\n") + # 检查米电阻配置是否存在 + if not self.mdz_config: + logging.warning("未找到米电阻配置,无法触发查询") + return False + + # 检查米电阻串口是否已打开 + ser_name = self.mdz_config.get('ser') + if not ser_name or not self.is_port_open(ser_name): + logging.warning(f"米电阻串口 {ser_name} 未打开,无法触发查询") + return False + + # 从配置获取查询命令,如果没有则使用默认命令 + query_cmd = self.mdz_config.get('query_cmd', '01 03 00 01 00 07 55 C8') - # 检查 SerialManager 是否已管理此端口且已打开 - if self.is_port_open(mdz_port_name): - logging.info(f"[SerialManager] 米电阻串口 {mdz_port_name} 已由 SerialManager 管理并打开,直接发送指令。") - print(f"\n[米电阻查询] 串口 {mdz_port_name} 已打开,直接发送指令\n") - if self.write_data(mdz_port_name, byte_data): - logging.info(f"[SerialManager] 指令已发送到 {mdz_port_name} (通过已打开的串口)。响应将由读取线程处理。") - print("\n[米电阻查询] 指令发送成功,等待响应\n") - # 当串口已打开时,指令发送后,响应会由 _read_resistance_thread 捕获并处理。 - # _read_resistance_thread 内部的 _process_mdz_response 会负责更新 self.data, - # 调用 _write_data_to_file 和 _notify_callbacks。 - # 因此,这里不需要再显式地 sleep 后调用 _write_data_to_file 和 _notify_callbacks, - # 以避免数据竞争或重复通知。 - return # 指令已发送,等待线程处理 - else: - logging.warning(f"[SerialManager] 向已打开的串口 {mdz_port_name} 发送指令失败。将尝试临时打开。") - print("\n[米电阻查询] 指令发送失败,尝试临时打开串口\n") - - # 如果串口未被 SerialManager 管理或发送失败,则尝试临时打开 - logging.info(f"[SerialManager] 米电阻串口 {mdz_port_name} 未打开或发送失败。尝试临时打开并查询...") - print(f"\n[米电阻查询] 串口 {mdz_port_name} 未打开,尝试临时打开\n") - temp_ser = serial.Serial( - port=mdz_port_name, - baudrate=baud_rate, - bytesize=data_bits, - stopbits=stop_bits, - parity=parity, - timeout=timeout - ) - if not temp_ser.is_open: - temp_ser.open() - - temp_ser.write(byte_data) - logging.info(f"[SerialManager] 指令已通过临时串口发送到 {mdz_port_name}。等待响应...") - print("\n[米电阻查询] 指令已通过临时串口发送,等待响应\n") - time.sleep(0.1) # 等待设备响应 - response_bytes = b'' - if temp_ser.in_waiting > 0: - response_bytes = temp_ser.read(temp_ser.in_waiting) - - if response_bytes: - logging.info(f"[SerialManager] 收到来自 {mdz_port_name} (临时串口) 的响应: {response_bytes.hex(' ').upper()}") - print(f"\n[米电阻查询] 收到响应: {response_bytes.hex(' ').upper()}\n") - - # 将响应交给标准的处理函数 - parse_success = self._process_mdz_response(mdz_port_name, response_bytes) - if not parse_success: - logging.warning(f"[SerialManager] _process_mdz_response未能成功处理来自临时串口{mdz_port_name}的响应。将依赖其内部的mock/old data逻辑。") - print("\n[米电阻查询] 响应解析失败\n") - # _process_mdz_response 内部在失败时会处理 mock/old data 及文件写入和通知,这里无需额外操作。 + # 转换为字节数据 + try: + byte_data = bytes.fromhex(query_cmd.replace(' ', '')) + except ValueError as e: + logging.error(f"米电阻查询命令格式错误: {query_cmd}, 错误: {e}") + return False + + # 发送查询命令 + result = self.write_data(ser_name, byte_data) + if result: + logging.info(f"已向米电阻串口 {ser_name} 发送查询命令: {query_cmd}") + + # 特殊情况:如果触发了查询,但串口没有响应,尝试模拟一个合理的响应 + # 这里我们先等待一段时间,让设备有机会响应 + time.sleep(0.5) + + # 如果没有数据可读,或者读取的数据不包含有效的米电阻值 + if self.serial_ports[ser_name].in_waiting == 0: + logging.warning("米电阻串口未返回数据,检查设备连接") + + # 在这里我们不再自动提供模拟数据,而是由用户决定是否重试 + return True else: - logging.warning(f"[SerialManager] 未收到来自 {mdz_port_name} (临时串口) 的响应。") - print("\n[米电阻查询] 未收到响应\n") - except serial.SerialException as se: - logging.error(f"[SerialManager] 临时打开或操作串口 {mdz_port_name} 失败: {se}") - print(f"\n[米电阻查询] 串口操作失败: {se}\n") - except ValueError as ve: - logging.error(f"[SerialManager] 指令转换错误或响应解析错误 (临时查询): {ve}") - print(f"\n[米电阻查询] 指令转换或响应解析错误: {ve}\n") + logging.error(f"向米电阻串口 {ser_name} 发送查询命令失败") + return False + except Exception as e: - logging.error(f"[SerialManager] 触发米电阻查询时发生未知错误 (临时查询): {e}", exc_info=True) - print(f"\n[米电阻查询] 未知错误: {e}\n") - finally: - if temp_ser and temp_ser.is_open: - temp_ser.close() - logging.info("[SerialManager] 米电阻数据查询流程结束。") - print("\n[米电阻查询] 查询流程结束\n") + logging.error(f"触发米电阻查询时出错: {e}") + return False def _notify_callbacks(self, port_name, value): """通知所有相关回调函数""" @@ -1045,20 +984,41 @@ class SerialManager: if os_type == "Darwin" and ( (self.mdz_config and 'ser' in self.mdz_config and self.mdz_config['ser'] and self.mdz_config['ser'].startswith('COM')) or (self.cz_config and 'ser' in self.cz_config and self.cz_config['ser'] and self.cz_config['ser'].startswith('COM')) or + (self.xj_config and 'ser' in self.xj_config and self.xj_config['ser'] and self.xj_config['ser'].startswith('COM')) or (self.scanner_config and 'ser' in self.scanner_config and self.scanner_config['ser'] and self.scanner_config['ser'].startswith('COM')) ): logging.warning("检测到在macOS系统上配置了Windows格式的COM端口,这些端口将无法正常打开") logging.warning("macOS上的串口通常是/dev/tty.*或/dev/cu.*格式") # 继续尝试打开,但不影响程序流程 - # 尝试打开线径串口 + # 尝试打开称重串口 if self.cz_config and 'ser' in self.cz_config and self.cz_config['ser'] and self.cz_config['ser'].strip(): port_name = self.cz_config['ser'] - baud_rate = self.cz_config.get('port', 2400) + baud_rate = self.cz_config.get('port', 9600) if not self.is_port_open(port_name): try: if self.open_port(port_name, 'cz', baud_rate): + logging.info(f"自动打开称重串口 {port_name} 成功") + else: + logging.error(f"自动打开称重串口 {port_name} 失败") + success = False + except Exception as e: + logging.error(f"自动打开称重串口 {port_name} 时发生异常: {e}") + success = False + else: + logging.info(f"称重串口 {port_name} 已经打开,无需重新打开") + else: + logging.warning("称重串口未配置或设置为不使用,跳过自动打开") + + # 尝试打开线径串口 + if self.xj_config and 'ser' in self.xj_config and self.xj_config['ser'] and self.xj_config['ser'].strip(): + port_name = self.xj_config['ser'] + baud_rate = self.xj_config.get('port', 19200) + + if not self.is_port_open(port_name): + try: + if self.open_port(port_name, 'xj', baud_rate): logging.info(f"自动打开线径串口 {port_name} 成功") else: logging.error(f"自动打开线径串口 {port_name} 失败") @@ -1143,16 +1103,38 @@ class SerialManager: """ try: logging.info(f"[{port_name}] 线径线程启动") + logging.info(f"线径自动查询已开启,将持续发送查询指令获取数据") + while self.running_flags.get(port_name, False): if not self.is_port_open(port_name): time.sleep(0.1) continue - # 检查是否有数据可读 - if self.serial_ports[port_name].in_waiting > 0: - response = self.serial_ports[port_name].read(self.serial_ports[port_name].in_waiting) - self._process_diameter_response(port_name, response) + try: + # 从配置获取查询命令,如果没有则使用默认命令 + query_cmd = self.xj_config.get('query_cmd', '01 41 0d') + # 发送查询指令 + byte_data = bytes.fromhex(query_cmd.replace(' ', '')) + self.serial_ports[port_name].write(byte_data) + + # 等待响应 + time.sleep(0.5) + + if self.serial_ports[port_name].in_waiting > 0: + response = self.serial_ports[port_name].read(self.serial_ports[port_name].in_waiting) + self._process_diameter_response(port_name, response) + except Exception as e: + logging.error(f"线径数据处理异常: {e}") + + # 查询间隔,从配置中获取或使用默认值 + query_interval = self.xj_config.get('query_interval', 5) if self.xj_config else 5 + wait_cycles = int(query_interval * 10) # 转换为0.1秒的周期数 + + # 每隔query_interval秒查询一次 + for i in range(wait_cycles): + if not self.running_flags.get(port_name, False): + break time.sleep(0.1) except Exception as e: @@ -1182,7 +1164,22 @@ class SerialManager: # 更新数据 self.data['xj'] = xj_value self._write_data_to_file() + + # 构建MainWindow.on_diameter_data_received期望的格式 + callback_data_str = f"线径数据: {xj_value}" + if 'xj_data' in self.callbacks: + try: + # 与米电阻类似,传递实际的串口名称 + logging.info(f"线径回调开始调用,回调对象: {self.callbacks['xj_data'].__self__.__class__.__name__}, 数据: {xj_value}") + self.callbacks['xj_data'](port_name, callback_data_str.encode('utf-8')) + logging.info(f"通知 'xj_data' 回调成功. 值: {xj_value}, 串口: {port_name}") + except Exception as e: + logging.error(f"调用 'xj_data' 回调失败: {e}") + else: + # 如果未注册回调,仍然使用通用方法通知 + logging.warning(f"未找到xj_data回调,使用通用_notify_callbacks方法") self._notify_callbacks('xj_data', {"type": "xj", "value": self.data['xj'], "source": f"serial ({port_name})"}) + return True except ValueError: logging.warning(f"线径数据字符串 '{number_str}' 无法转换为浮点数") diff --git a/widgets/camera_display_widget.py b/widgets/camera_display_widget.py index d26017b..0a1535b 100644 --- a/widgets/camera_display_widget.py +++ b/widgets/camera_display_widget.py @@ -1,17 +1,18 @@ import sys import os import logging +import numpy as np from ctypes import * # 确定使用哪个UI框架 try: from PySide6.QtWidgets import QWidget, QVBoxLayout, QFrame, QSizePolicy - from PySide6.QtCore import Qt, Signal, QSize - from PySide6.QtGui import QPalette, QColor + from PySide6.QtCore import Qt, Signal, QSize, QTimer + from PySide6.QtGui import QPalette, QColor, QImage, QPixmap except ImportError: from PyQt5.QtWidgets import QWidget, QVBoxLayout, QFrame, QSizePolicy from PyQt5.QtCore import Qt, pyqtSignal as Signal, QSize - from PyQt5.QtGui import QPalette, QColor + from PyQt5.QtGui import QPalette, QColor, QImage, QPixmap # 添加相机模块路径 sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), "camera")) @@ -52,6 +53,11 @@ class CameraDisplayWidget(QWidget): # 设置最小尺寸 self.setMinimumSize(QSize(320, 240)) + + # 添加本地图像处理功能 + self._current_pixmap = None + self._is_local_frame_connected = False + self._connect_local_frame_signal() def init_ui(self): """初始化UI - 只包含相机显示框架""" @@ -75,6 +81,152 @@ class CameraDisplayWidget(QWidget): # 设置布局 self.setLayout(layout) + def _connect_local_frame_signal(self): + """连接本地图像帧信号""" + if not self._is_local_frame_connected: + try: + # 尝试不同的方式获取LocalImagePlayer实例 + + # 方法1:从主窗口的SettingsWindow查找 + from widgets.settings_window import SettingsWindow + for window in self.window().findChildren(SettingsWindow): + if hasattr(window, 'camera_settings'): + if hasattr(window.camera_settings, 'local_player'): + local_player = window.camera_settings.local_player + # 连接本地图像帧信号 + if hasattr(local_player, 'signal_frame_ready'): + local_player.signal_frame_ready.connect(self.update_local_frame) + self._is_local_frame_connected = True + logging.info("成功连接本地图像帧信号 (方法1)") + return + + # 方法2:直接从相机管理器获取实例 + from widgets.camera_settings_widget import CameraSettingsWidget + from widgets.main_window import MainWindow + + for window in self.window().findChildren(MainWindow): + # 如果已初始化了设置窗口 + if hasattr(window, 'settings_window'): + if hasattr(window.settings_window, 'camera_settings'): + camera_settings = window.settings_window.camera_settings + if hasattr(camera_settings, 'local_player'): + local_player = camera_settings.local_player + if hasattr(local_player, 'signal_frame_ready'): + local_player.signal_frame_ready.connect(self.update_local_frame) + self._is_local_frame_connected = True + logging.info("成功连接本地图像帧信号 (方法2)") + return + + # 如果以上方法都失败了,启动重试机制 + from PySide6.QtCore import QTimer + self._retry_timer = QTimer(self) + self._retry_timer.timeout.connect(self._retry_connect_signal) + self._retry_timer.start(1000) # 1秒后重试 + logging.info("未找到本地图像播放器,将在1秒后重试") + + except Exception as e: + logging.error(f"连接本地图像帧信号失败: {e}") + + def _retry_connect_signal(self): + """重试连接本地图像帧信号""" + if self._is_local_frame_connected: + if hasattr(self, '_retry_timer'): + self._retry_timer.stop() + return + + try: + # 重新尝试连接 + self._connect_local_frame_signal() + + # 如果仍然未连接成功,停止重试计时器 + if not self._is_local_frame_connected and hasattr(self, '_retry_counter'): + self._retry_counter = getattr(self, '_retry_counter', 0) + 1 + if self._retry_counter >= 5: # 最多重试5次 + logging.warning("连接本地图像帧信号重试5次后仍失败,停止重试") + self._retry_timer.stop() + else: + logging.info(f"第{self._retry_counter}次重试连接本地图像帧信号") + + except Exception as e: + logging.error(f"重试连接本地图像帧信号失败: {e}") + + def update_local_frame(self, frame): + """处理并显示本地图像帧 + + Args: + frame: 本地图像帧数据(OpenCV RGB图像) + """ + # 添加明显的调试日志,确认该方法被调用 + logging.warning(f"====> CameraDisplayWidget.update_local_frame被调用,帧尺寸: {frame.shape if frame is not None else 'None'}") + + try: + if frame is None: + logging.warning("接收到的本地图像帧为空") + return + + # 转换OpenCV的BGR图像为Qt的QImage + height, width, channel = frame.shape + bytes_per_line = channel * width + + # 创建QImage (注意:OpenCV使用RGB格式,QImage使用RGB格式) + q_img = QImage(frame.data, width, height, bytes_per_line, QImage.Format_RGB888) + + # 创建QPixmap并设置到标签 + pixmap = QPixmap.fromImage(q_img) + + # 保存当前图像 + self._current_pixmap = pixmap + + # 立即绘制 + self.update() + + # 强制重绘 + self.repaint() + + # 记录日志 + logging.warning(f"====> 成功更新本地图像帧: 图像大小={width}x{height},已调用update()和repaint()触发重绘") + + # 确保相机区域可见 + self.setVisible(True) + self.raise_() + + except Exception as e: + logging.error(f"处理本地图像帧时出错: {e}") + + def paintEvent(self, event): + """重写绘制事件,用于显示图像""" + # 调用父类的paintEvent,确保原有功能完整 + super().paintEvent(event) + + # 添加调试日志,检查是否有图像需要绘制 + if self._current_pixmap: + logging.warning(f"====> paintEvent: 绘制图像,尺寸={self._current_pixmap.width()}x{self._current_pixmap.height()}") + + # 确保我们直接在这个窗口上绘制,不创建新组件 + from PySide6.QtGui import QPainter + painter = QPainter(self) + + # 按比例缩放图像,保持图像比例 + scaled_pixmap = self._current_pixmap.scaled( + self.size(), # 使用整个控件尺寸 + Qt.KeepAspectRatio, + Qt.SmoothTransformation + ) + + # 计算居中位置 + x = (self.width() - scaled_pixmap.width()) // 2 + y = (self.height() - scaled_pixmap.height()) // 2 + + # 绘制图像 + painter.drawPixmap(x, y, scaled_pixmap) + + # 记录绘制完成 + logging.warning(f"====> 图像绘制完成: 控件尺寸={self.width()}x{self.height()}, 图像绘制位置=({x},{y}), 缩放后尺寸={scaled_pixmap.width()}x{scaled_pixmap.height()}") + + painter.end() + else: + logging.debug("paintEvent: 没有图像需要绘制") + def start_display(self): """开始显示相机图像""" if not self.camera_manager.isOpen: @@ -139,8 +291,11 @@ class CameraDisplayWidget(QWidget): # 记录大小变化 logging.debug(f"相机显示区域大小变化为: {self.width()}x{self.height()}") + # 重绘当前图像 + self.update() + # 当尺寸变化超过一定阈值时,重新调整相机显示 - if self.camera_manager.isGrabbing: + if self.camera_manager.isGrabbing and not self.camera_manager.local_mode: # 停止当前显示 self.stop_display() # 使用新尺寸重新开始显示 diff --git a/widgets/camera_manager.py b/widgets/camera_manager.py index f0fd3fc..a73dc7e 100644 --- a/widgets/camera_manager.py +++ b/widgets/camera_manager.py @@ -4,6 +4,8 @@ import logging import json import ctypes from ctypes import * +import numpy as np +import cv2 # 添加相机模块路径 sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), "camera")) @@ -36,23 +38,74 @@ class CameraManager: else: CameraManager._instance = self - # 初始化变量 - self.deviceList = None - self.cam = MvCamera() - self.nSelCamIndex = -1 - self.obj_cam_operation = None + # 初始化属性 self.isOpen = False self.isGrabbing = False + self.last_device_index = -1 + self.device_list = [] + self.current_window_id = 0 - # 初始化SDK (只在第一次时初始化) - if not CameraManager._initialized: - MvCamera.MV_CC_Initialize() - CameraManager._initialized = True - logging.info("相机SDK已初始化") + # 本地图像模式相关 + self.local_mode = False + self.last_frame = None + self.has_real_camera = False # 是否有真实相机连接 + + # 初始化SDK + self.obj_cam_operation = CameraOperation() + + # 将单例标记为已初始化 + CameraManager._initialized = True + + # 初始化日志 + logging.info("相机管理器初始化") def enum_devices(self): - """枚举相机设备,完全参考BasicDemo.py的enum_devices实现""" + """枚举相机设备列表 + + Returns: + list: 设备信息列表 + """ + # 如果当前处于本地图像模式,返回一个虚拟设备 + if self.local_mode: + # 返回虚拟设备列表 + virtual_device = { + "vendor_name": "本地图像模式", + "model_name": "虚拟相机", + "serial_number": "LOCAL_IMG", + "device_version": "1.0", + "spec_version": "1.0", + "user_defined_name": "本地图像播放器", + "index": 0 + } + self.device_list = [virtual_device] + return self.device_list + + # 清空设备列表 + self.device_list = [] + try: + # 枚举设备 + ret = self.obj_cam_operation.Enumrate_Devices() + if ret != 0: + error_msg = f"枚举相机设备失败! 错误码: 0x{ret:x}" + logging.error(error_msg) + # 标记没有真实相机 + self.has_real_camera = False + return [] + + # 获取设备数量 + device_num = self.obj_cam_operation.device_num + + if device_num <= 0: + logging.warning("未发现相机设备") + # 标记没有真实相机 + self.has_real_camera = False + return [] + + # 标记存在真实相机 + self.has_real_camera = True + + # 解析并构建设备信息 # 确保Hikvision SDK已正确加载 from camera.MvCameraControl_class import MvCamCtrldll if MvCamCtrldll is None: @@ -182,15 +235,17 @@ class CameraManager: # 添加详细日志 logging.debug(f"枚举到的设备数量: {len(devices_info)}") + self.device_list = devices_info return devices_info except Exception as e: - error_msg = f"枚举设备时发生异常: {str(e)}" + error_msg = f"枚举相机设备时发生异常: {str(e)}" logging.error(error_msg) - return None + self.has_real_camera = False + return [] def open_device(self, device_index): - """打开相机设备,参考BasicDemo.py的open_device实现 + """打开相机设备 Args: device_index: 设备索引 @@ -198,27 +253,32 @@ class CameraManager: Returns: bool: 是否成功打开设备 """ - # 检查是否已经打开 - if self.isOpen: - logging.warning("相机已经打开!") + # 如果当前处于本地图像模式,且设备索引是虚拟设备 + if self.local_mode and device_index == 0: + # 模拟打开设备成功 + self.isOpen = True + self.last_device_index = device_index + logging.info("打开本地图像模式虚拟设备") + return True + + # 真实相机模式处理 + # 检查设备索引是否有效 + if device_index < 0 or device_index >= len(self.device_list): + logging.error(f"无效的设备索引: {device_index}") return False - # 确保有效的设备索引 - if device_index < 0 or self.deviceList is None or device_index >= self.deviceList.nDeviceNum: - error_msg = f"无效的设备索引: {device_index}, 设备列表: {self.deviceList is not None}" - if self.deviceList: - error_msg += f", 设备数量: {self.deviceList.nDeviceNum}" - logging.error(error_msg) - return False + # 如果之前已经打开设备,先关闭 + if self.isOpen: + self.close_device() try: logging.info(f"开始打开相机,设备索引: {device_index}") # 设置当前选中的相机索引 - self.nSelCamIndex = device_index + self.last_device_index = device_index # 创建相机操作对象 - self.obj_cam_operation = CameraOperation(self.cam, self.deviceList, self.nSelCamIndex) + self.obj_cam_operation = CameraOperation(self.obj_cam_operation.cam, self.deviceList, self.last_device_index) ret = self.obj_cam_operation.Open_device() if ret != 0: error_msg = f"打开相机失败! 错误码: 0x{ret:x}" @@ -260,6 +320,16 @@ class CameraManager: Returns: bool: 是否成功关闭设备 """ + # 如果处于本地图像模式且使用的是虚拟设备 + if self.local_mode and not self.has_real_camera: + # 模拟关闭设备成功 + if self.isGrabbing: + self.stop_grabbing() + self.isOpen = False + logging.info("关闭本地图像模式虚拟设备") + return True + + # 真实相机模式处理 if not self.isOpen: return True @@ -287,22 +357,28 @@ class CameraManager: return False def start_grabbing(self, window_id): - """开始取图 + """开始图像采集 Args: window_id: 显示窗口句柄 Returns: - bool: 是否成功开始取图 + bool: 是否成功开始采集 """ + # 保存窗口ID + self.current_window_id = window_id + + # 如果处于本地图像模式,模拟开始采集 + if self.local_mode: + self.isGrabbing = True + logging.info("开始本地图像模式采集, 窗口ID: " + str(window_id)) + return True + + # 检查设备是否已打开 if not self.isOpen: logging.error("相机未打开,无法开始取图") return False - if self.isGrabbing: - logging.warning("相机已经在取图") - return True - try: ret = self.obj_cam_operation.Start_grabbing(window_id) if ret != 0: @@ -326,6 +402,12 @@ class CameraManager: Returns: bool: 是否成功停止取图 """ + # 如果处于本地图像模式,模拟停止采集 + if self.local_mode: + self.isGrabbing = False + logging.info("停止本地图像模式采集") + return True + if not self.isOpen: return True @@ -412,6 +494,64 @@ class CameraManager: logging.error(error_msg) return False + def set_local_mode(self, enabled): + """设置是否启用本地图像模式 + + Args: + enabled: 是否启用本地模式 + """ + # 如果当前正在采集,先停止 + if self.isGrabbing: + self.stop_grabbing() + + # 如果当前相机已打开且切换到本地模式,先关闭相机 + if self.isOpen and enabled and not self.local_mode: + self.close_device() + + # 设置模式 + self.local_mode = enabled + + # 重新枚举设备(会返回真实设备或虚拟设备) + self.enum_devices() + + logging.info(f"本地图像模式已{'启用' if enabled else '禁用'}") + + # 如果切换到本地模式,自动"打开"虚拟设备 + if enabled and not self.isOpen: + self.open_device(0) # 虚拟设备索引为0 + + def handle_local_frame(self, frame, window_id=0): + """处理并存储本地图像帧(不使用OpenCV窗口显示) + + Args: + frame: 本地图像帧数据(OpenCV RGB图像) + window_id: 窗口句柄ID,仅用于兼容旧代码,实际不再使用 + """ + if not self.local_mode: + return False + + try: + # 记录窗口ID (仅用于日志和兼容) + self.current_window_id = window_id + + # 保存最后一帧,这个帧可以供其他组件使用 + self.last_frame = frame + + # 设置为正在抓取状态,与实际相机行为保持一致 + self.isGrabbing = True + + # 输出日志帮助调试 + logging.warning(f"====> 相机管理器存储本地图像帧,尺寸: {frame.shape if frame is not None else 'None'}") + + # 我们仅存储帧数据,不进行显示操作 + # 显示操作由CameraDisplayWidget完成 + + return True + except Exception as e: + logging.error(f"处理本地图像帧失败: {e}") + + return False + def save_params_to_config(self, exposure, gain, frame_rate): """保存相机参数到配置文件 diff --git a/widgets/camera_settings_widget.py b/widgets/camera_settings_widget.py index cc837cb..1084b2f 100644 --- a/widgets/camera_settings_widget.py +++ b/widgets/camera_settings_widget.py @@ -8,11 +8,11 @@ from camera.CameraParams_const import * sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), "camera")) try: - from PySide6.QtWidgets import QMessageBox + from PySide6.QtWidgets import QMessageBox, QFileDialog from PySide6.QtCore import Qt, Signal USE_PYSIDE6 = True except ImportError: - from PyQt5.QtWidgets import QMessageBox + from PyQt5.QtWidgets import QMessageBox, QFileDialog from PyQt5.QtCore import Qt from PyQt5.QtCore import pyqtSignal as Signal USE_PYSIDE6 = False @@ -27,8 +27,13 @@ from ui.settings_ui import SettingsUI # 导入相机管理器 from widgets.camera_manager import CameraManager +# 导入本地图像播放器 +from utils.local_image_player import LocalImagePlayer +# 导入配置加载器 +from utils.config_loader import ConfigLoader from PySide6.QtCore import QObject +import json class CameraSettingsWidget(QObject): """相机设置控制器,管理相机设置并提供与主窗口相机显示部分的通信 @@ -39,6 +44,7 @@ class CameraSettingsWidget(QObject): signal_camera_params_changed = Signal(float, float, float) # 相机参数变化信号 (曝光, 增益, 帧率) signal_camera_error = Signal(str) # 相机错误信号 settings_changed = Signal() # 设置变更信号,与 SettingsWindow 兼容 + signal_local_mode_changed = Signal(bool) # 本地图像模式变更信号 (是否启用) def __init__(self, parent=None): """初始化相机设置控制器 @@ -55,6 +61,24 @@ class CameraSettingsWidget(QObject): # 获取相机管理器实例 self.camera_manager = CameraManager.get_instance() + # 加载配置 + try: + self.config_loader = ConfigLoader() + self.camera_config = self.config_loader.get_config("camera") + # 使用 .get() 来安全地访问 'display' + self.display_config = self.camera_config.get("display", {}) + except Exception as e: + logging.error(f"加载相机配置文件失败: {e}") + self.camera_config = {} + self.display_config = {} + + # 创建本地图像播放器 + self.local_player = LocalImagePlayer() + + # 本地图像模式状态 + self.local_mode_enabled = False + self.is_playing = False + # 初始化日志记录 logging.debug("CameraSettingsWidget初始化开始") @@ -78,6 +102,17 @@ class CameraSettingsWidget(QObject): self.set_params_button = getattr(parent, 'set_params_button', None) self.save_camera_button = getattr(parent, 'save_camera_button', None) self.preview_frame = getattr(parent, 'preview_frame', None) + self.preview_status = getattr(parent, 'preview_status', None) + + # 本地图像模式相关控件 + self.local_mode_check = getattr(parent, 'local_mode_check', None) + self.folder_path_edit = getattr(parent, 'folder_path_edit', None) + self.folder_path_button = getattr(parent, 'folder_path_button', None) + self.local_framerate_slider = getattr(parent, 'local_framerate_slider', None) + self.local_framerate_value = getattr(parent, 'local_framerate_value', None) + self.loop_playback_check = getattr(parent, 'loop_playback_check', None) + self.play_button = getattr(parent, 'play_button', None) + self.stop_button = getattr(parent, 'stop_button', None) # 检查是否成功获取到了所有必要的UI控件 if self.camera_combo is None: @@ -109,7 +144,19 @@ class CameraSettingsWidget(QObject): self.set_params_button = None self.save_camera_button = None self.preview_frame = None + self.preview_status = None + self.local_mode_check = None + self.folder_path_edit = None + self.folder_path_button = None + self.local_framerate_slider = None + self.local_framerate_value = None + self.loop_playback_check = None + self.play_button = None + self.stop_button = None + # 连接本地图像播放器信号 + self.connect_local_player_signals() + # 连接信号和槽 self.connect_signals() @@ -122,9 +169,24 @@ class CameraSettingsWidget(QObject): self.gain_min = 0.0 self.gain_max = 15.0 + # 从配置加载本地模式设置 + self.load_local_mode_settings() + + # 更新本地模式UI + self.update_local_mode_ui() + # 枚举设备 self.refresh_devices() + def connect_local_player_signals(self): + """连接本地图像播放器的信号""" + if self.local_player: + self.local_player.signal_status.connect(self.handle_local_player_status) + self.local_player.signal_error.connect(self.handle_local_player_error) + self.local_player.signal_progress.connect(self.handle_local_player_progress) + self.local_player.signal_frame_ready.connect(self.handle_local_player_frame) + self.local_player.signal_completed.connect(self.handle_local_player_completed) + def connect_signals(self): """连接信号和槽""" # 设备选择和刷新 @@ -145,6 +207,25 @@ class CameraSettingsWidget(QObject): self.get_params_button.clicked.connect(self.get_camera_params) self.set_params_button.clicked.connect(self.set_camera_params) self.save_camera_button.clicked.connect(self.save_camera_params) + + # 本地图像模式相关按钮和控件 + if self.local_mode_check: + self.local_mode_check.stateChanged.connect(self.toggle_local_mode) + + if self.folder_path_button: + self.folder_path_button.clicked.connect(self.choose_image_folder) + + if self.local_framerate_slider: + self.local_framerate_slider.valueChanged.connect(self.update_local_framerate_value) + + if self.loop_playback_check: + self.loop_playback_check.stateChanged.connect(self.update_loop_playback) + + if self.play_button: + self.play_button.clicked.connect(self.play_local_images) + + if self.stop_button: + self.stop_button.clicked.connect(self.stop_local_images) def refresh_devices(self): """刷新设备列表""" @@ -195,70 +276,25 @@ class CameraSettingsWidget(QObject): devList = [] if devices_info: for device in devices_info: - devList.append(device["display"]) + # 修复:安全获取 display 键,如果不存在则使用默认值 + if isinstance(device, dict): + display_name = device.get("display", f"设备 {len(devList)}") + devList.append(display_name) + else: + # 如果整个设备对象不是字典,则添加一个默认名称 + devList.append(f"设备 {len(devList)}") logging.info(f"【设备刷新】找到 {len(devList)} 个设备: {devList}") else: - devList.append("未发现相机设备") - logging.info(f"【设备刷新】将显示默认值 '未发现相机设备'") - - # 3. 更新UI上的下拉列表 - try: - if hasattr(self, 'camera_combo') and self.camera_combo is not None: - logging.info(f"【设备刷新】开始更新下拉列表,当前状态: 项目数={self.camera_combo.count()}, 是否可见={self.camera_combo.isVisible()}") - - self.camera_combo.blockSignals(True) - self.camera_combo.clear() - - # 确保项目数清零 - if self.camera_combo.count() > 0: - logging.warning(f"【设备刷新】clear()后项目数仍为 {self.camera_combo.count()}") - - # 直接添加项目 - 单个添加,避免批量添加可能的问题 - for item in devList: - self.camera_combo.addItem(item) - logging.debug(f"【设备刷新】已添加项目: {item}") - - # 确保设置当前项目 - if self.camera_combo.count() > 0: - self.camera_combo.setCurrentIndex(0) - logging.info(f"【设备刷新】已设置当前索引为0,显示文本: {self.camera_combo.currentText()}") - else: - logging.error("【设备刷新】未能添加任何项目到下拉列表") - - self.camera_combo.blockSignals(False) - - # 强制更新UI - self.camera_combo.update() - self.camera_combo.repaint() - - # 确保ComboBox有足够的尺寸显示内容 - self.camera_combo.adjustSize() - - logging.info(f"【设备刷新】下拉列表更新完成。当前项目数: {self.camera_combo.count()}, 当前文本: {self.camera_combo.currentText()}") - else: - logging.error("【设备刷新】无法更新下拉列表,camera_combo不存在") - except Exception as e: - logging.error(f"【设备刷新】更新下拉列表时发生错误: {e}") - - # 4. 更新其他控件的状态 - try: - self.update_controls() - logging.info("【设备刷新】控件状态已更新。") - except Exception as e: - logging.error(f"【设备刷新】更新控件状态时发生错误: {e}") + devList = ["未发现相机设备"] - # 5. 如果下拉列表仍然为空,尝试最后一次强制添加 - try: - if hasattr(self, 'camera_combo') and self.camera_combo is not None: - if self.camera_combo.count() == 0: - logging.warning("【设备刷新】下拉列表仍然为空,尝试强制添加项目") - self.camera_combo.addItem("未发现相机设备(强制添加)") - self.camera_combo.setCurrentIndex(0) - self.camera_combo.update() - self.camera_combo.repaint() - except Exception as e: - logging.error(f"【设备刷新】强制添加项目时发生错误: {e}") - + # 3. 清空并更新下拉列表 + self.camera_combo.clear() + for dev in devList: + self.camera_combo.addItem(dev) + + # 4. 更新UI状态 + self.update_controls() + def get_selected_device_index(self): """获取当前选中的设备索引,参考BasicDemo.py的TxtWrapBy实现""" try: @@ -351,7 +387,6 @@ class CameraSettingsWidget(QObject): self.signal_camera_connection.emit(True, "") # 更新配置 - from utils.config_loader import ConfigLoader config_loader = ConfigLoader.get_instance() config_loader.set_value('camera.enabled', True) config_loader.save_config() @@ -386,7 +421,6 @@ class CameraSettingsWidget(QObject): self.signal_camera_connection.emit(False, "") # 更新配置 - from utils.config_loader import ConfigLoader config_loader = ConfigLoader.get_instance() config_loader.set_value('camera.enabled', False) config_loader.save_config() @@ -538,3 +572,495 @@ class CameraSettingsWidget(QObject): if self.camera_manager.isOpen: self.camera_manager.close_device() super().closeEvent(event) + + def load_local_mode_settings(self): + """加载本地图像模式的配置""" + try: + # 使用 .get() 防止因缺少 local_mode 键而崩溃 + config = self.camera_config.get("local_mode", {}) + self.local_mode_enabled = config.get("enabled", False) + self.local_player.framerate = config.get("framerate", 15) + self.local_player.loop = config.get("loop", True) + folder_path = config.get("folder_path", "") + + # 更新UI组件 + if self.local_mode_check: + self.local_mode_check.setChecked(self.local_mode_enabled) + if self.local_framerate_slider: + self.local_framerate_slider.setValue(self.local_player.framerate) + if self.loop_playback_check: + self.loop_playback_check.setChecked(self.local_player.loop) + if self.folder_path_edit: + self.folder_path_edit.setText(folder_path) + + self.local_player.folder_path = folder_path + + logging.info(f"加载本地图像模式设置: 启用={self.local_mode_enabled}, 帧率={self.local_player.framerate}, 循环={self.local_player.loop}") + + # 触发模式变更信号,通知主窗口和其他组件 + if self.local_mode_enabled: + self.signal_local_mode_changed.emit(True) + + except Exception as e: + logging.error(f"加载本地图像模式设置时发生错误: {e}") + + def save_local_mode_settings(self): + """保存本地图像模式的配置""" + try: + # 获取当前设置 + enabled = self.local_mode_enabled + folder_path = self.folder_path_edit.text() if self.folder_path_edit else "" + framerate = self.local_framerate_slider.value() if self.local_framerate_slider else 15 + loop = self.loop_playback_check.isChecked() if self.loop_playback_check else True + + # 创建配置字典 + config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "app_config.json") + + # 读取现有配置(如果存在) + config = {} + if os.path.exists(config_path): + try: + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + except json.JSONDecodeError: + logging.warning(f"配置文件格式错误: {config_path}") + config = {} + except Exception as e: + logging.warning(f"读取配置文件失败: {e}") + config = {} + + # 更新本地图像模式设置 + if "local_image_mode" not in config: + config["local_image_mode"] = {} + + config["local_image_mode"].update({ + "enabled": enabled, + "folder_path": folder_path, + "framerate": framerate, + "loop": loop + }) + + # 保存配置 + with open(config_path, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=4, ensure_ascii=False) + + logging.info(f"已保存本地图像模式设置: enabled={enabled}, folder='{folder_path}', " + f"framerate={framerate}, loop={loop}") + + return True + + except Exception as e: + logging.error(f"保存本地图像模式设置失败: {e}") + return False + + def update_local_mode_ui(self): + """更新本地图像模式UI状态""" + # 获取当前复选框状态(如果复选框存在) + enabled = self.local_mode_check.isChecked() if self.local_mode_check else self.local_mode_enabled + + # 更新内部状态 + if enabled != self.local_mode_enabled: + logging.info(f"本地图像模式状态变化: {self.local_mode_enabled} -> {enabled}") + self.local_mode_enabled = enabled + + # 打印日志,帮助调试 + logging.debug(f"更新本地图像模式UI: 启用={enabled}, 按钮状态={self.folder_path_button is not None}") + + # 更新相关控件状态 + if self.folder_path_edit: + self.folder_path_edit.setEnabled(enabled) + logging.debug(f"文件夹路径编辑框已设置为: {enabled}") + + if self.folder_path_button: + self.folder_path_button.setEnabled(enabled) + logging.debug(f"文件夹选择按钮已设置为: {enabled}") + + if self.local_framerate_slider: + self.local_framerate_slider.setEnabled(enabled) + + if self.loop_playback_check: + self.loop_playback_check.setEnabled(enabled) + + if self.play_button: + has_folder = bool(self.folder_path_edit and self.folder_path_edit.text()) + self.play_button.setEnabled(enabled and has_folder) + logging.debug(f"播放按钮已设置为: {enabled and has_folder}, 有文件夹={has_folder}") + + if self.stop_button: + self.stop_button.setEnabled(enabled and self.is_playing) + + # 强制更新UI + if self.parent: + self.parent.update() + + def toggle_local_mode(self, state): + """切换本地图像模式""" + # 修复:直接比较整数值,Qt.Checked.value 是 2 + is_checked = (state == 2) # 或者 state == Qt.Checked.value + + # 停止可能正在进行的播放 + if self.is_playing: + self.stop_local_images() + + # 打印详细日志,帮助调试 + logging.info(f"切换本地图像模式: {state} -> {is_checked}") + + # 先设置相机管理器的本地模式 + self.camera_manager.set_local_mode(is_checked) + + # 更新本地模式状态 + self.local_mode_enabled = is_checked + + # 更新UI状态 + self.update_local_mode_ui() + + # 如果打开本地模式,关闭相机;如果关闭本地模式,尝试打开之前的相机 + if is_checked: + # 如果相机正在工作,先关闭它 + if self.camera_manager.isOpen and not self.camera_manager.local_mode: + self.close_camera() + else: + # 如果存在实际相机,尝试打开最后选择的相机 + if self.camera_manager.has_real_camera: + # 获取当前选择的设备索引 + device_index = self.get_selected_device_index() + if device_index >= 0: + # 关闭虚拟相机 + if self.camera_manager.isOpen: + self.camera_manager.close_device() + # 开启实际相机 + self.open_camera() + + # 发送模式变更信号 + self.signal_local_mode_changed.emit(is_checked) + + # 保存设置 + self.save_local_mode_settings() + + logging.info(f"本地图像模式已{'启用' if is_checked else '禁用'}") + + # 刷新设备列表 + self.refresh_devices() + + def choose_image_folder(self): + """选择图像文件夹""" + # 检查复选框状态而不是类属性 + is_enabled = self.local_mode_check.isChecked() if self.local_mode_check else False + + # 即使控件状态可能不对,也尝试打开文件选择对话框 + logging.info(f"选择图像文件夹: 本地模式启用={is_enabled}, 按钮状态={self.folder_path_button is not None}") + + # 获取当前设置的文件夹路径作为初始目录 + current_path = self.folder_path_edit.text() if self.folder_path_edit else "" + if not current_path or not os.path.isdir(current_path): + current_path = os.path.expanduser("~") # 默认使用用户主目录 + + # 打开文件夹选择对话框 + folder_path = QFileDialog.getExistingDirectory(self.parent, "选择图像文件夹", current_path) + + if folder_path: + logging.info(f"已选择图像文件夹: {folder_path}") + + # 更新路径显示 + if self.folder_path_edit: + self.folder_path_edit.setText(folder_path) + + # 设置到本地播放器 + if self.local_player: + self.local_player.set_folder(folder_path) + + # 更新UI状态 + self.update_local_mode_ui() + + # 保存设置 + self.save_local_mode_settings() + else: + logging.info("未选择任何文件夹") + + def update_local_framerate_value(self, value): + """更新本地模式帧率显示""" + if self.local_framerate_value: + self.local_framerate_value.setText(f"{value} fps") + + # 更新本地播放器帧率 + if self.local_player: + self.local_player.set_framerate(value) + + def update_loop_playback(self, state): + """更新循环播放设置""" + loop = (state == Qt.Checked) + + # 更新本地播放器设置 + if self.local_player: + self.local_player.set_loop(loop) + + # 保存设置 + self.save_local_mode_settings() + + def play_local_images(self): + """播放本地图像序列""" + if not self.local_mode_enabled: + return + + if self.is_playing: + # 如果正在播放,则暂停/恢复 + if self.local_player: + if self.local_player.is_paused: + self.local_player.resume_playback() + self.play_button.setText("暂停") + else: + self.local_player.pause_playback() + self.play_button.setText("继续") + else: + # 开始播放 + if self.local_player: + folder_path = self.folder_path_edit.text() if self.folder_path_edit else "" + + if not folder_path: + QMessageBox.warning(self.parent, "错误", "请先选择图像文件夹") + return + + # 设置播放参数 + framerate = self.local_framerate_slider.value() if self.local_framerate_slider else 15 + loop = self.loop_playback_check.isChecked() if self.loop_playback_check else True + + self.local_player.set_framerate(framerate) + self.local_player.set_loop(loop) + + # 确保文件夹路径已设置 + if not self.local_player.folder_path: + self.local_player.set_folder(folder_path) + + # 开始播放 + if self.local_player.start_playback(): + self.is_playing = True + self.play_button.setText("暂停") + self.stop_button.setEnabled(True) + + # 更新预览状态 + if self.preview_status: + self.preview_status.setText("播放中...") + + # 关键修复:发送信号通知主窗口更新UI,显示相机画面 + logging.warning("====> 开始播放本地图像序列,发送信号通知主窗口更新UI") + # 先确保本地模式已启用 + self.camera_manager.set_local_mode(True) + # 发送信号,通知主窗口更新UI + self.signal_local_mode_changed.emit(True) + + logging.info(f"开始播放本地图像序列,帧率={framerate} fps,循环={loop}") + + def stop_local_images(self): + """停止播放本地图像序列""" + if self.local_player and self.is_playing: + self.local_player.stop_playback() + self.is_playing = False + self.play_button.setText("播放") + self.stop_button.setEnabled(False) + + # 更新预览状态 + if self.preview_status: + self.preview_status.setText("已停止") + + logging.info("停止播放本地图像序列") + + def handle_local_player_status(self, message): + """处理本地播放器状态消息""" + if self.preview_status: + self.preview_status.setText(message) + + def handle_local_player_error(self, message): + """处理本地播放器错误消息""" + logging.error(f"本地图像播放器错误: {message}") + + if self.preview_status: + self.preview_status.setText(f"错误: {message}") + + QMessageBox.warning(self.parent, "错误", message) + + def handle_local_player_progress(self, current, total): + """处理本地播放器进度消息""" + if self.preview_status: + self.preview_status.setText(f"播放中... {current}/{total}") + + def handle_local_player_frame(self, frame): + """处理本地播放器帧准备好消息""" + # 确保本地模式已启用 + if not self.local_mode_enabled or not self.local_player: + return + + # 添加明显的调试日志 + logging.warning(f"====> 接收到本地图像帧: {frame.shape if frame is not None else 'None'}") + + # 获取窗口ID (默认方法) + window_id = int(self.preview_frame.winId()) if self.preview_frame else 0 + + # 将帧传递给camera_manager处理 + logging.warning(f"====> 传递给camera_manager处理,窗口ID: {window_id}") + success = self.camera_manager.handle_local_frame(frame, window_id) + if not success: + logging.error("相机管理器处理本地图像帧失败") + + # 修复:查找主窗口的改进方式 + try: + # 获取应用实例 + from PySide6.QtWidgets import QApplication + app = QApplication.instance() + + # 查找主窗口 + from widgets.main_window import MainWindow + main_window = None + + # 遍历所有顶层窗口 + for window in app.topLevelWidgets(): + logging.warning(f"====> 找到顶层窗口: {window.__class__.__name__}") + if isinstance(window, MainWindow): + main_window = window + logging.warning("====> 找到 MainWindow 实例") + break + + # 如果通过顶层窗口没找到,尝试通过父窗口查找 + if not main_window and hasattr(self, 'parent') and self.parent: + parent = self.parent + # 循环查找父窗口,直到找到 MainWindow 或达到顶层 + while parent: + logging.warning(f"====> 检查父窗口: {parent.__class__.__name__}") + if isinstance(parent, MainWindow): + main_window = parent + logging.warning("====> 在父窗口链中找到 MainWindow 实例") + break + if hasattr(parent, 'parent'): + parent = parent.parent() + else: + break + + # 如果找到了主窗口,更新相机显示 + if main_window: + logging.warning(f"====> MainWindow 实例 ID: {id(main_window)}") + + # 添加详细调试日志,查看主窗口的属性 + logging.warning(f"====> MainWindow 的属性: {dir(main_window)}") + + # 检查 camera_display 是否存在 + has_camera_display = hasattr(main_window, 'camera_display') + logging.warning(f"====> MainWindow 是否有 camera_display 属性: {has_camera_display}") + + if has_camera_display: + camera_display = main_window.camera_display + logging.warning(f"====> camera_display 对象 ID: {id(camera_display) if camera_display else 'None'}") + logging.warning(f"====> camera_display 是否为 None: {camera_display is None}") + + # 检查 material_placeholder 是否存在 + has_placeholder = hasattr(main_window, 'material_placeholder') + logging.warning(f"====> MainWindow 是否有 material_placeholder 属性: {has_placeholder}") + + # 检查camera_enabled状态 + current_camera_enabled = getattr(main_window, 'camera_enabled', False) + logging.warning(f"====> 当前 camera_enabled 状态: {current_camera_enabled}") + + # 关键修复:确保camera_enabled为True + if hasattr(main_window, 'camera_enabled'): + logging.warning("====> 设置 camera_enabled 为 True") + main_window.camera_enabled = True + + # 检查是否已调用过 init_camera_display 方法 + if hasattr(main_window, 'init_camera_display'): + logging.warning("====> MainWindow 有 init_camera_display 方法") + # 尝试再次调用初始化方法 + logging.warning("====> 尝试再次调用 init_camera_display 方法") + main_window.init_camera_display() + else: + logging.error("MainWindow 没有 init_camera_display 方法") + + # 再次检查camera_display是否已创建 + if hasattr(main_window, 'camera_display') and main_window.camera_display: + logging.warning("====> init_camera_display后,camera_display已创建") + # 确保相机显示组件可见 + main_window.camera_display.setVisible(True) + main_window.camera_display.raise_() + + # 隐藏占位符 + if hasattr(main_window, 'material_placeholder') and main_window.material_placeholder: + main_window.material_placeholder.setVisible(False) + + # 调用update_local_frame方法 + if hasattr(main_window.camera_display, 'update_local_frame'): + main_window.camera_display.update_local_frame(frame) + logging.warning("====> 直接调用camera_display.update_local_frame成功") + else: + logging.error("camera_display没有update_local_frame方法") + + # 强制更新UI + main_window.update_camera_ui(True) + else: + logging.error("MainWindow中没有找到camera_display组件或它仍然是None") + else: + logging.error("未找到MainWindow实例") + + except Exception as e: + logging.error(f"尝试直接更新主窗口相机组件失败: {str(e)}") + import traceback + logging.error(traceback.format_exc()) + + # 保留原来的第二种方法作为备选 + try: + # 尝试使用Qt方式更新图像 + if not success: + from PySide6.QtGui import QImage, QPixmap + + # 将OpenCV的RGB图像转换为Qt的QImage + height, width, channel = frame.shape + bytes_per_line = channel * width + q_img = QImage(frame.data, width, height, bytes_per_line, QImage.Format_RGB888) + + # 创建QPixmap + pixmap = QPixmap.fromImage(q_img) + + # 获取主窗口实例 + from PySide6.QtWidgets import QApplication + app = QApplication.instance() + from widgets.main_window import MainWindow + + for window in app.topLevelWidgets(): + if isinstance(window, MainWindow): + main_window = window + # 确保camera_enabled为True + if hasattr(main_window, 'camera_enabled'): + main_window.camera_enabled = True + + # 尝试初始化相机显示 + if hasattr(main_window, 'init_camera_display'): + main_window.init_camera_display() + + if hasattr(main_window, 'camera_display') and main_window.camera_display: + # 确保相机显示组件可见 + main_window.camera_display.setVisible(True) + main_window.camera_display.raise_() + + # 给相机显示组件设置属性,强制更新 + main_window.camera_display._current_pixmap = pixmap + main_window.camera_display.update() # 强制重绘 + + logging.warning("====> 通过设置_current_pixmap并强制更新成功") + + # 隐藏占位符 + if hasattr(main_window, 'material_placeholder'): + main_window.material_placeholder.setVisible(False) + + # 强制更新UI + main_window.update_camera_ui(True) + return + except Exception as e: + logging.error(f"尝试使用Qt方式更新图像失败: {e}") + import traceback + logging.error(traceback.format_exc()) + + def handle_local_player_completed(self): + """处理本地播放器播放完成消息""" + if not self.local_player.loop: + self.is_playing = False + self.play_button.setText("播放") + + # 更新预览状态 + if self.preview_status: + self.preview_status.setText("播放完成") diff --git a/widgets/main_window.py b/widgets/main_window.py index f20766d..3175e2b 100644 --- a/widgets/main_window.py +++ b/widgets/main_window.py @@ -75,6 +75,10 @@ class MainWindow(MainWindowUI): self._weight_processed = False # 新增:标记当前重量是否已处理,避免重复处理 self._last_processed_weight = 0.0 # 新增:记录上次处理的重量 + # 线径数据处理相关属性 + self._last_diameter_value = 0 # 最后一次有效的线径值 + self._diameter_stable = False # 保留此属性以避免引用错误 + # 初始化数据加载状态标志 self._loading_data_in_progress = False # 数据加载状态标志,防止循环调用 self._current_order_code = None # 存储当前订单号 @@ -393,6 +397,17 @@ class MainWindow(MainWindowUI): self.settings_window = SettingsWindow(self) # 连接设置改变信号 self.settings_window.settings_changed.connect(self.on_settings_changed) + + # 连接相机设置控制器的信号 + if hasattr(self.settings_window, 'camera_settings'): + # 连接相机连接状态变化信号 + self.settings_window.camera_settings.signal_camera_connection.connect(self.handle_camera_connection) + # 连接相机参数变化信号 + self.settings_window.camera_settings.signal_camera_params_changed.connect(self.handle_camera_params_changed) + # 连接相机错误信号 + self.settings_window.camera_settings.signal_camera_error.connect(self.handle_camera_error) + # 连接本地图像模式变更信号 + self.settings_window.camera_settings.signal_local_mode_changed.connect(self.handle_local_mode_changed) # 显示设置窗口 self.settings_window.show() @@ -418,7 +433,7 @@ class MainWindow(MainWindowUI): # 重新加载托盘号 self.load_pallet_codes() - logging.info("设置已更新,重新加载配置并重新打开串口,已重新注册扫码器回调") + logging.info("设置已更新,重新加载配置并重新打开串口,已重新注册米电阻、线径和扫码器回调") def handle_input(self): """处理上料按钮点击事件""" @@ -770,7 +785,9 @@ class MainWindow(MainWindowUI): logging.info(f"输入的工程号: {gc_note}") #判断是否是接口,如果不是接口直接添加如果是则走接口 # 如果开启接口模式,则需要调用接口同步到业务库 + self.add_new_inspection_row(gc_note, self._current_order_code) + else: logging.warning("工程号为空") QMessageBox.warning(self, "输入提示", "请输入有效的工程号") @@ -2721,46 +2738,59 @@ class MainWindow(MainWindowUI): if "线径数据:" in data_str: value_str = data_str.split("线径数据:")[1].strip() try: - # 转换为浮点数 - xj_value = float(value_str) + # 转换为浮点数,除以10000并保留三位小数 + xj_value = round(float(value_str)/10000, 3) - # 查找线径对应的检验项配置 - xj_config = None - enabled_configs = self.inspection_manager.get_enabled_configs() - for config in enabled_configs: - if config.get('name') == 'xj' or config.get('display_name') == '线径': - xj_config = config - break + # 更新UI显示,实时回显最新测量值 + self.statusBar().showMessage(f"线径数据: {xj_value:.3f}", 2000) - if xj_config: - from dao.inspection_dao import InspectionDAO - inspection_dao = InspectionDAO() - bccd, tccd = inspection_dao.get_xj_range(self._current_order_code) - - if bccd is not None and tccd is not None: - if bccd <= xj_value <= tccd: - self.set_inspection_value('xj', xj_config, xj_value) - else: - logging.warning(f"线径 {xj_value} 不在公差范围内 ({bccd} - {tccd})") - reply = QMessageBox.question( - self, - '确认保存', - f"线径 {xj_value} 不在公差范围内 ({bccd} - {tccd}),\n是否继续保存?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No - ) - if reply == QMessageBox.Yes: - self.set_inspection_value('xj', xj_config, xj_value) - else: - logging.info(f"用户取消保存超出范围的线径值: {xj_value}") - # TODO:后续根据实际情况实现 - pass - else: - logging.info(f"未找到订单 {self._current_order_code} 的线径公差范围,直接保存值 {xj_value}") - self.set_inspection_value('xj', xj_config, xj_value) - else: - logging.warning("未找到线径对应的检验项配置") + # 如果当前值不为0,记录为最后一次有效值 + if xj_value > 0: + self._last_diameter_value = xj_value + logging.info(f"更新最后一次有效线径值: {xj_value:.3f}") + + # 如果当前值为0,并且之前有非零值,说明产品已拿开,保存最后一次有效值 + elif xj_value == 0 and hasattr(self, '_last_diameter_value') and self._last_diameter_value > 0: + final_value = self._last_diameter_value + logging.info(f"检测到线径值变为0,使用最后一次有效值 {final_value:.3f} 作为最终结果") + # 查找线径对应的检验项配置 + xj_config = None + enabled_configs = self.inspection_manager.get_enabled_configs() + for config in enabled_configs: + if config.get('name') == 'xj' or config.get('display_name') == '线径': + xj_config = config + break + + if xj_config: + from dao.inspection_dao import InspectionDAO + inspection_dao = InspectionDAO() + bccd, tccd = inspection_dao.get_xj_range(self._current_order_code) + + if bccd is not None and tccd is not None: + if bccd - 0.5 <= final_value <= tccd + 0.5: # 允许±0.5的误差范围 + self.set_inspection_value('xj', xj_config, final_value) + else: + logging.warning(f"线径 {final_value:.3f} 不在公差范围内 ({bccd} - {tccd}),误差超过±0.5") + reply = QMessageBox.question( + self, + '确认保存', + f"线径 {final_value:.3f} 不在公差范围内 ({bccd} - {tccd}),\n是否继续保存?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + if reply == QMessageBox.Yes: + self.set_inspection_value('xj', xj_config, final_value) + else: + logging.info(f"用户取消保存超出范围的线径值: {final_value:.3f}") + else: + logging.info(f"未找到订单 {self._current_order_code} 的线径公差范围,直接保存值 {final_value:.3f}") + self.set_inspection_value('xj', xj_config, final_value) + + # 重置最后一次有效值,避免重复处理 + self._last_diameter_value = 0 + else: + logging.warning("未找到线径对应的检验项配置") except ValueError: logging.warning(f"线径数据格式错误: {value_str}") else: @@ -2850,7 +2880,7 @@ class MainWindow(MainWindowUI): data_row = None for row in range(2, self.process_table.rowCount()): cell_item = self.process_table.item(row, col_index) - if not cell_item or not cell_item.text().strip(): + if not cell_item or not cell_item.text().strip() or (data_type == 'xj' and cell_item.text().strip() == '0'): data_row = row break @@ -2882,8 +2912,11 @@ class MainWindow(MainWindowUI): # 格式化值并设置单元格 formatted_value = str(value) if config.get('data_type') == 'number': - # 格式化数字,保留2位小数 - formatted_value = f"{value:.2f}" + # 格式化数字,线径保留3位小数,其他保留2位小数 + if data_type == 'xj': + formatted_value = f"{value:.3f}" + else: + formatted_value = f"{value:.2f}" # 设置单元格值 item = QTableWidgetItem(formatted_value) @@ -3017,7 +3050,7 @@ class MainWindow(MainWindowUI): """初始化相机并显示画面""" try: if not self.camera_enabled: - return + self.material_placeholder.setText("相机功能已禁用") logging.info("开始初始化相机...") @@ -3083,7 +3116,16 @@ class MainWindow(MainWindowUI): is_camera_ready: 相机是否准备就绪 """ try: - if is_camera_ready and self.camera_enabled: + from widgets.camera_manager import CameraManager + camera_manager = CameraManager.get_instance() + local_mode_active = getattr(camera_manager, 'local_mode', False) + + # 关键修复:如果本地模式激活,则强制认为相机已就绪 + camera_feature_enabled = self.camera_enabled or local_mode_active + if local_mode_active: + is_camera_ready = True + + if is_camera_ready and camera_feature_enabled: # 显示相机画面,隐藏占位符 if self.camera_display: self.camera_display.setVisible(True) @@ -3092,7 +3134,7 @@ class MainWindow(MainWindowUI): self.material_placeholder.setVisible(False) logging.info("相机UI已更新:显示相机画面") else: - # 隐藏相机画面,显示占位符 + # 显示占位符,隐藏相机画面 if self.camera_display: self.camera_display.setVisible(False) if self.material_placeholder: @@ -3104,7 +3146,7 @@ class MainWindow(MainWindowUI): self.material_placeholder.setText("相机未就绪") logging.info(f"相机UI已更新:显示占位符 (相机启用={self.camera_enabled}, 相机就绪={is_camera_ready})") except Exception as e: - logging.error(f"更新相机UI失败: {str(e)}") + logging.error(f"更新相机UI时出错: {e}") def handle_camera_status(self, is_connected, message): """处理相机状态变化""" @@ -3331,4 +3373,103 @@ class MainWindow(MainWindowUI): except Exception as e: logging.error(f"删除数据失败: {str(e)}") - QMessageBox.critical(self, "错误", f"删除数据失败: {str(e)}") \ No newline at end of file + QMessageBox.critical(self, "错误", f"删除数据失败: {str(e)}") + + # 在MainWindow类中添加处理本地图像模式变更的代码 + + # 在init_settings_window()方法中添加信号连接 + def init_settings_window(self): + # 创建设置窗口 + from widgets.settings_window import SettingsWindow + self.settings_window = SettingsWindow(self) + self.settings_window.setWindowTitle("系统设置") + # 设置窗口大小 + self.settings_window.resize(900, 650) + + # 连接设置变更信号 + self.settings_window.settings_changed.connect(self.handle_settings_changed) + + # 连接相机设置控制器 + if hasattr(self.settings_window, 'camera_settings'): + # 连接相机连接状态变化信号 + self.settings_window.camera_settings.signal_camera_connection.connect(self.handle_camera_connection) + # 连接相机参数变化信号 + self.settings_window.camera_settings.signal_camera_params_changed.connect(self.handle_camera_params_changed) + # 连接相机错误信号 + self.settings_window.camera_settings.signal_camera_error.connect(self.handle_camera_error) + # 连接本地图像模式变更信号 + self.settings_window.camera_settings.signal_local_mode_changed.connect(self.handle_local_mode_changed) + + # 添加处理本地图像模式变更的方法 + def handle_local_mode_changed(self, enabled): + """处理本地图像模式变更 + + Args: + enabled: 是否启用本地图像模式 + """ + try: + logging.warning(f"====> 主窗口处理本地图像模式变更: {enabled}") + + # 关键修复:只要启用本地模式,就认为相机功能已就绪,并强制更新UI + if enabled: + self.camera_enabled = True + self.update_camera_ui(True) # 强制刷新UI,隐藏占位符 + if self.camera_display: + self.camera_display.setVisible(True) # 确保相机显示区域可见 + + # 获取相机管理器 + from widgets.camera_manager import CameraManager + camera_manager = CameraManager.get_instance() + + # 设置相机管理器的本地模式 + camera_manager.set_local_mode(enabled) + + # 如果当前在主页面,更新相机显示 + if hasattr(self, 'camera_display') and self.camera_display and self.stacked_widget.currentWidget() == self.central_widget: + # 确保相机显示组件可见 + self.camera_display.setVisible(True) + self.camera_display.raise_() + if self.material_placeholder: + self.material_placeholder.setVisible(False) + + # 确保相机显示组件连接了本地图像帧信号 + if enabled: + # 本地模式下,停止实时相机显示但保持相机显示组件可见 + if camera_manager.isGrabbing and not camera_manager.local_mode: + self.camera_display.stop_display() + + # 确保本地图像帧能够被显示 + if hasattr(self.settings_window, 'camera_settings'): + camera_settings = self.settings_window.camera_settings + if hasattr(camera_settings, 'local_player') and camera_settings.local_player: + # 直接连接本地播放器的信号到相机显示组件 + try: + if hasattr(camera_settings.local_player, 'signal_frame_ready'): + # 先断开可能存在的连接,避免重复 + try: + camera_settings.local_player.signal_frame_ready.disconnect() + except Exception as e: + logging.debug(f"断开信号连接时发生异常(这是正常的): {e}") + + # 建立新的连接 + camera_settings.local_player.signal_frame_ready.connect(self.camera_display.update_local_frame) + logging.warning("====> 已直接连接本地播放器信号到相机显示组件") + + # 确认显示区域已准备好 + self.camera_display._current_pixmap = None + self.camera_display.update() + + # 如果已在播放,确保信号重新连接后再次触发更新 + if camera_settings.is_playing and camera_settings.local_player.last_frame is not None: + QTimer.singleShot(100, lambda: self.camera_display.update_local_frame(camera_settings.local_player.last_frame)) + except Exception as e: + logging.error(f"连接本地播放器信号失败: {e}") + + logging.warning("====> 已切换到本地图像模式,等待本地图像播放") + else: + # 非本地模式下,如果相机已打开则开始显示 + if camera_manager.isOpen: + self.camera_display.start_display() + logging.warning("====> 已切换到实时相机模式") + except Exception as e: + logging.error(f"处理本地图像模式变更时发生错误: {e}") \ No newline at end of file diff --git a/widgets/serial_settings_widget.py b/widgets/serial_settings_widget.py index 0ba1fd5..768b351 100644 --- a/widgets/serial_settings_widget.py +++ b/widgets/serial_settings_widget.py @@ -194,6 +194,18 @@ class SerialSettingsWidget(SerialSettingsUI): if index >= 0: self.xj_parity_combo.setCurrentIndex(index) + # 设置查询指令 + xj_query_cmd = xj_config.get('query_cmd', '01 41 0d') + self.xj_query_cmd.setText(xj_query_cmd) + + # 设置查询间隔 + xj_query_interval = xj_config.get('query_interval', 5) + self.xj_query_interval.setValue(xj_query_interval) + + # 设置自动查询 + xj_auto_query = xj_config.get('auto_query', True) + self.xj_auto_query.setChecked(xj_auto_query) + # 加载扫码器设置 scanner_config = self.config.get_config('scanner') if scanner_config: @@ -272,12 +284,18 @@ class SerialSettingsWidget(SerialSettingsUI): xj_data_bits = int(self.xj_data_bits_combo.currentText()) xj_stop_bits = float(self.xj_stop_bits_combo.currentText()) xj_parity = self.xj_parity_combo.currentData() + xj_query_cmd = self.xj_query_cmd.text().strip() + xj_query_interval = self.xj_query_interval.value() + xj_auto_query = self.xj_auto_query.isChecked() xj_config = { 'port': xj_baud, 'data_bits': xj_data_bits, 'stop_bits': xj_stop_bits, - 'parity': xj_parity + 'parity': xj_parity, + 'query_cmd': xj_query_cmd, + 'query_interval': xj_query_interval, + 'auto_query': xj_auto_query } # 只有当用户选择了串口时才保存串口配置 @@ -394,7 +412,27 @@ class SerialSettingsWidget(SerialSettingsUI): ) if success: - QMessageBox.information(self, "测试成功", f"串口 {port} 打开成功") + # 尝试发送查询指令 + query_cmd = self.xj_query_cmd.text() + if query_cmd: + try: + # 转换查询指令为字节 + cmd_bytes = bytes.fromhex(query_cmd.replace(' ', '')) + self.serial_manager.write_data(port, cmd_bytes) + time.sleep(0.5) # 等待响应 + + # 读取响应 + response = self.serial_manager.read_data(port) + if response: + # 将字节转换为十六进制字符串 + hex_str = ' '.join(f'{b:02X}' for b in response) + QMessageBox.information(self, "测试成功", f"串口打开成功,收到响应:\n{hex_str}") + else: + QMessageBox.information(self, "测试成功", "串口打开成功,但未收到响应") + except Exception as e: + QMessageBox.warning(self, "测试结果", f"串口打开成功,但发送指令失败: {e}") + else: + QMessageBox.information(self, "测试成功", "串口打开成功") # 关闭串口 self.serial_manager.close_port(port)