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:
|
||||
|
||||
+27
-5
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+164
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user