mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +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:
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user