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:
1 parent
d1eb87ba25
commit
2b1e36634a
53 files changed
+4254
-636
No files matched your search
+520
-93
@@ -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}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user