diff --git a/babyping.py b/babyping.py index 39337b9..e1f3493 100644 --- a/babyping.py +++ b/babyping.py @@ -130,9 +130,106 @@ def get_fps(self): } +def mask_credentials(url): + """Mask username:password in a URL for safe logging.""" + return re.sub(r'(://)[^@]+@', r'\1***:***@', url) + + +class ThreadedVideoCapture: + """Thread-safe video capture that reads frames in a background thread. + + Prevents RTSP/network stream lag by continuously reading frames and + always providing the latest one to the caller. + """ + + def __init__(self, source, rtsp_transport="tcp"): + if isinstance(source, str) and source.startswith("rtsp://"): + os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = f"rtsp_transport;{rtsp_transport}" + self._cap = cv2.VideoCapture(source) + self._lock = threading.Lock() + self._ret = False + self._frame = None + self._stopped = False + self._last_frame_time = None + self._thread = threading.Thread(target=self._reader, daemon=True) + self._thread.start() + + def _reader(self): + while not self._stopped: + ret, frame = self._cap.read() + with self._lock: + self._ret = ret + self._frame = frame + if ret: + self._last_frame_time = time.monotonic() + + def read(self): + with self._lock: + frame = self._frame.copy() if self._frame is not None else None + return self._ret, frame + + def isOpened(self): + return self._cap.isOpened() + + def release(self): + self._stopped = True + self._thread.join(timeout=2.0) + self._cap.release() + + def get(self, prop): + return self._cap.get(prop) + + def set(self, prop, value): + return self._cap.set(prop, value) + + def is_healthy(self, timeout=10.0): + with self._lock: + if self._last_frame_time is None: + return False + return (time.monotonic() - self._last_frame_time) < timeout + + +def _is_network_source(source): + """Check if a camera source string is a network URL.""" + return isinstance(source, str) and ( + source.startswith("rtsp://") or source.startswith("http://") or source.startswith("https://") + ) + + +def open_camera_source(source, rtsp_transport="tcp"): + """Open a camera source. Returns VideoCapture (local) or ThreadedVideoCapture (network). + + Args: + source: Camera index as string ("0") or URL ("rtsp://...") + rtsp_transport: "tcp" or "udp" for RTSP streams + """ + if _is_network_source(source): + cap = ThreadedVideoCapture(source, rtsp_transport=rtsp_transport) + if not cap.isOpened(): + cap.release() + print(f"Error: Could not open camera at {mask_credentials(source)}") + sys.exit(1) + return cap + + try: + index = int(source) + except ValueError: + print(f"Error: Invalid camera source: {source}") + sys.exit(1) + + cap = try_open_camera(index) + if cap is None: + print(f"Error: Could not open camera at index {index}") + sys.exit(1) + return cap + + def parse_args(): parser = argparse.ArgumentParser(description="BabyPing — lightweight baby monitor with motion detection") - parser.add_argument("--camera", type=int, default=0, help="Camera index (default: 0)") + parser.add_argument("--camera", type=str, default="0", + help="Camera index or RTSP/HTTP URL (default: 0)") + parser.add_argument("--rtsp-transport", choices=["tcp", "udp"], default="tcp", + help="RTSP transport protocol (default: tcp)") parser.add_argument("--sensitivity", choices=["low", "medium", "high"], default="medium", help="Motion sensitivity (default: medium)") parser.add_argument("--cooldown", type=int, default=30, help="Seconds between notifications (default: 30)") @@ -339,36 +436,39 @@ def try_open_camera(index): return None -def reconnect_camera(index, max_attempts=10, base_delay=2.0): +def reconnect_camera(source, max_attempts=10, base_delay=2.0, rtsp_transport="tcp"): """Retry opening camera with exponential backoff. Returns cap or None.""" for attempt in range(max_attempts): delay = min(base_delay * (2 ** attempt), 60) print(f" Reconnect attempt {attempt + 1}/{max_attempts} (waiting {delay:.0f}s)...") time.sleep(delay) - cap = try_open_camera(index) - if cap is not None: - return cap + if _is_network_source(source): + cap = ThreadedVideoCapture(source, rtsp_transport=rtsp_transport) + if cap.isOpened(): + return cap + cap.release() + else: + try: + index = int(source) + except ValueError: + return None + cap = try_open_camera(index) + if cap is not None: + return cap return None -def open_camera(index): - cap = try_open_camera(index) - if cap is None: - print(f"Error: Could not open camera at index {index}") - sys.exit(1) - return cap - - def main(): args = parse_args() frame_buffer.set_sensitivity(args.sensitivity) frame_buffer.set_fps(args.fps) threshold = SENSITIVITY_THRESHOLDS[args.sensitivity] - print(f"BabyPing starting — camera={args.camera}, sensitivity={args.sensitivity} ({threshold}px²), cooldown={args.cooldown}s") + camera_label = mask_credentials(args.camera) if _is_network_source(args.camera) else args.camera + print(f"BabyPing starting — camera={camera_label}, sensitivity={args.sensitivity} ({threshold}px²), cooldown={args.cooldown}s") - cap = open_camera(args.camera) + cap = open_camera_source(args.camera, rtsp_transport=args.rtsp_transport) print("Camera opened. Press 'q' to quit.") - print(f" Camera index: {args.camera}") + print(f" Camera: {camera_label}") print(f" Sensitivity: {args.sensitivity} ({threshold}px² threshold)") print(f" Cooldown: {args.cooldown}s") print(f" Preview: {'off' if args.no_preview else 'on'}") @@ -444,7 +544,7 @@ def main(): print(f"Camera lost after {consecutive_failures} dropped frames. Reconnecting...") send_notification("BabyPing", "Camera disconnected — reconnecting...") cap.release() - cap = reconnect_camera(args.camera) + cap = reconnect_camera(args.camera, rtsp_transport=args.rtsp_transport) if cap is None: print("Error: Could not reconnect to camera. Exiting.") send_notification("BabyPing", "Camera reconnect failed — stopping") diff --git a/docs/plans/2026-02-07-rtsp-camera-support-design.md b/docs/plans/2026-02-07-rtsp-camera-support-design.md new file mode 100644 index 0000000..2e898eb --- /dev/null +++ b/docs/plans/2026-02-07-rtsp-camera-support-design.md @@ -0,0 +1,66 @@ +# RTSP/IP Camera Support — Design + +## Problem +BabyPing only supports local cameras via `cv2.VideoCapture(index)`. Users want to connect IP cameras (Reolink, TP-Link Tapo, Hikvision, etc.) that expose RTSP streams over the network. + +## Decision +Support RTSP/IP cameras via a `ThreadedVideoCapture` wrapper. No cloud camera APIs, no multi-camera, no ONVIF discovery. + +**Why RTSP:** OpenCV's `cv2.VideoCapture` already accepts RTSP URLs natively. Every major IP camera brand supports RTSP. Cloud camera APIs (Ring, Nest, Arlo) are walled gardens with no official streaming access. + +## Design + +### Camera Source Abstraction + +The `--camera` argument changes from `int` to `str`: +- `--camera 0` — local camera by index (existing, unchanged) +- `--camera rtsp://user:pass@192.168.1.100:554/stream1` — RTSP stream +- `--camera http://192.168.1.100/mjpeg` — HTTP MJPEG stream + +New CLI flag: `--rtsp-transport tcp|udp` (default: `tcp`) + +A factory function `open_camera_source(source)` detects the format and returns either a plain `cv2.VideoCapture` (local) or `ThreadedVideoCapture` (network). + +### ThreadedVideoCapture (~40 lines) + +Uses "latest frame" pattern — a daemon thread continuously reads frames, main thread always gets the most recent one. No queue, no buffer buildup. + +``` +_reader thread: cap.read() → self._frame (overwrite, continuous) +main thread: source.read() → returns copy of self._frame +``` + +- Same API as cv2.VideoCapture: `read()`, `isOpened()`, `release()`, `get()`, `set()` +- `threading.Lock` protects shared frame +- Tracks `_last_frame_time` for health monitoring +- `is_healthy()` returns False if no frame for 10s + +### Reconnection + +Existing `reconnect_camera()` updated to accept string sources. Same exponential backoff. `ThreadedVideoCapture` releases old capture, creates new one, restarts reader thread. + +### Credential Masking + +`mask_credentials(url)` replaces `://user:pass@` with `://***:***@` in all print output. + +## Files Modified + +- **`babyping.py`** — ThreadedVideoCapture class, open_camera_source factory, mask_credentials, updated parse_args/try_open_camera/reconnect_camera/main +- **`tests/test_babyping.py`** — ~15 new tests + +No changes to `web.py`, `events.py`, or the web UI. + +## Out of Scope + +- Multi-camera support (different architecture) +- ONVIF auto-discovery (extra dependency, users can find their RTSP URL) +- Config file (CLI args sufficient for one source) +- Cloud camera APIs (walled gardens, legal risk) +- go2rtc/GStreamer (OpenCV FFmpeg backend is sufficient) + +## Tests (~15 new) + +- ThreadedVideoCapture: init, read, release, is_healthy, reconnect +- open_camera_source: local index, RTSP URL, HTTP URL, invalid source +- mask_credentials: with creds, without creds, no auth +- Integration: RTSP source through detection loop (mocked) diff --git a/tests/test_babyping.py b/tests/test_babyping.py index 1894d13..8c6d62b 100644 --- a/tests/test_babyping.py +++ b/tests/test_babyping.py @@ -12,7 +12,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from unittest.mock import MagicMock, patch -from babyping import apply_night_mode, crop_to_roi, detect_motion, FrameBuffer, get_tailscale_ip, _tailscale_cache, offset_contours, parse_args, parse_roi_string, reconnect_camera, save_snapshot, throttle_fps, try_open_camera, SENSITIVITY_THRESHOLDS +from babyping import apply_night_mode, crop_to_roi, detect_motion, FrameBuffer, get_tailscale_ip, _is_network_source, _tailscale_cache, mask_credentials, offset_contours, open_camera_source, parse_args, parse_roi_string, reconnect_camera, save_snapshot, ThreadedVideoCapture, throttle_fps, try_open_camera, SENSITIVITY_THRESHOLDS # --- detect_motion tests --- @@ -64,7 +64,7 @@ class TestParseArgs: def test_defaults(self, monkeypatch): monkeypatch.setattr(sys, "argv", ["babyping"]) args = parse_args() - assert args.camera == 0 + assert args.camera == "0" assert args.sensitivity == "medium" assert args.cooldown == 30 assert args.no_preview is False @@ -75,7 +75,7 @@ def test_custom_values(self, monkeypatch): "--cooldown", "10", "--no-preview", ]) args = parse_args() - assert args.camera == 2 + assert args.camera == "2" assert args.sensitivity == "high" assert args.cooldown == 10 assert args.no_preview is True @@ -748,3 +748,241 @@ def test_returns_none_on_os_error(self, tmp_path): blocker.write_text("not a dir") result = save_snapshot(frame, snapshot_dir=str(blocker / "subdir")) assert result is None + + +# --- mask_credentials tests --- + +class TestMaskCredentials: + def test_masks_user_and_password(self): + url = "rtsp://admin:secret@192.168.1.100:554/stream" + assert mask_credentials(url) == "rtsp://***:***@192.168.1.100:554/stream" + + def test_no_credentials_unchanged(self): + url = "rtsp://192.168.1.100:554/stream" + assert mask_credentials(url) == "rtsp://192.168.1.100:554/stream" + + def test_http_url_masked(self): + url = "http://user:pass@example.com/mjpeg" + assert mask_credentials(url) == "http://***:***@example.com/mjpeg" + + def test_local_camera_index_unchanged(self): + assert mask_credentials("0") == "0" + + +# --- _is_network_source tests --- + +class TestIsNetworkSource: + def test_rtsp_url(self): + assert _is_network_source("rtsp://192.168.1.100/stream") is True + + def test_http_url(self): + assert _is_network_source("http://192.168.1.100/mjpeg") is True + + def test_https_url(self): + assert _is_network_source("https://192.168.1.100/stream") is True + + def test_local_index_string(self): + assert _is_network_source("0") is False + + def test_integer(self): + assert _is_network_source(0) is False + + +# --- ThreadedVideoCapture tests --- + +class TestThreadedVideoCapture: + def test_init_starts_reader_thread(self): + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.read.return_value = (True, np.zeros((240, 320, 3), dtype=np.uint8)) + with patch("babyping.cv2.VideoCapture", return_value=mock_cap): + tvc = ThreadedVideoCapture("rtsp://fake") + time.sleep(0.1) # Let reader thread run + assert tvc.isOpened() is True + tvc.release() + + def test_read_returns_latest_frame(self): + frame = np.full((240, 320, 3), 128, dtype=np.uint8) + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.read.return_value = (True, frame) + with patch("babyping.cv2.VideoCapture", return_value=mock_cap): + tvc = ThreadedVideoCapture("rtsp://fake") + time.sleep(0.1) + ret, result = tvc.read() + assert ret is True + assert result is not None + np.testing.assert_array_equal(result, frame) + tvc.release() + + def test_read_returns_copy_not_reference(self): + frame = np.full((240, 320, 3), 128, dtype=np.uint8) + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.read.return_value = (True, frame) + with patch("babyping.cv2.VideoCapture", return_value=mock_cap): + tvc = ThreadedVideoCapture("rtsp://fake") + time.sleep(0.1) + _, frame1 = tvc.read() + _, frame2 = tvc.read() + assert frame1 is not frame2 + tvc.release() + + def test_release_stops_thread(self): + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.read.return_value = (False, None) + with patch("babyping.cv2.VideoCapture", return_value=mock_cap): + tvc = ThreadedVideoCapture("rtsp://fake") + tvc.release() + assert tvc._stopped is True + + def test_is_healthy_true_after_frame(self): + frame = np.zeros((240, 320, 3), dtype=np.uint8) + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.read.return_value = (True, frame) + with patch("babyping.cv2.VideoCapture", return_value=mock_cap): + tvc = ThreadedVideoCapture("rtsp://fake") + time.sleep(0.1) + assert tvc.is_healthy() is True + tvc.release() + + def test_is_healthy_false_initially(self): + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + # Simulate slow connection — no frames yet + mock_cap.read.side_effect = lambda: (time.sleep(1) or (False, None)) + with patch("babyping.cv2.VideoCapture", return_value=mock_cap): + tvc = ThreadedVideoCapture("rtsp://fake") + assert tvc.is_healthy() is False + tvc.release() + + def test_is_healthy_false_after_timeout(self): + frame = np.zeros((240, 320, 3), dtype=np.uint8) + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.read.return_value = (True, frame) + with patch("babyping.cv2.VideoCapture", return_value=mock_cap): + tvc = ThreadedVideoCapture("rtsp://fake") + time.sleep(0.1) + # Manually expire + with tvc._lock: + tvc._last_frame_time = time.monotonic() - 11.0 + assert tvc.is_healthy() is False + tvc.release() + + def test_sets_rtsp_transport_env(self): + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.read.return_value = (False, None) + with patch("babyping.cv2.VideoCapture", return_value=mock_cap), \ + patch.dict(os.environ, {}, clear=False): + tvc = ThreadedVideoCapture("rtsp://fake", rtsp_transport="udp") + assert os.environ.get("OPENCV_FFMPEG_CAPTURE_OPTIONS") == "rtsp_transport;udp" + tvc.release() + + +# --- open_camera_source tests --- + +class TestOpenCameraSource: + def test_local_camera_index(self): + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + with patch("babyping.try_open_camera", return_value=mock_cap): + result = open_camera_source("0") + assert result is mock_cap + + def test_local_camera_not_found_exits(self): + with patch("babyping.try_open_camera", return_value=None): + with pytest.raises(SystemExit): + open_camera_source("0") + + def test_invalid_source_exits(self): + with pytest.raises(SystemExit): + open_camera_source("not_a_number_or_url") + + def test_rtsp_url_returns_threaded_capture(self): + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.read.return_value = (False, None) + with patch("babyping.cv2.VideoCapture", return_value=mock_cap): + result = open_camera_source("rtsp://192.168.1.100/stream") + assert isinstance(result, ThreadedVideoCapture) + result.release() + + def test_http_url_returns_threaded_capture(self): + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.read.return_value = (False, None) + with patch("babyping.cv2.VideoCapture", return_value=mock_cap): + result = open_camera_source("http://192.168.1.100/mjpeg") + assert isinstance(result, ThreadedVideoCapture) + result.release() + + def test_rtsp_url_not_opened_exits(self): + mock_cap = MagicMock() + mock_cap.isOpened.return_value = False + mock_cap.read.return_value = (False, None) + with patch("babyping.cv2.VideoCapture", return_value=mock_cap): + with pytest.raises(SystemExit): + open_camera_source("rtsp://bad-url") + + +# --- parse_args RTSP tests --- + +class TestParseArgsRtsp: + def test_camera_accepts_rtsp_url(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["babyping", "--camera", "rtsp://192.168.1.100/stream"]) + args = parse_args() + assert args.camera == "rtsp://192.168.1.100/stream" + + def test_camera_default_is_string_zero(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["babyping"]) + args = parse_args() + assert args.camera == "0" + assert isinstance(args.camera, str) + + def test_rtsp_transport_default_tcp(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["babyping"]) + args = parse_args() + assert args.rtsp_transport == "tcp" + + def test_rtsp_transport_udp(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["babyping", "--rtsp-transport", "udp"]) + args = parse_args() + assert args.rtsp_transport == "udp" + + def test_rtsp_transport_invalid_rejected(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["babyping", "--rtsp-transport", "invalid"]) + with pytest.raises(SystemExit): + parse_args() + + +# --- reconnect_camera with RTSP tests --- + +class TestReconnectCameraRtsp: + def test_reconnect_local_camera(self): + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + with patch("babyping.try_open_camera", return_value=mock_cap): + result = reconnect_camera("0", max_attempts=3, base_delay=0.01) + assert result is mock_cap + + def test_reconnect_rtsp_camera(self): + mock_cap = MagicMock() + mock_cap.isOpened.return_value = True + mock_cap.read.return_value = (False, None) + with patch("babyping.cv2.VideoCapture", return_value=mock_cap): + result = reconnect_camera("rtsp://192.168.1.100/stream", max_attempts=3, base_delay=0.01) + assert result is not None + assert isinstance(result, ThreadedVideoCapture) + result.release() + + def test_reconnect_rtsp_fails_returns_none(self): + mock_cap = MagicMock() + mock_cap.isOpened.return_value = False + mock_cap.read.return_value = (False, None) + with patch("babyping.cv2.VideoCapture", return_value=mock_cap): + result = reconnect_camera("rtsp://bad-url", max_attempts=2, base_delay=0.01) + assert result is None diff --git a/tests/test_integration.py b/tests/test_integration.py index c903f96..0dfb56e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -33,7 +33,8 @@ def make_frame(value=128, width=320, height=240): def make_fake_args(**overrides): """Return an argparse.Namespace with sane test defaults.""" defaults = dict( - camera=0, + camera="0", + rtsp_transport="tcp", sensitivity="medium", cooldown=30, no_preview=True, @@ -100,7 +101,7 @@ def __init__(self, args, frames, *, audio_monitor=None, cap=None): self.mock_notification = None self.mock_start_web = None - self.mock_open_camera = None + self.mock_open_camera_source = None self.mock_destroyAllWindows = None self.mock_web_thread = None self.cap = None @@ -122,7 +123,7 @@ def __enter__(self): targets = { "parse_args": "babyping.parse_args", - "open_camera": "babyping.open_camera", + "open_camera_source": "babyping.open_camera_source", "send_notification": "babyping.send_notification", "start_web_server": "babyping.start_web_server", "imshow": "cv2.imshow", @@ -140,7 +141,7 @@ def __enter__(self): mocks = {name: p.start() for name, p in self._patches.items()} mocks["parse_args"].return_value = self.args - mocks["open_camera"].return_value = self.cap + mocks["open_camera_source"].return_value = self.cap mocks["send_notification"].return_value = None mocks["start_web_server"].return_value = self.mock_web_thread mocks["get_local_ip"].return_value = "127.0.0.1" @@ -151,7 +152,7 @@ def __enter__(self): self.mock_notification = mocks["send_notification"] self.mock_start_web = mocks["start_web_server"] - self.mock_open_camera = mocks["open_camera"] + self.mock_open_camera_source = mocks["open_camera_source"] self.mock_destroyAllWindows = mocks["destroyAllWindows"] self._mocks = mocks