aboutsummaryrefslogtreecommitdiffstats
path: root/static/modules/k1c.js
diff options
context:
space:
mode:
Diffstat (limited to 'static/modules/k1c.js')
-rw-r--r--static/modules/k1c.js199
1 files changed, 199 insertions, 0 deletions
diff --git a/static/modules/k1c.js b/static/modules/k1c.js
new file mode 100644
index 0000000..e7d3ed1
--- /dev/null
+++ b/static/modules/k1c.js
@@ -0,0 +1,199 @@
+// deck-it - the K1C tab: a Klipper printer, read-only bar the emergency stop
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// 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 = '<span class="mlabel"></span><span class="mvalue num"></span><span class="msub"></span>';
+ 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: 2,
+
+ mount(el) {
+ el.innerHTML = `<div class="k1c">
+ <div class="k1c-off"></div>
+ <div class="k1c-on">
+ <div class="k1c-head">
+ <span class="k1c-state"></span>
+ <span class="k1c-file"></span>
+ <button class="estop" aria-label="Emergency stop: press and hold">
+ <span class="estop-label">Emergency stop</span><span class="estop-hint">hold</span>
+ </button>
+ </div>
+ <div class="k1c-alert" hidden></div>
+ <div class="k1c-job">
+ <img class="k1c-thumb" alt="Model preview" hidden>
+ <div class="k1c-progress">
+ <div class="k1c-pct num"></div>
+ <div class="gbar"><i></i></div>
+ <div class="k1c-times"></div>
+ </div>
+ </div>
+ <div class="k1c-body">
+ <div class="stats k1c-tiles"></div>
+ <img class="k1c-cam" alt="Printer camera" hidden>
+ </div>
+ </div>
+ </div>`;
+ 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` : "—"),
+ );
+
+ // A fresh snapshot each poll, fetched by the tablet from the printer
+ // directly. 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");
+ if (st.snapshot && !cam._loading) {
+ cam._loading = true;
+ cam.onload = () => { cam._loading = false; cam.hidden = false; };
+ cam.onerror = () => { cam._loading = false; cam.hidden = true; };
+ cam.src = `${st.snapshot}${st.snapshot.includes("?") ? "&" : "?"}t=${Date.now()}`;
+ }
+ if (!st.snapshot) cam.hidden = true;
+ },
+};