main_window.py 49 KB

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