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,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()