diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-10 10:33:13 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-10 10:33:13 +0100 |
| commit | 6933aa3220ff420beeb883d25fc68b9c79aa7df1 (patch) | |
| tree | 0ca54434b4b8c8d0768d1d5165c2e00e33fde6ac /static | |
| parent | 213a82118a7f5db58759edffe8d1776475e4e4e4 (diff) | |
| download | deck-it-6933aa3220ff420beeb883d25fc68b9c79aa7df1.tar.gz deck-it-6933aa3220ff420beeb883d25fc68b9c79aa7df1.zip | |
feat: K1C printer tab and proper cards for switched-off sources
A K1C tab follows a Klipper printer through Moonraker ([k1c] url):
state, file, progress with elapsed, left and finish time, layer, the
slicer's model thumbnail, nozzle and bed with target and power, fan,
speed, position, filament, and the camera snapshot. It is read-only bar
one action, the emergency stop (M112), behind a press-and-hold button so
a stray touch on an always-on tablet cannot kill a print. Klipper being
down (after a stop) shows its message in a red banner.
The printer is often off, so a failed poll backs off 10s instead of
costing every tick a timeout. File metadata is fetched once per job; the
slicer's estimate covers the start of a print until the file-position
estimate settles. The tablet loads the camera and the thumbnail straight
from the printer, so both need it on the printer's LAN.
OBS, the build VM and LLM usage showed raw text when their source was
off ("OBS offline: [Errno 111] ..."). They now share static/offline.js:
a greyed app icon, what is off, what brings it back, and a pulsing
"checking" note, with the raw error in the tooltip. OBS hides its empty
blocks while off. The card is not rebuilt on every poll.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'static')
| -rw-r--r-- | static/deck.css | 119 | ||||
| -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 | ||||
| -rw-r--r-- | static/offline.js | 37 |
6 files changed, 378 insertions, 12 deletions
diff --git a/static/deck.css b/static/deck.css index 2484e1b..f0a9fdc 100644 --- a/static/deck.css +++ b/static/deck.css @@ -447,11 +447,122 @@ select { .chan input[type=range] { flex: 1; min-width: 60px; accent-color: var(--udt-accent); } .chan-pct { flex: none; min-width: 40px; text-align: right; font-size: 0.857rem; } -.deck-offline { - flex: 1; +/* What a panel shows while the thing it watches is off (static/offline.js). */ +.offline { + height: 100%; + min-height: 140px; display: flex; + flex-direction: column; align-items: center; justify-content: center; - min-height: 120px; - color: var(--udt-subtext0); + gap: 4px; + padding: 16px; + text-align: center; +} +.off-icon { + width: 48px; + height: 48px; + margin-bottom: 6px; + filter: grayscale(1); + opacity: .45; +} +.off-title { font-size: 1.143rem; font-weight: 600; color: var(--udt-subtext1, var(--udt-text)); } +.off-detail { max-width: 32ch; font-size: 0.857rem; color: var(--udt-overlay0); } +.off-poll { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; + font-size: 0.714rem; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--udt-overlay0); +} +.off-poll i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--udt-overlay0); + animation: off-pulse 2s ease-in-out infinite; +} +@keyframes off-pulse { 50% { opacity: .2; } } +@media (prefers-reduced-motion: reduce) { .off-poll i { animation: none; } } + +/* The K1C tab. */ +.k1c { display: flex; flex-direction: column; gap: 14px; padding: 4px 2px; } +.k1c-on { display: flex; flex-direction: column; gap: 14px; } +.k1c-on[hidden], .k1c-off[hidden] { display: none; } +.k1c-head { display: flex; align-items: center; gap: 12px; } +.k1c-state { + flex: none; + padding: 4px 12px; + border-radius: 999px; + background: var(--udt-surface1); + font-weight: 600; +} +.k1c-state.good { background: var(--udt-green); color: var(--udt-crust); } +.k1c-state.warn { background: var(--udt-yellow); color: var(--udt-crust); } +.k1c-state.bad { background: var(--udt-red); color: var(--udt-crust); } +.k1c-file { flex: 1; min-width: 0; font-size: 1.143rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.k1c-alert { + padding: 10px 14px; + border-radius: 12px; + border: 1px solid var(--udt-red); + color: var(--udt-red); } +.k1c-job { display: flex; align-items: center; gap: 16px; } +.k1c-thumb { + flex: none; + width: 120px; + height: 120px; + object-fit: contain; + padding: 6px; + border-radius: 14px; + background: var(--udt-surface0); + border: 1px solid var(--udt-surface1); +} +.k1c-thumb[hidden] { display: none; } +.k1c-progress { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; } +.k1c-progress[hidden] { display: none; } +.k1c-pct { font-size: 2.571rem; font-weight: 600; line-height: 1; } +.k1c-progress .gbar { height: 10px; border-radius: 5px; } +.k1c-times { font-size: 0.857rem; color: var(--udt-subtext0); } +.k1c-body { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; } +@media (max-width: 900px) { .k1c-body { grid-template-columns: 1fr; } } +.k1c-tiles { grid-template-columns: 1fr 1fr; } +.k1c-cam { width: 100%; border-radius: 12px; background: var(--udt-crust); } + +/* Press and hold: the fill grows across the button over --hold. */ +.estop { + flex: none; + position: relative; + overflow: hidden; + min-width: 150px; + min-height: 64px; + padding: 0 18px; + border: 2px solid var(--udt-red); + border-radius: 16px; + background: color-mix(in srgb, var(--udt-red) 22%, transparent); + color: var(--udt-text); + font: inherit; + font-weight: 700; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + touch-action: none; + cursor: pointer; +} +.estop::before { + content: ""; + position: absolute; + inset: 0; + background: var(--udt-red); + transform: scaleX(0); + transform-origin: left; +} +.estop.holding::before { transform: scaleX(1); transition: transform var(--hold, 800ms) linear; } +.estop.sent { background: var(--udt-red); color: var(--udt-crust); } +.estop > span { position: relative; } +.estop-label { font-size: 1.071rem; text-transform: uppercase; letter-spacing: .04em; } +.estop-hint { font-size: 0.714rem; font-weight: 400; opacity: .7; } 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 ?? {}; diff --git a/static/offline.js b/static/offline.js new file mode 100644 index 0000000..f3e7f62 --- /dev/null +++ b/static/offline.js @@ -0,0 +1,37 @@ +// deck-it - the card a panel shows while what it watches is switched off +// 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. + +// Replaces box's content with a dimmed app icon, a title, a line on what +// brings it back, and a pulsing "checking" note, since the poll keeps going +// and the panel returns by itself. icon is a URL; a missing one is hidden. +// The raw reason, if any, is kept in the tooltip rather than shown. +export function offline(box, { icon, title, detail, reason }) { + const key = `${icon}|${title}|${detail}|${reason ?? ""}`; + if (box._offline === key && box.firstElementChild?.classList.contains("offline")) return; + box._offline = key; + box.innerHTML = `<div class="offline"> + <img class="off-icon" alt=""> + <div class="off-title"></div> + <div class="off-detail"></div> + <div class="off-poll"><i></i>checking</div> + </div>`; + const img = box.querySelector(".off-icon"); + img.onerror = () => img.remove(); + img.src = icon; + box.querySelector(".off-title").textContent = title; + box.querySelector(".off-detail").textContent = detail; + if (reason) box.firstElementChild.title = reason; +} |
