Grotti 2.0.0: eigenes Repo, Umbenennung von Farm.
This commit is contained in:
commit
b2e6443c68
69 files changed
+18719
No files matched your search
@@ -0,0 +1,970 @@
|
||||
"""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
|
||||
|
||||
|
||||
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 fetch_bed_mesh(host):
|
||||
"""Roh-Bettnetz von Moonraker (ohne UI-Spiegelung)."""
|
||||
url = "http://%s/printer/objects/query?bed_mesh" % host
|
||||
payload = fetch_json(url, timeout=10)
|
||||
status = (payload.get("result") or {}).get("status") or {}
|
||||
mesh = status.get("bed_mesh") or {}
|
||||
matrix = _raw_matrix(mesh.get("probed_matrix"))
|
||||
if not matrix:
|
||||
matrix = _raw_matrix(mesh.get("mesh_matrix"))
|
||||
mesh_min = mesh.get("mesh_min")
|
||||
mesh_max = mesh.get("mesh_max")
|
||||
if not (isinstance(mesh_min, (list, tuple)) and len(mesh_min) >= 2):
|
||||
mesh_min = None
|
||||
if not (isinstance(mesh_max, (list, tuple)) and len(mesh_max) >= 2):
|
||||
mesh_max = None
|
||||
if not matrix or mesh_min is None or mesh_max is None:
|
||||
return None
|
||||
return {
|
||||
"matrix": matrix,
|
||||
"mesh_min": [float(mesh_min[0]), float(mesh_min[1])],
|
||||
"mesh_max": [float(mesh_max[0]), float(mesh_max[1])],
|
||||
"profile": mesh.get("profile_name") or "",
|
||||
}
|
||||
|
||||
|
||||
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 is_timeout(exc):
|
||||
text = (getattr(exc, "message", None) or str(exc) or "").lower()
|
||||
return "zu lange nicht geantwortet" in text or "timed out" in text
|
||||
|
||||
|
||||
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 multipart(filename, content):
|
||||
boundary = "----GrottiBoundary7f3a"
|
||||
ascii_name = "".join(ch if ch.isascii() and ch not in '"\\' else "_" for ch in filename) or "job.gcode"
|
||||
quoted = urllib.parse.quote(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\"; filename*=UTF-8''%s\r\n"
|
||||
"Content-Type: application/octet-stream\r\n\r\n"
|
||||
) % (boundary, ascii_name, quoted)
|
||||
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):
|
||||
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 {}
|
||||
return 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.
|
||||
"""
|
||||
use_mqtt = bool(bed_leveling) or bool(ams_settings)
|
||||
if not use_mqtt:
|
||||
return post_json(host, "/printer/print/start", {"filename": filename})
|
||||
from grotti import anycubic_lan
|
||||
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
|
||||
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 == "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)
|
||||
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
|
||||
return None
|
||||
png = embedded_png(text)
|
||||
EMBEDDED[key] = png
|
||||
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 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 _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")
|
||||
|
||||
|
||||
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")
|
||||
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 {}
|
||||
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)
|
||||
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,
|
||||
"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"),
|
||||
"bed": bed.get("temperature"),
|
||||
"bed_target": bed.get("target"),
|
||||
"chamber": chamber,
|
||||
"chamber_target": chamber_target,
|
||||
"material": card.get("filament_type") or meta.get("filament_type") or "",
|
||||
"weight": 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": "/api/v1/kamera?host=%s" % 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),
|
||||
"exclude": exclude_status(host),
|
||||
}
|
||||
|
||||
|
||||
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 (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as error:
|
||||
card.update({
|
||||
"ok": False,
|
||||
"state": "offline",
|
||||
"message": str(error),
|
||||
"fan": None,
|
||||
"fan_rpm": None,
|
||||
"fans": {"part": None, "chamber": None, "aux": None},
|
||||
"chamber": None,
|
||||
"chamber_target": None,
|
||||
"speed_factor": None,
|
||||
"extrude_factor": None,
|
||||
"slots": [],
|
||||
})
|
||||
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 embedded_bytes(host, name):
|
||||
return EMBEDDED.get((host, name))
|
||||
Reference in new issue
Block a user