ams_drying.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. """Shared reading of the firmware's own AMS drying state.
  2. Kept as a leaf module on purpose. ``drying_preflight`` would be the natural
  3. home, but it imports ``printer_manager``, which imports ``bambu_mqtt`` — and
  4. ``bambu_mqtt`` is one of the callers here, so putting these there would close an
  5. import cycle. Nothing in this module imports from the app.
  6. """
  7. from collections.abc import Mapping
  8. from datetime import datetime, timedelta
  9. from typing import Any
  10. # ``dry_status`` is bits 4-7 of the per-AMS ``info`` hex string (BambuStudio
  11. # DevFilaSystem.cpp): 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping,
  12. # 5=Error, 6=HeatOutOfControl, 7=PrdTesting. Only the first three mean a cycle
  13. # is still live.
  14. #
  15. # 4 (Stopping) and 5 (Error) are excluded because the cycle is over or ending.
  16. # 6 (HeatOutOfControl) is excluded deliberately and for a different reason: an
  17. # AMS that has lost thermal control is exactly when a high-temperature alarm
  18. # should still reach the user, so it must never read as "expected heat".
  19. ACTIVE_DRY_STATUSES = frozenset({1, 2, 3}) # Checking, Drying, Cooling
  20. # A live drying countdown ticks down once per minute. A ``dry_time`` that has
  21. # not changed for this long — with no active ``dry_status`` phase vouching for
  22. # the cycle — is a timer with no cycle running behind it. Usually one that never
  23. # started (seen on an H2D mid-print with two AMS-HT cycles already active: the
  24. # third unit's timer froze at its full duration and no heating ever began), but a
  25. # cycle paused partway reads the same. 150 s allows two full missed ticks plus
  26. # jitter before the countdown is called stalled.
  27. DRY_COUNTDOWN_STALL_SECONDS = 150
  28. def is_drying_active(ams_data: Any) -> bool:
  29. """True when this AMS unit reports a drying cycle in progress.
  30. Two independent signals, because neither alone is sufficient. ``dry_time``
  31. is minutes remaining and reads 0 through the cooling phase that closes a
  32. cycle; ``dry_status`` covers that phase but is only present when the
  33. firmware sent a parseable ``info`` field.
  34. A parked countdown (``dry_countdown_stalled``, see is_countdown_parked) is
  35. not a cycle in progress: the AMS is not heating, so nothing it reports is
  36. "expected heat" from drying.
  37. """
  38. if not isinstance(ams_data, Mapping):
  39. return False
  40. if ams_data.get("dry_countdown_stalled"):
  41. return False
  42. try:
  43. if int(ams_data.get("dry_time") or 0) > 0:
  44. return True
  45. except (TypeError, ValueError):
  46. pass # Unparseable countdown — fall through to the phase field
  47. try:
  48. return int(ams_data["dry_status"]) in ACTIVE_DRY_STATUSES
  49. except (KeyError, TypeError, ValueError):
  50. return False
  51. def is_countdown_parked(ams_data: Any) -> bool:
  52. """True when this AMS unit holds a drying timer that is not running.
  53. The MQTT layer sets ``dry_countdown_stalled`` once ``dry_time`` has stayed
  54. unchanged past DRY_COUNTDOWN_STALL_SECONDS with no active ``dry_status``
  55. phase. Such a timer never reaches 0 on its own, so anything that waits for
  56. drying to finish must not wait on it.
  57. """
  58. if not isinstance(ams_data, Mapping) or not ams_data.get("dry_countdown_stalled"):
  59. return False
  60. try:
  61. return int(ams_data.get("dry_time") or 0) > 0
  62. except (TypeError, ValueError):
  63. return False
  64. def temperature_alarm_suppressed(
  65. *,
  66. drying_active: bool,
  67. temperature: float | None,
  68. threshold: float,
  69. latched_at: datetime | None,
  70. now: datetime,
  71. grace_minutes: int,
  72. ) -> tuple[bool, datetime | None]:
  73. """Decide whether to hold back the AMS high-temperature alarm (#1802).
  74. Drying heats an AMS far past the alarm threshold by design — 45 C for PLA,
  75. 65 C for PETG, up to 85 C on an AMS-HT, against a default threshold of
  76. 35 C — so without this the alarm fires once an hour for the length of the
  77. cycle and keeps going while the unit cools back down.
  78. Returns ``(suppress, latched_at)``. The second element is the latch to
  79. persist: a timestamp while suppression is in force, ``None`` to clear it.
  80. Suppression is released as soon as the unit reads back at or below the
  81. threshold rather than after a fixed delay, so a 65 C cycle in a cold
  82. basement and a 45 C one in a warm room each get exactly the cool-down they
  83. need. ``grace_minutes`` only bounds the case where the unit never returns
  84. below the threshold at all — and a unit that stays that hot would have been
  85. alarming with no drying involved, so releasing there restores the ordinary
  86. behaviour instead of inventing a new alert.
  87. """
  88. if drying_active:
  89. return True, now
  90. if latched_at is None:
  91. return False, None
  92. # Back at a normal storage temperature: the cool-down is over. Note this is
  93. # also the only path that can clear the latch promptly, so it is checked
  94. # before the cap.
  95. if temperature is not None and temperature <= threshold:
  96. return False, None
  97. # ``latched_at`` is never in the future: the caller either just stamped it
  98. # with this ``now`` or read it back through a loader that clamps. A future
  99. # stamp would make this difference negative and hold suppression for the
  100. # skew on top of the cap, which is why the clamp lives at the read.
  101. if now - latched_at >= timedelta(minutes=grace_minutes):
  102. return False, None
  103. return True, latched_at