Files
grotti/web/templates/wand.html
T

1326 lines
52 KiB
HTML

{% extends "layout.html" %}
{% block body %}
<div class="page-head" id="wall-head">
<div>
<h1>Drucker</h1>
<div class="summary summary-chips" id="summary">lade …</div>
</div>
<div class="page-head-side">
<label class="check"><input type="checkbox" id="hide-offline"> Offline ausblenden</label>
<span id="when"></span>
</div>
</div>
<p class="msg" id="note"></p>
<div id="cards"><div class="fleet"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div></div>
<section class="desk no-cam" id="desk" hidden data-host="">
<header class="desk-bar">
<div class="desk-bar-top">
<button type="button" class="ghost desk-back" id="desk-back">← Drucker</button>
<div class="desk-title">
<h2 data-role="name"></h2>
<span data-role="badge"></span>
<span data-role="chip"></span>
</div>
<a class="desk-mainsail" data-role="mainsail" href="#" target="_blank" rel="noopener">Mainsail ↗</a>
</div>
<div class="desk-pills" data-role="pills"></div>
</header>
<div class="desk-stage">
<details class="desk-fold desk-cam-fold" data-fold="cam" open>
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Kamera</span><span class="tiny" data-role="cam-sum"></span></summary>
<div class="desk-cam" data-role="cam-stage">
<img class="desk-cam-img" data-role="camera" alt="Kamera" hidden>
<p class="desk-cam-empty" data-role="cam-empty"><svg class="ico"><use href="#i-camera"/></svg><span>Keine Kamera</span></p>
</div>
</details>
<aside class="desk-rail">
<div class="desk-rail-body">
<div data-role="hero"></div>
<div data-role="bar"></div>
<div class="stats" data-role="stats"></div>
<div data-role="temps"></div>
<div class="desk-job" data-role="jobline">
<img class="desk-thumb" data-role="preview" alt="" hidden>
<div class="desk-job-text">
<span class="name" data-role="job"></span>
<span class="swatches" data-role="swatches"></span>
</div>
</div>
<div class="desk-facts" data-role="facts"></div>
<div data-role="flow"></div>
<div data-role="strips"></div>
<details class="desk-fold desk-steer-fold" data-fold="steer" open>
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Steuerung</span></summary>
<div data-role="steer"></div>
</details>
</div>
<div class="desk-foot">
<div class="card-actions" data-role="controls"></div>
<button type="button" class="warn" data-role="estop" data-steer-go="emergency_stop" hidden>E-Stop</button>
</div>
</aside>
</div>
<div class="desk-tools">
<details class="desk-fold desk-tool" data-fold="path" open>
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">3D-Pfad</span><span class="tiny" data-role="path-caption"></span></summary>
<div class="desk-fold-body">
<p class="tiny">Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück</p>
<canvas class="gcode-canvas desk-canvas" data-role="path-canvas"></canvas>
<div class="exclude-panel" data-role="exclude-panel" hidden>
<div class="exclude-head">
<span class="tiny grow">Objekte ausschließen</span>
<span class="tiny" data-role="exclude-hint">Tippen wählt · zwei Klicks schließen aus</span>
</div>
<div class="exclude-list" data-role="exclude-list"></div>
</div>
</div>
</details>
<details class="desk-fold desk-tool desk-tool-mesh" data-fold="mesh" open>
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Bettnetz</span></summary>
<div class="desk-fold-body" data-role="mesh">
<p class="desk-empty"><svg class="ico"><use href="#i-grid"/></svg><span>Noch kein Bettnetz</span></p>
</div>
</details>
</div>
</section>
{% endblock %}
{% block script %}
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=8"></script>
<script>
const labels = {
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline"
};
const rank = { printing: 0, paused: 1, error: 2, heating: 3, complete: 4, cancelled: 5, standby: 6, offline: 7 };
let deskHost = "";
let hideOffline = false;
let libraryFiles = [];
let wallSpools = [];
let deskViewer = null;
let deskViewerFileId = null;
let deskExcludeSelected = "";
const wallPullTried = new Set();
const toolpathCache = new Map();
const cardNodes = new Map();
const runningJobs = new Map();
const printerCache = new Map();
let lastFleetKey = "";
let deskHostsOrder = [];
let cameraTimer = null;
let openingDesk = false;
let deskCamLive = false;
const DESK_FOLD_KEY = "grotti-desk-closed";
const deskEl = document.getElementById("desk");
const wallHead = document.getElementById("wall-head");
const cardsEl = document.getElementById("cards");
function syncDeskCamCollapsed() {
const camFold = deskEl.querySelector(".desk-cam-fold");
const collapsed = deskEl.classList.contains("no-cam") || !(camFold && camFold.open);
deskEl.classList.toggle("cam-collapsed", collapsed);
}
function restoreDeskFolds() {
let closed = [];
try { closed = JSON.parse(localStorage.getItem(DESK_FOLD_KEY) || "[]"); } catch (error) { closed = []; }
if (!Array.isArray(closed)) closed = [];
deskEl.querySelectorAll("details[data-fold]").forEach((box) => {
box.open = !closed.includes(box.dataset.fold);
box.addEventListener("toggle", () => {
const now = [...deskEl.querySelectorAll("details[data-fold]")].filter((d) => !d.open).map((d) => d.dataset.fold);
localStorage.setItem(DESK_FOLD_KEY, JSON.stringify(now));
if (box.classList.contains("desk-cam-fold")) syncDeskCamCollapsed();
});
});
syncDeskCamCollapsed();
}
restoreDeskFolds();
function setDeskCameraMode(live, note) {
const wasLive = deskCamLive;
deskCamLive = !!live;
deskEl.classList.toggle("no-cam", !deskCamLive);
const camFold = deskEl.querySelector(".desk-cam-fold");
// Beim Wechsel live → keine: Fold zu (kein leeres Loch). Manuelles Aufklappen bleibt.
if (wasLive && !deskCamLive && camFold) {
let closed = [];
try { closed = JSON.parse(localStorage.getItem(DESK_FOLD_KEY) || "[]"); } catch (error) { closed = []; }
if (!Array.isArray(closed)) closed = [];
if (!closed.includes("cam")) {
closed.push("cam");
localStorage.setItem(DESK_FOLD_KEY, JSON.stringify(closed));
}
camFold.open = false;
} else if (!deskCamLive && camFold && !localStorage.getItem(DESK_FOLD_KEY)) {
camFold.open = false;
}
syncDeskCamCollapsed();
const sum = deskEl.querySelector('[data-role="cam-sum"]');
if (sum) sum.textContent = deskCamLive ? "live" : (note || "keine");
}
function heatColor(value, low, high) {
const span = high - low || 1;
const t = Math.max(0, Math.min(1, (value - low) / span));
return "hsl(" + (210 - t * 210) + " 42% 58%)";
}
function meshStamp(printer) {
if (!printer.mesh_at) return "";
const at = new Date(Number(printer.mesh_at) * 1000);
if (Number.isNaN(at.getTime())) return "";
return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
function heatMapHtml(printer) {
const rows = printer.mesh;
if (!rows || !rows.length || !rows[0].length) {
return `<p class="desk-empty">${icon("grid")}<span>Noch kein Bettnetz</span></p>`;
}
const flat = rows.flat();
const low = Math.min(...flat);
const high = Math.max(...flat);
const cells = rows.map((row) => row.map((value) => {
const label = (value > 0 ? "+" : "") + value.toFixed(2);
return `<span class="cell" style="background:${heatColor(value, low, high)}">${label}</span>`;
}).join("")).join("");
const stamp = meshStamp(printer);
const bits = [escapeHtml(printer.name || printer.host || "Drucker")];
if (stamp) bits.push(printer.mesh_cached ? "zuletzt " + stamp : stamp);
if (printer.mesh_cached) bits.push("offline gespeichert");
return `<div class="mesh desk-mesh"><p>Aktuelles Netz, oben ist hinten. ${low.toFixed(2)} bis ${high.toFixed(2)} mm</p><div class="gridmesh" style="grid-template-columns:repeat(${rows[0].length},1fr)">${cells}</div><p class="tiny">${bits.join(" · ")}</p></div>`;
}
function shownState(printer) {
const state = printer.state || "offline";
const hot = (printer.nozzle_target || 0) > 40 || (printer.bed_target || 0) > 30;
if ((state === "standby" || state === "complete") && hot) return "heating";
return state;
}
function jobName(filename) {
return (filename || "").replace(/\.gcode$/i, "").replace(/^(\d+-)+/, "");
}
function needsClear(printer) {
return !!printer.needs_clear;
}
function setHtml(el, html) {
if (!el) return;
if (el._html !== html) {
el.innerHTML = html;
el._html = html;
}
}
function tempbarHtml(label, value, target, scale, iconName) {
if (value == null) return "";
const now = Math.round(value);
const goal = Math.round(target || 0);
const fill = Math.max(2, Math.min(100, (value / scale) * 100));
const mark = goal > 0 ? Math.min(100, (goal / scale) * 100) : null;
let cls = "";
if (goal > 0) cls = value < goal - 3 ? "heating" : "hot";
const markLabel = iconName ? icon(iconName) + `<span>${label}</span>` : `<span>${label}</span>`;
return `<div class="tempbar ${cls}">
<div class="tempbar-head">${markLabel}<b>${now}°${goal ? ` <small>/ ${goal}°</small>` : ""}</b></div>
<div class="tempbar-track"><span style="width:${fill.toFixed(1)}%"></span>${mark != null ? `<i style="left:${mark.toFixed(1)}%"></i>` : ""}</div>
</div>`;
}
function tempbarsHtml(printer, state) {
if (state === "offline") return "";
const nozzle = 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 "";
return `<div class="tempbars">${nozzle}${bed}${chamber}</div>`;
}
function fanPct(printer, which) {
const fans = printer.fans || {};
const value = which ? fans[which] : (typeof printer.fan === "number" ? printer.fan : fans.part);
if (typeof value !== "number") return "";
return Math.round(value * 100) + " %";
}
function speedPct(printer) {
if (typeof printer.speed_factor !== "number") return "";
return Math.round(printer.speed_factor * 100) + " %";
}
function spoolForSlot(host, index) {
return wallSpools.find((s) => s.printer_host === host && Number(s.ace_slot) === Number(index));
}
function swatchesHtml(printer) {
const slots = printer.slots;
if (!slots || !slots.length) return "";
return slots.map((slot) => {
const hex = slot.color && slot.color.startsWith("#") ? slot.color : "#555";
const spool = spoolForSlot(printer.host, slot.index);
const rest = spool && spool.remaining_g != null ? grams(spool.remaining_g) : "";
const title = [
slot.index ? "Platz " + slot.index : "",
slot.type,
spool ? spool.name : "",
rest,
].filter(Boolean).join(" · ");
return `<span class="dot" style="background:${escapeHtml(hex)}" title="${escapeHtml(title)}"></span>`;
}).join("");
}
function actionsHtml(state) {
if (state === "printing") {
return `<button type="button" class="ghost" data-cmd="pause">${icon("pause")}Pause</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
}
if (state === "paused") {
return `<button type="button" data-cmd="fortsetzen">${icon("play")}Weiter</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
}
return "";
}
function flowHtml(printer) {
const job = printer.queue_running;
const bits = [];
if (job) {
const rep = repeatText(job);
const title = job.is_ablauf ? "Ablauf" + (rep ? " " + rep : "") : "Aus der Warteschlange";
bits.push(`<div class="strip flow">
<div class="flow-head">${icon("queue")}<span class="grow">${escapeHtml(title)}</span>
<button type="button" class="icon-btn danger" data-cancel-run="${job.id}" title="Ablauf abbrechen">${icon("x")}</button></div>
${job.is_ablauf ? stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index) : ""}
${job.message ? `<div class="flow-msg">${escapeHtml(phaseText(job))}</div>` : ""}
</div>`);
}
const waiting = Number(printer.queue_waiting) || 0;
if (waiting) {
const next = jobLabel(printer.queue_waiting_next);
bits.push(`<div class="waiting-note">${icon("clock")}<span><b>${waiting}</b> ${waiting === 1 ? "wartet" : "warten"}${next ? " · als nächstes " + escapeHtml(next) : ""}</span></div>`);
}
return bits.join("");
}
function stripsHtml(printer, state) {
const bits = [];
const busy = state === "printing" || state === "paused" || state === "heating";
const queueBusy = !!printer.queue_running;
const ablauf = printer.queue_running && printer.queue_running.is_ablauf;
if (state === "error") {
bits.push(`<div class="strip bad">${icon("alert")}<span class="grow">${escapeHtml(printer.message || "Drucker meldet einen Fehler.")}</span><button type="button" class="warn" data-cmd="clear_error">Fehler löschen</button></div>`);
}
const failed = !queueBusy && printer.queue_failed;
if (failed) {
const step = failed.step_label_current || "Schritt " + ((Number(failed.step_index) || 0) + 1);
const rep = repeatText(failed);
bits.push(`<div class="strip bad stopped">
<div class="strip-head">${icon("alert")}<span class="grow"><b>Ablauf gestoppt${rep ? " · " + escapeHtml(rep) : ""}</b> bei ${escapeHtml(step)}</span></div>
${stepsHtml(failed.step_labels, Number(failed.display_step_index != null ? failed.display_step_index : failed.step_index) || 0, true)}
<span class="strip-msg">${escapeHtml(failed.message || "")}</span>
<div class="strip-actions">
<button type="button" data-resume-run="${failed.id}">${icon("play")}Weiter ab ${escapeHtml(step)}</button>
<button type="button" class="ghost" data-dismiss-run="${failed.id}">Verwerfen</button>
</div>
</div>`);
}
// Nicht während Start/Heizen/Queue — sonst kurz „Platte belegt“ obwohl der Druck gerade anläuft
if (needsClear(printer) && !busy && !queueBusy && !failed && state !== "offline") {
const waiting = Number(printer.queue_waiting) || 0;
const hint = waiting ? "Platte belegt — " + waiting + (waiting === 1 ? " Job wartet" : " Jobs warten") : "Platte belegt";
bits.push(`<div class="strip warn">${icon("sweep")}<span class="grow">${escapeHtml(hint)}</span><button type="button" data-cmd="abraeumen">Platte geräumt</button></div>`);
}
return bits.join("");
}
function steerPanelHtml(printer) {
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;
const flow = typeof printer.extrude_factor === "number" ? Math.round(printer.extrude_factor * 100) : 100;
const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0;
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}">
<div class="steer-row">
<label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label>
<label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label>
<button type="button" data-steer-go="temps">Temp</button>
<button type="button" class="ghost" data-steer-go="heaters_off" title="Heizung und Lüfter aus">Aus</button>
</div>
<div class="steer-row">
<label>Speed % <input type="number" data-steer="speed" data-steer-action="speed" min="10" max="200" value="${escapeHtml(String(sp))}"></label>
<label>Flow % <input type="number" data-steer="flow" data-steer-action="flow" min="50" max="150" value="${escapeHtml(String(flow))}"></label>
<label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label>
</div>
<p class="tiny">Enter im Feld setzt nur diesen Wert.</p>
</div>`;
}
function heroHtml(printer, state) {
const remain = hours(printer.remain);
const eta = finishClock(printer.remain);
if (state === "printing" || state === "paused") {
if (!eta) {
const word = (Number(printer.progress) || 0) >= 0.99 ? "schließt ab …" : (Number(printer.elapsed) || 0) > 0 ? "gleich fertig …" : "startet …";
return `<div class="hero"><span class="hero-main"><small>${state === "paused" ? "pausiert" : "druckt"}</small>${word}</span></div>`;
}
return `<div class="hero"><span class="hero-main"><small>${state === "paused" ? "pausiert ·" : "fertig"}</small>${escapeHtml(eta)}</span><span class="hero-side">noch ${escapeHtml(remain)}</span></div>`;
}
if (state === "heating") {
const n = printer.nozzle != null ? Math.round(printer.nozzle) : null;
const goal = Math.round(printer.nozzle_target || 0);
return `<div class="hero"><span class="hero-main"><small>heizt</small>${n != null && goal ? n + "° → " + goal + "°" : "auf"}</span></div>`;
}
if (state === "error") {
return `<div class="hero error"><span class="hero-main">Fehler</span></div>`;
}
if (state === "offline") {
return `<div class="hero idle"><span class="hero-main">nicht erreichbar</span></div>`;
}
if (state === "complete") {
const took = hours(printer.elapsed);
return `<div class="hero idle"><span class="hero-main">fertig</span>${took ? `<span class="hero-side">nach ${escapeHtml(took)}</span>` : ""}</div>`;
}
if (state === "cancelled") {
return `<div class="hero idle"><span class="hero-main">abgebrochen</span></div>`;
}
const busyQueue = printer.queue_running ? "Ablauf läuft" : "";
return `<div class="hero idle"><span class="hero-main">${needsClear(printer) ? "Platte belegt" : "bereit"}</span>${busyQueue ? `<span class="hero-side">${busyQueue}</span>` : ""}</div>`;
}
function statsHtml(printer, state) {
if (state !== "printing" && state !== "paused") return "";
const tiles = [
[hours(printer.remain) || "—", "Rest"],
[printer.layers ? printer.layer + " / " + printer.layers : "—", "Schicht"],
[hours(printer.elapsed) || "—", "läuft"],
];
return tiles.map(([value, label]) => `<div class="stat"><b>${escapeHtml(value)}</b><span>${label}</span></div>`).join("");
}
function patchBar(box, printer, state) {
const busy = state === "printing" || state === "paused";
if (!busy) {
setHtml(box, "");
return;
}
const pct = Math.round((printer.progress || 0) * 100);
let bar = box.querySelector(".bar");
if (!bar) {
box.innerHTML = `<div class="bar"><span></span><b></b></div>`;
box._html = null;
bar = box.querySelector(".bar");
}
bar.classList.toggle("live", state === "printing");
bar.querySelector("span").style.width = pct + "%";
bar.querySelector("b").textContent = pct + " %";
}
function factsHtml(printer) {
const part = fanPct(printer, "part") || fanPct(printer);
const chamberFan = fanPct(printer, "chamber");
const auxFan = fanPct(printer, "aux");
const speed = speedPct(printer);
const loaded = wallSpools.filter((s) => s.printer_host === printer.host);
const spoolLine = loaded.length
? "<span>Spule <b>" + escapeHtml(loaded.map((s) => (s.ace_slot ? s.ace_slot + ": " : "") + s.name + " (" + grams(s.remaining_g) + ")").join(", ")) + "</b></span>"
: "";
return `
${printer.locked ? "<span class=\"msg\"><b>gesperrt</b> — kein Auto-Start</span>" : ""}
${spoolLine}
${printer.material ? "<span>Material <b>" + escapeHtml(String(printer.material).split(";")[0]) + "</b></span>" : ""}
${printer.weight ? "<span>Gewicht <b>" + grams(printer.weight) + "</b></span>" : ""}
${part ? "<span class=\"fact-ico\">" + icon("fan") + " Teil <b>" + part + "</b></span>" : ""}
${chamberFan ? "<span class=\"fact-ico\">" + icon("fan-chamber") + " Bauraum <b>" + chamberFan + "</b></span>" : ""}
${auxFan ? "<span class=\"fact-ico\">" + icon("fan-aux") + " Aux <b>" + auxFan + "</b></span>" : ""}
${speed ? "<span>Speed <b>" + speed + "</b></span>" : ""}
${anycubicTaskLine(printer)}
<span>Host <b>${escapeHtml(printer.host)}</b></span>
${printer.message && printer.ok === false ? `<p class="msg">${escapeHtml(printer.message)}</p>` : ""}
`;
}
function anycubicTaskLine(printer) {
const task = printer.anycubic_task;
if (!task || typeof task !== "object") return "";
const state = String(task.state || "").trim();
const tid = task.task_id != null && String(task.task_id).trim() ? String(task.task_id) : "";
const file = task.filename ? String(task.filename) : "";
if (!state && !tid && !file) return "";
const bits = [];
if (state) bits.push(state);
if (tid) bits.push("task " + tid);
if (file) bits.push(file);
return "<span>Anycubic <b>" + escapeHtml(bits.join(" · ")) + "</b></span>";
}
function jobLineVisible(printer, state) {
return !!printer.filename && state !== "standby" && state !== "offline";
}
function shortName(name) {
const text = String(name || "");
return text.length > 14 ? text.slice(0, 12) + "…" : text;
}
function pillsHtml(activeHost) {
return deskHostsOrder.map((host) => {
const printer = printerCache.get(host);
if (!printer) return "";
const state = shownState(printer);
const on = host === activeHost ? " on" : "";
return `<button type="button" class="desk-pill${on}" data-desk-host="${escapeHtml(host)}" title="${escapeHtml(printer.name)}"><i class="state-dot ${state}"></i>${escapeHtml(shortName(printer.name))}</button>`;
}).join("");
}
function buildCard(printer) {
const state = shownState(printer);
const badge = printer.family_label ? `<span class="badge">${escapeHtml(printer.family_label)}</span>` : "";
const article = document.createElement("article");
article.className = "machine " + state;
article.dataset.host = printer.host;
article.dataset.filename = printer.filename || "";
article.dataset.progress = String(printer.progress || 0);
article.dataset.family = printer.family || "";
article.dataset.state = state;
article.dataset.needsClear = needsClear(printer) ? "1" : "";
article.innerHTML = `
<div class="machine-head">
<div class="machine-title">
<h2></h2>
${badge}
<span data-role="chip"></span>
</div>
<div data-role="hero"></div>
</div>
<div class="machine-body">
<div data-role="bar"></div>
<div class="stats" data-role="stats"></div>
<div data-role="temps"></div>
<div class="job-line" data-role="jobline">${icon("cube")}<span class="name" data-role="job"></span><span class="swatches" data-role="swatches"></span></div>
<div data-role="flow"></div>
<div data-role="strips"></div>
<div class="card-actions" data-role="controls"></div>
</div>`;
article.querySelector("h2").textContent = printer.name;
article.dataset.state = "";
patchCard(article, printer);
return article;
}
function steerHasFocus(root) {
const active = document.activeElement;
return !!(active && root.contains(active) && active.matches("input, select, textarea, button"));
}
function patchSteerValues(root, printer) {
if (steerHasFocus(root)) return;
const steer = root.querySelector("[data-role=steer]");
if (!steer) return;
const nozzle = steer.querySelector('[data-steer="nozzle"]');
const bed = steer.querySelector('[data-steer="bed"]');
const speed = steer.querySelector('[data-steer="speed"]');
const flow = steer.querySelector('[data-steer="flow"]');
const fan = steer.querySelector('[data-steer="fan"]');
if (nozzle && document.activeElement !== nozzle) {
nozzle.value = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
}
if (bed && document.activeElement !== bed) {
bed.value = printer.bed_target != null ? Math.round(printer.bed_target) : "";
}
if (speed && document.activeElement !== speed && typeof printer.speed_factor === "number") {
speed.value = Math.round(printer.speed_factor * 100);
}
if (flow && document.activeElement !== flow && typeof printer.extrude_factor === "number") {
flow.value = Math.round(printer.extrude_factor * 100);
}
if (fan && document.activeElement !== fan && typeof printer.fan === "number") {
fan.value = Math.round(printer.fan * 100);
}
}
function patchCard(article, printer) {
const state = shownState(printer);
const clearNow = needsClear(printer);
article.dataset.state = state;
article.dataset.filename = printer.filename || "";
article.dataset.progress = String(printer.progress || 0);
article.dataset.family = printer.family || "";
article.dataset.needsClear = clearNow ? "1" : "";
article.className = "machine " + state;
if (printer.queue_running) runningJobs.set(printer.host, printer.queue_running);
else runningJobs.delete(printer.host);
const role = (name) => article.querySelector(`[data-role=${name}]`);
setHtml(role("chip"), chipHtml(state, labels[state]));
setHtml(role("hero"), heroHtml(printer, state));
patchBar(role("bar"), printer, state);
setHtml(role("stats"), statsHtml(printer, state));
setHtml(role("temps"), tempbarsHtml(printer, state));
const jobline = role("jobline");
if (jobline) jobline.hidden = !jobLineVisible(printer, state);
const job = role("job");
if (job) {
job.textContent = jobName(printer.filename) || "";
job.title = printer.filename || "";
}
setHtml(role("swatches"), swatchesHtml(printer));
setHtml(role("flow"), flowHtml(printer));
setHtml(role("strips"), stripsHtml(printer, state));
setHtml(role("controls"), actionsHtml(state));
}
function refreshDeskCamera(printer) {
const img = deskEl.querySelector('[data-role="camera"]');
const empty = deskEl.querySelector('[data-role="cam-empty"]');
if (!img || !empty) return;
if (!printer || !printer.camera) {
img.removeAttribute("src");
img.hidden = true;
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
setDeskCameraMode(false, "keine");
img.dataset.srcKey = "";
return;
}
// Layout (no-cam) nicht bei jedem Frame-Fehler umwerfen — sonst springt die
// ganze Bühne inkl. 3D-Viewer alle paar Sekunden.
img.alt = "Kamera " + (printer.name || printer.host);
const next = withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 3000);
if (img.dataset.srcKey === next) return;
img.dataset.srcKey = next;
const probe = new Image();
probe.onload = () => {
if (img.dataset.srcKey !== next) return;
img.src = next;
img.hidden = false;
empty.hidden = true;
setDeskCameraMode(true, "live");
};
probe.onerror = () => {
if (img.dataset.srcKey !== next) return;
// Altes Bild behalten; nur wenn noch nie was kam, Platzhalter zeigen
if (!img.src || img.hidden) {
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Kamera nicht erreichbar</span>";
}
const sum = deskEl.querySelector('[data-role="cam-sum"]');
if (sum && deskCamLive) sum.textContent = "live · kurz weg";
};
probe.src = next;
}
function startCameraTimer() {
stopCameraTimer();
const tick = () => {
if (!deskHost) return;
const printer = printerCache.get(deskHost);
if (printer) refreshDeskCamera(printer);
};
tick();
cameraTimer = setInterval(tick, 3000);
}
function stopCameraTimer() {
if (cameraTimer) {
clearInterval(cameraTimer);
cameraTimer = null;
}
}
function destroyDeskViewer() {
if (deskViewer) {
deskViewer.destroy();
deskViewer = null;
}
deskViewerFileId = null;
deskExcludeSelected = "";
const exPanel = deskEl.querySelector('[data-role="exclude-panel"]');
if (exPanel) {
exPanel.hidden = true;
const list = deskEl.querySelector('[data-role="exclude-list"]');
if (list) list.innerHTML = "";
}
}
function patchDesk(printer) {
if (!printer || printer.host !== deskHost) return;
const state = shownState(printer);
const prevFile = deskEl.dataset.filename || "";
const fileChanged = prevFile !== (printer.filename || "");
deskEl.dataset.host = printer.host;
deskEl.dataset.filename = printer.filename || "";
deskEl.dataset.progress = String(printer.progress || 0);
deskEl.className = "desk " + state;
const role = (name) => deskEl.querySelector(`[data-role=${name}]`);
const nameEl = role("name");
if (nameEl) nameEl.textContent = printer.name || printer.host;
setHtml(role("badge"), printer.family_label ? `<span class="badge">${escapeHtml(printer.family_label)}</span>` : "");
setHtml(role("chip"), chipHtml(state, labels[state]));
setHtml(role("pills"), pillsHtml(printer.host));
const mainsail = role("mainsail");
if (mainsail) {
mainsail.href = printer.mainsail || "#";
mainsail.hidden = !printer.mainsail;
}
setHtml(role("hero"), heroHtml(printer, state));
patchBar(role("bar"), printer, state);
setHtml(role("stats"), statsHtml(printer, state));
setHtml(role("temps"), tempbarsHtml(printer, state));
const jobline = role("jobline");
if (jobline) jobline.hidden = !jobLineVisible(printer, state) && !printer.preview;
const job = role("job");
if (job) {
job.textContent = jobName(printer.filename) || (jobLineVisible(printer, state) ? "" : "Kein Auftrag");
job.title = printer.filename || "";
}
const preview = role("preview");
if (preview) {
if (printer.preview) {
preview.hidden = false;
preview.src = withToken(printer.preview);
preview.onerror = () => { preview.hidden = true; };
} else {
preview.hidden = true;
}
}
setHtml(role("swatches"), swatchesHtml(printer));
setHtml(role("facts"), factsHtml(printer));
setHtml(role("flow"), flowHtml(printer));
setHtml(role("strips"), stripsHtml(printer, state));
setHtml(role("controls"), actionsHtml(state));
const estop = role("estop");
if (estop) {
const showStop = state === "printing" || state === "paused" || state === "heating";
estop.hidden = !showStop;
if (!showStop && estop.dataset.armed === "1") {
estop.dataset.armed = "";
estop.classList.remove("confirm-armed");
if (estop.dataset.idleHtml) estop.innerHTML = estop.dataset.idleHtml;
}
}
const steer = role("steer");
if (steer && (!steer.querySelector(".steer") || fileChanged)) {
setHtml(steer, steerPanelHtml(printer));
} else {
patchSteerValues(deskEl, printer);
}
setHtml(role("mesh"), heatMapHtml(printer));
// Kamera nur über Timer aktualisieren — sonst doppelter Reload + Layout-Ruckler
syncDeskViewer(printer).catch(() => {});
}
async function syncDeskViewer(printer) {
if (!deskHost || !printer || printer.host !== deskHost) return;
const canvas = deskEl.querySelector('[data-role="path-canvas"]');
const caption = deskEl.querySelector('[data-role="path-caption"]');
if (!canvas || !window.GrottiGcodeViewer || !window.THREE) return;
const filename = printer.filename || "";
const progress = Number(printer.progress || 0);
if (!deskViewer) {
deskViewer = GrottiGcodeViewer.create(canvas);
deskViewer.setExcludeObjects({ objects: [], excluded: [], current: null }, {
onSelect: (name) => {
deskExcludeSelected = name || "";
deskViewer.setSelectedExclude(deskExcludeSelected);
const printerNow = printerCache.get(deskHost);
if (printerNow) renderExcludePanel(printerNow);
},
});
}
let file = matchLibraryFile(filename);
if (!file && filename && !wallPullTried.has(deskHost + "|" + filename)) {
wallPullTried.add(deskHost + "|" + filename);
if (caption) caption.textContent = "lade vom Drucker …";
try {
const saved = await api("/dateien/von-drucker", {
method: "POST",
json: { host: deskHost, filename },
});
libraryFiles = await api("/dateien");
file = saved || matchLibraryFile(filename);
} catch (error) {
/* bleibt leer */
}
}
if (!file) {
deskViewer.setPath({ points: [] });
deskViewerFileId = null;
if (caption) caption.textContent = filename ? "Datei nicht in Grotti" : "kein Auftrag";
renderExcludePanel(printer);
return;
}
try {
let data = toolpathCache.get(file.id);
const needLoad = !data || data.version !== 5 || deskViewerFileId !== file.id;
if (needLoad) {
if (caption) caption.textContent = "wird gelesen …";
data = await api("/dateien/" + file.id + "/pfad");
toolpathCache.set(file.id, data);
const sameFile = deskViewerFileId === file.id;
deskViewer.setPath(data, { keepView: sameFile });
deskViewerFileId = file.id;
}
if (!(data.points && data.points.length)) {
if (caption) caption.textContent = "leer in Datei";
return;
}
} catch (error) {
// File-Id behalten — sonst setzt der nächste Poll die 3D-Ansicht zurück
if (caption) caption.textContent = error.message || "fehlgeschlagen";
return;
}
deskViewer.setProgress(progress, {
layer: printer.layer,
layers: printer.layers,
});
// Exclude nur neu zeichnen, wenn sich etwas geändert hat
const ex = printer.exclude || {};
const exKey = JSON.stringify({
n: (ex.objects || []).length,
x: ex.excluded || [],
c: ex.current || null,
s: deskExcludeSelected,
});
if (deskEl.dataset.excludeKey !== exKey) {
deskEl.dataset.excludeKey = exKey;
deskViewer.setExcludeObjects(ex, { selected: deskExcludeSelected });
renderExcludePanel(printer);
}
const layerTxt = printer.layers
? " · Schicht " + (printer.layer || 0) + "/" + printer.layers
: "";
if (caption) caption.textContent = file.name + " · " + Math.round(progress * 100) + " %" + layerTxt;
}
function renderExcludePanel(printer) {
const panel = deskEl.querySelector('[data-role="exclude-panel"]');
const list = deskEl.querySelector('[data-role="exclude-list"]');
if (!panel || !list) return;
const ex = (printer && printer.exclude) || {};
const objects = ex.objects || [];
const excluded = new Set(ex.excluded || []);
const current = ex.current || "";
const canAct = ["printing", "paused"].includes((printer.state || "").toLowerCase());
if (!objects.length) {
panel.hidden = true;
list.innerHTML = "";
return;
}
panel.hidden = false;
list.innerHTML = objects.map((obj) => {
const name = obj.name || "";
const isEx = excluded.has(name);
const isCur = current === name;
const isSel = deskExcludeSelected === name;
const badges = [
isEx ? "aus" : "",
isCur ? "aktiv" : "",
].filter(Boolean).join(" · ");
const cls = [
"exclude-item",
isEx ? "is-excluded" : "",
isSel ? "is-selected" : "",
isCur ? "is-current" : "",
].filter(Boolean).join(" ");
const btn = (!isEx && canAct)
? `<button type="button" class="tiny-btn warn" data-exclude-name="${escapeHtml(name)}">ausschließen</button>`
: (isEx ? `<span class="tiny">ausgeschlossen</span>` : "");
return `<div class="${cls}" data-exclude-pick="${escapeHtml(name)}">
<span class="grow"><b>${escapeHtml(name)}</b>${badges ? ` <span class="tiny">${escapeHtml(badges)}</span>` : ""}</span>
${btn}
</div>`;
}).join("");
}
function matchLibraryFile(filename) {
const base = (filename || "").replace(/^.*[\\/]/, "");
if (!base) return null;
return libraryFiles.find((file) => file.name === base)
|| libraryFiles.find((file) => base.endsWith(file.name) || file.name.endsWith(base))
|| null;
}
function setDeskVisible(on) {
deskEl.hidden = !on;
wallHead.hidden = on;
cardsEl.hidden = on;
document.getElementById("note").hidden = on;
document.body.classList.toggle("desk-on", on);
}
function openDesk(host, opts) {
const printer = printerCache.get(host);
if (!printer) return;
const same = deskHost === host && !deskEl.hidden;
const prev = deskHost;
deskHost = host;
setDeskVisible(true);
if (prev !== host) destroyDeskViewer();
const steer = deskEl.querySelector("[data-role=steer]");
if (!same || !steer.querySelector(".steer")) setHtml(steer, steerPanelHtml(printer));
patchDesk(printer);
startCameraTimer();
if (!same) {
const back = document.getElementById("desk-back");
if (back) back.focus();
}
const want = "#" + encodeURIComponent(host);
if ((opts && opts.fromHash) || location.hash === want) return;
openingDesk = true;
location.hash = want;
requestAnimationFrame(() => { openingDesk = false; });
}
function closeDesk(opts) {
if (!deskHost && deskEl.hidden) return;
deskHost = "";
stopCameraTimer();
destroyDeskViewer();
setDeskVisible(false);
if (opts && opts.keepHash) return;
if (location.hash) {
openingDesk = true;
history.pushState("", document.title, location.pathname + location.search);
requestAnimationFrame(() => { openingDesk = false; });
}
}
function openDeskFromHash() {
if (openingDesk) return;
const host = decodeURIComponent((location.hash || "").slice(1));
if (!host) {
if (!deskEl.hidden) closeDesk({ keepHash: true });
return;
}
if (!printerCache.has(host)) return;
openDesk(host, { fromHash: true });
}
function stepDesk(delta) {
if (!deskHost || !deskHostsOrder.length) return;
const index = deskHostsOrder.indexOf(deskHost);
if (index < 0) return;
const next = deskHostsOrder[(index + delta + deskHostsOrder.length) % deskHostsOrder.length];
openDesk(next);
}
function groupPrinters(printers) {
const groups = new Map();
for (const printer of printers) {
const key = printer.family_label || printer.family || "Drucker";
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(printer);
}
const order = ["Kobra 3", "Kobra S1"];
const keys = [...groups.keys()].sort((a, b) => {
const ai = order.indexOf(a);
const bi = order.indexOf(b);
if (ai !== -1 || bi !== -1) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
return a.localeCompare(b, "de");
});
return keys.map((key) => ({ label: key, printers: groups.get(key) }));
}
function ensureFleetShell(printers) {
const sections = groupPrinters(printers);
const key = sections.map((s) => s.label).join("|") + "|" + (sections.length <= 1 ? "flat" : "sec");
const root = document.getElementById("cards");
if (key !== lastFleetKey || !root.children.length) {
lastFleetKey = key;
cardNodes.clear();
if (sections.length <= 1) {
root.innerHTML = '<div class="fleet" data-fleet></div>';
} else {
root.innerHTML = sections.map((section) => `
<section class="fleet-section" data-section="${escapeHtml(section.label)}">
<h3>${escapeHtml(section.label)}</h3>
<div class="fleet" data-fleet="${escapeHtml(section.label)}"></div>
</section>`).join("");
}
}
return sections;
}
function fleetBucket(printer, sections) {
if (sections.length <= 1) {
return document.querySelector("#cards [data-fleet]");
}
const label = printer.family_label || printer.family || "Drucker";
return document.querySelector(`#cards [data-fleet="${CSS.escape(label)}"]`);
}
function summaryText(printers) {
const counts = {};
for (const printer of printers) {
const state = shownState(printer);
counts[state] = (counts[state] || 0) + 1;
}
const order = ["printing", "paused", "error", "heating", "complete", "standby", "offline"];
const chips = order.filter((state) => counts[state]).map((state) => chipHtml(state, counts[state] + " " + labels[state]));
const bits = [];
let nextFinish = null;
let nextFreeName = "";
for (const printer of printers) {
const state = shownState(printer);
if ((state === "printing" || state === "paused") && printer.remain > 0) {
if (!nextFinish || printer.remain < nextFinish.remain) {
nextFinish = printer;
}
}
if ((state === "standby" || state === "complete" || state === "cancelled") && !needsClear(printer) && !printer.queue_running && !nextFreeName) {
nextFreeName = printer.name;
}
}
if (nextFinish) {
const clock = finishClock(nextFinish.remain);
if (clock) bits.push(`${icon("clock")} nächster fertig <b>${escapeHtml(clock)}</b> · ${escapeHtml(nextFinish.name)}`);
}
if (nextFreeName) bits.push(`frei: <b>${escapeHtml(nextFreeName)}</b>`);
return chips.join("") + (bits.length ? `<span class="summary-text">${bits.join(" · ")}</span>` : "");
}
function sortPrinters(printers) {
printers.sort((a, b) => (rank[shownState(a)] ?? 9) - (rank[shownState(b)] ?? 9) || a.name.localeCompare(b.name, "de"));
if (hideOffline) return printers.filter((p) => shownState(p) !== "offline");
return printers;
}
async function refresh() {
if (!libraryFiles.length) {
try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; }
}
if (!wallSpools.length) {
try { wallSpools = await api("/spulen"); } catch (error) { wallSpools = []; }
}
const all = await api("/drucker");
const printers = sortPrinters(all.slice());
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" });
const sections = ensureFleetShell(printers);
const seen = new Set();
for (const printer of printers) {
seen.add(printer.host);
let article = cardNodes.get(printer.host);
const bucket = fleetBucket(printer, sections);
if (!bucket) continue;
if (!article) {
article = buildCard(printer);
cardNodes.set(printer.host, article);
bucket.appendChild(article);
} else {
patchCard(article, printer);
if (article.parentElement !== bucket) bucket.appendChild(article);
}
}
for (const [host, article] of [...cardNodes.entries()]) {
if (!seen.has(host)) {
article.remove();
cardNodes.delete(host);
}
}
for (const section of sections.length <= 1 ? [{ printers }] : sections) {
const bucket = sections.length <= 1
? document.querySelector("#cards [data-fleet]")
: document.querySelector(`#cards [data-fleet="${CSS.escape(section.label)}"]`);
if (!bucket) continue;
for (const printer of section.printers) {
const article = cardNodes.get(printer.host);
if (article) bucket.appendChild(article);
}
}
if (deskHost) {
if (!printerCache.has(deskHost)) {
closeDesk();
} else {
patchDesk(printerCache.get(deskHost));
}
} else {
openDeskFromHash();
}
}
window.onQueueChanged = () => refresh().catch(() => {});
document.getElementById("hide-offline").addEventListener("change", (event) => {
hideOffline = event.target.checked;
refresh().catch((error) => showNote("note", error));
});
document.getElementById("desk-back").addEventListener("click", () => closeDesk());
window.addEventListener("hashchange", () => {
if (openingDesk) return;
openDeskFromHash();
});
window.addEventListener("keydown", (event) => {
if (!deskHost || deskEl.hidden) return;
if (event.target && event.target.matches("input, textarea, select")) return;
if (event.key === "Escape") {
event.preventDefault();
closeDesk();
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
stepDesk(-1);
} else if (event.key === "ArrowRight") {
event.preventDefault();
stepDesk(1);
}
});
async function handleWallClick(event) {
const pill = event.target.closest("[data-desk-host]");
if (pill) {
openDesk(pill.dataset.deskHost);
return;
}
const resumeRun = event.target.closest("[data-resume-run]");
if (resumeRun) {
armButton(resumeRun, "Kiste steht? Nochmal klicken", async () => {
resumeRun.disabled = true;
try {
await api("/druck-queue/" + resumeRun.dataset.resumeRun, { method: "PATCH", json: { status: "running", resume: true } });
toast("Ablauf geht weiter.");
} catch (error) {
toast(error.message, "bad");
}
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
refresh().catch(() => {});
});
return;
}
const dismissRun = event.target.closest("[data-dismiss-run]");
if (dismissRun) {
armButton(dismissRun, "Verwerfen?", async () => {
try {
await api("/druck-queue/" + dismissRun.dataset.dismissRun, { method: "PATCH", json: { status: "dismissed" } });
} catch (error) {
toast(error.message, "bad");
}
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
refresh().catch(() => {});
});
return;
}
const cancelRun = event.target.closest("[data-cancel-run]");
if (cancelRun) {
const host = cancelRun.closest("[data-host]")?.dataset.host;
const job = runningJobs.get(host);
if (job) window.cancelQueueItem(job, cancelRun);
return;
}
const excludePick = event.target.closest("[data-exclude-pick]");
if (excludePick && !event.target.closest("[data-exclude-name]")) {
deskExcludeSelected = excludePick.dataset.excludePick || "";
if (deskViewer) deskViewer.setSelectedExclude(deskExcludeSelected);
const printerNow = printerCache.get(deskHost);
if (printerNow) renderExcludePanel(printerNow);
return;
}
const excludeBtn = event.target.closest("[data-exclude-name]");
if (excludeBtn) {
const host = deskHost;
const name = excludeBtn.dataset.excludeName || "";
if (!host || !name) return;
armButton(excludeBtn, "Wirklich ausschließen?", async () => {
excludeBtn.disabled = true;
try {
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST",
json: { action: "exclude_object", name, confirm: true },
});
toast("Objekt ausgeschlossen: " + name);
deskExcludeSelected = name;
refresh();
} catch (error) {
toast(error.message, "bad");
} finally {
excludeBtn.disabled = false;
}
});
return;
}
const steerBtn = event.target.closest("[data-steer-go]");
if (steerBtn) {
const host = steerBtn.closest("[data-host]")?.dataset.host
|| steerBtn.closest("[data-steer-host]")?.dataset.steerHost
|| deskHost;
if (!host) return;
const box = deskEl.querySelector(`[data-steer-host="${CSS.escape(host)}"]`)
|| steerBtn.closest("[data-steer-host]");
const go = steerBtn.dataset.steerGo;
const val = (name) => {
const input = box && box.querySelector('[data-steer="' + name + '"]');
return input ? Number(input.value) : null;
};
if (go === "emergency_stop") {
armButton(steerBtn, "Strom aus/an nötig — nochmal", async () => {
steerBtn.disabled = true;
try {
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST", json: { action: "emergency_stop", confirm: true },
});
toast("E-Stop. Danach Strom aus und wieder an — Firmware-Restart geht nicht.", "bad");
refresh();
} catch (error) {
toast(error.message, "bad");
} finally {
steerBtn.disabled = false;
}
});
return;
}
if (!box) return;
steerBtn.disabled = true;
try {
if (go === "temps") {
const nozzle = val("nozzle");
const bed = val("bed");
if (nozzle != null && !Number.isNaN(nozzle)) {
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST", json: { action: "nozzle", temp: nozzle },
});
}
if (bed != null && !Number.isNaN(bed)) {
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST", json: { action: "bed", temp: bed },
});
}
} else if (go === "heaters_off") {
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST", json: { action: "nozzle", temp: 0 },
});
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST", json: { action: "bed", temp: 0 },
});
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST", json: { action: "fan", percent: 0 },
});
toast("Heizung und Lüfter aus.");
}
showNote("note", null);
refresh();
} catch (error) {
showNote("note", error);
} finally {
steerBtn.disabled = false;
}
return;
}
const button = event.target.closest("[data-cmd]");
if (button) {
const host = button.closest("[data-host]")?.dataset.host;
if (!host) return;
if (button.dataset.cmd === "abbrechen") {
armButton(button, "Wirklich abbrechen?", async () => {
button.disabled = true;
try {
await api("/drucker/" + encodeURIComponent(host) + "/abbrechen", { method: "POST", json: {} });
toast("Druck abgebrochen.");
refresh();
} catch (error) { toast(error.message, "bad"); }
finally { button.disabled = false; }
});
return;
}
if (button.dataset.cmd === "clear_error") {
armButton(button, "Fehler löschen?", async () => {
button.disabled = true;
try {
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST",
json: { action: "clear_error", confirm: true },
});
toast("Fehlerstatus zurückgesetzt.");
refresh();
} catch (error) { toast(error.message, "bad"); }
finally { button.disabled = false; }
});
return;
}
if (button.dataset.cmd === "abraeumen") {
armButton(button, "Platte frei?", async () => {
button.disabled = true;
try {
await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", {
method: "POST",
json: { confirm: true },
});
toast("Platte quittiert — der nächste Job darf starten.");
refresh();
if (window.refreshTasks) window.refreshTasks();
} catch (error) { toast(error.message, "bad"); }
finally { button.disabled = false; }
});
return;
}
button.disabled = true;
try {
await api("/drucker/" + encodeURIComponent(host) + "/" + button.dataset.cmd, { method: "POST", json: {} });
showNote("note", null);
refresh();
} catch (error) {
showNote("note", error);
} finally {
button.disabled = false;
}
return;
}
if (event.target.closest("button, a, input, label, select, textarea")) return;
const article = event.target.closest("article.machine");
if (article && article.dataset.host) {
openDesk(article.dataset.host);
}
}
cardsEl.addEventListener("click", handleWallClick);
deskEl.addEventListener("click", handleWallClick);
deskEl.addEventListener("keydown", async (event) => {
if (event.key !== "Enter") return;
const input = event.target.closest("[data-steer-action]");
if (!input) return;
event.preventDefault();
const host = input.closest("[data-steer-host]")?.dataset.steerHost || deskHost;
if (!host) return;
const action = input.dataset.steerAction;
const value = Number(input.value);
if (Number.isNaN(value)) return;
input.disabled = true;
try {
const body = action === "nozzle" || action === "bed"
? { action, temp: value }
: { action, percent: value };
await api("/drucker/" + encodeURIComponent(host) + "/steuern", { method: "POST", json: body });
toast((action === "nozzle" ? "Düse" : action === "bed" ? "Bett" : action === "speed" ? "Speed" : action === "flow" ? "Flow" : "Lüfter") + " gesetzt.");
refresh();
} catch (error) {
toast(error.message, "bad");
} finally {
input.disabled = false;
}
});
refresh().catch((error) => showNote("note", error));
setInterval(() => refresh().catch((error) => showNote("note", error)), 5000);
setInterval(() => {
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
api("/spulen").then((rows) => { wallSpools = rows || []; }).catch(() => {});
}, 30000);
api("/spulen").then((rows) => { wallSpools = rows || []; }).catch(() => {});
</script>
{% endblock %}