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]