Grotti 2.10.0: Orca-Senden, Slice-Ordner, ED-Strom, Hygiene.

Slice-Watch mit Ordnern/Filtern, Orca-Nachbearbeitung, Cockpit-Verlauf
mit ED, Zeitzone im Browser. Tasten-Grenze und README-API angeglichen,
toter Code entfernt, Requirements gepinnt.
This commit is contained in:
Dominic Gundel committed 2026-10-18 00:41:15 +02:00
1 parent efd0801cd1
commit 9e22cebb81
48 files changed
+2259 -422

No files matched your search

+53 -8
View File
@@ -4,15 +4,20 @@ import json
import secrets
from pathlib import Path
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"
def _energy(raw):
energy = raw.get("energy") or {}
return {
"watts": float(energy.get("watts", 120)),
"watts": float(energy.get("watts", 40)),
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
"bed_watts": float(energy.get("bed_watts", 220)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)),
@@ -99,10 +104,7 @@ def load():
data_dir = Path(raw.get("data_dir") or "data")
if not data_dir.is_absolute():
data_dir = ROOT / data_dir
watch = (raw.get("watch_dir") or "").strip()
watch_dir = Path(watch) if watch else None
if watch_dir and not watch_dir.is_absolute():
watch_dir = (ROOT / watch_dir).resolve()
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
return {
"host": host,
"port": int(raw.get("port") or 8770),
@@ -130,11 +132,48 @@ def mask_token(token):
return token[:4] + "…" + token[-2:]
def parse_watch_dir(value, *, create=False, missing_ok=False):
"""Pfad aus Einstellungen/Config → (Path|None, gespeicherter String).
Relativ zum Repo-Root. Leer = aus. create legt den Ordner an.
"""
text = str(value or "").strip()
if not text:
return None, ""
path = Path(text)
if not path.is_absolute():
path = (ROOT / path).resolve()
else:
path = path.resolve()
if path.exists() and not path.is_dir():
raise GrottiError("Der Slice-Ordner ist keine Ordner.", "watch")
if create:
try:
path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise GrottiError("Slice-Ordner konnte nicht angelegt werden: %s" % exc, "watch") from exc
elif not path.is_dir():
if missing_ok:
return None, text
raise GrottiError("Slice-Ordner fehlt: %s" % path, "watch")
try:
stored = path.relative_to(ROOT.resolve()).as_posix()
except ValueError:
stored = str(path)
return path, stored
def default_watch_suggestion():
"""Absoluter Standardpfad (Repo/slice) für die Einstellungen."""
return str((ROOT / DEFAULT_WATCH_REL).resolve())
def settings_public(cfg):
tg = cfg.get("telegram") or {}
oauth = cfg.get("oauth") or {}
return {
"watch_dir": str(cfg.get("watch_dir") or ""),
"watch_dir_default": default_watch_suggestion(),
"auto_queue": bool(cfg.get("auto_queue", True)),
"fqdn": str(cfg.get("fqdn") or ""),
"legacy_token_set": bool(cfg.get("token")),
@@ -155,8 +194,8 @@ def settings_public(cfg):
}
def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None):
"""Schreibt energy/telegram/alerts/auto_queue/fqdn zurück nach config.json und aktualisiert cfg."""
def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None, watch_dir=None):
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
path = cfg.get("_path") or CONFIG_PATH
raw = dict(cfg.get("_raw") or {})
if path.exists():
@@ -167,13 +206,19 @@ def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None,
if auto_queue is not None:
raw["auto_queue"] = bool(auto_queue)
cfg["auto_queue"] = bool(auto_queue)
if watch_dir is not None:
resolved, stored = parse_watch_dir(watch_dir, create=True, missing_ok=False)
raw["watch_dir"] = stored
cfg["watch_dir"] = resolved
if fqdn is not None:
normalized = normalize_fqdn(fqdn)
raw["fqdn"] = normalized
cfg["fqdn"] = normalized
if energy is not None:
raw["energy"] = {
"watts": float(energy.get("watts", 120)),
"watts": float(energy.get("watts", 40)),
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
"bed_watts": float(energy.get("bed_watts", 220)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)),
+91 -3
View File
@@ -1,5 +1,7 @@
"""Dieselbe Rechnung wie in der Druckkalkulation."""
from datetime import datetime
def calculate(job, material):
part_g = float(job.get("weight_g") or 0) * max(1, float(job.get("qty") or 1))
@@ -37,6 +39,92 @@ def calculate(job, material):
}
def energy_cost(duration_s, energy):
hours = float(duration_s or 0) / 3600
return (float(energy["watts"]) / 1000) * hours * float(energy["euro_per_kwh"])
def _parse_ts(value):
if value is None:
return None
if isinstance(value, datetime):
return value.timestamp()
text = str(value).strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
def _avg_power(a, b):
vals = [float(v) for v in (a, b) if isinstance(v, (int, float)) and not isinstance(v, bool)]
if not vals:
return 0.0
return max(0.0, min(1.0, sum(vals) / len(vals)))
def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
"""Trapez aus Grundlast + ED×Heizerleistung. None wenn zu wenig ED-Proben."""
if not samples or len(samples) < 2:
return None
total = 0.0
ed_steps = 0
# Max. Intervall: 3× Normal-Sample (90 s), damit Lücken nicht die ED fortschreiben
max_dt = 90.0
for i in range(len(samples) - 1):
a, b = samples[i], samples[i + 1]
t0 = _parse_ts(a.get("sampled_at"))
t1 = _parse_ts(b.get("sampled_at"))
if t0 is None or t1 is None or t1 <= t0:
continue
dt = min(t1 - t0, max_dt)
pn0, pn1 = a.get("nozzle_power"), b.get("nozzle_power")
pb0, pb1 = a.get("bed_power"), b.get("bed_power")
has_ed = any(
isinstance(v, (int, float)) and not isinstance(v, bool)
for v in (pn0, pn1, pb0, pb1)
)
if has_ed:
ed_steps += 1
ed_n = _avg_power(pn0, pn1)
ed_b = _avg_power(pb0, pb1)
watts = float(base_w) + float(nozzle_w) * ed_n + float(bed_w) * ed_b
total += (watts / 1000.0) * (dt / 3600.0)
if ed_steps < 1:
return None
return total
def energy_estimate(duration_s, energy, samples=None):
"""Strom aus ED-Proben oder pauschal Dauer × Watt.
Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen.
"""
energy = energy or {}
euro = float(energy.get("euro_per_kwh") or 0)
dry_kwh = (float(energy.get("dryer_watts") or 0) / 1000) * float(energy.get("dry_hours") or 0)
base_w = float(energy.get("watts") or 0)
nozzle_w = float(energy.get("nozzle_watts") or 0)
bed_w = float(energy.get("bed_watts") or 0)
print_kwh = None
source = "flat"
if (nozzle_w > 0 or bed_w > 0) and samples:
print_kwh = _ed_print_kwh(samples, base_w, nozzle_w, bed_w)
if print_kwh is not None:
source = "ed"
if print_kwh is None:
hours = float(duration_s or 0) / 3600
print_kwh = (base_w / 1000) * hours
source = "flat"
kwh = print_kwh + dry_kwh
return {
"kwh": kwh,
"print_kwh": print_kwh,
"dry_kwh": dry_kwh,
"cost": kwh * euro,
"source": source,
}
def energy_cost(duration_s, energy, samples=None):
return energy_estimate(duration_s, energy, samples)["cost"]
+2 -101
View File
@@ -55,8 +55,8 @@ DEFAULT_SWEEP = {
# Ein Punkt in der Bettmitte — kein LeviQ, kein 3×3, kein Heizen.
DEFAULT_CHECK = {
"type": "check",
# Result is z= gegen Grenze. Temporär 3 mm — kalter Kunststoff an der Düse
# tastet sonst oft 1–2 mm und meldet Restteil. Später wieder straffen.
# Result is z= gegen Grenze. 3 mm: kalter Kunststoff an der Düse
# tastet sonst oft 1–2 mm und meldet Restteil.
"max_delta_mm": 3.0,
"timeout_min": 2,
"x": 125.0,
@@ -454,11 +454,6 @@ def sweep_lower_to_push_script(sweep):
])
def sweep_probe_height_script(sweep):
"""Kompatibel: hoch, hinten, langsam auf z_push."""
return sweep_go_back_high_script(sweep) + "\n" + sweep_lower_to_push_script(sweep)
def assert_clear_before_lower(live, sweep, min_z_mm=15.0):
"""Vor dem Senken: Toolhead muss noch klar über dem Bett stehen."""
pos = (live or {}).get("position") or []
@@ -562,100 +557,6 @@ def check_lift_script(check):
])
def _axis_coords(lo, hi, count):
if count <= 1:
return [(lo + hi) / 2.0]
return [lo + (hi - lo) * i / (count - 1) for i in range(count)]
def _matrix_ok(matrix):
if not isinstance(matrix, list) or not matrix:
return False
width = None
for row in matrix:
if not isinstance(row, list) or not row:
return False
if width is None:
width = len(row)
if len(row) != width:
return False
for value in row:
if not isinstance(value, (int, float)):
return False
return True
def interpolate_mesh_z(mesh, x, y):
"""Bilinear aus Roh-probed_matrix. Klipper: Zeile 0 = mesh_min Y."""
matrix = mesh.get("matrix") if isinstance(mesh, dict) else None
if not _matrix_ok(matrix):
return None
mesh_min = mesh.get("mesh_min") or [0, 0]
mesh_max = mesh.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError):
return None
rows = len(matrix)
cols = len(matrix[0])
if cols < 1 or rows < 1:
return None
if abs(x1 - x0) < 1e-9 or abs(y1 - y0) < 1e-9:
return float(matrix[0][0])
tx = 0.0 if cols == 1 else (float(x) - x0) / (x1 - x0) * (cols - 1)
ty = 0.0 if rows == 1 else (float(y) - y0) / (y1 - y0) * (rows - 1)
tx = max(0.0, min(float(cols - 1), tx))
ty = max(0.0, min(float(rows - 1), ty))
c0 = int(tx)
r0 = int(ty)
c1 = min(cols - 1, c0 + 1)
r1 = min(rows - 1, r0 + 1)
fx = tx - c0
fy = ty - r0
z00 = float(matrix[r0][c0])
z10 = float(matrix[r0][c1])
z01 = float(matrix[r1][c0])
z11 = float(matrix[r1][c1])
z0 = z00 * (1 - fx) + z10 * fx
z1 = z01 * (1 - fx) + z11 * fx
return z0 * (1 - fy) + z1 * fy
def mesh_delta_mm(baseline, measured):
"""Max |Δ| und Mittel |Δ|: Messpunkte gegen interpoliertes Baseline-Netz."""
if not isinstance(baseline, dict) or not _matrix_ok(baseline.get("matrix")):
raise GrottiError("Kein Bettnetz zum Vergleich — vorher nivellieren.", "mesh")
if not isinstance(measured, dict) or not _matrix_ok(measured.get("matrix")):
raise GrottiError("Neues Bettnetz fehlt nach dem Antasten.", "mesh")
matrix = measured["matrix"]
mesh_min = measured.get("mesh_min") or [0, 0]
mesh_max = measured.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError) as exc:
raise GrottiError("Messnetz ohne gültigen Bereich.", "mesh") from exc
rows = len(matrix)
cols = len(matrix[0])
xs = _axis_coords(x0, x1, cols)
ys = _axis_coords(y0, y1, rows)
deltas = []
for ri, y in enumerate(ys):
for ci, x in enumerate(xs):
expected = interpolate_mesh_z(baseline, x, y)
if expected is None:
raise GrottiError("Baseline-Netz lässt sich nicht interpolieren.", "mesh")
deltas.append(abs(float(matrix[ri][ci]) - expected))
if not deltas:
raise GrottiError("Keine Messpunkte zum Vergleich.", "mesh")
return {
"max_delta_mm": max(deltas),
"mean_delta_mm": sum(deltas) / len(deltas),
"points": len(deltas),
}
def step_label(step):
if not isinstance(step, dict):
return ""
+2 -1
View File
@@ -270,7 +270,8 @@ def apply_deduction(conn, run, energy, metadata=None):
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)
samples = store.list_run_samples(conn, run["id"])
power = energy_cost(run["duration_s"], energy, samples)
# 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(
+137 -1
View File
@@ -1,5 +1,7 @@
"""Watch-Ordner, Upload und Vorschaubild."""
import re
import shutil
import time
import zipfile
from pathlib import Path
@@ -10,6 +12,8 @@ from grotti.parse import embedded_png, inspect_file, safe_zip_name
_LAST_WATCH_SYNC = 0.0
_WATCH_MIN_INTERVAL_S = 20
_FOLDER_PART = re.compile(r"^[A-Za-z0-9._\- äöüÄÖÜß]+$")
_MAX_FOLDER_DEPTH = 6
def skip_path(path, data_dir):
@@ -36,7 +40,7 @@ def sync_watch(conn, cfg, force=False):
global _LAST_WATCH_SYNC
folder = cfg["watch_dir"]
if folder is None:
return {"gelesen": 0, "hinweis": "In config.json watch_dir auf den Slice-Ordner setzen."}
return {"gelesen": 0, "hinweis": "Unter Einstellungen den Slice-Ordner setzen."}
if not folder.exists():
return {"gelesen": 0, "hinweis": "Der Watch-Ordner fehlt: %s" % folder}
now = time.monotonic()
@@ -113,6 +117,138 @@ def unique_path(directory, filename):
return candidate
def require_watch_dir(cfg):
folder = cfg.get("watch_dir")
if not folder:
raise GrottiError("Kein Slice-Ordner gesetzt — unter Einstellungen festlegen.", "watch")
root = Path(folder).resolve()
if not root.is_dir():
raise GrottiError("Der Slice-Ordner fehlt: %s" % root, "watch")
return root
def normalize_folder(rel):
"""Relativpfad unter watch_dir; leer = Wurzel. Kein .., begrenzte Tiefe."""
text = str(rel or "").strip().replace("\\", "/").strip("/")
if not text:
return ""
parts = []
for part in text.split("/"):
part = part.strip()
if not part or part in (".", ".."):
raise GrottiError("Ungültiger Ordnername.", "ordner")
if not _FOLDER_PART.match(part):
raise GrottiError(
"Ordnername nur Buchstaben, Ziffern, Leerzeichen, . _ -.",
"ordner",
)
parts.append(part)
if len(parts) > _MAX_FOLDER_DEPTH:
raise GrottiError("Ordner zu tief verschachtelt (max. %s)." % _MAX_FOLDER_DEPTH, "ordner")
return "/".join(parts)
def path_under_watch(watch_dir, rel_folder, name=None):
"""Absoluter Pfad unter watch_dir; wirft bei Escape."""
root = Path(watch_dir).resolve()
folder = normalize_folder(rel_folder)
target = root if not folder else (root / folder)
if name:
target = target / safe_filename(name)
resolved = target.resolve()
if resolved != root and root not in resolved.parents:
raise GrottiError("Pfad außerhalb des Slice-Ordners.", "ordner")
return resolved
def list_folders(cfg):
"""Unterordner unter watch_dir (auch leer), relativ, sortiert."""
try:
root = require_watch_dir(cfg)
except GrottiError:
return []
data_dir = cfg.get("data_dir")
found = set()
for path in root.rglob("*"):
if not path.is_dir():
continue
if data_dir is not None and skip_path(path, data_dir):
continue
try:
rel = path.resolve().relative_to(root).as_posix()
except ValueError:
continue
if rel and rel != ".":
found.add(rel)
return sorted(found, key=lambda s: s.lower())
def create_folder(cfg, rel):
root = require_watch_dir(cfg)
folder = normalize_folder(rel)
if not folder:
raise GrottiError("Ordnername fehlt.", "ordner")
target = path_under_watch(root, folder)
target.mkdir(parents=True, exist_ok=True)
return folder
def delete_folder(cfg, rel):
root = require_watch_dir(cfg)
folder = normalize_folder(rel)
if not folder:
raise GrottiError("Die Wurzel lässt sich nicht löschen.", "ordner")
target = path_under_watch(root, folder)
if not target.is_dir():
raise GrottiError("Der Ordner fehlt.", "ordner", 404)
try:
next(target.iterdir())
raise GrottiError("Ordner ist nicht leer.", "ordner")
except StopIteration:
pass
target.rmdir()
return folder
def move_file(conn, cfg, file_id, folder):
"""Watch-Datei in Unterordner (oder Wurzel) verschieben; ID bleibt."""
root = require_watch_dir(cfg)
folder = normalize_folder(folder)
row = store.get_file_row(conn, file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
if row.get("source") != "watch":
raise GrottiError(
"Nur Dateien aus dem Slice-Ordner lassen sich verschieben.",
"watch",
)
src = Path(row.get("stored_path") or "")
if not src.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
dest_dir = path_under_watch(root, folder)
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / src.name
if dest.resolve() == src.resolve():
return store.file_out(row, watch_dir=root)
if dest.exists():
dest = unique_path(dest_dir, src.name)
try:
shutil.move(str(src), str(dest))
except OSError as exc:
raise GrottiError("Verschieben fehlgeschlagen: %s" % exc, "ordner") from exc
stat = dest.stat()
store.update_file_location(
conn,
file_id,
str(dest.resolve()),
name=dest.name,
size_bytes=stat.st_size,
mtime=stat.st_mtime,
)
row = store.get_file_row(conn, file_id)
return store.file_out(row, watch_dir=root)
def save_upload(conn, filename, content, cfg, project_id=None):
directory = cfg["data_dir"] / "eingang"
directory.mkdir(parents=True, exist_ok=True)
+12 -30
View File
@@ -50,31 +50,6 @@ def _raw_matrix(points):
return []
def fetch_bed_mesh(host):
"""Roh-Bettnetz von Moonraker (ohne UI-Spiegelung)."""
url = "http://%s/printer/objects/query?bed_mesh" % host
payload = fetch_json(url, timeout=10)
status = (payload.get("result") or {}).get("status") or {}
mesh = status.get("bed_mesh") or {}
matrix = _raw_matrix(mesh.get("probed_matrix"))
if not matrix:
matrix = _raw_matrix(mesh.get("mesh_matrix"))
mesh_min = mesh.get("mesh_min")
mesh_max = mesh.get("mesh_max")
if not (isinstance(mesh_min, (list, tuple)) and len(mesh_min) >= 2):
mesh_min = None
if not (isinstance(mesh_max, (list, tuple)) and len(mesh_max) >= 2):
mesh_max = None
if not matrix or mesh_min is None or mesh_max is None:
return None
return {
"matrix": matrix,
"mesh_min": [float(mesh_min[0]), float(mesh_min[1])],
"mesh_max": [float(mesh_max[0]), float(mesh_max[1])],
"profile": mesh.get("profile_name") or "",
}
def store_mesh(host, shown, profile=""):
if not shown:
return
@@ -118,11 +93,6 @@ class PrinterError(Exception):
self.message = message
def is_timeout(exc):
text = (getattr(exc, "message", None) or str(exc) or "").lower()
return "zu lange nicht geantwortet" in text or "timed out" in text
def fetch_json(url, timeout=4):
request = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -776,6 +746,16 @@ def fan_values(status):
return speed, rpm
def _heater_power(entry):
"""Klipper-Einschaltdauer 0…1 (ED), sonst None."""
if not isinstance(entry, dict):
return None
power = entry.get("power")
if isinstance(power, (int, float)) and not isinstance(power, bool):
return max(0.0, min(1.0, float(power)))
return None
def _object_names(host):
now = time.time()
cached = OBJECT_CACHE.get(host)
@@ -1078,8 +1058,10 @@ def query(host):
"elapsed": stats.get("print_duration") or 0,
"nozzle": extruder.get("temperature"),
"nozzle_target": extruder.get("target"),
"nozzle_power": _heater_power(extruder),
"bed": bed.get("temperature"),
"bed_target": bed.get("target"),
"bed_power": _heater_power(bed),
"chamber": chamber,
"chamber_target": chamber_target,
"filament_used": filament_used,
-23
View File
@@ -1,7 +1,5 @@
"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL."""
import json
FAMILY_LABELS = {"kobra_s1": "Kobra S1", "kobra_3": "Kobra 3"}
@@ -21,24 +19,3 @@ def family_from_text(text):
if "kobra 3" in low or "kobra3" in low:
return "kobra_3"
return ""
def load_printers(path):
rows = json.loads(path.read_text(encoding="utf-8"))
printers = []
for row in rows:
family = row.get("family") or family_from_name(row["name"])
printers.append({
"name": row["name"],
"host": row["host"],
"family": family,
"family_label": FAMILY_LABELS.get(family, family),
})
return printers
def printer_by_host(path, host):
for printer in load_printers(path):
if printer["host"] == host:
return printer
return None
+6 -1
View File
@@ -282,11 +282,16 @@ CREATE TABLE IF NOT EXISTS run_samples (
layer integer,
progress double precision,
filament_used_mm double precision,
mmu_tool smallint
mmu_tool smallint,
nozzle_power double precision,
bed_power double precision
);
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS nozzle_power double precision;
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS bed_power double precision;
-- Objekt-Chargen für Etiketten
CREATE TABLE IF NOT EXISTS run_labels (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+231 -45
View File
@@ -3,6 +3,7 @@
import json
import uuid
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from psycopg.types.json import Json
@@ -30,7 +31,21 @@ def project_out(row, files=None, file_count=None):
return data
def file_out(row):
def file_folder(stored_path, source, watch_dir=None):
"""Relativpfad des Elternordners unter watch_dir; Uploads → „Upload“."""
if (source or "") == "upload":
return "Upload"
if not stored_path or not watch_dir:
return ""
try:
rel = Path(stored_path).resolve().relative_to(Path(watch_dir).resolve())
except (ValueError, OSError):
return ""
parent = rel.parent.as_posix()
return "" if parent in (".", "") else parent
def file_out(row, watch_dir=None):
# stored_path bleibt intern (get_file_row / list_file_rows), nicht in der API.
from grotti.parse import path_has_ace_trailer
@@ -54,6 +69,7 @@ def file_out(row):
"mtime": row["mtime"],
"slice_meta": row.get("slice_meta") or {},
"ace_trailer": ace_trailer,
"folder": file_folder(stored, row.get("source"), watch_dir),
"preview": "/api/v1/dateien/%s/vorschau" % row["id"] if row["thumb_entry"] else "",
}
@@ -227,8 +243,8 @@ def list_file_rows(conn):
return conn.execute("SELECT * FROM files ORDER BY mtime DESC, name").fetchall()
def list_files(conn):
return [file_out(row) for row in list_file_rows(conn)]
def list_files(conn, watch_dir=None):
return [file_out(row, watch_dir=watch_dir) for row in list_file_rows(conn)]
def get_file_row(conn, file_id):
@@ -291,7 +307,7 @@ def insert_upload(conn, meta):
return file_out(row)
def update_file(conn, file_id, fields):
def update_file(conn, file_id, fields, watch_dir=None):
sets = []
values = []
for key in ("project_id", "spool_id", "printer_family", "weight_g", "estimated_s", "thumb_entry", "kind", "plate"):
@@ -306,13 +322,33 @@ def update_file(conn, file_id, fields):
values.append(Json(fields.get("slice_meta") or {}))
if not sets:
row = get_file_row(conn, file_id)
return file_out(row) if row else None
return file_out(row, watch_dir=watch_dir) if row else None
values.append(file_id)
row = conn.execute(
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
values,
).fetchone()
return file_out(row) if row else None
return file_out(row, watch_dir=watch_dir) if row else None
def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None):
"""Pfad nach Verschieben auf der Platte — ID und Zuordnungen bleiben."""
sets = ["stored_path = %s"]
values = [stored_path]
if name is not None:
sets.append("name = %s")
values.append(name)
if size_bytes is not None:
sets.append("size_bytes = %s")
values.append(size_bytes)
if mtime is not None:
sets.append("mtime = %s")
values.append(mtime)
values.append(file_id)
return conn.execute(
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
values,
).fetchone()
def delete_file(conn, file_id):
@@ -766,6 +802,34 @@ def get_run_row(conn, run_id):
return conn.execute("SELECT * FROM runs WHERE id = %s", (run_id,)).fetchone()
def latest_run_for_host(conn, host):
"""Aktueller oder letzter Lauf mit Proben fürs Cockpit-Verlauf."""
host = (host or "").strip()
if not host:
return None
runs = list_runs(conn, limit=12, host=host)
if not runs:
return None
active = [r for r in runs if (r.get("state") or "") in ("in_progress", "printing", "paused")]
candidates = active + [r for r in runs if r not in active]
best = None
best_n = -1
for run in candidates:
row = conn.execute(
"SELECT count(*) AS n FROM run_samples WHERE run_id = %s",
(run["id"],),
).fetchone()
n = int(row["n"] or 0) if row else 0
if (run.get("state") or "") in ("in_progress", "printing", "paused"):
run["_sample_count"] = n
return run
if n >= 2 and n > best_n:
best = run
best_n = n
best["_sample_count"] = n
return best
def get_run_by_chargen(conn, code):
text = (code or "").strip()
if not text:
@@ -806,21 +870,33 @@ def assign_run_spool(conn, run_id, spool_id):
def next_druck_chargen(conn):
"""Nächste Druck-Charge des Tages. Objekt-Etiketten (`D-…-01`) blockieren die Nummer."""
day = datetime.now(timezone.utc).strftime("%Y%m%d")
prefix = "D-%s-" % day
batch_re = "^D-%s-[0-9]{4}$" % day
object_re = "^D-%s-[0-9]{4}-[0-9]{2}$" % day
row = conn.execute(
"""
SELECT chargen_nr FROM runs
WHERE chargen_nr LIKE %s
ORDER BY chargen_nr DESC
SELECT n FROM (
SELECT chargen_nr AS n FROM runs
WHERE chargen_nr ~ %s
UNION
SELECT CASE
WHEN chargen_nr ~ %s THEN regexp_replace(chargen_nr, '-[0-9]{2}$', '')
ELSE chargen_nr
END AS n
FROM run_labels
WHERE chargen_nr ~ %s OR chargen_nr ~ %s
) AS nums
ORDER BY n DESC
LIMIT 1
""",
(prefix + "%",),
(batch_re, object_re, batch_re, object_re),
).fetchone()
number = 1
if row and row["chargen_nr"]:
if row and row["n"]:
try:
number = int(str(row["chargen_nr"]).rsplit("-", 1)[-1]) + 1
number = int(str(row["n"]).rsplit("-", 1)[-1]) + 1
except ValueError:
number = 1
return "%s%04d" % (prefix, number)
@@ -1203,7 +1279,7 @@ def adopt_live_run(conn, host, filename, real_run_id):
return
live_id = live["id"]
live_chargen = (live.get("chargen_nr") or "").strip()
for table in ("run_samples", "run_segments", "run_spools", "run_labels"):
for table in ("run_samples", "run_segments", "run_spools"):
conn.execute(
"UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table,
(real_run_id, live_id),
@@ -1238,6 +1314,26 @@ def adopt_live_run(conn, host, filename, real_run_id):
else:
# Live-Charge war verfrüht — nicht übernehmen
conn.execute("UPDATE runs SET chargen_nr = NULL WHERE id = %s", (live_id,))
# Etiketten nur mit derselben Charge. Sonst bleiben sie am Live-Lauf und
# fallen beim Löschen weg, statt eine fremde Nummer zu blockieren.
real_after = get_run_row(conn, real_run_id)
real_chargen = ((real_after or {}).get("chargen_nr") or "").strip()
if live_chargen and real_chargen == live_chargen:
conn.execute(
"""
DELETE FROM run_labels AS src
WHERE src.run_id = %s
AND EXISTS (
SELECT 1 FROM run_labels AS dst
WHERE dst.run_id = %s AND dst.object_name = src.object_name
)
""",
(live_id, real_run_id),
)
conn.execute(
"UPDATE run_labels SET run_id = %s WHERE run_id = %s",
(real_run_id, live_id),
)
conn.execute("DELETE FROM runs WHERE id = %s", (live_id,))
@@ -1272,26 +1368,36 @@ def add_run_sample(conn, run_id, sample):
INSERT INTO run_samples (
run_id, sampled_at, nozzle, nozzle_target, bed, bed_target, chamber,
fan, speed_factor, extrude_factor, layer, progress,
filament_used_mm, mmu_tool
filament_used_mm, mmu_tool, nozzle_power, bed_power
) VALUES (
%(run_id)s, COALESCE(%(sampled_at)s, now()), %(nozzle)s, %(nozzle_target)s,
%(bed)s, %(bed_target)s, %(chamber)s, %(fan)s, %(speed_factor)s,
%(extrude_factor)s, %(layer)s, %(progress)s, %(filament_used_mm)s, %(mmu_tool)s
%(extrude_factor)s, %(layer)s, %(progress)s, %(filament_used_mm)s, %(mmu_tool)s,
%(nozzle_power)s, %(bed_power)s
)
RETURNING id
""",
{"run_id": run_id, "sampled_at": sample.get("sampled_at"), **sample},
{
"run_id": run_id,
"sampled_at": sample.get("sampled_at"),
"nozzle": sample.get("nozzle"),
"nozzle_target": sample.get("nozzle_target"),
"bed": sample.get("bed"),
"bed_target": sample.get("bed_target"),
"chamber": sample.get("chamber"),
"fan": sample.get("fan"),
"speed_factor": sample.get("speed_factor"),
"extrude_factor": sample.get("extrude_factor"),
"layer": sample.get("layer"),
"progress": sample.get("progress"),
"filament_used_mm": sample.get("filament_used_mm"),
"mmu_tool": sample.get("mmu_tool"),
"nozzle_power": sample.get("nozzle_power"),
"bed_power": sample.get("bed_power"),
},
).fetchone()
def last_sample_at(conn, run_id):
row = conn.execute(
"SELECT sampled_at FROM run_samples WHERE run_id = %s ORDER BY sampled_at DESC LIMIT 1",
(run_id,),
).fetchone()
return row["sampled_at"] if row else None
def list_run_samples(conn, run_id, limit=2000):
rows = conn.execute(
"""
@@ -1318,6 +1424,8 @@ def list_run_samples(conn, run_id, limit=2000):
"progress": row["progress"],
"filament_used_mm": row["filament_used_mm"],
"mmu_tool": row["mmu_tool"],
"nozzle_power": row.get("nozzle_power"),
"bed_power": row.get("bed_power"),
})
return out
@@ -1384,6 +1492,72 @@ def list_run_segments(conn, run_id):
]
def _object_chargen(base, index):
return "%s-%02d" % (base, index)
def _label_owner(conn, code):
return conn.execute(
"""
SELECT l.id, l.run_id, l.chargen_nr, COALESCE(r.chargen_nr, '') AS run_chargen
FROM run_labels l
JOIN runs r ON r.id = l.run_id
WHERE l.chargen_nr = %s
""",
(code,),
).fetchone()
def _claim_object_chargen(conn, base, start, reserved, depth=0):
"""Freie Objekt-Charge ab start. Nummer einer anderen Lauf-Charge wird dorthin umgehängt."""
if depth > 8:
raise RuntimeError("Objekt-Charge lässt sich nicht vergeben")
number = max(1, int(start or 1))
while number < 1000:
code = _object_chargen(base, number)
if code in reserved:
number += 1
continue
owner = _label_owner(conn, code)
if not owner:
return code
owner_base = (owner["run_chargen"] or "").strip()
if owner_base == base:
number += 1
continue
if owner_base:
moved = _claim_object_chargen(conn, owner_base, 1, reserved | {code}, depth + 1)
conn.execute(
"UPDATE run_labels SET chargen_nr = %s WHERE id = %s AND chargen_nr = %s",
(moved, owner["id"], code),
)
if not _label_owner(conn, code):
return code
else:
conn.execute("DELETE FROM run_labels WHERE id = %s AND chargen_nr = %s", (owner["id"], code))
if not _label_owner(conn, code):
return code
number += 1
raise RuntimeError("Keine freie Objekt-Charge")
def _insert_run_label(conn, run_id, name, code):
from psycopg.errors import UniqueViolation
try:
with conn.transaction():
conn.execute(
"""
INSERT INTO run_labels (run_id, object_name, chargen_nr, excluded)
VALUES (%s, %s, %s, false)
""",
(run_id, name, code),
)
return ""
except UniqueViolation as exc:
return (exc.diag.constraint_name if exc.diag else "") or "unique"
def ensure_run_labels(conn, run_id, objects, qty=1):
"""Objekt- oder Stückzahl-Etiketten anlegen. Charge vom Lauf vorausgesetzt."""
run = get_run_row(conn, run_id)
@@ -1399,6 +1573,7 @@ def ensure_run_labels(conn, run_id, objects, qty=1):
for row in existing
if (row["object_name"] or "").strip()
}
reserved = {(row["chargen_nr"] or "") for row in existing}
names = []
for item in objects or []:
if isinstance(item, dict):
@@ -1412,29 +1587,44 @@ def ensure_run_labels(conn, run_id, objects, qty=1):
if not existing and not names:
count = max(1, int(qty or 1))
for index in range(count):
code = base if count == 1 else "%s-%02d" % (base, index + 1)
conn.execute(
"""
INSERT INTO run_labels (run_id, object_name, chargen_nr, excluded)
VALUES (%s, %s, %s, false)
""",
(run_id, "" if count == 1 else "Teil %s" % (index + 1), code),
)
label_name = "" if count == 1 else "Teil %s" % (index + 1)
placed = False
for _try in range(5):
if count == 1 and _try == 0 and base not in reserved and not _label_owner(conn, base):
code = base
else:
code = _claim_object_chargen(conn, base, index + 1 + _try, reserved)
clash = _insert_run_label(conn, run_id, label_name, code)
if not clash:
reserved.add(code)
placed = True
break
reserved.add(code)
if "object_name" in clash:
break
if not placed and count == 1:
break
elif names:
index = len(known_names)
for name, excluded in names:
if excluded or name in known_names:
continue
index += 1
code = "%s-%02d" % (base, index)
conn.execute(
"""
INSERT INTO run_labels (run_id, object_name, chargen_nr, excluded)
VALUES (%s, %s, %s, false)
""",
(run_id, name, code),
)
known_names.add(name)
placed = False
for _try in range(5):
code = _claim_object_chargen(conn, base, index + _try, reserved)
clash = _insert_run_label(conn, run_id, name, code)
if not clash:
reserved.add(code)
known_names.add(name)
placed = True
break
reserved.add(code)
if "object_name" in clash:
known_names.add(name)
break
if not placed:
continue
rows = conn.execute(
"SELECT * FROM run_labels WHERE run_id = %s ORDER BY id",
(run_id,),
@@ -1477,10 +1667,6 @@ def list_run_events(conn, run_id, limit=50):
return [event_out(row) for row in rows]
def attach_event_run(conn, event_id, run_id):
conn.execute("UPDATE events SET run_id = %s WHERE id = %s", (run_id, event_id))
def exclusive_until(until):
"""Reines Datum YYYY-MM-DD zählt inklusiv: Grenze = Folgetag."""
if until is None or until == "":
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "2.5.1"
__version__ = "2.10.0"
+68 -8
View File
@@ -10,6 +10,7 @@ from grotti import moonraker, notify, store, usage
from grotti.db import connect
SAMPLE_EVERY_S = 30
SAMPLE_HEAT_S = 10
class StateWatcher:
@@ -100,9 +101,28 @@ class StateWatcher:
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)
# Aufheiz-Proben behalten: Live-Lauf gleicher Datei nicht verwerfen
keep = None
existing = store.find_live_run(conn, host)
if existing:
same = (
not filename
or not existing.get("filename")
or existing.get("filename") == filename
)
if same:
keep = existing["id"]
if filename and existing.get("filename") != filename:
conn.execute(
"UPDATE runs SET filename = %s WHERE id = %s",
(filename, keep),
)
store.abandon_live_runs(conn, host, keep_id=keep)
sample_now = self._should_sample(conn, host, live, state)
heating = sample_now and state not in ("printing", "paused")
if sample_now:
force = starting or (heating and not prev.get("heating"))
run = self._track_print(conn, host, name, filename, live, state, force_sample=force)
run_id = run["id"] if run else None
if prev_offline:
@@ -187,12 +207,39 @@ class StateWatcher:
self._prev[host] = {
"state": state,
"offline": False,
"heating": heating,
"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),
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") or heating else None),
}
def _track_print(self, conn, host, name, filename, live, state):
def _should_sample(self, conn, host, live, state):
"""Proben bei Druck/Pause und beim Aufheizen mit Bezug zu einem Auftrag."""
if state in ("printing", "paused"):
return True
if state in ("error", "offline", ""):
return False
nozzle_t = usage.as_float(live.get("nozzle_target"))
bed_t = usage.as_float(live.get("bed_target"))
if nozzle_t <= 40 and bed_t <= 30:
return False
if live.get("filename"):
return True
if store.running_print_on_host(conn, host):
return True
return bool(store.find_live_run(conn, host))
def _track_print(self, conn, host, name, filename, live, state, force_sample=False):
if not filename:
running = store.running_print_on_host(conn, host)
if running:
filename = running.get("filename") or ""
if not filename:
existing = store.find_live_run(conn, host)
if existing:
filename = existing.get("filename") or ""
if not filename and state not in ("printing", "paused"):
return None
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)
@@ -214,7 +261,7 @@ class StateWatcher:
"duration_s": usage.as_float(live.get("elapsed")),
})
store.ensure_run_chargen(conn, run["id"])
self._maybe_sample(conn, run["id"], live, used_mm)
self._maybe_sample(conn, run["id"], live, used_mm, force=force_sample)
return store.get_run_row(conn, run["id"])
def _merge_objects(self, current, exclude):
@@ -243,10 +290,21 @@ class StateWatcher:
known[name] = {"name": name, "excluded": True}
return list(known.values())
def _maybe_sample(self, conn, run_id, live, used_mm):
def _heat_gap(self, live):
"""Abstand Ist↔Soll — während Aufheizen dichter samplen."""
gaps = []
for key, tkey in (("nozzle", "nozzle_target"), ("bed", "bed_target")):
target = usage.as_float(live.get(tkey))
if target <= 0:
continue
gaps.append(abs(usage.as_float(live.get(key)) - target))
return max(gaps) if gaps else 0
def _maybe_sample(self, conn, run_id, live, used_mm, force=False):
now = time.monotonic()
last = self._last_sample.get(run_id, 0)
if now - last < SAMPLE_EVERY_S:
interval = SAMPLE_HEAT_S if self._heat_gap(live) > 3 else SAMPLE_EVERY_S
if not force and now - last < interval:
return
self._last_sample[run_id] = now
store.add_run_sample(conn, run_id, {
@@ -263,6 +321,8 @@ class StateWatcher:
"progress": live.get("progress"),
"filament_used_mm": used_mm,
"mmu_tool": live.get("mmu_tool"),
"nozzle_power": live.get("nozzle_power"),
"bed_power": live.get("bed_power"),
})
def _ensure_start_segment(self, conn, host, filename, live, run_id):