main_window.py 54 KB

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