From 6933aa3220ff420beeb883d25fc68b9c79aa7df1 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 10 Nov 2026 10:33:13 +0100 Subject: 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 --- AGENTS.md | 8 ++ README.md | 4 + config.toml | 6 ++ deckit/modules/k1c.py | 147 ++++++++++++++++++++++++++++++++ static/deck.css | 119 +++++++++++++++++++++++++- static/modules/k1c.js | 199 ++++++++++++++++++++++++++++++++++++++++++++ static/modules/llm.js | 10 ++- static/modules/obs.js | 15 +++- static/modules/sbbuilder.js | 10 ++- static/offline.js | 37 ++++++++ tests/test_k1c.py | 108 ++++++++++++++++++++++++ 11 files changed, 651 insertions(+), 12 deletions(-) create mode 100644 deckit/modules/k1c.py create mode 100644 static/modules/k1c.js create mode 100644 static/offline.js create mode 100644 tests/test_k1c.py 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. +# +# 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. +// +// 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 = ''; + 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 = `
+
+
+
+ + + +
+ +
+ +
+
+
+
+
+
+
+
+ +
+
+
`; + 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: '', stream: '', @@ -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 = `
${state.error ? `OBS offline: ${state.error}` : "OBS offline"}
`; - 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. +// +// 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 = `
+ +
+
+
checking
+
`; + 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. +# +# 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() -- cgit v1.2.3