fix(scheduler): pin the force-color variant when selecting the AMS slot (#2650)

Follow-up to 0f203ce: force color match now picks the right AMS slot, not
just the right printer. The slot mapper cleared tray_info_idx when applying
an override, so on a printer holding two same-colour PLA spools of different
variants (Basic GFA00 / Matte GFA01 / Silk GFA06) it could map to the wrong
one. It now keeps the variant for force_color_match overrides (both the 3MF
and no-3MF fallback paths) so the matcher pins the matching tray, and falls
back to type+colour when that variant isn't loaded. A manual filament swap
(a preference override) still clears the idx so it matches the swapped-in
spool rather than the old one.

The printer-card queue-compatibility hint applies the same variant rule.
This commit is contained in:
maziggy
2026-07-24 13:13:04 +02:00
parent 0f203ce7ca
commit 800c45536e
10 changed files with 211 additions and 18 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ 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`.
- **Force color match dispatched a print onto the wrong PLA variant — Matte jobs went to Basic and Silk printers alike, and the wrong AMS slot on a printer holding two same-colour variants (#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. Three 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`; 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; and the AMS slot mapper cleared `tray_info_idx` when applying the override, so even on the correct printer it could pick a different-variant tray of the same colour. **Fix.** The force override now carries the 3MF's `tray_info_idx`; 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); and the slot mapper now keeps the variant for force-colour overrides so it pins the matching tray. 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, and a manual filament *swap* (a preference override) still clears the idx so it matches the swapped-in spool rather than the old one. A job sliced for GFA01 now goes only to a printer with GFA01 loaded, and lands on that printer's GFA01 tray. The printer-card queue-compatibility hint (which printers show a pending job as runnable) now applies the same variant rule. Covered by scheduler tests (Matte requirement rejects Basic/Silk, accepts Matte, blank loaded idx falls back, requirement without an idx unchanged; the mapper pins the GFA01 tray over a same-colour GFA00 on both the 3MF and no-3MF paths; a preference swap still matches by colour), a Virtual-Printer test asserting the override carries `tray_info_idx`, and frontend tests for the variant-aware queue hint (rejects other variants, accepts the match, blank-idx and no-variant-data fall back).
## [1.2.5] - 2026-07-24
+17 -4
View File
@@ -1388,9 +1388,18 @@ class PrintScheduler:
override = override_map[req["slot_id"]]
req["type"] = override["type"]
req["color"] = override["color"]
# Clear tray_info_idx so matching uses type+color instead of
# the original 3MF's tray_info_idx (which would match the old filament)
req["tray_info_idx"] = ""
# A manual/preference override SWAPS the slot's filament, so the
# 3MF's original tray_info_idx now points at the old spool and must
# be cleared — matching then falls back to type+colour. A
# force_color_match override is not a swap: it carries the 3MF's
# intended variant (Basic GFA00 / Matte GFA01 / Silk GFA06), so keep
# it here too, letting the matcher pin the correct variant slot on a
# printer holding two same-colour spools of different variants (#2650).
# If that variant isn't loaded the matcher falls back to type+colour,
# so an eligible printer never fails to map.
req["tray_info_idx"] = (
override.get("tray_info_idx", "") if override.get("force_color_match") else ""
)
logger.debug(
"Queue item %s: Override slot %d -> %s %s",
item.id,
@@ -1454,7 +1463,11 @@ class PrintScheduler:
"slot_id": o["slot_id"],
"type": o.get("type", ""),
"color": o.get("color", ""),
"tray_info_idx": "",
# These are all force_color_match overrides, so the idx (when the
# 3MF carried one) is the intended variant, not a stale swap —
# keep it so the matcher pins the right variant slot, falling back
# to type+colour when it isn't loaded (#2650).
"tray_info_idx": o.get("tray_info_idx", ""),
}
for o in force_overrides
]
@@ -124,6 +124,27 @@ class TestBuildOverrideDirectMapping:
# Should match by colour (#CBC6B8 ≈ CBC6B8FF after strip), not by tray_info_idx.
assert result == [0]
def test_direct_mapping_pins_variant_when_override_carries_idx(self, scheduler):
"""#2650: the no-3MF fallback honours a force override's own tray_info_idx,
so two same-colour PLA variants map to the intended slot rather than the
first same-colour tray."""
status = self._status(
ams=[
{
"id": 0,
"tray": [
{"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
{"id": 1, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA01"},
],
}
]
)
overrides = [
{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA01", "force_color_match": True}
]
result = scheduler._build_override_direct_mapping(overrides, status)
assert result == [1] # global_tray_id 1 = GFA01 (Matte), not GFA00 (Basic) at 0
class TestComputeAmsMappingFallback:
"""Integration tests for the force-color fallback inside
@@ -242,6 +263,84 @@ class TestComputeAmsMappingFallback:
assert result is None
@pytest.mark.asyncio
@patch("backend.app.services.print_scheduler.printer_manager")
async def test_force_color_override_pins_the_matching_variant_slot(self, mock_pm, scheduler):
"""#2650 slot selection: with two same-colour PLA spools of different
variants loaded, applying a force_color_match override must map to the
tray whose tray_info_idx matches the 3MF (Matte GFA01), not the first
same-colour tray (Basic GFA00)."""
mock_pm.get_status.return_value = MagicMock(
raw_data={
"ams": [
{
"id": 0,
"tray": [
{"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
{"id": 1, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA01"},
],
}
]
},
ams_filament_backup=None,
)
item = self._make_item(
filament_overrides_json=(
'[{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", '
'"tray_info_idx": "GFA01", "force_color_match": true}]'
)
)
filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA01"}]
db = AsyncMock()
with (
patch.object(scheduler, "_get_filament_requirements", return_value=filament_reqs),
patch.object(scheduler, "_get_bool_setting", new=AsyncMock(return_value=False)),
):
result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
assert result == [1] # global_tray_id 1 = GFA01 (Matte), not GFA00 (Basic) at 0
@pytest.mark.asyncio
@patch("backend.app.services.print_scheduler.printer_manager")
async def test_preference_override_still_clears_idx_so_a_swap_matches_by_colour(self, mock_pm, scheduler):
"""A non-force (preference) override is a filament SWAP: its slot must
match by the new type+colour, never by a stale 3MF variant that would pin
the old spool. Only force_color_match overrides keep their idx — anything
else is cleared, exactly as before #2650."""
mock_pm.get_status.return_value = MagicMock(
raw_data={
"ams": [
{
"id": 0,
"tray": [
# The 3MF's original variant (blue GFA01) and the swapped-to red.
{"id": 0, "tray_type": "PLA", "tray_color": "0000FFFF", "tray_info_idx": "GFA01"},
{"id": 1, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFA00"},
],
}
]
},
ams_filament_backup=None,
)
# 3MF wants blue GFA01; the user swaps this slot to red via a preference
# override that (defensively) also carries the stale GFA01 idx.
item = self._make_item(
filament_overrides_json='[{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "GFA01"}]'
)
filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#0000FF", "tray_info_idx": "GFA01"}]
db = AsyncMock()
with (
patch.object(scheduler, "_get_filament_requirements", return_value=filament_reqs),
patch.object(scheduler, "_get_bool_setting", new=AsyncMock(return_value=False)),
):
result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
# idx cleared → matches the swapped-to red spool (global 1), not the stale
# GFA01 blue (global 0) that a preserved idx would have pinned.
assert result == [1]
class TestGetMissingForceColorSlotsVariant:
"""force_color_match must distinguish Bambu PLA variants that share a base
+41 -1
View File
@@ -8,7 +8,8 @@
*/
import { describe, it, expect } from 'vitest';
import { getPrinterImage, isGcodeCompatible } from '../../utils/printer';
import { getPrinterImage, isGcodeCompatible, filterCompatibleQueueItems } from '../../utils/printer';
import type { PrintQueueItem } from '../../api/client';
describe('getPrinterImage', () => {
describe('X2D (#988)', () => {
@@ -136,3 +137,42 @@ describe('isGcodeCompatible', () => {
expect(isGcodeCompatible('H2D Pro', 'H2DPRO')).toBe(true);
});
});
describe('filterCompatibleQueueItems — force-color PLA variant (#2650)', () => {
const makeItem = (
overrides: Array<{ slot_id: number; type: string; color: string; tray_info_idx?: string; force_color_match?: boolean }>,
): PrintQueueItem => ({ id: 1, filament_overrides: overrides } as unknown as PrintQueueItem);
// A job sliced for White PLA Matte (GFA01).
const matteJob = makeItem([
{ slot_id: 1, type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA01', force_color_match: true },
]);
const loadedTypes = new Set(['PLA']);
const loaded = new Set(['PLA:ffffff']);
it('rejects a printer loaded only with other white PLA variants (Basic/Silk)', () => {
const variants = new Set(['PLA:ffffff:GFA00', 'PLA:ffffff:GFA06']);
expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(0);
});
it('accepts a printer loaded with the matching variant (Matte GFA01)', () => {
const variants = new Set(['PLA:ffffff:GFA00', 'PLA:ffffff:GFA01']);
expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(1);
});
it('accepts a same-colour spool that reports no tray_info_idx (custom/third-party)', () => {
const variants = new Set(['PLA:ffffff:']);
expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(1);
});
it('falls back to type+colour when no variant data is supplied', () => {
// loadedVariants omitted → the hint is never stricter than the data it has.
expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded)).toHaveLength(1);
});
it('an override without a tray_info_idx keeps the old type+colour behaviour', () => {
const noIdxJob = makeItem([{ slot_id: 1, type: 'PLA', color: '#FFFFFF', force_color_match: true }]);
const variants = new Set(['PLA:ffffff:GFA06']);
expect(filterCompatibleQueueItems([noIdxJob], loadedTypes, loaded, variants)).toHaveLength(1);
});
});
+1 -1
View File
@@ -2192,7 +2192,7 @@ export interface PrintQueueItem {
// PrintModal's deficit warning was acknowledged.
skip_filament_check: boolean;
ams_mapping: number[] | null; // AMS slot mapping for multi-color prints
filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null; // Filament overrides for model-based assignment
filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null; // Filament overrides for model-based assignment
plate_id: number | null; // Plate ID for multi-plate 3MF files
// Print options
bed_levelling: CalibrationMode;
@@ -11,10 +11,11 @@ interface PrinterQueueWidgetProps {
printerModel?: string | null;
loadedFilamentTypes?: Set<string>;
loadedFilaments?: Set<string>; // "TYPE:rrggbb" pairs for filament override color matching
loadedVariants?: Set<string>; // "TYPE:rrggbb:idx" triples for PLA sub-variant matching (#2650)
variant?: 'card' | 'panelExtension';
}
export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentTypes, loadedFilaments, variant = 'card' }: PrinterQueueWidgetProps) {
export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentTypes, loadedFilaments, loadedVariants, variant = 'card' }: PrinterQueueWidgetProps) {
const { t } = useTranslation();
const { data: queue } = useQuery({
queryKey: ['queue', printerId, 'pending', printerModel],
@@ -23,7 +24,7 @@ export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentType
});
// Filter queue to items this printer can actually print (filament type + color check)
const compatibleQueue = queue ? filterCompatibleQueueItems(queue, loadedFilamentTypes, loadedFilaments) : undefined;
const compatibleQueue = queue ? filterCompatibleQueueItems(queue, loadedFilamentTypes, loadedFilaments, loadedVariants) : undefined;
const totalPending = compatibleQueue?.length || 0;
if (totalPending === 0) {
+27 -2
View File
@@ -1990,6 +1990,30 @@ function PrinterCard({
return filaments;
}, [status?.ams, status?.vt_tray]);
// Collect loaded type+color+tray_info_idx triples for variant-aware force-color
// matching. Format: "TYPE:rrggbb:idx" (idx "" for custom/third-party spools) —
// distinguishes Bambu PLA sub-variants that share a base type+colour (#2650).
const loadedVariants = useMemo(() => {
const variants = new Set<string>();
if (status?.ams) {
for (const ams of status.ams) {
for (const tray of ams.tray || []) {
if (tray.tray_type && tray.tray_color) {
const color = tray.tray_color.replace('#', '').toLowerCase().slice(0, 6);
variants.add(`${tray.tray_type.toUpperCase()}:${color}:${tray.tray_info_idx || ''}`);
}
}
}
}
for (const vt of status?.vt_tray ?? []) {
if (vt.tray_type && vt.tray_color) {
const color = vt.tray_color.replace('#', '').toLowerCase().slice(0, 6);
variants.add(`${vt.tray_type.toUpperCase()}:${color}:${vt.tray_info_idx || ''}`);
}
}
return variants;
}, [status?.ams, status?.vt_tray]);
// Fetch cloud filament info for tooltips (name includes color, also has K value)
const { data: filamentInfo } = useQuery({
queryKey: ['filamentInfo', trayInfoIds],
@@ -2163,8 +2187,8 @@ function PrinterCard({
// An empty Set means no filaments are loaded — jobs requiring specific types are incompatible.
const queueCount = useMemo(() => {
if (!queueItems?.length) return 0;
return filterCompatibleQueueItems(queueItems, loadedFilamentTypes, loadedFilaments).length;
}, [queueItems, loadedFilamentTypes, loadedFilaments]);
return filterCompatibleQueueItems(queueItems, loadedFilamentTypes, loadedFilaments, loadedVariants).length;
}, [queueItems, loadedFilamentTypes, loadedFilaments, loadedVariants]);
// Fetch currently printing queue item to show who started it (Issue #206)
const { data: printingQueueItems } = useQuery({
@@ -3740,6 +3764,7 @@ function PrinterCard({
printerModel={printer.model}
loadedFilamentTypes={loadedFilamentTypes}
loadedFilaments={loadedFilaments}
loadedVariants={loadedVariants}
variant="panelExtension"
/>
</div>
+18 -3
View File
@@ -56,12 +56,19 @@ import type { PrintQueueItem } from '../api/client';
* @param items - Array of queue items to filter
* @param loadedFilamentTypes - Set of loaded filament types (e.g., "PLA", "PETG")
* @param loadedFilaments - Set of loaded filament type+color pairs (e.g., "PLA:ffffff", "PETG:ff0000")
* @param loadedVariants - Set of loaded type+color+tray_info_idx triples
* (e.g., "PLA:ffffff:GFA01"; the idx is "" for custom/third-party spools). Used to
* distinguish Bambu PLA sub-variants (Basic GFA00 / Matte GFA01 / Silk GFA06) that
* share a base type+colour, mirroring the backend _get_missing_force_color_slots (#2650).
* When omitted, force matching falls back to type+colour so the hint is never stricter
* than the data available.
* @returns Array of compatible queue items
*/
export function filterCompatibleQueueItems(
items: PrintQueueItem[],
loadedFilamentTypes?: Set<string>,
loadedFilaments?: Set<string>
loadedFilaments?: Set<string>,
loadedVariants?: Set<string>
): PrintQueueItem[] {
return items.filter(item => {
// Type check: all required filament types must be loaded
@@ -78,12 +85,20 @@ export function filterCompatibleQueueItems(
const forceOverrides = item.filament_overrides.filter(o => o.force_color_match === true);
const prefOverrides = item.filament_overrides.filter(o => o.force_color_match !== true);
// All force-matched slots must have exact type+color on this printer
// All force-matched slots must have an exact type+color match — and, when the
// override carries a tray_info_idx, the same variant too (a loaded tray with a
// blank idx still satisfies it, matching the backend's type+colour fallback).
if (forceOverrides.length > 0) {
const allForceMatch = forceOverrides.every(o => {
const oType = (o.type || '').toUpperCase();
const oColor = (o.color || '').replace('#', '').toLowerCase().slice(0, 6);
return loadedFilaments.has(`${oType}:${oColor}`);
const oIdx = o.tray_info_idx || '';
// No variant on the override, or no variant data supplied → type+colour only.
if (!oIdx || loadedVariants === undefined) {
return loadedFilaments.has(`${oType}:${oColor}`);
}
// Variant-specific: same idx, or a same-colour tray that reports no idx.
return loadedVariants.has(`${oType}:${oColor}:${oIdx}`) || loadedVariants.has(`${oType}:${oColor}:`);
});
if (!allForceMatch) return false;
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-ByvLwk1T.js"></script>
<script type="module" crossorigin src="/assets/index-Bx0t8q7D.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-kl51qImb.css">
</head>
<body>