Grotti 2.5.1: Druckbericht, Statistik, Spulenakte und Protokoll-Fixes.
This commit is contained in:
1 parent
b277ca48aa
commit
efd0801cd1
30 files changed
+3393
-178
No files matched your search
+244
-52
@@ -5,7 +5,7 @@ import urllib.error
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from grotti import acm, eject, library, moonraker, store
|
||||
from grotti import acm, eject, library, 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
|
||||
@@ -15,17 +15,7 @@ TERMINAL = {"completed", "cancelled", "error", "interrupted"}
|
||||
|
||||
|
||||
def weight_of(metadata):
|
||||
raw = (metadata or {}).get("filament_weight_total")
|
||||
if isinstance(raw, list):
|
||||
return sum(float(item or 0) for item in raw)
|
||||
if isinstance(raw, (int, float)):
|
||||
return float(raw)
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return float(raw.replace(",", "."))
|
||||
except ValueError:
|
||||
return 0.0
|
||||
return 0.0
|
||||
return usage.weight_sum(metadata)
|
||||
|
||||
|
||||
def material_of(metadata):
|
||||
@@ -35,6 +25,37 @@ def material_of(metadata):
|
||||
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):
|
||||
targets = []
|
||||
for item in ace:
|
||||
slot = int(item["ams_index"]) + 1
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM spools
|
||||
WHERE printer_host = %s AND ace_slot = %s
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(host, slot),
|
||||
).fetchone()
|
||||
if row:
|
||||
targets.append(row)
|
||||
return targets
|
||||
|
||||
|
||||
def unix_to_dt(value):
|
||||
if not value:
|
||||
return None
|
||||
@@ -128,50 +149,152 @@ def assert_spool_at_host(conn, spool_id, host):
|
||||
)
|
||||
|
||||
|
||||
def apply_deduction(conn, run, energy):
|
||||
if run["deducted"] or run["weight_g"] <= 0 or run["state"] not in TERMINAL:
|
||||
def apply_deduction(conn, run, energy, metadata=None):
|
||||
if run["deducted"] or run["state"] not in TERMINAL:
|
||||
return False
|
||||
weight = float(run["weight_g"] or 0)
|
||||
# ACE: Abzug anteilig auf die Plätze der letzten Sendung
|
||||
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 = []
|
||||
if sent:
|
||||
raw = sent.get("ace_map")
|
||||
if isinstance(raw, list):
|
||||
ace = raw
|
||||
elif isinstance(raw, str):
|
||||
try:
|
||||
ace = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
ace = []
|
||||
targets = []
|
||||
if ace:
|
||||
for item in acm.normalize_mapping(ace):
|
||||
slot = int(item["ams_index"]) + 1
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM spools
|
||||
WHERE printer_host = %s AND ace_slot = %s
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(run["printer_host"], slot),
|
||||
).fetchone()
|
||||
if row:
|
||||
targets.append(row)
|
||||
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"])
|
||||
|
||||
entries = []
|
||||
targets = _targets_from_ace(conn, run["printer_host"], ace) if ace else []
|
||||
if not targets and run.get("spool_id"):
|
||||
spool = store.get_spool_row(conn, run["spool_id"])
|
||||
if spool:
|
||||
targets = [spool]
|
||||
if not targets:
|
||||
# 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)
|
||||
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, 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": 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
|
||||
share = weight / len(targets)
|
||||
filament = sum((share / 1000) * float(spool["price_per_kg"] or 0) for spool in targets)
|
||||
|
||||
if not entries:
|
||||
return False
|
||||
used_total = sum(item["weight_g"] for item in entries)
|
||||
filament = sum(item["cost"] for item in entries)
|
||||
power = energy_cost(run["duration_s"], energy)
|
||||
marked = store.mark_deducted(conn, run["id"], targets[0]["id"], filament, power)
|
||||
# run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen
|
||||
store.replace_run_spools(conn, run["id"], entries)
|
||||
marked = store.mark_deducted(
|
||||
conn,
|
||||
run["id"],
|
||||
entries[0]["spool_id"],
|
||||
filament,
|
||||
power,
|
||||
weight_used_g=used_total,
|
||||
weight_source=source or ("estimate" if any(e["is_estimate"] for e in entries) else "measured"),
|
||||
)
|
||||
if not marked:
|
||||
store.replace_run_spools(conn, run["id"], [])
|
||||
return False
|
||||
for spool in targets:
|
||||
store.deduct_spool(conn, spool["id"], share)
|
||||
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
|
||||
|
||||
|
||||
@@ -190,13 +313,16 @@ def sync_history(conn, cfg):
|
||||
deducted = 0
|
||||
for printer, job in collected:
|
||||
job_id = str(job.get("job_id") or "")
|
||||
if not job_id:
|
||||
if not job_id or str(job_id).startswith("live:"):
|
||||
continue
|
||||
filename = job.get("filename") or ""
|
||||
metadata = job.get("metadata") or {}
|
||||
weight = weight_of(metadata)
|
||||
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")
|
||||
@@ -204,6 +330,23 @@ def sync_history(conn, cfg):
|
||||
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
|
||||
spool = choose_spool(conn, printer["host"], material, hinted)
|
||||
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"],
|
||||
@@ -215,13 +358,33 @@ def sync_history(conn, cfg):
|
||||
"started_at": unix_to_dt(job.get("start_time")),
|
||||
"ended_at": unix_to_dt(job.get("end_time")),
|
||||
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
|
||||
"weight_g": weight,
|
||||
"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 weight > 0 and not spool),
|
||||
"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,
|
||||
})
|
||||
if apply_deduction(conn, row, cfg["energy"]):
|
||||
# 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
|
||||
return {"gelesen": len(collected), "abgezogen": deducted}
|
||||
|
||||
@@ -373,7 +536,9 @@ def send_file(
|
||||
filaments = [{"type": "PLA", "color": ""}]
|
||||
|
||||
progress("vorbereiten", 30, "Datei vorbereiten…")
|
||||
filename, content, plate = library.printer_file(row)
|
||||
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)
|
||||
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||||
@@ -486,6 +651,33 @@ 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 = (spool.get("material_type") or "").strip()
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user