Files
bambuddy/backend/tests/integration/test_ams_drying_latch_persistence.py
maziggy 7a9b4921bd 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.
2026-08-16 11:45:46 +02:00

137 lines
6.2 KiB
Python

"""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)}