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.
This commit is contained in:
2026-10-15 23:09:54 +00:00
parent 6d6ed62836
commit e928fcf658
5 changed files with 166 additions and 3 deletions
+56
View File
@@ -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<string, string>,
): Promise<PrinterStream | null> {
try {
return await $fetch<PrinterStream>(`${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;
}
}
+7 -1
View File
@@ -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`.
},
},
+34
View File
@@ -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<Printer[]>(`${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<Record<string, PrinterStream | null>>({});
// 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<string, string>;
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 },
);
</script>
<template>
@@ -26,6 +59,7 @@ const { data: printers } = await useFetch<Printer[]>(`${config.public.apiBase}/p
:key="printer.id"
:printer="printer"
:live="telemetry.printers[printer.id]"
:stream="streams[printer.id]"
/>
</div>
<p v-if="!printers?.length">No printers registered yet.</p>