1765 lines
69 KiB
HTML
1765 lines
69 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"><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 %}
|