···47474848- **Native RTSP (port 554)**
4949 - Works well for mains-powered cameras.
5050-- **Neolink proxy (recommended for Reolink WLAN / battery cameras)**
5050+- **ReolinkProxy (recommended for Reolink WLAN / battery cameras)**
5151 - Many Reolink WLAN/battery models use the **Baichuan protocol (port 9000)**.
5252 - WildCam will automatically:
5353- - add/update `neolink.toml`
5353+ - add/update `reolinkproxy.env`
5454 - switch the stored RTSP URL to `rtsp://localhost:8554/<NAME>/mainStream`
5555- - so the app uses the Neolink-proxied stream
5555+ - so the app uses the ReolinkProxy-proxied stream
56565757#### What gets stored where
58585959- **`camera_config.json`**
6060 - What the app actually uses at runtime.
6161- - After Neolink conversion, URLs look like:
6161+ - After ReolinkProxy conversion, URLs look like:
6262 - `rtsp://localhost:8554/D58/mainStream`
6363-- **`neolink.toml`**
6363+- **`reolinkproxy.env`**
6464 - Contains the real camera IP/credentials for port 9000 cameras.
6565 - Example:
6666- - `address = "192.168.8.58:9000"`
6666+ - `REOLINK_CAMERA_0_HOST="192.168.8.58"`
6767+ - `REOLINK_CAMERA_0_RTSP_PATH="D58/mainStream"`
67686869#### Quick start (recommended)
6970···73747475This will:
75767676-- Generate/extend `neolink.toml` based on `camera_config.json`
7777-- Start Neolink via Docker
7777+- Generate/extend `reolinkproxy.env` based on `camera_config.json`
7878+- Start ReolinkProxy via Docker
7879- Start WildCam
79808081Important:
81828282-- WildCam manages the Neolink configuration, but it does **not** embed the actual `neolink` runtime.
8383-- For Reolink WLAN / battery cameras you still need a running Neolink instance, typically via `docker compose up -d`.
8484-- For regular RTSP cameras on port 554, Neolink is not required.
8383+- WildCam manages the ReolinkProxy configuration, but it does **not** embed the actual `reolinkproxy` runtime.
8484+- For Reolink WLAN / battery cameras you still need a running ReolinkProxy instance, typically via `docker compose up -d`.
8585+- For regular RTSP cameras on port 554, ReolinkProxy is not required.
85868687## Configuration File (`camera_config.json`)
8788···9697Manual editing is optional (e.g. to bulk-edit RTSP URLs or names).
97989899- The file is **gitignored** because it can contain **credentials** inside RTSP URLs.
100100+- `reolinkproxy.env` is generated from this file and should not be edited as the primary configuration.
99101- Use `camera_config.json.example` as a safe template and create your local config from it.
100102101103### Create your local config
···113115 - **`id`** must be unique.
114116 - **`url`** is the RTSP URL.
115117 - **`name`** is the display name.
118118+ - **`proxy`** is optional and contains ReolinkProxy connection settings for battery/WLAN cameras.
116119- **`recording_path`**
117120 - Target directory for recordings.
118121- **`next_camera_id`**
···130133 "cameras": [
131134 {
132135 "id": 1,
133133- "url": "rtsp://USER:PASS@192.168.1.100:554/h264Preview_01_main",
134134- "name": "Camera 1"
136136+ "url": "rtsp://localhost:8554/D54/mainStream",
137137+ "name": "D54",
138138+ "uid": "9527000000000000",
139139+ "proxy": {
140140+ "type": "reolinkproxy",
141141+ "host": "192.168.1.100",
142142+ "port": 9000,
143143+ "username": "admin",
144144+ "password": "password",
145145+ "stream": "main",
146146+ "battery": true,
147147+ "pause_on_client": true,
148148+ "idle_disconnect": true,
149149+ "idle_timeout": "30s"
150150+ }
135151 }
136152 ],
137153 "recording_path": "/home/USER/Videos/Reolink",
···161177162178WildCam will automatically:
163179164164-- append/update the camera entry in `neolink.toml`
180180+- append/update the camera entry in `reolinkproxy.env`
165181- store the camera in `camera_config.json` as:
166182 - `rtsp://localhost:8554/<NAME>/mainStream`
167183···171187172188When you add found Reolink WLAN/battery cameras, WildCam will automatically:
173189174174-- update `neolink.toml`
190190+- update `reolinkproxy.env`
175191- store `localhost:8554/...` URLs in `camera_config.json`
176192177177-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.
193193+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.
178194179195Important:
180196181181-- WildCam writes and updates `neolink.toml`, but the actual Neolink proxy must run separately.
197197+- WildCam writes and updates `reolinkproxy.env`, but the actual ReolinkProxy must run separately.
182198- The recommended setup in this repository is the Docker Compose stack from `docker-compose.yml`.
183199184200## Battery Cameras
185201186186-### Automatic Neolink Setup ⭐
202202+### Automatic ReolinkProxy Setup
187203188188-WildCam includes automatic Neolink setup for battery cameras using port 9000 (Baichuan protocol).
204204+WildCam includes automatic ReolinkProxy setup for battery cameras using port 9000 (Baichuan protocol).
189205190206This is handled in two places:
191207192192-- The **GUI** automatically switches Reolink WLAN/Baichuan cameras to `rtsp://localhost:8554/...` and appends the camera to `neolink.toml`.
193193-- The **startup script** can start the Neolink container for you.
208208+- The **GUI** automatically switches Reolink WLAN/Baichuan cameras to `rtsp://localhost:8554/...` and appends the camera to `reolinkproxy.env`.
209209+- The **startup script** can start the ReolinkProxy container for you.
194210195211#### Quick Start
196212197213```bash
198198-# 1. Start WildCam with automatic Neolink setup
214214+# 1. Start WildCam with automatic ReolinkProxy setup
199215./start_wildcam.sh
200216```
201217202218**What it does:**
203219- ✅ Detects battery cameras (port 9000) in `camera_config.json`
204204-- ✅ Auto-generates `neolink.toml` configuration
205205-- ✅ Starts Neolink Docker container
220220+- Auto-generates `reolinkproxy.env` configuration
221221+- Starts ReolinkProxy Docker container
206222- ✅ The app stores/uses `rtsp://localhost:8554/...` URLs for these cameras
207223208224#### Manual Setup
···210226If you prefer manual control:
211227212228```bash
213213-# 1. Generate neolink.toml from your camera config
214214-python3 neolink_manager.py
229229+# 1. Generate reolinkproxy.env from your camera config
230230+python3 reolinkproxy_manager.py --auto-update
215231216216-# 2. Start Neolink container
232232+# 2. Start ReolinkProxy container
217233docker compose up -d
218234219219-# 3. Check Neolink logs
220220-docker logs wildcam-neolink
235235+# 3. Check ReolinkProxy logs
236236+docker logs wildcam-reolinkproxy
221237222238# 4. Start WildCam
223239python3 main.py
···225241226242If you distribute WildCam as a standalone build:
227243228228-- the bundle can include `docker-compose.yml`, `neolink_manager.py`, `neolink.toml` (if present), and the app itself
229229-- but it still does **not** include the external Neolink container/image
230230-- users who rely on Reolink battery / Baichuan cameras still need Docker/Compose or a separately installed `neolink`
244244+- the bundle can include `docker-compose.yml`, `reolinkproxy_manager.py`, `camera_config.json.example`, and the app itself
245245+- `reolinkproxy.env` is not bundled because it is generated locally and can contain credentials
246246+- but it still does **not** include the external ReolinkProxy container/image
247247+- users who rely on Reolink battery / Baichuan cameras still need Docker/Compose or a separately installed `reolinkproxy`
231248232249#### How it Works
233250···242259}
243260```
244261245245-**2. Neolink Config Generation:**
246246-Creates `neolink.toml` automatically:
247247-```toml
248248-[[cameras]]
249249-name = "ArgusCamera"
250250-username = "admin"
251251-password = "password"
252252-address = "192.168.8.58:9000"
253253-uid = "9527000KVKX2161S"
254254-idle_disconnect = true
262262+**2. ReolinkProxy Config Generation:**
263263+Creates `reolinkproxy.env` automatically:
264264+```env
265265+REOLINK_CAMERA_0_NAME="ArgusCamera"
266266+REOLINK_CAMERA_0_HOST="192.168.8.58"
267267+REOLINK_CAMERA_0_PORT=9000
268268+REOLINK_CAMERA_0_USERNAME="admin"
269269+REOLINK_CAMERA_0_PASSWORD="password"
270270+REOLINK_CAMERA_0_UID="9527000KVKX2161S"
271271+REOLINK_CAMERA_0_RTSP_PATH="ArgusCamera/mainStream"
272272+REOLINK_CAMERA_0_BATTERY_CAMERA=true
273273+REOLINK_CAMERA_0_PAUSE_ON_CLIENT=true
274274+REOLINK_CAMERA_0_IDLE_DISCONNECT=true
255275```
256276257277**3. URL Conversion (Optional):**
258258-Updates camera URLs to use Neolink:
278278+Updates camera URLs to use ReolinkProxy:
259279```
260280Before: rtsp://admin:password@192.168.8.58:9000/...
261281After: rtsp://localhost:8554/ArgusCamera/mainStream
262282```
263283264264-#### Stopping Neolink
284284+#### Stopping ReolinkProxy
265285266286```bash
267287docker compose down
···276296277297The repository contains helper scripts using PyInstaller.
278298279279-Note about Neolink:
299299+Note about ReolinkProxy:
280300281281-- The packaged app bundles WildCam and helper files such as `docker-compose.yml` and `neolink_manager.py`.
282282-- The actual Neolink runtime is **not** bundled into the app archive.
301301+- The packaged app bundles WildCam and helper files such as `docker-compose.yml` and `reolinkproxy_manager.py`.
302302+- The actual ReolinkProxy runtime is **not** bundled into the app archive.
283303- If you use only normal RTSP cameras, the standalone app is enough.
284284-- If you use Reolink WLAN / battery cameras, you must additionally run Neolink externally.
304304+- If you use Reolink WLAN / battery cameras, you must additionally run ReolinkProxy externally.
285305286306### Linux
287307
+4-3
TODO.md
···11# TODO
2233-## Neolink integration (Reolink WLAN/Baichuan)
33+## ReolinkProxy integration (Reolink WLAN/Baichuan)
4455- [x] Auto-switch Port 9000 / battery cameras to `rtsp://localhost:8554/<NAME>/mainStream`
66-- [x] Auto-update/extend `neolink.toml` when adding/editing/discovering cameras
66+- [x] Store proxy settings in `camera_config.json`
77+- [x] Auto-generate `reolinkproxy.env` from `camera_config.json`
78- [x] Handle `#` in passwords (URL parsing)
88-- [x] Use `docker compose` (v2) for starting/stopping Neolink
99+- [x] Use `docker compose` (v2) for starting/stopping ReolinkProxy
9101011## Refactor `main.py` into modules (best practice)
1112
···1515 QMessageBox, QComboBox, QGroupBox, QScrollArea,
1616 QProgressBar, QDialog, QDialogButtonBox, QTableWidget,
1717 QTableWidgetItem, QHeaderView, QSizePolicy, QSplitter,
1818- QTabWidget, QStyle)
1818+ QTabWidget, QStyle, QProgressDialog)
1919from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer, QMimeData, QSize, QEvent, QRect
2020from PyQt6.QtGui import QImage, QPixmap, QDrag, QIcon, QPainter, QPen, QColor
2121from datetime import datetime
···167167 "• Akku wird bei Dauerstream stark belastet\n"
168168 "• Stream kann nach 30-60 Sekunden abbrechen\n\n"
169169 "💡 EMPFEHLUNG:\n"
170170- "Verwende Neolink als Proxy für stabiles Streaming:\n"
171171- "https://github.com/QuantumEntangledAndy/neolink\n\n"
172172- "Siehe README für Neolink-Konfiguration.\n\n"
170170+ "Verwende ReolinkProxy als Proxy für stabiles Streaming:\n"
171171+ "https://github.com/Shareed2k/reolinkproxy\n\n"
172172+ "Siehe README für ReolinkProxy-Konfiguration.\n\n"
173173 "Kamera trotzdem hinzufügen?",
174174 "battery.indicator": "🔋 AKKU",
175175 },
···286286 "• Battery drains quickly with continuous streaming\n"
287287 "• Stream may disconnect after 30-60 seconds\n\n"
288288 "💡 RECOMMENDATION:\n"
289289- "Use Neolink as proxy for stable streaming:\n"
290290- "https://github.com/QuantumEntangledAndy/neolink\n\n"
291291- "See README for Neolink configuration.\n\n"
289289+ "Use ReolinkProxy as proxy for stable streaming:\n"
290290+ "https://github.com/Shareed2k/reolinkproxy\n\n"
291291+ "See README for ReolinkProxy configuration.\n\n"
292292 "Add camera anyway?",
293293 "battery.indicator": "🔋 BATTERY",
294294 },
···403403 return f"{scheme}://{auth}{host}:{port}{path}"
404404405405406406-def _toml_escape(value: str) -> str:
407407- """Escape TOML string values (" and \\)."""
408408- if value is None:
409409- return ""
410410- return value.replace("\\", "\\\\").replace("\"", "\\\"")
411411-412412-413413-def _neolink_camera_name(name: str) -> str:
414414- """Normalize camera name for Neolink stream path."""
406406+def _reolinkproxy_camera_name(name: str) -> str:
407407+ """Normalize camera name for ReolinkProxy stream path."""
415408 return (name or "Camera").strip().replace(" ", "_")
416409417410418418-def _neolink_rtsp_url(name: str, port: int = 8554) -> str:
419419- cam_name = _neolink_camera_name(name)
411411+def _reolinkproxy_rtsp_url(name: str, port: int = 8554) -> str:
412412+ cam_name = _reolinkproxy_camera_name(name)
420413 return f"rtsp://localhost:{port}/{cam_name}/mainStream"
421414422415423423-def _ensure_neolink_config_entry(name: str, username: str, password: str, host: str, uid: str = "") -> None:
424424- """Ensure Neolink config contains a camera entry."""
425425- if not host:
426426- return
427427-428428- config_path = os.path.join(os.path.dirname(__file__), "neolink.toml")
429429- cam_name = _neolink_camera_name(name)
430430- entry_marker = f"name = \"{cam_name}\""
431431-432432- header = (
433433- "# Auto-generated by WildCam\n"
434434- "# This file is automatically created from camera_config.json\n\n"
435435- "bind = \"0.0.0.0\"\n"
436436- "bind_port = 8554\n\n"
437437- )
438438-439439- try:
440440- existing = ""
441441- if os.path.exists(config_path):
442442- with open(config_path, "r", encoding="utf-8") as f:
443443- existing = f.read()
444444- if entry_marker in existing:
445445- return
446446- else:
447447- existing = header
448448-449449- entry = (
450450- f"\n[[cameras]]\n"
451451- f"name = \"{_toml_escape(cam_name)}\"\n"
452452- f"username = \"{_toml_escape(username)}\"\n"
453453- f"password = \"{_toml_escape(password)}\"\n"
454454- f"address = \"{_toml_escape(host)}:9000\"\n"
455455- )
456456- if uid:
457457- entry += f"uid = \"{_toml_escape(uid)}\"\n"
458458-459459- entry += (
460460- "\n# Battery optimization\n"
461461- "idle_disconnect = true\n\n"
462462- "pause.on_client = true\n"
463463- "pause.timeout = 2.1\n"
464464- )
465465-466466- with open(config_path, "w", encoding="utf-8") as f:
467467- f.write(existing + entry)
468468- except Exception as e:
469469- print(f"Neolink config error: {e}")
470470-471471-472472-def _maybe_use_neolink(rtsp_url: str, name: str, username: str, password: str, uid: str = "", model: str = "", manufacturer: str = "") -> str:
473473- """Switch to Neolink for Reolink WLAN/Battery cameras and update neolink.toml."""
416416+def _reolinkproxy_proxy_config(rtsp_url: str, name: str, username: str, password: str, uid: str = "", model: str = "", manufacturer: str = "") -> dict | None:
417417+ """Build a persistent proxy config for Reolink WLAN/Battery cameras."""
474418 host, port, user, pwd = _parse_rtsp_url(rtsp_url)
475419 if host in ("localhost", "127.0.0.1") and port == 8554:
476476- return rtsp_url
420420+ return None
477421478422 is_reolink = (
479423 (manufacturer or "").lower() == "reolink"
480424 or "reolink" in (model or "").lower()
481425 or port == 9000
482426 )
483483- use_neolink = port == 9000 or _is_battery_camera(model, name)
427427+ use_reolinkproxy = port == 9000 or _is_battery_camera(model, name)
484428485485- if is_reolink and use_neolink and host:
486486- cam_user = username or user or ""
487487- cam_pass = password or pwd or ""
488488- _ensure_neolink_config_entry(name, cam_user, cam_pass, host, uid)
489489- return _neolink_rtsp_url(name)
429429+ if not (is_reolink and use_reolinkproxy and host):
430430+ return None
431431+432432+ proxy_port = int(port or 9000)
433433+434434+ return {
435435+ "type": "reolinkproxy",
436436+ "host": host,
437437+ "port": proxy_port,
438438+ "username": username or user or "",
439439+ "password": password or pwd or "",
440440+ "stream": "main",
441441+ "battery": True,
442442+ "pause_on_client": True,
443443+ "idle_disconnect": True,
444444+ "idle_timeout": "30s",
445445+ }
446446+447447+448448+def _maybe_use_reolinkproxy(rtsp_url: str, name: str, username: str, password: str, uid: str = "", model: str = "", manufacturer: str = "") -> str:
449449+ """Switch Reolink WLAN/Battery cameras to the local ReolinkProxy RTSP URL."""
450450+ if _reolinkproxy_proxy_config(rtsp_url, name, username, password, uid, model, manufacturer):
451451+ return _reolinkproxy_rtsp_url(name)
490452 return rtsp_url
491453492454···12931255 self.cap = None
12941256 self.reconnect_delay = 5 # Mehr Zeit für Akku-Kameras
12951257 self._host, self._port, self._user, self._password = _parse_rtsp_url(rtsp_url)
12581258+ self._is_proxy_stream = self._host in ("localhost", "127.0.0.1") and int(self._port or 0) == 8554
1296125912971260 # Alternative Pfade (Reolink Fallbacks)
12981261 self._alt_paths = [
···13131276 except Exception as e:
13141277 self.connection_status.emit(False, self.camera_id, tr("error.prefix", error=str(e)))
13151278 if self.running:
13161316- self.sleep(self.reconnect_delay) # Warte vor erneutem Versuch
12791279+ for _ in range(int(self.reconnect_delay * 10)):
12801280+ if not self.running:
12811281+ break
12821282+ self.msleep(100)
1317128313181284 self._cleanup()
13191285···13551321 # (manchen Kameras antworten nicht auf Port-Checks, aber auf echte RTSP-Anfragen)
13561322 self.connection_status.emit(False, self.camera_id, tr("camera.status.connecting"))
1357132313581358- # RTSP Stream öffnen (mit Fallback-Pfaden für Reolink)
13241324+ open_timeout_ms = 12000 if self._is_proxy_stream else 3000
13251325+ read_timeout_ms = 12000 if self._is_proxy_stream else 3000
13261326+13271327+ # RTSP Stream öffnen (mit Fallback-Pfaden für native Reolink-RTSP-URLs)
13591328 # Use TCP transport to reduce RTP packet loss warnings
13601329 self.cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG, [
13611361- cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 10000,
13621362- cv2.CAP_PROP_READ_TIMEOUT_MSEC, 10000
13301330+ cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, open_timeout_ms,
13311331+ cv2.CAP_PROP_READ_TIMEOUT_MSEC, read_timeout_ms
13631332 ])
1364133313651365- # Falls die Kamera nicht öffnet, probieren wir Reolink-typische Varianten (H.264/H.265/Sub)
13661366- if not self.cap.isOpened():
13341334+ # Falls eine native Kamera nicht öffnet, probieren wir Reolink-typische Varianten.
13351335+ # Bei ReolinkProxy-URLs ist der Pfad absichtlich fix (<Name>/mainStream).
13361336+ if not self.cap.isOpened() and not self._is_proxy_stream:
13671337 # Parse URL properly to rebuild with alternative paths
13681338 try:
13691339 u = urlparse(self.rtsp_url)
···1384135413851355 self.connection_status.emit(False, self.camera_id, f"Prüfe Pfad: {path}...")
13861356 self.cap = cv2.VideoCapture(test_url, cv2.CAP_FFMPEG, [
13871387- cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 10000,
13881388- cv2.CAP_PROP_READ_TIMEOUT_MSEC, 10000
13571357+ cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, open_timeout_ms,
13581358+ cv2.CAP_PROP_READ_TIMEOUT_MSEC, read_timeout_ms
13891359 ])
13901360 if self.cap.isOpened():
13911361 self.rtsp_url = test_url
···1395136513961366 if not self.cap.isOpened():
13971367 # Diagnostik: Wenn RTSP zu ist, aber Port 8000 offen, ist RTSP wahrscheinlich in der Kamera deaktiviert
13981398- if self._host:
13681368+ if self._host and not self._is_proxy_stream:
13991369 ok_api, _ = _tcp_probe(self._host, 8000, timeout=0.5)
14001370 if ok_api:
14011371 raise Exception("Kamera antwortet auf API (Port 8000), aber RTSP ist blockiert. Bitte 'RTSP' in den Kamera-Einstellungen (Netzwerk -> Fortgeschritten -> Servereinstellungen) aktivieren!")
···14981468 pass
14991469 self.video_writer = None
1500147015011501- def stop(self):
15021502- """Thread stoppen"""
14711471+ def request_stop(self):
14721472+ """Signal the stream loop to stop.
14731473+14741474+ OpenCV/FFmpeg can abort if VideoCapture is released from a different
14751475+ thread while open/read is active. The stream thread owns cleanup.
14761476+ """
15031477 self.running = False
15041504- self.wait()
14781478+ self.stop_recording()
14791479+14801480+ def stop(self, timeout_ms=2000, force=False):
14811481+ """Thread stoppen"""
14821482+ self.request_stop()
14831483+ if not self.wait(timeout_ms):
14841484+ return False
14851485+ return True
150514861506148715071488class CameraWidget(QWidget):
···17171698 def update_frame(self, frame):
17181699 """Frame aktualisieren mit FPS-Berechnung"""
17191700 self.last_frame = frame
17011701+ if self.is_selected_for_view:
17021702+ return
1720170317211704 # FPS berechnen
17221705 now = datetime.now()
···1801178418021785 def _on_checkbox_changed(self, state):
18031786 self.is_selected_for_view = (state == Qt.CheckState.Checked.value)
17871787+ if self.is_selected_for_view:
17881788+ self.video_label.setPixmap(QPixmap())
17891789+ self.video_label.setText(f"{self.camera_name}\n{tr('camera.preview.waiting')}")
18041790 self.selection_changed.emit(self.camera_id, self.is_selected_for_view)
1805179118061792 def set_selected(self, selected):
···19271913 self.next_camera_id = 1
19281914 self.selected_camera_id = None
19291915 self.selected_camera_ids = [] # Multi-Kamera-Auswahl
19161916+ self._restore_preview_camera_ids = []
19301917 self.multi_view_labels = {} # Labels für Multi-Kamera-Ansicht
19311918 self.zoomed_camera_id = None
19321919 self.preview_crop_camera_id = None
···19341921 self._rebuilding_camera_list = False
19351922 self._pending_order_apply = False
19361923 self._closing = False
19241924+ self._shutdown_started_at = None
19251925+ self._shutdown_dialog = None
19371926 self._order_custom = False
1938192719391928 # Erstelle Aufzeichnungsordner
···23352324 path="h264Preview_01_main"
23362325 )
2337232623382338- # Reolink WLAN/Battery: automatisch auf Neolink umstellen
23392339- rtsp_url = _maybe_use_neolink(
23272327+ proxy_config = _reolinkproxy_proxy_config(
23282328+ rtsp_url=rtsp_url,
23292329+ name=name,
23302330+ username=username,
23312331+ password=password,
23322332+ uid=camera_info.get('uid', ''),
23332333+ model=model,
23342334+ manufacturer=manufacturer
23352335+ )
23362336+23372337+ # Reolink WLAN/Battery: automatisch auf ReolinkProxy umstellen
23382338+ rtsp_url = _maybe_use_reolinkproxy(
23402339 rtsp_url=rtsp_url,
23412340 name=name,
23422341 username=username,
···23542353 camera_id = self.next_camera_id
23552354 self.next_camera_id += 1
2356235523572357- self.cameras.append({
23562356+ camera_entry = {
23582357 'id': camera_id,
23592358 'url': rtsp_url,
23602359 'name': name,
23612360 'uid': camera_info.get('uid', ''),
23622361 'model': model,
23632362 'manufacturer': manufacturer
23642364- })
23632363+ }
23642364+ if proxy_config:
23652365+ camera_entry['proxy'] = proxy_config
23662366+ self.cameras.append(camera_entry)
2365236723662368 # Widget erstellen
23672369 is_battery = _is_battery_camera(model, name)
···24202422 if reply == QMessageBox.StandardButton.No:
24212423 return
2422242424232423- # Reolink WLAN/Battery (Port 9000) automatisch auf Neolink umstellen
24242424- url = _maybe_use_neolink(
24252425+ proxy_config = _reolinkproxy_proxy_config(
24262426+ rtsp_url=url,
24272427+ name=camera_name,
24282428+ username="",
24292429+ password="",
24302430+ uid=uid
24312431+ )
24322432+24332433+ # Reolink WLAN/Battery (Port 9000) automatisch auf ReolinkProxy umstellen
24342434+ url = _maybe_use_reolinkproxy(
24252435 rtsp_url=url,
24262436 name=camera_name,
24272437 username="",
···24302440 )
2431244124322442 # Kamera zur Liste hinzufügen
24332433- self.cameras.append({
24432443+ camera_entry = {
24342444 'id': camera_id,
24352445 'url': url,
24362446 'name': camera_name,
24372447 'uid': uid,
24382448 'model': '' # Model info not available from manual add
24392439- })
24492449+ }
24502450+ if proxy_config:
24512451+ camera_entry['proxy'] = proxy_config
24522452+ self.cameras.append(camera_entry)
2440245324412454 # Widget erstellen
24422455 is_battery = _is_battery_camera('', camera_name)
···24732486 thread = self.camera_threads[camera_id]
24742487 if thread.isRunning():
24752488 was_running = True
24762476- thread.stop()
24772477- del self.camera_threads[camera_id]
24892489+ if thread.stop(timeout_ms=3000):
24902490+ del self.camera_threads[camera_id]
24912491+ else:
24922492+ QMessageBox.warning(self, tr("dialog.title.error"), "Stream wird noch beendet. Bitte gleich erneut versuchen.")
24932493+ return
2478249424792495 # Edit Dialog öffnen
24802496 dialog = CameraEditDialog(camera_data, self)
···24822498 if dialog.exec() == QDialog.DialogCode.Accepted:
24832499 updated_data = dialog.get_camera_data()
2484250024852485- # Reolink WLAN/Battery (Port 9000) automatisch auf Neolink umstellen
24862486- updated_data['url'] = _maybe_use_neolink(
25012501+ proxy_config = _reolinkproxy_proxy_config(
25022502+ rtsp_url=updated_data.get('url', ''),
25032503+ name=updated_data.get('name', ''),
25042504+ username="",
25052505+ password="",
25062506+ uid=updated_data.get('uid', ''),
25072507+ model=camera_data.get('model', ''),
25082508+ manufacturer=camera_data.get('manufacturer', '')
25092509+ )
25102510+25112511+ # Reolink WLAN/Battery (Port 9000) automatisch auf ReolinkProxy umstellen
25122512+ updated_data['url'] = _maybe_use_reolinkproxy(
24872513 rtsp_url=updated_data.get('url', ''),
24882514 name=updated_data.get('name', ''),
24892515 username="",
···24922518 model=camera_data.get('model', ''),
24932519 manufacturer=camera_data.get('manufacturer', '')
24942520 )
25212521+ if proxy_config:
25222522+ updated_data['proxy'] = proxy_config
2495252324962524 # Daten aktualisieren
24972525 for camera in self.cameras:
···2563259125642592 def stop_single_stream(self, camera_id):
25652593 if camera_id in self.camera_threads:
25662566- self.camera_threads[camera_id].stop()
25672567- del self.camera_threads[camera_id]
25942594+ if self.camera_threads[camera_id].stop(timeout_ms=3000):
25952595+ del self.camera_threads[camera_id]
25962596+ else:
25972597+ self.statusBar().showMessage("Stream wird noch beendet...")
25982598+ return
2568259925692600 if self.preview_crop_camera_id == camera_id:
25702601 self._clear_big_preview_crop()
···2612264326132644 # Thread stoppen falls aktiv
26142645 if camera_id in self.camera_threads:
26152615- self.camera_threads[camera_id].stop()
26162616- del self.camera_threads[camera_id]
26462646+ if self.camera_threads[camera_id].stop(timeout_ms=3000):
26472647+ del self.camera_threads[camera_id]
26482648+ else:
26492649+ QMessageBox.warning(self, tr("dialog.title.error"), "Stream wird noch beendet. Bitte gleich erneut versuchen.")
26502650+ return
2617265126182652 # Widget entfernen
26192653 if camera_id in self.camera_widgets:
···2737277127382772 def stop_all_streams(self):
27392773 """Alle Streams stoppen"""
27402740- for thread in list(self.camera_threads.values()):
27412741- thread.stop()
27422742- self.camera_threads.clear()
27742774+ threads = list(self.camera_threads.values())
27752775+ force_stop = bool(getattr(self, "_closing", False))
27762776+27772777+ for thread in threads:
27782778+ thread.request_stop()
27792779+27802780+ deadline = time.monotonic() + (2.5 if force_stop else 5.0)
27812781+ for thread in threads:
27822782+ remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
27832783+ if thread.isRunning():
27842784+ thread.wait(remaining_ms)
27852785+ self.camera_threads = {
27862786+ camera_id: thread
27872787+ for camera_id, thread in self.camera_threads.items()
27882788+ if thread.isRunning()
27892789+ }
27902790+ if self.camera_threads:
27912791+ self.statusBar().showMessage("Streams werden noch beendet...")
27922792+ return
27432793 self._clear_big_preview_crop()
2744279427452795 for camera_id, widget in list(self.camera_widgets.items()):
···28722922 'next_camera_id': self.next_camera_id,
28732923 'language': self.language,
28742924 'order_custom': self._order_custom,
29252925+ 'preview_camera_ids': list(self.selected_camera_ids),
29262926+ 'selected_camera_id': self.selected_camera_id,
28752927 }
28762928 try:
28772929 with open('camera_config.json', 'w') as f:
···29442996 fixed_config = True # Sorgen wir dafür, dass es gespeichert wird
29452997 deduped_by_url.append(c)
2946299829472947- # Reolink WLAN/Baichuan URLs beim Laden automatisch auf Neolink umstellen
29992999+ # Reolink WLAN/Baichuan URLs beim Laden automatisch auf ReolinkProxy umstellen
29483000 for c in deduped_by_url:
29493001 url = (c.get('url') or '').strip()
29503002 if not url:
29513003 continue
29522952- new_url = _maybe_use_neolink(
30043004+ proxy_config = _reolinkproxy_proxy_config(
30053005+ rtsp_url=url,
30063006+ name=c.get('name', ''),
30073007+ username="",
30083008+ password="",
30093009+ uid=c.get('uid', ''),
30103010+ model=c.get('model', ''),
30113011+ manufacturer=c.get('manufacturer', '')
30123012+ )
30133013+ new_url = _maybe_use_reolinkproxy(
29533014 rtsp_url=url,
29543015 name=c.get('name', ''),
29553016 username="",
···29603021 )
29613022 if new_url != url:
29623023 c['url'] = new_url
30243024+ fixed_config = True
30253025+ if proxy_config and not c.get('proxy'):
30263026+ c['proxy'] = proxy_config
29633027 fixed_config = True
2964302829653029 self.cameras = deduped_by_url
29663030 self.recording_path = config.get('recording_path', self.recording_path)
29673031 self.snapshot_path = os.path.join(self.recording_path, "snapshots")
29683032 self.cameras_per_row = config.get('cameras_per_row', 3)
30333033+ valid_camera_ids = {int(c.get('id')) for c in self.cameras if c.get('id') is not None}
30343034+ preview_ids = []
30353035+ for cid in config.get('preview_camera_ids', []):
30363036+ try:
30373037+ cid = int(cid)
30383038+ except Exception:
30393039+ continue
30403040+ if cid in valid_camera_ids and cid not in preview_ids:
30413041+ preview_ids.append(cid)
30423042+ self._restore_preview_camera_ids = preview_ids
30433043+ try:
30443044+ selected_camera_id = int(config.get('selected_camera_id')) if config.get('selected_camera_id') is not None else None
30453045+ except Exception:
30463046+ selected_camera_id = None
30473047+ self.selected_camera_id = selected_camera_id if selected_camera_id in valid_camera_ids else None
29693048 self._order_custom = bool(config.get('order_custom', False))
29703049 if not self._order_custom:
29713050 try:
···29933072 self.update_grid_layout()
29943073 self.update_status_display()
29953074 self.retranslate_ui()
30753075+ self._restore_preview_state()
2996307629973077 if fixed_config:
29983078 self.save_config()
···3003308330043084 def closeEvent(self, event):
30053085 """Beim Schließen alle Threads sauber beenden"""
30063006- self._closing = True
30073007- self.save_config()
30083008- self.stop_all_streams()
30093009- event.accept()
30863086+ running_threads = [t for t in self.camera_threads.values() if t.isRunning()]
30873087+ if not running_threads:
30883088+ event.accept()
30893089+ return
30903090+30913091+ if not self._closing:
30923092+ self._closing = True
30933093+ self._shutdown_started_at = time.monotonic()
30943094+ self.save_config()
30953095+ for thread in running_threads:
30963096+ thread.request_stop()
30973097+ self.setEnabled(False)
30983098+ self._show_shutdown_dialog()
30993099+ QTimer.singleShot(100, self._finish_close_when_streams_stopped)
31003100+31013101+ event.ignore()
31023102+31033103+ def _finish_close_when_streams_stopped(self):
31043104+ self.camera_threads = {
31053105+ camera_id: thread
31063106+ for camera_id, thread in self.camera_threads.items()
31073107+ if thread.isRunning()
31083108+ }
31093109+ if self.camera_threads:
31103110+ elapsed = time.monotonic() - (self._shutdown_started_at or time.monotonic())
31113111+ message = f"Closing app. Please wait...\nStopping camera streams ({elapsed:.1f}s)"
31123112+ self.statusBar().showMessage(message.replace("\n", " "))
31133113+ if self._shutdown_dialog is not None:
31143114+ self._shutdown_dialog.setLabelText(message)
31153115+ QTimer.singleShot(100, self._finish_close_when_streams_stopped)
31163116+ return
31173117+31183118+ if self._shutdown_dialog is not None:
31193119+ self._shutdown_dialog.close()
31203120+ self._shutdown_dialog = None
31213121+ self.close()
31223122+31233123+ def _show_shutdown_dialog(self):
31243124+ self.statusBar().showMessage("Closing app. Please wait...")
31253125+ dialog = QProgressDialog("Closing app. Please wait...\nStopping camera streams", None, 0, 0, self)
31263126+ dialog.setWindowTitle("Closing app")
31273127+ dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
31283128+ dialog.setCancelButton(None)
31293129+ dialog.setMinimumDuration(0)
31303130+ dialog.setAutoClose(False)
31313131+ dialog.setAutoReset(False)
31323132+ dialog.show()
31333133+ self._shutdown_dialog = dialog
3010313430113135 def select_camera(self, camera_id):
30123136 # If multi-view is active (checkboxes), don't use old single-camera selection
···30383162 self.start_single_stream(camera_id)
30393163 else:
30403164 self._refresh_big_preview_from_last_frame(camera_id)
31653165+ self.save_config()
31663166+31673167+ def _restore_preview_state(self):
31683168+ restore_ids = [
31693169+ cid for cid in getattr(self, "_restore_preview_camera_ids", [])
31703170+ if cid in self.camera_widgets
31713171+ ]
31723172+ if restore_ids:
31733173+ self.selected_camera_ids = restore_ids
31743174+ self.selected_camera_id = None
31753175+ for cid, widget in self.camera_widgets.items():
31763176+ checked = cid in restore_ids
31773177+ widget.view_checkbox.blockSignals(True)
31783178+ widget.view_checkbox.setChecked(checked)
31793179+ widget.is_selected_for_view = checked
31803180+ widget.view_checkbox.blockSignals(False)
31813181+ widget.set_selected(False)
31823182+ if checked:
31833183+ widget.video_label.setPixmap(QPixmap())
31843184+ widget.video_label.setText(f"{widget.camera_name}\n{tr('camera.preview.waiting')}")
31853185+ self._rebuild_multi_view_layout()
31863186+ QTimer.singleShot(300, self._start_restored_preview_streams)
31873187+ return
31883188+31893189+ if self.selected_camera_id in self.camera_widgets:
31903190+ camera_id = self.selected_camera_id
31913191+ for cid, widget in self.camera_widgets.items():
31923192+ widget.set_selected(cid == camera_id)
31933193+ widget = self.camera_widgets.get(camera_id)
31943194+ if widget and hasattr(self, "big_preview_label"):
31953195+ self.big_preview_label.clear_frame_display_rect()
31963196+ self.big_preview_label.setText(f"{widget.camera_name}\n{tr('camera.preview.waiting')}")
31973197+ QTimer.singleShot(300, lambda cid=camera_id: self.start_single_stream(cid))
31983198+31993199+ def _start_restored_preview_streams(self):
32003200+ for cid in list(self.selected_camera_ids):
32013201+ if cid not in self.camera_threads or not self.camera_threads[cid].isRunning():
32023202+ self.start_single_stream(cid)
3041320330423204 def _on_language_changed(self):
30433205 if not hasattr(self, "language_combo"):
···31453307 self._sync_big_preview_crop_state(self._get_active_big_preview_camera_id())
3146330831473309 self._rebuild_multi_view_layout()
33103310+ self.save_config()
3148331131493312 # Start streams for selected cameras
31503313 for cid in self.selected_camera_ids:
···3352351533533516 # Rebuild the multi-view layout
33543517 self._rebuild_multi_view_layout()
35183518+ self.save_config()
3355351933563520 def _on_big_preview_label_double_clicked(self, camera_id):
33573521 if self.zoomed_camera_id is None:
33583522 if len(self.selected_camera_ids) > 1 and camera_id in self.selected_camera_ids:
33593523 self.zoomed_camera_id = camera_id
33603524 self._rebuild_multi_view_layout()
35253525+ self.save_config()
33613526 return
3362352733633528 active_camera_id = self._get_active_big_preview_camera_id()
···33733538 if self.zoomed_camera_id is not None:
33743539 self.zoomed_camera_id = None
33753540 self._rebuild_multi_view_layout()
35413541+ self.save_config()
3376354233773543 def _on_big_preview_region_selected(self, camera_id, selection_rect):
33783544 active_camera_id = self._get_active_big_preview_camera_id()
-224
neolink_manager.py
···11-#!/usr/bin/env python3
22-"""
33-Neolink Manager für WildCam
44-Generiert automatisch neolink.toml aus camera_config.json für Battery-Kameras (Port 9000)
55-"""
66-import json
77-import re
88-import sys
99-from pathlib import Path
1010-from urllib.parse import urlparse, unquote
1111-1212-def parse_rtsp_url(rtsp_url):
1313- """Parse RTSP URL und extrahiere Komponenten.
1414-1515- Hinweis: '#' in Passwörtern muss normalerweise URL-encoded (%23) sein.
1616- Wir erlauben hier bewusst allow_fragments=False, damit unescaped '#'
1717- nicht als Fragment abgeschnitten wird.
1818- """
1919- try:
2020- u = urlparse(rtsp_url, allow_fragments=False)
2121- if not u.hostname and "#" in rtsp_url:
2222- sanitized = rtsp_url.replace("#", "%23")
2323- u = urlparse(sanitized, allow_fragments=False)
2424- if u.hostname:
2525- return {
2626- 'host': u.hostname,
2727- 'port': u.port or 554,
2828- 'username': u.username or '',
2929- 'password': u.password or '',
3030- 'scheme': u.scheme
3131- }
3232- except Exception:
3333- pass
3434-3535- # Fallback: manuelles Parsing, falls urlparse versagt (z.B. unescaped '#')
3636- try:
3737- pattern = r'^[a-z]+://(?:([^:@/]+)(?::([^@/]*))?@)?([^:/]+)(?::(\d+))?'
3838- match = re.match(pattern, rtsp_url, re.IGNORECASE)
3939- if not match:
4040- return None
4141- username = unquote(match.group(1) or '')
4242- password = unquote(match.group(2) or '')
4343- host = match.group(3)
4444- port = int(match.group(4) or 554)
4545- return {
4646- 'host': host,
4747- 'port': port,
4848- 'username': username,
4949- 'password': password,
5050- 'scheme': 'rtsp'
5151- }
5252- except Exception:
5353- return None
5454-5555-def is_baichuan_camera(camera):
5656- """Prüfe ob Kamera Baichuan-Protokoll (Port 9000) nutzt."""
5757- url_info = parse_rtsp_url(camera.get('url', ''))
5858- if not url_info:
5959- return False
6060- return url_info['port'] == 9000
6161-6262-def generate_neolink_config(camera_config_path, output_path='neolink.toml'):
6363- """Generiere neolink.toml aus camera_config.json."""
6464-6565- # Lade camera_config.json
6666- try:
6767- with open(camera_config_path, 'r', encoding='utf-8') as f:
6868- config = json.load(f)
6969- except FileNotFoundError:
7070- print(f"❌ Fehler: {camera_config_path} nicht gefunden!")
7171- return False
7272- except json.JSONDecodeError as e:
7373- print(f"❌ Fehler beim Parsen der Config: {e}")
7474- return False
7575-7676- cameras = config.get('cameras', [])
7777-7878- # Filtere Battery-Kameras (Port 9000)
7979- battery_cameras = [cam for cam in cameras if is_baichuan_camera(cam)]
8080-8181- if not battery_cameras:
8282- print("ℹ️ Keine Battery-Kameras (Port 9000) gefunden.")
8383- print(" Neolink wird nicht benötigt.")
8484- return False
8585-8686- print(f"🔋 {len(battery_cameras)} Battery-Kamera(s) gefunden:")
8787- for cam in battery_cameras:
8888- print(f" - {cam['name']} ({cam['url']})")
8989-9090- # Generiere neolink.toml
9191- toml_content = """# Auto-generated by WildCam neolink_manager.py
9292-# This file is automatically created from camera_config.json
9393-9494-bind = "0.0.0.0"
9595-bind_port = 8554
9696-9797-"""
9898-9999- for cam in battery_cameras:
100100- url_info = parse_rtsp_url(cam['url'])
101101- if not url_info:
102102- continue
103103-104104- cam_name = cam['name'].replace(' ', '_')
105105- uid = cam.get('uid', '')
106106-107107- toml_content += f"""
108108-[[cameras]]
109109-name = "{cam_name}"
110110-username = "{url_info['username']}"
111111-password = "{url_info['password']}"
112112-address = "{url_info['host']}:9000"
113113-"""
114114-115115- if uid:
116116- toml_content += f'uid = "{uid}"\n'
117117-118118- toml_content += """
119119-# Battery optimization
120120-idle_disconnect = true
121121-122122-pause.on_client = true
123123-pause.timeout = 2.1
124124-125125-"""
126126-127127- # Schreibe neolink.toml
128128- try:
129129- with open(output_path, 'w', encoding='utf-8') as f:
130130- f.write(toml_content)
131131- print(f"✅ {output_path} erfolgreich erstellt!")
132132- return True
133133- except Exception as e:
134134- print(f"❌ Fehler beim Schreiben: {e}")
135135- return False
136136-137137-def update_camera_config(camera_config_path):
138138- """Aktualisiere camera_config.json: Ersetze Port 9000 URLs mit localhost:8554."""
139139-140140- try:
141141- with open(camera_config_path, 'r', encoding='utf-8') as f:
142142- config = json.load(f)
143143- except Exception as e:
144144- print(f"❌ Fehler beim Laden der Config: {e}")
145145- return False
146146-147147- cameras = config.get('cameras', [])
148148- updated = False
149149-150150- for cam in cameras:
151151- url_info = parse_rtsp_url(cam['url'])
152152- if url_info and url_info['port'] == 9000:
153153- # Ersetze mit Neolink localhost URL
154154- cam_name = cam['name'].replace(' ', '_')
155155- new_url = f"rtsp://localhost:8554/{cam_name}/mainStream"
156156-157157- print(f"🔄 {cam['name']}: {cam['url']}")
158158- print(f" → {new_url}")
159159-160160- cam['url'] = new_url
161161- updated = True
162162-163163- if updated:
164164- # Backup erstellen
165165- backup_path = f"{camera_config_path}.backup"
166166- try:
167167- with open(camera_config_path, 'r') as f:
168168- with open(backup_path, 'w') as backup:
169169- backup.write(f.read())
170170- print(f"💾 Backup erstellt: {backup_path}")
171171- except Exception:
172172- pass
173173-174174- # Aktualisierte Config speichern
175175- try:
176176- with open(camera_config_path, 'w', encoding='utf-8') as f:
177177- json.dump(config, f, indent=2, ensure_ascii=False)
178178- print(f"✅ {camera_config_path} aktualisiert!")
179179- return True
180180- except Exception as e:
181181- print(f"❌ Fehler beim Speichern: {e}")
182182- return False
183183- else:
184184- print("ℹ️ Keine Updates nötig.")
185185- return False
186186-187187-def main():
188188- script_dir = Path(__file__).parent
189189- config_path = script_dir / 'camera_config.json'
190190-191191- print("🚀 WildCam Neolink Manager")
192192- print("=" * 50)
193193-194194- # 1. Generiere neolink.toml
195195- print("\n📝 Schritt 1: Generiere neolink.toml...")
196196- success = generate_neolink_config(config_path)
197197-198198- if not success:
199199- sys.exit(0)
200200-201201- # 2. Frage ob Config aktualisiert werden soll
202202- print("\n" + "=" * 50)
203203- print("📋 Schritt 2: Camera Config aktualisieren?")
204204- print(" Ersetzt Port 9000 URLs mit localhost:8554 (Neolink)")
205205-206206- if '--auto-update' in sys.argv or '--yes' in sys.argv:
207207- update = True
208208- else:
209209- response = input(" Fortfahren? [j/N]: ").lower()
210210- update = response in ['j', 'ja', 'y', 'yes']
211211-212212- if update:
213213- update_camera_config(config_path)
214214- else:
215215- print("ℹ️ Übersprungen. URLs bleiben unverändert.")
216216-217217- print("\n" + "=" * 50)
218218- print("✨ Fertig!")
219219- print("\nNächste Schritte:")
220220- print(" 1. docker-compose up -d # Startet Neolink")
221221- print(" 2. python main.py # Startet WildCam")
222222-223223-if __name__ == '__main__':
224224- main()
+219
reolinkproxy_manager.py
···11+#!/usr/bin/env python3
22+"""
33+ReolinkProxy Manager fuer WildCam.
44+55+Generiert reolinkproxy.env ausschliesslich aus camera_config.json.
66+"""
77+import json
88+import re
99+import sys
1010+from pathlib import Path
1111+from urllib.parse import urlparse, unquote
1212+1313+1414+def parse_rtsp_url(rtsp_url):
1515+ try:
1616+ u = urlparse(rtsp_url, allow_fragments=False)
1717+ if not u.hostname and "#" in rtsp_url:
1818+ u = urlparse(rtsp_url.replace("#", "%23"), allow_fragments=False)
1919+ if u.hostname:
2020+ return {
2121+ "host": u.hostname,
2222+ "port": u.port or 554,
2323+ "username": unquote(u.username or ""),
2424+ "password": unquote(u.password or ""),
2525+ "scheme": u.scheme,
2626+ }
2727+ except Exception:
2828+ pass
2929+3030+ try:
3131+ pattern = r"^[a-z]+://(?:([^:@/]+)(?::([^@/]*))?@)?([^:/]+)(?::(\d+))?"
3232+ match = re.match(pattern, rtsp_url, re.IGNORECASE)
3333+ if not match:
3434+ return None
3535+ return {
3636+ "host": match.group(3),
3737+ "port": int(match.group(4) or 554),
3838+ "username": unquote(match.group(1) or ""),
3939+ "password": unquote(match.group(2) or ""),
4040+ "scheme": "rtsp",
4141+ }
4242+ except Exception:
4343+ return None
4444+4545+4646+def camera_name(name):
4747+ return (name or "Camera").strip().replace(" ", "_")
4848+4949+5050+def is_proxy_url(rtsp_url):
5151+ info = parse_rtsp_url(rtsp_url or "")
5252+ return bool(info and info["host"] in ("localhost", "127.0.0.1") and info["port"] == 8554)
5353+5454+5555+def is_reolinkproxy_camera(camera):
5656+ proxy = camera.get("proxy") or {}
5757+ if proxy.get("type") == "reolinkproxy":
5858+ return True
5959+6060+ url_info = parse_rtsp_url(camera.get("url", ""))
6161+ if url_info and url_info["port"] == 9000:
6262+ return True
6363+ return False
6464+6565+6666+def env_value(value):
6767+ value = "" if value is None else str(value)
6868+ value = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
6969+ return f'"{value}"'
7070+7171+7272+def make_proxy_from_rtsp(camera):
7373+ info = parse_rtsp_url(camera.get("url", ""))
7474+ if not info or is_proxy_url(camera.get("url", "")):
7575+ return None
7676+7777+ return {
7878+ "type": "reolinkproxy",
7979+ "host": info["host"],
8080+ "port": info["port"],
8181+ "username": info["username"],
8282+ "password": info["password"],
8383+ "stream": "main",
8484+ "battery": True,
8585+ "pause_on_client": True,
8686+ "idle_disconnect": True,
8787+ "idle_timeout": "30s",
8888+ }
8989+9090+9191+def write_env(cameras, output_path):
9292+ lines = [
9393+ "# Auto-generated by WildCam reolinkproxy_manager.py",
9494+ "# Existing WildCam RTSP paths are preserved with REOLINK_CAMERA_N_RTSP_PATH.",
9595+ "REOLINK_HEALTHCHECK_RTSP_ONLY=true",
9696+ "",
9797+ ]
9898+9999+ proxy_cameras = [cam for cam in cameras if is_reolinkproxy_camera(cam)]
100100+ incomplete_proxy_cameras = [
101101+ cam for cam in cameras
102102+ if is_proxy_url(cam.get("url", "")) and (cam.get("proxy") or {}).get("type") != "reolinkproxy"
103103+ ]
104104+ if incomplete_proxy_cameras:
105105+ names = ", ".join(camera_name(cam.get("name", "")) for cam in incomplete_proxy_cameras)
106106+ print(f"Unvollstaendige Proxy-Konfiguration fuer: {names}")
107107+ print("Bitte in camera_config.json je Kamera einen proxy-Block mit host/username/password ergaenzen.")
108108+ return False
109109+ if not proxy_cameras:
110110+ print("Keine ReolinkProxy-Kameras gefunden.")
111111+ return False
112112+113113+ print(f"{len(proxy_cameras)} ReolinkProxy-Kamera(s) gefunden:")
114114+115115+ for index, cam in enumerate(proxy_cameras):
116116+ name = camera_name(cam.get("name", f"Camera_{index}"))
117117+ proxy = cam.get("proxy") or make_proxy_from_rtsp(cam) or {}
118118+119119+ use_uid_only = bool(proxy.get("uid_only", False))
120120+ host = "" if use_uid_only else proxy.get("host", "")
121121+ port = int(proxy.get("port") or 9000)
122122+ username = proxy.get("username", "")
123123+ password = proxy.get("password", "")
124124+ stream = proxy.get("stream", "main")
125125+ uid = proxy.get("uid") or cam.get("uid", "")
126126+ battery = bool(proxy.get("battery", True))
127127+ pause_on_client = bool(proxy.get("pause_on_client", True))
128128+ idle_disconnect = bool(proxy.get("idle_disconnect", True))
129129+ idle_timeout = proxy.get("idle_timeout", "30s")
130130+131131+ print(f" - {name}: host={host or 'UID-only'}, uid={uid or '-'}")
132132+133133+ lines.extend(
134134+ [
135135+ f"REOLINK_CAMERA_{index}_NAME={env_value(name)}",
136136+ f"REOLINK_CAMERA_{index}_PORT={port}",
137137+ f"REOLINK_CAMERA_{index}_USERNAME={env_value(username)}",
138138+ f"REOLINK_CAMERA_{index}_PASSWORD={env_value(password)}",
139139+ f"REOLINK_CAMERA_{index}_STREAM={env_value(stream)}",
140140+ f"REOLINK_CAMERA_{index}_RTSP_PATH={env_value(f'{name}/mainStream')}",
141141+ f"REOLINK_CAMERA_{index}_BATTERY_CAMERA={str(battery).lower()}",
142142+ f"REOLINK_CAMERA_{index}_PAUSE_ON_CLIENT={str(pause_on_client).lower()}",
143143+ f"REOLINK_CAMERA_{index}_IDLE_DISCONNECT={str(idle_disconnect).lower()}",
144144+ f"REOLINK_CAMERA_{index}_IDLE_TIMEOUT={env_value(idle_timeout)}",
145145+ ]
146146+ )
147147+ if host:
148148+ lines.append(f"REOLINK_CAMERA_{index}_HOST={env_value(host)}")
149149+ if uid:
150150+ lines.append(f"REOLINK_CAMERA_{index}_UID={env_value(uid)}")
151151+ lines.append("")
152152+153153+ output_path.write_text("\n".join(lines), encoding="utf-8")
154154+ print(f"{output_path} geschrieben.")
155155+ return True
156156+157157+158158+def update_camera_config(camera_config_path):
159159+ try:
160160+ config = json.loads(camera_config_path.read_text(encoding="utf-8"))
161161+ except Exception as e:
162162+ print(f"Fehler beim Laden der Config: {e}")
163163+ return False
164164+165165+ updated = False
166166+ for cam in config.get("cameras", []):
167167+ if not is_reolinkproxy_camera(cam):
168168+ continue
169169+ name = camera_name(cam.get("name", ""))
170170+ if "proxy" not in cam:
171171+ proxy = make_proxy_from_rtsp(cam)
172172+ if proxy:
173173+ cam["proxy"] = proxy
174174+ updated = True
175175+ new_url = f"rtsp://localhost:8554/{name}/mainStream"
176176+ if cam.get("url") != new_url:
177177+ print(f"{cam.get('name', name)}: {cam.get('url')} -> {new_url}")
178178+ cam["url"] = new_url
179179+ updated = True
180180+181181+ if not updated:
182182+ return False
183183+184184+ backup_path = camera_config_path.with_suffix(camera_config_path.suffix + ".backup")
185185+ backup_path.write_text(camera_config_path.read_text(encoding="utf-8"), encoding="utf-8")
186186+ camera_config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
187187+ print(f"{camera_config_path} aktualisiert, Backup: {backup_path}")
188188+ return True
189189+190190+191191+def main():
192192+ script_dir = Path(__file__).parent
193193+ config_path = script_dir / "camera_config.json"
194194+ env_path = script_dir / "reolinkproxy.env"
195195+196196+ print("WildCam ReolinkProxy Manager")
197197+ print("=" * 50)
198198+199199+ try:
200200+ config = json.loads(config_path.read_text(encoding="utf-8"))
201201+ except FileNotFoundError:
202202+ print(f"Fehler: {config_path} nicht gefunden.")
203203+ sys.exit(0)
204204+ except json.JSONDecodeError as e:
205205+ print(f"Fehler beim Parsen der Config: {e}")
206206+ sys.exit(1)
207207+208208+ success = write_env(config.get("cameras", []), env_path)
209209+ if not success:
210210+ sys.exit(0)
211211+212212+ if "--auto-update" in sys.argv or "--yes" in sys.argv:
213213+ update_camera_config(config_path)
214214+ else:
215215+ print("camera_config.json bleibt unveraendert. Nutze --auto-update zum Umschreiben alter Port-9000-URLs.")
216216+217217+218218+if __name__ == "__main__":
219219+ main()
+1-5
scripts/build_linux.sh
···7272 exit 1
7373fi
74747575-for extra_file in README.md docker-compose.yml neolink_manager.py camera_config.json.example; do
7575+for extra_file in README.md docker-compose.yml reolinkproxy_manager.py camera_config.json.example; do
7676 if [[ -f "$extra_file" ]]; then
7777 cp "$extra_file" "$BUNDLE_PATH/"
7878 fi
7979done
8080-8181-if [[ -f "neolink.toml" ]]; then
8282- cp "neolink.toml" "$BUNDLE_PATH/"
8383-fi
84808581APPDIR_PATH="$BUILD_DIR/${APP_NAME}.AppDir"
8682APPDIR_USR_BIN="$APPDIR_PATH/usr/bin"
+1-4
scripts/build_macos.sh
···7676fi
77777878if [[ -d "$BUNDLE_PATH" ]]; then
7979- for extra_file in README.md docker-compose.yml neolink_manager.py camera_config.json.example; do
7979+ for extra_file in README.md docker-compose.yml reolinkproxy_manager.py camera_config.json.example; do
8080 if [[ -f "$extra_file" ]]; then
8181 cp "$extra_file" "$BUNDLE_PATH/"
8282 fi
8383 done
84848585- if [[ -f "neolink.toml" ]]; then
8686- cp "neolink.toml" "$BUNDLE_PATH/"
8787- fi
8885fi
89869087ZIP_PATH="$DIST_DIR/${APP_NAME}_${PLATFORM}_${ARTIFACT_SUFFIX}.zip"