diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json
index 5e9e447b..6f2b5fcb 100644
--- a/multiace/i18n/de.json
+++ b/multiace/i18n/de.json
@@ -11,7 +11,60 @@
"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…",
+ "webcam_unavailable": "Kamera nicht verfügbar",
+ "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",
+ "chamber": "Kammer",
+ "enclosure_fans": "Gehäuse-Lüfter",
+ "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",
+ "light_locked": "Bleibt während eines Drucks an: die Fehlererkennung braucht das Licht für ihre Kameraprüfung.",
+ "on": "An",
+ "off": "Aus",
+ "queued": "Gesendet. Der Drucker ist beschäftigt (ein Filamentwechsel kann Minuten dauern) und führt es danach aus."
+ },
"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..69477920 100644
--- a/multiace/i18n/en.json
+++ b/multiace/i18n/en.json
@@ -11,7 +11,60 @@
"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…",
+ "webcam_unavailable": "Camera unavailable",
+ "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",
+ "chamber": "Chamber",
+ "enclosure_fans": "Enclosure fans",
+ "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",
+ "light_locked": "Kept on during a print: defect detection needs the light for its camera check.",
+ "on": "On",
+ "off": "Off",
+ "queued": "Sent. The printer is busy (a filament swap can take minutes) and will run it afterwards."
+ },
"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..568ee73f 100644
--- a/multiace/i18n/zh.json
+++ b/multiace/i18n/zh.json
@@ -11,7 +11,60 @@
"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": "正在查找摄像头…",
+ "webcam_unavailable": "摄像头不可用",
+ "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": "跟随",
+ "chamber": "腔体",
+ "enclosure_fans": "机箱风扇",
+ "fans_off": "关闭风扇",
+ "exhaust": "排风风扇",
+ "inner": "内循环风扇",
+ "mode_0": "空闲",
+ "mode_1": "腔体降温",
+ "mode_2": "腔体预热",
+ "mode_3": "高温腔体",
+ "enc_locked_print": "打印期间不可用:机箱模式由切片文件决定。",
+ "enc_locked_mode": "机箱正在运行某个模式并自行控制风扇。请先点击“关闭风扇”。",
+ "restart_blocked": "打印进行中,已禁用",
+ "light": "灯光",
+ "light_locked": "打印期间保持开启:缺陷检测的摄像头检查需要该灯光。",
+ "on": "开",
+ "off": "关",
+ "queued": "已发送。打印机正忙(换料可能需要几分钟),稍后会执行。"
+ },
"tabs": {
+ "control": "控制",
"print_hub": "打印中心",
"dashboard": "控制台",
"dry": "烘干",
diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py
index 67ba6f66..fdf1b684 100644
--- a/multiace/web/backend/main.py
+++ b/multiace/web/backend/main.py
@@ -21,11 +21,13 @@
import ast
import hashlib
+import html
import asyncio
import json
import logging
import os
import re
+import shutil
import sys
import time
from collections import deque
@@ -2143,6 +2145,767 @@ 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:
+ # 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", ""))
+ 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
+ service = str(cam.get("service") or "mjpegstreamer")
+ return {
+ "available": True,
+ "name": name,
+ "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",
+ }
+ 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).
+ # 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
+
+@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") 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:
+ 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.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):
+ 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"})
+
+CONSOLE_BUFFER_MAX = int(os.environ.get("MULTIACE_CONSOLE_BUFFER", "300"))
+_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
+ 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
+ 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)}
+ _console_lines.append(line)
+ return line
+
+_console_seeded = False
+_console_seed_lock = asyncio.Lock()
+
+async def _seed_console_from_gcode_store() -> None:
+ """Backfill the buffer from Moonraker's own history, once, so a freshly
+ started backend does not show an empty console.
+
+ Live lines that arrived before the first view are part of that history
+ too, so the history replaces them; only lines newer than the request
+ are kept, because the history cannot contain those yet."""
+ global _next_console_id, _console_seeded
+ async with _console_seed_lock:
+ if _console_seeded:
+ return
+ started = time.time()
+ 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 []
+ newer = [ln for ln in _console_lines if ln["ts"] >= started]
+ _console_lines.clear()
+ for e in entries:
+ 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": msg.rstrip(),
+ "kind": ("command" if e.get("type") == "command"
+ else _console_kind(msg)),
+ })
+ for ln in newer:
+ _next_console_id += 1
+ ln["id"] = _next_console_id
+ _console_lines.append(ln)
+ _console_seeded = True
+
+@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_seeded:
+ 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}
+
+# ---------------------------------------------------------------------------
+# 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),
+ "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
+#: SET_LED. Not every Klipper printer has one, so the verb checks for it.
+CHAMBER_LED = "led cavity_led"
+
+#: Snapmaker's enclosure: the `purifier` object drives the exhaust and inner
+#: fans and has its own attach detection (power_detected). The cavity
+#: temperature sensor is part of the printer itself and reads without an
+#: enclosure too.
+PURIFIER = "purifier"
+CAVITY_SENSOR = "temperature_sensor cavity"
+
+#: Stock's AI camera check. It needs the chamber light, which stock turns on
+#: itself at DEFECT_DETECTION_START, so the light must not be switched off
+#: while it is watching a print.
+DEFECT_DETECTION = "defect_detection"
+
+#: 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 is the real limit and is read from the config.
+DEFAULT_HEATER_LIMITS = {"nozzle": (0.0, 300.0), "bed": (0.0, 120.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 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)))
+
+def _moonraker_error(resp: "httpx.Response") -> str:
+ """Moonraker's own message for a failed request. Its JSON error carries a
+ full Python traceback, which is no use in the panel; the message is."""
+ try:
+ err = resp.json().get("error")
+ if isinstance(err, dict) and err.get("message"):
+ return str(err["message"])
+ except (ValueError, AttributeError):
+ pass
+ return resp.text
+
+_LAST_PRINT_STATUS: dict = {}
+_LAST_PRINT_STATUS_TS: float = 0.0
+
+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.
+
+ Homing-gated like _query_state_gated: while ace.py's homing flag is
+ fresh, the last result is served instead of polling Moonraker, because
+ that I/O competes with the homing probe and bed mesh."""
+ global _LAST_PRINT_STATUS, _LAST_PRINT_STATUS_TS
+ now = time.time()
+ if _homing_active():
+ if _LAST_PRINT_STATUS and (now - _LAST_PRINT_STATUS_TS) <= _STATUS_CACHE_TTL:
+ return _LAST_PRINT_STATUS
+ # No usable cache: wait briefly for the homing window to close.
+ deadline = now + _GATE_WAIT_MAX
+ while _homing_active() and time.time() < deadline:
+ await asyncio.sleep(0.05)
+ objs = ["toolhead", "print_stats", "display_status", "virtual_sdcard",
+ "gcode_move", "fan", "heater_bed", CHAMBER_LED, PURIFIER,
+ CAVITY_SENSOR, DEFECT_DETECTION] + _EXTRUDERS
+ data = await _mr_get("/printer/objects/query?"
+ + "&".join(o.replace(" ", "%20") for o in objs))
+ status = data.get("result", {}).get("status", {}) or {}
+ _LAST_PRINT_STATUS = status
+ _LAST_PRINT_STATUS_TS = time.time()
+ return status
+
+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 _chamber_light_pct(status: dict):
+ """Chamber light brightness in percent, or None when the printer has
+ no such LED. color_data is [[r, g, b, w]]; the light is the white
+ channel."""
+ led = status.get(CHAMBER_LED)
+ if not isinstance(led, dict):
+ return None
+ try:
+ return round(float(led["color_data"][0][3]) * 100.0, 1)
+ except (KeyError, IndexError, TypeError, ValueError):
+ return None
+
+def _defect_detection_on(status: dict):
+ """Whether stock's defect detection is enabled: its master switch and at
+ least one of its checks. None when the printer has no such object."""
+ dd = status.get(DEFECT_DETECTION)
+ if not isinstance(dd, dict):
+ return None
+ checks = [dd.get(k) for k in ("clean_bed", "noodle", "residue", "nozzle")]
+ return bool(dd.get("main_enable")) and any(
+ isinstance(c, dict) and c.get("enable") for c in checks)
+
+def _enclosure_state(status: dict):
+ """Chamber temperature and, with an enclosure attached, its fans.
+
+ None when the printer has neither the cavity sensor nor the purifier
+ object, so the UI shows nothing instead of a row of dashes."""
+ sensor = status.get(CAVITY_SENSOR)
+ purifier = status.get(PURIFIER)
+ 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,
+ "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)
+ return out
+
+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,
+ "light": _chamber_light_pct(status),
+ "detection": _defect_detection_on(status),
+ "enclosure": _enclosure_state(status),
+ "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 "",
+ }
+
+_KLIPPER_CFG_TTL = 300.0
+_klipper_cfg_cache: dict = {"ts": 0.0, "settings": None}
+
+async def _klipper_settings() -> dict:
+ """Klipper's parsed config, cached: it only changes on a Klipper
+ restart, and the query must not add Moonraker load during homing."""
+ c = _klipper_cfg_cache
+ if c["settings"] is not None and (
+ time.time() - c["ts"] < _KLIPPER_CFG_TTL or _homing_active()):
+ return c["settings"]
+ if _homing_active():
+ return {}
+ cfg = await _mr_get("/printer/objects/query?configfile")
+ settings = ((cfg.get("result") or {}).get("status") or {}) \
+ .get("configfile", {}).get("settings", {}) or {}
+ c["settings"], c["ts"] = settings, time.time()
+ return settings
+
+async def _heater_limits(which: str, extruder: str = "extruder") -> tuple[float, float]:
+ """(min, max) for a heater, from Klipper's own config (min_temp and
+ max_temp), never hardcoded: a different hotend has different limits.
+
+ There is no per-material ceiling. Stock loads and unloads above a
+ material's printing range (PLA loads at 250), so a tighter cap here would
+ stop someone heating up to clear a clog. A target below the range is a
+ standby temperature set on purpose and is not raised, and 0 is off."""
+ lo, hi = DEFAULT_HEATER_LIMITS.get(which, (0.0, 250.0))
+ section = extruder if which == "nozzle" else "heater_bed"
+ try:
+ settings = await _klipper_settings()
+ 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
+ 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)
+ 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, ""
+
+async def _require_object(name: str, what: str) -> dict:
+ """409 unless the printer has this Klipper object: not every Klipper
+ printer has a chamber light or an enclosure. Returns the status it read."""
+ try:
+ status = await _print_status()
+ except httpx.HTTPError as e:
+ raise HTTPException(502, f"moonraker: {e}")
+ if name not in status:
+ raise HTTPException(409, f"this printer has no {what}")
+ return status
+
+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):
+ 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}"
+ elif verb == "fan":
+ # 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:
+ status = await _require_object(CHAMBER_LED, "chamber light")
+ printing = (status.get("print_stats") or {}).get("state") in (
+ "printing", "paused")
+ if applied < 100 and printing and _defect_detection_on(status):
+ raise HTTPException(
+ 409, "the light stays on during a print while defect "
+ "detection is enabled: its camera check needs it")
+ script = f"SET_LED LED=cavity_led WHITE={applied / 100:.2f} TRANSMIT=1"
+ 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)
+ # 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:
+ 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 == "enclosure_off":
+ # 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 EXHAUST_FAN_DELAY_OFF=0 "
+ "INNER_FAN_DELAY_OFF=0")
+ 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}
+ queued = False
+ 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: {_moonraker_error(e.response)}")
+ except httpx.ReadTimeout:
+ # The request reached Moonraker but the command has not finished:
+ # /printer/gcode/script only returns once it has run, and a
+ # multiACE swap holds Klipper's queue for minutes. The command is
+ # queued and WILL run, so report that instead of an error, or a
+ # second press sends it twice.
+ queued = True
+ except httpx.HTTPError as e:
+ raise HTTPException(status_code=502, detail=f"moonraker: {e}")
+ out = {"ok": True, "verb": verb, "applied": applied, "script": script}
+ if queued:
+ out["queued"] = True
+ 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)},
+ }
+
+#: 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")
+
+def _moonraker_is_local() -> bool:
+ from urllib.parse import urlparse
+ host = (urlparse(MOONRAKER_URL).hostname or "").lower()
+ return host in ("127.0.0.1", "localhost", "::1")
+
+def _local_reboot_command():
+ """The reboot binary, when this process may and should reboot the machine
+ itself: it runs as root on the same machine as Moonraker, so rebooting this
+ machine is rebooting the printer. A backend on a dev box talking to a remote
+ printer, or one that is not root, never qualifies."""
+ geteuid = getattr(os, "geteuid", None)
+ if geteuid is None or geteuid() != 0 or not _moonraker_is_local():
+ return None
+ # reboot lives in /usr/sbin, which the service's own PATH (set by the init
+ # script: .local/bin:/usr/bin:/bin) does not include.
+ return shutil.which("reboot") or shutil.which("reboot", path="/usr/sbin:/sbin")
+
+async def _reboot_here(cmd: str) -> None:
+ """Run reboot without waiting for the machine to go down."""
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
+ start_new_session=True)
+ out, _ = await asyncio.wait_for(proc.communicate(), timeout=10.0)
+ except asyncio.TimeoutError:
+ return # still going down: that is what a reboot looks like from here
+ except OSError as e:
+ raise HTTPException(500, f"reboot: {e}")
+ if proc.returncode:
+ raise HTTPException(500, f"reboot failed (rc={proc.returncode}): "
+ f"{(out or b'').decode('utf-8', 'replace').strip()}")
+
+@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")
+ via = "moonraker"
+ try:
+ await _mr_post(RESTART_ACTIONS[behavior], {}, timeout=15.0)
+ except httpx.HTTPStatusError as e:
+ # Moonraker's reboot runs `sudo systemctl reboot`. The Snapmaker U1
+ # has neither sudo nor systemd (busybox init), so it always fails
+ # there. Where this process runs as root on the printer itself, reboot
+ # directly; anywhere else the failure is reported as it is.
+ cmd = _local_reboot_command() if behavior == "printer" else None
+ if cmd is None:
+ raise HTTPException(e.response.status_code,
+ f"moonraker: {_moonraker_error(e.response)}")
+ await _reboot_here(cmd)
+ via = "local"
+ 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, "via": via}
+
@app.post("/api/upload-and-print")
async def upload_and_print(file: UploadFile = File(...)) -> dict:
@@ -4638,6 +5401,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
@@ -4904,6 +5671,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/multiace/web/frontend/app.js b/multiace/web/frontend/app.js
index b2d07fec..361dcbe9 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,402 @@ createApp({
},
});
}
+ // Control tab: the printer's camera. MJPEG comes through the backend as
+ // an ; a WebRTC camera (camera-streamer) is a