Explorar o código

(Post work): parked AMS drying timers and translated drying failures (issue #2896)

maziggy hai 1 día
pai
achega
b04d753186
Modificáronse 35 ficheiros con 140 adicións e 8 borrados
  1. 1 0
      CHANGELOG.md
  2. 5 0
      backend/app/core/database.py
  3. 4 0
      backend/app/models/scheduled_drying.py
  4. 1 0
      backend/app/schemas/scheduled_drying.py
  5. 10 0
      backend/app/services/drying_preflight.py
  6. 3 1
      backend/app/services/print_scheduler.py
  7. 20 0
      backend/tests/unit/test_scheduled_drying_routes.py
  8. 16 0
      backend/tests/unit/test_scheduler_scheduled_drying.py
  9. 45 0
      frontend/src/__tests__/pages/PrintersPageDryingStartModes.test.tsx
  10. 3 0
      frontend/src/api/client.ts
  11. 1 0
      frontend/src/i18n/locales/de.ts
  12. 1 0
      frontend/src/i18n/locales/en.ts
  13. 1 0
      frontend/src/i18n/locales/es.ts
  14. 1 0
      frontend/src/i18n/locales/fr.ts
  15. 1 0
      frontend/src/i18n/locales/it.ts
  16. 1 0
      frontend/src/i18n/locales/ja.ts
  17. 1 0
      frontend/src/i18n/locales/ko.ts
  18. 1 0
      frontend/src/i18n/locales/nl.ts
  19. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  20. 1 0
      frontend/src/i18n/locales/ru.ts
  21. 1 0
      frontend/src/i18n/locales/sv.ts
  22. 1 0
      frontend/src/i18n/locales/tr.ts
  23. 1 0
      frontend/src/i18n/locales/uk.ts
  24. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  25. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  26. 15 1
      frontend/src/pages/PrintersPage.tsx
  27. 0 1
      static/assets/PdfPreviewModal-Bxccllsw.js
  28. 0 1
      static/assets/PdfPreviewModal-K-KyO7Oy.js
  29. 0 0
      static/assets/SpreadsheetPreviewModal-BGgih2vi.js
  30. 0 1
      static/assets/SpreadsheetPreviewModal-CsevFqb3.js
  31. 0 1
      static/assets/index-82a1Eqef.js
  32. 1 1
      static/assets/index-CJGI4Fgw.js
  33. 0 0
      static/assets/pdf-BFNJieqg.js
  34. 0 0
      static/assets/pdf-Ce2j8LB_.js
  35. 1 1
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -45,6 +45,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A drying command the AMS never started no longer shows as an active cycle, or holds the queue forever (#2896, reported and contributed by @M2ABRAMSTANK in #3096)** — A printer can accept an AMS drying command and set its timer without ever starting the cycle. On the reporter's H2D, mid-print, two AMS-HT units were counting down and heating while a third sat at 720 minutes, cold, and its card still showed the amber drying badge. Bambuddy now notices when a unit's countdown hasn't moved for 150 seconds while the AMS reports no drying phase. A live countdown moves once a minute, so a real cycle is never mistaken for one. The card then shows a grey **Drying not running** badge instead, without the countdown and with Stop still available. It says "not running" rather than "not started" because a cycle paused partway looks the same. If the AMS does report checking, drying or cooling, it always counts as drying. The same timer also never reaches zero, so everything that waited for drying to end waited for good. With **Block queue until drying finishes** on, the queue held that printer indefinitely. A scheduled drying run stayed "running" forever, and the AMS temperature alarm stayed muted on a unit that wasn't heating. Now a printer whose only timers are parked doesn't hold the queue, and doesn't make a scheduled run wait. A scheduled run whose timer parks goes back to the queue if a print is running. On an idle printer it fails with the reason "the printer accepted the command, but the AMS did not start drying". In both cases the timer is left on the printer, since it may still start once power frees up. The temperature alarm treats a parked unit as not drying, and the printer card no longer counts one when deciding whether a dropped file prints now or queues. A failed scheduled drying run now shows its reason in your language instead of in English. Translated in all locales.
 - **Saving an empty value for a number setting through the API no longer breaks the Settings page** — A direct API call, script or Home Assistant `rest_command` could send `null` for a number setting such as the keep-warm bed temperature. Bambuddy stored it as the text "None", and from then on every load of the settings failed with an error, for every setting on the page, until the database was fixed by hand. A `null` for a setting that can't be empty, whether a number or an on/off switch, is now refused with a message that names the setting, and nothing from that request is saved. A setting already stored that way now loads as its default, so an affected install recovers on its own.
 - **"Reset usage to 0" on a Spoolman spool set it back to full (#2906, reported and contributed by @ojimpo in #2939)** — The reset set Spoolman's `used_weight` to 0. Spoolman calculates the remaining weight as initial minus used, so the spool jumped back to full and its measured remaining weight was lost: a roll with 737 g left read 1000 g, and later prints were charged against filament that wasn't there. The confirmation dialog promised the opposite, that the spool and its remaining weight would not change. The reset now stores the used weight at that moment in a Bambuddy field in the spool's custom fields (`bambu_weight_used_baseline`), the same way it has always worked without Spoolman, and Bambuddy subtracts it when it shows the consumed counter. Spoolman's initial, used and remaining weights are left untouched, and so is every other custom field. The value is stored as text because Spoolman sets a field's type on first use and never lets it change; a number would have been rejected on every install. The bulk reset works the same way. Spools already reset by the old version stay as they are: their remaining weight was overwritten in Spoolman, so they need re-weighing.
 - **Filament printed from the external spool was deducted from an AMS spool (#3166)** — The firmware doesn't accept the external spool in a print command's `ams_mapping` list. So BambuStudio and Bambuddy's own dispatch both write it there as -1, the same value as an unused slot, and put the real target in `ams_mapping2`. Bambuddy read only the first list, so an external-spool print looked unmapped, and usage tracking fell back to guessing by position: the first loaded AMS slot. The reporter's H2D charged every external-spool print that way, including 225 g of ABS taken from the PLA spool in AMS slot 1; the same was reported on H2S and P2S. The external spool is now read from `ams_mapping2` (left or right on dual-nozzle printers). A slot the mapping marks as unused is no longer given a tray by position. When a mapping names no tray at all, the tray the printer reports decides instead. The last change also stops an unused slot from taking over the external spool that another slot really used and dropping that slot's weight (#2880).

+ 5 - 0
backend/app/core/database.py

@@ -5113,6 +5113,11 @@ async def run_migrations(conn):
             {"off": False},
         )
 
+    # Migration: a code for why a scheduled drying failed, so the card can show
+    # the reason in the user's language. Nullable: rows that failed before this
+    # keep showing their English error_message.
+    await _safe_execute(conn, "ALTER TABLE scheduled_dryings ADD COLUMN error_code VARCHAR(32)")
+
 
 async def _migrate_confirm_prompt_body_template(conn) -> None:
     """Replace the one-tap verdict URLs in the outcome prompt's body (#1898).

+ 4 - 0
backend/app/models/scheduled_drying.py

@@ -33,6 +33,10 @@ class ScheduledDrying(Base):
     status: Mapped[str] = mapped_column(String(20), default="pending")
     waiting_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
     error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+    # Why a failed run failed, as a code the UI translates (screen_only /
+    # unsupported / did_not_start). error_message keeps the English text for the
+    # API and logs; a row without a code falls back to showing that.
+    error_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
 
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 1 - 0
backend/app/schemas/scheduled_drying.py

@@ -36,6 +36,7 @@ class ScheduledDryingResponse(BaseModel):
     status: str
     waiting_reason: str | None
     error_message: str | None
+    error_code: str | None = None
     created_at: UTCDatetime
     started_at: UTCDatetime
     completed_at: UTCDatetime

+ 10 - 0
backend/app/services/drying_preflight.py

@@ -10,6 +10,16 @@ from backend.app.services.printer_manager import drying_screen_only, supports_dr
 SCREEN_ONLY_DETAIL = "This printer only supports AMS drying from its own screen"
 UNSUPPORTED_DETAIL = "Drying not supported for this printer model or firmware version"
 
+# Codes stored on a failed scheduled drying (ScheduledDrying.error_code) so the
+# UI can show the reason translated. check_drying_supported() returns one of the
+# two details above; DETAIL_CODES maps it to its code.
+DID_NOT_START_DETAIL = "The printer accepted the drying command, but the AMS did not start drying"
+DETAIL_CODES = {
+    SCREEN_ONLY_DETAIL: "screen_only",
+    UNSUPPORTED_DETAIL: "unsupported",
+    DID_NOT_START_DETAIL: "did_not_start",
+}
+
 # Firmware dry_sf_reason codes, as surfaced by the AMS status payload.
 DRY_SF_REASON_MESSAGES = {
     0: "Printer is busy",

+ 3 - 1
backend/app/services/print_scheduler.py

@@ -4871,6 +4871,7 @@ class PrintScheduler:
             if unsupported:
                 row.status = "failed"
                 row.error_message = unsupported
+                row.error_code = drying_preflight.DETAIL_CODES.get(unsupported)
                 row.completed_at = now
                 logger.warning("Scheduled drying %d: %s", row.id, unsupported)
                 continue
@@ -4993,7 +4994,8 @@ class PrintScheduler:
                     row.ams_id,
                 )
                 row.status = "failed"
-                row.error_message = "The printer accepted the drying command, but the AMS did not start drying"
+                row.error_message = drying_preflight.DID_NOT_START_DETAIL
+                row.error_code = drying_preflight.DETAIL_CODES[drying_preflight.DID_NOT_START_DETAIL]
                 row.completed_at = now
             return
         if dry_time > 0:

+ 20 - 0
backend/tests/unit/test_scheduled_drying_routes.py

@@ -156,6 +156,7 @@ async def test_failed_rows_are_listed_and_dismissable(async_client, printer_fact
         duration_hours=8,
         status="failed",
         error_message="Drying not supported for this printer model or firmware version",
+        error_code="unsupported",
     )
     db_session.add(row)
     await db_session.commit()
@@ -164,6 +165,8 @@ async def test_failed_rows_are_listed_and_dismissable(async_client, printer_fact
     listed = await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")
     assert [r["id"] for r in listed.json()] == [row.id]
     assert listed.json()[0]["error_message"].startswith("Drying not supported")
+    # The code the card translates the reason from.
+    assert listed.json()[0]["error_code"] == "unsupported"
 
     resp = await async_client.delete(f"/api/v1/scheduled-dryings/{row.id}")
     assert resp.status_code == 200
@@ -174,6 +177,23 @@ async def test_failed_rows_are_listed_and_dismissable(async_client, printer_fact
     assert (await async_client.delete(f"/api/v1/scheduled-dryings/{row.id}")).status_code == 404
 
 
+@pytest.mark.asyncio
+async def test_a_row_without_an_error_code_lists_it_as_null(async_client, printer_factory, db_session):
+    """Rows that failed before codes existed carry none; the card then shows
+    their English error_message."""
+    from backend.app.models.scheduled_drying import ScheduledDrying
+
+    printer = await printer_factory()
+    db_session.add(
+        ScheduledDrying(printer_id=printer.id, temp=65, duration_hours=8, status="failed", error_message="old reason")
+    )
+    await db_session.commit()
+
+    listed = (await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")).json()
+    assert listed[0]["error_code"] is None
+    assert listed[0]["error_message"] == "old reason"
+
+
 @pytest.mark.asyncio
 async def test_list_orders_by_start_after_then_id(async_client, printer_factory):
     printer = await printer_factory()

+ 16 - 0
backend/tests/unit/test_scheduler_scheduled_drying.py

@@ -200,6 +200,8 @@ async def test_screen_only_model_fails_instead_of_dispatching(scheduler, db_sess
     await db_session.refresh(row)
     assert row.status == "failed"
     assert row.error_message
+    # The code the card translates; error_message stays English for the API.
+    assert row.error_code == "screen_only"
     assert row.completed_at is not None
 
 
@@ -217,6 +219,7 @@ async def test_firmware_below_minimum_fails(scheduler, db_session, printer_facto
     await db_session.refresh(row)
     assert row.status == "failed"
     assert row.error_message
+    assert row.error_code == "unsupported"
     assert row.completed_at is not None
 
 
@@ -676,6 +679,7 @@ async def test_running_with_a_parked_countdown_on_an_idle_printer_fails(schedule
     await db_session.refresh(row)
     assert row.status == "failed"
     assert "did not start drying" in row.error_message
+    assert row.error_code == "did_not_start"
     assert row.completed_at is not None
 
 
@@ -750,3 +754,15 @@ class TestDryingIsOnlyParked:
     def test_offline_printer_is_not_parked(self, mock_pm):
         mock_pm.get_status.return_value = None
         assert PrintScheduler._drying_is_only_parked(1) is False
+
+
+def test_every_failure_detail_has_a_code():
+    """Each English failure text the scheduler can store maps to a code the
+    frontend translates (FAILED_REASON_KEYS in PrintersPage.tsx)."""
+    from backend.app.services import drying_preflight
+
+    assert drying_preflight.DETAIL_CODES == {
+        drying_preflight.SCREEN_ONLY_DETAIL: "screen_only",
+        drying_preflight.UNSUPPORTED_DETAIL: "unsupported",
+        drying_preflight.DID_NOT_START_DETAIL: "did_not_start",
+    }

+ 45 - 0
frontend/src/__tests__/pages/PrintersPageDryingStartModes.test.tsx

@@ -247,6 +247,51 @@ describe('PrintersPage - drying start modes', () => {
     expect(await screen.findByText('Connect AMS power adapter to enable drying')).toBeInTheDocument();
   });
 
+  it('shows a failed run by its error code, translated, not the backend English text', async () => {
+    server.use(
+      http.get('/api/v1/scheduled-dryings', () =>
+        HttpResponse.json([
+          {
+            ...PENDING_ROW,
+            status: 'failed',
+            error_code: 'did_not_start',
+            // Deliberately different from the translation, so the test shows
+            // which of the two was rendered.
+            error_message: 'backend English text',
+            completed_at: '2026-07-25T23:25:00',
+          },
+        ])
+      ),
+    );
+    render(<PrintersPage />);
+    expect(
+      await screen.findByText(
+        'Scheduled drying failed: The printer accepted the command, but the AMS did not start drying'
+      )
+    ).toBeInTheDocument();
+    expect(screen.queryByText(/backend English text/)).not.toBeInTheDocument();
+  });
+
+  it('falls back to the error message for an unknown error code', async () => {
+    server.use(
+      http.get('/api/v1/scheduled-dryings', () =>
+        HttpResponse.json([
+          {
+            ...PENDING_ROW,
+            status: 'failed',
+            error_code: 'something_new',
+            error_message: 'A reason this frontend has no key for',
+            completed_at: '2026-07-25T23:25:00',
+          },
+        ])
+      ),
+    );
+    render(<PrintersPage />);
+    expect(
+      await screen.findByText('Scheduled drying failed: A reason this frontend has no key for')
+    ).toBeInTheDocument();
+  });
+
   it('shows a run that failed at dispatch, with a dismiss that clears it', async () => {
     // Only dispatch can fail (firmware too old on a printer that was offline
     // at schedule time). Without this the run vanishes and only the backend

+ 3 - 0
frontend/src/api/client.ts

@@ -501,6 +501,9 @@ export interface ScheduledDrying {
   status: string;
   waiting_reason: string | null;
   error_message: string | null;
+  // Why a failed run failed (screen_only / unsupported / did_not_start); null on
+  // rows that failed before codes existed, which show error_message instead.
+  error_code?: string | null;
   created_at: string;
   started_at: string | null;
   completed_at: string | null;

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -713,6 +713,7 @@ export default {
       waitingInterrupted: 'Unterbrochen, startet erneut, sobald der Drucker frei ist',
       scheduleFailedReason: 'Geplante Trocknung fehlgeschlagen: {{reason}}',
       scheduleFailedUnknown: 'Unbekannter Fehler',
+      scheduleFailedDidNotStart: 'Der Drucker hat den Befehl angenommen, aber das AMS hat nicht mit dem Trocknen begonnen',
       dismissFailed: 'Ausblenden',
     },
     amsBackup: {

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -717,6 +717,7 @@ export default {
       waitingInterrupted: 'Interrupted, will restart when the printer is free',
       scheduleFailedReason: 'Scheduled drying failed: {{reason}}',
       scheduleFailedUnknown: 'Unknown error',
+      scheduleFailedDidNotStart: 'The printer accepted the command, but the AMS did not start drying',
       dismissFailed: 'Dismiss',
     },
     // AMS Filament Backup status badge (printer-wide auto-switch to another spool)

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -713,6 +713,7 @@ export default {
       waitingInterrupted: 'Interrumpido, se reanudará cuando la impresora esté libre',
       scheduleFailedReason: 'El secado programado falló: {{reason}}',
       scheduleFailedUnknown: 'Error desconocido',
+      scheduleFailedDidNotStart: 'La impresora aceptó el comando, pero el AMS no empezó a secar',
       dismissFailed: 'Descartar',
     },
     amsBackup: {

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -713,6 +713,7 @@ export default {
       waitingInterrupted: 'Interrompu, reprendra lorsque l\'imprimante sera libre',
       scheduleFailedReason: 'Échec du séchage planifié : {{reason}}',
       scheduleFailedUnknown: 'Erreur inconnue',
+      scheduleFailedDidNotStart: 'L\'imprimante a accepté la commande, mais l\'AMS n\'a pas commencé le séchage',
       dismissFailed: 'Masquer',
     },
     amsBackup: {

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -713,6 +713,7 @@ export default {
       waitingInterrupted: 'Interrotta, riprenderà quando la stampante sarà libera',
       scheduleFailedReason: 'Asciugatura pianificata non riuscita: {{reason}}',
       scheduleFailedUnknown: 'Errore sconosciuto',
+      scheduleFailedDidNotStart: 'La stampante ha accettato il comando, ma l\'AMS non ha avviato l\'essiccazione',
       dismissFailed: 'Ignora',
     },
     amsBackup: {

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -712,6 +712,7 @@ export default {
       waitingInterrupted: '中断されました。プリンターが空き次第、再開します',
       scheduleFailedReason: '予約した乾燥に失敗しました: {{reason}}',
       scheduleFailedUnknown: '不明なエラー',
+      scheduleFailedDidNotStart: 'プリンターはコマンドを受け付けましたが、AMSは乾燥を開始しませんでした',
       dismissFailed: '閉じる',
     },
     amsBackup: {

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

@@ -675,6 +675,7 @@ export default {
       waitingInterrupted: '중단됨, 프린터가 사용 가능해지면 다시 시작됩니다',
       scheduleFailedReason: '예약된 건조에 실패했습니다: {{reason}}',
       scheduleFailedUnknown: '알 수 없는 오류',
+      scheduleFailedDidNotStart: '프린터가 명령을 수락했지만 AMS가 건조를 시작하지 않았습니다',
       dismissFailed: '닫기',
     },
     amsBackup: {

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -717,6 +717,7 @@ export default {
       waitingInterrupted: 'Onderbroken, wordt opnieuw gestart zodra de printer vrij is',
       scheduleFailedReason: 'Gepland drogen mislukt: {{reason}}',
       scheduleFailedUnknown: 'Onbekende fout',
+      scheduleFailedDidNotStart: 'De printer heeft de opdracht geaccepteerd, maar de AMS is niet begonnen met drogen',
       dismissFailed: 'Negeren',
     },
     // AMS Filament Backup status badge (printer-wide auto-switch to another spool)

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

@@ -713,6 +713,7 @@ export default {
       waitingInterrupted: 'Interrompida, será retomada quando a impressora estiver livre',
       scheduleFailedReason: 'Falha na secagem agendada: {{reason}}',
       scheduleFailedUnknown: 'Erro desconhecido',
+      scheduleFailedDidNotStart: 'A impressora aceitou o comando, mas o AMS não começou a secar',
       dismissFailed: 'Dispensar',
     },
     amsBackup: {

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -680,6 +680,7 @@ export default {
       waitingInterrupted: "Прервано, возобновится, когда принтер освободится",
       scheduleFailedReason: "Не удалось выполнить запланированную сушку: {{reason}}",
       scheduleFailedUnknown: "Неизвестная ошибка",
+      scheduleFailedDidNotStart: "Принтер принял команду, но AMS не начал сушку",
       dismissFailed: "Скрыть",
     },
     amsBackup: {

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -717,6 +717,7 @@ export default {
       waitingInterrupted: 'Avbruten, kommer att starta om när skrivaren är ledig',
       scheduleFailedReason: 'Schemalagd torkning misslyckades: {{reason}}',
       scheduleFailedUnknown: 'Okänt fel',
+      scheduleFailedDidNotStart: 'Skrivaren accepterade kommandot, men AMS började inte torka',
       dismissFailed: 'Avfärda',
     },
     // AMS Filament Backup status badge (printer-wide auto-switch to another spool)

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -713,6 +713,7 @@ export default {
       waitingInterrupted: 'Kesildi, yazıcı boşaldığında yeniden başlayacak',
       scheduleFailedReason: 'Zamanlanmış kurutma başarısız oldu: {{reason}}',
       scheduleFailedUnknown: 'Bilinmeyen hata',
+      scheduleFailedDidNotStart: 'Yazıcı komutu kabul etti ancak AMS kurutmaya başlamadı',
       dismissFailed: 'Kapat',
     },
     amsBackup: {

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -716,6 +716,7 @@ export default {
       waitingInterrupted: "Перервано, відновиться, коли принтер звільниться",
       scheduleFailedReason: "Не вдалося виконати заплановане сушіння: {{reason}}",
       scheduleFailedUnknown: "Невідома помилка",
+      scheduleFailedDidNotStart: "Принтер прийняв команду, але AMS не розпочав сушіння",
       dismissFailed: "Сховати",
     },
     // AMS Filament Backup status badge (printer-wide auto-switch to another spool)

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -713,6 +713,7 @@ export default {
       waitingInterrupted: '已中断,将在打印机空闲后重新开始',
       scheduleFailedReason: '预约干燥失败:{{reason}}',
       scheduleFailedUnknown: '未知错误',
+      scheduleFailedDidNotStart: '打印机已接受命令,但 AMS 没有开始干燥',
       dismissFailed: '忽略',
     },
     amsBackup: {

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

@@ -713,6 +713,7 @@ export default {
       waitingInterrupted: '已中斷,將在印表機空閒後重新開始',
       scheduleFailedReason: '預約乾燥失敗:{{reason}}',
       scheduleFailedUnknown: '未知錯誤',
+      scheduleFailedDidNotStart: '印表機已接受指令,但 AMS 沒有開始乾燥',
       dismissFailed: '忽略',
     },
     amsBackup: {

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

@@ -58,6 +58,19 @@ function waitingReasonKey(reason: string | null | undefined): string | undefined
   return reason ? WAITING_REASON_KEYS[reason] : undefined;
 }
 
+// Why a scheduled drying failed, by the code the scheduler stores. A row with
+// no code (failed before codes existed) or an unknown one shows the backend's
+// English error_message instead.
+const FAILED_REASON_KEYS: Record<string, string> = {
+  screen_only: 'printers.drying.screenOnly',
+  unsupported: 'printers.drying.notSupported',
+  did_not_start: 'printers.drying.scheduleFailedDidNotStart',
+};
+
+function failedReasonKey(code: string | null | undefined): string | undefined {
+  return code ? FAILED_REASON_KEYS[code] : undefined;
+}
+
 // Which cannot-dry code to name when the firmware reports several at once.
 // Same priority as the backend's drying_preflight.primary_reason_code, so the
 // button's tooltip and a scheduled row's waiting reason describe one blocked
@@ -2049,6 +2062,7 @@ function ScheduledDryingBanner({ printerId, dryingActive, timeFormat }: { printe
       {rows.map(s => {
         const failed = s.status === 'failed';
         const reasonKey = waitingReasonKey(s.waiting_reason);
+        const failedKey = failedReasonKey(s.error_code);
         return (
           <div
             key={s.id}
@@ -2060,7 +2074,7 @@ function ScheduledDryingBanner({ printerId, dryingActive, timeFormat }: { printe
             <span className={failed ? 'text-red-700 dark:text-red-400' : 'text-amber-700 dark:text-amber-400'}>
               {failed ? (
                 t('printers.drying.scheduleFailedReason', {
-                  reason: s.error_message || t('printers.drying.scheduleFailedUnknown'),
+                  reason: failedKey ? t(failedKey) : s.error_message || t('printers.drying.scheduleFailedUnknown'),
                 })
               ) : (
                 <>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
static/assets/PdfPreviewModal-Bxccllsw.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
static/assets/PdfPreviewModal-K-KyO7Oy.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpreadsheetPreviewModal-BGgih2vi.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
static/assets/SpreadsheetPreviewModal-CsevFqb3.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
static/assets/index-82a1Eqef.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 1
static/assets/index-CJGI4Fgw.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/pdf-BFNJieqg.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/pdf-Ce2j8LB_.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-82a1Eqef.js"></script>
+    <script type="module" crossorigin src="/assets/index-CJGI4Fgw.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-BPfxi5P1.css">
   </head>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio