diff --git a/apps/web/composables/usePrinterStream.ts b/apps/web/composables/usePrinterStream.ts
index 5447f24..cd4c0d6 100644
--- a/apps/web/composables/usePrinterStream.ts
+++ b/apps/web/composables/usePrinterStream.ts
@@ -1,7 +1,13 @@
/**
* Where a printer's camera can be watched, from `GET /printers/:id/stream`.
*/
-export interface PrinterStream {
+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;
/**
diff --git a/apps/web/pages/index.vue b/apps/web/pages/index.vue
index 18c4bfc..9525e34 100644
--- a/apps/web/pages/index.vue
+++ b/apps/web/pages/index.vue
@@ -56,6 +56,11 @@ async function signalOffer(printerId: string, sdp: string): Promise {
return answer.sdp;
}
+/** One camera frame (P1/A1) as an image, through the backend. */
+function fetchFrame(printerId: string): Promise {
+ return apiFetch(`/printers/${printerId}/snapshot`, { responseType: "blob" });
+}
+
// Which farms exist, and for each whether its gateway is connected and what
// discovery has found. Polled, because gateway presence has no push channel;
// ten seconds is quick enough to notice an unplugged box without hammering.
@@ -119,7 +124,7 @@ watch(
printers,
(list) => {
for (const printer of list ?? []) {
- if (!printer.cameraStream || printer.id in streams.value) continue;
+ if (!(printer.cameraStream || printer.cameraSnapshot) || 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
@@ -149,6 +154,7 @@ watch(
:printer="current(printer)"
:stream="streams[printer.id]"
:signal="(sdp: string) => signalOffer(printer.id, sdp)"
+ :fetch-frame="() => fetchFrame(printer.id)"
:send="(command: PrinterCommand) => sendCommand(printer.id, command)"
/>
diff --git a/packages/ui/src/components/PrinterCard.vue b/packages/ui/src/components/PrinterCard.vue
index 5dbde63..47fec76 100644
--- a/packages/ui/src/components/PrinterCard.vue
+++ b/packages/ui/src/components/PrinterCard.vue
@@ -3,6 +3,7 @@ import { computed } from "vue";
import type { AppPrinterState, PrinterCommand } from "../types";
import PrinterControls from "./PrinterControls.vue";
import TemperatureGraph from "./TemperatureGraph.vue";
+import SnapshotView from "./SnapshotView.vue";
import WebRTCStreamView from "./WebRTCStreamView.vue";
/**
@@ -17,11 +18,10 @@ import WebRTCStreamView from "./WebRTCStreamView.vue";
* Undefined for most printers and that is expected - see
* `usePrinterStream`. The player is simply not rendered.
*/
-interface PrinterStream {
- cameraId: string;
- iceServers: RTCIceServer[];
- turnConfigured: boolean;
-}
+type PrinterStream =
+ | { kind?: "webrtc"; cameraId: string; iceServers: RTCIceServer[]; turnConfigured: boolean }
+ // A P1/A1: no stream to negotiate, a picture to fetch again and again.
+ | { kind: "snapshot"; intervalMs?: number };
const props = defineProps<{
/**
@@ -49,6 +49,8 @@ const props = defineProps<{
* read-only embedding of the card cannot offer buttons that do nothing.
*/
send?: (command: PrinterCommand) => Promise;
+ /** Fetches one camera frame, for a `kind: "snapshot"` stream. */
+ fetchFrame?: () => Promise;
}>();
// `status` is open-ended and null for a printer that has never reported.
@@ -88,8 +90,13 @@ const lastSeen = computed(() => {
{{ lastSeen ? `Last heard from ${lastSeen}` : "Never reported" }}
+
+import { onBeforeUnmount, onMounted, ref } from "vue";
+
+const props = defineProps<{
+ /**
+ * Fetches the next frame as an image. Supplied by the app, which knows the
+ * API; this package does not.
+ */
+ fetchFrame: () => Promise;
+ /** Pause between the end of one fetch and the start of the next. */
+ intervalMs?: number;
+}>();
+
+const src = ref(null);
+const status = ref<"connecting" | "connected" | "failed">("connecting");
+const error = ref(null);
+
+let stopped = false;
+let timer: ReturnType | undefined;
+
+/**
+ * One request at a time, the next only after the last has finished: a slow
+ * printer then slows the picture down instead of piling requests onto a
+ * camera that serves one client. Skipped while the tab is hidden, and
+ * backed off after failures, because every attempt logs in to the printer.
+ */
+let failures = 0;
+async function tick() {
+ if (stopped) return;
+ let delay = props.intervalMs ?? 1000;
+ if (document.hidden) {
+ timer = setTimeout(tick, delay);
+ return;
+ }
+ try {
+ const blob = await props.fetchFrame();
+ if (stopped) return;
+ const next = URL.createObjectURL(blob);
+ if (src.value) URL.revokeObjectURL(src.value);
+ src.value = next;
+ status.value = "connected";
+ error.value = null;
+ failures = 0;
+ } catch (err) {
+ failures += 1;
+ status.value = "failed";
+ error.value = err instanceof Error ? err.message : String(err);
+ delay = Math.min(30_000, delay * 2 ** Math.min(failures, 5));
+ }
+ if (!stopped) timer = setTimeout(tick, delay);
+}
+
+onMounted(tick);
+onBeforeUnmount(() => {
+ stopped = true;
+ clearTimeout(timer);
+ if (src.value) URL.revokeObjectURL(src.value);
+});
+
+
+
+
+
![Printer camera]()
+
{{ status === "failed" ? "No picture" : "Connecting…" }}
+
+ {{ status }}
+
+
+
+
+
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 4c0b069..9711b38 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -2,6 +2,7 @@ export { default as PrinterCard } from "./components/PrinterCard.vue";
export { default as PrinterControls } from "./components/PrinterControls.vue";
export { default as TemperatureGraph } from "./components/TemperatureGraph.vue";
export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue";
+export { default as SnapshotView } from "./components/SnapshotView.vue";
export { default as JobList } from "./components/JobList.vue";
export { default as JobUpload } from "./components/JobUpload.vue";
export { default as AlertList } from "./components/AlertList.vue";
diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts
index 1b51692..7936ee4 100644
--- a/packages/ui/src/types.ts
+++ b/packages/ui/src/types.ts
@@ -40,6 +40,8 @@ export interface AppPrinterState {
activeAlerts?: number | null;
/** go2rtc stream name; null/absent means render no player. */
cameraStream?: string | null;
+ /** True for a P1/A1: the camera is a refreshing picture, not a stream. */
+ cameraSnapshot?: boolean;
/** When a gateway last reported this printer; null means never. */
lastSeenAt?: string | null;
}