Forráskód Böngészése

refactor: 多 AI 模块支持、MQTT 自动重订阅、RTSP 等比缩放、开机自启 & 桌面快捷方式

- config_loader: 自动扫描 AI:N 段,不再依赖 module_count
- mqtt_client: 异步连接状态 + _pending_topics 断线自动重订阅
- main_window: 移除 config.ai 死节点引用,MQTT 优先于 RTSP
- rtsp_player: 保持原始宽高比缩放 + AI 坐标映射修复
- main_window: 摄像头信号检测 (ping) + 信号指示图标
- main_window: READY 状态非对称防抖阈值 15 帧
- tools/png_to_ico: 无依赖 PNG→ICO 转换
- tools/install_shortcut: 桌面快捷方式 + 注册表开机自启
- build_exe.bat: logo.ico 打包、pywin32 隐式导入
lsfoo 3 hónapja
szülő
commit
a24d2691f6

BIN
assets/logo.ico


BIN
assets/logo.png


+ 15 - 0
build_exe.bat

@@ -0,0 +1,15 @@
+@echo off
+rmdir /s /q dist build 2>nul
+
+REM Step 1: Convert logo.png to .ico
+echo === Converting logo to .ico ===
+python tools\png_to_ico.py assets\logo.png assets\logo.ico
+if %errorlevel% neq 0 exit /b %errorlevel%
+
+REM Step 2: Build the executable
+echo === Building HookVision.exe ===
+pyinstaller --noconsole --name=HookVision --icon="assets\logo.ico" --paths="." --additional-hooks-dir="hooks" --add-data="config.ini;." --add-data="assets;assets" --add-data="hookvision;hookvision" --hidden-import=av --hidden-import=pymodbus --hidden-import=pymodbus.client --hidden-import=paho.mqtt --hidden-import=paho.mqtt.client --hidden-import=cv2 --hidden-import=qt_material --hidden-import=configparser --hidden-import=numpy --hidden-import=win32com --hidden-import=pythoncom --collect-all=pywin32 main.py
+if %errorlevel% neq 0 exit /b %errorlevel%
+
+echo === Build complete! Run the following to install shortcuts: ===
+echo     python tools\install_shortcut.py

+ 75 - 14
config.ini

@@ -1,5 +1,5 @@
 [App]
-name = 钢包工位视频监测系统
+name = 恒达钢包工位视频监测系统
 copyright = 恒莱达
 screen = 0
 
@@ -11,7 +11,7 @@ module_type = RFID
 # module_type = HD3200
 
 [RFID]
-ip = 192.168.1.250
+ip = 192.168.100.113
 port = 60000
 stability_time = 5
 stability_count = 3
@@ -28,6 +28,32 @@ broker = 192.168.1.248
 port = 18830
 topics =
     ds/channel/#
+module_count = 2
+
+[AI:0]
+broker = 192.168.100.144
+port = 18830
+topics =
+    ds/channel/#
+
+[AI:1]
+broker = 192.168.100.101
+port = 18830
+topics =
+    ds/channel/#
+
+[AI:2]
+broker = 192.168.100.102
+port = 18830
+topics =
+    ds/channel/#
+
+[AI:3]
+broker = 192.168.100.100
+port = 18830
+topics =
+    ds/channel/#
+
 
 [NVR]
 count = 1
@@ -44,20 +70,55 @@ count = 2
 
 [Bucket:0]
 id = 1
-description = 1号钢包位
-code_x = 10000001
-code_y = 1
-left_rtsp = rtsp://admin:hd135792468@192.168.1.124:554/Streaming/Channels/101
-right_rtsp = rtsp://admin:hd135792468@192.168.1.125:554/Streaming/Channels/101
+description = 1号工位
+code_x = 00000001
+code_y = 
+ai_module = 0
+left_rtsp = rtsp://admin:hd135792468@192.168.100.122:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.100.117:554/Streaming/Channels/101
 left_topic = ds/channel/0
 right_topic = ds/channel/1
 
 [Bucket:1]
 id = 2
-description = 2号钢包位
-code_x = 10000001
-code_y =2
-left_rtsp = rtsp://admin:12345@192.168.1.64:554/Streaming/Channels/201
-right_rtsp = rtsp://admin:12345@192.168.1.64:554/Streaming/Channels/202
-left_topic = ladle/left/cam2
-right_topic = ladle/right/cam2
+description = 2号工位
+code_x = 00000002
+code_y =
+ai_module = 0
+left_rtsp = rtsp://admin:hd135792468@192.168.100.143:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.100.142:554/Streaming/Channels/101
+left_topic = ds/channel/3
+right_topic = ds/channel/2
+
+[Bucket:2]
+id = 3
+description = 3号工位
+code_x = 00000003
+code_y =
+ai_module = 1
+left_rtsp = rtsp://admin:hd135792468@192.168.100.120:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.100.119:554/Streaming/Channels/101
+left_topic = ds/channel/0
+right_topic = ds/channel/1
+
+[Bucket:3]
+id = 4
+description = 4号工位
+code_x = 00000004
+code_y =
+ai_module = 2
+left_rtsp = rtsp://admin:hd135792468@192.168.100.115:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.100.116:554/Streaming/Channels/101
+left_topic = ds/channel/0
+right_topic = ds/channel/1
+
+[Bucket:4]
+id = 5
+description = 5号工位
+code_x = 00000005
+code_y =
+ai_module = 3
+left_rtsp = rtsp://admin:hd135792468@192.168.100.118:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.100.121:554/Streaming/Channels/101
+left_topic = ds/channel/0
+right_topic = ds/channel/1

+ 27 - 0
hooks/hook-hookvision.py

@@ -0,0 +1,27 @@
+import sys
+import os
+
+_project_root = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
+if _project_root not in sys.path:
+    sys.path.insert(0, _project_root)
+
+_hookvision_dir = os.path.join(_project_root, 'hookvision')
+
+hiddenimports = []
+
+if os.path.isdir(_hookvision_dir):
+    from os import listdir
+    for fname in sorted(listdir(_hookvision_dir)):
+        if fname.endswith('.py') and fname != '__init__.py':
+            hiddenimports.append('hookvision.' + fname[:-3])
+
+    if not hiddenimports:
+        hiddenimports = [
+            'hookvision.models',
+            'hookvision.config_loader',
+            'hookvision.main_window',
+            'hookvision.mqtt_client',
+            'hookvision.modbus_client',
+            'hookvision.rfid_client',
+            'hookvision.rtsp_player',
+        ]

+ 5 - 0
hooks/hook-win32com.py

@@ -0,0 +1,5 @@
+from PyInstaller.utils.hooks import collect_submodules, collect_data_files
+
+hiddenimports = collect_submodules('win32com')
+hiddenimports += ['pythoncom', 'win32com.client']
+datas = collect_data_files('win32com')

+ 7 - 0
hookvision/__init__.py

@@ -0,0 +1,7 @@
+import sys
+import os
+
+_pkg_dir = os.path.dirname(os.path.abspath(__file__))
+_parent = os.path.dirname(_pkg_dir)
+if _parent not in sys.path:
+    sys.path.insert(0, _parent)

+ 24 - 5
hookvision/config_loader.py

@@ -43,6 +43,21 @@ def load_config(config_path: str) -> Config:
         topics_str = cfg.get("AI", "topics", fallback="")
         config.ai.topics = [t.strip() for t in topics_str.split('\n') if t.strip()]
 
+    # AI Modules (自动扫描 AI:N 段,无需依赖 module_count)
+    import re
+    for section in sorted(
+        (s for s in cfg.sections() if re.match(r'^AI:\d+$', s)),
+        key=lambda s: int(s.split(':')[1])
+    ):
+        idx = int(section.split(':')[1])
+        topics_str = cfg.get(section, "topics", fallback="")
+        config.ai_modules.append(AIModuleModel(
+            id=idx,
+            broker=cfg.get(section, "broker"),
+            port=cfg.getint(section, "port", fallback=1883),
+            topics=[t.strip() for t in topics_str.split('\n') if t.strip()]
+        ))
+
     # NVRs
     nvr_count = cfg.getint("NVR", "count", fallback=0)
     for i in range(nvr_count):
@@ -57,15 +72,19 @@ def load_config(config_path: str) -> Config:
         )
         config.nvrs.append(nvr)
 
-    # Buckets
-    bucket_count = cfg.getint("Bucket", "count", fallback=0)
-    for i in range(bucket_count):
-        section = f"Bucket:{i}"
+    # Buckets (自动扫描 Bucket:N 段,无需依赖 count)
+    import re
+    bucket_sections = sorted(
+        (s for s in cfg.sections() if re.match(r'^Bucket:\d+$', s)),
+        key=lambda s: int(s.split(':')[1])
+    )
+    for section in bucket_sections:
         bucket = BucketModel(
             id=cfg.getint(section, "id"),
             description=cfg.get(section, "description"),
             code_x=cfg.get(section, "code_x"),
-            code_y=cfg.get(section, "code_y", fallback="")
+            code_y=cfg.get(section, "code_y", fallback=""),
+            ai_module_id=cfg.getint(section, "ai_module", fallback=None)
         )
         left_rtsp = cfg.get(section, "left_rtsp")
         right_rtsp = cfg.get(section, "right_rtsp")

+ 465 - 61
hookvision/main_window.py

@@ -1,12 +1,17 @@
 import os
+import sys
 import time
+import re
 import logging
 import threading
 import queue
+import subprocess
+from collections import defaultdict
 from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
                                QPushButton, QLabel, QGridLayout, QScrollArea,
-                               QToolButton, QFrame, QApplication, QCheckBox)
-from PySide6.QtCore import Qt, QTimer, QSize
+                               QToolButton, QFrame, QApplication, QCheckBox,
+                               QMessageBox, QSlider)
+from PySide6.QtCore import Qt, QTimer, QSize, Signal
 from PySide6.QtGui import QIcon, QPixmap, QResizeEvent, QKeyEvent, QCloseEvent
 from .models import *
 from .rtsp_player import RTSPPlayer
@@ -21,6 +26,15 @@ class MainWindow(QMainWindow):
     STATE_THRESHOLD = 3
     # 放包判断位移阈值(像素)
     RELEASE_DELTA_THRESHOLD = 5
+    APPTITLE = "HookVision"
+
+    # 跨线程信号
+    rfid_tag_signal = Signal(object)
+    rfid_state_signal = Signal(object)
+    modbus_data_signal = Signal(object)
+    mqtt_message_signal = Signal(str, object)
+    voice_signal = Signal(str)
+    camera_signal_signal = Signal(dict)
 
     def __init__(self, config: Config):
         super().__init__()
@@ -28,6 +42,8 @@ class MainWindow(QMainWindow):
         self.buckets = config.buckets
         self.active_bucket: BucketModel = None
         self.switch_mode = SwitchMode.AUTO
+        self._subscribed_topics: set = set()
+        self._current_ai_module_id: Optional[int] = None
 
         self.left_player: RTSPPlayer = None
         self.right_player: RTSPPlayer = None
@@ -35,6 +51,7 @@ class MainWindow(QMainWindow):
         self.rfid_client = None
         self.modbus_client = None
         self.mqtt_client = None
+        self._last_rfid_tag: Optional[str] = None
 
         self.video_aspect_ratio = 16 / 9
         self.left_label = None
@@ -58,6 +75,7 @@ class MainWindow(QMainWindow):
         self.last_voice_state = None
         self._last_hooking_voice_time = 0.0
         self.voice_enabled = True
+        self._voice_volume = 100
         self.ai_enabled = True
         self._last_left_draw: list = []
         self._last_right_draw: list = []
@@ -65,7 +83,30 @@ class MainWindow(QMainWindow):
         self._voice_thread = threading.Thread(target=self._voice_worker, daemon=True)
         self._voice_thread.start()
 
+        # RTSP 退避重连
+        self._rtsp_retry_left: Optional[QTimer] = None
+        self._rtsp_retry_right: Optional[QTimer] = None
+        self._rtsp_retry_delay_left: int = 1
+        self._rtsp_retry_delay_right: int = 1
+        self._last_mqtt_connect_attempt: float = 0.0
+
+        # 摄像头信号监控
+        self._camera_signal: dict = {}  # ip → 延迟(ms), -1=不可达
+        self._bucket_signal_labels: dict = {}  # bucket.id → QLabel
+        self._last_signal_check: float = 0.0
+
+        # 连接跨线程信号到主线程槽
+        self.rfid_tag_signal.connect(self._on_rfid_tag_impl)
+        self.rfid_state_signal.connect(self._on_rfid_state_impl)
+        self.modbus_data_signal.connect(self._on_modbus_data_impl)
+        self.mqtt_message_signal.connect(self._on_mqtt_message_impl)
+        self.voice_signal.connect(self._speak_impl)
+        self.camera_signal_signal.connect(self._on_camera_signal)
+
         self.setWindowTitle(config.app_name)
+        logo_path = os.path.join(os.path.dirname(__file__), "..", "assets", "logo.png")
+        if os.path.exists(logo_path):
+            self.setWindowIcon(QIcon(logo_path))
 
         screen = QApplication.primaryScreen()
         screen_geometry = screen.availableGeometry()
@@ -99,6 +140,13 @@ class MainWindow(QMainWindow):
         title_widget = QWidget()
         title_layout = QHBoxLayout(title_widget)
         title_layout.setContentsMargins(0, 0, 0, 0)
+        logo_path = os.path.join(os.path.dirname(__file__), "..", "assets", "logo.png")
+        if os.path.exists(logo_path):
+            logo_label = QLabel()
+            logo_pixmap = QPixmap(logo_path).scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation)
+            logo_label.setPixmap(logo_pixmap)
+            logo_label.setFixedSize(28, 28)
+            title_layout.addWidget(logo_label)
         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)
@@ -119,11 +167,16 @@ class MainWindow(QMainWindow):
         self.min_btn = QPushButton("—")
         self.min_btn.setStyleSheet(btn_style)
         self.min_btn.clicked.connect(self.showMinimized)
+        self.fullscreen_btn = QPushButton("⛶")
+        self.fullscreen_btn.setStyleSheet(btn_style)
+        self.fullscreen_btn.setToolTip("全屏 / 还原")
+        self.fullscreen_btn.clicked.connect(self._toggle_fullscreen)
         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.fullscreen_btn)
         title_layout.addWidget(self.close_btn)
         main_layout.addWidget(title_widget)
 
@@ -143,30 +196,23 @@ class MainWindow(QMainWindow):
         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
-
+        # 按 code_x 分组纵向排列
         groups = defaultdict(list)
         for bucket in self.buckets:
             groups[bucket.code_x].append(bucket)
 
         sorted_keys = sorted(groups.keys())
-        self.bucket_buttons = []  # 重置
+        self.bucket_buttons = []
 
         col = 0
         for key in sorted_keys:
             buckets_in_group = groups[key]
             for row, bucket in enumerate(buckets_in_group):
+                cell = QWidget()
+                cell_layout = QVBoxLayout(cell)
+                cell_layout.setContentsMargins(0, 0, 0, 0)
+                cell_layout.setSpacing(2)
+
                 btn = QToolButton()
                 btn.setFixedSize(100, 100)
                 btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
@@ -181,17 +227,28 @@ class MainWindow(QMainWindow):
                     }
                 """)
                 btn.clicked.connect(lambda checked, b=bucket: self.on_bucket_clicked(b))
-                self.bucket_grid.addWidget(btn, row, col)
+                cell_layout.addWidget(btn)
+
+                # 摄像头信号指示
+                has_rtsp = any(c.rtsp_url for c in bucket.cameras)
+                sig_label = QLabel("" if not has_rtsp else "● --")
+                sig_label.setAlignment(Qt.AlignCenter)
+                sig_label.setFixedHeight(18)
+                if has_rtsp:
+                    sig_label.setStyleSheet("font-size: 12px; color: #909399;")
+                cell_layout.addWidget(sig_label)
+                self._bucket_signal_labels[bucket.id] = sig_label
+
+                self.bucket_grid.addWidget(cell, 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)
@@ -240,31 +297,50 @@ class MainWindow(QMainWindow):
         self.voice_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
         self.voice_checkbox.stateChanged.connect(self.on_voice_toggle)
 
+        self.volume_slider = QSlider(Qt.Horizontal)
+        self.volume_slider.setRange(0, 100)
+        self.volume_slider.setValue(self._voice_volume)
+        self.volume_slider.setFixedWidth(80)
+        self.volume_slider.setToolTip(f"音量: {self._voice_volume}%")
+        self.volume_slider.valueChanged.connect(self._on_volume_changed)
+        self.volume_slider.setStyleSheet("""
+            QSlider::groove:horizontal { height: 4px; background: #dcdfe6; border-radius: 2px; }
+            QSlider::handle:horizontal { width: 12px; margin: -4px 0; border-radius: 6px; background: #409eff; }
+            QSlider::sub-page:horizontal { background: #409eff; border-radius: 2px; }
+        """)
+
         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 = QCheckBox("自动模式")
         self.auto_switch_btn.setChecked(True)
-        self.auto_switch_btn.setStyleSheet("border-radius: 15px; padding: 5px 15px; font-weight: bold;")
+        self.auto_switch_btn.setStyleSheet("color: #606266; font-weight: bold;")
         self.auto_switch_btn.clicked.connect(self.toggle_switch_mode)
 
+        self.autostart_checkbox = QCheckBox("开机自启")
+        self.autostart_checkbox.setChecked(self._read_autostart_flag())
+        self.autostart_checkbox.setStyleSheet("color: #606266; font-weight: bold; margin-left: 10px;")
+        self.autostart_checkbox.stateChanged.connect(self._on_autostart_toggle)
+
         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.volume_slider)
         status_layout.addWidget(self.ai_checkbox)
         status_layout.addWidget(self.auto_switch_btn)
+        status_layout.addWidget(self.autostart_checkbox)
         main_layout.addWidget(status_widget)
 
         self.update_video_size()
 
     # ---------- 事件与资源管理 ----------
     def closeEvent(self, event: QCloseEvent):
-        """窗口关闭时释放资源"""
+        self._cancel_rtsp_retry("left")
+        self._cancel_rtsp_retry("right")
         if self.rfid_client:
             self.rfid_client.disconnect()
         if self.modbus_client:
@@ -281,11 +357,23 @@ class MainWindow(QMainWindow):
     def on_voice_toggle(self, state):
         self.voice_enabled = (state == Qt.Checked)
 
+    def _on_volume_changed(self, value: int):
+        self._voice_volume = value
+        self.volume_slider.setToolTip(f"音量: {value}%")
+
     def on_ai_toggle(self, state):
         enabled = (state == Qt.Checked)
         if enabled == self.ai_enabled:
             return
         self.ai_enabled = enabled
+        if enabled and not self.mqtt_client:
+            self.mqtt_client = MQTTClient(self.on_mqtt_message)
+            self._connect_current_ai()
+        elif not enabled and self.mqtt_client:
+            self.mqtt_client.disconnect()
+            self.mqtt_client = None
+            self._subscribed_topics.clear()
+            self._current_ai_module_id = None
         if not enabled:
             self.hook_state = HookState.IDLE
             self.prev_state = HookState.IDLE
@@ -302,20 +390,22 @@ class MainWindow(QMainWindow):
             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_signal.emit(msg)
+
+    def _speak_impl(self, msg: str):
         self._voice_queue.put(msg)
 
     def _voice_worker(self):
         try:
             import pythoncom
             pythoncom.CoInitialize()
-        except ImportError:
+        except Exception:
             pass
         try:
             import win32com.client as _win32com
@@ -329,6 +419,7 @@ class MainWindow(QMainWindow):
             try:
                 speaker = _win32com.Dispatch("SAPI.SpVoice")
                 speaker.Rate = 2
+                speaker.Volume = self._voice_volume
                 speaker.Speak(msg)
             except Exception as e:
                 logging.warning(f"语音播报失败: {e}")
@@ -419,6 +510,12 @@ class MainWindow(QMainWindow):
         else:
             super().keyPressEvent(event)
 
+    def _toggle_fullscreen(self):
+        if self.isFullScreen():
+            self.showNormal()
+        else:
+            self.showFullScreen()
+
     # ---------- 加载动画和超时 ----------
     def show_loading_on_label(self, label: QLabel):
         label.setText("加载中...")
@@ -442,12 +539,80 @@ class MainWindow(QMainWindow):
 
     def on_player_timeout(self, label: QLabel, player_ref, side: str):
         self.show_timeout_on_label(label)
-        if side == "left" and self.left_player:
+        # 只停止当前活动的播放器(防止旧信号误杀新播放器)
+        if side == "left" and self.left_player is player_ref:
             self.left_player.stop()
             self.left_player = None
-        elif side == "right" and self.right_player:
+        elif side == "right" and self.right_player is player_ref:
             self.right_player.stop()
             self.right_player = None
+        self._start_rtsp_retry(side)
+
+    def _cancel_rtsp_retry(self, side: str):
+        timer = self._rtsp_retry_left if side == "left" else self._rtsp_retry_right
+        if timer:
+            timer.stop()
+            timer.deleteLater()
+        if side == "left":
+            self._rtsp_retry_left = None
+            self._rtsp_retry_delay_left = 1
+        else:
+            self._rtsp_retry_right = None
+            self._rtsp_retry_delay_right = 1
+
+    def _start_rtsp_retry(self, side: str):
+        if not self.active_bucket:
+            return
+        cam = next((c for c in self.active_bucket.cameras if c.position == (CameraPosition.LEFT if side == "left" else CameraPosition.RIGHT)), None)
+        if not cam:
+            return
+        label = self.left_label if side == "left" else self.right_label
+        delay = self._rtsp_retry_delay_left if side == "left" else self._rtsp_retry_delay_right
+        label.setText(f"正在重连({delay}s)...")
+        label.setAlignment(Qt.AlignCenter)
+        label.setStyleSheet(label.styleSheet() + "color: orange; font-size: 20px;")
+        timer = QTimer(self)
+        timer.setSingleShot(True)
+        timer.timeout.connect(lambda: self._retry_rtsp(side))
+        timer.start(delay * 1000)
+        if side == "left":
+            self._rtsp_retry_left = timer
+        else:
+            self._rtsp_retry_right = timer
+
+    def _retry_rtsp(self, side: str):
+        if not self.active_bucket:
+            return
+        cam = next((c for c in self.active_bucket.cameras if c.position == (CameraPosition.LEFT if side == "left" else CameraPosition.RIGHT)), None)
+        if not cam:
+            return
+        label = self.left_label if side == "left" else self.right_label
+        self.show_loading_on_label(label)
+        w, h = label.width(), label.height()
+        timeout = self.config.video.rtsp_timeout
+        player = RTSPPlayer(cam.rtsp_url, w, h, timeout_sec=timeout)
+        player.frame_ready.connect(label.setPixmap)
+        player.first_frame_received.connect(lambda: self._on_rtsp_retry_success(side))
+        player.timeout.connect(lambda: self.on_player_timeout(label, player, side))
+        if side == "left":
+            if self.left_player:
+                self.left_player.stop()
+            self.left_player = player
+        else:
+            if self.right_player:
+                self.right_player.stop()
+            self.right_player = player
+        player.start()
+
+    def _on_rtsp_retry_success(self, side: str):
+        self.hide_loading_on_label(self.left_label if side == "left" else self.right_label)
+        self._cancel_rtsp_retry(side)
+        # 重连成功后恢复 AI 对象标注
+        if self.active_bucket and self.ai_enabled:
+            objects = self._last_left_draw if side == "left" else self._last_right_draw
+            player = self.left_player if side == "left" else self.right_player
+            if player:
+                player.set_ai_objects(objects)
 
     # ---------- 点击工位 ----------
     def on_bucket_clicked(self, bucket: BucketModel):
@@ -458,6 +623,9 @@ class MainWindow(QMainWindow):
         self.activate_bucket(bucket)
 
     def on_rfid_state_changed(self, online: bool):
+        self.rfid_state_signal.emit(online)
+
+    def _on_rfid_state_impl(self, online: bool):
         if online:
             self.module_status.setText("通讯状态: RFID正常")
         else:
@@ -486,13 +654,34 @@ class MainWindow(QMainWindow):
             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()
+            self.mqtt_client = MQTTClient(self.on_mqtt_message)
+            self._connect_current_ai()
+
+    def _connect_current_ai(self, bucket=None):
+        if bucket is None:
+            bucket = self.active_bucket
+        idx = None
+        if bucket and bucket.ai_module_id is not None:
+            idx = bucket.ai_module_id
+        elif self.config.ai_modules:
+            idx = 0
+        if idx is not None and idx < len(self.config.ai_modules):
+            target = self.config.ai_modules[idx]
+            self.mqtt_client.disconnect()
+            self.mqtt_client.connect(target.broker, target.port)
+            self._current_ai_module_id = idx
+            if bucket:
+                topics = [c.topic for c in bucket.cameras if c.topic]
+                if topics:
+                    self.mqtt_client.subscribe(topics)
+                    self._subscribed_topics = set(topics)
 
     # ---------- 激活工位 ----------
     def activate_bucket(self, bucket: BucketModel):
         if self.active_bucket == bucket:
             return
+        self._cancel_rtsp_retry("left")
+        self._cancel_rtsp_retry("right")
         if bucket and self.voice_enabled:
             self._speak(f"到达{bucket.description}")
         if self.left_player:
@@ -523,8 +712,26 @@ class MainWindow(QMainWindow):
         self.prev_right_lug = None
         self.last_voice_state = None
         self._last_hooking_voice_time = 0.0
+        self.update_ui_for_state(HookState.IDLE)
+
+        # 先切 AI 模块 + 订阅 MQTT topic(非阻塞),再启 RTSP
+        if bucket and self.config.ai.enabled and self.mqtt_client:
+            if bucket.ai_module_id != self._current_ai_module_id:
+                self._unsubscribe_all_topics()
+                self._connect_current_ai(bucket)
 
         if bucket:
+            if self.mqtt_client:
+                topics = [c.topic for c in bucket.cameras if c.topic]
+                stale = self._subscribed_topics - set(topics)
+                for t in stale:
+                    self.mqtt_client.unsubscribe(t)
+                new_t = [t for t in topics if t not in self._subscribed_topics]
+                if new_t:
+                    self.mqtt_client.subscribe(new_t)
+                self._subscribed_topics = set(topics)
+
+            # 再启 RTSP
             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)
 
@@ -549,17 +756,24 @@ class MainWindow(QMainWindow):
                 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._unsubscribe_all_topics()
             self.left_label.clear()
             self.right_label.clear()
+            QApplication.processEvents()
+
+    def _unsubscribe_all_topics(self):
+        if self.mqtt_client and self._subscribed_topics:
+            for t in self._subscribed_topics:
+                self.mqtt_client.unsubscribe(t)
+            self._subscribed_topics.clear()
 
     # ---------- RFID 标签回调 ----------
     def on_rfid_tag(self, tag: str):
+        self.rfid_tag_signal.emit(tag)
+
+    def _on_rfid_tag_impl(self, tag: str):
+        self._last_rfid_tag = tag
         if tag:
             self.rfid_tag_label.setText(f"当前卡号: {tag}")
         else:
@@ -577,15 +791,32 @@ class MainWindow(QMainWindow):
 
     # ---------- Modbus 数据回调 ----------
     def on_modbus_data(self, bools):
+        self.modbus_data_signal.emit(bools)
+
+    def _on_modbus_data_impl(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
+                addr_x = int(bucket.code_x)
+            except (ValueError, TypeError):
+                continue
+            if addr_x < 0 or addr_x >= len(bools):
+                continue
+            if bucket.code_y and bucket.code_y.strip():
+                try:
+                    addr_y = int(bucket.code_y)
+                except (ValueError, TypeError):
+                    continue
+                if addr_y < 0 or addr_y >= len(bools):
+                    continue
+                if bools[addr_x] and bools[addr_y]:
+                    if self.switch_mode == SwitchMode.AUTO and self.active_bucket != bucket:
+                        self.activate_bucket(bucket)
+                        break
+            else:
+                if bools[addr_x]:
+                    if self.switch_mode == SwitchMode.AUTO and self.active_bucket != bucket:
+                        self.activate_bucket(bucket)
+                        break
 
     # ---------- MQTT 消息处理 ----------
     def parse_ai_objects(self, payload: dict, display_width: int, display_height: int,
@@ -606,23 +837,21 @@ class MainWindow(QMainWindow):
                     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)   # 红色
+                        color = (0, 0, 255)
                     elif 'lug' in label:
-                        color = (255, 0, 0)   # 蓝色
+                        color = (255, 0, 0)
                     elif 'ready' in label:
                         if not include_ready:
                             continue
-                        color = (0, 255, 255) # 黄色(不在画面上显示,仅用于内部)
+                        color = (0, 255, 255)
                     else:
-                        color = (128, 128, 128) # 灰色(其他类别)
+                        color = (128, 128, 128)
                     objects.append({
                         "x": x,
                         "y": y,
@@ -637,6 +866,9 @@ class MainWindow(QMainWindow):
         return objects
 
     def on_mqtt_message(self, topic: str, payload: dict):
+        self.mqtt_message_signal.emit(topic, payload)
+
+    def _on_mqtt_message_impl(self, topic: str, payload: dict):
         if payload is None or not isinstance(payload, dict):
             return
         if not self.active_bucket:
@@ -656,7 +888,6 @@ class MainWindow(QMainWindow):
                     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:
@@ -664,10 +895,8 @@ class MainWindow(QMainWindow):
                 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'):
@@ -712,7 +941,6 @@ class MainWindow(QMainWindow):
 
         is_releasing = False
 
-        # 处理左侧配对
         if left_pair:
             h, l = left_pair
             if self.prev_left_hook and self.prev_left_lug:
@@ -726,7 +954,6 @@ class MainWindow(QMainWindow):
             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:
@@ -740,7 +967,6 @@ class MainWindow(QMainWindow):
             self.prev_right_hook = None
             self.prev_right_lug = None
 
-        # 状态判断
         if is_releasing:
             new_state = HookState.RELEASING
         else:
@@ -753,8 +979,10 @@ class MainWindow(QMainWindow):
             else:
                 new_state = HookState.IDLE
 
-        # 防抖(非对称:进入非空闲状态需 STATE_THRESHOLD 帧确认,回到空闲只需 1 帧)
-        threshold = 1 if new_state == HookState.IDLE else self.STATE_THRESHOLD
+        if self.hook_state == HookState.READY and new_state != HookState.READY:
+            threshold = 15
+        else:
+            threshold = 1 if new_state == HookState.IDLE else self.STATE_THRESHOLD
         if new_state == self.prev_state:
             self.state_counter += 1
         else:
@@ -767,7 +995,6 @@ class MainWindow(QMainWindow):
                 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:
@@ -811,10 +1038,8 @@ class MainWindow(QMainWindow):
             border_color = "#dcdfe6"
             text = ""
 
-        # 更新状态栏标签
         self.hook_status_label.setText(f"挂钩状态: {text}")
 
-        # 更新视频边框
         style = f"""
             QLabel {{
                 border: 4px solid {border_color};
@@ -825,7 +1050,6 @@ class MainWindow(QMainWindow):
         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:
@@ -841,11 +1065,191 @@ class MainWindow(QMainWindow):
     def check_idle(self):
         if self.rfid_client:
             self.rfid_client.check_idle()
+        now = time.time()
+        if self.config.ai.enabled and self.mqtt_client and not self.mqtt_client.connected:
+            if now - self._last_mqtt_connect_attempt > 30:
+                self._last_mqtt_connect_attempt = now
+                self._connect_current_ai()
+
+        # 每 30 秒检测一次摄像头信号
+        if now - self._last_signal_check > 30:
+            self._last_signal_check = now
+            threading.Thread(target=self._check_all_cameras, daemon=True).start()
+
+    def _collect_camera_ips(self) -> list:
+        ips = set()
+        for bucket in self.buckets:
+            for cam in bucket.cameras:
+                m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
+                if m:
+                    ips.add(m.group(1))
+        return sorted(ips)
+
+    def _check_all_cameras(self):
+        ips = self._collect_camera_ips()
+        if not ips:
+            return
+        results = {}
+        for ip in ips:
+            try:
+                if sys.platform == "win32":
+                    r = subprocess.run(["ping", "-n", "1", ip], capture_output=True, timeout=5, text=True, creationflags=subprocess.CREATE_NO_WINDOW)
+                else:
+                    r = subprocess.run(["ping", "-c", "1", ip], capture_output=True, timeout=5, text=True)
+                if r.returncode != 0:
+                    results[ip] = -1
+                else:
+                    m = re.search(r'time[=<]\s*(\d+(?:\.\d+)?)\s*ms', r.stdout, re.IGNORECASE)
+                    if m:
+                        results[ip] = float(m.group(1))
+                    else:
+                        results[ip] = 0
+            except subprocess.TimeoutExpired:
+                results[ip] = -1
+            except Exception:
+                results[ip] = -1
+        self.camera_signal_signal.emit(results)
+
+    def _on_camera_signal(self, results: dict):
+        self._camera_signal.update(results)
+        for bucket in self.buckets:
+            label = self._bucket_signal_labels.get(bucket.id)
+            if not label:
+                continue
+            best = None
+            for cam in bucket.cameras:
+                m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
+                if not m:
+                    continue
+                ip = m.group(1)
+                if ip in self._camera_signal:
+                    lat = self._camera_signal[ip]
+                    if best is None or (lat >= 0 and (best < 0 or lat < best)):
+                        best = lat
+            if best is None:
+                label.setText("")
+                label.setStyleSheet("font-size: 12px; color: #909399;")
+            elif best < 0:
+                label.setText("● 断开")
+                label.setStyleSheet("font-size: 12px; color: #f56c6c;")
+            elif best < 50:
+                label.setText(f"● {int(best)}ms")
+                label.setStyleSheet("font-size: 12px; color: #67c23a;")
+            elif best < 200:
+                label.setText(f"● {int(best)}ms")
+                label.setStyleSheet("font-size: 12px; color: #e6a23c;")
+            else:
+                label.setText(f"● {int(best)}ms")
+                label.setStyleSheet("font-size: 12px; color: #f56c6c;")
 
-    def toggle_switch_mode(self, checked):
+    def toggle_switch_mode(self, checked: bool):
         if checked:
             self.switch_mode = SwitchMode.AUTO
             self.auto_switch_btn.setText("自动模式")
+            if self._last_rfid_tag:
+                self._on_rfid_tag_impl(self._last_rfid_tag)
+            elif self.active_bucket:
+                self.activate_bucket(None)
         else:
             self.switch_mode = SwitchMode.HAND
-            self.auto_switch_btn.setText("手动模式")
+            self.auto_switch_btn.setText("手动模式")
+
+    # ---------- 开机自启 ----------
+    @staticmethod
+    def _autostart_config_dir():
+        if sys.platform == "win32":
+            return os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "HookVision")
+        return os.path.join(os.path.expanduser("~"), ".config", "HookVision")
+
+    @staticmethod
+    def _autostart_config_path():
+        return os.path.join(MainWindow._autostart_config_dir(), "autostart.txt")   # 改为 txt
+
+    @staticmethod
+    def _autostart_desktop_path():
+        return os.path.expanduser("~/.config/autostart/HookVision.desktop")
+
+    @staticmethod
+    def _read_autostart_flag():
+        path = MainWindow._autostart_config_path()
+        try:
+            with open(path, "r", encoding="utf-8") as f:
+                content = f.read().strip().lower()
+                return content == "true"
+        except:
+            return False
+
+    @staticmethod
+    def _write_autostart_flag(enabled: bool):
+        path = MainWindow._autostart_config_path()
+        os.makedirs(os.path.dirname(path), exist_ok=True)
+        with open(path, "w", encoding="utf-8") as f:
+            f.write("true" if enabled else "false")
+
+    @staticmethod
+    def _autostart_command():
+        if getattr(sys, 'frozen', False):
+            return sys.executable
+        else:
+            main_script = os.path.abspath(sys.argv[0])
+            python_exe = sys.executable
+            if ' ' in python_exe:
+                python_exe = f'"{python_exe}"'
+            if ' ' in main_script:
+                main_script = f'"{main_script}"'
+            if sys.platform == "win32":
+                pythonw = python_exe.replace('python.exe', 'pythonw.exe')
+                if pythonw != python_exe and os.path.exists(pythonw):
+                    python_exe = pythonw
+            return f'{python_exe} {main_script}'
+
+    @staticmethod
+    def _apply_system_autostart(enabled: bool) -> bool:
+        cmd = MainWindow._autostart_command()
+        try:
+            if sys.platform == "win32":
+                import winreg
+                key = r"Software\Microsoft\Windows\CurrentVersion\Run"
+                with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as rk:
+                    if enabled:
+                        winreg.SetValueEx(rk, MainWindow.APPTITLE, 0, winreg.REG_SZ, cmd)
+                    else:
+                        try:
+                            winreg.DeleteValue(rk, MainWindow.APPTITLE)
+                        except OSError:
+                            pass
+                return True
+            else:
+                desktop_path = MainWindow._autostart_desktop_path()
+                if enabled:
+                    os.makedirs(os.path.dirname(desktop_path), exist_ok=True)
+                    with open(desktop_path, "w", encoding="utf-8") as f:
+                        f.write(f"""[Desktop Entry]
+Type=Application
+Name={MainWindow.APPTITLE}
+Exec={cmd}
+Terminal=false
+X-GNOME-Autostart-enabled=true
+""")
+                else:
+                    try:
+                        os.remove(desktop_path)
+                    except OSError:
+                        pass
+                return True
+        except Exception as e:
+            logger.error(f"设置开机自启失败: {e}")
+            return False
+
+    def _on_autostart_toggle(self, state):
+        enabled = (state == Qt.Checked)
+        self._write_autostart_flag(enabled)
+        ok = self._apply_system_autostart(enabled)
+        if not ok:
+            QMessageBox.warning(
+                self,
+                "设置失败",
+                "开机自启动设置失败,请检查权限或手动添加。\n"
+                "Windows: 将本程序快捷方式放入 '启动' 文件夹。\n"
+                "Linux: 检查 ~/.config/autostart/ 目录权限。"
+            )

+ 33 - 2
hookvision/modbus_client.py

@@ -6,6 +6,9 @@ from typing import Callable, List
 
 logger = logging.getLogger(__name__)
 
+_MIN_BACKOFF = 5
+_MAX_BACKOFF = 60
+
 class ModbusClient:
     def __init__(self, config, on_data_callback: Callable[[List[bool]], None]):
         self.config = config
@@ -13,33 +16,61 @@ class ModbusClient:
         self.client: ModbusTcpClient = None
         self.running = False
         self.thread = None
+        self._coil_count = 100
+        self._backoff = _MIN_BACKOFF
+        self._last_connect_attempt = 0.0
 
     def connect(self) -> bool:
+        self._last_connect_attempt = time.time()
         try:
             self.client = ModbusTcpClient(self.config.ip, port=self.config.port)
             self.client.connect()
+            self._backoff = _MIN_BACKOFF
             self.running = True
             self.thread = threading.Thread(target=self._poll_loop, daemon=True)
             self.thread.start()
             logger.info(f"Modbus连接成功 {self.config.ip}:{self.config.port}")
             return True
         except Exception as e:
-            logger.error(f"Modbus连接失败: {e}")
+            self._backoff = min(self._backoff * 2, _MAX_BACKOFF)
+            logger.error(f"Modbus连接失败,下次{self._backoff}s后重试: {e}")
             return False
 
     def disconnect(self):
         self.running = False
         if self.client:
             self.client.close()
+            self.client = None
             logger.info("Modbus已断开")
 
     def _poll_loop(self):
         while self.running:
             try:
-                result = self.client.read_coils(0, 100)
+                if self.client is None or not self.client.is_socket_open():
+                    logger.warning("Modbus连接断开,尝试重连")
+                    self._try_reconnect()
+                    if self.client is None:
+                        time.sleep(min(self._backoff, self.config.frequency / 1000.0))
+                        continue
+                result = self.client.read_coils(0, self._coil_count)
                 if result and not result.isError():
                     bools = [result.bits[i] for i in range(len(result.bits))]
                     self.on_data_callback(bools)
             except Exception as e:
                 logger.error(f"Modbus读取错误: {e}")
+                self.client = None
             time.sleep(self.config.frequency / 1000.0)
+
+    def _try_reconnect(self):
+        if time.time() - self._last_connect_attempt < self._backoff:
+            return
+        self._last_connect_attempt = time.time()
+        try:
+            self.client = ModbusTcpClient(self.config.ip, port=self.config.port)
+            self.client.connect()
+            self._backoff = _MIN_BACKOFF
+            logger.info("Modbus重连成功")
+        except Exception as e:
+            self._backoff = min(self._backoff * 2, _MAX_BACKOFF)
+            logger.error(f"Modbus重连失败,下次{self._backoff}s后重试: {e}")
+            self.client = None

+ 9 - 0
hookvision/models.py

@@ -36,6 +36,7 @@ class BucketModel:
     description: str
     code_x: str          # 大车标识(RFID卡号或Modbus点号)
     code_y: str = ""     # 小车标识(垂直工位)
+    ai_module_id: Optional[int] = None  # 关联AI模块索引
     cameras: List[CameraModel] = field(default_factory=list)
     is_active: bool = False
     ready_x: bool = False
@@ -50,6 +51,13 @@ class NVRModel:
     username: str = "admin"
     password: str = ""
 
+@dataclass
+class AIModuleModel:
+    id: int
+    broker: str = "127.0.0.1"
+    port: int = 1883
+    topics: List[str] = field(default_factory=list)
+
 @dataclass
 class AIConfig:
     enabled: bool = False
@@ -84,6 +92,7 @@ class Config:
     rfid: Optional[SocketConfig] = None
     modbus: Optional[ModbusConfig] = None
     ai: AIConfig = field(default_factory=AIConfig)
+    ai_modules: List[AIModuleModel] = field(default_factory=list)
     buckets: List[BucketModel] = field(default_factory=list)
     nvrs: List[NVRModel] = field(default_factory=list)
     video: VideoConfig = field(default_factory=VideoConfig)

+ 28 - 13
hookvision/mqtt_client.py

@@ -4,43 +4,58 @@ import logging
 from typing import Callable
 
 class MQTTClient:
-    def __init__(self, config, on_message_callback: Callable[[str, dict], None]):
-        self.config = config
+    def __init__(self, on_message_callback: Callable[[str, dict], None]):
         self.on_message_callback = on_message_callback
         self.client = mqtt.Client()
         self.client.on_connect = self._on_connect
+        self.client.on_disconnect = self._on_disconnect
         self.client.on_message = self._on_message
         self.connected = False
+        self._pending_topics: list = []
 
-    def connect(self):
-        if not self.config.enabled:
-            return
+    def connect(self, broker: str, port: int = 1883):
+        self.disconnect()
         try:
-            self.client.connect(self.config.broker, self.config.port, 60)
+            logging.info(f"MQTT正在连接 {broker}:{port}")
+            self.client.connect(broker, port, 60)
             self.client.loop_start()
-            self.connected = True
         except Exception as e:
-            logging.error(f"MQTT连接失败: {e}")
-            self.connected = False
+            logging.error(f"MQTT连接失败 {broker}:{port}: {e}")
 
     def disconnect(self):
-        if self.connected:
+        self.connected = False
+        try:
             self.client.loop_stop()
             self.client.disconnect()
+        except Exception:
+            pass
 
     def subscribe(self, topics):
+        self._pending_topics.extend([t for t in topics if t not in self._pending_topics])
         if not self.connected:
             return
         for topic in topics:
             self.client.subscribe(topic)
 
+    def unsubscribe(self, topic):
+        self._pending_topics = [t for t in self._pending_topics if t != topic]
+        if not self.connected:
+            return
+        self.client.unsubscribe(topic)
+
     def _on_connect(self, client, userdata, flags, rc):
         if rc == 0:
             self.connected = True
-            for topic in self.config.topics:
+            logging.info(f"MQTT已连接,正在订阅 {len(self._pending_topics)} 个topic")
+            for topic in self._pending_topics:
                 client.subscribe(topic)
         else:
-            logging.warning(f"MQTT连接失败,返回码: {rc}")
+            logging.warning(f"MQTT broker拒绝连接,返回码: {rc}")
+
+    def _on_disconnect(self, client, userdata, rc):
+        self.connected = False
+        if rc != 0:
+            logging.warning(f"MQTT意外断开,rc={rc}")
 
     def _on_message(self, client, userdata, msg):
         try:
@@ -53,4 +68,4 @@ class MQTTClient:
             else:
                 logging.debug("收到 null payload,忽略")
         except Exception as e:
-            logging.exception(f"MQTT消息解析错误")  # 打印堆栈
+            logging.exception(f"MQTT消息解析错误")

+ 31 - 11
hookvision/rfid_client.py

@@ -6,6 +6,9 @@ from typing import Callable, Optional
 
 logger = logging.getLogger(__name__)
 
+_MIN_BACKOFF = 5
+_MAX_BACKOFF = 60
+
 class RFIDClient:
     def __init__(self, config, on_tag_callback: Callable[[Optional[str]], None],
                  on_state_changed=None, tag_offset: int = -6, tag_length: int = 6, hex_output: bool = True):
@@ -20,7 +23,7 @@ class RFIDClient:
         self._stable_count = 0
         self._last_time = time.time()
         self._last_receive_time = time.time()   # 最后一次收到数据的时间
-        self._reconnect_interval = 5            # 重连尝试间隔(秒)
+        self._backoff = _MIN_BACKOFF            # 当前退避间隔(秒)
         self._last_connect_attempt = 0
         self.thread = None
         self.buffer = b""
@@ -31,7 +34,7 @@ class RFIDClient:
         if self.sock:
             return True
         # 避免频繁重连
-        if time.time() - self._last_connect_attempt < self._reconnect_interval:
+        if time.time() - self._last_connect_attempt < self._backoff:
             return False
         self._last_connect_attempt = time.time()
         try:
@@ -42,12 +45,14 @@ class RFIDClient:
             self.thread = threading.Thread(target=self._read_loop, daemon=True)
             self.thread.start()
             self._last_receive_time = time.time()
+            self._backoff = _MIN_BACKOFF
             logger.info(f"RFID连接成功 {self.config.ip}:{self.config.port}")
             if self.on_state_changed:
                 self.on_state_changed(True)
             return True
         except Exception as e:
-            logger.error(f"RFID连接失败: {e}")
+            self._backoff = min(self._backoff * 2, _MAX_BACKOFF)
+            logger.error(f"RFID连接失败,下次{self._backoff}s后重试: {e}")
             self.sock = None
             if self.on_state_changed:
                self.on_state_changed(False)
@@ -56,18 +61,25 @@ class RFIDClient:
     def disconnect(self):
         """主动断开连接"""
         self.running = False
-        if self.sock:
-            self.sock.close()
-            self.sock = None
+        sock = self.sock
+        self.sock = None
+        if sock:
+            try:
+                sock.close()
+            except OSError:
+                pass
         logger.info("RFID已断开")
         if self.on_state_changed:
             self.on_state_changed(False)
         self.on_tag_callback(None)   # 通知上层
 
     def _read_loop(self):
-        while self.running and self.sock:
+        while self.running:
+            sock = self.sock
+            if sock is None:
+                break
             try:
-                data = self.sock.recv(1024)
+                data = sock.recv(1024)
                 if not data:
                     break
                 self._last_receive_time = time.time()   # 收到任何数据都更新时间
@@ -84,11 +96,12 @@ class RFIDClient:
                     self._process_packet(packet)
             except socket.timeout:
                 continue   # 超时不影响循环
+            except OSError:
+                break
             except Exception as e:
                 logger.error(f"RFID读取错误: {e}")
                 break
         # 退出循环(连接断开)
-        self.sock = None
         logger.info("RFID读取循环结束,将触发重连")
         if self.on_state_changed:
             self.on_state_changed(False)
@@ -152,10 +165,17 @@ class RFIDClient:
         # 连接状态维护
         if self.sock is None:
             # 如果 socket 已断开,尝试重连
-            if time.time() - self._last_connect_attempt > self._reconnect_interval:
+            if time.time() - self._last_connect_attempt > self._backoff:
                 self.connect()
         else:
             # 如果 socket 存在但长时间未收到任何数据(10秒),认为连接僵死,主动断开并重连
             if (time.time() - self._last_receive_time) > 10:
                 logger.warning("RFID长时间未收到数据,主动重连")
-                self.disconnect()
+                self.running = False
+                sock = self.sock
+                self.sock = None
+                if sock:
+                    try:
+                        sock.close()
+                    except OSError:
+                        pass

+ 35 - 14
hookvision/rtsp_player.py

@@ -32,7 +32,9 @@ class RTSPPlayer(QThread):
         self.mutex.unlock()
 
     def set_ai_objects(self, objects):
-        self.ai_objects = objects
+        self.mutex.lock()
+        self.ai_objects = list(objects)
+        self.mutex.unlock()
 
     def run(self):
         self.running = True
@@ -42,8 +44,8 @@ class RTSPPlayer(QThread):
             'max_delay': '50000',
             'flags': 'low_delay',
             'fflags': 'nobuffer+discardcorrupt',
-            'analyzeduration': '100000',
-            'probesize': '100000',
+            'analyzeduration': '50000',
+            'probesize': '50000',
             'timeout': str(int(self.timeout_sec * 1000000)),
             'stimeout': str(int(self.timeout_sec * 1000000)),
         }
@@ -53,6 +55,8 @@ class RTSPPlayer(QThread):
 
         try:
             container = av.open(self.rtsp_url, options=options)
+            open_time = time.time() - start_time
+            logger.info(f"RTSP av.open 耗时 {open_time:.1f}s: {self.rtsp_url}")
             video_stream = container.streams.video[0]
             video_stream.thread_type = 'AUTO'
             video_stream.thread_count = 1
@@ -65,6 +69,7 @@ class RTSPPlayer(QThread):
                     break
                 if first_packet:
                     first_packet = False
+                    logger.info(f"RTSP 首包到达耗时 {time.time() - start_time:.1f}s: {self.rtsp_url}")
                     self.first_frame_received.emit()
 
                 for frame in packet.decode():
@@ -99,19 +104,36 @@ class RTSPPlayer(QThread):
         self.wait()
 
     def process_frame(self, frame):
-        # 先缩放到目标尺寸
         self.mutex.lock()
         tw, th = self.target_width, self.target_height
+        objects = list(self.ai_objects)
         self.mutex.unlock()
-        if tw > 0 and th > 0 and (frame.shape[1] != tw or frame.shape[0] != th):
-            frame = cv2.resize(frame, (tw, th), interpolation=cv2.INTER_LINEAR)
-
-        # 再在缩放后的图像上绘制 AI 标注
-        for obj in self.ai_objects:
-            x = obj.get('x', 0)
-            y = obj.get('y', 0)
-            w = obj.get('w', 0)
-            h = obj.get('h', 0)
+
+        orig_h, orig_w = frame.shape[:2]
+        if tw > 0 and th > 0 and (orig_w != tw or orig_h != th):
+            # 保持原始宽高比缩放到 target 内,不足补黑边
+            scale = min(tw / orig_w, th / orig_h)
+            new_w = int(orig_w * scale)
+            new_h = int(orig_h * scale)
+            resized = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
+            canvas = np.zeros((th, tw, 3), dtype=np.uint8)
+            x_off = (tw - new_w) // 2
+            y_off = (th - new_h) // 2
+            canvas[y_off:y_off+new_h, x_off:x_off+new_w] = resized
+            frame = canvas
+
+            # AI 坐标从 display 空间映射到实际画布内图像区域
+            sx = new_w / tw
+            sy = new_h / th
+        else:
+            sx = sy = 1.0
+            x_off = y_off = 0
+
+        for obj in objects:
+            x = int(obj.get('x', 0) * sx + x_off)
+            y = int(obj.get('y', 0) * sy + y_off)
+            w = int(obj.get('w', 0) * sx)
+            h = int(obj.get('h', 0) * sy)
             label = obj.get('label', '')
             color = obj.get('color', (0, 255, 0))
             dimmed = obj.get('dimmed', False)
@@ -121,7 +143,6 @@ class RTSPPlayer(QThread):
             cv2.rectangle(frame, (x, y), (x+w, y+h), color, thickness)
             cv2.putText(frame, label, (x, y-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, thickness)
 
-        # 转换为 QPixmap
         h, w, ch = frame.shape
         bytes_per_line = ch * w
         qt_img = QImage(frame.data, w, h, bytes_per_line, QImage.Format_RGB888)

+ 40 - 0
main.py

@@ -1,6 +1,7 @@
 #!/usr/bin/env python3
 import sys
 import os
+import json
 from PySide6.QtWidgets import QApplication
 from qt_material import apply_stylesheet
 from hookvision.config_loader import load_config
@@ -8,7 +9,46 @@ from hookvision.main_window import MainWindow
 import logging
 logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
 
+def _autostart_diag():
+    import winreg
+    log = []
+    log.append(f"sys.executable = {sys.executable}")
+    log.append(f"sys.platform = {sys.platform}")
+    log.append(f"frozen = {getattr(sys, 'frozen', False)}")
+    config_dir = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "HookVision")
+    config_path = os.path.join(config_dir, "autostart.json")
+    log.append(f"config_path = {config_path}")
+    try:
+        os.makedirs(config_dir, exist_ok=True)
+        with open(config_path, "w", encoding="utf-8") as f:
+            json.dump({"enabled": True}, f)
+        with open(config_path, "r", encoding="utf-8") as f:
+            data = json.load(f)
+        log.append(f"JSON OK: {data}")
+        os.remove(config_path)
+    except Exception as e:
+        log.append(f"JSON FAIL: {e}")
+    key = r"Software\Microsoft\Windows\CurrentVersion\Run"
+    try:
+        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as rk:
+            winreg.SetValueEx(rk, "HookVision", 0, winreg.REG_SZ, sys.executable)
+        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_READ) as rk:
+            val, _ = winreg.QueryValueEx(rk, "HookVision")
+        log.append(f"Registry OK: {val}")
+        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as rk:
+            winreg.DeleteValue(rk, "HookVision")
+    except Exception as e:
+        log.append(f"Registry FAIL: {e}")
+    msg = "\n".join(log)
+    print(msg)
+    with open("autostart_diag.txt", "w", encoding="utf-8") as f:
+        f.write(msg)
+
 def main():
+    if "--autostart-test" in sys.argv:
+        _autostart_diag()
+        return
+
     app = QApplication(sys.argv)
     apply_stylesheet(app, theme='dark_teal.xml')
 

+ 5 - 0
requirements.txt

@@ -0,0 +1,5 @@
+PySide6>=6.5.0
+opencv-python>=4.8.0
+pymodbus>=3.5.0
+paho-mqtt>=1.6.0
+pywin32>=306

+ 62 - 0
tests/conftest.py

@@ -0,0 +1,62 @@
+import sys
+import os
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+import pytest
+from PySide6.QtWidgets import QApplication
+
+
+@pytest.fixture(scope="session")
+def qapp():
+    app = QApplication.instance()
+    if app is None:
+        app = QApplication(sys.argv)
+    yield app
+
+
+@pytest.fixture(scope="session")
+def sample_config_path(tmp_path_factory):
+    tmpdir = tmp_path_factory.mktemp("config")
+    cfg = tmpdir / "config.ini"
+    cfg.write_text(
+        """[App]
+name = 测试系统
+copyright = 测试
+screen = 0
+
+[Video]
+rtsp_timeout = 5
+
+[Hardware]
+module_type = RFID
+
+[RFID]
+ip = 192.168.1.1
+port = 60000
+stability_time = 5
+stability_count = 3
+
+[AI]
+enabled = false
+broker = 127.0.0.1
+port = 1883
+topics =
+
+[NVR]
+count = 0
+
+[Bucket]
+count = 1
+
+[Bucket:0]
+id = 1
+description = 测试工位
+code_x = TEST001
+code_y = 1
+left_rtsp = rtsp://test:test@192.168.1.10:554/stream1
+right_rtsp = rtsp://test:test@192.168.1.11:554/stream2
+left_topic = test/left
+right_topic = test/right
+""", encoding="utf-8"
+    )
+    return str(cfg)

+ 44 - 0
tests/test_config_loader.py

@@ -0,0 +1,44 @@
+from hookvision.config_loader import load_config
+from hookvision.models import ModuleType, CameraPosition
+
+
+class TestLoadConfig:
+    def test_load_rfid_config(self, sample_config_path):
+        config = load_config(sample_config_path)
+        assert config.app_name == "测试系统"
+        assert config.module_type == ModuleType.RFID
+        assert config.rfid is not None
+        assert config.rfid.ip == "192.168.1.1"
+        assert config.rfid.port == 60000
+        assert config.modbus is None
+        assert config.ai.enabled is False
+
+    def test_load_buckets(self, sample_config_path):
+        config = load_config(sample_config_path)
+        assert len(config.buckets) == 1
+        bucket = config.buckets[0]
+        assert bucket.id == 1
+        assert bucket.description == "测试工位"
+        assert bucket.code_x == "TEST001"
+        assert len(bucket.cameras) == 2
+
+    def test_load_cameras(self, sample_config_path):
+        config = load_config(sample_config_path)
+        bucket = config.buckets[0]
+        left = next(c for c in bucket.cameras if c.position == CameraPosition.LEFT)
+        right = next(c for c in bucket.cameras if c.position == CameraPosition.RIGHT)
+        assert left.rtsp_url == "rtsp://test:test@192.168.1.10:554/stream1"
+        assert left.topic == "test/left"
+        assert right.rtsp_url == "rtsp://test:test@192.168.1.11:554/stream2"
+        assert right.topic == "test/right"
+
+    def test_load_video_config(self, sample_config_path):
+        config = load_config(sample_config_path)
+        assert config.video.rtsp_timeout == 5.0
+
+    def test_config_file_not_exists(self, tmp_path):
+        from configparser import NoSectionError
+        path = tmp_path / "nonexistent.ini"
+        import pytest
+        with pytest.raises(NoSectionError):
+            load_config(str(path))

+ 170 - 0
tests/test_main_window.py

@@ -0,0 +1,170 @@
+import pytest
+from PySide6.QtWidgets import QMainWindow
+from hookvision.models import HookState, Config
+from hookvision.main_window import MainWindow
+from hookvision.config_loader import load_config
+
+
+class TestParseAIObjects:
+    def test_none_payload(self, qapp, sample_config_path):
+        config = load_config(sample_config_path)
+        mw = MainWindow(config)
+        assert mw.parse_ai_objects(None, 640, 360) == []
+        assert mw.parse_ai_objects({}, 640, 360) == []
+
+    def test_empty_objects(self, qapp, sample_config_path):
+        config = load_config(sample_config_path)
+        mw = MainWindow(config)
+        result = mw.parse_ai_objects({"width": 640, "height": 360, "objects": []}, 640, 360)
+        assert result == []
+
+    def test_valid_objects(self, qapp, sample_config_path):
+        config = load_config(sample_config_path)
+        mw = MainWindow(config)
+        payload = {
+            "width": 640,
+            "height": 360,
+            "objects": [
+                "0|100|200|50|80|hoist",
+                "0|300|150|40|60|cup",
+            ]
+        }
+        result = mw.parse_ai_objects(payload, 640, 360)
+        assert len(result) == 2
+        assert result[0]["label"] == "hook"
+        assert result[0]["x"] == 100
+        assert result[0]["y"] == 200
+        assert result[0]["color"] == (0, 0, 255)
+        assert result[1]["label"] == "lug"
+        assert result[1]["color"] == (255, 0, 0)
+
+    def test_scale_transformation(self, qapp, sample_config_path):
+        config = load_config(sample_config_path)
+        mw = MainWindow(config)
+        payload = {
+            "width": 320,
+            "height": 180,
+            "objects": ["0|50|50|100|100|hoist"],
+        }
+        result = mw.parse_ai_objects(payload, 640, 360)
+        assert result[0]["x"] == 100
+        assert result[0]["y"] == 100
+        assert result[0]["w"] == 200
+        assert result[0]["h"] == 200
+
+    def test_ready_object_excluded(self, qapp, sample_config_path):
+        config = load_config(sample_config_path)
+        mw = MainWindow(config)
+        payload = {
+            "width": 640,
+            "height": 360,
+            "objects": [
+                "0|100|200|50|80|hoist",
+                "0|300|150|40|60|ready",
+            ]
+        }
+        result = mw.parse_ai_objects(payload, 640, 360, include_ready=False)
+        assert len(result) == 1
+        assert result[0]["label"] == "hook"
+
+    def test_ready_object_included(self, qapp, sample_config_path):
+        config = load_config(sample_config_path)
+        mw = MainWindow(config)
+        payload = {
+            "width": 640,
+            "height": 360,
+            "objects": [
+                "0|100|200|50|80|ready",
+            ]
+        }
+        result = mw.parse_ai_objects(payload, 640, 360, include_ready=True)
+        assert len(result) == 1
+        assert "ready" in result[0]["label"]
+
+    def test_malformed_objects_skipped(self, qapp, sample_config_path):
+        config = load_config(sample_config_path)
+        mw = MainWindow(config)
+        payload = {
+            "width": 640,
+            "height": 360,
+            "objects": [
+                "not_enough_parts",
+                "0|abc|def|ghi|jkl|hoist",
+                "0|100|200|50|80|hoist",
+            ]
+        }
+        result = mw.parse_ai_objects(payload, 640, 360)
+        assert len(result) == 1
+
+    def test_zero_orig_size_no_division_error(self, qapp, sample_config_path):
+        config = load_config(sample_config_path)
+        mw = MainWindow(config)
+        payload = {
+            "width": 0,
+            "height": 0,
+            "objects": ["0|100|200|50|80|hoist"],
+        }
+        result = mw.parse_ai_objects(payload, 640, 360)
+        assert len(result) == 1
+        assert result[0]["x"] == 100
+        assert result[0]["y"] == 200
+
+
+class TestRectsIntersect:
+    def setup_method(self):
+        self.mw = MainWindow(Config())
+
+    def test_overlapping(self, qapp):
+        mw = MainWindow(Config())
+        a = {"x": 0, "y": 0, "w": 100, "h": 100}
+        b = {"x": 50, "y": 50, "w": 100, "h": 100}
+        assert mw.rects_intersect(a, b) is True
+
+    def test_non_overlapping(self, qapp):
+        mw = MainWindow(Config())
+        a = {"x": 0, "y": 0, "w": 50, "h": 50}
+        b = {"x": 100, "y": 100, "w": 50, "h": 50}
+        assert mw.rects_intersect(a, b) is False
+
+    def test_touching_edge_intersecting(self, qapp):
+        mw = MainWindow(Config())
+        a = {"x": 0, "y": 0, "w": 50, "h": 50}
+        b = {"x": 50, "y": 0, "w": 50, "h": 50}
+        # 边沿相接触视为相交(用于挂钩/吊耳判定的宽松策略)
+        assert mw.rects_intersect(a, b) is True
+
+    def test_one_contains_other(self, qapp):
+        mw = MainWindow(Config())
+        a = {"x": 0, "y": 0, "w": 200, "h": 200}
+        b = {"x": 50, "y": 50, "w": 50, "h": 50}
+        assert mw.rects_intersect(a, b) is True
+
+
+class TestHasIntersectingPair:
+    def test_no_pairs(self, qapp):
+        mw = MainWindow(Config())
+        assert mw._has_intersecting_pair(None, None) is False
+
+    def test_left_pair_intersects(self, qapp):
+        mw = MainWindow(Config())
+        left_pair = (
+            {"x": 0, "y": 0, "w": 100, "h": 100},
+            {"x": 50, "y": 50, "w": 100, "h": 100},
+        )
+        assert mw._has_intersecting_pair(left_pair, None) is True
+
+    def test_right_pair_intersects(self, qapp):
+        mw = MainWindow(Config())
+        right_pair = (
+            {"x": 0, "y": 0, "w": 100, "h": 100},
+            {"x": 50, "y": 50, "w": 100, "h": 100},
+        )
+        assert mw._has_intersecting_pair(None, right_pair) is True
+
+    def test_pair_not_intersecting(self, qapp):
+        mw = MainWindow(Config())
+        pair = (
+            {"x": 0, "y": 0, "w": 50, "h": 50},
+            {"x": 100, "y": 100, "w": 50, "h": 50},
+        )
+        assert mw._has_intersecting_pair(pair, None) is False

+ 84 - 0
tests/test_models.py

@@ -0,0 +1,84 @@
+from hookvision.models import (
+    ModuleType, CameraPosition, CameraState, SwitchMode, HookState,
+    CameraModel, BucketModel, NVRModel, AIConfig, ModbusConfig,
+    SocketConfig, VideoConfig, Config
+)
+
+
+class TestEnums:
+    def test_module_type(self):
+        assert ModuleType.RFID.value == "RFID"
+        assert ModuleType.HD3200.value == "HD3200"
+
+    def test_camera_position(self):
+        assert CameraPosition.LEFT.value == "left"
+        assert CameraPosition.RIGHT.value == "right"
+
+    def test_camera_state(self):
+        assert CameraState.ONLINE.value == "online"
+        assert CameraState.OFFLINE.value == "offline"
+
+    def test_switch_mode(self):
+        assert SwitchMode.AUTO.value == "auto"
+        assert SwitchMode.HAND.value == "hand"
+
+    def test_hook_state(self):
+        assert HookState.IDLE.value == "idle"
+        assert HookState.RELEASING.value == "releasing"
+        assert HookState.HOOKING.value == "hooking"
+        assert HookState.READY.value == "ready"
+
+
+class TestModels:
+    def test_camera_model_defaults(self):
+        cam = CameraModel(position=CameraPosition.LEFT, rtsp_url="rtsp://test")
+        assert cam.position == CameraPosition.LEFT
+        assert cam.rtsp_url == "rtsp://test"
+        assert cam.topic is None
+        assert cam.state == CameraState.OFFLINE
+
+    def test_bucket_model_defaults(self):
+        b = BucketModel(id=1, description="test", code_x="X001")
+        assert b.code_y == ""
+        assert b.cameras == []
+        assert b.is_active is False
+        assert b.ready_x is False
+        assert b.ready_y is True
+
+    def test_nvr_model_defaults(self):
+        nvr = NVRModel(id=0, name="test", ip="192.168.1.1")
+        assert nvr.port == 8000
+        assert nvr.username == "admin"
+        assert nvr.password == ""
+
+    def test_ai_config_defaults(self):
+        ai = AIConfig()
+        assert ai.enabled is False
+        assert ai.broker == "127.0.0.1"
+        assert ai.port == 1883
+        assert ai.topics == []
+
+    def test_modbus_config(self):
+        m = ModbusConfig(ip="192.168.1.1")
+        assert m.port == 502
+        assert m.frequency == 100
+        assert m.stability_count == 3
+
+    def test_socket_config(self):
+        s = SocketConfig(ip="192.168.1.1")
+        assert s.port == 6000
+        assert s.stability_time == 20
+        assert s.stability_count == 3
+
+    def test_video_config_defaults(self):
+        v = VideoConfig()
+        assert v.rtsp_timeout == 5.0
+
+    def test_config_defaults(self):
+        c = Config()
+        assert c.app_name == "钢包工位视频监测系统"
+        assert c.module_type == ModuleType.RFID
+        assert c.buckets == []
+        assert c.nvrs == []
+        assert c.rfid is None
+        assert c.modbus is None

+ 323 - 0
tests/test_state_machine.py

@@ -0,0 +1,323 @@
+"""Test hook state evaluation logic (evaluate_hook_state).
+
+The state machine transitions:
+  IDLE -> HOOKING (hook-lug intersecting, debounce 3 frames)
+  HOOKING -> READY (hook-lug intersecting + ready object, debounce 3)
+  HOOKING/READY -> IDLE (no intersection, immediate)
+  Any -> RELEASING (downward movement, debounce 3)
+
+For RELEASING: both hook and lug centers must move down by > RELEASE_DELTA_THRESHOLD pixels.
+"""
+
+import pytest
+from hookvision.models import HookState
+
+
+def make_hook(x, y, w=20, h=20):
+    return {"x": x, "y": y, "w": w, "h": h, "label": "hook", "color": (0, 0, 255)}
+
+
+def make_lug(x, y, w=20, h=20):
+    return {"x": x, "y": y, "w": w, "h": h, "label": "lug", "color": (255, 0, 0)}
+
+
+def make_ready(x, y, w=10, h=10):
+    return {"x": x, "y": y, "w": w, "h": h, "label": "ready", "color": (0, 255, 255)}
+
+
+class MockMainWindow:
+    """Minimal mock of MainWindow for testing evaluate_hook_state."""
+
+    STATE_THRESHOLD = 3
+    RELEASE_DELTA_THRESHOLD = 5
+
+    def __init__(self):
+        self.left_objects = []
+        self.right_objects = []
+        self.hook_state = HookState.IDLE
+        self.prev_state = HookState.IDLE
+        self.state_counter = 0
+        self.prev_left_hook = None
+        self.prev_right_hook = None
+        self.prev_left_lug = None
+        self.prev_right_lug = None
+        self.last_state = None  # tracks update_ui_for_state calls
+
+    def evaluate_hook_state(self):
+        # This is the exact implementation from main_window.py
+        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
+
+        # 防抖
+        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.last_state = new_state
+            self.state_counter = 0
+
+        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
+
+
+class TestStateMachine:
+    def test_initial_state_idle(self):
+        mw = MockMainWindow()
+        mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.IDLE
+
+    def test_no_objects_stays_idle(self):
+        mw = MockMainWindow()
+        for _ in range(5):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.IDLE
+
+    def test_hooking_requires_debounce(self):
+        mw = MockMainWindow()
+        hook = make_hook(0, 0)
+        lug = make_lug(5, 5)  # overlapping
+        mw.left_objects = [hook, lug]
+
+        # First call: counter starts, no transition
+        mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.IDLE
+        assert mw.state_counter == 1
+
+        # Second call: still not enough
+        mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.IDLE
+
+        # Third call: threshold reached, transition to HOOKING
+        mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.HOOKING
+
+    def test_idle_immediate_no_debounce(self):
+        mw = MockMainWindow()
+        hook = make_hook(0, 0)
+        lug = make_lug(5, 5)
+        mw.left_objects = [hook, lug]
+
+        # Transition to HOOKING
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.HOOKING
+
+        # Remove objects - should immediately go IDLE
+        mw.left_objects = []
+        mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.IDLE
+
+    def test_ready_state(self):
+        mw = MockMainWindow()
+        hook = make_hook(0, 0)
+        lug = make_lug(5, 5)
+        ready = make_ready(100, 100)
+        mw.left_objects = [hook, lug, ready]
+
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.READY
+
+    def test_releasing_state(self):
+        mw = MockMainWindow()
+        hook = make_hook(0, 0)
+        lug = make_lug(5, 5)
+        mw.left_objects = [hook, lug]
+
+        # First establish HOOKING
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.HOOKING
+
+        # Simulate continuous downward movement (>5px each frame) for debounce
+        for step in range(1, 4):
+            mw.left_objects = [make_hook(0, step * 6), make_lug(5, step * 6 + 5)]
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.RELEASING
+
+    def test_releasing_upward_movement_no_change(self):
+        mw = MockMainWindow()
+        hook = make_hook(0, 0)
+        lug = make_lug(5, 5)
+        mw.left_objects = [hook, lug]
+
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.HOOKING
+
+        # Move upward (negative dy) - should NOT trigger releasing
+        mw.left_objects = [make_hook(0, -10), make_lug(5, -10)]
+
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state != HookState.RELEASING
+
+    def test_releasing_small_movement_no_change(self):
+        mw = MockMainWindow()
+        hook = make_hook(0, 0)
+        lug = make_lug(5, 5)
+        mw.left_objects = [hook, lug]
+
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.HOOKING
+
+        # Move down by 3px (less than threshold of 5) - should NOT trigger
+        mw.left_objects = [make_hook(0, 3), make_lug(5, 3)]
+
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state != HookState.RELEASING
+
+    def test_both_sides_for_releasing(self):
+        mw = MockMainWindow()
+        hook_l = make_hook(0, 0)
+        lug_l = make_lug(5, 5)
+        hook_r = make_hook(100, 0)
+        lug_r = make_lug(105, 5)
+        mw.left_objects = [hook_l, lug_l]
+        mw.right_objects = [hook_r, lug_r]
+
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.HOOKING
+
+        # Only left side moves down progressively
+        for step in range(1, 4):
+            mw.left_objects = [make_hook(0, step * 6), make_lug(5, step * 6 + 5)]
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.RELEASING
+
+    def test_ready_requires_intersecting_pair(self):
+        mw = MockMainWindow()
+        # Non-intersecting hook/lug + ready should be IDLE, not READY
+        hook = make_hook(0, 0)
+        lug = make_lug(200, 200)  # far apart
+        ready = make_ready(100, 100)
+        mw.left_objects = [hook, lug, ready]
+
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        # No intersecting pair -> IDLE, even with ready present
+        assert mw.hook_state == HookState.IDLE
+
+    def test_paired_flag_set(self):
+        mw = MockMainWindow()
+        hook1 = make_hook(0, 0)
+        lug1 = make_lug(5, 5)
+        hook2 = make_hook(100, 100)  # far away, unpaired
+        lug2 = make_lug(200, 200)
+        mw.left_objects = [hook1, lug1, hook2, lug2]
+
+        mw.evaluate_hook_state()
+
+        paired = [o for o in mw.left_objects if o.get('paired')]
+        unpaired = [o for o in mw.left_objects if not o.get('paired')]
+        assert len(paired) == 2  # hook1 and lug1
+        assert len(unpaired) == 2  # hook2 and lug2
+
+    def test_reset_prev_positions_when_no_pair(self):
+        mw = MockMainWindow()
+        hook = make_hook(0, 0)
+        lug = make_lug(5, 5)
+        mw.left_objects = [hook, lug]
+        mw.evaluate_hook_state()
+        assert mw.prev_left_hook is not None
+
+        mw.left_objects = []
+        mw.evaluate_hook_state()
+        assert mw.prev_left_hook is None
+        assert mw.prev_left_lug is None

+ 115 - 0
tools/install_shortcut.py

@@ -0,0 +1,115 @@
+"""
+创建桌面快捷方式并添加到 Windows 开机自启。
+
+用法:
+    python tools/install_shortcut.py                  # 自动查找 dist 下的 exe
+    python tools/install_shortcut.py --exe "C:\path\to\HookVision.exe"
+    python tools/install_shortcut.py --remove          # 移除开机自启和桌面快捷方式
+"""
+import os, sys, argparse
+from win32com.client import Dispatch
+
+APPTITLE = "HookVision"
+
+def _app_name():
+    try:
+        import configparser
+        base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+        for d in (base, os.path.join(base, "dist", APPTITLE)):
+            p = os.path.join(d, "config.ini")
+            if os.path.exists(p):
+                cfg = configparser.ConfigParser()
+                cfg.read(p, encoding='utf-8')
+                return cfg.get("App", "name", fallback=APPTITLE)
+    except Exception:
+        pass
+    return APPTITLE
+
+def _desktop():
+    return os.path.join(os.path.expanduser("~"), "Desktop")
+
+def find_exe():
+    base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    p = os.path.join(base, "dist", APPTITLE, f"{APPTITLE}.exe")
+    if os.path.exists(p):
+        return p
+    for root, dirs, files in os.walk(base):
+        for f in files:
+            if f.lower() == f"{APPTITLE.lower()}.exe":
+                return os.path.join(root, f)
+    return None
+
+def create_shortcut(exe_path):
+    name = _app_name()
+    lnk_path = os.path.join(_desktop(), f"{name}.lnk")
+    shell = Dispatch('WScript.Shell')
+    shortcut = shell.CreateShortCut(lnk_path)
+    shortcut.Targetpath = exe_path
+    shortcut.WorkingDirectory = os.path.dirname(exe_path)
+    shortcut.IconLocation = exe_path
+    shortcut.Description = name
+    shortcut.Save()
+    print(f"桌面快捷方式已创建: {lnk_path}")
+
+def add_autostart(exe_path):
+    name = _app_name()
+    key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
+    import winreg
+    try:
+        key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE)
+        winreg.SetValueEx(key, name, 0, winreg.REG_SZ, exe_path)
+        winreg.CloseKey(key)
+        print(f"开机自启已添加 (HKCU\\{key_path}\\{name})")
+    except Exception as e:
+        print(f"添加开机自启失败: {e}")
+
+def remove_autostart():
+    name = _app_name()
+    key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
+    import winreg
+    try:
+        key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE)
+        winreg.DeleteValue(key, name)
+        winreg.CloseKey(key)
+        print("开机自启已移除")
+    except FileNotFoundError:
+        print("开机自启未设置")
+    except Exception as e:
+        print(f"移除开机自启失败: {e}")
+
+def remove_shortcut():
+    name = _app_name()
+    lnk_path = os.path.join(_desktop(), f"{name}.lnk")
+    if os.path.exists(lnk_path):
+        os.remove(lnk_path)
+        print(f"桌面快捷方式已移除: {lnk_path}")
+    else:
+        print("桌面快捷方式不存在")
+
+def main():
+    parser = argparse.ArgumentParser(description="安装/卸载桌面快捷方式和开机自启")
+    parser.add_argument("--exe", help="指定 exe 路径")
+    parser.add_argument("--remove", action="store_true", help="移除快捷方式和开机自启")
+    args = parser.parse_args()
+
+    if args.remove:
+        remove_autostart()
+        remove_shortcut()
+        return
+
+    exe_path = args.exe or find_exe()
+    if not exe_path:
+        print("未找到 exe,请先运行 build_exe.bat 或指定 --exe 路径")
+        sys.exit(1)
+    exe_path = os.path.abspath(exe_path)
+
+    try:
+        create_shortcut(exe_path)
+        add_autostart(exe_path)
+        print("安装完成")
+    except Exception as e:
+        print(f"安装失败: {e}")
+        sys.exit(1)
+
+if __name__ == '__main__':
+    main()

+ 21 - 0
tools/png_to_ico.py

@@ -0,0 +1,21 @@
+import struct, os, sys
+
+def _png_size(data):
+    # PNG IHDR chunk at offset 16: 4 bytes width, 4 bytes height
+    return struct.unpack_from('>II', data, 16)
+
+def png_to_ico(png_path, ico_path):
+    with open(png_path, 'rb') as f:
+        png_data = f.read()
+    w, h = _png_size(png_data)
+    png_data = png_data  # full PNG data as icon payload
+    header = struct.pack('<HHH', 0, 1, 1)
+    entry = struct.pack('<BBBBHHII',
+        w if w < 256 else 0,
+        h if h < 256 else 0,
+        0, 0, 1, 32, len(png_data), 22)
+    with open(ico_path, 'wb') as f:
+        f.write(header + entry + png_data)
+
+if __name__ == '__main__':
+    png_to_ico(sys.argv[1], sys.argv[2])