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

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