1159 lines
42 KiB
Python
1159 lines
42 KiB
Python
"""Senden, Steuern und das Druckprotokoll."""
|
||
|
||
import json
|
||
import urllib.error
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from datetime import datetime, timezone
|
||
|
||
from grotti import acm, catalog, eject, library, logutil, moonraker, store, usage
|
||
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):
|
||
return usage.weight_sum(metadata)
|
||
|
||
|
||
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 _ace_map_of(sent):
|
||
if not sent:
|
||
return []
|
||
raw = sent.get("ace_map")
|
||
if isinstance(raw, list):
|
||
return acm.normalize_mapping(raw)
|
||
if isinstance(raw, str):
|
||
try:
|
||
return acm.normalize_mapping(json.loads(raw))
|
||
except json.JSONDecodeError:
|
||
return []
|
||
return []
|
||
|
||
|
||
def _targets_from_ace(conn, host, ace, ended_at=None):
|
||
targets = []
|
||
for item in ace:
|
||
slot = int(item["ams_index"]) + 1
|
||
row = store.spool_at_slot(conn, host, slot)
|
||
if row and _spool_valid_for_end(row, ended_at):
|
||
targets.append(row)
|
||
return targets
|
||
|
||
|
||
def unix_to_dt(value):
|
||
if not value:
|
||
return None
|
||
return datetime.fromtimestamp(float(value), timezone.utc)
|
||
|
||
|
||
# Anycubic schließt bei Filament-Pause den History-Job (cancelled) und öffnet
|
||
# beim Fortsetzen eine neue Job-ID. Zusammengelegt wird nur mit Beweis:
|
||
# print_duration läuft weiter und die Lücke ist klein, oder der Watcher
|
||
# hat paused→printing gesehen. Ein Neustart mit Dauer nahe 0 bleibt getrennt.
|
||
RESUME_GAP_S = 5
|
||
RESUME_DURATION_TOL_S = 30
|
||
RESUME_FRESH_MAX_S = 60
|
||
# Abwärtskompatibel für alte Aufrufe / Docs.
|
||
RESUME_JOIN_S = RESUME_GAP_S
|
||
LIVE_DEDUCT_FALLBACK_S = 600
|
||
SYNC_LOCK_KEY = 748291053
|
||
|
||
|
||
def _as_utc(value):
|
||
if value is None:
|
||
return None
|
||
if getattr(value, "tzinfo", None) is None:
|
||
return value.replace(tzinfo=timezone.utc)
|
||
return value
|
||
|
||
|
||
def history_shell_is_empty(run_or_job):
|
||
"""History-Job ohne Ende und ohne Dauer. Nie als eigenen Lauf speichern."""
|
||
if not run_or_job:
|
||
return False
|
||
duration = float(
|
||
run_or_job.get("duration_s")
|
||
or run_or_job.get("print_duration")
|
||
or run_or_job.get("total_duration")
|
||
or 0
|
||
)
|
||
ended = run_or_job.get("ended_at") or run_or_job.get("end_time")
|
||
return duration <= 0 and not ended
|
||
|
||
|
||
def is_filament_resume(
|
||
prev_end,
|
||
new_start,
|
||
prev_duration_s=None,
|
||
new_duration_s=None,
|
||
watcher_resume=False,
|
||
gap_s=RESUME_GAP_S,
|
||
):
|
||
"""True nur mit Beweis: Dauer fortgesetzt + kleine Lücke, oder Watcher-Signal.
|
||
|
||
Aufruf mit zwei Zeitpunkten (alt): nur Lücke — gilt nicht mehr als Resume.
|
||
"""
|
||
prev_end = _as_utc(prev_end)
|
||
new_start = _as_utc(new_start)
|
||
if prev_end is None or new_start is None:
|
||
return False
|
||
gap = (new_start - prev_end).total_seconds()
|
||
if not (-30 <= gap <= max(float(gap_s), RESUME_GAP_S)):
|
||
# Watcher-Signal: Lücke darf länger sein (Pause vor dem Resume-Ende).
|
||
if not watcher_resume or gap > 24 * 3600 or gap < -30:
|
||
return False
|
||
return True
|
||
if watcher_resume:
|
||
return True
|
||
# Zwei-Argument-Aufruf (Tests/alt): ohne Dauer kein Beweis → kein Resume.
|
||
if prev_duration_s is None and new_duration_s is None:
|
||
return False
|
||
prev_d = float(prev_duration_s or 0)
|
||
new_d = float(new_duration_s or 0)
|
||
# Frischer Neustart: Dauer beginnt nahe 0 — nie zusammenlegen.
|
||
if new_d <= RESUME_FRESH_MAX_S:
|
||
return False
|
||
if new_d < max(0.0, prev_d - RESUME_DURATION_TOL_S):
|
||
return False
|
||
return True
|
||
|
||
|
||
def undo_deduction(conn, run):
|
||
"""Spulenabzug eines Laufs zurücknehmen.
|
||
|
||
Letzten Abschnitt wieder öffnen — nach Filamentpause schließt ein
|
||
vorzeitiger History-Abgleich den Abschnitt, bevor der Druck weiterläuft.
|
||
"""
|
||
if not run or not run.get("deducted"):
|
||
return False
|
||
rows = conn.execute(
|
||
"SELECT spool_id, weight_g FROM run_spools WHERE run_id = %s",
|
||
(run["id"],),
|
||
).fetchall()
|
||
for row in rows:
|
||
grams = float(row["weight_g"] or 0)
|
||
if grams and row["spool_id"]:
|
||
conn.execute(
|
||
"UPDATE spools SET remaining_g = remaining_g + %s WHERE id = %s",
|
||
(grams, row["spool_id"]),
|
||
)
|
||
conn.execute("DELETE FROM run_spools WHERE run_id = %s", (run["id"],))
|
||
conn.execute(
|
||
"UPDATE runs SET deducted = false, filament_cost = 0 WHERE id = %s",
|
||
(run["id"],),
|
||
)
|
||
conn.execute(
|
||
"""
|
||
UPDATE run_segments
|
||
SET mm_end = NULL, closed_at = NULL
|
||
WHERE id = (
|
||
SELECT id FROM run_segments
|
||
WHERE run_id = %s
|
||
ORDER BY id DESC
|
||
LIMIT 1
|
||
)
|
||
AND mm_end IS NOT NULL
|
||
""",
|
||
(run["id"],),
|
||
)
|
||
return True
|
||
|
||
|
||
def fold_filament_resume(
|
||
conn,
|
||
host,
|
||
filename,
|
||
job_id,
|
||
start_dt,
|
||
print_duration_s=0,
|
||
watcher_resume=False,
|
||
):
|
||
"""Fortsetzung nach Filament-Pause auf den bestehenden Lauf legen.
|
||
|
||
Gibt die Lauf-ID zurück, deren moonraker_job_id jetzt job_id ist.
|
||
Leere Hüllen (Dauer 0, kein Ende) werden vorher übersprungen — nicht hier.
|
||
"""
|
||
pred = store.find_resume_predecessor(
|
||
conn, host, filename, job_id, start_dt, watcher_resume=watcher_resume
|
||
)
|
||
if not pred:
|
||
return None
|
||
if not is_filament_resume(
|
||
pred.get("ended_at"),
|
||
start_dt,
|
||
prev_duration_s=pred.get("duration_s"),
|
||
new_duration_s=print_duration_s,
|
||
watcher_resume=watcher_resume,
|
||
):
|
||
return None
|
||
other = conn.execute(
|
||
"""
|
||
SELECT * FROM runs
|
||
WHERE printer_host = %s AND moonraker_job_id = %s AND id <> %s
|
||
""",
|
||
(host, str(job_id), pred["id"]),
|
||
).fetchone()
|
||
store.remember_run_job(conn, host, pred.get("moonraker_job_id"), pred["id"], replace=True)
|
||
undo_deduction(conn, pred)
|
||
kept = None
|
||
if other:
|
||
undo_deduction(conn, other)
|
||
conn.execute(
|
||
"UPDATE events SET run_id = %s WHERE run_id = %s",
|
||
(pred["id"], other["id"]),
|
||
)
|
||
if not (pred.get("verdict") or "").strip() and (other.get("verdict") or "").strip():
|
||
conn.execute(
|
||
"""
|
||
UPDATE runs
|
||
SET verdict = %s, verdict_reason = %s, pieces_ok = %s, pieces_bad = %s
|
||
WHERE id = %s
|
||
""",
|
||
(
|
||
other.get("verdict") or "",
|
||
other.get("verdict_reason") or "",
|
||
other.get("pieces_ok") or 0,
|
||
other.get("pieces_bad") or 0,
|
||
pred["id"],
|
||
),
|
||
)
|
||
kept = {
|
||
"state": other.get("state") or "completed",
|
||
"ended_at": other.get("ended_at"),
|
||
"duration_s": float(other.get("duration_s") or 0),
|
||
"weight_used_g": float(other.get("weight_used_g") or 0),
|
||
"weight_source": other.get("weight_source") or "",
|
||
"filament_used_mm": float(other.get("filament_used_mm") or 0),
|
||
"filament_total_mm": float(other.get("filament_total_mm") or 0),
|
||
"progress_last": float(other.get("progress_last") or 0),
|
||
}
|
||
for table in ("run_samples", "run_segments"):
|
||
conn.execute(
|
||
"UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table,
|
||
(pred["id"], other["id"]),
|
||
)
|
||
try:
|
||
conn.execute(
|
||
"""
|
||
DELETE FROM run_labels AS src
|
||
WHERE src.run_id = %s
|
||
AND EXISTS (
|
||
SELECT 1 FROM run_labels AS dst
|
||
WHERE dst.run_id = %s AND dst.object_name = src.object_name
|
||
)
|
||
""",
|
||
(other["id"], pred["id"]),
|
||
)
|
||
conn.execute(
|
||
"UPDATE run_labels SET run_id = %s WHERE run_id = %s",
|
||
(pred["id"], other["id"]),
|
||
)
|
||
except Exception:
|
||
logutil.get("grotti.jobs").warning(
|
||
"Etiketten von Lauf %s nicht übernommen.", other["id"]
|
||
)
|
||
conn.execute("DELETE FROM run_spools WHERE run_id = %s", (other["id"],))
|
||
store.move_run_jobs(conn, host, other["id"], pred["id"])
|
||
# Job-ID freigeben, sonst Unique (printer_host, moonraker_job_id) beim Pred.
|
||
conn.execute(
|
||
"UPDATE runs SET moonraker_job_id = %s WHERE id = %s",
|
||
("live:merged:%s" % other["id"], other["id"]),
|
||
)
|
||
conn.execute("DELETE FROM runs WHERE id = %s", (other["id"],))
|
||
# Der Druck läuft weiter. Das Ende kommt vom Folgejob.
|
||
conn.execute(
|
||
"""
|
||
UPDATE runs
|
||
SET moonraker_job_id = %s,
|
||
ended_at = NULL,
|
||
state = CASE
|
||
WHEN %s IN ('in_progress', 'printing', 'paused') THEN %s
|
||
WHEN state IN ('cancelled', 'interrupted', 'error') THEN 'in_progress'
|
||
ELSE state
|
||
END
|
||
WHERE id = %s
|
||
""",
|
||
(
|
||
str(job_id),
|
||
(kept or {}).get("state") or "in_progress",
|
||
(kept or {}).get("state") or "in_progress",
|
||
pred["id"],
|
||
),
|
||
)
|
||
store.remember_run_job(conn, host, job_id, pred["id"], replace=True)
|
||
store.set_printer_active_run(conn, host, pred["id"])
|
||
if kept and kept.get("ended_at"):
|
||
conn.execute(
|
||
"""
|
||
UPDATE runs SET
|
||
state = %s,
|
||
ended_at = COALESCE(%s, ended_at),
|
||
duration_s = GREATEST(COALESCE(duration_s, 0), %s),
|
||
weight_used_g = GREATEST(COALESCE(weight_used_g, 0), %s),
|
||
weight_source = CASE WHEN %s <> '' THEN %s ELSE weight_source END,
|
||
filament_used_mm = GREATEST(COALESCE(filament_used_mm, 0), %s),
|
||
filament_total_mm = CASE WHEN %s > 0 THEN %s ELSE filament_total_mm END,
|
||
progress_last = GREATEST(COALESCE(progress_last, 0), %s),
|
||
deducted = false
|
||
WHERE id = %s
|
||
""",
|
||
(
|
||
kept["state"],
|
||
kept["ended_at"],
|
||
kept["duration_s"],
|
||
kept["weight_used_g"],
|
||
kept["weight_source"],
|
||
kept["weight_source"],
|
||
kept["filament_used_mm"],
|
||
kept["filament_total_mm"],
|
||
kept["filament_total_mm"],
|
||
kept["progress_last"],
|
||
pred["id"],
|
||
),
|
||
)
|
||
elif print_duration_s:
|
||
conn.execute(
|
||
"""
|
||
UPDATE runs
|
||
SET duration_s = GREATEST(COALESCE(duration_s, 0), %s),
|
||
deducted = false
|
||
WHERE id = %s
|
||
""",
|
||
(float(print_duration_s), pred["id"]),
|
||
)
|
||
return pred["id"]
|
||
|
||
|
||
def history_in_order(items):
|
||
"""Älteste zuerst. Der abgebrochene Teil muss stehen, bevor die Fortsetzung kommt."""
|
||
return sorted(
|
||
items or [],
|
||
key=lambda job: (float(job.get("start_time") or 0), str(job.get("job_id") or "")),
|
||
)
|
||
|
||
|
||
def manual_merge_runs(conn, keep_id, absorb_id, confirm=False):
|
||
"""Zwei Läufe manuell verbinden. keep überlebt, absorb wird übernommen."""
|
||
if not confirm:
|
||
raise GrottiError("Verbinden braucht eine Bestätigung.", "bestaetigung")
|
||
keep = store.get_run_row(conn, keep_id)
|
||
absorb = store.get_run_row(conn, absorb_id)
|
||
if not keep or not absorb:
|
||
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
|
||
if keep["id"] == absorb["id"]:
|
||
raise GrottiError("Gleicher Lauf.", "lauf")
|
||
if (keep.get("printer_host") or "") != (absorb.get("printer_host") or ""):
|
||
raise GrottiError("Nur Läufe desselben Druckers.", "drucker")
|
||
if (keep.get("filename") or "") != (absorb.get("filename") or ""):
|
||
raise GrottiError("Nur Läufe derselben Datei.", "datei")
|
||
# Früherer Start bleibt der überlebende Lauf.
|
||
if keep.get("started_at") and absorb.get("started_at") and absorb["started_at"] < keep["started_at"]:
|
||
keep, absorb = absorb, keep
|
||
host = keep["printer_host"]
|
||
undo_deduction(conn, keep)
|
||
undo_deduction(conn, absorb)
|
||
snapshot = {
|
||
"keep_id": keep["id"],
|
||
"absorb_id": absorb["id"],
|
||
"absorb_job_id": absorb.get("moonraker_job_id"),
|
||
}
|
||
for table in ("run_samples", "run_segments", "run_spools", "events"):
|
||
conn.execute(
|
||
"UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table,
|
||
(keep["id"], absorb["id"]),
|
||
)
|
||
try:
|
||
conn.execute(
|
||
"""
|
||
DELETE FROM run_labels AS src
|
||
WHERE src.run_id = %s
|
||
AND EXISTS (
|
||
SELECT 1 FROM run_labels AS dst
|
||
WHERE dst.run_id = %s AND dst.object_name = src.object_name
|
||
)
|
||
""",
|
||
(absorb["id"], keep["id"]),
|
||
)
|
||
conn.execute(
|
||
"UPDATE run_labels SET run_id = %s WHERE run_id = %s",
|
||
(keep["id"], absorb["id"]),
|
||
)
|
||
except Exception:
|
||
pass
|
||
store.move_run_jobs(conn, host, absorb["id"], keep["id"])
|
||
job_id = absorb.get("moonraker_job_id") or keep.get("moonraker_job_id")
|
||
if job_id and not str(job_id).startswith("live:"):
|
||
store.remember_run_job(conn, host, job_id, keep["id"], replace=True)
|
||
# Job-ID am absorb freigeben, sonst Unique beim UPDATE auf keep.
|
||
conn.execute(
|
||
"UPDATE runs SET moonraker_job_id = %s WHERE id = %s",
|
||
("live:merged:%s" % absorb["id"], absorb["id"]),
|
||
)
|
||
absorb_terminal = (absorb.get("state") or "") in TERMINAL
|
||
keep_open = (keep.get("state") or "") in ("in_progress", "printing", "paused")
|
||
new_state = absorb["state"] if absorb_terminal and not keep_open else (
|
||
absorb["state"] if not keep_open else keep["state"]
|
||
)
|
||
if keep_open or (absorb.get("state") or "") in ("in_progress", "printing", "paused"):
|
||
new_state = absorb["state"] if (absorb.get("state") or "") in ("in_progress", "printing", "paused") else "in_progress"
|
||
ended = None
|
||
else:
|
||
ended = absorb.get("ended_at") or keep.get("ended_at")
|
||
conn.execute(
|
||
"""
|
||
UPDATE runs SET
|
||
moonraker_job_id = COALESCE(NULLIF(%s, ''), moonraker_job_id),
|
||
state = %s,
|
||
ended_at = %s,
|
||
duration_s = GREATEST(COALESCE(duration_s, 0), COALESCE(%s, 0)),
|
||
weight_used_g = GREATEST(COALESCE(weight_used_g, 0), COALESCE(%s, 0)),
|
||
filament_used_mm = GREATEST(COALESCE(filament_used_mm, 0), COALESCE(%s, 0)),
|
||
progress_last = GREATEST(COALESCE(progress_last, 0), COALESCE(%s, 0)),
|
||
deducted = false
|
||
WHERE id = %s
|
||
""",
|
||
(
|
||
str(job_id or ""),
|
||
new_state,
|
||
ended,
|
||
float(absorb.get("duration_s") or 0),
|
||
float(absorb.get("weight_used_g") or 0),
|
||
float(absorb.get("filament_used_mm") or 0),
|
||
float(absorb.get("progress_last") or 0),
|
||
keep["id"],
|
||
),
|
||
)
|
||
try:
|
||
conn.execute(
|
||
"UPDATE print_queue SET run_id = %s WHERE run_id = %s",
|
||
(keep["id"], absorb["id"]),
|
||
)
|
||
except Exception:
|
||
pass
|
||
conn.execute("DELETE FROM runs WHERE id = %s", (absorb["id"],))
|
||
store.set_printer_active_run(
|
||
conn, host, keep["id"] if new_state in ("in_progress", "printing", "paused") else None
|
||
)
|
||
fresh = store.get_run_row(conn, keep["id"])
|
||
return {"run": store.run_out(fresh), "undo": snapshot}
|
||
|
||
|
||
def choose_spool(conn, host, material, hinted_id, ended_at=None):
|
||
"""Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten."""
|
||
at_host = [
|
||
row for row in store.spools_for_printer(conn, host)
|
||
if _spool_valid_for_end(row, ended_at)
|
||
]
|
||
if hinted_id:
|
||
row = store.get_spool_row(conn, hinted_id)
|
||
if row and not _spool_valid_for_end(row, ended_at):
|
||
return None
|
||
# Hint nur nutzen, wenn die Rolle noch am Drucker liegt — sonst Rollenwechsel
|
||
if row and (row.get("printer_host") or "") == (host or ""):
|
||
return row
|
||
if row and not at_host:
|
||
# Rolle schon im Regal, kein Nachfolger am Host → Hint behalten (alte Jobs)
|
||
return row
|
||
if len(at_host) == 1:
|
||
return at_host[0]
|
||
if material:
|
||
same = [row for row in at_host if (row["material_type"] or "").lower() == material.lower()]
|
||
if len(same) == 1:
|
||
return same[0]
|
||
if hinted_id:
|
||
row = store.get_spool_row(conn, hinted_id)
|
||
if row and _spool_valid_for_end(row, ended_at):
|
||
return row
|
||
return None
|
||
|
||
|
||
def resolve_tracking_spool(conn, host, hinted_id, ace_map=None):
|
||
"""Spule für Live-Tracking: am Host liegende Rolle schlägt veralteten Send-Hint."""
|
||
current = resolve_spools_at_host(conn, host, ace_map=ace_map)
|
||
if len(current) == 1:
|
||
return current[0]["id"]
|
||
if hinted_id:
|
||
row = store.get_spool_row(conn, hinted_id)
|
||
if row and (row.get("printer_host") or "") == (host or ""):
|
||
return hinted_id
|
||
if current:
|
||
return current[0]["id"]
|
||
return hinted_id
|
||
|
||
|
||
def _spool_valid_for_end(spool, ended_at):
|
||
"""Verhindert Abzug alter Moonraker-Jobs auf neu eingelegte Spulen."""
|
||
if ended_at is None or not spool:
|
||
return True
|
||
created = spool.get("created_at")
|
||
if created is None:
|
||
return True
|
||
end = ended_at
|
||
if getattr(created, "tzinfo", None) is None and getattr(end, "tzinfo", None) is not None:
|
||
created = created.replace(tzinfo=timezone.utc)
|
||
if getattr(end, "tzinfo", None) is None and getattr(created, "tzinfo", None) is not None:
|
||
end = end.replace(tzinfo=timezone.utc)
|
||
try:
|
||
return created <= end
|
||
except TypeError:
|
||
return True
|
||
|
||
|
||
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 = store.spool_at_slot(conn, host, slot)
|
||
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):
|
||
"""Falsche Sorte bricht den Start nicht ab. Die Oberfläche warnt vorher."""
|
||
return
|
||
|
||
|
||
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, metadata=None):
|
||
if run["deducted"] or run["state"] not in TERMINAL:
|
||
return False
|
||
planned = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
|
||
used = float(run.get("weight_used_g") or 0)
|
||
source = run.get("weight_source") or ""
|
||
if used <= 0 and planned > 0 and source != "none":
|
||
used, source = usage.resolve_used_grams(
|
||
planned,
|
||
run.get("filament_used_mm") or 0,
|
||
run.get("filament_total_mm") or 0,
|
||
run.get("progress_last") or 0,
|
||
run["state"],
|
||
run.get("duration_s") or 0,
|
||
)
|
||
if used <= 0:
|
||
return False
|
||
|
||
sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
|
||
ace = _ace_map_of(sent)
|
||
meta = metadata or {}
|
||
if not meta and run.get("file_id"):
|
||
file_row = store.get_file_row(conn, run["file_id"]) if hasattr(store, "get_file_row") else None
|
||
if file_row:
|
||
meta = {"filament_weight_total": file_row.get("weight_g") or planned}
|
||
|
||
store.close_open_segments(conn, run["id"], None, run.get("filament_used_mm") or 0)
|
||
segments = store.list_run_segments(conn, run["id"])
|
||
ended = run.get("ended_at")
|
||
|
||
entries = []
|
||
targets = _targets_from_ace(conn, run["printer_host"], ace, ended_at=ended) if ace else []
|
||
if not targets and run.get("spool_id"):
|
||
spool = store.get_spool_row(conn, run["spool_id"])
|
||
if spool and _spool_valid_for_end(spool, ended):
|
||
targets = [spool]
|
||
# Abschnitte ohne ACE-Map: externe Rollen nacheinander
|
||
if segments and not ace:
|
||
final_mm = float(run.get("filament_used_mm") or 0)
|
||
total_mm = float(run.get("filament_total_mm") or 0)
|
||
deltas = usage.segment_deltas(segments, final_mm)
|
||
# Messwert (oder Soll) anteilig nach mm — nicht Soll gegen Soll-Gesamtlänge,
|
||
# sonst fehlen Gramm, wenn ein Abschnitt zu früh geschlossen wurde.
|
||
base_g = used if used > 0 else planned
|
||
split_mm = sum(d for _, _, d in deltas) or final_mm or total_mm
|
||
for spool_id, ace_slot, delta_mm in deltas:
|
||
spool = store.get_spool_row(conn, spool_id)
|
||
if not spool:
|
||
continue
|
||
grams = usage.grams_from_mm(delta_mm, split_mm, base_g)
|
||
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||
entries.append({
|
||
"spool_id": spool_id,
|
||
"ace_slot": ace_slot,
|
||
"weight_g": grams,
|
||
"weight_planned_g": planned / max(1, len(deltas)),
|
||
"cost": cost,
|
||
"is_estimate": source != "measured",
|
||
})
|
||
elif targets:
|
||
shares = usage.planned_shares(meta, ace, len(targets))
|
||
parts, is_estimate = usage.split_used(used, shares, multi=len(targets) > 1)
|
||
if source != "measured":
|
||
is_estimate = True
|
||
# ACE-Rollenwechsel: Anteil der gewechselten Farbe nach mm aufteilen
|
||
by_slot = {}
|
||
if segments and ace:
|
||
final_mm = float(run.get("filament_used_mm") or 0)
|
||
for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
|
||
by_slot.setdefault(ace_slot, []).append((spool_id, delta_mm))
|
||
for index, spool in enumerate(targets):
|
||
plan = shares[index] if index < len(shares) else 0
|
||
grams = parts[index] if index < len(parts) else 0
|
||
slot = spool.get("ace_slot")
|
||
slot_segs = by_slot.get(slot) or []
|
||
if len(slot_segs) > 1:
|
||
mm_sum = sum(d for _, d in slot_segs) or 1.0
|
||
for seg_spool_id, delta_mm in slot_segs:
|
||
seg_spool = store.get_spool_row(conn, seg_spool_id)
|
||
if not seg_spool:
|
||
continue
|
||
share_g = grams * (delta_mm / mm_sum)
|
||
cost = (share_g / 1000.0) * float(seg_spool.get("price_per_kg") or 0)
|
||
entries.append({
|
||
"spool_id": seg_spool_id,
|
||
"ace_slot": slot,
|
||
"weight_g": share_g,
|
||
"weight_planned_g": plan / len(slot_segs),
|
||
"cost": cost,
|
||
"is_estimate": True,
|
||
})
|
||
else:
|
||
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||
entries.append({
|
||
"spool_id": spool["id"],
|
||
"ace_slot": slot,
|
||
"weight_g": grams,
|
||
"weight_planned_g": plan,
|
||
"cost": cost,
|
||
"is_estimate": is_estimate,
|
||
})
|
||
elif segments:
|
||
# Fallback: Abschnitte ohne aktuelle Targets
|
||
final_mm = float(run.get("filament_used_mm") or 0)
|
||
total_mm = float(run.get("filament_total_mm") or 0)
|
||
for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
|
||
spool = store.get_spool_row(conn, spool_id)
|
||
if not spool:
|
||
continue
|
||
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
|
||
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||
entries.append({
|
||
"spool_id": spool_id,
|
||
"ace_slot": ace_slot,
|
||
"weight_g": grams,
|
||
"weight_planned_g": 0,
|
||
"cost": cost,
|
||
"is_estimate": source != "measured",
|
||
})
|
||
else:
|
||
return False
|
||
|
||
if not entries:
|
||
return False
|
||
used_total = sum(item["weight_g"] for item in entries)
|
||
filament = sum(item["cost"] for item in entries)
|
||
samples = store.list_run_samples(conn, run["id"])
|
||
power = energy_cost(run["duration_s"], energy, samples)
|
||
weight_source = source or (
|
||
"estimate" if any(e["is_estimate"] for e in entries) else "measured"
|
||
)
|
||
# Zuerst deducted=true — nur der Gewinner schreibt Akte und zieht ab.
|
||
marked = store.mark_deducted(
|
||
conn,
|
||
run["id"],
|
||
entries[0]["spool_id"],
|
||
filament,
|
||
power,
|
||
weight_used_g=used_total,
|
||
weight_source=weight_source,
|
||
)
|
||
if not marked:
|
||
return False
|
||
store.replace_run_spools(conn, run["id"], entries)
|
||
for item in entries:
|
||
store.deduct_spool(conn, item["spool_id"], item["weight_g"])
|
||
store.ensure_run_chargen(conn, run["id"], used_total)
|
||
objects = run.get("objects")
|
||
if isinstance(objects, str):
|
||
try:
|
||
objects = json.loads(objects)
|
||
except json.JSONDecodeError:
|
||
objects = []
|
||
store.ensure_run_labels(conn, run["id"], objects or [], qty=1)
|
||
return True
|
||
|
||
|
||
def _sync_one_job(conn, cfg, printer, job, watcher_resume=False):
|
||
"""Einen History-Job verarbeiten. Gibt 1 zurück wenn abgezogen wurde."""
|
||
job_id = str(job.get("job_id") or "")
|
||
if not job_id or str(job_id).startswith("live:"):
|
||
return 0
|
||
filename = job.get("filename") or ""
|
||
start_dt = unix_to_dt(job.get("start_time"))
|
||
end_dt = unix_to_dt(job.get("end_time"))
|
||
duration_s = float(job.get("print_duration") or job.get("total_duration") or 0)
|
||
state = job.get("status") or ""
|
||
# Leere Hülle: nie speichern. Sobald Dauer da ist, gilt die Beweis-Regel.
|
||
if history_shell_is_empty({
|
||
"duration_s": duration_s,
|
||
"ended_at": end_dt,
|
||
"print_duration": duration_s,
|
||
"end_time": end_dt,
|
||
}) and state not in TERMINAL:
|
||
return 0
|
||
if store.history_job_already_folded(conn, printer["host"], filename, job_id, end_dt):
|
||
return 0
|
||
if store.history_job_retired(conn, printer["host"], job_id):
|
||
return 0
|
||
fold_filament_resume(
|
||
conn,
|
||
printer["host"],
|
||
filename,
|
||
job_id,
|
||
start_dt,
|
||
print_duration_s=duration_s,
|
||
watcher_resume=watcher_resume,
|
||
)
|
||
metadata = job.get("metadata") or {}
|
||
planned = weight_of(metadata)
|
||
material = material_of(metadata)
|
||
used_mm = usage.filament_used_mm(job)
|
||
total_mm = usage.filament_total_mm(metadata, job)
|
||
estimated = float(metadata.get("estimated_time") or 0)
|
||
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")
|
||
ended_at = end_dt
|
||
if not sent and not hinted:
|
||
spool = None
|
||
else:
|
||
spool = choose_spool(conn, printer["host"], material, hinted, ended_at=ended_at)
|
||
terminal = state in TERMINAL
|
||
live = store.find_live_run(conn, printer["host"], filename)
|
||
progress = float((live or {}).get("progress_last") or 0)
|
||
if used_mm <= 0 and live:
|
||
used_mm = float(live.get("filament_used_mm") or 0)
|
||
if total_mm <= 0 and live:
|
||
total_mm = float(live.get("filament_total_mm") or 0)
|
||
if planned <= 0 and live:
|
||
planned = float(live.get("weight_planned_g") or 0)
|
||
objects = (live or {}).get("objects") or []
|
||
if isinstance(objects, str):
|
||
try:
|
||
objects = json.loads(objects)
|
||
except json.JSONDecodeError:
|
||
objects = []
|
||
used_g, source = usage.resolve_used_grams(
|
||
planned, used_mm, total_mm, progress, state, duration_s
|
||
)
|
||
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": start_dt,
|
||
"ended_at": ended_at,
|
||
"duration_s": duration_s,
|
||
"weight_g": planned,
|
||
"weight_planned_g": planned,
|
||
"weight_used_g": used_g,
|
||
"weight_source": source,
|
||
"filament_used_mm": used_mm,
|
||
"filament_total_mm": total_mm,
|
||
"progress_last": progress,
|
||
"estimated_s": estimated,
|
||
"energy_cost": energy_cost(duration_s, cfg["energy"]),
|
||
"moonraker_job_id": job_id,
|
||
"needs_spool": bool(terminal and used_g > 0 and not spool),
|
||
"material_type": material,
|
||
"message": (job.get("message") or "") if state == "error" else "",
|
||
"objects": objects,
|
||
})
|
||
store.adopt_live_run(conn, printer["host"], filename, row["id"])
|
||
row = store.get_run_row(conn, row["id"])
|
||
if not terminal:
|
||
store.set_printer_active_run(conn, printer["host"], row["id"])
|
||
else:
|
||
active = store.find_live_run(conn, printer["host"])
|
||
if not active:
|
||
ended = row.get("ended_at") or ended_at
|
||
store.ensure_bed_after_print(
|
||
conn, printer["host"], ended_at=ended, message=filename or state
|
||
)
|
||
store.set_printer_active_run(conn, printer["host"], None)
|
||
# Resume nach Filamentpause: History kann den alten Teil schon als „fertig“
|
||
# melden. Abzug und Segment-Schluss erst beim echten Druckende.
|
||
if watcher_resume:
|
||
return 0
|
||
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
|
||
return 1
|
||
return 0
|
||
|
||
|
||
def deduct_stale_live_runs(conn, cfg, older_than_s=LIVE_DEDUCT_FALLBACK_S):
|
||
"""Live-Läufe ohne History nach Frist abziehen (Fertig-Pfad ohne History)."""
|
||
rows = store.list_stale_live_runs(conn, older_than_s=older_than_s)
|
||
n = 0
|
||
for row in rows:
|
||
if apply_deduction(conn, row, cfg["energy"]):
|
||
n += 1
|
||
return n
|
||
|
||
|
||
def sync_history(conn, cfg, host=None, watcher_resume=False):
|
||
"""History abgleichen. Serialisiert über Session-Advisory-Lock."""
|
||
printers = store.list_printers(conn)
|
||
if host:
|
||
printers = [p for p in printers if p.get("host") == host]
|
||
|
||
def fetch(printer):
|
||
return printer, moonraker.history(printer["host"])
|
||
|
||
with ThreadPoolExecutor(max_workers=max(1, len(printers) or 1)) as pool:
|
||
fetched = list(pool.map(fetch, printers)) if printers else []
|
||
collected = []
|
||
for printer, items in fetched:
|
||
for job in history_in_order(items):
|
||
collected.append((printer, job))
|
||
deducted = 0
|
||
conn.execute("SELECT pg_advisory_lock(%s)", (SYNC_LOCK_KEY,))
|
||
try:
|
||
for printer, job in collected:
|
||
deducted += _sync_one_job(
|
||
conn, cfg, printer, job, watcher_resume=watcher_resume
|
||
)
|
||
if not watcher_resume:
|
||
deducted += deduct_stale_live_runs(conn, cfg)
|
||
finally:
|
||
conn.execute("SELECT pg_advisory_unlock(%s)", (SYNC_LOCK_KEY,))
|
||
return {"gelesen": len(collected), "abgezogen": deducted}
|
||
|
||
|
||
def assert_filament_enough(conn, file_row, spool_id=None, ace_map=None, host=""):
|
||
"""Zu wenig Rest bricht den Start nicht ab. Die Oberfläche warnt vorher."""
|
||
return
|
||
|
||
|
||
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, OSError, ValueError) 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, conn=conn, data_dir=(cfg or {}).get("data_dir")
|
||
)
|
||
# CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name)
|
||
filename = moonraker.safe_upload_name(filename)
|
||
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
|
||
if slots and mapping:
|
||
# Immer an G-code-Tn ausrichten (auch wenn UI fälschlich 0/1 schickt)
|
||
content, mapping, filaments, remap = acm.align_tools_for_ace(
|
||
content, mapping, filaments
|
||
)
|
||
if remap:
|
||
progress(
|
||
"ace",
|
||
32,
|
||
"Slicer-T verdichtet (%s)…"
|
||
% ", ".join("T%s→T%s" % pair for pair in sorted(remap.items())),
|
||
)
|
||
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||
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)
|
||
else:
|
||
if slots and mapping:
|
||
_, mapping, filaments, _ = acm.align_tools_for_ace(
|
||
b"", mapping, filaments
|
||
)
|
||
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||
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)
|
||
params = dict(params) if isinstance(params, dict) else {}
|
||
if action == "slot_setzen":
|
||
try:
|
||
ace_slot = int(params.get("ace_slot"))
|
||
except (TypeError, ValueError) as exc:
|
||
raise GrottiError("ACE-Platz muss 1–8 sein.", "platz") from exc
|
||
spool = None
|
||
for row in store.spools_for_printer(conn, host):
|
||
raw_slot = row.get("ace_slot")
|
||
if raw_slot in (None, ""):
|
||
continue
|
||
try:
|
||
if int(raw_slot) == ace_slot:
|
||
spool = row
|
||
break
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if not spool:
|
||
raise GrottiError("Auf diesem Platz liegt keine Spule in Grotti.", "spule")
|
||
material = catalog.ace_material_type(spool.get("material_type"))
|
||
if not material:
|
||
raise GrottiError("Die Spule braucht eine Sorte.", "material")
|
||
color = moonraker.spool_rgb(spool)
|
||
if not color:
|
||
raise GrottiError("Die Spule braucht eine Farbe.", "farbe")
|
||
hub_id, index = moonraker.ace_hub_index(ace_slot)
|
||
params = {"id": hub_id, "index": index, "type": material, "color": color}
|
||
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)
|