aboutsummaryrefslogtreecommitdiffstats
path: root/deckit
diff options
context:
space:
mode:
Diffstat (limited to 'deckit')
-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})