homesec 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. homesec/__init__.py +20 -0
  2. homesec/app.py +393 -0
  3. homesec/cli.py +159 -0
  4. homesec/config/__init__.py +18 -0
  5. homesec/config/loader.py +109 -0
  6. homesec/config/validation.py +82 -0
  7. homesec/errors.py +71 -0
  8. homesec/health/__init__.py +5 -0
  9. homesec/health/server.py +226 -0
  10. homesec/interfaces.py +249 -0
  11. homesec/logging_setup.py +176 -0
  12. homesec/maintenance/__init__.py +1 -0
  13. homesec/maintenance/cleanup_clips.py +632 -0
  14. homesec/models/__init__.py +79 -0
  15. homesec/models/alert.py +32 -0
  16. homesec/models/clip.py +71 -0
  17. homesec/models/config.py +362 -0
  18. homesec/models/events.py +184 -0
  19. homesec/models/filter.py +62 -0
  20. homesec/models/source.py +77 -0
  21. homesec/models/storage.py +12 -0
  22. homesec/models/vlm.py +99 -0
  23. homesec/pipeline/__init__.py +6 -0
  24. homesec/pipeline/alert_policy.py +5 -0
  25. homesec/pipeline/core.py +639 -0
  26. homesec/plugins/__init__.py +62 -0
  27. homesec/plugins/alert_policies/__init__.py +80 -0
  28. homesec/plugins/alert_policies/default.py +111 -0
  29. homesec/plugins/alert_policies/noop.py +60 -0
  30. homesec/plugins/analyzers/__init__.py +126 -0
  31. homesec/plugins/analyzers/openai.py +446 -0
  32. homesec/plugins/filters/__init__.py +124 -0
  33. homesec/plugins/filters/yolo.py +317 -0
  34. homesec/plugins/notifiers/__init__.py +80 -0
  35. homesec/plugins/notifiers/mqtt.py +189 -0
  36. homesec/plugins/notifiers/multiplex.py +106 -0
  37. homesec/plugins/notifiers/sendgrid_email.py +228 -0
  38. homesec/plugins/storage/__init__.py +116 -0
  39. homesec/plugins/storage/dropbox.py +272 -0
  40. homesec/plugins/storage/local.py +108 -0
  41. homesec/plugins/utils.py +63 -0
  42. homesec/py.typed +0 -0
  43. homesec/repository/__init__.py +5 -0
  44. homesec/repository/clip_repository.py +552 -0
  45. homesec/sources/__init__.py +17 -0
  46. homesec/sources/base.py +224 -0
  47. homesec/sources/ftp.py +209 -0
  48. homesec/sources/local_folder.py +238 -0
  49. homesec/sources/rtsp.py +1251 -0
  50. homesec/state/__init__.py +10 -0
  51. homesec/state/postgres.py +501 -0
  52. homesec/storage_paths.py +46 -0
  53. homesec/telemetry/__init__.py +0 -0
  54. homesec/telemetry/db/__init__.py +1 -0
  55. homesec/telemetry/db/log_table.py +16 -0
  56. homesec/telemetry/db_log_handler.py +246 -0
  57. homesec/telemetry/postgres_settings.py +42 -0
  58. homesec-0.1.0.dist-info/METADATA +446 -0
  59. homesec-0.1.0.dist-info/RECORD +62 -0
  60. homesec-0.1.0.dist-info/WHEEL +4 -0
  61. homesec-0.1.0.dist-info/entry_points.txt +2 -0
  62. homesec-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,1251 @@
1
+ """RTSP motion-detecting clip source.
2
+
3
+ Ported from motion_recorder.py to keep functionality within homesec.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import platform
12
+ import random
13
+ import shlex
14
+ import subprocess
15
+ import time
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timedelta
18
+ from pathlib import Path
19
+ from queue import Empty, Full, Queue
20
+ from threading import Event, Thread
21
+ from typing import Any, cast
22
+
23
+ import cv2
24
+ import numpy as np
25
+ import numpy.typing as npt
26
+ from homesec.models.clip import Clip
27
+ from homesec.models.source import RTSPSourceConfig
28
+ from homesec.sources.base import ThreadedClipSource
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ @dataclass
34
+ class HardwareAccelConfig:
35
+ """Configuration for hardware-accelerated video decoding."""
36
+
37
+ hwaccel: str | None
38
+ hwaccel_device: str | None = None
39
+
40
+ @property
41
+ def is_available(self) -> bool:
42
+ """Check if hardware acceleration is available."""
43
+ return self.hwaccel is not None
44
+
45
+
46
+ class HardwareAccelDetector:
47
+ """Detect available hardware acceleration options for ffmpeg."""
48
+
49
+ @staticmethod
50
+ def detect(rtsp_url: str) -> HardwareAccelConfig:
51
+ """Detect the best available hardware acceleration method."""
52
+ if Path("/dev/dri/renderD128").exists():
53
+ if HardwareAccelDetector._test_hwaccel("vaapi"):
54
+ config = HardwareAccelConfig(
55
+ hwaccel="vaapi",
56
+ hwaccel_device="/dev/dri/renderD128",
57
+ )
58
+ if HardwareAccelDetector._test_decode(rtsp_url, config):
59
+ return config
60
+ logger.warning("VAAPI detected but failed to decode stream - disabling")
61
+
62
+ if HardwareAccelDetector._check_nvidia():
63
+ if HardwareAccelDetector._test_hwaccel("cuda"):
64
+ config = HardwareAccelConfig(hwaccel="cuda")
65
+ if HardwareAccelDetector._test_decode(rtsp_url, config):
66
+ return config
67
+ logger.warning("CUDA detected but failed to decode stream - disabling")
68
+
69
+ if platform.system() == "Darwin":
70
+ if HardwareAccelDetector._test_hwaccel("videotoolbox"):
71
+ config = HardwareAccelConfig(hwaccel="videotoolbox")
72
+ if HardwareAccelDetector._test_decode(rtsp_url, config):
73
+ return config
74
+ logger.warning("VideoToolbox detected but failed to decode stream - disabling")
75
+
76
+ if HardwareAccelDetector._test_hwaccel("qsv"):
77
+ config = HardwareAccelConfig(hwaccel="qsv")
78
+ if HardwareAccelDetector._test_decode(rtsp_url, config):
79
+ return config
80
+ logger.warning("QSV detected but failed to decode stream - disabling")
81
+
82
+ logger.info("Using software decoding (no working hardware acceleration found)")
83
+ return HardwareAccelConfig(hwaccel=None)
84
+
85
+ @staticmethod
86
+ def _test_hwaccel(method: str) -> bool:
87
+ """Test if a hardware acceleration method is available in ffmpeg."""
88
+ try:
89
+ result = subprocess.run(
90
+ ["ffmpeg", "-hwaccels"],
91
+ capture_output=True,
92
+ text=True,
93
+ timeout=2,
94
+ check=False,
95
+ )
96
+ return method in result.stdout
97
+ except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
98
+ return False
99
+
100
+ @staticmethod
101
+ def _test_decode(rtsp_url: str, config: HardwareAccelConfig) -> bool:
102
+ """Test if hardware acceleration works by decoding a few frames."""
103
+ cmd = ["ffmpeg"]
104
+
105
+ if config.hwaccel:
106
+ cmd.extend(["-hwaccel", config.hwaccel])
107
+ if config.hwaccel_device:
108
+ cmd.extend(["-hwaccel_device", config.hwaccel_device])
109
+
110
+ cmd.extend(
111
+ [
112
+ "-rtsp_transport",
113
+ "tcp",
114
+ "-i",
115
+ rtsp_url,
116
+ "-frames:v",
117
+ "5",
118
+ "-f",
119
+ "null",
120
+ "-",
121
+ ]
122
+ )
123
+
124
+ try:
125
+ result = subprocess.run(
126
+ cmd,
127
+ capture_output=True,
128
+ timeout=10,
129
+ text=True,
130
+ check=False,
131
+ )
132
+ if result.returncode == 0:
133
+ return True
134
+ if any(
135
+ err in result.stderr
136
+ for err in (
137
+ "No VA display found",
138
+ "Device creation failed",
139
+ "No device available for decoder",
140
+ "Failed to initialise VAAPI",
141
+ "Cannot load",
142
+ )
143
+ ):
144
+ return False
145
+ return result.returncode == 0
146
+ except subprocess.TimeoutExpired:
147
+ return True
148
+ except Exception as exc:
149
+ logger.warning("VAAPI check failed: %s", exc, exc_info=True)
150
+ return False
151
+
152
+ @staticmethod
153
+ def _check_nvidia() -> bool:
154
+ """Check if NVIDIA GPU is available."""
155
+ try:
156
+ subprocess.run(
157
+ ["nvidia-smi"],
158
+ capture_output=True,
159
+ check=True,
160
+ timeout=2,
161
+ )
162
+ return True
163
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
164
+ return False
165
+
166
+
167
+ class RTSPSource(ThreadedClipSource):
168
+ """RTSP clip source with motion detection.
169
+
170
+ Uses ffmpeg for frame extraction and recording; detects motion from
171
+ downscaled grayscale frames and emits clips when recordings finish.
172
+ """
173
+
174
+ def __init__(self, config: RTSPSourceConfig, camera_name: str) -> None:
175
+ """Initialize RTSP source."""
176
+ super().__init__()
177
+ rtsp_url = config.rtsp_url
178
+ if config.rtsp_url_env:
179
+ env_value = os.getenv(config.rtsp_url_env)
180
+ if env_value:
181
+ rtsp_url = env_value
182
+ if not rtsp_url:
183
+ raise ValueError("rtsp_url_env or rtsp_url required for RTSP source")
184
+
185
+ self.rtsp_url = rtsp_url
186
+
187
+ detect_rtsp_url = config.detect_rtsp_url
188
+ if config.detect_rtsp_url_env:
189
+ env_value = os.getenv(config.detect_rtsp_url_env)
190
+ if env_value:
191
+ detect_rtsp_url = env_value
192
+
193
+ derived_detect = self._derive_detect_rtsp_url(self.rtsp_url)
194
+ if detect_rtsp_url:
195
+ self.detect_rtsp_url = detect_rtsp_url
196
+ self._detect_rtsp_url_source = "explicit"
197
+ elif derived_detect:
198
+ self.detect_rtsp_url = derived_detect
199
+ self._detect_rtsp_url_source = "derived_subtype=1"
200
+ else:
201
+ self.detect_rtsp_url = self.rtsp_url
202
+ self._detect_rtsp_url_source = "same_as_rtsp_url"
203
+
204
+ self.output_dir = Path(config.output_dir)
205
+ sanitized_name = self._sanitize_camera_name(camera_name)
206
+ self.camera_name = sanitized_name or camera_name
207
+
208
+ self.pixel_threshold = int(config.pixel_threshold)
209
+ self.min_changed_pct = float(config.min_changed_pct)
210
+ self.blur_kernel = self._normalize_blur_kernel(config.blur_kernel)
211
+ self.motion_stop_delay = float(config.stop_delay)
212
+ self.max_recording_s = float(config.max_recording_s)
213
+ self.max_reconnect_attempts = int(config.max_reconnect_attempts)
214
+ self.debug_motion = bool(config.debug_motion)
215
+ self.heartbeat_s = float(config.heartbeat_s)
216
+ self.frame_timeout_s = float(config.frame_timeout_s)
217
+ self.frame_queue_size = int(config.frame_queue_size)
218
+ self.reconnect_backoff_s = float(config.reconnect_backoff_s)
219
+ self.rtsp_connect_timeout_s = float(config.rtsp_connect_timeout_s)
220
+ self.rtsp_io_timeout_s = float(config.rtsp_io_timeout_s)
221
+
222
+ if config.disable_hwaccel:
223
+ logger.info("Hardware acceleration manually disabled")
224
+ self.hwaccel_config = HardwareAccelConfig(hwaccel=None)
225
+ self._hwaccel_failed = True
226
+ else:
227
+ logger.info("Testing hardware acceleration with camera stream...")
228
+ self.hwaccel_config = HardwareAccelDetector.detect(self.detect_rtsp_url)
229
+ self._hwaccel_failed = not self.hwaccel_config.is_available
230
+
231
+ self.output_dir.mkdir(parents=True, exist_ok=True)
232
+
233
+ self.recording_process: subprocess.Popen[bytes] | None = None
234
+ self.last_motion_time: float | None = None
235
+ self.recording_start_time: float | None = None
236
+ self.recording_start_wall: datetime | None = None
237
+ self.output_file: Path | None = None
238
+ self._stderr_log: Path | None = None
239
+ self._recording_id: str | None = None
240
+ self._stall_grace_until: float | None = None
241
+
242
+ self._prev_motion_frame: npt.NDArray[np.uint8] | None = None
243
+ self._last_changed_pct = 0.0
244
+ self._last_changed_pixels = 0
245
+ self._debug_frame_count = 0
246
+
247
+ self.frame_pipe: subprocess.Popen[bytes] | None = None
248
+ self._frame_pipe_stderr: Any | None = None
249
+ self._frame_reader_thread: Thread | None = None
250
+ self._frame_reader_stop: Event | None = None
251
+ self._frame_queue: Queue[bytes] | None = None
252
+ self._frame_width: int | None = None
253
+ self._frame_height: int | None = None
254
+ self._frame_size: int | None = None
255
+ self.reconnect_count = 0
256
+ self.last_successful_frame = self._last_heartbeat
257
+
258
+ logger.info(
259
+ "RTSPSource initialized: camera=%s, output_dir=%s",
260
+ self.camera_name,
261
+ self.output_dir,
262
+ )
263
+
264
+ def is_healthy(self) -> bool:
265
+ """Check if source is healthy."""
266
+ if not self._thread_is_healthy():
267
+ return False
268
+
269
+ age = time.monotonic() - self.last_successful_frame
270
+ return age < (self.frame_timeout_s * 3)
271
+
272
+ def _touch_heartbeat(self) -> None:
273
+ self.last_successful_frame = time.monotonic()
274
+ super()._touch_heartbeat()
275
+
276
+ def _stop_timeout(self) -> float:
277
+ return 10.0
278
+
279
+ def _on_start(self) -> None:
280
+ logger.info("Starting RTSPSource: %s", self.camera_name)
281
+
282
+ def _on_stop(self) -> None:
283
+ logger.info("Stopping RTSPSource...")
284
+
285
+ def _on_stopped(self) -> None:
286
+ logger.info("RTSPSource stopped")
287
+
288
+ def _derive_detect_rtsp_url(self, rtsp_url: str) -> str | None:
289
+ if "subtype=0" in rtsp_url:
290
+ return rtsp_url.replace("subtype=0", "subtype=1")
291
+ return None
292
+
293
+ def _sanitize_camera_name(self, name: str | None) -> str | None:
294
+ if not name:
295
+ return None
296
+ raw = str(name).strip()
297
+ if not raw:
298
+ return None
299
+ out: list[str] = []
300
+ for ch in raw:
301
+ if ch.isalnum() or ch in ("-", "_"):
302
+ out.append(ch)
303
+ elif ch.isspace():
304
+ out.append("_")
305
+ else:
306
+ out.append("_")
307
+ cleaned = "".join(out).strip("_")
308
+ while "__" in cleaned:
309
+ cleaned = cleaned.replace("__", "_")
310
+ return cleaned or None
311
+
312
+ def _recording_prefix(self) -> str:
313
+ if not self.camera_name:
314
+ return ""
315
+ return f"{self.camera_name}_"
316
+
317
+ def _make_recording_paths(self, timestamp: str) -> tuple[Path, Path]:
318
+ prefix = self._recording_prefix()
319
+ output_file = self.output_dir / f"{prefix}motion_{timestamp}.mp4"
320
+ stderr_log = self.output_dir / f"{prefix}recording_{timestamp}.log"
321
+ return output_file, stderr_log
322
+
323
+ def _telemetry_common_fields(self) -> dict[str, object]:
324
+ return {
325
+ "pixel_threshold": self.pixel_threshold,
326
+ "min_changed_pct": self.min_changed_pct,
327
+ "blur_kernel": self.blur_kernel,
328
+ "stop_delay": self.motion_stop_delay,
329
+ "max_recording_s": self.max_recording_s,
330
+ "hwaccel": self.hwaccel_config.hwaccel,
331
+ "hwaccel_device": self.hwaccel_config.hwaccel_device,
332
+ }
333
+
334
+ def _event_extra(self, event_type: str, **fields: object) -> dict[str, object]:
335
+ extra: dict[str, object] = {
336
+ "kind": "event",
337
+ "event_type": event_type,
338
+ "camera_name": self.camera_name,
339
+ "recording_id": self._recording_id,
340
+ }
341
+ extra.update(self._telemetry_common_fields())
342
+ extra.update(fields)
343
+ return extra
344
+
345
+ def _probe_stream_info(self, rtsp_url: str) -> dict[str, object] | None:
346
+ cmd = [
347
+ "ffprobe",
348
+ "-v",
349
+ "error",
350
+ "-select_streams",
351
+ "v:0",
352
+ "-show_entries",
353
+ "stream=codec_name,width,height,avg_frame_rate",
354
+ "-of",
355
+ "json",
356
+ "-rtsp_transport",
357
+ "tcp",
358
+ rtsp_url,
359
+ ]
360
+ try:
361
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=10, check=False)
362
+ data = json.loads(result.stdout)
363
+ if not data.get("streams"):
364
+ return None
365
+ stream = data["streams"][0]
366
+ return {
367
+ "codec_name": stream.get("codec_name"),
368
+ "width": stream.get("width"),
369
+ "height": stream.get("height"),
370
+ "avg_frame_rate": stream.get("avg_frame_rate"),
371
+ }
372
+ except Exception as exc:
373
+ logger.warning("Failed to probe stream info: %s", exc, exc_info=True)
374
+ return None
375
+
376
+ def _redact_rtsp_url(self, url: str) -> str:
377
+ if "://" not in url:
378
+ return url
379
+ scheme, rest = url.split("://", 1)
380
+ if "@" not in rest:
381
+ return url
382
+ _creds, host = rest.split("@", 1)
383
+ return f"{scheme}://***:***@{host}"
384
+
385
+ def _format_cmd(self, cmd: list[str]) -> str:
386
+ try:
387
+ return shlex.join([str(x) for x in cmd])
388
+ except Exception as exc:
389
+ logger.warning(
390
+ "Failed to format command with shlex.join: %s", exc, exc_info=True
391
+ )
392
+ return " ".join([str(x) for x in cmd])
393
+
394
+ def detect_motion(self, frame: npt.NDArray[np.uint8]) -> bool:
395
+ """Return True if motion detected in frame."""
396
+ if frame.ndim == 3:
397
+ gray = cast(npt.NDArray[np.uint8], cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY))
398
+ else:
399
+ gray = frame
400
+
401
+ if self.blur_kernel > 1:
402
+ gray = cast(
403
+ npt.NDArray[np.uint8],
404
+ cv2.GaussianBlur(gray, (self.blur_kernel, self.blur_kernel), 0),
405
+ )
406
+
407
+ if self._prev_motion_frame is None:
408
+ self._prev_motion_frame = gray
409
+ self._last_changed_pct = 0.0
410
+ self._last_changed_pixels = 0
411
+ return False
412
+
413
+ diff = cv2.absdiff(self._prev_motion_frame, gray)
414
+ _, mask = cv2.threshold(diff, self.pixel_threshold, 255, cv2.THRESH_BINARY)
415
+ changed_pixels = int(cv2.countNonZero(mask))
416
+
417
+ total_pixels = int(gray.shape[0]) * int(gray.shape[1])
418
+ changed_pct = (changed_pixels / total_pixels * 100.0) if total_pixels else 0.0
419
+
420
+ self._prev_motion_frame = gray
421
+ self._last_changed_pct = changed_pct
422
+ self._last_changed_pixels = changed_pixels
423
+
424
+ motion = changed_pct >= self.min_changed_pct
425
+
426
+ if self.debug_motion:
427
+ self._debug_frame_count += 1
428
+ if self._debug_frame_count % 100 == 0:
429
+ logger.debug(
430
+ "Motion check: changed_pct=%.3f%% changed_px=%s pixel_threshold=%s min_changed_pct=%.3f%% blur=%s",
431
+ changed_pct,
432
+ changed_pixels,
433
+ self.pixel_threshold,
434
+ self.min_changed_pct,
435
+ self.blur_kernel,
436
+ )
437
+
438
+ return motion
439
+
440
+ def check_recording_health(self) -> bool:
441
+ """Check if recording process is still alive."""
442
+ if self.recording_process and self.recording_process.poll() is not None:
443
+ proc = self.recording_process
444
+ output_file = self.output_file
445
+ exit_code = proc.returncode
446
+ logger.warning("Recording process died unexpectedly (exit code: %s)", exit_code)
447
+ if output_file:
448
+ logger.error(
449
+ "Recording process died",
450
+ extra=self._event_extra(
451
+ "recording_process_died",
452
+ recording_id=output_file.name,
453
+ recording_path=str(output_file),
454
+ exit_code=exit_code,
455
+ pid=proc.pid,
456
+ ),
457
+ )
458
+
459
+ log_file: Path | None = None
460
+ if self._stderr_log and self._stderr_log.exists():
461
+ log_file = self._stderr_log
462
+ elif output_file:
463
+ stem = output_file.stem
464
+ if "motion_" in stem:
465
+ prefix_part, timestamp = stem.split("motion_", 1)
466
+ candidate = output_file.parent / f"{prefix_part}recording_{timestamp}.log"
467
+ if candidate.exists():
468
+ log_file = candidate
469
+
470
+ if log_file:
471
+ try:
472
+ with open(log_file, "r") as f:
473
+ error_lines = f.read()
474
+ if error_lines:
475
+ logger.warning("Recording error log:\n%s", error_lines[-1000:])
476
+ except Exception as e:
477
+ logger.warning("Could not read log file: %s", e, exc_info=True)
478
+
479
+ start_wall = self.recording_start_wall
480
+ start_mono = self.recording_start_time
481
+ self.recording_process = None
482
+ self.output_file = None
483
+ self._stderr_log = None
484
+ self.recording_start_time = None
485
+ self.recording_start_wall = None
486
+ self._recording_id = None
487
+
488
+ self._stop_recording_process(proc, output_file)
489
+ self._finalize_clip(output_file, start_wall, start_mono)
490
+ return False
491
+ return True
492
+
493
+ def start_recording(self) -> None:
494
+ """Start ffmpeg recording process with audio."""
495
+ if self.recording_process:
496
+ return
497
+
498
+ self.output_dir.mkdir(parents=True, exist_ok=True)
499
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
500
+ output_file, stderr_log = self._make_recording_paths(timestamp)
501
+
502
+ proc = self._spawn_recording_process(output_file, stderr_log)
503
+ if not proc:
504
+ logger.error(
505
+ "Recording failed to start",
506
+ extra=self._event_extra(
507
+ "recording_start_error",
508
+ recording_id=output_file.name,
509
+ recording_path=str(output_file),
510
+ stderr_log=str(stderr_log),
511
+ ),
512
+ )
513
+ return
514
+
515
+ self.recording_process = proc
516
+ self.output_file = output_file
517
+ self._stderr_log = stderr_log
518
+ self.recording_start_time = time.monotonic()
519
+ self.recording_start_wall = datetime.now()
520
+ self._recording_id = output_file.name
521
+
522
+ logger.info("Started recording: %s (PID: %s)", output_file, proc.pid)
523
+ logger.debug("Recording logs: %s", stderr_log)
524
+ logger.info(
525
+ "Recording started",
526
+ extra=self._event_extra(
527
+ "recording_start",
528
+ recording_id=output_file.name,
529
+ recording_path=str(output_file),
530
+ stderr_log=str(stderr_log),
531
+ pid=proc.pid,
532
+ detect_stream_source=getattr(self, "_detect_rtsp_url_source", None),
533
+ detect_stream_is_same=(self.detect_rtsp_url == self.rtsp_url),
534
+ ),
535
+ )
536
+
537
+ def _spawn_recording_process(
538
+ self, output_file: Path, stderr_log: Path
539
+ ) -> subprocess.Popen[bytes] | None:
540
+ cmd = [
541
+ "ffmpeg",
542
+ "-rtsp_transport",
543
+ "tcp",
544
+ "-rtsp_flags",
545
+ "prefer_tcp",
546
+ "-user_agent",
547
+ "Lavf",
548
+ "-i",
549
+ self.rtsp_url,
550
+ "-c",
551
+ "copy",
552
+ "-f",
553
+ "mp4",
554
+ "-y",
555
+ str(output_file),
556
+ ]
557
+
558
+ safe_cmd = list(cmd)
559
+ try:
560
+ idx = safe_cmd.index("-i")
561
+ safe_cmd[idx + 1] = self._redact_rtsp_url(str(safe_cmd[idx + 1]))
562
+ except Exception as exc:
563
+ logger.warning("Failed to redact recording RTSP URL: %s", exc, exc_info=True)
564
+ logger.debug("Recording ffmpeg: %s", self._format_cmd(safe_cmd))
565
+
566
+ try:
567
+ with open(stderr_log, "w") as stderr_file:
568
+ proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=stderr_file)
569
+
570
+ time.sleep(0.5)
571
+ if proc.poll() is not None:
572
+ logger.error("Recording process died immediately (exit code: %s)", proc.returncode)
573
+ logger.error("Check logs at: %s", stderr_log)
574
+ try:
575
+ with open(stderr_log, "r") as f:
576
+ error_lines = f.read()
577
+ if error_lines:
578
+ logger.error("Error output: %s", error_lines[:500])
579
+ except Exception:
580
+ logger.exception("Failed reading recording log: %s", stderr_log)
581
+ return None
582
+
583
+ return proc
584
+ except Exception:
585
+ logger.exception("Failed to start recording")
586
+ return None
587
+
588
+ def stop_recording(self) -> None:
589
+ """Stop ffmpeg recording process."""
590
+ if not self.recording_process:
591
+ return
592
+
593
+ proc = self.recording_process
594
+ output_file = self.output_file
595
+ started_at = self.recording_start_time
596
+ started_wall = self.recording_start_wall
597
+ self.recording_process = None
598
+ self.output_file = None
599
+ self._stderr_log = None
600
+ self.recording_start_time = None
601
+ self.recording_start_wall = None
602
+ self._recording_id = None
603
+
604
+ self._stop_recording_process(proc, output_file)
605
+ self._finalize_clip(output_file, started_wall, started_at)
606
+
607
+ if output_file:
608
+ duration_s = (time.monotonic() - started_at) if started_at else None
609
+ logger.info(
610
+ "Recording stopped",
611
+ extra=self._event_extra(
612
+ "recording_stop",
613
+ recording_id=output_file.name,
614
+ recording_path=str(output_file),
615
+ duration_s=duration_s,
616
+ last_changed_pct=getattr(self, "_last_changed_pct", None),
617
+ last_changed_pixels=getattr(self, "_last_changed_pixels", None),
618
+ ),
619
+ )
620
+
621
+ def _stop_recording_process(
622
+ self, proc: subprocess.Popen[bytes], output_file: Path | None
623
+ ) -> None:
624
+ try:
625
+ if proc.poll() is None:
626
+ proc.terminate()
627
+ proc.wait(timeout=5)
628
+ except subprocess.TimeoutExpired:
629
+ logger.warning("Recording process did not terminate, killing (PID: %s)", proc.pid)
630
+ try:
631
+ proc.kill()
632
+ proc.wait(timeout=2)
633
+ except Exception:
634
+ logger.exception("Failed to kill recording process (PID: %s)", proc.pid)
635
+ except Exception:
636
+ logger.exception("Failed while stopping recording process (PID: %s)", proc.pid)
637
+
638
+ logger.debug(
639
+ "Stopped recording: %s",
640
+ output_file,
641
+ extra={"recording_id": output_file.name if output_file else None},
642
+ )
643
+
644
+ def _rotate_recording_if_needed(self) -> None:
645
+ if not self.recording_process or not self.recording_start_time:
646
+ return
647
+
648
+ now = time.monotonic()
649
+ if (now - self.recording_start_time) < self.max_recording_s:
650
+ return
651
+
652
+ if not self.last_motion_time:
653
+ return
654
+
655
+ if (now - self.last_motion_time) > self.motion_stop_delay:
656
+ return
657
+
658
+ old_proc = self.recording_process
659
+ old_output = self.output_file
660
+ old_started_wall = self.recording_start_wall
661
+ old_started_mono = self.recording_start_time
662
+
663
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
664
+ new_output, new_log = self._make_recording_paths(timestamp)
665
+
666
+ logger.info(
667
+ "Max recording length reached (%.1fs), rotating to: %s",
668
+ self.max_recording_s,
669
+ new_output,
670
+ )
671
+
672
+ new_proc = self._spawn_recording_process(new_output, new_log)
673
+ if new_proc:
674
+ self.last_motion_time = now
675
+ self.recording_process = new_proc
676
+ self.output_file = new_output
677
+ self._stderr_log = new_log
678
+ self.recording_start_time = now
679
+ self.recording_start_wall = datetime.now()
680
+ self._recording_id = new_output.name
681
+
682
+ if old_output:
683
+ logger.info(
684
+ "Recording rotated",
685
+ extra=self._event_extra(
686
+ "recording_rotate",
687
+ recording_id=old_output.name,
688
+ recording_path=str(old_output),
689
+ new_recording_id=new_output.name,
690
+ new_recording_path=str(new_output),
691
+ ),
692
+ )
693
+
694
+ self._stop_recording_process(old_proc, old_output)
695
+ self._finalize_clip(old_output, old_started_wall, old_started_mono)
696
+ return
697
+
698
+ logger.warning("Rotation start failed; stopping current recording and retrying")
699
+ self.recording_process = None
700
+ self.output_file = None
701
+ self.recording_start_time = None
702
+ self.recording_start_wall = None
703
+ self._recording_id = None
704
+ self._stop_recording_process(old_proc, old_output)
705
+ self._finalize_clip(old_output, old_started_wall, old_started_mono)
706
+ self.start_recording()
707
+
708
+ def probe_stream_resolution(self) -> tuple[int, int]:
709
+ """Probe RTSP stream to get native resolution."""
710
+ cmd = [
711
+ "ffprobe",
712
+ "-v",
713
+ "error",
714
+ "-select_streams",
715
+ "v:0",
716
+ "-show_entries",
717
+ "stream=width,height",
718
+ "-of",
719
+ "json",
720
+ "-rtsp_transport",
721
+ "tcp",
722
+ self.rtsp_url,
723
+ ]
724
+ try:
725
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=10, check=False)
726
+ data = json.loads(result.stdout)
727
+ width = data["streams"][0]["width"]
728
+ height = data["streams"][0]["height"]
729
+ return int(width), int(height)
730
+ except Exception as e:
731
+ logger.warning(
732
+ "Failed to probe stream, using default 1920x1080: %s",
733
+ e,
734
+ exc_info=True,
735
+ )
736
+ return 1920, 1080
737
+
738
+ def get_frame_pipe(self) -> tuple[subprocess.Popen[bytes], Any, int, int]:
739
+ """Create ffmpeg process to get frames for motion detection."""
740
+ detect_width, detect_height = 320, 240
741
+
742
+ stderr_log = self.output_dir / "frame_pipeline.log"
743
+ self.output_dir.mkdir(parents=True, exist_ok=True)
744
+
745
+ def _read_tail(path: Path, max_bytes: int = 4000) -> str:
746
+ try:
747
+ data = path.read_bytes()
748
+ except Exception as exc:
749
+ logger.warning("Failed to read stderr tail: %s", exc, exc_info=True)
750
+ return ""
751
+ if len(data) <= max_bytes:
752
+ return data.decode(errors="replace")
753
+ return data[-max_bytes:].decode(errors="replace")
754
+
755
+ cmd = ["ffmpeg"]
756
+
757
+ if self.hwaccel_config.is_available and not self._hwaccel_failed:
758
+ hwaccel = self.hwaccel_config.hwaccel
759
+ if hwaccel is not None:
760
+ cmd.extend(["-hwaccel", hwaccel])
761
+ if self.hwaccel_config.hwaccel_device:
762
+ cmd.extend(["-hwaccel_device", self.hwaccel_config.hwaccel_device])
763
+ elif self._hwaccel_failed:
764
+ logger.info("Hardware acceleration disabled due to previous failures")
765
+
766
+ base_input_args = [
767
+ "-rtsp_transport",
768
+ "tcp",
769
+ "-i",
770
+ self.detect_rtsp_url,
771
+ "-f",
772
+ "rawvideo",
773
+ "-pix_fmt",
774
+ "gray",
775
+ "-vf",
776
+ f"fps=10,scale={detect_width}:{detect_height}",
777
+ "-an",
778
+ "-",
779
+ ]
780
+
781
+ timeout_us_connect = str(int(max(0.1, self.rtsp_connect_timeout_s) * 1_000_000))
782
+ timeout_us_io = str(int(max(0.1, self.rtsp_io_timeout_s) * 1_000_000))
783
+ attempts: list[tuple[str, list[str]]] = [
784
+ (
785
+ "stimeout+rw_timeout",
786
+ ["-stimeout", timeout_us_connect, "-rw_timeout", timeout_us_io] + base_input_args,
787
+ ),
788
+ ("stimeout", ["-stimeout", timeout_us_connect] + base_input_args),
789
+ ("no_timeouts", base_input_args),
790
+ ]
791
+
792
+ process: subprocess.Popen[bytes] | None = None
793
+ stderr_file: Any | None = None
794
+ for label, extra_args in attempts:
795
+ cmd_attempt = list(cmd) + extra_args
796
+ logger.debug("Starting frame pipeline (%s), logging to: %s", label, stderr_log)
797
+ safe_cmd = list(cmd_attempt)
798
+ try:
799
+ idx = safe_cmd.index("-i")
800
+ safe_cmd[idx + 1] = self._redact_rtsp_url(str(safe_cmd[idx + 1]))
801
+ except Exception as exc:
802
+ logger.warning(
803
+ "Failed to redact frame pipeline RTSP URL: %s",
804
+ exc,
805
+ exc_info=True,
806
+ )
807
+ logger.debug("Frame pipeline ffmpeg (%s): %s", label, self._format_cmd(safe_cmd))
808
+
809
+ try:
810
+ stderr_file = open(stderr_log, "w")
811
+ process = subprocess.Popen(
812
+ cmd_attempt,
813
+ stdout=subprocess.PIPE,
814
+ stderr=stderr_file,
815
+ bufsize=detect_width * detect_height,
816
+ )
817
+ except Exception:
818
+ try:
819
+ if stderr_file:
820
+ stderr_file.close()
821
+ except Exception as exc:
822
+ logger.warning("Failed to close stderr log: %s", exc, exc_info=True)
823
+ logger.exception("Failed to start frame pipeline subprocess (%s)", label)
824
+ continue
825
+
826
+ time.sleep(1)
827
+ if process.poll() is None:
828
+ return process, stderr_file, detect_width, detect_height
829
+
830
+ try:
831
+ stderr_file.close()
832
+ except Exception as exc:
833
+ logger.warning("Failed to close stderr log: %s", exc, exc_info=True)
834
+ stderr_tail = _read_tail(stderr_log)
835
+ logger.error("Frame pipeline died immediately (%s, exit code: %s)", label, process.returncode)
836
+ if stderr_tail:
837
+ logger.error("Frame pipeline stderr tail (%s):\n%s", label, stderr_tail)
838
+ process = None
839
+ stderr_file = None
840
+ continue
841
+
842
+ raise RuntimeError("Frame pipeline failed to start")
843
+
844
+ def _stop_process(self, proc: subprocess.Popen[bytes], name: str, terminate_timeout_s: float) -> None:
845
+ if proc.poll() is not None:
846
+ return
847
+ try:
848
+ proc.terminate()
849
+ proc.wait(timeout=terminate_timeout_s)
850
+ except subprocess.TimeoutExpired:
851
+ logger.warning("%s did not terminate, killing (PID: %s)", name, proc.pid)
852
+ proc.kill()
853
+ try:
854
+ proc.wait(timeout=2)
855
+ except Exception:
856
+ logger.exception("Failed waiting after kill for %s (PID: %s)", name, proc.pid)
857
+
858
+ def _stop_frame_pipeline(self) -> None:
859
+ if self._frame_reader_stop is not None:
860
+ self._frame_reader_stop.set()
861
+
862
+ if self.frame_pipe is not None:
863
+ try:
864
+ self._stop_process(self.frame_pipe, "Frame pipeline", terminate_timeout_s=2)
865
+ except Exception:
866
+ logger.exception("Error stopping frame pipeline process")
867
+ self.frame_pipe = None
868
+
869
+ if self._frame_reader_thread is not None:
870
+ try:
871
+ self._frame_reader_thread.join(timeout=5)
872
+ except Exception:
873
+ logger.exception("Error joining frame reader thread")
874
+ self._frame_reader_thread = None
875
+
876
+ self._frame_reader_stop = None
877
+
878
+ if self._frame_pipe_stderr is not None:
879
+ try:
880
+ self._frame_pipe_stderr.close()
881
+ except Exception:
882
+ logger.exception("Error closing frame pipeline stderr log")
883
+ self._frame_pipe_stderr = None
884
+
885
+ self._frame_queue = None
886
+ self._frame_width = None
887
+ self._frame_height = None
888
+ self._frame_size = None
889
+
890
+ def _start_frame_pipeline(self) -> None:
891
+ self._stop_frame_pipeline()
892
+
893
+ self.frame_pipe, self._frame_pipe_stderr, self._frame_width, self._frame_height = self.get_frame_pipe()
894
+ self._frame_size = int(self._frame_width) * int(self._frame_height)
895
+ self._frame_queue = Queue(maxsize=self.frame_queue_size)
896
+ self._frame_reader_stop = Event()
897
+
898
+ frame_pipe = self.frame_pipe
899
+ frame_size = self._frame_size
900
+ frame_queue = self._frame_queue
901
+ stop_event = self._frame_reader_stop
902
+
903
+ def reader_loop() -> None:
904
+ stdout = frame_pipe.stdout if frame_pipe else None
905
+ if stdout is None:
906
+ logger.error("Frame pipeline stdout is None")
907
+ return
908
+
909
+ while not stop_event.is_set():
910
+ try:
911
+ raw = stdout.read(frame_size)
912
+ except Exception:
913
+ logger.exception("Error reading from frame pipeline stdout")
914
+ return
915
+
916
+ if not raw or len(raw) != frame_size:
917
+ return
918
+
919
+ self._touch_heartbeat()
920
+
921
+ try:
922
+ frame_queue.put_nowait(raw)
923
+ except Full:
924
+ try:
925
+ _ = frame_queue.get_nowait()
926
+ except Empty:
927
+ pass
928
+ try:
929
+ frame_queue.put_nowait(raw)
930
+ except Full:
931
+ pass
932
+
933
+ self._frame_reader_thread = Thread(target=reader_loop, name="frame-reader", daemon=True)
934
+ self._frame_reader_thread.start()
935
+
936
+ def _wait_for_first_frame(self, timeout_s: float) -> bool:
937
+ if not self._frame_queue:
938
+ return False
939
+ try:
940
+ raw = self._frame_queue.get(timeout=float(timeout_s))
941
+ except Empty:
942
+ return False
943
+ try:
944
+ self._frame_queue.put_nowait(raw)
945
+ except Full:
946
+ pass
947
+ return True
948
+
949
+ def _reconnect_frame_pipeline(self, *, aggressive: bool) -> bool:
950
+ initial_backoff_s = 0.2 if aggressive else float(self.reconnect_backoff_s)
951
+ backoff_s = initial_backoff_s
952
+ backoff_cap_s = 10.0 if aggressive else float(self.reconnect_backoff_s)
953
+
954
+ first_attempt = True
955
+ while not self._stop_event.is_set():
956
+ self.reconnect_count += 1
957
+
958
+ if self.max_reconnect_attempts == 0:
959
+ logger.warning(
960
+ "Reconnect attempt %s (mode=%s max=inf)...",
961
+ self.reconnect_count,
962
+ "aggressive" if aggressive else "normal",
963
+ )
964
+ else:
965
+ logger.warning(
966
+ "Reconnect attempt %s/%s (mode=%s)...",
967
+ self.reconnect_count,
968
+ self.max_reconnect_attempts,
969
+ "aggressive" if aggressive else "normal",
970
+ )
971
+ if self.reconnect_count >= self.max_reconnect_attempts:
972
+ logger.error("Max reconnect attempts reached. Camera may be offline.")
973
+ return False
974
+
975
+ if first_attempt and aggressive:
976
+ sleep_s = 0.0
977
+ else:
978
+ jitter = random.uniform(0.0, min(0.25, backoff_s * 0.25))
979
+ sleep_s = backoff_s + jitter
980
+ first_attempt = False
981
+
982
+ if sleep_s > 0:
983
+ logger.debug("Waiting %.2fs before reconnect...", sleep_s)
984
+ time.sleep(sleep_s)
985
+
986
+ try:
987
+ self._start_frame_pipeline()
988
+ except Exception as exc:
989
+ logger.warning("Failed to restart frame pipeline: %s", exc, exc_info=True)
990
+ if aggressive:
991
+ backoff_s = min(backoff_s * 1.6, backoff_cap_s)
992
+ continue
993
+
994
+ startup_timeout_s = min(2.0, max(0.5, float(self.frame_timeout_s)))
995
+ if self._wait_for_first_frame(startup_timeout_s):
996
+ logger.info("Reconnected successfully")
997
+ self.reconnect_count = 0
998
+ return True
999
+
1000
+ logger.warning("Frame pipeline restarted but still no frames; retrying...")
1001
+ try:
1002
+ self._stop_frame_pipeline()
1003
+ except Exception as exc:
1004
+ logger.warning(
1005
+ "Failed to stop frame pipeline after reconnect: %s",
1006
+ exc,
1007
+ exc_info=True,
1008
+ )
1009
+ if aggressive:
1010
+ backoff_s = min(backoff_s * 1.6, backoff_cap_s)
1011
+
1012
+ return False
1013
+
1014
+ def cleanup(self) -> None:
1015
+ """Clean up resources."""
1016
+ logger.info("Cleaning up...")
1017
+ try:
1018
+ self.stop_recording()
1019
+ except Exception:
1020
+ logger.exception("Error stopping recording")
1021
+ try:
1022
+ self._stop_frame_pipeline()
1023
+ except Exception:
1024
+ logger.exception("Error stopping frame pipeline")
1025
+
1026
+ def _finalize_clip(
1027
+ self,
1028
+ output_file: Path | None,
1029
+ started_wall: datetime | None,
1030
+ started_mono: float | None,
1031
+ ) -> None:
1032
+ if output_file is None:
1033
+ return
1034
+ try:
1035
+ if not output_file.exists() or output_file.stat().st_size == 0:
1036
+ return
1037
+ except Exception as exc:
1038
+ logger.warning(
1039
+ "Failed to stat output clip %s: %s",
1040
+ output_file,
1041
+ exc,
1042
+ exc_info=True,
1043
+ )
1044
+ return
1045
+
1046
+ end_ts = datetime.now()
1047
+ if started_wall is None:
1048
+ if started_mono is not None:
1049
+ duration_s = time.monotonic() - started_mono
1050
+ start_ts = end_ts - timedelta(seconds=duration_s)
1051
+ else:
1052
+ start_ts = end_ts
1053
+ duration_s = 0.0
1054
+ else:
1055
+ start_ts = started_wall
1056
+ duration_s = (end_ts - start_ts).total_seconds()
1057
+
1058
+ clip = Clip(
1059
+ clip_id=output_file.stem,
1060
+ camera_name=self.camera_name,
1061
+ local_path=output_file,
1062
+ start_ts=start_ts,
1063
+ end_ts=end_ts,
1064
+ duration_s=duration_s,
1065
+ source_type="rtsp",
1066
+ )
1067
+
1068
+ self._emit_clip(clip)
1069
+
1070
+ @staticmethod
1071
+ def _normalize_blur_kernel(blur_kernel: int) -> int:
1072
+ kernel = int(blur_kernel)
1073
+ if kernel < 0:
1074
+ return 0
1075
+ if kernel % 2 == 0 and kernel != 0:
1076
+ return kernel + 1
1077
+ return kernel
1078
+
1079
+ def _log_startup_info(self) -> None:
1080
+ logger.info("Connecting to camera...")
1081
+ logger.info(
1082
+ "Motion: pixel_threshold=%s min_changed_pct=%.3f%% blur=%s stop_delay=%.1fs max_recording_s=%.1fs",
1083
+ self.pixel_threshold,
1084
+ self.min_changed_pct,
1085
+ self.blur_kernel,
1086
+ self.motion_stop_delay,
1087
+ self.max_recording_s,
1088
+ )
1089
+ logger.info("Max reconnect attempts: %s", self.max_reconnect_attempts)
1090
+ if self.max_reconnect_attempts == 0:
1091
+ logger.info("Reconnect policy: retry forever")
1092
+
1093
+ if self.hwaccel_config.is_available:
1094
+ device_info = (
1095
+ f" (device: {self.hwaccel_config.hwaccel_device})"
1096
+ if self.hwaccel_config.hwaccel_device
1097
+ else ""
1098
+ )
1099
+ logger.info("Hardware acceleration: %s%s", self.hwaccel_config.hwaccel, device_info)
1100
+ else:
1101
+ logger.info("Hardware acceleration: disabled (using software decoding)")
1102
+
1103
+ logger.info("Detecting camera resolution...")
1104
+ native_width, native_height = self.probe_stream_resolution()
1105
+ logger.info("Camera resolution: %sx%s", native_width, native_height)
1106
+
1107
+ if self.detect_rtsp_url != self.rtsp_url:
1108
+ info = self._probe_stream_info(self.detect_rtsp_url)
1109
+ if info and info.get("width") and info.get("height"):
1110
+ logger.info(
1111
+ "Motion RTSP stream (%s) available: %s (%sx%s @ %s)",
1112
+ self._detect_rtsp_url_source,
1113
+ self._redact_rtsp_url(self.detect_rtsp_url),
1114
+ info.get("width"),
1115
+ info.get("height"),
1116
+ info.get("avg_frame_rate"),
1117
+ )
1118
+ else:
1119
+ logger.warning(
1120
+ "Motion RTSP stream (%s) did not probe cleanly; falling back to main RTSP stream",
1121
+ self._detect_rtsp_url_source,
1122
+ )
1123
+ self.detect_rtsp_url = self.rtsp_url
1124
+ self._detect_rtsp_url_source = "fallback_to_rtsp_url"
1125
+
1126
+ logger.info("Motion detection: 320x240@10fps (downscaled for efficiency)")
1127
+
1128
+ def _handle_frame_timeout(self) -> bool:
1129
+ pipe_status = self.frame_pipe.poll() if self.frame_pipe else None
1130
+ if pipe_status is not None:
1131
+ logger.error(
1132
+ "Frame pipeline exited (code: %s). Check logs: %s/frame_pipeline.log",
1133
+ pipe_status,
1134
+ self.output_dir,
1135
+ )
1136
+ else:
1137
+ logger.warning(
1138
+ "No frames received for %.1fs (stall). Last frame %.1fs ago.",
1139
+ self.frame_timeout_s,
1140
+ time.monotonic() - self.last_successful_frame,
1141
+ )
1142
+
1143
+ aggressive = self.recording_process is None
1144
+ return self._reconnect_frame_pipeline(aggressive=aggressive)
1145
+
1146
+ def _stall_grace_remaining(self, now: float) -> float:
1147
+ if not self.recording_process or not self.last_motion_time:
1148
+ return 0.0
1149
+ return max(0.0, self.motion_stop_delay - (now - self.last_motion_time))
1150
+
1151
+ def _run(self) -> None:
1152
+ """Start monitoring camera for motion and recording videos."""
1153
+ self._log_startup_info()
1154
+
1155
+ try:
1156
+ self._start_frame_pipeline()
1157
+ logger.info("Connected, monitoring for motion...")
1158
+ self.reconnect_count = 0
1159
+
1160
+ frame_count = 0
1161
+ last_heartbeat = time.monotonic()
1162
+
1163
+ while not self._stop_event.is_set():
1164
+ if time.monotonic() - last_heartbeat > self.heartbeat_s:
1165
+ logger.debug(
1166
+ "[HEARTBEAT] Processed %s frames, recording=%s",
1167
+ frame_count,
1168
+ self.recording_process is not None,
1169
+ )
1170
+ if self.frame_pipe and self.frame_pipe.poll() is not None:
1171
+ logger.error("Frame pipeline died! Exit code: %s", self.frame_pipe.returncode)
1172
+ break
1173
+ last_heartbeat = time.monotonic()
1174
+
1175
+ frame_count += 1
1176
+
1177
+ if not self._frame_queue or not self._frame_width or not self._frame_height or not self._frame_size:
1178
+ break
1179
+
1180
+ try:
1181
+ raw_frame = self._frame_queue.get(timeout=self.frame_timeout_s)
1182
+ except Empty:
1183
+ now = time.monotonic()
1184
+ if self._stall_grace_until is not None and now < self._stall_grace_until:
1185
+ time.sleep(min(0.5, self._stall_grace_until - now))
1186
+ continue
1187
+
1188
+ if not self._handle_frame_timeout():
1189
+ remaining = self._stall_grace_remaining(now)
1190
+ if remaining > 0:
1191
+ self._stall_grace_until = now + remaining
1192
+ logger.warning(
1193
+ "Frame pipeline stalled; keeping recording alive for %.1fs",
1194
+ remaining,
1195
+ )
1196
+ time.sleep(min(0.5, remaining))
1197
+ continue
1198
+ break
1199
+ continue
1200
+
1201
+ self._stall_grace_until = None
1202
+ frame = np.frombuffer(raw_frame, dtype=np.uint8).reshape(
1203
+ (self._frame_height, self._frame_width)
1204
+ )
1205
+ now = time.monotonic()
1206
+ motion_detected = self.detect_motion(frame)
1207
+
1208
+ if motion_detected:
1209
+ logger.debug(
1210
+ "[MOTION DETECTED at frame %s] changed_pct=%.3f%%",
1211
+ frame_count,
1212
+ self._last_changed_pct,
1213
+ )
1214
+ self.last_motion_time = now
1215
+ if not self.recording_process:
1216
+ logger.debug("-> Starting new recording...")
1217
+ self.start_recording()
1218
+ if not self.recording_process:
1219
+ logger.error("-> Recording failed to start!")
1220
+ else:
1221
+ if not self.check_recording_health():
1222
+ logger.warning("-> Recording was dead, trying to restart...")
1223
+ self.recording_process = None
1224
+ self.start_recording()
1225
+
1226
+ if self.recording_process and self.last_motion_time:
1227
+ keepalive_threshold = self.min_changed_pct * 0.5
1228
+ if self._last_changed_pct >= keepalive_threshold:
1229
+ self.last_motion_time = now
1230
+
1231
+ if self.recording_process and self.last_motion_time:
1232
+ if not self.check_recording_health():
1233
+ logger.warning("Recording died, stopping...")
1234
+ self.stop_recording()
1235
+ self.last_motion_time = None
1236
+ elif now - self.last_motion_time > self.motion_stop_delay:
1237
+ logger.info(
1238
+ "No motion for %.1fs > stop_delay=%.1fs (last changed_pct=%.3f%%), stopping",
1239
+ now - self.last_motion_time,
1240
+ self.motion_stop_delay,
1241
+ self._last_changed_pct,
1242
+ )
1243
+ self.stop_recording()
1244
+ self.last_motion_time = None
1245
+ else:
1246
+ self._rotate_recording_if_needed()
1247
+
1248
+ except Exception as e:
1249
+ logger.exception("Unexpected error: %s", e)
1250
+ finally:
1251
+ self.cleanup()