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 @@

{{ state.calibration.state === 'verified' + +
+
+ +
+

{{ webcam.loaded ? t('ui.control.webcam_unavailable') + : t('ui.control.webcam_loading') }}

+

{{ webcam.reason }}

+

{{ t('ui.control.webcam_hint') }}

+
+
+ {{ webcam.name || t('ui.control.webcam') }} + +
+
+
+

{{ t('ui.spools.title') }} :not(h2) { opacity: .5; } html[data-theme="new"][data-shade="light"] .panel:has(.panel-temp .ace-offline-text) .panel-cards { opacity: .55; } html[data-theme="new"][data-shade="light"] .preflight-dialog .row > button.primary:disabled { opacity: 1; background: var(--nt-main-dis-bg); border-color: var(--nt-main-dis-bg); color: var(--nt-main-dis-fg); } + +/* Control tab (Print Hub): the camera. The shared rules in style.css use the + theme variables; this only squares the card with the other tabs. */ +html[data-theme="new"] .control-webcam { border-radius: 0; } +html[data-theme="new"] .control-webcam .webcam-foot { background: var(--nt-bg2); border-top-color: var(--nt-line); } diff --git a/multiace/web/frontend/style.css b/multiace/web/frontend/style.css index 4a837709..f8c22705 100644 --- a/multiace/web/frontend/style.css +++ b/multiace/web/frontend/style.css @@ -1951,3 +1951,13 @@ body:has(.panel) .overlay .dialog { background: var(--card); border: 1px solid var(--border); border-radius: 4px; white-space: pre-wrap; font-size: .8rem; } + +/* Control tab (Print Hub): the camera. */ +.control-tab { display: flex; flex-direction: column; gap: 1rem; max-width: 1100px; } +.control-webcam { background: var(--card); border: 1px solid var(--border); + border-radius: 6px; overflow: hidden; max-width: 720px; } +.control-webcam .webcam-img { width: 100%; display: block; background: #000; object-fit: contain; } +.control-webcam .webcam-none { padding: 1.5rem 1rem; display: flex; flex-direction: column; gap: .4rem; } +.control-webcam .webcam-none p { margin: 0; } +.control-webcam .webcam-foot { display: flex; align-items: center; justify-content: space-between; + gap: .6rem; padding: .4rem .6rem; border-top: 1px solid var(--border); } From 7944a9ffd0db12c41d45a466053bc03d4254ed22 Mon Sep 17 00:00:00 2001 From: stefanodangelo <61756862+stefanodangelo@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:31:17 +0200 Subject: [PATCH 03/16] feat: add the read-only console log endpoint --- multiace/web/backend/main.py | 65 +++++++++++++++++++ tests/fixtures/mock_console.json | 13 ++++ tests/unit/test_console.py | 106 +++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/fixtures/mock_console.json create mode 100644 tests/unit/test_console.py diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py index 14d8e823..236480fb 100644 --- a/multiace/web/backend/main.py +++ b/multiace/web/backend/main.py @@ -2270,6 +2270,70 @@ async def webcam_snapshot(request: Request): media_type=r.headers.get("content-type", "image/jpeg"), headers={"Cache-Control": "no-store"}) +CONSOLE_BUFFER_MAX = int(os.environ.get("MULTIACE_CONSOLE_BUFFER", "300")) +_console_lines: deque = deque(maxlen=CONSOLE_BUFFER_MAX) +_next_console_id = 0 + +def _console_kind(text: str) -> str: + s = (text or "").strip() + body = s[3:].strip() if s.startswith("// ") else s + if body.startswith("!!") or "Error:" in body: + return "error" + if s.startswith("//"): + return "response" + return "command" + +def _record_console_line(text: str) -> dict | None: + """Append one Klipper console line to the ring buffer. + + Fed by the Moonraker websocket that already powers the error + notifications, so the console costs no extra printer polling.""" + global _next_console_id + if not isinstance(text, str) or not text.strip(): + return None + _next_console_id += 1 + line = {"id": _next_console_id, "ts": time.time(), + "msg": text.rstrip(), "kind": _console_kind(text)} + _console_lines.append(line) + return line + +async def _seed_console_from_gcode_store() -> None: + """Backfill the buffer from Moonraker's own history, so a freshly + started backend does not show an empty console.""" + global _next_console_id + try: + data = await _mr_get(f"/server/gcode_store?count={CONSOLE_BUFFER_MAX}") + except Exception: + return + entries = (data.get("result") or {}).get("gcode_store") or [] + for e in entries: + msg = e.get("message") + if not msg: + continue + _next_console_id += 1 + _console_lines.append({ + "id": _next_console_id, + "ts": float(e.get("time") or time.time()), + "msg": str(msg).rstrip(), + "kind": ("command" if e.get("type") == "command" + else _console_kind(str(msg))), + }) + +@app.get("/api/console-logs") +async def console_logs(request: Request, lines: int = 100, + since_id: int = 0) -> dict: + """Recent console lines, read only. `since_id` returns only what is + newer, so a poller stays incremental.""" + n = max(1, min(int(lines or 100), CONSOLE_BUFFER_MAX)) + if _mock_enabled(request): + mock = _mock_load("mock_console.json") or {"lines": []} + out = [ln for ln in mock.get("lines", []) if ln.get("id", 0) > since_id] + return {"lines": out[-n:], "mock": True} + if not _console_lines: + await _seed_console_from_gcode_store() + out = [ln for ln in _console_lines if ln["id"] > since_id] + return {"lines": out[-n:], "buffer_max": CONSOLE_BUFFER_MAX} + @app.post("/api/upload-and-print") async def upload_and_print(file: UploadFile = File(...)) -> dict: @@ -5031,6 +5095,7 @@ async def _moonraker_log_listener() -> None: if not params: continue text = params[0] + _record_console_line(text) if (isinstance(text, str) and _SPOOL_UNMATCHED_RE.search(text)): # Klipper just read a tag with no table entry - diff --git a/tests/fixtures/mock_console.json b/tests/fixtures/mock_console.json new file mode 100644 index 00000000..7f37c687 --- /dev/null +++ b/tests/fixtures/mock_console.json @@ -0,0 +1,13 @@ +{ + "_comment": "Mock /api/console-logs payload for the dev UI console pane.", + "lines": [ + {"id": 1, "ts": 1786000000.0, "kind": "command", "msg": "SET_ACE_MODE MODE=multi"}, + {"id": 2, "ts": 1786000000.4, "kind": "response", "msg": "// [multiACE] Switched to MULTI mode."}, + {"id": 3, "ts": 1786000012.0, "kind": "command", "msg": "ACE_LOAD_HEAD HEAD=0 ACE=0 SLOT=0"}, + {"id": 4, "ts": 1786000012.2, "kind": "response", "msg": "// [multiACE] head 0: loading from ACE 0 / slot 0"}, + {"id": 5, "ts": 1786000031.8, "kind": "response", "msg": "// [multiACE] head 0 loaded (ACE 0 / slot 0)"}, + {"id": 6, "ts": 1786000090.0, "kind": "command", "msg": "SDCARD_PRINT_FILE FILENAME=benchy.gcode"}, + {"id": 7, "ts": 1786000091.1, "kind": "response", "msg": "// Klipper state: Ready"}, + {"id": 8, "ts": 1786000210.5, "kind": "error", "msg": "!! Move out of range: 0.000 250.000 0.000 [0.000]"} + ] +} diff --git a/tests/unit/test_console.py b/tests/unit/test_console.py new file mode 100644 index 00000000..0531c51b --- /dev/null +++ b/tests/unit/test_console.py @@ -0,0 +1,106 @@ +"""Console ring buffer and the read-only /api/console-logs endpoint.""" +import asyncio + +import httpx +import pytest + +from .test_mock_mode import _load_main, mock_client, live_client # noqa: F401 + + +@pytest.fixture +def main(monkeypatch, tmp_path): + m = _load_main(monkeypatch, tmp_path, mock=False) + m._console_lines.clear() + return m + + +class TestKind: + @pytest.mark.parametrize("text,kind", [ + ("G28", "command"), + ("// [multiACE] head 0 loaded", "response"), + ("!! Move out of range", "error"), + ("// !! Timer too close", "error"), + ("Error: something broke", "error"), + ]) + def test_kind(self, main, text, kind): + assert main._console_kind(text) == kind + + +class TestRecord: + def test_ids_increase_and_blank_lines_are_skipped(self, main): + a = main._record_console_line("G28\n") + b = main._record_console_line(" ") + c = main._record_console_line("M105") + assert b is None and a["msg"] == "G28" and c["id"] == a["id"] + 1 + + def test_non_strings_are_ignored(self, main): + assert main._record_console_line(None) is None + assert main._record_console_line(5) is None + + def test_buffer_is_bounded(self, main): + for i in range(main.CONSOLE_BUFFER_MAX + 20): + main._record_console_line(f"line {i}") + assert len(main._console_lines) == main.CONSOLE_BUFFER_MAX + assert main._console_lines[-1]["msg"] == f"line {main.CONSOLE_BUFFER_MAX + 19}" + + +class TestSeed: + def test_backfills_from_moonraker_history(self, main, monkeypatch): + async def fake(path): + assert path.startswith("/server/gcode_store") + return {"result": {"gcode_store": [ + {"message": "G28", "time": 10.0, "type": "command"}, + {"message": "// ok", "time": 11.0, "type": "response"}, + {"message": "", "time": 12.0, "type": "response"}]}} + monkeypatch.setattr(main, "_mr_get", fake) + asyncio.run(main._seed_console_from_gcode_store()) + assert [(l["msg"], l["kind"]) for l in main._console_lines] == [ + ("G28", "command"), ("// ok", "response")] + + def test_moonraker_down_leaves_the_buffer_empty(self, main, monkeypatch): + async def boom(path): + raise httpx.ConnectError("down") + monkeypatch.setattr(main, "_mr_get", boom) + asyncio.run(main._seed_console_from_gcode_store()) + assert not main._console_lines + + +class TestEndpoint: + def _client(self, main): + from fastapi.testclient import TestClient + return TestClient(main.app) + + def test_since_id_and_lines_limit(self, main, monkeypatch): + for i in range(5): + main._record_console_line(f"M{i}") + with self._client(main) as c: + allr = c.get("/api/console-logs?lines=3").json()["lines"] + newer = c.get(f"/api/console-logs?since_id={allr[0]['id']}").json()["lines"] + assert [l["msg"] for l in allr] == ["M2", "M3", "M4"] + assert [l["msg"] for l in newer] == ["M3", "M4"] + + def test_empty_buffer_seeds_from_moonraker_once(self, main, monkeypatch): + calls = [] + async def fake(path): + calls.append(path) + return {"result": {"gcode_store": [{"message": "G28", "time": 1.0}]}} + monkeypatch.setattr(main, "_mr_get", fake) + with self._client(main) as c: + j = c.get("/api/console-logs").json() + c.get("/api/console-logs") + assert [l["msg"] for l in j["lines"]] == ["G28"] and len(calls) == 1 + + def test_there_is_no_write_endpoint(self, main): + with self._client(main) as c: + assert c.post("/api/console", json={"script": "G28"}).status_code in (404, 405) + + +class TestMockMode: + def test_served_from_fixture(self, mock_client): + j = mock_client.get("/api/console-logs").json() + assert j["mock"] is True and j["lines"] + newest = j["lines"][-1]["id"] + assert mock_client.get(f"/api/console-logs?since_id={newest}").json()["lines"] == [] + + def test_query_param_cannot_enable_mock_on_a_real_printer(self, live_client): + assert live_client.get("/api/console-logs?mock=1").json().get("mock") is not True From 46d8ca91805f09203c6138443ca3b065fa2b44a9 Mon Sep 17 00:00:00 2001 From: stefanodangelo <61756862+stefanodangelo@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:33:11 +0200 Subject: [PATCH 04/16] feat: add whitelisted live print control endpoints --- multiace/web/backend/main.py | 247 +++++++++++++++++++++++++ tests/fixtures/mock_print_control.json | 7 + tests/unit/test_print_control.py | 229 +++++++++++++++++++++++ 3 files changed, 483 insertions(+) create mode 100644 tests/fixtures/mock_print_control.json create mode 100644 tests/unit/test_print_control.py diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py index 236480fb..1118bdbb 100644 --- a/multiace/web/backend/main.py +++ b/multiace/web/backend/main.py @@ -2334,6 +2334,253 @@ async def console_logs(request: Request, lines: int = 100, out = [ln for ln in _console_lines if ln["id"] > since_id] return {"lines": out[-n:], "buffer_max": CONSOLE_BUFFER_MAX} +# --------------------------------------------------------------------------- +# Live print control: verbs, not G-code +# --------------------------------------------------------------------------- +# +# The Control tab does what tapping the printer's own screen does mid-print. +# Nothing here forwards a string from the browser: each request is a verb +# from a fixed list plus a number, and the number is clamped SERVER-side, so +# a stale page, a curl or a slider that fires an out-of-range value on touch +# all land on the same ceiling. + +#: Verb -> (min, max). Speed, fan, pause/resume/cancel are not hardware +#: risks (worst case: a ruined print). Flow and the heaters are: M221 at a +#: high value is sustained over-extrusion, and a temperature above the +#: material's range carbonises filament in the melt zone. +PRINT_CONTROL_RANGES = { + "speed": (25.0, 300.0), + "flow": (75.0, 125.0), + "fan": (0.0, 100.0), +} + +#: Z babystep is the damaging control: it moves the nozzle relative to the +#: bed while printing, and repeated negative steps drive it into the plate. +#: Discrete steps only (the UI has buttons, never a slider), and a +#: cumulative floor, because the situation where a user keeps pressing +#: "down" is the one where the next press does damage. +BABYSTEP_MAX_STEP_MM = 0.05 +BABYSTEP_MAX_TOTAL_MM = 0.5 + +#: Fallback heater ceilings, used only when Moonraker's config cannot be +#: read. Klipper's own max_temp catches the extreme; the MATERIAL limit is +#: tighter and Klipper knows nothing about it. +DEFAULT_HEATER_LIMITS = {"nozzle": (0.0, 300.0), "bed": (0.0, 120.0)} + +MATERIAL_TEMP_LIMITS = { + "PLA": (170.0, 230.0), "PLA+": (180.0, 240.0), "SILK": (190.0, 240.0), + "PETG": (210.0, 260.0), "ABS": (220.0, 270.0), "ASA": (230.0, 270.0), + "TPU": (200.0, 245.0), "PC": (240.0, 300.0), "PVA": (170.0, 225.0), + "PA": (240.0, 300.0), "HIPS": (220.0, 250.0), +} + +_EXTRUDERS = ["extruder"] + ["extruder%d" % i for i in range(1, 4)] + +class PrintControl(BaseModel): + verb: str + value: float | None = None + +#: Cumulative babystep for the current job, reset on a job change rather +#: than on page reload: a reload must not hand back the other half of the +#: allowance. +_babystep_state: dict = {"job": None, "total": 0.0} + +def _clamp(value: float, lo: float, hi: float) -> float: + return max(lo, min(hi, float(value))) + +async def _print_status() -> dict: + """Raw Moonraker status for the Control tab. A query of its own, so the + shared state poll does not grow for a tab most people have closed.""" + objs = ["toolhead", "print_stats", "display_status", "virtual_sdcard", + "gcode_move", "fan", "heater_bed"] + _EXTRUDERS + data = await _mr_get("/printer/objects/query?" + "&".join(objs)) + return data.get("result", {}).get("status", {}) or {} + +def _active_extruder(status: dict) -> str: + name = str((status.get("toolhead") or {}).get("extruder") or "extruder") + return name if name in _EXTRUDERS else "extruder" + +def _pct(v, default=100.0): + try: + return round(float(v) * 100.0, 1) + except (TypeError, ValueError): + return default + +def _print_control_state(status: dict) -> dict: + """Speed, flow, fan, temperatures and progress, READ BACK from + Moonraker so a change made on the printer's display shows up here.""" + gm = status.get("gcode_move") or {} + ext = status.get(_active_extruder(status)) or {} + bed = status.get("heater_bed") or {} + homing = gm.get("homing_origin") or [0, 0, 0, 0] + try: + z_offset = round(float(homing[2]), 3) + except (TypeError, ValueError, IndexError): + z_offset = 0.0 + return { + "speed_factor": _pct(gm.get("speed_factor")), + "extrude_factor": _pct(gm.get("extrude_factor")), + "fan": _pct((status.get("fan") or {}).get("speed"), 0.0), + "z_offset": z_offset, + "nozzle": {"temp": ext.get("temperature"), "target": ext.get("target")}, + "bed": {"temp": bed.get("temperature"), "target": bed.get("target")}, + "progress": round(float( + (status.get("virtual_sdcard") or {}).get("progress") or 0.0) * 100.0, 1), + "message": (status.get("display_status") or {}).get("message") or "", + "state": (status.get("print_stats") or {}).get("state") or "", + "filename": (status.get("print_stats") or {}).get("filename") or "", + } + +async def _heater_limits(which: str, extruder: str = "extruder") -> tuple[float, float]: + """(min, max) for a heater: Klipper's configured limits min()-ed with + the loaded material's range. Read from Moonraker's config, never + hardcoded: a different hotend has different limits.""" + lo, hi = DEFAULT_HEATER_LIMITS.get(which, (0.0, 250.0)) + section = extruder if which == "nozzle" else "heater_bed" + try: + cfg = await _mr_get("/printer/objects/query?configfile") + settings = ((cfg.get("result") or {}).get("status") or {}) \ + .get("configfile", {}).get("settings", {}) or {} + block = settings.get(section) or {} + if block.get("min_temp") is not None: + lo = max(lo, float(block["min_temp"])) + if block.get("max_temp") is not None: + hi = min(hi, float(block["max_temp"])) + except Exception: + pass + if which == "nozzle": + try: + parsed = _parse_state(await _query_state_gated()) + for th in parsed.get("toolheads") or []: + if not th.get("filament_detected"): + continue + mat = MATERIAL_TEMP_LIMITS.get( + str(th.get("material") or "").strip().upper()) + if mat: + lo, hi = max(lo, mat[0]), min(hi, mat[1]) + except Exception: + pass + return lo, hi + +async def _babystep_allowance(delta: float) -> tuple[float, str]: + """Clamp one babystep against the per-press and per-job limits.""" + step = _clamp(delta, -BABYSTEP_MAX_STEP_MM, BABYSTEP_MAX_STEP_MM) + try: + ps = (await _print_status()).get("print_stats") or {} + job = ps.get("filename") or ps.get("state") + except Exception: + job = None + if _babystep_state["job"] != job: + _babystep_state["job"] = job + _babystep_state["total"] = 0.0 + total = _babystep_state["total"] + step + if total < -BABYSTEP_MAX_TOTAL_MM: + return 0.0, (f"-{BABYSTEP_MAX_TOTAL_MM:.2f} mm reached - " + "re-level instead of stepping further down") + if total > BABYSTEP_MAX_TOTAL_MM: + return 0.0, f"+{BABYSTEP_MAX_TOTAL_MM:.2f} mm reached" + _babystep_state["total"] = total + return step, "" + +@app.get("/api/print-control") +async def print_control_state(request: Request) -> dict: + if _mock_enabled(request): + return {**(_mock_load("mock_print_control.json") or {}), "mock": True} + try: + return _print_control_state(await _print_status()) + except httpx.HTTPError as e: + raise HTTPException(502, f"moonraker: {e}") + +@app.post("/api/print-control") +async def print_control(request: Request, req: PrintControl) -> dict: + """A whitelisted verb + value, never a raw string from the UI. + + Returns the APPLIED value so the UI can snap back to the truth: an + out-of-range request is clamped, not silently rejected, because a + slider that keeps showing 400 % after the printer refused it is a + slider that lies about the machine.""" + verb = (req.verb or "").strip().lower() + value = req.value + mocked = _mock_enabled(request) + + if verb in PRINT_CONTROL_RANGES: + if value is None: + raise HTTPException(status_code=400, detail=f"{verb} needs a value") + lo, hi = PRINT_CONTROL_RANGES[verb] + applied = _clamp(value, lo, hi) + if verb == "speed": + script = f"M220 S{applied:.0f}" + elif verb == "flow": + script = f"M221 S{applied:.0f}" + else: + # Percent in, 0-255 out: the UI thinks in percent and M106 + # does not. + script = f"M106 S{round(applied * 255 / 100):d}" + elif verb in ("nozzle", "bed"): + if value is None: + raise HTTPException(status_code=400, detail=f"{verb} needs a value") + heater = "heater_bed" + if verb == "nozzle": + try: + heater = _active_extruder(await _print_status()) + except Exception: + heater = "extruder" + lo, hi = await _heater_limits(verb, heater) + applied = _clamp(value, lo, hi) + script = f"SET_HEATER_TEMPERATURE HEATER={heater} TARGET={applied:.0f}" + elif verb == "babystep": + if value is None: + raise HTTPException(status_code=400, detail="babystep needs a value") + applied, refused = await _babystep_allowance(value) + if refused: + return {"ok": False, "verb": verb, "applied": 0.0, + "total_mm": _babystep_state["total"], "detail": refused} + script = f"SET_GCODE_OFFSET Z_ADJUST={applied:.3f} MOVE=1" + elif verb in ("pause", "resume", "cancel"): + applied = None + script = {"pause": "PAUSE", "resume": "RESUME", + "cancel": "CANCEL_PRINT"}[verb] + else: + raise HTTPException(status_code=400, detail=f"unknown verb: {verb}") + + if mocked: + # Mock mode never contacts Moonraker: it has to work with no + # printer attached. + return {"ok": True, "verb": verb, "applied": applied, + "script": script, "mock": True} + try: + await _mr_post("/printer/gcode/script", {"script": script}, timeout=30.0) + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code, + detail=f"moonraker: {e.response.text}") + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"moonraker: {e}") + out = {"ok": True, "verb": verb, "applied": applied, "script": script} + if verb == "babystep": + out["total_mm"] = round(_babystep_state["total"], 3) + return out + +@app.get("/api/print-control/limits") +async def print_control_limits() -> dict: + """The clamps the UI should render, so a slider cannot even offer a + value the server will refuse. The server check stays regardless: this + is convenience, not the guard.""" + try: + extruder = _active_extruder(await _print_status()) + except Exception: + extruder = "extruder" + nozzle = await _heater_limits("nozzle", extruder) + bed = await _heater_limits("bed") + return { + "ranges": {k: {"min": v[0], "max": v[1]} + for k, v in PRINT_CONTROL_RANGES.items()}, + "nozzle": {"min": nozzle[0], "max": nozzle[1]}, + "bed": {"min": bed[0], "max": bed[1]}, + "babystep": {"step": BABYSTEP_MAX_STEP_MM, + "total": BABYSTEP_MAX_TOTAL_MM, + "used": round(_babystep_state["total"], 3)}, + } + @app.post("/api/upload-and-print") async def upload_and_print(file: UploadFile = File(...)) -> dict: diff --git a/tests/fixtures/mock_print_control.json b/tests/fixtures/mock_print_control.json new file mode 100644 index 00000000..14af4b67 --- /dev/null +++ b/tests/fixtures/mock_print_control.json @@ -0,0 +1,7 @@ +{ + "_comment": "Mock GET /api/print-control payload: an idle printer at default factors.", + "speed_factor": 100.0, "extrude_factor": 100.0, "fan": 0.0, "z_offset": 0.0, + "nozzle": {"temp": 24.1, "target": 0.0}, + "bed": {"temp": 23.6, "target": 0.0}, + "progress": 0.0, "message": "", "state": "standby", "filename": "" +} diff --git a/tests/unit/test_print_control.py b/tests/unit/test_print_control.py new file mode 100644 index 00000000..86175927 --- /dev/null +++ b/tests/unit/test_print_control.py @@ -0,0 +1,229 @@ +"""Live print control: whitelisted verbs, server-side clamps, state readback. + +Moonraker is replaced by stubs for `_mr_get` / `_mr_post`; every G-code line +the endpoint would send is captured instead. +""" +import pytest + +from .test_mock_mode import _load_main, mock_client, live_client # noqa: F401 + +CFG = {"result": {"status": {"configfile": {"settings": { + "extruder": {"min_temp": 0, "max_temp": 300}, + "extruder1": {"min_temp": 0, "max_temp": 280}, + "heater_bed": {"min_temp": 0, "max_temp": 110}}}}}} + + +@pytest.fixture +def main(monkeypatch, tmp_path): + m = _load_main(monkeypatch, tmp_path, mock=False) + m._babystep_state.update(job=None, total=0.0) + return m + + +@pytest.fixture +def printer(main, monkeypatch): + """A stub printer: records the scripts, serves a status and config.""" + class P: + sent = [] + status = {"toolhead": {"extruder": "extruder"}, + "print_stats": {"state": "printing", "filename": "a.gcode"}} + toolheads = [] + P.sent = [] + + async def fake_get(path): + if "configfile" in path: + return CFG + return {"result": {"status": P.status}} + + async def fake_post(path, body=None, timeout=30.0): + assert path == "/printer/gcode/script" + P.sent.append(body["script"]) + return {"result": "ok"} + + async def fake_state(): + return {} + + monkeypatch.setattr(main, "_mr_get", fake_get) + monkeypatch.setattr(main, "_mr_post", fake_post) + monkeypatch.setattr(main, "_query_state_gated", fake_state) + monkeypatch.setattr(main, "_parse_state", lambda s: {"toolheads": P.toolheads}) + return P + + +@pytest.fixture +def client(main): + from fastapi.testclient import TestClient + with TestClient(main.app) as c: + yield c + + +def post(client, verb, value=None): + body = {"verb": verb} + if value is not None: + body["value"] = value + return client.post("/api/print-control", json=body) + + +class TestRanges: + @pytest.mark.parametrize("verb,value,script,applied", [ + ("speed", 120, "M220 S120", 120), + ("speed", 5000, "M220 S300", 300), + ("speed", 1, "M220 S25", 25), + ("flow", 400, "M221 S125", 125), + ("flow", 0, "M221 S75", 75), + ("fan", 50, "M106 S128", 50), + ("fan", 900, "M106 S255", 100), + ("fan", -5, "M106 S0", 0), + ]) + def test_value_is_clamped_before_it_becomes_gcode( + self, client, printer, verb, value, script, applied): + j = post(client, verb, value).json() + assert printer.sent == [script] + assert j["applied"] == applied and j["ok"] is True + + @pytest.mark.parametrize("verb", ["speed", "flow", "fan", "nozzle", "bed", + "babystep"]) + def test_a_value_is_required(self, client, printer, verb): + assert post(client, verb).status_code == 400 + assert printer.sent == [] + + +class TestWhitelist: + @pytest.mark.parametrize("verb", ["G28", "M104 S300", "gcode", "restart", + "", "speed; M104 S400"]) + def test_unknown_verbs_send_nothing(self, client, printer, verb): + assert post(client, verb, 1).status_code == 400 + assert printer.sent == [] + + def test_a_script_field_is_not_a_way_in(self, client, printer): + r = client.post("/api/print-control", + json={"verb": "pause", "script": "M104 S400"}) + assert r.status_code == 200 and printer.sent == ["PAUSE"] + + def test_job_verbs(self, client, printer): + for verb, script in (("pause", "PAUSE"), ("resume", "RESUME"), + ("cancel", "CANCEL_PRINT")): + printer.sent.clear() + assert post(client, verb).json()["applied"] is None + assert printer.sent == [script] + + def test_verb_is_case_and_space_insensitive(self, client, printer): + post(client, " Pause ") + assert printer.sent == ["PAUSE"] + + +class TestHeaters: + def test_nozzle_is_clamped_to_the_klipper_config(self, client, printer): + post(client, "nozzle", 999) + assert printer.sent == ["SET_HEATER_TEMPERATURE HEATER=extruder TARGET=300"] + + def test_bed_uses_its_own_limit(self, client, printer): + post(client, "bed", 200) + assert printer.sent == ["SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=110"] + + def test_loaded_material_tightens_the_nozzle_ceiling(self, client, printer): + printer.toolheads = [{"filament_detected": True, "material": "PLA"}] + j = post(client, "nozzle", 300).json() + assert j["applied"] == 230 + assert post(client, "nozzle", 0).json()["applied"] == 170 + + def test_unloaded_heads_do_not_count(self, client, printer): + printer.toolheads = [{"filament_detected": False, "material": "PLA"}] + assert post(client, "nozzle", 300).json()["applied"] == 300 + + def test_follows_the_active_extruder(self, client, printer): + printer.status = {"toolhead": {"extruder": "extruder1"}} + post(client, "nozzle", 999) + assert printer.sent == ["SET_HEATER_TEMPERATURE HEATER=extruder1 TARGET=280"] + + def test_an_unknown_extruder_name_falls_back(self, client, printer): + printer.status = {"toolhead": {"extruder": "extruder; M112"}} + post(client, "nozzle", 100) + assert printer.sent == ["SET_HEATER_TEMPERATURE HEATER=extruder TARGET=100"] + + +class TestBabystep: + def test_one_press_is_capped(self, client, printer): + j = post(client, "babystep", -5).json() + assert j["applied"] == -0.05 + assert printer.sent == ["SET_GCODE_OFFSET Z_ADJUST=-0.050 MOVE=1"] + + def test_total_floor_refuses_and_sends_nothing(self, client, printer): + for _ in range(10): + assert post(client, "babystep", -0.05).json()["ok"] is True + printer.sent.clear() + j = post(client, "babystep", -0.01).json() + assert j["ok"] is False and "re-level" in j["detail"] + assert printer.sent == [] + + def test_going_back_up_frees_the_allowance(self, client, printer): + for _ in range(10): + post(client, "babystep", -0.05) + assert post(client, "babystep", 0.05).json()["ok"] is True + assert post(client, "babystep", -0.05).json()["ok"] is True + + def test_new_job_resets_the_total(self, client, printer): + for _ in range(10): + post(client, "babystep", -0.05) + printer.status = {"print_stats": {"state": "printing", "filename": "b.gcode"}} + assert post(client, "babystep", -0.05).json()["total_mm"] == -0.05 + + +class TestState: + def test_readback(self, client, printer): + printer.status = { + "toolhead": {"extruder": "extruder"}, + "gcode_move": {"speed_factor": 1.25, "extrude_factor": 0.95, + "homing_origin": [0, 0, -0.1234, 0]}, + "fan": {"speed": 0.5}, + "extruder": {"temperature": 201.5, "target": 210}, + "heater_bed": {"temperature": 59.9, "target": 60}, + "virtual_sdcard": {"progress": 0.4321}, + "display_status": {"message": "hi"}, + "print_stats": {"state": "printing", "filename": "a.gcode"}} + j = client.get("/api/print-control").json() + assert j == {"speed_factor": 125.0, "extrude_factor": 95.0, "fan": 50.0, + "z_offset": -0.123, "nozzle": {"temp": 201.5, "target": 210}, + "bed": {"temp": 59.9, "target": 60}, "progress": 43.2, + "message": "hi", "state": "printing", "filename": "a.gcode"} + + def test_an_empty_status_gives_defaults(self, client, printer): + printer.status = {} + j = client.get("/api/print-control").json() + assert j["speed_factor"] == 100.0 and j["fan"] == 0.0 + assert j["nozzle"] == {"temp": None, "target": None} + + def test_limits(self, client, printer): + j = client.get("/api/print-control/limits").json() + assert j["ranges"]["flow"] == {"min": 75.0, "max": 125.0} + assert j["nozzle"]["max"] == 300 and j["bed"]["max"] == 110 + assert j["babystep"] == {"step": 0.05, "total": 0.5, "used": 0.0} + + def test_moonraker_down_is_a_502(self, main, client, monkeypatch): + import httpx + + async def boom(path): + raise httpx.ConnectError("down") + monkeypatch.setattr(main, "_mr_get", boom) + assert client.get("/api/print-control").status_code == 502 + + +class TestMockMode: + def test_state_comes_from_the_fixture(self, mock_client): + j = mock_client.get("/api/print-control").json() + assert j["mock"] is True and j["state"] == "standby" + + def test_verbs_never_reach_moonraker(self, main, mock_client, monkeypatch): + # The mock_client app is a different import; patch that module. + import sys + m = sys.modules["main"] + + async def boom(*a, **k): + raise AssertionError("mock mode must not contact Moonraker") + monkeypatch.setattr(m, "_mr_post", boom) + j = post(mock_client, "speed", 150).json() + assert j["mock"] is True and j["script"] == "M220 S150" + + def test_query_param_cannot_enable_mock_on_a_real_printer(self, live_client): + assert live_client.get("/api/print-control?mock=1").status_code in (200, 502) + assert live_client.get("/api/print-control?mock=1").json().get("mock") is not True From cd7c37080e0b0cbc6ea89f337e230ed2df32d633 Mon Sep 17 00:00:00 2001 From: stefanodangelo <61756862+stefanodangelo@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:41:54 +0200 Subject: [PATCH 05/16] feat: add the print panel, console and WebRTC camera to the Control tab --- multiace/i18n/de.json | 21 ++- multiace/i18n/en.json | 21 ++- multiace/i18n/zh.json | 21 ++- multiace/web/backend/main.py | 64 ++++++- multiace/web/frontend/app.js | 281 ++++++++++++++++++++++++++-- multiace/web/frontend/index.html | 143 ++++++++++++-- multiace/web/frontend/style-new.css | 13 +- multiace/web/frontend/style.css | 43 ++++- tests/unit/test_console.py | 6 + tests/unit/test_print_control.py | 8 +- tests/unit/test_webcam.py | 75 +++++++- 11 files changed, 653 insertions(+), 43 deletions(-) diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json index 346ef45d..773a0ec3 100644 --- a/multiace/i18n/de.json +++ b/multiace/i18n/de.json @@ -15,7 +15,26 @@ "webcam": "Webcam", "webcam_loading": "Suche Kamera…", "webcam_unavailable": "Kamera nicht verfügbar", - "webcam_hint": "Kamera in Moonraker (crowsnest) einrichten, dann neu laden." + "webcam_hint": "Kamera in Moonraker (crowsnest) einrichten, dann neu laden.", + "target": "Ziel", + "print": "Druck", + "speed": "Geschwindigkeit", + "flow": "Fluss", + "fan": "Lüfter", + "nozzle": "Düse", + "bed": "Bett", + "babystep": "Z-Offset", + "babystep_used": "{used} mm von ±{max} mm pro Job verbraucht. Tasten statt Schieberegler - ein Druck ist ein kleiner Schritt.", + "reset_hint": "Zurück auf 100 %", + "pause": "Pause", + "resume": "Fortsetzen", + "cancel": "Druck abbrechen", + "cancel_title": "Diesen Druck abbrechen?", + "cancel_msg": "Der Druck stoppt sofort und kann nicht fortgesetzt werden.", + "cancel_ok": "Druck abbrechen", + "console": "Konsole", + "console_empty": "Noch keine Konsolenausgabe.", + "console_follow": "Folgen" }, "tabs": { "control": "Steuerung", diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json index 73be7f25..7581a8ea 100644 --- a/multiace/i18n/en.json +++ b/multiace/i18n/en.json @@ -15,7 +15,26 @@ "webcam": "Webcam", "webcam_loading": "Looking for a camera…", "webcam_unavailable": "Camera unavailable", - "webcam_hint": "Add a camera in Moonraker (crowsnest), then reload." + "webcam_hint": "Add a camera in Moonraker (crowsnest), then reload.", + "target": "target", + "print": "Print", + "speed": "Speed", + "flow": "Flow", + "fan": "Fan", + "nozzle": "Nozzle", + "bed": "Bed", + "babystep": "Z offset", + "babystep_used": "{used} mm of the ±{max} mm per-job allowance used. Buttons, not a slider - one press is one small step.", + "reset_hint": "Back to 100 %", + "pause": "Pause", + "resume": "Resume", + "cancel": "Cancel print", + "cancel_title": "Cancel this print?", + "cancel_msg": "The print stops immediately and cannot be resumed.", + "cancel_ok": "Cancel the print", + "console": "Console", + "console_empty": "No console output yet.", + "console_follow": "Follow" }, "tabs": { "control": "Control", diff --git a/multiace/i18n/zh.json b/multiace/i18n/zh.json index 59f5818a..5a8ef53b 100644 --- a/multiace/i18n/zh.json +++ b/multiace/i18n/zh.json @@ -15,7 +15,26 @@ "webcam": "摄像头", "webcam_loading": "正在查找摄像头…", "webcam_unavailable": "摄像头不可用", - "webcam_hint": "请在 Moonraker (crowsnest) 中添加摄像头,然后重新加载。" + "webcam_hint": "请在 Moonraker (crowsnest) 中添加摄像头,然后重新加载。", + "target": "目标", + "print": "打印", + "speed": "速度", + "flow": "流量", + "fan": "风扇", + "nozzle": "喷嘴", + "bed": "热床", + "babystep": "Z 偏移", + "babystep_used": "本次任务已用 {used} mm,上限 ±{max} mm。使用按钮而非滑块 — 一次点击只走一小步。", + "reset_hint": "恢复 100 %", + "pause": "暂停", + "resume": "继续", + "cancel": "取消打印", + "cancel_title": "取消这次打印?", + "cancel_msg": "打印会立即停止且无法恢复。", + "cancel_ok": "取消打印", + "console": "控制台", + "console_empty": "暂无控制台输出。", + "console_follow": "跟随" }, "tabs": { "control": "控制", diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py index 1118bdbb..e82eda5e 100644 --- a/multiace/web/backend/main.py +++ b/multiace/web/backend/main.py @@ -21,6 +21,7 @@ import ast import hashlib +import html import asyncio import json import logging @@ -2188,10 +2189,12 @@ async def _resolve_webcam() -> dict: stream = str(cam.get("stream_url") or "") if not stream: continue + service = str(cam.get("service") or "mjpegstreamer") return { "available": True, "name": name, - "service": str(cam.get("service") or "mjpegstreamer"), + "service": service, + "webrtc": "webrtc" in service.lower(), "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", @@ -2212,7 +2215,14 @@ async def webcam_info(request: Request) -> dict: # 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" + # A WebRTC camera (camera-streamer) has no MJPEG stream: the media + # travels browser-to-camera over ICE, and only the signalling goes + # through us. + if info["webrtc"]: + info["signal_path"] = "/api/webcam/webrtc-signal" + else: + info["proxy_path"] = "/api/webcam/stream" + info["snapshot_path"] = "/api/webcam/snapshot" info.pop("stream_url", None) info.pop("snapshot_url", None) return info @@ -2225,8 +2235,8 @@ async def webcam_stream(request: Request): 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") + if not info.get("available") or info.get("webrtc"): + raise HTTPException(503, info.get("reason") or "no MJPEG webcam") client = httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=None)) try: @@ -2252,6 +2262,31 @@ async def _pump(): return StreamingResponse(_pump(), media_type=ctype, headers={"Cache-Control": "no-store"}) +@app.post("/api/webcam/webrtc-signal") +async def webcam_webrtc_signal(request: Request) -> dict: + """Relay one leg of the browser<->camera SDP handshake. + + camera-streamer sends no CORS headers and answers OPTIONS with 501, so + a browser POST straight to it is blocked at the preflight. From the + browser's side this is same-origin; httpx talks to the camera directly, + where CORS does not apply.""" + if _mock_enabled(request): + raise HTTPException(503, "webrtc signalling unavailable in mock mode") + info = await _resolve_webcam() + if not info.get("available") or not info.get("webrtc"): + raise HTTPException(503, info.get("reason") or "no WebRTC camera") + try: + body = await request.json() + except ValueError: + raise HTTPException(400, "invalid JSON") + try: + async with httpx.AsyncClient(timeout=10.0) as client: + r = await client.post(info["stream_url"], json=body) + r.raise_for_status() + return r.json() + except (httpx.HTTPError, ValueError) as e: + raise HTTPException(502, f"webrtc signal: {e}") + @app.get("/api/webcam/snapshot") async def webcam_snapshot(request: Request): if _mock_enabled(request): @@ -2274,6 +2309,13 @@ async def webcam_snapshot(request: Request): _console_lines: deque = deque(maxlen=CONSOLE_BUFFER_MAX) _next_console_id = 0 +_HTML_TAG_RE = re.compile(r"<[^>]+>") + +def _console_text(text: str) -> str: + """Klipper messages from some modules carry colour markup (Fluidd + renders it); the console shows plain text.""" + return html.unescape(_HTML_TAG_RE.sub("", text)) + def _console_kind(text: str) -> str: s = (text or "").strip() body = s[3:].strip() if s.startswith("// ") else s @@ -2291,6 +2333,9 @@ def _record_console_line(text: str) -> dict | None: global _next_console_id if not isinstance(text, str) or not text.strip(): return None + text = _console_text(text) + if not text.strip(): + return None _next_console_id += 1 line = {"id": _next_console_id, "ts": time.time(), "msg": text.rstrip(), "kind": _console_kind(text)} @@ -2310,13 +2355,16 @@ async def _seed_console_from_gcode_store() -> None: msg = e.get("message") if not msg: continue + msg = _console_text(str(msg)) + if not msg.strip(): + continue _next_console_id += 1 _console_lines.append({ "id": _next_console_id, "ts": float(e.get("time") or time.time()), - "msg": str(msg).rstrip(), + "msg": msg.rstrip(), "kind": ("command" if e.get("type") == "command" - else _console_kind(str(msg))), + else _console_kind(msg)), }) @app.get("/api/console-logs") @@ -2526,7 +2574,9 @@ async def print_control(request: Request, req: PrintControl) -> dict: except Exception: heater = "extruder" lo, hi = await _heater_limits(verb, heater) - applied = _clamp(value, lo, hi) + # 0 is "off" and is always allowed: the material range is a floor + # for printing, not for switching the heater off. + applied = 0.0 if value <= 0 else _clamp(value, lo, hi) script = f"SET_HEATER_TEMPERATURE HEATER={heater} TARGET={applied:.0f}" elif verb == "babystep": if value is None: diff --git a/multiace/web/frontend/app.js b/multiace/web/frontend/app.js index 67383b00..44cfab4d 100644 --- a/multiace/web/frontend/app.js +++ b/multiace/web/frontend/app.js @@ -7328,27 +7328,115 @@ 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. + // Control tab: the printer's camera. MJPEG comes through the backend as + // an ; a WebRTC camera (camera-streamer) is a

+ +
+ {{ t('ui.control.enclosure_fans') }} + {{ t('ui.control.exhaust') }} {{ Math.round(printCtl.enclosure.exhaust) }} % + · {{ t('ui.control.inner') }} {{ Math.round(printCtl.enclosure.inner) }} % + +
+ diff --git a/multiace/web/frontend/style.css b/multiace/web/frontend/style.css index d8936bdf..62955f83 100644 --- a/multiace/web/frontend/style.css +++ b/multiace/web/frontend/style.css @@ -1978,6 +1978,7 @@ body:has(.panel) .overlay .dialog { .pc-baby { flex-wrap: wrap; row-gap: .3rem; } .pc-baby button { min-width: 3.2rem; } .pc-baby b { flex: 1; text-align: center; } +.pc-enclosure b { flex: 1; min-width: 0; text-align: left; font-weight: 600; } .pc-light .pc-bulb { flex: 0 0 auto; color: var(--muted); } .pc-light.on .pc-bulb { color: var(--warn); } .pc-light.on .pc-bulb-glass { fill: currentColor; fill-opacity: .25; } diff --git a/tests/fixtures/mock_print_control.json b/tests/fixtures/mock_print_control.json index f6e6f79d..bf41b567 100644 --- a/tests/fixtures/mock_print_control.json +++ b/tests/fixtures/mock_print_control.json @@ -1,6 +1,7 @@ { "_comment": "Mock GET /api/print-control payload: an idle printer at default factors.", "speed_factor": 100.0, "extrude_factor": 100.0, "fan": 0.0, "z_offset": 0.0, "light": 0.0, + "enclosure": {"temp": 24.0, "attached": true, "exhaust": 0.0, "inner": 0.0}, "nozzle": {"temp": 24.1, "target": 0.0}, "bed": {"temp": 23.6, "target": 0.0}, "progress": 0.0, "message": "", "state": "standby", "filename": "" diff --git a/tests/unit/test_console.py b/tests/unit/test_console.py index 0c81c6c2..d44dc2a8 100644 --- a/tests/unit/test_console.py +++ b/tests/unit/test_console.py @@ -71,6 +71,47 @@ async def boom(path): assert not main._console_lines +class TestSeedWithLiveLines: + """On a real printer lines arrive constantly, so live lines are almost + always in the buffer before anyone opens the console.""" + + def test_history_is_still_loaded_and_not_duplicated(self, main, monkeypatch): + main._record_console_line("// early live line") # also in the history + + async def fake(path): + main._record_console_line("// arrived during the request") + return {"result": {"gcode_store": [ + {"message": "G28", "time": 1.0, "type": "command"}, + {"message": "// early live line", "time": 2.0, "type": "response"}]}} + monkeypatch.setattr(main, "_mr_get", fake) + asyncio.run(main._seed_console_from_gcode_store()) + assert [l["msg"] for l in main._console_lines] == [ + "G28", "// early live line", "// arrived during the request"] + ids = [l["id"] for l in main._console_lines] + assert ids == sorted(ids) and len(set(ids)) == 3 + + def test_it_is_seeded_only_once(self, main, monkeypatch): + calls = [] + + async def fake(path): + calls.append(path) + return {"result": {"gcode_store": [{"message": "G28", "time": 1.0}]}} + monkeypatch.setattr(main, "_mr_get", fake) + asyncio.run(main._seed_console_from_gcode_store()) + asyncio.run(main._seed_console_from_gcode_store()) + assert len(calls) == 1 and len(main._console_lines) == 1 + + def test_a_failed_seed_is_retried_and_keeps_the_live_lines(self, main, monkeypatch): + main._record_console_line("// live") + + async def boom(path): + raise httpx.ConnectError("down") + monkeypatch.setattr(main, "_mr_get", boom) + asyncio.run(main._seed_console_from_gcode_store()) + assert [l["msg"] for l in main._console_lines] == ["// live"] + assert main._console_seeded is False + + class TestEndpoint: def _client(self, main): from fastapi.testclient import TestClient diff --git a/tests/unit/test_print_control.py b/tests/unit/test_print_control.py index 4825145c..2998f851 100644 --- a/tests/unit/test_print_control.py +++ b/tests/unit/test_print_control.py @@ -253,6 +253,70 @@ def test_limits_offer_the_light_range(self, client, printer): == {"min": 0.0, "max": 100.0} +PURIFIER_OBJ = { + "power_detected": True, "mode": 0, + "exhaust_fan": {"speed": 0.5, "delay": 180, "speed_threshold": 0}, + "inner_fan": {"speed": 0.25, "delay": 180, "speed_threshold": 0}} +CAVITY = {"temperature": 31.4, "measured_min_temp": 18.0, "measured_max_temp": 33.0} + + +class TestEnclosure: + def test_attached_enclosure_reports_temperature_and_fans(self, client, printer): + printer.status = {"purifier": PURIFIER_OBJ, + "temperature_sensor cavity": CAVITY} + assert client.get("/api/print-control").json()["enclosure"] == { + "temp": 31.4, "attached": True, "exhaust": 50.0, "inner": 25.0} + + def test_no_enclosure_still_reads_the_chamber_temperature(self, client, printer): + # The cavity sensor is part of the printer; power_detected says + # whether an enclosure is attached. + printer.status = { + "purifier": {**PURIFIER_OBJ, "power_detected": False, + "exhaust_fan": {"speed": 0.0}, "inner_fan": {"speed": 0.0}}, + "temperature_sensor cavity": CAVITY} + assert client.get("/api/print-control").json()["enclosure"] == { + "temp": 31.4, "attached": False, "exhaust": 0.0, "inner": 0.0} + + def test_a_printer_with_only_the_sensor(self, client, printer): + printer.status = {"temperature_sensor cavity": CAVITY} + assert client.get("/api/print-control").json()["enclosure"] == { + "temp": 31.4, "attached": None, "exhaust": None, "inner": None} + + def test_a_printer_with_neither_has_no_enclosure(self, client, printer): + printer.status = {"toolhead": {"extruder": "extruder"}} + assert client.get("/api/print-control").json()["enclosure"] is None + + def test_malformed_objects_do_not_crash_the_readback(self, client, printer): + printer.status = {"purifier": {"exhaust_fan": None, "inner_fan": "x"}, + "temperature_sensor cavity": {}} + j = client.get("/api/print-control").json()["enclosure"] + assert j["temp"] is None and j["attached"] is False + assert j["exhaust"] == 0.0 and j["inner"] == 0.0 + + def test_enclosure_off_sends_the_stock_off_command(self, client, printer): + printer.status = {"purifier": PURIFIER_OBJ} + j = post(client, "enclosure_off").json() + assert printer.sent == ["SET_PURIFIER_MODE MODE=0"] + assert j["ok"] is True and j["applied"] is None + + def test_enclosure_off_ignores_any_value(self, client, printer): + printer.status = {"purifier": PURIFIER_OBJ} + post(client, "enclosure_off", 3) + assert printer.sent == ["SET_PURIFIER_MODE MODE=0"] + + def test_a_printer_without_the_purifier_is_refused_and_sends_nothing( + self, client, printer): + printer.status = {"toolhead": {"extruder": "extruder"}} + r = post(client, "enclosure_off") + assert r.status_code == 409 and printer.sent == [] + + def test_there_is_no_verb_that_starts_the_fans(self, client, printer): + printer.status = {"purifier": PURIFIER_OBJ} + for verb in ("enclosure_on", "exhaust", "inner_fan", "purifier"): + assert post(client, verb, 100).status_code == 400 + assert printer.sent == [] + + class TestQueued: def test_a_read_timeout_is_queued_not_an_error(self, client, printer): # A swap holds Klipper's queue: the command runs later, so a second @@ -346,7 +410,7 @@ def test_readback(self, client, printer): "print_stats": {"state": "printing", "filename": "a.gcode"}} j = client.get("/api/print-control").json() assert j == {"speed_factor": 125.0, "extrude_factor": 95.0, "fan": 50.0, - "z_offset": -0.123, "light": 75.0, "nozzle": {"temp": 201.5, "target": 210}, + "z_offset": -0.123, "light": 75.0, "enclosure": None, "nozzle": {"temp": 201.5, "target": 210}, "bed": {"temp": 59.9, "target": 60}, "progress": 43.2, "message": "hi", "state": "printing", "filename": "a.gcode"} From e434825d8054f504056f6eed7ffe8edcdbee285a Mon Sep 17 00:00:00 2001 From: stefanodangelo <61756862+stefanodangelo@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:14:01 +0200 Subject: [PATCH 09/16] feat: add Restart Firmware and Restart Printer to the Control tab POST /api/restart takes one of two fixed behaviours (firmware -> /printer/firmware_restart, printer -> /machine/reboot). It is refused while a print is running or paused, read fresh from Moonraker rather than from the homing cache, and refused under MULTIACE_MANAGED, where the platform owns restarts. A downed Klipper (503) does not block a firmware restart, which is when it is needed; an unreachable Moonraker does, rather than guessing. A connection that drops mid-request counts as sent. In the UI both buttons ask for confirmation, are disabled with an explanation while a print runs, are not rendered on a managed install, and the poll errors that follow a restart are held back until the printer answers again. Also: the webcam lookup failed once at first load with an empty error message and then stayed on "Camera unavailable" until Reload was pressed. The error now names the exception, and the tab looks again every ~10 s while no camera is found. --- multiace/i18n/de.json | 11 +++ multiace/i18n/en.json | 11 +++ multiace/i18n/zh.json | 11 +++ multiace/web/backend/main.py | 56 ++++++++++- multiace/web/frontend/app.js | 63 ++++++++++++- multiace/web/frontend/index.html | 10 ++ multiace/web/frontend/style.css | 3 + tests/unit/test_restart.py | 156 +++++++++++++++++++++++++++++++ tests/unit/test_webcam.py | 8 ++ 9 files changed, 325 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_restart.py diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json index 2b53c334..b26eb3e6 100644 --- a/multiace/i18n/de.json +++ b/multiace/i18n/de.json @@ -11,6 +11,16 @@ "offline": "offline", "ws_error": "WS-Fehler" }, + "restart": { + "firmware_btn": "Firmware neu starten", + "firmware_title": "Firmware neu starten?", + "firmware_msg": "Führt FIRMWARE_RESTART aus: Klipper lädt seine Konfiguration neu und verbindet sich erneut mit den MCUs. Ein laufender Druck würde unterbrochen.", + "firmware_sent": "Firmware-Neustart angefordert.", + "printer_btn": "Drucker neu starten", + "printer_title": "Drucker neu starten?", + "printer_msg": "Startet den Bordcomputer des Druckers neu. Langsamer als ein Firmware-Neustart und unterbricht jeden laufenden Druck.", + "printer_sent": "Drucker-Neustart angefordert." + }, "control": { "webcam": "Webcam", "webcam_loading": "Suche Kamera…", @@ -40,6 +50,7 @@ "exhaust": "Abluft", "inner": "Innen", "fans_off": "Lüfter aus", + "restart_blocked": "Gesperrt, solange ein Druck läuft", "light": "Licht", "on": "An", "off": "Aus", diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json index 4117825f..1c6e7444 100644 --- a/multiace/i18n/en.json +++ b/multiace/i18n/en.json @@ -11,6 +11,16 @@ "offline": "offline", "ws_error": "WS error" }, + "restart": { + "firmware_btn": "Restart Firmware", + "firmware_title": "Restart the firmware?", + "firmware_msg": "Runs FIRMWARE_RESTART: Klipper reloads its config and reconnects to the MCUs. A print in progress would be interrupted.", + "firmware_sent": "Firmware restart requested.", + "printer_btn": "Restart Printer", + "printer_title": "Restart the printer?", + "printer_msg": "Reboots the printer's onboard computer. Slower than a firmware restart and interrupts any running print.", + "printer_sent": "Printer restart requested." + }, "control": { "webcam": "Webcam", "webcam_loading": "Looking for a camera…", @@ -40,6 +50,7 @@ "exhaust": "Exhaust", "inner": "Inner", "fans_off": "Fans off", + "restart_blocked": "Blocked while a print is running", "light": "Light", "on": "On", "off": "Off", diff --git a/multiace/i18n/zh.json b/multiace/i18n/zh.json index 75e8b190..bdbf35b1 100644 --- a/multiace/i18n/zh.json +++ b/multiace/i18n/zh.json @@ -11,6 +11,16 @@ "offline": "离线", "ws_error": "WebSocket 错误" }, + "restart": { + "firmware_btn": "重启固件", + "firmware_title": "重启固件?", + "firmware_msg": "执行 FIRMWARE_RESTART:Klipper 将重新加载配置并重新连接 MCU。正在进行的打印会被中断。", + "firmware_sent": "已请求固件重启。", + "printer_btn": "重启打印机", + "printer_title": "重启打印机?", + "printer_msg": "重启打印机的主板电脑。比固件重启更慢,并会中断任何正在进行的打印。", + "printer_sent": "已请求打印机重启。" + }, "control": { "webcam": "摄像头", "webcam_loading": "正在查找摄像头…", @@ -40,6 +50,7 @@ "exhaust": "排风", "inner": "内循环", "fans_off": "关闭风扇", + "restart_blocked": "打印进行中,已禁用", "light": "灯光", "on": "开", "off": "关", diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py index c590b3c1..fbb8f81a 100644 --- a/multiace/web/backend/main.py +++ b/multiace/web/backend/main.py @@ -2176,7 +2176,8 @@ async def _resolve_webcam() -> dict: try: listing = await _mr_get("/server/webcams/list") except Exception as e: - return {"available": False, "reason": f"moonraker: {e}"} + # httpx timeouts have an empty message: name the error then. + return {"available": False, "reason": f"moonraker: {str(e) or type(e).__name__}"} cams = (listing.get("result") or {}).get("webcams") or [] for cam in cams: name = str(cam.get("name", "")) @@ -2768,6 +2769,59 @@ async def print_control_limits() -> dict: "used": round(_babystep_state["total"], 3)}, } +#: Restarts the Control tab can ask for: a fixed list, each one a single +#: Moonraker call. Klipper's firmware restart reloads its config and +#: reconnects the MCUs; the printer restart reboots the onboard computer. +RESTART_ACTIONS = {"firmware": "/printer/firmware_restart", + "printer": "/machine/reboot"} + +class RestartRequest(BaseModel): + behavior: str + +async def _is_printing() -> bool: + """True while a print is running or paused. Fresh from Moonraker, never + the homing cache: a stale "idle" is how a restart kills a print. + + 503 means Klipper is down. That is exactly when a firmware restart is + needed, and nothing is printing then, so it must not block it.""" + try: + data = await _mr_get("/printer/objects/query?print_stats") + except httpx.HTTPStatusError as e: + if e.response.status_code == 503: + return False + raise HTTPException(502, f"moonraker: {e}") + except httpx.HTTPError as e: + raise HTTPException(502, f"moonraker: {e}") + state = (((data.get("result") or {}).get("status") or {}) + .get("print_stats") or {}).get("state") + return state in ("printing", "paused") + +@app.post("/api/restart") +async def restart(request: Request, req: RestartRequest) -> dict: + """Restart the firmware or the printer: a fixed behaviour, refused while + a print is running and when the platform manages the install.""" + behavior = (req.behavior or "").strip().lower() + if behavior not in RESTART_ACTIONS: + raise HTTPException(400, f"unknown restart behavior: {behavior}") + if MULTIACE_MANAGED: + raise HTTPException(409, "restarts are managed by the platform.") + if _mock_enabled(request): + return {"ok": True, "behavior": behavior, "mock": True} + if await _is_printing(): + raise HTTPException(409, "blocked while a print is running") + try: + await _mr_post(RESTART_ACTIONS[behavior], {}, timeout=15.0) + except httpx.HTTPStatusError as e: + raise HTTPException(e.response.status_code, + f"moonraker: {e.response.text}") + except (httpx.ReadTimeout, httpx.ReadError, httpx.RemoteProtocolError): + # The request reached Moonraker, and the machine going down under + # it is what a restart looks like from here. + pass + except httpx.HTTPError as e: + raise HTTPException(502, f"moonraker: {e}") + return {"ok": True, "behavior": behavior} + @app.post("/api/upload-and-print") async def upload_and_print(file: UploadFile = File(...)) -> dict: diff --git a/multiace/web/frontend/app.js b/multiace/web/frontend/app.js index e0d97f72..3154b226 100644 --- a/multiace/web/frontend/app.js +++ b/multiace/web/frontend/app.js @@ -7432,7 +7432,9 @@ createApp({ if (webcam.available && webcam.webrtc) startWebrtc(); else loadWebcam(); } + let webcamLoading = false; async function loadWebcam() { + webcamLoading = true; try { const r = await fetch(`${API}/webcam/info`); const j = r.ok ? await r.json() : {available: false, reason: `HTTP ${r.status}`}; @@ -7447,6 +7449,7 @@ createApp({ webcam.available = false; webcam.reason = String(e); } finally { + webcamLoading = false; webcam.loaded = true; webcam.nonce++; if (controlShown.value && webcam.available && webcam.webrtc) startWebrtc(); @@ -7497,15 +7500,25 @@ createApp({ async function loadPrintControl() { try { const r = await fetch(`${API}/print-control`); - if (!r.ok) { printCtlError.value = `HTTP ${r.status}`; return; } + if (!r.ok) { + if (printCtlRestarting.value) restartSawFail = true; + else printCtlError.value = `HTTP ${r.status}`; + return; + } const pc = await r.json(); printCtlError.value = ""; + if (printCtlRestarting.value && restartSawFail) { + // The printer has answered again after the restart. + printCtlRestarting.value = false; + printCtlNotice.value = ""; + } Object.assign(printCtl, pc); if (printCtlDragging.value !== "speed") printCtlDraft.speed = pc.speed_factor; if (printCtlDragging.value !== "flow") printCtlDraft.flow = pc.extrude_factor; if (printCtlDragging.value !== "fan") printCtlDraft.fan = pc.fan; } catch (e) { - printCtlError.value = e.message || String(e); + if (printCtlRestarting.value) restartSawFail = true; + else printCtlError.value = e.message || String(e); } } async function loadPrintControlLimits() { @@ -7575,6 +7588,44 @@ createApp({ if (Number.isFinite(v)) sendPrintControl(verb, v); ev.target.value = ""; } + // Restart Firmware / Restart Printer. Refused by the server while a + // print runs and under a managed install; the buttons mirror that. After + // a restart the printer is unreachable for a while, so poll errors are + // expected and are not shown until it has answered again. + const printCtlRestarting = ref(false); + let restartSawFail = false; + const printCtlRestartBlocked = computed(() => + printCtl.state === "printing" || printCtl.state === "paused" || printCtlBusy.value !== ""); + function printCtlRestart(behavior) { + confirm({ + title: t(`ui.restart.${behavior}_title`), + message: t(`ui.restart.${behavior}_msg`), + okLabel: t(`ui.restart.${behavior}_btn`), + onOk: async () => { + printCtlBusy.value = "restart"; + printCtlError.value = ""; + try { + const r = await fetch(`${API}/restart`, { + method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify({behavior}), + }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.detail || `${r.status}`); + printCtlNotice.value = t(`ui.restart.${behavior}_sent`); + printCtlRestarting.value = true; + restartSawFail = false; + setTimeout(() => { + printCtlRestarting.value = false; + printCtlNotice.value = ""; + }, 120000); + } catch (e) { + printCtlError.value = e.message || String(e); + } finally { + printCtlBusy.value = ""; + } + }, + }); + } function printCtlCancel() { confirm({ title: t("ui.control.cancel_title"), @@ -7623,9 +7674,15 @@ createApp({ // Poll the printer only while the Control tab is showing. const CONTROL_POLL_MS = 2000; let controlTimer = null; + let webcamRetryTick = 0; function controlPoll() { loadPrintControl(); loadConsole(); + // No camera yet (Moonraker was slow, or it was added later): look again + // every ~10 s while the tab is open instead of waiting for a click. + if (webcam.loaded && !webcam.available && !webcamLoading && ++webcamRetryTick % 5 === 0) { + loadWebcam(); + } } watch(controlShown, (shown) => { clearInterval(controlTimer); @@ -7799,7 +7856,7 @@ createApp({ navItems, topItems, subItems, topActive, openNav, webcam, webcamSrc, webcamVideo, reloadWebcam, printCtl, printCtlLimits, printCtlDraft, printCtlBusy, printCtlError, printCtlCan, - printCtlNotice, printCtlQueued, + printCtlNotice, printCtlQueued, printCtlRestart, printCtlRestartBlocked, printCtlSlide, printCtlRelease, printCtlReset, printCtlHeat, printCtlCancel, sendPrintControl, consoleLines, consoleFollow, consoleEl, loadConsole, consoleTime, diff --git a/multiace/web/frontend/index.html b/multiace/web/frontend/index.html index 27bbc219..f3ae065d 100644 --- a/multiace/web/frontend/index.html +++ b/multiace/web/frontend/index.html @@ -1497,6 +1497,16 @@

{{ t('ui.control.print') }}

+ +
+ + +
diff --git a/multiace/web/frontend/style.css b/multiace/web/frontend/style.css index 62955f83..8cb57b96 100644 --- a/multiace/web/frontend/style.css +++ b/multiace/web/frontend/style.css @@ -1997,6 +1997,9 @@ body:has(.panel) .overlay .dialog { .pc-switch[aria-checked="true"] .pc-knob { transform: translateX(36px); } .pc-actions { gap: .4rem; flex-wrap: wrap; } .pc-actions button { flex: 1; } +.pc-restart { gap: .4rem; flex-wrap: wrap; margin-top: .9rem; padding-top: .7rem; + border-top: 1px solid var(--border); } +.pc-restart button { flex: 1; } .console-log { height: 14rem; overflow-y: auto; padding: .4rem .5rem; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: .8rem; } diff --git a/tests/unit/test_restart.py b/tests/unit/test_restart.py new file mode 100644 index 00000000..d55d4ae7 --- /dev/null +++ b/tests/unit/test_restart.py @@ -0,0 +1,156 @@ +"""Restart Firmware / Restart Printer: a fixed list of behaviours, refused +while a print runs and under a managed install. + +Moonraker is replaced by stubs; the calls the endpoint would make are +captured instead. +""" +import httpx +import pytest + +from .test_mock_mode import _load_main, mock_client # noqa: F401 + + +@pytest.fixture +def main(monkeypatch, tmp_path): + return _load_main(monkeypatch, tmp_path, mock=False) + + +@pytest.fixture +def printer(main, monkeypatch): + class P: + state = "standby" + posts = [] + get_error = None + post_error = None + P.posts = [] + + async def fake_get(path): + assert path == "/printer/objects/query?print_stats" + if P.get_error: + raise P.get_error + return {"result": {"status": {"print_stats": {"state": P.state}}}} + + async def fake_post(path, body=None, timeout=30.0): + if P.post_error: + raise P.post_error + P.posts.append(path) + return {"result": "ok"} + + monkeypatch.setattr(main, "_mr_get", fake_get) + monkeypatch.setattr(main, "_mr_post", fake_post) + return P + + +@pytest.fixture +def client(main): + from fastapi.testclient import TestClient + with TestClient(main.app) as c: + yield c + + +def restart(client, behavior): + return client.post("/api/restart", json={"behavior": behavior}) + + +def status_error(code): + req = httpx.Request("GET", "http://x") + return httpx.HTTPStatusError("e", request=req, response=httpx.Response(code, request=req)) + + +class TestBehaviours: + @pytest.mark.parametrize("behavior,path", [ + ("firmware", "/printer/firmware_restart"), + ("printer", "/machine/reboot"), + ]) + def test_each_behaviour_is_one_moonraker_call(self, client, printer, behavior, path): + r = restart(client, behavior) + assert r.status_code == 200 and r.json() == {"ok": True, "behavior": behavior} + assert printer.posts == [path] + + def test_behaviour_is_case_and_space_insensitive(self, client, printer): + assert restart(client, " Firmware ").status_code == 200 + assert printer.posts == ["/printer/firmware_restart"] + + @pytest.mark.parametrize("behavior", ["", "klipper", "reboot", "poweroff", + "/machine/shutdown", "firmware; x"]) + def test_anything_else_is_refused_and_sends_nothing(self, client, printer, behavior): + assert restart(client, behavior).status_code == 400 + assert printer.posts == [] + + +class TestPrintGuard: + @pytest.mark.parametrize("state", ["printing", "paused"]) + @pytest.mark.parametrize("behavior", ["firmware", "printer"]) + def test_blocked_while_a_print_is_running_or_paused( + self, client, printer, state, behavior): + printer.state = state + r = restart(client, behavior) + assert r.status_code == 409 and "print" in r.json()["detail"] + assert printer.posts == [] + + @pytest.mark.parametrize("state", ["standby", "complete", "cancelled", "error"]) + def test_allowed_when_nothing_is_printing(self, client, printer, state): + printer.state = state + assert restart(client, "firmware").status_code == 200 + + def test_a_downed_klipper_does_not_block_a_firmware_restart(self, client, printer): + # 503: Klipper is not running, which is exactly when it is needed. + printer.get_error = status_error(503) + assert restart(client, "firmware").status_code == 200 + assert printer.posts == ["/printer/firmware_restart"] + + def test_an_unreachable_moonraker_blocks_it_rather_than_guessing(self, client, printer): + printer.get_error = httpx.ConnectError("down") + assert restart(client, "printer").status_code == 502 + assert printer.posts == [] + + def test_another_error_status_blocks_it(self, client, printer): + printer.get_error = status_error(500) + assert restart(client, "printer").status_code == 502 + assert printer.posts == [] + + def test_the_state_is_read_fresh_not_from_the_homing_cache(self, main, client, printer): + main._LAST_PRINT_STATUS = {"print_stats": {"state": "standby"}} + main._LAST_PRINT_STATUS_TS = 9e12 + printer.state = "printing" + assert restart(client, "firmware").status_code == 409 + + +class TestManaged: + def test_refused_when_the_platform_owns_restarts(self, main, client, printer, monkeypatch): + monkeypatch.setattr(main, "MULTIACE_MANAGED", True) + r = restart(client, "firmware") + assert r.status_code == 409 and "managed" in r.json()["detail"] + assert printer.posts == [] + + +class TestTransport: + @pytest.mark.parametrize("err", [httpx.ReadTimeout("t"), httpx.ReadError("r"), + httpx.RemoteProtocolError("p")]) + def test_a_dropped_connection_is_what_a_restart_looks_like(self, client, printer, err): + printer.post_error = err + assert restart(client, "printer").status_code == 200 + + def test_a_connection_that_never_opened_is_a_failure(self, client, printer): + printer.post_error = httpx.ConnectError("no route") + assert restart(client, "firmware").status_code == 502 + + def test_a_moonraker_error_status_is_passed_on(self, client, printer): + printer.post_error = status_error(500) + assert restart(client, "firmware").status_code == 500 + + +class TestMockMode: + def test_never_reaches_moonraker(self, mock_client, monkeypatch): + import sys + m = sys.modules["main"] + + async def boom(*a, **k): + raise AssertionError("mock mode must not contact Moonraker") + monkeypatch.setattr(m, "_mr_get", boom) + monkeypatch.setattr(m, "_mr_post", boom) + r = restart(mock_client, "printer") + assert r.status_code == 200 and r.json()["mock"] is True + + def test_unknown_behaviours_are_refused_in_mock_mode_too(self, mock_client): + assert restart(mock_client, "poweroff").status_code == 400 diff --git a/tests/unit/test_webcam.py b/tests/unit/test_webcam.py index 4b85424d..62df6b08 100644 --- a/tests/unit/test_webcam.py +++ b/tests/unit/test_webcam.py @@ -70,6 +70,14 @@ async def boom(path): info = asyncio.run(main._resolve_webcam()) assert info["available"] is False and "moonraker" in info["reason"] + def test_a_timeout_with_an_empty_message_still_says_what_failed( + self, main, monkeypatch): + async def boom(path): + raise httpx.ReadTimeout("") + monkeypatch.setattr(main, "_mr_get", boom) + info = asyncio.run(main._resolve_webcam()) + assert info["reason"] == "moonraker: ReadTimeout" + class TestEndpoints: def _client(self, main): From f67e39382828bb1af8e82ec9a7ff9dcf5941c08d Mon Sep 17 00:00:00 2001 From: stefanodangelo <61756862+stefanodangelo@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:19:04 +0200 Subject: [PATCH 10/16] test: make two timing-dependent tests deterministic Found under load: the console seed test recorded its "early" line in the same clock tick as the request start, and the homing-gate test wrote a flag whose mtime could read slightly ahead of time.time() on Windows, so it counted as stale. Neither was a logic error in the code under test. --- tests/unit/test_console.py | 2 ++ tests/unit/test_print_control.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_console.py b/tests/unit/test_console.py index d44dc2a8..5caa22bd 100644 --- a/tests/unit/test_console.py +++ b/tests/unit/test_console.py @@ -1,5 +1,6 @@ """Console ring buffer and the read-only /api/console-logs endpoint.""" import asyncio +import time import httpx import pytest @@ -77,6 +78,7 @@ class TestSeedWithLiveLines: def test_history_is_still_loaded_and_not_duplicated(self, main, monkeypatch): main._record_console_line("// early live line") # also in the history + time.sleep(0.05) # a clearly earlier clock tick than the request async def fake(path): main._record_console_line("// arrived during the request") diff --git a/tests/unit/test_print_control.py b/tests/unit/test_print_control.py index 2998f851..483f5701 100644 --- a/tests/unit/test_print_control.py +++ b/tests/unit/test_print_control.py @@ -355,8 +355,11 @@ def homing(self, main, tmp_path, monkeypatch): monkeypatch.setattr(main, "_GATE_WAIT_MAX", 0.0) def on(): + # Fresh, but safely in the past: a file mtime can read a little + # ahead of time.time() on Windows, which would count as stale. flag.write_text("1") - os.utime(flag, None) + past = time.time() - 0.2 + os.utime(flag, (past, past)) def off(): if flag.exists(): From 9fdc2ee6f3eee3c73a5873eed8b24cfa56bfd582 Mon Sep 17 00:00:00 2001 From: stefanodangelo <61756862+stefanodangelo@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:27:25 +0200 Subject: [PATCH 11/16] fix: heater ceiling only, base material names, babystep reset on print start - Cap only the top of the nozzle target (max_temp and the material maximum). A target below the material's printing range is a standby temperature set on purpose; it used to be lifted to the floor (PLA 150 became 170, PC 200 became 240). 0 still passes as off. - Match the material ceiling on the base name before the first "-", so PLA-CF, PETG-CF and PETG-HF get their base material's ceiling instead of only max_temp. A name with no entry still gets max_temp alone. - The Z babystep allowance now resets when a print starts, from the websocket's print-state change (pause and resume is the same print), and no longer depends on the filename, so printing the same file again starts over. --- multiace/web/backend/main.py | 50 +++++++++++------- tests/unit/test_print_control.py | 87 +++++++++++++++++++++++++++----- 2 files changed, 106 insertions(+), 31 deletions(-) diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py index fbb8f81a..d9126377 100644 --- a/multiace/web/backend/main.py +++ b/multiace/web/backend/main.py @@ -2453,16 +2453,27 @@ async def console_logs(request: Request, lines: int = 100, "PA": (240.0, 300.0), "HIPS": (220.0, 250.0), } +def _material_max(name: str): + """Highest nozzle temperature for a material, or None when unknown. + + Variants share their base material's ceiling: PLA-CF, PETG-HF, ABS-GF. + A name with no entry (PA12, a vendor string) gets no material ceiling, + only Klipper's max_temp.""" + key = str(name or "").strip().upper() + mat = MATERIAL_TEMP_LIMITS.get(key) or MATERIAL_TEMP_LIMITS.get(key.split("-")[0]) + return mat[1] if mat else None + _EXTRUDERS = ["extruder"] + ["extruder%d" % i for i in range(1, 4)] class PrintControl(BaseModel): verb: str value: float | None = None -#: Cumulative babystep for the current job, reset on a job change rather -#: than on page reload: a reload must not hand back the other half of the -#: allowance. -_babystep_state: dict = {"job": None, "total": 0.0} +#: Cumulative babystep for the current print. Reset when a print starts (see +#: _on_status_update), not on page reload: a reload must not hand back the +#: other half of the allowance, and printing the same file again is a new +#: print. +_babystep_state: dict = {"total": 0.0} def _clamp(value: float, lo: float, hi: float) -> float: return max(lo, min(hi, float(value))) @@ -2582,9 +2593,15 @@ async def _klipper_settings() -> dict: return settings async def _heater_limits(which: str, extruder: str = "extruder") -> tuple[float, float]: - """(min, max) for a heater: Klipper's configured limits min()-ed with - the material range of the ACTIVE head. Read from Moonraker's config, - never hardcoded: a different hotend has different limits.""" + """(min, max) for a heater: Klipper's configured limits, with the + nozzle ceiling lowered to the material maximum of the ACTIVE head. Read + from Moonraker's config, never hardcoded: a different hotend has + different limits. + + Only the top is capped. A target below the material's printing range is + a standby temperature someone typed on purpose, and lifting it to the + floor would heat the nozzle more than asked, the same way 0 passes as + "off".""" lo, hi = DEFAULT_HEATER_LIMITS.get(which, (0.0, 250.0)) section = extruder if which == "nozzle" else "heater_bed" try: @@ -2607,10 +2624,9 @@ async def _heater_limits(which: str, extruder: str = "extruder") -> tuple[float, for th in parsed.get("toolheads") or []: if th.get("idx") != head or not th.get("filament_detected"): continue - mat = MATERIAL_TEMP_LIMITS.get( - str(th.get("material") or "").strip().upper()) - if mat: - lo, hi = max(lo, mat[0]), min(hi, mat[1]) + ceiling = _material_max(th.get("material")) + if ceiling is not None: + hi = min(hi, ceiling) except Exception: pass return lo, hi @@ -2618,14 +2634,6 @@ async def _heater_limits(which: str, extruder: str = "extruder") -> tuple[float, async def _babystep_allowance(delta: float) -> tuple[float, str]: """Clamp one babystep against the per-press and per-job limits.""" step = _clamp(delta, -BABYSTEP_MAX_STEP_MM, BABYSTEP_MAX_STEP_MM) - try: - ps = (await _print_status()).get("print_stats") or {} - job = ps.get("filename") or ps.get("state") - except Exception: - job = None - if _babystep_state["job"] != job: - _babystep_state["job"] = job - _babystep_state["total"] = 0.0 total = _babystep_state["total"] + step if total < -BABYSTEP_MAX_TOTAL_MM: return 0.0, (f"-{BABYSTEP_MAX_TOTAL_MM:.2f} mm reached - " @@ -5317,6 +5325,10 @@ async def _on_status_update(params: list) -> None: if not st or st == _print_state_last: return prev, _print_state_last = _print_state_last, st + if st == "printing" and prev != "paused": + # A print starts (resuming from a pause is the same print): the Z + # babystep allowance starts over. + _babystep_state["total"] = 0.0 if st == "paused" and prev == "printing": # A spool is only ever swapped with the print paused, and pausing is # also when Klipper releases the binding of a slot whose gate ran diff --git a/tests/unit/test_print_control.py b/tests/unit/test_print_control.py index 483f5701..7a7eb08a 100644 --- a/tests/unit/test_print_control.py +++ b/tests/unit/test_print_control.py @@ -3,6 +3,7 @@ Moonraker is replaced by stubs for `_mr_get` / `_mr_post`; every G-code line the endpoint would send is captured instead. """ +import asyncio import os import time @@ -20,7 +21,7 @@ @pytest.fixture def main(monkeypatch, tmp_path): m = _load_main(monkeypatch, tmp_path, mock=False) - m._babystep_state.update(job=None, total=0.0) + m._babystep_state.update(total=0.0) return m @@ -132,11 +133,37 @@ def test_bed_uses_its_own_limit(self, client, printer): post(client, "bed", 200) assert printer.sent == ["SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=110"] - def test_loaded_material_tightens_the_nozzle_ceiling(self, client, printer): + def test_loaded_material_lowers_the_nozzle_ceiling(self, client, printer): printer.toolheads = [{"idx": 0, "filament_detected": True, "material": "PLA"}] - j = post(client, "nozzle", 300).json() - assert j["applied"] == 230 - assert post(client, "nozzle", 100).json()["applied"] == 170 + assert post(client, "nozzle", 300).json()["applied"] == 230 + + @pytest.mark.parametrize("material,value", [("PLA", 150), ("PC", 200), + ("PETG", 100), ("PLA", 1)]) + def test_a_target_below_the_material_floor_is_not_raised( + self, client, printer, material, value): + # A standby temperature someone typed on purpose: only the top is + # capped, the same way 0 passes as "off". + printer.toolheads = [{"idx": 0, "filament_detected": True, "material": material}] + assert post(client, "nozzle", value).json()["applied"] == value + assert printer.sent[-1] == f"SET_HEATER_TEMPERATURE HEATER=extruder TARGET={value}" + + @pytest.mark.parametrize("material,ceiling", [ + ("PLA", 230), ("pla", 230), ("PLA+", 240), ("PETG", 260), ("PC", 300), + # variants share the ceiling of their base material + ("PLA-CF", 230), ("pla-cf", 230), ("PETG-CF", 260), ("PETG-HF", 260), + ("ABS-GF", 270), ("PLA+-CF", 240), + # no entry: only Klipper's max_temp (300) applies + ("PA12", 300), ("FOO", 300), ("", 300), (None, 300)]) + def test_material_ceiling_matches_the_base_name( + self, client, printer, material, ceiling): + printer.toolheads = [{"idx": 0, "filament_detected": True, "material": material}] + assert post(client, "nozzle", 999).json()["applied"] == ceiling + + def test_material_max_helper(self, main): + assert main._material_max("PETG-HF") == 260.0 + assert main._material_max(" petg ") == 260.0 + assert main._material_max("PLA+") == 240.0 + assert main._material_max("nope") is None and main._material_max(None) is None @pytest.mark.parametrize("value", [0, -10]) def test_zero_is_off_whatever_the_material_floor(self, client, printer, value): @@ -149,12 +176,12 @@ def test_unloaded_heads_do_not_count(self, client, printer): assert post(client, "nozzle", 300).json()["applied"] == 300 def test_other_heads_materials_do_not_count(self, client, printer): - # PLA on the active head 0 and PETG on head 1: the range is PLA's - # 170-230, not the 210-230 overlap of both. + # PLA on the active head 0 and PETG on head 1: the ceiling is PLA's + # 230, whatever head 1 holds. printer.toolheads = [ {"idx": 0, "filament_detected": True, "material": "PLA"}, {"idx": 1, "filament_detected": True, "material": "PETG"}] - assert post(client, "nozzle", 100).json()["applied"] == 170 + assert post(client, "nozzle", 150).json()["applied"] == 150 assert post(client, "nozzle", 999).json()["applied"] == 230 def test_mixed_materials_cannot_invert_the_range(self, client, printer): @@ -170,8 +197,8 @@ def test_the_active_head_decides(self, client, printer): printer.toolheads = [ {"idx": 0, "filament_detected": True, "material": "PLA"}, {"idx": 1, "filament_detected": True, "material": "PETG"}] - assert post(client, "nozzle", 100).json()["applied"] == 210 - assert printer.sent == ["SET_HEATER_TEMPERATURE HEATER=extruder1 TARGET=210"] + assert post(client, "nozzle", 999).json()["applied"] == 260 + assert printer.sent == ["SET_HEATER_TEMPERATURE HEATER=extruder1 TARGET=260"] def test_klipper_config_is_read_once_not_on_every_call(self, client, printer): post(client, "nozzle", 100) @@ -209,12 +236,48 @@ def test_going_back_up_frees_the_allowance(self, client, printer): assert post(client, "babystep", 0.05).json()["ok"] is True assert post(client, "babystep", -0.05).json()["ok"] is True - def test_new_job_resets_the_total(self, client, printer): + @staticmethod + def _state(main, state): + asyncio.run(main._on_status_update([{"print_stats": {"state": state}}])) + + def _use_up_the_allowance(self, client): for _ in range(10): post(client, "babystep", -0.05) - printer.status = {"print_stats": {"state": "printing", "filename": "b.gcode"}} + + def test_a_print_start_resets_the_total(self, main, client, printer): + self._state(main, "printing") + self._use_up_the_allowance(client) + assert post(client, "babystep", -0.01).json()["ok"] is False + self._state(main, "complete") + self._state(main, "printing") # the same file again assert post(client, "babystep", -0.05).json()["total_mm"] == -0.05 + def test_a_pause_and_resume_is_the_same_print(self, main, client, printer): + self._state(main, "printing") + self._use_up_the_allowance(client) + self._state(main, "paused") + self._state(main, "printing") + assert post(client, "babystep", -0.01).json()["ok"] is False + + def test_a_cancelled_print_then_a_new_one_starts_over(self, main, client, printer): + self._state(main, "printing") + self._use_up_the_allowance(client) + self._state(main, "cancelled") + self._state(main, "standby") + self._state(main, "printing") + assert post(client, "babystep", -0.05).json()["ok"] is True + + def test_the_total_is_kept_between_prints_until_one_starts(self, main, client, printer): + # Idle stepping accumulates too: the floor protects the plate then as well. + self._use_up_the_allowance(client) + assert post(client, "babystep", -0.01).json()["ok"] is False + + def test_the_filename_plays_no_part(self, main, client, printer): + printer.status = {"print_stats": {"state": "printing", "filename": "a.gcode"}} + self._use_up_the_allowance(client) + printer.status = {"print_stats": {"state": "printing", "filename": "b.gcode"}} + assert post(client, "babystep", -0.01).json()["ok"] is False + class TestLight: HAS_LED = {"toolhead": {"extruder": "extruder"}, From d72cc70c816533823387ca569ae413ce1e8f3b9e Mon Sep 17 00:00:00 2001 From: stefanodangelo <61756862+stefanodangelo@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:28:05 +0200 Subject: [PATCH 12/16] feat: drive the enclosure fans directly and show the active mode Using what stock purifier.py (2.0.0) offers: - Fans off now stops both fans at once (MODE=0 with EXHAUST_FAN_DELAY_OFF=0 and INNER_FAN_DELAY_OFF=0). Plain MODE=0 only starts stock's delayed turn-off (180 s by default). - Two sliders set the exhaust and inner fan directly (SET_PURIFIER FAN=... SPEED=0..1, clamped as a percentage). The server refuses it unless an enclosure is attached, no print is running (the mode usually comes from the sliced file) and stock is not running a mode (it drives the fans itself, and in cool mode dynamically, so a direct speed would compete). The sliders are disabled with the reason. Fans off stays available in a mode: it is the way out. - The active mode and its target temperature are read back and shown. The modes themselves (cool, preheat, hot chamber) are not driven from here: they take extra parameters and stock sets them during a print. None of this could be run against an enclosure; it is covered by unit tests and mock mode. --- multiace/i18n/de.json | 10 +++- multiace/i18n/en.json | 10 +++- multiace/i18n/zh.json | 10 +++- multiace/web/backend/main.py | 48 +++++++++++++-- multiace/web/frontend/app.js | 27 ++++++++- multiace/web/frontend/index.html | 28 ++++++--- multiace/web/frontend/style.css | 3 + tests/fixtures/mock_print_control.json | 2 +- tests/unit/test_print_control.py | 83 +++++++++++++++++++++++--- 9 files changed, 192 insertions(+), 29 deletions(-) diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json index b26eb3e6..7bd69bca 100644 --- a/multiace/i18n/de.json +++ b/multiace/i18n/de.json @@ -47,9 +47,15 @@ "console_follow": "Folgen", "chamber": "Kammer", "enclosure_fans": "Gehäuse-Lüfter", - "exhaust": "Abluft", - "inner": "Innen", "fans_off": "Lüfter aus", + "exhaust": "Abluft-Lüfter", + "inner": "Innen-Lüfter", + "mode_0": "Leerlauf", + "mode_1": "Kammer kühlen", + "mode_2": "Kammer vorheizen", + "mode_3": "Heiße Kammer", + "enc_locked_print": "Nicht während eines Drucks: der Gehäusemodus kommt aus der Slicer-Datei.", + "enc_locked_mode": "Das Gehäuse läuft in einem Modus und steuert seine Lüfter selbst. Zuerst Lüfter aus.", "restart_blocked": "Gesperrt, solange ein Druck läuft", "light": "Licht", "on": "An", diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json index 1c6e7444..29ad8533 100644 --- a/multiace/i18n/en.json +++ b/multiace/i18n/en.json @@ -47,9 +47,15 @@ "console_follow": "Follow", "chamber": "Chamber", "enclosure_fans": "Enclosure fans", - "exhaust": "Exhaust", - "inner": "Inner", "fans_off": "Fans off", + "exhaust": "Exhaust fan", + "inner": "Inner fan", + "mode_0": "Idle", + "mode_1": "Cooling chamber", + "mode_2": "Preheating chamber", + "mode_3": "Hot chamber", + "enc_locked_print": "Not during a print: the enclosure mode comes from the sliced file.", + "enc_locked_mode": "The enclosure is running a mode and drives its fans itself. Use Fans off first.", "restart_blocked": "Blocked while a print is running", "light": "Light", "on": "On", diff --git a/multiace/i18n/zh.json b/multiace/i18n/zh.json index bdbf35b1..f2f13cc3 100644 --- a/multiace/i18n/zh.json +++ b/multiace/i18n/zh.json @@ -47,9 +47,15 @@ "console_follow": "跟随", "chamber": "腔体", "enclosure_fans": "机箱风扇", - "exhaust": "排风", - "inner": "内循环", "fans_off": "关闭风扇", + "exhaust": "排风风扇", + "inner": "内循环风扇", + "mode_0": "空闲", + "mode_1": "腔体降温", + "mode_2": "腔体预热", + "mode_3": "高温腔体", + "enc_locked_print": "打印期间不可用:机箱模式由切片文件决定。", + "enc_locked_mode": "机箱正在运行某个模式并自行控制风扇。请先点击“关闭风扇”。", "restart_blocked": "打印进行中,已禁用", "light": "灯光", "on": "开", diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py index d9126377..0fa9f810 100644 --- a/multiace/web/backend/main.py +++ b/multiace/web/backend/main.py @@ -2420,6 +2420,8 @@ async def console_logs(request: Request, lines: int = 100, "flow": (75.0, 125.0), "fan": (0.0, 100.0), "light": (0.0, 100.0), + "exhaust": (0.0, 100.0), + "inner": (0.0, 100.0), } #: The printer's own chamber light: a single white channel, driven with @@ -2539,9 +2541,13 @@ def _enclosure_state(status: dict): if not isinstance(sensor, dict) and not isinstance(purifier, dict): return None out = {"temp": sensor.get("temperature") if isinstance(sensor, dict) else None, - "attached": None, "exhaust": None, "inner": None} + "attached": None, "exhaust": None, "inner": None, + "mode": None, "desired_temp": None} if isinstance(purifier, dict): out["attached"] = bool(purifier.get("power_detected")) + mode = purifier.get("mode") + out["mode"] = mode if isinstance(mode, int) and not isinstance(mode, bool) else None + out["desired_temp"] = purifier.get("desired_chamber_temp") for key, fan in (("exhaust", "exhaust_fan"), ("inner", "inner_fan")): obj = purifier.get(fan) out[key] = _pct(obj.get("speed") if isinstance(obj, dict) else None, 0.0) @@ -2653,6 +2659,28 @@ async def _require_object(name: str, what: str) -> None: if not present: raise HTTPException(409, f"this printer has no {what}") +async def _require_enclosure_idle() -> None: + """409 unless the enclosure fans may be driven directly: an enclosure is + attached, no print is running, and stock is not running a mode. In modes + 1 to 3 stock drives the fans itself (dynamically in cool mode), so a + direct speed would compete with it, and during a print the mode usually + comes from the sliced file.""" + try: + status = await _print_status() + except httpx.HTTPError as e: + raise HTTPException(502, f"moonraker: {e}") + purifier = status.get(PURIFIER) + if not isinstance(purifier, dict): + raise HTTPException(409, "this printer has no enclosure") + if not purifier.get("power_detected"): + raise HTTPException(409, "no enclosure is attached") + if (status.get("print_stats") or {}).get("state") in ("printing", "paused"): + raise HTTPException(409, "not during a print: the enclosure mode comes " + "from the sliced file") + if purifier.get("mode") != 0: + raise HTTPException(409, "the enclosure is running a mode and drives " + "its fans itself; stop it first") + @app.get("/api/print-control") async def print_control_state(request: Request) -> dict: if _mock_enabled(request): @@ -2687,6 +2715,12 @@ async def print_control(request: Request, req: PrintControl) -> dict: # Percent in, 0-255 out: the UI thinks in percent and M106 # does not. script = f"M106 S{round(applied * 255 / 100):d}" + elif verb in ("exhaust", "inner"): + # One enclosure fan directly (stock SET_PURIFIER). SPEED=0 starts + # stock's delayed turn-off rather than stopping at once. + if not mocked: + await _require_enclosure_idle() + script = f"SET_PURIFIER FAN={verb} SPEED={applied / 100:.2f}" else: if not mocked: await _require_object(CHAMBER_LED, "chamber light") @@ -2714,14 +2748,16 @@ async def print_control(request: Request, req: PrintControl) -> dict: "total_mm": _babystep_state["total"], "detail": refused} script = f"SET_GCODE_OFFSET Z_ADJUST={applied:.3f} MOVE=1" elif verb == "enclosure_off": - # Stops the enclosure fans. This is the one enclosure command that - # stock itself sends (PRINT_END and CANCEL_PRINT), so its meaning is - # known: MODE=0 is "off". Other modes and fan speeds are not driven - # from here. + # Back to idle and both fans off NOW. Plain MODE=0 only starts stock's + # delayed turn-off (180 s by default); the two DELAY_OFF=0 parameters + # stop them at once. The other modes (cool, preheat, hot chamber) are + # not driven from here: they take extra parameters, and during a print + # stock sets them from the sliced file. applied = None if not mocked: await _require_object(PURIFIER, "enclosure") - script = "SET_PURIFIER_MODE MODE=0" + script = ("SET_PURIFIER_MODE MODE=0 EXHAUST_FAN_DELAY_OFF=0 " + "INNER_FAN_DELAY_OFF=0") elif verb in ("pause", "resume", "cancel"): applied = None script = {"pause": "PAUSE", "resume": "RESUME", diff --git a/multiace/web/frontend/app.js b/multiace/web/frontend/app.js index 3154b226..6876eece 100644 --- a/multiace/web/frontend/app.js +++ b/multiace/web/frontend/app.js @@ -7481,7 +7481,7 @@ createApp({ }); // Local slider positions. They follow the readback except while the // user is holding one. - const printCtlDraft = reactive({speed: 100, flow: 100, fan: 0}); + const printCtlDraft = reactive({speed: 100, flow: 100, fan: 0, exhaust: 0, inner: 0}); const printCtlDragging = ref(""); const printCtlBusy = ref(""); const printCtlError = ref(""); @@ -7497,6 +7497,24 @@ createApp({ resume: printCtl.state === "paused", cancel: printCtl.state === "printing" || printCtl.state === "paused", })); + // The enclosure fans may be driven directly only when stock is not: not + // during a print (the mode comes from the sliced file) and not while a + // mode runs. The server enforces both; this only explains it. + const enclosureLock = computed(() => { + const e = printCtl.enclosure; + if (printCtl.state === "printing" || printCtl.state === "paused") { + return t("ui.control.enc_locked_print"); + } + if (e && e.mode) return t("ui.control.enc_locked_mode"); + return ""; + }); + const enclosureModeText = computed(() => { + const e = printCtl.enclosure; + if (!e || e.mode === null || e.mode === undefined) return ""; + const name = t("ui.control.mode_" + e.mode); + return e.desired_temp && (e.mode === 1 || e.mode === 2) + ? `${name} · ${Math.round(e.desired_temp)} °C` : name; + }); async function loadPrintControl() { try { const r = await fetch(`${API}/print-control`); @@ -7516,6 +7534,11 @@ createApp({ if (printCtlDragging.value !== "speed") printCtlDraft.speed = pc.speed_factor; if (printCtlDragging.value !== "flow") printCtlDraft.flow = pc.extrude_factor; if (printCtlDragging.value !== "fan") printCtlDraft.fan = pc.fan; + for (const f of ["exhaust", "inner"]) { + if (pc.enclosure && pc.enclosure[f] !== null && printCtlDragging.value !== f) { + printCtlDraft[f] = pc.enclosure[f]; + } + } } catch (e) { if (printCtlRestarting.value) restartSawFail = true; else printCtlError.value = e.message || String(e); @@ -7545,6 +7568,7 @@ createApp({ if (verb === "speed") printCtlDraft.speed = j.applied; if (verb === "flow") printCtlDraft.flow = j.applied; if (verb === "fan") printCtlDraft.fan = j.applied; + if (verb === "exhaust" || verb === "inner") printCtlDraft[verb] = j.applied; } if (j.ok === false && j.detail) printCtlError.value = j.detail; if (j.queued) { @@ -7857,6 +7881,7 @@ createApp({ webcam, webcamSrc, webcamVideo, reloadWebcam, printCtl, printCtlLimits, printCtlDraft, printCtlBusy, printCtlError, printCtlCan, printCtlNotice, printCtlQueued, printCtlRestart, printCtlRestartBlocked, + enclosureLock, enclosureModeText, printCtlSlide, printCtlRelease, printCtlReset, printCtlHeat, printCtlCancel, sendPrintControl, consoleLines, consoleFollow, consoleEl, loadConsole, consoleTime, diff --git a/multiace/web/frontend/index.html b/multiace/web/frontend/index.html index f3ae065d..d87c805c 100644 --- a/multiace/web/frontend/index.html +++ b/multiace/web/frontend/index.html @@ -1462,14 +1462,26 @@

{{ t('ui.control.print') }}

-
- {{ t('ui.control.enclosure_fans') }} - {{ t('ui.control.exhaust') }} {{ Math.round(printCtl.enclosure.exhaust) }} % - · {{ t('ui.control.inner') }} {{ Math.round(printCtl.enclosure.inner) }} % - + Fans can be set directly only when stock is not driving them: the + server refuses it during a print and while a mode runs, and the + sliders say why. "Fans off" returns to idle and stops both at once. --> +
+
+ {{ t('ui.control.enclosure_fans') }} + {{ enclosureModeText }} + +
+ +

{{ enclosureLock }}