Просмотр исходного кода

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.
maziggy 1 месяц назад
Родитель
Сommit
ccbfcfa295

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 207 - 0
frontend/src/__tests__/pages/PrintersPageDryingFeedback.test.tsx

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -569,6 +569,9 @@ export default {
       notSupported: 'Trocknung nicht unterstützt',
       notSupported: 'Trocknung nicht unterstützt',
       powerRequired: 'AMS-Netzteil anschließen, um Trocknung zu aktivieren',
       powerRequired: 'AMS-Netzteil anschließen, um Trocknung zu aktivieren',
       startingDrying: 'Trocknung wird gestartet...',
       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...',
       stoppingDrying: 'Trocknung wird gestoppt...',
       rotateTray: 'Spule während der Trocknung drehen',
       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.',
       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 - 0
frontend/src/i18n/locales/en.ts

@@ -572,6 +572,9 @@ export default {
       notSupported: 'Drying not supported',
       notSupported: 'Drying not supported',
       powerRequired: 'Connect AMS power adapter to enable drying',
       powerRequired: 'Connect AMS power adapter to enable drying',
       startingDrying: 'Starting 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...',
       stoppingDrying: 'Stopping drying...',
       rotateTray: 'Rotate spool during 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.',
       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 - 0
frontend/src/i18n/locales/es.ts

@@ -569,6 +569,9 @@ export default {
       notSupported: 'Secado no compatible',
       notSupported: 'Secado no compatible',
       powerRequired: 'Conecte el adaptador de corriente del AMS para activar el secado',
       powerRequired: 'Conecte el adaptador de corriente del AMS para activar el secado',
       startingDrying: 'Iniciando 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...',
       stoppingDrying: 'Deteniendo el secado...',
       rotateTray: 'Girar la bobina durante 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.',
       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 - 0
frontend/src/i18n/locales/fr.ts

@@ -569,6 +569,9 @@ export default {
       notSupported: 'Séchage non pris en charge',
       notSupported: 'Séchage non pris en charge',
       powerRequired: 'Brancher l\'adaptateur secteur AMS pour activer le séchage',
       powerRequired: 'Brancher l\'adaptateur secteur AMS pour activer le séchage',
       startingDrying: 'Démarrage du 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...',
       stoppingDrying: 'Arrêt du séchage...',
       rotateTray: 'Tourner la bobine pendant le 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.',
       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 - 0
frontend/src/i18n/locales/it.ts

@@ -569,6 +569,9 @@ export default {
       notSupported: 'Essiccazione non supportata',
       notSupported: 'Essiccazione non supportata',
       powerRequired: 'Collegare l\'alimentatore AMS per abilitare l\'asciugatura',
       powerRequired: 'Collegare l\'alimentatore AMS per abilitare l\'asciugatura',
       startingDrying: 'Avvio essiccazione...',
       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...',
       stoppingDrying: 'Arresto essiccazione...',
       rotateTray: 'Ruota la bobina durante l\'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.',
       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 - 0
frontend/src/i18n/locales/ja.ts

@@ -568,6 +568,9 @@ export default {
       notSupported: '乾燥非対応',
       notSupported: '乾燥非対応',
       powerRequired: 'AMS電源アダプターを接続して乾燥を有効にしてください',
       powerRequired: 'AMS電源アダプターを接続して乾燥を有効にしてください',
       startingDrying: '乾燥を開始しています...',
       startingDrying: '乾燥を開始しています...',
+      toastStarted: '乾燥を開始しました',
+      toastStopped: '乾燥を停止しました',
+      toastNotStarted: 'プリンターはコマンドを受け付けましたが、AMS は乾燥を開始しませんでした。AMS の電源アダプターが接続されているか、プリンターがアイドル状態かを確認してください。',
       stoppingDrying: '乾燥を停止しています...',
       stoppingDrying: '乾燥を停止しています...',
       rotateTray: '乾燥中にスプールを回転',
       rotateTray: '乾燥中にスプールを回転',
       rotateUnavailableReason: '利用不可 — このAMSのスロットがツールヘッドにロードされています。スプールが供給チューブで固定されているため回転できません。先にフィラメントを引き戻してください。',
       rotateUnavailableReason: '利用不可 — このAMSのスロットがツールヘッドにロードされています。スプールが供給チューブで固定されているため回転できません。先にフィラメントを引き戻してください。',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -532,6 +532,9 @@ export default {
       notSupported: '건조 지원 안 됨',
       notSupported: '건조 지원 안 됨',
       powerRequired: '건조를 활성화하려면 AMS 전원 어댑터를 연결하세요',
       powerRequired: '건조를 활성화하려면 AMS 전원 어댑터를 연결하세요',
       startingDrying: '건조 시작 중...',
       startingDrying: '건조 시작 중...',
+      toastStarted: '건조를 시작했습니다',
+      toastStopped: '건조를 중지했습니다',
+      toastNotStarted: '프린터가 명령을 수락했지만 AMS가 건조를 시작하지 않았습니다. AMS 전원 어댑터가 연결되어 있는지, 프린터가 대기 상태인지 확인하십시오.',
       stoppingDrying: '건조 정지 중...',
       stoppingDrying: '건조 정지 중...',
       rotateTray: '건조 중 스풀 회전',
       rotateTray: '건조 중 스풀 회전',
       rotateUnavailableReason: '사용할 수 없음 — 이 AMS의 슬롯이 툴헤드로 로드되어 있습니다. 스풀이 공급 튜브에 의해 고정되어 회전할 수 없습니다. 먼저 필라멘트를 뺀 후 다시 시도하십시오.'
       rotateUnavailableReason: '사용할 수 없음 — 이 AMS의 슬롯이 툴헤드로 로드되어 있습니다. 스풀이 공급 튜브에 의해 고정되어 회전할 수 없습니다. 먼저 필라멘트를 뺀 후 다시 시도하십시오.'

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -569,6 +569,9 @@ export default {
       notSupported: 'Secagem não suportada',
       notSupported: 'Secagem não suportada',
       powerRequired: 'Conecte o adaptador de energia AMS para ativar a secagem',
       powerRequired: 'Conecte o adaptador de energia AMS para ativar a secagem',
       startingDrying: 'Iniciando 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...',
       stoppingDrying: 'Parando secagem...',
       rotateTray: 'Girar o carretel durante a 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.',
       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 - 0
frontend/src/i18n/locales/tr.ts

@@ -569,6 +569,9 @@ export default {
       notSupported: 'Kurutma desteklenmiyor',
       notSupported: 'Kurutma desteklenmiyor',
       powerRequired: 'Kurutmayı etkinleştirmek için AMS güç adaptörünü bağlayın',
       powerRequired: 'Kurutmayı etkinleştirmek için AMS güç adaptörünü bağlayın',
       startingDrying: 'Kurutma başlatılıyor...',
       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...',
       stoppingDrying: 'Kurutma durduruluyor...',
       rotateTray: 'Kurutma sırasında makarayı döndür',
       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.',
       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 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -569,6 +569,9 @@ export default {
       notSupported: '不支持干燥',
       notSupported: '不支持干燥',
       powerRequired: '连接AMS电源适配器以启用干燥',
       powerRequired: '连接AMS电源适配器以启用干燥',
       startingDrying: '正在启动干燥...',
       startingDrying: '正在启动干燥...',
+      toastStarted: '已开始干燥',
+      toastStopped: '已停止干燥',
+      toastNotStarted: '打印机已接受命令,但 AMS 未开始干燥。请检查 AMS 电源适配器是否已连接,以及打印机是否处于空闲状态。',
       stoppingDrying: '正在停止干燥...',
       stoppingDrying: '正在停止干燥...',
       rotateTray: '干燥时旋转料盘',
       rotateTray: '干燥时旋转料盘',
       rotateUnavailableReason: '不可用 — 此 AMS 中有插槽已装入打印头。料盘被送料管固定,无法旋转。请先回退耗材。',
       rotateUnavailableReason: '不可用 — 此 AMS 中有插槽已装入打印头。料盘被送料管固定,无法旋转。请先回退耗材。',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -569,6 +569,9 @@ export default {
       notSupported: '不支援乾燥',
       notSupported: '不支援乾燥',
       powerRequired: '連線AMS電源介面卡以啟用乾燥',
       powerRequired: '連線AMS電源介面卡以啟用乾燥',
       startingDrying: '正在啟動乾燥...',
       startingDrying: '正在啟動乾燥...',
+      toastStarted: '已開始乾燥',
+      toastStopped: '已停止乾燥',
+      toastNotStarted: '印表機已接受命令,但 AMS 未開始乾燥。請檢查 AMS 電源變壓器是否已連接,以及印表機是否處於閒置狀態。',
       stoppingDrying: '正在停止乾燥...',
       stoppingDrying: '正在停止乾燥...',
       rotateTray: '乾燥時旋轉料盤',
       rotateTray: '乾燥時旋轉料盤',
       rotateUnavailableReason: '無法使用 — 此 AMS 中有插槽已裝入列印頭。料盤被進料管固定,無法旋轉。請先退回耗材。',
       rotateUnavailableReason: '無法使用 — 此 AMS 中有插槽已裝入列印頭。料盤被進料管固定,無法旋轉。請先退回耗材。',

+ 39 - 1
frontend/src/pages/PrintersPage.tsx

@@ -875,6 +875,12 @@ function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | nu
   return (tray?.state === 9 || tray?.state === 10) ? 'physical' : 'reset';
   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({
 function CoverImage({
   url,
   url,
@@ -1843,6 +1849,10 @@ function PrinterCard({
   const [dryingDuration, setDryingDuration] = useState(4);
   const [dryingDuration, setDryingDuration] = useState(4);
   const [dryingRotateTray, setDryingRotateTray] = useState(false);
   const [dryingRotateTray, setDryingRotateTray] = useState(false);
   const [dryingPopoverPos, setDryingPopoverPos] = useState<{ top: number; left: number } | null>(null);
   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 [isDraggingFile, setIsDraggingFile] = useState(false);
   const [isDropUploading, setIsDropUploading] = useState(false);
   const [isDropUploading, setIsDropUploading] = useState(false);
   const printerActionsMenuRef = useRef<HTMLDivElement>(null);
   const printerActionsMenuRef = useRef<HTMLDivElement>(null);
@@ -2056,6 +2066,29 @@ function PrinterCard({
   }, [status?.ams]);
   }, [status?.ams]);
   const amsData = (status?.ams && status.ams.length > 0) ? status.ams : cachedAmsData.current;
   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
   // Cache tray_now to prevent flickering when undefined values come in
   // Valid tray IDs: 0-253 for AMS, 254 for external spool
   // Valid tray IDs: 0-253 for AMS, 254 for external spool
   // tray_now=255 means "no tray loaded" (Bambu protocol sentinel) — never active
   // tray_now=255 means "no tray loaded" (Bambu protocol sentinel) — never active
@@ -2214,8 +2247,10 @@ function PrinterCard({
   const startDryingMutation = useMutation({
   const startDryingMutation = useMutation({
     mutationFn: ({ amsId, temp, duration, filament, rotateTray }: { amsId: number; temp: number; duration: number; filament: string; rotateTray: boolean }) =>
     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),
       api.startDrying(printer.id, amsId, temp, duration, filament, rotateTray),
-    onSuccess: () => {
+    onSuccess: (_data, { amsId }) => {
       setDryingPopoverAmsId(null);
       setDryingPopoverAmsId(null);
+      setDryStartWatch({ amsId });
+      showToast(t('printers.drying.toastStarted'), 'success');
       queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
       queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
     },
     },
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
@@ -2224,6 +2259,8 @@ function PrinterCard({
   const stopDryingMutation = useMutation({
   const stopDryingMutation = useMutation({
     mutationFn: (amsId: number) => api.stopDrying(printer.id, amsId),
     mutationFn: (amsId: number) => api.stopDrying(printer.id, amsId),
     onSuccess: () => {
     onSuccess: () => {
+      setDryStartWatch(null);
+      showToast(t('printers.drying.toastStopped'), 'success');
       queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
       queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
     },
     },
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
@@ -6462,6 +6499,7 @@ function PrinterCard({
                     }
                     }
                   }}
                   }}
                   disabled={startDryingMutation.isPending}
                   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"
                   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')}
                   {startDryingMutation.isPending ? t('printers.drying.startingDrying') : t('printers.drying.start')}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DMFedMEr.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CWda6lvk.js"></script>
+    <script type="module" crossorigin src="/assets/index-DMFedMEr.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-blSspT6K.css">
     <link rel="stylesheet" crossorigin href="/assets/index-blSspT6K.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов