About Multi-camera viewer optimized for RTSP streams
1import os
2
3os.environ['OPENCV_FFMPEG_LOGLEVEL'] = '-8'
4os.environ['OPENCV_LOG_LEVEL'] = 'SILENT'
5os.environ['AV_LOG_FORCE_NOCOLOR'] = '1'
6os.environ['AV_LOG_FORCE_LEVEL'] = '-8'
7os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'rtsp_transport;tcp|loglevel;quiet'
8
9import cv2
10import numpy as np
11import threading
12from PyQt6.QtWidgets import (
13 QComboBox,
14 QCheckBox,
15 QDialog,
16 QDoubleSpinBox,
17 QFileDialog,
18 QGroupBox,
19 QHBoxLayout,
20 QLabel,
21 QLineEdit,
22 QMainWindow,
23 QMessageBox,
24 QProgressDialog,
25 QPushButton,
26 QScrollArea,
27 QSizePolicy,
28 QSpinBox,
29 QSplitter,
30 QStyle,
31 QTabWidget,
32 QVBoxLayout,
33 QWidget,
34)
35from PyQt6.QtCore import QEvent, QRect, QSize, Qt, QTimer, pyqtSignal
36from PyQt6.QtGui import QImage, QPixmap
37from datetime import datetime
38import time
39
40from camera_utils import (
41 _build_rtsp_url,
42 _is_battery_camera,
43 normalize_reolinkproxy_camera,
44)
45from config import DEFAULT_RECORDING_PATH, config_payload, load_config_data, save_config_data, snapshot_path_for
46from detection import DEFAULT_DETECTION_CONFIG, DetectionWorker, default_model_dir, prepare_model_path
47from dialogs import CameraDiscoveryDialog, CameraEditDialog
48from i18n import set_language, tr
49from notifications import DEFAULT_EMAIL_CONFIG, send_detection_email
50from stream import CameraThread
51from ui_resources import load_svg_icon
52from widgets import CameraListContainer, CameraWidget, PreviewLabel
53
54try:
55 import sip # type: ignore
56except Exception: # pragma: no cover
57 try:
58 from PyQt6 import sip # type: ignore
59 except Exception: # pragma: no cover
60 sip = None
61
62
63class MainWindow(QMainWindow):
64 """Hauptfenster der Anwendung"""
65 email_test_finished = pyqtSignal(bool, str)
66 model_test_finished = pyqtSignal(bool, str, str)
67
68 def __init__(self):
69 super().__init__()
70 self.language = "de"
71 set_language(self.language)
72 self.setWindowTitle(tr("app.title"))
73 self.setGeometry(100, 100, 1200, 800)
74
75 self.cameras = []
76 self.camera_threads = {} # Dict für parallele Thread-Verwaltung
77 self.camera_widgets = {} # Dict für Widget-Zugriff
78 self.recording_path = DEFAULT_RECORDING_PATH
79 self.snapshot_path = snapshot_path_for(self.recording_path)
80 self.event_path = os.path.join(self.recording_path, "events")
81 self.detection_config = dict(DEFAULT_DETECTION_CONFIG)
82 self.email_config = dict(DEFAULT_EMAIL_CONFIG)
83 self.detection_worker = None
84 self._model_retry_delays = [30, 120, 300]
85 self._model_retry_attempt = 0
86 self._model_retry_scheduled = False
87 self.cameras_per_row = 3 # Standard: 3 Kameras pro Reihe
88 self.next_camera_id = 1
89 self.selected_camera_id = None
90 self.selected_camera_ids = [] # Multi-Kamera-Auswahl
91 self._restore_preview_camera_ids = []
92 self.multi_view_labels = {} # Labels für Multi-Kamera-Ansicht
93 self.zoomed_camera_id = None
94 self.preview_crop_camera_id = None
95 self.preview_crop_rect = None
96 self._rebuilding_camera_list = False
97 self._closing = False
98 self._shutdown_started_at = None
99 self._shutdown_dialog = None
100 self._order_custom = False
101 self.email_test_finished.connect(self._on_email_test_finished)
102 self.model_test_finished.connect(self._on_model_test_finished)
103
104 # Erstelle Aufzeichnungsordner
105 os.makedirs(self.recording_path, exist_ok=True)
106 os.makedirs(self.snapshot_path, exist_ok=True)
107 os.makedirs(self.event_path, exist_ok=True)
108
109 self.init_ui()
110 self.load_config()
111
112 def init_ui(self):
113 """UI initialisieren"""
114 central_widget = QWidget()
115 self.setCentralWidget(central_widget)
116 main_layout = QVBoxLayout(central_widget)
117
118 tabs = QTabWidget()
119 tab_cameras = QWidget()
120 tab_config = QWidget()
121 self.tabs = tabs
122 tabs.addTab(tab_cameras, tr("tab.cameras"))
123 tabs.addTab(tab_config, tr("tab.config"))
124 main_layout.addWidget(tabs)
125
126 cameras_layout = QVBoxLayout(tab_cameras)
127 config_tab_layout = QVBoxLayout(tab_config)
128 cameras_layout.setContentsMargins(6, 6, 6, 6)
129 cameras_layout.setSpacing(6)
130
131 # Konfigurations-Panel
132 config_group = QGroupBox(tr("group.camera_config"))
133 self.config_group = config_group
134 config_layout = QVBoxLayout()
135
136 # Erste Zeile: URL und Name
137 row1 = QHBoxLayout()
138 self.url_label = QLabel(tr("label.rtsp_url"))
139 row1.addWidget(self.url_label)
140 self.url_input = QLineEdit()
141 self.url_input.setPlaceholderText(tr("placeholder.rtsp_url"))
142 self.url_input.setMinimumWidth(400)
143 row1.addWidget(self.url_input)
144
145 self.name_label = QLabel(tr("label.name"))
146 row1.addWidget(self.name_label)
147 self.name_input = QLineEdit()
148 self.name_input.setPlaceholderText(tr("placeholder.name.short"))
149 self.name_input.setMaximumWidth(150)
150 row1.addWidget(self.name_input)
151
152 self.uid_label = QLabel(tr("label.uid"))
153 row1.addWidget(self.uid_label)
154 self.uid_input = QLineEdit()
155 self.uid_input.setPlaceholderText(tr("placeholder.uid"))
156 self.uid_input.setMaximumWidth(150)
157 row1.addWidget(self.uid_input)
158
159 self.add_btn = QPushButton(tr("btn.add"))
160 add_btn = self.add_btn
161 add_btn.clicked.connect(self.add_camera)
162 add_btn.setIcon(load_svg_icon("plus.svg"))
163 add_btn.setIconSize(QSize(18, 18))
164 row1.addWidget(add_btn)
165
166 self.discover_btn = QPushButton(tr("btn.discover"))
167 discover_btn = self.discover_btn
168 discover_btn.clicked.connect(self.show_discovery_dialog)
169 discover_btn.setIcon(load_svg_icon("search.svg"))
170 discover_btn.setIconSize(QSize(18, 18))
171 discover_btn.setStyleSheet("background-color: #1976d2; color: white; font-weight: bold;")
172 row1.addWidget(discover_btn)
173
174 config_layout.addLayout(row1)
175
176 # Zweite Zeile: Grid-Einstellungen und Aktionen
177 row2 = QHBoxLayout()
178 self.grid_cols_label = QLabel(tr("label.cameras_per_row"))
179 row2.addWidget(self.grid_cols_label)
180 self.grid_cols_spin = QSpinBox()
181 self.grid_cols_spin.setRange(1, 5)
182 self.grid_cols_spin.setValue(3)
183 self.grid_cols_spin.valueChanged.connect(self.update_grid_layout)
184 row2.addWidget(self.grid_cols_spin)
185
186 self.clear_btn = QPushButton(tr("btn.clear_all"))
187 clear_btn = self.clear_btn
188 clear_btn.clicked.connect(self.clear_cameras)
189 row2.addWidget(clear_btn)
190
191 self.path_btn = QPushButton(tr("btn.path"))
192 path_btn = self.path_btn
193 path_btn.clicked.connect(self.select_recording_path)
194 path_btn.setIcon(load_svg_icon("folder.svg"))
195 path_btn.setIconSize(QSize(18, 18))
196 row2.addWidget(path_btn)
197
198 self.language_label = QLabel(tr("label.language"))
199 row2.addWidget(self.language_label)
200 self.language_combo = QComboBox()
201 self.language_combo.addItem(tr("language.de"), "de")
202 self.language_combo.addItem(tr("language.en"), "en")
203 self.language_combo.currentIndexChanged.connect(self._on_language_changed)
204 self.language_combo.setMaximumWidth(140)
205 row2.addWidget(self.language_combo)
206
207 row2.addStretch()
208 config_layout.addLayout(row2)
209
210 config_group.setLayout(config_layout)
211 config_tab_layout.addWidget(config_group)
212 config_tab_layout.addWidget(self._create_detection_config_group())
213 config_tab_layout.addWidget(self._create_email_config_group())
214 config_tab_layout.addStretch()
215
216 # Control Panel
217 control_widget = QWidget()
218 control_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
219 control_widget.setFixedHeight(36)
220
221 control_layout = QHBoxLayout(control_widget)
222 control_layout.setContentsMargins(0, 0, 0, 0)
223 control_layout.setSpacing(6)
224
225 self.start_all_btn = QPushButton(tr("btn.start_all"))
226 self.start_all_btn.clicked.connect(self.start_all_streams)
227 self.start_all_btn.setIcon(load_svg_icon("play.svg"))
228 self.start_all_btn.setIconSize(QSize(18, 18))
229 self.start_all_btn.setStyleSheet("font-weight: bold; padding: 4px 10px;")
230 self.start_all_btn.setFixedHeight(28)
231
232 self.stop_all_btn = QPushButton(tr("btn.stop_all"))
233 self.stop_all_btn.clicked.connect(self.stop_all_streams)
234 self.stop_all_btn.setIcon(load_svg_icon("stop.svg"))
235 self.stop_all_btn.setIconSize(QSize(18, 18))
236 self.stop_all_btn.setFixedHeight(28)
237
238 self.record_all_btn = QPushButton(tr("btn.record_all"))
239 self.record_all_btn.setCheckable(True)
240 self.record_all_btn.clicked.connect(self.toggle_all_recording)
241 self.record_all_btn.setIcon(load_svg_icon("record.svg"))
242 self.record_all_btn.setIconSize(QSize(18, 18))
243 self.record_all_btn.setFixedHeight(28)
244
245 self.camera_count_label = QLabel(tr("label.camera_count", total=0, active=0))
246 self.camera_count_label.setStyleSheet("font-weight: bold;")
247
248 control_layout.addWidget(self.start_all_btn)
249 control_layout.addWidget(self.stop_all_btn)
250 control_layout.addWidget(self.record_all_btn)
251 control_layout.addStretch()
252 control_layout.addWidget(self.camera_count_label)
253
254 cameras_layout.addWidget(control_widget)
255
256 self.grid_cols_label.setVisible(False)
257 self.grid_cols_spin.setVisible(False)
258
259 content_layout = QHBoxLayout()
260 content_layout.setContentsMargins(0, 0, 0, 0)
261
262 splitter = QSplitter(Qt.Orientation.Horizontal)
263 splitter.setChildrenCollapsible(False)
264 self.content_splitter = splitter
265
266 self.left_scroll = QScrollArea()
267 self.left_scroll.setWidgetResizable(True)
268 self.left_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
269 self.left_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
270 self.left_scroll.setMinimumWidth(260)
271
272 self.camera_list_container = CameraListContainer()
273 self.camera_list_container.order_changed.connect(self._on_camera_order_changed)
274 self.left_scroll.setWidget(self.camera_list_container)
275
276 splitter.addWidget(self.left_scroll)
277
278 self.big_preview_container = QWidget()
279 self.big_preview_layout = QVBoxLayout(self.big_preview_container)
280 self.big_preview_layout.setContentsMargins(10, 0, 0, 0)
281 self.big_preview_label = PreviewLabel()
282 self.big_preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
283 self.big_preview_label.setText(tr("big.select_camera"))
284 self.big_preview_label.setStyleSheet(self._preview_label_style())
285 self.big_preview_label.setMinimumHeight(360)
286 self.big_preview_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
287 self._connect_preview_label(self.big_preview_label)
288 self.big_preview_layout.addWidget(self.big_preview_label)
289
290 splitter.addWidget(self.big_preview_container)
291 splitter.setStretchFactor(0, 1)
292 splitter.setStretchFactor(1, 4)
293 splitter.setSizes([300, 900])
294
295 content_layout.addWidget(splitter)
296 cameras_layout.addLayout(content_layout)
297
298 # Status Bar
299 self.statusBar().showMessage(tr("status.ready"))
300
301 # Timer für Status-Updates
302 self.status_timer = QTimer()
303 self.status_timer.timeout.connect(self.update_status_display)
304 self.status_timer.start(2000) # Alle 2 Sekunden
305
306 def _create_detection_config_group(self):
307 group = QGroupBox(tr("group.detection_config"))
308 self.detection_group = group
309 layout = QVBoxLayout()
310
311 row1 = QHBoxLayout()
312 self.detection_model_label = QLabel(tr("label.detection_model"))
313 self.detection_model_input = QLineEdit(str(self.detection_config.get("model", "yolo11n.pt")))
314 self.detection_model_input.setMaximumWidth(180)
315 row1.addWidget(self.detection_model_label)
316 row1.addWidget(self.detection_model_input)
317 self.detection_model_test_btn = QPushButton(tr("btn.model_test"))
318 self.detection_model_test_btn.clicked.connect(self.test_detection_model)
319 row1.addWidget(self.detection_model_test_btn)
320
321 self.detection_device_label = QLabel(tr("label.detection_device"))
322 self.detection_device_combo = QComboBox()
323 for label, value in (
324 (tr("detection.device.auto"), "auto"),
325 (tr("detection.device.cuda"), "cuda:0"),
326 (tr("detection.device.cpu"), "cpu"),
327 ):
328 self.detection_device_combo.addItem(label, value)
329 row1.addWidget(self.detection_device_label)
330 row1.addWidget(self.detection_device_combo)
331
332 self.detection_imgsz_label = QLabel(tr("label.detection_imgsz"))
333 self.detection_imgsz_spin = QSpinBox()
334 self.detection_imgsz_spin.setRange(320, 1536)
335 self.detection_imgsz_spin.setSingleStep(32)
336 self.detection_imgsz_spin.setValue(int(self.detection_config.get("imgsz", 640)))
337 row1.addWidget(self.detection_imgsz_label)
338 row1.addWidget(self.detection_imgsz_spin)
339 row1.addStretch()
340 layout.addLayout(row1)
341
342 row2 = QHBoxLayout()
343 self.detection_conf_label = QLabel(tr("label.detection_conf"))
344 self.detection_conf_spin = QDoubleSpinBox()
345 self.detection_conf_spin.setRange(0.05, 0.95)
346 self.detection_conf_spin.setSingleStep(0.05)
347 self.detection_conf_spin.setDecimals(2)
348 self.detection_conf_spin.setValue(float(self.detection_config.get("confidence", 0.4)))
349 row2.addWidget(self.detection_conf_label)
350 row2.addWidget(self.detection_conf_spin)
351
352 self.detection_fps_label = QLabel(tr("label.detection_fps"))
353 self.detection_fps_spin = QDoubleSpinBox()
354 self.detection_fps_spin.setRange(0.2, 15.0)
355 self.detection_fps_spin.setSingleStep(0.5)
356 self.detection_fps_spin.setDecimals(1)
357 self.detection_fps_spin.setValue(float(self.detection_config.get("analysis_fps_per_camera", 3.0)))
358 row2.addWidget(self.detection_fps_label)
359 row2.addWidget(self.detection_fps_spin)
360
361 self.detection_cooldown_label = QLabel(tr("label.detection_cooldown"))
362 self.detection_cooldown_spin = QSpinBox()
363 self.detection_cooldown_spin.setRange(0, 3600)
364 self.detection_cooldown_spin.setValue(int(self.detection_config.get("cooldown_seconds", 180)))
365 row2.addWidget(self.detection_cooldown_label)
366 row2.addWidget(self.detection_cooldown_spin)
367
368 self.detection_suppress_label = QLabel(tr("label.detection_suppress"))
369 self.detection_suppress_spin = QSpinBox()
370 self.detection_suppress_spin.setRange(0, 600)
371 self.detection_suppress_spin.setValue(int(self.detection_config.get("event_suppress_seconds", 30)))
372 row2.addWidget(self.detection_suppress_label)
373 row2.addWidget(self.detection_suppress_spin)
374
375 self.detection_clip_label = QLabel(tr("label.detection_clip"))
376 self.detection_clip_spin = QSpinBox()
377 self.detection_clip_spin.setRange(1, 180)
378 self.detection_clip_spin.setValue(int(self.detection_config.get("event_clip_seconds", 30)))
379 row2.addWidget(self.detection_clip_label)
380 row2.addWidget(self.detection_clip_spin)
381 row2.addStretch()
382 layout.addLayout(row2)
383
384 row3 = QHBoxLayout()
385 self.detection_classes_label = QLabel(tr("label.detection_classes"))
386 self.detection_classes_input = QLineEdit(", ".join(self.detection_config.get("classes", [])))
387 row3.addWidget(self.detection_classes_label)
388 row3.addWidget(self.detection_classes_input)
389 layout.addLayout(row3)
390
391 row4 = QHBoxLayout()
392 self.detection_motion_classes_label = QLabel(tr("label.detection_motion_classes"))
393 self.detection_motion_classes_input = QLineEdit(", ".join(self.detection_config.get("motion_required_classes", [])))
394 row4.addWidget(self.detection_motion_classes_label)
395 row4.addWidget(self.detection_motion_classes_input)
396
397 self.detection_motion_pixels_label = QLabel(tr("label.detection_motion_pixels"))
398 self.detection_motion_pixels_spin = QDoubleSpinBox()
399 self.detection_motion_pixels_spin.setRange(0.0, 200.0)
400 self.detection_motion_pixels_spin.setSingleStep(1.0)
401 self.detection_motion_pixels_spin.setDecimals(1)
402 self.detection_motion_pixels_spin.setValue(float(self.detection_config.get("motion_min_pixels", 12.0)))
403 row4.addWidget(self.detection_motion_pixels_label)
404 row4.addWidget(self.detection_motion_pixels_spin)
405 layout.addLayout(row4)
406
407 group.setLayout(layout)
408 for widget in (
409 self.detection_model_input,
410 self.detection_device_combo,
411 self.detection_imgsz_spin,
412 self.detection_conf_spin,
413 self.detection_fps_spin,
414 self.detection_cooldown_spin,
415 self.detection_suppress_spin,
416 self.detection_clip_spin,
417 self.detection_classes_input,
418 self.detection_motion_classes_input,
419 self.detection_motion_pixels_spin,
420 ):
421 if isinstance(widget, QLineEdit):
422 widget.editingFinished.connect(self._save_detection_config_from_ui)
423 else:
424 widget.valueChanged.connect(self._save_detection_config_from_ui) if hasattr(widget, "valueChanged") else widget.currentIndexChanged.connect(self._save_detection_config_from_ui)
425 return group
426
427 def _create_email_config_group(self):
428 group = QGroupBox(tr("group.email_config"))
429 self.email_group = group
430 layout = QVBoxLayout()
431
432 row1 = QHBoxLayout()
433 self.email_enabled_check = QCheckBox(tr("label.email_enabled"))
434 self.email_enabled_check.setChecked(bool(self.email_config.get("enabled", False)))
435 row1.addWidget(self.email_enabled_check)
436 self.email_host_label = QLabel(tr("label.smtp_host"))
437 self.email_host_input = QLineEdit(str(self.email_config.get("smtp_host", "")))
438 row1.addWidget(self.email_host_label)
439 row1.addWidget(self.email_host_input)
440 self.email_port_label = QLabel(tr("label.smtp_port"))
441 self.email_port_spin = QSpinBox()
442 self.email_port_spin.setRange(1, 65535)
443 self.email_port_spin.setValue(int(self.email_config.get("smtp_port", 587)))
444 row1.addWidget(self.email_port_label)
445 row1.addWidget(self.email_port_spin)
446 self.email_tls_check = QCheckBox(tr("label.smtp_tls"))
447 self.email_tls_check.setChecked(bool(self.email_config.get("use_tls", True)))
448 row1.addWidget(self.email_tls_check)
449 layout.addLayout(row1)
450
451 row2 = QHBoxLayout()
452 self.email_user_label = QLabel(tr("label.smtp_username"))
453 self.email_user_input = QLineEdit(str(self.email_config.get("smtp_username", "")))
454 row2.addWidget(self.email_user_label)
455 row2.addWidget(self.email_user_input)
456 self.email_password_label = QLabel(tr("label.smtp_password"))
457 self.email_password_input = QLineEdit(str(self.email_config.get("smtp_password", "")))
458 self.email_password_input.setEchoMode(QLineEdit.EchoMode.Password)
459 row2.addWidget(self.email_password_label)
460 row2.addWidget(self.email_password_input)
461 layout.addLayout(row2)
462
463 row3 = QHBoxLayout()
464 self.email_from_label = QLabel(tr("label.email_from"))
465 self.email_from_input = QLineEdit(str(self.email_config.get("from", "")))
466 row3.addWidget(self.email_from_label)
467 row3.addWidget(self.email_from_input)
468 self.email_to_label = QLabel(tr("label.email_to"))
469 to_value = self.email_config.get("to", [])
470 if isinstance(to_value, list):
471 to_value = ", ".join(to_value)
472 self.email_to_input = QLineEdit(str(to_value))
473 row3.addWidget(self.email_to_label)
474 row3.addWidget(self.email_to_input)
475 self.email_test_btn = QPushButton(tr("btn.email_test"))
476 self.email_test_btn.clicked.connect(self.send_test_email)
477 row3.addWidget(self.email_test_btn)
478 layout.addLayout(row3)
479
480 group.setLayout(layout)
481 for widget in (
482 self.email_enabled_check,
483 self.email_tls_check,
484 self.email_host_input,
485 self.email_port_spin,
486 self.email_user_input,
487 self.email_password_input,
488 self.email_from_input,
489 self.email_to_input,
490 ):
491 if isinstance(widget, QLineEdit):
492 widget.editingFinished.connect(self._save_email_config_from_ui)
493 elif isinstance(widget, QCheckBox):
494 widget.stateChanged.connect(self._save_email_config_from_ui)
495 else:
496 widget.valueChanged.connect(self._save_email_config_from_ui)
497 return group
498
499 def _is_qobject_deleted(self, obj) -> bool:
500 if obj is None:
501 return True
502 if sip is None:
503 return False
504 try:
505 return bool(sip.isdeleted(obj))
506 except Exception:
507 return False
508
509 def _save_detection_config_from_ui(self):
510 if not hasattr(self, "detection_model_input"):
511 return
512 classes = [
513 item.strip()
514 for item in self.detection_classes_input.text().split(",")
515 if item.strip()
516 ]
517 motion_classes = [
518 item.strip()
519 for item in self.detection_motion_classes_input.text().split(",")
520 if item.strip()
521 ]
522 self.detection_config.update({
523 "model": self.detection_model_input.text().strip() or "yolo11n.pt",
524 "device": self.detection_device_combo.currentData() or "auto",
525 "imgsz": int(self.detection_imgsz_spin.value()),
526 "confidence": float(self.detection_conf_spin.value()),
527 "analysis_fps_per_camera": float(self.detection_fps_spin.value()),
528 "cooldown_seconds": int(self.detection_cooldown_spin.value()),
529 "event_suppress_seconds": int(self.detection_suppress_spin.value()),
530 "event_clip_seconds": int(self.detection_clip_spin.value()),
531 "classes": classes,
532 "motion_required_classes": motion_classes,
533 "motion_min_pixels": float(self.detection_motion_pixels_spin.value()),
534 "recording_path": self.recording_path,
535 "model_dir": str(default_model_dir(self.recording_path)),
536 })
537 self.save_config()
538
539 def _save_email_config_from_ui(self):
540 if not hasattr(self, "email_enabled_check"):
541 return
542 recipients = [
543 item.strip()
544 for item in self.email_to_input.text().split(",")
545 if item.strip()
546 ]
547 self.email_config.update({
548 "enabled": bool(self.email_enabled_check.isChecked()),
549 "smtp_host": self.email_host_input.text().strip(),
550 "smtp_port": int(self.email_port_spin.value()),
551 "smtp_username": self.email_user_input.text().strip(),
552 "smtp_password": self.email_password_input.text(),
553 "use_tls": bool(self.email_tls_check.isChecked()),
554 "from": self.email_from_input.text().strip(),
555 "to": recipients,
556 })
557 self.save_config()
558
559 def _sync_detection_config_ui(self):
560 if not hasattr(self, "detection_model_input"):
561 return
562 widgets = [
563 self.detection_model_input,
564 self.detection_device_combo,
565 self.detection_imgsz_spin,
566 self.detection_conf_spin,
567 self.detection_fps_spin,
568 self.detection_cooldown_spin,
569 self.detection_suppress_spin,
570 self.detection_clip_spin,
571 self.detection_classes_input,
572 self.detection_motion_classes_input,
573 self.detection_motion_pixels_spin,
574 ]
575 for widget in widgets:
576 widget.blockSignals(True)
577 try:
578 self.detection_model_input.setText(str(self.detection_config.get("model", "yolo11n.pt")))
579 idx = self.detection_device_combo.findData(str(self.detection_config.get("device", "auto")))
580 if idx >= 0:
581 self.detection_device_combo.setCurrentIndex(idx)
582 self.detection_imgsz_spin.setValue(int(self.detection_config.get("imgsz", 640)))
583 self.detection_conf_spin.setValue(float(self.detection_config.get("confidence", 0.4)))
584 self.detection_fps_spin.setValue(float(self.detection_config.get("analysis_fps_per_camera", 3.0)))
585 self.detection_cooldown_spin.setValue(int(self.detection_config.get("cooldown_seconds", 180)))
586 self.detection_suppress_spin.setValue(int(self.detection_config.get("event_suppress_seconds", 30)))
587 self.detection_clip_spin.setValue(int(self.detection_config.get("event_clip_seconds", 30)))
588 self.detection_classes_input.setText(", ".join(self.detection_config.get("classes", [])))
589 self.detection_motion_classes_input.setText(", ".join(self.detection_config.get("motion_required_classes", [])))
590 self.detection_motion_pixels_spin.setValue(float(self.detection_config.get("motion_min_pixels", 12.0)))
591 finally:
592 for widget in widgets:
593 widget.blockSignals(False)
594
595 def _sync_email_config_ui(self):
596 if not hasattr(self, "email_enabled_check"):
597 return
598 widgets = [
599 self.email_enabled_check,
600 self.email_host_input,
601 self.email_port_spin,
602 self.email_tls_check,
603 self.email_user_input,
604 self.email_password_input,
605 self.email_from_input,
606 self.email_to_input,
607 ]
608 for widget in widgets:
609 widget.blockSignals(True)
610 try:
611 self.email_enabled_check.setChecked(bool(self.email_config.get("enabled", False)))
612 self.email_host_input.setText(str(self.email_config.get("smtp_host", "")))
613 self.email_port_spin.setValue(int(self.email_config.get("smtp_port", 587)))
614 self.email_tls_check.setChecked(bool(self.email_config.get("use_tls", True)))
615 self.email_user_input.setText(str(self.email_config.get("smtp_username", "")))
616 self.email_password_input.setText(str(self.email_config.get("smtp_password", "")))
617 self.email_from_input.setText(str(self.email_config.get("from", "")))
618 to_value = self.email_config.get("to", [])
619 if isinstance(to_value, list):
620 to_value = ", ".join(to_value)
621 self.email_to_input.setText(str(to_value))
622 finally:
623 for widget in widgets:
624 widget.blockSignals(False)
625
626 def _runtime_detection_config(self):
627 config = dict(self.detection_config)
628 config["recording_path"] = self.recording_path
629 config["model_dir"] = str(default_model_dir(self.recording_path))
630 return config
631
632 def test_detection_model(self):
633 self._save_detection_config_from_ui()
634 if hasattr(self, "detection_model_test_btn"):
635 self.detection_model_test_btn.setEnabled(False)
636 self.statusBar().showMessage(tr("status.model_test_running"))
637
638 model_name = str(self.detection_config.get("model", "yolo11n.pt"))
639 model_dir = default_model_dir(self.recording_path)
640
641 def run():
642 try:
643 model_path = prepare_model_path(model_name, model_dir)
644 self.model_test_finished.emit(
645 True,
646 tr("status.model_test_ok", path=model_path),
647 str(model_path),
648 )
649 except Exception as exc:
650 self.model_test_finished.emit(
651 False,
652 tr("status.model_test_failed", error=exc),
653 "",
654 )
655
656 threading.Thread(target=run, daemon=True).start()
657
658 def _on_model_test_finished(self, ok: bool, message: str, model_path: str):
659 if hasattr(self, "detection_model_test_btn"):
660 self.detection_model_test_btn.setEnabled(True)
661 if ok and model_path:
662 self.detection_config["model"] = model_path
663 if hasattr(self, "detection_model_input"):
664 self.detection_model_input.setText(model_path)
665 self.save_config()
666 self.statusBar().showMessage(message)
667
668 def send_test_email(self):
669 self._save_email_config_from_ui()
670 if hasattr(self, "email_test_btn"):
671 self.email_test_btn.setEnabled(False)
672 self.statusBar().showMessage(tr("status.email_test_sending"))
673
674 config = dict(self.email_config)
675 subject = tr("email.test.subject")
676 body = tr("email.test.body")
677
678 def send():
679 try:
680 send_detection_email(config, subject, body, require_enabled=False)
681 self.email_test_finished.emit(True, tr("status.email_test_sent"))
682 except Exception as exc:
683 self.email_test_finished.emit(False, tr("status.email_test_failed", error=exc))
684
685 threading.Thread(target=send, daemon=True).start()
686
687 def _on_email_test_finished(self, ok: bool, message: str):
688 if hasattr(self, "email_test_btn"):
689 self.email_test_btn.setEnabled(True)
690 self.statusBar().showMessage(message)
691
692 def _connect_preview_label(self, label: PreviewLabel):
693 label.double_clicked.connect(self._on_big_preview_label_double_clicked)
694 label.region_selected.connect(self._on_big_preview_region_selected)
695
696 def _detection_worker_is_running(self) -> bool:
697 return bool(self.detection_worker is not None and self.detection_worker.isRunning())
698
699 def _camera_detection_enabled(self, camera_id) -> bool:
700 try:
701 camera_id = int(camera_id)
702 except Exception:
703 return False
704 camera = next((c for c in self.cameras if int(c.get("id", -1)) == camera_id), None)
705 return bool(camera and camera.get("detection_enabled", False))
706
707 def _any_camera_detection_enabled(self) -> bool:
708 return any(bool(camera.get("detection_enabled", False)) for camera in self.cameras)
709
710 def _preview_label_style(self, camera_id=None):
711 border_color = "#2196f3" if camera_id is not None and self._camera_detection_enabled(camera_id) else "#555"
712 return f"border: 2px solid {border_color}; background-color: black;"
713
714 def _apply_detection_visual_state(self):
715 for widget in self.camera_widgets.values():
716 try:
717 widget.set_detection_active(self._camera_detection_enabled(widget.camera_id))
718 except RuntimeError:
719 continue
720 labels = []
721 label = getattr(self, "big_preview_label", None)
722 if label is not None and not self._is_qobject_deleted(label):
723 labels.append(label)
724 for label in self.multi_view_labels.values():
725 if label is not None and not self._is_qobject_deleted(label):
726 labels.append(label)
727 for label in labels:
728 try:
729 camera_id = getattr(label, "camera_id", None)
730 if camera_id is None:
731 camera_id = self._get_active_big_preview_camera_id()
732 label.setStyleSheet(self._preview_label_style(camera_id))
733 except RuntimeError:
734 pass
735
736 def _get_active_big_preview_camera_id(self):
737 if self.selected_camera_ids:
738 if self.zoomed_camera_id is not None and self.zoomed_camera_id in self.selected_camera_ids:
739 return self.zoomed_camera_id
740 if len(self.selected_camera_ids) == 1:
741 return self.selected_camera_ids[0]
742 return None
743 return self.selected_camera_id
744
745 def _get_preview_label_for_camera(self, camera_id):
746 if camera_id is None:
747 return None
748
749 label = getattr(self, "big_preview_label", None)
750 if label is not None and not self._is_qobject_deleted(label):
751 try:
752 if getattr(label, "camera_id", None) == camera_id:
753 return label
754 except RuntimeError:
755 pass
756
757 label = self.multi_view_labels.get(camera_id)
758 if label is not None and not self._is_qobject_deleted(label):
759 return label
760 return None
761
762 def _clear_big_preview_crop(self):
763 self.preview_crop_camera_id = None
764 self.preview_crop_rect = None
765
766 def _sync_big_preview_crop_state(self, active_camera_id):
767 if active_camera_id is None or self.preview_crop_camera_id != active_camera_id:
768 self._clear_big_preview_crop()
769
770 def _update_big_preview_selection_state(self):
771 active_camera_id = self._get_active_big_preview_camera_id()
772 known_labels = []
773 label = getattr(self, "big_preview_label", None)
774 if label is not None and not self._is_qobject_deleted(label):
775 known_labels.append(label)
776 for multi_label in self.multi_view_labels.values():
777 if multi_label is not None and not self._is_qobject_deleted(multi_label):
778 known_labels.append(multi_label)
779 for known_label in known_labels:
780 try:
781 known_label.set_selection_enabled(False)
782 except RuntimeError:
783 continue
784
785 label = self._get_preview_label_for_camera(active_camera_id)
786 if label is not None:
787 try:
788 label.set_selection_enabled(
789 self.preview_crop_rect is None and self.preview_crop_camera_id is None
790 )
791 except RuntimeError:
792 pass
793
794 def _refresh_big_preview_from_last_frame(self, camera_id):
795 widget = self.camera_widgets.get(camera_id)
796 if widget is None or widget.last_frame is None:
797 return
798
799 if self.selected_camera_ids:
800 self._update_multi_view_frame(widget.last_frame, camera_id)
801 elif self.selected_camera_id == camera_id:
802 self._update_big_preview_frame(widget.last_frame)
803
804 def _render_frame_to_label(self, label, frame, crop_rect=None):
805 if label is None or self._is_qobject_deleted(label):
806 return
807
808 display_w = max(1, label.width())
809 display_h = max(1, label.height())
810
811 src_h, src_w = frame.shape[:2]
812 if src_w <= 0 or src_h <= 0:
813 label.clear_frame_display_rect()
814 return
815
816 crop_x = 0
817 crop_y = 0
818 crop_w = src_w
819 crop_h = src_h
820
821 if crop_rect is not None:
822 crop_x = max(0, min(src_w - 1, crop_rect.x()))
823 crop_y = max(0, min(src_h - 1, crop_rect.y()))
824 max_crop_w = max(1, src_w - crop_x)
825 max_crop_h = max(1, src_h - crop_y)
826 crop_w = max(1, min(max_crop_w, crop_rect.width()))
827 crop_h = max(1, min(max_crop_h, crop_rect.height()))
828
829 frame_view = frame[crop_y:crop_y + crop_h, crop_x:crop_x + crop_w]
830 if frame_view.size == 0:
831 label.clear_frame_display_rect()
832 return
833
834 scale = min(display_w / crop_w, display_h / crop_h)
835 new_w = max(1, int(crop_w * scale))
836 new_h = max(1, int(crop_h * scale))
837
838 frame_resized = cv2.resize(frame_view, (new_w, new_h))
839 rgb_small = cv2.cvtColor(frame_resized, cv2.COLOR_BGR2RGB)
840 rgb_frame = np.zeros((display_h, display_w, 3), dtype=np.uint8)
841 x = (display_w - new_w) // 2
842 y = (display_h - new_h) // 2
843 rgb_frame[y:y + new_h, x:x + new_w] = rgb_small
844
845 h, w, ch = rgb_frame.shape
846 bytes_per_line = ch * w
847 qt_image = QImage(rgb_frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888).copy()
848 pixmap = QPixmap.fromImage(qt_image)
849 pixmap.setDevicePixelRatio(1.0)
850 label.setPixmap(pixmap)
851 label.set_frame_display_rect(QRect(x, y, new_w, new_h))
852
853 def _get_or_create_camera_widget(self, camera: dict) -> CameraWidget:
854 camera_id = int(camera.get('id'))
855 camera_name = camera.get('name', tr("camera.default_name.id", id=camera_id))
856
857 existing = self.camera_widgets.get(camera_id)
858 if existing is not None and not self._is_qobject_deleted(existing):
859 return existing
860
861 # Check if battery camera
862 model = camera.get('model', '')
863 is_battery = _is_battery_camera(model, camera_name)
864
865 widget = CameraWidget(camera_id, camera_name, is_battery=is_battery)
866 widget.remove_btn.clicked.connect(lambda checked, cid=camera_id: self.remove_camera(cid))
867 widget.edit_btn.clicked.connect(lambda checked, cid=camera_id: self.edit_camera(cid))
868 widget.stream_toggled.connect(self.toggle_camera_stream)
869 widget.clicked.connect(self.select_camera)
870 widget.snapshot_requested.connect(self.save_camera_snapshot)
871 widget.selection_changed.connect(self.on_camera_selection_changed)
872 widget.detection_toggled.connect(self.toggle_camera_detection)
873 widget.record_btn.clicked.connect(lambda checked, cid=camera_id, w=widget: self._on_record_btn_clicked(cid, w, checked))
874 widget.set_detection_active(self._camera_detection_enabled(camera_id))
875 self.camera_widgets[camera_id] = widget
876 return widget
877
878 def _remove_camera_list_item(self, camera_id: int):
879 if not hasattr(self, "camera_list_container"):
880 return
881 layout = self.camera_list_container.layout_ref
882 for index in range(layout.count()):
883 item = layout.itemAt(index)
884 widget = item.widget() if item else None
885 if widget is not None and getattr(widget, "camera_id", None) == camera_id:
886 layout.takeAt(index)
887 widget.setParent(None)
888 return
889
890 def _on_record_btn_clicked(self, camera_id: int, widget: CameraWidget, checked: bool):
891 thread = self.camera_threads.get(camera_id)
892 if thread is None:
893 return
894 self.toggle_camera_recording(thread, widget, checked)
895
896 def _allocate_camera_id(self) -> int:
897 camera_id = self.next_camera_id
898 self.next_camera_id += 1
899 return camera_id
900
901 def _add_camera_entry(self, camera_entry: dict):
902 self.cameras.append(camera_entry)
903 self._get_or_create_camera_widget(camera_entry)
904
905 def _build_discovered_camera_entry(self, camera_info: dict, username: str, password: str) -> dict:
906 rtsp_port = 554 if 554 in camera_info['ports'] else (
907 8554 if 8554 in camera_info['ports'] else 554
908 )
909 camera_entry = {
910 'id': self._allocate_camera_id(),
911 'url': _build_rtsp_url(
912 host=camera_info['ip'],
913 port=rtsp_port,
914 username=username,
915 password=password,
916 path="h264Preview_01_main"
917 ),
918 'name': camera_info['name'],
919 'uid': camera_info.get('uid', ''),
920 'model': camera_info.get('model', ''),
921 'manufacturer': camera_info.get('manufacturer', ''),
922 'detection_enabled': False,
923 }
924 normalize_reolinkproxy_camera(camera_entry, username=username, password=password)
925 return camera_entry
926
927 def _build_manual_camera_entry(self, url: str, name: str, uid: str) -> dict:
928 camera_id = self._allocate_camera_id()
929 camera_entry = {
930 'id': camera_id,
931 'url': url,
932 'name': name if name else tr("camera.default_name.id", id=camera_id),
933 'uid': uid,
934 'model': '',
935 'detection_enabled': False,
936 }
937 normalize_reolinkproxy_camera(camera_entry)
938 return camera_entry
939
940 def _normalize_edited_camera_data(self, camera_data: dict, updated_data: dict) -> dict:
941 normalized = dict(updated_data)
942 normalized.setdefault('model', camera_data.get('model', ''))
943 normalized.setdefault('manufacturer', camera_data.get('manufacturer', ''))
944 normalize_reolinkproxy_camera(normalized)
945 return normalized
946
947 def _start_camera_thread(self, camera: dict) -> CameraThread:
948 camera_id = camera['id']
949 thread = CameraThread(camera_id, camera['url'], camera.get('uid', ''))
950 thread.frame_ready.connect(lambda frame, cid=camera_id: self.update_camera_frame(frame, cid))
951 thread.connection_status.connect(lambda connected, cid, msg: self.update_camera_status(connected, cid, msg))
952 thread.start()
953 self.camera_threads[camera_id] = thread
954 if camera_id in self.camera_widgets:
955 self.camera_widgets[camera_id].set_stream_active(True)
956 return thread
957
958 def _clear_big_preview_label(self, text: str = ""):
959 self.big_preview_label.setPixmap(QPixmap())
960 self.big_preview_label.clear_frame_display_rect()
961 if text:
962 self.big_preview_label.setText(text)
963
964 def _camera_waiting_text(self, camera_name: str) -> str:
965 return f"{camera_name}\n{tr('camera.preview.waiting')}"
966
967 def _camera_click_to_start_text(self, camera_name: str) -> str:
968 return f"{camera_name}\n{tr('camera.preview.click_to_start')}"
969
970 def show_discovery_dialog(self):
971 """Kamera-Suche Dialog anzeigen"""
972 dialog = CameraDiscoveryDialog(self)
973
974 if dialog.exec() == QDialog.DialogCode.Accepted:
975 selected_cameras = dialog.get_selected_cameras()
976
977 if not selected_cameras:
978 return
979
980 # Zugangsdaten aus Dialog
981 username = dialog.username_input.text()
982 password = dialog.password_input.text()
983
984 added_count = 0
985 battery_cameras = []
986
987 for camera_info in selected_cameras:
988 name = camera_info['name']
989 model = camera_info.get('model', '')
990
991 # Check if battery camera
992 if _is_battery_camera(model, name):
993 battery_cameras.append((name, model))
994
995 camera_entry = self._build_discovered_camera_entry(camera_info, username, password)
996
997 # Prüfe ob Kamera bereits existiert
998 if any(c['url'] == camera_entry['url'] for c in self.cameras):
999 continue
1000
1001 self._add_camera_entry(camera_entry)
1002
1003 added_count += 1
1004
1005 if added_count > 0:
1006 self.update_grid_layout()
1007 self.update_status_display()
1008 self.save_config()
1009 self.statusBar().showMessage(tr("status.auto_added", count=added_count))
1010
1011 # Show battery camera warning if any detected
1012 if battery_cameras:
1013 cam_list = "\n".join([f"• {name} ({model})" for name, model in battery_cameras])
1014 QMessageBox.warning(
1015 self,
1016 tr("battery.warning.title"),
1017 f"{len(battery_cameras)} {tr('battery.indicator')}:\n\n{cam_list}\n\n" +
1018 tr("battery.warning.message", name="", model="").replace("Die Kamera '' () ist eine Akku-betriebene Kamera.",
1019 "Diese Kameras sind Akku-betrieben.").replace("Camera '' () is battery-powered.",
1020 "These cameras are battery-powered.")
1021 )
1022
1023 def add_camera(self):
1024 """Kamera hinzufügen"""
1025 url = self.url_input.text().strip()
1026 name = self.name_input.text().strip()
1027 uid = self.uid_input.text().strip()
1028
1029 if not url:
1030 QMessageBox.warning(self, tr("dialog.title.error"), tr("label.rtsp_url"))
1031 return
1032
1033 camera_name = name if name else tr("camera.default_name.id", id=self.next_camera_id)
1034
1035 # Check if battery camera and show warning
1036 if _is_battery_camera("", camera_name):
1037 reply = QMessageBox.question(
1038 self,
1039 tr("battery.warning.title"),
1040 tr("battery.warning.message", name=camera_name, model="Battery Camera"),
1041 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1042 QMessageBox.StandardButton.No
1043 )
1044 if reply == QMessageBox.StandardButton.No:
1045 return
1046
1047 camera_entry = self._build_manual_camera_entry(url, name, uid)
1048 self._add_camera_entry(camera_entry)
1049 camera_name = camera_entry['name']
1050
1051 # Im Grid platzieren
1052 self.update_grid_layout()
1053
1054 # Eingabe leeren
1055 self.url_input.clear()
1056 self.name_input.clear()
1057 self.uid_input.clear()
1058
1059 self.update_status_display()
1060 self.statusBar().showMessage(tr("status.camera_added", name=camera_name))
1061 self.save_config()
1062
1063 def edit_camera(self, camera_id):
1064 """Kamera bearbeiten"""
1065 # Kamera-Daten finden
1066 camera_data = next((c for c in self.cameras if c['id'] == camera_id), None)
1067 if not camera_data:
1068 return
1069
1070 # Stream stoppen falls aktiv
1071 was_running = False
1072 if camera_id in self.camera_threads:
1073 thread = self.camera_threads[camera_id]
1074 if thread.isRunning():
1075 was_running = True
1076 if thread.stop(timeout_ms=3000):
1077 del self.camera_threads[camera_id]
1078 else:
1079 QMessageBox.warning(self, tr("dialog.title.error"), "Stream wird noch beendet. Bitte gleich erneut versuchen.")
1080 return
1081
1082 # Edit Dialog öffnen
1083 dialog = CameraEditDialog(camera_data, self)
1084
1085 if dialog.exec() == QDialog.DialogCode.Accepted:
1086 updated_data = self._normalize_edited_camera_data(camera_data, dialog.get_camera_data())
1087
1088 # Daten aktualisieren
1089 for camera in self.cameras:
1090 if camera['id'] == camera_id:
1091 camera.update(updated_data)
1092 break
1093
1094 # Widget aktualisieren
1095 if camera_id in self.camera_widgets:
1096 widget = self.camera_widgets[camera_id]
1097 widget.camera_name = updated_data['name']
1098 widget.info_label.setText(f"{updated_data['name']} - {tr('camera.status.offline')}")
1099 if widget.stream_active:
1100 widget.video_label.setText(self._camera_waiting_text(updated_data['name']))
1101 else:
1102 widget.video_label.setText(self._camera_click_to_start_text(updated_data['name']))
1103
1104 if self.selected_camera_id == camera_id:
1105 if camera_id in self.camera_threads and self.camera_threads[camera_id].isRunning():
1106 self.big_preview_label.setText(self._camera_waiting_text(updated_data['name']))
1107 else:
1108 self.big_preview_label.setText(self._camera_click_to_start_text(updated_data['name']))
1109
1110 self.save_config()
1111 self.statusBar().showMessage(tr("status.camera_updated", name=updated_data['name']))
1112
1113 # Stream neu starten falls vorher aktiv
1114 if was_running:
1115 QTimer.singleShot(500, lambda: self.start_single_stream(camera_id))
1116 else:
1117 # Bei Abbruch: Stream wieder starten falls vorher aktiv
1118 if was_running:
1119 QTimer.singleShot(500, lambda: self.start_single_stream(camera_id))
1120
1121 def start_single_stream(self, camera_id):
1122 """Einzelnen Stream starten"""
1123 camera = next((c for c in self.cameras if c['id'] == camera_id), None)
1124 if not camera:
1125 return
1126
1127 # Skip wenn bereits läuft
1128 if camera_id in self.camera_threads and self.camera_threads[camera_id].isRunning():
1129 return
1130
1131 if camera_id in self.camera_widgets and self.camera_widgets[camera_id].record_btn.isChecked():
1132 self.camera_widgets[camera_id].record_btn.setChecked(False)
1133 self.camera_widgets[camera_id].toggle_recording()
1134
1135 self._start_camera_thread(camera)
1136 self.statusBar().showMessage(tr("status.stream_started", name=camera['name']))
1137
1138 def stop_single_stream(self, camera_id):
1139 if camera_id in self.camera_threads:
1140 if self.camera_threads[camera_id].stop(timeout_ms=3000):
1141 del self.camera_threads[camera_id]
1142 else:
1143 self.statusBar().showMessage("Stream wird noch beendet...")
1144 return
1145
1146 if self.preview_crop_camera_id == camera_id:
1147 self._clear_big_preview_crop()
1148
1149 if camera_id in self.camera_widgets:
1150 widget = self.camera_widgets[camera_id]
1151 if widget.record_btn.isChecked():
1152 widget.record_btn.setChecked(False)
1153 widget.toggle_recording()
1154 widget.set_stream_active(False)
1155 widget.update_status(False, tr("camera.status.stopped"))
1156
1157 if self.selected_camera_id == camera_id:
1158 widget = self.camera_widgets.get(camera_id)
1159 if widget:
1160 self._clear_big_preview_label(self._camera_click_to_start_text(widget.camera_name))
1161
1162 def toggle_camera_stream(self, camera_id, enabled):
1163 if enabled:
1164 self.start_single_stream(camera_id)
1165 else:
1166 self.stop_single_stream(camera_id)
1167
1168 def remove_camera(self, camera_id):
1169 """Einzelne Kamera entfernen"""
1170 camera = next((c for c in self.cameras if c.get('id') == camera_id), None)
1171 camera_name = None
1172 if camera:
1173 camera_name = camera.get('name')
1174 if not camera_name and camera_id in self.camera_widgets:
1175 camera_name = self.camera_widgets[camera_id].camera_name
1176 if not camera_name:
1177 camera_name = tr("camera.default_name.id", id=camera_id)
1178
1179 reply = QMessageBox.question(
1180 self,
1181 tr('dialog.title.confirm'),
1182 tr('dialog.confirm.remove_one', name=camera_name),
1183 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1184 )
1185 if reply != QMessageBox.StandardButton.Yes:
1186 return
1187
1188 # Thread stoppen falls aktiv
1189 if camera_id in self.camera_threads:
1190 if self.camera_threads[camera_id].stop(timeout_ms=3000):
1191 del self.camera_threads[camera_id]
1192 else:
1193 QMessageBox.warning(self, tr("dialog.title.error"), "Stream wird noch beendet. Bitte gleich erneut versuchen.")
1194 return
1195
1196 # Widget entfernen
1197 if camera_id in self.camera_widgets:
1198 widget = self.camera_widgets[camera_id]
1199 self._remove_camera_list_item(camera_id)
1200 widget.deleteLater()
1201 del self.camera_widgets[camera_id]
1202
1203 if self.selected_camera_id == camera_id:
1204 self.selected_camera_id = None
1205 if self.preview_crop_camera_id == camera_id:
1206 self._clear_big_preview_crop()
1207 self._clear_big_preview_label(tr("big.select_camera"))
1208
1209 if camera_id in self.selected_camera_ids:
1210 self.selected_camera_ids.remove(camera_id)
1211 if self.zoomed_camera_id == camera_id:
1212 self.zoomed_camera_id = None
1213 self._sync_big_preview_crop_state(self._get_active_big_preview_camera_id())
1214 self._rebuild_multi_view_layout()
1215
1216 # Aus Liste entfernen
1217 self.cameras = [c for c in self.cameras if c['id'] != camera_id]
1218 if not self._any_camera_detection_enabled():
1219 self.stop_detection()
1220
1221 self.update_grid_layout()
1222 self.update_status_display()
1223 self.save_config()
1224 self.statusBar().showMessage(tr("status.camera_removed", id=camera_id))
1225
1226 def update_grid_layout(self):
1227 """Kamera-Liste neu aufbauen"""
1228 if not hasattr(self, "camera_list_container"):
1229 return
1230
1231 self._rebuilding_camera_list = True
1232 layout = self.camera_list_container.layout_ref
1233
1234 # Detach existing widgets from layout without deleting them
1235 while layout.count():
1236 item = layout.takeAt(0)
1237 w = item.widget() if item else None
1238 if w is not None:
1239 w.setParent(None)
1240
1241 for camera in self.cameras:
1242 widget = self._get_or_create_camera_widget(camera)
1243 if self._is_qobject_deleted(widget):
1244 continue
1245 layout.addWidget(widget)
1246
1247 self._rebuilding_camera_list = False
1248
1249 def clear_cameras(self):
1250 """Alle Kameras entfernen"""
1251 if not self.cameras:
1252 return
1253
1254 reply = QMessageBox.question(self, tr('dialog.title.confirm'),
1255 tr('dialog.confirm.remove_all'),
1256 QMessageBox.StandardButton.Yes |
1257 QMessageBox.StandardButton.No)
1258
1259 if reply == QMessageBox.StandardButton.Yes:
1260 self.stop_all_streams()
1261
1262 for widget in self.camera_widgets.values():
1263 widget.deleteLater()
1264
1265 self.camera_widgets.clear()
1266 self.cameras.clear()
1267 self.next_camera_id = 1
1268 self.selected_camera_id = None
1269 self.selected_camera_ids = []
1270 self.zoomed_camera_id = None
1271 self._clear_big_preview_crop()
1272 self._clear_big_preview_label(tr("big.select_camera"))
1273 if hasattr(self, "camera_list_container"):
1274 layout = self.camera_list_container.layout_ref
1275 while layout.count():
1276 item = layout.takeAt(0)
1277 w = item.widget() if item else None
1278 if w is not None:
1279 w.setParent(None)
1280 self.update_status_display()
1281 self.save_config()
1282 self.statusBar().showMessage(tr("status.cameras_removed"))
1283
1284 def start_all_streams(self):
1285 """Alle Streams parallel starten"""
1286 if not self.cameras:
1287 QMessageBox.information(self, tr("dialog.title.info"), tr("dialog.msg.no_cameras"))
1288 return
1289
1290 # Threads parallel starten
1291 for camera in self.cameras:
1292 camera_id = camera['id']
1293
1294 # Skip wenn bereits läuft
1295 if camera_id in self.camera_threads and self.camera_threads[camera_id].isRunning():
1296 continue
1297
1298 self._start_camera_thread(camera)
1299
1300 self.statusBar().showMessage(tr("status.streams_starting", count=len(self.cameras)))
1301
1302 def stop_all_streams(self):
1303 """Alle Streams stoppen"""
1304 threads = list(self.camera_threads.values())
1305 force_stop = bool(getattr(self, "_closing", False))
1306
1307 for thread in threads:
1308 thread.request_stop()
1309
1310 deadline = time.monotonic() + (2.5 if force_stop else 5.0)
1311 for thread in threads:
1312 remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
1313 if thread.isRunning():
1314 thread.wait(remaining_ms)
1315 self.camera_threads = {
1316 camera_id: thread
1317 for camera_id, thread in self.camera_threads.items()
1318 if thread.isRunning()
1319 }
1320 if self.camera_threads:
1321 self.statusBar().showMessage("Streams werden noch beendet...")
1322 return
1323 self._clear_big_preview_crop()
1324
1325 for camera_id, widget in list(self.camera_widgets.items()):
1326 try:
1327 if widget.record_btn.isChecked():
1328 widget.record_btn.setChecked(False)
1329 widget.toggle_recording()
1330 widget.set_stream_active(False)
1331 widget.update_status(False, tr("camera.status.stopped"))
1332 except RuntimeError:
1333 # Widget already deleted during shutdown/rebuild
1334 continue
1335
1336 if self.selected_camera_id is not None:
1337 widget = self.camera_widgets.get(self.selected_camera_id)
1338 if widget:
1339 self._clear_big_preview_label(self._camera_click_to_start_text(widget.camera_name))
1340 self._update_big_preview_selection_state()
1341
1342 self.update_status_display()
1343 self.statusBar().showMessage(tr("status.streams_stopped"))
1344
1345 def update_camera_frame(self, frame, camera_id):
1346 """Frame einer Kamera aktualisieren"""
1347 widget = self.camera_widgets.get(camera_id)
1348 if widget is not None:
1349 try:
1350 widget.update_frame(frame)
1351 except RuntimeError:
1352 return
1353
1354 if self._detection_worker_is_running() and self._camera_detection_enabled(camera_id):
1355 camera_name = widget.camera_name if widget is not None else str(camera_id)
1356 self.detection_worker.submit_frame(camera_id, camera_name, frame)
1357
1358 # Update big preview for single camera selection (old behavior)
1359 if self.selected_camera_id == camera_id and len(self.selected_camera_ids) == 0:
1360 self._update_big_preview_frame(frame)
1361
1362 # Update multi-view if camera is selected via checkbox
1363 if camera_id in self.selected_camera_ids:
1364 self._update_multi_view_frame(frame, camera_id)
1365
1366 def update_camera_status(self, connected, camera_id, message):
1367 """Status einer Kamera aktualisieren"""
1368 widget = self.camera_widgets.get(camera_id)
1369 if widget is not None:
1370 try:
1371 widget.update_status(connected, message)
1372 except RuntimeError:
1373 return
1374
1375 def toggle_camera_recording(self, thread, widget, checked):
1376 """Aufnahme einer einzelnen Kamera umschalten"""
1377 if checked:
1378 filename = thread.start_recording(self.recording_path)
1379 if filename:
1380 self.statusBar().showMessage(tr("status.recording", name=os.path.basename(filename)))
1381 else:
1382 thread.stop_recording()
1383 self.statusBar().showMessage(tr("status.recording_stopped", name=widget.camera_name))
1384
1385 def toggle_all_recording(self):
1386 """Alle Aufnahmen umschalten"""
1387 recording = self.record_all_btn.isChecked()
1388
1389 count = 0
1390 for camera_id, widget in self.camera_widgets.items():
1391 if widget.record_btn.isEnabled() and camera_id in self.camera_threads:
1392 widget.record_btn.setChecked(recording)
1393 widget.toggle_recording()
1394
1395 thread = self.camera_threads[camera_id]
1396 if recording:
1397 thread.start_recording(self.recording_path)
1398 else:
1399 thread.stop_recording()
1400 count += 1
1401
1402 if recording:
1403 self.record_all_btn.setText(tr("btn.record_all_stop"))
1404 self.record_all_btn.setIcon(load_svg_icon("stop.svg"))
1405 self.record_all_btn.setIconSize(QSize(18, 18))
1406 self.record_all_btn.setStyleSheet("background-color: #d32f2f; color: white; font-weight: bold;")
1407 self.statusBar().showMessage(tr("status.recordings_started", count=count))
1408 else:
1409 self.record_all_btn.setText(tr("btn.record_all"))
1410 self.record_all_btn.setIcon(load_svg_icon("record.svg"))
1411 self.record_all_btn.setIconSize(QSize(18, 18))
1412 self.record_all_btn.setStyleSheet("")
1413 self.statusBar().showMessage(tr("status.recordings_stopped", count=count))
1414
1415 def update_status_display(self):
1416 """Statusanzeige aktualisieren"""
1417 total = len(self.cameras)
1418 active = len([t for t in self.camera_threads.values() if t.isRunning()])
1419 self.camera_count_label.setText(tr("label.camera_count", total=total, active=active))
1420
1421 def select_recording_path(self):
1422 """Speicherort für Aufnahmen wählen"""
1423 path = QFileDialog.getExistingDirectory(self, tr("dialog.path.choose"), self.recording_path)
1424 if path:
1425 self.recording_path = path
1426 self.snapshot_path = snapshot_path_for(self.recording_path)
1427 self.event_path = os.path.join(self.recording_path, "events")
1428 os.makedirs(self.snapshot_path, exist_ok=True)
1429 os.makedirs(self.event_path, exist_ok=True)
1430 self.save_config()
1431 self.statusBar().showMessage(tr("status.path", path=path))
1432
1433 def toggle_camera_detection(self, camera_id: int, enabled: bool):
1434 camera = next((c for c in self.cameras if int(c.get("id", -1)) == int(camera_id)), None)
1435 if camera is None:
1436 return
1437 camera["detection_enabled"] = bool(enabled)
1438 widget = self.camera_widgets.get(camera_id)
1439 if widget is not None:
1440 widget.set_detection_active(enabled)
1441
1442 if enabled:
1443 self._model_retry_attempt = 0
1444 self._model_retry_scheduled = False
1445 self._ensure_detection_worker()
1446 if camera_id not in self.camera_threads or not self.camera_threads[camera_id].isRunning():
1447 self.start_single_stream(camera_id)
1448 elif not self._any_camera_detection_enabled():
1449 self.stop_detection()
1450 self._apply_detection_visual_state()
1451 self.save_config()
1452
1453 def _ensure_detection_worker(self):
1454 if self._detection_worker_is_running():
1455 return
1456 self.detection_worker = DetectionWorker(self._runtime_detection_config(), self)
1457 self.detection_worker.detected.connect(self.handle_detection_event)
1458 self.detection_worker.status.connect(self._on_detection_status)
1459 self.detection_worker.start()
1460
1461 def stop_detection(self):
1462 worker = self.detection_worker
1463 self.detection_worker = None
1464 if worker is not None:
1465 worker.stop(timeout_ms=3000)
1466 self._apply_detection_visual_state()
1467 self.save_config()
1468 self.statusBar().showMessage(tr("status.detection_stopped"))
1469
1470 def _on_detection_status(self, message: str):
1471 if "deaktiviert" in message or "disabled" in message or "Modell-Laden fehlgeschlagen" in message:
1472 self.detection_worker = None
1473 self._schedule_detection_retry(message)
1474 self._apply_detection_visual_state()
1475 self.save_config()
1476 return
1477 elif "aktiv" in message or "active" in message:
1478 self._model_retry_attempt = 0
1479 self._model_retry_scheduled = False
1480 self.statusBar().showMessage(message)
1481
1482 def _schedule_detection_retry(self, reason: str):
1483 if not self._any_camera_detection_enabled():
1484 return
1485 if self._model_retry_scheduled:
1486 return
1487 delay = self._model_retry_delays[min(self._model_retry_attempt, len(self._model_retry_delays) - 1)]
1488 self._model_retry_attempt += 1
1489 self._model_retry_scheduled = True
1490 self.statusBar().showMessage(tr("status.model_retry_scheduled", seconds=delay))
1491 QTimer.singleShot(delay * 1000, self._retry_detection_worker)
1492
1493 def _retry_detection_worker(self):
1494 self._model_retry_scheduled = False
1495 if not self._any_camera_detection_enabled():
1496 return
1497 if self._detection_worker_is_running():
1498 return
1499 self._ensure_detection_worker()
1500
1501 def handle_detection_event(self, event):
1502 os.makedirs(self.snapshot_path, exist_ok=True)
1503 os.makedirs(self.event_path, exist_ok=True)
1504 timestamp = datetime.fromtimestamp(event.timestamp).strftime("%Y%m%d_%H%M%S")
1505 safe_camera = "".join(c if (c.isalnum() or c in "-_") else "_" for c in event.camera_name)
1506 safe_label = "".join(c if (c.isalnum() or c in "-_") else "_" for c in event.label)
1507 snapshot_file = os.path.join(
1508 self.snapshot_path,
1509 f"detect_{safe_camera}_{event.camera_id}_{safe_label}_{timestamp}.jpg",
1510 )
1511
1512 try:
1513 cv2.imwrite(snapshot_file, event.annotated_frame)
1514 except Exception as exc:
1515 self.statusBar().showMessage(tr("status.snapshot_error", error=exc))
1516 return
1517
1518 clip_file = None
1519 thread = self.camera_threads.get(event.camera_id)
1520 if thread is not None:
1521 clip_seconds = min(180.0, max(1.0, float(self.detection_config.get("event_clip_seconds", 30))))
1522 pre_seconds = min(clip_seconds - 1.0, max(0.0, float(self.detection_config.get("pre_event_seconds", 8))))
1523 post_seconds = max(1.0, clip_seconds - pre_seconds)
1524 clip_file = thread.start_event_clip(
1525 self.event_path,
1526 event.label,
1527 camera_name=event.camera_name,
1528 pre_seconds=pre_seconds,
1529 post_seconds=post_seconds,
1530 max_seconds=clip_seconds,
1531 )
1532
1533 self.statusBar().showMessage(
1534 tr(
1535 "status.detection_event",
1536 label=event.label,
1537 camera=event.camera_name,
1538 confidence=f"{event.confidence:.2f}",
1539 )
1540 )
1541 self._send_detection_email_async(event, snapshot_file, clip_file)
1542
1543 def _send_detection_email_async(self, event, snapshot_file: str, clip_file: str | None):
1544 if not self.email_config.get("enabled"):
1545 return
1546
1547 subject = tr("email.detection.subject", label=event.label, camera=event.camera_name)
1548 lines = [
1549 tr(
1550 "email.detection.body",
1551 label=event.label,
1552 camera=event.camera_name,
1553 confidence=f"{event.confidence:.2f}",
1554 ),
1555 "",
1556 f"Snapshot: {snapshot_file}",
1557 ]
1558 if clip_file:
1559 lines.append(f"Clip: {clip_file}")
1560
1561 def send():
1562 try:
1563 send_detection_email(self.email_config, subject, "\n".join(lines), snapshot_file)
1564 except Exception as exc:
1565 print(f"Detection email failed: {exc}")
1566
1567 threading.Thread(target=send, daemon=True).start()
1568
1569 def save_camera_snapshot(self, camera_id):
1570 widget = self.camera_widgets.get(camera_id)
1571 if not widget or widget.last_frame is None:
1572 self.statusBar().showMessage(tr("status.no_image"))
1573 return
1574
1575 os.makedirs(self.snapshot_path, exist_ok=True)
1576 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1577 safe_name = "".join(c if (c.isalnum() or c in "-_") else "_" for c in widget.camera_name)
1578 filename = os.path.join(self.snapshot_path, f"{safe_name}_{camera_id}_{timestamp}.jpg")
1579
1580 try:
1581 cv2.imwrite(filename, widget.last_frame)
1582 self.statusBar().showMessage(tr("status.snapshot_saved", name=os.path.basename(filename)))
1583 except Exception as e:
1584 self.statusBar().showMessage(tr("status.snapshot_error", error=e))
1585
1586 def save_config(self):
1587 """Konfiguration speichern"""
1588 try:
1589 save_config_data(config_payload(self))
1590 except Exception as e:
1591 print(f"Fehler beim Speichern: {e}")
1592
1593 def load_config(self):
1594 """Konfiguration laden"""
1595 try:
1596 config, fixed_config = load_config_data()
1597 if config is None:
1598 return
1599
1600 self.language = config.get('language', self.language)
1601 set_language(self.language)
1602 self.cameras = config.get('cameras', [])
1603 self.recording_path = config.get('recording_path', self.recording_path)
1604 self.snapshot_path = config.get('snapshot_path', snapshot_path_for(self.recording_path))
1605 self.event_path = os.path.join(self.recording_path, "events")
1606 self.detection_config = {**DEFAULT_DETECTION_CONFIG, **config.get('detection', {})}
1607 self.email_config = {**DEFAULT_EMAIL_CONFIG, **config.get('email', {})}
1608 self._sync_detection_config_ui()
1609 self._sync_email_config_ui()
1610 self.cameras_per_row = config.get('cameras_per_row', 3)
1611 self._restore_preview_camera_ids = config.get('preview_camera_ids', [])
1612 self.selected_camera_id = config.get('selected_camera_id')
1613 self._order_custom = bool(config.get('order_custom', False))
1614 self.next_camera_id = config.get('next_camera_id', 1)
1615
1616 self.grid_cols_spin.setValue(self.cameras_per_row)
1617 if hasattr(self, "language_combo"):
1618 idx = self.language_combo.findData(self.language)
1619 if idx >= 0:
1620 self.language_combo.blockSignals(True)
1621 self.language_combo.setCurrentIndex(idx)
1622 self.language_combo.blockSignals(False)
1623
1624 for camera in self.cameras:
1625 widget = self._get_or_create_camera_widget(camera)
1626 widget.retranslate_ui()
1627
1628 self.update_grid_layout()
1629 self.update_status_display()
1630 self.retranslate_ui()
1631 self._restore_preview_state()
1632
1633 if fixed_config:
1634 self.save_config()
1635 if self._any_camera_detection_enabled():
1636 QTimer.singleShot(300, self._ensure_detection_worker)
1637 except Exception as e:
1638 print(f"Fehler beim Laden: {e}")
1639
1640 def closeEvent(self, event):
1641 """Beim Schließen alle Threads sauber beenden"""
1642 running_threads = [t for t in self.camera_threads.values() if t.isRunning()]
1643 if self.detection_worker is not None:
1644 self.detection_worker.stop(timeout_ms=3000)
1645 self.detection_worker = None
1646 if not running_threads:
1647 event.accept()
1648 return
1649
1650 if not self._closing:
1651 self._closing = True
1652 self._shutdown_started_at = time.monotonic()
1653 self.save_config()
1654 for thread in running_threads:
1655 thread.request_stop()
1656 self.setEnabled(False)
1657 self._show_shutdown_dialog()
1658 QTimer.singleShot(100, self._finish_close_when_streams_stopped)
1659
1660 event.ignore()
1661
1662 def _finish_close_when_streams_stopped(self):
1663 self.camera_threads = {
1664 camera_id: thread
1665 for camera_id, thread in self.camera_threads.items()
1666 if thread.isRunning()
1667 }
1668 if self.camera_threads:
1669 elapsed = time.monotonic() - (self._shutdown_started_at or time.monotonic())
1670 message = f"Closing app. Please wait...\nStopping camera streams ({elapsed:.1f}s)"
1671 self.statusBar().showMessage(message.replace("\n", " "))
1672 if self._shutdown_dialog is not None:
1673 self._shutdown_dialog.setLabelText(message)
1674 QTimer.singleShot(100, self._finish_close_when_streams_stopped)
1675 return
1676
1677 if self._shutdown_dialog is not None:
1678 self._shutdown_dialog.close()
1679 self._shutdown_dialog = None
1680 self.close()
1681
1682 def _show_shutdown_dialog(self):
1683 self.statusBar().showMessage("Closing app. Please wait...")
1684 dialog = QProgressDialog("Closing app. Please wait...\nStopping camera streams", None, 0, 0, self)
1685 dialog.setWindowTitle("Closing app")
1686 dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
1687 dialog.setCancelButton(None)
1688 dialog.setMinimumDuration(0)
1689 dialog.setAutoClose(False)
1690 dialog.setAutoReset(False)
1691 dialog.show()
1692 self._shutdown_dialog = dialog
1693
1694 def select_camera(self, camera_id):
1695 # If multi-view is active (checkboxes), don't use old single-camera selection
1696 if len(self.selected_camera_ids) > 0:
1697 return
1698
1699 self.selected_camera_id = camera_id
1700 self._sync_big_preview_crop_state(camera_id)
1701 self._apply_detection_visual_state()
1702
1703 for cid, widget in self.camera_widgets.items():
1704 if cid == camera_id:
1705 widget.set_selected(True)
1706 else:
1707 widget.set_selected(False)
1708
1709 widget = self.camera_widgets.get(camera_id)
1710 if widget and hasattr(self, 'big_preview_label') and self.big_preview_label is not None:
1711 try:
1712 self.big_preview_label.clear_frame_display_rect()
1713 if camera_id in self.camera_threads and self.camera_threads[camera_id].isRunning():
1714 self.big_preview_label.setText(f"{widget.camera_name}\n{tr('camera.preview.waiting')}")
1715 else:
1716 self.big_preview_label.setText(f"{widget.camera_name}\n{tr('camera.preview.click_to_start')}")
1717 except RuntimeError:
1718 # Label was deleted during multi-view rebuild
1719 pass
1720
1721 if camera_id not in self.camera_threads or not self.camera_threads[camera_id].isRunning():
1722 self.start_single_stream(camera_id)
1723 else:
1724 self._refresh_big_preview_from_last_frame(camera_id)
1725 self.save_config()
1726
1727 def _restore_preview_state(self):
1728 restore_ids = [
1729 cid for cid in getattr(self, "_restore_preview_camera_ids", [])
1730 if cid in self.camera_widgets
1731 ]
1732 if restore_ids:
1733 self.selected_camera_ids = restore_ids
1734 self.selected_camera_id = None
1735 for cid, widget in self.camera_widgets.items():
1736 checked = cid in restore_ids
1737 widget.view_checkbox.blockSignals(True)
1738 widget.view_checkbox.setChecked(checked)
1739 widget.is_selected_for_view = checked
1740 widget.view_checkbox.blockSignals(False)
1741 widget.set_selected(False)
1742 if checked:
1743 widget.video_label.setPixmap(QPixmap())
1744 widget.video_label.setText(f"{widget.camera_name}\n{tr('camera.preview.waiting')}")
1745 self._rebuild_multi_view_layout()
1746 QTimer.singleShot(300, self._start_restored_preview_streams)
1747 return
1748
1749 if self.selected_camera_id in self.camera_widgets:
1750 camera_id = self.selected_camera_id
1751 for cid, widget in self.camera_widgets.items():
1752 widget.set_selected(cid == camera_id)
1753 widget = self.camera_widgets.get(camera_id)
1754 if widget and hasattr(self, "big_preview_label"):
1755 self.big_preview_label.clear_frame_display_rect()
1756 self.big_preview_label.setText(f"{widget.camera_name}\n{tr('camera.preview.waiting')}")
1757 QTimer.singleShot(300, lambda cid=camera_id: self.start_single_stream(cid))
1758
1759 def _start_restored_preview_streams(self):
1760 for cid in list(self.selected_camera_ids):
1761 if cid not in self.camera_threads or not self.camera_threads[cid].isRunning():
1762 self.start_single_stream(cid)
1763
1764 def _on_language_changed(self):
1765 if not hasattr(self, "language_combo"):
1766 return
1767 lang = self.language_combo.currentData()
1768 if not lang:
1769 return
1770 self.language = lang
1771 set_language(lang)
1772 self.retranslate_ui()
1773 for w in self.camera_widgets.values():
1774 w.retranslate_ui()
1775 self.save_config()
1776
1777 def retranslate_ui(self):
1778 self.setWindowTitle(tr("app.title"))
1779 if hasattr(self, "tabs"):
1780 self.tabs.setTabText(0, tr("tab.cameras"))
1781 self.tabs.setTabText(1, tr("tab.config"))
1782 if hasattr(self, "config_group"):
1783 self.config_group.setTitle(tr("group.camera_config"))
1784 if hasattr(self, "detection_group"):
1785 self.detection_group.setTitle(tr("group.detection_config"))
1786 if hasattr(self, "email_group"):
1787 self.email_group.setTitle(tr("group.email_config"))
1788 if hasattr(self, "url_label"):
1789 self.url_label.setText(tr("label.rtsp_url"))
1790 if hasattr(self, "name_label"):
1791 self.name_label.setText(tr("label.name"))
1792 if hasattr(self, "detection_model_label"):
1793 self.detection_model_label.setText(tr("label.detection_model"))
1794 self.detection_device_label.setText(tr("label.detection_device"))
1795 self.detection_imgsz_label.setText(tr("label.detection_imgsz"))
1796 self.detection_conf_label.setText(tr("label.detection_conf"))
1797 self.detection_fps_label.setText(tr("label.detection_fps"))
1798 self.detection_cooldown_label.setText(tr("label.detection_cooldown"))
1799 self.detection_suppress_label.setText(tr("label.detection_suppress"))
1800 self.detection_clip_label.setText(tr("label.detection_clip"))
1801 self.detection_classes_label.setText(tr("label.detection_classes"))
1802 self.detection_motion_classes_label.setText(tr("label.detection_motion_classes"))
1803 self.detection_motion_pixels_label.setText(tr("label.detection_motion_pixels"))
1804 if hasattr(self, "detection_model_test_btn"):
1805 self.detection_model_test_btn.setText(tr("btn.model_test"))
1806 current_device = self.detection_device_combo.currentData()
1807 self.detection_device_combo.blockSignals(True)
1808 self.detection_device_combo.clear()
1809 self.detection_device_combo.addItem(tr("detection.device.auto"), "auto")
1810 self.detection_device_combo.addItem(tr("detection.device.cuda"), "cuda:0")
1811 self.detection_device_combo.addItem(tr("detection.device.cpu"), "cpu")
1812 idx = self.detection_device_combo.findData(current_device or self.detection_config.get("device", "auto"))
1813 if idx >= 0:
1814 self.detection_device_combo.setCurrentIndex(idx)
1815 self.detection_device_combo.blockSignals(False)
1816 if hasattr(self, "email_enabled_check"):
1817 self.email_enabled_check.setText(tr("label.email_enabled"))
1818 self.email_host_label.setText(tr("label.smtp_host"))
1819 self.email_port_label.setText(tr("label.smtp_port"))
1820 self.email_tls_check.setText(tr("label.smtp_tls"))
1821 self.email_user_label.setText(tr("label.smtp_username"))
1822 self.email_password_label.setText(tr("label.smtp_password"))
1823 self.email_from_label.setText(tr("label.email_from"))
1824 self.email_to_label.setText(tr("label.email_to"))
1825 if hasattr(self, "email_test_btn"):
1826 self.email_test_btn.setText(tr("btn.email_test"))
1827 if hasattr(self, "grid_cols_label"):
1828 self.grid_cols_label.setText(tr("label.cameras_per_row"))
1829 if hasattr(self, "url_input"):
1830 self.url_input.setPlaceholderText(tr("placeholder.rtsp_url"))
1831 if hasattr(self, "name_input"):
1832 self.name_input.setPlaceholderText(tr("placeholder.name.short"))
1833 if hasattr(self, "add_btn"):
1834 self.add_btn.setText(tr("btn.add"))
1835 if hasattr(self, "discover_btn"):
1836 self.discover_btn.setText(tr("btn.discover"))
1837 if hasattr(self, "clear_btn"):
1838 self.clear_btn.setText(tr("btn.clear_all"))
1839 if hasattr(self, "path_btn"):
1840 self.path_btn.setText(tr("btn.path"))
1841 if hasattr(self, "language_label"):
1842 self.language_label.setText(tr("label.language"))
1843 if hasattr(self, "language_combo"):
1844 current = self.language_combo.currentData()
1845 self.language_combo.blockSignals(True)
1846 self.language_combo.clear()
1847 self.language_combo.addItem(tr("language.de"), "de")
1848 self.language_combo.addItem(tr("language.en"), "en")
1849 idx = self.language_combo.findData(current or self.language)
1850 if idx >= 0:
1851 self.language_combo.setCurrentIndex(idx)
1852 self.language_combo.blockSignals(False)
1853 if hasattr(self, "start_all_btn"):
1854 self.start_all_btn.setText(tr("btn.start_all"))
1855 if hasattr(self, "stop_all_btn"):
1856 self.stop_all_btn.setText(tr("btn.stop_all"))
1857 if hasattr(self, "record_all_btn"):
1858 if self.record_all_btn.isChecked():
1859 self.record_all_btn.setText(tr("btn.record_all_stop"))
1860 else:
1861 self.record_all_btn.setText(tr("btn.record_all"))
1862 self.update_status_display()
1863 if self.selected_camera_id is None and hasattr(self, "big_preview_label"):
1864 if not self.big_preview_label.pixmap() or self.big_preview_label.pixmap().isNull():
1865 self.big_preview_label.setText(tr("big.select_camera"))
1866
1867 def _on_camera_order_changed(self, ordered_ids):
1868 if getattr(self, "_closing", False):
1869 return
1870 if getattr(self, "_rebuilding_camera_list", False):
1871 return
1872
1873 try:
1874 ordered_ids = [int(x) for x in ordered_ids]
1875 except Exception:
1876 return
1877
1878 if not ordered_ids:
1879 return
1880
1881 camera_by_id = {int(c.get('id')): c for c in self.cameras if c.get('id') is not None}
1882 new_cameras = []
1883 for cid in ordered_ids:
1884 cam = camera_by_id.get(cid)
1885 if cam:
1886 new_cameras.append(cam)
1887
1888 leftover = [c for c in self.cameras if int(c.get('id', -1)) not in set(ordered_ids)]
1889 self.cameras = new_cameras + leftover
1890 self._order_custom = True
1891 self.save_config()
1892 self.update_grid_layout()
1893
1894 def on_camera_selection_changed(self, camera_id, selected):
1895 """Handle checkbox selection change for multi-camera view"""
1896 if selected:
1897 if camera_id not in self.selected_camera_ids:
1898 self.selected_camera_ids.append(camera_id)
1899 else:
1900 if camera_id in self.selected_camera_ids:
1901 self.selected_camera_ids.remove(camera_id)
1902
1903 if self.zoomed_camera_id is not None and self.zoomed_camera_id not in self.selected_camera_ids:
1904 self.zoomed_camera_id = None
1905
1906 self._sync_big_preview_crop_state(self._get_active_big_preview_camera_id())
1907
1908 self._rebuild_multi_view_layout()
1909 self.save_config()
1910
1911 # Start streams for selected cameras
1912 for cid in self.selected_camera_ids:
1913 if cid not in self.camera_threads or not self.camera_threads[cid].isRunning():
1914 self.start_single_stream(cid)
1915
1916 def _create_multi_view_close_button(self, camera_id, label):
1917 close_btn = QPushButton()
1918 close_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarCloseButton))
1919 close_btn.setIconSize(QSize(14, 14))
1920 close_btn.setFixedSize(28, 28)
1921 close_btn.setStyleSheet("""
1922 QPushButton {
1923 background-color: rgba(220, 53, 69, 220);
1924 border: 2px solid white;
1925 border-radius: 14px;
1926 }
1927 QPushButton:hover {
1928 background-color: rgba(200, 35, 51, 255);
1929 border: 2px solid #ffcccc;
1930 }
1931 """)
1932 close_btn.setCursor(Qt.CursorShape.PointingHandCursor)
1933 close_btn.clicked.connect(lambda checked, cid=camera_id: self._close_multi_view_camera(cid))
1934 close_btn.setParent(label)
1935 close_btn.raise_()
1936 if not hasattr(self, 'multi_view_close_buttons'):
1937 self.multi_view_close_buttons = {}
1938 self.multi_view_close_buttons[camera_id] = close_btn
1939 label.installEventFilter(self)
1940 return close_btn
1941
1942 def _rebuild_multi_view_layout(self):
1943 """Rebuild the big preview layout based on selected cameras"""
1944 # Clear existing layout
1945 while self.big_preview_layout.count():
1946 item = self.big_preview_layout.takeAt(0)
1947 if item.widget():
1948 item.widget().deleteLater()
1949 elif item.layout():
1950 self._clear_layout(item.layout())
1951
1952 self.multi_view_labels.clear()
1953 if hasattr(self, 'multi_view_close_buttons'):
1954 self.multi_view_close_buttons.clear()
1955
1956 selected_ids = list(self.selected_camera_ids)
1957 if self.zoomed_camera_id is not None and self.zoomed_camera_id in selected_ids:
1958 selected_ids = [self.zoomed_camera_id]
1959
1960 self._sync_big_preview_crop_state(selected_ids[0] if len(selected_ids) == 1 else None)
1961
1962 num_selected = len(selected_ids)
1963
1964 if num_selected == 0:
1965 # No selection - show default message
1966 label = PreviewLabel()
1967 label.setAlignment(Qt.AlignmentFlag.AlignCenter)
1968 label.setText(tr("big.select_camera"))
1969 label.setStyleSheet(self._preview_label_style())
1970 label.setMinimumHeight(360)
1971 label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
1972 self._connect_preview_label(label)
1973 self.big_preview_layout.addWidget(label)
1974 self.big_preview_label = label
1975 elif num_selected == 1:
1976 # Single camera - full view
1977 camera_id = selected_ids[0]
1978 label = PreviewLabel(camera_id)
1979 label.setAlignment(Qt.AlignmentFlag.AlignCenter)
1980 label.setStyleSheet(self._preview_label_style(camera_id))
1981 label.setMinimumHeight(360)
1982 label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
1983 self._connect_preview_label(label)
1984 self.big_preview_layout.addWidget(label)
1985 self.big_preview_label = label
1986 self.multi_view_labels[camera_id] = label
1987
1988 self._create_multi_view_close_button(camera_id, label)
1989 else:
1990 # Multi-camera grid view
1991 # Calculate grid: 2 cameras = 1 row x 2 cols, 3-4 = 2 rows, 5-6 = 3 rows, etc.
1992 cols = 2
1993 rows = (num_selected + 1) // 2
1994
1995 for row in range(rows):
1996 row_layout = QHBoxLayout()
1997 row_layout.setSpacing(4)
1998
1999 # No left spacer - single camera should be on left side
2000
2001 for col in range(cols):
2002 idx = row * cols + col
2003 if idx >= num_selected:
2004 # Add right spacer to fill remaining space (makes single camera take 1/2 width)
2005 spacer = QWidget()
2006 spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
2007 row_layout.addWidget(spacer, 1)
2008 continue
2009
2010 camera_id = selected_ids[idx]
2011
2012 # Container for label + close button
2013 container = QWidget()
2014 container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
2015 container_layout = QVBoxLayout(container)
2016 container_layout.setContentsMargins(0, 0, 0, 0)
2017 container_layout.setSpacing(0)
2018
2019 # Label for video
2020 label = PreviewLabel(camera_id)
2021 label.setAlignment(Qt.AlignmentFlag.AlignCenter)
2022 label.setStyleSheet(self._preview_label_style(camera_id))
2023 label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
2024 label.setMinimumHeight(180)
2025 label.setMinimumWidth(0)
2026 label.setMaximumWidth(16777215) # Qt max
2027 label.setScaledContents(False)
2028 self._connect_preview_label(label)
2029
2030 widget = self.camera_widgets.get(camera_id)
2031 if widget:
2032 label.setText(f"{widget.camera_name}\n{tr('camera.preview.waiting')}")
2033
2034 self._create_multi_view_close_button(camera_id, label)
2035
2036 container_layout.addWidget(label)
2037 row_layout.addWidget(container, 1)
2038 self.multi_view_labels[camera_id] = label
2039 if self.big_preview_label is None or self._is_qobject_deleted(self.big_preview_label):
2040 self.big_preview_label = label
2041
2042 # Set equal stretch for all rows
2043 self.big_preview_layout.addLayout(row_layout, 1)
2044
2045 self._update_big_preview_selection_state()
2046 self._apply_detection_visual_state()
2047
2048 for camera_id in selected_ids:
2049 widget = self.camera_widgets.get(camera_id)
2050 if widget is not None and widget.last_frame is not None:
2051 self._update_multi_view_frame(widget.last_frame, camera_id)
2052
2053 def eventFilter(self, obj, event):
2054 """Event filter to reposition close buttons on label resize"""
2055 if event.type() in (QEvent.Type.Show, QEvent.Type.Resize):
2056 # Check if this is a multi-view label
2057 for camera_id, label in self.multi_view_labels.items():
2058 if obj == label and hasattr(self, 'multi_view_close_buttons'):
2059 close_btn = self.multi_view_close_buttons.get(camera_id)
2060 if close_btn:
2061 # Position button at top-right corner
2062 close_btn.move(label.width() - close_btn.width() - 4, 4)
2063 close_btn.raise_()
2064 return super().eventFilter(obj, event)
2065
2066 def _close_multi_view_camera(self, camera_id):
2067 """Close a camera from multi-view and rebuild grid"""
2068 # Remove from selected list
2069 if camera_id in self.selected_camera_ids:
2070 self.selected_camera_ids.remove(camera_id)
2071
2072 if self.zoomed_camera_id == camera_id:
2073 self.zoomed_camera_id = None
2074
2075 self._sync_big_preview_crop_state(self._get_active_big_preview_camera_id())
2076
2077 # Uncheck the checkbox in the camera widget
2078 widget = self.camera_widgets.get(camera_id)
2079 if widget and widget.view_checkbox:
2080 widget.view_checkbox.blockSignals(True)
2081 widget.view_checkbox.setChecked(False)
2082 widget.view_checkbox.blockSignals(False)
2083
2084 # Clear close button reference
2085 if hasattr(self, 'multi_view_close_buttons') and camera_id in self.multi_view_close_buttons:
2086 del self.multi_view_close_buttons[camera_id]
2087
2088 # Rebuild the multi-view layout
2089 self._rebuild_multi_view_layout()
2090 self.save_config()
2091
2092 def _on_big_preview_label_double_clicked(self, camera_id):
2093 if self.zoomed_camera_id is None:
2094 if len(self.selected_camera_ids) > 1 and camera_id in self.selected_camera_ids:
2095 self.zoomed_camera_id = camera_id
2096 self._rebuild_multi_view_layout()
2097 self.save_config()
2098 return
2099
2100 active_camera_id = self._get_active_big_preview_camera_id()
2101 if active_camera_id is None or camera_id != active_camera_id:
2102 return
2103
2104 if self.preview_crop_camera_id == camera_id and self.preview_crop_rect is not None:
2105 self._clear_big_preview_crop()
2106 self._update_big_preview_selection_state()
2107 self._refresh_big_preview_from_last_frame(camera_id)
2108 return
2109
2110 if self.zoomed_camera_id is not None:
2111 self.zoomed_camera_id = None
2112 self._rebuild_multi_view_layout()
2113 self.save_config()
2114
2115 def _on_big_preview_region_selected(self, camera_id, selection_rect):
2116 active_camera_id = self._get_active_big_preview_camera_id()
2117 if active_camera_id is None or camera_id != active_camera_id:
2118 return
2119 if self.preview_crop_rect is not None:
2120 return
2121
2122 label = self._get_preview_label_for_camera(camera_id)
2123 widget = self.camera_widgets.get(camera_id)
2124 if label is None or widget is None or widget.last_frame is None:
2125 return
2126
2127 frame_rect = label.frame_display_rect()
2128 if frame_rect.isNull():
2129 return
2130
2131 selection_rect = selection_rect.intersected(frame_rect)
2132 if selection_rect.width() < 8 or selection_rect.height() < 8:
2133 return
2134
2135 frame_h, frame_w = widget.last_frame.shape[:2]
2136 rel_x = (selection_rect.x() - frame_rect.x()) / frame_rect.width()
2137 rel_y = (selection_rect.y() - frame_rect.y()) / frame_rect.height()
2138 rel_w = selection_rect.width() / frame_rect.width()
2139 rel_h = selection_rect.height() / frame_rect.height()
2140
2141 crop_x = int(round(rel_x * frame_w))
2142 crop_y = int(round(rel_y * frame_h))
2143 crop_w = int(round(rel_w * frame_w))
2144 crop_h = int(round(rel_h * frame_h))
2145
2146 crop_x = max(0, min(frame_w - 1, crop_x))
2147 crop_y = max(0, min(frame_h - 1, crop_y))
2148 crop_w = max(24, min(frame_w - crop_x, crop_w))
2149 crop_h = max(24, min(frame_h - crop_y, crop_h))
2150
2151 if crop_w <= 0 or crop_h <= 0:
2152 return
2153
2154 self.preview_crop_camera_id = camera_id
2155 self.preview_crop_rect = QRect(crop_x, crop_y, crop_w, crop_h)
2156 self._update_big_preview_selection_state()
2157 self._refresh_big_preview_from_last_frame(camera_id)
2158
2159 def _clear_layout(self, layout):
2160 """Recursively clear a layout"""
2161 while layout.count():
2162 item = layout.takeAt(0)
2163 if item.widget():
2164 item.widget().deleteLater()
2165 elif item.layout():
2166 self._clear_layout(item.layout())
2167
2168 def _update_big_preview_frame(self, frame):
2169 label = getattr(self, "big_preview_label", None)
2170 if label is None or self._is_qobject_deleted(label):
2171 return
2172
2173 crop_rect = None
2174 active_camera_id = self._get_active_big_preview_camera_id()
2175 if (
2176 active_camera_id is not None
2177 and self.preview_crop_camera_id == active_camera_id
2178 and self.preview_crop_rect is not None
2179 ):
2180 crop_rect = self.preview_crop_rect
2181
2182 self._render_frame_to_label(label, frame, crop_rect=crop_rect)
2183 label.setStyleSheet(self._preview_label_style(active_camera_id))
2184 self._update_big_preview_selection_state()
2185
2186 def _update_multi_view_frame(self, frame, camera_id):
2187 """Update frame in multi-camera view with aspect ratio preservation"""
2188 label = self.multi_view_labels.get(camera_id)
2189 if not label:
2190 return
2191
2192 crop_rect = None
2193 if self.zoomed_camera_id == camera_id and self.preview_crop_rect is not None:
2194 if self.preview_crop_camera_id == camera_id:
2195 crop_rect = self.preview_crop_rect
2196
2197 self._render_frame_to_label(label, frame, crop_rect=crop_rect)
2198 label.set_selection_enabled(
2199 camera_id == self._get_active_big_preview_camera_id()
2200 and len(self.multi_view_labels) == 1
2201 and self.preview_crop_rect is None
2202 and self.preview_crop_camera_id is None
2203 )