Files
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

89 lines
3.9 KiB
Python

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