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

+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"));
});