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

fix(drying): P1 AMS drying is screen-only — stop offering it (#2533)

The reporter found what his P1S was doing, and it is in Bambu's P1 manual:
"P1S connected AMS drying functions may only be controlled from the P1S screen."
The firmware acks ams_filament_drying with result: success and then discards it,
which is why three commands on an idle printer left the AMS 2 Pro at dry_status 0.
No command can start a cycle on a P1, on any firmware, so don't offer one.

supports_drying() now excludes the P1 series outright, replacing the 01.08+ gate
carried since #292 — that version is when P1 firmware gained AMS 2 Pro support,
not remote drying, and it was never checked against a live P1. Both drying routes
refuse with a specific 400 instead of publishing a message the printer will drop;
queue and ambient auto-drying skip P1s via the same helper.

A new drying_screen_only flag keeps the control on the card, disabled, saying why
— a P1 owner needs to learn where to dry, not watch the button disappear. A cycle
started at the printer still shows with its countdown; only Stop goes away, since
a P1 ignores stop exactly as it ignores start.

Also corrects the wiki firmware matrix, which listed P1P/P1S as supported and
(separately) P2S/H2S/H2C as unsupported. 8 tests.
maziggy 1 месяц назад
Родитель
Сommit
ca3f6e5ee0

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


+ 14 - 0
backend/app/api/routes/printers.py

@@ -50,6 +50,7 @@ from backend.app.services.bambu_ftp import (
 )
 from backend.app.services.printer_diagnostic import run_connection_diagnostic
 from backend.app.services.printer_manager import (
+    drying_screen_only,
     get_derived_status_name,
     printer_manager,
     resolve_plate_id,
@@ -779,6 +780,7 @@ async def get_printer_status(
         awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
         supports_drying=supports_drying(printer.model, state.firmware_version),
         supports_drying_while_printing=supports_drying_while_printing(printer.model, state.firmware_version),
+        drying_screen_only=drying_screen_only(printer.model),
         supports_chamber_heater=supports_chamber_heater(printer.model),
         current_archive_id=current_archive_id,
         current_plate_id=current_plate_id,
@@ -1756,6 +1758,11 @@ async def clear_mqtt_logs(
 # AMS Drying Endpoints
 # ============================================
 
+# The P1 firmware acks `ams_filament_drying` with result: success and then ignores it
+# — Bambu's own P1 manual says drying "may only be controlled from the P1S screen"
+# (#2533). Refuse the command rather than let the caller believe it landed.
+_DRYING_SCREEN_ONLY_DETAIL = "This printer only supports AMS drying from its own screen"
+
 
 @router.post("/{printer_id}/drying/start")
 async def start_drying(
@@ -1777,6 +1784,8 @@ async def start_drying(
     # Server-side guard: reject if this model/firmware doesn't support drying
     live_state = printer_manager.get_status(printer_id)
     firmware = live_state.firmware_version if live_state else None
+    if drying_screen_only(printer.model):
+        raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
     if not supports_drying(printer.model, firmware):
         raise HTTPException(400, "Drying not supported for this printer model or firmware version")
 
@@ -1849,6 +1858,11 @@ async def stop_drying(
     if not printer:
         raise HTTPException(404, "Printer not found")
 
+    # Screen-only models ignore stop just as they ignore start — a cycle running on a
+    # P1S was started at the printer and has to be ended there too (#2533).
+    if drying_screen_only(printer.model):
+        raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
+
     success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
     if not success:
         raise HTTPException(400, "Printer not connected")

+ 4 - 0
backend/app/schemas/printer.py

@@ -365,6 +365,10 @@ class PrinterStatus(BaseModel):
     # AMS "Print While Drying" — drying mid-print. Verified per Bambu wiki release notes;
     # see _DRY_WHILE_PRINTING_MIN_FIRMWARE in printer_manager.py for the matrix.
     supports_drying_while_printing: bool = False
+    # The AMS can dry, but only from the printer's own screen (P1 series, #2533).
+    # supports_drying is False on these; the UI keeps the control visible but disabled
+    # and says why, rather than dropping it without explanation.
+    drying_screen_only: bool = False
     # Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
     supports_chamber_heater: bool = False
     # Linked archive for the active print (resolved via subtask_id). Frontend uses

+ 27 - 5
backend/app/services/printer_manager.py

@@ -209,27 +209,48 @@ _DRYING_MIN_FIRMWARE: dict[str, str] = {
     "O1C2": "01.02.00.00",  # H2C dual-nozzle SSDP model code
     "X1": "01.09.00.00",
     "X1C": "01.09.00.00",
-    "P1P": "01.08.00.00",
-    "P1S": "01.08.00.00",
     "P2S": "01.02.00.00",
     "N7": "01.02.00.00",  # P2S internal model code
 }
 # Models that definitely don't support AMS drying (no AMS 2 Pro / AMS-HT compatibility)
 _DRYING_UNSUPPORTED_MODELS = frozenset({"A1", "A1MINI", "A1-MINI", "A1 MINI", "O1S", "N1", "N2S"})
 
+# Models whose AMS can dry, but only from the printer's own touchscreen. Bambu's P1
+# manual is explicit: "P1S connected AMS drying functions may only be controlled from
+# the P1S screen." The firmware still answers `ams_filament_drying` with
+# result: success and then does nothing — the reporter of #2533 sent it three times
+# on an idle P1S with an AMS 2 Pro and the unit never left dry_status 0. Bambuddy
+# originally listed P1P/P1S here as fw-gated (01.08+, #292); that version is when P1
+# firmware gained AMS 2 Pro *support*, not remote drying, and it was never verified
+# against a live P1. Nothing we can send will start a cycle, so we don't offer to.
+_DRYING_SCREEN_ONLY_MODELS = frozenset({"P1P", "P1S"})
+
+
+def drying_screen_only(model: str | None) -> bool:
+    """True when the model's AMS dries only via the printer's own screen (#2533).
+
+    Distinct from "unsupported": these printers *can* dry, and Bambuddy still shows
+    a cycle started on the printer. They just can't be commanded to start or stop
+    one remotely, so the UI explains that instead of silently dropping the control.
+    """
+    if not model:
+        return False
+    return model.strip().upper() in _DRYING_SCREEN_ONLY_MODELS
+
 
 def supports_drying(model: str | None, firmware: str | None) -> bool:
-    """Check if a printer model supports AMS drying commands.
+    """Check if a printer model accepts remote AMS drying commands.
 
     Known models with confirmed min firmware get version-gated.
-    Known unsupported models are blocked.
+    Known unsupported models, and models that only dry from their own screen,
+    are blocked.
     All other models (H2D Pro, X1E, future models) are allowed —
     the command fails gracefully with result: "fail" if unsupported.
     """
     if not model:
         return False
     model_upper = model.strip().upper()
-    if model_upper in _DRYING_UNSUPPORTED_MODELS:
+    if model_upper in _DRYING_UNSUPPORTED_MODELS or model_upper in _DRYING_SCREEN_ONLY_MODELS:
         return False
     if model_upper in _DRYING_MIN_FIRMWARE:
         return bool(firmware and firmware >= _DRYING_MIN_FIRMWARE[model_upper])
@@ -1263,6 +1284,7 @@ def printer_state_to_dict(
         # AMS drying support
         "supports_drying": supports_drying(model, state.firmware_version),
         "supports_drying_while_printing": supports_drying_while_printing(model, state.firmware_version),
+        "drying_screen_only": drying_screen_only(model),
         # 1-indexed plate number parsed from gcode_file (e.g. /Metadata/plate_2.gcode).
         # Pushed via WebSocket so the printer card picks up plate transitions within
         # a multi-plate 3MF without waiting for the 30 s REST poll (#881 follow-up).

+ 70 - 0
backend/tests/integration/test_drying_screen_only.py

@@ -0,0 +1,70 @@
+"""P1-series AMS drying is screen-only — the API must refuse it (#2533).
+
+Bambu's P1 manual states that "P1S connected AMS drying functions may only be
+controlled from the P1S screen". The firmware still answers
+``ams_filament_drying`` with ``result: success`` and then ignores it, which is
+exactly what the reporter saw: three commands accepted on an idle P1S with an
+AMS 2 Pro, and the unit never left ``dry_status: 0``.
+
+So a command we can't fulfil must be refused rather than acked, and that has to
+hold for stop as well as start — a cycle a P1S user started at the printer can
+only be ended there.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.fixture
+def mqtt_send():
+    """Watch the MQTT command so we can assert nothing was published."""
+    with patch(
+        "backend.app.services.printer_manager.printer_manager.send_drying_command",
+        new=MagicMock(return_value=True),
+    ) as m:
+        yield m
+
+
+@pytest.fixture
+def live_state():
+    """A connected printer on firmware new enough that only the model gates drying."""
+    state = MagicMock()
+    state.firmware_version = "01.10.00.00"
+    state.raw_data = {"ams": [{"id": 0, "module_type": "n3f", "tray": []}]}
+    with patch(
+        "backend.app.services.printer_manager.printer_manager.get_status",
+        new=MagicMock(return_value=state),
+    ) as m:
+        yield m
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("model", ["P1S", "P1P"])
+@pytest.mark.parametrize("action", ["start", "stop"])
+async def test_screen_only_model_refuses_drying(
+    async_client: AsyncClient, printer_factory, mqtt_send, live_state, model, action
+):
+    printer = await printer_factory(model=model)
+
+    response = await async_client.post(f"/api/v1/printers/{printer.id}/drying/{action}?ams_id=0")
+
+    assert response.status_code == 400
+    assert "screen" in response.json()["detail"].lower()
+    # And nothing went out on the wire — an ack the printer would drop is worse
+    # than a refusal, because it leaves the user believing drying is running.
+    mqtt_send.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("action", ["start", "stop"])
+async def test_commandable_model_still_dries(async_client: AsyncClient, printer_factory, mqtt_send, live_state, action):
+    printer = await printer_factory(model="X1C")
+
+    response = await async_client.post(f"/api/v1/printers/{printer.id}/drying/{action}?ams_id=0")
+
+    assert response.status_code == 200
+    mqtt_send.assert_called_once()

+ 27 - 2
backend/tests/unit/services/test_printer_manager.py

@@ -10,6 +10,7 @@ import pytest
 
 from backend.app.services.printer_manager import (
     PrinterManager,
+    drying_screen_only,
     get_derived_status_name,
     has_stg_cur_idle_bug,
     init_printer_connections,
@@ -1490,7 +1491,6 @@ class TestSupportsDrying:
     def test_known_supported_with_firmware(self):
         """Verify known models with sufficient firmware return True."""
         assert supports_drying("X1C", "01.09.00.00") is True
-        assert supports_drying("P1S", "01.08.00.00") is True
         assert supports_drying("H2D", "01.02.30.00") is True
         assert supports_drying("H2S", "01.02.00.00") is True
         assert supports_drying("H2C", "01.02.00.00") is True
@@ -1502,7 +1502,6 @@ class TestSupportsDrying:
     def test_known_supported_old_firmware(self):
         """Verify known models with old firmware return False."""
         assert supports_drying("X1C", "01.08.00.00") is False
-        assert supports_drying("P1S", "01.07.00.00") is False
         assert supports_drying("H2S", "01.01.00.00") is False
         assert supports_drying("H2C", "01.01.99.99") is False
         assert supports_drying("O1C", "01.01.99.99") is False
@@ -1545,6 +1544,32 @@ class TestSupportsDrying:
         assert supports_drying("a1", "99.99.99.99") is False
 
 
+class TestDryingScreenOnly:
+    """P1-series AMS drying is screen-only (#2533).
+
+    Bambu's P1 manual: "P1S connected AMS drying functions may only be controlled
+    from the P1S screen." The firmware acks `ams_filament_drying` with
+    result: success and then does nothing — so no command we send can ever start a
+    cycle, whatever the firmware version.
+    """
+
+    @pytest.mark.parametrize("model", ["P1S", "P1P", "p1s", " p1p "])
+    def test_screen_only_models_reject_remote_drying(self, model):
+        assert drying_screen_only(model) is True
+        # Not firmware-gated: even the newest firmware won't take the command.
+        assert supports_drying(model, "99.99.99.99") is False
+
+    @pytest.mark.parametrize("model", ["X1C", "P2S", "H2D", "A1", None])
+    def test_other_models_are_not_screen_only(self, model):
+        assert drying_screen_only(model) is False
+
+    def test_screen_only_is_not_the_same_as_unsupported(self):
+        # The A1 has no drying-capable AMS at all; the P1S does, it just can't be
+        # driven remotely. The UI needs to tell those two apart.
+        assert drying_screen_only("A1") is False
+        assert supports_drying("A1", "99.99.99.99") is False
+
+
 class TestSupportsDryingWhilePrinting:
     """Tests for the supports_drying_while_printing gate (concurrent drying during print).
 

+ 64 - 5
frontend/src/__tests__/pages/PrintersPageDryingFeedback.test.tsx

@@ -6,6 +6,12 @@
  * 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.
+ *
+ * The reporter's own printer is now handled further up: Bambu's P1 manual says
+ * P1-series AMS drying is screen-only, so the card no longer offers to command
+ * it (last describe block). The watcher stays for any other firmware that acks
+ * and declines — on the models we *can* command, the ack is still all we have,
+ * because the `dry_sf_reason` refusal array only exists on some of them.
  */
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
 import { screen, waitFor } from '@testing-library/react';
@@ -23,11 +29,11 @@ vi.mock('../../contexts/ToastContext', async (importOriginal) => {
 
 const mockPrinter = {
   id: 1,
-  name: 'P1S',
+  name: 'X1C',
   ip_address: '192.168.1.100',
   serial_number: '01P00A000000001',
   access_code: '12345678',
-  model: 'P1S',
+  model: 'X1C',
   enabled: true,
   nozzle_diameter: 0.4,
   nozzle_type: 'stainless_steel',
@@ -55,8 +61,11 @@ const baseTray = {
   state: 3,
 };
 
-/** AMS 2 Pro (n3f) on an idle printer — the reporter's hardware. */
-function makeStatus(dry: { dry_time: number; dry_status: number }) {
+/** AMS 2 Pro (n3f) on an idle printer that accepts remote drying commands. */
+function makeStatus(
+  dry: { dry_time: number; dry_status: number },
+  caps: { supports_drying?: boolean; drying_screen_only?: boolean } = {},
+) {
   return {
     connected: true,
     state: 'IDLE',
@@ -68,7 +77,8 @@ function makeStatus(dry: { dry_time: number; dry_status: number }) {
     filename: null,
     wifi_signal: -29,
     speed_level: 2,
-    supports_drying: true,
+    supports_drying: caps.supports_drying ?? true,
+    drying_screen_only: caps.drying_screen_only ?? false,
     vt_tray: [],
     ams: [
       {
@@ -96,6 +106,19 @@ function makeStatus(dry: { dry_time: number; dry_status: number }) {
 const IDLE = makeStatus({ dry_time: 0, dry_status: 0 });
 const DRYING = makeStatus({ dry_time: 720, dry_status: 2 });
 
+/** A P1: the AMS dries, but only from the printer's own screen. */
+const SCREEN_ONLY = makeStatus(
+  { dry_time: 0, dry_status: 0 },
+  { supports_drying: false, drying_screen_only: true },
+);
+const SCREEN_ONLY_DRYING = makeStatus(
+  { dry_time: 720, dry_status: 2 },
+  { supports_drying: false, drying_screen_only: true },
+);
+
+const SCREEN_ONLY_TITLE =
+  "AMS drying on this printer can only be controlled from the printer's own screen (Bambu limitation)";
+
 /**
  * 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
@@ -205,3 +228,39 @@ describe('PrintersPage - AMS drying feedback (#2533)', () => {
     expect(mockShowToast).not.toHaveBeenCalledWith(expect.stringContaining('never started drying'), 'error');
   });
 });
+
+describe('PrintersPage - screen-only AMS drying (#2533)', () => {
+  beforeEach(() => {
+    mockShowToast.mockClear();
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+      http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    );
+  });
+
+  it('keeps the drying control visible but disabled, and says why', async () => {
+    const user = userEvent.setup();
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(SCREEN_ONLY)));
+
+    render(<PrintersPage />);
+
+    // Present, so the user learns the AMS *can* dry and where to do it — silently
+    // dropping the button would just look like the feature vanished.
+    const buttons = await screen.findAllByTitle(SCREEN_ONLY_TITLE);
+    expect(buttons[0]).toBeDisabled();
+
+    await user.click(buttons[0]);
+    expect(screen.queryByTestId('drying-start-confirm')).not.toBeInTheDocument();
+  });
+
+  it('shows a cycle started at the printer, without offering to stop it', async () => {
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(SCREEN_ONLY_DRYING)));
+
+    render(<PrintersPage />);
+
+    // The countdown is pure observation and still works.
+    expect((await screen.findAllByText(/12h 0m/)).length).toBeGreaterThan(0);
+    // Stop is a command, and a P1 ignores it exactly as it ignores start.
+    expect(screen.queryByTitle('Stop Drying')).not.toBeInTheDocument();
+  });
+});

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

@@ -526,6 +526,8 @@ export interface PrinterStatus {
   awaiting_plate_clear: boolean;
   // AMS drying support
   supports_drying: boolean;
+  // The AMS can dry, but only from the printer's own screen (P1 series, #2533).
+  drying_screen_only?: boolean;
   // Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
   supports_chamber_heater?: boolean;
 }

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

@@ -572,6 +572,7 @@ export default {
       toastCommandSent: 'Trocknungsbefehl gesendet',
       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.',
+      screenOnly: 'Die AMS-Trocknung kann bei diesem Drucker nur am Bildschirm des Druckers selbst gesteuert werden (Einschränkung von Bambu)',
       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.',

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

@@ -575,6 +575,7 @@ export default {
       toastCommandSent: 'Drying command sent',
       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.',
+      screenOnly: 'AMS drying on this printer can only be controlled from the printer\'s own screen (Bambu limitation)',
       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.',

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

@@ -572,6 +572,7 @@ export default {
       toastCommandSent: 'Comando de secado enviado',
       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.',
+      screenOnly: 'En esta impresora, el secado del AMS solo se puede controlar desde la pantalla de la propia impresora (limitación de Bambu)',
       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.',

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

@@ -572,6 +572,7 @@ export default {
       toastCommandSent: 'Commande de séchage envoyée',
       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.',
+      screenOnly: 'Sur cette imprimante, le séchage de l\'AMS ne peut être commandé que depuis l\'écran de l\'imprimante (limitation de Bambu)',
       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.',

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

@@ -572,6 +572,7 @@ export default {
       toastCommandSent: 'Comando di essiccazione inviato',
       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.',
+      screenOnly: 'Su questa stampante l\'essiccazione dell\'AMS può essere comandata solo dallo schermo della stampante (limitazione di Bambu)',
       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.',

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

@@ -571,6 +571,7 @@ export default {
       toastCommandSent: '乾燥コマンドを送信しました',
       toastStopped: '乾燥を停止しました',
       toastNotStarted: 'プリンターはコマンドを受け付けましたが、AMS は乾燥を開始しませんでした。AMS の電源アダプターが接続されているか、プリンターがアイドル状態かを確認してください。',
+      screenOnly: 'このプリンターでは、AMS の乾燥はプリンター本体の画面からのみ操作できます (Bambu の仕様上の制限)',
       stoppingDrying: '乾燥を停止しています...',
       rotateTray: '乾燥中にスプールを回転',
       rotateUnavailableReason: '利用不可 — このAMSのスロットがツールヘッドにロードされています。スプールが供給チューブで固定されているため回転できません。先にフィラメントを引き戻してください。',

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

@@ -535,6 +535,7 @@ export default {
       toastCommandSent: '건조 명령을 전송했습니다',
       toastStopped: '건조를 중지했습니다',
       toastNotStarted: '프린터가 명령을 수락했지만 AMS가 건조를 시작하지 않았습니다. AMS 전원 어댑터가 연결되어 있는지, 프린터가 대기 상태인지 확인하십시오.',
+      screenOnly: '이 프린터에서는 AMS 건조를 프린터 자체 화면에서만 제어할 수 있습니다 (Bambu 제한 사항)',
       stoppingDrying: '건조 정지 중...',
       rotateTray: '건조 중 스풀 회전',
       rotateUnavailableReason: '사용할 수 없음 — 이 AMS의 슬롯이 툴헤드로 로드되어 있습니다. 스풀이 공급 튜브에 의해 고정되어 회전할 수 없습니다. 먼저 필라멘트를 뺀 후 다시 시도하십시오.'

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

@@ -572,6 +572,7 @@ export default {
       toastCommandSent: 'Comando de secagem enviado',
       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.',
+      screenOnly: 'Nesta impressora, a secagem do AMS só pode ser controlada pela tela da própria impressora (limitação da Bambu)',
       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.',

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

@@ -572,6 +572,7 @@ export default {
       toastCommandSent: 'Kurutma komutu gönderildi',
       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.',
+      screenOnly: 'Bu yazıcıda AMS kurutma yalnızca yazıcının kendi ekranından kontrol edilebilir (Bambu kısıtlaması)',
       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.',

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

@@ -572,6 +572,7 @@ export default {
       toastCommandSent: '已发送干燥命令',
       toastStopped: '已停止干燥',
       toastNotStarted: '打印机已接受命令,但 AMS 未开始干燥。请检查 AMS 电源适配器是否已连接,以及打印机是否处于空闲状态。',
+      screenOnly: '此打印机的 AMS 干燥只能在打印机自带的屏幕上操作(Bambu 的限制)',
       stoppingDrying: '正在停止干燥...',
       rotateTray: '干燥时旋转料盘',
       rotateUnavailableReason: '不可用 — 此 AMS 中有插槽已装入打印头。料盘被送料管固定,无法旋转。请先回退耗材。',

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

@@ -572,6 +572,7 @@ export default {
       toastCommandSent: '已傳送乾燥命令',
       toastStopped: '已停止乾燥',
       toastNotStarted: '印表機已接受命令,但 AMS 未開始乾燥。請檢查 AMS 電源變壓器是否已連接,以及印表機是否處於閒置狀態。',
+      screenOnly: '此印表機的 AMS 乾燥只能在印表機本身的螢幕上操作(Bambu 的限制)',
       stoppingDrying: '正在停止乾燥...',
       rotateTray: '乾燥時旋轉料盤',
       rotateUnavailableReason: '無法使用 — 此 AMS 中有插槽已裝入列印頭。料盤被進料管固定,無法旋轉。請先退回耗材。',

+ 35 - 24
frontend/src/pages/PrintersPage.tsx

@@ -4549,10 +4549,12 @@ function PrinterCard({
                                       />
                                     </div>
                                   )}
-                                  {/* Drying button — only for AMS 2 Pro (n3f) and AMS-HT (n3s) */}
-                                  {status.supports_drying && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
+                                  {/* Drying button — only for AMS 2 Pro (n3f) and AMS-HT (n3s).
+                                      Screen-only models (P1 series) keep the control but can't
+                                      be commanded: it stays disabled and says why (#2533). */}
+                                  {(status.supports_drying || status.drying_screen_only) && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
                                     <button
-                                      disabled={!!(ams.dry_sf_reason?.length && ams.dry_time === 0)}
+                                      disabled={status.drying_screen_only || !!(ams.dry_sf_reason?.length && ams.dry_time === 0)}
                                       onClick={(e) => {
                                         if (ams.dry_time > 0) {
                                           stopDryingMutation.mutate(ams.id);
@@ -4576,11 +4578,11 @@ function PrinterCard({
                                       className={`ml-1 flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${
                                         ams.dry_time > 0
                                           ? 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400'
-                                          : ams.dry_sf_reason?.length
+                                          : status.drying_screen_only || ams.dry_sf_reason?.length
                                             ? 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
                                             : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80'
                                       }`}
-                                      title={ams.dry_time > 0 ? t('printers.drying.stop') : ams.dry_sf_reason?.length ? t('printers.drying.powerRequired') : t('printers.drying.start')}
+                                      title={status.drying_screen_only ? t('printers.drying.screenOnly') : ams.dry_time > 0 ? t('printers.drying.stop') : ams.dry_sf_reason?.length ? t('printers.drying.powerRequired') : t('printers.drying.start')}
                                     >
                                       <Flame className="w-3 h-3" />
                                     </button>
@@ -4605,14 +4607,18 @@ function PrinterCard({
                                       : `${ams.dry_time}m`
                                   })}
                                 </span>
-                                <button
-                                  onClick={() => stopDryingMutation.mutate(ams.id)}
-                                  disabled={stopDryingMutation.isPending}
-                                  className="ml-auto text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 transition-colors disabled:opacity-50"
-                                  title={t('printers.drying.stop')}
-                                >
-                                  <X className="w-3 h-3" />
-                                </button>
+                                {/* A cycle on a screen-only model was started at the printer
+                                    and can only be stopped there (#2533). */}
+                                {!status.drying_screen_only && (
+                                  <button
+                                    onClick={() => stopDryingMutation.mutate(ams.id)}
+                                    disabled={stopDryingMutation.isPending}
+                                    className="ml-auto text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 transition-colors disabled:opacity-50"
+                                    title={t('printers.drying.stop')}
+                                  >
+                                    <X className="w-3 h-3" />
+                                  </button>
+                                )}
                               </div>
                             )}
                             {/* Slots grid: 4 columns - always render 4 slots */}
@@ -5036,9 +5042,10 @@ function PrinterCard({
                                 )}
                               </div>
                               {/* Drying button for HT AMS */}
-                              {status.supports_drying && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
+                              {(status.supports_drying || status.drying_screen_only) && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
                                 <div className="relative ml-auto">
                                   <button
+                                    disabled={status.drying_screen_only}
                                     onClick={(e) => {
                                       if (ams.dry_time > 0) {
                                         stopDryingMutation.mutate(ams.id);
@@ -5062,9 +5069,11 @@ function PrinterCard({
                                     className={`flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${
                                       ams.dry_time > 0
                                         ? 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400'
-                                        : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80'
+                                        : status.drying_screen_only
+                                          ? 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
+                                          : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80'
                                     }`}
-                                    title={ams.dry_time > 0 ? t('printers.drying.stop') : t('printers.drying.start')}
+                                    title={status.drying_screen_only ? t('printers.drying.screenOnly') : ams.dry_time > 0 ? t('printers.drying.stop') : t('printers.drying.start')}
                                   >
                                     <Flame className="w-3 h-3" />
                                   </button>
@@ -5085,14 +5094,16 @@ function PrinterCard({
                                     ? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m`
                                     : `${ams.dry_time}m`}
                                 </span>
-                                <button
-                                  onClick={() => stopDryingMutation.mutate(ams.id)}
-                                  disabled={stopDryingMutation.isPending}
-                                  className="ml-auto text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 transition-colors disabled:opacity-50 shrink-0"
-                                  title={t('printers.drying.stop')}
-                                >
-                                  <X className="w-3 h-3" />
-                                </button>
+                                {!status.drying_screen_only && (
+                                  <button
+                                    onClick={() => stopDryingMutation.mutate(ams.id)}
+                                    disabled={stopDryingMutation.isPending}
+                                    className="ml-auto text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 transition-colors disabled:opacity-50 shrink-0"
+                                    title={t('printers.drying.stop')}
+                                  >
+                                    <X className="w-3 h-3" />
+                                  </button>
+                                )}
                               </div>
                             )}
                             {/* Row 2: Slot (left) + Stats (right stacked) */}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-D8XeZXKl.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-mGOsF6O8.js"></script>
+    <script type="module" crossorigin src="/assets/index-D8XeZXKl.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-blSspT6K.css">
   </head>
   <body>

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