Fix AMS auto-matching to use tray_info_idx from 3MF files

When multiple AMS trays have the same filament type and color, Bambuddy
now uses the tray_info_idx attribute from the 3MF file to identify the
exact spool selected during slicing. This ensures the correct tray is
used rather than just picking the first match.

Matching priority: tray_info_idx > exact color > similar color > type-only

Closes #245
This commit is contained in:
maziggy
2026-02-04 08:18:05 +01:00
parent a0d878a231
commit fe17a6905f
2 changed files with 512 additions and 0 deletions
@@ -265,3 +265,166 @@ class TestMatchFilamentsToSlots:
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [254]
def test_match_by_tray_info_idx_priority(self, scheduler):
"""tray_info_idx match should have highest priority over color match."""
required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"}]
loaded = [
{
"type": "PLA",
"color": "#000000",
"global_tray_id": 0,
"tray_info_idx": "GFB00",
}, # Same color, different spool
{
"type": "PLA",
"color": "#000000",
"global_tray_id": 1,
"tray_info_idx": "GFA00",
}, # Same color, exact spool
{
"type": "PLA",
"color": "#000000",
"global_tray_id": 2,
"tray_info_idx": "GFC00",
}, # Same color, different spool
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [1] # Should pick tray 1 (exact tray_info_idx match)
def test_match_by_tray_info_idx_with_different_colors(self, scheduler):
"""tray_info_idx match should work even if colors differ slightly."""
required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "P4d64437"}]
loaded = [
{"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": ""}, # No idx
{
"type": "PLA",
"color": "#000010",
"global_tray_id": 3,
"tray_info_idx": "P4d64437",
}, # Exact spool (slightly different color reported)
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [3] # Should pick tray 3 (exact tray_info_idx match)
def test_match_fallback_to_color_when_no_tray_info_idx(self, scheduler):
"""Should fall back to color matching when tray_info_idx is empty."""
required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": ""}]
loaded = [
{"type": "PLA", "color": "#00FF00", "global_tray_id": 0, "tray_info_idx": "GFA00"},
{"type": "PLA", "color": "#FF0000", "global_tray_id": 1, "tray_info_idx": "GFB00"}, # Color match
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [1] # Should pick tray 1 (color match)
def test_match_fallback_to_color_when_no_matching_tray_info_idx(self, scheduler):
"""Should fall back to color when tray_info_idx doesn't match any loaded spool."""
required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "OLD_SPOOL"}]
loaded = [
{
"type": "PLA",
"color": "#FF0000",
"global_tray_id": 0,
"tray_info_idx": "NEW_SPOOL",
}, # Different idx but same color
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [0] # Should fall back to color match
def test_match_multiple_same_color_with_tray_info_idx(self, scheduler):
"""Multiple identical filaments should be matched by tray_info_idx (H2D Pro scenario)."""
# This is the exact scenario from issue #245 - 3 black PLA spools
required = [
{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA03"}, # Wants tray 3
]
loaded = [
{"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00"}, # Tray 0
{"type": "PLA", "color": "#000000", "global_tray_id": 1, "tray_info_idx": "GFA01"}, # Tray 1
{"type": "PLA", "color": "#000000", "global_tray_id": 2, "tray_info_idx": "GFA02"}, # Tray 2
{
"type": "PLA",
"color": "#000000",
"global_tray_id": 3,
"tray_info_idx": "GFA03",
}, # Tray 3 - the one we want
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [3] # Should pick tray 3, not tray 0
def test_match_tray_info_idx_not_reused(self, scheduler):
"""tray_info_idx matched trays should not be reused for other slots."""
required = [
{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"},
{"slot_id": 2, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA01"},
]
loaded = [
{"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00"},
{"type": "PLA", "color": "#000000", "global_tray_id": 1, "tray_info_idx": "GFA01"},
]
result = scheduler._match_filaments_to_slots(required, loaded)
assert result == [0, 1] # Each slot gets its specific tray
class TestBuildLoadedFilamentsTrayInfoIdx:
"""Test tray_info_idx extraction in _build_loaded_filaments."""
@pytest.fixture
def scheduler(self):
return PrintScheduler()
def test_build_loaded_filaments_includes_tray_info_idx(self, scheduler):
"""Should extract tray_info_idx from AMS trays."""
class MockStatus:
raw_data = {
"ams": [
{
"id": 0,
"tray": [
{"id": 0, "tray_type": "PLA", "tray_color": "000000", "tray_info_idx": "GFA00"},
{"id": 1, "tray_type": "PLA", "tray_color": "000000", "tray_info_idx": "GFA01"},
],
}
]
}
result = scheduler._build_loaded_filaments(MockStatus())
assert len(result) == 2
assert result[0]["tray_info_idx"] == "GFA00"
assert result[1]["tray_info_idx"] == "GFA01"
def test_build_loaded_filaments_empty_tray_info_idx(self, scheduler):
"""Missing tray_info_idx should default to empty string."""
class MockStatus:
raw_data = {
"ams": [
{
"id": 0,
"tray": [
{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}, # No tray_info_idx
],
}
]
}
result = scheduler._build_loaded_filaments(MockStatus())
assert len(result) == 1
assert result[0]["tray_info_idx"] == ""
def test_build_loaded_filaments_external_spool_tray_info_idx(self, scheduler):
"""Should extract tray_info_idx from external spool."""
class MockStatus:
raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}}
result = scheduler._build_loaded_filaments(MockStatus())
assert len(result) == 1
assert result[0]["tray_info_idx"] == "P4d64437"
assert result[0]["is_external"] is True
@@ -0,0 +1,349 @@
/**
* Tests for the useFilamentMapping hook and helper functions.
*
* Tests the tray_info_idx matching logic that ensures the exact spool
* selected during slicing is used when multiple trays have identical filament.
*/
import { describe, it, expect } from 'vitest';
import {
buildLoadedFilaments,
computeAmsMapping,
} from '../../hooks/useFilamentMapping';
import type { PrinterStatus } from '../../api/client';
// Helper to create a minimal printer status with AMS data
function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray?: PrinterStatus['vt_tray']): PrinterStatus {
return {
ams,
vt_tray,
} as PrinterStatus;
}
describe('buildLoadedFilaments', () => {
it('returns empty array for undefined status', () => {
const result = buildLoadedFilaments(undefined);
expect(result).toEqual([]);
});
it('extracts filaments from AMS units', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
{ id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFA01' },
],
},
]);
const result = buildLoadedFilaments(status);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
type: 'PLA',
color: '#FF0000',
amsId: 0,
trayId: 0,
globalTrayId: 0,
trayInfoIdx: 'GFA00',
});
expect(result[1]).toMatchObject({
type: 'PETG',
color: '#00FF00',
globalTrayId: 1,
trayInfoIdx: 'GFA01',
});
});
it('includes tray_info_idx from AMS trays', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'P4d64437' },
],
},
]);
const result = buildLoadedFilaments(status);
expect(result[0].trayInfoIdx).toBe('P4d64437');
});
it('handles missing tray_info_idx', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // No tray_info_idx
],
},
]);
const result = buildLoadedFilaments(status);
expect(result[0].trayInfoIdx).toBe('');
});
it('extracts external spool with tray_info_idx', () => {
const status = createPrinterStatus(
[],
{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
);
const result = buildLoadedFilaments(status);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: 'TPU',
isExternal: true,
globalTrayId: 254,
trayInfoIdx: 'EXT001',
});
});
it('skips empty trays', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
{ id: 1, tray_type: '', tray_color: '' }, // Empty tray
{ id: 2 }, // No tray_type
],
},
]);
const result = buildLoadedFilaments(status);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('PLA');
});
it('marks AMS-HT units correctly', () => {
const status = createPrinterStatus([
{
id: 128, // AMS-HT typically has high ID
tray: [
{ id: 0, tray_type: 'PLA-CF', tray_color: '000000', tray_info_idx: 'HT001' },
], // Single tray = AMS-HT
},
]);
const result = buildLoadedFilaments(status);
expect(result[0].isHt).toBe(true);
expect(result[0].globalTrayId).toBe(512); // 128 * 4 + 0
});
});
describe('computeAmsMapping', () => {
it('returns undefined for empty filament requirements', () => {
const status = createPrinterStatus([
{
id: 0,
tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
},
]);
expect(computeAmsMapping(undefined, status)).toBeUndefined();
expect(computeAmsMapping({ filaments: [] }, status)).toBeUndefined();
});
it('returns undefined when no filaments loaded', () => {
const reqs = {
filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
};
expect(computeAmsMapping(reqs, undefined)).toBeUndefined();
expect(computeAmsMapping(reqs, createPrinterStatus([]))).toBeUndefined();
});
it('matches by tray_info_idx with highest priority', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA01' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' }, // Same color, wrong idx
{ id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' }, // Exact idx match
{ id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' }, // Same color, wrong idx
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([1]); // Should pick tray 1, not tray 0
});
it('matches multiple identical filaments by tray_info_idx (H2D Pro scenario)', () => {
// This is the exact scenario from issue #245 - multiple black PLA spools
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 50, tray_info_idx: 'GFA03' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
{ id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
{ id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
{ id: 3, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA03' }, // This one
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([3]); // Should pick tray 3, not tray 0
});
it('falls back to color match when tray_info_idx is empty', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: '' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '00FF00', tray_info_idx: 'GFA00' }, // Wrong color
{ id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA01' }, // Color match
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([1]);
});
it('falls back to color match when tray_info_idx does not match', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'OLD_SPOOL' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'NEW_SPOOL' }, // Different idx, same color
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([0]); // Falls back to color match
});
it('matches by type only when color differs', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '0000FF' }, // Same type, different color
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([0]); // Type-only match
});
it('returns -1 for unmatched slots', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'TPU', color: '#FF0000', used_grams: 10 }, // No TPU loaded
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([-1]);
});
it('avoids duplicate tray assignment', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
{ slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 }, // Same requirements
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // Only one PLA
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([0, -1]); // First slot gets the match, second is unmatched
});
it('handles multi-slot mapping with tray_info_idx', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA00' },
{ slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA02' },
],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
{ id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
{ id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
],
},
]);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([0, 2]); // Each slot gets its specific tray
});
it('handles external spool matching', () => {
const reqs = {
filaments: [
{ slot_id: 1, type: 'TPU', color: '#0000FF', used_grams: 10, tray_info_idx: 'EXT001' },
],
};
const status = createPrinterStatus(
[],
{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
);
const result = computeAmsMapping(reqs, status);
expect(result).toEqual([254]); // External spool global ID
});
});