test_auto_drying_parked_3478.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. """Auto-drying and a countdown the firmware never runs (#3478).
  2. An AMS HT took a drying command mid-print and left the heater off: dry_time
  3. sat at 720 with dry_status Off for a week. The MQTT layer flags that as
  4. dry_countdown_stalled, but auto-drying treated any dry_time > 0 as a running
  5. cycle, so the unit was never dried again. Now a parked timer is not drying: a
  6. cycle Bambuddy armed that parks is closed out and counted on its own (it says
  7. nothing about humidity), and two in a row suspend the unit with a message that
  8. says the printer did not heat.
  9. """
  10. import time
  11. from unittest.mock import AsyncMock, MagicMock, patch
  12. import pytest
  13. from backend.app.services.print_scheduler import (
  14. AUTO_DRY_MAX_NEVER_HEATED_CYCLES,
  15. AUTO_DRY_REARM_COOLDOWN_SECONDS,
  16. PrintScheduler,
  17. )
  18. UNIT = (1, 128)
  19. WET = 75
  20. DRY = 50
  21. def _state(dry_time: int, stalled: bool, humidity: int = WET):
  22. state = MagicMock()
  23. state.raw_data = {
  24. "ams": [
  25. {
  26. "id": 128,
  27. "module_type": "n3s",
  28. "dry_time": dry_time,
  29. "dry_status": 0,
  30. "dry_countdown_stalled": stalled,
  31. "humidity_raw": str(humidity),
  32. "dry_sf_reason": [],
  33. "tray": [{"tray_type": "PLA"}],
  34. }
  35. ]
  36. }
  37. state.firmware_version = "01.09.00.00"
  38. return state
  39. def _setting(value):
  40. s = MagicMock()
  41. s.value = value
  42. return s
  43. def _db():
  44. settings = {
  45. "queue_drying_enabled": _setting("false"),
  46. "ambient_drying_enabled": _setting("true"),
  47. "ams_humidity_fair": _setting("60"),
  48. "queue_drying_block": _setting("false"),
  49. }
  50. async def execute(stmt):
  51. result = MagicMock()
  52. try:
  53. params = list(stmt.compile().params.values())
  54. except Exception:
  55. params = []
  56. for key, val in settings.items():
  57. if key in params:
  58. result.scalar_one_or_none.return_value = val
  59. return result
  60. if "printer" in str(stmt).lower():
  61. printer = MagicMock()
  62. printer.id = 1
  63. printer.is_active = True
  64. scalars = MagicMock()
  65. scalars.__iter__ = MagicMock(return_value=iter([printer]))
  66. result.scalars.return_value = scalars
  67. else:
  68. result.scalar_one_or_none.return_value = None
  69. return result
  70. db = AsyncMock()
  71. db.execute = AsyncMock(side_effect=execute)
  72. return db
  73. @pytest.fixture
  74. def env():
  75. with (
  76. patch("backend.app.services.print_scheduler.printer_manager") as pm,
  77. patch("backend.app.services.print_scheduler.notification_service") as notify,
  78. patch("backend.app.services.print_scheduler.supports_drying", return_value=True),
  79. ):
  80. pm.is_connected.return_value = True
  81. pm.get_model.return_value = "P2S"
  82. pm.send_drying_command.return_value = True
  83. notify.on_ams_drying_suspended = AsyncMock()
  84. scheduler = PrintScheduler()
  85. scheduler._is_printer_idle = MagicMock(return_value=True)
  86. db = _db()
  87. async def run(dry_time, stalled, humidity=WET):
  88. pm.get_status.return_value = _state(dry_time, stalled, humidity)
  89. await scheduler._check_auto_drying(db, [], set())
  90. yield scheduler, pm, notify, run
  91. def _age_out_cooldown(scheduler):
  92. scheduler._auto_dry_units[UNIT]["ended_at"] -= AUTO_DRY_REARM_COOLDOWN_SECONDS + 1
  93. async def _parked_cycle(run):
  94. """Arm, see the command taken, then see the stall detector flag it."""
  95. await run(0, False)
  96. await run(720, False)
  97. await run(720, True)
  98. class TestParkedTimerIsNotDrying:
  99. @pytest.mark.asyncio
  100. async def test_a_parked_timer_bambuddy_did_not_start_gets_a_start(self, env):
  101. """The reporter's unit: parked at 720, humidity above the threshold."""
  102. scheduler, pm, _notify, run = env
  103. await run(720, True)
  104. pm.send_drying_command.assert_called_once_with(1, 128, 45, 12, mode=1, filament="PLA")
  105. assert scheduler._auto_dry_units[UNIT]["running"] is True
  106. @pytest.mark.asyncio
  107. async def test_a_timer_not_yet_called_parked_still_runs(self, env):
  108. _scheduler, pm, _notify, run = env
  109. await run(720, False)
  110. pm.send_drying_command.assert_not_called()
  111. @pytest.mark.asyncio
  112. async def test_a_parked_cycle_is_closed_counted_apart_and_cools_off(self, env):
  113. scheduler, pm, _notify, run = env
  114. await _parked_cycle(run)
  115. unit = scheduler._auto_dry_units[UNIT]
  116. assert "running" not in unit
  117. assert unit["never_heated"] == 1
  118. assert unit.get("unproductive", 0) == 0
  119. assert "best_end_humidity" not in unit
  120. assert isinstance(unit["ended_at"], float)
  121. await run(720, True)
  122. assert pm.send_drying_command.call_count == 1
  123. @pytest.mark.asyncio
  124. async def test_parked_cycles_suspend_with_the_not_heating_message(self, env):
  125. scheduler, pm, notify, run = env
  126. for _ in range(AUTO_DRY_MAX_NEVER_HEATED_CYCLES - 1):
  127. await _parked_cycle(run)
  128. _age_out_cooldown(scheduler)
  129. # The next start goes out on top of the parked timer and parks again.
  130. await run(720, True)
  131. await run(720, True)
  132. assert pm.send_drying_command.call_count == AUTO_DRY_MAX_NEVER_HEATED_CYCLES
  133. unit = scheduler._auto_dry_units[UNIT]
  134. assert unit["suspended"] is True
  135. notify.on_ams_drying_suspended.assert_awaited_once()
  136. assert notify.on_ams_drying_suspended.await_args.kwargs["never_heated"] is True
  137. _age_out_cooldown(scheduler)
  138. await run(720, True)
  139. assert pm.send_drying_command.call_count == AUTO_DRY_MAX_NEVER_HEATED_CYCLES
  140. @pytest.mark.asyncio
  141. async def test_a_cycle_that_runs_resets_the_parked_count(self, env):
  142. scheduler, pm, notify, run = env
  143. await _parked_cycle(run)
  144. _age_out_cooldown(scheduler)
  145. # This one counts down and finishes.
  146. await run(720, True)
  147. await run(719, False)
  148. await run(0, False)
  149. assert "never_heated" not in scheduler._auto_dry_units[UNIT]
  150. notify.on_ams_drying_suspended.assert_not_awaited()
  151. @pytest.mark.asyncio
  152. async def test_dry_humidity_lifts_a_not_heating_suspension(self, env):
  153. scheduler, _pm, _notify, run = env
  154. scheduler._auto_dry_units[UNIT] = {"suspended": True, "never_heated": 2, "ended_at": None}
  155. await run(0, False, humidity=DRY)
  156. unit = scheduler._auto_dry_units[UNIT]
  157. assert not unit.get("suspended")
  158. assert "never_heated" not in unit
  159. class TestSyncDryingState:
  160. def test_a_printer_with_only_a_parked_timer_is_not_drying(self):
  161. scheduler = PrintScheduler()
  162. scheduler._drying_in_progress[1] = time.monotonic()
  163. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  164. pm.get_status.return_value = _state(720, True)
  165. scheduler._sync_drying_state()
  166. assert 1 not in scheduler._drying_in_progress
  167. def test_a_running_timer_keeps_the_printer_drying(self):
  168. scheduler = PrintScheduler()
  169. scheduler._drying_in_progress[1] = time.monotonic()
  170. with patch("backend.app.services.print_scheduler.printer_manager") as pm:
  171. pm.get_status.return_value = _state(700, False)
  172. scheduler._sync_drying_state()
  173. assert 1 in scheduler._drying_in_progress
  174. class TestNotification:
  175. @pytest.mark.asyncio
  176. @pytest.mark.parametrize(
  177. ("never_heated", "template"),
  178. [(False, "ams_drying_suspended"), (True, "ams_drying_not_heating")],
  179. )
  180. async def test_picks_the_template_on_the_same_switch(self, never_heated, template):
  181. from backend.app.services.notification_service import NotificationService
  182. service = NotificationService()
  183. with (
  184. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as get,
  185. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  186. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as build,
  187. ):
  188. get.return_value = [MagicMock()]
  189. build.return_value = ("t", "m")
  190. await service.on_ams_drying_suspended(
  191. 1, "P2S", "HT-A", 75.0, 60.0, 2, AsyncMock(), never_heated=never_heated
  192. )
  193. assert get.await_args.args[1] == "on_ams_drying_suspended"
  194. assert build.await_args.args[1] == template