From 27291fd3a12462730c02adf1e3c533f6ce59cc8a Mon Sep 17 00:00:00 2001
From: stefanodangelo <61756862+stefanodangelo@users.noreply.github.com>
Date: Wed, 21 Oct 2026 18:32:29 +0200
Subject: [PATCH 01/16] feat: add the webcam proxy endpoints (info, stream,
snapshot)
---
multiace/web/backend/main.py | 127 ++++++++++++++++++++++++++
tests/fixtures/mock_webcam.json | 5 +
tests/unit/test_webcam.py | 157 ++++++++++++++++++++++++++++++++
3 files changed, 289 insertions(+)
create mode 100644 tests/fixtures/mock_webcam.json
create mode 100644 tests/unit/test_webcam.py
diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py
index 67ba6f66..14d8e823 100644
--- a/multiace/web/backend/main.py
+++ b/multiace/web/backend/main.py
@@ -2143,6 +2143,133 @@ async def fluidd_camera() -> dict:
raise HTTPException(status_code=502,
detail=f"moonraker: {e}")
+WEBCAM_BASE = os.environ.get("MULTIACE_WEBCAM_BASE", "")
+
+def _webcam_base() -> str:
+ """Where a relative camera URL ('/webcam/?action=stream') points.
+
+ Moonraker stores those relative to the printer's own web root, not to
+ Moonraker's port, so strip the port off MOONRAKER_URL rather than
+ reusing it whole."""
+ if WEBCAM_BASE:
+ return WEBCAM_BASE.rstrip("/")
+ m = re.match(r"^(https?)://([^/:]+)", MOONRAKER_URL)
+ if not m:
+ return "http://127.0.0.1"
+ return f"{m.group(1)}://{m.group(2)}"
+
+def _abs_webcam_url(url: str) -> str:
+ if not url:
+ return ""
+ if url.startswith(("http://", "https://")):
+ return url
+ return f"{_webcam_base()}/{url.lstrip('/')}"
+
+async def _resolve_webcam() -> dict:
+ """First real camera Moonraker knows about.
+
+ Skips our own Fluidd panel entry: it is an iframe pointing back at
+ this UI, and embedding it in the UI's own page would be a mirror
+ tunnel rather than a webcam.
+ """
+ try:
+ listing = await _mr_get("/server/webcams/list")
+ except Exception as e:
+ return {"available": False, "reason": f"moonraker: {e}"}
+ cams = (listing.get("result") or {}).get("webcams") or []
+ for cam in cams:
+ name = str(cam.get("name", ""))
+ if name.strip().lower() == FLUIDD_CAMERA_NAME.lower():
+ continue
+ if str(cam.get("service", "")).lower() == "iframe":
+ continue
+ if cam.get("enabled") is False:
+ continue
+ stream = str(cam.get("stream_url") or "")
+ if not stream:
+ continue
+ return {
+ "available": True,
+ "name": name,
+ "service": str(cam.get("service") or "mjpegstreamer"),
+ "stream_url": _abs_webcam_url(stream),
+ "snapshot_url": _abs_webcam_url(str(cam.get("snapshot_url") or "")),
+ "aspect_ratio": cam.get("aspect_ratio") or "16:9",
+ }
+ return {"available": False,
+ "reason": "no camera configured in Moonraker"}
+
+@app.get("/api/webcam/info")
+async def webcam_info(request: Request) -> dict:
+ if _mock_enabled(request):
+ return {**(_mock_load("mock_webcam.json") or {"available": False,
+ "reason": "no fixture"}),
+ "mock": True}
+ info = await _resolve_webcam()
+ if info.get("available"):
+ # The browser always goes through our proxy: the camera may live
+ # on a host/port the UI's origin cannot reach, and https pages
+ # refuse plain-http streams. The path is relative to the client's
+ # own API base (the UI is mounted at /multiace behind nginx but
+ # at / on the dev server).
+ info["proxy_path"] = "/api/webcam/stream"
+ info.pop("stream_url", None)
+ info.pop("snapshot_url", None)
+ return info
+
+@app.get("/api/webcam/stream")
+async def webcam_stream(request: Request):
+ """Pipe the camera's MJPEG stream through this service."""
+ from fastapi.responses import StreamingResponse
+
+ if _mock_enabled(request):
+ raise HTTPException(503, "webcam stream unavailable in mock mode")
+ info = await _resolve_webcam()
+ if not info.get("available"):
+ raise HTTPException(503, info.get("reason") or "no webcam")
+
+ client = httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=None))
+ try:
+ upstream = await client.send(
+ client.build_request("GET", info["stream_url"]), stream=True)
+ upstream.raise_for_status()
+ except httpx.HTTPError as e:
+ await client.aclose()
+ raise HTTPException(502, f"webcam: {e}")
+
+ async def _pump():
+ try:
+ async for chunk in upstream.aiter_raw():
+ yield chunk
+ except httpx.HTTPError:
+ return
+ finally:
+ await upstream.aclose()
+ await client.aclose()
+
+ ctype = upstream.headers.get(
+ "content-type", "multipart/x-mixed-replace; boundary=boundarydonotcross")
+ return StreamingResponse(_pump(), media_type=ctype,
+ headers={"Cache-Control": "no-store"})
+
+@app.get("/api/webcam/snapshot")
+async def webcam_snapshot(request: Request):
+ if _mock_enabled(request):
+ raise HTTPException(503, "webcam snapshot unavailable in mock mode")
+ info = await _resolve_webcam()
+ url = info.get("snapshot_url") or ""
+ if not info.get("available") or not url:
+ raise HTTPException(503, info.get("reason") or "no webcam snapshot")
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ r = await client.get(url)
+ r.raise_for_status()
+ except httpx.HTTPError as e:
+ raise HTTPException(502, f"webcam: {e}")
+ return Response(content=r.content,
+ media_type=r.headers.get("content-type", "image/jpeg"),
+ headers={"Cache-Control": "no-store"})
+
@app.post("/api/upload-and-print")
async def upload_and_print(file: UploadFile = File(...)) -> dict:
diff --git a/tests/fixtures/mock_webcam.json b/tests/fixtures/mock_webcam.json
new file mode 100644
index 00000000..8010a8bf
--- /dev/null
+++ b/tests/fixtures/mock_webcam.json
@@ -0,0 +1,5 @@
+{
+ "_comment": "Mock /api/webcam/info payload. available:false exercises the 'Camera unavailable' state, which is the one a dev box without crowsnest will really see.",
+ "available": false,
+ "reason": "mock mode: no camera stream to serve"
+}
diff --git a/tests/unit/test_webcam.py b/tests/unit/test_webcam.py
new file mode 100644
index 00000000..a2869e89
--- /dev/null
+++ b/tests/unit/test_webcam.py
@@ -0,0 +1,157 @@
+"""Webcam proxy: camera lookup through Moonraker, MJPEG stream and snapshot.
+
+Moonraker is replaced by a stub for `_mr_get`; the camera itself by an
+httpx MockTransport, so nothing here touches the network.
+"""
+import asyncio
+
+import httpx
+import pytest
+
+from .test_mock_mode import _load_main, mock_client, live_client # noqa: F401
+
+
+def _cam(**kw):
+ cam = {"name": "Printer", "service": "mjpegstreamer", "enabled": True,
+ "stream_url": "/webcam/?action=stream",
+ "snapshot_url": "/webcam/?action=snapshot", "aspect_ratio": "4:3"}
+ cam.update(kw)
+ return cam
+
+
+@pytest.fixture
+def main(monkeypatch, tmp_path):
+ return _load_main(monkeypatch, tmp_path, mock=False)
+
+
+def _listing(main, monkeypatch, cams):
+ async def fake(path):
+ assert path == "/server/webcams/list"
+ return {"result": {"webcams": cams}}
+ monkeypatch.setattr(main, "_mr_get", fake)
+
+
+class TestResolve:
+ def _resolve(self, main, monkeypatch, cams):
+ _listing(main, monkeypatch, cams)
+ return asyncio.run(main._resolve_webcam())
+
+ def test_relative_urls_use_the_printer_host_without_the_moonraker_port(
+ self, main, monkeypatch):
+ monkeypatch.setattr(main, "MOONRAKER_URL", "http://10.0.0.5:7125")
+ monkeypatch.setattr(main, "WEBCAM_BASE", "")
+ info = self._resolve(main, monkeypatch, [_cam()])
+ assert info["stream_url"] == "http://10.0.0.5/webcam/?action=stream"
+ assert info["snapshot_url"] == "http://10.0.0.5/webcam/?action=snapshot"
+
+ def test_webcam_base_override_wins(self, main, monkeypatch):
+ monkeypatch.setattr(main, "WEBCAM_BASE", "http://cam.local:8080/")
+ info = self._resolve(main, monkeypatch, [_cam()])
+ assert info["stream_url"] == "http://cam.local:8080/webcam/?action=stream"
+
+ def test_skips_own_panel_iframes_disabled_and_streamless(
+ self, main, monkeypatch):
+ cams = [_cam(name="multiACE", service="iframe"),
+ _cam(name="Frame", service="iframe"),
+ _cam(name="Off", enabled=False),
+ _cam(name="Empty", stream_url=""),
+ _cam(name="Real")]
+ info = self._resolve(main, monkeypatch, cams)
+ assert info["name"] == "Real"
+
+ def test_no_camera(self, main, monkeypatch):
+ info = self._resolve(main, monkeypatch, [])
+ assert info["available"] is False
+
+ def test_moonraker_down_is_reported_not_raised(self, main, monkeypatch):
+ async def boom(path):
+ raise httpx.ConnectError("down")
+ monkeypatch.setattr(main, "_mr_get", boom)
+ info = asyncio.run(main._resolve_webcam())
+ assert info["available"] is False and "moonraker" in info["reason"]
+
+
+class TestEndpoints:
+ def _client(self, main):
+ from fastapi.testclient import TestClient
+ return TestClient(main.app)
+
+ def _camera_transport(self, main, monkeypatch):
+ def handler(req):
+ if "snapshot" in req.url.query.decode():
+ return httpx.Response(200, content=b"JPEG",
+ headers={"content-type": "image/jpeg"})
+ return httpx.Response(
+ 200, stream=httpx.ByteStream(b"--frame\r\nJPEG\r\n"),
+ headers={"content-type":
+ "multipart/x-mixed-replace; boundary=frame"})
+ real = httpx.AsyncClient
+ monkeypatch.setattr(
+ main.httpx, "AsyncClient",
+ lambda **kw: real(transport=httpx.MockTransport(handler), **kw))
+
+ def test_info_hides_the_camera_address(self, main, monkeypatch):
+ _listing(main, monkeypatch, [_cam()])
+ with self._client(main) as c:
+ j = c.get("/api/webcam/info").json()
+ assert j["available"] is True
+ assert j["proxy_path"] == "/api/webcam/stream"
+ assert "stream_url" not in j and "snapshot_url" not in j
+
+ def test_info_when_unavailable_has_no_proxy_path(self, main, monkeypatch):
+ _listing(main, monkeypatch, [])
+ with self._client(main) as c:
+ j = c.get("/api/webcam/info").json()
+ assert j["available"] is False and "proxy_path" not in j
+
+ def test_stream_is_piped_with_the_cameras_content_type(
+ self, main, monkeypatch):
+ _listing(main, monkeypatch, [_cam()])
+ self._camera_transport(main, monkeypatch)
+ with self._client(main) as c:
+ r = c.get("/api/webcam/stream")
+ assert r.status_code == 200
+ assert r.headers["content-type"].startswith("multipart/x-mixed-replace")
+ assert r.headers["cache-control"] == "no-store"
+ assert b"JPEG" in r.content
+
+ def test_stream_without_camera_is_503(self, main, monkeypatch):
+ _listing(main, monkeypatch, [])
+ with self._client(main) as c:
+ assert c.get("/api/webcam/stream").status_code == 503
+
+ def test_stream_camera_error_is_502(self, main, monkeypatch):
+ _listing(main, monkeypatch, [_cam()])
+ real = httpx.AsyncClient
+ monkeypatch.setattr(
+ main.httpx, "AsyncClient",
+ lambda **kw: real(transport=httpx.MockTransport(
+ lambda req: httpx.Response(500)), **kw))
+ with self._client(main) as c:
+ assert c.get("/api/webcam/stream").status_code == 502
+
+ def test_snapshot(self, main, monkeypatch):
+ _listing(main, monkeypatch, [_cam()])
+ self._camera_transport(main, monkeypatch)
+ with self._client(main) as c:
+ r = c.get("/api/webcam/snapshot")
+ assert r.status_code == 200 and r.content == b"JPEG"
+ assert r.headers["content-type"] == "image/jpeg"
+
+ def test_snapshot_without_url_is_503(self, main, monkeypatch):
+ _listing(main, monkeypatch, [_cam(snapshot_url="")])
+ with self._client(main) as c:
+ assert c.get("/api/webcam/snapshot").status_code == 503
+
+
+class TestMockMode:
+ def test_info_comes_from_the_fixture(self, mock_client):
+ j = mock_client.get("/api/webcam/info").json()
+ assert j["mock"] is True and j["available"] is False
+
+ def test_stream_and_snapshot_refuse(self, mock_client):
+ assert mock_client.get("/api/webcam/stream").status_code == 503
+ assert mock_client.get("/api/webcam/snapshot").status_code == 503
+
+ def test_query_param_cannot_enable_mock_on_a_real_printer(self, live_client):
+ assert live_client.get("/api/webcam/info?mock=1").json().get("mock") is not True
From 5d7d9f3d73b6735a2b143c5bcb0a51b01347bf4f Mon Sep 17 00:00:00 2001
From: stefanodangelo <61756862+stefanodangelo@users.noreply.github.com>
Date: Wed, 21 Oct 2026 19:53:47 +0200
Subject: [PATCH 02/16] feat: add the Control tab with the webcam under Print
Hub
---
multiace/i18n/de.json | 7 +++++
multiace/i18n/en.json | 7 +++++
multiace/i18n/zh.json | 7 +++++
multiace/web/frontend/app.js | 44 ++++++++++++++++++++++++++++-
multiace/web/frontend/index.html | 20 +++++++++++++
multiace/web/frontend/style-new.css | 5 ++++
multiace/web/frontend/style.css | 10 +++++++
7 files changed, 99 insertions(+), 1 deletion(-)
diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json
index 5e9e447b..346ef45d 100644
--- a/multiace/i18n/de.json
+++ b/multiace/i18n/de.json
@@ -11,7 +11,14 @@
"offline": "offline",
"ws_error": "WS-Fehler"
},
+ "control": {
+ "webcam": "Webcam",
+ "webcam_loading": "Suche Kamera…",
+ "webcam_unavailable": "Kamera nicht verfügbar",
+ "webcam_hint": "Kamera in Moonraker (crowsnest) einrichten, dann neu laden."
+ },
"tabs": {
+ "control": "Steuerung",
"print_hub": "Print Hub",
"dashboard": "Übersicht",
"dry": "Trocknen",
diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json
index 2ea0944d..73be7f25 100644
--- a/multiace/i18n/en.json
+++ b/multiace/i18n/en.json
@@ -11,7 +11,14 @@
"offline": "offline",
"ws_error": "WS error"
},
+ "control": {
+ "webcam": "Webcam",
+ "webcam_loading": "Looking for a camera…",
+ "webcam_unavailable": "Camera unavailable",
+ "webcam_hint": "Add a camera in Moonraker (crowsnest), then reload."
+ },
"tabs": {
+ "control": "Control",
"print_hub": "Print Hub",
"dashboard": "Dashboard",
"dry": "Dry",
diff --git a/multiace/i18n/zh.json b/multiace/i18n/zh.json
index 5503671a..59f5818a 100644
--- a/multiace/i18n/zh.json
+++ b/multiace/i18n/zh.json
@@ -11,7 +11,14 @@
"offline": "离线",
"ws_error": "WebSocket 错误"
},
+ "control": {
+ "webcam": "摄像头",
+ "webcam_loading": "正在查找摄像头…",
+ "webcam_unavailable": "摄像头不可用",
+ "webcam_hint": "请在 Moonraker (crowsnest) 中添加摄像头,然后重新加载。"
+ },
"tabs": {
+ "control": "控制",
"print_hub": "打印中心",
"dashboard": "控制台",
"dry": "烘干",
diff --git a/multiace/web/frontend/app.js b/multiace/web/frontend/app.js
index b2d07fec..67383b00 100644
--- a/multiace/web/frontend/app.js
+++ b/multiace/web/frontend/app.js
@@ -11,7 +11,7 @@ createApp({
// Bowden-path calibration tab (physicsG port): hidden for now, also on
// dev (Dirk 2026-09-25). The code stays; flip to true to show it.
const CALIBRATION_TAB = false;
- const _validTabs = new Set(["dashboard", "spools", "config"]);
+ const _validTabs = new Set(["dashboard", "control", "spools", "config"]);
if (CALIBRATION_TAB) _validTabs.add("calibration");
const _storedTab = localStorage.getItem("multiace.tab");
const _isPluginTab = (s) => typeof s === "string" && s.startsWith("plugin:");
@@ -59,6 +59,9 @@ createApp({
if (state.mode !== "normal") {
items.push({key: "dashboard", label: t("ui.tabs.dashboard")});
}
+ // Print Hub children, in their fixed order: Control, History, Queue,
+ // Favorites. Each feature adds its own entry here.
+ items.push({key: "control", group: "printhub", label: t("ui.tabs.control")});
items.push({key: "spools", label: t("ui.tabs.spools")});
items.push({key: "config", label: t("ui.tabs.config")});
// Calibration hangs off Config: shown only while one of the two is
@@ -7325,6 +7328,44 @@ createApp({
},
});
}
+ // Control tab: the printer's camera, proxied through the backend. The
+ // stream is only opened while the tab is showing, so a hidden tab does
+ // not keep a connection to the camera.
+ const webcam = reactive({
+ loaded: false, available: false, reason: "", name: "", proxy: "",
+ aspect: "16 / 9",
+ // Cache-buster: an MJPEG that was torn down keeps its old
+ // connection cached, so re-opening the tab shows a frozen frame
+ // until the src actually changes.
+ nonce: 0,
+ });
+ const webcamShown = computed(() => !panelMode && tab.value === "control");
+ const webcamSrc = computed(() =>
+ webcam.available && webcamShown.value
+ ? `${BASE}${webcam.proxy}?n=${webcam.nonce}` : "");
+ async function loadWebcam() {
+ try {
+ const r = await fetch(`${API}/webcam/info`);
+ const j = r.ok ? await r.json() : {available: false, reason: `HTTP ${r.status}`};
+ webcam.available = !!(j.available && j.proxy_path);
+ webcam.proxy = j.proxy_path || "";
+ webcam.reason = j.reason || "";
+ webcam.name = j.name || "";
+ webcam.aspect = String(j.aspect_ratio || "16:9").replace(":", " / ");
+ } catch (e) {
+ webcam.available = false;
+ webcam.reason = String(e);
+ } finally {
+ webcam.loaded = true;
+ webcam.nonce++;
+ }
+ }
+ watch(webcamShown, (shown) => {
+ if (!shown) return;
+ if (!webcam.loaded) loadWebcam();
+ else webcam.nonce++;
+ }, {immediate: true});
+
let resizeObserver = null;
onMounted(async () => {
// Page loaded straight into the spools tab (stored tab) - the watcher
@@ -7485,6 +7526,7 @@ createApp({
updateState, updateCheck, updateApply,
debugState, debugEnable, debugDisable,
navItems, topItems, subItems, topActive, openNav,
+ webcam, webcamSrc, loadWebcam,
plugins, refreshPlugins, pluginIframeSrc,
notifications, notifWarnOnly, notifTime, dismissNotification, dismissAllNotifications,
confirmDialog, okConfirm, altConfirm, cancelConfirm, confirmInputError,
diff --git a/multiace/web/frontend/index.html b/multiace/web/frontend/index.html
index fe32519c..932b69bf 100644
--- a/multiace/web/frontend/index.html
+++ b/multiace/web/frontend/index.html
@@ -1351,6 +1351,26 @@
{{ webcam.loaded ? t('ui.control.webcam_unavailable') + : t('ui.control.webcam_loading') }}
+{{ webcam.reason }}
+{{ t('ui.control.webcam_hint') }}
+