Quellcode durchsuchen

fix(ams): show a parked drying command as not started instead of an active cycle (#3096)

M2ABRAMSTANK vor 1 Tag
Ursprung
Commit
6c0292b09a
41 geänderte Dateien mit 626 neuen und 28 gelöschten Zeilen
  1. 1 0
      backend/app/api/routes/printers.py
  2. 15 2
      backend/app/main.py
  3. 4 0
      backend/app/schemas/printer.py
  4. 27 1
      backend/app/services/bambu_mqtt.py
  5. 57 3
      backend/app/services/print_scheduler.py
  6. 9 0
      backend/app/services/printer_manager.py
  7. 31 0
      backend/app/utils/ams_drying.py
  8. 80 0
      backend/tests/unit/services/test_bambu_mqtt.py
  9. 28 0
      backend/tests/unit/services/test_printer_manager.py
  10. 23 1
      backend/tests/unit/test_ams_alarm_gating.py
  11. 16 0
      backend/tests/unit/test_scheduler_pinned_waiting_reason_3074.py
  12. 133 0
      backend/tests/unit/test_scheduler_scheduled_drying.py
  13. 60 4
      backend/tests/unit/test_status_broadcast_ams_slot_config.py
  14. 4 2
      frontend/src/__tests__/components/PdfPreviewModal.test.tsx
  15. 32 1
      frontend/src/__tests__/pages/PrintersPageDryingFeedback.test.tsx
  16. 19 0
      frontend/src/__tests__/utils/isPrinterCurrentlyDispatchable.test.ts
  17. 1 0
      frontend/src/api/client.ts
  18. 2 0
      frontend/src/i18n/locales/de.ts
  19. 2 0
      frontend/src/i18n/locales/en.ts
  20. 2 0
      frontend/src/i18n/locales/es.ts
  21. 2 0
      frontend/src/i18n/locales/fr.ts
  22. 2 0
      frontend/src/i18n/locales/it.ts
  23. 2 0
      frontend/src/i18n/locales/ja.ts
  24. 2 0
      frontend/src/i18n/locales/ko.ts
  25. 2 0
      frontend/src/i18n/locales/nl.ts
  26. 2 0
      frontend/src/i18n/locales/pt-BR.ts
  27. 2 0
      frontend/src/i18n/locales/ru.ts
  28. 2 0
      frontend/src/i18n/locales/sv.ts
  29. 2 0
      frontend/src/i18n/locales/tr.ts
  30. 2 0
      frontend/src/i18n/locales/uk.ts
  31. 2 0
      frontend/src/i18n/locales/zh-CN.ts
  32. 2 0
      frontend/src/i18n/locales/zh-TW.ts
  33. 49 11
      frontend/src/pages/PrintersPage.tsx
  34. 2 1
      frontend/src/utils/printer.ts
  35. 1 0
      static/assets/PdfPreviewModal-BbRpR5w7.js
  36. 1 0
      static/assets/SpreadsheetPreviewModal-CsevFqb3.js
  37. 1 0
      static/assets/index-BPfxi5P1.css
  38. 0 1
      static/assets/index-DDT9pHR2.css
  39. 1 0
      static/assets/index-RfDgRZjl.js
  40. 0 0
      static/assets/pdf-Ce2j8LB_.js
  41. 1 1
      static/index.html

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

@@ -628,6 +628,7 @@ async def get_printer_status(
                     sw_ver=str(ams_data.get("sw_ver") or ""),
                     # Drying: dry_time > 0 means drying is active (minutes remaining)
                     dry_time=int(ams_data.get("dry_time") or 0),
+                    dry_countdown_stalled=bool(ams_data.get("dry_countdown_stalled") or False),
                     dry_target_temp=dry_target_temp,
                     dry_filament=dry_filament,
                     module_type=str(ams_data.get("module_type") or ""),

+ 15 - 2
backend/app/main.py

@@ -1528,8 +1528,21 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
     # Include tray_now and vt_tray hash so external spool changes trigger broadcasts
     vt_tray_key = hash(str(state.raw_data.get("vt_tray", []))) if state.raw_data else 0
-    # Include AMS dry_time and tray state values so drying/slot changes trigger broadcasts
-    ams_dry_key = tuple(a.get("dry_time", 0) for a in (state.raw_data.get("ams") or [])) if state.raw_data else ()
+    # Include AMS dry_time and tray state values so drying/slot changes trigger broadcasts.
+    #
+    # dry_countdown_stalled rides along because it is the one drying signal the
+    # countdown itself cannot carry: the MQTT layer raises it precisely BECAUSE
+    # dry_time stopped moving, so on the frame that flips it every other member
+    # of this key is identical and the push would be deduplicated away. Mid-print
+    # a temperature would eventually break the tie, but a parked command on an
+    # idle machine changes nothing else at all — AMS temp and humidity are not in
+    # the key — so the badge could sit unreachable indefinitely. The flag flips at
+    # most once per drying cycle, so it costs no mid-print broadcast traffic.
+    ams_dry_key = (
+        tuple((a.get("dry_time", 0), bool(a.get("dry_countdown_stalled"))) for a in (state.raw_data.get("ams") or []))
+        if state.raw_data
+        else ()
+    )
     # Include tray states so load/unload transitions (state 11→10) trigger broadcasts (#784)
     #
     # The filament identity fields are here because Configure Slot writes

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

@@ -213,6 +213,10 @@ class AMSUnit(BaseModel):
     serial_number: str = ""  # AMS unit serial number (sn from MQTT)
     sw_ver: str = ""  # AMS firmware version (from get_version info.module)
     dry_time: int = 0  # Minutes remaining (0 = not drying, >0 = drying active)
+    # True when dry_time > 0 but the countdown has not ticked for over
+    # DRY_COUNTDOWN_STALL_SECONDS with no active dry_status phase: the timer is
+    # set but no cycle is running (never started, or paused partway).
+    dry_countdown_stalled: bool = False
     dry_status: int = 0  # 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
     dry_sub_status: int = 0  # 0=Off, 1=Heating, 2=Dehumidify
     dry_sf_reason: list[int] = []  # Cannot-dry reasons from firmware (see CannotDryReason)

+ 27 - 1
backend/app/services/bambu_mqtt.py

@@ -23,7 +23,7 @@ import paho.mqtt.client as mqtt
 
 from backend.app.services.hms_actions import HMSAction, get_actions_for_error_code
 from backend.app.services.hms_errors import describe_fault
-from backend.app.utils.ams_drying import ACTIVE_DRY_STATUSES
+from backend.app.utils.ams_drying import ACTIVE_DRY_STATUSES, DRY_COUNTDOWN_STALL_SECONDS
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.paho_teardown import retire_paho_client
 
@@ -1311,6 +1311,15 @@ class BambuMQTTClient:
         # Per-AMS previous dry_time, used to detect the falling edge above.
         # Seeded lazily as we observe each AMS unit.
         self._previous_dry_times: dict[int, int] = {}
+        # Per-AMS monotonic stamp of the last time dry_time CHANGED value.
+        # A live cycle's countdown ticks once a minute; a command the firmware
+        # accepted but never actually started (observed on an H2D mid-print
+        # with two AMS-HT cycles already running: the third unit's timer sat
+        # frozen at its full duration) never ticks. dry_time > 0 with no tick
+        # for DRY_COUNTDOWN_STALL_SECONDS and no active dry_status phase is
+        # reported as dry_countdown_stalled so the UI can stop claiming an
+        # active cycle that is not running.
+        self._dry_time_changed_at: dict[int, float] = {}
         # Per-AMS active-cycle target params (filament + temp) we sent on the
         # last start. Bambu does not echo these back in the per-tick AMS push
         # — only the dry_time countdown — so we cache what we sent to drive
@@ -3634,6 +3643,11 @@ class BambuMQTTClient:
                 continue
             if ams_id < 0:
                 continue
+            # The firmware phase outranks the stalled-countdown heuristic even
+            # on a transient-zero frame, which the completion guard below skips.
+            # Clear a previously raised flag before that early continue.
+            if ams_unit.get("dry_status") in ACTIVE_DRY_STATUSES:
+                ams_unit["dry_countdown_stalled"] = False
             # Only evaluate the edge when this update carries an explicit
             # dry_time. An absent / unparseable value is NOT zero — treating
             # it as 0 lets a tray-only partial fake a drying-complete edge
@@ -3669,6 +3683,18 @@ class BambuMQTTClient:
                 continue
             previous = self._previous_dry_times.get(ams_id, 0)
             self._previous_dry_times[ams_id] = current
+            # Stall detection: stamp value CHANGES only — a live countdown
+            # decrements once a minute, so repeats of the same value within
+            # the minute must not refresh the stamp, and a frame without a
+            # dry_time never reaches here (the absent-value skip above).
+            now_mono = time.monotonic()
+            if current != previous or ams_id not in self._dry_time_changed_at:
+                self._dry_time_changed_at[ams_id] = now_mono
+            ams_unit["dry_countdown_stalled"] = bool(
+                current > 0
+                and ams_unit.get("dry_status") not in ACTIVE_DRY_STATUSES
+                and now_mono - self._dry_time_changed_at[ams_id] > DRY_COUNTDOWN_STALL_SECONDS
+            )
             if previous > 0 and current == 0:
                 self._log_drying_cycle_end(ams_id, previous, ams_unit, self._drying_targets.pop(ams_id, None))
                 if self.on_drying_complete:

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

@@ -59,6 +59,7 @@ from backend.app.services.printer_manager import (
     supports_drying_while_printing,
 )
 from backend.app.services.smart_plug_manager import smart_plug_manager
+from backend.app.utils.ams_drying import is_countdown_parked
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.archive_paths import archive_photos_dir
 from backend.app.utils.color_utils import perceptual_color_distance
@@ -1641,8 +1642,12 @@ class PrintScheduler:
                     # Drying blocks the queue, if the user asked it to. A hold
                     # is a skip like any other, so it belongs here with the
                     # rest of the availability checks.
-                    if self._drying_in_progress.get(item.printer_id) and await self._get_bool_setting(
-                        db, "queue_drying_block"
+                    # A parked timer (#2896) never ends, so it must not hold the
+                    # queue; it stays tracked so the stop paths still reach it.
+                    if (
+                        self._drying_in_progress.get(item.printer_id)
+                        and not self._drying_is_only_parked(item.printer_id)
+                        and await self._get_bool_setting(db, "queue_drying_block")
                     ):
                         # Busy-shaped on purpose: the cycle ends on its own and
                         # the job goes out, so there is nothing to alert about.
@@ -4717,6 +4722,26 @@ class PrintScheduler:
         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)
 
+    @staticmethod
+    def _drying_is_only_parked(printer_id: int) -> bool:
+        """True when every AMS unit with a drying timer on this printer is parked.
+
+        A parked timer (#2896: the command was taken but the countdown never
+        runs) does not end on its own, so nothing may wait on it. False when no
+        unit reports a timer yet -- a command just sent that the firmware has not
+        reported back is real drying about to begin.
+        """
+        state = printer_manager.get_status(printer_id)
+        units = [a for a in ((state.raw_data or {}).get("ams") or [] if state else []) if isinstance(a, dict)]
+        timed = []
+        for unit in units:
+            try:
+                if int(unit.get("dry_time") or 0) > 0:
+                    timed.append(unit)
+            except (TypeError, ValueError):
+                continue
+        return bool(timed) and all(is_countdown_parked(unit) for unit in timed)
+
     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.
 
@@ -4850,7 +4875,9 @@ class PrintScheduler:
                 logger.warning("Scheduled drying %d: %s", row.id, unsupported)
                 continue
 
-            if self._drying_in_progress.get(row.printer_id) or row.printer_id in running_printer_ids:
+            if (
+                self._drying_in_progress.get(row.printer_id) and not self._drying_is_only_parked(row.printer_id)
+            ) or row.printer_id in running_printer_ids:
                 row.waiting_reason = "already_drying"
                 continue
             if not self._is_printer_idle(row.printer_id, require_plate_clear=False):
@@ -4942,6 +4969,33 @@ class PrintScheduler:
             dry_time = int(target.get("dry_time") or 0) if target else 0
         except (TypeError, ValueError):
             dry_time = 0
+        if dry_time > 0 and is_countdown_parked(target):
+            # The printer took the command but the countdown is not running
+            # (#2896) and will never reach 0, so the run would stay "running"
+            # forever. A print in progress is the likely cause (the power budget
+            # is spent), so re-queue it like any interruption; the next start
+            # waits for the printer to be idle. Parked on an idle printer is a
+            # refusal, not something a retry fixes. The timer itself is left on
+            # the printer: it may yet start once power frees up.
+            if not self._is_printer_idle(row.printer_id, require_plate_clear=False):
+                logger.info(
+                    "Scheduled drying %d: AMS %d countdown is not running during a print; re-queued",
+                    row.id,
+                    row.ams_id,
+                )
+                row.status = "pending"
+                row.started_at = None
+                row.waiting_reason = "interrupted"
+            else:
+                logger.warning(
+                    "Scheduled drying %d: printer accepted the command but AMS %d never started drying",
+                    row.id,
+                    row.ams_id,
+                )
+                row.status = "failed"
+                row.error_message = "The printer accepted the drying command, but the AMS did not start drying"
+                row.completed_at = now
+            return
         if dry_time > 0:
             return
 

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

@@ -1446,6 +1446,15 @@ def printer_state_to_dict(
                     "sw_ver": str(ams_data.get("sw_ver") or ""),
                     # Drying: dry_time > 0 means drying is active (minutes remaining)
                     "dry_time": int(ams_data.get("dry_time") or 0),
+                    # Stall flag stamped onto raw_data by the MQTT layer: the
+                    # firmware set the countdown but it never ticked. The REST
+                    # serializer already emits it (routes/printers.py); without
+                    # it here the WS shallow-merge replaces the REST-seeded ams
+                    # object a second later, the field reads undefined, and the
+                    # card falls back to the amber "active cycle" badge — so the
+                    # neutral "Drying not started" state would never be reachable
+                    # in the live UI. Same failure mode as `exists` in #2670.
+                    "dry_countdown_stalled": bool(ams_data.get("dry_countdown_stalled") or False),
                     # Drying status from info hex bits (0=Off, 1=Checking, 2=Drying, 3=Cooling, etc.)
                     "dry_status": int(ams_data.get("dry_status") or 0),
                     "dry_sub_status": int(ams_data.get("dry_sub_status") or 0),

+ 31 - 0
backend/app/utils/ams_drying.py

@@ -21,6 +21,15 @@ from typing import Any
 # should still reach the user, so it must never read as "expected heat".
 ACTIVE_DRY_STATUSES = frozenset({1, 2, 3})  # Checking, Drying, Cooling
 
+# A live drying countdown ticks down once per minute. A ``dry_time`` that has
+# not changed for this long — with no active ``dry_status`` phase vouching for
+# the cycle — is a timer with no cycle running behind it. Usually one that never
+# started (seen on an H2D mid-print with two AMS-HT cycles already active: the
+# third unit's timer froze at its full duration and no heating ever began), but a
+# cycle paused partway reads the same. 150 s allows two full missed ticks plus
+# jitter before the countdown is called stalled.
+DRY_COUNTDOWN_STALL_SECONDS = 150
+
 
 def is_drying_active(ams_data: Any) -> bool:
     """True when this AMS unit reports a drying cycle in progress.
@@ -29,9 +38,15 @@ def is_drying_active(ams_data: Any) -> bool:
     is minutes remaining and reads 0 through the cooling phase that closes a
     cycle; ``dry_status`` covers that phase but is only present when the
     firmware sent a parseable ``info`` field.
+
+    A parked countdown (``dry_countdown_stalled``, see is_countdown_parked) is
+    not a cycle in progress: the AMS is not heating, so nothing it reports is
+    "expected heat" from drying.
     """
     if not isinstance(ams_data, Mapping):
         return False
+    if ams_data.get("dry_countdown_stalled"):
+        return False
     try:
         if int(ams_data.get("dry_time") or 0) > 0:
             return True
@@ -43,6 +58,22 @@ def is_drying_active(ams_data: Any) -> bool:
         return False
 
 
+def is_countdown_parked(ams_data: Any) -> bool:
+    """True when this AMS unit holds a drying timer that is not running.
+
+    The MQTT layer sets ``dry_countdown_stalled`` once ``dry_time`` has stayed
+    unchanged past DRY_COUNTDOWN_STALL_SECONDS with no active ``dry_status``
+    phase. Such a timer never reaches 0 on its own, so anything that waits for
+    drying to finish must not wait on it.
+    """
+    if not isinstance(ams_data, Mapping) or not ams_data.get("dry_countdown_stalled"):
+        return False
+    try:
+        return int(ams_data.get("dry_time") or 0) > 0
+    except (TypeError, ValueError):
+        return False
+
+
 def temperature_alarm_suppressed(
     *,
     drying_active: bool,

+ 80 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -6410,6 +6410,86 @@ class TestAmsFilamentSettingExternalSpoolEncoding:
         assert cmd["slot_id"] == 0
 
 
+class TestDryCountdownStall:
+    """A ``dry_time`` the firmware set but whose countdown never ticks is a
+    parked command, not a running cycle — ``dry_countdown_stalled`` is how the
+    UI stops showing an active-drying badge for it. Live countdowns decrement
+    once a minute; the stall threshold (150 s) is two missed ticks plus jitter.
+    Found on an H2D mid-print: two AMS-HT cycles running, the third unit's
+    timer frozen at its full 720 while the AMS never heated."""
+
+    @pytest.fixture
+    def mqtt_client(self, monkeypatch):
+        from backend.app.services import bambu_mqtt as mod
+
+        clock = {"now": 1000.0}
+        monkeypatch.setattr(mod.time, "monotonic", lambda: clock["now"])
+        client = mod.BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST-STALL",
+            access_code="12345678",
+        )
+        client._test_clock = clock  # Expose for tests to advance
+        return client
+
+    def _unit(self, mqtt_client, ams_id=0):
+        for u in mqtt_client.state.raw_data["ams"]:
+            if int(u["id"]) == ams_id:
+                return u
+        raise AssertionError(f"AMS {ams_id} not in raw_data")
+
+    def test_frozen_countdown_flags_stalled(self, mqtt_client):
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
+        assert self._unit(mqtt_client)["dry_countdown_stalled"] is False
+        mqtt_client._test_clock["now"] += 151
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
+        assert self._unit(mqtt_client)["dry_countdown_stalled"] is True
+
+    def test_ticking_countdown_never_flags(self, mqtt_client):
+        """A live cycle decrements every minute; repeats of the same value
+        inside the minute must not flag either."""
+        for dt, value in ((0, 720), (60, 719), (100, 719), (120, 718)):
+            mqtt_client._test_clock["now"] = 1000.0 + dt
+            mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": value, "tray": []}]})
+            assert self._unit(mqtt_client)["dry_countdown_stalled"] is False, dt
+
+    def test_active_dry_status_vouches_for_frozen_countdown(self, mqtt_client):
+        """When the firmware's own info phase says Drying, a frozen countdown
+        is trusted as live — the phase field outranks the tick heuristic."""
+        # info bits 4-7 = 2 (Drying) -> 0x20
+        frame = {"ams": [{"id": "0", "dry_time": 720, "info": "20", "tray": []}]}
+        mqtt_client._handle_ams_data(frame)
+        mqtt_client._test_clock["now"] += 300
+        mqtt_client._handle_ams_data(frame)
+        assert self._unit(mqtt_client)["dry_countdown_stalled"] is False
+
+    def test_active_phase_clears_stall_on_transient_zero(self, mqtt_client):
+        """A Checking/Drying phase is authoritative even when its transient
+        dry_time=0 frame is ignored for completion-edge tracking (#2759)."""
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
+        mqtt_client._test_clock["now"] += 151
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
+        assert self._unit(mqtt_client)["dry_countdown_stalled"] is True
+
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "info": "20", "tray": []}]})
+        assert self._unit(mqtt_client)["dry_countdown_stalled"] is False
+        assert mqtt_client._previous_dry_times[0] == 720
+
+    def test_fresh_start_gets_grace_before_flagging(self, mqtt_client):
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        mqtt_client._test_clock["now"] += 30
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
+        mqtt_client._test_clock["now"] += 30
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
+        assert self._unit(mqtt_client)["dry_countdown_stalled"] is False
+
+    def test_finished_cycle_never_reads_stalled(self, mqtt_client):
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 1, "tray": []}]})
+        mqtt_client._test_clock["now"] += 200
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        assert self._unit(mqtt_client)["dry_countdown_stalled"] is False
+
+
 class TestDryingCompleteCallback:
     """#1349 — fires ``on_drying_complete(ams_id)`` on a dry_time falling edge."""
 

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

@@ -1104,6 +1104,34 @@ class TestPrinterStateToDict:
         assert ht_tray["exists"] is False
         assert reg_tray["exists"] is True
 
+    def test_dry_countdown_stalled_is_serialized_for_websocket(self, mock_state):
+        """The WS status payload must carry ``dry_countdown_stalled`` — the REST
+        serializer already does (routes/printers.py). The frontend merges a WS
+        frame over the cached status with a top-level shallow spread, so the ~1/s
+        frame replaces the REST-seeded ``ams`` array wholesale. Omit the flag here
+        and it reads undefined a second after load, the card falls back to the
+        amber "Drying - 45m left" badge, and the neutral "Drying not started"
+        state is unreachable in the live UI. Same failure mode as `exists`/#2670.
+        """
+        mock_state.raw_data = {
+            "ams": [
+                # Parked command: timer set, countdown never ticked.
+                {"id": 0, "dry_time": 720, "dry_countdown_stalled": True, "tray": []},
+                # Genuinely running cycle.
+                {"id": 1, "dry_time": 45, "dry_countdown_stalled": False, "tray": []},
+                # Pre-flag raw_data (a unit the MQTT layer has not stamped yet)
+                # must serialize False, never None/absent — the REST shape is a
+                # non-optional bool and the two surfaces must not disagree.
+                {"id": 2, "dry_time": 0, "tray": []},
+            ]
+        }
+
+        result = printer_state_to_dict(mock_state)
+
+        assert result["ams"][0]["dry_countdown_stalled"] is True
+        assert result["ams"][1]["dry_countdown_stalled"] is False
+        assert result["ams"][2]["dry_countdown_stalled"] is False
+
     def test_vt_tray_parsing(self, mock_state):
         """Verify virtual tray is parsed correctly as a list."""
         mock_state.raw_data = {

+ 23 - 1
backend/tests/unit/test_ams_alarm_gating.py

@@ -18,7 +18,7 @@ can skip empty units while still alarming on loaded ones in the same printer.
 from datetime import datetime, timedelta, timezone
 
 from backend.app.main import _ams_has_filament, _resolve_temp_alarm_threshold
-from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
+from backend.app.utils.ams_drying import is_countdown_parked, is_drying_active, temperature_alarm_suppressed
 
 
 class TestAmsHasFilament:
@@ -128,6 +128,28 @@ class TestIsDryingActive:
         assert is_drying_active({"dry_time": 30}) is True
         assert is_drying_active({"dry_time": 0}) is False
 
+    def test_parked_countdown_is_not_active(self):
+        # #2896: the printer took the command but the AMS is not heating, so a
+        # high temperature is not "expected heat" and must not be suppressed.
+        assert is_drying_active({"dry_time": 720, "dry_countdown_stalled": True}) is False
+        assert is_drying_active({"dry_time": 720, "dry_countdown_stalled": False}) is True
+
+
+class TestIsCountdownParked:
+    def test_stalled_timer_is_parked(self):
+        assert is_countdown_parked({"dry_time": 720, "dry_countdown_stalled": True}) is True
+        assert is_countdown_parked({"dry_time": "720", "dry_countdown_stalled": True}) is True
+
+    def test_running_or_absent_timer_is_not_parked(self):
+        assert is_countdown_parked({"dry_time": 720}) is False
+        assert is_countdown_parked({"dry_time": 720, "dry_countdown_stalled": False}) is False
+        # A flag left behind on a finished cycle does not make it parked.
+        assert is_countdown_parked({"dry_time": 0, "dry_countdown_stalled": True}) is False
+
+    def test_malformed_input_is_not_parked(self):
+        assert is_countdown_parked(None) is False
+        assert is_countdown_parked({"dry_time": "x", "dry_countdown_stalled": True}) is False
+
     def test_unparseable_values_do_not_raise(self):
         assert is_drying_active({"dry_time": "junk", "dry_status": 2}) is True
         assert is_drying_active({"dry_time": None, "dry_status": None}) is False

+ 16 - 0
backend/tests/unit/test_scheduler_pinned_waiting_reason_3074.py

@@ -215,6 +215,22 @@ class TestThePinnedItemSaysWhyItIsWaiting:
         launched.assert_not_called()
         assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01 (drying)"
 
+    @pytest.mark.asyncio
+    async def test_a_parked_drying_timer_does_not_block_the_queue(self, ctx):
+        """#2896: a timer the printer took but never runs does not reach 0 on
+        its own, so "block the queue until drying finishes" would hold the job
+        forever. The printer stays tracked as drying, but the hold lets go."""
+        await _set(ctx, "queue_drying_block", "true")
+        item_id = await _add_item(ctx)
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = True
+
+        with patch.object(PrintScheduler, "_drying_is_only_parked", staticmethod(lambda printer_id: True)):
+            launched = await _run(ctx, scheduler, idle=True)
+
+        launched.assert_called_once()
+        assert (await _item(ctx, item_id)).waiting_reason is None
+
 
 class TestTheReasonNeverOutlivesTheThingItDescribes:
     """Every exit from the branch writes, so no pass can leave a stale reason."""

+ 133 - 0
backend/tests/unit/test_scheduler_scheduled_drying.py

@@ -617,3 +617,136 @@ async def test_malformed_ams_id_does_not_throw_while_running(scheduler, db_sessi
     await db_session.refresh(row)
     assert row.status == "pending"
     assert row.waiting_reason == "interrupted"
+
+
+def _parked_state(ams_id=0, dry_time=720):
+    """A unit whose countdown the MQTT layer has flagged as not running (#2896)."""
+    state = _mock_state(ams_id=ams_id, dry_time=dry_time)
+    state.raw_data["ams"][0]["dry_countdown_stalled"] = True
+    return state
+
+
+@pytest.mark.asyncio
+async def test_running_with_a_ticking_countdown_stays_running(scheduler, db_session, printer_factory):
+    row = await _make_row(
+        db_session, printer_factory, status="running", started_at=_utcnow_naive() - timedelta(minutes=30)
+    )
+    with patch("backend.app.services.print_scheduler.printer_manager") as mock_pm:
+        mock_pm.get_status.return_value = _mock_state(dry_time=450)
+        await scheduler._check_scheduled_dryings(db_session)
+    await db_session.refresh(row)
+    assert row.status == "running"
+
+
+@pytest.mark.asyncio
+async def test_running_with_a_parked_countdown_during_a_print_requeues(scheduler, db_session, printer_factory):
+    """#2896: a timer the printer took but never runs would keep the row
+    "running" forever. Mid-print it is re-queued like any interruption."""
+    row = await _make_row(
+        db_session, printer_factory, status="running", started_at=_utcnow_naive() - timedelta(minutes=30)
+    )
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=False),
+    ):
+        mock_pm.get_status.return_value = _parked_state()
+        await scheduler._check_scheduled_dryings(db_session)
+    # Neither a stop nor a restart: the timer stays on the printer.
+    mock_pm.send_drying_command.assert_not_called()
+    await db_session.refresh(row)
+    assert row.status == "pending"
+    assert row.started_at is None
+    assert row.waiting_reason == "interrupted"
+    assert row.printer_id not in scheduler._scheduled_drying_printer_ids
+
+
+@pytest.mark.asyncio
+async def test_running_with_a_parked_countdown_on_an_idle_printer_fails(scheduler, db_session, printer_factory):
+    """Parked with nothing else running is a refusal, not a user stop: the row
+    fails with a reason instead of reading as cancelled or retrying forever."""
+    row = await _make_row(
+        db_session, printer_factory, status="running", started_at=_utcnow_naive() - timedelta(minutes=30)
+    )
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _parked_state()
+        await scheduler._check_scheduled_dryings(db_session)
+    await db_session.refresh(row)
+    assert row.status == "failed"
+    assert "did not start drying" in row.error_message
+    assert row.completed_at is not None
+
+
+@pytest.mark.asyncio
+async def test_a_parked_timer_does_not_make_a_pending_run_wait(scheduler, db_session, printer_factory):
+    """Auto-drying keeps tracking a parked unit so its stop paths still reach it,
+    but a scheduled run must not wait "already_drying" on a timer that never ends."""
+    row = await _make_row(db_session, printer_factory, start_after=None)
+    scheduler._drying_in_progress[row.printer_id] = 1.0
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _parked_state()
+        mock_pm.send_drying_command.return_value = True
+        await scheduler._check_scheduled_dryings(db_session)
+    mock_pm.send_drying_command.assert_called_once()
+    await db_session.refresh(row)
+    assert row.status == "running"
+
+
+@pytest.mark.asyncio
+async def test_real_drying_still_makes_a_pending_run_wait(scheduler, db_session, printer_factory):
+    row = await _make_row(db_session, printer_factory, start_after=None)
+    scheduler._drying_in_progress[row.printer_id] = 1.0
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
+        patch.object(scheduler, "_is_printer_idle", return_value=True),
+    ):
+        mock_pm.get_status.return_value = _mock_state(dry_time=450)
+        await scheduler._check_scheduled_dryings(db_session)
+    mock_pm.send_drying_command.assert_not_called()
+    await db_session.refresh(row)
+    assert row.waiting_reason == "already_drying"
+
+
+class TestDryingIsOnlyParked:
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_every_timed_unit_parked(self, mock_pm):
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {"id": 0, "dry_time": 720, "dry_countdown_stalled": True},
+                {"id": 1, "dry_time": 0},
+            ]
+        }
+        mock_pm.get_status.return_value = state
+        assert PrintScheduler._drying_is_only_parked(1) is True
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_one_running_unit_is_enough_to_count_as_drying(self, mock_pm):
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {"id": 0, "dry_time": 720, "dry_countdown_stalled": True},
+                {"id": 1, "dry_time": 300},
+            ]
+        }
+        mock_pm.get_status.return_value = state
+        assert PrintScheduler._drying_is_only_parked(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_no_timer_yet_is_not_parked(self, mock_pm):
+        """A command just sent that the firmware has not reported back yet is
+        real drying about to begin, not a parked one."""
+        state = MagicMock()
+        state.raw_data = {"ams": [{"id": 0, "dry_time": 0}]}
+        mock_pm.get_status.return_value = state
+        assert PrintScheduler._drying_is_only_parked(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_offline_printer_is_not_parked(self, mock_pm):
+        mock_pm.get_status.return_value = None
+        assert PrintScheduler._drying_is_only_parked(1) is False

+ 60 - 4
backend/tests/unit/test_status_broadcast_ams_slot_config.py

@@ -52,12 +52,17 @@ def _tray(**overrides) -> dict:
     return tray
 
 
-def _state(trays: list[dict]) -> SimpleNamespace:
+def _state(trays: list[dict], ams_extra: dict | None = None) -> SimpleNamespace:
     """Minimal PrinterState stub carrying one AMS unit.
 
     Idle and unheated, so the handler runs straight from the dedup check to the
     broadcast without touching progress milestones, HMS notifications or the DB.
+
+    ``ams_extra`` sets unit-level (not per-tray) fields such as the drying pair
+    ``dry_time`` / ``dry_countdown_stalled``.
     """
+    ams_unit = {"id": "0", "dry_time": 0, "tray": trays}
+    ams_unit.update(ams_extra or {})
     return SimpleNamespace(
         connected=True,
         state="IDLE",
@@ -65,7 +70,7 @@ def _state(trays: list[dict]) -> SimpleNamespace:
         layer_num=0,
         temperatures={},
         nozzles=[],
-        raw_data={"ams": [{"id": "0", "dry_time": 0, "tray": trays}]},
+        raw_data={"ams": [ams_unit]},
         stg_cur=0,
         # Real PrinterState always carries these; the status-broadcast dedup
         # key reads them so a Filament Track Switch rebind reaches the card.
@@ -98,7 +103,7 @@ def _reset_edge_state():
     main_module._printer_reconciled_since_connect.clear()
 
 
-async def _push(ws_mgr, trays: list[dict]) -> None:
+async def _push(ws_mgr, trays: list[dict], ams_extra: dict | None = None) -> None:
     """Deliver one status push to the handler."""
     relay = MagicMock()
     relay.on_printer_status = AsyncMock()
@@ -113,7 +118,7 @@ async def _push(ws_mgr, trays: list[dict]) -> None:
         _spawn_patch(),
         patch("backend.app.main.printer_state_to_dict", return_value={}),
     ):
-        await main_module.on_printer_status_change(1, _state(trays))
+        await main_module.on_printer_status_change(1, _state(trays, ams_extra))
 
 
 @pytest.fixture
@@ -322,3 +327,54 @@ class TestFilamentTrackSwitchBroadcasts:
         await self._push_state(ws_mgr, self._fts_state({"1": "B", "0": "A"}))
 
         assert ws_mgr.send_printer_status.await_count == 1
+
+
+class TestDryCountdownStallBroadcasts:
+    """#2896: a parked drying command must be able to reach the card.
+
+    ``dry_countdown_stalled`` is the one drying signal the countdown cannot
+    carry, because the MQTT layer raises it exactly when ``dry_time`` has
+    STOPPED moving. On the frame that flips it, every other member of the dedup
+    key is identical, so unless the flag is itself in the key the handler
+    returns before broadcasting. Mid-print a temperature change would eventually
+    break the tie, but a command parked on an idle machine moves nothing else —
+    AMS temp and humidity are not in the key — so the grey "Drying not started"
+    badge would sit unreachable.
+    """
+
+    @pytest.mark.asyncio
+    async def test_the_stall_flip_broadcasts(self, ws_mgr):
+        await _push(ws_mgr, [_tray()], {"dry_time": 720})
+        assert ws_mgr.send_printer_status.await_count == 1
+
+        # Same frozen countdown again: genuinely nothing changed.
+        await _push(ws_mgr, [_tray()], {"dry_time": 720})
+        assert ws_mgr.send_printer_status.await_count == 1
+
+        # 150s of no tick later the MQTT layer stamps the stall. dry_time is
+        # unchanged by definition — only the flag can break the key.
+        await _push(ws_mgr, [_tray()], {"dry_time": 720, "dry_countdown_stalled": True})
+
+        assert ws_mgr.send_printer_status.await_count == 2, (
+            "the stall flag did not reach the frontend — the card would keep "
+            "claiming an active 12h drying cycle that is not running"
+        )
+
+    @pytest.mark.asyncio
+    async def test_clearing_the_stall_broadcasts(self, ws_mgr):
+        """The recovery edge matters too: the firmware finally starts the cycle,
+        the countdown ticks, and the badge must go back to amber."""
+        await _push(ws_mgr, [_tray()], {"dry_time": 720, "dry_countdown_stalled": True})
+
+        await _push(ws_mgr, [_tray()], {"dry_time": 719})
+
+        assert ws_mgr.send_printer_status.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_a_steady_stalled_unit_does_not_reflood(self, ws_mgr):
+        """Once flagged, repeats of the flagged frame stay deduplicated — the
+        flag flips once per cycle, so widening the key costs no traffic."""
+        await _push(ws_mgr, [_tray()], {"dry_time": 720, "dry_countdown_stalled": True})
+        await _push(ws_mgr, [_tray()], {"dry_time": 720, "dry_countdown_stalled": True})
+
+        assert ws_mgr.send_printer_status.await_count == 1

+ 4 - 2
frontend/src/__tests__/components/PdfPreviewModal.test.tsx

@@ -7,7 +7,7 @@
  */
 
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { PdfPreviewModal } from '../../components/PdfPreviewModal';
 
@@ -68,7 +68,9 @@ describe('PdfPreviewModal', () => {
   it('shows the page indicator once the document loads', async () => {
     renderModal();
     expect(await screen.findByText('Page 1 of 3')).toBeInTheDocument();
-    expect(pdfjsMocks.render).toHaveBeenCalled();
+    // The indicator appears when the document loads; the page is drawn a step
+    // later, after getPage() resolves in an effect, so wait for it.
+    await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
   });
 
   it('navigates between pages', async () => {

+ 32 - 1
frontend/src/__tests__/pages/PrintersPageDryingFeedback.test.tsx

@@ -63,7 +63,7 @@ const baseTray = {
 
 /** AMS 2 Pro (n3f) on an idle printer that accepts remote drying commands. */
 function makeStatus(
-  dry: { dry_time: number; dry_status: number },
+  dry: { dry_time: number; dry_status: number; dry_countdown_stalled?: boolean },
   caps: { supports_drying?: boolean; drying_screen_only?: boolean } = {},
 ) {
   return {
@@ -105,6 +105,8 @@ function makeStatus(
 
 const IDLE = makeStatus({ dry_time: 0, dry_status: 0 });
 const DRYING = makeStatus({ dry_time: 720, dry_status: 2 });
+/** Timer set by an accepted command, but the countdown never started ticking. */
+const STALLED = makeStatus({ dry_time: 720, dry_status: 0, dry_countdown_stalled: true });
 
 /** A P1: the AMS dries, but only from the printer's own screen. */
 const SCREEN_ONLY = makeStatus(
@@ -264,3 +266,32 @@ describe('PrintersPage - screen-only AMS drying (#2533)', () => {
     expect(screen.queryByTitle('Stop Drying')).not.toBeInTheDocument();
   });
 });
+
+describe('PrintersPage - parked drying command is not shown as an active cycle', () => {
+  beforeEach(() => {
+    mockShowToast.mockClear();
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+      http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    );
+  });
+
+  it('shows "Drying not running" instead of the active badge when the countdown is stalled', async () => {
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(STALLED)));
+
+    render(<PrintersPage />);
+
+    expect((await screen.findAllByText('Drying not running')).length).toBeGreaterThan(0);
+    // The active-cycle claims are gone: no "Drying" badge text, no countdown.
+    expect(screen.queryByText('12h 0m left')).not.toBeInTheDocument();
+  });
+
+  it('keeps the amber active badge for a countdown that is really ticking', async () => {
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(DRYING)));
+
+    render(<PrintersPage />);
+
+    expect((await screen.findAllByText(/12h 0m/)).length).toBeGreaterThan(0);
+    expect(screen.queryByText('Drying not running')).not.toBeInTheDocument();
+  });
+});

+ 19 - 0
frontend/src/__tests__/utils/isPrinterCurrentlyDispatchable.test.ts

@@ -45,6 +45,25 @@ describe('isPrinterCurrentlyDispatchable', () => {
     ).toBe(false);
   });
 
+  it('ignores a parked drying timer, which never ends on its own (#2896)', () => {
+    expect(
+      isPrinterCurrentlyDispatchable(
+        status({ ams: [{ dry_time: 720, dry_countdown_stalled: true }] as PrinterStatus['ams'] })
+      )
+    ).toBe(true);
+    // One unit really drying still holds the printer back.
+    expect(
+      isPrinterCurrentlyDispatchable(
+        status({
+          ams: [
+            { dry_time: 720, dry_countdown_stalled: true },
+            { dry_time: 240, dry_countdown_stalled: false },
+          ] as PrinterStatus['ams'],
+        })
+      )
+    ).toBe(false);
+  });
+
   it('ignores an AMS that is loaded but not drying', () => {
     expect(
       isPrinterCurrentlyDispatchable(status({ ams: [{ dry_time: 0 }] as PrinterStatus['ams'] }))

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

@@ -480,6 +480,7 @@ export interface AMSUnit {
   serial_number: string;  // AMS unit serial number (from MQTT sn field)
   sw_ver: string;         // AMS firmware version (from get_version info.module ams/* entry)
   dry_time: number;       // Minutes remaining (0 = not drying, >0 = drying active)
+  dry_countdown_stalled?: boolean; // Timer set but countdown not ticking (never started or paused)
   dry_status: number;     // 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
   dry_sub_status: number; // 0=Off, 1=Heating, 2=Dehumidify
   dry_sf_reason: number[]; // Cannot-dry reasons (1=InsufficientPower, 8=NeedPluginPower)

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

@@ -682,6 +682,8 @@ export default {
       hours: 'Stunden',
       timeRemaining: '{{time}} verbleibend',
       active: 'Trocknung',
+      notRunning: 'Trocknung läuft nicht',
+      notRunningHint: 'Der Drucker hat einen Trocknungstimer gesetzt, aber der Countdown steht still und das AMS meldet keine Trocknungsphase. Der Zyklus wurde nie gestartet oder unterbrochen, zum Beispiel durch die Stromgrenze oder den laufenden Druck.',
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Trocknung nicht unterstützt',
       powerRequired: 'AMS-Netzteil anschließen, um Trocknung zu aktivieren',

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

@@ -685,6 +685,8 @@ export default {
       hours: 'hours',
       timeRemaining: '{{time}} left',
       active: 'Drying',
+      notRunning: 'Drying not running',
+      notRunningHint: 'The printer holds a drying timer, but the countdown has stopped and the AMS reports no drying phase. The cycle never started or was paused, for example by the power limit or the current print.',
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Drying not supported',
       powerRequired: 'Connect AMS power adapter to enable drying',

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

@@ -682,6 +682,8 @@ export default {
       hours: 'horas',
       timeRemaining: '{{time}} restante',
       active: 'Secando',
+      notRunning: 'El secado no está en curso',
+      notRunningHint: 'La impresora tiene un temporizador de secado, pero la cuenta atrás se ha detenido y el AMS no informa de ninguna fase de secado. El ciclo nunca empezó o se pausó, por ejemplo por el límite de potencia o por la impresión en curso.',
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Secado no compatible',
       powerRequired: 'Conecte el adaptador de corriente del AMS para activar el secado',

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

@@ -682,6 +682,8 @@ export default {
       hours: 'heures',
       timeRemaining: '{{time}} restant',
       active: 'Séchage',
+      notRunning: 'Séchage à l\'arrêt',
+      notRunningHint: 'L\'imprimante a un minuteur de séchage, mais le compte à rebours est arrêté et l\'AMS ne signale aucune phase de séchage. Le cycle n\'a jamais démarré ou a été mis en pause, par exemple par la limite d\'alimentation ou l\'impression en cours.',
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Séchage non pris en charge',
       powerRequired: 'Brancher l\'adaptateur secteur AMS pour activer le séchage',

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

@@ -682,6 +682,8 @@ export default {
       hours: 'ore',
       timeRemaining: '{{time}} rimanente',
       active: 'Essiccazione',
+      notRunning: 'Essiccazione non in corso',
+      notRunningHint: 'La stampante ha un timer di essiccazione, ma il conto alla rovescia è fermo e l\'AMS non segnala alcuna fase di essiccazione. Il ciclo non è mai partito o è stato messo in pausa, ad esempio dal limite di potenza o dalla stampa in corso.',
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Essiccazione non supportata',
       powerRequired: 'Collegare l\'alimentatore AMS per abilitare l\'asciugatura',

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

@@ -681,6 +681,8 @@ export default {
       hours: '時間',
       timeRemaining: '残り {{time}}',
       active: '乾燥中',
+      notRunning: '乾燥は実行されていません',
+      notRunningHint: 'プリンターに乾燥タイマーが設定されていますが、カウントダウンが止まっており、AMSは乾燥フェーズを報告していません。サイクルが開始されなかったか、電力制限や進行中の印刷などにより一時停止しています。',
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '乾燥非対応',
       powerRequired: 'AMS電源アダプターを接続して乾燥を有効にしてください',

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

@@ -644,6 +644,8 @@ export default {
       hours: '시간',
       timeRemaining: '{{time}} 남음',
       active: '건조 중',
+      notRunning: '건조가 진행되고 있지 않음',
+      notRunningHint: '프린터에 건조 타이머가 설정되어 있지만 카운트다운이 멈췄고 AMS가 건조 단계를 보고하지 않습니다. 사이클이 시작되지 않았거나 전력 한도나 진행 중인 출력 등으로 일시 중지되었습니다.',
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '건조 지원 안 됨',
       powerRequired: '건조를 활성화하려면 AMS 전원 어댑터를 연결하세요',

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

@@ -685,6 +685,8 @@ export default {
       hours: 'uren',
       timeRemaining: '{{time}} resterend',
       active: 'Drogen',
+      notRunning: 'Drogen loopt niet',
+      notRunningHint: 'De printer heeft een droogtimer, maar het aftellen staat stil en de AMS meldt geen droogfase. De cyclus is nooit gestart of is gepauzeerd, bijvoorbeeld door de stroomlimiet of de lopende print.',
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Drogen niet ondersteund',
       powerRequired: 'Sluit de AMS-voedingsadapter aan om drogen in te schakelen',

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

@@ -682,6 +682,8 @@ export default {
       hours: 'horas',
       timeRemaining: '{{time}} restante',
       active: 'Secagem',
+      notRunning: 'Secagem não está em andamento',
+      notRunningHint: 'A impressora tem um temporizador de secagem, mas a contagem regressiva parou e o AMS não informa nenhuma fase de secagem. O ciclo nunca começou ou foi pausado, por exemplo pelo limite de energia ou pela impressão em andamento.',
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Secagem não suportada',
       powerRequired: 'Conecte o adaptador de energia AMS para ativar a secagem',

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

@@ -649,6 +649,8 @@ export default {
       hours: "ч",
       timeRemaining: "осталось {{time}}",
       active: "Сушка",
+      notRunning: "Сушка не идёт",
+      notRunningHint: "На принтере установлен таймер сушки, но обратный отсчёт остановился, а AMS не сообщает о фазе сушки. Цикл так и не начался или был приостановлен, например из-за ограничения мощности или текущей печати.",
       targetSummary: "{{filament}} при {{temp}} °C",
       notSupported: "Сушка не поддерживается",
       powerRequired: "Подключите адаптер питания AMS, чтобы включить сушку",

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

@@ -685,6 +685,8 @@ export default {
       hours: 'timmar',
       timeRemaining: '{{time}} kvar',
       active: 'Torkar',
+      notRunning: 'Torkningen pågår inte',
+      notRunningHint: 'Skrivaren har en torkningstimer, men nedräkningen har stannat och AMS rapporterar ingen torkningsfas. Cykeln startade aldrig eller pausades, till exempel av effektgränsen eller den pågående utskriften.',
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Torkning stöds inte',
       powerRequired: 'Anslut AMS-strömadapter för att aktivera torkning',

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

@@ -682,6 +682,8 @@ export default {
       hours: 'saat',
       timeRemaining: '{{time}} kaldı',
       active: 'Kurutuluyor',
+      notRunning: 'Kurutma çalışmıyor',
+      notRunningHint: 'Yazıcıda bir kurutma zamanlayıcısı var, ancak geri sayım durdu ve AMS herhangi bir kurutma aşaması bildirmiyor. Döngü hiç başlamadı ya da örneğin güç sınırı veya devam eden baskı nedeniyle duraklatıldı.',
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: 'Kurutma desteklenmiyor',
       powerRequired: 'Kurutmayı etkinleştirmek için AMS güç adaptörünü bağlayın',

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

@@ -685,6 +685,8 @@ export default {
       hours: "години",
       timeRemaining: "Залишилося {{time}}",
       active: "Сушіння",
+      notRunning: "Сушіння не триває",
+      notRunningHint: "На принтері встановлено таймер сушіння, але зворотний відлік зупинився, а AMS не повідомляє про фазу сушіння. Цикл так і не розпочався або був призупинений, наприклад через обмеження потужності чи поточний друк.",
       targetSummary: "{{filament}} при {{temp}}°C",
       notSupported: "Сушіння не підтримується",
       powerRequired: "Підключіть адаптер живлення AMS, щоб увімкнути сушіння",

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

@@ -682,6 +682,8 @@ export default {
       hours: '小时',
       timeRemaining: '剩余 {{time}}',
       active: '干燥中',
+      notRunning: '干燥未在进行',
+      notRunningHint: '打印机设置了干燥计时器,但倒计时已停止,且 AMS 未报告任何干燥阶段。该周期从未启动,或因功率限制、当前打印等原因被暂停。',
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '不支持干燥',
       powerRequired: '连接AMS电源适配器以启用干燥',

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

@@ -682,6 +682,8 @@ export default {
       hours: '小時',
       timeRemaining: '剩餘 {{time}}',
       active: '乾燥中',
+      notRunning: '乾燥未在進行',
+      notRunningHint: '印表機設有乾燥計時器,但倒數已停止,且 AMS 未回報任何乾燥階段。該週期從未啟動,或因功率限制、目前的列印等原因被暫停。',
       targetSummary: '{{filament}} @ {{temp}}°C',
       notSupported: '不支援乾燥',
       powerRequired: '連線AMS電源介面卡以啟用乾燥',

+ 49 - 11
frontend/src/pages/PrintersPage.tsx

@@ -123,6 +123,7 @@ import {
   Info,
   Cable,
   Flame,
+  Hourglass,
   Repeat,
   Snowflake,
   Gauge,
@@ -5490,8 +5491,36 @@ function PrinterCard({
                                 </div>
                               )}
                             </div>
-                            {/* Drying status bar */}
-                            {ams.dry_time > 0 && (
+                            {/* Drying status bar. A dry_time whose countdown is not
+                                ticking, with no active drying phase (a parked command —
+                                e.g. an H2D mid-print already powering other drying — or
+                                a paused cycle), is shown as "not running", not as an
+                                active cycle: the amber badge claiming a running dry
+                                that the AMS never began is how this was found. */}
+                            {ams.dry_time > 0 && ams.dry_countdown_stalled && (
+                              <div className="flex items-center gap-2 rounded-lg bg-slate-100 dark:bg-slate-500/10 px-2 py-1 text-[length:var(--pc-t9,9px)]" title={t('printers.drying.notRunningHint')}>
+                                <Hourglass className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-slate-500 dark:text-slate-400 shrink-0" />
+                                <span className="text-slate-600 dark:text-slate-300 font-medium">{t('printers.drying.notRunning')}</span>
+                                {ams.dry_filament && (
+                                  <span className="text-slate-500/90 dark:text-slate-400/80">
+                                    {ams.dry_target_temp != null
+                                      ? t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })
+                                      : ams.dry_filament}
+                                  </span>
+                                )}
+                                {!status.drying_screen_only && (
+                                  <button
+                                    onClick={() => stopDryingMutation.mutate(ams.id)}
+                                    disabled={stopDryingMutation.isPending}
+                                    className="ml-auto text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors disabled:opacity-50"
+                                    title={t('printers.drying.stop')}
+                                  >
+                                    <X className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)]" />
+                                  </button>
+                                )}
+                              </div>
+                            )}
+                            {ams.dry_time > 0 && !ams.dry_countdown_stalled && (
                               <div className="flex items-center gap-2 rounded-lg bg-amber-50 dark:bg-amber-500/10 px-2 py-1 text-[length:var(--pc-t9,9px)]">
                                 <Flame className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-amber-600 dark:text-amber-400 shrink-0" />
                                 <span className="text-amber-700 dark:text-amber-400 font-medium">{t('printers.drying.active')}</span>
@@ -6059,22 +6088,31 @@ function PrinterCard({
                                 </div>
                               )}
                             </div>
-                            {/* HT AMS drying status bar */}
+                            {/* HT AMS drying status bar. Same not-running split as the
+                                standard-AMS bar above: a frozen countdown is not a
+                                running cycle. */}
                             {ams.dry_time > 0 && (
-                              <div className="flex items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-lg bg-amber-50 dark:bg-amber-500/10 px-2 py-1 text-[length:var(--pc-t9,9px)]">
-                                <Flame className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-amber-600 dark:text-amber-400 shrink-0" />
+                              <div className={`flex items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-lg px-2 py-1 text-[length:var(--pc-t9,9px)] ${ams.dry_countdown_stalled ? 'bg-slate-100 dark:bg-slate-500/10' : 'bg-amber-50 dark:bg-amber-500/10'}`} title={ams.dry_countdown_stalled ? t('printers.drying.notRunningHint') : undefined}>
+                                {ams.dry_countdown_stalled
+                                  ? <Hourglass className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-slate-500 dark:text-slate-400 shrink-0" />
+                                  : <Flame className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-amber-600 dark:text-amber-400 shrink-0" />}
+                                {ams.dry_countdown_stalled && (
+                                  <span className="text-slate-600 dark:text-slate-300 text-[length:var(--pc-t8,8px)] font-medium truncate">{t('printers.drying.notRunning')}</span>
+                                )}
                                 {ams.dry_filament && (
-                                  <span className="text-amber-700/80 dark:text-amber-300/70 text-[length:var(--pc-t8,8px)] truncate">
+                                  <span className={`${ams.dry_countdown_stalled ? 'text-slate-500/90 dark:text-slate-400/80' : 'text-amber-700/80 dark:text-amber-300/70'} text-[length:var(--pc-t8,8px)] truncate`}>
                                     {ams.dry_target_temp != null
                                       ? t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })
                                       : ams.dry_filament}
                                   </span>
                                 )}
-                                <span className="text-amber-700/80 dark:text-amber-300/70 text-[length:var(--pc-t8,8px)] truncate">
-                                  {ams.dry_time >= 60
-                                    ? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m`
-                                    : `${ams.dry_time}m`}
-                                </span>
+                                {!ams.dry_countdown_stalled && (
+                                  <span className="text-amber-700/80 dark:text-amber-300/70 text-[length:var(--pc-t8,8px)] truncate">
+                                    {ams.dry_time >= 60
+                                      ? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m`
+                                      : `${ams.dry_time}m`}
+                                  </span>
+                                )}
                                 {!status.drying_screen_only && (
                                   <button
                                     onClick={() => stopDryingMutation.mutate(ams.id)}

+ 2 - 1
frontend/src/utils/printer.ts

@@ -70,7 +70,8 @@ import type { PrinterStatus, PrintQueueItem } from '../api/client';
 export function isPrinterCurrentlyDispatchable(status: PrinterStatus | undefined): boolean {
   if (!status?.connected) return false;
   if (status.awaiting_plate_clear) return false;
-  if (status.ams?.some((ams) => ams.dry_time > 0)) return false;
+  // A parked drying timer (#2896) never ends, so it does not hold a print back.
+  if (status.ams?.some((ams) => ams.dry_time > 0 && !ams.dry_countdown_stalled)) return false;
   return ['IDLE', 'FINISH', 'FAILED'].includes(status.state ?? '');
 }
 

Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
static/assets/PdfPreviewModal-BbRpR5w7.js


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
static/assets/SpreadsheetPreviewModal-CsevFqb3.js


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
static/assets/index-BPfxi5P1.css


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 1
static/assets/index-DDT9pHR2.css


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
static/assets/index-RfDgRZjl.js


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/pdf-Ce2j8LB_.js


+ 1 - 1
static/index.html

@@ -28,7 +28,7 @@
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <script type="module" crossorigin src="/assets/index-82a1Eqef.js"></script>
     <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-BPfxi5P1.css">
   </head>
   <body>
     <div id="root"></div>

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.