diff options
| -rw-r--r-- | deckit/modules/k1c.py | 18 | ||||
| -rw-r--r-- | static/modules/k1c.js | 26 | ||||
| -rw-r--r-- | tests/test_k1c.py | 8 |
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__": |
