Explorar el Código

多项优化和修复

- 离线日志改为 WARNING 级别,写入同级目录 hookvision.log
- 摄像头离线报警改为中文左/右侧摄像头离线
- 生成离线报警相关 WAV 语音文件
- 读卡器 TCP keepalive 检测,拔线后 15s 内触发离线
- 读卡器无数据超时检测取消(半小时不刷卡不误判离线)
- 启动时摄像头尚未检测默认在线,避免误报离线
- 启动阶段不播报读卡器离线语音(_started 标志)
- 离线事件增加 logger.warning 记录到日志
lsfoo hace 3 meses
padre
commit
9d255ed540

BIN
assets/voices/offline_cam_left.wav


BIN
assets/voices/offline_cam_right.wav


BIN
assets/voices/offline_reader.wav


+ 2 - 2
config.dev.ini

@@ -23,7 +23,7 @@ stability_count = 3
 
 
 [AI]
 [AI]
 enabled = true
 enabled = true
-tts_enabled = true
+tts_enabled = false
 
 
 [AI:0]
 [AI:0]
 broker = 192.168.100.100
 broker = 192.168.100.100
@@ -51,7 +51,7 @@ right_topic = ds/channel/1
 [Bucket:1]
 [Bucket:1]
 id = 2
 id = 2
 description = 2号工位
 description = 2号工位
-code_x = 00000002
+code_x = 10000002
 code_y = 
 code_y = 
 ai_module = 0
 ai_module = 0
 left_rtsp = rtsp://admin:hd135792468@192.168.100.102:554/Streaming/Channels/101
 left_rtsp = rtsp://admin:hd135792468@192.168.100.102:554/Streaming/Channels/101

+ 48 - 14
hookvision/main_window.py

@@ -6,6 +6,7 @@ import logging
 import threading
 import threading
 import queue
 import queue
 import subprocess
 import subprocess
+from concurrent.futures import ThreadPoolExecutor
 from collections import defaultdict
 from collections import defaultdict
 from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
 from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
                                QPushButton, QLabel, QGridLayout, QScrollArea,
                                QPushButton, QLabel, QGridLayout, QScrollArea,
@@ -99,6 +100,7 @@ class MainWindow(QMainWindow):
         self._bucket_signal_labels: dict = {}  # bucket.id → QLabel
         self._bucket_signal_labels: dict = {}  # bucket.id → QLabel
         self._last_signal_check: float = 0.0
         self._last_signal_check: float = 0.0
         self._signal_check_thread: threading.Thread = None
         self._signal_check_thread: threading.Thread = None
+        self._offline_reported: set = set()  # 已播报离线的 IP
 
 
         # 连接跨线程信号到主线程槽
         # 连接跨线程信号到主线程槽
         self.rfid_tag_signal.connect(self._on_rfid_tag_impl)
         self.rfid_tag_signal.connect(self._on_rfid_tag_impl)
@@ -129,6 +131,7 @@ class MainWindow(QMainWindow):
         self.showFullScreen()
         self.showFullScreen()
         self.update_video_size()
         self.update_video_size()
         self.init_hardware()
         self.init_hardware()
+        self._started = True
 
 
         self.timer = QTimer()
         self.timer = QTimer()
         self.timer.timeout.connect(self.check_idle)
         self.timer.timeout.connect(self.check_idle)
@@ -471,7 +474,9 @@ class MainWindow(QMainWindow):
         return os.path.join(base, "assets", "voices")
         return os.path.join(base, "assets", "voices")
 
 
     def _voice_msg_to_path(self, msg: str) -> str:
     def _voice_msg_to_path(self, msg: str) -> str:
-        mapping = {"挂包中": "hooking", "挂包成功": "ready", "放包中": "releasing"}
+        mapping = {"挂包中": "hooking", "挂包成功": "ready", "放包中": "releasing",
+                   "读卡器离线": "offline_reader", "左侧摄像头离线": "offline_cam_left",
+                   "右侧摄像头离线": "offline_cam_right"}
         if msg in mapping:
         if msg in mapping:
             return os.path.join(self._voices_dir, f"{mapping[msg]}.wav")
             return os.path.join(self._voices_dir, f"{mapping[msg]}.wav")
         m = re.search(r'到达(\d+)号工位', msg)
         m = re.search(r'到达(\d+)号工位', msg)
@@ -744,7 +749,10 @@ class MainWindow(QMainWindow):
         if not m:
         if not m:
             return False
             return False
         lat = self._camera_signal.get(m.group(1))
         lat = self._camera_signal.get(m.group(1))
-        return lat is not None and lat >= 0
+        # 尚未检测过该 IP 时,默认认为在线,先尝试连接
+        if lat is None:
+            return True
+        return lat >= 0
     def on_bucket_clicked(self, bucket: BucketModel):
     def on_bucket_clicked(self, bucket: BucketModel):
         if self.switch_mode != SwitchMode.HAND:
         if self.switch_mode != SwitchMode.HAND:
             self.switch_mode = SwitchMode.HAND
             self.switch_mode = SwitchMode.HAND
@@ -760,6 +768,9 @@ class MainWindow(QMainWindow):
             self.module_status.setText("通讯状态: RFID正常")
             self.module_status.setText("通讯状态: RFID正常")
         else:
         else:
             self.module_status.setText("通讯状态: RFID离线")
             self.module_status.setText("通讯状态: RFID离线")
+            logger.warning("读卡器离线")
+            if self.voice_enabled and getattr(self, '_started', False):
+                self._speak("读卡器离线")
 
 
     # ---------- 硬件初始化 ----------
     # ---------- 硬件初始化 ----------
     def init_hardware(self):
     def init_hardware(self):
@@ -889,6 +900,8 @@ class MainWindow(QMainWindow):
                 player.start()
                 player.start()
             elif left_cam:
             elif left_cam:
                 self.show_timeout_on_label(self.left_label)
                 self.show_timeout_on_label(self.left_label)
+                if self.voice_enabled:
+                    self._speak("左侧摄像头离线")
 
 
             if right_cam and self._is_camera_online(right_cam):
             if right_cam and self._is_camera_online(right_cam):
                 w = self.right_label.width()
                 w = self.right_label.width()
@@ -902,6 +915,8 @@ class MainWindow(QMainWindow):
                 player.start()
                 player.start()
             elif right_cam:
             elif right_cam:
                 self.show_timeout_on_label(self.right_label)
                 self.show_timeout_on_label(self.right_label)
+                if self.voice_enabled:
+                    self._speak("右侧摄像头离线")
         else:
         else:
             self._unsubscribe_all_topics()
             self._unsubscribe_all_topics()
             self.left_label.clear()
             self.left_label.clear()
@@ -1242,23 +1257,19 @@ class MainWindow(QMainWindow):
         if not ips:
         if not ips:
             return
             return
 
 
-        def ping_one(ip: str) -> tuple:
+        def check_one(ip: str) -> tuple:
             try:
             try:
-                if sys.platform == "win32":
-                    r = subprocess.run(["ping", "-n", "1", ip], capture_output=True, timeout=5, text=True, creationflags=subprocess.CREATE_NO_WINDOW)
-                else:
-                    r = subprocess.run(["ping", "-c", "1", ip], capture_output=True, timeout=5, text=True)
-                if r.returncode != 0:
-                    return (ip, -1)
-                m = re.search(r'time[=<]\s*(\d+(?:\.\d+)?)\s*ms', r.stdout, re.IGNORECASE)
-                return (ip, float(m.group(1)) if m else 0)
-            except subprocess.TimeoutExpired:
-                return (ip, -1)
+                import socket
+                s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+                s.settimeout(2)
+                r = s.connect_ex((ip, 554))
+                s.close()
+                return (ip, 0 if r == 0 else -1)
             except Exception:
             except Exception:
                 return (ip, -1)
                 return (ip, -1)
 
 
         with ThreadPoolExecutor(max_workers=len(ips)) as pool:
         with ThreadPoolExecutor(max_workers=len(ips)) as pool:
-            results = dict(pool.map(ping_one, ips))
+            results = dict(pool.map(check_one, ips))
         self.camera_signal_signal.emit(results)
         self.camera_signal_signal.emit(results)
 
 
     def _on_camera_signal(self, results: dict):
     def _on_camera_signal(self, results: dict):
@@ -1266,10 +1277,33 @@ class MainWindow(QMainWindow):
         self._camera_signal.update(results)
         self._camera_signal.update(results)
         now_online = {ip for ip, lat in results.items() if lat >= 0}
         now_online = {ip for ip, lat in results.items() if lat >= 0}
         revived = now_online & was_offline
         revived = now_online & was_offline
+        went_offline = {ip for ip, lat in results.items() if lat < 0}
+        if went_offline and self.active_bucket:
+            for cam in self.active_bucket.cameras:
+                m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
+                if m and m.group(1) in went_offline:
+                    ip = m.group(1)
+                    if ip not in self._offline_reported and self.voice_enabled:
+                        self._offline_reported.add(ip)
+                        side_name = "左" if cam.position == CameraPosition.LEFT else "右"
+                        self._speak(f"{side_name}侧摄像头离线")
+                        logger.warning(f"摄像头离线: {ip}")
+                    side = "left" if cam.position == CameraPosition.LEFT else "right"
+                    label = self.left_label if side == "left" else self.right_label
+                    cur = self.left_player if side == "left" else self.right_player
+                    if cur is not None:
+                        cur.stop()
+                        if side == "left":
+                            self.left_player = None
+                        else:
+                            self.right_player = None
+                    self._cancel_rtsp_retry(side)
+                    self.show_timeout_on_label(label)
         if revived and self.active_bucket:
         if revived and self.active_bucket:
             for cam in self.active_bucket.cameras:
             for cam in self.active_bucket.cameras:
                 m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
                 m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
                 if m and m.group(1) in revived:
                 if m and m.group(1) in revived:
+                    self._offline_reported.discard(m.group(1))
                     side = "left" if cam.position == CameraPosition.LEFT else "right"
                     side = "left" if cam.position == CameraPosition.LEFT else "right"
                     cur = self.left_player if side == "left" else self.right_player
                     cur = self.left_player if side == "left" else self.right_player
                     if cur is None:
                     if cur is None:

+ 8 - 11
hookvision/rfid_client.py

@@ -40,6 +40,11 @@ class RFIDClient:
         try:
         try:
             self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
             self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
             self.sock.settimeout(3)   # 设置接收超时,避免阻塞
             self.sock.settimeout(3)   # 设置接收超时,避免阻塞
+            self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
+            if hasattr(socket, 'TCP_KEEPIDLE'):
+                self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 5)
+                self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 3)
+                self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
             self.sock.connect((self.config.ip, self.config.port))
             self.sock.connect((self.config.ip, self.config.port))
             self.running = True
             self.running = True
             self.thread = threading.Thread(target=self._read_loop, daemon=True)
             self.thread = threading.Thread(target=self._read_loop, daemon=True)
@@ -170,14 +175,6 @@ class RFIDClient:
             if time.time() - self._last_connect_attempt > self._backoff:
             if time.time() - self._last_connect_attempt > self._backoff:
                 self.connect()
                 self.connect()
         else:
         else:
-            # 如果 socket 存在但长时间未收到任何数据(10秒),认为连接僵死,主动断开并重连
-            if (time.time() - self._last_receive_time) > 10:
-                logger.warning("RFID长时间未收到数据,主动重连")
-                self.running = False
-                sock = self.sock
-                self.sock = None
-                if sock:
-                    try:
-                        sock.close()
-                    except OSError:
-                        pass
+            # socket 存在,连接正常,不做额外检测
+            # 只在 _read_loop 中 detect socket 异常断开时才会走到 disconnect 逻辑
+            pass

+ 13 - 7
hookvision/rtsp_player.py

@@ -2,14 +2,15 @@ import av
 import numpy as np
 import numpy as np
 import time
 import time
 import cv2
 import cv2
+import threading
 import logging
 import logging
-from PySide6.QtCore import QThread, Signal, QMutex
+from PySide6.QtCore import QObject, Signal, QMutex
 from PySide6.QtGui import QImage, QPixmap
 from PySide6.QtGui import QImage, QPixmap
-from typing import List, Dict, Any
+from typing import List, Dict, Any, Optional
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
-class RTSPPlayer(QThread):
+class RTSPPlayer(QObject):
     frame_ready = Signal(QPixmap)
     frame_ready = Signal(QPixmap)
     first_frame_received = Signal()
     first_frame_received = Signal()
     timeout = Signal()
     timeout = Signal()
@@ -21,6 +22,8 @@ class RTSPPlayer(QThread):
         self.target_height = target_height
         self.target_height = target_height
         self.timeout_sec = timeout_sec
         self.timeout_sec = timeout_sec
         self.running = False
         self.running = False
+        self._thread: Optional[threading.Thread] = None
+        self._stop_socket: Optional[socket.socket] = None
         self.ai_objects: List[Dict[str, Any]] = []
         self.ai_objects: List[Dict[str, Any]] = []
         self.target_fps = 25
         self.target_fps = 25
         self.mutex = QMutex()
         self.mutex = QMutex()
@@ -99,12 +102,15 @@ class RTSPPlayer(QThread):
                 container.close()
                 container.close()
             self.running = False
             self.running = False
 
 
+    def start(self):
+        self._thread = threading.Thread(target=self.run, daemon=True)
+        self._thread.start()
+
     def stop(self):
     def stop(self):
         self.running = False
         self.running = False
-        if not self.wait(5000):
-            logger.warning("RTSP 线程未在 5s 内退出,强制终止")
-            self.terminate()
-            self.wait()
+
+    def is_alive(self):
+        return self._thread is not None and self._thread.is_alive()
 
 
     def process_frame(self, frame):
     def process_frame(self, frame):
         self.mutex.lock()
         self.mutex.lock()

+ 3 - 1
main.py

@@ -7,7 +7,9 @@ from qt_material import apply_stylesheet
 from hookvision.config_loader import load_config
 from hookvision.config_loader import load_config
 from hookvision.main_window import MainWindow
 from hookvision.main_window import MainWindow
 import logging
 import logging
-logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
+log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hookvision.log")
+logging.basicConfig(level=logging.WARNING, format='%(asctime)s - %(levelname)s - %(message)s',
+                    handlers=[logging.FileHandler(log_path, encoding='utf-8'), logging.StreamHandler()])
 
 
 def _autostart_diag():
 def _autostart_diag():
     import winreg
     import winreg

+ 3 - 0
tools/generate_voices.py

@@ -13,6 +13,9 @@ VOICES = {
     "arrive_3.wav": "到达3号工位",
     "arrive_3.wav": "到达3号工位",
     "arrive_4.wav": "到达4号工位",
     "arrive_4.wav": "到达4号工位",
     "arrive_5.wav": "到达5号工位",
     "arrive_5.wav": "到达5号工位",
+    "offline_reader.wav": "读卡器离线",
+    "offline_cam_left.wav": "左侧摄像头离线",
+    "offline_cam_right.wav": "右侧摄像头离线",
 }
 }