About Multi-camera viewer optimized for RTSP streams
0

Configure Feed

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

from neolink to reolinkproxy

+607 -423
+3 -1
.gitignore
··· 1 1 camera_config.json 2 2 camera_config.json.backup 3 - neolink.toml 3 + reolinkproxy.env 4 4 5 5 __pycache__/ 6 6 *.py[cod] ··· 19 19 20 20 .idea/ 21 21 .vscode/ 22 + .codex 23 + .windsurf 22 24 23 25 .DS_Store
+68 -48
README.md
··· 47 47 48 48 - **Native RTSP (port 554)** 49 49 - Works well for mains-powered cameras. 50 - - **Neolink proxy (recommended for Reolink WLAN / battery cameras)** 50 + - **ReolinkProxy (recommended for Reolink WLAN / battery cameras)** 51 51 - Many Reolink WLAN/battery models use the **Baichuan protocol (port 9000)**. 52 52 - WildCam will automatically: 53 - - add/update `neolink.toml` 53 + - add/update `reolinkproxy.env` 54 54 - switch the stored RTSP URL to `rtsp://localhost:8554/<NAME>/mainStream` 55 - - so the app uses the Neolink-proxied stream 55 + - so the app uses the ReolinkProxy-proxied stream 56 56 57 57 #### What gets stored where 58 58 59 59 - **`camera_config.json`** 60 60 - What the app actually uses at runtime. 61 - - After Neolink conversion, URLs look like: 61 + - After ReolinkProxy conversion, URLs look like: 62 62 - `rtsp://localhost:8554/D58/mainStream` 63 - - **`neolink.toml`** 63 + - **`reolinkproxy.env`** 64 64 - Contains the real camera IP/credentials for port 9000 cameras. 65 65 - Example: 66 - - `address = "192.168.8.58:9000"` 66 + - `REOLINK_CAMERA_0_HOST="192.168.8.58"` 67 + - `REOLINK_CAMERA_0_RTSP_PATH="D58/mainStream"` 67 68 68 69 #### Quick start (recommended) 69 70 ··· 73 74 74 75 This will: 75 76 76 - - Generate/extend `neolink.toml` based on `camera_config.json` 77 - - Start Neolink via Docker 77 + - Generate/extend `reolinkproxy.env` based on `camera_config.json` 78 + - Start ReolinkProxy via Docker 78 79 - Start WildCam 79 80 80 81 Important: 81 82 82 - - WildCam manages the Neolink configuration, but it does **not** embed the actual `neolink` runtime. 83 - - For Reolink WLAN / battery cameras you still need a running Neolink instance, typically via `docker compose up -d`. 84 - - For regular RTSP cameras on port 554, Neolink is not required. 83 + - WildCam manages the ReolinkProxy configuration, but it does **not** embed the actual `reolinkproxy` runtime. 84 + - For Reolink WLAN / battery cameras you still need a running ReolinkProxy instance, typically via `docker compose up -d`. 85 + - For regular RTSP cameras on port 554, ReolinkProxy is not required. 85 86 86 87 ## Configuration File (`camera_config.json`) 87 88 ··· 96 97 Manual editing is optional (e.g. to bulk-edit RTSP URLs or names). 97 98 98 99 - The file is **gitignored** because it can contain **credentials** inside RTSP URLs. 100 + - `reolinkproxy.env` is generated from this file and should not be edited as the primary configuration. 99 101 - Use `camera_config.json.example` as a safe template and create your local config from it. 100 102 101 103 ### Create your local config ··· 113 115 - **`id`** must be unique. 114 116 - **`url`** is the RTSP URL. 115 117 - **`name`** is the display name. 118 + - **`proxy`** is optional and contains ReolinkProxy connection settings for battery/WLAN cameras. 116 119 - **`recording_path`** 117 120 - Target directory for recordings. 118 121 - **`next_camera_id`** ··· 130 133 "cameras": [ 131 134 { 132 135 "id": 1, 133 - "url": "rtsp://USER:PASS@192.168.1.100:554/h264Preview_01_main", 134 - "name": "Camera 1" 136 + "url": "rtsp://localhost:8554/D54/mainStream", 137 + "name": "D54", 138 + "uid": "9527000000000000", 139 + "proxy": { 140 + "type": "reolinkproxy", 141 + "host": "192.168.1.100", 142 + "port": 9000, 143 + "username": "admin", 144 + "password": "password", 145 + "stream": "main", 146 + "battery": true, 147 + "pause_on_client": true, 148 + "idle_disconnect": true, 149 + "idle_timeout": "30s" 150 + } 135 151 } 136 152 ], 137 153 "recording_path": "/home/USER/Videos/Reolink", ··· 161 177 162 178 WildCam will automatically: 163 179 164 - - append/update the camera entry in `neolink.toml` 180 + - append/update the camera entry in `reolinkproxy.env` 165 181 - store the camera in `camera_config.json` as: 166 182 - `rtsp://localhost:8554/<NAME>/mainStream` 167 183 ··· 171 187 172 188 When you add found Reolink WLAN/battery cameras, WildCam will automatically: 173 189 174 - - update `neolink.toml` 190 + - update `reolinkproxy.env` 175 191 - store `localhost:8554/...` URLs in `camera_config.json` 176 192 177 - Neolink is a **proxy** and does not magically discover/wake sleeping cameras. The camera still needs to be reachable (awake) for discovery and for Neolink to connect. 193 + ReolinkProxy is a **proxy** and does not magically discover/wake sleeping cameras. The camera still needs to be reachable (awake) for discovery and for ReolinkProxy to connect. 178 194 179 195 Important: 180 196 181 - - WildCam writes and updates `neolink.toml`, but the actual Neolink proxy must run separately. 197 + - WildCam writes and updates `reolinkproxy.env`, but the actual ReolinkProxy must run separately. 182 198 - The recommended setup in this repository is the Docker Compose stack from `docker-compose.yml`. 183 199 184 200 ## Battery Cameras 185 201 186 - ### Automatic Neolink Setup ⭐ 202 + ### Automatic ReolinkProxy Setup 187 203 188 - WildCam includes automatic Neolink setup for battery cameras using port 9000 (Baichuan protocol). 204 + WildCam includes automatic ReolinkProxy setup for battery cameras using port 9000 (Baichuan protocol). 189 205 190 206 This is handled in two places: 191 207 192 - - The **GUI** automatically switches Reolink WLAN/Baichuan cameras to `rtsp://localhost:8554/...` and appends the camera to `neolink.toml`. 193 - - The **startup script** can start the Neolink container for you. 208 + - The **GUI** automatically switches Reolink WLAN/Baichuan cameras to `rtsp://localhost:8554/...` and appends the camera to `reolinkproxy.env`. 209 + - The **startup script** can start the ReolinkProxy container for you. 194 210 195 211 #### Quick Start 196 212 197 213 ```bash 198 - # 1. Start WildCam with automatic Neolink setup 214 + # 1. Start WildCam with automatic ReolinkProxy setup 199 215 ./start_wildcam.sh 200 216 ``` 201 217 202 218 **What it does:** 203 219 - ✅ Detects battery cameras (port 9000) in `camera_config.json` 204 - - ✅ Auto-generates `neolink.toml` configuration 205 - - ✅ Starts Neolink Docker container 220 + - Auto-generates `reolinkproxy.env` configuration 221 + - Starts ReolinkProxy Docker container 206 222 - ✅ The app stores/uses `rtsp://localhost:8554/...` URLs for these cameras 207 223 208 224 #### Manual Setup ··· 210 226 If you prefer manual control: 211 227 212 228 ```bash 213 - # 1. Generate neolink.toml from your camera config 214 - python3 neolink_manager.py 229 + # 1. Generate reolinkproxy.env from your camera config 230 + python3 reolinkproxy_manager.py --auto-update 215 231 216 - # 2. Start Neolink container 232 + # 2. Start ReolinkProxy container 217 233 docker compose up -d 218 234 219 - # 3. Check Neolink logs 220 - docker logs wildcam-neolink 235 + # 3. Check ReolinkProxy logs 236 + docker logs wildcam-reolinkproxy 221 237 222 238 # 4. Start WildCam 223 239 python3 main.py ··· 225 241 226 242 If you distribute WildCam as a standalone build: 227 243 228 - - the bundle can include `docker-compose.yml`, `neolink_manager.py`, `neolink.toml` (if present), and the app itself 229 - - but it still does **not** include the external Neolink container/image 230 - - users who rely on Reolink battery / Baichuan cameras still need Docker/Compose or a separately installed `neolink` 244 + - the bundle can include `docker-compose.yml`, `reolinkproxy_manager.py`, `camera_config.json.example`, and the app itself 245 + - `reolinkproxy.env` is not bundled because it is generated locally and can contain credentials 246 + - but it still does **not** include the external ReolinkProxy container/image 247 + - users who rely on Reolink battery / Baichuan cameras still need Docker/Compose or a separately installed `reolinkproxy` 231 248 232 249 #### How it Works 233 250 ··· 242 259 } 243 260 ``` 244 261 245 - **2. Neolink Config Generation:** 246 - Creates `neolink.toml` automatically: 247 - ```toml 248 - [[cameras]] 249 - name = "ArgusCamera" 250 - username = "admin" 251 - password = "password" 252 - address = "192.168.8.58:9000" 253 - uid = "9527000KVKX2161S" 254 - idle_disconnect = true 262 + **2. ReolinkProxy Config Generation:** 263 + Creates `reolinkproxy.env` automatically: 264 + ```env 265 + REOLINK_CAMERA_0_NAME="ArgusCamera" 266 + REOLINK_CAMERA_0_HOST="192.168.8.58" 267 + REOLINK_CAMERA_0_PORT=9000 268 + REOLINK_CAMERA_0_USERNAME="admin" 269 + REOLINK_CAMERA_0_PASSWORD="password" 270 + REOLINK_CAMERA_0_UID="9527000KVKX2161S" 271 + REOLINK_CAMERA_0_RTSP_PATH="ArgusCamera/mainStream" 272 + REOLINK_CAMERA_0_BATTERY_CAMERA=true 273 + REOLINK_CAMERA_0_PAUSE_ON_CLIENT=true 274 + REOLINK_CAMERA_0_IDLE_DISCONNECT=true 255 275 ``` 256 276 257 277 **3. URL Conversion (Optional):** 258 - Updates camera URLs to use Neolink: 278 + Updates camera URLs to use ReolinkProxy: 259 279 ``` 260 280 Before: rtsp://admin:password@192.168.8.58:9000/... 261 281 After: rtsp://localhost:8554/ArgusCamera/mainStream 262 282 ``` 263 283 264 - #### Stopping Neolink 284 + #### Stopping ReolinkProxy 265 285 266 286 ```bash 267 287 docker compose down ··· 276 296 277 297 The repository contains helper scripts using PyInstaller. 278 298 279 - Note about Neolink: 299 + Note about ReolinkProxy: 280 300 281 - - The packaged app bundles WildCam and helper files such as `docker-compose.yml` and `neolink_manager.py`. 282 - - The actual Neolink runtime is **not** bundled into the app archive. 301 + - The packaged app bundles WildCam and helper files such as `docker-compose.yml` and `reolinkproxy_manager.py`. 302 + - The actual ReolinkProxy runtime is **not** bundled into the app archive. 283 303 - If you use only normal RTSP cameras, the standalone app is enough. 284 - - If you use Reolink WLAN / battery cameras, you must additionally run Neolink externally. 304 + - If you use Reolink WLAN / battery cameras, you must additionally run ReolinkProxy externally. 285 305 286 306 ### Linux 287 307
+4 -3
TODO.md
··· 1 1 # TODO 2 2 3 - ## Neolink integration (Reolink WLAN/Baichuan) 3 + ## ReolinkProxy integration (Reolink WLAN/Baichuan) 4 4 5 5 - [x] Auto-switch Port 9000 / battery cameras to `rtsp://localhost:8554/<NAME>/mainStream` 6 - - [x] Auto-update/extend `neolink.toml` when adding/editing/discovering cameras 6 + - [x] Store proxy settings in `camera_config.json` 7 + - [x] Auto-generate `reolinkproxy.env` from `camera_config.json` 7 8 - [x] Handle `#` in passwords (URL parsing) 8 - - [x] Use `docker compose` (v2) for starting/stopping Neolink 9 + - [x] Use `docker compose` (v2) for starting/stopping ReolinkProxy 9 10 10 11 ## Refactor `main.py` into modules (best practice) 11 12
+14 -2
camera_config.json.example
··· 2 2 "cameras": [ 3 3 { 4 4 "id": 1, 5 - "url": "rtsp://admin:password@192.168.1.100:554/h264Preview_01_main", 5 + "url": "rtsp://localhost:8554/Eingang/mainStream", 6 6 "name": "Eingang", 7 - "uid": "9527000000000000" 7 + "uid": "9527000000000000", 8 + "proxy": { 9 + "type": "reolinkproxy", 10 + "host": "192.168.1.100", 11 + "port": 9000, 12 + "username": "admin", 13 + "password": "password", 14 + "stream": "main", 15 + "battery": true, 16 + "pause_on_client": true, 17 + "idle_disconnect": true, 18 + "idle_timeout": "30s" 19 + } 8 20 } 9 21 ], 10 22 "recording_path": "~/Videos/Reolink",
+6 -7
docker-compose.yml
··· 1 1 services: 2 - neolink: 3 - image: quantumentangledandy/neolink:latest 4 - container_name: wildcam-neolink 2 + reolinkproxy: 3 + image: ghcr.io/shareed2k/reolinkproxy:latest 4 + container_name: wildcam-reolinkproxy 5 5 network_mode: host 6 - command: ["neolink", "rtsp", "--config=/etc/neolink.toml"] 7 - volumes: 8 - - ./neolink.toml:/etc/neolink.toml:ro 9 6 restart: unless-stopped 7 + env_file: 8 + - ./reolinkproxy.env 10 9 environment: 11 - - RUST_LOG=info 10 + - REOLINK_HEALTHCHECK_RTSP_ONLY=true 12 11 logging: 13 12 driver: "json-file" 14 13 options:
+279 -113
main.py
··· 15 15 QMessageBox, QComboBox, QGroupBox, QScrollArea, 16 16 QProgressBar, QDialog, QDialogButtonBox, QTableWidget, 17 17 QTableWidgetItem, QHeaderView, QSizePolicy, QSplitter, 18 - QTabWidget, QStyle) 18 + QTabWidget, QStyle, QProgressDialog) 19 19 from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer, QMimeData, QSize, QEvent, QRect 20 20 from PyQt6.QtGui import QImage, QPixmap, QDrag, QIcon, QPainter, QPen, QColor 21 21 from datetime import datetime ··· 167 167 "• Akku wird bei Dauerstream stark belastet\n" 168 168 "• Stream kann nach 30-60 Sekunden abbrechen\n\n" 169 169 "💡 EMPFEHLUNG:\n" 170 - "Verwende Neolink als Proxy für stabiles Streaming:\n" 171 - "https://github.com/QuantumEntangledAndy/neolink\n\n" 172 - "Siehe README für Neolink-Konfiguration.\n\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 173 "Kamera trotzdem hinzufügen?", 174 174 "battery.indicator": "🔋 AKKU", 175 175 }, ··· 286 286 "• Battery drains quickly with continuous streaming\n" 287 287 "• Stream may disconnect after 30-60 seconds\n\n" 288 288 "💡 RECOMMENDATION:\n" 289 - "Use Neolink as proxy for stable streaming:\n" 290 - "https://github.com/QuantumEntangledAndy/neolink\n\n" 291 - "See README for Neolink configuration.\n\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 292 "Add camera anyway?", 293 293 "battery.indicator": "🔋 BATTERY", 294 294 }, ··· 403 403 return f"{scheme}://{auth}{host}:{port}{path}" 404 404 405 405 406 - def _toml_escape(value: str) -> str: 407 - """Escape TOML string values (" and \\).""" 408 - if value is None: 409 - return "" 410 - return value.replace("\\", "\\\\").replace("\"", "\\\"") 411 - 412 - 413 - def _neolink_camera_name(name: str) -> str: 414 - """Normalize camera name for Neolink stream path.""" 406 + def _reolinkproxy_camera_name(name: str) -> str: 407 + """Normalize camera name for ReolinkProxy stream path.""" 415 408 return (name or "Camera").strip().replace(" ", "_") 416 409 417 410 418 - def _neolink_rtsp_url(name: str, port: int = 8554) -> str: 419 - cam_name = _neolink_camera_name(name) 411 + def _reolinkproxy_rtsp_url(name: str, port: int = 8554) -> str: 412 + cam_name = _reolinkproxy_camera_name(name) 420 413 return f"rtsp://localhost:{port}/{cam_name}/mainStream" 421 414 422 415 423 - def _ensure_neolink_config_entry(name: str, username: str, password: str, host: str, uid: str = "") -> None: 424 - """Ensure Neolink config contains a camera entry.""" 425 - if not host: 426 - return 427 - 428 - config_path = os.path.join(os.path.dirname(__file__), "neolink.toml") 429 - cam_name = _neolink_camera_name(name) 430 - entry_marker = f"name = \"{cam_name}\"" 431 - 432 - header = ( 433 - "# Auto-generated by WildCam\n" 434 - "# This file is automatically created from camera_config.json\n\n" 435 - "bind = \"0.0.0.0\"\n" 436 - "bind_port = 8554\n\n" 437 - ) 438 - 439 - try: 440 - existing = "" 441 - if os.path.exists(config_path): 442 - with open(config_path, "r", encoding="utf-8") as f: 443 - existing = f.read() 444 - if entry_marker in existing: 445 - return 446 - else: 447 - existing = header 448 - 449 - entry = ( 450 - f"\n[[cameras]]\n" 451 - f"name = \"{_toml_escape(cam_name)}\"\n" 452 - f"username = \"{_toml_escape(username)}\"\n" 453 - f"password = \"{_toml_escape(password)}\"\n" 454 - f"address = \"{_toml_escape(host)}:9000\"\n" 455 - ) 456 - if uid: 457 - entry += f"uid = \"{_toml_escape(uid)}\"\n" 458 - 459 - entry += ( 460 - "\n# Battery optimization\n" 461 - "idle_disconnect = true\n\n" 462 - "pause.on_client = true\n" 463 - "pause.timeout = 2.1\n" 464 - ) 465 - 466 - with open(config_path, "w", encoding="utf-8") as f: 467 - f.write(existing + entry) 468 - except Exception as e: 469 - print(f"Neolink config error: {e}") 470 - 471 - 472 - def _maybe_use_neolink(rtsp_url: str, name: str, username: str, password: str, uid: str = "", model: str = "", manufacturer: str = "") -> str: 473 - """Switch to Neolink for Reolink WLAN/Battery cameras and update neolink.toml.""" 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.""" 474 418 host, port, user, pwd = _parse_rtsp_url(rtsp_url) 475 419 if host in ("localhost", "127.0.0.1") and port == 8554: 476 - return rtsp_url 420 + return None 477 421 478 422 is_reolink = ( 479 423 (manufacturer or "").lower() == "reolink" 480 424 or "reolink" in (model or "").lower() 481 425 or port == 9000 482 426 ) 483 - use_neolink = port == 9000 or _is_battery_camera(model, name) 427 + use_reolinkproxy = port == 9000 or _is_battery_camera(model, name) 484 428 485 - if is_reolink and use_neolink and host: 486 - cam_user = username or user or "" 487 - cam_pass = password or pwd or "" 488 - _ensure_neolink_config_entry(name, cam_user, cam_pass, host, uid) 489 - return _neolink_rtsp_url(name) 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) 490 452 return rtsp_url 491 453 492 454 ··· 1293 1255 self.cap = None 1294 1256 self.reconnect_delay = 5 # Mehr Zeit für Akku-Kameras 1295 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 1296 1259 1297 1260 # Alternative Pfade (Reolink Fallbacks) 1298 1261 self._alt_paths = [ ··· 1313 1276 except Exception as e: 1314 1277 self.connection_status.emit(False, self.camera_id, tr("error.prefix", error=str(e))) 1315 1278 if self.running: 1316 - self.sleep(self.reconnect_delay) # Warte vor erneutem Versuch 1279 + for _ in range(int(self.reconnect_delay * 10)): 1280 + if not self.running: 1281 + break 1282 + self.msleep(100) 1317 1283 1318 1284 self._cleanup() 1319 1285 ··· 1355 1321 # (manchen Kameras antworten nicht auf Port-Checks, aber auf echte RTSP-Anfragen) 1356 1322 self.connection_status.emit(False, self.camera_id, tr("camera.status.connecting")) 1357 1323 1358 - # RTSP Stream öffnen (mit Fallback-Pfaden für Reolink) 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) 1359 1328 # Use TCP transport to reduce RTP packet loss warnings 1360 1329 self.cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG, [ 1361 - cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 10000, 1362 - cv2.CAP_PROP_READ_TIMEOUT_MSEC, 10000 1330 + cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, open_timeout_ms, 1331 + cv2.CAP_PROP_READ_TIMEOUT_MSEC, read_timeout_ms 1363 1332 ]) 1364 1333 1365 - # Falls die Kamera nicht öffnet, probieren wir Reolink-typische Varianten (H.264/H.265/Sub) 1366 - if not self.cap.isOpened(): 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: 1367 1337 # Parse URL properly to rebuild with alternative paths 1368 1338 try: 1369 1339 u = urlparse(self.rtsp_url) ··· 1384 1354 1385 1355 self.connection_status.emit(False, self.camera_id, f"Prüfe Pfad: {path}...") 1386 1356 self.cap = cv2.VideoCapture(test_url, cv2.CAP_FFMPEG, [ 1387 - cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 10000, 1388 - cv2.CAP_PROP_READ_TIMEOUT_MSEC, 10000 1357 + cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, open_timeout_ms, 1358 + cv2.CAP_PROP_READ_TIMEOUT_MSEC, read_timeout_ms 1389 1359 ]) 1390 1360 if self.cap.isOpened(): 1391 1361 self.rtsp_url = test_url ··· 1395 1365 1396 1366 if not self.cap.isOpened(): 1397 1367 # Diagnostik: Wenn RTSP zu ist, aber Port 8000 offen, ist RTSP wahrscheinlich in der Kamera deaktiviert 1398 - if self._host: 1368 + if self._host and not self._is_proxy_stream: 1399 1369 ok_api, _ = _tcp_probe(self._host, 8000, timeout=0.5) 1400 1370 if ok_api: 1401 1371 raise Exception("Kamera antwortet auf API (Port 8000), aber RTSP ist blockiert. Bitte 'RTSP' in den Kamera-Einstellungen (Netzwerk -> Fortgeschritten -> Servereinstellungen) aktivieren!") ··· 1498 1468 pass 1499 1469 self.video_writer = None 1500 1470 1501 - def stop(self): 1502 - """Thread stoppen""" 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 + """ 1503 1477 self.running = False 1504 - self.wait() 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 1505 1486 1506 1487 1507 1488 class CameraWidget(QWidget): ··· 1717 1698 def update_frame(self, frame): 1718 1699 """Frame aktualisieren mit FPS-Berechnung""" 1719 1700 self.last_frame = frame 1701 + if self.is_selected_for_view: 1702 + return 1720 1703 1721 1704 # FPS berechnen 1722 1705 now = datetime.now() ··· 1801 1784 1802 1785 def _on_checkbox_changed(self, state): 1803 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')}") 1804 1790 self.selection_changed.emit(self.camera_id, self.is_selected_for_view) 1805 1791 1806 1792 def set_selected(self, selected): ··· 1927 1913 self.next_camera_id = 1 1928 1914 self.selected_camera_id = None 1929 1915 self.selected_camera_ids = [] # Multi-Kamera-Auswahl 1916 + self._restore_preview_camera_ids = [] 1930 1917 self.multi_view_labels = {} # Labels für Multi-Kamera-Ansicht 1931 1918 self.zoomed_camera_id = None 1932 1919 self.preview_crop_camera_id = None ··· 1934 1921 self._rebuilding_camera_list = False 1935 1922 self._pending_order_apply = False 1936 1923 self._closing = False 1924 + self._shutdown_started_at = None 1925 + self._shutdown_dialog = None 1937 1926 self._order_custom = False 1938 1927 1939 1928 # Erstelle Aufzeichnungsordner ··· 2335 2324 path="h264Preview_01_main" 2336 2325 ) 2337 2326 2338 - # Reolink WLAN/Battery: automatisch auf Neolink umstellen 2339 - rtsp_url = _maybe_use_neolink( 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( 2340 2339 rtsp_url=rtsp_url, 2341 2340 name=name, 2342 2341 username=username, ··· 2354 2353 camera_id = self.next_camera_id 2355 2354 self.next_camera_id += 1 2356 2355 2357 - self.cameras.append({ 2356 + camera_entry = { 2358 2357 'id': camera_id, 2359 2358 'url': rtsp_url, 2360 2359 'name': name, 2361 2360 'uid': camera_info.get('uid', ''), 2362 2361 'model': model, 2363 2362 'manufacturer': manufacturer 2364 - }) 2363 + } 2364 + if proxy_config: 2365 + camera_entry['proxy'] = proxy_config 2366 + self.cameras.append(camera_entry) 2365 2367 2366 2368 # Widget erstellen 2367 2369 is_battery = _is_battery_camera(model, name) ··· 2420 2422 if reply == QMessageBox.StandardButton.No: 2421 2423 return 2422 2424 2423 - # Reolink WLAN/Battery (Port 9000) automatisch auf Neolink umstellen 2424 - url = _maybe_use_neolink( 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( 2425 2435 rtsp_url=url, 2426 2436 name=camera_name, 2427 2437 username="", ··· 2430 2440 ) 2431 2441 2432 2442 # Kamera zur Liste hinzufügen 2433 - self.cameras.append({ 2443 + camera_entry = { 2434 2444 'id': camera_id, 2435 2445 'url': url, 2436 2446 'name': camera_name, 2437 2447 'uid': uid, 2438 2448 'model': '' # Model info not available from manual add 2439 - }) 2449 + } 2450 + if proxy_config: 2451 + camera_entry['proxy'] = proxy_config 2452 + self.cameras.append(camera_entry) 2440 2453 2441 2454 # Widget erstellen 2442 2455 is_battery = _is_battery_camera('', camera_name) ··· 2473 2486 thread = self.camera_threads[camera_id] 2474 2487 if thread.isRunning(): 2475 2488 was_running = True 2476 - thread.stop() 2477 - del self.camera_threads[camera_id] 2489 + if thread.stop(timeout_ms=3000): 2490 + del self.camera_threads[camera_id] 2491 + else: 2492 + QMessageBox.warning(self, tr("dialog.title.error"), "Stream wird noch beendet. Bitte gleich erneut versuchen.") 2493 + return 2478 2494 2479 2495 # Edit Dialog öffnen 2480 2496 dialog = CameraEditDialog(camera_data, self) ··· 2482 2498 if dialog.exec() == QDialog.DialogCode.Accepted: 2483 2499 updated_data = dialog.get_camera_data() 2484 2500 2485 - # Reolink WLAN/Battery (Port 9000) automatisch auf Neolink umstellen 2486 - updated_data['url'] = _maybe_use_neolink( 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( 2487 2513 rtsp_url=updated_data.get('url', ''), 2488 2514 name=updated_data.get('name', ''), 2489 2515 username="", ··· 2492 2518 model=camera_data.get('model', ''), 2493 2519 manufacturer=camera_data.get('manufacturer', '') 2494 2520 ) 2521 + if proxy_config: 2522 + updated_data['proxy'] = proxy_config 2495 2523 2496 2524 # Daten aktualisieren 2497 2525 for camera in self.cameras: ··· 2563 2591 2564 2592 def stop_single_stream(self, camera_id): 2565 2593 if camera_id in self.camera_threads: 2566 - self.camera_threads[camera_id].stop() 2567 - del self.camera_threads[camera_id] 2594 + if self.camera_threads[camera_id].stop(timeout_ms=3000): 2595 + del self.camera_threads[camera_id] 2596 + else: 2597 + self.statusBar().showMessage("Stream wird noch beendet...") 2598 + return 2568 2599 2569 2600 if self.preview_crop_camera_id == camera_id: 2570 2601 self._clear_big_preview_crop() ··· 2612 2643 2613 2644 # Thread stoppen falls aktiv 2614 2645 if camera_id in self.camera_threads: 2615 - self.camera_threads[camera_id].stop() 2616 - del self.camera_threads[camera_id] 2646 + if self.camera_threads[camera_id].stop(timeout_ms=3000): 2647 + del self.camera_threads[camera_id] 2648 + else: 2649 + QMessageBox.warning(self, tr("dialog.title.error"), "Stream wird noch beendet. Bitte gleich erneut versuchen.") 2650 + return 2617 2651 2618 2652 # Widget entfernen 2619 2653 if camera_id in self.camera_widgets: ··· 2737 2771 2738 2772 def stop_all_streams(self): 2739 2773 """Alle Streams stoppen""" 2740 - for thread in list(self.camera_threads.values()): 2741 - thread.stop() 2742 - self.camera_threads.clear() 2774 + threads = list(self.camera_threads.values()) 2775 + force_stop = bool(getattr(self, "_closing", False)) 2776 + 2777 + for thread in threads: 2778 + thread.request_stop() 2779 + 2780 + deadline = time.monotonic() + (2.5 if force_stop else 5.0) 2781 + for thread in threads: 2782 + remaining_ms = max(0, int((deadline - time.monotonic()) * 1000)) 2783 + if thread.isRunning(): 2784 + thread.wait(remaining_ms) 2785 + self.camera_threads = { 2786 + camera_id: thread 2787 + for camera_id, thread in self.camera_threads.items() 2788 + if thread.isRunning() 2789 + } 2790 + if self.camera_threads: 2791 + self.statusBar().showMessage("Streams werden noch beendet...") 2792 + return 2743 2793 self._clear_big_preview_crop() 2744 2794 2745 2795 for camera_id, widget in list(self.camera_widgets.items()): ··· 2872 2922 'next_camera_id': self.next_camera_id, 2873 2923 'language': self.language, 2874 2924 'order_custom': self._order_custom, 2925 + 'preview_camera_ids': list(self.selected_camera_ids), 2926 + 'selected_camera_id': self.selected_camera_id, 2875 2927 } 2876 2928 try: 2877 2929 with open('camera_config.json', 'w') as f: ··· 2944 2996 fixed_config = True # Sorgen wir dafür, dass es gespeichert wird 2945 2997 deduped_by_url.append(c) 2946 2998 2947 - # Reolink WLAN/Baichuan URLs beim Laden automatisch auf Neolink umstellen 2999 + # Reolink WLAN/Baichuan URLs beim Laden automatisch auf ReolinkProxy umstellen 2948 3000 for c in deduped_by_url: 2949 3001 url = (c.get('url') or '').strip() 2950 3002 if not url: 2951 3003 continue 2952 - new_url = _maybe_use_neolink( 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( 2953 3014 rtsp_url=url, 2954 3015 name=c.get('name', ''), 2955 3016 username="", ··· 2960 3021 ) 2961 3022 if new_url != url: 2962 3023 c['url'] = new_url 3024 + fixed_config = True 3025 + if proxy_config and not c.get('proxy'): 3026 + c['proxy'] = proxy_config 2963 3027 fixed_config = True 2964 3028 2965 3029 self.cameras = deduped_by_url 2966 3030 self.recording_path = config.get('recording_path', self.recording_path) 2967 3031 self.snapshot_path = os.path.join(self.recording_path, "snapshots") 2968 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 2969 3048 self._order_custom = bool(config.get('order_custom', False)) 2970 3049 if not self._order_custom: 2971 3050 try: ··· 2993 3072 self.update_grid_layout() 2994 3073 self.update_status_display() 2995 3074 self.retranslate_ui() 3075 + self._restore_preview_state() 2996 3076 2997 3077 if fixed_config: 2998 3078 self.save_config() ··· 3003 3083 3004 3084 def closeEvent(self, event): 3005 3085 """Beim Schließen alle Threads sauber beenden""" 3006 - self._closing = True 3007 - self.save_config() 3008 - self.stop_all_streams() 3009 - event.accept() 3086 + running_threads = [t for t in self.camera_threads.values() if t.isRunning()] 3087 + if not running_threads: 3088 + event.accept() 3089 + return 3090 + 3091 + if not self._closing: 3092 + self._closing = True 3093 + self._shutdown_started_at = time.monotonic() 3094 + self.save_config() 3095 + for thread in running_threads: 3096 + thread.request_stop() 3097 + self.setEnabled(False) 3098 + self._show_shutdown_dialog() 3099 + QTimer.singleShot(100, self._finish_close_when_streams_stopped) 3100 + 3101 + event.ignore() 3102 + 3103 + def _finish_close_when_streams_stopped(self): 3104 + self.camera_threads = { 3105 + camera_id: thread 3106 + for camera_id, thread in self.camera_threads.items() 3107 + if thread.isRunning() 3108 + } 3109 + if self.camera_threads: 3110 + elapsed = time.monotonic() - (self._shutdown_started_at or time.monotonic()) 3111 + message = f"Closing app. Please wait...\nStopping camera streams ({elapsed:.1f}s)" 3112 + self.statusBar().showMessage(message.replace("\n", " ")) 3113 + if self._shutdown_dialog is not None: 3114 + self._shutdown_dialog.setLabelText(message) 3115 + QTimer.singleShot(100, self._finish_close_when_streams_stopped) 3116 + return 3117 + 3118 + if self._shutdown_dialog is not None: 3119 + self._shutdown_dialog.close() 3120 + self._shutdown_dialog = None 3121 + self.close() 3122 + 3123 + def _show_shutdown_dialog(self): 3124 + self.statusBar().showMessage("Closing app. Please wait...") 3125 + dialog = QProgressDialog("Closing app. Please wait...\nStopping camera streams", None, 0, 0, self) 3126 + dialog.setWindowTitle("Closing app") 3127 + dialog.setWindowModality(Qt.WindowModality.ApplicationModal) 3128 + dialog.setCancelButton(None) 3129 + dialog.setMinimumDuration(0) 3130 + dialog.setAutoClose(False) 3131 + dialog.setAutoReset(False) 3132 + dialog.show() 3133 + self._shutdown_dialog = dialog 3010 3134 3011 3135 def select_camera(self, camera_id): 3012 3136 # If multi-view is active (checkboxes), don't use old single-camera selection ··· 3038 3162 self.start_single_stream(camera_id) 3039 3163 else: 3040 3164 self._refresh_big_preview_from_last_frame(camera_id) 3165 + self.save_config() 3166 + 3167 + def _restore_preview_state(self): 3168 + restore_ids = [ 3169 + cid for cid in getattr(self, "_restore_preview_camera_ids", []) 3170 + if cid in self.camera_widgets 3171 + ] 3172 + if restore_ids: 3173 + self.selected_camera_ids = restore_ids 3174 + self.selected_camera_id = None 3175 + for cid, widget in self.camera_widgets.items(): 3176 + checked = cid in restore_ids 3177 + widget.view_checkbox.blockSignals(True) 3178 + widget.view_checkbox.setChecked(checked) 3179 + widget.is_selected_for_view = checked 3180 + widget.view_checkbox.blockSignals(False) 3181 + widget.set_selected(False) 3182 + if checked: 3183 + widget.video_label.setPixmap(QPixmap()) 3184 + widget.video_label.setText(f"{widget.camera_name}\n{tr('camera.preview.waiting')}") 3185 + self._rebuild_multi_view_layout() 3186 + QTimer.singleShot(300, self._start_restored_preview_streams) 3187 + return 3188 + 3189 + if self.selected_camera_id in self.camera_widgets: 3190 + camera_id = self.selected_camera_id 3191 + for cid, widget in self.camera_widgets.items(): 3192 + widget.set_selected(cid == camera_id) 3193 + widget = self.camera_widgets.get(camera_id) 3194 + if widget and hasattr(self, "big_preview_label"): 3195 + self.big_preview_label.clear_frame_display_rect() 3196 + self.big_preview_label.setText(f"{widget.camera_name}\n{tr('camera.preview.waiting')}") 3197 + QTimer.singleShot(300, lambda cid=camera_id: self.start_single_stream(cid)) 3198 + 3199 + def _start_restored_preview_streams(self): 3200 + for cid in list(self.selected_camera_ids): 3201 + if cid not in self.camera_threads or not self.camera_threads[cid].isRunning(): 3202 + self.start_single_stream(cid) 3041 3203 3042 3204 def _on_language_changed(self): 3043 3205 if not hasattr(self, "language_combo"): ··· 3145 3307 self._sync_big_preview_crop_state(self._get_active_big_preview_camera_id()) 3146 3308 3147 3309 self._rebuild_multi_view_layout() 3310 + self.save_config() 3148 3311 3149 3312 # Start streams for selected cameras 3150 3313 for cid in self.selected_camera_ids: ··· 3352 3515 3353 3516 # Rebuild the multi-view layout 3354 3517 self._rebuild_multi_view_layout() 3518 + self.save_config() 3355 3519 3356 3520 def _on_big_preview_label_double_clicked(self, camera_id): 3357 3521 if self.zoomed_camera_id is None: 3358 3522 if len(self.selected_camera_ids) > 1 and camera_id in self.selected_camera_ids: 3359 3523 self.zoomed_camera_id = camera_id 3360 3524 self._rebuild_multi_view_layout() 3525 + self.save_config() 3361 3526 return 3362 3527 3363 3528 active_camera_id = self._get_active_big_preview_camera_id() ··· 3373 3538 if self.zoomed_camera_id is not None: 3374 3539 self.zoomed_camera_id = None 3375 3540 self._rebuild_multi_view_layout() 3541 + self.save_config() 3376 3542 3377 3543 def _on_big_preview_region_selected(self, camera_id, selection_rect): 3378 3544 active_camera_id = self._get_active_big_preview_camera_id()
+219
reolinkproxy_manager.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + ReolinkProxy Manager fuer WildCam. 4 + 5 + Generiert reolinkproxy.env ausschliesslich aus camera_config.json. 6 + """ 7 + import json 8 + import re 9 + import sys 10 + from pathlib import Path 11 + from urllib.parse import urlparse, unquote 12 + 13 + 14 + 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: 43 + return None 44 + 45 + 46 + def camera_name(name): 47 + return (name or "Camera").strip().replace(" ", "_") 48 + 49 + 50 + def is_proxy_url(rtsp_url): 51 + info = parse_rtsp_url(rtsp_url or "") 52 + return bool(info and info["host"] in ("localhost", "127.0.0.1") and info["port"] == 8554) 53 + 54 + 55 + def is_reolinkproxy_camera(camera): 56 + proxy = camera.get("proxy") or {} 57 + if proxy.get("type") == "reolinkproxy": 58 + return True 59 + 60 + url_info = parse_rtsp_url(camera.get("url", "")) 61 + if url_info and url_info["port"] == 9000: 62 + return True 63 + return False 64 + 65 + 66 + def env_value(value): 67 + value = "" if value is None else str(value) 68 + value = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") 69 + return f'"{value}"' 70 + 71 + 72 + 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 + } 89 + 90 + 91 + def write_env(cameras, output_path): 92 + lines = [ 93 + "# Auto-generated by WildCam reolinkproxy_manager.py", 94 + "# Existing WildCam RTSP paths are preserved with REOLINK_CAMERA_N_RTSP_PATH.", 95 + "REOLINK_HEALTHCHECK_RTSP_ONLY=true", 96 + "", 97 + ] 98 + 99 + proxy_cameras = [cam for cam in cameras if is_reolinkproxy_camera(cam)] 100 + incomplete_proxy_cameras = [ 101 + cam for cam in cameras 102 + if is_proxy_url(cam.get("url", "")) and (cam.get("proxy") or {}).get("type") != "reolinkproxy" 103 + ] 104 + if incomplete_proxy_cameras: 105 + names = ", ".join(camera_name(cam.get("name", "")) for cam in incomplete_proxy_cameras) 106 + print(f"Unvollstaendige Proxy-Konfiguration fuer: {names}") 107 + print("Bitte in camera_config.json je Kamera einen proxy-Block mit host/username/password ergaenzen.") 108 + return False 109 + if not proxy_cameras: 110 + print("Keine ReolinkProxy-Kameras gefunden.") 111 + return False 112 + 113 + print(f"{len(proxy_cameras)} ReolinkProxy-Kamera(s) gefunden:") 114 + 115 + for index, cam in enumerate(proxy_cameras): 116 + name = camera_name(cam.get("name", f"Camera_{index}")) 117 + proxy = cam.get("proxy") or make_proxy_from_rtsp(cam) or {} 118 + 119 + use_uid_only = bool(proxy.get("uid_only", False)) 120 + host = "" if use_uid_only else proxy.get("host", "") 121 + port = int(proxy.get("port") or 9000) 122 + username = proxy.get("username", "") 123 + password = proxy.get("password", "") 124 + stream = proxy.get("stream", "main") 125 + uid = proxy.get("uid") or cam.get("uid", "") 126 + battery = bool(proxy.get("battery", True)) 127 + pause_on_client = bool(proxy.get("pause_on_client", True)) 128 + idle_disconnect = bool(proxy.get("idle_disconnect", True)) 129 + idle_timeout = proxy.get("idle_timeout", "30s") 130 + 131 + print(f" - {name}: host={host or 'UID-only'}, uid={uid or '-'}") 132 + 133 + lines.extend( 134 + [ 135 + f"REOLINK_CAMERA_{index}_NAME={env_value(name)}", 136 + f"REOLINK_CAMERA_{index}_PORT={port}", 137 + f"REOLINK_CAMERA_{index}_USERNAME={env_value(username)}", 138 + f"REOLINK_CAMERA_{index}_PASSWORD={env_value(password)}", 139 + f"REOLINK_CAMERA_{index}_STREAM={env_value(stream)}", 140 + f"REOLINK_CAMERA_{index}_RTSP_PATH={env_value(f'{name}/mainStream')}", 141 + f"REOLINK_CAMERA_{index}_BATTERY_CAMERA={str(battery).lower()}", 142 + f"REOLINK_CAMERA_{index}_PAUSE_ON_CLIENT={str(pause_on_client).lower()}", 143 + f"REOLINK_CAMERA_{index}_IDLE_DISCONNECT={str(idle_disconnect).lower()}", 144 + f"REOLINK_CAMERA_{index}_IDLE_TIMEOUT={env_value(idle_timeout)}", 145 + ] 146 + ) 147 + if host: 148 + lines.append(f"REOLINK_CAMERA_{index}_HOST={env_value(host)}") 149 + if uid: 150 + lines.append(f"REOLINK_CAMERA_{index}_UID={env_value(uid)}") 151 + lines.append("") 152 + 153 + output_path.write_text("\n".join(lines), encoding="utf-8") 154 + print(f"{output_path} geschrieben.") 155 + return True 156 + 157 + 158 + def update_camera_config(camera_config_path): 159 + try: 160 + config = json.loads(camera_config_path.read_text(encoding="utf-8")) 161 + except Exception as e: 162 + print(f"Fehler beim Laden der Config: {e}") 163 + return False 164 + 165 + updated = False 166 + for cam in config.get("cameras", []): 167 + if not is_reolinkproxy_camera(cam): 168 + continue 169 + name = camera_name(cam.get("name", "")) 170 + if "proxy" not in cam: 171 + proxy = make_proxy_from_rtsp(cam) 172 + if proxy: 173 + cam["proxy"] = proxy 174 + updated = True 175 + new_url = f"rtsp://localhost:8554/{name}/mainStream" 176 + if cam.get("url") != new_url: 177 + print(f"{cam.get('name', name)}: {cam.get('url')} -> {new_url}") 178 + cam["url"] = new_url 179 + updated = True 180 + 181 + if not updated: 182 + return False 183 + 184 + backup_path = camera_config_path.with_suffix(camera_config_path.suffix + ".backup") 185 + backup_path.write_text(camera_config_path.read_text(encoding="utf-8"), encoding="utf-8") 186 + camera_config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") 187 + print(f"{camera_config_path} aktualisiert, Backup: {backup_path}") 188 + return True 189 + 190 + 191 + def main(): 192 + script_dir = Path(__file__).parent 193 + config_path = script_dir / "camera_config.json" 194 + env_path = script_dir / "reolinkproxy.env" 195 + 196 + print("WildCam ReolinkProxy Manager") 197 + print("=" * 50) 198 + 199 + try: 200 + config = json.loads(config_path.read_text(encoding="utf-8")) 201 + except FileNotFoundError: 202 + print(f"Fehler: {config_path} nicht gefunden.") 203 + sys.exit(0) 204 + except json.JSONDecodeError as e: 205 + print(f"Fehler beim Parsen der Config: {e}") 206 + sys.exit(1) 207 + 208 + success = write_env(config.get("cameras", []), env_path) 209 + if not success: 210 + sys.exit(0) 211 + 212 + if "--auto-update" in sys.argv or "--yes" in sys.argv: 213 + update_camera_config(config_path) 214 + else: 215 + print("camera_config.json bleibt unveraendert. Nutze --auto-update zum Umschreiben alter Port-9000-URLs.") 216 + 217 + 218 + if __name__ == "__main__": 219 + main()
+1 -5
scripts/build_linux.sh
··· 72 72 exit 1 73 73 fi 74 74 75 - for extra_file in README.md docker-compose.yml neolink_manager.py camera_config.json.example; do 75 + for extra_file in README.md docker-compose.yml reolinkproxy_manager.py camera_config.json.example; do 76 76 if [[ -f "$extra_file" ]]; then 77 77 cp "$extra_file" "$BUNDLE_PATH/" 78 78 fi 79 79 done 80 - 81 - if [[ -f "neolink.toml" ]]; then 82 - cp "neolink.toml" "$BUNDLE_PATH/" 83 - fi 84 80 85 81 APPDIR_PATH="$BUILD_DIR/${APP_NAME}.AppDir" 86 82 APPDIR_USR_BIN="$APPDIR_PATH/usr/bin"
+1 -4
scripts/build_macos.sh
··· 76 76 fi 77 77 78 78 if [[ -d "$BUNDLE_PATH" ]]; then 79 - for extra_file in README.md docker-compose.yml neolink_manager.py camera_config.json.example; do 79 + for extra_file in README.md docker-compose.yml reolinkproxy_manager.py camera_config.json.example; do 80 80 if [[ -f "$extra_file" ]]; then 81 81 cp "$extra_file" "$BUNDLE_PATH/" 82 82 fi 83 83 done 84 84 85 - if [[ -f "neolink.toml" ]]; then 86 - cp "neolink.toml" "$BUNDLE_PATH/" 87 - fi 88 85 fi 89 86 90 87 ZIP_PATH="$DIST_DIR/${APP_NAME}_${PLATFORM}_${ARTIFACT_SUFFIX}.zip"
+1 -5
scripts/build_windows.ps1
··· 56 56 $extraFiles = @( 57 57 "README.md", 58 58 "docker-compose.yml", 59 - "neolink_manager.py", 59 + "reolinkproxy_manager.py", 60 60 "camera_config.json.example" 61 61 ) 62 62 ··· 64 64 if (Test-Path $extraFile) { 65 65 Copy-Item $extraFile -Destination $bundlePath -Force 66 66 } 67 - } 68 - 69 - if (Test-Path "neolink.toml") { 70 - Copy-Item "neolink.toml" -Destination $bundlePath -Force 71 67 } 72 68 73 69 if ([string]::IsNullOrWhiteSpace($ArtifactSuffix)) {
+11 -11
start_wildcam.sh
··· 1 1 #!/bin/bash 2 - # WildCam Start-Script mit automatischem Neolink-Setup 2 + # WildCam Start-Script mit automatischem ReolinkProxy-Setup 3 3 4 4 set -e 5 5 ··· 22 22 fi 23 23 fi 24 24 25 - # Generiere Neolink Config wenn Battery-Kameras vorhanden 25 + # Generiere ReolinkProxy Config wenn Battery-Kameras vorhanden 26 26 echo "" 27 27 echo "🔋 Prüfe auf Battery-Kameras (Port 9000)..." 28 - python3 neolink_manager.py 28 + python3 reolinkproxy_manager.py --auto-update 29 29 30 - # Starte Neolink wenn neolink.toml existiert 31 - if [ -f "neolink.toml" ]; then 30 + # Starte ReolinkProxy wenn reolinkproxy.env existiert 31 + if [ -f "reolinkproxy.env" ]; then 32 32 echo "" 33 33 34 34 # Prüfe ob Docker läuft ··· 42 42 docker compose up -d 43 43 44 44 # Warte kurz für Container-Start 45 - echo "⏳ Warte auf Neolink..." 45 + echo "⏳ Warte auf ReolinkProxy..." 46 46 sleep 3 47 47 48 48 # Prüfe ob Container läuft 49 - if docker ps | grep -q wildcam-neolink; then 50 - echo "✅ Neolink läuft (localhost:8554)" 49 + if docker ps | grep -q wildcam-reolinkproxy; then 50 + echo "✅ ReolinkProxy läuft (localhost:8554)" 51 51 else 52 - echo "⚠️ Neolink Container nicht gestartet" 53 - echo " Logs: docker logs wildcam-neolink" 52 + echo "⚠️ ReolinkProxy Container nicht gestartet" 53 + echo " Logs: docker logs wildcam-reolinkproxy" 54 54 fi 55 55 else 56 - echo "ℹ️ Keine Battery-Kameras - Neolink nicht benötigt" 56 + echo "ℹ️ Keine Battery-Kameras - ReolinkProxy nicht benötigt" 57 57 fi 58 58 59 59 # Starte WildCam