Files
continuum-app/apps/web/composables/usePrinterStream.ts
T
octoturge 6b1c7c6db3 Show P1/A1 cameras as a refreshing picture
SnapshotView fetches one frame at a time through the backend, pauses
while the tab is hidden and backs off after failures. PrinterCard picks
it when /stream answers kind "snapshot".
2026-10-29 16:11:10 +00:00

79 lines
3.4 KiB
TypeScript

/**
* Where a printer's camera can be watched, from `GET /printers/:id/stream`.
*/
export type PrinterStream =
| WebrtcStream
// A P1/A1: no stream to negotiate, a picture to fetch again and again.
| { kind: "snapshot"; snapshotPath: string; intervalMs: number };
export interface WebrtcStream {
kind?: "webrtc";
/** The go2rtc stream name to negotiate for. */
cameraId: string;
/**
* ICE servers for the player's `RTCPeerConnection`, minted per request and
* short-lived. Empty when the backend has no TURN configured.
*/
iceServers: RTCIceServer[];
/**
* Whether TURN is configured at all, which is the difference between "no
* relay is needed here" and "the relay is missing". Empty `iceServers`
* with this true would mean minting silently failed.
*/
turnConfigured: boolean;
}
/**
* Asks the backend where a printer's camera can be watched, or `null` if it
* cannot.
*
* It returns no URL, and that is the point. A browser cannot reach a gateway
* directly: go2rtc sends no CORS headers and checks its API password before
* CORS, so an unauthenticated preflight gets a 401 - measured against
* 1.9.14 - and a gateway sits behind a workshop NAT anyway. This used to
* return the gateway's LAN address, which could only have worked for an
* operator standing on that same network.
*
* So what comes back is a camera name and the ICE servers to negotiate with.
* The offer goes to the backend, which relays it to the gateway over the
* socket the gateway already holds open; the media then travels
* browser-to-gateway through the TURN relay, DTLS-SRTP encrypted, so neither
* the backend nor the relay can read it.
*
* A plain function and not a `useAsyncData` wrapper despite the file it lives
* in: the caller needs one of these per printer, discovered from a list that
* itself arrives asynchronously, and Nuxt's data composables may only be
* called while a component is setting up. A loop over a resolved list is not
* that. It takes the fetcher as an argument for the same reason - the caller
* builds it in setup, where `useApiFetch` is valid.
*
* **Absence is the normal case, not an error.** Only Bambu's X1/H2 class
* publishes a camera URL a gateway can restream; a P1 or A1 reports that it
* has a camera and gives nothing to connect to. The backend answers 404 for
* those, so this answers `null` and the caller shows no player at all -
* rendering one that can never load would be worse than rendering nothing.
*
* The credentials are deliberately not cached: they are short-lived, so a
* player that reconnects should ask again rather than retry a session with a
* credential that has since expired.
*/
export async function fetchPrinterStream(
apiFetch: ReturnType<typeof useApiFetch>,
printerId: string,
): Promise<PrinterStream | null> {
try {
return await apiFetch<PrinterStream>(`/printers/${printerId}/stream`);
} catch (err: unknown) {
// A 404 means "this printer has no watchable camera", which is expected
// often enough that treating it as a failure would put an error state on
// most cards in a mixed fleet. Anything else is a real problem: logged
// and then treated as "no stream", because a broken camera lookup should
// not take the fleet overview down with it.
const status = (err as { statusCode?: number }).statusCode;
if (status !== 404) {
console.warn(`could not look up the camera stream for printer ${printerId}`, err);
}
return null;
}
}