main_window.py 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423
  1. import os
  2. import sys
  3. import time
  4. import re
  5. import logging
  6. import threading
  7. import queue
  8. import subprocess
  9. from collections import defaultdict
  10. from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
  11. QPushButton, QLabel, QGridLayout, QScrollArea,
  12. QToolButton, QFrame, QApplication, QCheckBox,
  13. QMessageBox, QSlider)
  14. from PySide6.QtCore import Qt, QTimer, QSize, Signal
  15. from PySide6.QtGui import QIcon, QPixmap, QResizeEvent, QKeyEvent, QCloseEvent
  16. from .models import *
  17. from .rtsp_player import RTSPPlayer
  18. from .rfid_client import RFIDClient
  19. from .modbus_client import ModbusClient
  20. from .mqtt_client import MQTTClient
  21. logger = logging.getLogger(__name__)
  22. class MainWindow(QMainWindow):
  23. # 防抖阈值(连续相同状态次数)
  24. STATE_THRESHOLD = 3
  25. # 放包判断位移阈值(像素)
  26. RELEASE_DELTA_THRESHOLD = 5
  27. APPTITLE = "HookVision"
  28. # 跨线程信号
  29. rfid_tag_signal = Signal(object)
  30. rfid_state_signal = Signal(object)
  31. modbus_data_signal = Signal(object)
  32. mqtt_message_signal = Signal(str, object)
  33. voice_signal = Signal(str)
  34. camera_signal_signal = Signal(dict)
  35. def __init__(self, config: Config, config_path: str = ""):
  36. super().__init__()
  37. self.config = config
  38. self._config_path = config_path
  39. self.buckets = config.buckets
  40. self.active_bucket: BucketModel = None
  41. self.switch_mode = SwitchMode.AUTO
  42. self._subscribed_topics: set = set()
  43. self._current_ai_module_id: Optional[int] = None
  44. self.left_player: RTSPPlayer = None
  45. self.right_player: RTSPPlayer = None
  46. self.rfid_client = None
  47. self.modbus_client = None
  48. self.mqtt_client = None
  49. self._last_rfid_tag: Optional[str] = None
  50. self.video_aspect_ratio = 16 / 9
  51. self.left_label = None
  52. self.right_label = None
  53. # 挂钩状态相关
  54. self.hook_state = HookState.IDLE
  55. self.prev_state = HookState.IDLE
  56. self.state_counter = 0
  57. # 存储左右画面最近检测对象
  58. self.left_objects = []
  59. self.right_objects = []
  60. # 前一帧 hook/lug 中心位置(用于轨迹判断)
  61. self.prev_left_hook = None
  62. self.prev_right_hook = None
  63. self.prev_left_lug = None
  64. self.prev_right_lug = None
  65. # 语音引擎(在工作线程内初始化)
  66. self.last_voice_state = None
  67. self._last_hooking_voice_time = 0.0
  68. self.voice_enabled = True
  69. self._voice_volume = 100
  70. self.ai_enabled = True
  71. self._last_left_draw: list = []
  72. self._last_right_draw: list = []
  73. self._voice_queue = queue.Queue(maxsize=32)
  74. self._voice_thread = threading.Thread(target=self._voice_worker, daemon=True)
  75. self._voice_thread.start()
  76. self._voices_dir = self._get_voices_dir()
  77. self._use_tts = self.config.ai.tts_enabled
  78. # RTSP 退避重连
  79. self._rtsp_retry_left: Optional[QTimer] = None
  80. self._rtsp_retry_right: Optional[QTimer] = None
  81. self._rtsp_retry_delay_left: int = 1
  82. self._rtsp_retry_delay_right: int = 1
  83. self._last_mqtt_connect_attempt: float = 0.0
  84. self._last_mqtt_process: float = 0.0
  85. # 摄像头信号监控
  86. self._camera_signal: dict = {} # ip → 延迟(ms), -1=不可达
  87. self._bucket_signal_labels: dict = {} # bucket.id → QLabel
  88. self._last_signal_check: float = 0.0
  89. self._signal_check_thread: threading.Thread = None
  90. # 连接跨线程信号到主线程槽
  91. self.rfid_tag_signal.connect(self._on_rfid_tag_impl)
  92. self.rfid_state_signal.connect(self._on_rfid_state_impl)
  93. self.modbus_data_signal.connect(self._on_modbus_data_impl)
  94. self.mqtt_message_signal.connect(self._on_mqtt_message_impl)
  95. self.voice_signal.connect(self._speak_impl)
  96. self.camera_signal_signal.connect(self._on_camera_signal)
  97. self.setWindowTitle(config.app_name)
  98. logo_path = os.path.join(os.path.dirname(__file__), "..", "assets", "logo.png")
  99. if os.path.exists(logo_path):
  100. self.setWindowIcon(QIcon(logo_path))
  101. screen = QApplication.primaryScreen()
  102. screen_geometry = screen.availableGeometry()
  103. screen_width = screen_geometry.width()
  104. screen_height = screen_geometry.height()
  105. window_width = int(screen_width * 0.9)
  106. window_height = int(screen_height * 0.85)
  107. x = (screen_width - window_width) // 2
  108. y = (screen_height - window_height) // 2
  109. self.setGeometry(x, y, window_width, window_height)
  110. self.setMinimumSize(800, 600)
  111. self.setup_ui()
  112. self.showFullScreen()
  113. self.update_video_size()
  114. self.init_hardware()
  115. self.timer = QTimer()
  116. self.timer.timeout.connect(self.check_idle)
  117. self.timer.start(1000)
  118. def setup_ui(self):
  119. central = QWidget()
  120. self.setCentralWidget(central)
  121. main_layout = QVBoxLayout(central)
  122. main_layout.setContentsMargins(10, 10, 10, 10)
  123. main_layout.setSpacing(10)
  124. # 标题栏
  125. title_widget = QWidget()
  126. title_layout = QHBoxLayout(title_widget)
  127. title_layout.setContentsMargins(0, 0, 0, 0)
  128. logo_path = os.path.join(os.path.dirname(__file__), "..", "assets", "logo.png")
  129. if os.path.exists(logo_path):
  130. logo_label = QLabel()
  131. logo_pixmap = QPixmap(logo_path).scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation)
  132. logo_label.setPixmap(logo_pixmap)
  133. logo_label.setFixedSize(28, 28)
  134. title_layout.addWidget(logo_label)
  135. self.title_label = QLabel(self.config.app_name)
  136. self.title_label.setStyleSheet("font-size: 22px; font-weight: bold; padding: 5px;")
  137. title_layout.addWidget(self.title_label)
  138. title_layout.addStretch()
  139. btn_style = """
  140. QPushButton {
  141. background-color: transparent;
  142. border: none;
  143. font-size: 18px;
  144. padding: 6px 12px;
  145. border-radius: 5px;
  146. }
  147. QPushButton:hover {
  148. background-color: rgba(255, 255, 255, 0.1);
  149. }
  150. """
  151. self.min_btn = QPushButton("—")
  152. self.min_btn.setStyleSheet(btn_style)
  153. self.min_btn.clicked.connect(self.showMinimized)
  154. self.fullscreen_btn = QPushButton("⛶")
  155. self.fullscreen_btn.setStyleSheet(btn_style)
  156. self.fullscreen_btn.setToolTip("全屏 / 还原")
  157. self.fullscreen_btn.clicked.connect(self._toggle_fullscreen)
  158. self.close_btn = QPushButton("✕")
  159. self.close_btn.setStyleSheet(btn_style)
  160. self.close_btn.clicked.connect(self.close)
  161. title_layout.addWidget(self.min_btn)
  162. title_layout.addWidget(self.fullscreen_btn)
  163. title_layout.addWidget(self.close_btn)
  164. main_layout.addWidget(title_widget)
  165. # 工位区域
  166. bucket_group = QFrame()
  167. bucket_group.setStyleSheet("QFrame { border-radius: 8px; padding: 8px; }")
  168. bucket_layout = QVBoxLayout(bucket_group)
  169. scroll_area = QScrollArea()
  170. scroll_area.setWidgetResizable(True)
  171. scroll_area.setFrameShape(QFrame.NoFrame)
  172. scroll_area.setStyleSheet("QScrollArea { background: transparent; border: none; }")
  173. self.bucket_container = QWidget()
  174. self.bucket_grid = QGridLayout(self.bucket_container)
  175. self.bucket_grid.setSpacing(15)
  176. self.bucket_grid.setContentsMargins(10, 10, 10, 10)
  177. self.bucket_grid.setAlignment(Qt.AlignLeft | Qt.AlignTop)
  178. # 按 code_x 分组纵向排列
  179. groups = defaultdict(list)
  180. for bucket in self.buckets:
  181. groups[bucket.code_x].append(bucket)
  182. sorted_keys = sorted(groups.keys())
  183. self.bucket_buttons = []
  184. col = 0
  185. for key in sorted_keys:
  186. buckets_in_group = groups[key]
  187. for row, bucket in enumerate(buckets_in_group):
  188. cell = QWidget()
  189. cell_layout = QVBoxLayout(cell)
  190. cell_layout.setContentsMargins(0, 0, 0, 0)
  191. cell_layout.setSpacing(2)
  192. btn = QToolButton()
  193. btn.setFixedSize(100, 100)
  194. btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
  195. btn.setText(bucket.description)
  196. btn.setIcon(self.get_bucket_icon(bucket.is_active))
  197. btn.setIconSize(QSize(64, 64))
  198. btn.setStyleSheet("""
  199. QToolButton {
  200. border-radius: 12px;
  201. font-size: 14px;
  202. font-weight: bold;
  203. }
  204. """)
  205. btn.clicked.connect(lambda checked, b=bucket: self.on_bucket_clicked(b))
  206. cell_layout.addWidget(btn)
  207. # 摄像头信号指示条(左右两段)
  208. bar = QWidget()
  209. bar.setFixedHeight(8)
  210. bar_layout = QHBoxLayout(bar)
  211. bar_layout.setContentsMargins(0, 0, 0, 0)
  212. bar_layout.setSpacing(2)
  213. left_bar = QFrame()
  214. right_bar = QFrame()
  215. for fb in (left_bar, right_bar):
  216. fb.setFixedHeight(8)
  217. fb.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
  218. bar_layout.addWidget(left_bar)
  219. bar_layout.addWidget(right_bar)
  220. cell_layout.addWidget(bar)
  221. self._bucket_signal_labels[bucket.id] = (left_bar, right_bar)
  222. self.bucket_grid.addWidget(cell, row, col)
  223. self.bucket_buttons.append((btn, bucket))
  224. col += 1
  225. self.bucket_container.setLayout(self.bucket_grid)
  226. scroll_area.setWidget(self.bucket_container)
  227. bucket_layout.addWidget(scroll_area)
  228. main_layout.addWidget(bucket_group)
  229. # 视频区域(仅保留边框)
  230. video_container = QWidget()
  231. video_layout = QHBoxLayout(video_container)
  232. video_layout.setSpacing(15)
  233. video_layout.setContentsMargins(0, 0, 0, 0)
  234. self.left_label = QLabel("左侧画面")
  235. self.left_label.setScaledContents(False)
  236. self.left_label.setAlignment(Qt.AlignCenter)
  237. self.left_label.setStyleSheet("""
  238. QLabel {
  239. border: 2px solid #dcdfe6;
  240. border-radius: 8px;
  241. background-color: black;
  242. }
  243. """)
  244. self.right_label = QLabel("右侧画面")
  245. self.right_label.setScaledContents(False)
  246. self.right_label.setAlignment(Qt.AlignCenter)
  247. self.right_label.setStyleSheet("""
  248. QLabel {
  249. border: 2px solid #dcdfe6;
  250. border-radius: 8px;
  251. background-color: black;
  252. }
  253. """)
  254. video_layout.addWidget(self.left_label)
  255. video_layout.addWidget(self.right_label)
  256. main_layout.addWidget(video_container, 1)
  257. # 状态栏
  258. status_widget = QWidget()
  259. status_layout = QHBoxLayout(status_widget)
  260. status_layout.setContentsMargins(5, 5, 15, 5)
  261. self.module_status = QLabel("通讯状态: 初始化")
  262. self.module_status.setStyleSheet("color: #606266; font-weight: bold;")
  263. self.rfid_tag_label = QLabel("当前卡号: 无")
  264. self.rfid_tag_label.setStyleSheet("color: #409eff; font-weight: bold; margin-left: 20px;")
  265. self.hook_status_label = QLabel("挂钩状态: 空闲")
  266. self.hook_status_label.setStyleSheet("color: #ffa500; font-weight: bold; margin-left: 20px;")
  267. self.voice_checkbox = QCheckBox("语音播报")
  268. self.voice_checkbox.setChecked(True)
  269. self.voice_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
  270. self.voice_checkbox.stateChanged.connect(self.on_voice_toggle)
  271. self.tts_checkbox = QCheckBox("TTS语音")
  272. self.tts_checkbox.setChecked(self._use_tts)
  273. self.tts_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
  274. self.tts_checkbox.toggled.connect(self._on_tts_toggle)
  275. self.volume_btn = QPushButton(f"音量 {self._voice_volume}%")
  276. self.volume_btn.setFixedWidth(80)
  277. self.volume_btn.setStyleSheet("""
  278. QPushButton {
  279. color: #606266; font-weight: bold; font-size: 12px;
  280. border: 1px solid #dcdfe6; border-radius: 4px;
  281. padding: 2px 6px; background: transparent;
  282. }
  283. QPushButton:hover { border-color: #409eff; color: #409eff; }
  284. """)
  285. self.volume_btn.clicked.connect(self._show_volume_popup)
  286. self.ai_checkbox = QCheckBox("AI启用")
  287. self.ai_checkbox.setChecked(True)
  288. self.ai_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
  289. self.ai_checkbox.stateChanged.connect(self.on_ai_toggle)
  290. self.auto_switch_btn = QCheckBox("自动模式")
  291. self.auto_switch_btn.setChecked(True)
  292. self.auto_switch_btn.setStyleSheet("color: #606266; font-weight: bold;")
  293. self.auto_switch_btn.clicked.connect(self.toggle_switch_mode)
  294. self.autostart_checkbox = QCheckBox("开机自启")
  295. self.autostart_checkbox.setChecked(self._read_autostart_flag())
  296. self.autostart_checkbox.setStyleSheet("color: #606266; font-weight: bold; margin-left: 10px;")
  297. self.autostart_checkbox.stateChanged.connect(self._on_autostart_toggle)
  298. status_layout.addWidget(self.module_status)
  299. status_layout.addWidget(self.rfid_tag_label)
  300. status_layout.addWidget(self.hook_status_label)
  301. status_layout.addStretch()
  302. status_layout.addWidget(self.voice_checkbox)
  303. status_layout.addWidget(self.tts_checkbox)
  304. status_layout.addWidget(self.volume_btn)
  305. status_layout.addWidget(self.ai_checkbox)
  306. status_layout.addWidget(self.auto_switch_btn)
  307. status_layout.addWidget(self.autostart_checkbox)
  308. main_layout.addWidget(status_widget)
  309. self.update_video_size()
  310. # ---------- 事件与资源管理 ----------
  311. def closeEvent(self, event: QCloseEvent):
  312. self._cancel_rtsp_retry("left")
  313. self._cancel_rtsp_retry("right")
  314. if self.rfid_client:
  315. self.rfid_client.disconnect()
  316. if self.modbus_client:
  317. self.modbus_client.disconnect()
  318. if self.mqtt_client:
  319. self.mqtt_client.disconnect()
  320. if self.left_player:
  321. self.left_player.stop()
  322. if self.right_player:
  323. self.right_player.stop()
  324. event.accept()
  325. # ---------- 语音控制 ----------
  326. def on_voice_toggle(self, state):
  327. self.voice_enabled = (state == Qt.Checked)
  328. def _on_tts_toggle(self):
  329. self._use_tts = self.tts_checkbox.isChecked()
  330. self.config.ai.tts_enabled = self._use_tts
  331. try:
  332. self._save_config()
  333. except Exception as e:
  334. logger.warning(f"保存 TTS 设置失败: {e}")
  335. def _on_volume_changed(self, value: int):
  336. self._voice_volume = value
  337. self.volume_btn.setText(f"音量 {value}%")
  338. def _preview_volume(self, vol: int):
  339. self._voice_volume = vol
  340. self.volume_btn.setText(f"音量 {vol}%")
  341. if not hasattr(self, '_preview_timer'):
  342. self._preview_timer = QTimer()
  343. self._preview_timer.setSingleShot(True)
  344. self._preview_timer.timeout.connect(lambda: self._speak("挂包中"))
  345. self._preview_timer.start(300)
  346. def _show_volume_popup(self):
  347. from PySide6.QtWidgets import QDialog, QVBoxLayout
  348. from PySide6.QtCore import Qt
  349. dlg = QDialog(self)
  350. dlg.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint)
  351. dlg.setStyleSheet("background: #2c2c2c; border: 1px solid #555; border-radius: 6px;")
  352. layout = QVBoxLayout(dlg)
  353. layout.setContentsMargins(12, 8, 12, 8)
  354. slider = QSlider(Qt.Horizontal)
  355. slider.setRange(0, 100)
  356. slider.setValue(self._voice_volume)
  357. slider.setFixedWidth(160)
  358. slider.setStyleSheet("""
  359. QSlider::groove:horizontal { height: 4px; background: #555; border-radius: 2px; }
  360. QSlider::handle:horizontal { width: 14px; margin: -5px 0; border-radius: 7px; background: #409eff; }
  361. QSlider::sub-page:horizontal { background: #409eff; border-radius: 2px; }
  362. """)
  363. slider.valueChanged.connect(self._preview_volume)
  364. layout.addWidget(slider)
  365. dlg.adjustSize()
  366. btn_global = self.volume_btn.mapToGlobal(self.volume_btn.rect().topLeft())
  367. dlg.move(btn_global.x(), btn_global.y() - dlg.height() - 4)
  368. dlg.exec()
  369. def on_ai_toggle(self, state):
  370. enabled = (state == Qt.Checked)
  371. if enabled == self.ai_enabled:
  372. return
  373. self.ai_enabled = enabled
  374. if enabled and not self.mqtt_client:
  375. self.mqtt_client = MQTTClient(self.on_mqtt_message)
  376. self._connect_current_ai()
  377. elif not enabled and self.mqtt_client:
  378. self.mqtt_client.disconnect()
  379. self.mqtt_client = None
  380. self._subscribed_topics.clear()
  381. self._current_ai_module_id = None
  382. if not enabled:
  383. self.hook_state = HookState.IDLE
  384. self.prev_state = HookState.IDLE
  385. self.state_counter = 0
  386. self.left_objects = []
  387. self.right_objects = []
  388. self.prev_left_hook = None
  389. self.prev_right_hook = None
  390. self.prev_left_lug = None
  391. self.prev_right_lug = None
  392. self.update_ui_for_state(HookState.IDLE)
  393. if self.left_player:
  394. self.left_player.set_ai_objects([])
  395. if self.right_player:
  396. self.right_player.set_ai_objects([])
  397. else:
  398. if self.left_player and self._last_left_draw:
  399. self.left_player.set_ai_objects(self._last_left_draw)
  400. if self.right_player and self._last_right_draw:
  401. self.right_player.set_ai_objects(self._last_right_draw)
  402. def _speak(self, msg: str):
  403. self.voice_signal.emit(msg)
  404. def _speak_impl(self, msg: str):
  405. try:
  406. self._voice_queue.put_nowait(msg)
  407. except queue.Full:
  408. pass
  409. @staticmethod
  410. def _get_voices_dir() -> str:
  411. if getattr(sys, 'frozen', False):
  412. base = sys._MEIPASS
  413. else:
  414. base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  415. return os.path.join(base, "assets", "voices")
  416. def _voice_msg_to_path(self, msg: str) -> str:
  417. mapping = {"挂包中": "hooking", "挂包成功": "ready", "放包中": "releasing"}
  418. if msg in mapping:
  419. return os.path.join(self._voices_dir, f"{mapping[msg]}.wav")
  420. m = re.search(r'到达(\d+)号工位', msg)
  421. if m:
  422. return os.path.join(self._voices_dir, f"arrive_{m.group(1)}.wav")
  423. return ""
  424. def _voice_worker(self):
  425. try:
  426. import pythoncom
  427. pythoncom.CoInitialize()
  428. except Exception:
  429. pass
  430. speaker = None
  431. try:
  432. import win32com.client as _win32com
  433. speaker = _win32com.Dispatch("SAPI.SpVoice")
  434. speaker.Rate = 2
  435. except Exception:
  436. logging.warning("SAPI 不可用,仅可使用 WAV 语音")
  437. while True:
  438. msg = self._voice_queue.get()
  439. if msg is None:
  440. break
  441. try:
  442. if self._use_tts and speaker is not None:
  443. speaker.Volume = self._voice_volume
  444. speaker.Speak(msg)
  445. else:
  446. self._play_wav(msg)
  447. except Exception as e:
  448. logging.warning(f"语音播报失败: {e}")
  449. self._voice_queue.task_done()
  450. def _play_wav(self, msg: str):
  451. path = self._voice_msg_to_path(msg)
  452. if not path:
  453. return
  454. import struct, wave, io, winsound
  455. try:
  456. with wave.open(path, 'rb') as wf:
  457. params = wf.getparams()
  458. raw = bytearray(wf.readframes(wf.getnframes()))
  459. gain = self._voice_volume / 100.0
  460. if params.sampwidth == 2:
  461. fmt = '<' + 'h' * (len(raw) // 2)
  462. samples = list(struct.unpack_from(fmt, bytes(raw)))
  463. samples = [max(-32768, min(32767, int(s * gain))) for s in samples]
  464. raw = bytearray(struct.pack(fmt, *samples))
  465. elif params.sampwidth == 1:
  466. samples = list(raw)
  467. samples = [max(0, min(255, int((s - 128) * gain) + 128)) for s in samples]
  468. raw = bytearray(samples)
  469. buf = io.BytesIO()
  470. with wave.open(buf, 'wb') as wf:
  471. wf.setparams(params)
  472. wf.writeframes(bytes(raw))
  473. winsound.PlaySound(buf.getvalue(), winsound.SND_MEMORY)
  474. except Exception:
  475. winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC)
  476. # ---------- 工位图标 ----------
  477. def get_bucket_icon(self, is_active):
  478. icon_dir = os.path.join(os.path.dirname(__file__), "..", "assets")
  479. if not os.path.exists(icon_dir):
  480. os.makedirs(icon_dir, exist_ok=True)
  481. return QIcon()
  482. if is_active:
  483. icon_path = os.path.join(icon_dir, "bucket_active.svg")
  484. else:
  485. icon_path = os.path.join(icon_dir, "bucket_normal.svg")
  486. if os.path.exists(icon_path):
  487. return QIcon(icon_path)
  488. else:
  489. pixmap = QPixmap(64, 64)
  490. pixmap.fill(Qt.green if is_active else Qt.gray)
  491. return QIcon(pixmap)
  492. def refresh_bucket_icons(self):
  493. for btn, bucket in self.bucket_buttons:
  494. icon = self.get_bucket_icon(bucket.is_active)
  495. btn.setIcon(icon)
  496. if bucket.is_active:
  497. btn.setStyleSheet("""
  498. QToolButton {
  499. background-color: #e1f3d8;
  500. border: 2px solid #67c23a;
  501. border-radius: 12px;
  502. font-size: 14px;
  503. font-weight: bold;
  504. color: #1f2d3d;
  505. }
  506. """)
  507. else:
  508. btn.setStyleSheet("""
  509. QToolButton {
  510. background-color: transparent;
  511. border: 2px solid #d0d7de;
  512. border-radius: 12px;
  513. font-size: 14px;
  514. font-weight: bold;
  515. color: #1f2d3d;
  516. }
  517. QToolButton:hover {
  518. background-color: rgba(0, 0, 0, 0.05);
  519. border-color: #409eff;
  520. }
  521. """)
  522. # ---------- 视频尺寸自适应 ----------
  523. def update_video_size(self):
  524. if not self.left_label or not self.right_label:
  525. return
  526. container = self.left_label.parentWidget()
  527. if not container:
  528. return
  529. available_width = container.width() - 15
  530. single_width = available_width // 2
  531. target_height = int(single_width / self.video_aspect_ratio)
  532. if target_height > container.height():
  533. target_height = container.height()
  534. self.left_label.setFixedSize(single_width, target_height)
  535. self.right_label.setFixedSize(single_width, target_height)
  536. def update_players_target_size(self):
  537. if self.left_player:
  538. self.left_player.set_target_size(self.left_label.width(), self.left_label.height())
  539. if self.right_player:
  540. self.right_player.set_target_size(self.right_label.width(), self.right_label.height())
  541. def resizeEvent(self, event: QResizeEvent):
  542. self.update_video_size()
  543. self.update_players_target_size()
  544. super().resizeEvent(event)
  545. def keyPressEvent(self, event: QKeyEvent):
  546. if event.key() == Qt.Key_Escape and self.isFullScreen():
  547. self.showNormal()
  548. elif event.key() == Qt.Key_F11:
  549. if self.isFullScreen():
  550. self.showNormal()
  551. else:
  552. self.showFullScreen()
  553. else:
  554. super().keyPressEvent(event)
  555. def _toggle_fullscreen(self):
  556. if self.isFullScreen():
  557. self.showNormal()
  558. else:
  559. self.showFullScreen()
  560. # ---------- 加载动画和超时 ----------
  561. def show_loading_on_label(self, label: QLabel):
  562. label.setText("加载中...")
  563. label.setAlignment(Qt.AlignCenter)
  564. label.setStyleSheet(label.styleSheet() + "color: white; font-size: 20px;")
  565. def hide_loading_on_label(self, label: QLabel):
  566. label.setText("")
  567. label.setStyleSheet("""
  568. QLabel {
  569. border: 2px solid #dcdfe6;
  570. border-radius: 8px;
  571. background-color: black;
  572. }
  573. """)
  574. def show_timeout_on_label(self, label: QLabel):
  575. label.setText("连接超时")
  576. label.setAlignment(Qt.AlignCenter)
  577. label.setStyleSheet(label.styleSheet() + "color: red; font-size: 20px;")
  578. def on_player_timeout(self, label: QLabel, player_ref, side: str):
  579. self.show_timeout_on_label(label)
  580. # 只停止当前活动的播放器(防止旧信号误杀新播放器)
  581. if side == "left" and self.left_player is player_ref:
  582. self.left_player.stop()
  583. self.left_player = None
  584. elif side == "right" and self.right_player is player_ref:
  585. self.right_player.stop()
  586. self.right_player = None
  587. self._start_rtsp_retry(side)
  588. def _cancel_rtsp_retry(self, side: str):
  589. timer = self._rtsp_retry_left if side == "left" else self._rtsp_retry_right
  590. if timer:
  591. timer.stop()
  592. timer.deleteLater()
  593. if side == "left":
  594. self._rtsp_retry_left = None
  595. self._rtsp_retry_delay_left = 1
  596. else:
  597. self._rtsp_retry_right = None
  598. self._rtsp_retry_delay_right = 1
  599. def _start_rtsp_retry(self, side: str):
  600. if not self.active_bucket:
  601. return
  602. cam = next((c for c in self.active_bucket.cameras if c.position == (CameraPosition.LEFT if side == "left" else CameraPosition.RIGHT)), None)
  603. if not cam:
  604. return
  605. if not self._is_camera_online(cam):
  606. return
  607. label = self.left_label if side == "left" else self.right_label
  608. delay = self._rtsp_retry_delay_left if side == "left" else self._rtsp_retry_delay_right
  609. label.setText(f"正在重连({delay}s)...")
  610. label.setAlignment(Qt.AlignCenter)
  611. label.setStyleSheet(label.styleSheet() + "color: orange; font-size: 20px;")
  612. timer = QTimer(self)
  613. timer.setSingleShot(True)
  614. timer.timeout.connect(lambda: self._retry_rtsp(side))
  615. timer.start(delay * 1000)
  616. if side == "left":
  617. self._rtsp_retry_left = timer
  618. else:
  619. self._rtsp_retry_right = timer
  620. def _retry_rtsp(self, side: str):
  621. if not self.active_bucket:
  622. return
  623. cam = next((c for c in self.active_bucket.cameras if c.position == (CameraPosition.LEFT if side == "left" else CameraPosition.RIGHT)), None)
  624. if not cam:
  625. return
  626. label = self.left_label if side == "left" else self.right_label
  627. if not self._is_camera_online(cam):
  628. self.show_timeout_on_label(label)
  629. self._cancel_rtsp_retry(side)
  630. return
  631. self.show_loading_on_label(label)
  632. w, h = label.width(), label.height()
  633. timeout = self.config.video.rtsp_timeout
  634. player = RTSPPlayer(cam.rtsp_url, w, h, timeout_sec=timeout)
  635. player.frame_ready.connect(label.setPixmap)
  636. player.first_frame_received.connect(lambda s=side: self._on_rtsp_retry_success(s))
  637. player.timeout.connect(lambda l=label, p=player, s=side: self.on_player_timeout(l, p, s))
  638. old = self.left_player if side == "left" else self.right_player
  639. if old:
  640. try:
  641. old.frame_ready.disconnect()
  642. except TypeError:
  643. pass
  644. try:
  645. old.first_frame_received.disconnect()
  646. except TypeError:
  647. pass
  648. try:
  649. old.timeout.disconnect()
  650. except TypeError:
  651. pass
  652. old.stop()
  653. if side == "left":
  654. self.left_player = player
  655. else:
  656. self.right_player = player
  657. player.start()
  658. def _on_rtsp_retry_success(self, side: str):
  659. self.hide_loading_on_label(self.left_label if side == "left" else self.right_label)
  660. self._cancel_rtsp_retry(side)
  661. # 重连成功后恢复 AI 对象标注
  662. if self.active_bucket and self.ai_enabled:
  663. objects = self._last_left_draw if side == "left" else self._last_right_draw
  664. player = self.left_player if side == "left" else self.right_player
  665. if player:
  666. player.set_ai_objects(objects)
  667. def _is_camera_online(self, cam) -> bool:
  668. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  669. if not m:
  670. return False
  671. lat = self._camera_signal.get(m.group(1))
  672. return lat is not None and lat >= 0
  673. def on_bucket_clicked(self, bucket: BucketModel):
  674. if self.switch_mode != SwitchMode.HAND:
  675. self.switch_mode = SwitchMode.HAND
  676. self.auto_switch_btn.setChecked(False)
  677. self.auto_switch_btn.setText("手动模式")
  678. self.activate_bucket(bucket)
  679. def on_rfid_state_changed(self, online: bool):
  680. self.rfid_state_signal.emit(online)
  681. def _on_rfid_state_impl(self, online: bool):
  682. if online:
  683. self.module_status.setText("通讯状态: RFID正常")
  684. else:
  685. self.module_status.setText("通讯状态: RFID离线")
  686. # ---------- 硬件初始化 ----------
  687. def init_hardware(self):
  688. if self.config.module_type == ModuleType.RFID and self.config.rfid:
  689. self.rfid_client = RFIDClient(
  690. self.config.rfid,
  691. self.on_rfid_tag,
  692. on_state_changed=self.on_rfid_state_changed,
  693. tag_offset=18,
  694. tag_length=4,
  695. hex_output=True
  696. )
  697. if self.rfid_client.connect():
  698. self.module_status.setText("通讯状态: RFID正常")
  699. else:
  700. self.module_status.setText("通讯状态: RFID离线")
  701. if self.config.module_type == ModuleType.HD3200 and self.config.modbus:
  702. self.modbus_client = ModbusClient(self.config.modbus, self.on_modbus_data)
  703. if self.modbus_client.connect():
  704. self.module_status.setText("通讯状态: Modbus正常")
  705. else:
  706. self.module_status.setText("通讯状态: Modbus离线")
  707. if self.config.ai.enabled:
  708. self.mqtt_client = MQTTClient(self.on_mqtt_message)
  709. self._connect_current_ai()
  710. def _connect_current_ai(self, bucket=None):
  711. if bucket is None:
  712. bucket = self.active_bucket
  713. idx = None
  714. if bucket and bucket.ai_module_id is not None:
  715. idx = bucket.ai_module_id
  716. elif self.config.ai_modules:
  717. idx = 0
  718. if idx is not None and idx < len(self.config.ai_modules):
  719. target = self.config.ai_modules[idx]
  720. self.mqtt_client.disconnect()
  721. self.mqtt_client.connect(target.broker, target.port)
  722. self._current_ai_module_id = idx
  723. if bucket:
  724. topics = [c.topic for c in bucket.cameras if c.topic]
  725. if topics:
  726. self.mqtt_client.subscribe(topics)
  727. self._subscribed_topics = set(topics)
  728. # ---------- 激活工位 ----------
  729. def activate_bucket(self, bucket: BucketModel):
  730. if self.active_bucket == bucket:
  731. return
  732. self._cancel_rtsp_retry("left")
  733. self._cancel_rtsp_retry("right")
  734. if bucket and self.voice_enabled:
  735. self._speak(f"到达{bucket.description}")
  736. for player in (self.left_player, self.right_player):
  737. if player is None:
  738. continue
  739. try:
  740. player.frame_ready.disconnect()
  741. except TypeError:
  742. pass
  743. try:
  744. player.first_frame_received.disconnect()
  745. except TypeError:
  746. pass
  747. try:
  748. player.timeout.disconnect()
  749. except TypeError:
  750. pass
  751. player.stop()
  752. self.left_player = None
  753. self.right_player = None
  754. if self.active_bucket:
  755. self.active_bucket.is_active = False
  756. self.active_bucket = bucket
  757. if bucket:
  758. bucket.is_active = True
  759. self.refresh_bucket_icons()
  760. self.show_loading_on_label(self.left_label)
  761. self.show_loading_on_label(self.right_label)
  762. # 重置挂钩状态
  763. self.hook_state = HookState.IDLE
  764. self.prev_state = HookState.IDLE
  765. self.state_counter = 0
  766. self.left_objects = []
  767. self.right_objects = []
  768. self.prev_left_hook = None
  769. self.prev_right_hook = None
  770. self.prev_left_lug = None
  771. self.prev_right_lug = None
  772. self.last_voice_state = None
  773. self._last_hooking_voice_time = 0.0
  774. self.update_ui_for_state(HookState.IDLE)
  775. # 先切 AI 模块 + 订阅 MQTT topic(非阻塞),再启 RTSP
  776. if bucket and self.config.ai.enabled and self.mqtt_client:
  777. if bucket.ai_module_id != self._current_ai_module_id:
  778. self._unsubscribe_all_topics()
  779. self._connect_current_ai(bucket)
  780. if bucket:
  781. if self.mqtt_client:
  782. topics = [c.topic for c in bucket.cameras if c.topic]
  783. stale = self._subscribed_topics - set(topics)
  784. for t in stale:
  785. self.mqtt_client.unsubscribe(t)
  786. new_t = [t for t in topics if t not in self._subscribed_topics]
  787. if new_t:
  788. self.mqtt_client.subscribe(new_t)
  789. self._subscribed_topics = set(topics)
  790. # 再启 RTSP
  791. left_cam = next((c for c in bucket.cameras if c.position == CameraPosition.LEFT), None)
  792. right_cam = next((c for c in bucket.cameras if c.position == CameraPosition.RIGHT), None)
  793. if left_cam and self._is_camera_online(left_cam):
  794. w = self.left_label.width()
  795. h = self.left_label.height()
  796. timeout = self.config.video.rtsp_timeout
  797. player = RTSPPlayer(left_cam.rtsp_url, w, h, timeout_sec=timeout)
  798. player.frame_ready.connect(self.left_label.setPixmap)
  799. player.first_frame_received.connect(lambda p=player: self.hide_loading_on_label(self.left_label))
  800. player.timeout.connect(lambda p=player: self.on_player_timeout(self.left_label, p, "left"))
  801. self.left_player = player
  802. player.start()
  803. elif left_cam:
  804. self.show_timeout_on_label(self.left_label)
  805. if right_cam and self._is_camera_online(right_cam):
  806. w = self.right_label.width()
  807. h = self.right_label.height()
  808. timeout = self.config.video.rtsp_timeout
  809. player = RTSPPlayer(right_cam.rtsp_url, w, h, timeout_sec=timeout)
  810. player.frame_ready.connect(self.right_label.setPixmap)
  811. player.first_frame_received.connect(lambda p=player: self.hide_loading_on_label(self.right_label))
  812. player.timeout.connect(lambda p=player: self.on_player_timeout(self.right_label, p, "right"))
  813. self.right_player = player
  814. player.start()
  815. elif right_cam:
  816. self.show_timeout_on_label(self.right_label)
  817. else:
  818. self._unsubscribe_all_topics()
  819. self.left_label.clear()
  820. self.right_label.clear()
  821. QApplication.processEvents()
  822. def _unsubscribe_all_topics(self):
  823. if self.mqtt_client and self._subscribed_topics:
  824. for t in self._subscribed_topics:
  825. self.mqtt_client.unsubscribe(t)
  826. self._subscribed_topics.clear()
  827. # ---------- RFID 标签回调 ----------
  828. def on_rfid_tag(self, tag: str):
  829. self.rfid_tag_signal.emit(tag)
  830. def _on_rfid_tag_impl(self, tag: str):
  831. self._last_rfid_tag = tag
  832. if tag:
  833. self.rfid_tag_label.setText(f"当前卡号: {tag}")
  834. else:
  835. self.rfid_tag_label.setText("当前卡号: 无")
  836. if tag is None:
  837. if self.switch_mode == SwitchMode.AUTO and self.active_bucket:
  838. self.activate_bucket(None)
  839. return
  840. for bucket in self.buckets:
  841. if bucket.code_x == tag:
  842. if self.switch_mode == SwitchMode.AUTO:
  843. self.activate_bucket(bucket)
  844. break
  845. # ---------- Modbus 数据回调 ----------
  846. def on_modbus_data(self, bools):
  847. self.modbus_data_signal.emit(bools)
  848. def _on_modbus_data_impl(self, bools):
  849. for bucket in self.buckets:
  850. try:
  851. addr_x = int(bucket.code_x)
  852. except (ValueError, TypeError):
  853. continue
  854. if addr_x < 0 or addr_x >= len(bools):
  855. continue
  856. if bucket.code_y and bucket.code_y.strip():
  857. try:
  858. addr_y = int(bucket.code_y)
  859. except (ValueError, TypeError):
  860. continue
  861. if addr_y < 0 or addr_y >= len(bools):
  862. continue
  863. if bools[addr_x] and bools[addr_y]:
  864. if self.switch_mode == SwitchMode.AUTO and self.active_bucket != bucket:
  865. self.activate_bucket(bucket)
  866. break
  867. else:
  868. if bools[addr_x]:
  869. if self.switch_mode == SwitchMode.AUTO and self.active_bucket != bucket:
  870. self.activate_bucket(bucket)
  871. break
  872. # ---------- MQTT 消息处理 ----------
  873. def parse_ai_objects(self, payload: dict, display_width: int, display_height: int,
  874. include_ready: bool = True) -> list:
  875. if payload is None or not isinstance(payload, dict):
  876. return []
  877. orig_width = payload.get("width", 640)
  878. orig_height = payload.get("height", 360)
  879. scale_x = display_width / orig_width if orig_width > 0 else 1.0
  880. scale_y = display_height / orig_height if orig_height > 0 else 1.0
  881. objects = []
  882. for obj_str in payload.get("objects", []):
  883. parts = obj_str.split('|')
  884. if len(parts) >= 6:
  885. try:
  886. x = int(float(parts[1]) * scale_x)
  887. y = int(float(parts[2]) * scale_y)
  888. w = int(float(parts[3]) * scale_x)
  889. h = int(float(parts[4]) * scale_y)
  890. raw_label = parts[5]
  891. label = raw_label
  892. if 'hoist' in raw_label:
  893. label = 'hook'
  894. elif 'cup' in raw_label:
  895. label = 'lug'
  896. if 'hook' in label:
  897. color = (0, 0, 255)
  898. elif 'lug' in label:
  899. color = (255, 0, 0)
  900. elif 'ready' in label:
  901. if not include_ready:
  902. continue
  903. color = (0, 255, 255)
  904. else:
  905. color = (128, 128, 128)
  906. objects.append({
  907. "x": x,
  908. "y": y,
  909. "w": w,
  910. "h": h,
  911. "label": label,
  912. "raw_label": raw_label,
  913. "color": color
  914. })
  915. except (ValueError, IndexError):
  916. continue
  917. return objects
  918. def on_mqtt_message(self, topic: str, payload: dict):
  919. self.mqtt_message_signal.emit(topic, payload)
  920. def _on_mqtt_message_impl(self, topic: str, payload: dict):
  921. if payload is None or not isinstance(payload, dict):
  922. return
  923. if not self.active_bucket:
  924. return
  925. if not self.ai_enabled:
  926. return
  927. now = time.time()
  928. if now - self._last_mqtt_process < 0.03:
  929. return
  930. self._last_mqtt_process = now
  931. for cam in self.active_bucket.cameras:
  932. if cam.topic == topic:
  933. self.update_video_size()
  934. if cam.position == CameraPosition.LEFT:
  935. display_width = self.left_label.width()
  936. display_height = self.left_label.height()
  937. else:
  938. display_width = self.right_label.width()
  939. display_height = self.right_label.height()
  940. if display_width == 0 or display_height == 0:
  941. display_width = 640
  942. display_height = 360
  943. all_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=True)
  944. if cam.position == CameraPosition.LEFT:
  945. self.left_objects = all_objects
  946. else:
  947. self.right_objects = all_objects
  948. self.evaluate_hook_state()
  949. draw_objects = [o for o in all_objects if 'ready' not in o['label'].lower()]
  950. for obj in draw_objects:
  951. if not obj.get('paired'):
  952. obj['dimmed'] = True
  953. if cam.position == CameraPosition.LEFT:
  954. self._last_left_draw = draw_objects
  955. if self.left_player:
  956. self.left_player.set_ai_objects(draw_objects)
  957. else:
  958. self._last_right_draw = draw_objects
  959. if self.right_player:
  960. self.right_player.set_ai_objects(draw_objects)
  961. break
  962. # ---------- 挂钩状态评估 ----------
  963. def evaluate_hook_state(self):
  964. left_hooks = [o for o in self.left_objects if 'hook' in o['label'].lower()]
  965. left_lugs = [o for o in self.left_objects if 'lug' in o['label'].lower()]
  966. right_hooks = [o for o in self.right_objects if 'hook' in o['label'].lower()]
  967. right_lugs = [o for o in self.right_objects if 'lug' in o['label'].lower()]
  968. def find_nearest_pair(hooks, lugs):
  969. if not hooks or not lugs:
  970. return None
  971. best_pair = None
  972. best_dist = float('inf')
  973. for h in hooks:
  974. hx = h['x'] + h['w'] / 2
  975. hy = h['y'] + h['h'] / 2
  976. for l in lugs:
  977. lx = l['x'] + l['w'] / 2
  978. ly = l['y'] + l['h'] / 2
  979. dist = (hx - lx) ** 2 + (hy - ly) ** 2
  980. if dist < best_dist:
  981. best_dist = dist
  982. best_pair = (h, l)
  983. return best_pair
  984. left_pair = find_nearest_pair(left_hooks, left_lugs)
  985. right_pair = find_nearest_pair(right_hooks, right_lugs)
  986. is_releasing = False
  987. if left_pair:
  988. h, l = left_pair
  989. if self.prev_left_hook and self.prev_left_lug:
  990. dy_h = (h['y'] + h['h'] / 2) - self.prev_left_hook[1]
  991. dy_l = (l['y'] + l['h'] / 2) - self.prev_left_lug[1]
  992. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  993. is_releasing = True
  994. self.prev_left_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  995. self.prev_left_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  996. else:
  997. self.prev_left_hook = None
  998. self.prev_left_lug = None
  999. if right_pair:
  1000. h, l = right_pair
  1001. if self.prev_right_hook and self.prev_right_lug:
  1002. dy_h = (h['y'] + h['h'] / 2) - self.prev_right_hook[1]
  1003. dy_l = (l['y'] + l['h'] / 2) - self.prev_right_lug[1]
  1004. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  1005. is_releasing = True
  1006. self.prev_right_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  1007. self.prev_right_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  1008. else:
  1009. self.prev_right_hook = None
  1010. self.prev_right_lug = None
  1011. if is_releasing:
  1012. new_state = HookState.RELEASING
  1013. else:
  1014. any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects)
  1015. has_intersecting_pair = self._has_intersecting_pair(left_pair, right_pair)
  1016. if any_ready and has_intersecting_pair:
  1017. new_state = HookState.READY
  1018. elif has_intersecting_pair:
  1019. new_state = HookState.HOOKING
  1020. else:
  1021. new_state = HookState.IDLE
  1022. if self.hook_state == HookState.READY and new_state != HookState.READY:
  1023. threshold = 15
  1024. else:
  1025. threshold = 1 if new_state == HookState.IDLE else self.STATE_THRESHOLD
  1026. if new_state == self.prev_state:
  1027. self.state_counter += 1
  1028. else:
  1029. self.prev_state = new_state
  1030. self.state_counter = 1
  1031. if self.state_counter >= threshold:
  1032. if new_state != self.hook_state:
  1033. self.hook_state = new_state
  1034. self.update_ui_for_state(new_state)
  1035. self.state_counter = 0
  1036. for obj in self.left_objects + self.right_objects:
  1037. obj.pop('paired', None)
  1038. if left_pair:
  1039. left_pair[0]['paired'] = True
  1040. left_pair[1]['paired'] = True
  1041. if right_pair:
  1042. right_pair[0]['paired'] = True
  1043. right_pair[1]['paired'] = True
  1044. def rects_intersect(self, a, b):
  1045. ax1, ay1, ax2, ay2 = a['x'], a['y'], a['x']+a['w'], a['y']+a['h']
  1046. bx1, by1, bx2, by2 = b['x'], b['y'], b['x']+b['w'], b['y']+b['h']
  1047. return not (ax2 < bx1 or bx2 < ax1 or ay2 < by1 or by2 < ay1)
  1048. def _has_intersecting_pair(self, left_pair, right_pair):
  1049. if left_pair:
  1050. h, l = left_pair
  1051. if self.rects_intersect(h, l):
  1052. return True
  1053. if right_pair:
  1054. h, l = right_pair
  1055. if self.rects_intersect(h, l):
  1056. return True
  1057. return False
  1058. # ---------- UI 更新与语音 ----------
  1059. def update_ui_for_state(self, state):
  1060. if state == HookState.IDLE:
  1061. border_color = "#dcdfe6"
  1062. text = "空闲"
  1063. elif state == HookState.RELEASING:
  1064. border_color = "#ffa500"
  1065. text = "放包中"
  1066. elif state == HookState.HOOKING:
  1067. border_color = "#ff0000"
  1068. text = "挂包中"
  1069. elif state == HookState.READY:
  1070. border_color = "#00ff00"
  1071. text = "挂包成功"
  1072. else:
  1073. border_color = "#dcdfe6"
  1074. text = ""
  1075. self.hook_status_label.setText(f"挂钩状态: {text}")
  1076. style = f"""
  1077. QLabel {{
  1078. border: 4px solid {border_color};
  1079. border-radius: 8px;
  1080. background-color: black;
  1081. }}
  1082. """
  1083. self.left_label.setStyleSheet(style)
  1084. self.right_label.setStyleSheet(style)
  1085. if self.voice_enabled and state in (HookState.HOOKING, HookState.READY):
  1086. now = time.time()
  1087. if state == HookState.HOOKING:
  1088. if state != self.last_voice_state or now - self._last_hooking_voice_time > 15:
  1089. self._speak("挂包中")
  1090. self.last_voice_state = state
  1091. self._last_hooking_voice_time = now
  1092. elif state != self.last_voice_state:
  1093. self._speak("挂包成功")
  1094. self.last_voice_state = state
  1095. # ---------- 定时任务 ----------
  1096. def check_idle(self):
  1097. if self.rfid_client:
  1098. self.rfid_client.check_idle()
  1099. now = time.time()
  1100. if self.config.ai.enabled and self.mqtt_client and not self.mqtt_client.connected:
  1101. if now - self._last_mqtt_connect_attempt > 30:
  1102. self._last_mqtt_connect_attempt = now
  1103. self._connect_current_ai()
  1104. # 每 5 秒检测一次摄像头信号
  1105. if now - self._last_signal_check > 5:
  1106. self._last_signal_check = now
  1107. if not getattr(self, '_signal_check_thread', None) or not self._signal_check_thread.is_alive():
  1108. self._signal_check_thread = threading.Thread(target=self._check_all_cameras, daemon=True)
  1109. self._signal_check_thread.start()
  1110. def _collect_camera_ips(self) -> list:
  1111. ips = set()
  1112. for bucket in self.buckets:
  1113. for cam in bucket.cameras:
  1114. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  1115. if m:
  1116. ips.add(m.group(1))
  1117. return sorted(ips)
  1118. def _check_all_cameras(self):
  1119. ips = self._collect_camera_ips()
  1120. if not ips:
  1121. return
  1122. results = {}
  1123. for ip in ips:
  1124. try:
  1125. if sys.platform == "win32":
  1126. r = subprocess.run(["ping", "-n", "1", ip], capture_output=True, timeout=5, text=True, creationflags=subprocess.CREATE_NO_WINDOW)
  1127. else:
  1128. r = subprocess.run(["ping", "-c", "1", ip], capture_output=True, timeout=5, text=True)
  1129. if r.returncode != 0:
  1130. results[ip] = -1
  1131. else:
  1132. m = re.search(r'time[=<]\s*(\d+(?:\.\d+)?)\s*ms', r.stdout, re.IGNORECASE)
  1133. if m:
  1134. results[ip] = float(m.group(1))
  1135. else:
  1136. results[ip] = 0
  1137. except subprocess.TimeoutExpired:
  1138. results[ip] = -1
  1139. except Exception:
  1140. results[ip] = -1
  1141. self.camera_signal_signal.emit(results)
  1142. def _on_camera_signal(self, results: dict):
  1143. was_offline = {ip for ip, lat in self._camera_signal.items() if lat is not None and lat < 0}
  1144. self._camera_signal.update(results)
  1145. now_online = {ip for ip, lat in results.items() if lat >= 0}
  1146. revived = now_online & was_offline
  1147. if revived and self.active_bucket:
  1148. for cam in self.active_bucket.cameras:
  1149. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  1150. if m and m.group(1) in revived:
  1151. side = "left" if cam.position == CameraPosition.LEFT else "right"
  1152. cur = self.left_player if side == "left" else self.right_player
  1153. if cur is None:
  1154. self._retry_rtsp(side)
  1155. for bucket in self.buckets:
  1156. bars = self._bucket_signal_labels.get(bucket.id)
  1157. if not bars:
  1158. continue
  1159. left_bar, right_bar = bars
  1160. for bar, cam in zip((left_bar, right_bar), bucket.cameras):
  1161. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  1162. if not m:
  1163. bar.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
  1164. continue
  1165. ip = m.group(1)
  1166. lat = self._camera_signal.get(ip)
  1167. if lat is None:
  1168. bar.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
  1169. elif lat < 0:
  1170. bar.setStyleSheet("background-color: #f56c6c; border-radius: 2px;")
  1171. elif lat < 50:
  1172. bar.setStyleSheet("background-color: #67c23a; border-radius: 2px;")
  1173. elif lat < 200:
  1174. bar.setStyleSheet("background-color: #e6a23c; border-radius: 2px;")
  1175. else:
  1176. bar.setStyleSheet("background-color: #f56c6c; border-radius: 2px;")
  1177. def toggle_switch_mode(self, checked: bool):
  1178. if checked:
  1179. self.switch_mode = SwitchMode.AUTO
  1180. self.auto_switch_btn.setText("自动模式")
  1181. if self._last_rfid_tag:
  1182. self._on_rfid_tag_impl(self._last_rfid_tag)
  1183. elif self.active_bucket:
  1184. self.activate_bucket(None)
  1185. else:
  1186. self.switch_mode = SwitchMode.HAND
  1187. self.auto_switch_btn.setText("手动模式")
  1188. # ---------- 开机自启 ----------
  1189. def _save_config(self):
  1190. import configparser
  1191. cfg = configparser.ConfigParser()
  1192. cfg.read(self._config_path, encoding="utf-8")
  1193. if not cfg.has_section("AI"):
  1194. cfg.add_section("AI")
  1195. val = "true" if self.config.ai.tts_enabled else "false"
  1196. cfg.set("AI", "tts_enabled", val)
  1197. with open(self._config_path, "w", encoding="utf-8") as f:
  1198. cfg.write(f)
  1199. logger.info(f"TTS 设置已保存: {self._config_path} → tts_enabled={val}")
  1200. @staticmethod
  1201. def _autostart_config_dir():
  1202. if sys.platform == "win32":
  1203. return os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "HookVision")
  1204. return os.path.join(os.path.expanduser("~"), ".config", "HookVision")
  1205. @staticmethod
  1206. def _autostart_config_path():
  1207. return os.path.join(MainWindow._autostart_config_dir(), "autostart.txt") # 改为 txt
  1208. @staticmethod
  1209. def _autostart_desktop_path():
  1210. return os.path.expanduser("~/.config/autostart/HookVision.desktop")
  1211. @staticmethod
  1212. def _read_autostart_flag():
  1213. path = MainWindow._autostart_config_path()
  1214. try:
  1215. with open(path, "r", encoding="utf-8") as f:
  1216. content = f.read().strip().lower()
  1217. return content == "true"
  1218. except:
  1219. return False
  1220. @staticmethod
  1221. def _write_autostart_flag(enabled: bool):
  1222. path = MainWindow._autostart_config_path()
  1223. os.makedirs(os.path.dirname(path), exist_ok=True)
  1224. with open(path, "w", encoding="utf-8") as f:
  1225. f.write("true" if enabled else "false")
  1226. @staticmethod
  1227. def _autostart_command():
  1228. if getattr(sys, 'frozen', False):
  1229. return sys.executable
  1230. else:
  1231. main_script = os.path.abspath(sys.argv[0])
  1232. python_exe = sys.executable
  1233. if ' ' in python_exe:
  1234. python_exe = f'"{python_exe}"'
  1235. if ' ' in main_script:
  1236. main_script = f'"{main_script}"'
  1237. if sys.platform == "win32":
  1238. pythonw = python_exe.replace('python.exe', 'pythonw.exe')
  1239. if pythonw != python_exe and os.path.exists(pythonw):
  1240. python_exe = pythonw
  1241. return f'{python_exe} {main_script}'
  1242. @staticmethod
  1243. def _apply_system_autostart(enabled: bool) -> bool:
  1244. cmd = MainWindow._autostart_command()
  1245. try:
  1246. if sys.platform == "win32":
  1247. import winreg
  1248. key = r"Software\Microsoft\Windows\CurrentVersion\Run"
  1249. with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as rk:
  1250. if enabled:
  1251. winreg.SetValueEx(rk, MainWindow.APPTITLE, 0, winreg.REG_SZ, cmd)
  1252. else:
  1253. try:
  1254. winreg.DeleteValue(rk, MainWindow.APPTITLE)
  1255. except OSError:
  1256. pass
  1257. return True
  1258. else:
  1259. desktop_path = MainWindow._autostart_desktop_path()
  1260. if enabled:
  1261. os.makedirs(os.path.dirname(desktop_path), exist_ok=True)
  1262. with open(desktop_path, "w", encoding="utf-8") as f:
  1263. f.write(f"""[Desktop Entry]
  1264. Type=Application
  1265. Name={MainWindow.APPTITLE}
  1266. Exec={cmd}
  1267. Terminal=false
  1268. X-GNOME-Autostart-enabled=true
  1269. """)
  1270. else:
  1271. try:
  1272. os.remove(desktop_path)
  1273. except OSError:
  1274. pass
  1275. return True
  1276. except Exception as e:
  1277. logger.error(f"设置开机自启失败: {e}")
  1278. return False
  1279. def _on_autostart_toggle(self, state):
  1280. enabled = (state == Qt.Checked)
  1281. self._write_autostart_flag(enabled)
  1282. ok = self._apply_system_autostart(enabled)
  1283. if not ok:
  1284. QMessageBox.warning(
  1285. self,
  1286. "设置失败",
  1287. "开机自启动设置失败,请检查权限或手动添加。\n"
  1288. "Windows: 将本程序快捷方式放入 '启动' 文件夹。\n"
  1289. "Linux: 检查 ~/.config/autostart/ 目录权限。"
  1290. )