Files
grotti/web/templates/wand.html
T

1765 lines
69 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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"><svg class="ico"><use href="#i-chevron-left"/></svg>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><svg class="ico title-ico"><use href="#i-camera"/></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>
</div>
<div class="desk-foot">
<div class="card-actions" data-role="controls"></div>
<button type="button" class="btn-danger" data-role="estop" data-steer-go="emergency_stop" hidden>E-Stop</button>
</div>
</aside>
</div>
<details class="desk-fold desk-technik" data-fold="technik">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-sliders"/></svg><span class="grow">Technik</span><span class="tiny">Steuerung, Verlauf, 3D, Bettnetz</span></summary>
<div class="desk-technik-body">
<div data-role="tech-facts" class="desk-facts desk-tech-facts"></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><svg class="ico title-ico"><use href="#i-gauge"/></svg><span class="grow">Steuerung</span></summary>
<div data-role="steer"></div>
</details>
<div class="desk-tools">
<details class="desk-fold desk-tool" data-fold="verlauf" open>
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-activity"/></svg><span class="grow">Verlauf</span><span class="tiny" data-role="verlauf-caption"></span></summary>
<div class="desk-fold-body desk-verlauf" data-role="verlauf">
<p class="tiny">Lade Verlauf …</p>
</div>
</details>
<details class="desk-fold desk-tool" data-fold="path">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-cube"/></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">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><svg class="ico title-ico"><use href="#i-grid"/></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>
</div>
</details>
</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=10"></script>
<script>
const labels = STATE_LABELS;
const rank = { printing: 0, paused: 1, error: 2, heating: 3, complete: 4, cancelled: 5, standby: 6, offline: 7 };
const DESK_STATES = ["printing", "paused", "complete", "standby", "heating", "error", "cancelled", "offline"];
let deskHost = "";
let hideOffline = false;
let libraryFiles = [];
let wallSpools = [];
let deskViewer = null;
let deskCamFails = 0;
let deskViewerFileId = null;
let deskSyncGen = 0;
let deskExcludeSelected = "";
let deskVerlaufAt = 0;
let deskVerlaufHtml = "";
let deskVerlaufHost = "";
const wallPullTried = new Set();
const toolpathCache = new Map();
/** Kurzes leeres filename vom Drucker nicht übernehmen — sonst springt der Name alle paar Sekunden. */
function stickyFilename(printer, previous) {
const next = (printer && printer.filename) || "";
if (next) return next;
const state = shownState(printer || {});
if (previous && ["printing", "paused", "heating", "complete"].includes(state)) return previous;
return "";
}
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) || "null"); } catch (error) { closed = null; }
if (!Array.isArray(closed)) closed = ["technik", "path", "mesh"];
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) {
if (!deskCamLive) sum.textContent = note || "keine";
else if (note === "stream") sum.textContent = "stream";
else sum.textContent = "live";
}
}
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 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 setText(el, text) {
if (!el) return;
const next = text == null ? "" : String(text);
if (el._text === next) return;
el._text = next;
el.textContent = next;
}
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 etikettHtml(printer) {
const runId = printer && printer.live_run_id;
if (!runId) return "";
return `<a class="ghost" href="/etikett/lauf/${runId}?mode=druck" target="_blank" rel="noopener">Etikett</a>`;
}
function actionsHtml(state, printer) {
const label = (state === "printing" || state === "paused") ? etikettHtml(printer) : "";
if (state === "printing") {
return `${label}<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 `${label}<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 = [];
// Nur Ablauf braucht den Strip (Schritte). Einfacher Queue-Druck: Karte zeigt schon DRUCKT.
if (job && job.is_ablauf) {
const rep = repeatText(job);
const title = "Ablauf" + (rep ? " " + rep : "");
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>
${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 filamentPauseHint(printer) {
if (!printer) return "";
if (printer.filament_empty) return printer.pause_reason || printer.message || "Filament leer";
const text = String(printer.pause_reason || printer.message || "");
if (/filament|spule|material.?leer|gerissen|runout|10107/i.test(text)) return text;
return "";
}
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 filamentHint = state === "paused" ? filamentPauseHint(printer) : "";
if (filamentHint) {
bits.push(`<div class="strip warn filament-swap">
<div class="strip-head">${icon("alert")}<span class="grow"><b>Neue Rolle in Material dem Drucker zuweisen</b></span></div>
<span class="strip-msg">${escapeHtml(filamentHint)} — sonst zählt der Verbrauch weiter zur alten Spule. Ort = dieser Drucker, speichern, dann Weiter.</span>
<div class="strip-actions"><a class="ghost" href="/material">Zu Material</a></div>
</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 dryerLabel(status) {
if (status === "drying") return "trocknet";
if (status === "heater_err") return "Fehler";
return "aus";
}
function dryerDefaultTemp(dryer) {
if (dryer.target > 0) return dryer.target;
if (dryer.temp > 0) return dryer.temp;
return 45;
}
function aceSteerKey(printer) {
const dryers = printer.dryers || [];
const dryerPart = dryers.map((d) => String(d.id)).join(",");
const slots = wallSpools
.filter((s) => s.printer_host === printer.host && s.ace_slot)
.map((s) => Number(s.ace_slot))
.sort((a, b) => a - b)
.join(",");
return dryerPart + "|" + slots;
}
function spoolHex(spool) {
const colors = spool && spool.colors;
if (Array.isArray(colors)) {
for (const item of colors) {
if (item && item.hex) return item.hex;
if (typeof item === "string" && item.trim()) return item.trim();
}
}
const label = spool && spool.color;
if (typeof label === "string" && /^#?[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(label.trim())) {
return label.trim().startsWith("#") ? label.trim() : "#" + label.trim();
}
return "";
}
function aceSlotLabel(slot) {
const n = Number(slot);
if (n <= 4) return "ACE 1 · " + n;
return "ACE 2 · " + (n - 4);
}
function aceSteerHtml(printer) {
const dryers = printer.dryers || [];
const loaded = wallSpools
.filter((s) => s.printer_host === printer.host && s.ace_slot)
.slice()
.sort((a, b) => Number(a.ace_slot) - Number(b.ace_slot));
if (!dryers.length && !loaded.length) return "";
const bits = [];
dryers.forEach((dryer) => {
const id = Number(dryer.id);
const label = "ACE " + (id + 1);
const status = dryerLabel(dryer.status);
const temp = dryer.temp || 0;
const target = dryer.target || 0;
const remain = dryer.remain || 0;
const dur = remain > 0 ? remain : 240;
const hot = dryerDefaultTemp(dryer);
const info = [
status,
temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "",
remain > 0 ? "noch " + remain + " min" : "",
].filter(Boolean).join(" · ");
bits.push(`<div class="steer-ace" data-dryer-id="${id}">
<div class="steer-ace-head"><b>${escapeHtml(label)} Trockner</b><span class="tiny" data-dryer-info>${escapeHtml(info)}</span></div>
<div class="steer-row">
<label>Temp °C <input type="number" data-steer="dryer-temp-${id}" min="35" max="65" value="${escapeHtml(String(hot))}"></label>
<label>Dauer min <input type="number" data-steer="dryer-dur-${id}" min="1" max="720" value="${escapeHtml(String(dur))}"></label>
<button type="button" data-steer-go="dryer_start" data-dryer-id="${id}">Start</button>
<button type="button" class="ghost" data-steer-go="dryer_stop" data-dryer-id="${id}">Stopp</button>
</div>
</div>`);
});
if (loaded.length) {
bits.push(`<div class="steer-ace-slots"><div class="steer-ace-head"><b>Plätze</b><span class="tiny">Sorte und Farbe aus Grotti</span></div>`);
loaded.forEach((spool) => {
const slot = Number(spool.ace_slot);
const hex = spoolHex(spool) || "#555";
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>
<button type="button" class="ghost" data-steer-go="slot_setzen" data-ace-slot="${slot}">an den Drucker</button>
</div>`);
});
bits.push(`</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;
const aceKey = aceSteerKey(printer);
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}" data-ace-key="${escapeHtml(aceKey)}">
<div class="steer-group">
<div class="steer-group-label">Heizung</div>
<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>
<div class="steer-group">
<div class="steer-group-label">Druck</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>
</div>
${aceSteerHtml(printer)}
<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);
const last = jobLabel(printer.filename || "");
const side = [last ? "zuletzt " + last : "", took].filter(Boolean).join(", ");
return `<div class="hero idle"><span class="hero-main">bereit</span>${side ? `<span class="hero-side">${escapeHtml(side)}</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 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>" : ""}
${printer.message && printer.ok === false ? `<p class="msg">${escapeHtml(printer.message)}</p>` : ""}
`;
}
function factChip(label, value, ico) {
if (!value && value !== 0) return "";
return `<span class="fact-chip">${ico || ""}<span class="fact-chip-label">${escapeHtml(label)}</span><b>${escapeHtml(String(value))}</b></span>`;
}
function techFactsHtml(printer) {
const part = fanPct(printer, "part") || fanPct(printer);
const chamberFan = fanPct(printer, "chamber");
const auxFan = fanPct(printer, "aux");
const speed = speedPct(printer);
return [
part ? factChip("Teil", part, icon("fan")) : "",
chamberFan ? factChip("Bauraum", chamberFan, icon("fan-chamber")) : "",
auxFan ? factChip("Aux", auxFan, icon("fan-aux")) : "",
speed ? factChip("Speed", speed) : "",
anycubicTaskLine(printer),
factChip("Host", printer.host),
].filter(Boolean).join("");
}
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) : "";
if (!state && !tid) return "";
const bits = [];
if (state) bits.push(state);
if (tid) bits.push("#" + tid);
return factChip("Anycubic", bits.join(" · "));
}
function jobLineVisible(printer, state, filename) {
const name = filename != null ? filename : (printer && printer.filename);
return !!name && 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-head-main">
<div class="machine-title">
<h2></h2>
${badge}
<span data-role="chip"></span>
</div>
<div data-role="hero"></div>
</div>
<img class="wall-cam-thumb" data-role="wall-cam" alt="" hidden>
</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">
<img class="job-thumb" data-role="thumb" alt="" hidden>
<span class="job-ico" data-role="jobico">${icon("cube")}</span>
<div class="job-meta">
<span class="name" data-role="job"></span>
<span class="swatches" data-role="swatches"></span>
</div>
</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;
}
/** Wand-Thumb immer Snapshot — nie Stream, unabhängig von der Browser-Einstellung. */
function wallCamSnapshotUrl(printer) {
if (!printer || !printer.camera || printer.has_camera === false) return "";
return withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 3000);
}
function patchWallCam(article, printer) {
const img = article.querySelector('[data-role="wall-cam"]');
const head = article.querySelector(".machine-head");
if (!img) return;
const next = wallCamSnapshotUrl(printer);
if (!next) {
img.hidden = true;
img.removeAttribute("src");
img.dataset.srcKey = "";
img.dataset.pending = "";
if (head) head.classList.remove("has-cam");
return;
}
img.alt = "Kamera " + (printer.name || printer.host || "");
if (img.dataset.srcKey === next) {
img.hidden = false;
if (head) head.classList.add("has-cam");
return;
}
img.dataset.pending = next;
const probe = new Image();
probe.onload = () => {
if (img.dataset.pending !== next) return;
img.dataset.srcKey = next;
img.dataset.pending = "";
img.src = next;
img.hidden = false;
if (head) head.classList.add("has-cam");
};
probe.onerror = () => {
if (img.dataset.pending !== next) return;
img.dataset.pending = "";
img.hidden = true;
img.removeAttribute("src");
img.dataset.srcKey = "";
if (head) head.classList.remove("has-cam");
};
probe.src = next;
}
function refreshWallCams() {
for (const [host, article] of cardNodes.entries()) {
const printer = printerCache.get(host);
if (printer) patchWallCam(article, printer);
}
}
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 panel = steer.querySelector(".steer");
if (!panel) return;
const nozzle = panel.querySelector('[data-steer="nozzle"]');
const bed = panel.querySelector('[data-steer="bed"]');
const speed = panel.querySelector('[data-steer="speed"]');
const flow = panel.querySelector('[data-steer="flow"]');
const fan = panel.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);
}
(printer.dryers || []).forEach((dryer) => {
const id = Number(dryer.id);
const box = panel.querySelector(`[data-dryer-id="${id}"]`);
if (!box) return;
const info = box.querySelector("[data-dryer-info]");
if (info) {
const status = dryerLabel(dryer.status);
const temp = dryer.temp || 0;
const target = dryer.target || 0;
const remain = dryer.remain || 0;
info.textContent = [
status,
temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "",
remain > 0 ? "noch " + remain + " min" : "",
].filter(Boolean).join(" · ");
}
const tempInput = box.querySelector(`[data-steer="dryer-temp-${id}"]`);
const durInput = box.querySelector(`[data-steer="dryer-dur-${id}"]`);
if (tempInput && document.activeElement !== tempInput && dryer.status === "drying" && dryer.target > 0) {
tempInput.value = dryer.target;
}
if (durInput && document.activeElement !== durInput && dryer.remain > 0) {
durInput.value = dryer.remain;
}
});
}
function patchCard(article, printer) {
const state = shownState(printer);
const clearNow = needsClear(printer);
const shownFile = stickyFilename(printer, article.dataset.filename || "");
article.dataset.state = state;
article.dataset.filename = shownFile;
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");
const jobVisible = jobLineVisible(printer, state, shownFile);
const hasThumb = !!(jobVisible && printer.preview);
if (jobline) {
jobline.hidden = !jobVisible;
jobline.classList.toggle("has-thumb", hasThumb);
}
const thumb = role("thumb");
const jobico = role("jobico");
if (thumb) {
if (hasThumb) {
const next = withToken(printer.preview);
if (thumb.dataset.srcKey !== next) {
thumb.dataset.srcKey = next;
thumb.src = next;
}
thumb.hidden = false;
thumb.onerror = () => {
thumb.hidden = true;
thumb.dataset.srcKey = "";
if (jobline) jobline.classList.remove("has-thumb");
if (jobico) jobico.hidden = false;
};
if (jobico) jobico.hidden = true;
} else {
thumb.hidden = true;
thumb.removeAttribute("src");
thumb.dataset.srcKey = "";
if (jobico) jobico.hidden = false;
}
}
const job = role("job");
if (job) {
setText(job, jobName(shownFile) || "");
job.title = shownFile || "";
}
setHtml(role("swatches"), swatchesHtml(printer));
setHtml(role("flow"), flowHtml(printer));
setHtml(role("strips"), stripsHtml(printer, state));
setHtml(role("controls"), actionsHtml(state, printer));
patchWallCam(article, printer);
}
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 || printer.has_camera === false) {
img.removeAttribute("src");
img.hidden = true;
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
setDeskCameraMode(false, "keine");
img.dataset.srcKey = "";
deskCamFails = 0;
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 streaming = typeof cameraMode === "function" && cameraMode() === "stream";
const next = typeof cameraUrl === "function" ? cameraUrl(printer, streaming ? 0 : 3000) : (withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 3000));
if (img.dataset.srcKey === next) return;
img.dataset.srcKey = next;
const onOk = () => {
if (img.dataset.srcKey !== next) return;
deskCamFails = 0;
img.src = next;
img.hidden = false;
empty.hidden = true;
setDeskCameraMode(true, streaming ? "stream" : "live");
};
const onFail = () => {
if (img.dataset.srcKey !== next) return;
deskCamFails += 1;
if (deskCamFails >= 2) {
img.removeAttribute("src");
img.hidden = true;
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
setDeskCameraMode(false, "offline");
img.dataset.srcKey = "";
return;
}
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 = streaming ? "stream · kurz weg" : "live · kurz weg";
};
if (streaming) {
img.onload = onOk;
img.onerror = onFail;
img.src = next;
return;
}
const probe = new Image();
probe.onload = onOk;
probe.onerror = onFail;
probe.src = next;
}
function startCameraTimer() {
stopCameraTimer();
const tick = () => {
if (!deskHost) return;
const printer = printerCache.get(deskHost);
if (printer) refreshDeskCamera(printer);
};
tick();
// Stream braucht keinen Poll — MJPEG läuft im <img>.
if (typeof cameraMode === "function" && cameraMode() === "stream") return;
cameraTimer = setInterval(tick, 3000);
}
window.addEventListener("grotti-camera-mode", () => {
if (!deskHost) return;
const img = deskEl.querySelector('[data-role="camera"]');
if (img) img.dataset.srcKey = "";
startCameraTimer();
});
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 shownFile = stickyFilename(printer, prevFile);
const fileChanged = prevFile !== shownFile;
deskEl.dataset.host = printer.host;
deskEl.dataset.filename = shownFile;
deskEl.dataset.progress = String(printer.progress || 0);
deskEl.classList.remove(...DESK_STATES);
deskEl.classList.add(state);
syncDeskCamCollapsed();
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");
const jobVisible = jobLineVisible(printer, state, shownFile);
if (jobline) jobline.hidden = !jobVisible && !printer.preview;
const job = role("job");
if (job) {
setText(job, jobName(shownFile) || (jobVisible ? "" : "Kein Auftrag"));
job.title = shownFile || "";
}
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("tech-facts"), techFactsHtml(printer));
setHtml(role("flow"), flowHtml(printer));
setHtml(role("strips"), stripsHtml(printer, state));
setHtml(role("controls"), actionsHtml(state, printer));
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) {
const panel = steer.querySelector(".steer");
const nextKey = aceSteerKey(printer);
if (!panel || fileChanged || panel.dataset.aceKey !== nextKey) {
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(() => {});
refreshDeskVerlauf(false);
}
async function refreshDeskVerlauf(force) {
if (!deskHost || deskEl.hidden) return;
const now = Date.now();
if (!force && deskVerlaufHost === deskHost && now - deskVerlaufAt < 20000) return;
const host = deskHost;
deskVerlaufAt = now;
deskVerlaufHost = host;
const box = deskEl.querySelector('[data-role="verlauf"]');
const caption = deskEl.querySelector('[data-role="verlauf-caption"]');
if (!box) return;
try {
const data = await api("/laeufe/aktuell?host=" + encodeURIComponent(host));
if (deskHost !== host) return;
const run = data.run;
if (!run) {
const empty = '<p class="desk-empty"><svg class="ico"><use href="#i-activity"/></svg><span>Noch kein Verlauf</span></p>';
if (deskVerlaufHtml !== empty) {
deskVerlaufHtml = empty;
box.innerHTML = empty;
}
if (caption) caption.textContent = "";
return;
}
const active = ["in_progress", "printing", "paused"].includes(run.state);
const energy = data.energy || {};
const kwh = energy.kwh != null
? (Number(energy.kwh) || 0).toLocaleString("de-DE", { maximumFractionDigits: 2 }) + " kWh"
+ (energy.source === "ed" ? " ED" : "")
: "";
const bits = [
active ? "läuft" : "letzter Druck",
formatDateRange(run.started_at, run.ended_at),
kwh,
].filter(Boolean);
if (caption) caption.textContent = bits.join(" · ");
const head = `<div class="desk-verlauf-head">
<span class="tiny grow">${escapeHtml(jobLabel(run.filename) || "Druck")}${run.chargen_nr ? " · " + escapeHtml(run.chargen_nr) : ""}</span>
<a class="ghost tiny-btn" href="/protokoll/${run.id}">Bericht →</a>
</div>`;
const html = head + tempChartHtml(data.samples || []);
if (deskVerlaufHtml !== html) {
deskVerlaufHtml = html;
box.innerHTML = html;
}
} catch (error) {
if (deskHost !== host) return;
const fail = '<p class="tiny msg">' + escapeHtml(error.message || "Verlauf nicht geladen") + "</p>";
if (deskVerlaufHtml !== fail) {
deskVerlaufHtml = fail;
box.innerHTML = fail;
}
}
}
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 = stickyFilename(printer, deskEl.dataset.filename || "");
const progress = Number(printer.progress || 0);
const gen = ++deskSyncGen;
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);
setText(caption, "lade vom Drucker …");
try {
const saved = await api("/dateien/von-drucker", {
method: "POST",
json: { host: deskHost, filename },
});
if (gen !== deskSyncGen) return;
libraryFiles = await api("/dateien");
if (gen !== deskSyncGen) return;
file = saved || matchLibraryFile(filename);
} catch (error) {
/* bleibt leer */
}
}
if (!file) {
// Kurz leerer filename: Pfad und Caption nicht verwerfen (sonst Flackern alle 5 s).
if (!filename && deskViewerFileId) return;
deskViewer.setPath({ points: [] });
deskViewerFileId = null;
setText(caption, filename ? "Datei nicht in Grotti" : "kein Auftrag");
renderExcludePanel(printer);
return;
}
try {
let data = toolpathCache.get(file.id);
const cachedOk = data && Number(data.version) === 8;
const needFetch = !cachedOk;
const needSetPath = deskViewerFileId !== file.id;
if (needFetch) {
if (needSetPath) setText(caption, "wird gelesen …");
data = await api("/dateien/" + file.id + "/pfad?tp=8");
if (gen !== deskSyncGen) return;
toolpathCache.set(file.id, data);
}
if (needFetch || needSetPath) {
deskViewer.setPath(data, { keepView: deskViewerFileId === file.id });
deskViewerFileId = file.id;
}
if (!(data.points && data.points.length)) {
setText(caption, "leer in Datei");
return;
}
} catch (error) {
// File-Id behalten — sonst setzt der nächste Poll die 3D-Ansicht zurück
if (gen !== deskSyncGen) return;
setText(caption, error.message || "fehlgeschlagen");
return;
}
if (gen !== deskSyncGen) 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
: "";
setText(caption, jobName(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();
deskVerlaufHtml = "";
deskVerlaufAt = 0;
}
const steer = deskEl.querySelector("[data-role=steer]");
if (!same || !steer.querySelector(".steer")) setHtml(steer, steerPanelHtml(printer));
patchDesk(printer);
refreshDeskVerlauf(true);
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 = "";
deskVerlaufHtml = "";
deskVerlaufHost = "";
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 || deskHost) {
try { wallSpools = await api("/spulen"); } catch (error) { /* behalten */ }
}
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" });
if (!all.length) {
lastFleetKey = "";
cardNodes.clear();
cardsEl.innerHTML = `<div class="empty-state">${icon("printer", "lg")}<p>Noch keine Drucker.</p><p class="tiny">Unter Einstellungen anlegen.</p><a href="/einstellungen">Zu den Einstellungen</a></div>`;
if (deskHost) closeDesk();
return;
}
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(() => {});
window.onDruckerLive = () => 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.");
} else if (go === "dryer_start") {
const id = Number(steerBtn.dataset.dryerId);
const temp = val("dryer-temp-" + id);
const duration = val("dryer-dur-" + id);
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST",
json: { action: "dryer_start", id, temp, duration },
});
toast("Trockner gestartet.");
} else if (go === "dryer_stop") {
const id = Number(steerBtn.dataset.dryerId);
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST",
json: { action: "dryer_stop", id },
});
toast("Trockner gestoppt.");
} else if (go === "slot_setzen") {
const aceSlot = Number(steerBtn.dataset.aceSlot);
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST",
json: { action: "slot_setzen", ace_slot: aceSlot },
});
toast("Platz an den Drucker geschrieben.");
}
showNote("note", null);
refresh();
} catch (error) {
showNote("note", error);
toast(error.message, "bad");
} 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") {
(async () => {
const payload = await promptAbraeumen(host);
if (!payload) return;
button.disabled = true;
try {
await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", {
method: "POST",
json: payload,
});
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 {
if (button.dataset.cmd === "fortsetzen") {
const card = printerCache.get(host);
if (card && filamentPauseHint(card)) {
toast("Neue Spule unter Material dem Drucker zuweisen — sonst bleibt der Abzug bei der alten Rolle.");
}
}
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));
createPoller(() => refresh().catch((error) => showNote("note", error)), 12000).start();
setInterval(refreshWallCams, 3000);
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 %}