About Multi-camera viewer optimized for RTSP streams
0

Configure Feed

Select the types of activity you want to include in your feed.

refactoring

+2438 -2328
+40 -18
TODO.md
··· 17 17 - Improve testability (services/config without GUI) 18 18 - Reduce coupling and circular imports 19 19 20 - ### Proposed module layout 20 + ### Current module layout 21 + 22 + - `main.py` 23 + - `MainWindow`, app startup, high-level UI orchestration 24 + - `widgets.py` 25 + - `CameraListContainer`, `CameraWidget`, `PreviewLabel` 26 + - `dialogs.py` 27 + - camera edit dialog and discovery dialog 28 + - `stream.py` 29 + - `CameraThread`, RTSP/OpenCV capture loop, reconnect logic, recording writer 30 + - `discovery.py` 31 + - `CameraDiscoveryThread`, network scan flow 32 + - `camera_utils.py` 33 + - RTSP URL parsing/building, ReolinkProxy normalization, TCP/UDP discovery helpers 34 + - `config.py` 35 + - `camera_config.json` load/save, repair, dedupe, defaults 36 + - `i18n.py` 37 + - translations and `tr()` 38 + - `ui_resources.py` 39 + - resource/icon path helpers 40 + - `reolinkproxy_manager.py` 41 + - CLI helper for generating `reolinkproxy.env` 21 42 22 - - `app.py` 23 - - Application start (`QApplication`), wiring, entry point 43 + ### Possible future package layout 44 + 45 + Use packages only when the flat module layout becomes hard to navigate. A reasonable target would be: 46 + 47 + - `app.py` or `main.py` 48 + - application start only 24 49 - `ui/` 25 - - `ui/main_window.py` (main window, menus, layout wiring) 26 - - `ui/camera_widget.py` (`CameraWidget`, frame update/render + controls) 27 - - `ui/dialogs.py` (add/edit camera dialogs, settings dialogs) 50 + - `main_window.py`, `widgets.py`, `dialogs.py`, `resources.py` 28 51 - `services/` 29 - - `services/discovery.py` (`CameraDiscoveryThread`, scan logic) 30 - - `services/stream.py` (RTSP/OpenCV capture loop, reconnect logic) 31 - - `services/recording.py` (recording, snapshots) 32 - - `config.py` 33 - - Load/save `camera_config.json`, defaults, validation 34 - - `models.py` (optional) 35 - - `dataclass` models such as `Camera`, typed config structures 36 - - `constants.py` 37 - - Shared constants (paths, defaults, ports, keys) 52 + - `stream.py`, `discovery.py`, `recording.py` 53 + - `core/` 54 + - `camera_utils.py`, `config.py`, `i18n.py`, optional `models.py` 55 + - `tools/` 56 + - `reolinkproxy_manager.py` 38 57 39 58 ### Migration strategy 40 59 41 - - [ ] Extract the easiest, least-coupled code first (e.g. `config.py` + `models.py`) 42 - - [ ] Move one UI class at a time into `ui/*` and keep imports stable 43 - - [ ] Move worker threads / background logic into `services/*` 60 + - [x] Extract config load/save/repair into `config.py` 61 + - [x] Move reusable UI classes into `widgets.py` 62 + - [x] Move dialogs into `dialogs.py` 63 + - [x] Move worker threads / background logic into `stream.py` and `discovery.py` 64 + - [x] Move RTSP/ReolinkProxy helpers into `camera_utils.py` 65 + - [ ] Consider package directories (`ui/`, `services/`, `core/`) only if modules keep growing 44 66 - [ ] After each extraction: 45 67 - [ ] Run the app 46 68 - [ ] Verify core flows (stream start/stop, recording, snapshot, discovery, config persistence)
+381
camera_utils.py
··· 1 + import json 2 + import re 3 + import socket 4 + import struct 5 + import time 6 + from urllib.parse import quote, unquote, urlparse 7 + 8 + 9 + def _parse_rtsp_url(rtsp_url: str): 10 + """Best-effort RTSP URL parsing (host/port/user/pass).""" 11 + try: 12 + u = urlparse(rtsp_url, allow_fragments=False) 13 + if not u.hostname and "#" in rtsp_url: 14 + sanitized = rtsp_url.replace("#", "%23") 15 + u = urlparse(sanitized, allow_fragments=False) 16 + if u.hostname: 17 + return u.hostname, u.port or 554, unquote(u.username or ""), unquote(u.password or "") 18 + except Exception: 19 + pass 20 + 21 + try: 22 + pattern = r"^[a-z]+://(?:([^:@/]+)(?::([^@/]*))?@)?([^:/]+)(?::(\d+))?" 23 + match = re.match(pattern, rtsp_url or "", re.IGNORECASE) 24 + if not match: 25 + return None, 554, None, None 26 + return ( 27 + match.group(3), 28 + int(match.group(4) or 554), 29 + unquote(match.group(1) or ""), 30 + unquote(match.group(2) or ""), 31 + ) 32 + except Exception: 33 + return None, 554, None, None 34 + 35 + 36 + def _normalize_rtsp_url(rtsp_url: str) -> str: 37 + """Normalize RTSP URL to always include explicit port to avoid FFmpeg TCP fallback errors.""" 38 + try: 39 + u = urlparse(rtsp_url) 40 + if not u.scheme or u.scheme not in ('rtsp', 'rtsps'): 41 + return rtsp_url 42 + 43 + # Ensure port is explicit 44 + port = u.port or 554 45 + host = u.hostname 46 + if not host: 47 + return rtsp_url 48 + 49 + # Rebuild URL with explicit port 50 + auth = f"{u.username}:{u.password}@" if u.username else "" 51 + path = u.path or "/" 52 + query = f"?{u.query}" if u.query else "" 53 + 54 + return f"{u.scheme}://{auth}{host}:{port}{path}{query}" 55 + except Exception: 56 + return rtsp_url 57 + 58 + 59 + def _is_battery_camera(model: str, name: str = "") -> bool: 60 + """Check if camera is battery-powered based on model/name.""" 61 + if not model and not name: 62 + return False 63 + 64 + search_text = f"{model} {name}".lower() 65 + 66 + # Known battery camera series/models 67 + battery_keywords = [ 68 + "argus", # Argus series (Argus 2, 3, PT, Eco, Ultra) 69 + "go", # Go series (Go, Go Plus, Go PT) 70 + "altas", # Altas PT Ultra 71 + "battery", # Explicit battery mention 72 + "solar", # Solar-powered (usually battery) 73 + ] 74 + 75 + return any(keyword in search_text for keyword in battery_keywords) 76 + 77 + 78 + def _build_rtsp_url(host: str, port: int = 554, username: str = "", password: str = "", 79 + path: str = "h264Preview_01_main", scheme: str = "rtsp") -> str: 80 + """Build RTSP URL with proper URL-encoding for credentials containing special characters. 81 + 82 + Args: 83 + host: Camera IP or hostname 84 + port: RTSP port (default 554) 85 + username: Username (will be URL-encoded) 86 + password: Password (will be URL-encoded) 87 + path: RTSP path (default h264Preview_01_main) 88 + scheme: URL scheme (rtsp or rtsps) 89 + 90 + Returns: 91 + Properly formatted RTSP URL with encoded credentials 92 + """ 93 + # URL-encode credentials to handle special characters like #, @, :, etc. 94 + # safe='' means encode ALL special characters 95 + encoded_user = quote(username, safe='') if username else '' 96 + encoded_pass = quote(password, safe='') if password else '' 97 + 98 + # Build auth string 99 + if encoded_user and encoded_pass: 100 + auth = f"{encoded_user}:{encoded_pass}@" 101 + elif encoded_user: 102 + auth = f"{encoded_user}@" 103 + else: 104 + auth = "" 105 + 106 + # Ensure path starts with / 107 + if path and not path.startswith('/'): 108 + path = f"/{path}" 109 + 110 + return f"{scheme}://{auth}{host}:{port}{path}" 111 + 112 + 113 + def _reolinkproxy_camera_name(name: str) -> str: 114 + """Normalize camera name for ReolinkProxy stream path.""" 115 + return (name or "Camera").strip().replace(" ", "_") 116 + 117 + 118 + def _reolinkproxy_rtsp_url(name: str, port: int = 8554) -> str: 119 + cam_name = _reolinkproxy_camera_name(name) 120 + return f"rtsp://localhost:{port}/{cam_name}/mainStream" 121 + 122 + 123 + def _reolinkproxy_proxy_config(rtsp_url: str, name: str, username: str, password: str, uid: str = "", model: str = "", manufacturer: str = "") -> dict | None: 124 + """Build a persistent proxy config for Reolink WLAN/Battery cameras.""" 125 + host, port, user, pwd = _parse_rtsp_url(rtsp_url) 126 + if host in ("localhost", "127.0.0.1") and port == 8554: 127 + return None 128 + 129 + is_reolink = ( 130 + (manufacturer or "").lower() == "reolink" 131 + or "reolink" in (model or "").lower() 132 + or port == 9000 133 + ) 134 + use_reolinkproxy = port == 9000 or _is_battery_camera(model, name) 135 + 136 + if not (is_reolink and use_reolinkproxy and host): 137 + return None 138 + 139 + proxy_port = int(port or 9000) 140 + 141 + return { 142 + "type": "reolinkproxy", 143 + "host": host, 144 + "port": proxy_port, 145 + "username": username or user or "", 146 + "password": password or pwd or "", 147 + "stream": "main", 148 + "battery": True, 149 + "pause_on_client": True, 150 + "idle_disconnect": True, 151 + "idle_timeout": "30s", 152 + } 153 + 154 + 155 + def normalize_reolinkproxy_camera(camera: dict, username: str = "", password: str = "") -> bool: 156 + """Normalize a camera dict to use ReolinkProxy when it represents a Reolink battery/Baichuan camera.""" 157 + url = (camera.get("url") or "").strip() 158 + if not url: 159 + return False 160 + 161 + proxy_config = _reolinkproxy_proxy_config( 162 + rtsp_url=url, 163 + name=camera.get("name", ""), 164 + username=username, 165 + password=password, 166 + uid=camera.get("uid", ""), 167 + model=camera.get("model", ""), 168 + manufacturer=camera.get("manufacturer", ""), 169 + ) 170 + changed = False 171 + if proxy_config: 172 + new_url = _reolinkproxy_rtsp_url(camera.get("name", "")) 173 + if camera.get("url") != new_url: 174 + camera["url"] = new_url 175 + changed = True 176 + if camera.get("proxy") != proxy_config: 177 + camera["proxy"] = proxy_config 178 + changed = True 179 + return changed 180 + 181 + 182 + def _tcp_probe(host: str, port: int, timeout: float = 0.7) -> tuple[bool, str]: 183 + """Fast TCP reachability check. 184 + 185 + Returns (ok, reason) where reason is one of: ok, timeout, refused, unreachable, error. 186 + """ 187 + if not host: 188 + return False, "error" 189 + try: 190 + sock = socket.create_connection((host, int(port)), timeout=timeout) 191 + sock.close() 192 + return True, "ok" 193 + except ConnectionRefusedError: 194 + return False, "refused" 195 + except TimeoutError: 196 + return False, "timeout" 197 + except OSError as e: 198 + # e.g. No route to host, network unreachable, etc. 199 + if getattr(e, "errno", None) in (101, 113): 200 + return False, "unreachable" 201 + return False, "error" 202 + 203 + 204 + def _ws_discovery(timeout: float = 2.0) -> list[str]: 205 + """ONVIF/WS-Discovery (UDP 3702).""" 206 + msg = ( 207 + '<?xml version="1.0" encoding="utf-8"?>' 208 + '<Envelope xmlns:tds="http://www.onvif.org/ver10/device/wsdl" xmlns="http://www.w3.org/2003/05/soap-envelope">' 209 + '<Header><MessageID xmlns="http://schemas.xmlsoap.org/ws/2004/08/addressing">uuid:8253()</MessageID>' 210 + '<To xmlns="http://schemas.xmlsoap.org/ws/2004/08/addressing">urn:schemas-xmlsoap-org:ws:2004:08:discovery</To>' 211 + '<Action xmlns="http://schemas.xmlsoap.org/ws/2004/08/addressing">http://schemas.xmlsoap.org/ws/2004/08/discovery/Probe</Action></Header>' 212 + '<Body><Probe xmlns="http://schemas.xmlsoap.org/ws/2004/08/discovery"><Types>tds:Device</Types></Probe></Body></Envelope>' 213 + ) 214 + ips = set() 215 + sock = None 216 + try: 217 + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 218 + sock.settimeout(timeout) 219 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 220 + sock.sendto(msg.encode(), ("239.255.255.250", 3702)) 221 + 222 + while True: 223 + try: 224 + data, addr = sock.recvfrom(4096) 225 + ips.add(addr[0]) 226 + except socket.timeout: 227 + break 228 + except Exception: 229 + pass 230 + finally: 231 + if sock is not None: 232 + sock.close() 233 + return list(ips) 234 + 235 + 236 + def _ssdp_discovery(timeout: float = 2.0) -> list[str]: 237 + """UPnP/SSDP Discovery (UDP 1900).""" 238 + msg = ( 239 + 'M-SEARCH * HTTP/1.1\r\n' 240 + 'HOST: 239.255.255.250:1900\r\n' 241 + 'MAN: "ssdp:discover"\r\n' 242 + 'MX: 2\r\n' 243 + 'ST: ssdp:all\r\n' 244 + '\r\n' 245 + ) 246 + ips = set() 247 + sock = None 248 + try: 249 + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 250 + sock.settimeout(timeout) 251 + sock.sendto(msg.encode(), ("239.255.255.250", 1900)) 252 + 253 + while True: 254 + try: 255 + data, addr = sock.recvfrom(4096) 256 + ips.add(addr[0]) 257 + except socket.timeout: 258 + break 259 + except Exception: 260 + pass 261 + finally: 262 + if sock is not None: 263 + sock.close() 264 + return list(ips) 265 + 266 + 267 + def _udp_reolink_probe(ip: str, timeout: float = 2.0) -> list | dict | None: 268 + """Sendet ein Reolink UDP Discovery Paket an eine spezifische oder Broadcast IP.""" 269 + ports = [9000, 10000, 2000] 270 + 271 + # Discovery JSON Payloads (GetDevInfo und Search) 272 + payloads = [ 273 + [{"cmd": "GetDevInfo", "action": 0, "param": {}}], 274 + {"cmd": "GetDevInfo", "action": 0, "param": {}}, 275 + [{"cmd": "Search", "action": 0, "param": {}}], 276 + {"cmd": "Search", "action": 0, "param": {}} 277 + ] 278 + 279 + results = [] 280 + 281 + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 282 + sock.settimeout(timeout) 283 + if ip == "255.255.255.255": 284 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 285 + 286 + for port in ports: 287 + for cmd_data in payloads: 288 + data = json.dumps(cmd_data).encode('utf-8') 289 + for endian in ['<', '>']: 290 + header = struct.pack(endian + "2sHHHII", b"BC", 0, 1, 0, len(data), 0) 291 + payload = header + data 292 + try: 293 + # Weck-Schuss 294 + sock.sendto(b"\x00" * 32, (ip, port)) 295 + sock.sendto(payload, (ip, port)) 296 + 297 + while True: 298 + try: 299 + resp_data, addr = sock.recvfrom(4096) 300 + except socket.timeout: 301 + break 302 + 303 + if len(resp_data) >= 16: 304 + idx = -1 305 + for i in range(len(resp_data) - 1): 306 + if resp_data[i:i+2].lower() == b"bc": 307 + for j in range(i+2, min(i+48, len(resp_data))): 308 + if resp_data[j] in (ord('['), ord('{')): 309 + idx = j 310 + break 311 + if idx != -1: 312 + break 313 + 314 + if idx != -1: 315 + content = resp_data[idx:].decode('utf-8', 'ignore') 316 + if content.startswith('['): 317 + end = content.rfind(']') 318 + else: 319 + end = content.rfind('}') 320 + 321 + if end != -1: 322 + try: 323 + res = json.loads(content[:end+1]) 324 + info = None 325 + # Wir suchen nach DevInfo oder Search-Response 326 + val = res[0].get('value', {}) if isinstance(res, list) else res.get('value', {}) 327 + info = val.get('DevInfo') or val.get('SearchResult') or val 328 + 329 + if info and (info.get('name') or info.get('serial') or info.get('mac')): 330 + info['remote_ip'] = addr[0] 331 + if ip != "255.255.255.255": 332 + sock.close() 333 + return info 334 + if info.get('remote_ip') not in [r.get('remote_ip') for r in results]: 335 + results.append(info) 336 + except Exception: 337 + pass 338 + if ip != "255.255.255.255": 339 + break 340 + except Exception: 341 + break 342 + if ip != "255.255.255.255" and results: 343 + break 344 + 345 + sock.close() 346 + return results if ip == "255.255.255.255" else (results[0] if results else None) 347 + 348 + 349 + def _udp_reolink_wake(ip: str, uid: str = ""): 350 + """Sendet einen intensiven Weck-Burst an eine Reolink Kamera.""" 351 + if not ip: 352 + return 353 + sock = None 354 + try: 355 + # Falls UID vorhanden, bauen wir ein echtes Abfrage-Paket 356 + payload = b"" 357 + if uid: 358 + msg = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] 359 + data = json.dumps(msg).encode('utf-8') 360 + header = struct.pack("<2sHHHII", b"BC", 0, 1, 0, len(data), 0) 361 + payload = header + data 362 + 363 + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 364 + # Wir pingen alle relevanten Ports mehrfach 365 + for port in [9000, 10000, 8000]: 366 + for _ in range(5): 367 + # Null-Bytes zum "Aufwecken" des WLAN/PIR 368 + sock.sendto(b"\x00" * 64, (ip, port)) 369 + if payload: 370 + # Gezielte Abfrage mit UID (Baichuan) 371 + sock.sendto(payload, (ip, port)) 372 + else: 373 + # Generischer Header 374 + h = struct.pack("<2sHHHII", b"BC", 0, 1, 0, 0, 0) 375 + sock.sendto(h, (ip, port)) 376 + time.sleep(0.02) 377 + except Exception: 378 + pass 379 + finally: 380 + if sock is not None: 381 + sock.close()
+145
config.py
··· 1 + import json 2 + import os 3 + 4 + from camera_utils import normalize_reolinkproxy_camera 5 + 6 + 7 + CONFIG_PATH = "camera_config.json" 8 + DEFAULT_RECORDING_PATH = os.path.expanduser("~/Videos/Reolink") 9 + 10 + 11 + def snapshot_path_for(recording_path: str) -> str: 12 + return os.path.join(recording_path, "snapshots") 13 + 14 + 15 + def config_payload(window) -> dict: 16 + return { 17 + "cameras": window.cameras, 18 + "recording_path": window.recording_path, 19 + "cameras_per_row": window.cameras_per_row, 20 + "next_camera_id": window.next_camera_id, 21 + "language": window.language, 22 + "order_custom": window._order_custom, 23 + "preview_camera_ids": list(window.selected_camera_ids), 24 + "selected_camera_id": window.selected_camera_id, 25 + } 26 + 27 + 28 + def save_config_data(config: dict, path: str = CONFIG_PATH): 29 + with open(path, "w") as f: 30 + json.dump(config, f, indent=2) 31 + 32 + 33 + def _coerce_camera_id(camera: dict): 34 + try: 35 + return int(camera.get("id")) 36 + except Exception: 37 + return None 38 + 39 + 40 + def _repair_camera_ids(cameras: list[dict]) -> tuple[list[dict], bool]: 41 + used_ids = set() 42 + max_id = 0 43 + fixed = False 44 + for camera in cameras: 45 + camera_id = _coerce_camera_id(camera) 46 + if camera_id is not None: 47 + max_id = max(max_id, camera_id) 48 + 49 + repaired = [] 50 + for camera in cameras: 51 + camera_id = _coerce_camera_id(camera) 52 + if camera_id is None or camera_id in used_ids: 53 + max_id += 1 54 + camera["id"] = max_id 55 + used_ids.add(max_id) 56 + fixed = True 57 + else: 58 + used_ids.add(camera_id) 59 + repaired.append(camera) 60 + return repaired, fixed 61 + 62 + 63 + def _dedupe_by_url(cameras: list[dict]) -> tuple[list[dict], bool]: 64 + seen_urls = set() 65 + deduped = [] 66 + fixed = False 67 + for camera in cameras: 68 + url = (camera.get("url") or "").strip() 69 + if url: 70 + if url in seen_urls: 71 + fixed = True 72 + continue 73 + seen_urls.add(url) 74 + if "uid" not in camera: 75 + camera["uid"] = "" 76 + fixed = True 77 + deduped.append(camera) 78 + return deduped, fixed 79 + 80 + 81 + def repair_cameras(cameras: list[dict], order_custom: bool) -> tuple[list[dict], bool]: 82 + repaired, ids_fixed = _repair_camera_ids(cameras) 83 + repaired, urls_fixed = _dedupe_by_url(repaired) 84 + proxy_fixed = False 85 + for camera in repaired: 86 + proxy_fixed = normalize_reolinkproxy_camera(camera) or proxy_fixed 87 + if not order_custom: 88 + try: 89 + repaired.sort(key=lambda camera: int(camera.get("id", 0))) 90 + except Exception: 91 + pass 92 + return repaired, ids_fixed or urls_fixed or proxy_fixed 93 + 94 + 95 + def load_config_data(path: str = CONFIG_PATH) -> tuple[dict | None, bool]: 96 + try: 97 + with open(path, "r") as f: 98 + raw_config = json.load(f) 99 + except FileNotFoundError: 100 + return None, False 101 + 102 + order_custom = bool(raw_config.get("order_custom", False)) 103 + cameras, fixed = repair_cameras(raw_config.get("cameras", []), order_custom) 104 + next_camera_id = max([camera.get("id", 0) for camera in cameras] + [0]) + 1 105 + if raw_config.get("next_camera_id") != next_camera_id: 106 + fixed = True 107 + 108 + valid_camera_ids = { 109 + int(camera.get("id")) 110 + for camera in cameras 111 + if camera.get("id") is not None 112 + } 113 + preview_ids = [] 114 + for camera_id in raw_config.get("preview_camera_ids", []): 115 + try: 116 + camera_id = int(camera_id) 117 + except Exception: 118 + continue 119 + if camera_id in valid_camera_ids and camera_id not in preview_ids: 120 + preview_ids.append(camera_id) 121 + 122 + try: 123 + selected_camera_id = ( 124 + int(raw_config.get("selected_camera_id")) 125 + if raw_config.get("selected_camera_id") is not None 126 + else None 127 + ) 128 + except Exception: 129 + selected_camera_id = None 130 + if selected_camera_id not in valid_camera_ids: 131 + selected_camera_id = None 132 + 133 + config = { 134 + **raw_config, 135 + "cameras": cameras, 136 + "recording_path": raw_config.get("recording_path", DEFAULT_RECORDING_PATH), 137 + "snapshot_path": snapshot_path_for(raw_config.get("recording_path", DEFAULT_RECORDING_PATH)), 138 + "cameras_per_row": raw_config.get("cameras_per_row", 3), 139 + "next_camera_id": next_camera_id, 140 + "language": raw_config.get("language", "de"), 141 + "order_custom": order_custom, 142 + "preview_camera_ids": preview_ids, 143 + "selected_camera_id": selected_camera_id, 144 + } 145 + return config, fixed
+393
dialogs.py
··· 1 + import socket 2 + 3 + from PyQt6.QtCore import Qt 4 + from PyQt6.QtWidgets import ( 5 + QCheckBox, 6 + QComboBox, 7 + QDialog, 8 + QDialogButtonBox, 9 + QGridLayout, 10 + QGroupBox, 11 + QHBoxLayout, 12 + QHeaderView, 13 + QLabel, 14 + QLineEdit, 15 + QMessageBox, 16 + QProgressBar, 17 + QPushButton, 18 + QTableWidget, 19 + QTableWidgetItem, 20 + QVBoxLayout, 21 + QWidget, 22 + ) 23 + 24 + from camera_utils import _build_rtsp_url 25 + from discovery import CameraDiscoveryThread 26 + from i18n import tr 27 + 28 + 29 + class CameraEditDialog(QDialog): 30 + """Dialog zum Bearbeiten einer Kamera""" 31 + def __init__(self, camera_data, parent=None): 32 + super().__init__(parent) 33 + self.setWindowTitle(tr("dialog.edit.title")) 34 + self.setModal(True) 35 + self.resize(600, 300) 36 + 37 + self.camera_data = camera_data.copy() 38 + self.init_ui() 39 + 40 + def init_ui(self): 41 + layout = QVBoxLayout() 42 + 43 + # Name 44 + name_layout = QHBoxLayout() 45 + name_layout.addWidget(QLabel(tr("dialog.edit.name"))) 46 + self.name_input = QLineEdit() 47 + self.name_input.setText(self.camera_data.get('name', '')) 48 + self.name_input.setPlaceholderText(tr("dialog.edit.name_ph")) 49 + name_layout.addWidget(self.name_input) 50 + layout.addLayout(name_layout) 51 + 52 + # RTSP URL 53 + url_layout = QVBoxLayout() 54 + url_layout.addWidget(QLabel(tr("dialog.edit.rtsp_url"))) 55 + self.url_input = QLineEdit() 56 + self.url_input.setText(self.camera_data.get('url', '')) 57 + self.url_input.setPlaceholderText(tr("dialog.edit.rtsp_ph")) 58 + url_layout.addWidget(self.url_input) 59 + layout.addLayout(url_layout) 60 + 61 + # UID 62 + uid_layout = QHBoxLayout() 63 + uid_layout.addWidget(QLabel(tr("label.uid"))) 64 + self.uid_input = QLineEdit() 65 + self.uid_input.setText(self.camera_data.get('uid', '')) 66 + self.uid_input.setPlaceholderText(tr("placeholder.uid")) 67 + uid_layout.addWidget(self.uid_input) 68 + layout.addLayout(uid_layout) 69 + 70 + # Hilfe-Text 71 + help_group = QGroupBox(tr("dialog.edit.help_group")) 72 + help_layout = QVBoxLayout() 73 + help_text = QLabel(tr("dialog.edit.help_text")) 74 + help_text.setWordWrap(True) 75 + help_text.setStyleSheet("color: #aaa; font-size: 10px;") 76 + help_layout.addWidget(help_text) 77 + help_group.setLayout(help_layout) 78 + layout.addWidget(help_group) 79 + 80 + # URL Builder Shortcut 81 + builder_group = QGroupBox(tr("dialog.edit.builder_group")) 82 + builder_layout = QGridLayout() 83 + 84 + builder_layout.addWidget(QLabel(tr("dialog.edit.ip")), 0, 0) 85 + self.ip_input = QLineEdit() 86 + self.ip_input.setPlaceholderText(tr("dialog.edit.ip_ph")) 87 + builder_layout.addWidget(self.ip_input, 0, 1) 88 + 89 + builder_layout.addWidget(QLabel(tr("dialog.edit.port")), 0, 2) 90 + self.port_input = QLineEdit() 91 + self.port_input.setText("554") 92 + self.port_input.setMaximumWidth(60) 93 + builder_layout.addWidget(self.port_input, 0, 3) 94 + 95 + builder_layout.addWidget(QLabel(tr("dialog.edit.username")), 1, 0) 96 + self.username_input = QLineEdit() 97 + self.username_input.setText("admin") 98 + builder_layout.addWidget(self.username_input, 1, 1) 99 + 100 + builder_layout.addWidget(QLabel(tr("dialog.edit.password")), 1, 2) 101 + self.password_input = QLineEdit() 102 + self.password_input.setEchoMode(QLineEdit.EchoMode.Password) 103 + builder_layout.addWidget(self.password_input, 1, 3) 104 + 105 + builder_layout.addWidget(QLabel(tr("dialog.edit.path")), 2, 0) 106 + self.path_combo = QComboBox() 107 + self.path_combo.addItems([ 108 + "h264Preview_01_main", 109 + "h264Preview_01_sub", 110 + "onvif1", 111 + "Streaming/Channels/101", 112 + "stream1", 113 + "live" 114 + ]) 115 + self.path_combo.setEditable(True) 116 + builder_layout.addWidget(self.path_combo, 2, 1, 1, 3) 117 + 118 + build_btn = QPushButton(tr("dialog.edit.build_url")) 119 + build_btn.clicked.connect(self.build_url) 120 + build_btn.setStyleSheet("background-color: #1976d2; color: white;") 121 + builder_layout.addWidget(build_btn, 3, 0, 1, 4) 122 + 123 + builder_group.setLayout(builder_layout) 124 + layout.addWidget(builder_group) 125 + 126 + # Aktuellen URL parsen 127 + self.parse_current_url() 128 + 129 + # Dialog Buttons 130 + button_box = QDialogButtonBox( 131 + QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel 132 + ) 133 + button_box.accepted.connect(self.accept) 134 + button_box.rejected.connect(self.reject) 135 + layout.addWidget(button_box) 136 + 137 + self.setLayout(layout) 138 + 139 + def parse_current_url(self): 140 + """Aktuellen URL in Felder zerlegen""" 141 + url = self.camera_data.get('url', '') 142 + 143 + try: 144 + # Format: rtsp://username:password@ip:port/path 145 + if url.startswith('rtsp://'): 146 + url = url[7:] # "rtsp://" entfernen 147 + 148 + if '@' in url: 149 + auth, rest = url.split('@', 1) 150 + if ':' in auth: 151 + username, password = auth.split(':', 1) 152 + self.username_input.setText(username) 153 + self.password_input.setText(password) 154 + 155 + if ':' in rest: 156 + ip, port_path = rest.split(':', 1) 157 + self.ip_input.setText(ip) 158 + 159 + if '/' in port_path: 160 + port, path = port_path.split('/', 1) 161 + self.port_input.setText(port) 162 + self.path_combo.setCurrentText(path) 163 + except Exception: 164 + pass 165 + 166 + def build_url(self): 167 + """URL aus Einzelteilen zusammenbauen""" 168 + ip = self.ip_input.text().strip() 169 + port = self.port_input.text().strip() or "554" 170 + username = self.username_input.text().strip() or "admin" 171 + password = self.password_input.text().strip() 172 + path = self.path_combo.currentText().strip() 173 + 174 + if not ip: 175 + QMessageBox.warning(self, tr("dialog.title.error"), tr("dialog.edit.err_ip")) 176 + return 177 + 178 + url = _build_rtsp_url( 179 + host=ip, 180 + port=int(port), 181 + username=username, 182 + password=password, 183 + path=path 184 + ) 185 + self.url_input.setText(url) 186 + 187 + def get_camera_data(self): 188 + """Geänderte Daten zurückgeben""" 189 + self.camera_data['name'] = self.name_input.text().strip() 190 + self.camera_data['url'] = self.url_input.text().strip() 191 + self.camera_data['uid'] = self.uid_input.text().strip() 192 + return self.camera_data 193 + 194 + 195 + class CameraDiscoveryDialog(QDialog): 196 + """Dialog für Kamera-Suche""" 197 + def __init__(self, parent=None): 198 + super().__init__(parent) 199 + self.setWindowTitle(tr("dialog.discovery.title")) 200 + self.setModal(True) 201 + self.resize(700, 500) 202 + 203 + self.found_cameras = [] 204 + self.discovery_thread = None 205 + 206 + self.init_ui() 207 + 208 + def init_ui(self): 209 + layout = QVBoxLayout() 210 + 211 + # Netzwerk-Konfiguration 212 + config_group = QGroupBox(tr("dialog.discovery.scan_config")) 213 + config_layout = QVBoxLayout() 214 + 215 + # Netzwerk-Bereich 216 + network_layout = QHBoxLayout() 217 + network_layout.addWidget(QLabel(tr("dialog.discovery.network"))) 218 + self.network_input = QLineEdit() 219 + self.network_input.setPlaceholderText(tr("dialog.discovery.network_ph")) 220 + self.network_input.setText(self._get_local_network()) 221 + network_layout.addWidget(self.network_input) 222 + config_layout.addLayout(network_layout) 223 + 224 + # Zugangsdaten 225 + auth_layout = QHBoxLayout() 226 + auth_layout.addWidget(QLabel(tr("dialog.discovery.username"))) 227 + self.username_input = QLineEdit() 228 + self.username_input.setText("admin") 229 + auth_layout.addWidget(self.username_input) 230 + 231 + auth_layout.addWidget(QLabel(tr("dialog.discovery.password"))) 232 + self.password_input = QLineEdit() 233 + self.password_input.setEchoMode(QLineEdit.EchoMode.Password) 234 + auth_layout.addWidget(self.password_input) 235 + config_layout.addLayout(auth_layout) 236 + 237 + config_group.setLayout(config_layout) 238 + layout.addWidget(config_group) 239 + 240 + # Scan-Kontrolle 241 + scan_layout = QHBoxLayout() 242 + self.scan_btn = QPushButton(tr("dialog.discovery.start")) 243 + self.scan_btn.clicked.connect(self.start_scan) 244 + scan_layout.addWidget(self.scan_btn) 245 + 246 + self.stop_btn = QPushButton(tr("dialog.discovery.stop")) 247 + self.stop_btn.clicked.connect(self.stop_scan) 248 + self.stop_btn.setEnabled(False) 249 + scan_layout.addWidget(self.stop_btn) 250 + 251 + scan_layout.addStretch() 252 + layout.addLayout(scan_layout) 253 + 254 + # Progress Bar 255 + self.progress_bar = QProgressBar() 256 + self.progress_bar.setValue(0) 257 + layout.addWidget(self.progress_bar) 258 + 259 + self.status_label = QLabel(tr("dialog.discovery.ready")) 260 + layout.addWidget(self.status_label) 261 + 262 + # Gefundene Kameras Tabelle 263 + found_group = QGroupBox(tr("dialog.discovery.found")) 264 + found_layout = QVBoxLayout() 265 + 266 + self.camera_table = QTableWidget() 267 + self.camera_table.setColumnCount(7) 268 + self.camera_table.setHorizontalHeaderLabels([ 269 + tr("dialog.discovery.col.select"), 270 + tr("dialog.discovery.col.ip"), 271 + tr("dialog.discovery.col.name"), 272 + tr("dialog.discovery.col.model"), 273 + tr("dialog.discovery.col.manufacturer"), 274 + tr("dialog.discovery.col.ports"), 275 + tr("dialog.discovery.col.uid"), 276 + ]) 277 + self.camera_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) 278 + self.camera_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) 279 + 280 + found_layout.addWidget(self.camera_table) 281 + found_group.setLayout(found_layout) 282 + layout.addWidget(found_group) 283 + 284 + # Dialog Buttons 285 + button_box = QDialogButtonBox( 286 + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel 287 + ) 288 + button_box.accepted.connect(self.accept) 289 + button_box.rejected.connect(self.reject) 290 + layout.addWidget(button_box) 291 + 292 + self.setLayout(layout) 293 + 294 + def _get_local_network(self): 295 + """Lokales Netzwerk ermitteln""" 296 + try: 297 + # Lokale IP ermitteln 298 + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 299 + s.connect(("8.8.8.8", 80)) 300 + local_ip = s.getsockname()[0] 301 + s.close() 302 + 303 + # Netzwerk-Bereich ableiten (Class C) 304 + ip_parts = local_ip.split('.') 305 + network = f"{ip_parts[0]}.{ip_parts[1]}.{ip_parts[2]}.0/24" 306 + return network 307 + except Exception: 308 + return "192.168.1.0/24" 309 + 310 + def start_scan(self): 311 + """Scan starten""" 312 + network = self.network_input.text().strip() 313 + username = self.username_input.text().strip() 314 + password = self.password_input.text() 315 + 316 + if not network: 317 + QMessageBox.warning(self, tr("dialog.title.error"), tr("dialog.discovery.err_network")) 318 + return 319 + 320 + # UI anpassen 321 + self.scan_btn.setEnabled(False) 322 + self.stop_btn.setEnabled(True) 323 + self.camera_table.setRowCount(0) 324 + self.found_cameras.clear() 325 + self.progress_bar.setValue(0) 326 + 327 + # Discovery Thread starten 328 + self.discovery_thread = CameraDiscoveryThread(network, username=username, password=password) 329 + self.discovery_thread.camera_found.connect(self.on_camera_found) 330 + self.discovery_thread.progress_update.connect(self.on_progress_update) 331 + self.discovery_thread.scan_complete.connect(self.on_scan_complete) 332 + self.discovery_thread.start() 333 + 334 + def stop_scan(self): 335 + """Scan stoppen""" 336 + if self.discovery_thread: 337 + self.discovery_thread.stop() 338 + self.discovery_thread.wait() 339 + 340 + self.scan_btn.setEnabled(True) 341 + self.stop_btn.setEnabled(False) 342 + self.status_label.setText(tr("dialog.discovery.scan_cancelled", count=len(self.found_cameras))) 343 + 344 + def on_camera_found(self, camera_info): 345 + """Kamera zur Tabelle hinzufügen""" 346 + self.found_cameras.append(camera_info) 347 + 348 + row = self.camera_table.rowCount() 349 + self.camera_table.insertRow(row) 350 + 351 + # Checkbox 352 + checkbox = QCheckBox() 353 + checkbox.setChecked(True) 354 + checkbox_widget = QWidget() 355 + checkbox_layout = QHBoxLayout(checkbox_widget) 356 + checkbox_layout.addWidget(checkbox) 357 + checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) 358 + checkbox_layout.setContentsMargins(0, 0, 0, 0) 359 + self.camera_table.setCellWidget(row, 0, checkbox_widget) 360 + 361 + # Daten 362 + self.camera_table.setItem(row, 1, QTableWidgetItem(camera_info['ip'])) 363 + self.camera_table.setItem(row, 2, QTableWidgetItem(camera_info['name'])) 364 + self.camera_table.setItem(row, 3, QTableWidgetItem(camera_info['model'])) 365 + self.camera_table.setItem(row, 4, QTableWidgetItem(camera_info['manufacturer'])) 366 + self.camera_table.setItem(row, 5, QTableWidgetItem(', '.join(map(str, camera_info['ports'])))) 367 + self.camera_table.setItem(row, 6, QTableWidgetItem(camera_info.get('uid', ''))) 368 + 369 + def on_progress_update(self, progress, message): 370 + """Progress aktualisieren""" 371 + self.progress_bar.setValue(progress) 372 + self.status_label.setText(message) 373 + 374 + def on_scan_complete(self, count): 375 + """Scan abgeschlossen""" 376 + self.scan_btn.setEnabled(True) 377 + self.stop_btn.setEnabled(False) 378 + self.progress_bar.setValue(100) 379 + self.status_label.setText(tr("dialog.discovery.scan_done", count=count)) 380 + 381 + def get_selected_cameras(self): 382 + """Ausgewählte Kameras zurückgeben""" 383 + selected = [] 384 + 385 + for row in range(self.camera_table.rowCount()): 386 + checkbox_widget = self.camera_table.cellWidget(row, 0) 387 + checkbox = checkbox_widget.findChild(QCheckBox) 388 + 389 + if checkbox and checkbox.isChecked(): 390 + camera_info = self.found_cameras[row] 391 + selected.append(camera_info) 392 + 393 + return selected
+174
discovery.py
··· 1 + import ipaddress 2 + import socket 3 + 4 + import requests 5 + from PyQt6.QtCore import QThread, pyqtSignal 6 + from requests.auth import HTTPDigestAuth 7 + 8 + from camera_utils import _ssdp_discovery, _udp_reolink_probe, _ws_discovery 9 + from i18n import tr 10 + 11 + 12 + class CameraDiscoveryThread(QThread): 13 + """Thread für automatische Kamera-Suche im Netzwerk""" 14 + camera_found = pyqtSignal(dict) # {ip, name, model, ports, uid} 15 + progress_update = pyqtSignal(int, str) 16 + scan_complete = pyqtSignal(int) 17 + 18 + def __init__(self, network_range, ports=None, username="admin", password=""): 19 + super().__init__() 20 + self.network_range = network_range 21 + self.ports = ports or [554, 8000, 80, 8554] # Typische Reolink/RTSP Ports 22 + self.username = username 23 + self.password = password 24 + self.running = False 25 + self.found_cameras = [] 26 + 27 + def run(self): 28 + """Netzwerk nach Kameras durchsuchen""" 29 + self.running = True 30 + self.found_cameras = [] 31 + 32 + try: 33 + # 1. Multi-Discovery (UDP Broadcasts) 34 + self.progress_update.emit(5, "Starte Netzwerk-Suche (UDP/WS/SSDP)...") 35 + 36 + discovery_ips = set() 37 + 38 + # Reolink BC Discovery 39 + broadcast_results = _udp_reolink_probe("255.255.255.255", timeout=1.5) 40 + if broadcast_results and isinstance(broadcast_results, list): 41 + for info in broadcast_results: 42 + discovery_ips.add(info['remote_ip']) 43 + camera_info = { 44 + 'ip': info.get('remote_ip', ''), 45 + 'ports': [554, 8000, 9000], 46 + 'name': info.get('name', 'Reolink Camera'), 47 + 'model': info.get('model', 'Unknown'), 48 + 'manufacturer': "Reolink", 49 + 'uid': info.get('devNo', '') or info.get('serial', '') 50 + } 51 + if camera_info['ip'] not in [c['ip'] for c in self.found_cameras]: 52 + self.found_cameras.append(camera_info) 53 + self.camera_found.emit(camera_info) 54 + 55 + # ONVIF Discovery 56 + onvif_ips = _ws_discovery(timeout=1.0) 57 + discovery_ips.update(onvif_ips) 58 + 59 + # SSDP Discovery 60 + ssdp_ips = _ssdp_discovery(timeout=1.0) 61 + discovery_ips.update(ssdp_ips) 62 + 63 + # Wenn wir Kameras über Broadcast gefunden haben, prüfen wir diese zuerst 64 + for dip in discovery_ips: 65 + if dip not in [c['ip'] for c in self.found_cameras]: 66 + # Hole Details für diese IP 67 + c_info = self._get_camera_info(dip, [80, 8000, 554, 9000]) 68 + if c_info: 69 + self.found_cameras.append(c_info) 70 + self.camera_found.emit(c_info) 71 + 72 + network = ipaddress.ip_network(self.network_range, strict=False) 73 + total_hosts = network.num_addresses - 2 # Ohne Netzwerk- und Broadcast-Adresse 74 + checked = 0 75 + 76 + for ip in network.hosts(): 77 + if not self.running: 78 + break 79 + 80 + ip_str = str(ip) 81 + checked += 1 82 + self.progress_update.emit(int((checked / total_hosts) * 100), tr("scan.checking", ip=ip_str)) 83 + 84 + # Schneller Port-Scan 85 + open_ports = self._scan_ports(ip_str) 86 + 87 + if open_ports: 88 + # Versuche Kamera-Info abzurufen 89 + camera_info = self._get_camera_info(ip_str, open_ports) 90 + if camera_info: 91 + self.found_cameras.append(camera_info) 92 + self.camera_found.emit(camera_info) 93 + 94 + self.scan_complete.emit(len(self.found_cameras)) 95 + 96 + except Exception as e: 97 + self.progress_update.emit(100, tr("scan.error", error=str(e))) 98 + 99 + def _scan_ports(self, ip, timeout=0.5): 100 + """Schneller Port-Scan für bestimmte IP""" 101 + open_ports = [] 102 + 103 + for port in self.ports: 104 + if not self.running: 105 + break 106 + 107 + try: 108 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 109 + sock.settimeout(timeout) 110 + result = sock.connect_ex((ip, port)) 111 + sock.close() 112 + 113 + if result == 0: 114 + open_ports.append(port) 115 + except Exception: 116 + pass 117 + 118 + return open_ports 119 + 120 + def _get_camera_info(self, ip, ports): 121 + """Versuche Kamera-Informationen abzurufen""" 122 + camera_info = { 123 + 'ip': ip, 124 + 'ports': ports, 125 + 'name': tr("camera.default_name.ip", ip=ip), 126 + 'model': tr("camera.meta.unknown"), 127 + 'manufacturer': tr("camera.meta.unknown"), 128 + 'uid': '', 129 + } 130 + 131 + # 1. Versuche UDP Reolink Probe (Port 9000) - am besten für UID & Standby 132 + udp_info = _udp_reolink_probe(ip) 133 + if udp_info: 134 + camera_info['name'] = udp_info.get('name', camera_info['name']) 135 + camera_info['model'] = udp_info.get('model', camera_info['model']) 136 + camera_info['manufacturer'] = "Reolink" 137 + camera_info['uid'] = udp_info.get('devNo', '') or udp_info.get('serial', '') 138 + return camera_info 139 + 140 + # 2. Versuche ONVIF/HTTP Zugriff 141 + if 80 in ports or 8000 in ports: 142 + for port in [80, 8000]: 143 + if port in ports: 144 + try: 145 + # Reolink API Versuch 146 + url = f"http://{ip}:{port}/api.cgi?cmd=GetDevInfo" 147 + response = requests.get( 148 + url, 149 + auth=HTTPDigestAuth(self.username, self.password), 150 + timeout=2 151 + ) 152 + 153 + if response.status_code == 200: 154 + data = response.json() 155 + if isinstance(data, list) and len(data) > 0: 156 + info = data[0].get('value', {}).get('DevInfo', {}) 157 + camera_info['name'] = info.get('name', camera_info['name']) 158 + camera_info['model'] = info.get('model', camera_info['model']) 159 + camera_info['manufacturer'] = "Reolink" 160 + camera_info['uid'] = info.get('devNo', '') or info.get('serial', '') 161 + return camera_info 162 + except Exception: 163 + pass 164 + 165 + # Wenn HTTP nicht funktioniert, aber RTSP Port offen ist 166 + if 554 in ports or 8554 in ports: 167 + camera_info['manufacturer'] = tr("camera.meta.rtsp_camera") 168 + return camera_info 169 + 170 + return None 171 + 172 + def stop(self): 173 + """Scan stoppen""" 174 + self.running = False
+260
i18n.py
··· 1 + CURRENT_LANG = "de" 2 + 3 + 4 + TRANSLATIONS = { 5 + "de": { 6 + "app.title": "Reolink Multi-Camera Viewer", 7 + "tab.cameras": "Kameras", 8 + "tab.config": "Konfiguration", 9 + "group.camera_config": "Kamera-Konfiguration", 10 + "label.rtsp_url": "RTSP URL:", 11 + "label.name": "Name:", 12 + "placeholder.rtsp_url": "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main", 13 + "placeholder.name.short": "z.B. Eingang", 14 + "btn.add": "Hinzufügen", 15 + "btn.discover": "Auto-Suche", 16 + "btn.clear_all": "Alle entfernen", 17 + "btn.path": "Speicherort", 18 + "btn.start_all": "Alle Streams starten", 19 + "btn.stop_all": "Alle Streams stoppen", 20 + "btn.record_all": "Alle aufnehmen", 21 + "btn.record_all_stop": "Alle stoppen", 22 + "label.camera_count": "Kameras: {total} | Aktiv: {active}", 23 + "big.select_camera": "Kamera auswählen…", 24 + "status.ready": "Bereit - CPU-optimiert für parallele Streams", 25 + "status.auto_added": "{count} Kameras automatisch hinzugefügt", 26 + "status.camera_added": "{name} hinzugefügt", 27 + "status.camera_updated": "Kamera {name} aktualisiert", 28 + "status.stream_started": "Stream für {name} gestartet", 29 + "status.streams_starting": "{count} Streams werden parallel gestartet...", 30 + "status.streams_stopped": "Alle Streams gestoppt", 31 + "status.camera_removed": "Kamera {id} entfernt", 32 + "status.cameras_removed": "Alle Kameras entfernt", 33 + "dialog.title.info": "Info", 34 + "dialog.title.error": "Fehler", 35 + "dialog.title.confirm": "Bestätigung", 36 + "dialog.msg.no_cameras": "Keine Kameras konfiguriert!", 37 + "dialog.confirm.remove_all": "Alle Kameras entfernen?", 38 + "dialog.confirm.remove_one": "Kamera '{name}' wirklich entfernen?", 39 + "dialog.path.choose": "Speicherort wählen", 40 + "label.cameras_per_row": "Kameras pro Reihe:", 41 + "status.path": "Speicherort: {path}", 42 + "status.no_image": "Kein Bild verfügbar", 43 + "status.snapshot_saved": "Snapshot gespeichert: {name}", 44 + "status.snapshot_error": "Snapshot Fehler: {error}", 45 + "status.recording": "Aufnahme: {name}", 46 + "status.recording_stopped": "Aufnahme gestoppt: {name}", 47 + "status.recordings_started": "{count} Aufnahmen gestartet", 48 + "status.recordings_stopped": "{count} Aufnahmen gestoppt", 49 + "camera.preview.click_to_start": "Stream starten klicken", 50 + "camera.preview.waiting": "Warte auf Stream...", 51 + "camera.preview.retrying": "Versuche erneut...", 52 + "camera.status.offline": "Offline", 53 + "camera.status.stopped": "Gestoppt", 54 + "camera.status.connected": "Verbunden", 55 + "camera.status.connecting": "Verbinde...", 56 + "camera.status.sleep": "Sleep/Offline", 57 + "camera.default_name.id": "Kamera {id}", 58 + "camera.default_name.ip": "Kamera {ip}", 59 + "camera.meta.unknown": "Unbekannt", 60 + "camera.meta.rtsp_camera": "RTSP-Kamera", 61 + "camera.error.stream_unreachable": "Stream nicht erreichbar", 62 + "camera.error.stream_interrupted": "Stream unterbrochen", 63 + "camera.tooltip.record": "Aufzeichnung starten/stoppen", 64 + "camera.tooltip.stream": "Stream starten/stoppen", 65 + "camera.tooltip.snapshot": "Einzelbild speichern", 66 + "camera.tooltip.edit": "Kamera bearbeiten", 67 + "camera.tooltip.remove": "Kamera entfernen", 68 + "dialog.edit.title": "Kamera bearbeiten", 69 + "dialog.edit.name": "Name:", 70 + "dialog.edit.name_ph": "z.B. Eingang Haupttür", 71 + "dialog.edit.rtsp_url": "RTSP URL:", 72 + "dialog.edit.rtsp_ph": "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main", 73 + "dialog.edit.help_group": "RTSP URL Format", 74 + "dialog.edit.help_text": "Standard Format: rtsp://username:password@ip:port/pfad\n\nReolink Beispiele:\n• Main Stream: rtsp://admin:pass@192.168.1.100:554/h264Preview_01_main\n• Sub Stream: rtsp://admin:pass@192.168.1.100:554/h264Preview_01_sub\n\nAndere Kameras:\n• ONVIF: rtsp://admin:pass@192.168.1.100:554/onvif1\n• Hikvision: rtsp://admin:pass@192.168.1.100:554/Streaming/Channels/101", 75 + "dialog.edit.builder_group": "Schnell-Editor", 76 + "dialog.edit.ip": "IP:", 77 + "dialog.edit.ip_ph": "192.168.1.100", 78 + "dialog.edit.port": "Port:", 79 + "dialog.edit.username": "Username:", 80 + "dialog.edit.password": "Password:", 81 + "dialog.edit.path": "Pfad:", 82 + "dialog.edit.build_url": "→ URL Generieren", 83 + "dialog.edit.err_ip": "Bitte IP-Adresse eingeben!", 84 + "dialog.discovery.title": "Kamera-Suche im Netzwerk", 85 + "dialog.discovery.scan_config": "Scan-Konfiguration", 86 + "dialog.discovery.network": "Netzwerk:", 87 + "dialog.discovery.network_ph": "192.168.1.0/24", 88 + "dialog.discovery.username": "Benutzername:", 89 + "dialog.discovery.password": "Passwort:", 90 + "dialog.discovery.start": "🔍 Suche starten", 91 + "dialog.discovery.stop": "⏹ Stoppen", 92 + "dialog.discovery.ready": "Bereit zum Scannen", 93 + "dialog.discovery.found": "Gefundene Kameras", 94 + "dialog.discovery.col.select": "Auswählen", 95 + "dialog.discovery.col.ip": "IP-Adresse", 96 + "dialog.discovery.col.name": "Name", 97 + "dialog.discovery.col.model": "Modell", 98 + "dialog.discovery.col.manufacturer": "Hersteller", 99 + "dialog.discovery.col.ports": "Ports", 100 + "dialog.discovery.col.uid": "UID", 101 + "dialog.discovery.err_network": "Bitte Netzwerk-Bereich eingeben!", 102 + "dialog.discovery.scan_cancelled": "Scan abgebrochen - {count} Kameras gefunden", 103 + "dialog.discovery.scan_done": "Scan abgeschlossen - {count} Kameras gefunden", 104 + "label.uid": "UID (optional):", 105 + "placeholder.uid": "z.B. 9527000000000000", 106 + "scan.checking": "Prüfe {ip}...", 107 + "scan.error": "Fehler: {error}", 108 + "error.prefix": "Fehler: {error}", 109 + "label.language": "Sprache:", 110 + "language.de": "Deutsch", 111 + "language.en": "English", 112 + "battery.warning.title": "⚠️ Akku-Kamera erkannt", 113 + "battery.warning.message": "Die Kamera '{name}' ({model}) ist eine Akku-betriebene Kamera.\n\n" 114 + "⚡ WICHTIG:\n" 115 + "• RTSP-Streaming ist eingeschränkt (Kamera schläft automatisch)\n" 116 + "• Akku wird bei Dauerstream stark belastet\n" 117 + "• Stream kann nach 30-60 Sekunden abbrechen\n\n" 118 + "💡 EMPFEHLUNG:\n" 119 + "Verwende ReolinkProxy als Proxy für stabiles Streaming:\n" 120 + "https://github.com/Shareed2k/reolinkproxy\n\n" 121 + "Siehe README für ReolinkProxy-Konfiguration.\n\n" 122 + "Kamera trotzdem hinzufügen?", 123 + "battery.indicator": "🔋 AKKU", 124 + }, 125 + "en": { 126 + "app.title": "Reolink Multi-Camera Viewer", 127 + "tab.cameras": "Cameras", 128 + "tab.config": "Settings", 129 + "group.camera_config": "Camera Configuration", 130 + "label.rtsp_url": "RTSP URL:", 131 + "label.name": "Name:", 132 + "placeholder.rtsp_url": "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main", 133 + "placeholder.name.short": "e.g. Entrance", 134 + "btn.add": "➕ Add", 135 + "btn.discover": "🔍 Auto-Discover", 136 + "btn.clear_all": "Remove all", 137 + "btn.path": "📁 Storage", 138 + "btn.start_all": "▶ Start all streams", 139 + "btn.stop_all": "⏹ Stop all streams", 140 + "btn.record_all": "● Record all", 141 + "btn.record_all_stop": "■ Stop all", 142 + "label.camera_count": "Cameras: {total} | Active: {active}", 143 + "big.select_camera": "Select a camera…", 144 + "status.ready": "Ready - CPU-optimized for parallel streams", 145 + "status.auto_added": "{count} cameras added automatically", 146 + "status.camera_added": "{name} added", 147 + "status.camera_updated": "Camera {name} updated", 148 + "status.stream_started": "Stream started for {name}", 149 + "status.streams_starting": "Starting {count} streams in parallel...", 150 + "status.streams_stopped": "All streams stopped", 151 + "status.camera_removed": "Camera {id} removed", 152 + "status.cameras_removed": "All cameras removed", 153 + "dialog.title.info": "Info", 154 + "dialog.title.error": "Error", 155 + "dialog.title.confirm": "Confirm", 156 + "dialog.msg.no_cameras": "No cameras configured!", 157 + "dialog.confirm.remove_all": "Remove all cameras?", 158 + "dialog.confirm.remove_one": "Remove camera '{name}'?", 159 + "dialog.path.choose": "Choose storage folder", 160 + "label.cameras_per_row": "Cameras per row:", 161 + "status.path": "Storage: {path}", 162 + "status.no_image": "No image available", 163 + "status.snapshot_saved": "Snapshot saved: {name}", 164 + "status.snapshot_error": "Snapshot error: {error}", 165 + "status.recording": "Recording: {name}", 166 + "status.recording_stopped": "Recording stopped: {name}", 167 + "status.recordings_started": "{count} recordings started", 168 + "status.recordings_stopped": "{count} recordings stopped", 169 + "camera.preview.click_to_start": "Click start stream", 170 + "camera.preview.waiting": "Waiting for stream...", 171 + "camera.preview.retrying": "Retrying...", 172 + "camera.status.offline": "Offline", 173 + "camera.status.stopped": "Stopped", 174 + "camera.status.connected": "Connected", 175 + "camera.status.connecting": "Connecting...", 176 + "camera.status.sleep": "Sleep/Offline", 177 + "camera.default_name.id": "Camera {id}", 178 + "camera.default_name.ip": "Camera {ip}", 179 + "camera.meta.unknown": "Unknown", 180 + "camera.meta.rtsp_camera": "RTSP camera", 181 + "camera.error.stream_unreachable": "Stream unreachable", 182 + "camera.error.stream_interrupted": "Stream interrupted", 183 + "camera.tooltip.record": "Start/stop recording", 184 + "camera.tooltip.stream": "Start/stop stream", 185 + "camera.tooltip.snapshot": "Save snapshot", 186 + "camera.tooltip.edit": "Edit camera", 187 + "camera.tooltip.remove": "Remove camera", 188 + "dialog.edit.title": "Edit camera", 189 + "dialog.edit.name": "Name:", 190 + "dialog.edit.name_ph": "e.g. Front door", 191 + "dialog.edit.rtsp_url": "RTSP URL:", 192 + "dialog.edit.rtsp_ph": "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main", 193 + "dialog.edit.help_group": "RTSP URL format", 194 + "dialog.edit.help_text": "Standard format: rtsp://username:password@ip:port/path\n\nReolink examples:\n• Main stream: rtsp://admin:pass@192.168.1.100:554/h264Preview_01_main\n• Sub stream: rtsp://admin:pass@192.168.1.100:554/h264Preview_01_sub\n\nOther cameras:\n• ONVIF: rtsp://admin:pass@192.168.1.100:554/onvif1\n• Hikvision: rtsp://admin:pass@192.168.1.100:554/Streaming/Channels/101", 195 + "dialog.edit.builder_group": "Quick editor", 196 + "dialog.edit.ip": "IP:", 197 + "dialog.edit.ip_ph": "192.168.1.100", 198 + "dialog.edit.port": "Port:", 199 + "dialog.edit.username": "Username:", 200 + "dialog.edit.password": "Password:", 201 + "dialog.edit.path": "Path:", 202 + "dialog.edit.build_url": "→ Build URL", 203 + "dialog.edit.err_ip": "Please enter an IP address!", 204 + "dialog.discovery.title": "Network camera discovery", 205 + "dialog.discovery.scan_config": "Scan configuration", 206 + "dialog.discovery.network": "Network:", 207 + "dialog.discovery.network_ph": "192.168.1.0/24", 208 + "dialog.discovery.username": "Username:", 209 + "dialog.discovery.password": "Password:", 210 + "dialog.discovery.start": "🔍 Start scan", 211 + "dialog.discovery.stop": "⏹ Stop", 212 + "dialog.discovery.ready": "Ready to scan", 213 + "dialog.discovery.found": "Found cameras", 214 + "dialog.discovery.col.select": "Select", 215 + "dialog.discovery.col.ip": "IP address", 216 + "dialog.discovery.col.name": "Name", 217 + "dialog.discovery.col.model": "Model", 218 + "dialog.discovery.col.manufacturer": "Vendor", 219 + "dialog.discovery.col.ports": "Ports", 220 + "dialog.discovery.col.uid": "UID", 221 + "dialog.discovery.err_network": "Please enter a network range!", 222 + "dialog.discovery.scan_cancelled": "Scan cancelled - {count} cameras found", 223 + "dialog.discovery.scan_done": "Scan finished - {count} cameras found", 224 + "label.uid": "UID (optional):", 225 + "placeholder.uid": "e.g. 9527000000000000", 226 + "scan.checking": "Checking {ip}...", 227 + "scan.error": "Error: {error}", 228 + "error.prefix": "Error: {error}", 229 + "label.language": "Language:", 230 + "language.de": "Deutsch", 231 + "language.en": "English", 232 + "battery.warning.title": "⚠️ Battery Camera Detected", 233 + "battery.warning.message": "Camera '{name}' ({model}) is battery-powered.\n\n" 234 + "⚡ IMPORTANT:\n" 235 + "• RTSP streaming is limited (camera sleeps automatically)\n" 236 + "• Battery drains quickly with continuous streaming\n" 237 + "• Stream may disconnect after 30-60 seconds\n\n" 238 + "💡 RECOMMENDATION:\n" 239 + "Use ReolinkProxy as proxy for stable streaming:\n" 240 + "https://github.com/Shareed2k/reolinkproxy\n\n" 241 + "See README for ReolinkProxy configuration.\n\n" 242 + "Add camera anyway?", 243 + "battery.indicator": "🔋 BATTERY", 244 + }, 245 + } 246 + 247 + 248 + def set_language(lang: str): 249 + global CURRENT_LANG 250 + if lang in TRANSLATIONS: 251 + CURRENT_LANG = lang 252 + 253 + 254 + def tr(key: str, **kwargs) -> str: 255 + lang_map = TRANSLATIONS.get(CURRENT_LANG) or TRANSLATIONS["de"] 256 + s = lang_map.get(key) or TRANSLATIONS["de"].get(key) or key 257 + try: 258 + return s.format(**kwargs) 259 + except Exception: 260 + return s
+201 -2262
main.py
··· 9 9 10 10 import cv2 11 11 import numpy as np 12 - from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, 13 - QHBoxLayout, QGridLayout, QPushButton, QLabel, 14 - QLineEdit, QSpinBox, QCheckBox, QFileDialog, 15 - QMessageBox, QComboBox, QGroupBox, QScrollArea, 16 - QProgressBar, QDialog, QDialogButtonBox, QTableWidget, 17 - QTableWidgetItem, QHeaderView, QSizePolicy, QSplitter, 18 - QTabWidget, QStyle, QProgressDialog) 19 - from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer, QMimeData, QSize, QEvent, QRect 20 - from PyQt6.QtGui import QImage, QPixmap, QDrag, QIcon, QPainter, QPen, QColor 12 + from PyQt6.QtWidgets import ( 13 + QApplication, 14 + QComboBox, 15 + QDialog, 16 + QFileDialog, 17 + QGroupBox, 18 + QHBoxLayout, 19 + QLabel, 20 + QLineEdit, 21 + QMainWindow, 22 + QMessageBox, 23 + QProgressDialog, 24 + QPushButton, 25 + QScrollArea, 26 + QSizePolicy, 27 + QSpinBox, 28 + QSplitter, 29 + QStyle, 30 + QTabWidget, 31 + QVBoxLayout, 32 + QWidget, 33 + ) 34 + from PyQt6.QtCore import QEvent, QRect, QSize, Qt, QTimer 35 + from PyQt6.QtGui import QImage, QPixmap 21 36 from datetime import datetime 22 - import json 23 - import socket 24 - import requests 25 - from requests.auth import HTTPDigestAuth 26 - import ipaddress 27 - import threading 28 - from urllib.parse import urlparse, quote 29 - import struct 30 37 import time 31 38 39 + from camera_utils import ( 40 + _build_rtsp_url, 41 + _is_battery_camera, 42 + normalize_reolinkproxy_camera, 43 + ) 44 + from config import DEFAULT_RECORDING_PATH, config_payload, load_config_data, save_config_data, snapshot_path_for 45 + from dialogs import CameraDiscoveryDialog, CameraEditDialog 46 + from i18n import set_language, tr 47 + from stream import CameraThread 48 + from ui_resources import load_svg_icon 49 + from widgets import CameraListContainer, CameraWidget, PreviewLabel 50 + 32 51 try: 33 52 import sip # type: ignore 34 53 except Exception: # pragma: no cover ··· 38 57 sip = None 39 58 40 59 41 - CURRENT_LANG = "de" 42 - 43 - 44 - def resource_path(relative_path: str) -> str: 45 - if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): 46 - base_path = sys._MEIPASS # type: ignore[attr-defined] 47 - else: 48 - base_path = os.path.abspath(os.path.dirname(__file__)) 49 - return os.path.join(base_path, relative_path) 50 - 51 - 52 - def load_svg_icon(name: str) -> QIcon: 53 - return QIcon(resource_path(os.path.join("assets", "icons", name))) 54 - 55 - 56 - TRANSLATIONS = { 57 - "de": { 58 - "app.title": "Reolink Multi-Camera Viewer", 59 - "tab.cameras": "Kameras", 60 - "tab.config": "Konfiguration", 61 - "group.camera_config": "Kamera-Konfiguration", 62 - "label.rtsp_url": "RTSP URL:", 63 - "label.name": "Name:", 64 - "placeholder.rtsp_url": "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main", 65 - "placeholder.name.short": "z.B. Eingang", 66 - "btn.add": "Hinzufügen", 67 - "btn.discover": "Auto-Suche", 68 - "btn.clear_all": "Alle entfernen", 69 - "btn.path": "Speicherort", 70 - "btn.start_all": "Alle Streams starten", 71 - "btn.stop_all": "Alle Streams stoppen", 72 - "btn.record_all": "Alle aufnehmen", 73 - "btn.record_all_stop": "Alle stoppen", 74 - "label.camera_count": "Kameras: {total} | Aktiv: {active}", 75 - "big.select_camera": "Kamera auswählen…", 76 - "status.ready": "Bereit - CPU-optimiert für parallele Streams", 77 - "status.auto_added": "{count} Kameras automatisch hinzugefügt", 78 - "status.camera_added": "{name} hinzugefügt", 79 - "status.camera_updated": "Kamera {name} aktualisiert", 80 - "status.stream_started": "Stream für {name} gestartet", 81 - "status.streams_starting": "{count} Streams werden parallel gestartet...", 82 - "status.streams_stopped": "Alle Streams gestoppt", 83 - "status.camera_removed": "Kamera {id} entfernt", 84 - "status.cameras_removed": "Alle Kameras entfernt", 85 - "dialog.title.info": "Info", 86 - "dialog.title.error": "Fehler", 87 - "dialog.title.confirm": "Bestätigung", 88 - "dialog.msg.no_cameras": "Keine Kameras konfiguriert!", 89 - "dialog.confirm.remove_all": "Alle Kameras entfernen?", 90 - "dialog.confirm.remove_one": "Kamera '{name}' wirklich entfernen?", 91 - "dialog.path.choose": "Speicherort wählen", 92 - "label.cameras_per_row": "Kameras pro Reihe:", 93 - "status.path": "Speicherort: {path}", 94 - "status.no_image": "Kein Bild verfügbar", 95 - "status.snapshot_saved": "Snapshot gespeichert: {name}", 96 - "status.snapshot_error": "Snapshot Fehler: {error}", 97 - "status.recording": "Aufnahme: {name}", 98 - "status.recording_stopped": "Aufnahme gestoppt: {name}", 99 - "status.recordings_started": "{count} Aufnahmen gestartet", 100 - "status.recordings_stopped": "{count} Aufnahmen gestoppt", 101 - "camera.preview.click_to_start": "Stream starten klicken", 102 - "camera.preview.waiting": "Warte auf Stream...", 103 - "camera.preview.retrying": "Versuche erneut...", 104 - "camera.status.offline": "Offline", 105 - "camera.status.stopped": "Gestoppt", 106 - "camera.status.connected": "Verbunden", 107 - "camera.status.sleep": "Sleep/Offline", 108 - "camera.default_name.id": "Kamera {id}", 109 - "camera.default_name.ip": "Kamera {ip}", 110 - "camera.meta.unknown": "Unbekannt", 111 - "camera.meta.rtsp_camera": "RTSP-Kamera", 112 - "camera.error.stream_unreachable": "Stream nicht erreichbar", 113 - "camera.error.stream_interrupted": "Stream unterbrochen", 114 - "camera.tooltip.record": "Aufzeichnung starten/stoppen", 115 - "camera.tooltip.stream": "Stream starten/stoppen", 116 - "camera.tooltip.snapshot": "Einzelbild speichern", 117 - "camera.tooltip.edit": "Kamera bearbeiten", 118 - "camera.tooltip.remove": "Kamera entfernen", 119 - "dialog.edit.title": "Kamera bearbeiten", 120 - "dialog.edit.name": "Name:", 121 - "dialog.edit.name_ph": "z.B. Eingang Haupttür", 122 - "dialog.edit.rtsp_url": "RTSP URL:", 123 - "dialog.edit.rtsp_ph": "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main", 124 - "dialog.edit.help_group": "RTSP URL Format", 125 - "dialog.edit.help_text": "Standard Format: rtsp://username:password@ip:port/pfad\n\nReolink Beispiele:\n• Main Stream: rtsp://admin:pass@192.168.1.100:554/h264Preview_01_main\n• Sub Stream: rtsp://admin:pass@192.168.1.100:554/h264Preview_01_sub\n\nAndere Kameras:\n• ONVIF: rtsp://admin:pass@192.168.1.100:554/onvif1\n• Hikvision: rtsp://admin:pass@192.168.1.100:554/Streaming/Channels/101", 126 - "dialog.edit.builder_group": "Schnell-Editor", 127 - "dialog.edit.ip": "IP:", 128 - "dialog.edit.ip_ph": "192.168.1.100", 129 - "dialog.edit.port": "Port:", 130 - "dialog.edit.username": "Username:", 131 - "dialog.edit.password": "Password:", 132 - "dialog.edit.path": "Pfad:", 133 - "dialog.edit.build_url": "→ URL Generieren", 134 - "dialog.edit.err_ip": "Bitte IP-Adresse eingeben!", 135 - "dialog.discovery.title": "Kamera-Suche im Netzwerk", 136 - "dialog.discovery.scan_config": "Scan-Konfiguration", 137 - "dialog.discovery.network": "Netzwerk:", 138 - "dialog.discovery.network_ph": "192.168.1.0/24", 139 - "dialog.discovery.username": "Benutzername:", 140 - "dialog.discovery.password": "Passwort:", 141 - "dialog.discovery.start": "🔍 Suche starten", 142 - "dialog.discovery.stop": "⏹ Stoppen", 143 - "dialog.discovery.ready": "Bereit zum Scannen", 144 - "dialog.discovery.found": "Gefundene Kameras", 145 - "dialog.discovery.col.select": "Auswählen", 146 - "dialog.discovery.col.ip": "IP-Adresse", 147 - "dialog.discovery.col.name": "Name", 148 - "dialog.discovery.col.model": "Modell", 149 - "dialog.discovery.col.manufacturer": "Hersteller", 150 - "dialog.discovery.col.ports": "Ports", 151 - "dialog.discovery.col.uid": "UID", 152 - "dialog.discovery.err_network": "Bitte Netzwerk-Bereich eingeben!", 153 - "dialog.discovery.scan_cancelled": "Scan abgebrochen - {count} Kameras gefunden", 154 - "dialog.discovery.scan_done": "Scan abgeschlossen - {count} Kameras gefunden", 155 - "label.uid": "UID (optional):", 156 - "placeholder.uid": "z.B. 9527000000000000", 157 - "scan.checking": "Prüfe {ip}...", 158 - "scan.error": "Fehler: {error}", 159 - "error.prefix": "Fehler: {error}", 160 - "label.language": "Sprache:", 161 - "language.de": "Deutsch", 162 - "language.en": "English", 163 - "battery.warning.title": "⚠️ Akku-Kamera erkannt", 164 - "battery.warning.message": "Die Kamera '{name}' ({model}) ist eine Akku-betriebene Kamera.\n\n" 165 - "⚡ WICHTIG:\n" 166 - "• RTSP-Streaming ist eingeschränkt (Kamera schläft automatisch)\n" 167 - "• Akku wird bei Dauerstream stark belastet\n" 168 - "• Stream kann nach 30-60 Sekunden abbrechen\n\n" 169 - "💡 EMPFEHLUNG:\n" 170 - "Verwende ReolinkProxy als Proxy für stabiles Streaming:\n" 171 - "https://github.com/Shareed2k/reolinkproxy\n\n" 172 - "Siehe README für ReolinkProxy-Konfiguration.\n\n" 173 - "Kamera trotzdem hinzufügen?", 174 - "battery.indicator": "🔋 AKKU", 175 - }, 176 - "en": { 177 - "app.title": "Reolink Multi-Camera Viewer", 178 - "tab.cameras": "Cameras", 179 - "tab.config": "Settings", 180 - "group.camera_config": "Camera Configuration", 181 - "label.rtsp_url": "RTSP URL:", 182 - "label.name": "Name:", 183 - "placeholder.rtsp_url": "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main", 184 - "placeholder.name.short": "e.g. Entrance", 185 - "btn.add": "➕ Add", 186 - "btn.discover": "🔍 Auto-Discover", 187 - "btn.clear_all": "Remove all", 188 - "btn.path": "📁 Storage", 189 - "btn.start_all": "▶ Start all streams", 190 - "btn.stop_all": "⏹ Stop all streams", 191 - "btn.record_all": "● Record all", 192 - "btn.record_all_stop": "■ Stop all", 193 - "label.camera_count": "Cameras: {total} | Active: {active}", 194 - "big.select_camera": "Select a camera…", 195 - "status.ready": "Ready - CPU-optimized for parallel streams", 196 - "status.auto_added": "{count} cameras added automatically", 197 - "status.camera_added": "{name} added", 198 - "status.camera_updated": "Camera {name} updated", 199 - "status.stream_started": "Stream started for {name}", 200 - "status.streams_starting": "Starting {count} streams in parallel...", 201 - "status.streams_stopped": "All streams stopped", 202 - "status.camera_removed": "Camera {id} removed", 203 - "status.cameras_removed": "All cameras removed", 204 - "dialog.title.info": "Info", 205 - "dialog.title.error": "Error", 206 - "dialog.title.confirm": "Confirm", 207 - "dialog.msg.no_cameras": "No cameras configured!", 208 - "dialog.confirm.remove_all": "Remove all cameras?", 209 - "dialog.confirm.remove_one": "Remove camera '{name}'?", 210 - "dialog.path.choose": "Choose storage folder", 211 - "label.cameras_per_row": "Cameras per row:", 212 - "status.path": "Storage: {path}", 213 - "status.no_image": "No image available", 214 - "status.snapshot_saved": "Snapshot saved: {name}", 215 - "status.snapshot_error": "Snapshot error: {error}", 216 - "status.recording": "Recording: {name}", 217 - "status.recording_stopped": "Recording stopped: {name}", 218 - "status.recordings_started": "{count} recordings started", 219 - "status.recordings_stopped": "{count} recordings stopped", 220 - "camera.preview.click_to_start": "Click start stream", 221 - "camera.preview.waiting": "Waiting for stream...", 222 - "camera.preview.retrying": "Retrying...", 223 - "camera.status.offline": "Offline", 224 - "camera.status.stopped": "Stopped", 225 - "camera.status.connected": "Connected", 226 - "camera.status.sleep": "Sleep/Offline", 227 - "camera.default_name.id": "Camera {id}", 228 - "camera.default_name.ip": "Camera {ip}", 229 - "camera.meta.unknown": "Unknown", 230 - "camera.meta.rtsp_camera": "RTSP camera", 231 - "camera.error.stream_unreachable": "Stream unreachable", 232 - "camera.error.stream_interrupted": "Stream interrupted", 233 - "camera.tooltip.record": "Start/stop recording", 234 - "camera.tooltip.stream": "Start/stop stream", 235 - "camera.tooltip.snapshot": "Save snapshot", 236 - "camera.tooltip.edit": "Edit camera", 237 - "camera.tooltip.remove": "Remove camera", 238 - "dialog.edit.title": "Edit camera", 239 - "dialog.edit.name": "Name:", 240 - "dialog.edit.name_ph": "e.g. Front door", 241 - "dialog.edit.rtsp_url": "RTSP URL:", 242 - "dialog.edit.rtsp_ph": "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main", 243 - "dialog.edit.help_group": "RTSP URL format", 244 - "dialog.edit.help_text": "Standard format: rtsp://username:password@ip:port/path\n\nReolink examples:\n• Main stream: rtsp://admin:pass@192.168.1.100:554/h264Preview_01_main\n• Sub stream: rtsp://admin:pass@192.168.1.100:554/h264Preview_01_sub\n\nOther cameras:\n• ONVIF: rtsp://admin:pass@192.168.1.100:554/onvif1\n• Hikvision: rtsp://admin:pass@192.168.1.100:554/Streaming/Channels/101", 245 - "dialog.edit.builder_group": "Quick editor", 246 - "dialog.edit.ip": "IP:", 247 - "dialog.edit.ip_ph": "192.168.1.100", 248 - "dialog.edit.port": "Port:", 249 - "dialog.edit.username": "Username:", 250 - "dialog.edit.password": "Password:", 251 - "dialog.edit.path": "Path:", 252 - "dialog.edit.build_url": "→ Build URL", 253 - "dialog.edit.err_ip": "Please enter an IP address!", 254 - "dialog.discovery.title": "Network camera discovery", 255 - "dialog.discovery.scan_config": "Scan configuration", 256 - "dialog.discovery.network": "Network:", 257 - "dialog.discovery.network_ph": "192.168.1.0/24", 258 - "dialog.discovery.username": "Username:", 259 - "dialog.discovery.password": "Password:", 260 - "dialog.discovery.start": "🔍 Start scan", 261 - "dialog.discovery.stop": "⏹ Stop", 262 - "dialog.discovery.ready": "Ready to scan", 263 - "dialog.discovery.found": "Found cameras", 264 - "dialog.discovery.col.select": "Select", 265 - "dialog.discovery.col.ip": "IP address", 266 - "dialog.discovery.col.name": "Name", 267 - "dialog.discovery.col.model": "Model", 268 - "dialog.discovery.col.manufacturer": "Vendor", 269 - "dialog.discovery.col.ports": "Ports", 270 - "dialog.discovery.col.uid": "UID", 271 - "dialog.discovery.err_network": "Please enter a network range!", 272 - "dialog.discovery.scan_cancelled": "Scan cancelled - {count} cameras found", 273 - "dialog.discovery.scan_done": "Scan finished - {count} cameras found", 274 - "label.uid": "UID (optional):", # Added by instruction 275 - "placeholder.uid": "e.g. 9527000000000000", # Added by instruction 276 - "scan.checking": "Checking {ip}...", 277 - "scan.error": "Error: {error}", 278 - "error.prefix": "Error: {error}", 279 - "label.language": "Language:", 280 - "language.de": "Deutsch", 281 - "language.en": "English", 282 - "battery.warning.title": "⚠️ Battery Camera Detected", 283 - "battery.warning.message": "Camera '{name}' ({model}) is battery-powered.\n\n" 284 - "⚡ IMPORTANT:\n" 285 - "• RTSP streaming is limited (camera sleeps automatically)\n" 286 - "• Battery drains quickly with continuous streaming\n" 287 - "• Stream may disconnect after 30-60 seconds\n\n" 288 - "💡 RECOMMENDATION:\n" 289 - "Use ReolinkProxy as proxy for stable streaming:\n" 290 - "https://github.com/Shareed2k/reolinkproxy\n\n" 291 - "See README for ReolinkProxy configuration.\n\n" 292 - "Add camera anyway?", 293 - "battery.indicator": "🔋 BATTERY", 294 - }, 295 - } 296 - 297 - 298 - def set_language(lang: str): 299 - global CURRENT_LANG 300 - if lang in TRANSLATIONS: 301 - CURRENT_LANG = lang 302 - 303 - 304 - def tr(key: str, **kwargs) -> str: 305 - lang_map = TRANSLATIONS.get(CURRENT_LANG) or TRANSLATIONS["de"] 306 - s = lang_map.get(key) or TRANSLATIONS["de"].get(key) or key 307 - try: 308 - return s.format(**kwargs) 309 - except Exception: 310 - return s 311 - 312 - 313 - def _parse_rtsp_url(rtsp_url: str): 314 - """Best-effort RTSP URL parsing (host/port/user/pass).""" 315 - try: 316 - u = urlparse(rtsp_url, allow_fragments=False) 317 - if not u.hostname and "#" in rtsp_url: 318 - sanitized = rtsp_url.replace("#", "%23") 319 - u = urlparse(sanitized, allow_fragments=False) 320 - host = u.hostname 321 - port = u.port or 554 322 - user = u.username 323 - password = u.password 324 - return host, port, user, password 325 - except Exception: 326 - return None, 554, None, None 327 - 328 - 329 - def _normalize_rtsp_url(rtsp_url: str) -> str: 330 - """Normalize RTSP URL to always include explicit port to avoid FFmpeg TCP fallback errors.""" 331 - try: 332 - u = urlparse(rtsp_url) 333 - if not u.scheme or u.scheme not in ('rtsp', 'rtsps'): 334 - return rtsp_url 335 - 336 - # Ensure port is explicit 337 - port = u.port or 554 338 - host = u.hostname 339 - if not host: 340 - return rtsp_url 341 - 342 - # Rebuild URL with explicit port 343 - auth = f"{u.username}:{u.password}@" if u.username else "" 344 - path = u.path or "/" 345 - query = f"?{u.query}" if u.query else "" 346 - 347 - return f"{u.scheme}://{auth}{host}:{port}{path}{query}" 348 - except Exception: 349 - return rtsp_url 350 - 351 - 352 - def _is_battery_camera(model: str, name: str = "") -> bool: 353 - """Check if camera is battery-powered based on model/name.""" 354 - if not model and not name: 355 - return False 356 - 357 - search_text = f"{model} {name}".lower() 358 - 359 - # Known battery camera series/models 360 - battery_keywords = [ 361 - "argus", # Argus series (Argus 2, 3, PT, Eco, Ultra) 362 - "go", # Go series (Go, Go Plus, Go PT) 363 - "altas", # Altas PT Ultra 364 - "battery", # Explicit battery mention 365 - "solar", # Solar-powered (usually battery) 366 - ] 367 - 368 - return any(keyword in search_text for keyword in battery_keywords) 369 - 370 - 371 - def _build_rtsp_url(host: str, port: int = 554, username: str = "", password: str = "", 372 - path: str = "h264Preview_01_main", scheme: str = "rtsp") -> str: 373 - """Build RTSP URL with proper URL-encoding for credentials containing special characters. 374 - 375 - Args: 376 - host: Camera IP or hostname 377 - port: RTSP port (default 554) 378 - username: Username (will be URL-encoded) 379 - password: Password (will be URL-encoded) 380 - path: RTSP path (default h264Preview_01_main) 381 - scheme: URL scheme (rtsp or rtsps) 382 - 383 - Returns: 384 - Properly formatted RTSP URL with encoded credentials 385 - """ 386 - # URL-encode credentials to handle special characters like #, @, :, etc. 387 - # safe='' means encode ALL special characters 388 - encoded_user = quote(username, safe='') if username else '' 389 - encoded_pass = quote(password, safe='') if password else '' 390 - 391 - # Build auth string 392 - if encoded_user and encoded_pass: 393 - auth = f"{encoded_user}:{encoded_pass}@" 394 - elif encoded_user: 395 - auth = f"{encoded_user}@" 396 - else: 397 - auth = "" 398 - 399 - # Ensure path starts with / 400 - if path and not path.startswith('/'): 401 - path = f"/{path}" 402 - 403 - return f"{scheme}://{auth}{host}:{port}{path}" 404 - 405 - 406 - def _reolinkproxy_camera_name(name: str) -> str: 407 - """Normalize camera name for ReolinkProxy stream path.""" 408 - return (name or "Camera").strip().replace(" ", "_") 409 - 410 - 411 - def _reolinkproxy_rtsp_url(name: str, port: int = 8554) -> str: 412 - cam_name = _reolinkproxy_camera_name(name) 413 - return f"rtsp://localhost:{port}/{cam_name}/mainStream" 414 - 415 - 416 - def _reolinkproxy_proxy_config(rtsp_url: str, name: str, username: str, password: str, uid: str = "", model: str = "", manufacturer: str = "") -> dict | None: 417 - """Build a persistent proxy config for Reolink WLAN/Battery cameras.""" 418 - host, port, user, pwd = _parse_rtsp_url(rtsp_url) 419 - if host in ("localhost", "127.0.0.1") and port == 8554: 420 - return None 421 - 422 - is_reolink = ( 423 - (manufacturer or "").lower() == "reolink" 424 - or "reolink" in (model or "").lower() 425 - or port == 9000 426 - ) 427 - use_reolinkproxy = port == 9000 or _is_battery_camera(model, name) 428 - 429 - if not (is_reolink and use_reolinkproxy and host): 430 - return None 431 - 432 - proxy_port = int(port or 9000) 433 - 434 - return { 435 - "type": "reolinkproxy", 436 - "host": host, 437 - "port": proxy_port, 438 - "username": username or user or "", 439 - "password": password or pwd or "", 440 - "stream": "main", 441 - "battery": True, 442 - "pause_on_client": True, 443 - "idle_disconnect": True, 444 - "idle_timeout": "30s", 445 - } 446 - 447 - 448 - def _maybe_use_reolinkproxy(rtsp_url: str, name: str, username: str, password: str, uid: str = "", model: str = "", manufacturer: str = "") -> str: 449 - """Switch Reolink WLAN/Battery cameras to the local ReolinkProxy RTSP URL.""" 450 - if _reolinkproxy_proxy_config(rtsp_url, name, username, password, uid, model, manufacturer): 451 - return _reolinkproxy_rtsp_url(name) 452 - return rtsp_url 453 - 454 - 455 - def _tcp_probe(host: str, port: int, timeout: float = 0.7) -> tuple[bool, str]: 456 - """Fast TCP reachability check. 457 - 458 - Returns (ok, reason) where reason is one of: ok, timeout, refused, unreachable, error. 459 - """ 460 - if not host: 461 - return False, "error" 462 - try: 463 - sock = socket.create_connection((host, int(port)), timeout=timeout) 464 - sock.close() 465 - return True, "ok" 466 - except ConnectionRefusedError: 467 - return False, "refused" 468 - except TimeoutError: 469 - return False, "timeout" 470 - except OSError as e: 471 - # e.g. No route to host, network unreachable, etc. 472 - if getattr(e, "errno", None) in (101, 113): 473 - return False, "unreachable" 474 - return False, "error" 475 - 476 - 477 - def _ws_discovery(timeout: float = 2.0) -> list[str]: 478 - """ONVIF/WS-Discovery (UDP 3702).""" 479 - msg = ( 480 - '<?xml version="1.0" encoding="utf-8"?>' 481 - '<Envelope xmlns:tds="http://www.onvif.org/ver10/device/wsdl" xmlns="http://www.w3.org/2003/05/soap-envelope">' 482 - '<Header><MessageID xmlns="http://schemas.xmlsoap.org/ws/2004/08/addressing">uuid:8253()</MessageID>' 483 - '<To xmlns="http://schemas.xmlsoap.org/ws/2004/08/addressing">urn:schemas-xmlsoap-org:ws:2004:08:discovery</To>' 484 - '<Action xmlns="http://schemas.xmlsoap.org/ws/2004/08/addressing">http://schemas.xmlsoap.org/ws/2004/08/discovery/Probe</Action></Header>' 485 - '<Body><Probe xmlns="http://schemas.xmlsoap.org/ws/2004/08/discovery"><Types>tds:Device</Types></Probe></Body></Envelope>' 486 - ) 487 - ips = set() 488 - try: 489 - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 490 - sock.settimeout(timeout) 491 - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 492 - sock.sendto(msg.encode(), ("239.255.255.250", 3702)) 493 - 494 - while True: 495 - try: 496 - data, addr = sock.recvfrom(4096) 497 - ips.add(addr[0]) 498 - except socket.timeout: 499 - break 500 - sock.close() 501 - except: pass 502 - return list(ips) 503 - 504 - 505 - def _ssdp_discovery(timeout: float = 2.0) -> list[str]: 506 - """UPnP/SSDP Discovery (UDP 1900).""" 507 - msg = ( 508 - 'M-SEARCH * HTTP/1.1\r\n' 509 - 'HOST: 239.255.255.250:1900\r\n' 510 - 'MAN: "ssdp:discover"\r\n' 511 - 'MX: 2\r\n' 512 - 'ST: ssdp:all\r\n' 513 - '\r\n' 514 - ) 515 - ips = set() 516 - try: 517 - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 518 - sock.settimeout(timeout) 519 - sock.sendto(msg.encode(), ("239.255.255.250", 1900)) 520 - 521 - while True: 522 - try: 523 - data, addr = sock.recvfrom(4096) 524 - ips.add(addr[0]) 525 - except socket.timeout: 526 - break 527 - sock.close() 528 - except: pass 529 - return list(ips) 530 - 531 - 532 - def _udp_reolink_probe(ip: str, timeout: float = 2.0) -> list | dict | None: 533 - """Sendet ein Reolink UDP Discovery Paket an eine spezifische oder Broadcast IP.""" 534 - ports = [9000, 10000, 2000] 535 - 536 - # Discovery JSON Payloads (GetDevInfo und Search) 537 - payloads = [ 538 - [{"cmd": "GetDevInfo", "action": 0, "param": {}}], 539 - {"cmd": "GetDevInfo", "action": 0, "param": {}}, 540 - [{"cmd": "Search", "action": 0, "param": {}}], 541 - {"cmd": "Search", "action": 0, "param": {}} 542 - ] 543 - 544 - results = [] 545 - 546 - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 547 - sock.settimeout(timeout) 548 - if ip == "255.255.255.255": 549 - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 550 - 551 - for port in ports: 552 - for cmd_data in payloads: 553 - data = json.dumps(cmd_data).encode('utf-8') 554 - for endian in ['<', '>']: 555 - header = struct.pack(endian + "2sHHHII", b"BC", 0, 1, 0, len(data), 0) 556 - payload = header + data 557 - try: 558 - # Weck-Schuss 559 - sock.sendto(b"\x00" * 32, (ip, port)) 560 - sock.sendto(payload, (ip, port)) 561 - 562 - while True: 563 - try: 564 - resp_data, addr = sock.recvfrom(4096) 565 - except socket.timeout: 566 - break 567 - 568 - if len(resp_data) >= 16: 569 - idx = -1 570 - for i in range(len(resp_data) - 1): 571 - if resp_data[i:i+2].lower() == b"bc": 572 - for j in range(i+2, min(i+48, len(resp_data))): 573 - if resp_data[j] in (ord('['), ord('{')): 574 - idx = j 575 - break 576 - if idx != -1: break 577 - 578 - if idx != -1: 579 - content = resp_data[idx:].decode('utf-8', 'ignore') 580 - if content.startswith('['): end = content.rfind(']') 581 - else: end = content.rfind('}') 582 - 583 - if end != -1: 584 - try: 585 - res = json.loads(content[:end+1]) 586 - info = None 587 - # Wir suchen nach DevInfo oder Search-Response 588 - val = res[0].get('value', {}) if isinstance(res, list) else res.get('value', {}) 589 - info = val.get('DevInfo') or val.get('SearchResult') or val 590 - 591 - if info and (info.get('name') or info.get('serial') or info.get('mac')): 592 - info['remote_ip'] = addr[0] 593 - if ip != "255.255.255.255": 594 - sock.close() 595 - return info 596 - if info.get('remote_ip') not in [r.get('remote_ip') for r in results]: 597 - results.append(info) 598 - except: pass 599 - if ip != "255.255.255.255": break 600 - except: break 601 - if ip != "255.255.255.255" and results: break 602 - 603 - sock.close() 604 - return results if ip == "255.255.255.255" else (results[0] if results else None) 605 - 606 - 607 - def _udp_reolink_wake(ip: str, uid: str = ""): 608 - """Sendet einen intensiven Weck-Burst an eine Reolink Kamera.""" 609 - if not ip: return 610 - try: 611 - # Falls UID vorhanden, bauen wir ein echtes Abfrage-Paket 612 - payload = b"" 613 - if uid: 614 - msg = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] 615 - data = json.dumps(msg).encode('utf-8') 616 - header = struct.pack("<2sHHHII", b"BC", 0, 1, 0, len(data), 0) 617 - payload = header + data 618 - 619 - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 620 - # Wir pingen alle relevanten Ports mehrfach 621 - for port in [9000, 10000, 8000]: 622 - for _ in range(5): 623 - # Null-Bytes zum "Aufwecken" des WLAN/PIR 624 - sock.sendto(b"\x00" * 64, (ip, port)) 625 - if payload: 626 - # Gezielte Abfrage mit UID (Baichuan) 627 - sock.sendto(payload, (ip, port)) 628 - else: 629 - # Generischer Header 630 - h = struct.pack("<2sHHHII", b"BC", 0, 1, 0, 0, 0) 631 - sock.sendto(h, (ip, port)) 632 - time.sleep(0.02) 633 - sock.close() 634 - except: pass 635 - 636 - 637 - class CameraListContainer(QWidget): 638 - order_changed = pyqtSignal(object) # list[int] 639 - 640 - def __init__(self, parent=None): 641 - super().__init__(parent) 642 - self.setAcceptDrops(True) 643 - self._layout = QVBoxLayout(self) 644 - self._layout.setSpacing(8) 645 - self._layout.setAlignment(Qt.AlignmentFlag.AlignTop) 646 - self._layout.setContentsMargins(0, 0, 0, 0) 647 - 648 - @property 649 - def layout_ref(self) -> QVBoxLayout: 650 - return self._layout 651 - 652 - def dragEnterEvent(self, event): 653 - if event.mimeData().hasFormat("application/x-wildcam-camera-id"): 654 - event.acceptProposedAction() 655 - else: 656 - super().dragEnterEvent(event) 657 - 658 - def dragMoveEvent(self, event): 659 - if event.mimeData().hasFormat("application/x-wildcam-camera-id"): 660 - event.acceptProposedAction() 661 - else: 662 - super().dragMoveEvent(event) 663 - 664 - def dropEvent(self, event): 665 - if not event.mimeData().hasFormat("application/x-wildcam-camera-id"): 666 - super().dropEvent(event) 667 - return 668 - 669 - data = bytes(event.mimeData().data("application/x-wildcam-camera-id")).decode("utf-8", "ignore") 670 - try: 671 - dragged_id = int(data) 672 - except Exception: 673 - event.ignore() 674 - return 675 - 676 - ordered_ids = [] 677 - for i in range(self._layout.count()): 678 - item = self._layout.itemAt(i) 679 - w = item.widget() if item else None 680 - if w is not None and hasattr(w, "camera_id"): 681 - ordered_ids.append(int(getattr(w, "camera_id"))) 682 - 683 - if dragged_id not in ordered_ids: 684 - event.ignore() 685 - return 686 - 687 - drop_y = event.position().y() if hasattr(event, "position") else event.pos().y() 688 - insert_index = len(ordered_ids) 689 - for idx in range(self._layout.count()): 690 - item = self._layout.itemAt(idx) 691 - w = item.widget() if item else None 692 - if w is None: 693 - continue 694 - mid = w.y() + (w.height() / 2) 695 - if drop_y < mid: 696 - insert_index = idx 697 - break 698 - 699 - ordered_ids.remove(dragged_id) 700 - if insert_index > len(ordered_ids): 701 - insert_index = len(ordered_ids) 702 - ordered_ids.insert(insert_index, dragged_id) 703 - 704 - event.acceptProposedAction() 705 - QTimer.singleShot(0, lambda: self.order_changed.emit(ordered_ids)) 706 - 707 - 708 - class CameraDiscoveryThread(QThread): 709 - """Thread für automatische Kamera-Suche im Netzwerk""" 710 - camera_found = pyqtSignal(dict) # {ip, name, model, ports, uid} 711 - progress_update = pyqtSignal(int, str) 712 - scan_complete = pyqtSignal(int) 713 - 714 - def __init__(self, network_range, ports=None, username="admin", password=""): 715 - super().__init__() 716 - self.network_range = network_range 717 - self.ports = ports or [554, 8000, 80, 8554] # Typische Reolink/RTSP Ports 718 - self.username = username 719 - self.password = password 720 - self.running = False 721 - self.found_cameras = [] 722 - 723 - def run(self): 724 - """Netzwerk nach Kameras durchsuchen""" 725 - self.running = True 726 - self.found_cameras = [] 727 - 728 - try: 729 - # 1. Multi-Discovery (UDP Broadcasts) 730 - self.progress_update.emit(5, "Starte Netzwerk-Suche (UDP/WS/SSDP)...") 731 - 732 - discovery_ips = set() 733 - 734 - # Reolink BC Discovery 735 - broadcast_results = _udp_reolink_probe("255.255.255.255", timeout=1.5) 736 - if broadcast_results and isinstance(broadcast_results, list): 737 - for info in broadcast_results: 738 - discovery_ips.add(info['remote_ip']) 739 - camera_info = { 740 - 'ip': info.get('remote_ip', ''), 741 - 'ports': [554, 8000, 9000], 742 - 'name': info.get('name', 'Reolink Camera'), 743 - 'model': info.get('model', 'Unknown'), 744 - 'manufacturer': "Reolink", 745 - 'uid': info.get('devNo', '') or info.get('serial', '') 746 - } 747 - if camera_info['ip'] not in [c['ip'] for c in self.found_cameras]: 748 - self.found_cameras.append(camera_info) 749 - self.camera_found.emit(camera_info) 750 - 751 - # ONVIF Discovery 752 - onvif_ips = _ws_discovery(timeout=1.0) 753 - discovery_ips.update(onvif_ips) 754 - 755 - # SSDP Discovery 756 - ssdp_ips = _ssdp_discovery(timeout=1.0) 757 - discovery_ips.update(ssdp_ips) 758 - 759 - # Wenn wir Kameras über Broadcast gefunden haben, prüfen wir diese zuerst 760 - for dip in discovery_ips: 761 - if dip not in [c['ip'] for c in self.found_cameras]: 762 - # Hole Details für diese IP 763 - c_info = self._get_camera_info(dip, [80, 8000, 554, 9000]) 764 - if c_info: 765 - self.found_cameras.append(c_info) 766 - self.camera_found.emit(c_info) 767 - 768 - network = ipaddress.ip_network(self.network_range, strict=False) 769 - total_hosts = network.num_addresses - 2 # Ohne Netzwerk- und Broadcast-Adresse 770 - checked = 0 771 - 772 - for ip in network.hosts(): 773 - if not self.running: 774 - break 775 - 776 - ip_str = str(ip) 777 - checked += 1 778 - self.progress_update.emit(int((checked / total_hosts) * 100), tr("scan.checking", ip=ip_str)) 779 - 780 - # Schneller Port-Scan 781 - open_ports = self._scan_ports(ip_str) 782 - 783 - if open_ports: 784 - # Versuche Kamera-Info abzurufen 785 - camera_info = self._get_camera_info(ip_str, open_ports) 786 - if camera_info: 787 - self.found_cameras.append(camera_info) 788 - self.camera_found.emit(camera_info) 789 - 790 - self.scan_complete.emit(len(self.found_cameras)) 791 - 792 - except Exception as e: 793 - self.progress_update.emit(100, tr("scan.error", error=str(e))) 794 - 795 - def _scan_ports(self, ip, timeout=0.5): 796 - """Schneller Port-Scan für bestimmte IP""" 797 - open_ports = [] 798 - 799 - for port in self.ports: 800 - if not self.running: 801 - break 802 - 803 - try: 804 - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 805 - sock.settimeout(timeout) 806 - result = sock.connect_ex((ip, port)) 807 - sock.close() 808 - 809 - if result == 0: 810 - open_ports.append(port) 811 - except: 812 - pass 813 - 814 - return open_ports 815 - 816 - def _get_camera_info(self, ip, ports): 817 - """Versuche Kamera-Informationen abzurufen""" 818 - camera_info = { 819 - 'ip': ip, 820 - 'ports': ports, 821 - 'name': tr("camera.default_name.ip", ip=ip), 822 - 'model': tr("camera.meta.unknown"), 823 - 'manufacturer': tr("camera.meta.unknown"), 824 - 'uid': '', 825 - } 826 - 827 - # 1. Versuche UDP Reolink Probe (Port 9000) - am besten für UID & Standby 828 - udp_info = _udp_reolink_probe(ip) 829 - if udp_info: 830 - camera_info['name'] = udp_info.get('name', camera_info['name']) 831 - camera_info['model'] = udp_info.get('model', camera_info['model']) 832 - camera_info['manufacturer'] = "Reolink" 833 - camera_info['uid'] = udp_info.get('devNo', '') or udp_info.get('serial', '') 834 - return camera_info 835 - 836 - # 2. Versuche ONVIF/HTTP Zugriff 837 - if 80 in ports or 8000 in ports: 838 - for port in [80, 8000]: 839 - if port in ports: 840 - try: 841 - # Reolink API Versuch 842 - url = f"http://{ip}:{port}/api.cgi?cmd=GetDevInfo" 843 - response = requests.get( 844 - url, 845 - auth=HTTPDigestAuth(self.username, self.password), 846 - timeout=2 847 - ) 848 - 849 - if response.status_code == 200: 850 - data = response.json() 851 - if isinstance(data, list) and len(data) > 0: 852 - info = data[0].get('value', {}).get('DevInfo', {}) 853 - camera_info['name'] = info.get('name', camera_info['name']) 854 - camera_info['model'] = info.get('model', camera_info['model']) 855 - camera_info['manufacturer'] = "Reolink" 856 - camera_info['uid'] = info.get('devNo', '') or info.get('serial', '') 857 - return camera_info 858 - except: 859 - pass 860 - 861 - # Wenn HTTP nicht funktioniert, aber RTSP Port offen ist 862 - if 554 in ports or 8554 in ports: 863 - camera_info['manufacturer'] = tr("camera.meta.rtsp_camera") 864 - return camera_info 865 - 866 - return None 867 - 868 - def stop(self): 869 - """Scan stoppen""" 870 - self.running = False 871 - 872 - 873 - class CameraEditDialog(QDialog): 874 - """Dialog zum Bearbeiten einer Kamera""" 875 - def __init__(self, camera_data, parent=None): 876 - super().__init__(parent) 877 - self.setWindowTitle(tr("dialog.edit.title")) 878 - self.setModal(True) 879 - self.resize(600, 300) 880 - 881 - self.camera_data = camera_data.copy() 882 - self.init_ui() 883 - 884 - def init_ui(self): 885 - layout = QVBoxLayout() 886 - 887 - # Name 888 - name_layout = QHBoxLayout() 889 - name_layout.addWidget(QLabel(tr("dialog.edit.name"))) 890 - self.name_input = QLineEdit() 891 - self.name_input.setText(self.camera_data.get('name', '')) 892 - self.name_input.setPlaceholderText(tr("dialog.edit.name_ph")) 893 - name_layout.addWidget(self.name_input) 894 - layout.addLayout(name_layout) 895 - 896 - # RTSP URL 897 - url_layout = QVBoxLayout() 898 - url_layout.addWidget(QLabel(tr("dialog.edit.rtsp_url"))) 899 - self.url_input = QLineEdit() 900 - self.url_input.setText(self.camera_data.get('url', '')) 901 - self.url_input.setPlaceholderText(tr("dialog.edit.rtsp_ph")) 902 - url_layout.addWidget(self.url_input) 903 - layout.addLayout(url_layout) 904 - 905 - # UID 906 - uid_layout = QHBoxLayout() 907 - uid_layout.addWidget(QLabel(tr("label.uid"))) 908 - self.uid_input = QLineEdit() 909 - self.uid_input.setText(self.camera_data.get('uid', '')) 910 - self.uid_input.setPlaceholderText(tr("placeholder.uid")) 911 - uid_layout.addWidget(self.uid_input) 912 - layout.addLayout(uid_layout) 913 - 914 - # Hilfe-Text 915 - help_group = QGroupBox(tr("dialog.edit.help_group")) 916 - help_layout = QVBoxLayout() 917 - help_text = QLabel(tr("dialog.edit.help_text")) 918 - help_text.setWordWrap(True) 919 - help_text.setStyleSheet("color: #aaa; font-size: 10px;") 920 - help_layout.addWidget(help_text) 921 - help_group.setLayout(help_layout) 922 - layout.addWidget(help_group) 923 - 924 - # URL Builder Shortcut 925 - builder_group = QGroupBox(tr("dialog.edit.builder_group")) 926 - builder_layout = QGridLayout() 927 - 928 - builder_layout.addWidget(QLabel(tr("dialog.edit.ip")), 0, 0) 929 - self.ip_input = QLineEdit() 930 - self.ip_input.setPlaceholderText(tr("dialog.edit.ip_ph")) 931 - builder_layout.addWidget(self.ip_input, 0, 1) 932 - 933 - builder_layout.addWidget(QLabel(tr("dialog.edit.port")), 0, 2) 934 - self.port_input = QLineEdit() 935 - self.port_input.setText("554") 936 - self.port_input.setMaximumWidth(60) 937 - builder_layout.addWidget(self.port_input, 0, 3) 938 - 939 - builder_layout.addWidget(QLabel(tr("dialog.edit.username")), 1, 0) 940 - self.username_input = QLineEdit() 941 - self.username_input.setText("admin") 942 - builder_layout.addWidget(self.username_input, 1, 1) 943 - 944 - builder_layout.addWidget(QLabel(tr("dialog.edit.password")), 1, 2) 945 - self.password_input = QLineEdit() 946 - self.password_input.setEchoMode(QLineEdit.EchoMode.Password) 947 - builder_layout.addWidget(self.password_input, 1, 3) 948 - 949 - builder_layout.addWidget(QLabel(tr("dialog.edit.path")), 2, 0) 950 - self.path_combo = QComboBox() 951 - self.path_combo.addItems([ 952 - "h264Preview_01_main", 953 - "h264Preview_01_sub", 954 - "onvif1", 955 - "Streaming/Channels/101", 956 - "stream1", 957 - "live" 958 - ]) 959 - self.path_combo.setEditable(True) 960 - builder_layout.addWidget(self.path_combo, 2, 1, 1, 3) 961 - 962 - build_btn = QPushButton(tr("dialog.edit.build_url")) 963 - build_btn.clicked.connect(self.build_url) 964 - build_btn.setStyleSheet("background-color: #1976d2; color: white;") 965 - builder_layout.addWidget(build_btn, 3, 0, 1, 4) 966 - 967 - builder_group.setLayout(builder_layout) 968 - layout.addWidget(builder_group) 969 - 970 - # Aktuellen URL parsen 971 - self.parse_current_url() 972 - 973 - # Dialog Buttons 974 - button_box = QDialogButtonBox( 975 - QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel 976 - ) 977 - button_box.accepted.connect(self.accept) 978 - button_box.rejected.connect(self.reject) 979 - layout.addWidget(button_box) 980 - 981 - self.setLayout(layout) 982 - 983 - def parse_current_url(self): 984 - """Aktuellen URL in Felder zerlegen""" 985 - url = self.camera_data.get('url', '') 986 - 987 - try: 988 - # Format: rtsp://username:password@ip:port/path 989 - if url.startswith('rtsp://'): 990 - url = url[7:] # "rtsp://" entfernen 991 - 992 - if '@' in url: 993 - auth, rest = url.split('@', 1) 994 - if ':' in auth: 995 - username, password = auth.split(':', 1) 996 - self.username_input.setText(username) 997 - self.password_input.setText(password) 998 - 999 - if ':' in rest: 1000 - ip, port_path = rest.split(':', 1) 1001 - self.ip_input.setText(ip) 1002 - 1003 - if '/' in port_path: 1004 - port, path = port_path.split('/', 1) 1005 - self.port_input.setText(port) 1006 - self.path_combo.setCurrentText(path) 1007 - except: 1008 - pass 1009 - 1010 - def build_url(self): 1011 - """URL aus Einzelteilen zusammenbauen""" 1012 - ip = self.ip_input.text().strip() 1013 - port = self.port_input.text().strip() or "554" 1014 - username = self.username_input.text().strip() or "admin" 1015 - password = self.password_input.text().strip() 1016 - path = self.path_combo.currentText().strip() 1017 - 1018 - if not ip: 1019 - QMessageBox.warning(self, tr("dialog.title.error"), tr("dialog.edit.err_ip")) 1020 - return 1021 - 1022 - url = _build_rtsp_url( 1023 - host=ip, 1024 - port=int(port), 1025 - username=username, 1026 - password=password, 1027 - path=path 1028 - ) 1029 - self.url_input.setText(url) 1030 - 1031 - def get_camera_data(self): 1032 - """Geänderte Daten zurückgeben""" 1033 - self.camera_data['name'] = self.name_input.text().strip() 1034 - self.camera_data['url'] = self.url_input.text().strip() 1035 - self.camera_data['uid'] = self.uid_input.text().strip() 1036 - return self.camera_data 1037 - 1038 - 1039 - class CameraDiscoveryDialog(QDialog): 1040 - """Dialog für Kamera-Suche""" 1041 - def __init__(self, parent=None): 1042 - super().__init__(parent) 1043 - self.setWindowTitle(tr("dialog.discovery.title")) 1044 - self.setModal(True) 1045 - self.resize(700, 500) 1046 - 1047 - self.found_cameras = [] 1048 - self.discovery_thread = None 1049 - 1050 - self.init_ui() 1051 - 1052 - def init_ui(self): 1053 - layout = QVBoxLayout() 1054 - 1055 - # Netzwerk-Konfiguration 1056 - config_group = QGroupBox(tr("dialog.discovery.scan_config")) 1057 - config_layout = QVBoxLayout() 1058 - 1059 - # Netzwerk-Bereich 1060 - network_layout = QHBoxLayout() 1061 - network_layout.addWidget(QLabel(tr("dialog.discovery.network"))) 1062 - self.network_input = QLineEdit() 1063 - self.network_input.setPlaceholderText(tr("dialog.discovery.network_ph")) 1064 - self.network_input.setText(self._get_local_network()) 1065 - network_layout.addWidget(self.network_input) 1066 - config_layout.addLayout(network_layout) 1067 - 1068 - # Zugangsdaten 1069 - auth_layout = QHBoxLayout() 1070 - auth_layout.addWidget(QLabel(tr("dialog.discovery.username"))) 1071 - self.username_input = QLineEdit() 1072 - self.username_input.setText("admin") 1073 - auth_layout.addWidget(self.username_input) 1074 - 1075 - auth_layout.addWidget(QLabel(tr("dialog.discovery.password"))) 1076 - self.password_input = QLineEdit() 1077 - self.password_input.setEchoMode(QLineEdit.EchoMode.Password) 1078 - auth_layout.addWidget(self.password_input) 1079 - config_layout.addLayout(auth_layout) 1080 - 1081 - config_group.setLayout(config_layout) 1082 - layout.addWidget(config_group) 1083 - 1084 - # Scan-Kontrolle 1085 - scan_layout = QHBoxLayout() 1086 - self.scan_btn = QPushButton(tr("dialog.discovery.start")) 1087 - self.scan_btn.clicked.connect(self.start_scan) 1088 - scan_layout.addWidget(self.scan_btn) 1089 - 1090 - self.stop_btn = QPushButton(tr("dialog.discovery.stop")) 1091 - self.stop_btn.clicked.connect(self.stop_scan) 1092 - self.stop_btn.setEnabled(False) 1093 - scan_layout.addWidget(self.stop_btn) 1094 - 1095 - scan_layout.addStretch() 1096 - layout.addLayout(scan_layout) 1097 - 1098 - # Progress Bar 1099 - self.progress_bar = QProgressBar() 1100 - self.progress_bar.setValue(0) 1101 - layout.addWidget(self.progress_bar) 1102 - 1103 - self.status_label = QLabel(tr("dialog.discovery.ready")) 1104 - layout.addWidget(self.status_label) 1105 - 1106 - # Gefundene Kameras Tabelle 1107 - found_group = QGroupBox(tr("dialog.discovery.found")) 1108 - found_layout = QVBoxLayout() 1109 - 1110 - self.camera_table = QTableWidget() 1111 - self.camera_table.setColumnCount(7) 1112 - self.camera_table.setHorizontalHeaderLabels([ 1113 - tr("dialog.discovery.col.select"), 1114 - tr("dialog.discovery.col.ip"), 1115 - tr("dialog.discovery.col.name"), 1116 - tr("dialog.discovery.col.model"), 1117 - tr("dialog.discovery.col.manufacturer"), 1118 - tr("dialog.discovery.col.ports"), 1119 - tr("dialog.discovery.col.uid"), 1120 - ]) 1121 - self.camera_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) 1122 - self.camera_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) 1123 - 1124 - found_layout.addWidget(self.camera_table) 1125 - found_group.setLayout(found_layout) 1126 - layout.addWidget(found_group) 1127 - 1128 - # Dialog Buttons 1129 - button_box = QDialogButtonBox( 1130 - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel 1131 - ) 1132 - button_box.accepted.connect(self.accept) 1133 - button_box.rejected.connect(self.reject) 1134 - layout.addWidget(button_box) 1135 - 1136 - self.setLayout(layout) 1137 - 1138 - def _get_local_network(self): 1139 - """Lokales Netzwerk ermitteln""" 1140 - try: 1141 - # Lokale IP ermitteln 1142 - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 1143 - s.connect(("8.8.8.8", 80)) 1144 - local_ip = s.getsockname()[0] 1145 - s.close() 1146 - 1147 - # Netzwerk-Bereich ableiten (Class C) 1148 - ip_parts = local_ip.split('.') 1149 - network = f"{ip_parts[0]}.{ip_parts[1]}.{ip_parts[2]}.0/24" 1150 - return network 1151 - except: 1152 - return "192.168.1.0/24" 1153 - 1154 - def start_scan(self): 1155 - """Scan starten""" 1156 - network = self.network_input.text().strip() 1157 - username = self.username_input.text().strip() 1158 - password = self.password_input.text() 1159 - 1160 - if not network: 1161 - QMessageBox.warning(self, tr("dialog.title.error"), tr("dialog.discovery.err_network")) 1162 - return 1163 - 1164 - # UI anpassen 1165 - self.scan_btn.setEnabled(False) 1166 - self.stop_btn.setEnabled(True) 1167 - self.camera_table.setRowCount(0) 1168 - self.found_cameras.clear() 1169 - self.progress_bar.setValue(0) 1170 - 1171 - # Discovery Thread starten 1172 - self.discovery_thread = CameraDiscoveryThread(network, username=username, password=password) 1173 - self.discovery_thread.camera_found.connect(self.on_camera_found) 1174 - self.discovery_thread.progress_update.connect(self.on_progress_update) 1175 - self.discovery_thread.scan_complete.connect(self.on_scan_complete) 1176 - self.discovery_thread.start() 1177 - 1178 - def stop_scan(self): 1179 - """Scan stoppen""" 1180 - if self.discovery_thread: 1181 - self.discovery_thread.stop() 1182 - self.discovery_thread.wait() 1183 - 1184 - self.scan_btn.setEnabled(True) 1185 - self.stop_btn.setEnabled(False) 1186 - self.status_label.setText(tr("dialog.discovery.scan_cancelled", count=len(self.found_cameras))) 1187 - 1188 - def on_camera_found(self, camera_info): 1189 - """Kamera zur Tabelle hinzufügen""" 1190 - self.found_cameras.append(camera_info) 1191 - 1192 - row = self.camera_table.rowCount() 1193 - self.camera_table.insertRow(row) 1194 - 1195 - # Checkbox 1196 - checkbox = QCheckBox() 1197 - checkbox.setChecked(True) 1198 - checkbox_widget = QWidget() 1199 - checkbox_layout = QHBoxLayout(checkbox_widget) 1200 - checkbox_layout.addWidget(checkbox) 1201 - checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) 1202 - checkbox_layout.setContentsMargins(0, 0, 0, 0) 1203 - self.camera_table.setCellWidget(row, 0, checkbox_widget) 1204 - 1205 - # Daten 1206 - self.camera_table.setItem(row, 1, QTableWidgetItem(camera_info['ip'])) 1207 - self.camera_table.setItem(row, 2, QTableWidgetItem(camera_info['name'])) 1208 - self.camera_table.setItem(row, 3, QTableWidgetItem(camera_info['model'])) 1209 - self.camera_table.setItem(row, 4, QTableWidgetItem(camera_info['manufacturer'])) 1210 - self.camera_table.setItem(row, 5, QTableWidgetItem(', '.join(map(str, camera_info['ports'])))) 1211 - self.camera_table.setItem(row, 6, QTableWidgetItem(camera_info.get('uid', ''))) 1212 - 1213 - def on_progress_update(self, progress, message): 1214 - """Progress aktualisieren""" 1215 - self.progress_bar.setValue(progress) 1216 - self.status_label.setText(message) 1217 - 1218 - def on_scan_complete(self, count): 1219 - """Scan abgeschlossen""" 1220 - self.scan_btn.setEnabled(True) 1221 - self.stop_btn.setEnabled(False) 1222 - self.progress_bar.setValue(100) 1223 - self.status_label.setText(tr("dialog.discovery.scan_done", count=count)) 1224 - 1225 - def get_selected_cameras(self): 1226 - """Ausgewählte Kameras zurückgeben""" 1227 - selected = [] 1228 - 1229 - for row in range(self.camera_table.rowCount()): 1230 - checkbox_widget = self.camera_table.cellWidget(row, 0) 1231 - checkbox = checkbox_widget.findChild(QCheckBox) 1232 - 1233 - if checkbox and checkbox.isChecked(): 1234 - camera_info = self.found_cameras[row] 1235 - selected.append(camera_info) 1236 - 1237 - return selected 1238 - 1239 - 1240 - class CameraThread(QThread): 1241 - """Thread für einzelne Kamera mit OpenCV - optimiert für parallele Streams""" 1242 - frame_ready = pyqtSignal(np.ndarray, int) 1243 - connection_status = pyqtSignal(bool, int, str) 1244 - 1245 - def __init__(self, camera_id, rtsp_url, uid=""): 1246 - super().__init__() 1247 - self.camera_id = camera_id 1248 - # Normalize URL to ensure explicit port (prevents FFmpeg TCP fallback errors) 1249 - self.rtsp_url = _normalize_rtsp_url(rtsp_url) 1250 - self.uid = uid 1251 - self.running = False 1252 - self.recording = False 1253 - self.video_writer = None 1254 - self._writer_lock = threading.Lock() 1255 - self.cap = None 1256 - self.reconnect_delay = 5 # Mehr Zeit für Akku-Kameras 1257 - self._host, self._port, self._user, self._password = _parse_rtsp_url(rtsp_url) 1258 - self._is_proxy_stream = self._host in ("localhost", "127.0.0.1") and int(self._port or 0) == 8554 1259 - 1260 - # Alternative Pfade (Reolink Fallbacks) 1261 - self._alt_paths = [ 1262 - "h264Preview_01_main", 1263 - "h265Preview_01_main", 1264 - "Preview_01_main", 1265 - "h264Preview_01_sub", 1266 - "Preview_01_sub" 1267 - ] 1268 - 1269 - def run(self): 1270 - """Hauptschleife mit automatischem Retry""" 1271 - self.running = True 1272 - 1273 - while self.running: 1274 - try: 1275 - self._connect_and_stream() 1276 - except Exception as e: 1277 - self.connection_status.emit(False, self.camera_id, tr("error.prefix", error=str(e))) 1278 - if self.running: 1279 - for _ in range(int(self.reconnect_delay * 10)): 1280 - if not self.running: 1281 - break 1282 - self.msleep(100) 1283 - 1284 - self._cleanup() 1285 - 1286 - def _connect_and_stream(self): 1287 - """Verbindung herstellen und streamen""" 1288 - # Best-effort wake attempt for sleeping/battery cameras 1289 - if self._host: 1290 - # Intensiv-Weckphase (für Akku-Kameras wie Argus PT Ultra) 1291 - # Wir wiederholen das Wecken und prüfen die Erreichbarkeit über mind. 10 Sek. 1292 - self.connection_status.emit(False, self.camera_id, tr("camera.preview.waiting")) 1293 - 1294 - wake_ok = False 1295 - for attempt in range(10): # 10 Versuche alle ~1s = ca. 10s total 1296 - if not self.running: break 1297 - 1298 - # 1. UDP Wake Burst 1299 - _udp_reolink_wake(self._host, self.uid) 1300 - 1301 - # 2. Optionaler HTTP Ping 1302 - try: requests.get(f"http://{self._host}:8000/api.cgi?cmd=GetDevInfo", timeout=0.2) 1303 - except: pass 1304 - 1305 - # 3. RTSP Erreichbarkeit prüfen (Port 554) 1306 - for _ in range(3): 1307 - if not self.running: break 1308 - ok, _ = _tcp_probe(self._host, int(self._port or 554), timeout=0.2) 1309 - if ok: 1310 - wake_ok = True 1311 - break 1312 - time.sleep(0.3) 1313 - 1314 - if wake_ok: break 1315 - 1316 - if wake_ok: 1317 - self.connection_status.emit(True, self.camera_id, tr("camera.status.connected")) # Wach! 1318 - time.sleep(1.0) 1319 - else: 1320 - # Auch wenn TCP Probe fehlschlägt, versuchen wir es trotzdem 1321 - # (manchen Kameras antworten nicht auf Port-Checks, aber auf echte RTSP-Anfragen) 1322 - self.connection_status.emit(False, self.camera_id, tr("camera.status.connecting")) 1323 - 1324 - open_timeout_ms = 12000 if self._is_proxy_stream else 3000 1325 - read_timeout_ms = 12000 if self._is_proxy_stream else 3000 1326 - 1327 - # RTSP Stream öffnen (mit Fallback-Pfaden für native Reolink-RTSP-URLs) 1328 - # Use TCP transport to reduce RTP packet loss warnings 1329 - self.cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG, [ 1330 - cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, open_timeout_ms, 1331 - cv2.CAP_PROP_READ_TIMEOUT_MSEC, read_timeout_ms 1332 - ]) 1333 - 1334 - # Falls eine native Kamera nicht öffnet, probieren wir Reolink-typische Varianten. 1335 - # Bei ReolinkProxy-URLs ist der Pfad absichtlich fix (<Name>/mainStream). 1336 - if not self.cap.isOpened() and not self._is_proxy_stream: 1337 - # Parse URL properly to rebuild with alternative paths 1338 - try: 1339 - u = urlparse(self.rtsp_url) 1340 - port = u.port or 554 1341 - 1342 - for path in self._alt_paths: 1343 - # Use _build_rtsp_url to properly encode credentials 1344 - test_url = _build_rtsp_url( 1345 - host=u.hostname, 1346 - port=port, 1347 - username=u.username or '', 1348 - password=u.password or '', 1349 - path=path, 1350 - scheme=u.scheme 1351 - ) 1352 - if test_url == self.rtsp_url: 1353 - continue 1354 - 1355 - self.connection_status.emit(False, self.camera_id, f"Prüfe Pfad: {path}...") 1356 - self.cap = cv2.VideoCapture(test_url, cv2.CAP_FFMPEG, [ 1357 - cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, open_timeout_ms, 1358 - cv2.CAP_PROP_READ_TIMEOUT_MSEC, read_timeout_ms 1359 - ]) 1360 - if self.cap.isOpened(): 1361 - self.rtsp_url = test_url 1362 - break 1363 - except Exception: 1364 - pass 1365 - 1366 - if not self.cap.isOpened(): 1367 - # Diagnostik: Wenn RTSP zu ist, aber Port 8000 offen, ist RTSP wahrscheinlich in der Kamera deaktiviert 1368 - if self._host and not self._is_proxy_stream: 1369 - ok_api, _ = _tcp_probe(self._host, 8000, timeout=0.5) 1370 - if ok_api: 1371 - raise Exception("Kamera antwortet auf API (Port 8000), aber RTSP ist blockiert. Bitte 'RTSP' in den Kamera-Einstellungen (Netzwerk -> Fortgeschritten -> Servereinstellungen) aktivieren!") 1372 - raise Exception(tr("camera.error.stream_unreachable")) 1373 - 1374 - # Optimierungen für geringe Latenz 1375 - self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) 1376 - self.cap.set(cv2.CAP_PROP_FPS, 25) 1377 - 1378 - self.connection_status.emit(True, self.camera_id, tr("camera.status.connected")) 1379 - 1380 - frame_skip = 0 1381 - skip_interval = 1 # Jedes zweite Frame für CPU-Schonung 1382 - 1383 - while self.running: 1384 - ret, frame = self.cap.read() 1385 - 1386 - if not ret: 1387 - raise Exception(tr("camera.error.stream_interrupted")) 1388 - 1389 - # CPU-Schonung: nicht jedes Frame verarbeiten 1390 - frame_skip += 1 1391 - if frame_skip % skip_interval == 0: 1392 - # Frame an UI senden 1393 - self.frame_ready.emit(frame.copy(), self.camera_id) 1394 - 1395 - # Aufzeichnung (alle Frames) 1396 - with self._writer_lock: 1397 - if self.recording and self.video_writer is not None: 1398 - try: 1399 - self.video_writer.write(frame) 1400 - except Exception: 1401 - # Don't crash the streaming thread due to writer issues. 1402 - pass 1403 - 1404 - # CPU-Schonung: Kleine Pause 1405 - self.msleep(33) # ~30 FPS 1406 - 1407 - def _cleanup(self): 1408 - """Ressourcen freigeben""" 1409 - if self.cap: 1410 - self.cap.release() 1411 - self.cap = None 1412 - with self._writer_lock: 1413 - if self.video_writer: 1414 - self.video_writer.release() 1415 - self.video_writer = None 1416 - self.recording = False 1417 - 1418 - def start_recording(self, output_path): 1419 - """Starte Aufzeichnung""" 1420 - if not (self.cap and self.cap.isOpened()): 1421 - return None 1422 - 1423 - with self._writer_lock: 1424 - if self.recording and self.video_writer is not None: 1425 - return None 1426 - 1427 - # Ensure any previous writer is closed before re-opening 1428 - if self.video_writer is not None: 1429 - try: 1430 - self.video_writer.release() 1431 - except Exception: 1432 - pass 1433 - self.video_writer = None 1434 - 1435 - fps = float(self.cap.get(cv2.CAP_PROP_FPS)) 1436 - if not fps or fps <= 0 or fps > 120: 1437 - fps = 25.0 1438 - width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) 1439 - height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) 1440 - if width <= 0 or height <= 0: 1441 - width, height = 640, 480 1442 - 1443 - os.makedirs(output_path, exist_ok=True) 1444 - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 1445 - 1446 - # Some streams behave badly with MPEG4/XVID timestamping (invalid PTS). 1447 - # MJPG-in-AVI is usually more tolerant. 1448 - fourcc = cv2.VideoWriter_fourcc(*'MJPG') 1449 - filename = os.path.join(output_path, f"camera_{self.camera_id}_{timestamp}.avi") 1450 - 1451 - vw = cv2.VideoWriter(filename, fourcc, fps, (width, height)) 1452 - if not vw.isOpened(): 1453 - return None 1454 - 1455 - self.video_writer = vw 1456 - self.recording = True 1457 - return filename 1458 - return None 1459 - 1460 - def stop_recording(self): 1461 - """Stoppe Aufzeichnung""" 1462 - with self._writer_lock: 1463 - self.recording = False 1464 - if self.video_writer: 1465 - try: 1466 - self.video_writer.release() 1467 - except Exception: 1468 - pass 1469 - self.video_writer = None 1470 - 1471 - def request_stop(self): 1472 - """Signal the stream loop to stop. 1473 - 1474 - OpenCV/FFmpeg can abort if VideoCapture is released from a different 1475 - thread while open/read is active. The stream thread owns cleanup. 1476 - """ 1477 - self.running = False 1478 - self.stop_recording() 1479 - 1480 - def stop(self, timeout_ms=2000, force=False): 1481 - """Thread stoppen""" 1482 - self.request_stop() 1483 - if not self.wait(timeout_ms): 1484 - return False 1485 - return True 1486 - 1487 - 1488 - class CameraWidget(QWidget): 1489 - """Widget für einzelne Kamera-Anzeige""" 1490 - clicked = pyqtSignal(int) 1491 - stream_toggled = pyqtSignal(int, bool) 1492 - snapshot_requested = pyqtSignal(int) 1493 - selection_changed = pyqtSignal(int, bool) 1494 - 1495 - def __init__(self, camera_id, camera_name="", is_battery=False): 1496 - super().__init__() 1497 - self.camera_id = camera_id 1498 - self.camera_name = camera_name or tr("camera.default_name.id", id=camera_id) 1499 - self.is_battery = is_battery 1500 - self.recording = False 1501 - self.last_frame_time = datetime.now() 1502 - self.stream_active = False 1503 - self.last_frame = None 1504 - self._drag_start_pos = None 1505 - self._video_drag_start_pos = None 1506 - self._video_dragging = False 1507 - self.is_selected_for_view = False 1508 - 1509 - layout = QVBoxLayout() 1510 - layout.setContentsMargins(2, 2, 2, 2) 1511 - layout.setSpacing(4) 1512 - 1513 - # Checkbox für Multi-Kamera-Auswahl 1514 - checkbox_layout = QHBoxLayout() 1515 - checkbox_layout.setContentsMargins(4, 2, 4, 2) 1516 - self.view_checkbox = QCheckBox("✓") 1517 - self.view_checkbox.setToolTip("Kamera in großer Ansicht anzeigen") 1518 - self.view_checkbox.setStyleSheet(""" 1519 - QCheckBox { 1520 - font-weight: bold; 1521 - font-size: 10px; 1522 - color: #4CAF50; 1523 - } 1524 - QCheckBox::indicator { 1525 - width: 14px; 1526 - height: 14px; 1527 - border: 2px solid #4CAF50; 1528 - border-radius: 2px; 1529 - background-color: #2b2b2b; 1530 - } 1531 - QCheckBox::indicator:checked { 1532 - background-color: #4CAF50; 1533 - border-color: #4CAF50; 1534 - } 1535 - QCheckBox::indicator:hover { 1536 - border-color: #66BB6A; 1537 - } 1538 - """) 1539 - self.view_checkbox.stateChanged.connect(self._on_checkbox_changed) 1540 - checkbox_layout.addWidget(self.view_checkbox) 1541 - checkbox_layout.addStretch() 1542 - layout.addLayout(checkbox_layout) 1543 - 1544 - # Video Label 1545 - self.video_label = QLabel() 1546 - self.video_label.setFixedSize(180, 120) 1547 - border_color = "#ff9800" if is_battery else "#555" 1548 - self.video_label.setStyleSheet(f"border: 2px solid {border_color}; background-color: black;") 1549 - self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 1550 - self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.click_to_start')}") 1551 - self.video_label.setScaledContents(False) 1552 - self.video_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) 1553 - self.video_label.mousePressEvent = self._on_video_mouse_press 1554 - self.video_label.mouseMoveEvent = self._on_video_mouse_move 1555 - self.video_label.mouseReleaseEvent = self._on_video_mouse_release 1556 - 1557 - # Info Label mit FPS und Battery-Indikator 1558 - battery_indicator = f" {tr('battery.indicator')}" if is_battery else "" 1559 - self.info_label = QLabel(f"{self.camera_name}{battery_indicator} - {tr('camera.status.offline')}") 1560 - label_color = "#ff9800" if is_battery else "red" 1561 - self.info_label.setStyleSheet(f"color: {label_color}; font-weight: bold; font-size: 11px;") 1562 - self.info_label.setWordWrap(False) 1563 - self.info_label.setFixedHeight(18) 1564 - 1565 - # Button Layout 1566 - btn_layout = QHBoxLayout() 1567 - btn_layout.setContentsMargins(0, 0, 0, 0) 1568 - btn_layout.setSpacing(4) 1569 - 1570 - icon_size = QSize(18, 18) 1571 - 1572 - # Aufnahme Button 1573 - self.record_btn = QPushButton() 1574 - self.record_btn.setCheckable(True) 1575 - self.record_btn.setEnabled(False) 1576 - self.record_btn.setMaximumWidth(40) 1577 - self.record_btn.setFixedHeight(24) 1578 - self.record_btn.setIcon(load_svg_icon("record.svg")) 1579 - self.record_btn.setIconSize(icon_size) 1580 - self.record_btn.setToolTip(tr("camera.tooltip.record")) 1581 - self.record_btn.clicked.connect(self.toggle_recording) 1582 - 1583 - self.stream_btn = QPushButton() 1584 - self.stream_btn.setCheckable(True) 1585 - self.stream_btn.setMaximumWidth(40) 1586 - self.stream_btn.setFixedHeight(24) 1587 - self.stream_btn.setIcon(load_svg_icon("play.svg")) 1588 - self.stream_btn.setIconSize(icon_size) 1589 - self.stream_btn.setToolTip(tr("camera.tooltip.stream")) 1590 - self.stream_btn.clicked.connect(self.toggle_stream) 1591 - 1592 - self.snapshot_btn = QPushButton() 1593 - self.snapshot_btn.setMaximumWidth(40) 1594 - self.snapshot_btn.setFixedHeight(24) 1595 - self.snapshot_btn.setIcon(load_svg_icon("camera.svg")) 1596 - self.snapshot_btn.setIconSize(icon_size) 1597 - self.snapshot_btn.setToolTip(tr("camera.tooltip.snapshot")) 1598 - self.snapshot_btn.clicked.connect(self._request_snapshot) 1599 - 1600 - # Edit Button 1601 - self.edit_btn = QPushButton() 1602 - self.edit_btn.setMaximumWidth(40) 1603 - self.edit_btn.setFixedHeight(24) 1604 - self.edit_btn.setToolTip(tr("camera.tooltip.edit")) 1605 - self.edit_btn.setIcon(load_svg_icon("pencil.svg")) 1606 - self.edit_btn.setIconSize(icon_size) 1607 - self.edit_btn.setStyleSheet("color: #64b5f6;") 1608 - 1609 - # Entfernen Button 1610 - self.remove_btn = QPushButton() 1611 - self.remove_btn.setMaximumWidth(40) 1612 - self.remove_btn.setFixedHeight(24) 1613 - self.remove_btn.setToolTip(tr("camera.tooltip.remove")) 1614 - self.remove_btn.setIcon(load_svg_icon("trash.svg")) 1615 - self.remove_btn.setIconSize(icon_size) 1616 - self.remove_btn.setStyleSheet("color: #999;") 1617 - 1618 - btn_layout.addWidget(self.stream_btn) 1619 - btn_layout.addWidget(self.record_btn) 1620 - btn_layout.addWidget(self.snapshot_btn) 1621 - btn_layout.addWidget(self.edit_btn) 1622 - btn_layout.addWidget(self.remove_btn) 1623 - btn_layout.addStretch() 1624 - 1625 - layout.addWidget(self.video_label) 1626 - layout.addWidget(self.info_label) 1627 - layout.addLayout(btn_layout) 1628 - 1629 - self.setLayout(layout) 1630 - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 1631 - self.setMinimumWidth(200) 1632 - self.setFixedHeight(120 + 18 + 24 + 16 + 24) 1633 - 1634 - self.set_selected(False) 1635 - 1636 - def mousePressEvent(self, event): 1637 - if event.button() == Qt.MouseButton.LeftButton: 1638 - self._drag_start_pos = event.pos() 1639 - super().mousePressEvent(event) 1640 - 1641 - def mouseMoveEvent(self, event): 1642 - if not (event.buttons() & Qt.MouseButton.LeftButton): 1643 - super().mouseMoveEvent(event) 1644 - return 1645 - 1646 - if self._drag_start_pos is None: 1647 - super().mouseMoveEvent(event) 1648 - return 1649 - 1650 - if (event.pos() - self._drag_start_pos).manhattanLength() < 8: 1651 - super().mouseMoveEvent(event) 1652 - return 1653 - 1654 - mime = QMimeData() 1655 - mime.setData("application/x-wildcam-camera-id", str(self.camera_id).encode("utf-8")) 1656 - drag = QDrag(self) 1657 - drag.setMimeData(mime) 1658 - drag.exec(Qt.DropAction.MoveAction) 1659 - 1660 - self._drag_start_pos = None 1661 - super().mouseMoveEvent(event) 1662 - 1663 - def _on_video_clicked(self, event): 1664 - self.clicked.emit(self.camera_id) 1665 - 1666 - def _on_video_mouse_press(self, event): 1667 - if event.button() == Qt.MouseButton.LeftButton: 1668 - self._video_drag_start_pos = event.pos() 1669 - self._video_dragging = False 1670 - 1671 - def _on_video_mouse_move(self, event): 1672 - if not (event.buttons() & Qt.MouseButton.LeftButton): 1673 - return 1674 - 1675 - if self._video_drag_start_pos is None: 1676 - return 1677 - 1678 - if (event.pos() - self._video_drag_start_pos).manhattanLength() < 8: 1679 - return 1680 - 1681 - if self._video_dragging: 1682 - return 1683 - 1684 - self._video_dragging = True 1685 - mime = QMimeData() 1686 - mime.setData("application/x-wildcam-camera-id", str(self.camera_id).encode("utf-8")) 1687 - drag = QDrag(self) 1688 - drag.setMimeData(mime) 1689 - drag.exec(Qt.DropAction.MoveAction) 1690 - 1691 - def _on_video_mouse_release(self, event): 1692 - if event.button() == Qt.MouseButton.LeftButton: 1693 - if not self._video_dragging: 1694 - self._on_video_clicked(event) 1695 - self._video_drag_start_pos = None 1696 - self._video_dragging = False 1697 - 1698 - def update_frame(self, frame): 1699 - """Frame aktualisieren mit FPS-Berechnung""" 1700 - self.last_frame = frame 1701 - if self.is_selected_for_view: 1702 - return 1703 - 1704 - # FPS berechnen 1705 - now = datetime.now() 1706 - fps = 1.0 / (now - self.last_frame_time).total_seconds() if (now - self.last_frame_time).total_seconds() > 0 else 0 1707 - self.last_frame_time = now 1708 - 1709 - # Resize für Display 1710 - display_w = max(1, self.video_label.width()) 1711 - display_h = max(1, self.video_label.height()) 1712 - frame_resized = cv2.resize(frame, (display_w, display_h)) 1713 - 1714 - # Convert BGR to RGB 1715 - rgb_frame = cv2.cvtColor(frame_resized, cv2.COLOR_BGR2RGB) 1716 - 1717 - # Aufnahme-Indikator 1718 - if self.recording: 1719 - cv2.circle(rgb_frame, (20, 20), 8, (255, 0, 0), -1) 1720 - cv2.putText(rgb_frame, "REC", (35, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 0), 2) 1721 - 1722 - # FPS anzeigen 1723 - cv2.putText(rgb_frame, f"{fps:.1f} FPS", (display_w - 80, 25), 1724 - cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) 1725 - 1726 - # Convert to QImage 1727 - h, w, ch = rgb_frame.shape 1728 - bytes_per_line = ch * w 1729 - qt_image = QImage(rgb_frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) 1730 - 1731 - self.video_label.setPixmap(QPixmap.fromImage(qt_image)) 1732 - 1733 - def update_status(self, connected, message): 1734 - """Status aktualisieren""" 1735 - if connected: 1736 - self.info_label.setText(f"{self.camera_name} - {message}") 1737 - self.info_label.setStyleSheet("color: green; font-weight: bold; font-size: 11px;") 1738 - self.record_btn.setEnabled(True) 1739 - else: 1740 - self.info_label.setText(f"{self.camera_name} - {message}") 1741 - self.info_label.setStyleSheet("color: red; font-weight: bold; font-size: 11px;") 1742 - self.record_btn.setEnabled(False) 1743 - if self.stream_active: 1744 - self.video_label.setText(f"{self.camera_name}\n{message}\n{tr('camera.preview.retrying')}") 1745 - else: 1746 - self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.click_to_start')}") 1747 - 1748 - def toggle_recording(self): 1749 - """Aufnahme umschalten""" 1750 - self.recording = self.record_btn.isChecked() 1751 - if self.recording: 1752 - self.record_btn.setStyleSheet("background-color: #d32f2f; color: white; font-weight: bold;") 1753 - else: 1754 - self.record_btn.setStyleSheet("") 1755 - 1756 - def toggle_stream(self): 1757 - self.stream_active = self.stream_btn.isChecked() 1758 - if self.stream_active: 1759 - self.stream_btn.setIcon(load_svg_icon("stop.svg")) 1760 - else: 1761 - self.stream_btn.setIcon(load_svg_icon("play.svg")) 1762 - self.stream_toggled.emit(self.camera_id, self.stream_active) 1763 - 1764 - def set_stream_active(self, active): 1765 - self.stream_active = active 1766 - self.stream_btn.blockSignals(True) 1767 - self.stream_btn.setChecked(active) 1768 - self.stream_btn.setIcon(load_svg_icon("stop.svg") if active else load_svg_icon("play.svg")) 1769 - self.stream_btn.blockSignals(False) 1770 - if not active: 1771 - self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.click_to_start')}") 1772 - 1773 - def retranslate_ui(self): 1774 - self.record_btn.setToolTip(tr("camera.tooltip.record")) 1775 - self.stream_btn.setToolTip(tr("camera.tooltip.stream")) 1776 - self.snapshot_btn.setToolTip(tr("camera.tooltip.snapshot")) 1777 - self.edit_btn.setToolTip(tr("camera.tooltip.edit")) 1778 - self.remove_btn.setToolTip(tr("camera.tooltip.remove")) 1779 - if not self.stream_active: 1780 - self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.click_to_start')}") 1781 - 1782 - def _request_snapshot(self): 1783 - self.snapshot_requested.emit(self.camera_id) 1784 - 1785 - def _on_checkbox_changed(self, state): 1786 - self.is_selected_for_view = (state == Qt.CheckState.Checked.value) 1787 - if self.is_selected_for_view: 1788 - self.video_label.setPixmap(QPixmap()) 1789 - self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.waiting')}") 1790 - self.selection_changed.emit(self.camera_id, self.is_selected_for_view) 1791 - 1792 - def set_selected(self, selected): 1793 - if selected: 1794 - self.video_label.setStyleSheet("border: 2px solid #4CAF50; background-color: black;") 1795 - else: 1796 - border_color = "#ff9800" if self.is_battery else "#555" 1797 - self.video_label.setStyleSheet(f"border: 2px solid {border_color}; background-color: black;") 1798 - 1799 - 1800 - class PreviewLabel(QLabel): 1801 - clicked = pyqtSignal(int) 1802 - double_clicked = pyqtSignal(int) 1803 - region_selected = pyqtSignal(int, QRect) 1804 - 1805 - def __init__(self, camera_id=None, parent=None): 1806 - super().__init__(parent) 1807 - self.camera_id = camera_id 1808 - self._selection_enabled = False 1809 - self._selection_origin = None 1810 - self._selection_rect = QRect() 1811 - self._frame_display_rect = QRect() 1812 - 1813 - def set_selection_enabled(self, enabled: bool): 1814 - self._selection_enabled = bool(enabled) 1815 - self.setCursor( 1816 - Qt.CursorShape.CrossCursor if self._selection_enabled else Qt.CursorShape.ArrowCursor 1817 - ) 1818 - if not self._selection_enabled: 1819 - self.clear_selection() 1820 - 1821 - def clear_selection(self): 1822 - if not self._selection_rect.isNull(): 1823 - self._selection_rect = QRect() 1824 - self.update() 1825 - self._selection_origin = None 1826 - 1827 - def set_frame_display_rect(self, rect: QRect): 1828 - self._frame_display_rect = QRect(rect) 1829 - 1830 - def clear_frame_display_rect(self): 1831 - self._frame_display_rect = QRect() 1832 - self.clear_selection() 1833 - 1834 - def frame_display_rect(self) -> QRect: 1835 - return QRect(self._frame_display_rect) 1836 - 1837 - def sizeHint(self): 1838 - return QSize(0, 0) 1839 - 1840 - def minimumSizeHint(self): 1841 - return QSize(0, 0) 1842 - 1843 - def mousePressEvent(self, event): 1844 - if ( 1845 - event.button() == Qt.MouseButton.LeftButton 1846 - and self.camera_id is not None 1847 - and self._selection_enabled 1848 - ): 1849 - pos = event.position().toPoint() 1850 - if self._frame_display_rect.contains(pos): 1851 - self._selection_origin = pos 1852 - self._selection_rect = QRect(pos, pos) 1853 - self.update() 1854 - super().mousePressEvent(event) 1855 - 1856 - def mouseMoveEvent(self, event): 1857 - if self._selection_enabled and self._selection_origin is not None: 1858 - pos = event.position().toPoint() 1859 - self._selection_rect = QRect(self._selection_origin, pos).normalized() 1860 - self.update() 1861 - event.accept() 1862 - return 1863 - super().mouseMoveEvent(event) 1864 - 1865 - def mouseReleaseEvent(self, event): 1866 - if event.button() == Qt.MouseButton.LeftButton and self._selection_origin is not None: 1867 - selection_rect = QRect(self._selection_origin, event.position().toPoint()).normalized() 1868 - selection_rect = selection_rect.intersected(self._frame_display_rect) 1869 - self.clear_selection() 1870 - if ( 1871 - self.camera_id is not None 1872 - and selection_rect.width() >= 8 1873 - and selection_rect.height() >= 8 1874 - ): 1875 - self.region_selected.emit(int(self.camera_id), selection_rect) 1876 - event.accept() 1877 - return 1878 - super().mouseReleaseEvent(event) 1879 - 1880 - def mouseDoubleClickEvent(self, event): 1881 - if event.button() == Qt.MouseButton.LeftButton and self.camera_id is not None: 1882 - self.clear_selection() 1883 - self.double_clicked.emit(int(self.camera_id)) 1884 - super().mouseDoubleClickEvent(event) 1885 - 1886 - def paintEvent(self, event): 1887 - super().paintEvent(event) 1888 - if self._selection_rect.isNull(): 1889 - return 1890 - 1891 - painter = QPainter(self) 1892 - painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) 1893 - painter.setPen(QPen(QColor(76, 175, 80), 2)) 1894 - painter.fillRect(self._selection_rect, QColor(76, 175, 80, 45)) 1895 - painter.drawRect(self._selection_rect) 1896 - 1897 - 1898 60 class MainWindow(QMainWindow): 1899 61 """Hauptfenster der Anwendung""" 1900 62 def __init__(self): ··· 1907 69 self.cameras = [] 1908 70 self.camera_threads = {} # Dict für parallele Thread-Verwaltung 1909 71 self.camera_widgets = {} # Dict für Widget-Zugriff 1910 - self.recording_path = os.path.expanduser("~/Videos/Reolink") 1911 - self.snapshot_path = os.path.join(self.recording_path, "snapshots") 72 + self.recording_path = DEFAULT_RECORDING_PATH 73 + self.snapshot_path = snapshot_path_for(self.recording_path) 1912 74 self.cameras_per_row = 3 # Standard: 3 Kameras pro Reihe 1913 75 self.next_camera_id = 1 1914 76 self.selected_camera_id = None ··· 1919 81 self.preview_crop_camera_id = None 1920 82 self.preview_crop_rect = None 1921 83 self._rebuilding_camera_list = False 1922 - self._pending_order_apply = False 1923 84 self._closing = False 1924 85 self._shutdown_started_at = None 1925 86 self._shutdown_dialog = None ··· 2277 438 self.camera_widgets[camera_id] = widget 2278 439 return widget 2279 440 441 + def _remove_camera_list_item(self, camera_id: int): 442 + if not hasattr(self, "camera_list_container"): 443 + return 444 + layout = self.camera_list_container.layout_ref 445 + for index in range(layout.count()): 446 + item = layout.itemAt(index) 447 + widget = item.widget() if item else None 448 + if widget is not None and getattr(widget, "camera_id", None) == camera_id: 449 + layout.takeAt(index) 450 + widget.setParent(None) 451 + return 452 + 2280 453 def _on_record_btn_clicked(self, camera_id: int, widget: CameraWidget, checked: bool): 2281 454 thread = self.camera_threads.get(camera_id) 2282 455 if thread is None: 2283 456 return 2284 457 self.toggle_camera_recording(thread, widget, checked) 458 + 459 + def _allocate_camera_id(self) -> int: 460 + camera_id = self.next_camera_id 461 + self.next_camera_id += 1 462 + return camera_id 463 + 464 + def _add_camera_entry(self, camera_entry: dict): 465 + self.cameras.append(camera_entry) 466 + self._get_or_create_camera_widget(camera_entry) 467 + 468 + def _build_discovered_camera_entry(self, camera_info: dict, username: str, password: str) -> dict: 469 + rtsp_port = 554 if 554 in camera_info['ports'] else ( 470 + 8554 if 8554 in camera_info['ports'] else 554 471 + ) 472 + camera_entry = { 473 + 'id': self._allocate_camera_id(), 474 + 'url': _build_rtsp_url( 475 + host=camera_info['ip'], 476 + port=rtsp_port, 477 + username=username, 478 + password=password, 479 + path="h264Preview_01_main" 480 + ), 481 + 'name': camera_info['name'], 482 + 'uid': camera_info.get('uid', ''), 483 + 'model': camera_info.get('model', ''), 484 + 'manufacturer': camera_info.get('manufacturer', '') 485 + } 486 + normalize_reolinkproxy_camera(camera_entry, username=username, password=password) 487 + return camera_entry 488 + 489 + def _build_manual_camera_entry(self, url: str, name: str, uid: str) -> dict: 490 + camera_id = self._allocate_camera_id() 491 + camera_entry = { 492 + 'id': camera_id, 493 + 'url': url, 494 + 'name': name if name else tr("camera.default_name.id", id=camera_id), 495 + 'uid': uid, 496 + 'model': '' 497 + } 498 + normalize_reolinkproxy_camera(camera_entry) 499 + return camera_entry 500 + 501 + def _normalize_edited_camera_data(self, camera_data: dict, updated_data: dict) -> dict: 502 + normalized = dict(updated_data) 503 + normalized.setdefault('model', camera_data.get('model', '')) 504 + normalized.setdefault('manufacturer', camera_data.get('manufacturer', '')) 505 + normalize_reolinkproxy_camera(normalized) 506 + return normalized 507 + 508 + def _start_camera_thread(self, camera: dict) -> CameraThread: 509 + camera_id = camera['id'] 510 + thread = CameraThread(camera_id, camera['url'], camera.get('uid', '')) 511 + thread.frame_ready.connect(lambda frame, cid=camera_id: self.update_camera_frame(frame, cid)) 512 + thread.connection_status.connect(lambda connected, cid, msg: self.update_camera_status(connected, cid, msg)) 513 + thread.start() 514 + self.camera_threads[camera_id] = thread 515 + if camera_id in self.camera_widgets: 516 + self.camera_widgets[camera_id].set_stream_active(True) 517 + return thread 518 + 519 + def _clear_big_preview_label(self, text: str = ""): 520 + self.big_preview_label.setPixmap(QPixmap()) 521 + self.big_preview_label.clear_frame_display_rect() 522 + if text: 523 + self.big_preview_label.setText(text) 524 + 525 + def _camera_waiting_text(self, camera_name: str) -> str: 526 + return f"{camera_name}\n{tr('camera.preview.waiting')}" 527 + 528 + def _camera_click_to_start_text(self, camera_name: str) -> str: 529 + return f"{camera_name}\n{tr('camera.preview.click_to_start')}" 2285 530 2286 531 def show_discovery_dialog(self): 2287 532 """Kamera-Suche Dialog anzeigen""" ··· 2301 546 battery_cameras = [] 2302 547 2303 548 for camera_info in selected_cameras: 2304 - ip = camera_info['ip'] 2305 549 name = camera_info['name'] 2306 550 model = camera_info.get('model', '') 2307 - manufacturer = camera_info.get('manufacturer', '') 2308 551 2309 552 # Check if battery camera 2310 553 if _is_battery_camera(model, name): 2311 554 battery_cameras.append((name, model)) 2312 - 2313 - # RTSP Port bestimmen 2314 - rtsp_port = 554 if 554 in camera_info['ports'] else ( 2315 - 8554 if 8554 in camera_info['ports'] else 554 2316 - ) 2317 - 2318 - # RTSP URL generieren (Reolink Standard) mit URL-Encoding für Sonderzeichen 2319 - rtsp_url = _build_rtsp_url( 2320 - host=ip, 2321 - port=rtsp_port, 2322 - username=username, 2323 - password=password, 2324 - path="h264Preview_01_main" 2325 - ) 2326 555 2327 - proxy_config = _reolinkproxy_proxy_config( 2328 - rtsp_url=rtsp_url, 2329 - name=name, 2330 - username=username, 2331 - password=password, 2332 - uid=camera_info.get('uid', ''), 2333 - model=model, 2334 - manufacturer=manufacturer 2335 - ) 2336 - 2337 - # Reolink WLAN/Battery: automatisch auf ReolinkProxy umstellen 2338 - rtsp_url = _maybe_use_reolinkproxy( 2339 - rtsp_url=rtsp_url, 2340 - name=name, 2341 - username=username, 2342 - password=password, 2343 - uid=camera_info.get('uid', ''), 2344 - model=model, 2345 - manufacturer=manufacturer 2346 - ) 556 + camera_entry = self._build_discovered_camera_entry(camera_info, username, password) 2347 557 2348 558 # Prüfe ob Kamera bereits existiert 2349 - if any(c['url'] == rtsp_url for c in self.cameras): 559 + if any(c['url'] == camera_entry['url'] for c in self.cameras): 2350 560 continue 2351 - 2352 - # Kamera hinzufügen 2353 - camera_id = self.next_camera_id 2354 - self.next_camera_id += 1 2355 - 2356 - camera_entry = { 2357 - 'id': camera_id, 2358 - 'url': rtsp_url, 2359 - 'name': name, 2360 - 'uid': camera_info.get('uid', ''), 2361 - 'model': model, 2362 - 'manufacturer': manufacturer 2363 - } 2364 - if proxy_config: 2365 - camera_entry['proxy'] = proxy_config 2366 - self.cameras.append(camera_entry) 2367 - 2368 - # Widget erstellen 2369 - is_battery = _is_battery_camera(model, name) 2370 - widget = CameraWidget(camera_id, name, is_battery=is_battery) 2371 - widget.remove_btn.clicked.connect(lambda checked, cid=camera_id: self.remove_camera(cid)) 2372 - widget.edit_btn.clicked.connect(lambda checked, cid=camera_id: self.edit_camera(cid)) 2373 - widget.stream_toggled.connect(self.toggle_camera_stream) 2374 - widget.clicked.connect(self.select_camera) 2375 - widget.snapshot_requested.connect(self.save_camera_snapshot) 2376 - self.camera_widgets[camera_id] = widget 561 + 562 + self._add_camera_entry(camera_entry) 2377 563 2378 564 added_count += 1 2379 565 ··· 2405 591 QMessageBox.warning(self, tr("dialog.title.error"), tr("label.rtsp_url")) 2406 592 return 2407 593 2408 - camera_id = self.next_camera_id 2409 - self.next_camera_id += 1 2410 - 2411 - camera_name = name if name else tr("camera.default_name.id", id=camera_id) 594 + camera_name = name if name else tr("camera.default_name.id", id=self.next_camera_id) 2412 595 2413 596 # Check if battery camera and show warning 2414 597 if _is_battery_camera("", camera_name): ··· 2422 605 if reply == QMessageBox.StandardButton.No: 2423 606 return 2424 607 2425 - proxy_config = _reolinkproxy_proxy_config( 2426 - rtsp_url=url, 2427 - name=camera_name, 2428 - username="", 2429 - password="", 2430 - uid=uid 2431 - ) 2432 - 2433 - # Reolink WLAN/Battery (Port 9000) automatisch auf ReolinkProxy umstellen 2434 - url = _maybe_use_reolinkproxy( 2435 - rtsp_url=url, 2436 - name=camera_name, 2437 - username="", 2438 - password="", 2439 - uid=uid 2440 - ) 2441 - 2442 - # Kamera zur Liste hinzufügen 2443 - camera_entry = { 2444 - 'id': camera_id, 2445 - 'url': url, 2446 - 'name': camera_name, 2447 - 'uid': uid, 2448 - 'model': '' # Model info not available from manual add 2449 - } 2450 - if proxy_config: 2451 - camera_entry['proxy'] = proxy_config 2452 - self.cameras.append(camera_entry) 2453 - 2454 - # Widget erstellen 2455 - is_battery = _is_battery_camera('', camera_name) 2456 - widget = CameraWidget(camera_id, camera_name, is_battery=is_battery) 2457 - widget.remove_btn.clicked.connect(lambda: self.remove_camera(camera_id)) 2458 - widget.edit_btn.clicked.connect(lambda: self.edit_camera(camera_id)) 2459 - widget.clicked.connect(self.select_camera) 2460 - widget.stream_toggled.connect(self.toggle_camera_stream) 2461 - widget.snapshot_requested.connect(self.save_camera_snapshot) 2462 - self.camera_widgets[camera_id] = widget 608 + camera_entry = self._build_manual_camera_entry(url, name, uid) 609 + self._add_camera_entry(camera_entry) 610 + camera_name = camera_entry['name'] 2463 611 2464 612 # Im Grid platzieren 2465 613 self.update_grid_layout() ··· 2496 644 dialog = CameraEditDialog(camera_data, self) 2497 645 2498 646 if dialog.exec() == QDialog.DialogCode.Accepted: 2499 - updated_data = dialog.get_camera_data() 2500 - 2501 - proxy_config = _reolinkproxy_proxy_config( 2502 - rtsp_url=updated_data.get('url', ''), 2503 - name=updated_data.get('name', ''), 2504 - username="", 2505 - password="", 2506 - uid=updated_data.get('uid', ''), 2507 - model=camera_data.get('model', ''), 2508 - manufacturer=camera_data.get('manufacturer', '') 2509 - ) 2510 - 2511 - # Reolink WLAN/Battery (Port 9000) automatisch auf ReolinkProxy umstellen 2512 - updated_data['url'] = _maybe_use_reolinkproxy( 2513 - rtsp_url=updated_data.get('url', ''), 2514 - name=updated_data.get('name', ''), 2515 - username="", 2516 - password="", 2517 - uid=updated_data.get('uid', ''), 2518 - model=camera_data.get('model', ''), 2519 - manufacturer=camera_data.get('manufacturer', '') 2520 - ) 2521 - if proxy_config: 2522 - updated_data['proxy'] = proxy_config 647 + updated_data = self._normalize_edited_camera_data(camera_data, dialog.get_camera_data()) 2523 648 2524 649 # Daten aktualisieren 2525 650 for camera in self.cameras: ··· 2533 658 widget.camera_name = updated_data['name'] 2534 659 widget.info_label.setText(f"{updated_data['name']} - {tr('camera.status.offline')}") 2535 660 if widget.stream_active: 2536 - widget.video_label.setText(f"{updated_data['name']}\n{tr('camera.preview.waiting')}") 661 + widget.video_label.setText(self._camera_waiting_text(updated_data['name'])) 2537 662 else: 2538 - widget.video_label.setText(f"{updated_data['name']}\n{tr('camera.preview.click_to_start')}") 663 + widget.video_label.setText(self._camera_click_to_start_text(updated_data['name'])) 2539 664 2540 665 if self.selected_camera_id == camera_id: 2541 666 if camera_id in self.camera_threads and self.camera_threads[camera_id].isRunning(): 2542 - self.big_preview_label.setText(f"{updated_data['name']}\n{tr('camera.preview.waiting')}") 667 + self.big_preview_label.setText(self._camera_waiting_text(updated_data['name'])) 2543 668 else: 2544 - self.big_preview_label.setText(f"{updated_data['name']}\n{tr('camera.preview.click_to_start')}") 669 + self.big_preview_label.setText(self._camera_click_to_start_text(updated_data['name'])) 2545 670 2546 671 self.save_config() 2547 672 self.statusBar().showMessage(tr("status.camera_updated", name=updated_data['name'])) ··· 2568 693 self.camera_widgets[camera_id].record_btn.setChecked(False) 2569 694 self.camera_widgets[camera_id].toggle_recording() 2570 695 2571 - # Neuen Thread erstellen 2572 - thread = CameraThread(camera_id, camera['url'], camera.get('uid', '')) 2573 - 2574 - # Signals verbinden 2575 - thread.frame_ready.connect(lambda frame, cid=camera_id: self.update_camera_frame(frame, cid)) 2576 - thread.connection_status.connect(lambda connected, cid, msg: self.update_camera_status(connected, cid, msg)) 2577 - 2578 - # Aufnahme-Button verbinden 2579 - if camera_id in self.camera_widgets: 2580 - widget = self.camera_widgets[camera_id] 2581 - widget.record_btn.clicked.connect( 2582 - lambda checked, t=thread, w=widget: self.toggle_camera_recording(t, w, checked) 2583 - ) 2584 - 2585 - # Thread starten 2586 - thread.start() 2587 - self.camera_threads[camera_id] = thread 2588 - if camera_id in self.camera_widgets: 2589 - self.camera_widgets[camera_id].set_stream_active(True) 696 + self._start_camera_thread(camera) 2590 697 self.statusBar().showMessage(tr("status.stream_started", name=camera['name'])) 2591 698 2592 699 def stop_single_stream(self, camera_id): ··· 2609 716 widget.update_status(False, tr("camera.status.stopped")) 2610 717 2611 718 if self.selected_camera_id == camera_id: 2612 - self.big_preview_label.setPixmap(QPixmap()) 2613 - self.big_preview_label.clear_frame_display_rect() 2614 719 widget = self.camera_widgets.get(camera_id) 2615 720 if widget: 2616 - self.big_preview_label.setText(f"{widget.camera_name}\n{tr('camera.preview.click_to_start')}") 721 + self._clear_big_preview_label(self._camera_click_to_start_text(widget.camera_name)) 2617 722 2618 723 def toggle_camera_stream(self, camera_id, enabled): 2619 724 if enabled: ··· 2660 765 self.selected_camera_id = None 2661 766 if self.preview_crop_camera_id == camera_id: 2662 767 self._clear_big_preview_crop() 2663 - self.big_preview_label.setPixmap(QPixmap()) 2664 - self.big_preview_label.clear_frame_display_rect() 2665 - self.big_preview_label.setText(tr("big.select_camera")) 768 + self._clear_big_preview_label(tr("big.select_camera")) 2666 769 2667 770 if camera_id in self.selected_camera_ids: 2668 771 self.selected_camera_ids.remove(camera_id) ··· 2725 828 self.selected_camera_ids = [] 2726 829 self.zoomed_camera_id = None 2727 830 self._clear_big_preview_crop() 2728 - self.big_preview_label.setPixmap(QPixmap()) 2729 - self.big_preview_label.clear_frame_display_rect() 2730 - self.big_preview_label.setText(tr("big.select_camera")) 831 + self._clear_big_preview_label(tr("big.select_camera")) 2731 832 if hasattr(self, "camera_list_container"): 2732 833 layout = self.camera_list_container.layout_ref 2733 834 while layout.count(): ··· 2753 854 if camera_id in self.camera_threads and self.camera_threads[camera_id].isRunning(): 2754 855 continue 2755 856 2756 - # Neuen Thread erstellen 2757 - thread = CameraThread(camera_id, camera['url'], camera.get('uid', '')) 2758 - 2759 - # Signals verbinden 2760 - thread.frame_ready.connect(lambda frame, cid=camera_id: self.update_camera_frame(frame, cid)) 2761 - thread.connection_status.connect(lambda connected, cid, msg: self.update_camera_status(connected, cid, msg)) 2762 - 2763 - # Thread starten (parallel) 2764 - thread.start() 2765 - self.camera_threads[camera_id] = thread 2766 - 2767 - if camera_id in self.camera_widgets: 2768 - self.camera_widgets[camera_id].set_stream_active(True) 857 + self._start_camera_thread(camera) 2769 858 2770 859 self.statusBar().showMessage(tr("status.streams_starting", count=len(self.cameras))) 2771 860 ··· 2806 895 if self.selected_camera_id is not None: 2807 896 widget = self.camera_widgets.get(self.selected_camera_id) 2808 897 if widget: 2809 - self.big_preview_label.setPixmap(QPixmap()) 2810 - self.big_preview_label.clear_frame_display_rect() 2811 - self.big_preview_label.setText(f"{widget.camera_name}\n{tr('camera.preview.click_to_start')}") 898 + self._clear_big_preview_label(self._camera_click_to_start_text(widget.camera_name)) 2812 899 self._update_big_preview_selection_state() 2813 900 2814 901 self.update_status_display() ··· 2891 978 path = QFileDialog.getExistingDirectory(self, tr("dialog.path.choose"), self.recording_path) 2892 979 if path: 2893 980 self.recording_path = path 2894 - self.snapshot_path = os.path.join(self.recording_path, "snapshots") 981 + self.snapshot_path = snapshot_path_for(self.recording_path) 2895 982 os.makedirs(self.snapshot_path, exist_ok=True) 2896 983 self.save_config() 2897 984 self.statusBar().showMessage(tr("status.path", path=path)) ··· 2915 1002 2916 1003 def save_config(self): 2917 1004 """Konfiguration speichern""" 2918 - config = { 2919 - 'cameras': self.cameras, 2920 - 'recording_path': self.recording_path, 2921 - 'cameras_per_row': self.cameras_per_row, 2922 - 'next_camera_id': self.next_camera_id, 2923 - 'language': self.language, 2924 - 'order_custom': self._order_custom, 2925 - 'preview_camera_ids': list(self.selected_camera_ids), 2926 - 'selected_camera_id': self.selected_camera_id, 2927 - } 2928 1005 try: 2929 - with open('camera_config.json', 'w') as f: 2930 - json.dump(config, f, indent=2) 1006 + save_config_data(config_payload(self)) 2931 1007 except Exception as e: 2932 1008 print(f"Fehler beim Speichern: {e}") 2933 1009 2934 1010 def load_config(self): 2935 1011 """Konfiguration laden""" 2936 1012 try: 2937 - with open('camera_config.json', 'r') as f: 2938 - config = json.load(f) 2939 - self.language = config.get('language', self.language) 2940 - set_language(self.language) 2941 - loaded_cameras = config.get('cameras', []) 2942 - fixed_config = False 1013 + config, fixed_config = load_config_data() 1014 + if config is None: 1015 + return 2943 1016 2944 - # Deduplicate IDs: duplicate IDs lead to widget reuse, gaps, and crashes. 2945 - used_ids = set() 2946 - max_id = 0 2947 - for c in loaded_cameras: 2948 - try: 2949 - cid = int(c.get('id')) 2950 - except Exception: 2951 - cid = None 2952 - if cid is not None: 2953 - max_id = max(max_id, cid) 2954 - 2955 - deduped = [] 2956 - for c in loaded_cameras: 2957 - try: 2958 - cid = int(c.get('id')) 2959 - except Exception: 2960 - cid = None 1017 + self.language = config.get('language', self.language) 1018 + set_language(self.language) 1019 + self.cameras = config.get('cameras', []) 1020 + self.recording_path = config.get('recording_path', self.recording_path) 1021 + self.snapshot_path = config.get('snapshot_path', snapshot_path_for(self.recording_path)) 1022 + self.cameras_per_row = config.get('cameras_per_row', 3) 1023 + self._restore_preview_camera_ids = config.get('preview_camera_ids', []) 1024 + self.selected_camera_id = config.get('selected_camera_id') 1025 + self._order_custom = bool(config.get('order_custom', False)) 1026 + self.next_camera_id = config.get('next_camera_id', 1) 2961 1027 2962 - if cid is None: 2963 - max_id += 1 2964 - c['id'] = max_id 2965 - used_ids.add(max_id) 2966 - deduped.append(c) 2967 - fixed_config = True 2968 - continue 1028 + self.grid_cols_spin.setValue(self.cameras_per_row) 1029 + if hasattr(self, "language_combo"): 1030 + idx = self.language_combo.findData(self.language) 1031 + if idx >= 0: 1032 + self.language_combo.blockSignals(True) 1033 + self.language_combo.setCurrentIndex(idx) 1034 + self.language_combo.blockSignals(False) 2969 1035 2970 - if cid in used_ids: 2971 - max_id += 1 2972 - c['id'] = max_id 2973 - used_ids.add(max_id) 2974 - deduped.append(c) 2975 - fixed_config = True 2976 - else: 2977 - used_ids.add(cid) 2978 - deduped.append(c) 1036 + for camera in self.cameras: 1037 + widget = self._get_or_create_camera_widget(camera) 1038 + widget.retranslate_ui() 2979 1039 2980 - # Deduplicate by URL as well (older drag&drop/config corruption could 2981 - # duplicate entire camera entries). 2982 - seen_urls = set() 2983 - deduped_by_url = [] 2984 - for c in deduped: 2985 - url = (c.get('url') or '').strip() 2986 - if not url: 2987 - deduped_by_url.append(c) 2988 - continue 2989 - if url in seen_urls: 2990 - fixed_config = True 2991 - continue 2992 - seen_urls.add(url) 2993 - # UID explizit sicherstellen (falls vorhanden) 2994 - if 'uid' not in c: 2995 - c['uid'] = '' 2996 - fixed_config = True # Sorgen wir dafür, dass es gespeichert wird 2997 - deduped_by_url.append(c) 1040 + self.update_grid_layout() 1041 + self.update_status_display() 1042 + self.retranslate_ui() 1043 + self._restore_preview_state() 2998 1044 2999 - # Reolink WLAN/Baichuan URLs beim Laden automatisch auf ReolinkProxy umstellen 3000 - for c in deduped_by_url: 3001 - url = (c.get('url') or '').strip() 3002 - if not url: 3003 - continue 3004 - proxy_config = _reolinkproxy_proxy_config( 3005 - rtsp_url=url, 3006 - name=c.get('name', ''), 3007 - username="", 3008 - password="", 3009 - uid=c.get('uid', ''), 3010 - model=c.get('model', ''), 3011 - manufacturer=c.get('manufacturer', '') 3012 - ) 3013 - new_url = _maybe_use_reolinkproxy( 3014 - rtsp_url=url, 3015 - name=c.get('name', ''), 3016 - username="", 3017 - password="", 3018 - uid=c.get('uid', ''), 3019 - model=c.get('model', ''), 3020 - manufacturer=c.get('manufacturer', '') 3021 - ) 3022 - if new_url != url: 3023 - c['url'] = new_url 3024 - fixed_config = True 3025 - if proxy_config and not c.get('proxy'): 3026 - c['proxy'] = proxy_config 3027 - fixed_config = True 3028 - 3029 - self.cameras = deduped_by_url 3030 - self.recording_path = config.get('recording_path', self.recording_path) 3031 - self.snapshot_path = os.path.join(self.recording_path, "snapshots") 3032 - self.cameras_per_row = config.get('cameras_per_row', 3) 3033 - valid_camera_ids = {int(c.get('id')) for c in self.cameras if c.get('id') is not None} 3034 - preview_ids = [] 3035 - for cid in config.get('preview_camera_ids', []): 3036 - try: 3037 - cid = int(cid) 3038 - except Exception: 3039 - continue 3040 - if cid in valid_camera_ids and cid not in preview_ids: 3041 - preview_ids.append(cid) 3042 - self._restore_preview_camera_ids = preview_ids 3043 - try: 3044 - selected_camera_id = int(config.get('selected_camera_id')) if config.get('selected_camera_id') is not None else None 3045 - except Exception: 3046 - selected_camera_id = None 3047 - self.selected_camera_id = selected_camera_id if selected_camera_id in valid_camera_ids else None 3048 - self._order_custom = bool(config.get('order_custom', False)) 3049 - if not self._order_custom: 3050 - try: 3051 - self.cameras.sort(key=lambda c: int(c.get('id', 0))) 3052 - except Exception: 3053 - pass 3054 - repaired_next_id = max([c.get('id', 0) for c in self.cameras] + [0]) + 1 3055 - if config.get('next_camera_id') != repaired_next_id: 3056 - fixed_config = True 3057 - self.next_camera_id = repaired_next_id 3058 - 3059 - self.grid_cols_spin.setValue(self.cameras_per_row) 3060 - if hasattr(self, "language_combo"): 3061 - idx = self.language_combo.findData(self.language) 3062 - if idx >= 0: 3063 - self.language_combo.blockSignals(True) 3064 - self.language_combo.setCurrentIndex(idx) 3065 - self.language_combo.blockSignals(False) 3066 - 3067 - # Widgets erstellen 3068 - for camera in self.cameras: 3069 - widget = self._get_or_create_camera_widget(camera) 3070 - widget.retranslate_ui() 3071 - 3072 - self.update_grid_layout() 3073 - self.update_status_display() 3074 - self.retranslate_ui() 3075 - self._restore_preview_state() 3076 - 3077 - if fixed_config: 3078 - self.save_config() 3079 - except FileNotFoundError: 3080 - pass 1045 + if fixed_config: 1046 + self.save_config() 3081 1047 except Exception as e: 3082 1048 print(f"Fehler beim Laden: {e}") 3083 1049 ··· 3313 1279 for cid in self.selected_camera_ids: 3314 1280 if cid not in self.camera_threads or not self.camera_threads[cid].isRunning(): 3315 1281 self.start_single_stream(cid) 1282 + 1283 + def _create_multi_view_close_button(self, camera_id, label): 1284 + close_btn = QPushButton() 1285 + close_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarCloseButton)) 1286 + close_btn.setIconSize(QSize(14, 14)) 1287 + close_btn.setFixedSize(28, 28) 1288 + close_btn.setStyleSheet(""" 1289 + QPushButton { 1290 + background-color: rgba(220, 53, 69, 220); 1291 + border: 2px solid white; 1292 + border-radius: 14px; 1293 + } 1294 + QPushButton:hover { 1295 + background-color: rgba(200, 35, 51, 255); 1296 + border: 2px solid #ffcccc; 1297 + } 1298 + """) 1299 + close_btn.setCursor(Qt.CursorShape.PointingHandCursor) 1300 + close_btn.clicked.connect(lambda checked, cid=camera_id: self._close_multi_view_camera(cid)) 1301 + close_btn.setParent(label) 1302 + close_btn.raise_() 1303 + if not hasattr(self, 'multi_view_close_buttons'): 1304 + self.multi_view_close_buttons = {} 1305 + self.multi_view_close_buttons[camera_id] = close_btn 1306 + label.installEventFilter(self) 1307 + return close_btn 3316 1308 3317 1309 def _rebuild_multi_view_layout(self): 3318 1310 """Rebuild the big preview layout based on selected cameras""" ··· 3360 1352 self.big_preview_label = label 3361 1353 self.multi_view_labels[camera_id] = label 3362 1354 3363 - close_btn = QPushButton() 3364 - close_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarCloseButton)) 3365 - close_btn.setIconSize(QSize(14, 14)) 3366 - close_btn.setFixedSize(28, 28) 3367 - close_btn.setStyleSheet(""" 3368 - QPushButton { 3369 - background-color: rgba(220, 53, 69, 220); 3370 - border: 2px solid white; 3371 - border-radius: 14px; 3372 - } 3373 - QPushButton:hover { 3374 - background-color: rgba(200, 35, 51, 255); 3375 - border: 2px solid #ffcccc; 3376 - } 3377 - """) 3378 - close_btn.setCursor(Qt.CursorShape.PointingHandCursor) 3379 - close_btn.clicked.connect(lambda checked, cid=camera_id: self._close_multi_view_camera(cid)) 3380 - close_btn.setParent(label) 3381 - close_btn.raise_() 3382 - if not hasattr(self, 'multi_view_close_buttons'): 3383 - self.multi_view_close_buttons = {} 3384 - self.multi_view_close_buttons[camera_id] = close_btn 3385 - label.installEventFilter(self) 1355 + self._create_multi_view_close_button(camera_id, label) 3386 1356 else: 3387 1357 # Multi-camera grid view 3388 1358 # Calculate grid: 2 cameras = 1 row x 2 cols, 3-4 = 2 rows, 5-6 = 3 rows, etc. ··· 3392 1362 for row in range(rows): 3393 1363 row_layout = QHBoxLayout() 3394 1364 row_layout.setSpacing(4) 3395 - 3396 - # Calculate how many cameras in this row 3397 - cameras_in_row = min(cols, num_selected - row * cols) 3398 1365 3399 1366 # No left spacer - single camera should be on left side 3400 1367 ··· 3431 1398 if widget: 3432 1399 label.setText(f"{widget.camera_name}\n{tr('camera.preview.waiting')}") 3433 1400 3434 - # Close button overlay (top-right) 3435 - close_btn = QPushButton() 3436 - close_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarCloseButton)) 3437 - close_btn.setIconSize(QSize(14, 14)) 3438 - close_btn.setFixedSize(28, 28) 3439 - close_btn.setStyleSheet(""" 3440 - QPushButton { 3441 - background-color: rgba(220, 53, 69, 220); 3442 - border: 2px solid white; 3443 - border-radius: 14px; 3444 - } 3445 - QPushButton:hover { 3446 - background-color: rgba(200, 35, 51, 255); 3447 - border: 2px solid #ffcccc; 3448 - } 3449 - """) 3450 - close_btn.setCursor(Qt.CursorShape.PointingHandCursor) 3451 - close_btn.clicked.connect(lambda checked, cid=camera_id: self._close_multi_view_camera(cid)) 3452 - 3453 - # Position close button at top-right - will be repositioned on resize 3454 - close_btn.setParent(label) 3455 - close_btn.raise_() 3456 - 3457 - # Store button reference for repositioning 3458 - if not hasattr(self, 'multi_view_close_buttons'): 3459 - self.multi_view_close_buttons = {} 3460 - self.multi_view_close_buttons[camera_id] = close_btn 3461 - 3462 - # Install event filter to reposition button on resize 3463 - label.installEventFilter(self) 1401 + self._create_multi_view_close_button(camera_id, label) 3464 1402 3465 1403 container_layout.addWidget(label) 3466 1404 row_layout.addWidget(container, 1) ··· 3628 1566 and self.preview_crop_rect is None 3629 1567 and self.preview_crop_camera_id is None 3630 1568 ) 1569 + 3631 1570 3632 1571 3633 1572 def main():
+27 -48
reolinkproxy_manager.py
··· 5 5 Generiert reolinkproxy.env ausschliesslich aus camera_config.json. 6 6 """ 7 7 import json 8 - import re 9 8 import sys 10 9 from pathlib import Path 11 - from urllib.parse import urlparse, unquote 10 + 11 + from camera_utils import ( 12 + _parse_rtsp_url, 13 + _reolinkproxy_camera_name, 14 + _reolinkproxy_proxy_config, 15 + _reolinkproxy_rtsp_url, 16 + ) 12 17 13 18 14 19 def parse_rtsp_url(rtsp_url): 15 - try: 16 - u = urlparse(rtsp_url, allow_fragments=False) 17 - if not u.hostname and "#" in rtsp_url: 18 - u = urlparse(rtsp_url.replace("#", "%23"), allow_fragments=False) 19 - if u.hostname: 20 - return { 21 - "host": u.hostname, 22 - "port": u.port or 554, 23 - "username": unquote(u.username or ""), 24 - "password": unquote(u.password or ""), 25 - "scheme": u.scheme, 26 - } 27 - except Exception: 28 - pass 29 - 30 - try: 31 - pattern = r"^[a-z]+://(?:([^:@/]+)(?::([^@/]*))?@)?([^:/]+)(?::(\d+))?" 32 - match = re.match(pattern, rtsp_url, re.IGNORECASE) 33 - if not match: 34 - return None 35 - return { 36 - "host": match.group(3), 37 - "port": int(match.group(4) or 554), 38 - "username": unquote(match.group(1) or ""), 39 - "password": unquote(match.group(2) or ""), 40 - "scheme": "rtsp", 41 - } 42 - except Exception: 20 + host, port, username, password = _parse_rtsp_url(rtsp_url or "") 21 + if not host: 43 22 return None 23 + return { 24 + "host": host, 25 + "port": port, 26 + "username": username or "", 27 + "password": password or "", 28 + "scheme": "rtsp", 29 + } 44 30 45 31 46 32 def camera_name(name): 47 - return (name or "Camera").strip().replace(" ", "_") 33 + return _reolinkproxy_camera_name(name) 48 34 49 35 50 36 def is_proxy_url(rtsp_url): ··· 70 56 71 57 72 58 def make_proxy_from_rtsp(camera): 73 - info = parse_rtsp_url(camera.get("url", "")) 74 - if not info or is_proxy_url(camera.get("url", "")): 75 - return None 76 - 77 - return { 78 - "type": "reolinkproxy", 79 - "host": info["host"], 80 - "port": info["port"], 81 - "username": info["username"], 82 - "password": info["password"], 83 - "stream": "main", 84 - "battery": True, 85 - "pause_on_client": True, 86 - "idle_disconnect": True, 87 - "idle_timeout": "30s", 88 - } 59 + return _reolinkproxy_proxy_config( 60 + rtsp_url=camera.get("url", ""), 61 + name=camera.get("name", ""), 62 + username="", 63 + password="", 64 + uid=camera.get("uid", ""), 65 + model=camera.get("model", ""), 66 + manufacturer=camera.get("manufacturer", ""), 67 + ) 89 68 90 69 91 70 def write_env(cameras, output_path): ··· 172 151 if proxy: 173 152 cam["proxy"] = proxy 174 153 updated = True 175 - new_url = f"rtsp://localhost:8554/{name}/mainStream" 154 + new_url = _reolinkproxy_rtsp_url(name) 176 155 if cam.get("url") != new_url: 177 156 print(f"{cam.get('name', name)}: {cam.get('url')} -> {new_url}") 178 157 cam["url"] = new_url
+302
stream.py
··· 1 + import os 2 + import threading 3 + import time 4 + from datetime import datetime 5 + from urllib.parse import urlparse 6 + 7 + import cv2 8 + import numpy as np 9 + import requests 10 + from PyQt6.QtCore import QThread, pyqtSignal 11 + 12 + from camera_utils import ( 13 + _build_rtsp_url, 14 + _normalize_rtsp_url, 15 + _parse_rtsp_url, 16 + _tcp_probe, 17 + _udp_reolink_wake, 18 + ) 19 + from i18n import tr 20 + 21 + 22 + class CameraThread(QThread): 23 + """Thread für einzelne Kamera mit OpenCV - optimiert für parallele Streams""" 24 + frame_ready = pyqtSignal(np.ndarray, int) 25 + connection_status = pyqtSignal(bool, int, str) 26 + 27 + def __init__(self, camera_id, rtsp_url, uid=""): 28 + super().__init__() 29 + self.camera_id = camera_id 30 + # Normalize URL to ensure explicit port (prevents FFmpeg TCP fallback errors) 31 + self.rtsp_url = _normalize_rtsp_url(rtsp_url) 32 + self.uid = uid 33 + self.running = False 34 + self.recording = False 35 + self.video_writer = None 36 + self._writer_lock = threading.Lock() 37 + self.cap = None 38 + self.reconnect_delay = 5 # Mehr Zeit für Akku-Kameras 39 + self._host, self._port, self._user, self._password = _parse_rtsp_url(rtsp_url) 40 + self._is_proxy_stream = self._host in ("localhost", "127.0.0.1") and int(self._port or 0) == 8554 41 + 42 + # Alternative Pfade (Reolink Fallbacks) 43 + self._alt_paths = [ 44 + "h264Preview_01_main", 45 + "h265Preview_01_main", 46 + "Preview_01_main", 47 + "h264Preview_01_sub", 48 + "Preview_01_sub" 49 + ] 50 + 51 + def run(self): 52 + """Hauptschleife mit automatischem Retry""" 53 + self.running = True 54 + 55 + while self.running: 56 + try: 57 + self._connect_and_stream() 58 + except Exception as e: 59 + self.connection_status.emit(False, self.camera_id, tr("error.prefix", error=str(e))) 60 + finally: 61 + self._release_capture() 62 + 63 + if self.running: 64 + self.connection_status.emit(False, self.camera_id, tr("camera.preview.retrying")) 65 + for _ in range(int(self.reconnect_delay * 10)): 66 + if not self.running: 67 + break 68 + self.msleep(100) 69 + 70 + self._cleanup() 71 + 72 + def _release_capture(self): 73 + if self.cap: 74 + try: 75 + self.cap.release() 76 + except Exception: 77 + pass 78 + self.cap = None 79 + 80 + def _release_writer(self): 81 + with self._writer_lock: 82 + if self.video_writer: 83 + try: 84 + self.video_writer.release() 85 + except Exception: 86 + pass 87 + self.video_writer = None 88 + self.recording = False 89 + 90 + def _wait_before_reconnect(self, seconds: float): 91 + end_time = time.monotonic() + seconds 92 + while self.running and time.monotonic() < end_time: 93 + self.msleep(100) 94 + 95 + def _open_capture(self, rtsp_url: str, open_timeout_ms: int, read_timeout_ms: int): 96 + self._release_capture() 97 + return cv2.VideoCapture(rtsp_url, cv2.CAP_FFMPEG, [ 98 + cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, open_timeout_ms, 99 + cv2.CAP_PROP_READ_TIMEOUT_MSEC, read_timeout_ms 100 + ]) 101 + 102 + def _connect_and_stream(self): 103 + """Verbindung herstellen und streamen""" 104 + # Best-effort wake attempt for sleeping/battery cameras 105 + if self._host and not self._is_proxy_stream: 106 + # Intensiv-Weckphase (für Akku-Kameras wie Argus PT Ultra) 107 + # Wir wiederholen das Wecken und prüfen die Erreichbarkeit über mind. 10 Sek. 108 + self.connection_status.emit(False, self.camera_id, tr("camera.preview.waiting")) 109 + 110 + wake_ok = False 111 + for attempt in range(10): # 10 Versuche alle ~1s = ca. 10s total 112 + if not self.running: 113 + break 114 + 115 + # 1. UDP Wake Burst 116 + _udp_reolink_wake(self._host, self.uid) 117 + 118 + # 2. Optionaler HTTP Ping 119 + try: 120 + requests.get(f"http://{self._host}:8000/api.cgi?cmd=GetDevInfo", timeout=0.2) 121 + except Exception: 122 + pass 123 + 124 + # 3. RTSP Erreichbarkeit prüfen (Port 554) 125 + for _ in range(3): 126 + if not self.running: 127 + break 128 + ok, _ = _tcp_probe(self._host, int(self._port or 554), timeout=0.2) 129 + if ok: 130 + wake_ok = True 131 + break 132 + time.sleep(0.3) 133 + 134 + if wake_ok: 135 + break 136 + 137 + if wake_ok: 138 + self.connection_status.emit(True, self.camera_id, tr("camera.status.connected")) # Wach! 139 + self._wait_before_reconnect(1.0) 140 + else: 141 + # Auch wenn TCP Probe fehlschlägt, versuchen wir es trotzdem 142 + # (manchen Kameras antworten nicht auf Port-Checks, aber auf echte RTSP-Anfragen) 143 + self.connection_status.emit(False, self.camera_id, tr("camera.status.connecting")) 144 + 145 + open_timeout_ms = 12000 if self._is_proxy_stream else 3000 146 + read_timeout_ms = 5000 if self._is_proxy_stream else 3000 147 + 148 + # RTSP Stream öffnen (mit Fallback-Pfaden für native Reolink-RTSP-URLs) 149 + # Use TCP transport to reduce RTP packet loss warnings 150 + self.cap = self._open_capture(self.rtsp_url, open_timeout_ms, read_timeout_ms) 151 + 152 + # Falls eine native Kamera nicht öffnet, probieren wir Reolink-typische Varianten. 153 + # Bei ReolinkProxy-URLs ist der Pfad absichtlich fix (<Name>/mainStream). 154 + if not self.cap.isOpened() and not self._is_proxy_stream: 155 + # Parse URL properly to rebuild with alternative paths 156 + try: 157 + u = urlparse(self.rtsp_url) 158 + port = u.port or 554 159 + 160 + for path in self._alt_paths: 161 + # Use _build_rtsp_url to properly encode credentials 162 + test_url = _build_rtsp_url( 163 + host=u.hostname, 164 + port=port, 165 + username=u.username or '', 166 + password=u.password or '', 167 + path=path, 168 + scheme=u.scheme 169 + ) 170 + if test_url == self.rtsp_url: 171 + continue 172 + 173 + self.connection_status.emit(False, self.camera_id, f"Prüfe Pfad: {path}...") 174 + self.cap = self._open_capture(test_url, open_timeout_ms, read_timeout_ms) 175 + if self.cap.isOpened(): 176 + self.rtsp_url = test_url 177 + break 178 + except Exception: 179 + pass 180 + 181 + if not self.cap.isOpened(): 182 + # Diagnostik: Wenn RTSP zu ist, aber Port 8000 offen, ist RTSP wahrscheinlich in der Kamera deaktiviert 183 + if self._host and not self._is_proxy_stream: 184 + ok_api, _ = _tcp_probe(self._host, 8000, timeout=0.5) 185 + if ok_api: 186 + raise Exception("Kamera antwortet auf API (Port 8000), aber RTSP ist blockiert. Bitte 'RTSP' in den Kamera-Einstellungen (Netzwerk -> Fortgeschritten -> Servereinstellungen) aktivieren!") 187 + raise Exception(tr("camera.error.stream_unreachable")) 188 + 189 + # Optimierungen für geringe Latenz 190 + self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) 191 + self.cap.set(cv2.CAP_PROP_FPS, 25) 192 + 193 + self.connection_status.emit(True, self.camera_id, tr("camera.status.connected")) 194 + 195 + frame_skip = 0 196 + skip_interval = 1 # Jedes zweite Frame für CPU-Schonung 197 + failed_reads = 0 198 + max_failed_reads = 3 199 + 200 + while self.running: 201 + ret, frame = self.cap.read() 202 + 203 + if not ret: 204 + failed_reads += 1 205 + if failed_reads >= max_failed_reads: 206 + raise Exception(tr("camera.error.stream_interrupted")) 207 + self.msleep(200) 208 + continue 209 + 210 + failed_reads = 0 211 + 212 + # CPU-Schonung: nicht jedes Frame verarbeiten 213 + frame_skip += 1 214 + if frame_skip % skip_interval == 0: 215 + # Frame an UI senden 216 + self.frame_ready.emit(frame.copy(), self.camera_id) 217 + 218 + # Aufzeichnung (alle Frames) 219 + with self._writer_lock: 220 + if self.recording and self.video_writer is not None: 221 + try: 222 + self.video_writer.write(frame) 223 + except Exception: 224 + # Don't crash the streaming thread due to writer issues. 225 + pass 226 + 227 + # CPU-Schonung: Kleine Pause 228 + self.msleep(33) # ~30 FPS 229 + 230 + def _cleanup(self): 231 + """Ressourcen freigeben""" 232 + self._release_capture() 233 + self._release_writer() 234 + 235 + def start_recording(self, output_path): 236 + """Starte Aufzeichnung""" 237 + if not (self.cap and self.cap.isOpened()): 238 + return None 239 + 240 + with self._writer_lock: 241 + if self.recording and self.video_writer is not None: 242 + return None 243 + 244 + # Ensure any previous writer is closed before re-opening 245 + if self.video_writer is not None: 246 + try: 247 + self.video_writer.release() 248 + except Exception: 249 + pass 250 + self.video_writer = None 251 + 252 + fps = float(self.cap.get(cv2.CAP_PROP_FPS)) 253 + if not fps or fps <= 0 or fps > 120: 254 + fps = 25.0 255 + width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) 256 + height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) 257 + if width <= 0 or height <= 0: 258 + width, height = 640, 480 259 + 260 + os.makedirs(output_path, exist_ok=True) 261 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 262 + 263 + # Some streams behave badly with MPEG4/XVID timestamping (invalid PTS). 264 + # MJPG-in-AVI is usually more tolerant. 265 + fourcc = cv2.VideoWriter_fourcc(*'MJPG') 266 + filename = os.path.join(output_path, f"camera_{self.camera_id}_{timestamp}.avi") 267 + 268 + vw = cv2.VideoWriter(filename, fourcc, fps, (width, height)) 269 + if not vw.isOpened(): 270 + return None 271 + 272 + self.video_writer = vw 273 + self.recording = True 274 + return filename 275 + return None 276 + 277 + def stop_recording(self): 278 + """Stoppe Aufzeichnung""" 279 + with self._writer_lock: 280 + self.recording = False 281 + if self.video_writer: 282 + try: 283 + self.video_writer.release() 284 + except Exception: 285 + pass 286 + self.video_writer = None 287 + 288 + def request_stop(self): 289 + """Signal the stream loop to stop. 290 + 291 + OpenCV/FFmpeg can abort if VideoCapture is released from a different 292 + thread while open/read is active. The stream thread owns cleanup. 293 + """ 294 + self.running = False 295 + self.stop_recording() 296 + 297 + def stop(self, timeout_ms=2000): 298 + """Thread stoppen""" 299 + self.request_stop() 300 + if not self.wait(timeout_ms): 301 + return False 302 + return True
+16
ui_resources.py
··· 1 + import os 2 + import sys 3 + 4 + from PyQt6.QtGui import QIcon 5 + 6 + 7 + def resource_path(relative_path: str) -> str: 8 + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): 9 + base_path = sys._MEIPASS # type: ignore[attr-defined] 10 + else: 11 + base_path = os.path.abspath(os.path.dirname(__file__)) 12 + return os.path.join(base_path, relative_path) 13 + 14 + 15 + def load_svg_icon(name: str) -> QIcon: 16 + return QIcon(resource_path(os.path.join("assets", "icons", name)))
+499
widgets.py
··· 1 + from datetime import datetime 2 + 3 + import cv2 4 + from PyQt6.QtCore import QMimeData, QRect, QSize, Qt, QTimer, pyqtSignal 5 + from PyQt6.QtGui import QColor, QDrag, QImage, QPainter, QPen, QPixmap 6 + from PyQt6.QtWidgets import ( 7 + QCheckBox, 8 + QHBoxLayout, 9 + QLabel, 10 + QPushButton, 11 + QSizePolicy, 12 + QVBoxLayout, 13 + QWidget, 14 + ) 15 + 16 + from i18n import tr 17 + from ui_resources import load_svg_icon 18 + 19 + 20 + class CameraListContainer(QWidget): 21 + order_changed = pyqtSignal(object) # list[int] 22 + 23 + def __init__(self, parent=None): 24 + super().__init__(parent) 25 + self.setAcceptDrops(True) 26 + self._layout = QVBoxLayout(self) 27 + self._layout.setSpacing(8) 28 + self._layout.setAlignment(Qt.AlignmentFlag.AlignTop) 29 + self._layout.setContentsMargins(0, 0, 0, 0) 30 + 31 + @property 32 + def layout_ref(self) -> QVBoxLayout: 33 + return self._layout 34 + 35 + def dragEnterEvent(self, event): 36 + if event.mimeData().hasFormat("application/x-wildcam-camera-id"): 37 + event.acceptProposedAction() 38 + else: 39 + super().dragEnterEvent(event) 40 + 41 + def dragMoveEvent(self, event): 42 + if event.mimeData().hasFormat("application/x-wildcam-camera-id"): 43 + event.acceptProposedAction() 44 + else: 45 + super().dragMoveEvent(event) 46 + 47 + def dropEvent(self, event): 48 + if not event.mimeData().hasFormat("application/x-wildcam-camera-id"): 49 + super().dropEvent(event) 50 + return 51 + 52 + data = bytes(event.mimeData().data("application/x-wildcam-camera-id")).decode("utf-8", "ignore") 53 + try: 54 + dragged_id = int(data) 55 + except Exception: 56 + event.ignore() 57 + return 58 + 59 + ordered_ids = [] 60 + for i in range(self._layout.count()): 61 + item = self._layout.itemAt(i) 62 + w = item.widget() if item else None 63 + if w is not None and hasattr(w, "camera_id"): 64 + ordered_ids.append(int(getattr(w, "camera_id"))) 65 + 66 + if dragged_id not in ordered_ids: 67 + event.ignore() 68 + return 69 + 70 + drop_y = event.position().y() if hasattr(event, "position") else event.pos().y() 71 + insert_index = len(ordered_ids) 72 + for idx in range(self._layout.count()): 73 + item = self._layout.itemAt(idx) 74 + w = item.widget() if item else None 75 + if w is None: 76 + continue 77 + mid = w.y() + (w.height() / 2) 78 + if drop_y < mid: 79 + insert_index = idx 80 + break 81 + 82 + ordered_ids.remove(dragged_id) 83 + if insert_index > len(ordered_ids): 84 + insert_index = len(ordered_ids) 85 + ordered_ids.insert(insert_index, dragged_id) 86 + 87 + event.acceptProposedAction() 88 + QTimer.singleShot(0, lambda: self.order_changed.emit(ordered_ids)) 89 + 90 + 91 + class CameraWidget(QWidget): 92 + """Widget für einzelne Kamera-Anzeige""" 93 + clicked = pyqtSignal(int) 94 + stream_toggled = pyqtSignal(int, bool) 95 + snapshot_requested = pyqtSignal(int) 96 + selection_changed = pyqtSignal(int, bool) 97 + 98 + def __init__(self, camera_id, camera_name="", is_battery=False): 99 + super().__init__() 100 + self.camera_id = camera_id 101 + self.camera_name = camera_name or tr("camera.default_name.id", id=camera_id) 102 + self.is_battery = is_battery 103 + self.recording = False 104 + self.last_frame_time = datetime.now() 105 + self.stream_active = False 106 + self.last_frame = None 107 + self._drag_start_pos = None 108 + self._video_drag_start_pos = None 109 + self._video_dragging = False 110 + self.is_selected_for_view = False 111 + 112 + layout = QVBoxLayout() 113 + layout.setContentsMargins(2, 2, 2, 2) 114 + layout.setSpacing(4) 115 + 116 + # Checkbox für Multi-Kamera-Auswahl 117 + checkbox_layout = QHBoxLayout() 118 + checkbox_layout.setContentsMargins(4, 2, 4, 2) 119 + self.view_checkbox = QCheckBox("✓") 120 + self.view_checkbox.setToolTip("Kamera in großer Ansicht anzeigen") 121 + self.view_checkbox.setStyleSheet(""" 122 + QCheckBox { 123 + font-weight: bold; 124 + font-size: 10px; 125 + color: #4CAF50; 126 + } 127 + QCheckBox::indicator { 128 + width: 14px; 129 + height: 14px; 130 + border: 2px solid #4CAF50; 131 + border-radius: 2px; 132 + background-color: #2b2b2b; 133 + } 134 + QCheckBox::indicator:checked { 135 + background-color: #4CAF50; 136 + border-color: #4CAF50; 137 + } 138 + QCheckBox::indicator:hover { 139 + border-color: #66BB6A; 140 + } 141 + """) 142 + self.view_checkbox.stateChanged.connect(self._on_checkbox_changed) 143 + checkbox_layout.addWidget(self.view_checkbox) 144 + checkbox_layout.addStretch() 145 + layout.addLayout(checkbox_layout) 146 + 147 + # Video Label 148 + self.video_label = QLabel() 149 + self.video_label.setFixedSize(180, 120) 150 + border_color = "#ff9800" if is_battery else "#555" 151 + self.video_label.setStyleSheet(f"border: 2px solid {border_color}; background-color: black;") 152 + self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 153 + self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.click_to_start')}") 154 + self.video_label.setScaledContents(False) 155 + self.video_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) 156 + self.video_label.mousePressEvent = self._on_video_mouse_press 157 + self.video_label.mouseMoveEvent = self._on_video_mouse_move 158 + self.video_label.mouseReleaseEvent = self._on_video_mouse_release 159 + 160 + # Info Label mit FPS und Battery-Indikator 161 + battery_indicator = f" {tr('battery.indicator')}" if is_battery else "" 162 + self.info_label = QLabel(f"{self.camera_name}{battery_indicator} - {tr('camera.status.offline')}") 163 + label_color = "#ff9800" if is_battery else "red" 164 + self.info_label.setStyleSheet(f"color: {label_color}; font-weight: bold; font-size: 11px;") 165 + self.info_label.setWordWrap(False) 166 + self.info_label.setFixedHeight(18) 167 + 168 + # Button Layout 169 + btn_layout = QHBoxLayout() 170 + btn_layout.setContentsMargins(0, 0, 0, 0) 171 + btn_layout.setSpacing(4) 172 + 173 + icon_size = QSize(18, 18) 174 + 175 + # Aufnahme Button 176 + self.record_btn = QPushButton() 177 + self.record_btn.setCheckable(True) 178 + self.record_btn.setEnabled(False) 179 + self.record_btn.setMaximumWidth(40) 180 + self.record_btn.setFixedHeight(24) 181 + self.record_btn.setIcon(load_svg_icon("record.svg")) 182 + self.record_btn.setIconSize(icon_size) 183 + self.record_btn.setToolTip(tr("camera.tooltip.record")) 184 + self.record_btn.clicked.connect(self.toggle_recording) 185 + 186 + self.stream_btn = QPushButton() 187 + self.stream_btn.setCheckable(True) 188 + self.stream_btn.setMaximumWidth(40) 189 + self.stream_btn.setFixedHeight(24) 190 + self.stream_btn.setIcon(load_svg_icon("play.svg")) 191 + self.stream_btn.setIconSize(icon_size) 192 + self.stream_btn.setToolTip(tr("camera.tooltip.stream")) 193 + self.stream_btn.clicked.connect(self.toggle_stream) 194 + 195 + self.snapshot_btn = QPushButton() 196 + self.snapshot_btn.setMaximumWidth(40) 197 + self.snapshot_btn.setFixedHeight(24) 198 + self.snapshot_btn.setIcon(load_svg_icon("camera.svg")) 199 + self.snapshot_btn.setIconSize(icon_size) 200 + self.snapshot_btn.setToolTip(tr("camera.tooltip.snapshot")) 201 + self.snapshot_btn.clicked.connect(self._request_snapshot) 202 + 203 + # Edit Button 204 + self.edit_btn = QPushButton() 205 + self.edit_btn.setMaximumWidth(40) 206 + self.edit_btn.setFixedHeight(24) 207 + self.edit_btn.setToolTip(tr("camera.tooltip.edit")) 208 + self.edit_btn.setIcon(load_svg_icon("pencil.svg")) 209 + self.edit_btn.setIconSize(icon_size) 210 + self.edit_btn.setStyleSheet("color: #64b5f6;") 211 + 212 + # Entfernen Button 213 + self.remove_btn = QPushButton() 214 + self.remove_btn.setMaximumWidth(40) 215 + self.remove_btn.setFixedHeight(24) 216 + self.remove_btn.setToolTip(tr("camera.tooltip.remove")) 217 + self.remove_btn.setIcon(load_svg_icon("trash.svg")) 218 + self.remove_btn.setIconSize(icon_size) 219 + self.remove_btn.setStyleSheet("color: #999;") 220 + 221 + btn_layout.addWidget(self.stream_btn) 222 + btn_layout.addWidget(self.record_btn) 223 + btn_layout.addWidget(self.snapshot_btn) 224 + btn_layout.addWidget(self.edit_btn) 225 + btn_layout.addWidget(self.remove_btn) 226 + btn_layout.addStretch() 227 + 228 + layout.addWidget(self.video_label) 229 + layout.addWidget(self.info_label) 230 + layout.addLayout(btn_layout) 231 + 232 + self.setLayout(layout) 233 + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 234 + self.setMinimumWidth(200) 235 + self.setFixedHeight(120 + 18 + 24 + 16 + 24) 236 + 237 + self.set_selected(False) 238 + 239 + def mousePressEvent(self, event): 240 + if event.button() == Qt.MouseButton.LeftButton: 241 + self._drag_start_pos = event.pos() 242 + super().mousePressEvent(event) 243 + 244 + def mouseMoveEvent(self, event): 245 + if not (event.buttons() & Qt.MouseButton.LeftButton): 246 + super().mouseMoveEvent(event) 247 + return 248 + 249 + if self._drag_start_pos is None: 250 + super().mouseMoveEvent(event) 251 + return 252 + 253 + if (event.pos() - self._drag_start_pos).manhattanLength() < 8: 254 + super().mouseMoveEvent(event) 255 + return 256 + 257 + mime = QMimeData() 258 + mime.setData("application/x-wildcam-camera-id", str(self.camera_id).encode("utf-8")) 259 + drag = QDrag(self) 260 + drag.setMimeData(mime) 261 + drag.exec(Qt.DropAction.MoveAction) 262 + 263 + self._drag_start_pos = None 264 + super().mouseMoveEvent(event) 265 + 266 + def _on_video_clicked(self, event): 267 + self.clicked.emit(self.camera_id) 268 + 269 + def _on_video_mouse_press(self, event): 270 + if event.button() == Qt.MouseButton.LeftButton: 271 + self._video_drag_start_pos = event.pos() 272 + self._video_dragging = False 273 + 274 + def _on_video_mouse_move(self, event): 275 + if not (event.buttons() & Qt.MouseButton.LeftButton): 276 + return 277 + 278 + if self._video_drag_start_pos is None: 279 + return 280 + 281 + if (event.pos() - self._video_drag_start_pos).manhattanLength() < 8: 282 + return 283 + 284 + if self._video_dragging: 285 + return 286 + 287 + self._video_dragging = True 288 + mime = QMimeData() 289 + mime.setData("application/x-wildcam-camera-id", str(self.camera_id).encode("utf-8")) 290 + drag = QDrag(self) 291 + drag.setMimeData(mime) 292 + drag.exec(Qt.DropAction.MoveAction) 293 + 294 + def _on_video_mouse_release(self, event): 295 + if event.button() == Qt.MouseButton.LeftButton: 296 + if not self._video_dragging: 297 + self._on_video_clicked(event) 298 + self._video_drag_start_pos = None 299 + self._video_dragging = False 300 + 301 + def update_frame(self, frame): 302 + """Frame aktualisieren mit FPS-Berechnung""" 303 + self.last_frame = frame 304 + if self.is_selected_for_view: 305 + return 306 + 307 + # FPS berechnen 308 + now = datetime.now() 309 + fps = 1.0 / (now - self.last_frame_time).total_seconds() if (now - self.last_frame_time).total_seconds() > 0 else 0 310 + self.last_frame_time = now 311 + 312 + # Resize für Display 313 + display_w = max(1, self.video_label.width()) 314 + display_h = max(1, self.video_label.height()) 315 + frame_resized = cv2.resize(frame, (display_w, display_h)) 316 + 317 + # Convert BGR to RGB 318 + rgb_frame = cv2.cvtColor(frame_resized, cv2.COLOR_BGR2RGB) 319 + 320 + # Aufnahme-Indikator 321 + if self.recording: 322 + cv2.circle(rgb_frame, (20, 20), 8, (255, 0, 0), -1) 323 + cv2.putText(rgb_frame, "REC", (35, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 0), 2) 324 + 325 + # FPS anzeigen 326 + cv2.putText(rgb_frame, f"{fps:.1f} FPS", (display_w - 80, 25), 327 + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) 328 + 329 + # Convert to QImage 330 + h, w, ch = rgb_frame.shape 331 + bytes_per_line = ch * w 332 + qt_image = QImage(rgb_frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) 333 + 334 + self.video_label.setPixmap(QPixmap.fromImage(qt_image)) 335 + 336 + def update_status(self, connected, message): 337 + """Status aktualisieren""" 338 + if connected: 339 + self.info_label.setText(f"{self.camera_name} - {message}") 340 + self.info_label.setStyleSheet("color: green; font-weight: bold; font-size: 11px;") 341 + self.record_btn.setEnabled(True) 342 + else: 343 + self.info_label.setText(f"{self.camera_name} - {message}") 344 + self.info_label.setStyleSheet("color: red; font-weight: bold; font-size: 11px;") 345 + self.record_btn.setEnabled(False) 346 + if self.stream_active: 347 + self.video_label.setText(f"{self.camera_name}\n{message}\n{tr('camera.preview.retrying')}") 348 + else: 349 + self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.click_to_start')}") 350 + 351 + def toggle_recording(self): 352 + """Aufnahme umschalten""" 353 + self.recording = self.record_btn.isChecked() 354 + if self.recording: 355 + self.record_btn.setStyleSheet("background-color: #d32f2f; color: white; font-weight: bold;") 356 + else: 357 + self.record_btn.setStyleSheet("") 358 + 359 + def toggle_stream(self): 360 + self.stream_active = self.stream_btn.isChecked() 361 + if self.stream_active: 362 + self.stream_btn.setIcon(load_svg_icon("stop.svg")) 363 + else: 364 + self.stream_btn.setIcon(load_svg_icon("play.svg")) 365 + self.stream_toggled.emit(self.camera_id, self.stream_active) 366 + 367 + def set_stream_active(self, active): 368 + self.stream_active = active 369 + self.stream_btn.blockSignals(True) 370 + self.stream_btn.setChecked(active) 371 + self.stream_btn.setIcon(load_svg_icon("stop.svg") if active else load_svg_icon("play.svg")) 372 + self.stream_btn.blockSignals(False) 373 + if not active: 374 + self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.click_to_start')}") 375 + 376 + def retranslate_ui(self): 377 + self.record_btn.setToolTip(tr("camera.tooltip.record")) 378 + self.stream_btn.setToolTip(tr("camera.tooltip.stream")) 379 + self.snapshot_btn.setToolTip(tr("camera.tooltip.snapshot")) 380 + self.edit_btn.setToolTip(tr("camera.tooltip.edit")) 381 + self.remove_btn.setToolTip(tr("camera.tooltip.remove")) 382 + if not self.stream_active: 383 + self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.click_to_start')}") 384 + 385 + def _request_snapshot(self): 386 + self.snapshot_requested.emit(self.camera_id) 387 + 388 + def _on_checkbox_changed(self, state): 389 + self.is_selected_for_view = (state == Qt.CheckState.Checked.value) 390 + if self.is_selected_for_view: 391 + self.video_label.setPixmap(QPixmap()) 392 + self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.waiting')}") 393 + self.selection_changed.emit(self.camera_id, self.is_selected_for_view) 394 + 395 + def set_selected(self, selected): 396 + if selected: 397 + self.video_label.setStyleSheet("border: 2px solid #4CAF50; background-color: black;") 398 + else: 399 + border_color = "#ff9800" if self.is_battery else "#555" 400 + self.video_label.setStyleSheet(f"border: 2px solid {border_color}; background-color: black;") 401 + 402 + 403 + class PreviewLabel(QLabel): 404 + clicked = pyqtSignal(int) 405 + double_clicked = pyqtSignal(int) 406 + region_selected = pyqtSignal(int, QRect) 407 + 408 + def __init__(self, camera_id=None, parent=None): 409 + super().__init__(parent) 410 + self.camera_id = camera_id 411 + self._selection_enabled = False 412 + self._selection_origin = None 413 + self._selection_rect = QRect() 414 + self._frame_display_rect = QRect() 415 + 416 + def set_selection_enabled(self, enabled: bool): 417 + self._selection_enabled = bool(enabled) 418 + self.setCursor( 419 + Qt.CursorShape.CrossCursor if self._selection_enabled else Qt.CursorShape.ArrowCursor 420 + ) 421 + if not self._selection_enabled: 422 + self.clear_selection() 423 + 424 + def clear_selection(self): 425 + if not self._selection_rect.isNull(): 426 + self._selection_rect = QRect() 427 + self.update() 428 + self._selection_origin = None 429 + 430 + def set_frame_display_rect(self, rect: QRect): 431 + self._frame_display_rect = QRect(rect) 432 + 433 + def clear_frame_display_rect(self): 434 + self._frame_display_rect = QRect() 435 + self.clear_selection() 436 + 437 + def frame_display_rect(self) -> QRect: 438 + return QRect(self._frame_display_rect) 439 + 440 + def sizeHint(self): 441 + return QSize(0, 0) 442 + 443 + def minimumSizeHint(self): 444 + return QSize(0, 0) 445 + 446 + def mousePressEvent(self, event): 447 + if ( 448 + event.button() == Qt.MouseButton.LeftButton 449 + and self.camera_id is not None 450 + and self._selection_enabled 451 + ): 452 + pos = event.position().toPoint() 453 + if self._frame_display_rect.contains(pos): 454 + self._selection_origin = pos 455 + self._selection_rect = QRect(pos, pos) 456 + self.update() 457 + super().mousePressEvent(event) 458 + 459 + def mouseMoveEvent(self, event): 460 + if self._selection_enabled and self._selection_origin is not None: 461 + pos = event.position().toPoint() 462 + self._selection_rect = QRect(self._selection_origin, pos).normalized() 463 + self.update() 464 + event.accept() 465 + return 466 + super().mouseMoveEvent(event) 467 + 468 + def mouseReleaseEvent(self, event): 469 + if event.button() == Qt.MouseButton.LeftButton and self._selection_origin is not None: 470 + selection_rect = QRect(self._selection_origin, event.position().toPoint()).normalized() 471 + selection_rect = selection_rect.intersected(self._frame_display_rect) 472 + self.clear_selection() 473 + if ( 474 + self.camera_id is not None 475 + and selection_rect.width() >= 8 476 + and selection_rect.height() >= 8 477 + ): 478 + self.region_selected.emit(int(self.camera_id), selection_rect) 479 + event.accept() 480 + return 481 + super().mouseReleaseEvent(event) 482 + 483 + def mouseDoubleClickEvent(self, event): 484 + if event.button() == Qt.MouseButton.LeftButton and self.camera_id is not None: 485 + self.clear_selection() 486 + self.double_clicked.emit(int(self.camera_id)) 487 + super().mouseDoubleClickEvent(event) 488 + 489 + def paintEvent(self, event): 490 + super().paintEvent(event) 491 + if self._selection_rect.isNull(): 492 + return 493 + 494 + painter = QPainter(self) 495 + painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) 496 + painter.setPen(QPen(QColor(76, 175, 80), 2)) 497 + painter.fillRect(self._selection_rect, QColor(76, 175, 80, 45)) 498 + painter.drawRect(self._selection_rect) 499 +