Grotti 3.7.0: Treiber für Kobra 4 und Marlin, smartere Dateien-Auswahl und einstellbare Etiketten.

Senden und Abbruch laufen über den Treiber, Orca-Doppelzeilen und tote Ideen-API fallen weg, Drucker folgen der Datei, Etikettenformat liegt in den Einstellungen.
This commit is contained in:
Dominic Gundel committed 2026-11-02 14:03:57 +01:00
1 parent 5081212212
commit db6a85a703
54 files changed
+3379 -384

No files matched your search

+77 -51
View File
@@ -40,10 +40,13 @@ DOCS_DIR = TOOL_ROOT / "docs"
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
VERDICTS = {"", "gut", "ausschuss"}
VERDICT_REASONS = sorted(grotti_verdict.VERDICT_REASONS)
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
SERIAL_RE = re.compile(
r"^(?:COM\d+|/dev/tty(?:USB|ACM)\d+|/dev/serial/by-id/[A-Za-z0-9._\-]+)(?:@\d{3,7})?$",
re.IGNORECASE,
)
TOKEN_QUERY_PATHS = (
"/api/v1/kamera",
"/api/v1/kamera/stream",
@@ -59,13 +62,11 @@ STEER_ACTIONS = {
def validate_printer_host(host):
host = (host or "").strip()
if not host or not HOST_RE.fullmatch(host):
if not host or ".." in host or not (HOST_RE.fullmatch(host) or SERIAL_RE.fullmatch(host)):
raise GrottiError(
"Host nur als Hostname oder IP, optional mit Port (z. B. 10.10.1.60).",
"Host als IP, Hostname, COM-Port (COM5 oder COM5@250000) oder /dev/tty…",
"host",
)
if ".." in host:
raise GrottiError("Ungültiger Host.", "host")
return host
@@ -732,6 +733,7 @@ def create_app(cfg):
token="",
spool=spool,
qr_url=qr_url,
etikett=cfg.get("etikett") or grotti_config._etikett({}),
)
@app.get("/protokoll")
@@ -792,6 +794,7 @@ def create_app(cfg):
sheets=sheets,
spools=spools,
target_g=target_g,
etikett=cfg.get("etikett") or grotti_config._etikett({}),
)
@app.get("/ideen")
@@ -879,6 +882,7 @@ def create_app(cfg):
auto_retry = data.get("auto_retry") if "auto_retry" in data else None
fqdn = data.get("fqdn") if "fqdn" in data else None
watch_dir = data.get("watch_dir") if "watch_dir" in data else None
etikett = data.get("etikett") if isinstance(data.get("etikett"), dict) else None
return jsonify(grotti_config.save_settings(
cfg,
energy=energy,
@@ -889,6 +893,7 @@ def create_app(cfg):
auto_retry=auto_retry,
fqdn=fqdn,
watch_dir=watch_dir,
etikett=etikett,
))
@app.post("/api/v1/telegram/test")
@@ -931,8 +936,12 @@ def create_app(cfg):
by_id = {row["host"]: row for row in rows}
if request.args.get("live", "1") == "0":
from grotti.drivers import for_printer
for row in rows:
row["mainsail"] = "http://%s/" % row["host"]
if for_printer(row).transport == "moonraker":
row["mainsail"] = "http://%s/" % row["host"]
else:
row["mainsail"] = ""
attach(row)
return jsonify(rows)
# Eine Live-Quelle: Watcher. Frischer als 60 s, sonst einmal selbst abfragen.
@@ -940,7 +949,8 @@ def create_app(cfg):
cached = watcher.by_host()
age = watcher.age_s()
if not cached or age is None or age > 60:
lives = moonraker.snapshot(rows)
from grotti.drivers import snapshot
lives = snapshot(rows)
else:
lives = []
for row in rows:
@@ -955,7 +965,11 @@ def create_app(cfg):
item = dict(row)
item["ok"] = False
item["state"] = "offline"
item["mainsail"] = "http://%s/" % row["host"]
from grotti.drivers import for_printer as _for_printer
if _for_printer(row).transport == "moonraker":
item["mainsail"] = "http://%s/" % row["host"]
else:
item["mainsail"] = ""
lives.append(item)
for live in lives:
meta = by_id.get(live.get("host") or "") or {}
@@ -963,6 +977,53 @@ def create_app(cfg):
attach(live)
return jsonify(lives)
@app.get("/api/v1/marlin-dienst")
def marlin_dienst():
from grotti.marlin_supervisor import current_status, ensure_if_enabled
ensure_if_enabled()
return jsonify(current_status())
@app.post("/api/v1/meldungen")
def meldung_create():
from grotti import meldung as grotti_meldung
require_role("operator")
data = body()
try:
row = grotti_meldung.create(
conn(),
cfg,
text=data.get("text") or "",
page=data.get("page") or "",
printer_host=data.get("printer_host") or "",
error_text=data.get("error_text") or "",
user=g.user,
)
conn().commit()
except ValueError as exc:
raise GrottiError(str(exc), "meldung") from exc
return jsonify(row), 201
@app.get("/api/v1/meldungen")
def meldung_list():
from grotti import meldung as grotti_meldung
require_role("admin")
status = (request.args.get("status") or "").strip() or None
return jsonify(grotti_meldung.list_rows(conn(), status=status))
@app.post("/api/v1/meldungen/<int:meldung_id>/erledigt")
def meldung_close(meldung_id):
from grotti import meldung as grotti_meldung
require_role("admin")
row = grotti_meldung.close(conn(), meldung_id)
if not row:
raise GrottiError("Die Meldung fehlt.", "meldung", 404)
conn().commit()
return jsonify(row)
@app.post("/api/v1/drucker")
def printer_create():
data = body()
@@ -1555,6 +1616,7 @@ def create_app(cfg):
optional_id(data.get("spool_id")),
ace_maps,
bed_leveling=bool(data.get("bed_leveling")),
marlin_mode=(data.get("marlin_mode") or "stream"),
)
return jsonify({"jobs": created, "count": len(created), "mode": "now"}), 202
@@ -1973,8 +2035,13 @@ def create_app(cfg):
# laufenden Druck auf dem Host best-effort stoppen
if was_running and row.get("host"):
try:
moonraker.command(row["host"], "abbrechen")
stopped = ", Druck gestoppt"
from grotti.drivers import for_printer
printer = store.printer_by_host(conn(), row["host"])
if printer:
for_printer(printer).command(row["host"], "abbrechen")
stopped = ", Druck gestoppt"
else:
stopped = ", Druck nicht gestoppt"
except Exception:
stopped = ", Druck nicht gestoppt"
store.add_event(
@@ -2142,47 +2209,6 @@ def create_app(cfg):
def color_list_api():
return jsonify(color_catalog())
@app.get("/api/v1/ideen")
def idea_list():
return jsonify(store.list_ideas(conn()))
@app.post("/api/v1/ideen")
def idea_create():
data = body()
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Die Idee braucht einen Titel.", "titel")
status = data.get("status") or "offen"
if status not in IDEA_STATUS:
raise GrottiError("Unbekannter Status.", "status")
return jsonify(store.create_idea(conn(), title, (data.get("body") or "").strip(), status)), 201
@app.patch("/api/v1/ideen/<int:idea_id>")
def idea_patch(idea_id):
data = body()
fields = {}
if "title" in data:
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Die Idee braucht einen Titel.", "titel")
fields["title"] = title
if "body" in data:
fields["body"] = (data.get("body") or "").strip()
if "status" in data:
if data.get("status") not in IDEA_STATUS:
raise GrottiError("Unbekannter Status.", "status")
fields["status"] = data.get("status")
row = store.update_idea(conn(), idea_id, fields)
if not row:
raise GrottiError("Die Idee fehlt.", "idee", 404)
return jsonify(row)
@app.delete("/api/v1/ideen/<int:idea_id>")
def idea_delete(idea_id):
if not store.delete_idea(conn(), idea_id):
raise GrottiError("Die Idee fehlt.", "idee", 404)
return "", 204
@app.get("/api/v1/kunden")
def kunden_list():
return jsonify(grotti_orders.list_customers(conn()))
+93
View File
@@ -0,0 +1,93 @@
{# Gemeinsames Druck-CSS für Spulen- und Lauf-Etiketten. Maße aus Einstellungen. #}
{% set w = etikett.width_mm if etikett is defined else 70 %}
{% set h = etikett.height_mm if etikett is defined else 40 %}
{% set qr = etikett.qr_mm if etikett is defined else 20 %}
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
color: #111;
background: #f3f3f3;
}
.toolbar {
display: flex;
gap: 0.6rem;
padding: 0.8rem 1rem;
background: #1a1a1a;
color: #eee;
}
.toolbar a, .toolbar button {
color: #111;
background: #f97316;
border: 0;
border-radius: 8px;
padding: 0.4rem 0.85rem;
font: inherit;
font-weight: 650;
text-decoration: none;
cursor: pointer;
}
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
.sheet {
width: {{ w }}mm;
min-height: {{ h }}mm;
margin: 1.2rem auto;
padding: 3.5mm;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
display: grid;
grid-template-columns: 1fr {{ qr }}mm;
gap: 2.5mm;
align-items: center;
overflow: hidden;
}
.meta { min-width: 0; }
.meta h1 {
margin: 0 0 2mm;
font-size: 4.2mm;
letter-spacing: -0.02em;
overflow-wrap: anywhere;
}
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
.dots { display: inline-flex; vertical-align: middle; margin-right: 1.5mm; }
.dot {
width: 3.2mm;
height: 3.2mm;
border-radius: 50%;
border: 0.2mm solid rgba(0,0,0,0.25);
margin-left: -1mm;
}
.dot:first-child { margin-left: 0; }
.qr-wrap {
width: {{ qr }}mm;
height: {{ qr }}mm;
overflow: hidden;
flex-shrink: 0;
}
.qr-wrap canvas,
.qr-wrap img {
display: block;
width: {{ qr }}mm !important;
height: {{ qr }}mm !important;
max-width: 100%;
max-height: 100%;
}
@page {
size: {{ w }}mm {{ h }}mm;
margin: 0;
}
@media print {
body { background: #fff; }
.toolbar { display: none; }
.sheet {
margin: 0;
border: 0;
border-radius: 0;
page-break-inside: avoid;
}
}
</style>
+199 -58
View File
@@ -32,6 +32,9 @@
<option value="">Alle Maschinen</option>
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
<option value="kobra_4">Kobra 4</option>
<option value="anycubic_i3">Anycubic i3</option>
<option value="tenlog">Tenlog</option>
</select>
<select id="file-filter-project">
<option value="">Alle Projekte</option>
@@ -116,7 +119,15 @@
<div class="opt-body">
<div id="loaded-spools" class="loaded-spools"></div>
<p class="tiny">Verbrauch geht von der Rolle am Drucker. Wechsel unter <a href="/material">Material</a>.</p>
<label class="check"><input id="send-bed-level" type="checkbox"> Bettnetz vor dem Druck messen</label>
<label class="check" id="send-bed-level-wrap"><input id="send-bed-level" type="checkbox"> Bettnetz vor dem Druck messen</label>
<label class="check" id="send-marlin-wrap" hidden>
Start über USB
<select id="send-marlin-mode">
<option value="stream">Strom (Zeile für Zeile)</option>
<option value="sd">SD-Karte</option>
</select>
</label>
<p class="tiny" id="send-marlin-hint" hidden>„Wenn frei“ startet immer als Strom.</p>
<label class="check"><input id="send-override" type="checkbox"> Auch senden, wenn die Maschine nicht passt</label>
<label for="send-project">Projekt</label>
<select id="send-project"><option value="">keins</option></select>
@@ -168,7 +179,12 @@
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
<script src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script>
<script>
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
const families = {
kobra_s1: "Kobra S1", kobra_3: "Kobra 3", kobra_4: "Kobra 4",
anycubic_i3: "Anycubic i3", tenlog: "Tenlog",
};
const MOONRAKER_FAMILIES = new Set(["kobra_3", "kobra_s1"]);
const MARLIN_FAMILIES = new Set(["anycubic_i3", "tenlog"]);
const ABLAUF_KEY = "grotti-ablauf-v2";
const SEND_KEY = "grotti-send-v1";
const LIB_KEY = "grotti-lib-v1";
@@ -359,40 +375,8 @@ function spoolWarnText() {
if (!file || need <= 0) return "";
const maps = collectAceMaps();
const hosts = selectedHosts();
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean));
const PROFILE_VARIANTS = ["meta", "matte", "matt", "silk", "plus", "hf", "cf"];
function profileHasVariant(want) {
return PROFILE_VARIANTS.some((v) => new RegExp("\\b" + v + "\\b", "i").test(want || ""));
}
function profileBaseType(want) {
const m = String(want || "").toLowerCase().match(/\b(pla|petg|abs|asa|tpu|pa|pc|hips|pva)\b/);
return m ? m[1] : "";
}
function profileOk(spoolRows) {
if (!fileProfiles.length) return true;
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (!names.length && !types.length) return true;
return fileProfiles.every((want) => {
if (names.some((n) => {
if (n === want) return true;
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
return !PROFILE_VARIANTS.includes(first);
})) return true;
// Basis-Profil (nur „… PLA“): Typ reicht, Marke egal — keine Meta/Matte-Spule
if (profileHasVariant(want)) return false;
const kind = profileBaseType(want);
if (!kind) return true;
return spoolRows.some((s) => {
const t = (s.material_type || "").toLowerCase();
const n = (s.name || "").toLowerCase();
return t === kind && !profileHasVariant(n);
});
});
}
const fileTypes = fileTypesOf(file);
const fileProfiles = fileProfilesOf(file);
for (const host of hosts) {
const name = (printers.find((p) => p.host === host) || {}).name || host;
const mapping = maps[host] || [];
@@ -414,7 +398,7 @@ function spoolWarnText() {
});
if (!any) return name + ": keine Grotti-Spule auf den gewählten ACE-Plätzen — Abzug entfällt.";
if (left + 0.5 < need) return "ACE auf " + name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
if (!profileOk(mappedSpools)) {
if (!profileOk(file, mappedSpools)) {
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
}
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
@@ -431,7 +415,7 @@ function spoolWarnText() {
if (left + 0.5 < need) {
return name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
}
if (!profileOk(external)) {
if (!profileOk(file, external)) {
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
}
const types = external.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
@@ -986,13 +970,116 @@ function familyMismatch(p, file) {
return !!(file && file.printer_family && p.family && file.printer_family !== p.family);
}
function renderTargets() {
const box = document.getElementById("targets");
const PROFILE_VARIANTS = ["meta", "matte", "matt", "silk", "plus", "hf", "cf"];
function fileProfilesOf(file) {
return ((file && file.filaments) || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean);
}
function fileTypesOf(file) {
return ((file && file.filaments) || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean);
}
function profileHasVariant(want) {
return PROFILE_VARIANTS.some((v) => new RegExp("\\b" + v + "\\b", "i").test(want || ""));
}
function profileBaseType(want) {
const m = String(want || "").toLowerCase().match(/\b(pla|petg|abs|asa|tpu|pa|pc|hips|pva)\b/);
return m ? m[1] : "";
}
function profileOk(file, spoolRows) {
const fileProfiles = fileProfilesOf(file);
if (!fileProfiles.length) return true;
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (!names.length && !types.length) return true;
return fileProfiles.every((want) => {
if (names.some((n) => {
if (n === want) return true;
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
return !PROFILE_VARIANTS.includes(first);
})) return true;
if (profileHasVariant(want)) return false;
const kind = profileBaseType(want);
if (!kind) return true;
return spoolRows.some((s) => {
const t = (s.material_type || "").toLowerCase();
const n = (s.name || "").toLowerCase();
return t === kind && !profileHasVariant(n);
});
});
}
function materialFitsHost(file, host) {
const profiles = fileProfilesOf(file);
const types = fileTypesOf(file);
if (!profiles.length && !types.length) return true;
const onHost = spoolsOnHost(host);
if (!onHost.length) return false;
if (profiles.length) return profileOk(file, onHost);
const spoolTypes = onHost.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (!spoolTypes.length) return false;
return types.some((t) => spoolTypes.includes(t));
}
function printerFitsFile(p, file, override) {
if (!p || !file) return false;
if (sel.tab === "ablauf" && p.family !== "kobra_3") return false;
if (familyMismatch(p, file) && !override) return false;
if (!materialFitsHost(file, p.host)) return false;
return true;
}
function printerPickScore(p, file) {
let score = 0;
if (canStartNow(p)) score += 100;
else if (isFree(p)) score += 50;
else if (p.needs_clear) score += 20;
if (spoolsOnHost(p.host).length) score += 10;
if (file && file.printer_family && p.family === file.printer_family) score += 5;
return score;
}
function suggestHostsForFile(file) {
if (!file) return new Set();
const override = !!(document.getElementById("send-override") || {}).checked;
const kept = [...sel.hosts].filter((host) => {
const p = printers.find((x) => x.host === host);
return printerFitsFile(p, file, override);
});
if (kept.length) return new Set(kept);
const candidates = printers.filter((p) => printerFitsFile(p, file, override));
candidates.sort((a, b) => printerPickScore(b, file) - printerPickScore(a, file)
|| String(a.name || "").localeCompare(String(b.name || ""), "de"));
if (!candidates.length) return new Set();
return new Set([candidates[0].host]);
}
function noPrinterReason(file) {
if (!file || !printers.length) return "";
const override = !!(document.getElementById("send-override") || {}).checked;
const familyOk = printers.filter((p) => {
if (sel.tab === "ablauf" && p.family !== "kobra_3") return false;
return !familyMismatch(p, file) || override;
});
if (!familyOk.length) {
const want = families[file.printer_family] || file.printer_family || "diese Maschine";
return "Kein Drucker für " + want + ".";
}
if (familyOk.some((p) => materialFitsHost(file, p.host))) return "";
const need = fileProfilesOf(file).join("/") || fileTypesOf(file).join("/") || "Material";
return "Keine passende Rolle am Drucker (" + need + "). Unter Material zuweisen.";
}
function targetsHtml() {
const file = selectedFile();
const override = document.getElementById("send-override").checked;
const nextMode = sel.tab === "send" && sel.mode === "next";
box.classList.toggle("dimmed", nextMode);
box.innerHTML = printers.map((p) => {
return printers.map((p) => {
const s = targetState(p);
const mismatch = familyMismatch(p, file);
const notK3 = sel.tab === "ablauf" && p.family !== "kobra_3";
@@ -1005,6 +1092,17 @@ function renderTargets() {
<span class="row2 ${s.bad || off ? "bad" : ""}">${escapeHtml(why)}</span>
</button>`;
}).join("") || '<div class="picked-empty">' + icon("printer") + '<span>Noch kein Drucker.</span><span class="tiny"><a href="/einstellungen">In Einstellungen anlegen</a></span></div>';
}
function renderTargets() {
const box = document.getElementById("targets");
const nextMode = sel.tab === "send" && sel.mode === "next";
box.classList.toggle("dimmed", nextMode);
const html = targetsHtml();
if (box._html !== html) {
box._html = html;
box.innerHTML = html;
}
document.getElementById("step-target").classList.toggle("ok", nextMode || selectedHosts().length > 0);
}
@@ -1060,7 +1158,12 @@ function updateSendButton() {
button.disabled = !!problem || !!sendPoll;
hint.textContent = problem || note;
hint.classList.toggle("bad", !!problem);
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti nach Sorte." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
let targetNote = "";
if (sel.mode === "next" && sel.tab === "send") targetNote = "Ziel wählt Grotti nach Sorte.";
else if (chosen.length > 1) targetNote = "Je Drucker ein eigener Auftrag.";
else if (file && !chosen.length && sel.mode === "this") targetNote = noPrinterReason(file);
targetHint.textContent = targetNote;
targetHint.classList.toggle("bad", !!(file && !chosen.length && sel.mode === "this" && targetNote));
}
function updateAblaufButton() {
@@ -1082,12 +1185,33 @@ function updateAblaufButton() {
hint.classList.toggle("bad", !!problem);
}
function syncSendOptions() {
const chosen = selectedPrinters();
const moonraker = chosen.some((p) => MOONRAKER_FAMILIES.has(p.family));
const marlin = chosen.some((p) => MARLIN_FAMILIES.has(p.family));
const bedWrap = document.getElementById("send-bed-level-wrap");
const marlinWrap = document.getElementById("send-marlin-wrap");
const marlinHint = document.getElementById("send-marlin-hint");
if (bedWrap) {
bedWrap.hidden = chosen.length > 0 && !moonraker;
if (bedWrap.hidden) document.getElementById("send-bed-level").checked = false;
}
if (marlinWrap) marlinWrap.hidden = !marlin;
if (marlinHint) marlinHint.hidden = !marlin;
}
function updateOptionsSummary() {
syncSendOptions();
const parts = [];
const hosts = selectedHosts();
const loaded = hosts.reduce((n, h) => n + spoolsOnHost(h).length, 0);
if (loaded) parts.push(loaded + (loaded === 1 ? " Spule" : " Spulen"));
if (document.getElementById("send-bed-level").checked) parts.push("Bettnetz");
if (!document.getElementById("send-bed-level-wrap")?.hidden && document.getElementById("send-bed-level").checked) {
parts.push("Bettnetz");
}
if (!document.getElementById("send-marlin-wrap")?.hidden) {
parts.push(document.getElementById("send-marlin-mode").value === "sd" ? "SD-Karte" : "Strom");
}
if (document.getElementById("send-override").checked) parts.push("Maschine egal");
const project = projects.find((p) => String(p.id) === document.getElementById("send-project")?.value);
if (project) parts.push(project.name);
@@ -1116,7 +1240,9 @@ function refreshFlow() {
updateAblaufButton();
}
function selectFile(id) {
function selectFile(id, opts) {
const keepHosts = !!(opts && opts.keepHosts);
const fromUrlHost = !!(opts && opts.fromUrlHost);
sel.fileId = id;
const file = selectedFile();
if (file && file.project_id && document.getElementById("send-project")) {
@@ -1127,6 +1253,10 @@ function selectFile(id) {
saveAblaufMemory();
if (sel.tab === "ablauf") renderAblauf();
}
if (file && !keepHosts && !fromUrlHost && !(sel.tab === "send" && sel.mode === "next")) {
sel.hosts = suggestHostsForFile(file);
saveSendMemory();
}
document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id)));
refreshFlow();
scheduleToolpath(file, 0, 0);
@@ -1170,12 +1300,17 @@ async function load() {
fillProjectFilter();
fillMaterialFilter();
fillFolderFilter();
const deepProject = new URLSearchParams(location.search).get("project");
const deepParams = new URLSearchParams(location.search);
const deepProject = deepParams.get("project");
if (deepProject) {
const pf = document.getElementById("file-filter-project");
if (pf && [...pf.options].some((o) => o.value === deepProject)) pf.value = deepProject;
}
releaseRootForProject();
// Beim Laden: gemerkten Drucker nur behalten, wenn er zur Datei passt — außer Deep-Link ?host=
if (selectedFile() && !deepParams.get("host") && !(sel.tab === "send" && sel.mode === "next")) {
sel.hosts = suggestHostsForFile(selectedFile());
}
renderLibrary();
renderTabs();
refreshFlow();
@@ -1323,18 +1458,22 @@ async function watchJobs(ids) {
}
async function postSenden(hosts, whenFree) {
return api("/senden", {
method: "POST",
json: {
file_id: Number(sel.fileId),
hosts,
confirm: true,
override_machine: document.getElementById("send-override").checked,
bed_leveling: document.getElementById("send-bed-level").checked,
ace_maps: collectAceMaps(),
when_free: whenFree,
},
});
syncSendOptions();
const marlinOn = !document.getElementById("send-marlin-wrap")?.hidden;
const payload = {
file_id: Number(sel.fileId),
hosts,
confirm: true,
override_machine: document.getElementById("send-override").checked,
bed_leveling: !document.getElementById("send-bed-level-wrap")?.hidden
&& document.getElementById("send-bed-level").checked,
ace_maps: collectAceMaps(),
when_free: whenFree,
};
if (marlinOn && !whenFree) {
payload.marlin_mode = document.getElementById("send-marlin-mode").value || "stream";
}
return api("/senden", { method: "POST", json: payload });
}
async function doSend() {
@@ -1760,6 +1899,7 @@ document.querySelector(".tabs").addEventListener("click", (event) => {
});
document.getElementById("send-bed-level").addEventListener("change", updateOptionsSummary);
document.getElementById("send-marlin-mode")?.addEventListener("change", updateOptionsSummary);
document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); });
document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary);
@@ -1863,6 +2003,7 @@ document.getElementById("rescan").addEventListener("click", async () => {
});
window.onQueueChanged = refreshPrinters;
window.onDruckerLive = refreshPrinters;
loadSendMemory();
loadAblaufMemory();
load().catch((error) => toast(error.message || String(error), "bad"));
+223 -4
View File
@@ -14,7 +14,9 @@
<a href="#sec-auto"><svg class="ico"><use href="#i-zap"/></svg>Automatik</a>
<a href="#sec-slice" class="admin-only"><svg class="ico"><use href="#i-folder"/></svg>Slice</a>
<a href="#sec-fqdn" class="admin-only"><svg class="ico"><use href="#i-link"/></svg>Adresse</a>
<a href="#sec-etikett" class="admin-only"><svg class="ico"><use href="#i-tag"/></svg>Etiketten</a>
<a href="#sec-drucker" class="admin-only"><svg class="ico"><use href="#i-printer"/></svg>Drucker</a>
<a href="#sec-meldungen" class="admin-only"><svg class="ico"><use href="#i-alert"/></svg>Meldungen</a>
<a href="#sec-telegram" class="admin-only"><svg class="ico"><use href="#i-message"/></svg>Telegram</a>
<a href="#sec-ntfy" class="admin-only"><svg class="ico"><use href="#i-bell"/></svg>ntfy</a>
<a href="#sec-backup" class="admin-only"><svg class="ico"><use href="#i-hard-drive"/></svg>Backup</a>
@@ -100,9 +102,34 @@
<p id="fqdn-note"></p>
</section>
<section class="card admin-only" id="sec-etikett">
<h2>Etiketten</h2>
<p class="hint">Papierformat für Spulen- und Drucketiketten (Browser-Druck). Standard 70×40 mm. Der QR schrumpft auf flacheren Rollen mit.</p>
<div class="field" style="max-width:16rem">
<label for="etikett-preset">Voreinstellung</label>
<select id="etikett-preset">
<option value="">Eigene Maße</option>
</select>
</div>
<div class="grid-2" style="max-width:20rem">
<div class="field">
<label for="etikett-w">Breite (mm)</label>
<input id="etikett-w" type="number" min="20" max="200" step="0.1" value="70">
</div>
<div class="field">
<label for="etikett-h">Höhe (mm)</label>
<input id="etikett-h" type="number" min="15" max="200" step="0.1" value="40">
</div>
</div>
<div class="actions"><button type="button" id="etikett-save" hidden>Speichern</button></div>
<p id="etikett-note"></p>
</section>
<section class="card admin-only" id="sec-drucker">
<h2>Drucker</h2>
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom). Tippen öffnet den Dialog.</p>
<p class="hint">USB (Anycubic i3, Tenlog): Schnittstelle statt IP, zum Beispiel <code>COM5</code> oder <code>COM5@250000</code>. Grotti startet den USB-Dienst dazu selbst und lässt ihn bei einem Neustart weiterlaufen.</p>
<p class="hint" id="marlin-line" hidden></p>
<div id="printer-list" class="printer-edit-cards"></div>
<form id="printer-add" class="grid-2 section-gap">
<div class="field">
@@ -110,7 +137,7 @@
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
</div>
<div class="field">
<label for="add-host">Host / IP</label>
<label for="add-host" id="add-host-label">Host / IP</label>
<input id="add-host" required placeholder="10.10.1.60">
</div>
<div class="field">
@@ -119,6 +146,9 @@
<option value="">automatisch</option>
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
<option value="kobra_4">Kobra 4</option>
<option value="anycubic_i3">Anycubic i3</option>
<option value="tenlog">Tenlog</option>
</select>
</div>
<div class="actions" style="align-self:end"><button type="submit">Hinzufügen</button></div>
@@ -126,6 +156,13 @@
<p id="printer-note"></p>
</section>
<section class="card admin-only" id="sec-meldungen">
<h2>Problem-Meldungen</h2>
<p class="hint">Kopien von „Problem melden“. Offene Einträge hier erledigen. Issues landen beim Entwickler über den Empfänger auf git.gundel.dev.</p>
<div id="meldung-list" class="printer-edit-cards"></div>
<p id="meldung-admin-note" class="tiny"></p>
</section>
<section class="card admin-only" id="sec-telegram">
<h2>Telegram</h2>
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
@@ -272,11 +309,14 @@
<input type="hidden" id="pe-id">
<div class="grid-2">
<div class="field"><label for="pe-name">Name</label><input id="pe-name" required></div>
<div class="field"><label for="pe-host">Host / IP</label><input id="pe-host" required></div>
<div class="field"><label for="pe-host" id="pe-host-label">Host / IP</label><input id="pe-host" required placeholder="10.10.1.60"></div>
<div class="field"><label for="pe-family">Familie</label>
<select id="pe-family">
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
<option value="kobra_4">Kobra 4</option>
<option value="anycubic_i3">Anycubic i3</option>
<option value="tenlog">Tenlog</option>
<option value="">—</option>
</select>
</div>
@@ -299,7 +339,18 @@
{% block script %}
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
<script>
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
const families = {
kobra_3: "Kobra 3", kobra_s1: "Kobra S1", kobra_4: "Kobra 4",
anycubic_i3: "Anycubic i3", tenlog: "Tenlog", "": "—",
};
const USB_FAMILIES = new Set(["anycubic_i3", "tenlog"]);
function syncHostField(family, labelEl, inputEl, nameHint) {
const usb = USB_FAMILIES.has(family)
|| (!family && /tenlog|i3/i.test(nameHint || ""));
if (labelEl) labelEl.textContent = usb ? "Schnittstelle" : "Host / IP";
if (inputEl) inputEl.placeholder = usb ? "COM5 oder COM5@250000" : "10.10.1.60";
}
const isAdmin = document.querySelector('meta[name="user-role"]')?.content === "admin";
document.querySelectorAll(".admin-only").forEach((el) => { el.hidden = !isAdmin; });
if (!isAdmin) {
@@ -333,6 +384,7 @@ function openPrinterDialog(row) {
document.getElementById("pe-name").value = row.name || "";
document.getElementById("pe-host").value = row.host || "";
document.getElementById("pe-family").value = row.family || "";
syncHostField(row.family || "", document.getElementById("pe-host-label"), document.getElementById("pe-host"), row.name || "");
document.getElementById("pe-locked").checked = !!row.locked;
document.getElementById("pe-win-on").checked = win.enabled;
document.getElementById("pe-win-from").value = win.from;
@@ -455,6 +507,62 @@ function renderTokens(rows) {
});
}
async function loadMeldungen() {
const root = document.getElementById("meldung-list");
const note = document.getElementById("meldung-admin-note");
if (!root || !isAdmin) return;
try {
const rows = await api("/meldungen?status=offen");
if (!rows.length) {
root.innerHTML = '<p class="tiny">Keine offenen Meldungen.</p>';
return;
}
root.innerHTML = rows.map((row) => `
<article class="printer-edit-card">
<div class="grow">
<strong>#${row.id}</strong>
<span class="tiny">${escapeHtml((row.created_at || "").replace("T", " ").slice(0, 16))} · ${escapeHtml(row.username || "—")} · ${escapeHtml(row.page || "")}</span>
<p>${escapeHtml(row.text || "")}</p>
<p class="tiny">${row.relay_ok ? "Beim Entwickler angekommen" : ("Lokal: " + escapeHtml(row.relay_message || "Empfänger fehlte"))}</p>
</div>
<button type="button" class="ghost" data-meldung-done="${row.id}">Erledigt</button>
</article>`).join("");
root.querySelectorAll("[data-meldung-done]").forEach((button) => {
button.addEventListener("click", async () => {
button.disabled = true;
try {
await api("/meldungen/" + button.dataset.meldungDone + "/erledigt", { method: "POST", json: {} });
loadMeldungen();
} catch (error) {
if (note) note.textContent = error.message || String(error);
}
});
});
} catch (error) {
if (note) note.textContent = error.message || String(error);
}
}
async function loadMarlin(printers) {
const line = document.getElementById("marlin-line");
if (!line) return;
const usb = (printers || []).some((row) => row.family === "tenlog" || row.family === "anycubic_i3");
if (!usb) {
line.hidden = true;
return;
}
try {
const status = await api("/marlin-dienst");
line.hidden = false;
line.textContent = status.running
? "USB-Dienst läuft."
: (status.message || "USB-Dienst ist aus.");
} catch (err) {
line.hidden = false;
line.textContent = "USB-Dienst: " + (err.message || "keine Antwort");
}
}
async function loadUsers() {
if (!isAdmin) return;
renderUsers(await api("/users"));
@@ -469,7 +577,11 @@ async function load() {
api("/einstellungen"),
api("/drucker?live=0"),
]);
if (isAdmin) renderPrinters(printers);
if (isAdmin) {
renderPrinters(printers);
loadMarlin(printers);
loadMeldungen();
}
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
const ar = document.getElementById("auto-retry");
if (ar) ar.checked = settings.auto_retry !== false;
@@ -480,6 +592,7 @@ async function load() {
}
const fqdn = document.getElementById("fqdn");
if (fqdn) fqdn.value = settings.fqdn || "";
fillEtikett(settings.etikett || {});
const tgEnabled = document.getElementById("tg-enabled");
if (tgEnabled) {
tgEnabled.checked = !!(settings.telegram && settings.telegram.enabled);
@@ -643,6 +756,109 @@ document.getElementById("fqdn-save").addEventListener("click", async () => {
}
});
let etikettPresets = [];
function fillEtikett(etikett) {
const w = document.getElementById("etikett-w");
const h = document.getElementById("etikett-h");
const preset = document.getElementById("etikett-preset");
if (!w || !h || !preset) return;
etikettPresets = Array.isArray(etikett.presets) ? etikett.presets : etikettPresets;
const width = Number(etikett.width_mm) || 70;
const height = Number(etikett.height_mm) || 40;
w.value = String(width);
h.value = String(height);
const current = preset.value;
preset.innerHTML = '<option value="">Eigene Maße</option>';
etikettPresets.forEach((row) => {
const option = document.createElement("option");
option.value = row.id;
option.textContent = row.label;
option.dataset.w = String(row.width_mm);
option.dataset.h = String(row.height_mm);
preset.appendChild(option);
});
const hit = etikettPresets.find(
(row) => Number(row.width_mm) === width && Number(row.height_mm) === height
);
preset.value = hit ? hit.id : (current && [...preset.options].some((o) => o.value === current) ? current : "");
}
function syncEtikettPresetFromFields() {
const preset = document.getElementById("etikett-preset");
const width = Number(document.getElementById("etikett-w").value);
const height = Number(document.getElementById("etikett-h").value);
const hit = etikettPresets.find(
(row) => Number(row.width_mm) === width && Number(row.height_mm) === height
);
if (preset) preset.value = hit ? hit.id : "";
}
document.getElementById("etikett-preset")?.addEventListener("change", () => {
const preset = document.getElementById("etikett-preset");
const option = preset.selectedOptions[0];
if (!option || !option.value) return;
document.getElementById("etikett-w").value = option.dataset.w || "70";
document.getElementById("etikett-h").value = option.dataset.h || "40";
document.getElementById("etikett-save")?.click();
});
["etikett-w", "etikett-h"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", () => {
syncEtikettPresetFromFields();
});
});
document.getElementById("etikett-save").addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
method: "PATCH",
json: {
etikett: {
width_mm: Number(document.getElementById("etikett-w").value) || 70,
height_mm: Number(document.getElementById("etikett-h").value) || 40,
},
},
});
fillEtikett(out.etikett || {});
const e = out.etikett || {};
showNote(
"etikett-note",
null,
"Gespeichert — " + (e.width_mm || 70) + " × " + (e.height_mm || 40) + " mm (QR " + (e.qr_mm || 20) + " mm)."
);
} catch (error) {
showNote("etikett-note", error);
}
});
function syncAddHost() {
syncHostField(
document.getElementById("add-family").value,
document.getElementById("add-host-label"),
document.getElementById("add-host"),
document.getElementById("add-name").value,
);
}
document.getElementById("add-family")?.addEventListener("change", syncAddHost);
document.getElementById("add-name")?.addEventListener("input", syncAddHost);
document.getElementById("pe-family")?.addEventListener("change", () => {
syncHostField(
document.getElementById("pe-family").value,
document.getElementById("pe-host-label"),
document.getElementById("pe-host"),
document.getElementById("pe-name").value,
);
});
document.getElementById("pe-name")?.addEventListener("input", () => {
syncHostField(
document.getElementById("pe-family").value,
document.getElementById("pe-host-label"),
document.getElementById("pe-host"),
document.getElementById("pe-name").value,
);
});
document.getElementById("printer-add").addEventListener("submit", async (event) => {
event.preventDefault();
try {
@@ -976,6 +1192,9 @@ document.getElementById("auto-retry")?.addEventListener("change", wireAutosave("
document.getElementById(id)?.addEventListener("change", wireAutosave("watch-save", "watch-note"));
});
document.getElementById("fqdn")?.addEventListener("change", wireAutosave("fqdn-save", "fqdn-note"));
["etikett-w", "etikett-h"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("etikett-save", "etikett-note"));
});
["tg-enabled", "tg-token", "tg-chat", "al-fertig", "al-fehler", "al-offline", "al-queue", "al-ablauf", "al-start"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("tg-save", "tg-note"));
});
+4 -86
View File
@@ -4,90 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
color: #111;
background: #f3f3f3;
}
.toolbar {
display: flex;
gap: 0.6rem;
padding: 0.8rem 1rem;
background: #1a1a1a;
color: #eee;
}
.toolbar a, .toolbar button {
color: #111;
background: #f97316;
border: 0;
border-radius: 8px;
padding: 0.4rem 0.85rem;
font: inherit;
font-weight: 650;
text-decoration: none;
cursor: pointer;
}
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
.sheet {
width: 70mm;
min-height: 40mm;
margin: 1.2rem auto;
padding: 3.5mm;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
display: grid;
grid-template-columns: 1fr 20mm;
gap: 2.5mm;
align-items: center;
overflow: hidden;
}
.meta { min-width: 0; }
.meta h1 {
margin: 0 0 2mm;
font-size: 4.2mm;
letter-spacing: -0.02em;
overflow-wrap: anywhere;
}
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
.dots { display: inline-flex; vertical-align: middle; margin-right: 1.5mm; }
.dot {
width: 3.2mm;
height: 3.2mm;
border-radius: 50%;
border: 0.2mm solid rgba(0,0,0,0.25);
margin-left: -1mm;
}
.dot:first-child { margin-left: 0; }
.qr-wrap {
width: 20mm;
height: 20mm;
overflow: hidden;
flex-shrink: 0;
}
.qr-wrap canvas {
display: block;
width: 20mm !important;
height: 20mm !important;
max-width: 100%;
max-height: 100%;
}
@media print {
body { background: #fff; }
.toolbar { display: none; }
.sheet {
margin: 0;
border: 0;
border-radius: 0;
page-break-inside: avoid;
}
}
</style>
{% include "_etikett_style.html" %}
</head>
<body>
<div class="toolbar">
@@ -120,14 +37,15 @@
</div>
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
<script>
const qrMm = {{ (etikett.qr_mm if etikett is defined else 20)|tojson }};
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
width: 80,
margin: 1,
color: { dark: "#111111", light: "#ffffff" }
}, () => {
const node = document.getElementById("qr");
node.style.width = "20mm";
node.style.height = "20mm";
node.style.width = qrMm + "mm";
node.style.height = qrMm + "mm";
});
</script>
</body>
+5 -77
View File
@@ -4,83 +4,10 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
{% include "_etikett_style.html" %}
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
color: #111;
background: #f3f3f3;
}
.toolbar {
display: flex;
gap: 0.6rem;
padding: 0.8rem 1rem;
background: #1a1a1a;
color: #eee;
}
.toolbar a, .toolbar button {
color: #111;
background: #f97316;
border: 0;
border-radius: 8px;
padding: 0.4rem 0.85rem;
font: inherit;
font-weight: 650;
text-decoration: none;
cursor: pointer;
}
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
.sheet {
width: 70mm;
min-height: 40mm;
margin: 1.2rem auto;
padding: 3.5mm;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
display: grid;
grid-template-columns: 1fr 20mm;
gap: 2.5mm;
align-items: center;
overflow: hidden;
page-break-after: always;
}
.sheet { page-break-after: always; }
.sheet:last-child { page-break-after: auto; }
.meta { min-width: 0; }
.meta h1 {
margin: 0 0 2mm;
font-size: 4.2mm;
letter-spacing: -0.02em;
overflow-wrap: anywhere;
}
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
.qr-wrap {
width: 20mm;
height: 20mm;
overflow: hidden;
flex-shrink: 0;
}
.qr-wrap canvas,
.qr-wrap img {
display: block;
width: 20mm !important;
height: 20mm !important;
max-width: 100%;
max-height: 100%;
}
@media print {
body { background: #fff; }
.toolbar { display: none; }
.sheet {
margin: 0;
border: 0;
border-radius: 0;
page-break-inside: avoid;
}
}
</style>
</head>
<body>
@@ -111,14 +38,15 @@
{% endfor %}
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
<script>
const qrMm = {{ (etikett.qr_mm if etikett is defined else 20)|tojson }};
document.querySelectorAll("canvas.qr").forEach((node) => {
QRCode.toCanvas(node, node.dataset.url, {
width: 80,
margin: 1,
color: { dark: "#111111", light: "#ffffff" }
}, () => {
node.style.width = "20mm";
node.style.height = "20mm";
node.style.width = qrMm + "mm";
node.style.height = qrMm + "mm";
});
});
</script>
+92 -1
View File
@@ -107,6 +107,7 @@
{% endif %}
</div>
<div class="topbar-tools">
<button type="button" class="icon-btn" id="meldung-open" title="Problem melden" aria-label="Problem melden"><svg class="ico"><use href="#i-alert"/></svg></button>
<a class="icon-btn {{ 'on' if page == 'einstellungen' else '' }}" href="/einstellungen" title="Einstellungen" aria-label="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg></a>
<a class="icon-btn {{ 'on' if page == 'doku' else '' }}" href="/doku" title="Doku" aria-label="Doku"><svg class="ico"><use href="#i-book"/></svg></a>
{% if current_user %}
@@ -163,6 +164,22 @@
{% endif %}
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
</dialog>
<dialog id="meldung-dlg" class="abraeumen-dlg">
<form method="dialog" id="meldung-form" class="abraeumen-form">
<h2>Problem melden</h2>
<p class="tiny">Kurz beschreiben. Grotti hängt Version und Seite an und speichert eine Kopie hier. Zusätzlich geht die Meldung an den Entwickler.</p>
<div class="field">
<label for="meldung-text">Was stimmt nicht?</label>
<textarea id="meldung-text" rows="5" required maxlength="4000" placeholder="z. B. Start auf dem Tenlog bricht ab…"></textarea>
</div>
<p class="tiny" id="meldung-error-hint" hidden></p>
<div class="actions">
<button type="submit" class="btn-primary" id="meldung-send">Senden</button>
<button type="button" class="ghost" id="meldung-cancel">Abbrechen</button>
</div>
<p id="meldung-note" class="tiny"></p>
</form>
</dialog>
<script src="{{ url_for('static', filename='api.js') }}?v=33"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=6"></script>
<script>
@@ -172,6 +189,80 @@ async function doLogout() {
}
document.getElementById("logout-btn")?.addEventListener("click", doLogout);
document.getElementById("logout-btn-m")?.addEventListener("click", doLogout);
(function meldungUi() {
const dlg = document.getElementById("meldung-dlg");
const form = document.getElementById("meldung-form");
const text = document.getElementById("meldung-text");
const errHint = document.getElementById("meldung-error-hint");
const note = document.getElementById("meldung-note");
if (!dlg || !form) return;
let lastError = "";
function openMeldung(prefill) {
note.textContent = "";
note.className = "tiny";
if (prefill) text.value = prefill;
if (lastError) {
errHint.hidden = false;
errHint.textContent = "Letzter Fehler: " + lastError.slice(0, 240);
} else {
errHint.hidden = true;
errHint.textContent = "";
}
dlg.showModal();
text.focus();
}
window.openMeldung = openMeldung;
window.rememberUiError = (message) => {
lastError = String(message || "").slice(0, 2000);
};
document.getElementById("meldung-open")?.addEventListener("click", () => openMeldung(""));
document.getElementById("meldung-cancel")?.addEventListener("click", () => dlg.close());
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = document.getElementById("meldung-send");
button.disabled = true;
try {
const desk = document.querySelector("#desk[data-host]:not([hidden])");
const host = desk?.dataset?.host || "";
const row = await api("/meldungen", {
method: "POST",
json: {
text: text.value,
page: location.pathname + location.search + location.hash,
printer_host: host,
error_text: lastError,
},
});
if (row.relay_ok) {
toast("Meldung gespeichert und an den Entwickler geschickt.", "good");
} else {
toast("Lokal gespeichert. Beim Entwickler nicht angekommen: " + (row.relay_message || ""), "bad");
}
text.value = "";
lastError = "";
dlg.close();
} catch (error) {
note.textContent = error.message || String(error);
note.className = "tiny msg";
} finally {
button.disabled = false;
}
});
window.addEventListener("error", (event) => {
const message = event?.message || String(event?.error || "");
if (message) lastError = message.slice(0, 2000);
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event?.reason;
const message = reason && reason.message ? reason.message : String(reason || "");
if (message) lastError = message.slice(0, 2000);
});
});
(function userMenuClose() {
const menu = document.getElementById("user-menu");
if (!menu) return;
@@ -624,8 +715,8 @@ refreshJetzt();
aufgaben: () => refreshJetzt(),
drucker: () => {
refreshJetzt();
// Nur onDruckerLive — sonst zeichnen Wand und Dateien doppelt.
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
},
};
+52 -5
View File
@@ -270,12 +270,58 @@ function dots(colors) {
return wrap;
}
function slotLabel(slot) {
function printerFamily(host) {
return (printers.find((p) => p.host === host) || {}).family || "";
}
function slotLabel(slot, host) {
if (!slot) return "";
const family = printerFamily(host);
if (family === "tenlog") return "Kopf " + slot;
if (family === "anycubic_i3") return "am Drucker";
if (slot <= 4) return "ACE 1 · " + slot;
return "ACE 2 · " + (slot - 4);
}
function fillSlotOptions(host, keep) {
const select = document.getElementById("spool-slot");
const label = document.querySelector('label[for="spool-slot"]');
const current = keep != null ? String(keep) : select.value;
const family = printerFamily(host);
let options = [['', 'nicht im ACE']];
let tip = "ACE 1 = Plätze 1–4, ACE 2 = 5–8. Leer = externe Spule am Drucker. Ohne Drucker = Regal.";
if (!host) {
options = [['', 'Regal — kein Platz']];
tip = "Ohne Drucker liegt die Rolle im Regal.";
} else if (family === "tenlog") {
options = [['', 'kein Kopf'], ['1', 'Kopf 1'], ['2', 'Kopf 2']];
tip = "Tenlog: Kopf 1 oder Kopf 2. Leer = Rolle am Drucker ohne festen Kopf.";
} else if (family === "anycubic_i3") {
options = [['', 'am Drucker']];
tip = "Anycubic i3 hat keinen ACE — die Rolle liegt am Drucker.";
} else if (["kobra_3", "kobra_s1", "kobra_4"].includes(family)) {
options = [
['', 'nicht im ACE'],
['1', 'ACE 1 · Platz 1'], ['2', 'ACE 1 · Platz 2'], ['3', 'ACE 1 · Platz 3'], ['4', 'ACE 1 · Platz 4'],
['5', 'ACE 2 · Platz 1'], ['6', 'ACE 2 · Platz 2'], ['7', 'ACE 2 · Platz 3'], ['8', 'ACE 2 · Platz 4'],
];
} else {
options = [['', 'am Drucker']];
tip = "Platz nur bei ACE-Druckern.";
}
select.innerHTML = options.map(([value, text]) =>
`<option value="${escapeHtml(value)}">${escapeHtml(text)}</option>`
).join("");
if ([...select.options].some((o) => o.value === current)) select.value = current;
else select.value = "";
if (label) {
const tipNode = label.querySelector(".tip");
if (tipNode) tipNode.title = tip;
const title = family === "tenlog" ? "Kopf" : family === "anycubic_i3" ? "Lage" : "ACE-Platz";
label.childNodes[0].textContent = title + " ";
}
}
function printerName(host) {
return (printers.find((p) => p.host === host) || {}).name || host || "";
}
@@ -283,7 +329,7 @@ function printerName(host) {
function ortLabel(spool) {
if (!spool.printer_host) return "Regal";
const name = printerName(spool.printer_host);
if (spool.ace_slot) return name + " · " + slotLabel(spool.ace_slot);
if (spool.ace_slot) return name + " · " + slotLabel(spool.ace_slot, spool.printer_host);
return name + " · extern";
}
@@ -556,7 +602,7 @@ function fill(spool) {
document.getElementById("spool-chargen").value = spool ? (spool.chargen_nr || spool.code || "") : "";
document.getElementById("spool-hersteller").value = spool ? (spool.hersteller_charge || "") : "";
document.getElementById("spool-printer").value = spool ? spool.printer_host : "";
document.getElementById("spool-slot").value = spool && spool.ace_slot ? String(spool.ace_slot) : "";
fillSlotOptions(spool ? spool.printer_host : "", spool && spool.ace_slot ? String(spool.ace_slot) : "");
document.getElementById("spool-price").value = spool ? spool.price_per_kg : 0;
document.getElementById("spool-left").value = spool
? Math.round((Number(spool.remaining_g) || 0) * 10) / 10
@@ -676,6 +722,7 @@ function fillSelects() {
select.appendChild(option);
});
select.value = current;
fillSlotOptions(select.value);
const materialSelect = document.getElementById("spool-material");
const currentMaterial = materialSelect.value;
@@ -905,6 +952,7 @@ document.getElementById("sorten-close").addEventListener("click", closeSortenPan
document.getElementById("spool-material").addEventListener("change", () => applyMaterial(selectedMaterial(), true));
document.getElementById("spool-printer").addEventListener("change", () => {
fillSlotOptions(document.getElementById("spool-printer").value);
const material = selectedMaterial();
if (material) document.getElementById("spool-vol").value = volumeOf(material);
showHint();
@@ -1134,8 +1182,7 @@ load().then(() => {
document.getElementById("spool-new")?.click?.();
const hostField = document.getElementById("spool-printer");
if (hostField) hostField.value = hostHint;
const slotField = document.getElementById("spool-slot");
if (slotField && slotHint) slotField.value = String(slotHint);
fillSlotOptions(hostHint, slotHint ? String(slotHint) : "");
document.getElementById("spool-name")?.scrollIntoView({ behavior: "smooth", block: "center" });
toast("Drucker und Platz vorausgefüllt — Rolle wählen und speichern.");
}
+21 -6
View File
@@ -81,11 +81,26 @@ function shortRunName(filename) {
return name || (filename || "").split("/").pop() || "";
}
function render() {
let lastRunsKey = "";
function runsKey(rows) {
return (rows || []).map((run) => [
run.id, run.state, run.duration_s, run.pause_s, run.weight_used_g,
run.verdict, run.spool_id, run.merge_with_id, run.chargen_nr,
].join(":")).join("|");
}
function render(force) {
fillPrinterFilter();
const body = document.getElementById("run-rows");
body.innerHTML = "";
const shown = filteredRuns();
const key = runsKey(shown) + "|" + runPage + "|"
+ (document.getElementById("run-filter-printer")?.value || "")
+ "|" + (document.getElementById("run-filter-verdict")?.value || "")
+ "|" + (document.getElementById("run-filter-spool")?.value || "");
if (!force && key === lastRunsKey && body.children.length) return;
lastRunsKey = key;
body.innerHTML = "";
if (!runs.length) {
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>';
renderPager(document.getElementById("run-pager"), null, () => {});
@@ -324,13 +339,13 @@ function render() {
});
}
async function load() {
async function load(resetPage) {
[runs, spools] = await Promise.all([api("/laeufe?limit=500"), api("/spulen")]);
runPage = 1;
if (resetPage !== false) runPage = 1;
render();
}
window.onDruckerLive = () => { load().catch(() => {}); };
window.onQueueChanged = () => { load().catch(() => {}); };
window.onDruckerLive = () => { load(false).catch(() => {}); };
window.onQueueChanged = () => { load(false).catch(() => {}); };
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); });
+20 -7
View File
@@ -266,7 +266,13 @@ function tempbarHtml(label, value, target, scale, iconName) {
function tempbarsHtml(printer, state) {
if (state === "offline") return "";
const nozzle = tempbarHtml("Düse", printer.nozzle, printer.nozzle_target, 300, "nozzle");
const tools = Array.isArray(printer.tools) ? printer.tools : [];
const nozzle = tools.length > 1
? tools.map((tool) => tempbarHtml(
tool.name || ("Kopf " + (Number(tool.index) + 1)),
tool.temp, tool.target, 300, "nozzle",
)).join("")
: tempbarHtml("Düse", printer.nozzle, printer.nozzle_target, 300, "nozzle");
const bed = tempbarHtml("Bett", printer.bed, printer.bed_target, 110, "bed");
const chamber = tempbarHtml("Bauraum", printer.chamber, printer.chamber_target, 80, "chamber");
if (!nozzle && !bed && !chamber) return "";
@@ -443,8 +449,10 @@ function spoolHex(spool) {
return "";
}
function aceSlotLabel(slot) {
function aceSlotLabel(slot, family) {
const n = Number(slot);
if (family === "tenlog") return "Kopf " + n;
if (family === "anycubic_i3") return "am Drucker";
if (n <= 4) return "ACE 1 · " + n;
return "ACE 2 · " + (n - 4);
}
@@ -489,7 +497,7 @@ function aceSteerHtml(printer) {
const kind = spool.material_type || "—";
bits.push(`<div class="steer-slot-row">
<span class="dot" style="background:${escapeHtml(hex)}"></span>
<span class="grow"><b>${escapeHtml(aceSlotLabel(slot))}</b> · ${escapeHtml(kind)} · ${escapeHtml(spool.name || "")}</span>
<span class="grow"><b>${escapeHtml(aceSlotLabel(slot, printer.family))}</b> · ${escapeHtml(kind)} · ${escapeHtml(spool.name || "")}</span>
<button type="button" class="ghost" data-steer-go="slot_setzen" data-ace-slot="${slot}">an den Drucker</button>
</div>`);
});
@@ -499,6 +507,7 @@ function aceSteerHtml(printer) {
}
function steerPanelHtml(printer) {
if (printer.family === "tenlog" || printer.family === "anycubic_i3") return "";
const n = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
const b = printer.bed_target != null ? Math.round(printer.bed_target) : "";
const sp = typeof printer.speed_factor === "number" ? Math.round(printer.speed_factor * 100) : 100;
@@ -1590,8 +1599,8 @@ async function refresh() {
printerCache.clear();
for (const printer of all) printerCache.set(printer.host, printer);
deskHostsOrder = printers.map((p) => p.host);
document.getElementById("summary").innerHTML = summaryText(all) || "keine Drucker";
document.getElementById("when").textContent = new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
setHtml(document.getElementById("summary"), summaryText(all) || "keine Drucker");
setText(document.getElementById("when"), new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }));
if (!all.length) {
lastFleetKey = "";
@@ -1614,7 +1623,6 @@ async function refresh() {
bucket.appendChild(article);
} else {
patchCard(article, printer);
if (article.parentElement !== bucket) bucket.appendChild(article);
}
}
for (const [host, article] of [...cardNodes.entries()]) {
@@ -1628,9 +1636,14 @@ async function refresh() {
? document.querySelector("#cards [data-fleet]")
: document.querySelector(`#cards [data-fleet="${CSS.escape(section.label)}"]`);
if (!bucket) continue;
let marker = bucket.firstChild;
for (const printer of section.printers) {
const article = cardNodes.get(printer.host);
if (article) bucket.appendChild(article);
if (!article) continue;
if (article !== marker) {
bucket.insertBefore(article, marker);
}
marker = article.nextSibling;
}
}