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.
This commit is contained in:
maziggy 2026-07-24 12:54:22 +02:00
parent 30e4577838
commit 0f203ce7ca
5 changed files with 151 additions and 9 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 —

View file

@ -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)) == []