fix(ams): confirm drying start instead of trusting the firmware ack (#2533)

The Start Drying button gave no feedback at all and reported success on the
strength of the MQTT ack alone. On a P1S the firmware answers
ams_filament_drying with result=success and then silently declines, and
P1-family firmware never publishes dry_sf_reason — so the #971 reason guard
is inert there and the card just sat unchanged.

Both drying mutations now toast on success. After a start, the card watches
the unit's dry_status/dry_time (straight from the info bitmask, updated on
every push); firmware reaches DryStatus 1 within seconds of a real start, so
still sitting at zero 30s later means the cycle never began. Bambuddy now
says so and names the two causes: AMS power adapter not connected, or the
printer not idle. Unlike the dry_sf_reason guard this is model-agnostic.
This commit is contained in:
maziggy
2026-07-11 08:37:57 +02:00
parent 50c3e94d33
commit ccbfcfa295
16 changed files with 406 additions and 127 deletions
@@ -0,0 +1,207 @@
/**
* Feedback for the AMS drying start/stop buttons (#2533).
*
* The reporter's P1S accepted `ams_filament_drying` with result=success and
* then never dried. Two gaps: the Start button gave no confirmation at all,
* and Bambuddy treated the MQTT ack as proof the cycle had begun. These tests
* cover the toast and the post-ack watcher that catches a printer which takes
* the command and drops it.
*/
import { describe, it, expect, beforeEach, afterEach, vi } 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 mockShowToast = vi.fn();
vi.mock('../../contexts/ToastContext', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
return { ...actual, useToast: () => ({ showToast: mockShowToast }) };
});
const mockPrinter = {
id: 1,
name: 'P1S',
ip_address: '192.168.1.100',
serial_number: '01P00A000000001',
access_code: '12345678',
model: 'P1S',
enabled: true,
nozzle_diameter: 0.4,
nozzle_type: 'stainless_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: 3,
};
/** AMS 2 Pro (n3f) on an idle printer — the reporter's hardware. */
function makeStatus(dry: { dry_time: number; dry_status: number }) {
return {
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: -29,
speed_level: 2,
supports_drying: true,
vt_tray: [],
ams: [
{
id: 0,
humidity: 30,
temp: 33,
is_ams_ht: false,
serial_number: 'AMS00',
sw_ver: '03.00.21.29',
dry_sub_status: 0,
dry_sf_reason: [],
module_type: 'n3f',
...dry,
tray: [
{ id: 0, ...baseTray },
{ id: 1, ...baseTray },
{ id: 2, ...baseTray },
{ id: 3, ...baseTray },
],
},
],
};
}
const IDLE = makeStatus({ dry_time: 0, dry_status: 0 });
const DRYING = makeStatus({ dry_time: 720, dry_status: 2 });
/**
* Open the drying popover and press Start. The card renders the AMS in two
* layouts, so the flame icon appears more than once — either opens the same
* popover.
*/
async function startDrying(user: ReturnType<typeof userEvent.setup>) {
await waitFor(() => {
expect(screen.getAllByTitle('Start Drying').length).toBeGreaterThan(0);
});
await user.click(screen.getAllByTitle('Start Drying')[0]);
await user.click(await screen.findByTestId('drying-start-confirm'));
}
describe('PrintersPage - AMS drying feedback (#2533)', () => {
beforeEach(() => {
mockShowToast.mockClear();
server.use(
http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
http.get('/api/v1/queue/', () => HttpResponse.json([])),
http.post('/api/v1/printers/:id/drying/start', () =>
HttpResponse.json({ status: 'drying_started', ams_id: 0, temp: 45, duration: 12 }),
),
http.post('/api/v1/printers/:id/drying/stop', () =>
HttpResponse.json({ status: 'drying_stopped', ams_id: 0 }),
),
);
});
afterEach(() => {
vi.useRealTimers();
});
it('confirms the start command with a toast', async () => {
const user = userEvent.setup();
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(IDLE)));
render(<PrintersPage />);
await startDrying(user);
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith('Drying started', 'success');
});
});
it('confirms the stop command with a toast', async () => {
const user = userEvent.setup();
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(DRYING)));
render(<PrintersPage />);
// While a cycle is live the same button becomes Stop Drying.
await waitFor(() => {
expect(screen.getAllByTitle('Stop Drying').length).toBeGreaterThan(0);
});
await user.click(screen.getAllByTitle('Stop Drying')[0]);
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith('Drying stopped', 'success');
});
});
it('warns when the printer acks the command but the AMS never starts drying', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Status keeps reporting dry_status 0 — the reporter's exact symptom.
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(IDLE)));
render(<PrintersPage />);
await startDrying(user);
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith('Drying started', 'success');
});
await vi.advanceTimersByTimeAsync(31_000);
expect(mockShowToast).toHaveBeenCalledWith(
'The printer accepted the command but the AMS never started drying. Check that the AMS power adapter is connected and that the printer is idle.',
'error',
);
});
it('stays quiet when the AMS does enter a drying cycle', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Idle until the command lands, then the AMS reports a live cycle — which is
// what the mutation's cache invalidation refetches.
let started = false;
server.use(
http.get('/api/v1/printers/:id/status', () => HttpResponse.json(started ? DRYING : IDLE)),
http.post('/api/v1/printers/:id/drying/start', () => {
started = true;
return HttpResponse.json({ status: 'drying_started', ams_id: 0, temp: 45, duration: 12 });
}),
);
render(<PrintersPage />);
await startDrying(user);
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith('Drying started', 'success');
});
await vi.advanceTimersByTimeAsync(31_000);
expect(mockShowToast).not.toHaveBeenCalledWith(expect.stringContaining('never started drying'), 'error');
});
});
+3
View File
@@ -569,6 +569,9 @@ export default {
notSupported: 'Trocknung nicht unterstützt',
powerRequired: 'AMS-Netzteil anschließen, um Trocknung zu aktivieren',
startingDrying: 'Trocknung wird gestartet...',
toastStarted: 'Trocknung gestartet',
toastStopped: 'Trocknung gestoppt',
toastNotStarted: 'Der Drucker hat den Befehl angenommen, aber das AMS hat die Trocknung nicht gestartet. Prüfe, ob das AMS-Netzteil angeschlossen ist und der Drucker im Leerlauf ist.',
stoppingDrying: 'Trocknung wird gestoppt...',
rotateTray: 'Spule während der Trocknung drehen',
rotateUnavailableReason: 'Nicht verfügbar — in diesem AMS ist ein Slot zum Druckkopf hin geladen. Die Spule ist durch den Zuführschlauch blockiert und kann nicht rotieren. Filament zuerst zurückziehen.',
+3
View File
@@ -572,6 +572,9 @@ export default {
notSupported: 'Drying not supported',
powerRequired: 'Connect AMS power adapter to enable drying',
startingDrying: 'Starting drying...',
toastStarted: 'Drying started',
toastStopped: 'Drying stopped',
toastNotStarted: 'The printer accepted the command but the AMS never started drying. Check that the AMS power adapter is connected and that the printer is idle.',
stoppingDrying: 'Stopping drying...',
rotateTray: 'Rotate spool during drying',
rotateUnavailableReason: 'Unavailable — a slot in this AMS is loaded to the toolhead. The spool is locked by the feed tube and cannot rotate. Retract the filament first.',
+3
View File
@@ -569,6 +569,9 @@ export default {
notSupported: 'Secado no compatible',
powerRequired: 'Conecte el adaptador de corriente del AMS para activar el secado',
startingDrying: 'Iniciando el secado...',
toastStarted: 'Secado iniciado',
toastStopped: 'Secado detenido',
toastNotStarted: 'La impresora aceptó el comando, pero el AMS no inició el secado. Comprueba que el adaptador de corriente del AMS esté conectado y que la impresora esté inactiva.',
stoppingDrying: 'Deteniendo el secado...',
rotateTray: 'Girar la bobina durante el secado',
rotateUnavailableReason: 'No disponible — un slot de este AMS está cargado hacia el cabezal. La bobina está bloqueada por el tubo de alimentación y no puede girar. Retira el filamento primero.',
+3
View File
@@ -569,6 +569,9 @@ export default {
notSupported: 'Séchage non pris en charge',
powerRequired: 'Brancher l\'adaptateur secteur AMS pour activer le séchage',
startingDrying: 'Démarrage du séchage...',
toastStarted: 'Séchage démarré',
toastStopped: 'Séchage arrêté',
toastNotStarted: 'L\'imprimante a accepté la commande, mais l\'AMS n\'a pas démarré le séchage. Vérifiez que l\'adaptateur secteur de l\'AMS est branché et que l\'imprimante est inactive.',
stoppingDrying: 'Arrêt du séchage...',
rotateTray: 'Tourner la bobine pendant le séchage',
rotateUnavailableReason: 'Indisponible — un emplacement de cet AMS est chargé vers la tête d\'impression. La bobine est bloquée par le tube d\'alimentation et ne peut pas tourner. Rétractez d\'abord le filament.',
+3
View File
@@ -569,6 +569,9 @@ export default {
notSupported: 'Essiccazione non supportata',
powerRequired: 'Collegare l\'alimentatore AMS per abilitare l\'asciugatura',
startingDrying: 'Avvio essiccazione...',
toastStarted: 'Essiccazione avviata',
toastStopped: 'Essiccazione interrotta',
toastNotStarted: 'La stampante ha accettato il comando, ma l\'AMS non ha avviato l\'essiccazione. Verifica che l\'alimentatore dell\'AMS sia collegato e che la stampante sia inattiva.',
stoppingDrying: 'Arresto essiccazione...',
rotateTray: 'Ruota la bobina durante l\'essiccazione',
rotateUnavailableReason: 'Non disponibile — uno slot di questo AMS è caricato verso la testa di stampa. La bobina è bloccata dal tubo di alimentazione e non può ruotare. Ritrai prima il filamento.',
+3
View File
@@ -568,6 +568,9 @@ export default {
notSupported: '乾燥非対応',
powerRequired: 'AMS電源アダプターを接続して乾燥を有効にしてください',
startingDrying: '乾燥を開始しています...',
toastStarted: '乾燥を開始しました',
toastStopped: '乾燥を停止しました',
toastNotStarted: 'プリンターはコマンドを受け付けましたが、AMS は乾燥を開始しませんでした。AMS の電源アダプターが接続されているか、プリンターがアイドル状態かを確認してください。',
stoppingDrying: '乾燥を停止しています...',
rotateTray: '乾燥中にスプールを回転',
rotateUnavailableReason: '利用不可 — このAMSのスロットがツールヘッドにロードされています。スプールが供給チューブで固定されているため回転できません。先にフィラメントを引き戻してください。',
+3
View File
@@ -532,6 +532,9 @@ export default {
notSupported: '건조 지원 안 됨',
powerRequired: '건조를 활성화하려면 AMS 전원 어댑터를 연결하세요',
startingDrying: '건조 시작 중...',
toastStarted: '건조를 시작했습니다',
toastStopped: '건조를 중지했습니다',
toastNotStarted: '프린터가 명령을 수락했지만 AMS가 건조를 시작하지 않았습니다. AMS 전원 어댑터가 연결되어 있는지, 프린터가 대기 상태인지 확인하십시오.',
stoppingDrying: '건조 정지 중...',
rotateTray: '건조 중 스풀 회전',
rotateUnavailableReason: '사용할 수 없음 — 이 AMS의 슬롯이 툴헤드로 로드되어 있습니다. 스풀이 공급 튜브에 의해 고정되어 회전할 수 없습니다. 먼저 필라멘트를 뺀 후 다시 시도하십시오.'
+3
View File
@@ -569,6 +569,9 @@ export default {
notSupported: 'Secagem não suportada',
powerRequired: 'Conecte o adaptador de energia AMS para ativar a secagem',
startingDrying: 'Iniciando secagem...',
toastStarted: 'Secagem iniciada',
toastStopped: 'Secagem interrompida',
toastNotStarted: 'A impressora aceitou o comando, mas o AMS não iniciou a secagem. Verifique se o adaptador de energia do AMS está conectado e se a impressora está ociosa.',
stoppingDrying: 'Parando secagem...',
rotateTray: 'Girar o carretel durante a secagem',
rotateUnavailableReason: 'Indisponível — um slot deste AMS está carregado em direção ao cabeçote. O carretel está travado pelo tubo de alimentação e não pode girar. Retraia o filamento primeiro.',
+3
View File
@@ -569,6 +569,9 @@ export default {
notSupported: 'Kurutma desteklenmiyor',
powerRequired: 'Kurutmayı etkinleştirmek için AMS güç adaptörünü bağlayın',
startingDrying: 'Kurutma başlatılıyor...',
toastStarted: 'Kurutma başlatıldı',
toastStopped: 'Kurutma durduruldu',
toastNotStarted: 'Yazıcı komutu kabul etti ancak AMS kurutmayı başlatmadı. AMS güç adaptörünün bağlı olduğundan ve yazıcının boşta olduğundan emin olun.',
stoppingDrying: 'Kurutma durduruluyor...',
rotateTray: 'Kurutma sırasında makarayı döndür',
rotateUnavailableReason: 'Kullanılamaz — bu AMS\'nin bir yuvası kafaya doğru yüklenmiş durumda. Makara besleme borusu tarafından kilitlendiği için döndürülemez. Önce filamenti geri çekin.',
+3
View File
@@ -569,6 +569,9 @@ export default {
notSupported: '不支持干燥',
powerRequired: '连接AMS电源适配器以启用干燥',
startingDrying: '正在启动干燥...',
toastStarted: '已开始干燥',
toastStopped: '已停止干燥',
toastNotStarted: '打印机已接受命令,但 AMS 未开始干燥。请检查 AMS 电源适配器是否已连接,以及打印机是否处于空闲状态。',
stoppingDrying: '正在停止干燥...',
rotateTray: '干燥时旋转料盘',
rotateUnavailableReason: '不可用 — 此 AMS 中有插槽已装入打印头。料盘被送料管固定,无法旋转。请先回退耗材。',
+3
View File
@@ -569,6 +569,9 @@ export default {
notSupported: '不支援乾燥',
powerRequired: '連線AMS電源介面卡以啟用乾燥',
startingDrying: '正在啟動乾燥...',
toastStarted: '已開始乾燥',
toastStopped: '已停止乾燥',
toastNotStarted: '印表機已接受命令,但 AMS 未開始乾燥。請檢查 AMS 電源變壓器是否已連接,以及印表機是否處於閒置狀態。',
stoppingDrying: '正在停止乾燥...',
rotateTray: '乾燥時旋轉料盤',
rotateUnavailableReason: '無法使用 — 此 AMS 中有插槽已裝入列印頭。料盤被進料管固定,無法旋轉。請先退回耗材。',
+39 -1
View File
@@ -875,6 +875,12 @@ function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | nu
return (tray?.state === 9 || tray?.state === 10) ? 'physical' : 'reset';
}
// How long to wait for an AMS to report a live drying cycle after the printer
// acked the start command (#2533). Firmware moves to DryStatus 1 (Checking)
// within a couple of seconds; 30s covers the slowest observed push cadence
// without leaving the user waiting on a verdict.
const DRY_START_CONFIRM_MS = 30_000;
function CoverImage({
url,
@@ -1843,6 +1849,10 @@ function PrinterCard({
const [dryingDuration, setDryingDuration] = useState(4);
const [dryingRotateTray, setDryingRotateTray] = useState(false);
const [dryingPopoverPos, setDryingPopoverPos] = useState<{ top: number; left: number } | null>(null);
// Which AMS we are waiting on to actually enter a drying cycle (#2533). Held as
// an object rather than a bare id so restarting drying on the SAME unit produces
// a new identity and rearms the timeout below.
const [dryStartWatch, setDryStartWatch] = useState<{ amsId: number } | null>(null);
const [isDraggingFile, setIsDraggingFile] = useState(false);
const [isDropUploading, setIsDropUploading] = useState(false);
const printerActionsMenuRef = useRef<HTMLDivElement>(null);
@@ -2056,6 +2066,29 @@ function PrinterCard({
}, [status?.ams]);
const amsData = (status?.ams && status.ams.length > 0) ? status.ams : cachedAmsData.current;
// Confirm a drying cycle actually started (#2533). Firmware answers
// ams_filament_drying with result=success even when it then silently declines,
// and P1-family firmware never publishes dry_sf_reason — so the server-side
// reason guard is blind there and the card just sits unchanged. Watch the unit
// after the ack: leaving DryStatus 0 means the cycle is live, staying there
// means the printer took the command and dropped it.
useEffect(() => {
if (!dryStartWatch) return;
const unit = amsData.find(a => a.id === dryStartWatch.amsId);
if (unit && (unit.dry_time > 0 || unit.dry_status > 0)) setDryStartWatch(null);
}, [dryStartWatch, amsData]);
// Deps deliberately exclude amsData: status pushes arrive continuously and would
// otherwise rearm the timeout on every frame, so it would never elapse.
useEffect(() => {
if (!dryStartWatch) return;
const timer = setTimeout(() => {
setDryStartWatch(null);
showToast(t('printers.drying.toastNotStarted'), 'error');
}, DRY_START_CONFIRM_MS);
return () => clearTimeout(timer);
}, [dryStartWatch, showToast, t]);
// Cache tray_now to prevent flickering when undefined values come in
// Valid tray IDs: 0-253 for AMS, 254 for external spool
// tray_now=255 means "no tray loaded" (Bambu protocol sentinel) — never active
@@ -2214,8 +2247,10 @@ function PrinterCard({
const startDryingMutation = useMutation({
mutationFn: ({ amsId, temp, duration, filament, rotateTray }: { amsId: number; temp: number; duration: number; filament: string; rotateTray: boolean }) =>
api.startDrying(printer.id, amsId, temp, duration, filament, rotateTray),
onSuccess: () => {
onSuccess: (_data, { amsId }) => {
setDryingPopoverAmsId(null);
setDryStartWatch({ amsId });
showToast(t('printers.drying.toastStarted'), 'success');
queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
},
onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
@@ -2224,6 +2259,8 @@ function PrinterCard({
const stopDryingMutation = useMutation({
mutationFn: (amsId: number) => api.stopDrying(printer.id, amsId),
onSuccess: () => {
setDryStartWatch(null);
showToast(t('printers.drying.toastStopped'), 'success');
queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
},
onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
@@ -6462,6 +6499,7 @@ function PrinterCard({
}
}}
disabled={startDryingMutation.isPending}
data-testid="drying-start-confirm"
className="w-full py-1.5 bg-bambu-green hover:bg-bambu-green/80 text-white text-xs font-medium rounded-lg transition-colors disabled:opacity-50"
>
{startDryingMutation.isPending ? t('printers.drying.startingDrying') : t('printers.drying.start')}