ams_drying.py 3.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
  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. def is_drying_active(ams_data: Any) -> bool:
  21. """True when this AMS unit reports a drying cycle in progress.
  22. Two independent signals, because neither alone is sufficient. ``dry_time``
  23. is minutes remaining and reads 0 through the cooling phase that closes a
  24. cycle; ``dry_status`` covers that phase but is only present when the
  25. firmware sent a parseable ``info`` field.
  26. """
  27. if not isinstance(ams_data, Mapping):
  28. return False
  29. try:
  30. if int(ams_data.get("dry_time") or 0) > 0:
  31. return True
  32. except (TypeError, ValueError):
  33. pass # Unparseable countdown — fall through to the phase field
  34. try:
  35. return int(ams_data["dry_status"]) in ACTIVE_DRY_STATUSES
  36. except (KeyError, TypeError, ValueError):
  37. return False
  38. def temperature_alarm_suppressed(
  39. *,
  40. drying_active: bool,
  41. temperature: float | None,
  42. threshold: float,
  43. latched_at: datetime | None,
  44. now: datetime,
  45. grace_minutes: int,
  46. ) -> tuple[bool, datetime | None]:
  47. """Decide whether to hold back the AMS high-temperature alarm (#1802).
  48. Drying heats an AMS far past the alarm threshold by design — 45 C for PLA,
  49. 65 C for PETG, up to 85 C on an AMS-HT, against a default threshold of
  50. 35 C — so without this the alarm fires once an hour for the length of the
  51. cycle and keeps going while the unit cools back down.
  52. Returns ``(suppress, latched_at)``. The second element is the latch to
  53. persist: a timestamp while suppression is in force, ``None`` to clear it.
  54. Suppression is released as soon as the unit reads back at or below the
  55. threshold rather than after a fixed delay, so a 65 C cycle in a cold
  56. basement and a 45 C one in a warm room each get exactly the cool-down they
  57. need. ``grace_minutes`` only bounds the case where the unit never returns
  58. below the threshold at all — and a unit that stays that hot would have been
  59. alarming with no drying involved, so releasing there restores the ordinary
  60. behaviour instead of inventing a new alert.
  61. """
  62. if drying_active:
  63. return True, now
  64. if latched_at is None:
  65. return False, None
  66. # Back at a normal storage temperature: the cool-down is over. Note this is
  67. # also the only path that can clear the latch promptly, so it is checked
  68. # before the cap.
  69. if temperature is not None and temperature <= threshold:
  70. return False, None
  71. # ``latched_at`` is never in the future: the caller either just stamped it
  72. # with this ``now`` or read it back through a loader that clamps. A future
  73. # stamp would make this difference negative and hold suppression for the
  74. # skew on top of the cap, which is why the clamp lives at the read.
  75. if now - latched_at >= timedelta(minutes=grace_minutes):
  76. return False, None
  77. return True, latched_at