aboutsummaryrefslogtreecommitdiffstats
path: root/static/modules
diff options
context:
space:
mode:
Diffstat (limited to 'static/modules')
-rw-r--r--static/modules/k1c.js199
-rw-r--r--static/modules/llm.js10
-rw-r--r--static/modules/obs.js15
-rw-r--r--static/modules/sbbuilder.js10
4 files changed, 226 insertions, 8 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;
+ },
+};
diff --git a/static/modules/llm.js b/static/modules/llm.js
index 22f8737..9ee28e1 100644
--- a/static/modules/llm.js
+++ b/static/modules/llm.js
@@ -14,6 +14,8 @@
// 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";
+
const num = (v) => (v == null ? null : Number(v));
const money = (v) => (num(v) == null ? "—" : `$${num(v).toFixed(2)}`);
const tokens = (n) => {
@@ -83,11 +85,15 @@ export default {
update(state, el) {
const box = el.querySelector(".llm");
- box.textContent = "";
if (state.offline) {
- box.textContent = "llm-cost-dashboard offline";
+ offline(box, {
+ icon: "/logos/claude.svg",
+ title: "Usage dashboard is off",
+ detail: "Start llm-cost-dashboard and Claude and opencode usage appear here.",
+ });
return;
}
+ box.textContent = "";
const c = state.claude ?? {};
const o = state.opencode ?? {};
diff --git a/static/modules/obs.js b/static/modules/obs.js
index 3cc4197..5005ffc 100644
--- a/static/modules/obs.js
+++ b/static/modules/obs.js
@@ -17,6 +17,8 @@
// OBS as a stream-deck tab: icon-forward buttons and volume sliders, sized
// for a finger rather than a mouse.
+import { offline } from "../offline.js";
+
const ICONS = {
record: '<circle cx="12" cy="12" r="7"/>',
stream: '<path d="M2 8.8a15 15 0 0 1 20 0"/><path d="M5 12.5a10 10 0 0 1 14 0"/><path d="M8.5 16.2a5 5 0 0 1 7 0"/><circle cx="12" cy="20" r="1"/>',
@@ -154,11 +156,16 @@ export default {
update(state, el) {
const transport = el.querySelector(".deck-transport");
+ // Off, the empty Scenes/Audio/Sources/Transition blocks would only be
+ // headings, so they go and the card says what brings OBS back.
+ for (const cols of el.querySelectorAll(".deck-cols")) cols.hidden = !state.connected;
if (!state.connected) {
- transport.innerHTML = `<div class="deck-offline">${state.error ? `OBS offline: ${state.error}` : "OBS offline"}</div>`;
- for (const box of el.querySelectorAll(".scenes, .sources, .mixer, .transitions")) {
- box.textContent = "";
- }
+ offline(transport, {
+ icon: "/icons/com.obsproject.Studio.svg",
+ title: "OBS is not running",
+ detail: "Start OBS and its scenes, sources and audio appear here.",
+ reason: state.error,
+ });
return;
}
renderTransport(transport, state);
diff --git a/static/modules/sbbuilder.js b/static/modules/sbbuilder.js
index 9e49d90..6285dd5 100644
--- a/static/modules/sbbuilder.js
+++ b/static/modules/sbbuilder.js
@@ -14,6 +14,8 @@
// 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";
+
const gib = (b) => (b == null ? "?" : (b / 1073741824).toFixed(1));
const pct = (n) => (n == null ? "?" : `${Math.round(n)}%`);
const ago = (ts) => {
@@ -52,11 +54,15 @@ export default {
update(state, el) {
const box = el.querySelector(".sbb");
- box.textContent = "";
if (state.offline) {
- box.textContent = "build VM unreachable";
+ offline(box, {
+ icon: "/icons/virt-manager.svg",
+ title: "Build VM is off",
+ detail: "Start the VM and its jobs and package counts appear here.",
+ });
return;
}
+ box.textContent = "";
const s = state.stats ?? {};
const p = s.packages ?? {};
const sr = s.slackrepo ?? {};