Grotti 3.5.0: Pagination, Logging, Deb-Paket, Makro-Doku, PLA-Match-Fix.

This commit is contained in:
Dominic Gundel committed 2026-10-19 11:14:24 +02:00
1 parent 4ffe1dcfc6
commit f1f79c2ce2
38 files changed
+1062 -75

No files matched your search

+24 -4
View File
@@ -1,6 +1,7 @@
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
import json
import os
import secrets
import threading
from pathlib import Path
@@ -9,11 +10,24 @@ from grotti.errors import GrottiError
ROOT = Path(__file__).resolve().parent.parent
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
CONFIG_PATH = ROOT / "config.json"
DEFAULT_WATCH_REL = "slice"
_SETTINGS_LOCK = threading.Lock()
def config_path():
"""GROTTI_CONFIG, sonst /etc/grotti/config.json (Paket), sonst Repo-config.json."""
env = (os.environ.get("GROTTI_CONFIG") or "").strip()
if env:
return Path(env)
etc = Path("/etc/grotti/config.json")
if etc.is_file():
return etc
return ROOT / "config.json"
CONFIG_PATH = config_path()
def _tariff(raw):
tariff = raw.get("tariff") if isinstance(raw, dict) else None
if not isinstance(tariff, dict) or not tariff:
@@ -121,9 +135,11 @@ def _ensure_secret_key(path, raw):
def load():
path = CONFIG_PATH
path = config_path()
if not path.exists():
path.write_text((ROOT / "config.example.json").read_text(encoding="utf-8"), encoding="utf-8")
example = ROOT / "config.example.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
raw = json.loads(path.read_text(encoding="utf-8"))
host = (raw.get("host") or "127.0.0.1").strip()
token = raw.get("token") or ""
@@ -139,6 +155,9 @@ def load():
if not data_dir.is_absolute():
data_dir = ROOT / data_dir
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
printers_file = Path(raw.get("printers_file") or (ROOT / "drucker.json"))
if not printers_file.is_absolute():
printers_file = ROOT / printers_file
return {
"host": host,
"port": int(raw.get("port") or 8770),
@@ -149,7 +168,8 @@ def load():
"secret_key": secret_key,
"oauth": _oauth(raw),
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
"printers_file": ROOT / "drucker.json",
"printers_file": printers_file,
"log_level": str(raw.get("log_level") or "INFO").strip() or "INFO",
"energy": _energy(raw),
"telegram": _telegram(raw),
"ntfy": _ntfy(raw),
+5 -1
View File
@@ -13,6 +13,10 @@ from psycopg import sql
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from grotti import logutil
log = logutil.get("grotti.db")
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
@@ -123,7 +127,7 @@ def prepare(url):
if not exists:
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
except psycopg.Error as exc:
print("Datenbank anlegen: %s" % exc, flush=True)
log.warning("Datenbank anlegen: %s", exc)
try:
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
apply_migrations(conn)
+52
View File
@@ -0,0 +1,52 @@
"""Zentraler Logger für Dienst und journald (stdout)."""
from __future__ import annotations
import logging
import sys
_CONFIGURED = False
LEVELS = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"warn": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
}
def parse_level(value, default=logging.INFO):
if isinstance(value, int):
return value
text = str(value or "").strip().lower()
return LEVELS.get(text, default)
def setup(level="INFO"):
"""Root-Logger einmalig auf stdout (systemd → journald)."""
global _CONFIGURED
resolved = parse_level(level)
root = logging.getLogger()
if _CONFIGURED:
root.setLevel(resolved)
return root
root.handlers.clear()
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root.addHandler(handler)
root.setLevel(resolved)
logging.getLogger("waitress").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
_CONFIGURED = True
return root
def get(name="grotti"):
return logging.getLogger(name)
+4 -1
View File
@@ -8,8 +8,11 @@ import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from grotti import logutil
from grotti.parse import embedded_png
log = logutil.get("grotti.moonraker")
META_CACHE = {}
CAMERA_CACHE = {}
EMBEDDED = {}
@@ -1241,7 +1244,7 @@ def snapshot(printers):
card.update(query(printer["host"]))
except Exception as error:
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
print("snapshot %s: %s" % (printer.get("host"), error), flush=True)
log.warning("snapshot %s: %s", printer.get("host"), error)
card.update({
"ok": False,
"state": "offline",
+72 -14
View File
@@ -10,7 +10,7 @@ import threading
import time
from datetime import datetime, timezone
from grotti import acm, eject, jobs, moonraker, notify, store
from grotti import acm, eject, jobs, logutil, moonraker, notify, store
from grotti.db import connect
from grotti.errors import GrottiError
from grotti import time_window
@@ -18,6 +18,8 @@ from grotti import time_window
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
from grotti.events_bus import publish as publish_live
log = logutil.get("grotti.queue")
def _notify_queue():
publish_live("queue", {})
@@ -111,7 +113,7 @@ class PrintQueueDispatcher:
try:
self.tick()
except Exception as exc:
print("Druck-Queue: %s" % exc, flush=True)
log.warning("Druck-Queue: %s", exc)
self._stop.wait(self.interval)
def _live_map(self, printers):
@@ -207,7 +209,7 @@ class PrintQueueDispatcher:
try:
target(*args)
except Exception as exc:
print("Queue-Worker %s: %s" % (item_id, exc), flush=True)
log.warning("Queue-Worker %s: %s", item_id, exc)
finally:
_notify_queue()
with self._worker_lock:
@@ -278,13 +280,15 @@ class PrintQueueDispatcher:
continue
running = store.running_print_on_host(conn, host)
if running:
if (
not _has_ablauf(running)
and (running.get("phase") or "").lower() == "starting"
and not self._worker_busy(running["id"])
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
):
self._fail_stuck_simple_start(conn, running, printer)
if not _has_ablauf(running):
if (
(running.get("phase") or "").lower() == "starting"
and not self._worker_busy(running["id"])
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
):
self._fail_stuck_simple_start(conn, running, printer)
else:
self._advance_simple(conn, running, printer, live)
conn.commit()
continue
if store.bed_needs_clear(conn, host):
@@ -417,15 +421,17 @@ class PrintQueueDispatcher:
from grotti import orders
orders.attach_order_item_to_live_run(wconn, host, name, oid)
# Nicht sofort „done“: sonst greift der nächste Wartende, während
# der Drucker noch druckt (Live-Cache kann kurz FREE bleiben).
store.update_print_queue(
wconn,
item_id,
{
"status": "done",
"message": "Gestartet auf %s: %s" % (printer_copy["name"], name),
"status": "running",
"message": "Druckt auf %s: %s" % (printer_copy["name"], name),
"host": host,
"phase": "",
"phase_started_at": None,
"phase": "print_active",
"phase_started_at": "now",
},
)
self._event(
@@ -477,6 +483,58 @@ class PrintQueueDispatcher:
self._spawn_worker(item_id, work)
def _advance_simple(self, conn, item, printer, live):
"""Einfach-Druck: running bleiben bis Moonraker fertig/abgebrochen."""
phase = (item.get("phase") or "").lower()
state = (live.get("state") or "").lower()
if phase == "starting":
if state in ("printing", "paused", "heating") or live.get("print_active"):
store.update_print_queue(
conn,
item["id"],
{
"phase": "print_active",
"phase_started_at": "now",
"message": "Druckt…",
},
)
return
if phase != "print_active":
return
if state in ("printing", "paused", "heating"):
return
if state == "error":
store.update_print_queue(
conn,
item["id"],
{
"status": "error",
"message": (live.get("message") or "Druckerfehler").strip()[:200],
"phase": "",
"phase_started_at": None,
},
)
return
if state in FREE or state in ("complete", "cancelled"):
store.update_print_queue(
conn,
item["id"],
{
"status": "done",
"message": "Fertig.",
"phase": "",
"phase_started_at": None,
},
)
self._event(
conn,
printer["host"],
printer["name"],
"fertig",
"%s fertig: %s" % (printer["name"], item.get("filename") or "Druck"),
item.get("filename") or "",
)
def _begin_ablauf(self, conn, item, printer, live):
try:
eject.require_kobra_3(printer)
+21 -11
View File
@@ -2813,6 +2813,20 @@ def _file_needs(conn, file_id):
return types, float(row.get("weight_g") or 0)
def _prefer_color(spools, need_color):
"""Farbe soft: Slice-Hex ignorieren; benannte Farbe bevorzugen, nie blockieren."""
from grotti.bedarf import _is_slicer_hex
need_c = (need_color or "").strip().lower()
if not need_c or _is_slicer_hex(need_c) or not spools:
return list(spools)
preferred = [
h for h in spools
if not (h.get("color") or "").strip() or (h.get("color") or "").strip().lower() == need_c
]
return preferred or list(spools)
def _match_spools_for_need(need, have, used_ids=None):
used = used_ids or set()
candidates = [h for h in have if h.get("spool_id") not in used]
@@ -2822,29 +2836,25 @@ def _match_spools_for_need(need, have, used_ids=None):
kind = (kind or "").strip().lower()
return [
h for h in candidates
if (not kind or h["material_type"] == kind)
and (not need.get("color") or h["color"] == need["color"] or not h["color"])
if not kind or h["material_type"] == kind
]
if profile:
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
if by_profile:
colored = [
h for h in by_profile
if not need.get("color") or h["color"] == need["color"] or not h["color"]
]
return colored or by_profile
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht (Sunlu PLA ↔ Eryone PLA).
# Spulen mit Variante im Namen bleiben draußen (kein Meta für „nur PLA“).
return _prefer_color(by_profile, need.get("color"))
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht
# (Sunlu PLA ↔ Eryone PLA Lite; Slice-Hex #26A69A blockiert nicht „Grau“).
if not _profile_has_variant(profile):
kind = _profile_base_type(profile) or (need.get("material_type") or "")
if kind:
return [
typed = [
h for h in _by_type(kind)
if not _profile_has_variant(h.get("material_name") or "")
]
return _prefer_color(typed, need.get("color"))
return []
return _by_type(need.get("material_type") or "")
return _prefer_color(_by_type(need.get("material_type") or ""), need.get("color"))
def material_fit_score(conn, host, file_id):
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "3.4.0"
__version__ = "3.5.0"
+10 -8
View File
@@ -6,10 +6,12 @@ import threading
import time
from datetime import datetime, timezone
from grotti import jobs, moonraker, notify, store, usage
from grotti import jobs, logutil, moonraker, notify, store, usage
from grotti.events_bus import publish as publish_live
from grotti.db import connect
log = logutil.get("grotti.watcher")
SAMPLE_EVERY_S = 30
SAMPLE_HEAT_S = 10
@@ -59,7 +61,7 @@ class StateWatcher:
try:
self.tick()
except Exception as exc:
print("Watcher: %s" % exc, flush=True)
log.warning("Watcher: %s", exc)
self._stop.wait(self.interval)
def tick(self):
@@ -118,7 +120,7 @@ class StateWatcher:
jobs.apply_deduction(conn, closed, self.cfg["energy"])
except Exception as exc:
print("Watcher Abzug offline %s: %s" % (host, exc), flush=True)
log.warning("Watcher Abzug offline %s: %s", host, exc)
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
return
@@ -193,7 +195,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
log.warning("Watcher Abgleich %s: %s", host, exc)
# History oft ohne Endzeit (interrupted) — Live trotzdem schließen.
dangling = store.find_live_run(conn, host)
if dangling:
@@ -204,7 +206,7 @@ class StateWatcher:
jobs.apply_deduction(conn, closed, self.cfg["energy"])
except Exception as exc:
print("Watcher Abzug %s: %s" % (host, exc), flush=True)
log.warning("Watcher Abzug %s: %s", host, exc)
elif state in ("cancelled", "standby"):
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
@@ -231,7 +233,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
log.warning("Watcher Abgleich %s: %s", host, exc)
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")
@@ -254,7 +256,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
log.warning("Watcher Abgleich %s: %s", host, exc)
elif state == "error":
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host)
@@ -436,7 +438,7 @@ class StateWatcher:
try:
moonraker.heaters_off(host)
except Exception as exc:
print("Watcher Heizung aus %s: %s" % (host, exc), flush=True)
log.warning("Watcher Heizung aus %s: %s", host, exc)
def _check_spool(self, conn, host, name, filename, live):
need = float(live.get("weight") or 0)