Parcourir la source

多项优化和修复

- 离线日志改为 WARNING 级别,写入同级目录 hookvision.log
- 摄像头离线报警改为中文左/右侧摄像头离线
- 生成离线报警相关 WAV 语音文件
- 读卡器 TCP keepalive 检测,拔线后 15s 内触发离线
- 读卡器无数据超时检测取消(半小时不刷卡不误判离线)
- 启动时摄像头尚未检测默认在线,避免误报离线
- 启动阶段不播报读卡器离线语音(_started 标志)
- 离线事件增加 logger.warning 记录到日志
lsfoo il y a 3 mois
Parent
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]
 enabled = true
-tts_enabled = true
+tts_enabled = false
 
 [AI:0]
 broker = 192.168.100.100
@@ -51,7 +51,7 @@ right_topic = ds/channel/1
 [Bucket:1]
 id = 2
 description = 2号工位
-code_x = 00000002
+code_x = 10000002
 code_y = 
 ai_module = 0
 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 queue
 import subprocess
+from concurrent.futures import ThreadPoolExecutor
 from collections import defaultdict
 from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
                                QPushButton, QLabel, QGridLayout, QScrollArea,
@@ -99,6 +100,7 @@ class MainWindow(QMainWindow):
         self._bucket_signal_labels: dict = {}  # bucket.id → QLabel
         self._last_signal_check: float = 0.0
         self._signal_check_thread: threading.Thread = None
+        self._offline_reported: set = set()  # 已播报离线的 IP
 
         # 连接跨线程信号到主线程槽
         self.rfid_tag_signal.connect(self._on_rfid_tag_impl)
@@ -129,6 +131,7 @@ class MainWindow(QMainWindow):
         self.showFullScreen()
         self.update_video_size()
         self.init_hardware()
+        self._started = True
 
         self.timer = QTimer()
         self.timer.timeout.connect(self.check_idle)
@@ -471,7 +474,9 @@ class MainWindow(QMainWindow):
         return os.path.join(base, "assets", "voices")
 
     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:
             return os.path.join(self._voices_dir, f"{mapping[msg]}.wav")
         m = re.search(r'到达(\d+)号工位', msg)
@@ -744,7 +749,10 @@ class MainWindow(QMainWindow):
         if not m:
             return False
         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):
         if self.switch_mode != SwitchMode.HAND:
             self.switch_mode = SwitchMode.HAND
@@ -760,6 +768,9 @@ class MainWindow(QMainWindow):
             self.module_status.setText("通讯状态: RFID正常")
         else:
             self.module_status.setText("通讯状态: RFID离线")
+            logger.warning("读卡器离线")
+            if self.voice_enabled and getattr(self, '_started', False):
+                self._speak("读卡器离线")
 
     # ---------- 硬件初始化 ----------
     def init_hardware(self):
@@ -889,6 +900,8 @@ class MainWindow(QMainWindow):
                 player.start()
             elif left_cam:
                 self.show_timeout_on_label(self.left_label)
+                if self.voice_enabled:
+                    self._speak("左侧摄像头离线")
 
             if right_cam and self._is_camera_online(right_cam):
                 w = self.right_label.width()
@@ -902,6 +915,8 @@ class MainWindow(QMainWindow):
                 player.start()
             elif right_cam:
                 self.show_timeout_on_label(self.right_label)
+                if self.voice_enabled:
+                    self._speak("右侧摄像头离线")
         else:
             self._unsubscribe_all_topics()
             self.left_label.clear()
@@ -1242,23 +1257,19 @@ class MainWindow(QMainWindow):
         if not ips:
             return
 
-        def ping_one(ip: str) -> tuple:
+        def check_one(ip: str) -> tuple:
             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:
                 return (ip, -1)
 
         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)
 
     def _on_camera_signal(self, results: dict):
@@ -1266,10 +1277,33 @@ class MainWindow(QMainWindow):
         self._camera_signal.update(results)
         now_online = {ip for ip, lat in results.items() if lat >= 0}
         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:
             for cam in self.active_bucket.cameras:
                 m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
                 if m and m.group(1) in revived:
+                    self._offline_reported.discard(m.group(1))
                     side = "left" if cam.position == CameraPosition.LEFT else "right"
                     cur = self.left_player if side == "left" else self.right_player
                     if cur is None:

+ 8 - 11
hookvision/rfid_client.py

@@ -40,6 +40,11 @@ class RFIDClient:
         try:
             self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
             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.running = 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:
                 self.connect()
         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 time
 import cv2
+import threading
 import logging
-from PySide6.QtCore import QThread, Signal, QMutex
+from PySide6.QtCore import QObject, Signal, QMutex
 from PySide6.QtGui import QImage, QPixmap
-from typing import List, Dict, Any
+from typing import List, Dict, Any, Optional
 
 logger = logging.getLogger(__name__)
 
-class RTSPPlayer(QThread):
+class RTSPPlayer(QObject):
     frame_ready = Signal(QPixmap)
     first_frame_received = Signal()
     timeout = Signal()
@@ -21,6 +22,8 @@ class RTSPPlayer(QThread):
         self.target_height = target_height
         self.timeout_sec = timeout_sec
         self.running = False
+        self._thread: Optional[threading.Thread] = None
+        self._stop_socket: Optional[socket.socket] = None
         self.ai_objects: List[Dict[str, Any]] = []
         self.target_fps = 25
         self.mutex = QMutex()
@@ -99,12 +102,15 @@ class RTSPPlayer(QThread):
                 container.close()
             self.running = False
 
+    def start(self):
+        self._thread = threading.Thread(target=self.run, daemon=True)
+        self._thread.start()
+
     def stop(self):
         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):
         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.main_window import MainWindow
 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():
     import winreg

+ 3 - 0
tools/generate_voices.py

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