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".
79 lines
3.4 KiB
TypeScript
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;
|
|
}
|
|
}
|