diff --git a/CHANGELOG.md b/CHANGELOG.md index acc0fc68d..62d5f1c0a 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 6465aada6..5fe80714e 100644 --- a/backend/app/services/print_scheduler.py +++ b/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 diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index 78ba79453..81805a3eb 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/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 diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index d6e1a0dc7..7b8e83b6d 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/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 — diff --git a/backend/tests/unit/test_scheduler_force_color_ams_fallback.py b/backend/tests/unit/test_scheduler_force_color_ams_fallback.py index c3c1c8d86..f6cc66fa9 100644 --- a/backend/tests/unit/test_scheduler_force_color_ams_fallback.py +++ b/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)) == []