homesec 1.2.1__py3-none-any.whl → 1.2.3__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 (55) hide show
  1. homesec/app.py +5 -14
  2. homesec/cli.py +5 -4
  3. homesec/config/__init__.py +8 -1
  4. homesec/config/loader.py +17 -2
  5. homesec/config/validation.py +99 -6
  6. homesec/interfaces.py +2 -2
  7. homesec/maintenance/cleanup_clips.py +17 -4
  8. homesec/models/__init__.py +3 -23
  9. homesec/models/clip.py +1 -1
  10. homesec/models/config.py +10 -259
  11. homesec/models/enums.py +8 -0
  12. homesec/models/events.py +1 -1
  13. homesec/models/filter.py +3 -21
  14. homesec/models/vlm.py +11 -20
  15. homesec/pipeline/__init__.py +1 -2
  16. homesec/pipeline/core.py +9 -10
  17. homesec/plugins/alert_policies/__init__.py +5 -5
  18. homesec/plugins/alert_policies/default.py +21 -2
  19. homesec/plugins/analyzers/__init__.py +1 -3
  20. homesec/plugins/analyzers/openai.py +20 -13
  21. homesec/plugins/filters/__init__.py +1 -2
  22. homesec/plugins/filters/yolo.py +25 -5
  23. homesec/plugins/notifiers/__init__.py +1 -6
  24. homesec/plugins/notifiers/mqtt.py +21 -1
  25. homesec/plugins/notifiers/sendgrid_email.py +52 -1
  26. homesec/plugins/registry.py +27 -0
  27. homesec/plugins/sources/__init__.py +4 -4
  28. homesec/plugins/sources/ftp.py +1 -1
  29. homesec/plugins/sources/local_folder.py +1 -1
  30. homesec/plugins/sources/rtsp.py +2 -2
  31. homesec/plugins/storage/__init__.py +1 -9
  32. homesec/plugins/storage/dropbox.py +13 -1
  33. homesec/plugins/storage/local.py +8 -1
  34. homesec/repository/clip_repository.py +1 -1
  35. homesec/sources/__init__.py +3 -4
  36. homesec/sources/ftp.py +95 -2
  37. homesec/sources/local_folder.py +27 -2
  38. homesec/sources/rtsp/__init__.py +5 -0
  39. homesec/sources/rtsp/clock.py +18 -0
  40. homesec/sources/rtsp/core.py +1424 -0
  41. homesec/sources/rtsp/frame_pipeline.py +325 -0
  42. homesec/sources/rtsp/hardware.py +143 -0
  43. homesec/sources/rtsp/motion.py +94 -0
  44. homesec/sources/rtsp/recorder.py +180 -0
  45. homesec/sources/rtsp/utils.py +35 -0
  46. {homesec-1.2.1.dist-info → homesec-1.2.3.dist-info}/METADATA +13 -16
  47. homesec-1.2.3.dist-info/RECORD +73 -0
  48. homesec/models/source.py +0 -81
  49. homesec/pipeline/alert_policy.py +0 -5
  50. homesec/sources/rtsp.py +0 -1304
  51. homesec-1.2.1.dist-info/RECORD +0 -68
  52. /homesec/{plugins/notifiers → notifiers}/multiplex.py +0 -0
  53. {homesec-1.2.1.dist-info → homesec-1.2.3.dist-info}/WHEEL +0 -0
  54. {homesec-1.2.1.dist-info → homesec-1.2.3.dist-info}/entry_points.txt +0 -0
  55. {homesec-1.2.1.dist-info → homesec-1.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1424 @@
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 random
12
+ import subprocess
13
+ import time
14
+ from datetime import datetime, timedelta
15
+ from enum import Enum
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ import numpy.typing as npt
20
+ from pydantic import BaseModel, Field, model_validator
21
+
22
+ from homesec.models.clip import Clip
23
+ from homesec.sources.base import ThreadedClipSource
24
+ from homesec.sources.rtsp.clock import Clock, SystemClock
25
+ from homesec.sources.rtsp.frame_pipeline import FfmpegFramePipeline, FramePipeline
26
+ from homesec.sources.rtsp.hardware import HardwareAccelConfig, HardwareAccelDetector
27
+ from homesec.sources.rtsp.motion import MotionDetector
28
+ from homesec.sources.rtsp.recorder import FfmpegRecorder, Recorder
29
+ from homesec.sources.rtsp.utils import (
30
+ _format_cmd,
31
+ _is_timeout_option_error,
32
+ _next_backoff,
33
+ _redact_rtsp_url,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class RTSPMotionConfig(BaseModel):
40
+ """Motion detection configuration."""
41
+
42
+ model_config = {"extra": "forbid"}
43
+
44
+ pixel_threshold: int = Field(
45
+ default=45,
46
+ ge=0,
47
+ description="Pixel intensity delta required to count a pixel as changed.",
48
+ )
49
+ min_changed_pct: float = Field(
50
+ default=1.0,
51
+ ge=0.0,
52
+ description="Percent of pixels that must change to trigger motion (idle state).",
53
+ )
54
+ recording_sensitivity_factor: float = Field(
55
+ default=2.0,
56
+ ge=1.0,
57
+ description="Factor to reduce the threshold while recording (>=1.0).",
58
+ )
59
+ blur_kernel: int = Field(
60
+ default=5,
61
+ ge=0,
62
+ description="Gaussian blur kernel size (odd or zero; even values are normalized).",
63
+ )
64
+
65
+
66
+ class RTSPRecordingConfig(BaseModel):
67
+ """Recording lifecycle configuration."""
68
+
69
+ model_config = {"extra": "forbid"}
70
+
71
+ stop_delay: float = Field(
72
+ default=10.0,
73
+ ge=0.0,
74
+ description="Seconds to keep recording after motion stops.",
75
+ )
76
+ max_recording_s: float = Field(
77
+ default=60.0,
78
+ gt=0.0,
79
+ description="Maximum seconds per recording before rotating.",
80
+ )
81
+
82
+
83
+ class RTSPStreamConfig(BaseModel):
84
+ """RTSP/ffmpeg transport configuration."""
85
+
86
+ model_config = {"extra": "forbid"}
87
+
88
+ connect_timeout_s: float = Field(
89
+ default=2.0,
90
+ ge=0.0,
91
+ description="RTSP connect timeout (seconds) passed to ffmpeg/ffprobe when supported.",
92
+ )
93
+ io_timeout_s: float = Field(
94
+ default=2.0,
95
+ ge=0.0,
96
+ description="RTSP I/O timeout (seconds) passed to ffmpeg/ffprobe when supported.",
97
+ )
98
+ ffmpeg_flags: list[str] = Field(
99
+ default_factory=list,
100
+ description="Additional ffmpeg flags appended to the command.",
101
+ )
102
+ disable_hwaccel: bool = Field(
103
+ default=False,
104
+ description="Disable hardware-accelerated decoding.",
105
+ )
106
+
107
+
108
+ class RTSPReconnectConfig(BaseModel):
109
+ """Reconnect and fallback policy."""
110
+
111
+ model_config = {"extra": "forbid"}
112
+
113
+ max_attempts: int = Field(
114
+ default=0,
115
+ ge=0,
116
+ description="Max reconnect attempts (0 = retry forever).",
117
+ )
118
+ backoff_s: float = Field(
119
+ default=1.0,
120
+ ge=0.0,
121
+ description="Base backoff (seconds) between reconnect attempts.",
122
+ )
123
+ detect_fallback_attempts: int = Field(
124
+ default=3,
125
+ ge=0,
126
+ description="Failures before falling back from detect stream to main stream.",
127
+ )
128
+
129
+
130
+ class RTSPRuntimeConfig(BaseModel):
131
+ """Runtime loop configuration."""
132
+
133
+ model_config = {"extra": "forbid"}
134
+
135
+ frame_timeout_s: float = Field(
136
+ default=2.0,
137
+ ge=0.0,
138
+ description="Seconds without frames before considering the pipeline stalled.",
139
+ )
140
+ frame_queue_size: int = Field(
141
+ default=20,
142
+ ge=1,
143
+ description="Frame queue size used by the frame reader thread.",
144
+ )
145
+ heartbeat_s: float = Field(
146
+ default=30.0,
147
+ ge=0.0,
148
+ description="Seconds between heartbeat logs.",
149
+ )
150
+ debug_motion: bool = Field(
151
+ default=False,
152
+ description="Enable verbose motion detection logging.",
153
+ )
154
+
155
+
156
+ class RTSPSourceConfig(BaseModel):
157
+ """RTSP source configuration."""
158
+
159
+ model_config = {"extra": "forbid"}
160
+
161
+ camera_name: str | None = Field(
162
+ default=None,
163
+ description="Optional human-friendly camera name.",
164
+ )
165
+ rtsp_url_env: str | None = Field(
166
+ default=None,
167
+ description="Environment variable containing the RTSP URL.",
168
+ )
169
+ rtsp_url: str | None = Field(
170
+ default=None,
171
+ description="RTSP URL for the main stream.",
172
+ )
173
+ detect_rtsp_url_env: str | None = Field(
174
+ default=None,
175
+ description="Environment variable containing the detect stream RTSP URL.",
176
+ )
177
+ detect_rtsp_url: str | None = Field(
178
+ default=None,
179
+ description="RTSP URL for the detect stream.",
180
+ )
181
+ output_dir: str = Field(
182
+ default="./recordings",
183
+ description="Directory to store recordings and logs.",
184
+ )
185
+
186
+ motion: RTSPMotionConfig = Field(default_factory=RTSPMotionConfig)
187
+ recording: RTSPRecordingConfig = Field(default_factory=RTSPRecordingConfig)
188
+ stream: RTSPStreamConfig = Field(default_factory=RTSPStreamConfig)
189
+ reconnect: RTSPReconnectConfig = Field(default_factory=RTSPReconnectConfig)
190
+ runtime: RTSPRuntimeConfig = Field(default_factory=RTSPRuntimeConfig)
191
+
192
+ @model_validator(mode="after")
193
+ def _require_rtsp_url(self) -> RTSPSourceConfig:
194
+ if not (self.rtsp_url or self.rtsp_url_env):
195
+ raise ValueError("rtsp_url_env or rtsp_url required for RTSP source")
196
+ return self
197
+
198
+
199
+ class RTSPRunState(str, Enum):
200
+ IDLE = "idle"
201
+ RECORDING = "recording"
202
+ STALLED = "stalled"
203
+ RECONNECTING = "reconnecting"
204
+
205
+
206
+ class RTSPSource(ThreadedClipSource):
207
+ """RTSP clip source with motion detection.
208
+
209
+ Uses ffmpeg for frame extraction and recording; detects motion from
210
+ downscaled grayscale frames and emits clips when recordings finish.
211
+ """
212
+
213
+ def __init__(
214
+ self,
215
+ config: RTSPSourceConfig,
216
+ camera_name: str,
217
+ *,
218
+ frame_pipeline: FramePipeline | None = None,
219
+ recorder: Recorder | None = None,
220
+ clock: Clock | None = None,
221
+ ) -> None:
222
+ """Initialize RTSP source."""
223
+ super().__init__()
224
+ self._clock = clock or SystemClock()
225
+ rtsp_url = config.rtsp_url
226
+ if config.rtsp_url_env:
227
+ env_value = os.getenv(config.rtsp_url_env)
228
+ if env_value:
229
+ rtsp_url = env_value
230
+ if not rtsp_url:
231
+ raise ValueError("rtsp_url_env or rtsp_url required for RTSP source")
232
+
233
+ self.rtsp_url = rtsp_url
234
+
235
+ detect_rtsp_url = config.detect_rtsp_url
236
+ if config.detect_rtsp_url_env:
237
+ env_value = os.getenv(config.detect_rtsp_url_env)
238
+ if env_value:
239
+ detect_rtsp_url = env_value
240
+
241
+ derived_detect = self._derive_detect_rtsp_url(self.rtsp_url)
242
+ if detect_rtsp_url:
243
+ self.detect_rtsp_url = detect_rtsp_url
244
+ self._detect_rtsp_url_source = "explicit"
245
+ elif derived_detect:
246
+ self.detect_rtsp_url = derived_detect
247
+ self._detect_rtsp_url_source = "derived_subtype=1"
248
+ else:
249
+ self.detect_rtsp_url = self.rtsp_url
250
+ self._detect_rtsp_url_source = "same_as_rtsp_url"
251
+
252
+ self.output_dir = Path(config.output_dir)
253
+ sanitized_name = self._sanitize_camera_name(camera_name)
254
+ self.camera_name = sanitized_name or camera_name
255
+
256
+ self.pixel_threshold = int(config.motion.pixel_threshold)
257
+ self.min_changed_pct = float(config.motion.min_changed_pct)
258
+ self.recording_sensitivity_factor = float(config.motion.recording_sensitivity_factor)
259
+ self.blur_kernel = self._normalize_blur_kernel(config.motion.blur_kernel)
260
+ self.motion_stop_delay = float(config.recording.stop_delay)
261
+ self.max_recording_s = float(config.recording.max_recording_s)
262
+ self.max_reconnect_attempts = int(config.reconnect.max_attempts)
263
+ self.detect_fallback_attempts = int(config.reconnect.detect_fallback_attempts)
264
+ self.debug_motion = bool(config.runtime.debug_motion)
265
+ self.heartbeat_s = float(config.runtime.heartbeat_s)
266
+ self.frame_timeout_s = float(config.runtime.frame_timeout_s)
267
+ self.frame_queue_size = int(config.runtime.frame_queue_size)
268
+ self.reconnect_backoff_s = float(config.reconnect.backoff_s)
269
+ self.rtsp_connect_timeout_s = float(config.stream.connect_timeout_s)
270
+ self.rtsp_io_timeout_s = float(config.stream.io_timeout_s)
271
+ self.ffmpeg_flags = list(config.stream.ffmpeg_flags)
272
+
273
+ if config.stream.disable_hwaccel:
274
+ logger.info("Hardware acceleration manually disabled")
275
+ self.hwaccel_config = HardwareAccelConfig(hwaccel=None)
276
+ self._hwaccel_failed = True
277
+ else:
278
+ logger.info("Testing hardware acceleration with camera stream...")
279
+ self.hwaccel_config = HardwareAccelDetector.detect(self.detect_rtsp_url)
280
+ self._hwaccel_failed = not self.hwaccel_config.is_available
281
+
282
+ self.output_dir.mkdir(parents=True, exist_ok=True)
283
+
284
+ self.recording_process: subprocess.Popen[bytes] | None = None
285
+ self.last_motion_time: float | None = None
286
+ self.recording_start_time: float | None = None
287
+ self.recording_start_wall: datetime | None = None
288
+ self.output_file: Path | None = None
289
+ self._stderr_log: Path | None = None
290
+ self._recording_id: str | None = None
291
+ self._recording_restart_backoff_s = 0.0
292
+ self._recording_restart_backoff_max_s = 10.0
293
+ self._recording_restart_base_s = 0.5
294
+ self._recording_next_restart_at: float | None = None
295
+ self._stall_grace_until: float | None = None
296
+ self._motion_detector = MotionDetector(
297
+ pixel_threshold=self.pixel_threshold,
298
+ min_changed_pct=self.min_changed_pct,
299
+ blur_kernel=self.blur_kernel,
300
+ debug=self.debug_motion,
301
+ )
302
+
303
+ self._frame_pipeline: FramePipeline = frame_pipeline or FfmpegFramePipeline(
304
+ output_dir=self.output_dir,
305
+ frame_queue_size=self.frame_queue_size,
306
+ rtsp_connect_timeout_s=self.rtsp_connect_timeout_s,
307
+ rtsp_io_timeout_s=self.rtsp_io_timeout_s,
308
+ ffmpeg_flags=self.ffmpeg_flags,
309
+ hwaccel_config=self.hwaccel_config,
310
+ hwaccel_failed=self._hwaccel_failed,
311
+ on_frame=self._touch_heartbeat,
312
+ clock=self._clock,
313
+ )
314
+ self._recorder: Recorder = recorder or FfmpegRecorder(
315
+ rtsp_url=self.rtsp_url,
316
+ ffmpeg_flags=self.ffmpeg_flags,
317
+ rtsp_connect_timeout_s=self.rtsp_connect_timeout_s,
318
+ rtsp_io_timeout_s=self.rtsp_io_timeout_s,
319
+ clock=self._clock,
320
+ )
321
+ self._run_state = RTSPRunState.IDLE
322
+ self._motion_rtsp_url = self.detect_rtsp_url
323
+ self._detect_stream_available = self.detect_rtsp_url != self.rtsp_url
324
+ self._detect_fallback_active = False
325
+ self._detect_fallback_deferred = False
326
+ self._detect_failure_count = 0
327
+ self._detect_next_probe_at: float | None = None
328
+ self._detect_probe_interval_s = 25.0
329
+ self._detect_probe_backoff_s = self._detect_probe_interval_s
330
+ self._detect_probe_backoff_max_s = 60.0
331
+ self.reconnect_count = 0
332
+ self.last_successful_frame = self._clock.now()
333
+
334
+ logger.info(
335
+ "RTSPSource initialized: camera=%s, output_dir=%s",
336
+ self.camera_name,
337
+ self.output_dir,
338
+ )
339
+
340
+ def is_healthy(self) -> bool:
341
+ """Check if source is healthy."""
342
+ if not self._thread_is_healthy():
343
+ return False
344
+
345
+ age = self._clock.now() - self.last_successful_frame
346
+ return age < (self.frame_timeout_s * 3)
347
+
348
+ def _touch_heartbeat(self) -> None:
349
+ self.last_successful_frame = self._clock.now()
350
+ super()._touch_heartbeat()
351
+
352
+ def _stop_timeout(self) -> float:
353
+ return 10.0
354
+
355
+ def _on_start(self) -> None:
356
+ logger.info("Starting RTSPSource: %s", self.camera_name)
357
+
358
+ def _on_stop(self) -> None:
359
+ logger.info("Stopping RTSPSource...")
360
+
361
+ def _on_stopped(self) -> None:
362
+ logger.info("RTSPSource stopped")
363
+
364
+ def _derive_detect_rtsp_url(self, rtsp_url: str) -> str | None:
365
+ if "subtype=0" in rtsp_url:
366
+ return rtsp_url.replace("subtype=0", "subtype=1")
367
+ return None
368
+
369
+ def _sanitize_camera_name(self, name: str | None) -> str | None:
370
+ if not name:
371
+ return None
372
+ raw = str(name).strip()
373
+ if not raw:
374
+ return None
375
+ out: list[str] = []
376
+ for ch in raw:
377
+ if ch.isalnum() or ch in ("-", "_"):
378
+ out.append(ch)
379
+ elif ch.isspace():
380
+ out.append("_")
381
+ else:
382
+ out.append("_")
383
+ cleaned = "".join(out).strip("_")
384
+ while "__" in cleaned:
385
+ cleaned = cleaned.replace("__", "_")
386
+ return cleaned or None
387
+
388
+ def _recording_prefix(self) -> str:
389
+ if not self.camera_name:
390
+ return ""
391
+ return f"{self.camera_name}_"
392
+
393
+ def _make_recording_paths(self, timestamp: str) -> tuple[Path, Path]:
394
+ prefix = self._recording_prefix()
395
+ output_file = self.output_dir / f"{prefix}motion_{timestamp}.mp4"
396
+ stderr_log = self.output_dir / f"{prefix}recording_{timestamp}.log"
397
+ return output_file, stderr_log
398
+
399
+ def _set_recording_state(
400
+ self,
401
+ *,
402
+ proc: subprocess.Popen[bytes],
403
+ output_file: Path,
404
+ stderr_log: Path,
405
+ start_mono: float,
406
+ start_wall: datetime,
407
+ ) -> None:
408
+ self.recording_process = proc
409
+ self.output_file = output_file
410
+ self._stderr_log = stderr_log
411
+ self.recording_start_time = start_mono
412
+ self.recording_start_wall = start_wall
413
+ self._recording_id = output_file.name
414
+
415
+ def _clear_recording_state(
416
+ self,
417
+ ) -> tuple[
418
+ subprocess.Popen[bytes] | None,
419
+ Path | None,
420
+ float | None,
421
+ datetime | None,
422
+ ]:
423
+ proc = self.recording_process
424
+ output_file = self.output_file
425
+ start_mono = self.recording_start_time
426
+ start_wall = self.recording_start_wall
427
+ self.recording_process = None
428
+ self.output_file = None
429
+ self._stderr_log = None
430
+ self.recording_start_time = None
431
+ self.recording_start_wall = None
432
+ self._recording_id = None
433
+ return proc, output_file, start_mono, start_wall
434
+
435
+ def _telemetry_common_fields(self) -> dict[str, object]:
436
+ return {
437
+ "pixel_threshold": self.pixel_threshold,
438
+ "min_changed_pct": self.min_changed_pct,
439
+ "recording_sensitivity_factor": self.recording_sensitivity_factor,
440
+ "blur_kernel": self.blur_kernel,
441
+ "stop_delay": self.motion_stop_delay,
442
+ "max_recording_s": self.max_recording_s,
443
+ "detect_fallback_attempts": self.detect_fallback_attempts,
444
+ "hwaccel": self.hwaccel_config.hwaccel,
445
+ "hwaccel_device": self.hwaccel_config.hwaccel_device,
446
+ }
447
+
448
+ def _event_extra(self, event_type: str, **fields: object) -> dict[str, object]:
449
+ extra: dict[str, object] = {
450
+ "kind": "event",
451
+ "event_type": event_type,
452
+ "camera_name": self.camera_name,
453
+ "recording_id": self._recording_id,
454
+ }
455
+ extra.update(self._telemetry_common_fields())
456
+ extra.update(fields)
457
+ return extra
458
+
459
+ def _probe_stream_info(
460
+ self,
461
+ rtsp_url: str,
462
+ *,
463
+ timeout_s: float = 10.0,
464
+ ) -> dict[str, object] | None:
465
+ base_cmd = [
466
+ "ffprobe",
467
+ "-v",
468
+ "error",
469
+ "-select_streams",
470
+ "v:0",
471
+ "-show_entries",
472
+ "stream=codec_name,width,height,avg_frame_rate",
473
+ "-of",
474
+ "json",
475
+ "-rtsp_transport",
476
+ "tcp",
477
+ ]
478
+ timeout_args: list[str] = []
479
+ if self.rtsp_connect_timeout_s > 0:
480
+ timeout_us_connect = str(int(max(0.1, self.rtsp_connect_timeout_s) * 1_000_000))
481
+ timeout_args.extend(["-stimeout", timeout_us_connect])
482
+ if self.rtsp_io_timeout_s > 0:
483
+ timeout_us_io = str(int(max(0.1, self.rtsp_io_timeout_s) * 1_000_000))
484
+ timeout_args.extend(["-rw_timeout", timeout_us_io])
485
+
486
+ attempts: list[tuple[str, list[str]]] = []
487
+ if timeout_args:
488
+ attempts.append(("timeouts", base_cmd + timeout_args + [rtsp_url]))
489
+ attempts.append(("no_timeouts" if timeout_args else "default", base_cmd + [rtsp_url]))
490
+
491
+ for label, cmd in attempts:
492
+ try:
493
+ result = subprocess.run(
494
+ cmd,
495
+ capture_output=True,
496
+ text=True,
497
+ timeout=timeout_s,
498
+ check=False,
499
+ )
500
+ if result.returncode != 0:
501
+ if _is_timeout_option_error(result.stderr):
502
+ logger.debug("ffprobe missing timeout options (%s), retrying", label)
503
+ continue
504
+ logger.debug("ffprobe failed (%s) with exit code %s", label, result.returncode)
505
+ break
506
+
507
+ data = json.loads(result.stdout)
508
+ if not data.get("streams"):
509
+ continue
510
+ stream = data["streams"][0]
511
+ return {
512
+ "codec_name": stream.get("codec_name"),
513
+ "width": stream.get("width"),
514
+ "height": stream.get("height"),
515
+ "avg_frame_rate": stream.get("avg_frame_rate"),
516
+ }
517
+ except Exception as exc:
518
+ logger.warning("Failed to probe stream info: %s", exc, exc_info=True)
519
+ return None
520
+
521
+ return None
522
+
523
+ def _redact_rtsp_url(self, url: str) -> str:
524
+ return _redact_rtsp_url(url)
525
+
526
+ def _format_cmd(self, cmd: list[str]) -> str:
527
+ return _format_cmd(cmd)
528
+
529
+ def detect_motion(
530
+ self, frame: npt.NDArray[np.uint8], *, threshold: float | None = None
531
+ ) -> bool:
532
+ """Return True if motion detected in frame."""
533
+ return self._motion_detector.detect(frame, threshold=threshold)
534
+
535
+ def check_recording_health(self) -> bool:
536
+ """Check if recording process is still alive."""
537
+ if self.recording_process and not self._recorder.is_alive(self.recording_process):
538
+ proc = self.recording_process
539
+ output_file = self.output_file
540
+ exit_code = getattr(proc, "returncode", None)
541
+ logger.warning("Recording process died unexpectedly (exit code: %s)", exit_code)
542
+ if output_file:
543
+ logger.error(
544
+ "Recording process died",
545
+ extra=self._event_extra(
546
+ "recording_process_died",
547
+ recording_id=output_file.name,
548
+ recording_path=str(output_file),
549
+ exit_code=exit_code,
550
+ pid=proc.pid,
551
+ ),
552
+ )
553
+
554
+ log_file: Path | None = None
555
+ if self._stderr_log and self._stderr_log.exists():
556
+ log_file = self._stderr_log
557
+ elif output_file:
558
+ stem = output_file.stem
559
+ if "motion_" in stem:
560
+ prefix_part, timestamp = stem.split("motion_", 1)
561
+ candidate = output_file.parent / f"{prefix_part}recording_{timestamp}.log"
562
+ if candidate.exists():
563
+ log_file = candidate
564
+
565
+ if log_file:
566
+ try:
567
+ with open(log_file) as f:
568
+ error_lines = f.read()
569
+ if error_lines:
570
+ logger.warning("Recording error log:\n%s", error_lines[-1000:])
571
+ except Exception as e:
572
+ logger.warning("Could not read log file: %s", e, exc_info=True)
573
+
574
+ cleared_proc, cleared_output, start_mono, start_wall = self._clear_recording_state()
575
+ if cleared_proc is not None:
576
+ self._stop_recording_process(cleared_proc, cleared_output)
577
+ self._finalize_clip(cleared_output, start_wall, start_mono)
578
+ return False
579
+ return True
580
+
581
+ def start_recording(self) -> None:
582
+ """Start ffmpeg recording process with audio."""
583
+ if self.recording_process:
584
+ return
585
+ now = self._clock.now()
586
+ if not self._recording_backoff_ready(now):
587
+ return
588
+
589
+ self.output_dir.mkdir(parents=True, exist_ok=True)
590
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
591
+ output_file, stderr_log = self._make_recording_paths(timestamp)
592
+
593
+ proc = self._recorder.start(output_file, stderr_log)
594
+ if not proc:
595
+ self._bump_recording_backoff(now)
596
+ logger.error(
597
+ "Recording failed to start",
598
+ extra=self._event_extra(
599
+ "recording_start_error",
600
+ recording_id=output_file.name,
601
+ recording_path=str(output_file),
602
+ stderr_log=str(stderr_log),
603
+ ),
604
+ )
605
+ return
606
+
607
+ self._set_recording_state(
608
+ proc=proc,
609
+ output_file=output_file,
610
+ stderr_log=stderr_log,
611
+ start_mono=self._clock.now(),
612
+ start_wall=datetime.now(),
613
+ )
614
+ self._reset_recording_backoff()
615
+
616
+ logger.info("Started recording: %s (PID: %s)", output_file, proc.pid)
617
+ logger.debug("Recording logs: %s", stderr_log)
618
+ logger.info(
619
+ "Recording started",
620
+ extra=self._event_extra(
621
+ "recording_start",
622
+ recording_id=output_file.name,
623
+ recording_path=str(output_file),
624
+ stderr_log=str(stderr_log),
625
+ pid=proc.pid,
626
+ detect_stream_source=getattr(self, "_detect_rtsp_url_source", None),
627
+ detect_stream_is_same=(self.detect_rtsp_url == self.rtsp_url),
628
+ ),
629
+ )
630
+
631
+ def stop_recording(self) -> None:
632
+ """Stop ffmpeg recording process."""
633
+ if not self.recording_process:
634
+ return
635
+
636
+ proc, output_file, started_at, started_wall = self._clear_recording_state()
637
+ if proc is not None:
638
+ self._stop_recording_process(proc, output_file)
639
+ self._finalize_clip(output_file, started_wall, started_at)
640
+
641
+ if output_file:
642
+ duration_s = (self._clock.now() - started_at) if started_at else None
643
+ logger.info(
644
+ "Recording stopped",
645
+ extra=self._event_extra(
646
+ "recording_stop",
647
+ recording_id=output_file.name,
648
+ recording_path=str(output_file),
649
+ duration_s=duration_s,
650
+ last_changed_pct=self._motion_detector.last_changed_pct,
651
+ last_changed_pixels=self._motion_detector.last_changed_pixels,
652
+ ),
653
+ )
654
+
655
+ def _stop_recording_process(
656
+ self, proc: subprocess.Popen[bytes], output_file: Path | None
657
+ ) -> None:
658
+ try:
659
+ self._recorder.stop(proc, output_file)
660
+ except Exception:
661
+ logger.exception("Failed while stopping recording process (PID: %s)", proc.pid)
662
+
663
+ def _rotate_recording_if_needed(self) -> None:
664
+ if self.recording_process is None or self.recording_start_time is None:
665
+ return
666
+
667
+ now = self._clock.now()
668
+ if (now - self.recording_start_time) < self.max_recording_s:
669
+ return
670
+
671
+ if self.last_motion_time is None:
672
+ return
673
+
674
+ if (now - self.last_motion_time) > self.motion_stop_delay:
675
+ return
676
+
677
+ old_proc = self.recording_process
678
+ old_output = self.output_file
679
+ old_started_wall = self.recording_start_wall
680
+ old_started_mono = self.recording_start_time
681
+
682
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
683
+ new_output, new_log = self._make_recording_paths(timestamp)
684
+
685
+ logger.info(
686
+ "Max recording length reached (%.1fs), rotating to: %s",
687
+ self.max_recording_s,
688
+ new_output,
689
+ )
690
+
691
+ new_proc = self._recorder.start(new_output, new_log)
692
+ if new_proc:
693
+ self.last_motion_time = now
694
+ self._set_recording_state(
695
+ proc=new_proc,
696
+ output_file=new_output,
697
+ stderr_log=new_log,
698
+ start_mono=now,
699
+ start_wall=datetime.now(),
700
+ )
701
+
702
+ if old_output:
703
+ logger.info(
704
+ "Recording rotated",
705
+ extra=self._event_extra(
706
+ "recording_rotate",
707
+ recording_id=old_output.name,
708
+ recording_path=str(old_output),
709
+ new_recording_id=new_output.name,
710
+ new_recording_path=str(new_output),
711
+ ),
712
+ )
713
+
714
+ self._stop_recording_process(old_proc, old_output)
715
+ self._finalize_clip(old_output, old_started_wall, old_started_mono)
716
+ return
717
+
718
+ logger.warning("Rotation start failed; stopping current recording and retrying")
719
+ proc, output_file, started_mono, started_wall = self._clear_recording_state()
720
+ if proc is not None:
721
+ self._stop_recording_process(proc, output_file)
722
+ self._finalize_clip(output_file, started_wall, started_mono)
723
+ self.start_recording()
724
+
725
+ def _start_frame_pipeline(self) -> None:
726
+ try:
727
+ self._frame_pipeline.stop()
728
+ except Exception:
729
+ logger.exception("Error stopping frame pipeline before start")
730
+ self._frame_pipeline.start(self._motion_rtsp_url)
731
+ self._motion_detector.reset()
732
+
733
+ def _stop_frame_pipeline(self) -> None:
734
+ self._frame_pipeline.stop()
735
+ self._motion_detector.reset()
736
+
737
+ def _wait_for_first_frame(self, timeout_s: float) -> bool:
738
+ return self._frame_pipeline.read_frame(timeout_s) is not None
739
+
740
+ def _reconnect_frame_pipeline(self, *, aggressive: bool) -> bool:
741
+ initial_backoff_s = 0.2 if aggressive else float(self.reconnect_backoff_s)
742
+ backoff_s = initial_backoff_s
743
+ backoff_cap_s = 10.0 if aggressive else float(self.reconnect_backoff_s)
744
+
745
+ first_attempt = True
746
+ while not self._stop_event.is_set():
747
+ self.reconnect_count += 1
748
+
749
+ if self.max_reconnect_attempts == 0:
750
+ logger.warning(
751
+ "Reconnect attempt %s (mode=%s max=inf)...",
752
+ self.reconnect_count,
753
+ "aggressive" if aggressive else "normal",
754
+ extra=self._event_extra(
755
+ "rtsp_reconnect_attempt",
756
+ attempt=self.reconnect_count,
757
+ max_attempts=None,
758
+ mode="aggressive" if aggressive else "normal",
759
+ detect_is_fallback=self._detect_fallback_active,
760
+ motion_rtsp_url=self._redact_rtsp_url(self._motion_rtsp_url),
761
+ ),
762
+ )
763
+ else:
764
+ logger.warning(
765
+ "Reconnect attempt %s/%s (mode=%s)...",
766
+ self.reconnect_count,
767
+ self.max_reconnect_attempts,
768
+ "aggressive" if aggressive else "normal",
769
+ extra=self._event_extra(
770
+ "rtsp_reconnect_attempt",
771
+ attempt=self.reconnect_count,
772
+ max_attempts=self.max_reconnect_attempts,
773
+ mode="aggressive" if aggressive else "normal",
774
+ detect_is_fallback=self._detect_fallback_active,
775
+ motion_rtsp_url=self._redact_rtsp_url(self._motion_rtsp_url),
776
+ ),
777
+ )
778
+
779
+ sleep_s = self._reconnect_sleep_s(
780
+ backoff_s=backoff_s,
781
+ aggressive=aggressive,
782
+ first_attempt=first_attempt,
783
+ )
784
+ first_attempt = False
785
+
786
+ if sleep_s > 0:
787
+ logger.debug("Waiting %.2fs before reconnect...", sleep_s)
788
+ self._clock.sleep(sleep_s)
789
+
790
+ try:
791
+ self._start_frame_pipeline()
792
+ except Exception as exc:
793
+ logger.warning("Failed to restart frame pipeline: %s", exc, exc_info=True)
794
+ triggered_fallback = self._note_detect_failure(self._clock.now())
795
+ if self._reconnect_exhausted(aggressive=aggressive):
796
+ return False
797
+ if triggered_fallback:
798
+ backoff_s = initial_backoff_s
799
+ first_attempt = True
800
+ continue
801
+ backoff_s = self._bump_reconnect_backoff(
802
+ backoff_s,
803
+ backoff_cap_s,
804
+ aggressive=aggressive,
805
+ )
806
+ continue
807
+
808
+ startup_timeout_s = min(2.0, max(0.5, float(self.frame_timeout_s)))
809
+ if self._wait_for_first_frame(startup_timeout_s):
810
+ logger.info(
811
+ "Reconnected successfully",
812
+ extra=self._event_extra(
813
+ "rtsp_reconnect_success",
814
+ attempt=self.reconnect_count,
815
+ mode="aggressive" if aggressive else "normal",
816
+ detect_is_fallback=self._detect_fallback_active,
817
+ motion_rtsp_url=self._redact_rtsp_url(self._motion_rtsp_url),
818
+ ),
819
+ )
820
+ self.reconnect_count = 0
821
+ self._detect_failure_count = 0
822
+ self._detect_fallback_deferred = False
823
+ return True
824
+
825
+ logger.warning("Frame pipeline restarted but still no frames; retrying...")
826
+ self._note_detect_failure(self._clock.now())
827
+ try:
828
+ self._stop_frame_pipeline()
829
+ except Exception as exc:
830
+ logger.warning(
831
+ "Failed to stop frame pipeline after reconnect: %s",
832
+ exc,
833
+ exc_info=True,
834
+ )
835
+ if self._reconnect_exhausted(aggressive=aggressive):
836
+ return False
837
+ backoff_s = self._bump_reconnect_backoff(
838
+ backoff_s,
839
+ backoff_cap_s,
840
+ aggressive=aggressive,
841
+ )
842
+
843
+ return False
844
+
845
+ def _reconnect_sleep_s(
846
+ self,
847
+ *,
848
+ backoff_s: float,
849
+ aggressive: bool,
850
+ first_attempt: bool,
851
+ ) -> float:
852
+ if first_attempt and aggressive:
853
+ return 0.0
854
+ jitter = random.uniform(0.0, min(0.25, backoff_s * 0.25))
855
+ return backoff_s + jitter
856
+
857
+ def _bump_reconnect_backoff(
858
+ self,
859
+ backoff_s: float,
860
+ backoff_cap_s: float,
861
+ *,
862
+ aggressive: bool,
863
+ ) -> float:
864
+ if not aggressive:
865
+ return backoff_s
866
+ return _next_backoff(backoff_s, backoff_cap_s)
867
+
868
+ def _recording_backoff_ready(self, now: float) -> bool:
869
+ next_retry = self._recording_next_restart_at
870
+ if next_retry is None or now >= next_retry:
871
+ return True
872
+ remaining = next_retry - now
873
+ logger.debug("Recording restart backoff active (%.2fs remaining)", remaining)
874
+ return False
875
+
876
+ def _bump_recording_backoff(self, now: float) -> None:
877
+ if self._recording_restart_backoff_s <= 0:
878
+ self._recording_restart_backoff_s = self._recording_restart_base_s
879
+ else:
880
+ self._recording_restart_backoff_s = _next_backoff(
881
+ self._recording_restart_backoff_s,
882
+ self._recording_restart_backoff_max_s,
883
+ )
884
+ self._recording_next_restart_at = now + self._recording_restart_backoff_s
885
+
886
+ def _reset_recording_backoff(self) -> None:
887
+ self._recording_restart_backoff_s = 0.0
888
+ self._recording_next_restart_at = None
889
+
890
+ def _reconnect_exhausted(self, *, aggressive: bool) -> bool:
891
+ if self.max_reconnect_attempts <= 0:
892
+ return False
893
+ if self.reconnect_count < self.max_reconnect_attempts:
894
+ return False
895
+ logger.error(
896
+ "Max reconnect attempts reached. Camera may be offline.",
897
+ extra=self._event_extra(
898
+ "rtsp_reconnect_failed",
899
+ attempt=self.reconnect_count,
900
+ max_attempts=self.max_reconnect_attempts,
901
+ mode="aggressive" if aggressive else "normal",
902
+ detect_is_fallback=self._detect_fallback_active,
903
+ motion_rtsp_url=self._redact_rtsp_url(self._motion_rtsp_url),
904
+ ),
905
+ )
906
+ return True
907
+
908
+ def _note_detect_failure(self, now: float) -> bool:
909
+ if not self._detect_stream_available:
910
+ return False
911
+ if self._motion_rtsp_url != self.detect_rtsp_url:
912
+ return False
913
+ if self._detect_fallback_active:
914
+ return False
915
+ if self.detect_fallback_attempts <= 0:
916
+ return False
917
+
918
+ self._detect_failure_count = min(
919
+ self._detect_failure_count + 1,
920
+ self.detect_fallback_attempts,
921
+ )
922
+ if self._detect_failure_count < self.detect_fallback_attempts:
923
+ return False
924
+
925
+ if self.recording_process is not None:
926
+ if not self._detect_fallback_deferred:
927
+ logger.warning(
928
+ "Detect fallback deferred while recording",
929
+ extra=self._event_extra(
930
+ "detect_fallback_deferred",
931
+ detect_stream_source=getattr(self, "_detect_rtsp_url_source", None),
932
+ detect_stream_is_same=False,
933
+ ),
934
+ )
935
+ self._detect_fallback_deferred = True
936
+ return False
937
+
938
+ self._activate_detect_fallback(now)
939
+ return True
940
+
941
+ def _activate_detect_fallback(self, now: float) -> None:
942
+ self._detect_fallback_active = True
943
+ self._detect_fallback_deferred = False
944
+ self._detect_failure_count = 0
945
+ self._motion_rtsp_url = self.rtsp_url
946
+ self._detect_next_probe_at = now + self._detect_probe_interval_s
947
+ self._detect_probe_backoff_s = self._detect_probe_interval_s
948
+ logger.warning(
949
+ "Detect stream failed; falling back to main RTSP stream",
950
+ extra=self._event_extra(
951
+ "detect_fallback_enabled",
952
+ detect_stream_source=getattr(self, "_detect_rtsp_url_source", None),
953
+ detect_stream_is_same=False,
954
+ ),
955
+ )
956
+
957
+ def _schedule_detect_probe(self, now: float) -> None:
958
+ self._detect_next_probe_at = now + self._detect_probe_backoff_s
959
+ self._detect_probe_backoff_s = _next_backoff(
960
+ self._detect_probe_backoff_s,
961
+ self._detect_probe_backoff_max_s,
962
+ )
963
+
964
+ def _maybe_recover_detect_stream(self, now: float) -> bool:
965
+ if not self._detect_fallback_active:
966
+ return False
967
+ if not self._detect_stream_available:
968
+ return False
969
+ if self._detect_next_probe_at is not None and now < self._detect_next_probe_at:
970
+ return False
971
+
972
+ info = self._probe_stream_info(self.detect_rtsp_url, timeout_s=3.0)
973
+ if not info or not info.get("width") or not info.get("height"):
974
+ self._schedule_detect_probe(now)
975
+ return False
976
+
977
+ logger.info(
978
+ "Detect stream recovered; attempting switch back",
979
+ extra=self._event_extra(
980
+ "detect_fallback_recovering",
981
+ detect_stream_source=getattr(self, "_detect_rtsp_url_source", None),
982
+ detect_stream_is_same=False,
983
+ ),
984
+ )
985
+
986
+ self._motion_rtsp_url = self.detect_rtsp_url
987
+ try:
988
+ self._start_frame_pipeline()
989
+ startup_timeout_s = min(2.0, max(0.5, float(self.frame_timeout_s)))
990
+ if self._wait_for_first_frame(startup_timeout_s):
991
+ self._detect_fallback_active = False
992
+ self._detect_next_probe_at = None
993
+ self._detect_probe_backoff_s = self._detect_probe_interval_s
994
+ logger.info(
995
+ "Detect stream restored; using detect stream again",
996
+ extra=self._event_extra(
997
+ "detect_fallback_recovered",
998
+ detect_stream_source=getattr(self, "_detect_rtsp_url_source", None),
999
+ detect_stream_is_same=False,
1000
+ ),
1001
+ )
1002
+ return True
1003
+ except Exception as exc:
1004
+ logger.warning("Detect stream switch failed: %s", exc, exc_info=True)
1005
+
1006
+ self._motion_rtsp_url = self.rtsp_url
1007
+ self._detect_fallback_active = True
1008
+ self._schedule_detect_probe(now)
1009
+ try:
1010
+ self._start_frame_pipeline()
1011
+ except Exception as exc:
1012
+ logger.warning(
1013
+ "Failed to restart fallback pipeline after detect switch: %s",
1014
+ exc,
1015
+ exc_info=True,
1016
+ )
1017
+ return False
1018
+
1019
+ def _recording_threshold(self) -> float:
1020
+ if self.recording_sensitivity_factor <= 0:
1021
+ return self.min_changed_pct
1022
+ return max(0.0, self.min_changed_pct / self.recording_sensitivity_factor)
1023
+
1024
+ def _set_run_state(self, state: RTSPRunState) -> None:
1025
+ if self._run_state == state:
1026
+ return
1027
+ logger.debug("RTSP state change: %s -> %s", self._run_state, state)
1028
+ self._run_state = state
1029
+
1030
+ def _ensure_recording(self, now: float) -> None:
1031
+ if self.recording_process:
1032
+ if not self.check_recording_health():
1033
+ logger.warning(
1034
+ "Recording died, attempting restart",
1035
+ extra=self._event_extra(
1036
+ "recording_restart_attempt",
1037
+ reason="health_check",
1038
+ ),
1039
+ )
1040
+ if self.last_motion_time is not None and (
1041
+ (now - self.last_motion_time) <= self.motion_stop_delay
1042
+ ):
1043
+ self.start_recording()
1044
+ if self.recording_process:
1045
+ logger.info(
1046
+ "Recording restarted",
1047
+ extra=self._event_extra(
1048
+ "recording_restart",
1049
+ reason="health_check",
1050
+ ),
1051
+ )
1052
+ else:
1053
+ self.last_motion_time = None
1054
+ return
1055
+
1056
+ if (
1057
+ self.last_motion_time is not None
1058
+ and (now - self.last_motion_time) <= self.motion_stop_delay
1059
+ ):
1060
+ logger.warning(
1061
+ "Recording missing while motion is recent; restarting",
1062
+ extra=self._event_extra(
1063
+ "recording_restart_attempt",
1064
+ reason="missing",
1065
+ ),
1066
+ )
1067
+ self.start_recording()
1068
+ if self.recording_process:
1069
+ logger.info(
1070
+ "Recording restarted",
1071
+ extra=self._event_extra(
1072
+ "recording_restart",
1073
+ reason="missing",
1074
+ ),
1075
+ )
1076
+ elif (
1077
+ self.last_motion_time is not None
1078
+ and (now - self.last_motion_time) > self.motion_stop_delay
1079
+ ):
1080
+ self.last_motion_time = None
1081
+
1082
+ def cleanup(self) -> None:
1083
+ """Clean up resources."""
1084
+ logger.info("Cleaning up...")
1085
+ try:
1086
+ self.stop_recording()
1087
+ except Exception:
1088
+ logger.exception("Error stopping recording")
1089
+ try:
1090
+ self._stop_frame_pipeline()
1091
+ except Exception:
1092
+ logger.exception("Error stopping frame pipeline")
1093
+
1094
+ def _finalize_clip(
1095
+ self,
1096
+ output_file: Path | None,
1097
+ started_wall: datetime | None,
1098
+ started_mono: float | None,
1099
+ ) -> None:
1100
+ if output_file is None:
1101
+ return
1102
+ try:
1103
+ if not output_file.exists() or output_file.stat().st_size == 0:
1104
+ return
1105
+ except Exception as exc:
1106
+ logger.warning(
1107
+ "Failed to stat output clip %s: %s",
1108
+ output_file,
1109
+ exc,
1110
+ exc_info=True,
1111
+ )
1112
+ return
1113
+
1114
+ end_ts = datetime.now()
1115
+ if started_wall is None:
1116
+ if started_mono is not None:
1117
+ duration_s = time.monotonic() - started_mono
1118
+ start_ts = end_ts - timedelta(seconds=duration_s)
1119
+ else:
1120
+ start_ts = end_ts
1121
+ duration_s = 0.0
1122
+ else:
1123
+ start_ts = started_wall
1124
+ duration_s = (end_ts - start_ts).total_seconds()
1125
+
1126
+ clip = Clip(
1127
+ clip_id=output_file.stem,
1128
+ camera_name=self.camera_name,
1129
+ local_path=output_file,
1130
+ start_ts=start_ts,
1131
+ end_ts=end_ts,
1132
+ duration_s=duration_s,
1133
+ source_backend="rtsp",
1134
+ )
1135
+
1136
+ self._emit_clip(clip)
1137
+
1138
+ @staticmethod
1139
+ def _normalize_blur_kernel(blur_kernel: int) -> int:
1140
+ kernel = int(blur_kernel)
1141
+ if kernel < 0:
1142
+ return 0
1143
+ if kernel % 2 == 0 and kernel != 0:
1144
+ return kernel + 1
1145
+ return kernel
1146
+
1147
+ def _log_startup_info(self) -> None:
1148
+ logger.info("Connecting to camera...")
1149
+ logger.info(
1150
+ "Motion: pixel_threshold=%s min_changed_pct=%.3f%% blur=%s stop_delay=%.1fs max_recording_s=%.1fs",
1151
+ self.pixel_threshold,
1152
+ self.min_changed_pct,
1153
+ self.blur_kernel,
1154
+ self.motion_stop_delay,
1155
+ self.max_recording_s,
1156
+ )
1157
+ logger.info(
1158
+ "Motion thresholds: idle=%.3f%% recording=%.3f%% (recording_sensitivity_factor=%.2f)",
1159
+ self.min_changed_pct,
1160
+ self._recording_threshold(),
1161
+ self.recording_sensitivity_factor,
1162
+ )
1163
+ logger.info("Max reconnect attempts: %s", self.max_reconnect_attempts)
1164
+ if self.max_reconnect_attempts == 0:
1165
+ logger.info("Reconnect policy: retry forever")
1166
+ if self._detect_stream_available:
1167
+ logger.info("Detect fallback attempts: %s", self.detect_fallback_attempts)
1168
+
1169
+ if self.hwaccel_config.is_available:
1170
+ device_info = (
1171
+ f" (device: {self.hwaccel_config.hwaccel_device})"
1172
+ if self.hwaccel_config.hwaccel_device
1173
+ else ""
1174
+ )
1175
+ logger.info("Hardware acceleration: %s%s", self.hwaccel_config.hwaccel, device_info)
1176
+ else:
1177
+ logger.info("Hardware acceleration: disabled (using software decoding)")
1178
+
1179
+ logger.info("Detecting camera resolution...")
1180
+ info = self._probe_stream_info(self.rtsp_url)
1181
+ width = info.get("width") if info else None
1182
+ height = info.get("height") if info else None
1183
+ if isinstance(width, (int, str)) and isinstance(height, (int, str)):
1184
+ native_width = int(width)
1185
+ native_height = int(height)
1186
+ else:
1187
+ logger.warning("Failed to probe stream, using default 1920x1080")
1188
+ native_width, native_height = 1920, 1080
1189
+ logger.info("Camera resolution: %sx%s", native_width, native_height)
1190
+
1191
+ if self.detect_rtsp_url != self.rtsp_url:
1192
+ info = self._probe_stream_info(self.detect_rtsp_url)
1193
+ if info and info.get("width") and info.get("height"):
1194
+ logger.info(
1195
+ "Motion RTSP stream (%s) available: %s (%sx%s @ %s)",
1196
+ self._detect_rtsp_url_source,
1197
+ self._redact_rtsp_url(self.detect_rtsp_url),
1198
+ info.get("width"),
1199
+ info.get("height"),
1200
+ info.get("avg_frame_rate"),
1201
+ )
1202
+ else:
1203
+ if self.detect_fallback_attempts > 0:
1204
+ logger.warning(
1205
+ "Motion RTSP stream (%s) did not probe cleanly; falling back to main RTSP stream",
1206
+ self._detect_rtsp_url_source,
1207
+ )
1208
+ self._activate_detect_fallback(self._clock.now())
1209
+ else:
1210
+ logger.warning(
1211
+ "Motion RTSP stream (%s) did not probe cleanly; fallback disabled",
1212
+ self._detect_rtsp_url_source,
1213
+ )
1214
+
1215
+ logger.info("Motion detection: 320x240@10fps (downscaled for efficiency)")
1216
+
1217
+ def _handle_frame_timeout(self) -> bool:
1218
+ pipe_status = self._frame_pipeline.exit_code()
1219
+ if pipe_status is not None:
1220
+ logger.error(
1221
+ "Frame pipeline exited (code: %s). Check logs: %s/frame_pipeline.log",
1222
+ pipe_status,
1223
+ self.output_dir,
1224
+ )
1225
+ else:
1226
+ logger.warning(
1227
+ "No frames received for %.1fs (stall). Last frame %.1fs ago.",
1228
+ self.frame_timeout_s,
1229
+ self._clock.now() - self.last_successful_frame,
1230
+ )
1231
+
1232
+ aggressive = self.recording_process is None
1233
+ return self._reconnect_frame_pipeline(aggressive=aggressive)
1234
+
1235
+ def _stall_grace_remaining(self, now: float) -> float:
1236
+ if not self.recording_process or self.last_motion_time is None:
1237
+ return 0.0
1238
+ return max(0.0, self.motion_stop_delay - (now - self.last_motion_time))
1239
+
1240
+ def _enter_stalled(self, now: float, remaining: float) -> None:
1241
+ self._stall_grace_until = now + remaining
1242
+ logger.warning(
1243
+ "Frame pipeline stalled; keeping recording alive for %.1fs",
1244
+ remaining,
1245
+ )
1246
+
1247
+ def _handle_stalled_wait(self, now: float) -> bool:
1248
+ if self._stall_grace_until is None or now >= self._stall_grace_until:
1249
+ return False
1250
+ self._set_run_state(RTSPRunState.STALLED)
1251
+ self._clock.sleep(min(0.5, self._stall_grace_until - now))
1252
+ return True
1253
+
1254
+ def _handle_reconnect_needed(self, now: float) -> tuple[bool, bool]:
1255
+ self._set_run_state(RTSPRunState.RECONNECTING)
1256
+ if self._handle_frame_timeout():
1257
+ return True, False
1258
+ remaining = self._stall_grace_remaining(now)
1259
+ if remaining > 0:
1260
+ self._enter_stalled(now, remaining)
1261
+ self._handle_stalled_wait(now)
1262
+ return True, True
1263
+ return False, False
1264
+
1265
+ def _handle_heartbeat(
1266
+ self,
1267
+ now: float,
1268
+ frame_count: int,
1269
+ last_heartbeat: float,
1270
+ ) -> tuple[bool, bool, float]:
1271
+ if now - last_heartbeat <= self.heartbeat_s:
1272
+ return True, False, last_heartbeat
1273
+
1274
+ logger.debug(
1275
+ "[HEARTBEAT] Processed %s frames, recording=%s",
1276
+ frame_count,
1277
+ self.recording_process is not None,
1278
+ )
1279
+ if not self._frame_pipeline.is_running():
1280
+ logger.error(
1281
+ "Frame pipeline died! Exit code: %s",
1282
+ self._frame_pipeline.exit_code(),
1283
+ )
1284
+ ok, stalled = self._handle_reconnect_needed(now)
1285
+ if not ok:
1286
+ return False, False, last_heartbeat
1287
+ return True, stalled, now
1288
+
1289
+ return True, False, now
1290
+
1291
+ def _handle_missing_dimensions(self, now: float) -> bool:
1292
+ logger.warning("Frame pipeline missing dimensions; reconnecting")
1293
+ ok, _ = self._handle_reconnect_needed(now)
1294
+ return ok
1295
+
1296
+ def _handle_missing_frame(self, now: float) -> bool:
1297
+ if self._handle_stalled_wait(now):
1298
+ return True
1299
+ ok, _ = self._handle_reconnect_needed(now)
1300
+ return ok
1301
+
1302
+ def _handle_motion_detected(self, now: float, frame_count: int) -> None:
1303
+ logger.debug(
1304
+ "[MOTION DETECTED at frame %s] changed_pct=%.3f%%",
1305
+ frame_count,
1306
+ self._motion_detector.last_changed_pct,
1307
+ )
1308
+ self.last_motion_time = now
1309
+ if not self.recording_process:
1310
+ logger.debug("-> Starting new recording...")
1311
+ self.start_recording()
1312
+ if not self.recording_process:
1313
+ logger.error("-> Recording failed to start!")
1314
+
1315
+ def _update_recording_state(self, now: float) -> None:
1316
+ if self.recording_process and self.last_motion_time is not None:
1317
+ if now - self.last_motion_time > self.motion_stop_delay:
1318
+ logger.info(
1319
+ "No motion for %.1fs > stop_delay=%.1fs (last changed_pct=%.3f%%), stopping",
1320
+ now - self.last_motion_time,
1321
+ self.motion_stop_delay,
1322
+ self._motion_detector.last_changed_pct,
1323
+ )
1324
+ self.stop_recording()
1325
+ self.last_motion_time = None
1326
+ self._set_run_state(RTSPRunState.IDLE)
1327
+ return
1328
+
1329
+ self._rotate_recording_if_needed()
1330
+ self._set_run_state(RTSPRunState.RECORDING)
1331
+ return
1332
+
1333
+ self._set_run_state(RTSPRunState.IDLE)
1334
+
1335
+ def _process_frame(
1336
+ self,
1337
+ frame: npt.NDArray[np.uint8],
1338
+ now: float,
1339
+ frame_count: int,
1340
+ ) -> None:
1341
+ threshold = (
1342
+ self._recording_threshold()
1343
+ if self.recording_process is not None
1344
+ else self.min_changed_pct
1345
+ )
1346
+ motion_detected = self.detect_motion(frame, threshold=threshold)
1347
+
1348
+ if motion_detected:
1349
+ self._handle_motion_detected(now, frame_count)
1350
+
1351
+ if self.recording_process or self.last_motion_time is not None:
1352
+ self._ensure_recording(now)
1353
+
1354
+ self._update_recording_state(now)
1355
+
1356
+ # State flow (simplified):
1357
+ # Idle -> Recording (motion)
1358
+ # Recording -> Stalled (frame timeout) -> Reconnecting -> Recording/Idle
1359
+ # Recording -> Idle (no motion for stop_delay)
1360
+ def _run(self) -> None:
1361
+ """Start monitoring camera for motion and recording videos."""
1362
+ self._log_startup_info()
1363
+
1364
+ try:
1365
+ try:
1366
+ self._start_frame_pipeline()
1367
+ except Exception as exc:
1368
+ logger.warning("Initial frame pipeline start failed: %s", exc, exc_info=True)
1369
+ if not self._reconnect_frame_pipeline(aggressive=True):
1370
+ return
1371
+
1372
+ logger.info("Connected, monitoring for motion...")
1373
+ self.reconnect_count = 0
1374
+ self._set_run_state(RTSPRunState.IDLE)
1375
+
1376
+ frame_count = 0
1377
+ last_heartbeat = self._clock.now()
1378
+
1379
+ while not self._stop_event.is_set():
1380
+ now = self._clock.now()
1381
+ if self._handle_stalled_wait(now):
1382
+ continue
1383
+
1384
+ ok, stalled, last_heartbeat = self._handle_heartbeat(
1385
+ now,
1386
+ frame_count,
1387
+ last_heartbeat,
1388
+ )
1389
+ if not ok:
1390
+ break
1391
+ if stalled:
1392
+ continue
1393
+
1394
+ if self._maybe_recover_detect_stream(now):
1395
+ self._set_run_state(RTSPRunState.RECONNECTING)
1396
+ continue
1397
+
1398
+ frame_count += 1
1399
+
1400
+ raw_frame = self._frame_pipeline.read_frame(timeout_s=self.frame_timeout_s)
1401
+ if raw_frame is None:
1402
+ now = self._clock.now()
1403
+ if self._handle_missing_frame(now):
1404
+ continue
1405
+ break
1406
+
1407
+ self._stall_grace_until = None
1408
+ frame_width = self._frame_pipeline.frame_width
1409
+ frame_height = self._frame_pipeline.frame_height
1410
+ if frame_width is None or frame_height is None:
1411
+ if not self._handle_missing_dimensions(now):
1412
+ break
1413
+ continue
1414
+
1415
+ frame = np.frombuffer(raw_frame, dtype=np.uint8).reshape(
1416
+ (frame_height, frame_width)
1417
+ )
1418
+ now = self._clock.now()
1419
+ self._process_frame(frame, now, frame_count)
1420
+
1421
+ except Exception as e:
1422
+ logger.exception("Unexpected error: %s", e)
1423
+ finally:
1424
+ self.cleanup()