diff options
| -rw-r--r-- | AGENTS.md | 8 | ||||
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | config.toml | 6 | ||||
| -rw-r--r-- | deckit/modules/k1c.py | 147 | ||||
| -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 | ||||
| -rw-r--r-- | tests/test_k1c.py | 108 |
11 files changed, 651 insertions, 12 deletions
@@ -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. @@ -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() |
