Browse Source

fix(spoolman): resolve -1 in ams_mapping to external spool (#1276)

  BambuStudio encodes virtual tray IDs (254/255) as -1 in the flat
  ams_mapping array — a convention already documented in
  bambu_mqtt.py:start_print(). The spoolman tracking helper was treating
  -1 as "unmapped, use position-based default", which mapped slot_id=1
  to AMS tray 0 and credited external-spool prints to whatever Spoolman
  spool happened to be linked to AMS slot 0. The reporter's TPU prints
  on an H2S were credited to a PLA spool for ~49g over 4 prints before
  being noticed (regression of #853).

  When slot_to_tray[slot_id-1] == -1 and ams_trays contains 254/255,
  return the external tray ID directly. Prefers 254 over 255 (matches
  single-nozzle tray_now reporting + the vir_slot id=255->254 remap in
  bambu_mqtt.py:864). Legacy fall-through preserved for callers that
  don't pass ams_trays.

  Root cause investigation and patch by @ojimpo.
maziggy 2 weeks ago
parent
commit
6fe00adb23

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 16 - 1
backend/app/services/spoolman_tracking.py

@@ -106,15 +106,30 @@ def _resolve_global_tray_id(slot_id: int, slot_to_tray: list | None, ams_trays:
     """Map a 1-based slot_id to a global_tray_id using optional custom mapping.
     """Map a 1-based slot_id to a global_tray_id using optional custom mapping.
 
 
     Custom mapping: slot_to_tray[slot_id - 1] is used when >= 0.
     Custom mapping: slot_to_tray[slot_id - 1] is used when >= 0.
+    A value of -1 in the custom mapping means the slicer routed this slot to
+    the external spool. BambuStudio converts virtual tray IDs (254/255) to -1
+    in the flat ams_mapping array before sending to the printer — see
+    start_print() in bambu_mqtt.py which documents this convention. We mirror
+    it here: when -1 is seen, look up the external spool's actual
+    global_tray_id (254/255) in ams_trays rather than falling through to the
+    position-based default (which would map slot_id=1 to the first AMS tray
+    and credit an unrelated spool — see #1276, regression of #853).
     Position-based default: uses sorted ams_trays keys so external spools (ID 254/255)
     Position-based default: uses sorted ams_trays keys so external spools (ID 254/255)
     naturally follow standard AMS trays, matching the slicer's slot numbering.
     naturally follow standard AMS trays, matching the slicer's slot numbering.
-    A value of -1 in the custom mapping means unmapped (uses position-based default).
     Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools).
     Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools).
     """
     """
     if slot_to_tray and slot_id <= len(slot_to_tray):
     if slot_to_tray and slot_id <= len(slot_to_tray):
         mapped_tray = slot_to_tray[slot_id - 1]
         mapped_tray = slot_to_tray[slot_id - 1]
         if mapped_tray >= 0:
         if mapped_tray >= 0:
             return mapped_tray
             return mapped_tray
+        if mapped_tray == -1 and ams_trays:
+            # -1 means external spool. 254 = VIRTUAL_TRAY_DEPUTY_ID (main on
+            # single-nozzle, left/deputy on H2D dual-nozzle); 255 =
+            # VIRTUAL_TRAY_MAIN_ID. Prefer 254 when both exist since that's
+            # what single-nozzle printers report via tray_now.
+            for ext_id in (254, 255):
+                if ext_id in ams_trays:
+                    return ext_id
     # Position-based default: sort available tray IDs so external spools (254/255)
     # Position-based default: sort available tray IDs so external spools (254/255)
     # come after standard AMS trays, matching the slicer's slot assignment order.
     # come after standard AMS trays, matching the slicer's slot assignment order.
     if ams_trays:
     if ams_trays:

+ 29 - 0
backend/tests/unit/services/test_spoolman_tracking.py

@@ -91,6 +91,35 @@ class TestResolveGlobalTrayId:
         mapping = []
         mapping = []
         assert _resolve_global_tray_id(1, mapping) == 0
         assert _resolve_global_tray_id(1, mapping) == 0
 
 
+    def test_minus_one_resolves_to_external_spool_when_present(self):
+        """#1276 (regression of #853): -1 in slot_to_tray is BambuStudio's
+        encoding for "external spool used" — look up the external spool in
+        ams_trays rather than falling through to the position-based default
+        (which would credit an unrelated AMS tray). Reporter ojimpo's H2S
+        had AMS slot 0 occupied with PLA and ran a TPU external-spool print;
+        the bug credited the TPU usage to the PLA spool.
+        """
+        # Single external spool (most common: H2S/X1C/P1S + external)
+        assert _resolve_global_tray_id(1, [-1], ams_trays={254: {}}) == 254
+        # AMS occupied with material AND external in use — fix prevents
+        # crediting AMS slot 0 (the actual bug from #1276)
+        assert _resolve_global_tray_id(1, [-1], ams_trays={0: {}, 1: {}, 2: {}, 3: {}, 254: {}}) == 254
+        # H2D-style deputy nozzle at 255
+        assert _resolve_global_tray_id(1, [-1], ams_trays={0: {}, 255: {}}) == 255
+        # Both external slots present (multi-nozzle) — prefer 254 (main on
+        # single-nozzle, deputy on H2D — matches tray_now reporting)
+        assert _resolve_global_tray_id(1, [-1], ams_trays={254: {}, 255: {}}) == 254
+
+    def test_minus_one_falls_through_when_no_external_in_ams_trays(self):
+        """If -1 is seen but ams_trays has no external spool (254/255),
+        fall through to position-based default (legacy behavior preserved
+        for callers that don't pass ams_trays or pre-fix call sites).
+        """
+        # ams_trays without external — fall through to legacy behavior
+        assert _resolve_global_tray_id(1, [-1], ams_trays={0: {}, 1: {}}) == 0
+        # No ams_trays passed at all — legacy fallback
+        assert _resolve_global_tray_id(1, [-1]) == 0
+
 
 
 class TestFallbackTagHelpers:
 class TestFallbackTagHelpers:
     """Tests for frontend-mirrored fallback tag helpers."""
     """Tests for frontend-mirrored fallback tag helpers."""

Some files were not shown because too many files changed in this diff