mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04: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:
parent
30e4577838
commit
0f203ce7ca
5 changed files with 151 additions and 9 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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)) == []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue