/** * 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, printerId: string, ): Promise { try { return await apiFetch(`/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; } }