Sfoglia il codice sorgente

feat: Wait for sustained AMS humidity before an ambient auto-dry starts (issue #2518)

M2ABRAMSTANK 22 ore fa
parent
commit
568702486b

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

@@ -261,6 +261,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "queue_keep_warm_bed_temp",
             "queue_keep_warm_bed_temp",
             "queue_keep_warm_max_minutes",
             "queue_keep_warm_max_minutes",
             "queue_max_concurrent_uploads",
             "queue_max_concurrent_uploads",
+            "ambient_drying_sustained_minutes",
         ]:
         ]:
             settings_dict[setting.key] = int(setting.value)
             settings_dict[setting.key] = int(setting.value)
         elif setting.key == "default_printer_id":
         elif setting.key == "default_printer_id":

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

@@ -144,6 +144,16 @@ class AppSettings(BaseModel):
         default=False,
         default=False,
         description="Automatically dry AMS filament on idle printers when humidity exceeds threshold, regardless of queue",
         description="Automatically dry AMS filament on idle printers when humidity exceeds threshold, regardless of queue",
     )
     )
+    ambient_drying_sustained_minutes: int = Field(
+        default=0,
+        ge=0,
+        le=240,
+        description=(
+            "Minutes the humidity must stay above the threshold before an ambient "
+            "auto-dry starts (0 = start immediately). Rides out the reading spike "
+            "from opening the AMS lid instead of buying a dry cycle for it."
+        ),
+    )
     print_drying_enabled: bool = Field(
     print_drying_enabled: bool = Field(
         default=False,
         default=False,
         description=(
         description=(
@@ -700,6 +710,7 @@ class AppSettingsUpdate(BaseModel):
     queue_drying_enabled: bool | None = None
     queue_drying_enabled: bool | None = None
     queue_drying_block: bool | None = None
     queue_drying_block: bool | None = None
     ambient_drying_enabled: bool | None = None
     ambient_drying_enabled: bool | None = None
+    ambient_drying_sustained_minutes: int | None = Field(default=None, ge=0, le=240)
     print_drying_enabled: bool | None = None
     print_drying_enabled: bool | None = None
     drying_presets: str | None = None
     drying_presets: str | None = None
     ams_humidity_thresholds: str | None = None
     ams_humidity_thresholds: str | None = None

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

@@ -194,6 +194,13 @@ class _KeepWarmEntry:
 # manual or firmware-run dry is untouched.
 # manual or firmware-run dry is untouched.
 AUTO_DRY_REARM_COOLDOWN_SECONDS = 30 * 60
 AUTO_DRY_REARM_COOLDOWN_SECONDS = 30 * 60
 AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES = 2
 AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES = 2
+# Sustained-humidity wait (#2518): an above-threshold streak is only
+# "continuous" if some pass observed it within the gap ceiling. A unit that
+# goes unobserved longer (print running, printer disconnected, sensor silent)
+# restarts its streak rather than inheriting a stale one. The ceiling is
+# derived from the scheduler cadence — four missed passes — with this floor so
+# a fast-polling configuration does not void streaks on a single hiccup.
+AUTO_DRY_SUSTAINED_GAP_FLOOR_SECONDS = 120
 
 
 # How long a finished scheduled drying row is kept before it is pruned.
 # How long a finished scheduled drying row is kept before it is pruned.
 SCHEDULED_DRYING_RETENTION_DAYS = 7
 SCHEDULED_DRYING_RETENTION_DAYS = 7
@@ -906,6 +913,16 @@ class PrintScheduler:
         #                  still above the threshold
         #                  still above the threshold
         #   suspended    — we have stopped arming this unit and said so
         #   suspended    — we have stopped arming this unit and said so
         self._auto_dry_units: dict[tuple[int, int], dict[str, object]] = {}
         self._auto_dry_units: dict[tuple[int, int], dict[str, object]] = {}
+        # Sustained-humidity streaks for the ambient-drying wait (#2518). Keyed
+        # like _auto_dry_units but deliberately a SEPARATE dict: membership in
+        # _auto_dry_units means "Bambuddy armed a cycle on this unit", and the
+        # print-takes-priority stop, the manual-cycle adoption guard, and the
+        # arming setdefault all act on that meaning -- a unit that is merely
+        # waiting out its streak must not become stoppable or judgeable.
+        #   since -- monotonic stamp when the current continuous above-threshold
+        #            streak began
+        #   last  -- monotonic stamp of the last pass that observed the streak
+        self._auto_dry_above: dict[tuple[int, int], dict[str, float]] = {}
         # Printers with a "running" scheduled drying row (#2638). Rebuilt from the
         # Printers with a "running" scheduled drying row (#2638). Rebuilt from the
         # DB on every _check_scheduled_dryings call so route-side cancels show up.
         # DB on every _check_scheduled_dryings call so route-side cancels show up.
         # Auto-drying's stop-all branches must not stop or untrack these printers;
         # Auto-drying's stop-all branches must not stop or untrack these printers;
@@ -4168,6 +4185,17 @@ class PrintScheduler:
         queue_drying_enabled = await self._get_bool_setting(db, "queue_drying_enabled")
         queue_drying_enabled = await self._get_bool_setting(db, "queue_drying_enabled")
         ambient_drying_enabled = await self._get_bool_setting(db, "ambient_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")
         print_drying_enabled = await self._get_bool_setting(db, "print_drying_enabled")
+        sustained_minutes = await self._get_int_setting(db, "ambient_drying_sustained_minutes", default=0)
+        # The wait belongs to ambient drying: the settings UI only shows it while
+        # ambient drying is on, so a value left behind when ambient is turned off
+        # must not keep delaying the one path that still reaches the wait gate
+        # without it (a mid-print start under print_drying).
+        sustained_wait_active = sustained_minutes > 0 and ambient_drying_enabled
+        # Clear every streak as soon as the wait is inactive. An early return (or
+        # an already-drying unit) may otherwise skip the per-unit cleanup and let
+        # a quick toggle-on inherit an old streak.
+        if not sustained_wait_active:
+            self._auto_dry_above.clear()
         if not queue_drying_enabled and not ambient_drying_enabled:
         if not queue_drying_enabled and not ambient_drying_enabled:
             # Stop active drying on all printers if both features disabled
             # Stop active drying on all printers if both features disabled
             if self._drying_in_progress:
             if self._drying_in_progress:
@@ -4352,6 +4380,11 @@ class PrintScheduler:
                 # values from reading as progress every other cycle.
                 # values from reading as progress every other cycle.
                 if unit_state is not None and unit_state.pop("running", False):
                 if unit_state is not None and unit_state.pop("running", False):
                     unit_state["ended_at"] = time.monotonic()
                     unit_state["ended_at"] = time.monotonic()
+                    # The streak that armed this cycle is spent; the next one
+                    # starts fresh (and accumulates through the re-arm cooldown
+                    # below, so the wait overlaps the cooldown, never stacks on
+                    # top of it).
+                    self._auto_dry_above.pop(unit_key, None)
                     if humidity is not None and humidity > humidity_threshold:
                     if humidity is not None and humidity > humidity_threshold:
                         best = unit_state.get("best_end_humidity")
                         best = unit_state.get("best_end_humidity")
                         if isinstance(best, int) and humidity < best:
                         if isinstance(best, int) and humidity < best:
@@ -4402,6 +4435,24 @@ class PrintScheduler:
                         unit_state.pop("suspended", None)
                         unit_state.pop("suspended", None)
                         unit_state.pop("unproductive", None)
                         unit_state.pop("unproductive", None)
                         unit_state.pop("best_end_humidity", None)
                         unit_state.pop("best_end_humidity", None)
+                    # A real below-threshold reading ends any sustained-wait
+                    # streak (#2518) -- "continuously above" means exactly that.
+                    # An absent reading (humidity is None) is no-information and
+                    # leaves the streak alone; the observation-gap guard handles
+                    # a prolonged sensor silence.
+                    if humidity is not None:
+                        _above = self._auto_dry_above.pop(unit_key, None)
+                        if _above is not None and sustained_wait_active:
+                            logger.info(
+                                "Auto-drying: printer %d AMS %d — humidity fell back to %s%% after "
+                                "%.0fs of the required %dm above the %d%% threshold; not drying",
+                                pid,
+                                ams_id,
+                                humidity,
+                                time.monotonic() - _above["since"],
+                                sustained_minutes,
+                                humidity_threshold,
+                            )
                     logger.debug(
                     logger.debug(
                         "Auto-drying: printer %d AMS %d skipped — humidity %s <= threshold %d",
                         "Auto-drying: printer %d AMS %d skipped — humidity %s <= threshold %d",
                         pid,
                         pid,
@@ -4411,6 +4462,42 @@ class PrintScheduler:
                     )
                     )
                     continue
                     continue
 
 
+                # Sustained-humidity streak (#2518): updated on every pass that
+                # observes the reading above the threshold, BEFORE the
+                # suspension/cooldown gates below -- a suspended or cooling-down
+                # unit still accumulates streak time, so the wait overlaps those
+                # gates instead of stacking after them. Inert when the feature
+                # is off: no entries are written, and an entry left over from a
+                # toggle-off is dropped so it cannot seed a stale streak later.
+                if sustained_wait_active:
+                    _now = time.monotonic()
+                    # Four missed scheduler passes, floored: a single slow pass
+                    # must not void a streak, but the ceiling has to scale with
+                    # the cadence or a slow loop silently restarts every streak.
+                    _gap_ceiling = max(4 * self._check_interval, AUTO_DRY_SUSTAINED_GAP_FLOOR_SECONDS)
+                    _above = self._auto_dry_above.get(unit_key)
+                    if _above is None:
+                        self._auto_dry_above[unit_key] = {"since": _now, "last": _now}
+                    elif _now - _above["last"] > _gap_ceiling:
+                        # Restart, and say so at the same level as the dip
+                        # reset: a silent restart voids the streak invisibly,
+                        # and a user who set a long wait and never gets a dry
+                        # has no way to see why.
+                        logger.info(
+                            "Auto-drying: printer %d AMS %d — sustained-humidity streak restarted after a "
+                            "%.0fs observation gap (ceiling %ds); the %dm wait starts over",
+                            pid,
+                            ams_id,
+                            _now - _above["last"],
+                            _gap_ceiling,
+                            sustained_minutes,
+                        )
+                        self._auto_dry_above[unit_key] = {"since": _now, "last": _now}
+                    else:
+                        _above["last"] = _now
+                else:
+                    self._auto_dry_above.pop(unit_key, None)
+
                 if unit_state is not None:
                 if unit_state is not None:
                     if unit_state.get("suspended"):
                     if unit_state.get("suspended"):
                         logger.debug(
                         logger.debug(
@@ -4452,7 +4539,9 @@ class PrintScheduler:
                         )
                         )
                         continue
                         continue
 
 
-                # Check cannot-dry reasons (power constraints etc.)
+                # Check cannot-dry reasons (power constraints etc.). Sits
+                # ahead of the sustained wait so a unit the firmware refuses
+                # to dry never logs a wait it was never going to cash in.
                 sf_reasons = ams_data.get("dry_sf_reason", [])
                 sf_reasons = ams_data.get("dry_sf_reason", [])
                 if sf_reasons:
                 if sf_reasons:
                     logger.debug(
                     logger.debug(
@@ -4463,6 +4552,32 @@ class PrintScheduler:
                     )
                     )
                     continue
                     continue
 
 
+                # Sustained-humidity wait (#2518): ambient-triggered starts
+                # wait; only a printer with a scheduled queue item pending
+                # keeps the instant behavior, because that drying has a real
+                # deadline. Mid-print is deliberately NOT exempt: print_drying
+                # is a permission overlay, not a trigger -- an ambient start on
+                # a printer that happens to be printing is the same
+                # transient-vulnerable humidity trigger as on an idle one
+                # (proven live: a 2-point threshold crossing mid-print bought a
+                # parked 12h command). Inactive when ambient drying is off, so a
+                # mid-print start under print_drying alone stays instant.
+                if sustained_wait_active and pid not in printers_with_scheduled:
+                    _above = self._auto_dry_above.get(unit_key)
+                    _waited = time.monotonic() - _above["since"] if _above else 0.0
+                    if _waited < sustained_minutes * 60:
+                        logger.debug(
+                            "Auto-drying: printer %d AMS %d waiting — humidity %s%% above the %d%% "
+                            "threshold for %.0fs of the required %dm",
+                            pid,
+                            ams_id,
+                            humidity,
+                            humidity_threshold,
+                            _waited,
+                            sustained_minutes,
+                        )
+                        continue
+
                 # Get conservative drying params for mixed filaments
                 # Get conservative drying params for mixed filaments
                 params = self._get_conservative_drying_params(trays, module_type, presets)
                 params = self._get_conservative_drying_params(trays, module_type, presets)
                 if not params:
                 if not params:
@@ -4559,6 +4674,9 @@ class PrintScheduler:
         if state is not None:
         if state is not None:
             state.pop("running", None)
             state.pop("running", None)
             state["ended_at"] = time.monotonic()
             state["ended_at"] = time.monotonic()
+        # A stopped cycle spends the streak that armed it, same as a completed
+        # one (#2518).
+        self._auto_dry_above.pop((printer_id, ams_id), None)
 
 
     def _sync_drying_state(self):
     def _sync_drying_state(self):
         """Drop printers from ``_drying_in_progress`` that are no longer drying.
         """Drop printers from ``_drying_in_progress`` that are no longer drying.
@@ -4593,6 +4711,11 @@ class PrintScheduler:
         # inherit a suspension it never earned.
         # inherit a suspension it never earned.
         for key in [k for k in self._auto_dry_units if printer_manager.get_status(k[0]) is None]:
         for key in [k for k in self._auto_dry_units if printer_manager.get_status(k[0]) is None]:
             self._auto_dry_units.pop(key, None)
             self._auto_dry_units.pop(key, None)
+        # Same for sustained-wait streaks (#2518): a deleted-and-re-added
+        # printer starts a fresh wait, and vanished printers do not leak
+        # entries.
+        for key in [k for k in self._auto_dry_above if printer_manager.get_status(k[0]) is None]:
+            self._auto_dry_above.pop(key, None)
 
 
     async def _drying_may_continue_through_print(self, db: AsyncSession, printer_id: int) -> bool:
     async def _drying_may_continue_through_print(self, db: AsyncSession, printer_id: int) -> bool:
         """True when a running cycle can be left alone while the next print runs.
         """True when a running cycle can be left alone while the next print runs.

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

@@ -8,6 +8,7 @@ Covers:
 - Sync drying state after restart
 - Sync drying state after restart
 """
 """
 
 
+import re
 import time
 import time
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
@@ -16,6 +17,7 @@ import pytest
 from backend.app.services.print_scheduler import (
 from backend.app.services.print_scheduler import (
     AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
     AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
     AUTO_DRY_REARM_COOLDOWN_SECONDS,
     AUTO_DRY_REARM_COOLDOWN_SECONDS,
+    AUTO_DRY_SUSTAINED_GAP_FLOOR_SECONDS,
     PrintScheduler,
     PrintScheduler,
 )
 )
 
 
@@ -1886,3 +1888,609 @@ class TestAutoDryProgressKeepsItGoing(_DryingTestBase):
 
 
         assert scheduler._auto_dry_units[(1, 0)]["suspended"] is True
         assert scheduler._auto_dry_units[(1, 0)]["suspended"] is True
         mock_notify.on_ams_drying_suspended.assert_awaited_once()
         mock_notify.on_ams_drying_suspended.assert_awaited_once()
+
+
+class TestAmbientDryingSustainedDelay(_DryingTestBase):
+    """#2518 — ambient auto-drying waits for CONTINUOUSLY-above-threshold humidity.
+
+    ``ambient_drying_sustained_minutes`` (default 0 = instant, matching the
+    pre-#2518 behavior) makes a pure-ambient start wait for the reading to sit
+    above the threshold for that many minutes straight before drying begins.
+    The streak lives in ``scheduler._auto_dry_above``, keyed like
+    ``_auto_dry_units`` by (printer_id, ams_id), but deliberately a separate
+    dict: arming a streak must never look like a Bambuddy-started cycle to
+    ``_auto_dry_units``, which is what ``_stop_drying`` and the manual-cycle
+    immunity in #2801 key off of.
+    """
+
+    UNIT_KEY = (1, 0)
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @staticmethod
+    def _ams_unit(dry_time=0, humidity="75", include_humidity=True):
+        ams = {
+            "id": 0,
+            "module_type": "n3f",
+            "dry_time": dry_time,
+            "dry_sf_reason": [],
+            "tray": [{"tray_type": "PLA"}],
+        }
+        if include_humidity:
+            ams["humidity_raw"] = humidity
+        return ams
+
+    @classmethod
+    def _state(cls, dry_time=0, humidity="75", include_humidity=True):
+        state = MagicMock()
+        state.raw_data = {"ams": [cls._ams_unit(dry_time, humidity, include_humidity)]}
+        state.firmware_version = "01.09.00.00"
+        return state
+
+    def _db(self, sustained_minutes=None, queue_enabled="false", ambient_enabled="true"):
+        settings = {
+            "queue_drying_enabled": self._make_setting(queue_enabled),
+            "ambient_drying_enabled": self._make_setting(ambient_enabled),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        if sustained_minutes is not None:
+            settings["ambient_drying_sustained_minutes"] = self._make_setting(str(sustained_minutes))
+        db = AsyncMock()
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings))
+        return db
+
+    @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_setting_absent_dries_immediately(self, mock_sd, mock_pm, scheduler):
+        """No ``ambient_drying_sustained_minutes`` row → default 0 → instant dry,
+        same as ambient mode before #2518."""
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        mock_pm.send_drying_command.return_value = True
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+
+        await scheduler._check_auto_drying(self._db(sustained_minutes=None), [], set())
+
+        mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
+
+    @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_first_above_threshold_pass_arms_but_does_not_dry(self, mock_sd, mock_pm, scheduler):
+        """First observation above threshold arms the streak instead of drying."""
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+
+        await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
+
+        mock_pm.send_drying_command.assert_not_called()
+        assert self.UNIT_KEY in scheduler._auto_dry_above
+
+    @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_matured_streak_dries_on_next_pass(self, mock_sd, mock_pm, scheduler):
+        """Once the streak's age clears the configured minutes, the next pass dries."""
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        mock_pm.send_drying_command.return_value = True
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = self._db(sustained_minutes=5)
+
+        await scheduler._check_auto_drying(db, [], set())
+        mock_pm.send_drying_command.assert_not_called()
+
+        # Age the streak past the 5-minute requirement, keeping "last" recent
+        # so the observation-gap guard doesn't treat it as a fresh streak.
+        scheduler._auto_dry_above[self.UNIT_KEY]["since"] = time.monotonic() - (5 * 60 + 5)
+        scheduler._auto_dry_above[self.UNIT_KEY]["last"] = time.monotonic() - 5
+
+        await scheduler._check_auto_drying(db, [], set())
+
+        mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
+
+    @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_dip_below_threshold_clears_streak(self, mock_sd, mock_pm, scheduler):
+        """A below-threshold reading between passes clears the streak — the wait restarts."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = self._db(sustained_minutes=5)
+
+        mock_pm.get_status.return_value = self._state(humidity="75")
+        await scheduler._check_auto_drying(db, [], set())
+        assert self.UNIT_KEY in scheduler._auto_dry_above
+
+        mock_pm.get_status.return_value = self._state(humidity="40")  # below the 60% threshold
+        await scheduler._check_auto_drying(db, [], set())
+
+        assert self.UNIT_KEY not in scheduler._auto_dry_above
+
+    @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_missing_humidity_does_not_clear_armed_streak(self, mock_sd, mock_pm, scheduler):
+        """No humidity_raw/humidity in the AMS payload is no-information, not a dip below threshold."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = self._db(sustained_minutes=5)
+
+        mock_pm.get_status.return_value = self._state(humidity="75")
+        await scheduler._check_auto_drying(db, [], set())
+        assert self.UNIT_KEY in scheduler._auto_dry_above
+
+        mock_pm.get_status.return_value = self._state(include_humidity=False)
+        await scheduler._check_auto_drying(db, [], set())
+
+        assert self.UNIT_KEY in scheduler._auto_dry_above
+        mock_pm.send_drying_command.assert_not_called()
+
+    @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_manual_cycle_immunity(self, mock_sd, mock_pm, scheduler):
+        """Arming a streak must never create a ``_auto_dry_units`` entry — that dict is
+        reserved for cycles Bambuddy itself started (#2801's manual-cycle immunity). A
+        unit already drying by the user's own hand (dry_time > 0) with a streak still
+        present is left alone: no command sent, no entry created, streak untouched.
+        """
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = self._db(sustained_minutes=5)
+
+        # First pass: nothing drying yet, arms the streak.
+        mock_pm.get_status.return_value = self._state(dry_time=0)
+        await scheduler._check_auto_drying(db, [], set())
+        assert self.UNIT_KEY not in scheduler._auto_dry_units
+        assert self.UNIT_KEY in scheduler._auto_dry_above
+
+        # A manual cycle is now running on the same unit — dry_time > 0.
+        mock_pm.get_status.return_value = self._state(dry_time=120)
+        await scheduler._check_auto_drying(db, [], set())
+
+        mock_pm.send_drying_command.assert_not_called()
+        assert self.UNIT_KEY not in scheduler._auto_dry_units
+        assert self.UNIT_KEY in scheduler._auto_dry_above
+
+    @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_scheduled_queue_item_dries_immediately(self, mock_sd, mock_pm, scheduler):
+        """A printer with a scheduled (non-manual) queue item dries instantly —
+        the sustained wait applies only to pure-ambient starts."""
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        mock_pm.send_drying_command.return_value = True
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = self._db(sustained_minutes=5, queue_enabled="true", ambient_enabled="false")
+
+        item = MagicMock()
+        item.printer_id = 1
+        item.scheduled_time = MagicMock()
+        item.manual_start = False
+
+        await scheduler._check_auto_drying(db, [item], set())
+
+        mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
+
+    @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_observation_gap_restarts_the_streak(self, mock_sd, mock_pm, scheduler):
+        """A streak whose last observation is older than the gap ceiling
+        can no longer claim "continuously above" — it is treated as fresh and the
+        pass must not dry, even though 'since' alone clears the required minutes."""
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+
+        old_since = time.monotonic() - (5 * 60 + 100)
+        scheduler._auto_dry_above[self.UNIT_KEY] = {
+            "since": old_since,
+            "last": time.monotonic() - (AUTO_DRY_SUSTAINED_GAP_FLOOR_SECONDS + 10),
+        }
+
+        await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
+
+        mock_pm.send_drying_command.assert_not_called()
+        # The streak was restarted, not left at its stale (matured) age.
+        assert scheduler._auto_dry_above[self.UNIT_KEY]["since"] > old_since + 90
+
+    @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_suspension_wins_over_a_matured_streak(self, mock_sd, mock_pm, scheduler):
+        """A suspended unit (#2770) stays suspended even once the sustained wait matures."""
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+
+        scheduler._auto_dry_units[self.UNIT_KEY] = {
+            "unproductive": AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
+            "suspended": True,
+            "ended_at": None,
+        }
+        scheduler._auto_dry_above[self.UNIT_KEY] = {
+            "since": time.monotonic() - (5 * 60 + 5),
+            "last": time.monotonic() - 5,
+        }
+
+        await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
+
+        mock_pm.send_drying_command.assert_not_called()
+        assert scheduler._auto_dry_units[self.UNIT_KEY]["suspended"] is True
+
+    def test_forget_auto_dry_cycle_pops_the_streak(self, scheduler):
+        """``forget_auto_dry_cycle`` (the route-side stop path) also spends the
+        streak that armed the cycle (#2518). The other cycle-end path — the
+        in-loop branch inside ``_check_auto_drying`` that pops ``running`` when
+        firmware reports ``dry_time == 0`` — is covered separately by
+        ``test_in_loop_cycle_end_restarts_the_streak``."""
+        scheduler._auto_dry_units[self.UNIT_KEY] = {
+            "running": True,
+            "unproductive": 0,
+            "suspended": False,
+            "ended_at": None,
+        }
+        scheduler._auto_dry_above[self.UNIT_KEY] = {"since": time.monotonic(), "last": time.monotonic()}
+
+        scheduler.forget_auto_dry_cycle(*self.UNIT_KEY)
+
+        assert self.UNIT_KEY not in scheduler._auto_dry_above
+        assert "running" not in scheduler._auto_dry_units[self.UNIT_KEY]
+
+    @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_in_loop_cycle_end_restarts_the_streak(self, mock_sd, mock_pm, scheduler):
+        """When the in-loop cycle-end branch fires (a Bambuddy-armed cycle whose
+        ``dry_time`` has gone to 0), the matured streak that armed it is spent —
+        the pass re-arms a FRESH streak rather than keeping the old ``since``.
+        Without the pop, a matured pre-cycle streak would survive the cycle and
+        reduce the sustained wait to the re-arm cooldown on every re-arm."""
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        scheduler._auto_dry_units[self.UNIT_KEY] = {"running": True}
+        old_since = time.monotonic() - (60 * 60)
+        scheduler._auto_dry_above[self.UNIT_KEY] = {"since": old_since, "last": time.monotonic() - 5}
+
+        before = time.monotonic()
+        await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
+
+        # The cycle-end pass never dries (its own ended_at starts the cooldown)...
+        mock_pm.send_drying_command.assert_not_called()
+        # ...and the streak now on file must be fresh, not the pre-cycle one.
+        assert scheduler._auto_dry_above[self.UNIT_KEY]["since"] >= before
+
+    @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_streak_arms_during_the_rearm_cooldown(self, mock_sd, mock_pm, scheduler):
+        """The wait OVERLAPS the 30-minute re-arm cooldown instead of stacking
+        after it: a pass blocked by the cooldown still arms/advances the streak,
+        so a streak matured inside the cooldown dries at cooldown expiry."""
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        mock_pm.send_drying_command.return_value = True
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = self._db(sustained_minutes=5)
+        scheduler._auto_dry_units[self.UNIT_KEY] = {"ended_at": time.monotonic() - 60}
+
+        await scheduler._check_auto_drying(db, [], set())
+        mock_pm.send_drying_command.assert_not_called()
+        # The cooldown blocked the start, but the streak armed anyway.
+        assert self.UNIT_KEY in scheduler._auto_dry_above
+
+        # Cooldown over; the streak that matured inside it satisfies the wait.
+        scheduler._auto_dry_units[self.UNIT_KEY]["ended_at"] = time.monotonic() - (AUTO_DRY_REARM_COOLDOWN_SECONDS + 5)
+        scheduler._auto_dry_above[self.UNIT_KEY]["since"] = time.monotonic() - (5 * 60 + 5)
+        scheduler._auto_dry_above[self.UNIT_KEY]["last"] = time.monotonic() - 5
+
+        await scheduler._check_auto_drying(db, [], set())
+        mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_mid_print_ambient_start_waits_like_any_ambient_start(self, mock_pm, scheduler):
+        """print_drying is a permission overlay, not a trigger: an ambient
+        start on a printer that happens to be printing is still an ambient
+        start and must serve the sustained wait. (The original exemption here
+        was disproven live — a 2-point threshold crossing mid-print bought a
+        parked 12h command instantly.)"""
+        state = self._state()
+        state.state = "RUNNING"
+        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)
+        state.firmware_version = "01.03.00.00"
+
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("false"),
+            "ambient_drying_enabled": self._make_setting("true"),
+            "print_drying_enabled": self._make_setting("true"),
+            "ambient_drying_sustained_minutes": self._make_setting("5"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db = AsyncMock()
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        # First pass: the streak arms; no instant start.
+        await scheduler._check_auto_drying(db, [], {1})
+        mock_pm.send_drying_command.assert_not_called()
+        assert self.UNIT_KEY in scheduler._auto_dry_above
+
+        # Streak matured: the start fires, at the mid-print capped
+        # temperature max(40, 45 - 5) = 40.
+        scheduler._auto_dry_above[self.UNIT_KEY]["since"] = time.monotonic() - (5 * 60 + 5)
+        scheduler._auto_dry_above[self.UNIT_KEY]["last"] = time.monotonic() - 5
+        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_called_once_with(1, 0, 40, 12, mode=1, filament="PLA")
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_mid_print_with_scheduled_item_stays_instant(self, mock_pm, scheduler):
+        """The instant exemption keys on the pending schedule — a printer
+        drying ahead of a scheduled job keeps it whether idle or printing."""
+        state = self._state()
+        state.state = "RUNNING"
+        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)
+        state.firmware_version = "01.03.00.00"
+
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("false"),
+            "ambient_drying_enabled": self._make_setting("true"),
+            "print_drying_enabled": self._make_setting("true"),
+            "ambient_drying_sustained_minutes": self._make_setting("5"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db = AsyncMock()
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        item = MagicMock()
+        item.printer_id = 1
+        item.scheduled_time = MagicMock()
+        item.manual_start = False
+
+        await scheduler._check_auto_drying(db, [item], {1})
+        mock_pm.send_drying_command.assert_called_once_with(1, 0, 40, 12, mode=1, filament="PLA")
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_stored_wait_is_inert_while_ambient_drying_is_off(self, mock_pm, scheduler):
+        """The settings UI hides the wait while ambient drying is off, so a value
+        left behind must not keep delaying a mid-print start under print_drying
+        (queue mode on, no scheduled item): that start stays instant, and no
+        streak entry is written for it."""
+        state = self._state()
+        state.state = "RUNNING"
+        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)
+        state.firmware_version = "01.03.00.00"
+
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "print_drying_enabled": self._make_setting("true"),
+            "ambient_drying_sustained_minutes": self._make_setting("15"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db = AsyncMock()
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+        # A streak left over from when ambient drying was on is dropped too.
+        scheduler._auto_dry_above[self.UNIT_KEY] = {"since": time.monotonic(), "last": time.monotonic()}
+
+        await scheduler._check_auto_drying(db, [], {1})
+        mock_pm.send_drying_command.assert_called_once_with(1, 0, 40, 12, mode=1, filament="PLA")
+        assert scheduler._auto_dry_above == {}
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_sync_drying_state_prunes_streaks_of_vanished_printers(self, mock_pm, scheduler):
+        """A printer that has gone away takes its streak with it — a deleted and
+        re-added printer starts a fresh wait, and entries do not leak."""
+        mock_pm.get_status.return_value = None
+        scheduler._auto_dry_above[(99, 0)] = {"since": time.monotonic(), "last": time.monotonic()}
+        scheduler._auto_dry_units[(99, 0)] = {"unproductive": 1}
+
+        scheduler._sync_drying_state()
+
+        assert (99, 0) not in scheduler._auto_dry_above
+        assert (99, 0) not in scheduler._auto_dry_units
+
+    @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_dip_below_threshold_logs_the_discard_at_info(self, mock_sd, mock_pm, scheduler, caplog):
+        """Discarding an armed streak on a real below-threshold reading is logged
+        at INFO — it is the only way an operator can tell why an ambient dry
+        never started."""
+        import logging
+
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = self._db(sustained_minutes=5)
+
+        await scheduler._check_auto_drying(db, [], set())
+        assert self.UNIT_KEY in scheduler._auto_dry_above
+
+        mock_pm.get_status.return_value = self._state(humidity="50")
+        with caplog.at_level(logging.INFO, logger="backend.app.services.print_scheduler"):
+            await scheduler._check_auto_drying(db, [], set())
+
+        assert self.UNIT_KEY not in scheduler._auto_dry_above
+        assert any("fell back" in rec.getMessage() for rec in caplog.records if rec.levelno == logging.INFO)
+
+    @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_observation_gap_restart_logs_at_info(self, mock_sd, mock_pm, scheduler, caplog):
+        """Restarting a streak on an observation gap is logged at INFO with the
+        measured gap — the silent twin of the dip reset, and the only way a
+        user who set a long wait can see why it never matures."""
+        import logging
+
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+
+        scheduler._auto_dry_above[self.UNIT_KEY] = {
+            "since": time.monotonic() - (5 * 60 + 100),
+            "last": time.monotonic() - (AUTO_DRY_SUSTAINED_GAP_FLOOR_SECONDS + 10),
+        }
+
+        with caplog.at_level(logging.INFO, logger="backend.app.services.print_scheduler"):
+            await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
+
+        restarts = [
+            rec for rec in caplog.records if rec.levelno == logging.INFO and "streak restarted" in rec.getMessage()
+        ]
+        assert len(restarts) == 1
+        # The measured gap is in the message, not just the fact of a restart.
+        assert re.search(r"\d+s observation gap", restarts[0].getMessage())
+
+    @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_gap_ceiling_scales_with_the_check_interval(self, mock_sd, mock_pm, scheduler):
+        """The ceiling is max(4 * check_interval, floor): a scheduler polling
+        slower than the default must not void streaks it structurally cannot
+        observe often enough (review on #2895)."""
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        mock_pm.send_drying_command.return_value = True
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        scheduler._check_interval = 100  # ceiling 400s, well above the 120s floor
+
+        scheduler._auto_dry_above[self.UNIT_KEY] = {
+            "since": time.monotonic() - (5 * 60 + 100),
+            # Over the floor, under 4 * check_interval: must NOT restart.
+            "last": time.monotonic() - (AUTO_DRY_SUSTAINED_GAP_FLOOR_SECONDS + 50),
+        }
+
+        await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
+
+        # The streak survived the slow pass and had already matured.
+        mock_pm.send_drying_command.assert_called_once()
+
+    @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_streak_tracking_is_inert_when_the_feature_is_off(self, mock_sd, mock_pm, scheduler):
+        """sustained_minutes 0 must not write streak entries — every install
+        would otherwise pay the bookkeeping for a feature nobody enabled
+        (review on #2895)."""
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        mock_pm.send_drying_command.return_value = True
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+
+        await scheduler._check_auto_drying(self._db(sustained_minutes=0), [], set())
+
+        mock_pm.send_drying_command.assert_called_once()
+        assert scheduler._auto_dry_above == {}
+
+    @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_toggle_off_drops_a_leftover_streak_entry(self, mock_sd, mock_pm, scheduler):
+        """An entry armed while the feature was on is dropped once it is off,
+        so toggling back on later cannot inherit a stale streak."""
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        mock_pm.send_drying_command.return_value = True
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+
+        scheduler._auto_dry_above[self.UNIT_KEY] = {
+            "since": time.monotonic() - 30,
+            "last": time.monotonic() - 1,
+        }
+
+        await scheduler._check_auto_drying(self._db(sustained_minutes=0), [], set())
+
+        assert self.UNIT_KEY not in scheduler._auto_dry_above
+
+    @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_disabling_all_auto_drying_drops_streak_before_early_return(self, mock_sd, mock_pm, scheduler):
+        """A quick disable/re-enable must start a fresh wait, even though the
+        disabled pass returns before visiting individual AMS units."""
+        mock_pm.get_status.return_value = self._state()
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        mock_pm.send_drying_command.return_value = True
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        scheduler._auto_dry_above[self.UNIT_KEY] = {
+            "since": time.monotonic() - 360,
+            "last": time.monotonic() - 1,
+        }
+
+        await scheduler._check_auto_drying(self._db(sustained_minutes=5, ambient_enabled="false"), [], set())
+        assert self.UNIT_KEY not in scheduler._auto_dry_above
+
+        await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
+        mock_pm.send_drying_command.assert_not_called()
+        assert self.UNIT_KEY in scheduler._auto_dry_above
+
+    @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_firmware_refusal_skips_before_the_wait_logs(self, mock_sd, mock_pm, scheduler, caplog):
+        """A unit the firmware refuses to dry (dry_sf_reason set) hits its skip
+        before the sustained wait, so it never logs "waiting" for a dry it was
+        never going to get (review nit on #2895)."""
+        import logging
+
+        state = self._state()
+        state.raw_data["ams"][0]["dry_sf_reason"] = ["8"]
+        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)
+
+        with caplog.at_level(logging.DEBUG, logger="backend.app.services.print_scheduler"):
+            await scheduler._check_auto_drying(self._db(sustained_minutes=5), [], set())
+
+        messages = [rec.getMessage() for rec in caplog.records]
+        assert any("cannot dry reasons" in m for m in messages)
+        assert not any("waiting" in m for m in messages)

+ 112 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -969,6 +969,118 @@ describe('SettingsPage', () => {
     });
     });
   });
   });
 
 
+  describe('sustained-minutes input drafts while typing (#2518)', () => {
+    const openWorkflowTab = async (user: ReturnType<typeof userEvent.setup>) => {
+      render(<SettingsPage />);
+      await waitFor(() => {
+        expect(screen.getByText('Workflow')).toBeInTheDocument();
+      });
+      await user.click(screen.getByText('Workflow'));
+      await waitFor(() => {
+        expect(screen.getByText('Queue Auto-Drying')).toBeInTheDocument();
+      });
+      // 15 is unique to this input in the mock settings below
+      return screen.getByDisplayValue('15') as HTMLInputElement;
+    };
+
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({
+            ...mockSettings,
+            ambient_drying_enabled: true,
+            ambient_drying_sustained_minutes: 15,
+          });
+        })
+      );
+    });
+
+    it('seeds a new sustained wait at 15 minutes when enabled from off', async () => {
+      const user = userEvent.setup();
+      let saved: Record<string, unknown> | null = null;
+      server.use(
+        http.get('/api/v1/settings/', () =>
+          HttpResponse.json({
+            ...mockSettings,
+            ambient_drying_enabled: true,
+            ambient_drying_sustained_minutes: 0,
+          })
+        ),
+        http.put('/api/v1/settings/', async ({ request }) => {
+          saved = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...mockSettings, ...saved });
+        })
+      );
+
+      render(<SettingsPage />);
+      await user.click(await screen.findByText('Workflow'));
+      const label = await screen.findByText('Require sustained humidity');
+      const row = label.closest('div')!.parentElement!;
+      const toggle = within(row).getByRole('checkbox');
+      expect(toggle).not.toBeChecked();
+
+      // Wait out the page's initial-load save suppression before toggling.
+      await new Promise((resolve) => setTimeout(resolve, 200));
+      await user.click(toggle);
+
+      expect(toggle).toBeChecked();
+      expect(screen.getByDisplayValue('15')).toBeInTheDocument();
+      await waitFor(() => {
+        expect(saved).not.toBeNull();
+      }, { timeout: 3000 });
+      expect(saved!.ambient_drying_sustained_minutes).toBe(15);
+    });
+
+    it('clearing the field does not snap it to a value mid-edit', async () => {
+      const user = userEvent.setup();
+      const input = await openWorkflowTab(user);
+
+      await user.clear(input);
+      // The old per-keystroke clamp rewrote '' to 10 immediately.
+      expect(input.value).toBe('');
+    });
+
+    it('intermediate below-minimum digits are not rewritten while typing', async () => {
+      const user = userEvent.setup();
+      const input = await openWorkflowTab(user);
+
+      await user.clear(input);
+      await user.type(input, '2');
+      // The old clamp turned the '2' (on the way to '25') into 5.
+      expect(input.value).toBe('2');
+      await user.type(input, '5');
+      expect(input.value).toBe('25');
+
+      await user.tab();
+      await waitFor(() => {
+        expect(input.value).toBe('25');
+      });
+    });
+
+    it('blur with an empty field reverts to the saved value instead of inventing one', async () => {
+      const user = userEvent.setup();
+      const input = await openWorkflowTab(user);
+
+      await user.clear(input);
+      await user.tab();
+      await waitFor(() => {
+        expect(input.value).toBe('15');
+      });
+    });
+
+    it('still clamps an out-of-range value on blur', async () => {
+      const user = userEvent.setup();
+      const input = await openWorkflowTab(user);
+
+      await user.clear(input);
+      await user.type(input, '500');
+      await user.tab();
+      await waitFor(() => {
+        expect(input.value).toBe('240');
+      });
+    });
+  });
+
   describe('API Keys tab', () => {
   describe('API Keys tab', () => {
     it('can switch to API Keys tab', async () => {
     it('can switch to API Keys tab', async () => {
       const user = userEvent.setup();
       const user = userEvent.setup();

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

@@ -1380,6 +1380,7 @@ export interface AppSettings {
   queue_drying_enabled: boolean;  // Auto-dry AMS between queued prints
   queue_drying_enabled: boolean;  // Auto-dry AMS between queued prints
   queue_drying_block: boolean;  // Block queue until drying completes
   queue_drying_block: boolean;  // Block queue until drying completes
   ambient_drying_enabled: boolean;  // Auto-dry idle printers based on humidity regardless of queue
   ambient_drying_enabled: boolean;  // Auto-dry idle printers based on humidity regardless of queue
+  ambient_drying_sustained_minutes: number;  // Minutes humidity must stay above threshold before ambient auto-dry starts (0 = instant)
   print_drying_enabled: boolean;  // Continue drying while a print is running on capable hardware
   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
   drying_presets: string;  // JSON blob of drying presets per filament type
   ams_humidity_thresholds: string;  // JSON blob of per-filament humidity thresholds (#1605)
   ams_humidity_thresholds: string;  // JSON blob of per-filament humidity thresholds (#1605)

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

@@ -2406,6 +2406,9 @@ export default {
     queueDryingBlockDescription: 'Druckwarteschlange blockieren, bis die Trocknung abgeschlossen ist. Wenn aus, haben Drucke Vorrang.',
     queueDryingBlockDescription: 'Druckwarteschlange blockieren, bis die Trocknung abgeschlossen ist. Wenn aus, haben Drucke Vorrang.',
     ambientDryingEnabled: 'Umgebungstrocknung',
     ambientDryingEnabled: 'Umgebungstrocknung',
     ambientDryingEnabledDescription: 'Filament auf inaktiven Druckern automatisch trocknen, wenn die Luftfeuchtigkeit den Schwellenwert überschreitet — auch ohne Warteschlange.',
     ambientDryingEnabledDescription: 'Filament auf inaktiven Druckern automatisch trocknen, wenn die Luftfeuchtigkeit den Schwellenwert überschreitet — auch ohne Warteschlange.',
+    ambientDryingSustainedEnabled: 'Anhaltende Luftfeuchtigkeit erforderlich',
+    ambientDryingSustainedDescription: 'Warten, bis die Luftfeuchtigkeit für eine festgelegte Zeit über dem Schwellenwert geblieben ist, bevor eine Umgebungstrocknung startet — überbrückt den Anstieg durch das Öffnen des AMS-Deckels.',
+    ambientDryingSustainedMinutes: 'Wartezeit (Minuten)',
     printDryingEnabled: 'Trocknen während des Drucks',
     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.',
     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',
     dryingPresets: 'Trocknungsvoreinstellungen',

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

@@ -2426,6 +2426,9 @@ export default {
     queueDryingBlockDescription: 'Block the print queue until drying finishes. When off, prints take priority over drying.',
     queueDryingBlockDescription: 'Block the print queue until drying finishes. When off, prints take priority over drying.',
     ambientDryingEnabled: 'Ambient drying',
     ambientDryingEnabled: 'Ambient drying',
     ambientDryingEnabledDescription: 'Automatically dry filament on idle printers when humidity exceeds threshold, even without queued prints.',
     ambientDryingEnabledDescription: 'Automatically dry filament on idle printers when humidity exceeds threshold, even without queued prints.',
+    ambientDryingSustainedEnabled: 'Require sustained humidity',
+    ambientDryingSustainedDescription: 'Wait until humidity has stayed above the threshold for a set time before starting an ambient dry — rides out the spike from opening the AMS lid.',
+    ambientDryingSustainedMinutes: 'Sustained time (minutes)',
     printDryingEnabled: 'Continue drying while printing',
     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.',
     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',
     dryingPresets: 'Drying Presets',

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

@@ -2409,6 +2409,9 @@ export default {
     queueDryingBlockDescription: 'Bloquear la cola de impresión hasta que termine el secado. Cuando está desactivado, las impresiones tienen prioridad sobre el secado.',
     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',
     ambientDryingEnabled: 'Secado ambiental',
     ambientDryingEnabledDescription: 'Secar automáticamente el filamento en impresoras inactivas cuando la humedad supera el umbral, incluso sin impresiones en cola.',
     ambientDryingEnabledDescription: 'Secar automáticamente el filamento en impresoras inactivas cuando la humedad supera el umbral, incluso sin impresiones en cola.',
+    ambientDryingSustainedEnabled: 'Requerir humedad sostenida',
+    ambientDryingSustainedDescription: 'Esperar hasta que la humedad se haya mantenido por encima del umbral durante un tiempo definido antes de iniciar un secado ambiental — absorbe el pico al abrir la tapa del AMS.',
+    ambientDryingSustainedMinutes: 'Tiempo sostenido (minutos)',
     printDryingEnabled: 'Continuar secado durante la impresión',
     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.',
     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',
     dryingPresets: 'Preajustes de secado',

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

@@ -2362,6 +2362,9 @@ export default {
     queueDryingBlockDescription: 'Bloquer la file d\'attente jusqu\'à la fin du séchage. Désactivé, les impressions sont prioritaires.',
     queueDryingBlockDescription: 'Bloquer la file d\'attente jusqu\'à la fin du séchage. Désactivé, les impressions sont prioritaires.',
     ambientDryingEnabled: 'Séchage ambiant',
     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.',
     ambientDryingEnabledDescription: 'Sécher automatiquement le filament sur les imprimantes inactives lorsque l\'humidité dépasse le seuil, même sans impressions en file.',
+    ambientDryingSustainedEnabled: 'Exiger une humidité soutenue',
+    ambientDryingSustainedDescription: 'Attendre que l\'humidité soit restée au-dessus du seuil pendant une durée définie avant de démarrer un séchage ambiant — absorbe le pic dû à l\'ouverture du couvercle de l\'AMS.',
+    ambientDryingSustainedMinutes: 'Durée soutenue (minutes)',
     printDryingEnabled: 'Séchage pendant l\'impression',
     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.',
     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',
     dryingPresets: 'Préréglages de séchage',

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

@@ -2362,6 +2362,9 @@ export default {
     queueDryingBlockDescription: 'Blocca la coda di stampa fino al completamento dell\'asciugatura. Se disattivato, le stampe hanno priorità.',
     queueDryingBlockDescription: 'Blocca la coda di stampa fino al completamento dell\'asciugatura. Se disattivato, le stampe hanno priorità.',
     ambientDryingEnabled: 'Asciugatura ambientale',
     ambientDryingEnabled: 'Asciugatura ambientale',
     ambientDryingEnabledDescription: 'Asciuga automaticamente il filamento sulle stampanti inattive quando l\'umidità supera la soglia, anche senza stampe in coda.',
     ambientDryingEnabledDescription: 'Asciuga automaticamente il filamento sulle stampanti inattive quando l\'umidità supera la soglia, anche senza stampe in coda.',
+    ambientDryingSustainedEnabled: 'Richiedi umidità sostenuta',
+    ambientDryingSustainedDescription: 'Attendere che l\'umidità sia rimasta sopra la soglia per un periodo di tempo impostato prima di avviare un\'asciugatura ambientale — assorbe il picco causato dall\'apertura del coperchio dell\'AMS.',
+    ambientDryingSustainedMinutes: 'Tempo sostenuto (minuti)',
     printDryingEnabled: 'Asciugatura durante la stampa',
     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.',
     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',
     dryingPresets: 'Preset di asciugatura',

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

@@ -2405,6 +2405,9 @@ export default {
     queueDryingBlockDescription: '乾燥が完了するまで印刷キューをブロックします。オフの場合、印刷が優先されます。',
     queueDryingBlockDescription: '乾燥が完了するまで印刷キューをブロックします。オフの場合、印刷が優先されます。',
     ambientDryingEnabled: '常時乾燥',
     ambientDryingEnabled: '常時乾燥',
     ambientDryingEnabledDescription: 'キューに関係なく、アイドル状態のプリンターで湿度がしきい値を超えた場合に自動的にフィラメントを乾燥。',
     ambientDryingEnabledDescription: 'キューに関係なく、アイドル状態のプリンターで湿度がしきい値を超えた場合に自動的にフィラメントを乾燥。',
+    ambientDryingSustainedEnabled: '湿度の持続を要求',
+    ambientDryingSustainedDescription: '環境乾燥を開始する前に、湿度が設定した時間にわたってしきい値を超え続けるまで待機します — AMSの蓋を開けたときの急上昇をやり過ごします。',
+    ambientDryingSustainedMinutes: '持続時間(分)',
     printDryingEnabled: '印刷中も乾燥を継続',
     printDryingEnabled: '印刷中も乾燥を継続',
     printDryingEnabledDescription: '対応ハードウェア(H2D、H2C、H2S、P2S、H2D Pro、X2D、X1C、A2L、最新ファームウェア)で印刷中も自動乾燥を継続します。スプール保護のため、乾燥温度はアイドル時のプリセットより自動的に5°C低く制限されます。',
     printDryingEnabledDescription: '対応ハードウェア(H2D、H2C、H2S、P2S、H2D Pro、X2D、X1C、A2L、最新ファームウェア)で印刷中も自動乾燥を継続します。スプール保護のため、乾燥温度はアイドル時のプリセットより自動的に5°C低く制限されます。',
     dryingPresets: '乾燥プリセット',
     dryingPresets: '乾燥プリセット',

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

@@ -2283,6 +2283,9 @@ export default {
     queueDryingBlockDescription: '건조가 완료될 때까지 인쇄 대기열을 차단합니다. 끄면 인쇄가 건조보다 우선합니다.',
     queueDryingBlockDescription: '건조가 완료될 때까지 인쇄 대기열을 차단합니다. 끄면 인쇄가 건조보다 우선합니다.',
     ambientDryingEnabled: '주변 건조',
     ambientDryingEnabled: '주변 건조',
     ambientDryingEnabledDescription: '대기 중인 인쇄가 없어도 습도가 임계값을 초과하면 유휴 프린터에서 자동으로 필라멘트 건조',
     ambientDryingEnabledDescription: '대기 중인 인쇄가 없어도 습도가 임계값을 초과하면 유휴 프린터에서 자동으로 필라멘트 건조',
+    ambientDryingSustainedEnabled: '지속적인 습도 요구',
+    ambientDryingSustainedDescription: '환경 건조를 시작하기 전에 습도가 설정된 시간 동안 임계값을 초과한 상태를 유지할 때까지 대기합니다 — AMS 덮개를 열 때 발생하는 급증을 걸러냅니다.',
+    ambientDryingSustainedMinutes: '지속 시간 (분)',
     printDryingEnabled: '인쇄 중 건조 계속',
     printDryingEnabled: '인쇄 중 건조 계속',
     printDryingEnabledDescription: '지원되는 하드웨어(H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L 최신 펌웨어)에서 인쇄 중에도 자동 건조를 계속 실행합니다. 스풀 보호를 위해 건조 온도가 유휴 프리셋보다 자동으로 5°C 낮게 제한됩니다.',
     printDryingEnabledDescription: '지원되는 하드웨어(H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L 최신 펌웨어)에서 인쇄 중에도 자동 건조를 계속 실행합니다. 스풀 보호를 위해 건조 온도가 유휴 프리셋보다 자동으로 5°C 낮게 제한됩니다.',
     dryingPresets: '건조 프리셋',
     dryingPresets: '건조 프리셋',

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

@@ -2426,6 +2426,9 @@ export default {
     queueDryingBlockDescription: 'Blokkeer de afdrukwachtrij tot het drogen is voltooid. Wanneer uitgeschakeld hebben afdrukken voorrang op drogen.',
     queueDryingBlockDescription: 'Blokkeer de afdrukwachtrij tot het drogen is voltooid. Wanneer uitgeschakeld hebben afdrukken voorrang op drogen.',
     ambientDryingEnabled: 'Drogen zonder wachtrij',
     ambientDryingEnabled: 'Drogen zonder wachtrij',
     ambientDryingEnabledDescription: 'Droog filament automatisch op inactieve printers wanneer de luchtvochtigheid boven de drempel komt, ook zonder afdrukken in de wachtrij.',
     ambientDryingEnabledDescription: 'Droog filament automatisch op inactieve printers wanneer de luchtvochtigheid boven de drempel komt, ook zonder afdrukken in de wachtrij.',
+    ambientDryingSustainedEnabled: 'Aanhoudende luchtvochtigheid vereisen',
+    ambientDryingSustainedDescription: 'Wacht tot de luchtvochtigheid gedurende een ingestelde tijd boven de drempel is gebleven voordat drogen zonder wachtrij start — vangt zo de piek op door het openen van het AMS-deksel.',
+    ambientDryingSustainedMinutes: 'Aanhoudende tijd (minuten)',
     printDryingEnabled: 'Doorgaan met drogen tijdens afdrukken',
     printDryingEnabled: 'Doorgaan met drogen tijdens afdrukken',
     printDryingEnabledDescription: 'Sta toe dat automatisch drogen tijdens een afdruk blijft draaien op ondersteunde hardware (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L met recente firmware). De droogtemperatuur wordt automatisch begrensd op 5°C onder de inactieve preset om spoelen te beschermen.',
     printDryingEnabledDescription: 'Sta toe dat automatisch drogen tijdens een afdruk blijft draaien op ondersteunde hardware (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L met recente firmware). De droogtemperatuur wordt automatisch begrensd op 5°C onder de inactieve preset om spoelen te beschermen.',
     dryingPresets: 'Droogpresets',
     dryingPresets: 'Droogpresets',

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

@@ -2362,6 +2362,9 @@ export default {
     queueDryingBlockDescription: 'Bloquear a fila de impressão até a secagem terminar. Quando desativado, impressões têm prioridade.',
     queueDryingBlockDescription: 'Bloquear a fila de impressão até a secagem terminar. Quando desativado, impressões têm prioridade.',
     ambientDryingEnabled: 'Secagem ambiente',
     ambientDryingEnabled: 'Secagem ambiente',
     ambientDryingEnabledDescription: 'Secar automaticamente o filamento em impressoras ociosas quando a umidade exceder o limite, mesmo sem impressões na fila.',
     ambientDryingEnabledDescription: 'Secar automaticamente o filamento em impressoras ociosas quando a umidade exceder o limite, mesmo sem impressões na fila.',
+    ambientDryingSustainedEnabled: 'Exigir umidade sustentada',
+    ambientDryingSustainedDescription: 'Aguardar até que a umidade permaneça acima do limite por um tempo definido antes de iniciar uma secagem ambiente — absorve o pico causado ao abrir a tampa do AMS.',
+    ambientDryingSustainedMinutes: 'Tempo sustentado (minutos)',
     printDryingEnabled: 'Continuar secagem durante a impressão',
     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.',
     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',
     dryingPresets: 'Predefinições de secagem',

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

@@ -2286,6 +2286,9 @@ export default {
     queueDryingBlockDescription: "Блокировать очередь печати до завершения сушки. Если выключено, печать имеет приоритет.",
     queueDryingBlockDescription: "Блокировать очередь печати до завершения сушки. Если выключено, печать имеет приоритет.",
     ambientDryingEnabled: "Фоновая сушка",
     ambientDryingEnabled: "Фоновая сушка",
     ambientDryingEnabledDescription: "Автоматически сушить филамент на простаивающих принтерах при превышении порога влажности, даже если очередь пуста.",
     ambientDryingEnabledDescription: "Автоматически сушить филамент на простаивающих принтерах при превышении порога влажности, даже если очередь пуста.",
+    ambientDryingSustainedEnabled: "Требовать устойчивую влажность",
+    ambientDryingSustainedDescription: "Дождаться, пока влажность остаётся выше порога в течение заданного времени, прежде чем начать окружающую сушку — сглаживает всплеск от открытия крышки AMS.",
+    ambientDryingSustainedMinutes: "Время устойчивости (мин)",
     printDryingEnabled: "Продолжать сушку во время печати",
     printDryingEnabled: "Продолжать сушку во время печати",
     printDryingEnabledDescription: "Разрешить автоматической сушке продолжаться во время печати на поддерживаемом оборудовании (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L с актуальной прошивкой). Для защиты катушек температура автоматически ограничивается на 5 °C ниже пресета сушки в простое.",
     printDryingEnabledDescription: "Разрешить автоматической сушке продолжаться во время печати на поддерживаемом оборудовании (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L с актуальной прошивкой). Для защиты катушек температура автоматически ограничивается на 5 °C ниже пресета сушки в простое.",
     dryingPresets: "Пресеты сушки",
     dryingPresets: "Пресеты сушки",

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

@@ -2426,6 +2426,9 @@ export default {
     queueDryingBlockDescription: 'Blockera utskriftskön tills torkningen är klar. När avstängt prioriteras utskrifter över torkning.',
     queueDryingBlockDescription: 'Blockera utskriftskön tills torkningen är klar. När avstängt prioriteras utskrifter över torkning.',
     ambientDryingEnabled: 'Bakgrundstorkning',
     ambientDryingEnabled: 'Bakgrundstorkning',
     ambientDryingEnabledDescription: 'Torka filament automatiskt på inaktiva skrivare när luftfuktigheten överskrider tröskelvärdet, även utan köade utskrifter.',
     ambientDryingEnabledDescription: 'Torka filament automatiskt på inaktiva skrivare när luftfuktigheten överskrider tröskelvärdet, även utan köade utskrifter.',
+    ambientDryingSustainedEnabled: 'Kräv varaktigt hög luftfuktighet',
+    ambientDryingSustainedDescription: 'Vänta tills luftfuktigheten har legat över tröskelvärdet en viss tid innan bakgrundstorkning startar. Det undviker tillfälliga toppar när AMS-locket öppnas.',
+    ambientDryingSustainedMinutes: 'Varaktighet (minuter)',
     printDryingEnabled: 'Fortsätt torka under utskrift',
     printDryingEnabled: 'Fortsätt torka under utskrift',
     printDryingEnabledDescription: 'Tillåt auto-torkning att fortsätta köra under en utskrift på hårdvara som stödjer det (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L med senaste firmware). Torkningstemperaturen begränsas automatiskt till 5°C under idle-förinställningen för att skydda rullar.',
     printDryingEnabledDescription: 'Tillåt auto-torkning att fortsätta köra under en utskrift på hårdvara som stödjer det (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L med senaste firmware). Torkningstemperaturen begränsas automatiskt till 5°C under idle-förinställningen för att skydda rullar.',
     dryingPresets: 'Torkningsförinställningar',
     dryingPresets: 'Torkningsförinställningar',

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

@@ -2410,6 +2410,9 @@ export default {
     queueDryingBlockDescription: 'Kurutma bitene kadar baskı kuyruğunu engelle. Kapalıyken, baskılar kurutmadan önceliklidir.',
     queueDryingBlockDescription: 'Kurutma bitene kadar baskı kuyruğunu engelle. Kapalıyken, baskılar kurutmadan önceliklidir.',
     ambientDryingEnabled: 'Ortam kurutma',
     ambientDryingEnabled: 'Ortam kurutma',
     ambientDryingEnabledDescription: 'Kuyrukta baskı olmasa bile, nem eşiği aştığında boşta yazıcılarda filamenti otomatik olarak kurut.',
     ambientDryingEnabledDescription: 'Kuyrukta baskı olmasa bile, nem eşiği aştığında boşta yazıcılarda filamenti otomatik olarak kurut.',
+    ambientDryingSustainedEnabled: 'Sürekli nem gerektir',
+    ambientDryingSustainedDescription: 'Ortam kurutmasını başlatmadan önce nemin belirlenen bir süre boyunca eşiğin üzerinde kalmasını bekle — AMS kapağının açılmasından kaynaklanan ani yükselişi atlatır.',
+    ambientDryingSustainedMinutes: 'Sürekli süre (dakika)',
     printDryingEnabled: 'Baskı sırasında kurutmaya devam et',
     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.',
     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ı',
     dryingPresets: 'Kurutma Ön Ayarları',

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

@@ -2425,6 +2425,9 @@ export default {
     queueDryingBlockDescription: "Не запускати завдання з черги до завершення сушіння. Якщо вимкнено, друк має пріоритет над сушінням.",
     queueDryingBlockDescription: "Не запускати завдання з черги до завершення сушіння. Якщо вимкнено, друк має пріоритет над сушінням.",
     ambientDryingEnabled: "Фонове сушіння",
     ambientDryingEnabled: "Фонове сушіння",
     ambientDryingEnabledDescription: "Автоматичне сушіння філаменту на неактивних принтерах, коли вологість перевищує порогове значення, навіть без друку в черзі.",
     ambientDryingEnabledDescription: "Автоматичне сушіння філаменту на неактивних принтерах, коли вологість перевищує порогове значення, навіть без друку в черзі.",
+    ambientDryingSustainedEnabled: "Вимагати стійку вологість",
+    ambientDryingSustainedDescription: "Зачекати, поки вологість триматиметься вище порогу протягом заданого часу, перш ніж почати сушіння в навколишньому середовищі — згладжує сплеск від відкриття кришки AMS.",
+    ambientDryingSustainedMinutes: "Тривалість утримання (хв)",
     printDryingEnabled: "Продовжувати сушіння під час друку",
     printDryingEnabled: "Продовжувати сушіння під час друку",
     printDryingEnabledDescription: "Дозволити автоматичному сушінню тривати під час друку на підтримуваному обладнанні (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L з новими версіями прошивки). Для захисту котушок температура сушіння автоматично обмежується значенням на 5 °C нижчим за профіль для режиму простою.",
     printDryingEnabledDescription: "Дозволити автоматичному сушінню тривати під час друку на підтримуваному обладнанні (H2D, H2C, H2S, P2S, H2D Pro, X2D, X1C, A2L з новими версіями прошивки). Для захисту котушок температура сушіння автоматично обмежується значенням на 5 °C нижчим за профіль для режиму простою.",
     dryingPresets: "Профілі сушіння",
     dryingPresets: "Профілі сушіння",

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

@@ -2407,6 +2407,9 @@ export default {
     queueDryingBlockDescription: '阻止打印队列直到干燥完成。关闭时,打印优先于干燥。',
     queueDryingBlockDescription: '阻止打印队列直到干燥完成。关闭时,打印优先于干燥。',
     ambientDryingEnabled: '环境干燥',
     ambientDryingEnabled: '环境干燥',
     ambientDryingEnabledDescription: '当空闲打印机的湿度超过阈值时自动干燥耗材,无需排队打印。',
     ambientDryingEnabledDescription: '当空闲打印机的湿度超过阈值时自动干燥耗材,无需排队打印。',
+    ambientDryingSustainedEnabled: '需要持续湿度',
+    ambientDryingSustainedDescription: '开始环境干燥前,等待湿度持续高于阈值达到设定时长——以缓冲打开AMS盖子造成的湿度骤升。',
+    ambientDryingSustainedMinutes: '持续时长(分钟)',
     printDryingEnabled: '打印时继续干燥',
     printDryingEnabled: '打印时继续干燥',
     printDryingEnabledDescription: '允许自动干燥在支持的硬件(H2D、H2C、H2S、P2S、H2D Pro、X2D、X1C、A2L 最新固件)打印过程中继续运行。为保护料盘,干燥温度会自动比空闲时预设低 5°C。',
     printDryingEnabledDescription: '允许自动干燥在支持的硬件(H2D、H2C、H2S、P2S、H2D Pro、X2D、X1C、A2L 最新固件)打印过程中继续运行。为保护料盘,干燥温度会自动比空闲时预设低 5°C。',
     dryingPresets: '干燥预设',
     dryingPresets: '干燥预设',

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

@@ -2407,6 +2407,9 @@ export default {
     queueDryingBlockDescription: '阻止列印佇列直到乾燥完成。關閉時,列印優先於乾燥。',
     queueDryingBlockDescription: '阻止列印佇列直到乾燥完成。關閉時,列印優先於乾燥。',
     ambientDryingEnabled: '環境乾燥',
     ambientDryingEnabled: '環境乾燥',
     ambientDryingEnabledDescription: '當空閒印表機的濕度超過閾值時自動乾燥耗材,無需佇列列印。',
     ambientDryingEnabledDescription: '當空閒印表機的濕度超過閾值時自動乾燥耗材,無需佇列列印。',
+    ambientDryingSustainedEnabled: '需要持續濕度',
+    ambientDryingSustainedDescription: '在開始環境乾燥前,等待濕度持續高於閾值達到設定時長——以緩衝開啟AMS蓋子造成的濕度驟升。',
+    ambientDryingSustainedMinutes: '持續時長(分鐘)',
     printDryingEnabled: '列印時繼續乾燥',
     printDryingEnabled: '列印時繼續乾燥',
     printDryingEnabledDescription: '允許自動乾燥在支援的硬體(H2D、H2C、H2S、P2S、H2D Pro、X2D、X1C、A2L 最新韌體)列印過程中繼續執行。為保護料盤,乾燥溫度會自動比閒置時的預設低 5°C。',
     printDryingEnabledDescription: '允許自動乾燥在支援的硬體(H2D、H2C、H2S、P2S、H2D Pro、X2D、X1C、A2L 最新韌體)列印過程中繼續執行。為保護料盤,乾燥溫度會自動比閒置時的預設低 5°C。',
     dryingPresets: '乾燥預設',
     dryingPresets: '乾燥預設',

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

@@ -219,6 +219,11 @@ export function SettingsPage() {
   // so intermediate values ("", "3", "5") are not eaten by the [5, 95] clamp
   // so intermediate values ("", "3", "5") are not eaten by the [5, 95] clamp
   // while the user is mid-typing.
   // while the user is mid-typing.
   const [humidityDrafts, setHumidityDrafts] = useState<Record<string, string>>({});
   const [humidityDrafts, setHumidityDrafts] = useState<Record<string, string>>({});
+  // Same transient-draft treatment for the ambient-drying sustained-minutes
+  // input: committed (clamped 5..240) on blur, so clearing the field or typing
+  // an intermediate value ("", "2" on the way to "25") is not rewritten by the
+  // clamp mid-keystroke. Empty/non-numeric on blur reverts to the saved value.
+  const [sustainedDraft, setSustainedDraft] = useState<string | null>(null);
   const [showPlugModal, setShowPlugModal] = useState(false);
   const [showPlugModal, setShowPlugModal] = useState(false);
   const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
   const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
   const [showHASensorModal, setShowHASensorModal] = useState(false);
   const [showHASensorModal, setShowHASensorModal] = useState(false);
@@ -1161,6 +1166,7 @@ export function SettingsPage() {
       (baseline.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
       (baseline.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
       (baseline.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
       (baseline.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
       (baseline.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
       (baseline.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
+      (baseline.ambient_drying_sustained_minutes ?? 0) !== (localSettings.ambient_drying_sustained_minutes ?? 0) ||
       (baseline.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) ||
       (baseline.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) ||
       (baseline.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
       (baseline.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
       (baseline.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
       (baseline.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
@@ -1276,6 +1282,7 @@ export function SettingsPage() {
         queue_drying_enabled: localSettings.queue_drying_enabled,
         queue_drying_enabled: localSettings.queue_drying_enabled,
         queue_drying_block: localSettings.queue_drying_block,
         queue_drying_block: localSettings.queue_drying_block,
         ambient_drying_enabled: localSettings.ambient_drying_enabled,
         ambient_drying_enabled: localSettings.ambient_drying_enabled,
+        ambient_drying_sustained_minutes: localSettings.ambient_drying_sustained_minutes,
         print_drying_enabled: localSettings.print_drying_enabled,
         print_drying_enabled: localSettings.print_drying_enabled,
         drying_presets: localSettings.drying_presets,
         drying_presets: localSettings.drying_presets,
         ams_humidity_thresholds: localSettings.ams_humidity_thresholds,
         ams_humidity_thresholds: localSettings.ams_humidity_thresholds,
@@ -5690,12 +5697,66 @@ export function SettingsPage() {
                   <input
                   <input
                     type="checkbox"
                     type="checkbox"
                     checked={localSettings.ambient_drying_enabled ?? false}
                     checked={localSettings.ambient_drying_enabled ?? false}
-                    onChange={(e) => updateSetting('ambient_drying_enabled', e.target.checked)}
+                    onChange={(e) => {
+                      setSustainedDraft(null);
+                      updateSetting('ambient_drying_enabled', e.target.checked);
+                    }}
                     className="sr-only peer"
                     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>
                   <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>
                 </label>
               </div>
               </div>
+              {localSettings.ambient_drying_enabled && (
+                <div className="flex items-center justify-between">
+                  <div>
+                    <label className="block text-sm text-white">
+                      {t('settings.ambientDryingSustainedEnabled')}
+                    </label>
+                    <p className="text-xs text-bambu-gray mt-0.5">
+                      {t('settings.ambientDryingSustainedDescription')}
+                    </p>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={(localSettings.ambient_drying_sustained_minutes ?? 0) > 0}
+                      onChange={(e) => {
+                        setSustainedDraft(null);
+                        updateSetting('ambient_drying_sustained_minutes', e.target.checked ? 15 : 0);
+                      }}
+                      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.ambient_drying_enabled && (localSettings.ambient_drying_sustained_minutes ?? 0) > 0 && (
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">
+                    {t('settings.ambientDryingSustainedMinutes')}
+                  </label>
+                  <input
+                    type="number"
+                    min="5"
+                    max="240"
+                    value={sustainedDraft ?? String(localSettings.ambient_drying_sustained_minutes ?? 15)}
+                    onChange={(e) => setSustainedDraft(e.target.value)}
+                    onBlur={(e) => {
+                      const parsed = parseInt(e.target.value, 10);
+                      if (!Number.isNaN(parsed)) {
+                        updateSetting('ambient_drying_sustained_minutes', Math.max(5, Math.min(240, parsed)));
+                      }
+                      setSustainedDraft(null);
+                    }}
+                    onKeyDown={(e) => {
+                      if (e.key === 'Enter') {
+                        (e.currentTarget as HTMLInputElement).blur();
+                      }
+                    }}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  />
+                </div>
+              )}
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div>
                 <div>
                   <label className="block text-sm text-white">
                   <label className="block text-sm text-white">

File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-Bxccllsw.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-CLiqfNPc.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/index-82a1Eqef.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-CaTYEs2g.js


+ 1 - 1
static/index.html

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

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