瀏覽代碼

feat(drying): continue drying while printing + gate rotate-spool when tray loaded (issue #1816)

  Continue Auto-Drying while a print is running on capable hardware.
  New Settings > Print Queue > "Continue drying while printing" toggle
  (default OFF). Extends _check_auto_drying in print_scheduler.py to
  evaluate running printers when supports_drying_while_printing(model,
  firmware) returns true. Strict allowlist verified per Bambu wiki
  release notes for "Print While Drying" / "printing while filament is
  drying": H2D 01.03.00.00+, H2C/H2S/P2S/H2D Pro 01.02.00.00+, X2D/A2L
  01.01.00.00+, X1C 01.11.02.00+. P1*, A1, A1 Mini, X1 (non-C), X1E
  intentionally excluded. Mid-print drying temperature is capped at
  max(40, preset_temp - 5) to protect spools from heat damage inside the
  hot enclosure during a print, matching Bambu's own "lower drying
  temperature during printing" guidance.

  Rotate-spool toggle in the drying popover is now disabled when any tray
  in the targeted AMS has filament threaded into the feed tube
  (tray.state === 11). The whole AMS rotates as one mechanism, so a
  single loaded slot locks the entire unit. Previously the toggle was
  always clickable and the firmware rejected with dry_sf_reason=[3]
  (ConsumableAtAmsOutlet) after the click. The first cut keyed on the
  printer-level tray_now but missed the H2D's typical post-print state
  where tray_now resets to 255 while filament stays in the tube — the
  per-tray state field reports it correctly. Submission also clamps
  rotateTray off so a stale-true state from a previous AMS can't leak
  through.

  Backend: supports_drying_while_printing in printer_manager.py covers
  display names and internal SSDP/MQTT codes (O1D, O1E/O2D, O1C/O1C2,
  O1S, N6, BL-P001, N7, N9). New print_drying_enabled boolean in
  settings schema. Frontend: toggle on SettingsPage, gate + clamp on
  PrintersPage drying popover using existing amsData cache. i18n: 3 new
  keys x 11 locales, no English fallback. Tests: 7 cases on the gate
  matrix (TestSupportsDryingWhilePrinting), 4 cases on the scheduler
  mid-print path (TestMidPrintDrying), 9 cases on the rotate gate state
  transitions. Full backend pytest -n 30 green (4251/4251), ruff clean,
  frontend npm run build clean, i18n parity 5355 leaves per locale.
maziggy 2 月之前
父節點
當前提交
8d6f701f1d

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 2 - 1
README.md

@@ -160,9 +160,10 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - **AMS Filament Backup status + control with pair view** — Mirrors BambuStudio's per-printer "AMS Filament Backup" auto-switch (when a spool runs out, the printer rolls over to a same-preset, same-colour spool in another slot). A small badge in the Filaments section header on each printer card shows the live state (blue circular-arrow icon = ON, dim = OFF, "?" = A1 family with no `cfg` field yet); click to open the AMS Filament Backup modal — a BambuStudio Auto Refill-style ring graphic per backup pair, with the filament colour as the ring fill and member slot labels (e.g. `A·1`, `B·3`) on contrast-aware pills around the band. Dual-extruder printers (H2D / H2C / X2D) carry an `R` / `L` badge per ring because the firmware can't cross extruders. State syncs in real time whether you toggled from Bambuddy, BambuStudio, or the printer's touchscreen. Bambuddy's "insufficient filament" check is **backup-aware**: when Backup is ON, the deficit check pools remaining grams across same-`(preset, colour)` spools on the printer, so the warning doesn't fire spuriously when the firmware will swap to a peer mid-print (#1762). Bambuddy's **Prefer Lowest Remaining Filament** sort also respects the toggle — when Backup is OFF the dispatcher skips the prefer-lowest sort entirely so it won't reach for a near-empty spool the printer can't roll off of.
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
 - AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
-- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets, optional spool rotation; automatic PSU detection and HMS power error reporting
+- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets, optional spool rotation; automatic PSU detection and HMS power error reporting. Rotate-spool toggle is disabled per-AMS when any tray has filament threaded into the feed tube (the AMS mechanism is locked there — rotating would jam the filament)
 - **Queue auto-drying** — Automatically dry filament between scheduled prints when humidity exceeds threshold; configurable presets per filament type, optional blocking mode
 - **Ambient drying** — Automatically keep filament dry on idle printers based on humidity, regardless of whether prints are queued
+- **Continue drying while printing** — On capable hardware (H2D 01.03.00.00+, H2C / H2S / P2S / H2D Pro 01.02.00.00+, X2D / A2L 01.01.00.00+, X1C 01.11.02.00+), auto-drying can keep running during a print. Default off, opt-in toggle in Settings → Print Queue. Drying temperature is automatically capped 5°C below the idle preset (floor 40°C) to protect spools inside the hot enclosure
 - Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
 - **Per-filament humidity threshold** — Set a different humidity trigger per filament type (e.g. Nylon at 20%, PLA at 60%, ASA at 30%) instead of one global value. Mixed-material AMS units use the most-restrictive threshold across the loaded spools so a single PLA + Nylon unit triggers at Nylon's level. Drives both the auto-drying scheduler and the hourly humidity alarm so the two can never disagree on whether a unit is "too humid"
 - Dual external spool support for H2D (Ext-L / Ext-R)

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

@@ -55,6 +55,7 @@ from backend.app.services.printer_manager import (
     supports_chamber_heater,
     supports_chamber_temp,
     supports_drying,
+    supports_drying_while_printing,
 )
 from backend.app.utils.http import build_content_disposition
 
@@ -727,6 +728,7 @@ async def get_printer_status(
         ams_filament_backup=state.ams_filament_backup if state else None,
         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),
         supports_chamber_heater=supports_chamber_heater(printer.model),
         current_archive_id=current_archive_id,
         current_plate_id=current_plate_id,

+ 1 - 0
backend/app/api/routes/settings.py

@@ -128,6 +128,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "queue_drying_enabled",
             "queue_drying_block",
             "ambient_drying_enabled",
+            "print_drying_enabled",
             "require_plate_clear",
             "queue_shortest_first",
             "default_bed_levelling",

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

@@ -333,6 +333,9 @@ class PrinterStatus(BaseModel):
     awaiting_plate_clear: bool = False
     # AMS drying support
     supports_drying: bool = False
+    # 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
     # 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

+ 11 - 0
backend/app/schemas/settings.py

@@ -92,6 +92,16 @@ class AppSettings(BaseModel):
         default=False,
         description="Automatically dry AMS filament on idle printers when humidity exceeds threshold, regardless of queue",
     )
+    print_drying_enabled: bool = Field(
+        default=False,
+        description=(
+            "Allow auto-drying to also fire on a printer that is currently printing, "
+            "when its model+firmware supports concurrent drying (H2D 01.03.00.00+, "
+            "H2C/H2S/P2S/H2D Pro 01.02.00.00+, X2D/A2L 01.01.00.00+, X1C 01.11.02.00+). "
+            "Drying temperature is automatically capped 5 degC below the idle preset "
+            "(floor 40 degC) to protect spools during print."
+        ),
+    )
     drying_presets: str = Field(
         default="",
         description="JSON blob of drying presets per filament type (empty = use built-in defaults)",
@@ -425,6 +435,7 @@ class AppSettingsUpdate(BaseModel):
     queue_drying_enabled: bool | None = None
     queue_drying_block: bool | None = None
     ambient_drying_enabled: bool | None = None
+    print_drying_enabled: bool | None = None
     drying_presets: str | None = None
     ams_humidity_thresholds: str | None = None
     per_printer_mapping_expanded: bool | None = None

+ 56 - 26
backend/app/services/print_scheduler.py

@@ -31,7 +31,11 @@ from backend.app.services.bambu_ftp import (
 )
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.notification_service import notification_service
-from backend.app.services.printer_manager import printer_manager, supports_drying
+from backend.app.services.printer_manager import (
+    printer_manager,
+    supports_drying,
+    supports_drying_while_printing,
+)
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.utils.filename import derive_remote_filename
 from backend.app.utils.printer_models import normalize_printer_model
@@ -1591,12 +1595,17 @@ class PrintScheduler:
     ):
         """Start drying on idle printers based on humidity.
 
-        Two modes (can both be enabled):
+        Three modes (can all be enabled independently):
         - queue_drying_enabled: Dry between scheduled queue prints
         - ambient_drying_enabled: Dry any idle printer when humidity is high, regardless of queue
+        - print_drying_enabled: Also evaluate printers that are currently printing,
+          when model+firmware supports "Print While Drying" (gated by
+          supports_drying_while_printing). Drying temperature is capped at
+          max(40, preset_temp - 5) to protect spools mid-print.
         """
         queue_drying_enabled = await self._get_bool_setting(db, "queue_drying_enabled")
         ambient_drying_enabled = await self._get_bool_setting(db, "ambient_drying_enabled")
+        print_drying_enabled = await self._get_bool_setting(db, "print_drying_enabled")
         if not queue_drying_enabled and not ambient_drying_enabled:
             # Stop active drying on all printers if both features disabled
             if self._drying_in_progress:
@@ -1618,7 +1627,9 @@ class PrintScheduler:
                     printers_with_scheduled.add(item.printer_id)
 
         # If only queue mode is on and no printers have scheduled items, stop drying
-        if not ambient_drying_enabled and not printers_with_scheduled:
+        # (but skip this short-circuit when print_drying_enabled is on — busy printers
+        # may still be eligible for mid-print drying regardless of queue state).
+        if not ambient_drying_enabled and not printers_with_scheduled and not print_drying_enabled:
             for pid in list(self._drying_in_progress):
                 logger.info("Auto-drying: printer %d — stopping, no scheduled prints in queue", pid)
                 await self._stop_drying(pid)
@@ -1643,36 +1654,47 @@ class PrintScheduler:
         all_printers = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
         for printer in all_printers.scalars():
             pid = printer.id
-            if pid in busy_printers:
-                logger.debug("Auto-drying: printer %d skipped — busy", pid)
-                continue
-            # In queue-only mode, only dry printers that have scheduled prints
-            if not ambient_drying_enabled and pid not in printers_with_scheduled:
-                if self._drying_in_progress.get(pid):
-                    logger.info("Auto-drying: printer %d — stopping, no scheduled prints for this printer", pid)
-                    await self._stop_drying(pid)
-                logger.debug("Auto-drying: printer %d skipped — no scheduled prints", pid)
+
+            # Resolve model+firmware up front — needed to decide whether this printer
+            # qualifies for mid-print drying (busy printer on capable hardware).
+            state = printer_manager.get_status(pid)
+            if not state:
+                logger.debug("Auto-drying: printer %d skipped — no state", pid)
                 continue
-            # When block mode is on, don't START new drying on printers with pending items.
-            # But allow already-drying printers through so humidity auto-stop logic still runs.
-            if block_for_drying and pid in printers_with_items and not self._drying_in_progress.get(pid):
-                logger.debug("Auto-drying: printer %d skipped — has pending items (block mode)", pid)
+            model = printer_manager.get_model(pid)
+            firmware = state.firmware_version
+
+            mid_print = (
+                pid in busy_printers and print_drying_enabled and supports_drying_while_printing(model, firmware)
+            )
+
+            if pid in busy_printers and not mid_print:
+                logger.debug("Auto-drying: printer %d skipped — busy", pid)
                 continue
+
+            if not mid_print:
+                # In queue-only mode, only dry printers that have scheduled prints
+                if not ambient_drying_enabled and pid not in printers_with_scheduled:
+                    if self._drying_in_progress.get(pid):
+                        logger.info("Auto-drying: printer %d — stopping, no scheduled prints for this printer", pid)
+                        await self._stop_drying(pid)
+                    logger.debug("Auto-drying: printer %d skipped — no scheduled prints", pid)
+                    continue
+                # When block mode is on, don't START new drying on printers with pending items.
+                # But allow already-drying printers through so humidity auto-stop logic still runs.
+                if block_for_drying and pid in printers_with_items and not self._drying_in_progress.get(pid):
+                    logger.debug("Auto-drying: printer %d skipped — has pending items (block mode)", pid)
+                    continue
             if not printer_manager.is_connected(pid):
                 logger.debug("Auto-drying: printer %d skipped — not connected", pid)
                 continue
-            if not self._is_printer_idle(pid, require_plate_clear):
+            if not mid_print and not self._is_printer_idle(pid, require_plate_clear):
                 logger.debug("Auto-drying: printer %d skipped — not idle", pid)
                 continue
 
-            # Check if this printer supports drying
-            state = printer_manager.get_status(pid)
-            if not state:
-                logger.debug("Auto-drying: printer %d skipped — no state", pid)
-                continue
-            model = printer_manager.get_model(pid)
-            firmware = state.firmware_version
-            if not supports_drying(model, firmware):
+            # Check drying capability. For mid-print path, supports_drying_while_printing
+            # was already verified when computing mid_print above.
+            if not mid_print and not supports_drying(model, firmware):
                 logger.debug("Auto-drying: printer %d skipped — model %s does not support drying", pid, model)
                 continue
 
@@ -1773,10 +1795,17 @@ class PrintScheduler:
 
                 temp, duration_hours, filament_type = params
 
+                # Mid-print drying: cap drying temperature to protect spools (Bambu warns
+                # "drying temperature must not exceed the filament's softening temperature"
+                # for Print While Drying). Floor at 40 degC — below that the dryer is
+                # ineffective and firmware will reject anyway.
+                if mid_print:
+                    temp = max(40, temp - 5)
+
                 # Start drying
                 logger.info(
                     "Auto-drying: printer %d AMS %d — humidity %d%% > threshold %d%%, "
-                    "starting %s drying at %d°C for %dh",
+                    "starting %s drying at %d°C for %dh%s",
                     pid,
                     ams_id,
                     humidity,
@@ -1784,6 +1813,7 @@ class PrintScheduler:
                     filament_type,
                     temp,
                     duration_hours,
+                    " (mid-print)" if mid_print else "",
                 )
                 success = printer_manager.send_drying_command(
                     pid, ams_id, temp, duration_hours, mode=1, filament=filament_type

+ 46 - 0
backend/app/services/printer_manager.py

@@ -192,6 +192,51 @@ def supports_drying(model: str | None, firmware: str | None) -> bool:
     return True
 
 
+# Minimum firmware versions for AMS "Print While Drying" — drying that runs CONCURRENTLY
+# with an active print. Strictly stricter than _DRYING_MIN_FIRMWARE (idle drying). Verified
+# against Bambu wiki release notes — the canonical phrasing on every supported model is
+# "printing while filament is drying" / "Print While Drying". Models absent from the wiki
+# release notes (A1, A1 Mini, P1*, X1 non-C, X1E) are intentionally excluded — the firmware
+# will reject the command in those cases anyway via dry_sf_reason=[0] (TaskOccupied).
+_DRY_WHILE_PRINTING_MIN_FIRMWARE: dict[str, str] = {
+    "H2D": "01.03.00.00",
+    "H2D PRO": "01.02.00.00",
+    "H2DPRO": "01.02.00.00",
+    "O1E": "01.02.00.00",  # H2D Pro SSDP code
+    "O2D": "01.02.00.00",  # H2D Pro alternate code
+    "H2C": "01.02.00.00",
+    "O1C": "01.02.00.00",  # H2C SSDP code
+    "O1C2": "01.02.00.00",  # H2C dual-nozzle SSDP code
+    "H2S": "01.02.00.00",
+    "X2D": "01.01.00.00",
+    "N6": "01.01.00.00",  # X2D internal code
+    "X1C": "01.11.02.00",
+    "BL-P001": "01.11.02.00",  # X1C internal code
+    "P2S": "01.02.00.00",
+    "N7": "01.02.00.00",  # P2S internal code
+    "A2L": "01.01.00.00",
+    "N9": "01.01.00.00",  # A2L internal code
+}
+
+
+def supports_drying_while_printing(model: str | None, firmware: str | None) -> bool:
+    """Check if a printer model+firmware supports running AMS drying CONCURRENTLY
+    with an active print.
+
+    Distinct from supports_drying() — that gates idle drying. This gate is strict:
+    only models explicitly confirmed by Bambu wiki release notes are allowed.
+    On unsupported models the firmware returns dry_sf_reason=[0] (TaskOccupied)
+    while a print is running, so being conservative here costs nothing — the
+    firmware is the ultimate arbiter, this gate just hides UI affordances.
+    """
+    if not model:
+        return False
+    model_upper = model.strip().upper()
+    if model_upper not in _DRY_WHILE_PRINTING_MIN_FIRMWARE:
+        return False
+    return bool(firmware and firmware >= _DRY_WHILE_PRINTING_MIN_FIRMWARE[model_upper])
+
+
 class PrinterInfo:
     """Basic printer info for callbacks."""
 
@@ -1084,6 +1129,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         ],
         # AMS drying support
         "supports_drying": supports_drying(model, state.firmware_version),
+        "supports_drying_while_printing": supports_drying_while_printing(model, state.firmware_version),
         # 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).

+ 86 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -17,6 +17,7 @@ from backend.app.services.printer_manager import (
     printer_state_to_dict,
     supports_chamber_temp,
     supports_drying,
+    supports_drying_while_printing,
 )
 
 
@@ -1416,6 +1417,91 @@ class TestSupportsDrying:
         assert supports_drying("a1", "99.99.99.99") is False
 
 
+class TestSupportsDryingWhilePrinting:
+    """Tests for the supports_drying_while_printing gate (concurrent drying during print).
+
+    Stricter than supports_drying — only models explicitly confirmed by Bambu wiki
+    release notes are allowed (verified phrase: "printing while filament is drying"
+    / "Print While Drying").
+    """
+
+    def test_known_supported_with_firmware(self):
+        """Matrix-confirmed models with min firmware return True."""
+        assert supports_drying_while_printing("H2D", "01.03.00.00") is True
+        assert supports_drying_while_printing("H2D Pro", "01.02.00.00") is True
+        assert supports_drying_while_printing("O1E", "01.02.00.00") is True
+        assert supports_drying_while_printing("O2D", "01.02.00.00") is True
+        assert supports_drying_while_printing("H2C", "01.02.00.00") is True
+        assert supports_drying_while_printing("O1C", "01.02.00.00") is True
+        assert supports_drying_while_printing("O1C2", "01.02.00.00") is True
+        assert supports_drying_while_printing("H2S", "01.02.00.00") is True
+        assert supports_drying_while_printing("X2D", "01.01.00.00") is True
+        assert supports_drying_while_printing("N6", "01.01.00.00") is True
+        assert supports_drying_while_printing("X1C", "01.11.02.00") is True
+        assert supports_drying_while_printing("BL-P001", "01.11.02.00") is True
+        assert supports_drying_while_printing("P2S", "01.02.00.00") is True
+        assert supports_drying_while_printing("N7", "01.02.00.00") is True
+        assert supports_drying_while_printing("A2L", "01.01.00.00") is True
+        assert supports_drying_while_printing("N9", "01.01.00.00") is True
+
+    def test_known_supported_below_min_firmware(self):
+        """Matrix-confirmed models on too-old firmware return False."""
+        assert supports_drying_while_printing("H2D", "01.02.30.00") is False
+        assert supports_drying_while_printing("X1C", "01.11.01.00") is False
+        assert supports_drying_while_printing("P2S", "01.01.99.99") is False
+        assert supports_drying_while_printing("H2S", "01.01.99.99") is False
+        assert supports_drying_while_printing("A2L", "01.00.99.99") is False
+
+    def test_not_in_matrix_excluded(self):
+        """Models absent from the matrix return False regardless of firmware.
+
+        P1*, A1, A1 Mini, X1 (non-C), X1E are intentionally excluded — their wiki
+        release notes never mention "Print While Drying" / "printing while filament
+        is drying".
+        """
+        for model in [
+            "P1P",
+            "P1S",
+            "C11",
+            "C12",
+            "A1",
+            "A1 MINI",
+            "A1MINI",
+            "N1",
+            "N2S",
+            "X1",
+            "X1E",
+            "BL-P002",
+            "C13",
+        ]:
+            assert supports_drying_while_printing(model, "99.99.99.99") is False, f"Expected False for {model}"
+
+    def test_no_firmware_returns_false(self):
+        """Missing firmware version returns False even for supported models."""
+        assert supports_drying_while_printing("H2D", None) is False
+        assert supports_drying_while_printing("P2S", None) is False
+
+    def test_none_model_returns_false(self):
+        """None model returns False."""
+        assert supports_drying_while_printing(None, "01.03.00.00") is False
+
+    def test_case_insensitive(self):
+        """Model matching is case-insensitive."""
+        assert supports_drying_while_printing("h2d", "01.03.00.00") is True
+        assert supports_drying_while_printing("p2s", "01.02.00.00") is True
+        assert supports_drying_while_printing("a1", "99.99.99.99") is False
+
+    def test_unknown_model_returns_false(self):
+        """Unknown models default to FALSE (strict gate — not the lenient default-allow).
+
+        This contrasts with supports_drying which defaults to True for unknown
+        models. For while-printing the cost of being wrong is real (firmware
+        rejection mid-print is annoying; melted spool is worse), so we err
+        toward conservative.
+        """
+        assert supports_drying_while_printing("FUTURE_MODEL", "99.99.99.99") is False
+
+
 class TestGetDerivedStatusName:
     """Tests for get_derived_status_name function."""
 

+ 178 - 0
backend/tests/unit/test_scheduler_auto_drying.py

@@ -1005,3 +1005,181 @@ class TestGetHumidityThresholds:
         db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
         result = await scheduler._get_humidity_thresholds(db)
         assert result == {"default": 60, "PLA": 50, "ASA": 30}
+
+
+class TestMidPrintDrying(_DryingTestBase):
+    """Tests for the print_drying_enabled path — drying that runs CONCURRENTLY
+    with an active print on capable hardware (H2D / H2C / H2S / P2S / X2D / X1C /
+    A2L / H2D Pro on recent firmware). Distinct from idle drying.
+
+    Verifies:
+      - With the toggle ON and capable hardware, a printer in the busy set is
+        still evaluated and drying fires at the capped temperature.
+      - The temperature cap is max(40, preset_temp - 5) — protects spools.
+      - With the toggle OFF, the existing busy-printer skip still applies.
+      - With the toggle ON but unsupported firmware, the busy-printer skip
+        still applies (gated by supports_drying_while_printing).
+    """
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @staticmethod
+    def _ams_unit(humidity: str = "75"):
+        return {
+            "id": 0,
+            "module_type": "n3f",
+            "dry_time": 0,
+            "humidity_raw": humidity,
+            "dry_sf_reason": [],
+            "tray": [{"tray_type": "PLA"}],
+        }
+
+    def _state(self, firmware: str):
+        state = MagicMock()
+        state.raw_data = {"ams": [self._ams_unit()]}
+        state.firmware_version = firmware
+        return state
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_running_printer_dries_when_enabled_and_capable(self, mock_pm, scheduler):
+        """Toggle ON + capable hardware: running printer dries at capped temp."""
+        mock_pm.get_status.return_value = self._state("01.03.00.00")
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "H2D"
+        mock_pm.send_drying_command.return_value = True
+
+        scheduler._is_printer_idle = MagicMock(return_value=False)
+        db = AsyncMock()
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "print_drying_enabled": self._make_setting("true"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        # Printer 1 is in busy_printers — would normally be skipped
+        await scheduler._check_auto_drying(db, [], {1})
+
+        # PLA preset is 45 degC for n3f; mid-print cap is max(40, 45-5) = 40
+        mock_pm.send_drying_command.assert_called_once_with(1, 0, 40, 12, mode=1, filament="PLA")
+        assert 1 in scheduler._drying_in_progress
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_temp_cap_applied_above_floor(self, mock_pm, scheduler):
+        """Higher-temp filament (PETG n3f=65) caps to 60, not floor."""
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": 0,
+                    "humidity_raw": "75",
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PETG"}],
+                }
+            ]
+        }
+        state.firmware_version = "01.03.00.00"
+        mock_pm.get_status.return_value = state
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "H2D"
+        mock_pm.send_drying_command.return_value = True
+
+        scheduler._is_printer_idle = MagicMock(return_value=False)
+        db = AsyncMock()
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "print_drying_enabled": self._make_setting("true"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        await scheduler._check_auto_drying(db, [], {1})
+
+        # PETG preset 65 -> max(40, 65-5) = 60
+        mock_pm.send_drying_command.assert_called_once_with(1, 0, 60, 12, mode=1, filament="PETG")
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
+    async def test_running_printer_skipped_when_toggle_off(self, mock_sd, mock_pm, scheduler):
+        """Toggle OFF: running printer is skipped even on capable hardware."""
+        mock_pm.get_status.return_value = self._state("01.03.00.00")
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "H2D"
+
+        scheduler._is_printer_idle = MagicMock(return_value=False)
+        db = AsyncMock()
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "print_drying_enabled": self._make_setting("false"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        await scheduler._check_auto_drying(db, [], {1})
+
+        mock_pm.send_drying_command.assert_not_called()
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_running_printer_skipped_when_firmware_too_old(self, mock_pm, scheduler):
+        """Toggle ON but firmware below matrix threshold: skip."""
+        # H2D matrix minimum is 01.03.00.00; this is below
+        mock_pm.get_status.return_value = self._state("01.02.30.00")
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "H2D"
+
+        scheduler._is_printer_idle = MagicMock(return_value=False)
+        db = AsyncMock()
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "print_drying_enabled": self._make_setting("true"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        await scheduler._check_auto_drying(db, [], {1})
+
+        mock_pm.send_drying_command.assert_not_called()
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_running_printer_skipped_when_model_excluded(self, mock_pm, scheduler):
+        """Toggle ON, recent firmware, but excluded model (A1): skip."""
+        mock_pm.get_status.return_value = self._state("99.99.99.99")
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "A1"
+
+        scheduler._is_printer_idle = MagicMock(return_value=False)
+        db = AsyncMock()
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "print_drying_enabled": self._make_setting("true"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        await scheduler._check_auto_drying(db, [], {1})
+
+        mock_pm.send_drying_command.assert_not_called()

+ 105 - 0
frontend/src/__tests__/pages/PrintersPageDrying.test.ts

@@ -219,3 +219,108 @@ describe('rotate tray option', () => {
     expect(rotateTray).toBe(false);
   });
 });
+
+describe('rotate tray gate (per-AMS tray.state === 11)', () => {
+  /**
+   * Mirrors the gate from PrintersPage.tsx — rotation is physically impossible
+   * when ANY tray in the targeted AMS has its filament threaded out into the
+   * feed tube. The whole AMS rotates as one mechanism (all 4 spools turn
+   * together), so a single loaded slot locks the entire unit.
+   *
+   * Per-tray Bambu `state`:
+   *   9  = empty (no spool)
+   *   10 = spool present, NOT loaded into tube (rotation possible)
+   *   11 = loaded into tube (rotation impossible)
+   *
+   * This catches both mid-print (active feed) AND idle-with-threaded-filament
+   * — the H2D's post-print state leaves filament in the tube but tray_now
+   * resets to 255, which a tray_now-only check would silently miss.
+   */
+  type TrayLike = { state?: number };
+  type AmsLike = { id: number; tray?: TrayLike[] };
+
+  function isTrayLoadedInThisAms(
+    amsData: AmsLike[],
+    targetAmsId: number | null,
+  ): boolean {
+    if (targetAmsId === null) return false;
+    const targetAms = amsData.find(a => a.id === targetAmsId);
+    return (targetAms?.tray ?? []).some(tray => tray.state === 11);
+  }
+
+  it('returns false when AMS id is null (modal closed)', () => {
+    const ams = [{ id: 0, tray: [{ state: 11 }] }];
+    expect(isTrayLoadedInThisAms(ams, null)).toBe(false);
+  });
+
+  it('returns false when targeted AMS not found in amsData', () => {
+    const ams = [{ id: 0, tray: [{ state: 11 }] }];
+    expect(isTrayLoadedInThisAms(ams, 1)).toBe(false);
+  });
+
+  it('returns false when all trays are empty (state=9)', () => {
+    const ams = [{ id: 0, tray: [{ state: 9 }, { state: 9 }, { state: 9 }, { state: 9 }] }];
+    expect(isTrayLoadedInThisAms(ams, 0)).toBe(false);
+  });
+
+  it('returns false when all trays have spools but none loaded (state=10)', () => {
+    // The "all AMS have spools loaded" case the gate now catches correctly:
+    // spool present in the slot, NOT threaded into the tube → rotation possible.
+    const ams = [{ id: 0, tray: [{ state: 10 }, { state: 10 }, { state: 10 }, { state: 10 }] }];
+    expect(isTrayLoadedInThisAms(ams, 0)).toBe(false);
+  });
+
+  it('returns true when ANY tray is loaded into tube (state=11)', () => {
+    // H2D's typical post-print state: one tray's filament is still threaded out
+    // into the feed tube even after the print finishes. tray_now=255 but
+    // this tray's state stays at 11. The whole AMS is mechanically locked.
+    const ams = [{ id: 0, tray: [{ state: 10 }, { state: 11 }, { state: 9 }, { state: 10 }] }];
+    expect(isTrayLoadedInThisAms(ams, 0)).toBe(true);
+  });
+
+  it('returns true when targeted AMS-B has a loaded tray (per-AMS isolation)', () => {
+    // AMS-A locked, AMS-B free; targeting AMS-A → true, targeting AMS-B → false
+    const ams = [
+      { id: 0, tray: [{ state: 11 }, { state: 9 }] },
+      { id: 1, tray: [{ state: 10 }, { state: 10 }] },
+    ];
+    expect(isTrayLoadedInThisAms(ams, 0)).toBe(true);
+    expect(isTrayLoadedInThisAms(ams, 1)).toBe(false);
+  });
+
+  it('returns false when targeted AMS has no trays array', () => {
+    const ams = [{ id: 0 }];
+    expect(isTrayLoadedInThisAms(ams, 0)).toBe(false);
+  });
+
+  it('treats missing state as not-loaded (conservative default-allow)', () => {
+    // If firmware doesn't report a state field, default to allowing rotate.
+    // The firmware-side dry_sf_reason check still rejects on the route side
+    // if rotation is actually impossible, so being lenient here is safe.
+    const ams = [{ id: 0, tray: [{ state: undefined }, { state: undefined }] }];
+    expect(isTrayLoadedInThisAms(ams, 0)).toBe(false);
+  });
+
+  it('submission clamp: rotateTray collapses to false when gate is active', () => {
+    // Mirrors:  rotateTray: dryingRotateTray && !isTrayLoadedInThisAms
+    // A user enabling rotate before tray.state shifts to 11 (e.g. user loads
+    // filament from the AMS UI while popover is open) sees the toggle disable,
+    // and the submit also sends rotate_tray=false. Without the clamp, firmware
+    // would reject with dry_sf_reason=[3] (ConsumableAtAmsOutlet) post-click.
+    const userToggleState = true;
+    const ams = [{ id: 0, tray: [{ state: 11 }, { state: 10 }] }];
+    const trayLoaded = isTrayLoadedInThisAms(ams, 0);
+    const submittedValue = userToggleState && !trayLoaded;
+    expect(trayLoaded).toBe(true);
+    expect(submittedValue).toBe(false);
+  });
+
+  it('submission clamp: rotateTray passes through when gate is inactive', () => {
+    const userToggleState = true;
+    const ams = [{ id: 0, tray: [{ state: 10 }, { state: 10 }] }];
+    const trayLoaded = isTrayLoadedInThisAms(ams, 0);
+    const submittedValue = userToggleState && !trayLoaded;
+    expect(trayLoaded).toBe(false);
+    expect(submittedValue).toBe(true);
+  });
+});

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

@@ -1097,6 +1097,7 @@ export interface AppSettings {
   queue_drying_enabled: boolean;  // Auto-dry AMS between queued prints
   queue_drying_block: boolean;  // Block queue until drying completes
   ambient_drying_enabled: boolean;  // Auto-dry idle printers based on humidity regardless of queue
+  print_drying_enabled: boolean;  // Continue drying while a print is running on capable hardware
   drying_presets: string;  // JSON blob of drying presets per filament type
   ams_humidity_thresholds: string;  // JSON blob of per-filament humidity thresholds (#1605)
   gcode_snippets: string;  // JSON: per-model G-code injection snippets

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

@@ -540,6 +540,7 @@ export default {
       startingDrying: 'Trocknung wird gestartet...',
       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.',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup ist EIN. Zum Deaktivieren klicken.',
@@ -2012,6 +2013,8 @@ export default {
     queueDryingBlockDescription: 'Druckwarteschlange blockieren, bis die Trocknung abgeschlossen ist. Wenn aus, haben Drucke Vorrang.',
     ambientDryingEnabled: 'Umgebungstrocknung',
     ambientDryingEnabledDescription: 'Filament auf inaktiven Druckern automatisch trocknen, wenn die Luftfeuchtigkeit den Schwellenwert überschreitet — auch ohne Warteschlange.',
+    printDryingEnabled: 'Trocknen während des Drucks',
+    printDryingEnabledDescription: 'Automatische Trocknung auch während eines laufenden Drucks auf unterstützter Hardware (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L mit aktueller Firmware). Die Trocknungstemperatur wird zum Schutz der Spulen automatisch um 5°C unter dem Leerlaufwert begrenzt.',
     dryingPresets: 'Trocknungsvoreinstellungen',
     dryingPresetsDescription: 'Temperatur und Dauer pro Filamenttyp. AMS 2 Pro verwendet niedrigere Temperaturen, AMS-HT unterstützt höhere.',
     dryingFilament: 'Filament',

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

@@ -543,6 +543,7 @@ export default {
       startingDrying: 'Starting drying...',
       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.',
     },
     // AMS Filament Backup status badge (printer-wide auto-switch to another spool)
     amsBackup: {
@@ -2026,6 +2027,8 @@ export default {
     queueDryingBlockDescription: 'Block the print queue until drying finishes. When off, prints take priority over drying.',
     ambientDryingEnabled: 'Ambient drying',
     ambientDryingEnabledDescription: 'Automatically dry filament on idle printers when humidity exceeds threshold, even without queued prints.',
+    printDryingEnabled: 'Continue drying while printing',
+    printDryingEnabledDescription: 'Allow auto-drying to keep running during a print on supported hardware (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L on recent firmware). Drying temperature is automatically capped 5°C below the idle preset to protect spools.',
     dryingPresets: 'Drying Presets',
     dryingPresetsDescription: 'Temperature and duration per filament type. AMS 2 Pro uses lower temps, AMS-HT supports higher temps.',
     dryingFilament: 'Filament',

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

@@ -540,6 +540,7 @@ export default {
       startingDrying: 'Iniciando el secado...',
       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.',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup está ACTIVADO. Haz clic para desactivar.',
@@ -2015,6 +2016,8 @@ export default {
     queueDryingBlockDescription: 'Bloquear la cola de impresión hasta que termine el secado. Cuando está desactivado, las impresiones tienen prioridad sobre el secado.',
     ambientDryingEnabled: 'Secado ambiental',
     ambientDryingEnabledDescription: 'Secar automáticamente el filamento en impresoras inactivas cuando la humedad supera el umbral, incluso sin impresiones en cola.',
+    printDryingEnabled: 'Continuar secado durante la impresión',
+    printDryingEnabledDescription: 'Permite que el secado automático siga funcionando durante una impresión en hardware compatible (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L con firmware reciente). La temperatura de secado se limita automáticamente 5°C por debajo del preajuste en reposo para proteger las bobinas.',
     dryingPresets: 'Preajustes de secado',
     dryingPresetsDescription: 'Temperatura y duración por tipo de filamento. El AMS 2 Pro usa temperaturas más bajas; el AMS-HT admite temperaturas más altas.',
     dryingFilament: 'Filamento',

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

@@ -540,6 +540,7 @@ export default {
       startingDrying: 'Démarrage du séchage...',
       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.',
     },
     amsBackup: {
       titleOn: "AMS Filament Backup est ACTIVÉ. Cliquez pour désactiver.",
@@ -1968,6 +1969,8 @@ export default {
     queueDryingBlockDescription: 'Bloquer la file d\'attente jusqu\'à la fin du séchage. Désactivé, les impressions sont prioritaires.',
     ambientDryingEnabled: 'Séchage ambiant',
     ambientDryingEnabledDescription: 'Sécher automatiquement le filament sur les imprimantes inactives lorsque l\'humidité dépasse le seuil, même sans impressions en file.',
+    printDryingEnabled: 'Séchage pendant l\'impression',
+    printDryingEnabledDescription: 'Autorise le séchage automatique à continuer pendant une impression sur le matériel pris en charge (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L avec firmware récent). La température de séchage est automatiquement limitée à 5°C sous le préréglage en attente pour protéger les bobines.',
     dryingPresets: 'Préréglages de séchage',
     dryingPresetsDescription: 'Température et durée par type de filament. AMS 2 Pro utilise des températures plus basses, AMS-HT supporte des températures plus élevées.',
     dryingFilament: 'Filament',

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

@@ -540,6 +540,7 @@ export default {
       startingDrying: 'Avvio essiccazione...',
       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.',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup è ATTIVO. Clicca per disabilitare.',
@@ -1968,6 +1969,8 @@ export default {
     queueDryingBlockDescription: 'Blocca la coda di stampa fino al completamento dell\'asciugatura. Se disattivato, le stampe hanno priorità.',
     ambientDryingEnabled: 'Asciugatura ambientale',
     ambientDryingEnabledDescription: 'Asciuga automaticamente il filamento sulle stampanti inattive quando l\'umidità supera la soglia, anche senza stampe in coda.',
+    printDryingEnabled: 'Asciugatura durante la stampa',
+    printDryingEnabledDescription: 'Consente all\'asciugatura automatica di continuare durante una stampa su hardware supportato (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L con firmware recente). La temperatura di asciugatura viene automaticamente limitata a 5°C sotto il preset di riposo per proteggere le bobine.',
     dryingPresets: 'Preset di asciugatura',
     dryingPresetsDescription: 'Temperatura e durata per tipo di filamento. AMS 2 Pro usa temperature più basse, AMS-HT supporta temperature più alte.',
     dryingFilament: 'Filamento',

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

@@ -539,6 +539,7 @@ export default {
       startingDrying: '乾燥を開始しています...',
       stoppingDrying: '乾燥を停止しています...',
       rotateTray: '乾燥中にスプールを回転',
+      rotateUnavailableReason: '利用不可 — このAMSのスロットがツールヘッドにロードされています。スプールが供給チューブで固定されているため回転できません。先にフィラメントを引き戻してください。',
     },
     amsBackup: {
       titleOn: 'AMSフィラメントバックアップはONです。クリックして無効化します。',
@@ -2011,6 +2012,8 @@ export default {
     queueDryingBlockDescription: '乾燥が完了するまで印刷キューをブロックします。オフの場合、印刷が優先されます。',
     ambientDryingEnabled: '常時乾燥',
     ambientDryingEnabledDescription: 'キューに関係なく、アイドル状態のプリンターで湿度がしきい値を超えた場合に自動的にフィラメントを乾燥。',
+    printDryingEnabled: '印刷中も乾燥を継続',
+    printDryingEnabledDescription: '対応ハードウェア(H2D、H2C、H2S、P2S、H2D Pro、X2D、X1C、A2L、最新ファームウェア)で印刷中も自動乾燥を継続します。スプール保護のため、乾燥温度はアイドル時のプリセットより自動的に5°C低く制限されます。',
     dryingPresets: '乾燥プリセット',
     dryingPresetsDescription: 'フィラメントタイプごとの温度と時間。AMS 2 Proは低温、AMS-HTは高温に対応。',
     dryingFilament: 'フィラメント',

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

@@ -502,7 +502,8 @@ export default {
       powerRequired: '건조를 활성화하려면 AMS 전원 어댑터를 연결하세요',
       startingDrying: '건조 시작 중...',
       stoppingDrying: '건조 정지 중...',
-      rotateTray: '건조 중 스풀 회전'
+      rotateTray: '건조 중 스풀 회전',
+      rotateUnavailableReason: '사용할 수 없음 — 이 AMS의 슬롯이 툴헤드로 로드되어 있습니다. 스풀이 공급 튜브에 의해 고정되어 회전할 수 없습니다. 먼저 필라멘트를 뺀 후 다시 시도하십시오.'
     },
     amsBackup: {
       titleOn: 'AMS 필라멘트 백업이 켜져 있습니다. 비활성화하려면 클릭하세요.',
@@ -1894,6 +1895,8 @@ export default {
     queueDryingBlockDescription: '건조가 완료될 때까지 인쇄 대기열을 차단합니다. 끄면 인쇄가 건조보다 우선합니다.',
     ambientDryingEnabled: '주변 건조',
     ambientDryingEnabledDescription: '대기 중인 인쇄가 없어도 습도가 임계값을 초과하면 유휴 프린터에서 자동으로 필라멘트 건조',
+    printDryingEnabled: '인쇄 중 건조 계속',
+    printDryingEnabledDescription: '지원되는 하드웨어(H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L 최신 펌웨어)에서 인쇄 중에도 자동 건조를 계속 실행합니다. 스풀 보호를 위해 건조 온도가 유휴 프리셋보다 자동으로 5°C 낮게 제한됩니다.',
     dryingPresets: '건조 프리셋',
     dryingPresetsDescription: '필라멘트 유형별 온도 및 시간. AMS 2 Pro는 낮은 온도, AMS-HT는 높은 온도를 지원합니다.',
     dryingFilament: '필라멘트',

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

@@ -540,6 +540,7 @@ export default {
       startingDrying: 'Iniciando secagem...',
       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.',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup está LIGADO. Clique para desativar.',
@@ -1968,6 +1969,8 @@ export default {
     queueDryingBlockDescription: 'Bloquear a fila de impressão até a secagem terminar. Quando desativado, impressões têm prioridade.',
     ambientDryingEnabled: 'Secagem ambiente',
     ambientDryingEnabledDescription: 'Secar automaticamente o filamento em impressoras ociosas quando a umidade exceder o limite, mesmo sem impressões na fila.',
+    printDryingEnabled: 'Continuar secagem durante a impressão',
+    printDryingEnabledDescription: 'Permite que a secagem automática continue funcionando durante uma impressão em hardware compatível (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L com firmware recente). A temperatura de secagem é limitada automaticamente 5°C abaixo do valor de inatividade para proteger as bobinas.',
     dryingPresets: 'Predefinições de secagem',
     dryingPresetsDescription: 'Temperatura e duração por tipo de filamento. AMS 2 Pro usa temperaturas mais baixas, AMS-HT suporta temperaturas mais altas.',
     dryingFilament: 'Filamento',

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

@@ -540,6 +540,7 @@ export default {
       startingDrying: 'Kurutma başlatılıyor...',
       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.',
     },
     amsBackup: {
       titleOn: 'AMS Filament Backup AÇIK. Devre dışı bırakmak için tıklayın.',
@@ -2015,6 +2016,8 @@ export default {
     queueDryingBlockDescription: 'Kurutma bitene kadar baskı kuyruğunu engelle. Kapalıyken, baskılar kurutmadan önceliklidir.',
     ambientDryingEnabled: 'Ortam kurutma',
     ambientDryingEnabledDescription: 'Kuyrukta baskı olmasa bile, nem eşiği aştığında boşta yazıcılarda filamenti otomatik olarak kurut.',
+    printDryingEnabled: 'Baskı sırasında kurutmaya devam et',
+    printDryingEnabledDescription: 'Desteklenen donanımda (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L güncel firmware ile) baskı sırasında otomatik kurutmanın çalışmaya devam etmesine izin verir. Makara koruması için kurutma sıcaklığı otomatik olarak boştaki ön ayarın 5°C altına sınırlandırılır.',
     dryingPresets: 'Kurutma Ön Ayarları',
     dryingPresetsDescription: 'Filament türü başına sıcaklık ve süre. AMS 2 Pro daha düşük sıcaklıklar kullanır, AMS-HT daha yüksek sıcaklıkları destekler.',
     dryingFilament: 'Filament',

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

@@ -540,6 +540,7 @@ export default {
       startingDrying: '正在启动干燥...',
       stoppingDrying: '正在停止干燥...',
       rotateTray: '干燥时旋转料盘',
+      rotateUnavailableReason: '不可用 — 此 AMS 中有插槽已装入打印头。料盘被送料管固定,无法旋转。请先回退耗材。',
     },
     amsBackup: {
       titleOn: 'AMS 备用料盘已开启。点击以禁用。',
@@ -2013,6 +2014,8 @@ export default {
     queueDryingBlockDescription: '阻止打印队列直到干燥完成。关闭时,打印优先于干燥。',
     ambientDryingEnabled: '环境干燥',
     ambientDryingEnabledDescription: '当空闲打印机的湿度超过阈值时自动干燥耗材,无需排队打印。',
+    printDryingEnabled: '打印时继续干燥',
+    printDryingEnabledDescription: '允许自动干燥在支持的硬件(H2D、H2C、H2S、P2S、H2D Pro、X2D、X1C、A2L 最新固件)打印过程中继续运行。为保护料盘,干燥温度会自动比空闲时预设低 5°C。',
     dryingPresets: '干燥预设',
     dryingPresetsDescription: '每种耗材类型的温度和时长。AMS 2 Pro使用较低温度,AMS-HT支持较高温度。',
     dryingFilament: '耗材',

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

@@ -540,6 +540,7 @@ export default {
       startingDrying: '正在啟動乾燥...',
       stoppingDrying: '正在停止乾燥...',
       rotateTray: '乾燥時旋轉料盤',
+      rotateUnavailableReason: '無法使用 — 此 AMS 中有插槽已裝入列印頭。料盤被進料管固定,無法旋轉。請先退回耗材。',
     },
     amsBackup: {
       titleOn: 'AMS 備用料盤已開啟。點擊以停用。',
@@ -2013,6 +2014,8 @@ export default {
     queueDryingBlockDescription: '阻止列印佇列直到乾燥完成。關閉時,列印優先於乾燥。',
     ambientDryingEnabled: '環境乾燥',
     ambientDryingEnabledDescription: '當空閒印表機的濕度超過閾值時自動乾燥耗材,無需佇列列印。',
+    printDryingEnabled: '列印時繼續乾燥',
+    printDryingEnabledDescription: '允許自動乾燥在支援的硬體(H2D、H2C、H2S、P2S、H2D Pro、X2D、X1C、A2L 最新韌體)列印過程中繼續執行。為保護料盤,乾燥溫度會自動比閒置時的預設低 5°C。',
     dryingPresets: '乾燥預設',
     dryingPresetsDescription: '每種耗材類型的溫度和時長。AMS 2 Pro使用較低溫度,AMS-HT支援較高溫度。',
     dryingFilament: '耗材',

+ 52 - 14
frontend/src/pages/PrintersPage.tsx

@@ -6371,19 +6371,41 @@ function PrinterCard({
                     <span>24h</span>
                   </div>
                 </div>
-                {/* Rotate tray */}
-                <button
-                  type="button"
-                  onClick={() => setDryingRotateTray(enabled => !enabled)}
-                  aria-pressed={dryingRotateTray}
-                  className={`h-8 w-full rounded-lg border px-2 text-sm font-medium transition-colors ${
-                    dryingRotateTray
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary'
-                  }`}
-                >
-                  {t('printers.drying.rotateTray')}
-                </button>
+                {/* Rotate tray — disabled when any tray in THIS AMS has its
+                    filament threaded out into the feed tube. The whole AMS
+                    rotates as one mechanism (all 4 spools turn together), so a
+                    single loaded slot locks the entire unit. Bambu per-tray
+                    `state`: 9 = empty, 10 = spool present but not loaded
+                    (rotation possible), 11 = loaded into tube (rotation impossible).
+                    Catches both mid-print (active feed) AND idle-with-threaded-
+                    filament — the H2D's post-print state leaves filament in the
+                    tube but tray_now resets to 255, which a tray_now-only check
+                    would silently miss. */}
+                {(() => {
+                  const targetAms = dryingPopoverAmsId !== null
+                    ? amsData.find(a => a.id === dryingPopoverAmsId)
+                    : undefined;
+                  const trayLoadedInThisAms = (targetAms?.tray ?? []).some(
+                    tray => tray.state === 11,
+                  );
+                  const rotateChecked = dryingRotateTray && !trayLoadedInThisAms;
+                  return (
+                    <button
+                      type="button"
+                      onClick={() => setDryingRotateTray(enabled => !enabled)}
+                      aria-pressed={rotateChecked}
+                      disabled={trayLoadedInThisAms}
+                      title={trayLoadedInThisAms ? t('printers.drying.rotateUnavailableReason') : undefined}
+                      className={`h-8 w-full rounded-lg border px-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
+                        rotateChecked
+                          ? 'bg-bambu-green border-bambu-green text-white'
+                          : 'bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary disabled:hover:bg-bambu-dark'
+                      }`}
+                    >
+                      {t('printers.drying.rotateTray')}
+                    </button>
+                  );
+                })()}
               </div>
               <div className="shrink-0 h-px bg-bambu-dark-tertiary" />
               {/* Footer */}
@@ -6391,7 +6413,23 @@ function PrinterCard({
                 <button
                   onClick={() => {
                     if (dryingPopoverAmsId !== null) {
-                      startDryingMutation.mutate({ amsId: dryingPopoverAmsId, temp: dryingTemp, duration: dryingDuration, filament: dryingFilament, rotateTray: dryingRotateTray });
+                      // Clamp rotateTray off when any tray in this AMS is loaded into
+                      // the tube — the rotate UI is disabled there, but the state may
+                      // linger as `true` from a previous AMS, or a print may have
+                      // started while the popover was open. Without this clamp the
+                      // Start payload would carry rotate_tray=true and firmware would
+                      // reject with dry_sf_reason=[3] (ConsumableAtAmsOutlet).
+                      const targetAms = amsData.find(a => a.id === dryingPopoverAmsId);
+                      const trayLoadedInThisAms = (targetAms?.tray ?? []).some(
+                        tray => tray.state === 11,
+                      );
+                      startDryingMutation.mutate({
+                        amsId: dryingPopoverAmsId,
+                        temp: dryingTemp,
+                        duration: dryingDuration,
+                        filament: dryingFilament,
+                        rotateTray: dryingRotateTray && !trayLoadedInThisAms,
+                      });
                     }
                   }}
                   disabled={startDryingMutation.isPending}

+ 21 - 0
frontend/src/pages/SettingsPage.tsx

@@ -946,6 +946,7 @@ export function SettingsPage() {
       (settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
       (settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
       (settings.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
+      (settings.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) ||
       (settings.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
       (settings.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
       settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
@@ -1038,6 +1039,7 @@ export function SettingsPage() {
         queue_drying_enabled: localSettings.queue_drying_enabled,
         queue_drying_block: localSettings.queue_drying_block,
         ambient_drying_enabled: localSettings.ambient_drying_enabled,
+        print_drying_enabled: localSettings.print_drying_enabled,
         drying_presets: localSettings.drying_presets,
         ams_humidity_thresholds: localSettings.ams_humidity_thresholds,
         per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded,
@@ -4583,6 +4585,25 @@ export function SettingsPage() {
                   <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
                 </label>
               </div>
+              <div className="flex items-center justify-between">
+                <div>
+                  <label className="block text-sm text-white">
+                    {t('settings.printDryingEnabled')}
+                  </label>
+                  <p className="text-xs text-bambu-gray mt-0.5">
+                    {t('settings.printDryingEnabledDescription')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.print_drying_enabled ?? false}
+                    onChange={(e) => updateSetting('print_drying_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
               {/* Drying Presets Table */}
               <div className="space-y-2">
                 <p className="text-sm text-white font-medium">{t('settings.dryingPresets')}</p>

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DbNkcHxe.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-B9FR66k0.js"></script>
+    <script type="module" crossorigin src="/assets/index-DbNkcHxe.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CfaUjcJN.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff