Files
grotti/grotti/eject.py
T
Dominic Gundel 9e22cebb81 Grotti 2.10.0: Orca-Senden, Slice-Ordner, ED-Strom, Hygiene.
Slice-Watch mit Ordnern/Filtern, Orca-Nachbearbeitung, Cockpit-Verlauf
mit ED, Zeitzone im Browser. Tasten-Grenze und README-API angeglichen,
toter Code entfernt, Requirements gepinnt.
2026-10-18 00:41:15 +02:00

573 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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. 3 mm: kalter Kunststoff an der Düse
# tastet sonst oft 1–2 mm und meldet Restteil.
"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 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 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 ""