Grotti 3.5.0: Pagination, Logging, Deb-Paket, Makro-Doku, PLA-Match-Fix.
This commit is contained in:
1 parent
4ffe1dcfc6
commit
f1f79c2ce2
38 files changed
+1062
-75
No files matched your search
+24
-4
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user