Grotti 2.0.0: eigenes Repo, Umbenennung von Farm.

This commit is contained in:
Dominic Gundel committed 2026-10-15 08:45:56 +02:00
commit b2e6443c68
69 files changed
+18719

No files matched your search

+970
View File
@@ -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))