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
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from "vue";
import TemperatureGraph from "./TemperatureGraph.vue";
import WebRTCStreamView from "./WebRTCStreamView.vue";
interface Printer {
id: string;
@@ -16,9 +17,27 @@ interface LiveTelemetry {
progressPct: number;
}
/**
* Where this printer's camera can be watched, from
* `GET /printers/:id/stream`.
*
* Passed in rather than fetched here, matching `live` above: this package is
* plain Vue with no knowledge of the API or of Nuxt, and keeping the fetching
* in the app is what lets these components be rendered from a test or the
* desktop shell without a server behind them.
*
* Undefined for most printers and that is expected - see
* `usePrinterStream`. The player is simply not rendered.
*/
interface PrinterStream {
streamUrl: string;
cameraId: string;
}
const props = defineProps<{
printer: Printer;
live?: LiveTelemetry;
stream?: PrinterStream | null;
}>();
const state = computed(() => props.live?.state ?? "offline");
@@ -33,6 +52,12 @@ const state = computed(() => props.live?.state ?? "offline");
<p class="printer-card__state">{{ state }}</p>
<WebRTCStreamView
v-if="stream"
:stream-base-url="stream.streamUrl"
:camera-id="stream.cameraId"
/>
<TemperatureGraph
v-if="live"
:nozzle-temp-c="live.nozzleTempC"
@@ -9,9 +9,20 @@ const props = defineProps<{
const videoEl = ref<HTMLVideoElement | null>(null);
const pc = ref<RTCPeerConnection | null>(null);
const status = ref<"connecting" | "connected" | "failed">("connecting");
const error = ref<string | null>(null);
/**
* Opens a WebRTC session against the gateway's go2rtc.
*
* Note what is deliberately absent: no `iceServers`. Both ends are on the
* same LAN - the gateway is a box in the workshop - so host candidates are
* enough and a STUN round-trip would only add latency. The same fact is why
* this component cannot work from outside that network at all; there is no
* relay, and the backend's /printers/:id/stream says so too.
*/
async function connect() {
status.value = "connecting";
error.value = null;
pc.value?.close();
const connection = new RTCPeerConnection();
@@ -36,11 +47,40 @@ async function connect() {
body: offer.sdp,
});
// Checked before the body is used. go2rtc answers a request for a stream
// it does not have with an error page, and handing that to
// setRemoteDescription throws something about SDP parsing - which sends
// whoever is debugging it looking at the wrong layer, when the real
// answer is that this printer is not in go2rtc's config.
if (!response.ok) {
throw new Error(`the gateway refused the stream request (HTTP ${response.status})`);
}
const answerSdp = await response.text();
// The props can change again while the request above is in flight, which
// starts a second connect(). Without this check the slower of the two
// finishes last and applies its answer to a connection that has been
// replaced and closed, leaving a black video element and a "connected"
// badge.
if (pc.value !== connection) return;
await connection.setRemoteDescription({ type: "answer", sdp: answerSdp });
}
watch(() => [props.streamBaseUrl, props.cameraId], connect, { immediate: true });
// `connect` is async inside a watcher, so nothing would otherwise catch a
// rejection: the status would sit on "connecting" forever and the only sign
// of the failure would be an unhandled rejection in the console.
watch(
() => [props.streamBaseUrl, props.cameraId],
() => {
connect().catch((err: unknown) => {
status.value = "failed";
error.value = err instanceof Error ? err.message : String(err);
});
},
{ immediate: true },
);
onBeforeUnmount(() => {
pc.value?.close();
@@ -51,7 +91,9 @@ onBeforeUnmount(() => {
<template>
<div class="webrtc-view">
<video ref="videoEl" autoplay playsinline muted />
<span class="webrtc-view__status" :class="`is-${status}`">{{ status }}</span>
<span class="webrtc-view__status" :class="`is-${status}`" :title="error ?? undefined">
{{ status }}
</span>
</div>
</template>