Grotti 2.5.1: Druckbericht, Statistik, Spulenakte und Protokoll-Fixes.

This commit is contained in:
Dominic Gundel committed 2026-10-17 15:32:51 +02:00
1 parent b277ca48aa
commit efd0801cd1
30 files changed
+3393 -178

No files matched your search

+244 -52
View File
@@ -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:
+27 -5
View File
@@ -142,8 +142,30 @@ def ensure_library_gcode(conn, cfg, host, filename):
return save_upload(conn, name, content, cfg)
def printer_file(row):
path = Path(row["stored_path"])
def resolve_stored_path(conn, row, data_dir=None):
"""Gespeicherter Pfad, ggf. nach Umzug von data_dir unter eingang/<name> wiederfinden."""
stored = (row or {}).get("stored_path") or ""
path = Path(stored) if stored else None
if path and path.is_file():
return path
name = (row or {}).get("name") or (path.name if path else "")
if not name or data_dir is None:
return None
candidate = Path(data_dir) / "eingang" / name
if not candidate.is_file():
return None
resolved = str(candidate.resolve())
file_id = (row or {}).get("id")
if file_id and conn is not None:
conn.execute("UPDATE files SET stored_path = %s WHERE id = %s", (resolved, file_id))
row["stored_path"] = resolved
return candidate.resolve()
def printer_file(row, conn=None, data_dir=None):
path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
if path is None:
path = Path(row.get("stored_path") or "")
if not path.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
if row["kind"] == "gcode":
@@ -153,9 +175,9 @@ def printer_file(row):
return path.name, path.read_bytes(), row["plate"] or ""
def preview_png(row):
path = Path(row["stored_path"])
if not path.is_file():
def preview_png(row, conn=None, data_dir=None):
path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
if path is None or not path.is_file():
return None
entry = row["thumb_entry"]
if row["kind"] == "gcode":
+159 -1
View File
@@ -418,6 +418,64 @@ def steuern(host, action, params=None):
if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name):
raise PrinterError("Ungültiger Objektname.")
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20)
if action == "dryer_start":
try:
unit = int(params.get("id"))
except (TypeError, ValueError) as exc:
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
if unit not in (0, 1):
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
try:
temp = int(round(float(params.get("temp"))))
duration = int(round(float(params.get("duration"))))
except (TypeError, ValueError) as exc:
raise PrinterError("Temperatur und Dauer müssen Zahlen sein.") from exc
if temp < 35 or temp > 65:
raise PrinterError("Trockner-Temperatur außerhalb 35–65 °C.")
if duration < 1 or duration > 720:
raise PrinterError("Trockner-Dauer außerhalb 1–720 Minuten.")
return post_json(
host,
"/server/filament_hub/start_drying",
{"id": unit, "temp": temp, "duration": duration, "fan_speed": 0},
timeout=15,
)
if action == "dryer_stop":
try:
unit = int(params.get("id"))
except (TypeError, ValueError) as exc:
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
if unit not in (0, 1):
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
return post_json(host, "/server/filament_hub/stop_drying", {"id": unit}, timeout=15)
if action == "slot_setzen":
hub_id = _int_or_none(params.get("id"))
index = _int_or_none(params.get("index"))
if hub_id not in (0, 1) or index is None or index < 0 or index > 3:
raise PrinterError("ACE-Platz ungültig.")
material = str(params.get("type") or "").strip()
if not material:
raise PrinterError("Die Spule braucht eine Sorte.")
color = params.get("color")
if not isinstance(color, dict):
raise PrinterError("Die Spule braucht eine Farbe.")
try:
red = max(0, min(255, int(color.get("R"))))
green = max(0, min(255, int(color.get("G"))))
blue = max(0, min(255, int(color.get("B"))))
except (TypeError, ValueError) as exc:
raise PrinterError("Die Spule braucht eine Farbe.") from exc
return post_json(
host,
"/printer/filament_hub/set_filament_info",
{
"id": hub_id,
"index": index,
"type": material,
"color": {"R": red, "G": green, "B": blue},
},
timeout=15,
)
if action == "firmware_restart":
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
raise PrinterError(
@@ -600,6 +658,80 @@ def extract_slots(status):
return found[:8]
def _int_or_none(value):
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return int(value)
def extract_dryers(status):
"""Echte ACE-Boxen aus mmu_machine (num_gates > 0), Dummy-Unit bleibt weg."""
machine = (status or {}).get("mmu_machine")
if not isinstance(machine, dict):
return []
found = []
for unit_id in (0, 1):
unit = machine.get("unit_%d" % unit_id)
if not isinstance(unit, dict):
continue
gates = _int_or_none(unit.get("num_gates"))
if gates is None or gates < 1:
continue
found.append({
"id": unit_id,
"status": str(unit.get("dryer_status") or "stop"),
"temp": _int_or_none(unit.get("dryer_temp")) or 0,
"target": _int_or_none(unit.get("dryer_target_temp")) or 0,
"remain": _int_or_none(unit.get("dryer_remaining")) or 0,
"humidity": _int_or_none(unit.get("dryer_humidity")) or 0,
"gates": gates,
})
return found
def ace_hub_index(ace_slot):
"""Grotti-Platz 1–8 → Moonraker id (0/1) und index in der Box (0–3)."""
try:
slot = int(ace_slot)
except (TypeError, ValueError) as exc:
raise PrinterError("ACE-Platz muss 1–8 sein.") from exc
if slot < 1 or slot > 8:
raise PrinterError("ACE-Platz muss 1–8 sein.")
return (slot - 1) // 4, (slot - 1) % 4
def spool_rgb(spool):
"""Erste Farbe der Spule als RGB-Dict für set_filament_info."""
colors = (spool or {}).get("colors") or []
if isinstance(colors, str):
try:
colors = json.loads(colors)
except (json.JSONDecodeError, TypeError):
colors = []
hex_value = ""
if isinstance(colors, list):
for item in colors:
if isinstance(item, dict) and item.get("hex"):
hex_value = str(item["hex"]).strip()
break
if isinstance(item, str) and item.strip():
hex_value = item.strip()
break
if not hex_value:
hex_value = str((spool or {}).get("color") or "").strip()
if hex_value.startswith("#"):
hex_value = hex_value[1:]
if len(hex_value) == 8:
hex_value = hex_value[:6]
if len(hex_value) != 6 or any(ch not in "0123456789abcdefABCDEF" for ch in hex_value):
return None
return {
"R": int(hex_value[0:2], 16),
"G": int(hex_value[2:4], 16),
"B": int(hex_value[4:6], 16),
}
def slots(host, status=None):
if status is not None:
found = extract_slots(status)
@@ -868,6 +1000,8 @@ def query(host):
extras = []
if "probe" in names:
extras.append("probe")
if "mmu_machine" in names:
extras.append("mmu_machine")
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
payload = fetch_json(url)
status = payload["result"]["status"]
@@ -875,6 +1009,21 @@ def query(host):
card = status.get("virtual_sdcard") or {}
extruder = status.get("extruder") or {}
bed = status.get("heater_bed") or {}
mmu = status.get("mmu") or {}
mmu_tool = mmu.get("tool")
if not isinstance(mmu_tool, (int, float)) or isinstance(mmu_tool, bool):
mmu_tool = mmu.get("gate")
if isinstance(mmu_tool, (int, float)) and not isinstance(mmu_tool, bool):
mmu_tool = int(mmu_tool)
if mmu_tool < 0:
mmu_tool = None
else:
mmu_tool = None
filament_used = stats.get("filament_used")
if not isinstance(filament_used, (int, float)) or isinstance(filament_used, bool):
filament_used = None
else:
filament_used = float(filament_used)
raw_name = stats.get("filename") or ""
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
meta = metadata(host, raw_name) if raw_name else {}
@@ -933,8 +1082,15 @@ def query(host):
"bed_target": bed.get("target"),
"chamber": chamber,
"chamber_target": chamber_target,
"filament_used": filament_used,
"filament_total": meta.get("filament_total") or 0,
"mmu_tool": mmu_tool,
"material": card.get("filament_type") or meta.get("filament_type") or "",
"weight": meta.get("filament_weight_total") or 0,
"weight": (
sum(float(x or 0) for x in meta["filament_weight_total"])
if isinstance(meta.get("filament_weight_total"), list)
else (meta.get("filament_weight_total") or 0)
),
"height": meta.get("object_height") or 0,
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
"estimated": meta.get("estimated_time") or 0,
@@ -952,6 +1108,7 @@ def query(host):
"speed_factor": speed_factor,
"extrude_factor": extrude_factor,
"slots": slots(host, status),
"dryers": extract_dryers(status),
"exclude": exclude_status(host),
"anycubic_task": _anycubic_task_field(host),
}
@@ -993,6 +1150,7 @@ def snapshot(printers):
"speed_factor": None,
"extrude_factor": None,
"slots": [],
"dryers": [],
"anycubic_task": None,
})
card.update(mesh_fields(printer["host"]))
+2 -2
View File
@@ -43,11 +43,11 @@ def alert_enabled(cfg, kind):
return bool(alerts.get(key, key not in DEFAULT_OFF))
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60):
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60, run_id=None):
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
from grotti import store
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
store.add_event(conn, host, printer, kind, message, filename or "")
store.add_event(conn, host, printer, kind, message, filename or "", run_id=run_id)
if not recent and alert_enabled(cfg, kind):
send_telegram(cfg, message)
+83
View File
@@ -215,3 +215,86 @@ CREATE TABLE IF NOT EXISTS oauth_identities (
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, subject)
);
-- Druckbericht: Soll/Ist, Charge, Live-Fortschritt
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_planned_g double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_used_g double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_source text NOT NULL DEFAULT '';
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_used_mm double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_total_mm double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS progress_last double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS estimated_s double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS chargen_nr text;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS message text NOT NULL DEFAULT '';
ALTER TABLE runs ADD COLUMN IF NOT EXISTS objects jsonb NOT NULL DEFAULT '[]'::jsonb;
CREATE UNIQUE INDEX IF NOT EXISTS runs_chargen_nr
ON runs (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
ALTER TABLE events ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS events_run ON events (run_id) WHERE run_id IS NOT NULL;
-- Abzug je Spule (Messwert oder Schätzwert)
CREATE TABLE IF NOT EXISTS run_spools (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
ace_slot smallint,
weight_g double precision NOT NULL DEFAULT 0,
weight_planned_g double precision NOT NULL DEFAULT 0,
cost double precision NOT NULL DEFAULT 0,
is_estimate boolean NOT NULL DEFAULT false,
UNIQUE (run_id, spool_id, ace_slot)
);
CREATE INDEX IF NOT EXISTS run_spools_spool ON run_spools (spool_id);
CREATE INDEX IF NOT EXISTS run_spools_run ON run_spools (run_id);
-- Abschnitte bei Rollenwechsel mitten im Druck
CREATE TABLE IF NOT EXISTS run_segments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
ace_slot smallint,
mm_start double precision NOT NULL DEFAULT 0,
mm_end double precision,
created_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz
);
CREATE INDEX IF NOT EXISTS run_segments_run ON run_segments (run_id);
CREATE INDEX IF NOT EXISTS run_segments_open
ON run_segments (run_id) WHERE mm_end IS NULL;
-- Temperatur / Fortschritt während des Drucks
CREATE TABLE IF NOT EXISTS run_samples (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
sampled_at timestamptz NOT NULL DEFAULT now(),
nozzle double precision,
nozzle_target double precision,
bed double precision,
bed_target double precision,
chamber double precision,
fan double precision,
speed_factor double precision,
extrude_factor double precision,
layer integer,
progress double precision,
filament_used_mm double precision,
mmu_tool smallint
);
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
-- Objekt-Chargen für Etiketten
CREATE TABLE IF NOT EXISTS run_labels (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
object_name text NOT NULL DEFAULT '',
chargen_nr text NOT NULL,
excluded boolean NOT NULL DEFAULT false,
UNIQUE (run_id, object_name)
);
CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr);
+1077 -14
View File
File diff suppressed because it is too large. Load diff
+164
View File
@@ -0,0 +1,164 @@
"""Ist-Gramm aus Moonraker-Filamentzählern und Spulenanteilen."""
from __future__ import annotations
def as_float(value, default=0.0):
if value is None or value is False:
return float(default)
if isinstance(value, bool):
return float(default)
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.replace(",", ".").strip())
except ValueError:
return float(default)
if isinstance(value, list):
return sum(as_float(item) for item in value)
return float(default)
def weight_list(metadata):
"""Gramm je Farbe aus filament_weight_total (Zahl oder Liste)."""
raw = (metadata or {}).get("filament_weight_total")
if isinstance(raw, list):
return [as_float(item) for item in raw]
total = as_float(raw)
return [total] if total > 0 else []
def weight_sum(metadata):
return sum(weight_list(metadata))
def filament_total_mm(metadata, job=None):
raw = (metadata or {}).get("filament_total")
if raw in (None, "", [], 0, 0.0) and isinstance(job, dict):
raw = job.get("filament_total")
if isinstance(raw, list):
return sum(as_float(item) for item in raw)
return as_float(raw)
def filament_used_mm(job):
if not isinstance(job, dict):
return 0.0
return as_float(job.get("filament_used"))
def planned_shares(metadata, ace_map, target_count):
"""Geplante Gramm je Ziel-Spule. Index entspricht targets-Reihenfolge."""
weights = weight_list(metadata)
mapping = ace_map if isinstance(ace_map, list) else []
if weights and mapping and len(weights) >= len(mapping) and target_count == len(mapping):
shares = []
for item in mapping:
paint = item.get("paint_index")
try:
index = int(paint)
except (TypeError, ValueError):
index = len(shares)
if 0 <= index < len(weights):
shares.append(weights[index])
else:
shares.append(0.0)
if sum(shares) > 0:
return shares
if weights and target_count == len(weights):
return list(weights)
total = sum(weights) if weights else 0.0
if target_count <= 0:
return []
if total <= 0:
return [0.0] * target_count
each = total / target_count
return [each] * target_count
def resolve_used_grams(planned_g, used_mm, total_mm, progress, state, duration_s):
"""
Ist-Gramm und Herkunft.
measured = filament_used, estimate = Fortschritt oder Soll, none = nichts.
"""
planned = max(0.0, as_float(planned_g))
used = max(0.0, as_float(used_mm))
total = max(0.0, as_float(total_mm))
prog = max(0.0, min(1.0, as_float(progress)))
duration = max(0.0, as_float(duration_s))
terminal_abort = state in ("cancelled", "error", "interrupted")
if used > 0.5 and total > 0:
grams = planned * (used / total) if planned > 0 else 0.0
if grams <= 0 and used > 0.5:
# Länge ohne Gewicht: grob 1.24 g/cm³ · 1.75 mm → ~3 g/m
grams = used * 0.003
return max(0.0, grams), "measured"
if used > 0.5 and planned > 0 and total <= 0:
# Millimeter da, Gesamtlänge fehlt — Fortschritt oder Dauer helfen nicht
return used * 0.003, "estimate"
if terminal_abort and used <= 0.5:
if prog > 0.005 and planned > 0:
return planned * prog, "estimate"
return 0.0, "none"
if planned > 0:
if prog > 0.005 and prog < 0.995 and state != "completed":
return planned * prog, "estimate"
return planned, "estimate" if state == "completed" else "none"
return 0.0, "none"
def split_used(used_g, planned_shares_list, multi):
"""
Ist-Gramm auf Spulen verteilen.
multi=True → Anteile sind Schätzwerte, Summe bleibt used_g.
"""
used = max(0.0, as_float(used_g))
shares = [max(0.0, as_float(s)) for s in (planned_shares_list or [])]
if not shares:
return [], False
total_plan = sum(shares)
if total_plan <= 0:
each = used / len(shares)
parts = [each] * len(shares)
else:
parts = [used * (share / total_plan) for share in shares]
is_estimate = bool(multi) and len(shares) > 1
return parts, is_estimate
def segment_deltas(segments, final_mm):
"""
Abschnitte mit mm_start/mm_end → Delta-mm je (spool_id, ace_slot).
Offene Abschnitte schließen auf final_mm.
"""
end = max(0.0, as_float(final_mm))
totals = {}
order = []
for seg in segments or []:
start = max(0.0, as_float(seg.get("mm_start")))
raw_end = seg.get("mm_end")
stop = end if raw_end is None else max(0.0, as_float(raw_end))
delta = max(0.0, stop - start)
key = (seg.get("spool_id"), seg.get("ace_slot"))
if key not in totals:
totals[key] = 0.0
order.append(key)
totals[key] += delta
return [(key[0], key[1], totals[key]) for key in order]
def grams_from_mm(delta_mm, total_mm, planned_g):
delta = max(0.0, as_float(delta_mm))
total = max(0.0, as_float(total_mm))
planned = max(0.0, as_float(planned_g))
if delta <= 0:
return 0.0
if total > 0 and planned > 0:
return planned * (delta / total)
return delta * 0.003
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "2.2.6"
__version__ = "2.5.1"
+152 -20
View File
@@ -4,10 +4,13 @@ from __future__ import annotations
import threading
import time
from datetime import datetime, timezone
from grotti import moonraker, notify, store
from grotti import moonraker, notify, store, usage
from grotti.db import connect
SAMPLE_EVERY_S = 30
class StateWatcher:
def __init__(self, database_url, cfg, interval=10):
@@ -21,6 +24,7 @@ class StateWatcher:
self._updated_at = 0.0
self._stop = threading.Event()
self._thread = None
self._last_sample = {}
def start(self):
if self._thread and self._thread.is_alive():
@@ -79,6 +83,7 @@ class StateWatcher:
prev = self._prev.get(host) or {}
prev_state = (prev.get("state") or "").lower()
prev_offline = bool(prev.get("offline"))
run_id = None
if offline:
hits = self._offline_hits.get(host, 0) + 1
@@ -89,16 +94,32 @@ class StateWatcher:
return
self._offline_hits[host] = 0
starting = (
bool(prev_state)
and prev_state not in ("printing", "paused")
and state == "printing"
)
if starting:
store.abandon_live_runs(conn, host)
if state in ("printing", "paused"):
run = self._track_print(conn, host, name, filename, live, state)
run_id = run["id"] if run else None
if prev_offline:
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename)
elif prev_state and prev_state not in ("printing", "paused") and state == "printing":
self._emit(conn, host, name, "start", "%s druckt %s" % (name, filename or "einen Auftrag"), filename)
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id)
elif starting:
self._emit(
conn, host, name, "start",
"%s druckt %s" % (name, filename or "einen Auftrag"),
filename, run_id,
)
self._ensure_start_segment(conn, host, filename, live, run_id)
elif prev_state == "printing" and state == "paused":
self._emit(conn, host, name, "start", "%s pausiert." % name, filename)
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
elif prev_state == "paused" and state == "printing":
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename)
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
elif prev_state == "printing" and state == "complete":
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
self._emit(
conn,
host,
@@ -106,8 +127,8 @@ class StateWatcher:
"fertig",
"%s fertig: %s" % (name, filename or "Druck"),
filename,
run_id or (prev.get("run_id")),
)
# Protokoll und Spulenabzug nicht erst nach manuellem Knopf
try:
from grotti import jobs
@@ -115,7 +136,7 @@ class StateWatcher:
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
store.set_bed_needs_clear(conn, host, True, filename or state)
store.ensure_bed_after_print(conn, host, message=filename or state)
self._emit(
conn,
host,
@@ -123,10 +144,17 @@ class StateWatcher:
"abbruch",
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
filename,
run_id or prev.get("run_id"),
)
try:
from grotti import jobs
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif state == "error" and prev_state != "error":
if prev_state == "printing" or prev_state == "paused":
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
store.ensure_bed_after_print(conn, host, message=filename or "Fehler")
msg = (live.get("message") or "").strip()
if not msg or msg.lower() in ("error", "fehler"):
msg = "Ursache unbekannt — in Mainsail prüfen"
@@ -138,18 +166,19 @@ class StateWatcher:
"fehler",
"%s: %s · Heizung aus" % (name, msg),
filename,
run_id or prev.get("run_id"),
)
try:
from grotti import jobs
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif state == "error":
# Schon im Fehler, Heizer laufen noch → aus
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host)
elif state == "complete":
known = conn.execute(
"SELECT 1 FROM bed_status WHERE host = %s", (host,)
).fetchone()
if not known:
# Erster Stand nach Deploy: fertiger Druck ohne Quittierung
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
elif state == "complete" and (not prev_state or prev_offline):
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
spool_warn = bool(prev.get("spool_warn"))
if state == "printing" and not spool_warn:
@@ -160,8 +189,108 @@ class StateWatcher:
"offline": False,
"filename": filename,
"spool_warn": spool_warn if state == "printing" else False,
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") else None),
}
def _track_print(self, conn, host, name, filename, live, state):
sent = store.latest_send(conn, host, filename) if filename else None
spool_id = (sent or {}).get("spool_id")
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
objects = self._merge_objects(run.get("objects"), live.get("exclude"))
planned = usage.as_float(live.get("weight"))
total_mm = usage.as_float(live.get("filament_total"))
used_mm = usage.as_float(live.get("filament_used"))
progress = usage.as_float(live.get("progress"))
estimated = usage.as_float(live.get("estimated"))
store.update_run_progress(conn, run["id"], {
"state": "paused" if state == "paused" else "in_progress",
"progress_last": progress,
"filament_used_mm": used_mm,
"filament_total_mm": total_mm,
"weight_planned_g": planned,
"estimated_s": estimated,
"message": (live.get("message") or "") if state == "error" else "",
"objects": objects,
"duration_s": usage.as_float(live.get("elapsed")),
})
store.ensure_run_chargen(conn, run["id"])
self._maybe_sample(conn, run["id"], live, used_mm)
return store.get_run_row(conn, run["id"])
def _merge_objects(self, current, exclude):
known = {}
if isinstance(current, str):
try:
import json
current = json.loads(current)
except Exception:
current = []
for item in current or []:
if isinstance(item, dict) and item.get("name"):
known[item["name"]] = {
"name": item["name"],
"excluded": bool(item.get("excluded")),
}
exclude = exclude or {}
excluded = set(exclude.get("excluded") or [])
for item in exclude.get("objects") or []:
name = (item.get("name") if isinstance(item, dict) else str(item)).strip()
if not name:
continue
known[name] = {"name": name, "excluded": name in excluded}
for name in excluded:
if name not in known:
known[name] = {"name": name, "excluded": True}
return list(known.values())
def _maybe_sample(self, conn, run_id, live, used_mm):
now = time.monotonic()
last = self._last_sample.get(run_id, 0)
if now - last < SAMPLE_EVERY_S:
return
self._last_sample[run_id] = now
store.add_run_sample(conn, run_id, {
"sampled_at": datetime.now(timezone.utc),
"nozzle": live.get("nozzle"),
"nozzle_target": live.get("nozzle_target"),
"bed": live.get("bed"),
"bed_target": live.get("bed_target"),
"chamber": live.get("chamber"),
"fan": live.get("fan"),
"speed_factor": live.get("speed_factor"),
"extrude_factor": live.get("extrude_factor"),
"layer": live.get("layer") or 0,
"progress": live.get("progress"),
"filament_used_mm": used_mm,
"mmu_tool": live.get("mmu_tool"),
})
def _ensure_start_segment(self, conn, host, filename, live, run_id):
"""Nur externe Einzelspule: ein Abschnitt. ACE-Farben ohne Wechsel → Anteil-Split."""
if not run_id:
return
existing = store.list_run_segments(conn, run_id)
if existing:
return
sent = store.latest_send(conn, host, filename) if filename else None
ace = (sent or {}).get("ace_map") or []
from grotti import acm
mapping = acm.normalize_mapping(ace) if ace else []
if mapping:
return
used_mm = usage.as_float(live.get("filament_used"))
rows = conn.execute(
"""
SELECT id FROM spools
WHERE printer_host = %s AND ace_slot IS NULL
ORDER BY id LIMIT 1
""",
(host,),
).fetchone()
if rows:
store.open_segment(conn, run_id, rows["id"], None, used_mm)
def _cool_heaters(self, host):
try:
moonraker.heaters_off(host)
@@ -193,5 +322,8 @@ class StateWatcher:
)
return True
def _emit(self, conn, host, printer, kind, message, filename):
notify.record(conn, self.cfg, host, printer, kind, message, filename, dedupe_s=60)
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
notify.record(
conn, self.cfg, host, printer, kind, message, filename,
dedupe_s=60, run_id=run_id,
)