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