mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 11:47:46 +02:00
feat(camera): optional snapshot URL override for external cameras (#1177)
go2rtc and several IP cameras still emit a warm-up / black frame on every fresh MJPEG connection — even with the v0.2.4b2 warm-up-skip fix it slipped through intermittently for @nkm8's setup. His own bisect named the clean solution: go2rtc exposes /api/frame.jpeg as a dedicated single-frame endpoint that never returns the encoder's stale keyframe. Adds an optional external_camera_snapshot_url column on printers. When set, every single-frame capture path (snapshot endpoint, [SNAPSHOT] notification thumbnails, [PHOTO-BG] finish photo, layer timelapse, Obico ML, plate-detect / calibrate-plate) routes through _capture_snapshot on the override URL via plain HTTP GET, bypassing the warm-up dance. Live view stays on the configured stream URL — only single-frame captures use the override. Override is camera-type-agnostic. SSRF guard applies (existing _sanitize_camera_url allowlist). Empty string treated as unset. Settings UI: new "Snapshot URL (optional)" input + Test button under External Cameras, hidden for camera_type=snapshot since the live URL is already a single-frame source. en + de fully translated; 6 other locales seeded with English copy. 5 backend tests pin the routing contract; 3 frontend tests pin the input + debounced PATCH. Documented in bambuddy-wiki/docs/features/camera.md with the go2rtc example.
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
@@ -792,7 +792,12 @@ async def camera_snapshot(
|
||||
if printer.external_camera_enabled and printer.external_camera_url:
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type, timeout=15)
|
||||
frame_data = await capture_frame(
|
||||
printer.external_camera_url,
|
||||
printer.external_camera_type,
|
||||
timeout=15,
|
||||
snapshot_url=printer.external_camera_snapshot_url,
|
||||
)
|
||||
if not frame_data:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
@@ -1040,6 +1045,7 @@ async def check_plate_empty(
|
||||
external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None,
|
||||
use_external=use_external,
|
||||
roi=roi,
|
||||
external_camera_snapshot_url=printer.external_camera_snapshot_url if printer.external_camera_enabled else None,
|
||||
)
|
||||
|
||||
# Get reference count for the response
|
||||
@@ -1124,6 +1130,7 @@ async def calibrate_plate_detection(
|
||||
external_camera_url=printer.external_camera_url if printer.external_camera_enabled else None,
|
||||
external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None,
|
||||
use_external=use_external,
|
||||
external_camera_snapshot_url=printer.external_camera_snapshot_url if printer.external_camera_enabled else None,
|
||||
)
|
||||
|
||||
if light_warning and success:
|
||||
|
||||
@@ -905,6 +905,7 @@ async def run_migrations(conn):
|
||||
await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_url VARCHAR(500)")
|
||||
await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_type VARCHAR(20)")
|
||||
await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_enabled BOOLEAN DEFAULT 0")
|
||||
await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_snapshot_url VARCHAR(500)")
|
||||
|
||||
# Migration: Add external_url column to print_archives for user-defined links (Printables, etc.)
|
||||
await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)")
|
||||
|
||||
+11
-2
@@ -1346,7 +1346,11 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
|
||||
logger.info("[SNAPSHOT] Capturing from external camera for printer %s", printer_id)
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type or "mjpeg")
|
||||
frame_data = await capture_frame(
|
||||
printer.external_camera_url,
|
||||
printer.external_camera_type or "mjpeg",
|
||||
snapshot_url=printer.external_camera_snapshot_url,
|
||||
)
|
||||
if frame_data and len(frame_data) <= 2_500_000:
|
||||
logger.info("[SNAPSHOT] External camera frame: %s bytes", len(frame_data))
|
||||
return _apply_camera_rotation(frame_data, printer, logger)
|
||||
@@ -1616,6 +1620,7 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
external_camera_type=printer.external_camera_type,
|
||||
use_external=printer.external_camera_enabled,
|
||||
roi=roi,
|
||||
external_camera_snapshot_url=printer.external_camera_snapshot_url,
|
||||
)
|
||||
|
||||
# Restore chamber light to original state
|
||||
@@ -2201,6 +2206,7 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
fallback_archive.id,
|
||||
printer.external_camera_url,
|
||||
printer.external_camera_type or "mjpeg",
|
||||
snapshot_url=printer.external_camera_snapshot_url,
|
||||
)
|
||||
logger.info("Started layer timelapse for printer %s, archive %s", printer_id, fallback_archive.id)
|
||||
|
||||
@@ -2290,6 +2296,7 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
archive.id,
|
||||
printer.external_camera_url,
|
||||
printer.external_camera_type or "mjpeg",
|
||||
snapshot_url=printer.external_camera_snapshot_url,
|
||||
)
|
||||
logger.info("Started layer timelapse for printer %s, archive %s", printer_id, archive.id)
|
||||
|
||||
@@ -3332,7 +3339,9 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
frame_data = await capture_frame(
|
||||
printer.external_camera_url, printer.external_camera_type or "mjpeg"
|
||||
printer.external_camera_url,
|
||||
printer.external_camera_type or "mjpeg",
|
||||
snapshot_url=printer.external_camera_snapshot_url,
|
||||
)
|
||||
if frame_data:
|
||||
photos_dir = archive_dir / "photos"
|
||||
|
||||
@@ -28,6 +28,11 @@ class Printer(Base):
|
||||
external_camera_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
external_camera_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # mjpeg, rtsp, snapshot
|
||||
external_camera_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# Optional single-frame snapshot URL — when set, used for snapshot / finish-photo
|
||||
# / timelapse / plate-detect captures instead of opening the live stream and
|
||||
# skipping a warm-up frame. Bypasses MJPEG warm-up issues on sources that
|
||||
# expose a dedicated frame endpoint (e.g. go2rtc's /api/frame.jpeg). #1177.
|
||||
external_camera_snapshot_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
camera_rotation: Mapped[int] = mapped_column(default=0) # 0, 90, 180, 270 degrees
|
||||
# Plate detection - check if build plate is empty before starting print
|
||||
plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
@@ -18,6 +18,7 @@ class PrinterBase(BaseModel):
|
||||
external_camera_url: str | None = None
|
||||
external_camera_type: str | None = None # "mjpeg", "rtsp", "snapshot", "usb"
|
||||
external_camera_enabled: bool = False
|
||||
external_camera_snapshot_url: str | None = None # Optional single-frame override; #1177
|
||||
camera_rotation: int = 0 # 0, 90, 180, 270 degrees
|
||||
|
||||
|
||||
@@ -50,6 +51,7 @@ class PrinterUpdate(BaseModel):
|
||||
external_camera_url: str | None = None
|
||||
external_camera_type: str | None = None
|
||||
external_camera_enabled: bool | None = None
|
||||
external_camera_snapshot_url: str | None = None # #1177
|
||||
camera_rotation: int | None = None # 0, 90, 180, 270 degrees
|
||||
plate_detection_enabled: bool | None = None
|
||||
plate_detection_roi: PlateDetectionROI | None = None
|
||||
@@ -63,6 +65,7 @@ class PrinterResponse(PrinterBase):
|
||||
external_camera_url: str | None = None
|
||||
external_camera_type: str | None = None
|
||||
external_camera_enabled: bool = False
|
||||
external_camera_snapshot_url: str | None = None # #1177
|
||||
camera_rotation: int = 0 # 0, 90, 180, 270 degrees
|
||||
plate_detection_enabled: bool = False
|
||||
plate_detection_roi: PlateDetectionROI | None = None
|
||||
@@ -87,6 +90,7 @@ class PrinterResponse(PrinterBase):
|
||||
"external_camera_url": printer.external_camera_url,
|
||||
"external_camera_type": printer.external_camera_type,
|
||||
"external_camera_enabled": printer.external_camera_enabled,
|
||||
"external_camera_snapshot_url": printer.external_camera_snapshot_url,
|
||||
"camera_rotation": printer.camera_rotation,
|
||||
"is_active": printer.is_active,
|
||||
"nozzle_count": printer.nozzle_count,
|
||||
|
||||
@@ -173,17 +173,30 @@ def get_ffmpeg_path() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
async def capture_frame(url: str, camera_type: str, timeout: int = 15) -> bytes | None:
|
||||
async def capture_frame(
|
||||
url: str,
|
||||
camera_type: str,
|
||||
timeout: int = 15,
|
||||
snapshot_url: str | None = None,
|
||||
) -> bytes | None:
|
||||
"""Capture single frame from external camera.
|
||||
|
||||
Args:
|
||||
url: Camera URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path)
|
||||
camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
|
||||
timeout: Connection timeout in seconds
|
||||
url: Live-stream URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path).
|
||||
camera_type: "mjpeg", "rtsp", "snapshot", or "usb".
|
||||
timeout: Connection timeout in seconds.
|
||||
snapshot_url: Optional override for single-frame capture. When set, fetched
|
||||
via plain HTTP GET regardless of `camera_type`. Bypasses MJPEG warm-up
|
||||
handling on sources that expose a dedicated frame endpoint (e.g. go2rtc's
|
||||
`/api/frame.jpeg` reliably returns a clean image while the MJPEG stream's
|
||||
first frame is often the encoder's stale keyframe). #1177.
|
||||
|
||||
Returns:
|
||||
JPEG bytes or None on failure
|
||||
"""
|
||||
if snapshot_url:
|
||||
logger.debug("capture_frame using snapshot override url=%s...", snapshot_url[:50])
|
||||
return await _capture_snapshot(snapshot_url, timeout)
|
||||
logger.debug("capture_frame called: type=%s, url=%s...", camera_type, url[:50] if url else "None")
|
||||
if camera_type == "mjpeg":
|
||||
return await _capture_mjpeg_frame(url, timeout)
|
||||
|
||||
@@ -40,6 +40,7 @@ class TimelapseSession:
|
||||
archive_id: int | None
|
||||
camera_url: str
|
||||
camera_type: str
|
||||
snapshot_url: str | None = None # Optional single-frame override; #1177
|
||||
last_layer: int = -1
|
||||
frame_count: int = 0
|
||||
session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
|
||||
@@ -66,7 +67,7 @@ class TimelapseSession:
|
||||
self.last_layer = layer_num
|
||||
|
||||
try:
|
||||
frame_data = await capture_frame(self.camera_url, self.camera_type)
|
||||
frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
|
||||
if frame_data:
|
||||
frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
|
||||
await asyncio.to_thread(frame_path.write_bytes, frame_data)
|
||||
@@ -180,7 +181,13 @@ class TimelapseSession:
|
||||
logger.warning("Failed to cleanup timelapse frames: %s", e)
|
||||
|
||||
|
||||
def start_session(printer_id: int, archive_id: int | None, url: str, cam_type: str) -> TimelapseSession:
|
||||
def start_session(
|
||||
printer_id: int,
|
||||
archive_id: int | None,
|
||||
url: str,
|
||||
cam_type: str,
|
||||
snapshot_url: str | None = None,
|
||||
) -> TimelapseSession:
|
||||
"""Start new timelapse session for a printer.
|
||||
|
||||
Args:
|
||||
@@ -188,6 +195,8 @@ def start_session(printer_id: int, archive_id: int | None, url: str, cam_type: s
|
||||
archive_id: Associated print archive ID (optional)
|
||||
url: External camera URL
|
||||
cam_type: Camera type ("mjpeg", "rtsp", "snapshot")
|
||||
snapshot_url: Optional single-frame URL override; when set, layer captures
|
||||
fetch from it directly instead of opening the live stream. #1177.
|
||||
|
||||
Returns:
|
||||
The new TimelapseSession
|
||||
@@ -200,6 +209,7 @@ def start_session(printer_id: int, archive_id: int | None, url: str, cam_type: s
|
||||
archive_id=archive_id,
|
||||
camera_url=url,
|
||||
camera_type=cam_type,
|
||||
snapshot_url=snapshot_url,
|
||||
)
|
||||
_active_sessions[printer_id] = session
|
||||
logger.info("Started timelapse session for printer %s", printer_id)
|
||||
|
||||
@@ -197,6 +197,7 @@ class ObicoDetectionService:
|
||||
printer.external_camera_url,
|
||||
printer.external_camera_type,
|
||||
timeout=SNAPSHOT_CAPTURE_TIMEOUT,
|
||||
snapshot_url=printer.external_camera_snapshot_url,
|
||||
)
|
||||
return await capture_camera_frame_bytes(
|
||||
ip_address=printer.ip_address,
|
||||
|
||||
@@ -588,6 +588,7 @@ async def capture_camera_image(
|
||||
external_camera_url: str | None = None,
|
||||
external_camera_type: str | None = None,
|
||||
use_external: bool = False,
|
||||
external_camera_snapshot_url: str | None = None,
|
||||
) -> tuple[bytes | None, str]:
|
||||
"""Capture an image from the printer camera.
|
||||
|
||||
@@ -605,7 +606,11 @@ async def capture_camera_image(
|
||||
try:
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
image_data = await capture_frame(external_camera_url, external_camera_type)
|
||||
image_data = await capture_frame(
|
||||
external_camera_url,
|
||||
external_camera_type,
|
||||
snapshot_url=external_camera_snapshot_url,
|
||||
)
|
||||
if image_data:
|
||||
camera_source = "external"
|
||||
logger.debug("Captured frame from external camera for printer %s", printer_id)
|
||||
@@ -665,6 +670,7 @@ async def check_plate_empty(
|
||||
external_camera_type: str | None = None,
|
||||
use_external: bool = False,
|
||||
roi: tuple[float, float, float, float] | None = None,
|
||||
external_camera_snapshot_url: str | None = None,
|
||||
) -> PlateDetectionResult:
|
||||
"""Check if the build plate is empty for a printer.
|
||||
|
||||
@@ -692,7 +698,14 @@ async def check_plate_empty(
|
||||
)
|
||||
|
||||
image_data, camera_source = await capture_camera_image(
|
||||
printer_id, ip_address, access_code, model, external_camera_url, external_camera_type, use_external
|
||||
printer_id,
|
||||
ip_address,
|
||||
access_code,
|
||||
model,
|
||||
external_camera_url,
|
||||
external_camera_type,
|
||||
use_external,
|
||||
external_camera_snapshot_url=external_camera_snapshot_url,
|
||||
)
|
||||
|
||||
if image_data is None:
|
||||
@@ -722,6 +735,7 @@ async def calibrate_plate(
|
||||
external_camera_url: str | None = None,
|
||||
external_camera_type: str | None = None,
|
||||
use_external: bool = False,
|
||||
external_camera_snapshot_url: str | None = None,
|
||||
) -> tuple[bool, str, int]:
|
||||
"""Calibrate plate detection by capturing a reference image of the empty plate.
|
||||
|
||||
@@ -742,7 +756,14 @@ async def calibrate_plate(
|
||||
return False, "OpenCV not available - plate detection disabled", -1
|
||||
|
||||
image_data, camera_source = await capture_camera_image(
|
||||
printer_id, ip_address, access_code, model, external_camera_url, external_camera_type, use_external
|
||||
printer_id,
|
||||
ip_address,
|
||||
access_code,
|
||||
model,
|
||||
external_camera_url,
|
||||
external_camera_type,
|
||||
use_external,
|
||||
external_camera_snapshot_url=external_camera_snapshot_url,
|
||||
)
|
||||
|
||||
if image_data is None:
|
||||
|
||||
@@ -372,6 +372,147 @@ class TestCameraTypeValidation:
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestSnapshotUrlOverride:
|
||||
"""#1177 follow-up. When ``external_camera_snapshot_url`` is set on the
|
||||
printer, every single-frame capture (notification thumbnail, finish photo,
|
||||
timelapse, plate-detect) must route through the plain HTTP-GET path on the
|
||||
snapshot URL instead of opening the live stream and skipping a warm-up
|
||||
frame. Sources that expose a dedicated frame endpoint (e.g. go2rtc's
|
||||
``/api/frame.jpeg``) reliably return a clean image — the warm-up dance is
|
||||
only required for sources that don't, and bypassing it removes the
|
||||
inconsistency the reporter still saw after the warm-up fix landed."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_override_routes_to_snapshot_path(self):
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.external_camera._capture_snapshot",
|
||||
new=AsyncMock(return_value=b"\xff\xd8snapshot\xff\xd9"),
|
||||
) as mocked_snapshot,
|
||||
patch(
|
||||
"backend.app.services.external_camera._capture_mjpeg_frame",
|
||||
new=AsyncMock(return_value=b"should-not-be-called"),
|
||||
) as mocked_mjpeg,
|
||||
):
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
result = await capture_frame(
|
||||
"http://192.168.1.61:1984/api/stream.mjpeg",
|
||||
"mjpeg",
|
||||
snapshot_url="http://192.168.1.61:1984/api/frame.jpeg",
|
||||
)
|
||||
|
||||
assert result == b"\xff\xd8snapshot\xff\xd9"
|
||||
mocked_snapshot.assert_awaited_once()
|
||||
# First positional arg is the snapshot URL; the live-stream URL is ignored.
|
||||
assert mocked_snapshot.await_args.args[0] == "http://192.168.1.61:1984/api/frame.jpeg"
|
||||
mocked_mjpeg.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_snapshot_override_routes_to_camera_type_handler(self):
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.external_camera._capture_snapshot",
|
||||
new=AsyncMock(return_value=b"should-not-be-called"),
|
||||
) as mocked_snapshot,
|
||||
patch(
|
||||
"backend.app.services.external_camera._capture_mjpeg_frame",
|
||||
new=AsyncMock(return_value=b"\xff\xd8live\xff\xd9"),
|
||||
) as mocked_mjpeg,
|
||||
):
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
result = await capture_frame("http://192.168.1.61:1984/api/stream.mjpeg", "mjpeg")
|
||||
|
||||
assert result == b"\xff\xd8live\xff\xd9"
|
||||
mocked_mjpeg.assert_awaited_once()
|
||||
mocked_snapshot.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_string_snapshot_url_treated_as_unset(self):
|
||||
"""Falsy snapshot_url (empty string from a cleared input) must NOT
|
||||
hijack the live-stream path — the form-cleared input becomes ``None``
|
||||
in the DB, but a defence-in-depth empty-string guard means a stale
|
||||
config row still uses the live stream rather than firing GET ''."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.external_camera._capture_snapshot",
|
||||
new=AsyncMock(return_value=b"should-not-be-called"),
|
||||
) as mocked_snapshot,
|
||||
patch(
|
||||
"backend.app.services.external_camera._capture_mjpeg_frame",
|
||||
new=AsyncMock(return_value=b"\xff\xd8live\xff\xd9"),
|
||||
) as mocked_mjpeg,
|
||||
):
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
result = await capture_frame(
|
||||
"http://192.168.1.61:1984/api/stream.mjpeg",
|
||||
"mjpeg",
|
||||
snapshot_url="",
|
||||
)
|
||||
|
||||
assert result == b"\xff\xd8live\xff\xd9"
|
||||
mocked_mjpeg.assert_awaited_once()
|
||||
mocked_snapshot.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_override_honours_ssrf_guard(self):
|
||||
"""The override goes through ``_capture_snapshot`` which already
|
||||
sanitises the URL — link-local / metadata / blocked-host targets
|
||||
return None instead of being fetched."""
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
result = await capture_frame(
|
||||
"http://192.168.1.61:1984/api/stream.mjpeg",
|
||||
"mjpeg",
|
||||
snapshot_url="http://169.254.169.254/latest/meta-data/",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_override_works_for_rtsp_and_usb_camera_types(self):
|
||||
"""The override is camera-type agnostic: a user with an RTSP or USB
|
||||
stream paired with a separate HTTP snapshot endpoint (e.g. go2rtc
|
||||
feeding a USB cam, exposing both /api/stream.mjpeg and
|
||||
/api/frame.jpeg) gets clean snapshots without spinning up ffmpeg."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
for camera_type in ("rtsp", "usb"):
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.external_camera._capture_snapshot",
|
||||
new=AsyncMock(return_value=b"\xff\xd8snap\xff\xd9"),
|
||||
) as mocked_snapshot,
|
||||
patch(
|
||||
"backend.app.services.external_camera._capture_rtsp_frame",
|
||||
new=AsyncMock(return_value=b"should-not-be-called"),
|
||||
) as mocked_rtsp,
|
||||
patch(
|
||||
"backend.app.services.external_camera._capture_usb_frame",
|
||||
new=AsyncMock(return_value=b"should-not-be-called"),
|
||||
) as mocked_usb,
|
||||
):
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
result = await capture_frame(
|
||||
"rtsp://printer/stream" if camera_type == "rtsp" else "/dev/video0",
|
||||
camera_type,
|
||||
snapshot_url="http://192.168.1.61:1984/api/frame.jpeg",
|
||||
)
|
||||
|
||||
assert result == b"\xff\xd8snap\xff\xd9", f"camera_type={camera_type}"
|
||||
mocked_snapshot.assert_awaited_once()
|
||||
mocked_rtsp.assert_not_awaited()
|
||||
mocked_usb.assert_not_awaited()
|
||||
|
||||
|
||||
class TestRtspUrlHandling:
|
||||
"""Tests for RTSP/RTSPS URL handling."""
|
||||
|
||||
|
||||
@@ -717,4 +717,95 @@ describe('SettingsPage', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('external camera snapshot URL override (#1177)', () => {
|
||||
/**
|
||||
* The snapshot URL input only appears for stream camera types where the
|
||||
* MJPEG warm-up problem can occur (mjpeg / rtsp / usb). Pure HTTP
|
||||
* snapshot sources don't need an override since their stream URL is
|
||||
* already a single-frame endpoint.
|
||||
*/
|
||||
const mjpegPrinter = {
|
||||
id: 7,
|
||||
name: 'go2rtc Cam',
|
||||
serial_number: 'TEST123',
|
||||
ip_address: '192.168.1.100',
|
||||
access_code: 'XXXX',
|
||||
model: 'P1S',
|
||||
location: null,
|
||||
nozzle_count: 1,
|
||||
is_active: true,
|
||||
auto_archive: true,
|
||||
external_camera_url: 'http://192.168.1.61:1984/api/stream.mjpeg?src=printer',
|
||||
external_camera_type: 'mjpeg',
|
||||
external_camera_enabled: true,
|
||||
external_camera_snapshot_url: null,
|
||||
camera_rotation: 0,
|
||||
plate_detection_enabled: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
it('renders the snapshot URL input when camera_type is mjpeg', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
|
||||
);
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the snapshot URL input when camera_type is snapshot (already a single-frame source)', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/', () =>
|
||||
HttpResponse.json([{ ...mjpegPrinter, external_camera_type: 'snapshot' }]),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
// Wait for the live-stream URL placeholder to render so we know the
|
||||
// camera section finished mounting before asserting absence of the
|
||||
// snapshot input below.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/Camera URL/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PATCHes the printer with external_camera_snapshot_url when the user types into the input', async () => {
|
||||
let receivedBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
|
||||
http.patch('/api/v1/printers/7', async ({ request }) => {
|
||||
receivedBody = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ ...mjpegPrinter, ...receivedBody });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
const input = await waitFor(() =>
|
||||
screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(input, 'http://192.168.1.61:1984/api/frame.jpeg?src=printer');
|
||||
|
||||
// Save is debounced by 800ms; assert the PATCH eventually fires with
|
||||
// the typed snapshot URL.
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(receivedBody).not.toBeNull();
|
||||
expect(receivedBody!.external_camera_snapshot_url).toBe(
|
||||
'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
|
||||
);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface Printer {
|
||||
external_camera_url: string | null;
|
||||
external_camera_type: string | null; // "mjpeg", "rtsp", "snapshot"
|
||||
external_camera_enabled: boolean;
|
||||
external_camera_snapshot_url: string | null; // optional single-frame override (#1177)
|
||||
camera_rotation: number; // 0, 90, 180, 270 degrees
|
||||
plate_detection_enabled: boolean; // Check plate before print
|
||||
plate_detection_roi?: PlateDetectionROI; // ROI for plate detection
|
||||
@@ -351,6 +352,7 @@ export interface PrinterCreate {
|
||||
external_camera_url?: string | null;
|
||||
external_camera_type?: string | null;
|
||||
external_camera_enabled?: boolean;
|
||||
external_camera_snapshot_url?: string | null;
|
||||
camera_rotation?: number;
|
||||
plate_detection_enabled?: boolean;
|
||||
plate_detection_roi?: PlateDetectionROI;
|
||||
|
||||
@@ -2056,6 +2056,9 @@ export default {
|
||||
cameraTypeRtsp: 'RTSP-Stream',
|
||||
cameraTypeSnapshot: 'HTTP-Snapshot',
|
||||
cameraTypeUsb: 'USB-Kamera (V4L2)',
|
||||
cameraSnapshotUrl: 'Snapshot-URL (optional)',
|
||||
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
|
||||
cameraSnapshotUrlHelp: 'URL für Einzelbildaufnahmen — wird für Benachrichtigungs-Vorschaubilder, Abschlussfotos, Zeitraffer und Plattenerkennung verwendet. Leer lassen, um Bilder aus dem oben konfigurierten Live-Stream zu verwenden. Nützlich für go2rtc (/api/frame.jpeg) und IP-Kameras mit dediziertem Snapshot-Endpunkt.',
|
||||
cameraRotation: 'Drehung',
|
||||
test: 'Testen',
|
||||
connected: 'Verbunden',
|
||||
|
||||
@@ -2059,6 +2059,9 @@ export default {
|
||||
cameraTypeRtsp: 'RTSP Stream',
|
||||
cameraTypeSnapshot: 'HTTP Snapshot',
|
||||
cameraTypeUsb: 'USB Camera (V4L2)',
|
||||
cameraSnapshotUrl: 'Snapshot URL (optional)',
|
||||
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
|
||||
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
|
||||
cameraRotation: 'Rotation',
|
||||
test: 'Test',
|
||||
connected: 'Connected',
|
||||
|
||||
@@ -2010,6 +2010,9 @@ export default {
|
||||
cameraTypeRtsp: 'Flux RTSP',
|
||||
cameraTypeSnapshot: 'Snapshot HTTP',
|
||||
cameraTypeUsb: 'Caméra USB (V4L2)',
|
||||
cameraSnapshotUrl: 'Snapshot URL (optional)',
|
||||
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
|
||||
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
|
||||
cameraRotation: 'Rotation',
|
||||
test: 'Tester',
|
||||
connected: 'Connecté',
|
||||
|
||||
@@ -2009,6 +2009,9 @@ export default {
|
||||
cameraTypeRtsp: 'Stream RTSP',
|
||||
cameraTypeSnapshot: 'Snapshot HTTP',
|
||||
cameraTypeUsb: 'Fotocamera USB (V4L2)',
|
||||
cameraSnapshotUrl: 'Snapshot URL (optional)',
|
||||
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
|
||||
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
|
||||
cameraRotation: 'Rotazione',
|
||||
test: 'Test',
|
||||
connected: 'Connesso',
|
||||
|
||||
@@ -2055,6 +2055,9 @@ export default {
|
||||
cameraTypeRtsp: 'RTSPストリーム',
|
||||
cameraTypeSnapshot: 'HTTPスナップショット',
|
||||
cameraTypeUsb: 'USBカメラ (V4L2)',
|
||||
cameraSnapshotUrl: 'Snapshot URL (optional)',
|
||||
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
|
||||
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
|
||||
cameraRotation: '回転',
|
||||
test: 'テスト',
|
||||
connected: '接続済み',
|
||||
|
||||
@@ -2009,6 +2009,9 @@ export default {
|
||||
cameraTypeRtsp: 'Stream RTSP',
|
||||
cameraTypeSnapshot: 'Snapshot HTTP',
|
||||
cameraTypeUsb: 'Câmera USB (V4L2)',
|
||||
cameraSnapshotUrl: 'Snapshot URL (optional)',
|
||||
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
|
||||
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
|
||||
cameraRotation: 'Rotação',
|
||||
test: 'Testar',
|
||||
connected: 'Conectado',
|
||||
|
||||
@@ -2053,6 +2053,9 @@ export default {
|
||||
cameraTypeRtsp: 'RTSP 流',
|
||||
cameraTypeSnapshot: 'HTTP 快照',
|
||||
cameraTypeUsb: 'USB 摄像头 (V4L2)',
|
||||
cameraSnapshotUrl: 'Snapshot URL (optional)',
|
||||
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
|
||||
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
|
||||
cameraRotation: '旋转',
|
||||
test: '测试',
|
||||
connected: '已连接',
|
||||
|
||||
@@ -2053,6 +2053,9 @@ export default {
|
||||
cameraTypeRtsp: 'RTSP 流',
|
||||
cameraTypeSnapshot: 'HTTP 快照',
|
||||
cameraTypeUsb: 'USB 攝影機 (V4L2)',
|
||||
cameraSnapshotUrl: 'Snapshot URL (optional)',
|
||||
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
|
||||
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
|
||||
cameraRotation: '旋轉',
|
||||
test: '測試',
|
||||
connected: '已連線',
|
||||
|
||||
@@ -893,7 +893,7 @@ export function SettingsPage() {
|
||||
});
|
||||
|
||||
const updatePrinterMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> }) =>
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean; external_camera_snapshot_url: string | null; camera_rotation: number }> }) =>
|
||||
api.updatePrinter(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['printers'] });
|
||||
@@ -1116,20 +1116,31 @@ export function SettingsPage() {
|
||||
const [localCameraUrls, setLocalCameraUrls] = useState<Record<number, string>>({});
|
||||
const cameraUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
|
||||
const initializedPrinterUrlsRef = useRef<Set<number>>(new Set());
|
||||
const [localSnapshotUrls, setLocalSnapshotUrls] = useState<Record<number, string>>({});
|
||||
const snapshotUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
|
||||
const initializedPrinterSnapshotUrlsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
// Initialize local camera URLs from printer data
|
||||
useEffect(() => {
|
||||
if (printers) {
|
||||
const urls: Record<number, string> = {};
|
||||
const snapUrls: Record<number, string> = {};
|
||||
printers.forEach(p => {
|
||||
if (p.external_camera_url && !initializedPrinterUrlsRef.current.has(p.id)) {
|
||||
urls[p.id] = p.external_camera_url;
|
||||
initializedPrinterUrlsRef.current.add(p.id);
|
||||
}
|
||||
if (p.external_camera_snapshot_url && !initializedPrinterSnapshotUrlsRef.current.has(p.id)) {
|
||||
snapUrls[p.id] = p.external_camera_snapshot_url;
|
||||
initializedPrinterSnapshotUrlsRef.current.add(p.id);
|
||||
}
|
||||
});
|
||||
if (Object.keys(urls).length > 0) {
|
||||
setLocalCameraUrls(prev => ({ ...prev, ...urls }));
|
||||
}
|
||||
if (Object.keys(snapUrls).length > 0) {
|
||||
setLocalSnapshotUrls(prev => ({ ...prev, ...snapUrls }));
|
||||
}
|
||||
}
|
||||
}, [printers]);
|
||||
|
||||
@@ -1151,6 +1162,21 @@ export function SettingsPage() {
|
||||
}, 800);
|
||||
};
|
||||
|
||||
const handleSnapshotUrlChange = (printerId: number, url: string) => {
|
||||
setLocalSnapshotUrls(prev => ({ ...prev, [printerId]: url }));
|
||||
|
||||
if (snapshotUrlSaveTimeoutRef.current[printerId]) {
|
||||
clearTimeout(snapshotUrlSaveTimeoutRef.current[printerId]);
|
||||
}
|
||||
|
||||
snapshotUrlSaveTimeoutRef.current[printerId] = setTimeout(() => {
|
||||
updatePrinterMutation.mutate({
|
||||
id: printerId,
|
||||
data: { external_camera_snapshot_url: url || null }
|
||||
});
|
||||
}, 800);
|
||||
};
|
||||
|
||||
const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean; rotation?: number }) => {
|
||||
const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> = {};
|
||||
if (updates.type !== undefined) data.external_camera_type = updates.type || null;
|
||||
@@ -1912,6 +1938,35 @@ export function SettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(printer.external_camera_type === 'mjpeg' || printer.external_camera_type === 'rtsp' || printer.external_camera_type === 'usb') && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-bambu-gray">{t('settings.cameraSnapshotUrl', 'Snapshot URL (optional)')}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('settings.cameraSnapshotUrlPlaceholder', 'http://192.168.1.61:1984/api/frame.jpeg?src=printer')}
|
||||
value={localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? ''}
|
||||
onChange={(e) => handleSnapshotUrlChange(printer.id, e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleTestExternalCamera(printer.id, localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? '', 'snapshot')}
|
||||
disabled={extCameraTestLoading[printer.id] || !(localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url)}
|
||||
>
|
||||
{extCameraTestLoading[printer.id] ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
t('settings.test')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray opacity-75">
|
||||
{t('settings.cameraSnapshotUrlHelp', 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-bambu-gray">{t('settings.cameraRotation')}</label>
|
||||
<select
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="./img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="./assets/index-CwcBz1oz.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CrzRt--w.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Cw7zekS6.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user