Files
grotti/grotti/jobs.py
T

1159 lines
42 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.
"""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)