From e928fcf658935c540b9db0953020b3d39a2fd9b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Thu, 15 Oct 2026 23:09:54 +0000 Subject: [PATCH] Show a printer's camera, from the backend's address not a build-time one `WebRTCStreamView` has existed since before this commit with no consumer: nothing rendered it, and the URL it would have used came from a `go2rtcStreamBase` in `nuxt.config.ts` defaulting to `http://localhost:1984`. That default was the real problem. A camera stream lives on a gateway box inside one farm's network, each farm has its own, and the gateway now reports its address to the backend when it connects. A compiled-in constant could only ever be correct for a single-farm install, and its localhost default pointed every player at the operator's own laptop. It is gone, replaced by `fetchPrinterStream`, which asks `GET /printers/:id/stream`. Absence is the normal case here, not an error: only Bambu's X1/H2 class publishes a camera URL a gateway can restream, so a P1 or A1 has a camera and nothing to connect to. The backend 404s for those, this resolves to null, and the card renders no player - offering one that can never load would be worse than offering nothing. The lookup also runs only for printers whose `cameraStream` the fleet list already reports, so a twenty-printer farm does not make twenty requests to learn that eighteen of them 404. `PrinterCard` takes the stream as a prop rather than fetching it, like `live` above it: `packages/ui` is plain Vue that knows nothing about the API or about Nuxt, and that is what lets these components render from a test or the desktop shell with no server behind them. Three things in `WebRTCStreamView` that would have bitten whoever first pointed it at a real printer: - The fetch status was never checked. go2rtc answers a request for a stream it does not have with an error page, and handing that to `setRemoteDescription` throws about SDP parsing - which sends you looking at the wrong layer entirely. - `connect()` is async inside a watcher with nothing catching it, so a failure left the badge on "connecting" forever and showed up only as an unhandled rejection in the console. - Two prop changes in quick succession raced: the slower request could finish last and apply its answer to a connection already replaced and closed, leaving a black video with a "connected" badge. No `iceServers`, deliberately and now commented: both ends are on the same LAN, so host candidates are enough. The same fact is the MVP's limitation - there is no relay, so this plays for someone on the printers' network and for nobody else. `nuxt typecheck` and `vue-tsc --noEmit` both clean. --- apps/web/composables/usePrinterStream.ts | 56 +++++++++++++++++++ apps/web/nuxt.config.ts | 8 ++- apps/web/pages/index.vue | 34 +++++++++++ packages/ui/src/components/PrinterCard.vue | 25 +++++++++ .../ui/src/components/WebRTCStreamView.vue | 46 ++++++++++++++- 5 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 apps/web/composables/usePrinterStream.ts diff --git a/apps/web/composables/usePrinterStream.ts b/apps/web/composables/usePrinterStream.ts new file mode 100644 index 0000000..50f64ad --- /dev/null +++ b/apps/web/composables/usePrinterStream.ts @@ -0,0 +1,56 @@ +/** + * Where a printer's camera can be watched, from `GET /printers/:id/stream`. + */ +export interface PrinterStream { + streamUrl: string; + cameraId: string; +} + +/** + * Asks the backend where a printer's camera can be watched, or `null` if it + * cannot. + * + * This replaces the build-time `go2rtcStreamBase` that used to live in + * `nuxt.config.ts`. A single compiled-in URL can only ever be correct for a + * one-farm deployment: the stream lives on a gateway box inside a farm's own + * network, each farm has its own, and the gateway reports its address to the + * backend when it connects. So the address has to be asked for at runtime, + * per printer, rather than baked in. + * + * 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. + * + * **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. + * + * It is also a LAN address with no relay in front of it, so the stream plays + * only for someone on the same network as the printers. That is the right + * trade-off for an operator standing in the workshop, and a real limitation + * for anyone else. + */ +export async function fetchPrinterStream( + apiBase: string, + printerId: string, + headers: Record, +): Promise { + try { + return await $fetch(`${apiBase}/printers/${printerId}/stream`, { headers }); + } 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; + } +} diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index 51dbf33..b30622b 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -23,7 +23,13 @@ export default defineNuxtConfig({ public: { apiBase: process.env.NUXT_PUBLIC_API_BASE || "http://localhost:3001", wsBase: process.env.NUXT_PUBLIC_WS_BASE || "ws://localhost:3001", - go2rtcStreamBase: process.env.NUXT_PUBLIC_GO2RTC_STREAM_BASE || "http://localhost:1984", + // There is deliberately no go2rtcStreamBase here any more. A camera + // stream lives on a gateway inside a particular farm's network, each + // farm has its own, and the gateway tells the backend its address when + // it connects - so a build-time constant could only ever be right for a + // single-farm install, and its localhost:1984 default meant the UI + // quietly pointed every player at the operator's own machine. Asked for + // per printer instead: see `usePrinterStream`. }, }, diff --git a/apps/web/pages/index.vue b/apps/web/pages/index.vue index 88e8abb..3367ccc 100644 --- a/apps/web/pages/index.vue +++ b/apps/web/pages/index.vue @@ -7,6 +7,12 @@ interface Printer { name: string; model: string; farmId: string; + /** + * Set only for a printer whose camera the gateway can actually restream, + * which in a mixed fleet is the minority. Its presence is what decides + * whether to ask for a stream URL at all - see the lookup below. + */ + cameraStream?: string | null; } const telemetry = useTelemetryStore(); @@ -15,6 +21,33 @@ const config = useRuntimeConfig(); const { data: printers } = await useFetch(`${config.public.apiBase}/printers`, { headers: useRequestHeaders(["cookie"]), }); + +// Looked up per printer, because the answer is per printer, but only for the +// printers the list already says have a camera. Asking for all of them would +// mean one request per machine in the fleet, most of them 404 by design. +const streams = ref>({}); + +// Read here, in setup, rather than inside the loop below: Nuxt's request +// helpers are only valid while a component is setting up. +const streamHeaders = useRequestHeaders(["cookie"]) as Record; + +watch( + printers, + (list) => { + for (const printer of list ?? []) { + if (!printer.cameraStream || printer.id in streams.value) continue; + // Marked before the request resolves, so a re-render in the meantime + // does not fire a second lookup for the same printer. `null` renders + // no player, which is also the right thing to show while this is in + // flight. + streams.value[printer.id] = null; + void fetchPrinterStream(config.public.apiBase, printer.id, streamHeaders).then((stream) => { + streams.value[printer.id] = stream; + }); + } + }, + { immediate: true }, +);