From 2f222a0b93b7636d828919a9608cfcbe7593f8ef Mon Sep 17 00:00:00 2001 From: Walter Almada B Date: Tue, 21 Jul 2026 22:13:48 -0700 Subject: [PATCH 01/16] fix(ams): point gap placeholders at a loaded tray, not an empty one Printing via OrcaSlicer "Upload and print" failed or fed the wrong spool when the slot below the used filament was empty (e.g. Filament 4 with slot 3 empty); all-slots-full worked. _start_print -> _build_auto_ams_box_mapping keeps the AMS mapping positional (entry N = TN) by inserting a placeholder at each gap. The placeholder's ams_index pointed at the gap's own index, which for an empty slot references a physically empty tray. The printer rejects a mapping entry aimed at an empty tray even for a tool the GCode never calls, so the print broke. Point gap placeholders (ams_index/color/material) at a definitely-loaded fallback tray (the highest loaded slot) instead. Positional alignment is preserved; the all-full path is unchanged. Adds tests/test_auto_ams_box_mapping_empty_slot.py (invariant: every mapping entry references a loaded/status-5 tray). Co-Authored-By: Claude Opus 4.8 (1M context) --- NIGHTLY_CHANGELOG.md | 1 + kobrax_moonraker_bridge.py | 14 ++- tests/test_auto_ams_box_mapping_empty_slot.py | 85 +++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/test_auto_ams_box_mapping_empty_slot.py diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index f3f3667..43259d9 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -2,3 +2,4 @@ - Fix: on printers with multiple daisy-chained ACE units and no toolhead buffer (e.g. Kobra S1 with 2 ACE Pro), only the first unit's 4 slots were ever shown on the dashboard or synced to OrcaSlicer — the bridge silently discarded every ACE unit after the first. All units now show up correctly (Issue #95, thanks @hoovercl for the detailed report and logs) - Feat: the GCode browser now supports multi-select and bulk delete — click any file's checkbox to enter select mode, use "Select All" to grab everything currently visible (respecting your active search/filter), then delete the selection with one confirmation instead of one-by-one (Issue #94, thanks @Blaim) +- Fix: **printing via OrcaSlicer "Upload and print" failed (or fed the wrong spool) when a slot below the used filament was empty** — e.g. printing with Filament 4 while slot 3 was empty. The generated AMS slot mapping inserted a placeholder pointing at the physically empty tray, which the printer rejects. Placeholders now point at a loaded tray instead. Printing with all slots full already worked and is unchanged. diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 263a86c..6215f03 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1762,6 +1762,14 @@ class KobraXBridge: max_idx = max(loaded_map.keys()) # The printer interprets ams_box_mapping as an ordered list (entry N = TN). # Missing slots must be inserted as placeholders, otherwise everything shifts. + # A placeholder must NOT reference a physically empty tray: the printer + # rejects such an entry even for a tool the GCode never calls (printing + # Filament 4 with the slot below it empty fails; all-full works). Point + # gap placeholders at a definitely-loaded tray instead of the gap's own + # (empty) index. + fallback_gidx = max_idx # highest loaded slot -> loaded + printable + fallback_slot = loaded_map[fallback_gidx] + fallback_ams = self._slot_to_print_ams_index(fallback_gidx) result = [] for i in range(max_idx + 1): if i in loaded_map: @@ -1776,10 +1784,10 @@ class KobraXBridge: else: result.append({ "paint_index": i, - "ams_index": self._slot_to_print_ams_index(i), + "ams_index": fallback_ams, "paint_color": [255, 255, 255, 255], - "ams_color": [255, 255, 255, 255], - "material_type": "PLA", + "ams_color": self._slot_color_rgba(fallback_slot), + "material_type": fallback_slot.get("type", "PLA"), }) return result diff --git a/tests/test_auto_ams_box_mapping_empty_slot.py b/tests/test_auto_ams_box_mapping_empty_slot.py new file mode 100644 index 0000000..7c90466 --- /dev/null +++ b/tests/test_auto_ams_box_mapping_empty_slot.py @@ -0,0 +1,85 @@ +"""Empty-tray placeholder bug in the OrcaSlicer "upload and print" path. + +Real KX1 bug (confirmed 2026-07-22): printing Filament 4 with the slot below it +EMPTY fails; with all slots full it works. `_start_print` -> `_build_auto_ams_box_mapping` +inserts a positional placeholder at each gap whose `ams_index` points at the +gap's own (physically EMPTY) tray. The printer rejects a mapping entry that +references an empty tray, even for a tool the GCode never calls. + +Invariant the fix must hold: EVERY entry's ams_index references a LOADED tray. +Positional alignment (entry N = TN, from a16062f) must be preserved. +""" +import argparse +import tempfile +from unittest.mock import MagicMock + +from kobrax_moonraker_bridge import KobraXBridge + +# Real KX1 AMS state: slot 2 (idx 2) EMPTY, below the used Filament 4 (idx 3). +AMS_SLOTS = [ + {"global_index": 0, "box_id": -1, "status": 5, "type": "PLA", "color": [101, 88, 177]}, + {"global_index": 1, "box_id": -1, "status": 5, "type": "PLA SILK", "color": [239, 96, 163]}, + {"global_index": 2, "box_id": -1, "status": 4, "type": "PLA", "color": [223, 221, 220]}, # EMPTY + {"global_index": 3, "box_id": -1, "status": 5, "type": "PLA", "color": [117, 120, 123]}, +] + + +def _bridge(slots=AMS_SLOTS): + c = MagicMock(); c.callbacks = {}; c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxauto-"), + ) + b = KobraXBridge(c, args=args) + b._filament_mode = "toolhead" + b._ams_slots = [dict(s) for s in slots] + return b + + +def _loaded_ams_indices(b): + return {b._slot_to_print_ams_index(int(s["global_index"])) + for s in b._ams_slots if s["status"] == 5} + + +def test_no_entry_points_at_an_empty_tray_when_only_used_slot_loaded(): + """_start_print filters loaded to the used slot only: loaded=[(3, slot3)]. + Positions 0..2 are placeholders and must NOT reference empty tray idx 2.""" + b = _bridge() + loaded = [(3, b._ams_slots[3])] + + mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded) + + assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3] # positional alignment kept + loaded_ams = _loaded_ams_indices(b) + bad = [e for e in mapping if e["ams_index"] not in loaded_ams] + assert not bad, f"entries reference an empty/non-loaded tray: {bad}" + + +def test_no_entry_points_at_an_empty_tray_with_gap_in_loaded_set(): + """All occupied slots mapped (0,1,3 loaded, 2 empty). The placeholder at + position 2 must not reference the empty tray idx 2.""" + b = _bridge() + loaded = [(0, b._ams_slots[0]), (1, b._ams_slots[1]), (3, b._ams_slots[3])] + + mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded) + + assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3] + loaded_ams = _loaded_ams_indices(b) + bad = [e for e in mapping if e["ams_index"] not in loaded_ams] + assert not bad, f"entries reference an empty/non-loaded tray: {bad}" + # Real loaded slots keep their own ams_index. + assert mapping[0]["ams_index"] == 0 + assert mapping[1]["ams_index"] == 1 + assert mapping[3]["ams_index"] == 3 + + +def test_all_full_is_unchanged(): + """All slots loaded -> no placeholders, identity mapping (the working case).""" + slots = [dict(s, status=5) for s in AMS_SLOTS] + b = _bridge(slots) + loaded = [(i, b._ams_slots[i]) for i in range(4)] + + mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded) + + assert [e["ams_index"] for e in mapping] == [0, 1, 2, 3] From 8f14580e30eebdc7e79cb1734da68c2176ba9fad Mon Sep 17 00:00:00 2001 From: viewit Date: Fri, 24 Jul 2026 13:57:17 +0200 Subject: [PATCH 02/16] fix(ams): don't crash on rejected multiColorBox setInfo (Issue #100) The printer replies with state="failed" and data as a 2-element list (["multi_color_box", [...]]) instead of the usual dict when it rejects a manual ACE slot filament assignment (e.g. custom-RFID/third-party material). _on_multicolor_box called data.get(...) unconditionally, crashing with AttributeError and silently dropping the report - including the regular slot-state update that would otherwise follow. Add a state="failed" guard plus a defensive isinstance check, and surface the rejection via _state["last_ams_set_error"] so the caller of handle_api_ams_set_slot's optimistic cache update isn't left showing a false success. Note: this fixes the crash, not necessarily the underlying rejection itself - the printer/ACE may still refuse assignments for material types it doesn't recognize. --- NIGHTLY_CHANGELOG.md | 1 + kobrax_moonraker_bridge.py | 9 +++ tests/test_multicolor_box_failed_state.py | 69 +++++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 tests/test_multicolor_box_failed_state.py diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 43259d9..7ae28db 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -3,3 +3,4 @@ - Fix: on printers with multiple daisy-chained ACE units and no toolhead buffer (e.g. Kobra S1 with 2 ACE Pro), only the first unit's 4 slots were ever shown on the dashboard or synced to OrcaSlicer — the bridge silently discarded every ACE unit after the first. All units now show up correctly (Issue #95, thanks @hoovercl for the detailed report and logs) - Feat: the GCode browser now supports multi-select and bulk delete — click any file's checkbox to enter select mode, use "Select All" to grab everything currently visible (respecting your active search/filter), then delete the selection with one confirmation instead of one-by-one (Issue #94, thanks @Blaim) - Fix: **printing via OrcaSlicer "Upload and print" failed (or fed the wrong spool) when a slot below the used filament was empty** — e.g. printing with Filament 4 while slot 3 was empty. The generated AMS slot mapping inserted a placeholder pointing at the physically empty tray, which the printer rejects. Placeholders now point at a loaded tray instead. Printing with all slots full already worked and is unchanged. +- Fix: manually assigning a filament profile to an ACE slot could crash the MQTT callback (`'list' object has no attribute 'get'`) when the printer rejected the assignment, e.g. for custom-RFID/third-party filament. The bridge now handles the printer's failure response cleanly instead of crashing (Issue #100, thanks @Blaim) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 6215f03..81a5a1b 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1879,10 +1879,18 @@ class KobraXBridge: return activity def _on_multicolor_box(self, payload: dict): + if payload.get("state") == "failed": + log.warning(f"multiColorBox/report failed: {payload.get('data')}") + self._state["last_ams_set_error"] = True + return data = payload.get("data") or {} + if not isinstance(data, dict): + log.warning(f"multiColorBox/report: unexpected data shape: {data!r}") + return boxes = data.get("multi_color_box") or [] if not boxes: return + self._state["last_ams_set_error"] = False self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model)) self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model) self._state["filament_mode"] = self._filament_mode @@ -4037,6 +4045,7 @@ class KobraXBridge: return web.json_response({"error": "color must be [r,g,b]"}, status=400) box_id, local_slot = self._global_to_box_slot(index) loop = asyncio.get_event_loop() + self._state["last_ams_set_error"] = False # setInfo goes via the web/printer topic (like tempature/set). Verified via # Workbench-Vue mqtt_setInfo verifiziert — via slicer/printer/ wurden # slot changes are ignored by the printer and overwritten with the old diff --git a/tests/test_multicolor_box_failed_state.py b/tests/test_multicolor_box_failed_state.py new file mode 100644 index 0000000..16fd71b --- /dev/null +++ b/tests/test_multicolor_box_failed_state.py @@ -0,0 +1,69 @@ +"""AttributeError crash on multiColorBox/report failure (Issue #100). + +Real KX2-Pro bug: manually assigning a filament profile to an ACE slot +(custom-RFID / third-party filament) gets rejected by the printer. Instead of +echoing the normal success shape, the printer replies with `state: "failed"` +and `data` as a 2-element LIST (`["multi_color_box", [...]]`) instead of the +usual dict (`{"multi_color_box": [...]}`). `_on_multicolor_box` called +`data.get(...)` unconditionally, crashing with +`AttributeError: 'list' object has no attribute 'get'` and silently dropping +the report (including the slot-state update it would otherwise have done). +""" +import argparse +import tempfile +from unittest.mock import MagicMock + +from kobrax_moonraker_bridge import KobraXBridge + +# Exact failure payload from the Issue #100 log. +FAILED_PAYLOAD = { + "state": "failed", + "data": ["multi_color_box", [{"filaments": {"id": 2}, "id": 0}]], +} + +SUCCESS_PAYLOAD = { + "state": "success", + "data": { + "head_tools_model": 1, + "multi_color_box": [ + {"id": -1, "slots": []}, + { + "id": 0, + "slots": [ + {"index": 0, "type": "PLA", "color": [0, 156, 189], "status": 5}, + ], + }, + ], + }, +} + + +def _bridge(): + c = MagicMock(); c.callbacks = {}; c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxmcb-"), + ) + return KobraXBridge(c, args=args) + + +def test_failed_state_does_not_crash(): + b = _bridge() + b._on_multicolor_box(FAILED_PAYLOAD) # must not raise + assert b._state["last_ams_set_error"] is True + + +def test_success_after_failure_clears_error_flag(): + b = _bridge() + b._on_multicolor_box(FAILED_PAYLOAD) + assert b._state["last_ams_set_error"] is True + b._on_multicolor_box(SUCCESS_PAYLOAD) + assert b._state["last_ams_set_error"] is False + + +def test_non_dict_data_without_failed_state_does_not_crash(): + """Defensive guard: any future non-dict `data` shape must not crash, + even if the printer doesn't set state="failed" for it.""" + b = _bridge() + b._on_multicolor_box({"state": "success", "data": ["multi_color_box", []]}) From 884bdfc0c3df9d24acd2129f5578da6301cc987f Mon Sep 17 00:00:00 2001 From: viewit Date: Fri, 24 Jul 2026 14:01:04 +0200 Subject: [PATCH 03/16] fix(ams): log rejected multiColorBox setInfo with the triggering request The printer's failure payload for a rejected slot assignment carries no slot/type/color info of its own, only an opaque ["multi_color_box", [{"filaments": {"id": N}, "id": box_id}]] shape - useless for diagnosing why an assignment was rejected. Remember the last setInfo request (_last_ams_set_request) and log it alongside the failure so bridge logs alone can answer "what exact type/color was sent, and to which slot" without asking the user to dig it out manually or run extra reproduction steps. --- NIGHTLY_CHANGELOG.md | 2 +- kobrax_moonraker_bridge.py | 10 +++++++++- tests/test_multicolor_box_failed_state.py | 12 ++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 7ae28db..2916255 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -3,4 +3,4 @@ - Fix: on printers with multiple daisy-chained ACE units and no toolhead buffer (e.g. Kobra S1 with 2 ACE Pro), only the first unit's 4 slots were ever shown on the dashboard or synced to OrcaSlicer — the bridge silently discarded every ACE unit after the first. All units now show up correctly (Issue #95, thanks @hoovercl for the detailed report and logs) - Feat: the GCode browser now supports multi-select and bulk delete — click any file's checkbox to enter select mode, use "Select All" to grab everything currently visible (respecting your active search/filter), then delete the selection with one confirmation instead of one-by-one (Issue #94, thanks @Blaim) - Fix: **printing via OrcaSlicer "Upload and print" failed (or fed the wrong spool) when a slot below the used filament was empty** — e.g. printing with Filament 4 while slot 3 was empty. The generated AMS slot mapping inserted a placeholder pointing at the physically empty tray, which the printer rejects. Placeholders now point at a loaded tray instead. Printing with all slots full already worked and is unchanged. -- Fix: manually assigning a filament profile to an ACE slot could crash the MQTT callback (`'list' object has no attribute 'get'`) when the printer rejected the assignment, e.g. for custom-RFID/third-party filament. The bridge now handles the printer's failure response cleanly instead of crashing (Issue #100, thanks @Blaim) +- Fix: manually assigning a filament profile to an ACE slot could crash the MQTT callback (`'list' object has no attribute 'get'`) when the printer rejected the assignment, e.g. for custom-RFID/third-party filament. The bridge now handles the printer's failure response cleanly instead of crashing, and the log now correlates a rejected assignment with the request that triggered it (slot/type/color) so a rejection reason can be diagnosed from bridge logs alone (Issue #100, thanks @Blaim) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 81a5a1b..158ab6c 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -989,6 +989,7 @@ class KobraXBridge: except Exception: self._visible_vendors = [] self._last_state: dict = {} + self._last_ams_set_request: dict | None = None self._state = { "nozzle_temp": 0.0, "nozzle_target": 0.0, @@ -1880,7 +1881,10 @@ class KobraXBridge: def _on_multicolor_box(self, payload: dict): if payload.get("state") == "failed": - log.warning(f"multiColorBox/report failed: {payload.get('data')}") + req = getattr(self, "_last_ams_set_request", None) + log.warning( + f"multiColorBox setInfo rejected by printer: request={req} raw_response={payload.get('data')}" + ) self._state["last_ams_set_error"] = True return data = payload.get("data") or {} @@ -4046,6 +4050,10 @@ class KobraXBridge: box_id, local_slot = self._global_to_box_slot(index) loop = asyncio.get_event_loop() self._state["last_ams_set_error"] = False + # Remembered so a later state="failed" report (which carries no slot + # info of its own, see _on_multicolor_box) can be logged alongside the + # request that triggered it - otherwise the failure is unattributable. + self._last_ams_set_request = {"global": index, "box": box_id, "local_slot": local_slot, "type": mat, "color": color} # setInfo goes via the web/printer topic (like tempature/set). Verified via # Workbench-Vue mqtt_setInfo verifiziert — via slicer/printer/ wurden # slot changes are ignored by the printer and overwritten with the old diff --git a/tests/test_multicolor_box_failed_state.py b/tests/test_multicolor_box_failed_state.py index 16fd71b..3e3654e 100644 --- a/tests/test_multicolor_box_failed_state.py +++ b/tests/test_multicolor_box_failed_state.py @@ -67,3 +67,15 @@ def test_non_dict_data_without_failed_state_does_not_crash(): even if the printer doesn't set state="failed" for it.""" b = _bridge() b._on_multicolor_box({"state": "success", "data": ["multi_color_box", []]}) + + +def test_failed_report_is_logged_with_the_triggering_request(caplog): + """The failure payload alone carries no slot/type/color info - the log + must correlate it with the setInfo request that triggered it, otherwise + the failure reason can't be diagnosed from bridge logs alone.""" + import logging + b = _bridge() + b._last_ams_set_request = {"global": 6, "box": 0, "local_slot": 3, "type": "PLA", "color": [33, 39, 33]} + with caplog.at_level(logging.WARNING): + b._on_multicolor_box(FAILED_PAYLOAD) + assert any("global" in r.message and "6" in r.message for r in caplog.records) From 16c1a8ee73e87e5c99fb48ef7d81f1013e363706 Mon Sep 17 00:00:00 2001 From: viewit Date: Fri, 24 Jul 2026 14:10:54 +0200 Subject: [PATCH 04/16] fix(camera): recover automatically after printer reboot rotates stream token (Issue #99) The printer rotates its FLV/RTSP stream token on reboot. CameraCache.set_url() was a bare assignment, so the three running ffmpeg loops never noticed - they only re-read self._url at the top of their outer loop, which they never reach while permanently blocked in a stdout read on the now-silent, stale-token connection (TCP stays ESTABLISHED with no data and no FIN, so a passive reader can't tell "peer is quiet" from "peer is gone"). Three-part fix, all per the excellent root-cause analysis and reproduction in the issue (thanks @fmontagna): 1. set_url() now detects a URL change and calls reset() to tear down the stale ffmpeg loops, so the next ensure_running() respawns them against the new URL. 2. ffmpeg's -timeout option (10s, applies to both RTSP and HTTP-FLW since both share _input_args) as a second line of defense for a source that goes silent while the URL stays the same (network loss, camera hang). 3. handle_camera_stream now waits up to 5s for the first frame BEFORE calling resp.prepare() and returns 503 on timeout. Previously it had no first-frame timeout at all, and prepare() commits the response status to 200 - so a stalled source could never surface as an error to the client, only as an infinite hang. --- NIGHTLY_CHANGELOG.md | 1 + kobrax_moonraker_bridge.py | 29 +++++++++++++- tests/test_camera_cache_url_rotation.py | 51 +++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 tests/test_camera_cache_url_rotation.py diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 2916255..290cdb1 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -4,3 +4,4 @@ - Feat: the GCode browser now supports multi-select and bulk delete — click any file's checkbox to enter select mode, use "Select All" to grab everything currently visible (respecting your active search/filter), then delete the selection with one confirmation instead of one-by-one (Issue #94, thanks @Blaim) - Fix: **printing via OrcaSlicer "Upload and print" failed (or fed the wrong spool) when a slot below the used filament was empty** — e.g. printing with Filament 4 while slot 3 was empty. The generated AMS slot mapping inserted a placeholder pointing at the physically empty tray, which the printer rejects. Placeholders now point at a loaded tray instead. Printing with all slots full already worked and is unchanged. - Fix: manually assigning a filament profile to an ACE slot could crash the MQTT callback (`'list' object has no attribute 'get'`) when the printer rejected the assignment, e.g. for custom-RFID/third-party filament. The bridge now handles the printer's failure response cleanly instead of crashing, and the log now correlates a rejected assignment with the request that triggered it (slot/type/color) so a rejection reason can be diagnosed from bridge logs alone (Issue #100, thanks @Blaim) +- Fix: **the camera stream could hang forever after a printer reboot** — the printer rotates its stream token on reboot, but the running ffmpeg processes kept reading from the stale, now-silent connection and never noticed. The bridge now detects the URL change and automatically restarts the camera pipeline, ffmpeg itself now times out on a stalled read as a second line of defense, and `/api/camera/stream` now returns a proper 503 instead of hanging if no frame arrives within 5s (Issue #99, thanks @fmontagna for the excellent root-cause analysis and reproduction) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 158ab6c..cdc56d2 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -631,7 +631,16 @@ class CameraCache: self._fail_count_mjpeg: int = 0 def set_url(self, url: str): + # A changed URL means the printer rotated its stream token (typically + # after a reboot). Running ffmpeg processes still hold the stale URL + # and will never pick it up on their own - they only re-read self._url + # at the top of their outer loop, which they never reach while blocked + # in a stdout read on the old, now-silent connection. Tear them down; + # the next ensure_running() respawns them against the new URL. + changed = bool(url and self._url and url != self._url) self._url = url + if changed: + self.reset() def reset(self): """Reset backoff counters and forcefully tear down any running @@ -679,7 +688,12 @@ class CameraCache: self._task_mjpeg = asyncio.create_task(self._run_mjpeg_loop()) def _input_args(self, url: str) -> list[str]: - args = ["-fflags", "nobuffer", "-flags", "low_delay"] + args = ["-fflags", "nobuffer", "-flags", "low_delay", + # Bail out if the source goes silent. A printer reboot or + # network loss leaves the TCP connection ESTABLISHED with no + # data and no FIN, so a passive stdout read blocks forever + # without this (Issue #99). Value is microseconds. + "-timeout", "10000000"] if url.lower().startswith("rtsp://"): args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"] else: @@ -4368,6 +4382,16 @@ class KobraXBridge: q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=8) self.camera_cache.mjpeg_subscribers.add(q) + # Wait for the first frame BEFORE resp.prepare() - once prepare() sends + # the response headers the status is committed to 200, so a stalled + # source (Issue #99) must be caught here to actually return a 503 + # instead of hanging the client forever with no frame ever arriving. + try: + first_frame = await asyncio.wait_for(q.get(), timeout=5.0) + except asyncio.TimeoutError: + self.camera_cache.mjpeg_subscribers.discard(q) + return web.Response(status=503, text="No frame in cache yet") + boundary = "kobraxframe" resp = web.StreamResponse(headers={ "Content-Type": f"multipart/x-mixed-replace;boundary={boundary}", @@ -4376,8 +4400,8 @@ class KobraXBridge: }) await resp.prepare(request) try: + frame = first_frame while True: - frame = await q.get() header = ( f"--{boundary}\r\n" f"Content-Type: image/jpeg\r\n" @@ -4389,6 +4413,7 @@ class KobraXBridge: break except Exception: break + frame = await q.get() except Exception as e: log.warning(f"Camera stream interrupted: {e}") finally: diff --git a/tests/test_camera_cache_url_rotation.py b/tests/test_camera_cache_url_rotation.py new file mode 100644 index 0000000..8cb4caf --- /dev/null +++ b/tests/test_camera_cache_url_rotation.py @@ -0,0 +1,51 @@ +"""Camera stream hangs forever after printer reboot (Issue #99). + +The printer rotates its stream token on reboot, changing the camera URL. +CameraCache.set_url() used to be a bare assignment - the running ffmpeg loops +never noticed since they only re-read self._url at the top of their outer +loop, which they never reach while permanently blocked reading stdout from +the now-silent, stale-token connection. set_url() must detect the change and +tear the loops down so the next ensure_running() respawns them against the +new URL. +""" +from kobrax_moonraker_bridge import CameraCache + + +def test_set_url_first_time_does_not_reset(): + """No prior URL - nothing stale to tear down.""" + c = CameraCache() + calls = [] + c.reset = lambda: calls.append(True) + c.set_url("http://printer/live/tokenA") + assert c._url == "http://printer/live/tokenA" + assert not calls + + +def test_set_url_same_value_does_not_reset(): + c = CameraCache() + calls = [] + c.set_url("http://printer/live/tokenA") + c.reset = lambda: calls.append(True) + c.set_url("http://printer/live/tokenA") + assert not calls + + +def test_set_url_changed_triggers_reset(): + """The actual bug scenario: token rotates after a printer reboot.""" + c = CameraCache() + calls = [] + c.set_url("http://printer/live/tokenA") + c.reset = lambda: calls.append(True) + c.set_url("http://printer/live/tokenB") + assert c._url == "http://printer/live/tokenB" + assert calls == [True] + + +def test_set_url_empty_to_value_does_not_reset(): + """Startup case: no URL known yet, first status push sets it - nothing + running to tear down.""" + c = CameraCache() + calls = [] + c.reset = lambda: calls.append(True) + c.set_url("http://printer/live/tokenA") + assert not calls From 33b42e64cc569ffef5f9697477e213734adff724 Mon Sep 17 00:00:00 2001 From: viewit Date: Fri, 24 Jul 2026 15:13:19 +0200 Subject: [PATCH 05/16] fix(dashboard): tile resize clipping and phantom scrollbars (Issue #97) Two dashboard tile CSS issues reported after resizing tiles in the GridStack-based dashboard: 1. Shrinking the Progress tile's height clipped the lower content (time grid, filename, buttons) out of the visible area instead of making it scale or scroll - #card-progress had no flex layout, so its fixed-size children just overflowed silently under the generic overflow:auto rule. Applied the same display:flex;flex-direction: column pattern already used for #card-camera, with flex-shrink:0 on the direct children so the container can properly scroll instead of hiding content off-screen. 2. Tiles could show a scrollbar even with nothing to scroll, from sub-pixel horizontal overflow under the blanket overflow-y:auto/overflow-x:hidden rule (was overflow:auto covering both axes) - split it so only vertical scroll is offered, which is the only axis dashboard content actually needs. Verified visually via Playwright: resizing the progress tile to h=2/h=4 no longer clips content, and a fan-card resize with scrollHeight==clientHeight now shows no scrollbar. --- NIGHTLY_CHANGELOG.md | 1 + web/themes/default/style.css | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 290cdb1..6752d5a 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -5,3 +5,4 @@ - Fix: **printing via OrcaSlicer "Upload and print" failed (or fed the wrong spool) when a slot below the used filament was empty** — e.g. printing with Filament 4 while slot 3 was empty. The generated AMS slot mapping inserted a placeholder pointing at the physically empty tray, which the printer rejects. Placeholders now point at a loaded tray instead. Printing with all slots full already worked and is unchanged. - Fix: manually assigning a filament profile to an ACE slot could crash the MQTT callback (`'list' object has no attribute 'get'`) when the printer rejected the assignment, e.g. for custom-RFID/third-party filament. The bridge now handles the printer's failure response cleanly instead of crashing, and the log now correlates a rejected assignment with the request that triggered it (slot/type/color) so a rejection reason can be diagnosed from bridge logs alone (Issue #100, thanks @Blaim) - Fix: **the camera stream could hang forever after a printer reboot** — the printer rotates its stream token on reboot, but the running ffmpeg processes kept reading from the stale, now-silent connection and never noticed. The bridge now detects the URL change and automatically restarts the camera pipeline, ffmpeg itself now times out on a stalled read as a second line of defense, and `/api/camera/stream` now returns a proper 503 instead of hanging if no frame arrives within 5s (Issue #99, thanks @fmontagna for the excellent root-cause analysis and reproduction) +- Fix: shrinking the Progress dashboard tile below its default height clipped the lower content (time grid, filename, buttons) out of view instead of scaling with it; dashboard tiles in general could show a scrollbar even when there was nothing to scroll. Both fixed (Issue #97, thanks @Blaim) diff --git a/web/themes/default/style.css b/web/themes/default/style.css index 7902d25..efb661f 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -108,7 +108,7 @@ main{flex:1;overflow-y:auto;padding:20px} GridStack's resize-handle hit area and drag listeners. */ /* Doc pattern: the card fills its cell; content scrolls if the user resizes the cell smaller than the content needs. */ -.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow:auto;box-sizing:border-box} +.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow-y:auto;overflow-x:hidden;box-sizing:border-box} /* .card:hover{transform:translateY(-1px)} creates a new containing block right as the user starts dragging, throwing off GridStack's position math. Kill the hover transform for dashboard cards specifically. */ @@ -164,6 +164,11 @@ main{flex:1;overflow-y:auto;padding:20px} .cam-toggle:hover{background:rgba(0,0,0,.7)} /* ── PROGRESS ── */ +/* Same pattern as #card-camera above: without this, shrinking the tile's + height just clips the lower content (time-grid/filename/buttons) outside + the visible area instead of making it scrollable (Issue #97). */ +.grid-stack-item-content>#card-progress{display:flex;flex-direction:column;min-height:0} +.grid-stack-item-content>#card-progress>*{flex-shrink:0} .hero-info{display:flex;flex-direction:column;gap:12px} .pct-big{font-size:52px;font-weight:700;line-height:1;color:var(--txt)} .pct-big small{font-size:20px;font-weight:400;color:var(--txt2)} From 6d6df59ff2b56f1ab98f50e3052d2ba5c5ed3b35 Mon Sep 17 00:00:00 2001 From: viewit Date: Sun, 26 Jul 2026 23:19:36 +0200 Subject: [PATCH 06/16] feat(filament): auto-match combined ACE-RFID vendor+type strings (Issue #101) Anycubic's ACE RFID system concatenates vendor + material + a truncated serial into one `type` string for custom (third-party) RFID tags, e.g. "GEEETECH PLA Bas" for a Geeetech PLA spool. The bridge previously treated this whole string as an unknown material and fell back to a neutral "Generic " profile, even when the user had already imported a matching OrcaSlicer profile via the ZIP import feature (Issue #41) - forcing a manual per-slot reassignment every time that spool was loaded. Anycubic Slicer Next resolves the same tag correctly. Add two helpers next to the existing _normalize_material/_material_family: - _parse_combined_rfid_type(): splits the raw type string, recognizes a known vendor as the first token (checked against the merged system+user filament library, so custom vendors like "Geeetech" that only exist in the user's imported profiles are included), and extracts the material family from the remainder via the existing _material_family() prefix search. Returns ("", "") for a vendorless string like plain "PLA", leaving normal spool reports untouched. - _match_profile_by_vendor_family(): looks up an imported/system profile by (vendor, family) rather than exact name, since the truncated RFID string never contains the full profile name verbatim. Wired into _build_lane_data() as a third resolution layer, after the existing manual per-slot override and before the Generic-library fallback - not persisted to config.ini, so it re-derives fresh on every call and can't go stale if a differently-tagged spool is loaded later. --- NIGHTLY_CHANGELOG.md | 1 + kobrax_moonraker_bridge.py | 60 +++++++++++++ tests/test_ace_rfid_vendor_matching.py | 111 +++++++++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 tests/test_ace_rfid_vendor_matching.py diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 6752d5a..12b7a39 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -6,3 +6,4 @@ - Fix: manually assigning a filament profile to an ACE slot could crash the MQTT callback (`'list' object has no attribute 'get'`) when the printer rejected the assignment, e.g. for custom-RFID/third-party filament. The bridge now handles the printer's failure response cleanly instead of crashing, and the log now correlates a rejected assignment with the request that triggered it (slot/type/color) so a rejection reason can be diagnosed from bridge logs alone (Issue #100, thanks @Blaim) - Fix: **the camera stream could hang forever after a printer reboot** — the printer rotates its stream token on reboot, but the running ffmpeg processes kept reading from the stale, now-silent connection and never noticed. The bridge now detects the URL change and automatically restarts the camera pipeline, ffmpeg itself now times out on a stalled read as a second line of defense, and `/api/camera/stream` now returns a proper 503 instead of hanging if no frame arrives within 5s (Issue #99, thanks @fmontagna for the excellent root-cause analysis and reproduction) - Fix: shrinking the Progress dashboard tile below its default height clipped the lower content (time grid, filename, buttons) out of view instead of scaling with it; dashboard tiles in general could show a scrollbar even when there was nothing to scroll. Both fixed (Issue #97, thanks @Blaim) +- Feat: custom ACE-RFID filament tags (e.g. written via the "ACE RFID" app for third-party spools) are now matched automatically against your imported OrcaSlicer profiles. Anycubic's ACE RFID system concatenates vendor + material + a truncated serial into one type string (e.g. "GEEETECH PLA Bas") for custom tags — the bridge now recognizes the vendor prefix and resolves it to your already-imported profile instead of falling back to a generic default, so no manual per-slot reassignment is needed anymore (Issue #101, thanks @Blaim) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index cdc56d2..0f2d081 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -2123,6 +2123,51 @@ class KobraXBridge: return fam return m + def _parse_combined_rfid_type(self, raw_type: str) -> tuple[str, str]: + """Split a combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g. + "GEEETECH PLA Bas", written via third-party RFID tools) into + (vendor, material_family). + + Anycubic's ACE RFID system concatenates vendor + material + a + truncated serial/variant into one `type` string for custom tags - + unlike a normal spool report where `type` is just "PLA"/"PETG"/etc. + Returns ("", "") when the first token isn't a known vendor (from the + merged system+user filament library), which leaves plain type + strings like "PLA" completely unaffected (Issue #101). + """ + tokens = raw_type.split() + if len(tokens) < 2: + return "", "" + first = tokens[0].strip().lower() + vendors = {p.get("vendor", "").lower(): p.get("vendor", "") for p in self._load_orca_filaments()} + vendor = vendors.get(first) + if not vendor: + return "", "" + family = self._material_family(" ".join(tokens[1:])) + if not family: + return "", "" + return vendor, family + + def _match_profile_by_vendor_family(self, vendor: str, family: str) -> dict: + """Find an imported/system filament profile by (vendor, material + family) - used to auto-resolve a combined ACE-RFID type string to + the user's already-imported OrcaSlicer profile (Issue #101), since + the exact profile `name` never appears verbatim in the truncated + RFID string.""" + matches = [ + p for p in self._load_orca_filaments() + if p.get("vendor", "").lower() == vendor.lower() + and self._material_family(p.get("type", "")) == family + ] + if not matches: + return {} + if len(matches) > 1: + log.debug( + f"_match_profile_by_vendor_family: {len(matches)} profiles match " + f"vendor={vendor!r} family={family!r}, using first: {matches[0].get('name')}" + ) + return matches[0] + def _profile_material(self, profile: dict) -> str: """Material type (e.g. "PETG") of a saved slot profile, resolved by (vendor, name) from the Orca filament library. Returns "" when the @@ -2206,6 +2251,21 @@ class KobraXBridge: # family still matches the loaded filament (PETG profile + PLA # loaded -> dropped). user_profile = self._effective_slot_profile(slot_index, material) + if not user_profile.get("name"): + # Third layer: auto-resolve a combined ACE-RFID "VENDOR TYPE + # SERIAL" string (e.g. "GEEETECH PLA Bas", from third-party + # RFID tools) against the user's already-imported profile + # library, instead of falling through to the neutral Generic + # fallback (Issue #101). Not persisted to config.ini - this + # re-derives on every _build_lane_data() call, so a + # differently-tagged spool loaded later isn't stuck with a + # stale match. + vendor_guess, family_guess = self._parse_combined_rfid_type(slot.get("type", "")) + if vendor_guess: + auto_profile = self._match_profile_by_vendor_family(vendor_guess, family_guess) + if auto_profile.get("name"): + user_profile = auto_profile + material = family_guess if user_profile.get("name"): vendor = user_profile.get("vendor", "") fila_name = user_profile.get("name", "") diff --git a/tests/test_ace_rfid_vendor_matching.py b/tests/test_ace_rfid_vendor_matching.py new file mode 100644 index 0000000..88b9135 --- /dev/null +++ b/tests/test_ace_rfid_vendor_matching.py @@ -0,0 +1,111 @@ +"""Auto-matching for custom ACE-RFID filament tags (Issue #101). + +Anycubic's ACE RFID system concatenates vendor + material + a truncated +serial into one `type` string for custom (third-party) tags, e.g. +"GEEETECH PLA Bas" (vendor "Geeetech", material "PLA", serial "Bas" for +"Basic"). Previously the bridge treated this whole string as an unknown +material and fell back to a neutral "Generic " profile, even though +the user had already imported a matching OrcaSlicer profile via the ZIP +import feature (Issue #41) - requiring a manual per-slot reassignment every +time that spool was loaded. + +_parse_combined_rfid_type() + _match_profile_by_vendor_family() resolve this +automatically against the merged system+user filament library. +""" +import argparse +import tempfile +from unittest.mock import MagicMock + +from kobrax_moonraker_bridge import KobraXBridge + +USER_PROFILES = [ + {"id": "OGFL99", "name": "Generic PLA", "vendor": "Generic", "type": "PLA", "color": ""}, + {"id": "GTPLA01", "name": "Geeetech PLA Basic", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True}, +] + + +def _bridge(profiles=USER_PROFILES): + c = MagicMock(); c.callbacks = {}; c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxrfid-"), + ) + b = KobraXBridge(c, args=args) + b._orca_filaments_cache = profiles + return b + + +def test_combined_rfid_type_parses_known_vendor_and_family(): + b = _bridge() + vendor, family = b._parse_combined_rfid_type("GEEETECH PLA Bas") + assert vendor == "Geeetech" + assert family == "PLA" + + +def test_plain_type_string_is_unaffected(): + """Regression guard: a normal type="PLA" report (no vendor prefix) must + not be mistaken for a combined RFID string.""" + b = _bridge() + vendor, family = b._parse_combined_rfid_type("PLA") + assert vendor == "" + assert family == "" + + +def test_unknown_vendor_prefix_returns_no_match(): + b = _bridge() + vendor, family = b._parse_combined_rfid_type("TOTALLYUNKNOWNBRAND PLA Bas") + assert vendor == "" + assert family == "" + + +def test_match_profile_by_vendor_family_finds_imported_profile(): + b = _bridge() + profile = b._match_profile_by_vendor_family("Geeetech", "PLA") + assert profile.get("name") == "Geeetech PLA Basic" + + +def test_match_profile_by_vendor_family_no_match_returns_empty(): + b = _bridge() + profile = b._match_profile_by_vendor_family("Geeetech", "PETG") + assert profile == {} + + +def test_match_profile_by_vendor_family_ambiguous_picks_first_without_crashing(): + profiles = USER_PROFILES + [ + {"id": "GTPLA02", "name": "Geeetech PLA Silk", "vendor": "Geeetech", "type": "PLA SILK", "color": "", "is_user": True}, + ] + b = _bridge(profiles) + profile = b._match_profile_by_vendor_family("Geeetech", "PLA") + assert profile.get("name") in ("Geeetech PLA Basic", "Geeetech PLA Silk") + + +def test_build_lane_data_auto_resolves_combined_rfid_slot(): + """End-to-end: a slot reporting the combined RFID string should surface + the imported Geeetech profile in lane_data instead of the Generic + fallback.""" + b = _bridge() + b._filament_profiles = {} # no manual per-slot override - isolate the auto-match path + b._filament_mode = "ace_hub" + b._ams_slots = [ + {"global_index": 0, "box_id": 0, "status": 5, "type": "GEEETECH PLA Bas", "color": [238, 190, 152]}, + ] + lane = b._build_lane_data() + tray = lane["ams"][0]["tray"][0] + assert tray["vendor_name"] == "Geeetech" + assert tray["name"] == "Geeetech PLA Basic" + + +def test_build_lane_data_plain_type_still_uses_generic_fallback(): + """Regression guard: everyday type="PLA" slots must keep using the + existing Generic-library fallback, unaffected by the new matching path.""" + b = _bridge() + b._filament_profiles = {} # no manual per-slot override - isolate the fallback path + b._filament_mode = "ace_hub" + b._ams_slots = [ + {"global_index": 0, "box_id": 0, "status": 5, "type": "PLA", "color": [255, 255, 255]}, + ] + lane = b._build_lane_data() + tray = lane["ams"][0]["tray"][0] + assert tray["name"] == "Generic PLA" + assert tray["vendor_name"] == "Generic" From 0ca7618c850371c0120264c77dc75ae600b4e3ca Mon Sep 17 00:00:00 2001 From: viewit Date: Sun, 26 Jul 2026 23:29:32 +0200 Subject: [PATCH 07/16] fix(moonraker): metadata state-leak + terminal-state reset gaps (Issue #102) Three related gaps found via careful moonraker-obico observation: 1. _build_file_metadata() read layer_height/total_layers/estimated_time from live self._state before falling back to the queried file's own GCodeStore row - so querying metadata for any file other than the currently/last tracked job leaked that job's values into the response. Live state is now only used when the query targets that same tracked file; any other filename relies solely on its own stored row. 2. curr_layer/total_layers were never reset at print end in either _on_print or _on_info - only explicitly overwritten if a later payload happened to carry those keys, otherwise stuck indefinitely. Additionally, a successful "finished" print only ever cleared file_ready (Issue #29's fix), while stoped/canceled reset every other per-job field (progress, filename, duration, ...) - finished now gets the same full reset. Found and fixed a knock-on bug this surfaced: the unconditional `self._state["filename"] = d.get(...)` right after the reset block would immediately undo the filename reset, since the printer's own finished/stoped/canceled payload still carries the just-ended job's filename - now only applied outside terminal states. 3. The printer's own "progress" during pre-print phases (auto_leveling/ preheating/checking/updated/init) was forwarded as-is to virtual_sdcard.progress/display_status.progress, causing a non-monotonic jump-then-reset once real printing began. These phases no longer update the tracked progress value. Reported by @fmontagna, who correctly identified all three as genuine gaps rather than intentional behavior. --- NIGHTLY_CHANGELOG.md | 3 + kobrax_moonraker_bridge.py | 49 ++++-- tests/test_metadata_and_print_state_reset.py | 151 +++++++++++++++++++ 3 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 tests/test_metadata_and_print_state_reset.py diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 12b7a39..9bebc9d 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -7,3 +7,6 @@ - Fix: **the camera stream could hang forever after a printer reboot** — the printer rotates its stream token on reboot, but the running ffmpeg processes kept reading from the stale, now-silent connection and never noticed. The bridge now detects the URL change and automatically restarts the camera pipeline, ffmpeg itself now times out on a stalled read as a second line of defense, and `/api/camera/stream` now returns a proper 503 instead of hanging if no frame arrives within 5s (Issue #99, thanks @fmontagna for the excellent root-cause analysis and reproduction) - Fix: shrinking the Progress dashboard tile below its default height clipped the lower content (time grid, filename, buttons) out of view instead of scaling with it; dashboard tiles in general could show a scrollbar even when there was nothing to scroll. Both fixed (Issue #97, thanks @Blaim) - Feat: custom ACE-RFID filament tags (e.g. written via the "ACE RFID" app for third-party spools) are now matched automatically against your imported OrcaSlicer profiles. Anycubic's ACE RFID system concatenates vendor + material + a truncated serial into one type string (e.g. "GEEETECH PLA Bas") for custom tags — the bridge now recognizes the vendor prefix and resolves it to your already-imported profile instead of falling back to a generic default, so no manual per-slot reassignment is needed anymore (Issue #101, thanks @Blaim) +- Fix: `server/files/metadata` could leak the currently/last tracked print job's layer count and estimated time into a query for a *different* file, since those values were read from live state before falling back to the queried file's own stored metadata. Now only the currently-tracked file's own query uses live state — any other filename relies solely on its own record (Issue #102, thanks @fmontagna) +- Fix: `curr_layer`/`total_layers` (and, after a successful print, every other per-job field — progress, filename, duration, etc.) were never reset when a print finished normally, only on stop/cancel — they stayed at the last job's values until the next print happened to overwrite them (Issue #102, thanks @fmontagna) +- Fix: the printer's own progress value during pre-print phases (auto-leveling/preheating/bed-checking) was passed straight through to `virtual_sdcard.progress`/`display_status.progress`, causing it to jump non-monotonically once real printing started and progress reset. Pre-print progress is no longer forwarded (Issue #102, thanks @fmontagna) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 0f2d081..5dc5dfd 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1394,12 +1394,12 @@ class KobraXBridge: self._spoolman_notify_end() self._current_job_id = "" - # Hide the upload banner after the print finishes (Issue #29): the - # printer reports "finished" after a successful print - file_ready used to be - # cleared only on stoped/canceled, which brought the banner back. - if kobra_state == "finished": - self._state["file_ready"] = "" - if kobra_state in ("stoped", "canceled"): + # Terminal states (successful finish AND stop/cancel) must leave the + # same clean end state - a "finished" print used to only clear + # file_ready (Issue #29), leaving progress/filename/duration/layer + # fields stuck at the last job's values until the *next* print + # happened to overwrite them (Issue #102). + if kobra_state in ("finished", "stoped", "canceled"): self._state["progress"] = 0.0 self._state["filename"] = "" self._state["file_ready"] = "" @@ -1409,9 +1409,20 @@ class KobraXBridge: self._state["layer_height"] = 0.0 self._state["first_layer_height"] = 0.0 self._state["supplies_usage"] = 0 + self._state["curr_layer"] = 0 + self._state["total_layers"] = 0 self._thumbnail_b64 = "" - self._state["filename"] = d.get("filename", self._state["filename"]) - if "progress" in d: + else: + # Only adopt the payload's filename outside terminal states - the + # printer often still reports the just-finished job's filename in + # the same "finished"/"stoped"/"canceled" message that triggered + # the reset above, which would otherwise immediately undo it. + self._state["filename"] = d.get("filename", self._state["filename"]) + # Pre-print phases (leveling/preheating/checking) report their own + # "progress" - passing it through would make display_status.progress/ + # virtual_sdcard.progress jump non-monotonically once real printing + # starts and the value resets (Issue #102). + if "progress" in d and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"): self._state["progress"] = float(d["progress"]) / 100.0 if "print_time" in d: self._state["print_duration"] = int(d["print_time"]) * 60 @@ -1447,8 +1458,13 @@ class KobraXBridge: self._state["kobra_state"] = kobra_state # Hide the upload banner after the print ends (Issue #29) - the state also # arrives via info/report (project.state) depending on the printer, not only print/report. + # Layer fields must reset here too (Issue #102) - info/report is the + # only source for curr_layer/total_layers on some printers, and they + # otherwise stay stuck at the last job's values indefinitely. if kobra_state in ("finished", "stoped", "canceled"): self._state["file_ready"] = "" + self._state["curr_layer"] = 0 + self._state["total_layers"] = 0 # Camera auto-start here as well (OrcaSlicer often reports the start via info/report). # The _camera_autostarted guard prevents a double start with _on_print. if kobra_state == "printing": @@ -1467,7 +1483,8 @@ class KobraXBridge: if project: if "filename" in project: self._state["filename"] = project["filename"] - if "progress" in project: + # Same non-monotonic-progress guard as _on_print (Issue #102). + if "progress" in project and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"): self._state["progress"] = float(project["progress"]) / 100.0 if "print_time" in project: self._state["print_duration"] = int(project["print_time"]) * 60 @@ -3405,10 +3422,16 @@ class KobraXBridge: `modified` are non-nullable in GCodeFile; `print_start_time` and the Slicer-Felder optional.""" s = self._state - layer_h = float(s.get("layer_height") or 0.0) - first_h = float(s.get("first_layer_height") or 0.0) - total_layers = int(s.get("total_layers") or 0) - est_time = int(s.get("slicer_time") or 0) + # Live _state values are only relevant for the currently/last tracked + # job's own file - using them as a starting point for a DIFFERENT + # filename leaked the tracked job's layer count/time into unrelated + # metadata queries (Issue #102). For any other filename, rely solely + # on that file's own GCodeStore row. + is_tracked_file = bool(filename) and filename == s.get("filename") + layer_h = float(s.get("layer_height") or 0.0) if is_tracked_file else 0.0 + first_h = float(s.get("first_layer_height") or 0.0) if is_tracked_file else 0.0 + total_layers = int(s.get("total_layers") or 0) if is_tracked_file else 0 + est_time = int(s.get("slicer_time") or 0) if is_tracked_file else 0 size_bytes = 0 try: gf = self._store.get_file_by_name(filename) or {} diff --git a/tests/test_metadata_and_print_state_reset.py b/tests/test_metadata_and_print_state_reset.py new file mode 100644 index 0000000..7a9b70d --- /dev/null +++ b/tests/test_metadata_and_print_state_reset.py @@ -0,0 +1,151 @@ +"""server/files/metadata state-leak + terminal-state reset gaps (Issue #102). + +Reported by @fmontagna via moonraker-obico: +1. Querying metadata for a file OTHER than the currently/last tracked job + leaked that job's live layer count / estimated time into the response, + because _build_file_metadata() read from self._state first and only fell + back to the file's own GCodeStore row when the state value was falsy. +2. curr_layer/total_layers (and, for a successful "finished" print, every + other per-job field) were never reset at print end - they stayed at the + last job's values until the next print happened to overwrite them. +3. The printer reports its own "progress" during pre-print phases + (preheating/auto_leveling/checking/...), which used to pass straight + through to display_status.progress/virtual_sdcard.progress and then jump + non-monotonically once real printing started and progress reset. +""" +import argparse +import tempfile +from unittest.mock import MagicMock + +from kobrax_moonraker_bridge import KobraXBridge + + +def _bridge(): + c = MagicMock(); c.callbacks = {}; c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxmeta-"), + ) + return KobraXBridge(c, args=args) + + +def _insert_store_row(b, filename, layer_count=None, est_time=None, size_bytes=0): + with b._store._lock: + b._store._conn.execute( + "INSERT OR REPLACE INTO gcode_files (id, filename, path, size_bytes, uploaded_at, layer_count, est_print_time_sec) " + "VALUES (?,?,?,?,?,?,?)", + (filename, filename, "/tmp/" + filename, size_bytes, "2026-01-01T00:00:00Z", layer_count, est_time), + ) + b._store._conn.commit() + + +# --- Fix 1: metadata state-leak ------------------------------------------- + +def test_metadata_for_untracked_file_does_not_leak_running_job_state(): + b = _bridge() + # A job is "running": live state has its own layer/time values. + b._state["filename"] = "running.gcode" + b._state["total_layers"] = 999 + b._state["slicer_time"] = 12345 + b._state["layer_height"] = 0.3 + + # Querying a DIFFERENT, unrelated file must use ITS OWN store row, not + # the running job's live state. + _insert_store_row(b, "other.gcode", layer_count=42, est_time=600, size_bytes=1000) + meta = b._build_file_metadata("other.gcode") + assert meta["layer_count"] == 42 + assert meta["estimated_time"] == 600 + assert meta["size"] == 1000 + + +def test_metadata_for_tracked_file_still_uses_live_state(): + """The currently-tracked file's OWN metadata query should still prefer + live state (fresher than what was known at upload time).""" + b = _bridge() + b._state["filename"] = "running.gcode" + b._state["total_layers"] = 55 + b._state["slicer_time"] = 999 + _insert_store_row(b, "running.gcode", layer_count=1, est_time=1) + meta = b._build_file_metadata("running.gcode") + assert meta["layer_count"] == 55 + assert meta["estimated_time"] == 999 + + +def test_metadata_for_nonexistent_file_falls_back_cleanly(): + b = _bridge() + b._state["filename"] = "running.gcode" + b._state["total_layers"] = 999 + meta = b._build_file_metadata("DOES_NOT_EXIST.gcode") + assert meta["layer_count"] is None + assert meta["size"] == 1 # documented fallback, unrelated to this fix + + +# --- Fix 2: terminal-state reset ------------------------------------------- + +def _print_payload(state, **extra): + """print/report envelope: `state` is top-level, everything else is under + `data` (see _on_print: kobra_state = payload.get("state", "")). """ + d = {"filename": "job.gcode"} + d.update(extra) + return {"state": state, "data": d} + + +def test_finished_resets_layer_fields_like_stoped_canceled(): + b = _bridge() + b._state["curr_layer"] = 10 + b._state["total_layers"] = 20 + b._state["progress"] = 0.5 + b._state["filename"] = "job.gcode" + b._on_print(_print_payload("finished")) + assert b._state["curr_layer"] == 0 + assert b._state["total_layers"] == 0 + assert b._state["progress"] == 0.0 + assert b._state["filename"] == "" + + +def test_canceled_resets_layer_fields(): + b = _bridge() + b._state["curr_layer"] = 7 + b._state["total_layers"] = 20 + b._on_print(_print_payload("canceled")) + assert b._state["curr_layer"] == 0 + assert b._state["total_layers"] == 0 + + +def test_on_info_resets_layer_fields_on_terminal_state(): + b = _bridge() + b._state["curr_layer"] = 7 + b._state["total_layers"] = 20 + b._on_info({"data": {"project": {"state": "finished"}}}) + assert b._state["curr_layer"] == 0 + assert b._state["total_layers"] == 0 + + +# --- Fix 3: progress clamping during pre-print phases ---------------------- + +def test_progress_not_updated_during_auto_leveling(): + b = _bridge() + b._state["progress"] = 0.0 + b._on_print(_print_payload("auto_leveling", progress=87)) + assert b._state["progress"] == 0.0 + + +def test_progress_not_updated_during_preheating(): + b = _bridge() + b._state["progress"] = 0.0 + b._on_print(_print_payload("preheating", progress=42)) + assert b._state["progress"] == 0.0 + + +def test_progress_updates_normally_once_printing(): + b = _bridge() + b._on_print(_print_payload("printing", progress=33)) + assert b._state["progress"] == 0.33 + + +def test_on_info_progress_clamped_during_checking(): + b = _bridge() + b._state["progress"] = 0.0 + b._on_info({"data": {"project": {"state": "checking", "progress": 55}}}) + assert b._state["progress"] == 0.0 From 64c8bc32d7ea71aa24eefd995294431f06e1e7ae Mon Sep 17 00:00:00 2001 From: viewit Date: Mon, 27 Jul 2026 00:09:15 +0200 Subject: [PATCH 08/16] README.de.md aktualisiert --- README.de.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.de.md b/README.de.md index 2cbdfb1..d7b52bd 100644 --- a/README.de.md +++ b/README.de.md @@ -29,9 +29,7 @@ Feedback willkommen. -> [!CAUTION] -> **Laufende Wartungsarbeiten** — Wir strukturieren das Repository um (Branch-Modell, CI-Workflows, Beitragsprozess). Es kann zu Änderungen bei Branch-Namen, PR-Templates und der Art der Veröffentlichungen kommen. Wir entschuldigen uns für etwaige Unannehmlichkeiten. Handhabung, Workflow und langfristige Wartbarkeit werden dadurch deutlich verbessert. -> + > 👉 Möchtest du beitragen? Bitte zuerst [CONTRIBUTING.md](CONTRIBUTING.md) lesen. --- From 1d5ac8dc4e78cfc23f40bb233c12ee2d075950b0 Mon Sep 17 00:00:00 2001 From: viewit Date: Mon, 27 Jul 2026 00:15:22 +0200 Subject: [PATCH 09/16] chore(ci): reset NIGHTLY_CHANGELOG.md after each nightly release NIGHTLY_CHANGELOG.md was never cleared by either nightly.yml or release.yml - it only grew across every push, so each nightly release kept re-listing every entry since the last manual reset instead of just what changed since the previous build. Add a step after the Gitea nightly release is created that resets the file to its empty header and pushes the reset commit to nightly. Not part of the workflow's own push-trigger paths, so it doesn't re-trigger a nightly build. --- .gitea/workflows/nightly.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 9b9818c..a3d95f5 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -174,3 +174,20 @@ jobs: --data-binary @/tmp/release_body.json \ "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases" rm -f "$BODY_FILE" /tmp/release_body.json + + - name: Reset NIGHTLY_CHANGELOG.md for the next build + env: + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + . /tmp/nightly_version.env + # The changelog just consumed above must not carry over into the + # next nightly - otherwise every build re-lists all prior entries + # since the last manual reset instead of just what's new. Not in + # nightly.yml's own push-trigger paths, so this commit does not + # re-trigger this workflow. + printf '## Changes in this build\n\n' > NIGHTLY_CHANGELOG.md + git config user.name "gitea-actions" + git config user.email "actions@gitea.it-drui.de" + git add NIGHTLY_CHANGELOG.md + git commit -m "chore: reset NIGHTLY_CHANGELOG.md after nightly-${VERSION} release" || exit 0 + git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git HEAD:nightly From 4e7f851799309f27494903a530569b71d3cfde6a Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Mon, 27 Jul 2026 02:07:48 +0000 Subject: [PATCH 10/16] chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.28-nightly46 release --- NIGHTLY_CHANGELOG.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 9bebc9d..f1eff73 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,12 +1,2 @@ ## Changes in this build -- Fix: on printers with multiple daisy-chained ACE units and no toolhead buffer (e.g. Kobra S1 with 2 ACE Pro), only the first unit's 4 slots were ever shown on the dashboard or synced to OrcaSlicer — the bridge silently discarded every ACE unit after the first. All units now show up correctly (Issue #95, thanks @hoovercl for the detailed report and logs) -- Feat: the GCode browser now supports multi-select and bulk delete — click any file's checkbox to enter select mode, use "Select All" to grab everything currently visible (respecting your active search/filter), then delete the selection with one confirmation instead of one-by-one (Issue #94, thanks @Blaim) -- Fix: **printing via OrcaSlicer "Upload and print" failed (or fed the wrong spool) when a slot below the used filament was empty** — e.g. printing with Filament 4 while slot 3 was empty. The generated AMS slot mapping inserted a placeholder pointing at the physically empty tray, which the printer rejects. Placeholders now point at a loaded tray instead. Printing with all slots full already worked and is unchanged. -- Fix: manually assigning a filament profile to an ACE slot could crash the MQTT callback (`'list' object has no attribute 'get'`) when the printer rejected the assignment, e.g. for custom-RFID/third-party filament. The bridge now handles the printer's failure response cleanly instead of crashing, and the log now correlates a rejected assignment with the request that triggered it (slot/type/color) so a rejection reason can be diagnosed from bridge logs alone (Issue #100, thanks @Blaim) -- Fix: **the camera stream could hang forever after a printer reboot** — the printer rotates its stream token on reboot, but the running ffmpeg processes kept reading from the stale, now-silent connection and never noticed. The bridge now detects the URL change and automatically restarts the camera pipeline, ffmpeg itself now times out on a stalled read as a second line of defense, and `/api/camera/stream` now returns a proper 503 instead of hanging if no frame arrives within 5s (Issue #99, thanks @fmontagna for the excellent root-cause analysis and reproduction) -- Fix: shrinking the Progress dashboard tile below its default height clipped the lower content (time grid, filename, buttons) out of view instead of scaling with it; dashboard tiles in general could show a scrollbar even when there was nothing to scroll. Both fixed (Issue #97, thanks @Blaim) -- Feat: custom ACE-RFID filament tags (e.g. written via the "ACE RFID" app for third-party spools) are now matched automatically against your imported OrcaSlicer profiles. Anycubic's ACE RFID system concatenates vendor + material + a truncated serial into one type string (e.g. "GEEETECH PLA Bas") for custom tags — the bridge now recognizes the vendor prefix and resolves it to your already-imported profile instead of falling back to a generic default, so no manual per-slot reassignment is needed anymore (Issue #101, thanks @Blaim) -- Fix: `server/files/metadata` could leak the currently/last tracked print job's layer count and estimated time into a query for a *different* file, since those values were read from live state before falling back to the queried file's own stored metadata. Now only the currently-tracked file's own query uses live state — any other filename relies solely on its own record (Issue #102, thanks @fmontagna) -- Fix: `curr_layer`/`total_layers` (and, after a successful print, every other per-job field — progress, filename, duration, etc.) were never reset when a print finished normally, only on stop/cancel — they stayed at the last job's values until the next print happened to overwrite them (Issue #102, thanks @fmontagna) -- Fix: the printer's own progress value during pre-print phases (auto-leveling/preheating/bed-checking) was passed straight through to `virtual_sdcard.progress`/`display_status.progress`, causing it to jump non-monotonically once real printing started and progress reset. Pre-print progress is no longer forwarded (Issue #102, thanks @fmontagna) From cbcb17f45aff60fe54ef39e1962b3f4dc855b09f Mon Sep 17 00:00:00 2001 From: viewit Date: Mon, 27 Jul 2026 14:04:22 +0200 Subject: [PATCH 11/16] feat(browser): add second tab for files on the printer's own storage The GCode browser previously only showed files the bridge itself had stored (its own SQLite GCodeStore, uploaded through the bridge). Files printed directly via Anycubic Slicer Next (bypassing the bridge) land on the printer's internal storage instead, and were only visible/ manageable from the printer's own display. Adds a second sub-tab ("On Printer") using the same master-detail tab pattern already used for Settings categories (showSettingsCat), backed by the printer's file/listLocal and file/deleteBatch MQTT actions - verified live against a real Kobra X, see memory reference_mqtt_listlocal.md. Key implementation detail: publish()'s own return value for these actions is just a generic immediate ACK skeleton (code=0, empty fields) - the real response arrives asynchronously via the file/report callback (_on_file), same as the existing fileDetails fire-and-forget pattern. Added _wait_for_file_action() as a small reusable bridge between that async callback delivery and the synchronous HTTP handler, via a per-action threading.Event registered in _on_file. New endpoints: GET /kx/printer-files, POST /kx/printer-files/delete (single endpoint for both single- and multi-select delete, since deleteBatch natively accepts a filename list). Frontend mirrors the existing store multi-select pattern (Issue #94): select mode, select-all (scoped to what's loaded), bulk delete with confirmation. No print/download actions in this tab for now - printing a file already on the printer without re-uploading needs its own MQTT schema that hasn't been verified yet. Verified end-to-end against the real printer: listed 146 files, deleted one, confirmed via a follow-up list that it was gone and nothing else was affected. Also verified visually (Playwright): tab switching, card rendering, multi-select mode, and cancel all work as expected. --- NIGHTLY_CHANGELOG.md | 1 + kobrax_moonraker_bridge.py | 96 +++++++++++++++++ tests/test_printer_files_endpoint.py | 144 +++++++++++++++++++++++++ web/themes/default/app.js | 153 +++++++++++++++++++++++++++ web/themes/default/index.html | 112 +++++++++++++------- web/themes/default/style.css | 6 ++ web/translations/de.json | 44 ++++---- web/translations/en.json | 44 ++++---- web/translations/es.json | 44 ++++---- web/translations/fr.json | 16 ++- web/translations/it.json | 16 ++- web/translations/zh-cn.json | 44 ++++---- 12 files changed, 594 insertions(+), 126 deletions(-) create mode 100644 tests/test_printer_files_endpoint.py diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index f1eff73..a7acaab 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,2 +1,3 @@ ## Changes in this build +- Feat: the GCode browser now has a second tab showing files stored directly on the printer's own internal storage (e.g. from prints started via Anycubic Slicer Next, bypassing the bridge) — list, multi-select, and delete them right from the Bridge UI instead of needing the printer's own display. diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 5dc5dfd..71c2923 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1051,6 +1051,13 @@ class KobraXBridge: self._head_tools_model: int = -1 self._filament_mode: str = "toolhead" self._last_uploaded_file: str = "" + # Pending waiters for a specific file/report `action` (e.g. "listLocal", + # "deleteBatch"). publish()'s own return value for these actions is just + # a generic immediate ACK skeleton (code=0, all fields empty) - the real + # answer arrives later via the file/report callback (_on_file), same + # as the existing fileDetails fire-and-forget pattern. Format: + # {action: {"event": threading.Event(), "result": dict|None}}. + self._file_action_waiters: dict[str, dict] = {} self._store = store if store is not None else GCodeStore(args.data_dir) self._serve_dir_path: str = self._store._gcode_dir self._current_job_id: str = "" @@ -1559,7 +1566,41 @@ class KobraXBridge: if payload.get("state") == "done" or payload.get("code") == 200: log.info(f"Skip response: state={payload.get('state')} code={payload.get('code')} skipped={skipped}") + def _wait_for_file_action(self, action: str, send_fn, timeout: float = 8.0) -> dict | None: + """Sends a file/* MQTT request (via send_fn, which must call + self.client.publish(..., timeout=0) fire-and-forget) and blocks the + calling thread until a matching file/report with this `action` + arrives via _on_file, or the timeout elapses. + + Needed because the printer's publish() return value for actions like + listLocal/deleteBatch is just a generic immediate ACK skeleton + (code=0, empty fields) - the real response is a separate, later + file/report message, same as the existing fileDetails pattern. + Must be called from a worker thread (e.g. via run_in_executor), not + the asyncio event loop, since it blocks on a threading.Event. + """ + event = threading.Event() + waiter = {"event": event, "result": None} + self._file_action_waiters[action] = waiter + try: + send_fn() + event.wait(timeout) + return waiter["result"] + finally: + if self._file_action_waiters.get(action) is waiter: + del self._file_action_waiters[action] + def _on_file(self, payload: dict): + # Deliver to any pending listLocal/deleteBatch waiter first (see + # _wait_for_file_action) - these actions carry no file_details/ + # thumbnail payload of their own, so this doesn't interfere with the + # handling below. + action = payload.get("action") or "" + waiter = self._file_action_waiters.get(action) + if waiter is not None: + waiter["result"] = payload + waiter["event"].set() + d = payload.get("data") or {} details = d.get("file_details") or {} thumb = details.get("thumbnail") or details.get("png_image") or "" @@ -2776,6 +2817,59 @@ class KobraXBridge: return self._json_cors({"result": "ok"}) return self._json_cors({"error": "not found"}, status=404) + async def handle_kx_printer_files(self, request): + """GET /kx/printer-files - lists files on the printer's OWN internal + storage (file/listLocal MQTT action), as opposed to /kx/files which + lists what the bridge itself has stored. Needed because prints + started directly from Anycubic Slicer Next (bypassing the bridge) + leave files on the printer that were previously only visible/ + deletable from the printer's own display (Issue #102 context).""" + loop = asyncio.get_event_loop() + def _fetch(): + return self._wait_for_file_action( + "listLocal", + lambda: self.client.publish( + "file", "listLocal", + {"page_num": 1, "page_size": 200, "path": "/"}, + timeout=0, + ), + timeout=8.0, + ) + result = await loop.run_in_executor(None, _fetch) + if not result or result.get("code") != 200: + return self._json_cors({"error": "printer unreachable or query failed"}, status=502) + records = (result.get("data") or {}).get("records") or [] + files = [r for r in records if not r.get("is_dir")] + return self._json_cors({"result": files}) + + async def handle_kx_printer_file_delete(self, request): + """POST /kx/printer-files/delete - body: {"filenames": ["a.gcode", ...]}. + Single endpoint for both single and multi-select delete - the + printer's file/deleteBatch MQTT action natively accepts a list.""" + try: + body = await request.json() + except Exception: + body = {} + filenames = body.get("filenames") or [] + if not filenames: + return self._json_cors({"error": "no filenames given"}, status=400) + files = [{"path": "/", "filename": fn} for fn in filenames if fn] + loop = asyncio.get_event_loop() + def _delete(): + return self._wait_for_file_action( + "deleteBatch", + lambda: self.client.publish( + "file", "deleteBatch", + {"root": "local", "files": files}, + timeout=0, + ), + timeout=8.0, + ) + result = await loop.run_in_executor(None, _delete) + if not result or result.get("state") != "success": + return self._json_cors({"error": "delete failed", "detail": result}, status=502) + return self._json_cors({"result": "ok"}) + async def handle_kx_file_download(self, request): file_id = request.match_info["file_id"] f = self._store.get_file(file_id) @@ -5733,6 +5827,8 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_delete("/kx/files/{file_id}", bridge.handle_kx_file_delete) r.add_get("/kx/files/{file_id}/download", bridge.handle_kx_file_download) r.add_post("/kx/files/{file_id}/verify", bridge.handle_kx_file_verify) + r.add_get("/kx/printer-files", bridge.handle_kx_printer_files) + r.add_post("/kx/printer-files/delete", bridge.handle_kx_printer_file_delete) r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots) r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles) r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile) diff --git a/tests/test_printer_files_endpoint.py b/tests/test_printer_files_endpoint.py new file mode 100644 index 0000000..d03070b --- /dev/null +++ b/tests/test_printer_files_endpoint.py @@ -0,0 +1,144 @@ +""" +Tests für /kx/printer-files (list) und /kx/printer-files/delete — +der zweite Browser-Tab, der Dateien auf dem Drucker selbst zeigt +(via MQTT file/listLocal + file/deleteBatch, live gegen den echten +Kobra X verifiziert, siehe Memory reference_mqtt_listlocal.md). + +Important: publish()'s own return value for these actions is just a +generic immediate ACK skeleton (code=0, empty fields) - the real answer +arrives asynchronously via the file/report callback (_on_file), same as +the existing fileDetails fire-and-forget pattern. So publish() itself +returns None/skeleton here, and the "real" response is delivered by +firing bridge._on_file(...) from a background thread, simulating what +the MQTT reader thread would do when the printer's file/report arrives. +""" +import threading +import time + +import pytest + + +LISTLOCAL_SUCCESS = { + "action": "listLocal", + "code": 200, + "state": "success", + "data": { + "list_mode": 0, + "records": [ + {"filename": "a.gcode", "is_dir": False, "size": 100, "timestamp": 1700000000000}, + {"filename": "subdir", "is_dir": True, "size": 0, "timestamp": 1700000001000}, + {"filename": "b.gcode", "is_dir": False, "size": 200, "timestamp": 1700000002000}, + ], + }, +} + +LISTLOCAL_FAILED = { + "action": "listLocal", + "code": 10112, + "state": "failed", + "data": None, +} + +DELETEBATCH_SUCCESS = { + "action": "deleteBatch", + "code": 200, + "state": "success", + "data": None, + "msg": "done", +} + +DELETEBATCH_FAILED = { + "action": "deleteBatch", + "code": 10112, + "state": "failed", + "data": None, +} + + +def _deliver_async(bridge, payload, delay=0.05): + """Simulates the MQTT reader thread delivering a file/report a moment + after the fire-and-forget publish() call returns.""" + def _fire(): + time.sleep(delay) + bridge._on_file(payload) + threading.Thread(target=_fire, daemon=True).start() + + +@pytest.mark.asyncio +async def test_printer_files_lists_files_and_excludes_dirs(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1] + resp = await c.get("/kx/printer-files") + assert resp.status == 200 + data = await resp.json() + filenames = [f["filename"] for f in data["result"]] + assert filenames == ["a.gcode", "b.gcode"] # "subdir" (is_dir=True) excluded + + +@pytest.mark.asyncio +async def test_printer_files_uses_correct_mqtt_payload(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1] + await c.get("/kx/printer-files") + args, kwargs = bridge.client.publish.call_args + assert args[0] == "file" + assert args[1] == "listLocal" + assert args[2] == {"page_num": 1, "page_size": 200, "path": "/"} + + +@pytest.mark.asyncio +async def test_printer_files_returns_502_on_printer_failure(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_FAILED), None)[1] + resp = await c.get("/kx/printer-files") + assert resp.status == 502 + + +@pytest.mark.asyncio +async def test_printer_files_returns_502_on_timeout(client): + c, bridge = client + bridge.client.publish.return_value = None # no file/report ever arrives + resp = await c.get("/kx/printer-files") + assert resp.status == 502 + + +@pytest.mark.asyncio +async def test_printer_file_delete_success(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1] + resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]}) + assert resp.status == 200 + data = await resp.json() + assert data["result"] == "ok" + + +@pytest.mark.asyncio +async def test_printer_file_delete_uses_correct_mqtt_payload(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1] + await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode", "b.gcode"]}) + args, kwargs = bridge.client.publish.call_args + assert args[0] == "file" + assert args[1] == "deleteBatch" + assert args[2] == { + "root": "local", + "files": [ + {"path": "/", "filename": "a.gcode"}, + {"path": "/", "filename": "b.gcode"}, + ], + } + + +@pytest.mark.asyncio +async def test_printer_file_delete_empty_filenames_returns_400(client): + c, _ = client + resp = await c.post("/kx/printer-files/delete", json={"filenames": []}) + assert resp.status == 400 + + +@pytest.mark.asyncio +async def test_printer_file_delete_returns_502_on_printer_rejection(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_FAILED), None)[1] + resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]}) + assert resp.status == 502 diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 8b583ec..ae98222 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -357,6 +357,11 @@ function applyLang(){ setText('store-lbl-select-all',T.store_select_all||'Select All'); setText('store-lbl-delete-selected',T.store_delete_selected||'Delete Selected'); setText('store-lbl-exit-select',T.store_exit_select||'Cancel'); + setText('btab-lbl-uploaded',T.browser_tab_uploaded||'Uploaded'); + setText('btab-lbl-printer',T.browser_tab_printer||'On Printer'); + setText('printer-store-lbl-select-all',T.store_select_all||'Select All'); + setText('printer-store-lbl-delete-selected',T.store_delete_selected||'Delete Selected'); + setText('printer-store-lbl-exit-select',T.store_exit_select||'Cancel'); // Dashboard card titles setText('d-card-progress',T.card_progress); setText('d-card-temps',T.card_temps); @@ -594,6 +599,17 @@ function showSettingsCat(name){ var c=document.getElementById('setcat-'+name);if(c)c.classList.add('active'); } +// Browser-Sub-Tab umschalten: hochgeladene Dateien (Bridge-Store) vs. Dateien +// auf dem Drucker selbst (interner Speicher, via listLocal MQTT). +var _printerFilesLoaded=false; +function showBrowserTab(name){ + document.querySelectorAll('.browser-group').forEach(g=>g.classList.remove('active')); + document.querySelectorAll('.browser-tab').forEach(b=>b.classList.remove('active')); + var g=document.getElementById('browser-group-'+name);if(g)g.classList.add('active'); + var t=document.getElementById('btab-'+name);if(t)t.classList.add('active'); + if(name==='printer'&&!_printerFilesLoaded)loadPrinterFiles(); +} + // ── Console log ── var consoleLogs=[]; var logAutoScroll=true; @@ -2987,6 +3003,143 @@ function storeDeleteSelected(){ }); } +// ── Files on the printer's own internal storage (Issue: 2nd Browser tab) ── +// Uses filename as the identity key (the printer has no numeric file id like +// the bridge's own GCodeStore) and a single batch-delete call, since the +// printer's file/deleteBatch MQTT action natively accepts a filename list. +var printerFiles=[]; +var _printerFilesSelectMode=false; +var _printerFilesSelected={}; // filename -> true + +function loadPrinterFiles(){ + var errEl=document.getElementById('printer-store-error'); + if(errEl)errEl.style.display='none'; + fetch(_apiUrl('/kx/printer-files')).then(function(r){return r.json()}).then(function(d){ + _printerFilesLoaded=true; + if(d.error){ + printerFiles=[]; + if(errEl){errEl.textContent=T.printer_store_unreachable||d.error;errEl.style.display='block';} + renderPrinterFiles(); + return; + } + printerFiles=d.result||[]; + _printerFilesSelected={}; + _printerFilesSelectMode=false; + var bar=document.getElementById('printer-store-select-bar'); + if(bar)bar.style.display='none'; + renderPrinterFiles(); + }).catch(function(e){ + _printerFilesLoaded=true; + if(errEl){errEl.textContent=T.printer_store_unreachable||String(e);errEl.style.display='block';} + }); +} + +function renderPrinterFiles(){ + var grid=document.getElementById('printer-store-grid'); + var empty=document.getElementById('printer-store-empty'); + if(!grid||!empty)return; + if(!printerFiles.length){ + empty.textContent=T.printer_store_empty||'No files on the printer.'; + grid.innerHTML=''; + empty.style.display='block'; + return; + } + empty.style.display='none'; + grid.innerHTML=printerFiles.map(function(f){ + var name=f.filename.length>28?f.filename.slice(0,25)+'…':f.filename; + var sizeKb=f.size?(f.size/1024).toFixed(0)+' KB':'–'; + var date=f.timestamp?new Date(f.timestamp).toISOString().replace('T',' ').slice(0,16):''; + var isSelected=!!_printerFilesSelected[f.filename]; + var selectBorder=isSelected?'border:2px solid var(--accent);padding:9px':'border:1px solid var(--border);padding:10px'; + var checkbox=''+ + ''; + var cardClick=_printerFilesSelectMode?'onclick="printerFileToggleSelect(\''+f.filename.replace(/'/g,"\\'")+'\')" style="cursor:pointer;position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"': + 'style="position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"'; + return '
'+ + checkbox+ + '
🖨
'+ + '
'+name+'
'+ + '
💾 '+sizeKb+'
'+ + '
📅 '+date+'
'+ + '
'+ + ''+ + '
'+ + '
'; + }).join(''); + _printerFilesUpdateSelectBar(); +} + +function _printerFilesUpdateSelectBar(){ + var selectAll=document.getElementById('printer-store-select-all'); + var countEl=document.getElementById('printer-store-selected-count'); + var delBtn=document.getElementById('printer-store-delete-selected-btn'); + if(!selectAll||!countEl||!delBtn)return; + var allNames=printerFiles.map(function(f){return f.filename;}); + var selected=allNames.filter(function(n){return _printerFilesSelected[n];}); + var n=selected.length; + selectAll.checked=n>0&&n===allNames.length; + selectAll.indeterminate=n>0&&n0?(T.store_selected_count||'{n} selected').replace('{n}',n):''; + delBtn.disabled=n===0; +} + +function printerFileToggleSelect(filename){ + if(!_printerFilesSelectMode){ + _printerFilesSelectMode=true; + var bar=document.getElementById('printer-store-select-bar'); + if(bar)bar.style.display='flex'; + } + if(_printerFilesSelected[filename])delete _printerFilesSelected[filename]; + else _printerFilesSelected[filename]=true; + renderPrinterFiles(); +} + +function printerFileToggleSelectAll(checked){ + _printerFilesSelected={}; + if(checked)printerFiles.forEach(function(f){_printerFilesSelected[f.filename]=true;}); + renderPrinterFiles(); +} + +function printerFileExitSelectMode(){ + _printerFilesSelectMode=false; + _printerFilesSelected={}; + var bar=document.getElementById('printer-store-select-bar'); + if(bar)bar.style.display='none'; + renderPrinterFiles(); +} + +function _printerFilesDeleteRequest(filenames){ + return fetch(_apiUrl('/kx/printer-files/delete'),{ + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({filenames:filenames}), + }).then(function(r){return r.json().then(function(d){return{ok:r.ok,body:d};});}); +} + +function printerFileDelete(filename){ + if(!confirm(T.printer_store_delete_confirm||'Delete file?'))return; + _printerFilesDeleteRequest([filename]).then(function(res){ + if(!res.ok){clog((T.log_delete_failed||'Delete failed')+': '+(res.body.error||''),'msg-err');return;} + loadPrinterFiles(); + }); +} + +function printerFileDeleteSelected(){ + var names=Object.keys(_printerFilesSelected); + if(!names.length)return; + if(!confirm((T.printer_store_delete_selected_confirm||'Delete {n} selected files?').replace('{n}',names.length)))return; + _printerFilesDeleteRequest(names).then(function(res){ + if(!res.ok){clog((T.log_delete_failed||'Delete failed')+': '+(res.body.error||''),'msg-err');return;} + printerFileExitSelectMode(); + loadPrinterFiles(); + }); +} + var _gcodeFilaments=[]; function _setGcodeFilamentsFromFileObj(fileObj){ diff --git a/web/themes/default/index.html b/web/themes/default/index.html index 0f4e19f..30836fa 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -388,49 +388,81 @@ 🗂 Datei-Browser -
- - - +
+ +
-
- - - GCode hierher ziehen oder durchsuchen - + +
+
+ + + +
+
+ + + GCode hierher ziehen oder durchsuchen + +
+ + +
-
diff --git a/web/themes/default/style.css b/web/themes/default/style.css index efb661f..3e1a248 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -322,6 +322,12 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background: .set-cat .nav-text{display:inline} } +/* ── BROWSER SUB-TABS (uploaded vs. on-printer files) ── */ +.browser-tab.active{color:var(--accent);border-bottom-color:var(--accent)!important} +.browser-tab:hover{color:var(--txt)} +.browser-group{display:none} +.browser-group.active{display:block} + /* ── FILE BROWSER UPLOAD ZONE ── */ #store-upload-zone{ display:flex;flex-direction:column;align-items:center;justify-content:center; diff --git a/web/translations/de.json b/web/translations/de.json index d72c052..2a5857e 100644 --- a/web/translations/de.json +++ b/web/translations/de.json @@ -42,6 +42,8 @@ "apd_placeholder_name": "z.B. Kobra X Wohnzimmer", "apd_success": "Drucker hinzugefügt, Bridge startet neu…", "apd_title": "Drucker hinzufügen", + "browser_tab_printer": "Auf dem Drucker", + "browser_tab_uploaded": "Hochgeladen", "btn_cam_start": "▶ Kamera", "btn_cam_start2": "▶ Start", "btn_cam_stop": "◼ Kamera", @@ -68,6 +70,18 @@ "card_speed": "Druckgeschwindigkeit", "card_temps": "Temperaturen", "confirm_cancel": "Druck wirklich abbrechen?", + "dash_done": "Fertig", + "dash_edit": "Dashboard anpassen", + "dash_hidden_cards": "Ausgeblendete Karten", + "dash_hide": "Ausblenden", + "dash_preset_delete_confirm": "Preset \"{name}\" löschen?", + "dash_preset_name_prompt": "Preset-Name:", + "dash_preset_standard": "Standard", + "dash_preset_wide89": "Desktop breit", + "dash_reset": "Zurücksetzen", + "dash_save_preset": "Als Preset speichern", + "dash_show": "Einblenden", + "dash_toggle_width": "Breite umschalten", "fd_cancel": "Abbrechen", "fd_no_matching_material": "Kein passendes Material", "fd_no_slots_msg": "Keine belegten AMS-Slots.{br}Druck trotzdem starten?", @@ -200,6 +214,10 @@ "panel_temps_chart": "Verlauf (letzte 60 Messungen)", "panel_temps_nozzle": "Düse", "print_auto_leveling": "Auto-Leveling für diesen Druck", + "printer_store_delete_confirm": "Datei vom Drucker löschen?", + "printer_store_delete_selected_confirm": "{n} ausgewählte Dateien vom Drucker löschen?", + "printer_store_empty": "Keine Dateien auf dem Drucker.", + "printer_store_unreachable": "Drucker nicht erreichbar oder Abfrage fehlgeschlagen.", "printers_active": "● aktiv", "printers_current": "Aktueller Drucker", "printers_empty_hint": "Noch kein Drucker eingerichtet.", @@ -213,7 +231,6 @@ "progress_action_slots": "Slots zuordnen", "settings_auto_leveling": "Auto-Leveling vor Druck", "settings_auto_leveling_label": "Auto-Leveling vor dem Druck", - "settings_vibration_compensation": "Resonanzkompensation", "settings_btn_tooltip": "Einstellungen", "settings_camera_on_print": "Kamera bei Druckstart einschalten", "settings_cat_connection": "Verbindung", @@ -247,19 +264,6 @@ "settings_password": "MQTT-Passwort", "settings_poll": "Poll-Intervall (Sekunden)", "settings_poll_interval_hint": "Wie oft die Bridge den Druckerstatus abfragt", - "settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)", - "dash_edit": "Dashboard anpassen", - "dash_done": "Fertig", - "dash_reset": "Zurücksetzen", - "dash_preset_standard": "Standard", - "dash_preset_wide89": "Desktop breit", - "dash_hidden_cards": "Ausgeblendete Karten", - "dash_show": "Einblenden", - "dash_hide": "Ausblenden", - "dash_toggle_width": "Breite umschalten", - "dash_save_preset": "Als Preset speichern", - "dash_preset_name_prompt": "Preset-Name:", - "dash_preset_delete_confirm": "Preset \"{name}\" löschen?", "settings_poll_interval_label": "Poll-Intervall (Sekunden)", "settings_print": "Druckeinstellungen", "settings_printer_ip": "Drucker-IP", @@ -271,7 +275,9 @@ "settings_title": "Einstellungen", "settings_username": "MQTT-Benutzername", "settings_vendor_filter_placeholder": "Hersteller suchen…", + "settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)", "settings_version": "Version", + "settings_vibration_compensation": "Resonanzkompensation", "settings_visible_vendors": "Sichtbare Hersteller (Profil-Dropdown)", "settings_visible_vendors_hint": "Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic\" und eigene Profile sind immer sichtbar.", "settings_visible_vendors_label": "Sichtbare Hersteller (Profil-Dropdown)", @@ -293,6 +299,7 @@ "skip_sending": "Sende …", "skip_success": "Objekte werden übersprungen.", "skip_title": "✂ Objekte überspringen", + "slot_copy_from": "Farbe von Slot kopieren…", "slot_edit_color": "Farbe", "slot_edit_custom": "z.B. PLA, PETG, ABS…", "slot_edit_load": "⬇ Einziehen", @@ -316,15 +323,15 @@ "store_download": "⬇ Download", "store_empty": "Noch keine Dateien hochgeladen.", "store_estimate": "Schätzung", + "store_exit_select": "Abbrechen", "store_never": "noch nicht gedruckt", "store_no_results": "Keine Dateien gefunden.", "store_print": "▶ Drucken", "store_print_confirm": "Datei drucken?", "store_refresh": "↻ Aktualisieren", + "store_search_placeholder": "🔍 Suche…", "store_select_all": "Alle auswählen", "store_selected_count": "{n} ausgewählt", - "store_exit_select": "Abbrechen", - "store_search_placeholder": "🔍 Suche…", "store_upload_busy": "⏳ Hochladen…", "store_upload_error": "✗ {error}", "store_upload_label_browse": "durchsuchen", @@ -344,6 +351,5 @@ "update_docker_copied": "Kopiert! Ausführen: docker compose pull && docker compose up -d", "update_error": "Fehler", "update_none": "Bereits aktuell", - "update_restarting": "Starte neu...", - "slot_copy_from": "Farbe von Slot kopieren…" -} \ No newline at end of file + "update_restarting": "Starte neu..." +} diff --git a/web/translations/en.json b/web/translations/en.json index 67c699d..c1aa333 100644 --- a/web/translations/en.json +++ b/web/translations/en.json @@ -42,6 +42,8 @@ "apd_placeholder_name": "e.g. Kobra X Living Room", "apd_success": "Printer added, bridge restarting…", "apd_title": "Add printer", + "browser_tab_printer": "On Printer", + "browser_tab_uploaded": "Uploaded", "btn_cam_start": "▶ Camera", "btn_cam_start2": "▶ Start", "btn_cam_stop": "◼ Camera", @@ -68,6 +70,18 @@ "card_speed": "Print Speed", "card_temps": "Temperatures", "confirm_cancel": "Really cancel the print?", + "dash_done": "Done", + "dash_edit": "Customize dashboard", + "dash_hidden_cards": "Hidden cards", + "dash_hide": "Hide", + "dash_preset_delete_confirm": "Delete preset \"{name}\"?", + "dash_preset_name_prompt": "Preset name:", + "dash_preset_standard": "Standard", + "dash_preset_wide89": "Wide desktop", + "dash_reset": "Reset", + "dash_save_preset": "Save as preset", + "dash_show": "Show", + "dash_toggle_width": "Toggle width", "fd_cancel": "Cancel", "fd_no_matching_material": "No matching material", "fd_no_slots_msg": "No loaded AMS slots.{br}Start print anyway?", @@ -200,6 +214,10 @@ "panel_temps_chart": "History (last 60 readings)", "panel_temps_nozzle": "Nozzle", "print_auto_leveling": "Auto-Leveling", + "printer_store_delete_confirm": "Delete file from the printer?", + "printer_store_delete_selected_confirm": "Delete {n} selected files from the printer?", + "printer_store_empty": "No files on the printer.", + "printer_store_unreachable": "Printer unreachable or query failed.", "printers_active": "● active", "printers_current": "Current printer", "printers_empty_hint": "No printer set up yet.", @@ -213,7 +231,6 @@ "progress_action_slots": "Map slots", "settings_auto_leveling": "Auto-Leveling Default", "settings_auto_leveling_label": "Auto-Leveling before print", - "settings_vibration_compensation": "Resonance Compensation", "settings_btn_tooltip": "Settings", "settings_camera_on_print": "Turn camera on at print start", "settings_cat_connection": "Connection", @@ -247,19 +264,6 @@ "settings_password": "MQTT Password", "settings_poll": "Poll Interval (seconds)", "settings_poll_interval_hint": "How often the bridge queries printer status", - "settings_verbose_http_log": "Log every HTTP request (verbose)", - "dash_edit": "Customize dashboard", - "dash_done": "Done", - "dash_reset": "Reset", - "dash_preset_standard": "Standard", - "dash_preset_wide89": "Wide desktop", - "dash_hidden_cards": "Hidden cards", - "dash_show": "Show", - "dash_hide": "Hide", - "dash_toggle_width": "Toggle width", - "dash_save_preset": "Save as preset", - "dash_preset_name_prompt": "Preset name:", - "dash_preset_delete_confirm": "Delete preset \"{name}\"?", "settings_poll_interval_label": "Poll Interval (seconds)", "settings_print": "Print Settings", "settings_printer_ip": "Printer IP", @@ -271,7 +275,9 @@ "settings_title": "Settings", "settings_username": "MQTT Username", "settings_vendor_filter_placeholder": "Search vendors…", + "settings_verbose_http_log": "Log every HTTP request (verbose)", "settings_version": "Version", + "settings_vibration_compensation": "Resonance Compensation", "settings_visible_vendors": "Visible vendors (profile dropdown)", "settings_visible_vendors_hint": "Only these vendors appear in the slot profile dropdown. Nothing selected = show all. \"Generic\" and your own profiles are always visible.", "settings_visible_vendors_label": "Visible vendors (profile dropdown)", @@ -293,6 +299,7 @@ "skip_sending": "Sending …", "skip_success": "Objects will be skipped.", "skip_title": "✂ Skip objects", + "slot_copy_from": "Copy color from slot…", "slot_edit_color": "Color", "slot_edit_custom": "e.g. PLA, PETG, ABS…", "slot_edit_load": "⬇ Load", @@ -316,15 +323,15 @@ "store_download": "⬇ Download", "store_empty": "No files uploaded yet.", "store_estimate": "Estimate", + "store_exit_select": "Cancel", "store_never": "never printed", "store_no_results": "No files found.", "store_print": "▶ Print", "store_print_confirm": "Print file?", "store_refresh": "↻ Refresh", + "store_search_placeholder": "🔍 Search…", "store_select_all": "Select All", "store_selected_count": "{n} selected", - "store_exit_select": "Cancel", - "store_search_placeholder": "🔍 Search…", "store_upload_busy": "⏳ Uploading…", "store_upload_error": "✗ {error}", "store_upload_label_browse": "browse", @@ -344,6 +351,5 @@ "update_docker_copied": "Copied! Run: docker compose pull && docker compose up -d", "update_error": "Error", "update_none": "Already up to date", - "update_restarting": "Restarting...", - "slot_copy_from": "Copy color from slot…" -} \ No newline at end of file + "update_restarting": "Restarting..." +} diff --git a/web/translations/es.json b/web/translations/es.json index df5a2d5..6725e6a 100644 --- a/web/translations/es.json +++ b/web/translations/es.json @@ -42,6 +42,8 @@ "apd_placeholder_name": "p. ej. Kobra X Sala", "apd_success": "Impresora añadida, reiniciando bridge…", "apd_title": "Agregar impresora", + "browser_tab_printer": "En la impresora", + "browser_tab_uploaded": "Subidos", "btn_cam_start": "▶ Cámara", "btn_cam_start2": "▶ Iniciar", "btn_cam_stop": "◼ Cámara", @@ -68,6 +70,18 @@ "card_speed": "Velocidad de impresión", "card_temps": "Temperaturas", "confirm_cancel": "¿Realmente cancelar la impresión?", + "dash_done": "Listo", + "dash_edit": "Personalizar panel", + "dash_hidden_cards": "Tarjetas ocultas", + "dash_hide": "Ocultar", + "dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?", + "dash_preset_name_prompt": "Nombre del preset:", + "dash_preset_standard": "Estándar", + "dash_preset_wide89": "Escritorio ancho", + "dash_reset": "Restablecer", + "dash_save_preset": "Guardar como preset", + "dash_show": "Mostrar", + "dash_toggle_width": "Cambiar ancho", "fd_cancel": "Cancelar", "fd_no_matching_material": "No hay material compatible", "fd_no_slots_msg": "No hay slots AMS cargados.{br}¿Iniciar impresión de todos modos?", @@ -200,6 +214,10 @@ "panel_temps_chart": "Historial (últimas 60 lecturas)", "panel_temps_nozzle": "Boquilla", "print_auto_leveling": "Autonivelado para esta impresión", + "printer_store_delete_confirm": "¿Eliminar archivo de la impresora?", + "printer_store_delete_selected_confirm": "¿Eliminar {n} archivos seleccionados de la impresora?", + "printer_store_empty": "No hay archivos en la impresora.", + "printer_store_unreachable": "Impresora inaccesible o consulta fallida.", "printers_active": "● activa", "printers_current": "Impresora actual", "printers_empty_hint": "Aún no hay impresora configurada.", @@ -213,7 +231,6 @@ "progress_action_slots": "Asignar ranuras", "settings_auto_leveling": "Autonivelado antes de imprimir", "settings_auto_leveling_label": "Autonivelado antes de imprimir", - "settings_vibration_compensation": "Compensación de resonancia", "settings_btn_tooltip": "Ajustes", "settings_camera_on_print": "Encender cámara al iniciar impresión", "settings_cat_connection": "Conexión", @@ -247,19 +264,6 @@ "settings_password": "Contraseña MQTT", "settings_poll": "Intervalo de sondeo (segundos)", "settings_poll_interval_hint": "Con qué frecuencia el bridge consulta el estado de la impresora", - "settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)", - "dash_edit": "Personalizar panel", - "dash_done": "Listo", - "dash_reset": "Restablecer", - "dash_preset_standard": "Estándar", - "dash_preset_wide89": "Escritorio ancho", - "dash_hidden_cards": "Tarjetas ocultas", - "dash_show": "Mostrar", - "dash_hide": "Ocultar", - "dash_toggle_width": "Cambiar ancho", - "dash_save_preset": "Guardar como preset", - "dash_preset_name_prompt": "Nombre del preset:", - "dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?", "settings_poll_interval_label": "Intervalo de sondeo (segundos)", "settings_print": "Ajustes de impresión", "settings_printer_ip": "IP de impresora", @@ -271,7 +275,9 @@ "settings_title": "Configuración", "settings_username": "Usuario MQTT", "settings_vendor_filter_placeholder": "Buscar fabricantes…", + "settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)", "settings_version": "Versión", + "settings_vibration_compensation": "Compensación de resonancia", "settings_visible_vendors": "Fabricantes visibles (lista de perfiles)", "settings_visible_vendors_hint": "Solo estos fabricantes aparecen en la lista de perfiles de ranura. Nada seleccionado = mostrar todos. «Generic» y tus propios perfiles siempre son visibles.", "settings_visible_vendors_label": "Fabricantes visibles (lista de perfiles)", @@ -293,6 +299,7 @@ "skip_sending": "Enviando …", "skip_success": "Se omitirán los objetos.", "skip_title": "✂ Omitir objetos", + "slot_copy_from": "Copiar color del slot…", "slot_edit_color": "Color", "slot_edit_custom": "p. ej. PLA, PETG, ABS…", "slot_edit_load": "⬇ Cargar", @@ -316,15 +323,15 @@ "store_download": "⬇ Descargar", "store_empty": "Aún no hay archivos subidos.", "store_estimate": "Estimación", + "store_exit_select": "Cancelar", "store_never": "nunca impreso", "store_no_results": "No se encontraron archivos.", "store_print": "▶ Imprimir", "store_print_confirm": "¿Imprimir archivo?", "store_refresh": "↻ Actualizar", + "store_search_placeholder": "🔍 Buscar…", "store_select_all": "Seleccionar todo", "store_selected_count": "{n} seleccionados", - "store_exit_select": "Cancelar", - "store_search_placeholder": "🔍 Buscar…", "store_upload_busy": "⏳ Subiendo…", "store_upload_error": "✗ {error}", "store_upload_label_browse": "buscar", @@ -344,6 +351,5 @@ "update_docker_copied": "Copiado. Ejecutar: docker compose pull && docker compose up -d", "update_error": "Error", "update_none": "Ya actualizado", - "update_restarting": "Reiniciando...", - "slot_copy_from": "Copiar color del slot…" -} \ No newline at end of file + "update_restarting": "Reiniciando..." +} diff --git a/web/translations/fr.json b/web/translations/fr.json index 526917f..28a3605 100644 --- a/web/translations/fr.json +++ b/web/translations/fr.json @@ -42,6 +42,8 @@ "apd_placeholder_name": "ex. Kobra X Salon", "apd_success": "Imprimante ajoutée, redémarrage du bridge…", "apd_title": "Ajouter une imprimante", + "browser_tab_printer": "Sur l'imprimante", + "browser_tab_uploaded": "Téléversés", "btn_cam_start": "▶ Caméra", "btn_cam_start2": "▶ Démarrer", "btn_cam_stop": "◼ Caméra", @@ -200,6 +202,10 @@ "panel_temps_chart": "Historique (60 dernières valeurs)", "panel_temps_nozzle": "Buse", "print_auto_leveling": "Mise à niveau auto pour cette impression", + "printer_store_delete_confirm": "Supprimer le fichier de l'imprimante ?", + "printer_store_delete_selected_confirm": "Supprimer {n} fichiers sélectionnés de l'imprimante ?", + "printer_store_empty": "Aucun fichier sur l'imprimante.", + "printer_store_unreachable": "Imprimante injoignable ou requête échouée.", "printers_active": "● actif", "printers_current": "Imprimante actuelle", "printers_empty_hint": "Aucune imprimante configurée.", @@ -279,6 +285,7 @@ "skip_sending": "Envoi …", "skip_success": "Les objets seront ignorés.", "skip_title": "✂ Ignorer des objets", + "slot_copy_from": "Copier la couleur du slot…", "slot_edit_color": "Couleur", "slot_edit_custom": "ex. PLA, PETG, ABS…", "slot_edit_load": "⬇ Charger", @@ -302,15 +309,15 @@ "store_download": "⬇ Télécharger", "store_empty": "Aucun fichier uploadé.", "store_estimate": "Estimation", + "store_exit_select": "Annuler", "store_never": "jamais imprimé", "store_no_results": "Aucun fichier trouvé.", "store_print": "▶ Imprimer", "store_print_confirm": "Imprimer le fichier ?", "store_refresh": "↻ Actualiser", + "store_search_placeholder": "🔍 Rechercher…", "store_select_all": "Tout sélectionner", "store_selected_count": "{n} sélectionné(s)", - "store_exit_select": "Annuler", - "store_search_placeholder": "🔍 Rechercher…", "store_upload_busy": "⏳ Envoi en cours…", "store_upload_error": "✗ {error}", "store_upload_label_browse": "parcourir", @@ -330,6 +337,5 @@ "update_docker_copied": "Copié ! Exécuter : docker compose pull && docker compose up -d", "update_error": "Erreur", "update_none": "Déjà à jour", - "update_restarting": "Redémarrage…", - "slot_copy_from": "Copier la couleur du slot…" -} \ No newline at end of file + "update_restarting": "Redémarrage…" +} diff --git a/web/translations/it.json b/web/translations/it.json index ab170b3..2478eb8 100644 --- a/web/translations/it.json +++ b/web/translations/it.json @@ -42,6 +42,8 @@ "apd_placeholder_name": "es. Kobra X Soggiorno", "apd_success": "Stampante aggiunta, riavvio del bridge in corso…", "apd_title": "Aggiungi stampante", + "browser_tab_printer": "Sulla stampante", + "browser_tab_uploaded": "Caricati", "btn_cam_start": "▶ Camera", "btn_cam_start2": "▶ Avvia", "btn_cam_stop": "◼ Camera", @@ -200,6 +202,10 @@ "panel_temps_chart": "Cronologia (ultime 60 letture)", "panel_temps_nozzle": "Ugello", "print_auto_leveling": "Livellamento automatico", + "printer_store_delete_confirm": "Eliminare il file dalla stampante?", + "printer_store_delete_selected_confirm": "Eliminare {n} file selezionati dalla stampante?", + "printer_store_empty": "Nessun file sulla stampante.", + "printer_store_unreachable": "Stampante non raggiungibile o richiesta fallita.", "printers_active": "● attiva", "printers_current": "Stampante corrente", "printers_empty_hint": "Nessuna stampante ancora configurata.", @@ -279,6 +285,7 @@ "skip_sending": "Invio in corso …", "skip_success": "Gli oggetti verranno saltati.", "skip_title": "✂ Salta oggetti", + "slot_copy_from": "Copia colore dallo slot…", "slot_edit_color": "Colore", "slot_edit_custom": "es. PLA, PETG, ABS…", "slot_edit_load": "⬇ Carica", @@ -302,15 +309,15 @@ "store_download": "⬇ Scarica", "store_empty": "Nessun file caricato.", "store_estimate": "Stima", + "store_exit_select": "Annulla", "store_never": "mai stampato", "store_no_results": "Nessun file trovato.", "store_print": "▶ Stampa", "store_print_confirm": "Stampare il file?", "store_refresh": "↻ Aggiorna", + "store_search_placeholder": "🔍 Cerca…", "store_select_all": "Seleziona tutto", "store_selected_count": "{n} selezionati", - "store_exit_select": "Annulla", - "store_search_placeholder": "🔍 Cerca…", "store_upload_busy": "⏳ Caricamento in corso…", "store_upload_error": "✗ {error}", "store_upload_label_browse": "sfoglia", @@ -330,6 +337,5 @@ "update_docker_copied": "Copiato! Eseguire: docker compose pull && docker compose up -d", "update_error": "Errore", "update_none": "Già aggiornato", - "update_restarting": "Riavvio in corso...", - "slot_copy_from": "Copia colore dallo slot…" -} \ No newline at end of file + "update_restarting": "Riavvio in corso..." +} diff --git a/web/translations/zh-cn.json b/web/translations/zh-cn.json index 9a7cf27..d460fc0 100644 --- a/web/translations/zh-cn.json +++ b/web/translations/zh-cn.json @@ -42,6 +42,8 @@ "apd_placeholder_name": "例如 Kobra X 客厅", "apd_success": "打印机已添加,Bridge 正在重启…", "apd_title": "添加打印机", + "browser_tab_printer": "打印机上", + "browser_tab_uploaded": "已上传", "btn_cam_start": "▶ 相机", "btn_cam_start2": "▶ 启动", "btn_cam_stop": "◼ 相机", @@ -68,6 +70,18 @@ "card_speed": "打印速度", "card_temps": "温度", "confirm_cancel": "确定要取消打印吗?", + "dash_done": "完成", + "dash_edit": "自定义仪表盘", + "dash_hidden_cards": "隐藏的卡片", + "dash_hide": "隐藏", + "dash_preset_delete_confirm": "删除预设 \"{name}\"?", + "dash_preset_name_prompt": "预设名称:", + "dash_preset_standard": "标准", + "dash_preset_wide89": "宽屏桌面", + "dash_reset": "重置", + "dash_save_preset": "另存为预设", + "dash_show": "显示", + "dash_toggle_width": "切换宽度", "fd_cancel": "取消", "fd_no_matching_material": "无匹配材料", "fd_no_slots_msg": "没有已装载的 AMS 槽位。{br}仍要开始打印吗?", @@ -200,6 +214,10 @@ "panel_temps_chart": "历史 (最近 60 次读数)", "panel_temps_nozzle": "喷嘴", "print_auto_leveling": "本次打印自动调平", + "printer_store_delete_confirm": "从打印机删除文件?", + "printer_store_delete_selected_confirm": "从打印机删除选中的 {n} 个文件?", + "printer_store_empty": "打印机上没有文件。", + "printer_store_unreachable": "无法连接打印机或查询失败。", "printers_active": "● 活动", "printers_current": "当前打印机", "printers_empty_hint": "尚未设置打印机。", @@ -213,7 +231,6 @@ "progress_action_slots": "分配槽位", "settings_auto_leveling": "打印前自动调平", "settings_auto_leveling_label": "打印前自动调平", - "settings_vibration_compensation": "打印前共振补偿", "settings_btn_tooltip": "设置", "settings_camera_on_print": "打印开始时开启相机", "settings_cat_connection": "连接", @@ -247,19 +264,6 @@ "settings_password": "MQTT 密码", "settings_poll": "轮询间隔(秒)", "settings_poll_interval_hint": "Bridge 查询打印机状态的频率", - "settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)", - "dash_edit": "自定义仪表盘", - "dash_done": "完成", - "dash_reset": "重置", - "dash_preset_standard": "标准", - "dash_preset_wide89": "宽屏桌面", - "dash_hidden_cards": "隐藏的卡片", - "dash_show": "显示", - "dash_hide": "隐藏", - "dash_toggle_width": "切换宽度", - "dash_save_preset": "另存为预设", - "dash_preset_name_prompt": "预设名称:", - "dash_preset_delete_confirm": "删除预设 \"{name}\"?", "settings_poll_interval_label": "轮询间隔(秒)", "settings_print": "打印设置", "settings_printer_ip": "打印机 IP", @@ -271,7 +275,9 @@ "settings_title": "设置", "settings_username": "MQTT 用户名", "settings_vendor_filter_placeholder": "搜索厂商…", + "settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)", "settings_version": "版本", + "settings_vibration_compensation": "打印前共振补偿", "settings_visible_vendors": "可见厂商(配置下拉框)", "settings_visible_vendors_hint": "仅这些厂商会出现在槽位配置下拉框中。未选择 = 显示全部。“Generic”和您自己的配置始终可见。", "settings_visible_vendors_label": "可见厂商(配置下拉框)", @@ -293,6 +299,7 @@ "skip_sending": "发送中 …", "skip_success": "对象将被跳过。", "skip_title": "✂ 跳过对象", + "slot_copy_from": "从插槽复制颜色…", "slot_edit_color": "颜色", "slot_edit_custom": "例如 PLA, PETG, ABS…", "slot_edit_load": "⬇ 进料", @@ -316,15 +323,15 @@ "store_download": "⬇ 下载", "store_empty": "尚未上传文件。", "store_estimate": "估算", + "store_exit_select": "取消", "store_never": "从未打印", "store_no_results": "未找到文件。", "store_print": "▶ 打印", "store_print_confirm": "打印文件?", "store_refresh": "↻ 刷新", + "store_search_placeholder": "🔍 搜索…", "store_select_all": "全选", "store_selected_count": "已选择 {n} 个", - "store_exit_select": "取消", - "store_search_placeholder": "🔍 搜索…", "store_upload_busy": "⏳ 上传中…", "store_upload_error": "✗ {error}", "store_upload_label_browse": "浏览", @@ -344,6 +351,5 @@ "update_docker_copied": "已复制!执行:docker compose pull && docker compose up -d", "update_error": "错误", "update_none": "已是最新版本", - "update_restarting": "重启中...", - "slot_copy_from": "从插槽复制颜色…" -} \ No newline at end of file + "update_restarting": "重启中..." +} From 9f76d286226b793c495d67af0b5f3cde46d7e6cb Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Mon, 27 Jul 2026 12:13:12 +0000 Subject: [PATCH 12/16] chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.28-nightly47 release --- NIGHTLY_CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index a7acaab..f1eff73 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,3 +1,2 @@ ## Changes in this build -- Feat: the GCode browser now has a second tab showing files stored directly on the printer's own internal storage (e.g. from prints started via Anycubic Slicer Next, bypassing the bridge) — list, multi-select, and delete them right from the Bridge UI instead of needing the printer's own display. From 1f1d60d571fe660bd4e6970fec2e436488436b98 Mon Sep 17 00:00:00 2001 From: viewit Date: Mon, 27 Jul 2026 14:25:50 +0200 Subject: [PATCH 13/16] feat(browser): show real GCode thumbnails in the "On Printer" tab The printer's file/fileDetails MQTT action extracts and base64-encodes the embedded "; thumbnail begin" block from a GCode file's header on demand and returns it inline as data.file_details.thumbnail - verified live against a real Kobra X (a valid 230x110 PNG came back for an existing print file). New endpoint GET /kx/printer-files/{filename}/thumbnail wraps this via the existing _wait_for_file_action() helper (same fire-and-forget + file/report-callback pattern as listLocal/deleteBatch). Results are cached in-memory per filename (self._printer_thumbnail_cache) - a file's thumbnail never changes while it exists on the printer, and the tab can list 100+ files at once. Frontend fetches thumbnails lazily through a small sequential queue (_printerThumbQueue/_pumpPrinterThumbQueue) after rendering the file list, one request at a time rather than firing 100+ concurrent MQTT roundtrips, and swaps each card's placeholder printer icon for the real once its thumbnail arrives. Client-side cache (_printerThumbCache) avoids re-fetching on re-render (e.g. after a selection change). Verified end-to-end against the real printer and visually via Playwright: all 10 listed files rendered their actual, distinct print preview thumbnails instead of the generic icon. --- NIGHTLY_CHANGELOG.md | 1 + kobrax_moonraker_bridge.py | 39 ++++++++++++ tests/test_printer_files_endpoint.py | 89 ++++++++++++++++++++++++++++ web/themes/default/app.js | 49 ++++++++++++++- 4 files changed, 176 insertions(+), 2 deletions(-) diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index f1eff73..7ceb2a1 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,2 +1,3 @@ ## Changes in this build +- Feat: the "On Printer" browser tab now shows each file's actual embedded GCode thumbnail instead of a generic printer icon — fetched lazily and cached, so switching to the tab doesn't need to query the printer 100+ times at once. diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 71c2923..db32088 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1058,6 +1058,10 @@ class KobraXBridge: # as the existing fileDetails fire-and-forget pattern. Format: # {action: {"event": threading.Event(), "result": dict|None}}. self._file_action_waiters: dict[str, dict] = {} + # Thumbnail cache for files on the printer's own storage (filename -> + # base64 PNG string, "" if the file has no embedded thumbnail). + # In-memory only - not persisted, cleared on restart. + self._printer_thumbnail_cache: dict[str, str] = {} self._store = store if store is not None else GCodeStore(args.data_dir) self._serve_dir_path: str = self._store._gcode_dir self._current_job_id: str = "" @@ -2870,6 +2874,40 @@ class KobraXBridge: return self._json_cors({"error": "delete failed", "detail": result}, status=502) return self._json_cors({"result": "ok"}) + async def handle_kx_printer_file_thumbnail(self, request): + """GET /kx/printer-files/{filename}/thumbnail - fetches the embedded + GCode thumbnail for a file on the printer's own storage, via + file/fileDetails. The printer extracts and base64-encodes the + "; thumbnail begin"-block from the GCode header on demand and + returns it inline in data.file_details.thumbnail - no separate + download/presigned-URL step needed (verified live against a real + Kobra X). Cached in-memory per filename since a file's thumbnail + never changes while it exists on the printer, and re-querying on + every render/scroll would mean one MQTT roundtrip per visible card.""" + filename = request.match_info.get("filename", "") + if not filename: + return self._json_cors({"error": "no filename given"}, status=400) + cached = self._printer_thumbnail_cache.get(filename) + if cached is not None: + return self._json_cors({"result": {"thumbnail": cached}}) + loop = asyncio.get_event_loop() + def _fetch(): + return self._wait_for_file_action( + "fileDetails", + lambda: self.client.publish( + "file", "fileDetails", + {"root": "local", "filename": filename}, + timeout=0, + ), + timeout=8.0, + ) + result = await loop.run_in_executor(None, _fetch) + if not result or result.get("code") != 200: + return self._json_cors({"error": "printer unreachable or query failed"}, status=502) + thumb = ((result.get("data") or {}).get("file_details") or {}).get("thumbnail") or "" + self._printer_thumbnail_cache[filename] = thumb + return self._json_cors({"result": {"thumbnail": thumb}}) + async def handle_kx_file_download(self, request): file_id = request.match_info["file_id"] f = self._store.get_file(file_id) @@ -5829,6 +5867,7 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_post("/kx/files/{file_id}/verify", bridge.handle_kx_file_verify) r.add_get("/kx/printer-files", bridge.handle_kx_printer_files) r.add_post("/kx/printer-files/delete", bridge.handle_kx_printer_file_delete) + r.add_get("/kx/printer-files/{filename}/thumbnail", bridge.handle_kx_printer_file_thumbnail) r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots) r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles) r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile) diff --git a/tests/test_printer_files_endpoint.py b/tests/test_printer_files_endpoint.py index d03070b..6550f89 100644 --- a/tests/test_printer_files_endpoint.py +++ b/tests/test_printer_files_endpoint.py @@ -54,6 +54,40 @@ DELETEBATCH_FAILED = { "data": None, } +FILEDETAILS_SUCCESS = { + "action": "fileDetails", + "code": 200, + "state": "done", + "data": { + "file_details": { + "thumbnail": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB", + "png_image": "", + "svg_image": "", + "objects_skip_parts": [], + }, + "filename": "a.gcode", + "root": "local", + }, +} + +FILEDETAILS_NO_THUMBNAIL = { + "action": "fileDetails", + "code": 200, + "state": "done", + "data": { + "file_details": {"thumbnail": "", "png_image": "", "svg_image": "", "objects_skip_parts": []}, + "filename": "a.gcode", + "root": "local", + }, +} + +FILEDETAILS_FAILED = { + "action": "fileDetails", + "code": 10112, + "state": "failed", + "data": None, +} + def _deliver_async(bridge, payload, delay=0.05): """Simulates the MQTT reader thread delivering a file/report a moment @@ -142,3 +176,58 @@ async def test_printer_file_delete_returns_502_on_printer_rejection(client): bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_FAILED), None)[1] resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]}) assert resp.status == 502 + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_success(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1] + resp = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp.status == 200 + data = await resp.json() + assert data["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_uses_correct_mqtt_payload(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1] + await c.get("/kx/printer-files/a.gcode/thumbnail") + args, kwargs = bridge.client.publish.call_args + assert args[0] == "file" + assert args[1] == "fileDetails" + assert args[2] == {"root": "local", "filename": "a.gcode"} + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_empty_when_no_thumbnail_embedded(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_NO_THUMBNAIL), None)[1] + resp = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp.status == 200 + data = await resp.json() + assert data["result"]["thumbnail"] == "" + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_returns_502_on_printer_failure(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_FAILED), None)[1] + resp = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp.status == 502 + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_is_cached_after_first_fetch(client): + """Second request for the same filename must not call publish() again.""" + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1] + resp1 = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp1.status == 200 + call_count_after_first = bridge.client.publish.call_count + + resp2 = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp2.status == 200 + data2 = await resp2.json() + assert data2["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" + assert bridge.client.publish.call_count == call_count_after_first # no new MQTT call diff --git a/web/themes/default/app.js b/web/themes/default/app.js index ae98222..aa8d591 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -3045,7 +3045,7 @@ function renderPrinterFiles(){ return; } empty.style.display='none'; - grid.innerHTML=printerFiles.map(function(f){ + grid.innerHTML=printerFiles.map(function(f,idx){ var name=f.filename.length>28?f.filename.slice(0,25)+'…':f.filename; var sizeKb=f.size?(f.size/1024).toFixed(0)+' KB':'–'; var date=f.timestamp?new Date(f.timestamp).toISOString().replace('T',' ').slice(0,16):''; @@ -3059,9 +3059,14 @@ function renderPrinterFiles(){ 'style="width:16px;height:16px;margin:0">'; var cardClick=_printerFilesSelectMode?'onclick="printerFileToggleSelect(\''+f.filename.replace(/'/g,"\\'")+'\')" style="cursor:pointer;position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"': 'style="position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"'; + var thumbId='pft-'+idx; + var cachedThumb=_printerThumbCache[f.filename]; + var thumbHtml=cachedThumb + ? '' + : '
🖨
'; return '
'+ checkbox+ - '
🖨
'+ + thumbHtml+ '
'+name+'
'+ '
💾 '+sizeKb+'
'+ '
📅 '+date+'
'+ @@ -3072,6 +3077,46 @@ function renderPrinterFiles(){ '
'; }).join(''); _printerFilesUpdateSelectBar(); + _loadVisiblePrinterThumbnails(); +} + +// Thumbnails are fetched lazily, one at a time, only for cards not already +// cached - fetching all of them upfront would mean one MQTT roundtrip per +// file (145+ files is common), overwhelming the single MQTT connection. +var _printerThumbCache={}; // filename -> base64 PNG string ("" = no thumbnail) +var _printerThumbQueue=[]; +var _printerThumbLoading=false; + +function _loadVisiblePrinterThumbnails(){ + var placeholders=document.querySelectorAll('#printer-store-grid [data-filename]'); + _printerThumbQueue=Array.prototype.slice.call(placeholders); + _pumpPrinterThumbQueue(); +} + +function _pumpPrinterThumbQueue(){ + if(_printerThumbLoading||!_printerThumbQueue.length)return; + var el=_printerThumbQueue.shift(); + if(!el||!el.isConnected){_pumpPrinterThumbQueue();return;} + var encodedName=el.getAttribute('data-filename'); + _printerThumbLoading=true; + fetch(_apiUrl('/kx/printer-files/'+encodedName+'/thumbnail')) + .then(function(r){return r.json()}) + .then(function(d){ + var thumb=(d.result&&d.result.thumbnail)||''; + _printerThumbCache[decodeURIComponent(encodedName)]=thumb; + if(thumb&&el.isConnected){ + var img=document.createElement('img'); + img.src='data:image/png;base64,'+thumb; + img.style.cssText='width:100%;height:60px;object-fit:contain;border-radius:6px;background:var(--raised);margin-bottom:8px'; + img.id=el.id; + el.replaceWith(img); + } + }) + .catch(function(){/* keep the placeholder icon on failure */}) + .finally(function(){ + _printerThumbLoading=false; + _pumpPrinterThumbQueue(); + }); } function _printerFilesUpdateSelectBar(){ From c4f321c9b0b05db172708143ef362b8c54efebb8 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Mon, 27 Jul 2026 12:33:09 +0000 Subject: [PATCH 14/16] chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.28-nightly48 release --- NIGHTLY_CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 7ceb2a1..f1eff73 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,3 +1,2 @@ ## Changes in this build -- Feat: the "On Printer" browser tab now shows each file's actual embedded GCode thumbnail instead of a generic printer icon — fetched lazily and cached, so switching to the tab doesn't need to query the printer 100+ times at once. From 71664e7e8ba48dd5c24963f7d4786dc400e4dc31 Mon Sep 17 00:00:00 2001 From: viewit Date: Mon, 27 Jul 2026 20:18:44 +0200 Subject: [PATCH 15/16] docs: translate docker-compose-KX.yml comments to English --- docker-compose-KX.yml | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docker-compose-KX.yml b/docker-compose-KX.yml index 12752d0..6423220 100644 --- a/docker-compose-KX.yml +++ b/docker-compose-KX.yml @@ -1,8 +1,8 @@ # KobraX Full Stack — KX-Bridge + Obico Self-Hosted + Spoolman # -# Für Portainer: Stack → Add Stack → Upload → diese Datei wählen +# For Portainer: Stack → Add Stack → Upload → select this file # -# Voraussetzung: Obico-Images einmalig in Gitea-Registry pushen: +# Prerequisite: push the Obico images to the Gitea registry once: # docker tag obico-server-web:latest gitea.it-drui.de/viewit/obico-web:latest # docker tag obico-server-ml_api:latest gitea.it-drui.de/viewit/obico-ml:latest # docker tag obico-server-tasks:latest gitea.it-drui.de/viewit/obico-tasks:latest @@ -10,14 +10,14 @@ # docker push gitea.it-drui.de/viewit/obico-ml:latest # docker push gitea.it-drui.de/viewit/obico-tasks:latest # -# Persistente Daten: /mnt/dockerdata/KobraXStack// +# Persistent data: /mnt/dockerdata/KobraXStack// # # Ports: -# 7125 — KX-Bridge (Moonraker-API) -# 3334 — Obico (Web-UI) -# 7912 — Spoolman (Web-UI) +# 7125 — KX-Bridge (Moonraker API) +# 3334 — Obico (Web UI) +# 7912 — Spoolman (Web UI) # -# Obico Admin-Account nach dem ersten Start: +# Obico admin account after first start: # docker exec obico-web python manage.py createsuperuser x-obico-base: &obico-base @@ -162,12 +162,12 @@ services: max-size: "10m" max-file: "3" - # ── moonraker-obico Plugin ────────────────────────────────── - # Verbindet KX-Bridge mit dem Obico-Server (Spaghetti-Detektion, Remote-UI) - # Voraussetzung: /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg - # muss existieren und einen gültigen auth_token enthalten. + # ── moonraker-obico plugin ────────────────────────────────── + # Connects KX-Bridge to the Obico server (spaghetti detection, remote UI) + # Prerequisite: /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg + # must exist and contain a valid auth_token. # - # Token holen (nach erstem obico-web Start): + # Getting a token (after the first obico-web start): # docker exec obico-web python manage.py shell -c " # from app.models import OneTimeVerificationCode, User # from django.utils import timezone; from datetime import timedelta; import random @@ -175,7 +175,7 @@ services: # c = OneTimeVerificationCode.objects.create(user=u, code='%06d' % random.randint(100000,999999), expired_at=timezone.now()+timedelta(hours=2)) # print('CODE:', c.code)" # curl -X POST 'http://localhost:3334/api/v1/octo/verify/?code=' - # → printer.auth_token aus der Antwort in die cfg eintragen + # → enter printer.auth_token from the response into the cfg moonraker-obico: image: gitea.it-drui.de/viewit/moonraker-obico:latest container_name: moonraker-obico @@ -195,7 +195,7 @@ networks: kobrax-stack: driver: bridge -# Verzeichnisse müssen auf dem Host existieren: +# Directories must exist on the host: # mkdir -p /mnt/dockerdata/KobraXStack/kx-bridge/config \ # /mnt/dockerdata/KobraXStack/kx-bridge/data \ # /mnt/dockerdata/KobraXStack/spoolman \ @@ -203,8 +203,8 @@ networks: # /mnt/dockerdata/KobraXStack/obico/frontend \ # /mnt/dockerdata/KobraXStack/obico/redis \ # /mnt/dockerdata/KobraXStack/moonraker-obico/logs -# Spoolman benötigt UID/GID 1000: +# Spoolman requires UID/GID 1000: # sudo chown -R 1000:1000 /mnt/dockerdata/KobraXStack/spoolman # -# moonraker-obico Config anlegen (auth_token nach Obico-Setup eintragen): +# Create the moonraker-obico config (enter auth_token after Obico setup): # cp /path/to/moonraker-obico.cfg.example /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg From 157517ffb444488c614781671e88f125a3adea84 Mon Sep 17 00:00:00 2001 From: viewit Date: Mon, 27 Jul 2026 20:31:32 +0200 Subject: [PATCH 16/16] docs: update README video link and feature list - Replace outdated YouTube tutorial link with the current video - Add recently shipped features: custom RFID vendor matching, Spoolman integration, multi-ACE support, on-printer GCode browser tab with thumbnails, free-form dashboard grid, automatic camera reconnect - Mention docker-compose-KX.yml (full stack: bridge + Spoolman + self-hosted Obico) as an option alongside the plain docker compose setup --- README.de.md | 17 +++++++++++++---- README.es.md | 17 +++++++++++++---- README.md | 17 +++++++++++++---- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/README.de.md b/README.de.md index d7b52bd..e7ae143 100644 --- a/README.de.md +++ b/README.de.md @@ -23,7 +23,7 @@ Feedback willkommen.   [![Downloads](https://img.shields.io/badge/Downloads-3.1k%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)   -[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM) +[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=E3sDigSeSdM) Gefällt dir KX-Bridge? Ein Kaffee auf Ko-fi hält das Projekt am Leben. ☕ @@ -41,12 +41,16 @@ Feedback willkommen. | 🖨️ | **Druckersteuerung** — Start, Pause, Resume, Abbruch, Temperaturen, Druckgeschwindigkeit | | 📊 | **Live-Status** — Temperatur, Fortschritt, Layer, Restzeit, Kamera-Stream | | 🎨 | **AMS / Multicolor** — Slots mit **Profil-Picker pro Slot** (eigene Marke aus OrcaSlicer-Profilen pro Slot zuweisen); Bridge schreibt Material und Farbe ans Drucker-Display zurück | +| 🏷️ | **Custom-RFID-Tag-Matching** — mit Drittanbieter-Tools (z.B. der „ACE RFID"-App) beschriebene Spulen werden automatisch nach Marke + Material gegen deine importierten OrcaSlicer-Profile gematcht, statt auf ein generisches Profil zurückzufallen | | 📦 | **Eigene OrcaSlicer-Profile importieren** — ZIP aus `~/.config/OrcaSlicer/user//filament/` in die Bridge ziehen; tauchen im Slot-Dropdown unter ★ Eigene Profile auf | +| 🧵 | **Spoolman-Integration** — Spulen einzelnen AMS-Slots zuweisen, Filament-Verbrauch wird automatisch beim Drucken erfasst und synchronisiert | +| 🔗 | **Multi-ACE-Unterstützung** — mehrere aneinandergekettete ACE-Einheiten, auch bei Druckern ohne Toolhead-Buffer | | 📷 | **Obico-Integration (experimentell)** — Time-Lapse und WebRTC-Livestream gegen einen selbst gehosteten [Obico-Server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico | -| 📐 | **H.264-Direkt-Stream + Z-Höhe** — sparsamer Kamera-Pfad für Obico, aktuelle Z aus der Layer-Höhe abgeleitet (Mm-Progress-Widget) | -| 🗂️ | **GCode-Browser** — hochgeladene Dateien mit Thumbnail, Druckhistorie, Suche & Filter | +| 📐 | **H.264-Direkt-Stream + Z-Höhe** — sparsamer Kamera-Pfad für Obico, aktuelle Z aus der Layer-Höhe abgeleitet (Mm-Progress-Widget); erholt sich automatisch nach einem Drucker-Reboot mit rotiertem Stream-Token, kein manueller Reset nötig | +| 🗂️ | **GCode-Browser** — zwei Tabs: hochgeladene Dateien (mit Thumbnail, Druckhistorie, Suche & Filter, Mehrfachauswahl + Sammel-Löschen) und Dateien direkt auf dem Drucker-Speicher (mit echten Thumbnails, Mehrfachauswahl + Löschen) | | 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown | | ➕ | **Drucker hinzufügen per Klick** — nur die IP eingeben, Zugangsdaten werden automatisch importiert | +| 🖱️ | **Frei anpassbares Dashboard** — Kacheln per Drag & Drop verschieben und in der Größe anpassen, als Preset speichern | | 🔁 | **Robuster MQTT-Reconnect** — Bridge überlebt nächtlichen Drucker-Reboot ohne manuellen Neustart | | 🌐 | **Mehrsprachiges UI** — DE / EN / ES / FR / IT / 中文, Browser-Sprache automatisch erkannt | | 🔄 | **Self-Update** — neue Versionen direkt im Browser installieren | @@ -68,6 +72,11 @@ LAN-Modus am Kobra X aktivieren: docker compose up -d ``` +> Zusätzlich Spoolman und einen kompletten selbst gehosteten Obico-Setup +> (Spaghetti-Erkennung, Live-Stream) neben der Bridge? [`docker-compose-KX.yml`](docker-compose-KX.yml) +> bündelt KX-Bridge + Spoolman + Obico (Web/ML/Tasks/Redis) + moonraker-obico +> in einem Netzwerk — Setup-Schritte in den Kommentaren am Dateianfang. + **Linux-Binary (kein Docker):** ```bash chmod +x kx-bridge && ./kx-bridge @@ -110,7 +119,7 @@ Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125` ## 📺 Video-Tutorial -[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM) +[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/E3sDigSeSdM/hqdefault.jpg)](https://www.youtube.com/watch?v=E3sDigSeSdM) --- diff --git a/README.es.md b/README.es.md index ba0d32f..21b202f 100644 --- a/README.es.md +++ b/README.es.md @@ -22,7 +22,7 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.   [![Downloads](https://img.shields.io/badge/Descargas-3.1k%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)   -[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM) +[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=E3sDigSeSdM) ¿Te gusta KX-Bridge? Un café en Ko-fi mantiene el proyecto vivo. ☕ @@ -42,12 +42,16 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback. | 🖨️ | **Control de impresora** — iniciar, pausar, reanudar, cancelar, temperaturas, velocidad de impresión | | 📊 | **Estado en tiempo real** — temperatura, progreso, capas, tiempo restante, transmisión de cámara | | 🎨 | **AMS / multicolor** — ranuras con **selector de perfil por ranura** (asigna tu propia marca de los perfiles de OrcaSlicer a cada ranura); el puente escribe material y color al display de la impresora | +| 🏷️ | **Coincidencia de etiquetas RFID personalizadas** — las bobinas etiquetadas con herramientas de terceros (p. ej. la app "ACE RFID") se emparejan automáticamente por marca + material con tus perfiles de OrcaSlicer importados, en lugar de caer en un perfil genérico | | 📦 | **Importa tus propios perfiles de OrcaSlicer** — arrastra un ZIP de `~/.config/OrcaSlicer/user//filament/` al puente; aparecen en el desplegable de la ranura bajo ★ Perfiles propios | +| 🧵 | **Integración con Spoolman** — asigna bobinas a las ranuras del AMS, el consumo de filamento se registra y sincroniza automáticamente al imprimir | +| 🔗 | **Soporte multi-ACE** — múltiples unidades ACE encadenadas, incluso en impresoras sin buffer en el cabezal | | 📷 | **Integración con Obico (experimental)** — Time-Lapse y stream en vivo WebRTC contra un [servidor Obico](https://github.com/TheSpaghettiDetective/obico-server) autoalojado vía moonraker-obico | -| 📐 | **Stream H.264 directo + altura Z** — ruta de cámara de bajo consumo de CPU para Obico, Z actual derivada de la altura de capa (widget de progreso) | -| 🗂️ | **Explorador de GCode** — archivos subidos con vistas previas, historial de impresión, búsqueda y filtros | +| 📐 | **Stream H.264 directo + altura Z** — ruta de cámara de bajo consumo de CPU para Obico, Z actual derivada de la altura de capa (widget de progreso); se recupera automáticamente tras un reinicio de la impresora que rota el token del stream, sin necesidad de reinicio manual | +| 🗂️ | **Explorador de GCode** — dos pestañas: archivos subidos (con vistas previas, historial de impresión, búsqueda y filtros, selección múltiple + borrado masivo) y archivos almacenados directamente en la memoria de la impresora (con vistas previas reales, selección múltiple + borrado) | | 🧩 | **Multi-impresora** — múltiples impresoras en **una** instancia del puente, cambia mediante un menú desplegable | | ➕ | **Añade una impresora con un clic** — solo introduce la IP, las credenciales se importan automáticamente | +| 🖱️ | **Panel de control libre** — arrastra y redimensiona las tarjetas del panel a tu gusto, guárdalo como preset | | 🔁 | **Reconexión MQTT robusta** — el puente sobrevive a reinicios nocturnos de la impresora sin reinicio manual | | 🌐 | **Interfaz multilingüe** — DE / EN / ES / FR / IT / 中文, detecta automáticamente el idioma del navegador | | 🔄 | **Actualización automática** — instala nuevas versiones directamente desde el navegador | @@ -69,6 +73,11 @@ Activa el modo LAN en la Kobra X: docker compose up -d ``` +> ¿Quieres Spoolman y un setup completo de Obico autoalojado (detección de +> espagueti, stream en vivo) junto al puente? [`docker-compose-KX.yml`](docker-compose-KX.yml) +> combina KX-Bridge + Spoolman + Obico (web/ML/tasks/redis) + moonraker-obico +> en una sola red — consulta los comentarios al inicio del archivo para los pasos de configuración. + **Binario Linux (sin Docker):** ```bash chmod +x kx-bridge && ./kx-bridge @@ -111,7 +120,7 @@ Impresora → Tipo de conexión **Moonraker** → Host: `http://IP-DEL-PUENTE:71 ## 📺 Vídeo tutorial -[![Configuración y uso de KX-Bridge](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM) +[![Configuración y uso de KX-Bridge](https://img.youtube.com/vi/E3sDigSeSdM/hqdefault.jpg)](https://www.youtube.com/watch?v=E3sDigSeSdM) --- diff --git a/README.md b/README.md index b7a9861..8f56b8d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ officially tested or supported. Feedback welcome.   [![Downloads](https://img.shields.io/badge/Downloads-3.1k%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)   -[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM) +[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=E3sDigSeSdM) Like KX-Bridge? A coffee on Ko-fi keeps the project alive. ☕ @@ -42,12 +42,16 @@ officially tested or supported. Feedback welcome. | 🖨️ | **Printer control** — start, pause, resume, cancel, temperatures, print speed | | 📊 | **Live status** — temperature, progress, layers, remaining time, camera stream | | 🎨 | **AMS / multicolor** — slots with per-slot **profile picker** (assign your own brand from OrcaSlicer profiles per slot); bridge writes material & colour back to the printer display | +| 🏷️ | **Custom RFID tag matching** — spools tagged with third-party tools (e.g. the "ACE RFID" app) auto-match vendor + material against your imported OrcaSlicer profiles instead of falling back to a generic default | | 📦 | **Import your own OrcaSlicer profiles** — drag a ZIP from `~/.config/OrcaSlicer/user//filament/` into the bridge; they show up in the slot dropdown under ★ Own profiles | +| 🧵 | **Spoolman integration** — assign spools to AMS slots, filament usage tracked and synced automatically as you print | +| 🔗 | **Multi-ACE support** — multiple daisy-chained ACE units, including printers without a toolhead buffer | | 📷 | **Obico integration (experimental)** — Time-Lapse and WebRTC live stream against a self-hosted [Obico server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico | -| 📐 | **Direct H.264 stream + Z-height** — low-CPU camera path for Obico, current Z derived from layer-height for the print-progress widget | -| 🗂️ | **GCode browser** — uploaded files with thumbnails, print history, search & filter | +| 📐 | **Direct H.264 stream + Z-height** — low-CPU camera path for Obico, current Z derived from layer-height for the print-progress widget; auto-recovers after a printer reboot rotates its stream token, no manual reset needed | +| 🗂️ | **GCode browser** — two tabs: files you've uploaded (with thumbnails, print history, search & filter, multi-select + bulk delete) and files stored directly on the printer's own storage (with real thumbnails, multi-select + delete) | | 🧩 | **Multi-printer** — multiple printers in **one** bridge instance, switch via dropdown | | ➕ | **Add a printer with one click** — just enter the IP, credentials are imported automatically | +| 🖱️ | **Free-form dashboard** — drag & resize the dashboard tiles into your own layout, save it as a preset | | 🔁 | **Robust MQTT reconnect** — bridge survives overnight printer reboots without manual restart | | 🌐 | **Multi-language UI** — DE / EN / ES / FR / IT / 中文, auto-detect browser locale | | 🔄 | **Self-update** — install new versions directly in the browser | @@ -69,6 +73,11 @@ Enable LAN mode on the Kobra X: docker compose up -d ``` +> Want Spoolman and a full self-hosted Obico setup (spaghetti detection, live stream) +> alongside the bridge? [`docker-compose-KX.yml`](docker-compose-KX.yml) bundles +> KX-Bridge + Spoolman + Obico (web/ML/tasks/redis) + moonraker-obico behind one +> network — see the file's header comments for setup steps. + **Linux binary (no Docker):** ```bash chmod +x kx-bridge && ./kx-bridge @@ -111,7 +120,7 @@ Printer → Connection type **Moonraker** → Host: `http://BRIDGE-IP:7125` ## 📺 Video Tutorial -[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM) +[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/E3sDigSeSdM/hqdefault.jpg)](https://www.youtube.com/watch?v=E3sDigSeSdM) ---