diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-10 10:33:13 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-10 10:33:13 +0100 |
| commit | 6933aa3220ff420beeb883d25fc68b9c79aa7df1 (patch) | |
| tree | 0ca54434b4b8c8d0768d1d5165c2e00e33fde6ac /tests | |
| parent | 213a82118a7f5db58759edffe8d1776475e4e4e4 (diff) | |
| download | deck-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 'tests')
| -rw-r--r-- | tests/test_k1c.py | 108 |
1 files changed, 108 insertions, 0 deletions
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() |
