main_window.py 58 KB

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