diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py
index cfe34b80e..d3b65de0e 100644
--- a/backend/app/api/routes/printers.py
+++ b/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 ""),
diff --git a/backend/app/main.py b/backend/app/main.py
index 84a2fb89f..de8b6869a 100644
--- a/backend/app/main.py
+++ b/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
diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py
index 3af5c1fd0..8acaa213b 100644
--- a/backend/app/schemas/printer.py
+++ b/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)
diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py
index 7b42760eb..b1af6d104 100644
--- a/backend/app/services/bambu_mqtt.py
+++ b/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:
diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py
index 14eec0146..288eb5000 100644
--- a/backend/app/services/print_scheduler.py
+++ b/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
diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py
index 08c6e6688..aad005068 100644
--- a/backend/app/services/printer_manager.py
+++ b/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),
diff --git a/backend/app/utils/ams_drying.py b/backend/app/utils/ams_drying.py
index b8a7365ee..12709de60 100644
--- a/backend/app/utils/ams_drying.py
+++ b/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,
diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py
index 8491ca512..4d3743434 100644
--- a/backend/tests/unit/services/test_bambu_mqtt.py
+++ b/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."""
diff --git a/backend/tests/unit/services/test_printer_manager.py b/backend/tests/unit/services/test_printer_manager.py
index 1e08e6375..d85e00e24 100644
--- a/backend/tests/unit/services/test_printer_manager.py
+++ b/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 = {
diff --git a/backend/tests/unit/test_ams_alarm_gating.py b/backend/tests/unit/test_ams_alarm_gating.py
index 4ef97d4d4..63f491197 100644
--- a/backend/tests/unit/test_ams_alarm_gating.py
+++ b/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
diff --git a/backend/tests/unit/test_scheduler_pinned_waiting_reason_3074.py b/backend/tests/unit/test_scheduler_pinned_waiting_reason_3074.py
index 444bb79bb..cbc89d9c7 100644
--- a/backend/tests/unit/test_scheduler_pinned_waiting_reason_3074.py
+++ b/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."""
diff --git a/backend/tests/unit/test_scheduler_scheduled_drying.py b/backend/tests/unit/test_scheduler_scheduled_drying.py
index a1076dd29..ec4ff18eb 100644
--- a/backend/tests/unit/test_scheduler_scheduled_drying.py
+++ b/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
diff --git a/backend/tests/unit/test_status_broadcast_ams_slot_config.py b/backend/tests/unit/test_status_broadcast_ams_slot_config.py
index ffc1eb524..9bb95c8c4 100644
--- a/backend/tests/unit/test_status_broadcast_ams_slot_config.py
+++ b/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
diff --git a/frontend/src/__tests__/components/PdfPreviewModal.test.tsx b/frontend/src/__tests__/components/PdfPreviewModal.test.tsx
index 91d0dd166..7cbc64ffd 100644
--- a/frontend/src/__tests__/components/PdfPreviewModal.test.tsx
+++ b/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 () => {
diff --git a/frontend/src/__tests__/pages/PrintersPageDryingFeedback.test.tsx b/frontend/src/__tests__/pages/PrintersPageDryingFeedback.test.tsx
index c3e05eaf7..70ce29d1f 100644
--- a/frontend/src/__tests__/pages/PrintersPageDryingFeedback.test.tsx
+++ b/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(