mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
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:
@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
|
|||||||
|
|
||||||
## [1.2.6b1] - Unreleased
|
## [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
|
## [1.2.5] - 2026-07-24
|
||||||
|
|
||||||
|
|||||||
@@ -1158,6 +1158,14 @@ class PrintScheduler:
|
|||||||
to carry ``force_color_match: True``. The printer must have **every** such slot loaded
|
to carry ``force_color_match: True``. The printer must have **every** such slot loaded
|
||||||
with an exact type+color match.
|
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:
|
Returns:
|
||||||
List of ``"TYPE (color)"`` strings for unmatched slots (empty list means all match).
|
List of ``"TYPE (color)"`` strings for unmatched slots (empty list means all match).
|
||||||
"""
|
"""
|
||||||
@@ -1165,26 +1173,32 @@ class PrintScheduler:
|
|||||||
if not status:
|
if not status:
|
||||||
return [f"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})" for o in force_overrides]
|
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
|
# Build loaded (type, colour, tray_info_idx) triples from AMS and external spool.
|
||||||
loaded: set[tuple[str, str]] = set()
|
loaded: list[tuple[str, str, str]] = []
|
||||||
for ams_unit in status.raw_data.get("ams", []):
|
for ams_unit in status.raw_data.get("ams", []):
|
||||||
for tray in ams_unit.get("tray", []):
|
for tray in ams_unit.get("tray", []):
|
||||||
tray_type = tray.get("tray_type")
|
tray_type = tray.get("tray_type")
|
||||||
tray_color = tray.get("tray_color", "")
|
|
||||||
if tray_type:
|
if tray_type:
|
||||||
color_norm = tray_color.replace("#", "").lower()[:6]
|
color_norm = (tray.get("tray_color", "") or "").replace("#", "").lower()[:6]
|
||||||
loaded.add((_canonical_filament_type(tray_type), color_norm))
|
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 []:
|
for vt in status.raw_data.get("vt_tray") or []:
|
||||||
vt_type = vt.get("tray_type")
|
vt_type = vt.get("tray_type")
|
||||||
if vt_type:
|
if vt_type:
|
||||||
color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
|
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 = []
|
missing = []
|
||||||
for o in force_overrides:
|
for o in force_overrides:
|
||||||
o_type = _canonical_filament_type(o.get("type") or "")
|
o_type = _canonical_filament_type(o.get("type") or "")
|
||||||
o_color = (o.get("color") or "").replace("#", "").lower()[:6]
|
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", "?")
|
color_label = o.get("color_name") or o.get("color", "?")
|
||||||
missing.append(f"{o_type} ({color_label})")
|
missing.append(f"{o_type} ({color_label})")
|
||||||
return missing
|
return missing
|
||||||
|
|||||||
@@ -903,11 +903,20 @@ class VirtualPrinterInstance:
|
|||||||
if types:
|
if types:
|
||||||
required_filament_types_json = json.dumps(types)
|
required_filament_types_json = json.dumps(types)
|
||||||
if self.queue_force_color_match:
|
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 = [
|
overrides = [
|
||||||
{
|
{
|
||||||
"slot_id": r["slot_id"],
|
"slot_id": r["slot_id"],
|
||||||
"type": r.get("type", ""),
|
"type": r.get("type", ""),
|
||||||
"color": r.get("color", ""),
|
"color": r.get("color", ""),
|
||||||
|
"tray_info_idx": r.get("tray_info_idx", ""),
|
||||||
"force_color_match": True,
|
"force_color_match": True,
|
||||||
}
|
}
|
||||||
for r in requirements
|
for r in requirements
|
||||||
|
|||||||
@@ -1169,12 +1169,77 @@ class TestVirtualPrinterInstance:
|
|||||||
assert queue_item.filament_overrides is not None
|
assert queue_item.filament_overrides is not None
|
||||||
overrides = json.loads(queue_item.filament_overrides)
|
overrides = json.loads(queue_item.filament_overrides)
|
||||||
assert overrides == [
|
assert overrides == [
|
||||||
{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "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", "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.
|
# required_filament_types still populated alongside overrides.
|
||||||
assert json.loads(queue_item.required_filament_types) == ["PLA"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_add_to_print_queue_force_color_match_skips_when_3mf_unparseable(self, tmp_path):
|
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 —
|
"""A malformed or fake-bytes 3MF must not crash the upload path —
|
||||||
|
|||||||
@@ -241,3 +241,54 @@ class TestComputeAmsMappingFallback:
|
|||||||
result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
|
result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
|
||||||
|
|
||||||
assert result is None
|
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)) == []
|
||||||
|
|||||||
Reference in New Issue
Block a user