fix(ams): show "?" for loaded-but-unconfigured AMS slots (#1694)

Reporter on a 3-AMS P1S saw slots labelled "Empty" even though spools
  were physically loaded - OrcaSlicer's Device view showed the same
  slots as loaded.

  Root cause: the compact label below the AMS slot circle rendered
  tray.tray_type || t('ams.slotEmpty'), falling back to "Empty" whenever
  the printer firmware hadn't been told which material is in the slot.
  getEmptySlotKind already distinguishes 'physical' (firmware confirmed
  empty via state 9/10) from 'reset' (tray_type absent but firmware
  hasn't confirmed empty - spool loaded, just unassigned). The hover
  card and circle border already used that distinction; the compact
  label did not.

  Fix: label branches on emptyKind - 'physical' keeps "Empty", 'reset'
  shows "?" matching the slicer's own convention. External / VT tray
  label is unchanged (external trays have no "configured/unconfigured"
  distinction - they're either loaded or not). SpoolBuddy AmsUnitCard
  carried the same bug and got the same fix plus a tooltip.
This commit is contained in:
maziggy
2026-06-09 08:41:46 +02:00
parent 7a9c78c32c
commit 529155413f
17 changed files with 121 additions and 74 deletions
@@ -50,7 +50,9 @@ function makeUnit(overrides: Partial<AMSUnit> = {}): AMSUnit {
makeTray({ id: 0, tray_color: 'FF0000FF', tray_type: 'PLA', remain: 80 }),
makeTray({ id: 1, tray_color: '00FF00FF', tray_type: 'PETG', remain: 50 }),
makeTray({ id: 2, tray_color: '0000FFFF', tray_type: 'ABS', remain: 10 }),
makeTray({ id: 3, tray_color: null, tray_type: '', remain: -1 }),
// state=9 = firmware-confirmed empty (#1694: vs state=null which would
// be "spool loaded but unconfigured", labelled "?" in the UI).
makeTray({ id: 3, tray_color: null, tray_type: '', remain: -1, state: 9 } as Partial<AMSTray> & { state: number }),
],
serial_number: 'AMS001',
sw_ver: '1.0.0',
@@ -88,11 +90,30 @@ describe('AmsUnitCard', () => {
expect(screen.getByText('ABS')).toBeDefined();
});
it('shows "Empty" for empty slot', () => {
it('shows "Empty" for firmware-confirmed empty slot (state 9/10)', () => {
render(<AmsUnitCard unit={makeUnit()} activeSlot={null} />);
expect(screen.getByText('Empty')).toBeDefined();
});
it('shows "?" for loaded-but-unconfigured slot (#1694)', () => {
// No state reported by firmware + empty tray_type = spool loaded into the
// slot but no material assigned. Reporter on a 3-AMS P1S saw these slots
// mislabelled as "Empty" because the prior logic only checked tray_type.
const unit = makeUnit({
tray: [
makeTray({ id: 0, tray_type: 'PLA', remain: 80 }),
makeTray({ id: 1, tray_color: null, tray_type: '', remain: -1 } as Partial<AMSTray> & { state?: number }),
makeTray({ id: 2, tray_type: 'ABS', remain: 10 }),
makeTray({ id: 3, tray_color: null, tray_type: '', remain: -1, state: 9 } as Partial<AMSTray> & { state: number }),
],
});
render(<AmsUnitCard unit={unit} activeSlot={null} />);
expect(screen.getByText('?')).toBeDefined();
// The firmware-empty slot still reads "Empty" — the two states are visually
// distinct, not collapsed.
expect(screen.getByText('Empty')).toBeDefined();
});
it('renders fill level bars for slots with filament', () => {
const { container } = render(
<AmsUnitCard unit={makeUnit()} activeSlot={null} />
@@ -10,6 +10,15 @@ function isTrayEmpty(tray: AMSTray): boolean {
return !tray.tray_type || tray.tray_type === '';
}
// Mirror of PrintersPage.getEmptySlotKind (#1694): 'physical' when firmware
// confirms no spool (state 9/10), 'reset' when tray_type is absent but the
// firmware hasn't confirmed empty (= spool loaded, slot just unconfigured).
function getEmptySlotKind(tray: AMSTray): 'physical' | 'reset' | null {
if (tray.tray_type) return null;
const state = (tray as { state?: number | null }).state ?? null;
return state === 9 || state === 10 ? 'physical' : 'reset';
}
function getAmsName(id: number): string {
if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
@@ -147,6 +156,7 @@ interface SpoolSlotProps {
function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onClick }: SpoolSlotProps) {
const isEmpty = isTrayEmpty(tray);
const emptyKind = getEmptySlotKind(tray);
const color = trayColorToCSS(tray.tray_color);
const amsFill = tray.remain !== null && tray.remain !== undefined && tray.remain >= 0 ? tray.remain : null;
// If inventory says 0% but AMS reports positive remain, prefer AMS (#676)
@@ -181,8 +191,11 @@ function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onCl
</div>
{/* Material type */}
<span className="text-sm text-white/70 truncate max-w-full">
{isEmpty ? 'Empty' : tray.tray_type || 'Unknown'}
<span
className="text-sm text-white/70 truncate max-w-full"
title={emptyKind === 'reset' ? 'Spool loaded — slot not configured' : undefined}
>
{isEmpty ? (emptyKind === 'reset' ? '?' : 'Empty') : tray.tray_type || 'Unknown'}
</span>
{/* Fill level bar */}
+1
View File
@@ -3972,6 +3972,7 @@ export default {
empty: 'Leer',
emptySlot: 'Leerer Slot',
slotEmpty: 'Leer',
slotUnconfigured: '?',
emptySlotReset: 'Keine Spule zugewiesen',
unknown: 'Unbekannt',
humidity: 'Luftfeuchtigkeit',
+1
View File
@@ -3984,6 +3984,7 @@ export default {
empty: 'Empty',
emptySlot: 'Empty slot',
slotEmpty: 'Empty',
slotUnconfigured: '?',
emptySlotReset: 'No filament assigned',
unknown: 'Unknown',
humidity: 'Humidity',
+1
View File
@@ -3980,6 +3980,7 @@ export default {
empty: 'Vacía',
emptySlot: 'Ranura vacía',
slotEmpty: 'Vacía',
slotUnconfigured: '?',
emptySlotReset: 'No hay filamento asignado',
unknown: 'Desconocido',
humidity: 'Humedad',
+1
View File
@@ -3961,6 +3961,7 @@ export default {
empty: 'Vide',
emptySlot: 'Slot vide',
slotEmpty: 'Vide',
slotUnconfigured: '?',
emptySlotReset: 'Aucune bobine assignée',
unknown: 'Inconnu',
humidity: 'Humidité',
+1
View File
@@ -3960,6 +3960,7 @@ export default {
empty: 'Vuoto',
emptySlot: 'Slot vuoto',
slotEmpty: 'Vuoto',
slotUnconfigured: '?',
emptySlotReset: 'Nessuna bobina assegnata',
unknown: 'Sconosciuto',
humidity: 'Umidità',
+1
View File
@@ -3972,6 +3972,7 @@ export default {
empty: '<空>',
emptySlot: '空のスロット',
slotEmpty: '空',
slotUnconfigured: '?',
emptySlotReset: 'スプール未割当',
unknown: '不明',
humidity: '湿度',
+1
View File
@@ -3764,6 +3764,7 @@ export default {
used: '사용됨',
remainingUnit: '남음',
slotEmpty: '비어 있음',
slotUnconfigured: '?',
emptySlotReset: '필라멘트가 할당되지 않음'
},
printModal: {
+1
View File
@@ -3960,6 +3960,7 @@ export default {
empty: 'Vazio',
emptySlot: 'Slot vazio',
slotEmpty: 'Vazio',
slotUnconfigured: '?',
emptySlotReset: 'Nenhum carretel atribuído',
unknown: 'Desconhecido',
humidity: 'Umidade',
+1
View File
@@ -3949,6 +3949,7 @@ export default {
empty: 'Boş',
emptySlot: 'Boş yuva',
slotEmpty: 'Boş',
slotUnconfigured: '?',
emptySlotReset: 'Atanmış filament yok',
unknown: 'Bilinmiyor',
humidity: 'Nem',
+1
View File
@@ -3960,6 +3960,7 @@ export default {
empty: '空',
emptySlot: '空槽位',
slotEmpty: '空',
slotUnconfigured: '?',
emptySlotReset: '未分配料盘',
unknown: '未知',
humidity: '湿度',
+1
View File
@@ -3960,6 +3960,7 @@ export default {
empty: '空',
emptySlot: '空槽位',
slotEmpty: '空',
slotUnconfigured: '?',
emptySlotReset: '未指派線材',
unknown: '未知',
humidity: '濕度',
+2 -2
View File
@@ -3649,7 +3649,7 @@ function PrinterCard({
slotNumber={slotIdx + 1}
/>
<div className="text-[9px] text-white font-bold truncate">
{tray?.tray_type || t('ams.slotEmpty')}
{tray?.tray_type || t(emptyKind === 'reset' ? 'ams.slotUnconfigured' : 'ams.slotEmpty')}
</div>
{/* Fill bar */}
<div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
@@ -3977,7 +3977,7 @@ function PrinterCard({
slotNumber={1}
/>
<div className="text-[9px] text-white font-bold truncate">
{tray?.tray_type || t('ams.slotEmpty')}
{tray?.tray_type || t(emptyKind === 'reset' ? 'ams.slotUnconfigured' : 'ams.slotEmpty')}
</div>
{/* Fill bar */}
<div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">