Grotti 3.6.0: Lauf-Identität nach Filament-Pause, robustere Queue, Projekte/Aufträge list-first und API-Polish.

This commit is contained in:
Dominic Gundel committed 2026-10-25 08:24:24 +01:00
1 parent d1eb87ba25
commit 2b1e36634a
53 files changed
+4254 -636

No files matched your search

+520 -93
View File
@@ -5,7 +5,7 @@ import urllib.error
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from grotti import acm, catalog, eject, library, moonraker, store, usage
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
@@ -39,12 +39,12 @@ def _ace_map_of(sent):
return []
def _targets_from_ace(conn, host, ace):
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:
if row and _spool_valid_for_end(row, ended_at):
targets.append(row)
return targets
@@ -55,6 +55,380 @@ def unix_to_dt(value):
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. Abschnitte bleiben."""
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"],),
)
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 = [
@@ -196,12 +570,13 @@ def apply_deduction(conn, run, energy, metadata=None):
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) if ace else []
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:
if spool and _spool_valid_for_end(spool, ended):
targets = [spool]
# Abschnitte ohne ACE-Map: externe Rollen nacheinander
if segments and not ace:
@@ -291,8 +666,10 @@ def apply_deduction(conn, run, energy, metadata=None):
filament = sum(item["cost"] for item in entries)
samples = store.list_run_samples(conn, run["id"])
power = energy_cost(run["duration_s"], energy, samples)
# run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen
store.replace_run_spools(conn, run["id"], entries)
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"],
@@ -300,11 +677,11 @@ def apply_deduction(conn, run, energy, metadata=None):
filament,
power,
weight_used_g=used_total,
weight_source=source or ("estimate" if any(e["is_estimate"] for e in entries) else "measured"),
weight_source=weight_source,
)
if not marked:
store.replace_run_spools(conn, run["id"], [])
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)
@@ -318,99 +695,149 @@ def apply_deduction(conn, run, energy, metadata=None):
return True
def sync_history(conn, cfg):
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)
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))) as pool:
fetched = list(pool.map(fetch, printers))
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 items:
for job in history_in_order(items):
collected.append((printer, job))
deducted = 0
for printer, job in collected:
job_id = str(job.get("job_id") or "")
if not job_id or str(job_id).startswith("live:"):
continue
filename = job.get("filename") or ""
metadata = job.get("metadata") or {}
planned = weight_of(metadata)
material = material_of(metadata)
state = job.get("status") or ""
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 = unix_to_dt(job.get("end_time"))
# Ohne Send-Eintrag keine Rate — sonst zieht eine neue Spule alle alten Jobs ab.
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, job.get("print_duration") or 0
)
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": 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(job.get("print_duration") or job.get("total_duration") or 0, 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,
})
# Live sofort auf History hängen — auch solange noch in_progress (eine Protokoll-Zeile)
store.adopt_live_run(conn, printer["host"], filename, row["id"])
row = store.get_run_row(conn, row["id"])
if terminal:
# Nachzug nicht während eines neuen Live-Drucks — sonst „Platte belegt“ zu Unrecht
active = store.find_live_run(conn, printer["host"])
if not active:
ended = row.get("ended_at") or unix_to_dt(job.get("end_time"))
store.ensure_bed_after_print(
conn, printer["host"], ended_at=ended, message=filename or state
)
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
deducted += 1
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
)
deducted += deduct_stale_live_runs(conn, cfg)
finally:
conn.execute("SELECT pg_advisory_unlock(%s)", (SYNC_LOCK_KEY,))
return {"gelesen": len(collected), "abgezogen": deducted}