// deck-it - the K1C tab: a Klipper printer, read-only bar the emergency stop // Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import { offline } from "../offline.js"; // Holding the stop for this long fires it: fast in an emergency, but a // stray touch on an always-on tablet cannot kill a print. const HOLD_MS = 800; const STATES = { printing: ["Printing", "good"], paused: ["Paused", "warn"], complete: ["Complete", "good"], cancelled: ["Cancelled", "warn"], error: ["Error", "bad"], standby: ["Idle", ""], }; const temp = (h) => (h?.temp == null ? "—" : `${Math.round(h.temp)}°`); const target = (h) => (h?.target ? `→ ${Math.round(h.target)}° · ${Math.round((h.power ?? 0) * 100)}%` : "off"); function dur(s) { if (s == null) return "—"; s = Math.round(s); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); return h ? `${h}h ${String(m).padStart(2, "0")}m` : `${m}m`; } function tile(label, value, sub) { const d = document.createElement("div"); d.className = "mtile"; d.innerHTML = ''; d.children[0].textContent = label; d.children[1].textContent = value; d.children[2].textContent = sub ?? ""; return d; } async function emergencyStop(btn) { btn.classList.add("sent"); btn.querySelector(".estop-label").textContent = "Stopping…"; try { const r = await fetch("/api/k1c/emergency_stop", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); btn.querySelector(".estop-label").textContent = r.ok ? "Stop sent" : "Stop FAILED"; } catch { btn.querySelector(".estop-label").textContent = "Stop FAILED"; } setTimeout(() => { btn.classList.remove("sent"); btn.querySelector(".estop-label").textContent = "Emergency stop"; }, 4000); } // Press and hold: the ring fills over HOLD_MS, releasing early cancels. function armHold(btn) { let timer = null; const cancel = () => { clearTimeout(timer); timer = null; btn.classList.remove("holding"); }; btn.addEventListener("pointerdown", (e) => { e.preventDefault(); btn.setPointerCapture(e.pointerId); btn.classList.add("holding"); timer = setTimeout(() => { cancel(); emergencyStop(btn); }, HOLD_MS); }); btn.addEventListener("pointerup", cancel); btn.addEventListener("pointercancel", cancel); btn.style.setProperty("--hold", `${HOLD_MS}ms`); } export default { title: "K1C", tab: "K1C", interval: 1, mount(el) { el.innerHTML = `
`; armHold(el.querySelector(".estop")); }, update(st, el) { const off = el.querySelector(".k1c-off"); const on = el.querySelector(".k1c-on"); off.hidden = !st.offline; on.hidden = Boolean(st.offline); if (st.offline) { offline(off, st.unconfigured ? { icon: "/icons/prusa-slicer.svg", title: "No printer configured", detail: "Set the K1C's Moonraker address on keystone's deck-it page." } : { icon: "/icons/prusa-slicer.svg", title: "K1C is off", detail: "Switch the printer on and its progress, temperatures and camera appear here." }); return; } // Klipper itself down (after an emergency stop, or a config error) wins // over the print state, which then reads stale. const klippyDown = st.klippy && st.klippy !== "ready"; const [label, cls] = klippyDown ? [st.klippy === "shutdown" ? "Stopped" : "Klipper " + st.klippy, "bad"] : STATES[st.state] ?? [st.state ?? "?", ""]; const pill = el.querySelector(".k1c-state"); pill.textContent = label; pill.className = `k1c-state ${cls}`; el.querySelector(".k1c-file").textContent = st.file ? st.file.replace(/\.gcode$/i, "") : "no job"; const alert = el.querySelector(".k1c-alert"); alert.hidden = !klippyDown; alert.textContent = klippyDown ? `${st.message || "Klipper is not ready"}` : ""; // The slicer's preview of the model, loaded from the printer like the // camera; set only when the URL changes, so it does not reload each poll. const thumb = el.querySelector(".k1c-thumb"); if (!st.thumbnail || !st.file) thumb.hidden = true; else if (thumb.dataset.src !== st.thumbnail) { thumb.dataset.src = st.thumbnail; thumb.hidden = true; thumb.onload = () => { thumb.hidden = false; }; thumb.onerror = () => { thumb.hidden = true; }; thumb.src = st.thumbnail; } else if (thumb.complete && thumb.naturalWidth) thumb.hidden = false; const active = st.state === "printing" || st.state === "paused"; const pct = Math.round((st.progress ?? 0) * 100); el.querySelector(".k1c-progress").hidden = !active && !(st.state === "complete"); el.querySelector(".k1c-pct").textContent = `${pct}%`; el.querySelector(".k1c-progress .gbar > i").style.width = `${pct}%`; const eta = st.remaining != null ? new Date(Date.now() + st.remaining * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : null; el.querySelector(".k1c-times").textContent = [ `elapsed ${dur(st.elapsed)}`, st.remaining != null ? `left ${dur(st.remaining)} · done ${eta}` : null, st.layers ? `layer ${st.layer ?? "?"} / ${st.layers}` : null, ].filter(Boolean).join(" · "); const tiles = el.querySelector(".k1c-tiles"); tiles.textContent = ""; const [x, y, z] = st.position ?? []; tiles.append( tile("Nozzle", temp(st.extruder), target(st.extruder)), tile("Bed", temp(st.bed), target(st.bed)), tile("Part fan", st.fan == null ? "—" : `${Math.round(st.fan * 100)}%`), tile("Speed", st.speed_factor == null ? "—" : `${Math.round(st.speed_factor * 100)}%`), tile("Z", z == null ? "—" : `${z.toFixed(2)}`, x == null ? "" : `X ${x.toFixed(0)} · Y ${y.toFixed(0)}`), tile("Filament", st.filament ? `${(st.filament / 1000).toFixed(2)} m` : "—"), ); // The live MJPEG stream, which an plays by itself, fetched by the // tablet from the printer directly. Connected only while this tab is on // screen: a hidden tab would keep the printer streaming for nothing. // ponytail: needs the tablet on the printer's LAN; proxy it through the // server if it ever is not. const cam = el.querySelector(".k1c-cam"); const shown = st.camera && !document.hidden && el.offsetParent !== null; if (shown && cam.dataset.src !== st.camera) { cam.dataset.src = st.camera; // Shown at once: a never-ending stream may never fire load. Dropped // (camera restarted): hide it and forget it, so the next poll reconnects. cam.onerror = () => { cam.hidden = true; delete cam.dataset.src; }; cam.src = st.camera; cam.hidden = false; } else if (!shown && cam.dataset.src) { delete cam.dataset.src; cam.removeAttribute("src"); cam.hidden = true; } }, };