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
+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)
|
||||
Reference in new issue
Block a user