Files
grotti/grotti/moonraker.py
T

1318 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 import logutil
from grotti.parse import embedded_png
log = logutil.get("grotti.moonraker")
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 _mmu_tool_index(mmu):
"""Aktives ACE/MMU-Tool, sonst None. −1 / fehlend = kein Tool (Anycubic-Normalfall)."""
if not isinstance(mmu, dict):
return None
tool = mmu.get("tool")
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
tool = mmu.get("gate")
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
return None
tool_i = int(tool)
return tool_i if tool_i >= 0 else None
def filament_out_hint(mmu, message="", host=None):
"""Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None).
Quellen: print_stats.message, mmu.reason_for_pause, gate_status bei aktivem Tool,
active_filament.empty nur mit aktivem Tool, optional G-code-Store (host).
Nicht: active_filament.empty allein — Anycubic ACE meldet empty=true auch
beim normalen Druck und bei manueller Pause (tool/gate = −1).
"""
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:
rfp = (mmu.get("reason_for_pause") or "").strip()
if rfp and _FILAMENT_HINT_RE.search(rfp):
if "10107" in rfp.lower():
return "reason", "Filament gerissen oder leer (10107)"
return "reason", rfp[:160]
tool_i = _mmu_tool_index(mmu)
states = mmu.get("gate_status")
# gate_status==0 nur, wenn Status differenziert (nicht alle 0 = Anycubic-Unknown)
if (
tool_i is not None
and isinstance(states, list)
and tool_i < len(states)
and int(states[tool_i] or 0) == 0
and any(int(s or 0) != 0 for s in states)
):
return "gate_empty", "ACE-Platz %d leer" % (tool_i + 1)
active = mmu.get("active_filament")
if (
tool_i is not None
and 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)"
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.
log.warning("snapshot %s: %s", printer.get("host"), error)
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))