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

View File
Whitespace-only changes.
+111
View File
@@ -0,0 +1,111 @@
"""Anycubic ACE Sidecar (.acm) für Moonraker-Start."""
from __future__ import annotations
import json
import re
# Rinkhals mmu_ace: ohne `; referenced_tools = …` kein Auto-Feed / Nachschub.
TRAILER_WARN = "ACE-Nachschub braucht Slicer-Metadaten/Trailer"
def color_channels(value):
if isinstance(value, (list, tuple)) and len(value) >= 3:
out = []
for channel in value[:3]:
if isinstance(channel, bool) or not isinstance(channel, (int, float)):
return []
out.append(max(0, min(255, int(round(float(channel))))))
return out
if value in (None, ""):
return []
text = str(value).strip()
if text.startswith("#"):
text = text[1:]
text = re.sub(r"[^0-9A-Fa-f]", "", text)
if len(text) == 8:
# Moonraker mmu.gate_color: RRGGBBAA
text = text[:6]
if len(text) != 6:
return []
try:
return [int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16)]
except ValueError:
return []
def acm_filename(gcode_name):
"""Sidecar neben dem Start-Dateinamen: `job.gcode` → `job.gcode.acm`."""
name = (gcode_name or "job.gcode").replace("\\", "/").split("/")[-1]
if name.lower().endswith(".acm"):
return name
return name + ".acm"
def acm_upload_names(gcode_name):
"""Primärer Basename wie Start-filename, plus Stamm-Variante zum Überschreiben.
Anycubic/Rinkhals erwarten typisch `name.gcode.acm`. Parallel kann eine
ältere Identity-Map als `name.acm` liegen und Grotti-Map verdrängen.
"""
primary = acm_filename(gcode_name)
names = [primary]
base = (gcode_name or "job.gcode").replace("\\", "/").split("/")[-1]
if base.lower().endswith(".acm"):
base = base[: -len(".acm")]
if base.lower().endswith(".gcode"):
alt = base[: -len(".gcode")] + ".acm"
if alt.lower() != primary.lower():
names.append(alt)
return names
def normalize_mapping(raw):
if not isinstance(raw, list):
return []
out = []
seen = set()
for item in raw:
if not isinstance(item, dict):
continue
try:
paint = int(item.get("paint_index"))
ams = int(item.get("ams_index"))
except (TypeError, ValueError):
continue
if paint < 0 or ams < 0 or ams > 7 or paint in seen:
continue
seen.add(paint)
out.append({"paint_index": paint, "ams_index": ams})
return out
def build_acm(filaments, mapping, slots):
filaments = filaments or []
slots = slots or []
slot_by_index = {int(slot.get("index")): slot for slot in slots if isinstance(slot.get("index"), int)}
entries = []
for item in normalize_mapping(mapping):
paint = item["paint_index"]
ams = item["ams_index"]
filament = filaments[paint] if 0 <= paint < len(filaments) else {}
slot = slot_by_index.get(ams) or {}
paint_color = color_channels(filament.get("color")) or [0, 0, 0]
ams_color = color_channels(slot.get("color")) or paint_color
material = (filament.get("type") or slot.get("type") or "PLA").strip() or "PLA"
entries.append(
{
"paint_index": paint,
"paint_color": paint_color,
"material_type": material,
"ams_index": ams,
"ams_color": ams_color,
}
)
if not entries:
return None
return {"use_ams": True, "ams_box_mapping": entries}
def acm_bytes(payload):
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
+347
View File
@@ -0,0 +1,347 @@
"""Anycubic LAN: Credentials über :18910, Start/Stop über MQTTS :9883.
Entspricht Rinkhals kobra.py mqtt_print_file, aber mit pro-Job task_settings
(statt globaler App-Property mqtt_print_auto_leveling). Stop: action stop,
taskid -1 (Rinkhals MQTT-Docs) — nötig, weil Moonraker-cancel den Anycubic-Task
nicht räumt (sonst 10101).
"""
from __future__ import annotations
import hashlib
import json
import random
import ssl
import string
import time
import uuid
import urllib.error
import urllib.request
from base64 import b64decode
import paho.mqtt.client as mqtt
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
CRED_TTL = 300
_CRED_CACHE = {}
class LanError(Exception):
def __init__(self, message):
super().__init__(message)
self.message = message
def _info(host):
try:
with urllib.request.urlopen("http://%s:18910/info" % host, timeout=5) as response:
return json.load(response)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
raise LanError("LAN-Info vom Drucker nicht erreichbar (:18910).") from exc
def _ctrl(host, token, did="grotti"):
ts = str(int(time.time() * 1000))
nonce = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(6))
sign = hashlib.md5(
(hashlib.md5(token[:16].encode()).hexdigest() + ts + nonce).encode()
).hexdigest()
url = "http://%s:18910/ctrl?ts=%s&nonce=%s&sign=%s&did=%s" % (
host, ts, nonce, sign, did,
)
try:
request = urllib.request.Request(url, data=b"", method="POST")
with urllib.request.urlopen(request, timeout=8) as response:
payload = json.load(response)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
raise LanError("LAN-Credentials vom Drucker nicht erreichbar (:18910/ctrl).") from exc
if payload.get("code") != 200 or not isinstance(payload.get("data"), dict):
raise LanError("LAN-Credentials abgelehnt.")
return payload["data"]
def _decrypt(info_token, ctrl_data):
key = info_token[16:32].encode("ascii")
iv_raw = ctrl_data.get("token") or ""
iv = iv_raw.encode("ascii")[:16] if isinstance(iv_raw, str) else b""
if len(iv) != 16:
raise LanError("LAN-Credentials ungültig (IV).")
try:
blob = b64decode(ctrl_data["info"])
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
decryptor = cipher.decryptor()
raw = decryptor.update(blob) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
plain = unpadder.update(raw) + unpadder.finalize()
data = json.loads(plain.decode("utf-8"))
except Exception as exc:
raise LanError("LAN-Credentials lassen sich nicht lesen.") from exc
if not data.get("deviceId") or not data.get("username") or not data.get("password"):
raise LanError("LAN-Credentials unvollständig.")
return data
def credentials(host):
now = time.time()
cached = _CRED_CACHE.get(host)
if cached and now - cached[0] < CRED_TTL:
return cached[1]
info = _info(host)
token = info.get("token") or ""
if len(token) < 32:
raise LanError("LAN-Info ohne Token.")
data = _decrypt(token, _ctrl(host, token))
data["modelId"] = str(info.get("modelId") or data.get("modeId") or data.get("modelId") or "")
if not data["modelId"]:
raise LanError("LAN-Info ohne modelId.")
_CRED_CACHE[host] = (now, data)
return data
def _rgba(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 [0, 0, 0, 255]
channels.append(max(0, min(255, int(round(float(channel))))))
alpha = 255
if len(value) >= 4 and isinstance(value[3], (int, float)) and not isinstance(value[3], bool):
alpha = max(0, min(255, int(round(float(value[3])))))
return channels + [alpha]
return [0, 0, 0, 255]
def normalize_ams(ams_settings):
if not isinstance(ams_settings, dict):
return None
mapping = ams_settings.get("ams_box_mapping")
if not isinstance(mapping, list) or not mapping:
return None
entries = []
for item in mapping:
if not isinstance(item, dict):
continue
try:
paint = int(item.get("paint_index"))
ams = int(item.get("ams_index"))
except (TypeError, ValueError):
continue
material = (item.get("material_type") or "PLA").strip() or "PLA"
entries.append({
"paint_index": paint,
"ams_index": ams,
"paint_color": _rgba(item.get("paint_color")),
"ams_color": _rgba(item.get("ams_color") or item.get("paint_color")),
"material_type": material,
})
if not entries:
return None
return {"use_ams": True, "ams_box_mapping": entries}
def _report_error(body, default_message):
code = body.get("code")
if code == 10107:
return "Filament gerissen. Bitte neues laden. (code 10107)"
if code == 10101:
return (
"Alter Druckauftrag noch aktiv (Anycubic-MQTT). "
"Bitte zuerst abbrechen. (code 10101)"
)
message = str(body.get("msg") or default_message)
return "%s%s" % (message, (" (code %s)" % code) if code else "")
def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Druckbefehl."):
"""Publish auf slicer/…/print, warte auf print/report."""
cred = credentials(host)
model_id = cred["modelId"]
device_id = cred["deviceId"]
payload = json.dumps(request)
topic_print = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/print" % (model_id, device_id)
topic_report = "anycubic/anycubicCloud/v1/printer/public/%s/%s/print/report" % (
model_id, device_id,
)
state = {"done": False, "error": None, "report": None}
def on_connect(client, userdata, flags, reason_code, properties=None):
if getattr(reason_code, "is_failure", False):
state["error"] = "MQTT-Verbindung abgelehnt (%s)." % reason_code
state["done"] = True
return
client.subscribe(topic_report)
client.publish(topic_print, payload=payload, qos=1)
def on_message(client, userdata, msg):
try:
body = json.loads(msg.payload.decode("utf-8", "replace"))
except (json.JSONDecodeError, UnicodeError):
return
state["report"] = body
report = str(body.get("state") or "")
action = str(request.get("action") or "")
if action == "start" and report in ("failed", "stoped", "stopped"):
state["error"] = _report_error(body, "Druckstart abgelehnt")
elif action == "stop" and report in ("failed",):
state["error"] = _report_error(body, "Druckabbruch abgelehnt")
# start: heating / leveling / printing / preheating = angenommen
# stop: stoped / stopped / done = angenommen
state["done"] = True
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
protocol=mqtt.MQTTv311,
client_id="grotti-%s" % uuid.uuid4().hex[:10],
)
client.username_pw_set(cred["username"], cred["password"])
client.tls_set(cert_reqs=ssl.CERT_NONE)
client.tls_insecure_set(True)
client.on_connect = on_connect
client.on_message = on_message
try:
client.connect(host, 9883, keepalive=30)
except OSError as exc:
raise LanError("MQTT am Drucker nicht erreichbar (:9883).") from exc
deadline = time.time() + max(5, int(timeout))
try:
while not state["done"]:
if time.time() > deadline:
state["error"] = timeout_message
break
client.loop(timeout=0.25)
finally:
try:
client.disconnect()
except Exception:
pass
if state["error"]:
raise LanError(state["error"])
return state["report"]
def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=30):
"""MQTT-Start wie Rinkhals, mit task_settings.auto_leveling 1/0."""
name = (filename or "").replace("\\", "/").split("/")[-1]
if not name:
raise LanError("Dateiname fehlt.")
data = {
"filename": name,
"filepath": "/",
"taskid": str(random.randint(0, 1000000)),
"task_mode": 1,
"filetype": 1,
"task_settings": {
"auto_leveling": 1 if auto_leveling else 0,
"vibration_compensation": 0,
"flow_calibration": 0,
},
}
ams = normalize_ams(ams_settings)
if ams:
data["ams_settings"] = ams
request = {
"type": "print",
"action": "start",
"msgid": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"data": data,
}
timeout_message = "Timeout beim MQTT-Druckstart."
try:
_mqtt_print(host, request, timeout=timeout, timeout_message=timeout_message)
except LanError as exc:
# Nach Bettnetz-Prüfung kam das Start-Report schon > 30 s zu spät, der Druck lief trotzdem.
if exc.message != timeout_message:
raise
for attempt in range(3):
project = (print_info(host) or {}).get("project") or {}
if str(project.get("task_id") or "") == data["taskid"]:
break
time.sleep(5)
else:
raise
return {"filename": name, "auto_leveling": bool(auto_leveling)}
def print_info(host, timeout=10):
"""MQTT info/query, liefert data aus info/report (project.task_id, state …)."""
cred = credentials(host)
model_id = cred["modelId"]
device_id = cred["deviceId"]
topic_query = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/info" % (model_id, device_id)
topic_report = "anycubic/anycubicCloud/v1/printer/public/%s/%s/info/report" % (
model_id, device_id,
)
payload = json.dumps({
"type": "info",
"action": "query",
"msgid": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"data": None,
})
state = {"data": None}
def on_connect(client, userdata, flags, reason_code, properties=None):
if getattr(reason_code, "is_failure", False):
return
client.subscribe(topic_report)
client.publish(topic_query, payload=payload, qos=1)
def on_message(client, userdata, msg):
try:
body = json.loads(msg.payload.decode("utf-8", "replace"))
except (json.JSONDecodeError, UnicodeError):
return
if isinstance(body.get("data"), dict):
state["data"] = body["data"]
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
protocol=mqtt.MQTTv311,
client_id="grotti-%s" % uuid.uuid4().hex[:10],
)
client.username_pw_set(cred["username"], cred["password"])
client.tls_set(cert_reqs=ssl.CERT_NONE)
client.tls_insecure_set(True)
client.on_connect = on_connect
client.on_message = on_message
try:
client.connect(host, 9883, keepalive=30)
except OSError:
return None
deadline = time.time() + max(3, int(timeout))
try:
while state["data"] is None and time.time() < deadline:
client.loop(timeout=0.25)
finally:
try:
client.disconnect()
except Exception:
pass
return state["data"]
def cancel_print(host, timeout=15):
"""MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task)."""
request = {
"type": "print",
"action": "stop",
"msgid": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"data": {"taskid": "-1"},
}
report = _mqtt_print(
host,
request,
timeout=timeout,
timeout_message="Timeout beim MQTT-Druckabbruch.",
)
return {"mqtt": "stop", "report": report}
+257
View File
@@ -0,0 +1,257 @@
"""Sorten aus den Filamentprofilen und die Farbvorauswahl."""
MATERIALS = [
{
"name": "Sunlu PLA",
"brand": "Sunlu",
"material_type": "PLA",
"density": 1.24,
"volumetric": 12,
"volumetric_s1": 14,
"machines": "beide",
"price_per_kg": 0,
"note": "Holz und Marmor laufen über dieses Profil. Glow nur mit gehärteter Düse.",
},
{
"name": "Sunlu PLA Meta",
"brand": "Sunlu",
"material_type": "PLA",
"density": 1.21,
"volumetric": 14,
"volumetric_s1": 16,
"machines": "beide",
"price_per_kg": 0,
"note": "",
},
{
"name": "Sunlu PLA Matte",
"brand": "Sunlu",
"material_type": "PLA",
"density": 1.31,
"volumetric": 10,
"volumetric_s1": 10,
"machines": "beide",
"price_per_kg": 0,
"note": "",
},
{
"name": "Sunlu PLA Silk",
"brand": "Sunlu",
"material_type": "PLA",
"density": 1.24,
"volumetric": 8,
"volumetric_s1": 8,
"machines": "beide",
"price_per_kg": 0,
"note": "Langsamer, damit die Oberfläche glänzt.",
},
{
"name": "Sunlu PLA+",
"brand": "Sunlu",
"material_type": "PLA",
"density": 1.21,
"volumetric": 12,
"volumetric_s1": 13,
"machines": "beide",
"price_per_kg": 0,
"note": "",
},
{
"name": "Sunlu PETG",
"brand": "Sunlu",
"material_type": "PETG",
"density": 1.27,
"volumetric": 10,
"volumetric_s1": 12,
"machines": "beide",
"price_per_kg": 0,
"note": "",
},
{
"name": "Sunlu PETG Matte",
"brand": "Sunlu",
"material_type": "PETG",
"density": 1.27,
"volumetric": 9,
"volumetric_s1": 10,
"machines": "beide",
"price_per_kg": 0,
"note": "",
},
{
"name": "Sunlu TPU 95A",
"brand": "Sunlu",
"material_type": "TPU",
"density": 1.21,
"volumetric": 5,
"volumetric_s1": 5,
"machines": "beide",
"price_per_kg": 0,
"note": "",
},
{
"name": "NinjaFlex",
"brand": "NinjaTek",
"material_type": "TPU",
"density": 1.19,
"volumetric": 2,
"volumetric_s1": None,
"machines": "kobra_3",
"price_per_kg": 0,
"note": "Nur Kobra 3.",
},
{
"name": "Sunlu ASA",
"brand": "Sunlu",
"material_type": "ASA",
"density": 1.04,
"volumetric": 12,
"volumetric_s1": 12,
"machines": "kobra_s1",
"price_per_kg": 0,
"note": "Nur Kobra S1, Tür zu.",
},
{
"name": "Formfutura ApolloX",
"brand": "Formfutura",
"material_type": "ASA",
"density": 1.07,
"volumetric": 12,
"volumetric_s1": 12,
"machines": "kobra_s1",
"price_per_kg": 0,
"note": "Nur Kobra S1, Tür zu.",
},
{
"name": "Sunlu ABS",
"brand": "Sunlu",
"material_type": "ABS",
"density": 1.02,
"volumetric": 11,
"volumetric_s1": 11,
"machines": "kobra_s1",
"price_per_kg": 0,
"note": "Nur Kobra S1, Tür zu.",
},
{
"name": "Sunlu PA12-CF",
"brand": "Sunlu",
"material_type": "PA",
"density": 1.08,
"volumetric": 8,
"volumetric_s1": 8,
"machines": "kobra_s1",
"price_per_kg": 50,
"note": "Nur Kobra S1, gehärtete Düse. Der ACE trocknet das nicht, dafür den externen Trockner.",
},
{
"name": "eSUN PETG-CF",
"brand": "eSUN",
"material_type": "PETG",
"density": 1.26,
"volumetric": 8,
"volumetric_s1": 8,
"machines": "kobra_s1",
"price_per_kg": 0,
"note": "Nur Kobra S1, gehärtete Düse.",
},
]
SOLIDS = [
{"id": "schwarz", "name": "Schwarz", "hex": "#1c1c1c"},
{"id": "weiss", "name": "Weiß", "hex": "#f4f4f4"},
{"id": "grau", "name": "Grau", "hex": "#8a8f8c"},
{"id": "rot", "name": "Rot", "hex": "#c23b3b"},
{"id": "orange", "name": "Orange", "hex": "#e07a2f"},
{"id": "gelb", "name": "Gelb", "hex": "#e2c04a"},
{"id": "gruen", "name": "Grün", "hex": "#3d8f5a"},
{"id": "blau", "name": "Blau", "hex": "#3a6ea5"},
{"id": "hellblau", "name": "Hellblau", "hex": "#7eb6d9"},
{"id": "lila", "name": "Lila", "hex": "#7a5ea7"},
{"id": "pink", "name": "Pink", "hex": "#e7a0b4"},
{"id": "braun", "name": "Braun", "hex": "#8a5a3c"},
{"id": "beige", "name": "Beige", "hex": "#d7c4a3"},
{"id": "natur", "name": "Natur", "hex": "#e4d7c3"},
{"id": "gold", "name": "Gold", "hex": "#d4af37"},
{"id": "silber", "name": "Silber", "hex": "#c0c6cc"},
{"id": "transparent", "name": "Transparent", "hex": "#d5e4ea"},
{"id": "kohle", "name": "Kohlefaser", "hex": "#2a2a2a"},
{"id": "glow", "name": "Glow", "hex": "#b6f25c"},
]
MULTI = [
{
"id": "regenbogen",
"name": "Regenbogen",
"colors": [
{"name": "Rot", "hex": "#e23b3b"},
{"name": "Orange", "hex": "#e07a2f"},
{"name": "Gelb", "hex": "#e2c04a"},
{"name": "Grün", "hex": "#3d8f5a"},
{"name": "Blau", "hex": "#3a6ea5"},
{"name": "Lila", "hex": "#7a5ea7"},
],
},
{
"id": "holz",
"name": "Holz",
"colors": [
{"name": "Braun", "hex": "#8a5a3c"},
{"name": "Beige", "hex": "#d7c4a3"},
{"name": "Dunkelbraun", "hex": "#5c3a24"},
],
},
{
"id": "marmor",
"name": "Marmor",
"colors": [
{"name": "Weiß", "hex": "#f4f4f4"},
{"name": "Grau", "hex": "#8a8f8c"},
{"name": "Schwarz", "hex": "#1c1c1c"},
],
},
{
"id": "schwarz-weiss",
"name": "Schwarz / Weiß",
"colors": [
{"name": "Schwarz", "hex": "#1c1c1c"},
{"name": "Weiß", "hex": "#f4f4f4"},
],
},
{
"id": "rot-schwarz",
"name": "Rot / Schwarz",
"colors": [
{"name": "Rot", "hex": "#c23b3b"},
{"name": "Schwarz", "hex": "#1c1c1c"},
],
},
{
"id": "blau-weiss",
"name": "Blau / Weiß",
"colors": [
{"name": "Blau", "hex": "#3a6ea5"},
{"name": "Weiß", "hex": "#f4f4f4"},
],
},
{
"id": "gold-silber",
"name": "Gold / Silber",
"colors": [
{"name": "Gold", "hex": "#d4af37"},
{"name": "Silber", "hex": "#c0c6cc"},
],
},
]
MACHINE_LABELS = {
"beide": "Kobra 3 und S1",
"kobra_3": "nur Kobra 3",
"kobra_s1": "nur Kobra S1",
}
def color_catalog():
solids = [{"id": row["id"], "name": row["name"], "colors": [{"name": row["name"], "hex": row["hex"]}]} for row in SOLIDS]
multi = [{"id": row["id"], "name": row["name"], "colors": row["colors"]} for row in MULTI]
return {"solids": solids, "multi": multi}
+170
View File
@@ -0,0 +1,170 @@
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
CONFIG_PATH = ROOT / "config.json"
def _energy(raw):
energy = raw.get("energy") or {}
return {
"watts": float(energy.get("watts", 120)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)),
"waste_percent": float(energy.get("waste_percent", 0)),
"utilization": float(energy.get("utilization", 50)),
}
def _telegram(raw):
tg = raw.get("telegram") or {}
return {
"enabled": bool(tg.get("enabled")),
"bot_token": str(tg.get("bot_token") or ""),
"chat_id": str(tg.get("chat_id") or ""),
}
def _alerts(raw):
alerts = raw.get("alerts") or {}
return {
"fertig": bool(alerts.get("fertig", True)),
"fehler": bool(alerts.get("fehler", True)),
"offline": bool(alerts.get("offline", True)),
"queue": bool(alerts.get("queue", True)),
"ablauf": bool(alerts.get("ablauf", False)),
"start": bool(alerts.get("start", False)),
}
def normalize_fqdn(value):
"""Leer string oder Basis-URL ohne trailing slash; ohne Schema → https://."""
text = str(value or "").strip().rstrip("/")
if not text:
return ""
if "://" not in text:
text = "https://" + text
return text.rstrip("/")
def public_base(cfg, request_root=None):
"""Öffentliche Basis für Etiketten-QR: fqdn, sonst Request-Root."""
base = normalize_fqdn(cfg.get("fqdn") or "")
if base:
return base
return str(request_root or "").rstrip("/")
def load():
path = CONFIG_PATH
if not path.exists():
path.write_text((ROOT / "config.example.json").read_text(encoding="utf-8"), encoding="utf-8")
raw = json.loads(path.read_text(encoding="utf-8"))
host = (raw.get("host") or "127.0.0.1").strip()
token = raw.get("token") or ""
if host not in LOOPBACK and not token:
raise SystemExit("Ohne Token startet der Dienst nicht, wenn er nicht nur lokal lauscht.")
data_dir = Path(raw.get("data_dir") or "data")
if not data_dir.is_absolute():
data_dir = ROOT / data_dir
watch = (raw.get("watch_dir") or "").strip()
watch_dir = Path(watch) if watch else None
if watch_dir and not watch_dir.is_absolute():
watch_dir = (ROOT / watch_dir).resolve()
return {
"host": host,
"port": int(raw.get("port") or 8770),
"data_dir": data_dir,
"watch_dir": watch_dir,
"database_url": raw.get("database_url") or "postgresql://[email protected]:5432/grotti",
"token": token,
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
"printers_file": ROOT / "drucker.json",
"energy": _energy(raw),
"telegram": _telegram(raw),
"alerts": _alerts(raw),
"auto_queue": bool(raw.get("auto_queue", True)),
"_raw": raw,
"_path": path,
}
def mask_token(token):
token = token or ""
if len(token) <= 8:
return "••••" if token else ""
return token[:4] + "…" + token[-2:]
def settings_public(cfg):
tg = cfg.get("telegram") or {}
return {
"watch_dir": str(cfg.get("watch_dir") or ""),
"auto_queue": bool(cfg.get("auto_queue", True)),
"fqdn": str(cfg.get("fqdn") or ""),
"energy": dict(cfg.get("energy") or {}),
"telegram": {
"enabled": bool(tg.get("enabled")),
"bot_token": mask_token(tg.get("bot_token") or ""),
"bot_token_set": bool(tg.get("bot_token")),
"chat_id": str(tg.get("chat_id") or ""),
},
"alerts": dict(cfg.get("alerts") or {}),
}
def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None):
"""Schreibt energy/telegram/alerts/auto_queue/fqdn zurück nach config.json und aktualisiert cfg."""
path = cfg.get("_path") or CONFIG_PATH
raw = dict(cfg.get("_raw") or {})
if path.exists():
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
if auto_queue is not None:
raw["auto_queue"] = bool(auto_queue)
cfg["auto_queue"] = bool(auto_queue)
if fqdn is not None:
normalized = normalize_fqdn(fqdn)
raw["fqdn"] = normalized
cfg["fqdn"] = normalized
if energy is not None:
raw["energy"] = {
"watts": float(energy.get("watts", 120)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)),
"waste_percent": float(energy.get("waste_percent", 0)),
"utilization": float(energy.get("utilization", 50)),
}
cfg["energy"] = _energy(raw)
if telegram is not None:
current = _telegram(raw)
token = telegram.get("bot_token")
if token is None or token == "" or "…" in str(token) or str(token).startswith("••••"):
token = current["bot_token"]
raw["telegram"] = {
"enabled": bool(telegram.get("enabled", current["enabled"])),
"bot_token": str(token or ""),
"chat_id": str(telegram.get("chat_id", current["chat_id"]) or ""),
}
cfg["telegram"] = _telegram(raw)
if alerts is not None:
raw["alerts"] = {
"fertig": bool(alerts.get("fertig", True)),
"fehler": bool(alerts.get("fehler", True)),
"offline": bool(alerts.get("offline", True)),
"queue": bool(alerts.get("queue", True)),
"ablauf": bool(alerts.get("ablauf", False)),
"start": bool(alerts.get("start", False)),
}
cfg["alerts"] = _alerts(raw)
path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
cfg["_raw"] = raw
cfg["_path"] = path
return settings_public(cfg)
+42
View File
@@ -0,0 +1,42 @@
"""Dieselbe Rechnung wie in der Druckkalkulation."""
def calculate(job, material):
part_g = float(job.get("weight_g") or 0) * max(1, float(job.get("qty") or 1))
waste = float(job.get("waste_percent") or 0)
filament_g = part_g * (1 + waste / 100)
price = float(material.get("price_per_kg") or 0)
filament_cost = (filament_g / 1000) * price
util = min(100, max(1, float(job.get("utilization") or 1))) / 100
density = float(material.get("density") or 0) or 1
volumetric = float(material.get("volumetric") or 0)
cm3_per_hour = volumetric * 3.6 * util
hours = (filament_g / density) / cm3_per_hour if cm3_per_hour else 0
watts = float(job.get("watts") or 0)
euro_per_kwh = float(job.get("euro_per_kwh") or 0)
print_kwh = (watts / 1000) * hours
dry_kwh = (float(job.get("dryer_watts") or 0) / 1000) * float(job.get("dry_hours") or 0)
energy_cost = (print_kwh + dry_kwh) * euro_per_kwh
if job.get("price_mode") == "perKg":
revenue = (part_g / 1000) * float(job.get("price") or 0)
else:
revenue = float(job.get("price") or 0) * max(1, float(job.get("qty") or 1))
margin = revenue - filament_cost - energy_cost
per_hour = margin / hours if hours else 0
return {
"part_g": part_g,
"filament_g": filament_g,
"filament_cost": filament_cost,
"hours": hours,
"energy_cost": energy_cost,
"revenue": revenue,
"margin": margin,
"per_hour": per_hour,
"print_kwh": print_kwh,
"dry_kwh": dry_kwh,
}
def energy_cost(duration_s, energy):
hours = float(duration_s or 0) / 3600
return (float(energy["watts"]) / 1000) * hours * float(energy["euro_per_kwh"])
+62
View File
@@ -0,0 +1,62 @@
"""PostgreSQL-Verbindung und Schema."""
import re
from contextlib import contextmanager
from pathlib import Path
from urllib.parse import urlparse, urlunparse
import psycopg
from psycopg import sql
from psycopg.rows import dict_row
SCHEMA = (Path(__file__).parent / "schema.sql").read_text(encoding="utf-8")
def database_name(url):
name = urlparse(url).path.lstrip("/")
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""):
raise SystemExit("Der Datenbankname in database_url darf nur Buchstaben, Zahlen und Unterstrich enthalten.")
return name
def apply_schema(conn):
for statement in SCHEMA.split(";"):
cleaned = statement.strip()
if cleaned:
conn.execute(cleaned)
def prepare(url):
name = database_name(url)
admin = urlunparse(urlparse(url)._replace(path="/postgres"))
try:
with psycopg.connect(admin, autocommit=True, connect_timeout=5) as conn:
exists = conn.execute("SELECT 1 FROM pg_database WHERE datname = %s", (name,)).fetchone()
if not exists:
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
except psycopg.Error:
pass
try:
with psycopg.connect(url, connect_timeout=5) as conn:
apply_schema(conn)
conn.commit()
except psycopg.Error as exc:
raise SystemExit(
"PostgreSQL ist nicht erreichbar.\n"
"In config.json bei database_url das Passwort eintragen, zum Beispiel\n"
"postgresql://postgres:[email protected]:5432/grotti\n"
"Die Datenbank grotti legt die App an, sobald die Anmeldung klappt.\n"
+ str(exc)
) from exc
@contextmanager
def connect(url):
conn = psycopg.connect(url, row_factory=dict_row)
try:
yield conn
except Exception:
conn.rollback()
raise
finally:
conn.close()
+671
View File
@@ -0,0 +1,671 @@
"""Abräumen auf der Kobra 3: Abkühlen und mit dem Extruder nach vorn schieben."""
from __future__ import annotations
from grotti.errors import GrottiError
from grotti.printers import family_from_name
# Grenzen gegen Tippfehler (Wischpad ~Y 260, Achse max 260 — Ansetzen knapper davor)
LIMITS = {
"x_min": 0.0,
"x_max": 250.0,
"y_min": 0.0,
"y_max": 250.0,
"z_push_min": 0.6,
"z_push_max": 30.0,
"z_travel_min": 1.0,
"z_travel_max": 80.0,
"speed_min": 5.0,
"speed_max": 80.0,
"bed_max_lo": 15.0,
"bed_max_hi": 80.0,
"nozzle_max_lo": 20.0,
"nozzle_max_hi": 120.0,
"fan_lo": 0,
"fan_hi": 100,
"timeout_lo": 1,
"timeout_hi": 120,
"max_delta_lo": 0.05,
"max_delta_hi": 5.0,
"check_timeout_lo": 1,
"check_timeout_hi": 15,
}
DEFAULT_COOL = {
"type": "cool",
"bed_max": 30.0,
"nozzle_max": 45.0,
"fan_percent": 100,
"timeout_min": 25,
}
DEFAULT_SWEEP = {
"type": "sweep",
"z_push": 2.0,
"z_travel": 20.0,
"x_from": 25.0,
"x_to": 225.0,
"x_step": 20.0,
# möglichst weit hinten, bevor Z sinkt — Present war 220, Teile oft davor
"y_from": 250.0,
"y_to": 5.0,
"speed": 25.0,
}
# Ein Punkt in der Bettmitte — kein LeviQ, kein 3×3, kein Heizen.
DEFAULT_CHECK = {
"type": "check",
# Result is z= gegen Grenze. Temporär 3 mm — kalter Kunststoff an der Düse
# tastet sonst oft 1–2 mm und meldet Restteil. Später wieder straffen.
"max_delta_mm": 3.0,
"timeout_min": 2,
"x": 125.0,
"y": 125.0,
"ref_x": 25.0,
"ref_y": 125.0,
"z_clear": 20.0,
}
def _num(value, default, lo, hi):
try:
n = float(value if value is not None else default)
except (TypeError, ValueError):
n = float(default)
return max(lo, min(hi, n))
def _int(value, default, lo, hi):
return int(round(_num(value, default, lo, hi)))
def require_kobra_3(printer):
family = (printer or {}).get("family") or family_from_name((printer or {}).get("name") or "")
if family != "kobra_3":
raise GrottiError("Abräumen gibt es nur auf der Kobra 3.", "familie")
return family
def normalize_cool(step=None):
raw = step if isinstance(step, dict) else {}
return {
"type": "cool",
"bed_max": _num(raw.get("bed_max"), DEFAULT_COOL["bed_max"], LIMITS["bed_max_lo"], LIMITS["bed_max_hi"]),
"nozzle_max": _num(
raw.get("nozzle_max"), DEFAULT_COOL["nozzle_max"], LIMITS["nozzle_max_lo"], LIMITS["nozzle_max_hi"]
),
"fan_percent": _int(raw.get("fan_percent"), DEFAULT_COOL["fan_percent"], LIMITS["fan_lo"], LIMITS["fan_hi"]),
"timeout_min": _int(
raw.get("timeout_min"), DEFAULT_COOL["timeout_min"], LIMITS["timeout_lo"], LIMITS["timeout_hi"]
),
}
def normalize_sweep(step=None):
raw = step if isinstance(step, dict) else {}
z_push = _num(raw.get("z_push"), DEFAULT_SWEEP["z_push"], LIMITS["z_push_min"], LIMITS["z_push_max"])
z_travel = _num(raw.get("z_travel"), DEFAULT_SWEEP["z_travel"], LIMITS["z_travel_min"], LIMITS["z_travel_max"])
if z_travel < z_push + 1.0:
z_travel = min(LIMITS["z_travel_max"], z_push + 1.0)
x_from = _num(raw.get("x_from"), DEFAULT_SWEEP["x_from"], LIMITS["x_min"], LIMITS["x_max"])
x_to = _num(raw.get("x_to"), DEFAULT_SWEEP["x_to"], LIMITS["x_min"], LIMITS["x_max"])
x_step = abs(_num(raw.get("x_step"), DEFAULT_SWEEP["x_step"], 1.0, 100.0))
y_from = _num(raw.get("y_from"), DEFAULT_SWEEP["y_from"], LIMITS["y_min"], LIMITS["y_max"])
y_to = _num(raw.get("y_to"), DEFAULT_SWEEP["y_to"], LIMITS["y_min"], LIMITS["y_max"])
if y_from <= y_to:
raise GrottiError("Schieben: Y hinten (y_from) muss größer sein als Y vorn (y_to).", "bahn")
return {
"type": "sweep",
"z_push": z_push,
"z_travel": z_travel,
"x_from": x_from,
"x_to": x_to,
"x_step": x_step,
"y_from": y_from,
"y_to": y_to,
"speed": _num(raw.get("speed"), DEFAULT_SWEEP["speed"], LIMITS["speed_min"], LIMITS["speed_max"]),
}
def normalize_print(step=None, fallback=None):
raw = step if isinstance(step, dict) else {}
fb = fallback if isinstance(fallback, dict) else {}
try:
file_id = int(raw.get("file_id") if raw.get("file_id") is not None else fb.get("file_id"))
except (TypeError, ValueError) as exc:
raise GrottiError("Druck-Schritt braucht eine Datei.", "datei") from exc
spool_raw = raw.get("spool_id") if "spool_id" in raw else fb.get("spool_id")
try:
spool_id = int(spool_raw) if spool_raw not in (None, "") else None
except (TypeError, ValueError):
spool_id = None
ace = raw.get("ace_map") if isinstance(raw.get("ace_map"), list) else fb.get("ace_map")
if not isinstance(ace, list):
ace = []
return {
"type": "print",
"file_id": file_id,
"spool_id": spool_id,
"ace_map": ace,
"bed_leveling": bool(raw.get("bed_leveling", fb.get("bed_leveling", False))),
"override_machine": bool(raw.get("override_machine", fb.get("override_machine", False))),
}
def normalize_check(step=None):
raw = step if isinstance(step, dict) else {}
# Alte enge Grenzen (1.0 / 2.5) → aktueller Default (Düsen-Klumpen).
raw_delta = raw.get("max_delta_mm")
try:
if raw_delta is not None and raw_delta != "":
d = float(raw_delta)
if abs(d - 2.5) < 1e-9 or abs(d - 1.0) < 1e-9:
raw_delta = DEFAULT_CHECK["max_delta_mm"]
except (TypeError, ValueError):
raw_delta = raw.get("max_delta_mm")
return {
"type": "check",
"max_delta_mm": _num(
raw_delta, DEFAULT_CHECK["max_delta_mm"], LIMITS["max_delta_lo"], LIMITS["max_delta_hi"]
),
"timeout_min": _int(
raw.get("timeout_min"),
DEFAULT_CHECK["timeout_min"],
LIMITS["check_timeout_lo"],
LIMITS["check_timeout_hi"],
),
"x": _num(raw.get("x"), DEFAULT_CHECK["x"], LIMITS["x_min"], LIMITS["x_max"]),
"y": _num(raw.get("y"), DEFAULT_CHECK["y"], LIMITS["y_min"], LIMITS["y_max"]),
"ref_x": _num(raw.get("ref_x"), DEFAULT_CHECK["ref_x"], LIMITS["x_min"], LIMITS["x_max"]),
"ref_y": _num(raw.get("ref_y"), DEFAULT_CHECK["ref_y"], LIMITS["y_min"], LIMITS["y_max"]),
"z_clear": _num(
raw.get("z_clear"), DEFAULT_CHECK["z_clear"], LIMITS["z_travel_min"], LIMITS["z_travel_max"]
),
}
def normalize_steps(steps, fallback=None):
if not isinstance(steps, list) or not steps:
raise GrottiError("Ablauf braucht mindestens einen Schritt.", "ablauf")
out = []
for raw in steps:
if not isinstance(raw, dict):
raise GrottiError("Ungültiger Ablauf-Schritt.", "ablauf")
kind = (raw.get("type") or "").strip().lower()
if kind == "print":
out.append(normalize_print(raw, fallback))
elif kind == "cool":
out.append(normalize_cool(raw))
elif kind == "sweep":
out.append(normalize_sweep(raw))
elif kind == "check":
out.append(normalize_check(raw))
else:
raise GrottiError("Unbekannter Schritt: %s" % kind, "ablauf")
if not any(step["type"] == "print" for step in out):
raise GrottiError("Ablauf braucht mindestens einen Druck-Schritt.", "ablauf")
return out
def first_print_file_id(steps):
for step in steps:
if step.get("type") == "print":
return step["file_id"]
return None
def x_positions(sweep):
start = sweep["x_from"]
end = sweep["x_to"]
step = sweep["x_step"]
if abs(end - start) < 0.01:
return [round(start, 2)]
direction = 1.0 if end >= start else -1.0
positions = []
x = start
# inklusive Endpunkt, ohne Endlosschleife
for _ in range(200):
positions.append(round(x, 2))
if direction > 0 and x >= end - 0.01:
break
if direction < 0 and x <= end + 0.01:
break
x += direction * step
if direction > 0 and x > end:
positions.append(round(end, 2))
break
if direction < 0 and x < end:
positions.append(round(end, 2))
break
# Duplikate am Ende entfernen
cleaned = []
for pos in positions:
if not cleaned or abs(cleaned[-1] - pos) > 0.01:
cleaned.append(pos)
return cleaned
# Abkühlen: Düse zur Bettmitte (Park während Abkühlung).
COOL_PARK_X = 125.0
COOL_PARK_Y = 125.0
# Slice-Ende hebt Z nicht — relativ weit hoch, gegen axis_maximum Z gekappt.
COOL_PARK_LIFT = 40.0
COOL_PARK_Z_MAX_DEFAULT = 250.0
COOL_PARK_TOLERANCE_MM = 20.0
COOL_PARK_Z_TOLERANCE_MM = 2.0
def cool_script(cool):
fan = max(0, min(100, int(cool["fan_percent"])))
pwm = int(round(fan * 255 / 100)) if fan > 0 else 0
lines = [
"SET_HEATER_TEMPERATURE HEATER=extruder TARGET=0",
"SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=0",
]
if pwm > 0:
lines.append("M106 S%d" % pwm)
else:
lines.append("M107")
return "\n".join(lines)
def cool_park_z_now(live):
pos = (live or {}).get("position") or []
if len(pos) >= 3 and isinstance(pos[2], (int, float)):
return float(pos[2])
return None
def cool_park_z_max(live):
z_max = (live or {}).get("z_max")
if isinstance(z_max, (int, float)) and float(z_max) > 10:
return float(z_max)
return float(COOL_PARK_Z_MAX_DEFAULT)
def cool_park_lift_mm(live):
"""Relativer Z-Hub, begrenzt auf verbleibenden Weg bis Z-max."""
z_now = cool_park_z_now(live)
z_max = cool_park_z_max(live)
want = float(COOL_PARK_LIFT)
if z_now is None:
return want
headroom = z_max - z_now
if headroom < 0.5:
return 0.0
return min(want, headroom)
def cool_park_script(live=None):
"""Relativ Z hoch (gegen Z-max gekappt), dann Bettmitte."""
lift = cool_park_lift_mm(live)
lines = ["M83", "G90"]
if lift >= 0.5:
lines.extend([
"G91",
"G0 Z%.2f F1800" % lift,
"G90",
])
lines.extend([
"G0 X%.2f Y%.2f F6000" % (COOL_PARK_X, COOL_PARK_Y),
"M400",
"G90",
])
return "\n".join(lines)
def cool_parked(live, before_z=None, lift_mm=None, tolerance_mm=COOL_PARK_TOLERANCE_MM):
"""True wenn Mitte und geplanter Z-Hub angekommen ist (before_z/lift_mm nötig)."""
pos = (live or {}).get("position") or []
if len(pos) < 3 or not all(isinstance(v, (int, float)) for v in pos[:3]):
return False
dx = float(pos[0]) - COOL_PARK_X
dy = float(pos[1]) - COOL_PARK_Y
if (dx * dx + dy * dy) ** 0.5 > float(tolerance_mm):
return False
if before_z is None or lift_mm is None:
return False
if float(lift_mm) < 0.5:
return True
need = float(before_z) + float(lift_mm) - float(COOL_PARK_Z_TOLERANCE_MM)
return float(pos[2]) >= need
def idle_timeout_script(cool=None):
"""Leerlauf-Timeout verlängern — Stepper bleiben gehomed.
Ohne cool-Schritt: 1 h (Ablauf-Druck). Mit cool: Cool-Zeit + 10 min.
"""
if cool and isinstance(cool, dict) and cool.get("timeout_min") is not None:
seconds = max(900, int(cool["timeout_min"]) * 60 + 600)
else:
seconds = 3600
return "SET_IDLE_TIMEOUT TIMEOUT=%d" % seconds
def strip_end_motor_disable(content):
"""M84/M18 aus G-code entfernen — Slice-Ende schaltet sonst die Stepper aus.
Anycubic Present-Ende: `M84; disable motors` nach X250 Y220. Danach unhomed,
Abkühlen/Schieben scheitern. Kommentarzeilen bleiben.
"""
if content is None:
return content
if isinstance(content, bytes):
text = content.decode("utf-8", errors="replace")
as_bytes = True
else:
text = str(content)
as_bytes = False
out = []
for line in text.splitlines(keepends=True):
body = line.split(";", 1)[0].strip().upper()
if body == "M84" or body.startswith("M84 ") or body == "M18" or body.startswith("M18 "):
continue
out.append(line)
text = "".join(out)
return text.encode("utf-8") if as_bytes else text
def restore_position_script(live, sweep, allow_low_z=False):
"""Nach M84/Idle: letzte Toolhead-XYZ übernehmen (keine Bewegung).
Nur echte Moonraker-Position — keine erfundene Z. Nach Slice-M84 steht der Kopf
noch am Present; SET_KINEMATIC mit genau dieser XYZ ist korrekt. Zu niedrige Z → Abbruch.
"""
_ = allow_low_z
pos = (live or {}).get("position") or []
if len(pos) < 3 or not all(isinstance(v, (int, float)) for v in pos[:3]):
raise GrottiError(
"Achsen nicht gehomed und letzte Position unbekannt. "
"Bei leerem Bett in Mainsail G28, dann Ablauf neu.",
"homed",
)
x, y, z = (float(v) for v in pos[:3])
min_z = float((sweep or {}).get("z_push") or 2) + 1
if z < min_z:
raise GrottiError(
"Achsen nicht gehomed und Düse zu tief gemeldet (Z %.1f mm) — unsicher. "
"Bei leerem Bett in Mainsail G28, dann Ablauf neu." % z,
"homed",
)
return "SET_KINEMATIC_POSITION X=%.3f Y=%.3f Z=%.3f" % (x, y, z), z
def cool_ready(live, cool):
bed = live.get("bed")
nozzle = live.get("nozzle")
if not isinstance(bed, (int, float)) or not isinstance(nozzle, (int, float)):
return False
return float(bed) <= float(cool["bed_max"]) and float(nozzle) <= float(cool["nozzle_max"])
def explain_printer_error(message):
"""Moonraker/Klipper-Text → kurze Grotti-Meldung."""
text = (message or "").strip()
low = text.lower()
if "must home axis first" in low or "must home" in low:
return (
"Achsen sind nicht gehomed. Gleich nach dem Druck abräumen "
"(bevor die Stepper ausgehen), oder in Mainsail homen — "
"Z nur bei leerem Bett."
)
if "is not homed" in low:
return (
"Achsen sind nicht gehomed. In Mainsail homen "
"(Z nur bei leerem Bett), dann nochmal abräumen."
)
if "no trigger on probe" in low:
return (
"Taste hat nicht ausgelöst (Wägezelle, voller Hub ohne Kontakt). "
"Bei leerem Bett meist falsche Z nach Leerlauf (zu wenig Hub) — "
"nicht Restteil. Düse auf Klumpen prüfen, dann Tasten erneut; "
"Grotti setzt vor dem Tasten ein Z-Budget."
)
return text or "Der Drucker hat den Befehl abgelehnt."
# Senken auf z_push: nie F3000 — bei falscher Z-Null war das Vollgas ins Bett.
SWEEP_FEED_DOWN = 400 # mm/min ≈ 6,7 mm/s
def sweep_go_back_high_script(sweep):
"""Nur hoch + nach hinten (y_from) — noch nicht senken."""
travel = sweep["z_travel"]
mid_x = (sweep["x_from"] + sweep["x_to"]) / 2.0
feed_travel = max(3000, int(round(float(sweep["speed"]) * 60)))
return "\n".join([
"G90",
"G91",
"G0 Z%.2f F1800" % max(20.0, float(travel)),
"G90",
"G0 X%.2f Y%.2f F%d" % (mid_x, sweep["y_from"], feed_travel),
"M400",
])
def sweep_lower_to_push_script(sweep):
"""Langsam auf z_push — kein F3000-Sturz."""
push = sweep["z_push"]
return "\n".join([
"G90",
"G0 Z%.2f F%d" % (push, SWEEP_FEED_DOWN),
"M400",
])
def sweep_probe_height_script(sweep):
"""Kompatibel: hoch, hinten, langsam auf z_push."""
return sweep_go_back_high_script(sweep) + "\n" + sweep_lower_to_push_script(sweep)
def assert_clear_before_lower(live, sweep, min_z_mm=15.0):
"""Vor dem Senken: Toolhead muss noch klar über dem Bett stehen."""
pos = (live or {}).get("position") or []
if len(pos) < 3 or not isinstance(pos[2], (int, float)):
raise GrottiError("Schieben: Z-Position unbekannt — kein Senken.", "bahn")
z = float(pos[2])
need = max(float(min_z_mm), float(sweep["z_push"]) + 8.0)
if z < need:
raise GrottiError(
"Schieben gestoppt: nach hinten nur Z %.1f mm (braucht ≥ %.0f mm). "
"Senken würde ins Bett/Teil rauschen — Z-Null prüfen, leeres Bett: G28."
% (z, need),
"bahn",
)
def assert_at_push_height(live, sweep, tol_mm=1.5):
"""Wenn Z nicht bei z_push liegt, würde die Bahn das Teil nur überfliegen."""
pos = (live or {}).get("position") or []
if len(pos) < 3 or not isinstance(pos[2], (int, float)):
raise GrottiError("Schieben: Z-Position unbekannt — Abbruch.", "bahn")
z = float(pos[2])
want = float(sweep["z_push"])
if abs(z - want) > float(tol_mm):
raise GrottiError(
"Schieben gestoppt: Düse bei Z %.1f mm, erwartet %.1f mm. "
"Mit falscher Z-Höhe fährt die Bahn über das Teil (ohne Schrittverlust). "
"Z-Koordinate und reale Höhe passen oft nicht — "
"bei leerem Bett in Mainsail G28, dann neu."
% (z, want),
"bahn",
)
def sweep_script(sweep):
"""Absolute Bahn: anheben, nach hinten, langsam senken, nach vorn — nur Config-Spuren."""
travel = sweep["z_travel"]
push = sweep["z_push"]
y_from = sweep["y_from"]
y_to = sweep["y_to"]
# mm/s → mm/min für G0 F
feed = int(round(float(sweep["speed"]) * 60))
feed_travel = max(feed, 3000)
mid_x = (sweep["x_from"] + sweep["x_to"]) / 2.0
lines = [
"G90",
"G91",
"G0 Z%.2f F1800" % max(20.0, float(travel)),
"G90",
"G0 X%.2f Y%.2f F%d" % (mid_x, y_from, feed_travel),
]
for x in x_positions(sweep):
lines.append("G0 X%.2f Y%.2f F%d" % (x, y_from, feed_travel))
lines.append("G0 Z%.2f F%d" % (push, SWEEP_FEED_DOWN))
lines.append("G0 Y%.2f F%d" % (y_to, feed))
lines.append("G0 Z%.2f F%d" % (travel, feed_travel))
# hinten oben parken, weg von der Kiste
lines.append("G0 X%.2f Y%.2f Z%.2f F%d" % (mid_x, y_from, travel, feed_travel))
lines.append("M107")
lines.append("M400")
return "\n".join(lines)
def check_script(check, current_z=None):
"""Restteil-Tasten ohne G28 Z — mit aktivem Bettnetz (LeviQ).
Absolut auf Freiraum (z_clear bzw. aktuelle Z), Tastpunkt, ein PROBE.
Result is z= = Höhe in der echten Koordinate. Kein SET Z=100 / Bezug-Null —
das hat die Koordinate nur kaputt gemacht.
"""
check = normalize_check(check)
clear = max(25.0, float(check["z_clear"]))
z_travel = clear
if isinstance(current_z, (int, float)) and float(current_z) > clear:
z_travel = float(current_z)
return "\n".join([
"G90",
"G0 Z%.2f F1800" % z_travel,
"G0 X%.2f Y%.2f F6000" % (check["x"], check["y"]),
"PROBE",
"M400",
"G90",
])
def check_z_from_live(live):
"""Nur Notfall: probe.last_z_result. Toolhead-Z nicht nutzen (Retract +2 mm)."""
probe_z = (live or {}).get("probe_z")
if isinstance(probe_z, (int, float)):
return float(probe_z), "probe"
return None, ""
def check_lift_script(check):
"""Nach dem Tasten absolut hoch — Düse nicht auf dem Bett lassen."""
check = normalize_check(check)
return "\n".join([
"G90",
"G0 Z%.2f F1800" % max(25.0, float(check["z_clear"])),
"M400",
])
def _axis_coords(lo, hi, count):
if count <= 1:
return [(lo + hi) / 2.0]
return [lo + (hi - lo) * i / (count - 1) for i in range(count)]
def _matrix_ok(matrix):
if not isinstance(matrix, list) or not matrix:
return False
width = None
for row in matrix:
if not isinstance(row, list) or not row:
return False
if width is None:
width = len(row)
if len(row) != width:
return False
for value in row:
if not isinstance(value, (int, float)):
return False
return True
def interpolate_mesh_z(mesh, x, y):
"""Bilinear aus Roh-probed_matrix. Klipper: Zeile 0 = mesh_min Y."""
matrix = mesh.get("matrix") if isinstance(mesh, dict) else None
if not _matrix_ok(matrix):
return None
mesh_min = mesh.get("mesh_min") or [0, 0]
mesh_max = mesh.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError):
return None
rows = len(matrix)
cols = len(matrix[0])
if cols < 1 or rows < 1:
return None
if abs(x1 - x0) < 1e-9 or abs(y1 - y0) < 1e-9:
return float(matrix[0][0])
tx = 0.0 if cols == 1 else (float(x) - x0) / (x1 - x0) * (cols - 1)
ty = 0.0 if rows == 1 else (float(y) - y0) / (y1 - y0) * (rows - 1)
tx = max(0.0, min(float(cols - 1), tx))
ty = max(0.0, min(float(rows - 1), ty))
c0 = int(tx)
r0 = int(ty)
c1 = min(cols - 1, c0 + 1)
r1 = min(rows - 1, r0 + 1)
fx = tx - c0
fy = ty - r0
z00 = float(matrix[r0][c0])
z10 = float(matrix[r0][c1])
z01 = float(matrix[r1][c0])
z11 = float(matrix[r1][c1])
z0 = z00 * (1 - fx) + z10 * fx
z1 = z01 * (1 - fx) + z11 * fx
return z0 * (1 - fy) + z1 * fy
def mesh_delta_mm(baseline, measured):
"""Max |Δ| und Mittel |Δ|: Messpunkte gegen interpoliertes Baseline-Netz."""
if not isinstance(baseline, dict) or not _matrix_ok(baseline.get("matrix")):
raise GrottiError("Kein Bettnetz zum Vergleich — vorher nivellieren.", "mesh")
if not isinstance(measured, dict) or not _matrix_ok(measured.get("matrix")):
raise GrottiError("Neues Bettnetz fehlt nach dem Antasten.", "mesh")
matrix = measured["matrix"]
mesh_min = measured.get("mesh_min") or [0, 0]
mesh_max = measured.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError) as exc:
raise GrottiError("Messnetz ohne gültigen Bereich.", "mesh") from exc
rows = len(matrix)
cols = len(matrix[0])
xs = _axis_coords(x0, x1, cols)
ys = _axis_coords(y0, y1, rows)
deltas = []
for ri, y in enumerate(ys):
for ci, x in enumerate(xs):
expected = interpolate_mesh_z(baseline, x, y)
if expected is None:
raise GrottiError("Baseline-Netz lässt sich nicht interpolieren.", "mesh")
deltas.append(abs(float(matrix[ri][ci]) - expected))
if not deltas:
raise GrottiError("Keine Messpunkte zum Vergleich.", "mesh")
return {
"max_delta_mm": max(deltas),
"mean_delta_mm": sum(deltas) / len(deltas),
"points": len(deltas),
}
def step_label(step):
if not isinstance(step, dict):
return ""
kind = step.get("type")
if kind == "print":
return "Drucken"
if kind == "cool":
return "Abkühlen"
if kind == "sweep":
return "Schieben"
if kind == "check":
return "Tasten"
return kind or ""
+6
View File
@@ -0,0 +1,6 @@
class GrottiError(Exception):
def __init__(self, message, code="fehler", status=400):
super().__init__(message)
self.message = message
self.code = code
self.status = status
+503
View File
@@ -0,0 +1,503 @@
"""Senden, Steuern und das Druckprotokoll."""
import json
import urllib.error
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from grotti import acm, eject, library, moonraker, store
from grotti.cost import energy_cost
from grotti.errors import GrottiError
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
from grotti.printers import FAMILY_LABELS
TERMINAL = {"completed", "cancelled", "error", "interrupted"}
def weight_of(metadata):
raw = (metadata or {}).get("filament_weight_total")
if isinstance(raw, list):
return sum(float(item or 0) for item in raw)
if isinstance(raw, (int, float)):
return float(raw)
if isinstance(raw, str):
try:
return float(raw.replace(",", "."))
except ValueError:
return 0.0
return 0.0
def material_of(metadata):
raw = (metadata or {}).get("filament_type") or ""
if isinstance(raw, list):
raw = ";".join(str(item) for item in raw if item)
return str(raw).split(";")[0].strip()
def unix_to_dt(value):
if not value:
return None
return datetime.fromtimestamp(float(value), timezone.utc)
def choose_spool(conn, host, material, hinted_id):
if hinted_id:
row = store.get_spool_row(conn, hinted_id)
if row:
return row
rows = store.spools_for_printer(conn, host)
if len(rows) == 1:
return rows[0]
if material:
same = [row for row in rows if (row["material_type"] or "").lower() == material.lower()]
if len(same) == 1:
return same[0]
return None
def file_material_types(file_row):
types = []
for item in (file_row or {}).get("filaments") or []:
kind = (item.get("type") or "").strip()
if kind and kind not in types:
types.append(kind)
return types
def resolve_spools_at_host(conn, host, ace_map=None):
"""Spulen am Drucker zur Startzeit: ACE-Plätze aus Map, sonst externe Rolle."""
mapping = acm.normalize_mapping(ace_map)
found = []
if mapping:
for item in mapping:
slot = int(item["ams_index"]) + 1
row = conn.execute(
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(host, slot),
).fetchone()
if row:
found.append(row)
return found
# Externe Spule: höchstens eine ohne ACE-Platz am Host.
rows = conn.execute(
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot IS NULL
ORDER BY id
""",
(host,),
).fetchall()
return list(rows)
def assert_material_match(file_row, spools):
"""Datei-Material gegen eingelegte Rollen — warnt nicht bei leerer Liste."""
want = {t.lower() for t in file_material_types(file_row)}
if not want or not spools:
return
have = {(row.get("material_type") or "").strip().lower() for row in spools}
have.discard("")
if not have:
return
if want.isdisjoint(have):
raise GrottiError(
"Material passt nicht: Datei braucht %s, im Drucker steckt %s."
% (", ".join(sorted(want)), ", ".join(sorted(have)) or "unbekannt"),
"material",
409,
)
def assert_spool_at_host(conn, spool_id, host):
if not spool_id or not host:
return
spool = store.get_spool_row(conn, spool_id)
if not spool:
return
if (spool.get("printer_host") or "") != host:
raise GrottiError(
"Spule „%s“ steckt nicht in diesem Drucker (Ort: %s)."
% (spool["name"], spool.get("printer_host") or "Regal"),
"spule",
409,
)
def apply_deduction(conn, run, energy):
if run["deducted"] or run["weight_g"] <= 0 or run["state"] not in TERMINAL:
return False
weight = float(run["weight_g"] or 0)
# ACE: Abzug anteilig auf die Plätze der letzten Sendung
sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
ace = []
if sent:
raw = sent.get("ace_map")
if isinstance(raw, list):
ace = raw
elif isinstance(raw, str):
try:
ace = json.loads(raw)
except json.JSONDecodeError:
ace = []
targets = []
if ace:
for item in acm.normalize_mapping(ace):
slot = int(item["ams_index"]) + 1
row = conn.execute(
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(run["printer_host"], slot),
).fetchone()
if row:
targets.append(row)
if not targets and run.get("spool_id"):
spool = store.get_spool_row(conn, run["spool_id"])
if spool:
targets = [spool]
if not targets:
return False
share = weight / len(targets)
filament = sum((share / 1000) * float(spool["price_per_kg"] or 0) for spool in targets)
power = energy_cost(run["duration_s"], energy)
marked = store.mark_deducted(conn, run["id"], targets[0]["id"], filament, power)
if not marked:
return False
for spool in targets:
store.deduct_spool(conn, spool["id"], share)
return True
def sync_history(conn, cfg):
printers = store.list_printers(conn)
def fetch(printer):
return printer, moonraker.history(printer["host"])
with ThreadPoolExecutor(max_workers=max(1, len(printers))) as pool:
fetched = list(pool.map(fetch, printers))
collected = []
for printer, items in fetched:
for job in items:
collected.append((printer, job))
deducted = 0
for printer, job in collected:
job_id = str(job.get("job_id") or "")
if not job_id:
continue
filename = job.get("filename") or ""
metadata = job.get("metadata") or {}
weight = weight_of(metadata)
material = material_of(metadata)
state = job.get("status") or ""
sent = store.latest_send(conn, printer["host"], filename)
known = store.file_by_name(conn, filename)
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
project_id = (sent or {}).get("project_id") or (known or {}).get("project_id")
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
spool = choose_spool(conn, printer["host"], material, hinted)
terminal = state in TERMINAL
row = store.upsert_run(conn, {
"printer_host": printer["host"],
"printer_name": printer["name"],
"filename": filename,
"file_id": file_id,
"project_id": project_id,
"spool_id": spool["id"] if spool else None,
"state": state,
"started_at": unix_to_dt(job.get("start_time")),
"ended_at": unix_to_dt(job.get("end_time")),
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
"weight_g": weight,
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
"moonraker_job_id": job_id,
"needs_spool": bool(terminal and weight > 0 and not spool),
"material_type": material,
})
if apply_deduction(conn, row, cfg["energy"]):
deducted += 1
return {"gelesen": len(collected), "abgezogen": deducted}
def assert_filament_enough(conn, file_row, spool_id=None, ace_map=None, host=""):
"""Dateien-Gewicht gegen Spule oder ACE-Plätze prüfen."""
need = float((file_row or {}).get("weight_g") or 0)
if need <= 0:
return
mapping = acm.normalize_mapping(ace_map)
if mapping and host:
leftovers = []
for item in mapping:
slot = int(item["ams_index"]) + 1
row = conn.execute(
"""
SELECT remaining_g, name FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(host, slot),
).fetchone()
if row:
leftovers.append(float(row["remaining_g"] or 0))
if leftovers and sum(leftovers) + 0.5 < need:
raise GrottiError(
"ACE-Plätze haben nur %.0f g, Datei braucht %.0f g."
% (sum(leftovers), need),
"spule",
)
return
if not spool_id:
return
spool = store.get_spool_row(conn, spool_id)
if not spool:
return
left = float(spool["remaining_g"] or 0)
if left + 0.5 < need:
raise GrottiError(
"Spule „%s“ hat nur %.0f g, Datei braucht %.0f g."
% (spool["name"], left, need),
"spule",
)
def prepare_send(conn, file_id, host, confirm, override, spool_id, ace_map=None):
if not confirm:
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
printer = store.printer_by_host(conn, host)
if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
row = store.get_file_row(conn, file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
file_family = row["printer_family"]
if file_family and printer["family"] and file_family != printer["family"] and not override:
raise GrottiError(
"Die Datei ist für %s geschnitten, der Drucker ist eine %s."
% (FAMILY_LABELS.get(file_family, file_family), printer["family_label"] or "andere Maschine"),
"maschine",
409,
)
# Spule ist Ort des Druckers — Auflösung zur Startzeit, optionaler Hinweis nur wenn
# die gewählte Spule wirklich an diesem Host steckt.
if spool_id:
assert_spool_at_host(conn, spool_id, host)
at_host = resolve_spools_at_host(conn, host, ace_map)
assert_material_match(row, at_host)
if spool_id and store.get_spool_row(conn, spool_id):
chosen = spool_id
elif len(at_host) == 1:
chosen = at_host[0]["id"]
elif at_host:
chosen = at_host[0]["id"]
else:
chosen = None
assert_filament_enough(conn, row, chosen, ace_map, host)
return printer, row, chosen
def send_file(
conn,
cfg,
file_id,
host,
confirm,
override,
spool_id,
on_progress=None,
prepared=False,
ace_map=None,
bed_leveling=False,
keep_motors=False,
):
def progress(step, percent, message):
if on_progress:
on_progress(step, percent, message)
if prepared:
printer = store.printer_by_host(conn, host)
row = store.get_file_row(conn, file_id)
if not printer or not row:
raise GrottiError("Die Datei oder der Drucker fehlt.", "datei", 404)
chosen = spool_id
if chosen and not store.get_spool_row(conn, chosen):
chosen = None
else:
printer, row, chosen = prepare_send(
conn, file_id, host, confirm, override, spool_id, ace_map=ace_map
)
if prepared:
assert_filament_enough(conn, row, chosen, ace_map, host)
progress("pruefen", 15, "Drucker prüfen…")
try:
live = moonraker.query(host)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as exc:
raise GrottiError("Der Drucker ist nicht erreichbar.", "offline") from exc
if live.get("state") in ("printing", "paused"):
raise GrottiError("Der Drucker druckt schon.", "belegt", 409)
state = (live.get("state") or "").lower()
if state == "error":
# Hängender Status nach fehlgeschlagenem Befehl — Start-G-code homed selbst,
# aber print_stats=error blockiert. Kurz zurücksetzen, dann weiter.
try:
moonraker.clear_print_error(host)
live = moonraker.query(host)
state = (live.get("state") or "").lower()
except Exception:
pass
if state == "error":
raise GrottiError(
"Drucker im Fehlerzustand — auf der Wand „Fehler löschen“, dann nochmal starten.",
"fehler",
409,
)
# Kein Homing-Zwang vor Start: Idle löscht homed_axes; der Slice-Start macht G28.
slots = live.get("slots") or moonraker.slots(host)
mapping = acm.normalize_mapping(ace_map)
filaments = list(row.get("filaments") or [])
if slots and not mapping:
raise GrottiError(
"Dieser Drucker hat ACE/MMU-Plätze. Bitte Farbe der Datei einem ACE-Platz zuordnen.",
"ace",
)
if slots and mapping and not filaments:
filaments = [{"type": "PLA", "color": ""}]
progress("vorbereiten", 30, "Datei vorbereiten…")
filename, content, plate = library.printer_file(row)
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
content = prepare_rinkhals_gcode(content, tools=ace_tools)
if keep_motors:
# Slice-Ende `M84` → sofort unhomed; Abräumen braucht die Stepper.
content = eject.strip_end_motor_disable(content)
size_mb = len(content) / (1024 * 1024)
def upload_progress(sent, total):
if total <= 0:
return
frac = min(1.0, sent / total)
pct = 45 + int(frac * 19)
if sent >= total:
progress(
"hochladen",
pct,
"%s liest die Datei…" % printer["name"],
)
return
progress(
"hochladen",
pct,
"Hochladen zu %s… %.0f/%.0f MB" % (
printer["name"],
sent / (1024 * 1024),
total / (1024 * 1024),
),
)
progress(
"hochladen",
45,
"Hochladen zu %s… 0/%.0f MB" % (printer["name"], size_mb),
)
ace_payload = None
warnings = []
has_trailer = content_has_ace_trailer(content, kind=row.get("kind") or "", plate=plate)
if slots and mapping and not has_trailer:
# Nach prepare_rinkhals_gcode sollte der Trailer da sein; sonst Warnung.
warnings.append(acm.TRAILER_WARN)
progress("ace", 42, acm.TRAILER_WARN)
try:
stored = moonraker.upload(host, filename, content, on_progress=upload_progress)
if slots and mapping:
ace_payload = acm.build_acm(filaments, mapping, slots)
if ace_payload:
progress("ace", 65, "ACE-Zuordnung hochladen…")
body = acm.acm_bytes(ace_payload)
for acm_name in acm.acm_upload_names(stored):
moonraker.upload(host, acm_name, body)
progress("starten", 80, "Druck starten…")
moonraker.start_print(
host,
stored,
bed_leveling=bool(bed_leveling),
ams_settings=ace_payload,
)
except moonraker.PrinterError as exc:
try:
moonraker.heaters_off(host)
except Exception:
pass
raise GrottiError(exc.message, "drucker") from exc
progress("buchen", 95, "Auftrag speichern…")
store.add_send(
conn,
host,
stored,
row["id"],
row["project_id"],
chosen,
ace_map=mapping,
bed_leveling=bool(bed_leveling),
)
# needs_clear erst bei Druckende (Watcher) — sonst kurz „Platte belegt“ vorm Start
note = "Gestartet auf %s: %s" % (printer["name"], stored)
if bed_leveling:
note += " (Bettnetz vor dem Druck)"
if warnings:
note += " · " + " · ".join(warnings)
progress("fertig", 100, note)
return {
"printer": printer["name"],
"filename": stored,
"plate": plate,
"ace": bool(slots and mapping),
"ace_trailer": has_trailer,
"warnings": warnings,
"bed_leveling": bool(bed_leveling),
"bed_leveling_wired": True,
}
def control(conn, host, action):
printer = store.printer_by_host(conn, host)
if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
try:
moonraker.command(host, action)
except moonraker.PrinterError as exc:
raise GrottiError(exc.message, "drucker") from exc
return {"printer": printer["name"], "action": action}
def steuern(conn, host, action, params=None):
printer = store.printer_by_host(conn, host)
if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
try:
moonraker.steuern(host, action, params)
except moonraker.PrinterError as exc:
raise GrottiError(exc.message, "drucker") from exc
return {"printer": printer["name"], "action": action}
def assign_and_deduct(conn, cfg, run_id, spool_id):
spool = store.get_spool_row(conn, spool_id)
if not spool:
raise GrottiError("Die Spule fehlt.", "spule", 404)
run = store.assign_run_spool(conn, run_id, spool_id)
if not run:
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
apply_deduction(conn, run, cfg["energy"])
fresh = store.get_run_row(conn, run_id)
return store.run_out(fresh)
+181
View File
@@ -0,0 +1,181 @@
"""Watch-Ordner, Upload und Vorschaubild."""
import time
import zipfile
from pathlib import Path
from grotti import store
from grotti.errors import GrottiError
from grotti.parse import embedded_png, inspect_file, safe_zip_name
_LAST_WATCH_SYNC = 0.0
_WATCH_MIN_INTERVAL_S = 20
def skip_path(path, data_dir):
if any(part.startswith(".") or part == "__pycache__" for part in path.parts):
return True
resolved = path.resolve()
data = data_dir.resolve()
return resolved == data or data in resolved.parents
def meta_for(path):
parsed = inspect_file(path)
stat = path.stat()
parsed.update({
"name": path.name,
"stored_path": str(path.resolve()),
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
})
return parsed
def sync_watch(conn, cfg, force=False):
global _LAST_WATCH_SYNC
folder = cfg["watch_dir"]
if folder is None:
return {"gelesen": 0, "hinweis": "In config.json watch_dir auf den Slice-Ordner setzen."}
if not folder.exists():
return {"gelesen": 0, "hinweis": "Der Watch-Ordner fehlt: %s" % folder}
now = time.monotonic()
if not force and _LAST_WATCH_SYNC and now - _LAST_WATCH_SYNC < _WATCH_MIN_INTERVAL_S:
return {"gelesen": 0, "hinweis": "", "skipped": True}
seen = []
for path in folder.rglob("*"):
if not path.is_file() or skip_path(path, cfg["data_dir"]):
continue
lower = path.name.lower()
if not (lower.endswith(".gcode") or lower.endswith(".3mf")):
continue
try:
store.upsert_scanned(conn, meta_for(path), force=force)
seen.append(str(path.resolve()))
except (OSError, zipfile.BadZipFile):
continue
store.forget_missing_watch(conn, seen)
_LAST_WATCH_SYNC = now
return {"gelesen": len(seen), "hinweis": ""}
def refresh_file_meta(conn, force=False):
"""Metadaten bestehender Dateien neu lesen (z. B. paint_info → filaments)."""
if not force:
return 0
# list_files/file_out ohne stored_path — Rohzeilen nutzen.
updated = 0
for row in store.list_file_rows(conn):
stored = row.get("stored_path") or ""
if not stored:
continue
path = Path(stored)
if not path.is_file():
continue
try:
meta = meta_for(path)
except (OSError, zipfile.BadZipFile):
continue
store.update_file(
conn,
row["id"],
{
"printer_family": meta.get("printer_family") or row.get("printer_family"),
"filaments": meta.get("filaments") or [],
"weight_g": meta.get("weight_g") or 0,
"estimated_s": meta.get("estimated_s") or 0,
"thumb_entry": meta.get("thumb_entry") or "",
"kind": meta.get("kind") or row.get("kind"),
"plate": meta.get("plate") or "",
"slice_meta": meta.get("slice_meta") or {},
},
)
updated += 1
return updated
def safe_filename(name):
base = Path(name).name.replace("\\", "_").replace("/", "_").replace('"', "")
return base or "datei.gcode"
def unique_path(directory, filename):
filename = safe_filename(filename)
if filename.lower().endswith(".gcode.3mf"):
stem, suffix = filename[: -len(".gcode.3mf")], ".gcode.3mf"
else:
stem, suffix = Path(filename).stem, Path(filename).suffix
candidate = directory / filename
number = 2
while candidate.exists():
candidate = directory / ("%s-%s%s" % (stem, number, suffix))
number += 1
return candidate
def save_upload(conn, filename, content, cfg, project_id=None):
directory = cfg["data_dir"] / "eingang"
directory.mkdir(parents=True, exist_ok=True)
path = unique_path(directory, filename)
path.write_bytes(content)
meta = meta_for(path)
meta["project_id"] = project_id
return store.insert_upload(conn, meta)
def ensure_library_gcode(conn, cfg, host, filename):
"""Wenn die laufende Datei fehlt: einmal vom Drucker nach Grotti ziehen."""
base = (filename or "").replace("\\", "/").split("/")[-1]
if not base or not base.lower().endswith(".gcode"):
return None
known = store.file_by_name(conn, base)
if known:
path = Path(known.get("stored_path") or "")
if path.is_file():
return store.file_out(known)
from grotti import moonraker
pulled = moonraker.download_gcode(host, base)
if not pulled:
return None
name, content = pulled
return save_upload(conn, name, content, cfg)
def printer_file(row):
path = Path(row["stored_path"])
if not path.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
if row["kind"] == "gcode":
return path.name, path.read_bytes(), ""
if row["kind"] != "gcode.3mf":
raise GrottiError("Die Datei enthält keinen G-code. Zuerst slicen.", "ungesliced")
return path.name, path.read_bytes(), row["plate"] or ""
def preview_png(row):
path = Path(row["stored_path"])
if not path.is_file():
return None
entry = row["thumb_entry"]
if row["kind"] == "gcode":
return embedded_png(path.read_bytes()[:400_000].decode("utf-8", "replace"))
if not zipfile.is_zipfile(path):
return None
with zipfile.ZipFile(path) as archive:
names = []
if entry and entry != "embedded" and safe_zip_name(entry) and entry in archive.namelist():
names.append(entry)
names.extend(
name for name in archive.namelist()
if name.lower().endswith(".png") and safe_zip_name(name) and name not in names
)
for name in names:
data = archive.read(name)
if data.startswith(b"\x89PNG"):
return data
plate = row["plate"]
if plate and safe_zip_name(plate) and plate in archive.namelist():
with archive.open(plate) as handle:
return embedded_png(handle.read(400_000).decode("utf-8", "replace"))
return None
+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))
+53
View File
@@ -0,0 +1,53 @@
"""Telegram-Benachrichtigungen (Bot API)."""
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
def telegram_configured(cfg):
tg = (cfg or {}).get("telegram") or {}
return bool(tg.get("enabled") and tg.get("bot_token") and tg.get("chat_id"))
def send_telegram(cfg, text):
"""Sendet Text an Telegram. Fehler werden geschluckt (False)."""
if not telegram_configured(cfg):
return False
tg = cfg["telegram"]
url = "https://api.telegram.org/bot%s/sendMessage" % tg["bot_token"]
payload = urllib.parse.urlencode({
"chat_id": str(tg["chat_id"]),
"text": text[:4000],
"disable_web_page_preview": "1",
}).encode("utf-8")
request = urllib.request.Request(url, data=payload, method="POST")
try:
with urllib.request.urlopen(request, timeout=12) as response:
body = json.loads(response.read().decode("utf-8", "replace"))
return bool(body.get("ok"))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
return False
ALERT_GROUP = {"abbruch": "fehler"}
DEFAULT_OFF = {"ablauf", "start"}
def alert_enabled(cfg, kind):
alerts = (cfg or {}).get("alerts") or {}
key = ALERT_GROUP.get(kind, kind)
return bool(alerts.get(key, key not in DEFAULT_OFF))
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60):
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
from grotti import store
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
store.add_event(conn, host, printer, kind, message, filename or "")
if not recent and alert_enabled(cfg, kind):
send_telegram(cfg, message)
+455
View File
@@ -0,0 +1,455 @@
"""Metadaten aus G-code und 3MF, ohne den Drucker anzufassen."""
import base64
import io
import json
import re
import zipfile
from pathlib import Path
from grotti.printers import family_from_text
THUMB_RE = re.compile(r"; thumbnail begin (\d+)x(\d+) \d+\s*(.*?); thumbnail end", re.S)
COMMENT_RE = re.compile(r"^;\s*([^:=]+?)\s*[:=]\s*(.*)$")
DURATION_RE = re.compile(r"(\d+)\s*([dhms])", re.I)
TRAILER_RE = re.compile(rb";\s*referenced_tools\s*=", re.I)
# Rinkhals mmu_ace_metadata.py: erste Zeile überspringt den vollen Scan auf dem Drucker.
RINKHALS_FINGERPRINT = b"; processed by MmuAcePatcher"
_SLICER_RE = re.compile(rb"^;.*generated by ([A-Za-z]*) .*$|^; (BambuStudio) .*$", re.I)
_TOOL_RE = re.compile(rb"^(?:MMU_CHANGE_TOOL(?:_STANDALONE)? .*?TOOL=|T)(\d{1,2})", re.I)
_AUTHORIZED_SLICERS = {
"prusaslicer",
"superslicer",
"orcaslicer",
"bambustudio",
"anycubicslicernext",
}
def parse_duration(value):
total = 0
for number, unit in DURATION_RE.findall(value or ""):
total += int(number) * {"d": 86400, "h": 3600, "m": 60, "s": 1}[unit.lower()]
return total
def sum_numbers(value):
total = 0.0
for number in re.findall(r"\d+(?:[.,]\d+)?", value or ""):
total += float(number.replace(",", "."))
return total
def comments_of(text):
found = {}
for line in text.splitlines():
stripped = line.strip()
if stripped and not stripped.startswith(";"):
break
match = COMMENT_RE.match(stripped)
if match:
found[match.group(1).strip().lower()] = match.group(2).strip()
return found
def comments_scan(text):
"""Alle `; key = value`-Zeilen, auch im Footer nach G-code."""
found = {}
for line in (text or "").splitlines():
match = COMMENT_RE.match(line.strip())
if match:
found[match.group(1).strip().lower()] = match.group(2).strip()
return found
def _num_comment(comments, *keys):
for key in keys:
raw = comments.get(key) or ""
if not raw:
continue
match = re.search(r"[-+]?\d+(?:[.,]\d+)?", raw.replace(",", "."))
if match:
try:
return float(match.group(0))
except ValueError:
continue
return 0.0
def _int_comment(comments, *keys):
value = _num_comment(comments, *keys)
return int(value) if value else 0
def color_hex(channels):
if not isinstance(channels, (list, tuple)) or len(channels) < 3:
return ""
try:
rgb = [max(0, min(255, int(round(float(channel))))) for channel in channels[:3]]
except (TypeError, ValueError):
return ""
return "#%02x%02x%02x" % tuple(rgb)
def filaments_from_paint_info(raw):
try:
items = json.loads(raw or "")
except (TypeError, json.JSONDecodeError):
return []
if not isinstance(items, list):
return []
found = []
for item in items:
if not isinstance(item, dict):
continue
try:
index = int(item.get("paint_index"))
except (TypeError, ValueError):
index = len(found)
while len(found) <= index:
found.append({"type": "", "color": ""})
found[index] = {
"type": str(item.get("material_type") or "").strip(),
"color": color_hex(item.get("paint_color")),
}
return [row for row in found if row["type"] or row["color"]]
def filaments_of(comments):
painted = filaments_from_paint_info(comments.get("paint_info"))
if painted:
return painted
types = [part.strip() for part in (comments.get("filament_type") or "").split(";") if part.strip()]
colors = [part.strip() for part in (comments.get("filament_colour") or comments.get("filament_color") or "").split(";")]
return [
{"type": kind, "color": colors[index] if index < len(colors) else ""}
for index, kind in enumerate(types)
]
def _gcode_lines(content):
start = 0
size = len(content)
while start < size:
end = content.find(b"\n", start)
if end < 0:
line = content[start:]
if line.endswith(b"\r"):
line = line[:-1]
if line:
yield line
break
line = content[start:end]
if line.endswith(b"\r"):
line = line[:-1]
yield line
start = end + 1
def _paint_tool_indexes(content):
"""paint_index aus ; paint_info = […] im G-code."""
found = set()
for line in _gcode_lines(content):
if b"paint_info" not in line.lower():
continue
text = line.decode("utf-8", "replace")
match = COMMENT_RE.match(text.strip())
raw = match.group(2).strip() if match else ""
if not raw and "=" in text:
raw = text.split("=", 1)[-1].strip()
try:
items = json.loads(raw or "")
except (TypeError, json.JSONDecodeError):
continue
if not isinstance(items, list):
continue
for item in items:
if not isinstance(item, dict):
continue
try:
found.add(int(item.get("paint_index")))
except (TypeError, ValueError):
continue
return found
def prepare_rinkhals_gcode(content, tools=None):
"""Kopfzeile für Rinkhals plus `; referenced_tools`, wenn sie fehlt.
`tools`: explizite Indizes (z. B. aus der ACE-Zuordnung). Sonst aus T-Befehlen
oder paint_info. Ohne Fingerprint scannt mmu_ace_metadata die ganze Datei auf
dem Drucker, bevor der Upload zurückkommt.
"""
if not content or content[:2] == b"PK":
return content
first_end = content.find(b"\n")
first = content if first_end < 0 else content[:first_end]
if first.endswith(b"\r"):
first = first[:-1]
has_fingerprint = first.lower().startswith(RINKHALS_FINGERPRINT.lower())
forced = set()
for value in tools or []:
try:
forced.add(int(value))
except (TypeError, ValueError):
continue
slicer = ""
found_tools = set()
for line in _gcode_lines(content):
if not slicer and line.startswith(b";"):
match = _SLICER_RE.match(line)
if match:
raw = match.group(1) or match.group(2) or b""
slicer = raw.decode("ascii", "replace").lower()
match = _TOOL_RE.match(line)
if match:
found_tools.add(int(match.group(1)))
paint_tools = _paint_tool_indexes(content)
chosen = found_tools or forced or paint_tools
# ACE-Send mit Map, aber nichts erkannt: Einzelfarbe Platz 0.
if forced and not chosen:
chosen = {0}
stamped = content if has_fingerprint else (RINKHALS_FINGERPRINT + b"\n" + content)
need_trailer = chosen and not has_ace_trailer(content)
allow_trailer = bool(forced) or slicer in _AUTHORIZED_SLICERS
if need_trailer and allow_trailer:
if not stamped.endswith(b"\n"):
stamped += b"\n"
listed = ",".join(str(number) for number in sorted(chosen))
stamped += ("; referenced_tools = %s\n" % listed).encode("ascii")
return stamped
def has_ace_trailer(data):
"""True wenn Rinkhals-/Slicer-Trailer `; referenced_tools = …` vorhanden ist."""
if not data:
return False
if isinstance(data, str):
data = data.encode("utf-8", "replace")
if TRAILER_RE.search(data[-131072:]):
return True
return bool(TRAILER_RE.search(data[:65536]))
def content_has_ace_trailer(content, kind="", plate=""):
if has_ace_trailer(content):
return True
kind = (kind or "").lower()
if kind not in ("gcode.3mf", "3mf") and not (content and content[:2] == b"PK"):
return False
try:
with zipfile.ZipFile(io.BytesIO(content)) as archive:
names = [name for name in archive.namelist() if safe_zip_name(name)]
candidates = []
if plate and plate in names:
candidates.append(plate)
candidates.extend(
name for name in names
if name.lower().endswith(".gcode") and name not in candidates
)
for name in candidates[:3]:
with archive.open(name) as handle:
raw = handle.read()
if has_ace_trailer(raw):
return True
except (zipfile.BadZipFile, OSError, RuntimeError):
return False
return False
def _stream_has_ace_trailer(handle, head_limit=400_000):
"""Liest Stream: Kopf für Parse, Ende für Trailer — ohne ganze Datei im RAM."""
head = handle.read(head_limit)
found = bool(TRAILER_RE.search(head[:65536])) or bool(TRAILER_RE.search(head[-131072:]))
tail = head[-131072:] if len(head) > 131072 else head
while True:
chunk = handle.read(256 * 1024)
if not chunk:
break
if len(chunk) >= 131072:
tail = chunk[-131072:]
else:
tail = (tail + chunk)[-131072:]
if not found and TRAILER_RE.search(tail):
found = True
return found, head
def path_has_ace_trailer(path, kind="", plate=""):
path = Path(path)
if not path.is_file():
return False
try:
kind = (kind or "").lower()
if kind in ("gcode.3mf", "3mf") or path.name.lower().endswith(".3mf"):
if not zipfile.is_zipfile(path):
return False
with zipfile.ZipFile(path) as archive:
names = [name for name in archive.namelist() if safe_zip_name(name)]
candidates = []
if plate and plate in names:
candidates.append(plate)
candidates.extend(
name for name in names
if name.lower().endswith(".gcode") and name not in candidates
)
for name in candidates[:3]:
with archive.open(name) as handle:
found, _ = _stream_has_ace_trailer(handle)
if found:
return True
return False
size = path.stat().st_size
with path.open("rb") as handle:
if size <= 200_000:
return has_ace_trailer(handle.read())
head = handle.read(65536)
handle.seek(max(0, size - 131072))
tail = handle.read(131072)
return has_ace_trailer(head + tail)
except (OSError, zipfile.BadZipFile, RuntimeError):
return False
def parse_gcode(text, trailer=""):
head = comments_of(text)
tail = comments_scan(trailer) if trailer else comments_scan(text[-131072:] if len(text) > 200_000 else "")
comments = dict(head)
comments.update(tail)
weight_key = comments.get("total filament used [g]") or comments.get("filament used [g]") or ""
model = comments.get("printer_model") or comments.get("printer model") or ""
family = family_from_text(model) if model else family_from_text(text[:8000])
time_key = (
comments.get("estimated printing time (normal mode)")
or comments.get("estimated printing time")
or comments.get("model printing time")
or ""
)
slicer = ""
for line in (text or "").splitlines()[:40]:
match = re.search(r"generated by\s+(\S+)|;\s*(BambuStudio)\b", line, re.I)
if match:
slicer = (match.group(1) or match.group(2) or "").strip()
break
slice_meta = {
"layer_height": _num_comment(comments, "layer_height", "layer height"),
"layers": _int_comment(comments, "total layer number", "total layers count"),
"max_z_mm": _num_comment(comments, "max_z_height", "max z height", "object_height"),
"nozzle_mm": _num_comment(comments, "nozzle_diameter", "nozzle diameter"),
"temp_nozzle": _num_comment(comments, "first_layer_temperature", "nozzle_temperature"),
"temp_bed": _num_comment(comments, "bed_temperature", "first_layer_bed_temperature"),
"slicer": slicer,
}
return {
"printer_family": family,
"filaments": filaments_of(comments) or filaments_of(head),
"weight_g": sum_numbers(weight_key),
"estimated_s": parse_duration(time_key),
"thumb_entry": "embedded" if "; thumbnail begin" in text else "",
"ace_trailer": has_ace_trailer(text.encode("utf-8", "replace") if isinstance(text, str) else text),
"slice_meta": {key: value for key, value in slice_meta.items() if value},
}
def embedded_png(text):
best = None
for match in THUMB_RE.finditer(text):
width = int(match.group(1))
raw = "".join(
line[1:].strip() if line.startswith(";") else line.strip()
for line in match.group(3).splitlines()
)
try:
png = base64.b64decode(raw)
except ValueError:
continue
if png.startswith(b"\x89PNG") and (best is None or width > best[0]):
best = (width, png)
return best[1] if best else None
def safe_zip_name(name):
parts = Path(name).parts
return not name.startswith(("/", "\\")) and ".." not in parts
def pick_name(names, suffix, preferred_end):
chosen = [name for name in names if name.lower().endswith(suffix) and safe_zip_name(name)]
for name in chosen:
if name.lower().endswith(preferred_end):
return name
return sorted(chosen)[0] if chosen else ""
def _read_gcode_head_tail(path, head_limit=400_000, tail_limit=131072):
path = Path(path)
size = path.stat().st_size
with path.open("rb") as handle:
head = handle.read(head_limit).decode("utf-8", "replace")
if size <= head_limit:
return head, ""
handle.seek(max(0, size - tail_limit))
tail = handle.read(tail_limit).decode("utf-8", "replace")
return head, tail
def inspect_file(path):
path = Path(path)
empty = {
"kind": "gcode",
"printer_family": "",
"filaments": [],
"weight_g": 0,
"estimated_s": 0,
"thumb_entry": "",
"plate": "",
"ace_trailer": False,
"slice_meta": {},
}
lower = path.name.lower()
if lower.endswith(".gcode") and not lower.endswith(".3mf"):
text, trailer = _read_gcode_head_tail(path)
meta = parse_gcode(text, trailer)
meta["kind"] = "gcode"
meta["plate"] = ""
meta["ace_trailer"] = path_has_ace_trailer(path, kind="gcode")
return meta
if not zipfile.is_zipfile(path):
empty["kind"] = "3mf" if lower.endswith(".3mf") else "gcode"
return empty
with zipfile.ZipFile(path) as archive:
names = [name for name in archive.namelist() if safe_zip_name(name) and not name.startswith("__MACOSX")]
plate = pick_name(names, ".gcode", "plate_1.gcode")
thumb = pick_name(names, ".png", "plate_1.png")
text = ""
trailer = ""
ace_trailer = False
if plate:
with archive.open(plate) as handle:
ace_trailer, head = _stream_has_ace_trailer(handle)
text = head.decode("utf-8", "replace")
# Trailer: Ende der Plate-Datei nachlesen
try:
info = archive.getinfo(plate)
if info.file_size > len(head):
with archive.open(plate) as again:
again.read(max(0, info.file_size - 131072))
trailer = again.read(131072).decode("utf-8", "replace")
except (OSError, RuntimeError, KeyError):
trailer = ""
meta = parse_gcode(text, trailer) if text else dict(empty)
meta["kind"] = "gcode.3mf" if plate else "3mf"
meta["plate"] = plate
meta["thumb_entry"] = thumb or meta.get("thumb_entry") or ""
meta["ace_trailer"] = ace_trailer
if not meta["printer_family"]:
for entry in names:
if entry.lower().endswith("project_settings.config"):
raw = archive.read(entry)[:200_000]
try:
model = str(json.loads(raw).get("printer_model") or "")
except (json.JSONDecodeError, UnicodeDecodeError, AttributeError):
model = raw.decode("utf-8", "replace")
meta["printer_family"] = family_from_text(model)
break
return meta
File diff suppressed because it is too large. Load diff
+44
View File
@@ -0,0 +1,44 @@
"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL."""
import json
FAMILY_LABELS = {"kobra_s1": "Kobra S1", "kobra_3": "Kobra 3"}
def family_from_name(name):
low = name.lower()
if "s1" in low:
return "kobra_s1"
if "kobra 3" in low or "kobra3" in low:
return "kobra_3"
return ""
def family_from_text(text):
low = (text or "").lower()
if "kobra s1" in low:
return "kobra_s1"
if "kobra 3" in low or "kobra3" in low:
return "kobra_3"
return ""
def load_printers(path):
rows = json.loads(path.read_text(encoding="utf-8"))
printers = []
for row in rows:
family = row.get("family") or family_from_name(row["name"])
printers.append({
"name": row["name"],
"host": row["host"],
"family": family,
"family_label": FAMILY_LABELS.get(family, family),
})
return printers
def printer_by_host(path, host):
for printer in load_printers(path):
if printer["host"] == host:
return printer
return None
+182
View File
@@ -0,0 +1,182 @@
CREATE TABLE IF NOT EXISTS projects (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
notes text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'idee',
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS spools (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
brand text NOT NULL DEFAULT '',
material_type text NOT NULL DEFAULT '',
color text NOT NULL DEFAULT '',
price_per_kg double precision NOT NULL DEFAULT 0,
density double precision NOT NULL DEFAULT 1.24,
volumetric double precision NOT NULL DEFAULT 12,
remaining_g double precision NOT NULL DEFAULT 1000,
printer_host text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS files (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
name text NOT NULL,
stored_path text NOT NULL,
source text NOT NULL,
kind text NOT NULL,
printer_family text NOT NULL DEFAULT '',
filaments jsonb NOT NULL DEFAULT '[]'::jsonb,
weight_g double precision NOT NULL DEFAULT 0,
estimated_s double precision NOT NULL DEFAULT 0,
thumb_entry text NOT NULL DEFAULT '',
plate text NOT NULL DEFAULT '',
size_bytes bigint NOT NULL DEFAULT 0,
mtime double precision NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (source, stored_path)
);
ALTER TABLE files ADD COLUMN IF NOT EXISTS slice_meta jsonb NOT NULL DEFAULT '{}'::jsonb;
CREATE TABLE IF NOT EXISTS sends (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
filename text NOT NULL,
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE sends ADD COLUMN IF NOT EXISTS ace_map jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE sends ADD COLUMN IF NOT EXISTS bed_leveling boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS runs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
printer_name text NOT NULL DEFAULT '',
filename text NOT NULL DEFAULT '',
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
state text NOT NULL DEFAULT '',
started_at timestamptz,
ended_at timestamptz,
duration_s double precision NOT NULL DEFAULT 0,
weight_g double precision NOT NULL DEFAULT 0,
filament_cost double precision NOT NULL DEFAULT 0,
energy_cost double precision NOT NULL DEFAULT 0,
moonraker_job_id text NOT NULL,
deducted boolean NOT NULL DEFAULT false,
needs_spool boolean NOT NULL DEFAULT false,
verdict text NOT NULL DEFAULT '',
material_type text NOT NULL DEFAULT '',
UNIQUE (printer_host, moonraker_job_id)
);
CREATE INDEX IF NOT EXISTS files_project ON files (project_id);
CREATE INDEX IF NOT EXISTS runs_started ON runs (started_at DESC);
CREATE TABLE IF NOT EXISTS printers (
id uuid PRIMARY KEY,
name text NOT NULL,
host text NOT NULL UNIQUE,
family text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE printers ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS ideas (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'offen',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS materials (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
brand text NOT NULL DEFAULT '',
material_type text NOT NULL DEFAULT '',
density double precision NOT NULL,
volumetric double precision NOT NULL,
volumetric_s1 double precision,
machines text NOT NULL DEFAULT 'beide',
price_per_kg double precision NOT NULL DEFAULT 0,
note text NOT NULL DEFAULT ''
);
ALTER TABLE spools ADD COLUMN IF NOT EXISTS colors jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS ace_slot smallint;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS material_id bigint REFERENCES materials (id) ON DELETE SET NULL;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT '';
ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text;
CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> '';
-- Ein ACE-Platz gehört höchstens einer Spule.
CREATE UNIQUE INDEX IF NOT EXISTS spools_host_slot
ON spools (printer_host, ace_slot)
WHERE printer_host <> '' AND ace_slot IS NOT NULL;
CREATE TABLE IF NOT EXISTS events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
host text NOT NULL DEFAULT '',
printer text NOT NULL DEFAULT '',
kind text NOT NULL,
message text NOT NULL DEFAULT '',
filename text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz
);
CREATE INDEX IF NOT EXISTS events_unread ON events (read_at, created_at DESC);
CREATE TABLE IF NOT EXISTS print_queue (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
file_id bigint NOT NULL REFERENCES files (id) ON DELETE CASCADE,
host text NOT NULL DEFAULT '',
family text NOT NULL DEFAULT '',
priority integer NOT NULL DEFAULT 100,
status text NOT NULL DEFAULT 'waiting',
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
ace_map jsonb NOT NULL DEFAULT '[]'::jsonb,
bed_leveling boolean NOT NULL DEFAULT false,
override_machine boolean NOT NULL DEFAULT false,
message text NOT NULL DEFAULT '',
steps jsonb NOT NULL DEFAULT '[]'::jsonb,
step_index integer NOT NULL DEFAULT 0,
repeat_total integer NOT NULL DEFAULT 1,
repeat_done integer NOT NULL DEFAULT 0,
phase text NOT NULL DEFAULT '',
phase_started_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS steps jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS step_index integer NOT NULL DEFAULT 0;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_total integer NOT NULL DEFAULT 1;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT '';
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz;
CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id);
CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running';
-- Nach dem Druck: Platte manuell quittieren, bevor „wenn frei“ den nächsten Job startet.
CREATE TABLE IF NOT EXISTS bed_status (
host text PRIMARY KEY,
needs_clear boolean NOT NULL DEFAULT false,
cleared_at timestamptz,
message text NOT NULL DEFAULT ''
);
+218
View File
@@ -0,0 +1,218 @@
"""Hintergrund-Queue für Senden und Starten an Moonraker."""
from __future__ import annotations
import threading
import time
import uuid
from collections import deque
from queue import Empty, Queue
from grotti import jobs
from grotti.db import connect
from grotti.errors import GrottiError
class SendQueue:
def __init__(self, database_url, cfg, workers=4):
self.database_url = database_url
self.cfg = cfg
self._lock = threading.Lock()
self._pending = Queue()
self._jobs = {}
self._order = deque(maxlen=60)
self._threads = []
for index in range(max(1, int(workers))):
thread = threading.Thread(
target=self._worker,
name="grotti-send-queue-%s" % (index + 1),
daemon=True,
)
thread.start()
self._threads.append(thread)
def enqueue(
self,
conn,
file_id,
host,
confirm,
override,
spool_id,
ace_map=None,
bed_leveling=False,
):
jobs_out = self.enqueue_many(
conn,
file_id,
[host],
confirm,
override,
spool_id,
{host: ace_map or []} if ace_map else {},
bed_leveling=bed_leveling,
)
return jobs_out[0]
def enqueue_many(
self,
conn,
file_id,
hosts,
confirm,
override,
spool_id,
ace_maps=None,
bed_leveling=False,
):
if not confirm:
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
hosts = [str(host or "").strip() for host in (hosts or []) if str(host or "").strip()]
if not hosts:
raise GrottiError("Mindestens einen Drucker wählen.", "drucker")
seen = set()
unique = []
for host in hosts:
if host in seen:
continue
seen.add(host)
unique.append(host)
ace_maps = ace_maps if isinstance(ace_maps, dict) else {}
# Eine Batch-Option für alle Drucker im Multi-Send (nicht pro Host).
want_level = bool(bed_leveling)
created = []
for host in unique:
printer, row, chosen = jobs.prepare_send(conn, file_id, host, True, override, spool_id)
job_id = uuid.uuid4().hex[:12]
mapping = ace_maps.get(host) or ace_maps.get(printer["name"]) or []
job = {
"id": job_id,
"status": "pending",
"progress": 0,
"step": "warteschlange",
"message": "In der Warteschlange…",
"file_id": row["id"],
"filename": row["name"],
"host": host,
"printer": printer["name"],
"spool_id": chosen,
"override_machine": bool(override),
"ace_map": mapping,
"bed_leveling": want_level,
"result": None,
"error": None,
"created_at": time.time(),
"updated_at": time.time(),
}
with self._lock:
self._jobs[job_id] = job
self._order.appendleft(job_id)
self._pending.put(
{
"id": job_id,
"file_id": row["id"],
"host": host,
"override": bool(override),
"spool_id": chosen,
"ace_map": mapping,
"bed_leveling": want_level,
}
)
created.append(self.snapshot(job_id))
return created
def get(self, job_id):
return self.snapshot(job_id)
def list_jobs(self, limit=20):
with self._lock:
ids = list(self._order)[: max(1, int(limit))]
return [dict(self._jobs[job_id]) for job_id in ids if job_id in self._jobs]
def snapshot(self, job_id):
with self._lock:
job = self._jobs.get(job_id)
return dict(job) if job else None
def _update(self, job_id, **fields):
with self._lock:
job = self._jobs.get(job_id)
if not job:
return
job.update(fields)
job["updated_at"] = time.time()
def _worker(self):
while True:
try:
item = self._pending.get(timeout=1)
except Empty:
continue
self._run(item)
def _run(self, item):
job_id = item["id"]
def on_progress(step, progress, message):
self._update(
job_id,
status="running",
step=step,
progress=int(progress),
message=message,
)
self._update(
job_id,
status="running",
step="vorbereiten",
progress=5,
message="Vorbereitung…",
)
try:
with connect(self.database_url) as conn:
result = jobs.send_file(
conn,
self.cfg,
item["file_id"],
item["host"],
True,
item["override"],
item["spool_id"],
on_progress=on_progress,
prepared=True,
ace_map=item.get("ace_map"),
bed_leveling=bool(item.get("bed_leveling")),
)
conn.commit()
done_msg = "Gestartet auf %s: %s" % (result["printer"], result["filename"])
warnings = result.get("warnings") or []
if warnings:
done_msg += " · " + " · ".join(warnings)
self._update(
job_id,
status="done",
step="fertig",
progress=100,
message=done_msg,
result=result,
error=None,
)
except GrottiError as exc:
self._update(
job_id,
status="error",
step="fehler",
progress=100,
message=exc.message,
error={"message": exc.message, "code": exc.code},
)
except Exception as exc:
self._update(
job_id,
status="error",
step="fehler",
progress=100,
message="Senden ist fehlgeschlagen.",
error={"message": str(exc) or "Senden ist fehlgeschlagen.", "code": "intern"},
)
+1282
View File
File diff suppressed because it is too large. Load diff
+142
View File
@@ -0,0 +1,142 @@
"""G-code zu einem dünnen 3D-Pfad für den Viewer."""
import re
import zipfile
from pathlib import Path
MOVE = re.compile(r"^(?:G0|G00|G1|G01)\b(.*)$", re.IGNORECASE)
# Auch E.39 / Z.6 ohne führende Null (Anycubic/Prusa-Stil)
AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
# Cache-/Viewer-Version: Segment-IDs, kein Sehnen-Thinning
TOOLPATH_VERSION = 5
def _gcode_text(path: Path) -> str:
name = path.name.lower()
if name.endswith(".3mf") or name.endswith(".gcode.3mf"):
with zipfile.ZipFile(path) as archive:
names = [
item
for item in archive.namelist()
if item.lower().endswith(".gcode") and not item.endswith("/")
]
if not names:
return ""
plate = next((item for item in names if "plate" in item.lower()), names[0])
return archive.read(plate).decode("utf-8", "replace")
return path.read_text(encoding="utf-8", errors="replace")
def _thin_points(points, layer_breaks, max_points):
"""Extrusions-Segmente stichprobenartig behalten — nie Endpunkte einer Bahn
weglassen und die Nachbarn verbinden (sonst Sehnen statt Infill)."""
if len(points) <= max_points:
return points, layer_breaks
extrude_ends = [i for i in range(1, len(points)) if points[i][3]]
keep = {0, len(points) - 1}
for break_i in layer_breaks:
keep.add(max(0, min(len(points) - 1, break_i)))
if break_i > 0:
keep.add(break_i - 1)
# Budget für Segment-Endpunkte (jedes Segment braucht 2 Indizes, viele teilen sich)
target_segs = max(2000, max_points // 2)
step = max(1, (len(extrude_ends) + target_segs - 1) // target_segs)
for j, end_i in enumerate(extrude_ends):
if j % step == 0:
keep.add(end_i - 1)
keep.add(end_i)
ordered = sorted(keep)
# Falls immer noch zu groß: Segmente grober
if len(ordered) > max_points:
step2 = max(1, (len(ordered) + max_points - 1) // max_points)
ordered = ordered[::step2]
if ordered[-1] != len(points) - 1:
ordered.append(len(points) - 1)
index_map = {old: new for new, old in enumerate(ordered)}
kept = [points[i] for i in ordered]
new_breaks = sorted({
index_map[b]
for b in layer_breaks
if b in index_map
})
if not new_breaks:
new_breaks = [0]
return kept, new_breaks
def build_toolpath(path: Path, max_points=120000):
text = _gcode_text(path)
if not text:
return {
"points": [],
"layer_breaks": [],
"count": 0,
"version": TOOLPATH_VERSION,
}
x = y = z = 0.0
e = 0.0
absolute_e = True
points = []
layer_breaks = [0]
last_z = None
seq = 0
for raw in text.splitlines():
line = raw.split(";", 1)[0].strip()
if not line:
continue
upper = line.upper()
if upper.startswith("M82"):
absolute_e = True
continue
if upper.startswith("M83"):
absolute_e = False
continue
match = MOVE.match(line)
if not match:
continue
nx, ny, nz, ne = x, y, z, e
saw_e = False
for axis, value in AXIS.findall(match.group(1)):
number = float(value)
axis = axis.upper()
if axis == "X":
nx = number
elif axis == "Y":
ny = number
elif axis == "Z":
nz = number
elif axis == "E":
saw_e = True
ne = number if absolute_e else e + number
if nx == x and ny == y and nz == z:
if saw_e:
e = ne
continue
extrude = 1 if saw_e and ne > e + 1e-6 else 0
x, y, z = nx, ny, nz
if saw_e:
e = ne
if last_z is None:
last_z = z
elif abs(z - last_z) > 0.01:
layer_breaks.append(len(points))
last_z = z
# seq = Originalindex — Viewer verbindet nur seq und seq+1
points.append([round(x, 3), round(y, 3), round(z, 3), extrude, seq])
seq += 1
points, layer_breaks = _thin_points(points, layer_breaks, max_points)
return {
"points": points,
"layer_breaks": layer_breaks,
"count": len(points),
"version": TOOLPATH_VERSION,
}
+3
View File
@@ -0,0 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "2.0.0"
+197
View File
@@ -0,0 +1,197 @@
"""Drucker-Zustände beobachten → events + optional Telegram."""
from __future__ import annotations
import threading
import time
from grotti import moonraker, notify, store
from grotti.db import connect
class StateWatcher:
def __init__(self, database_url, cfg, interval=10):
self.database_url = database_url
self.cfg = cfg
self.interval = max(5, int(interval))
self._prev = {}
self._offline_hits = {}
self.latest = []
self._lock = threading.Lock()
self._updated_at = 0.0
self._stop = threading.Event()
self._thread = None
def start(self):
if self._thread and self._thread.is_alive():
return
self._thread = threading.Thread(
target=self._loop,
name="grotti-watcher",
daemon=True,
)
self._thread.start()
def stop(self):
self._stop.set()
def snapshot(self):
"""Kopie des letzten Live-Stands (für API und Dispatcher)."""
with self._lock:
return [dict(row) for row in self.latest]
def by_host(self):
return {row.get("host"): row for row in self.snapshot() if row.get("host")}
def age_s(self):
if not self._updated_at:
return None
return time.monotonic() - self._updated_at
def _loop(self):
while not self._stop.is_set():
try:
self.tick()
except Exception as exc:
print("Watcher: %s" % exc, flush=True)
self._stop.wait(self.interval)
def tick(self):
with connect(self.database_url) as conn:
printers = store.list_printers(conn)
if not printers:
conn.commit()
return
lives = moonraker.snapshot(printers)
with self._lock:
self.latest = lives
self._updated_at = time.monotonic()
for live in lives:
self._one(conn, live)
conn.commit()
def _one(self, conn, live):
host = live.get("host") or ""
name = live.get("name") or host
state = (live.get("state") or "").lower()
filename = live.get("filename") or ""
offline = live.get("ok") is False or state in ("", "offline")
prev = self._prev.get(host) or {}
prev_state = (prev.get("state") or "").lower()
prev_offline = bool(prev.get("offline"))
if offline:
hits = self._offline_hits.get(host, 0) + 1
self._offline_hits[host] = hits
if hits >= 2 and not prev_offline:
self._emit(conn, host, name, "offline", "%s ist offline." % name, filename)
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
return
self._offline_hits[host] = 0
if prev_offline:
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename)
elif prev_state and prev_state not in ("printing", "paused") and state == "printing":
self._emit(conn, host, name, "start", "%s druckt %s" % (name, filename or "einen Auftrag"), filename)
elif prev_state == "printing" and state == "paused":
self._emit(conn, host, name, "start", "%s pausiert." % name, filename)
elif prev_state == "paused" and state == "printing":
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename)
elif prev_state == "printing" and state == "complete":
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
self._emit(
conn,
host,
name,
"fertig",
"%s fertig: %s" % (name, filename or "Druck"),
filename,
)
# Protokoll und Spulenabzug nicht erst nach manuellem Knopf
try:
from grotti import jobs
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
store.set_bed_needs_clear(conn, host, True, filename or state)
self._emit(
conn,
host,
name,
"abbruch",
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
filename,
)
elif state == "error" and prev_state != "error":
if prev_state == "printing" or prev_state == "paused":
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
msg = (live.get("message") or "").strip()
if not msg or msg.lower() in ("error", "fehler"):
msg = "Ursache unbekannt — in Mainsail prüfen"
self._cool_heaters(host)
self._emit(
conn,
host,
name,
"fehler",
"%s: %s · Heizung aus" % (name, msg),
filename,
)
elif state == "error":
# Schon im Fehler, Heizer laufen noch → aus
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host)
elif state == "complete":
known = conn.execute(
"SELECT 1 FROM bed_status WHERE host = %s", (host,)
).fetchone()
if not known:
# Erster Stand nach Deploy: fertiger Druck ohne Quittierung
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
spool_warn = bool(prev.get("spool_warn"))
if state == "printing" and not spool_warn:
spool_warn = self._check_spool(conn, host, name, filename, live)
self._prev[host] = {
"state": state,
"offline": False,
"filename": filename,
"spool_warn": spool_warn if state == "printing" else False,
}
def _cool_heaters(self, host):
try:
moonraker.heaters_off(host)
except Exception as exc:
print("Watcher Heizung aus %s: %s" % (host, exc), flush=True)
def _check_spool(self, conn, host, name, filename, live):
need = float(live.get("weight") or 0)
if need <= 0 or not filename:
return False
sent = store.latest_send(conn, host, filename)
spool_id = (sent or {}).get("spool_id")
if not spool_id:
return False
spool = store.get_spool_row(conn, spool_id)
if not spool:
return False
left = float(spool["remaining_g"] or 0)
if left + 0.5 >= need:
return False
self._emit(
conn,
host,
name,
"spule",
"%s: Spule „%s“ hat nur %.0f g, Druck braucht %.0f g."
% (name, spool["name"], left, need),
filename,
)
return True
def _emit(self, conn, host, printer, kind, message, filename):
notify.record(conn, self.cfg, host, printer, kind, message, filename, dedupe_s=60)