aboutsummaryrefslogtreecommitdiffstats
path: root/deckit/modules
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-10 10:33:13 +0100
committerDanilo M. <danix@danix.xyz>2026-11-10 10:33:13 +0100
commit6933aa3220ff420beeb883d25fc68b9c79aa7df1 (patch)
tree0ca54434b4b8c8d0768d1d5165c2e00e33fde6ac /deckit/modules
parent213a82118a7f5db58759edffe8d1776475e4e4e4 (diff)
downloaddeck-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 'deckit/modules')
-rw-r--r--deckit/modules/k1c.py147
1 files changed, 147 insertions, 0 deletions
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})