Explorar o código

fix(scheduler): force color match now distinguishes PLA variants (#2650)

Force color match dispatched onto the wrong PLA sub-variant: a job sliced
for White PLA Matte was treated as an exact match by printers loaded with
White PLA Basic or Silk+, because Bambu reports every variant as
tray_type "PLA" and the distinction lives only in tray_info_idx
(GFA00=Basic, GFA01=Matte, GFA06=Silk).

Two places dropped the field: the VP queue built each force override
without the parsed tray_info_idx, and _get_missing_force_color_slots
compared loaded trays on (type, colour) only.

Carry tray_info_idx into the override and require it to match when both
the override and a candidate tray have one; a blank idx on either side
(custom/third-party spools, older 3MFs) falls back to the historical
type+colour behaviour, so those setups are unaffected.
maziggy hai 1 mes
pai
achega
0f203ce7ca

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [1.2.6b1] - Unreleased
 
+### Fixed
+- **Force color match dispatched a print onto the wrong PLA variant — Matte jobs went to Basic and Silk printers alike (#2650, reporter @MartinNYHC)** — With **Force color match** on, a job sliced for **White PLA Matte** was dispatched to every printer that had *any* white PLA loaded — the ones holding White PLA **Basic** and White PLA **Silk+** included — so a matte model came out glossy on the wrong machine. **Root cause.** Bambu's MQTT status reports every PLA sub-variant as `tray_type == "PLA"`; the Basic/Matte/Silk distinction is carried only in `tray_info_idx` (`GFA00` = Basic, `GFA01` = Matte, `GFA06` = Silk, …), which the 3MF's `slice_info.config` also records per filament. Two places dropped it: the Virtual-Printer queue built each force override as `{slot_id, type, color, force_color_match}` without the parsed `tray_info_idx`, and the scheduler's eligibility check (`_get_missing_force_color_slots`) compared loaded trays on `(type, colour)` only — so `(PLA, #FFFFFF)` matched Basic, Matte and Silk indiscriminately and all three printers looked eligible. **Fix.** The force override now carries the 3MF's `tray_info_idx`, and a slot counts as satisfied only when a loaded tray matches type **and** colour **and** the variant — identical `tray_info_idx`, *or* either side lacks one. A blank idx on either side (custom/third-party spools report none, and older 3MFs carry none) falls back to the historical type+colour behaviour, so those setups are unaffected. A job sliced for GFA01 now goes only to a printer with GFA01 loaded; Basic/Silk report a mismatch. Covered by scheduler tests (Matte requirement rejects Basic/Silk, accepts Matte, blank loaded idx falls back, requirement without an idx is unchanged) and a Virtual-Printer test asserting the override carries `tray_info_idx`.
+
 
 ## [1.2.5] - 2026-07-24
 

+ 21 - 7
backend/app/services/print_scheduler.py

@@ -1158,6 +1158,14 @@ class PrintScheduler:
         to carry ``force_color_match: True``.  The printer must have **every** such slot loaded
         with an exact type+color match.
 
+        When both the override and a candidate tray carry a ``tray_info_idx``, they must also
+        match on it: Bambu reports every PLA variant as ``tray_type == "PLA"``, so the
+        Basic/Matte/Silk distinction lives only in ``tray_info_idx`` (GFA00/GFA01/GFA06/...).
+        Without this, a job sliced for PLA Matte matched every white PLA regardless of variant
+        (#2650). If either side lacks an idx (custom/third-party spools report a blank one, and
+        older 3MFs carry none) we fall back to the historical type+colour behaviour so those
+        setups are unaffected.
+
         Returns:
             List of ``"TYPE (color)"`` strings for unmatched slots (empty list means all match).
         """
@@ -1165,26 +1173,32 @@ class PrintScheduler:
         if not status:
             return [f"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})" for o in force_overrides]
 
-        # Build set of loaded type+colour pairs from AMS and external spool
-        loaded: set[tuple[str, str]] = set()
+        # Build loaded (type, colour, tray_info_idx) triples from AMS and external spool.
+        loaded: list[tuple[str, str, str]] = []
         for ams_unit in status.raw_data.get("ams", []):
             for tray in ams_unit.get("tray", []):
                 tray_type = tray.get("tray_type")
-                tray_color = tray.get("tray_color", "")
                 if tray_type:
-                    color_norm = tray_color.replace("#", "").lower()[:6]
-                    loaded.add((_canonical_filament_type(tray_type), color_norm))
+                    color_norm = (tray.get("tray_color", "") or "").replace("#", "").lower()[:6]
+                    loaded.append(
+                        (_canonical_filament_type(tray_type), color_norm, tray.get("tray_info_idx", "") or "")
+                    )
         for vt in status.raw_data.get("vt_tray") or []:
             vt_type = vt.get("tray_type")
             if vt_type:
                 color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
-                loaded.add((_canonical_filament_type(vt_type), color_norm))
+                loaded.append((_canonical_filament_type(vt_type), color_norm, vt.get("tray_info_idx", "") or ""))
 
         missing = []
         for o in force_overrides:
             o_type = _canonical_filament_type(o.get("type") or "")
             o_color = (o.get("color") or "").replace("#", "").lower()[:6]
-            if (o_type, o_color) not in loaded:
+            o_idx = o.get("tray_info_idx") or ""
+            satisfied = any(
+                t_type == o_type and t_color == o_color and (not o_idx or not t_idx or o_idx == t_idx)
+                for t_type, t_color, t_idx in loaded
+            )
+            if not satisfied:
                 color_label = o.get("color_name") or o.get("color", "?")
                 missing.append(f"{o_type} ({color_label})")
         return missing

+ 9 - 0
backend/app/services/virtual_printer/manager.py

@@ -903,11 +903,20 @@ class VirtualPrinterInstance:
                             if types:
                                 required_filament_types_json = json.dumps(types)
                             if self.queue_force_color_match:
+                                # Carry tray_info_idx so force_color_match can
+                                # tell Bambu PLA variants apart (#2650). Bambu
+                                # reports Basic/Matte/Silk all as tray_type
+                                # "PLA"; the variant lives only in tray_info_idx
+                                # (GFA00/GFA01/GFA06/...). A blank idx (custom or
+                                # third-party spool) means "no variant
+                                # constraint" and the scheduler falls back to
+                                # type+colour.
                                 overrides = [
                                     {
                                         "slot_id": r["slot_id"],
                                         "type": r.get("type", ""),
                                         "color": r.get("color", ""),
+                                        "tray_info_idx": r.get("tray_info_idx", ""),
                                         "force_color_match": True,
                                     }
                                     for r in requirements

+ 67 - 2
backend/tests/unit/services/test_virtual_printer.py

@@ -1169,12 +1169,77 @@ class TestVirtualPrinterInstance:
         assert queue_item.filament_overrides is not None
         overrides = json.loads(queue_item.filament_overrides)
         assert overrides == [
-            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "force_color_match": True},
-            {"slot_id": 2, "type": "PLA", "color": "#FF00FF", "force_color_match": True},
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "", "force_color_match": True},
+            {"slot_id": 2, "type": "PLA", "color": "#FF00FF", "tray_info_idx": "", "force_color_match": True},
         ]
         # required_filament_types still populated alongside overrides.
         assert json.loads(queue_item.required_filament_types) == ["PLA"]
 
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_force_color_match_carries_tray_info_idx(self, tmp_path):
+        """#2650: the force override must carry the 3MF's ``tray_info_idx`` so the
+        scheduler can tell Bambu PLA variants apart (Basic GFA00 / Matte GFA01 /
+        Silk GFA06) — they all report ``tray_type == "PLA"`` with the same colour,
+        so type+colour alone dispatches onto the wrong variant."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=24,
+            name="Variant",
+            mode="queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800024",
+            auto_dispatch=True,
+            queue_force_color_match=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "variant.3mf"
+        _write_3mf_with_filaments(
+            file_path,
+            [
+                # White PLA Matte — same colour as Basic/Silk, distinguished only by idx.
+                {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0", "tray_info_idx": "GFA01"},
+            ],
+            plate_index=1,
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "variant"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        overrides = json.loads(added_items[0].filament_overrides)
+        assert overrides == [
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA01", "force_color_match": True},
+        ]
+
     @pytest.mark.asyncio
     async def test_add_to_print_queue_force_color_match_skips_when_3mf_unparseable(self, tmp_path):
         """A malformed or fake-bytes 3MF must not crash the upload path —

+ 51 - 0
backend/tests/unit/test_scheduler_force_color_ams_fallback.py

@@ -241,3 +241,54 @@ class TestComputeAmsMappingFallback:
             result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
 
         assert result is None
+
+
+class TestGetMissingForceColorSlotsVariant:
+    """force_color_match must distinguish Bambu PLA variants that share a base
+    type+colour but differ in tray_info_idx (Basic GFA00 / Matte GFA01 /
+    Silk GFA06), while still accepting spools that report no idx (#2650)."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def _status(self, trays: list[dict]) -> MagicMock:
+        """One AMS unit whose trays are the given dicts (white PLA of assorted variants)."""
+        return MagicMock(raw_data={"ams": [{"id": 0, "tray": trays}]})
+
+    @staticmethod
+    def _white(idx: str) -> dict:
+        return {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": idx}
+
+    def _override(self, idx: str | None) -> list[dict]:
+        o = {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "force_color_match": True}
+        if idx is not None:
+            o["tray_info_idx"] = idx
+        return [o]
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_matte_requirement_rejects_basic_and_silk(self, mock_pm, scheduler):
+        """A GFA01 (Matte) job is unsatisfied by a printer loaded with only
+        Basic/Silk white PLA — the core #2650 regression."""
+        mock_pm.get_status.return_value = self._status([self._white("GFA00"), self._white("GFA06")])
+        assert scheduler._get_missing_force_color_slots(5, self._override("GFA01")) == ["PLA (#FFFFFF)"]
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_matte_requirement_accepts_matte(self, mock_pm, scheduler):
+        """The correct variant being loaded satisfies the override."""
+        mock_pm.get_status.return_value = self._status([self._white("GFA00"), self._white("GFA01")])
+        assert scheduler._get_missing_force_color_slots(5, self._override("GFA01")) == []
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_blank_loaded_idx_falls_back_to_type_and_colour(self, mock_pm, scheduler):
+        """A custom/third-party spool reports a blank tray_info_idx, so it must
+        still satisfy a variant-specific requirement (type+colour fallback)."""
+        mock_pm.get_status.return_value = self._status([self._white("")])
+        assert scheduler._get_missing_force_color_slots(5, self._override("GFA01")) == []
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_requirement_without_idx_unchanged(self, mock_pm, scheduler):
+        """An older 3MF whose override carries no idx keeps the historical
+        type+colour behaviour and matches any white PLA."""
+        mock_pm.get_status.return_value = self._status([self._white("GFA06")])
+        assert scheduler._get_missing_force_color_slots(5, self._override(None)) == []