Grotti 2.0.0: eigenes Repo, Umbenennung von Farm.

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

No files matched your search

+503
View File
@@ -0,0 +1,503 @@
"""Senden, Steuern und das Druckprotokoll."""
import json
import urllib.error
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from grotti import acm, eject, library, moonraker, store
from grotti.cost import energy_cost
from grotti.errors import GrottiError
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
from grotti.printers import FAMILY_LABELS
TERMINAL = {"completed", "cancelled", "error", "interrupted"}
def weight_of(metadata):
raw = (metadata or {}).get("filament_weight_total")
if isinstance(raw, list):
return sum(float(item or 0) for item in raw)
if isinstance(raw, (int, float)):
return float(raw)
if isinstance(raw, str):
try:
return float(raw.replace(",", "."))
except ValueError:
return 0.0
return 0.0
def material_of(metadata):
raw = (metadata or {}).get("filament_type") or ""
if isinstance(raw, list):
raw = ";".join(str(item) for item in raw if item)
return str(raw).split(";")[0].strip()
def unix_to_dt(value):
if not value:
return None
return datetime.fromtimestamp(float(value), timezone.utc)
def choose_spool(conn, host, material, hinted_id):
if hinted_id:
row = store.get_spool_row(conn, hinted_id)
if row:
return row
rows = store.spools_for_printer(conn, host)
if len(rows) == 1:
return rows[0]
if material:
same = [row for row in rows if (row["material_type"] or "").lower() == material.lower()]
if len(same) == 1:
return same[0]
return None
def file_material_types(file_row):
types = []
for item in (file_row or {}).get("filaments") or []:
kind = (item.get("type") or "").strip()
if kind and kind not in types:
types.append(kind)
return types
def resolve_spools_at_host(conn, host, ace_map=None):
"""Spulen am Drucker zur Startzeit: ACE-Plätze aus Map, sonst externe Rolle."""
mapping = acm.normalize_mapping(ace_map)
found = []
if mapping:
for item in mapping:
slot = int(item["ams_index"]) + 1
row = conn.execute(
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(host, slot),
).fetchone()
if row:
found.append(row)
return found
# Externe Spule: höchstens eine ohne ACE-Platz am Host.
rows = conn.execute(
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot IS NULL
ORDER BY id
""",
(host,),
).fetchall()
return list(rows)
def assert_material_match(file_row, spools):
"""Datei-Material gegen eingelegte Rollen — warnt nicht bei leerer Liste."""
want = {t.lower() for t in file_material_types(file_row)}
if not want or not spools:
return
have = {(row.get("material_type") or "").strip().lower() for row in spools}
have.discard("")
if not have:
return
if want.isdisjoint(have):
raise GrottiError(
"Material passt nicht: Datei braucht %s, im Drucker steckt %s."
% (", ".join(sorted(want)), ", ".join(sorted(have)) or "unbekannt"),
"material",
409,
)
def assert_spool_at_host(conn, spool_id, host):
if not spool_id or not host:
return
spool = store.get_spool_row(conn, spool_id)
if not spool:
return
if (spool.get("printer_host") or "") != host:
raise GrottiError(
"Spule „%s“ steckt nicht in diesem Drucker (Ort: %s)."
% (spool["name"], spool.get("printer_host") or "Regal"),
"spule",
409,
)
def apply_deduction(conn, run, energy):
if run["deducted"] or run["weight_g"] <= 0 or run["state"] not in TERMINAL:
return False
weight = float(run["weight_g"] or 0)
# ACE: Abzug anteilig auf die Plätze der letzten Sendung
sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
ace = []
if sent:
raw = sent.get("ace_map")
if isinstance(raw, list):
ace = raw
elif isinstance(raw, str):
try:
ace = json.loads(raw)
except json.JSONDecodeError:
ace = []
targets = []
if ace:
for item in acm.normalize_mapping(ace):
slot = int(item["ams_index"]) + 1
row = conn.execute(
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(run["printer_host"], slot),
).fetchone()
if row:
targets.append(row)
if not targets and run.get("spool_id"):
spool = store.get_spool_row(conn, run["spool_id"])
if spool:
targets = [spool]
if not targets:
return False
share = weight / len(targets)
filament = sum((share / 1000) * float(spool["price_per_kg"] or 0) for spool in targets)
power = energy_cost(run["duration_s"], energy)
marked = store.mark_deducted(conn, run["id"], targets[0]["id"], filament, power)
if not marked:
return False
for spool in targets:
store.deduct_spool(conn, spool["id"], share)
return True
def sync_history(conn, cfg):
printers = store.list_printers(conn)
def fetch(printer):
return printer, moonraker.history(printer["host"])
with ThreadPoolExecutor(max_workers=max(1, len(printers))) as pool:
fetched = list(pool.map(fetch, printers))
collected = []
for printer, items in fetched:
for job in items:
collected.append((printer, job))
deducted = 0
for printer, job in collected:
job_id = str(job.get("job_id") or "")
if not job_id:
continue
filename = job.get("filename") or ""
metadata = job.get("metadata") or {}
weight = weight_of(metadata)
material = material_of(metadata)
state = job.get("status") or ""
sent = store.latest_send(conn, printer["host"], filename)
known = store.file_by_name(conn, filename)
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
project_id = (sent or {}).get("project_id") or (known or {}).get("project_id")
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
spool = choose_spool(conn, printer["host"], material, hinted)
terminal = state in TERMINAL
row = store.upsert_run(conn, {
"printer_host": printer["host"],
"printer_name": printer["name"],
"filename": filename,
"file_id": file_id,
"project_id": project_id,
"spool_id": spool["id"] if spool else None,
"state": state,
"started_at": unix_to_dt(job.get("start_time")),
"ended_at": unix_to_dt(job.get("end_time")),
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
"weight_g": weight,
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
"moonraker_job_id": job_id,
"needs_spool": bool(terminal and weight > 0 and not spool),
"material_type": material,
})
if apply_deduction(conn, row, cfg["energy"]):
deducted += 1
return {"gelesen": len(collected), "abgezogen": deducted}
def assert_filament_enough(conn, file_row, spool_id=None, ace_map=None, host=""):
"""Dateien-Gewicht gegen Spule oder ACE-Plätze prüfen."""
need = float((file_row or {}).get("weight_g") or 0)
if need <= 0:
return
mapping = acm.normalize_mapping(ace_map)
if mapping and host:
leftovers = []
for item in mapping:
slot = int(item["ams_index"]) + 1
row = conn.execute(
"""
SELECT remaining_g, name FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(host, slot),
).fetchone()
if row:
leftovers.append(float(row["remaining_g"] or 0))
if leftovers and sum(leftovers) + 0.5 < need:
raise GrottiError(
"ACE-Plätze haben nur %.0f g, Datei braucht %.0f g."
% (sum(leftovers), need),
"spule",
)
return
if not spool_id:
return
spool = store.get_spool_row(conn, spool_id)
if not spool:
return
left = float(spool["remaining_g"] or 0)
if left + 0.5 < need:
raise GrottiError(
"Spule „%s“ hat nur %.0f g, Datei braucht %.0f g."
% (spool["name"], left, need),
"spule",
)
def prepare_send(conn, file_id, host, confirm, override, spool_id, ace_map=None):
if not confirm:
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
printer = store.printer_by_host(conn, host)
if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
row = store.get_file_row(conn, file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
file_family = row["printer_family"]
if file_family and printer["family"] and file_family != printer["family"] and not override:
raise GrottiError(
"Die Datei ist für %s geschnitten, der Drucker ist eine %s."
% (FAMILY_LABELS.get(file_family, file_family), printer["family_label"] or "andere Maschine"),
"maschine",
409,
)
# Spule ist Ort des Druckers — Auflösung zur Startzeit, optionaler Hinweis nur wenn
# die gewählte Spule wirklich an diesem Host steckt.
if spool_id:
assert_spool_at_host(conn, spool_id, host)
at_host = resolve_spools_at_host(conn, host, ace_map)
assert_material_match(row, at_host)
if spool_id and store.get_spool_row(conn, spool_id):
chosen = spool_id
elif len(at_host) == 1:
chosen = at_host[0]["id"]
elif at_host:
chosen = at_host[0]["id"]
else:
chosen = None
assert_filament_enough(conn, row, chosen, ace_map, host)
return printer, row, chosen
def send_file(
conn,
cfg,
file_id,
host,
confirm,
override,
spool_id,
on_progress=None,
prepared=False,
ace_map=None,
bed_leveling=False,
keep_motors=False,
):
def progress(step, percent, message):
if on_progress:
on_progress(step, percent, message)
if prepared:
printer = store.printer_by_host(conn, host)
row = store.get_file_row(conn, file_id)
if not printer or not row:
raise GrottiError("Die Datei oder der Drucker fehlt.", "datei", 404)
chosen = spool_id
if chosen and not store.get_spool_row(conn, chosen):
chosen = None
else:
printer, row, chosen = prepare_send(
conn, file_id, host, confirm, override, spool_id, ace_map=ace_map
)
if prepared:
assert_filament_enough(conn, row, chosen, ace_map, host)
progress("pruefen", 15, "Drucker prüfen…")
try:
live = moonraker.query(host)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as exc:
raise GrottiError("Der Drucker ist nicht erreichbar.", "offline") from exc
if live.get("state") in ("printing", "paused"):
raise GrottiError("Der Drucker druckt schon.", "belegt", 409)
state = (live.get("state") or "").lower()
if state == "error":
# Hängender Status nach fehlgeschlagenem Befehl — Start-G-code homed selbst,
# aber print_stats=error blockiert. Kurz zurücksetzen, dann weiter.
try:
moonraker.clear_print_error(host)
live = moonraker.query(host)
state = (live.get("state") or "").lower()
except Exception:
pass
if state == "error":
raise GrottiError(
"Drucker im Fehlerzustand — auf der Wand „Fehler löschen“, dann nochmal starten.",
"fehler",
409,
)
# Kein Homing-Zwang vor Start: Idle löscht homed_axes; der Slice-Start macht G28.
slots = live.get("slots") or moonraker.slots(host)
mapping = acm.normalize_mapping(ace_map)
filaments = list(row.get("filaments") or [])
if slots and not mapping:
raise GrottiError(
"Dieser Drucker hat ACE/MMU-Plätze. Bitte Farbe der Datei einem ACE-Platz zuordnen.",
"ace",
)
if slots and mapping and not filaments:
filaments = [{"type": "PLA", "color": ""}]
progress("vorbereiten", 30, "Datei vorbereiten…")
filename, content, plate = library.printer_file(row)
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
content = prepare_rinkhals_gcode(content, tools=ace_tools)
if keep_motors:
# Slice-Ende `M84` → sofort unhomed; Abräumen braucht die Stepper.
content = eject.strip_end_motor_disable(content)
size_mb = len(content) / (1024 * 1024)
def upload_progress(sent, total):
if total <= 0:
return
frac = min(1.0, sent / total)
pct = 45 + int(frac * 19)
if sent >= total:
progress(
"hochladen",
pct,
"%s liest die Datei…" % printer["name"],
)
return
progress(
"hochladen",
pct,
"Hochladen zu %s… %.0f/%.0f MB" % (
printer["name"],
sent / (1024 * 1024),
total / (1024 * 1024),
),
)
progress(
"hochladen",
45,
"Hochladen zu %s… 0/%.0f MB" % (printer["name"], size_mb),
)
ace_payload = None
warnings = []
has_trailer = content_has_ace_trailer(content, kind=row.get("kind") or "", plate=plate)
if slots and mapping and not has_trailer:
# Nach prepare_rinkhals_gcode sollte der Trailer da sein; sonst Warnung.
warnings.append(acm.TRAILER_WARN)
progress("ace", 42, acm.TRAILER_WARN)
try:
stored = moonraker.upload(host, filename, content, on_progress=upload_progress)
if slots and mapping:
ace_payload = acm.build_acm(filaments, mapping, slots)
if ace_payload:
progress("ace", 65, "ACE-Zuordnung hochladen…")
body = acm.acm_bytes(ace_payload)
for acm_name in acm.acm_upload_names(stored):
moonraker.upload(host, acm_name, body)
progress("starten", 80, "Druck starten…")
moonraker.start_print(
host,
stored,
bed_leveling=bool(bed_leveling),
ams_settings=ace_payload,
)
except moonraker.PrinterError as exc:
try:
moonraker.heaters_off(host)
except Exception:
pass
raise GrottiError(exc.message, "drucker") from exc
progress("buchen", 95, "Auftrag speichern…")
store.add_send(
conn,
host,
stored,
row["id"],
row["project_id"],
chosen,
ace_map=mapping,
bed_leveling=bool(bed_leveling),
)
# needs_clear erst bei Druckende (Watcher) — sonst kurz „Platte belegt“ vorm Start
note = "Gestartet auf %s: %s" % (printer["name"], stored)
if bed_leveling:
note += " (Bettnetz vor dem Druck)"
if warnings:
note += " · " + " · ".join(warnings)
progress("fertig", 100, note)
return {
"printer": printer["name"],
"filename": stored,
"plate": plate,
"ace": bool(slots and mapping),
"ace_trailer": has_trailer,
"warnings": warnings,
"bed_leveling": bool(bed_leveling),
"bed_leveling_wired": True,
}
def control(conn, host, action):
printer = store.printer_by_host(conn, host)
if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
try:
moonraker.command(host, action)
except moonraker.PrinterError as exc:
raise GrottiError(exc.message, "drucker") from exc
return {"printer": printer["name"], "action": action}
def steuern(conn, host, action, params=None):
printer = store.printer_by_host(conn, host)
if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
try:
moonraker.steuern(host, action, params)
except moonraker.PrinterError as exc:
raise GrottiError(exc.message, "drucker") from exc
return {"printer": printer["name"], "action": action}
def assign_and_deduct(conn, cfg, run_id, spool_id):
spool = store.get_spool_row(conn, spool_id)
if not spool:
raise GrottiError("Die Spule fehlt.", "spule", 404)
run = store.assign_run_spool(conn, run_id, spool_id)
if not run:
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
apply_deduction(conn, run, cfg["energy"])
fresh = store.get_run_row(conn, run_id)
return store.run_out(fresh)