Prechádzať zdrojové kódy

Stop the AMS temperature alert firing for heat the user asked for (#1802)

The alert compares against ams_temp_fair, the same threshold that colours
the printer card, which defaults to 35C. Drying deliberately runs at 45C
for PLA, 65C for PETG and up to 85C on an AMS-HT, and the alert repeats
once an hour for as long as the condition holds, so a twelve-hour dry
sent twelve notifications about a temperature the user chose. It then
kept sending them while the unit cooled back down, which is the half the
reporter confirmed on an AMS 2 Pro and an H2C.

Dispatch now consults the drying state the firmware already reports.
dry_time alone is not enough: it reads 0 through the cooling phase that
closes a cycle, so dry_status -- info bits 4-7, already parsed for the
drying-complete edge -- carries the rest. That constant moves out of
bambu_mqtt into a leaf util rather than being duplicated; drying_preflight
would have been the natural home, but it imports printer_manager, which
imports bambu_mqtt, and bambu_mqtt is one of the callers.

The cool-down afterwards is held by a latch released as soon as the unit
reads back at or below the threshold, rather than after a fixed delay, so
a 65C cycle in a cold basement and a 45C one in a warm room each get the
time they actually need. A two-hour cap bounds the one case the latch
cannot resolve on its own -- a unit that never returns below the
threshold -- and since such a unit would have been alarming with no
drying involved, releasing there restores the ordinary behaviour instead
of inventing a new alert.

Two exclusions are deliberate. Humidity is untouched, because during
drying that reading falling is the whole point. And dry_status 6,
HeatOutOfControl, is kept out of the active set: an AMS that has lost
thermal control is exactly when the alert should still arrive, so it must
never read as expected heat.

A cycle plus its cool-down outlasts a restart, so the latch is a settings
row rather than a dict beside _ams_alarm_cooldown -- the internal
timestamp-row pattern support.py already uses. It is read once per pass
and written back only when a unit changed it. Stamps ahead of now are
clamped on read, since a box whose clock jumps backwards writes them and
suppression is measured as now minus the stamp; without the clamp the cap
would measure from a moment that has not happened yet and hold the alert
quiet for the skew on top of it.

No new setting. The reporter was offered the opt-out checkbox they asked
for and said they would not want it if the alert simply never fired
during drying.
maziggy 3 týždňov pred
rodič
commit
7a9b4921bd

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
 ### Fixed
 ### Fixed
+- **Drying an AMS no longer sends an hourly "temperature high" alert for the whole cycle (#1802)** — The AMS temperature alert compares against the same threshold that colours the printer card, which defaults to 35 C, while drying deliberately runs at 45 C for PLA, 65 C for PETG and up to 85 C on an AMS-HT. The alert repeats once an hour for as long as the condition holds, so a twelve-hour dry sent twelve notifications about a temperature you asked for, and then kept sending them while the unit cooled back down. The alert is now held back for the length of a cycle and through the cool-down that follows it, using the drying state the firmware already reports rather than anything you have to configure. Suppression lifts as soon as the unit reads back at or below your threshold, so a 65 C cycle in a cold basement and a 45 C one in a warm room each get exactly the cool-down they need instead of a fixed guess. Two things are deliberately left alone: the humidity alert, which is the one you want during drying because the number falling is the point, and a unit reporting `HeatOutOfControl`, where an AMS that has lost thermal control is precisely when the alert should still reach you. Because a cycle plus its cool-down can outlast a restart, the suppression is stored rather than held in memory. No new setting — the alert simply stops firing for heat you asked for. Wiki updated. Covered by backend tests.
 - **Closing the bug-report panel no longer throws the capture away and leaves the logs running (#2847)** — Step 2 of the report flow asks you to reproduce the problem, and the panel sits over the part of the app you have to reach to do it. Closing it was the obvious move and it was the wrong one twice over. Reopening put you back on an empty step 1 — while the server was still logging at DEBUG, with nothing left in the flow that could stop it, because only **Stop & Submit** ever did. Leave it closed instead and the five-minute cap eventually fired behind your back: logging stopped and the report was filed with no window open and no confirmation that it had happened. Which of the two you got depended only on whether you reopened the panel inside five minutes. A capture is now a thing that outlives the panel. Closing keeps it running and says so — the bug button turns amber for as long as a capture is going, and clicking it returns you to step 2 with your description, your screenshot and the elapsed timer where you left them. If the cap does fire while the panel is closed, the panel reopens so the submission happens in front of you rather than behind you. The timer is measured against the capture's start time rather than counted in ticks, so a background tab, where browsers throttle timers hard, no longer stretches five minutes into something else. A capture also survives a page reload, which matters because reloading is a perfectly ordinary step in reproducing a bug: the report picks it back up where it was. One that outlived the cap while nobody was watching is not resumed and not filed — a description written an hour ago is not a report you are still expecting — but the log level is put back, which is the part that previously stayed wrong indefinitely. Translated in all locales; wiki updated. Covered by frontend tests.
 - **Closing the bug-report panel no longer throws the capture away and leaves the logs running (#2847)** — Step 2 of the report flow asks you to reproduce the problem, and the panel sits over the part of the app you have to reach to do it. Closing it was the obvious move and it was the wrong one twice over. Reopening put you back on an empty step 1 — while the server was still logging at DEBUG, with nothing left in the flow that could stop it, because only **Stop & Submit** ever did. Leave it closed instead and the five-minute cap eventually fired behind your back: logging stopped and the report was filed with no window open and no confirmation that it had happened. Which of the two you got depended only on whether you reopened the panel inside five minutes. A capture is now a thing that outlives the panel. Closing keeps it running and says so — the bug button turns amber for as long as a capture is going, and clicking it returns you to step 2 with your description, your screenshot and the elapsed timer where you left them. If the cap does fire while the panel is closed, the panel reopens so the submission happens in front of you rather than behind you. The timer is measured against the capture's start time rather than counted in ticks, so a background tab, where browsers throttle timers hard, no longer stretches five minutes into something else. A capture also survives a page reload, which matters because reloading is a perfectly ordinary step in reproducing a bug: the report picks it back up where it was. One that outlived the cap while nobody was watching is not resumed and not filed — a description written an hour ago is not a report you are still expecting — but the log level is put back, which is the part that previously stayed wrong indefinitely. Translated in all locales; wiki updated. Covered by frontend tests.
 - **The File Manager's card menu no longer loses its top entry (#2846)** — In grid view a file card's action menu was drawn inside the card, and the card clipped anything its children painted outside it. A card is as tall as its square thumbnail plus whatever metadata the file has, so an STL — which has none beyond a name and a size — produced the shortest card in the library, about 270px against a seven-entry menu that needs closer to 310px. The difference was one row, and the row it took was the top one: **Slice**, since **Print** is only offered for a file that is already sliced. A 3MF carries a target model and a print count, two more rows, and its card was tall enough, which is why the button appeared there and looked like a file-type rule rather than a layout accident. Nothing about STL was special; the shortest card simply lost the first item, whichever it happened to be. The menu now opens against the viewport, the way the archive card's menu already did, so no card can crop it, and the card no longer clips its own children. List view was never affected — it has no menu, only inline buttons. Covered by a frontend test.
 - **The File Manager's card menu no longer loses its top entry (#2846)** — In grid view a file card's action menu was drawn inside the card, and the card clipped anything its children painted outside it. A card is as tall as its square thumbnail plus whatever metadata the file has, so an STL — which has none beyond a name and a size — produced the shortest card in the library, about 270px against a seven-entry menu that needs closer to 310px. The difference was one row, and the row it took was the top one: **Slice**, since **Print** is only offered for a file that is already sliced. A 3MF carries a target model and a print count, two more rows, and its card was tall enough, which is why the button appeared there and looked like a file-type rule rather than a layout accident. Nothing about STL was special; the shortest card simply lost the first item, whichever it happened to be. The menu now opens against the viewport, the way the archive card's menu already did, so no card can crop it, and the card no longer clips its own children. List view was never affected — it has no menu, only inline buttons. Covered by a frontend test.
 
 

+ 112 - 1
backend/app/main.py

@@ -128,6 +128,7 @@ from backend.app.services.spoolman_tracking import (
     store_print_data as _store_spoolman_print_data,
     store_print_data as _store_spoolman_print_data,
 )
 )
 from backend.app.services.tasmota import tasmota_service
 from backend.app.services.tasmota import tasmota_service
+from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
 
 
 
 
 # =============================================================================
 # =============================================================================
@@ -6845,6 +6846,86 @@ _ams_cleanup_counter = 0  # Track recordings to trigger periodic cleanup
 _ams_alarm_cooldown: dict[str, datetime] = {}
 _ams_alarm_cooldown: dict[str, datetime] = {}
 AMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour
 AMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour
 
 
+# Per-AMS "drying was live at" latch that suppresses the high-temperature alarm
+# through a cycle and the cool-down after it (#1802). Stored in the settings
+# table rather than alongside _ams_alarm_cooldown above, because a restart
+# partway through a cool-down would otherwise resume alarming about heat the
+# user asked for — the same internal-timestamp-row pattern as
+# support.py's debug_logging_enabled_at.
+AMS_DRYING_LATCH_KEY = "ams_drying_alarm_latch"
+
+# Upper bound on that suppression. The latch normally clears as soon as the unit
+# reads at or below the threshold; see utils.ams_drying for why this cap only
+# matters when it never does.
+AMS_DRYING_GRACE_MINUTES = 120
+
+
+async def _load_ams_drying_latch(db) -> dict[str, datetime]:
+    """Read the persisted per-AMS drying latch, dropping entries out of window.
+
+    Anything older than the grace cap would expire on its next visit anyway, so
+    discarding it here costs nothing and stops rows for deleted printers from
+    accumulating.
+
+    Stamps ahead of now get two defences, because a box whose clock jumps
+    backwards (a Pi with no RTC coming up before NTP) writes them: wildly future
+    ones are discarded outright, and the rest are clamped to now. Without the
+    clamp the cap would measure from a moment that has not happened yet and hold
+    the alarm quiet for the skew on top of the cap. One unnecessary notification
+    after a clock jump is a far better failure than an alarm silently disabled
+    for hours.
+    """
+    from backend.app.models.settings import Settings
+
+    result = await db.execute(select(Settings).where(Settings.key == AMS_DRYING_LATCH_KEY))
+    setting = result.scalar_one_or_none()
+    if not setting or not setting.value:
+        return {}
+    try:
+        raw = json.loads(setting.value)
+    except (ValueError, TypeError):
+        return {}  # Corrupted row → no latch, alarms behave as they did before
+    if not isinstance(raw, dict):
+        return {}
+
+    now = datetime.now(timezone.utc)
+    window = timedelta(minutes=AMS_DRYING_GRACE_MINUTES)
+    latch: dict[str, datetime] = {}
+    for key, value in raw.items():
+        try:
+            stamp = datetime.fromisoformat(str(value))
+        except (ValueError, TypeError):
+            continue
+        if stamp.tzinfo is None:
+            stamp = stamp.replace(tzinfo=timezone.utc)
+        if not (now - window <= stamp <= now + window):
+            continue
+        # Nothing may sit in the future: suppression is measured as now minus
+        # the stamp, so a stamp ahead of now would extend it by the skew on top
+        # of the cap. Clamping the survivors keeps the cap an actual cap.
+        latch[str(key)] = min(stamp, now)
+    return latch
+
+
+async def _save_ams_drying_latch(db, latch: dict[str, datetime]) -> None:
+    """Persist the latch, writing only when it actually changed.
+
+    Adds the session change but does not commit — the caller's own commit
+    carries it, so the latch lands in the same transaction as the sensor rows
+    that produced it.
+    """
+    from backend.app.models.settings import Settings
+
+    payload = json.dumps({key: stamp.isoformat() for key, stamp in sorted(latch.items())})
+    result = await db.execute(select(Settings).where(Settings.key == AMS_DRYING_LATCH_KEY))
+    setting = result.scalar_one_or_none()
+    if setting is None:
+        # Don't create the row on installs that never dry anything.
+        if payload != "{}":
+            db.add(Settings(key=AMS_DRYING_LATCH_KEY, value=payload))
+    elif setting.value != payload:
+        setting.value = payload
+
 
 
 def _ams_has_filament(ams_data: dict) -> bool:
 def _ams_has_filament(ams_data: dict) -> bool:
     """True if this AMS unit has at least one tray slot holding filament.
     """True if this AMS unit has at least one tray slot holding filament.
@@ -6933,6 +7014,11 @@ async def record_ams_history():
                     except (ValueError, TypeError):
                     except (ValueError, TypeError):
                         pass  # Invalid JSON → no overrides, fall through to global threshold
                         pass  # Invalid JSON → no overrides, fall through to global threshold
 
 
+                # Per-AMS drying latch (#1802), loaded once per pass and written
+                # back below only if a unit changed it.
+                drying_latch = await _load_ams_drying_latch(db)
+                drying_latch_before = dict(drying_latch)
+
                 recorded_count = 0
                 recorded_count = 0
                 for printer in printers:
                 for printer in printers:
                     # Get current state from printer manager
                     # Get current state from printer manager
@@ -7050,8 +7136,30 @@ async def record_ams_history():
                                 except Exception as e:
                                 except Exception as e:
                                     logger.warning("Failed to send humidity alarm: %s", e)
                                     logger.warning("Failed to send humidity alarm: %s", e)
 
 
+                        # A drying cycle heats the unit far past ams_temp_fair on
+                        # purpose — 45 C for PLA, 65 C for PETG, 85 C on an
+                        # AMS-HT, against a 35 C default — so the alarm fired
+                        # once an hour for the whole cycle and kept firing while
+                        # the unit cooled back down (#1802). Latch on the
+                        # firmware's own drying state and hold until the reading
+                        # returns to normal. Humidity is deliberately left alone:
+                        # it falls during drying, which is the whole point.
+                        latch_key = f"{printer.id}:{ams_id}"
+                        suppress_temp_alarm, new_latch = temperature_alarm_suppressed(
+                            drying_active=is_drying_active(ams_data),
+                            temperature=temperature,
+                            threshold=temp_threshold,
+                            latched_at=drying_latch.get(latch_key),
+                            now=datetime.now(timezone.utc),
+                            grace_minutes=AMS_DRYING_GRACE_MINUTES,
+                        )
+                        if new_latch is None:
+                            drying_latch.pop(latch_key, None)
+                        else:
+                            drying_latch[latch_key] = new_latch
+
                         # Check temperature alarm (only if above threshold)
                         # Check temperature alarm (only if above threshold)
-                        if temperature is not None and temperature > temp_threshold:
+                        if temperature is not None and temperature > temp_threshold and not suppress_temp_alarm:
                             cooldown_key = f"{printer.id}:{ams_id}:temperature"
                             cooldown_key = f"{printer.id}:{ams_id}:temperature"
                             last_alarm = _ams_alarm_cooldown.get(cooldown_key)
                             last_alarm = _ams_alarm_cooldown.get(cooldown_key)
                             now = datetime.now(timezone.utc)
                             now = datetime.now(timezone.utc)
@@ -7076,6 +7184,9 @@ async def record_ams_history():
                                 except Exception as e:
                                 except Exception as e:
                                     logger.warning("Failed to send temperature alarm: %s", e)
                                     logger.warning("Failed to send temperature alarm: %s", e)
 
 
+                if drying_latch != drying_latch_before:
+                    await _save_ams_drying_latch(db, drying_latch)
+
                 await db.commit()
                 await db.commit()
                 if recorded_count > 0:
                 if recorded_count > 0:
                     logger.info("Recorded %s AMS sensor history entries", recorded_count)
                     logger.info("Recorded %s AMS sensor history entries", recorded_count)

+ 2 - 7
backend/app/services/bambu_mqtt.py

@@ -22,6 +22,7 @@ from datetime import datetime, timezone
 import paho.mqtt.client as mqtt
 import paho.mqtt.client as mqtt
 
 
 from backend.app.services.hms_actions import HMSAction, get_actions_for_error_code
 from backend.app.services.hms_actions import HMSAction, get_actions_for_error_code
+from backend.app.utils.ams_drying import ACTIVE_DRY_STATUSES
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -40,12 +41,6 @@ _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
 # printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
 # printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
 _ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
 _ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
 
 
-# AMS dry_status phases (info bits 4-7) in which a drying cycle is still live, so
-# a dry_time of 0 alongside one of them is a transient rather than a completion
-# (#2759). 0=Off, 4=Stopping and 5=Error all mean the cycle is over or ending and
-# are deliberately excluded — those SHOULD end it.
-_ACTIVE_DRY_STATUSES = frozenset({1, 2, 3})  # Checking, Drying, Cooling
-
 # A drying cycle that runs to term ends with its countdown all but exhausted, so
 # A drying cycle that runs to term ends with its countdown all but exhausted, so
 # the last dry_time we saw before the drop to 0 tells us whether the firmware
 # the last dry_time we saw before the drop to 0 tells us whether the firmware
 # ended the cycle on schedule or aborted it. More than this many minutes still on
 # ended the cycle on schedule or aborted it. More than this many minutes still on
@@ -3240,7 +3235,7 @@ class BambuMQTTClient:
             # schedules smart-plug auto-off. dry_status comes from the same info
             # schedules smart-plug auto-off. dry_status comes from the same info
             # hex parsed above; when it is absent we let the edge through, so a
             # hex parsed above; when it is absent we let the edge through, so a
             # firmware that never reports one still ends its cycles.
             # firmware that never reports one still ends its cycles.
-            if current == 0 and ams_unit.get("dry_status") in _ACTIVE_DRY_STATUSES:
+            if current == 0 and ams_unit.get("dry_status") in ACTIVE_DRY_STATUSES:
                 # Leave the remembered value alone, exactly as the absent-
                 # Leave the remembered value alone, exactly as the absent-
                 # dry_time skip above does: whichever push ends the cycle for
                 # dry_time skip above does: whichever push ends the cycle for
                 # real must still see a non-zero previous.
                 # real must still see a non-zero previous.

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

@@ -0,0 +1,88 @@
+"""Shared reading of the firmware's own AMS drying state.
+
+Kept as a leaf module on purpose. ``drying_preflight`` would be the natural
+home, but it imports ``printer_manager``, which imports ``bambu_mqtt`` — and
+``bambu_mqtt`` is one of the callers here, so putting these there would close an
+import cycle. Nothing in this module imports from the app.
+"""
+
+from collections.abc import Mapping
+from datetime import datetime, timedelta
+from typing import Any
+
+# ``dry_status`` is bits 4-7 of the per-AMS ``info`` hex string (BambuStudio
+# DevFilaSystem.cpp): 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping,
+# 5=Error, 6=HeatOutOfControl, 7=PrdTesting. Only the first three mean a cycle
+# is still live.
+#
+# 4 (Stopping) and 5 (Error) are excluded because the cycle is over or ending.
+# 6 (HeatOutOfControl) is excluded deliberately and for a different reason: an
+# AMS that has lost thermal control is exactly when a high-temperature alarm
+# should still reach the user, so it must never read as "expected heat".
+ACTIVE_DRY_STATUSES = frozenset({1, 2, 3})  # Checking, Drying, Cooling
+
+
+def is_drying_active(ams_data: Any) -> bool:
+    """True when this AMS unit reports a drying cycle in progress.
+
+    Two independent signals, because neither alone is sufficient. ``dry_time``
+    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.
+    """
+    if not isinstance(ams_data, Mapping):
+        return False
+    try:
+        if int(ams_data.get("dry_time") or 0) > 0:
+            return True
+    except (TypeError, ValueError):
+        pass  # Unparseable countdown — fall through to the phase field
+    try:
+        return int(ams_data["dry_status"]) in ACTIVE_DRY_STATUSES
+    except (KeyError, TypeError, ValueError):
+        return False
+
+
+def temperature_alarm_suppressed(
+    *,
+    drying_active: bool,
+    temperature: float | None,
+    threshold: float,
+    latched_at: datetime | None,
+    now: datetime,
+    grace_minutes: int,
+) -> tuple[bool, datetime | None]:
+    """Decide whether to hold back the AMS high-temperature alarm (#1802).
+
+    Drying heats an AMS far past the alarm threshold by design — 45 C for PLA,
+    65 C for PETG, up to 85 C on an AMS-HT, against a default threshold of
+    35 C — so without this the alarm fires once an hour for the length of the
+    cycle and keeps going while the unit cools back down.
+
+    Returns ``(suppress, latched_at)``. The second element is the latch to
+    persist: a timestamp while suppression is in force, ``None`` to clear it.
+
+    Suppression is released as soon as the unit reads back at or below the
+    threshold rather than after a fixed delay, so a 65 C cycle in a cold
+    basement and a 45 C one in a warm room each get exactly the cool-down they
+    need. ``grace_minutes`` only bounds the case where the unit never returns
+    below the threshold at all — and a unit that stays that hot would have been
+    alarming with no drying involved, so releasing there restores the ordinary
+    behaviour instead of inventing a new alert.
+    """
+    if drying_active:
+        return True, now
+    if latched_at is None:
+        return False, None
+    # Back at a normal storage temperature: the cool-down is over. Note this is
+    # also the only path that can clear the latch promptly, so it is checked
+    # before the cap.
+    if temperature is not None and temperature <= threshold:
+        return False, None
+    # ``latched_at`` is never in the future: the caller either just stamped it
+    # with this ``now`` or read it back through a loader that clamps. A future
+    # stamp would make this difference negative and hold suppression for the
+    # skew on top of the cap, which is why the clamp lives at the read.
+    if now - latched_at >= timedelta(minutes=grace_minutes):
+        return False, None
+    return True, latched_at

+ 136 - 0
backend/tests/integration/test_ams_drying_latch_persistence.py

@@ -0,0 +1,136 @@
+"""The AMS drying latch has to survive a backend restart (#1802).
+
+Suppression of the high-temperature alarm spans a drying cycle plus the
+cool-down after it, which together can run well over twelve hours. Holding that
+purely in memory — as the sibling ``_ams_alarm_cooldown`` dict does — meant any
+restart partway through resumed alarming about heat the user asked for, so the
+latch is stored in the settings table instead.
+"""
+
+import json
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.main import (
+    AMS_DRYING_GRACE_MINUTES,
+    AMS_DRYING_LATCH_KEY,
+    _load_ams_drying_latch,
+    _save_ams_drying_latch,
+)
+from backend.app.models.settings import Settings
+
+
+async def _stored_value(db_session) -> str | None:
+    result = await db_session.execute(select(Settings).where(Settings.key == AMS_DRYING_LATCH_KEY))
+    setting = result.scalar_one_or_none()
+    return setting.value if setting else None
+
+
+@pytest.mark.asyncio
+class TestAmsDryingLatchPersistence:
+    async def test_round_trip_survives_a_reload(self, db_session):
+        stamp = datetime.now(timezone.utc) - timedelta(minutes=10)
+        await _save_ams_drying_latch(db_session, {"1:0": stamp})
+        await db_session.commit()
+
+        # A fresh load is what a restarted backend does on its first pass.
+        assert await _load_ams_drying_latch(db_session) == {"1:0": stamp}
+
+    async def test_no_row_created_when_nothing_ever_dries(self, db_session):
+        await _save_ams_drying_latch(db_session, {})
+        await db_session.commit()
+        assert await _stored_value(db_session) is None
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_existing_row_is_updated_not_duplicated(self, db_session):
+        first = datetime.now(timezone.utc) - timedelta(minutes=30)
+        second = datetime.now(timezone.utc)
+        await _save_ams_drying_latch(db_session, {"1:0": first})
+        await db_session.commit()
+        await _save_ams_drying_latch(db_session, {"1:0": second})
+        await db_session.commit()
+
+        result = await db_session.execute(select(Settings).where(Settings.key == AMS_DRYING_LATCH_KEY))
+        assert len(result.scalars().all()) == 1
+        assert await _load_ams_drying_latch(db_session) == {"1:0": second}
+
+    async def test_clearing_the_latch_empties_the_row(self, db_session):
+        await _save_ams_drying_latch(db_session, {"1:0": datetime.now(timezone.utc)})
+        await db_session.commit()
+        await _save_ams_drying_latch(db_session, {})
+        await db_session.commit()
+
+        assert await _stored_value(db_session) == "{}"
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_multiple_units_are_tracked_independently(self, db_session):
+        now = datetime.now(timezone.utc)
+        latch = {"1:0": now - timedelta(minutes=5), "1:1": now, "2:128": now - timedelta(minutes=15)}
+        await _save_ams_drying_latch(db_session, latch)
+        await db_session.commit()
+        assert await _load_ams_drying_latch(db_session) == latch
+
+    async def test_entries_past_the_grace_cap_are_dropped_on_load(self, db_session):
+        now = datetime.now(timezone.utc)
+        fresh = now - timedelta(minutes=5)
+        stale = now - timedelta(minutes=AMS_DRYING_GRACE_MINUTES + 30)
+        await _save_ams_drying_latch(db_session, {"1:0": fresh, "9:3": stale})
+        await db_session.commit()
+
+        # The stale one would expire on its next visit anyway; dropping it here
+        # keeps rows for deleted printers from accumulating forever.
+        assert await _load_ams_drying_latch(db_session) == {"1:0": fresh}
+
+    async def test_wildly_future_stamps_are_dropped(self, db_session):
+        # A box whose clock jumps backwards (a Pi coming up before NTP) would
+        # otherwise hold the alarm suppressed until real time caught up.
+        future = datetime.now(timezone.utc) + timedelta(hours=6)
+        await _save_ams_drying_latch(db_session, {"1:0": future})
+        await db_session.commit()
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_near_future_stamps_are_clamped_to_now(self, db_session):
+        # Small backwards skew survives as a latch, but must not sit ahead of
+        # now: suppression is measured as now minus the stamp, so a future one
+        # would run for the skew on top of the cap instead of the cap alone.
+        before = datetime.now(timezone.utc)
+        await _save_ams_drying_latch(db_session, {"1:0": before + timedelta(minutes=30)})
+        await db_session.commit()
+
+        loaded = await _load_ams_drying_latch(db_session)
+        assert set(loaded) == {"1:0"}
+        assert before <= loaded["1:0"] <= datetime.now(timezone.utc)
+
+    async def test_corrupt_row_reads_as_no_latch(self, db_session):
+        db_session.add(Settings(key=AMS_DRYING_LATCH_KEY, value="{not json"))
+        await db_session.commit()
+        # Degrades to the pre-#1802 behaviour rather than crashing the recorder.
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_non_object_json_reads_as_no_latch(self, db_session):
+        db_session.add(Settings(key=AMS_DRYING_LATCH_KEY, value="[1, 2, 3]"))
+        await db_session.commit()
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_unparseable_stamps_are_skipped_individually(self, db_session):
+        good = datetime.now(timezone.utc) - timedelta(minutes=3)
+        db_session.add(
+            Settings(
+                key=AMS_DRYING_LATCH_KEY,
+                value=json.dumps({"1:0": good.isoformat(), "1:1": "yesterday"}),
+            )
+        )
+        await db_session.commit()
+        assert await _load_ams_drying_latch(db_session) == {"1:0": good}
+
+    async def test_naive_stamps_are_read_as_utc(self, db_session):
+        # SQLite hands back naive datetimes elsewhere in the app, so a hand-edited
+        # or migrated value without an offset must not raise on comparison.
+        naive = (datetime.now(timezone.utc) - timedelta(minutes=7)).replace(tzinfo=None)
+        db_session.add(Settings(key=AMS_DRYING_LATCH_KEY, value=json.dumps({"1:0": naive.isoformat()})))
+        await db_session.commit()
+
+        loaded = await _load_ams_drying_latch(db_session)
+        assert loaded == {"1:0": naive.replace(tzinfo=timezone.utc)}

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

@@ -1,4 +1,11 @@
-"""Tests for the empty-AMS alarm gate (#1619).
+"""Tests for the gates that hold back AMS humidity / temperature alarms.
+
+Two independent gates, both sitting in ``record_ams_history``'s dispatch: the
+empty-AMS gate (#1619) documented below, and the drying gate (#1802) that stops
+the temperature alarm firing throughout a drying cycle and the cool-down after
+it.
+
+Empty-AMS alarm gate (#1619).
 
 
 Empty AMS units still emit humidity/temperature sensor readings, but those
 Empty AMS units still emit humidity/temperature sensor readings, but those
 readings are ambient and not actionable — there's no filament to dry. Without
 readings are ambient and not actionable — there's no filament to dry. Without
@@ -8,7 +15,10 @@ array's ``tray_type`` strings) so the alarm dispatch in ``record_ams_history``
 can skip empty units while still alarming on loaded ones in the same printer.
 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
 from backend.app.main import _ams_has_filament
+from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
 
 
 
 
 class TestAmsHasFilament:
 class TestAmsHasFilament:
@@ -80,3 +90,183 @@ class TestAmsHasFilament:
         assert _ams_has_filament(loaded) is True
         assert _ams_has_filament(loaded) is True
         empty_int = {"tray_exist_bits": 0xED}  # no tray array, int ignored
         empty_int = {"tray_exist_bits": 0xED}  # no tray array, int ignored
         assert _ams_has_filament(empty_int) is False
         assert _ams_has_filament(empty_int) is False
+
+
+class TestIsDryingActive:
+    """The two firmware signals that mean "a drying cycle is running" (#1802)."""
+
+    def test_countdown_running_is_active(self):
+        assert is_drying_active({"dry_time": 720}) is True
+        # Strings appear in some payload shapes.
+        assert is_drying_active({"dry_time": "45"}) is True
+
+    def test_idle_unit_is_not_active(self):
+        assert is_drying_active({"dry_time": 0, "dry_status": 0}) is False
+        assert is_drying_active({}) is False
+
+    def test_cooling_phase_counts_as_active(self):
+        # The reason dry_time alone is not enough: the cycle's own cooling phase
+        # runs with the countdown already at 0.
+        assert is_drying_active({"dry_time": 0, "dry_status": 3}) is True
+
+    def test_checking_and_drying_phases_count_as_active(self):
+        assert is_drying_active({"dry_time": 0, "dry_status": 1}) is True
+        assert is_drying_active({"dry_time": 0, "dry_status": 2}) is True
+
+    def test_ending_phases_do_not_count_as_active(self):
+        # 4=Stopping, 5=Error — the cycle is over or aborting.
+        assert is_drying_active({"dry_time": 0, "dry_status": 4}) is False
+        assert is_drying_active({"dry_time": 0, "dry_status": 5}) is False
+
+    def test_heat_out_of_control_is_not_active(self):
+        # 6=HeatOutOfControl is the one phase where a high-temperature alarm is
+        # exactly what the user needs, so it must never read as expected heat.
+        assert is_drying_active({"dry_time": 0, "dry_status": 6}) is False
+
+    def test_missing_dry_status_falls_back_to_countdown(self):
+        # Firmware that never sends a parseable `info` has no dry_status at all.
+        assert is_drying_active({"dry_time": 30}) is True
+        assert is_drying_active({"dry_time": 0}) 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
+        assert is_drying_active({"dry_time": "junk", "dry_status": "junk"}) is False
+
+    def test_non_mapping_input_is_not_active(self):
+        assert is_drying_active(None) is False
+        assert is_drying_active("drying") is False
+        assert is_drying_active(42) is False
+
+
+class TestTemperatureAlarmSuppressed:
+    """Latch behaviour for the AMS high-temperature alarm during drying (#1802)."""
+
+    NOW = datetime(2026, 8, 16, 12, 0, tzinfo=timezone.utc)
+    GRACE = 120
+
+    def _call(self, **overrides):
+        kwargs = {
+            "drying_active": False,
+            "temperature": 50.0,
+            "threshold": 35.0,
+            "latched_at": None,
+            "now": self.NOW,
+            "grace_minutes": self.GRACE,
+        }
+        kwargs.update(overrides)
+        return temperature_alarm_suppressed(**kwargs)
+
+    def test_no_drying_no_latch_alarms_normally(self):
+        # The pre-#1802 behaviour has to survive untouched for units that never dry.
+        suppress, latch = self._call(temperature=40.0)
+        assert suppress is False
+        assert latch is None
+
+    def test_drying_suppresses_and_sets_latch(self):
+        suppress, latch = self._call(drying_active=True, temperature=65.0)
+        assert suppress is True
+        assert latch == self.NOW
+
+    def test_drying_latches_even_when_below_threshold(self):
+        # Early in a cycle the unit is still heating up. The latch has to be set
+        # then too, or the cool-down afterwards starts unprotected.
+        suppress, latch = self._call(drying_active=True, temperature=28.0)
+        assert suppress is True
+        assert latch == self.NOW
+
+    def test_still_hot_after_cycle_stays_suppressed(self):
+        # The reported symptom: alarms kept arriving while the unit cooled.
+        suppress, latch = self._call(
+            temperature=52.0,
+            latched_at=self.NOW - timedelta(minutes=20),
+        )
+        assert suppress is True
+        assert latch == self.NOW - timedelta(minutes=20)
+
+    def test_cooled_back_to_normal_clears_latch(self):
+        suppress, latch = self._call(
+            temperature=34.0,
+            latched_at=self.NOW - timedelta(minutes=40),
+        )
+        assert suppress is False
+        assert latch is None
+
+    def test_exactly_at_threshold_counts_as_cooled(self):
+        # The alarm itself fires on `> threshold`, so `== threshold` is not hot.
+        suppress, latch = self._call(
+            temperature=35.0,
+            latched_at=self.NOW - timedelta(minutes=40),
+        )
+        assert suppress is False
+        assert latch is None
+
+    def test_alarms_again_after_the_latch_is_cleared(self):
+        # Having cooled once, a later genuine overheat is not swallowed.
+        _, latch = self._call(temperature=34.0, latched_at=self.NOW - timedelta(minutes=40))
+        suppress, latch = self._call(temperature=48.0, latched_at=latch)
+        assert suppress is False
+        assert latch is None
+
+    def test_grace_cap_releases_a_unit_that_never_cools(self):
+        # A unit stuck above the threshold would have alarmed with no drying
+        # involved, so the cap restores that rather than inventing an alert.
+        suppress, latch = self._call(
+            temperature=45.0,
+            latched_at=self.NOW - timedelta(minutes=self.GRACE + 1),
+        )
+        assert suppress is False
+        assert latch is None
+
+    def test_grace_cap_boundary_releases(self):
+        suppress, _ = self._call(
+            temperature=45.0,
+            latched_at=self.NOW - timedelta(minutes=self.GRACE),
+        )
+        assert suppress is False
+
+    def test_just_inside_the_grace_cap_still_suppresses(self):
+        suppress, _ = self._call(
+            temperature=45.0,
+            latched_at=self.NOW - timedelta(minutes=self.GRACE - 1),
+        )
+        assert suppress is True
+
+    def test_a_new_cycle_refreshes_the_latch(self):
+        # Starting a second dry inside the grace window must restart the clock,
+        # otherwise the cap could expire midway through the new cycle.
+        suppress, latch = self._call(
+            drying_active=True,
+            temperature=60.0,
+            latched_at=self.NOW - timedelta(minutes=self.GRACE - 5),
+        )
+        assert suppress is True
+        assert latch == self.NOW
+
+    def test_unreadable_temperature_holds_the_latch(self):
+        # A dropped reading is not evidence the unit cooled, and there is no
+        # alarm to fire on this pass anyway.
+        suppress, latch = self._call(
+            temperature=None,
+            latched_at=self.NOW - timedelta(minutes=10),
+        )
+        assert suppress is True
+        assert latch == self.NOW - timedelta(minutes=10)
+
+    def test_the_cap_is_measured_from_the_latch(self):
+        # Guards the precondition the loader's clamp exists to maintain: with a
+        # non-future latch, suppression expires exactly one cap after it, so the
+        # cap is a real bound rather than a floor. A future latch would push the
+        # release out by the skew as well, which is why the clamp is at the read
+        # — see _load_ams_drying_latch and its persistence tests.
+        latched = self.NOW - timedelta(minutes=self.GRACE)
+        suppress, latch = temperature_alarm_suppressed(
+            drying_active=False,
+            temperature=45.0,
+            threshold=35.0,
+            latched_at=latched,
+            now=self.NOW,
+            grace_minutes=self.GRACE,
+        )
+        assert suppress is False
+        assert latch is None