aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-10 10:46:04 +0100
committerDanilo M. <danix@danix.xyz>2026-11-10 10:46:04 +0100
commitb5b56e4c1854314fb519d13af9dda80a10ee0b8a (patch)
tree4ddbc4b348e4be4a5af99ea50566094a4bc5e2c6
parent6ffa382c3f83e87da2e976868a9846f16ecc7321 (diff)
downloaddeck-it-master.tar.gz
deck-it-master.zip
feat(k1c): live camera stream instead of snapshotsHEADmaster
The camera was a snapshot reloaded on every poll. It is now the webcam's MJPEG stream_url, which an <img> plays by itself at the camera's own rate (15 fps on the K1C). The stream connects only while the K1C tab is on screen and the page visible, and disconnects otherwise, so a hidden tab does not keep the printer streaming. A dropped stream hides the image and reconnects on the next poll. The image is shown as soon as its source is set, since a never-ending stream may never fire load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--deckit/modules/k1c.py18
-rw-r--r--static/modules/k1c.js26
-rw-r--r--tests/test_k1c.py8
3 files changed, 30 insertions, 22 deletions
diff --git a/deckit/modules/k1c.py b/deckit/modules/k1c.py
index 8481e91..1f89088 100644
--- a/deckit/modules/k1c.py
+++ b/deckit/modules/k1c.py
@@ -41,10 +41,10 @@ def _http(url, data=None, timeout=2):
return json.loads(r.read())
-def _snapshot(webcams) -> str | None:
- """The first enabled webcam's snapshot URL, if it is plain http(s)."""
+def _camera(webcams) -> str | None:
+ """The first enabled webcam's live MJPEG stream URL, if it is plain http(s)."""
for cam in webcams or []:
- url = cam.get("snapshot_url") or ""
+ url = cam.get("stream_url") or ""
if cam.get("enabled", True) and url.startswith(("http://", "https://")):
return url
return None
@@ -64,7 +64,7 @@ def _first(*values):
return next((v for v in values if v is not None), None)
-def summarize(status: dict, snapshot: str | None = None) -> dict:
+def summarize(status: dict, camera: 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 {}
@@ -99,14 +99,14 @@ def summarize(status: dict, snapshot: str | None = None) -> dict:
(status.get("output_pin fan0") or {}).get("value")),
"position": ((status.get("toolhead") or {}).get("position") or [None] * 3)[:3],
"speed_factor": (status.get("gcode_move") or {}).get("speed_factor"),
- "snapshot": snapshot,
+ "camera": camera,
}
def make(cfg: dict) -> Module:
base = str(cfg.get("k1c", {}).get("url", "")).rstrip("/")
http = cfg.get("http", _http)
- down = {"until": 0.0, "snapshot": None}
+ down = {"until": 0.0, "camera": None}
# The current file's slicer metadata, fetched once per file.
meta = {"file": None, "thumbnail": None, "estimate": None}
@@ -130,12 +130,12 @@ def make(cfg: dict) -> Module:
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 ""
+ if down["camera"] is None:
+ down["camera"] = _camera(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)
+ st = summarize(status, down["camera"] or None)
file_meta(st["file"])
st["thumbnail"] = meta["thumbnail"]
# Until the file-position estimate settles, the slicer's is better.
diff --git a/static/modules/k1c.js b/static/modules/k1c.js
index 4cd0358..d8211fa 100644
--- a/static/modules/k1c.js
+++ b/static/modules/k1c.js
@@ -184,16 +184,24 @@ export default {
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.
+ // The live MJPEG stream, which an <img> plays by itself, fetched by the
+ // tablet from the printer directly. Connected only while this tab is on
+ // screen: a hidden tab would keep the printer streaming for nothing.
+ // 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()}`;
+ const shown = st.camera && !document.hidden && el.offsetParent !== null;
+ if (shown && cam.dataset.src !== st.camera) {
+ cam.dataset.src = st.camera;
+ // Shown at once: a never-ending stream may never fire load. Dropped
+ // (camera restarted): hide it and forget it, so the next poll reconnects.
+ cam.onerror = () => { cam.hidden = true; delete cam.dataset.src; };
+ cam.src = st.camera;
+ cam.hidden = false;
+ } else if (!shown && cam.dataset.src) {
+ delete cam.dataset.src;
+ cam.removeAttribute("src");
+ cam.hidden = true;
}
- if (!st.snapshot) cam.hidden = true;
},
};
diff --git a/tests/test_k1c.py b/tests/test_k1c.py
index 47c25ec..300a56a 100644
--- a/tests/test_k1c.py
+++ b/tests/test_k1c.py
@@ -35,7 +35,7 @@ STATUS = {
"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"}]}}
+CAMS = {"result": {"webcams": [{"enabled": True, "stream_url": "http://printer.example.org:8080/?action=stream"}]}}
class Fake:
@@ -65,7 +65,7 @@ class K1C(unittest.TestCase):
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.assertTrue(st["camera"].endswith("action=stream"))
self.assertEqual(st["thumbnail"], URL + "/server/files/gcodes/.thumbs/part-300x300.png")
def test_thumbnail_is_relative_to_the_file_and_quoted(self):
@@ -107,8 +107,8 @@ class K1C(unittest.TestCase):
with self.assertRaises(k1c.ModuleError):
m.actions["emergency_stop"]({})
- def test_snapshot_needs_http(self):
- self.assertIsNone(k1c._snapshot([{"snapshot_url": "javascript:alert(1)"}]))
+ def test_camera_needs_http(self):
+ self.assertIsNone(k1c._camera([{"stream_url": "javascript:alert(1)"}]))
if __name__ == "__main__":