diff options
Diffstat (limited to 'static/modules')
| -rw-r--r-- | static/modules/k1c.js | 199 | ||||
| -rw-r--r-- | static/modules/llm.js | 10 | ||||
| -rw-r--r-- | static/modules/obs.js | 15 | ||||
| -rw-r--r-- | static/modules/sbbuilder.js | 10 |
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 ?? {}; |
