Kaynağa Gözat

Stop auto-drying re-arming into a threshold it can never reach (#2770)

An H2D armed five 12-hour drying cycles inside four hours, one of them six
seconds after the previous one ended, and none ran more than a couple of
hours.

Two things combine. The firmware ends a cycle when it decides the filament
is dry rather than when the clock runs out, and reports no fault doing it --
across this printer's history the run length tracks how wet the spools were,
from nearly the full 12 hours starting at 32% down to minutes once the unit
sat at 10-13%. That part is the AMS doing its job.

The loop is ours. An AMS reports higher relative humidity while it is warm
than once it has cooled: the same unit read 10-13% cold and 15-20% through
every cycle. With the threshold at 14% the reading at the moment a cycle
ended was always still above it, so the next 30-second pass armed another
12-hour cycle. Nothing counted, nothing waited, and it only stopped when the
box finally cooled enough to read 13%.

Auto-drying now waits 30 minutes after a cycle ends before arming another on
the same unit, and gives up on a unit after two consecutive cycles that
bring the reading no lower -- logging why and sending a new notification,
on by default because it reports that Bambuddy has stopped acting. Progress
is judged against the lowest reading any cycle on that unit has ended at,
not against the threshold, so a genuinely wet spool in a humid room coming
down 40-37-35 keeps drying however far it still is from the target;
comparing against the best so far rather than the previous end stops a
sensor wobbling by one point reading as progress every other cycle. The
suspension lifts by itself once the reading falls below the threshold.

Neither guard can stop a running cycle, and a cycle Bambuddy cut short for a
print, or that the user stopped by hand, is not counted against the unit --
so a farm that dries between queue jobs is unaffected. The threshold field
now warns below 20%, and every cycle end logs the unit's temperature and
humidity, which is what made this diagnosable.

The same bundle showed unrelated tasks failing with "database is locked",
each inside a 30.000-second Discord connect timeout. Alarms are raised from
inside the loop that records sensor history, at a point where the new rows
are added but not committed; the first read in the notification path flushed
them to satisfy itself, opening a write transaction, and the provider was
then contacted over the network with that transaction still open. SQLite
allows one writer and 30 seconds outlives the 15-second busy timeout, so
every other write in that window failed. The two reads that run before a
provider is contacted no longer flush the caller's pending work, and the
connect timeout is 5 seconds rather than 30 -- the body keeps the full 30,
so image uploads on a slow uplink are unaffected. SQLite only; Postgres has
no single-writer limit.
maziggy 4 hafta önce
ebeveyn
işleme
328bac450a
33 değiştirilmiş dosya ile 969 ekleme ve 13 silme
  1. 0 0
      CHANGELOG.md
  2. 2 0
      backend/app/api/routes/notifications.py
  3. 9 0
      backend/app/api/routes/printers.py
  4. 8 0
      backend/app/core/database.py
  5. 3 0
      backend/app/models/notification.py
  6. 10 0
      backend/app/models/notification_template.py
  7. 4 0
      backend/app/schemas/notification.py
  8. 19 0
      backend/app/schemas/notification_template.py
  9. 15 3
      backend/app/services/bambu_mqtt.py
  10. 76 6
      backend/app/services/notification_service.py
  11. 196 1
      backend/app/services/print_scheduler.py
  12. 19 1
      backend/tests/unit/services/test_bambu_mqtt.py
  13. 102 0
      backend/tests/unit/services/test_notification_write_lock.py
  14. 387 1
      backend/tests/unit/test_scheduler_auto_drying.py
  15. 32 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  16. 3 0
      frontend/src/api/client.ts
  17. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  18. 4 0
      frontend/src/i18n/locales/de.ts
  19. 4 0
      frontend/src/i18n/locales/en.ts
  20. 4 0
      frontend/src/i18n/locales/es.ts
  21. 4 0
      frontend/src/i18n/locales/fr.ts
  22. 4 0
      frontend/src/i18n/locales/it.ts
  23. 4 0
      frontend/src/i18n/locales/ja.ts
  24. 4 0
      frontend/src/i18n/locales/ko.ts
  25. 4 0
      frontend/src/i18n/locales/pt-BR.ts
  26. 4 0
      frontend/src/i18n/locales/ru.ts
  27. 4 0
      frontend/src/i18n/locales/tr.ts
  28. 4 0
      frontend/src/i18n/locales/uk.ts
  29. 4 0
      frontend/src/i18n/locales/zh-CN.ts
  30. 4 0
      frontend/src/i18n/locales/zh-TW.ts
  31. 17 0
      frontend/src/pages/SettingsPage.tsx
  32. 0 0
      static/assets/index-0YeqkzMt.js
  33. 1 1
      static/index.html

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
CHANGELOG.md


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

@@ -54,6 +54,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         # AMS environmental alarms (regular AMS)
         "on_ams_humidity_high": provider.on_ams_humidity_high,
         "on_ams_temperature_high": provider.on_ams_temperature_high,
+        "on_ams_drying_suspended": provider.on_ams_drying_suspended,
         # AMS-HT environmental alarms
         "on_ams_ht_humidity_high": provider.on_ams_ht_humidity_high,
         "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
@@ -137,6 +138,7 @@ async def create_notification_provider(
         # AMS environmental alarms (regular AMS)
         on_ams_humidity_high=provider_data.on_ams_humidity_high,
         on_ams_temperature_high=provider_data.on_ams_temperature_high,
+        on_ams_drying_suspended=provider_data.on_ams_drying_suspended,
         # AMS-HT environmental alarms
         on_ams_ht_humidity_high=provider_data.on_ams_ht_humidity_high,
         on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,

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

@@ -2038,6 +2038,15 @@ async def stop_drying(
     success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
     if not success:
         raise HTTPException(400, "Printer not connected")
+
+    # A cycle the user stopped by hand tells us nothing about whether drying can
+    # move the humidity reading, so it must not count towards the auto-drying
+    # suspension (#2770). Imported here rather than at module scope to keep the
+    # existing routes/scheduler import direction.
+    from backend.app.services.print_scheduler import scheduler as print_scheduler
+
+    print_scheduler.forget_auto_dry_cycle(printer_id, ams_id)
+
     return {"status": "drying_stopped", "ams_id": ams_id}
 
 

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

@@ -4359,6 +4359,14 @@ async def run_migrations(conn):
     # is the one that actually applies on both.
     await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_ha_sensor_alert BOOLEAN DEFAULT FALSE")
 
+    # Migration: auto-drying-suspended notification opt-in (#2770). Defaults ON:
+    # it fires at most once per AMS unit, and only to say Bambuddy has STOPPED
+    # doing something it was doing before — silence there reads as "still
+    # drying" and is exactly how the reporter lost two days to a re-arm loop.
+    await _safe_execute(
+        conn, "ALTER TABLE notification_providers ADD COLUMN on_ams_drying_suspended BOOLEAN DEFAULT TRUE"
+    )
+
 
 async def _migrate_backfill_variant_groups(conn) -> None:
     """Build variant groups from the slice provenance already on disk (#671 / #2570).

+ 3 - 0
backend/app/models/notification.py

@@ -78,6 +78,9 @@ class NotificationProvider(Base):
     # Event triggers - AMS environmental alarms (regular AMS with 4 slots)
     on_ams_humidity_high = Column(Boolean, default=False)  # AMS humidity above threshold
     on_ams_temperature_high = Column(Boolean, default=False)  # AMS temperature above threshold
+    # Auto-drying gave up on a unit (#2770). Defaults True: it reports that
+    # Bambuddy has stopped acting, which nothing else in the UI would say.
+    on_ams_drying_suspended = Column(Boolean, default=True)
 
     # Event triggers - AMS-HT environmental alarms (single slot heated AMS)
     on_ams_ht_humidity_high = Column(Boolean, default=False)  # AMS-HT humidity above threshold

+ 10 - 0
backend/app/models/notification_template.py

@@ -121,6 +121,16 @@ DEFAULT_TEMPLATES = [
         "title_template": "AMS Temperature Alert",
         "body_template": "{printer} {ams_label}: Temperature {temperature}°C exceeds {threshold}°C threshold",
     },
+    {
+        "event_type": "ams_drying_suspended",
+        "name": "Auto-Drying Suspended",
+        "title_template": "Auto-Drying Suspended",
+        "body_template": (
+            "{printer} {ams_label}: stopped automatic drying after {cycles} cycles left humidity at "
+            "{humidity}%, still above the {threshold}% threshold. An AMS reads higher while it is warm, "
+            "so raise the threshold or dry the spools off the printer."
+        ),
+    },
     {
         "event_type": "bed_cooled",
         "name": "Bed Cooled",

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

@@ -55,6 +55,9 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - AMS environmental alarms (regular AMS)
     on_ams_humidity_high: bool = Field(default=False, description="Notify when AMS humidity exceeds threshold")
     on_ams_temperature_high: bool = Field(default=False, description="Notify when AMS temperature exceeds threshold")
+    on_ams_drying_suspended: bool = Field(
+        default=True, description="Notify when automatic drying gives up on an AMS unit"
+    )
 
     # Event triggers - AMS-HT environmental alarms
     on_ams_ht_humidity_high: bool = Field(default=False, description="Notify when AMS-HT humidity exceeds threshold")
@@ -150,6 +153,7 @@ class NotificationProviderUpdate(BaseModel):
     # Event triggers - AMS environmental alarms (regular AMS)
     on_ams_humidity_high: bool | None = None
     on_ams_temperature_high: bool | None = None
+    on_ams_drying_suspended: bool | None = None
 
     # Event triggers - AMS-HT environmental alarms
     on_ams_ht_humidity_high: bool | None = None

+ 19 - 0
backend/app/schemas/notification_template.py

@@ -23,6 +23,7 @@ class EventType(StrEnum):
     MAINTENANCE_DUE = "maintenance_due"
     AMS_HUMIDITY_HIGH = "ams_humidity_high"
     AMS_TEMPERATURE_HIGH = "ams_temperature_high"
+    AMS_DRYING_SUSPENDED = "ams_drying_suspended"
     BED_COOLED = "bed_cooled"
     HA_SENSOR_ALERT = "ha_sensor_alert"
     TEST = "test"
@@ -79,6 +80,15 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "maintenance_due": ["printer", "items", "timestamp", "app_name"],
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
+    "ams_drying_suspended": [
+        "printer",
+        "ams_label",
+        "humidity",
+        "threshold",
+        "cycles",
+        "timestamp",
+        "app_name",
+    ],
     "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
     "ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
@@ -209,6 +219,15 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
+    "ams_drying_suspended": {
+        "printer": "Bambu X1C",
+        "ams_label": "AMS-A",
+        "humidity": "16",
+        "threshold": "14",
+        "cycles": "2",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "bed_cooled": {
         "printer": "Bambu X1C",
         "bed_temp": "34",

+ 15 - 3
backend/app/services/bambu_mqtt.py

@@ -2917,23 +2917,34 @@ class BambuMQTTClient:
         that moment. Logging them at INFO puts them in every support bundle by
         default, which is what a report like #2770 needs before its cause can be
         argued about at all.
+
+        The unit's ``temp`` and ``humidity_raw`` at the moment of the end are
+        logged for every cycle, early or not, because they are what decides
+        whether auto-drying re-arms. Reconstructing them for #2770 meant
+        cross-referencing hourly alarm lines against 30-second scheduler debug
+        that was switched off at the time; one line here says it outright — a
+        cycle ending at 63 degC with the reading still above the threshold is
+        the whole shape of the re-arm loop.
         """
+        box = f"temp={ams_unit.get('temp')} humidity={ams_unit.get('humidity_raw', ams_unit.get('humidity'))}"
         if ams_id in self._drying_stops_sent:
             self._drying_stops_sent.discard(ams_id)
             logger.info(
-                "[%s] AMS %d drying stopped by Bambuddy (dry_time %d → 0)",
+                "[%s] AMS %d drying stopped by Bambuddy (dry_time %d → 0, %s)",
                 self.serial_number,
                 ams_id,
                 remaining,
+                box,
             )
             return
 
         if remaining <= _EARLY_DRY_END_MINUTES:
             logger.info(
-                "[%s] AMS %d drying complete (dry_time %d → 0)",
+                "[%s] AMS %d drying complete (dry_time %d → 0, %s)",
                 self.serial_number,
                 ams_id,
                 remaining,
+                box,
             )
             return
 
@@ -2947,7 +2958,7 @@ class BambuMQTTClient:
         logger.info(
             "[%s] AMS %d drying ended early — %d of %s minutes still on the clock. "
             "Bambuddy sent no stop command, so the firmware ended this cycle: "
-            "dry_status=%s dry_sub_status=%s dry_sf_reason=%s hms=%s",
+            "dry_status=%s dry_sub_status=%s dry_sf_reason=%s hms=%s %s",
             self.serial_number,
             ams_id,
             remaining,
@@ -2956,6 +2967,7 @@ class BambuMQTTClient:
             ams_unit.get("dry_sub_status"),
             ams_unit.get("dry_sf_reason") or [],
             [e.full_code for e in self.state.hms_errors] or "none",
+            box,
         )
 
     def register_assignment_verification(

+ 76 - 6
backend/app/services/notification_service.py

@@ -123,10 +123,20 @@ class NotificationService:
         self._last_digest_check: str = ""  # "HH:MM" to avoid duplicate checks
 
     async def _get_client(self) -> httpx.AsyncClient:
-        """Get or create HTTP client."""
+        """Get or create HTTP client.
+
+        The connect timeout is deliberately far shorter than the rest. A flat
+        30 s meant that when a site's internet went down, every alarm spent a
+        full 30 s inside ``connect`` — longer than SQLite's 15 s
+        ``busy_timeout`` — and any other task that wanted to write during that
+        window failed with "database is locked" (#2770). Reaching a host either
+        works in a couple of seconds or is not going to; sending the body is the
+        part that legitimately takes time, so read/write keep the old 30 s and
+        an image upload on a slow uplink is unaffected.
+        """
         if self._http_client is None or self._http_client.is_closed:
             self._http_client = httpx.AsyncClient(
-                timeout=30.0,
+                timeout=httpx.Timeout(30.0, connect=5.0),
                 headers={"User-Agent": _USER_AGENT},
             )
         return self._http_client
@@ -166,12 +176,18 @@ class NotificationService:
             return False
 
     async def _get_template(self, db: AsyncSession, event_type: str) -> NotificationTemplate | None:
-        """Get a notification template by event type."""
+        """Get a notification template by event type.
+
+        ``no_autoflush`` for the same reason as ``_get_providers_for_event``:
+        this read runs before the provider is contacted, and must not be the
+        thing that opens a write transaction on the caller's session (#2770).
+        """
         # Check cache first
         if event_type in self._template_cache:
             return self._template_cache[event_type]
 
-        result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.event_type == event_type))
+        with db.no_autoflush:
+            result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.event_type == event_type))
         template = result.scalar_one_or_none()
 
         if template:
@@ -971,7 +987,19 @@ class NotificationService:
         event_field: str,
         printer_id: int | None = None,
     ) -> list[NotificationProvider]:
-        """Get all enabled providers that want a specific event type."""
+        """Get all enabled providers that want a specific event type.
+
+        Runs under ``no_autoflush`` (#2770). Callers routinely hold pending
+        writes when they raise an event — the AMS sensor loop does
+        ``db.add(history)`` and only commits after the alarms have gone out — and
+        without this, autoflush satisfies this SELECT by writing those rows,
+        which opens a write transaction on SQLite. The provider is then contacted
+        over the network with that transaction still open, so a site whose
+        internet is down holds the single SQLite writer for the whole connect
+        timeout and unrelated background tasks fail with "database is locked".
+        Deferring the flush costs nothing here: providers are committed rows, so
+        a pending change in the caller's session cannot be one this query wants.
+        """
         # Build the query dynamically based on event field
         query = select(NotificationProvider).where(
             NotificationProvider.enabled.is_(True),
@@ -983,7 +1011,8 @@ class NotificationService:
                 (NotificationProvider.printer_id.is_(None)) | (NotificationProvider.printer_id == printer_id)
             )
 
-        result = await db.execute(query)
+        with db.no_autoflush:
+            result = await db.execute(query)
         return list(result.scalars().all())
 
     async def _log_notification(
@@ -1664,6 +1693,47 @@ class NotificationService:
             variables=variables,
         )
 
+    async def on_ams_drying_suspended(
+        self,
+        printer_id: int,
+        printer_name: str,
+        ams_label: str,
+        humidity: float,
+        threshold: float,
+        cycles: int,
+        db: AsyncSession,
+    ):
+        """Handle automatic drying giving up on one AMS unit (#2770).
+
+        Sent immediately rather than folded into a digest: it reports that
+        Bambuddy has STOPPED doing something, and a report of inaction that
+        arrives with tomorrow's summary has already cost the user a day.
+        """
+        providers = await self._get_providers_for_event(db, "on_ams_drying_suspended", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "ams_label": ams_label,
+            "humidity": f"{humidity:.0f}",
+            "threshold": f"{threshold:.0f}",
+            "cycles": str(cycles),
+        }
+
+        title, message = await self._build_message_from_template(db, "ams_drying_suspended", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "ams_drying_suspended",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
+        )
+
     async def on_ams_ht_humidity_high(
         self,
         printer_id: int,

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

@@ -69,6 +69,24 @@ logger = logging.getLogger(__name__)
 _DISPATCH_PROGRESS_BYTE_STEP = 256 * 1024
 _DISPATCH_PROGRESS_MIN_INTERVAL_SECS = 0.2
 
+# Auto-drying re-arm guards (#2770).
+#
+# An AMS reports HIGHER relative humidity while it is warm than once it has
+# cooled: measured on an H2D/AMS 2 Pro at 10-13% cold against 15-20% throughout
+# every drying cycle, and the same unit read 16-20% across a full 12 h dry. A
+# threshold set inside that band therefore cannot be satisfied while the box is
+# hot, and the firmware is free to end a cycle whenever it decides the filament
+# is dry — so the next 30 s pass sees dry_time 0 with the reading still above
+# the threshold and arms another cycle. The reporter's log has five 12-hour
+# cycles armed inside four hours, one of them six seconds after the previous
+# ended.
+#
+# The cooldown stops the six-second re-arm; the unproductive-cycle cap stops the
+# loop. Neither ever stops a running cycle — both only gate STARTING one, so a
+# manual or firmware-run dry is untouched.
+AUTO_DRY_REARM_COOLDOWN_SECONDS = 30 * 60
+AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES = 2
+
 
 class _UploadProgressBridge:
     """Thread-safe bridge from ``upload_file_async`` to the WS broadcaster.
@@ -493,6 +511,16 @@ class PrintScheduler:
         self._wake_failure_cooloff = 600  # seconds
         # Track which printers are currently auto-drying (printer_id -> start timestamp)
         self._drying_in_progress: dict[int, float] = {}
+        # Per-AMS memory of the auto-drying cycles WE armed, keyed by
+        # (printer_id, ams_id) (#2770). Entries only exist between arming a
+        # cycle and the humidity finally coming down, so the normal steady
+        # state is an empty dict. Fields:
+        #   running      — a cycle we armed is (or should be) on the firmware
+        #   ended_at     — monotonic when we observed that cycle end
+        #   unproductive — consecutive armed cycles that ended with the reading
+        #                  still above the threshold
+        #   suspended    — we have stopped arming this unit and said so
+        self._auto_dry_units: dict[tuple[int, int], dict[str, object]] = {}
         # Defensive in-memory dispatch hold (#1157): a printer that just received
         # a project_file command must not get a second dispatch until either it
         # transitions out of pre_state OR the hard timeout expires. The H2D Pro
@@ -3162,6 +3190,9 @@ class PrintScheduler:
                             humidity = int(h_idx)
                         except (ValueError, TypeError):
                             pass
+                unit_key = (pid, ams_id)
+                unit_state = self._auto_dry_units.get(unit_key)
+
                 # Already drying — let it run to its configured duration (#1892).
                 #
                 # We deliberately do NOT stop drying from a humidity re-check here.
@@ -3178,6 +3209,8 @@ class PrintScheduler:
                         # Drying we didn't start (manual or from before restart) —
                         # track it so scheduling stops still apply; never auto-stop it.
                         self._drying_in_progress[pid] = time.monotonic()
+                    if unit_state is not None:
+                        unit_state["running"] = True
                     logger.debug(
                         "Auto-drying: printer %d AMS %d — drying (%dm left, humidity %s%%), letting it run",
                         pid,
@@ -3187,8 +3220,61 @@ class PrintScheduler:
                     )
                     continue
 
-                # Humidity below threshold — no need to start drying
+                # Nothing is drying. Close out a cycle we armed ourselves and
+                # judge whether it achieved anything (#2770).
+                #
+                # "Achieved anything" is measured against the LOWEST reading any
+                # cycle on this unit has ended at, not against the threshold and
+                # not against the previous cycle. A spool that is genuinely wet
+                # in a humid room comes down slowly — 40, 37, 35 — and must be
+                # allowed to keep going for as long as it is still coming down,
+                # however far it still is from the threshold. What must not
+                # continue is a cycle that ends exactly where the last one did,
+                # which is the reporter's signature: 15, 15, 16, 15, forever.
+                # Comparing against the running minimum rather than the previous
+                # end is what stops a sensor oscillating by one point between two
+                # values from reading as progress every other cycle.
+                if unit_state is not None and unit_state.pop("running", False):
+                    unit_state["ended_at"] = time.monotonic()
+                    if humidity is not None and humidity > humidity_threshold:
+                        best = unit_state.get("best_end_humidity")
+                        if isinstance(best, int) and humidity < best:
+                            # Still coming down. Keep going, however far the
+                            # threshold still is.
+                            unproductive = 0
+                        else:
+                            unproductive = int(unit_state.get("unproductive", 0)) + 1
+                        if not isinstance(best, int) or humidity < best:
+                            unit_state["best_end_humidity"] = humidity
+                        unit_state["unproductive"] = unproductive
+                        logger.info(
+                            "Auto-drying: printer %d AMS %d — cycle ended with humidity still %d%% > "
+                            "threshold %d%% (best so far %s%%, %d unproductive in a row)",
+                            pid,
+                            ams_id,
+                            humidity,
+                            humidity_threshold,
+                            unit_state.get("best_end_humidity"),
+                            unproductive,
+                        )
+                    # A cycle that ended at or below the threshold needs no
+                    # counter reset here: the branch below drops the whole entry.
+
+                # Humidity below threshold — no need to start drying. This is
+                # also the only thing that lifts a suspension: the reading we
+                # gave up on has come down, so auto-drying works again and the
+                # unit goes back to having no history at all.
                 if humidity is None or humidity <= humidity_threshold:
+                    if unit_state is not None and unit_state.get("suspended"):
+                        logger.info(
+                            "Auto-drying: printer %d AMS %d — humidity %s%% is back at or below the %d%% "
+                            "threshold, resuming automatic drying",
+                            pid,
+                            ams_id,
+                            humidity,
+                            humidity_threshold,
+                        )
+                    self._auto_dry_units.pop(unit_key, None)
                     logger.debug(
                         "Auto-drying: printer %d AMS %d skipped — humidity %s <= threshold %d",
                         pid,
@@ -3198,6 +3284,47 @@ class PrintScheduler:
                     )
                     continue
 
+                if unit_state is not None:
+                    if unit_state.get("suspended"):
+                        logger.debug(
+                            "Auto-drying: printer %d AMS %d skipped — suspended, %d cycles left humidity "
+                            "above the %d%% threshold",
+                            pid,
+                            ams_id,
+                            int(unit_state.get("unproductive", 0)),
+                            humidity_threshold,
+                        )
+                        continue
+
+                    if int(unit_state.get("unproductive", 0)) >= AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES:
+                        unit_state["suspended"] = True
+                        logger.warning(
+                            "Auto-drying: printer %d AMS %d — suspending automatic drying. %d cycles in a "
+                            "row ended with humidity at %d%%, still above the %d%% threshold. The AMS reads "
+                            "higher while it is warm, so a threshold in that range can never be reached and "
+                            "re-arming would loop. Raise the threshold or dry the spools off the printer.",
+                            pid,
+                            ams_id,
+                            int(unit_state.get("unproductive", 0)),
+                            humidity,
+                            humidity_threshold,
+                        )
+                        await self._notify_auto_drying_suspended(
+                            db, printer, ams_id, humidity, humidity_threshold, int(unit_state.get("unproductive", 0))
+                        )
+                        continue
+
+                    ended_at = unit_state.get("ended_at")
+                    if isinstance(ended_at, float) and time.monotonic() - ended_at < AUTO_DRY_REARM_COOLDOWN_SECONDS:
+                        logger.debug(
+                            "Auto-drying: printer %d AMS %d skipped — cooling off for %ds after the last "
+                            "cycle before the humidity reading is worth acting on",
+                            pid,
+                            ams_id,
+                            AUTO_DRY_REARM_COOLDOWN_SECONDS,
+                        )
+                        continue
+
                 # Check cannot-dry reasons (power constraints etc.)
                 sf_reasons = ams_data.get("dry_sf_reason", [])
                 if sf_reasons:
@@ -3244,6 +3371,67 @@ class PrintScheduler:
                 )
                 if success:
                     self._drying_in_progress[pid] = time.monotonic()
+                    armed = self._auto_dry_units.setdefault(
+                        unit_key, {"unproductive": 0, "suspended": False, "ended_at": None}
+                    )
+                    armed["running"] = True
+
+    async def _notify_auto_drying_suspended(
+        self,
+        db: AsyncSession,
+        printer: Printer,
+        ams_id: int,
+        humidity: int,
+        threshold: int,
+        cycles: int,
+    ) -> None:
+        """Tell the user auto-drying has given up on one AMS unit (#2770).
+
+        Fires once per suspension — the caller sets ``suspended`` before calling
+        and every later pass short-circuits on it — because the whole point is
+        that Bambuddy has stopped acting. Somebody whose printer sits in another
+        building needs that to reach them, and the hourly humidity alarm they
+        are already getting says the opposite of what happened here.
+
+        Never raises: a notification provider being down must not stop the
+        suspension itself from taking effect.
+        """
+        ams_label = f"HT-{chr(65 + (ams_id - 128))}" if ams_id >= 128 else f"AMS-{chr(65 + ams_id)}"
+        try:
+            await notification_service.on_ams_drying_suspended(
+                printer.id,
+                printer.name,
+                ams_label,
+                float(humidity),
+                float(threshold),
+                cycles,
+                db,
+            )
+        except Exception as e:
+            logger.warning("Failed to send auto-drying suspended notification: %s", e)
+
+    def forget_auto_dry_cycle(self, printer_id: int, ams_id: int) -> None:
+        """Stop judging the drying cycle currently on this AMS unit (#2770).
+
+        The unproductive-cycle counter exists to notice that *drying* is not
+        moving the humidity reading. A cycle that ended because somebody sent a
+        stop says nothing about that — it was cut short before it had a chance —
+        so counting it would suspend auto-drying for a reason that has nothing to
+        do with the loop the counter is there to break.
+
+        Two callers, both of them a stop Bambuddy is responsible for: the
+        print-takes-priority stop below, and the manual Stop button. Left
+        uncalled, an install that dries between queue jobs would suspend its own
+        auto-drying after two prints interrupted a dry — exactly the install
+        queue-drying exists for.
+
+        The rest of the unit's history is kept: the cooldown before re-arming
+        still applies, and an earlier count still stands.
+        """
+        state = self._auto_dry_units.get((printer_id, ams_id))
+        if state is not None:
+            state.pop("running", None)
+            state["ended_at"] = time.monotonic()
 
     def _sync_drying_state(self):
         """Drop printers from ``_drying_in_progress`` that are no longer drying.
@@ -3273,6 +3461,12 @@ class PrintScheduler:
         for pid in to_remove:
             self._drying_in_progress.pop(pid, None)
 
+        # A printer that has gone away entirely takes its per-AMS auto-drying
+        # history with it (#2770), so a printer deleted and re-added does not
+        # 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]:
+            self._auto_dry_units.pop(key, 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)
@@ -3291,6 +3485,7 @@ class PrintScheduler:
                     ams_id,
                 )
                 printer_manager.send_drying_command(printer_id, ams_id, 0, 0, mode=0)
+                self.forget_auto_dry_cycle(printer_id, ams_id)
         self._drying_in_progress.pop(printer_id, None)
 
     async def _get_smart_plugs(self, db: AsyncSession, printer_id: int) -> list[SmartPlug]:

+ 19 - 1
backend/tests/unit/services/test_bambu_mqtt.py

@@ -6293,9 +6293,27 @@ class TestDryingCompleteCallback:
             mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
 
         message = "\n".join(r.getMessage() for r in caplog.records)
-        assert "drying complete (dry_time 1 → 0)" in message
+        assert "drying complete (dry_time 1 → 0" in message
         assert "ended early" not in message
 
+    def test_every_cycle_end_records_the_box_conditions(self, mqtt_client, caplog):
+        """Whether auto-drying re-arms is decided by the unit's temperature and
+        humidity at the moment the cycle ends, so both belong on the line that
+        reports the end — normal or early. Reconstructing them for #2770 meant
+        cross-referencing hourly alarm lines against scheduler debug that was
+        switched off at the time."""
+        mqtt_client._handle_ams_data(
+            {"ams": [{"id": "0", "dry_time": 1, "temp": "63.0", "humidity_raw": "16", "tray": []}]}
+        )
+        with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
+            mqtt_client._handle_ams_data(
+                {"ams": [{"id": "0", "dry_time": 0, "temp": "63.0", "humidity_raw": "16", "tray": []}]}
+            )
+
+        message = "\n".join(r.getMessage() for r in caplog.records)
+        assert "temp=63.0" in message
+        assert "humidity=16" in message
+
 
 class TestPrintRunningObservedCallback:
     """#1485 follow-up: on_print_running_observed fires the FIRST time we

+ 102 - 0
backend/tests/unit/services/test_notification_write_lock.py

@@ -0,0 +1,102 @@
+"""A notification must never be sent while holding the SQLite write lock (#2770).
+
+The reporter's bundle has two "database is locked" failures, and both sit inside
+a Discord connect timeout::
+
+    17:36:55  Sending humidity alarm ... 15.0% > 14.0%
+    17:37:12  WARNING  Printer sensor history recording failed: database is locked
+    17:37:25  ERROR    httpx.ConnectTimeout          <- exactly 30.000s later
+
+The mechanism is not contention from writing too much. The AMS sensor loop does
+``db.add(history)`` and only commits *after* the alarms have been dispatched, so
+the first SELECT inside the notification path used to autoflush that pending
+INSERT — opening a write transaction — and the provider was then contacted over
+the network with that transaction still open. SQLite allows one writer, and the
+30 s connect timeout comfortably outlived the 15 s ``busy_timeout``, so unrelated
+background tasks failed.
+
+These tests pin the two reads that run before the network call. They assert the
+caller's pending row is still unflushed afterwards, which is the same thing as
+"no write transaction was opened on its behalf" and holds on any dialect.
+"""
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.database import Base
+from backend.app.models.notification import NotificationProvider
+from backend.app.models.notification_template import NotificationTemplate
+from backend.app.services.notification_service import NotificationService
+
+
+@pytest.fixture
+async def session(tmp_path):
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'notify-lock.db'}")
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    maker = async_sessionmaker(engine, expire_on_commit=False)
+    async with maker() as s:
+        yield s
+    await engine.dispose()
+
+
+def _pending_row() -> NotificationProvider:
+    """A row the caller has added but not committed — the sensor loop's position."""
+    return NotificationProvider(name="pending", provider_type="discord", config="{}", enabled=False)
+
+
+@pytest.mark.asyncio
+async def test_provider_lookup_does_not_flush_the_callers_pending_writes(session):
+    service = NotificationService()
+    session.add(NotificationProvider(name="Discord", provider_type="discord", config="{}", enabled=True))
+    await session.commit()
+
+    pending = _pending_row()
+    session.add(pending)
+
+    providers = await service._get_providers_for_event(session, "on_ams_drying_suspended")
+
+    assert [p.name for p in providers] == ["Discord"]
+    assert pending in session.new, "the caller's pending INSERT was flushed, taking the SQLite write lock"
+
+
+@pytest.mark.asyncio
+async def test_template_lookup_does_not_flush_the_callers_pending_writes(session):
+    service = NotificationService()
+    session.add(
+        NotificationTemplate(
+            event_type="ams_drying_suspended",
+            name="Auto-Drying Suspended",
+            title_template="t",
+            body_template="b",
+            is_default=True,
+        )
+    )
+    await session.commit()
+
+    pending = _pending_row()
+    session.add(pending)
+
+    template = await service._get_template(session, "ams_drying_suspended")
+
+    assert template is not None
+    assert pending in session.new, "the caller's pending INSERT was flushed, taking the SQLite write lock"
+
+
+@pytest.mark.asyncio
+async def test_connect_timeout_stays_under_the_sqlite_busy_timeout():
+    """15 s is the ``busy_timeout`` set in database.py. A connect timeout at or
+    above it guarantees the "database is locked" failure whenever a site's
+    internet is down, whatever else is fixed."""
+    service = NotificationService()
+    client = await service._get_client()
+    try:
+        assert client.timeout.connect is not None
+        assert client.timeout.connect < 15.0
+        # The body still gets the generous budget — image uploads on a slow
+        # uplink must not start failing.
+        assert client.timeout.read == 30.0
+        assert client.timeout.write == 30.0
+    finally:
+        await service.close()

+ 387 - 1
backend/tests/unit/test_scheduler_auto_drying.py

@@ -13,7 +13,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 
-from backend.app.services.print_scheduler import PrintScheduler
+from backend.app.services.print_scheduler import (
+    AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
+    AUTO_DRY_REARM_COOLDOWN_SECONDS,
+    PrintScheduler,
+)
 
 
 class TestConservativeDryingParams:
@@ -1192,3 +1196,385 @@ class TestMidPrintDrying(_DryingTestBase):
         await scheduler._check_auto_drying(db, [], {1})
 
         mock_pm.send_drying_command.assert_not_called()
+
+
+class TestAutoDryRearmGuards(_DryingTestBase):
+    """The re-arm loop from #2770.
+
+    An AMS reads higher humidity while it is warm than once it has cooled, so a
+    threshold set inside that band is never satisfied at the moment a cycle
+    ends. The firmware is separately free to end a cycle early when it decides
+    the filament is dry. Together those produced five 12-hour cycles armed in
+    four hours on the reporter's H2D, one of them six seconds after the previous
+    ended. These tests pin the two guards that break the loop and, just as
+    importantly, that neither guard ever stops a cycle that is running.
+    """
+
+    THRESHOLD = "14"
+    ABOVE = 16
+    BELOW = 10
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @staticmethod
+    def _ams_state(dry_time, humidity):
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": dry_time,
+                    "humidity_raw": str(humidity),
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PLA"}],
+                }
+            ]
+        }
+        state.firmware_version = "01.09.00.00"
+        return state
+
+    def _db(self):
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=self._make_db_side_effect(
+                {
+                    "queue_drying_enabled": self._make_setting("false"),
+                    "ambient_drying_enabled": self._make_setting("true"),
+                    "ams_humidity_fair": self._make_setting(self.THRESHOLD),
+                    "queue_drying_block": self._make_setting("false"),
+                    "drying_presets": None,
+                }
+            )
+        )
+        return db
+
+    async def _pass(self, scheduler, mock_pm, db, dry_time, humidity):
+        """One 30-second scheduler pass with the AMS in the given state."""
+        mock_pm.get_status.return_value = self._ams_state(dry_time, humidity)
+        await scheduler._check_auto_drying(db, [], set())
+
+    async def _unproductive_cycle(self, scheduler, mock_pm, db):
+        """Arm a cycle, watch it run, then see it end with humidity still high."""
+        await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
+        await self._pass(scheduler, mock_pm, db, 720, self.ABOVE)
+        await self._pass(scheduler, mock_pm, db, 0, self.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_does_not_rearm_immediately_after_a_cycle_ends(self, mock_sd, mock_pm, scheduler):
+        """The six-second re-arm: one cycle ends, the next pass must not start another."""
+        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=True)
+        db = self._db()
+
+        await self._unproductive_cycle(scheduler, mock_pm, db)
+        await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
+
+        assert mock_pm.send_drying_command.call_count == 1
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.notification_service")
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
+    async def test_suspends_after_repeated_unproductive_cycles(self, mock_sd, mock_pm, mock_notify, scheduler):
+        """Past the cooldown the loop would resume, so the counter has to stop it."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "H2D"
+        mock_pm.send_drying_command.return_value = True
+        mock_notify.on_ams_drying_suspended = AsyncMock()
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = self._db()
+
+        for _ in range(AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES):
+            await self._unproductive_cycle(scheduler, mock_pm, db)
+            # Age the last cycle out of its cooldown so the next arm is allowed.
+            scheduler._auto_dry_units[(1, 0)]["ended_at"] -= AUTO_DRY_REARM_COOLDOWN_SECONDS + 1
+
+        assert mock_pm.send_drying_command.call_count == AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES
+
+        await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
+
+        assert mock_pm.send_drying_command.call_count == AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES
+        assert scheduler._auto_dry_units[(1, 0)]["suspended"] is True
+        mock_notify.on_ams_drying_suspended.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.notification_service")
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
+    async def test_suspension_notifies_once_and_stays_put(self, mock_sd, mock_pm, mock_notify, scheduler):
+        """A suspension is a state, not a repeating alarm."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "H2D"
+        mock_pm.send_drying_command.return_value = True
+        mock_notify.on_ams_drying_suspended = AsyncMock()
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = self._db()
+        scheduler._auto_dry_units[(1, 0)] = {
+            "unproductive": AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
+            "suspended": False,
+            "ended_at": None,
+        }
+
+        for _ in range(4):
+            await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
+
+        mock_pm.send_drying_command.assert_not_called()
+        assert mock_notify.on_ams_drying_suspended.await_count == 1
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.notification_service")
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
+    async def test_humidity_dropping_lifts_the_suspension(self, mock_sd, mock_pm, mock_notify, scheduler):
+        """The reading coming down is the evidence that drying works after all."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "H2D"
+        mock_pm.send_drying_command.return_value = True
+        mock_notify.on_ams_drying_suspended = AsyncMock()
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = self._db()
+        scheduler._auto_dry_units[(1, 0)] = {
+            "unproductive": AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
+            "suspended": True,
+            "ended_at": None,
+        }
+
+        await self._pass(scheduler, mock_pm, db, 0, self.BELOW)
+        assert (1, 0) not in scheduler._auto_dry_units
+
+        await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
+        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_manual_cycle_is_neither_counted_nor_interrupted(self, mock_sd, mock_pm, scheduler):
+        """A dry the user started by hand carries no history and is never stopped."""
+        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=True)
+        db = self._db()
+
+        await self._pass(scheduler, mock_pm, db, 720, self.ABOVE)
+        await self._pass(scheduler, mock_pm, db, 600, self.ABOVE)
+
+        mock_pm.send_drying_command.assert_not_called()
+        assert (1, 0) not in scheduler._auto_dry_units
+
+        # It ends; Bambuddy is free to arm its own cycle, with a clean slate.
+        await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
+        mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
+        assert scheduler._auto_dry_units[(1, 0)]["unproductive"] == 0
+
+    @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_history_is_dropped_when_the_printer_goes_away(self, mock_sd, mock_pm, scheduler):
+        """A printer deleted and re-added must not inherit a suspension."""
+        mock_pm.get_status.return_value = None
+        scheduler._auto_dry_units[(1, 0)] = {"unproductive": 5, "suspended": True, "ended_at": None}
+
+        scheduler._sync_drying_state()
+
+        assert scheduler._auto_dry_units == {}
+
+
+class TestAutoDryStoppedByBambuddy(_DryingTestBase):
+    """A cycle Bambuddy itself cut short must not count against the unit."""
+
+    THRESHOLD = "14"
+    ABOVE = 16
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @staticmethod
+    def _ams_state(dry_time, humidity):
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": dry_time,
+                    "humidity_raw": str(humidity),
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PLA"}],
+                }
+            ]
+        }
+        state.firmware_version = "01.09.00.00"
+        return state
+
+    def _db(self):
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=self._make_db_side_effect(
+                {
+                    "queue_drying_enabled": self._make_setting("false"),
+                    "ambient_drying_enabled": self._make_setting("true"),
+                    "ams_humidity_fair": self._make_setting(self.THRESHOLD),
+                    "queue_drying_block": self._make_setting("false"),
+                    "drying_presets": None,
+                }
+            )
+        )
+        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_print_takes_priority_stop_is_not_an_unproductive_cycle(self, mock_sd, mock_pm, scheduler):
+        """The queue stopping a dry so a print can start is Bambuddy's own doing.
+
+        Counting it would suspend auto-drying on any printer that dries between
+        jobs often enough -- exactly the install queue-drying exists for.
+        """
+        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=True)
+        db = self._db()
+
+        mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
+        await scheduler._check_auto_drying(db, [], set())
+        mock_pm.get_status.return_value = self._ams_state(720, self.ABOVE)
+        await scheduler._check_auto_drying(db, [], set())
+
+        # A print is ready: check_queue stops drying on this printer.
+        await scheduler._stop_drying(1)
+
+        mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
+        await scheduler._check_auto_drying(db, [], set())
+
+        assert scheduler._auto_dry_units[(1, 0)]["unproductive"] == 0
+
+    @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_stop_button_is_not_an_unproductive_cycle(self, mock_sd, mock_pm, scheduler):
+        """The Stop button goes straight to printer_manager, bypassing the
+        scheduler, so the route has to tell the scheduler to forget the cycle."""
+        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=True)
+        db = self._db()
+
+        mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
+        await scheduler._check_auto_drying(db, [], set())
+        mock_pm.get_status.return_value = self._ams_state(720, self.ABOVE)
+        await scheduler._check_auto_drying(db, [], set())
+
+        scheduler.forget_auto_dry_cycle(1, 0)
+
+        mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
+        await scheduler._check_auto_drying(db, [], set())
+
+        assert scheduler._auto_dry_units[(1, 0)]["unproductive"] == 0
+
+
+class TestAutoDryProgressKeepsItGoing(_DryingTestBase):
+    """Suspension must not punish a spool that is genuinely drying, just slowly."""
+
+    THRESHOLD = "25"
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @staticmethod
+    def _ams_state(dry_time, humidity):
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": dry_time,
+                    "humidity_raw": str(humidity),
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PLA"}],
+                }
+            ]
+        }
+        state.firmware_version = "01.09.00.00"
+        return state
+
+    def _db(self):
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=self._make_db_side_effect(
+                {
+                    "queue_drying_enabled": self._make_setting("false"),
+                    "ambient_drying_enabled": self._make_setting("true"),
+                    "ams_humidity_fair": self._make_setting(self.THRESHOLD),
+                    "queue_drying_block": self._make_setting("false"),
+                    "drying_presets": None,
+                }
+            )
+        )
+        return db
+
+    async def _cycle(self, scheduler, mock_pm, db, end_humidity):
+        """Arm a cycle, run it, and end it at the given reading."""
+        mock_pm.get_status.return_value = self._ams_state(0, end_humidity)
+        await scheduler._check_auto_drying(db, [], set())
+        mock_pm.get_status.return_value = self._ams_state(720, end_humidity)
+        await scheduler._check_auto_drying(db, [], set())
+        mock_pm.get_status.return_value = self._ams_state(0, end_humidity)
+        await scheduler._check_auto_drying(db, [], set())
+        entry = scheduler._auto_dry_units.get((1, 0))
+        if entry is not None and isinstance(entry.get("ended_at"), float):
+            entry["ended_at"] -= AUTO_DRY_REARM_COOLDOWN_SECONDS + 1
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.notification_service")
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
+    async def test_a_reading_that_keeps_falling_is_never_suspended(self, mock_sd, mock_pm, mock_notify, scheduler):
+        """40 -> 37 -> 35 -> 33 with a 25% threshold: nowhere near it yet, but
+        every cycle is working. A humid workshop must not lose auto-drying."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "H2D"
+        mock_pm.send_drying_command.return_value = True
+        mock_notify.on_ams_drying_suspended = AsyncMock()
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = self._db()
+
+        for reading in (40, 37, 35, 33, 31, 29):
+            await self._cycle(scheduler, mock_pm, db, reading)
+
+        assert scheduler._auto_dry_units[(1, 0)]["suspended"] is False
+        mock_notify.on_ams_drying_suspended.assert_not_awaited()
+        assert mock_pm.send_drying_command.call_count == 6
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.notification_service")
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
+    async def test_a_reading_oscillating_by_one_point_still_suspends(self, mock_sd, mock_pm, mock_notify, scheduler):
+        """A plateau with sensor noise — 31, 30, 31, 30 — is not progress.
+        Comparing against the previous cycle rather than the best-so-far would
+        read every other cycle as an improvement and loop indefinitely."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "H2D"
+        mock_pm.send_drying_command.return_value = True
+        mock_notify.on_ams_drying_suspended = AsyncMock()
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = self._db()
+
+        for reading in (31, 30, 31, 30, 31, 30):
+            await self._cycle(scheduler, mock_pm, db, reading)
+
+        assert scheduler._auto_dry_units[(1, 0)]["suspended"] is True
+        mock_notify.on_ams_drying_suspended.assert_awaited_once()

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

@@ -726,6 +726,38 @@ describe('SettingsPage', () => {
         expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
       });
     });
+
+    // #2770: an AMS reads 15-20% while its own heater runs, so a drying
+    // threshold set below that can never be met and auto-drying ends one
+    // cycle only to arm the next. The default of 60 must stay quiet.
+    const openFilamentTab = async (humidityFair: number) => {
+      server.use(
+        http.get('/api/v1/settings/', () =>
+          HttpResponse.json({ ...mockSettings, ams_humidity_fair: humidityFair })
+        )
+      );
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+      await waitFor(() => {
+        expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
+      });
+      await user.click(screen.getAllByText('Filament')[0]);
+      await waitFor(() => {
+        expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
+      });
+    };
+
+    it('warns when the humidity threshold is below what a drying AMS reports', async () => {
+      await openFilamentTab(14);
+
+      expect(await screen.findByText(/Auto-drying cannot reach this value/)).toBeInTheDocument();
+    });
+
+    it('stays quiet for a humidity threshold auto-drying can actually reach', async () => {
+      await openFilamentTab(60);
+
+      expect(screen.queryByText(/Auto-drying cannot reach this value/)).not.toBeInTheDocument();
+    });
   });
 
   describe('Workflow tab', () => {

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

@@ -2711,6 +2711,7 @@ export interface NotificationProvider {
   // AMS environmental alarms (regular AMS)
   on_ams_humidity_high: boolean;
   on_ams_temperature_high: boolean;
+  on_ams_drying_suspended: boolean;
   // AMS-HT environmental alarms
   on_ams_ht_humidity_high: boolean;
   on_ams_ht_temperature_high: boolean;
@@ -2773,6 +2774,7 @@ export interface NotificationProviderCreate {
   // AMS environmental alarms (regular AMS)
   on_ams_humidity_high?: boolean;
   on_ams_temperature_high?: boolean;
+  on_ams_drying_suspended?: boolean;
   // AMS-HT environmental alarms
   on_ams_ht_humidity_high?: boolean;
   on_ams_ht_temperature_high?: boolean;
@@ -2828,6 +2830,7 @@ export interface NotificationProviderUpdate {
   // AMS environmental alarms (regular AMS)
   on_ams_humidity_high?: boolean;
   on_ams_temperature_high?: boolean;
+  on_ams_drying_suspended?: boolean;
   // AMS-HT environmental alarms
   on_ams_ht_humidity_high?: boolean;
   on_ams_ht_temperature_high?: boolean;

+ 14 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -162,6 +162,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_ams_temperature_high && (
               <span className="px-2 py-0.5 bg-orange-100 dark:bg-orange-600/20 text-orange-700 dark:text-orange-300 text-xs rounded">{t('notifications.amsTemp')}</span>
             )}
+            {provider.on_ams_drying_suspended && (
+              <span className="px-2 py-0.5 bg-rose-100 dark:bg-rose-600/20 text-rose-700 dark:text-rose-300 text-xs rounded">{t('notifications.amsDryingSuspended')}</span>
+            )}
             {provider.on_ams_ht_humidity_high && (
               <span className="px-2 py-0.5 bg-cyan-100 dark:bg-cyan-600/20 text-cyan-700 dark:text-cyan-300 text-xs rounded">{t('notifications.amsHtHumidity')}</span>
             )}
@@ -467,6 +470,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                     onChange={(checked) => updateMutation.mutate({ on_ams_temperature_high: checked })}
                   />
                 </div>
+
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.amsDryingSuspendedTitle')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.amsDryingSuspendedDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_ams_drying_suspended ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_ams_drying_suspended: checked })}
+                  />
+                </div>
               </div>
 
               {/* AMS-HT Environmental Alarms */}

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

@@ -2227,6 +2227,7 @@ export default {
     fairOrange: 'Mittel (orange)',
     aboveFairBad: 'Über dem mittleren Schwellenwert wird rot angezeigt (schlecht)',
     fairAlsoDryingThreshold: 'Dieser Schwellenwert wird auch für die automatische Trocknung verwendet',
+    fairBelowDryingFloor: 'Die automatische Trocknung kann diesen Wert nicht erreichen: Ein AMS meldet bei laufender Heizung {{floor}} % oder mehr, sodass jeder Zyklus enden und sofort neu starten würde. Setzen Sie ihn über {{floor}} %, wenn die automatische Trocknung aktiv ist.',
     temperature: 'Temperatur',
     goodBlue: 'Gut (blau)',
     aboveFairHot: 'Über dem mittleren Schwellenwert wird rot angezeigt (heiß)',
@@ -5885,6 +5886,7 @@ export default {
     maintenance: 'Wartung',
     amsHumidity: 'AMS-Feuchtigkeit',
     amsTemp: 'AMS-Temperatur',
+    amsDryingSuspended: 'Trocknung ausgesetzt',
     amsHtHumidity: 'AMS-HT-Feuchtigkeit',
     amsHtTemp: 'AMS-HT-Temperatur',
     bedCooled: 'Bett abgekühlt',
@@ -5922,6 +5924,8 @@ export default {
     amsHumidityHighDescription: 'Normale AMS-Feuchtigkeit überschreitet Schwellenwert',
     amsTemperatureHigh: 'AMS-Temperatur hoch',
     amsTemperatureHighDescription: 'Normale AMS-Temperatur überschreitet Schwellenwert',
+    amsDryingSuspendedTitle: 'Automatische Trocknung ausgesetzt',
+    amsDryingSuspendedDescription: 'Automatische Trocknung wurde für eine AMS-Einheit aufgegeben, weil die Luftfeuchtigkeit nie unter den Schwellenwert fiel',
     amsHtHumidityHigh: 'AMS-HT-Feuchtigkeit hoch',
     amsHtHumidityHighDescription: 'AMS-HT-Feuchtigkeit überschreitet Schwellenwert',
     amsHtTemperatureHigh: 'AMS-HT-Temperatur hoch',

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

@@ -2246,6 +2246,7 @@ export default {
     fairOrange: 'Fair (orange)',
     aboveFairBad: 'Above fair threshold shows as red (bad)',
     fairAlsoDryingThreshold: 'This threshold is also used to trigger auto-drying when enabled',
+    fairBelowDryingFloor: 'Auto-drying cannot reach this value: an AMS reads {{floor}}% or more while its heater runs, so every cycle would end and restart immediately. Raise it above {{floor}}% if auto-drying is on.',
     temperature: 'Temperature',
     goodBlue: 'Good (blue)',
     aboveFairHot: 'Above fair threshold shows as red (hot)',
@@ -5934,6 +5935,7 @@ export default {
     maintenance: 'Maintenance',
     amsHumidity: 'AMS Humidity',
     amsTemp: 'AMS Temp',
+    amsDryingSuspended: 'Drying Suspended',
     amsHtHumidity: 'AMS-HT Humidity',
     amsHtTemp: 'AMS-HT Temp',
     bedCooled: 'Bed Cooled',
@@ -5971,6 +5973,8 @@ export default {
     amsHumidityHighDescription: 'Regular AMS humidity exceeds threshold',
     amsTemperatureHigh: 'AMS Temperature High',
     amsTemperatureHighDescription: 'Regular AMS temperature exceeds threshold',
+    amsDryingSuspendedTitle: 'Auto-Drying Suspended',
+    amsDryingSuspendedDescription: 'Automatic drying gave up on an AMS unit because humidity never fell below the threshold',
     amsHtHumidityHigh: 'AMS-HT Humidity High',
     amsHtHumidityHighDescription: 'AMS-HT humidity exceeds threshold',
     amsHtTemperatureHigh: 'AMS-HT Temperature High',

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

@@ -2230,6 +2230,7 @@ export default {
     fairOrange: 'Aceptable (naranja)',
     aboveFairBad: 'Por encima del umbral aceptable se muestra en rojo (mala)',
     fairAlsoDryingThreshold: 'Este umbral también se usa para activar el secado automático cuando está habilitado',
+    fairBelowDryingFloor: 'El secado automático no puede alcanzar este valor: un AMS indica {{floor}} % o más mientras calienta, por lo que cada ciclo terminaría y se reiniciaría al instante. Súbelo por encima del {{floor}} % si usas el secado automático.',
     temperature: 'Temperatura',
     goodBlue: 'Buena (azul)',
     aboveFairHot: 'Por encima del umbral aceptable se muestra en rojo (caliente)',
@@ -5893,6 +5894,7 @@ export default {
     maintenance: 'Mantenimiento',
     amsHumidity: 'Humedad del AMS',
     amsTemp: 'Temp. del AMS',
+    amsDryingSuspended: 'Secado suspendido',
     amsHtHumidity: 'Humedad del AMS-HT',
     amsHtTemp: 'Temp. del AMS-HT',
     bedCooled: 'Cama enfriada',
@@ -5930,6 +5932,8 @@ export default {
     amsHumidityHighDescription: 'La humedad del AMS normal supera el umbral',
     amsTemperatureHigh: 'Temperatura alta del AMS',
     amsTemperatureHighDescription: 'La temperatura del AMS normal supera el umbral',
+    amsDryingSuspendedTitle: 'Secado automático suspendido',
+    amsDryingSuspendedDescription: 'El secado automático se rindió con una unidad AMS porque la humedad nunca bajó del umbral',
     amsHtHumidityHigh: 'Humedad alta del AMS-HT',
     amsHtHumidityHighDescription: 'La humedad del AMS-HT supera el umbral',
     amsHtTemperatureHigh: 'Temperatura alta del AMS-HT',

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

@@ -2183,6 +2183,7 @@ export default {
     fairOrange: 'Moyen (orange)',
     aboveFairBad: 'Au-dessus = rouge (mauvais)',
     fairAlsoDryingThreshold: 'Ce seuil est aussi utilisé pour déclencher le séchage automatique',
+    fairBelowDryingFloor: 'Le séchage automatique ne peut pas atteindre cette valeur : un AMS indique {{floor}} % ou plus lorsqu\'il chauffe, donc chaque cycle se terminerait puis redémarrerait aussitôt. Passez au-dessus de {{floor}} % si le séchage automatique est activé.',
     temperature: 'Température',
     goodBlue: 'Bon (bleu)',
     aboveFairHot: 'Au-dessus = rouge (chaud)',
@@ -5875,6 +5876,7 @@ export default {
     maintenance: 'Maintenance',
     amsHumidity: 'Humidité AMS',
     amsTemp: 'Temp. AMS',
+    amsDryingSuspended: 'Séchage suspendu',
     amsHtHumidity: 'Humidité AMS-HT',
     amsHtTemp: 'Temp. AMS-HT',
     bedCooled: 'Plateau refroidi',
@@ -5912,6 +5914,8 @@ export default {
     amsHumidityHighDescription: 'L\'humidité de l\'AMS standard dépasse le seuil',
     amsTemperatureHigh: 'Température AMS élevée',
     amsTemperatureHighDescription: 'La température de l\'AMS standard dépasse le seuil',
+    amsDryingSuspendedTitle: 'Séchage automatique suspendu',
+    amsDryingSuspendedDescription: 'Le séchage automatique a abandonné une unité AMS car l\'humidité n\'est jamais descendue sous le seuil',
     amsHtHumidityHigh: 'Humidité AMS-HT élevée',
     amsHtHumidityHighDescription: 'L\'humidité de l\'AMS-HT dépasse le seuil',
     amsHtTemperatureHigh: 'Température AMS-HT élevée',

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

@@ -2183,6 +2183,7 @@ export default {
     fairOrange: 'Discreto (arancione)',
     aboveFairBad: 'Sopra soglia discreta mostra rosso (scarso)',
     fairAlsoDryingThreshold: 'Questa soglia viene usata anche per attivare l\'asciugatura automatica',
+    fairBelowDryingFloor: 'L\'asciugatura automatica non può raggiungere questo valore: un AMS segna {{floor}}% o più mentre riscalda, quindi ogni ciclo finirebbe e ripartirebbe subito. Portalo sopra il {{floor}}% se usi l\'asciugatura automatica.',
     temperature: 'Temperatura',
     goodBlue: 'Buono (blu)',
     aboveFairHot: 'Sopra soglia discreta mostra rosso (caldo)',
@@ -5874,6 +5875,7 @@ export default {
     maintenance: 'Manutenzione',
     amsHumidity: 'Umidità AMS',
     amsTemp: 'Temp AMS',
+    amsDryingSuspended: 'Asciugatura sospesa',
     amsHtHumidity: 'Umidità AMS-HT',
     amsHtTemp: 'Temp AMS-HT',
     bedCooled: 'Piatto raffreddato',
@@ -5911,6 +5913,8 @@ export default {
     amsHumidityHighDescription: 'L\'umidità dell\'AMS standard supera la soglia',
     amsTemperatureHigh: 'Temperatura AMS elevata',
     amsTemperatureHighDescription: 'La temperatura dell\'AMS standard supera la soglia',
+    amsDryingSuspendedTitle: 'Asciugatura automatica sospesa',
+    amsDryingSuspendedDescription: 'L\'asciugatura automatica ha rinunciato a un\'unità AMS perché l\'umidità non è mai scesa sotto la soglia',
     amsHtHumidityHigh: 'Umidità AMS-HT elevata',
     amsHtHumidityHighDescription: 'L\'umidità dell\'AMS-HT supera la soglia',
     amsHtTemperatureHigh: 'Temperatura AMS-HT elevata',

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

@@ -2226,6 +2226,7 @@ export default {
     fairOrange: '普通(オレンジ)≤',
     aboveFairBad: '普通のしきい値以上は赤(悪い)で表示',
     fairAlsoDryingThreshold: 'このしきい値は自動乾燥のトリガーにも使用されます',
+    fairBelowDryingFloor: '自動乾燥ではこの値に到達できません。AMS はヒーター動作中に {{floor}}% 以上を示すため、各サイクルが終了してすぐに再開してしまいます。自動乾燥を使う場合は {{floor}}% より高く設定してください。',
     temperature: '温度',
     goodBlue: '良好(青)≤',
     aboveFairHot: '普通のしきい値以上は赤(高温)で表示',
@@ -5886,6 +5887,7 @@ export default {
     maintenance: 'メンテナンス',
     amsHumidity: 'AMS湿度',
     amsTemp: 'AMS温度',
+    amsDryingSuspended: '乾燥を停止',
     amsHtHumidity: 'AMS-HT湿度',
     amsHtTemp: 'AMS-HT温度',
     bedCooled: 'ベッド冷却済み',
@@ -5923,6 +5925,8 @@ export default {
     amsHumidityHighDescription: '通常AMSの湿度がしきい値を超過',
     amsTemperatureHigh: 'AMS温度高',
     amsTemperatureHighDescription: '通常AMSの温度がしきい値を超過',
+    amsDryingSuspendedTitle: '自動乾燥の停止',
+    amsDryingSuspendedDescription: '湿度がしきい値を下回らなかったため、AMS ユニットの自動乾燥を中止しました',
     amsHtHumidityHigh: 'AMS-HT湿度高',
     amsHtHumidityHighDescription: 'AMS-HTの湿度がしきい値を超過',
     amsHtTemperatureHigh: 'AMS-HT温度高',

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

@@ -2107,6 +2107,7 @@ export default {
     fairOrange: '보통 (주황색)',
     aboveFairBad: '보통 임계값 초과 시 빨간색 (나쁨)으로 표시',
     fairAlsoDryingThreshold: '이 임계값은 자동 건조 활성화 시 트리거로도 사용됨',
+    fairBelowDryingFloor: '자동 건조로는 이 값에 도달할 수 없습니다. AMS는 히터가 작동하는 동안 {{floor}}% 이상을 보고하므로 각 주기가 끝나고 곧바로 다시 시작됩니다. 자동 건조를 사용한다면 {{floor}}%보다 높게 설정하세요.',
     temperature: '온도',
     goodBlue: '좋음 (파란색)',
     aboveFairHot: '보통 임계값 초과 시 빨간색 (뜨거움)으로 표시',
@@ -5605,6 +5606,7 @@ export default {
     maintenance: '유지 관리',
     amsHumidity: 'AMS 습도',
     amsTemp: 'AMS 온도',
+    amsDryingSuspended: '건조 중단됨',
     amsHtHumidity: 'AMS-HT 습도',
     amsHtTemp: 'AMS-HT 온도',
     bedCooled: '베드 냉각됨',
@@ -5641,6 +5643,8 @@ export default {
     amsHumidityHighDescription: '일반 AMS 습도가 임계값 초과',
     amsTemperatureHigh: 'AMS 온도 높음',
     amsTemperatureHighDescription: '일반 AMS 온도가 임계값 초과',
+    amsDryingSuspendedTitle: '자동 건조 중단',
+    amsDryingSuspendedDescription: '습도가 임계값 아래로 내려가지 않아 AMS 유닛의 자동 건조를 중단했습니다',
     amsHtHumidityHigh: 'AMS-HT 습도 높음',
     amsHtHumidityHighDescription: 'AMS-HT 습도가 임계값 초과',
     amsHtTemperatureHigh: 'AMS-HT 온도 높음',

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

@@ -2183,6 +2183,7 @@ export default {
     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',
+    fairBelowDryingFloor: 'A secagem automática não consegue atingir este valor: um AMS indica {{floor}}% ou mais enquanto aquece, então cada ciclo terminaria e recomeçaria de imediato. Aumente-o acima de {{floor}}% se usar a secagem automática.',
     temperature: 'Temperatura',
     goodBlue: 'Bom (azul)',
     aboveFairHot: 'Acima do limiar razoável mostra como vermelho (quente)',
@@ -5874,6 +5875,7 @@ export default {
     maintenance: 'Manutenção',
     amsHumidity: 'Umidade do AMS',
     amsTemp: 'Temp. do AMS',
+    amsDryingSuspended: 'Secagem suspensa',
     amsHtHumidity: 'Umidade do AMS-HT',
     amsHtTemp: 'Temp. do AMS-HT',
     bedCooled: 'Mesa Resfriada',
@@ -5911,6 +5913,8 @@ export default {
     amsHumidityHighDescription: 'Umidade do AMS regular excede o limite',
     amsTemperatureHigh: 'Temperatura Alta do AMS',
     amsTemperatureHighDescription: 'Temperatura do AMS regular excede o limite',
+    amsDryingSuspendedTitle: 'Secagem automática suspensa',
+    amsDryingSuspendedDescription: 'A secagem automática desistiu de uma unidade AMS porque a umidade nunca ficou abaixo do limite',
     amsHtHumidityHigh: 'Umidade Alta do AMS-HT',
     amsHtHumidityHighDescription: 'Umidade do AMS-HT excede o limite',
     amsHtTemperatureHigh: 'Temperatura Alta do AMS-HT',

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

@@ -2107,6 +2107,7 @@ export default {
     fairOrange: "Допустимо (оранжевый)",
     aboveFairBad: "Выше допустимого порога отображается красным",
     fairAlsoDryingThreshold: "Этот порог также запускает автоматическую сушку, если она включена",
+    fairBelowDryingFloor: "Автоматическая сушка не может достичь этого значения: при работающем нагревателе AMS показывает {{floor}} % и выше, поэтому каждый цикл завершался бы и тут же запускался снова. Установите значение выше {{floor}} %, если используете автоматическую сушку.",
     temperature: "Температура",
     goodBlue: "Норма (синий)",
     aboveFairHot: "Выше допустимого порога отображается красным как перегрев",
@@ -5592,6 +5593,7 @@ export default {
     maintenance: "Обслуживание",
     amsHumidity: "Влажность AMS",
     amsTemp: "Температура AMS",
+    amsDryingSuspended: "Сушка приостановлена",
     amsHtHumidity: "Влажность AMS-HT",
     amsHtTemp: "Температура AMS-HT",
     bedCooled: "Стол остыл",
@@ -5628,6 +5630,8 @@ export default {
     amsHumidityHighDescription: "Влажность в обычной AMS превысила заданный порог",
     amsTemperatureHigh: "Высокая температура в AMS",
     amsTemperatureHighDescription: "Температура в обычной AMS превысила заданный порог",
+    amsDryingSuspendedTitle: "Автоматическая сушка приостановлена",
+    amsDryingSuspendedDescription: "Автоматическая сушка блока AMS прекращена: влажность так и не опустилась ниже порога",
     amsHtHumidityHigh: "Высокая влажность в AMS-HT",
     amsHtHumidityHighDescription: "Влажность в AMS-HT превысила заданный порог",
     amsHtTemperatureHigh: "Высокая температура в AMS-HT",

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

@@ -2231,6 +2231,7 @@ export default {
     fairOrange: 'Orta (turuncu)',
     aboveFairBad: 'Orta eşiğin üstü kırmızı olarak gösterilir (kötü)',
     fairAlsoDryingThreshold: 'Bu eşik aynı zamanda etkinleştirildiğinde otomatik kurutmayı tetiklemek için de kullanılır',
+    fairBelowDryingFloor: 'Otomatik kurutma bu değere ulaşamaz: bir AMS, ısıtıcısı çalışırken %{{floor}} veya daha fazlasını bildirir, bu yüzden her döngü biter ve hemen yeniden başlar. Otomatik kurutma kullanıyorsanız %{{floor}} üzerine çıkarın.',
     temperature: 'Sıcaklık',
     goodBlue: 'İyi (mavi)',
     aboveFairHot: 'Orta eşiğin üstü kırmızı olarak gösterilir (sıcak)',
@@ -5842,6 +5843,7 @@ export default {
     maintenance: 'Bakım',
     amsHumidity: 'AMS Nemi',
     amsTemp: 'AMS Sıcaklığı',
+    amsDryingSuspended: 'Kurutma askıya alındı',
     amsHtHumidity: 'AMS-HT Nemi',
     amsHtTemp: 'AMS-HT Sıcaklığı',
     bedCooled: 'Tabla Soğudu',
@@ -5878,6 +5880,8 @@ export default {
     amsHumidityHighDescription: 'Normal AMS nemi eşiği aşıyor',
     amsTemperatureHigh: 'AMS Sıcaklığı Yüksek',
     amsTemperatureHighDescription: 'Normal AMS sıcaklığı eşiği aşıyor',
+    amsDryingSuspendedTitle: 'Otomatik kurutma askıya alındı',
+    amsDryingSuspendedDescription: 'Nem hiçbir zaman eşiğin altına inmediği için bir AMS ünitesinde otomatik kurutmadan vazgeçildi',
     amsHtHumidityHigh: 'AMS-HT Nemi Yüksek',
     amsHtHumidityHighDescription: 'AMS-HT nemi eşiği aşıyor',
     amsHtTemperatureHigh: 'AMS-HT Sıcaklığı Yüksek',

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

@@ -2246,6 +2246,7 @@ export default {
     fairOrange: "Задовільний (помаранчевий)",
     aboveFairBad: "Значення вище задовільного порога відображається червоним (погано)",
     fairAlsoDryingThreshold: "Цей поріг також використовується для запуску автоматичного сушіння, якщо його ввімкнено",
+    fairBelowDryingFloor: "Автоматичне сушіння не може досягти цього значення: за увімкненого нагрівача AMS показує {{floor}} % або більше, тож кожен цикл завершувався б і одразу починався знову. Встановіть значення понад {{floor}} %, якщо користуєтеся автоматичним сушінням.",
     temperature: "Температура",
     goodBlue: "Добре (синій)",
     aboveFairHot: "Значення вище задовільного порога відображається червоним (гаряче)",
@@ -5928,6 +5929,7 @@ export default {
     maintenance: "Технічне обслуговування",
     amsHumidity: "AMS Вологість",
     amsTemp: "AMS Темп",
+    amsDryingSuspended: "Сушіння призупинено",
     amsHtHumidity: "AMS-HT Вологість",
     amsHtTemp: "Температура AMS-HT",
     bedCooled: "Стіл охолов",
@@ -5965,6 +5967,8 @@ export default {
     amsHumidityHighDescription: "Звичайна AMS вологість перевищує порогове значення",
     amsTemperatureHigh: "AMS Висока температура",
     amsTemperatureHighDescription: "Звичайна AMS температура перевищує порогове значення",
+    amsDryingSuspendedTitle: "Автоматичне сушіння призупинено",
+    amsDryingSuspendedDescription: "Автоматичне сушіння блока AMS припинено: вологість так і не опустилася нижче порога",
     amsHtHumidityHigh: "AMS-HT Висока вологість",
     amsHtHumidityHighDescription: "AMS-HT вологість перевищує порогове значення",
     amsHtTemperatureHigh: "Висока температура AMS-HT",

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

@@ -2228,6 +2228,7 @@ export default {
     fairOrange: '一般(橙色)',
     aboveFairBad: '超过一般阈值显示为红色(差)',
     fairAlsoDryingThreshold: '此阈值也用于触发自动干燥',
+    fairBelowDryingFloor: '自动干燥无法达到该值:AMS 在加热时读数为 {{floor}}% 或更高,因此每个周期都会结束并立即重新开始。若使用自动干燥,请将其设为高于 {{floor}}%。',
     temperature: '温度',
     goodBlue: '良好(蓝色)',
     aboveFairHot: '超过一般阈值显示为红色(热)',
@@ -5874,6 +5875,7 @@ export default {
     maintenance: '维护',
     amsHumidity: 'AMS 湿度',
     amsTemp: 'AMS 温度',
+    amsDryingSuspended: '干燥已暂停',
     amsHtHumidity: 'AMS-HT 湿度',
     amsHtTemp: 'AMS-HT 温度',
     bedCooled: '热床已冷却',
@@ -5911,6 +5913,8 @@ export default {
     amsHumidityHighDescription: '普通 AMS 湿度超过阈值',
     amsTemperatureHigh: 'AMS 温度过高',
     amsTemperatureHighDescription: '普通 AMS 温度超过阈值',
+    amsDryingSuspendedTitle: '自动干燥已暂停',
+    amsDryingSuspendedDescription: '湿度始终未降到阈值以下,已放弃对某个 AMS 单元的自动干燥',
     amsHtHumidityHigh: 'AMS-HT 湿度过高',
     amsHtHumidityHighDescription: 'AMS-HT 湿度超过阈值',
     amsHtTemperatureHigh: 'AMS-HT 温度过高',

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

@@ -2228,6 +2228,7 @@ export default {
     fairOrange: '一般(橙色)',
     aboveFairBad: '超過一般閾值顯示為紅色(差)',
     fairAlsoDryingThreshold: '此閾值也用於觸發自動乾燥',
+    fairBelowDryingFloor: '自動乾燥無法達到此值:AMS 在加熱時讀數為 {{floor}}% 或更高,因此每個週期都會結束並立即重新開始。若使用自動乾燥,請將其設為高於 {{floor}}%。',
     temperature: '溫度',
     goodBlue: '良好(藍色)',
     aboveFairHot: '超過一般閾值顯示為紅色(熱)',
@@ -5874,6 +5875,7 @@ export default {
     maintenance: '維護',
     amsHumidity: 'AMS 濕度',
     amsTemp: 'AMS 溫度',
+    amsDryingSuspended: '乾燥已暫停',
     amsHtHumidity: 'AMS-HT 濕度',
     amsHtTemp: 'AMS-HT 溫度',
     bedCooled: '熱床已冷卻',
@@ -5911,6 +5913,8 @@ export default {
     amsHumidityHighDescription: '普通 AMS 濕度超過閾值',
     amsTemperatureHigh: 'AMS 溫度過高',
     amsTemperatureHighDescription: '普通 AMS 溫度超過閾值',
+    amsDryingSuspendedTitle: '自動乾燥已暫停',
+    amsDryingSuspendedDescription: '濕度始終未降到閾值以下,已放棄對某個 AMS 單元的自動乾燥',
     amsHtHumidityHigh: 'AMS-HT 濕度過高',
     amsHtHumidityHighDescription: 'AMS-HT 濕度超過閾值',
     amsHtTemperatureHigh: 'AMS-HT 溫度過高',

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

@@ -129,6 +129,12 @@ registerSettingsSearch({ labelKey: 'backup.history', labelFallback: 'Backup Hist
 registerSettingsSearch({ labelKey: 'backup.localBackup', labelFallback: 'Local Backup', tab: 'backup', keywords: 'local backup download zip manual export', anchor: 'card-backup-local' });
 registerSettingsSearch({ labelKey: 'backup.scheduledBackup', labelFallback: 'Scheduled Backups', tab: 'backup', keywords: 'scheduled backup automatic hourly daily weekly retention local path', anchor: 'card-backup-scheduled' });
 
+// Lowest humidity an AMS reports while its own dryer is running, measured on an
+// H2D/AMS 2 Pro that read 10-13% cold and 15-20% throughout every cycle (#2770).
+// A drying threshold below this can never be reached while the box is warm, so
+// auto-drying would end one cycle and immediately arm another.
+const HUMIDITY_DRYING_FLOOR = 20;
+
 const STORAGE_CATEGORY_COLORS: Record<string, string> = {
   database: 'bg-blue-600',
   library_files: 'bg-green-500',
@@ -5683,6 +5689,17 @@ export function SettingsPage() {
                   <p className="text-xs text-amber-700/80 dark:text-amber-400/70">
                     {t('settings.fairAlsoDryingThreshold')}
                   </p>
+                  {/* An AMS reads 15-20% while its heater is running, well above
+                      what the same unit reports once cold, so a drying threshold
+                      inside that band can never be satisfied while drying (#2770).
+                      Warn rather than clamp — the number is also the colour
+                      threshold on the AMS card, where a low value is a legitimate
+                      choice. */}
+                  {(localSettings.ams_humidity_fair ?? 60) < HUMIDITY_DRYING_FLOOR && (
+                    <p className="text-xs text-red-600 dark:text-red-400">
+                      {t('settings.fairBelowDryingFloor', { floor: HUMIDITY_DRYING_FLOOR })}
+                    </p>
+                  )}
                 </div>
 
                 {/* Temperature Thresholds */}

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-0YeqkzMt.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-WBZDnFeY.js"></script>
+    <script type="module" crossorigin src="/assets/index-0YeqkzMt.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DZYWm6I1.css">
   </head>
   <body>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor