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