aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md8
-rw-r--r--README.md4
-rw-r--r--config.toml6
-rw-r--r--deckit/modules/k1c.py147
-rw-r--r--static/deck.css119
-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
-rw-r--r--static/offline.js37
-rw-r--r--tests/test_k1c.py108
11 files changed, 651 insertions, 12 deletions
diff --git a/AGENTS.md b/AGENTS.md
index e1d804e..0de3b9b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,6 +43,14 @@ Keep the server dependency-free apart from that one package.
Keystone's deck-it page (quickshell repo, `desktop/modules/deckit`) writes
that sparse file and reads the defaults from `deck-it defaults`; keep that
JSON shape stable.
+- A panel whose source can be switched off (OBS, build VM, LLM, K1C) shows
+ `static/offline.js`'s card, not raw error text; the error goes in its tooltip.
+- `k1c` reads a Klipper printer through Moonraker (`[k1c] url`); its one action
+ is the emergency stop, a press-and-hold button on the tab. While the printer
+ is off it backs off 10s between tries. The camera snapshot and the slicer
+ thumbnail are loaded by the tablet straight from the printer.
+- The page holds a Screen Wake Lock while visible, so the tablet never sleeps
+ over the dashboard; nothing on the tablet's settings is changed.
- Launchers are `[launch] apps`, desktop ids run with `gtk-launch`; label and
icon come from the `.desktop` file. The client sends the id, which must be
configured and installed.
diff --git a/README.md b/README.md
index 42f16ae..de2c618 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,10 @@ launchers included, with a Restart button to apply a save.
Long-press a source to open its properties dialog in OBS.
- The Windows tab has workspaces, icon launchers, and a per-window `⋮` menu to
close it or move it to another desktop.
+- The K1C tab follows a Klipper printer through Moonraker (`[k1c] url`):
+ state, progress with the slicer's model preview, temperatures, camera, and
+ a red emergency stop that fires on a press and hold.
+- The screen stays on while the dashboard is in front.
## License
diff --git a/config.toml b/config.toml
index 17a907d..f3db547 100644
--- a/config.toml
+++ b/config.toml
@@ -47,6 +47,7 @@ windows = true
obs = true
llm = true
sbbuilder = true
+k1c = true
assistant = false
[obs]
@@ -64,6 +65,11 @@ url = "http://127.0.0.1:8420"
# ssh host alias of the build VM; the module runs `sbb-remote jobs` there.
host = "buildsystem"
+[k1c]
+# Moonraker on a Klipper printer, like "http://192.0.2.20:7125". Empty: the
+# K1C tab says no printer is configured.
+url = ""
+
[windows]
# Workspaces shown on the Windows tab, including empty ones.
workspaces = [1, 2, 3, 4, 5, 6, 7, 8]
diff --git a/deckit/modules/k1c.py b/deckit/modules/k1c.py
new file mode 100644
index 0000000..bc7c439
--- /dev/null
+++ b/deckit/modules/k1c.py
@@ -0,0 +1,147 @@
+# deck-it - a Klipper printer (Creality K1C) through Moonraker
+# 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.
+
+"""A Klipper printer, read through Moonraker's HTTP API.
+
+Read-only apart from one action, the emergency stop (M112), which Klipper
+answers by shutting down at once. The printer is often switched off, so a
+failed poll backs off instead of costing every tick a timeout.
+"""
+
+import json
+import posixpath
+import time
+import urllib.parse
+import urllib.request
+
+from ..module import Module, ModuleError
+
+QUERY = ("/printer/objects/query?webhooks&print_stats&virtual_sdcard"
+ "&extruder=temperature,target,power&heater_bed=temperature,target,power"
+ "&fan=speed&toolhead=position,homed_axes&gcode_move=speed_factor")
+BACKOFF = 10 # seconds between tries while the printer is off
+
+
+def _http(url, data=None, timeout=2):
+ req = urllib.request.Request(url, data=data, method="POST" if data is not None else "GET")
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ return json.loads(r.read())
+
+
+def _snapshot(webcams) -> str | None:
+ """The first enabled webcam's snapshot URL, if it is plain http(s)."""
+ for cam in webcams or []:
+ url = cam.get("snapshot_url") or ""
+ if cam.get("enabled", True) and url.startswith(("http://", "https://")):
+ return url
+ return None
+
+
+def _thumbnail(base: str, filename: str, meta: dict) -> str | None:
+ """The largest slicer thumbnail's URL; relative to the gcode's own folder."""
+ thumbs = [t for t in meta.get("thumbnails") or [] if t.get("relative_path")]
+ if not thumbs:
+ return None
+ best = max(thumbs, key=lambda t: (t.get("width") or 0) * (t.get("height") or 0))
+ path = posixpath.join(posixpath.dirname(filename), best["relative_path"])
+ return f"{base}/server/files/gcodes/{urllib.parse.quote(path)}"
+
+
+def summarize(status: dict, snapshot: str | None = None) -> dict:
+ """The panel's state from Moonraker's object status."""
+ ps = status.get("print_stats") or {}
+ sd = status.get("virtual_sdcard") or {}
+ hook = status.get("webhooks") or {}
+ progress = sd.get("progress") or 0.0
+ elapsed = ps.get("print_duration") or 0.0
+ # Remaining from progress by file position, as Fluidd's "file" estimate.
+ remaining = elapsed / progress - elapsed if progress > 0.01 and elapsed else None
+ info = ps.get("info") or {}
+
+ def heater(name):
+ h = status.get(name) or {}
+ return {"temp": h.get("temperature"), "target": h.get("target"), "power": h.get("power")}
+
+ return {
+ "offline": False,
+ "klippy": hook.get("state"), # ready, startup, shutdown, error
+ "message": hook.get("state_message"),
+ "state": ps.get("state"), # standby, printing, paused, complete, cancelled, error
+ "file": ps.get("filename") or None,
+ "progress": progress,
+ "elapsed": elapsed,
+ "remaining": remaining,
+ "layer": info.get("current_layer"),
+ "layers": info.get("total_layer"),
+ "filament": ps.get("filament_used"),
+ "extruder": heater("extruder"),
+ "bed": heater("heater_bed"),
+ "fan": (status.get("fan") or {}).get("speed"),
+ "position": ((status.get("toolhead") or {}).get("position") or [None] * 3)[:3],
+ "speed_factor": (status.get("gcode_move") or {}).get("speed_factor"),
+ "snapshot": snapshot,
+ }
+
+
+def make(cfg: dict) -> Module:
+ base = str(cfg.get("k1c", {}).get("url", "")).rstrip("/")
+ http = cfg.get("http", _http)
+ down = {"until": 0.0, "snapshot": None}
+ # The current file's slicer metadata, fetched once per file.
+ meta = {"file": None, "thumbnail": None, "estimate": None}
+
+ def file_meta(filename):
+ if filename == meta["file"]:
+ return
+ meta.update(file=filename, thumbnail=None, estimate=None)
+ if not filename:
+ return
+ try:
+ m = http(base + "/server/files/metadata?filename=" + urllib.parse.quote(filename))["result"]
+ except (OSError, ValueError, KeyError, TypeError):
+ meta["file"] = None # try again next poll
+ return
+ meta.update(thumbnail=_thumbnail(base, filename, m), estimate=m.get("estimated_time"))
+
+ def state():
+ if not base:
+ return {"offline": True, "unconfigured": True}
+ if time.monotonic() < down["until"]:
+ return {"offline": True}
+ try:
+ status = http(base + QUERY)["result"]["status"]
+ if down["snapshot"] is None:
+ down["snapshot"] = _snapshot(http(base + "/server/webcams/list")["result"]["webcams"]) or ""
+ except (OSError, ValueError, KeyError, TypeError):
+ down["until"] = time.monotonic() + BACKOFF
+ return {"offline": True}
+ st = summarize(status, down["snapshot"] or None)
+ file_meta(st["file"])
+ st["thumbnail"] = meta["thumbnail"]
+ # Until the file-position estimate settles, the slicer's is better.
+ if st["remaining"] is None and meta["estimate"] and st["state"] == "printing":
+ st["remaining"] = max(0.0, meta["estimate"] - st["elapsed"])
+ return st
+
+ def emergency_stop(body):
+ if not base:
+ raise ModuleError("no printer configured")
+ try:
+ http(base + "/printer/emergency_stop", data=b"", timeout=3)
+ except (OSError, ValueError) as e:
+ raise ModuleError(f"emergency stop failed: {e}") from e
+
+ return Module(name="k1c", title="K1C", state=state, actions={"emergency_stop": emergency_stop})
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;
+}
diff --git a/tests/test_k1c.py b/tests/test_k1c.py
new file mode 100644
index 0000000..b82869e
--- /dev/null
+++ b/tests/test_k1c.py
@@ -0,0 +1,108 @@
+# deck-it - tests for the K1C printer module
+# 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 sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+import unittest
+
+import deckit.modules.k1c as k1c
+
+URL = "http://printer.example.org:7125"
+STATUS = {
+ "webhooks": {"state": "ready", "state_message": "Printer is ready"},
+ "print_stats": {"state": "printing", "filename": "part.gcode", "print_duration": 600.0,
+ "filament_used": 1234.5, "info": {"current_layer": 12, "total_layer": 80}},
+ "virtual_sdcard": {"progress": 0.25},
+ "extruder": {"temperature": 219.8, "target": 220.0, "power": 0.4},
+ "heater_bed": {"temperature": 60.1, "target": 60.0, "power": 0.2},
+ "fan": {"speed": 1.0},
+ "toolhead": {"position": [10.0, 20.0, 3.2, 100.0]},
+ "gcode_move": {"speed_factor": 1.0},
+}
+CAMS = {"result": {"webcams": [{"enabled": True, "snapshot_url": "http://printer.example.org:8080/?action=snapshot"}]}}
+
+
+class Fake:
+ def __init__(self, down=False):
+ self.calls, self.down = [], down
+
+ def __call__(self, url, data=None, timeout=2):
+ self.calls.append((url, data))
+ if self.down:
+ raise OSError("connection refused")
+ if url.endswith("/server/webcams/list"):
+ return CAMS
+ if "/server/files/metadata" in url:
+ return {"result": {"estimated_time": 2700, "thumbnails": [
+ {"width": 32, "height": 32, "relative_path": ".thumbs/part-32x32.png"},
+ {"width": 300, "height": 300, "relative_path": ".thumbs/part-300x300.png"}]}}
+ if url.endswith("/printer/emergency_stop"):
+ return {"result": "ok"}
+ return {"result": {"status": STATUS}}
+
+
+class K1C(unittest.TestCase):
+ def test_summarizes_a_print(self):
+ st = k1c.make({"k1c": {"url": URL}, "http": Fake()}).state()
+ self.assertEqual(st["state"], "printing")
+ self.assertEqual(st["remaining"], 1800.0) # 600s for 25%: 1800s to go
+ self.assertEqual((st["layer"], st["layers"]), (12, 80))
+ self.assertEqual(st["extruder"]["target"], 220.0)
+ self.assertEqual(st["position"], [10.0, 20.0, 3.2])
+ self.assertTrue(st["snapshot"].endswith("action=snapshot"))
+ self.assertEqual(st["thumbnail"], URL + "/server/files/gcodes/.thumbs/part-300x300.png")
+
+ def test_thumbnail_is_relative_to_the_file_and_quoted(self):
+ url = k1c._thumbnail(URL, "jobs/my part.gcode", {"thumbnails": [{"width": 9, "height": 9,
+ "relative_path": ".thumbs/my part-9x9.png"}]})
+ self.assertEqual(url, URL + "/server/files/gcodes/jobs/.thumbs/my%20part-9x9.png")
+ self.assertIsNone(k1c._thumbnail(URL, "x.gcode", {}))
+
+ def test_metadata_is_fetched_once_per_file(self):
+ fake = Fake()
+ m = k1c.make({"k1c": {"url": URL}, "http": fake})
+ m.state(), m.state()
+ self.assertEqual(sum("/server/files/metadata" in u for u, _ in fake.calls), 1)
+
+ def test_off_backs_off_instead_of_polling_every_tick(self):
+ fake = Fake(down=True)
+ m = k1c.make({"k1c": {"url": URL}, "http": fake})
+ self.assertTrue(m.state()["offline"])
+ self.assertTrue(m.state()["offline"])
+ self.assertEqual(len(fake.calls), 1)
+
+ def test_unconfigured(self):
+ self.assertTrue(k1c.make({}).state()["unconfigured"])
+
+ def test_emergency_stop_posts_m112(self):
+ fake = Fake()
+ k1c.make({"k1c": {"url": URL}, "http": fake}).actions["emergency_stop"]({})
+ self.assertEqual(fake.calls, [(URL + "/printer/emergency_stop", b"")])
+
+ def test_emergency_stop_failure_is_reported(self):
+ m = k1c.make({"k1c": {"url": URL}, "http": Fake(down=True)})
+ with self.assertRaises(k1c.ModuleError):
+ m.actions["emergency_stop"]({})
+
+ def test_snapshot_needs_http(self):
+ self.assertIsNone(k1c._snapshot([{"snapshot_url": "javascript:alert(1)"}]))
+
+
+if __name__ == "__main__":
+ unittest.main()