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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,13 @@ export default defineNuxtConfig({
|
|||||||
public: {
|
public: {
|
||||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || "http://localhost:3001",
|
apiBase: process.env.NUXT_PUBLIC_API_BASE || "http://localhost:3001",
|
||||||
wsBase: process.env.NUXT_PUBLIC_WS_BASE || "ws://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`.
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ interface Printer {
|
|||||||
name: string;
|
name: string;
|
||||||
model: string;
|
model: string;
|
||||||
farmId: 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();
|
const telemetry = useTelemetryStore();
|
||||||
@@ -15,6 +21,33 @@ const config = useRuntimeConfig();
|
|||||||
const { data: printers } = await useFetch<Printer[]>(`${config.public.apiBase}/printers`, {
|
const { data: printers } = await useFetch<Printer[]>(`${config.public.apiBase}/printers`, {
|
||||||
headers: useRequestHeaders(["cookie"]),
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -26,6 +59,7 @@ const { data: printers } = await useFetch<Printer[]>(`${config.public.apiBase}/p
|
|||||||
:key="printer.id"
|
:key="printer.id"
|
||||||
:printer="printer"
|
:printer="printer"
|
||||||
:live="telemetry.printers[printer.id]"
|
:live="telemetry.printers[printer.id]"
|
||||||
|
:stream="streams[printer.id]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!printers?.length">No printers registered yet.</p>
|
<p v-if="!printers?.length">No printers registered yet.</p>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import TemperatureGraph from "./TemperatureGraph.vue";
|
import TemperatureGraph from "./TemperatureGraph.vue";
|
||||||
|
import WebRTCStreamView from "./WebRTCStreamView.vue";
|
||||||
|
|
||||||
interface Printer {
|
interface Printer {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,9 +17,27 @@ interface LiveTelemetry {
|
|||||||
progressPct: number;
|
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<{
|
const props = defineProps<{
|
||||||
printer: Printer;
|
printer: Printer;
|
||||||
live?: LiveTelemetry;
|
live?: LiveTelemetry;
|
||||||
|
stream?: PrinterStream | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const state = computed(() => props.live?.state ?? "offline");
|
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>
|
<p class="printer-card__state">{{ state }}</p>
|
||||||
|
|
||||||
|
<WebRTCStreamView
|
||||||
|
v-if="stream"
|
||||||
|
:stream-base-url="stream.streamUrl"
|
||||||
|
:camera-id="stream.cameraId"
|
||||||
|
/>
|
||||||
|
|
||||||
<TemperatureGraph
|
<TemperatureGraph
|
||||||
v-if="live"
|
v-if="live"
|
||||||
:nozzle-temp-c="live.nozzleTempC"
|
:nozzle-temp-c="live.nozzleTempC"
|
||||||
|
|||||||
@@ -9,9 +9,20 @@ const props = defineProps<{
|
|||||||
const videoEl = ref<HTMLVideoElement | null>(null);
|
const videoEl = ref<HTMLVideoElement | null>(null);
|
||||||
const pc = ref<RTCPeerConnection | null>(null);
|
const pc = ref<RTCPeerConnection | null>(null);
|
||||||
const status = ref<"connecting" | "connected" | "failed">("connecting");
|
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() {
|
async function connect() {
|
||||||
status.value = "connecting";
|
status.value = "connecting";
|
||||||
|
error.value = null;
|
||||||
pc.value?.close();
|
pc.value?.close();
|
||||||
|
|
||||||
const connection = new RTCPeerConnection();
|
const connection = new RTCPeerConnection();
|
||||||
@@ -36,11 +47,40 @@ async function connect() {
|
|||||||
body: offer.sdp,
|
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();
|
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 });
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
pc.value?.close();
|
pc.value?.close();
|
||||||
@@ -51,7 +91,9 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="webrtc-view">
|
<div class="webrtc-view">
|
||||||
<video ref="videoEl" autoplay playsinline muted />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user