import os import time import logging import threading import queue from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QGridLayout, QScrollArea, QToolButton, QFrame, QApplication, QCheckBox) from PySide6.QtCore import Qt, QTimer, QSize from PySide6.QtGui import QIcon, QPixmap, QResizeEvent, QKeyEvent, QCloseEvent from .models import * from .rtsp_player import RTSPPlayer from .rfid_client import RFIDClient from .modbus_client import ModbusClient from .mqtt_client import MQTTClient logger = logging.getLogger(__name__) class MainWindow(QMainWindow): # 防抖阈值(连续相同状态次数) STATE_THRESHOLD = 3 # 放包判断位移阈值(像素) RELEASE_DELTA_THRESHOLD = 5 def __init__(self, config: Config): super().__init__() self.config = config self.buckets = config.buckets self.active_bucket: BucketModel = None self.switch_mode = SwitchMode.AUTO self.left_player: RTSPPlayer = None self.right_player: RTSPPlayer = None self.rfid_client = None self.modbus_client = None self.mqtt_client = None self.video_aspect_ratio = 16 / 9 self.left_label = None self.right_label = None # 挂钩状态相关 self.hook_state = HookState.IDLE self.prev_state = HookState.IDLE self.state_counter = 0 # 存储左右画面最近检测对象 self.left_objects = [] self.right_objects = [] # 前一帧 hook/lug 中心位置(用于轨迹判断) self.prev_left_hook = None self.prev_right_hook = None self.prev_left_lug = None self.prev_right_lug = None # 语音引擎(在工作线程内初始化) self.last_voice_state = None self._last_hooking_voice_time = 0.0 self.voice_enabled = True self.ai_enabled = True self._last_left_draw: list = [] self._last_right_draw: list = [] self._voice_queue = queue.Queue() self._voice_thread = threading.Thread(target=self._voice_worker, daemon=True) self._voice_thread.start() self.setWindowTitle(config.app_name) screen = QApplication.primaryScreen() screen_geometry = screen.availableGeometry() screen_width = screen_geometry.width() screen_height = screen_geometry.height() window_width = int(screen_width * 0.9) window_height = int(screen_height * 0.85) x = (screen_width - window_width) // 2 y = (screen_height - window_height) // 2 self.setGeometry(x, y, window_width, window_height) self.setMinimumSize(800, 600) self.setup_ui() self.showFullScreen() self.update_video_size() self.init_hardware() self.timer = QTimer() self.timer.timeout.connect(self.check_idle) self.timer.start(1000) def setup_ui(self): central = QWidget() self.setCentralWidget(central) main_layout = QVBoxLayout(central) main_layout.setContentsMargins(10, 10, 10, 10) main_layout.setSpacing(10) # 标题栏 title_widget = QWidget() title_layout = QHBoxLayout(title_widget) title_layout.setContentsMargins(0, 0, 0, 0) self.title_label = QLabel(self.config.app_name) self.title_label.setStyleSheet("font-size: 22px; font-weight: bold; padding: 5px;") title_layout.addWidget(self.title_label) title_layout.addStretch() btn_style = """ QPushButton { background-color: transparent; border: none; font-size: 18px; padding: 6px 12px; border-radius: 5px; } QPushButton:hover { background-color: rgba(255, 255, 255, 0.1); } """ self.min_btn = QPushButton("—") self.min_btn.setStyleSheet(btn_style) self.min_btn.clicked.connect(self.showMinimized) self.close_btn = QPushButton("✕") self.close_btn.setStyleSheet(btn_style) self.close_btn.clicked.connect(self.close) title_layout.addWidget(self.min_btn) title_layout.addWidget(self.close_btn) main_layout.addWidget(title_widget) # 工位区域 bucket_group = QFrame() bucket_group.setStyleSheet("QFrame { border-radius: 8px; padding: 8px; }") bucket_layout = QVBoxLayout(bucket_group) scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setFrameShape(QFrame.NoFrame) scroll_area.setStyleSheet("QScrollArea { background: transparent; border: none; }") self.bucket_container = QWidget() self.bucket_grid = QGridLayout(self.bucket_container) self.bucket_grid.setSpacing(15) self.bucket_grid.setContentsMargins(10, 10, 10, 10) self.bucket_grid.setAlignment(Qt.AlignLeft | Qt.AlignTop) self.bucket_buttons = [] # 原代码(删除) # for idx, bucket in enumerate(self.buckets): # btn = QToolButton() # ... # row = idx // 6 # col = idx % 6 # self.bucket_grid.addWidget(btn, row, col) # self.bucket_buttons.append((btn, bucket)) # 新代码:按 code_x 分组纵向排列 from collections import defaultdict groups = defaultdict(list) for bucket in self.buckets: groups[bucket.code_x].append(bucket) sorted_keys = sorted(groups.keys()) self.bucket_buttons = [] # 重置 col = 0 for key in sorted_keys: buckets_in_group = groups[key] for row, bucket in enumerate(buckets_in_group): btn = QToolButton() btn.setFixedSize(100, 100) btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) btn.setText(bucket.description) btn.setIcon(self.get_bucket_icon(bucket.is_active)) btn.setIconSize(QSize(64, 64)) btn.setStyleSheet(""" QToolButton { border-radius: 12px; font-size: 14px; font-weight: bold; } """) btn.clicked.connect(lambda checked, b=bucket: self.on_bucket_clicked(b)) self.bucket_grid.addWidget(btn, row, col) self.bucket_buttons.append((btn, bucket)) col += 1 self.bucket_container.setLayout(self.bucket_grid) scroll_area.setWidget(self.bucket_container) bucket_layout.addWidget(scroll_area) main_layout.addWidget(bucket_group) # 视频区域(移除覆盖层,仅保留边框) video_container = QWidget() video_layout = QHBoxLayout(video_container) video_layout.setSpacing(15) video_layout.setContentsMargins(0, 0, 0, 0) self.left_label = QLabel("左侧画面") self.left_label.setScaledContents(False) self.left_label.setAlignment(Qt.AlignCenter) self.left_label.setStyleSheet(""" QLabel { border: 2px solid #dcdfe6; border-radius: 8px; background-color: black; } """) self.right_label = QLabel("右侧画面") self.right_label.setScaledContents(False) self.right_label.setAlignment(Qt.AlignCenter) self.right_label.setStyleSheet(""" QLabel { border: 2px solid #dcdfe6; border-radius: 8px; background-color: black; } """) video_layout.addWidget(self.left_label) video_layout.addWidget(self.right_label) main_layout.addWidget(video_container, 1) # 状态栏 status_widget = QWidget() status_layout = QHBoxLayout(status_widget) status_layout.setContentsMargins(5, 5, 5, 5) self.module_status = QLabel("通讯状态: 初始化") self.module_status.setStyleSheet("color: #606266; font-weight: bold;") self.rfid_tag_label = QLabel("当前卡号: 无") self.rfid_tag_label.setStyleSheet("color: #409eff; font-weight: bold; margin-left: 20px;") self.hook_status_label = QLabel("挂钩状态: 空闲") self.hook_status_label.setStyleSheet("color: #ffa500; font-weight: bold; margin-left: 20px;") self.voice_checkbox = QCheckBox("语音播报") self.voice_checkbox.setChecked(True) self.voice_checkbox.setStyleSheet("color: #606266; font-weight: bold;") self.voice_checkbox.stateChanged.connect(self.on_voice_toggle) self.ai_checkbox = QCheckBox("AI启用") self.ai_checkbox.setChecked(True) self.ai_checkbox.setStyleSheet("color: #606266; font-weight: bold;") self.ai_checkbox.stateChanged.connect(self.on_ai_toggle) self.auto_switch_btn = QPushButton("自动模式") self.auto_switch_btn.setCheckable(True) self.auto_switch_btn.setChecked(True) self.auto_switch_btn.setStyleSheet("border-radius: 15px; padding: 5px 15px; font-weight: bold;") self.auto_switch_btn.clicked.connect(self.toggle_switch_mode) status_layout.addWidget(self.module_status) status_layout.addWidget(self.rfid_tag_label) status_layout.addWidget(self.hook_status_label) status_layout.addStretch() status_layout.addWidget(self.voice_checkbox) status_layout.addWidget(self.ai_checkbox) status_layout.addWidget(self.auto_switch_btn) main_layout.addWidget(status_widget) self.update_video_size() # ---------- 事件与资源管理 ---------- def closeEvent(self, event: QCloseEvent): """窗口关闭时释放资源""" if self.rfid_client: self.rfid_client.disconnect() if self.modbus_client: self.modbus_client.disconnect() if self.mqtt_client: self.mqtt_client.disconnect() if self.left_player: self.left_player.stop() if self.right_player: self.right_player.stop() event.accept() # ---------- 语音控制 ---------- def on_voice_toggle(self, state): self.voice_enabled = (state == Qt.Checked) def on_ai_toggle(self, state): enabled = (state == Qt.Checked) if enabled == self.ai_enabled: return self.ai_enabled = enabled if not enabled: self.hook_state = HookState.IDLE self.prev_state = HookState.IDLE self.state_counter = 0 self.left_objects = [] self.right_objects = [] self.prev_left_hook = None self.prev_right_hook = None self.prev_left_lug = None self.prev_right_lug = None self.update_ui_for_state(HookState.IDLE) if self.left_player: self.left_player.set_ai_objects([]) if self.right_player: self.right_player.set_ai_objects([]) else: # 恢复最近一帧的标注框 if self.left_player and self._last_left_draw: self.left_player.set_ai_objects(self._last_left_draw) if self.right_player and self._last_right_draw: self.right_player.set_ai_objects(self._last_right_draw) def _speak(self, msg: str): self._voice_queue.put(msg) def _voice_worker(self): try: import pythoncom pythoncom.CoInitialize() except ImportError: pass try: import win32com.client as _win32com except ImportError: logging.warning("pywin32 未安装,语音播报不可用") return while True: msg = self._voice_queue.get() if msg is None: break try: speaker = _win32com.Dispatch("SAPI.SpVoice") speaker.Rate = 2 speaker.Speak(msg) except Exception as e: logging.warning(f"语音播报失败: {e}") self._voice_queue.task_done() # ---------- 工位图标 ---------- def get_bucket_icon(self, is_active): icon_dir = os.path.join(os.path.dirname(__file__), "..", "assets") if not os.path.exists(icon_dir): os.makedirs(icon_dir, exist_ok=True) return QIcon() if is_active: icon_path = os.path.join(icon_dir, "bucket_active.svg") else: icon_path = os.path.join(icon_dir, "bucket_normal.svg") if os.path.exists(icon_path): return QIcon(icon_path) else: pixmap = QPixmap(64, 64) pixmap.fill(Qt.green if is_active else Qt.gray) return QIcon(pixmap) def refresh_bucket_icons(self): for btn, bucket in self.bucket_buttons: icon = self.get_bucket_icon(bucket.is_active) btn.setIcon(icon) if bucket.is_active: btn.setStyleSheet(""" QToolButton { background-color: #e1f3d8; border: 2px solid #67c23a; border-radius: 12px; font-size: 14px; font-weight: bold; color: #1f2d3d; } """) else: btn.setStyleSheet(""" QToolButton { background-color: transparent; border: 2px solid #d0d7de; border-radius: 12px; font-size: 14px; font-weight: bold; color: #1f2d3d; } QToolButton:hover { background-color: rgba(0, 0, 0, 0.05); border-color: #409eff; } """) # ---------- 视频尺寸自适应 ---------- def update_video_size(self): if not self.left_label or not self.right_label: return container = self.left_label.parentWidget() if not container: return available_width = container.width() - 15 single_width = available_width // 2 target_height = int(single_width / self.video_aspect_ratio) if target_height > container.height(): target_height = container.height() self.left_label.setFixedSize(single_width, target_height) self.right_label.setFixedSize(single_width, target_height) def update_players_target_size(self): if self.left_player: self.left_player.set_target_size(self.left_label.width(), self.left_label.height()) if self.right_player: self.right_player.set_target_size(self.right_label.width(), self.right_label.height()) def resizeEvent(self, event: QResizeEvent): self.update_video_size() self.update_players_target_size() super().resizeEvent(event) def keyPressEvent(self, event: QKeyEvent): if event.key() == Qt.Key_Escape and self.isFullScreen(): self.showNormal() elif event.key() == Qt.Key_F11: if self.isFullScreen(): self.showNormal() else: self.showFullScreen() else: super().keyPressEvent(event) # ---------- 加载动画和超时 ---------- def show_loading_on_label(self, label: QLabel): label.setText("加载中...") label.setAlignment(Qt.AlignCenter) label.setStyleSheet(label.styleSheet() + "color: white; font-size: 20px;") def hide_loading_on_label(self, label: QLabel): label.setText("") label.setStyleSheet(""" QLabel { border: 2px solid #dcdfe6; border-radius: 8px; background-color: black; } """) def show_timeout_on_label(self, label: QLabel): label.setText("连接超时") label.setAlignment(Qt.AlignCenter) label.setStyleSheet(label.styleSheet() + "color: red; font-size: 20px;") def on_player_timeout(self, label: QLabel, player_ref, side: str): self.show_timeout_on_label(label) if side == "left" and self.left_player: self.left_player.stop() self.left_player = None elif side == "right" and self.right_player: self.right_player.stop() self.right_player = None # ---------- 点击工位 ---------- def on_bucket_clicked(self, bucket: BucketModel): if self.switch_mode != SwitchMode.HAND: self.switch_mode = SwitchMode.HAND self.auto_switch_btn.setChecked(False) self.auto_switch_btn.setText("手动模式") self.activate_bucket(bucket) def on_rfid_state_changed(self, online: bool): if online: self.module_status.setText("通讯状态: RFID正常") else: self.module_status.setText("通讯状态: RFID离线") # ---------- 硬件初始化 ---------- def init_hardware(self): if self.config.module_type == ModuleType.RFID and self.config.rfid: self.rfid_client = RFIDClient( self.config.rfid, self.on_rfid_tag, on_state_changed=self.on_rfid_state_changed, tag_offset=18, tag_length=4, hex_output=True ) if self.rfid_client.connect(): self.module_status.setText("通讯状态: RFID正常") else: self.module_status.setText("通讯状态: RFID离线") if self.config.module_type == ModuleType.HD3200 and self.config.modbus: self.modbus_client = ModbusClient(self.config.modbus, self.on_modbus_data) if self.modbus_client.connect(): self.module_status.setText("通讯状态: Modbus正常") else: self.module_status.setText("通讯状态: Modbus离线") if self.config.ai.enabled: self.mqtt_client = MQTTClient(self.config.ai, self.on_mqtt_message) self.mqtt_client.connect() # ---------- 激活工位 ---------- def activate_bucket(self, bucket: BucketModel): if self.active_bucket == bucket: return if bucket and self.voice_enabled: self._speak(f"到达{bucket.description}") if self.left_player: self.left_player.stop() self.left_player = None if self.right_player: self.right_player.stop() self.right_player = None if self.active_bucket: self.active_bucket.is_active = False self.active_bucket = bucket if bucket: bucket.is_active = True self.refresh_bucket_icons() self.show_loading_on_label(self.left_label) self.show_loading_on_label(self.right_label) # 重置挂钩状态 self.hook_state = HookState.IDLE self.prev_state = HookState.IDLE self.state_counter = 0 self.left_objects = [] self.right_objects = [] self.prev_left_hook = None self.prev_right_hook = None self.prev_left_lug = None self.prev_right_lug = None self.last_voice_state = None self._last_hooking_voice_time = 0.0 if bucket: left_cam = next((c for c in bucket.cameras if c.position == CameraPosition.LEFT), None) right_cam = next((c for c in bucket.cameras if c.position == CameraPosition.RIGHT), None) if left_cam: w = self.left_label.width() h = self.left_label.height() timeout = self.config.video.rtsp_timeout player = RTSPPlayer(left_cam.rtsp_url, w, h, timeout_sec=timeout) player.frame_ready.connect(self.left_label.setPixmap) player.first_frame_received.connect(lambda: self.hide_loading_on_label(self.left_label)) player.timeout.connect(lambda: self.on_player_timeout(self.left_label, player, "left")) self.left_player = player player.start() if right_cam: w = self.right_label.width() h = self.right_label.height() timeout = self.config.video.rtsp_timeout player = RTSPPlayer(right_cam.rtsp_url, w, h, timeout_sec=timeout) player.frame_ready.connect(self.right_label.setPixmap) player.first_frame_received.connect(lambda: self.hide_loading_on_label(self.right_label)) player.timeout.connect(lambda: self.on_player_timeout(self.right_label, player, "right")) self.right_player = player player.start() if self.mqtt_client: topics = [c.topic for c in bucket.cameras if c.topic] if topics: self.mqtt_client.subscribe(topics) else: self.left_label.clear() self.right_label.clear() # ---------- RFID 标签回调 ---------- def on_rfid_tag(self, tag: str): if tag: self.rfid_tag_label.setText(f"当前卡号: {tag}") else: self.rfid_tag_label.setText("当前卡号: 无") if tag is None: if self.switch_mode == SwitchMode.AUTO and self.active_bucket: self.activate_bucket(None) return for bucket in self.buckets: if bucket.code_x == tag: if self.switch_mode == SwitchMode.AUTO: self.activate_bucket(bucket) break # ---------- Modbus 数据回调 ---------- def on_modbus_data(self, bools): for bucket in self.buckets: try: addr = int(bucket.code_x) except: addr = -1 if 0 <= addr < len(bools) and bools[addr]: if self.switch_mode == SwitchMode.AUTO: self.activate_bucket(bucket) break # ---------- MQTT 消息处理 ---------- def parse_ai_objects(self, payload: dict, display_width: int, display_height: int, include_ready: bool = True) -> list: if payload is None or not isinstance(payload, dict): return [] orig_width = payload.get("width", 640) orig_height = payload.get("height", 360) scale_x = display_width / orig_width if orig_width > 0 else 1.0 scale_y = display_height / orig_height if orig_height > 0 else 1.0 objects = [] for obj_str in payload.get("objects", []): parts = obj_str.split('|') if len(parts) >= 6: try: x = int(float(parts[1]) * scale_x) y = int(float(parts[2]) * scale_y) w = int(float(parts[3]) * scale_x) h = int(float(parts[4]) * scale_y) raw_label = parts[5] # 类别映射(hoist→hook, cup→lug) label = raw_label if 'hoist' in raw_label: label = 'hook' elif 'cup' in raw_label: label = 'lug' # 颜色分配(BGR) if 'hook' in label: color = (0, 0, 255) # 红色 elif 'lug' in label: color = (255, 0, 0) # 蓝色 elif 'ready' in label: if not include_ready: continue color = (0, 255, 255) # 黄色(不在画面上显示,仅用于内部) else: color = (128, 128, 128) # 灰色(其他类别) objects.append({ "x": x, "y": y, "w": w, "h": h, "label": label, "raw_label": raw_label, "color": color }) except (ValueError, IndexError): continue return objects def on_mqtt_message(self, topic: str, payload: dict): if payload is None or not isinstance(payload, dict): return if not self.active_bucket: return if not self.ai_enabled: return for cam in self.active_bucket.cameras: if cam.topic == topic: self.update_video_size() if cam.position == CameraPosition.LEFT: display_width = self.left_label.width() display_height = self.left_label.height() else: display_width = self.right_label.width() display_height = self.right_label.height() if display_width == 0 or display_height == 0: display_width = 640 display_height = 360 # 解析所有对象(包括 ready) all_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=True) if cam.position == CameraPosition.LEFT: self.left_objects = all_objects else: self.right_objects = all_objects # 触发状态评估(会标记配对的 hook/lug) self.evaluate_hook_state() # 构造绘制对象(排除 ready,无关目标变淡) draw_objects = [o for o in all_objects if 'ready' not in o['label'].lower()] for obj in draw_objects: if not obj.get('paired'): obj['dimmed'] = True if cam.position == CameraPosition.LEFT: self._last_left_draw = draw_objects if self.left_player: self.left_player.set_ai_objects(draw_objects) else: self._last_right_draw = draw_objects if self.right_player: self.right_player.set_ai_objects(draw_objects) break # ---------- 挂钩状态评估 ---------- def evaluate_hook_state(self): left_hooks = [o for o in self.left_objects if 'hook' in o['label'].lower()] left_lugs = [o for o in self.left_objects if 'lug' in o['label'].lower()] right_hooks = [o for o in self.right_objects if 'hook' in o['label'].lower()] right_lugs = [o for o in self.right_objects if 'lug' in o['label'].lower()] def find_nearest_pair(hooks, lugs): if not hooks or not lugs: return None best_pair = None best_dist = float('inf') for h in hooks: hx = h['x'] + h['w'] / 2 hy = h['y'] + h['h'] / 2 for l in lugs: lx = l['x'] + l['w'] / 2 ly = l['y'] + l['h'] / 2 dist = (hx - lx) ** 2 + (hy - ly) ** 2 if dist < best_dist: best_dist = dist best_pair = (h, l) return best_pair left_pair = find_nearest_pair(left_hooks, left_lugs) right_pair = find_nearest_pair(right_hooks, right_lugs) is_releasing = False # 处理左侧配对 if left_pair: h, l = left_pair if self.prev_left_hook and self.prev_left_lug: dy_h = (h['y'] + h['h'] / 2) - self.prev_left_hook[1] dy_l = (l['y'] + l['h'] / 2) - self.prev_left_lug[1] if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD: is_releasing = True self.prev_left_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2) self.prev_left_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2) else: self.prev_left_hook = None self.prev_left_lug = None # 处理右侧配对 if right_pair: h, l = right_pair if self.prev_right_hook and self.prev_right_lug: dy_h = (h['y'] + h['h'] / 2) - self.prev_right_hook[1] dy_l = (l['y'] + l['h'] / 2) - self.prev_right_lug[1] if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD: is_releasing = True self.prev_right_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2) self.prev_right_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2) else: self.prev_right_hook = None self.prev_right_lug = None # 状态判断 if is_releasing: new_state = HookState.RELEASING else: any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects) has_intersecting_pair = self._has_intersecting_pair(left_pair, right_pair) if any_ready and has_intersecting_pair: new_state = HookState.READY elif has_intersecting_pair: new_state = HookState.HOOKING else: new_state = HookState.IDLE # 防抖(非对称:进入非空闲状态需 STATE_THRESHOLD 帧确认,回到空闲只需 1 帧) threshold = 1 if new_state == HookState.IDLE else self.STATE_THRESHOLD if new_state == self.prev_state: self.state_counter += 1 else: self.prev_state = new_state self.state_counter = 1 if self.state_counter >= threshold: if new_state != self.hook_state: self.hook_state = new_state self.update_ui_for_state(new_state) self.state_counter = 0 # 标记配对的 hook/lug(用于绘制时变淡无关目标) for obj in self.left_objects + self.right_objects: obj.pop('paired', None) if left_pair: left_pair[0]['paired'] = True left_pair[1]['paired'] = True if right_pair: right_pair[0]['paired'] = True right_pair[1]['paired'] = True def rects_intersect(self, a, b): ax1, ay1, ax2, ay2 = a['x'], a['y'], a['x']+a['w'], a['y']+a['h'] bx1, by1, bx2, by2 = b['x'], b['y'], b['x']+b['w'], b['y']+b['h'] return not (ax2 < bx1 or bx2 < ax1 or ay2 < by1 or by2 < ay1) def _has_intersecting_pair(self, left_pair, right_pair): if left_pair: h, l = left_pair if self.rects_intersect(h, l): return True if right_pair: h, l = right_pair if self.rects_intersect(h, l): return True return False # ---------- UI 更新与语音 ---------- def update_ui_for_state(self, state): if state == HookState.IDLE: border_color = "#dcdfe6" text = "空闲" elif state == HookState.RELEASING: border_color = "#ffa500" text = "放包中" elif state == HookState.HOOKING: border_color = "#ff0000" text = "挂包中" elif state == HookState.READY: border_color = "#00ff00" text = "挂包成功" else: border_color = "#dcdfe6" text = "" # 更新状态栏标签 self.hook_status_label.setText(f"挂钩状态: {text}") # 更新视频边框 style = f""" QLabel {{ border: 4px solid {border_color}; border-radius: 8px; background-color: black; }} """ self.left_label.setStyleSheet(style) self.right_label.setStyleSheet(style) # 语音播报(带开关和防重复) if self.voice_enabled and state in (HookState.HOOKING, HookState.READY): now = time.time() if state == HookState.HOOKING: if state != self.last_voice_state or now - self._last_hooking_voice_time > 15: self._speak("挂包中") self.last_voice_state = state self._last_hooking_voice_time = now elif state != self.last_voice_state: self._speak("挂包成功") self.last_voice_state = state # ---------- 定时任务 ---------- def check_idle(self): if self.rfid_client: self.rfid_client.check_idle() def toggle_switch_mode(self, checked): if checked: self.switch_mode = SwitchMode.AUTO self.auto_switch_btn.setText("自动模式") else: self.switch_mode = SwitchMode.HAND self.auto_switch_btn.setText("手动模式")