Files
bambuddy/backend/tests/unit/services/test_external_camera.py
T
maziggy 691fb133b7 Add external network camera support for printers
Add support for external network cameras (MJPEG, RTSP, HTTP snapshot)
that replace a printer's built-in camera when configured.

Features:
- Live streaming on printers page (replaces built-in camera)
- Finish photo capture from external camera on print complete
- Layer-based timelapse: captures frame on each layer change,
  stitches to MP4 video on print completion

Backend changes:
- Add external_camera_url, external_camera_type, external_camera_enabled
  fields to Printer model with database migration
- New external_camera.py service: MJPEG/RTSP/snapshot frame capture,
  connection testing, MJPEG stream generation
- New layer_timelapse.py service: TimelapseSession management,
  layer-by-layer frame capture, ffmpeg video stitching
- Add on_layer_change callback to MQTT client and printer manager
- Update camera routes with external camera streaming and tracking
- Update print lifecycle hooks for timelapse start/stitch/cancel
- Add external camera fields to backup/restore
- Rate limiting for external camera streams (prevents browser freeze)

Frontend changes:
- Add external camera configuration UI in Settings > Camera
- Per-printer enable toggle, URL input, type selector, test button
- Toast notification on save

Closes #143
2026-01-24 09:58:45 +01:00

218 lines
7.5 KiB
Python

"""
Tests for the external camera service.
These tests cover pure functions and frame parsing logic.
"""
from unittest.mock import MagicMock, patch
import pytest
class TestFormatMjpegFrame:
"""Tests for MJPEG frame formatting."""
def test_format_mjpeg_frame_basic(self):
"""Verify MJPEG frame is formatted correctly with boundary and headers."""
from backend.app.services.external_camera import _format_mjpeg_frame
# Minimal JPEG data (just SOI and EOI markers)
jpeg_data = b"\xff\xd8\xff\xd9"
result = _format_mjpeg_frame(jpeg_data)
# Check boundary
assert result.startswith(b"--frame\r\n")
# Check content type
assert b"Content-Type: image/jpeg\r\n" in result
# Check content length
assert b"Content-Length: 4\r\n" in result
# Check frame data is included
assert jpeg_data in result
# Check ends with CRLF
assert result.endswith(b"\r\n")
def test_format_mjpeg_frame_larger_data(self):
"""Verify content length is correct for larger frames."""
from backend.app.services.external_camera import _format_mjpeg_frame
# Simulate a larger JPEG (1000 bytes)
jpeg_data = b"\xff\xd8" + b"\x00" * 996 + b"\xff\xd9"
result = _format_mjpeg_frame(jpeg_data)
assert b"Content-Length: 1000\r\n" in result
class TestGetFfmpegPath:
"""Tests for ffmpeg path detection."""
def test_get_ffmpeg_path_from_shutil_which(self):
"""Verify ffmpeg found via shutil.which is returned."""
from backend.app.services.external_camera import get_ffmpeg_path
with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
result = get_ffmpeg_path()
assert result == "/usr/bin/ffmpeg"
def test_get_ffmpeg_path_fallback_to_common_paths(self):
"""Verify common paths are checked when shutil.which fails."""
from backend.app.services.external_camera import get_ffmpeg_path
with patch("shutil.which", return_value=None), patch("pathlib.Path.exists") as mock_exists:
# First common path exists
mock_exists.return_value = True
result = get_ffmpeg_path()
assert result in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]
def test_get_ffmpeg_path_returns_none_when_not_found(self):
"""Verify None is returned when ffmpeg not found anywhere."""
from backend.app.services.external_camera import get_ffmpeg_path
with patch("shutil.which", return_value=None), patch("pathlib.Path.exists", return_value=False):
result = get_ffmpeg_path()
assert result is None
class TestJpegFrameExtraction:
"""Tests for JPEG frame extraction from buffer."""
def test_extract_single_frame_from_buffer(self):
"""Test extracting a complete JPEG frame from buffer."""
# JPEG markers
jpeg_start = b"\xff\xd8"
jpeg_end = b"\xff\xd9"
# Create a buffer with one complete frame
frame_content = b"\x00" * 100
buffer = jpeg_start + frame_content + jpeg_end
# Find frame boundaries
start_idx = buffer.find(jpeg_start)
end_idx = buffer.find(jpeg_end, start_idx + 2)
assert start_idx == 0
assert end_idx == 102
# Extract frame
frame = buffer[start_idx : end_idx + 2]
assert frame == buffer
assert len(frame) == 104
def test_extract_frame_with_leading_garbage(self):
"""Test extracting frame when buffer has leading garbage data."""
jpeg_start = b"\xff\xd8"
jpeg_end = b"\xff\xd9"
# Buffer with garbage before the JPEG
garbage = b"\x00\x01\x02\x03"
frame_content = b"\xff" * 50
buffer = garbage + jpeg_start + frame_content + jpeg_end
start_idx = buffer.find(jpeg_start)
assert start_idx == 4 # After garbage
end_idx = buffer.find(jpeg_end, start_idx + 2)
frame = buffer[start_idx : end_idx + 2]
assert frame.startswith(jpeg_start)
assert frame.endswith(jpeg_end)
assert len(frame) == 54 # 2 + 50 + 2
def test_incomplete_frame_detection(self):
"""Test detection of incomplete frame (no end marker)."""
jpeg_start = b"\xff\xd8"
# Incomplete buffer - no end marker
buffer = jpeg_start + b"\x00" * 100
start_idx = buffer.find(jpeg_start)
end_idx = buffer.find(b"\xff\xd9", start_idx + 2)
assert start_idx == 0
assert end_idx == -1 # Not found
def test_multiple_frames_in_buffer(self):
"""Test extracting first frame when buffer contains multiple frames."""
jpeg_start = b"\xff\xd8"
jpeg_end = b"\xff\xd9"
# Two complete frames
frame1 = jpeg_start + b"\x01" * 10 + jpeg_end
frame2 = jpeg_start + b"\x02" * 20 + jpeg_end
buffer = frame1 + frame2
# Extract first frame
start_idx = buffer.find(jpeg_start)
end_idx = buffer.find(jpeg_end, start_idx + 2)
first_frame = buffer[start_idx : end_idx + 2]
assert first_frame == frame1
assert len(first_frame) == 14
# Remaining buffer should contain second frame
remaining = buffer[end_idx + 2 :]
assert remaining == frame2
class TestCameraTypeValidation:
"""Tests for camera type handling."""
@pytest.mark.asyncio
async def test_capture_frame_unknown_type_returns_none(self):
"""Verify unknown camera type returns None."""
from backend.app.services.external_camera import capture_frame
result = await capture_frame("http://example.com", "unknown_type")
assert result is None
@pytest.mark.asyncio
async def test_capture_frame_valid_types(self):
"""Verify valid camera types are accepted (they may fail but shouldn't error on type)."""
from backend.app.services.external_camera import capture_frame
# These will fail to connect but shouldn't raise type errors
for camera_type in ["mjpeg", "rtsp", "snapshot"]:
# Use a non-routable IP to fail fast
result = await capture_frame("http://192.0.2.1/test", camera_type, timeout=1)
# Should return None (failed connection) not raise exception
assert result is None
class TestRtspUrlHandling:
"""Tests for RTSP/RTSPS URL handling."""
def test_rtsps_url_detection(self):
"""Verify rtsps:// and rtsp:// URL schemes are distinct."""
url_rtsps = "rtsps://user:pass@192.168.1.1:554/stream"
url_rtsp = "rtsp://user:pass@192.168.1.1:554/stream"
assert url_rtsps.startswith("rtsps://")
assert not url_rtsp.startswith("rtsps://")
assert url_rtsp.startswith("rtsp://")
def test_ffmpeg_handles_both_rtsp_and_rtsps(self):
"""Verify ffmpeg command structure handles both URL schemes identically.
ffmpeg automatically handles TLS for rtsps:// URLs, so no special
flags are needed - both URL schemes use the same command structure.
"""
# Both URL types should use the same basic ffmpeg options
base_cmd = [
"ffmpeg",
"-rtsp_transport",
"tcp",
"-i",
]
rtsp_url = "rtsp://user:pass@192.168.1.1:554/stream"
rtsps_url = "rtsps://user:pass@192.168.1.1:554/stream"
# Command structure is identical for both
cmd_rtsp = base_cmd + [rtsp_url]
cmd_rtsps = base_cmd + [rtsps_url]
# Only the URL differs
assert cmd_rtsp[:-1] == cmd_rtsps[:-1]
assert cmd_rtsp[-1] != cmd_rtsps[-1]