Parcourir la source

Add queue auto-drying, configurable drying presets, and AMS PSU detection (#292)

  Queue auto-drying: scheduler automatically starts drying on idle printers
  with scheduled queue prints when AMS humidity exceeds the configured
  threshold. Uses conservative parameters (lowest temp, longest duration)
  for mixed filaments. Drying stops when humidity drops below threshold
  (30-minute minimum prevents oscillation), when scheduled items are
  removed, or when the feature is disabled. Optional "block queue" mode
  delays the next print until drying completes.

  Configurable presets: temperature and duration per filament type,
  editable in Settings → Print Queue, used by both manual drying popover
  and queue auto-drying. Separate presets for AMS 2 Pro (n3f) and AMS-HT
  (n3s) reflecting different heating capabilities.

  PSU detection: drying button disabled with tooltip when dry_sf_reason
  indicates insufficient power. Parses drying status bits and dry_sf_reason
  from AMS info hex string via MQTT.

  Backend: print_scheduler.py (+316 lines), bambu_mqtt.py, printer_manager,
  schemas, settings route. Frontend: PrintersPage drying presets prop,
  SettingsPage drying config UI, i18n (7 locales). Tests: 27 new tests in
  test_scheduler_auto_drying.py covering conservative params, presets,
  state sync, stop logic, minimum drying time, and auto-stop regressions.
maziggy il y a 2 mois
Parent
commit
cffba4a911

+ 3 - 0
CHANGELOG.md

@@ -7,6 +7,9 @@ All notable changes to Bambuddy will be documented in this file.
 ### New Features
 - **First Layer Complete Notification** ([#679](https://github.com/maziggy/bambuddy/issues/679)) — Get notified with a camera snapshot when the first layer finishes printing, so you can check adhesion remotely without watching the whole print. Enable the "First Layer Complete" toggle on any notification provider. Fires once per print when layer 2 begins (confirming layer 1 is done), with a guard against spurious triggers on printer reconnect. Requested by community.
 - **Remote AMS Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page. A flame icon appears on supported AMS cards; clicking it opens a popover to select filament type (PLA, PETG, TPU, ABS, ASA, PA, PC, PVA) with official BambuStudio temperature/duration presets, or set temperature manually. When drying is active, a status bar shows the time remaining with a live countdown and stop button. Supported on X1/X1C (fw 01.09+), P1P/P1S (fw 01.08+), H2D (fw 01.02.30+), H2D Pro, and X1E. Not supported on P2S, A1, A1 Mini, H2S, or H2C. Requires `printers:control` permission when authentication is enabled.
+- **Queue Auto-Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically dry filament between scheduled queue prints. When enabled in Settings → Print Queue, the scheduler starts drying on idle printers that have upcoming scheduled prints and whose AMS humidity exceeds the configured threshold. Uses conservative parameters (lowest temperature, longest duration) when mixed filament types are loaded. Drying stops automatically when humidity drops below threshold (with a 30-minute minimum to prevent oscillation), when scheduled items are removed, or when the feature is disabled. Optional "block queue" mode delays the next print until drying completes.
+- **Configurable Drying Presets** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Customize temperature and duration for each filament type in Settings → Print Queue. Defaults match BambuStudio presets (PLA 55°C/8h, PETG 65°C/8h, etc.) and are used by both the manual drying popover and queue auto-drying. AMS 2 Pro and AMS-HT use separate presets reflecting their different heating capabilities.
+- **AMS PSU Detection** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — The drying button is disabled with a tooltip when the AMS lacks sufficient power for drying (e.g. not connected to the external PSU). Reads `dry_sf_reason` from printer firmware and surfaces HMS error codes for AMS 2 Pro and AMS-HT power issues.
 
 ### Fixed
 - **AMS Fill Level Shows 0% for Non-Viewer Users** ([#676](https://github.com/maziggy/bambuddy/issues/676)) — When authentication was enabled with advanced permissions, users with `inventory:view_assignments` permission saw 0% fill level on AMS slots where inventory spool data had stale `weight_used` values. The fill level fallback chain (Spoolman → Inventory → AMS remain) used nullish coalescing (`??`), which doesn't fall through on `0` — so a stale inventory fill of 0% permanently shadowed the correct real-time AMS remain value from the printer. Now, when inventory says 0% but the AMS hardware reports a positive remain, the inventory value is bypassed in favor of the live AMS data. Viewer users were unaffected because their group lacked `inventory:view_assignments`, so the inventory query never fired and the AMS remain was used directly. Reported by @cadtoolbox.

+ 3 - 1
README.md

@@ -91,7 +91,9 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - AMS slot RFID re-read
 - 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
+- **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; automatic PSU detection and HMS power error reporting
+- **Queue auto-drying** — Automatically dry filament between scheduled prints when humidity exceeds threshold; configurable presets per filament type, optional blocking mode
+- Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
 - Dual external spool support for H2D (Ext-L / Ext-R)
 - HMS error monitoring with history and clear errors
 - Print success rates & trends

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

@@ -97,6 +97,8 @@ async def get_settings(
                 "ha_enabled",
                 "per_printer_mapping_expanded",
                 "prometheus_enabled",
+                "queue_drying_enabled",
+                "queue_drying_block",
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in [

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

@@ -143,6 +143,9 @@ class AMSUnit(BaseModel):
     serial_number: str = ""  # AMS unit serial number (sn from MQTT)
     sw_ver: str = ""  # AMS firmware version (from get_version info.module)
     dry_time: int = 0  # Minutes remaining (0 = not drying, >0 = drying active)
+    dry_status: int = 0  # 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
+    dry_sub_status: int = 0  # 0=Off, 1=Heating, 2=Dehumidify
+    dry_sf_reason: list[int] = []  # Cannot-dry reasons from firmware (see CannotDryReason)
     module_type: str = ""  # "ams", "n3f", "n3s"
 
 

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

@@ -57,6 +57,19 @@ class AppSettings(BaseModel):
     )
     ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
 
+    # Queue auto-drying settings
+    queue_drying_enabled: bool = Field(
+        default=False, description="Automatically dry AMS filament between queued prints"
+    )
+    queue_drying_block: bool = Field(
+        default=False,
+        description="Block queue until drying completes (when disabled, prints take priority over drying)",
+    )
+    drying_presets: str = Field(
+        default="",
+        description="JSON blob of drying presets per filament type (empty = use built-in defaults)",
+    )
+
     # Print modal settings
     per_printer_mapping_expanded: bool = Field(
         default=False, description="Expand custom filament mapping by default in print modal"
@@ -184,6 +197,9 @@ class AppSettingsUpdate(BaseModel):
     ams_temp_good: float | None = None
     ams_temp_fair: float | None = None
     ams_history_retention_days: int | None = None
+    queue_drying_enabled: bool | None = None
+    queue_drying_block: bool | None = None
+    drying_presets: str | None = None
     per_printer_mapping_expanded: bool | None = None
     date_format: str | None = None
     time_format: str | None = None

+ 26 - 0
backend/app/services/bambu_mqtt.py

@@ -1477,6 +1477,32 @@ class BambuMQTTClient:
             self.state.ams_extruder_map = ams_extruder_map
             logger.debug("[%s] ams_extruder_map: %s", self.serial_number, ams_extruder_map)
 
+        # Extract drying status from info hex string and dry_sf_reason per AMS unit
+        # BambuStudio DevFilaSystem.cpp parses info bits:
+        #   dry_status     = get_flag_bits(info, 4, 4)   // bits 4-7
+        #   dry_sub_status = get_flag_bits(info, 22, 4)  // bits 22-25
+        for ams_unit in merged_ams:
+            info = ams_unit.get("info")
+            if info is not None:
+                try:
+                    info_val = int(str(info), 16)
+                    ams_unit["dry_status"] = (info_val >> 4) & 0xF
+                    ams_unit["dry_sub_status"] = (info_val >> 22) & 0xF
+                except (ValueError, TypeError):
+                    pass  # Skip unparseable info values
+            # dry_sf_reason is a per-unit array of cannot-dry reason codes
+            if "dry_sf_reason" in ams_unit:
+                sf_reason = ams_unit["dry_sf_reason"]
+                if isinstance(sf_reason, list):
+                    ams_unit["dry_sf_reason"] = [
+                        int(r) for r in sf_reason if isinstance(r, int) or (isinstance(r, str) and r.isdigit())
+                    ]
+                else:
+                    ams_unit["dry_sf_reason"] = []
+
+        # Persist updated drying fields back to raw_data
+        self.state.raw_data["ams"] = merged_ams
+
         # Create a hash of relevant AMS data to detect changes
         ams_hash_data = []
         for ams_unit in ams_list:

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

@@ -3,6 +3,7 @@
 import asyncio
 import json
 import logging
+import time
 import zipfile
 from datetime import datetime, timezone
 from pathlib import Path
@@ -17,10 +18,11 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
 from backend.app.services.notification_service import notification_service
-from backend.app.services.printer_manager import printer_manager
+from backend.app.services.printer_manager import printer_manager, supports_drying
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.utils.printer_models import normalize_printer_model
 from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
@@ -48,11 +50,27 @@ def _canonical_filament_type(ftype: str) -> str:
 class PrintScheduler:
     """Background scheduler that processes the print queue."""
 
+    # Built-in drying presets per filament type (from BambuStudio filament profiles)
+    # Format: { n3f_temp, n3s_temp, n3f_hours, n3s_hours }
+    DEFAULT_DRYING_PRESETS: dict[str, dict[str, int]] = {
+        "PLA": {"n3f": 45, "n3s": 45, "n3f_hours": 12, "n3s_hours": 12},
+        "PETG": {"n3f": 65, "n3s": 65, "n3f_hours": 12, "n3s_hours": 12},
+        "TPU": {"n3f": 65, "n3s": 75, "n3f_hours": 12, "n3s_hours": 18},
+        "ABS": {"n3f": 65, "n3s": 80, "n3f_hours": 12, "n3s_hours": 8},
+        "ASA": {"n3f": 65, "n3s": 80, "n3f_hours": 12, "n3s_hours": 8},
+        "PA": {"n3f": 65, "n3s": 85, "n3f_hours": 12, "n3s_hours": 12},
+        "PC": {"n3f": 65, "n3s": 80, "n3f_hours": 12, "n3s_hours": 8},
+        "PVA": {"n3f": 65, "n3s": 85, "n3f_hours": 12, "n3s_hours": 18},
+    }
+
     def __init__(self):
         self._running = False
         self._check_interval = 30  # seconds
         self._power_on_wait_time = 180  # seconds to wait for printer after power on (3 min)
         self._power_on_check_interval = 10  # seconds between connection checks
+        self._min_drying_seconds = 1800  # 30 minutes minimum before humidity re-check can stop drying
+        # Track which printers are currently auto-drying (printer_id -> start timestamp)
+        self._drying_in_progress: dict[int, float] = {}
 
     async def run(self):
         """Main loop - check queue every interval."""
@@ -84,6 +102,8 @@ class PrintScheduler:
             items = list(result.scalars().all())
 
             if not items:
+                # No pending items — still check auto-drying on idle printers
+                await self._check_auto_drying(db, [], set())
                 return
 
             logger.info(
@@ -142,8 +162,24 @@ class PrintScheduler:
 
                     # Check if printer is idle (busy with another print)
                     if not printer_idle:
-                        busy_printers.add(item.printer_id)
-                        continue
+                        # If printer is drying (not truly busy), handle based on queue_drying_block
+                        if self._drying_in_progress.get(item.printer_id):
+                            block_for_drying = await self._get_bool_setting(db, "queue_drying_block")
+                            if block_for_drying:
+                                # Drying blocks queue — skip this printer
+                                busy_printers.add(item.printer_id)
+                                continue
+                            else:
+                                # Print takes priority — stop drying
+                                await self._stop_drying(item.printer_id)
+                                # Re-check idle after stopping drying
+                                printer_idle = self._is_printer_idle(item.printer_id)
+                                if not printer_idle:
+                                    busy_printers.add(item.printer_id)
+                                    continue
+                        else:
+                            busy_printers.add(item.printer_id)
+                            continue
 
                     # Check condition (previous print success)
                     if item.require_previous_success:
@@ -301,6 +337,9 @@ class PrintScheduler:
                         plate_cleared,
                     )
 
+            # Auto-drying: start drying on idle printers that have no pending queue items
+            await self._check_auto_drying(db, items, busy_printers)
+
     async def _find_idle_printer_for_model(
         self,
         db: AsyncSession,
@@ -995,6 +1034,295 @@ class PrintScheduler:
             )
         return idle
 
+    async def _get_bool_setting(self, db: AsyncSession, key: str, default: bool = False) -> bool:
+        """Read a boolean setting from the database."""
+        result = await db.execute(select(Settings).where(Settings.key == key))
+        setting = result.scalar_one_or_none()
+        if setting:
+            return setting.value.lower() == "true"
+        return default
+
+    async def _get_drying_presets(self, db: AsyncSession) -> dict[str, dict[str, int]]:
+        """Get drying presets (user-configured or built-in defaults)."""
+        result = await db.execute(select(Settings).where(Settings.key == "drying_presets"))
+        setting = result.scalar_one_or_none()
+        if setting and setting.value:
+            try:
+                presets = json.loads(setting.value)
+                if isinstance(presets, dict) and presets:
+                    return presets
+            except json.JSONDecodeError:
+                pass
+        return self.DEFAULT_DRYING_PRESETS
+
+    def _get_conservative_drying_params(
+        self, trays: list[dict], module_type: str, presets: dict[str, dict[str, int]]
+    ) -> tuple[int, int, str] | None:
+        """Get the most conservative drying params for mixed filament types in an AMS unit.
+
+        Returns (temp, duration_hours, filament_type) or None if no drying-eligible filaments.
+        """
+        temp_key = module_type if module_type in ("n3f", "n3s") else "n3f"
+        hours_key = f"{temp_key}_hours"
+
+        min_temp = None
+        max_hours = None
+        filament_type = ""
+
+        for tray in trays:
+            tray_type = tray.get("tray_type", "")
+            if not tray_type:
+                continue
+            # Normalize filament type for preset lookup (e.g., "PLA Basic" -> "PLA")
+            base_type = tray_type.split()[0].upper()
+            preset = presets.get(base_type)
+            if not preset:
+                continue
+
+            temp = preset.get(temp_key, 55)
+            hours = preset.get(hours_key, 12)
+
+            # Conservative: lowest temp, longest duration
+            if min_temp is None or temp < min_temp:
+                min_temp = temp
+            if max_hours is None or hours > max_hours:
+                max_hours = hours
+            if not filament_type:
+                filament_type = base_type
+
+        if min_temp is None:
+            return None
+        return (min_temp, max_hours or 12, filament_type)
+
+    async def _check_auto_drying(self, db: AsyncSession, queue_items: list[PrintQueueItem], busy_printers: set[int]):
+        """Start drying on idle printers that have no pending queue items.
+
+        Only runs when queue_drying_enabled is True.
+        """
+        queue_drying_enabled = await self._get_bool_setting(db, "queue_drying_enabled")
+        if not queue_drying_enabled:
+            # Stop active drying on all printers if feature was just disabled
+            if self._drying_in_progress:
+                for pid in list(self._drying_in_progress):
+                    logger.info("Auto-drying: printer %d — stopping, auto-drying disabled", pid)
+                    await self._stop_drying(pid)
+            return
+
+        # Update drying state from printer status (handles backend restart)
+        self._sync_drying_state()
+
+        # Find printers with scheduled items (auto-drying only makes sense
+        # when there are upcoming scheduled prints to fill idle time between)
+        printers_with_scheduled: set[int] = set()
+        printers_with_items: set[int] = set()
+        for item in queue_items:
+            if item.printer_id:
+                printers_with_items.add(item.printer_id)
+                if item.scheduled_time and not item.manual_start:
+                    printers_with_scheduled.add(item.printer_id)
+
+        # If no printers have scheduled items, stop any auto-started drying
+        if not printers_with_scheduled:
+            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)
+            return
+
+        # Get humidity threshold
+        result = await db.execute(select(Settings).where(Settings.key == "ams_humidity_fair"))
+        setting = result.scalar_one_or_none()
+        humidity_threshold = int(setting.value) if setting else 60
+
+        # Get drying presets
+        presets = await self._get_drying_presets(db)
+
+        # Determine if drying should be skipped for printers with pending items
+        block_for_drying = await self._get_bool_setting(db, "queue_drying_block")
+
+        # Get all active printers
+        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
+            # Only dry printers that have scheduled prints (filling idle time between prints)
+            if 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, skip printers whose scheduled time hasn't arrived
+            if block_for_drying and pid in printers_with_items:
+                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):
+                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):
+                logger.debug("Auto-drying: printer %d skipped — model %s does not support drying", pid, model)
+                continue
+
+            # Check each AMS unit from raw_data
+            ams_list = state.raw_data.get("ams", [])
+            logger.debug("Auto-drying: printer %d — checking %d AMS units", pid, len(ams_list))
+            for ams_data in ams_list:
+                module_type = str(ams_data.get("module_type") or "")
+                ams_id = int(ams_data.get("id", 0))
+                # Only n3f/n3s support drying
+                if module_type not in ("n3f", "n3s"):
+                    logger.debug("Auto-drying: printer %d AMS %d skipped — module_type=%s", pid, ams_id, module_type)
+                    continue
+
+                dry_time = int(ams_data.get("dry_time") or 0)
+
+                # Read humidity — prefer humidity_raw (actual %) over humidity (index 1-5)
+                humidity = None
+                h_raw = ams_data.get("humidity_raw")
+                if h_raw is not None:
+                    try:
+                        humidity = int(h_raw)
+                    except (ValueError, TypeError):
+                        pass
+                if humidity is None:
+                    h_idx = ams_data.get("humidity")
+                    if h_idx is not None:
+                        try:
+                            humidity = int(h_idx)
+                        except (ValueError, TypeError):
+                            pass
+                # Already drying — check if humidity dropped below threshold (with minimum drying time)
+                if dry_time > 0:
+                    if pid not in self._drying_in_progress:
+                        # Drying we didn't start (manual or from before restart) — track but don't stop
+                        self._drying_in_progress[pid] = time.monotonic()
+                    started_at = self._drying_in_progress[pid]
+                    elapsed = time.monotonic() - started_at
+                    if humidity is not None and humidity <= humidity_threshold and elapsed >= self._min_drying_seconds:
+                        logger.info(
+                            "Auto-drying: printer %d AMS %d — humidity %d%% <= threshold %d%% after %dm, stopping drying",
+                            pid,
+                            ams_id,
+                            humidity,
+                            humidity_threshold,
+                            int(elapsed / 60),
+                        )
+                        printer_manager.send_drying_command(pid, ams_id, temp=0, duration=0, mode=0)
+                    else:
+                        logger.debug(
+                            "Auto-drying: printer %d AMS %d — drying (%dm left, humidity %s%%, elapsed %dm/%dm min)",
+                            pid,
+                            ams_id,
+                            dry_time,
+                            humidity,
+                            int(elapsed / 60),
+                            self._min_drying_seconds // 60,
+                        )
+                    continue
+
+                # Humidity below threshold — no need to start drying
+                if humidity is None or humidity <= humidity_threshold:
+                    logger.debug(
+                        "Auto-drying: printer %d AMS %d skipped — humidity %s <= threshold %d",
+                        pid,
+                        ams_id,
+                        humidity,
+                        humidity_threshold,
+                    )
+                    continue
+
+                # Check cannot-dry reasons (power constraints etc.)
+                sf_reasons = ams_data.get("dry_sf_reason", [])
+                if sf_reasons:
+                    logger.debug(
+                        "Auto-drying: printer %d AMS %d skipped — cannot dry reasons: %s",
+                        pid,
+                        ams_id,
+                        sf_reasons,
+                    )
+                    continue
+
+                # Get conservative drying params for mixed filaments
+                trays = ams_data.get("tray", [])
+                params = self._get_conservative_drying_params(trays, module_type, presets)
+                if not params:
+                    logger.debug(
+                        "Auto-drying: printer %d AMS %d skipped — no drying-eligible filaments in trays", pid, ams_id
+                    )
+                    continue
+
+                temp, duration_hours, filament_type = params
+
+                # Start drying
+                logger.info(
+                    "Auto-drying: printer %d AMS %d — humidity %d%% > threshold %d%%, "
+                    "starting %s drying at %d°C for %dh",
+                    pid,
+                    ams_id,
+                    humidity,
+                    humidity_threshold,
+                    filament_type,
+                    temp,
+                    duration_hours,
+                )
+                success = printer_manager.send_drying_command(
+                    pid, ams_id, temp, duration_hours, mode=1, filament=filament_type
+                )
+                if success:
+                    self._drying_in_progress[pid] = time.monotonic()
+
+    def _sync_drying_state(self):
+        """Sync in-memory drying state with actual printer status.
+
+        Handles backend restart — if a printer is drying but we don't know about it,
+        update our state. If we think it's drying but it's not, clear it.
+        """
+        to_remove = []
+        for pid in self._drying_in_progress:
+            state = printer_manager.get_status(pid)
+            if not state:
+                to_remove.append(pid)
+                continue
+            # Check if any AMS unit is still drying
+            ams_list = state.raw_data.get("ams", [])
+            any_drying = any(int(a.get("dry_time") or 0) > 0 for a in ams_list)
+            if not any_drying:
+                to_remove.append(pid)
+        for pid in to_remove:
+            self._drying_in_progress.pop(pid, None)
+
+    async def _stop_drying(self, printer_id: int):
+        """Stop all active drying on a printer (print takes priority)."""
+        state = printer_manager.get_status(printer_id)
+        if not state:
+            self._drying_in_progress.pop(printer_id, None)
+            return
+
+        ams_list = state.raw_data.get("ams", [])
+        for ams_data in ams_list:
+            dry_time = int(ams_data.get("dry_time") or 0)
+            if dry_time > 0:
+                ams_id = int(ams_data.get("id", 0))
+                logger.info(
+                    "Auto-drying: stopping drying on printer %d AMS %d — print takes priority",
+                    printer_id,
+                    ams_id,
+                )
+                printer_manager.send_drying_command(printer_id, ams_id, 0, 0, mode=0)
+        self._drying_in_progress.pop(printer_id, None)
+
     async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
         """Get the smart plug associated with a printer."""
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))

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

@@ -646,6 +646,11 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                     "sw_ver": str(ams_data.get("sw_ver") or ""),
                     # Drying: dry_time > 0 means drying is active (minutes remaining)
                     "dry_time": int(ams_data.get("dry_time") or 0),
+                    # Drying status from info hex bits (0=Off, 1=Checking, 2=Drying, 3=Cooling, etc.)
+                    "dry_status": int(ams_data.get("dry_status") or 0),
+                    "dry_sub_status": int(ams_data.get("dry_sub_status") or 0),
+                    # Cannot-dry reasons from firmware (e.g. 1=InsufficientPower, 8=NeedPluginPower)
+                    "dry_sf_reason": list(ams_data.get("dry_sf_reason") or []),
                     # Module type: "ams", "n3f", "n3s" (from get_version)
                     "module_type": str(ams_data.get("module_type") or ""),
                 }

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

@@ -0,0 +1,520 @@
+"""Tests for the auto-drying feature in the print scheduler.
+
+Covers:
+- Conservative drying parameter selection (mixed filaments)
+- Drying preset loading (user-configured vs defaults)
+- Auto-drying lifecycle: start, humidity stop, minimum drying time
+- Auto-drying stop conditions: feature disabled, no scheduled items, per-printer
+- Sync drying state after restart
+"""
+
+import time
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+class TestConservativeDryingParams:
+    """Test _get_conservative_drying_params — picks safest temp/duration for mixed filaments."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_single_filament_pla(self, scheduler):
+        """Single PLA tray uses PLA preset."""
+        trays = [{"tray_type": "PLA"}]
+        presets = PrintScheduler.DEFAULT_DRYING_PRESETS
+        result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
+        assert result == (45, 12, "PLA")
+
+    def test_mixed_filaments_lowest_temp(self, scheduler):
+        """Mixed PLA + ABS: should use PLA's 45°C (lowest), ABS's 12h (longest for n3f)."""
+        trays = [{"tray_type": "PLA"}, {"tray_type": "ABS"}]
+        presets = PrintScheduler.DEFAULT_DRYING_PRESETS
+        result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
+        temp, hours, _ = result
+        assert temp == 45  # PLA is lowest
+        assert hours == 12
+
+    def test_mixed_filaments_longest_duration(self, scheduler):
+        """Mixed ABS (8h) + PVA (18h) on n3s: should use longest duration."""
+        trays = [{"tray_type": "ABS"}, {"tray_type": "PVA"}]
+        presets = PrintScheduler.DEFAULT_DRYING_PRESETS
+        result = scheduler._get_conservative_drying_params(trays, "n3s", presets)
+        temp, hours, _ = result
+        assert temp == 80  # ABS n3s=80, PVA n3s=85 → lowest=80
+        assert hours == 18  # ABS n3s_hours=8, PVA n3s_hours=18 → longest=18
+
+    def test_empty_trays_returns_none(self, scheduler):
+        """No loaded trays returns None."""
+        result = scheduler._get_conservative_drying_params([], "n3f", PrintScheduler.DEFAULT_DRYING_PRESETS)
+        assert result is None
+
+    def test_unknown_filament_skipped(self, scheduler):
+        """Unknown filament types are ignored."""
+        trays = [{"tray_type": "EXOTIC_WOOD"}]
+        result = scheduler._get_conservative_drying_params(trays, "n3f", PrintScheduler.DEFAULT_DRYING_PRESETS)
+        assert result is None
+
+    def test_filament_type_normalization(self, scheduler):
+        """'PLA Basic' should normalize to 'PLA'."""
+        trays = [{"tray_type": "PLA Basic"}]
+        presets = PrintScheduler.DEFAULT_DRYING_PRESETS
+        result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
+        assert result is not None
+        assert result[0] == 45  # PLA temp
+
+    def test_empty_tray_type_skipped(self, scheduler):
+        """Trays with empty tray_type are skipped."""
+        trays = [{"tray_type": ""}, {"tray_type": "PETG"}]
+        presets = PrintScheduler.DEFAULT_DRYING_PRESETS
+        result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
+        assert result is not None
+        assert result[2] == "PETG"
+
+    def test_n3s_uses_n3s_keys(self, scheduler):
+        """AMS-HT (n3s) should use n3s temp and n3s_hours."""
+        trays = [{"tray_type": "TPU"}]
+        presets = PrintScheduler.DEFAULT_DRYING_PRESETS
+        result = scheduler._get_conservative_drying_params(trays, "n3s", presets)
+        assert result == (75, 18, "TPU")  # n3s=75, n3s_hours=18
+
+    def test_n3f_uses_n3f_keys(self, scheduler):
+        """AMS 2 Pro (n3f) should use n3f temp and n3f_hours."""
+        trays = [{"tray_type": "TPU"}]
+        presets = PrintScheduler.DEFAULT_DRYING_PRESETS
+        result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
+        assert result == (65, 12, "TPU")  # n3f=65, n3f_hours=12
+
+    def test_custom_presets(self, scheduler):
+        """Custom presets override defaults."""
+        trays = [{"tray_type": "PLA"}]
+        custom = {"PLA": {"n3f": 50, "n3s": 50, "n3f_hours": 6, "n3s_hours": 6}}
+        result = scheduler._get_conservative_drying_params(trays, "n3f", custom)
+        assert result == (50, 6, "PLA")
+
+
+class TestDryingPresets:
+    """Test _get_drying_presets — loads user presets from DB or falls back to defaults."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @pytest.mark.asyncio
+    async def test_default_presets_when_no_setting(self, scheduler):
+        """Returns built-in defaults when no DB setting exists."""
+        db = AsyncMock()
+        result_mock = MagicMock()
+        result_mock.scalar_one_or_none.return_value = None
+        db.execute = AsyncMock(return_value=result_mock)
+
+        presets = await scheduler._get_drying_presets(db)
+        assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
+
+    @pytest.mark.asyncio
+    async def test_user_presets_from_db(self, scheduler):
+        """Returns user-configured presets when saved in DB."""
+        db = AsyncMock()
+        setting = MagicMock()
+        setting.value = '{"PLA": {"n3f": 50, "n3s": 50, "n3f_hours": 6, "n3s_hours": 6}}'
+        result_mock = MagicMock()
+        result_mock.scalar_one_or_none.return_value = setting
+        db.execute = AsyncMock(return_value=result_mock)
+
+        presets = await scheduler._get_drying_presets(db)
+        assert presets["PLA"]["n3f"] == 50
+
+    @pytest.mark.asyncio
+    async def test_invalid_json_falls_back(self, scheduler):
+        """Invalid JSON in DB falls back to defaults."""
+        db = AsyncMock()
+        setting = MagicMock()
+        setting.value = "not valid json{{"
+        result_mock = MagicMock()
+        result_mock.scalar_one_or_none.return_value = setting
+        db.execute = AsyncMock(return_value=result_mock)
+
+        presets = await scheduler._get_drying_presets(db)
+        assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
+
+    @pytest.mark.asyncio
+    async def test_empty_string_falls_back(self, scheduler):
+        """Empty string in DB falls back to defaults."""
+        db = AsyncMock()
+        setting = MagicMock()
+        setting.value = ""
+        result_mock = MagicMock()
+        result_mock.scalar_one_or_none.return_value = setting
+        db.execute = AsyncMock(return_value=result_mock)
+
+        presets = await scheduler._get_drying_presets(db)
+        assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
+
+
+class TestSyncDryingState:
+    """Test _sync_drying_state — syncs in-memory state with actual printer status."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_removes_stopped_printers(self, mock_pm, scheduler):
+        """Printers that stopped drying are removed from tracking."""
+        scheduler._drying_in_progress = {1: time.monotonic()}
+        state = MagicMock()
+        state.raw_data = {"ams": [{"dry_time": 0}]}
+        mock_pm.get_status.return_value = state
+
+        scheduler._sync_drying_state()
+        assert 1 not in scheduler._drying_in_progress
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_keeps_active_printers(self, mock_pm, scheduler):
+        """Printers still drying remain in tracking."""
+        ts = time.monotonic()
+        scheduler._drying_in_progress = {1: ts}
+        state = MagicMock()
+        state.raw_data = {"ams": [{"dry_time": 120}]}
+        mock_pm.get_status.return_value = state
+
+        scheduler._sync_drying_state()
+        assert scheduler._drying_in_progress[1] == ts
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_removes_disconnected_printers(self, mock_pm, scheduler):
+        """Disconnected printers are removed from tracking."""
+        scheduler._drying_in_progress = {1: time.monotonic()}
+        mock_pm.get_status.return_value = None
+
+        scheduler._sync_drying_state()
+        assert 1 not in scheduler._drying_in_progress
+
+
+class TestStopDrying:
+    """Test _stop_drying — sends stop commands and clears tracking."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_stops_all_ams_units(self, mock_pm, scheduler):
+        """Sends stop command to each AMS unit that is drying."""
+        scheduler._drying_in_progress = {1: time.monotonic()}
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {"id": 0, "dry_time": 120},
+                {"id": 1, "dry_time": 0},
+                {"id": 128, "dry_time": 60},
+            ]
+        }
+        mock_pm.get_status.return_value = state
+
+        await scheduler._stop_drying(1)
+
+        # Should send stop to AMS 0 and 128, not AMS 1
+        calls = mock_pm.send_drying_command.call_args_list
+        assert len(calls) == 2
+        assert calls[0].args == (1, 0, 0, 0)
+        assert calls[1].args == (1, 128, 0, 0)
+        assert 1 not in scheduler._drying_in_progress
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_clears_tracking_when_no_state(self, mock_pm, scheduler):
+        """Clears tracking when printer has no state (disconnected)."""
+        scheduler._drying_in_progress = {1: time.monotonic()}
+        mock_pm.get_status.return_value = None
+
+        await scheduler._stop_drying(1)
+        assert 1 not in scheduler._drying_in_progress
+
+
+class TestMinimumDryingTime:
+    """Regression: drying should not stop/restart rapidly when humidity oscillates near threshold."""
+
+    @pytest.fixture
+    def scheduler(self):
+        s = PrintScheduler()
+        s._min_drying_seconds = 1800  # 30 minutes
+        return s
+
+    @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_no_stop_before_minimum_time(self, mock_sd, mock_pm, scheduler):
+        """Drying should NOT stop when humidity drops below threshold before 30 min."""
+        # Simulate: drying started 5 minutes ago
+        scheduler._drying_in_progress = {1: time.monotonic() - 300}
+
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": 600,
+                    "humidity_raw": "18",
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PLA"}],
+                }
+            ]
+        }
+        state.firmware_version = "01.09.00.00"
+        mock_pm.get_status.return_value = state
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+
+        # Mock _is_printer_idle and DB
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = AsyncMock()
+
+        # Mock settings: enabled, threshold=21
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ams_humidity_fair": self._make_setting("21"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))
+
+        # Queue item with schedule
+        item = MagicMock()
+        item.printer_id = 1
+        item.scheduled_time = MagicMock()  # Has a schedule
+        item.manual_start = False
+
+        await scheduler._check_auto_drying(db, [item], set())
+
+        # Should NOT have sent stop command via humidity check — minimum time not elapsed
+        # The only calls should NOT include the humidity-based stop
+        for call in mock_pm.send_drying_command.call_args_list:
+            # If any stop was called, it should NOT be from the humidity path
+            # (humidity path uses keyword args: temp=0, duration=0, mode=0)
+            assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
+                "Humidity-based stop should not fire before minimum drying time"
+            )
+
+    @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_stops_after_minimum_time(self, mock_sd, mock_pm, scheduler):
+        """Drying SHOULD stop when humidity below threshold AND 30 min elapsed."""
+        # Simulate: drying started 35 minutes ago
+        scheduler._drying_in_progress = {1: time.monotonic() - 2100}
+
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": 600,
+                    "humidity_raw": "18",
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PLA"}],
+                }
+            ]
+        }
+        state.firmware_version = "01.09.00.00"
+        mock_pm.get_status.return_value = state
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = AsyncMock()
+
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ams_humidity_fair": self._make_setting("21"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))
+
+        item = MagicMock()
+        item.printer_id = 1
+        item.scheduled_time = MagicMock()
+        item.manual_start = False
+
+        await scheduler._check_auto_drying(db, [item], set())
+
+        # Should have sent stop command (humidity-based stop after minimum time)
+        mock_pm.send_drying_command.assert_any_call(1, 0, temp=0, duration=0, mode=0)
+
+    @staticmethod
+    def _make_setting(value):
+        s = MagicMock()
+        s.value = value
+        return s
+
+    @staticmethod
+    def _make_db_side_effect(settings_map, printer_id=1):
+        """Create a side_effect for db.execute that returns settings and printers."""
+
+        async def side_effect(stmt):
+            result = MagicMock()
+            stmt_str = str(stmt)
+
+            # Extract bind parameter values (SQLAlchemy uses :key_1 placeholders)
+            try:
+                compiled = stmt.compile(compile_kwargs={"literal_binds": False})
+                param_values = list(compiled.params.values())
+            except Exception:
+                param_values = []
+
+            # Match settings queries by checking bind parameter values
+            matched = False
+            for key, val in settings_map.items():
+                if key in param_values:
+                    result.scalar_one_or_none.return_value = val
+                    matched = True
+                    break
+
+            if not matched:
+                if "printer" in stmt_str.lower() or "is_active" in stmt_str:
+                    printer = MagicMock()
+                    printer.id = printer_id
+                    printer.is_active = True
+                    scalars_mock = MagicMock()
+                    scalars_mock.__iter__ = MagicMock(return_value=iter([printer]))
+                    result.scalars.return_value = scalars_mock
+                else:
+                    result.scalar_one_or_none.return_value = None
+            return result
+
+        return side_effect
+
+
+class TestAutoStopOnFeatureDisabled:
+    """Regression: disabling auto-drying in settings should stop active drying sessions."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_stops_drying_when_disabled(self, mock_pm, scheduler):
+        """Disabling auto-drying should send stop commands to all drying printers."""
+        scheduler._drying_in_progress = {1: time.monotonic(), 2: time.monotonic()}
+
+        # Printer 1: drying, Printer 2: drying
+        def get_status(pid):
+            state = MagicMock()
+            state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
+            return state
+
+        mock_pm.get_status.side_effect = get_status
+
+        db = AsyncMock()
+        # queue_drying_enabled = false
+        setting = MagicMock()
+        setting.value = "false"
+        result_mock = MagicMock()
+        result_mock.scalar_one_or_none.return_value = setting
+        db.execute = AsyncMock(return_value=result_mock)
+
+        await scheduler._check_auto_drying(db, [], set())
+
+        # Should have sent stop commands
+        assert mock_pm.send_drying_command.call_count == 2
+        assert not scheduler._drying_in_progress
+
+
+class TestAutoStopOnNoScheduledItems:
+    """Regression: removing scheduled items should stop auto-drying."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_stops_when_no_scheduled_items(self, mock_pm, scheduler):
+        """Auto-drying stops when queue has no scheduled items."""
+        scheduler._drying_in_progress = {1: time.monotonic()}
+
+        state = MagicMock()
+        state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
+        mock_pm.get_status.return_value = state
+
+        db = AsyncMock()
+        # queue_drying_enabled = true
+        enabled_setting = MagicMock()
+        enabled_setting.value = "true"
+
+        call_count = [0]
+
+        async def db_execute(stmt):
+            call_count[0] += 1
+            result = MagicMock()
+            result.scalar_one_or_none.return_value = enabled_setting
+            return result
+
+        db.execute = AsyncMock(side_effect=db_execute)
+
+        # Manual-start items only (no scheduled_time)
+        item = MagicMock()
+        item.printer_id = 1
+        item.scheduled_time = None
+        item.manual_start = True
+
+        await scheduler._check_auto_drying(db, [item], set())
+
+        # Should have stopped drying
+        assert mock_pm.send_drying_command.called
+        assert not scheduler._drying_in_progress
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_stops_when_empty_queue(self, mock_pm, scheduler):
+        """Auto-drying stops when queue is completely empty."""
+        scheduler._drying_in_progress = {1: time.monotonic()}
+
+        state = MagicMock()
+        state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
+        mock_pm.get_status.return_value = state
+
+        db = AsyncMock()
+        enabled_setting = MagicMock()
+        enabled_setting.value = "true"
+        result_mock = MagicMock()
+        result_mock.scalar_one_or_none.return_value = enabled_setting
+        db.execute = AsyncMock(return_value=result_mock)
+
+        await scheduler._check_auto_drying(db, [], set())
+
+        assert mock_pm.send_drying_command.called
+        assert not scheduler._drying_in_progress
+
+
+class TestDryingTrackingTimestamps:
+    """Test that _drying_in_progress uses timestamps, not booleans."""
+
+    def test_initial_state_empty(self):
+        """Fresh scheduler has no drying tracked."""
+        scheduler = PrintScheduler()
+        assert scheduler._drying_in_progress == {}
+
+    def test_timestamp_is_monotonic(self):
+        """Tracked values should be monotonic timestamps."""
+        scheduler = PrintScheduler()
+        before = time.monotonic()
+        scheduler._drying_in_progress[1] = time.monotonic()
+        after = time.monotonic()
+        assert before <= scheduler._drying_in_progress[1] <= after
+
+    def test_timestamp_is_truthy(self):
+        """Timestamps are truthy for .get() checks (backward compat with bool pattern)."""
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = time.monotonic()
+        assert scheduler._drying_in_progress.get(1)
+        assert not scheduler._drying_in_progress.get(999)

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

@@ -139,6 +139,9 @@ export interface AMSUnit {
   serial_number: string;  // AMS unit serial number (from MQTT sn field)
   sw_ver: string;         // AMS firmware version (from get_version info.module ams/* entry)
   dry_time: number;       // Minutes remaining (0 = not drying, >0 = drying active)
+  dry_status: number;     // 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
+  dry_sub_status: number; // 0=Off, 1=Heating, 2=Dehumidify
+  dry_sf_reason: number[]; // Cannot-dry reasons (1=InsufficientPower, 8=NeedPluginPower)
   module_type: string;    // "ams", "n3f", "n3s"
 }
 
@@ -797,6 +800,10 @@ export interface AppSettings {
   ams_temp_good: number;      // <= this is green/blue
   ams_temp_fair: number;      // <= this is orange, > is red
   ams_history_retention_days: number;  // days to keep AMS sensor history
+  // Queue auto-drying settings
+  queue_drying_enabled: boolean;  // Auto-dry AMS between queued prints
+  queue_drying_block: boolean;  // Block queue until drying completes
+  drying_presets: string;  // JSON blob of drying presets per filament type
   // Print modal settings
   per_printer_mapping_expanded: boolean;  // Whether custom mapping is expanded by default in print modal
   // Date/time format settings

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

@@ -414,6 +414,7 @@ export default {
       timeRemaining: '{{time}} verbleibend',
       active: 'Trocknung',
       notSupported: 'Trocknung nicht unterstützt',
+      powerRequired: 'AMS-Netzteil anschließen, um Trocknung zu aktivieren',
       startingDrying: 'Trocknung wird gestartet...',
       stoppingDrying: 'Trocknung wird gestoppt...',
     },
@@ -1481,12 +1482,22 @@ export default {
     goodGreen: 'Gut (grün)',
     fairOrange: 'Mittel (orange)',
     aboveFairBad: 'Über dem mittleren Schwellenwert wird rot angezeigt (schlecht)',
+    fairAlsoDryingThreshold: 'Dieser Schwellenwert wird auch für die automatische Trocknung verwendet',
     temperature: 'Temperatur',
     goodBlue: 'Gut (blau)',
     aboveFairHot: 'Über dem mittleren Schwellenwert wird rot angezeigt (heiß)',
     historyRetention: 'Verlaufsaufbewahrung',
     keepSensorHistory: 'Sensorverlauf behalten für',
     historyRetentionDescription: 'Ältere Feuchtigkeits- und Temperaturdaten werden automatisch gelöscht',
+    queueDrying: 'Automatische Trocknung',
+    queueDryingDescription: 'AMS-Filament automatisch trocknen, wenn der Drucker zwischen Warteschlangen-Drucken im Leerlauf ist. Verwendet den Feuchtigkeitsschwellenwert oben.',
+    queueDryingEnabled: 'Automatische Trocknung aktivieren',
+    queueDryingEnabledDescription: 'AMS-Trocknung automatisch starten, wenn der Drucker im Leerlauf ist und die Feuchtigkeit über dem Schwellenwert liegt',
+    queueDryingBlock: 'Auf Trocknung warten',
+    queueDryingBlockDescription: 'Druckwarteschlange blockieren, bis die Trocknung abgeschlossen ist. Wenn aus, haben Drucke Vorrang.',
+    dryingPresets: 'Trocknungsvoreinstellungen',
+    dryingPresetsDescription: 'Temperatur und Dauer pro Filamenttyp. AMS 2 Pro verwendet niedrigere Temperaturen, AMS-HT unterstützt höhere.',
+    dryingFilament: 'Filament',
     printModal: 'Druckdialog',
     expandCustomMapping: 'Benutzerdefinierte Zuordnung standardmäßig erweitern',
     expandCustomMappingDescription: 'Bei Druck auf mehrere Drucker die AMS-Zuordnung pro Drucker erweitert anzeigen',

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

@@ -414,6 +414,7 @@ export default {
       timeRemaining: '{{time}} left',
       active: 'Drying',
       notSupported: 'Drying not supported',
+      powerRequired: 'Connect AMS power adapter to enable drying',
       startingDrying: 'Starting drying...',
       stoppingDrying: 'Stopping drying...',
     },
@@ -1481,12 +1482,22 @@ export default {
     goodGreen: 'Good (green)',
     fairOrange: 'Fair (orange)',
     aboveFairBad: 'Above fair threshold shows as red (bad)',
+    fairAlsoDryingThreshold: 'This threshold is also used to trigger auto-drying when enabled',
     temperature: 'Temperature',
     goodBlue: 'Good (blue)',
     aboveFairHot: 'Above fair threshold shows as red (hot)',
     historyRetention: 'History Retention',
     keepSensorHistory: 'Keep sensor history for',
     historyRetentionDescription: 'Older humidity and temperature data will be automatically deleted',
+    queueDrying: 'Queue Auto-Drying',
+    queueDryingDescription: 'Automatically dry AMS filament when printer is idle between queued prints. Uses humidity threshold above to trigger drying.',
+    queueDryingEnabled: 'Enable auto-drying',
+    queueDryingEnabledDescription: 'Start AMS drying automatically when printer is idle and humidity is above threshold',
+    queueDryingBlock: 'Wait for drying to complete',
+    queueDryingBlockDescription: 'Block the print queue until drying finishes. When off, prints take priority over drying.',
+    dryingPresets: 'Drying Presets',
+    dryingPresetsDescription: 'Temperature and duration per filament type. AMS 2 Pro uses lower temps, AMS-HT supports higher temps.',
+    dryingFilament: 'Filament',
     printModal: 'Print Modal',
     expandCustomMapping: 'Expand custom mapping by default',
     expandCustomMappingDescription: 'When printing to multiple printers, show per-printer AMS mapping expanded',

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

@@ -414,6 +414,7 @@ export default {
       timeRemaining: '{{time}} restant',
       active: 'Séchage',
       notSupported: 'Séchage non pris en charge',
+      powerRequired: 'Brancher l\'adaptateur secteur AMS pour activer le séchage',
       startingDrying: 'Démarrage du séchage...',
       stoppingDrying: 'Arrêt du séchage...',
     },
@@ -1481,12 +1482,22 @@ export default {
     goodGreen: 'Bon (vert)',
     fairOrange: 'Moyen (orange)',
     aboveFairBad: 'Au-dessus = rouge (mauvais)',
+    fairAlsoDryingThreshold: 'Ce seuil est aussi utilisé pour déclencher le séchage automatique',
     temperature: 'Température',
     goodBlue: 'Bon (bleu)',
     aboveFairHot: 'Au-dessus = rouge (chaud)',
     historyRetention: 'Rétention d\'historique',
     keepSensorHistory: 'Garder l\'historique pendant',
     historyRetentionDescription: 'Les anciennes données seront supprimées.',
+    queueDrying: 'Séchage automatique',
+    queueDryingDescription: 'Sécher automatiquement le filament AMS lorsque l\'imprimante est inactive entre les impressions. Utilise le seuil d\'humidité ci-dessus.',
+    queueDryingEnabled: 'Activer le séchage automatique',
+    queueDryingEnabledDescription: 'Démarrer le séchage AMS automatiquement lorsque l\'imprimante est inactive et l\'humidité dépasse le seuil',
+    queueDryingBlock: 'Attendre la fin du séchage',
+    queueDryingBlockDescription: 'Bloquer la file d\'attente jusqu\'à la fin du séchage. Désactivé, les impressions sont prioritaires.',
+    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',
     printModal: 'Fenêtre d\'impression',
     expandCustomMapping: 'Étendre le mapping personnalisé par défaut',
     expandCustomMappingDescription: 'Affiche le mapping AMS par imprimante étendu.',

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

@@ -414,6 +414,7 @@ export default {
       timeRemaining: '{{time}} rimanente',
       active: 'Essiccazione',
       notSupported: 'Essiccazione non supportata',
+      powerRequired: 'Collegare l\'alimentatore AMS per abilitare l\'asciugatura',
       startingDrying: 'Avvio essiccazione...',
       stoppingDrying: 'Arresto essiccazione...',
     },
@@ -1481,12 +1482,22 @@ export default {
     goodGreen: 'Buono (verde)',
     fairOrange: 'Discreto (arancione)',
     aboveFairBad: 'Sopra soglia discreta mostra rosso (scarso)',
+    fairAlsoDryingThreshold: 'Questa soglia viene usata anche per attivare l\'asciugatura automatica',
     temperature: 'Temperatura',
     goodBlue: 'Buono (blu)',
     aboveFairHot: 'Sopra soglia discreta mostra rosso (caldo)',
     historyRetention: 'Conservazione cronologia',
     keepSensorHistory: 'Mantieni cronologia sensori per',
     historyRetentionDescription: 'I dati più vecchi saranno eliminati automaticamente',
+    queueDrying: 'Asciugatura automatica',
+    queueDryingDescription: 'Asciugare automaticamente il filamento AMS quando la stampante è inattiva tra le stampe in coda. Usa la soglia di umidità sopra.',
+    queueDryingEnabled: 'Abilita asciugatura automatica',
+    queueDryingEnabledDescription: 'Avvia l\'asciugatura AMS automaticamente quando la stampante è inattiva e l\'umidità supera la soglia',
+    queueDryingBlock: 'Attendi completamento asciugatura',
+    queueDryingBlockDescription: 'Blocca la coda di stampa fino al completamento dell\'asciugatura. Se disattivato, le stampe hanno priorità.',
+    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',
     printModal: 'Modale stampa',
     expandCustomMapping: 'Espandi mapping personalizzato di default',
     expandCustomMappingDescription: 'Quando stampi su più stampanti, mostra mapping AMS per stampante espanso',

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

@@ -414,6 +414,7 @@ export default {
       timeRemaining: '残り {{time}}',
       active: '乾燥中',
       notSupported: '乾燥非対応',
+      powerRequired: 'AMS電源アダプターを接続して乾燥を有効にしてください',
       startingDrying: '乾燥を開始しています...',
       stoppingDrying: '乾燥を停止しています...',
     },
@@ -1481,12 +1482,22 @@ export default {
     goodGreen: '良好(緑)≤',
     fairOrange: '普通(オレンジ)≤',
     aboveFairBad: '普通のしきい値以上は赤(悪い)で表示',
+    fairAlsoDryingThreshold: 'このしきい値は自動乾燥のトリガーにも使用されます',
     temperature: '温度',
     goodBlue: '良好(青)≤',
     aboveFairHot: '普通のしきい値以上は赤(高温)で表示',
     historyRetention: '履歴の保持',
     keepSensorHistory: 'センサー履歴の保持期間',
     historyRetentionDescription: '古い湿度と温度データは自動的に削除されます',
+    queueDrying: '自動乾燥',
+    queueDryingDescription: 'キュー印刷の合間にプリンターがアイドル状態の時、AMSフィラメントを自動的に乾燥します。上記の湿度しきい値を使用します。',
+    queueDryingEnabled: '自動乾燥を有効にする',
+    queueDryingEnabledDescription: 'プリンターがアイドル状態で湿度がしきい値を超えた場合、AMS乾燥を自動的に開始',
+    queueDryingBlock: '乾燥完了まで待機',
+    queueDryingBlockDescription: '乾燥が完了するまで印刷キューをブロックします。オフの場合、印刷が優先されます。',
+    dryingPresets: '乾燥プリセット',
+    dryingPresetsDescription: 'フィラメントタイプごとの温度と時間。AMS 2 Proは低温、AMS-HTは高温に対応。',
+    dryingFilament: 'フィラメント',
     printModal: '印刷ダイアログ',
     expandCustomMapping: 'カスタムマッピングをデフォルトで展開',
     expandCustomMappingDescription: '複数プリンターに印刷する際、プリンターごとのAMSマッピングを展開表示',

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

@@ -414,6 +414,7 @@ export default {
       timeRemaining: '{{time}} restante',
       active: 'Secagem',
       notSupported: 'Secagem não suportada',
+      powerRequired: 'Conecte o adaptador de energia AMS para ativar a secagem',
       startingDrying: 'Iniciando secagem...',
       stoppingDrying: 'Parando secagem...',
     },
@@ -1481,12 +1482,22 @@ export default {
     goodGreen: 'Bom (verde)',
     fairOrange: 'Razoável (laranja)',
     aboveFairBad: 'Acima do limiar razoável mostra como vermelho (ruim)',
+    fairAlsoDryingThreshold: 'Este limiar também é usado para acionar a secagem automática',
     temperature: 'Temperatura',
     goodBlue: 'Bom (azul)',
     aboveFairHot: 'Acima do limiar razoável mostra como vermelho (quente)',
     historyRetention: 'Retenção de Histórico',
     keepSensorHistory: 'Manter histórico do sensor por',
     historyRetentionDescription: 'Dados antigos de umidade e temperatura serão automaticamente excluídos',
+    queueDrying: 'Secagem Automática',
+    queueDryingDescription: 'Secar automaticamente o filamento AMS quando a impressora estiver ociosa entre impressões na fila. Usa o limite de umidade acima.',
+    queueDryingEnabled: 'Ativar secagem automática',
+    queueDryingEnabledDescription: 'Iniciar secagem AMS automaticamente quando a impressora estiver ociosa e a umidade estiver acima do limite',
+    queueDryingBlock: 'Aguardar conclusão da secagem',
+    queueDryingBlockDescription: 'Bloquear a fila de impressão até a secagem terminar. Quando desativado, impressões têm prioridade.',
+    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',
     printModal: 'Modal de Impressão',
     expandCustomMapping: 'Expandir mapeamento personalizado por padrão',
     expandCustomMappingDescription: 'Ao imprimir em várias impressoras, mostrar o mapeamento AMS por impressora expandido',

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

@@ -414,6 +414,7 @@ export default {
       timeRemaining: '剩余 {{time}}',
       active: '干燥中',
       notSupported: '不支持干燥',
+      powerRequired: '连接AMS电源适配器以启用干燥',
       startingDrying: '正在启动干燥...',
       stoppingDrying: '正在停止干燥...',
     },
@@ -1481,12 +1482,22 @@ export default {
     goodGreen: '良好(绿色)',
     fairOrange: '一般(橙色)',
     aboveFairBad: '超过一般阈值显示为红色(差)',
+    fairAlsoDryingThreshold: '此阈值也用于触发自动干燥',
     temperature: '温度',
     goodBlue: '良好(蓝色)',
     aboveFairHot: '超过一般阈值显示为红色(热)',
     historyRetention: '历史保留',
     keepSensorHistory: '保留传感器历史',
     historyRetentionDescription: '较旧的湿度和温度数据将被自动删除',
+    queueDrying: '自动干燥',
+    queueDryingDescription: '在队列打印之间,打印机空闲时自动干燥AMS耗材。使用上方的湿度阈值触发干燥。',
+    queueDryingEnabled: '启用自动干燥',
+    queueDryingEnabledDescription: '当打印机空闲且湿度超过阈值时,自动启动AMS干燥',
+    queueDryingBlock: '等待干燥完成',
+    queueDryingBlockDescription: '阻止打印队列直到干燥完成。关闭时,打印优先于干燥。',
+    dryingPresets: '干燥预设',
+    dryingPresetsDescription: '每种耗材类型的温度和时长。AMS 2 Pro使用较低温度,AMS-HT支持较高温度。',
+    dryingFilament: '耗材',
     printModal: '打印对话框',
     expandCustomMapping: '默认展开自定义映射',
     expandCustomMappingDescription: '打印到多台打印机时,默认展开显示每台打印机的 AMS 映射',

+ 30 - 10
frontend/src/pages/PrintersPage.tsx

@@ -1536,6 +1536,7 @@ function PrinterCard({
   cameraViewMode = 'window',
   onOpenEmbeddedCamera,
   checkPrinterFirmware = true,
+  dryingPresets = DRYING_PRESETS,
 }: {
   printer: Printer;
   hideIfDisconnected?: boolean;
@@ -1559,6 +1560,7 @@ function PrinterCard({
   cameraViewMode?: 'window' | 'embedded';
   onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
   checkPrinterFirmware?: boolean;
+  dryingPresets?: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }>;
 }) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -3099,6 +3101,7 @@ function PrinterCard({
                                   {/* 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') && (
                                     <button
+                                      disabled={!!(ams.dry_sf_reason?.length && ams.dry_time === 0)}
                                       onClick={(e) => {
                                         if (ams.dry_time > 0) {
                                           stopDryingMutation.mutate(ams.id);
@@ -3106,8 +3109,8 @@ function PrinterCard({
                                           setDryingPopoverAmsId(null);
                                         } else {
                                           const firstTray = ams.tray.find(t => t.tray_type);
-                                          const filType = firstTray?.tray_type || 'PLA';
-                                          const preset = DRYING_PRESETS[filType] || DRYING_PRESETS['PLA'];
+                                          const filType = (firstTray?.tray_type || 'PLA').split(' ')[0].toUpperCase();
+                                          const preset = dryingPresets[filType] || dryingPresets['PLA'];
                                           const moduleType = ams.module_type as 'n3f' | 'n3s';
                                           setDryingFilament(filType);
                                           setDryingTemp(preset[moduleType] || preset.n3f);
@@ -3121,9 +3124,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-500/20 text-amber-400'
-                                          : 'bg-bambu-dark-tertiary/50 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+                                          : ams.dry_sf_reason?.length
+                                            ? 'bg-bambu-dark-tertiary/30 text-bambu-gray/50 cursor-not-allowed'
+                                            : 'bg-bambu-dark-tertiary/50 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
                                       }`}
-                                      title={ams.dry_time > 0 ? t('printers.drying.stop') : t('printers.drying.start')}
+                                      title={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>
@@ -3497,12 +3502,12 @@ function PrinterCard({
                                         setDryingPopoverAmsId(null);
                                       } else {
                                         const firstTray = ams.tray.find(t => t.tray_type);
-                                        const filType = firstTray?.tray_type || 'PLA';
-                                        const preset = DRYING_PRESETS[filType] || DRYING_PRESETS['PLA'];
+                                        const filType = (firstTray?.tray_type || 'PLA').split(' ')[0].toUpperCase();
+                                        const preset = dryingPresets[filType] || dryingPresets['PLA'];
                                         const moduleType = ams.module_type as 'n3f' | 'n3s';
                                         setDryingFilament(filType);
-                                        setDryingTemp(firstTray?.drying_temp || preset[moduleType] || preset.n3f);
-                                        setDryingDuration(firstTray?.drying_time || (moduleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours));
+                                        setDryingTemp(preset[moduleType] || preset.n3f);
+                                        setDryingDuration(moduleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours);
                                         setDryingPopoverModuleType(ams.module_type);
                                         setDryingPopoverAmsId(ams.id);
                                         const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -4635,7 +4640,7 @@ function PrinterCard({
                     onChange={e => {
                       const fil = e.target.value;
                       setDryingFilament(fil);
-                      const preset = DRYING_PRESETS[fil];
+                      const preset = dryingPresets[fil];
                       if (preset) {
                         const key = dryingPopoverModuleType === 'n3s' ? 'n3s' : 'n3f';
                         setDryingTemp(preset[key]);
@@ -4644,7 +4649,7 @@ function PrinterCard({
                     }}
                     className="w-full px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs focus:outline-none focus:border-amber-500/50"
                   >
-                    {Object.keys(DRYING_PRESETS).map(fil => (
+                    {Object.keys(dryingPresets).map(fil => (
                       <option key={fil} value={fil}>{fil}</option>
                     ))}
                   </select>
@@ -5627,6 +5632,19 @@ export function PrintersPage() {
     queryFn: api.getSettings,
   });
 
+  // Compute drying presets: user-configured (from settings) merged over built-in defaults
+  const effectiveDryingPresets = useMemo(() => {
+    if (settings?.drying_presets) {
+      try {
+        const userPresets = JSON.parse(settings.drying_presets);
+        if (typeof userPresets === 'object' && userPresets !== null && Object.keys(userPresets).length > 0) {
+          return { ...DRYING_PRESETS, ...userPresets };
+        }
+      } catch { /* ignore parse errors, use defaults */ }
+    }
+    return DRYING_PRESETS;
+  }, [settings?.drying_presets]);
+
   // Close embedded cameras if mode changes to 'window'
   useEffect(() => {
     if (settings?.camera_view_mode === 'window' && embeddedCameraPrinters.size > 0) {
@@ -6010,6 +6028,7 @@ export function PrintersPage() {
                     cameraViewMode={settings?.camera_view_mode || 'window'}
                     onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
                     checkPrinterFirmware={settings?.check_printer_firmware !== false}
+                    dryingPresets={effectiveDryingPresets}
                   />
                 ))}
               </div>
@@ -6043,6 +6062,7 @@ export function PrintersPage() {
               cameraViewMode={settings?.camera_view_mode || 'window'}
               onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
               checkPrinterFirmware={settings?.check_printer_firmware !== false}
+              dryingPresets={effectiveDryingPresets}
             />
           ))}
         </div>

+ 127 - 1
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -709,6 +709,9 @@ export function SettingsPage() {
       settings.ams_temp_good !== localSettings.ams_temp_good ||
       settings.ams_temp_fair !== localSettings.ams_temp_fair ||
       settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
+      (settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
+      (settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
+      (settings.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
       settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
       settings.date_format !== localSettings.date_format ||
       settings.time_format !== localSettings.time_format ||
@@ -775,6 +778,9 @@ export function SettingsPage() {
         ams_temp_good: localSettings.ams_temp_good,
         ams_temp_fair: localSettings.ams_temp_fair,
         ams_history_retention_days: localSettings.ams_history_retention_days,
+        queue_drying_enabled: localSettings.queue_drying_enabled,
+        queue_drying_block: localSettings.queue_drying_block,
+        drying_presets: localSettings.drying_presets,
         per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded,
         date_format: localSettings.date_format,
         time_format: localSettings.time_format,
@@ -3284,6 +3290,9 @@ export function SettingsPage() {
                   <p className="text-xs text-bambu-gray">
                     {t('settings.aboveFairBad')}
                   </p>
+                  <p className="text-xs text-amber-400/70">
+                    {t('settings.fairAlsoDryingThreshold')}
+                  </p>
                 </div>
 
                 {/* Temperature Thresholds */}
@@ -3360,6 +3369,123 @@ export function SettingsPage() {
                   </p>
                 </div>
 
+                {/* Queue Auto-Drying */}
+                <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
+                  <div className="flex items-center gap-2 text-white">
+                    <Flame className="w-4 h-4 text-amber-400" />
+                    <span className="font-medium">{t('settings.queueDrying')}</span>
+                  </div>
+                  <p className="text-xs text-bambu-gray">
+                    {t('settings.queueDryingDescription')}
+                  </p>
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <label className="block text-sm text-white">
+                        {t('settings.queueDryingEnabled')}
+                      </label>
+                      <p className="text-xs text-bambu-gray mt-0.5">
+                        {t('settings.queueDryingEnabledDescription')}
+                      </p>
+                    </div>
+                    <label className="relative inline-flex items-center cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={localSettings.queue_drying_enabled ?? false}
+                        onChange={(e) => updateSetting('queue_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>
+                  {localSettings.queue_drying_enabled && (
+                    <>
+                      <div className="flex items-center justify-between">
+                        <div>
+                          <label className="block text-sm text-white">
+                            {t('settings.queueDryingBlock')}
+                          </label>
+                          <p className="text-xs text-bambu-gray mt-0.5">
+                            {t('settings.queueDryingBlockDescription')}
+                          </p>
+                        </div>
+                        <label className="relative inline-flex items-center cursor-pointer">
+                          <input
+                            type="checkbox"
+                            checked={localSettings.queue_drying_block ?? false}
+                            onChange={(e) => updateSetting('queue_drying_block', 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-amber-500"></div>
+                        </label>
+                      </div>
+                    </>
+                  )}
+                  {/* Drying Presets Table — always visible since manual drying also uses these */}
+                  <div className="space-y-2">
+                    <p className="text-sm text-white font-medium">{t('settings.dryingPresets')}</p>
+                    <p className="text-xs text-bambu-gray">{t('settings.dryingPresetsDescription')}</p>
+                    <div className="overflow-x-auto">
+                      <table className="w-full text-xs">
+                        <thead>
+                          <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
+                            <th className="text-left py-1.5 pr-2">{t('settings.dryingFilament')}</th>
+                            <th className="text-center py-1.5 px-1">AMS 2 Pro °C</th>
+                            <th className="text-center py-1.5 px-1">AMS-HT °C</th>
+                            <th className="text-center py-1.5 px-1">AMS 2 Pro h</th>
+                            <th className="text-center py-1.5 px-1">AMS-HT h</th>
+                          </tr>
+                        </thead>
+                        <tbody>
+                          {(() => {
+                            const defaults: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {
+                              PLA: { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
+                              PETG: { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
+                              TPU: { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
+                              ABS: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+                              ASA: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+                              PA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },
+                              PC: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+                              PVA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },
+                            };
+                            let presets = { ...defaults };
+                            try {
+                              if (localSettings.drying_presets) {
+                                const parsed = JSON.parse(localSettings.drying_presets);
+                                if (typeof parsed === 'object' && parsed !== null) {
+                                  presets = { ...defaults, ...parsed };
+                                }
+                              }
+                            } catch { /* use defaults */ }
+
+                            const updatePreset = (fil: string, key: string, value: number) => {
+                              const updated = { ...presets, [fil]: { ...presets[fil], [key]: value } };
+                              updateSetting('drying_presets', JSON.stringify(updated));
+                            };
+
+                            return Object.entries(presets).map(([fil, preset]) => (
+                              <tr key={fil} className="border-b border-bambu-dark-tertiary/50">
+                                <td className="py-1.5 pr-2 text-white font-medium">{fil}</td>
+                                {(['n3f', 'n3s', 'n3f_hours', 'n3s_hours'] as const).map(key => (
+                                  <td key={key} className="py-1 px-1">
+                                    <input
+                                      type="number"
+                                      min={key.includes('hours') ? 1 : 30}
+                                      max={key.includes('hours') ? 24 : (key === 'n3s' ? 85 : 65)}
+                                      value={preset[key]}
+                                      onChange={e => updatePreset(fil, key, Math.max(1, parseInt(e.target.value) || 0))}
+                                      className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+                                    />
+                                  </td>
+                                ))}
+                              </tr>
+                            ));
+                          })()}
+                        </tbody>
+                      </table>
+                    </div>
+                  </div>
+                </div>
+
                 {/* Per-Printer Mapping Default */}
                 <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
                   <div className="flex items-center gap-2 text-white">

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-Bevj-FAl.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-BgeMokkR.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Dqwr9PJ1.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index--YKaUCwD.css">
+    <script type="module" crossorigin src="/assets/index-Bevj-FAl.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BgeMokkR.css">
   </head>
   <body>
     <div id="root"></div>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff