mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 13:11:35 +02:00
Renew overlay camera connections after silent stalls (#3213)
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useOverlayCameraRecovery } from '../../hooks/useOverlayCameraRecovery';
|
||||
|
||||
describe('useOverlayCameraRecovery', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('renews the connection periodically without an error event', () => {
|
||||
const { result } = renderHook(() => useOverlayCameraRecovery(true));
|
||||
const initial = result.current.imageKey;
|
||||
act(() => vi.advanceTimersByTime(59_999));
|
||||
expect(result.current.imageKey).toBe(initial);
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(result.current.imageKey).not.toBe(initial);
|
||||
act(() => vi.advanceTimersByTime(60_000));
|
||||
expect(result.current.imageKey).toBe(initial + 2);
|
||||
});
|
||||
|
||||
it('deduplicates image errors and retries after three seconds', () => {
|
||||
const { result } = renderHook(() => useOverlayCameraRecovery(true));
|
||||
const initial = result.current.imageKey;
|
||||
act(() => { result.current.handleStreamError(); result.current.handleStreamError(); });
|
||||
act(() => vi.advanceTimersByTime(2_999));
|
||||
expect(result.current.imageKey).toBe(initial);
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(result.current.imageKey).toBe(initial + 1);
|
||||
});
|
||||
|
||||
it('gives a connection opened by an error retry a full renewal period', () => {
|
||||
const { result } = renderHook(() => useOverlayCameraRecovery(true));
|
||||
const initial = result.current.imageKey;
|
||||
act(() => vi.advanceTimersByTime(56_000));
|
||||
act(() => result.current.handleStreamError());
|
||||
act(() => vi.advanceTimersByTime(3_000));
|
||||
expect(result.current.imageKey).toBe(initial + 1);
|
||||
act(() => vi.advanceTimersByTime(59_999));
|
||||
expect(result.current.imageKey).toBe(initial + 1);
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(result.current.imageKey).toBe(initial + 2);
|
||||
});
|
||||
|
||||
it('cancels a pending error retry when periodic renewal happens first', () => {
|
||||
const { result } = renderHook(() => useOverlayCameraRecovery(true));
|
||||
const initial = result.current.imageKey;
|
||||
act(() => vi.advanceTimersByTime(59_000));
|
||||
act(() => result.current.handleStreamError());
|
||||
act(() => vi.advanceTimersByTime(1_000));
|
||||
expect(result.current.imageKey).toBe(initial + 1);
|
||||
act(() => vi.advanceTimersByTime(3_000));
|
||||
expect(result.current.imageKey).toBe(initial + 1);
|
||||
});
|
||||
|
||||
it('does not schedule recovery while the camera is disabled', () => {
|
||||
const { result } = renderHook(() => useOverlayCameraRecovery(false));
|
||||
const initial = result.current.imageKey;
|
||||
act(() => result.current.handleStreamError());
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
act(() => vi.advanceTimersByTime(120_000));
|
||||
expect(result.current.imageKey).toBe(initial);
|
||||
});
|
||||
|
||||
it('cancels all recovery timers when hidden or unmounted', () => {
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ enabled }) => useOverlayCameraRecovery(enabled), { initialProps: { enabled: true } },
|
||||
);
|
||||
act(() => result.current.handleStreamError());
|
||||
rerender({ enabled: false });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
act(() => result.current.handleStreamError());
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
rerender({ enabled: true });
|
||||
act(() => result.current.handleStreamError());
|
||||
unmount();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { screen, waitFor, render as rtlRender } from '@testing-library/react';
|
||||
import { act, screen, waitFor, render as rtlRender } from '@testing-library/react';
|
||||
import { StreamOverlayPage } from '../../pages/StreamOverlayPage';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
@@ -106,6 +106,32 @@ describe('StreamOverlayPage', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each(['', '&artwork=2'])('reconnects the kiosk camera without changing its token or settings (%s)', async (artwork) => {
|
||||
server.use(http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json({
|
||||
...mockStatusIdle, camera_rotation: 90,
|
||||
})));
|
||||
const timers = vi.spyOn(globalThis, 'setTimeout');
|
||||
const view = renderOverlayPage(1, `?token=overlay-test&fps=7${artwork}`);
|
||||
try {
|
||||
const image = await screen.findByAltText('Camera stream');
|
||||
const source = image.getAttribute('src');
|
||||
expect(source).toContain('token=overlay-test');
|
||||
const renewal = timers.mock.calls.find(([, delay]) => delay === 60_000)?.[0];
|
||||
expect(renewal).toBeTypeOf('function');
|
||||
if (typeof renewal !== 'function') throw new Error('Missing camera renewal timer');
|
||||
act(() => renewal());
|
||||
const renewed = screen.getByAltText('Camera stream');
|
||||
expect(renewed).not.toBe(image);
|
||||
expect(renewed.getAttribute('src')).not.toBe(source);
|
||||
expect(renewed.getAttribute('src')).toContain('token=overlay-test');
|
||||
expect(renewed.getAttribute('src')).toContain('fps=7');
|
||||
expect(renewed.style.transform).toBe(artwork ? 'translate(-50%, -50%) rotate(90deg)' : 'rotate(90deg)');
|
||||
} finally {
|
||||
view.unmount();
|
||||
timers.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
describe('updated artwork', () => {
|
||||
it('maps legacy model codes in version 2', async () => {
|
||||
server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, model: 'BL-P001' })));
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
// MJPEG <img> connections can stop delivering frames without firing onError.
|
||||
// Renew only the camera connection, keeping the overlay and its credentials.
|
||||
// This also renews healthy connections; it does not measure frame freshness.
|
||||
const STREAM_RENEWAL_MS = 60_000;
|
||||
const ERROR_RETRY_MS = 3_000;
|
||||
|
||||
export function useOverlayCameraRecovery(enabled: boolean) {
|
||||
const [imageKey, setImageKey] = useState(Date.now);
|
||||
const retryTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const renew = useCallback(() => {
|
||||
if (retryTimer.current !== null) {
|
||||
clearTimeout(retryTimer.current);
|
||||
retryTimer.current = null;
|
||||
}
|
||||
setImageKey((key) => key + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Start a fresh deadline after each retry so a newly opened connection
|
||||
// isn't immediately replaced by the previous connection's renewal timer.
|
||||
const renewalTimer = setTimeout(renew, STREAM_RENEWAL_MS);
|
||||
return () => {
|
||||
clearTimeout(renewalTimer);
|
||||
if (retryTimer.current !== null) {
|
||||
clearTimeout(retryTimer.current);
|
||||
retryTimer.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, imageKey, renew]);
|
||||
|
||||
const handleStreamError = useCallback(() => {
|
||||
if (!enabled || retryTimer.current !== null) return;
|
||||
retryTimer.current = setTimeout(renew, ERROR_RETRY_MS);
|
||||
}, [enabled, renew]);
|
||||
|
||||
return { imageKey, handleStreamError };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -7,6 +7,7 @@ import { UpdatedStreamOverlay } from '../components/UpdatedStreamOverlay';
|
||||
import { api, ApiError, withStreamToken } from '../api/client';
|
||||
import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
|
||||
import { mapModelCode } from '../utils/printerModel';
|
||||
import { useOverlayCameraRecovery } from '../hooks/useOverlayCameraRecovery';
|
||||
|
||||
type TFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
@@ -168,7 +169,6 @@ export function StreamOverlayPage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const id = parseInt(printerId || '0', 10);
|
||||
const [imageKey, setImageKey] = useState(Date.now());
|
||||
|
||||
const config = useMemo(() => parseConfig(searchParams), [searchParams]);
|
||||
const sizes = getSizeClasses(config.size);
|
||||
@@ -224,6 +224,7 @@ export function StreamOverlayPage() {
|
||||
config.showModel ? mapModelCode(printer?.model ?? null) : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
const status = kiosk ? overlay : statusData;
|
||||
const { imageKey, handleStreamError } = useOverlayCameraRecovery(id > 0 && config.showCamera && status != null);
|
||||
const timeFormat: TimeFormat = (kiosk ? overlay?.time_format : settings?.time_format) || 'system';
|
||||
|
||||
// WebSocket for real-time updates (JWT-authenticated; skipped in kiosk mode,
|
||||
@@ -292,13 +293,6 @@ export function StreamOverlayPage() {
|
||||
};
|
||||
}, [printer, t]);
|
||||
|
||||
// Refresh stream on error
|
||||
const handleStreamError = () => {
|
||||
setTimeout(() => {
|
||||
setImageKey(Date.now());
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||
|
||||
Reference in New Issue
Block a user