Jetzt/Topbar und Material-Routing, Pause bei Filament-Ende, Druckbericht mit Stromanteilen, Design- und Kamera-Stream-Polish; Bedarf-Alert-Spam und Platte-belegt-Bugs behoben.
1297 lines
46 KiB
Python
1297 lines
46 KiB
Python
"""Moonraker, nur für Drucker, die in Grotti stehen."""
|
||
|
||
import json
|
||
import re
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
|
||
from grotti.parse import embedded_png
|
||
|
||
META_CACHE = {}
|
||
CAMERA_CACHE = {}
|
||
EMBEDDED = {}
|
||
SLOT_CACHE = {}
|
||
MESH_CACHE = {}
|
||
OBJECT_CACHE = {}
|
||
THUMB_RE_LIMIT = 180_000
|
||
_CACHE_MAX = 128
|
||
|
||
|
||
def _trim_cache(cache, max_items=_CACHE_MAX):
|
||
"""Einfache Größenbegrenzung (älteste Keys zuerst bei dict-Reihenfolge)."""
|
||
while len(cache) > max_items:
|
||
try:
|
||
cache.pop(next(iter(cache)))
|
||
except StopIteration:
|
||
break
|
||
|
||
|
||
def matrix_rows(points):
|
||
if isinstance(points, list) and points and isinstance(points[0], list):
|
||
return list(reversed(points))
|
||
return []
|
||
|
||
|
||
def extract_mesh(mesh):
|
||
mesh = mesh or {}
|
||
shown = matrix_rows(mesh.get("probed_matrix"))
|
||
if not shown:
|
||
shown = matrix_rows(mesh.get("mesh_matrix"))
|
||
profile = mesh.get("profile_name") or ""
|
||
return shown, profile
|
||
|
||
|
||
def _raw_matrix(points):
|
||
if isinstance(points, list) and points and isinstance(points[0], list):
|
||
rows = []
|
||
for row in points:
|
||
if not isinstance(row, list):
|
||
return []
|
||
cleaned = []
|
||
for value in row:
|
||
if not isinstance(value, (int, float)):
|
||
return []
|
||
cleaned.append(float(value))
|
||
rows.append(cleaned)
|
||
return rows
|
||
return []
|
||
|
||
|
||
def store_mesh(host, shown, profile=""):
|
||
if not shown:
|
||
return
|
||
MESH_CACHE[host] = {
|
||
"mesh": shown,
|
||
"at": time.time(),
|
||
"profile": profile or "",
|
||
}
|
||
|
||
|
||
def mesh_fields(host, shown=None, profile=""):
|
||
"""Live-Netz vom Drucker, sonst zuletzt bekannte Matrix (auch offline)."""
|
||
if shown:
|
||
store_mesh(host, shown, profile)
|
||
cached = MESH_CACHE[host]
|
||
return {
|
||
"mesh": cached["mesh"],
|
||
"mesh_at": cached["at"],
|
||
"mesh_cached": False,
|
||
"mesh_profile": cached.get("profile") or "",
|
||
}
|
||
cached = MESH_CACHE.get(host)
|
||
if cached and cached.get("mesh"):
|
||
return {
|
||
"mesh": cached["mesh"],
|
||
"mesh_at": cached["at"],
|
||
"mesh_cached": True,
|
||
"mesh_profile": cached.get("profile") or "",
|
||
}
|
||
return {
|
||
"mesh": [],
|
||
"mesh_at": None,
|
||
"mesh_cached": False,
|
||
"mesh_profile": "",
|
||
}
|
||
|
||
|
||
class PrinterError(Exception):
|
||
def __init__(self, message):
|
||
super().__init__(message)
|
||
self.message = message
|
||
|
||
|
||
def fetch_json(url, timeout=4):
|
||
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||
return json.load(response)
|
||
|
||
|
||
def read_error(exc):
|
||
try:
|
||
payload = json.loads(exc.read().decode("utf-8", "replace"))
|
||
message = (payload.get("error") or {}).get("message")
|
||
if message:
|
||
if "cannot parse the file" in message.lower():
|
||
return "Der Drucker lehnt die Datei ab."
|
||
return message
|
||
except (json.JSONDecodeError, AttributeError, UnicodeError):
|
||
pass
|
||
return "Der Drucker hat den Aufruf abgelehnt."
|
||
|
||
|
||
def post_json(host, path, payload, timeout=20):
|
||
data = json.dumps(payload).encode("utf-8")
|
||
request = urllib.request.Request(
|
||
"http://%s%s" % (host, path),
|
||
data=data,
|
||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||
raw = response.read()
|
||
return json.loads(raw.decode("utf-8")) if raw else {}
|
||
except urllib.error.HTTPError as exc:
|
||
raise PrinterError(read_error(exc)) from exc
|
||
except TimeoutError as exc:
|
||
raise PrinterError("Der Drucker hat zu lange nicht geantwortet.") from exc
|
||
except urllib.error.URLError as exc:
|
||
reason = str(getattr(exc, "reason", "") or exc)
|
||
if "timed out" in reason.lower() or isinstance(getattr(exc, "reason", None), TimeoutError):
|
||
raise PrinterError("Der Drucker hat zu lange nicht geantwortet.") from exc
|
||
raise PrinterError("Der Drucker ist nicht erreichbar.") from exc
|
||
|
||
|
||
def safe_upload_name(filename):
|
||
"""Dateiname für Moonraker/Rinkhals/gklib — nur ASCII.
|
||
|
||
`filename` vs. `filename*` mit CJK führte zu Upload unter `_` und Start
|
||
unter dem Originalnamen → Fehler 10115 „gcode … not found“.
|
||
"""
|
||
raw = (filename or "").replace("\\", "/")
|
||
parts = [part for part in raw.split("/") if part]
|
||
base = parts[-1] if parts else "job.gcode"
|
||
out = []
|
||
for ch in base:
|
||
if ch.isascii() and ch not in '"\\':
|
||
out.append(ch if ch not in " \t" else "_")
|
||
else:
|
||
out.append("_")
|
||
text = "".join(out)
|
||
while "__" in text:
|
||
text = text.replace("__", "_")
|
||
while "_-" in text or "-_" in text:
|
||
text = text.replace("_-", "-").replace("-_", "-")
|
||
text = text.strip("._-") or "job"
|
||
lower = text.lower()
|
||
if not (lower.endswith(".gcode") or lower.endswith(".gcode.3mf") or lower.endswith(".3mf") or lower.endswith(".acm")):
|
||
# Suffix vom Original behalten, falls nur der Stamm zerstört wurde
|
||
if base.lower().endswith(".gcode.3mf"):
|
||
text += ".gcode.3mf"
|
||
elif base.lower().endswith(".gcode"):
|
||
text += ".gcode"
|
||
elif base.lower().endswith(".acm"):
|
||
text += ".acm"
|
||
if parts:
|
||
parts[-1] = text
|
||
return "/".join(parts)
|
||
return text
|
||
|
||
|
||
def multipart(filename, content):
|
||
boundary = "----GrottiBoundary7f3a"
|
||
name = safe_upload_name(filename)
|
||
fields = (
|
||
"--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary
|
||
).encode("ascii")
|
||
head = (
|
||
"--%s\r\n"
|
||
"Content-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\n"
|
||
"Content-Type: application/octet-stream\r\n\r\n"
|
||
) % (boundary, name)
|
||
tail = ("\r\n--%s--\r\n" % boundary).encode("ascii")
|
||
return boundary, fields + head.encode("ascii") + content + tail
|
||
|
||
|
||
class _UploadBody:
|
||
"""Bytes als lesbarer Stream, damit urllib Fortschritt melden kann."""
|
||
|
||
def __init__(self, data, on_progress=None):
|
||
self._data = memoryview(data)
|
||
self._pos = 0
|
||
self._on_progress = on_progress
|
||
self._last_pct = -1
|
||
|
||
def __len__(self):
|
||
return len(self._data)
|
||
|
||
def read(self, size=-1):
|
||
total = len(self._data)
|
||
if self._pos >= total:
|
||
return b""
|
||
if size is None or size < 0:
|
||
size = 64 * 1024
|
||
end = min(self._pos + size, total)
|
||
chunk = self._data[self._pos:end].tobytes()
|
||
self._pos = end
|
||
if self._on_progress and total:
|
||
pct = int(100 * self._pos / total)
|
||
if pct != self._last_pct and (pct == 100 or pct - self._last_pct >= 2):
|
||
self._last_pct = pct
|
||
self._on_progress(self._pos, total)
|
||
return chunk
|
||
|
||
|
||
def upload_timeout(size):
|
||
# Große G-codes brauchen länger als 120 s (LAN + Moonraker-Metadaten).
|
||
size = max(0, int(size or 0))
|
||
return max(180, min(600, 90 + size // (80 * 1024)))
|
||
|
||
|
||
def upload(host, filename, content, on_progress=None):
|
||
filename = safe_upload_name(filename)
|
||
boundary, body = multipart(filename, content)
|
||
stream = _UploadBody(body, on_progress=on_progress)
|
||
request = urllib.request.Request(
|
||
"http://%s/server/files/upload" % host,
|
||
data=stream,
|
||
headers={
|
||
"Content-Type": "multipart/form-data; boundary=%s" % boundary,
|
||
"Content-Length": str(len(body)),
|
||
},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=upload_timeout(len(body))) as response:
|
||
payload = json.loads(response.read().decode("utf-8"))
|
||
except urllib.error.HTTPError as exc:
|
||
raise PrinterError(read_error(exc)) from exc
|
||
except (urllib.error.URLError, TimeoutError) as exc:
|
||
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
|
||
item = (payload.get("result") or {}).get("item") or {}
|
||
# Immer denselben ASCII-Namen nutzen, den wir hochgeladen haben
|
||
return safe_upload_name(item.get("path") or filename)
|
||
|
||
|
||
def start_print(host, filename, bed_leveling=False, ams_settings=None):
|
||
"""Start: Moonraker nur ohne Bettnetz und ohne Grotti-ACE-Map.
|
||
|
||
Moonraker `/printer/print/start` kennt nur filename. Rinkhals baut dann
|
||
MQTT selbst (auto_leveling aus App-Property; ams_settings aus .acm /
|
||
Identity-Fallback). Bei bed_leveling oder gesetzter Grotti-ams_settings
|
||
startet Grotti per MQTTS :9883 und legt die Map explizit in die Payload.
|
||
|
||
Vor dem reinen Moonraker-Pfad: best-effort ensure_task_free — Rinkhals
|
||
wandelt Start oft intern zu MQTT um; hängende gkapi-Tasks (10101) blocken.
|
||
"""
|
||
use_mqtt = bool(bed_leveling) or bool(ams_settings)
|
||
from grotti import anycubic_lan
|
||
|
||
if use_mqtt:
|
||
try:
|
||
return anycubic_lan.start_print(
|
||
host,
|
||
filename,
|
||
auto_leveling=bool(bed_leveling),
|
||
ams_settings=ams_settings,
|
||
)
|
||
except anycubic_lan.LanError as exc:
|
||
raise PrinterError(exc.message) from exc
|
||
|
||
try:
|
||
anycubic_lan.ensure_task_free(host)
|
||
except Exception:
|
||
pass
|
||
return post_json(host, "/printer/print/start", {"filename": filename})
|
||
|
||
|
||
def command(host, action):
|
||
path = {
|
||
"pause": "/printer/print/pause",
|
||
"fortsetzen": "/printer/print/resume",
|
||
"abbrechen": "/printer/print/cancel",
|
||
}.get(action)
|
||
if not path:
|
||
raise PrinterError("Unbekannter Befehl.")
|
||
result = post_json(host, path, {})
|
||
# Nach ACE/MQTT-Start reicht Moonraker-cancel nicht: Anycubic-Task bleibt
|
||
# aktiv → nächster MQTT-Start mit 10101. Best-effort MQTT stop.
|
||
if action == "abbrechen":
|
||
try:
|
||
from grotti import anycubic_lan
|
||
|
||
anycubic_lan.cancel_print(host)
|
||
except Exception:
|
||
pass
|
||
return result
|
||
|
||
|
||
def gcode_script(host, script, timeout=30):
|
||
script = (script or "").strip()
|
||
if not script:
|
||
raise PrinterError("Kein G-code.")
|
||
return post_json(host, "/printer/gcode/script", {"script": script}, timeout=max(10, int(timeout)))
|
||
|
||
|
||
def heaters_off(host):
|
||
"""Heizung und Lüfter aus — nach Fehler / Abbruch, nicht weiter heizen."""
|
||
return gcode_script(
|
||
host,
|
||
"\n".join([
|
||
"SET_HEATER_TEMPERATURE HEATER=extruder TARGET=0",
|
||
"SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=0",
|
||
"M107",
|
||
]),
|
||
timeout=20,
|
||
)
|
||
|
||
|
||
def clear_print_error(host):
|
||
"""Hängenden print_stats/idle error lösen (Anycubic lässt sonst 'error' stehen)."""
|
||
try:
|
||
heaters_off(host)
|
||
except PrinterError:
|
||
pass
|
||
try:
|
||
post_json(host, "/printer/print/cancel", {}, timeout=15)
|
||
except PrinterError:
|
||
pass
|
||
gcode_script(host, "SDCARD_RESET_FILE", timeout=20)
|
||
try:
|
||
from grotti import anycubic_lan
|
||
|
||
anycubic_lan.cancel_print(host)
|
||
except Exception:
|
||
pass
|
||
return {"ok": True, "message": "Fehlerstatus zurückgesetzt."}
|
||
|
||
|
||
def steuern(host, action, params=None):
|
||
"""Whitelist-Steuerung ohne freie Konsole."""
|
||
params = params if isinstance(params, dict) else {}
|
||
action = (action or "").strip()
|
||
# MCP und ältere Clients schicken "value" statt temp/percent.
|
||
if "temp" not in params and params.get("value") is not None and action in ("nozzle", "bed", "dryer_start"):
|
||
params = {**params, "temp": params["value"]}
|
||
if "percent" not in params and params.get("value") is not None and action in ("speed", "flow", "fan"):
|
||
params = {**params, "percent": params["value"]}
|
||
|
||
def pct(key, default=100, lo=0, hi=200):
|
||
try:
|
||
value = int(round(float(params.get(key, default))))
|
||
except (TypeError, ValueError) as exc:
|
||
raise PrinterError("%s muss eine Zahl sein." % key) from exc
|
||
return max(lo, min(hi, value))
|
||
|
||
def temp(key):
|
||
try:
|
||
value = float(params.get(key))
|
||
except (TypeError, ValueError) as exc:
|
||
raise PrinterError("%s muss eine Temperatur sein." % key) from exc
|
||
if value < 0 or value > 350:
|
||
raise PrinterError("Temperatur außerhalb 0–350 °C.")
|
||
return value
|
||
|
||
if action == "nozzle":
|
||
return gcode_script(
|
||
host,
|
||
"SET_HEATER_TEMPERATURE HEATER=extruder TARGET=%.1f" % temp("temp"),
|
||
)
|
||
if action == "bed":
|
||
return gcode_script(
|
||
host,
|
||
"SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=%.1f" % temp("temp"),
|
||
)
|
||
if action == "speed":
|
||
return gcode_script(host, "M220 S%d" % pct("percent", 100, 10, 200))
|
||
if action == "flow":
|
||
return gcode_script(host, "M221 S%d" % pct("percent", 100, 50, 150))
|
||
if action == "fan":
|
||
value = pct("percent", 0, 0, 100)
|
||
if value <= 0:
|
||
return gcode_script(host, "M107")
|
||
return gcode_script(host, "M106 S%d" % int(round(value * 255 / 100)))
|
||
if action == "emergency_stop":
|
||
return post_json(host, "/printer/emergency_stop", {}, timeout=10)
|
||
if action == "clear_error":
|
||
return clear_print_error(host)
|
||
if action == "exclude_object":
|
||
name = str(params.get("name") or "").strip()
|
||
if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name):
|
||
raise PrinterError("Ungültiger Objektname.")
|
||
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20)
|
||
if action == "dryer_start":
|
||
try:
|
||
unit = int(params.get("id"))
|
||
except (TypeError, ValueError) as exc:
|
||
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
|
||
if unit not in (0, 1):
|
||
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
|
||
try:
|
||
temp = int(round(float(params.get("temp"))))
|
||
duration = int(round(float(params.get("duration"))))
|
||
except (TypeError, ValueError) as exc:
|
||
raise PrinterError("Temperatur und Dauer müssen Zahlen sein.") from exc
|
||
if temp < 35 or temp > 65:
|
||
raise PrinterError("Trockner-Temperatur außerhalb 35–65 °C.")
|
||
if duration < 1 or duration > 720:
|
||
raise PrinterError("Trockner-Dauer außerhalb 1–720 Minuten.")
|
||
return post_json(
|
||
host,
|
||
"/server/filament_hub/start_drying",
|
||
{"id": unit, "temp": temp, "duration": duration, "fan_speed": 0},
|
||
timeout=15,
|
||
)
|
||
if action == "dryer_stop":
|
||
try:
|
||
unit = int(params.get("id"))
|
||
except (TypeError, ValueError) as exc:
|
||
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
|
||
if unit not in (0, 1):
|
||
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
|
||
return post_json(host, "/server/filament_hub/stop_drying", {"id": unit}, timeout=15)
|
||
if action == "slot_setzen":
|
||
hub_id = _int_or_none(params.get("id"))
|
||
index = _int_or_none(params.get("index"))
|
||
if hub_id not in (0, 1) or index is None or index < 0 or index > 3:
|
||
raise PrinterError("ACE-Platz ungültig.")
|
||
material = str(params.get("type") or "").strip()
|
||
if not material:
|
||
raise PrinterError("Die Spule braucht eine Sorte.")
|
||
color = params.get("color")
|
||
if not isinstance(color, dict):
|
||
raise PrinterError("Die Spule braucht eine Farbe.")
|
||
try:
|
||
red = max(0, min(255, int(color.get("R"))))
|
||
green = max(0, min(255, int(color.get("G"))))
|
||
blue = max(0, min(255, int(color.get("B"))))
|
||
except (TypeError, ValueError) as exc:
|
||
raise PrinterError("Die Spule braucht eine Farbe.") from exc
|
||
return post_json(
|
||
host,
|
||
"/printer/filament_hub/set_filament_info",
|
||
{
|
||
"id": hub_id,
|
||
"index": index,
|
||
"type": material,
|
||
"color": {"R": red, "G": green, "B": blue},
|
||
},
|
||
timeout=15,
|
||
)
|
||
if action == "firmware_restart":
|
||
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
|
||
raise PrinterError(
|
||
"Firmware-Restart geht auf GoKlipper nicht — Drucker am Display "
|
||
"neu starten oder Strom aus. Hängenden Fehler: „Fehler löschen“."
|
||
)
|
||
raise PrinterError("Unbekannte Steuerung.")
|
||
|
||
|
||
def download_gcode(host, filename, max_bytes=40 * 1024 * 1024):
|
||
"""Lädt eine G-code-Datei vom Drucker (für Grotti-Viewer-Cache)."""
|
||
name = (filename or "").replace("\\", "/").split("/")[-1]
|
||
if not name or ".." in name:
|
||
return None
|
||
url = "http://%s/server/files/gcodes/%s" % (host, urllib.parse.quote(name))
|
||
try:
|
||
with urllib.request.urlopen(url, timeout=60) as response:
|
||
data = response.read(max_bytes + 1)
|
||
except (urllib.error.URLError, TimeoutError, OSError):
|
||
return None
|
||
if not data or len(data) > max_bytes:
|
||
return None
|
||
return name, data
|
||
|
||
|
||
def history(host, limit=50):
|
||
url = "http://%s/server/history/list?limit=%s&start=0&order=desc" % (host, int(limit))
|
||
try:
|
||
payload = fetch_json(url, timeout=8)
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
|
||
return []
|
||
return (payload.get("result") or {}).get("jobs") or []
|
||
|
||
|
||
def metadata(host, path):
|
||
key = (host, path)
|
||
cached = META_CACHE.get(key)
|
||
now = time.time()
|
||
if cached and now - cached[0] < 60:
|
||
return cached[1]
|
||
url = "http://%s/server/files/metadata?filename=%s" % (host, urllib.parse.quote(path))
|
||
try:
|
||
data = fetch_json(url).get("result") or {}
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
||
data = {}
|
||
META_CACHE[key] = (now, data)
|
||
_trim_cache(META_CACHE)
|
||
return data
|
||
|
||
|
||
def preview_path(gcode_path, meta):
|
||
thumbs = meta.get("thumbnails") or []
|
||
if not thumbs or not gcode_path:
|
||
return ""
|
||
best = max(thumbs, key=lambda item: item.get("width") or 0)
|
||
relative = (best.get("relative_path") or "").replace("\\", "/")
|
||
if not relative.endswith(".png") or ".thumbs/" not in relative or ".." in relative:
|
||
return ""
|
||
folder = gcode_path.replace("\\", "/").rsplit("/", 1)[0] if "/" in gcode_path else ""
|
||
return "%s/%s" % (folder, relative) if folder else relative
|
||
|
||
|
||
def embedded_preview(host, path):
|
||
key = (host, path)
|
||
if key in EMBEDDED:
|
||
return EMBEDDED[key]
|
||
url = "http://%s/server/files/gcodes/%s" % (host, urllib.parse.quote(path))
|
||
request = urllib.request.Request(url, headers={"Range": "bytes=0-%s" % THUMB_RE_LIMIT})
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=6) as response:
|
||
text = response.read().decode("utf-8", "replace")
|
||
except (urllib.error.URLError, TimeoutError):
|
||
EMBEDDED[key] = None
|
||
_trim_cache(EMBEDDED)
|
||
return None
|
||
png = embedded_png(text)
|
||
EMBEDDED[key] = png
|
||
_trim_cache(EMBEDDED)
|
||
return png
|
||
|
||
|
||
def css_hex(value):
|
||
if isinstance(value, (list, tuple)) and len(value) >= 3:
|
||
channels = []
|
||
for channel in value[:3]:
|
||
if isinstance(channel, bool) or not isinstance(channel, (int, float)):
|
||
return ""
|
||
channels.append(max(0, min(255, int(round(float(channel))))))
|
||
return "#%02x%02x%02x" % tuple(channels)
|
||
if value in (None, ""):
|
||
return ""
|
||
text = str(value).strip()
|
||
if text.startswith("#"):
|
||
text = text[1:]
|
||
digits = "0123456789abcdefABCDEF"
|
||
if not text or any(ch not in digits for ch in text):
|
||
return ""
|
||
if len(text) == 8:
|
||
if text[6:8].lower() != "ff":
|
||
return "#" + text.lower()
|
||
text = text[:6]
|
||
if len(text) == 6:
|
||
return "#" + text.lower()
|
||
return ""
|
||
|
||
|
||
def slot_kind(value):
|
||
text = str(value or "").strip()
|
||
if not text or text.lower() == "unknown":
|
||
return ""
|
||
return text
|
||
|
||
|
||
def slots_from_mmu(mmu):
|
||
if not isinstance(mmu, dict):
|
||
return []
|
||
colors = mmu.get("gate_color")
|
||
materials = mmu.get("gate_material")
|
||
names = mmu.get("gate_filament_name")
|
||
states = mmu.get("gate_status")
|
||
colors = colors if isinstance(colors, list) else []
|
||
materials = materials if isinstance(materials, list) else []
|
||
names = names if isinstance(names, list) else []
|
||
states = states if isinstance(states, list) else []
|
||
count = mmu.get("num_gates")
|
||
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
|
||
count = max(len(colors), len(materials), len(names), len(states))
|
||
found = []
|
||
for index in range(min(count, 8)):
|
||
state = states[index] if index < len(states) else None
|
||
if state == 0:
|
||
continue
|
||
kind = slot_kind(materials[index] if index < len(materials) else "")
|
||
if not kind and index < len(names):
|
||
kind = slot_kind(names[index])
|
||
color = css_hex(colors[index] if index < len(colors) else "")
|
||
if state not in (1, 2) and not kind and color in ("", "#000000"):
|
||
continue
|
||
if not color and not kind:
|
||
continue
|
||
found.append({"index": index, "color": color, "type": kind})
|
||
return found
|
||
|
||
|
||
def slots_from_hubs(status):
|
||
hubs = status.get("filament_hubs")
|
||
if isinstance(hubs, list):
|
||
groups = hubs
|
||
elif isinstance(status.get("filament_hub"), dict):
|
||
groups = [status["filament_hub"]]
|
||
else:
|
||
return []
|
||
found = []
|
||
for hub_index, hub in enumerate(groups):
|
||
if not isinstance(hub, dict):
|
||
continue
|
||
items = hub.get("slots")
|
||
if not isinstance(items, list):
|
||
continue
|
||
for offset, item in enumerate(items):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
index = item.get("index")
|
||
if isinstance(index, bool) or not isinstance(index, int):
|
||
index = hub_index * 4 + offset
|
||
raw_color = item.get("color")
|
||
if raw_color in (None, ""):
|
||
raw_color = item.get("colour")
|
||
color = css_hex(raw_color)
|
||
kind = slot_kind(item.get("type") or item.get("material") or item.get("filament_type"))
|
||
if not color and not kind:
|
||
continue
|
||
found.append({"index": index, "color": color, "type": kind})
|
||
return found
|
||
|
||
|
||
def extract_slots(status):
|
||
status = status or {}
|
||
found = slots_from_mmu(status.get("mmu"))
|
||
if not found:
|
||
found = slots_from_hubs(status)
|
||
found.sort(key=lambda item: item.get("index") or 0)
|
||
return found[:8]
|
||
|
||
|
||
def _int_or_none(value):
|
||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||
return None
|
||
return int(value)
|
||
|
||
|
||
def extract_dryers(status):
|
||
"""Echte ACE-Boxen aus mmu_machine (num_gates > 0), Dummy-Unit bleibt weg."""
|
||
machine = (status or {}).get("mmu_machine")
|
||
if not isinstance(machine, dict):
|
||
return []
|
||
found = []
|
||
for unit_id in (0, 1):
|
||
unit = machine.get("unit_%d" % unit_id)
|
||
if not isinstance(unit, dict):
|
||
continue
|
||
gates = _int_or_none(unit.get("num_gates"))
|
||
if gates is None or gates < 1:
|
||
continue
|
||
found.append({
|
||
"id": unit_id,
|
||
"status": str(unit.get("dryer_status") or "stop"),
|
||
"temp": _int_or_none(unit.get("dryer_temp")) or 0,
|
||
"target": _int_or_none(unit.get("dryer_target_temp")) or 0,
|
||
"remain": _int_or_none(unit.get("dryer_remaining")) or 0,
|
||
"humidity": _int_or_none(unit.get("dryer_humidity")) or 0,
|
||
"gates": gates,
|
||
})
|
||
return found
|
||
|
||
|
||
def ace_hub_index(ace_slot):
|
||
"""Grotti-Platz 1–8 → Moonraker id (0/1) und index in der Box (0–3)."""
|
||
try:
|
||
slot = int(ace_slot)
|
||
except (TypeError, ValueError) as exc:
|
||
raise PrinterError("ACE-Platz muss 1–8 sein.") from exc
|
||
if slot < 1 or slot > 8:
|
||
raise PrinterError("ACE-Platz muss 1–8 sein.")
|
||
return (slot - 1) // 4, (slot - 1) % 4
|
||
|
||
|
||
def spool_rgb(spool):
|
||
"""Erste Farbe der Spule als RGB-Dict für set_filament_info."""
|
||
colors = (spool or {}).get("colors") or []
|
||
if isinstance(colors, str):
|
||
try:
|
||
colors = json.loads(colors)
|
||
except (json.JSONDecodeError, TypeError):
|
||
colors = []
|
||
hex_value = ""
|
||
if isinstance(colors, list):
|
||
for item in colors:
|
||
if isinstance(item, dict) and item.get("hex"):
|
||
hex_value = str(item["hex"]).strip()
|
||
break
|
||
if isinstance(item, str) and item.strip():
|
||
hex_value = item.strip()
|
||
break
|
||
if not hex_value:
|
||
hex_value = str((spool or {}).get("color") or "").strip()
|
||
if hex_value.startswith("#"):
|
||
hex_value = hex_value[1:]
|
||
if len(hex_value) == 8:
|
||
hex_value = hex_value[:6]
|
||
if len(hex_value) != 6 or any(ch not in "0123456789abcdefABCDEF" for ch in hex_value):
|
||
return None
|
||
return {
|
||
"R": int(hex_value[0:2], 16),
|
||
"G": int(hex_value[2:4], 16),
|
||
"B": int(hex_value[4:6], 16),
|
||
}
|
||
|
||
|
||
def slots(host, status=None):
|
||
if status is not None:
|
||
found = extract_slots(status)
|
||
SLOT_CACHE[host] = (time.time(), found)
|
||
return found
|
||
cached = SLOT_CACHE.get(host)
|
||
now = time.time()
|
||
if cached and now - cached[0] < 60:
|
||
return cached[1]
|
||
found = []
|
||
try:
|
||
payload = fetch_json(
|
||
"http://%s/printer/objects/query?mmu" % host,
|
||
timeout=2,
|
||
)
|
||
found = extract_slots((payload.get("result") or {}).get("status") or {})
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
||
found = []
|
||
SLOT_CACHE[host] = (now, found)
|
||
return found
|
||
|
||
|
||
def fan_speed_of(entry):
|
||
if not isinstance(entry, dict):
|
||
return None
|
||
speed = entry.get("speed")
|
||
if isinstance(speed, (int, float)):
|
||
return max(0.0, min(1.0, float(speed)))
|
||
return None
|
||
|
||
|
||
def fan_values(status):
|
||
fan = status.get("fan")
|
||
if not isinstance(fan, dict):
|
||
return None, None
|
||
speed = fan_speed_of(fan)
|
||
rpm = fan.get("rpm")
|
||
if isinstance(rpm, (int, float)):
|
||
rpm = int(rpm)
|
||
else:
|
||
rpm = None
|
||
return speed, rpm
|
||
|
||
|
||
def _heater_power(entry):
|
||
"""Klipper-Einschaltdauer 0…1 (ED), sonst None."""
|
||
if not isinstance(entry, dict):
|
||
return None
|
||
power = entry.get("power")
|
||
if isinstance(power, (int, float)) and not isinstance(power, bool):
|
||
return max(0.0, min(1.0, float(power)))
|
||
return None
|
||
|
||
|
||
def _object_names(host):
|
||
now = time.time()
|
||
cached = OBJECT_CACHE.get(host)
|
||
if cached and now - cached[0] < 120:
|
||
return cached[1]
|
||
names = []
|
||
try:
|
||
payload = fetch_json("http://%s/printer/objects/list" % host, timeout=3)
|
||
names = list((payload.get("result") or {}).get("objects") or [])
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError, TypeError):
|
||
names = []
|
||
OBJECT_CACHE[host] = (now, names)
|
||
return names
|
||
|
||
|
||
def _pick_object(names, prefixes, needles):
|
||
lowered = [(name, name.lower()) for name in names]
|
||
for needle in needles:
|
||
for name, low in lowered:
|
||
if any(low.startswith(prefix) for prefix in prefixes) and needle in low:
|
||
return name
|
||
for name, low in lowered:
|
||
if any(low.startswith(prefix) for prefix in prefixes):
|
||
return name
|
||
return ""
|
||
|
||
|
||
def chamber_and_fans(host, status):
|
||
names = _object_names(host)
|
||
chamber_name = _pick_object(
|
||
names,
|
||
("temperature_sensor ", "heater_generic "),
|
||
("chamber", "bauraum", "盒", "box"),
|
||
)
|
||
part_name = "fan" if "fan" in names else ""
|
||
chamber_fan = _pick_object(names, ("temperature_fan ", "heater_fan ", "controller_fan "), ("chamber", "bauraum"))
|
||
aux_fan = _pick_object(names, ("temperature_fan ", "heater_fan ", "controller_fan ", "fan_generic "), ("aux", "auxiliary", "side"))
|
||
wanted = [name for name in (chamber_name, part_name, chamber_fan, aux_fan) if name and name not in status]
|
||
extra = {}
|
||
if wanted:
|
||
try:
|
||
url = "http://%s/printer/objects/query?%s" % (
|
||
host,
|
||
"&".join(urllib.parse.quote(name, safe="") for name in wanted),
|
||
)
|
||
payload = fetch_json(url, timeout=3)
|
||
extra = (payload.get("result") or {}).get("status") or {}
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError):
|
||
extra = {}
|
||
merged = dict(status)
|
||
merged.update(extra)
|
||
chamber = None
|
||
chamber_target = None
|
||
if chamber_name and isinstance(merged.get(chamber_name), dict):
|
||
entry = merged[chamber_name]
|
||
temp = entry.get("temperature")
|
||
if isinstance(temp, (int, float)):
|
||
chamber = float(temp)
|
||
target = entry.get("target")
|
||
if isinstance(target, (int, float)):
|
||
chamber_target = float(target)
|
||
fans = {
|
||
"part": fan_speed_of(merged.get("fan")),
|
||
"chamber": fan_speed_of(merged.get(chamber_fan)) if chamber_fan else None,
|
||
"aux": fan_speed_of(merged.get(aux_fan)) if aux_fan else None,
|
||
}
|
||
return chamber, chamber_target, fans
|
||
|
||
|
||
def last_gcode_error(host, limit=40):
|
||
"""Letzte Fehlerzeile aus dem G-code-Log (Anycubic lässt print_stats.message oft leer)."""
|
||
try:
|
||
payload = fetch_json("http://%s/server/gcode_store?count=%d" % (host, int(limit)), timeout=3)
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError):
|
||
return ""
|
||
items = ((payload.get("result") or {}).get("gcode_store") or [])
|
||
for item in reversed(items):
|
||
if (item.get("type") or "") != "response":
|
||
continue
|
||
text = (item.get("message") or "").strip()
|
||
low = text.lower()
|
||
if "error" not in low and "!!" not in text and "must home" not in low:
|
||
continue
|
||
# "error: typ = WebRequestError, code = 10011600, message = Must home axis first"
|
||
marker = "message ="
|
||
if marker in text:
|
||
text = text.split(marker, 1)[-1].strip()
|
||
if text.lower().startswith("error:"):
|
||
text = text[6:].strip()
|
||
return text
|
||
return ""
|
||
|
||
|
||
def enrich_error_message(host, stats, status):
|
||
raw = (stats.get("message") or "").strip()
|
||
if raw and raw.lower() not in ("error", "fehler", "failed", "fail"):
|
||
return raw
|
||
bits = []
|
||
detail = last_gcode_error(host)
|
||
if detail:
|
||
# Nur echte G-code-Antworten — leeres homed_axes im Idle ist normal
|
||
# (Stepper aus) und darf keinen Druckstart blockieren oder erklären.
|
||
if "must home" in detail.lower():
|
||
bits.append("Bewegung ohne Homing (Must home axis first)")
|
||
else:
|
||
bits.append(detail)
|
||
duration = stats.get("print_duration") or 0
|
||
if stats.get("filename") and isinstance(duration, (int, float)) and float(duration) <= 0:
|
||
bits.append("Druck sofort abgebrochen")
|
||
idle = ((status.get("idle_timeout") or {}).get("state") or "").lower()
|
||
if idle == "error" and not bits:
|
||
bits.append("Drucker im Fehlerzustand")
|
||
return " · ".join(bits) if bits else (raw or "Unbekannter Fehler — in Mainsail prüfen")
|
||
|
||
|
||
_FILAMENT_HINT_RE = re.compile(
|
||
r"filament|runout|out of fil|no fil|fil.?empty|10107|"
|
||
r"material.?leer|spule.?leer|filament.?gerissen|filament.?fehlt|"
|
||
r"filament.?sensor|out of material",
|
||
re.I,
|
||
)
|
||
|
||
|
||
def last_filament_gcode_hint(host, limit=40):
|
||
"""Letzte G-code-Zeile, die auf Filament-Ende / Runout hindeutet."""
|
||
try:
|
||
payload = fetch_json("http://%s/server/gcode_store?count=%d" % (host, int(limit)), timeout=3)
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError):
|
||
return ""
|
||
items = ((payload.get("result") or {}).get("gcode_store") or [])
|
||
for item in reversed(items):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
text = (item.get("message") or "").strip()
|
||
if not text or not _FILAMENT_HINT_RE.search(text):
|
||
continue
|
||
low = text.lower()
|
||
if "10107" in low:
|
||
return "Filament gerissen oder leer (10107)"
|
||
return text[:160]
|
||
return ""
|
||
|
||
|
||
def filament_out_hint(mmu, message="", host=None):
|
||
"""Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None).
|
||
|
||
Quellen: print_stats.message, ACE/mmu (active_filament.empty, gate_status),
|
||
optional G-code-Store (nur wenn host gesetzt).
|
||
"""
|
||
text = (message or "").strip()
|
||
if text and _FILAMENT_HINT_RE.search(text):
|
||
if "10107" in text.lower():
|
||
return "message", "Filament gerissen oder leer (10107)"
|
||
return "message", text[:160]
|
||
|
||
if isinstance(mmu, dict) and mmu:
|
||
active = mmu.get("active_filament")
|
||
if isinstance(active, dict) and (
|
||
active.get("empty") is True
|
||
or str(active.get("empty") or "").lower() in ("1", "true")
|
||
):
|
||
return "active_empty", "Aktives Filament leer (ACE)"
|
||
|
||
tool = mmu.get("tool")
|
||
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
|
||
tool = mmu.get("gate")
|
||
if isinstance(tool, (int, float)) and not isinstance(tool, bool) and int(tool) >= 0:
|
||
tool_i = int(tool)
|
||
states = mmu.get("gate_status")
|
||
if isinstance(states, list) and tool_i < len(states) and states[tool_i] == 0:
|
||
return "gate_empty", "ACE-Platz %d leer" % (tool_i + 1)
|
||
|
||
# ACE pausiert und kein aktives Filament → oft Runout ohne leeren gate_status
|
||
if mmu.get("is_paused") and isinstance(active, dict):
|
||
kind = (
|
||
active.get("filament_type")
|
||
or active.get("type")
|
||
or active.get("material")
|
||
or ""
|
||
)
|
||
if not str(kind).strip():
|
||
return "mmu_paused", "ACE pausiert — kein Filament aktiv"
|
||
|
||
if host:
|
||
hint = last_filament_gcode_hint(host)
|
||
if hint:
|
||
return "gcode", hint
|
||
return None, None
|
||
|
||
|
||
def exclude_status(host):
|
||
"""Klipper [exclude_object] — leer, wenn Modul fehlt oder Query scheitert."""
|
||
empty = {"objects": [], "excluded": [], "current": None}
|
||
url = "http://%s/printer/objects/query?exclude_object" % host
|
||
try:
|
||
payload = fetch_json(url, timeout=4)
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, TypeError):
|
||
return empty
|
||
eo = ((payload.get("result") or {}).get("status") or {}).get("exclude_object")
|
||
if not isinstance(eo, dict):
|
||
return empty
|
||
objects = []
|
||
for item in eo.get("objects") or []:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
name = str(item.get("name") or "").strip()
|
||
if not name:
|
||
continue
|
||
center = item.get("center")
|
||
polygon = item.get("polygon")
|
||
objects.append({
|
||
"name": name,
|
||
"center": center if isinstance(center, list) else [],
|
||
"polygon": polygon if isinstance(polygon, list) else [],
|
||
})
|
||
excluded = [str(x) for x in (eo.get("excluded_objects") or []) if x]
|
||
current = eo.get("current_object")
|
||
return {
|
||
"objects": objects,
|
||
"excluded": excluded,
|
||
"current": str(current) if current else None,
|
||
}
|
||
|
||
|
||
def _probe_z_of(status):
|
||
probe = status.get("probe")
|
||
if not isinstance(probe, dict):
|
||
return None
|
||
raw = probe.get("last_z_result")
|
||
if isinstance(raw, (int, float)):
|
||
return float(raw)
|
||
return None
|
||
|
||
|
||
_PROBE_RESULT_RE = re.compile(r"Result is z=\s*([+-]?\d+(?:\.\d+)?)", re.I)
|
||
|
||
|
||
def _message_has_probe_cmd(message):
|
||
return bool(re.search(r"(?im)^\s*PROBE\s*$", message or ""))
|
||
|
||
|
||
def latest_probe_result_z(host, after_time=None):
|
||
"""Tast-Z aus Moonraker-G-code-Store (`Result is z=`).
|
||
|
||
Auf Kobra 3/GoKlipper ist `printer.probe` oft leer — `last_z_result` fehlt.
|
||
Tasten-Script: ein Moonraker-Befehl mit zwei PROBE (Rand-Null + Mitte) —
|
||
es zählt das **letzte** `Result is z=` nach diesem Befehl.
|
||
"""
|
||
url = "http://%s/server/gcode_store?count=60" % host
|
||
payload = fetch_json(url, timeout=10)
|
||
entries = (payload.get("result") or {}).get("gcode_store") or []
|
||
min_t = None if after_time is None else float(after_time) - 2.0
|
||
last_probe_cmd = -1
|
||
for idx, entry in enumerate(entries):
|
||
if not isinstance(entry, dict) or (entry.get("type") or "") != "command":
|
||
continue
|
||
if not _message_has_probe_cmd(entry.get("message") or ""):
|
||
continue
|
||
if min_t is not None:
|
||
try:
|
||
if float(entry.get("time") or 0) < min_t:
|
||
continue
|
||
except (TypeError, ValueError):
|
||
continue
|
||
last_probe_cmd = idx
|
||
if last_probe_cmd >= 0:
|
||
found = None
|
||
for entry in entries[last_probe_cmd + 1 :]:
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
if (entry.get("type") or "") == "command":
|
||
break
|
||
match = _PROBE_RESULT_RE.search(entry.get("message") or "")
|
||
if match:
|
||
found = float(match.group(1))
|
||
return found
|
||
for entry in reversed(entries):
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
if min_t is not None:
|
||
try:
|
||
if float(entry.get("time") or 0) < min_t:
|
||
continue
|
||
except (TypeError, ValueError):
|
||
continue
|
||
match = _PROBE_RESULT_RE.search(entry.get("message") or "")
|
||
if match:
|
||
return float(match.group(1))
|
||
return None
|
||
|
||
|
||
def query(host):
|
||
base = (
|
||
"print_stats&heater_bed&extruder&virtual_sdcard&bed_mesh&fan&gcode_move&mmu&toolhead&idle_timeout"
|
||
)
|
||
names = _object_names(host)
|
||
extras = []
|
||
if "probe" in names:
|
||
extras.append("probe")
|
||
if "mmu_machine" in names:
|
||
extras.append("mmu_machine")
|
||
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
|
||
payload = fetch_json(url)
|
||
status = payload["result"]["status"]
|
||
stats = status.get("print_stats") or {}
|
||
card = status.get("virtual_sdcard") or {}
|
||
extruder = status.get("extruder") or {}
|
||
bed = status.get("heater_bed") or {}
|
||
mmu = status.get("mmu") or {}
|
||
mmu_tool = mmu.get("tool")
|
||
if not isinstance(mmu_tool, (int, float)) or isinstance(mmu_tool, bool):
|
||
mmu_tool = mmu.get("gate")
|
||
if isinstance(mmu_tool, (int, float)) and not isinstance(mmu_tool, bool):
|
||
mmu_tool = int(mmu_tool)
|
||
if mmu_tool < 0:
|
||
mmu_tool = None
|
||
else:
|
||
mmu_tool = None
|
||
filament_used = stats.get("filament_used")
|
||
if not isinstance(filament_used, (int, float)) or isinstance(filament_used, bool):
|
||
filament_used = None
|
||
else:
|
||
filament_used = float(filament_used)
|
||
raw_name = stats.get("filename") or ""
|
||
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
|
||
meta = metadata(host, raw_name) if raw_name else {}
|
||
picture = preview_path(raw_name, meta)
|
||
if not picture and raw_name:
|
||
embedded_preview(host, raw_name)
|
||
shown, mesh_profile = extract_mesh(status.get("bed_mesh"))
|
||
info = stats.get("info") or {}
|
||
progress = card.get("progress")
|
||
if not isinstance(progress, (int, float)):
|
||
size = card.get("file_size") or 0
|
||
pos = card.get("file_position") or 0
|
||
progress = (pos / size) if size else 0
|
||
quoted_host = urllib.parse.quote(host)
|
||
fan_speed, fan_rpm = fan_values(status)
|
||
chamber, chamber_target, fans = chamber_and_fans(host, status)
|
||
if fans.get("part") is None:
|
||
fans["part"] = fan_speed
|
||
move = status.get("gcode_move") or {}
|
||
speed_factor = move.get("speed_factor")
|
||
if isinstance(speed_factor, (int, float)):
|
||
speed_factor = float(speed_factor)
|
||
else:
|
||
speed_factor = None
|
||
extrude_factor = move.get("extrude_factor")
|
||
if isinstance(extrude_factor, (int, float)):
|
||
extrude_factor = float(extrude_factor)
|
||
else:
|
||
extrude_factor = None
|
||
state = stats.get("state") or "standby"
|
||
message = stats.get("message") or ""
|
||
if (state or "").lower() == "error":
|
||
message = enrich_error_message(host, stats, status)
|
||
pause_code, pause_reason = None, ""
|
||
filament_empty = False
|
||
if (state or "").lower() == "paused":
|
||
# G-code-Store nur bei Pause abfragen — sonst unnötige Last.
|
||
pause_code, pause_reason = filament_out_hint(mmu, message, host=host)
|
||
filament_empty = bool(pause_code)
|
||
if filament_empty and pause_reason:
|
||
if not message or message.lower() in ("paused", "pause", "pausiert"):
|
||
message = pause_reason
|
||
toolhead = status.get("toolhead") or {}
|
||
axis_max = toolhead.get("axis_maximum") or []
|
||
z_max = None
|
||
if isinstance(axis_max, (list, tuple)) and len(axis_max) >= 3 and isinstance(axis_max[2], (int, float)):
|
||
z_max = float(axis_max[2])
|
||
return {
|
||
"ok": True,
|
||
"state": state,
|
||
"message": message,
|
||
"filament_empty": filament_empty,
|
||
"pause_reason": pause_reason or "",
|
||
"homed_axes": toolhead.get("homed_axes") or "",
|
||
"position": toolhead.get("position") or [],
|
||
"z_max": z_max,
|
||
"probe_z": _probe_z_of(status),
|
||
"filename": filename,
|
||
"progress": max(0, min(1, float(progress))),
|
||
"layer": info.get("current_layer") or card.get("current_layer") or 0,
|
||
"layers": info.get("total_layer") or card.get("total_layer") or 0,
|
||
"remain": card.get("remain_time") or 0,
|
||
"elapsed": stats.get("print_duration") or 0,
|
||
"nozzle": extruder.get("temperature"),
|
||
"nozzle_target": extruder.get("target"),
|
||
"nozzle_power": _heater_power(extruder),
|
||
"bed": bed.get("temperature"),
|
||
"bed_target": bed.get("target"),
|
||
"bed_power": _heater_power(bed),
|
||
"chamber": chamber,
|
||
"chamber_target": chamber_target,
|
||
"filament_used": filament_used,
|
||
"filament_total": meta.get("filament_total") or 0,
|
||
"mmu_tool": mmu_tool,
|
||
"material": card.get("filament_type") or meta.get("filament_type") or "",
|
||
"weight": (
|
||
sum(float(x or 0) for x in meta["filament_weight_total"])
|
||
if isinstance(meta.get("filament_weight_total"), list)
|
||
else (meta.get("filament_weight_total") or 0)
|
||
),
|
||
"height": meta.get("object_height") or 0,
|
||
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
|
||
"estimated": meta.get("estimated_time") or 0,
|
||
"preview": (
|
||
"/api/v1/vorschau?host=%s&file=%s" % (quoted_host, urllib.parse.quote(picture))
|
||
if picture else
|
||
"/api/v1/vorschau?host=%s&embedded=%s" % (quoted_host, urllib.parse.quote(raw_name))
|
||
if raw_name and EMBEDDED.get((host, raw_name)) else ""
|
||
),
|
||
**_camera_fields(host, quoted_host),
|
||
**mesh_fields(host, shown, mesh_profile),
|
||
"fan": fan_speed,
|
||
"fan_rpm": fan_rpm,
|
||
"fans": fans,
|
||
"speed_factor": speed_factor,
|
||
"extrude_factor": extrude_factor,
|
||
"slots": slots(host, status),
|
||
"dryers": extract_dryers(status),
|
||
"exclude": exclude_status(host),
|
||
"anycubic_task": _anycubic_task_field(host),
|
||
}
|
||
|
||
|
||
def _anycubic_task_field(host):
|
||
"""gkapi-Task aus print_info (TTL-Cache in anycubic_lan), best-effort."""
|
||
try:
|
||
from grotti import anycubic_lan
|
||
|
||
return anycubic_lan.anycubic_task(host)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def snapshot(printers):
|
||
def one(printer):
|
||
card = {
|
||
"name": printer["name"],
|
||
"host": printer["host"],
|
||
"family": printer["family"],
|
||
"family_label": printer["family_label"],
|
||
"mainsail": "http://%s/" % printer["host"],
|
||
}
|
||
try:
|
||
card.update(query(printer["host"]))
|
||
except Exception as error:
|
||
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
|
||
print("snapshot %s: %s" % (printer.get("host"), error), flush=True)
|
||
card.update({
|
||
"ok": False,
|
||
"state": "offline",
|
||
"message": str(error),
|
||
"filament_empty": False,
|
||
"pause_reason": "",
|
||
"camera": "",
|
||
"has_camera": False,
|
||
"fan": None,
|
||
"fan_rpm": None,
|
||
"fans": {"part": None, "chamber": None, "aux": None},
|
||
"chamber": None,
|
||
"chamber_target": None,
|
||
"speed_factor": None,
|
||
"extrude_factor": None,
|
||
"slots": [],
|
||
"dryers": [],
|
||
"anycubic_task": None,
|
||
})
|
||
card.update(mesh_fields(printer["host"]))
|
||
return card
|
||
|
||
with ThreadPoolExecutor(max_workers=max(1, len(printers))) as pool:
|
||
return list(pool.map(one, printers))
|
||
|
||
|
||
def proxy_image(host, url):
|
||
request = urllib.request.Request(url)
|
||
with urllib.request.urlopen(request, timeout=5) as response:
|
||
body = response.read()
|
||
kind = response.headers.get("Content-Type") or "image/jpeg"
|
||
return kind, body
|
||
|
||
|
||
def camera_image(host):
|
||
cached = CAMERA_CACHE.get(host)
|
||
now = time.time()
|
||
if cached and now - cached[0] < cached[1]:
|
||
return cached[2]
|
||
try:
|
||
kind, body = proxy_image(host, "http://%s/webcam/?action=snapshot" % host)
|
||
if not body.startswith(b"\xff\xd8") and not body.startswith(b"\x89PNG"):
|
||
raise urllib.error.URLError("kein Bild")
|
||
CAMERA_CACHE[host] = (now, 2, (kind, body))
|
||
except (urllib.error.URLError, TimeoutError):
|
||
CAMERA_CACHE[host] = (now, 30, None)
|
||
return CAMERA_CACHE[host][2]
|
||
|
||
|
||
def open_camera_stream(host):
|
||
"""Öffnet den MJPEG-Stream am Drucker. Aufrufer schließt die Response."""
|
||
url = "http://%s/webcam/?action=stream" % host
|
||
request = urllib.request.Request(url)
|
||
return urllib.request.urlopen(request, timeout=30)
|
||
|
||
|
||
def _camera_fields(host, quoted_host):
|
||
"""Kamera-URL nur, wenn nicht als tot bekannt (Cache-Miss = erst versuchen)."""
|
||
snap = "/api/v1/kamera?host=%s" % quoted_host
|
||
stream = "/api/v1/kamera/stream?host=%s" % quoted_host
|
||
cached = CAMERA_CACHE.get(host)
|
||
if cached is not None and cached[2] is None:
|
||
return {"camera": "", "camera_stream": "", "has_camera": False}
|
||
if cached is not None and cached[2] is not None:
|
||
return {"camera": snap, "camera_stream": stream, "has_camera": True}
|
||
return {"camera": snap, "camera_stream": stream, "has_camera": None}
|
||
|
||
|
||
def embedded_bytes(host, name):
|
||
return EMBEDDED.get((host, name))
|