Grotti 2.0.0: eigenes Repo, Umbenennung von Farm.
This commit is contained in:
commit
b2e6443c68
69 files changed
+18719
No files matched your search
Whitespace-only changes.
+111
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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
@@ -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 ""
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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 ''
|
||||
);
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user