homesec 1.2.0__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.
@@ -0,0 +1,325 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import subprocess
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from queue import Empty, Full, Queue
8
+ from threading import Event, Thread
9
+ from typing import Any, Protocol
10
+
11
+ from homesec.sources.rtsp.clock import Clock
12
+ from homesec.sources.rtsp.hardware import HardwareAccelConfig
13
+ from homesec.sources.rtsp.utils import _format_cmd, _is_timeout_option_error, _redact_rtsp_url
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class FramePipeline(Protocol):
19
+ """Decode RTSP frames for motion detection.
20
+
21
+ Implementations should assume `start()` is called only after `stop()`,
22
+ and `stop()` must be safe to call when already stopped.
23
+ """
24
+
25
+ frame_width: int | None
26
+ frame_height: int | None
27
+
28
+ def start(self, rtsp_url: str) -> None: ...
29
+
30
+ def stop(self) -> None: ...
31
+
32
+ def read_frame(self, timeout_s: float) -> bytes | None: ...
33
+
34
+ def is_running(self) -> bool: ...
35
+
36
+ def exit_code(self) -> int | None: ...
37
+
38
+
39
+ class FfmpegFramePipeline:
40
+ def __init__(
41
+ self,
42
+ *,
43
+ output_dir: Path,
44
+ frame_queue_size: int,
45
+ rtsp_connect_timeout_s: float,
46
+ rtsp_io_timeout_s: float,
47
+ ffmpeg_flags: list[str],
48
+ hwaccel_config: HardwareAccelConfig,
49
+ hwaccel_failed: bool,
50
+ on_frame: Callable[[], None],
51
+ clock: Clock,
52
+ ) -> None:
53
+ self._output_dir = output_dir
54
+ self._frame_queue_size = frame_queue_size
55
+ self._rtsp_connect_timeout_s = rtsp_connect_timeout_s
56
+ self._rtsp_io_timeout_s = rtsp_io_timeout_s
57
+ self._ffmpeg_flags = ffmpeg_flags
58
+ self._hwaccel_config = hwaccel_config
59
+ self._hwaccel_failed = hwaccel_failed
60
+ self._on_frame = on_frame
61
+ self._clock = clock
62
+
63
+ self._process: subprocess.Popen[bytes] | None = None
64
+ self._stderr: Any | None = None
65
+ self._reader_thread: Thread | None = None
66
+ self._reader_stop: Event | None = None
67
+ self._frame_queue: Queue[bytes] | None = None
68
+ self.frame_width: int | None = None
69
+ self.frame_height: int | None = None
70
+ self._frame_size: int | None = None
71
+
72
+ def start(self, rtsp_url: str) -> None:
73
+ (
74
+ self._process,
75
+ self._stderr,
76
+ self.frame_width,
77
+ self.frame_height,
78
+ ) = self._get_frame_pipe(rtsp_url)
79
+ self._frame_size = int(self.frame_width) * int(self.frame_height)
80
+ self._frame_queue = Queue(maxsize=self._frame_queue_size)
81
+ self._reader_stop = Event()
82
+
83
+ process = self._process
84
+ frame_size = self._frame_size
85
+ frame_queue = self._frame_queue
86
+ stop_event = self._reader_stop
87
+
88
+ def reader_loop() -> None:
89
+ stdout = process.stdout if process else None
90
+ if stdout is None:
91
+ logger.error("Frame pipeline stdout is None")
92
+ return
93
+
94
+ while not stop_event.is_set():
95
+ try:
96
+ raw = stdout.read(frame_size)
97
+ except Exception:
98
+ logger.exception("Error reading from frame pipeline stdout")
99
+ return
100
+
101
+ if not raw or len(raw) != frame_size:
102
+ return
103
+
104
+ self._on_frame()
105
+
106
+ try:
107
+ frame_queue.put_nowait(raw)
108
+ except Full:
109
+ try:
110
+ _ = frame_queue.get_nowait()
111
+ except Empty:
112
+ pass
113
+ try:
114
+ frame_queue.put_nowait(raw)
115
+ except Full:
116
+ pass
117
+
118
+ self._reader_thread = Thread(target=reader_loop, name="frame-reader", daemon=True)
119
+ self._reader_thread.start()
120
+
121
+ def stop(self) -> None:
122
+ if self._reader_stop is not None:
123
+ self._reader_stop.set()
124
+
125
+ if self._process is not None:
126
+ try:
127
+ self._stop_process(self._process, "Frame pipeline", terminate_timeout_s=2)
128
+ except Exception:
129
+ logger.exception("Error stopping frame pipeline process")
130
+ self._process = None
131
+
132
+ if self._reader_thread is not None:
133
+ try:
134
+ self._reader_thread.join(timeout=5)
135
+ except Exception:
136
+ logger.exception("Error joining frame reader thread")
137
+ self._reader_thread = None
138
+
139
+ self._reader_stop = None
140
+
141
+ if self._stderr is not None:
142
+ try:
143
+ self._stderr.close()
144
+ except Exception:
145
+ logger.exception("Error closing frame pipeline stderr log")
146
+ self._stderr = None
147
+
148
+ self._frame_queue = None
149
+ self.frame_width = None
150
+ self.frame_height = None
151
+ self._frame_size = None
152
+
153
+ def read_frame(self, timeout_s: float) -> bytes | None:
154
+ if not self._frame_queue:
155
+ return None
156
+ try:
157
+ return self._frame_queue.get(timeout=float(timeout_s))
158
+ except Empty:
159
+ return None
160
+
161
+ def is_running(self) -> bool:
162
+ return self._process is not None and self._process.poll() is None
163
+
164
+ def exit_code(self) -> int | None:
165
+ if self._process is None:
166
+ return None
167
+ return self._process.poll()
168
+
169
+ def _get_frame_pipe(self, rtsp_url: str) -> tuple[subprocess.Popen[bytes], Any, int, int]:
170
+ detect_width, detect_height = 320, 240
171
+
172
+ stderr_log = self._output_dir / "frame_pipeline.log"
173
+ self._output_dir.mkdir(parents=True, exist_ok=True)
174
+
175
+ def _read_tail(path: Path, max_bytes: int = 4000) -> str:
176
+ try:
177
+ data = path.read_bytes()
178
+ except Exception as exc:
179
+ logger.warning("Failed to read stderr tail: %s", exc, exc_info=True)
180
+ return ""
181
+ if len(data) <= max_bytes:
182
+ return data.decode(errors="replace")
183
+ return data[-max_bytes:].decode(errors="replace")
184
+
185
+ cmd = ["ffmpeg"]
186
+
187
+ # 1. Global Flags (Hardware Acceleration)
188
+ if self._hwaccel_config.is_available and not self._hwaccel_failed:
189
+ hwaccel = self._hwaccel_config.hwaccel
190
+ if hwaccel is not None:
191
+ cmd.extend(["-hwaccel", hwaccel])
192
+ if self._hwaccel_config.hwaccel_device:
193
+ cmd.extend(["-hwaccel_device", self._hwaccel_config.hwaccel_device])
194
+ elif self._hwaccel_failed:
195
+ logger.info("Hardware acceleration disabled due to previous failures")
196
+
197
+ # 2. Global Flags (Robustness & Logging)
198
+ user_flags = self._ffmpeg_flags
199
+
200
+ has_loglevel = any(x == "-loglevel" for x in user_flags)
201
+ if not has_loglevel:
202
+ cmd.extend(["-loglevel", "warning"])
203
+
204
+ has_fflags = any(x == "-fflags" for x in user_flags)
205
+ if not has_fflags:
206
+ cmd.extend(["-fflags", "+genpts+igndts"])
207
+
208
+ # Add all user flags to global scope.
209
+ cmd.extend(user_flags)
210
+
211
+ has_stimeout = any(x == "-stimeout" for x in user_flags)
212
+ has_rw_timeout = any(x == "-rw_timeout" for x in user_flags)
213
+
214
+ timeout_us_connect = str(int(max(0.1, self._rtsp_connect_timeout_s) * 1_000_000))
215
+ timeout_us_io = str(int(max(0.1, self._rtsp_io_timeout_s) * 1_000_000))
216
+
217
+ base_input_prefix = ["-rtsp_transport", "tcp"]
218
+ base_input_args = [
219
+ "-i",
220
+ rtsp_url,
221
+ "-f",
222
+ "rawvideo",
223
+ "-pix_fmt",
224
+ "gray",
225
+ "-vf",
226
+ f"fps=10,scale={detect_width}:{detect_height}",
227
+ "-an",
228
+ "-",
229
+ ]
230
+
231
+ timeout_args: list[str] = []
232
+ if not has_stimeout and self._rtsp_connect_timeout_s > 0:
233
+ timeout_args.extend(["-stimeout", timeout_us_connect])
234
+ if not has_rw_timeout and self._rtsp_io_timeout_s > 0:
235
+ timeout_args.extend(["-rw_timeout", timeout_us_io])
236
+
237
+ attempts: list[tuple[str, list[str]]] = []
238
+ if timeout_args:
239
+ attempts.append(("timeouts", base_input_prefix + timeout_args + base_input_args))
240
+ attempts.append(("no_timeouts", base_input_prefix + base_input_args))
241
+
242
+ process: subprocess.Popen[bytes] | None = None
243
+ stderr_file: Any | None = None
244
+
245
+ for label, extra_args in attempts:
246
+ cmd_attempt = list(cmd) + extra_args
247
+ logger.debug("Starting frame pipeline (%s), logging to: %s", label, stderr_log)
248
+ safe_cmd = list(cmd_attempt)
249
+ try:
250
+ idx = safe_cmd.index("-i")
251
+ safe_cmd[idx + 1] = _redact_rtsp_url(str(safe_cmd[idx + 1]))
252
+ except Exception as exc:
253
+ logger.warning(
254
+ "Failed to redact frame pipeline RTSP URL: %s",
255
+ exc,
256
+ exc_info=True,
257
+ )
258
+ logger.debug("Frame pipeline ffmpeg (%s): %s", label, _format_cmd(safe_cmd))
259
+
260
+ try:
261
+ stderr_file = open(stderr_log, "w")
262
+ process = subprocess.Popen(
263
+ cmd_attempt,
264
+ stdout=subprocess.PIPE,
265
+ stderr=stderr_file,
266
+ bufsize=detect_width * detect_height,
267
+ )
268
+ except Exception:
269
+ try:
270
+ if stderr_file:
271
+ stderr_file.close()
272
+ except Exception as exc:
273
+ logger.warning("Failed to close stderr log: %s", exc, exc_info=True)
274
+ logger.exception("Failed to start frame pipeline subprocess (%s)", label)
275
+ continue
276
+
277
+ self._clock.sleep(1)
278
+ if process.poll() is None:
279
+ return process, stderr_file, detect_width, detect_height
280
+
281
+ try:
282
+ stderr_file.close()
283
+ except Exception as exc:
284
+ logger.warning("Failed to close stderr log: %s", exc, exc_info=True)
285
+ stderr_tail = _read_tail(stderr_log)
286
+ timeout_option_error = (
287
+ label == "timeouts" and bool(stderr_tail) and _is_timeout_option_error(stderr_tail)
288
+ )
289
+ if timeout_option_error:
290
+ logger.warning(
291
+ "Frame pipeline died immediately (%s, exit code: %s); timeout options unsupported",
292
+ label,
293
+ process.returncode,
294
+ )
295
+ if stderr_tail:
296
+ logger.warning("Frame pipeline stderr tail (%s):\n%s", label, stderr_tail)
297
+ else:
298
+ logger.error(
299
+ "Frame pipeline died immediately (%s, exit code: %s)",
300
+ label,
301
+ process.returncode,
302
+ )
303
+ if stderr_tail:
304
+ logger.error("Frame pipeline stderr tail (%s):\n%s", label, stderr_tail)
305
+ process = None
306
+ stderr_file = None
307
+ continue
308
+
309
+ raise RuntimeError("Frame pipeline failed to start")
310
+
311
+ def _stop_process(
312
+ self, proc: subprocess.Popen[bytes], name: str, terminate_timeout_s: float
313
+ ) -> None:
314
+ if proc.poll() is not None:
315
+ return
316
+ try:
317
+ proc.terminate()
318
+ proc.wait(timeout=terminate_timeout_s)
319
+ except subprocess.TimeoutExpired:
320
+ logger.warning("%s did not terminate, killing (PID: %s)", name, proc.pid)
321
+ proc.kill()
322
+ try:
323
+ proc.wait(timeout=2)
324
+ except Exception:
325
+ logger.exception("Failed waiting after kill for %s (PID: %s)", name, proc.pid)
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import platform
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ @dataclass
13
+ class HardwareAccelConfig:
14
+ """Configuration for hardware-accelerated video decoding."""
15
+
16
+ hwaccel: str | None
17
+ hwaccel_device: str | None = None
18
+
19
+ @property
20
+ def is_available(self) -> bool:
21
+ """Check if hardware acceleration is available."""
22
+ return self.hwaccel is not None
23
+
24
+
25
+ class HardwareAccelDetector:
26
+ """Detect available hardware acceleration options for ffmpeg."""
27
+
28
+ @staticmethod
29
+ def detect(rtsp_url: str) -> HardwareAccelConfig:
30
+ """Detect the best available hardware acceleration method."""
31
+ if Path("/dev/dri/renderD128").exists():
32
+ if HardwareAccelDetector._test_hwaccel("vaapi"):
33
+ config = HardwareAccelConfig(
34
+ hwaccel="vaapi",
35
+ hwaccel_device="/dev/dri/renderD128",
36
+ )
37
+ if HardwareAccelDetector._test_decode(rtsp_url, config):
38
+ return config
39
+ logger.warning("VAAPI detected but failed to decode stream - disabling")
40
+
41
+ if HardwareAccelDetector._check_nvidia():
42
+ if HardwareAccelDetector._test_hwaccel("cuda"):
43
+ config = HardwareAccelConfig(hwaccel="cuda")
44
+ if HardwareAccelDetector._test_decode(rtsp_url, config):
45
+ return config
46
+ logger.warning("CUDA detected but failed to decode stream - disabling")
47
+
48
+ if platform.system() == "Darwin":
49
+ if HardwareAccelDetector._test_hwaccel("videotoolbox"):
50
+ config = HardwareAccelConfig(hwaccel="videotoolbox")
51
+ if HardwareAccelDetector._test_decode(rtsp_url, config):
52
+ return config
53
+ logger.warning("VideoToolbox detected but failed to decode stream - disabling")
54
+
55
+ if HardwareAccelDetector._test_hwaccel("qsv"):
56
+ config = HardwareAccelConfig(hwaccel="qsv")
57
+ if HardwareAccelDetector._test_decode(rtsp_url, config):
58
+ return config
59
+ logger.warning("QSV detected but failed to decode stream - disabling")
60
+
61
+ logger.info("Using software decoding (no working hardware acceleration found)")
62
+ return HardwareAccelConfig(hwaccel=None)
63
+
64
+ @staticmethod
65
+ def _test_hwaccel(method: str) -> bool:
66
+ """Test if a hardware acceleration method is available in ffmpeg."""
67
+ try:
68
+ result = subprocess.run(
69
+ ["ffmpeg", "-hwaccels"],
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=2,
73
+ check=False,
74
+ )
75
+ return method in result.stdout
76
+ except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
77
+ return False
78
+
79
+ @staticmethod
80
+ def _test_decode(rtsp_url: str, config: HardwareAccelConfig) -> bool:
81
+ """Test if hardware acceleration works by decoding a few frames."""
82
+ cmd = ["ffmpeg"]
83
+
84
+ if config.hwaccel:
85
+ cmd.extend(["-hwaccel", config.hwaccel])
86
+ if config.hwaccel_device:
87
+ cmd.extend(["-hwaccel_device", config.hwaccel_device])
88
+
89
+ cmd.extend(
90
+ [
91
+ "-rtsp_transport",
92
+ "tcp",
93
+ "-i",
94
+ rtsp_url,
95
+ "-frames:v",
96
+ "5",
97
+ "-f",
98
+ "null",
99
+ "-",
100
+ ]
101
+ )
102
+
103
+ try:
104
+ result = subprocess.run(
105
+ cmd,
106
+ capture_output=True,
107
+ timeout=10,
108
+ text=True,
109
+ check=False,
110
+ )
111
+ if result.returncode == 0:
112
+ return True
113
+ if any(
114
+ err in result.stderr
115
+ for err in (
116
+ "No VA display found",
117
+ "Device creation failed",
118
+ "No device available for decoder",
119
+ "Failed to initialise VAAPI",
120
+ "Cannot load",
121
+ )
122
+ ):
123
+ return False
124
+ return result.returncode == 0
125
+ except subprocess.TimeoutExpired:
126
+ return True
127
+ except Exception as exc:
128
+ logger.warning("VAAPI check failed: %s", exc, exc_info=True)
129
+ return False
130
+
131
+ @staticmethod
132
+ def _check_nvidia() -> bool:
133
+ """Check if NVIDIA GPU is available."""
134
+ try:
135
+ subprocess.run(
136
+ ["nvidia-smi"],
137
+ capture_output=True,
138
+ check=True,
139
+ timeout=2,
140
+ )
141
+ return True
142
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
143
+ return False
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import cast
5
+
6
+ import cv2
7
+ import numpy as np
8
+ import numpy.typing as npt
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class MotionDetector:
14
+ def __init__(
15
+ self,
16
+ *,
17
+ pixel_threshold: int,
18
+ min_changed_pct: float,
19
+ blur_kernel: int,
20
+ debug: bool,
21
+ ) -> None:
22
+ self._pixel_threshold = int(pixel_threshold)
23
+ self._min_changed_pct = float(min_changed_pct)
24
+ self._blur_kernel = int(blur_kernel)
25
+ self._debug = bool(debug)
26
+
27
+ self._prev_frame: npt.NDArray[np.uint8] | None = None
28
+ self._last_changed_pct = 0.0
29
+ self._last_changed_pixels = 0
30
+ self._debug_frame_count = 0
31
+
32
+ @property
33
+ def last_changed_pct(self) -> float:
34
+ return self._last_changed_pct
35
+
36
+ @property
37
+ def last_changed_pixels(self) -> int:
38
+ return self._last_changed_pixels
39
+
40
+ def reset(self) -> None:
41
+ self._prev_frame = None
42
+ self._last_changed_pct = 0.0
43
+ self._last_changed_pixels = 0
44
+ self._debug_frame_count = 0
45
+
46
+ def detect(self, frame: npt.NDArray[np.uint8], *, threshold: float | None = None) -> bool:
47
+ if frame.ndim == 3:
48
+ gray = cast(npt.NDArray[np.uint8], cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY))
49
+ else:
50
+ gray = frame
51
+
52
+ if self._blur_kernel > 1:
53
+ gray = cast(
54
+ npt.NDArray[np.uint8],
55
+ cv2.GaussianBlur(gray, (self._blur_kernel, self._blur_kernel), 0),
56
+ )
57
+
58
+ if self._prev_frame is None:
59
+ self._prev_frame = gray
60
+ self._last_changed_pct = 0.0
61
+ self._last_changed_pixels = 0
62
+ return False
63
+
64
+ diff = cv2.absdiff(self._prev_frame, gray)
65
+ _, mask = cv2.threshold(diff, self._pixel_threshold, 255, cv2.THRESH_BINARY)
66
+ changed_pixels = int(cv2.countNonZero(mask))
67
+
68
+ total_pixels = int(gray.shape[0]) * int(gray.shape[1])
69
+ changed_pct = (changed_pixels / total_pixels * 100.0) if total_pixels else 0.0
70
+
71
+ self._prev_frame = gray
72
+ self._last_changed_pct = changed_pct
73
+ self._last_changed_pixels = changed_pixels
74
+
75
+ if threshold is None:
76
+ threshold = self._min_changed_pct
77
+ if threshold < 0:
78
+ threshold = 0.0
79
+
80
+ motion = changed_pct >= float(threshold)
81
+
82
+ if self._debug:
83
+ self._debug_frame_count += 1
84
+ if self._debug_frame_count % 100 == 0:
85
+ logger.debug(
86
+ "Motion check: changed_pct=%.3f%% changed_px=%s pixel_threshold=%s min_changed_pct=%.3f%% blur=%s",
87
+ changed_pct,
88
+ changed_pixels,
89
+ self._pixel_threshold,
90
+ self._min_changed_pct,
91
+ self._blur_kernel,
92
+ )
93
+
94
+ return motion