feat(printers): AMS slot Load / Unload from the printer card (#891)

The ams_load_filament / ams_unload_filament MQTT primitives existed
  in bambu_mqtt.py but were unused — no HTTP route and no UI. Surface
  both as POST /printers/{id}/ams/load?tray_id={int} and
  POST /printers/{id}/ams/unload, gated on PRINTERS_CONTROL.

  Wire them into the existing AMS slot popover (next to "Re-read RFID")
  and add a popover wrapper on the external spool slot which had none.
  Hidden while the printer is RUNNING, mirroring the RFID re-read
  gating. Both buttons enabled when permission is granted; the printer
  no-ops gracefully if there's nothing to do (matches BambuStudio).

  Dual-extruder H2D Ext-R support is the trickier piece. The existing
  ams_load_filament(254) capture came from a single-extruder printer
  and used slot_id=254, curr/tar=-1. Captured the Ext-R command from
  BambuStudio fresh: it sends ams_id=255, slot_id=0 (the right
  extruder index, NOT a slot index), target=255, and curr/tar = the
  actual right-nozzle temp (read from state.temperatures["nozzle_2"],
  falling back to 215 °C if cold so the printer doesn't reject the
  command on a nonsensical temp). Added that as a new branch in
  ams_load_filament; the existing tray_id=254 branch is preserved
  verbatim — no risk of regression on single-external setups.
This commit is contained in:
maziggy
2026-05-02 12:10:09 +02:00
parent 459cfdc51f
commit 3320c7fd45
19 changed files with 951 additions and 169 deletions
@@ -0,0 +1,220 @@
/**
* Tests for the AMS slot load / unload buttons on PrintersPage (#891).
*
* Verifies that the menu in each AMS slot popover exposes Load and Unload,
* that clicking them POSTs to the right endpoint with the right tray_id, and
* that the buttons are hidden while the printer is RUNNING.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { PrintersPage } from '../../pages/PrintersPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockPrinter = {
id: 1,
name: 'X1 Carbon',
ip_address: '192.168.1.100',
serial_number: '00M09A350100001',
access_code: '12345678',
model: 'X1C',
enabled: true,
nozzle_diameter: 0.4,
nozzle_type: 'hardened_steel',
location: 'Workshop',
auto_archive: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
const baseTray = {
tray_color: 'FF0000FF',
tray_type: 'PLA',
tray_sub_brands: 'PLA Basic',
tray_id_name: 'A00-R0',
tray_info_idx: 'GFA00',
remain: 80,
k: 0.02,
cali_idx: null,
tag_uid: null,
tray_uuid: null,
nozzle_temp_min: 190,
nozzle_temp_max: 230,
drying_temp: null,
drying_time: null,
state: 11,
};
const mockIdleStatusWithAms = {
connected: true,
state: 'IDLE',
progress: 0,
layer_num: 0,
total_layers: 0,
temperatures: { nozzle: 25, bed: 25, chamber: 25 },
remaining_time: 0,
filename: null,
wifi_signal: -50,
speed_level: 2,
vt_tray: [],
ams: [
{
id: 0,
humidity: 30,
temp: 25,
is_ams_ht: false,
serial_number: 'AMS00',
sw_ver: '1.0.0',
dry_time: 0,
dry_status: 0,
dry_sub_status: 0,
dry_sf_reason: [],
module_type: 'ams',
tray: [
{ id: 0, ...baseTray },
{ id: 1, ...baseTray, tray_color: '00FF00FF', tray_type: 'PETG' },
{ id: 2, ...baseTray, tray_color: '0000FFFF', tray_type: 'ABS' },
{ id: 3, ...baseTray, tray_color: 'FFFF00FF', tray_type: 'TPU' },
],
},
],
};
const mockRunningStatus = {
...mockIdleStatusWithAms,
state: 'RUNNING',
};
describe('PrintersPage - AMS load/unload (#891)', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
http.get('/api/v1/queue/', () => HttpResponse.json([])),
);
});
it('Load posts to /ams/load with tray_id derived from amsId*4 + slot', async () => {
const user = userEvent.setup();
let captured: { tray_id: string | null } | null = null;
server.use(
http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockIdleStatusWithAms)),
http.post('/api/v1/printers/:id/ams/load', ({ request }) => {
const url = new URL(request.url);
captured = { tray_id: url.searchParams.get('tray_id') };
return HttpResponse.json({ success: true, message: 'Loading filament from AMS 0 slot 3' });
}),
);
render(<PrintersPage />);
await waitFor(() => {
// The slot menu button is hidden until we hover. Pull it directly out of the DOM.
expect(document.querySelectorAll('[title="Slot options"]').length).toBeGreaterThan(0);
});
// Slot 2 (third one, slotIdx=2) → expected tray_id = 0*4 + 2 = 2
const menuButtons = document.querySelectorAll<HTMLButtonElement>('[title="Slot options"]');
await user.click(menuButtons[2]);
await waitFor(() => {
expect(screen.getByText('Load')).toBeInTheDocument();
});
await user.click(screen.getByText('Load'));
await waitFor(() => {
expect(captured).not.toBeNull();
expect(captured!.tray_id).toBe('2');
});
});
it('Unload posts to /ams/unload (no body, no params)', async () => {
const user = userEvent.setup();
let unloadCalled = false;
server.use(
http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockIdleStatusWithAms)),
http.post('/api/v1/printers/:id/ams/unload', () => {
unloadCalled = true;
return HttpResponse.json({ success: true, message: 'Unloading filament' });
}),
);
render(<PrintersPage />);
await waitFor(() => {
expect(document.querySelectorAll('[title="Slot options"]').length).toBeGreaterThan(0);
});
const menuButtons = document.querySelectorAll<HTMLButtonElement>('[title="Slot options"]');
await user.click(menuButtons[0]);
await waitFor(() => {
expect(screen.getByText('Unload')).toBeInTheDocument();
});
await user.click(screen.getByText('Unload'));
await waitFor(() => {
expect(unloadCalled).toBe(true);
});
});
it('hides the slot menu while the printer is RUNNING', async () => {
server.use(
http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockRunningStatus)),
);
render(<PrintersPage />);
// Wait for the page to render the printer card.
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
// No "Slot options" menu trigger should be present at all while running.
expect(document.querySelectorAll('[title="Slot options"]').length).toBe(0);
});
it('external spool slot exposes Load and posts tray_id=254', async () => {
const user = userEvent.setup();
let captured: string | null = null;
server.use(
http.get('/api/v1/printers/:id/status', () =>
HttpResponse.json({
...mockIdleStatusWithAms,
ams: [], // external-only
vt_tray: [{ id: 254, ...baseTray, tray_type: 'PLA', tray_color: 'FFFFFFFF' }],
}),
),
http.post('/api/v1/printers/:id/ams/load', ({ request }) => {
captured = new URL(request.url).searchParams.get('tray_id');
return HttpResponse.json({ success: true, message: 'Loading filament from external spool' });
}),
);
render(<PrintersPage />);
await waitFor(() => {
expect(document.querySelectorAll('[title="Slot options"]').length).toBeGreaterThan(0);
});
const menuButtons = document.querySelectorAll<HTMLButtonElement>('[title="Slot options"]');
await user.click(menuButtons[0]);
await waitFor(() => {
expect(screen.getByText('Load')).toBeInTheDocument();
});
await user.click(screen.getByText('Load'));
await waitFor(() => {
expect(captured).toBe('254');
});
});
});
+14
View File
@@ -3095,6 +3095,20 @@ export const api = {
{ method: 'POST' }
),
// Load filament from a tray. trayId: 0-15 for AMS (amsId*4+slotId), 254 for external spool.
loadAmsTray: (printerId: number, trayId: number) =>
request<{ success: boolean; message: string }>(
`/printers/${printerId}/ams/load?tray_id=${trayId}`,
{ method: 'POST' }
),
// Unload the currently loaded filament.
unloadAms: (printerId: number) =>
request<{ success: boolean; message: string }>(
`/printers/${printerId}/ams/unload`,
{ method: 'POST' }
),
// MQTT Debug Logging
enableMQTTLogging: (printerId: number) =>
request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/enable`, {
+9
View File
@@ -311,6 +311,10 @@ export default {
calibrationSaved: 'Kalibrierung gespeichert!',
calibrationFailed: 'Kalibrierung fehlgeschlagen',
rfidRereadInitiated: 'RFID-Neueinlesen gestartet',
loadInitiated: 'Filament wird geladen…',
unloadInitiated: 'Filament wird entladen…',
failedToLoad: 'Filament konnte nicht geladen werden',
failedToUnload: 'Filament konnte nicht entladen werden',
},
// Connection status
connection: {
@@ -334,6 +338,11 @@ export default {
rfid: {
reread: 'RFID neu lesen',
},
// AMS Laden/Entladen (#891)
ams: {
load: 'Laden',
unload: 'Entladen',
},
bedJog: {
title: 'Druckbett bewegen',
bed: 'Bett',
+9
View File
@@ -311,6 +311,10 @@ export default {
calibrationSaved: 'Calibration saved!',
calibrationFailed: 'Calibration failed',
rfidRereadInitiated: 'RFID re-read initiated',
loadInitiated: 'Loading filament…',
unloadInitiated: 'Unloading filament…',
failedToLoad: 'Failed to load filament',
failedToUnload: 'Failed to unload filament',
},
// Connection status
connection: {
@@ -334,6 +338,11 @@ export default {
rfid: {
reread: 'Re-read RFID',
},
// AMS load/unload (#891)
ams: {
load: 'Load',
unload: 'Unload',
},
bedJog: {
title: 'Move build plate',
bed: 'Bed',
+9
View File
@@ -311,6 +311,10 @@ export default {
calibrationSaved: 'Calibration enregistrée !',
calibrationFailed: 'Échec de la calibration',
rfidRereadInitiated: 'Lecture RFID initiée',
loadInitiated: 'Loading filament…',
unloadInitiated: 'Unloading filament…',
failedToLoad: 'Failed to load filament',
failedToUnload: 'Failed to unload filament',
},
// Connection status
connection: {
@@ -334,6 +338,11 @@ export default {
rfid: {
reread: 'Relire RFID',
},
// AMS load/unload (#891)
ams: {
load: 'Load',
unload: 'Unload',
},
bedJog: {
title: 'Déplacer le plateau',
bed: 'Plateau',
+9
View File
@@ -311,6 +311,10 @@ export default {
calibrationSaved: 'Calibrazione salvata!',
calibrationFailed: 'Calibrazione non riuscita',
rfidRereadInitiated: 'Rilettura RFID avviata',
loadInitiated: 'Loading filament…',
unloadInitiated: 'Unloading filament…',
failedToLoad: 'Failed to load filament',
failedToUnload: 'Failed to unload filament',
},
// Connection status
connection: {
@@ -334,6 +338,11 @@ export default {
rfid: {
reread: 'Rileggi RFID',
},
// AMS load/unload (#891)
ams: {
load: 'Load',
unload: 'Unload',
},
bedJog: {
title: 'Muovi il piano di stampa',
bed: 'Piano',
+9
View File
@@ -310,6 +310,10 @@ export default {
calibrationSaved: 'キャリブレーションを保存しました!',
calibrationFailed: 'キャリブレーションに失敗しました',
rfidRereadInitiated: 'RFID再読み取りを開始しました',
loadInitiated: 'Loading filament…',
unloadInitiated: 'Unloading filament…',
failedToLoad: 'Failed to load filament',
failedToUnload: 'Failed to unload filament',
},
// Connection status
connection: {
@@ -333,6 +337,11 @@ export default {
rfid: {
reread: 'RFID再読み取り',
},
// AMS load/unload (#891)
ams: {
load: 'Load',
unload: 'Unload',
},
bedJog: {
title: 'ビルドプレートを移動',
bed: 'ベッド',
+9
View File
@@ -311,6 +311,10 @@ export default {
calibrationSaved: 'Calibração salva!',
calibrationFailed: 'Falha na calibração',
rfidRereadInitiated: 'Releitura de RFID iniciada',
loadInitiated: 'Loading filament…',
unloadInitiated: 'Unloading filament…',
failedToLoad: 'Failed to load filament',
failedToUnload: 'Failed to unload filament',
},
// Connection status
connection: {
@@ -334,6 +338,11 @@ export default {
rfid: {
reread: 'Releitura de RFID',
},
// AMS load/unload (#891)
ams: {
load: 'Load',
unload: 'Unload',
},
bedJog: {
title: 'Mover a mesa de impressão',
bed: 'Mesa',
+9
View File
@@ -311,6 +311,10 @@ export default {
calibrationSaved: '校准已保存!',
calibrationFailed: '校准失败',
rfidRereadInitiated: '已发起 RFID 重新读取',
loadInitiated: 'Loading filament…',
unloadInitiated: 'Unloading filament…',
failedToLoad: 'Failed to load filament',
failedToUnload: 'Failed to unload filament',
},
// Connection status
connection: {
@@ -334,6 +338,11 @@ export default {
rfid: {
reread: '重新读取 RFID',
},
// AMS load/unload (#891)
ams: {
load: 'Load',
unload: 'Unload',
},
bedJog: {
title: '移动热床',
bed: '热床',
+9
View File
@@ -311,6 +311,10 @@ export default {
calibrationSaved: '校準已儲存!',
calibrationFailed: '校準失敗',
rfidRereadInitiated: '已發起 RFID 重新讀取',
loadInitiated: 'Loading filament…',
unloadInitiated: 'Unloading filament…',
failedToLoad: 'Failed to load filament',
failedToUnload: 'Failed to unload filament',
},
// Connection status
connection: {
@@ -334,6 +338,11 @@ export default {
rfid: {
reread: '重新讀取 RFID',
},
// AMS load/unload (#891)
ams: {
load: 'Load',
unload: 'Unload',
},
bedJog: {
title: '移動熱床',
bed: '熱床',
+152
View File
@@ -55,6 +55,8 @@ import {
DoorOpen,
DoorClosed,
MoveVertical,
LogIn,
LogOut,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
@@ -1997,6 +1999,27 @@ function PrinterCard({
},
});
// AMS load/unload mutations (#891)
const loadAmsTrayMutation = useMutation({
mutationFn: ({ trayId }: { trayId: number }) => api.loadAmsTray(printer.id, trayId),
onSuccess: (data) => {
showToast(data.message || t('printers.toast.loadInitiated'));
},
onError: (error: Error) => {
showToast(error.message || t('printers.toast.failedToLoad'), 'error');
},
});
const unloadAmsMutation = useMutation({
mutationFn: () => api.unloadAms(printer.id),
onSuccess: (data) => {
showToast(data.message || t('printers.toast.unloadInitiated'));
},
onError: (error: Error) => {
showToast(error.message || t('printers.toast.failedToUnload'), 'error');
},
});
// Plate references state
const [plateReferences, setPlateReferences] = useState<{
references: Array<{ index: number; label: string; timestamp: string; has_image: boolean; thumbnail_url: string }>;
@@ -3534,6 +3557,42 @@ function PrinterCard({
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
{t('printers.rfid.reread')}
</button>
<button
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
hasPermission('printers:control')
? 'text-white hover:bg-bambu-dark-tertiary'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
onClick={(e) => {
e.stopPropagation();
if (!hasPermission('printers:control')) return;
loadAmsTrayMutation.mutate({ trayId: ams.id * 4 + slotIdx });
setAmsSlotMenu(null);
}}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
>
<LogIn className="w-3 h-3" />
{t('printers.ams.load')}
</button>
<button
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
hasPermission('printers:control')
? 'text-white hover:bg-bambu-dark-tertiary'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
onClick={(e) => {
e.stopPropagation();
if (!hasPermission('printers:control')) return;
unloadAmsMutation.mutate();
setAmsSlotMenu(null);
}}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
>
<LogOut className="w-3 h-3" />
{t('printers.ams.unload')}
</button>
</div>
)}
{/* Hover card wraps only the visual content */}
@@ -3840,6 +3899,42 @@ function PrinterCard({
<RefreshCw className={`w-3 h-3 ${isHtRefreshing ? 'animate-spin' : ''}`} />
{t('printers.rfid.reread')}
</button>
<button
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
hasPermission('printers:control')
? 'text-white hover:bg-bambu-dark-tertiary'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
onClick={(e) => {
e.stopPropagation();
if (!hasPermission('printers:control')) return;
loadAmsTrayMutation.mutate({ trayId: ams.id * 4 + htSlotId });
setAmsSlotMenu(null);
}}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
>
<LogIn className="w-3 h-3" />
{t('printers.ams.load')}
</button>
<button
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
hasPermission('printers:control')
? 'text-white hover:bg-bambu-dark-tertiary'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
onClick={(e) => {
e.stopPropagation();
if (!hasPermission('printers:control')) return;
unloadAmsMutation.mutate();
setAmsSlotMenu(null);
}}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
>
<LogOut className="w-3 h-3" />
{t('printers.ams.unload')}
</button>
</div>
)}
{/* Hover card wraps only the visual content */}
@@ -4043,8 +4138,65 @@ function PrinterCard({
</div>
);
const extMenuKey = 255 * 10 + slotTrayId; // unique slotId space for external menu state
const isExtMenuOpen = amsSlotMenu?.amsId === 255 && amsSlotMenu?.slotId === extMenuKey;
return (
<div key={extTrayId} className="relative group">
{/* Menu button - appears on hover, hidden when printer busy */}
{status?.state !== 'RUNNING' && (
<button
onClick={(e) => {
e.stopPropagation();
setAmsSlotMenu(isExtMenuOpen ? null : { amsId: 255, slotId: extMenuKey });
}}
className="absolute -top-1 -right-1 w-4 h-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10 hover:bg-bambu-dark-tertiary"
title={t('printers.slotOptions')}
>
<MoreVertical className="w-2.5 h-2.5 text-bambu-gray" />
</button>
)}
{/* Dropdown menu */}
{status?.state !== 'RUNNING' && isExtMenuOpen && (
<div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
<button
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
hasPermission('printers:control')
? 'text-white hover:bg-bambu-dark-tertiary'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
onClick={(e) => {
e.stopPropagation();
if (!hasPermission('printers:control')) return;
loadAmsTrayMutation.mutate({ trayId: extTrayId });
setAmsSlotMenu(null);
}}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
>
<LogIn className="w-3 h-3" />
{t('printers.ams.load')}
</button>
<button
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
hasPermission('printers:control')
? 'text-white hover:bg-bambu-dark-tertiary'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
onClick={(e) => {
e.stopPropagation();
if (!hasPermission('printers:control')) return;
unloadAmsMutation.mutate();
setAmsSlotMenu(null);
}}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
>
<LogOut className="w-3 h-3" />
{t('printers.ams.unload')}
</button>
</div>
)}
{!isEmpty ? (
<FilamentHoverCard
data={extFilamentData}