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