main_window.py 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397
  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. label = self.left_label if side == "left" else self.right_label
  606. delay = self._rtsp_retry_delay_left if side == "left" else self._rtsp_retry_delay_right
  607. label.setText(f"正在重连({delay}s)...")
  608. label.setAlignment(Qt.AlignCenter)
  609. label.setStyleSheet(label.styleSheet() + "color: orange; font-size: 20px;")
  610. timer = QTimer(self)
  611. timer.setSingleShot(True)
  612. timer.timeout.connect(lambda: self._retry_rtsp(side))
  613. timer.start(delay * 1000)
  614. if side == "left":
  615. self._rtsp_retry_left = timer
  616. else:
  617. self._rtsp_retry_right = timer
  618. def _retry_rtsp(self, side: str):
  619. if not self.active_bucket:
  620. return
  621. cam = next((c for c in self.active_bucket.cameras if c.position == (CameraPosition.LEFT if side == "left" else CameraPosition.RIGHT)), None)
  622. if not cam:
  623. return
  624. label = self.left_label if side == "left" else self.right_label
  625. self.show_loading_on_label(label)
  626. w, h = label.width(), label.height()
  627. timeout = self.config.video.rtsp_timeout
  628. player = RTSPPlayer(cam.rtsp_url, w, h, timeout_sec=timeout)
  629. player.frame_ready.connect(label.setPixmap)
  630. player.first_frame_received.connect(lambda s=side: self._on_rtsp_retry_success(s))
  631. player.timeout.connect(lambda l=label, p=player, s=side: self.on_player_timeout(l, p, s))
  632. old = self.left_player if side == "left" else self.right_player
  633. if old:
  634. try:
  635. old.frame_ready.disconnect()
  636. except TypeError:
  637. pass
  638. try:
  639. old.first_frame_received.disconnect()
  640. except TypeError:
  641. pass
  642. try:
  643. old.timeout.disconnect()
  644. except TypeError:
  645. pass
  646. old.stop()
  647. if side == "left":
  648. self.left_player = player
  649. else:
  650. self.right_player = player
  651. player.start()
  652. def _on_rtsp_retry_success(self, side: str):
  653. self.hide_loading_on_label(self.left_label if side == "left" else self.right_label)
  654. self._cancel_rtsp_retry(side)
  655. # 重连成功后恢复 AI 对象标注
  656. if self.active_bucket and self.ai_enabled:
  657. objects = self._last_left_draw if side == "left" else self._last_right_draw
  658. player = self.left_player if side == "left" else self.right_player
  659. if player:
  660. player.set_ai_objects(objects)
  661. # ---------- 点击工位 ----------
  662. def on_bucket_clicked(self, bucket: BucketModel):
  663. if self.switch_mode != SwitchMode.HAND:
  664. self.switch_mode = SwitchMode.HAND
  665. self.auto_switch_btn.setChecked(False)
  666. self.auto_switch_btn.setText("手动模式")
  667. self.activate_bucket(bucket)
  668. def on_rfid_state_changed(self, online: bool):
  669. self.rfid_state_signal.emit(online)
  670. def _on_rfid_state_impl(self, online: bool):
  671. if online:
  672. self.module_status.setText("通讯状态: RFID正常")
  673. else:
  674. self.module_status.setText("通讯状态: RFID离线")
  675. # ---------- 硬件初始化 ----------
  676. def init_hardware(self):
  677. if self.config.module_type == ModuleType.RFID and self.config.rfid:
  678. self.rfid_client = RFIDClient(
  679. self.config.rfid,
  680. self.on_rfid_tag,
  681. on_state_changed=self.on_rfid_state_changed,
  682. tag_offset=18,
  683. tag_length=4,
  684. hex_output=True
  685. )
  686. if self.rfid_client.connect():
  687. self.module_status.setText("通讯状态: RFID正常")
  688. else:
  689. self.module_status.setText("通讯状态: RFID离线")
  690. if self.config.module_type == ModuleType.HD3200 and self.config.modbus:
  691. self.modbus_client = ModbusClient(self.config.modbus, self.on_modbus_data)
  692. if self.modbus_client.connect():
  693. self.module_status.setText("通讯状态: Modbus正常")
  694. else:
  695. self.module_status.setText("通讯状态: Modbus离线")
  696. if self.config.ai.enabled:
  697. self.mqtt_client = MQTTClient(self.on_mqtt_message)
  698. self._connect_current_ai()
  699. def _connect_current_ai(self, bucket=None):
  700. if bucket is None:
  701. bucket = self.active_bucket
  702. idx = None
  703. if bucket and bucket.ai_module_id is not None:
  704. idx = bucket.ai_module_id
  705. elif self.config.ai_modules:
  706. idx = 0
  707. if idx is not None and idx < len(self.config.ai_modules):
  708. target = self.config.ai_modules[idx]
  709. self.mqtt_client.disconnect()
  710. self.mqtt_client.connect(target.broker, target.port)
  711. self._current_ai_module_id = idx
  712. if bucket:
  713. topics = [c.topic for c in bucket.cameras if c.topic]
  714. if topics:
  715. self.mqtt_client.subscribe(topics)
  716. self._subscribed_topics = set(topics)
  717. # ---------- 激活工位 ----------
  718. def activate_bucket(self, bucket: BucketModel):
  719. if self.active_bucket == bucket:
  720. return
  721. self._cancel_rtsp_retry("left")
  722. self._cancel_rtsp_retry("right")
  723. if bucket and self.voice_enabled:
  724. self._speak(f"到达{bucket.description}")
  725. for player in (self.left_player, self.right_player):
  726. if player is None:
  727. continue
  728. try:
  729. player.frame_ready.disconnect()
  730. except TypeError:
  731. pass
  732. try:
  733. player.first_frame_received.disconnect()
  734. except TypeError:
  735. pass
  736. try:
  737. player.timeout.disconnect()
  738. except TypeError:
  739. pass
  740. player.stop()
  741. self.left_player = None
  742. self.right_player = None
  743. if self.active_bucket:
  744. self.active_bucket.is_active = False
  745. self.active_bucket = bucket
  746. if bucket:
  747. bucket.is_active = True
  748. self.refresh_bucket_icons()
  749. self.show_loading_on_label(self.left_label)
  750. self.show_loading_on_label(self.right_label)
  751. # 重置挂钩状态
  752. self.hook_state = HookState.IDLE
  753. self.prev_state = HookState.IDLE
  754. self.state_counter = 0
  755. self.left_objects = []
  756. self.right_objects = []
  757. self.prev_left_hook = None
  758. self.prev_right_hook = None
  759. self.prev_left_lug = None
  760. self.prev_right_lug = None
  761. self.last_voice_state = None
  762. self._last_hooking_voice_time = 0.0
  763. self.update_ui_for_state(HookState.IDLE)
  764. # 先切 AI 模块 + 订阅 MQTT topic(非阻塞),再启 RTSP
  765. if bucket and self.config.ai.enabled and self.mqtt_client:
  766. if bucket.ai_module_id != self._current_ai_module_id:
  767. self._unsubscribe_all_topics()
  768. self._connect_current_ai(bucket)
  769. if bucket:
  770. if self.mqtt_client:
  771. topics = [c.topic for c in bucket.cameras if c.topic]
  772. stale = self._subscribed_topics - set(topics)
  773. for t in stale:
  774. self.mqtt_client.unsubscribe(t)
  775. new_t = [t for t in topics if t not in self._subscribed_topics]
  776. if new_t:
  777. self.mqtt_client.subscribe(new_t)
  778. self._subscribed_topics = set(topics)
  779. # 再启 RTSP
  780. left_cam = next((c for c in bucket.cameras if c.position == CameraPosition.LEFT), None)
  781. right_cam = next((c for c in bucket.cameras if c.position == CameraPosition.RIGHT), None)
  782. if left_cam:
  783. w = self.left_label.width()
  784. h = self.left_label.height()
  785. timeout = self.config.video.rtsp_timeout
  786. player = RTSPPlayer(left_cam.rtsp_url, w, h, timeout_sec=timeout)
  787. player.frame_ready.connect(self.left_label.setPixmap)
  788. player.first_frame_received.connect(lambda p=player: self.hide_loading_on_label(self.left_label))
  789. player.timeout.connect(lambda p=player: self.on_player_timeout(self.left_label, p, "left"))
  790. self.left_player = player
  791. player.start()
  792. if right_cam:
  793. w = self.right_label.width()
  794. h = self.right_label.height()
  795. timeout = self.config.video.rtsp_timeout
  796. player = RTSPPlayer(right_cam.rtsp_url, w, h, timeout_sec=timeout)
  797. player.frame_ready.connect(self.right_label.setPixmap)
  798. player.first_frame_received.connect(lambda p=player: self.hide_loading_on_label(self.right_label))
  799. player.timeout.connect(lambda p=player: self.on_player_timeout(self.right_label, p, "right"))
  800. self.right_player = player
  801. player.start()
  802. else:
  803. self._unsubscribe_all_topics()
  804. self.left_label.clear()
  805. self.right_label.clear()
  806. QApplication.processEvents()
  807. def _unsubscribe_all_topics(self):
  808. if self.mqtt_client and self._subscribed_topics:
  809. for t in self._subscribed_topics:
  810. self.mqtt_client.unsubscribe(t)
  811. self._subscribed_topics.clear()
  812. # ---------- RFID 标签回调 ----------
  813. def on_rfid_tag(self, tag: str):
  814. self.rfid_tag_signal.emit(tag)
  815. def _on_rfid_tag_impl(self, tag: str):
  816. self._last_rfid_tag = tag
  817. if tag:
  818. self.rfid_tag_label.setText(f"当前卡号: {tag}")
  819. else:
  820. self.rfid_tag_label.setText("当前卡号: 无")
  821. if tag is None:
  822. if self.switch_mode == SwitchMode.AUTO and self.active_bucket:
  823. self.activate_bucket(None)
  824. return
  825. for bucket in self.buckets:
  826. if bucket.code_x == tag:
  827. if self.switch_mode == SwitchMode.AUTO:
  828. self.activate_bucket(bucket)
  829. break
  830. # ---------- Modbus 数据回调 ----------
  831. def on_modbus_data(self, bools):
  832. self.modbus_data_signal.emit(bools)
  833. def _on_modbus_data_impl(self, bools):
  834. for bucket in self.buckets:
  835. try:
  836. addr_x = int(bucket.code_x)
  837. except (ValueError, TypeError):
  838. continue
  839. if addr_x < 0 or addr_x >= len(bools):
  840. continue
  841. if bucket.code_y and bucket.code_y.strip():
  842. try:
  843. addr_y = int(bucket.code_y)
  844. except (ValueError, TypeError):
  845. continue
  846. if addr_y < 0 or addr_y >= len(bools):
  847. continue
  848. if bools[addr_x] and bools[addr_y]:
  849. if self.switch_mode == SwitchMode.AUTO and self.active_bucket != bucket:
  850. self.activate_bucket(bucket)
  851. break
  852. else:
  853. if bools[addr_x]:
  854. if self.switch_mode == SwitchMode.AUTO and self.active_bucket != bucket:
  855. self.activate_bucket(bucket)
  856. break
  857. # ---------- MQTT 消息处理 ----------
  858. def parse_ai_objects(self, payload: dict, display_width: int, display_height: int,
  859. include_ready: bool = True) -> list:
  860. if payload is None or not isinstance(payload, dict):
  861. return []
  862. orig_width = payload.get("width", 640)
  863. orig_height = payload.get("height", 360)
  864. scale_x = display_width / orig_width if orig_width > 0 else 1.0
  865. scale_y = display_height / orig_height if orig_height > 0 else 1.0
  866. objects = []
  867. for obj_str in payload.get("objects", []):
  868. parts = obj_str.split('|')
  869. if len(parts) >= 6:
  870. try:
  871. x = int(float(parts[1]) * scale_x)
  872. y = int(float(parts[2]) * scale_y)
  873. w = int(float(parts[3]) * scale_x)
  874. h = int(float(parts[4]) * scale_y)
  875. raw_label = parts[5]
  876. label = raw_label
  877. if 'hoist' in raw_label:
  878. label = 'hook'
  879. elif 'cup' in raw_label:
  880. label = 'lug'
  881. if 'hook' in label:
  882. color = (0, 0, 255)
  883. elif 'lug' in label:
  884. color = (255, 0, 0)
  885. elif 'ready' in label:
  886. if not include_ready:
  887. continue
  888. color = (0, 255, 255)
  889. else:
  890. color = (128, 128, 128)
  891. objects.append({
  892. "x": x,
  893. "y": y,
  894. "w": w,
  895. "h": h,
  896. "label": label,
  897. "raw_label": raw_label,
  898. "color": color
  899. })
  900. except (ValueError, IndexError):
  901. continue
  902. return objects
  903. def on_mqtt_message(self, topic: str, payload: dict):
  904. self.mqtt_message_signal.emit(topic, payload)
  905. def _on_mqtt_message_impl(self, topic: str, payload: dict):
  906. if payload is None or not isinstance(payload, dict):
  907. return
  908. if not self.active_bucket:
  909. return
  910. if not self.ai_enabled:
  911. return
  912. now = time.time()
  913. if now - self._last_mqtt_process < 0.03:
  914. return
  915. self._last_mqtt_process = now
  916. for cam in self.active_bucket.cameras:
  917. if cam.topic == topic:
  918. self.update_video_size()
  919. if cam.position == CameraPosition.LEFT:
  920. display_width = self.left_label.width()
  921. display_height = self.left_label.height()
  922. else:
  923. display_width = self.right_label.width()
  924. display_height = self.right_label.height()
  925. if display_width == 0 or display_height == 0:
  926. display_width = 640
  927. display_height = 360
  928. all_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=True)
  929. if cam.position == CameraPosition.LEFT:
  930. self.left_objects = all_objects
  931. else:
  932. self.right_objects = all_objects
  933. self.evaluate_hook_state()
  934. draw_objects = [o for o in all_objects if 'ready' not in o['label'].lower()]
  935. for obj in draw_objects:
  936. if not obj.get('paired'):
  937. obj['dimmed'] = True
  938. if cam.position == CameraPosition.LEFT:
  939. self._last_left_draw = draw_objects
  940. if self.left_player:
  941. self.left_player.set_ai_objects(draw_objects)
  942. else:
  943. self._last_right_draw = draw_objects
  944. if self.right_player:
  945. self.right_player.set_ai_objects(draw_objects)
  946. break
  947. # ---------- 挂钩状态评估 ----------
  948. def evaluate_hook_state(self):
  949. left_hooks = [o for o in self.left_objects if 'hook' in o['label'].lower()]
  950. left_lugs = [o for o in self.left_objects if 'lug' in o['label'].lower()]
  951. right_hooks = [o for o in self.right_objects if 'hook' in o['label'].lower()]
  952. right_lugs = [o for o in self.right_objects if 'lug' in o['label'].lower()]
  953. def find_nearest_pair(hooks, lugs):
  954. if not hooks or not lugs:
  955. return None
  956. best_pair = None
  957. best_dist = float('inf')
  958. for h in hooks:
  959. hx = h['x'] + h['w'] / 2
  960. hy = h['y'] + h['h'] / 2
  961. for l in lugs:
  962. lx = l['x'] + l['w'] / 2
  963. ly = l['y'] + l['h'] / 2
  964. dist = (hx - lx) ** 2 + (hy - ly) ** 2
  965. if dist < best_dist:
  966. best_dist = dist
  967. best_pair = (h, l)
  968. return best_pair
  969. left_pair = find_nearest_pair(left_hooks, left_lugs)
  970. right_pair = find_nearest_pair(right_hooks, right_lugs)
  971. is_releasing = False
  972. if left_pair:
  973. h, l = left_pair
  974. if self.prev_left_hook and self.prev_left_lug:
  975. dy_h = (h['y'] + h['h'] / 2) - self.prev_left_hook[1]
  976. dy_l = (l['y'] + l['h'] / 2) - self.prev_left_lug[1]
  977. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  978. is_releasing = True
  979. self.prev_left_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  980. self.prev_left_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  981. else:
  982. self.prev_left_hook = None
  983. self.prev_left_lug = None
  984. if right_pair:
  985. h, l = right_pair
  986. if self.prev_right_hook and self.prev_right_lug:
  987. dy_h = (h['y'] + h['h'] / 2) - self.prev_right_hook[1]
  988. dy_l = (l['y'] + l['h'] / 2) - self.prev_right_lug[1]
  989. if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
  990. is_releasing = True
  991. self.prev_right_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
  992. self.prev_right_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
  993. else:
  994. self.prev_right_hook = None
  995. self.prev_right_lug = None
  996. if is_releasing:
  997. new_state = HookState.RELEASING
  998. else:
  999. any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects)
  1000. has_intersecting_pair = self._has_intersecting_pair(left_pair, right_pair)
  1001. if any_ready and has_intersecting_pair:
  1002. new_state = HookState.READY
  1003. elif has_intersecting_pair:
  1004. new_state = HookState.HOOKING
  1005. else:
  1006. new_state = HookState.IDLE
  1007. if self.hook_state == HookState.READY and new_state != HookState.READY:
  1008. threshold = 15
  1009. else:
  1010. threshold = 1 if new_state == HookState.IDLE else self.STATE_THRESHOLD
  1011. if new_state == self.prev_state:
  1012. self.state_counter += 1
  1013. else:
  1014. self.prev_state = new_state
  1015. self.state_counter = 1
  1016. if self.state_counter >= threshold:
  1017. if new_state != self.hook_state:
  1018. self.hook_state = new_state
  1019. self.update_ui_for_state(new_state)
  1020. self.state_counter = 0
  1021. for obj in self.left_objects + self.right_objects:
  1022. obj.pop('paired', None)
  1023. if left_pair:
  1024. left_pair[0]['paired'] = True
  1025. left_pair[1]['paired'] = True
  1026. if right_pair:
  1027. right_pair[0]['paired'] = True
  1028. right_pair[1]['paired'] = True
  1029. def rects_intersect(self, a, b):
  1030. ax1, ay1, ax2, ay2 = a['x'], a['y'], a['x']+a['w'], a['y']+a['h']
  1031. bx1, by1, bx2, by2 = b['x'], b['y'], b['x']+b['w'], b['y']+b['h']
  1032. return not (ax2 < bx1 or bx2 < ax1 or ay2 < by1 or by2 < ay1)
  1033. def _has_intersecting_pair(self, left_pair, right_pair):
  1034. if left_pair:
  1035. h, l = left_pair
  1036. if self.rects_intersect(h, l):
  1037. return True
  1038. if right_pair:
  1039. h, l = right_pair
  1040. if self.rects_intersect(h, l):
  1041. return True
  1042. return False
  1043. # ---------- UI 更新与语音 ----------
  1044. def update_ui_for_state(self, state):
  1045. if state == HookState.IDLE:
  1046. border_color = "#dcdfe6"
  1047. text = "空闲"
  1048. elif state == HookState.RELEASING:
  1049. border_color = "#ffa500"
  1050. text = "放包中"
  1051. elif state == HookState.HOOKING:
  1052. border_color = "#ff0000"
  1053. text = "挂包中"
  1054. elif state == HookState.READY:
  1055. border_color = "#00ff00"
  1056. text = "挂包成功"
  1057. else:
  1058. border_color = "#dcdfe6"
  1059. text = ""
  1060. self.hook_status_label.setText(f"挂钩状态: {text}")
  1061. style = f"""
  1062. QLabel {{
  1063. border: 4px solid {border_color};
  1064. border-radius: 8px;
  1065. background-color: black;
  1066. }}
  1067. """
  1068. self.left_label.setStyleSheet(style)
  1069. self.right_label.setStyleSheet(style)
  1070. if self.voice_enabled and state in (HookState.HOOKING, HookState.READY):
  1071. now = time.time()
  1072. if state == HookState.HOOKING:
  1073. if state != self.last_voice_state or now - self._last_hooking_voice_time > 15:
  1074. self._speak("挂包中")
  1075. self.last_voice_state = state
  1076. self._last_hooking_voice_time = now
  1077. elif state != self.last_voice_state:
  1078. self._speak("挂包成功")
  1079. self.last_voice_state = state
  1080. # ---------- 定时任务 ----------
  1081. def check_idle(self):
  1082. if self.rfid_client:
  1083. self.rfid_client.check_idle()
  1084. now = time.time()
  1085. if self.config.ai.enabled and self.mqtt_client and not self.mqtt_client.connected:
  1086. if now - self._last_mqtt_connect_attempt > 30:
  1087. self._last_mqtt_connect_attempt = now
  1088. self._connect_current_ai()
  1089. # 每 30 秒检测一次摄像头信号
  1090. if now - self._last_signal_check > 30:
  1091. self._last_signal_check = now
  1092. if not getattr(self, '_signal_check_thread', None) or not self._signal_check_thread.is_alive():
  1093. self._signal_check_thread = threading.Thread(target=self._check_all_cameras, daemon=True)
  1094. self._signal_check_thread.start()
  1095. def _collect_camera_ips(self) -> list:
  1096. ips = set()
  1097. for bucket in self.buckets:
  1098. for cam in bucket.cameras:
  1099. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  1100. if m:
  1101. ips.add(m.group(1))
  1102. return sorted(ips)
  1103. def _check_all_cameras(self):
  1104. ips = self._collect_camera_ips()
  1105. if not ips:
  1106. return
  1107. results = {}
  1108. for ip in ips:
  1109. try:
  1110. if sys.platform == "win32":
  1111. r = subprocess.run(["ping", "-n", "1", ip], capture_output=True, timeout=5, text=True, creationflags=subprocess.CREATE_NO_WINDOW)
  1112. else:
  1113. r = subprocess.run(["ping", "-c", "1", ip], capture_output=True, timeout=5, text=True)
  1114. if r.returncode != 0:
  1115. results[ip] = -1
  1116. else:
  1117. m = re.search(r'time[=<]\s*(\d+(?:\.\d+)?)\s*ms', r.stdout, re.IGNORECASE)
  1118. if m:
  1119. results[ip] = float(m.group(1))
  1120. else:
  1121. results[ip] = 0
  1122. except subprocess.TimeoutExpired:
  1123. results[ip] = -1
  1124. except Exception:
  1125. results[ip] = -1
  1126. self.camera_signal_signal.emit(results)
  1127. def _on_camera_signal(self, results: dict):
  1128. self._camera_signal.update(results)
  1129. for bucket in self.buckets:
  1130. bars = self._bucket_signal_labels.get(bucket.id)
  1131. if not bars:
  1132. continue
  1133. left_bar, right_bar = bars
  1134. for bar, cam in zip((left_bar, right_bar), bucket.cameras):
  1135. m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
  1136. if not m:
  1137. bar.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
  1138. continue
  1139. ip = m.group(1)
  1140. lat = self._camera_signal.get(ip)
  1141. if lat is None:
  1142. bar.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
  1143. elif lat < 0:
  1144. bar.setStyleSheet("background-color: #f56c6c; border-radius: 2px;")
  1145. elif lat < 50:
  1146. bar.setStyleSheet("background-color: #67c23a; border-radius: 2px;")
  1147. elif lat < 200:
  1148. bar.setStyleSheet("background-color: #e6a23c; border-radius: 2px;")
  1149. else:
  1150. bar.setStyleSheet("background-color: #f56c6c; border-radius: 2px;")
  1151. def toggle_switch_mode(self, checked: bool):
  1152. if checked:
  1153. self.switch_mode = SwitchMode.AUTO
  1154. self.auto_switch_btn.setText("自动模式")
  1155. if self._last_rfid_tag:
  1156. self._on_rfid_tag_impl(self._last_rfid_tag)
  1157. elif self.active_bucket:
  1158. self.activate_bucket(None)
  1159. else:
  1160. self.switch_mode = SwitchMode.HAND
  1161. self.auto_switch_btn.setText("手动模式")
  1162. # ---------- 开机自启 ----------
  1163. def _save_config(self):
  1164. import configparser
  1165. cfg = configparser.ConfigParser()
  1166. cfg.read(self._config_path, encoding="utf-8")
  1167. if not cfg.has_section("AI"):
  1168. cfg.add_section("AI")
  1169. val = "true" if self.config.ai.tts_enabled else "false"
  1170. cfg.set("AI", "tts_enabled", val)
  1171. with open(self._config_path, "w", encoding="utf-8") as f:
  1172. cfg.write(f)
  1173. logger.info(f"TTS 设置已保存: {self._config_path} → tts_enabled={val}")
  1174. @staticmethod
  1175. def _autostart_config_dir():
  1176. if sys.platform == "win32":
  1177. return os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "HookVision")
  1178. return os.path.join(os.path.expanduser("~"), ".config", "HookVision")
  1179. @staticmethod
  1180. def _autostart_config_path():
  1181. return os.path.join(MainWindow._autostart_config_dir(), "autostart.txt") # 改为 txt
  1182. @staticmethod
  1183. def _autostart_desktop_path():
  1184. return os.path.expanduser("~/.config/autostart/HookVision.desktop")
  1185. @staticmethod
  1186. def _read_autostart_flag():
  1187. path = MainWindow._autostart_config_path()
  1188. try:
  1189. with open(path, "r", encoding="utf-8") as f:
  1190. content = f.read().strip().lower()
  1191. return content == "true"
  1192. except:
  1193. return False
  1194. @staticmethod
  1195. def _write_autostart_flag(enabled: bool):
  1196. path = MainWindow._autostart_config_path()
  1197. os.makedirs(os.path.dirname(path), exist_ok=True)
  1198. with open(path, "w", encoding="utf-8") as f:
  1199. f.write("true" if enabled else "false")
  1200. @staticmethod
  1201. def _autostart_command():
  1202. if getattr(sys, 'frozen', False):
  1203. return sys.executable
  1204. else:
  1205. main_script = os.path.abspath(sys.argv[0])
  1206. python_exe = sys.executable
  1207. if ' ' in python_exe:
  1208. python_exe = f'"{python_exe}"'
  1209. if ' ' in main_script:
  1210. main_script = f'"{main_script}"'
  1211. if sys.platform == "win32":
  1212. pythonw = python_exe.replace('python.exe', 'pythonw.exe')
  1213. if pythonw != python_exe and os.path.exists(pythonw):
  1214. python_exe = pythonw
  1215. return f'{python_exe} {main_script}'
  1216. @staticmethod
  1217. def _apply_system_autostart(enabled: bool) -> bool:
  1218. cmd = MainWindow._autostart_command()
  1219. try:
  1220. if sys.platform == "win32":
  1221. import winreg
  1222. key = r"Software\Microsoft\Windows\CurrentVersion\Run"
  1223. with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as rk:
  1224. if enabled:
  1225. winreg.SetValueEx(rk, MainWindow.APPTITLE, 0, winreg.REG_SZ, cmd)
  1226. else:
  1227. try:
  1228. winreg.DeleteValue(rk, MainWindow.APPTITLE)
  1229. except OSError:
  1230. pass
  1231. return True
  1232. else:
  1233. desktop_path = MainWindow._autostart_desktop_path()
  1234. if enabled:
  1235. os.makedirs(os.path.dirname(desktop_path), exist_ok=True)
  1236. with open(desktop_path, "w", encoding="utf-8") as f:
  1237. f.write(f"""[Desktop Entry]
  1238. Type=Application
  1239. Name={MainWindow.APPTITLE}
  1240. Exec={cmd}
  1241. Terminal=false
  1242. X-GNOME-Autostart-enabled=true
  1243. """)
  1244. else:
  1245. try:
  1246. os.remove(desktop_path)
  1247. except OSError:
  1248. pass
  1249. return True
  1250. except Exception as e:
  1251. logger.error(f"设置开机自启失败: {e}")
  1252. return False
  1253. def _on_autostart_toggle(self, state):
  1254. enabled = (state == Qt.Checked)
  1255. self._write_autostart_flag(enabled)
  1256. ok = self._apply_system_autostart(enabled)
  1257. if not ok:
  1258. QMessageBox.warning(
  1259. self,
  1260. "设置失败",
  1261. "开机自启动设置失败,请检查权限或手动添加。\n"
  1262. "Windows: 将本程序快捷方式放入 '启动' 文件夹。\n"
  1263. "Linux: 检查 ~/.config/autostart/ 目录权限。"
  1264. )