mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 12:15:36 +02:00
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:
@@ -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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user