test_scheduler_auto_drying.py 70 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731
  1. """Tests for the auto-drying feature in the print scheduler.
  2. Covers:
  3. - Conservative drying parameter selection (mixed filaments)
  4. - Drying preset loading (user-configured vs defaults)
  5. - Auto-drying lifecycle: start, humidity stop, minimum drying time
  6. - Auto-drying stop conditions: feature disabled, no scheduled items, per-printer
  7. - Sync drying state after restart
  8. """
  9. import time
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from backend.app.services.print_scheduler import (
  13. AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
  14. AUTO_DRY_REARM_COOLDOWN_SECONDS,
  15. PrintScheduler,
  16. )
  17. class TestConservativeDryingParams:
  18. """Test _get_conservative_drying_params — picks safest temp/duration for mixed filaments."""
  19. @pytest.fixture
  20. def scheduler(self):
  21. return PrintScheduler()
  22. def test_single_filament_pla(self, scheduler):
  23. """Single PLA tray uses PLA preset."""
  24. trays = [{"tray_type": "PLA"}]
  25. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  26. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  27. assert result == (45, 12, "PLA")
  28. def test_mixed_filaments_lowest_temp(self, scheduler):
  29. """Mixed PLA + ABS: should use PLA's 45°C (lowest), ABS's 12h (longest for n3f)."""
  30. trays = [{"tray_type": "PLA"}, {"tray_type": "ABS"}]
  31. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  32. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  33. temp, hours, _ = result
  34. assert temp == 45 # PLA is lowest
  35. assert hours == 12
  36. def test_mixed_filaments_longest_duration(self, scheduler):
  37. """Mixed ABS (8h) + PVA (18h) on n3s: should use longest duration."""
  38. trays = [{"tray_type": "ABS"}, {"tray_type": "PVA"}]
  39. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  40. result = scheduler._get_conservative_drying_params(trays, "n3s", presets)
  41. temp, hours, _ = result
  42. assert temp == 80 # ABS n3s=80, PVA n3s=85 → lowest=80
  43. assert hours == 18 # ABS n3s_hours=8, PVA n3s_hours=18 → longest=18
  44. def test_empty_trays_returns_none(self, scheduler):
  45. """No loaded trays returns None."""
  46. result = scheduler._get_conservative_drying_params([], "n3f", PrintScheduler.DEFAULT_DRYING_PRESETS)
  47. assert result is None
  48. def test_unknown_filament_skipped(self, scheduler):
  49. """Unknown filament types are ignored."""
  50. trays = [{"tray_type": "EXOTIC_WOOD"}]
  51. result = scheduler._get_conservative_drying_params(trays, "n3f", PrintScheduler.DEFAULT_DRYING_PRESETS)
  52. assert result is None
  53. def test_filament_type_normalization(self, scheduler):
  54. """'PLA Basic' should normalize to 'PLA'."""
  55. trays = [{"tray_type": "PLA Basic"}]
  56. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  57. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  58. assert result is not None
  59. assert result[0] == 45 # PLA temp
  60. def test_empty_tray_type_skipped(self, scheduler):
  61. """Trays with empty tray_type are skipped."""
  62. trays = [{"tray_type": ""}, {"tray_type": "PETG"}]
  63. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  64. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  65. assert result is not None
  66. assert result[2] == "PETG"
  67. def test_n3s_uses_n3s_keys(self, scheduler):
  68. """AMS-HT (n3s) should use n3s temp and n3s_hours."""
  69. trays = [{"tray_type": "TPU"}]
  70. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  71. result = scheduler._get_conservative_drying_params(trays, "n3s", presets)
  72. assert result == (75, 18, "TPU") # n3s=75, n3s_hours=18
  73. def test_n3f_uses_n3f_keys(self, scheduler):
  74. """AMS 2 Pro (n3f) should use n3f temp and n3f_hours."""
  75. trays = [{"tray_type": "TPU"}]
  76. presets = PrintScheduler.DEFAULT_DRYING_PRESETS
  77. result = scheduler._get_conservative_drying_params(trays, "n3f", presets)
  78. assert result == (65, 12, "TPU") # n3f=65, n3f_hours=12
  79. def test_custom_presets(self, scheduler):
  80. """Custom presets override defaults."""
  81. trays = [{"tray_type": "PLA"}]
  82. custom = {"PLA": {"n3f": 50, "n3s": 50, "n3f_hours": 6, "n3s_hours": 6}}
  83. result = scheduler._get_conservative_drying_params(trays, "n3f", custom)
  84. assert result == (50, 6, "PLA")
  85. class TestDryingPresets:
  86. """Test _get_drying_presets — loads user presets from DB or falls back to defaults."""
  87. @pytest.fixture
  88. def scheduler(self):
  89. return PrintScheduler()
  90. @pytest.mark.asyncio
  91. async def test_default_presets_when_no_setting(self, scheduler):
  92. """Returns built-in defaults when no DB setting exists."""
  93. db = AsyncMock()
  94. result_mock = MagicMock()
  95. result_mock.scalar_one_or_none.return_value = None
  96. db.execute = AsyncMock(return_value=result_mock)
  97. presets = await scheduler._get_drying_presets(db)
  98. assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
  99. @pytest.mark.asyncio
  100. async def test_user_presets_from_db(self, scheduler):
  101. """Returns user-configured presets when saved in DB."""
  102. db = AsyncMock()
  103. setting = MagicMock()
  104. setting.value = '{"PLA": {"n3f": 50, "n3s": 50, "n3f_hours": 6, "n3s_hours": 6}}'
  105. result_mock = MagicMock()
  106. result_mock.scalar_one_or_none.return_value = setting
  107. db.execute = AsyncMock(return_value=result_mock)
  108. presets = await scheduler._get_drying_presets(db)
  109. assert presets["PLA"]["n3f"] == 50
  110. @pytest.mark.asyncio
  111. async def test_invalid_json_falls_back(self, scheduler):
  112. """Invalid JSON in DB falls back to defaults."""
  113. db = AsyncMock()
  114. setting = MagicMock()
  115. setting.value = "not valid json{{"
  116. result_mock = MagicMock()
  117. result_mock.scalar_one_or_none.return_value = setting
  118. db.execute = AsyncMock(return_value=result_mock)
  119. presets = await scheduler._get_drying_presets(db)
  120. assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
  121. @pytest.mark.asyncio
  122. async def test_empty_string_falls_back(self, scheduler):
  123. """Empty string in DB falls back to defaults."""
  124. db = AsyncMock()
  125. setting = MagicMock()
  126. setting.value = ""
  127. result_mock = MagicMock()
  128. result_mock.scalar_one_or_none.return_value = setting
  129. db.execute = AsyncMock(return_value=result_mock)
  130. presets = await scheduler._get_drying_presets(db)
  131. assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS
  132. class TestSyncDryingState:
  133. """Test _sync_drying_state — syncs in-memory state with actual printer status."""
  134. @pytest.fixture
  135. def scheduler(self):
  136. return PrintScheduler()
  137. @patch("backend.app.services.print_scheduler.printer_manager")
  138. def test_removes_stopped_printers(self, mock_pm, scheduler):
  139. """Printers that stopped drying are removed from tracking."""
  140. scheduler._drying_in_progress = {1: time.monotonic()}
  141. state = MagicMock()
  142. state.raw_data = {"ams": [{"dry_time": 0}]}
  143. mock_pm.get_status.return_value = state
  144. scheduler._sync_drying_state()
  145. assert 1 not in scheduler._drying_in_progress
  146. @patch("backend.app.services.print_scheduler.printer_manager")
  147. def test_keeps_active_printers(self, mock_pm, scheduler):
  148. """Printers still drying remain in tracking."""
  149. ts = time.monotonic()
  150. scheduler._drying_in_progress = {1: ts}
  151. state = MagicMock()
  152. state.raw_data = {"ams": [{"dry_time": 120}]}
  153. mock_pm.get_status.return_value = state
  154. scheduler._sync_drying_state()
  155. assert scheduler._drying_in_progress[1] == ts
  156. @patch("backend.app.services.print_scheduler.printer_manager")
  157. def test_removes_disconnected_printers(self, mock_pm, scheduler):
  158. """Disconnected printers are removed from tracking."""
  159. scheduler._drying_in_progress = {1: time.monotonic()}
  160. mock_pm.get_status.return_value = None
  161. scheduler._sync_drying_state()
  162. assert 1 not in scheduler._drying_in_progress
  163. class TestPlateHoldDoesNotGateDrying:
  164. """#2801 — an unacknowledged plate must not stop the AMS heating.
  165. Plate-clear answers "is the bed ready for the next job". It says nothing
  166. about whether filament may be dried, and the gap between a finished print
  167. and the acknowledgment is exactly when drying is most useful: the printer
  168. is free and nobody is waiting on it. Leaving the plate unacknowledged is
  169. also how people hold the queue by hand.
  170. Before this, such a printer landed in the dispatch set, was read as
  171. "currently printing", took the mid-print path -- capped temperature,
  172. (mid-print) in the log -- and bypassed the very gate that was meant to
  173. hold it, while the queue loop tore the cycle down once a tick.
  174. """
  175. @pytest.fixture
  176. def scheduler(self):
  177. return PrintScheduler()
  178. @staticmethod
  179. def _finished_printer_state():
  180. state = MagicMock()
  181. state.state = "FINISH"
  182. state.firmware_version = "01.03.00.00"
  183. state.raw_data = {
  184. "ams": [
  185. {
  186. "id": 0,
  187. "module_type": "n3f",
  188. "dry_time": 0,
  189. "humidity_raw": "75",
  190. "dry_sf_reason": [],
  191. "tray": [{"tray_type": "PLA"}],
  192. }
  193. ]
  194. }
  195. return state
  196. def _db(self):
  197. db = AsyncMock()
  198. db.execute = AsyncMock(
  199. side_effect=TestAmbientDrying._make_db_side_effect(
  200. {
  201. "queue_drying_enabled": TestAmbientDrying._make_setting("false"),
  202. "ambient_drying_enabled": TestAmbientDrying._make_setting("true"),
  203. "print_drying_enabled": TestAmbientDrying._make_setting("true"),
  204. "ams_humidity_fair": TestAmbientDrying._make_setting("60"),
  205. "queue_drying_block": TestAmbientDrying._make_setting("false"),
  206. "drying_presets": None,
  207. }
  208. )
  209. )
  210. return db
  211. @pytest.mark.asyncio
  212. @patch("backend.app.services.print_scheduler.printer_manager")
  213. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  214. async def test_finished_printer_with_dirty_plate_dries_at_full_temperature(self, mock_sd, mock_pm, scheduler):
  215. mock_pm.get_status.return_value = self._finished_printer_state()
  216. mock_pm.is_connected.return_value = True
  217. mock_pm.get_model.return_value = "P2S"
  218. mock_pm.send_drying_command.return_value = True
  219. scheduler._is_printer_idle = MagicMock(return_value=True)
  220. await scheduler._check_auto_drying(self._db(), [], set())
  221. # 45 degC is the uncapped PLA preset: mid-print would have sent 40.
  222. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  223. @pytest.mark.asyncio
  224. @patch("backend.app.services.print_scheduler.printer_manager")
  225. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  226. async def test_idleness_is_judged_without_the_plate_gate(self, mock_sd, mock_pm, scheduler):
  227. mock_pm.get_status.return_value = self._finished_printer_state()
  228. mock_pm.is_connected.return_value = True
  229. mock_pm.get_model.return_value = "P2S"
  230. mock_pm.send_drying_command.return_value = True
  231. scheduler._is_printer_idle = MagicMock(return_value=True)
  232. await scheduler._check_auto_drying(self._db(), [], set())
  233. scheduler._is_printer_idle.assert_called_with(1, require_plate_clear=False)
  234. @pytest.mark.asyncio
  235. @patch("backend.app.services.print_scheduler.printer_manager")
  236. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  237. async def test_a_printer_about_to_print_is_still_left_alone(self, mock_sd, mock_pm, scheduler):
  238. """The narrow set keeps its job: an imminent print must not be dried into."""
  239. mock_pm.get_status.return_value = self._finished_printer_state()
  240. mock_pm.is_connected.return_value = True
  241. mock_pm.get_model.return_value = "P2S"
  242. mock_pm.send_drying_command.return_value = True
  243. scheduler._is_printer_idle = MagicMock(return_value=True)
  244. await scheduler._check_auto_drying(self._db(), [], {1})
  245. assert not mock_pm.send_drying_command.called
  246. class TestStopDrying:
  247. """Test _stop_drying — sends stop commands and clears tracking."""
  248. @pytest.fixture
  249. def scheduler(self):
  250. return PrintScheduler()
  251. @pytest.mark.asyncio
  252. @patch("backend.app.services.print_scheduler.printer_manager")
  253. async def test_stops_all_ams_units(self, mock_pm, scheduler):
  254. """Sends stop command to each auto-armed AMS unit that is drying."""
  255. scheduler._drying_in_progress = {1: time.monotonic()}
  256. scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
  257. scheduler._auto_dry_units[(1, 128)] = {"ended_at": None}
  258. state = MagicMock()
  259. state.raw_data = {
  260. "ams": [
  261. {"id": 0, "dry_time": 120},
  262. {"id": 1, "dry_time": 0},
  263. {"id": 128, "dry_time": 60},
  264. ]
  265. }
  266. mock_pm.get_status.return_value = state
  267. await scheduler._stop_drying(1)
  268. # Should send stop to AMS 0 and 128, not AMS 1
  269. calls = mock_pm.send_drying_command.call_args_list
  270. assert len(calls) == 2
  271. assert calls[0].args == (1, 0, 0, 0)
  272. assert calls[1].args == (1, 128, 0, 0)
  273. assert 1 not in scheduler._drying_in_progress
  274. @pytest.mark.asyncio
  275. @patch("backend.app.services.print_scheduler.printer_manager")
  276. async def test_leaves_cycles_bambuddy_did_not_start(self, mock_pm, scheduler):
  277. """A hand-started dry on another unit survives (#2801).
  278. One auto-dried unit used to be enough to stop every AMS on the
  279. printer reporting dry_time > 0, which took the user's own cycle with
  280. it. The entry gate only ever knew about cycles Bambuddy began; the
  281. action now matches.
  282. """
  283. scheduler._drying_in_progress = {1: time.monotonic()}
  284. scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
  285. state = MagicMock()
  286. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}, {"id": 1, "dry_time": 600}]}
  287. mock_pm.get_status.return_value = state
  288. await scheduler._stop_drying(1)
  289. calls = mock_pm.send_drying_command.call_args_list
  290. assert [c.args[1] for c in calls] == [0]
  291. @pytest.mark.asyncio
  292. @patch("backend.app.services.print_scheduler.printer_manager")
  293. async def test_stops_nothing_it_cannot_prove_it_started(self, mock_pm, scheduler):
  294. """After a restart Bambuddy cannot tell its own cycle from a manual one.
  295. _sync_drying_state prunes but never adopts, for exactly this reason, so
  296. a cycle armed before the restart is left running rather than risking a
  297. stop on somebody's manual dry. Tracking is still cleared.
  298. """
  299. scheduler._drying_in_progress = {1: time.monotonic()}
  300. state = MagicMock()
  301. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  302. mock_pm.get_status.return_value = state
  303. await scheduler._stop_drying(1)
  304. assert not mock_pm.send_drying_command.called
  305. assert 1 not in scheduler._drying_in_progress
  306. @pytest.mark.asyncio
  307. @patch("backend.app.services.print_scheduler.printer_manager")
  308. async def test_clears_tracking_when_no_state(self, mock_pm, scheduler):
  309. """Clears tracking when printer has no state (disconnected)."""
  310. scheduler._drying_in_progress = {1: time.monotonic()}
  311. mock_pm.get_status.return_value = None
  312. await scheduler._stop_drying(1)
  313. assert 1 not in scheduler._drying_in_progress
  314. class TestMinimumDryingTime:
  315. """Regression #1892: a running drying cycle must never be stopped by a humidity re-check.
  316. Relative humidity reads low in heated air (the AMS sensor sees ~15-20% within
  317. minutes of the dryer starting even while the filament is still saturated), so a
  318. humidity-based auto-stop would truncate every cycle — manual or Bambuddy-started —
  319. to the old minimum-time floor. Drying is now left to run to its configured
  320. duration; the firmware stops it when the duration elapses.
  321. """
  322. @pytest.fixture
  323. def scheduler(self):
  324. return PrintScheduler()
  325. @pytest.mark.asyncio
  326. @patch("backend.app.services.print_scheduler.printer_manager")
  327. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  328. async def test_no_stop_before_minimum_time(self, mock_sd, mock_pm, scheduler):
  329. """Drying should NOT stop when humidity drops below threshold shortly after start."""
  330. # Simulate: drying started 5 minutes ago
  331. scheduler._drying_in_progress = {1: time.monotonic() - 300}
  332. state = MagicMock()
  333. state.raw_data = {
  334. "ams": [
  335. {
  336. "id": 0,
  337. "module_type": "n3f",
  338. "dry_time": 600,
  339. "humidity_raw": "18",
  340. "dry_sf_reason": [],
  341. "tray": [{"tray_type": "PLA"}],
  342. }
  343. ]
  344. }
  345. state.firmware_version = "01.09.00.00"
  346. mock_pm.get_status.return_value = state
  347. mock_pm.is_connected.return_value = True
  348. mock_pm.get_model.return_value = "X1C"
  349. # Mock _is_printer_idle and DB
  350. scheduler._is_printer_idle = MagicMock(return_value=True)
  351. db = AsyncMock()
  352. # Mock settings: enabled, threshold=21
  353. settings_returns = {
  354. "queue_drying_enabled": self._make_setting("true"),
  355. "ams_humidity_fair": self._make_setting("21"),
  356. "queue_drying_block": self._make_setting("false"),
  357. "drying_presets": None,
  358. }
  359. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))
  360. # Queue item with schedule
  361. item = MagicMock()
  362. item.printer_id = 1
  363. item.scheduled_time = MagicMock() # Has a schedule
  364. item.manual_start = False
  365. await scheduler._check_auto_drying(db, [item], set())
  366. # Should NOT have sent stop command via humidity check — minimum time not elapsed
  367. # The only calls should NOT include the humidity-based stop
  368. for call in mock_pm.send_drying_command.call_args_list:
  369. # If any stop was called, it should NOT be from the humidity path
  370. # (humidity path uses keyword args: temp=0, duration=0, mode=0)
  371. assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
  372. "Humidity-based stop should not fire before minimum drying time"
  373. )
  374. @pytest.mark.asyncio
  375. @patch("backend.app.services.print_scheduler.printer_manager")
  376. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  377. async def test_no_stop_after_long_elapsed_time(self, mock_sd, mock_pm, scheduler):
  378. """#1892: drying must NOT stop even long after start with low humidity — let it run."""
  379. # Simulate: drying started 35 minutes ago, humidity reads low (heated air)
  380. scheduler._drying_in_progress = {1: time.monotonic() - 2100}
  381. state = MagicMock()
  382. state.raw_data = {
  383. "ams": [
  384. {
  385. "id": 0,
  386. "module_type": "n3f",
  387. "dry_time": 600,
  388. "humidity_raw": "18",
  389. "dry_sf_reason": [],
  390. "tray": [{"tray_type": "PLA"}],
  391. }
  392. ]
  393. }
  394. state.firmware_version = "01.09.00.00"
  395. mock_pm.get_status.return_value = state
  396. mock_pm.is_connected.return_value = True
  397. mock_pm.get_model.return_value = "X1C"
  398. scheduler._is_printer_idle = MagicMock(return_value=True)
  399. db = AsyncMock()
  400. settings_returns = {
  401. "queue_drying_enabled": self._make_setting("true"),
  402. "ams_humidity_fair": self._make_setting("21"),
  403. "queue_drying_block": self._make_setting("false"),
  404. "drying_presets": None,
  405. }
  406. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))
  407. item = MagicMock()
  408. item.printer_id = 1
  409. item.scheduled_time = MagicMock()
  410. item.manual_start = False
  411. await scheduler._check_auto_drying(db, [item], set())
  412. # Must NOT send a humidity-based stop — drying is left to run to its duration
  413. for call in mock_pm.send_drying_command.call_args_list:
  414. assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
  415. "Humidity re-check must never stop a running drying cycle (#1892)"
  416. )
  417. @staticmethod
  418. def _make_setting(value):
  419. s = MagicMock()
  420. s.value = value
  421. return s
  422. @staticmethod
  423. def _make_db_side_effect(settings_map, printer_id=1):
  424. """Create a side_effect for db.execute that returns settings and printers."""
  425. async def side_effect(stmt):
  426. result = MagicMock()
  427. stmt_str = str(stmt)
  428. # Extract bind parameter values (SQLAlchemy uses :key_1 placeholders)
  429. try:
  430. compiled = stmt.compile(compile_kwargs={"literal_binds": False})
  431. param_values = list(compiled.params.values())
  432. except Exception:
  433. param_values = []
  434. # Match settings queries by checking bind parameter values
  435. matched = False
  436. for key, val in settings_map.items():
  437. if key in param_values:
  438. result.scalar_one_or_none.return_value = val
  439. matched = True
  440. break
  441. if not matched:
  442. if "printer" in stmt_str.lower() or "is_active" in stmt_str:
  443. printer = MagicMock()
  444. printer.id = printer_id
  445. printer.is_active = True
  446. scalars_mock = MagicMock()
  447. scalars_mock.__iter__ = MagicMock(return_value=iter([printer]))
  448. result.scalars.return_value = scalars_mock
  449. else:
  450. result.scalar_one_or_none.return_value = None
  451. return result
  452. return side_effect
  453. class TestAutoStopOnFeatureDisabled:
  454. """Regression: disabling auto-drying in settings should stop active drying sessions."""
  455. @pytest.fixture
  456. def scheduler(self):
  457. return PrintScheduler()
  458. @pytest.mark.asyncio
  459. @patch("backend.app.services.print_scheduler.printer_manager")
  460. async def test_stops_drying_when_disabled(self, mock_pm, scheduler):
  461. """Disabling auto-drying should send stop commands to all drying printers."""
  462. scheduler._drying_in_progress = {1: time.monotonic(), 2: time.monotonic()}
  463. scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
  464. scheduler._auto_dry_units[(2, 0)] = {"ended_at": None}
  465. # Printer 1: drying, Printer 2: drying
  466. def get_status(pid):
  467. state = MagicMock()
  468. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  469. return state
  470. mock_pm.get_status.side_effect = get_status
  471. db = AsyncMock()
  472. # queue_drying_enabled = false
  473. setting = MagicMock()
  474. setting.value = "false"
  475. result_mock = MagicMock()
  476. result_mock.scalar_one_or_none.return_value = setting
  477. db.execute = AsyncMock(return_value=result_mock)
  478. await scheduler._check_auto_drying(db, [], set())
  479. # Should have sent stop commands
  480. assert mock_pm.send_drying_command.call_count == 2
  481. assert not scheduler._drying_in_progress
  482. class TestAutoStopOnNoScheduledItems:
  483. """Regression: removing scheduled items should stop auto-drying."""
  484. @pytest.fixture
  485. def scheduler(self):
  486. return PrintScheduler()
  487. @staticmethod
  488. def _make_setting(value):
  489. s = MagicMock()
  490. s.value = value
  491. return s
  492. @staticmethod
  493. def _make_db_side_effect(settings_map):
  494. """Create a side_effect for db.execute that returns settings by key."""
  495. async def side_effect(stmt):
  496. result = MagicMock()
  497. try:
  498. compiled = stmt.compile(compile_kwargs={"literal_binds": False})
  499. param_values = list(compiled.params.values())
  500. except Exception:
  501. param_values = []
  502. for key, val in settings_map.items():
  503. if key in param_values:
  504. result.scalar_one_or_none.return_value = val
  505. return result
  506. result.scalar_one_or_none.return_value = None
  507. return result
  508. return side_effect
  509. @pytest.mark.asyncio
  510. @patch("backend.app.services.print_scheduler.printer_manager")
  511. async def test_stops_when_no_scheduled_items(self, mock_pm, scheduler):
  512. """Auto-drying stops when queue has no scheduled items (queue mode only)."""
  513. scheduler._drying_in_progress = {1: time.monotonic()}
  514. scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
  515. state = MagicMock()
  516. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  517. mock_pm.get_status.return_value = state
  518. db = AsyncMock()
  519. settings_returns = {
  520. "queue_drying_enabled": self._make_setting("true"),
  521. "ambient_drying_enabled": self._make_setting("false"),
  522. }
  523. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  524. # Manual-start items only (no scheduled_time)
  525. item = MagicMock()
  526. item.printer_id = 1
  527. item.scheduled_time = None
  528. item.manual_start = True
  529. await scheduler._check_auto_drying(db, [item], set())
  530. # Should have stopped drying
  531. assert mock_pm.send_drying_command.called
  532. assert not scheduler._drying_in_progress
  533. @pytest.mark.asyncio
  534. @patch("backend.app.services.print_scheduler.printer_manager")
  535. async def test_stops_when_empty_queue(self, mock_pm, scheduler):
  536. """Auto-drying stops when queue is completely empty (queue mode only)."""
  537. scheduler._drying_in_progress = {1: time.monotonic()}
  538. scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
  539. state = MagicMock()
  540. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  541. mock_pm.get_status.return_value = state
  542. db = AsyncMock()
  543. settings_returns = {
  544. "queue_drying_enabled": self._make_setting("true"),
  545. "ambient_drying_enabled": self._make_setting("false"),
  546. }
  547. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  548. await scheduler._check_auto_drying(db, [], set())
  549. assert mock_pm.send_drying_command.called
  550. assert not scheduler._drying_in_progress
  551. class TestDryingTrackingTimestamps:
  552. """Test that _drying_in_progress uses timestamps, not booleans."""
  553. def test_initial_state_empty(self):
  554. """Fresh scheduler has no drying tracked."""
  555. scheduler = PrintScheduler()
  556. assert scheduler._drying_in_progress == {}
  557. def test_timestamp_is_monotonic(self):
  558. """Tracked values should be monotonic timestamps."""
  559. scheduler = PrintScheduler()
  560. before = time.monotonic()
  561. scheduler._drying_in_progress[1] = time.monotonic()
  562. after = time.monotonic()
  563. assert before <= scheduler._drying_in_progress[1] <= after
  564. def test_timestamp_is_truthy(self):
  565. """Timestamps are truthy for .get() checks (backward compat with bool pattern)."""
  566. scheduler = PrintScheduler()
  567. scheduler._drying_in_progress[1] = time.monotonic()
  568. assert scheduler._drying_in_progress.get(1)
  569. assert not scheduler._drying_in_progress.get(999)
  570. class _DryingTestBase:
  571. """Shared helpers for auto-drying integration tests."""
  572. @staticmethod
  573. def _make_setting(value):
  574. s = MagicMock()
  575. s.value = value
  576. return s
  577. @staticmethod
  578. def _make_db_side_effect(settings_map, printer_ids=None):
  579. """Create a side_effect for db.execute that returns settings by key and printers."""
  580. if printer_ids is None:
  581. printer_ids = [1]
  582. async def side_effect(stmt):
  583. result = MagicMock()
  584. stmt_str = str(stmt)
  585. try:
  586. compiled = stmt.compile(compile_kwargs={"literal_binds": False})
  587. param_values = list(compiled.params.values())
  588. except Exception:
  589. param_values = []
  590. for key, val in settings_map.items():
  591. if key in param_values:
  592. result.scalar_one_or_none.return_value = val
  593. return result
  594. if "printer" in stmt_str.lower() or "is_active" in stmt_str:
  595. printers = []
  596. for pid in printer_ids:
  597. p = MagicMock()
  598. p.id = pid
  599. p.is_active = True
  600. printers.append(p)
  601. scalars_mock = MagicMock()
  602. scalars_mock.__iter__ = MagicMock(return_value=iter(printers))
  603. result.scalars.return_value = scalars_mock
  604. else:
  605. result.scalar_one_or_none.return_value = None
  606. return result
  607. return side_effect
  608. class TestAmbientDrying(_DryingTestBase):
  609. """Tests for ambient drying mode — drying based on humidity regardless of queue state."""
  610. @pytest.fixture
  611. def scheduler(self):
  612. return PrintScheduler()
  613. @pytest.mark.asyncio
  614. @patch("backend.app.services.print_scheduler.printer_manager")
  615. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  616. async def test_ambient_dries_idle_printer_without_queue(self, mock_sd, mock_pm, scheduler):
  617. """Ambient mode starts drying on idle printers even with no queue items."""
  618. state = MagicMock()
  619. state.raw_data = {
  620. "ams": [
  621. {
  622. "id": 0,
  623. "module_type": "n3f",
  624. "dry_time": 0,
  625. "humidity_raw": "75",
  626. "dry_sf_reason": [],
  627. "tray": [{"tray_type": "PLA"}],
  628. }
  629. ]
  630. }
  631. state.firmware_version = "01.09.00.00"
  632. mock_pm.get_status.return_value = state
  633. mock_pm.is_connected.return_value = True
  634. mock_pm.get_model.return_value = "X1C"
  635. mock_pm.send_drying_command.return_value = True
  636. scheduler._is_printer_idle = MagicMock(return_value=True)
  637. db = AsyncMock()
  638. settings_returns = {
  639. "queue_drying_enabled": self._make_setting("false"),
  640. "ambient_drying_enabled": self._make_setting("true"),
  641. "ams_humidity_fair": self._make_setting("60"),
  642. "queue_drying_block": self._make_setting("false"),
  643. "drying_presets": None,
  644. }
  645. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  646. # Empty queue — ambient mode should still dry
  647. await scheduler._check_auto_drying(db, [], set())
  648. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  649. assert 1 in scheduler._drying_in_progress
  650. @pytest.mark.asyncio
  651. @patch("backend.app.services.print_scheduler.printer_manager")
  652. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  653. async def test_ambient_does_not_dry_below_threshold(self, mock_sd, mock_pm, scheduler):
  654. """Ambient mode does NOT dry when humidity is below threshold."""
  655. state = MagicMock()
  656. state.raw_data = {
  657. "ams": [
  658. {
  659. "id": 0,
  660. "module_type": "n3f",
  661. "dry_time": 0,
  662. "humidity_raw": "40",
  663. "dry_sf_reason": [],
  664. "tray": [{"tray_type": "PLA"}],
  665. }
  666. ]
  667. }
  668. state.firmware_version = "01.09.00.00"
  669. mock_pm.get_status.return_value = state
  670. mock_pm.is_connected.return_value = True
  671. mock_pm.get_model.return_value = "X1C"
  672. scheduler._is_printer_idle = MagicMock(return_value=True)
  673. db = AsyncMock()
  674. settings_returns = {
  675. "queue_drying_enabled": self._make_setting("false"),
  676. "ambient_drying_enabled": self._make_setting("true"),
  677. "ams_humidity_fair": self._make_setting("60"),
  678. "queue_drying_block": self._make_setting("false"),
  679. "drying_presets": None,
  680. }
  681. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  682. await scheduler._check_auto_drying(db, [], set())
  683. mock_pm.send_drying_command.assert_not_called()
  684. @pytest.mark.asyncio
  685. @patch("backend.app.services.print_scheduler.printer_manager")
  686. async def test_ambient_off_stops_drying_without_queue(self, mock_pm, scheduler):
  687. """Disabling ambient drying stops drying on printers without queue items."""
  688. scheduler._drying_in_progress = {1: time.monotonic()}
  689. scheduler._auto_dry_units[(1, 0)] = {"ended_at": None}
  690. state = MagicMock()
  691. state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
  692. mock_pm.get_status.return_value = state
  693. db = AsyncMock()
  694. settings_returns = {
  695. "queue_drying_enabled": self._make_setting("false"),
  696. "ambient_drying_enabled": self._make_setting("false"),
  697. }
  698. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  699. await scheduler._check_auto_drying(db, [], set())
  700. assert mock_pm.send_drying_command.called
  701. assert not scheduler._drying_in_progress
  702. @pytest.mark.asyncio
  703. @patch("backend.app.services.print_scheduler.printer_manager")
  704. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  705. async def test_ambient_continues_when_queue_empty(self, mock_sd, mock_pm, scheduler):
  706. """Ambient drying continues even when queue has no scheduled items (unlike queue mode)."""
  707. scheduler._drying_in_progress = {1: time.monotonic() - 100}
  708. state = MagicMock()
  709. state.raw_data = {
  710. "ams": [
  711. {
  712. "id": 0,
  713. "module_type": "n3f",
  714. "dry_time": 600,
  715. "humidity_raw": "75",
  716. "dry_sf_reason": [],
  717. "tray": [{"tray_type": "PLA"}],
  718. }
  719. ]
  720. }
  721. state.firmware_version = "01.09.00.00"
  722. mock_pm.get_status.return_value = state
  723. mock_pm.is_connected.return_value = True
  724. mock_pm.get_model.return_value = "X1C"
  725. scheduler._is_printer_idle = MagicMock(return_value=True)
  726. db = AsyncMock()
  727. settings_returns = {
  728. "queue_drying_enabled": self._make_setting("false"),
  729. "ambient_drying_enabled": self._make_setting("true"),
  730. "ams_humidity_fair": self._make_setting("60"),
  731. "queue_drying_block": self._make_setting("false"),
  732. "drying_presets": None,
  733. }
  734. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  735. await scheduler._check_auto_drying(db, [], set())
  736. # Should NOT have sent stop — humidity still high, drying continues
  737. for call in mock_pm.send_drying_command.call_args_list:
  738. assert call.kwargs.get("mode") != 0, "Should not stop drying in ambient mode with high humidity"
  739. assert 1 in scheduler._drying_in_progress
  740. @pytest.mark.asyncio
  741. @patch("backend.app.services.print_scheduler.printer_manager")
  742. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  743. async def test_queue_only_does_not_dry_without_scheduled_items(self, mock_sd, mock_pm, scheduler):
  744. """Queue mode alone does NOT dry printers that have no scheduled queue items."""
  745. state = MagicMock()
  746. state.raw_data = {
  747. "ams": [
  748. {
  749. "id": 0,
  750. "module_type": "n3f",
  751. "dry_time": 0,
  752. "humidity_raw": "75",
  753. "dry_sf_reason": [],
  754. "tray": [{"tray_type": "PLA"}],
  755. }
  756. ]
  757. }
  758. state.firmware_version = "01.09.00.00"
  759. mock_pm.get_status.return_value = state
  760. mock_pm.is_connected.return_value = True
  761. mock_pm.get_model.return_value = "X1C"
  762. scheduler._is_printer_idle = MagicMock(return_value=True)
  763. db = AsyncMock()
  764. settings_returns = {
  765. "queue_drying_enabled": self._make_setting("true"),
  766. "ambient_drying_enabled": self._make_setting("false"),
  767. "ams_humidity_fair": self._make_setting("60"),
  768. "queue_drying_block": self._make_setting("false"),
  769. "drying_presets": None,
  770. }
  771. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  772. # No queue items at all
  773. await scheduler._check_auto_drying(db, [], set())
  774. mock_pm.send_drying_command.assert_not_called()
  775. class TestBlockForDryingBugFix(_DryingTestBase):
  776. """Regression: block mode gates NEW drying starts but must leave running dries alone (#1892)."""
  777. @pytest.fixture
  778. def scheduler(self):
  779. return PrintScheduler()
  780. @pytest.mark.asyncio
  781. @patch("backend.app.services.print_scheduler.printer_manager")
  782. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  783. async def test_block_mode_leaves_active_drying_running(self, mock_sd, mock_pm, scheduler):
  784. """#1892: a printer already drying in block mode must not be stopped by a humidity re-check."""
  785. # Drying started 35 minutes ago
  786. scheduler._drying_in_progress = {1: time.monotonic() - 2100}
  787. state = MagicMock()
  788. state.raw_data = {
  789. "ams": [
  790. {
  791. "id": 0,
  792. "module_type": "n3f",
  793. "dry_time": 600,
  794. "humidity_raw": "30", # Below threshold
  795. "dry_sf_reason": [],
  796. "tray": [{"tray_type": "PLA"}],
  797. }
  798. ]
  799. }
  800. state.firmware_version = "01.09.00.00"
  801. mock_pm.get_status.return_value = state
  802. mock_pm.is_connected.return_value = True
  803. mock_pm.get_model.return_value = "X1C"
  804. scheduler._is_printer_idle = MagicMock(return_value=True)
  805. db = AsyncMock()
  806. settings_returns = {
  807. "queue_drying_enabled": self._make_setting("true"),
  808. "ambient_drying_enabled": self._make_setting("false"),
  809. "ams_humidity_fair": self._make_setting("60"),
  810. "queue_drying_block": self._make_setting("true"),
  811. "drying_presets": None,
  812. }
  813. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  814. # Queue item exists for this printer (triggers block mode gate)
  815. item = MagicMock()
  816. item.printer_id = 1
  817. item.scheduled_time = MagicMock()
  818. item.manual_start = False
  819. await scheduler._check_auto_drying(db, [item], set())
  820. # Must NOT stop the running dry — block mode gates new starts, not active cycles
  821. for call in mock_pm.send_drying_command.call_args_list:
  822. assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
  823. "Block mode must not stop an already-running drying cycle (#1892)"
  824. )
  825. @pytest.mark.asyncio
  826. @patch("backend.app.services.print_scheduler.printer_manager")
  827. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  828. async def test_block_mode_prevents_new_drying_start(self, mock_sd, mock_pm, scheduler):
  829. """Block mode should still prevent starting NEW drying on printers with pending items."""
  830. state = MagicMock()
  831. state.raw_data = {
  832. "ams": [
  833. {
  834. "id": 0,
  835. "module_type": "n3f",
  836. "dry_time": 0,
  837. "humidity_raw": "75",
  838. "dry_sf_reason": [],
  839. "tray": [{"tray_type": "PLA"}],
  840. }
  841. ]
  842. }
  843. state.firmware_version = "01.09.00.00"
  844. mock_pm.get_status.return_value = state
  845. mock_pm.is_connected.return_value = True
  846. mock_pm.get_model.return_value = "X1C"
  847. scheduler._is_printer_idle = MagicMock(return_value=True)
  848. db = AsyncMock()
  849. settings_returns = {
  850. "queue_drying_enabled": self._make_setting("true"),
  851. "ambient_drying_enabled": self._make_setting("false"),
  852. "ams_humidity_fair": self._make_setting("60"),
  853. "queue_drying_block": self._make_setting("true"),
  854. "drying_presets": None,
  855. }
  856. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  857. item = MagicMock()
  858. item.printer_id = 1
  859. item.scheduled_time = MagicMock()
  860. item.manual_start = False
  861. await scheduler._check_auto_drying(db, [item], set())
  862. # Should NOT start drying — block mode with pending items
  863. mock_pm.send_drying_command.assert_not_called()
  864. class TestResolveHumidityThreshold:
  865. """Per-filament humidity threshold resolver (#1605).
  866. Resolves the trigger threshold for an AMS unit from the loaded tray types.
  867. Mixed loads use the lowest (most restrictive) value. Empty / unloaded trays
  868. contribute no constraint; falls back to the global ``ams_humidity_fair``
  869. when no per-type overrides are configured.
  870. """
  871. def test_no_overrides_falls_back_to_global(self):
  872. """Empty overrides map → caller's global fallback is used verbatim."""
  873. result = PrintScheduler.resolve_humidity_threshold([{"tray_type": "PLA"}], {}, 60)
  874. assert result == 60
  875. def test_single_known_type_uses_override(self):
  876. """Single PLA tray with override = 50 returns 50."""
  877. result = PrintScheduler.resolve_humidity_threshold(
  878. [{"tray_type": "PLA Basic"}],
  879. {"default": 60, "PLA": 50},
  880. 60,
  881. )
  882. assert result == 50
  883. def test_mixed_load_picks_lowest(self):
  884. """Mixed PLA (60) + Nylon (20) → most restrictive = 20."""
  885. result = PrintScheduler.resolve_humidity_threshold(
  886. [{"tray_type": "PLA Basic"}, {"tray_type": "PA Glass"}],
  887. {"default": 60, "PLA": 60, "PA": 20},
  888. 60,
  889. )
  890. assert result == 20
  891. def test_unknown_type_uses_default_key(self):
  892. """Tray type not in the map falls back to the 'default' key, not the
  893. caller fallback. Lets the user tune unknown-filament behavior."""
  894. result = PrintScheduler.resolve_humidity_threshold(
  895. [{"tray_type": "EXOTIC_WOOD"}],
  896. {"default": 40, "PLA": 60},
  897. 999,
  898. )
  899. assert result == 40
  900. def test_empty_tray_slots_skipped(self):
  901. """Empty tray_type strings (unloaded slots) contribute no constraint."""
  902. result = PrintScheduler.resolve_humidity_threshold(
  903. [{"tray_type": ""}, {"tray_type": "PLA"}],
  904. {"default": 30, "PLA": 50},
  905. 60,
  906. )
  907. assert result == 50
  908. def test_all_empty_trays_uses_default_key(self):
  909. """No loaded trays at all → falls back to default key (or fallback if
  910. no overrides). Matches the empty-AMS behavior of the existing alarm
  911. site so an empty AMS still alarms at the user's default rate."""
  912. result = PrintScheduler.resolve_humidity_threshold(
  913. [{"tray_type": ""}, {}],
  914. {"default": 30, "PLA": 50},
  915. 60,
  916. )
  917. assert result == 30
  918. def test_filament_name_normalized(self):
  919. """Tray types like 'PLA Basic', 'pla basic' all normalize to 'PLA'."""
  920. result = PrintScheduler.resolve_humidity_threshold(
  921. [{"tray_type": "pla basic"}],
  922. {"default": 60, "PLA": 25},
  923. 60,
  924. )
  925. assert result == 25
  926. def test_no_tray_type_field_skipped(self):
  927. """Missing tray_type field is treated as empty (unloaded)."""
  928. result = PrintScheduler.resolve_humidity_threshold(
  929. [{}, {"tray_type": "ASA"}],
  930. {"default": 60, "ASA": 30},
  931. 60,
  932. )
  933. assert result == 30
  934. class TestGetHumidityThresholds:
  935. """The DB-loading helper for ``ams_humidity_thresholds`` (#1605)."""
  936. @pytest.fixture
  937. def scheduler(self):
  938. return PrintScheduler()
  939. @pytest.mark.asyncio
  940. async def test_missing_setting_returns_empty(self, scheduler):
  941. db = AsyncMock()
  942. db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)))
  943. result = await scheduler._get_humidity_thresholds(db)
  944. assert result == {}
  945. @pytest.mark.asyncio
  946. async def test_empty_value_returns_empty(self, scheduler):
  947. db = AsyncMock()
  948. setting = MagicMock(value="")
  949. db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
  950. result = await scheduler._get_humidity_thresholds(db)
  951. assert result == {}
  952. @pytest.mark.asyncio
  953. async def test_invalid_json_returns_empty(self, scheduler):
  954. db = AsyncMock()
  955. setting = MagicMock(value="not json{")
  956. db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
  957. result = await scheduler._get_humidity_thresholds(db)
  958. assert result == {}
  959. @pytest.mark.asyncio
  960. async def test_valid_json_normalizes_keys(self, scheduler):
  961. """Filament-type keys uppercase; 'default' preserved."""
  962. db = AsyncMock()
  963. setting = MagicMock(value='{"default": 60, "pla": 50, "ASA": 30, "garbage": "x"}')
  964. db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
  965. result = await scheduler._get_humidity_thresholds(db)
  966. assert result == {"default": 60, "PLA": 50, "ASA": 30}
  967. class TestMidPrintDrying(_DryingTestBase):
  968. """Tests for the print_drying_enabled path — drying that runs CONCURRENTLY
  969. with an active print on capable hardware (H2D / H2C / H2S / P2S / X2D / X1C /
  970. A2L / H2D Pro on recent firmware). Distinct from idle drying.
  971. Verifies:
  972. - With the toggle ON and capable hardware, a printer in the busy set is
  973. still evaluated and drying fires at the capped temperature.
  974. - The temperature cap is max(40, preset_temp - 5) — protects spools.
  975. - With the toggle OFF, the existing busy-printer skip still applies.
  976. - With the toggle ON but unsupported firmware, the busy-printer skip
  977. still applies (gated by supports_drying_while_printing).
  978. """
  979. @pytest.fixture
  980. def scheduler(self):
  981. return PrintScheduler()
  982. @staticmethod
  983. def _ams_unit(humidity: str = "75"):
  984. return {
  985. "id": 0,
  986. "module_type": "n3f",
  987. "dry_time": 0,
  988. "humidity_raw": humidity,
  989. "dry_sf_reason": [],
  990. "tray": [{"tray_type": "PLA"}],
  991. }
  992. def _state(self, firmware: str):
  993. state = MagicMock()
  994. state.raw_data = {"ams": [self._ams_unit()]}
  995. state.firmware_version = firmware
  996. return state
  997. @pytest.mark.asyncio
  998. @patch("backend.app.services.print_scheduler.printer_manager")
  999. async def test_running_printer_dries_when_enabled_and_capable(self, mock_pm, scheduler):
  1000. """Toggle ON + capable hardware: running printer dries at capped temp."""
  1001. state = self._state("01.03.00.00")
  1002. state.state = "RUNNING"
  1003. mock_pm.get_status.return_value = state
  1004. mock_pm.is_connected.return_value = True
  1005. mock_pm.get_model.return_value = "H2D"
  1006. mock_pm.send_drying_command.return_value = True
  1007. scheduler._is_printer_idle = MagicMock(return_value=False)
  1008. db = AsyncMock()
  1009. settings_returns = {
  1010. "queue_drying_enabled": self._make_setting("true"),
  1011. "ambient_drying_enabled": self._make_setting("false"),
  1012. "print_drying_enabled": self._make_setting("true"),
  1013. "ams_humidity_fair": self._make_setting("60"),
  1014. "queue_drying_block": self._make_setting("false"),
  1015. "drying_presets": None,
  1016. }
  1017. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1018. # Actually printing (RUNNING), so the mid-print path applies
  1019. await scheduler._check_auto_drying(db, [], {1})
  1020. # PLA preset is 45 degC for n3f; mid-print cap is max(40, 45-5) = 40
  1021. mock_pm.send_drying_command.assert_called_once_with(1, 0, 40, 12, mode=1, filament="PLA")
  1022. assert 1 in scheduler._drying_in_progress
  1023. @pytest.mark.asyncio
  1024. @patch("backend.app.services.print_scheduler.printer_manager")
  1025. async def test_temp_cap_applied_above_floor(self, mock_pm, scheduler):
  1026. """Higher-temp filament (PETG n3f=65) caps to 60, not floor."""
  1027. state = MagicMock()
  1028. state.raw_data = {
  1029. "ams": [
  1030. {
  1031. "id": 0,
  1032. "module_type": "n3f",
  1033. "dry_time": 0,
  1034. "humidity_raw": "75",
  1035. "dry_sf_reason": [],
  1036. "tray": [{"tray_type": "PETG"}],
  1037. }
  1038. ]
  1039. }
  1040. state.firmware_version = "01.03.00.00"
  1041. state.state = "RUNNING"
  1042. mock_pm.get_status.return_value = state
  1043. mock_pm.is_connected.return_value = True
  1044. mock_pm.get_model.return_value = "H2D"
  1045. mock_pm.send_drying_command.return_value = True
  1046. scheduler._is_printer_idle = MagicMock(return_value=False)
  1047. db = AsyncMock()
  1048. settings_returns = {
  1049. "queue_drying_enabled": self._make_setting("true"),
  1050. "ambient_drying_enabled": self._make_setting("false"),
  1051. "print_drying_enabled": self._make_setting("true"),
  1052. "ams_humidity_fair": self._make_setting("60"),
  1053. "queue_drying_block": self._make_setting("false"),
  1054. "drying_presets": None,
  1055. }
  1056. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1057. await scheduler._check_auto_drying(db, [], {1})
  1058. # PETG preset 65 -> max(40, 65-5) = 60
  1059. mock_pm.send_drying_command.assert_called_once_with(1, 0, 60, 12, mode=1, filament="PETG")
  1060. @pytest.mark.asyncio
  1061. @patch("backend.app.services.print_scheduler.printer_manager")
  1062. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1063. async def test_running_printer_skipped_when_toggle_off(self, mock_sd, mock_pm, scheduler):
  1064. """Toggle OFF: running printer is skipped even on capable hardware."""
  1065. mock_pm.get_status.return_value = self._state("01.03.00.00")
  1066. mock_pm.is_connected.return_value = True
  1067. mock_pm.get_model.return_value = "H2D"
  1068. scheduler._is_printer_idle = MagicMock(return_value=False)
  1069. db = AsyncMock()
  1070. settings_returns = {
  1071. "queue_drying_enabled": self._make_setting("true"),
  1072. "ambient_drying_enabled": self._make_setting("false"),
  1073. "print_drying_enabled": self._make_setting("false"),
  1074. "ams_humidity_fair": self._make_setting("60"),
  1075. "queue_drying_block": self._make_setting("false"),
  1076. "drying_presets": None,
  1077. }
  1078. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1079. await scheduler._check_auto_drying(db, [], {1})
  1080. mock_pm.send_drying_command.assert_not_called()
  1081. @pytest.mark.asyncio
  1082. @patch("backend.app.services.print_scheduler.printer_manager")
  1083. async def test_running_printer_skipped_when_firmware_too_old(self, mock_pm, scheduler):
  1084. """Toggle ON but firmware below matrix threshold: skip."""
  1085. # H2D matrix minimum is 01.03.00.00; this is below
  1086. mock_pm.get_status.return_value = self._state("01.02.30.00")
  1087. mock_pm.is_connected.return_value = True
  1088. mock_pm.get_model.return_value = "H2D"
  1089. scheduler._is_printer_idle = MagicMock(return_value=False)
  1090. db = AsyncMock()
  1091. settings_returns = {
  1092. "queue_drying_enabled": self._make_setting("true"),
  1093. "ambient_drying_enabled": self._make_setting("false"),
  1094. "print_drying_enabled": self._make_setting("true"),
  1095. "ams_humidity_fair": self._make_setting("60"),
  1096. "queue_drying_block": self._make_setting("false"),
  1097. "drying_presets": None,
  1098. }
  1099. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1100. await scheduler._check_auto_drying(db, [], {1})
  1101. mock_pm.send_drying_command.assert_not_called()
  1102. @pytest.mark.asyncio
  1103. @patch("backend.app.services.print_scheduler.printer_manager")
  1104. async def test_running_printer_skipped_when_model_excluded(self, mock_pm, scheduler):
  1105. """Toggle ON, recent firmware, but excluded model (A1): skip."""
  1106. mock_pm.get_status.return_value = self._state("99.99.99.99")
  1107. mock_pm.is_connected.return_value = True
  1108. mock_pm.get_model.return_value = "A1"
  1109. scheduler._is_printer_idle = MagicMock(return_value=False)
  1110. db = AsyncMock()
  1111. settings_returns = {
  1112. "queue_drying_enabled": self._make_setting("true"),
  1113. "ambient_drying_enabled": self._make_setting("false"),
  1114. "print_drying_enabled": self._make_setting("true"),
  1115. "ams_humidity_fair": self._make_setting("60"),
  1116. "queue_drying_block": self._make_setting("false"),
  1117. "drying_presets": None,
  1118. }
  1119. db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
  1120. await scheduler._check_auto_drying(db, [], {1})
  1121. mock_pm.send_drying_command.assert_not_called()
  1122. class TestAutoDryRearmGuards(_DryingTestBase):
  1123. """The re-arm loop from #2770.
  1124. An AMS reads higher humidity while it is warm than once it has cooled, so a
  1125. threshold set inside that band is never satisfied at the moment a cycle
  1126. ends. The firmware is separately free to end a cycle early when it decides
  1127. the filament is dry. Together those produced five 12-hour cycles armed in
  1128. four hours on the reporter's H2D, one of them six seconds after the previous
  1129. ended. These tests pin the two guards that break the loop and, just as
  1130. importantly, that neither guard ever stops a cycle that is running.
  1131. """
  1132. THRESHOLD = "14"
  1133. ABOVE = 16
  1134. BELOW = 10
  1135. @pytest.fixture
  1136. def scheduler(self):
  1137. return PrintScheduler()
  1138. @staticmethod
  1139. def _ams_state(dry_time, humidity):
  1140. state = MagicMock()
  1141. state.raw_data = {
  1142. "ams": [
  1143. {
  1144. "id": 0,
  1145. "module_type": "n3f",
  1146. "dry_time": dry_time,
  1147. "humidity_raw": str(humidity),
  1148. "dry_sf_reason": [],
  1149. "tray": [{"tray_type": "PLA"}],
  1150. }
  1151. ]
  1152. }
  1153. state.firmware_version = "01.09.00.00"
  1154. return state
  1155. def _db(self):
  1156. db = AsyncMock()
  1157. db.execute = AsyncMock(
  1158. side_effect=self._make_db_side_effect(
  1159. {
  1160. "queue_drying_enabled": self._make_setting("false"),
  1161. "ambient_drying_enabled": self._make_setting("true"),
  1162. "ams_humidity_fair": self._make_setting(self.THRESHOLD),
  1163. "queue_drying_block": self._make_setting("false"),
  1164. "drying_presets": None,
  1165. }
  1166. )
  1167. )
  1168. return db
  1169. async def _pass(self, scheduler, mock_pm, db, dry_time, humidity):
  1170. """One 30-second scheduler pass with the AMS in the given state."""
  1171. mock_pm.get_status.return_value = self._ams_state(dry_time, humidity)
  1172. await scheduler._check_auto_drying(db, [], set())
  1173. async def _unproductive_cycle(self, scheduler, mock_pm, db):
  1174. """Arm a cycle, watch it run, then see it end with humidity still high."""
  1175. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1176. await self._pass(scheduler, mock_pm, db, 720, self.ABOVE)
  1177. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1178. @pytest.mark.asyncio
  1179. @patch("backend.app.services.print_scheduler.printer_manager")
  1180. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1181. async def test_does_not_rearm_immediately_after_a_cycle_ends(self, mock_sd, mock_pm, scheduler):
  1182. """The six-second re-arm: one cycle ends, the next pass must not start another."""
  1183. mock_pm.is_connected.return_value = True
  1184. mock_pm.get_model.return_value = "H2D"
  1185. mock_pm.send_drying_command.return_value = True
  1186. scheduler._is_printer_idle = MagicMock(return_value=True)
  1187. db = self._db()
  1188. await self._unproductive_cycle(scheduler, mock_pm, db)
  1189. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1190. assert mock_pm.send_drying_command.call_count == 1
  1191. @pytest.mark.asyncio
  1192. @patch("backend.app.services.print_scheduler.notification_service")
  1193. @patch("backend.app.services.print_scheduler.printer_manager")
  1194. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1195. async def test_suspends_after_repeated_unproductive_cycles(self, mock_sd, mock_pm, mock_notify, scheduler):
  1196. """Past the cooldown the loop would resume, so the counter has to stop it."""
  1197. mock_pm.is_connected.return_value = True
  1198. mock_pm.get_model.return_value = "H2D"
  1199. mock_pm.send_drying_command.return_value = True
  1200. mock_notify.on_ams_drying_suspended = AsyncMock()
  1201. scheduler._is_printer_idle = MagicMock(return_value=True)
  1202. db = self._db()
  1203. for _ in range(AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES):
  1204. await self._unproductive_cycle(scheduler, mock_pm, db)
  1205. # Age the last cycle out of its cooldown so the next arm is allowed.
  1206. scheduler._auto_dry_units[(1, 0)]["ended_at"] -= AUTO_DRY_REARM_COOLDOWN_SECONDS + 1
  1207. assert mock_pm.send_drying_command.call_count == AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES
  1208. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1209. assert mock_pm.send_drying_command.call_count == AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES
  1210. assert scheduler._auto_dry_units[(1, 0)]["suspended"] is True
  1211. mock_notify.on_ams_drying_suspended.assert_awaited_once()
  1212. @pytest.mark.asyncio
  1213. @patch("backend.app.services.print_scheduler.notification_service")
  1214. @patch("backend.app.services.print_scheduler.printer_manager")
  1215. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1216. async def test_suspension_notifies_once_and_stays_put(self, mock_sd, mock_pm, mock_notify, scheduler):
  1217. """A suspension is a state, not a repeating alarm."""
  1218. mock_pm.is_connected.return_value = True
  1219. mock_pm.get_model.return_value = "H2D"
  1220. mock_pm.send_drying_command.return_value = True
  1221. mock_notify.on_ams_drying_suspended = AsyncMock()
  1222. scheduler._is_printer_idle = MagicMock(return_value=True)
  1223. db = self._db()
  1224. scheduler._auto_dry_units[(1, 0)] = {
  1225. "unproductive": AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
  1226. "suspended": False,
  1227. "ended_at": None,
  1228. }
  1229. for _ in range(4):
  1230. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1231. mock_pm.send_drying_command.assert_not_called()
  1232. assert mock_notify.on_ams_drying_suspended.await_count == 1
  1233. @pytest.mark.asyncio
  1234. @patch("backend.app.services.print_scheduler.notification_service")
  1235. @patch("backend.app.services.print_scheduler.printer_manager")
  1236. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1237. async def test_humidity_dropping_lifts_the_suspension(self, mock_sd, mock_pm, mock_notify, scheduler):
  1238. """The reading coming down is the evidence that drying works after all."""
  1239. mock_pm.is_connected.return_value = True
  1240. mock_pm.get_model.return_value = "H2D"
  1241. mock_pm.send_drying_command.return_value = True
  1242. mock_notify.on_ams_drying_suspended = AsyncMock()
  1243. scheduler._is_printer_idle = MagicMock(return_value=True)
  1244. db = self._db()
  1245. scheduler._auto_dry_units[(1, 0)] = {
  1246. "unproductive": AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
  1247. "suspended": True,
  1248. "ended_at": None,
  1249. }
  1250. await self._pass(scheduler, mock_pm, db, 0, self.BELOW)
  1251. # The judgement is cleared, but the re-arm clock is kept (#2801).
  1252. assert not scheduler._auto_dry_units[(1, 0)].get("suspended")
  1253. assert not scheduler._auto_dry_units[(1, 0)].get("unproductive")
  1254. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1255. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  1256. @pytest.mark.asyncio
  1257. @patch("backend.app.services.print_scheduler.printer_manager")
  1258. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1259. async def test_manual_cycle_is_neither_counted_nor_interrupted(self, mock_sd, mock_pm, scheduler):
  1260. """A dry the user started by hand carries no history and is never stopped."""
  1261. mock_pm.is_connected.return_value = True
  1262. mock_pm.get_model.return_value = "H2D"
  1263. mock_pm.send_drying_command.return_value = True
  1264. scheduler._is_printer_idle = MagicMock(return_value=True)
  1265. db = self._db()
  1266. await self._pass(scheduler, mock_pm, db, 720, self.ABOVE)
  1267. await self._pass(scheduler, mock_pm, db, 600, self.ABOVE)
  1268. mock_pm.send_drying_command.assert_not_called()
  1269. assert (1, 0) not in scheduler._auto_dry_units
  1270. # It ends; Bambuddy is free to arm its own cycle, with a clean slate.
  1271. await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
  1272. mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
  1273. assert scheduler._auto_dry_units[(1, 0)]["unproductive"] == 0
  1274. @pytest.mark.asyncio
  1275. @patch("backend.app.services.print_scheduler.printer_manager")
  1276. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1277. async def test_history_is_dropped_when_the_printer_goes_away(self, mock_sd, mock_pm, scheduler):
  1278. """A printer deleted and re-added must not inherit a suspension."""
  1279. mock_pm.get_status.return_value = None
  1280. scheduler._auto_dry_units[(1, 0)] = {"unproductive": 5, "suspended": True, "ended_at": None}
  1281. scheduler._sync_drying_state()
  1282. assert scheduler._auto_dry_units == {}
  1283. class TestAutoDryStoppedByBambuddy(_DryingTestBase):
  1284. """A cycle Bambuddy itself cut short must not count against the unit."""
  1285. THRESHOLD = "14"
  1286. ABOVE = 16
  1287. @pytest.fixture
  1288. def scheduler(self):
  1289. return PrintScheduler()
  1290. @staticmethod
  1291. def _ams_state(dry_time, humidity):
  1292. state = MagicMock()
  1293. state.raw_data = {
  1294. "ams": [
  1295. {
  1296. "id": 0,
  1297. "module_type": "n3f",
  1298. "dry_time": dry_time,
  1299. "humidity_raw": str(humidity),
  1300. "dry_sf_reason": [],
  1301. "tray": [{"tray_type": "PLA"}],
  1302. }
  1303. ]
  1304. }
  1305. state.firmware_version = "01.09.00.00"
  1306. return state
  1307. def _db(self):
  1308. db = AsyncMock()
  1309. db.execute = AsyncMock(
  1310. side_effect=self._make_db_side_effect(
  1311. {
  1312. "queue_drying_enabled": self._make_setting("false"),
  1313. "ambient_drying_enabled": self._make_setting("true"),
  1314. "ams_humidity_fair": self._make_setting(self.THRESHOLD),
  1315. "queue_drying_block": self._make_setting("false"),
  1316. "drying_presets": None,
  1317. }
  1318. )
  1319. )
  1320. return db
  1321. @pytest.mark.asyncio
  1322. @patch("backend.app.services.print_scheduler.printer_manager")
  1323. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1324. async def test_print_takes_priority_stop_is_not_an_unproductive_cycle(self, mock_sd, mock_pm, scheduler):
  1325. """The queue stopping a dry so a print can start is Bambuddy's own doing.
  1326. Counting it would suspend auto-drying on any printer that dries between
  1327. jobs often enough -- exactly the install queue-drying exists for.
  1328. """
  1329. mock_pm.is_connected.return_value = True
  1330. mock_pm.get_model.return_value = "H2D"
  1331. mock_pm.send_drying_command.return_value = True
  1332. scheduler._is_printer_idle = MagicMock(return_value=True)
  1333. db = self._db()
  1334. mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
  1335. await scheduler._check_auto_drying(db, [], set())
  1336. mock_pm.get_status.return_value = self._ams_state(720, self.ABOVE)
  1337. await scheduler._check_auto_drying(db, [], set())
  1338. # A print is ready: check_queue stops drying on this printer.
  1339. await scheduler._stop_drying(1)
  1340. mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
  1341. await scheduler._check_auto_drying(db, [], set())
  1342. assert scheduler._auto_dry_units[(1, 0)]["unproductive"] == 0
  1343. @pytest.mark.asyncio
  1344. @patch("backend.app.services.print_scheduler.printer_manager")
  1345. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1346. async def test_manual_stop_button_is_not_an_unproductive_cycle(self, mock_sd, mock_pm, scheduler):
  1347. """The Stop button goes straight to printer_manager, bypassing the
  1348. scheduler, so the route has to tell the scheduler to forget the cycle."""
  1349. mock_pm.is_connected.return_value = True
  1350. mock_pm.get_model.return_value = "H2D"
  1351. mock_pm.send_drying_command.return_value = True
  1352. scheduler._is_printer_idle = MagicMock(return_value=True)
  1353. db = self._db()
  1354. mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
  1355. await scheduler._check_auto_drying(db, [], set())
  1356. mock_pm.get_status.return_value = self._ams_state(720, self.ABOVE)
  1357. await scheduler._check_auto_drying(db, [], set())
  1358. scheduler.forget_auto_dry_cycle(1, 0)
  1359. mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
  1360. await scheduler._check_auto_drying(db, [], set())
  1361. assert scheduler._auto_dry_units[(1, 0)]["unproductive"] == 0
  1362. class TestAutoDryProgressKeepsItGoing(_DryingTestBase):
  1363. """Suspension must not punish a spool that is genuinely drying, just slowly."""
  1364. THRESHOLD = "25"
  1365. @pytest.fixture
  1366. def scheduler(self):
  1367. return PrintScheduler()
  1368. @staticmethod
  1369. def _ams_state(dry_time, humidity):
  1370. state = MagicMock()
  1371. state.raw_data = {
  1372. "ams": [
  1373. {
  1374. "id": 0,
  1375. "module_type": "n3f",
  1376. "dry_time": dry_time,
  1377. "humidity_raw": str(humidity),
  1378. "dry_sf_reason": [],
  1379. "tray": [{"tray_type": "PLA"}],
  1380. }
  1381. ]
  1382. }
  1383. state.firmware_version = "01.09.00.00"
  1384. return state
  1385. def _db(self):
  1386. db = AsyncMock()
  1387. db.execute = AsyncMock(
  1388. side_effect=self._make_db_side_effect(
  1389. {
  1390. "queue_drying_enabled": self._make_setting("false"),
  1391. "ambient_drying_enabled": self._make_setting("true"),
  1392. "ams_humidity_fair": self._make_setting(self.THRESHOLD),
  1393. "queue_drying_block": self._make_setting("false"),
  1394. "drying_presets": None,
  1395. }
  1396. )
  1397. )
  1398. return db
  1399. async def _cycle(self, scheduler, mock_pm, db, end_humidity):
  1400. """Arm a cycle, run it, and end it at the given reading."""
  1401. mock_pm.get_status.return_value = self._ams_state(0, end_humidity)
  1402. await scheduler._check_auto_drying(db, [], set())
  1403. mock_pm.get_status.return_value = self._ams_state(720, end_humidity)
  1404. await scheduler._check_auto_drying(db, [], set())
  1405. mock_pm.get_status.return_value = self._ams_state(0, end_humidity)
  1406. await scheduler._check_auto_drying(db, [], set())
  1407. entry = scheduler._auto_dry_units.get((1, 0))
  1408. if entry is not None and isinstance(entry.get("ended_at"), float):
  1409. entry["ended_at"] -= AUTO_DRY_REARM_COOLDOWN_SECONDS + 1
  1410. @pytest.mark.asyncio
  1411. @patch("backend.app.services.print_scheduler.notification_service")
  1412. @patch("backend.app.services.print_scheduler.printer_manager")
  1413. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1414. async def test_a_reading_that_keeps_falling_is_never_suspended(self, mock_sd, mock_pm, mock_notify, scheduler):
  1415. """40 -> 37 -> 35 -> 33 with a 25% threshold: nowhere near it yet, but
  1416. every cycle is working. A humid workshop must not lose auto-drying."""
  1417. mock_pm.is_connected.return_value = True
  1418. mock_pm.get_model.return_value = "H2D"
  1419. mock_pm.send_drying_command.return_value = True
  1420. mock_notify.on_ams_drying_suspended = AsyncMock()
  1421. scheduler._is_printer_idle = MagicMock(return_value=True)
  1422. db = self._db()
  1423. for reading in (40, 37, 35, 33, 31, 29):
  1424. await self._cycle(scheduler, mock_pm, db, reading)
  1425. assert scheduler._auto_dry_units[(1, 0)]["suspended"] is False
  1426. mock_notify.on_ams_drying_suspended.assert_not_awaited()
  1427. assert mock_pm.send_drying_command.call_count == 6
  1428. @pytest.mark.asyncio
  1429. @patch("backend.app.services.print_scheduler.notification_service")
  1430. @patch("backend.app.services.print_scheduler.printer_manager")
  1431. @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
  1432. async def test_a_reading_oscillating_by_one_point_still_suspends(self, mock_sd, mock_pm, mock_notify, scheduler):
  1433. """A plateau with sensor noise — 31, 30, 31, 30 — is not progress.
  1434. Comparing against the previous cycle rather than the best-so-far would
  1435. read every other cycle as an improvement and loop indefinitely."""
  1436. mock_pm.is_connected.return_value = True
  1437. mock_pm.get_model.return_value = "H2D"
  1438. mock_pm.send_drying_command.return_value = True
  1439. mock_notify.on_ams_drying_suspended = AsyncMock()
  1440. scheduler._is_printer_idle = MagicMock(return_value=True)
  1441. db = self._db()
  1442. for reading in (31, 30, 31, 30, 31, 30):
  1443. await self._cycle(scheduler, mock_pm, db, reading)
  1444. assert scheduler._auto_dry_units[(1, 0)]["suspended"] is True
  1445. mock_notify.on_ams_drying_suspended.assert_awaited_once()