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:
1 parent
efd0801cd1
commit
9e22cebb81
48 files changed
+2259
-422
No files matched your search
+53
-8
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user